This commit is contained in:
董宏凯 committed 2026-05-27 22:23:44 +08:00
commit 047e700d09
168 files changed
+76575

No files matched your search

+383
View File
@@ -0,0 +1,383 @@
#root {
margin: 0 auto;
}
:root {
/* 深色主题(默认) */
--bg: #0f1117;
--surface: #161b22;
--card: #1c2128;
--border: #30363d;
--text: #e6edf3;
--text-muted: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--yellow: #d29922;
--red: #f85149;
--purple: #8957e5;
--phase-starting: var(--accent);
--phase-diverging: var(--yellow);
--phase-closing: var(--green);
--phase-done: var(--purple);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
/* font-size: 14px; */
line-height: 1.6;
color: var(--text);
background: var(--bg);
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
/* 多智能体检索 */
/* 浅色主题 */
[data-theme="light"] {
--bg: #f5f7fa;
--surface: #ffffff;
--card: #f0f2f5;
--border: #e2e8f0;
--text: #1a202c;
--text-muted: #718096;
--accent: #3182ce;
--green: #38a169;
--yellow: #d69e2e;
--red: #e53e3e;
--purple: #805ad5;
}
body {
background: var(--bg);
color: var(--text);
min-height: 100vh;
}
#root {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* App layout */
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Idle: centered form */
.app-idle {
position: relative;
display: flex;
align-items: flex-start;
justify-content: center;
min-height: 100vh;
padding: 24px;
background: var(--bg);
}
.app-idle-header {
position: absolute;
top: 16px;
right: 16px;
}
.app-idle-scroll {
align-items: flex-start;
overflow-y: auto;
}
/* Running / done: two-column */
.app-running {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.app-header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 10px 16px;
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.app-header-logo {
font-weight: 700;
/* font-size: 15px; */
color: var(--accent);
letter-spacing: 0.5px;
}
.app-header-task {
flex: 1;
color: var(--text-muted);
/* font-size: 13px; */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-header-actions {
display: flex;
gap: 8px;
align-items: center;
}
/* 主题切换按钮 */
.theme-toggle {
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
/* font-size: 12px; */
display: flex;
align-items: center;
gap: 4px;
transition: all 0.2s;
}
.theme-toggle:hover {
color: var(--text);
border-color: var(--text-muted);
background: var(--card);
}
.app-body {
display: flex;
flex: 1;
overflow: hidden;
}
.app-sidebar {
width: 240px;
flex-shrink: 0;
overflow-y: auto;
border-right: 1px solid var(--border);
background: var(--surface);
}
.app-main {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.app-progress-sidebar {
width: 300px;
flex-shrink: 0;
overflow-y: auto;
border-left: 1px solid var(--border);
background: var(--surface);
}
/* Buttons */
button {
cursor: pointer;
border: none;
border-radius: 6px;
padding: 7px 16px;
/* font-size: 13px; */
font-family: inherit;
transition: opacity 0.15s, background 0.15s;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent);
color: #000;
font-weight: 600;
}
.btn-primary:hover:not(:disabled) {
opacity: 0.85;
}
.btn-danger {
background: transparent;
color: var(--red);
border: 1px solid var(--red);
}
.btn-danger:hover:not(:disabled) {
background: rgba(248, 81, 73, 0.1);
}
.btn-secondary {
background: transparent;
color: var(--text-muted);
border: 1px solid var(--border);
}
.btn-secondary:hover:not(:disabled) {
color: var(--text);
border-color: var(--text-muted);
}
.btn-ghost {
background: transparent;
color: var(--text-muted);
padding: 4px 8px;
/* font-size: 12px; */
}
.btn-ghost:hover {
color: var(--text);
}
/* Badges */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
/* font-size: 11px; */
font-weight: 600;
letter-spacing: 0.3px;
}
.badge-starting { background: rgba(88, 166, 255, 0.15); color: var(--accent); }
.badge-diverging { background: rgba(210, 153, 34, 0.15); color: var(--yellow); }
.badge-closing { background: rgba(63, 185, 80, 0.15); color: var(--green); }
.badge-done { background: rgba(137, 87, 229, 0.15); color: var(--purple); }
.badge-default { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); }
/* Spinner */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spinner {
display: inline-block;
animation: spin 1s linear infinite;
}
/* Reference cite */
.ref-cite {
display: inline-block;
background: rgba(88, 166, 255, 0.2);
color: var(--accent);
border-radius: 4px;
padding: 0 3px;
font-family: 'SFMono-Regular', Consolas, monospace;
/* font-size: 11px; */
cursor: pointer;
position: relative;
}
.ref-cite:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 4px);
left: 50%;
transform: translateX(-50%);
background: var(--card);
border: 1px solid var(--border);
color: var(--text);
padding: 4px 8px;
border-radius: 4px;
/* font-size: 11px; */
white-space: nowrap;
z-index: 100;
font-family: inherit;
pointer-events: none;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
}
/* Error banner */
.error-banner {
background: rgba(248, 81, 73, 0.1);
border: 1px solid var(--red);
border-radius: 6px;
padding: 8px 12px;
color: var(--red);
/* font-size: 13px; */
margin-bottom: 12px;
}
@media (max-width: 1200px) {
.app-progress-sidebar {
width: 260px;
}
}
@media (max-width: 960px) {
.app-body {
flex-direction: column;
}
.app-sidebar,
.app-progress-sidebar {
width: 100%;
border-right: none;
border-left: none;
border-bottom: 1px solid var(--border);
max-height: 260px;
}
.app-main {
padding: 14px;
}
}
@keyframes qa-typing-dot {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.35;
}
40% {
transform: translateY(-5px);
opacity: 1;
}
}
+798
View File
@@ -0,0 +1,798 @@
import {
Routes,
Route,
useLocation,
useParams,
Navigate,
useNavigate,
useSearchParams,
} from "react-router-dom";
import "./App.css";
import { ThemeProvider } from "./contexts/ThemeContext";
import { ChatProvider } from "./contexts/ChatContext";
import Sidebar, { PageType } from "./components/Sidebar";
import Header from "./components/Header";
import SceneList from "./pages/SceneList";
import SceneDetail from "./pages/SceneDetail";
import AgentList from "./pages/AgentList";
import AgentDetail from "./pages/AgentDetail";
import AgentTrial from "./pages/AgentTrial";
import TaskList from "./components/taskManagement/TaskList";
import TaskCreate from "./components/taskManagement/TaskCreate";
import TaskSingle from "./components/taskManagement/TaskSingle";
import TaskWorkspace from "./components/taskManagement/TaskWorkspace";
import TaskConsultCreate from "./components/taskManagement/TaskConsultCreate";
import TaskConsult from "./components/taskManagement/TaskConsult";
import TaskConsultResult from "./components/taskManagement/TaskConsultResult";
import StrategyResult from "./pages/StrategyResult";
import FinalPlan from "./pages/FinalPlan";
import SystemSettings from "./pages/SystemSettings";
import TaskDashboard from "./components/taskManagement/TaskDashboard";
import TaskListPage from "./components/taskManagement/TaskListPage";
import QA from "./pages/QA";
import QARecords from "./pages/QARecords";
import ResultManagement from "./pages/ResultManagement";
import ResultDetail from "./pages/ResultDetail";
import ZZResourceManagement from "./pages/ZZResourceManagement";
import NetworkActorResourceManagement from "./pages/NetworkActorResourceManagement";
import KnowledgeBaseResourceManagement from "./pages/KnowledgeBaseResourceManagement";
import EvaluationIndexResourceManagement from "./pages/EvaluationIndexResourceManagement";
import LightAppSquare from "./pages/LightAppSquare";
import LightAppRun from "./pages/LightAppRun";
import LightAppRecords from "./pages/LightAppRecords";
import { LightAppType } from "./pages/LightAppSquare";
import { MessagePlugin, Button, Space } from "tdesign-react";
import { useState, useEffect } from "react";
import TaskManagement from "./pages/TaskManagement";
import TaskAnalyzes from "./components/taskManagement/TaskAnalyzes";
import SentimentAnalyzes from "./components/taskManagement/SentimentAnalyzes";
import { ActionAnalyzes } from "./components/taskManagement/ActionAnalyzes";
import MopeChat from "./components/qa/MopeChat";
import QACenterPage from "./components/qa/QACenterPage";
import GeneralQA from "./components/qa/GeneralQa";
import AgentQA from "./components/qa/AgentQA";
import CollaborativeQA from "./components/qa/CollaborativeQA";
import ResultManagementLayout from "./components/result/ResultManagementLayout";
import ResultPlanning from "./components/result/ResultPlanning";
import ResultMnty from "./components/result/ResultMnty";
import ResultEffectPg from "./components/result/ResultEffectPg";
import ResultXk from "./components/result/ResultXk";
import ResultDq from "./components/result/ResultDq";
import ResultOther from "./components/result/ResultOther";
import ExpertsAgentsPage from "./pages/AgentListExperts";
import AgentExpertCenterPage from "./components/agent/AgentExpertCenterPage";
import AgentExpertEditPage from "./components/agent/AgentExpertEditPage";
import AgentChatPage from "./components/agent/AgentChatPage";
import SkillsWorkshop from "./components/agent/SkillsWorkshop";
import SkillCreatorPage from "./components/agent/SkillCreatorPage";
import TemplateEditor from "./components/qa/TemplateEditor";
import Template6BEditor from "./components/qa/Template6BEditor";
import SixStepChatPage from "./components/angentchat/SixStepChatPage";
import TopicResearch from "./components/qa/TopicResearch";
// import Login from './pages/Login';
import TaskChat from "./components/qa/TaskChat";
import ActionChat from "./components/qa/ActionChat";
import GAFromQA from "./components/qa/GAFrom";
import XingdongTyPage from "./components/taskManagement/XingdongTy";
import FanganTyPage from "./components/taskManagement/FanganTy";
import XXTyPage from "./components/taskManagement/XxTy";
import ObservabilityPage from './pages/ObservabilityPage';
import AgentCardList from './components/angentchat/AgentCardList';
import AgentEditPage from './components/angentchat/AgentEditPage';
import FileSkillsWorkshop from './components/agent/FileSkillsWorkshop';
import TaskPgFrom from './components/taskManagement/TaskPgFrom';
import Login from './pages/Login';
import TaskXkFrom from "./components/taskManagement/TaskXkFrom";
import GAFromNewQA from "./components/qa/GAFromNew";
function AppContent() {
const location = useLocation();
const params = useParams();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// 从查询参数中获取appId
const appIdFromQuery = searchParams.get("appId");
// 页面初始化时打印params
useEffect(() => {
console.log("页面初始化,当前params:", params);
console.log("查询参数appId:", appIdFromQuery);
}, []);
// 从路径中提取当前页面ID,忽略层级结构
const getCurrentPageFromPath = (path: string): PageType => {
console.log(path, "path");
const parts = path.split("/").filter(Boolean);
if (parts.length === 0) return "dashboard";
// 提取最后一个部分作为页面ID
let pageId = parts[parts.length - 1];
// 处理特殊情况
if (pageId === "index") {
pageId = parts[parts.length - 2] || "dashboard";
}
return pageId as PageType;
};
const currentPage = getCurrentPageFromPath(location.pathname);
const getTaskMenuConfig = (page: PageType) => {
const configs: Record<
string,
{ title: string; category: string; subCategory: string }
> = {
"xk-task-jc": {
title: "任务JC",
category: "XK任务",
subCategory: "XK任务",
},
"xk-task-dispatch": {
title: "任务调度",
category: "XK任务",
subCategory: "XK任务",
},
"plan-task-fx": {
title: "任务FX",
category: "规划任务",
subCategory: "规划任务",
},
"plan-task-action": {
title: "行动CE",
category: "规划任务",
subCategory: "规划任务",
},
"mnty-task-mechanism": {
title: "XDTY任务",
category: "MNTY任务",
subCategory: "MNTY任务",
},
"mnty-task-plan": {
title: "方案TY任务",
category: "MNTY任务",
subCategory: "MNTY任务",
},
"mnty-task-zsty": {
title: "XXTY任务",
category: "MNTY任务",
subCategory: "MNTY任务",
},
"effect-task-pg": {
title: "任务PG",
category: "效果PG任务",
subCategory: "效果PG任务",
},
"effect-action-pg": {
title: "行动PG",
category: "效果PG任务",
subCategory: "效果PG任务",
},
"effect-info-pg": {
title: "信息PG",
category: "效果PG任务",
subCategory: "效果PG任务",
},
"effect-account-pg": {
title: "账号PG",
category: "效果PG任务",
subCategory: "效果PG任务",
},
};
return (
configs[page] || { title: "任务管理", category: "", subCategory: "" }
);
};
const isTaskSubMenuPage = (page: PageType): boolean => {
return [
"xk-task-jc",
"xk-task-dispatch",
"plan-task-fx",
"plan-task-action",
"mnty-task-mechanism",
"mnty-task-plan",
"mnty-task-zsty",
"effect-task-pg",
"effect-action-pg",
"effect-info-pg",
"effect-account-pg",
].includes(page);
};
const handleNavigate = (page: PageType, appId?: string) => {
console.log("导航到:", page, appId);
// 根据页面ID构建正确的导航路径
let path = `/${page}`;
// 任务管理下的子页面
if (
page.startsWith("xk-task") ||
page.startsWith("plan-task") ||
page.startsWith("mnty-task") ||
page.startsWith("effect-task")
) {
const taskType = page.split("-")[0] + "-" + page.split("-")[1];
path = `/task-management/${taskType}/${page}`;
}
// 任务管理下的其他页面
else if (
[
"dashboard",
"task-create",
"task-single",
"task-scene",
"task-consult-create",
"task-consult",
"task-consult-result",
"task-list",
].includes(page)
) {
path = `/task-management/${page}`;
}
// 问答中心下的子页面
else if (
[
"new-qa",
"general-qa",
"agent-qa",
"multi-agent-qa",
"qa-records",
].includes(page)
) {
path = `/qa-center/${page}`;
}
// 轻应用中心下的子页面
else if (
["light-app-square", "light-app-records", "light-app-run"].includes(page)
) {
path = `/light-app-center/${page}`;
// 如果是light-app-run且有appId,添加到路径
if (page === "light-app-run" && appId) {
path = `/light-app-center/light-app-run?appId=${appId}`;
}
}
// 智能体管理下的子页面
else if (["agent-trial"].includes(page)) {
path = `/agent-list/${page}`;
}
// 结果管理下的子页面
else if (
[
"result-planning",
"result-mnty",
"result-effect-pg",
"result-xk",
"result-dq",
"result-other",
"result-detail",
].includes(page)
) {
path = `/result-management/${page}`;
}
// 资源管理下的子页面
else if (
[
"resource-zz",
"resource-network-actor",
"resource-knowledge-base",
"resource-evaluation-index",
].includes(page)
) {
path = `/resource-management/${page}`;
}
navigate(path);
};
return (
<div className="min-h-screen theme-bg theme-transition">
<Sidebar currentPage={currentPage} />
<div className="ml-64">
<Header />
<main className="p-6 theme-transition">
{appIdFromQuery}
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
{/* 问答中心 */}
{/* <Route path="/qa-center" element={<QA onNavigate={handleNavigate} initialMode="new" />} />
<Route path="/qa-center/new-qa" element={<QA onNavigate={handleNavigate} initialMode="new" />} />
<Route path="/qa-center/general-qa" element={<QA onNavigate={handleNavigate} />} />
<Route path="/qa-center/agent-qa" element={<QA onNavigate={handleNavigate} />} />
<Route path="/qa-center/multi-agent-qa" element={<QA onNavigate={handleNavigate} initialMode="multi-agent" />} />
<Route path="/qa-center/qa-records" element={<QARecords onNavigate={handleNavigate} />} /> */}
{/* 问答中心 - 嵌套路由 */}
<Route path="/qa-center" element={<QACenterPage />}>
{/* 默认重定向到通用问答页面 */}
<Route
index
element={<Navigate to="/qa-center/general-qa" replace />}
/>
{/* 新建问答页面 - 初始化模式为新建 */}
<Route
path="new-qa"
element={
<GeneralQA onNavigate={handleNavigate} initialMode="new" />
}
/>
{/* 通用问答页面 - 初始化模式为通用 */}
<Route
path="general-qa"
element={
<GeneralQA
onNavigate={handleNavigate}
initialMode="general"
/>
}
/>
{/* 智能体问答页面 - 基于AI智能体的问答功能 */}
<Route
path="agent-qa"
element={<AgentQA onNavigate={handleNavigate} />}
/>
{/* 多智能体协作问答页面 - 多个AI智能体协同回答问题 */}
<Route
path="multi-agent-qa"
element={<CollaborativeQA onNavigate={handleNavigate} />}
/>
{/* 问答记录 */}
<Route
path="qa-records"
element={<QARecords onNavigate={handleNavigate} />}
/>
<Route path="mope-chat" element={<MopeChat />} />
<Route path="template-editor" element={<TemplateEditor />} />
<Route path="template-6b-editor" element={<Template6BEditor />} />
<Route path="sixstep-chat" element={<SixStepChatPage />} />
{/* <Route
path="topic-research"
element={
<TopicResearch
onNavigate={handleNavigate}
initialMode="new"
/>
}
/> */}
<Route
path="topic-research"
element={
<GAFromNewQA gopath="/page/canvas"/>
}
/>
<Route
path="task-chat"
element={
<TaskChat onNavigate={handleNavigate} initialMode="new" />
}
/>
<Route
path="action-chat"
element={
<ActionChat onNavigate={handleNavigate} initialMode="new" />
}
/>
<Route path="sixstep-chat" element={<SixStepChatPage />} />
<Route path="qa-from" element={<GAFromQA gopath="/page/workspace/chats/new"/>} />
<Route path="qa-from-6bf" element={<GAFromQA gopath="/page/strategy/agents/6bf/chats/new"/>} />
<Route path="skills-workshop" element={<SkillsWorkshop/>} />
<Route path="skill-creator" element={<SkillCreatorPage />} />
</Route>
{/* 轻应用中心 */}
<Route
path="/light-app-center"
element={<LightAppSquare onNavigate={handleNavigate} />}
/>
<Route
path="/light-app-center/light-app-square"
element={<LightAppSquare onNavigate={handleNavigate} />}
/>
<Route
path="/light-app-center/light-app-records"
element={<LightAppRecords onNavigate={handleNavigate} />}
/>
<Route
path="/light-app-center/light-app-run"
element={
<LightAppRun
onNavigate={handleNavigate}
appId={appIdFromQuery as any}
/>
}
/>
{/* 任务管理 */}
<Route path="/task-management" element={<TaskManagement />}>
<Route
path="dashboard"
element={<TaskDashboard onNavigate={handleNavigate} />}
/>
<Route
path="task-create"
element={<TaskCreate onNavigate={handleNavigate} />}
/>
<Route
path="task-single"
element={<TaskSingle onNavigate={handleNavigate} />}
/>
<Route
path="task-scene/:taskId"
element={<TaskWorkspace onNavigate={handleNavigate} />}
/>
<Route
path="task-scene"
element={<TaskWorkspace onNavigate={handleNavigate} />}
/>
<Route
path="task-consult-create"
element={<TaskConsultCreate onNavigate={handleNavigate} />}
/>
<Route
path="task-consult"
element={<TaskConsult onNavigate={handleNavigate} />}
/>
<Route
path="task-consult-result"
element={<TaskConsultResult onNavigate={handleNavigate} />}
/>
<Route
path="task-list"
element={
<TaskList
onViewWorkspace={(id) =>
navigate(`/task-management/task-scene/${id}`)
}
onNavigate={handleNavigate}
/>
}
/>
{/* XK任务 */}
<Route
path="xk-task/xk-task-jc"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageHome/pageSwrw'}
/>
}
/>
<Route
path="xk-task/xk-task-dispatch"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageJczl'}
/>
}
/>
{/* 规划任务 */}
<Route
path="plan-task/plan-task-fx"
element={
<TaskAnalyzes
title="任务FX"
category="规划任务"
subCategory="规划任务"
onNavigate={handleNavigate}
/>
}
/>
<Route
path="plan-task/plan-task-sentiment"
element={
<SentimentAnalyzes
title="任务FX"
category="规划任务"
subCategory="规划任务"
onNavigate={handleNavigate}
/>
}
/>
<Route
path="plan-task/plan-task-action"
element={
<ActionAnalyzes
title="行动CE"
category="规划任务"
subCategory="规划任务"
onNavigate={handleNavigate}
/>
}
/>
{/* MNTY任务 */}
<Route
path="mnty-task/mnty-task-mechanism"
element={
<XingdongTyPage
title="XDTY任务"
category="MNTY任务"
subCategory="MNTY任务"
onNavigate={handleNavigate}
/>
}
/>
<Route
path="mnty-task/mnty-task-plan"
element={
<FanganTyPage
title="方案TY任务"
category="MNTY任务"
subCategory="MNTY任务"
onNavigate={handleNavigate}
/>
}
/>
<Route
path="mnty-task/mnty-task-zsty"
element={
<XXTyPage
title="XXTY任务"
category="MNTY任务"
subCategory="MNTY任务"
onNavigate={handleNavigate}
/>
}
/>
{/* 效果PG任务 */}
<Route
path="effect-task/effect-task-pg"
element={
<TaskPgFrom
gopath="task"
/>
}
/>
<Route
path="effect-task/effect-action-pg"
element={
<TaskPgFrom
gopath="xd"
/>
}
/>
<Route
path="effect-task/effect-info-pg"
element={
<TaskPgFrom
gopath="info"
/>
}
/>
<Route
path="effect-task/effect-account-pg"
element={
<TaskListPage
title="账号PG"
category="效果PG任务"
subCategory="效果PG任务"
onNavigate={handleNavigate}
/>
}
/>
</Route>
{/* 场景管理 */}
<Route
path="/scene-list"
element={
<SceneList
onViewDetail={(id) => navigate(`/scene-detail/${id}`)}
onNavigate={handleNavigate}
/>
}
/>
<Route
path="/scene-detail/:sceneId"
element={
<SceneDetail
sceneId={params.sceneId || null}
onBack={() => navigate("/scene-list")}
/>
}
/>
{/* 智能体管理 */}
<Route
path="/agent-list"
element={
<AgentList
onViewDetail={(id) => navigate(`/agent-detail/${id}`)}
onNavigate={handleNavigate}
/>
}
/>
<Route
path="/agent-list/agent-trial"
element={<AgentTrial onNavigate={handleNavigate} />}
/>
<Route
path="/agent-detail/:agentId"
element={
<AgentDetail
agentId={params.agentId || null}
onBack={() => navigate("/agent-list")}
/>
}
/>
<Route
path="/agent-list/agent-experts"
element={<ExpertsAgentsPage />}
></Route>
{/* <GAFromQA gopath="/page/strategy/agents/6bf/chats/new"/> */}
{/* <Route
path="/agent-expert-center"
element={<AgentExpertCenterPage />}
/> */}
<Route
path="/agent-expert-center"
element={<GAFromQA gopath="/page/strategy/agents"/>}
/>
<Route
path="/agent-expert-edit"
element={<AgentExpertEditPage agentId={searchParams.get("id")} />}
/>
<Route path="/agent-chat" element={<AgentChatPage />} />
<Route path="/skills-workshop" element={<SkillsWorkshop />} />
<Route path="/skill-creator" element={<SkillCreatorPage />} />
<Route
path="sixstep-agents"
element={<AgentCardList onEdit={(key) => navigate(`/sixstep-agent-edit?key=${key}`)} />}
/>
<Route
path="sixstep-agent-edit"
element={
<AgentEditPage
agentKey={searchParams.get('key') || 'purpose'}
onBack={() => navigate('/sixstep-agents')}
/>
}
/>
<Route path="file-skills" element={<FileSkillsWorkshop />} />
{/* 结果管理 */}
{/* <Route path="/result-management" element={<ResultManagement onNavigate={handleNavigate} initialCategory="planning" />} />
<Route path="/result-management/result-planning" element={<ResultManagement onNavigate={handleNavigate} initialCategory="planning" />} />
<Route path="/result-management/result-mnty" element={<ResultManagement onNavigate={handleNavigate} initialCategory="mnty" />} />
<Route path="/result-management/result-effect-pg" element={<ResultManagement onNavigate={handleNavigate} initialCategory="effect-pg" />} />
<Route path="/result-management/result-xk" element={<ResultManagement onNavigate={handleNavigate} initialCategory="xk" />} />
<Route path="/result-management/result-dq" element={<ResultManagement onNavigate={handleNavigate} initialCategory="dq-research" />} />
<Route path="/result-management/result-other" element={<ResultManagement onNavigate={handleNavigate} initialCategory="other" />} />
<Route path="/result-management/result-detail/:taskId" element={<ResultDetail taskId={params.taskId} onNavigate={handleNavigate} onBack={() => navigate('/result-management')} />} />
<Route path="/result-management/result-detail" element={<ResultDetail taskId={undefined} onNavigate={handleNavigate} onBack={() => navigate('/result-management')} />} /> */}
{/* 结果管理 - 嵌套路由 */}
<Route
path="/result-management"
element={<ResultManagementLayout />}
>
<Route
index
element={
<Navigate to="/result-management/result-planning" replace />
}
/>
<Route
path="result-planning"
element={<ResultPlanning onNavigate={handleNavigate} />}
/>
<Route
path="result-mnty"
element={<ResultMnty onNavigate={handleNavigate} />}
/>
<Route
path="result-effect-pg"
element={<ResultEffectPg onNavigate={handleNavigate} />}
/>
<Route
path="result-xk"
element={<ResultXk onNavigate={handleNavigate} />}
/>
<Route
path="result-dq"
element={<ResultDq onNavigate={handleNavigate} />}
/>
<Route
path="result-other"
element={<ResultOther onNavigate={handleNavigate} />}
/>
<Route
path="result-detail"
element={
<ResultDetail
taskId={undefined}
onNavigate={handleNavigate}
onBack={() => navigate("/result-management")}
/>
}
/>
<Route
path="result-detail/:taskId"
element={
<ResultDetail
taskId={params.taskId}
onNavigate={handleNavigate}
onBack={() => navigate("/result-management")}
/>
}
/>
</Route>
{/* 资源管理 */}
<Route
path="/resource-management"
element={<ZZResourceManagement onNavigate={handleNavigate} />}
/>
<Route
path="/resource-management/resource-zz"
element={<ZZResourceManagement onNavigate={handleNavigate} />}
/>
<Route
path="/resource-management/resource-network-actor"
element={
<NetworkActorResourceManagement onNavigate={handleNavigate} />
}
/>
<Route
path="/resource-management/resource-knowledge-base"
element={
<KnowledgeBaseResourceManagement onNavigate={handleNavigate} />
}
/>
{/* <Route
path="/resource-management/resource-evaluation-index"
element={
<EvaluationIndexResourceManagement
onNavigate={handleNavigate}
/>
}
/> */}
<Route
path="/resource-management/resource-evaluation-index"
element={
<TaskXkFrom
gopath={'#/pageIndie/pageZyjc/pageZyzl'}
/>
}
/>
{/* 其他路由 */}
<Route
path="/dashboard"
element={<TaskDashboard onNavigate={handleNavigate} />}
/>
<Route
path="/strategy-result"
element={<StrategyResult onNavigate={handleNavigate} />}
/>
<Route
path="/final-plan"
element={<FinalPlan onNavigate={handleNavigate} />}
/>
<Route
path="/system-settings"
element={<SystemSettings onNavigate={handleNavigate} />}
/>
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/login" element={<Login/>}/>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</main>
</div>
</div>
);
}
function App() {
return (
<ThemeProvider>
<ChatProvider>
<AppContent />
</ChatProvider>
</ThemeProvider>
);
}
export default App;
+17
View File
@@ -0,0 +1,17 @@
<svg id="轻应用logo" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="40" height="40" viewBox="0 0 40 40">
<defs>
<linearGradient id="linear-gradient" x1="-0.025" x2="1" y2="1" gradientUnits="objectBoundingBox">
<stop offset="0" stop-color="#006fea"/>
<stop offset="1" stop-color="#004ebd"/>
</linearGradient>
<linearGradient id="linear-gradient-2" x2="1" y2="1" gradientUnits="objectBoundingBox">
<stop offset="0" stop-color="#00c7ff"/>
<stop offset="1" stop-color="#92eeff"/>
</linearGradient>
</defs>
<rect id="矩形_152188" data-name="矩形 152188" width="40" height="40" rx="8" fill="url(#linear-gradient)"/>
<g id="应用_application-one" transform="translate(8 8)">
<rect id="矩形_153908" data-name="矩形 153908" width="24" height="24" fill="#fff" opacity="0"/>
<path id="联合_1324" data-name="联合 1324" d="M-8800,20219.859l-8.5-5a.994.994,0,0,1-.5-.863v-10a.994.994,0,0,1,.5-.863l8.5-5a.988.988,0,0,1,.5-.143,1.008,1.008,0,0,1,.508.143l8.5,5a.989.989,0,0,1,.492.863v10a.989.989,0,0,1-.492.863l-8.5,5a.977.977,0,0,1-.506.141A.976.976,0,0,1-8800,20219.859Zm-7-15.285v8.855l7.5,4.41,7.5-4.41v-8.855l-7.5-4.414Zm6.5,8.926v-3.945l-3.526-2.209a1,1,0,0,1-.32-1.381,1,1,0,0,1,1.381-.314l3.466,2.172,3.471-2.172a1,1,0,0,1,1.375.314,1,1,0,0,1-.314,1.381l-3.531,2.207v3.947a1,1,0,0,1-1,1A1,1,0,0,1-8800.5,20213.5Z" transform="translate(8811.5 -20197)" fill="url(#linear-gradient-2)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

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

After

Width:  |  Height:  |  Size: 1.2 KiB

+175
View File
@@ -0,0 +1,175 @@
import React, { useState } from 'react';
import { X, Upload, FileText, Calendar, Users, AlertTriangle } from 'lucide-react';
interface CreateTaskModalProps {
isOpen: boolean;
onClose: () => void;
onCreate: (taskData: any) => void;
}
const CreateTaskModal: React.FC<CreateTaskModalProps> = ({ isOpen, onClose, onCreate }) => {
const [formData, setFormData] = useState({
name: '',
description: '',
scene: '宣传策略生成',
deadline: '',
resources: '',
constraints: '',
});
const handleSubmit = () => {
if (!formData.name || !formData.scene) {
alert('请填写必填项');
return;
}
onCreate(formData);
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative bg-slate-800/95 backdrop-blur-xl border border-blue-800/30 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden shadow-2xl">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-blue-800/30">
<div>
<h2 className="text-xl font-bold text-white">新建任务</h2>
<p className="text-slate-400 text-sm mt-1">创建一个新的宣传策略分析任务</p>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-700/50 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-400" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-5 max-h-[60vh] overflow-y-auto">
{/* Task Name */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2">
任务名称 <span className="text-red-400">*</span>
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例如:五四青年节宣传活动策略"
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
/>
</div>
{/* Task Description */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2">
任务描述
</label>
<textarea
rows={3}
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="详细描述任务目标和背景..."
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
/>
</div>
{/* Scene Selection */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2">
选择场景 <span className="text-red-400">*</span>
</label>
<select
value={formData.scene}
onChange={(e) => setFormData({ ...formData, scene: e.target.value })}
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
>
<option value="宣传策略生成">宣传策略生成</option>
<option value="宣传方案推演">宣传方案推演</option>
<option value="宣传活动复盘">宣传活动复盘</option>
<option value="舆情应对策略">舆情应对策略</option>
<option value="多版本策略比较">多版本策略比较</option>
</select>
</div>
{/* Deadline */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
<Calendar className="w-4 h-4" />
时间要求
</label>
<input
type="date"
value={formData.deadline}
onChange={(e) => setFormData({ ...formData, deadline: e.target.value })}
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
/>
</div>
{/* Resources */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
<Users className="w-4 h-4" />
资源条件
</label>
<input
type="text"
value={formData.resources}
onChange={(e) => setFormData({ ...formData, resources: e.target.value })}
placeholder="例如:预算10万,运营团队3人..."
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors"
/>
</div>
{/* Constraints */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
<AlertTriangle className="w-4 h-4" />
风险约束
</label>
<textarea
rows={2}
value={formData.constraints}
onChange={(e) => setFormData({ ...formData, constraints: e.target.value })}
placeholder="描述任务的限制条件和风险边界..."
className="w-full px-4 py-3 bg-slate-900/50 border border-blue-800/30 rounded-lg text-white placeholder-slate-500 text-sm focus:outline-none focus:border-blue-500/50 transition-colors resize-none"
/>
</div>
{/* File Upload */}
<div>
<label className="block text-slate-300 text-sm font-medium mb-2 flex items-center gap-2">
<FileText className="w-4 h-4" />
附件/参考资料
</label>
<div className="border-2 border-dashed border-blue-800/30 rounded-lg p-6 text-center hover:border-blue-500/50 transition-colors cursor-pointer">
<Upload className="w-8 h-8 text-slate-500 mx-auto mb-2" />
<p className="text-slate-400 text-sm">点击或拖拽文件到此处上传</p>
<p className="text-slate-500 text-xs mt-1">支持 PDF、Word、图片等格式</p>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-blue-800/30 bg-slate-900/30">
<button
onClick={onClose}
className="px-5 py-2.5 bg-slate-700/50 hover:bg-slate-600/50 border border-blue-800/30 text-slate-300 rounded-lg transition-colors"
>
取消
</button>
<button
onClick={handleSubmit}
className="px-5 py-2.5 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors font-medium"
>
开始分析
</button>
</div>
</div>
</div>
);
};
export default CreateTaskModal;
+35
View File
@@ -0,0 +1,35 @@
import React from 'react';
const searilizeError = (error: any) => {
if (error instanceof Error) {
return error.message + '\n' + error.stack;
}
return JSON.stringify(error, null, 2);
};
export class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean; error: any }
> {
constructor(props: { children: React.ReactNode }) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: any) {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
<div className="p-4 border border-red-500 rounded">
<h2 className="text-red-500">Something went wrong.</h2>
<pre className="mt-2 text-sm">{searilizeError(this.state.error)}</pre>
</div>
);
}
return this.props.children;
}
}
+99
View File
@@ -0,0 +1,99 @@
import React, { useEffect, useState } from 'react';
import { Bell, Search, User, ChevronDown, Moon, Sun, Palette } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
export interface userData {
userName: string;
typeName: string;
}
const Header: React.FC = () => {
const { theme, toggleTheme } = useTheme();
const isDark = theme === 'dark';
const [userData, setUserData] = useState<userData>({
userName: '',
typeName: ''
});
useEffect(() => {
let userInfo: any = localStorage.getItem('userInfo');
if (userInfo) {
userInfo = JSON.parse(userInfo);
setUserData(userInfo);
}
}, []);
return (
<header className={`h-16 flex items-center justify-between px-6 theme-transition ${
isDark
? 'bg-slate-900/50 backdrop-blur-xl border-b border-blue-800/30'
: 'bg-red-600 border-b border-red-700'
}`}>
{/* 左侧 - 搜索框 */}
<div className="flex items-center gap-4">
{/* <div className="relative">
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${isDark ? 'text-slate-500' : 'text-red-200'}`} />
<input
type="text"
placeholder="搜索场景、智能体、任务..."
className={`w-80 pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-white/20 transition-all duration-200 theme-transition ${
isDark
? 'bg-slate-800/50 border border-blue-800/30 text-white placeholder-slate-500'
: 'bg-red-700 border border-red-800 text-white placeholder-red-200'
}`}
/>
</div> */}
</div>
{/* 右侧 - 用户信息和通知 */}
<div className="flex items-center gap-4">
{/* 主题切换按钮 */}
<button
onClick={toggleTheme}
className={`p-2 rounded-lg transition-all duration-200 ${
isDark
? 'hover:bg-slate-800/50 text-slate-400 hover:text-white'
: 'hover:bg-red-500 text-white'
}`}
title={isDark ? '切换到红白主题' : '切换到深色主题'}
>
{isDark ? (
<Palette className="w-5 h-5" />
) : (
<Moon className="w-5 h-5" />
)}
</button>
{/* 通知 */}
{/* <button className={`relative p-2 rounded-lg transition-colors ${
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
}`}>
<Bell className={`w-5 h-5 ${isDark ? 'text-slate-400' : 'text-white'}`} />
<span className={`absolute top-1 right-1 w-2 h-2 rounded-full ${isDark ? 'bg-cyan-500' : 'bg-white'}`} />
</button> */}
{/* 分隔线 */}
<div className={`w-px h-8 ${isDark ? 'bg-blue-800/30' : 'bg-red-500'}`} />
{/* 用户信息 */}
<button className={`flex items-center gap-3 px-3 py-1.5 rounded-lg transition-colors ${
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
isDark
? 'bg-gradient-to-br from-blue-500 to-cyan-400'
: 'bg-white'
}`}>
<User className={`w-4 h-4 ${isDark ? 'text-white' : 'text-red-600'}`} />
</div>
<div className="text-left">
<p className={`text-sm font-medium ${isDark ? 'text-white' : 'text-white'}`}>{userData.userName}</p>
<p className={`text-xs ${isDark ? 'text-blue-400/60' : 'text-red-200'}`}>{userData.typeName}</p>
</div>
{/* <ChevronDown className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-white'}`} /> */}
</button>
</div>
</header>
);
};
export default Header;
+127
View File
@@ -0,0 +1,127 @@
import React, { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
interface MarkdownRendererProps {
content: string;
className?: string;
onCitationClick?: (index: number) => void;
}
const extractTextFromNode = (node: React.ReactNode): string => {
if (typeof node === 'string' || typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(extractTextFromNode).join('');
if (React.isValidElement<{ children?: React.ReactNode }>(node)) return extractTextFromNode(node.props.children);
return '';
};
const CodeBlock: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className = '' }) => {
const [copied, setCopied] = useState(false);
const codeText = extractTextFromNode(children).replace(/\n$/, '');
const language = className?.match(/language-(\w+)/)?.[1];
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(codeText);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch (error) {
console.error('复制代码失败:', error);
}
};
return (
<div className="group relative my-4 overflow-hidden rounded-lg border border-gray-200 bg-gray-50 shadow-sm dark:border-slate-700 dark:bg-slate-900">
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-100 px-3 py-2 text-xs text-gray-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400">
<span>{language || 'code'}</span>
<button
type="button"
onClick={handleCopy}
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-600 transition-colors hover:bg-gray-50 dark:border-slate-600 dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600"
>
{copied ? '已复制' : '复制'}
</button>
</div>
<pre className="m-0 overflow-x-auto p-4 text-sm leading-relaxed text-gray-800 dark:text-slate-100">
<code className={className}>{children}</code>
</pre>
</div>
);
};
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className = '', onCitationClick }) => {
const contentWithCitationBadge = content.replace(
/\[\[(\d+)\]\]/g,
'<button type="button" data-citation-index="$1" class="inline-flex items-center justify-center min-w-[1.5rem] h-6 px-2 mx-0.5 rounded-md border text-xs font-semibold align-middle bg-red-50 text-red-600 border-red-200 dark:bg-slate-700 dark:text-slate-100 dark:border-slate-500 cursor-pointer transition-all hover:opacity-85">[$1]</button>',
);
return (
<div
className={`markdown-content ${className}`}
onClick={(event) => {
if (!onCitationClick) return;
const target = event.target as HTMLElement;
const button = target.closest('[data-citation-index]') as HTMLElement | null;
if (!button) return;
const index = Number(button.dataset.citationIndex || '0');
if (Number.isNaN(index) || index <= 0) return;
onCitationClick(index);
}}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
h1: ({ node, ...props }) => <h1 className="text-2xl font-bold mb-4" {...props} />,
h2: ({ node, ...props }) => <h2 className="text-xl font-bold mb-3" {...props} />,
h3: ({ node, ...props }) => <h3 className="text-lg font-bold mb-2" {...props} />,
h4: ({ node, ...props }) => <h4 className="text-base font-bold mb-2" {...props} />,
h5: ({ node, ...props }) => <h5 className="text-sm font-bold mb-2" {...props} />,
h6: ({ node, ...props }) => <h6 className="text-xs font-bold mb-2" {...props} />,
p: ({ node, ...props }) => <p className="mb-4 leading-relaxed" {...props} />,
ul: ({ node, ...props }) => <ul className="list-disc list-inside mb-4 ml-4" {...props} />,
ol: ({ node, ...props }) => <ol className="list-decimal list-inside mb-4 ml-4" {...props} />,
li: ({ node, ...props }) => <li className="mb-1" {...props} />,
// code: ({ node, inline, className, children, ...props }: any) => {
// if (inline) {
// return (
// <code
// className={`rounded bg-gray-100 px-1 py-0.5 text-sm text-gray-800 dark:bg-slate-800 dark:text-slate-100 ${className || ''}`}
// {...props}
// >
// {children}
// </code>
// );
// }
// return <CodeBlock className={className}>{children}</CodeBlock>;
// },
// pre: ({ node, children }) => <>{children}</>,
blockquote: ({ node, ...props }) => (
<blockquote className="border-l-4 border-gray-300 dark:border-gray-600 pl-4 italic my-4" {...props} />
),
a: ({ node, ...props }) => (
<a className="text-blue-600 dark:text-blue-400 hover:underline" {...props} />
),
table: ({ node, ...props }) => (
<div className="overflow-x-auto mb-4">
<table className="min-w-full border-collapse" {...props} />
</div>
),
thead: ({ node, ...props }) => <thead className="bg-gray-100 dark:bg-gray-800" {...props} />,
tbody: ({ node, ...props }) => <tbody className="divide-y divide-gray-200 dark:divide-gray-700" {...props} />,
tr: ({ node, ...props }) => <tr className="hover:bg-gray-50 dark:hover:bg-gray-700" {...props} />,
th: ({ node, ...props }) => <th className="px-4 py-2 text-left font-semibold" {...props} />,
td: ({ node, ...props }) => <td className="px-4 py-2" {...props} />,
hr: ({ node, ...props }) => <hr className="my-4 border-gray-300 dark:border-gray-600" {...props} />,
strong: ({ node, ...props }) => <strong className="font-bold" {...props} />,
em: ({ node, ...props }) => <em className="italic" {...props} />,
}}
>
{contentWithCitationBadge}
</ReactMarkdown>
</div>
);
};
export default MarkdownRenderer;
+572
View File
@@ -0,0 +1,572 @@
import React, { useState } from "react";
import { Link, useLocation } from "react-router-dom";
import {
BarChart3,
Bot,
Brain,
ChevronLeft,
ChevronRight,
CircleHelp,
Compass,
Database,
FileBox,
FileOutput,
FileText,
Folder,
FolderOpen,
Grid3X3,
HelpCircle,
History,
Layers,
Layout,
Library,
ListTodo,
MessageSquare,
MoreHorizontal,
Network,
PlayCircle,
Plus,
Scale,
Target,
TrendingUp,
Users,
Workflow,
} from "lucide-react";
import { useTheme } from "../contexts/ThemeContext";
export type PageType = string;
type MenuItem = {
id: PageType;
label: string;
icon: React.ElementType;
children?: MenuItem[];
path?: string;
activePath?: string;
};
type Props = {
currentPage: PageType;
isCollapsed?: boolean;
onToggleCollapse?: () => void;
};
type Level1MenuId =
| "qa-center"
| "light-app-center"
| "task-management"
| "agent-list"
| "result-management"
| "resource-management";
type TaskGroupId = string;
const taskGroups: TaskGroupId[] = [
"xk-task",
"plan-task",
"mnty-task",
"effect-task",
];
const collapsibleMenus = new Set<PageType>([
"qa-center",
"light-app-center",
"task-management",
"agent-list",
"result-management",
"resource-management",
]);
const taskPages = new Set([
"dashboard",
"task-create",
"task-single",
"task-scene",
"task-consult-create",
"task-consult",
"task-consult-result",
"task-list",
]);
const qaPages = new Set([
"new-qa",
"general-qa",
"agent-qa",
"multi-agent-qa",
"qa-records",
"sixstep-chat",
"topic-research",
"task-chat",
"action-chat",
"qa-from",
"qa-from-6bf"
]);
const lightAppPages = new Set([
"light-app-square",
"light-app-records",
"light-app-run",
]);
const agentPages = new Set(["agent-trial"]);
const resultPages = new Set([
"result-planning",
"result-mnty",
"result-effect-pg",
"result-xk",
"result-dq",
"result-other",
"result-detail",
]);
const resourcePages = new Set([
"resource-zz",
"resource-network-actor",
"resource-knowledge-base",
"resource-evaluation-index",
]);
const menuItems: MenuItem[] = [
{
id: "qa-center",
label: "问答管理",
icon: HelpCircle,
children: [
{ id: "qa-from", label: "通用问答", icon: Bot }, // general-qa
// { id: 'sixstep-chat', label: 'zz决策支持', icon: Layers },
{ id: "qa-from-6bf", label: "zz决策支持", icon: Layers },
{ id: "action-chat", label: "zzXD应用", icon: History },
{ id: "topic-research", label: "课题研究", icon: CircleHelp },
// { id: 'agent-qa', label: '智能体问答', icon: Bot },
// { id: 'multi-agent-qa', label: '多智能体协同', icon: Layers },
// { id: 'qa-records', label: '问答记录', icon: History },
],
},
{
id: "task-management",
label: "任务管理",
icon: Target,
children: [
// { id: "dashboard", label: "任务统计", icon: BarChart3 },
{
id: "xk-task",
label: "XK中任务",
icon: Folder,
children: [
{ id: "xk-task-jc", label: "任务JC", icon: ListTodo },
{ id: "xk-task-dispatch", label: "任务调度", icon: ListTodo },
],
},
{
id: "plan-task",
label: "COH中任务",
icon: Folder,
children: [
{ id: "plan-task-sentiment", label: "三情FX", icon: ListTodo },
{ id: "plan-task-fx", label: "任务FX", icon: ListTodo },
{ id: "plan-task-action", label: "行动CE", icon: ListTodo },
],
},
{
id: "effect-task",
label: "PG中任务",
icon: Folder,
children: [
{ id: "effect-task-pg", label: "任务PG", icon: ListTodo },
{ id: "effect-action-pg", label: "行动PG", icon: ListTodo },
{ id: "effect-info-pg", label: "信息PG", icon: ListTodo },
// { id: "effect-account-pg", label: "账号PG", icon: ListTodo },
],
},
{
id: "mnty-task",
label: "TY中任务",
icon: Folder,
children: [
{ id: "mnty-task-mechanism", label: "XDTY任务", icon: ListTodo },
{ id: "mnty-task-plan", label: "方案TY任务", icon: ListTodo },
{ id: "mnty-task-zsty", label: "XXTY任务", icon: ListTodo },
],
},
],
},
// {
// id: 'light-app-center', label: '工具管理', icon: Grid3X3,
// children: [
// { id: 'light-app-square', label: '应用广场', icon: Layout },
// { id: 'light-app-records', label: '运行记录', icon: History },
// { id: 'scene-list', label: '场景管理', icon: Layers },
// ]
// },
{
id: "agent-list",
label: " 综合管理",
icon: Bot,
children: [
// { id: 'agent-list', label: '业务链条管理', icon: Users },
// { id: 'agent-list', label: '智能体管理', icon: Users },
{ id: "agent-expert-center", label: "智能体管理", icon: Users },
{ id: 'scene-list', label: '业务链条管理', icon: Layers },
// { id: "skills-workshop", label: "技能列表", icon: Users },
// { id: "sixstep-agents", label: "mcp管理", icon: Layers },
// { id: "file-skills", label: "skills管理", icon: Layers },
// { id: 'agent-trial', label: '单智能体试用', icon: PlayCircle }
],
},
// { id: 'result-management', label: '结果管理', icon: BarChart3, children: [
// { id: 'result-planning', label: '规划任务', icon: Target, children: [
// { id: 'result-planning-all', label: '全部', icon: BarChart3, path: '/result-management/result-planning', activePath: '/result-management/result-planning' },
// { id: 'result-planning-goal', label: '目标分析', icon: Target, path: '/result-management/result-planning?subCategory=目标分析' },
// { id: 'result-planning-brand', label: '品牌策略', icon: Target, path: '/result-management/result-planning?subCategory=品牌策略' },
// { id: 'result-planning-content', label: '内容规划', icon: Target, path: '/result-management/result-planning?subCategory=内容规划' },
// { id: 'result-planning-channel', label: '渠道规划', icon: Target, path: '/result-management/result-planning?subCategory=渠道规划' },
// { id: 'result-planning-narrative', label: '叙事策略', icon: Target, path: '/result-management/result-planning?subCategory=叙事策略' },
// ] },
// { id: 'result-mnty', label: 'MNTY任务', icon: Brain, children: [
// { id: 'result-mnty-all', label: '全部', icon: BarChart3, path: '/result-management/result-mnty', activePath: '/result-management/result-mnty' },
// { id: 'result-mnty-mechanism', label: '机制研究', icon: Brain, path: '/result-management/result-mnty?subCategory=机制研究' },
// { id: 'result-mnty-plan', label: '方案设计', icon: Brain, path: '/result-management/result-mnty?subCategory=方案设计' },
// { id: 'result-mnty-zsty', label: 'ZSTY分析', icon: Brain, path: '/result-management/result-mnty?subCategory=ZSTY分析' },
// ] },
// { id: 'result-effect-pg', label: '效果PG任务', icon: TrendingUp, children: [
// { id: 'result-effect-pg-all', label: '全部', icon: BarChart3, path: '/result-management/result-effect-pg', activePath: '/result-management/result-effect-pg' },
// { id: 'result-effect-pg-behavior', label: '行为PG', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=行为PG' },
// { id: 'result-effect-pg-evaluation', label: '效果评估', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=效果评估' },
// { id: 'result-effect-pg-info', label: '信息PG', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=信息PG' },
// { id: 'result-effect-pg-account', label: '账号PG', icon: TrendingUp, path: '/result-management/result-effect-pg?subCategory=账号PG' },
// ] },
// { id: 'result-xk', label: 'XK任务', icon: Compass, children: [
// { id: 'result-xk-all', label: '全部', icon: BarChart3, path: '/result-management/result-xk', activePath: '/result-management/result-xk' },
// { id: 'result-xk-jc', label: '任务JC', icon: Compass, path: '/result-management/result-xk?subCategory=任务JC' },
// { id: 'result-xk-dispatch', label: '任务调度', icon: Compass, path: '/result-management/result-xk?subCategory=任务调度' },
// ] },
// { id: 'result-dq', label: 'DQ研究类任务', icon: Database, children: [
// { id: 'result-dq-all', label: '全部', icon: BarChart3, path: '/result-management/result-dq', activePath: '/result-management/result-dq' },
// { id: 'result-dq-audience', label: '受众分析', icon: Database, path: '/result-management/result-dq?subCategory=受众分析' },
// { id: 'result-dq-kb', label: '知识库研究', icon: Database, path: '/result-management/result-dq?subCategory=知识库研究' },
// { id: 'result-dq-trend', label: '趋势研究', icon: Database, path: '/result-management/result-dq?subCategory=趋势研究' },
// ] },
// { id: 'result-other', label: '其他类任务', icon: MoreHorizontal, children: [
// { id: 'result-other-all', label: '全部', icon: BarChart3, path: '/result-management/result-other', activePath: '/result-management/result-other' },
// { id: 'result-other-general', label: '通用分析', icon: MoreHorizontal, path: '/result-management/result-other?subCategory=通用分析' },
// { id: 'result-other-temp', label: '临时任务', icon: MoreHorizontal, path: '/result-management/result-other?subCategory=临时任务' },
// { id: 'result-other-other', label: '其他', icon: MoreHorizontal, path: '/result-management/result-other?subCategory=其他' },
// ] },
// ] },
{
id: "resource-management",
label: "资源管理",
icon: Database,
children: [
// { id: "resource-zz", label: "ZZ资源管理", icon: FileBox },
{
id: "resource-network-actor",
label: "网络行为体资源管理",
icon: Network,
},
{ id: "resource-knowledge-base", label: "知识库资源管理", icon: Library },
{ id: "resource-evaluation-index", label: "作战资源管理", icon: Library },
// {
// id: "resource-evaluation-index",
// label: "评估指标体系资源管理",
// icon: Scale,
// },
],
},
];
const subEntries = [
// { id: 'task-create', label: '新建任务', icon: ListTodo, parent: 'task-management' },
// { id: 'task-single', label: '单智能体任务', icon: Bot, parent: 'task-management' },
// { id: 'task-scene', label: '场景任务', icon: Workflow, parent: 'task-management' },
// { id: 'task-consult-create', label: '多智能体会商', icon: MessageSquare, parent: 'task-management' },
// { id: 'final-plan', label: '方案输出', icon: FileOutput, parent: 'strategy-result' },
];
const getPath = (id: PageType) => {
if (taskGroups.includes(id as TaskGroupId)) {
console.log('----task-management', id);
return `/task-management/${id}`;
}
if (taskGroups.some((group) => id.startsWith(group))) {
return `/task-management/${id.split("-").slice(0, 2).join("-")}/${id}`;
}
// 特殊处理 effect-task 下的子菜单项(effect-action-pg, effect-info-pg, effect-account-pg)
if (id.startsWith("effect-") && !id.startsWith("effect-task")) {
return `/task-management/effect-task/${id}`;
}
if (taskPages.has(id)) return `/task-management/${id}`;
if (qaPages.has(id)) return `/qa-center/${id}`;
if (id === "qa-center") return "/qa-center";
if (lightAppPages.has(id)) return `/light-app-center/${id}`;
if (agentPages.has(id)) return `/agent-list/${id}`;
if (resultPages.has(id)) return `/result-management/${id}`;
if (resourcePages.has(id)) return `/resource-management/${id}`;
return `/${id}`;
};
const toggleSet = <T,>(set: Set<T>, item: T) => {
const next = new Set(set);
next.has(item) ? next.delete(item) : next.add(item);
return next;
};
export default function Sidebar({
currentPage,
isCollapsed = false,
onToggleCollapse,
}: Props) {
const { theme } = useTheme();
const location = useLocation();
const isDark = theme === "dark";
const [collapsedMenus, setCollapsedMenus] = useState<Set<Level1MenuId>>(
new Set()
);
const [expandedTaskGroups, setExpandedTaskGroups] = useState<Set<string>>(
() =>
new Set([
...taskGroups.filter((group) => currentPage.startsWith(group)),
...(currentPage.startsWith("result-") ? [currentPage] : []),
])
);
const panel = `ml-4 pl-4 border-l space-y-1 theme-transition ${
isDark ? "border-blue-800/30" : "border-red-100"
}`;
const iconBtn = `rounded-lg transition-all duration-200 ${
isDark
? "text-slate-400 hover:text-white hover:bg-slate-800/50"
: "text-gray-500 hover:text-red-600 hover:bg-red-50"
}`;
const itemClass = (active: boolean) =>
`rounded-lg transition-all duration-200 theme-transition ${
active
? isDark
? "bg-blue-600/20 text-blue-400 border border-blue-500/30"
: "bg-red-50 text-red-600 border border-red-200"
: isDark
? "text-slate-400 hover:text-white hover:bg-slate-800/50"
: "text-gray-600 hover:text-red-600 hover:bg-red-50"
}`;
const isTopActive = (id: PageType) => {
if (id === "qa-center") return qaPages.has(currentPage);
if (id === "light-app-center") return currentPage.startsWith("light-app");
if (id === "task-management")
return (
currentPage === "dashboard" ||
taskPages.has(currentPage) ||
taskGroups.some((group) => currentPage.startsWith(group))
);
if (id === "scene-list") return currentPage.startsWith("scene");
if (id === "agent-list") return currentPage.startsWith("agent");
if (id === "result-management") return currentPage.startsWith("result");
if (id === "resource-management") return currentPage.startsWith("resource");
return currentPage === id;
};
const hasActiveChild = (item: MenuItem): boolean =>
currentPage === item.id || Boolean(item.children?.some(hasActiveChild));
const renderLink = (item: MenuItem, top = false) => {
const Icon = item.icon;
const activePath = decodeURI(`${location.pathname}${location.search}`);
const active = top
? isTopActive(item.id)
: item.path
? activePath === (item.activePath ?? item.path)
: currentPage === item.id;
return (
<Link
key={item.id}
to={item.path ?? getPath(item.id)}
onClick={() =>
top &&
item.id === "task-management" &&
expandedTaskGroups.size === 0 &&
setExpandedTaskGroups(new Set(["xk-task"]))
}
title={isCollapsed ? item.label : undefined}
className={`${top ? "flex-1" : "w-full"} flex items-center ${
isCollapsed && top ? "justify-center gap-0 px-2" : "gap-2 px-3"
} py-2 ${itemClass(active)}`}
>
<Icon
className={`${top ? "w-5 h-5" : "w-4 h-4"} flex-shrink-0 ${
active ? (isDark ? "text-blue-400" : "text-red-500") : ""
}`}
/>
{(!isCollapsed || !top) && (
<span
className={`${
top ? "text-sm" : "text-xs"
} font-medium flex-1 text-left`}
>
{item.label}
</span>
)}
</Link>
);
};
const renderTaskGroup = (item: MenuItem) => {
const expanded = expandedTaskGroups.has(item.id);
const active = hasActiveChild(item);
const Icon = expanded ? FolderOpen : Folder;
return (
<div key={item.id} className="space-y-1">
<button
type="button"
onClick={() =>
setExpandedTaskGroups((prev) => toggleSet(prev, item.id))
}
className={`w-full flex items-center gap-2 px-3 py-2 ${itemClass(
active
)}`}
>
<Icon
className={`w-4 h-4 flex-shrink-0 ${
active ? (isDark ? "text-blue-400" : "text-red-500") : ""
}`}
/>
<span className="text-xs font-medium flex-1 text-left">
{item.label}
</span>
<ChevronRight
className={`w-4 h-4 transition-transform duration-200 ${
expanded ? "rotate-90" : ""
}`}
/>
</button>
{expanded && item.children && (
<div className={panel}>
{item.children.map((child) => renderLink(child))}
</div>
)}
</div>
);
};
const renderChildren = (item: MenuItem) =>
item.children ? (
<div className={panel}>
{item.children.map((child) =>
child.children ? renderTaskGroup(child) : renderLink(child)
)}
</div>
) : null;
const renderSubEntries = (item: MenuItem) => {
const entries = subEntries.filter((entry) => entry.parent === item.id);
return entries.length && isTopActive(item.id) ? (
<div className={panel}>{entries.map((entry) => renderLink(entry))}</div>
) : null;
};
return (
<aside
className={`fixed left-0 top-0 h-screen ${
isCollapsed ? "w-16" : "w-64"
} z-50 theme-transition overflow-y-auto ${
isDark
? "bg-slate-900/80 backdrop-blur-xl border-r border-blue-800/30"
: "bg-white border-r border-red-100 shadow-sm"
}`}
>
<div
className={`h-16 flex items-center ${
isCollapsed ? "justify-center px-2" : "justify-between px-6"
} border-b theme-transition flex-shrink-0 ${
isDark ? "border-blue-800/30" : "border-red-100"
}`}
>
<div className="flex items-center gap-3 min-w-0">
<div
className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${
isDark
? "bg-gradient-to-br from-blue-500 to-cyan-400"
: "bg-gradient-to-br from-red-500 to-red-400"
}`}
>
<FileText className="w-4 h-4 text-white" />
</div>
{!isCollapsed && (
<div>
<h1
className={`font-semibold text-sm ${
isDark ? "text-white" : "text-gray-900"
}`}
>
智能CM系统
</h1>
<p
className={`text-xs ${
isDark ? "text-blue-400/60" : "text-red-400/80"
}`}
>
Smart CM Platform
</p>
</div>
)}
</div>
{!isCollapsed && (
<button
type="button"
onClick={onToggleCollapse}
className={`p-2 ${iconBtn}`}
title="收起侧边栏"
>
<ChevronLeft className="w-4 h-4" />
</button>
)}
</div>
<nav className={`${isCollapsed ? "p-2" : "p-4"} space-y-2`}>
{isCollapsed && (
<button
type="button"
onClick={onToggleCollapse}
className={`w-full flex items-center justify-center p-2.5 ${iconBtn}`}
title="展开侧边栏"
>
<ChevronRight className="w-5 h-5" />
</button>
)}
{menuItems.map((item) => {
const canCollapse = collapsibleMenus.has(item.id);
const childrenCollapsed =
canCollapse && collapsedMenus.has(item.id as Level1MenuId);
return (
<div key={item.id} className="space-y-1">
<div className="flex items-center gap-2">
{renderLink(item, true)}
{!isCollapsed && item.children && canCollapse && (
<button
type="button"
onClick={() =>
setCollapsedMenus((prev) =>
toggleSet(prev, item.id as Level1MenuId)
)
}
className={`p-1.5 ${iconBtn}`}
title={childrenCollapsed ? "展开菜单" : "折叠菜单"}
>
<ChevronRight
className={`w-4 h-4 transition-transform duration-200 ${
childrenCollapsed ? "" : "rotate-90"
}`}
/>
</button>
)}
</div>
{!isCollapsed &&
item.children &&
!childrenCollapsed &&
renderChildren(item)}
{!isCollapsed && !childrenCollapsed && renderSubEntries(item)}
</div>
);
})}
</nav>
{isDark ? (
<>
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/10 rounded-full blur-3xl pointer-events-none" />
<div className="absolute bottom-20 right-0 w-24 h-24 bg-cyan-500/10 rounded-full blur-3xl pointer-events-none" />
</>
) : (
<>
<div className="absolute top-0 right-0 w-32 h-32 bg-red-500/5 rounded-full blur-3xl pointer-events-none" />
<div className="absolute bottom-20 right-0 w-24 h-24 bg-red-400/5 rounded-full blur-3xl pointer-events-none" />
</>
)}
</aside>
);
}
@@ -0,0 +1,70 @@
@keyframes breathing {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes breathingGlow {
0%, 100% {
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4);
}
50% {
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
}
}
.animate-breathing {
animation: breathing 1.5s ease-in-out infinite, breathingGlow 1.5s ease-in-out infinite;
}
.agent-resizing {
cursor: col-resize !important;
user-select: none !important;
}
.agent-file-tree-scroll,
.agent-file-preview-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.45) transparent;
}
.agent-file-tree-scroll::-webkit-scrollbar,
.agent-file-preview-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.agent-file-tree-scroll::-webkit-scrollbar-track,
.agent-file-preview-scroll::-webkit-scrollbar-track {
background: transparent;
}
.agent-file-tree-scroll::-webkit-scrollbar-thumb,
.agent-file-preview-scroll::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.38);
border-radius: 999px;
border: 2px solid transparent;
background-clip: content-box;
}
.agent-file-tree-scroll::-webkit-scrollbar-thumb:hover,
.agent-file-preview-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(96, 165, 250, 0.58);
background-clip: content-box;
}
.agent-file-markdown-preview > :first-child {
margin-top: 0;
}
.agent-file-markdown-preview > :last-child {
margin-bottom: 0;
}
.agent-file-markdown-preview img {
max-width: 100%;
border-radius: 12px;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,55 @@
import SkillCreatorConversationItem from './SkillCreatorConversationItem';
export interface ToolCall {
id: string;
name: string;
input?: Record<string, unknown>;
output?: string;
}
export interface ConversationStep {
text: string;
subText?: string;
status: 'done' | 'processing';
}
export interface ConversationMessage {
id: string;
role: 'user' | 'assistant';
content: string;
steps?: ConversationStep[];
thinkings?: Array<{ duration: number; content: string }>;
toolCalls?: ToolCall[];
}
interface AgentConversationListProps {
messages: ConversationMessage[];
isConnecting?: boolean;
className?: string;
}
export default function AgentConversationList({ messages, isConnecting = false, className = '' }: AgentConversationListProps) {
return (
<div className={`w-full max-w-4xl mx-auto space-y-5 pb-6 ${className}`}>
{messages.map((message, index) => {
// 仅在最后一条 assistant 且尚未收到任何生成内容时显示“连接中”态
// 一旦出现 steps/thinkings/toolCalls/content,就切回正常渲染
const hasStartedGenerating =
Boolean(message.content) ||
Boolean(message.steps?.length) ||
Boolean(message.thinkings?.length) ||
Boolean(message.toolCalls?.length);
const shouldShowConnecting =
isConnecting && index === messages.length - 1 && message.role === 'assistant' && !hasStartedGenerating;
return (
<SkillCreatorConversationItem
key={message.id}
message={message}
isConnecting={shouldShowConnecting}
/>
);
})}
</div>
);
}
@@ -0,0 +1,364 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Plus, Sparkles, ArrowRight, MessageCircle, Bot, BriefcaseBusiness, Info, Pencil, Trash2, Eye, EyeOff, MessageCircleMore, InfoIcon, Edit } from 'lucide-react';
import { Dialog, Loading, MessagePlugin, Popconfirm, Switch } from 'tdesign-react';
import { useTheme } from '../../contexts/ThemeContext';
import { useExpertAuthBootstrap } from '../../hooks';
import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
interface ExpertAgentItem {
id: number;
name: string;
avatar_url: string | null;
description: string | null;
model_name: string;
is_draft: boolean;
user_id: string;
created_at: string;
}
type ExpertTab = 'mine' | 'published';
export default function AgentExpertCenterPage() {
const { theme } = useTheme();
const isDark = theme === 'dark';
const { llmList, currentUser, authedFetch } = useExpertAuthBootstrap();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<ExpertTab>('mine');
const [agentList, setAgentList] = useState<ExpertAgentItem[]>([]);
const [isListLoading, setIsListLoading] = useState(false);
const [isStatusUpdating, setIsStatusUpdating] = useState(false);
const [detailVisible, setDetailVisible] = useState(false);
const [selectedAgent, setSelectedAgent] = useState<ExpertAgentItem | null>(null);
const fetchExpertList = useCallback(async () => {
setIsListLoading(true);
try {
const isDraft = activeTab === 'mine';
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/list`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ is_draft: isDraft }),
});
if (!res.ok) {
console.error('expert-agents/list接口调用失败:', res.status, res.statusText);
setAgentList([]);
return;
}
const data: unknown = await res.json();
const rawList = Array.isArray(data) ? (data as ExpertAgentItem[]) : [];
if (isDraft) {
const mineFromList = currentUser ? rawList.filter((item) => item.user_id === currentUser.id) : [];
const allRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents`, {
method: 'GET',
});
let mineFromAll: ExpertAgentItem[] = [];
if (allRes.ok) {
const allData: unknown = await allRes.json();
const allList = Array.isArray(allData) ? (allData as ExpertAgentItem[]) : [];
mineFromAll = currentUser ? allList.filter((item) => item.user_id === currentUser.id) : [];
} else {
console.error('expert-agents接口调用失败:', allRes.status, allRes.statusText);
}
const mergedMap = new Map<number, ExpertAgentItem>();
[...mineFromList, ...mineFromAll].forEach((item) => mergedMap.set(item.id, item));
setAgentList(Array.from(mergedMap.values()));
return;
}
setAgentList(rawList);
} catch (error) {
console.error('expert-agents/list接口异常:', error);
setAgentList([]);
} finally {
setIsListLoading(false);
}
}, [activeTab, authedFetch, currentUser]);
useEffect(() => {
if (activeTab === 'mine' && !currentUser) return;
void fetchExpertList();
}, [activeTab, currentUser, fetchExpertList]);
const handleStatusToggle = async (nextPublished: boolean) => {
if (!selectedAgent) return;
const nextIsDraft = !nextPublished;
setIsStatusUpdating(true);
try {
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${selectedAgent.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ is_draft: nextIsDraft }),
});
if (!res.ok) {
console.error('更新专家状态失败:', res.status, res.statusText);
return;
}
setSelectedAgent({ ...selectedAgent, is_draft: nextIsDraft });
await fetchExpertList();
} catch (error) {
console.error('更新专家状态异常:', error);
} finally {
setIsStatusUpdating(false);
}
};
const addAgentSearch = async () => {
const parmy = {
model_name: 'Qwen3.5-27B',
name: '未命名专家',
system_prompt: '你是一个专业的助手,能够回答用户的问题。'
}
const res: any = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(parmy),
});
console.log('创建专家', res);
const data: any = await res.json()
navigate(`/agent-expert-edit?id=${data.id}`)
}
const delAgentSearch = async (id) => {
const res: any = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/${id}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
});
MessagePlugin.success('操作成功')
await fetchExpertList();
}
const cards = useMemo(
() =>
agentList.map((item) => ({
id: String(item.id),
title: item.name || '未命名专家',
desc: item.description || '暂无描述',
status: item.is_draft ? '草稿' : '已发布',
avatarSvg: item.avatar_url,
raw: item,
})),
[agentList],
);
return (
<div className={`w-full min-h-[calc(100vh-140px)] ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
<div className="max-w-[1100px] mx-auto">
<section className={`rounded-2xl border px-6 py-6 ${isDark ? 'bg-slate-900/60 border-slate-700' : 'bg-white border-gray-200'}`}>
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-fuchsia-500 to-violet-600 flex items-center justify-center text-white shadow">
<Sparkles className="w-5 h-5" />
</div>
<h1 className="text-3xl font-bold tracking-tight">我的智能体</h1>
</div>
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>管理你的专属 AI 专家助手</p>
<p className={`mt-1 text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>已加载模型:{llmList.length}</p>
<div className="mt-4 inline-flex items-center rounded-lg border overflow-hidden">
<button
onClick={() => setActiveTab('mine')}
className={`px-3 py-1.5 text-xs ${activeTab === 'mine' ? 'bg-slate-900 text-white' : isDark ? 'bg-slate-800/60 text-slate-300' : 'bg-gray-50 text-gray-600'}`}
>
我的智能体
</button>
<button
onClick={() => setActiveTab('published')}
className={`px-3 py-1.5 text-xs ${activeTab === 'published' ? 'bg-slate-900 text-white' : isDark ? 'bg-slate-800/60 text-slate-300' : 'bg-gray-50 text-gray-600'}`}
>
发布的智能体
</button>
</div>
</div>
<button className="inline-flex items-center gap-2 h-10 px-4 rounded-full bg-slate-900 text-white hover:bg-slate-800 transition" onClick={addAgentSearch}>
<Plus className="w-4 h-4" /> 创建智能体
</button>
</div>
</section>
<section className="mt-6">
{isListLoading ? (
<div className={`min-h-[280px] rounded-2xl border flex items-center justify-center ${isDark ? 'bg-slate-900/40 border-slate-700' : 'bg-white border-gray-200'}`}>
<Loading loading text="加载专家列表中..." />
</div>
) : cards.length === 0 ? (
<div className={`min-h-[280px] rounded-2xl border flex flex-col items-center justify-center text-center px-4 ${isDark ? 'bg-slate-900/40 border-slate-700' : 'bg-white border-gray-200'}`}>
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center mb-3 ${isDark ? 'bg-slate-800 text-slate-300' : 'bg-gray-100 text-gray-500'}`}>
<BriefcaseBusiness className="w-6 h-6" />
</div>
<h3 className={`text-base font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
{activeTab === 'mine' ? '暂无我的专家' : '暂无发布的专家'}
</h3>
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
创建你的第一个专家助手,开始高效工作
</p>
<button className="mt-4 inline-flex items-center gap-2 h-10 px-4 rounded-xl bg-slate-900 text-white hover:bg-slate-800 transition">
<Plus className="w-4 h-4" /> 创建第一个专家
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{cards.map((item, idx) => (
<article
key={item.id}
className={`rounded-2xl border p-5 transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 ${isDark ? 'bg-slate-900/60 border-slate-700 hover:border-slate-500' : 'bg-white border-gray-200 hover:border-gray-300'}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
{item.avatarSvg ? (
<img
src={`data:image/svg+xml;utf8,${encodeURIComponent(item.avatarSvg)}`}
alt={item.title}
className="w-10 h-10 rounded-xl object-cover flex-shrink-0"
/>
) : (
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-violet-500 to-purple-600 flex items-center justify-center text-white shadow-sm flex-shrink-0">
{idx % 2 === 0 ? <MessageCircle className="w-5 h-5" /> : <Bot className="w-5 h-5" />}
</div>
)}
<div className="min-w-0">
<h3 className="font-semibold text-[15px] leading-6 truncate">{item.title}</h3>
<p className={`text-xs mt-1 line-clamp-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{item.desc}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{activeTab === 'mine' && (
<>
<button
type="button"
title="查看详情"
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border transition ${isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-700/60' : 'border-gray-200 text-gray-500 hover:bg-gray-100'}`}
onClick={() => {
setSelectedAgent(item.raw);
setDetailVisible(true);
}}
>
<Info className="w-4 h-4 shrink-0" />
</button>
<button
type="button"
title="编辑"
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border transition ${isDark ? 'border-slate-600 text-slate-300 hover:bg-slate-700/60' : 'border-gray-200 text-gray-500 hover:bg-gray-100'}`}
onClick={() => {
navigate(`/agent-expert-edit?id=${item.id}`);
}}
>
<Edit className="w-4 h-4 shrink-0" />
</button>
<Popconfirm
content="确认删除该条数据吗?"
onConfirm={() => delAgentSearch(item.id)}
>
<button
type="button"
title="删除"
className={`inline-flex items-center justify-center w-7 h-7 rounded-md border transition ${isDark ? 'border-slate-600 text-slate-300 hover:bg-rose-900/20 hover:text-rose-300' : 'border-gray-200 text-gray-500 hover:bg-rose-50 hover:text-rose-500'}`}
>
<Trash2 className="w-4 h-4 shrink-0" />
</button>
</Popconfirm>
</>
)}
<span className={`inline-flex items-center gap-1.5 text-xs px-2 py-1 rounded-full border ${isDark ? 'bg-emerald-950/50 border-emerald-800 text-emerald-300' : 'bg-emerald-50 border-emerald-200 text-emerald-700'}`}>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500" />
{item.status}
</span>
</div>
</div>
<button
onClick={() => navigate(`/agent-chat?id=${item.id}`)}
className={`mt-6 inline-flex items-center gap-1 text-sm ${isDark ? 'text-slate-300 hover:text-white' : 'text-gray-700 hover:text-gray-900'}`}
>
进行对话
<ArrowRight className="w-4 h-4" />
</button>
</article>
))}
</div>
)}
</section>
<section className={`mt-6 rounded-2xl border px-6 py-6 flex flex-col md:flex-row md:items-center md:justify-between gap-4 ${isDark ? 'bg-violet-950/30 border-violet-800/50' : 'bg-violet-50 border-violet-100'}`}>
<div>
<h3 className="text-xl font-semibold">不确定从哪个开始?</h3>
<p className={`mt-2 text-sm ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>直接开始对话,AI 会自动匹配最合适的专家助手</p>
</div>
<button className="inline-flex items-center justify-center gap-2 h-11 px-6 rounded-xl bg-gradient-to-r from-fuchsia-500 to-violet-600 text-white font-medium hover:opacity-95 transition">
开始对话
<ArrowRight className="w-4 h-4" />
</button>
</section>
<Dialog
visible={detailVisible}
onClose={() => setDetailVisible(false)}
confirmBtn={null}
cancelBtn={null}
header="专家详情"
width={520}
>
<div className="py-2">
<div className="flex flex-col items-center text-center">
{selectedAgent?.avatar_url ? (
<img
src={`data:image/svg+xml;utf8,${encodeURIComponent(selectedAgent.avatar_url)}`}
alt={selectedAgent?.name || '专家头像'}
className="w-20 h-20 rounded-2xl object-cover"
/>
) : (
<div className="w-20 h-20 rounded-2xl bg-gray-100" />
)}
<h4 className="mt-4 text-2xl font-semibold text-gray-900">{selectedAgent?.name || '未命名专家'}</h4>
<p className="mt-2 text-sm text-gray-400">{selectedAgent?.description || '暂无描述'}</p>
</div>
<div className="mt-5 rounded-xl bg-gray-50 px-3 py-3 flex items-center justify-between">
<div className="inline-flex items-center gap-2 text-sm text-gray-500">
{selectedAgent?.is_draft ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
{selectedAgent?.is_draft ? '草稿状态' : '已发布'}
</div>
<Switch
value={!selectedAgent?.is_draft}
loading={isStatusUpdating}
disabled={isStatusUpdating}
onChange={(value) => {
void handleStatusToggle(value as boolean);
}}
/>
</div>
<button className="mt-4 w-full h-10 rounded-lg bg-gradient-to-r from-fuchsia-500 to-violet-600 text-white inline-flex items-center justify-center gap-2">
<MessageCircleMore className="w-4 h-4" />
开始聊天
</button>
</div>
</Dialog>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,130 @@
import type { KeyboardEvent, ReactNode } from 'react';
import { Send, Square, Upload } from 'lucide-react';
import { Textarea } from 'tdesign-react';
import { useRef } from 'react';
interface ChatInputComposerProps {
value: string;
onChange?: (value: string) => void;
onSend?: () => void;
onStop?: () => void;
onKeyDown?: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
placeholder?: string;
disabled?: boolean;
isSending?: boolean;
isDarkMode: boolean;
trailingActions?: ReactNode;
readOnly?: boolean;
className?: string;
showBorderTop?: boolean;
showUpload?: boolean;
onUploadFile?: (file: File) => void | Promise<void>;
}
export default function ChatInputComposer({
value,
onChange,
onSend,
onStop,
onKeyDown,
placeholder = '输入你的问题...',
disabled = false,
isSending = false,
isDarkMode,
trailingActions,
readOnly = false,
className = '',
showBorderTop = false,
showUpload = true,
onUploadFile,
}: ChatInputComposerProps) {
const submitEnabled = Boolean(value.trim()) && !disabled && !readOnly && Boolean(onSend);
const inputRef = useRef<HTMLInputElement | null>(null);
const iconButtonClass = `rounded-lg p-2 transition-colors ${
isDarkMode ? 'text-slate-400 hover:bg-slate-600 hover:text-slate-200' : 'text-gray-500 hover:bg-gray-200 hover:text-gray-700'
}`;
const handleKeyDown = (val, context) => {
if (context?.e?.key === 'Enter') {
// 阻止默认的换行行为(如果是 Textarea)
context.e.preventDefault();
// 模拟发送请求
onSend()
}
};
return (
<div className={`${showBorderTop ? (isDarkMode ? 'border-t border-slate-700/50' : 'border-t border-red-100') : ''} p-4 ${className}`}>
<div className={`flex items-end gap-3 rounded-2xl p-3 shadow-inner ${isDarkMode ? 'bg-slate-700/50' : 'bg-gray-100'}`}>
<Textarea
value={value}
onChange={(nextValue) => onChange?.(String(nextValue))}
onKeydown={handleKeyDown}
placeholder={placeholder}
autosize={{ minRows: 1, maxRows: 8 }}
disabled={disabled}
readonly={readOnly}
className={`flex-1 !bg-transparent !border-0 !shadow-none qa-composer-textarea [&]:!border-0 [&]:!shadow-none [&]:!outline-none [&]:focus:!border-0 [&]:focus:!shadow-none [&]:focus:!outline-none [&_.t-textarea]:!border-0 [&_.t-textarea]:!shadow-none [&_.t-textarea]:!outline-none [&_.t-textarea:hover]:!border-0 [&_.t-textarea:hover]:!shadow-none [&_.t-textarea--focused]:!border-0 [&_.t-textarea--focused]:!shadow-none [&_.t-is-focused]:!border-0 [&_.t-is-focused]:!shadow-none [&_.t-textarea__inner]:!min-h-[40px] [&_.t-textarea__inner]:!resize-none [&_.t-textarea__inner]:!bg-transparent [&_.t-textarea__inner]:!border-0 [&_.t-textarea__inner]:!p-0 [&_.t-textarea__inner]:!shadow-none [&_.t-textarea__inner]:!outline-none [&_.t-textarea__inner:hover]:!border-0 [&_.t-textarea__inner:hover]:!shadow-none [&_.t-textarea__inner:focus]:!border-0 [&_.t-textarea__inner:focus]:!shadow-none [&_.t-textarea__inner:focus]:!outline-none [&_.t-textarea__inner]:disabled:!cursor-not-allowed ${
isDarkMode
? '[&_.t-textarea__inner]:!text-white [&_.t-textarea__inner]:placeholder:!text-slate-500'
: '[&_.t-textarea__inner]:!text-gray-800 [&_.t-textarea__inner]:placeholder:!text-gray-400'
}`}
/>
<div className="flex items-center gap-2">
{showUpload && (
<>
<input
ref={inputRef}
type="file"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
void onUploadFile?.(file);
}
event.target.value = '';
}}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={disabled || !onUploadFile}
className={`${iconButtonClass} ${(disabled || !onUploadFile) ? 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-inherit' : ''}`}
title="上传文件"
>
<Upload className="h-5 w-5" />
</button>
</>
)}
{trailingActions && <div className="flex items-center gap-2">{trailingActions}</div>}
<button
onClick={isSending ? onStop : onSend}
disabled={!isSending && !submitEnabled}
className={`flex items-center gap-2 rounded-xl px-4 py-2 font-medium shadow-sm transition-all ${
isSending
? isDarkMode
? 'bg-rose-600 text-white'
: 'bg-red-600 text-white'
: submitEnabled
? isDarkMode
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-red-500 text-white hover:bg-red-600'
: isDarkMode
? 'cursor-not-allowed bg-slate-600 text-slate-400'
: 'cursor-not-allowed bg-gray-300 text-gray-500'
}`}
type="button"
title={isSending ? '停止' : '发送'}
>
{isSending ? <Square className="h-4 w-4 fill-current" /> : <Send className="h-4 w-4" />}
{isSending ? '停止' : '发送'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,770 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
FileCode2,
FileText,
MoreVertical,
Pencil,
Plus,
Search,
Sparkles,
Trash2,
Upload,
X,
} from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
interface FileSkill {
id: string;
folder: string;
name: string;
description: string;
kind: 'md' | 'py';
filename: string;
created_at: number;
updated_at: number;
}
const API_BASE = 'https://ch1.b.uat.4.cn/zncmback/api/skills/files';
export default function FileSkillsWorkshop() {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [skills, setSkills] = useState<FileSkill[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState<FileSkill | null>(null);
const [openCardMenuId, setOpenCardMenuId] = useState<string | null>(null);
const cardMenuRef = useRef<HTMLDivElement | null>(null);
const reload = async () => {
setLoading(true);
setError('');
try {
const res = await fetch(API_BASE);
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
const data = (await res.json()) as FileSkill[];
setSkills(data);
} catch (err) {
console.error('加载技能失败', err);
setError('加载技能失败,请稍后重试');
setSkills([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
void reload();
}, []);
useEffect(() => {
const handle = (event: MouseEvent) => {
const target = event.target as Node;
if (cardMenuRef.current && !cardMenuRef.current.contains(target)) {
setOpenCardMenuId(null);
}
};
document.addEventListener('mousedown', handle);
return () => document.removeEventListener('mousedown', handle);
}, []);
const filtered = useMemo(() => {
const keyword = searchTerm.trim().toLowerCase();
if (!keyword) return skills;
return skills.filter((s) =>
`${s.name} ${s.description} ${s.filename}`.toLowerCase().includes(keyword),
);
}, [searchTerm, skills]);
const handleDelete = async (skill: FileSkill) => {
setOpenCardMenuId(null);
if (!window.confirm(`确认删除技能「${skill.name}」?`)) return;
try {
const res = await fetch(`${API_BASE}/${encodeURIComponent(skill.folder)}`, {
method: 'DELETE',
});
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
setSkills((prev) => prev.filter((s) => s.folder !== skill.folder));
} catch (err) {
console.error('删除技能失败', err);
setError('删除技能失败');
}
};
return (
<div
className={`w-full min-h-[calc(100vh-140px)] ${
isDark ? 'bg-slate-950 text-slate-100' : 'bg-[#fcfcfd] text-[#101828]'
}`}
>
{createOpen && (
<CreateSkillModal
isDark={isDark}
onClose={() => setCreateOpen(false)}
onCreated={() => {
setCreateOpen(false);
void reload();
}}
/>
)}
{editing && (
<EditSkillModal
isDark={isDark}
skill={editing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null);
void reload();
}}
/>
)}
<div className="mx-auto max-w-[1360px] px-6 py-8">
<section className="mb-7 flex items-start justify-between gap-5">
<div>
<h1
className={`text-[22px] font-bold tracking-tight ${
isDark ? 'text-slate-100' : 'text-[#111827]'
}`}
>
技能管理
</h1>
<p className={`mt-2 text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>
上传 Markdown 提示词或 Python 脚本作为技能,供 MCP 与模型调用。
</p>
</div>
<button
type="button"
onClick={() => setCreateOpen(true)}
className="inline-flex h-11 items-center gap-2 rounded-xl bg-[#ea2d2d] px-5 text-sm font-medium text-white shadow-[0_10px_20px_-12px_rgba(234,45,45,0.7)] transition hover:bg-[#d92626]"
>
<Plus className="h-4 w-4" />
新建技能
</button>
</section>
<section
className={`mb-8 rounded-[28px] border px-6 py-5 ${
isDark ? 'border-slate-800 bg-slate-900/65' : 'border-[#f5d4d4] bg-white'
}`}
>
<div
className={`flex items-center gap-3 rounded-2xl border px-4 py-3 ${
isDark ? 'border-slate-700 bg-slate-950/50' : 'border-[#f3b6b6] bg-white'
}`}
>
<Search className={`h-4 w-4 ${isDark ? 'text-slate-400' : 'text-[#ff6b6b]'}`} />
<input
type="text"
placeholder="搜索技能名称 / 描述..."
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
className={`min-w-0 flex-1 bg-transparent text-sm outline-none ${
isDark
? 'text-slate-100 placeholder:text-slate-500'
: 'text-gray-800 placeholder:text-[#9ca3af]'
}`}
/>
</div>
</section>
<section>
{loading ? (
<div
className={`flex items-center justify-center rounded-[28px] border py-20 text-sm ${
isDark
? 'border-slate-800 bg-slate-900/60 text-slate-300'
: 'border-[#f5d4d4] bg-white text-[#667085]'
}`}
>
正在加载技能...
</div>
) : error ? (
<div
className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${
isDark ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'
}`}
>
<h3
className={`mb-2 text-lg font-semibold ${
isDark ? 'text-slate-100' : 'text-gray-900'
}`}
>
加载失败
</h3>
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>{error}</p>
</div>
) : filtered.length === 0 ? (
<div
className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${
isDark ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'
}`}
>
<div
className={`mb-4 flex h-12 w-12 items-center justify-center rounded-2xl ${
isDark ? 'bg-slate-800 text-slate-400' : 'bg-[#fff1f1] text-[#ff6b6b]'
}`}
>
<Sparkles className="h-6 w-6" />
</div>
<h3
className={`mb-2 text-lg font-semibold ${
isDark ? 'text-slate-100' : 'text-gray-900'
}`}
>
暂无技能
</h3>
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-[#667085]'}`}>
点击右上角「新建技能」上传你的第一个技能。
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{filtered.map((skill) => {
const isPy = skill.kind === 'py';
const Icon = isPy ? FileCode2 : FileText;
return (
<article
key={skill.folder}
className={`group relative overflow-hidden rounded-[22px] border px-5 py-5 transition-all duration-200 ${
isDark
? 'border-slate-800 bg-slate-900/65 hover:border-slate-700 hover:shadow-xl hover:shadow-black/20'
: 'border-[#f5d4d4] bg-white hover:border-[#ffbdbd] hover:shadow-[0_16px_40px_-26px_rgba(239,68,68,0.35)]'
}`}
>
<div
className={`pointer-events-none absolute inset-x-0 top-0 h-1 ${
isDark
? 'bg-gradient-to-r from-slate-700 via-slate-500 to-slate-700'
: 'bg-gradient-to-r from-[#ffd4d4] via-[#ff8c8c] to-[#ffd4d4]'
}`}
/>
<div
className="absolute right-4 top-4 z-20"
ref={openCardMenuId === skill.folder ? cardMenuRef : null}
>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setOpenCardMenuId((prev) =>
prev === skill.folder ? null : skill.folder,
);
}}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
isDark
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
: 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
}`}
>
<MoreVertical className="h-4 w-4" />
</button>
{openCardMenuId === skill.folder && (
<div
className={`absolute right-0 top-[calc(100%+6px)] z-30 w-36 rounded-2xl border py-1 shadow-xl ${
isDark ? 'border-slate-700 bg-slate-900' : 'border-[#f0d6d6] bg-white'
}`}
>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setOpenCardMenuId(null);
setEditing(skill);
}}
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${
isDark
? 'text-slate-300 hover:bg-slate-800'
: 'text-gray-700 hover:bg-[#fff6f6]'
}`}
>
<Pencil className="h-4 w-4 shrink-0" />
编辑
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void handleDelete(skill);
}}
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${
isDark
? 'text-rose-400 hover:bg-slate-800'
: 'text-rose-500 hover:bg-[#fff6f6]'
}`}
>
<Trash2 className="h-4 w-4 shrink-0" />
删除
</button>
</div>
)}
</div>
<div className="flex items-start gap-3 pr-8">
<div
className={`relative flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br shadow-[0_18px_32px_-22px_rgba(15,23,42,0.35)] ring-1 ${
isPy
? 'from-indigo-100 via-blue-50 to-white text-indigo-500 shadow-indigo-100/80 ring-indigo-100'
: 'from-rose-100 via-red-50 to-white text-red-500 shadow-red-100/80 ring-red-100'
}`}
>
<Icon className="relative z-10 h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<h3
className={`truncate text-[17px] font-semibold ${
isDark ? 'text-slate-100' : 'text-[#111827]'
}`}
title={skill.name}
>
{skill.name}
</h3>
<p
className={`mt-1 line-clamp-3 min-h-[3.6em] text-sm ${
isDark ? 'text-slate-400' : 'text-[#667085]'
}`}
>
{skill.description || '暂无描述'}
</p>
</div>
</div>
<div
className={`mt-4 flex items-center justify-between text-xs ${
isDark ? 'text-slate-500' : 'text-[#98a2b3]'
}`}
>
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 font-medium ${
isPy
? 'bg-indigo-50 text-indigo-600'
: 'bg-[#fff2f2] text-[#ef4444]'
}`}
>
{isPy ? 'Python' : 'Markdown'}
</span>
<span>{new Date(skill.updated_at * 1000).toLocaleString()}</span>
</div>
</article>
);
})}
</div>
)}
</section>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Create modal
// ---------------------------------------------------------------------------
interface CreateModalProps {
isDark: boolean;
onClose: () => void;
onCreated: () => void;
}
function CreateSkillModal({ isDark, onClose, onCreated }: CreateModalProps) {
const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const [errMsg, setErrMsg] = useState('');
const inputRef = useRef<HTMLInputElement | null>(null);
const handleFileChange = (next: File | null) => {
setFile(next);
if (next && !name.trim()) {
const stem = next.name.replace(/\.[^.]+$/, '');
setName(stem);
}
};
const submit = async () => {
if (!file) {
setErrMsg('请上传 .md 或 .py 文件');
return;
}
setErrMsg('');
setSubmitting(true);
try {
const fd = new FormData();
fd.append('file', file);
if (name.trim()) fd.append('name', name.trim());
if (description.trim()) fd.append('description', description.trim());
const res = await fetch(API_BASE, { method: 'POST', body: fd });
if (!res.ok) {
const text = await res.text();
throw new Error(text || `${res.status} ${res.statusText}`);
}
onCreated();
} catch (err) {
console.error('新建技能失败', err);
setErrMsg(err instanceof Error ? err.message : '新建技能失败');
} finally {
setSubmitting(false);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div
className={`relative w-[480px] overflow-hidden rounded-[28px] border shadow-2xl ${
isDark
? 'border-slate-700 bg-slate-900 text-slate-100'
: 'border-[#f3d6d6] bg-white text-gray-900'
}`}
>
<div
className={`h-1 w-full ${
isDark
? 'bg-gradient-to-r from-slate-700 via-slate-500 to-slate-700'
: 'bg-gradient-to-r from-[#ffd4d4] via-[#ff8c8c] to-[#ffd4d4]'
}`}
/>
<div className="flex items-center justify-between px-6 pb-3 pt-5">
<h2 className="text-base font-semibold">新建技能</h2>
<button
type="button"
onClick={onClose}
className={`flex h-9 w-9 items-center justify-center rounded-xl transition ${
isDark
? 'text-slate-400 hover:bg-slate-700 hover:text-slate-100'
: 'text-gray-400 hover:bg-[#fff3f3] hover:text-[#ef4444]'
}`}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-4 px-6 pb-2 pt-2">
<div>
<label
className={`mb-1 block text-xs font-medium ${
isDark ? 'text-slate-400' : 'text-[#667085]'
}`}
>
技能文件(.md 或 .py)
</label>
<input
ref={inputRef}
type="file"
accept=".md,.py"
className="hidden"
onChange={(event) => handleFileChange(event.target.files?.[0] ?? null)}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
className={`flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition ${
isDark
? 'border-slate-700 bg-slate-950/40 hover:bg-slate-800'
: 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'
}`}
>
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow ring-1 ring-white/20">
<Upload className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p
className={`truncate text-sm font-medium ${
isDark ? 'text-slate-100' : 'text-gray-900'
}`}
>
{file ? file.name : '选择文件'}
</p>
<p className={`mt-0.5 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
支持 .md 提示词 或 .py 可执行脚本
</p>
</div>
</button>
</div>
<div>
<label
className={`mb-1 block text-xs font-medium ${
isDark ? 'text-slate-400' : 'text-[#667085]'
}`}
>
技能名称
</label>
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="默认使用上传文件名"
className={`w-full rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
isDark
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
}`}
/>
</div>
<div>
<label
className={`mb-1 block text-xs font-medium ${
isDark ? 'text-slate-400' : 'text-[#667085]'
}`}
>
描述(可选)
</label>
<textarea
rows={3}
value={description}
onChange={(event) => setDescription(event.target.value)}
placeholder="技能用途、输入输出说明等"
className={`w-full resize-none rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
isDark
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
}`}
/>
</div>
{errMsg && (
<p className="text-xs text-rose-500">{errMsg}</p>
)}
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-5 pt-4">
<button
type="button"
onClick={onClose}
className={`rounded-xl px-4 py-2 text-sm transition ${
isDark
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
: 'text-[#667085] hover:bg-[#fff3f3] hover:text-[#ef4444]'
}`}
>
取消
</button>
<button
type="button"
disabled={submitting}
onClick={() => void submit()}
className={`rounded-xl bg-[#ea2d2d] px-4 py-2 text-sm font-medium text-white shadow transition hover:bg-[#d92626] ${
submitting ? 'cursor-not-allowed opacity-60' : ''
}`}
>
{submitting ? '提交中...' : '创建'}
</button>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Edit modal
// ---------------------------------------------------------------------------
interface EditModalProps {
isDark: boolean;
skill: FileSkill;
onClose: () => void;
onSaved: () => void;
}
function EditSkillModal({ isDark, skill, onClose, onSaved }: EditModalProps) {
const [name, setName] = useState(skill.name);
const [description, setDescription] = useState(skill.description ?? '');
const [file, setFile] = useState<File | null>(null);
const [submitting, setSubmitting] = useState(false);
const [errMsg, setErrMsg] = useState('');
const inputRef = useRef<HTMLInputElement | null>(null);
const submit = async () => {
setErrMsg('');
setSubmitting(true);
try {
const fd = new FormData();
if (file) fd.append('file', file);
if (name.trim() !== skill.name) fd.append('name', name.trim());
if (description !== (skill.description ?? '')) fd.append('description', description);
const res = await fetch(`${API_BASE}/${encodeURIComponent(skill.folder)}`, {
method: 'PUT',
body: fd,
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `${res.status} ${res.statusText}`);
}
onSaved();
} catch (err) {
console.error('保存技能失败', err);
setErrMsg(err instanceof Error ? err.message : '保存技能失败');
} finally {
setSubmitting(false);
}
};
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div
className={`relative w-[480px] overflow-hidden rounded-[28px] border shadow-2xl ${
isDark
? 'border-slate-700 bg-slate-900 text-slate-100'
: 'border-[#f3d6d6] bg-white text-gray-900'
}`}
>
<div
className={`h-1 w-full ${
isDark
? 'bg-gradient-to-r from-slate-700 via-slate-500 to-slate-700'
: 'bg-gradient-to-r from-[#ffd4d4] via-[#ff8c8c] to-[#ffd4d4]'
}`}
/>
<div className="flex items-center justify-between px-6 pb-3 pt-5">
<h2 className="text-base font-semibold">编辑技能</h2>
<button
type="button"
onClick={onClose}
className={`flex h-9 w-9 items-center justify-center rounded-xl transition ${
isDark
? 'text-slate-400 hover:bg-slate-700 hover:text-slate-100'
: 'text-gray-400 hover:bg-[#fff3f3] hover:text-[#ef4444]'
}`}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-4 px-6 pb-2 pt-2">
<div>
<label
className={`mb-1 block text-xs font-medium ${
isDark ? 'text-slate-400' : 'text-[#667085]'
}`}
>
技能名称
</label>
<input
type="text"
value={name}
onChange={(event) => setName(event.target.value)}
className={`w-full rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
isDark
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
}`}
/>
</div>
<div>
<label
className={`mb-1 block text-xs font-medium ${
isDark ? 'text-slate-400' : 'text-[#667085]'
}`}
>
描述
</label>
<textarea
rows={3}
value={description}
onChange={(event) => setDescription(event.target.value)}
className={`w-full resize-none rounded-2xl border px-4 py-2.5 text-sm outline-none transition ${
isDark
? 'border-slate-700 bg-slate-950/40 text-slate-100 placeholder:text-slate-500 focus:border-slate-500'
: 'border-[#f5d4d4] bg-white text-gray-900 placeholder:text-gray-400 focus:border-[#ffbdbd]'
}`}
/>
</div>
<div>
<label
className={`mb-1 block text-xs font-medium ${
isDark ? 'text-slate-400' : 'text-[#667085]'
}`}
>
覆盖文件(可选,选择后将替换原有内容)
</label>
<input
ref={inputRef}
type="file"
accept=".md,.py"
className="hidden"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
className={`flex w-full items-center gap-3 rounded-2xl border px-4 py-3 text-left transition ${
isDark
? 'border-slate-700 bg-slate-950/40 hover:bg-slate-800'
: 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'
}`}
>
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow ring-1 ring-white/20">
<Upload className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p
className={`truncate text-sm font-medium ${
isDark ? 'text-slate-100' : 'text-gray-900'
}`}
>
{file ? file.name : `当前文件:${skill.filename}`}
</p>
<p className={`mt-0.5 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
仅支持 .md 或 .py
</p>
</div>
</button>
</div>
{errMsg && <p className="text-xs text-rose-500">{errMsg}</p>}
</div>
<div className="flex items-center justify-end gap-3 px-6 pb-5 pt-4">
<button
type="button"
onClick={onClose}
className={`rounded-xl px-4 py-2 text-sm transition ${
isDark
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-200'
: 'text-[#667085] hover:bg-[#fff3f3] hover:text-[#ef4444]'
}`}
>
取消
</button>
<button
type="button"
disabled={submitting}
onClick={() => void submit()}
className={`rounded-xl bg-[#ea2d2d] px-4 py-2 text-sm font-medium text-white shadow transition hover:bg-[#d92626] ${
submitting ? 'cursor-not-allowed opacity-60' : ''
}`}
>
{submitting ? '保存中...' : '保存'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,246 @@
import { useEffect, useState } from 'react';
import { Bot, CheckCircle2, ChevronDown, Info, Sparkles, UserRound, Wrench } from 'lucide-react';
import MarkdownRenderer from '../MarkdownRenderer';
import { useTheme } from '../../contexts/ThemeContext';
interface ConversationStep {
text: string;
subText?: string;
status: 'done' | 'processing';
}
interface ToolCall {
id: string;
name: string;
input?: Record<string, unknown>;
output?: string;
}
interface ConversationMessage {
id: string;
role: 'user' | 'assistant';
content: string;
steps?: ConversationStep[];
thinkings?: Array<{ duration: number; content: string }>;
toolCalls?: ToolCall[];
}
interface SkillCreatorConversationItemProps {
message: ConversationMessage;
isConnecting?: boolean;
}
function MessageAvatar({ role, active = false }: { role: 'user' | 'assistant'; active?: boolean }) {
const { theme } = useTheme();
const isDarkMode = theme === 'dark';
const Icon = role === 'assistant' ? Bot : UserRound;
return (
<div
className={`relative w-9 h-9 rounded-2xl flex items-center justify-center flex-shrink-0 shadow-sm ${
role === 'assistant'
? isDarkMode
? 'bg-gradient-to-br from-slate-700 to-slate-900 text-sky-200 ring-1 ring-slate-600'
: 'bg-gradient-to-br from-red-50 to-rose-100 text-red-500 ring-1 ring-red-100'
: isDarkMode
? 'bg-gradient-to-br from-blue-500 to-indigo-600 text-white'
: 'bg-gradient-to-br from-red-500 to-rose-500 text-white'
}`}
>
<Icon className="w-4 h-4" />
{role === 'assistant' && active && (
<Sparkles className={`absolute -right-0.5 -top-0.5 w-3.5 h-3.5 ${isDarkMode ? 'text-sky-200' : 'text-amber-400'}`} />
)}
</div>
);
}
function StepsSummary({ steps }: { steps: ConversationStep[] }) {
const { theme } = useTheme();
const isDarkMode = theme === 'dark';
const [open, setOpen] = useState(true);
const doneCount = steps.filter((step) => step.status === 'done').length;
return (
<div
className={`mb-3 overflow-hidden rounded-2xl border shadow-sm ${
isDarkMode ? 'border-slate-700 bg-slate-900/60 text-slate-300' : 'border-red-100 bg-white text-gray-600'
}`}
>
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className={`w-full h-10 px-3.5 flex items-center gap-2 text-xs transition ${
isDarkMode ? 'bg-slate-800/70 hover:bg-slate-800' : 'bg-red-50/60 hover:bg-red-50'
}`}
>
<CheckCircle2 className={`w-3.5 h-3.5 ${isDarkMode ? 'text-emerald-400' : 'text-red-500'}`} />
<span className="font-medium">执行步骤</span>
<span className={isDarkMode ? 'text-slate-500' : 'text-gray-400'}>已完成 {doneCount} / {steps.length}</span>
<ChevronDown className={`w-3.5 h-3.5 ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
</button>
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${open ? 'max-h-[640px] opacity-100' : 'max-h-0 opacity-0'}`}>
<div className={`border-t px-4 py-3 space-y-3 ${isDarkMode ? 'border-slate-700' : 'border-red-50'}`}>
{steps.map((step, index) => {
const done = step.status === 'done';
return (
<div key={`${step.text}-${index}`} className="flex gap-3">
<div className="pt-0.5">
{done ? (
<CheckCircle2 className={`w-4 h-4 ${isDarkMode ? 'text-emerald-400' : 'text-red-500'}`} />
) : (
<span className={`block w-4 h-4 rounded-full border-2 ${isDarkMode ? 'border-sky-300 border-t-transparent' : 'border-red-400 border-t-transparent'} animate-spin`} />
)}
</div>
<div className="min-w-0 flex-1">
<div className={`text-xs font-medium ${isDarkMode ? 'text-slate-200' : 'text-gray-800'}`}>
第 {index + 1} 步:{step.text}
</div>
{step.subText && (
<div className={`mt-1 rounded-xl px-3 py-2 text-xs leading-5 ${isDarkMode ? 'bg-slate-950/50 text-slate-400' : 'bg-gray-50 text-gray-600'}`}>
{step.subText}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
function ThinkingPanel({ duration, content }: { duration: number; content: string }) {
const { theme } = useTheme();
const isDarkMode = theme === 'dark';
const [open, setOpen] = useState(true);
useEffect(() => {
if (duration > 0) setOpen(false);
}, [duration]);
return (
<div className={`mb-3 rounded-2xl border overflow-hidden shadow-sm ${isDarkMode ? 'border-slate-700 bg-slate-900/60' : 'border-red-100 bg-white'}`}>
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className={`w-full h-10 px-3.5 flex items-center gap-2 text-xs transition ${
isDarkMode ? 'text-slate-300 bg-slate-800/70 hover:bg-slate-800' : 'text-gray-600 bg-red-50/60 hover:bg-red-50'
}`}
>
<Info className={`w-3.5 h-3.5 ${isDarkMode ? 'text-sky-300' : 'text-red-500'}`} />
<span className="font-medium">思考过程</span>
<ChevronDown className={`w-3.5 h-3.5 ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />
</button>
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${open ? 'opacity-100' : 'max-h-0 opacity-0'}`}>
<div className={`px-4 py-3 text-xs leading-6 whitespace-pre-wrap border-t ${isDarkMode ? 'border-slate-700 text-slate-300' : 'border-red-50 text-gray-600'}`}>
{content}
</div>
</div>
</div>
);
}
function ToolCallPanel({ toolCall }: { toolCall: ToolCall }) {
const { theme } = useTheme();
const isDarkMode = theme === 'dark';
const [open, setOpen] = useState(false);
const details = {
input: toolCall.input && Object.keys(toolCall.input).length > 0 ? JSON.stringify(toolCall.input, null, 2) : '',
output: toolCall.output ?? '',
};
const hasDetails = Boolean(details.input || details.output);
return (
<div className={`mb-3 rounded-2xl border overflow-hidden shadow-sm ${isDarkMode ? 'border-slate-700 bg-slate-900/60' : 'border-red-100 bg-white'}`}>
<button
type="button"
onClick={() => setOpen((prev) => !prev)}
className={`w-full h-10 px-3.5 flex items-center gap-2 text-xs transition ${
isDarkMode ? 'text-slate-300 bg-slate-800/70 hover:bg-slate-800' : 'text-gray-600 bg-red-50/60 hover:bg-red-50'
}`}
>
<Wrench className={`w-3.5 h-3.5 ${isDarkMode ? 'text-amber-300' : 'text-red-500'}`} />
<span className="font-medium">{toolCall.name}</span>
{hasDetails && <ChevronDown className={`w-3.5 h-3.5 ml-auto transition-transform ${open ? '' : '-rotate-90'}`} />}
</button>
{hasDetails && (
<div className={`overflow-hidden transition-all duration-300 ease-in-out ${open ? 'max-h-[520px] opacity-100' : 'max-h-0 opacity-0'}`}>
<div className={`border-t px-4 py-3 space-y-3 ${isDarkMode ? 'border-slate-700' : 'border-red-50'}`}>
{details.input && (
<div>
<p className={`text-[11px] mb-1 ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>请求参数</p>
<pre className={`rounded-xl px-3 py-2 text-[11px] leading-5 overflow-x-auto ${isDarkMode ? 'bg-slate-950/60 text-slate-300' : 'bg-gray-50 text-gray-700'}`}>{details.input}</pre>
</div>
)}
{details.output && (
<div>
<p className={`text-[11px] mb-1 ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>返回结果</p>
<pre className={`rounded-xl px-3 py-2 text-[11px] leading-5 overflow-x-auto ${isDarkMode ? 'bg-slate-950/60 text-slate-300' : 'bg-gray-50 text-gray-700'}`}>{details.output}</pre>
</div>
)}
</div>
</div>
)}
</div>
);
}
export default function SkillCreatorConversationItem({ message, isConnecting = false }: SkillCreatorConversationItemProps) {
const { theme } = useTheme();
const isDarkMode = theme === 'dark';
if (message.role === 'user') {
return (
<div className="flex justify-end gap-3">
<div className={`max-w-[72%] rounded-2xl rounded-tr-md px-4 py-3 text-sm leading-relaxed shadow-sm ${
isDarkMode ? 'bg-blue-600/80 text-white shadow-blue-950/20' : 'bg-gradient-to-r from-red-500 to-rose-500 text-white shadow-red-200/70'
}`}>
{message.content}
</div>
<MessageAvatar role="user" />
</div>
);
}
return (
<div className="flex gap-3">
<MessageAvatar role="assistant" active={!message.content} />
<div
className={`max-w-[86%] flex-1 rounded-3xl border px-4 py-3.5 text-sm shadow-sm ${
isDarkMode ? 'border-slate-700 bg-slate-800/45 text-slate-200 shadow-black/10' : 'border-red-100 bg-white text-[#2a2f3f] shadow-red-100/60'
}`}
>
{message.steps && message.steps.length > 0 && <StepsSummary steps={message.steps} />}
{message.thinkings?.map((thinking, index) => (
<ThinkingPanel key={`${message.id}-thinking-${index}`} duration={thinking.duration} content={thinking.content} />
))}
{message.toolCalls?.map((toolCall) => (
<ToolCallPanel key={toolCall.id} toolCall={toolCall} />
))}
{isConnecting && (
<div className={`flex items-center gap-2 text-sm ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>
<span className="skill-creator-dot" />
<span className="skill-creator-dot" />
<span className="skill-creator-dot" />
<span
data-text="正在连接技能服务..."
className={`ml-1 skill-creator-shimmer-text ${isDarkMode ? 'skill-creator-shimmer-dark' : 'skill-creator-shimmer-light'}`}
>
正在连接技能服务...
</span>
</div>
)}
{message.content && (
<MarkdownRenderer
content={message.content}
className={isDarkMode ? '[&_p]:text-slate-200 [&_li]:text-slate-200' : '[&_p]:text-[#2a2f3f] [&_li]:text-[#2a2f3f]'}
/>
)}
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,337 @@
import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
import { Archive, Loader2, MessageSquareText, Plus, RefreshCw, Trash2, X } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
export interface SkillCreatorThread {
id: number;
title: string;
archived: boolean;
expert_agent_id: number | null;
created_at: string;
updated_at: string;
}
interface ThreadListResponse {
threads?: SkillCreatorThread[];
archived_threads?: SkillCreatorThread[];
}
interface SkillCreatorThreadSidebarProps {
authedFetch: (input: Parameters<typeof fetch>[0], init?: Parameters<typeof fetch>[1]) => Promise<Response>;
currentThreadId: string;
isDarkMode: boolean;
isOpen: boolean;
isBusy?: boolean;
createThreadPayload?: Record<string, unknown>;
createButtonText?: string;
emptyText?: string;
subtitle?: string;
threadFilter?: (thread: SkillCreatorThread) => boolean;
onClose: () => void;
onThreadSelect: (threadId: string) => void;
}
const THREADS_API_URL = `${knowledgetoolUrl}/api/v1/expert-agent-chat/threads`;
const sortThreads = (threads: SkillCreatorThread[]) =>
[...threads].sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
export default function SkillCreatorThreadSidebar({
authedFetch,
currentThreadId,
isDarkMode,
isOpen,
isBusy = false,
createThreadPayload,
createButtonText = '新建聊天',
emptyText = '暂无记录',
subtitle = '切换或管理当前会话',
threadFilter,
onClose,
onThreadSelect,
}: SkillCreatorThreadSidebarProps) {
const [threads, setThreads] = useState<SkillCreatorThread[]>([]);
const [archivedThreads, setArchivedThreads] = useState<SkillCreatorThread[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [deletingThreadId, setDeletingThreadId] = useState<number | null>(null);
const [error, setError] = useState('');
const applyFilter = useCallback(
(items: SkillCreatorThread[]) => (threadFilter ? items.filter(threadFilter) : items),
[threadFilter],
);
const refreshThreads = useCallback(async () => {
setIsLoading(true);
setError('');
try {
const response = await authedFetch(THREADS_API_URL, { method: 'GET' });
if (!response.ok) {
throw new Error(`获取会话列表失败: ${response.status}`);
}
const data = (await response.json()) as ThreadListResponse;
const nextThreads = Array.isArray(data.threads) ? data.threads : [];
const nextArchivedThreads = Array.isArray(data.archived_threads) ? data.archived_threads : [];
setThreads(sortThreads(applyFilter(nextThreads)));
setArchivedThreads(sortThreads(applyFilter(nextArchivedThreads)));
} catch (fetchError) {
console.error('获取会话列表失败', fetchError);
setError('会话列表加载失败,请稍后重试');
} finally {
setIsLoading(false);
}
}, [applyFilter, authedFetch]);
useEffect(() => {
if (!isOpen) return;
void refreshThreads();
}, [isOpen, refreshThreads]);
const allThreads = useMemo(() => [...threads, ...archivedThreads], [archivedThreads, threads]);
const handleCreateThread = async () => {
if (isBusy || isCreating) return;
setIsCreating(true);
setError('');
try {
const response = await authedFetch(THREADS_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: '新建技能',
archived: false,
is_real_chat: true,
...createThreadPayload,
}),
});
if (!response.ok) {
throw new Error(`创建会话失败: ${response.status}`);
}
const newThread = (await response.json()) as SkillCreatorThread;
if (!threadFilter || threadFilter(newThread)) {
setThreads((prev) => sortThreads([newThread, ...prev]));
}
onThreadSelect(String(newThread.id));
} catch (createError) {
console.error('创建会话失败', createError);
setError('新建会话失败,请稍后重试');
} finally {
setIsCreating(false);
}
};
const handleDeleteThread = async (thread: SkillCreatorThread) => {
if (isBusy || deletingThreadId === thread.id) return;
const confirmed = window.confirm(`确认删除会话“${thread.title || `会话 ${thread.id}`}”吗?`);
if (!confirmed) return;
setDeletingThreadId(thread.id);
setError('');
try {
const response = await authedFetch(`${THREADS_API_URL}/${thread.id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`删除会话失败: ${response.status}`);
}
const nextThreads = threads.filter((item) => item.id !== thread.id);
const nextArchivedThreads = archivedThreads.filter((item) => item.id !== thread.id);
setThreads(nextThreads);
setArchivedThreads(nextArchivedThreads);
if (currentThreadId === String(thread.id)) {
const fallbackThread = [...nextThreads, ...nextArchivedThreads][0];
onThreadSelect(fallbackThread ? String(fallbackThread.id) : '');
}
} catch (deleteError) {
console.error('删除会话失败', deleteError);
setError('删除会话失败,请稍后重试');
} finally {
setDeletingThreadId(null);
}
};
const renderThreadSection = (title: string, items: SkillCreatorThread[], archived = false) => (
<div className="space-y-2.5">
<div className="flex items-center justify-between px-1">
<div className="flex items-center gap-1.5">
{archived ? <Archive className="h-3.5 w-3.5" /> : <MessageSquareText className="h-3.5 w-3.5" />}
<span className="text-[11px] font-semibold tracking-[0.14em]">{title}</span>
</div>
<span className={`text-[11px] ${isDarkMode ? 'text-slate-500' : 'text-gray-400'}`}>{items.length}</span>
</div>
{items.length === 0 ? (
<div
className={`rounded-xl border border-dashed px-3 py-4 text-center text-xs ${
isDarkMode ? 'border-slate-700 text-slate-500' : 'border-gray-200 text-gray-400'
}`}
>
{emptyText}
</div>
) : (
<div className="space-y-1">
{items.map((thread) => {
const isActive = currentThreadId === String(thread.id);
const isDeleting = deletingThreadId === thread.id;
return (
<button
key={thread.id}
type="button"
disabled={isBusy}
onClick={() => onThreadSelect(String(thread.id))}
className={`group flex w-full items-center gap-3 rounded-xl border px-3 py-2.5 text-left transition-all duration-200 ${
isActive
? isDarkMode
? 'border-slate-600 bg-slate-800/90 text-slate-50 shadow-sm'
: 'border-red-200 bg-red-50/80 text-gray-900 shadow-sm'
: isDarkMode
? 'border-transparent text-slate-300 hover:border-slate-700 hover:bg-slate-800/60 hover:text-slate-100'
: 'border-transparent text-gray-700 hover:border-gray-200 hover:bg-gray-50 hover:text-gray-900'
} ${isBusy ? 'cursor-not-allowed opacity-70' : ''}`}
>
<span
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
isActive
? isDarkMode
? 'bg-slate-700 text-slate-200'
: 'bg-white text-red-500 ring-1 ring-red-100'
: isDarkMode
? 'bg-slate-800 text-slate-400'
: 'bg-gray-100 text-gray-500'
}`}
>
{archived ? <Archive className="h-4 w-4" /> : <MessageSquareText className="h-4 w-4" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{thread.title || `会话 ${thread.id}`}</span>
</span>
<span
onClick={(event) => {
event.stopPropagation();
void handleDeleteThread(thread);
}}
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 group-focus-within:opacity-100'
} ${
isDarkMode
? 'text-slate-400 hover:bg-rose-500/15 hover:text-rose-300'
: 'text-gray-400 hover:bg-rose-50 hover:text-rose-500'
}`}
role="button"
aria-label={`删除会话 ${thread.title || thread.id}`}
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
void handleDeleteThread(thread);
}
}}
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
</span>
</button>
);
})}
</div>
)}
</div>
);
return (
<aside
className={`flex h-full shrink-0 flex-col overflow-hidden border-r transition-[width,opacity,transform,border-color] duration-300 ease-out ${
isOpen ? 'w-[286px] translate-x-0 opacity-100' : 'w-0 -translate-x-3 opacity-0 pointer-events-none'
} ${
isDarkMode ? 'border-slate-700 bg-slate-950/90 text-slate-100' : 'border-gray-200 bg-[#fafafa] text-gray-900'
}`}
>
<div className={`border-b px-4 py-4 ${isDarkMode ? 'border-slate-800 bg-slate-900/70' : 'border-gray-200 bg-white'}`}>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold">聊天记录</h3>
<p className={`mt-1 text-xs ${isDarkMode ? 'text-slate-500' : 'text-gray-500'}`}>{subtitle}</p>
</div>
<button
type="button"
onClick={onClose}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
isDarkMode ? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
}`}
title="关闭聊天记录"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-4 flex items-center gap-2">
<button
type="button"
disabled={isBusy || isCreating}
onClick={() => void handleCreateThread()}
className={`inline-flex h-9 flex-1 items-center justify-center gap-2 rounded-lg border px-3 text-sm font-medium transition ${
isDarkMode
? 'border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700'
: 'border-gray-200 bg-white text-gray-700 hover:border-red-200 hover:bg-red-50 hover:text-red-600'
} disabled:cursor-not-allowed disabled:opacity-70`}
>
{isCreating ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
{createButtonText}
</button>
<button
type="button"
disabled={isLoading}
onClick={() => void refreshThreads()}
className={`flex h-9 w-9 items-center justify-center rounded-lg border transition ${
isDarkMode
? 'border-slate-700 bg-slate-800 text-slate-300 hover:bg-slate-700'
: 'border-gray-200 bg-white text-gray-500 hover:border-gray-300 hover:text-gray-700'
} ${isLoading ? 'cursor-not-allowed opacity-70' : ''}`}
title="刷新会话列表"
>
<RefreshCw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
</div>
{error && (
<div className={`mt-3 rounded-lg px-3 py-2 text-xs ${isDarkMode ? 'bg-rose-500/10 text-rose-300' : 'bg-rose-50 text-rose-500'}`}>
{error}
</div>
)}
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-3 py-3">
{isLoading && allThreads.length === 0 ? (
<div className={`flex h-full items-center justify-center gap-2 text-sm ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>
<Loader2 className="h-4 w-4 animate-spin" />
正在加载聊天记录...
</div>
) : (
<div className="space-y-5">
{renderThreadSection('进行中', threads)}
{renderThreadSection('已归档', archivedThreads, true)}
</div>
)}
</div>
</aside>
);
}
@@ -0,0 +1,811 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Bot,
Boxes,
Braces,
ChevronDown,
ChevronRight,
Compass,
Crown,
Edit,
FileCode2,
Globe,
LineChart,
MoreVertical,
PenTool,
Pencil,
PencilIcon,
Plus,
Search,
ShieldCheck,
Sparkles,
Target,
Trash2,
Upload,
Wand2,
Workflow,
X,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useTheme } from '../../contexts/ThemeContext';
import { useExpertAuthBootstrap } from '../../hooks';
import { knowledgetoolUrl } from '@/hooks/useExpertAuthBootstrap';
import { MessagePlugin } from 'tdesign-react';
interface Skill {
id: string;
name: string;
description: string;
category: string;
highlight: string;
relatedCount: number;
version: string;
inputCount: number;
outputCount: number;
isNew?: boolean;
isMine: boolean;
source?: string;
}
interface ApiSkill {
id: number;
name: string;
description: string;
source?: string;
}
interface SkillsResponse {
items: ApiSkill[];
total: number;
page: number;
page_size: number;
}
interface CreateSkillThreadResponse {
id: number;
title: string;
archived: boolean;
expert_agent_id: number | null;
user_id: string;
created_at: string;
updated_at: string;
}
type CategoryKey = 'all' | 'writing' | 'analysis' | 'decision' | 'validation' | 'workflow';
const CATEGORY_LABELS: Record<CategoryKey, string> = {
all: '全部类型',
writing: '编辑类',
analysis: '分析类',
decision: '决策类',
validation: '校验类',
workflow: '流程类',
};
const categoryOrder: CategoryKey[] = ['all', 'writing', 'analysis', 'decision', 'validation', 'workflow'];
const AVATAR_STYLES = [
{
Icon: Bot,
wrapper: 'from-rose-100 via-red-50 to-white text-red-500',
glow: 'shadow-red-100/80',
ring: 'ring-red-100',
orb: 'bg-red-500/10',
},
{
Icon: Wand2,
wrapper: 'from-violet-100 via-fuchsia-50 to-white text-violet-500',
glow: 'shadow-violet-100/80',
ring: 'ring-violet-100',
orb: 'bg-violet-500/10',
},
{
Icon: LineChart,
wrapper: 'from-cyan-100 via-sky-50 to-white text-sky-500',
glow: 'shadow-sky-100/80',
ring: 'ring-sky-100',
orb: 'bg-sky-500/10',
},
{
Icon: ShieldCheck,
wrapper: 'from-emerald-100 via-green-50 to-white text-emerald-500',
glow: 'shadow-emerald-100/80',
ring: 'ring-emerald-100',
orb: 'bg-emerald-500/10',
},
{
Icon: Workflow,
wrapper: 'from-amber-100 via-yellow-50 to-white text-amber-500',
glow: 'shadow-amber-100/80',
ring: 'ring-amber-100',
orb: 'bg-amber-500/10',
},
{
Icon: FileCode2,
wrapper: 'from-indigo-100 via-blue-50 to-white text-indigo-500',
glow: 'shadow-indigo-100/80',
ring: 'ring-indigo-100',
orb: 'bg-indigo-500/10',
},
{
Icon: Target,
wrapper: 'from-pink-100 via-rose-50 to-white text-pink-500',
glow: 'shadow-pink-100/80',
ring: 'ring-pink-100',
orb: 'bg-pink-500/10',
},
{
Icon: Compass,
wrapper: 'from-orange-100 via-amber-50 to-white text-orange-500',
glow: 'shadow-orange-100/80',
ring: 'ring-orange-100',
orb: 'bg-orange-500/10',
},
];
const hashString = (value: string) =>
value.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
const inferCategory = (skill: ApiSkill): CategoryKey => {
const text = `${skill.name} ${skill.description}`.toLowerCase();
if (/(summary|readme|writer|copy|frontend|narrative|edit|document)/.test(text)) return 'writing';
if (/(analysis|audience|risk|search|insight|research|data)/.test(text)) return 'analysis';
if (/(plan|decision|goal|strategy|logic|outreach)/.test(text)) return 'decision';
if (/(check|validate|review|guard|test|lint)/.test(text)) return 'validation';
return 'workflow';
};
const buildVersion = (id: number) => `v${1 + (id % 3)}.${(id % 8) + 1}`;
const buildCount = (id: number, offset: number) => 2 + ((id + offset) % 4);
const buildRelatedCount = (id: number) => 3 + (id % 4);
const mapApiSkillToUiSkill = (skill: ApiSkill, type: 'official' | 'mine'): Skill => ({
id: String(skill.id),
name: skill.name,
description: skill.description,
category: inferCategory(skill),
highlight: type === 'mine' ? '已接入' : '可添加',
relatedCount: buildRelatedCount(skill.id),
version: buildVersion(skill.id),
inputCount: buildCount(skill.id, 1),
outputCount: buildCount(skill.id, 2),
isNew: skill.id % 3 === 0,
isMine: type === 'mine',
});
const getAvatarStyle = (skillId: string) => AVATAR_STYLES[hashString(skillId) % AVATAR_STYLES.length];
export default function SkillsWorkshop() {
const navigate = useNavigate();
const { authedFetch } = useExpertAuthBootstrap();
const { theme } = useTheme();
const isDarkMode = theme === 'dark';
const [searchTerm, setSearchTerm] = useState('');
const [selectedFilter, setSelectedFilter] = useState<'all' | 'mine'>('all');
const [selectedCategory, setSelectedCategory] = useState<CategoryKey>('all');
const [skills, setSkills] = useState<Skill[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [isCreateMenuOpen, setIsCreateMenuOpen] = useState(false);
const [isCreatingSkillThread, setIsCreatingSkillThread] = useState(false);
const [openCardMenuId, setOpenCardMenuId] = useState<string | null>(null);
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
const [addingSkillId, setAddingSkillId] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [isCreatingByUpload, setIsCreatingByUpload] = useState(false);
const [isAiEditing, setIsAiEditing] = useState(false);
const createMenuRef = useRef<HTMLDivElement | null>(null);
const cardMenuRef = useRef<HTMLDivElement | null>(null);
const uploadInputRef = useRef<HTMLInputElement | null>(null);
const createUploadInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
let canceled = false;
const fetchByType = async (type: 'official' | 'mine') => {
const params = new URLSearchParams({
type,
keyword: searchTerm.trim(),
page: '1',
page_size: '100',
});
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills?${params.toString()}`, {
method: 'GET',
});
if (!res.ok) {
throw new Error(`加载技能失败: ${res.status} ${res.statusText}`);
}
const data = (await res.json()) as SkillsResponse;
return data.items.map((item) => mapApiSkillToUiSkill(item, type));
};
const loadSkills = async () => {
setLoading(true);
setError('');
try {
const list =
selectedFilter === 'all'
? await Promise.all([fetchByType('official'), fetchByType('mine')])
: [await fetchByType('mine')];
if (canceled) return;
const merged = selectedFilter === 'all' ? [...list[0], ...list[1]] : list[0];
const deduped = Array.from(new Map(merged.map((item) => [item.id, item])).values());
setSkills(deduped);
} catch {
if (canceled) return;
setError('加载技能失败,请稍后重试');
setSkills([]);
} finally {
if (!canceled) setLoading(false);
}
};
void loadSkills();
return () => {
canceled = true;
};
}, [searchTerm, selectedFilter, authedFetch]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node;
if (createMenuRef.current && !createMenuRef.current.contains(target)) {
setIsCreateMenuOpen(false);
}
if (cardMenuRef.current && !cardMenuRef.current.contains(target)) {
setOpenCardMenuId(null);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
const filteredSkills = useMemo(
() => skills.filter((skill) => selectedCategory === 'all' || skill.category === selectedCategory),
[selectedCategory, skills],
);
const categoryCounts = useMemo(
() =>
categoryOrder.reduce<Record<CategoryKey, number>>((acc, category) => {
acc[category] = category === 'all' ? skills.length : skills.filter((skill) => skill.category === category).length;
return acc;
}, {} as Record<CategoryKey, number>),
[skills],
);
const mySkillCount = skills.filter((skill) => skill.isMine).length;
const officialSkillCount = skills.filter((skill) => !skill.isMine).length;
const handlePublishSkill = async (skillId: string) => {
setOpenCardMenuId(null);
try {
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agents/skills/publish`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
skill_id: parseInt(skillId, 10),
}),
});
if (!res.ok) {
throw new Error(`发布失败: ${res.status} ${res.statusText}`);
}
} catch (err) {
console.error('发布技能失败', err);
setError('发布技能失败,请稍后重试');
}
};
const handleEditSkill = (skillId: string) => {
setOpenCardMenuId(null);
const skill = skills.find((item) => item.id === skillId) ?? null;
setEditingSkill(skill);
};
const handleDeleteSkill = async (skillId: string) => {
setOpenCardMenuId(null);
try {
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/mine/${skillId}`, {
method: 'DELETE',
});
if (!res.ok) {
throw new Error(`删除失败: ${res.status} ${res.statusText}`);
}
setSkills((prev) => prev.filter((item) => item.id !== skillId));
MessagePlugin.success('操作成功');
} catch (err) {
console.error('删除技能失败', err);
setError('删除技能失败,请稍后重试');
}
};
const handleUploadSkillFile = async (file: File, skillId: string) => {
setIsUploading(true);
try {
const formData = new FormData();
formData.append('file', file);
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/${skillId}/upload`, {
method: 'PUT',
body: formData,
});
if (!res.ok) {
throw new Error(`上传失败: ${res.status} ${res.statusText}`);
}
setEditingSkill(null);
} catch (err) {
console.error('上传技能失败', err);
setError('上传技能失败,请稍后重试');
} finally {
setIsUploading(false);
if (uploadInputRef.current) uploadInputRef.current.value = '';
}
};
const handleAddOfficialSkill = async (skillId: string) => {
if (addingSkillId) return;
setAddingSkillId(skillId);
try {
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/mine/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ official_skill_id: parseInt(skillId, 10) }),
});
if (!res.ok) {
throw new Error(`添加失败: ${res.status} ${res.statusText}`);
}
setSkills((prev) => prev.map((item) => (item.id === skillId ? { ...item, isMine: true, highlight: '已接入' } : item)));
MessagePlugin.success('添加成功');
} catch (err) {
console.error('添加技能失败', err);
setError('添加技能失败,请稍后重试');
} finally {
setAddingSkillId(null);
}
};
const handleAiEditSkill = async (skill: Skill) => {
if (isAiEditing) return;
setIsAiEditing(true);
try {
const threadRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: `编辑技能:${skill.name}`,
archived: true,
is_real_chat: true,
}),
});
if (!threadRes.ok) {
throw new Error(`创建会话失败: ${threadRes.status} ${threadRes.statusText}`);
}
const threadData = (await threadRes.json()) as CreateSkillThreadResponse;
const threadId = threadData?.id;
if (!threadId) throw new Error('创建会话成功,但未返回会话 id');
const copyRes = await authedFetch(
`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads/${threadId}/copy-skill/${skill.id}`,
{ method: 'POST' },
);
if (!copyRes.ok) {
throw new Error(`复制技能失败: ${copyRes.status} ${copyRes.statusText}`);
}
setEditingSkill(null);
localStorage.setItem('skill_creator_thread_id', String(threadId));
navigate(`/skill-creator?id=${threadId}`);
} catch (err) {
console.error('AI 辅助编辑失败', err);
setError('AI 辅助编辑失败,请稍后重试');
} finally {
setIsAiEditing(false);
}
};
const handleCreateSkillByUpload = async (file: File) => {
setIsCreatingByUpload(true);
setIsCreateMenuOpen(false);
try {
const formData = new FormData();
formData.append('file', file);
const res = await authedFetch(`${knowledgetoolUrl}/api/v1/skills/upload`, {
method: 'POST',
body: formData,
});
if (!res.ok) {
throw new Error(`上传失败: ${res.status} ${res.statusText}`);
}
} catch (err) {
console.error('上传技能失败', err);
setError('上传技能失败,请稍后重试');
} finally {
setIsCreatingByUpload(false);
if (createUploadInputRef.current) createUploadInputRef.current.value = '';
}
};
const handleCreateSkillByAgent = async () => {
if (isCreatingSkillThread) return;
setIsCreatingSkillThread(true);
setIsCreateMenuOpen(false);
try {
const threadRes = await authedFetch(`${knowledgetoolUrl}/api/v1/expert-agent-chat/threads`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
title: '新建技能',
archived: true,
is_real_chat: true,
}),
});
if (!threadRes.ok) {
throw new Error(`创建技能会话失败: ${threadRes.status} ${threadRes.statusText}`);
}
const threadData = (await threadRes.json()) as CreateSkillThreadResponse;
const threadId = threadData?.id;
if (!threadId) {
throw new Error('创建技能会话成功,但未返回会话 id');
}
localStorage.setItem('skill_creator_thread_id', String(threadId));
navigate(`/qa-center/skill-creator?id=${threadId}`);
} catch (createError) {
console.error('创建技能会话失败', createError);
setError('创建技能会话失败,请稍后重试');
} finally {
setIsCreatingSkillThread(false);
}
};
return (
<div className={`w-full min-h-[calc(100vh-140px)] ${isDarkMode ? 'bg-slate-950 text-slate-100' : 'bg-[#fcfcfd] text-[#101828]'}`}>
{editingSkill && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm"
onMouseDown={(event) => {
if (event.target === event.currentTarget) setEditingSkill(null);
}}
>
<div className={`relative w-[440px] overflow-hidden rounded-[28px] border shadow-2xl ${isDarkMode ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-[#f3d6d6] bg-white text-gray-900'}`}>
<div className="flex items-center justify-between px-6 pb-4 pt-5">
<h2 className="text-base font-semibold">编辑技能</h2>
<button
type="button"
onClick={() => setEditingSkill(null)}
className={`flex h-9 w-9 items-center justify-center rounded-xl transition ${isDarkMode ? 'text-slate-400 hover:bg-slate-700 hover:text-slate-100' : 'text-gray-400 hover:bg-[#fff3f3] hover:text-[#ef4444]'}`}
>
<X className="h-4 w-4" />
</button>
</div>
<p className={`px-6 pb-4 text-sm ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>
选择编辑方式:<span className={`font-medium ${isDarkMode ? 'text-slate-200' : 'text-gray-800'}`}>{editingSkill.name}</span>
</p>
<div className={`mx-6 border-t ${isDarkMode ? 'border-slate-700' : 'border-[#f4e4e4]'}`} />
<div className="flex flex-col gap-3 p-4">
<button
type="button"
disabled={isAiEditing}
onClick={() => void handleAiEditSkill(editingSkill)}
className={`group/opt flex w-full items-center gap-4 rounded-[22px] border px-4 py-4 text-left transition ${isDarkMode ? 'border-slate-700 bg-slate-800/60 hover:bg-slate-800' : 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'} ${isAiEditing ? 'cursor-not-allowed opacity-50' : ''}`}
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-violet-500 to-indigo-600 text-white shadow-lg shadow-violet-500/30 ring-1 ring-white/20">
<Bot className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>
{isAiEditing ? '处理中...' : 'AI 辅助编辑'}
</p>
<p className={`mt-0.5 text-xs ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>通过对话开启 AI 帮助您优化技能内容</p>
</div>
<ChevronRight className={`h-4 w-4 shrink-0 transition ${isDarkMode ? 'text-slate-500 group-hover/opt:text-slate-300' : 'text-gray-300 group-hover/opt:text-gray-500'}`} />
</button>
<input
ref={uploadInputRef}
type="file"
accept=".md,.zip"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file && editingSkill) {
void handleUploadSkillFile(file, editingSkill.id);
}
}}
/>
<button
type="button"
disabled={isUploading}
onClick={() => uploadInputRef.current?.click()}
className={`group/opt flex w-full items-center gap-4 rounded-[22px] border px-4 py-4 text-left transition ${isDarkMode ? 'border-slate-700 bg-slate-800/60 hover:bg-slate-800' : 'border-[#f5d4d4] bg-[#fffafa] hover:border-[#ffbdbd] hover:bg-[#fff5f5]'} ${isUploading ? 'cursor-not-allowed opacity-50' : ''}`}
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br from-amber-400 to-orange-500 text-white shadow-lg shadow-orange-400/30 ring-1 ring-white/20">
<Upload className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>
{isUploading ? '上传中...' : '上传新版覆盖'}
</p>
<p className={`mt-0.5 text-xs ${isDarkMode ? 'text-slate-400' : 'text-gray-500'}`}>上传 `.md` 或 `.zip` 文件替换当前内容</p>
</div>
<ChevronRight className={`h-4 w-4 shrink-0 transition ${isDarkMode ? 'text-slate-500 group-hover/opt:text-slate-300' : 'text-gray-300 group-hover/opt:text-gray-500'}`} />
</button>
</div>
<div className={`flex items-center justify-end gap-3 px-6 pb-5 pt-1 ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>
<button
type="button"
onClick={() => setEditingSkill(null)}
className={`rounded-xl px-4 py-2 text-sm transition ${isDarkMode ? 'hover:bg-slate-800 hover:text-slate-200' : 'hover:bg-[#fff3f3] hover:text-[#ef4444]'}`}
>
取消
</button>
</div>
</div>
</div>
)}
<div className="mx-auto max-w-[1360px] px-6 py-8">
<section className={`mb-7 ${isDarkMode ? '' : ''}`}>
<div className="flex items-start justify-between gap-5">
<div>
<h1 className={`text-[22px] font-bold tracking-tight ${isDarkMode ? 'text-slate-100' : 'text-[#111827]'}`}>技能工坊</h1>
<p className={`mt-2 text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>统一管理系统中的技能资产,支持发现、接入、发布与编辑。</p>
</div>
<div className="relative" ref={createMenuRef}>
<button
type="button"
onClick={() => setIsCreateMenuOpen((prev) => !prev)}
className="inline-flex h-11 items-center gap-2 rounded-xl bg-[#ea2d2d] px-5 text-sm font-medium text-white shadow-[0_10px_20px_-12px_rgba(234,45,45,0.7)] transition hover:bg-[#d92626]"
>
<Plus className="h-4 w-4" />
新建技能体
</button>
{isCreateMenuOpen && (
<div className={`absolute right-0 top-[calc(100%+10px)] z-20 w-60 rounded-2xl border py-2 shadow-xl ${isDarkMode ? 'border-slate-700 bg-slate-900' : 'border-[#f0d6d6] bg-white'}`}>
<button
type="button"
disabled={isCreatingSkillThread}
onClick={() => void handleCreateSkillByAgent()}
className={`w-full px-4 py-3 text-left transition ${isDarkMode ? 'hover:bg-slate-800' : 'hover:bg-[#fff6f6]'} ${isCreatingSkillThread ? 'cursor-not-allowed opacity-50' : ''}`}
>
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>{isCreatingSkillThread ? '创建中...' : '通过 Agent 构建'}</p>
<p className={`mt-1 text-xs ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>通过对话构建和训练 AI 技能</p>
</button>
<input
ref={createUploadInputRef}
type="file"
accept=".md,.zip"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void handleCreateSkillByUpload(file);
}}
/>
<button
type="button"
disabled={isCreatingByUpload}
onClick={() => createUploadInputRef.current?.click()}
className={`w-full px-4 py-3 text-left transition ${isDarkMode ? 'hover:bg-slate-800' : 'hover:bg-[#fff6f6]'} ${isCreatingByUpload ? 'cursor-not-allowed opacity-50' : ''}`}
>
<p className={`text-sm font-medium ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>{isCreatingByUpload ? '上传中...' : '上传技能文件'}</p>
<p className={`mt-1 text-xs ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>导入 `.md` 或 `.zip` 文件</p>
</button>
</div>
)}
</div>
</div>
</section>
<section className={`mb-8 rounded-[28px] border px-6 py-6 ${isDarkMode ? 'border-slate-800 bg-slate-900/65' : 'border-[#f5d4d4] bg-white'}`}>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className={`flex min-w-0 flex-1 items-center gap-3 rounded-2xl border px-4 py-3 ${isDarkMode ? 'border-slate-700 bg-slate-950/50' : 'border-[#f3b6b6] bg-white'}`}>
<Search className={`h-4 w-4 ${isDarkMode ? 'text-slate-400' : 'text-[#ff6b6b]'}`} />
<input
type="text"
placeholder="搜索技能名称..."
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
className={`min-w-0 flex-1 bg-transparent text-sm outline-none ${isDarkMode ? 'text-slate-100 placeholder:text-slate-500' : 'text-gray-800 placeholder:text-[#9ca3af]'}`}
/>
</div>
<div className="flex items-center gap-3">
<div className={`inline-flex rounded-2xl border p-1 ${isDarkMode ? 'border-slate-700 bg-slate-950/50' : 'border-[#f3b6b6] bg-white'}`}>
<button
type="button"
onClick={() => setSelectedFilter('all')}
className={`rounded-xl px-4 py-2 text-sm font-medium transition ${selectedFilter === 'all' ? 'bg-[#fff1f1] text-[#ef4444]' : isDarkMode ? 'text-slate-300 hover:bg-slate-800' : 'text-[#475467] hover:bg-[#f9fafb]'}`}
>
全部类型
</button>
<button
type="button"
onClick={() => setSelectedFilter('mine')}
className={`rounded-xl px-4 py-2 text-sm font-medium transition ${selectedFilter === 'mine' ? 'bg-[#fff1f1] text-[#ef4444]' : isDarkMode ? 'text-slate-300 hover:bg-slate-800' : 'text-[#475467] hover:bg-[#f9fafb]'}`}
>
我的技能
</button>
</div>
</div>
</div>
{/* <div className="flex flex-wrap items-center gap-3">
<span className={`text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#475467]'}`}>按类型筛选:</span>
{categoryOrder.slice(1).map((category) => {
const active = selectedCategory === category;
return (
<button
key={category}
type="button"
onClick={() => setSelectedCategory(active ? 'all' : category)}
className={`rounded-full border px-4 py-2 text-sm transition ${
active
? 'border-[#ffb5b5] bg-[#fff5f5] text-[#ef4444]'
: isDarkMode
? 'border-slate-700 bg-slate-900/40 text-slate-300 hover:border-slate-600'
: 'border-[#f3d0d0] bg-white text-[#667085] hover:border-[#ffcccc] hover:text-[#ef4444]'
}`}
>
{CATEGORY_LABELS[category]}
</button>
);
})}
</div> */}
</div>
</section>
<section>
{loading ? (
<div className={`flex items-center justify-center rounded-[28px] border py-20 text-sm ${isDarkMode ? 'border-slate-800 bg-slate-900/60 text-slate-300' : 'border-[#f5d4d4] bg-white text-[#667085]'}`}>
正在加载技能...
</div>
) : error ? (
<div className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${isDarkMode ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'}`}>
<h3 className={`mb-2 text-lg font-semibold ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>加载失败</h3>
<p className={`text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>{error}</p>
</div>
) : filteredSkills.length === 0 ? (
<div className={`flex flex-col items-center justify-center rounded-[28px] border px-4 py-20 text-center ${isDarkMode ? 'border-slate-800 bg-slate-900/60' : 'border-[#f5d4d4] bg-white'}`}>
<div className={`mb-4 flex h-12 w-12 items-center justify-center rounded-2xl ${isDarkMode ? 'bg-slate-800 text-slate-400' : 'bg-[#fff1f1] text-[#ff6b6b]'}`}>
<Search className="h-6 w-6" />
</div>
<h3 className={`mb-2 text-lg font-semibold ${isDarkMode ? 'text-slate-100' : 'text-gray-900'}`}>未找到匹配的技能</h3>
<p className={`text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>尝试调整搜索词或切换筛选条件。</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{filteredSkills.map((skill) => {
const avatar = getAvatarStyle(skill.id);
const AvatarIcon = avatar.Icon;
const categoryLabel = CATEGORY_LABELS[skill.category as CategoryKey] ?? '流程类';
return (
<article
key={skill.id}
className={`group relative rounded-[22px] border px-5 py-5 transition-all duration-200 ${
isDarkMode
? 'border-slate-800 bg-slate-900/65 hover:border-slate-700 hover:shadow-xl hover:shadow-black/20'
: 'border-[#f5d4d4] bg-white hover:border-[#ffbdbd] hover:shadow-[0_16px_40px_-26px_rgba(239,68,68,0.35)]'
}`}
>
{!skill.isMine && selectedFilter === 'all' && (
<div className="absolute right-4 top-4 z-20 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
disabled={addingSkillId === skill.id}
onClick={(event) => {
event.stopPropagation();
void handleAddOfficialSkill(skill.id);
}}
className={`flex items-center justify-center rounded-lg bg-emerald-500 text-white shadow-sm transition hover:bg-emerald-600 ${addingSkillId === skill.id ? 'cursor-not-allowed opacity-50' : ''}`}
>
<Plus className="h-4 w-4" />
</button>
</div>
)}
{skill.isMine && (
<div className="absolute right-4 top-4 z-20" ref={openCardMenuId === skill.id ? cardMenuRef : null}>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
setOpenCardMenuId((prev) => (prev === skill.id ? null : skill.id));
}}
className={`flex items-center justify-center rounded-lg transition ${isDarkMode ? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100' : 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'}`}
>
<MoreVertical className="h-4 w-4" />
</button>
{openCardMenuId === skill.id && (
<div className={`absolute right-0 top-[calc(100%+6px)] z-30 w-40 rounded-2xl border py-1 shadow-xl ${isDarkMode ? 'border-slate-700 bg-slate-900' : 'border-[#f0d6d6] bg-white'}`}>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void handlePublishSkill(skill.id);
}}
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${isDarkMode ? 'text-emerald-400 hover:bg-slate-800' : 'text-emerald-600 hover:bg-[#fff6f6]'}`}
>
<Globe className="h-4 w-4 shrink-0" />
发布到全部
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleEditSkill(skill.id);
}}
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${isDarkMode ? 'text-slate-300 hover:bg-slate-800' : 'text-amber-600 hover:bg-[#fff6f6]'}`}
>
<Edit className="h-4 w-4 shrink-0" />
编辑
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
void handleDeleteSkill(skill.id);
}}
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition ${isDarkMode ? 'text-rose-400 hover:bg-slate-800' : 'text-rose-500 hover:bg-[#fff6f6]'}`}
>
<Trash2 className="h-4 w-4 shrink-0" />
删除
</button>
</div>
)}
</div>
)}
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<div className={`relative flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-gradient-to-br ${avatar.wrapper} shadow-[0_18px_32px_-22px_rgba(15,23,42,0.35)] ${avatar.glow} ring-1 ${avatar.ring}`}>
<div className={`absolute inset-0 rounded-2xl ${avatar.orb}`} />
<AvatarIcon className="relative z-10 h-5 w-5" />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className={`text-[17px] font-semibold ${skill.isMine ? 'text-[#111827]' : isDarkMode ? 'text-slate-100' : 'text-[#111827]'}`}>{skill.name}</h3>
<span className="inline-flex items-center rounded-full bg-[#fff2f2] px-2.5 py-1 text-xs font-medium text-[#ef4444]">
{ skill.isMine ? '我的' : '官方' }
</span>
</div>
<p className={`mt-1 line-clamp-3 text-sm ${isDarkMode ? 'text-slate-400' : 'text-[#667085]'}`}>{skill.description}</p>
</div>
</div>
</div>
</article>
);
})}
</div>
)}
</section>
</div>
</div>
);
}
@@ -0,0 +1,322 @@
export interface AgentStepItem {
title: string;
content: string;
}
export interface AgentParsed {
steps: AgentStepItem[];
thinkingMarkdown: string;
answerMarkdown: string;
thinkingComplete: boolean;
}
const STEP_RE = /<step\s+title="([^"]+)">([\s\S]*?)<\/step>/g;
const ANSWER_RE = /<answer>([\s\S]*?)<\/answer>/;
const THINKING_COMMENT_RE = /<!--\s*thinking:\s*([\s\S]*?)\s*-->/i;
const WAITING_PREFIX = '仍在等待模型首个 token...';
const stripCodeFence = (raw: string): string => {
const trimmed = raw.trim();
const match = /```(?:json)?\s*([\s\S]*?)```/i.exec(trimmed);
if (match) return match[1].trim();
return trimmed.replace(/^```(?:json)?\s*/i, '').trim();
};
const stripThinkTags = (
text: string,
): { remainder: string; thinkBlock: string; closed: boolean } => {
const start = text.indexOf('<think>');
if (start === -1) return { remainder: text, thinkBlock: '', closed: false };
const after = text.slice(start + '<think>'.length);
const endIdx = after.indexOf('</think>');
if (endIdx === -1) {
return {
remainder: text.slice(0, start),
thinkBlock: after,
closed: false,
};
}
return {
remainder: text.slice(0, start) + after.slice(endIdx + '</think>'.length),
thinkBlock: after.slice(0, endIdx),
closed: true,
};
};
const mergeStep = (steps: AgentStepItem[], nextStep: AgentStepItem) => {
const existingIndex = steps.findIndex((step) => step.title === nextStep.title);
if (existingIndex === -1) {
steps.push(nextStep);
return;
}
const existing = steps[existingIndex];
const nextIsWaiting = nextStep.content.startsWith(WAITING_PREFIX);
const existingLines = existing.content
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
const stableLines = existingLines.filter((line) => !line.startsWith(WAITING_PREFIX));
if (nextIsWaiting) {
const mergedLines = stableLines.length > 0 ? [...stableLines, nextStep.content] : [nextStep.content];
steps[existingIndex] = {
...existing,
content: mergedLines.join('\n'),
};
return;
}
const normalizedLines = stableLines.includes(nextStep.content)
? stableLines
: [...stableLines, nextStep.content];
steps[existingIndex] = {
...existing,
content: normalizedLines.join('\n'),
};
};
const extractSteps = (text: string): { steps: AgentStepItem[]; remainder: string } => {
const steps: AgentStepItem[] = [];
const remainder = text.replace(STEP_RE, (_, title: string, content: string) => {
mergeStep(steps, { title: title.trim(), content: content.trim() });
return '';
});
return { steps, remainder };
};
const extractTaggedSection = (
text: string,
re: RegExp,
): { value: string; remainder: string } => {
const match = re.exec(text);
if (!match) return { value: '', remainder: text };
return {
value: match[1].trim(),
remainder: text.replace(match[0], '').trim(),
};
};
const extractJsonStringField = (
text: string,
field: string,
): { value: string; complete: boolean; matched: boolean } => {
const re = new RegExp(`"${field}"\\s*:\\s*"`);
const match = re.exec(text);
if (!match) return { value: '', complete: false, matched: false };
let i = match.index + match[0].length;
let value = '';
while (i < text.length) {
const ch = text[i];
if (ch === '\\') {
if (i + 1 >= text.length) return { value, complete: false, matched: true };
const next = text[i + 1];
switch (next) {
case 'n':
value += '\n';
break;
case 't':
value += '\t';
break;
case 'r':
value += '\r';
break;
case '"':
value += '"';
break;
case '\\':
value += '\\';
break;
case '/':
value += '/';
break;
case 'b':
value += '\b';
break;
case 'f':
value += '\f';
break;
case 'u':
if (i + 5 < text.length) {
const hex = text.slice(i + 2, i + 6);
value += String.fromCharCode(parseInt(hex, 16));
i += 6;
continue;
}
return { value, complete: false, matched: true };
default:
value += next;
}
i += 2;
} else if (ch === '"') {
return { value, complete: true, matched: true };
} else {
value += ch;
i++;
}
}
return { value, complete: false, matched: true };
};
const extractItems = (text: string): unknown[] => {
const re = /"items"\s*:\s*\[/;
const match = re.exec(text);
if (!match) return [];
let i = match.index + match[0].length;
const out: unknown[] = [];
while (i < text.length) {
while (i < text.length && /[\s,]/.test(text[i])) i++;
if (i >= text.length) break;
if (text[i] === ']') break;
if (text[i] !== '{') {
i++;
continue;
}
let depth = 0;
let inStr = false;
let escape = false;
let j = i;
for (; j < text.length; j++) {
const c = text[j];
if (escape) {
escape = false;
continue;
}
if (inStr) {
if (c === '\\') escape = true;
else if (c === '"') inStr = false;
} else if (c === '"') inStr = true;
else if (c === '{') depth++;
else if (c === '}') {
depth--;
if (depth === 0) {
j++;
break;
}
}
}
if (depth !== 0) break;
try {
out.push(JSON.parse(text.slice(i, j)));
} catch {
// ignore partial item
}
i = j;
}
return out;
};
const itemToMarkdown = (item: unknown, idx: number): string => {
if (item == null) return `${idx + 1}.`;
if (typeof item !== 'object') return `${idx + 1}. ${String(item)}`;
const o = item as Record<string, unknown>;
const title =
(typeof o.title === 'string' && o.title) ||
(typeof o.expected_behavior === 'string' && o.expected_behavior) ||
(typeof o.name === 'string' && o.name) ||
`条目 ${idx + 1}`;
const body =
(typeof o.content === 'string' && o.content) ||
(typeof o.reason === 'string' && o.reason) ||
'';
const lines: string[] = [`**${idx + 1}. ${title}**`];
if (body) lines.push(body);
if (typeof o.expected_behavior === 'string' && typeof o.driving_factor === 'string') {
if (!o.title) {
lines.length = 0;
lines.push(`**${idx + 1}. ${o.expected_behavior}**`);
}
lines.push(`_驱动因素_:${o.driving_factor}`);
}
if (Array.isArray(o.references) && o.references.length > 0) {
const refs = (o.references as unknown[])
.filter((x) => typeof x === 'string' || typeof x === 'number')
.map((x) => `\`${String(x)}\``)
.join(' ');
if (refs) lines.push(`引用:${refs}`);
}
return lines.join('\n\n');
};
const itemsToMarkdown = (items: unknown[]): string =>
items.map((item, idx) => itemToMarkdown(item, idx)).join('\n\n---\n\n');
export const parseAgentResponse = (raw: string): AgentParsed => {
if (!raw) {
return {
steps: [],
thinkingMarkdown: '',
answerMarkdown: '',
thinkingComplete: false,
};
}
const stripped = stripThinkTags(raw);
const stepsResult = extractSteps(stripped.remainder);
let body = stripCodeFence(stepsResult.remainder);
const extraThinking = stripped.thinkBlock;
let thinkingComplete = stripped.closed;
const answerSection = extractTaggedSection(body, ANSWER_RE);
body = answerSection.remainder;
const thinkingCommentSection = extractTaggedSection(body, THINKING_COMMENT_RE);
body = thinkingCommentSection.remainder;
if (body) {
const start = body.indexOf('{');
if (start !== -1) body = body.slice(start);
}
if (body) {
try {
const obj = JSON.parse(body) as Record<string, unknown>;
if (obj && typeof obj === 'object') {
const thinking = typeof obj.thinking === 'string' ? obj.thinking : '';
const items = Array.isArray(obj.items) ? (obj.items as unknown[]) : [];
const answerMarkdown = items.length > 0 ? itemsToMarkdown(items) : answerSection.value;
return {
steps: stepsResult.steps,
thinkingMarkdown: [extraThinking, thinking, thinkingCommentSection.value]
.filter(Boolean)
.join('\n\n'),
answerMarkdown,
thinkingComplete: true,
};
}
} catch {
// fall through
}
}
const thinkingField = extractJsonStringField(body, 'thinking');
const items = extractItems(body);
if (thinkingField.complete) thinkingComplete = true;
let fallbackAnswer = answerSection.value || body.trim();
if (items.length > 0) {
fallbackAnswer = itemsToMarkdown(items);
}
if (!thinkingField.matched && items.length === 0 && !extraThinking) {
return {
steps: stepsResult.steps,
thinkingMarkdown: '',
answerMarkdown: fallbackAnswer,
thinkingComplete: false,
};
}
return {
steps: stepsResult.steps,
thinkingMarkdown: [extraThinking, thinkingField.value, thinkingCommentSection.value]
.filter(Boolean)
.join('\n\n'),
answerMarkdown: fallbackAnswer,
thinkingComplete,
};
};
+736
View File
@@ -0,0 +1,736 @@
# Agent 组件开发问答(Q&A)
> 目录范围:`src/components/agent`
>
> 涵盖文件:
> - `AgentExpertCenterPage.tsx`
> - `AgentExpertEditPage.tsx`
> - `AgentChatPage.tsx`
> - `SkillCreatorPage.tsx`
> - `SkillsWorkshop.tsx`
> - `SkillCreatorThreadSidebar.tsx`
> - `AgentConversationList.tsx`
> - `SkillCreatorConversationItem.tsx`
> - `ChatInputComposer.tsx`
> - `AgentChatPage.css`
---
## Q1:这个目录整体是干什么的?
**A:这是“专家 Agent + 技能工坊 + 技能创建对话”的前端 UI 组件集合。**
- `AgentExpertCenterPage`:专家列表入口(我的专家/发布专家)。
- `AgentExpertEditPage`:专家配置编辑页(左侧对话预览 + 右侧配置)。
- `AgentChatPage`:某个专家的真实聊天页(含线程、文件上传、流式回答)。
- `SkillsWorkshop`:技能资产中心(搜索/筛选/接入/发布/删除/编辑)。
- `SkillCreatorPage`:技能创建/改写专用对话页(支持文件树预览和保存弹窗)。
- `SkillCreatorThreadSidebar`:复用的会话侧边栏(获取线程、新建、删除、切换)。
- `AgentConversationList` + `SkillCreatorConversationItem`:统一消息列表与消息卡片渲染。
- `ChatInputComposer`:统一输入框(发送/停止/上传)。
- `AgentChatPage.css`:`AgentChatPage` 专用动画和滚动条样式。
---
## Q2:核心共享数据模型有哪些?
**A:对话层主要围绕 `ConversationMessage`。**
- 定义位置:`AgentConversationList.tsx`
- 字段:
- `id`: 消息唯一标识。
- `role`: `'user' | 'assistant'`
- `content`: 文本内容(markdown 渲染)。
- `steps`: 思考步骤列表(数据来自 `data-thinking-step`)。
- `thinkings`: 推理内容列表(来自 `reasoning-*` 事件)。
- `toolCalls`: 工具调用(输入输出)列表。
**A:文件树使用 `FileNode`。**
- 在 `AgentChatPage`、`SkillCreatorPage` 内部定义并使用。
- 字段:`name/path/type/size/extension/children`。
**A:线程侧边栏使用 `SkillCreatorThread`。**
- 定义位置:`SkillCreatorThreadSidebar.tsx`
- 字段:`id/title/archived/expert_agent_id/created_at/updated_at`。
---
## Q3:整个目录的“数据流主干”怎么走?
**A:可以看成 3 条主链路。**
### 1) 专家链路(Center -> Edit -> Chat)
1. `AgentExpertCenterPage` 拉专家列表(我的/发布)。
2. 点击“编辑”跳 `AgentExpertEditPage`,加载专家详情 + 工具 + 技能绑定状态。
3. 点击“进行对话”跳 `AgentChatPage`,加载该专家详情和对应会话消息。
### 2) 技能链路(Workshop -> SkillCreator)
1. `SkillsWorkshop` 拉技能列表(official/mine),本地映射成 UI `Skill`。
2. 新建技能:创建会话后跳 `SkillCreatorPage?id=threadId`。
3. AI 辅助编辑已有技能:先创建线程,再 copy skill 到线程,再跳转 `SkillCreatorPage`。
### 3) 对话链路(输入 -> SSE -> UI -> 持久化)
1. `ChatInputComposer` 触发 `onSend`。
2. 页面组件(`AgentChatPage` 或 `SkillCreatorPage`)执行 `handleSendMessage`。
3. 先写入用户消息 + assistant 占位消息。
4. 调流式接口读取 SSE:
- `data-thinking-step` -> 更新 `steps`
- `reasoning-start/delta/end` -> 更新 `thinkings`
- `tool-input-available/tool-output-available` -> 更新 `toolCalls`
- `text-delta` -> 追加 `content`
5. 完成后将 assistant 结果再次 POST 到消息接口持久化。
---
## Q4:`AgentExpertCenterPage` 组件是干什么的?
**A:专家资产“总览入口”,负责展示列表和基础操作入口。**
### 组件职责
- 展示“我的专家”和“发布专家”两种视图。
- 打开专家详情弹窗(支持发布状态切换)。
- 提供“编辑”和“进行对话”跳转入口。
### 数据来源与接口
- 列表查询:
- `POST /api/v1/expert-agents/list`(按 `is_draft`)
- 我的专家场景还会额外 `GET /api/v1/expert-agents` 做合并去重。
- 状态切换:
- `PATCH /api/v1/expert-agents/{id}`(更新 `is_draft`)
### 数据流
- `activeTab` 改变 -> `fetchExpertList` 触发 -> 更新 `agentList`。
- `agentList` -> `cards(useMemo)` -> 渲染卡片。
- 卡片按钮:
- 编辑 -> `navigate('/qa-center/agent-expert-edit?id=...')`
- 对话 -> `navigate('/qa-center/agent-chat?id=...')`
### 维护注意
- “创建专家”按钮目前是 UI 壳子,未接创建接口。
- “删除”按钮当前仅 `console.log`,未接真实删除逻辑。
---
## Q5:`AgentExpertEditPage` 组件是干什么的?
**A:专家配置编辑工作台,同时内置一个“测试对话区”。**
### 组件职责
- 左侧:和该专家进行流式对话(非真实聊天线程,使用 no_real 线程)。
- 右侧:编辑专家基础配置(名称、模型、描述、系统提示词)。
- 管理工具绑定、技能绑定(弹窗操作)。
### 关键状态
- 对话相关:`messages/inputValue/threadId/isStreaming`
- 配置相关:`formValues`
- 工具相关:`toolOptions/selectedTools/pendingTools/boundToolIdMap`
- 技能相关:`skillOptions/selectedSkills/boundSkillMap/skillKeyword`
### 主要接口
- 专家详情:`GET /api/v1/expert-agents/{agentId}`
- 保存专家:`PUT /api/v1/expert-agents/{agentId}`
- 绑定工具:
- 查询:`GET /api/v1/expert-agents/{agentId}/tools`
- 添加:`POST /api/v1/expert-agents/{agentId}/tools`
- 删除:`DELETE /api/v1/expert-agents/{agentId}/tools/{toolId}`
- 绑定技能:
- 查询:`GET /api/v1/expert-agents/{agentId}/skills`
- 添加:`POST /api/v1/expert-agents/{agentId}/skills`
- 删除:`DELETE /api/v1/expert-agents/{agentId}/skills/{skillId}`
- 测试对话:
- 线程:`GET /api/v1/expert-agent-chat/threads/no_real/{agentId}`
- 用户消息:`POST /api/v1/expert-agent-chat/messages`
- SSE:`POST /api/v1/expert-agent-chat/run/stream/help_expert`
### 数据流(对话)
- 用户发送 -> append user msg -> 保证 threadId -> 保存用户消息 -> append assistant 占位 -> SSE 增量更新。
### 维护注意
- 右侧 `preview` 区底部 `ChatInputComposer` 是只读展示,不参与真实请求。
- 工具与技能弹窗是“前端状态 + 后端同步”双层状态,调试时优先检查 refresh 查询是否成功。
---
## Q6:`AgentChatPage` 组件是干什么的?
**A:专家“真实聊天页”,并带线程管理、文件上传和文件预览。**
### 组件职责
- 根据 URL `id` 读取专家详情。
- 管理当前线程(localStorage 持久化)。
- 获取历史消息并转换为统一消息结构。
- 流式发送消息并渲染思考过程。
- 管理会话文件(上传、树形结构、预览、侧栏缩放)。
### 关键状态
- 会话:`currentThreadId/messages/inputValue/isStreaming/isLoading`
- UI:`isThreadSidebarOpen/autoScrollEnabled/showScrollButton`
- 文件:`fileList/fileListThreadId/isFileSidebarOpen/expandedPaths/selectedFile/filePreview*`
### 主要接口
- 专家详情:`GET /api/v1/expert-agents/{agentId}`
- 会话消息:
- 历史:`GET /api/v1/expert-agent-chat/threads/{id}/messages`
- 保存用户:`POST /api/v1/expert-agent-chat/messages`
- SSE:`POST /api/v1/expert-agent-chat/run/stream`
- 保存助手:`POST /api/v1/expert-agent-chat/messages`
- 文件:
- 列表:`GET /api/v1/expert-agent-chat/threads/{id}/files/all`
- 上传:`POST /api/v1/expert-agent-chat/threads/{id}/files/upload`
- 预览:`GET /api/v1/expert-agent-chat/threads/{id}/files/preview?path=...`
### 关键行为细节
- **发送即出现 assistant 占位**:点击发送后立即插入 assistant 空消息,保证用户立刻看到“连接中”态。
- **连接中显示规则**:由 `AgentConversationList` 判断“最后一条 assistant 且还没有任何生成内容”才显示连接状态。
- **失败回滚**:发送或流式失败时移除该 assistant 占位,避免残留空卡片。
- **停止流式**:`AbortController` 中断请求,并清理 loading 态字段。
### 维护注意
- `threadStorageKey` 与 `agentId` 强绑定,切换专家会切换独立 thread 记忆。
- `normalizeHistoryContent` 是历史消息兼容核心,后端 content 结构变更时先改这里。
---
## Q7:`SkillCreatorPage` 组件是干什么的?
**A:技能创建/重构专用对话页,结构上是 `AgentChatPage` 的“技能场景定制版”。**
### 组件职责
- 从 query/localStorage 同步当前线程 ID。
- 加载技能会话历史与会话文件。
- 走技能专属流式接口输出思考步骤/推理/tool call/文本。
- 提供“保存技能”弹窗(当前主要是 UI + 本地状态)。
### 与 `AgentChatPage` 的主要差异
- 线程 ID 同步到 URL(`useSearchParams`)。
- 有独立 `isConnecting` 状态,用于连接中阶段展示。
- 流式接口是:`POST /api/v1/expert-agent-chat/skills-agent`
- 页面内注入了 dot/shimmer 动画样式(供消息连接中态使用)。
### 主要接口
- 会话消息:`GET /threads/{id}/messages`
- 流式:`POST /skills-agent`
- 保存 assistant:`POST /messages`
- 文件:`GET all` / `POST upload` / `GET preview`
### 数据流
- 发消息 -> append 用户 + assistant 占位 -> `isConnecting=true` -> 收到首个流块后 `isConnecting=false` -> 增量刷新 assistant -> 持久化。
### 维护注意
- 保存技能按钮目前仅 `console.log` 输出技能名和描述,未接正式后端保存接口。
---
## Q8:`SkillsWorkshop` 组件是干什么的?
**A:技能资产管理页(发现、筛选、接入、发布、删除、进入编辑)。**
### 组件职责
- 拉取 `official` 与 `mine` 技能,映射成统一 UI 结构。
- 支持搜索、类型筛选、分类筛选。
- 支持官方技能接入到“我的技能”。
- 支持发布、删除、编辑(编辑弹窗支持 AI 编辑和上传覆盖)。
- 支持“新建技能体”:Agent 对话创建 / 上传文件创建。
### 主要接口
- 列表:`GET /api/v1/skills?type=official|mine`
- 发布:`POST /api/v1/expert-agents/skills/publish`
- 删除我的技能:`DELETE /api/v1/skills/mine/{skillId}`
- 上传覆盖:`PUT /api/v1/skills/{skillId}/upload`
- 接入官方技能:`POST /api/v1/skills/mine/add`
- 上传创建:`POST /api/v1/skills/upload`
- Agent 创建线程:`POST /api/v1/expert-agent-chat/threads`
- AI 编辑复制技能:`POST /threads/{threadId}/copy-skill/{skillId}`
### 数据流
- filter/search 改变 -> `useEffect` 重新拉取技能 -> 本地去重并缓存到 `skills`。
- 卡片编辑 -> `editingSkill` 打开弹窗 -> 选择 AI 编辑时创建线程后跳 `SkillCreatorPage`。
### 维护注意
- `inferCategory` 是关键词规则映射,新增技能类型时可优先扩展该规则。
- “进入”按钮目前使用 `skill.id` 跳 `skill-creator`,若后端语义改成 threadId 需调整。
---
## Q9:`SkillCreatorThreadSidebar` 组件是干什么的?
**A:会话线程管理的复用侧边栏。**
### 组件职责
- 打开时加载会话列表。
- 新建会话并自动选中。
- 删除会话并在当前会话被删时自动回退到其它会话。
- 支持线程过滤(如仅展示当前专家线程)。
### 输入输出(Props 视角)
- 输入:`authedFetch/currentThreadId/isOpen/isBusy/threadFilter/createThreadPayload...`
- 输出:
- `onThreadSelect(threadId)`
- `onClose()`
### 数据流
- `isOpen` 从 false -> true:触发 `refreshThreads`。
- 新建成功:更新本地列表并触发 `onThreadSelect`。
- 删除成功:本地移除;若删的是当前线程,自动选择 fallback。
### 维护注意
- `createThreadPayload` 是高复用扩展点,场景页可注入 `expert_agent_id/title` 等字段。
---
## Q10:`AgentConversationList` 组件是干什么的?
**A:统一遍历消息并决定“连接中状态”是否显示。**
### 核心逻辑
- 逐条渲染 `SkillCreatorConversationItem`。
- 只在以下条件满足时给最后一条 assistant 传 `isConnecting=true`:
- 页面处于连接/流式阶段;
- 当前消息是最后一条 assistant;
- 且还没有任何实际生成内容(`content/steps/thinkings/toolCalls` 都为空)。
### 价值
- 将“连接中占位显示时机”从页面组件抽离,统一控制,减少重复判断代码。
---
## Q11:`SkillCreatorConversationItem` 组件是干什么的?
**A:单条消息卡片渲染器(用户消息 + 助手多面板消息)。**
### 渲染结构
- 用户消息:右侧气泡 + user avatar。
- 助手消息:左侧 avatar + 多段内容:
- `StepsSummary`
- `ThinkingPanel[]`
- `ToolCallPanel[]`
- 连接中动画文案(`正在连接技能服务...`)
- Markdown 正文
### 数据来源
- 完全由父级传入 `message` 和 `isConnecting` 控制,不直接请求后端。
### 维护注意
- 此组件是所有对话页的统一呈现层;若改动 message schema,要同步更新这里的各面板。
---
## Q12:`ChatInputComposer` 组件是干什么的?
**A:通用输入组件,封装输入、发送/停止、上传文件。**
### 输入输出(Props)
- 输入:`value/disabled/readOnly/isSending/isDarkMode/showUpload/placeholder...`
- 回调:
- `onChange(value)`
- `onSend()`
- `onStop()`
- `onUploadFile(file)`
### 关键交互
- Enter(非 Shift)触发发送(前提:`submitEnabled` 且非 `isSending`)。
- 发送按钮在发送中切换为停止按钮(`onClick={isSending ? onStop : onSend}`)。
- 文件上传通过隐藏 `input[type=file]` + 按钮触发。
### 维护注意
- 当前实现里文本框只受 `disabled` 控制,不再因为 `isSending` 禁止输入。
- 上传按钮仍受 `disabled || !onUploadFile` 控制。
---
## Q13:`AgentChatPage.css` 主要做了什么?
**A:提供 `AgentChatPage` 的辅助样式。**
- `.agent-resizing`:拖拽文件侧栏时全局 cursor/user-select 控制。
- `.agent-file-tree-scroll` / `.agent-file-preview-scroll`:滚动条样式统一。
- `.agent-file-markdown-preview`:markdown 首尾边距和图片样式收敛。
- `.animate-breathing` 等动画类:预留呼吸动效。
---
## Q14:典型业务流程:发送一条消息时,数据如何传输?
**A:以 `AgentChatPage` 为例。**
1. 用户在 `ChatInputComposer` 输入后点击发送。
2. `handleSendMessage` 校验 `text/threadId/isStreaming`。
3. 立即写入:
- user message
- assistant 占位 message(空内容)
4. 调 `POST /messages` 落用户消息。
5. 调 `POST /run/stream` 开始 SSE。
6. 每个 SSE event 增量更新 assistant message:
- step -> `steps`
- reasoning -> `thinkings`
- tool -> `toolCalls`
- text -> `content`
7. 流结束后将聚合结果组装成 `persistedContent`,再 `POST /messages` 落库。
8. 刷新文件列表(对可能新增输出文件做同步)。
---
## Q15:典型业务流程:从技能工坊进入技能创建页,数据如何传输?
**A:两种入口。**
### 入口 A:新建技能(Agent 构建)
1. `SkillsWorkshop.handleCreateSkillByAgent` 创建线程。
2. 将 `threadId` 写入 localStorage。
3. 跳转 `navigate('/qa-center/skill-creator?id=threadId')`。
4. `SkillCreatorPage` 用 query/localStorage 同步 `currentThreadId`,然后拉消息和文件。
### 入口 B:AI 辅助编辑已有技能
1. 创建线程。
2. `POST /threads/{threadId}/copy-skill/{skillId}` 把技能内容复制到线程工作区。
3. 跳转 SkillCreator 页面,后续在线程中对话编辑。
---
## Q16:如果要新增一个“对话页”,建议复用哪些组件?
**A:建议最小复用组合如下。**
- 消息列表:`AgentConversationList`
- 单消息卡片:`SkillCreatorConversationItem`(自动包含 steps/thinking/tool 等面板)
- 输入框:`ChatInputComposer`
- 线程侧边栏:`SkillCreatorThreadSidebar`
**页面层只负责:**
- 状态管理(`messages/input/isStreaming/currentThreadId`)
- 接口请求(历史 + SSE + 持久化)
- 事件映射(把后端事件转为 `ConversationMessage` 结构)
---
## Q17:维护时最容易踩坑的点有哪些?
**A:建议优先关注这 8 个点。**
1. **SSE 事件兼容性**:后端 event type 或字段名变化会直接导致 UI 不更新。
2. **历史内容兼容**:`normalizeHistoryContent` 没跟上 content schema 会出现历史消息空白。
3. **线程切换中断**:切换线程时要记得 abort 流并清状态,避免串流到新线程。
4. **占位消息回收**:发送失败/流失败需移除 assistant 占位,避免残留空卡片。
5. **threadId 语义一致性**:某些页面参数传 skillId/agentId,需避免误当 threadId 使用。
6. **工具/技能绑定状态双源**:本地状态和后端状态不同步时,优先以 refresh 查询结果为准。
7. **上传入口 accept 限制**:上传文件类型修改时要同步后端校验和 UI 提示。
8. **深色主题样式**:该目录深色/浅色分支很多,改样式需双主题回归。
---
## Q18:后续文档扩展建议
**A:当前文档适合开发入门,下一步可补充“接口契约版”。**
- 补完整 SSE event JSON 示例(每种事件一例)。
- 增加错误码到 UI 提示映射表。
- 补一份“页面状态机图”(Idle / Connecting / Streaming / Stopped / Error)。
- 给每个页面补“可测点清单”(单测/联调/回归)。
---
## Q19:能否给一份可联调的“接口契约附录”?
**A:可以,以下按模块整理。**
### 1) 专家模块(Expert)
- 查询专家列表
- `POST /api/v1/expert-agents/list`
- 请求体:
- `is_draft: boolean`
- 响应(数组项):
- `id/name/avatar_url/description/model_name/is_draft/user_id/created_at`
- 查询专家详情
- `GET /api/v1/expert-agents/{agentId}`
- 响应关键字段:
- `id/name/description/system_prompt/model_name`
- 更新专家配置
- `PUT /api/v1/expert-agents/{agentId}`
- 请求体关键字段:
- `name`
- `description`
- `system_prompt`
- `model_name`
- `avatar_url`(当前传 `null`)
- 切换草稿/发布状态
- `PATCH /api/v1/expert-agents/{agentId}`
- 请求体:
- `is_draft: boolean`
### 2) 线程与消息模块(Chat Thread + Message)
- 查询线程列表
- `GET /api/v1/expert-agent-chat/threads`
- 响应:
- `threads: SkillCreatorThread[]`
- `archived_threads: SkillCreatorThread[]`
- 新建线程
- `POST /api/v1/expert-agent-chat/threads`
- 请求体常见字段:
- `title`
- `archived`
- `is_real_chat`
- `expert_agent_id`(专家聊天场景)
- 删除线程
- `DELETE /api/v1/expert-agent-chat/threads/{threadId}`
- 查询线程消息历史
- `GET /api/v1/expert-agent-chat/threads/{threadId}/messages`
- 响应消息项关键字段:
- `id/thread_id/role/content/created_at`
- 保存消息(用户或助手)
- `POST /api/v1/expert-agent-chat/messages`
- 请求体(用户消息):
- `thread_id`
- `role: "user"`
- `content: { type: "text", text: string }`
- 请求体(助手消息):
- `thread_id`
- `role: "assistant"`
- `content: persistedContent[]`
### 3) 流式回复模块(SSE)
- 专家聊天流式
- `POST /api/v1/expert-agent-chat/run/stream`
- 请求体关键字段:
- `chat_id`
- `expert_agent_id`
- `user_query`
- `messages`
- 专家编辑页测试流式
- `POST /api/v1/expert-agent-chat/run/stream/help_expert`
- 技能创建流式
- `POST /api/v1/expert-agent-chat/skills-agent`
- 请求体关键字段:
- `chat_id`
- `user_query`
### 4) 文件模块(线程文件)
- 查询线程文件树
- `GET /api/v1/expert-agent-chat/threads/{threadId}/files/all`
- 上传文件
- `POST /api/v1/expert-agent-chat/threads/{threadId}/files/upload`
- form-data:
- `file`
- 文件预览
- `GET /api/v1/expert-agent-chat/threads/{threadId}/files/preview?path=...`
- 兼容响应:
- 文本
- JSON 字符串
- JSON 对象中的 `content`
### 5) 技能模块(Skills)
- 查询技能列表
- `GET /api/v1/skills?type=official|mine|all&keyword=&page=1&page_size=100`
- 发布技能
- `POST /api/v1/expert-agents/skills/publish`
- 请求体:
- `skill_id`
- 添加官方技能到我的技能
- `POST /api/v1/skills/mine/add`
- 请求体:
- `official_skill_id`
- 删除我的技能
- `DELETE /api/v1/skills/mine/{skillId}`
- 上传技能文件创建
- `POST /api/v1/skills/upload`
- 上传覆盖已有技能
- `PUT /api/v1/skills/{skillId}/upload`
### 6) 专家绑定工具/技能
- 工具
- 查询:`GET /api/v1/expert-agents/{agentId}/tools`
- 绑定:`POST /api/v1/expert-agents/{agentId}/tools`(`tool_name`)
- 解绑:`DELETE /api/v1/expert-agents/{agentId}/tools/{toolId}`
- 技能
- 查询:`GET /api/v1/expert-agents/{agentId}/skills`
- 绑定:`POST /api/v1/expert-agents/{agentId}/skills`(`skill_id`)
- 解绑:`DELETE /api/v1/expert-agents/{agentId}/skills/{skillId}`
---
## Q20:SSE 事件规范可以补全吗?
**A:可以,以下是前端当前实现依赖的事件集合。**
### 1) 事件封包格式
- 每行以 `data: ` 开头
- 正文为 JSON 字符串
- 结束标记:`data: [DONE]`
### 2) 前端识别的事件类型
- `data-thinking-step`
- 用途:更新执行步骤面板
- 关键字段:
- `data.id`
- `data.title`
- `data.status`(`completed` 或其它)
- `data.items[]`
- `reasoning-start`
- 用途:开始一段思考
- 行为:创建新的 thinking 段,记录开始时间
- `reasoning-delta`
- 用途:思考内容增量
- 关键字段:
- `delta`
- `reasoning-end`
- 用途:结束当前思考段
- 行为:计算该段耗时并写入 `duration`
- `tool-input-available`
- 用途:记录工具调用入参
- 关键字段:
- `toolCallId`
- `toolName`
- `input`
- `tool-output-available`
- 用途:记录工具调用输出
- 关键字段:
- `toolCallId`
- `output`
- `text-delta`
- 用途:助手正文 token 增量
- 关键字段:
- `delta`
### 3) 推荐后端保证
- 同一次回复里的 `toolCallId` 全局唯一。
- `reasoning-start` 与 `reasoning-end` 成对(允许无 `delta`)。
- `data-thinking-step` 同一个 `id` 可重复更新状态。
- `text-delta` 在结束前允许为空片段,但尽量减少空片段。
### 4) 前端容错策略(已实现)
- JSON parse 失败:跳过该行,不中断整流。
- 未识别 event.type:忽略。
- `output` 非字符串:尝试 `result` 字段,否则 `JSON.stringify`。
- `AbortError`:按“用户主动停止”处理,不弹致命错误。
---
## Q21:可以给一份页面级测试清单吗?
**A:可以,以下清单按页面拆分,适合联调和回归。**
### A. `AgentChatPage` 测试清单
- 打开页面后,能加载专家信息(名称、描述)。
- 未选线程时,空态正确显示;选中线程后历史消息正确渲染。
- 点击发送后立即出现 assistant 占位卡片,显示连接中状态。
- 首个有效流式事件到达后,连接中占位消失,转为正常内容增量渲染。
- 发送失败/流失败时,assistant 占位会被回收,不留空白卡片。
- 点击停止后,流式中断,按钮状态恢复,后续不再追加内容。
- 上传文件后文件树刷新;文件预览支持 text/json 两类返回。
- 切换线程时会中断当前流,且不会把旧线程流内容写到新线程。
### B. `SkillCreatorPage` 测试清单
- URL `id` 与 localStorage 线程 ID 同步正确。
- 新线程空态和快捷提示按钮显示正常。
- 发送后 `isConnecting=true`,收到首包后 `isConnecting=false`。
- steps/thinking/tool/text 四类流式内容都能正确出现。
- 线程切换、停止流、文件上传/预览路径行为与 `AgentChatPage` 一致。
- 保存技能弹窗开关正常(当前为 UI 行为)。
### C. `AgentExpertEditPage` 测试清单
- 进入页面后基础信息(name/model/description/system_prompt)能回填。
- 保存专家配置后接口成功,按钮 loading 状态恢复。
- 工具弹窗:勾选新增 -> 确认添加 -> 列表刷新一致。
- 技能弹窗:绑定/解绑/一键绑定后状态与后端一致。
- 左侧测试对话能正常流式返回,滚动与发送状态正确。
### D. `SkillsWorkshop` 测试清单
- `all`/`mine` 切换结果正确,搜索词可生效。
- 分类筛选(writing/analysis/decision/validation/workflow)正确过滤。
- 官方技能“+”接入后卡片状态转“已接入”。
- 我的技能菜单中的发布/删除操作正确执行。
- 编辑弹窗中 AI 编辑路径可成功创建线程并跳转 SkillCreator。
- 上传创建、上传覆盖两条路径成功后状态正确恢复(含 input 清空)。
### E. `AgentExpertCenterPage` 测试清单
- mine/published 标签切换后列表正确变化。
- mine 模式会按当前用户过滤,且与全量接口合并去重后正确展示。
- 详情弹窗中的发布开关可切换并刷新列表。
- 编辑、对话跳转路径参数正确。
### F. 主题与可用性回归
- 深色/浅色主题下对话气泡、按钮、边框、hover 都可读。
- 长会话场景滚动性能可接受,自动滚动按钮行为符合预期。
- 键盘发送(Enter)与换行(Shift+Enter)行为正确。
@@ -0,0 +1,594 @@
import { useEffect, useMemo, useState } from 'react';
import { Bot, Edit, Eye, Plus, Search, Sparkles, Trash2, X } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
interface AgentItem {
key: string;
type: 'builtin' | 'custom' | string;
deletable: boolean;
name: string;
description: string;
default_prompt: string;
schema_hint: string;
bound_skills: string[];
order: number;
}
interface SkillItem {
key: string;
name: string;
description?: string;
}
interface AgentCardListProps {
onEdit?: (key: string) => void;
}
type TypeFilter = 'all' | 'builtin' | 'custom';
interface CreateAgentForm {
name: string;
description: string;
bound_skills: string[];
}
const INITIAL_FORM: CreateAgentForm = {
name: '',
description: '',
bound_skills: [],
};
const backUrl = 'https://ch1.b.uat.4.cn/zncmback';
export default function AgentCardList({ onEdit }: AgentCardListProps) {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [agents, setAgents] = useState<AgentItem[]>([]);
const [skills, setSkills] = useState<SkillItem[]>([]);
const [keyword, setKeyword] = useState('');
const [typeFilter, setTypeFilter] = useState<TypeFilter>('all');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createForm, setCreateForm] = useState<CreateAgentForm>(INITIAL_FORM);
const [skillKeyword, setSkillKeyword] = useState('');
const showNotice = (message: string) => {
setNotice(message);
window.setTimeout(() => {
setNotice((current) => (current === message ? null : current));
}, 2400);
};
const loadData = async () => {
setLoading(true);
setError(null);
try {
const [agentRes, skillRes] = await Promise.all([fetch(`${backUrl}/api/agents`), fetch(`${backUrl}/api/skills`)]);
if (!agentRes.ok) {
throw new Error(`加载 MCP 失败:${agentRes.status}`);
}
const agentData = (await agentRes.json()) as AgentItem[];
const skillData = skillRes.ok ? ((await skillRes.json()) as SkillItem[]) : [];
setAgents(agentData.sort((a, b) => a.order - b.order));
setSkills(skillData);
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadData();
}, []);
const filteredAgents = useMemo(() => {
const kw = keyword.trim().toLowerCase();
return agents.filter((agent) => {
const matchesKeyword =
!kw ||
agent.name.toLowerCase().includes(kw) ||
agent.key.toLowerCase().includes(kw) ||
agent.description.toLowerCase().includes(kw);
const matchesType = typeFilter === 'all' || agent.type === typeFilter;
return matchesKeyword && matchesType;
});
}, [agents, keyword, typeFilter]);
const filteredSkills = useMemo(() => {
const kw = skillKeyword.trim().toLowerCase();
if (!kw) {
return skills;
}
return skills.filter((skill) =>
`${skill.name} ${skill.key} ${skill.description || ''}`.toLowerCase().includes(kw),
);
}, [skillKeyword, skills]);
const resetCreateForm = () => {
setCreateForm(INITIAL_FORM);
setSkillKeyword('');
};
const openCreateModal = () => {
resetCreateForm();
setCreateOpen(true);
};
const closeCreateModal = () => {
setCreateOpen(false);
resetCreateForm();
};
const toggleSkill = (skillKey: string) => {
setCreateForm((prev) => ({
...prev,
bound_skills: prev.bound_skills.includes(skillKey)
? prev.bound_skills.filter((item) => item !== skillKey)
: [...prev.bound_skills, skillKey],
}));
};
const handleCreate = async () => {
const name = createForm.name.trim();
const description = createForm.description.trim();
if (!name) {
setError('请先输入 MCP 名称');
return;
}
setCreating(true);
setError(null);
try {
const res = await fetch(`${backUrl}/api/agents`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
description,
bound_skills: createForm.bound_skills,
}),
});
if (!res.ok) {
let detail = `新增失败:${res.status}`;
try {
const body = (await res.json()) as { detail?: string };
if (body.detail) {
detail = body.detail;
}
} catch {
// ignore json parse errors
}
throw new Error(detail);
}
await res.json();
await loadData();
closeCreateModal();
showNotice('MCP 已新增');
} catch (e) {
setError(e instanceof Error ? e.message : '新增失败');
} finally {
setCreating(false);
}
};
const handleDelete = async (agent: AgentItem) => {
if (!agent.deletable) return;
const ok = window.confirm(`确认删除 MCP “${agent.name}”吗?`);
if (!ok) return;
const res = await fetch(`${backUrl}/api/agents/${agent.key}`, { method: 'DELETE' });
if (!res.ok) {
setError(`删除失败:${res.status}`);
return;
}
await loadData();
showNotice('MCP 已删除');
};
const selectedSkillCount = createForm.bound_skills.length;
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className={`mb-2 text-2xl font-bold ${isDark ? 'text-white' : 'text-gray-900'}`}>
MCP 智能体配置
</h1>
<p className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
管理内置与用户新增的 MCP,并支持绑定技能
</p>
</div>
<button
type="button"
onClick={openCreateModal}
className={`inline-flex h-10 items-center justify-center gap-2 rounded-xl px-4 text-sm font-medium transition ${
isDark ? 'bg-blue-600 text-white hover:bg-blue-500' : 'bg-red-600 text-white hover:bg-red-500'
}`}
>
<Plus className="h-4 w-4" />
新增 MCP
</button>
</div>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="relative max-w-xl flex-1">
<Search
className={`absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 ${
isDark ? 'text-slate-500' : 'text-red-400'
}`}
/>
<input
type="text"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
placeholder="搜索 MCP 名称、Key 或描述"
className={`w-full rounded-xl border py-2.5 pl-10 pr-4 text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-red-500/20 ${
isDark
? 'border-blue-800/30 bg-slate-800/50 text-white placeholder-slate-500'
: 'border-red-200 bg-white text-gray-900 placeholder-red-300'
}`}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
{[
{ key: 'all', label: '全部' },
{ key: 'builtin', label: '内置 MCP' },
{ key: 'custom', label: '用户新增' },
].map((item) => {
const active = typeFilter === item.key;
return (
<button
key={item.key}
type="button"
onClick={() => setTypeFilter(item.key as TypeFilter)}
className={`rounded-full px-3 py-1.5 text-sm transition ${
active
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-600 text-white'
: isDark
? 'bg-slate-800 text-slate-300 hover:bg-slate-700'
: 'bg-white text-gray-600 ring-1 ring-gray-200 hover:bg-gray-50'
}`}
>
{item.label}
</button>
);
})}
</div>
</div>
{loading ? <div className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>加载中...</div> : null}
{error ? <div className="text-sm text-red-500">{error}</div> : null}
{notice ? <div className="text-sm text-emerald-500">{notice}</div> : null}
{!loading && !error ? (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{filteredAgents.map((agent) => (
<div
key={agent.key}
className={`group rounded-xl border p-5 transition-all duration-300 ${
isDark
? 'border-blue-800/30 bg-slate-800/30 backdrop-blur-xl hover:border-blue-500/50'
: 'border-red-100 bg-white hover:border-red-300 hover:shadow-md'
}`}
>
<div className="mb-4 flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div
className={`flex h-12 w-12 items-center justify-center rounded-xl ${
isDark ? 'bg-blue-500/20' : 'bg-red-100'
}`}
>
<Bot className={`h-6 w-6 ${isDark ? 'text-blue-400' : 'text-red-500'}`} />
</div>
<div>
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{agent.name}
</h3>
<p className={`text-xs font-mono ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
{agent.key}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={`rounded-full px-2.5 py-1 text-xs font-medium ${
agent.type === 'builtin'
? isDark
? 'bg-cyan-500/15 text-cyan-200'
: 'bg-cyan-50 text-cyan-700'
: isDark
? 'bg-amber-500/15 text-amber-200'
: 'bg-amber-50 text-amber-700'
}`}
>
{agent.type === 'builtin' ? '内置' : '自定义'}
</span>
<span
className={`rounded-full px-2.5 py-1 text-xs font-medium ${
isDark ? 'bg-slate-700/60 text-slate-300' : 'bg-gray-100 text-gray-600'
}`}
>
#{agent.order + 1}
</span>
</div>
</div>
<p className={`mb-4 min-h-[40px] text-sm ${isDark ? 'text-slate-400' : 'text-gray-600'}`}>
{agent.description || '暂无描述'}
</p>
<div className="mb-4">
<div className={`mb-2 flex items-center gap-1 text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>
<Sparkles className="h-3 w-3" />
已绑定技能
</div>
<div className="flex min-h-[28px] flex-wrap gap-1.5">
{agent.bound_skills.length === 0 ? (
<span className={`text-xs ${isDark ? 'text-slate-600' : 'text-gray-400'}`}>未绑定</span>
) : (
agent.bound_skills.map((skillKey) => {
const skill = skills.find((item) => item.key === skillKey);
return (
<span
key={skillKey}
className={`rounded-full border px-2 py-0.5 text-xs ${
isDark
? 'border-emerald-500/30 bg-emerald-500/15 text-emerald-300'
: 'border-emerald-200 bg-emerald-50 text-emerald-700'
}`}
>
{skill?.name || skillKey}
</span>
);
})
)}
</div>
</div>
<div
className={`flex items-center justify-end gap-2 border-t pt-3 ${
isDark ? 'border-blue-800/30' : 'border-red-100'
}`}
>
<button
type="button"
onClick={() => onEdit?.(agent.key)}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm transition-colors ${
isDark
? 'bg-slate-700/60 text-slate-200 hover:bg-slate-700'
: 'border border-red-200 bg-white text-red-600 hover:bg-red-50'
}`}
>
<Eye className="h-3.5 w-3.5" />
详情
</button>
<button
type="button"
onClick={() => onEdit?.(agent.key)}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm transition-colors ${
isDark ? 'bg-blue-600 text-white hover:bg-blue-500' : 'bg-red-600 text-white hover:bg-red-500'
}`}
>
<Edit className="h-3.5 w-3.5" />
编辑
</button>
{agent.deletable ? (
<button
type="button"
onClick={() => void handleDelete(agent)}
className={`inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm transition-colors ${
isDark
? 'bg-rose-500/15 text-rose-200 hover:bg-rose-500/25'
: 'bg-rose-50 text-rose-600 hover:bg-rose-100'
}`}
>
<Trash2 className="h-3.5 w-3.5" />
删除
</button>
) : null}
</div>
</div>
))}
</div>
) : null}
{!loading && !error && filteredAgents.length === 0 ? (
<div className={`py-12 text-center text-sm ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
没有匹配的 MCP
</div>
) : null}
{createOpen ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4 backdrop-blur-sm"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
closeCreateModal();
}
}}
>
<div
className={`max-h-[85vh] w-full max-w-2xl overflow-hidden rounded-3xl border shadow-2xl ${
isDark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-red-100 bg-white text-gray-900'
}`}
>
<div className="flex items-center justify-between border-b px-6 py-5 dark:border-slate-700">
<div>
<h2 className="text-lg font-semibold">新增 MCP</h2>
<p className={`mt-1 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
输入 MCP 名称、详情并选择要绑定的技能
</p>
</div>
<button
type="button"
onClick={closeCreateModal}
className={`flex h-9 w-9 items-center justify-center rounded-xl ${
isDark ? 'hover:bg-slate-800' : 'hover:bg-gray-100'
}`}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-5 overflow-y-auto px-6 py-5">
<div>
<label className={`mb-2 block text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
MCP 名称
</label>
<input
type="text"
value={createForm.name}
onChange={(e) => setCreateForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="例如:游戏分析助手"
className={`w-full rounded-xl border px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-red-500/20 ${
isDark
? 'border-slate-700 bg-slate-950 text-white placeholder:text-slate-500'
: 'border-gray-200 bg-white text-gray-900 placeholder:text-gray-400'
}`}
/>
</div>
<div>
<label className={`mb-2 block text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
详情
</label>
<textarea
value={createForm.description}
onChange={(e) => setCreateForm((prev) => ({ ...prev, description: e.target.value }))}
placeholder="一句话说明这个 MCP 的职责"
rows={4}
className={`w-full rounded-xl border px-4 py-3 text-sm outline-none focus:ring-2 focus:ring-red-500/20 ${
isDark
? 'border-slate-700 bg-slate-950 text-white placeholder:text-slate-500'
: 'border-gray-200 bg-white text-gray-900 placeholder:text-gray-400'
}`}
/>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<label className={`block text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
绑定技能
</label>
<span className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
已选 {selectedSkillCount} 项
</span>
</div>
<div className="mb-3">
<input
type="text"
value={skillKeyword}
onChange={(e) => setSkillKeyword(e.target.value)}
placeholder="搜索技能名称或描述"
className={`w-full rounded-xl border px-4 py-2.5 text-sm outline-none focus:ring-2 focus:ring-red-500/20 ${
isDark
? 'border-slate-700 bg-slate-950 text-white placeholder:text-slate-500'
: 'border-gray-200 bg-white text-gray-900 placeholder:text-gray-400'
}`}
/>
</div>
<div
className={`max-h-72 space-y-2 overflow-y-auto rounded-2xl border p-3 ${
isDark ? 'border-slate-700 bg-slate-950/60' : 'border-gray-200 bg-gray-50/70'
}`}
>
{filteredSkills.length === 0 ? (
<div className={`py-8 text-center text-sm ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
没有匹配的技能
</div>
) : (
filteredSkills.map((skill) => {
const checked = createForm.bound_skills.includes(skill.key);
return (
<button
key={skill.key}
type="button"
onClick={() => toggleSkill(skill.key)}
className={`flex w-full items-start gap-3 rounded-xl border px-3 py-3 text-left transition ${
checked
? isDark
? 'border-emerald-500/50 bg-emerald-500/10'
: 'border-emerald-200 bg-emerald-50'
: isDark
? 'border-slate-700 bg-slate-900/50 hover:bg-slate-800/70'
: 'border-white bg-white hover:bg-gray-50'
}`}
>
<div
className={`mt-1 h-4 w-4 shrink-0 rounded border ${
checked
? 'border-emerald-500 bg-emerald-500'
: isDark
? 'border-slate-600'
: 'border-gray-300'
}`}
/>
<div className="min-w-0">
<div className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
{skill.name}
<span className={`ml-2 font-mono text-[11px] ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
{skill.key}
</span>
</div>
<p className={`mt-1 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{skill.description || '暂无描述'}
</p>
</div>
</button>
);
})
)}
</div>
</div>
</div>
<div
className={`flex items-center justify-end gap-3 border-t px-6 py-4 ${
isDark ? 'border-slate-700' : 'border-gray-200'
}`}
>
<button
type="button"
onClick={closeCreateModal}
className={`rounded-xl px-4 py-2 text-sm ${
isDark ? 'text-slate-300 hover:bg-slate-800' : 'text-gray-600 hover:bg-gray-100'
}`}
>
取消
</button>
<button
type="button"
onClick={() => void handleCreate()}
disabled={creating}
className={`rounded-xl px-4 py-2 text-sm font-medium text-white ${
isDark ? 'bg-blue-600 hover:bg-blue-500' : 'bg-red-600 hover:bg-red-500'
} disabled:cursor-not-allowed disabled:opacity-60`}
>
{creating ? '创建中...' : '确认新增'}
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,327 @@
import React, { useMemo, useState } from 'react';
import { Bot, ChevronDown, CheckCircle2, Loader2, Sparkles, UserRound } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import MarkdownRenderer from '../MarkdownRenderer';
import { parseAgentResponse } from '../agent/agentResponseParser';
export interface AgentChatMsg {
id: string;
role: 'user' | 'assistant';
content: string;
}
interface AvatarProps {
role: 'user' | 'assistant';
isActive?: boolean;
}
const AgentAvatar: React.FC<AvatarProps> = ({ role, isActive = false }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const Icon = role === 'assistant' ? Bot : UserRound;
const gradientClass =
role === 'assistant'
? isDark
? 'bg-gradient-to-br from-cyan-500 via-blue-600 to-violet-600 text-white'
: 'bg-gradient-to-br from-red-400 via-rose-500 to-orange-400 text-white'
: isDark
? 'bg-gradient-to-br from-slate-500 via-slate-700 to-slate-900 text-white'
: 'bg-gradient-to-br from-sky-400 via-indigo-500 to-violet-500 text-white';
return (
<div
className={`relative flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-2xl shadow-sm ${gradientClass} ${isActive ? 'qa-avatar-active' : ''}`}
>
<div className="absolute inset-0 bg-white/20 opacity-0 qa-avatar-shine" />
<Icon className={`relative z-10 h-5 w-5 ${isActive ? 'qa-avatar-icon-active' : ''}`} />
{role === 'assistant' && isActive && (
<Sparkles className="qa-avatar-spark absolute -right-0.5 -top-0.5 h-3.5 w-3.5 text-yellow-200" />
)}
</div>
);
};
interface CollapsibleProps {
title: React.ReactNode;
children: React.ReactNode;
defaultOpen?: boolean;
className?: string;
}
const Collapsible: React.FC<CollapsibleProps> = ({
title,
children,
defaultOpen = true,
className = '',
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [open, setOpen] = useState(defaultOpen);
return (
<div
className={`overflow-hidden rounded-2xl border ${isDark ? 'border-slate-700/60 bg-slate-900/30' : 'border-red-100 bg-white'} ${className}`}
>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className={`flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition-colors ${
isDark ? 'hover:bg-slate-800/70' : 'hover:bg-red-50/60'
}`}
>
<div className="min-w-0 flex-1">{title}</div>
<ChevronDown
className={`h-4 w-4 shrink-0 transition-transform duration-300 ease-out ${open ? 'rotate-0' : '-rotate-90'} ${
isDark ? 'text-slate-400' : 'text-gray-500'
}`}
/>
</button>
<div
className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'}`}
>
<div className="overflow-hidden">
<div
className={`px-4 pb-4 transition-transform duration-300 ease-out ${open ? 'translate-y-0' : '-translate-y-2'} ${
isDark ? 'text-slate-200' : 'text-gray-700'
}`}
>
{children}
</div>
</div>
</div>
</div>
);
};
interface AssistantAnswerProps {
steps: { title: string; content: string }[];
thinkingMarkdown: string;
answerMarkdown: string;
thinkingComplete: boolean;
streaming: boolean;
}
const AssistantAnswer: React.FC<AssistantAnswerProps> = ({
steps,
thinkingMarkdown,
answerMarkdown,
thinkingComplete,
streaming,
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const showThinkingSection = Boolean(thinkingMarkdown) || (!thinkingComplete && streaming);
return (
<div className="w-full min-w-0 max-w-5xl space-y-3 overflow-hidden">
{steps.length > 0 && (
<Collapsible
defaultOpen
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
title={
<div className="flex items-center gap-3">
<div
className={`flex h-9 w-9 items-center justify-center rounded-2xl ${
isDark ? 'bg-emerald-500/15 text-emerald-300' : 'bg-emerald-50 text-emerald-600'
}`}
>
<CheckCircle2 className="h-5 w-5" />
</div>
<div>
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
步骤
</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
展示技能执行、上下文准备和模型调用进度
</div>
</div>
</div>
}
>
<div className="space-y-3">
{steps.map((step, index) => (
<div
key={`${step.title}-${index}`}
className={`rounded-xl border px-3 py-3 ${
isDark ? 'border-slate-700 bg-slate-900/40' : 'border-gray-100 bg-gray-50'
}`}
>
<div className={`text-sm font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>
{step.title}
</div>
<div className={`mt-1 whitespace-pre-wrap text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
{step.content}
</div>
</div>
))}
</div>
</Collapsible>
)}
{showThinkingSection && (
<Collapsible
defaultOpen
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
title={
<div className="flex items-center gap-3">
<div
className={`flex h-9 w-9 items-center justify-center rounded-2xl ${
isDark ? 'bg-violet-500/15 text-violet-300' : 'bg-orange-50 text-orange-500'
}`}
>
<Sparkles className="h-5 w-5" />
</div>
<div>
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
思考过程
</div>
{thinkingComplete ? (
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
思考已完成,可展开查看
</div>
) : (
<div
className={`qa-generating-marquee text-xs ${
isDark ? 'qa-generating-marquee-dark text-slate-300' : 'qa-generating-marquee-light text-gray-600'
}`}
>
正在生成思考内容...
</div>
)}
</div>
</div>
}
>
{thinkingMarkdown ? (
<MarkdownRenderer
content={thinkingMarkdown}
className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}
/>
) : (
<div className={`flex items-center gap-2 text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Loader2 className="h-4 w-4 animate-spin" />
等待模型输出思考内容...
</div>
)}
</Collapsible>
)}
{answerMarkdown ? (
<div
className={`rounded-2xl border p-4 ${
isDark ? 'border-slate-600/40 bg-slate-700/60' : 'border-gray-100 bg-white'
}`}
>
<MarkdownRenderer
content={answerMarkdown}
className={`text-sm ${isDark ? 'text-slate-200' : 'text-gray-700'}`}
/>
</div>
) : null}
</div>
);
};
interface MessageRowProps {
msg: AgentChatMsg;
streaming?: boolean;
}
const MessageRow: React.FC<MessageRowProps> = ({ msg, streaming = false }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const isUser = msg.role === 'user';
const parsed = useMemo(() => {
if (isUser) return null;
return parseAgentResponse(msg.content);
}, [msg.content, isUser]);
if (isUser) {
return (
<div className="flex items-end justify-end gap-3">
<div
className={`max-w-[72%] rounded-2xl px-4 py-3 text-sm leading-relaxed shadow-sm ${
isDark
? 'bg-blue-600/70 text-white'
: 'bg-gradient-to-r from-red-500 to-rose-500 text-white'
}`}
>
<p className="whitespace-pre-wrap leading-relaxed">{msg.content}</p>
</div>
<AgentAvatar role="user" />
</div>
);
}
return (
<div className="flex items-start gap-3">
<AgentAvatar role="assistant" isActive={streaming} />
<div className="min-w-0 flex-1">
{parsed ? (
<AssistantAnswer
steps={parsed.steps}
thinkingMarkdown={parsed.thinkingMarkdown}
answerMarkdown={parsed.answerMarkdown}
thinkingComplete={parsed.thinkingComplete && !streaming}
streaming={streaming}
/>
) : (
<div
className={`rounded-2xl border p-4 text-sm ${
isDark
? 'border-slate-600/40 bg-slate-700/60 text-slate-200'
: 'border-gray-100 bg-white text-gray-700'
}`}
>
<Loader2 className="h-4 w-4 animate-spin" />
</div>
)}
</div>
</div>
);
};
interface AgentChatMessageListProps {
messages: AgentChatMsg[];
streamingMsgId?: string | null;
agentName?: string;
}
const AgentChatMessageList: React.FC<AgentChatMessageListProps> = ({
messages,
streamingMsgId,
agentName,
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
if (messages.length === 0) {
return (
<div
className={`flex h-full flex-col items-center justify-center text-center ${
isDark ? 'text-slate-500' : 'text-gray-400'
}`}
>
<Bot className="mb-3 h-10 w-10" />
<p className="text-sm">直接与 {agentName || 'MCP 智能体'} 对话以测试当前配置</p>
<p className="mt-1 text-xs">已绑定技能会在步骤区显示实际执行过程</p>
</div>
);
}
return (
<div className="space-y-5">
{messages.map((msg, idx) => {
const isLast = idx === messages.length - 1;
const isStreaming = isLast && msg.role === 'assistant' && msg.id === streamingMsgId;
return <MessageRow key={msg.id} msg={msg} streaming={isStreaming} />;
})}
</div>
);
};
export default AgentChatMessageList;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,248 @@
import React from 'react';
import { Check, Plus, RefreshCcw, Sparkles, UserSearch } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import { InlineCollapsible, StageHeader, ThinkingBlock } from './SixStepCards';
import type { ExpertCard, ExpertMatchData } from './types';
interface ExpertCardItemProps {
card: ExpertCard;
index: number;
selected: boolean;
disabled?: boolean;
onSelect: (id: string) => void;
}
const renderAvatar = (avatar: string | undefined, fallback: React.ReactNode, isDark: boolean) => {
if (avatar && avatar.trim().startsWith('<svg')) {
return (
<span
className={`flex h-10 w-10 items-center justify-center overflow-hidden rounded-2xl ${
isDark ? 'bg-slate-700/40' : 'bg-white shadow-sm'
}`}
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: avatar }}
/>
);
}
return (
<span
className={`flex h-10 w-10 items-center justify-center rounded-2xl ${
isDark ? 'bg-cyan-500/20 text-cyan-200' : 'bg-red-50 text-red-500'
}`}
>
{fallback}
</span>
);
};
const ExpertCardItem: React.FC<ExpertCardItemProps> = ({ card, index, selected, disabled, onSelect }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const baseBorder = selected
? isDark
? 'border-cyan-300/60 ring-1 ring-cyan-300/40 shadow-lg shadow-cyan-500/10 bg-gradient-to-br from-slate-800/70 to-cyan-900/30'
: 'border-red-300 ring-1 ring-red-200 shadow-md shadow-red-100 bg-gradient-to-br from-white to-red-50/60'
: isDark
? 'border-slate-700/60 bg-gradient-to-br from-slate-800/40 to-slate-900/20 hover:border-cyan-300/40'
: 'border-gray-100 bg-white hover:border-red-200';
return (
<button
type="button"
disabled={disabled}
onClick={() => onSelect(card.id)}
className={`w-full text-left rounded-2xl border p-3 transition-all sixstep-fade-up disabled:cursor-not-allowed disabled:opacity-60 ${baseBorder}`}
>
<div className="flex items-start gap-3">
<span
className={`w-7 h-7 rounded-xl border flex items-center justify-center text-xs font-semibold shrink-0 ${
isDark
? 'bg-cyan-500/20 text-cyan-200 border-cyan-400/30'
: 'bg-red-50 text-red-600 border-red-200'
}`}
>
{index.toString().padStart(2, '0')}
</span>
{renderAvatar(card.avatarUrl, <UserSearch className="w-5 h-5" />, isDark)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className={`text-sm font-semibold leading-snug truncate ${isDark ? 'text-white' : 'text-gray-900'}`}>
{card.name}
</div>
{card.isCustom && (
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
isDark ? 'bg-violet-500/20 text-violet-200' : 'bg-violet-50 text-violet-600'
}`}
>
新建
</span>
)}
</div>
<div className={`text-xs mt-1 leading-relaxed line-clamp-3 ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
{card.detail || '暂无描述'}
</div>
</div>
<span
className={`shrink-0 flex h-6 w-6 items-center justify-center rounded-full border transition ${
selected
? isDark
? 'border-cyan-300 bg-cyan-400/30 text-cyan-100'
: 'border-red-400 bg-red-500 text-white'
: isDark
? 'border-slate-600 text-transparent'
: 'border-gray-300 text-transparent'
}`}
>
<Check className="w-3.5 h-3.5" />
</span>
</div>
</button>
);
};
interface ExpertMatchCardsProps {
data: ExpertMatchData;
onSelectExpert: (id: string) => void;
onRefetch: () => void;
onCreateExpert: () => void;
onConfirm: () => void;
locked?: boolean;
}
const ExpertMatchCards: React.FC<ExpertMatchCardsProps> = ({
data,
onSelectExpert,
onRefetch,
onCreateExpert,
onConfirm,
locked,
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const isSearching = data.status === 'searching';
const isCreating = data.status === 'creating';
const isReady = data.status === 'ready' || data.status === 'confirmed';
const cards = data.experts ?? [];
return (
<InlineCollapsible
defaultOpen
title={
<StageHeader
title="专家匹配"
desc={
isSearching
? cards.length > 0
? '专家已匹配完成,可选择一位进入下一步'
: '正在调用查找专家 MCP...'
: isCreating
? '正在调用创建专家 MCP...'
: data.status === 'error'
? data.errorMessage || '专家匹配失败'
: '请选择一位专家进入下一步'
}
count={cards.length}
loading={(isSearching && cards.length === 0) || isCreating}
done={data.status === 'confirmed'}
icon={UserSearch}
accent="actor"
/>
}
>
<div className="space-y-3">
<ThinkingBlock text={data.thinking} loading={isSearching && cards.length === 0 && !data.thinking} />
<InlineCollapsible
variant="subtle"
defaultOpen
title={
<div className="flex items-center gap-2">
<span className="text-xs font-medium">推荐专家列表</span>
<span className="text-[10px] opacity-70">{cards.length} 位</span>
</div>
}
>
{cards.length === 0 ? (
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
{isSearching && cards.length === 0 ? '正在匹配专家...' : '暂无匹配的专家'}
</div>
) : (
<div className="space-y-2">
{cards.map((card, idx) => (
<ExpertCardItem
key={card.id}
card={card}
index={idx + 1}
selected={data.selectedExpertId === card.id}
disabled={locked}
onSelect={onSelectExpert}
/>
))}
</div>
)}
</InlineCollapsible>
{data.status === 'error' && (
<div className={`rounded-xl border px-3 py-2 text-xs ${
isDark ? 'border-rose-500/30 bg-rose-500/10 text-rose-200' : 'border-rose-200 bg-rose-50 text-rose-700'
}`}>
{data.errorMessage}
</div>
)}
<div className="flex flex-wrap items-center gap-2 pt-1">
<button
type="button"
onClick={onRefetch}
disabled={locked || isSearching || isCreating}
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${
isDark
? 'border-slate-600 bg-slate-800/60 text-slate-200 hover:bg-slate-700'
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
}`}
>
<RefreshCcw className={`w-3.5 h-3.5 ${isSearching ? 'animate-spin' : ''}`} />
重新查找
</button>
<div className={`flex-1 text-right text-[11px] ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
没有想要的专家?
</div>
<button
type="button"
onClick={onCreateExpert}
disabled={locked || isCreating || isSearching}
className={`inline-flex items-center gap-1.5 rounded-xl border px-3 py-1.5 text-xs font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${
isDark
? 'border-violet-400/40 bg-violet-500/15 text-violet-100 hover:bg-violet-500/25'
: 'border-violet-200 bg-violet-50 text-violet-700 hover:bg-violet-100'
}`}
>
<Plus className="w-3.5 h-3.5" />
创建专家
</button>
<button
type="button"
onClick={onConfirm}
disabled={locked || !data.selectedExpertId || !isReady}
className={`inline-flex items-center gap-1.5 rounded-xl px-3 py-1.5 text-xs font-semibold text-white transition disabled:cursor-not-allowed disabled:opacity-50 ${
isDark
? 'bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400'
: 'bg-gradient-to-r from-red-500 to-rose-500 hover:from-red-400 hover:to-rose-400'
}`}
>
<Sparkles className="w-3.5 h-3.5" />
选择该专家
</button>
</div>
</div>
</InlineCollapsible>
);
};
export default ExpertMatchCards;
@@ -0,0 +1,846 @@
# 前端意图识别与交互集成 — 实现指南
> 对应后端系统:`backend/app/intent/` + `backend/app/orchestrator.py`
> 前端主文件:`SixStepChatPage.tsx`, `api.ts`, `types.ts`, `SixStepConversation.tsx`
> 完成度:**全部 6 步 + follow_up ✅**
---
## 一、核心概念回顾
### 意图体系(Intent System)
前端负责**传递上下文** + **处理 SSE 事件流** + **更新 UI 状态**,后端负责**识别意图** + **分支路由** + **执行业务**。
```
前端按需收集当前状态信息
↓
打包到 IntentOrchestrateRequest
↓
POST /orchestrate-stream (SSE)
↓
后端 detect_intent() 识别意图
↓
根据意图分支,emit SSE 事件
↓
前端处理事件,同步更新 message 和 phase
```
### 七种意图与前端对应
| 意图 | 触发词 | 后端处理 | 前端转换 | 最终状态 |
|------|-------|---------|---------|---------|
| **find_expert** | "找专家" | find_expert MCP | phase: idle → expert-match | 显示专家卡片 + MCP 选择弹窗 |
| **reselect_expert** | "换专家" | find_expert MCP + 过滤 | phase: expert-confirm → expert-match | 显示新的专家卡片 |
| **start_analysis** | "分析" "开始" | 全新 6bf | phase: expert-confirm → generate | 显示 6bf 阶段卡片和流程图 |
| **restart_analysis** | "重新分析" | 清空+重新执行 | phase: completed → generate | 同上 |
| **continue_analysis** | "继续" (在 paused 时) | 从中断点恢复 | phase: paused → generate | 显示 resume 提示 + 继续的卡片 |
| **follow_up** | "为什么" "解释" (在 completed 时) | LLM 回答 | phase: completed → follow-up | Markdown 渲染追问回答 |
| **unknown** | 含糊/歧义 | 请求澄清 | phase: * → clarifying | 显示澄清问题 |
---
## 二、数据模型层(types.ts)
### 2.1 SixStepPhase 枚举
```typescript
export type SixStepPhase =
| 'idle' // 【初始】未开始任何操作
| 'intent-detecting' // 【过渡】意图正在识别中(通常很短,用户看不到)
| 'expert-match' // 【等待】展示专家卡片,用户可浏览、搜索、重选
| 'expert-confirm' // 【交互】MCP 多选弹窗打开,用户确认选择
| 'generate' // 【处理】6bf 编排执行中,显示进度和卡片
| 'paused' // 【中断】用户手动停止;可继续或重新分析
| 'completed' // 【完成】编排全部完成;可追问或重新分析
| 'clarifying' // 【澄清】后端无法理解,请用户补充
| 'follow-up'; // 【追问】正在基于已有分析回答用户问题
```
**关键点**:
- `expert-match` vs `expert-confirm` 的区别:前者是**显示结果**,后者是**交互确认**(MCP 弹窗)
- `paused` **必须**在 `handleStop()` 手动设置,后端的 continue_analysis 分支不会主动发送 paused
- `follow-up` 仅在 `current_phase === 'completed'` 且用户输入匹配追问模式时触发
### 2.2 SixStepMessage 新增字段
```typescript
export interface SixStepMessage {
// ... 原有字段 (id, role, content, timestamp, stages, finalAnswer, ...)
// ========== 意图识别相关 ==========
intent?: {
name: string; // 识别的意图名(find_expert / follow_up / unknown 等)
confidence?: number; // 置信度 [0.0, 1.0]
reason?: string; // 识别理由(用于 debug/日志)
};
// ========== 澄清问题 ==========
clarifyQuestion?: string; // 后端提出的澄清问题(当 intent.name === "unknown")
// ========== 继续分析上下文 ==========
/**
* 原始步骤数据(来自后端 step_end 事件)。
* 结构:BackendStepResult[] = { key, name, status, thinking, structured_outputs, markdown, references }
* 用途:
* - 当用户暂停(handleStop)时,保存这些数据
* - 当用户"继续分析"时,传回后端作为 last_steps,后端可恢复上下文
* - 当用户"追问"时,后端用这些数据构造 context,生成更准确的回答
*/
rawSteps?: BackendStepResult[];
/**
* 本轮是从哪个步骤继续的(来自后端 resume 事件)。
* 值:step key(如 "behavior_factor")或 step name(如 "预期行为与驱动因素")
* UI 用途:在 AssistantMessage 上方显示"⏸ 从 [步骤名] 继续"提示
*/
resumedFrom?: string;
}
```
### 2.3 IntentOrchestrateRequest(api.ts)
```typescript
export interface IntentOrchestrateRequest {
// 意图识别相关
intent_mode?: 'auto' | 'force_orchestrate' | 'force_find_expert';
// 会话状态(前端告知后端)
current_phase: string;
selected_expert?: ExpertCard | null;
selected_mcps?: SelectableMcpKey[];
// 继续分析相关
last_steps?: BackendStepResult[];
resume_from_step?: string;
expert_candidates?: ExpertCard[]; // 用于 reselect_expert 时过滤
}
```
---
## 三、流处理层(api.ts)
### 3.1 runIntentOrchestrateStream() — 统一 SSE 处理器
```typescript
export async function runIntentOrchestrateStream(
message: string,
options: {
onUpdate?: (msg: SixStepMessage) => void;
onComplete?: () => void;
abortSignal?: AbortSignal;
},
requestBody?: IntentOrchestrateRequest
): Promise<SixStepStreamController>
```
**内部逻辑概述**:
```
1. 发起 POST /orchestrate-stream(带 requestBody 和 AbortSignal)
2. 遍历 SSE 事件流,解析 JSON
3. 根据事件名分发到不同处理函数
4. 每次修改 message 后调用 onUpdate(message),触发前端 re-render
5. 接收 done 或 error 事件时停止
```
### 3.2 关键事件处理函数
#### **intent 事件**
```typescript
// 来自后端:{ intent: "follow_up", confidence: 0.88, reason: "..." }
case 'intent':
const intentName = data.intent;
message.intent = { name: intentName, ...data };
// 👇 根据意图确定流程分支
if (intentName === 'find_expert' || intentName === 'reselect_expert') {
flow = 'expert'; // 接下来期待 expert_start/expert_end
} else if (intentName === 'follow_up') {
flow = 'follow_up'; // 接下来期待 delta(文本块)
message.phase = 'follow-up';
message.outputFormat = 'markdown';
} else if (intentName === 'unknown') {
flow = 'clarify'; // 接下来期待 clarify 事件
} else {
// start_analysis / restart_analysis / continue_analysis
flow = 'generate'; // 接下来期待 start / step_start / step_end
}
break;
```
**前端的 flow 变量**(内部状态,不持久化):用于判断预期的**下一个事件类型**和**数据处理方式**。
#### **expert_start / expert_end 事件**
```typescript
case 'expert_start':
message.phase = 'expert-match';
message.expertMatch = {
status: 'searching',
thinking: '',
experts: [],
selectedExpertId: undefined,
};
break;
case 'expert_end':
// data = { experts: [...], thinking: str, is_reselect: bool, ... }
message.expertMatch!.status = 'ready';
message.expertMatch!.thinking = data.thinking;
// 逐张卡片追加,配合 pop 动画
for (const expert of data.experts) {
message.expertMatch!.experts.push({
id: expert.id,
name: expert.name,
detail: expert.detail,
...
});
// 每张卡片之间延迟 100ms,触发 pop 动画
}
// 卡片全部加载后,更新 phase 并打开 MCP 选择弹窗
message.phase = 'expert-confirm';
// → SixStepChatPage 监听 expertMatch.status === 'ready',打开 mcpModalOpen
break;
```
#### **clarify 事件**
```typescript
case 'clarify':
message.phase = 'clarifying';
message.clarifyQuestion = data.message; // "我没有完全理解你的意图,请补充说明..."
break;
```
#### **resume 事件**(continue_analysis 特有)
```typescript
case 'resume':
// data = { task_id, resume_from, mcp_list: [...] }
message.resumedFrom = data.resume_from; // 后端告诉前端从哪个步骤继续
// UI 用途:在 AssistantMessage 顶部显示 "⏸ 从 [步骤名] 继续"
// 该变量会保留在消息中,整个 continue 过程结束后仍显示
break;
```
#### **start 事件**(开始 6bf 编排)
```typescript
case 'start':
message.phase = 'generate';
// data = { task_id, mcp_list: [...], ts: number }
// 👇 防止 restart_analysis 时保留旧卡片
if (!isResume) { // isResume 由 resume 事件设置,continue_analysis 时为 true
message.stages = {}; // 清空
message.subStepStatus = {};
message.mainStepStatus = { info: 'done', sixstep: 'pending', final: 'pending' };
}
// 若 isResume === true(continue_analysis),则保留已有的 stages,只追加新步骤
message.currentMainStep = 'sixstep';
break;
```
**关键**:`!isResume` 的条件确保 **restart_analysis 时清空旧卡片**,而 **continue_analysis 时保留已有卡片**。
#### **step_start / step_end 事件**
```typescript
case 'step_start':
// data = { name: "目的分析", key: "purpose" }
const stepKey = data.key;
if (stepKey === 'purpose') {
message.subStepStatus['purpose'] = 'running';
message.currentSubStep = 'purpose';
message.stages!.purpose = { thinking: '', cards: [], complete: false };
}
// ... 类似处理 actor / behavior / narrative
break;
case 'step_end':
// data = { key, status, thinking, structured_outputs, markdown, references, ... }
const stepKey = data.key;
// 👇 原始数据存档,用于后续 continue_analysis / follow_up
if (stepKey !== 'info_prepare') {
message.rawSteps = message.rawSteps || [];
message.rawSteps.push(data); // 保存完整的 BackendStepResult
}
// 更新当前消息的对应阶段
if (stepKey === 'purpose') {
message.stages!.purpose!.thinking = data.thinking;
message.stages!.purpose!.cards = buildPurposeCards(data.structured_outputs);
message.stages!.purpose!.complete = true;
message.subStepStatus['purpose'] = 'done';
}
// ... 类似处理 actor / behavior / narrative
break;
```
#### **delta 事件**(follow_up 专有)
```typescript
case 'delta':
// data = { text: "..." }(文本块)
// 仅当 flow === 'follow_up' 时处理
if (flow === 'follow_up') {
message.markdownContent = (message.markdownContent || '') + data.text;
onUpdate(message); // 逐块更新前端,流式显示
}
break;
```
#### **done 事件**
```typescript
case 'done':
// 各分支的终止信号
if (flow === 'follow_up') {
message.phase = 'completed'; // ✅ follow_up 回答完成
} else if (flow === 'expert') {
// 专家查询完成,等待前端用户在弹窗中确认选择
// phase 保持 'expert-confirm'
} else if (flow === 'generate') {
message.phase = 'completed'; // ✅ 6bf 推演完成
} else if (flow === 'clarify') {
// 保持 'clarifying',等待用户重新输入
}
onComplete?.(); // 回调:请求完成
break;
```
---
## 四、主页面逻辑(SixStepChatPage.tsx)
### 4.1 新增状态变量
```typescript
// 当前阶段(驱动 UI 和后端意图识别)
const [currentPhase, setCurrentPhase] = useState<SixStepPhase>('idle');
// 已选专家和 MCP(用于请求体和 UI 展示)
const [selectedExpert, setSelectedExpert] = useState<ExpertCard | null>(null);
const [selectedMcps, setSelectedMcps] = useState<SelectableMcpKey[]>([]);
// 已推荐过的专家列表(reselect_expert 时用于过滤)
const [expertCandidates, setExpertCandidates] = useState<ExpertCard[]>([]);
// 临时保存原始用户输入(确认专家后需要)
const [lastUserInput, setLastUserInput] = useState<string>('');
```
### 4.2 handleSend() — 请求体构造
```typescript
async function handleSend(inputText: string) {
setLastUserInput(inputText); // 保存原始输入
// 【1】获取上次的助手消息(用于获取 rawSteps)
const lastAssistant = messages.findLast(m => m.role === 'assistant');
// 【2】构造请求体(关键!传递上下文给后端)
const requestBody: IntentOrchestrateRequest = {
intent_mode: 'auto', // 自动意图识别
current_phase: currentPhase,
selected_expert: selectedExpert,
selected_mcps: selectedMcps,
last_steps: lastAssistant?.rawSteps || [], // 👈 用于 continue / follow_up
resume_from_step: undefined, // 让后端自动推断
expert_candidates: expertCandidates, // 👈 用于 reselect_expert 过滤
};
// 【3】调用流处理
const controller = await runIntentOrchestrateStream(
inputText,
{
onUpdate: (msg) => {
// 替换消息列表中的末尾消息(正在生成的消息)
setMessages(prev => {
const newMessages = [...prev];
newMessages[newMessages.length - 1] = msg;
return newMessages;
});
// 同步更新当前阶段
setCurrentPhase(msg.phase || 'idle');
// 【关键】监听专家卡片加载完成,打开 MCP 选择弹窗
if (msg.expertMatch?.status === 'ready') {
setMcpModalOpen(true);
}
},
onComplete: () => {
setIsGenerating(false);
},
},
requestBody
);
controllerRef.current = controller;
setIsGenerating(true);
}
```
**核心三步**:
1. **构造请求体**:携带所有必要的上下文(current_phase, last_steps, expert_candidates)
2. **调用流处理**:传入 onUpdate 回调,每个 SSE 事件都更新前端
3. **监听事件**:特别是 expertMatch.status === 'ready',自动打开 MCP 弹窗
### 4.3 handleMcpModalConfirm() — 确认专家后立即启动分析
```typescript
function handleMcpModalConfirm(selectedExpert: ExpertCard, selectedMcpList: SelectableMcpKey[]) {
// 【1】保存用户选择
setSelectedExpert(selectedExpert);
setSelectedMcps(selectedMcpList);
setExpertCandidates(prev => [...prev, selectedExpert]); // 添加到候选列表
// 【2】立即用 force_orchestrate 启动分析(不需要等用户再次输入)
const requestBody: IntentOrchestrateRequest = {
intent_mode: 'force_orchestrate', // 👈 强制执行编排,不识别意图
current_phase: 'expert-confirm',
selected_expert: selectedExpert,
selected_mcps: selectedMcpList,
last_steps: [],
};
// 【3】使用保存的原始输入(用户之前在输入框里输入的文本)
runIntentOrchestrateStream(
lastUserInput, // 👈 原始输入
{
onUpdate: (msg) => {
setMessages(prev => [...prev.slice(0, -1), msg]);
setCurrentPhase(msg.phase || 'idle');
},
onComplete: () => setIsGenerating(false),
},
requestBody
);
}
```
**关键点**:
- `intent_mode: 'force_orchestrate'` — 跳过意图识别,直接执行 6bf
- 使用 `lastUserInput`(保存的原始输入)而非动态输入
- 添加 selectedExpert 到 expertCandidates,以便后续 reselect 时过滤
### 4.4 handleRefetchExpert() — 重选专家
```typescript
function handleRefetchExpert() {
const requestBody: IntentOrchestrateRequest = {
intent_mode: 'force_find_expert', // 👈 强制查询专家
current_phase: 'expert-confirm',
expert_candidates: expertCandidates, // 👈 告诉后端已有候选,避免重复推荐
last_steps: [],
};
runIntentOrchestrateStream(
lastUserInput,
{
onUpdate: (msg) => {
setMessages(prev => [...prev.slice(0, -1), msg]);
setCurrentPhase(msg.phase || 'idle');
// MCP 弹窗保持打开,等待用户重新选择
},
},
requestBody
);
}
```
### 4.5 handleStop() — 停止生成
```typescript
function handleStop() {
controllerRef.current?.stop();
// 👇 关键:标记当前消息为 paused,供后续 continue_analysis 识别
setMessages(prev => {
const newMessages = [...prev];
const lastMsg = newMessages[newMessages.length - 1];
if (lastMsg?.role === 'assistant') {
lastMsg.phase = 'paused';
}
return newMessages;
});
setCurrentPhase('paused');
setIsGenerating(false);
}
```
**为什么需要标记 paused?**
- 后端的 intent 识别器会检查 `current_phase === 'paused'`,优先返回 `continue_analysis` 意图
- 前端显示 "⏸ 从 [步骤] 继续" 提示,让用户清楚地知道可以继续
---
## 五、对话区渲染(SixStepConversation.tsx)
### 5.1 AssistantMessage 的新渲染逻辑
```typescript
const AssistantMessage = ({ message, active }: Props) => {
const isExpertPhase = message.phase === 'expert-match' || message.phase === 'expert-confirm';
const isFollowUp = message.phase === 'follow-up';
const isClarifying = message.phase === 'clarifying';
const isMarkdown = message.outputFormat === 'markdown';
// 👇 【防护条件】防止旧分析内容在专家/澄清/追问阶段出现
const showAnalysisContent = !isExpertPhase && !isClarifying && !isFollowUp;
return (
<div>
{/* 【分支 1】专家匹配阶段 */}
{isExpertPhase && message.expertMatch && (
<ExpertMatchCards
data={message.expertMatch}
onRefetch={onRefetch}
onConfirm={onConfirm}
/>
)}
{/* 【分支 2】6bf 分析阶段(防护条件) */}
{showAnalysisContent && (
<>
{/* 继续分析提示 */}
{message.resumedFrom && (
<div className="resume-hint">
⏸ 从「{message.resumedFrom}」继续
</div>
)}
{/* 各阶段卡片 */}
{message.stages?.info && <InfoStage ... />}
{message.stages?.purpose && <PurposeStage ... />}
{message.stages?.actor && <ActorStage ... />}
{message.stages?.behavior && <BehaviorStage ... />}
{message.stages?.narrative && <NarrativeStage ... />}
{message.finalAnswer && <FinalAnswer ... />}
</>
)}
{/* 【分支 3】追问回答(Markdown) */}
{isFollowUp && message.markdownContent && (
<MarkdownRenderer content={message.markdownContent} />
)}
{/* 【分支 4】Markdown 输出格式 */}
{isMarkdown && !isFollowUp && message.markdownContent && (
<MarkdownRenderer content={message.markdownContent} />
)}
{/* 【分支 5】澄清问题 */}
{isClarifying && message.clarifyQuestion && (
<div className="clarify-block">
<p className="text-sm text-gray-600">我需要你进一步说明:</p>
<p className="font-medium">{message.clarifyQuestion}</p>
<p className="text-xs text-gray-500 mt-2">请重新输入以补充信息</p>
</div>
)}
</div>
);
};
```
**关键防护条件**:
```typescript
const showAnalysisContent = !isExpertPhase && !isClarifying && !isFollowUp;
```
这一行确保了:
- ✅ `expert-match` 阶段只显示专家卡片,不显示旧分析
- ✅ `clarifying` 阶段只显示澄清问题,不显示其他内容
- ✅ `follow-up` 阶段只显示 Markdown 回答,不显示旧分析
### 5.2 阶段加载动画
```typescript
// 当前阶段正在生成时,显示 shimmer(流光)动画
const purposeGenerating = message.phase === 'generate' &&
message.currentSubStep === 'purpose' &&
active;
<StageHeader
loading={purposeGenerating}
icon={<Target ... />}
title="目的分析"
count={message.stages?.purpose?.cards.length || 0}
/>
```
---
## 六、生命周期示例
### 6.1 用户"找专家"的流程
```
【用户输入】"帮我找个专家"
↓
【前端】handleSend("帮我找个专家")
- requestBody: { intent_mode: 'auto', current_phase: 'idle', last_steps: [] }
- 调用 runIntentOrchestrateStream
↓
【后端】detect_intent("帮我找个专家", current_phase='idle', ...)
- 规则识别:匹配 _FIND_PATTERNS → confidence=0.88
- 返回:IntentResult(intent='find_expert', ...)
↓
【后端】根据 intent='find_expert',调用 _stream_find_expert()
- emit: expert_start
- 查询 find_expert MCP agent
- emit: expert_end { experts: [...] }
- emit: done
↓
【前端】处理 SSE 事件
- intent: { name: 'find_expert', ... }
→ flow = 'expert'
- expert_start
→ message.phase = 'expert-match'
→ message.expertMatch.status = 'searching'
- expert_end
→ message.expertMatch.status = 'ready'
→ message.expertMatch.experts = [...]
→ message.phase = 'expert-confirm'
→ 检测到 expertMatch.status === 'ready'
→ setMcpModalOpen(true) 打开 MCP 选择弹窗
- done
→ onComplete()
↓
【用户】在 MCP 选择弹窗中选择专家和 MCP,点击"确认"
↓
【前端】handleMcpModalConfirm(selectedExpert, selectedMcps)
- setSelectedExpert(selectedExpert)
- setSelectedMcps(selectedMcps)
- requestBody: { intent_mode: 'force_orchestrate', ... }
- 调用 runIntentOrchestrateStream(lastUserInput, ...)
↓
【后端】orchestrate_stream() with intent_mode='force_orchestrate'
- 跳过意图识别
- 调用 _stream_orchestrate(..., intent_name='start_analysis')
- emit: start
- emit: step_start / step_end × 4 (purpose / actor / behavior / narrative)
- emit: flow { nodes, edges }
- emit: done
↓
【前端】处理 SSE 事件
- start
→ message.phase = 'generate'
→ 初始化 stages 和状态
- step_start × 4
→ 每个步骤的 status = 'running'
→ 对应阶段初始化为空
- step_end × 4
→ 填充 stages[stepKey].cards
→ message.rawSteps.push(data)
→ status = 'done'
- flow
→ 流程图数据更新(供沙箱显示)
- done
→ message.phase = 'completed'
↓
【前端 UI】
- ✅ 显示 4 个阶段的卡片
- ✅ 显示流程图沙箱
- ✅ 阶段完成时有 pop 动画和叙事卡片
```
### 6.2 用户"继续分析"的流程
```
【前次】完成了目的、行为体阶段,用户点击"停止"
- 后端返回 step_end(预期行为/驱动因素)但用户取消
- message.phase = 'paused'
- message.rawSteps = [
{ key: 'purpose', status: 'completed', ... },
{ key: 'actor', status: 'completed', ... },
{ key: 'behavior_factor', status: 'running', ... }
]
↓
【用户输入】"继续"
↓
【前端】handleSend("继续")
- requestBody: {
intent_mode: 'auto',
current_phase: 'paused', // 👈 关键
last_steps: message.rawSteps, // 👈 关键
}
↓
【后端】detect_intent("继续", current_phase='paused', last_steps=[...])
- 规则识别:匹配 _CONTINUE_PATTERNS AND current_phase='paused'
- 返回:IntentResult(intent='continue_analysis', confidence=0.90)
↓
【后端】_stream_orchestrate(..., intent_name='continue_analysis')
- _resume_context_from_steps(req.last_steps, None)
→ 检查已完成的步骤:['purpose', 'actor']
→ resume_key = 'behavior_factor'(下一个未完成的)
- emit: resume { resume_from: 'behavior_factor', mcp_list: ['behavior_factor', 'narrative'] }
- emit: start { mcp_list: ['behavior_factor', 'narrative'] }
- emit: step_start / step_end × 2 (behavior_factor, narrative)
- emit: done
↓
【前端】处理 SSE 事件
- intent: { name: 'continue_analysis', ... }
- resume: { resume_from: 'behavior_factor' }
→ message.resumedFrom = 'behavior_factor'
→ isResume = true // 标记为继续,不清空旧 stages
- start
→ 因为 isResume=true,不清空 message.stages
→ 保留已有的 purpose/actor 卡片
- step_start / step_end × 2
→ 追加新的 behavior/narrative 卡片
→ message.rawSteps.push(behavior_data)
→ message.rawSteps.push(narrative_data)
- done
→ message.phase = 'completed'
↓
【前端 UI】
- ✅ 显示"⏸ 从「预期行为与驱动因素」继续"提示
- ✅ 保留旧的目的/行为体卡片
- ✅ 追加新的预期行为/叙事卡片
```
### 6.3 用户"追问"的流程
```
【前次】完成了完整的 6bf,message.phase = 'completed'
- message.rawSteps = [
{ key: 'purpose', ... },
{ key: 'actor', ... },
{ key: 'behavior_factor', ... },
{ key: 'narrative', ... }
]
↓
【用户输入】"第一条是什么意思"
↓
【前端】handleSend("第一条是什么意思")
- requestBody: {
intent_mode: 'auto',
current_phase: 'completed', // 👈 关键
last_steps: message.rawSteps,
}
↓
【后端】detect_intent("第一条是什么意思", current_phase='completed', last_steps=[...])
- 规则识别:匹配 _FOLLOW_UP_PATTERNS(r"第[一二三四五六七八九十\d]+[点条个项]")
- 返回:IntentResult(intent='follow_up', confidence=0.88)
↓
【后端】_stream_follow_up(req, ...)
- 提取 req.last_steps 中已完成步骤的数据
- 构造 context_str(包含目的、行为体、预期行为、叙事)
- 调用 LLM 流式回答
- emit: delta { text: "..." } × N
- emit: done
↓
【前端】处理 SSE 事件
- intent: { name: 'follow_up', ... }
→ flow = 'follow_up'
→ message.phase = 'follow-up'
→ message.outputFormat = 'markdown'
- delta × N
→ message.markdownContent += text
→ 每块更新都触发 onUpdate,前端流式显示
- done
→ message.phase = 'completed'
↓
【前端 UI】
- ✅ 显示 Markdown 格式的追问回答
- ✅ 不显示旧的 6bf 卡片(因为 showAnalysisContent = false)
- ✅ 流式效果:用户看到实时生成的文本
```
---
## 七、常见问题解决
### Q1: 完成 6bf 后再选专家,为什么显示了旧的分析内容?
**原因**:AssistantMessage 中没有正确应用 `showAnalysisContent` 防护条件。
**检查**:
```typescript
const showAnalysisContent = !isExpertPhase && !isClarifying && !isFollowUp;
```
确保:
- `isExpertPhase = message.phase === 'expert-match' || message.phase === 'expert-confirm'` ✓
- 所有 `stages` / `finalAnswer` 的渲染都被 `{showAnalysisContent && (...)}` 包裹 ✓
### Q2: 继续分析后,旧卡片消失了
**原因**:SSE start 事件中清空了 stages(应该只在 !isResume 时清空)。
**检查** `api.ts` 中的 start 事件处理:
```typescript
case 'start':
if (!isResume) { // 👈 这个条件必须有
message.stages = {};
}
// ...
break;
```
### Q3: 用户点"继续",但后端返回了"开始新分析"
**原因**:`current_phase` 未传递为 'paused',或未手动设置 `phase = 'paused'`。
**检查** `SixStepChatPage.handleStop()`:
```typescript
setMessages(prev => {
const newMessages = [...prev];
const lastMsg = newMessages[newMessages.length - 1];
if (lastMsg?.role === 'assistant') {
lastMsg.phase = 'paused'; // 👈 必须设置
}
return newMessages;
});
setCurrentPhase('paused'); // 👈 必须同步状态
```
### Q4: 追问回答为空
**原因**:
1. `current_phase !== 'completed'`(后端不识别为 follow_up)
2. `last_steps` 为空或格式错误
3. LLM 调用失败(查看后端日志)
**检查**:
- 确保 message.rawSteps 在 step_end 时被正确赋值
- 确保 request 中 `last_steps` 被传递为 `message.rawSteps`
- 后端日志是否有 `_stream_follow_up` 的报错
---
## 八、开发建议
1. **调试技巧**:在 `onUpdate` 中 `console.log(message)` 观察每个事件的状态变化
2. **SSE 事件顺序不可打破**:前端严格依赖事件顺序做状态机转换
3. **rawSteps 的完整性**:非 info_prepare 的所有 step_end 都必须存档,否则 continue/follow_up 会丢失数据
4. **phase 与 outputFormat 的对应**:
- follow-up → outputFormat = 'markdown'
- generate → outputFormat = 'structured'(卡片渲染)
- expert-match → 无 outputFormat(专家卡片独立)
---
**相关文件导航**:
- 后端意图系统:`backend/app/intent/`
- 后端编排系统:`backend/app/orchestrator.py`
- 前端核心:`SixStepChatPage.tsx` (state hub) → `api.ts` (SSE handler) → `SixStepConversation.tsx` (rendering)
- 前端类型:`types.ts`
- 前端原始文档:`doc.md`
@@ -0,0 +1,233 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Check, Megaphone, Target, Users, X, Zap } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import type { ExpertCard, SelectableMcpKey } from './types';
interface McpOption {
key: SelectableMcpKey;
name: string;
description: string;
icon: React.ComponentType<{ className?: string }>;
accent: 'purpose' | 'actor' | 'behavior' | 'narrative';
}
const MCP_OPTIONS: McpOption[] = [
{ key: 'purpose', name: '目的', description: '识别本次推演的核心目的,构成后续推理的锚点。', icon: Target, accent: 'purpose' },
{ key: 'actor', name: '行为体', description: '推演支撑目的所需的关键行为体角色。', icon: Users, accent: 'actor' },
{ key: 'behavior_factor', name: '预期行为和驱动因素', description: '为每个行为体推演预期行为及背后的驱动因素。', icon: Zap, accent: 'behavior' },
{ key: 'narrative', name: '叙事', description: '基于驱动因素构建可被复用的叙事框架。', icon: Megaphone, accent: 'narrative' },
];
const ACCENT_LIGHT: Record<McpOption['accent'], { selected: string; idle: string; chip: string }> = {
purpose: {
selected: 'border-orange-300 bg-orange-50/70 ring-1 ring-orange-200',
idle: 'border-gray-200 bg-white hover:border-orange-200',
chip: 'bg-orange-50 text-orange-600',
},
actor: {
selected: 'border-red-300 bg-red-50/70 ring-1 ring-red-200',
idle: 'border-gray-200 bg-white hover:border-red-200',
chip: 'bg-red-50 text-red-600',
},
behavior: {
selected: 'border-emerald-300 bg-emerald-50/70 ring-1 ring-emerald-200',
idle: 'border-gray-200 bg-white hover:border-emerald-200',
chip: 'bg-emerald-50 text-emerald-600',
},
narrative: {
selected: 'border-amber-300 bg-amber-50/70 ring-1 ring-amber-200',
idle: 'border-gray-200 bg-white hover:border-amber-200',
chip: 'bg-amber-50 text-amber-600',
},
};
const ACCENT_DARK: Record<McpOption['accent'], { selected: string; idle: string; chip: string }> = {
purpose: {
selected: 'border-violet-300/50 bg-violet-500/15 ring-1 ring-violet-300/30',
idle: 'border-slate-700 bg-slate-800/60 hover:border-violet-300/40',
chip: 'bg-violet-500/20 text-violet-200',
},
actor: {
selected: 'border-cyan-300/50 bg-cyan-500/15 ring-1 ring-cyan-300/30',
idle: 'border-slate-700 bg-slate-800/60 hover:border-cyan-300/40',
chip: 'bg-cyan-500/20 text-cyan-200',
},
behavior: {
selected: 'border-emerald-300/50 bg-emerald-500/15 ring-1 ring-emerald-300/30',
idle: 'border-slate-700 bg-slate-800/60 hover:border-emerald-300/40',
chip: 'bg-emerald-500/20 text-emerald-200',
},
narrative: {
selected: 'border-amber-300/50 bg-amber-500/15 ring-1 ring-amber-300/30',
idle: 'border-slate-700 bg-slate-800/60 hover:border-amber-300/40',
chip: 'bg-amber-500/20 text-amber-200',
},
};
interface Props {
open: boolean;
expert: ExpertCard | null;
initialSelected?: SelectableMcpKey[];
onCancel: () => void;
onConfirm: (selected: SelectableMcpKey[]) => void;
}
const McpSelectModal: React.FC<Props> = ({ open, expert, initialSelected, onCancel, onConfirm }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const defaultAll = useMemo<SelectableMcpKey[]>(() => MCP_OPTIONS.map((m) => m.key), []);
const [selected, setSelected] = useState<SelectableMcpKey[]>(
initialSelected && initialSelected.length > 0 ? initialSelected : defaultAll,
);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open) {
setSelected(initialSelected && initialSelected.length > 0 ? initialSelected : defaultAll);
setError(null);
}
}, [defaultAll, initialSelected, open]);
if (!open) return null;
const toggle = (key: SelectableMcpKey) => {
setSelected((prev) => {
const has = prev.includes(key);
if (has) {
if (prev.length <= 1) {
setError('至少选择一个生成步骤');
return prev;
}
setError(null);
return prev.filter((k) => k !== key);
}
setError(null);
return [...prev, key];
});
};
const handleConfirm = () => {
if (selected.length === 0) {
setError('至少选择一个生成步骤');
return;
}
const ordered = MCP_OPTIONS.map((m) => m.key).filter((k) => selected.includes(k));
onConfirm(ordered);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center px-4">
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={onCancel}
role="presentation"
/>
<div
className={`relative z-10 w-full max-w-xl rounded-2xl border shadow-2xl ${
isDark ? 'border-slate-700 bg-slate-900 text-slate-100' : 'border-gray-200 bg-white text-gray-900'
}`}
>
<div className={`flex items-center justify-between border-b px-5 py-4 ${
isDark ? 'border-slate-700' : 'border-gray-100'
}`}>
<div className="min-w-0">
<div className="text-base font-semibold">选择本次执行的 MCP</div>
<div className={`text-xs mt-0.5 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{expert ? `专家:${expert.name}` : '默认全部勾选,至少保留 1 个步骤'}
</div>
</div>
<button
type="button"
onClick={onCancel}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
isDark ? 'text-slate-400 hover:bg-slate-800' : 'text-gray-400 hover:bg-gray-100'
}`}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="max-h-[60vh] overflow-y-auto px-5 py-4 space-y-2">
{MCP_OPTIONS.map((opt) => {
const isSelected = selected.includes(opt.key);
const palette = isDark ? ACCENT_DARK[opt.accent] : ACCENT_LIGHT[opt.accent];
const Icon = opt.icon;
return (
<button
key={opt.key}
type="button"
onClick={() => toggle(opt.key)}
className={`w-full text-left rounded-xl border px-3 py-3 transition ${
isSelected ? palette.selected : palette.idle
}`}
>
<div className="flex items-start gap-3">
<span className={`flex h-9 w-9 items-center justify-center rounded-xl shrink-0 ${palette.chip}`}>
<Icon className="w-4 h-4" />
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold">{opt.name}</div>
<div className={`text-xs mt-0.5 leading-relaxed ${isDark ? 'text-slate-400' : 'text-gray-600'}`}>
{opt.description}
</div>
</div>
<span
className={`shrink-0 mt-0.5 flex h-5 w-5 items-center justify-center rounded-md border transition ${
isSelected
? isDark
? 'border-cyan-300 bg-cyan-400/40 text-cyan-50'
: 'border-red-400 bg-red-500 text-white'
: isDark
? 'border-slate-600 text-transparent'
: 'border-gray-300 text-transparent'
}`}
>
<Check className="w-3 h-3" />
</span>
</div>
</button>
);
})}
{error && (
<div className={`text-xs ${isDark ? 'text-rose-300' : 'text-rose-600'}`}>{error}</div>
)}
</div>
<div className={`flex items-center justify-between gap-3 border-t px-5 py-3 ${
isDark ? 'border-slate-700' : 'border-gray-100'
}`}>
<div className={`text-[11px] ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
后端固定执行顺序:目的 → 行为体 → 预期行为和驱动因素 → 叙事
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onCancel}
className={`rounded-lg border px-3 py-1.5 text-sm font-medium transition ${
isDark
? 'border-slate-700 bg-slate-800 text-slate-200 hover:bg-slate-700'
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
}`}
>
取消
</button>
<button
type="button"
onClick={handleConfirm}
className={`rounded-lg px-3 py-1.5 text-sm font-semibold text-white transition ${
isDark
? 'bg-gradient-to-r from-cyan-500 to-blue-500 hover:from-cyan-400 hover:to-blue-400'
: 'bg-gradient-to-r from-red-500 to-rose-500 hover:from-red-400 hover:to-rose-400'
}`}
>
确认
</button>
</div>
</div>
</div>
</div>
);
};
export default McpSelectModal;
@@ -0,0 +1,360 @@
import React from 'react';
import {
ChevronDown,
Compass,
FileSearch,
Megaphone,
ScrollText,
Sparkles,
Target,
TrendingUp,
UserCheck,
Users,
Zap,
} from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import type {
ActorCard,
BehaviorDriverCard,
NarrativeCard,
PurposeCard,
} from './types';
interface CollapsibleProps {
title: React.ReactNode;
children: React.ReactNode;
defaultOpen?: boolean;
variant?: 'default' | 'subtle';
}
export const InlineCollapsible: React.FC<CollapsibleProps> = ({ title, children, defaultOpen = true, variant = 'default' }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [open, setOpen] = React.useState(defaultOpen);
const wrapperClass = variant === 'subtle'
? `${isDark ? 'border-slate-700/50 bg-slate-900/30' : 'border-gray-100 bg-white/80'}`
: `${isDark ? 'border-slate-700/60 bg-slate-800/40' : 'border-red-100 bg-white'}`;
return (
<div className={`rounded-2xl border overflow-hidden ${wrapperClass}`}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className={`w-full flex items-center justify-between gap-3 px-4 py-3 text-left transition-colors ${
isDark ? 'hover:bg-slate-800/70' : 'hover:bg-red-50/60'
}`}
>
<div className="flex-1 min-w-0">{title}</div>
<ChevronDown
className={`w-4 h-4 shrink-0 transition-transform duration-300 ${
open ? 'rotate-0' : '-rotate-90'
} ${isDark ? 'text-slate-400' : 'text-gray-500'}`}
/>
</button>
<div className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${
open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
}`}>
<div className="overflow-hidden">
<div className="px-4 pb-4 pt-1">{children}</div>
</div>
</div>
</div>
);
};
interface CardIndexBadgeProps {
index: number;
variant: 'purpose' | 'actor' | 'behavior' | 'narrative';
}
const VARIANT_COLOR: Record<CardIndexBadgeProps['variant'], { dark: string; light: string }> = {
purpose: { dark: 'bg-violet-500/20 text-violet-200 border-violet-400/30', light: 'bg-orange-50 text-orange-600 border-orange-200' },
actor: { dark: 'bg-cyan-500/20 text-cyan-200 border-cyan-400/30', light: 'bg-red-50 text-red-600 border-red-200' },
behavior: { dark: 'bg-emerald-500/20 text-emerald-200 border-emerald-400/30', light: 'bg-emerald-50 text-emerald-600 border-emerald-200' },
narrative: { dark: 'bg-amber-500/20 text-amber-200 border-amber-400/30', light: 'bg-amber-50 text-amber-600 border-amber-200' },
};
const CardIndexBadge: React.FC<CardIndexBadgeProps> = ({ index, variant }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const cls = VARIANT_COLOR[variant][isDark ? 'dark' : 'light'];
return (
<span className={`w-7 h-7 rounded-xl border flex items-center justify-center text-xs font-semibold shrink-0 ${cls}`}>
{index.toString().padStart(2, '0')}
</span>
);
};
export const PurposeCardItem: React.FC<{ card: PurposeCard }> = ({ card }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`group rounded-2xl border p-3 transition-all sixstep-fade-up ${
isDark
? 'border-violet-400/20 bg-gradient-to-br from-slate-800/60 to-violet-900/20 hover:border-violet-300/40'
: 'border-orange-100 bg-gradient-to-br from-white to-orange-50/40 hover:border-orange-200'
}`}>
<div className="flex items-start gap-3">
<CardIndexBadge index={card.index} variant="purpose" />
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
isDark ? 'bg-violet-500/20 text-violet-200' : 'bg-orange-50 text-orange-500'
}`}>
<Target className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
{card.title}
</div>
<div className={`text-xs mt-1 leading-relaxed ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
<span className={`inline-flex items-center gap-1 mr-1 font-medium ${isDark ? 'text-violet-200' : 'text-orange-600'}`}>
<Compass className="w-3 h-3" />理由
</span>
{card.reason}
</div>
</div>
</div>
</div>
);
};
export const ActorCardItem: React.FC<{ card: ActorCard }> = ({ card }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`rounded-2xl border p-3 transition-all sixstep-fade-up ${
isDark
? 'border-cyan-400/20 bg-gradient-to-br from-slate-800/60 to-cyan-900/20 hover:border-cyan-300/40'
: 'border-red-100 bg-gradient-to-br from-white to-red-50/40 hover:border-red-200'
}`}>
<div className="flex items-start gap-3">
<CardIndexBadge index={card.index} variant="actor" />
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
isDark ? 'bg-cyan-500/20 text-cyan-200' : 'bg-red-50 text-red-500'
}`}>
<UserCheck className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
{card.title}
</div>
<div className={`text-xs mt-1 leading-relaxed ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
{card.content}
</div>
</div>
</div>
</div>
);
};
export const BehaviorCardItem: React.FC<{ card: BehaviorDriverCard }> = ({ card }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`rounded-2xl border overflow-hidden transition-all sixstep-fade-up ${
isDark
? 'border-emerald-400/20 bg-gradient-to-br from-slate-800/60 to-emerald-900/20 hover:border-emerald-300/40'
: 'border-emerald-100 bg-gradient-to-br from-white to-emerald-50/40 hover:border-emerald-200'
}`}>
<div className="p-3">
<div className="flex items-start gap-3">
<CardIndexBadge index={card.index} variant="behavior" />
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
isDark ? 'bg-emerald-500/20 text-emerald-200' : 'bg-emerald-50 text-emerald-600'
}`}>
<TrendingUp className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-[11px] uppercase tracking-wider font-medium mb-1 ${isDark ? 'text-emerald-300/80' : 'text-emerald-600'}`}>
预期行为
</div>
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
{card.expectedBehavior}
</div>
</div>
</div>
</div>
<div className={`border-t px-3 py-3 ${
isDark ? 'border-emerald-400/15 bg-emerald-500/5' : 'border-emerald-100 bg-emerald-50/40'
}`}>
<div className="flex items-start gap-2">
<Zap className={`w-3.5 h-3.5 mt-0.5 shrink-0 ${isDark ? 'text-emerald-300' : 'text-emerald-600'}`} />
<div className="flex-1 min-w-0">
<div className={`text-[11px] uppercase tracking-wider font-medium mb-1 ${isDark ? 'text-emerald-300/80' : 'text-emerald-600'}`}>
驱动因素
</div>
<div className={`text-xs leading-relaxed ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
{card.driver}
</div>
</div>
</div>
</div>
</div>
);
};
export const NarrativeCardItem: React.FC<{ card: NarrativeCard }> = ({ card }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`rounded-2xl border p-3 transition-all sixstep-fade-up ${
isDark
? 'border-amber-400/20 bg-gradient-to-br from-slate-800/60 to-amber-900/20 hover:border-amber-300/40'
: 'border-amber-100 bg-gradient-to-br from-white to-amber-50/40 hover:border-amber-200'
}`}>
<div className="flex items-start gap-3">
<CardIndexBadge index={card.index} variant="narrative" />
<div className={`w-7 h-7 rounded-xl flex items-center justify-center shrink-0 ${
isDark ? 'bg-amber-500/20 text-amber-200' : 'bg-amber-50 text-amber-600'
}`}>
<ScrollText className="w-4 h-4" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold leading-snug ${isDark ? 'text-white' : 'text-gray-900'}`}>
{card.title}
</div>
<div className={`text-xs mt-1 leading-relaxed ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
<span className={`inline-flex items-center gap-1 mr-1 font-medium ${isDark ? 'text-amber-200' : 'text-amber-600'}`}>
<Sparkles className="w-3 h-3" />理由
</span>
{card.reason}
</div>
</div>
</div>
</div>
);
};
interface GroupHeaderProps {
label: string;
parentTitle: string;
count: number;
variant: 'actor' | 'behavior' | 'narrative';
}
export const ParentGroupHeader: React.FC<GroupHeaderProps> = ({ label, parentTitle, count, variant }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const Icon = variant === 'actor' ? Target : variant === 'behavior' ? Users : Zap;
const accent = variant === 'actor'
? (isDark ? 'text-violet-200 bg-violet-500/15 border-violet-400/30' : 'text-orange-600 bg-orange-50 border-orange-200')
: variant === 'behavior'
? (isDark ? 'text-cyan-200 bg-cyan-500/15 border-cyan-400/30' : 'text-red-600 bg-red-50 border-red-200')
: (isDark ? 'text-emerald-200 bg-emerald-500/15 border-emerald-400/30' : 'text-emerald-600 bg-emerald-50 border-emerald-200');
return (
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full border w-fit ${accent}`}>
<Icon className="w-3.5 h-3.5" />
<span className="text-[11px] font-medium">{label}</span>
<span className={`text-xs font-semibold ${isDark ? 'text-white' : 'text-gray-900'} truncate max-w-[260px]`} title={parentTitle}>
{parentTitle}
</span>
<span className={`text-[10px] ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>· {count} 张</span>
</div>
);
};
interface ThinkingBlockProps {
text: string;
loading?: boolean;
empty?: string;
}
export const ThinkingBlock: React.FC<ThinkingBlockProps> = ({ text, loading, empty = '正在生成思考内容...' }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`rounded-2xl border px-4 py-3 ${
isDark ? 'bg-slate-900/30 border-slate-700/60' : 'bg-white border-gray-100'
}`}>
<div className="flex items-center gap-2 mb-2">
<FileSearch className={`w-3.5 h-3.5 ${isDark ? 'text-cyan-300' : 'text-red-500'}`} />
<span className={`text-[11px] font-medium uppercase tracking-wider ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
思考过程
</span>
{loading && (
<span className={`text-[11px] ${isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}`}>
生成中
</span>
)}
</div>
{text ? (
<pre className={`whitespace-pre-wrap text-xs leading-relaxed font-sans ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
{text}
</pre>
) : (
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{empty}</div>
)}
</div>
);
};
interface StageHeaderProps {
title: string;
desc?: string;
count?: number;
loading?: boolean;
done?: boolean;
icon: React.ComponentType<{ className?: string }>;
accent: 'purpose' | 'actor' | 'behavior' | 'narrative';
}
const ACCENT_BG: Record<StageHeaderProps['accent'], { dark: string; light: string }> = {
purpose: { dark: 'bg-violet-500/15 text-violet-200', light: 'bg-orange-50 text-orange-500' },
actor: { dark: 'bg-cyan-500/15 text-cyan-200', light: 'bg-red-50 text-red-500' },
behavior: { dark: 'bg-emerald-500/15 text-emerald-200', light: 'bg-emerald-50 text-emerald-600' },
narrative: { dark: 'bg-amber-500/15 text-amber-200', light: 'bg-amber-50 text-amber-600' },
};
export const StageHeader: React.FC<StageHeaderProps> = ({ title, desc, count, loading, done, icon: Icon, accent }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const cls = ACCENT_BG[accent][isDark ? 'dark' : 'light'];
return (
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${cls} ${loading ? 'sixstep-pulse' : ''}`}>
<Icon className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'} flex items-center gap-2`}>
<span className="truncate">{title}</span>
{typeof count === 'number' && (
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
isDark ? 'bg-slate-700 text-slate-300' : 'bg-gray-100 text-gray-600'
}`}>
{count} 张
</span>
)}
{done && (
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
isDark ? 'bg-emerald-500/20 text-emerald-300' : 'bg-emerald-50 text-emerald-600'
}`}>
已完成
</span>
)}
{loading && (
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
isDark ? 'bg-cyan-500/20 text-cyan-200' : 'bg-red-50 text-red-600'
}`}>
生成中
</span>
)}
</div>
{desc && (
<div className={`text-xs mt-0.5 ${
loading ? (isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light') : (isDark ? 'text-slate-400' : 'text-gray-500')
}`}>
{desc}
</div>
)}
</div>
</div>
);
};
export const STAGE_ICONS = {
purpose: Target,
actor: Users,
behavior: Zap,
narrative: Megaphone,
};
@@ -0,0 +1,346 @@
import React, { useEffect, useRef, useState } from 'react';
import { Check, Eraser, MessageSquareText, Pencil, Plus, Trash2, X } from 'lucide-react';
export interface SixStepConversationMeta {
id: string;
title: string;
updatedAt: number;
}
interface Props {
isOpen: boolean;
isDark: boolean;
conversations: SixStepConversationMeta[];
activeId: string | null;
onClose: () => void;
onOpen: () => void;
onSelect: (id: string) => void;
onNew: () => void;
onDelete: (id: string) => void;
onRename: (id: string, title: string) => void;
onClearAll: () => void;
}
const UNTITLED = '未命名对话';
const SixStepChatHistory: React.FC<Props> = ({
isOpen,
isDark,
conversations,
activeId,
onClose,
onOpen,
onSelect,
onNew,
onDelete,
onRename,
onClearAll,
}) => {
const [editingId, setEditingId] = useState<string | null>(null);
const [draftTitle, setDraftTitle] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editingId && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editingId]);
const startEdit = (conversation: SixStepConversationMeta) => {
setEditingId(conversation.id);
setDraftTitle(conversation.title);
};
const commitEdit = () => {
if (!editingId) return;
onRename(editingId, draftTitle.trim() || UNTITLED);
setEditingId(null);
setDraftTitle('');
};
const cancelEdit = () => {
setEditingId(null);
setDraftTitle('');
};
const handleDelete = (conversation: SixStepConversationMeta) => {
const ok = window.confirm(`确认删除对话“${conversation.title || UNTITLED}”吗?`);
if (!ok) return;
onDelete(conversation.id);
};
const handleClearAll = () => {
if (conversations.length === 0) return;
const ok = window.confirm('确认清空当前对话列表中的全部历史记录吗?此操作无法撤销。');
if (!ok) return;
onClearAll();
};
const railButtonClass = (active = false) =>
`flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl border transition ${
active
? isDark
? 'border-cyan-400/40 bg-cyan-500/10 text-cyan-200 shadow-sm'
: 'border-red-200 bg-red-50/70 text-red-500 shadow-sm'
: isDark
? 'border-slate-700 bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-slate-100'
: 'border-gray-200 bg-white text-gray-500 hover:bg-gray-50 hover:text-gray-900'
}`;
const handleCollapsedSelect = (id: string) => {
onOpen();
window.setTimeout(() => {
onSelect(id);
}, 120);
};
const handleCollapsedNew = () => {
onOpen();
window.setTimeout(() => {
onNew();
}, 120);
};
return (
<aside
className={`flex h-full shrink-0 flex-col overflow-hidden border-r transition-[width,opacity] duration-300 ease-out ${
isOpen ? 'pointer-events-auto w-[300px] opacity-100' : 'pointer-events-auto w-[72px] opacity-100'
} ${
isDark
? 'border-slate-700/50 bg-slate-900/60 text-slate-100'
: 'border-red-100 bg-gray-50/70 text-gray-900'
}`}
>
{isOpen ? (
<>
<div
className={`border-b px-4 py-4 transition-all duration-300 ease-out ${isDark ? 'border-slate-700/50' : 'border-red-100'} ${
isOpen ? 'translate-x-0 opacity-100' : '-translate-x-2 opacity-0'
}`}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold">聊天记录</h3>
<p className={`mt-1 text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
查看和管理当前页面的历史对话
</p>
</div>
<button
type="button"
onClick={onClose}
title="收起聊天记录"
className={`flex h-8 w-8 items-center justify-center rounded-lg transition ${
isDark
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
: 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
}`}
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<button
type="button"
onClick={onNew}
className={`inline-flex h-9 items-center justify-center gap-2 rounded-lg border text-sm font-medium transition ${
isDark
? 'border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700'
: 'border-red-200 bg-white text-red-600 hover:bg-red-50'
}`}
>
<Plus className="h-4 w-4" />
新建对话
</button>
<button
type="button"
onClick={handleClearAll}
disabled={conversations.length === 0}
className={`inline-flex h-9 items-center justify-center gap-2 rounded-lg border text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-50 ${
isDark
? 'border-slate-700 bg-slate-800 text-slate-100 hover:bg-slate-700'
: 'border-gray-200 bg-white text-gray-700 hover:bg-gray-50'
}`}
>
<Eraser className="h-4 w-4" />
清空记录
</button>
</div>
</div>
<div
className={`min-h-0 flex-1 space-y-1 overflow-y-auto px-3 py-3 transition-all duration-300 ease-out ${
isOpen ? 'translate-x-0 opacity-100' : '-translate-x-2 opacity-0'
}`}
>
{conversations.length === 0 ? (
<div
className={`rounded-xl border border-dashed px-3 py-8 text-center text-xs ${
isDark ? 'border-slate-700 text-slate-500' : 'border-gray-200 text-gray-400'
}`}
>
还没有任何对话记录
</div>
) : (
conversations.map((conversation) => {
const isActive = activeId === conversation.id;
const isEditing = editingId === conversation.id;
return (
<div
key={conversation.id}
role={isEditing ? undefined : 'button'}
tabIndex={isEditing ? -1 : 0}
onClick={() => !isEditing && onSelect(conversation.id)}
onKeyDown={(event) => {
if (!isEditing && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault();
onSelect(conversation.id);
}
}}
className={`group flex cursor-pointer items-center gap-2 rounded-xl border px-3 py-2.5 text-left transition-all duration-200 ${
isActive
? isDark
? 'border-cyan-400/40 bg-cyan-500/10 text-slate-50 shadow-sm'
: 'border-red-200 bg-red-50/70 text-gray-900 shadow-sm'
: isDark
? 'border-transparent text-slate-300 hover:border-slate-700 hover:bg-slate-800/60 hover:text-slate-100'
: 'border-transparent text-gray-700 hover:border-gray-200 hover:bg-white hover:text-gray-900'
}`}
>
<span
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
isActive
? isDark
? 'bg-cyan-500/20 text-cyan-200'
: 'bg-white text-red-500 ring-1 ring-red-100'
: isDark
? 'bg-slate-800 text-slate-400'
: 'bg-gray-100 text-gray-500'
}`}
>
<MessageSquareText className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
{isEditing ? (
<input
ref={inputRef}
value={draftTitle}
onChange={(event) => setDraftTitle(event.target.value)}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
commitEdit();
} else if (event.key === 'Escape') {
event.preventDefault();
cancelEdit();
}
}}
onBlur={commitEdit}
className={`w-full border-b bg-transparent text-sm font-medium outline-none ${
isDark ? 'border-cyan-400/60 text-white' : 'border-red-300 text-gray-900'
}`}
/>
) : (
<div className="truncate text-sm font-medium">{conversation.title || UNTITLED}</div>
)}
</div>
{isEditing ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
commitEdit();
}}
title="保存"
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
isDark
? 'text-emerald-300 hover:bg-emerald-500/15'
: 'text-emerald-600 hover:bg-emerald-50'
}`}
>
<Check className="h-4 w-4" />
</button>
) : (
<>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
startEdit(conversation);
}}
title="重命名"
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
} ${
isDark
? 'text-slate-400 hover:bg-slate-800 hover:text-slate-100'
: 'text-gray-400 hover:bg-gray-100 hover:text-gray-700'
}`}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={(event) => {
event.stopPropagation();
handleDelete(conversation);
}}
title="删除对话"
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition ${
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
} ${
isDark
? 'text-slate-400 hover:bg-rose-500/15 hover:text-rose-300'
: 'text-gray-400 hover:bg-rose-50 hover:text-rose-500'
}`}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
);
})
)}
</div>
</>
) : (
<div className="flex min-h-0 flex-1 flex-col items-center gap-3 px-2 py-4">
<button
type="button"
onClick={handleCollapsedNew}
title="新建对话"
className={`${railButtonClass(false)} sixstep-fade-up`}
>
<Plus className="h-5 w-5" />
</button>
<div className="min-h-0 w-full flex-1 overflow-y-auto">
<div className="flex flex-col items-center gap-2">
{conversations.map((conversation, index) => (
<button
key={conversation.id}
type="button"
onClick={() => handleCollapsedSelect(conversation.id)}
title={conversation.title || UNTITLED}
className={`${railButtonClass(activeId === conversation.id)} transition-all duration-300 ease-out`}
style={{ transitionDelay: `${Math.min(index, 6) * 25}ms` }}
>
<MessageSquareText className="h-4 w-4" />
</button>
))}
</div>
</div>
</div>
)}
</aside>
);
};
export default SixStepChatHistory;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,660 @@
import React, { useEffect, useMemo, useRef } from 'react';
import { Bot, CheckCircle2, Database, FileCheck2, Sparkles, UserRound } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useTheme } from '../../contexts/ThemeContext';
import ExpertMatchCards from './ExpertMatchCards';
import StreamErrorNotice from './StreamErrorNotice';
import {
ActorCardItem,
BehaviorCardItem,
InlineCollapsible,
NarrativeCardItem,
ParentGroupHeader,
PurposeCardItem,
STAGE_ICONS,
StageHeader,
ThinkingBlock,
} from './SixStepCards';
import type {
ActorCard,
BehaviorDriverCard,
InfoStageData,
NarrativeCard,
PurposeCard,
SixStepMessage,
} from './types';
export interface ExpertMatchHandlers {
onSelectExpert: (messageId: string, expertId: string) => void;
onRefetchExpert: (messageId: string) => void;
onCreateExpert: (messageId: string) => void;
onConfirmExpert: (messageId: string) => void;
}
interface SixStepConversationProps {
messages: SixStepMessage[];
isGenerating: boolean;
activeMessageId?: string | null;
expertHandlers?: ExpertMatchHandlers;
}
interface AvatarProps {
role: 'user' | 'assistant';
active?: boolean;
}
const Avatar: React.FC<AvatarProps> = ({ role, active }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const Icon = role === 'assistant' ? Bot : UserRound;
return (
<div className={`relative w-9 h-9 rounded-2xl flex items-center justify-center shrink-0 shadow-sm overflow-hidden ${
role === 'assistant'
? isDark
? 'bg-gradient-to-br from-cyan-500 via-blue-600 to-violet-600 text-white'
: 'bg-gradient-to-br from-red-400 via-rose-500 to-orange-400 text-white'
: isDark
? 'bg-gradient-to-br from-slate-500 via-slate-700 to-slate-900 text-white'
: 'bg-gradient-to-br from-sky-400 via-indigo-500 to-violet-500 text-white'
} ${active ? 'sixstep-pulse' : ''}`}>
<Icon className="w-5 h-5" />
{role === 'assistant' && active && (
<Sparkles className="absolute -right-0.5 -top-0.5 w-3.5 h-3.5 text-yellow-200" />
)}
</div>
);
};
const InfoStage: React.FC<{ data: InfoStageData; running: boolean }> = ({ data, running }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<InlineCollapsible
defaultOpen={false}
title={
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center shrink-0 ${
isDark
? `bg-blue-500/15 text-blue-200 ${running ? 'sixstep-pulse' : ''}`
: `bg-blue-50 text-blue-500 ${running ? 'sixstep-pulse' : ''}`
}`}>
<Database className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-gray-900'}`}>
<span>信息准备</span>
{data.complete && (
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
isDark ? 'bg-emerald-500/20 text-emerald-300' : 'bg-emerald-50 text-emerald-600'
}`}>已完成</span>
)}
{running && (
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${
isDark ? 'bg-blue-500/20 text-blue-200' : 'bg-blue-50 text-blue-600'
}`}>收集中</span>
)}
</div>
<div className={`text-xs mt-0.5 ${
running
? isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'
: isDark ? 'text-slate-400' : 'text-gray-500'
}`}>
{running ? '正在收集热搜与上下文信息...' : '已整合信息准备素材'}
</div>
</div>
</div>
}
>
{data.content ? (
<div className={`prose prose-sm max-w-none text-sm leading-relaxed ${
isDark ? 'prose-invert text-slate-300' : 'text-gray-700'
}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{data.content}</ReactMarkdown>
</div>
) : (
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
{running ? '正在获取信息...' : '无内容'}
</div>
)}
</InlineCollapsible>
);
};
const MarkdownMessage: React.FC<{ content: string; isDark: boolean }> = ({ content, isDark }) => (
<div className={`rounded-2xl border px-5 py-4 ${
isDark
? 'border-slate-700/50 bg-slate-800/30'
: 'border-gray-100 bg-white'
}`}>
<div className={`prose prose-sm max-w-none leading-relaxed ${isDark ? 'prose-invert' : ''}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
</div>
);
const PurposeStage: React.FC<{ data: NonNullable<SixStepMessage['stages']>['purpose']; running: boolean }> = ({ data, running }) => {
if (!data) return null;
const Icon = STAGE_ICONS.purpose;
const cards = data.cards ?? [];
return (
<InlineCollapsible
defaultOpen
title={
<StageHeader
title="目的生成"
desc={running ? '正在推演核心目的...' : '已识别核心目的方向'}
count={cards.length}
loading={running}
done={data.complete}
icon={Icon}
accent="purpose"
/>
}
>
<div className="space-y-3">
<ThinkingBlock text={data.thinking} loading={running && !data.thinking} />
<InlineCollapsible
variant="subtle"
defaultOpen
title={
<div className="flex items-center gap-2">
<span className="text-xs font-medium">目的卡片列表</span>
<span className="text-[10px] opacity-70">{cards.length} 张</span>
</div>
}
>
{cards.length === 0 ? (
<div className="text-xs opacity-60">等待生成目的卡片...</div>
) : (
<div className="space-y-2">
{cards.map((card) => (
<PurposeCardItem key={card.id} card={card} />
))}
</div>
)}
</InlineCollapsible>
</div>
</InlineCollapsible>
);
};
const ActorStage: React.FC<{
data: NonNullable<SixStepMessage['stages']>['actor'];
purposes: PurposeCard[];
running: boolean;
}> = ({ data, purposes, running }) => {
if (!data) return null;
const Icon = STAGE_ICONS.actor;
const cards = data.cards ?? [];
const groups = useMemo(() => {
const map = new Map<string, ActorCard[]>();
cards.forEach((card) => {
const list = map.get(card.purposeId) ?? [];
list.push(card);
map.set(card.purposeId, list);
});
return purposes
.map((purpose) => ({ purpose, items: map.get(purpose.id) ?? [] }))
.filter((g) => g.items.length > 0);
}, [cards, purposes]);
return (
<InlineCollapsible
defaultOpen
title={
<StageHeader
title="行为体生成"
desc={running ? '正在匹配行为体角色...' : '已生成行为体清单'}
count={cards.length}
loading={running}
done={data.complete}
icon={Icon}
accent="actor"
/>
}
>
<div className="space-y-3">
<ThinkingBlock text={data.thinking} loading={running && !data.thinking} />
<InlineCollapsible
variant="subtle"
defaultOpen
title={
<div className="flex items-center gap-2">
<span className="text-xs font-medium">行为体卡片列表</span>
<span className="text-[10px] opacity-70">{cards.length} 张</span>
</div>
}
>
{groups.length === 0 ? (
<div className="text-xs opacity-60">等待生成行为体卡片...</div>
) : (
<div className="space-y-4">
{groups.map((group) => (
<div key={group.purpose.id} className="space-y-2 sixstep-fade-up">
<ParentGroupHeader label="所属目的" parentTitle={group.purpose.title} count={group.items.length} variant="actor" />
<div className="space-y-2 pl-2 border-l-2 border-dashed border-slate-400/20">
{group.items.map((card) => (
<ActorCardItem key={card.id} card={card} />
))}
</div>
</div>
))}
</div>
)}
</InlineCollapsible>
</div>
</InlineCollapsible>
);
};
const BehaviorStage: React.FC<{
data: NonNullable<SixStepMessage['stages']>['behavior'];
actors: ActorCard[];
running: boolean;
}> = ({ data, actors, running }) => {
if (!data) return null;
const Icon = STAGE_ICONS.behavior;
const cards = data.cards ?? [];
const groups = useMemo(() => {
const map = new Map<string, BehaviorDriverCard[]>();
cards.forEach((card) => {
const list = map.get(card.actorId) ?? [];
list.push(card);
map.set(card.actorId, list);
});
return actors
.map((actor) => ({ actor, items: map.get(actor.id) ?? [] }))
.filter((g) => g.items.length > 0);
}, [cards, actors]);
return (
<InlineCollapsible
defaultOpen
title={
<StageHeader
title="预期行为与驱动因素生成"
desc={running ? '正在推演行为及驱动因素...' : '已生成预期行为与驱动因素'}
count={cards.length}
loading={running}
done={data.complete}
icon={Icon}
accent="behavior"
/>
}
>
<div className="space-y-3">
<ThinkingBlock text={data.thinking} loading={running && !data.thinking} />
<InlineCollapsible
variant="subtle"
defaultOpen
title={
<div className="flex items-center gap-2">
<span className="text-xs font-medium">预期行为与驱动因素卡片列表</span>
<span className="text-[10px] opacity-70">{cards.length} 张</span>
</div>
}
>
{groups.length === 0 ? (
<div className="text-xs opacity-60">等待生成预期行为与驱动因素卡片...</div>
) : (
<div className="space-y-4">
{groups.map((group) => (
<div key={group.actor.id} className="space-y-2 sixstep-fade-up">
<ParentGroupHeader label="所属行为体" parentTitle={group.actor.title} count={group.items.length} variant="behavior" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-2 pl-2 border-l-2 border-dashed border-slate-400/20">
{group.items.map((card) => (
<BehaviorCardItem key={card.id} card={card} />
))}
</div>
</div>
))}
</div>
)}
</InlineCollapsible>
</div>
</InlineCollapsible>
);
};
const NarrativeStage: React.FC<{
data: NonNullable<SixStepMessage['stages']>['narrative'];
behaviors: BehaviorDriverCard[];
running: boolean;
}> = ({ data, behaviors, running }) => {
if (!data) return null;
const Icon = STAGE_ICONS.narrative;
const cards = data.cards ?? [];
const groups = useMemo(() => {
const map = new Map<string, NarrativeCard[]>();
cards.forEach((card) => {
const list = map.get(card.driverId) ?? [];
list.push(card);
map.set(card.driverId, list);
});
return behaviors
.map((behavior) => ({ behavior, items: map.get(behavior.id) ?? [] }))
.filter((g) => g.items.length > 0);
}, [cards, behaviors]);
return (
<InlineCollapsible
defaultOpen
title={
<StageHeader
title="叙事生成"
desc={running ? '正在构建叙事框架...' : '已生成叙事卡片'}
count={cards.length}
loading={running}
done={data.complete}
icon={Icon}
accent="narrative"
/>
}
>
<div className="space-y-3">
<ThinkingBlock text={data.thinking} loading={running && !data.thinking} />
<InlineCollapsible
variant="subtle"
defaultOpen
title={
<div className="flex items-center gap-2">
<span className="text-xs font-medium">叙事卡片列表</span>
<span className="text-[10px] opacity-70">{cards.length} 张</span>
</div>
}
>
{groups.length === 0 ? (
<div className="text-xs opacity-60">等待生成叙事卡片...</div>
) : (
<div className="space-y-4">
{groups.map((group) => (
<div key={group.behavior.id} className="space-y-2 sixstep-fade-up">
<ParentGroupHeader
label="所属驱动因素"
parentTitle={group.behavior.driver}
count={group.items.length}
variant="narrative"
/>
<div className="space-y-2 pl-2 border-l-2 border-dashed border-slate-400/20">
{group.items.map((card) => (
<NarrativeCardItem key={card.id} card={card} />
))}
</div>
</div>
))}
</div>
)}
</InlineCollapsible>
</div>
</InlineCollapsible>
);
};
const FinalAnswer: React.FC<{ text: string; running: boolean }> = ({ text, running }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
if (!text && !running) return null;
return (
<div className={`rounded-2xl border p-4 ${
isDark
? 'border-slate-600/40 bg-slate-700/40 shadow-lg shadow-black/10'
: 'border-red-100 bg-white shadow-sm'
}`}>
<div className="flex items-center gap-2 mb-2">
<FileCheck2 className={`w-4 h-4 ${isDark ? 'text-emerald-300' : 'text-emerald-600'}`} />
<span className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>最终结果</span>
{running && (
<span className={`text-[11px] ${isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}`}>
正在汇总输出
</span>
)}
</div>
<pre className={`whitespace-pre-wrap text-sm leading-relaxed font-sans ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
{text || '准备汇总输出...'}
</pre>
</div>
);
};
const SelectedSummaryPill: React.FC<{ message: SixStepMessage }> = ({ message }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
if (!message.selectedExpert) return null;
const mcps = message.selectedMcps ?? [];
const labelMap: Record<string, string> = {
purpose: '目的',
actor: '行为体',
behavior_factor: '预期行为和驱动因素',
narrative: '叙事',
};
return (
<div className={`flex flex-wrap items-center gap-2 rounded-2xl border px-3 py-2 ${
isDark ? 'border-cyan-400/30 bg-cyan-500/10 text-cyan-100' : 'border-red-200 bg-red-50/70 text-red-700'
}`}>
<CheckCircle2 className="w-4 h-4" />
<span className="text-xs font-semibold">已选专家</span>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
isDark ? 'bg-slate-900/40 text-cyan-100' : 'bg-white text-red-600 shadow-sm'
}`}>
{message.selectedExpert.name}
</span>
{mcps.length > 0 && (
<>
<span className={`text-[11px] ${isDark ? 'text-slate-300' : 'text-gray-500'}`}>·</span>
<span className={`text-[11px] ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
执行步骤:{mcps.map((k) => labelMap[k] || k).join(' → ')}
</span>
</>
)}
</div>
);
};
interface AssistantMessageProps {
message: SixStepMessage;
active: boolean;
expertHandlers?: ExpertMatchHandlers;
}
const AssistantMessage: React.FC<AssistantMessageProps> = ({ message, active, expertHandlers }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const isMarkdown = message.outputFormat === 'markdown';
const isFollowUp = message.phase === 'follow-up';
const stages = message.stages ?? {};
const purposes = stages.purpose?.cards ?? [];
const actors = stages.actor?.cards ?? [];
const behaviors = stages.behavior?.cards ?? [];
const isInfoRunning = message.mainStepStatus?.info === 'running';
const isPurposeRunning = message.subStepStatus?.purpose === 'running';
const isActorRunning = message.subStepStatus?.actor === 'running';
const isBehaviorRunning = message.subStepStatus?.behavior === 'running';
const isNarrativeRunning = message.subStepStatus?.narrative === 'running';
const isFinalRunning = message.mainStepStatus?.final === 'running';
const generating =
isInfoRunning ||
message.mainStepStatus?.sixstep === 'running' ||
isFinalRunning;
// follow-up 在等待第一个 delta 时显示加载态
const followUpGenerating = isFollowUp && active && !message.markdownContent;
const noStageYet = !stages.info && !stages.purpose && !stages.actor && !stages.behavior && !stages.narrative;
const expertMatch = message.expertMatch;
const inExpertPhase = message.phase === 'expert-match' && !!expertMatch;
const expertConfirmed = expertMatch?.status === 'confirmed' || !!message.selectedExpert;
const expertActive = active && inExpertPhase && !expertConfirmed;
// Only render info/stages/finalAnswer when we're in an analysis phase.
// Expert-match, clarifying, and follow-up phases show their own dedicated UI instead.
const showAnalysisContent =
!inExpertPhase &&
message.phase !== 'clarifying' &&
message.phase !== 'follow-up';
return (
<div className="flex gap-3">
<Avatar role="assistant" active={generating || expertActive || followUpGenerating} />
<div className={`flex-1 min-w-0 max-w-full rounded-2xl px-4 py-3 border ${
isDark
? 'bg-slate-800/40 border-slate-700/50'
: 'bg-transparent border-transparent shadow-none px-0 py-0'
}`}>
<div className="space-y-3">
{/* Expert match phase: show expert cards before stages */}
{inExpertPhase && expertMatch && (
<ExpertMatchCards
data={expertMatch}
locked={expertConfirmed || !active}
onSelectExpert={(id) => expertHandlers?.onSelectExpert(message.id, id)}
onRefetch={() => expertHandlers?.onRefetchExpert(message.id)}
onCreateExpert={() => expertHandlers?.onCreateExpert(message.id)}
onConfirm={() => expertHandlers?.onConfirmExpert(message.id)}
/>
)}
{inExpertPhase && (message.errors?.length ?? 0) > 0 && (
<StreamErrorNotice errors={message.errors!} />
)}
{/* Selected expert / MCP summary once moved to generate phase */}
{message.selectedExpert && message.phase !== 'expert-match' && (
<SelectedSummaryPill message={message} />
)}
{showAnalysisContent && (
<>
{/* Info preparing placeholder when no stage data yet */}
{isInfoRunning && noStageYet && (
<div className={`rounded-2xl border px-4 py-3 ${
isDark ? 'border-slate-700/60 bg-slate-900/40 text-slate-300' : 'border-blue-100 bg-white text-gray-600'
}`}>
<div className="flex items-center gap-2 text-xs">
<Database className={`w-4 h-4 ${isDark ? 'text-blue-300' : 'text-blue-500'}`} />
<span className={isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}>
正在调用信息准备,收集热搜与上下文...
</span>
</div>
</div>
)}
{/* Info prepare result (collapsible) */}
{stages.info && <InfoStage data={stages.info} running={isInfoRunning} />}
{(isMarkdown || isFollowUp) ? (
/* ---- Markdown / Follow-up mode ---- */
<>
{(message.markdownContent || generating || followUpGenerating) && (
message.markdownContent ? (
<MarkdownMessage content={message.markdownContent} isDark={isDark} />
) : (
<div className={`rounded-2xl border px-4 py-3 ${
isDark ? 'border-slate-700/60 bg-slate-900/40' : 'border-gray-100 bg-white'
}`}>
<div className="flex items-center gap-2 text-xs">
<Sparkles className={`w-4 h-4 ${isDark ? 'text-cyan-300' : 'text-red-500'}`} />
<span className={isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}>
{isFollowUp ? '正在回答你的问题...' : '正在生成推演内容...'}
</span>
</div>
</div>
)
)}
{(message.errors?.length ?? 0) > 0 && (
<StreamErrorNotice errors={message.errors!} />
)}
</>
) : (
/* ---- Structured mode ---- */
<>
{stages.purpose && <PurposeStage data={stages.purpose} running={isPurposeRunning} />}
{stages.actor && <ActorStage data={stages.actor} purposes={purposes} running={isActorRunning} />}
{stages.behavior && <BehaviorStage data={stages.behavior} actors={actors} running={isBehaviorRunning} />}
{stages.narrative && (
<NarrativeStage data={stages.narrative} behaviors={behaviors} running={isNarrativeRunning} />
)}
{(message.finalAnswer || isFinalRunning) && (
<FinalAnswer text={message.finalAnswer ?? ''} running={isFinalRunning} />
)}
{(message.errors?.length ?? 0) > 0 && (
<StreamErrorNotice errors={message.errors!} />
)}
</>
)}
</>
)}
</div>
</div>
</div>
);
};
const UserMessage: React.FC<{ message: SixStepMessage }> = ({ message }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="flex gap-3 justify-end">
<div className={`max-w-[72%] rounded-2xl px-4 py-3 shadow-sm ${
isDark ? 'bg-blue-600/70 text-white' : 'bg-gradient-to-r from-red-500 to-rose-500 text-white'
}`}>
<p className="whitespace-pre-wrap leading-relaxed text-sm">{message.content}</p>
</div>
<Avatar role="user" />
</div>
);
};
const SixStepConversation: React.FC<SixStepConversationProps> = ({ messages, isGenerating, activeMessageId, expertHandlers }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
}, [messages, isGenerating]);
if (messages.length === 0) {
return (
<div className={`h-full flex flex-col items-center justify-center px-6 text-center ${
isDark ? 'text-slate-400' : 'text-gray-500'
}`}>
<div className={`w-20 h-20 rounded-3xl mb-5 flex items-center justify-center ${
isDark ? 'bg-slate-700/40 text-cyan-300' : 'bg-gradient-to-br from-red-50 to-orange-50 text-red-500'
}`}>
<Sparkles className="w-10 h-10 opacity-80" />
</div>
<p className={`text-lg font-medium ${isDark ? 'text-white' : 'text-gray-800'}`}>开始 6 步法推演</p>
<p className="text-sm mt-2 max-w-md">
输入问题或场景描述,模型会按照"信息准备 → 6 步法推演 → 最终结果"的流程逐步生成结构化输出。
</p>
</div>
);
}
return (
<div ref={scrollRef} className="h-full overflow-y-auto px-5 py-5 space-y-5">
{messages.map((msg) => (
<div key={msg.id}>
{msg.role === 'user' ? (
<UserMessage message={msg} />
) : (
<AssistantMessage
message={msg}
active={msg.id === activeMessageId}
expertHandlers={expertHandlers}
/>
)}
</div>
))}
</div>
);
};
export default SixStepConversation;
@@ -0,0 +1,653 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Bot,
Compass,
Crosshair,
Download,
Megaphone,
Minus,
Plus,
RotateCcw,
ScrollText,
Target,
TrendingUp,
UserCheck,
X,
Zap,
} from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import type { ExpertCard, SixStepMessage } from './types';
import {
CURRENT_PAIR_BY_STAGE,
EDGE_COLOR,
EDGE_LABEL,
STATUS_TITLE,
VARIANT_LABEL,
buildEdgePath,
buildLayout,
type LayoutEdge,
type LayoutNode,
} from './flowLayout';
interface SixStepFlowFullscreenProps {
message?: SixStepMessage;
onClose: () => void;
}
const SCALE_MIN = 0.2;
const SCALE_MAX = 3;
const buildExpertRootDetail = (expert: ExpertCard): string => {
const segments = [expert.title, expert.detail].filter((item): item is string => Boolean(item && item.trim()));
return segments.join(' · ') || '已确认专家';
};
const VARIANT_TOKEN: Record<LayoutNode['variant'], {
dark: string; light: string;
icon: React.ComponentType<{ className?: string }>;
iconCls: { dark: string; light: string };
}> = {
agent: { dark: 'from-slate-700/80 to-slate-900/80 border-slate-500/60', light: 'from-white to-slate-50 border-slate-300', icon: Bot, iconCls: { dark: 'bg-slate-500/30 text-slate-200', light: 'bg-slate-100 text-slate-600' } },
purpose: { dark: 'from-violet-700/40 to-violet-900/40 border-violet-400/50', light: 'from-orange-50 to-white border-orange-300', icon: Target, iconCls: { dark: 'bg-violet-500/30 text-violet-200', light: 'bg-orange-100 text-orange-600' } },
actor: { dark: 'from-cyan-700/40 to-cyan-900/40 border-cyan-400/50', light: 'from-red-50 to-white border-red-300', icon: UserCheck, iconCls: { dark: 'bg-cyan-500/30 text-cyan-200', light: 'bg-red-100 text-red-600' } },
behavior: { dark: 'from-emerald-700/40 to-emerald-900/40 border-emerald-400/50', light: 'from-emerald-50 to-white border-emerald-300', icon: TrendingUp,iconCls: { dark: 'bg-emerald-500/30 text-emerald-200', light: 'bg-emerald-100 text-emerald-700' } },
driver: { dark: 'from-emerald-800/40 to-teal-900/40 border-teal-400/50', light: 'from-teal-50 to-white border-teal-300', icon: Zap, iconCls: { dark: 'bg-teal-500/30 text-teal-200', light: 'bg-teal-100 text-teal-700' } },
narrative: { dark: 'from-amber-700/40 to-amber-900/40 border-amber-400/50', light: 'from-amber-50 to-white border-amber-300', icon: ScrollText,iconCls: { dark: 'bg-amber-500/30 text-amber-200', light: 'bg-amber-100 text-amber-600' } },
};
const MINIMAP_VARIANT_FILL: Record<LayoutNode['variant'], { dark: string; light: string }> = {
agent: { dark: '#475569', light: '#cbd5e1' },
purpose: { dark: '#7c3aed', light: '#fb923c' },
actor: { dark: '#06b6d4', light: '#ef4444' },
behavior: { dark: '#10b981', light: '#10b981' },
driver: { dark: '#0d9488', light: '#0d9488' },
narrative: { dark: '#f59e0b', light: '#f59e0b' },
};
const DetailNode: React.FC<{
node: LayoutNode;
isDark: boolean;
collapsible: boolean;
collapsed: boolean;
onToggleCollapse?: (nodeId: string) => void;
entering: boolean;
exiting: boolean;
}> = ({ node, isDark, collapsible, collapsed, onToggleCollapse, entering, exiting }) => {
const token = VARIANT_TOKEN[node.variant];
const Icon = token.icon;
const grad = isDark ? token.dark : token.light;
const canToggle = collapsible && Boolean(onToggleCollapse);
const handleClick = (e: React.MouseEvent<SVGGElement>) => {
if (!canToggle) return;
e.stopPropagation();
onToggleCollapse?.(node.id);
};
const handleMouseDown = (e: React.MouseEvent<SVGGElement>) => {
if (canToggle) e.stopPropagation();
};
return (
<g
onMouseDown={handleMouseDown}
onClick={handleClick}
style={{
cursor: canToggle ? 'pointer' : undefined,
opacity: exiting ? 0 : 1,
transform: entering || exiting ? 'translateY(8px)' : 'translateY(0)',
transformOrigin: `${node.x + node.width / 2}px ${node.y + node.height / 2}px`,
transition: `opacity ${COLLAPSE_TRANSITION_MS}ms ease, transform ${COLLAPSE_TRANSITION_MS}ms ease`,
}}
>
<foreignObject x={node.x} y={node.y} width={node.width} height={node.height}>
<div className={`relative w-full h-full rounded-2xl border bg-gradient-to-br ${grad} backdrop-blur-sm p-2.5 shadow-sm overflow-hidden`}>
{collapsible && (
<div
className={`absolute right-2 top-2 w-4 h-4 rounded-full flex items-center justify-center text-[10px] font-bold ${
isDark ? 'bg-slate-800/75 text-slate-200' : 'bg-white text-slate-600 border border-slate-200'
}`}
title={collapsed ? '展开子节点' : '收起子节点'}
>
{collapsed ? '+' : '-'}
</div>
)}
<div className="flex items-center gap-1.5 mb-1">
<div className={`w-5 h-5 rounded-md flex items-center justify-center ${token.iconCls[isDark ? 'dark' : 'light']}`}>
<Icon className="w-3 h-3" />
</div>
<span className={`text-[10px] uppercase tracking-wider font-semibold ${isDark ? 'text-white/70' : 'text-slate-500'}`}>
{VARIANT_LABEL[node.variant]}
</span>
</div>
<div className={`text-xs font-semibold leading-tight line-clamp-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{node.title}
</div>
<div className={`text-[11px] leading-snug line-clamp-2 mt-0.5 ${isDark ? 'text-slate-300' : 'text-slate-600'}`}>
{node.detail || '—'}
</div>
</div>
</foreignObject>
</g>
);
};
const ToolbarButton: React.FC<{ title: string; onClick: () => void; isDark: boolean; children: React.ReactNode }> = ({ title, onClick, isDark, children }) => (
<button
type="button"
title={title}
onClick={onClick}
className={`sixstep-toolbar-btn ${
isDark ? 'text-slate-300 hover:bg-slate-700/70 hover:text-white' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}`}
>
{children}
</button>
);
interface MinimapProps {
layout: { nodes: LayoutNode[]; width: number; height: number };
transform: { tx: number; ty: number; scale: number };
viewportSize: { width: number; height: number };
onJump: (worldX: number, worldY: number) => void;
isDark: boolean;
}
const MINIMAP_W = 220;
const MINIMAP_H = 150;
const COLLAPSE_TRANSITION_MS = 240;
const Minimap: React.FC<MinimapProps> = ({ layout, transform, viewportSize, onJump, isDark }) => {
const ratio = layout.width > 0 && layout.height > 0
? Math.min((MINIMAP_W - 10) / layout.width, (MINIMAP_H - 10) / layout.height)
: 0.1;
const renderW = layout.width * ratio;
const renderH = layout.height * ratio;
const offsetX = (MINIMAP_W - renderW) / 2;
const offsetY = (MINIMAP_H - renderH) / 2;
// Visible area (in world coords): from world (-tx/scale, -ty/scale) with size viewportW/scale × viewportH/scale
const viewW = viewportSize.width / transform.scale;
const viewH = viewportSize.height / transform.scale;
const viewX = -transform.tx / transform.scale;
const viewY = -transform.ty / transform.scale;
const handleClick = (e: React.MouseEvent<SVGSVGElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const mx = e.clientX - rect.left - offsetX;
const my = e.clientY - rect.top - offsetY;
const wx = mx / ratio;
const wy = my / ratio;
onJump(wx, wy);
};
return (
<div className={`rounded-xl overflow-hidden border shadow-lg backdrop-blur-md ${
isDark ? 'bg-slate-900/80 border-slate-700/70' : 'bg-white/90 border-gray-200'
}`}>
<div className={`px-3 py-1.5 text-[10px] uppercase tracking-wider border-b font-medium ${
isDark ? 'text-slate-400 border-slate-700/50' : 'text-gray-500 border-gray-100'
}`}>
缩略图
</div>
<svg
width={MINIMAP_W}
height={MINIMAP_H}
onClick={handleClick}
style={{ cursor: 'pointer' }}
>
<rect width={MINIMAP_W} height={MINIMAP_H} fill={isDark ? '#0f172a' : '#f8fafc'} />
<g transform={`translate(${offsetX}, ${offsetY}) scale(${ratio})`}>
{layout.nodes.map((n) => (
<rect
key={n.id}
x={n.x}
y={n.y}
width={n.width}
height={n.height}
rx={6}
fill={MINIMAP_VARIANT_FILL[n.variant][isDark ? 'dark' : 'light']}
opacity={0.75}
/>
))}
</g>
<rect
className="sixstep-minimap-rect"
x={offsetX + viewX * ratio}
y={offsetY + viewY * ratio}
width={Math.max(6, viewW * ratio)}
height={Math.max(6, viewH * ratio)}
rx={3}
/>
</svg>
</div>
);
};
const SixStepFlowFullscreen: React.FC<SixStepFlowFullscreenProps> = ({ message, onClose }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const purposes = message?.stages?.purpose?.cards ?? [];
const actors = message?.stages?.actor?.cards ?? [];
const behaviors = message?.stages?.behavior?.cards ?? [];
const narratives = message?.stages?.narrative?.cards ?? [];
const selectedExpert = message?.selectedExpert;
const layout = useMemo(
() => buildLayout(purposes, actors, behaviors, narratives, {
rootTitle: selectedExpert?.name,
rootDetail: selectedExpert ? buildExpertRootDetail(selectedExpert) : undefined,
}),
[purposes, actors, behaviors, narratives, selectedExpert],
);
const currentSub = message?.currentSubStep;
const activePair = currentSub ? CURRENT_PAIR_BY_STAGE[currentSub] : null;
const statusTitle = currentSub ? STATUS_TITLE[currentSub] : '推演结果可视化';
const generating = Boolean(currentSub);
const [transform, setTransform] = useState({ tx: 0, ty: 0, scale: 1 });
const [isPanning, setIsPanning] = useState(false);
const [viewportSize, setViewportSize] = useState({ width: 800, height: 600 });
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set());
const [displayedNodeIds, setDisplayedNodeIds] = useState<Set<string>>(new Set());
const [enteringNodeIds, setEnteringNodeIds] = useState<Set<string>>(new Set());
const [exitingNodeIds, setExitingNodeIds] = useState<Set<string>>(new Set());
const [displayedEdgeIds, setDisplayedEdgeIds] = useState<Set<string>>(new Set());
const [exitingEdgeIds, setExitingEdgeIds] = useState<Set<string>>(new Set());
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const panStart = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
const transitionTimerRef = useRef<number | null>(null);
const nodeMap = useMemo(() => new Map(layout.nodes.map((node) => [node.id, node])), [layout.nodes]);
const targetVisibleNodeSet = useMemo(() => {
const visible = new Set<string>();
layout.nodes.forEach((node) => {
let parentId = node.parent;
let hidden = false;
while (parentId) {
if (collapsedNodeIds.has(parentId)) {
hidden = true;
break;
}
parentId = nodeMap.get(parentId)?.parent;
}
if (!hidden) visible.add(node.id);
});
return visible;
}, [collapsedNodeIds, layout.nodes, nodeMap]);
const targetVisibleEdgeSet = useMemo(() => {
const visible = new Set<string>();
layout.edges.forEach((edge) => {
if (targetVisibleNodeSet.has(edge.fromId) && targetVisibleNodeSet.has(edge.toId)) {
visible.add(edge.id);
}
});
return visible;
}, [layout.edges, targetVisibleNodeSet]);
const renderedNodeSet = displayedNodeIds.size > 0 ? displayedNodeIds : targetVisibleNodeSet;
const renderedEdgeSet = displayedEdgeIds.size > 0 ? displayedEdgeIds : targetVisibleEdgeSet;
const renderedNodes = useMemo(
() => layout.nodes.filter((node) => renderedNodeSet.has(node.id)),
[layout.nodes, renderedNodeSet],
);
const renderedEdges = useMemo(
() => layout.edges.filter((edge) => renderedEdgeSet.has(edge.id)),
[layout.edges, renderedEdgeSet],
);
const toggleNodeCollapse = (nodeId: string) => {
const node = nodeMap.get(nodeId);
if (!node || node.children.length === 0) return;
setCollapsedNodeIds((prev) => {
const next = new Set(prev);
if (next.has(nodeId)) next.delete(nodeId);
else next.add(nodeId);
return next;
});
};
useEffect(() => {
const update = () => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setViewportSize({ width: rect.width, height: rect.height });
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
// Native wheel listener with passive:false so preventDefault actually blocks browser zoom
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = container.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const delta = -e.deltaY * 0.0015;
setTransform((prev) => {
const nextScale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, prev.scale * (1 + delta)));
const ratio = nextScale / prev.scale;
return { tx: mx - (mx - prev.tx) * ratio, ty: my - (my - prev.ty) * ratio, scale: nextScale };
});
};
container.addEventListener('wheel', onWheel, { passive: false });
return () => container.removeEventListener('wheel', onWheel);
}, []);
const handlePanStart = (e: React.MouseEvent) => {
if (e.button !== 0) return;
panStart.current = { x: e.clientX, y: e.clientY, tx: transform.tx, ty: transform.ty };
setIsPanning(true);
};
useEffect(() => {
if (!isPanning) return;
const move = (e: MouseEvent) => {
const start = panStart.current;
if (!start) return;
setTransform((prev) => ({ ...prev, tx: start.tx + (e.clientX - start.x), ty: start.ty + (e.clientY - start.y) }));
};
const up = () => {
panStart.current = null;
setIsPanning(false);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
return () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
}, [isPanning]);
const fitToView = () => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const scale = Math.min((rect.width - 80) / Math.max(layout.width, 1), (rect.height - 80) / Math.max(layout.height, 1), 1);
const tx = (rect.width - layout.width * scale) / 2;
const ty = (rect.height - layout.height * scale) / 2;
setTransform({ tx, ty, scale });
};
const centerView = () => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
setTransform((prev) => ({
...prev,
tx: (rect.width - layout.width * prev.scale) / 2,
ty: (rect.height - layout.height * prev.scale) / 2,
}));
};
const zoomIn = () => setTransform((prev) => ({ ...prev, scale: Math.min(SCALE_MAX, prev.scale * 1.2) }));
const zoomOut = () => setTransform((prev) => ({ ...prev, scale: Math.max(SCALE_MIN, prev.scale / 1.2) }));
const reset = () => setTransform({ tx: 0, ty: 0, scale: 1 });
const fitAppliedRef = useRef(false);
useEffect(() => {
if (!fitAppliedRef.current && viewportSize.width > 0 && layout.nodes.length > 1) {
fitToView();
fitAppliedRef.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [viewportSize.width, layout.nodes.length]);
const handleMinimapJump = (wx: number, wy: number) => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
setTransform((prev) => ({
...prev,
tx: rect.width / 2 - wx * prev.scale,
ty: rect.height / 2 - wy * prev.scale,
}));
};
const handleDownload = () => {
const svg = svgRef.current;
if (!svg) return;
const cloned = svg.cloneNode(true) as SVGSVGElement;
cloned.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
cloned.setAttribute('width', String(layout.width));
cloned.setAttribute('height', String(layout.height));
cloned.setAttribute('viewBox', `0 0 ${layout.width} ${layout.height}`);
const inner = cloned.querySelector('g');
if (inner) inner.setAttribute('transform', '');
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(cloned);
const blob = new Blob([svgString], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `6步法推演-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.svg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
useEffect(() => {
const validIds = new Set(layout.nodes.filter((node) => node.children.length > 0).map((node) => node.id));
setCollapsedNodeIds((prev) => {
let changed = false;
const next = new Set<string>();
prev.forEach((id) => {
if (validIds.has(id)) next.add(id);
else changed = true;
});
return changed ? next : prev;
});
}, [layout.nodes]);
useEffect(() => {
if (transitionTimerRef.current) {
window.clearTimeout(transitionTimerRef.current);
transitionTimerRef.current = null;
}
const prevNodes = displayedNodeIds;
const nextNodes = targetVisibleNodeSet;
const prevEdges = displayedEdgeIds;
const nextEdges = targetVisibleEdgeSet;
const enteringNodes = new Set([...nextNodes].filter((id) => !prevNodes.has(id)));
const leavingNodes = new Set([...prevNodes].filter((id) => !nextNodes.has(id)));
const leavingEdges = new Set([...prevEdges].filter((id) => !nextEdges.has(id)));
const mergedNodes = new Set([...prevNodes, ...nextNodes]);
const mergedEdges = new Set([...prevEdges, ...nextEdges]);
setEnteringNodeIds(enteringNodes);
setExitingNodeIds(leavingNodes);
setExitingEdgeIds(leavingEdges);
setDisplayedNodeIds(mergedNodes);
setDisplayedEdgeIds(mergedEdges);
transitionTimerRef.current = window.setTimeout(() => {
setDisplayedNodeIds(new Set(nextNodes));
setDisplayedEdgeIds(new Set(nextEdges));
setEnteringNodeIds(new Set());
setExitingNodeIds(new Set());
setExitingEdgeIds(new Set());
transitionTimerRef.current = null;
}, COLLAPSE_TRANSITION_MS);
}, [targetVisibleNodeSet, targetVisibleEdgeSet]);
useEffect(() => {
return () => {
if (transitionTimerRef.current) window.clearTimeout(transitionTimerRef.current);
};
}, []);
return (
<div className={`fixed inset-0 z-50 flex flex-col ${isDark ? 'bg-slate-950/95' : 'bg-white/95'} backdrop-blur-sm`}>
<div className={`flex items-center justify-between gap-3 px-5 py-3 border-b ${
isDark ? 'border-slate-700/60 bg-slate-900/70' : 'border-red-100 bg-white'
}`}>
<div className="flex items-center gap-3 min-w-0">
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${
isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
} ${generating ? 'sixstep-pulse' : ''}`}>
<Compass className="w-5 h-5" />
</div>
<div className="min-w-0">
<div className={`text-base font-semibold ${
generating ? (isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light') : (isDark ? 'text-white' : 'text-gray-900')
}`}>
{statusTitle}
</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
全屏视图 · 完整内容展示
</div>
</div>
</div>
<div className="flex items-center gap-1">
<ToolbarButton title="放大" onClick={zoomIn} isDark={isDark}><Plus className="w-4 h-4" /></ToolbarButton>
<ToolbarButton title="缩小" onClick={zoomOut} isDark={isDark}><Minus className="w-4 h-4" /></ToolbarButton>
<ToolbarButton title="恢复 1:1" onClick={reset} isDark={isDark}><RotateCcw className="w-4 h-4" /></ToolbarButton>
<ToolbarButton title="定位到画布中央" onClick={centerView} isDark={isDark}><Crosshair className="w-4 h-4" /></ToolbarButton>
<ToolbarButton title="下载流程图(SVG)" onClick={handleDownload} isDark={isDark}><Download className="w-4 h-4" /></ToolbarButton>
<span className={`mx-2 h-5 w-px ${isDark ? 'bg-slate-700' : 'bg-gray-200'}`} />
<span className={`text-[11px] tabular-nums ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{Math.round(transform.scale * 100)}%
</span>
<ToolbarButton title="关闭全屏 (Esc)" onClick={onClose} isDark={isDark}>
<X className="w-4 h-4" />
</ToolbarButton>
</div>
</div>
<div className={`flex items-center gap-3 px-5 py-2 border-b text-[11px] flex-wrap ${
isDark ? 'border-slate-700/60 text-slate-400 bg-slate-900/40' : 'border-red-100 text-gray-500 bg-white'
}`}>
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
const active = activePair === pair;
return (
<span key={pair} className="inline-flex items-center gap-1.5">
<span className={`inline-block w-6 h-0.5 rounded-full ${active ? 'sixstep-ant-line' : ''}`} style={{ background: color, stroke: color }} />
<span className={active ? (isDark ? 'text-cyan-200' : 'text-red-600') : ''}>{EDGE_LABEL[pair]}</span>
</span>
);
})}
</div>
<div
ref={containerRef}
className={`flex-1 overflow-hidden relative ${isPanning ? 'sixstep-canvas-grabbing' : 'sixstep-canvas-grab'}`}
onMouseDown={handlePanStart}
>
{renderedNodes.length <= 1 ? (
<div className={`h-full flex flex-col items-center justify-center text-center ${
isDark ? 'text-slate-500' : 'text-gray-400'
}`}>
<Megaphone className="w-12 h-12 opacity-40 mb-3" />
<div className={`text-base font-medium ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>等待推演开始</div>
<div className="text-sm mt-1">推演产出将以流程图形式呈现</div>
</div>
) : (
<svg ref={svgRef} width="100%" height="100%" className="block select-none">
<defs>
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
return (
<marker
key={pair}
id={`sixstep-fs-arrow-${pair}`}
viewBox="0 0 8 8"
refX="7"
refY="4"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 8 4 L 0 8 z" fill={color} />
</marker>
);
})}
</defs>
<g transform={`translate(${transform.tx}, ${transform.ty}) scale(${transform.scale})`}>
{renderedEdges.map((edge) => {
const from = nodeMap.get(edge.fromId);
const to = nodeMap.get(edge.toId);
if (!from || !to) return null;
const color = EDGE_COLOR[edge.pair][isDark ? 'dark' : 'light'];
const isActive = activePair === edge.pair;
const exiting = exitingEdgeIds.has(edge.id);
return (
<path
key={edge.id}
d={buildEdgePath(from, to)}
fill="none"
stroke={color}
strokeWidth={isActive ? 2.4 : 1.8}
strokeOpacity={exiting ? 0 : isActive ? 1 : 0.75}
className={isActive ? 'sixstep-ant-line' : ''}
markerEnd={`url(#sixstep-fs-arrow-${edge.pair})`}
style={{
opacity: exiting ? 0 : 1,
transition: `opacity ${COLLAPSE_TRANSITION_MS}ms ease`,
}}
/>
);
})}
{renderedNodes.map((node) => (
<DetailNode
key={node.id}
node={node}
isDark={isDark}
collapsible={node.children.length > 0}
collapsed={collapsedNodeIds.has(node.id)}
onToggleCollapse={toggleNodeCollapse}
entering={enteringNodeIds.has(node.id)}
exiting={exitingNodeIds.has(node.id)}
/>
))}
</g>
</svg>
)}
{targetVisibleNodeSet.size > 1 && (
<div className="absolute bottom-4 right-4">
<Minimap
layout={{
nodes: layout.nodes.filter((node) => targetVisibleNodeSet.has(node.id)),
width: layout.width,
height: layout.height,
}}
transform={transform}
viewportSize={viewportSize}
onJump={handleMinimapJump}
isDark={isDark}
/>
</div>
)}
<div className={`absolute bottom-4 left-4 px-2 py-1 rounded-md text-[10px] backdrop-blur-md ${
isDark ? 'bg-slate-800/70 text-slate-400 border border-slate-700/50' : 'bg-white/90 text-gray-500 border border-gray-200'
}`}>
点击父节点可收起/展开子节点
</div>
</div>
</div>
);
};
export default SixStepFlowFullscreen;
@@ -0,0 +1,520 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
Bot,
Compass,
Crosshair,
Maximize2,
Megaphone,
Minus,
Plus,
RotateCcw,
ScrollText,
Target,
TrendingUp,
UserCheck,
X,
Zap,
} from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import type { ExpertCard, SixStepMessage } from './types';
import {
CURRENT_PAIR_BY_STAGE,
EDGE_COLOR,
EDGE_LABEL,
STATUS_TITLE,
VARIANT_ABBREV,
VARIANT_LABEL,
buildEdgePath,
buildLayout,
type LayoutEdge,
type LayoutNode,
} from './flowLayout';
interface SixStepFlowSandboxProps {
message?: SixStepMessage;
width: number;
onResizeStart: (e: React.MouseEvent) => void;
onClose: () => void;
onOpenFullscreen: () => void;
}
const COMPACT_OPTIONS = {
nodeWidth: 120,
nodeHeight: 60,
levelGap: 80,
nodeGap: 24,
paddingX: 32,
paddingY: 28,
};
const VARIANT_GRADIENT: Record<LayoutNode['variant'], { dark: string; light: string }> = {
agent: { dark: 'from-slate-700/80 to-slate-900/80 border-slate-500/60', light: 'from-white to-slate-50 border-slate-300' },
purpose: { dark: 'from-violet-700/40 to-violet-900/40 border-violet-400/50', light: 'from-orange-50 to-white border-orange-300' },
actor: { dark: 'from-cyan-700/40 to-cyan-900/40 border-cyan-400/50', light: 'from-red-50 to-white border-red-300' },
behavior: { dark: 'from-emerald-700/40 to-emerald-900/40 border-emerald-400/50', light: 'from-emerald-50 to-white border-emerald-300' },
driver: { dark: 'from-emerald-800/40 to-teal-900/40 border-teal-400/50', light: 'from-teal-50 to-white border-teal-300' },
narrative: { dark: 'from-amber-700/40 to-amber-900/40 border-amber-400/50', light: 'from-amber-50 to-white border-amber-300' },
};
const VARIANT_ICON: Record<LayoutNode['variant'], React.ComponentType<{ className?: string }>> = {
agent: Bot,
purpose: Target,
actor: UserCheck,
behavior: TrendingUp,
driver: Zap,
narrative: ScrollText,
};
// Colored icon wrapper per variant (dark / light)
const VARIANT_ICON_CLS: Record<LayoutNode['variant'], { dark: string; light: string }> = {
agent: { dark: 'bg-slate-600/60 text-slate-200', light: 'bg-slate-100 text-slate-600' },
purpose: { dark: 'bg-violet-500/25 text-violet-200', light: 'bg-orange-100 text-orange-600' },
actor: { dark: 'bg-cyan-500/25 text-cyan-200', light: 'bg-red-100 text-red-600' },
behavior: { dark: 'bg-emerald-500/25 text-emerald-200', light: 'bg-emerald-100 text-emerald-700' },
driver: { dark: 'bg-teal-500/25 text-teal-200', light: 'bg-teal-100 text-teal-700' },
narrative: { dark: 'bg-amber-500/25 text-amber-200', light: 'bg-amber-100 text-amber-600' },
};
const SCALE_MIN = 0.3;
const SCALE_MAX = 2.5;
const buildExpertRootDetail = (expert: ExpertCard): string => {
const segments = [expert.title, expert.detail].filter((item): item is string => Boolean(item && item.trim()));
return segments.join(' · ') || '已确认专家';
};
const CompactNode: React.FC<{
node: LayoutNode;
isDark: boolean;
appearing: boolean;
collapsible: boolean;
collapsed: boolean;
onToggleCollapse?: (nodeId: string) => void;
}> = ({ node, isDark, appearing, collapsible, collapsed, onToggleCollapse }) => {
const Icon = VARIANT_ICON[node.variant];
const grad = VARIANT_GRADIENT[node.variant][isDark ? 'dark' : 'light'];
const canToggle = collapsible && Boolean(onToggleCollapse);
const handleClick = (e: React.MouseEvent<SVGGElement>) => {
if (!canToggle) return;
e.stopPropagation();
onToggleCollapse(node.id);
};
const handleMouseDown = (e: React.MouseEvent<SVGGElement>) => {
if (canToggle) e.stopPropagation();
};
return (
<g
className={appearing ? 'sixstep-node-pop' : ''}
onMouseDown={handleMouseDown}
onClick={handleClick}
style={canToggle ? { cursor: 'pointer' } : undefined}
>
{appearing && (
<circle
cx={node.x + node.width / 2}
cy={node.y + node.height / 2}
r={0}
stroke={isDark ? '#22d3ee' : '#ef4444'}
className="sixstep-ring-burst"
/>
)}
<foreignObject x={node.x} y={node.y} width={node.width} height={node.height}>
<div className={`relative w-full h-full rounded-2xl border bg-gradient-to-br ${grad} backdrop-blur-sm flex items-center justify-center gap-1.5 px-2 shadow-sm`}>
{collapsible && (
<div
className={`absolute right-1.5 top-1.5 w-4 h-4 rounded-full flex items-center justify-center text-[10px] font-bold ${
isDark ? 'bg-slate-800/75 text-slate-200' : 'bg-white text-slate-600 border border-slate-200'
}`}
title={collapsed ? '展开子节点' : '收起子节点'}
>
{collapsed ? '+' : '-'}
</div>
)}
<div className={`w-7 h-7 rounded-lg flex items-center justify-center ${VARIANT_ICON_CLS[node.variant][isDark ? 'dark' : 'light']}`}>
<Icon className="w-3.5 h-3.5" />
</div>
<span className={`text-sm font-bold tracking-wider ${isDark ? 'text-white' : 'text-slate-800'}`}>
{VARIANT_ABBREV[node.variant]}
</span>
</div>
</foreignObject>
</g>
);
};
const ToolbarButton: React.FC<{ title: string; onClick: () => void; isDark: boolean; children: React.ReactNode }> = ({ title, onClick, isDark, children }) => (
<button
type="button"
title={title}
onClick={onClick}
className={`sixstep-toolbar-btn ${
isDark ? 'text-slate-300 hover:bg-slate-700/70 hover:text-white' : 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}`}
>
{children}
</button>
);
const SixStepFlowSandbox: React.FC<SixStepFlowSandboxProps> = ({ message, width, onResizeStart, onClose, onOpenFullscreen }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const purposes = message?.stages?.purpose?.cards ?? [];
const actors = message?.stages?.actor?.cards ?? [];
const behaviors = message?.stages?.behavior?.cards ?? [];
const narratives = message?.stages?.narrative?.cards ?? [];
const selectedExpert = message?.selectedExpert;
const layout = useMemo(
() => buildLayout(purposes, actors, behaviors, narratives, {
...COMPACT_OPTIONS,
rootTitle: selectedExpert?.name,
rootDetail: selectedExpert ? buildExpertRootDetail(selectedExpert) : undefined,
}),
[purposes, actors, behaviors, narratives, selectedExpert],
);
const currentSub = message?.currentSubStep;
const activePair = currentSub ? CURRENT_PAIR_BY_STAGE[currentSub] : null;
const statusTitle = currentSub ? STATUS_TITLE[currentSub] : '推演结果可视化';
const generating = Boolean(currentSub);
const [transform, setTransform] = useState({ tx: 0, ty: 0, scale: 1 });
const [isPanning, setIsPanning] = useState(false);
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set());
const containerRef = useRef<HTMLDivElement>(null);
const panStart = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
// Track newly appearing nodes for animation
const seenIdsRef = useRef<Set<string>>(new Set());
const [appearingIds, setAppearingIds] = useState<Set<string>>(new Set());
const nodeMap = useMemo(() => {
return new Map(layout.nodes.map((node) => [node.id, node]));
}, [layout.nodes]);
const visibleNodeSet = useMemo(() => {
const visible = new Set<string>();
layout.nodes.forEach((node) => {
let parentId = node.parent;
let hidden = false;
while (parentId) {
if (collapsedNodeIds.has(parentId)) {
hidden = true;
break;
}
parentId = nodeMap.get(parentId)?.parent;
}
if (!hidden) visible.add(node.id);
});
return visible;
}, [collapsedNodeIds, layout.nodes, nodeMap]);
const visibleNodes = useMemo(
() => layout.nodes.filter((node) => visibleNodeSet.has(node.id)),
[layout.nodes, visibleNodeSet],
);
const visibleEdges = useMemo(
() => layout.edges.filter((edge) => visibleNodeSet.has(edge.fromId) && visibleNodeSet.has(edge.toId)),
[layout.edges, visibleNodeSet],
);
const toggleNodeCollapse = (nodeId: string) => {
const targetNode = nodeMap.get(nodeId);
if (!targetNode || targetNode.children.length === 0) return;
setCollapsedNodeIds((prev) => {
const next = new Set(prev);
if (next.has(nodeId)) next.delete(nodeId);
else next.add(nodeId);
return next;
});
};
useEffect(() => {
const validIds = new Set(
layout.nodes.filter((node) => node.children.length > 0).map((node) => node.id),
);
setCollapsedNodeIds((prev) => {
let changed = false;
const next = new Set<string>();
prev.forEach((id) => {
if (validIds.has(id)) next.add(id);
else changed = true;
});
return changed ? next : prev;
});
}, [layout.nodes]);
useEffect(() => {
const newIds: string[] = [];
visibleNodes.forEach((n) => {
if (!seenIdsRef.current.has(n.id)) {
seenIdsRef.current.add(n.id);
newIds.push(n.id);
}
});
if (newIds.length > 0) {
setAppearingIds((prev) => {
const next = new Set(prev);
newIds.forEach((id) => next.add(id));
return next;
});
const timer = window.setTimeout(() => {
setAppearingIds((prev) => {
const next = new Set(prev);
newIds.forEach((id) => next.delete(id));
return next;
});
}, 700);
return () => window.clearTimeout(timer);
}
}, [visibleNodes]);
// Native wheel listener with passive:false so preventDefault actually blocks browser zoom
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = container.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const delta = -e.deltaY * 0.0015;
setTransform((prev) => {
const nextScale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, prev.scale * (1 + delta)));
const ratio = nextScale / prev.scale;
return { tx: mx - (mx - prev.tx) * ratio, ty: my - (my - prev.ty) * ratio, scale: nextScale };
});
};
container.addEventListener('wheel', onWheel, { passive: false });
return () => container.removeEventListener('wheel', onWheel);
}, []);
const handlePanStart = (e: React.MouseEvent) => {
if (e.button !== 0) return;
panStart.current = { x: e.clientX, y: e.clientY, tx: transform.tx, ty: transform.ty };
setIsPanning(true);
};
useEffect(() => {
if (!isPanning) return;
const move = (e: MouseEvent) => {
const start = panStart.current;
if (!start) return;
setTransform((prev) => ({ ...prev, tx: start.tx + (e.clientX - start.x), ty: start.ty + (e.clientY - start.y) }));
};
const up = () => {
panStart.current = null;
setIsPanning(false);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
return () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
}, [isPanning]);
const fitToView = () => {
const container = containerRef.current;
if (!container || layout.width <= 0 || layout.height <= 0) {
setTransform({ tx: 0, ty: 0, scale: 1 });
return;
}
const rect = container.getBoundingClientRect();
const scale = Math.min((rect.width - 40) / layout.width, (rect.height - 40) / layout.height, 1);
const tx = (rect.width - layout.width * scale) / 2;
const ty = (rect.height - layout.height * scale) / 2;
setTransform({ tx, ty, scale });
};
const centerView = () => {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
setTransform((prev) => ({
...prev,
tx: (rect.width - layout.width * prev.scale) / 2,
ty: (rect.height - layout.height * prev.scale) / 2,
}));
};
const zoomIn = () => setTransform((prev) => ({ ...prev, scale: Math.min(SCALE_MAX, prev.scale * 1.2) }));
const zoomOut = () => setTransform((prev) => ({ ...prev, scale: Math.max(SCALE_MIN, prev.scale / 1.2) }));
const reset = () => setTransform({ tx: 0, ty: 0, scale: 1 });
// Auto-fit on first render with content
const fitAppliedRef = useRef(false);
useEffect(() => {
if (!fitAppliedRef.current && layout.nodes.length > 1) {
fitToView();
fitAppliedRef.current = true;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layout.nodes.length]);
return (
<div
className={`relative h-full flex flex-col border-l ${
isDark ? 'border-slate-700/60 bg-slate-900/60' : 'border-red-100 bg-white'
}`}
style={{ width }}
>
<div className="sixstep-resize-handle" onMouseDown={onResizeStart} />
<div className={`flex items-center justify-between gap-3 px-4 py-3 border-b ${
isDark ? 'border-slate-700/60 bg-slate-900/40' : 'border-red-100 bg-gradient-to-r from-red-50/60 to-orange-50/40'
}`}>
<div className="flex items-center gap-2 min-w-0">
<div className={`w-8 h-8 rounded-2xl flex items-center justify-center ${
isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
} ${generating ? 'sixstep-pulse' : ''}`}>
<Compass className="w-4 h-4" />
</div>
<div className="min-w-0">
<div className={`text-sm font-semibold truncate ${
generating ? (isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light') : (isDark ? 'text-white' : 'text-gray-900')
}`}>
{statusTitle}
</div>
<div className={`text-[11px] ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
简化视图 · 节点缩写显示
</div>
</div>
</div>
<button
type="button"
onClick={onClose}
className={`p-1.5 rounded-lg transition-colors ${
isDark ? 'text-slate-400 hover:text-white hover:bg-slate-700/60' : 'text-gray-500 hover:text-gray-800 hover:bg-gray-100'
}`}
title="关闭沙箱"
>
<X className="w-4 h-4" />
</button>
</div>
<div className={`flex items-center gap-1 px-3 py-2 border-b ${
isDark ? 'border-slate-700/60 bg-slate-900/30' : 'border-red-100 bg-white'
}`}>
<ToolbarButton title="放大" onClick={zoomIn} isDark={isDark}><Plus className="w-4 h-4" /></ToolbarButton>
<ToolbarButton title="缩小" onClick={zoomOut} isDark={isDark}><Minus className="w-4 h-4" /></ToolbarButton>
<ToolbarButton title="恢复 1:1" onClick={reset} isDark={isDark}><RotateCcw className="w-4 h-4" /></ToolbarButton>
<ToolbarButton title="定位到画布中央" onClick={centerView} isDark={isDark}><Crosshair className="w-4 h-4" /></ToolbarButton>
<span className={`mx-2 h-5 w-px ${isDark ? 'bg-slate-700' : 'bg-gray-200'}`} />
<span className={`text-[11px] tabular-nums ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{Math.round(transform.scale * 100)}%
</span>
<div className="flex-1" />
<ToolbarButton title="全屏查看完整流程图" onClick={onOpenFullscreen} isDark={isDark}>
<Maximize2 className="w-4 h-4" />
</ToolbarButton>
</div>
<div className={`flex items-center gap-3 px-3 py-1.5 border-b text-[11px] flex-wrap ${
isDark ? 'border-slate-700/60 text-slate-400 bg-slate-900/30' : 'border-red-100 text-gray-500 bg-white'
}`}>
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
const active = activePair === pair;
return (
<span key={pair} className="inline-flex items-center gap-1.5">
<span className={`inline-block w-5 h-0.5 rounded-full ${active ? 'sixstep-ant-line' : ''}`} style={{ background: color, stroke: color }} />
<span className={active ? (isDark ? 'text-cyan-200' : 'text-red-600') : ''}>{EDGE_LABEL[pair]}</span>
</span>
);
})}
</div>
<div
ref={containerRef}
className={`flex-1 overflow-hidden relative ${isPanning ? 'sixstep-canvas-grabbing' : 'sixstep-canvas-grab'}`}
onMouseDown={handlePanStart}
>
{visibleNodes.length <= 1 ? (
<div className={`h-full flex flex-col items-center justify-center text-center ${
isDark ? 'text-slate-500' : 'text-gray-400'
}`}>
<Megaphone className="w-10 h-10 opacity-40 mb-3" />
<div className={`text-sm font-medium ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>等待推演开始</div>
<div className="text-xs mt-1">推演产出将以流程图形式呈现</div>
</div>
) : (
<svg
width="100%"
height="100%"
className="block select-none"
>
<defs>
{(Object.keys(EDGE_COLOR) as LayoutEdge['pair'][]).map((pair) => {
const color = EDGE_COLOR[pair][isDark ? 'dark' : 'light'];
return (
<marker
key={pair}
id={`sixstep-sandbox-arrow-${pair}`}
viewBox="0 0 8 8"
refX="7"
refY="4"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 8 4 L 0 8 z" fill={color} />
</marker>
);
})}
</defs>
<g transform={`translate(${transform.tx}, ${transform.ty}) scale(${transform.scale})`}>
{visibleEdges.map((edge) => {
const from = nodeMap.get(edge.fromId);
const to = nodeMap.get(edge.toId);
if (!from || !to) return null;
const color = EDGE_COLOR[edge.pair][isDark ? 'dark' : 'light'];
const isActive = activePair === edge.pair;
return (
<path
key={edge.id}
d={buildEdgePath(from, to)}
fill="none"
stroke={color}
strokeWidth={isActive ? 2.2 : 1.6}
strokeOpacity={isActive ? 1 : 0.7}
className={isActive ? 'sixstep-ant-line' : ''}
markerEnd={`url(#sixstep-sandbox-arrow-${edge.pair})`}
/>
);
})}
{visibleNodes.map((node) => (
<CompactNode
key={node.id}
node={node}
isDark={isDark}
appearing={appearingIds.has(node.id)}
collapsible={node.children.length > 0}
collapsed={collapsedNodeIds.has(node.id)}
onToggleCollapse={toggleNodeCollapse}
/>
))}
</g>
</svg>
)}
<div className={`absolute bottom-3 left-3 px-2 py-1 rounded-md text-[10px] backdrop-blur-md ${
isDark ? 'bg-slate-800/70 text-slate-400 border border-slate-700/50' : 'bg-white/90 text-gray-500 border border-gray-200'
}`}>
{VARIANT_LABEL.purpose}=md · {VARIANT_LABEL.actor}=xwt · {VARIANT_LABEL.behavior}=yqxw · {VARIANT_LABEL.driver}=qdys · {VARIANT_LABEL.narrative}=xs
</div>
<div className={`absolute bottom-3 right-3 px-2 py-1 rounded-md text-[10px] backdrop-blur-md ${
isDark ? 'bg-slate-800/70 text-slate-400 border border-slate-700/50' : 'bg-white/90 text-gray-500 border border-gray-200'
}`}>
点击父节点可收起/展开子节点
</div>
</div>
</div>
);
};
export default SixStepFlowSandbox;
@@ -0,0 +1,314 @@
import React from 'react';
import {
CheckCircle2,
ChevronLeft,
ChevronRight,
Circle,
Database,
FileCheck2,
Loader2,
Megaphone,
Sparkles,
Target,
Users,
Workflow,
Zap,
} from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import type { MainStepKey, StepStatus, SubStepKey } from './types';
interface SixStepProgressProps {
mainStatus: Record<MainStepKey, StepStatus>;
subStatus: Record<SubStepKey, StepStatus>;
collapsed?: boolean;
onToggleCollapse?: () => void;
}
interface StepItem<T extends string> {
key: T;
title: string;
desc: string;
icon: React.ComponentType<{ className?: string }>;
color: { dark: string; light: string };
}
const MAIN_STEPS: StepItem<MainStepKey>[] = [
{ key: 'info', title: '信息准备', desc: '梳理输入素材与上下文', icon: Database, color: { dark: 'text-sky-300 bg-sky-500/15 border-sky-400/40', light: 'text-sky-600 bg-sky-50 border-sky-200' } },
{ key: 'sixstep', title: '6 步法推演', desc: '依次完成四个阶段推演', icon: Workflow, color: { dark: 'text-violet-300 bg-violet-500/15 border-violet-400/40', light: 'text-violet-600 bg-violet-50 border-violet-200' } },
{ key: 'final', title: '最终结果生成', desc: '汇总各阶段产出,输出结论', icon: FileCheck2, color: { dark: 'text-emerald-300 bg-emerald-500/15 border-emerald-400/40', light: 'text-emerald-600 bg-emerald-50 border-emerald-200' } },
];
const SUB_STEPS: StepItem<SubStepKey>[] = [
{ key: 'purpose', title: '目的生成', desc: '识别核心目的方向', icon: Target, color: { dark: 'text-violet-300 bg-violet-500/15 border-violet-400/40', light: 'text-violet-600 bg-orange-50 border-orange-200' } },
{ key: 'actor', title: '行为体生成', desc: '匹配行为体角色', icon: Users, color: { dark: 'text-cyan-300 bg-cyan-500/15 border-cyan-400/40', light: 'text-red-600 bg-red-50 border-red-200' } },
{ key: 'behavior', title: '预期行为与驱动因素', desc: '推演行为与动因', icon: Zap, color: { dark: 'text-emerald-300 bg-emerald-500/15 border-emerald-400/40', light: 'text-emerald-600 bg-emerald-50 border-emerald-200' } },
{ key: 'narrative', title: '叙事生成', desc: '构建叙事框架', icon: Megaphone,color: { dark: 'text-amber-300 bg-amber-500/15 border-amber-400/40', light: 'text-amber-600 bg-amber-50 border-amber-200' } },
];
function dotColor(status: StepStatus, isDark: boolean) {
if (status === 'done') return isDark ? 'bg-emerald-400' : 'bg-emerald-500';
if (status === 'running') return isDark ? 'bg-cyan-400 sixstep-pulse' : 'bg-red-500 sixstep-pulse';
return isDark ? 'bg-slate-600' : 'bg-gray-300';
}
function StatusIcon({ status, isDark }: { status: StepStatus; isDark: boolean }) {
if (status === 'done') return <CheckCircle2 className={`w-4 h-4 ${isDark ? 'text-emerald-300' : 'text-emerald-500'}`} />;
if (status === 'running') return <Loader2 className={`w-4 h-4 sixstep-spin-soft ${isDark ? 'text-cyan-300' : 'text-red-500'}`} />;
return <Circle className={`w-3.5 h-3.5 ${isDark ? 'text-slate-500' : 'text-gray-300'}`} />;
}
function cardBorder(status: StepStatus, isDark: boolean) {
if (status === 'running') return isDark
? 'border-cyan-400/40 bg-cyan-500/5 shadow-[0_0_0_1px_rgba(34,211,238,0.15)]'
: 'border-red-300 bg-red-50/60 shadow-sm';
return isDark ? 'border-slate-700/60 bg-slate-800/40' : 'border-gray-200 bg-white';
}
function connectorColor(status: StepStatus, isDark: boolean) {
return status === 'done'
? isDark ? 'bg-emerald-400/40' : 'bg-emerald-300'
: isDark ? 'bg-slate-700/60' : 'bg-gray-200';
}
// ── Collapsed view ────────────────────────────────────────────────────────────
const CollapsedView: React.FC<{
mainStatus: Record<MainStepKey, StepStatus>;
subStatus: Record<SubStepKey, StepStatus>;
isDark: boolean;
onExpand: () => void;
}> = ({ mainStatus, subStatus, isDark, onExpand }) => {
const sixstepUnlocked = mainStatus.sixstep !== 'pending';
return (
<div className={`h-full flex flex-col items-center py-3 gap-3 border-r ${
isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/60'
}`}>
<button
type="button"
onClick={onExpand}
title="展开进度面板"
className={`w-9 h-9 rounded-xl flex items-center justify-center transition-colors ${
isDark ? 'text-slate-400 hover:text-white hover:bg-slate-700/60' : 'text-gray-500 hover:text-gray-800 hover:bg-gray-100'
}`}
>
<ChevronRight className="w-4 h-4" />
</button>
<div className="flex flex-col items-center gap-2 flex-1">
{MAIN_STEPS.map((step) => {
const status = mainStatus[step.key];
const Icon = step.icon;
const clr = step.color[isDark ? 'dark' : 'light'];
return (
<div key={step.key} className="relative flex flex-col items-center gap-1" title={step.title}>
<div className={`w-9 h-9 rounded-xl border flex items-center justify-center ${clr} ${
status === 'running' ? 'sixstep-pulse' : ''
}`}>
{status === 'running'
? <Loader2 className="w-4 h-4 sixstep-spin-soft" />
: <Icon className="w-4 h-4" />
}
</div>
<span className={`w-2 h-2 rounded-full ${dotColor(status, isDark)}`} />
{step.key === 'sixstep' && sixstepUnlocked && (
<div className="flex flex-col items-center gap-1 mt-1">
{SUB_STEPS.map((sub) => {
const ss = subStatus[sub.key];
const SubIcon = sub.icon;
const sc = sub.color[isDark ? 'dark' : 'light'];
return (
<div key={sub.key} title={sub.title} className={`w-7 h-7 rounded-lg border flex items-center justify-center ${sc} ${
ss === 'running' ? 'sixstep-pulse' : ''
}`}>
{ss === 'running'
? <Loader2 className="w-3 h-3 sixstep-spin-soft" />
: <SubIcon className="w-3 h-3" />
}
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
</div>
);
};
// ── Expanded view ─────────────────────────────────────────────────────────────
const SixStepProgress: React.FC<SixStepProgressProps> = ({
mainStatus,
subStatus,
collapsed = false,
onToggleCollapse,
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const sixstepUnlocked = mainStatus.sixstep !== 'pending';
const sixstepRunning = mainStatus.sixstep === 'running';
if (collapsed) {
return (
<CollapsedView
mainStatus={mainStatus}
subStatus={subStatus}
isDark={isDark}
onExpand={() => onToggleCollapse?.()}
/>
);
}
return (
<div className={`h-full flex flex-col overflow-hidden border-r ${isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/60'}`}>
{/* Header */}
<div className={`px-4 py-3 border-b flex items-center gap-2 ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center shrink-0 ${
isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
}`}>
<Sparkles className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>生成进度</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>实时跟踪推演阶段</div>
</div>
<button
type="button"
onClick={onToggleCollapse}
title="收起进度面板"
className={`w-8 h-8 rounded-xl flex items-center justify-center transition-colors shrink-0 ${
isDark ? 'text-slate-400 hover:text-white hover:bg-slate-700/60' : 'text-gray-500 hover:text-gray-800 hover:bg-gray-100'
}`}
>
<ChevronLeft className="w-4 h-4" />
</button>
</div>
{/* Steps */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-3">
{MAIN_STEPS.map((step, idx) => {
const status = mainStatus[step.key];
const Icon = step.icon;
const clr = step.color[isDark ? 'dark' : 'light'];
const showSubBlock = step.key === 'sixstep' && sixstepUnlocked;
return (
<div key={step.key} className="relative">
{idx < MAIN_STEPS.length - 1 && (
<div className={`absolute left-[22px] top-12 bottom-[-12px] w-px ${connectorColor(status, isDark)}`} />
)}
<div className={`relative rounded-2xl border p-3 transition-all ${cardBorder(status, isDark)}`}>
<div className="flex items-start gap-3">
<div className={`w-11 h-11 rounded-2xl border flex items-center justify-center shrink-0 transition-colors ${clr} ${
status === 'running' ? 'sixstep-pulse' : ''
}`}>
{status === 'running'
? <Loader2 className="w-5 h-5 sixstep-spin-soft" />
: <Icon className="w-5 h-5" />
}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className={`text-sm font-semibold truncate ${
status === 'running'
? isDark ? 'text-white' : 'text-gray-900'
: status === 'done'
? isDark ? 'text-slate-100' : 'text-gray-800'
: isDark ? 'text-slate-300' : 'text-gray-600'
}`}>{step.title}</div>
<span className={`px-2 py-0.5 rounded-full text-[10px] flex items-center gap-1 ${
status === 'done'
? isDark ? 'bg-emerald-500/20 text-emerald-300 border border-emerald-400/30' : 'bg-emerald-50 text-emerald-600 border border-emerald-200'
: status === 'running'
? isDark ? 'bg-cyan-500/20 text-cyan-200 border border-cyan-400/40' : 'bg-red-50 text-red-600 border border-red-200'
: isDark ? 'bg-slate-700/40 text-slate-400 border border-slate-600/40' : 'bg-gray-100 text-gray-500 border border-gray-200'
}`}>
<StatusIcon status={status} isDark={isDark} />
<span>{status === 'done' ? '已完成' : status === 'running' ? '生成中' : '待开始'}</span>
</span>
</div>
<div className={`text-xs mt-1 ${
status === 'running'
? isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'
: isDark ? 'text-slate-400' : 'text-gray-500'
}`}>{step.desc}</div>
</div>
</div>
{/* 6步法子阶段 */}
{showSubBlock && (
<div className="mt-3 pl-1">
<div className={`text-[10px] font-medium tracking-wider uppercase mb-2 ${
isDark ? 'text-slate-500' : 'text-gray-400'
}`}>
6 步法 · 子阶段
</div>
<div className="space-y-2">
{SUB_STEPS.map((sub, subIdx) => {
const ss = subStatus[sub.key];
const SubIcon = sub.icon;
const sc = sub.color[isDark ? 'dark' : 'light'];
return (
<div key={sub.key} className="relative pl-4 sixstep-fade-up">
{subIdx < SUB_STEPS.length - 1 && (
<div className={`absolute left-[7px] top-7 bottom-[-8px] w-px ${connectorColor(ss, isDark)}`} />
)}
<div className={`absolute left-0 top-3 w-3.5 h-3.5 rounded-full border-2 flex items-center justify-center ${
ss === 'running'
? isDark ? 'bg-cyan-500/30 border-cyan-300' : 'bg-red-100 border-red-400'
: ss === 'done'
? isDark ? 'bg-emerald-500/30 border-emerald-300' : 'bg-emerald-100 border-emerald-400'
: isDark ? 'bg-slate-900 border-slate-600' : 'bg-white border-gray-300'
}`} />
<div className={`flex items-start gap-2 rounded-xl border p-2 ${
ss === 'running'
? isDark ? 'border-cyan-400/40 bg-cyan-500/5' : 'border-red-300 bg-red-50/70'
: isDark ? 'border-slate-700/50 bg-slate-800/40' : 'border-gray-200 bg-white'
}`}>
<div className={`w-7 h-7 rounded-lg border flex items-center justify-center shrink-0 ${sc} ${
ss === 'running' ? 'sixstep-pulse' : ''
}`}>
{ss === 'running'
? <Loader2 className="w-3.5 h-3.5 sixstep-spin-soft" />
: <SubIcon className="w-3.5 h-3.5" />
}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2">
<div className={`text-xs font-semibold truncate ${
ss === 'done' ? isDark ? 'text-slate-100' : 'text-gray-800'
: ss === 'running' ? isDark ? 'text-white' : 'text-gray-900'
: isDark ? 'text-slate-300' : 'text-gray-600'
}`}>{sub.title}</div>
<StatusIcon status={ss} isDark={isDark} />
</div>
<div className={`text-[11px] mt-0.5 ${
ss === 'running'
? isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'
: isDark ? 'text-slate-500' : 'text-gray-400'
}`}>{sub.desc}</div>
</div>
</div>
</div>
);
})}
</div>
{sixstepRunning && (
<div className={`mt-2 text-[11px] flex items-center gap-1.5 ${isDark ? 'text-cyan-300' : 'text-red-500'}`}>
<Loader2 className="w-3 h-3 sixstep-spin-soft" />
<span className={isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'}>推演正在进行...</span>
</div>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
};
export default SixStepProgress;
@@ -0,0 +1,196 @@
import React from 'react';
import {
CheckCircle2,
Database,
FileCheck2,
Loader2,
Megaphone,
Target,
Users,
Workflow,
Zap,
} from 'lucide-react';
import type { MainStepKey, StepStatus, SubStepKey } from './types';
interface Props {
mainStatus: Record<MainStepKey, StepStatus>;
subStatus: Record<SubStepKey, StepStatus>;
isDark: boolean;
}
interface StepDef<T extends string> {
key: T;
title: string;
desc: string;
icon: React.ComponentType<{ className?: string }>;
color: { dark: string; light: string };
}
const MAIN_STEPS: StepDef<MainStepKey>[] = [
{ key: 'info', title: '信息准备', desc: '梳理输入素材与上下文', icon: Database, color: { dark: 'text-sky-300 bg-sky-500/15 border-sky-400/40', light: 'text-sky-600 bg-sky-50 border-sky-200' } },
{ key: 'sixstep', title: '6 步法推演', desc: '依次完成四个阶段推演', icon: Workflow, color: { dark: 'text-violet-300 bg-violet-500/15 border-violet-400/40', light: 'text-violet-600 bg-violet-50 border-violet-200' } },
{ key: 'final', title: '最终结果生成', desc: '汇总各阶段产出,输出结论', icon: FileCheck2, color: { dark: 'text-emerald-300 bg-emerald-500/15 border-emerald-400/40', light: 'text-emerald-600 bg-emerald-50 border-emerald-200' } },
];
const SUB_STEPS: StepDef<SubStepKey>[] = [
{ key: 'purpose', title: '目的生成', desc: '识别核心目的方向', icon: Target, color: { dark: 'text-violet-300 bg-violet-500/15 border-violet-400/40', light: 'text-violet-600 bg-orange-50 border-orange-200' } },
{ key: 'actor', title: '行为体生成', desc: '匹配行为体角色', icon: Users, color: { dark: 'text-cyan-300 bg-cyan-500/15 border-cyan-400/40', light: 'text-red-600 bg-red-50 border-red-200' } },
{ key: 'behavior', title: '预期行为与驱动因素', desc: '推演行为与动因', icon: Zap, color: { dark: 'text-emerald-300 bg-emerald-500/15 border-emerald-400/40', light: 'text-emerald-600 bg-emerald-50 border-emerald-200' } },
{ key: 'narrative', title: '叙事生成', desc: '构建叙事框架', icon: Megaphone, color: { dark: 'text-amber-300 bg-amber-500/15 border-amber-400/40', light: 'text-amber-600 bg-amber-50 border-amber-200' } },
];
function statusLabel(s: StepStatus) {
return s === 'done' ? '已完成' : s === 'running' ? '生成中' : '待开始';
}
function nodeStateClass(status: StepStatus, isDark: boolean) {
if (status === 'running') {
return `${isDark ? 'ring-2 ring-cyan-400/30' : 'ring-2 ring-red-200'} sixstep-pulse`;
}
if (status === 'done') {
return isDark ? 'ring-1 ring-emerald-400/40' : 'ring-1 ring-emerald-300';
}
return isDark ? 'opacity-55 saturate-50' : 'opacity-70 saturate-50';
}
function connectorClass(status: StepStatus, isDark: boolean) {
if (status === 'done') return isDark ? 'bg-emerald-400/60' : 'bg-emerald-400';
if (status === 'running') return isDark ? 'bg-cyan-400/50' : 'bg-red-300';
return isDark ? 'bg-slate-700/60' : 'bg-gray-200';
}
interface NodeProps {
status: StepStatus;
Icon: React.ComponentType<{ className?: string }>;
color: string;
size?: 'md' | 'sm';
title: string;
desc: string;
isDark: boolean;
}
const StepNode: React.FC<NodeProps> = ({ status, Icon, color, size = 'md', title, desc, isDark }) => {
const wrapSize = size === 'md' ? 'w-9 h-9' : 'w-7 h-7';
const iconSize = size === 'md' ? 'w-4 h-4' : 'w-3.5 h-3.5';
const badgeSize = size === 'md' ? 'w-4 h-4' : 'w-3.5 h-3.5';
const badgeIcon = size === 'md' ? 'w-3 h-3' : 'w-2.5 h-2.5';
return (
<div className="relative group shrink-0">
<div
className={`${wrapSize} rounded-xl border flex items-center justify-center transition-all ${color} ${nodeStateClass(status, isDark)}`}
>
{status === 'running'
? <Loader2 className={`${iconSize} sixstep-spin-soft`} />
: <Icon className={iconSize} />
}
</div>
{status === 'done' && (
<div
className={`absolute -bottom-0.5 -right-0.5 ${badgeSize} rounded-full flex items-center justify-center shadow-sm ${
isDark ? 'bg-emerald-500 text-slate-900' : 'bg-emerald-500 text-white'
}`}
>
<CheckCircle2 className={badgeIcon} />
</div>
)}
<div
className={`absolute left-1/2 -translate-x-1/2 top-full mt-2 px-2.5 py-1.5 rounded-lg whitespace-nowrap pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity z-50 shadow-lg border ${
isDark
? 'bg-slate-900 text-slate-100 border-slate-700'
: 'bg-white text-gray-800 border-gray-200'
}`}
>
<div className="text-xs font-semibold">{title}</div>
<div
className={`text-[11px] ${
status === 'running'
? isDark ? 'sixstep-shimmer-text' : 'sixstep-shimmer-text-light'
: isDark ? 'text-slate-400' : 'text-gray-500'
}`}
>
{desc}
</div>
<div
className={`text-[10px] mt-0.5 flex items-center gap-1 ${
status === 'done'
? isDark ? 'text-emerald-300' : 'text-emerald-600'
: status === 'running'
? isDark ? 'text-cyan-300' : 'text-red-500'
: isDark ? 'text-slate-500' : 'text-gray-400'
}`}
>
{status === 'running' && <Loader2 className="w-2.5 h-2.5 sixstep-spin-soft" />}
<span>{statusLabel(status)}</span>
</div>
</div>
</div>
);
};
const SixStepProgressHorizontal: React.FC<Props> = ({ mainStatus, subStatus, isDark }) => {
const sixstepUnlocked = mainStatus.sixstep !== 'pending';
return (
<div className="flex items-center gap-2 flex-nowrap py-1">
{MAIN_STEPS.map((step, idx) => {
const status = mainStatus[step.key];
const clr = step.color[isDark ? 'dark' : 'light'];
const isSixStep = step.key === 'sixstep';
const prevStatus = idx > 0 ? mainStatus[MAIN_STEPS[idx - 1].key] : 'pending';
return (
<React.Fragment key={step.key}>
{idx > 0 && (
<div className={`h-[2px] w-5 rounded shrink-0 transition-colors ${connectorClass(prevStatus, isDark)}`} />
)}
<StepNode
status={status}
Icon={step.icon}
color={clr}
title={step.title}
desc={step.desc}
isDark={isDark}
/>
{isSixStep && sixstepUnlocked && (
<>
<div className={`h-[2px] w-3 rounded shrink-0 transition-colors ${connectorClass(status, isDark)}`} />
<div
className={`flex items-center gap-1.5 px-2 py-1 rounded-xl border shrink-0 ${
isDark ? 'bg-slate-900/40 border-slate-700/60' : 'bg-gray-50 border-gray-200'
}`}
>
{SUB_STEPS.map((sub, sidx) => {
const ss = subStatus[sub.key];
const sc = sub.color[isDark ? 'dark' : 'light'];
const prevSub = sidx > 0 ? subStatus[SUB_STEPS[sidx - 1].key] : 'pending';
return (
<React.Fragment key={sub.key}>
{sidx > 0 && (
<div className={`h-[2px] w-2 rounded shrink-0 transition-colors ${connectorClass(prevSub, isDark)}`} />
)}
<StepNode
status={ss}
Icon={sub.icon}
color={sc}
size="sm"
title={sub.title}
desc={sub.desc}
isDark={isDark}
/>
</React.Fragment>
);
})}
</div>
</>
)}
</React.Fragment>
);
})}
</div>
);
};
export default SixStepProgressHorizontal;
@@ -0,0 +1,74 @@
import React, { useState } from 'react';
import { AlertTriangle, ChevronDown, ChevronUp, XCircle } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import type { StreamErrorItem } from './types';
interface StreamErrorNoticeProps {
errors: StreamErrorItem[];
}
const StreamErrorNotice: React.FC<StreamErrorNoticeProps> = ({ errors }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [expanded, setExpanded] = useState(false);
if (!errors || errors.length === 0) return null;
const hasErrors = errors.some((e) => e.level === 'error');
const errorCount = errors.filter((e) => e.level === 'error').length;
const warnCount = errors.filter((e) => e.level === 'warning').length;
let summaryText: string;
if (hasErrors && warnCount > 0) {
summaryText = `${errorCount} 个步骤失败,${warnCount} 个能力调用降级`;
} else if (hasErrors) {
summaryText = `${errorCount} 个步骤执行失败`;
} else {
summaryText = `${warnCount} 个能力调用失败,系统已继续执行`;
}
const borderClass = hasErrors
? isDark ? 'border-red-500/40 bg-red-500/10 text-red-200' : 'border-red-200 bg-red-50 text-red-700'
: isDark ? 'border-amber-500/40 bg-amber-500/10 text-amber-200' : 'border-amber-200 bg-amber-50 text-amber-700';
const dividerClass = hasErrors
? isDark ? 'border-red-500/20' : 'border-red-200'
: isDark ? 'border-amber-500/20' : 'border-amber-200';
return (
<div className={`rounded-xl border text-xs ${borderClass}`}>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-left"
onClick={() => setExpanded((v) => !v)}
>
{hasErrors
? <XCircle className="w-3.5 h-3.5 shrink-0" />
: <AlertTriangle className="w-3.5 h-3.5 shrink-0" />}
<span className="flex-1 font-medium">{summaryText}</span>
{expanded
? <ChevronUp className="w-3.5 h-3.5 shrink-0 opacity-50" />
: <ChevronDown className="w-3.5 h-3.5 shrink-0 opacity-50" />}
</button>
{expanded && (
<div className={`border-t px-3 py-2 space-y-2.5 ${dividerClass}`}>
{errors.map((err) => (
<div key={err.errorId}>
<div className="flex items-start gap-1.5">
{err.level === 'error'
? <XCircle className="w-3 h-3 mt-0.5 shrink-0" />
: <AlertTriangle className="w-3 h-3 mt-0.5 shrink-0" />}
<span className="leading-snug">{err.message}</span>
</div>
<div className={`mt-0.5 pl-[18px] font-mono text-[10px] opacity-40`}>
错误 ID:{err.errorId}
</div>
</div>
))}
</div>
)}
</div>
);
};
export default StreamErrorNotice;
File diff suppressed because it is too large. Load diff
+826
View File
@@ -0,0 +1,826 @@
# 6 步法推演问答页面 — 开发说明文档
> 所在目录:`src/components/angentchat/`(主页面 `SixStepChatPage.tsx`)
> 路由地址:`/qa-center/sixstep-chat`
> 菜单入口:问答中心 → **6bf**(侧栏文案见 `src/components/Sidebar.tsx`)
> 同模块相关路由:`/qa-center/sixstep-agents`、`/qa-center/sixstep-agent-edit`
> **最后更新(摘要)**:2026-05-12 — 流式 **`error_log`** / **`errors[]`** 与 `StreamErrorNotice`(后端 skill 失败摘要可含异常片段);`buildLayout` 根节点 **`rootTitle` / `rootDetail`**(已选专家);**`SixStepFlowFullscreen`** 父节点 **收起/展开子树**(过渡动画、缩略图随可见节点);**`/observability`** 独立全站路由。
---
## 一、功能概述
「6bf 推演问答」由 `SixStepChatPage` 对接后端 **SixStep MCP Backend**。主链路通过 **`POST /api/orchestrate/stream`** 统一流式返回:后端先做 **意图识别**(`intent` 事件),再分支为 **查找专家**(`expert_*`)、**6 步法编排**(信息准备 → 目的 → 行为体 → 预期行为与驱动因素 → 叙事 → 最终结果)、**追问**(`follow_up`,`delta` 流式 Markdown)或 **澄清**(`clarify`)。支持 **结构化卡片** 与 **Markdown** 输出切换;结构化模式下可打开 **流程图沙箱 / 全屏流程图**(`SixStepFlowSandbox` / `SixStepFlowFullscreen`),展示编排返回的实体关系。
单 MCP 调试仍可直接调用 **`POST /api/agents/{key}/invoke`**(例如 `find_expert`、`create_expert`);6bf 主页面以编排流为主,专家查找也可由流内 `intent` 分支触发。对接细节见同目录 **`INTENT_INTEGRATION.md`**。
页面布局为 **顶栏 + 下方主体**:顶栏含页面标题、`SixStepProgressHorizontal`(横向主/子步骤进度)、以及结构化·Markdown、聊天记录、流程图沙箱等控制;主体为 **可收起的会话列表轨**(`SixStepChatHistory`)、**中间对话区与输入**(`SixStepConversation` + `ChatInputComposer`,含专家匹配与 MCP 选择弹窗)、以及 **可选的右侧沙箱**(宽度可拖拽,可关闭)。全屏流程图仍以 `fixed` 遮罩覆盖视口,独立于上述布局。
```
┌──────────────────────────────────────────────────────────────────┐
│ 顶栏:标题 · SixStepProgressHorizontal · 输出格式 · 聊天记录 · 流程图 │
├───┬────────────────────────────────────────────────────┬─────────┤
│会话│ SixStepConversation(多轮结构化阶段) │ 流程图 │
│列表│ + ChatInputComposer │ 沙箱 │
│轨 │ │(可关闭) │
└───┴────────────────────────────────────────────────────┴─────────┘
```
---
## 二、目录结构与各文件职责
```
src/components/angentchat/
│
├── types.ts # 数据模型类型(含 phase / intent / rawSteps 等)
├── mockData.ts # 本地模拟流式控制器(与 api 侧事件对齐,便于离线演示)
├── api.ts # runIntentOrchestrateStream、runSixStepDeduction、runExpertSearch/Create
├── flowLayout.ts # SVG 流程图布局
├── styles.css # 动画与工具类
│
├── SixStepChatPage.tsx # 推演主页面(状态中枢)
├── SixStepProgressHorizontal.tsx# 顶栏横向主/子步骤进度(主页面使用)
├── SixStepProgress.tsx # 纵向进度面板(组件保留;布局以横向为准)
├── SixStepChatHistory.tsx # 会话列表轨(展开/收起)
├── SixStepConversation.tsx # 对话区(阶段卡片 + 专家匹配)
├── SixStepCards.tsx # 阶段卡片原子组件
├── ExpertMatchCards.tsx # 专家匹配卡片
├── McpSelectModal.tsx # MCP 多选确认弹窗
├── StreamErrorNotice.tsx # 助手消息底部:汇总 SSE error_log / 网络类错误的可折叠提示条
├── SixStepFlowSandbox.tsx # 右侧流程图沙箱
├── SixStepFlowFullscreen.tsx # 全屏流程图
├── AgentCardList.tsx # 路由 sixstep-agents:MCP 卡片列表
├── AgentEditPage.tsx # 路由 sixstep-agent-edit:单 MCP 调试编辑
├── AgentChatMessageList.tsx # 智能体对话消息列表(编辑页等)
├── INTENT_INTEGRATION.md # 意图流与前后端字段说明(对接备忘)
└── doc.md # 本文档
```
---
## 三、数据模型(`types.ts`)
所有类型均从此文件导出,是各组件之间传递数据的唯一契约。
### 3.1 基础枚举
| 类型 | 取值 | 说明 |
|------|------|------|
| `MainStepKey` | `'info' \| 'sixstep' \| 'final'` | 三个主步骤的键名 |
| `SubStepKey` | `'purpose' \| 'actor' \| 'behavior' \| 'narrative'` | 6步法的四个子阶段键名 |
| `StepStatus` | `'pending' \| 'running' \| 'done'` | 步骤状态,驱动进度面板和动画 |
| `SixStepPhase` | `'idle'` \| `'intent-detecting'` \| `'expert-match'` \| `'expert-confirm'` \| `'generate'` \| `'paused'` \| `'completed'` \| `'clarifying'` \| `'follow-up'` | 助手消息所处交互阶段(与编排流、停止、追问等配合) |
| `SelectableMcpKey` | `'purpose' \| 'actor' \| 'behavior_factor' \| 'narrative'` | MCP 弹窗可选子链,会随请求传给后端 `selected_mcps` |
`BackendStepResult`:与后端 `step_end` 原始结构对齐,用于 **`continue_analysis`** 时把 `last_steps` 回传编排接口;`SixStepMessage.rawSteps` 在流式过程中累积,`resumedFrom` 对应 **`resume`** 事件中的继续起点。
### 3.2 卡片数据结构
每类卡片都有独立接口,并通过外键(`purposeId`、`actorId`、`driverId`)建立父子关联,构成树形层级关系。
```
PurposeCard (目的)
└─ ActorCard.purposeId → PurposeCard.id
└─ BehaviorDriverCard.actorId → ActorCard.id
└─ NarrativeCard.driverId → BehaviorDriverCard.id
```
| 接口 | 字段 | 说明 |
|------|------|------|
| `PurposeCard` | `id, index, title, reason` | 目的卡片,包含标题和推演理由 |
| `ActorCard` | `id, index, purposeId, title, content` | 行为体卡片,`purposeId` 关联所属目的 |
| `BehaviorDriverCard` | `id, index, actorId, expectedBehavior, driver` | 预期行为 + 驱动因素二合一卡片,`actorId` 关联所属行为体 |
| `NarrativeCard` | `id, index, driverId, title, reason` | 叙事卡片,`driverId` 关联所属驱动因素(`BehaviorDriverCard.id`) |
### 3.3 阶段数据结构
每个子阶段有独立的 `*StageData` 接口:
```typescript
interface PurposeStageData {
thinking: string; // 模型思考过程文本(流式填充)
cards: PurposeCard[]; // 已生成的卡片列表(逐张追加)
complete: boolean; // 该阶段是否完全结束
}
// ActorStageData / BehaviorStageData / NarrativeStageData 结构类似
```
### 3.4 消息总线:`SixStepMessage`
这是整个页面最核心的数据结构,一条助手消息包含所有推演状态:
```typescript
interface SixStepMessage {
id: string;
role: 'user' | 'assistant';
content?: string;
timestamp: Date;
outputFormat?: 'structured' | 'markdown';
markdownContent?: string;
stages?: SixStepStages;
finalAnswer?: string;
mainStepStatus?: Record<MainStepKey, StepStatus>;
subStepStatus?: Record<SubStepKey, StepStatus>;
currentSubStep?: SubStepKey;
currentMainStep?: MainStepKey;
phase?: SixStepPhase;
expertMatch?: ExpertMatchData;
selectedExpert?: ExpertCard;
selectedMcps?: SelectableMcpKey[];
intent?: { name: string; confidence?: number; reason?: string };
clarifyQuestion?: string;
rawSteps?: BackendStepResult[];
resumedFrom?: string;
effectiveUserInput?: string;
errors?: StreamErrorItem[];
}
```
**`errors` / `StreamErrorItem`**:`runIntentOrchestrateStream` 收到 SSE **`error_log`** 时追加;致命失败仍会走 **`error`** 事件并映射为同类结构。`SixStepConversation` 中 **`StreamErrorNotice`** 读取 `message.errors` 展示摘要(可折叠列表)。
**关键设计**:`mainStepStatus` / `subStepStatus` 驱动顶栏 `SixStepProgressHorizontal`;`stages` 驱动中间对话渲染;`currentSubStep` 驱动流程图沙箱蚂蚁线。`phase` / `intent` / `rawSteps` 与编排流、暂停继续、追问分支一致。所有信息集中在一个对象内,组件通过 props 按需取用。
---
## 四、模拟数据生成器(`mockData.ts`)
> **线上主路径**:`SixStepChatPage` 使用 **`runIntentOrchestrateStream`** 消费真实 `/api/orchestrate/stream`。`mockData.ts` 中的 **`runSixStepDeduction`** 仍保留,用于本地演示或与 `api.ts` 中纯编排解析逻辑对齐;新功能(意图、专家 SSE、`resume` 等)以 `runIntentOrchestrateStream` 为准。
### 4.1 种子数据
文件顶部定义了四组静态种子数组:
| 变量 | 长度 | 用途 |
|------|------|------|
| `PURPOSE_SEED` | 3 | 目的卡片内容模板 |
| `ACTOR_SEED` | 7 | 行为体卡片内容模板 |
| `BEHAVIOR_SEED` | 8 | 预期行为与驱动因素卡片内容模板 |
| `NARRATIVE_SEED` | 8 | 叙事卡片内容模板 |
使用时循环取模(`(counter - 1) % SEED.length`),确保不越界。
### 4.2 卡片生成规则
| 阶段 | 数量规则 |
|------|---------|
| 目的 | 固定 3 张(取自 `PURPOSE_SEED` 全量) |
| 行为体 | 每个目的对应的数量:第 0 个目的 2 张,其余 1 张,共 4 张 |
| 预期行为/驱动 | 每个行为体:偶数索引 2 张,奇数索引 1 张,共 6 张 |
| 叙事 | 每个预期行为:偶数索引 2 张,奇数索引 1 张,共 9 张 |
最终流程图节点总数:`1(智能体)+ 3 + 4 + 6×2(预期行为+驱动因素)+ 9 = 29 节点`。
### 4.3 流式控制器:`runSixStepDeduction`
```typescript
export function runSixStepDeduction(
message: SixStepMessage, // 直接在此对象上原地修改字段
options: {
onUpdate: (msg: SixStepMessage) => void; // 每次有新内容时回调
onComplete?: () => void; // 全部完成时回调
}
): SixStepMockController
```
返回的 `SixStepMockController` 包含:
- `message`: 正在被修改的消息对象引用
- `stop()`: 立即取消所有待执行的 `setTimeout`,中止推演
- `promise`: 推演完成后 resolve 的 Promise(stop 后不再 resolve)
**内部时序**(所有延迟均可通过顶部常量调整):
```
常量 默认值 说明
TYPE_INTERVAL 28ms 每次打字输出 4 个字符后的等待间隔
STAGE_GAP 380ms 阶段切换之间的等待时长
```
执行顺序(伪代码):
```
setMain('info', 'running') → emit
wait(800ms) ← 模拟信息准备耗时
setMain('info', 'done')
setMain('sixstep', 'running') → emit
wait(STAGE_GAP)
setSub('purpose', 'running') → emit
typeInto(PURPOSE_THINKING) ← 逐字打出思考过程
append cards 逐张(wait 280ms/张)
setSub('purpose', 'done') → emit
wait(STAGE_GAP)
setSub('actor', 'running') → emit
typeInto(ACTOR_THINKING)
append cards 逐张(wait 220ms/张)
setSub('actor', 'done') → emit
wait(STAGE_GAP)
setSub('behavior', 'running') → emit
typeInto(BEHAVIOR_THINKING)
append cards 逐张(wait 200ms/张)
setSub('behavior', 'done') → emit
wait(STAGE_GAP)
setSub('narrative', 'running') → emit
typeInto(NARRATIVE_THINKING)
append cards 逐张(wait 200ms/张)
setSub('narrative', 'done')
setMain('sixstep', 'done')
setMain('final', 'running') → emit
wait(STAGE_GAP)
typeInto(FINAL_ANSWER) ← 逐字打出最终结论
setMain('final', 'done') → emit
onComplete()
```
**`emit` 机制**:每次调用 `onUpdate({ ...message, stages: { ...message.stages } })`,将消息对象浅拷贝后传给父组件,触发 React re-render。
---
## 五、流程图布局工具(`flowLayout.ts`)
此文件被沙箱和全屏组件共同引用,避免重复计算。
### 5.1 核心函数:`buildLayout`
```typescript
function buildLayout(
purposes: PurposeCard[],
actors: ActorCard[],
behaviors: BehaviorDriverCard[],
narratives: NarrativeCard[],
options?: BuildLayoutOptions // 可覆盖节点尺寸和间距,以及根节点文案
): FlowLayout
```
**`BuildLayoutOptions` 扩展**:除 `nodeWidth`、`nodeHeight`、`levelGap`、`nodeGap`、`paddingX`、`paddingY` 外,可选 **`rootTitle`**、**`rootDetail`**。用于将层级 0 的 `agent-root` 从默认「智能体 / 协同推演…」改为 **当前已选专家**(`SixStepFlowSandbox` / `SixStepFlowFullscreen` 在存在 `message.selectedExpert` 时传入专家姓名与简介摘要)。
返回 `FlowLayout`:
```typescript
interface FlowLayout {
nodes: LayoutNode[]; // 已计算好坐标的节点列表
edges: LayoutEdge[]; // 已确定连接关系和颜色对的边列表
width: number; // 整体画布宽度(用于 SVG viewBox)
height: number; // 整体画布高度
}
```
**布局算法**:改良的"叶节点优先后序遍历":
1. 从 agent 根节点开始,递归 DFS 处理子树
2. 叶节点(无子节点的节点)按从左到右顺序分配 x 坐标,每次递增 `nodeWidth + nodeGap`
3. 非叶节点的 x = (最左子节点 x + 最右子节点 x) / 2,确保父节点居中于子节点上方
4. 所有节点的 y = `paddingY + level × (nodeHeight + levelGap)`,level 即树的深度
节点层级(`level`)对应关系:
| level | variant | 说明 |
|-------|---------|------|
| 0 | `agent` | 智能体根节点 |
| 1 | `purpose` | 目的 |
| 2 | `actor` | 行为体 |
| 3 | `behavior` | 预期行为 |
| 4 | `driver` | 驱动因素 |
| 5 | `narrative` | 叙事 |
注意:`BehaviorDriverCard` 一张卡片会生成两个节点(`behavior` + `driver`),id 分别为 `${card.id}-behavior` 和 `${card.id}-driver`。
### 5.2 共享常量
| 常量 | 说明 |
|------|------|
| `EDGE_COLOR` | 按边类型分别定义深色/浅色主题颜色(5 组) |
| `EDGE_LABEL` | 每种边类型的中文图例说明 |
| `CURRENT_PAIR_BY_STAGE` | `SubStepKey → LayoutEdge['pair']` 映射,决定哪组边在哪个阶段展示蚂蚁线 |
| `STATUS_TITLE` | `SubStepKey → string` 映射,沙箱/全屏顶部标题文字 |
| `VARIANT_ABBREV` | 节点类型 → 简化缩写(沙箱专用,如 `purpose → 'md'`) |
| `VARIANT_LABEL` | 节点类型 → 完整中文标签 |
### 5.3 辅助函数:`buildEdgePath`
生成 SVG 三次贝塞尔曲线路径字符串:
```
M x1 y1 C x1 my, x2 my, x2 y2
```
控制点均取中间 y 坐标(`(y1+y2)/2`),形成平滑 S 型连线,不与节点重叠。
---
## 六、CSS 动画(`styles.css`)
| 类名 | 动画 | 用途 |
|------|------|------|
| `.sixstep-ant-line` | `stroke-dasharray: 6 6` + `dashoffset` 循环 | SVG 边上的蚂蚁线流动效果(当阶段正在生成时) |
| `.sixstep-pulse` | box-shadow 呼吸光晕 | 进度面板当前步骤图标、沙箱标题图标 |
| `.sixstep-shimmer-text` / `...-light` | 渐变横扫文字 | 进度面板"生成中"文字、沙箱标题生成中状态 |
| `.sixstep-spin-soft` | 360° 匀速旋转 | loading 图标 |
| `.sixstep-fade-up` | 从下方淡入 | 子阶段卡片逐步出现 |
| `.sixstep-node-pop` | scale 弹出 + 高光 | 流程图新增节点的弹出动画(持续 600ms) |
| `.sixstep-ring-burst` | SVG circle `r` 从 0 扩散到 60,同时 opacity→0 | 节点出现时的扩散光环效果(持续 700ms) |
| `.sixstep-canvas-grab` / `.sixstep-canvas-grabbing` | `cursor: grab/grabbing` | 流程图画布拖拽手型 |
| `.sixstep-toolbar-btn` | 基础尺寸 + hover 过渡 | 工具栏图标按钮 |
| `.sixstep-minimap-rect` | 静态样式 | 缩略图视口矩形指示框 |
| `.sixstep-resize-handle` | 竖线拖拽手柄 | 沙箱左边缘的宽度调节拖拽条 |
---
## 七、主页面(`SixStepChatPage.tsx`)
### 7.1 职责
主页面是整个功能的**状态中枢**,负责:
- 多会话列表与 **`/api/conversations`** 持久化(`filter_key` / `filter_value` 标识 6bf 页)。
- 创建并管理 **`runIntentOrchestrateStream`**(编排 + 意图)与 **`runExpertSearch` / `runExpertCreate`**(仍可用于「换一批专家」「创建专家」等直连 invoke)的控制器生命周期。
- 将状态与专家相关回调分发给 `SixStepConversation`、`McpSelectModal`、`SixStepFlowSandbox` 等。
- 管理沙箱宽度拖拽。
### 7.2 状态一览(节选)
| 状态 | 类型 | 说明 |
|------|------|------|
| `conversations` / `activeConvId` | 多会话 | 当前选中会话及其 `messages` |
| `input` | `string` | 输入框文本 |
| `isGenerating` | `boolean` | 是否正在请求流式接口 |
| `outputFormat` | `'structured' \| 'markdown'` | 传给后端的 `output_format` |
| `sandboxOpen` / `sandboxWidth` / `fullscreenOpen` | UI | 流程图沙箱与全屏 |
| `historyOpen` / `loading` | UI / 加载 | 聊天记录侧栏与首屏拉取 |
| `activeAssistantMsgId` | `string \| null` | 当前高亮/流式更新的助手消息 |
| `mcpModal` | `{ open, messageId, expert }` | MCP 多选弹窗状态 |
### 7.3 Ref 一览
| Ref | 类型 | 用途 |
|-----|------|------|
| `controllerRef` | `SixStepMockController \| null` | `runIntentOrchestrateStream` 的 `stop()` |
| `expertControllerRef` | `ExpertRunController \| null` | 专家搜索/创建流的 `stop()` |
| `conversationsRef` | 会话数组 | 持久化前读取最新消息 |
| `containerRef` / `resizeRef` | DOM / 拖拽 | 沙箱宽度拖拽 |
### 7.4 核心逻辑
**`handleSend`**
1. 追加用户消息;创建初始助手消息(`buildInitialAssistantMessage`,`phase` 随流程推进)。
2. 调用 **`runIntentOrchestrateStream`**,请求体为 **`IntentOrchestrateRequest`**:`user_input`、`output_format`、`intent_mode`(默认 `auto`)、`current_phase`(含上一条助手为 **`paused`** 时传 `paused`)、`selected_expert`、`selected_mcps`、`expert_candidates`、**`last_steps`(来自上一条助手 `rawSteps`)**、`conversation_history` 等。
- 暂停继续时会继承上一条助手的 `rawSteps` 与 `selectedMcps`(`pausedSelectedMcps`),并显式回传 `selected_mcps` / `last_steps`,避免恢复时丢失步骤选择与历史上下文。
3. `onUpdate` 内更新当前会话中对应 `id` 的助手消息;结束后 `persistConversation`。
**专家与 MCP**
- 用户确认专家后 **`handleMcpModalConfirm`**:写入 `selectedExpert` / `selectedMcps`,并可用 **`intent_mode: 'force_orchestrate'`** 立即启动分析(无需用户再次输入)。
- **`handleRefetchExpert`**:可用 `force_find_expert` 并排除已有候选。
**`handleStop`**
- `AbortController` 中止流;将未完成步骤从 `running` 收回;助手 **`phase` 置为 `paused`**,便于下次发送走 **`continue_analysis`** / `rerun_from_step` / `revise_input_continue` / `change_steps_continue` / `edit_stage_continue`。
**流内关键事件补充**
- `resume`:恢复继续分析时,会先把已有 `stages` 中的 `purpose/actor/behavior` 卡片回填到转换缓存,确保后续 `step_end` 的父子映射准确。
- `rerun`:前端会按 `affected_steps` 清空受影响阶段(`stages`、`subStepStatus`、`rawSteps` 与局部卡片缓存),再接收重跑后的 `step_*`。
- `change_steps`:按 `removed_steps` 清理被移除阶段与 `rawSteps`,保留未移除阶段并继续执行 `steps_to_run`。
- `edit_apply`:先更新被编辑卡片内容,再按 `affected_steps` 清理后续阶段并触发重跑。
- `intent`:包含 `slots`(如 `step`、`new_input`),用于更细粒度前端状态更新。
**沙箱宽度拖拽**:逻辑同前(`mousemove` / `mouseup`,保证对话区最小宽度)。
**活跃助手消息派生**:
```typescript
const activeAssistantId = /* activeAssistantMsgId 或最后一条 assistant */;
const activeAssistant = messages.find((m) => m.id === activeAssistantId);
const liveMainStatus = activeAssistant?.mainStepStatus ?? DEFAULT_MAIN_STATUS;
const liveSubStatus = activeAssistant?.subStepStatus ?? DEFAULT_SUB_STATUS;
```
`liveMainStatus` / `liveSubStatus` 传给 **`SixStepProgressHorizontal`**;`activeAssistant` 传给沙箱与全屏。
### 7.5 Props 传递图
```
SixStepChatPage
│
├─► SixStepChatHistory # 会话列表(与顶栏「聊天记录」联动)
├─► SixStepProgressHorizontal # mainStatus / subStatus / isDark
├─► SixStepConversation
│ messages, isGenerating, activeMessageId,
│ expertHandlers(选专家 / 换一批 / 创建 / 确认)
├─► ChatInputComposer # onSend → handleSend, onStop → handleStop
├─► SixStepFlowSandbox # message={activeAssistant}
├─► SixStepFlowFullscreen
└─► McpSelectModal # 专家确认后选 MCP
```
---
## 八、左侧进度面板(`SixStepProgress.tsx`)
> **主页面顶栏**使用的是 **`SixStepProgressHorizontal.tsx`**(横向主/子步骤);本节描述纵向 `SixStepProgress` 的 Props 与交互,组件仍保留供其他布局复用。
### 8.1 Props
| Prop | 类型 | 必填 | 说明 |
|------|------|------|------|
| `mainStatus` | `Record<MainStepKey, StepStatus>` | ✓ | 三主步骤状态 |
| `subStatus` | `Record<SubStepKey, StepStatus>` | ✓ | 四子阶段状态 |
| `collapsed` | `boolean` | 可选 | 是否折叠,默认 `false` |
| `onToggleCollapse` | `() => void` | 可选 | 展开/收起回调 |
### 8.2 展开视图(`collapsed = false`)
从上到下展示三个主步骤卡片,每个卡片包含:
- 彩色图标(每个步骤有独立配色,running 时呼吸脉冲)
- 标题 + 进度徽标(`已完成 / 生成中 / 待开始`)
- 副文本描述(running 时展示 shimmer 动画)
- 步骤之间用竖线连接(完成后变绿色)
当 `sixstep` 阶段进入 running 或 done,在其卡片内部展开四个子阶段列表(`SUB_STEPS`),每个子阶段包含:
- 彩色图标(与流程图对应色)
- 标题 + 状态图标
- 时间线左侧的节点圆点 + 连接竖线
右上角收起按钮(`ChevronLeft`)触发 `onToggleCollapse`。
### 8.3 折叠视图(`collapsed = true`)
渲染轻量的 `CollapsedView` 子组件:
- 宽度由父容器通过 `w-14` 控制(56px),有 `transition-all duration-300` 过渡动画
- 顶部 `ChevronRight` 按钮用于展开
- 竖向排列三个主步骤彩色图标 + 状态圆点
- 若 `sixstep` 已解锁,在其下方额外展示四个小尺寸子阶段图标(`w-7 h-7`)
- 所有元素均有 `title` 属性,鼠标悬停显示步骤名称
### 8.4 配色方案
步骤颜色定义在 `MAIN_STEPS` 和 `SUB_STEPS` 数组的 `color` 字段中:
| 步骤 | 深色 | 浅色 |
|------|------|------|
| 信息准备 | sky-300 / sky-500 | sky-600 / sky-50 |
| 6步法推演 | violet-300 / violet-500 | violet-600 / violet-50 |
| 最终结果 | emerald-300 / emerald-500 | emerald-600 / emerald-50 |
| 目的 | violet-300 | orange |
| 行为体 | cyan-300 | red |
| 预期行为/驱动 | emerald-300 | emerald |
| 叙事 | amber-300 | amber |
---
## 九、对话列表(`SixStepConversation.tsx`)
### 9.1 Props
| Prop | 类型 | 说明 |
|------|------|------|
| `messages` | `SixStepMessage[]` | 全部消息 |
| `isGenerating` | `boolean` | 是否正在生成 |
| `activeMessageId` | `string \| null` | 当前流式聚焦的助手消息 id |
| `expertHandlers` | 可选对象 | `onSelectExpert` / `onRefetchExpert` / `onCreateExpert` / `onConfirmExpert` |
### 9.2 消息渲染策略
每条消息根据 `role` 字段分叉:
- `role === 'user'` → 渲染 `UserMessage`:右对齐气泡 + 用户头像
- `role === 'assistant'` → 渲染 `AssistantMessage`:左对齐 + 机器人头像(生成中时呼吸脉冲 + 星光图标)
消息列表容器 `ref` 监听内容变化,每次 `messages` 或 `isGenerating` 变化后自动滚动到底部。
### 9.3 助手消息 `AssistantMessage`
内部读取 `message.stages` 和 `message.mainStepStatus`、`message.subStepStatus`,根据是否存在各阶段数据来条件渲染对应的阶段块。底部(及部分分支末尾)挂载 **`StreamErrorNotice`**,展示 **`message.errors`**(来自 SSE `error_log` 等)。
助手消息结构示意:
```
AssistantMessage
│
├─ 信息准备中(仅当 info=running 且无任何 stage)→ shimmer 提示
│
├─ PurposeStage (当 stages.purpose 存在时)
│ ├─ InlineCollapsible (以 StageHeader 为标题)
│ │ ├─ ThinkingBlock (思考过程)
│ │ └─ InlineCollapsible (目的卡片列表)
│ │ └─ PurposeCardItem × N
│
├─ ActorStage (当 stages.actor 存在时)
│ ├─ InlineCollapsible
│ │ ├─ ThinkingBlock
│ │ └─ InlineCollapsible (行为体卡片列表)
│ │ └─ 按 purpose 分组
│ │ ├─ ParentGroupHeader (所属目的)
│ │ └─ ActorCardItem × N
│
├─ BehaviorStage (当 stages.behavior 存在时)
│ ├─ InlineCollapsible
│ │ ├─ ThinkingBlock
│ │ └─ InlineCollapsible (预期行为/驱动因素卡片列表)
│ │ └─ 按 actor 分组
│ │ ├─ ParentGroupHeader (所属行为体)
│ │ └─ BehaviorCardItem × N(2列网格)
│
├─ NarrativeStage (当 stages.narrative 存在时)
│ ├─ InlineCollapsible
│ │ ├─ ThinkingBlock
│ │ └─ InlineCollapsible (叙事卡片列表)
│ │ └─ 按 driver 分组
│ │ ├─ ParentGroupHeader (所属驱动因素)
│ │ └─ NarrativeCardItem × N
│
└─ FinalAnswer (当 finalAnswer 存在或 final=running 时)
│
└─ StreamErrorNotice (当 message.errors 非空)
```
**分组计算**:`ActorStage`、`BehaviorStage`、`NarrativeStage` 都在内部用 `useMemo` 计算分组,将卡片按父 ID 聚合到 Map,再按父实体列表顺序输出。
---
## 十、卡片原子组件(`SixStepCards.tsx`)
此文件作为"组件库",为对话列表提供所有可复用的卡片和容器组件。
### 10.1 `InlineCollapsible`
通用折叠面板,思考过程区块、卡片列表区块均复用此组件。
| Prop | 说明 |
|------|------|
| `title` | 标题区 ReactNode |
| `children` | 折叠内容 |
| `defaultOpen` | 默认展开状态(默认 `true`) |
| `variant` | `'default'`(标准边框背景)或 `'subtle'`(更淡)|
展开/收起使用 `grid-template-rows` 动画(`0fr → 1fr`),配合 `opacity` 过渡,无需 JS 计算高度。
### 10.2 各卡片组件
| 组件 | 卡片类型 | 布局特点 |
|------|---------|---------|
| `PurposeCardItem` | 目的 | 紫/橙渐变背景,索引徽标 + Target 图标 + 标题 + 理由 |
| `ActorCardItem` | 行为体 | 青/红渐变背景,UserCheck 图标 + 标题 + 内容 |
| `BehaviorCardItem` | 预期行为/驱动因素 | 翠绿渐变背景,上下两部分:TrendingUp 图标+预期行为 / Zap 图标+驱动因素 |
| `NarrativeCardItem` | 叙事 | 琥珀渐变背景,ScrollText 图标 + 标题 + 理由 |
所有卡片均有 `.sixstep-fade-up` 进入动画,`hover` 时边框加深。
### 10.3 `ParentGroupHeader`
显示某类卡片所属父实体名称的标签行,用于行为体、预期行为/驱动、叙事列表的分组标题。
| Prop | 说明 |
|------|------|
| `label` | 关系说明(如"所属目的") |
| `parentTitle` | 父实体名称 |
| `count` | 该组卡片数量 |
| `variant` | `'actor' \| 'behavior' \| 'narrative'`,决定图标和配色 |
### 10.4 `ThinkingBlock`
展示思考过程文本的区块,`loading` 为 `true` 时标题行展示 "生成中" shimmer 文字。
### 10.5 `StageHeader`
折叠面板标题,包含阶段图标、阶段标题、卡片数量徽标、状态徽标(已完成/生成中)。`loading = true` 时图标呼吸脉冲,副文本展示 shimmer 动画。
---
## 十一、流程图沙箱(`SixStepFlowSandbox.tsx`)
### 11.1 Props
| Prop | 类型 | 说明 |
|------|------|------|
| `message` | `SixStepMessage \| undefined` | 活跃助手消息,用于提取各阶段卡片数据 |
| `width` | `number` | 当前沙箱宽度(px),由主页面拖拽控制 |
| `onResizeStart` | `(e: React.MouseEvent) => void` | 拖拽手柄 mousedown 回调 |
| `onClose` | `() => void` | 关闭沙箱回调 |
| `onOpenFullscreen` | `() => void` | 打开全屏回调 |
### 11.2 布局参数(`COMPACT_OPTIONS`)
沙箱使用缩小版布局,节点小于全屏版:
```typescript
const COMPACT_OPTIONS = {
nodeWidth: 120, nodeHeight: 60,
levelGap: 80, nodeGap: 24,
paddingX: 32, paddingY: 28,
};
```
### 11.3 简化节点(`CompactNode`)
每个节点只显示**彩色图标 + 阶段缩写**(`VARIANT_ABBREV`):
| 层级 | 缩写 |
|------|------|
| 智能体(或根标题改为已选专家名时仍为 variant `agent`) | AI |
| 目的 | md |
| 行为体 | xwt |
| 预期行为 | yqxw |
| 驱动因素 | qdys |
| 叙事 | xs |
底部固定图例说明对应关系。
### 11.4 新增节点动画
通过 `seenIdsRef`(`Set<string>`)记录已渲染过的节点 ID。每次 `layout.nodes` 变化时,找出新增的 ID,将其加入 `appearingIds` 状态,700ms 后自动移除。
`CompactNode` 接收 `appearing` prop,为 `true` 时:
1. 为 `<g>` 添加 `.sixstep-node-pop` CSS 类(scale 弹出)
2. 在节点中心绘制一个 SVG `<circle>` 并添加 `.sixstep-ring-burst` CSS 类(扩散光环)
### 11.5 交互能力
**平移(Pan)**:
- `mousedown` 记录起始坐标和初始 `tx/ty` 到 `panStart.current`
- 通过 `useEffect` 监听 `isPanning` 状态,动态注册/注销 `window` 的 `mousemove` 和 `mouseup`
- `mousemove` 计算偏移量 `(e.clientX - startX, e.clientY - startY)` 叠加到初始平移值
**缩放(Zoom)**:
- 通过 `useEffect` 向 `containerRef` 注册原生 `wheel` 事件(`{ passive: false }`),确保 `e.preventDefault()` 生效,不触发浏览器页面缩放
- 以鼠标位置为缩放中心:`nextTx = mx - (mx - prev.tx) * (nextScale / prev.scale)`
- 缩放范围:0.3x ~ 2.5x
**工具栏**:
| 按钮 | 功能 |
|------|------|
| `+` | 按 1.2 倍放大 |
| `-` | 按 1.2 倍缩小 |
| ↺ | 恢复 1:1(translate 0,0 scale 1) |
| ⊕ | 定位画布中央(保持当前缩放比,调整平移使图形居中) |
| ⤢ | 触发 `onOpenFullscreen` |
首次有内容时自动执行 `fitToView`(等比缩放 + 居中),由 `fitAppliedRef` 控制只执行一次。
### 11.6 连线说明
SVG 中的 `<defs>` 为每种边类型各定义一个箭头 marker(id 加 `sixstep-sandbox-` 前缀,与全屏版 marker 不冲突)。当 `activePair === edge.pair` 时,该边添加 `.sixstep-ant-line` CSS 类展示蚂蚁线动画。
### 11.7 调整宽度
左侧有一个 `position: absolute` 的 `.sixstep-resize-handle` 竖线拖拽条,`mousedown` 触发 `onResizeStart`(由主页面处理具体逻辑)。
---
## 十二、全屏流程图视图(`SixStepFlowFullscreen.tsx`)
### 12.1 Props
| Prop | 类型 | 说明 |
|------|------|------|
| `message` | `SixStepMessage \| undefined` | 与沙箱相同的活跃助手消息 |
| `onClose` | `() => void` | 关闭全屏回调 |
### 12.2 与沙箱的区别
| 特性 | 沙箱 | 全屏 |
|------|------|------|
| 布局参数 | 紧凑(节点 120×60) | 标准(节点 200×84) |
| 节点内容 | 缩写 + 图标 | 完整标题 + 详情 + 图标 |
| 节点出现动画 | 有(pop + ring-burst) | 无 |
| 子树收起/展开 | 无 | 有(点击**有子节点**的父节点;右下角「-」收起 / 「+」展开;画布左下角有操作提示) |
| 缩略图 | 无 | 有(右下角;仅绘制当前可见节点) |
| 下载 | 无 | 有(导出 SVG) |
| Esc 关闭 | 无 | 有 |
| 层级 | flex 子元素 | `fixed inset-0 z-50`(全视口遮罩) |
| 缩放范围 | 0.3x ~ 2.5x | 0.2x ~ 3x |
### 12.3 详细节点(`DetailNode`)
使用 `foreignObject` 嵌入 HTML,展示:
- 彩色图标包裹 + 大写类型标签(`VARIANT_LABEL`)
- 节点标题(`line-clamp-1`)
- 详情内容(`line-clamp-2`)
深色/浅色主题的渐变背景、图标颜色均通过 `VARIANT_TOKEN` 查表获取。
**收起子树**:当 `flowLayout` 中节点带 **`children`** 时,卡片右上角显示 **`+` / `-`**,点击节点(或该角标)切换 **`collapsedNodeIds`**:自该节点向下的后代节点与相关边不再纳入 **`targetVisibleNodeSet`**,并以约 **240ms** 的透明度过渡(进入/离开集合合并渲染,避免闪烁)。重新生成布局时会剔除已不存在的节点 id,避免残留收起状态。根节点若被选为专家展示,仍可按树结构收起其下子链。
### 12.4 缩略图(`Minimap`)
固定 220×150px,渲染在右下角。
内部逻辑:
1. 计算整体布局与 minimap 尺寸的缩放比 `ratio = Math.min((W-10)/layoutW, (H-10)/layoutH)`
2. 按 `ratio` 缩小绘制每个节点矩形(使用 `MINIMAP_VARIANT_FILL` 颜色,无文字)
3. 根据当前 `transform`(`tx, ty, scale`)和视口尺寸,计算可见区域在世界坐标的矩形,再乘 `ratio` 映射到 minimap 坐标系,绘制蓝色描边矩形(`.sixstep-minimap-rect`)
4. 点击 minimap:将点击的 minimap 坐标逆映射到世界坐标,调用 `onJump` 将视口移动到以该点为中心
### 12.5 SVG 下载
点击下载按钮时:
1. 克隆 `svgRef.current`(当前全屏 SVG 元素)
2. 设置 `width/height` 为实际布局尺寸,移除 transform
3. 用 `XMLSerializer` 序列化为字符串
4. 创建 `Blob` 和临时 `<a>` 标签,触发下载
5. 文件名格式:`6步法推演-YYYY-MM-DD-HH-mm-ss.svg`
> ⚠️ 注意:由于节点内容在 `foreignObject` 中使用 HTML + CSS 类名,下载的 SVG 打开时若外部查看器不支持 CSS,样式会丢失。可在未来改为纯 SVG 文本节点渲染以提升兼容性。
---
## 十三、整体数据流图
```
用户输入(回车)
│
▼
SixStepChatPage.handleSend()
│
├─► 当前会话追加 UserMessage + 初始 AssistantMessage
│
└─► runIntentOrchestrateStream(assistant, { onUpdate, onComplete }, IntentOrchestrateRequest)
│
│ readSSE('/api/orchestrate/stream', body)
│
│ intent / expert_* / clarify / resume / rerun / change_steps / edit_apply
│ start / step_* / flow / done / delta(追问或 markdown)
│ 每次 onUpdate → 更新会话中该 assistant 的 SixStepMessage
│
▼ React re-render
│
├─► SixStepProgressHorizontal(liveMainStatus / liveSubStatus)
├─► SixStepConversation(messages、expertHandlers、intent/clarify 等)
├─► SixStepFlowSandbox / Fullscreen(stages → buildLayout)
└─► persistConversation(SQLite)
```
---
## 十四、路由注册
**`src/App.tsx`**:
```tsx
// 路由定义
<Route path="sixstep-chat" element={<SixStepChatPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
// 导航路径映射(handleNavigate 函数)
else if ([..., 'sixstep-chat'].includes(page)) {
path = `/qa-center/${page}`;
}
```
**可观测性页面**(调用耗时 / 错误日志筛选):全站路由 **`/observability`**,侧栏顶级「可观测性」,对接后端 **`GET /api/observability/calls`**、**`/errors`**、**`/options`** 等(详见 `backend/doc.md`)。与 6bf 页面独立,不在 `/qa-center` 前缀下。
**`src/components/Sidebar.tsx`**:
```tsx
// 菜单配置(问答中心 children)
{ id: 'sixstep-chat', label: '6bf', icon: Workflow }
// qaPages 集合(控制问答中心父菜单高亮)
const qaPages = new Set([..., 'sixstep-chat']);
```
---
## 十五、流式错误与 `error_log`(`api.ts`)
编排 SSE 除 **`error`** 外,可能下发 **`error_log`**(步骤失败摘要、skill 降级等)。`runIntentOrchestrateStream` 将其归一为 **`StreamErrorItem`** 并 **`push` 到 `message.errors`**;网络异常也会写入类似结构。后端在可执行 **skill 失败已跳过**时,**`message` 文案**可能附带 **异常原因前若干字符**(与 `backend/app/mcp/base.py` 截断策略一致),便于在气泡内区分超时与其它错误。
**`StreamErrorNotice`**:仅在对应助手气泡内展示,支持折叠展开、错误/警告计数摘要;不参与 `showAnalysisContent` 隐藏逻辑(专家阶段亦可显示)。
---
## 十六、扩展指南
### 接入编排与意图流
1. 主入口为 **`runIntentOrchestrateStream`**(`api.ts`),与后端 **`/api/orchestrate/stream`** 事件一一对应;扩展新意图时通常同时改 **`backend/app/intent/detector.py`** 与 **`runIntentOrchestrateStream`** 的分支解析。
- 当前除 `continue_analysis` 外,还支持 `rerun_from_step`、`revise_input_continue`、`change_steps_continue`、`edit_stage_continue` 分支。
2. 保持 **`onUpdate(SixStepMessage)`** 契约:`SixStepChatPage` 只需合并助手消息字段。
3. 纯编排、无意图分支的调试可继续用 **`runSixStepDeduction`**(同文件内,仅 `start` / `step_*` / `flow` / `done`)。
详见 **`INTENT_INTEGRATION.md`** 与仓库 **`docs/xvqiu.md`**。
### 某子步(如行为体、叙事)体感很慢时
前端 **`step_end`** 事件里会带上该步的 **`duration_ms`**(整步总耗时),便于在 UI 或调试时对比各步。
更细拆分(skill 耗时 vs 拼 prompt 扩展 vs LLM)在**后端**完成:每次 `POST /api/agents/{key}/invoke` 及编排内对 `MCPAgent.invoke` 的调用,服务端会打 **`mcp_timing`** / **`mcp_skill_timing`** 日志(见 **`backend/doc.md`**「MCP 调用接口」与 **`backend/DEVELOPMENT.md`** §5.2)。排查时在运行 `python -m app.main` 的终端查看即可;无需改前端请求体。
### 替换流程图引擎(如 G6 / React Flow)
仅需替换 `SixStepFlowSandbox.tsx` 和 `SixStepFlowFullscreen.tsx` 中的 SVG 渲染部分:
- `buildLayout()` 的 `LayoutNode` 数组可直接转换为 G6 节点
- `LayoutEdge` 数组可直接转换为 G6 边
- `CURRENT_PAIR_BY_STAGE` 和 `EDGE_COLOR` 仍适用
### 新增推演子阶段
1. 在 `types.ts` 的 `SubStepKey` 中新增键名
2. 在 `SixStepStages` 中新增对应 `*StageData` 字段
3. 在 `mockData.ts` 的 `runSixStepDeduction` 中新增生成逻辑(若仍需要离线演示)
4. 在 `SixStepConversation.tsx` 的 `AssistantMessage` 中新增阶段渲染块
5. 在 `SixStepProgress.tsx` / **`SixStepProgressHorizontal.tsx`** 的 `SUB_STEPS` 中新增步骤项
6. 若需要流程图新层级,在 `flowLayout.ts` 的 `buildLayout` 中新增节点构建逻辑
@@ -0,0 +1,266 @@
import type {
ActorCard,
BehaviorDriverCard,
NarrativeCard,
PurposeCard,
SubStepKey,
} from './types';
export interface LayoutNode {
id: string;
level: number;
x: number;
y: number;
width: number;
height: number;
variant: 'agent' | 'purpose' | 'actor' | 'behavior' | 'driver' | 'narrative';
title: string;
detail: string;
children: string[];
parent?: string;
}
export interface LayoutEdge {
id: string;
fromId: string;
toId: string;
pair: 'agent-purpose' | 'purpose-actor' | 'actor-behavior' | 'behavior-driver' | 'driver-narrative';
}
export interface FlowLayout {
nodes: LayoutNode[];
edges: LayoutEdge[];
width: number;
height: number;
}
export interface BuildLayoutOptions {
nodeWidth?: number;
nodeHeight?: number;
levelGap?: number;
nodeGap?: number;
paddingX?: number;
paddingY?: number;
rootTitle?: string;
rootDetail?: string;
}
export const CURRENT_PAIR_BY_STAGE: Record<SubStepKey, LayoutEdge['pair'] | null> = {
purpose: 'agent-purpose',
actor: 'purpose-actor',
behavior: 'actor-behavior',
narrative: 'driver-narrative',
};
export const STATUS_TITLE: Record<SubStepKey, string> = {
purpose: '正在生成目的中',
actor: '正在生成行为体中',
behavior: '正在生成预期行为和驱动因素中',
narrative: '正在生成叙事中',
};
export const EDGE_COLOR: Record<LayoutEdge['pair'], { dark: string; light: string }> = {
'agent-purpose': { dark: '#a78bfa', light: '#fb923c' },
'purpose-actor': { dark: '#22d3ee', light: '#ef4444' },
'actor-behavior': { dark: '#34d399', light: '#10b981' },
'behavior-driver': { dark: '#5eead4', light: '#0d9488' },
'driver-narrative': { dark: '#fbbf24', light: '#f59e0b' },
};
export const EDGE_LABEL: Record<LayoutEdge['pair'], string> = {
'agent-purpose': '智能体→目的',
'purpose-actor': '目的→行为体',
'actor-behavior': '行为体→预期行为',
'behavior-driver': '预期行为→驱动因素',
'driver-narrative': '驱动因素→叙事',
};
export function buildEdgePath(from: LayoutNode, to: LayoutNode): string {
const x1 = from.x + from.width / 2;
const y1 = from.y + from.height;
const x2 = to.x + to.width / 2;
const y2 = to.y;
const my = (y1 + y2) / 2;
return `M ${x1} ${y1} C ${x1} ${my}, ${x2} ${my}, ${x2} ${y2}`;
}
export function buildLayout(
purposes: PurposeCard[],
actors: ActorCard[],
behaviors: BehaviorDriverCard[],
narratives: NarrativeCard[],
options: BuildLayoutOptions = {},
): FlowLayout {
const NODE_WIDTH = options.nodeWidth ?? 200;
const NODE_HEIGHT = options.nodeHeight ?? 84;
const LEVEL_GAP = options.levelGap ?? 130;
const NODE_GAP = options.nodeGap ?? 28;
const PADDING_X = options.paddingX ?? 48;
const PADDING_Y = options.paddingY ?? 40;
const rootTitle = options.rootTitle?.trim() || '智能体';
const rootDetail = options.rootDetail?.trim() || '协同推演 6 步法生成内容';
const nodes: LayoutNode[] = [];
const nodeMap = new Map<string, LayoutNode>();
const push = (node: LayoutNode) => {
nodes.push(node);
nodeMap.set(node.id, node);
};
const agent: LayoutNode = {
id: 'agent-root',
level: 0,
x: 0,
y: 0,
width: NODE_WIDTH,
height: NODE_HEIGHT,
variant: 'agent',
title: rootTitle,
detail: rootDetail,
children: purposes.map((p) => p.id),
};
push(agent);
purposes.forEach((p) => {
push({
id: p.id,
level: 1,
x: 0,
y: 0,
width: NODE_WIDTH,
height: NODE_HEIGHT,
variant: 'purpose',
title: p.title,
detail: p.reason,
children: actors.filter((a) => a.purposeId === p.id).map((a) => a.id),
parent: 'agent-root',
});
});
actors.forEach((a) => {
push({
id: a.id,
level: 2,
x: 0,
y: 0,
width: NODE_WIDTH,
height: NODE_HEIGHT,
variant: 'actor',
title: a.title,
detail: a.content,
children: behaviors.filter((b) => b.actorId === a.id).map((b) => `${b.id}-behavior`),
parent: a.purposeId,
});
});
behaviors.forEach((b) => {
const behaviorId = `${b.id}-behavior`;
const driverId = `${b.id}-driver`;
push({
id: behaviorId,
level: 3,
x: 0,
y: 0,
width: NODE_WIDTH,
height: NODE_HEIGHT,
variant: 'behavior',
title: '预期行为',
detail: b.expectedBehavior,
children: [driverId],
parent: b.actorId,
});
push({
id: driverId,
level: 4,
x: 0,
y: 0,
width: NODE_WIDTH,
height: NODE_HEIGHT,
variant: 'driver',
title: '驱动因素',
detail: b.driver,
children: narratives.filter((n) => n.driverId === b.id).map((n) => n.id),
parent: behaviorId,
});
});
narratives.forEach((n) => {
push({
id: n.id,
level: 5,
x: 0,
y: 0,
width: NODE_WIDTH,
height: NODE_HEIGHT,
variant: 'narrative',
title: n.title,
detail: n.reason,
children: [],
parent: `${n.driverId}-driver`,
});
});
const leafCounter = { v: 0 };
const place = (node: LayoutNode) => {
if (node.children.length === 0) {
node.x = PADDING_X + leafCounter.v * (NODE_WIDTH + NODE_GAP);
leafCounter.v += 1;
return;
}
node.children.forEach((cid) => {
const child = nodeMap.get(cid);
if (child) place(child);
});
const xs = node.children.map((cid) => nodeMap.get(cid)?.x ?? 0).filter((v) => v >= 0);
if (xs.length > 0) {
node.x = (Math.min(...xs) + Math.max(...xs)) / 2;
} else {
node.x = PADDING_X;
}
};
place(agent);
nodes.forEach((node) => {
node.y = PADDING_Y + node.level * (NODE_HEIGHT + LEVEL_GAP);
});
const edges: LayoutEdge[] = [];
nodes.forEach((node) => {
node.children.forEach((cid) => {
const child = nodeMap.get(cid);
if (!child) return;
let pair: LayoutEdge['pair'];
if (node.variant === 'agent') pair = 'agent-purpose';
else if (node.variant === 'purpose') pair = 'purpose-actor';
else if (node.variant === 'actor') pair = 'actor-behavior';
else if (node.variant === 'behavior') pair = 'behavior-driver';
else pair = 'driver-narrative';
edges.push({ id: `${node.id}->${cid}`, fromId: node.id, toId: cid, pair });
});
});
const allLevels = Math.max(...nodes.map((n) => n.level));
const maxX = Math.max(...nodes.map((n) => n.x + n.width));
const totalWidth = maxX + PADDING_X;
const totalHeight = PADDING_Y * 2 + (allLevels + 1) * NODE_HEIGHT + allLevels * LEVEL_GAP;
return { nodes, edges, width: totalWidth, height: totalHeight };
}
export const VARIANT_ABBREV: Record<LayoutNode['variant'], string> = {
agent: 'AI',
purpose: 'md',
actor: 'xwt',
behavior: 'yqxw',
driver: 'qdys',
narrative: 'xs',
};
export const VARIANT_LABEL: Record<LayoutNode['variant'], string> = {
agent: '智能体',
purpose: '目的',
actor: '行为体',
behavior: '预期行为',
driver: '驱动因素',
narrative: '叙事',
};
+346
View File
@@ -0,0 +1,346 @@
import type {
ActorCard,
BehaviorDriverCard,
MainStepKey,
NarrativeCard,
PurposeCard,
SixStepMessage,
StepStatus,
SubStepKey,
} from './types';
const PURPOSE_SEED: { title: string; reason: string }[] = [
{ title: '建立品牌权威认知', reason: '通过持续的专业内容输出,塑造行业领导者形象,使核心受众在面对同类品牌时优先想起本品牌,并带来长期信任度' },
{ title: '激活核心用户决策路径', reason: '梳理用户从兴趣触达到决策转化的关键触点,缩短决策链路、提升转化效率' },
{ title: '强化情感价值连接', reason: '通过故事化叙事激发情绪共鸣,沉淀难以被替代的品牌情感资产' },
];
const ACTOR_SEED: { title: string; content: string }[] = [
{ title: '行业意见领袖(KOL)', content: '在专业垂直领域有持续影响力,可在第一时间撬动核心圈层并带动二次扩散' },
{ title: '品牌官方账号矩阵', content: '统一对外传递品牌主张与最新动态,承担信息发布、用户互动与口碑响应' },
{ title: '深度内容创作者', content: '产出长图文、视频解读等内容形态,承担引发讨论与口碑沉淀的角色' },
{ title: '行业垂直媒体', content: '具备专业背书能力,承担议题设置与权威背书职责' },
{ title: '核心用户共创社区', content: '由忠诚用户构成,通过 UGC 形式输出真实使用反馈与场景示范' },
{ title: '行业分析机构', content: '通过研究报告与数据支撑品牌主张,加强观点的可信度' },
{ title: '品牌代言关键人', content: '与品牌价值观高度契合的代言人或合作方,承担情感投射与信任放大' },
];
const BEHAVIOR_SEED: { expectedBehavior: string; driver: string }[] = [
{ expectedBehavior: '主动转发并附带专业点评', driver: '内容契合自身专业立场,带来圈层认同与社交资本' },
{ expectedBehavior: '在专业社区发起延伸讨论', driver: '议题具备争议或洞察价值,能引发同行参与并扩散' },
{ expectedBehavior: '将品牌内容沉淀为参考资料', driver: '内容具备结构化、可复用的方法论价值,可在工作中长期引用' },
{ expectedBehavior: '主动在直播间或路演中引用品牌观点', driver: '观点具备可解释性,能帮助行为体强化自身专业可信度' },
{ expectedBehavior: '产出二次内容(长图文 / 视频)', driver: '原始素材具备可改编空间,能匹配自身受众语境' },
{ expectedBehavior: '组织线下闭门交流会', driver: '议题对核心受众具备稀缺性,符合社群运营节奏' },
{ expectedBehavior: '在调研报告中将品牌列为典型案例', driver: '品牌案例具备数据支撑、典型且可被结构化引用' },
{ expectedBehavior: '在公开演讲中引用品牌叙事框架', driver: '叙事框架具备方法论高度,可被借用以提升自身演讲含金量' },
];
const NARRATIVE_SEED: { title: string; reason: string }[] = [
{ title: '从噪音中识别真实信号', reason: '聚焦行业过度信息的痛点,主张以理性框架回归本质' },
{ title: '让专业判断回到决策中心', reason: '强调专业判断在复杂环境下的不可替代性' },
{ title: '以长期主义穿越周期', reason: '倡导关注长期价值积累,而非短期爆点' },
{ title: '把复杂问题讲简单', reason: '通过结构化表达让专业内容更易被传播与理解' },
{ title: '让每一次选择都有迹可循', reason: '强调决策可追溯、可复盘,建立专业信任' },
{ title: '与时间做朋友', reason: '把品牌成长锚定在长期视角,淡化短期波动焦虑' },
{ title: '相信专业的力量', reason: '通过行为体共同的专业身份认同强化品牌主张' },
{ title: '把方法论变成可复用的工具', reason: '将抽象主张沉淀为工具化资产,方便受众反复引用' },
];
const PURPOSE_THINKING = `根据当前问题语境,需要先厘清品牌战略推演的核心目的:
1. 综合输入素材,识别可能的目标方向;
2. 评估不同目的之间的优先级与互补性;
3. 选择最契合当前阶段的目的组合,作为后续推演基础。`;
const ACTOR_THINKING = `围绕已生成的目的,开始识别支撑该目的所需的关键行为体:
- 需要明确每个行为体的角色定位
- 评估其对目的的贡献度与覆盖范围
- 避免行为体之间能力重叠或缺位`;
const BEHAVIOR_THINKING = `针对每个行为体,推演其在场景中应当展现的预期行为:
- 行为需要可观察、可衡量
- 同时识别推动该行为发生的关键驱动因素
- 行为与驱动因素需保持因果一致性`;
const NARRATIVE_THINKING = `基于驱动因素,构建用于触发行为的叙事框架:
- 叙事需要紧扣驱动因素的情绪或理性触点
- 兼顾品牌主张与受众心智匹配度
- 形成可被复用的叙事模板`;
export interface SixStepMockController {
message: SixStepMessage;
stop: () => void;
promise: Promise<void>;
}
const TYPE_INTERVAL = 28;
const STAGE_GAP = 380;
function buildPurposeCards(): PurposeCard[] {
return PURPOSE_SEED.map((seed, idx) => ({
id: `purpose-${idx + 1}`,
index: idx + 1,
title: seed.title,
reason: seed.reason,
}));
}
function buildActorCards(purposes: PurposeCard[]): ActorCard[] {
const cards: ActorCard[] = [];
let counter = 0;
purposes.forEach((purpose, pIdx) => {
const count = pIdx === 0 ? 2 : 1;
for (let i = 0; i < count; i += 1) {
counter += 1;
const seed = ACTOR_SEED[(counter - 1) % ACTOR_SEED.length];
cards.push({
id: `actor-${counter}`,
index: counter,
purposeId: purpose.id,
title: seed.title,
content: seed.content,
});
}
});
return cards;
}
function buildBehaviorCards(actors: ActorCard[]): BehaviorDriverCard[] {
const cards: BehaviorDriverCard[] = [];
let counter = 0;
actors.forEach((actor, aIdx) => {
const count = aIdx % 2 === 0 ? 2 : 1;
for (let i = 0; i < count; i += 1) {
counter += 1;
const seed = BEHAVIOR_SEED[(counter - 1) % BEHAVIOR_SEED.length];
cards.push({
id: `behavior-${counter}`,
index: counter,
actorId: actor.id,
expectedBehavior: seed.expectedBehavior,
driver: seed.driver,
});
}
});
return cards;
}
function buildNarrativeCards(behaviors: BehaviorDriverCard[]): NarrativeCard[] {
const cards: NarrativeCard[] = [];
let counter = 0;
behaviors.forEach((behavior, bIdx) => {
const count = bIdx % 2 === 0 ? 2 : 1;
for (let i = 0; i < count; i += 1) {
counter += 1;
const seed = NARRATIVE_SEED[(counter - 1) % NARRATIVE_SEED.length];
cards.push({
id: `narrative-${counter}`,
index: counter,
driverId: behavior.id,
title: seed.title,
reason: seed.reason,
});
}
});
return cards;
}
function buildFinalAnswer(): string {
return `综合本次 6 步法推演结果,可以得到如下战略要点:
1. **目的**:以品牌权威认知为锚点,在专业人群中建立可信表达。
2. **行为体**:依靠 KOL、官方账号矩阵、深度创作者三类协同发声。
3. **预期行为与驱动因素**:通过专业内容激发主动传播与延伸讨论,形成圈层共振。
4. **叙事**:以"在噪音中识别真实信号"为核心叙事线,串联整个传播节奏。
后续可基于该推演继续生成传播节奏、渠道分工与内容资产清单。`;
}
export function buildInitialAssistantMessage(): SixStepMessage {
return {
id: `assistant-${Date.now()}`,
role: 'assistant',
content: '',
timestamp: new Date(),
stages: {},
finalAnswer: '',
mainStepStatus: { info: 'pending', sixstep: 'pending', final: 'pending' },
subStepStatus: { purpose: 'pending', actor: 'pending', behavior: 'pending', narrative: 'pending' },
};
}
interface RunOptions {
onUpdate: (message: SixStepMessage) => void;
onComplete?: () => void;
}
/**
* 模拟 6 步法推演的全过程:信息准备 → 6 步法(4 个阶段) → 最终结果。
*
* 通过分阶段写入 thinking 文本和卡片,模拟真实流式生成效果。
*/
export function runSixStepDeduction(message: SixStepMessage, options: RunOptions): SixStepMockController {
let cancelled = false;
const timers: number[] = [];
const wait = (ms: number) =>
new Promise<void>((resolve) => {
const id = window.setTimeout(() => {
resolve();
}, ms);
timers.push(id);
});
const setMain = (key: MainStepKey, status: StepStatus) => {
message.mainStepStatus = { ...(message.mainStepStatus ?? { info: 'pending', sixstep: 'pending', final: 'pending' }), [key]: status };
if (status === 'running') {
message.currentMainStep = key;
}
};
const setSub = (key: SubStepKey, status: StepStatus) => {
message.subStepStatus = {
...(message.subStepStatus ?? { purpose: 'pending', actor: 'pending', behavior: 'pending', narrative: 'pending' }),
[key]: status,
};
if (status === 'running') {
message.currentSubStep = key;
}
};
const emit = () => {
if (cancelled) return;
options.onUpdate({ ...message, stages: { ...message.stages } });
};
const typeInto = async (text: string, write: (chunk: string) => void) => {
const step = 4;
for (let i = 0; i <= text.length; i += step) {
if (cancelled) return;
write(text.slice(0, i));
emit();
await wait(TYPE_INTERVAL);
}
if (!cancelled) {
write(text);
emit();
}
};
const promise = (async () => {
setMain('info', 'running');
emit();
await wait(800);
if (cancelled) return;
setMain('info', 'done');
setMain('sixstep', 'running');
emit();
await wait(STAGE_GAP);
setSub('purpose', 'running');
message.stages = { ...message.stages, purpose: { thinking: '', cards: [], complete: false } };
emit();
await typeInto(PURPOSE_THINKING, (chunk) => {
message.stages = { ...message.stages, purpose: { ...(message.stages?.purpose as any), thinking: chunk } };
});
if (cancelled) return;
const purposes = buildPurposeCards();
for (const card of purposes) {
if (cancelled) return;
const existing = message.stages?.purpose?.cards ?? [];
message.stages = { ...message.stages, purpose: { ...(message.stages?.purpose as any), cards: [...existing, card] } };
emit();
await wait(280);
}
message.stages = { ...message.stages, purpose: { ...(message.stages?.purpose as any), complete: true } };
setSub('purpose', 'done');
emit();
await wait(STAGE_GAP);
setSub('actor', 'running');
message.stages = { ...message.stages, actor: { thinking: '', cards: [], complete: false } };
emit();
await typeInto(ACTOR_THINKING, (chunk) => {
message.stages = { ...message.stages, actor: { ...(message.stages?.actor as any), thinking: chunk } };
});
if (cancelled) return;
const actors = buildActorCards(purposes);
for (const card of actors) {
if (cancelled) return;
const existing = message.stages?.actor?.cards ?? [];
message.stages = { ...message.stages, actor: { ...(message.stages?.actor as any), cards: [...existing, card] } };
emit();
await wait(220);
}
message.stages = { ...message.stages, actor: { ...(message.stages?.actor as any), complete: true } };
setSub('actor', 'done');
emit();
await wait(STAGE_GAP);
setSub('behavior', 'running');
message.stages = { ...message.stages, behavior: { thinking: '', cards: [], complete: false } };
emit();
await typeInto(BEHAVIOR_THINKING, (chunk) => {
message.stages = { ...message.stages, behavior: { ...(message.stages?.behavior as any), thinking: chunk } };
});
if (cancelled) return;
const behaviors = buildBehaviorCards(actors);
for (const card of behaviors) {
if (cancelled) return;
const existing = message.stages?.behavior?.cards ?? [];
message.stages = { ...message.stages, behavior: { ...(message.stages?.behavior as any), cards: [...existing, card] } };
emit();
await wait(200);
}
message.stages = { ...message.stages, behavior: { ...(message.stages?.behavior as any), complete: true } };
setSub('behavior', 'done');
emit();
await wait(STAGE_GAP);
setSub('narrative', 'running');
message.stages = { ...message.stages, narrative: { thinking: '', cards: [], complete: false } };
emit();
await typeInto(NARRATIVE_THINKING, (chunk) => {
message.stages = { ...message.stages, narrative: { ...(message.stages?.narrative as any), thinking: chunk } };
});
if (cancelled) return;
const narratives = buildNarrativeCards(behaviors);
for (const card of narratives) {
if (cancelled) return;
const existing = message.stages?.narrative?.cards ?? [];
message.stages = { ...message.stages, narrative: { ...(message.stages?.narrative as any), cards: [...existing, card] } };
emit();
await wait(200);
}
message.stages = { ...message.stages, narrative: { ...(message.stages?.narrative as any), complete: true } };
setSub('narrative', 'done');
setMain('sixstep', 'done');
setMain('final', 'running');
emit();
await wait(STAGE_GAP);
if (cancelled) return;
await typeInto(buildFinalAnswer(), (chunk) => {
message.finalAnswer = chunk;
});
if (cancelled) return;
setMain('final', 'done');
message.currentMainStep = undefined;
message.currentSubStep = undefined;
emit();
options.onComplete?.();
})();
return {
message,
stop: () => {
cancelled = true;
timers.forEach((t) => window.clearTimeout(t));
},
promise,
};
}
+179
View File
@@ -0,0 +1,179 @@
@keyframes sixstep-ant-march {
to {
stroke-dashoffset: -24;
}
}
.sixstep-ant-line {
stroke-dasharray: 6 6;
animation: sixstep-ant-march 0.7s linear infinite;
}
@keyframes sixstep-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(56, 189, 248, 0.55);
}
50% {
box-shadow: 0 0 0 8px rgba(56, 189, 248, 0);
}
}
.sixstep-pulse {
animation: sixstep-pulse 1.6s ease-in-out infinite;
}
@keyframes sixstep-shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.sixstep-shimmer-text {
background-image: linear-gradient(
90deg,
rgba(148, 163, 184, 0.55) 0%,
rgba(248, 250, 252, 1) 45%,
rgba(148, 163, 184, 0.55) 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: sixstep-shimmer 2s linear infinite;
}
.sixstep-shimmer-text-light {
background-image: linear-gradient(
90deg,
rgba(220, 38, 38, 0.45) 0%,
rgba(190, 18, 60, 0.95) 45%,
rgba(220, 38, 38, 0.45) 100%
);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: sixstep-shimmer 2s linear infinite;
}
@keyframes sixstep-spin-soft {
to {
transform: rotate(360deg);
}
}
.sixstep-spin-soft {
animation: sixstep-spin-soft 1.4s linear infinite;
}
@keyframes sixstep-fade-up {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.sixstep-fade-up {
animation: sixstep-fade-up 0.32s ease-out both;
}
.sixstep-resize-handle {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: 6px;
cursor: col-resize;
user-select: none;
z-index: 10;
}
.sixstep-resize-handle::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2px;
height: 36px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.4);
transition: background 0.2s;
}
.sixstep-resize-handle:hover::after {
background: rgba(56, 189, 248, 0.8);
}
@keyframes sixstep-node-pop {
0% {
opacity: 0;
transform: scale(0.4);
filter: drop-shadow(0 0 0 rgba(56, 189, 248, 0));
}
60% {
opacity: 1;
transform: scale(1.18);
filter: drop-shadow(0 0 12px rgba(56, 189, 248, 0.8));
}
100% {
opacity: 1;
transform: scale(1);
filter: drop-shadow(0 0 0 rgba(56, 189, 248, 0));
}
}
.sixstep-node-pop {
transform-box: fill-box;
transform-origin: center;
animation: sixstep-node-pop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}
@keyframes sixstep-ring-burst {
0% {
opacity: 0.85;
r: 0;
stroke-width: 4;
}
100% {
opacity: 0;
r: 60;
stroke-width: 0.5;
}
}
.sixstep-ring-burst {
animation: sixstep-ring-burst 0.7s ease-out forwards;
fill: none;
}
.sixstep-canvas-grab {
cursor: grab;
}
.sixstep-canvas-grabbing {
cursor: grabbing;
}
.sixstep-toolbar-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 8px;
transition: background 0.2s, color 0.2s;
}
.sixstep-minimap-rect {
fill: rgba(56, 189, 248, 0.18);
stroke: rgba(56, 189, 248, 0.9);
stroke-width: 1;
}
+161
View File
@@ -0,0 +1,161 @@
export type MainStepKey = 'info' | 'sixstep' | 'final';
export type SubStepKey = 'purpose' | 'actor' | 'behavior' | 'narrative';
export type StepStatus = 'pending' | 'running' | 'done';
export interface PurposeCard {
id: string;
index: number;
title: string;
reason: string;
}
export interface ActorCard {
id: string;
index: number;
purposeId: string;
title: string;
content: string;
}
export interface BehaviorDriverCard {
id: string;
index: number;
actorId: string;
expectedBehavior: string;
driver: string;
}
export interface NarrativeCard {
id: string;
index: number;
driverId: string;
title: string;
reason: string;
}
export interface PurposeStageData {
thinking: string;
cards: PurposeCard[];
complete: boolean;
}
export interface ActorStageData {
thinking: string;
cards: ActorCard[];
complete: boolean;
}
export interface BehaviorStageData {
thinking: string;
cards: BehaviorDriverCard[];
complete: boolean;
}
export interface NarrativeStageData {
thinking: string;
cards: NarrativeCard[];
complete: boolean;
}
export interface InfoStageData {
content: string;
complete: boolean;
}
export interface SixStepStages {
info?: InfoStageData;
purpose?: PurposeStageData;
actor?: ActorStageData;
behavior?: BehaviorStageData;
narrative?: NarrativeStageData;
}
export type SelectableMcpKey = 'purpose' | 'actor' | 'behavior_factor' | 'narrative';
export interface ExpertCard {
id: string;
backendId?: string;
name: string;
detail: string;
avatarUrl?: string;
title?: string;
tags?: string[];
isCustom?: boolean;
}
export interface ExpertMatchData {
status: 'idle' | 'searching' | 'creating' | 'ready' | 'confirmed' | 'error';
thinking: string;
experts: ExpertCard[];
selectedExpertId?: string;
selectedMcps?: SelectableMcpKey[];
errorMessage?: string;
searchedAt?: number;
}
export type SixStepPhase =
| 'idle'
| 'intent-detecting'
| 'expert-match'
| 'expert-confirm'
| 'generate'
| 'paused'
| 'completed'
| 'clarifying'
| 'follow-up';
/** SSE error_log / error 事件归一后的错误记录,积累在 SixStepMessage.errors 中。 */
export interface StreamErrorItem {
errorId: string;
taskId?: string;
level: 'warning' | 'error';
source: 'orchestrator' | 'mcp' | 'skill' | 'llm' | 'network';
mcpKey?: string;
stepName?: string;
skillKey?: string;
skillName?: string;
message: string;
recoverable?: boolean;
createdAt: number;
}
/** 后端 step_end 的原始字段,用于 continue_analysis 时回传 last_steps */
export interface BackendStepResult {
key: string;
name?: string;
status: string;
thinking?: string;
structured_outputs?: Record<string, unknown>[];
markdown?: string;
references?: Record<string, unknown>[];
}
export interface SixStepMessage {
id: string;
role: 'user' | 'assistant';
content?: string;
timestamp: Date;
outputFormat?: 'structured' | 'markdown';
markdownContent?: string;
stages?: SixStepStages;
finalAnswer?: string;
mainStepStatus?: Record<MainStepKey, StepStatus>;
subStepStatus?: Record<SubStepKey, StepStatus>;
currentSubStep?: SubStepKey;
currentMainStep?: MainStepKey;
phase?: SixStepPhase;
expertMatch?: ExpertMatchData;
selectedExpert?: ExpertCard;
selectedMcps?: SelectableMcpKey[];
intent?: { name: string; confidence?: number; reason?: string };
clarifyQuestion?: string;
/** 后端原始步骤数据,paused 后继续时作为 last_steps 传回后端 */
rawSteps?: BackendStepResult[];
/** 本轮是从哪个步骤继续的(resume/rerun 事件携带),供 UI 显示"继续自…" */
resumedFrom?: string;
/** 实际用于分析的问题(revise_input_continue 后更新为新问题) */
effectiveUserInput?: string;
/** 本轮累积的错误/警告列表(error_log 与 error 事件均追加于此) */
errors?: StreamErrorItem[];
}
File diff suppressed because it is too large. Load diff
+578
View File
@@ -0,0 +1,578 @@
import React, { useState } from 'react';
import {
Search,
History,
Bot,
Sparkles,
Database,
Layers,
FileText,
ChevronRight,
ChevronLeft,
Cpu,
Target,
Zap,
CheckCircle,
} from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import { PageType } from '../../components/Sidebar';
import { ChatMessage, EmptyChatState, QAComposer, TypingIndicator } from './QACommon';
interface Agent {
id: string;
name: string;
category: string;
tags: string[];
description: string;
skills: string[];
dataSources: string[];
functions: string[];
outputFields: string[];
}
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
agentName?: string;
skills?: string[];
dataSources?: string[];
functions?: string[];
structuredOutput?: Record<string, any>;
}
/**
* 智能体问答组件
* 基于特定智能体的专业能力进行问答,支持选择不同领域的智能体
* @param onNavigate - 页面导航函数
*/
const AgentQA: React.FC<{ onNavigate: (page: PageType) => void }> = ({ onNavigate }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
// 模拟智能体列表
const agents: Agent[] = [
{
id: 'audience-agent',
name: 'AudienceAgent',
category: '受众分析',
tags: ['受众分层', '画像抽取', '行为预测'],
description: '专业的受众分析智能体,能够识别目标受众特征并生成画像',
skills: ['受众分层', '核心受众识别', '画像抽取', '行为预测'],
dataSources: ['知识库', 'Y系统', 'A系统'],
functions: ['信息抽取器', '标签聚类器', '结构化输出器'],
outputFields: ['core_audience', 'key_traits', 'barriers', 'opportunities'],
},
{
id: 'brand-agent',
name: 'BrandAgent',
category: '品牌分析',
tags: ['品牌定位', '传播策略', '调性分析'],
description: '专注品牌建设与传播策略的智能体',
skills: ['品牌定位分析', '传播路径规划', '品牌调性设计'],
dataSources: ['知识库', '行业数据库'],
functions: ['品牌诊断器', '策略生成器'],
outputFields: ['brand_position', 'communication_path', 'tone_guide'],
},
{
id: 'content-agent',
name: 'ContentAgent',
category: '内容创作',
tags: ['文案撰写', '创意生成', '多形式内容'],
description: '智能内容创作助手,支持多种形式的内容生成',
skills: ['文案撰写', '创意生成', '多形式内容', 'A/B测试文案'],
dataSources: ['知识库', '优秀案例库'],
functions: ['文案生成器', '风格适配器', '质量评估器'],
outputFields: ['headlines', 'body_copy', 'call_to_action', 'variants'],
},
{
id: 'channel-agent',
name: 'ChannelAgent',
category: '渠道规划',
tags: ['渠道选择', '投放策略', '效果评估'],
description: '专业的渠道分析与规划智能体',
skills: ['渠道选择', '投放策略规划', '效果评估', '预算分配'],
dataSources: ['Y系统', 'A系统', '行业数据'],
functions: ['渠道分析器', '投放优化器', 'ROI预测器'],
outputFields: ['recommended_channels', 'budget_allocation', 'expected_roi'],
},
];
// 状态管理
// 当前选中的智能体
const [selectedAgent, setSelectedAgent] = useState<Agent>(agents[0]);
// 智能体搜索关键词
const [searchQuery, setSearchQuery] = useState('');
// 聊天消息列表
const [messages, setMessages] = useState<Message[]>([]);
// 输入框内容
const [inputValue, setInputValue] = useState('');
// 加载状态
const [isLoading, setIsLoading] = useState(false);
// 左侧侧边栏折叠状态
const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false);
// 过滤智能体
const filteredAgents = agents.filter(agent =>
agent.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
agent.category.toLowerCase().includes(searchQuery.toLowerCase()) ||
agent.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
);
/**
* 处理发送消息
* 发送用户消息并模拟智能体响应
*/
const handleSend = () => {
if (!inputValue.trim()) return;
setIsLoading(true);
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: inputValue,
timestamp: new Date(),
};
setMessages([...messages, userMessage]);
setInputValue('');
// 模拟AI响应
setTimeout(() => {
const response: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: `基于${selectedAgent.name}的专业能力,我来为您分析:\n\n根据您的问题,我识别到核心受众特征如下:\n\n**核心受众识别**:\n- 主要年龄段:25-35岁\n- 职业特征:都市白领\n- 消费能力:中高端\n- 关注点:品质生活、个人成长\n\n**关键特质**:\n1. 追求个性化表达\n2. 注重社交认同\n3. 愿意为品质付费\n\n**可能存在的阻力**:\n- 价格敏感度较高\n- 对品牌忠诚度有限\n\n以上分析是否满足您的需求?如需调整维度,请继续补充信息。`,
timestamp: new Date(),
agentName: selectedAgent.name,
skills: selectedAgent.skills,
dataSources: selectedAgent.dataSources,
functions: selectedAgent.functions,
structuredOutput: {
core_audience: '25-35岁都市白领',
key_traits: ['追求个性化', '注重社交认同', '品质导向'],
barriers: ['价格敏感', '品牌忠诚度有限'],
opportunities: ['社交媒体影响大', '口碑传播效果好'],
},
};
setMessages(prev => [...prev, response]);
setIsLoading(false);
}, 1500);
};
/**
* 处理停止操作
* 停止当前正在进行的智能体响应
*/
const handleStop = () => {
setIsLoading(false);
};
/**
* 处理键盘事件
* 按Enter键发送消息(不按Shift)
*/
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
/**
* 保存问答记录
*/
const handleSaveRecord = () => {
alert('问答记录已保存');
};
/**
* 收藏当前对话
*/
const handleCollect = () => {
alert('已添加到收藏');
};
return (
<div className={`h-[calc(100vh-120px)] flex flex-col theme-bg rounded-xl ${isDark ? 'bg-slate-800/50' : 'bg-white'} border ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
{/* 顶部工具栏 */}
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className="flex items-center gap-4">
<h1 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
智能体问答
</h1>
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg ${isDark ? 'bg-slate-700/50' : 'bg-red-50'}`}>
<Bot className={`w-4 h-4 ${isDark ? 'text-blue-400' : 'text-red-500'}`} />
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
当前智能体:
</span>
<span className={`text-sm font-medium ${isDark ? 'text-blue-400' : 'text-red-600'}`}>
{selectedAgent.name}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onNavigate('qa-records')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
isDark
? 'bg-slate-700/50 text-slate-300 hover:bg-slate-600/50'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<History className="w-4 h-4" />
历史记录
</button>
</div>
</div>
{/* 主体内容区 */}
<div className="flex-1 flex overflow-hidden">
{/* 左侧智能体区 */}
<div className={`${isLeftSidebarCollapsed ? 'w-14 p-2' : 'w-72'} border-r flex-shrink-0 overflow-y-auto transition-all duration-300 ${isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/50'}`}>
<div className={`flex items-center ${isLeftSidebarCollapsed ? 'justify-center' : 'justify-between'} p-2 ${isLeftSidebarCollapsed ? '' : 'px-4 pt-4 pb-0'}`}>
{!isLeftSidebarCollapsed && (
<span className={`text-xs font-medium uppercase tracking-wider ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
智能体配置
</span>
)}
<button
type="button"
onClick={() => setIsLeftSidebarCollapsed(!isLeftSidebarCollapsed)}
className={`p-2 rounded-lg transition-all ${
isDark
? 'text-slate-400 hover:text-white hover:bg-slate-700/50'
: 'text-gray-500 hover:text-red-600 hover:bg-red-50'
}`}
title={isLeftSidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}
>
{isLeftSidebarCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
</div>
{!isLeftSidebarCollapsed && (<>
{/* 智能体搜索 */}
<div className="p-4">
<div className={`relative ${isDark ? 'bg-slate-700/50' : 'bg-white'} rounded-lg`}>
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${isDark ? 'text-slate-400' : 'text-gray-400'}`} />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索智能体..."
className={`w-full pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none ${
isDark
? 'bg-transparent text-white placeholder-slate-500'
: 'bg-transparent text-gray-800 placeholder-gray-400'
}`}
/>
</div>
</div>
{/* 智能体列表 */}
<div className="px-4 pb-4">
<h3 className={`text-xs font-medium uppercase tracking-wider mb-3 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
智能体列表
</h3>
<div className="space-y-2">
{filteredAgents.map((agent) => (
<button
key={agent.id}
onClick={() => setSelectedAgent(agent)}
className={`w-full text-left p-3 rounded-lg transition-all ${
selectedAgent.id === agent.id
? isDark
? 'bg-blue-600/20 border border-blue-500/50'
: 'bg-red-50 border border-red-200'
: isDark
? 'bg-slate-700/30 border border-transparent hover:bg-slate-700/50'
: 'bg-white border border-gray-200 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
selectedAgent.id === agent.id
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'bg-slate-600 text-slate-300'
: 'bg-gray-100 text-gray-600'
}`}>
<Bot className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-gray-900'}`}>
{agent.name}
</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{agent.category}
</div>
</div>
<ChevronRight className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-gray-400'}`} />
</div>
</button>
))}
</div>
</div>
{/* 当前智能体能力摘要 */}
<div className={`p-4 border-t ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<h3 className={`text-xs font-medium uppercase tracking-wider mb-3 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
能力摘要
</h3>
{/* 核心职责 */}
<div className="mb-4">
<div className={`flex items-center gap-2 mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Target className="w-4 h-4" />
<span className="text-xs">核心职责</span>
</div>
<p className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
{selectedAgent.description}
</p>
</div>
{/* 已启用技能 */}
<div className="mb-4">
<div className={`flex items-center gap-2 mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Sparkles className="w-4 h-4" />
<span className="text-xs">已启用技能</span>
</div>
<div className="flex flex-wrap gap-1">
{selectedAgent.skills.map((skill, idx) => (
<span
key={idx}
className={`px-2 py-0.5 rounded text-xs ${
isDark ? 'bg-blue-600/30 text-blue-400' : 'bg-blue-100 text-blue-600'
}`}
>
{skill}
</span>
))}
</div>
</div>
{/* 可访问数据源 */}
<div className="mb-4">
<div className={`flex items-center gap-2 mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Database className="w-4 h-4" />
<span className="text-xs">可访问数据源</span>
</div>
<div className="flex flex-wrap gap-1">
{selectedAgent.dataSources.map((source, idx) => (
<span
key={idx}
className={`px-2 py-0.5 rounded text-xs ${
isDark ? 'bg-green-600/30 text-green-400' : 'bg-green-100 text-green-600'
}`}
>
{source}
</span>
))}
</div>
</div>
{/* 可调用功能 */}
<div>
<div className={`flex items-center gap-2 mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Layers className="w-4 h-4" />
<span className="text-xs">可调用功能</span>
</div>
<div className="flex flex-wrap gap-1">
{selectedAgent.functions.map((func, idx) => (
<span
key={idx}
className={`px-2 py-0.5 rounded text-xs ${
isDark ? 'bg-purple-600/30 text-purple-400' : 'bg-purple-100 text-purple-600'
}`}
>
{func}
</span>
))}
</div>
</div>
</div>
</>)}
</div>
{/* 中间对话区 */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 ? (
<EmptyChatState
icon={<Bot className="w-11 h-11 opacity-60" />}
title={`开始与${selectedAgent.name}对话`}
description="基于智能体专业能力获取精准回答"
/>
) : (
messages.map((msg) => (
<ChatMessage
key={msg.id}
message={msg}
assistantLabel={msg.agentName || selectedAgent.name}
renderAssistantExtra={(assistantMessage) => (
<div className={`ml-12 p-3 rounded-xl ${isDark ? 'bg-slate-800/50' : 'bg-gray-50'}`}>
<div className="mb-3">
<h4 className={`text-xs font-medium mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>本轮启用技能</h4>
<div className="flex flex-wrap gap-1">
{assistantMessage.skills?.map((skill, idx) => (
<span key={idx} className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-blue-600/30 text-blue-400' : 'bg-blue-100 text-blue-600'}`}>
{skill}
</span>
))}
</div>
</div>
<div className="mb-3">
<h4 className={`text-xs font-medium mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>本轮访问数据源</h4>
<div className="flex flex-wrap gap-1">
{assistantMessage.dataSources?.map((source, idx) => (
<span key={idx} className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-green-600/30 text-green-400' : 'bg-green-100 text-green-600'}`}>
{source}
</span>
))}
</div>
</div>
<div className="mb-3">
<h4 className={`text-xs font-medium mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>本轮调用功能</h4>
<div className="flex flex-wrap gap-1">
{assistantMessage.functions?.map((func, idx) => (
<span key={idx} className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-purple-600/30 text-purple-400' : 'bg-purple-100 text-purple-600'}`}>
{func}
</span>
))}
</div>
</div>
{assistantMessage.structuredOutput && (
<div>
<h4 className={`text-xs font-medium mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>结构化输出</h4>
<div className={`p-3 rounded-lg ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
{Object.entries(assistantMessage.structuredOutput).map(([key, value]) => (
<div key={key} className="flex items-start gap-2 mb-1 last:mb-0">
<span className={`text-xs font-mono ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>{key}:</span>
<span className={`text-xs ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
{Array.isArray(value) ? value.join(', ') : String(value)}
</span>
</div>
))}
</div>
</div>
)}
</div>
)}
/>
))
)}
{isLoading && <TypingIndicator label={`${selectedAgent.name} 正在调用能力生成回复`} assistantLabel={selectedAgent.name} />}
</div>
{/* 底部输入区 */}
<QAComposer
value={inputValue}
onChange={setInputValue}
onKeyDown={handleKeyDown}
onSubmit={handleSend}
onStop={handleStop}
onSave={handleSaveRecord}
onCollect={handleCollect}
isLoading={isLoading}
placeholder="输入问题或任务要求..."
showUpload={false}
/>
</div>
{/* 右侧执行详情区 */}
<div className={`w-72 border-l flex-shrink-0 overflow-y-auto p-4 ${
isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/50'
}`}>
<h3 className={`text-sm font-medium mb-4 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
执行详情
</h3>
{/* 本轮启用技能 */}
<div className="mb-6">
<div className={`flex items-center gap-2 mb-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Sparkles className="w-4 h-4" />
<span className="text-xs font-medium">本轮启用技能</span>
</div>
<div className={`p-3 rounded-xl ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<div className="space-y-2">
{selectedAgent.skills.map((skill, idx) => (
<div key={idx} className="flex items-center gap-2">
<CheckCircle className={`w-3 h-3 ${isDark ? 'text-blue-400' : 'text-green-500'}`} />
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>{skill}</span>
</div>
))}
</div>
</div>
</div>
{/* 本轮访问数据源 */}
<div className="mb-6">
<div className={`flex items-center gap-2 mb-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Database className="w-4 h-4" />
<span className="text-xs font-medium">本轮访问数据源</span>
</div>
<div className={`p-3 rounded-xl ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<div className="space-y-2">
{selectedAgent.dataSources.map((source, idx) => (
<div key={idx} className="flex items-center gap-2">
<Database className={`w-3 h-3 ${isDark ? 'text-green-400' : 'text-green-500'}`} />
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>{source}</span>
</div>
))}
</div>
</div>
</div>
{/* 本轮调用功能 */}
<div className="mb-6">
<div className={`flex items-center gap-2 mb-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Layers className="w-4 h-4" />
<span className="text-xs font-medium">本轮调用功能</span>
</div>
<div className={`p-3 rounded-xl ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<div className="space-y-2">
{selectedAgent.functions.map((func, idx) => (
<div key={idx} className="flex items-center gap-2">
<Cpu className={`w-3 h-3 ${isDark ? 'text-purple-400' : 'text-purple-500'}`} />
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>{func}</span>
</div>
))}
</div>
</div>
</div>
{/* 结构化输出字段 */}
<div>
<div className={`flex items-center gap-2 mb-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<FileText className="w-4 h-4" />
<span className="text-xs font-medium">输出字段</span>
</div>
<div className={`p-3 rounded-xl ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<div className="space-y-2">
{selectedAgent.outputFields.map((field, idx) => (
<div key={idx} className="flex items-center gap-2">
<Zap className={`w-3 h-3 ${isDark ? 'text-yellow-400' : 'text-yellow-500'}`} />
<span className={`text-xs font-mono ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>{field}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default AgentQA;
+189
View File
@@ -0,0 +1,189 @@
import React, { useState } from 'react';
import { CheckCircle, ChevronLeft, ChevronRight, History, MessageCircle, Users } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import { PageType } from '../../components/Sidebar';
import { ChatMessage, EmptyChatState, QAComposer, TypingIndicator } from './QACommon';
interface CollaborativeQAProps {
onNavigate: (page: PageType) => void;
}
interface CollaborativeMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
agentName?: string;
}
const collaborativeAgents = [
{ id: 'goal-agent', name: 'GoalAgent', category: '目标规划', color: 'blue' },
{ id: 'audience-agent', name: 'AudienceAgent', category: '受众分析', color: 'green' },
{ id: 'risk-agent', name: 'RiskAgent', category: '风险识别', color: 'purple' },
];
/**
* 多智能体协同问答组件
* 多个智能体从不同视角联合分析问题,生成综合建议
* @param onNavigate - 页面导航函数
*/
const CollaborativeQA: React.FC<CollaborativeQAProps> = ({ onNavigate }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [messages, setMessages] = useState<CollaborativeMessage[]>([]);
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [selectedAgents, setSelectedAgents] = useState(collaborativeAgents.map((agent) => agent.id));
const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false);
const activeAgents = collaborativeAgents.filter((agent) => selectedAgents.includes(agent.id));
const handleSend = () => {
if (!inputValue.trim()) return;
const userMessage: CollaborativeMessage = {
id: `user-${Date.now()}`,
role: 'user',
content: inputValue,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInputValue('');
setIsLoading(true);
setTimeout(() => {
const agentNames = activeAgents.map((agent) => agent.name).join('、');
const assistantMessage: CollaborativeMessage = {
id: `assistant-${Date.now()}`,
role: 'assistant',
content: `已组织 ${agentNames || '默认协同组'} 完成一轮联合研判:\n\n1. GoalAgent 建议先明确任务目标与阶段性 KPI,避免讨论发散。\n2. AudienceAgent 认为应优先锁定核心受众画像,并补充行为触点数据。\n3. RiskAgent 提醒需要同步评估内容合规、舆情扩散和资源约束。\n\n综合建议:先形成“目标-受众-风险”三列表,再进入方案细化与执行拆解。`,
timestamp: new Date(),
agentName: '协同编排助手',
};
setMessages((prev) => [...prev, assistantMessage]);
setIsLoading(false);
}, 1800);
};
const handleStop = () => {
setIsLoading(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const toggleAgent = (agentId: string) => {
setSelectedAgents((prev) =>
prev.includes(agentId) ? prev.filter((id) => id !== agentId) : [...prev, agentId]
);
};
return (
<div className={`h-[calc(100vh-120px)] flex flex-col theme-bg rounded-xl ${isDark ? 'bg-slate-800/50' : 'bg-white'} border ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className="flex items-center gap-4">
<h1 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>多智能体协同问答</h1>
<span className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>多角色联合研判、共识汇总与风险补充</span>
</div>
<button
onClick={() => onNavigate('qa-records')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
isDark ? 'bg-slate-700/50 text-slate-300 hover:bg-slate-600/50' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<History className="w-4 h-4" />
历史记录
</button>
</div>
<div className="flex-1 flex overflow-hidden">
<aside className={`${isLeftSidebarCollapsed ? 'w-14 p-2' : 'w-72 p-4'} border-r flex-shrink-0 overflow-y-auto transition-all duration-300 ${isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/50'}`}>
<div className={`flex items-center ${isLeftSidebarCollapsed ? 'justify-center' : 'justify-between'} mb-4`}>
{!isLeftSidebarCollapsed && (
<h3 className={`text-sm font-medium ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>参与智能体</h3>
)}
<button
type="button"
onClick={() => setIsLeftSidebarCollapsed(!isLeftSidebarCollapsed)}
className={`p-2 rounded-lg transition-all ${
isDark
? 'text-slate-400 hover:text-white hover:bg-slate-700/50'
: 'text-gray-500 hover:text-red-600 hover:bg-red-50'
}`}
title={isLeftSidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}
>
{isLeftSidebarCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
</div>
{!isLeftSidebarCollapsed && (<>
<div className="space-y-2">
{collaborativeAgents.map((agent) => {
const active = selectedAgents.includes(agent.id);
return (
<button
key={agent.id}
onClick={() => toggleAgent(agent.id)}
className={`w-full p-3 rounded-xl text-left border transition-all ${
active
? isDark ? 'bg-blue-600/20 border-blue-500/50' : 'bg-red-50 border-red-200'
: isDark ? 'bg-slate-700/30 border-transparent hover:bg-slate-700/50' : 'bg-white border-gray-200 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-2xl flex items-center justify-center ${active ? 'bg-gradient-to-br from-red-500 to-orange-400 text-white' : isDark ? 'bg-slate-600 text-slate-300' : 'bg-gray-100 text-gray-600'}`}>
<Users className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-gray-900'}`}>{agent.name}</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{agent.category}</div>
</div>
{active && <CheckCircle className={`w-4 h-4 ${isDark ? 'text-blue-400' : 'text-red-500'}`} />}
</div>
</button>
);
})}
</div>
</>)}
</aside>
<section className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 ? (
<EmptyChatState
icon={<MessageCircle className="w-11 h-11 opacity-60" />}
title="发起一次协同会商"
description="输入任务背景,多个智能体会从不同视角生成联合建议"
/>
) : (
messages.map((message) => (
<ChatMessage key={message.id} message={message} assistantLabel={message.agentName || '协同编排助手'} />
))
)}
{isLoading && <TypingIndicator label="多智能体正在协同分析并汇总观点" assistantLabel="协同编排助手" />}
</div>
<QAComposer
value={inputValue}
onChange={setInputValue}
onKeyDown={handleKeyDown}
onSubmit={handleSend}
onStop={handleStop}
onSave={() => alert('问答记录已保存')}
onCollect={() => alert('已添加到收藏')}
isLoading={isLoading}
placeholder="输入协同研判主题或任务背景..."
showUpload={false}
/>
</section>
</div>
</div>
);
};
export default CollaborativeQA;
+29
View File
@@ -0,0 +1,29 @@
import { Outlet, useNavigate } from 'react-router-dom';
import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
const zncmUrl = import.meta.env.VITE_AIPI_CONSUMER_URL || '';
import { requestCrudService } from '@/service/request-axios-service';
const service = requestCrudService;
const userInfo = localStorage.getItem('userInfo');
const deerFlowUrl = 'https://ch1.b.uat.4.cn/mopeweb/#/login/';
interface TaskPgFromProps {
gopath: string
}
const GAFromQA: React.FC<TaskPgFromProps> = ({gopath}) => {
const [deerFlow, setDeerFlow] = useState('https://ch1.b.uat.4.cn/mopeweb/#/login/'); // http://localhost:5174/#/
useEffect(() => {
if(userInfo) {
const userName = JSON.parse(userInfo).userName;
const gaUrl = deerFlowUrl + userName + `?goPath=${gopath}`
console.log('gaUrl', gaUrl)
setDeerFlow(gaUrl);
}
}, [gopath]);
return (
<div className="h-full flex flex-col">
<iframe className="h-[calc(100vh-92px)]" src={deerFlow}></iframe>
</div>
);
}
export default GAFromQA
+29
View File
@@ -0,0 +1,29 @@
import { Outlet, useNavigate } from 'react-router-dom';
import React, { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
const zncmUrl = import.meta.env.VITE_AIPI_CONSUMER_URL || '';
import { requestCrudService } from '@/service/request-axios-service';
const service = requestCrudService;
const userInfo = localStorage.getItem('userInfo');
const deerFlowUrl = 'https://ch1.b.uat.4.cn/xwtweb/#/login/';
interface TaskPgFromProps {
gopath: string
}
const GAFromNewQA: React.FC<TaskPgFromProps> = ({gopath}) => {
const [deerFlow, setDeerFlow] = useState('https://ch1.b.uat.4.cn/xwtweb/#/login/'); // http://localhost:5174/#/
useEffect(() => {
if(userInfo) {
const userName = JSON.parse(userInfo).userName;
const gaUrl = deerFlowUrl + userName + `?pathTo=${gopath}`
console.log('gaUrl', gaUrl)
setDeerFlow(gaUrl);
}
}, [gopath]);
return (
<div className="h-full flex flex-col">
<iframe className="h-[calc(100vh-92px)]" src={deerFlow}></iframe>
</div>
);
}
export default GAFromNewQA
File diff suppressed because it is too large. Load diff
+450
View File
@@ -0,0 +1,450 @@
import type { QASingleRagTrace, QAThinkingTrace } from './QACommon';
import type { ComparePane, KnowledgeBaseChunk, Message, QAMode, SourceItem } from './GeneralQA.types';
/**
* 解析单轮 RAG 接口的 `<think>...</think>` 流式文本。
*
* 后端会把“思考过程”和“最终回答”混在同一个 answer 字段里返回,
* UI 需要分别展示在 `SingleRagAnswer` 的思考区和答案区。这个函数只做纯文本拆分,
* 不依赖 React 状态,因此既可用于模拟数据,也可作为对比模式里的兜底空 trace。
*/
export const parseSingleRagStream = (chunks: { answer: string }[]): QASingleRagTrace => {
let thinkingMarkdown = '';
let answerMarkdown = '';
let inThink = false;
let thinkingComplete = false;
const appendToken = (token: string) => {
let remaining = token;
while (remaining) {
if (inThink) {
const endIdx = remaining.indexOf('</think>');
if (endIdx === -1) {
thinkingMarkdown += remaining;
break;
}
thinkingMarkdown += remaining.slice(0, endIdx);
inThink = false;
thinkingComplete = true;
remaining = remaining.slice(endIdx + '</think>'.length);
} else {
const startIdx = remaining.indexOf('<think>');
if (startIdx === -1) {
answerMarkdown += remaining;
break;
}
answerMarkdown += remaining.slice(0, startIdx);
inThink = true;
remaining = remaining.slice(startIdx + '<think>'.length);
}
}
};
chunks.forEach((chunk) => appendToken(chunk.answer));
return {
title: '单轮 RAG 思考过程',
thinkingMarkdown,
answerMarkdown,
thinkingComplete,
};
};
/**
* 构造单轮 RAG 的演示 trace。
*
* 首屏默认需要一条完整的示例问答,帮助用户理解“思考过程 + 答案 + 引用”如何展示。
* 这里保留原组件中的演示内容,避免拆分后默认展示行为发生变化。
*/
export const createSingleRagTrace = (): QASingleRagTrace => parseSingleRagStream([
{ answer: '<think>' },
{ answer: '需要回答用户关于 **RAG 工作原理** 的问题。\n\n' },
{ answer: '先根据知识库召回内容组织核心流程:\n\n' },
{ answer: '- 检索阶段:查找相关知识片段\n' },
{ answer: '- 增强阶段:将证据注入提示词\n' },
{ answer: '- 生成阶段:模型基于上下文生成答案\n\n' },
{ answer: '回答需要保持结构清晰,并说明它为什么能降低幻觉。' },
{ answer: '</think>' },
{ answer: 'RAG(Retrieval-Augmented Generation,检索增强生成)是一种把 **知识库检索** 和 **大模型生成** 结合起来的问答方式。\n\n' },
{ answer: '### 工作流程\n\n' },
{ answer: '1. **问题理解**:系统接收用户问题并生成检索查询。\n' },
{ answer: '2. **知识检索**:从知识库中召回相关文档片段。\n' },
{ answer: '3. **上下文增强**:把检索到的内容和用户问题一起放入 Prompt。\n' },
{ answer: '4. **模型生成**:大模型基于增强后的上下文生成回答。\n\n' },
{ answer: '这种方式可以让回答更贴近已有资料,同时提升可追溯性。' },
]);
/**
* 构造多轮/深度检索的演示思考链。
*
* 真实多轮请求会通过 SSE 持续补齐 rounds;深度检索当前仍走本地模拟,
* 因此这个函数保留不同模式下的轮次差异,用于未接入真实接口时的展示。
*/
export const createThinkingTrace = (mode: QAMode): QAThinkingTrace => {
// 初始化轮次数组,第一轮总是基础检索
const rounds = [
{
id: 'round-1', // 第一轮唯一标识
title: '理解问题并拆解检索方向', // 本轮主题:理解问题并确定检索方向
thoughtMarkdown: '用户询问 **RAG 技术工作原理**。需要先明确核心链路:\n\n- 问题理解与向量化\n- 知识库召回\n- 上下文增强\n- 大模型生成\n\n第一轮优先检索基础定义和主流程。', // 模型思考过程,展示分析步骤
query: 'RAG 检索增强生成 工作原理 基础流程', // 发送给检索系统的查询词
queryShow: true,
retrievals: [ // 第一轮检索返回的资料列表
{
id: 'r1-1',
title: 'RAG技术白皮书',
content: 'RAG 将外部知识检索结果作为上下文注入生成模型,能够降低幻觉并提升答案的时效性和可追溯性。',
source: '知识库 / RAG技术白皮书.pdf',
},
{
id: 'r1-2',
title: 'LLM应用实践:检索增强生成',
content: '典型 RAG 流程包括查询改写、向量检索、片段重排、Prompt 组装和答案生成。',
source: '知识库 / LLM应用实践.docx',
},
],
},
];
// 多轮模式和深度检索模式需要第二轮检索,补充更多细节
if (mode !== 'single-round') {
// 添加第二轮检索:补充多轮检索与重排机制的差异
rounds.push({
id: 'round-2', // 第二轮的唯一标识
title: '补充多轮检索与重排机制', // 本轮检索的主题
thoughtMarkdown: '基础链路已经明确,继续检索 **多轮 RAG** 和 **深度检索** 的差异:\n\n1. 是否需要 Query 改写\n2. 是否需要对子问题分别召回\n3. 是否需要结果重排与证据融合',
query: '多轮 RAG Query 改写 子问题检索 结果重排', // 本轮发送给检索系统的查询词
// 本轮检索返回的资料列表
queryShow: true,
retrievals: [
{
id: 'r2-1',
title: '高级RAG技术指南',
content: '复杂问题通常需要分解为多个子问题,并对每个子问题分别检索,再通过重排模型筛选高相关证据。',
source: '资料库 / 高级RAG技术指南.md',
},
{
id: 'r2-2',
title: 'RAG召回与重排最佳实践',
content: '重排阶段可使用 Cross Encoder 或 LLM 评分,按相关性、覆盖度和可信度筛选最终上下文。',
source: '行业资料 / RAG最佳实践.html',
},
{
id: 'r2-3',
title: '检索增强系统评测报告',
content: '多阶段检索可提升复杂问题的召回率,但会增加延迟和计算成本,需要在效果与响应速度之间平衡。',
source: '评测库 / 检索增强系统评测报告.xlsx',
},
],
});
}
// 深度检索模式需要第三轮,综合所有证据并形成最终回答结构
if (mode === 'deep-search') {
rounds.push({
id: 'round-3', // 第三轮的唯一标识
title: '综合证据并形成回答结构', // 本轮的主题:整合前面检索到的所有证据,组织成完整的回答
thoughtMarkdown: '当前证据已覆盖定义、流程和优化机制。最后需要组织成清晰结构:\n\n- 一句话定义\n- 分阶段流程\n- 技术价值\n- 适用场景与注意事项',
query: 'RAG 技术价值 幻觉 时效性 可追溯性 应用场景', // 本轮查询:关于 RAG 价值和应用场景的信息
queryShow: true,
retrievals: [
{
id: 'r3-1',
title: '企业知识问答系统建设方案',
content: 'RAG 在企业知识问答中常用于连接内部文档、制度库和业务数据库,使回答具备来源依据。',
source: '方案库 / 企业知识问答系统建设方案.pptx',
},
{
id: 'r3-2',
title: '生成式AI落地风险控制',
content: 'RAG 不能完全消除幻觉,仍需要上下文质量控制、引用展示、权限过滤和答案审核机制。',
source: '风控库 / 生成式AI落地风险控制.pdf',
},
],
});
}
return {
title: mode === 'single-round' ? '单轮检索思考过程' : mode === 'multi-round' ? '多轮检索思考过程' : '深度检索思考过程',
rounds,
finalAnswerMarkdown: 'RAG(Retrieval-Augmented Generation,检索增强生成)是一种把 **外部知识检索** 和 **大模型生成** 结合起来的技术架构。\n\n### 核心流程\n\n1. **理解问题**:系统解析用户问题,并可能进行 Query 改写或问题拆解。\n2. **检索证据**:从知识库、文档库或业务数据源中召回相关片段。\n3. **筛选重排**:对召回片段按相关性和可信度排序,保留高质量上下文。\n4. **增强生成**:将问题和检索证据一起发送给大模型,生成回答。\n5. **引用溯源**:展示答案依据,便于用户核验。\n\n### 主要价值\n\n- 降低模型幻觉\n- 引入最新或私有知识\n- 提升回答可解释性和可追溯性\n- 适合企业知识问答、政策解读、资料分析等场景',
};
};
/**
* 构造流式多轮检索的初始空 trace。
*
* 请求刚建立时还没有真正的工具调用结果,先渲染一个“正在准备检索”的轮次,
* 可以让用户立即看到助手进入工作状态,后续 SSE 事件会继续更新这个 trace。
*/
export const createMultiRoundStreamTrace = (): QAThinkingTrace => ({
title: '多轮检索思考过程',
rounds: [{
id: `round-${Date.now()}`,
title: '正在准备检索',
thoughtMarkdown: '',
query: '',
queryShow: true,
queryLoading: true,
retrievals: [],
}],
thinkingComplete: false,
finalAnswerMarkdown: '',
});
/**
* 为对比模式创建左右面板的初始状态。
*
* 对比模式会并发运行两个不同的问答模式;每个面板需要独立维护 loading、trace 和 sources。
* 该工厂函数把不同模式的初始 trace 统一封装,避免页面组件重复判断。
*/
export const createComparePane = (mode: QAMode): ComparePane => ({
mode,
loading: true,
singleRagTrace: (mode === 'single-round' || mode === 'single-chat')
? {
title: '单轮 RAG 思考过程',
thinkingMarkdown: '',
answerMarkdown: '',
thinkingComplete: false,
}
: undefined,
thinkingTrace: (mode === 'single-round' || mode === 'single-chat') ? undefined : createMultiRoundStreamTrace(),
sources: [],
});
/**
* 首屏演示消息。
*
* 页面首次进入时,如果没有用户消息,会展示两轮预置问答,说明引用、思考过程和追问的展示形态。
* 拆分时完整保留原始 mock 数据,确保首屏体验不变。
*/
export const createMockSingleRagMessages = (): Message[] => ([
{
id: 'mock-user-1',
role: 'user',
content: 'RAG 在企业知识问答里具体怎么落地?',
timestamp: new Date(),
},
{
id: 'mock-assistant-1',
role: 'assistant',
content: '',
timestamp: new Date(),
singleRagTrace: {
title: '单轮 RAG 思考过程',
thinkingMarkdown: '先明确用户要的是“可落地方案”,所以需要回答:\n\n1. 数据准备与切片策略\n2. 检索与重排链路\n3. 答案引用与可追溯性\n\n优先给出一套可直接上线的最小闭环。',
answerMarkdown: '可以按“**数据层 → 检索层 → 生成层 → 评估层**”四步落地:\n\n1. **数据层**:把制度、FAQ、SOP 文档切片并向量化,按业务域建索引。[[1]]\n2. **检索层**:先向量召回,再用重排模型做精排,提升命中质量。[[2]]\n3. **生成层**:将 TopK 证据注入 Prompt,要求回答必须带引用编号。[[3]]\n4. **评估层**:持续监控命中率、引用准确率和用户满意度。\n\n如果你要,我可以下一步给你一份“可直接给研发执行”的字段级配置清单。',
thinkingComplete: true,
},
sources: [
{
title: '企业知识问答平台建设指南',
type: '知识库',
snippet: '建议按业务域拆分索引,并保持文档版本可追溯。',
source: '内部架构组',
time: '2026-03-18',
url: 'https://example.com/rag-enterprise-guide',
content: '企业落地 RAG 时,建议先从高频问答域切入,采用“文档切片 + 向量索引 + 重排”三层方案,确保上线初期可控。',
},
{
title: 'RAG 重排实践报告',
type: '技术报告',
snippet: '仅向量召回会引入噪声,重排可显著提升答案相关性。',
source: 'NLP 团队',
time: '2026-01-09',
url: 'https://example.com/rag-rerank-report',
content: '在 20 万条文档规模下,向量 Top50 + CrossEncoder 重排到 Top6,可使人工评测正确率提升约 12%。',
},
{
title: '可追溯生成规范',
type: '规范',
snippet: '回答输出应绑定证据编号,便于用户核验。',
source: 'AI 治理委员会',
time: '2025-12-22',
url: 'https://example.com/traceable-generation-spec',
content: '推荐在答案中输出 [[n]] 形式的引用标识,并支持点击查看证据明细、来源与时间信息。',
},
],
},
{
id: 'mock-user-2',
role: 'user',
content: '那单轮和多轮检索的差异是什么?',
timestamp: new Date(),
},
{
id: 'mock-assistant-2',
role: 'assistant',
content: '',
timestamp: new Date(),
singleRagTrace: {
title: '单轮 RAG 思考过程',
thinkingMarkdown: '用户在做方案选型,回答要突出“复杂问题覆盖率”和“时延成本”两个维度。',
answerMarkdown: '**单轮检索**更快,适合事实性问题;**多轮检索**覆盖更全,适合复杂推理问题。通常实践里会先单轮,召回不足再升级多轮。[[1]][[2]]',
thinkingComplete: true,
},
sources: [
{
title: 'RAG 模式选型对比',
type: '白皮书',
snippet: '单轮偏性能,多轮偏完整性。',
source: '智能搜索实验室',
time: '2026-02-11',
url: 'https://example.com/rag-mode-comparison',
content: '复杂查询在多轮检索中召回覆盖更高,但会增加 20%-60% 延迟,需按业务 SLA 选择。',
},
{
title: '检索系统时延评测',
type: '评测',
snippet: '多轮策略会增加查询与重排调用次数。',
source: '平台性能组',
time: '2026-02-27',
url: 'https://example.com/rag-latency-benchmark',
content: '在同等资源下,多轮检索平均时延高于单轮检索,但对于复杂问题的回答完整度更高。',
},
],
},
]);
/**
* 将多轮检索接口返回的 references 项映射为通用引用来源。
*/
export const mapReferenceToSource = (item: { title?: string; content?: string; snippet?: string }): SourceItem => ({
title: item.title || '未命名来源',
type: '参考资料',
snippet: item.snippet || item.content || '',
content: item.content || item.snippet || '',
});
/**
* 将知识库单轮接口返回的 docs 映射为页面引用来源。
*/
export const mapDocsToSources = (docs: KnowledgeBaseChunk['docs']): SourceItem[] => {
const normalizedDocs = Array.isArray(docs) ? docs : docs ? [docs] : [];
return normalizedDocs.map((item, idx) => {
console.log(item,'检索资料')
if (typeof item === 'string') {
return {
title: '',
type: '知识库',
snippet: item,
source: '',
time: '',
content: item,
};
}
return {
title: item.title || item.source || `参考资料 ${idx + 1}`,
type: '知识库',
snippet: item.snippet || item.content || item.url || '',
source: item.source || item.title || '',
time: '',
url: item.url,
content: item.content || item.snippet || '',
};
});
};
export const chatDataJSON = (chatJSON: string) => {
let chatJSONText = chatJSON.replace(/}{"/g, '},{"');
chatJSONText = '[' + chatJSONText + ']';
// console.log('解析chat',chatJSONText);
let chatData = [];
let chatTitle: any = '';
const docsList: any[] = [];
try {
chatData = JSON.parse(chatJSONText);
chatData.forEach((item: any) => {
if (item['answer']) {
chatTitle += item['answer'];
} else if (item['docs']) {
docsList.push(item);
}
});
return {
chatTitle,
docsList,
};
} catch (error) {
return {
chatTitle,
docsList,
};
}
};
/**
* 从流式文本中提取完整 JSON 对象。
*
* SSE 响应可能把一个 JSON 切成多段返回;该函数通过花括号深度和字符串状态,
* 找到已经完整闭合的对象,并把未闭合尾巴放回 `rest` 等待下一批数据拼接。
*/
export const extractJsonObjects = (raw: string): { objects: string[]; rest: string } => {
const objects: string[] = [];
let depth = 0;
let stringQuote: '"' | "'" | null = null;
let escaped = false;
let start = -1;
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i];
if (stringQuote) {
if (escaped) {
escaped = false;
} else if (ch === '\\') {
escaped = true;
} else if (ch === stringQuote) {
stringQuote = null;
}
continue;
}
if (ch === '"' || ch === "'") {
stringQuote = ch;
continue;
}
if (ch === '{') {
if (depth === 0) start = i;
depth += 1;
continue;
}
if (ch === '}') {
depth -= 1;
if (depth === 0 && start !== -1) {
objects.push(raw.slice(start, i + 1));
start = -1;
}
}
}
if (depth > 0 && start !== -1) {
return { objects, rest: raw.slice(start) };
}
return { objects, rest: '' };
};
// 处理平台数据
export const platformTypeChange = (platform: string) => {
let platformType: string = '';
if (platform.includes('fb')) {
platformType = 'facebook';
} else if (platform.includes('x')) {
platformType = 'x';
} else if (platform.includes('in')) {
platformType = 'instagram';
} else if (platform.includes('re')) {
platformType = 'reddit';
} else if (platform.includes('th')) {
platformType = 'threads';
} else {
platformType = 'facebook';
}
return platformType;
}
+100
View File
@@ -0,0 +1,100 @@
import type { QASingleRagTrace, QAThinkingTrace } from './QACommon';
import { LucideIcon } from 'lucide-react';
export type QAMode = 'single-chat' | 'single-round' | 'multi-round' | 'deep-search' | 'hx-search' | 'news-report';
export type ViewMode = 'single' | 'compare';
export type KnowledgeScope = 'all' | 'specific-kb' | 'specific-domain';
export type AnswerStyle = 'concise' | 'detailed';
export type CompareLayout = 'horizontal' | 'vertical';
export interface SourceItem {
title: string;
type: string;
snippet: string;
source?: string;
time?: string;
url?: string;
keywords?: string;
taskDirection?: string;
content?: string;
}
export interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
singleRagTrace?: QASingleRagTrace;
thinkingTrace?: QAThinkingTrace;
sources?: SourceItem[];
}
export interface ToolCallData {
tool_name: string;
tool_input: {
text?: string;
query?: string;
q?: string;
keyword?: string;
platform?: string;
user_fig?: string;
result?: string;
s?: string;
e?: string;
};
}
export interface KnowledgeBaseDoc {
title?: string;
content?: string;
source?: string;
url?: string;
snippet?: string;
}
export interface KnowledgeBaseChunk {
answer?: string;
docs?: KnowledgeBaseDoc[] | KnowledgeBaseDoc | string;
}
export interface ComparePane {
mode: QAMode;
loading: boolean;
singleRagTrace?: QASingleRagTrace;
thinkingTrace?: QAThinkingTrace;
sources: SourceItem[];
}
export interface CompareResult {
id: string | number;
compareUserInput: string;
left: ComparePane;
right: ComparePane;
differences?: {
consensus: string;
differences: string;
leftAdvantage: string;
rightAdvantage: string;
};
}
export interface SearchProcessState {
currentMode: string;
rounds: number;
hits: number;
queryRewrite: string;
}
export interface stateItem {
compareIndex: number;
compareId: string;
messageList: Message[],
compareResult: CompareResult[];
}
export interface qaModeItem {
value: QAMode;
label: string;
icon: LucideIcon;
desc: string;
}
@@ -0,0 +1,261 @@
import { BookOpen, Bot, Brain, ChevronLeft, ChevronRight, Sparkles, User2Icon } from 'lucide-react';
import type { AnswerStyle, CompareLayout, KnowledgeScope, QAMode, qaModeItem, ViewMode } from './GeneralQA.types';
/**
* 左侧配置栏 Props。
*
* 组件内不保存业务状态,所有选中值和 setter 都来自父组件。
* 这样父组件可以统一处理“生成中切换需要确认停止”的逻辑,配置栏只负责触发用户意图。
*/
type Props = {
isDark: boolean; // 当前是否为暗黑主题,用于样式适配
viewMode: ViewMode; // 当前视图模式:'single' 单一模式 或 'compare' 对比模式
qaMode: QAMode; // 单一模式下的问答模式:'single-round' 单轮RAG / 'multi-round' 多轮RAG / 'deep-search' 深度检索
compareLeftMode: QAMode; // 对比模式下左侧的问答模式
compareRightMode: QAMode; // 对比模式下右侧的问答模式
compareLayout: CompareLayout; // 对比模式的布局方式:'horizontal' 水平 或 'vertical' 垂直
knowledgeScope: KnowledgeScope; // 知识范围设置:'all' 全部 / 'specific-kb' 指定知识库 / 'specific-domain' 指定领域
answerStyle: AnswerStyle; // 回答风格偏好:'concise' 简洁 或 'detailed' 详细
showSources: boolean; // 是否在回答中展示引用来源
showProcess: boolean; // 是否展示模型的思考过程
autoSummarizeDiff: boolean; // 对比模式下是否自动总结左右差异
recommendBetter: boolean; // 对比模式下是否推荐更好的回答方案
isCollapsed: boolean; // 左侧配置面板是否折叠
qaModeOptions?: qaModeItem[];
setQaMode: (mode: QAMode) => void; // 设置单一模式的问答模式
setCompareLeftMode: (mode: QAMode) => void; // 设置对比模式左侧的模式
setCompareRightMode: (mode: QAMode) => void; // 设置对比模式右侧的模式
setCompareLayout: (layout: CompareLayout) => void; // 设置对比布局方式
setKnowledgeScope: (scope: KnowledgeScope) => void; // 设置知识范围
setAnswerStyle: (style: AnswerStyle) => void; // 设置回答风格
setShowSources: (show: boolean) => void; // 设置是否展示引用来源
setShowProcess: (show: boolean) => void; // 设置是否展示思考过程
setAutoSummarizeDiff: (enabled: boolean) => void; // 设置是否自动总结差异
setRecommendBetter: (enabled: boolean) => void; // 设置是否推荐更好的方案
setIsCollapsed: (collapsed: boolean) => void; // 设置配置面板折叠状态
};
/**
* 单一模式下可选的问答模式。
*
* 每个选项同时提供展示文案、图标和简短说明,便于配置栏用统一结构渲染卡片。
*/
const qaModeList: qaModeItem[] = [
{ value: 'single-chat' as const, label: '快速', icon: Bot, desc: '快速直接' },
{ value: 'single-round' as const, label: '单轮RAG', icon: Sparkles, desc: '快速直接' },
{ value: 'multi-round' as const, label: '多轮RAG', icon: Brain, desc: '深入理解' },
{ value: 'deep-search' as const, label: '任务检索', icon: BookOpen, desc: '检索相关任务' },
{ value: 'hx-search' as const, label: '画像检索', icon: User2Icon, desc: '检索相关账号' },
{ value: 'news-report' as const, label: '新闻报告生成', icon: User2Icon, desc: '检索相关新闻' },
];
/**
* 知识范围选项。
*
* 当前只影响 UI 配置状态,后续接入真实知识库筛选时可以直接把该值映射到请求参数。
*/
const knowledgeScopeOptions = [
{ value: 'all' as const, label: '全部知识源' },
{ value: 'specific-kb' as const, label: '指定知识库' },
{ value: 'specific-domain' as const, label: '指定资源域' },
];
/**
* 左侧问答配置栏。
*
* 该组件集中承载页面的“模式选择、知识范围、回答偏好、对比设置”等表单控件。
* 父组件仍是唯一状态源,配置栏通过 setter 把用户选择同步回父组件;这样不会改变原有请求逻辑,
* 同时把 300 多行配置 JSX 从 `GeneralQA.tsx` 中移出,便于后续维护表单项和样式。
*/
export default function GeneralQAConfigSidebar({
isDark,
viewMode,
qaMode,
compareLeftMode,
compareRightMode,
compareLayout,
knowledgeScope,
answerStyle,
showSources,
showProcess,
autoSummarizeDiff,
recommendBetter,
isCollapsed,
qaModeOptions = qaModeList,
setQaMode,
setCompareLeftMode,
setCompareRightMode,
setCompareLayout,
setKnowledgeScope,
setAnswerStyle,
setShowSources,
setShowProcess,
setAutoSummarizeDiff,
setRecommendBetter,
setIsCollapsed,
}: Props): JSX.Element {
/**
* 自定义勾选图标。
*
* 原生 checkbox 被隐藏后,所有勾选项都通过这个 SVG 保持一致的视觉风格。
*/
const renderCheckIcon = () => (
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
);
return (
<div className={`${isCollapsed ? 'w-14 p-2' : 'w-72 p-4'} border-r flex-shrink-0 overflow-y-auto transition-all duration-300 ${
isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/50'
}`}>
<div className={`flex items-center ${isCollapsed ? 'justify-center' : 'justify-between'} mb-4`}>
{!isCollapsed && (
<span className={`text-xs font-medium uppercase tracking-wider ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
问答配置
</span>
)}
<button
type="button"
onClick={() => setIsCollapsed(!isCollapsed)}
className={`p-2 rounded-lg transition-all ${
isDark
? 'text-slate-400 hover:text-white hover:bg-slate-700/50'
: 'text-gray-500 hover:text-red-600 hover:bg-red-50'
}`}
title={isCollapsed ? '展开侧边栏' : '收起侧边栏'}
>
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
</div>
{!isCollapsed && (
<>
{viewMode === 'single' ? (
<div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>问答模式</h3>
<div className="space-y-2">
{qaModeOptions.map((mode) => {
const Icon = mode.icon;
return (
<button
key={mode.value}
onClick={() => setQaMode(mode.value)}
className={`w-full flex items-center gap-3 p-3 rounded-lg transition-all text-left ${
qaMode === mode.value
? isDark
? 'bg-blue-600/20 border border-blue-500/50 text-blue-400'
: 'bg-red-50 border border-red-200 text-red-600'
: isDark
? 'bg-slate-700/30 border border-transparent text-slate-400 hover:bg-slate-700/50'
: 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
<Icon className="w-5 h-5" />
<div className="flex-1">
<div className="text-sm font-medium">{mode.label}</div>
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{mode.desc}</div>
</div>
</button>
)
})}
</div>
</div>
) : (
<>
<div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>对比模式设置</h3>
<div className="mb-4">
<label className={`text-xs mb-2 block ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>模式A</label>
<select value={compareLeftMode} onChange={(e) => setCompareLeftMode(e.target.value as QAMode)} className={`w-full px-3 py-2 rounded-lg text-sm ${isDark ? 'bg-slate-700/50 border border-slate-600 text-white' : 'bg-white border border-gray-200 text-gray-700'}`}>
{
qaModeOptions.filter(item => item.value !== compareRightMode).map(item => (
<option value={item.value} key={item.value}>{item.label}</option>
))
}
</select>
</div>
<div className={`text-center my-2 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}><span className="text-lg">VS</span></div>
<div className="mb-4">
<label className={`text-xs mb-2 block ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>模式B</label>
<select value={compareRightMode} onChange={(e) => setCompareRightMode(e.target.value as QAMode)} className={`w-full px-3 py-2 rounded-lg text-sm ${isDark ? 'bg-slate-700/50 border border-slate-600 text-white' : 'bg-white border border-gray-200 text-gray-700'}`}>
{
qaModeOptions.filter(item => item.value !== compareLeftMode).map(item => (
<option value={item.value} key={item.value}>{item.label}</option>
))
}
</select>
</div>
</div>
<div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>对比布局</h3>
<div className="flex gap-2">
{(['horizontal', 'vertical'] as const).map((layout) => (
<button key={layout} onClick={() => setCompareLayout(layout)} className={`flex-1 px-3 py-2 rounded-lg text-sm transition-all ${compareLayout === layout ? isDark ? 'bg-blue-600 text-white' : 'bg-red-500 text-white' : isDark ? 'bg-slate-700/50 text-slate-300' : 'bg-gray-100 text-gray-600'}`}>
{layout === 'horizontal' ? '左右对比' : '上下对比'}
</button>
))}
</div>
</div>
</>
)}
{/* <div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>知识范围</h3>
<div className="space-y-2">
{knowledgeScopeOptions.map((option) => (
<label key={option.value} className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${knowledgeScope === option.value ? isDark ? 'bg-blue-600/20 text-blue-400' : 'bg-red-50 text-red-600' : isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
<input type="radio" name="knowledge-scope" checked={knowledgeScope === option.value} onChange={() => setKnowledgeScope(option.value)} className="sr-only" />
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${knowledgeScope === option.value ? isDark ? 'border-blue-500 bg-blue-500' : 'border-red-500 bg-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>
{knowledgeScope === option.value && <div className="w-1.5 h-1.5 rounded-full bg-white" />}
</div>
<span className="text-sm">{option.label}</span>
</label>
))}
</div>
</div> */}
{/* <div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>回答偏好</h3>
<div className="space-y-3">
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
<input type="checkbox" checked={answerStyle === 'detailed'} onChange={() => setAnswerStyle(answerStyle === 'detailed' ? 'concise' : 'detailed')} className="sr-only" />
<div className={`w-4 h-4 rounded border flex items-center justify-center ${answerStyle === 'detailed' ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{answerStyle === 'detailed' && renderCheckIcon()}</div>
<span className="text-sm">详细回答</span>
</label>
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
<input type="checkbox" checked={showSources} onChange={() => setShowSources(!showSources)} className="sr-only" />
<div className={`w-4 h-4 rounded border flex items-center justify-center ${showSources ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{showSources && renderCheckIcon()}</div>
<span className="text-sm">展示引用来源</span>
</label>
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
<input type="checkbox" checked={showProcess} onChange={() => setShowProcess(!showProcess)} className="sr-only" />
<div className={`w-4 h-4 rounded border flex items-center justify-center ${showProcess ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{showProcess && renderCheckIcon()}</div>
<span className="text-sm">展示检索过程</span>
</label>
</div>
</div> */}
{/* {viewMode === 'compare' && (
<div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>对比设置</h3>
<div className="space-y-3">
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
<input type="checkbox" checked={autoSummarizeDiff} onChange={() => setAutoSummarizeDiff(!autoSummarizeDiff)} className="sr-only" />
<div className={`w-4 h-4 rounded border flex items-center justify-center ${autoSummarizeDiff ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{autoSummarizeDiff && renderCheckIcon()}</div>
<span className="text-sm">自动总结差异</span>
</label>
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
<input type="checkbox" checked={recommendBetter} onChange={() => setRecommendBetter(!recommendBetter)} className="sr-only" />
<div className={`w-4 h-4 rounded border flex items-center justify-center ${recommendBetter ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{recommendBetter && renderCheckIcon()}</div>
<span className="text-sm">推荐更优答案</span>
</label>
</div>
</div>
)} */}
</>
)}
</div>
);
}
@@ -0,0 +1,85 @@
import { History } from 'lucide-react';
import type { PageType } from '../../components/Sidebar';
import type { ViewMode } from './GeneralQA.types';
/**
* 顶部栏 Props。
*
* `setViewMode` 和 `onNavigate` 由父组件传入,父组件会在生成中切换时做确认拦截;
* Header 本身不直接感知 loading,保持纯展示和事件转发。
*/
type Props = {
isDark: boolean; // 当前是否为暗黑主题,用于样式适配
viewMode: ViewMode; // 当前视图模式:'single' 单一模式 或 'compare' 对比模式
chatHeadTitle?: string;
chatDesign?: string;
compareShow?: boolean;
setViewMode: (mode: ViewMode) => void; // 设置视图模式回调函数
onNavigate: (page: PageType) => void; // 页面导航回调函数,用于跳转到其他页面(如历史记录)
};
/**
* 通用问答页面顶部栏。
*
* 该组件只负责页面标题、模式切换和历史记录入口,所有业务状态仍由父组件维护。
* 这样拆分后,顶部导航的样式和交互不会和中间问答流处理逻辑混在一起,后续如果要新增
* “保存模板”“导出记录”等入口,也可以只改这个文件。
*/
export default function GeneralQAHeader({ isDark, viewMode, chatHeadTitle = '通用问答', chatDesign = '支持单轮RAG、多轮RAG、深度检索及结果对比', compareShow = true, setViewMode, onNavigate }: Props): JSX.Element {
return (
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className="flex items-center gap-4">
<h1 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{chatHeadTitle}
</h1>
<span className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{chatDesign}
</span>
</div>
{
compareShow && <div className="flex items-center gap-2">
<div className={`flex rounded-lg p-1 ${isDark ? 'bg-slate-700/50' : 'bg-red-50'}`}>
<button
onClick={() => setViewMode('single')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${viewMode === 'single'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
单一模式
</button>
<button
onClick={() => setViewMode('compare')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${viewMode === 'compare'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
对比模式
</button>
</div>
{/* <button
onClick={() => onNavigate('qa-records')}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
isDark
? 'bg-slate-700/50 text-slate-300 hover:bg-slate-600/50'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<History className="w-4 h-4" />
历史记录
</button> */}
</div>
}
</div>
);
}
File diff suppressed because it is too large. Load diff
+410
View File
@@ -0,0 +1,410 @@
.mope-chat {
--bg: #ffffff;
--surface: #ffffff;
--surface-raised: #ffffff;
--card: #ffffff;
--border: #fee2e2;
--border-strong: #fecaca;
--text: #111827;
--text-muted: #6b7280;
--accent: #dc2626;
--accent-hover: #ef4444;
--green: #16a34a;
--yellow: #f59e0b;
--red: #dc2626;
--purple: #7c3aed;
--button-text: #ffffff;
--surface-hover: rgba(220, 38, 38, 0.04);
--surface-hover-strong: rgba(220, 38, 38, 0.08);
--accent-soft: rgba(220, 38, 38, 0.08);
--accent-soft-strong: rgba(220, 38, 38, 0.16);
--danger-soft: rgba(220, 38, 38, 0.10);
--shadow-soft: 0 14px 40px rgba(15, 23, 42, 0.07);
--shadow-card: 0 8px 24px rgba(15, 23, 42, 0.05);
--text-primary: var(--text);
--text-secondary: var(--text-muted);
--border-color: var(--border);
--bg-secondary: var(--surface);
--accent-blue: var(--accent);
--phase-starting: var(--accent);
--phase-diverging: var(--yellow);
--phase-closing: var(--green);
--phase-done: var(--purple);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: 14px;
line-height: 1.6;
color: var(--text);
background: var(--bg);
}
.mope-chat.mope-chat-dark {
--bg: #0f172a;
--surface: #1e293b;
--surface-raised: #111827;
--card: #0f172a;
--border: rgba(30, 64, 175, 0.45);
--border-strong: rgba(59, 130, 246, 0.45);
--text: #f8fafc;
--text-muted: #94a3b8;
--accent: #3b82f6;
--accent-hover: #60a5fa;
--green: #10b981;
--yellow: #f59e0b;
--red: #f43f5e;
--purple: #8b5cf6;
--button-text: #ffffff;
--surface-hover: rgba(255, 255, 255, 0.03);
--surface-hover-strong: rgba(255, 255, 255, 0.06);
--accent-soft: rgba(59, 130, 246, 0.14);
--accent-soft-strong: rgba(59, 130, 246, 0.22);
--danger-soft: rgba(244, 63, 94, 0.12);
--shadow-soft: 0 18px 45px rgba(0, 0, 0, 0.28);
--shadow-card: 0 12px 28px rgba(0, 0, 0, 0.22);
}
.mope-chat,
.mope-chat * {
box-sizing: border-box;
}
.mope-chat {
color: var(--text);
min-height: 100%;
transition: background 0.2s ease, color 0.2s ease;
}
.mope-chat input,
.mope-chat textarea,
.mope-chat select,
.mope-chat button {
font: inherit;
}
.mope-chat p,
.mope-chat h1,
.mope-chat h2,
.mope-chat h3,
.mope-chat h4,
.mope-chat pre {
margin-top: 0;
}
.mope-chat ::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.mope-chat ::-webkit-scrollbar-track {
background: var(--surface);
}
.mope-chat ::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
.mope-chat ::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Idle: centered form */
.mope-chat.app-idle {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 96px);
padding: 36px 40px 42px;
background:
radial-gradient(circle at top center, var(--accent-soft) 0, transparent 34%),
var(--bg);
}
.mope-chat.app-idle-scroll {
align-items: flex-start;
overflow-y: auto;
}
/* Running / done: two-column */
.mope-chat.app-running {
display: flex;
flex-direction: column;
height: calc(100vh - 96px);
overflow: hidden;
background: var(--surface-raised);
border-radius: 18px;
border: 1px solid var(--border);
box-shadow: var(--shadow-card);
}
.mope-chat .app-header {
background: color-mix(in srgb, var(--surface) 92%, var(--accent) 8%);
border-bottom: 1px solid var(--border);
padding: 14px 18px;
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.mope-chat .app-header-logo {
font-weight: 700;
font-size: 15px;
color: var(--accent);
letter-spacing: 0.5px;
}
.mope-chat .app-header-task {
flex: 1;
color: var(--text-muted);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mope-chat .app-header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.mope-chat .app-body {
display: flex;
flex: 1;
overflow: hidden;
}
.mope-chat .app-sidebar {
width: 250px;
flex-shrink: 0;
overflow-y: auto;
border-right: 1px solid var(--border);
background: color-mix(in srgb, var(--surface) 96%, var(--accent) 4%);
}
.mope-chat .app-main {
flex: 1;
overflow-y: auto;
padding: 24px;
background: var(--bg);
}
.mope-chat .app-progress-sidebar {
width: 320px;
flex-shrink: 0;
overflow-y: auto;
border-left: 1px solid var(--border);
background: color-mix(in srgb, var(--surface) 96%, var(--accent) 4%);
}
.mope-chat .app-side-panel {
position: relative;
transition: width 0.22s ease, background 0.2s ease;
}
.mope-chat .app-side-panel.collapsed {
width: 44px;
overflow: hidden;
}
.mope-chat .app-side-toggle {
position: sticky;
top: 10px;
z-index: 2;
width: 28px;
height: 28px;
min-width: 28px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface-raised);
color: var(--text-muted);
box-shadow: var(--shadow-card);
}
.mope-chat .app-side-toggle:hover:not(:disabled) {
color: var(--accent);
border-color: var(--border-strong);
background: var(--surface-hover-strong);
transform: translateY(-1px);
}
.mope-chat .app-side-toggle-left {
float: right;
margin: 10px 10px 0 0;
}
.mope-chat .app-side-toggle-right {
float: left;
margin: 10px 0 0 10px;
}
.mope-chat .app-side-panel.collapsed .app-side-toggle-left,
.mope-chat .app-side-panel.collapsed .app-side-toggle-right {
float: none;
margin: 10px auto 0;
}
/* Buttons */
.mope-chat button {
cursor: pointer;
border: none;
border-radius: 10px;
padding: 8px 16px;
font-size: 13px;
font-family: inherit;
transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s, background 0.15s, border-color 0.15s, color 0.15s;
}
.mope-chat button:hover:not(:disabled) {
transform: translateY(-1px);
}
.mope-chat button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mope-chat .btn-primary {
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
color: var(--button-text);
font-weight: 600;
box-shadow: 0 10px 22px var(--accent-soft-strong);
}
.mope-chat .btn-primary:hover:not(:disabled) {
opacity: 0.85;
}
.mope-chat .btn-danger {
background: transparent;
color: var(--red);
border: 1px solid var(--red);
}
.mope-chat .btn-danger:hover:not(:disabled) {
background: var(--danger-soft);
}
.mope-chat .btn-secondary {
background: var(--surface-raised);
color: var(--text-muted);
border: 1px solid var(--border);
}
.mope-chat .btn-secondary:hover:not(:disabled) {
color: var(--text);
border-color: var(--border-strong);
background: var(--surface-hover);
}
.mope-chat .btn-ghost {
background: transparent;
color: var(--text-muted);
padding: 4px 8px;
font-size: 12px;
}
.mope-chat .btn-ghost:hover {
color: var(--text);
}
/* Badges */
.mope-chat .badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.3px;
}
.mope-chat .badge-starting { background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); }
.mope-chat .badge-diverging { background: color-mix(in srgb, var(--yellow) 15%, transparent); color: var(--yellow); }
.mope-chat .badge-closing { background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); }
.mope-chat .badge-done { background: color-mix(in srgb, var(--purple) 15%, transparent); color: var(--purple); }
.mope-chat .badge-default { background: color-mix(in srgb, var(--text-muted) 15%, transparent); color: var(--text-muted); }
/* Spinner */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.mope-chat .spinner {
display: inline-block;
animation: spin 1s linear infinite;
}
/* Reference cite */
.mope-chat .ref-cite {
display: inline-block;
background: var(--accent-soft-strong);
color: var(--accent);
border-radius: 4px;
padding: 0 3px;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 11px;
cursor: pointer;
position: relative;
}
.mope-chat .ref-cite:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 4px);
left: 50%;
transform: translateX(-50%);
background: var(--card);
border: 1px solid var(--border);
color: var(--text);
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
white-space: nowrap;
z-index: 100;
font-family: inherit;
pointer-events: none;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
}
/* Error banner */
.mope-chat .error-banner {
background: var(--danger-soft);
border: 1px solid var(--red);
border-radius: 6px;
padding: 8px 12px;
color: var(--red);
font-size: 13px;
margin-bottom: 12px;
}
@media (max-width: 1200px) {
.mope-chat .app-progress-sidebar {
width: 260px;
}
}
@media (max-width: 960px) {
.mope-chat .app-body {
flex-direction: column;
}
.mope-chat .app-sidebar,
.mope-chat .app-progress-sidebar {
width: 100%;
border-right: none;
border-left: none;
border-bottom: 1px solid var(--border);
max-height: 260px;
}
.mope-chat .app-main {
padding: 14px;
}
}
+349
View File
@@ -0,0 +1,349 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useSession } from '@/hooks/useSession';
import { useTheme } from '../../contexts/ThemeContext';
import ConfigPanel from './components/ConfigPanel';
import Sidebar from './components/Sidebar';
import Timeline from './components/Timeline';
import ProgressSidebar from './components/ProgressSidebar';
import ScenarioConfigPage from './components/ScenarioConfigPage';
import './MopeChat.css';
import { useChatContext } from '@/contexts/ChatContext';
import { requestCrudService } from '@/service/request-axios-service';
type StartConfig = {
task_context?: string;
[key: string]: any;
};
type SessionError = {
agent_id?: string;
error?: string;
};
type SessionHistoryPayload = {
typeName: string;
templateName: string;
resultJson: Record<string, any>;
};
const MOPE_HISTORY_API_BASE = '/api/mope/history';
const backendUrls = import.meta.env.VITE_AIPI_MOPECHAT_URL || '';
const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
const service = requestCrudService;
/**
* 保存模型完整生成结果。
*
* 接口暂未提供,先把调用方法和请求体结构固定下来:
* - typeName:类型名字,目前使用用户输入的任务背景。
* - templateName:用户选择的生成模板名称。
* - resultJson:完整结果 JSON,包含 timeline、agentStates、reportSchema、finalReport 等恢复页面所需数据。
*/
async function saveMopeHistory(payload: SessionHistoryPayload) {
console.log('保存了吗',payload )
const response = await fetch(MOPE_HISTORY_API_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`保存 MOPE 历史失败:HTTP ${response.status}`);
}
return response.json().catch(() => null);
}
/**
* 获取当前用户最近一次 MOPE 生成历史。
*
* 接口暂未提供,先约定返回值就是保存时的 resultJson。
* 如果接口没有历史数据,应返回 null / 空对象 / 404,页面会保持当前未生成状态。
*/
async function fetchMopeHistory(): Promise<Record<string, any> | null> {
const response = await fetch(MOPE_HISTORY_API_BASE, { method: 'GET' });
if (response.status === 404 || response.status === 204) return null;
if (!response.ok) {
throw new Error(`获取 MOPE 历史失败:HTTP ${response.status}`);
}
const data = await response.json();
return data?.resultJson || data || null;
}
export default function MopeChat() {
const session = useSession() as any;
const { theme } = useTheme() as { theme?: string };
const { startConfig } = useChatContext();
const isDark = theme === 'dark';
const [taskContext, setTaskContext] = useState<string>('');
const [idlePage, setIdlePage] = useState<string>('home');
const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false);
const [isRightSidebarCollapsed, setIsRightSidebarCollapsed] = useState(false);
const {
status,
phase,
coordinatorMode,
currentRound,
divergeRounds,
reportSchema,
agents,
agentStates,
timeline,
sharedMemory,
finalReportPartial,
currentFinalField,
pendingReview,
reviewSubmitting,
reviewRevising,
errors,
startSession,
stopSession,
approveCoordinator,
reviseCoordinator,
hydrateFromSnapshot,
reset,
} = session;
const historyLoadedRef = useRef(false);
const savedHistoryKeyRef = useRef<string | null>(null);
const currentConfigRef = useRef<StartConfig | null>(null);
const buildHistorySnapshot = useCallback(() => ({
status,
phase,
coordinatorMode,
currentRound,
divergeRounds,
reportSchema,
agents,
agentStates,
timeline,
sharedMemory,
finalReportPartial,
currentFinalField,
config: currentConfigRef.current,
}), [
status,
phase,
coordinatorMode,
currentRound,
divergeRounds,
reportSchema,
agents,
agentStates,
timeline,
sharedMemory,
finalReportPartial,
currentFinalField,
]);
const handleStart = (config: StartConfig, backendUrl: string) => {
currentConfigRef.current = config;
savedHistoryKeyRef.current = null;
setTaskContext(config.task_context || '');
console.log('config', config)
startSession(config, backendUrl);
};
const handleStop = () => {
console.log('state', )
stopSession();
const snapshot = buildHistorySnapshot();
const templateName = currentConfigRef.current?.template_name || '未命名模板';
const typeName = taskContext || currentConfigRef.current?.task_context || '未命名任务';
const saveKey = `${typeName}::${templateName}::${timeline.length}`;
if (savedHistoryKeyRef.current === saveKey) return;
savedHistoryKeyRef.current = saveKey;
void saveMopeHistory({
typeName,
templateName,
resultJson: {
...snapshot,
typeName,
templateName,
savedAt: new Date().toISOString(),
},
}).catch((error) => {
console.warn('MOPE 历史记录保存失败:', error);
savedHistoryKeyRef.current = null;
});
};
const handleReset = () => {
reset();
currentConfigRef.current = null;
savedHistoryKeyRef.current = null;
setTaskContext('');
};
useEffect(() => {
if (startConfig) {
handleStart({
...startConfig,
coordinator_mode: 'human_review',
}, backendUrls);
}
console.log('startConfig', startConfig)
}, [])
useEffect(() => {
if (historyLoadedRef.current) return;
historyLoadedRef.current = true;
void fetchMopeHistory()
.then((snapshot) => {
if (!snapshot || !Array.isArray(snapshot.timeline) || snapshot.timeline.length === 0) return;
currentConfigRef.current = snapshot.config || null;
setTaskContext(snapshot.config?.task_context || snapshot.typeName || '');
hydrateFromSnapshot(snapshot);
})
.catch((error) => {
console.warn('MOPE 历史记录获取失败,保持默认空状态:', error);
});
}, [hydrateFromSnapshot]);
useEffect(() => {
console.log('status', status)
if (status !== 'done' || !timeline?.length) return;
const snapshot = buildHistorySnapshot();
const templateName = currentConfigRef.current?.template_name || '未命名模板';
const typeName = taskContext || currentConfigRef.current?.task_context || '未命名任务';
const saveKey = `${typeName}::${templateName}::${timeline.length}`;
if (savedHistoryKeyRef.current === saveKey) return;
savedHistoryKeyRef.current = saveKey;
void saveMopeHistory({
typeName,
templateName,
resultJson: {
...snapshot,
typeName,
templateName,
savedAt: new Date().toISOString(),
},
}).catch((error) => {
console.warn('MOPE 历史记录保存失败:', error);
savedHistoryKeyRef.current = null;
});
}, [status, timeline, taskContext, buildHistorySnapshot]);
if (status === 'idle') {
if (idlePage === 'scenario_config') {
return (
<div className={`mope-chat app-idle app-idle-scroll ${isDark ? 'mope-chat-dark' : 'mope-chat-light'}`}>
<ScenarioConfigPage onBack={() => setIdlePage('home')} />
</div>
);
}
return (
<div className={`mope-chat app-idle ${isDark ? 'mope-chat-dark' : 'mope-chat-light'}`}>
<ConfigPanel
onStart={handleStart}
isRunning={false}
onStop={handleStop}
onOpenScenarioConfig={() => setIdlePage('scenario_config')}
/>
</div>
);
}
return (
<div className={`mope-chat app-running ${isDark ? 'mope-chat-dark' : 'mope-chat-light'}`}>
<header className="app-header">
<span className="app-header-logo">MOPE</span>
<span className="app-header-task">
{taskContext ? `"${taskContext.slice(0, 80)}${taskContext.length > 80 ? '…' : ''}"` : '分析中…'}
</span>
<span className="badge badge-default">
{coordinatorMode === 'human_review' ? '人工确认' : '全自动'}
</span>
{status === 'waiting_human' && (
<span className="badge badge-diverging">等待人工确认</span>
)}
<div className="app-header-actions">
{(status === 'running' || status === 'waiting_human') && (
<button className="btn-danger" onClick={handleStop}>
停止
</button>
)}
{(status === 'done' || status === 'error') && (
<button className="btn-secondary" onClick={handleReset}>
重新开始
</button>
)}
</div>
</header>
<div className="app-body">
<aside className={`app-sidebar app-side-panel${isLeftSidebarCollapsed ? ' collapsed' : ''}`}>
<button
type="button"
className="app-side-toggle app-side-toggle-left"
onClick={() => setIsLeftSidebarCollapsed(v => !v)}
aria-label={isLeftSidebarCollapsed ? '展开左侧栏' : '收起左侧栏'}
title={isLeftSidebarCollapsed ? '展开左侧栏' : '收起左侧栏'}
>
{isLeftSidebarCollapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</button>
{!isLeftSidebarCollapsed && (
<Sidebar
agents={agents}
agentStates={agentStates}
phase={phase}
currentRound={currentRound}
divergeRounds={divergeRounds}
/>
)}
</aside>
<main className="app-main">
{errors.length > 0 && errors.map((e: SessionError, i: number) => (
<div key={i} className="error-banner">
{e.agent_id ? `[${e.agent_id}] ` : ''}{e.error}
</div>
))}
<Timeline
timeline={timeline}
agentStates={agentStates}
reportSchema={reportSchema}
pendingReview={pendingReview}
onApproveReview={approveCoordinator}
onReviseReview={reviseCoordinator}
reviewSubmitting={reviewSubmitting}
reviewRevising={reviewRevising}
/>
</main>
<aside className={`app-progress-sidebar app-side-panel${isRightSidebarCollapsed ? ' collapsed' : ''}`}>
<button
type="button"
className="app-side-toggle app-side-toggle-right"
onClick={() => setIsRightSidebarCollapsed(v => !v)}
aria-label={isRightSidebarCollapsed ? '展开右侧栏' : '收起右侧栏'}
title={isRightSidebarCollapsed ? '展开右侧栏' : '收起右侧栏'}
>
{isRightSidebarCollapsed ? <ChevronLeft size={16} /> : <ChevronRight size={16} />}
</button>
{!isRightSidebarCollapsed && (
<ProgressSidebar
phase={phase}
status={status}
currentRound={currentRound}
divergeRounds={divergeRounds}
reportSchema={reportSchema}
sharedMemory={sharedMemory}
finalReportPartial={finalReportPartial}
currentFinalField={currentFinalField}
/>
)}
</aside>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
+9
View File
@@ -0,0 +1,9 @@
import { Outlet } from 'react-router-dom';
export default function QACenterPage() {
return (
<div>
<Outlet />
</div>
);
}
+935
View File
@@ -0,0 +1,935 @@
import React, { useEffect, useState } from 'react';
import { Dialog, Textarea } from 'tdesign-react';
import {
Bookmark,
BrainCircuit,
Bot,
ChevronDown,
FileSearch,
Loader2,
RotateCcw,
Search,
Send,
Settings,
Sparkles,
Square,
Copy,
Trash2,
Edit3,
Upload,
UserRound,
ExternalLink,
} from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
import MarkdownRenderer from '../MarkdownRenderer';
import { SourceItem } from './GeneralQA.types';
/**
* 单条检索结果。
*
* 多轮检索过程中每一轮可能返回多条资料,这里只保留 UI 展示所需的标题、摘要和来源。
*/
export interface QARetrievalItem {
id?: string;
title: string;
content: string;
keywords?: string;
taskDirection?: string;
type?: string;
time?: string;
source?: string;
recUuid?: string;
platform?: string;
fans?: number;
comment_count?: number;
message_id?: string;
hot_num?: number;
}
/**
* 多轮检索中的单轮思考节点。
*
* `queryLoading` 控制该轮检索结果是否仍显示 loading;retrievals 到达后再渲染资料列表。
*/
export interface QAThinkingRound {
id: string; // 唯一标识符,用于列表渲染时的 key
title: string; // 该轮检索的标题,如"第1轮检索"或"背景资料收集"
thoughtMarkdown: string; // 模型思考过程的 Markdown 内容,展示思考步骤和推理逻辑
queryTitle?: string; // 检索内容
query: string; // 该轮发送给检索系统的查询语句
queryLoading?: boolean; // 该轮检索是否仍在进行中,true 时显示加载动画
textidContent?: string; // 新闻详细内容
retrievals: QARetrievalItem[]; // 该轮返回的检索结果列表
queryShow: boolean; // 检索列表为空时是否显示
}
/**
* 多轮/深度检索的完整思考链。
*
* rounds 展示检索过程,finalAnswerMarkdown 展示最终回答;两者分开便于流式过程中逐步更新。
*/
export interface QAThinkingTrace {
title?: string; // 整体思考链的标题
rounds: QAThinkingRound[]; // 多轮检索过程中的所有轮次,每轮包含思考和检索结果
finalAnswerMarkdown: string; // 基于多轮检索结果生成的最终回答内容
thinkingComplete?: boolean; // 思考过程是否已完成,true 时思考区域可折叠
}
/**
* 单轮 RAG 的回答结构。
*
* 后端可能把 `<think>...</think>` 和答案混在同一个 answer 流中,解析后分别放到 thinkingMarkdown 与 answerMarkdown。
*/
export interface QASingleRagTrace {
title?: string; // 思考过程的标题,如"单轮 RAG 思考过程"
thinkingMarkdown: string; // 模型在生成回答前的思考过程内容,使用 Markdown 格式
answerMarkdown: string; // 最终生成的回答内容,使用 Markdown 格式
thinkingComplete?: boolean; // 思考过程是否已完成,true 时思考区域可折叠
}
export interface QACommonMessage {
id: string; // 消息唯一标识符
role: 'user' | 'assistant'; // 消息发送者角色:user 为用户消息,assistant 为 AI 回复
content: string; // 消息的纯文本内容(当使用 thinkingTrace 或 singleRagTrace 时为空)
timestamp?: Date; // 消息发送时间
thinkingTrace?: QAThinkingTrace; // 多轮/深度检索模式的思考链,包含多轮检索过程和最终回答
sources?: { // 消息引用的参考资料列表
title: string; // 参考资料标题
type: string; // 资料类型,如"文档"、"网页"等
snippet: string; // 资料摘要内容
source?: string; // 来源名称或 URL
time?: string; // 发布时间
url?: string; // 资料链接
content?: string; // 完整内容(可能用于详情弹窗)
}[];
}
export interface QACommonMessage {
singleRagTrace?: QASingleRagTrace; // 单轮 RAG 模式的思考过程和回答结构
}
interface QAAvatarProps {
role: 'user' | 'assistant';
isActive?: boolean;
label?: string;
variant?: 'default' | 'blue' | 'purple';
}
const getAssistantAvatarClass = (variant: QAAvatarProps['variant'], isDark: boolean) => {
switch (variant) {
case 'blue':
return isDark
? 'bg-gradient-to-br from-blue-400 via-blue-600 to-cyan-700 text-white'
: 'bg-gradient-to-br from-blue-300 via-blue-500 to-cyan-500 text-white';
case 'purple':
return isDark
? 'bg-gradient-to-br from-purple-400 via-violet-600 to-fuchsia-700 text-white'
: 'bg-gradient-to-br from-purple-300 via-violet-500 to-fuchsia-500 text-white';
default:
return isDark
? 'bg-gradient-to-br from-cyan-500 via-blue-600 to-violet-600 text-white'
: 'bg-gradient-to-br from-red-400 via-rose-500 to-orange-400 text-white';
}
};
/**
* 统一头像组件。
*
* 单一模式使用默认助手色;对比模式可通过 variant 指定蓝/紫色系,让头像与左右面板标签保持一致。
* `isActive` 会触发呼吸和星光动画,用来提示当前助手正在生成。
*/
export const QAAvatar: React.FC<QAAvatarProps> = ({ role, isActive = false, label, variant = 'default' }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const Icon = role === 'assistant' ? Bot : UserRound;
return (
<div
className={`relative w-9 h-9 rounded-2xl flex items-center justify-center flex-shrink-0 overflow-hidden shadow-sm ${role === 'assistant'
? getAssistantAvatarClass(variant, isDark)
: isDark
? 'bg-gradient-to-br from-slate-500 via-slate-700 to-slate-900 text-white'
: 'bg-gradient-to-br from-sky-400 via-indigo-500 to-violet-500 text-white'
} ${isActive ? 'qa-avatar-active' : ''}`}
title={label}
>
<div className="absolute inset-0 bg-white/20 opacity-0 qa-avatar-shine" />
<Icon className={`w-5 h-5 relative z-10 ${isActive ? 'qa-avatar-icon-active' : ''}`} />
{role === 'assistant' && isActive && (
<Sparkles className="absolute -right-0.5 -top-0.5 w-3.5 h-3.5 text-yellow-200 qa-avatar-spark" />
)}
</div>
);
};
interface TypingIndicatorProps {
label?: string;
assistantLabel?: string;
}
/**
* 助手预连接/生成中的占位提示。
*
* 用于请求已经发出但还没有生成可展示消息时,给用户明确的等待反馈。
*/
export const TypingIndicator: React.FC<TypingIndicatorProps> = ({ label = '正在思考并生成回复', assistantLabel = 'AI 助手' }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="flex gap-3">
<QAAvatar role="assistant" isActive label={assistantLabel} />
<div className={`max-w-[70%] rounded-2xl px-4 py-3 shadow-sm border ${isDark ? 'bg-slate-700/60 border-slate-600/50 text-slate-200' : 'bg-white border-red-100 text-gray-700'
}`}>
<div className="flex items-center gap-3">
<Loader2 className={`w-4 h-4 animate-spin ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
<span
className={`text-sm font-medium qa-generating-marquee ${isDark ? 'qa-generating-marquee-dark' : 'qa-generating-marquee-light'}`}
>
{label}
</span>
{/* <div className="flex gap-1">
{[0, 1, 2].map((idx) => (
<span
key={idx}
className={`w-1.5 h-1.5 rounded-full qa-typing-dot ${isDark ? 'bg-blue-300' : 'bg-red-400'}`}
style={{
animationDelay: `${idx * 160}ms`,
}}
/>
))}
</div> */}
</div>
</div>
</div>
);
};
interface ChatMessageProps<T extends QACommonMessage> {
message: T;
showSources?: boolean;
assistantLabel?: string;
assistantActive?: boolean;
onCitationClick?: (message: T, citationIndex: number) => void;
onCopyAssistant?: (message: T) => void;
onDeleteAssistant?: (message: T) => void;
onEditAssistant?: (message: T) => void;
onResendAssistant?: (message: T) => void;
renderAssistantExtra?: (message: T) => React.ReactNode;
}
/**
* 聊天消息渲染器。
*
* 根据消息类型自动选择普通文本、单轮 RAG 回答或多轮检索思考链。
* sources 区域独立折叠展示,避免引用资料过长时挤占主回答阅读空间。
*/
export function ChatMessage<T extends QACommonMessage>({
message,
showSources = true,
assistantLabel = 'AI 助手',
assistantActive = false,
onCitationClick,
onCopyAssistant,
onDeleteAssistant,
onEditAssistant,
onResendAssistant,
renderAssistantExtra,
}: ChatMessageProps<T>) {
const { theme } = useTheme();
const isDark = theme === 'dark';
const isUser = message.role === 'user';
const hasTrace = !isUser && !!(message.singleRagTrace || message.thinkingTrace);
const knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL;
const [visible, setVisible] = useState(false); // 打开详情对话框
const [listData, setListData] = useState<SourceItem>({
title: '',
type: '',
time: '',
snippet: '',
source: '',
content: '',
});
const showDialog = (item: SourceItem) => {
console.log('item', item)
if (item.url) {
window.open(`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${item.url}`);
} else {
setListData(item);
setVisible(true);
}
};
return (
<div className="space-y-3">
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
{!isUser && <QAAvatar role="assistant" isActive={assistantActive} label={assistantLabel} />}
<div className={`${hasTrace ? 'max-w-[85%] flex-1' : 'max-w-[72%]'} rounded-2xl px-4 py-3 shadow-sm ${isUser
? isDark
? 'bg-blue-600/70 text-white'
: 'bg-gradient-to-r from-red-500 to-rose-500 text-white'
: message.singleRagTrace || message.thinkingTrace
? isDark
? 'bg-slate-800/40 text-slate-100 border border-slate-600/40'
: 'bg-white text-gray-800 border border-transparent shadow-[0px_3px_12px_rgba(0, 0, 0, 0.12)] px-0 py-0'
: isDark
? 'bg-slate-700/60 text-slate-100 border border-slate-600/40'
: 'bg-white text-gray-800 border border-gray-100'
}`}>
{message.singleRagTrace && !isUser ? (
<SingleRagAnswer
trace={message.singleRagTrace}
onCitationClick={(citationIndex) => onCitationClick?.(message, citationIndex)}
/>
) : message.thinkingTrace && !isUser ? (
<RetrievalThinkingTrace
trace={message.thinkingTrace}
onCitationClick={(citationIndex) => onCitationClick?.(message, citationIndex)}
/>
) : (
<p className="whitespace-pre-wrap leading-relaxed">{message.content}</p>
)}
{!isUser && (onCopyAssistant || onDeleteAssistant || onEditAssistant || onResendAssistant) && (
<div className={`mt-2 flex items-center justify-end gap-1.5 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{onCopyAssistant && (
<button type="button" onClick={() => onCopyAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-slate-100' : 'hover:bg-gray-100 hover:text-gray-700'}`} title="复制回答">
<Copy className="w-3.5 h-3.5" />
</button>
)}
{onDeleteAssistant && (
<button type="button" onClick={() => onDeleteAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-rose-300' : 'hover:bg-red-50 hover:text-red-600'}`} title="删除问答">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{onEditAssistant && (
<button type="button" onClick={() => onEditAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-blue-300' : 'hover:bg-blue-50 hover:text-blue-600'}`} title="编辑回答和思考过程">
<Edit3 className="w-3.5 h-3.5" />
</button>
)}
{onResendAssistant && (
<button type="button" onClick={() => onResendAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-emerald-300' : 'hover:bg-emerald-50 hover:text-emerald-600'}`} title="重新发送">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
)}
</div>
{isUser && <QAAvatar role="user" label="用户" />}
</div>
{!isUser && message.sources && message.sources.length > 0 && showSources && (
<CollapsibleSection
defaultOpen={false}
className={`ml-12 ${hasTrace ? 'max-w-[85%]' : 'max-w-[72%]'} ${isDark ? 'bg-slate-800/50 border-slate-700/50' : 'bg-gray-50 border-gray-200'}`}
title={(
<div className="flex items-center gap-2">
<BrainCircuit className={`w-3.5 h-3.5 ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
<span className={`text-xs font-medium ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>引用来源</span>
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>({message.sources.length})</span>
</div>
)}
>
<div className="space-y-2 pt-[10px]">
{message.sources.map((source, idx) => (
<div key={idx} className={`text-xs p-2 rounded-[4px] border shadow-sm transition-all duration-200 ease-out cursor-pointer hover:-translate-y-0.5 hover:scale-[1.01] ${isDark ? 'bg-slate-700/50 border-slate-600/50 shadow-black/10 hover:bg-slate-700/80 hover:border-blue-400/40 hover:shadow-lg hover:shadow-blue-950/20' : 'bg-white border-gray-100 shadow-gray-200/60 hover:border-red-200 hover:shadow-md hover:shadow-red-100/70'}`} onClick={() => showDialog(source)}>
<div className="flex items-center gap-2 mb-1 min-w-0">
<BrainCircuit className={`w-3 h-3 shrink-0 ${isDark ? 'text-blue-400' : 'text-red-400'}`} />
<span className={`font-medium truncate min-w-0 flex-1 ${isDark ? 'text-slate-300' : 'text-gray-700'}`} title={source.title}>{source.title}</span>
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs ${isDark ? 'bg-slate-600 text-slate-300' : 'bg-gray-200 text-gray-500'}`}>
{source.type}
</span>
</div>
<p className={`line-clamp-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{source.snippet}</p>
</div>
))}
</div>
</CollapsibleSection>
)}
<Dialog
visible={visible}
onClose={() => setVisible(false)}
header="提示"
footer={null}
>
<div>
<div className="pr-9">
<h3 className="text-base font-semibold">{listData.title || '引用内容'}</h3>
<div className={`mt-2 text-xs space-y-1 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<div>来源:{listData.source || '未知来源'}</div>
<div>时间:{listData.time || '未知'}</div>
</div>
</div>
{listData.url && (
<div className="mt-4">
<a
href={`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${listData.url}`}
target="_blank"
rel="noreferrer"
className={`inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-sm transition-all ${isDark
? 'border-slate-600 text-slate-200 hover:bg-slate-800'
: 'border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
title="打开原始链接"
>
<ExternalLink className="w-4 h-4" />
查看原文链接
</a>
</div>
)}
<div className={`mt-4 rounded-xl p-3 text-sm leading-relaxed whitespace-pre-wrap ${isDark ? 'bg-slate-800/70 text-slate-200' : 'bg-gray-50 text-gray-700'
}`}>
{listData.content || '暂无引用内容'}
</div>
</div>
</Dialog>
{!isUser && renderAssistantExtra?.(message)}
</div>
);
}
interface CollapsibleSectionProps {
title: React.ReactNode;
children: React.ReactNode;
defaultOpen?: boolean;
className?: string;
headerClassName?: string;
}
/**
* 通用折叠面板。
*
* 思考过程、检索结果、引用来源都复用该组件,保证展开/收起动画和深浅色主题一致。
*/
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
children,
defaultOpen = true,
className = '',
headerClassName = '',
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [open, setOpen] = useState(defaultOpen);
// 当 defaultOpen 改变时,更新 open 状态
useEffect(() => {
setOpen(defaultOpen);
}, [defaultOpen]);
return (
<div className={`rounded-2xl border overflow-hidden ${isDark ? 'border-slate-700/60 bg-slate-900/30' : 'border-red-100 bg-white'} ${className}`}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className={`w-full flex items-center justify-between gap-3 px-4 py-3 text-left transition-colors ${isDark ? 'hover:bg-slate-800/70' : 'hover:bg-red-50/60'
} ${headerClassName}`}
>
<div className="flex-1 min-w-0">{title}</div>
<ChevronDown
className={`w-4 h-4 flex-shrink-0 transition-transform duration-300 ease-out ${open ? 'rotate-0' : '-rotate-90'
} ${isDark ? 'text-slate-400' : 'text-gray-500'}`}
/>
</button>
<div className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'}`}>
<div className="overflow-hidden">
<div className={`px-4 pb-4 transition-transform duration-300 ease-out ${open ? 'translate-y-0' : '-translate-y-2'} ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
{children}
</div>
</div>
</div>
</div>
);
};
interface SingleRagAnswerProps {
trace: QASingleRagTrace;
onCitationClick?: (citationIndex: number) => void;
}
/**
* 单轮 RAG 回答展示。
*
* 思考内容和最终回答分成两个区域:思考区可折叠,回答区始终展示。
* 这样既保留模型推理过程,也不会影响用户优先阅读最终答案。
*/
export const SingleRagAnswer: React.FC<SingleRagAnswerProps> = ({ trace, onCitationClick }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<CollapsibleSection
defaultOpen={!trace.thinkingComplete}
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
title={
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${isDark ? 'bg-violet-500/15 text-violet-300' : 'bg-orange-50 text-orange-500'
}`}>
<Sparkles className="w-5 h-5" />
</div>
<div>
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>{trace.title || '思考过程'}</div>
{trace.thinkingComplete ? (
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
思考已完成,可展开查看
</div>
) : (
<div className={`text-xs qa-generating-marquee ${isDark ? 'qa-generating-marquee-dark text-slate-300' : 'qa-generating-marquee-light text-gray-600'}`}>
正在接收 &lt;think&gt; 流式思考内容
</div>
)}
</div>
</div>
}
>
{trace.thinkingMarkdown ? (
<MarkdownRenderer
content={trace.thinkingMarkdown}
className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
) : (
<div className={`text-sm flex items-center gap-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Loader2 className="w-4 h-4 animate-spin" />
等待模型输出思考内容...
</div>
)}
</CollapsibleSection>
<div className={`rounded-2xl border p-4 ${isDark ? 'bg-slate-700/60 border-slate-600/40' : 'bg-white border-gray-100'}`}>
{trace.answerMarkdown ? (
<MarkdownRenderer
content={trace.answerMarkdown}
className={`text-sm ${isDark ? 'text-slate-200' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
) : (
<div className={`text-sm flex items-center gap-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Loader2 className="w-4 h-4 animate-spin" />
{trace.thinkingComplete ? '正在流式生成回答...' : '思考结束后开始生成回答...'}
</div>
)}
</div>
</div>
);
};
interface RetrievalListProps {
items: QARetrievalItem[];
}
/**
* 检索结果列表。
*
* 每条资料默认折叠,首条默认展开,便于用户快速看到最相关资料,同时保留完整资料明细。
*/
const RetrievalList: React.FC<RetrievalListProps> = ({ items }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [visible, setVisible] = useState(false); // 打开详情对话框
const [listData, setListData] = useState<QARetrievalItem>({
title: '',
content: '',
});
const knowledgeUrl = import.meta.env.VITE_AIPI_KNOWLEDGE_URL;
const b1SearchUrl = import.meta.env.VITE_AIPI_B1COH_URL;
const b2SearchUrl = import.meta.env.VITE_AIPI_B2ACTOR_URL;
const showDialog = (item: QARetrievalItem) => {
console.log('item', item)
if (item.type === 'more') {
if (item.recUuid) {
window.open(`${knowledgeUrl}/#/knowledge/mid-detail?recUuid=${item.recUuid}`);
} else {
setListData(item);
setVisible(true);
}
} else if (item.type === 'task') {
window.open(`${b1SearchUrl}/#/task/item-aicoh/step-info-search?id=${item.id}`)
} else if (item.type === 'hx') {
window.open(`${b2SearchUrl}/fromB/toDetail?user_id=${item.id}&platform=${item.platform}`);
} else {
setListData(item);
setVisible(true);
}
};
return (
<div className="space-y-2">
{items.map((item, index) => (
<div key={index}>
{
item.type !== 'user_behavior' ? <CollapsibleSection
key={index}
defaultOpen={index === 0}
className={isDark ? 'bg-slate-800/40' : 'bg-gray-50/70'}
title={
<div className="flex items-center gap-2">
<span className={`w-6 h-6 rounded-lg flex items-center justify-center text-xs font-semibold ${isDark ? 'bg-blue-500/20 text-blue-300' : 'bg-red-100 text-red-600'
}`}>{index + 1}</span>
<div className="min-w-0">
<div className={`text-sm font-medium truncate ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{item.title}</div>
{
item.type === 'more' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>时间:{item.time || '-'}</div>
}
{
item.type === 'task' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>关键词:{item.keywords || '-'}</div>
}
{
item.type === 'hx' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}><span>平台:{item.platform || '-'}</span><span className="ml-[10px]">粉丝数:{item.fans || '-'}</span></div>
}
{
(item.type === 'taiwan_news' || item.type === 'world_news' || item.type === 'messaage_comment' || item.type === 'america_news') && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}><span>平台:{item.platform || '-'}</span>{item.comment_count && <span className="ml-[10px]">评论数:{item.comment_count || '-'}</span>}</div>
}
{
item.type === 'get_time_data' && <div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}><span>时间:{item.time || '-'}</span><span className="ml-[10px]">热度:{item.hot_num || '-'}</span></div>
}
</div>
</div>
}
>
<p className={`text-sm leading-relaxed cursor-progress line-clamp-3 hover:underline ${isDark ? 'text-slate-300' : 'text-gray-600'}`} onClick={() => showDialog(item)}>{item.content}</p>
</CollapsibleSection>
: <div className="rounded-[5px] border border-[#f5f5f5] p-[10px]" key={index + 10}>
<span className="font-bold mr-[10px]">{index + 1}.</span>
<span>{item.title}</span>
</div>
}
</div>
))}
<Dialog
visible={visible}
onClose={() => setVisible(false)}
header="提示"
footer={null}
>
<div>
<div className="pr-9">
<h3 className="text-base font-semibold">{listData.title}</h3>
<div className={`mt-2 text-xs space-y-1 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{listData.time && <span>时间:{listData.time || '未知'}</span>}
{listData.platform && <span className="ml-[10px]">平台:{listData.platform || '未知'}</span>}
{listData.comment_count && <span className="ml-[10px]">评论数:{listData.comment_count || '未知'}</span>}
</div>
</div>
<div className={`mt-4 rounded-xl p-3 text-sm leading-relaxed whitespace-pre-wrap ${isDark ? 'bg-slate-800/70 text-slate-200' : 'bg-gray-50 text-gray-700'
}`}>
{listData.content || '暂无引用内容'}
</div>
</div>
</Dialog>
</div>
);
};
interface SearchQueryBadgeProps {
query: string;
loading?: boolean;
queryTitle?: string;
}
/**
* 检索词徽标。
*
* 多轮检索每一轮都会产生一个 query;loading 状态下展示旋转图标,表示该 query 正在召回资料。
*/
const SearchQueryBadge: React.FC<SearchQueryBadgeProps> = ({ query, loading = false, queryTitle = '检索词' }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`inline-flex max-w-full items-center gap-2 rounded-full px-3 py-1.5 border ${isDark ? 'bg-blue-500/10 border-blue-400/20 text-blue-200' : 'bg-red-50 border-red-100 text-red-600'
}`}>
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
<span className="text-xs flex-shrink-0">{queryTitle}</span>
<span className={`text-xs font-medium truncate ${loading && 'qa-generating-marquee'} ${loading && (isDark ? 'qa-generating-marquee-dark' : 'qa-generating-marquee-light')}`}>
{query}
</span>
</div>
);
};
interface RetrievalThinkingTraceProps {
trace: QAThinkingTrace;
onCitationClick?: (citationIndex: number) => void;
}
/**
* 多轮/深度检索思考链展示。
*
* 组件按轮次渲染思考内容、检索词和检索结果,底部展示最终回答。
* 它只消费已经整理好的 trace 数据,不关心数据来自真实 SSE 还是本地模拟。
*/
export const RetrievalThinkingTrace: React.FC<RetrievalThinkingTraceProps> = ({ trace, onCitationClick }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<CollapsibleSection
defaultOpen={!trace.thinkingComplete}
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
title={
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
}`}>
<BrainCircuit className="w-5 h-5" />
</div>
<div>
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{trace.title || '思考过程'}
</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
共 {trace.rounds.length} 轮推理与检索,可展开查看细节
</div>
</div>
</div>
}
>
<div className="space-y-3 pt-1">
{trace.rounds.map((round, index) => (
<div key={index} className={`relative pl-5 ${index < trace.rounds.length - 1 ? 'pb-2' : ''}`}>
{index < trace.rounds.length - 1 && (
<div className={`absolute left-[7px] top-8 bottom-0 w-px ${isDark ? 'bg-slate-700' : 'bg-red-100'}`} />
)}
<div className={`absolute left-0 top-4 w-3.5 h-3.5 rounded-full border-2 ${isDark ? 'bg-slate-900 border-cyan-400' : 'bg-white border-red-400'
}`} />
<CollapsibleSection
defaultOpen
className={isDark ? 'bg-slate-900/40' : 'bg-red-50/30'}
title={
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-100 text-red-600'
}`}>第 {index + 1} 轮</span>
<span className={`text-sm font-medium ${round.queryLoading && 'qa-generating-marquee'} ${round.queryLoading && (isDark ? 'text-slate-100 qa-generating-marquee-dark' : 'text-gray-900 qa-generating-marquee-light')}`}>{round.title}</span>
</div>
}
>
<div className="space-y-3">
<CollapsibleSection
defaultOpen
className={isDark ? 'bg-slate-800/50' : 'bg-white/80'}
title={
<div className="flex items-center gap-2">
<Sparkles className={`w-4 h-4 ${isDark ? 'text-violet-300' : 'text-orange-500'}`} />
<span className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-800'}`}>思考内容</span>
</div>
}
>
<MarkdownRenderer
content={round.thoughtMarkdown}
className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
</CollapsibleSection>
{
round.queryShow &&
<div className="flex items-center">
<SearchQueryBadge query={round.query} queryTitle={round.queryTitle} loading={round.queryLoading} />
</div>
}
{
round.queryShow &&
<CollapsibleSection
defaultOpen={!round.queryLoading}
className={isDark ? 'bg-slate-800/50' : 'bg-white/80'}
title={
<div className="flex items-center gap-2">
<FileSearch className={`w-4 h-4 ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
<span className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-800'}`}>检索内容</span>
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{round.retrievals.length} 条</span>
</div>
}
>
{round.queryLoading ? (
<div className={`rounded-xl px-3 py-4 text-sm flex items-center gap-2 ${isDark ? 'bg-slate-900/60 text-slate-400' : 'bg-gray-50 text-gray-500'}`}>
<Loader2 className="w-4 h-4 animate-spin" />
正在召回相关内容...
</div>
) : (
<RetrievalList items={round.retrievals} />
)}
</CollapsibleSection>
}
{
round.textidContent &&
<div className="mt-[10px]">
{round.textidContent}
</div>
}
</div>
</CollapsibleSection>
</div>
))}
</div>
</CollapsibleSection>
<div className={`rounded-2xl border p-4 ${isDark ? 'bg-slate-700/60 border-slate-600/40' : 'bg-white border-gray-100'}`}>
<div className={`text-sm font-semibold mb-3 ${isDark ? 'text-white' : 'text-gray-900'}`}>最终回答</div>
{
!trace.finalAnswerMarkdown && <div className={`rounded-xl px-3 py-4 text-sm flex items-center gap-2 ${isDark ? 'bg-slate-900/60 text-slate-400' : 'bg-gray-50 text-gray-500'}`}>
<Loader2 className="w-4 h-4 animate-spin" />
正在生成思考过程与检索内容...
</div>
}
<MarkdownRenderer
content={trace.finalAnswerMarkdown}
className={`text-sm ${isDark ? 'text-slate-200' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
</div>
</div>
);
};
interface EmptyChatStateProps {
icon?: React.ReactNode;
title: string;
description: string;
}
/**
* 空状态展示。
*
* 当当前模式没有任何消息时使用,提示用户可以开始输入问题。
*/
export const EmptyChatState: React.FC<EmptyChatStateProps> = ({ icon, title, description }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`h-full flex flex-col items-center justify-center ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
<div className={`w-20 h-20 rounded-3xl mb-5 flex items-center justify-center ${isDark ? 'bg-slate-700/40' : 'bg-gradient-to-br from-red-50 to-orange-50'
}`}>
{icon ?? <Bot className="w-11 h-11 opacity-60" />}
</div>
<p className="text-lg font-medium">{title}</p>
<p className="text-sm mt-2">{description}</p>
</div>
);
};
interface QAComposerProps {
value: string;
placeholder: string;
isLoading: boolean;
canSubmit?: boolean;
onChange: (value: string) => void;
onSubmit: () => void;
onStop: () => void;
onKeyDown?: (e) => void;
onUpload?: () => void;
onReset?: () => void;
onSave?: () => void;
onCollect?: () => void;
onOpenSettings?: () => void;
showUpload?: boolean;
showReset?: boolean;
showSave?: boolean;
showCollect?: boolean;
}
/**
* 问答输入区。
*
* 负责输入、发送/停止、附件、重置、保存、收藏和模型参数设置入口。
* 具体业务动作通过 props 回调交给父组件处理,保证输入区本身不依赖某个问答模式。
*/
export const QAComposer: React.FC<QAComposerProps> = ({
value,
placeholder,
isLoading,
canSubmit,
onChange,
onSubmit,
onStop,
onKeyDown,
onUpload,
onReset,
onSave,
onCollect,
onOpenSettings,
showUpload = true,
showReset = false,
showSave = true,
showCollect = true,
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const submitEnabled = canSubmit ?? Boolean(value.trim());
const iconButtonClass = `p-2 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-600 text-slate-400' : 'hover:bg-gray-200 text-gray-500'}`;
const handleKeyDown = (val, context) => {
if (context?.e?.key === 'Enter') {
// 阻止默认的换行行为(如果是 Textarea)
context.e.preventDefault();
// 模拟发送请求
onKeyDown(val)
}
};
return (
<div className={`border-t p-4 ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className={`flex items-end gap-3 rounded-2xl p-3 shadow-inner ${isDark ? 'bg-slate-700/50' : 'bg-gray-100'}`}>
<Textarea
value={value}
onChange={(nextValue) => onChange(String(nextValue))}
onKeydown={handleKeyDown}
placeholder={placeholder}
autosize={{ minRows: 1, maxRows: 15 }}
disabled={isLoading}
className={`flex-1 !bg-transparent !border-0 !shadow-none qa-composer-textarea [&]:!border-0 [&]:!shadow-none [&]:!outline-none [&]:focus:!border-0 [&]:focus:!shadow-none [&]:focus:!outline-none [&_.t-textarea]:!border-0 [&_.t-textarea]:!shadow-none [&_.t-textarea]:!outline-none [&_.t-textarea:hover]:!border-0 [&_.t-textarea:hover]:!shadow-none [&_.t-textarea--focused]:!border-0 [&_.t-textarea--focused]:!shadow-none [&_.t-is-focused]:!border-0 [&_.t-is-focused]:!shadow-none [&_.t-textarea__inner]:!bg-transparent [&_.t-textarea__inner]:!border-0 [&_.t-textarea__inner]:!shadow-none [&_.t-textarea__inner]:!outline-none [&_.t-textarea__inner]:!resize-none [&_.t-textarea__inner]:!min-h-[40px] [&_.t-textarea__inner]:!p-0 [&_.t-textarea__inner:hover]:!border-0 [&_.t-textarea__inner:hover]:!shadow-none [&_.t-textarea__inner:focus]:!border-0 [&_.t-textarea__inner:focus]:!shadow-none [&_.t-textarea__inner:focus]:!outline-none [&_.t-textarea__inner]:disabled:!cursor-not-allowed ${isDark
? '[&_.t-textarea__inner]:!text-white [&_.t-textarea__inner]:placeholder:!text-slate-500'
: '[&_.t-textarea__inner]:!text-gray-800 [&_.t-textarea__inner]:placeholder:!text-gray-400'
}`}
/>
<div className="flex items-center gap-2">
{/* {showUpload && (
<button onClick={onUpload} className={iconButtonClass} title="上传附件" type="button">
<Upload className="w-5 h-5" />
</button>
)}
{showReset && (
<button onClick={onReset} className={iconButtonClass} title="重新提问" type="button">
<RotateCcw className="w-5 h-5" />
</button>
)}
{showSave && (
<button onClick={onSave} className={iconButtonClass} title="保存记录" type="button">
<Bookmark className="w-5 h-5" />
</button>
)}
{showCollect && (
<button onClick={onCollect} className={iconButtonClass} title="加入收藏" type="button">
<Bookmark className="w-5 h-5" />
</button>
)} */}
{onOpenSettings && (
<button onClick={onOpenSettings} className={iconButtonClass} title="模型参数设置" type="button">
<Settings className="w-5 h-5" />
</button>
)}
<button
onClick={isLoading ? onStop : onSubmit}
disabled={!isLoading && !submitEnabled}
className={`px-4 py-2 rounded-xl font-medium transition-all flex items-center gap-2 shadow-sm ${isLoading
? isDark
? 'bg-rose-600 text-white qa-stop-breathing'
: 'bg-red-600 text-white qa-stop-breathing'
: submitEnabled
? isDark
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-red-500 hover:bg-red-600 text-white'
: isDark
? 'bg-slate-600 text-slate-400 cursor-not-allowed'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
type="button"
>
{isLoading ? <Square className="w-4 h-4 fill-current" /> : <Send className="w-4 h-4" />}
{isLoading ? '停止' : '发送'}
</button>
</div>
</div>
</div>
);
};
+5
View File
@@ -0,0 +1,5 @@
import { Outlet, Navigate } from 'react-router-dom';
export default function QALayout() {
return <Outlet />;
}
+121
View File
@@ -0,0 +1,121 @@
import { CircleHelp, Bot, Layers } from 'lucide-react';
import { useTheme } from '../../contexts/ThemeContext';
type QAProvider = 'general' | 'agent' | 'multi-agent';
type ViewMode = 'single' | 'compare';
interface QATypeSwitchProps {
qaProvider: QAProvider; // 当前问答模式:'general' 通用问答 / 'agent' 智能体问答 / 'multi-agent' 协同分析
setQaProvider: (provider: QAProvider) => void; // 设置问答模式的回调函数
selectedAgentName?: string; // 当选择智能体问答时,当前选中的智能体名称
viewMode?: ViewMode; // 当前视图模式(仅通用问答支持):'single' 单一模式 或 'compare' 对比模式
setViewMode?: (mode: ViewMode) => void; // 设置视图模式的回调函数(仅通用问答可用)
}
export default function QATypeSwitch({
qaProvider,
setQaProvider,
selectedAgentName,
viewMode = 'single',
setViewMode,
}: QATypeSwitchProps) {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="flex items-center gap-4">
{/* 问答模式切换 */}
<div className={`flex rounded-xl p-1 ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<button
onClick={() => setQaProvider('general')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${
qaProvider === 'general'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
<CircleHelp className="w-4 h-4" />
通用问答
</button>
<button
onClick={() => setQaProvider('agent')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${
qaProvider === 'agent'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
<Bot className="w-4 h-4" />
智能体问答
</button>
<button
onClick={() => setQaProvider('multi-agent')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${
qaProvider === 'multi-agent'
? isDark
? 'bg-green-600 text-white'
: 'bg-green-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-green-600'
}`}
>
<Layers className="w-4 h-4" />
协同分析
</button>
</div>
{/* 当前智能体指示 */}
{qaProvider === 'agent' && selectedAgentName && (
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full ${isDark ? 'bg-blue-600/20' : 'bg-blue-50'}`}>
<Bot className={`w-4 h-4 ${isDark ? 'text-blue-400' : 'text-blue-600'}`} />
<span className={`text-sm font-medium ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>
{selectedAgentName}
</span>
</div>
)}
{/* 对比模式切换 - 仅通用问答 */}
{qaProvider === 'general' && setViewMode && (
<div className={`flex rounded-xl p-1 ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<button
onClick={() => setViewMode('single')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
viewMode === 'single'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
单一模式
</button>
<button
onClick={() => setViewMode('compare')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
viewMode === 'compare'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
对比模式
</button>
</div>
)}
</div>
);
}
File diff suppressed because it is too large. Load diff
+638
View File
@@ -0,0 +1,638 @@
import React, { useState, useEffect, useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useTheme } from '../../contexts/ThemeContext';
import {
Edit2, Plus, Trash2, Check, X, ChevronDown, ChevronRight,
Target, Users, Activity, BookOpen, Zap, AlertTriangle,
Settings, FileText, Info, RefreshCw, AlertCircle, Brain, Save,
} from 'lucide-react';
interface FieldRequirement {
title: string;
goal: string;
must_cover: string[];
done_criteria: string[];
blockers: string[];
dependencies: string[];
evidence_needs: string[];
preferred_agents: string[];
style_notes: string;
min_chars?: number;
min_items?: number;
}
interface AgentData {
id: string;
name: string;
dimension: string;
expertise: string;
relevant_fields: string[];
analysis_contract: string[];
}
interface CoordinatorConfig {
system_prompt_prefix?: string;
round1_guidance?: string;
middle_round_guidance?: string;
final_round_guidance?: string;
speaker_selection_rules?: string;
focus_instruction_rules?: string;
dependency_driving_rules?: string[];
consistency_check_rules?: string[];
revise_prompt_prefix?: string;
}
interface TemplateData {
template_id: string;
template_name: string;
template_brief: string;
usage_notes: string[];
report_schema: Record<string, string>;
report_examples: Record<string, string>;
field_requirements: Record<string, FieldRequirement>;
agents: AgentData[];
coordinator_config: CoordinatorConfig;
}
const FIELD_LABELS: Record<string, string> = {
template_id: '模板ID',
template_name: '模板名称',
template_brief: '模板简介',
usage_notes: '使用说明',
report_schema: '报告字段结构',
report_examples: '报告字段示例',
field_requirements: '字段要求配置',
agents: '智能体配置',
coordinator_config: '协调者配置',
title: '字段标题',
goal: '字段目标',
must_cover: '必须覆盖内容',
done_criteria: '完成标准',
blockers: '阻塞条件',
dependencies: '依赖字段',
evidence_needs: '证据需求',
preferred_agents: '推荐智能体',
style_notes: '风格说明',
min_chars: '最小字符数',
min_items: '最小条目数',
id: '智能体ID',
name: '智能体名称',
dimension: '分析维度',
expertise: '专业能力',
relevant_fields: '关联字段',
analysis_contract: '分析契约',
system_prompt_prefix: '系统提示词前缀',
round1_guidance: '第一轮指导',
middle_round_guidance: '中间轮指导',
final_round_guidance: '最终轮指导',
speaker_selection_rules: '发言者选择规则',
focus_instruction_rules: '焦点指令规则',
dependency_driving_rules: '依赖驱动规则',
consistency_check_rules: '一致性检查规则',
revise_prompt_prefix: '修改提示前缀',
};
const REPORT_SCHEMA_LABELS: Record<string, string> = {
objective_analysis: '一、目的分析',
target_actor_analysis: '二、目标行为体分析',
expected_behavior_drivers: '三、预期行为及行为驱动因素分析',
narrative_theme_design: '四、叙事主题设计',
action_plan_list: '五、认知行动方案列表',
};
interface AgentVisual {
Icon: React.ElementType;
lightCard: string;
darkCard: string;
iconLight: string;
iconDark: string;
tagLight: string;
tagDark: string;
}
const AGENT_VISUALS: Record<string, AgentVisual> = {
objective: {
Icon: Target,
lightCard: 'from-blue-50 to-sky-50 border-blue-200 hover:border-blue-400 hover:shadow-blue-200/60',
darkCard: 'from-slate-900/80 to-blue-950/20 border-slate-700/70 hover:border-blue-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-blue-100 text-blue-600',
iconDark: 'bg-slate-800/90 text-blue-300 ring-1 ring-blue-500/20',
tagLight: 'bg-blue-100 text-blue-700 border-blue-200',
tagDark: 'bg-slate-800/70 text-blue-200 border-blue-500/25',
},
actor: {
Icon: Users,
lightCard: 'from-emerald-50 to-teal-50 border-emerald-200 hover:border-emerald-400 hover:shadow-emerald-200/60',
darkCard: 'from-slate-900/80 to-emerald-950/20 border-slate-700/70 hover:border-emerald-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-emerald-100 text-emerald-600',
iconDark: 'bg-slate-800/90 text-emerald-300 ring-1 ring-emerald-500/20',
tagLight: 'bg-emerald-100 text-emerald-700 border-emerald-200',
tagDark: 'bg-slate-800/70 text-emerald-200 border-emerald-500/25',
},
behavior: {
Icon: Activity,
lightCard: 'from-amber-50 to-orange-50 border-amber-200 hover:border-amber-400 hover:shadow-amber-200/60',
darkCard: 'from-slate-900/80 to-amber-950/20 border-slate-700/70 hover:border-amber-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-amber-100 text-amber-600',
iconDark: 'bg-slate-800/90 text-amber-300 ring-1 ring-amber-500/20',
tagLight: 'bg-amber-100 text-amber-700 border-amber-200',
tagDark: 'bg-slate-800/70 text-amber-200 border-amber-500/25',
},
narrative: {
Icon: BookOpen,
lightCard: 'from-purple-50 to-violet-50 border-purple-200 hover:border-purple-400 hover:shadow-purple-200/60',
darkCard: 'from-slate-900/80 to-purple-950/20 border-slate-700/70 hover:border-purple-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-purple-100 text-purple-600',
iconDark: 'bg-slate-800/90 text-purple-300 ring-1 ring-purple-500/20',
tagLight: 'bg-purple-100 text-purple-700 border-purple-200',
tagDark: 'bg-slate-800/70 text-purple-200 border-purple-500/25',
},
action: {
Icon: Zap,
lightCard: 'from-indigo-50 to-slate-50 border-indigo-200 hover:border-indigo-400 hover:shadow-indigo-200/60',
darkCard: 'from-slate-900/80 to-indigo-950/20 border-slate-700/70 hover:border-indigo-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-indigo-100 text-indigo-600',
iconDark: 'bg-slate-800/90 text-indigo-300 ring-1 ring-indigo-500/20',
tagLight: 'bg-indigo-100 text-indigo-700 border-indigo-200',
tagDark: 'bg-slate-800/70 text-indigo-200 border-indigo-500/25',
},
risk: {
Icon: AlertTriangle,
lightCard: 'from-rose-50 to-pink-50 border-rose-200 hover:border-rose-400 hover:shadow-rose-200/60',
darkCard: 'from-slate-900/80 to-rose-950/20 border-slate-700/70 hover:border-rose-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-rose-100 text-rose-600',
iconDark: 'bg-slate-800/90 text-rose-300 ring-1 ring-rose-500/20',
tagLight: 'bg-rose-100 text-rose-700 border-rose-200',
tagDark: 'bg-slate-800/70 text-rose-200 border-rose-500/25',
},
};
const DEFAULT_VISUAL: AgentVisual = {
Icon: Brain,
lightCard: 'from-gray-50 to-slate-50 border-gray-200 hover:border-gray-400 hover:shadow-gray-200/60',
darkCard: 'from-slate-900/80 to-slate-800/50 border-slate-700/70 hover:border-slate-500/50 hover:shadow-slate-950/50',
iconLight: 'bg-gray-100 text-gray-600',
iconDark: 'bg-slate-800/90 text-slate-300 ring-1 ring-slate-500/20',
tagLight: 'bg-gray-100 text-gray-700 border-gray-200',
tagDark: 'bg-slate-800/70 text-slate-200 border-slate-500/25',
};
const mapFieldLabel = (v: string) => REPORT_SCHEMA_LABELS[v] || v;
function EditableText({
value,
onChange,
isDark,
multiline = false,
}: {
value: string;
onChange: (v: string) => void;
isDark: boolean;
multiline?: boolean;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const confirm = () => { onChange(draft); setEditing(false); };
const cancel = () => { setDraft(value); setEditing(false); };
const inputCls = isDark
? 'bg-slate-800 border-blue-500/60 text-slate-100 focus:border-blue-400'
: 'bg-white border-blue-300 text-gray-800 focus:border-blue-500';
if (editing) {
return (
<div className="space-y-2">
{multiline ? (
<textarea
autoFocus
value={draft}
onChange={e => setDraft(e.target.value)}
rows={5}
className={`w-full px-3 py-2 text-sm rounded-lg border outline-none resize-y font-mono leading-relaxed transition-colors ${inputCls}`}
/>
) : (
<input
autoFocus
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') confirm(); if (e.key === 'Escape') cancel(); }}
className={`w-full px-3 py-1.5 text-sm rounded-lg border outline-none transition-colors ${inputCls}`}
/>
)}
<div className="flex gap-2">
<button onClick={confirm} className="flex items-center gap-1 px-3 py-1 text-xs rounded-lg bg-green-500 hover:bg-green-400 text-white transition-colors">
<Check className="w-3 h-3" /> 确认
</button>
<button onClick={cancel} className={`flex items-center gap-1 px-3 py-1 text-xs rounded-lg border transition-colors ${isDark ? 'border-slate-600 text-slate-400 hover:text-slate-200' : 'border-gray-300 text-gray-500 hover:text-gray-700'}`}>
<X className="w-3 h-3" /> 取消
</button>
</div>
</div>
);
}
return (
<div className="group/editable flex items-start gap-2 rounded-lg px-2 py-1.5 -mx-2 -my-1.5 transition-all duration-200 hover:bg-black/5 dark:hover:bg-white/5 cursor-pointer" onClick={() => { setDraft(value); setEditing(true); }}>
<span className={`flex-1 text-sm leading-relaxed break-words ${isDark ? 'text-slate-300' : 'text-gray-700'} ${multiline ? 'whitespace-pre-wrap font-mono text-xs' : ''}`}>
{value || <span className={isDark ? 'text-slate-600 italic' : 'text-gray-300 italic'}>(空)</span>}
</span>
<Edit2 className={`flex-shrink-0 w-3.5 h-3.5 mt-0.5 opacity-0 group-hover/editable:opacity-100 transition-all duration-200 ${isDark ? 'text-blue-400' : 'text-blue-500'}`} />
</div>
);
}
function TagList({
tags,
onChange,
isDark,
tagLight = 'bg-sky-50 text-sky-700 border-sky-200',
tagDark = 'bg-slate-800/70 text-sky-200 border-sky-500/25',
}: {
tags: string[];
onChange: (tags: string[]) => void;
isDark: boolean;
tagLight?: string;
tagDark?: string;
}) {
const [editIdx, setEditIdx] = useState<number | null>(null);
const [editVal, setEditVal] = useState('');
const [adding, setAdding] = useState(false);
const [addVal, setAddVal] = useState('');
const tagCls = isDark ? tagDark : tagLight;
const inputCls = isDark
? 'bg-slate-800 border-blue-500/60 text-slate-100 placeholder-slate-500 focus:border-blue-400'
: 'bg-white border-blue-400 text-gray-800 placeholder-gray-400 focus:border-blue-500';
const confirmEdit = () => {
if (editIdx === null) return;
const next = [...tags];
if (editVal.trim()) next[editIdx] = editVal.trim();
else next.splice(editIdx, 1);
onChange(next);
setEditIdx(null);
};
const confirmAdd = () => {
if (addVal.trim()) onChange([...tags, addVal.trim()]);
setAdding(false);
setAddVal('');
};
return (
<div className="flex flex-wrap gap-1.5 items-center">
{tags.map((tag, i) =>
editIdx === i ? (
<span key={i} className="inline-flex items-center gap-1">
<input
autoFocus
value={editVal}
onChange={e => setEditVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') confirmEdit(); if (e.key === 'Escape') setEditIdx(null); }}
className={`px-2 py-0.5 text-xs rounded border outline-none w-36 transition-colors ${inputCls}`}
/>
<button onClick={confirmEdit} className="text-green-500 hover:text-green-400 transition-colors"><Check className="w-3 h-3" /></button>
<button onClick={() => setEditIdx(null)} className="text-red-400 hover:text-red-300 transition-colors"><X className="w-3 h-3" /></button>
</span>
) : (
<span key={i} className="group/tag inline-flex items-center gap-1">
<span
onClick={() => { setEditIdx(i); setEditVal(tag); setAdding(false); }}
className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-xs rounded-full border cursor-pointer transition-all duration-200 hover:scale-105 hover:opacity-90 ${tagCls}`}
>
{tag}
<button
onClick={e => { e.stopPropagation(); onChange(tags.filter((_, j) => j !== i)); }}
className="opacity-0 group-hover/tag:opacity-100 transition-opacity hover:text-red-500 ml-0.5"
>
<X className="w-2.5 h-2.5" />
</button>
</span>
</span>
)
)}
{adding ? (
<span className="inline-flex items-center gap-1">
<input
autoFocus
value={addVal}
onChange={e => setAddVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') confirmAdd(); if (e.key === 'Escape') { setAdding(false); setAddVal(''); } }}
placeholder="输入内容…"
className={`px-2 py-0.5 text-xs rounded border outline-none w-36 transition-colors ${inputCls}`}
/>
<button onClick={confirmAdd} className="text-green-500 hover:text-green-400 transition-colors"><Check className="w-3 h-3" /></button>
<button onClick={() => { setAdding(false); setAddVal(''); }} className="text-red-400 hover:text-red-300 transition-colors"><X className="w-3 h-3" /></button>
</span>
) : (
<button
onClick={() => { setAdding(true); setEditIdx(null); }}
className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border border-dashed transition-all duration-200 hover:scale-105 ${isDark ? 'border-slate-600 text-slate-500 hover:border-blue-500 hover:text-blue-400' : 'border-gray-300 text-gray-400 hover:border-blue-400 hover:text-blue-500'}`}
>
<Plus className="w-3 h-3" /> 添加
</button>
)}
</div>
);
}
function AgentCard({
agent,
onUpdate,
onDelete,
isDark,
}: {
agent: AgentData;
onUpdate: (a: AgentData) => void;
onDelete: () => void;
isDark: boolean;
}) {
const [editing, setEditing] = useState(false);
const [hovered, setHovered] = useState(false);
const vis = AGENT_VISUALS[agent.id] ?? DEFAULT_VISUAL;
const { Icon } = vis;
const cardCls = isDark ? vis.darkCard : vis.lightCard;
const iconCls = isDark ? vis.iconDark : vis.iconLight;
const tagCls = isDark ? vis.tagDark : vis.tagLight;
const handleFieldChange = (field: keyof AgentData, value: unknown) => {
onUpdate({ ...agent, [field]: value });
};
return (
<>
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={`relative rounded-xl border p-4 bg-gradient-to-br transition-all duration-300 ${cardCls} ${hovered ? 'scale-[1.02] shadow-lg -translate-y-0.5' : 'scale-100 shadow-sm'}`}
>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-transform duration-300 ${iconCls} ${hovered ? 'scale-110 rotate-3' : ''}`}>
<Icon className="w-5 h-5" />
</div>
<div>
<p className={`font-semibold text-sm ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{agent.name}</p>
<p className={`text-xs mt-0.5 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{agent.dimension}</p>
</div>
</div>
<div className={`flex items-center gap-1 transition-opacity duration-200 ${hovered ? 'opacity-100' : 'opacity-0'}`}>
<button
onClick={() => setEditing(true)}
className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-700 text-slate-400 hover:text-blue-300' : 'hover:bg-white/70 text-gray-400 hover:text-blue-500'}`}
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={onDelete}
className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-700 text-slate-400 hover:text-red-400' : 'hover:bg-white/70 text-gray-400 hover:text-red-500'}`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<code className={`inline-block text-xs px-2 py-0.5 rounded-full border mb-2 font-mono ${tagCls}`}>{agent.id}</code>
<div className="mb-3">
<p className={`text-xs leading-relaxed mb-1.5 line-clamp-2 ${isDark ? 'text-slate-400' : 'text-gray-600'}`}>{agent.expertise.slice(0, 80)}</p>
</div>
{agent.relevant_fields.length > 0 && (
<div className="mb-2">
<p className={`text-xs font-medium mb-1.5 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.relevant_fields}</p>
<div className="flex flex-wrap gap-1">
{agent.relevant_fields.map(f => (
<span key={f} className={`text-xs px-2 py-0.5 rounded-full border ${tagCls}`}>{f}</span>
))}
</div>
</div>
)}
</div>
{editing && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setEditing(false)}>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div
className={`relative w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-2xl border shadow-2xl ${isDark ? 'bg-slate-900 border-slate-700' : 'bg-white border-gray-200'}`}
onClick={e => e.stopPropagation()}
>
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
<h2 className={`font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>编辑智能体</h2>
<button onClick={() => setEditing(false)} className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-800 text-slate-400' : 'hover:bg-gray-100 text-gray-500'}`}><X className="w-4 h-4" /></button>
</div>
<div className="p-6 space-y-4">
{(['id', 'name', 'dimension'] as const).map(k => (
<div key={k} className="space-y-1">
<label className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS[k]}</label>
<EditableText value={agent[k]} onChange={v => handleFieldChange(k, v)} isDark={isDark} />
</div>
))}
<div className="space-y-1">
<label className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.expertise}</label>
<EditableText value={agent.expertise} onChange={v => handleFieldChange('expertise', v)} isDark={isDark} multiline />
</div>
<div className="space-y-1.5">
<label className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.relevant_fields}</label>
<TagList tags={agent.relevant_fields} onChange={v => handleFieldChange('relevant_fields', v)} isDark={isDark} tagLight="bg-blue-50 text-blue-700 border-blue-200" tagDark="bg-slate-800/70 text-blue-200 border-blue-500/25" />
</div>
<div className="space-y-1.5">
<label className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.analysis_contract}</label>
<TagList tags={agent.analysis_contract} onChange={v => handleFieldChange('analysis_contract', v)} isDark={isDark} tagLight="bg-violet-50 text-violet-700 border-violet-200" tagDark="bg-slate-800/70 text-violet-200 border-violet-500/25" />
</div>
</div>
<div className={`flex justify-end gap-3 px-6 py-4 border-t ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
<button onClick={() => setEditing(false)} className={`px-4 py-2 text-sm rounded-lg border transition-colors ${isDark ? 'border-slate-600 text-slate-400 hover:text-slate-200' : 'border-gray-300 text-gray-500 hover:text-gray-700'}`}>取消</button>
<button onClick={() => setEditing(false)} className="px-4 py-2 text-sm rounded-lg bg-blue-500 hover:bg-blue-400 text-white transition-colors flex items-center gap-1.5">
<Check className="w-3.5 h-3.5" /> 保存
</button>
</div>
</div>
</div>
)}
</>
);
}
function SectionWrapper({
title,
icon: Icon,
children,
isDark,
defaultOpen = true,
}: {
title: string;
icon: React.ElementType;
children: React.ReactNode;
isDark: boolean;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className={`rounded-xl border overflow-hidden transition-all duration-200 ${isDark ? 'bg-slate-800/40 border-slate-700/60' : 'bg-white border-gray-200 shadow-sm'}`}>
<button
onClick={() => setOpen(o => !o)}
className={`w-full flex items-center justify-between px-5 py-4 transition-colors duration-200 ${isDark ? 'hover:bg-slate-700/30' : 'hover:bg-gray-50'}`}
>
<div className="flex items-center gap-2.5">
<div className={`w-7 h-7 rounded-lg flex items-center justify-center ${isDark ? 'bg-slate-700' : 'bg-red-50'}`}>
<Icon className={`w-4 h-4 ${isDark ? 'text-blue-400' : 'text-red-500'}`} />
</div>
<span className={`font-semibold text-sm ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{title}</span>
</div>
{open
? <ChevronDown className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-gray-400'}`} />
: <ChevronRight className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-gray-400'}`} />}
</button>
<div className={`grid transition-all duration-300 ease-in-out ${open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-70'}`}>
<div className="overflow-hidden">
<div className="px-5 pb-5 space-y-4">{children}</div>
</div>
</div>
</div>
);
}
export default function Template6BEditor() {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [template, setTemplate] = useState<TemplateData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
useEffect(() => {
fetch('https://ch1.b.uat.4.cn/zncmweb/templates/6b_template.json')
.then(r => r.json())
.then((data: TemplateData) => { setTemplate(data); setLoading(false); })
.catch(e => { setError(e.message); setLoading(false); });
}, []);
const update = <K extends keyof TemplateData>(key: K, value: TemplateData[K]) => {
setTemplate(prev => prev ? { ...prev, [key]: value } : null);
setDirty(true);
};
const handleSave = () => {
if (!template) return;
console.log('[Template6BEditor 保存]', JSON.stringify(template, null, 2));
window.alert('配置已保存,详情请查看控制台');
setDirty(false);
};
if (loading) {
return (
<div className={`flex items-center justify-center min-h-64 ${isDark ? 'text-slate-400' : 'text-gray-400'}`}>
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
<span className="text-sm">加载模板中…</span>
</div>
);
}
if (error || !template) {
return (
<div className={`flex items-center gap-2 p-4 rounded-xl ${isDark ? 'bg-red-950/30 text-red-400 border border-red-900/50' : 'bg-red-50 text-red-600 border border-red-200'}`}>
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">模板加载失败:{error}</span>
</div>
);
}
return (
<div className="h-[calc(100vh-24px)] max-h-[calc(100vh-24px)] flex flex-col max-w-5xl mx-auto px-1 pb-3">
<div className={`flex-shrink-0 flex items-center justify-between gap-4 py-3 border-b ${isDark ? 'border-slate-800' : 'border-gray-200'}`}>
<div>
<h1 className={`text-xl font-bold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>模板配置编辑器</h1>
<p className={`text-sm mt-0.5 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>六步行动方案 · {template.template_id}</p>
</div>
<div className="flex items-center gap-3">
{dirty && (
<div className={`flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border ${isDark ? 'bg-amber-900/20 border-amber-700/40 text-amber-300' : 'bg-amber-50 border-amber-200 text-amber-600'}`}>
<Info className="w-3.5 h-3.5" />
已有未保存修改
</div>
)}
<button
onClick={handleSave}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-xl border shadow-sm transition-all duration-200 hover:scale-[1.02] ${isDark ? 'bg-slate-900 border-slate-700 text-slate-100 hover:border-blue-500/40' : 'bg-white border-gray-200 text-gray-800 hover:border-blue-300'}`}
>
<Save className="w-4 h-4" /> 保存
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto py-3 pr-1 space-y-4">
<SectionWrapper title="基本信息" icon={FileText} isDark={isDark}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{(['template_id', 'template_name'] as const).map(k => (
<div key={k} className="space-y-1.5">
<span className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS[k]}</span>
<EditableText value={template[k]} onChange={v => update(k, v)} isDark={isDark} />
</div>
))}
</div>
<div className="space-y-1.5">
<span className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.template_brief}</span>
<EditableText value={template.template_brief} onChange={v => update('template_brief', v)} isDark={isDark} multiline />
</div>
</SectionWrapper>
<SectionWrapper title={FIELD_LABELS.usage_notes} icon={Info} isDark={isDark}>
<TagList
tags={template.usage_notes}
onChange={v => update('usage_notes', v)}
isDark={isDark}
tagLight="bg-amber-50 text-amber-700 border-amber-200"
tagDark="bg-slate-800/70 text-amber-200 border-amber-500/25"
/>
</SectionWrapper>
<SectionWrapper title={FIELD_LABELS.report_schema} icon={Settings} isDark={isDark}>
<div className="space-y-3">
{Object.entries(template.report_schema).map(([key, desc]) => (
<div key={key} className={`group/row rounded-lg border px-4 py-3 transition-all duration-200 hover:scale-[1.005] ${isDark ? 'border-slate-700/50 bg-slate-800/30 hover:border-slate-600' : 'border-gray-200 bg-gray-50/50 hover:border-gray-300 hover:shadow-sm'}`}>
<div className="flex items-center gap-2 mb-1.5">
<code className={`text-xs px-2 py-0.5 rounded font-mono ${isDark ? 'bg-slate-700 text-slate-400' : 'bg-gray-200 text-gray-500'}`}>{key}</code>
<span className={`text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>{REPORT_SCHEMA_LABELS[key] || key}</span>
</div>
<EditableText
value={desc}
onChange={v => update('report_schema', { ...template.report_schema, [key]: v })}
isDark={isDark}
multiline
/>
</div>
))}
</div>
</SectionWrapper>
<SectionWrapper title={FIELD_LABELS.agents} icon={Brain} isDark={isDark}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{template.agents.map((agent, i) => (
<AgentCard
key={agent.id + i}
agent={agent}
onUpdate={updated => {
const next = [...template.agents];
next[i] = updated;
update('agents', next);
}}
onDelete={() => {
if (template.agents.length <= 1) {
window.alert('智能体最少必须保留 1 个');
return;
}
update('agents', template.agents.filter((_, j) => j !== i));
}}
isDark={isDark}
/>
))}
</div>
</SectionWrapper>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,235 @@
.agent-card {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 14px;
margin-bottom: 16px;
overflow: hidden;
box-shadow: var(--shadow-card);
}
.agent-card-header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
background: var(--surface-hover);
}
.agent-card-name {
font-weight: 700;
font-size: 14px;
color: var(--text);
}
.agent-card-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-left: auto;
}
/* Card sections */
.agent-card-section {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.agent-card-section:last-child {
border-bottom: none;
}
.agent-card-section-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
margin-bottom: 8px;
}
/* Streaming card highlight */
.agent-card-streaming {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
/* Streaming badge */
.badge-streaming {
background: var(--accent-soft);
color: var(--accent);
border: 1px solid var(--accent-soft-strong);
border-radius: 4px;
padding: 1px 6px;
font-size: 10px;
font-weight: 600;
animation: pulse-badge 1.5s ease-in-out infinite;
}
@keyframes pulse-badge {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Streaming plain-text area */
.streaming-text {
font-size: 13px;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.7;
}
/* Blinking cursor */
.streaming-cursor {
display: inline-block;
width: 2px;
height: 0.9em;
background: var(--accent);
margin-left: 2px;
vertical-align: text-bottom;
animation: blink-cursor 0.9s step-end infinite;
}
@keyframes blink-cursor {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Content */
.agent-card-content-text {
font-size: 13px;
color: var(--text);
}
/* Thinking section */
.agent-card-thinking-toggle {
display: flex;
align-items: center;
gap: 6px;
background: transparent;
border: none;
padding: 0;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
margin-bottom: 8px;
}
.agent-card-thinking-toggle:hover {
color: var(--text);
}
.agent-card-thinking-text {
font-size: 12px;
color: var(--text-muted);
background: rgba(139, 148, 158, 0.06);
border-left: 2px solid var(--border);
padding: 8px 12px;
border-radius: 0 4px 4px 0;
}
.agent-card-thinking-text .markdown-body {
color: var(--text-muted);
font-size: 12px;
font-style: italic;
}
.agent-card-thinking-text .markdown-body strong {
color: var(--text-muted);
}
/* Dimension output */
.dim-output-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.dim-output-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.dim-output-key {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--accent);
}
.dim-output-value {
font-size: 13px;
color: var(--text);
}
/* Retrieval */
.retrieval-source-group {
margin-bottom: 10px;
}
.retrieval-source-group:last-child {
margin-bottom: 0;
}
.retrieval-query-label {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 6px;
}
.retrieval-adopted-badge {
background: rgba(63, 185, 80, 0.15);
color: var(--green);
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
}
.retrieval-not-adopted-badge {
background: rgba(139, 148, 158, 0.1);
color: var(--text-muted);
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
}
.retrieval-item {
display: flex;
gap: 6px;
margin-bottom: 4px;
align-items: flex-start;
}
.retrieval-item-num {
flex-shrink: 0;
background: var(--accent-soft);
color: var(--accent);
border-radius: 3px;
padding: 0 4px;
font-size: 10px;
font-family: monospace;
line-height: 18px;
margin-top: 1px;
}
.retrieval-item-text {
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
word-break: break-word;
}
.retrieval-source-label {
font-size: 10px;
color: var(--text-muted);
opacity: 0.7;
margin-left: 4px;
}
@@ -0,0 +1,193 @@
import React, { useState, useEffect, useRef } from 'react';
import './AgentCard.css';
import MarkdownContent from './MarkdownContent';
function getPhaseBadgeClass(phase?: string): string {
switch (phase) {
case 'starting': return 'badge badge-starting';
case 'diverging': return 'badge badge-diverging';
case 'closing': return 'badge badge-closing';
case 'done': return 'badge badge-done';
default: return 'badge badge-default';
}
}
const PHASE_LABELS: Record<string, string> = {
starting: '启动',
diverging: '辩论',
closing: '收敛',
done: '完成',
};
type RetrievalLogItem = {
query?: string;
source_name?: string;
source_id?: string;
source?: string;
adopted?: boolean;
content?: any[];
content_ids?: string[];
};
type Props = {
agentId: string;
agentName?: string;
dimension?: string;
round: number;
phase: string;
content?: string;
thinking?: string;
dimensionOutput?: Record<string, any>;
retrievalLog?: RetrievalLogItem[];
retrievalCount?: number;
referenceMap?: Record<string, string>;
streaming?: boolean;
};
export default function AgentCard({
agentId,
agentName,
dimension,
round,
phase,
content,
thinking,
dimensionOutput,
retrievalLog,
retrievalCount = 0,
referenceMap,
streaming = false,
}: Props): JSX.Element {
const [thinkingOpen, setThinkingOpen] = useState(false);
const autoOpenedRef = useRef(false);
useEffect(() => {
if (streaming && thinking && thinking.length > 0 && !autoOpenedRef.current) {
setThinkingOpen(true);
autoOpenedRef.current = true;
}
}, [streaming, thinking]);
const streamingPhase = streaming
? (content && content.length > 0 ? 'content' : 'thinking')
: null;
const hasThinking = thinking && thinking.trim().length > 0;
const hasRetrieval = retrievalCount > 0 && retrievalLog && retrievalLog.length > 0;
const hasDimOutput = dimensionOutput && Object.keys(dimensionOutput).length > 0;
return (
<div className={`agent-card${streaming ? ' agent-card-streaming' : ''}`}>
<div className="agent-card-header">
<span className="agent-card-name">{agentName || agentId}</span>
<div className="agent-card-badges">
{dimension && (
<span className="badge badge-default">{dimension}</span>
)}
<span className="badge badge-default">第 {round} 轮</span>
<span className={getPhaseBadgeClass(phase)}>
{PHASE_LABELS[phase] || phase}
</span>
{streaming && (
<span className="badge badge-streaming">生成中</span>
)}
</div>
</div>
{(hasThinking || streamingPhase === 'thinking') && (
<div className="agent-card-section">
<button
className="agent-card-thinking-toggle"
onClick={() => setThinkingOpen(o => !o)}
>
<span>{thinkingOpen ? '▾' : '▸'}</span>
<span>思考过程</span>
{streamingPhase === 'thinking' && (
<span className="streaming-cursor" style={{ marginLeft: 6 }} />
)}
</button>
{thinkingOpen && (
<div className="agent-card-thinking-text">
{streaming ? (
<span className="streaming-text" style={{ fontStyle: 'italic', fontSize: 12 }}>
{thinking}
</span>
) : (
<MarkdownContent content={thinking} />
)}
</div>
)}
</div>
)}
{(content || streamingPhase === 'content') && (
<div className="agent-card-section">
<div className="agent-card-section-title">分析内容</div>
<div className="agent-card-content-text">
{streaming ? (
<div className="streaming-text">
{content}
{streamingPhase === 'content' && <span className="streaming-cursor" />}
</div>
) : (
<MarkdownContent content={content} referenceMap={referenceMap} />
)}
</div>
</div>
)}
{hasDimOutput && (
<div className="agent-card-section">
<div className="agent-card-section-title">维度输出</div>
<div className="dim-output-grid">
{Object.entries(dimensionOutput).map(([key, value]) => (
<div key={key} className="dim-output-item">
<div className="dim-output-key">{key}</div>
<div className="dim-output-value">
<MarkdownContent
content={typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
/>
</div>
</div>
))}
</div>
</div>
)}
{hasRetrieval && (
<div className="agent-card-section">
<div className="agent-card-section-title">检索信息 ({retrievalCount})</div>
{retrievalLog.map((log, logIdx) => (
<div key={logIdx} className="retrieval-source-group">
<div className="retrieval-query-label">
<span>"{log.query}"</span>
{(log.source_name || log.source_id || log.source) && (
<span className="retrieval-source-label">{log.source_name || log.source_id || log.source}</span>
)}
{log.adopted !== undefined && (
<span className={log.adopted ? 'retrieval-adopted-badge' : 'retrieval-not-adopted-badge'}>
{log.adopted ? '已采用' : '未采用'}
</span>
)}
</div>
{log.content && log.content.map((item, itemIdx) => {
const refNum = log.content_ids ? log.content_ids[itemIdx] : null;
const refKey = refNum ? Object.entries(referenceMap || {}).find(([, v]) => v === refNum)?.[0] : null;
return (
<div key={itemIdx} className="retrieval-item">
{refKey && <span className="retrieval-item-num">[{refKey}]</span>}
<div className="retrieval-item-text">
{typeof item === 'string'
? (item.length > 200 ? item.slice(0, 200) + '…' : item)
: JSON.stringify(item).slice(0, 200)}
</div>
</div>
);
})}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,467 @@
.config-panel {
width: 100%;
max-width: 1080px;
margin: 0 auto;
}
.config-panel-title {
text-align: center;
margin-bottom: 24px;
padding-top: 4px;
}
.config-title-eyebrow {
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
padding: 4px 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface-raised);
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
text-transform: uppercase;
}
.config-panel-title h1 {
font-size: 32px;
line-height: 1.1;
font-weight: 800;
color: var(--accent);
letter-spacing: 1px;
margin-bottom: 8px;
}
.config-panel-title p {
color: var(--text-muted);
font-size: 14px;
}
.config-layout {
display: flex;
flex-direction: column;
gap: 18px;
}
.config-task-section,
.config-card,
.config-advanced-section {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 18px;
box-shadow: var(--shadow-card);
}
.config-task-section {
padding: 24px;
}
.config-task-section label {
display: block;
font-weight: 700;
font-size: 13px;
color: var(--text);
margin-bottom: 10px;
}
.config-task-textarea,
.config-textarea,
.config-input,
.config-json-editor {
width: 100%;
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
color: var(--text);
font-family: inherit;
font-size: 13px;
outline: none;
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.03);
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
.config-task-textarea,
.config-textarea,
.config-json-editor {
padding: 14px 16px;
resize: vertical;
line-height: 1.7;
}
.config-task-textarea {
min-height: 150px;
}
.config-textarea {
min-height: 108px;
}
.config-textarea-sm {
min-height: 78px;
}
.config-input {
padding: 11px 13px;
}
.config-json-editor {
min-height: 320px;
font-family: 'SFMono-Regular', Consolas, monospace;
}
.config-task-textarea:focus,
.config-textarea:focus,
.config-input:focus,
.config-json-editor:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
background: var(--surface-raised);
}
.config-submit-row {
display: flex;
align-items: center;
gap: 12px;
justify-content: flex-end;
margin-top: 18px;
flex-wrap: wrap;
padding-top: 4px;
}
.config-mode-switch {
display: inline-flex;
background: color-mix(in srgb, var(--surface) 96%, var(--accent) 4%);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
padding: 3px;
flex-shrink: 0;
}
.config-mode-option {
background: transparent;
color: var(--text-muted);
border: none;
border-radius: 9px;
padding: 8px 14px;
font-size: 12px;
font-weight: 700;
}
.config-mode-option.active {
background: var(--accent);
color: var(--button-text);
box-shadow: 0 8px 18px var(--accent-soft-strong);
}
.config-mode-option:not(.active):hover:not(:disabled) {
background: var(--surface-hover-strong);
color: var(--text);
}
.config-run-mode-row {
display: grid;
grid-template-columns: auto minmax(260px, 1fr) auto;
align-items: center;
gap: 14px;
margin-top: 16px;
}
.config-run-mode-row .config-field-hint {
margin-top: 0;
min-width: 0;
}
.config-mode-hint {
padding: 8px 12px;
border-radius: 10px;
background: var(--surface-hover);
}
.config-scenario-button {
flex-shrink: 0;
white-space: nowrap;
}
.config-run-options-row {
display: grid;
grid-template-columns: 80px minmax(120px, 1fr) minmax(260px, 1.2fr);
align-items: center;
gap: 12px;
margin-top: 14px;
}
.config-run-options-row label {
margin-bottom: 0;
min-width: 0;
color: var(--text-muted);
}
.config-number-input {
width: 100%;
max-width: 96px;
}
.config-run-options-row .config-field-hint {
margin-top: 0;
min-width: 0;
padding-left: 2px;
}
.btn-start {
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
color: var(--button-text);
font-weight: 800;
font-size: 14px;
padding: 11px 30px;
border-radius: 12px;
letter-spacing: 0.3px;
box-shadow: 0 12px 26px var(--accent-soft-strong);
}
.btn-start:hover:not(:disabled) {
opacity: 0.85;
}
.config-card-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.config-card {
padding: 22px 24px;
}
.config-card-header {
margin-bottom: 14px;
}
.config-card-header h3 {
font-size: 18px;
color: var(--text);
margin-bottom: 6px;
}
.config-card-header p {
font-size: 12px;
color: var(--text-muted);
line-height: 1.6;
}
.config-field {
margin-top: 14px;
}
.config-field:first-child {
margin-top: 0;
}
.config-field label {
display: block;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.config-field-hint {
margin-top: 6px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.6;
}
.config-task-textarea::placeholder,
.config-textarea::placeholder,
.config-input::placeholder,
.config-json-editor::placeholder {
color: color-mix(in srgb, var(--text-muted) 72%, transparent);
}
.config-fixed-mode {
margin-right: auto;
color: var(--text-muted);
font-size: 12px;
line-height: 1.5;
}
.config-agents,
.config-report-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.config-agent-card,
.config-report-card {
background: var(--surface-hover);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
}
.config-agents.readonly,
.config-report-grid.readonly {
margin-top: 10px;
}
.config-report-card p,
.config-agent-card p {
color: var(--text-muted);
font-size: 12px;
line-height: 1.65;
margin: 0;
}
.config-report-card pre,
.config-readonly-block pre {
margin: 12px 0 0;
max-height: 260px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-muted);
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 11px;
line-height: 1.55;
padding: 10px;
}
.config-readonly-block {
margin-top: 18px;
}
.config-readonly-block:first-child {
margin-top: 0;
}
.config-readonly-title {
color: var(--text);
font-size: 13px;
font-weight: 700;
margin-bottom: 10px;
}
.config-readonly-content {
color: var(--text-muted);
}
.config-readonly-meta {
margin-top: 8px !important;
color: var(--accent) !important;
}
.config-empty-detail {
color: var(--text-muted);
font-size: 13px;
padding: 12px 0 0;
}
.config-agent-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.config-agent-head span {
font-size: 14px;
font-weight: 700;
color: var(--text);
}
.config-agent-head code {
color: var(--accent);
background: var(--accent-soft);
border-radius: 999px;
padding: 3px 8px;
font-size: 11px;
}
.config-subgrid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.config-advanced-section {
overflow: hidden;
}
.config-advanced-toggle {
width: 100%;
background: transparent;
border: none;
padding: 16px 20px;
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
font-size: 13px;
font-weight: 700;
cursor: pointer;
text-align: left;
border-radius: 0;
}
.config-advanced-toggle:hover {
color: var(--accent);
background: var(--surface-hover);
}
.config-advanced-chevron {
margin-left: auto;
transition: transform 0.2s;
}
.config-advanced-chevron.open {
transform: rotate(180deg);
}
.config-advanced-body {
padding: 0 20px 20px;
border-top: 1px solid var(--border);
}
@media (max-width: 900px) {
.config-task-section,
.config-card {
padding: 18px;
}
.config-run-mode-row,
.config-run-options-row {
grid-template-columns: 1fr;
align-items: stretch;
}
.config-mode-switch {
width: 100%;
}
.config-mode-option {
flex: 1;
}
.config-scenario-button,
.config-number-input {
width: 100%;
max-width: none;
}
.config-card-grid,
.config-agents,
.config-report-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,419 @@
import React, { useEffect, useMemo, useState } from 'react';
import { DEFAULT_BACKEND_URL, DEFAULT_CONFIG } from '@/config/defaultConfig';
import { clone, hasTemplateOverride, loadTemplateByMeta, loadTemplateIndex } from '@/config/templateStore';
import './ConfigPanel.css';
import { useChatContext } from '@/contexts/ChatContext';
function Card({ title, description, children }) {
return (
<section className="config-card">
<div className="config-card-header">
<h3>{title}</h3>
{description && <p>{description}</p>}
</div>
{children}
</section>
);
}
function ReadOnlyBlock({ title, children }) {
return (
<div className="config-readonly-block">
<div className="config-readonly-title">{title}</div>
<div className="config-readonly-content">{children}</div>
</div>
);
}
export default function ConfigPanel({ onStart, isRunning, onStop, onOpenScenarioConfig }) {
const { startConfig } = useChatContext();
const [taskContext, setTaskContext] = useState(startConfig?.task_context || '');
const backendUrl = DEFAULT_BACKEND_URL;
const [config, setConfig] = useState(() => clone(DEFAULT_CONFIG));
const [rawOpen, setRawOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [templates, setTemplates] = useState([]);
const [templateLoading, setTemplateLoading] = useState(false);
const [templateError, setTemplateError] = useState('');
const [llmPresets, setLlmPresets] = useState([]);
const [llmLoading, setLlmLoading] = useState(false);
const [llmError, setLlmError] = useState('');
const updateConfig = (updater) => {
setConfig(prev => {
const next = clone(prev);
updater(next);
return next;
});
};
const setCoordinatorMode = (mode) => {
updateConfig(next => {
next.coordinator_mode = mode;
});
};
const setDivergeRounds = (value) => {
const parsed = Number.parseInt(value, 10);
const rounds = Number.isFinite(parsed) ? Math.min(8, Math.max(1, parsed)) : 3;
updateConfig(next => {
next.phase_config = {
...(next.phase_config || {}),
diverge_rounds: rounds,
};
});
};
const setLlmPreset = (presetId) => {
updateConfig(next => {
next.llm_preset_id = presetId;
});
};
const setConsistencyMode = (mode) => {
updateConfig(next => {
next.consistency_mode = mode;
});
};
const handleSubmit = (e) => {
e.preventDefault();
if (!taskContext.trim() || !config.template_id) return;
console.log('------------------点击了')
onStart({
...config,
task_context: taskContext.trim(),
coordinator_mode: config.coordinator_mode || 'auto',
}, backendUrl);
};
const loadTemplates = async () => {
setTemplateLoading(true);
setTemplateError('');
try {
setTemplates(await loadTemplateIndex());
} catch (error) {
setTemplateError(error.message || '模板列表加载失败');
} finally {
setTemplateLoading(false);
}
};
const loadConfigOptions = async () => {
setLlmLoading(true);
setLlmError('');
try {
const response = await fetch(`${backendUrl}/config/options`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const presets = data.llm_presets || [];
setLlmPresets(presets);
updateConfig(next => {
if (!next.llm_preset_id) {
next.llm_preset_id = data.default_llm_preset_id || presets.find(item => item.is_default)?.id || '';
}
});
} catch (error) {
setLlmError(error.message || '模型配置加载失败');
} finally {
setLlmLoading(false);
}
};
const applyTemplate = async (templateId) => {
if (!templateId) return;
const meta = templates.find(item => item.template_id === templateId);
setTemplateLoading(true);
setTemplateError('');
try {
const template = await loadTemplateByMeta(meta || templateId);
updateConfig(next => {
next.template_id = template.template_id || templateId;
next.template_name = template.template_name || '';
next.template_brief = template.template_brief || '';
next.consistency_mode = template.consistency_mode || next.consistency_mode || 'free';
next.report_schema = clone(template.report_schema || {});
next.report_examples = clone(template.report_examples || {});
next.field_requirements = clone(template.field_requirements || {});
next.agents = clone(template.agents || next.agents || []);
next.coordinator_mode = next.coordinator_mode || 'auto';
next.coordinator_config = clone(template.coordinator_config || DEFAULT_CONFIG.coordinator_config);
next.agent_adaptation_policy = clone(template.agent_adaptation_policy || {});
});
} catch (error) {
setTemplateError(error.message || '模板加载失败');
} finally {
setTemplateLoading(false);
}
};
useEffect(() => {
if (!backendUrl) return;
loadTemplates();
loadConfigOptions();
}, [backendUrl]);
useEffect(() => {
if(startConfig) {
setConfig((pev) => ({
...pev,
template_id: startConfig.template_id,
}))
}
}, []);
const selectedLlmPreset = useMemo(
() => llmPresets.find(item => item.id === config.llm_preset_id) || null,
[llmPresets, config.llm_preset_id]
);
const rawConfig = useMemo(() => JSON.stringify({ ...config, task_context: '<在上方输入>' }, null, 2), [config]);
const canStart = taskContext.trim() && config.template_id;
return (
<div className="config-panel">
<form onSubmit={handleSubmit} className="config-layout">
<div className="config-task-section">
<label htmlFor="task-context">任务背景</label>
<textarea
id="task-context"
className="config-task-textarea"
value={taskContext}
onChange={e => setTaskContext(e.target.value)}
placeholder="请描述事件背景、传播目标、限制条件和你关心的判断标准。"
disabled={isRunning}
/>
<div className="config-run-mode-row">
<div className="config-mode-switch" role="radiogroup" aria-label="协调者模式">
<button
type="button"
className={`config-mode-option ${(config.coordinator_mode || 'auto') === 'auto' ? 'active' : ''}`}
onClick={() => setCoordinatorMode('auto')}
disabled={isRunning}
>
自动完成
</button>
<button
type="button"
className={`config-mode-option ${config.coordinator_mode === 'human_review' ? 'active' : ''}`}
onClick={() => setCoordinatorMode('human_review')}
disabled={isRunning}
>
人工逐轮确认
</button>
</div>
<div className="config-field-hint config-mode-hint">
自动完成会由协调者连续推进;人工逐轮确认会在每轮协调者提案后等待确认或修改。
</div>
<button
type="button"
className="btn-secondary config-scenario-button"
onClick={onOpenScenarioConfig}
disabled={isRunning}
>
场景配置
</button>
</div>
<div className="config-run-options-row">
<label htmlFor="diverge-rounds">讨论轮次</label>
<input
id="diverge-rounds"
className="config-input config-number-input"
type="number"
min="1"
max="8"
step="1"
value={config.phase_config?.diverge_rounds || 3}
onChange={e => setDivergeRounds(e.target.value)}
disabled={isRunning}
/>
<div className="config-field-hint">
控制协调者推进的中间讨论轮数;轮次越多,材料更充分但耗时更长。
</div>
</div>
<div className="config-run-options-row">
<label htmlFor="llm-preset">运行模型</label>
<select
id="llm-preset"
className="config-input"
value={config.llm_preset_id || ''}
onChange={e => setLlmPreset(e.target.value)}
disabled={isRunning || llmLoading || llmPresets.length === 0}
>
<option value="">选择模型...</option>
{llmPresets.map(preset => (
<option key={preset.id} value={preset.id}>
{preset.label}
{preset.is_default ? '(默认)' : ''}
</option>
))}
</select>
<div className="config-field-hint">
{selectedLlmPreset
? `${selectedLlmPreset.description || '后端预设模型'} · ${selectedLlmPreset.model_name}`
: '模型预设由后端 config.py 管理,前端只选择预设。'}
</div>
</div>
<div className="config-run-options-row">
<label htmlFor="consistency-mode">一致性模式</label>
<select
id="consistency-mode"
className="config-input"
value={config.consistency_mode || 'free'}
onChange={e => setConsistencyMode(e.target.value)}
disabled={isRunning}
>
<option value="free">free</option>
<option value="soft">soft</option>
</select>
<div className="config-field-hint">
{config.consistency_mode === 'soft'
? 'soft 会优先承接依赖字段、统一命名口径,并在最终成文时增强跨模块衔接。'
: 'free 只做基础一致性控制,适合依赖关系较弱或开放式的场景模板。'}
</div>
</div>
<div className="config-submit-row">
<div className="config-fixed-mode">报告结构、智能体和协调者策略由所选模板固定。</div>
{isRunning ? (
<button type="button" className="btn-danger" onClick={onStop}>停止分析</button>
) : (
<button type="submit" className="btn-start" disabled={!canStart}>
{config.coordinator_mode === 'human_review' ? '开始并逐轮确认' : '开始分析'}
</button>
)}
</div>
{!config.template_id && (
<div className="config-field-hint">开始前需要先选择一个报告模板。</div>
)}
{llmError && (
<div className="human-review-error">模型配置加载失败:{llmError}</div>
)}
</div>
<Card title="报告模板" description="选择后会自动固定报告字段、字段标准、参与智能体和协调者策略。">
<div className="config-submit-row">
<select
className="config-input"
value={config.template_id || ''}
onChange={e => applyTemplate(e.target.value)}
disabled={isRunning || templateLoading}
>
<option value="">选择模板...</option>
{templates.map(template => (
<option key={template.template_id} value={template.template_id}>
{template.template_name || template.template_id}
{hasTemplateOverride(template.template_id) ? '(已修改)' : ''}
</option>
))}
</select>
<button
type="button"
className="btn-primary"
onClick={loadTemplates}
disabled={isRunning || templateLoading}
>
{templateLoading ? '加载中' : '刷新模板'}
</button>
</div>
{config.template_name && (
<div className="config-field-hint">
当前模板:{config.template_name}
{config.template_brief ? ` · ${config.template_brief}` : ''}
</div>
)}
{templateError && (
<div className="human-review-error">模板加载失败:{templateError}</div>
)}
</Card>
<div className="config-field-hint">
运行模型预设由后端 `config.py` 管理;前端只选择预设 id,不接触密钥。
</div>
<div className="config-advanced-section">
<button
type="button"
className="config-advanced-toggle"
onClick={() => setDetailOpen(open => !open)}
>
<span>模板详情(只读)</span>
<span className={`config-advanced-chevron ${detailOpen ? 'open' : ''}`}>▾</span>
</button>
{detailOpen && (
<div className="config-advanced-body">
{!config.template_id ? (
<div className="config-empty-detail">选择模板后可查看字段、智能体和协调者策略。</div>
) : (
<>
<ReadOnlyBlock title="报告字段">
<div className="config-report-grid readonly">
{Object.entries(config.report_schema || {}).map(([field, desc]) => (
<div key={field} className="config-report-card">
<div className="config-agent-head">
<span>{field}</span>
</div>
<p>{desc}</p>
{config.field_requirements?.[field] && (
<pre>{JSON.stringify(config.field_requirements[field], null, 2)}</pre>
)}
</div>
))}
</div>
</ReadOnlyBlock>
<ReadOnlyBlock title="参与智能体">
<div className="config-agents readonly">
{(config.agents || []).map(agent => (
<div key={agent.id} className="config-agent-card">
<div className="config-agent-head">
<span>{agent.name}</span>
<code>{agent.id}</code>
</div>
<p className="config-readonly-meta">{agent.dimension}</p>
<p>{agent.expertise || agent.description || agent.skill}</p>
{(agent.relevant_fields || []).length > 0 && (
<p className="config-readonly-meta">关联字段:{agent.relevant_fields.join('、')}</p>
)}
</div>
))}
</div>
</ReadOnlyBlock>
<ReadOnlyBlock title="协调者策略">
<pre>{JSON.stringify(config.coordinator_config || {}, null, 2)}</pre>
</ReadOnlyBlock>
</>
)}
</div>
)}
</div>
<div className="config-advanced-section">
<button
type="button"
className="config-advanced-toggle"
onClick={() => setRawOpen(open => !open)}
>
<span>原始配置预览</span>
<span className={`config-advanced-chevron ${rawOpen ? 'open' : ''}`}>▾</span>
</button>
{rawOpen && (
<div className="config-advanced-body">
<textarea className="config-json-editor" value={rawConfig} readOnly spellCheck={false} />
</div>
)}
</div>
</form>
</div>
);
}
@@ -0,0 +1,167 @@
.final-report {
background: var(--surface-raised);
border: 1px solid var(--green);
border-radius: 16px;
overflow: hidden;
margin-top: 24px;
box-shadow: var(--shadow-card);
}
.final-report-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: rgba(63, 185, 80, 0.05);
}
.final-report-icon {
font-size: 20px;
}
.final-report-title {
font-size: 18px;
font-weight: 700;
color: var(--green);
}
.final-report-meta {
margin-left: auto;
font-size: 12px;
color: var(--text-muted);
}
/* Report sections */
.final-report-sections {
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
}
.final-report-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.final-report-section-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--green);
padding-bottom: 6px;
border-bottom: 1px solid rgba(63, 185, 80, 0.2);
}
.final-report-section-content {
font-size: 14px;
color: var(--text);
}
.final-report-section-content .markdown-body {
font-size: 14px;
}
/* Dimensions section */
.final-report-dimensions-toggle {
width: 100%;
background: transparent;
border: none;
border-top: 1px solid var(--border);
padding: 12px 20px;
display: flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
cursor: pointer;
text-align: left;
border-radius: 0;
transition: color 0.15s, background 0.15s;
}
.final-report-dimensions-toggle:hover {
color: var(--text);
background: var(--surface-hover);
}
.final-report-dimensions-chevron {
margin-left: auto;
transition: transform 0.2s;
}
.final-report-dimensions-chevron.open {
transform: rotate(180deg);
}
.final-report-dimensions-body {
padding: 16px 20px 20px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 20px;
}
.dim-detail {
display: flex;
flex-direction: column;
gap: 8px;
}
.dim-detail-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--accent);
display: flex;
align-items: center;
gap: 8px;
}
.dim-detail-sublabel {
font-size: 11px;
color: var(--text-muted);
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
.dim-detail-analysis {
font-size: 13px;
color: var(--text);
}
.dim-detail-output {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 4px;
padding: 8px 12px;
background: var(--surface-hover);
border-radius: 6px;
border-left: 2px solid var(--accent);
}
.dim-detail-output-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.dim-detail-output-key {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.dim-detail-output-value {
font-size: 12px;
color: var(--text);
}
@@ -0,0 +1,98 @@
import React, { useState } from 'react';
import './FinalReport.css';
import MarkdownContent from './MarkdownContent';
const REPORT_FIELD_LABELS: Record<string, string> = {
core_strategy: '核心策略',
action_plan: '行动计划',
key_messages: '核心话术',
risk_warnings: '风险预警',
};
type Props = {
output?: any;
reportSchema?: Record<string, string>;
};
export default function FinalReport({ output, reportSchema }: Props): JSX.Element | null {
const [dimOpen, setDimOpen] = useState(false);
if (!output) return null;
const { report, dimensions, meta } = output;
const reportKeys = reportSchema && Object.keys(reportSchema).length > 0
? Object.keys(reportSchema)
: (report ? Object.keys(report) : []);
return (
<div className="final-report">
<div className="final-report-header">
<span className="final-report-icon">✦</span>
<span className="final-report-title">最终方案</span>
{meta && meta.total_rounds != null && (
<span className="final-report-meta">共 {meta.total_rounds} 轮分析</span>
)}
</div>
{report && reportKeys.length > 0 && (
<div className="final-report-sections">
{reportKeys.map(key => {
const content = report[key];
if (content == null) return null;
const label = (reportSchema && reportSchema[key])
? reportSchema[key]
: (REPORT_FIELD_LABELS[key] || key);
return (
<div key={key} className="final-report-section">
<div className="final-report-section-label">{label}</div>
<div className="final-report-section-content">
<MarkdownContent
content={typeof content === 'string' ? content : JSON.stringify(content, null, 2)}
/>
</div>
</div>
);
})}
</div>
)}
{dimensions && Object.keys(dimensions).length > 0 && (
<>
<button
className="final-report-dimensions-toggle"
onClick={() => setDimOpen(o => !o)}
>
<span>维度详情</span>
<span className={`final-report-dimensions-chevron ${dimOpen ? 'open' : ''}`}>▾</span>
</button>
{dimOpen && (
<div className="final-report-dimensions-body">
{Object.entries(dimensions).map(([dim, dimData]: [string, any]) => (
<div key={dim} className="dim-detail">
<div className="dim-detail-label">
<span>{dim}</span>
{typeof dimData === 'object' && dimData?.label && (
<span className="dim-detail-sublabel">{dimData.label}</span>
)}
</div>
{typeof dimData === 'string' && (
<div className="dim-detail-analysis">
<MarkdownContent content={dimData} />
</div>
)}
{typeof dimData === 'object' && dimData?.latest_analysis && (
<div className="dim-detail-analysis">
<MarkdownContent content={dimData.latest_analysis} />
</div>
)}
</div>
))}
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,131 @@
.markdown-body {
line-height: 1.7;
color: var(--text-primary);
word-break: break-word;
}
.markdown-body > *:first-child { margin-top: 0; }
.markdown-body > *:last-child { margin-bottom: 0; }
/* Headings */
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin: 1em 0 0.4em;
font-weight: 600;
line-height: 1.3;
color: var(--text-primary);
}
.markdown-body h1 { font-size: 1.3em; border-bottom: 1px solid var(--border-color); padding-bottom: 0.3em; }
.markdown-body h2 { font-size: 1.15em; border-bottom: 1px solid var(--border-color); padding-bottom: 0.2em; }
.markdown-body h3 { font-size: 1.05em; }
.markdown-body h4 { font-size: 0.95em; }
/* Paragraphs */
.markdown-body p {
margin: 0.5em 0;
}
/* Lists */
.markdown-body ul,
.markdown-body ol {
margin: 0.5em 0;
padding-left: 1.6em;
}
.markdown-body li {
margin: 0.25em 0;
}
.markdown-body li > ul,
.markdown-body li > ol {
margin: 0.2em 0;
}
/* Bold & italic */
.markdown-body strong { font-weight: 600; color: var(--text-primary); }
.markdown-body em { font-style: italic; color: var(--text-secondary); }
/* Inline code */
.markdown-body code {
background: rgba(110, 118, 129, 0.15);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.1em 0.4em;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.875em;
color: var(--red);
}
/* Code blocks */
.markdown-body pre {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1em;
overflow-x: auto;
margin: 0.6em 0;
}
.markdown-body pre code {
background: none;
border: none;
padding: 0;
font-size: 0.85em;
color: var(--text-primary);
}
/* Blockquote */
.markdown-body blockquote {
border-left: 3px solid var(--accent-blue);
margin: 0.5em 0;
padding: 0.3em 0.8em;
color: var(--text-secondary);
background: rgba(88, 166, 255, 0.05);
border-radius: 0 4px 4px 0;
}
.markdown-body blockquote p { margin: 0; }
/* Horizontal rule */
.markdown-body hr {
border: none;
border-top: 1px solid var(--border-color);
margin: 1em 0;
}
/* Links */
.markdown-body a {
color: var(--accent-blue);
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}
/* Tables (remark-gfm) */
.markdown-body table {
border-collapse: collapse;
width: 100%;
margin: 0.6em 0;
font-size: 0.9em;
}
.markdown-body th,
.markdown-body td {
border: 1px solid var(--border-color);
padding: 0.4em 0.7em;
text-align: left;
}
.markdown-body th {
background: var(--bg-secondary);
font-weight: 600;
color: var(--text-primary);
}
.markdown-body tr:nth-child(even) td {
background: var(--surface-hover);
}
/* Task lists (remark-gfm) */
.markdown-body input[type='checkbox'] {
margin-right: 0.4em;
accent-color: var(--accent-blue);
}
@@ -0,0 +1,68 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import './MarkdownContent.css';
type Props = {
content?: string;
referenceMap?: Record<string, string>;
className?: string;
};
function rehypeHighlightRefs(referenceMap?: Record<string, string>) {
return function (tree: any) {
function walk(node: any) {
if (!node.children) return;
const next = [];
for (const child of node.children) {
if (child.type === 'text') {
const parts = child.value.split(/(\[\d+\])/g);
if (parts.length > 1) {
for (const part of parts) {
if (!part) continue;
const m = part.match(/^\[(\d+)\]$/);
if (m) {
const num = m[1];
const itemId = referenceMap?.[num];
next.push({
type: 'element',
tagName: 'span',
properties: {
className: 'ref-cite',
'data-tooltip': itemId ? `ID: ${itemId}` : `引用 ${num}`,
},
children: [{ type: 'text', value: part }],
});
} else {
next.push({ type: 'text', value: part });
}
}
} else {
next.push(child);
}
} else {
walk(child);
next.push(child);
}
}
node.children = next;
}
walk(tree);
};
}
export default function MarkdownContent({ content, referenceMap, className = '' }: Props): JSX.Element | null {
if (!content) return null;
const rehypePlugins = [() => rehypeHighlightRefs(referenceMap)];
return (
<div className={`markdown-body ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={rehypePlugins}
>
{content}
</ReactMarkdown>
</div>
);
}
@@ -0,0 +1,123 @@
.progress-sidebar {
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.progress-card {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px;
box-shadow: var(--shadow-card);
}
.progress-title {
font-size: 12px;
font-weight: 700;
color: var(--text);
margin-bottom: 8px;
}
.progress-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.progress-phase,
.progress-status,
.progress-text,
.field-current,
.empty-hint,
.memory-subtitle,
.field-memory-status {
font-size: 11px;
color: var(--text-muted);
}
.progress-line {
height: 6px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--border);
overflow: hidden;
}
.progress-line-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--green));
transition: width 0.25s ease;
}
.progress-text,
.field-current {
margin-top: 8px;
}
.field-current {
color: var(--accent);
}
.memory-list,
.field-memory-list,
.agent-memory-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.memory-item,
.agent-memory-card,
.field-memory-card {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px;
}
.memory-item {
font-size: 11px;
color: var(--text-muted);
line-height: 1.55;
}
.memory-subtitle {
margin: 8px 0 6px;
}
.field-memory-card.status-filled {
border-left: 2px solid var(--green);
}
.field-memory-card.status-partial {
border-left: 2px solid var(--yellow);
}
.field-memory-card.status-blocked {
border-left: 2px solid var(--red);
}
.field-memory-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.field-memory-title,
.agent-memory-name {
font-size: 12px;
font-weight: 700;
color: var(--text);
}
.agent-memory-position {
font-size: 11px;
color: var(--text);
line-height: 1.55;
}
@@ -0,0 +1,141 @@
import './ProgressSidebar.css';
type MemoryListProps = {
items?: any[];
emptyText: string;
};
type Props = {
phase?: string;
status?: string;
currentRound?: number;
divergeRounds?: number;
reportSchema?: Record<string, any>;
sharedMemory?: Record<string, any>;
finalReportPartial?: Record<string, string>;
currentFinalField?: string;
};
function MemoryList({ items, emptyText }: MemoryListProps): JSX.Element {
if (!items || items.length === 0) {
return <div className="empty-hint">{emptyText}</div>;
}
return (
<div className="memory-list">
{items.map((item, index) => (
<div key={`${index}-${String(item).slice(0, 20)}`} className="memory-item">{item}</div>
))}
</div>
);
}
export default function ProgressSidebar({
phase,
status,
currentRound,
divergeRounds,
reportSchema,
sharedMemory,
finalReportPartial,
currentFinalField,
}: Props): JSX.Element {
const fields = Object.keys(reportSchema || {});
const finalFilled = fields.filter(field => (finalReportPartial?.[field] || '').trim()).length;
const progressPercent = fields.length > 0 ? Math.round((finalFilled / fields.length) * 100) : 0;
const fieldMemoryEntries = Object.entries(sharedMemory?.field_memory || {});
const agentMemoryEntries = Object.entries(sharedMemory?.agent_memory || {});
const displayStatus = status || '等待中';
return (
<div className="progress-sidebar">
<div className="progress-card">
<div className="progress-title">方案进度</div>
<div className="progress-meta">
<span className={`progress-phase phase-${phase || 'starting'}`}>{phase || 'starting'}</span>
<span className="progress-status">{displayStatus}</span>
</div>
<div className="progress-line">
<div className="progress-line-fill" style={{ width: `${progressPercent}%` }} />
</div>
<div className="progress-text">第 {currentRound || 0} 轮 / 发散 {divergeRounds || 0} 轮</div>
<div className="progress-text">最终报告已生成字段 {finalFilled}/{fields.length || 0}</div>
{currentFinalField && <div className="field-current">当前字段:{currentFinalField}</div>}
</div>
<div className="progress-card">
<div className="progress-title">稳定判断</div>
<MemoryList items={sharedMemory?.stable_claims || []} emptyText="尚未形成稳定判断" />
</div>
<div className="progress-card">
<div className="progress-title">关键依据</div>
<MemoryList items={sharedMemory?.key_evidence || []} emptyText="尚未沉淀关键依据" />
</div>
<div className="progress-card">
<div className="progress-title">冲突与待核</div>
<MemoryList items={sharedMemory?.conflicts || []} emptyText="当前没有显式冲突" />
<div className="memory-subtitle">待核问题</div>
<MemoryList items={sharedMemory?.open_questions || []} emptyText="当前没有待核问题" />
</div>
<div className="progress-card">
<div className="progress-title">字段记忆</div>
{fieldMemoryEntries.length === 0 ? (
<div className="empty-hint">尚未形成字段记忆</div>
) : (
<div className="field-memory-list">
{fieldMemoryEntries.map(([field, item]: [string, any]) => (
<div key={field} className={`field-memory-card status-${item.status || 'partial'}`}>
<div className="field-memory-head">
<span className="field-memory-title">{item.title || field}</span>
<span className="field-memory-status">{item.status || 'partial'}</span>
</div>
{item.current_takeaways?.length > 0 && (
<>
<div className="memory-subtitle">当前判断</div>
<MemoryList items={item.current_takeaways} emptyText="" />
</>
)}
{item.missing_points?.length > 0 && (
<>
<div className="memory-subtitle">待补内容</div>
<MemoryList items={item.missing_points} emptyText="" />
</>
)}
</div>
))}
</div>
)}
</div>
<div className="progress-card">
<div className="progress-title">角色记忆</div>
{agentMemoryEntries.length === 0 ? (
<div className="empty-hint">尚未形成角色记忆</div>
) : (
<div className="agent-memory-list">
{agentMemoryEntries.map(([agentId, item]: [string, any]) => (
<div key={agentId} className="agent-memory-card">
<div className="agent-memory-name">{item.name || agentId}</div>
{item.latest_position && <div className="agent-memory-position">{item.latest_position}</div>}
{item.useful_findings?.length > 0 && (
<>
<div className="memory-subtitle">有效发现</div>
<MemoryList items={item.useful_findings} emptyText="" />
</>
)}
{item.open_issues?.length > 0 && (
<>
<div className="memory-subtitle">待继续澄清</div>
<MemoryList items={item.open_issues} emptyText="" />
</>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,337 @@
.scenario-page {
width: 100%;
max-width: 1280px;
min-height: 100vh;
padding: 28px;
}
.scenario-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
margin-bottom: 18px;
}
.scenario-kicker {
color: var(--accent);
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
}
.scenario-header h1 {
color: var(--text);
font-size: 30px;
margin: 4px 0 6px;
}
.scenario-header p,
.scenario-card-desc {
color: var(--text-muted);
font-size: 13px;
}
.scenario-header-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.scenario-toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px;
margin-bottom: 14px;
}
.scenario-select,
.scenario-input,
.scenario-textarea,
.scenario-json {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-family: inherit;
font-size: 13px;
outline: none;
}
.scenario-select {
min-width: 280px;
padding: 9px 12px;
flex: 1;
}
.scenario-select-inline {
min-width: 0;
width: 100%;
flex: 0 0 auto;
margin-top: 6px;
}
.scenario-input {
width: 100%;
padding: 10px 12px;
}
.scenario-textarea,
.scenario-json {
width: 100%;
padding: 12px;
line-height: 1.65;
resize: vertical;
}
.scenario-textarea {
min-height: 120px;
}
.scenario-textarea-sm {
min-height: 80px;
}
.scenario-textarea-lg {
min-height: 180px;
}
.scenario-json {
min-height: 460px;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 12px;
}
.scenario-select:focus,
.scenario-input:focus,
.scenario-textarea:focus,
.scenario-json:focus {
border-color: var(--accent);
}
.scenario-message,
.scenario-error,
.scenario-empty {
border-radius: 10px;
padding: 10px 14px;
margin-bottom: 14px;
font-size: 13px;
}
.scenario-message {
background: rgba(63, 185, 80, 0.1);
border: 1px solid rgba(63, 185, 80, 0.45);
color: var(--green);
}
.scenario-error {
background: rgba(248, 81, 73, 0.1);
border: 1px solid rgba(248, 81, 73, 0.45);
color: var(--red);
}
.scenario-empty {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-muted);
}
.scenario-layout {
display: flex;
flex-direction: column;
gap: 16px;
}
.scenario-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 18px;
}
.scenario-card-title {
color: var(--text);
font-size: 16px;
font-weight: 700;
margin-bottom: 12px;
}
.scenario-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 14px;
}
.scenario-card-head .scenario-card-title {
margin-bottom: 2px;
}
.scenario-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.scenario-card label {
display: block;
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
margin-top: 14px;
}
.scenario-card label:first-child {
margin-top: 0;
}
.scenario-card label > input,
.scenario-card label > textarea {
margin-top: 6px;
}
.scenario-inline-hint {
margin-top: 6px;
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
line-height: 1.55;
}
.scenario-field-key {
color: var(--accent);
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 11px;
font-weight: 500;
margin-left: 8px;
}
.scenario-coordinator-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.scenario-agent-layout {
display: grid;
grid-template-columns: 260px 1fr;
gap: 16px;
}
.scenario-agent-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.scenario-agent-tab {
background: var(--card);
border: 1px solid var(--border);
color: var(--text-muted);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 10px 12px;
text-align: left;
}
.scenario-agent-tab.active {
border-color: var(--accent);
background: rgba(88, 166, 255, 0.1);
color: var(--text);
}
.scenario-agent-tab span {
font-weight: 700;
}
.scenario-agent-tab code {
color: var(--accent);
font-size: 11px;
}
.scenario-agent-editor {
background: rgba(255,255,255,0.02);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
}
.scenario-field-picker {
margin-top: 6px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.scenario-field-option {
display: flex !important;
gap: 10px;
align-items: flex-start;
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px;
margin-top: 0 !important;
cursor: pointer;
}
.scenario-field-option.selected {
border-color: var(--accent);
background: rgba(88, 166, 255, 0.08);
}
.scenario-field-option input[type='checkbox'] {
margin-top: 2px;
accent-color: var(--accent);
}
.scenario-field-option-body {
min-width: 0;
}
.scenario-field-option-title {
color: var(--text);
font-size: 12px;
font-weight: 700;
}
.scenario-field-option-key {
color: var(--accent);
font-size: 11px;
font-family: 'SFMono-Regular', Consolas, monospace;
margin-top: 2px;
}
.scenario-field-option-desc {
color: var(--text-muted);
font-size: 11px;
line-height: 1.5;
margin-top: 5px;
}
@media (max-width: 960px) {
.scenario-page {
padding: 18px;
}
.scenario-header,
.scenario-card-head {
flex-direction: column;
}
.scenario-grid-2,
.scenario-coordinator-grid,
.scenario-agent-layout,
.scenario-field-picker {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,416 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
clearTemplateOverride,
clone,
hasTemplateOverride,
loadOriginalTemplate,
loadTemplateByMeta,
loadTemplateIndex,
saveTemplateOverride,
} from '@/config/templateStore';
import './ScenarioConfigPage.css';
const COORDINATOR_FIELDS = [
['system_prompt_prefix', '协调者全局规则'],
['round1_guidance', '首轮策略'],
['middle_round_guidance', '中间轮策略'],
['final_round_guidance', '末轮策略'],
['speaker_selection_rules', '选人规则'],
['focus_instruction_rules', '任务指令规则'],
['dependency_driving_rules', '依赖推进规则'],
['consistency_check_rules', '一致性/冲突检查规则'],
['revise_prompt_prefix', '人工修改解释规则'],
];
function updateAtIndex(items, index, updater) {
return items.map((item, current) => current === index ? updater(clone(item)) : item);
}
function linesToList(value) {
return value
.split('\n')
.map(item => item.trim())
.filter(Boolean);
}
export default function ScenarioConfigPage({ onBack }) {
const [templates, setTemplates] = useState([]);
const [selectedId, setSelectedId] = useState('');
const [draft, setDraft] = useState(null);
const [rawJson, setRawJson] = useState('');
const [activeAgentIndex, setActiveAgentIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const selectedMeta = useMemo(
() => templates.find(item => item.template_id === selectedId),
[templates, selectedId],
);
const fieldOptions = useMemo(() => {
const schema = draft?.report_schema || {};
const requirements = draft?.field_requirements || {};
return Object.keys(schema).map(field => ({
key: field,
title: requirements?.[field]?.title || field,
description: schema[field] || '',
}));
}, [draft]);
const loadTemplates = async () => {
setLoading(true);
setError('');
try {
const list = await loadTemplateIndex();
setTemplates(list);
if (!selectedId && list[0]) {
setSelectedId(list[0].template_id);
}
} catch (err) {
setError(err.message || '模板列表加载失败');
} finally {
setLoading(false);
}
};
const loadTemplate = async (templateId) => {
if (!templateId) return;
const meta = templates.find(item => item.template_id === templateId) || templateId;
setLoading(true);
setError('');
setMessage('');
try {
const template = await loadTemplateByMeta(meta);
setDraft(template);
setRawJson(JSON.stringify(template, null, 2));
setActiveAgentIndex(0);
} catch (err) {
setError(err.message || '模板加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadTemplates();
}, []);
useEffect(() => {
if (selectedId && templates.length > 0) {
loadTemplate(selectedId);
}
}, [selectedId, templates.length]);
const syncDraft = (updater) => {
setDraft(prev => {
const next = clone(prev);
updater(next);
setRawJson(JSON.stringify(next, null, 2));
return next;
});
setMessage('');
};
const setCoordinatorField = (key, value) => {
syncDraft(next => {
next.coordinator_config = {
...(next.coordinator_config || {}),
[key]: value,
};
});
};
const setAgentField = (index, key, value) => {
syncDraft(next => {
next.agents = updateAtIndex(next.agents || [], index, agent => ({
...agent,
[key]: value,
}));
});
};
const applyRawJson = () => {
setError('');
setMessage('');
try {
const parsed = JSON.parse(rawJson);
if (!parsed.template_id) throw new Error('JSON 中缺少 template_id');
setDraft(parsed);
setSelectedId(parsed.template_id);
setMessage('完整 JSON 已应用,保存后生效。');
} catch (err) {
setError(err.message || 'JSON 解析失败');
}
};
const saveDraft = () => {
if (!draft?.template_id) return;
try {
saveTemplateOverride(draft.template_id, draft);
setMessage('已保存到浏览器本地覆盖。返回首页选择该模板即可使用修改后的提示。');
setError('');
} catch (err) {
setError(err.message || '保存失败');
}
};
const resetTemplate = async () => {
if (!selectedId) return;
setLoading(true);
setMessage('');
setError('');
try {
clearTemplateOverride(selectedId);
const original = await loadOriginalTemplate(selectedMeta || selectedId);
setDraft(original);
setRawJson(JSON.stringify(original, null, 2));
setMessage('已恢复为静态模板原始内容。');
} catch (err) {
setError(err.message || '恢复失败');
} finally {
setLoading(false);
}
};
const activeAgent = draft?.agents?.[activeAgentIndex];
const activeRelevantFields = new Set(activeAgent?.relevant_fields || []);
return (
<div className="scenario-page">
<header className="scenario-header">
<div>
<div className="scenario-kicker">场景配置</div>
<h1>模板提示资产</h1>
<p>查看和修改场景模板对应的协调者提示、推荐智能体配置及智能体提示。新增模板暂不支持。</p>
</div>
<div className="scenario-header-actions">
<button type="button" className="btn-secondary" onClick={onBack}>返回首页</button>
</div>
</header>
<section className="scenario-toolbar">
<select
className="scenario-select"
value={selectedId}
onChange={event => setSelectedId(event.target.value)}
disabled={loading}
>
<option value="">选择场景模板...</option>
{templates.map(template => (
<option key={template.template_id} value={template.template_id}>
{template.template_name || template.template_id}
{hasTemplateOverride(template.template_id) ? '(已修改)' : ''}
</option>
))}
</select>
<button type="button" className="btn-primary" disabled>新增模板(暂不支持)</button>
<button type="button" className="btn-secondary" onClick={loadTemplates} disabled={loading}>
刷新
</button>
<button type="button" className="btn-secondary" onClick={resetTemplate} disabled={!draft || loading}>
恢复原始模板
</button>
<button type="button" className="btn-primary" onClick={saveDraft} disabled={!draft || loading}>
保存修改
</button>
</section>
{message && <div className="scenario-message">{message}</div>}
{error && <div className="scenario-error">{error}</div>}
{!draft ? (
<div className="scenario-empty">请选择一个模板。</div>
) : (
<main className="scenario-layout">
<section className="scenario-card scenario-template-card">
<div className="scenario-card-title">模板信息</div>
<div className="scenario-grid-2">
<label>
模板 ID
<input className="scenario-input" value={draft.template_id || ''} readOnly />
</label>
<label>
模板名称
<input
className="scenario-input"
value={draft.template_name || ''}
onChange={event => syncDraft(next => { next.template_name = event.target.value; })}
/>
</label>
</div>
<label>
模板说明
<textarea
className="scenario-textarea scenario-textarea-sm"
value={draft.template_brief || ''}
onChange={event => syncDraft(next => { next.template_brief = event.target.value; })}
/>
</label>
<label>
一致性模式
<select
className="scenario-select scenario-select-inline"
value={draft.consistency_mode || 'free'}
onChange={event => syncDraft(next => { next.consistency_mode = event.target.value; })}
>
<option value="free">free</option>
<option value="soft">soft</option>
</select>
<div className="scenario-inline-hint">
`free` 适合开放式模板;`soft` 会要求后续字段尽量承接前文依赖和命名。
</div>
</label>
{draft.usage_notes && (
<label>
使用说明
<textarea
className="scenario-textarea"
value={draft.usage_notes || ''}
onChange={event => syncDraft(next => { next.usage_notes = event.target.value; })}
/>
</label>
)}
</section>
<section className="scenario-card">
<div className="scenario-card-title">协调者提示</div>
<div className="scenario-coordinator-grid">
{COORDINATOR_FIELDS.map(([key, label]) => (
<label key={key}>
{label}
<span className="scenario-field-key">{key}</span>
<textarea
className="scenario-textarea"
value={draft.coordinator_config?.[key] || ''}
onChange={event => setCoordinatorField(key, event.target.value)}
/>
</label>
))}
</div>
</section>
<section className="scenario-card">
<div className="scenario-card-head">
<div>
<div className="scenario-card-title">推荐智能体及提示</div>
<div className="scenario-card-desc">当前支持修改已有智能体,不支持新增或删除。</div>
</div>
<button type="button" className="btn-primary" disabled>新增智能体(暂不支持)</button>
</div>
<div className="scenario-agent-layout">
<div className="scenario-agent-list">
{(draft.agents || []).map((agent, index) => (
<button
type="button"
key={agent.id || index}
className={`scenario-agent-tab ${index === activeAgentIndex ? 'active' : ''}`}
onClick={() => setActiveAgentIndex(index)}
>
<span>{agent.name || agent.id || `agent-${index + 1}`}</span>
<code>{agent.id}</code>
</button>
))}
</div>
{activeAgent && (
<div className="scenario-agent-editor">
<div className="scenario-grid-2">
<label>
ID
<input className="scenario-input" value={activeAgent.id || ''} readOnly />
</label>
<label>
名称
<input
className="scenario-input"
value={activeAgent.name || ''}
onChange={event => setAgentField(activeAgentIndex, 'name', event.target.value)}
/>
</label>
</div>
<label>
分析维度
<input
className="scenario-input"
value={activeAgent.dimension || ''}
onChange={event => setAgentField(activeAgentIndex, 'dimension', event.target.value)}
/>
</label>
<label>
智能体提示 / 专家角色
<textarea
className="scenario-textarea scenario-textarea-lg"
value={activeAgent.expertise || ''}
onChange={event => setAgentField(activeAgentIndex, 'expertise', event.target.value)}
/>
</label>
<label>
分析契约(每行一条)
<textarea
className="scenario-textarea"
value={(activeAgent.analysis_contract || []).join('\n')}
onChange={event => setAgentField(activeAgentIndex, 'analysis_contract', linesToList(event.target.value))}
/>
</label>
<label>
关联字段
<div className="scenario-field-picker">
{fieldOptions.map(option => (
<label key={option.key} className={`scenario-field-option ${activeRelevantFields.has(option.key) ? 'selected' : ''}`}>
<input
type="checkbox"
checked={activeRelevantFields.has(option.key)}
onChange={event => {
const current = activeAgent.relevant_fields || [];
const next = event.target.checked
? [...current, option.key]
: current.filter(item => item !== option.key);
setAgentField(activeAgentIndex, 'relevant_fields', next);
}}
/>
<div className="scenario-field-option-body">
<div className="scenario-field-option-title">{option.title}</div>
<div className="scenario-field-option-key">{option.key}</div>
{option.description && (
<div className="scenario-field-option-desc">{option.description}</div>
)}
</div>
</label>
))}
</div>
</label>
<label>
检索补充说明
<textarea
className="scenario-textarea scenario-textarea-sm"
value={activeAgent.retrieval_instruction || ''}
onChange={event => setAgentField(activeAgentIndex, 'retrieval_instruction', event.target.value)}
/>
</label>
</div>
)}
</div>
</section>
<section className="scenario-card">
<div className="scenario-card-head">
<div>
<div className="scenario-card-title">完整模板 JSON</div>
<div className="scenario-card-desc">用于修改页面暂未拆出的提示资产,例如字段要求、样例和报告结构。</div>
</div>
<button type="button" className="btn-secondary" onClick={applyRawJson}>应用 JSON</button>
</div>
<textarea
className="scenario-json"
value={rawJson}
onChange={event => setRawJson(event.target.value)}
spellCheck={false}
/>
</section>
</main>
)}
</div>
);
}
@@ -0,0 +1,238 @@
.sidebar {
padding: 18px 14px;
}
.sidebar-section {
margin-bottom: 24px;
}
.sidebar-section-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
margin-bottom: 12px;
padding: 0 4px;
}
/* Phase stepper */
.phase-stepper {
display: flex;
flex-direction: column;
gap: 0;
}
.phase-step {
display: flex;
align-items: flex-start;
gap: 10px;
position: relative;
}
.phase-step:not(:last-child) .phase-step-line {
position: absolute;
left: 11px;
top: 24px;
bottom: 0;
width: 2px;
background: var(--border);
}
.phase-step-dot-col {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
padding-top: 3px;
}
.phase-step-dot {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--border);
background: var(--surface);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
transition: all 0.2s;
position: relative;
z-index: 1;
}
.phase-step.active .phase-step-dot {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--accent);
}
.phase-step.active.phase-diverging .phase-step-dot {
border-color: var(--yellow);
background: rgba(210, 153, 34, 0.15);
color: var(--yellow);
}
.phase-step.active.phase-closing .phase-step-dot {
border-color: var(--green);
background: rgba(63, 185, 80, 0.15);
color: var(--green);
}
.phase-step.done .phase-step-dot {
border-color: var(--green);
background: rgba(63, 185, 80, 0.15);
color: var(--green);
}
.phase-step-content {
flex: 1;
padding-bottom: 16px;
}
.phase-step-label {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
line-height: 22px;
transition: color 0.2s;
}
.phase-step.active .phase-step-label {
color: var(--text);
}
.phase-step.done .phase-step-label {
color: var(--text-muted);
}
.phase-step-sublabel {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
/* Diverge round sub-steps */
.diverge-rounds {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.diverge-round-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-muted);
padding: 2px 0;
}
.diverge-round-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--border);
flex-shrink: 0;
}
.diverge-round-item.active .diverge-round-dot {
background: var(--yellow);
box-shadow: 0 0 6px rgba(210, 153, 34, 0.5);
}
.diverge-round-item.done .diverge-round-dot {
background: var(--green);
}
.diverge-round-item.active {
color: var(--yellow);
}
.diverge-round-item.done {
color: var(--text-muted);
}
/* Connector line between phase steps */
.phase-connector {
width: 2px;
height: 8px;
background: var(--border);
margin-left: 10px;
}
/* Agent roster */
.agent-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.agent-item {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 10px;
border-radius: 10px;
transition: background 0.15s, color 0.15s, transform 0.15s;
background: transparent;
}
.agent-item-clickable {
cursor: pointer;
}
.agent-item-clickable:hover {
background: var(--surface-raised);
transform: translateX(2px);
}
.agent-item-active {
background: var(--accent-soft);
border-left: 2px solid var(--accent);
padding-left: 6px;
}
.agent-status-icon {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
flex-shrink: 0;
}
.agent-status-idle {
color: var(--border);
}
.agent-status-thinking {
color: var(--yellow);
}
.agent-status-done {
color: var(--green);
}
.agent-item-info {
flex: 1;
overflow: hidden;
}
.agent-item-name {
font-size: 12px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-item-dim {
font-size: 10px;
color: var(--text-muted);
}
@@ -0,0 +1,174 @@
import './Sidebar.css';
type Agent = {
id: string;
name?: string;
dimension?: string;
};
type AgentState = {
status?: string;
name?: string;
dimension?: string;
};
type Props = {
agents: Agent[];
agentStates: Record<string, AgentState>;
phase: string;
currentRound: number;
divergeRounds: number;
};
const PHASES = [
{ id: 'starting', label: '启动', dotLabel: '1' },
{ id: 'diverging', label: '辩论', dotLabel: '2' },
{ id: 'closing', label: '收敛', dotLabel: '3' },
{ id: 'done', label: '完成', dotLabel: '✓' },
];
const PHASE_ORDER = ['starting', 'diverging', 'closing', 'done'];
function getPhaseIndex(phase: string): number {
return PHASE_ORDER.indexOf(phase);
}
function scrollToTimelineElement(selector: string): void {
const appMain = document.querySelector('.app-main') as HTMLElement | null;
if (!appMain) return;
const el = appMain.querySelector(selector) as HTMLElement | null;
if (!el) return;
const targetRect = el.getBoundingClientRect();
const containerRect = appMain.getBoundingClientRect();
const targetScrollTop = appMain.scrollTop + targetRect.top - containerRect.top - 20;
(window as any).__mopeProgrammaticScroll = ((window as any).__mopeProgrammaticScroll || 0) + 1;
appMain.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
}
function scrollToDivergeRound(roundNum: number): void {
const appMain = document.querySelector('.app-main') as HTMLElement | null;
if (!appMain) return;
const divergeRounds = appMain.querySelectorAll('[data-phase="diverging"][data-round]');
const target = divergeRounds[roundNum - 1] as HTMLElement | undefined;
if (!target) return;
const targetRect = target.getBoundingClientRect();
const containerRect = appMain.getBoundingClientRect();
const targetScrollTop = appMain.scrollTop + targetRect.top - containerRect.top - 20;
(window as any).__mopeProgrammaticScroll = ((window as any).__mopeProgrammaticScroll || 0) + 1;
appMain.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
}
export default function Sidebar({
agents,
agentStates,
phase,
currentRound,
divergeRounds,
}: Props): JSX.Element {
const currentPhaseIdx = getPhaseIndex(phase);
return (
<div className="sidebar">
<div className="sidebar-section">
<div className="sidebar-section-title">进度</div>
<div className="phase-stepper">
{PHASES.map((p, idx) => {
const isActive = phase === p.id;
const isDone = currentPhaseIdx > idx;
const stepClass = `phase-step phase-${p.id}${isActive ? ' active' : ''}${isDone ? ' done' : ''}`;
return (
<div
key={p.id}
className={stepClass}
onClick={() => scrollToTimelineElement(`[data-phase="${p.id}"]`)}
title={`点击跳转到${p.label}阶段`}
style={{ cursor: 'pointer' }}
>
{idx < PHASES.length - 1 && <div className="phase-step-line" />}
<div className="phase-step-dot-col">
<div className="phase-step-dot">
{isDone ? '✓' : p.dotLabel}
</div>
</div>
<div className="phase-step-content">
<div className="phase-step-label">{p.label}</div>
{p.id === 'diverging' && (isDone || isActive) && divergeRounds > 0 && (
<div className="diverge-rounds">
{Array.from({ length: divergeRounds }, (_, i) => {
const roundNum = i + 1;
const isActiveDiverge = isActive && roundNum === (currentRound - 1);
const isDoneDiverge = isDone || (isActive && (currentRound - 1) > roundNum);
const itemClass = `diverge-round-item${isActiveDiverge ? ' active' : ''}${isDoneDiverge ? ' done' : ''}`;
return (
<div
key={roundNum}
className={itemClass}
onClick={(e) => {
e.stopPropagation();
scrollToDivergeRound(roundNum);
}}
title={`点击跳转到辩论轮 ${roundNum}`}
style={{ cursor: 'pointer' }}
>
<div className="diverge-round-dot" />
<span>辩论轮 {roundNum}</span>
</div>
);
})}
</div>
)}
{p.id === 'diverging' && !isDone && !isActive && divergeRounds > 0 && (
<div className="phase-step-sublabel">{divergeRounds} 轮</div>
)}
</div>
</div>
);
})}
</div>
</div>
{agents && agents.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">智能体</div>
<div className="agent-list">
{agents.map(agent => {
const agentState = agentStates[agent.id] || {};
const status = agentState.status || 'idle';
const handleClick = () => {
const cards = document.querySelectorAll(`[data-agent-id="${agent.id}"]`);
if (cards.length > 0) {
(cards[cards.length - 1] as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
return (
<div
key={agent.id}
className={`agent-item agent-item-clickable${status === 'thinking' ? ' agent-item-active' : ''}`}
onClick={handleClick}
title="点击跳转到最新输出"
>
<div className="agent-status-icon">
{status === 'idle' && <span className="agent-status-idle">○</span>}
{status === 'thinking' && <span className="agent-status-thinking spinner">⟳</span>}
{status === 'done' && <span className="agent-status-done">✓</span>}
</div>
<div className="agent-item-info">
<div className="agent-item-name">{agent.name || agent.id}</div>
<div className="agent-item-dim">{agent.dimension}</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,670 @@
.timeline {
display: flex;
flex-direction: column;
gap: 0;
max-width: 920px;
margin: 0 auto;
padding-bottom: 16px;
}
/* Empty state */
.timeline-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--text-muted);
gap: 12px;
}
.timeline-empty-spinner {
font-size: 28px;
animation: spin 1.2s linear infinite;
}
/* Round header */
.round-header {
display: flex;
align-items: center;
gap: 10px;
margin: 20px 0 12px;
padding: 0 2px;
}
.round-header:first-child {
margin-top: 0;
}
.round-header-line {
flex: 1;
height: 1px;
background: var(--border);
}
.round-header-label {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
white-space: nowrap;
letter-spacing: 0.3px;
}
.round-header-badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 700;
}
/* Background retrieval card */
.bg-retrieval-card {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 14px;
margin-bottom: 18px;
overflow: hidden;
box-shadow: var(--shadow-card);
}
.bg-retrieval-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
cursor: pointer;
background: var(--surface-hover);
user-select: none;
}
.bg-retrieval-header:hover {
background: var(--surface-hover-strong);
}
.bg-retrieval-title {
font-size: 13px;
font-weight: 700;
color: var(--text);
}
.bg-retrieval-meta {
font-size: 12px;
color: var(--text-muted);
margin-left: auto;
}
.bg-retrieval-chevron {
color: var(--text-muted);
font-size: 12px;
transition: transform 0.2s;
}
.bg-retrieval-chevron.open {
transform: rotate(180deg);
}
.bg-retrieval-body {
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 14px;
}
.bg-retrieval-dim {
display: flex;
flex-direction: column;
gap: 6px;
}
.bg-retrieval-dim-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--accent);
}
.bg-retrieval-result {
margin-bottom: 8px;
}
.bg-retrieval-query {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 4px;
font-style: italic;
}
.bg-retrieval-items {
display: flex;
flex-direction: column;
gap: 4px;
}
.bg-retrieval-item {
font-size: 12px;
color: var(--text);
line-height: 1.5;
padding: 4px 8px;
background: var(--surface-hover);
border-radius: 4px;
border-left: 2px solid var(--border);
}
/* Coordinator card */
.coordinator-card {
background: rgba(137, 87, 229, 0.06);
border: 1px solid rgba(137, 87, 229, 0.25);
border-radius: 14px;
padding: 14px 16px;
margin-bottom: 14px;
box-shadow: var(--shadow-card);
}
.coordinator-card-title {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--purple);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.coordinator-speakers {
font-size: 13px;
color: var(--text);
margin-bottom: 8px;
}
.coordinator-speaker-tag {
display: inline-block;
background: rgba(137, 87, 229, 0.15);
color: var(--purple);
border-radius: 4px;
padding: 1px 6px;
font-size: 12px;
margin: 0 3px;
font-weight: 600;
}
.coordinator-focus {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 6px;
}
.coordinator-focus-item {
display: flex;
gap: 8px;
align-items: flex-start;
font-size: 12px;
}
.coordinator-focus-agent {
font-weight: 700;
color: var(--purple);
flex-shrink: 0;
min-width: 60px;
}
.coordinator-focus-directive {
color: var(--text-muted);
line-height: 1.5;
}
.coordinator-round-badge {
font-weight: 400;
color: var(--text-muted);
text-transform: none;
letter-spacing: 0;
font-size: 11px;
}
.coordinator-speakers-label {
color: var(--text-muted);
margin-right: 4px;
}
/* 草稿进展区块 */
.coordinator-draft-section {
margin-bottom: 10px;
border: 1px solid rgba(137, 87, 229, 0.15);
border-radius: 6px;
overflow: hidden;
}
.coordinator-draft-header {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
background: rgba(137, 87, 229, 0.08);
cursor: pointer;
user-select: none;
}
.coordinator-draft-header:hover {
background: rgba(137, 87, 229, 0.13);
}
.coordinator-draft-title {
font-size: 12px;
font-weight: 600;
color: var(--purple);
}
.coordinator-draft-progress {
font-size: 11px;
color: var(--text-muted);
margin-left: auto;
}
.coordinator-draft-chevron {
font-size: 12px;
color: var(--text-muted);
transition: transform 0.15s;
}
.coordinator-draft-chevron.open {
transform: rotate(180deg);
}
.coordinator-draft-body {
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.coordinator-draft-field {
display: flex;
flex-direction: column;
gap: 2px;
padding: 5px 8px;
border-radius: 4px;
font-size: 12px;
}
.coordinator-draft-field.filled {
background: rgba(34, 197, 94, 0.06);
border-left: 2px solid rgba(34, 197, 94, 0.5);
}
.coordinator-draft-field.empty {
background: rgba(239, 68, 68, 0.05);
border-left: 2px solid rgba(239, 68, 68, 0.3);
}
.coordinator-draft-field-name {
font-weight: 600;
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.coordinator-draft-field-content {
color: var(--text);
line-height: 1.5;
}
.coordinator-draft-field-empty {
color: var(--text-muted);
font-style: italic;
}
/* 协调员流式动画点 */
.coordinator-streaming-dot {
color: var(--purple);
font-size: 10px;
animation: pulse 1s ease-in-out infinite;
margin-left: 6px;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* 协调员思考过程 */
.coordinator-thinking-section {
margin-bottom: 10px;
border: 1px solid rgba(137, 87, 229, 0.15);
border-radius: 6px;
overflow: hidden;
}
.coordinator-thinking-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: rgba(137, 87, 229, 0.06);
cursor: pointer;
user-select: none;
}
.coordinator-thinking-header:hover {
background: rgba(137, 87, 229, 0.1);
}
.coordinator-thinking-label {
font-size: 11px;
font-weight: 600;
color: var(--purple);
}
.coordinator-thinking-body {
padding: 8px 10px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
}
.coordinator-status-banner {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 7px 10px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 12px;
font-weight: 600;
}
.coordinator-status-banner-tail {
display: flex;
width: 100%;
margin-top: 10px;
margin-bottom: 0;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
box-shadow: 0 8px 18px color-mix(in srgb, var(--accent) 10%, transparent);
}
.coordinator-status-spinner {
display: inline-block;
animation: spin 1s linear infinite;
}
/* 活跃字段(正在生成)高亮 */
.coordinator-draft-field.active {
background: rgba(137, 87, 229, 0.08);
border-left: 2px solid rgba(137, 87, 229, 0.6);
}
/* 缺口列表 */
.coordinator-gaps {
margin-bottom: 10px;
}
.coordinator-gaps-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
color: #f59e0b;
margin-bottom: 4px;
}
.coordinator-gap-item {
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
padding: 1px 0;
}
.human-review-card {
background: rgba(88, 166, 255, 0.08);
border: 1px solid rgba(88, 166, 255, 0.28);
border-radius: 10px;
padding: 14px;
margin-bottom: 16px;
}
.human-review-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--accent);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
margin-bottom: 8px;
}
.human-review-hint {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 12px;
}
.human-review-chatbox {
min-height: 120px;
}
.human-review-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.human-review-field {
margin-bottom: 12px;
}
.human-review-field label {
display: block;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.human-review-input,
.human-review-textarea {
width: 100%;
background: var(--card);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 10px;
font-family: inherit;
font-size: 13px;
outline: none;
}
.human-review-speaker-select {
width: 100%;
}
.human-review-speaker-select .t-select__wrap,
.human-review-speaker-select .t-input,
.human-review-speaker-select .t-tag-input {
width: 100%;
}
.human-review-speaker-select .t-input,
.human-review-speaker-select .t-tag-input {
border-color: var(--border);
border-radius: 6px;
background: var(--card);
color: var(--text);
}
.human-review-speaker-select .t-input:hover,
.human-review-speaker-select .t-tag-input:hover,
.human-review-speaker-select .t-input.t-is-focused,
.human-review-speaker-select .t-tag-input.t-is-focused {
border-color: var(--accent);
}
.human-review-speaker-select .t-tag {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--border);
}
.human-review-textarea {
min-height: 220px;
resize: vertical;
font-family: 'SFMono-Regular', Consolas, monospace;
line-height: 1.5;
}
.human-review-textarea-sm {
min-height: 120px;
}
.human-review-input:focus,
.human-review-textarea:focus {
border-color: var(--accent);
}
.human-review-meta {
margin-top: 6px;
font-size: 11px;
color: var(--text-muted);
line-height: 1.5;
}
.human-review-actions {
display: flex;
justify-content: flex-end;
}
.human-review-actions-left {
justify-content: flex-start;
margin-top: 8px;
}
.human-review-error {
color: var(--red);
font-size: 12px;
margin-bottom: 10px;
}
@media (max-width: 900px) {
.human-review-grid {
grid-template-columns: 1fr;
}
}
/* 下滑按钮 */
.scroll-to-bottom-button {
position: fixed;
bottom: 34px;
left: 50%;
transform: translateX(-50%);
width: 48px;
height: 48px;
border-radius: 50%;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.34);
background:
radial-gradient(circle at 30% 22%, rgba(255, 255, 255, 0.42), transparent 34%),
linear-gradient(135deg, rgba(59, 130, 246, 0.46), rgba(124, 58, 237, 0.42));
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow:
0 16px 40px rgba(37, 99, 235, 0.28),
0 6px 18px rgba(15, 23, 42, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.24);
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
background 0.2s ease,
border-color 0.2s ease;
z-index: 1000;
backdrop-filter: blur(16px) saturate(1.2);
-webkit-backdrop-filter: blur(16px) saturate(1.2);
animation: scrollBtnIn 0.26s ease-out;
pointer-events: auto;
}
.scroll-to-bottom-button::before {
content: '';
position: absolute;
inset: -6px;
border-radius: inherit;
background: rgba(99, 102, 241, 0.12);
border: 1px solid rgba(255, 255, 255, 0.12);
pointer-events: none;
}
.scroll-to-bottom-button::after {
content: '';
position: absolute;
inset: 4px;
border-radius: inherit;
border: 1px solid rgba(255, 255, 255, 0.2);
pointer-events: none;
}
.scroll-to-bottom-button:hover {
background:
radial-gradient(circle at 30% 22%, rgba(255, 255, 255, 0.55), transparent 34%),
linear-gradient(135deg, rgba(59, 130, 246, 0.62), rgba(124, 58, 237, 0.58));
transform: translateX(-50%) translateY(-3px) scale(1.04);
box-shadow:
0 20px 48px rgba(37, 99, 235, 0.36),
0 10px 26px rgba(15, 23, 42, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.32);
border-color: rgba(255, 255, 255, 0.52);
}
.scroll-to-bottom-button:active {
transform: translateX(-50%) translateY(-1px) scale(0.97);
box-shadow:
0 10px 28px rgba(37, 99, 235, 0.28),
0 4px 14px rgba(15, 23, 42, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
}
@keyframes scrollBtnIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(12px) scale(0.86);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
}
.scroll-to-bottom-button .scroll-arrow {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
font-size: 24px;
line-height: 1;
font-weight: 800;
text-shadow: 0 2px 8px rgba(15, 23, 42, 0.32);
animation: scrollArrowBounce 1.35s ease-in-out infinite;
}
@keyframes scrollArrowBounce {
0%, 100% { transform: translateY(-1px); }
50% { transform: translateY(3px); }
}
@@ -0,0 +1,553 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Select } from 'tdesign-react';
import AgentCard from './AgentCard';
import FinalReport from './FinalReport';
import './Timeline.css';
const PHASE_LABELS: Record<string, string> = {
starting: '启动',
diverging: '辩论',
closing: '收敛',
done: '完成',
};
function getPhaseBadgeClass(phase?: string): string {
switch (phase) {
case 'starting': return 'badge badge-starting';
case 'diverging': return 'badge badge-diverging';
case 'closing': return 'badge badge-closing';
case 'done': return 'badge badge-done';
default: return 'badge badge-default';
}
}
function BackgroundRetrievalCard({ data }: { data: any }): JSX.Element {
const [open, setOpen] = useState(false);
const { results, dimensions, total_records } = data;
return (
<div className="bg-retrieval-card">
<div className="bg-retrieval-header" onClick={() => setOpen(o => !o)}>
<span className="bg-retrieval-title">背景检索完成</span>
<span className="bg-retrieval-meta">
{dimensions && dimensions.length > 0 ? `${dimensions.length} 维度` : ''}
{total_records != null ? ` · ${total_records} 条` : ''}
</span>
<span className={`bg-retrieval-chevron ${open ? 'open' : ''}`}>▾</span>
</div>
{open && results && (
<div className="bg-retrieval-body">
{Object.entries(results).map(([dim, entries]) => (
<div key={dim} className="bg-retrieval-dim">
<div className="bg-retrieval-dim-label">{dim}</div>
{Array.isArray(entries) && entries.map((entry: any, i: number) => (
<div key={i} className="bg-retrieval-result">
{entry.query && (
<div className="bg-retrieval-query">查询: "{entry.query}"</div>
)}
<div className="bg-retrieval-items">
{Array.isArray(entry.content) && entry.content.map((item: any, j: number) => (
<div key={j} className="bg-retrieval-item">
{typeof item === 'string'
? (item.length > 250 ? item.slice(0, 250) + '…' : item)
: JSON.stringify(item).slice(0, 250)}
</div>
))}
</div>
</div>
))}
</div>
))}
</div>
)}
</div>
);
}
function CoordinatorStreamingCard({ item, reportSchema }: { item: any; reportSchema: Record<string, any> }): JSX.Element {
const { phase, divergeRound, totalDivergeRounds, thinking, content, statusMessage, currentField, reportPartial } = item;
const [thinkingOpen, setThinkingOpen] = useState(false);
const isFinalReport = phase === 'final_report';
const reportFields = reportSchema ? Object.keys(reportSchema) : [];
return (
<div className="coordinator-card coordinator-card-streaming">
<div className="coordinator-card-title">
<span>协调员</span>
{!isFinalReport && divergeRound != null && (
<span className="coordinator-round-badge">
辩论轮 {divergeRound}/{totalDivergeRounds}
</span>
)}
{isFinalReport && (
<span className="coordinator-round-badge">生成最终方案</span>
)}
<span className="coordinator-streaming-dot">●</span>
</div>
{thinking && (
<div className="coordinator-thinking-section">
<div className="coordinator-thinking-header" onClick={() => setThinkingOpen(o => !o)}>
<span className="coordinator-thinking-label">思考中</span>
<span className={`coordinator-draft-chevron ${thinkingOpen ? 'open' : ''}`}>▾</span>
</div>
{thinkingOpen && (
<div className="coordinator-thinking-body">{thinking}</div>
)}
</div>
)}
{isFinalReport && reportFields.length > 0 && (
<div className="coordinator-draft-body" style={{ padding: '8px 0 0' }}>
{reportFields.map(field => {
const done = !!reportPartial[field];
const isActive = currentField === field && !done;
return (
<div key={field} className={`coordinator-draft-field ${done ? 'filled' : isActive ? 'active' : 'empty'}`}>
<span className="coordinator-draft-field-name">
{field}
{isActive && <span className="coordinator-streaming-dot" style={{ marginLeft: 6 }}>●</span>}
</span>
{done
? <span className="coordinator-draft-field-content">{reportPartial[field].slice(0, 200)}{reportPartial[field].length > 200 ? '…' : ''}</span>
: isActive && content
? <span className="coordinator-draft-field-content">{content.slice(-400)}</span>
: <span className="coordinator-draft-field-empty">{isActive ? '生成中…' : '等待中'}</span>
}
</div>
);
})}
</div>
)}
{!isFinalReport && content && (
<div className="coordinator-thinking-body" style={{ marginTop: 6 }}>{content.slice(-600)}</div>
)}
{statusMessage && !isFinalReport && (
<div className="coordinator-status-banner coordinator-status-banner-tail">
<span className="coordinator-status-spinner">⟳</span>
<span>{statusMessage}</span>
</div>
)}
</div>
);
}
function CoordinatorCard({ data, agentStates, thinking }: { data: any; agentStates: Record<string, any>; thinking?: string }): JSX.Element {
const { divergeRound, totalDivergeRounds, nextSpeakers, focus, draft } = data;
const [draftOpen, setDraftOpen] = useState(false);
const [thinkingOpen, setThinkingOpen] = useState(false);
const draftEntries = draft ? Object.entries(draft) : [];
const filledCount = draftEntries.filter(([, v]: [string, any]) => v && String(v).trim()).length;
return (
<div className="coordinator-card">
<div className="coordinator-card-title">
<span>协调员</span>
{divergeRound != null && totalDivergeRounds != null && (
<span className="coordinator-round-badge">
辩论轮 {divergeRound}/{totalDivergeRounds}
</span>
)}
</div>
{thinking && thinking.trim() && (
<div className="coordinator-thinking-section">
<div className="coordinator-thinking-header" onClick={() => setThinkingOpen(o => !o)}>
<span className="coordinator-thinking-label">思考过程</span>
<span className={`coordinator-draft-chevron ${thinkingOpen ? 'open' : ''}`}>▾</span>
</div>
{thinkingOpen && (
<div className="coordinator-thinking-body">{thinking}</div>
)}
</div>
)}
{draftEntries.length > 0 && (
<div className="coordinator-draft-section">
<div
className="coordinator-draft-header"
onClick={() => setDraftOpen(o => !o)}
>
<span className="coordinator-draft-title">方案草稿</span>
<span className="coordinator-draft-progress">
{filledCount}/{draftEntries.length} 字段已填写
</span>
<span className={`coordinator-draft-chevron ${draftOpen ? 'open' : ''}`}>▾</span>
</div>
{draftOpen && (
<div className="coordinator-draft-body">
{draftEntries.map(([field, content]: [string, any]) => (
<div key={field} className={`coordinator-draft-field ${content ? 'filled' : 'empty'}`}>
<span className="coordinator-draft-field-name">{field}</span>
{content
? <span className="coordinator-draft-field-content">{String(content).slice(0, 300)}{String(content).length > 300 ? '…' : ''}</span>
: <span className="coordinator-draft-field-empty">空缺</span>
}
</div>
))}
</div>
)}
</div>
)}
{nextSpeakers && nextSpeakers.length > 0 && (
<div className="coordinator-speakers">
<span className="coordinator-speakers-label">下一轮:</span>
{nextSpeakers.map((id: string) => {
const name = agentStates?.[id]?.name || id;
return <span key={id} className="coordinator-speaker-tag">{name}</span>;
})}
</div>
)}
{focus && Object.keys(focus).length > 0 && (
<div className="coordinator-focus">
{Object.entries(focus).map(([agentId, directive]) => {
const name = agentStates?.[agentId]?.name || agentId;
return (
<div key={agentId} className="coordinator-focus-item">
<span className="coordinator-focus-agent">{name}</span>
<span className="coordinator-focus-directive">{String(directive)}</span>
</div>
);
})}
</div>
)}
</div>
);
}
function HumanReviewCard({ pendingReview, agentStates, onApprove, onRevise, submitting, revising }: any) {
const [nextSpeakers, setNextSpeakers] = useState<string[]>(pendingReview.nextSpeakers || []);
const [focusText, setFocusText] = useState(JSON.stringify(pendingReview.focus || {}, null, 2));
const [draftText, setDraftText] = useState(JSON.stringify(pendingReview.draft || {}, null, 2));
const [gapsText, setGapsText] = useState(JSON.stringify(pendingReview.gaps || [], null, 2));
const [instruction, setInstruction] = useState('');
const [error, setError] = useState('');
useEffect(() => {
setNextSpeakers(pendingReview.nextSpeakers || []);
setFocusText(JSON.stringify(pendingReview.focus || {}, null, 2));
setDraftText(JSON.stringify(pendingReview.draft || {}, null, 2));
setGapsText(JSON.stringify(pendingReview.gaps || [], null, 2));
}, [pendingReview]);
const agentOptions = Object.entries(agentStates || {}).map(([id, agent]: [string, any]) => ({
label: agent.name || id,
value: id,
}));
const handleSpeakerChange = (value: any) => {
const values = Array.isArray(value) ? value : [];
setNextSpeakers(values.map(item => String(item)));
};
const handleSubmit = async () => {
try {
const parsedFocus = JSON.parse(focusText || '{}');
const parsedDraft = JSON.parse(draftText || '{}');
const parsedGaps = JSON.parse(gapsText || '[]');
const nextSpeakerIds = nextSpeakers.map(item => item.trim()).filter(Boolean);
setError('');
await onApprove({
nextSpeakers: nextSpeakerIds,
focus: parsedFocus,
draft: parsedDraft,
gaps: parsedGaps,
});
} catch (err: any) {
setError(err?.message || '提交失败');
}
};
const handleRevise = async () => {
if (!instruction.trim()) {
setError('请先输入你希望协调员如何修改');
return;
}
try {
setError('');
await onRevise(instruction.trim());
setInstruction('');
} catch (err: any) {
setError(err?.message || '修改失败');
}
};
return (
<div className="human-review-card">
<div className="human-review-title">
<span>人工确认</span>
<span className="coordinator-round-badge">
辩论轮 {pendingReview.divergeRound}/{pendingReview.totalDivergeRounds}
</span>
</div>
<div className="human-review-hint">
优先用自然语言告诉协调员怎么改;如果需要,也可以直接微调下方结构化内容。
</div>
<div className="human-review-field">
<label>对协调员说</label>
<textarea
className="human-review-textarea human-review-chatbox"
value={instruction}
onChange={e => setInstruction(e.target.value)}
placeholder="例如:不要让 risk 发言,改成 audience 和 narrative。把 focus 改得更具体,要求 narrative 给出 2 条可以直接发微博的文案。draft 里的 audience_map 先不要定死,保留为待验证。"
disabled={submitting || revising}
/>
<div className="human-review-actions human-review-actions-left">
<button className="btn-secondary" onClick={handleRevise} disabled={submitting || revising}>
{revising ? '协调员修改中…' : '让协调员修改'}
</button>
</div>
</div>
<div className="human-review-field">
<label>下一轮发言者</label>
<Select
className="human-review-speaker-select"
value={nextSpeakers}
options={agentOptions}
multiple
clearable
filterable
minCollapsedNum={3}
placeholder="请选择下一轮发言者"
disabled={submitting || revising}
onChange={handleSpeakerChange}
/>
<div className="human-review-meta">
可选 agent: {agentOptions.map(option => `${option.label}(${option.value})`).join(' / ')}
</div>
</div>
<div className="human-review-grid">
<div className="human-review-field">
<label>任务指令 focus(JSON)</label>
<textarea
className="human-review-textarea"
value={focusText}
onChange={e => setFocusText(e.target.value)}
disabled={submitting || revising}
spellCheck={false}
/>
</div>
<div className="human-review-field">
<label>草稿 draft(JSON)</label>
<textarea
className="human-review-textarea"
value={draftText}
onChange={e => setDraftText(e.target.value)}
disabled={submitting || revising}
spellCheck={false}
/>
</div>
</div>
<div className="human-review-field">
<label>当前缺口 gaps(JSON 数组)</label>
<textarea
className="human-review-textarea human-review-textarea-sm"
value={gapsText}
onChange={e => setGapsText(e.target.value)}
disabled={submitting || revising}
spellCheck={false}
/>
</div>
{error && <div className="human-review-error">{error}</div>}
<div className="human-review-actions">
<button className="btn-primary" onClick={handleSubmit} disabled={submitting || revising}>
{submitting ? '提交中…' : '确认并继续'}
</button>
</div>
</div>
);
}
type Props = {
timeline: any[];
agentStates: Record<string, any>;
reportSchema: Record<string, any>;
pendingReview: any;
onApproveReview: (payload: any) => Promise<void>;
onReviseReview: (instruction: string) => Promise<void>;
reviewSubmitting: boolean;
reviewRevising: boolean;
};
export default function Timeline({ timeline, agentStates, reportSchema, pendingReview, onApproveReview, onReviseReview, reviewSubmitting, reviewRevising }: Props): JSX.Element {
const endRef = useRef<HTMLDivElement | null>(null);
const programmaticScrollUntilRef = useRef(0);
const [showScrollButton, setShowScrollButton] = useState(false);
const [isAutoScrolling, setIsAutoScrolling] = useState(true);
const doScrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
const appMainElement = document.querySelector('.app-main') as HTMLElement | null;
if (!appMainElement) return;
programmaticScrollUntilRef.current = Date.now() + (behavior === 'smooth' ? 700 : 120);
appMainElement.scrollTo({ top: appMainElement.scrollHeight, behavior });
}, []);
useEffect(() => {
const handleScroll = () => {
const appMainElement = document.querySelector('.app-main') as HTMLElement | null;
if (!appMainElement) return;
const { scrollTop, scrollHeight, clientHeight } = appMainElement;
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
const isNearBottom = distanceToBottom < 24;
const isProgrammaticScroll = Date.now() < programmaticScrollUntilRef.current;
setShowScrollButton(!isNearBottom);
if (isNearBottom) {
setIsAutoScrolling(true);
return;
}
if (!isProgrammaticScroll) {
setIsAutoScrolling(false);
}
};
const appMainElement = document.querySelector('.app-main') as HTMLElement | null;
if (appMainElement) {
appMainElement.addEventListener('scroll', handleScroll, { passive: true });
}
return () => {
if (appMainElement) {
appMainElement.removeEventListener('scroll', handleScroll);
}
};
}, []);
useEffect(() => {
if (isAutoScrolling) {
doScrollToBottom('auto');
}
}, [timeline, pendingReview, isAutoScrolling, doScrollToBottom]);
const scrollToBottom = () => {
setIsAutoScrolling(true);
setShowScrollButton(false);
doScrollToBottom('smooth');
};
if (!timeline || timeline.length === 0) {
return (
<div className="timeline">
<div className="timeline-empty">
<div className="timeline-empty-spinner">⟳</div>
<span>等待智能体启动…</span>
</div>
</div>
);
}
return (
<div className="timeline">
{timeline.map((item, idx) => {
if (item.type === 'background_retrieval') {
return <BackgroundRetrievalCard key={idx} data={item.data} />;
}
if (item.type === 'round_start') {
return (
<div key={idx} className="round-header" data-phase={item.phase} data-round={item.round}>
<div className="round-header-line" />
<span className="round-header-label">第 {item.round} 轮</span>
<span className={getPhaseBadgeClass(item.phase)}>
{PHASE_LABELS[item.phase] || item.phase}
</span>
<div className="round-header-line" />
</div>
);
}
if (item.type === 'agent_contribution') {
const { agentId, round, phase, data, streaming } = item;
const agentState = agentStates[agentId] || {};
return (
<div key={`${agentId}-${round}`} data-agent-id={agentId}>
<AgentCard
agentId={agentId}
agentName={agentState.name || agentId}
dimension={agentState.dimension || agentId}
round={round}
phase={phase}
content={data.content}
thinking={data.thinking}
dimensionOutput={data.dimensionOutput}
retrievalLog={data.retrievalLog}
retrievalCount={data.retrievalCount}
referenceMap={data.referenceMap}
streaming={!!streaming}
/>
</div>
);
}
if (item.type === 'coordinator_streaming') {
return (
<div key={idx} data-phase={item.phase || 'closing'}>
<CoordinatorStreamingCard item={item} reportSchema={reportSchema} />
</div>
);
}
if (item.type === 'coordinator') {
return (
<div key={idx} data-phase={item.data?.phase || 'closing'}>
<CoordinatorCard data={item.data} agentStates={agentStates} thinking={item.thinking} />
</div>
);
}
if (item.type === 'final_report') {
return (
<div key={idx} data-phase="done">
<FinalReport output={item.data} reportSchema={reportSchema} />
</div>
);
}
return null;
})}
{pendingReview && (
<HumanReviewCard
pendingReview={pendingReview}
agentStates={agentStates}
onApprove={onApproveReview}
onRevise={onReviseReview}
submitting={reviewSubmitting}
revising={reviewRevising}
/>
)}
<div ref={endRef} />
{showScrollButton && (
<button
className="scroll-to-bottom-button"
onClick={scrollToBottom}
aria-label="滚动到底部并开启自动跟随"
title="回到底部并继续自动下滑"
>
<span className="scroll-arrow">↓</span>
</button>
)}
</div>
);
}
@@ -0,0 +1,176 @@
/**
* defaultConfig.ts - 默认配置模块
*
* 本模块定义应用的默认配置值
* 包括后端服务器地址和初始配置
* 这些配置在用户没有自定义时会作为默认值使用
*/
/**
* 默认后端服务器地址
* 用于前端与后端 API 通信
*/
export const DEFAULT_BACKEND_URL = 'http://10.26.128.38:8030'
/**
* 默认应用配置
* 当用户没有选择任何模板时使用的初始配置
* 包含:
* - 协调者模式配置
* - 协调者提示词
* - 默认智能体列表
* - 阶段配置
* - 报告结构定义
*/
export const DEFAULT_CONFIG = {
/** 任务背景(用户输入) */
task_context: "",
/** 协调者模式:auto=自动, human_review=人工确认 */
coordinator_mode: "auto",
/** 协调者配置 - 包含各种提示词和规则 */
coordinator_config: {
/** 系统提示前缀 - 协调者的全局规则 */
system_prompt_prefix: "",
/** 首轮策略 - 第1轮辩论时的指导 */
round1_guidance: `这是发散阶段第1轮,草稿刚起步。
策略:优先填写草稿中空缺最关键的字段,同时识别智能体之间最尖锐的分歧——
这些分歧往往意味着草稿中某个判断还无法做出,需要通过交锋来解决。`,
/** 中间轮策略 - 中间轮辩论时的指导 */
middle_round_guidance: `这是发散阶段的中间轮次。
策略:审查草稿现状,找出仍然太模糊或有冲突的字段,
选择能推进这些字段的智能体,给出让草稿变得更具体的任务。`,
/** 末轮策略 - 最后一轮辩论时的指导 */
final_round_guidance: `这是发散阶段最后一轮。
策略:草稿必须在这轮后达到可以收尾的状态,但仍要求智能体给出完整材料包,
包括明确判断、依据链、反例、风险边界和可核验线索;最终压缩、取舍和成文由协调者完成。`,
/** 选人规则 - 如何选择下一轮发言的智能体 */
speaker_selection_rules: "优先选择最能扩充 partial/blocked 字段材料、解决关键冲突、补充证据或论证链的智能体。",
/** 任务指令规则 - 如何生成给智能体的任务 */
focus_instruction_rules: "focus 必须写成内容型任务书,不能只催促 agent 继续分析。每条至少包含:目标字段、已有材料或草稿判断、真正缺口、为什么该缺口会阻塞报告、需要补充的具体材料类型、建议切入角度、预期输出形态、风险或禁止事项。指令应鼓励 agent 展开材料、证据线索、论证链、反例、分歧和边界条件,而不是只给短结论。",
/** 依赖推进规则 - 如何处理字段间的依赖关系 */
dependency_driving_rules: "",
/** 一致性/冲突检查规则 */
consistency_check_rules: "",
/** 人工修改解释规则 */
revise_prompt_prefix: ""
},
/** 默认智能体列表 */
agents: [
{
/** 背景研判师 */
id: "situation",
name: "背景研判师",
dimension: "背景研判",
expertise: "你是一位资深的舆情和战略分析专家。你的任务是判断当前舆论场的格局:现在存在哪几条叙事,各自的势头如何,哪些认知锚点可以被打散,哪些比较维度可以被引入或重构,竞争对手的动作有哪些逻辑漏洞。不是做损失报告,而是做战略研判——找到可以操作的叙事空间。",
},
{
/** 目的策略师 */
id: "purpose",
name: "目的策略师",
dimension: "传播目标",
expertise: "你是一位认知传播专家,专门设计可实现的传播目标——即通过传播内容能够在受众头脑中建立的具体认知或情绪。你不关心商业结果,只关心:这次传播结束后,目标受众的想法和感受会发生什么具体变化?用第一人称写出目标受众在成功传播后的内心独白。也可以设计竞争性目标(让受众对竞品产生质疑)。",
},
{
/** 受众分析师 */
id: "audience",
name: "受众分析师",
dimension: "受众图谱",
expertise: "你是一位受众研究专家。你的任务是识别具体的、可操作的目标受众群体。拒绝泛化:不接受'年轻人''汽车爱好者'这类描述,必须给出年龄段、性别比例、城市层级、具体平台和具体账号/社区名称。同时要写出该群体当前的心理状态——他们现在在想什么,核心疑虑是什么,用第一人称写出他们的内心独白。",
},
{
/** 叙事策划师 */
id: "narrative",
name: "叙事策划师",
dimension: "叙事方案",
expertise: "你是一位顶尖的叙事战略家和内容策划师。你的任务是设计具体的叙事支柱:每个支柱必须包含(1)具体内容——发什么、怎么说,给出可执行的内容方向或文案草稿;(2)为什么这么设计——预期的心理机制;(3)预期效果——目标受众会产生什么认知变化;(4)风险与局限——在什么条件下会失效或反噬。叙事框架和具体消息写在一起,不要分开。",
}
],
/** 阶段配置 */
phase_config: { diverge_rounds: 3 },
/** 模板 ID(默认未选择) */
template_id: "",
/** 模板名称 */
template_name: "",
/** 模板简短描述 */
template_brief: "",
/** 报告结构定义 - 字段名到描述的映射 */
report_schema: {
/** 背景研判:当前舆论场/市场/竞争格局的战略研判 */
situation_assessment: "背景研判:当前舆论场/市场/竞争格局的战略研判。包含现有几条并行叙事及其势头、可以被打散的认知锚点、竞争对手动作的逻辑漏洞、可操作的叙事空间。不是损失报告,是战略判断。",
/** 传播目标:2-4个本方案期望在受众中建立的具体认知或情绪 */
communication_objectives: "传播目标:2-4个本方案期望在受众中建立的具体认知或情绪,用目标受众的第一人称内心独白表达。不写商业结果,不写指标,只写认知和情绪目标。可含竞争性目标。",
/** 核心受众:2-4个具体受众群体 */
audience_map: "核心受众:2-4个具体受众群体,每个包含:(1)人口画像——年龄段、性别、城市层级、具体特征;(2)当前心理状态——用第一人称内心独白;(3)关键信息节点——具体平台和账号/社区名称,不接受泛化描述。",
/** 叙事方案:2-3个叙事支柱 */
narrative_plan: "叙事方案:2-3个叙事支柱,每个包含:(1)具体内容——可执行的内容方向或文案草稿;(2)为什么这么设计——预期心理机制;(3)预期效果——目标受众的认知变化;(4)风险与局限——失效条件。叙事框架和消息合并在一起。",
/** 分阶段行动计划 */
action_plan: "分阶段行动计划:按时间线的3-4个阶段,每个行动具体到:做什么、谁执行(官方/第三方/独立账号)、何时。",
/** 风险预案:3-5个风险场景 */
risk_playbook: "风险预案:3-5个风险场景,每个含触发条件、具体应对步骤、明确禁止的动作。"
},
/** 报告示例 */
report_examples: {
situation_assessment: `事故发生后,舆论场中存在三条并行叙事:
- 主流情绪叙事(当前占主导):"电动车关门困死人"——聚焦电动门锁这一单一缺陷,将问界塑造为主动伤人的产品
- 竞品借势叙事:理想、小米相继发声强调"物理门把手不会消失",将安全心智差异化
- 技术中性叙事(目前声量极弱):部分汽车工程师账号指出燃油车追尾同样可能起火且无法逃生,但未被放大
核心判断:目前的"关门困死人"叙事对问界的伤害远超事故本身,因为它指向的是所有电动车门锁的系统性问题,而非这次追尾的偶发性。叙事反转的关键在于:将"问界门锁缺陷"重新定框为"追尾高速起火是极端工况,不同于日常安全风险",同时打开"燃油车在同等追尾条件下结果是否更好"这一比较维度。
此外,竞品的"物理门把手"营销动作,实质上是在暗示所有纯电动车都有同等风险——问界可以借助行业数据,将这一模糊指控反向用于质疑竞品的安全宣传逻辑。`,
communication_objectives: `目标一:让现有问界车主相信此次事故属于极端偶发工况
> "这是一辆车被高速追尾、车身严重变形后才发生的,不是日常开车会遇到的情况,和我每天通勤的安全性是两件事。"
目标二:让公众了解燃油车在同等追尾工况下的起火概率并不低于电动车
> "我以为电动车特别容易起火,看了数据才知道燃油车追尾也会爆炸,而且逃生时间更短。这个问题不是问界独有的。"
目标三:让潜在购车者相信问界已做出真实的技术改进,而非只是公关操作
> "他们不只是道歉,而是真的改了——门锁逻辑改了,还加了物理把手,有第三方测了。这和之前那些只道歉不改的品牌不一样。"
目标四(竞争性):在科技圈和华为生态用户中种下"竞品安全宣传是过度营销"的质疑
> "理想说保留物理门把手就安全了?那燃油车追尾不也照样困死人,他们这是蹭热度。"`,
audience_map: `受众一:现有问界M7车主(最优先)
- 画像:25-45岁男性为主(问界买家中男性占比约75%),华为手机深度用户,在二三线城市比例高于理想/小米,家庭用车场景为主
- 当前心理状态:"在家人追问压力下动摇,既不想认错(承认买错了),又需要一个可以说服家人的说法"
- 关键触点:AITO官方App推送、华为花粉俱乐部社区(注册用户2000万+)、各省市问界车主微信群
受众二:考虑30万内家用SUV的25-38岁已婚男性(主要流失群体)
- 画像:集中在北京、上海、成都、杭州、武汉;在懂车帝/汽车之家浏览过问界M7、理想L7对比页面;家庭有6岁以下子女的比例约40%,配偶对选车有实质否决权
- 当前心理状态:"事故前已接近下定,现在进入观望,主要疑虑是'安全有保障吗',在等一个可以放心的理由"
- 关键触点:懂车帝的问界M7评测区(日均浏览约4万次)、B站@42号车库(汽车技术向,260万粉)、@老司机测评(抖音+微博,180万粉)`,
narrative_plan: `叙事支柱一:工况重构——把"电动门锁缺陷"扩展为"追尾高速起火"议题
具体内容:发布或推动媒体发布《追尾高速事故生存数据分析》,引用可验证的事故数据:中国高速追尾事故中燃油车起火比例、燃油车在高速追尾后因油箱破裂导致无法逃生的案例、断电门锁问题在行业内的普遍性(其他品牌纯电车是否存在类似设计)。
为什么这么设计:公众对"电动车起火"的恐惧,部分来自"燃油车才是安全默认值"的认知锚点。此叙事目标不是洗白问界,而是打散原有的比较基准——把"问界门锁失效"从"品牌失职"变成"高速追尾这一极端工况下的行业性挑战"。
预期效果:目标受众在搜索"问界安全"时,搜到的不是事故视频,而是工况对比分析,认知从"问界车会困死人"转向"高速追尾是极端事故,概率极低"。
风险与局限:如果数据无法支持"燃油车风险不低于电动车"的结论,此叙事会反噬——数据必须真实可查。部分受众会将此解读为"转移焦点"。`
}
}
@@ -0,0 +1,153 @@
/**
* templateStore.ts - 模板存储管理模块
*
* 本模块负责从后端加载模板配置,并支持本地覆盖功能
* 模板存储在 /public/templates 目录下,通过 fetch API 加载
*
* 主要功能:
* - 加载模板索引列表
* - 根据 ID 或元数据加载单个模板
* - 从本地存储读取/保存模板覆盖
* - 检查模板是否有本地覆盖
*/
import type { Template, TemplateMeta } from '../types'
/**
* 本地存储中覆盖模板的前缀
* 完整 key 格式:mope_template_override:{template_id}
*/
const OVERRIDE_PREFIX = 'mope_template_override:'
/**
* 深度克隆对象
* 使用 JSON 序列化/反序列化实现
* 注意:这会丢失 undefined、正则、函数等特殊类型
*
* @param value - 需要克隆的值
* @returns 克隆后的新值
*/
export function clone<T>(value: T): T {
return JSON.parse(JSON.stringify(value))
}
/**
* 加载模板索引列表
* 从 http://ch1.b.uat.4.cn/zncmweb/templates/index.json 获取所有可用模板的元数据
*
* @returns 模板元数据数组
* @throws 如果请求失败或响应非 OK,抛出错误
*/
export async function loadTemplateIndex(): Promise<TemplateMeta[]> {
const response = await fetch('https://ch1.b.uat.4.cn/zncmweb/templates/index.json')
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
return (data.templates || []) as TemplateMeta[]
}
/**
* 根据元数据或 ID 加载模板
* 如果本地有覆盖,返回覆盖版本
*
* @param metaOrId - 模板元数据对象或模板 ID 字符串
* @returns 模板对象
* @throws 如果请求失败或响应非 OK,抛出错误
*/
export async function loadTemplateByMeta(metaOrId: TemplateMeta | string): Promise<Template> {
// 提取模板 ID
const templateId = typeof metaOrId === 'string'
? metaOrId
: metaOrId?.template_id
// 确定请求的 URL
const file = typeof metaOrId === 'string'
? `/templates/${templateId}.json`
: (metaOrId as TemplateMeta)?.file
const response = await fetch(file || `/templates/${templateId}.json`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const template = await response.json() as Template
// 检查是否有本地覆盖
const override = getTemplateOverride(template.template_id || templateId || '')
// 如果有覆盖,返回覆盖版本
return override ? clone(override) : template
}
/**
* 加载原始模板(忽略本地覆盖)
* 用于在用户重置模板时恢复到原始状态
*
* @param metaOrId - 模板元数据对象或模板 ID 字符串
* @returns 原始模板对象
* @throws 如果请求失败或响应非 OK,抛出错误
*/
export async function loadOriginalTemplate(metaOrId: TemplateMeta | string): Promise<Template> {
// 提取模板 ID
const templateId = typeof metaOrId === 'string'
? metaOrId
: metaOrId?.template_id
// 确定请求的 URL
const file = typeof metaOrId === 'string'
? `/templates/${templateId}.json`
: (metaOrId as TemplateMeta)?.file
const response = await fetch(file || `/templates/${templateId}.json`)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json() as Promise<Template>
}
/**
* 从本地存储获取模板覆盖
*
* @param templateId - 模板 ID
* @returns 覆盖的模板对象,如果没有覆盖则返回 null
*/
export function getTemplateOverride(templateId: string): Template | null {
if (!templateId) return null
try {
const raw = localStorage.getItem(`${OVERRIDE_PREFIX}${templateId}`)
return raw ? JSON.parse(raw) : null
} catch {
return null
}
}
/**
* 检查指定模板是否有本地覆盖
*
* @param templateId - 模板 ID
* @returns 如果有覆盖返回 true,否则返回 false
*/
export function hasTemplateOverride(templateId: string): boolean {
return Boolean(getTemplateOverride(templateId))
}
/**
* 保存模板覆盖到本地存储
*
* @param templateId - 模板 ID
* @param template - 要保存的模板对象
* @throws 如果 templateId 为空,抛出错误
*/
export function saveTemplateOverride(templateId: string, template: Template): void {
if (!templateId) throw new Error('缺少 template_id')
localStorage.setItem(
`${OVERRIDE_PREFIX}${templateId}`,
JSON.stringify(template)
)
}
/**
* 清除指定模板的本地覆盖
*
* @param templateId - 模板 ID
*/
export function clearTemplateOverride(templateId: string): void {
if (!templateId) return
localStorage.removeItem(`${OVERRIDE_PREFIX}${templateId}`)
}
+756
View File
@@ -0,0 +1,756 @@
/**
* 全局类型定义文件
* 本文件定义了项目中使用的所有 TypeScript 类型和接口
* 包含组件 props、状态类型、API 响应类型等
*/
/**
* =============================================================================
* 智能体相关类型
* =============================================================================
*/
/**
* 智能体基本信息
* 描述一个智能体的标识和元数据
*/
export interface Agent {
/** 智能体唯一标识符 */
id: string;
/** 智能体显示名称 */
name: string;
/** 智能体所属的分析维度 */
dimension: string;
/** 专家角色描述或智能体提示词 */
expertise?: string;
/** 技能描述(与 expertise 类似) */
skill?: string;
/** 场景角色描述 */
scenario_role?: string;
/** 智能体描述 */
description?: string;
/** 该智能体负责的报告字段列表 */
relevant_fields?: string[];
/** 分析契约 - 智能体输出规则列表 */
analysis_contract?: string[];
/** 检索模式:always=总是检索, never=从不检索, smart=智能检索 */
retrieval_mode?: 'always' | 'never' | 'smart';
/** 允许检索的数据源 ID 列表 */
allowed_source_ids?: string[];
/** 检索补充说明 */
retrieval_instruction?: string;
}
/**
* 智能体状态
* 描述智能体在运行时的实时状态
*/
export interface AgentState {
/** 智能体唯一标识符 */
id: string;
/** 智能体显示名称 */
name: string;
/** 智能体所属的分析维度 */
dimension: string;
/** 当前状态:idle=空闲, thinking=思考中, done=已完成 */
status: 'idle' | 'thinking' | 'done';
/** 该智能体已完成的分析轮次列表 */
rounds: AgentRoundData[];
}
/**
* 智能体单轮分析数据
* 包含智能体在某一轮中的完整输出
*/
export interface AgentRoundData {
/** 轮次编号 */
round: number;
/** 所处阶段 */
phase: PhaseType;
/** 分析内容(Markdown 格式) */
content: string;
/** 思考过程(Markdown 格式) */
thinking: string;
/** 检索日志列表 */
retrievalLog: RetrievalLogItem[];
/** 检索次数 */
retrievalCount: number;
/** 引用映射表:将引用编号映射到实际数据源 ID */
referenceMap: Record<string, string>;
/** 额外元数据(可选) */
metadata?: Record<string, unknown>;
/** 维度输出 */
dimensionOutput?: Record<string, string>;
}
/**
* 检索日志条目
* 记录一次检索操作的详细信息
*/
export interface RetrievalLogItem {
/** 检索查询语句 */
query: string;
/** 数据源名称 */
source_name?: string;
/** 数据源 ID */
source_id?: string;
/** 数据源标识 */
source?: string;
/** 检索结果内容列表 */
content: (string | Record<string, unknown>)[];
/** 内容 ID 列表(用于关联 referenceMap) */
content_ids?: string[];
/** 该检索结果是否被智能体采用 */
adopted?: boolean;
}
/**
* =============================================================================
* 会话和状态相关类型
* =============================================================================
*/
/**
* 会话状态
* 描述整个应用的状态机状态
*/
export type SessionStatus =
| 'idle' // 空闲状态 - 初始状态,未开始会话
| 'running' // 运行中 - 会话正在执行
| 'waiting_human' // 等待人工确认 - 需要用户手动确认协调者提案
| 'done' // 已完成 - 会话正常结束
| 'error'; // 错误状态 - 会话执行过程中发生错误
/**
* 协调者模式
* 决定协调者如何推进会话
*/
export type CoordinatorMode =
| 'auto' // 自动模式 - 协调者自动推进,无需人工干预
| 'human_review'; // 人工确认模式 - 每轮协调者提案后需要人工确认
/**
* 阶段类型
* 描述当前所处的分析阶段
*/
export type PhaseType =
| 'starting' // 启动阶段 - 第一轮,初始化分析
| 'diverging' // 辩论阶段 - 多轮讨论,各智能体提供观点
| 'closing' // 收敛阶段 - 协调者整合观点,形成结论
| 'final_report' // 最终报告生成阶段 - 协调者正在生成最终报告
| 'done'; // 完成阶段 - 最终报告生成完毕
/**
* 时间线项目类型
* 描述时间线中不同类型的条目
*/
export type TimelineItemType =
| 'round_start' // 轮次开始标记
| 'agent_contribution' // 智能体贡献内容
| 'coordinator_streaming' // 协调者流式输出(思考中)
| 'coordinator' // 协调者完整提案
| 'final_report' // 最终报告
| 'background_retrieval'; // 背景检索结果
/**
* =============================================================================
* 配置相关类型
* =============================================================================
*/
/**
* 阶段配置
* 控制不同阶段的参数
*/
export interface PhaseConfig {
/** 发散(辩论)轮次数量 */
diverge_rounds: number;
}
/**
* 协调者配置
* 包含协调者的各项提示词和规则
*/
export interface CoordinatorConfig {
/** 系统提示前缀 - 协调者的全局规则 */
system_prompt_prefix?: string;
/** 首轮策略指导 */
round1_guidance?: string;
/** 中间轮策略指导 */
middle_round_guidance?: string;
/** 末轮策略指导 */
final_round_guidance?: string;
/** 选人规则 - 如何选择下一轮发言的智能体 */
speaker_selection_rules?: string;
/** 任务指令规则 - 如何生成给智能体的任务 */
focus_instruction_rules?: string;
/** 依赖推进规则 - 如何处理字段间的依赖关系 */
dependency_driving_rules?: string;
/** 一致性/冲突检查规则 */
consistency_check_rules?: string;
/** 人工修改解释规则 - 如何解释协调者的修改 */
revise_prompt_prefix?: string;
}
/**
* 模板元数据
* 用于模板列表展示
*/
export interface TemplateMeta {
/** 模板唯一标识符 */
template_id: string;
/** 模板显示名称 */
template_name?: string;
/** 模板简短描述 */
template_brief?: string;
/** 模板文件名 */
file?: string;
}
/**
* 完整模板结构
* 包含模板的所有配置和数据
*/
export interface Template {
/** 模板唯一标识符 */
template_id: string;
/** 模板显示名称 */
template_name?: string;
/** 模板简短描述 */
template_brief?: string;
/** 模板详细说明 */
usage_notes?: string;
/** 报告结构定义 - 字段名到描述的映射 */
report_schema: Record<string, string>;
/** 报告示例 - 各字段的示例内容 */
report_examples?: Record<string, string>;
/** 字段要求 - 各字段的详细要求 */
field_requirements?: Record<string, FieldRequirement>;
/** 参与智能体列表 */
agents: Agent[];
/** 协调者配置 */
coordinator_config?: CoordinatorConfig;
/** 智能体适应策略 */
agent_adaptation_policy?: Record<string, unknown>;
/** 阶段配置 */
phase_config?: PhaseConfig;
}
/**
* 字段要求
* 描述某个报告字段的详细要求
*/
export interface FieldRequirement {
/** 字段名称 */
name?: string;
/** 字段描述 */
description?: string;
/** 是否必需 */
required?: boolean;
/** 内容类型期望 */
content_type?: string;
/** 最大长度 */
max_length?: number;
/** 其他元数据 */
[key: string]: unknown;
}
/**
* 应用配置
* 用户在开始会话时提供的完整配置
*/
export interface AppConfig {
/** 任务背景描述 */
task_context: string;
/** 模板 ID */
template_id?: string;
/** 模板名称 */
template_name?: string;
/** 模板简短描述 */
template_brief?: string;
/** 协调者模式 */
coordinator_mode?: CoordinatorMode;
/** 协调者配置 */
coordinator_config?: CoordinatorConfig;
/** 参与智能体列表 */
agents?: Agent[];
/** 阶段配置 */
phase_config?: PhaseConfig;
/** 报告结构 */
report_schema?: Record<string, string>;
/** 报告示例 */
report_examples?: Record<string, string>;
/** 字段要求 */
field_requirements?: Record<string, FieldRequirement>;
/** 智能体适应策略 */
agent_adaptation_policy?: Record<string, unknown>;
/** 检索配置 */
retrieval?: Record<string, unknown>;
/** 检索配置(别名,用于兼容) */
retrieve?: Record<string, unknown>;
/** 审核模式(别名,用于兼容) */
review_mode?: CoordinatorMode;
}
/**
* =============================================================================
* 时间线相关类型
* =============================================================================
*/
/**
* 时间线条目基类
*/
export interface TimelineItemBase {
/** 条目类型 */
type: TimelineItemType;
/** 条目索引(用于列表渲染的 key) */
idx?: number;
}
/**
* 轮次开始条目
* 标记新一轮的开始
*/
export interface RoundStartItem extends TimelineItemBase {
type: 'round_start';
/** 轮次编号 */
round: number;
/** 所处阶段 */
phase: PhaseType;
/** 该轮参与的所有智能体 ID 列表 */
agentIds?: string[];
}
/**
* 智能体贡献条目
* 包含智能体的完整分析输出
*/
export interface AgentContributionItem extends TimelineItemBase {
type: 'agent_contribution';
/** 智能体 ID */
agentId: string;
/** 轮次编号 */
round: number;
/** 所处阶段 */
phase: PhaseType;
/** 是否处于流式输出状态 */
streaming: boolean;
/** 智能体输出数据 */
data: AgentRoundData;
}
/**
* 协调者流式输出条目
* 协调者正在生成内容时的实时输出
*/
export interface CoordinatorStreamingItem extends TimelineItemBase {
type: 'coordinator_streaming';
/** 当前阶段 */
phase: PhaseType;
/** 当前辩论轮次 */
divergeRound?: number;
/** 总辩论轮次 */
totalDivergeRounds?: number;
/** 思考内容(流式输出中) */
thinking: string;
/** 输出内容(流式输出中) */
content: string;
/** 当前正在生成的字段名 */
currentField?: string | null;
/** 最终报告的已完成字段 */
reportPartial: Record<string, string>;
}
/**
* 协调者提案数据
* 协调者完成后的完整提案
*/
export interface CoordinatorProposalData {
/** 轮次编号 */
round: number;
/** 辩论轮次编号 */
divergeRound?: number;
/** 总辩论轮次 */
totalDivergeRounds?: number;
/** 下一轮发言的智能体 ID 列表 */
nextSpeakers: string[];
/** 任务指令映射 - 智能体 ID 到指令内容的映射 */
focus: Record<string, string>;
/** 当前草稿 */
draft: Record<string, string>;
/** 草稿更新内容 */
draftUpdates: Record<string, string>;
/** 当前识别的缺口/问题 */
gaps: string[];
/** 字段评估结果 */
fieldAssessment: Record<string, FieldAssessment>;
/** 推理过程 */
reasoning: string;
/** 原始提案对象 */
proposal: Record<string, unknown>;
}
/**
* 字段评估
* 对某个报告字段的完成度评估
*/
export interface FieldAssessment {
/** 字段状态:filled=已完成, partial=部分完成, blocked=受阻, empty=未开始 */
status: 'filled' | 'partial' | 'blocked' | 'empty';
/** 阻塞原因(如果状态为 blocked) */
blocking_reason?: string;
/** 其他评估信息 */
[key: string]: unknown;
}
/**
* 协调者条目
* 协调者的完整提案
*/
export interface CoordinatorItem extends TimelineItemBase {
type: 'coordinator';
/** 协调者提案数据 */
data: CoordinatorProposalData;
/** 协调者的思考过程 */
thinking?: string;
}
/**
* 最终报告数据
*/
export interface FinalReportData {
/** 最终报告内容 - 字段名到内容的映射 */
report: Record<string, string>;
/** 各维度的详细分析 */
dimensions: Record<string, unknown>;
/** 报告元数据 */
meta: Record<string, unknown>;
}
/**
* 维度数据
* 某个分析维度的详细输出
*/
export interface DimensionData {
/** 维度标签 */
label?: string;
/** 最新分析内容 */
latest_analysis?: string;
/** 其他维度数据 */
[key: string]: unknown;
}
/**
* 最终报告条目
*/
export interface FinalReportItem extends TimelineItemBase {
type: 'final_report';
/** 最终报告数据 */
data: FinalReportData;
}
/**
* 背景检索数据
*/
export interface BackgroundRetrievalData {
/** 检索结果 - 维度到结果条目的映射 */
results: Record<string, RetrievalResultEntry[]>;
/** 涉及的维度列表 */
dimensions?: string[];
/** 总记录数 */
total_records?: number;
}
/**
* 检索结果条目
*/
export interface RetrievalResultEntry {
/** 检索查询 */
query?: string;
/** 检索结果内容 */
content: (string | Record<string, unknown>)[];
}
/**
* 背景检索条目
*/
export interface BackgroundRetrievalItem extends TimelineItemBase {
type: 'background_retrieval';
/** 检索数据 */
data: BackgroundRetrievalData;
}
/**
* 联合类型:所有可能的时间线条目
*/
export type AnyTimelineItem =
| RoundStartItem
| AgentContributionItem
| CoordinatorStreamingItem
| CoordinatorItem
| FinalReportItem
| BackgroundRetrievalItem;
/**
* =============================================================================
* API 和事件相关类型
* =============================================================================
*/
/**
* SSE 事件解析结果
* 从后端 SSE 流中解析出的事件
*/
export interface SseEvent {
/** 事件类型 */
event: string;
/** 事件数据 */
data: Record<string, unknown>;
}
/**
* 待审核提案
* 需要人工确认的协调者提案
*/
export interface PendingReview {
/** 轮次编号 */
divergeRound: number;
/** 总辩论轮次 */
totalDivergeRounds: number;
/** 下一轮发言的智能体 ID 列表 */
nextSpeakers: string[];
/** 任务指令 */
focus: Record<string, string>;
/** 当前草稿 */
draft: Record<string, string>;
/** 当前缺口 */
gaps: string[];
/** 原始提案 */
proposal: Record<string, unknown>;
}
/**
* 人工确认操作的载荷
*/
export interface HumanReviewPayload {
/** 下一轮发言的智能体 ID 列表 */
nextSpeakers: string[];
/** 任务指令 */
focus: Record<string, string>;
/** 草稿更新 */
draft: Record<string, string>;
/** 缺口列表 */
gaps: string[];
}
/**
* 会话状态类型
* 整个应用的状态对象
*/
export interface SessionState {
/** 会话状态 */
status: SessionStatus;
/** 会话 ID */
sessionId: string | null;
/** 后端服务器地址 */
backendUrl: string;
/** 协调者模式 */
coordinatorMode: CoordinatorMode;
/** 当前阶段 */
phase: PhaseType | null;
/** 当前轮次 */
currentRound: number;
/** 总轮次数 */
totalRounds: number;
/** 辩论轮次数 */
divergeRounds: number;
/** 报告结构定义 */
reportSchema: Record<string, string>;
/** 参与会话的智能体列表 */
agents: Agent[];
/** 智能体状态映射 */
agentStates: Record<string, AgentState>;
/** 协调者更新历史 */
coordinatorUpdates: CoordinatorProposalData[];
/** 最新草稿 */
latestDraft: Record<string, string>;
/** 背景检索数据 */
backgroundRetrieval: BackgroundRetrievalData | null;
/** 最终报告 */
finalReport: FinalReportData | null;
/** 最终报告已完成字段 */
finalReportPartial: Record<string, string>;
/** 当前正在生成的最终报告字段 */
currentFinalField: string | null;
/** 待人工确认的提案 */
pendingReview: PendingReview | null;
/** 是否正在提交人工确认 */
reviewSubmitting: boolean;
/** 是否正在等待协调者修改 */
reviewRevising: boolean;
/** 错误列表 */
errors: ErrorRecord[];
/** 时间线条目列表 */
timeline: AnyTimelineItem[];
}
/**
* 错误记录
*/
export interface ErrorRecord {
/** 错误消息 */
error: string;
/** 发生错误的智能体 ID */
agent_id?: string;
/** 其他错误数据 */
[key: string]: unknown;
}
/**
* =============================================================================
* 组件 Props 类型
* =============================================================================
*/
/**
* ConfigPanel 组件的 Props
*/
export interface ConfigPanelProps {
/** 开始会话回调 */
onStart: (config: AppConfig, backendUrl: string) => void;
/** 是否正在运行 */
isRunning: boolean;
/** 停止会话回调 */
onStop: () => void;
/** 打开场景配置页回调 */
onOpenScenarioConfig: () => void;
}
/**
* Sidebar 组件的 Props
*/
export interface SidebarProps {
/** 智能体列表 */
agents: Agent[];
/** 智能体状态映射 */
agentStates: Record<string, AgentState>;
/** 当前阶段 */
phase: PhaseType | null;
/** 当前轮次 */
currentRound: number;
/** 辩论轮次数 */
divergeRounds: number;
/** 总轮次数 */
totalRounds: number;
}
/**
* Timeline 组件的 Props
*/
export interface TimelineProps {
/** 时间线条目列表 */
timeline: AnyTimelineItem[];
/** 智能体状态映射 */
agentStates: Record<string, AgentState>;
/** 报告结构定义 */
reportSchema: Record<string, string>;
/** 待审核提案 */
pendingReview: PendingReview | null;
/** 审核通过回调 */
onApproveReview: (payload: HumanReviewPayload) => Promise<void>;
/** 审核修改回调 */
onReviseReview: (instruction: string) => Promise<void>;
/** 是否正在提交审核 */
reviewSubmitting: boolean;
/** 是否正在修改 */
reviewRevising: boolean;
}
/**
* AgentCard 组件的 Props
*/
export interface AgentCardProps {
/** 智能体 ID */
agentId: string;
/** 智能体名称 */
agentName: string;
/** 分析维度 */
dimension: string;
/** 当前轮次 */
round: number;
/** 当前阶段 */
phase: PhaseType;
/** 分析内容 */
content: string;
/** 思考过程 */
thinking: string;
/** 维度输出 */
dimensionOutput?: Record<string, string>;
/** 检索日志 */
retrievalLog?: RetrievalLogItem[];
/** 检索次数 */
retrievalCount?: number;
/** 引用映射表 */
referenceMap?: Record<string, string>;
/** 是否处于流式输出状态 */
streaming?: boolean;
}
/**
* ProgressSidebar 组件的 Props
*/
export interface ProgressSidebarProps {
/** 当前阶段 */
phase: PhaseType | null;
/** 会话状态 */
status: SessionStatus;
/** 当前轮次 */
currentRound: number;
/** 辩论轮次数 */
divergeRounds: number;
/** 报告结构定义 */
reportSchema: Record<string, string>;
/** 协调者更新历史 */
coordinatorUpdates: CoordinatorProposalData[];
/** 待审核提案 */
pendingReview: PendingReview | null;
/** 最新草稿 */
latestDraft: Record<string, string>;
/** 最终报告已完成字段 */
finalReportPartial: Record<string, string>;
/** 当前正在生成的字段 */
currentFinalField: string | null;
/** 智能体状态映射 */
agentStates: Record<string, AgentState>;
}
/**
* FinalReport 组件的 Props
*/
export interface FinalReportProps {
/** 最终报告输出数据 */
output: FinalReportData;
/** 报告结构定义 */
reportSchema: Record<string, string>;
}
/**
* MarkdownContent 组件的 Props
*/
export interface MarkdownContentProps {
/** Markdown 内容字符串 */
content: string;
/** 引用映射表(用于高亮引用标记) */
referenceMap?: Record<string, string>;
/** 额外的 CSS 类名 */
className?: string;
}
/**
* ScenarioConfigPage 组件的 Props
*/
export interface ScenarioConfigPageProps {
/** 返回上一页回调 */
onBack: () => void;
}
+10
View File
@@ -0,0 +1,10 @@
import ResultManagement from './ResultManagement';
import { PageType } from '@/components/Sidebar';
interface ResultDqProps {
onNavigate: (page: PageType, taskId?: string) => void;
}
export default function ResultDq({ onNavigate }: ResultDqProps) {
return <ResultManagement onNavigate={onNavigate} initialCategory="dq-research" />;
}
@@ -0,0 +1,10 @@
import ResultManagement from './ResultManagement';
import { PageType } from '@/components/Sidebar';
interface ResultEffectPgProps {
onNavigate: (page: PageType, taskId?: string) => void;
}
export default function ResultEffectPg({ onNavigate }: ResultEffectPgProps) {
return <ResultManagement onNavigate={onNavigate} initialCategory="effect-pg" />;
}
@@ -0,0 +1,413 @@
import React, { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
FileText,
Search,
Download,
Edit3,
Clock,
CheckCircle,
Target,
Brain,
TrendingUp,
Compass,
Database,
MoreHorizontal,
Send,
} from 'lucide-react';
import { useTheme } from '@/contexts/ThemeContext';
import { PageType } from '@/components/Sidebar';
type ResultCategory = 'planning' | 'mnty' | 'effect-pg' | 'xk' | 'dq-research' | 'other';
interface ResultTask {
id: string;
title: string;
category: ResultCategory;
subCategory: string;
status: 'completed' | 'pending' | 'draft';
createdAt: string;
updatedAt: string;
reportType: 'strategy' | 'consult' | 'analysis' | 'summary';
agentCount: number;
description: string;
}
interface ResultManagementProps {
onNavigate: (page: PageType, taskId?: string) => void;
initialCategory: ResultCategory;
}
// 任务类别配置
const categoryConfig: Record<ResultCategory, { label: string; icon: React.ElementType; color: string; subCategories: string[] }> = {
'planning': {
label: '规划任务',
icon: Target,
color: 'blue',
subCategories: ['目标分析', '品牌策略', '内容规划', '渠道规划', '叙事策略']
},
'mnty': {
label: 'MNTY任务',
icon: Brain,
color: 'purple',
subCategories: ['机制研究', '方案设计', 'ZSTY分析']
},
'effect-pg': {
label: '效果PG任务',
icon: TrendingUp,
color: 'amber',
subCategories: ['行为PG', '效果评估', '信息PG', '账号PG']
},
'xk': {
label: 'XK任务',
icon: Compass,
color: 'cyan',
subCategories: ['任务JC', '任务调度']
},
'dq-research': {
label: 'DQ研究类任务',
icon: Database,
color: 'green',
subCategories: ['受众分析', '知识库研究', '趋势研究']
},
'other': {
label: '其他类任务',
icon: MoreHorizontal,
color: 'slate',
subCategories: ['通用分析', '临时任务', '其他']
}
};
// 模拟任务数据
const mockTasks: ResultTask[] = [
{
id: 'task-001',
title: '五四青年节宣传活动策略',
category: 'planning',
subCategory: '叙事策略',
status: 'completed',
createdAt: '2026-03-19 10:30',
updatedAt: '2026-03-19 15:30',
reportType: 'strategy',
agentCount: 6,
description: '针对五四青年节的品牌传播策略方案'
},
{
id: 'task-002',
title: '品牌年轻化受众分析',
category: 'dq-research',
subCategory: '受众分析',
status: 'completed',
createdAt: '2026-03-18 14:20',
updatedAt: '2026-03-18 16:45',
reportType: 'analysis',
agentCount: 3,
description: '分析Z世代用户的消费行为和品牌偏好'
},
{
id: 'task-003',
title: 'UGC激励机制设计',
category: 'effect-pg',
subCategory: '效果评估',
status: 'completed',
createdAt: '2026-03-17 09:00',
updatedAt: '2026-03-17 11:20',
reportType: 'strategy',
agentCount: 4,
description: '设计用户生成内容激励机制方案'
},
{
id: 'task-004',
title: '短视频平台效果预测',
category: 'mnty',
subCategory: '方案设计',
status: 'pending',
createdAt: '2026-03-20 08:00',
updatedAt: '2026-03-20 08:00',
reportType: 'consult',
agentCount: 5,
description: '预测不同短视频平台的传播效果'
},
{
id: 'task-005',
title: '活动传播风险评估',
category: 'planning',
subCategory: '目标分析',
status: 'draft',
createdAt: '2026-03-20 10:00',
updatedAt: '2026-03-20 10:30',
reportType: 'analysis',
agentCount: 2,
description: '评估五四活动可能面临的传播风险'
},
{
id: 'task-006',
title: '竞品营销策略研究',
category: 'dq-research',
subCategory: '趋势研究',
status: 'completed',
createdAt: '2026-03-16 13:00',
updatedAt: '2026-03-16 18:00',
reportType: 'analysis',
agentCount: 3,
description: '分析主要竞品的营销策略和效果'
},
];
const ResultManagement: React.FC<ResultManagementProps> = ({ onNavigate, initialCategory }) => {
const { theme } = useTheme();
const [searchParams] = useSearchParams();
const isDark = theme === 'dark';
// 主题颜色类
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
const cardBorder = isDark ? 'border-blue-800/30' : 'border-gray-200';
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
const textMuted = isDark ? 'text-slate-500' : 'text-gray-500';
const btnSecondary = isDark ? 'bg-slate-700/50 hover:bg-slate-600/50 border-blue-800/30 text-slate-300' : 'bg-gray-100 hover:bg-gray-200 border-gray-300 text-gray-700';
const inputBg = isDark ? 'bg-slate-900/50' : 'bg-gray-50';
const inputBorder = isDark ? 'border-blue-800/30' : 'border-gray-300';
// 状态
const selectedCategory = initialCategory;
const selectedSubCategory = searchParams.get('subCategory');
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<'all' | 'completed' | 'pending' | 'draft'>('all');
// 过滤任务
const filteredTasks = mockTasks.filter(task => {
const matchesCategory = task.category === selectedCategory;
const matchesSubCategory = !selectedSubCategory || task.subCategory === selectedSubCategory;
const matchesSearch = !searchQuery ||
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.description.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = statusFilter === 'all' || task.status === statusFilter;
return matchesCategory && matchesSubCategory && matchesSearch && matchesStatus;
});
// 获取状态样式
const getStatusStyle = (status: ResultTask['status']) => {
switch (status) {
case 'completed':
return {
bg: isDark ? 'bg-green-500/20 text-green-400' : 'bg-green-100 text-green-700',
icon: CheckCircle,
label: '已完成'
};
case 'pending':
return {
bg: isDark ? 'bg-yellow-500/20 text-yellow-400' : 'bg-yellow-100 text-yellow-700',
icon: Clock,
label: '进行中'
};
case 'draft':
return {
bg: isDark ? 'bg-slate-500/20 text-slate-400' : 'bg-gray-100 text-gray-700',
icon: Edit3,
label: '草稿'
};
}
};
// 获取报告类型样式
const getReportTypeStyle = (type: ResultTask['reportType']) => {
const styles = {
'strategy': { color: 'blue', label: '策略报告' },
'consult': { color: 'purple', label: '会商报告' },
'analysis': { color: 'cyan', label: '分析报告' },
'summary': { color: 'green', label: '总结报告' }
};
return styles[type];
};
// 查看任务详情
const handleViewDetail = (taskId: string) => {
onNavigate('result-detail', taskId);
};
const currentCategoryConfig = categoryConfig[selectedCategory];
const CategoryIcon = currentCategoryConfig.icon;
return (
<div className="h-full flex">
<div className="flex-1 flex flex-col min-w-0">
{/* 顶部工具栏 */}
<div className={`${cardBg} backdrop-blur-xl ${cardBorder} rounded-xl p-4 mb-4`}>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl bg-${currentCategoryConfig.color}-500/20 flex items-center justify-center`}>
<CategoryIcon className={`w-5 h-5 text-${currentCategoryConfig.color}-400`} />
</div>
<div>
<h2 className={`${textPrimary} font-semibold`}>
{selectedSubCategory || currentCategoryConfig.label}
</h2>
<p className={`${textMuted} text-xs`}>
共 {filteredTasks.length} 个结果
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button className={`flex items-center gap-2 px-3 py-2 rounded-lg ${btnSecondary} text-sm`}>
<Download className="w-4 h-4" />
批量导出
</button>
</div>
</div>
{/* 搜索和筛选 */}
<div className="flex items-center gap-3">
<div className={`flex-1 relative ${inputBg} rounded-lg`}>
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${textMuted}`} />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索任务标题或描述..."
className={`w-full pl-10 pr-4 py-2.5 rounded-lg text-sm bg-transparent focus:outline-none ${
isDark ? 'text-white placeholder-slate-500' : 'text-gray-800 placeholder-gray-400'
}`}
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
className={`px-3 py-2.5 rounded-lg text-sm ${inputBg} ${inputBorder} focus:outline-none ${
isDark ? 'text-slate-300' : 'text-gray-700'
}`}
>
<option value="all">全部状态</option>
<option value="completed">已完成</option>
<option value="pending">进行中</option>
<option value="draft">草稿</option>
</select>
</div>
</div>
{/* 任务列表 */}
<div className={`flex-1 ${cardBg} backdrop-blur-xl ${cardBorder} rounded-xl overflow-hidden`}>
{filteredTasks.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center">
<FileText className={`w-16 h-16 ${textMuted} mb-4`} />
<p className={`${textSecondary} text-lg`}>暂无结果</p>
<p className={`${textMuted} text-sm mt-1`}>在对应任务中生成结果后,将在此显示</p>
</div>
) : (
<div className="h-full overflow-y-auto custom-scrollbar">
<table className="w-full">
<thead className={`sticky top-0 ${isDark ? 'bg-slate-800/80' : 'bg-gray-50'}`}>
<tr className={`text-xs ${textMuted} border-b ${cardBorder}`}>
<th className="text-left font-medium px-4 py-3">任务名称</th>
<th className="text-left font-medium px-4 py-3">报告类型</th>
<th className="text-left font-medium px-4 py-3">智能体数</th>
<th className="text-left font-medium px-4 py-3">状态</th>
<th className="text-left font-medium px-4 py-3">更新时间</th>
<th className="text-left font-medium px-4 py-3">操作</th>
</tr>
</thead>
<tbody>
{filteredTasks.map((task) => {
const statusInfo = getStatusStyle(task.status);
const StatusIcon = statusInfo.icon;
const reportTypeInfo = getReportTypeStyle(task.reportType);
return (
<tr
key={task.id}
className={`border-b cursor-pointer hover:${
isDark ? 'bg-slate-700/30' : 'bg-gray-50'
} transition-colors ${cardBorder}`}
onClick={() => handleViewDetail(task.id)}
>
<td className="px-4 py-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg bg-${reportTypeInfo.color}-500/20 flex items-center justify-center flex-shrink-0`}>
<FileText className={`w-5 h-5 text-${reportTypeInfo.color}-400`} />
</div>
<div className="min-w-0">
<p className={`${textPrimary} text-sm font-medium truncate`}>{task.title}</p>
<p className={`${textMuted} text-xs truncate`}>{task.description}</p>
</div>
</div>
</td>
<td className="px-4 py-4">
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
isDark ? `bg-${reportTypeInfo.color}-500/20 text-${reportTypeInfo.color}-400` : `bg-${reportTypeInfo.color}-100 text-${reportTypeInfo.color}-700`
}`}>
{reportTypeInfo.label}
</span>
</td>
<td className="px-4 py-4">
<span className={`${textSecondary} text-sm`}>{task.agentCount}</span>
</td>
<td className="px-4 py-4">
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-lg text-xs font-medium ${statusInfo.bg}`}>
<StatusIcon className="w-3 h-3" />
{statusInfo.label}
</span>
</td>
<td className="px-4 py-4">
<span className={`${textMuted} text-sm`}>{task.updatedAt}</span>
</td>
<td className="px-4 py-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-1">
<button
onClick={() => handleViewDetail(task.id)}
className={`p-1.5 rounded-lg hover:${isDark ? 'bg-slate-700' : 'bg-gray-100'} transition-colors`}
title="查看详情"
>
<FileText className={`w-4 h-4 ${textSecondary}`} />
</button>
<button
className={`p-1.5 rounded-lg hover:${isDark ? 'bg-slate-700' : 'bg-gray-100'} transition-colors`}
title="编辑"
>
<Edit3 className={`w-4 h-4 ${textSecondary}`} />
</button>
<button
className={`p-1.5 rounded-lg hover:${isDark ? 'bg-slate-700' : 'bg-gray-100'} transition-colors`}
title="下载"
>
<Download className={`w-4 h-4 ${textSecondary}`} />
</button>
<button
className={`p-1.5 rounded-lg hover:${isDark ? 'bg-slate-700' : 'bg-gray-100'} transition-colors`}
title="呈报"
>
<Send className={`w-4 h-4 ${textSecondary}`} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
{/* 自定义滚动条样式 */}
<style>{`
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: ${isDark ? 'rgba(148, 163, 184, 0.3)' : 'rgba(0, 0, 0, 0.15)'};
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: ${isDark ? 'rgba(148, 163, 184, 0.5)' : 'rgba(0, 0, 0, 0.25)'};
}
`}</style>
</div>
);
};
export default ResultManagement;
@@ -0,0 +1,5 @@
import { Outlet } from 'react-router-dom';
export default function ResultManagementLayout() {
return <Outlet />;
}
+10
View File
@@ -0,0 +1,10 @@
import ResultManagement from './ResultManagement';
import { PageType } from '@/components/Sidebar';
interface ResultMntyProps {
onNavigate: (page: PageType, taskId?: string) => void;
}
export default function ResultMnty({ onNavigate }: ResultMntyProps) {
return <ResultManagement onNavigate={onNavigate} initialCategory="mnty" />;
}
@@ -0,0 +1,10 @@
import ResultManagement from './ResultManagement';
import { PageType } from '@/components/Sidebar';
interface ResultOtherProps {
onNavigate: (page: PageType, taskId?: string) => void;
}
export default function ResultOther({ onNavigate }: ResultOtherProps) {
return <ResultManagement onNavigate={onNavigate} initialCategory="other" />;
}
@@ -0,0 +1,10 @@
import ResultManagement from './ResultManagement';
import { PageType } from '@/components/Sidebar';
interface ResultPlanningProps {
onNavigate: (page: PageType, taskId?: string) => void;
}
export default function ResultPlanning({ onNavigate }: ResultPlanningProps) {
return <ResultManagement onNavigate={onNavigate} initialCategory="planning" />;
}
+10
View File
@@ -0,0 +1,10 @@
import ResultManagement from './ResultManagement';
import { PageType } from '@/components/Sidebar';
interface ResultXkProps {
onNavigate: (page: PageType, taskId?: string) => void;
}
export default function ResultXk({ onNavigate }: ResultXkProps) {
return <ResultManagement onNavigate={onNavigate} initialCategory="xk" />;
}
+150
View File
@@ -0,0 +1,150 @@
import { Edit, PlayCircle, Trash2 } from "lucide-react";
import React, { useState, useMemo, useEffect } from "react";
import { Popconfirm } from "tdesign-react";
import { requestCrudService } from "@/service/request-axios-service";
import { format } from "date-fns";
import logoSvg from "@/assets/icon/轻应用logo-DB.svg";
interface analysisListItem {
id: number;
categoryName: string;
category: string;
appName: string;
appDesc: string;
appIcon: string;
jumpUrl: string;
appType: number;
}
const service = requestCrudService;
const zncmUrl = import.meta.env.VITE_API_BASE_URL || "";
export const AppCard: React.FC<{
app: analysisListItem;
isDark: boolean;
hoverColor: string;
btnClass: string;
delDataChange: (item: analysisListItem) => void;
editShowChange: (item: analysisListItem) => void;
}> = ({ app, isDark, hoverColor, btnClass, delDataChange, editShowChange }) => {
const listDataChange = (item: analysisListItem) => {
historyCreateSearch(item);
// if (item.appType === 1) {
// internalDataChange(item);
// } else if (item.appType === 2) {
// externalDataChange(item);
// }
};
const internalDataChange = (item: analysisListItem) => {
// 内部接口跳转
};
const externalDataChange = (item: analysisListItem) => {
// 外部应用接口跳转
window.open(item.jumpUrl);
};
const handleConfirm = (item: analysisListItem) => {
// 确认删除逻辑
console.log("用户点击了确认");
delDataChange(item);
return true; // 返回 true 表示确认执行
};
const handleCancel = () => {
// 取消删除逻辑
console.log("用户点击了取消");
return true; // 返回 true 表示取消
};
const historyCreateSearch = async (item: analysisListItem) => {
const time = format(new Date(), "yyyy-MM-dd HH:mm:ss");
console.log("time", time);
const param = {
appId: item.id,
appName: item.appName,
runTime: time,
};
const res = await service.post(`${zncmUrl}/app/history/create`, param);
console.log("res", res);
};
return (
<div
className={`rounded-xl border p-5 transition-all relative duration-200 hover:shadow-lg group ${
isDark
? "bg-slate-800/50 border-slate-700 hover:border-slate-600"
: "bg-white border-gray-200 hover:border-gray-300"
}`}
>
{/* 头部信息 */}
<div className="flex items-start gap-4 mb-4">
<div className="rounded-lg overflow-hidden flex-shrink-0">
<img
className="w-[47px] h-[47px] object-cover"
src={app.appIcon}
alt={app.appName}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.src = logoSvg;
}}
/>
</div>
<div className="flex-1 min-w-0">
<h3
className={`font-semibold truncate transition-colors ${hoverColor} ${
isDark ? "text-white" : "text-gray-900"
}`}
>
{app.appName}
</h3>
<p
className={`mt-1 text-sm line-clamp-2 ${
isDark ? "text-slate-400" : "text-gray-500"
}`}
>
{app.appDesc}
</p>
</div>
<div className="absolute right-[10px] top-[10px] flex items-center cursor-pointer opacity-100 group-hover:opacity-100 transition-opacity">
<Edit
className="w-4 h-4 text-[gray] mr-[10px]"
onClick={() => editShowChange(app)}
></Edit>
<Popconfirm
content="确认删除吗"
onConfirm={() => handleConfirm(app)}
onCancel={handleCancel}
>
<div>
<Trash2 className="w-4 h-4 text-[#ce2d39]"></Trash2>
</div>
</Popconfirm>
</div>
</div>
{/* 输入输出信息 */}
<div
className={`text-xs space-y-1 mb-4 ${
isDark ? "text-slate-500" : "text-gray-400"
}`}
>
<div className="flex items-start gap-2">
<span className="font-medium">输入:</span>
<span>信息文本/链接/素材</span>
</div>
<div className="flex items-start gap-2">
<span className="font-medium">输出:</span>
<span>信息特征、重点摘要、分析判断</span>
</div>
</div>
{/* 按钮 */}
<button
className={`w-full py-2.5 rounded-lg font-medium flex items-center justify-center gap-2 transition-all border ${btnClass}`}
onClick={() => listDataChange(app)}
>
<PlayCircle className="w-4 h-4" />
立即使用
</button>
</div>
);
};
@@ -0,0 +1,209 @@
import { useTheme } from '@/contexts/ThemeContext';
import { actionColumnItem, ActionListItem, TaskListItem, taskStatusList } from '@/pages/TaskManagement';
import { Bot, ChevronRight, Download, Edit, Eye, FileText, Layers, MoreHorizontal, Play, Search, User, Users, Workflow } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { PageType } from '../Sidebar';
import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { Col, Empty, Loading, Pagination, Progress, Row } from 'tdesign-react';
import { requestCrudService } from '@/service/request-axios-service';
import { ActionColumn } from './ActionColumn';
interface ActionAnalyzesProps {
TaskType?: string;
title: string;
category: string;
subCategory: string;
onNavigate: (page: PageType) => void;
}
const service = requestCrudService;
const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
export const ActionAnalyzes: React.FC<ActionAnalyzesProps> = ({ title, category, subCategory, onNavigate }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
// 主题颜色变量
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
const cardBorder = isDark ? 'border-blue-800/30' : 'border-red-100';
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
const textMuted = isDark ? 'text-slate-500' : 'text-gray-400';
const inputBg = isDark ? 'bg-slate-900/50' : 'bg-white';
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState<string>('');
const [taskList, setTaskList] = useState<TaskListItem[]>([]); // 任务列表
const [taskId, setTaskId] = useState<number>(0); // 任务
const [actionColumnList, setActionColumnList] = useState<actionColumnItem[]>([]);
const [taskLoading, setTaskLoading] = useState<boolean>(false);
const [actionLoading, setActionLoading] = useState<boolean>(false);
// 当前页码,默认为第 1 页
const [pageNum, setPageNum] = useState<number>(1);
// 每页显示数量,默认为 10 条
const [pageSize, setPageSize] = useState<number>(10); // 每页显示 10 条
const [total, setTotal] = useState<number>(0); // 查询任务总数
const state = useRef({
pageIndex: 1,
pageSize: 10
})
// 回车处理
const handleKeyDown = async (e) => {
if ((e.metaKey || e.ctrlKey || e.shiftKey) && e.key === "Enter") {
e.preventDefault();
} else if (e.keyCode === 13) {
e.preventDefault();
await taskListSearch()
} else if (e.key === "/") {
e.preventDefault();
}
};
/**
* 页码变化时的回调函数
* 当用户切换页码时触发,更新页码并重新获取数据
* @param value - 新的页码值
*/
const pageIndexChange = async (value: number) => {
// 更新 ref 中存储的页码值
// 更新页码状态,触发组件重新渲染
setPageNum(value);
state.current.pageIndex = value;
await taskListSearch();
}
// 任务查询
const taskListSearch = async () => {
const param = {
keyword: '',
pageNum: state.current.pageIndex,
pageSize: state.current.pageSize
}
setTaskLoading(true);
const res = await service.post(`${zncmUrl}/action/cop-action-tasks`, param);
setTaskLoading(false);
console.log('任务搜索', res);
const result = res.data;
result.forEach((item: TaskListItem) => {
item.progress = item.endCount / item.sumAll || 0; // 获取已完成和未完成的占比
item.taskStatusData = taskStatusList[item.taskStatus] // 根据任务状态码获取相关信息
})
setTaskList(result || []); // 设置任务列表页
const totalRes = await service.post(`${zncmUrl}/action/cop-action-tasks-count`, param); // 查询任务总数
console.log('totalRes', totalRes)
setTotal(totalRes.data || 0); // 设置总数
if (result.length > 0) {
setTaskId(result[0].id);
await actionListSearch(result[0].id);
}
}
// 行动查询
const actionListSearch = async (taskId: number) => {
const param = {
taskId: taskId,
}
setActionLoading(true);
const res = await service.get(`${zncmUrl}/action/cop-task-actions`, param);
console.log('行动查询', res);
setActionLoading(false);
const result = res.data;
setActionColumnList([
{ columnName: '待分析', icon: '待CEH.svg', actionList: result.collect || [] },
{ columnName: 'CEH中', icon: '进行中.svg', actionList: result.collect1 || [] },
{ columnName: 'CEH完成', icon: '完成.svg', actionList: result.collect2 || [] },
{ columnName: '已终止', icon: '已终止.svg', actionList: result.endDate || [] },
]);
}
const taskDataChange = async (taskId: number) => {
setTaskId(taskId);
await actionListSearch(taskId);
}
useEffect(() => {
taskListSearch();
}, [])
return (
<div className="h-full flex flex-col">
<Row className="h-full flex flex-col">
<Col span={2} className="h-full">
<div className={`h-[calc(100vh-100px)] p-[10px] ${isDark ? 'bg-[rgba(3,38,80,0.6)]' : 'bg-white'} shadow-[2px_0px_6px_rgba(0,0,0,0.12)]`} >
{/* <div className="flex justify-between">
<span>筛选</span>
<span className="flex items-center">
<span>下发时间排序</span>
</span>
</div> */}
<div className="relative flex-1 max-w-md">
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${textMuted}`} />
<input
type="text"
placeholder="搜索任务名称"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => handleKeyDown(e)}
className={`w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-colors ${inputBg} ${cardBorder} ${textPrimary} placeholder:${textMuted}`}
/>
</div>
{/* 任务列表 */}
<Loading loading={taskLoading}>
<div className="h-[calc(100vh-200px)] overflow-y-auto pr-[10px]">
{
taskList.map((item: TaskListItem, index: number) => (
<div key={index} className={`${isDark ? 'bg-[#0062d91a] border-[#55a6fe1a] shadow-[0_3px_4px_#8b8b8b0f] dark-theme' : 'bg-white border-[#f5f5f5] shadow-[2px_0px_6px_rgba(0,0,0,0.08)] task-item-active'} p-[10px] mt-[10px] mb-[10px] rounded-[4px] border ${taskId === item.id && 'task-active'} cursor-pointer`} onClick={() => taskDataChange(item.id)}>
<div className="text-[var(--text-color)] text-[17px] mb-[5px] font-[500]">{item.taskName}</div>
<div className="text-[#7f7f7f] mb-[5px]">{format(new Date(item.taskStartDate), 'yyyy-MM-dd HH:mm:ss')}</div>
<div className="flex items-center mb-[8px]">
<img src={`/icon/${item?.taskStatusData?.icon}`} alt="" />
<span className="ml-[5px] leading-[16px]">{item?.taskStatusData?.label}</span>
</div>
<div className="pb-[10px] pt-[10px]">
<div className="border-b border-[#e8e8e8]"></div>
</div>
<div className="flex items-center justify-between">
<div className="h-[24px] item-active pl-[10px] pr-[10px] task-tab-color leading-[24px] rounded-[50px] bg-[var(--bg-color)]">
完成{item.endCount}/{item.sumAll}
</div>
<div className="pl-[20px] flex-1">
<Progress percentage={item.progress} label={false} color={item?.taskStatusData?.color} />
</div>
</div>
</div>
))
}
</div>
</Loading>
{/* 分页 */}
<div>
<Pagination
total={total}
size="small"
theme="simple"
current={pageNum}
pageSize={pageSize}
showPageSize={false}
onCurrentChange={(page) => pageIndexChange(Number(page))}
/>
</div>
</div>
</Col>
<Col span={10} className="h-full">
<div className="h-full flex flex-col p-[0_10px]">
<Loading loading={actionLoading}>
<div className="flex grid grid-cols-4 gap-[10px] pl-[10px]">
{
actionColumnList.map((item: actionColumnItem, index: number) => (
<ActionColumn key={index} icon={item.icon} columnIndex={index} columnName={item.columnName} actionCount={item.actionList.length} actionList={item.actionList} title={title} category={category} subCategory={subCategory} />
))
}
{
actionColumnList.length === 0 && <Empty/>
}
</div>
</Loading>
</div>
</Col>
</Row>
</div>
);
}
@@ -0,0 +1,114 @@
import { useTheme } from '@/contexts/ThemeContext';
import { ActionListItem, TaskListItem, taskStatusList } from '@/pages/TaskManagement';
import { Bot, ChevronRight, Download, Edit, Eye, FileText, Layers, MoreHorizontal, Play, Search, User, Users, Workflow } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
import { PageType } from '../Sidebar';
import { format } from 'date-fns';
import { useNavigate } from 'react-router-dom';
import { Col, Empty, Pagination, Progress, Row } from 'tdesign-react';
import { requestCrudService } from '@/service/request-axios-service';
interface ActionColumnProps {
TaskType?: string;
title: string;
category: string;
subCategory: string;
icon: string;
columnIndex: number;
columnName: string;
actionCount: number;
actionList: ActionListItem[];
}
const service = requestCrudService;
const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
export const ActionColumn: React.FC<ActionColumnProps> = ({ title, category, subCategory, icon, columnName, columnIndex, actionCount, actionList }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
// 主题颜色变量
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
const cardBorder = isDark ? 'border-blue-800/30' : 'border-red-100';
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
const textMuted = isDark ? 'text-slate-500' : 'text-gray-400';
const inputBg = isDark ? 'bg-slate-900/50' : 'bg-white';
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState<string>('');
// 当前页码,默认为第 1 页
const [pageNum, setPageNum] = useState<number>(1);
// 每页显示数量,默认为 10 条
const [pageSize, setPageSize] = useState<number>(10); // 每页显示 10 条
const [total, setTotal] = useState<number>(0); // 查询任务总数
const state = useRef({
pageIndex: 1,
pageSize: 10
})
/**
* 页码变化时的回调函数
* 当用户切换页码时触发,更新页码并重新获取数据
* @param value - 新的页码值
*/
const pageIndexChange = async (value: number) => {
// 更新 ref 中存储的页码值
// 更新页码状态,触发组件重新渲染
setPageNum(value);
state.current.pageIndex = value;
}
const actionRouterChange = (item: ActionListItem) => {
if (item.actionStatus !== 0) {
window.open(`https://ch1.b.uat.4.cn/web/#/action/item-aiceh/step-analysis-three?id=${item.id}`);
} else {
window.open(`https://ch1.b.uat.4.cn/web/#/action/item-aiceh/step-action-topic?id=${item.id}&isPassApply=false`);
}
}
return (
<div className={`h-[calc(100vh-100px)] p-[10px] flex-1 ${isDark? 'dark-action-bg' : `bg-white action-bg${columnIndex} shadow-[2px_0px_6px_rgba(0,0,0,0.12)]`} rounded-[4px]`}>
<div className="mb-[10px]">
<div className="flex items-center mb-[20px]">
<img src={`/icon/${icon}`} className="w-[24px] mr-[10px]" alt="" />
<span className="text[#222222] text-[18px] font-bold mr-[20px]">{columnName}</span>
<span className="text[#222222] text-[18px] font-bold mr-[20px]">{actionCount}</span>
</div>
</div>
<div className="h-[calc(100vh-200px)] overflow-y-auto pr-[10px]">
{
actionList.map((item: ActionListItem, index: number) => (
<div key={index} className={`${isDark? 'dark-action-item' : 'bg-white border border-[#f5f5f5] shadow-[0_1px_16px_#0000000f] action-item'} rounded-md px-4 py-2.5 mb-[10px] hover:boder-[var(--card-border)]`} onClick={() => actionRouterChange(item)}>
<div className="line-clamp-4 overflow-hidden mb-[10px]">
{
item.actionName
}
</div>
<div className="flex items-center mb-[10px]">
<span className={`${isDark? 'bg-[#0062d94d] text-[#aaceff]' :'bg-[#f5f5f5]'} leading-[16px] p-[5px_10px] rounded-[2px]`}>
{item.level === 1 ? '主要行动' : '次要行动'}
</span>
</div>
<div className="flex justify-between">
<span className={`${isDark? 'bg-[#0062d94d] text-[#aaceff]' :'bg-[#f5f5f5]'} leading-[16px] p-[5px_10px] rounded-[2px]`}>
{item.deptName}
</span>
<span className="border border-[var(--color-main)] text-[var(--color-main)] cursor-pointer leading-[16px] ml-[10px] p-[5px_10px] rounded-[2px]">
{
item.actionStatus === 0 ? <span>XD签收</span> : <span>开始CEH</span>
}
</span>
</div>
</div>
))
}
{
actionList.length === 0 && <Empty image={
<img
src="/icon/empty-list.png"
alt="empty"
className="w-[135px]"
/>
} />
}
</div>
</div>
);
}
@@ -0,0 +1,35 @@
import React, { useEffect, useState } from "react";
import "./css/iframe.css";
import { useTheme } from "@/contexts/ThemeContext";
import { PageType } from "@/components/Sidebar";
interface FanganTyPageProps {
title: string;
category: string;
subCategory: string;
onNavigate: (page: PageType) => void;
}
const TYURL = import.meta.env.VITE_AIPI_B2ACTOR_URL || "";
const FanganTyPage: React.FC<FanganTyPageProps> = ({ onNavigate }) => {
const { theme } = useTheme();
const isDark = theme === "dark";
const [iframeUrl, setIframeUrl] = useState("");
useEffect(() => {
initIframeUrl();
}, []);
const initIframeUrl = () => {
const url = `${TYURL}/fromB/toPage?defaultPage=iframeFaty`;
console.log({ iframeUrl: url });
setIframeUrl(url);
};
return (
<div className="iframe-container">
<iframe src={iframeUrl} className="full-size-iframe"></iframe>
</div>
);
};
export default FanganTyPage;
@@ -0,0 +1,92 @@
import { useTheme } from '@/contexts/ThemeContext';
import { TaskListItem } from '@/pages/TaskManagement';
import { ChevronRight, FileText } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { PageType } from '../Sidebar';
import { TaskCard } from './TaskCard';
import { Pagination } from 'tdesign-react';
interface KanbanColumnProps {
title: string;
count: number;
color: string;
tasks: TaskListItem[];
taskType: string;
TaskDataType?: string; // 区分来源类型
onNavigate: (page: PageType) => void;
pageIndexSearch: (taskType: string, pageIndex: number) => void;
handleStartAnalysis: (task: TaskListItem) => void
}
export const KanbanColumn: React.FC<KanbanColumnProps> = ({ title, count, color, tasks,taskType, TaskDataType = 'task' , onNavigate, pageIndexSearch, handleStartAnalysis }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
const textMuted = isDark ? 'text-slate-500' : 'text-gray-400';
// 当前页码,默认为第 1 页
const [pageNum, setPageNum] = useState<number>(1);
// 每页显示数量,默认为 10 条
const [pageSize, setPageSize] = useState<number>(10); // 每页显示 10 条
/**
* 页码变化时的回调函数
* 当用户切换页码时触发,更新页码并重新获取数据
* @param value - 新的页码值
*/
const pageIndexChange = async (value: number) => {
// 更新 ref 中存储的页码值
// 更新页码状态,触发组件重新渲染
console.log('taskType', taskType)
setPageNum(value);
await pageIndexSearch(taskType, value);
}
/**
* 每页显示数量变化时的回调函数
* 当用户切换每页显示条数时触发,更新每页数量并重新获取数据
* @param value - 新的每页显示数量
*/
const pageSizeChange = async (value: number) => {
// 更新 ref 中存储的每页显示数量
// 更新每页显示数量状态,触发组件重新渲染
setPageSize(value);
}
return (
<div className="flex flex-col min-h-0 flex-1">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${color}`} />
<h2 className={`font-semibold text-sm ${textPrimary}`}>{title}</h2>
<span className={`px-2 py-0.5 rounded-full text-xs ${isDark ? 'bg-slate-700/50 text-slate-400' : 'bg-red-50 text-red-500'
}`}>{count}</span>
</div>
{/* <button className={`p-1 rounded transition-colors ${isDark ? 'hover:bg-slate-700/50' : 'hover:bg-red-50'
}`}>
<ChevronRight className={`w-4 h-4 ${textMuted}`} />
</button> */}
<div>
<Pagination
total={count}
size="small"
theme="simple"
current={pageNum}
pageSize={pageSize}
maxPageBtn={3}
showPageSize={false}
totalContent={false}
onCurrentChange={(page) => pageIndexChange(Number(page))}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto space-y-3 pr-1 scrollbar-thin">
{tasks.length === 0 ? (
<div className={`flex flex-col items-center justify-center py-8 ${textMuted}`}>
<FileText className="w-8 h-8 mb-2 opacity-50" />
<p className="text-sm">暂无任务</p>
</div>
) : (
tasks.map(task => <TaskCard key={task.id} task={task} TaskType={TaskDataType} onNavigate={onNavigate} handleStartAnalysis={handleStartAnalysis}/>)
)}
</div>
</div>
);
}
@@ -0,0 +1,401 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Plus,
Search,
Eye,
Play,
Pause,
Clock,
User,
ChevronRight,
Filter,
FileText,
AlertTriangle,
CheckCircle,
XCircle,
MoreHorizontal,
Edit,
Download,
Bot,
Users,
Workflow,
X,
ArrowRight,
Target,
Layers,
} from 'lucide-react';
import { useTheme } from '@/contexts/ThemeContext';
import { PageType } from '@/components/Sidebar';
// 导入请求服务,用于与后端 API 交互
import { requestCrudService } from '@/service/request-axios-service';
import { KanbanColumn } from './KanbanColumn';
import { TaskListItem } from '@/pages/TaskManagement';
import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from 'tdesign-react';
import dayjs from 'dayjs';
import { useChatContext } from '@/contexts/ChatContext';
import { useNavigate } from 'react-router-dom';
interface TaskListPageProps {
title: string;
category: string;
subCategory: string;
onNavigate: (page: PageType) => void;
}
interface stateItem {
startTime: string,
endTime: string,
task: TaskListItem,
}
type TaskType = 'single' | 'consultation' | 'scene' | 'multi-agent' | 'task-search';
type TaskStatus = 'draft' | 'pending' | 'running' | 'paused' | 'blocked' | 'completed';
type ColumnType = 'unanalyzed' | 'analyzing' | 'completed';
const service = requestCrudService;
const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategory, onNavigate }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const router = useNavigate();
// 主题颜色变量
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
const cardBorder = isDark ? 'border-blue-800/30' : 'border-red-100';
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
const textMuted = isDark ? 'text-slate-500' : 'text-gray-400';
const inputBg = isDark ? 'bg-slate-900/50' : 'bg-white';
const { setStartConfig } = useChatContext();
const [searchQuery, setSearchQuery] = useState('');
const [modeFilter, setModeFilter] = useState<string>('all');
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
const [selectedTaskForAnalysis, setSelectedTaskForAnalysis] = useState<TaskListItem | null>(null);
const [unanalyzedTotal, setUnanalyzedTotal] = useState<number>(0); // 未分析
const [runningTotal, setRunningTotal] = useState<number>(0); // 分析中
const [completedTotal, setCompletedTotal] = useState<number>(0); // 已完成
const [unanalyzedList, setUnanalyzedList] = useState<TaskListItem[]>([]); // 未分析
const [runningList, setRunningList] = useState<TaskListItem[]>([]); // 分析中
const [completedList, setCompletedList] = useState<TaskListItem[]>([]); // 已完成
const [unanalyzedLoading, setUnanalyzedLoading] = useState<boolean>(false); // 未分析
const [runningLoading, setRunningLoading] = useState<boolean>(false); // 分析中
const [completedLoading, setCompletedLoading] = useState<boolean>(false); // 已完成
const [presets] = useState<DateRangePickerProps['presets']>({
最近7天: [dayjs().subtract(6, 'day').toDate(), dayjs().toDate()],
最近3天: [dayjs().subtract(2, 'day').toDate(), dayjs().toDate()],
今天: [dayjs().toDate(), dayjs().toDate()],
});
const [range, setRange] = useState<DateRangeValue>([]);
const state = useRef<stateItem>({
startTime: '',
endTime: '',
task: {} as TaskListItem,
})
// 时间日期选择器查询
const setRangeChange = async (val) => {
setRange(val);
console.log('时间日期', val);
state.current.startTime = '';
state.current.endTime = '';
if (val.length > 0) {
state.current.startTime = val[0];
state.current.endTime = val[1];
}
await taskDataChange();
}
// 处理开始分析按钮点击
const handleStartAnalysis = (task: TaskListItem) => {
setSelectedTaskForAnalysis(task);
state.current.task = task;
setShowAnalysisModal(true);
};
// 处理分析模式选择
const handleSelectAnalysisMode = (mode: TaskType) => {
setShowAnalysisModal(false);
console.log('mode', mode);
if (mode === 'single') {
onNavigate('task-single');
} else if (mode === 'scene') {
onNavigate('task-scene');
} else if (mode === 'consultation') {
console.log('state.current.task', state.current.task)
const sceneItem = JSON.parse(state.current.task.scene);
if (sceneItem.taskJson) {
sceneItem.taskJson = JSON.parse(sceneItem.taskJson);
setStartConfig({
...sceneItem.taskJson,
llm_preset_id: 'BAILING',
consistency_mode: 'soft',
phase_config: { diverge_rounds: 3 },
task_context: state.current.task.overview
});
router('/qa-center/mope-chat')
}
console.log('sceneItem', sceneItem);
// onNavigate('task-consult-create');
// window.open(`http://ch1.b.uat.4.cn/mopeweb/#/mope?taskOverview=${state.current.task.overview}`)
} else if (mode === 'multi-agent') {
onNavigate('multi-agent-qa');
} else if (mode === 'task-search') {
window.open(`https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-info-search?id=${state.current.task.id}`)
}
};
// 处理卡片点击
const taskListSearch = async (taskType: string, pageNum: number = 1, pageSize: number = 10) => {
let taskStatus: number[] = []
if (taskType === 'unanalyzed') { // 未分析
taskStatus = [0, 10];
setUnanalyzedLoading(true);
} else if (taskType === 'running') { // 分析中
taskStatus = [20];
setRunningLoading(true);
} else if (taskType === 'completed') { // 已完成
taskStatus = [25, 30, 40, 60, 70, 80, 90, 100, 110];
setCompletedLoading(true);
}
const param = {
content: searchQuery,
pageNum: pageNum,
pageSize: pageSize,
taskStatus: taskStatus,
startTime: state.current.startTime,
endTime: state.current.endTime,
taskDirection: ''
}
const res = await service.post(`${zncmUrl}/three/cop-three-tasks`, param);
console.log('任务列表', res);
const result = res.data;
result.records.forEach((item: TaskListItem) => {
item.column = taskType;
if (!item.taskLevel) item.taskLevel = 1;
})
if (taskType === 'unanalyzed') {
setUnanalyzedLoading(false);
setUnanalyzedTotal(result.total);
setUnanalyzedList(result.records);
} else if (taskType === 'running') {
setRunningLoading(false);
setRunningTotal(result.total);
setRunningList(result.records);
} else if (taskType === 'completed') {
setCompletedLoading(false);
setCompletedTotal(result.total);
setCompletedList(result.records);
}
}
/**
* 页码变化时的回调函数
* 当用户切换页码时触发,更新页码并重新获取数据
* @param value - 新的页码值
*/
const pageIndexChange = async (type: string, pageIndex: number) => {
// 更新 ref 中存储的页码值
// 更新页码状态,触发组件重新渲染
await taskListSearch(type, pageIndex);
}
const taskDataChange = async (pageNum: number = 1, pageSize: number = 10) => {
taskListSearch('unanalyzed', pageNum, pageSize);
taskListSearch('running', pageNum, pageSize);
taskListSearch('completed', pageNum, pageSize);
}
// 回车处理
const handleKeyDown = async (e) => {
if ((e.metaKey || e.ctrlKey || e.shiftKey) && e.key === "Enter") {
e.preventDefault();
} else if (e.keyCode === 13) {
e.preventDefault();
await taskDataChange();
} else if (e.key === "/") {
e.preventDefault();
}
};
useEffect(() => {
taskDataChange();
}, []);
// 渲染看板列
return (
<div className="h-full flex flex-col">
{/* 顶部工具栏 */}
<div className={`rounded-xl p-4 flex-shrink-0 mb-4 ${cardBg} border ${cardBorder}`}>
<div className="flex items-center justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-blue-500/20 text-blue-400' : 'bg-red-100 text-red-600'}`}>
{category}
</span>
<span className={`text-xs ${textMuted}`}>/</span>
<span className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-cyan-500/20 text-cyan-400' : 'bg-red-100 text-red-600'}`}>
{subCategory}
</span>
</div>
<h1 className={`text-xl font-bold ${textPrimary}`}>任务中心</h1>
</div>
<button
onClick={() => onNavigate('task-create')}
className={`flex items-center gap-2 px-4 py-2 text-white rounded-lg text-sm font-medium transition-colors ${isDark
? 'bg-blue-600 hover:bg-blue-500'
: 'bg-red-600 hover:bg-red-500 shadow-md'
}`}
>
<Plus className="w-4 h-4" />
新建任务
</button>
</div>
{/* 搜索和筛选 */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${textMuted}`} />
<input
type="text"
placeholder="搜索任务名称"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => handleKeyDown(e)}
className={`w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-colors ${inputBg} ${cardBorder} ${textPrimary} placeholder:${textMuted}`}
/>
</div>
{/* <select
value={modeFilter}
onChange={(e) => setModeFilter(e.target.value)}
className={`px-4 py-2 border rounded-lg text-sm focus:outline-none cursor-pointer ${inputBg} ${cardBorder} ${textSecondary}`}
>
<option value="all">全部模式</option>
<option value="single">单智能体</option>
<option value="multi-agent">多智能体协同</option>
<option value="consultation">多智能体会商</option>
<option value="scene">场景执行</option>
</select> */}
{/* <button className={`flex items-center gap-2 px-4 py-2 border rounded-lg text-sm transition-colors ${inputBg} ${cardBorder} ${textSecondary}`}>
<Clock className="w-4 h-4" />
时间范围
</button> */}
<DateRangePicker value={range} presets={presets} onChange={(val) => setRangeChange(val)} enableTimePicker={true} clearable />
</div>
</div>
{/* 统计概览栏 */}
<div className="grid grid-cols-4 gap-4 mb-4 flex-shrink-0">
<button
onClick={() => { setModeFilter('all'); }}
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${textPrimary}`}>{unanalyzedTotal + runningTotal + completedTotal}</p>
<p className={`text-sm ${textSecondary}`}>全部任务</p>
</button>
<button
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${textPrimary}`}>{unanalyzedTotal}</p>
<p className={`text-sm ${textSecondary}`}>未分析</p>
</button>
<button
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${isDark ? 'text-blue-400' : 'text-red-600'}`}>{runningTotal}</p>
<p className={`text-sm ${textSecondary}`}>分析中</p>
</button>
<button
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${isDark ? 'text-green-400' : 'text-green-600'}`}>{completedTotal}</p>
<p className={`text-sm ${textSecondary}`}>已完成</p>
</button>
</div>
{/* 看板区 */}
<div className="flex-1 grid grid-cols-3 gap-4 min-h-0 overflow-hidden">
<Loading loading={unanalyzedLoading}>
<KanbanColumn title="未分析" taskType={"unanalyzed"} TaskDataType={'sentiment'} count={unanalyzedTotal} color={isDark ? 'bg-yellow-400' : 'bg-yellow-400'} tasks={unanalyzedList} onNavigate={onNavigate} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis} />
</Loading>
<Loading loading={runningLoading}>
<KanbanColumn title="分析中" taskType="running" TaskDataType={'sentiment'} count={runningTotal} color={isDark ? 'bg-blue-400' : 'bg-red-400'} tasks={runningList} onNavigate={onNavigate} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis} />
</Loading>
<Loading loading={completedLoading}>
<KanbanColumn title="已完成" taskType="completed" TaskDataType={'sentiment'} count={completedTotal} color={isDark ? 'bg-green-400' : 'bg-green-400'} tasks={completedList} onNavigate={onNavigate} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis} />
</Loading>
</div>
{/* 选择分析模式弹窗 */}
{showAnalysisModal && selectedTaskForAnalysis && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowAnalysisModal(false)}>
<div
className={`w-full max-w-lg rounded-2xl p-6 ${cardBg} border ${cardBorder} shadow-xl`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className={`text-xl font-bold ${textPrimary}`}>选择分析模式</h2>
<p className={`text-sm ${textMuted} mt-1`}>为任务「{selectedTaskForAnalysis.taskName}」选择分析方式</p>
</div>
<button
onClick={() => setShowAnalysisModal(false)}
className={`p-2 rounded-lg hover:bg-slate-700/50 ${textSecondary}`}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-3">
<button
onClick={() => handleSelectAnalysisMode('task-search')}
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
? 'bg-slate-700/50 border-blue-800/30 hover:border-green-500/50'
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-green-500/20' : 'bg-green-100'
}`}>
<Layers className={`w-6 h-6 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
</div>
<div className="flex-1">
<h3 className={`font-semibold ${textPrimary}`}>查看任务详情</h3>
<p className={`text-sm ${textSecondary}`}>进入b1系统查看任务详情</p>
</div>
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
</div>
</button>
{/* 多智能体会商 */}
<button
onClick={() => handleSelectAnalysisMode('consultation')}
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
? 'bg-slate-700/50 border-blue-800/30 hover:border-orange-500/50'
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-orange-500/20' : 'bg-red-100'
}`}>
<Users className={`w-6 h-6 ${isDark ? 'text-orange-400' : 'text-red-500'}`} />
</div>
<div className="flex-1">
<h3 className={`font-semibold ${textPrimary}`}>多智能体会商</h3>
<p className={`text-sm ${textSecondary}`}>多个AI智能体从不同视角开展讨论并形成共识</p>
</div>
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
</div>
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default SentimentAnalyzes;
@@ -0,0 +1,678 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Plus,
Search,
Eye,
Play,
Pause,
Clock,
User,
ChevronRight,
Filter,
FileText,
AlertTriangle,
CheckCircle,
XCircle,
MoreHorizontal,
Edit,
Download,
Bot,
Users,
Workflow,
X,
ArrowRight,
Target,
Layers,
} from 'lucide-react';
import { useTheme } from '@/contexts/ThemeContext';
import { PageType } from '@/components/Sidebar';
// 导入请求服务,用于与后端 API 交互
import { requestCrudService } from '@/service/request-axios-service';
import { KanbanColumn } from './KanbanColumn';
import { TaskListItem } from '@/pages/TaskManagement';
import { DateRangePicker, DateRangePickerProps, DateRangeValue, Loading } from 'tdesign-react';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import { useChatContext } from '@/contexts/ChatContext';
interface TaskListPageProps {
title: string;
category: string;
subCategory: string;
onNavigate: (page: PageType) => void;
}
type TaskType = 'single' | 'consultation' | 'scene' | 'multi-agent' | 'task-search';
type TaskStatus = 'draft' | 'pending' | 'running' | 'paused' | 'blocked' | 'completed';
type ColumnType = 'unanalyzed' | 'analyzing' | 'completed';
interface Task {
id: string;
name: string;
type: TaskType;
status: TaskStatus;
agent?: string;
scene?: string;
participants?: string[];
currentPhase?: string;
currentRound?: number;
summary?: string;
creator: string;
createTime: string;
updateTime: string;
column: ColumnType;
}
interface stateItem {
startTime: string,
endTime: string,
task: TaskListItem,
}
const service = requestCrudService;
const zncmUrl = import.meta.env.VITE_API_BASE_URL || '';
const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategory, onNavigate }) => {
const { theme } = useTheme();
const { setStartConfig } = useChatContext();
const router = useNavigate();
const isDark = theme === 'dark';
// 主题颜色变量
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
const cardBorder = isDark ? 'border-blue-800/30' : 'border-red-100';
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
const textMuted = isDark ? 'text-slate-500' : 'text-gray-400';
const inputBg = isDark ? 'bg-slate-900/50' : 'bg-white';
// 生成模拟数据
const [tasks] = useState<Task[]>([
// 未分析列
{
id: '2026-0320-001',
name: '端午节品牌传播方案',
type: 'scene',
scene: '宣传策略生成',
status: 'draft',
column: 'unanalyzed',
summary: '针对端午节品牌活动制定完整传播策略方案',
creator: '李策划',
createTime: '2026-03-20 10:00',
updateTime: '2026-03-20 10:00',
},
{
id: '2026-0320-002',
name: '新品上市用户调研',
type: 'single',
agent: 'AudienceAgent',
status: 'pending',
column: 'unanalyzed',
summary: '对新品目标用户进行画像分析',
creator: '王运营',
createTime: '2026-03-20 09:30',
updateTime: '2026-03-20 09:30',
},
// 分析中列
{
id: '2026-0319-001',
name: '五四青年节宣传活动策略',
type: 'scene',
scene: '宣传策略生成',
status: 'running',
currentPhase: '行为分析',
column: 'analyzing',
summary: '正在按场景模板执行目标、受众、行为、叙事等分析...',
creator: '张策略',
createTime: '2026-03-19 10:30',
updateTime: '2026-03-20 09:45',
},
{
id: '2026-0319-002',
name: '青年传播方向会商分析',
type: 'consultation',
participants: ['GoalAgent', 'AudienceAgent', 'NarrativeAgent'],
status: 'running',
currentRound: 2,
column: 'analyzing',
summary: '围绕宣传方向、叙事方式和受众切入点开展多视角会商...',
creator: '王策划',
createTime: '2026-03-19 14:00',
updateTime: '2026-03-20 09:00',
},
{
id: '2026-0318-001',
name: '年度品牌升级方案推演',
type: 'scene',
scene: '宣传方案推演',
status: 'paused',
currentPhase: '叙事分析',
column: 'analyzing',
summary: '任务已暂停,等待人工介入确认叙事方向',
creator: '王策划',
createTime: '2026-03-18 14:00',
updateTime: '2026-03-20 08:30',
},
// 已完成列
{
id: '2026-0318-002',
name: '核心受众画像分析',
type: 'single',
agent: 'AudienceAgent',
status: 'completed',
column: 'completed',
summary: '已完成目标受众识别、关切提炼和阻力分析',
creator: '赵运营',
createTime: '2026-03-18 09:00',
updateTime: '2026-03-18 16:30',
},
{
id: '2026-0319-003',
name: '五四青年节宣传策略',
type: 'scene',
scene: '宣传策略生成',
status: 'completed',
column: 'completed',
summary: '已完成宣传策略方案生成',
creator: '张策略',
createTime: '2026-03-19 10:00',
updateTime: '2026-03-19 18:00',
},
{
id: '2026-0319-004',
name: '青年品牌传播路径会商',
type: 'consultation',
participants: ['GoalAgent', 'NarrativeAgent', 'RiskAgent'],
status: 'completed',
column: 'completed',
summary: '已完成多智能体会商,形成结论与分歧报告',
creator: '王策划',
createTime: '2026-03-19 14:30',
updateTime: '2026-03-19 17:00',
},
]);
const [searchQuery, setSearchQuery] = useState('');
const [modeFilter, setModeFilter] = useState<string>('all');
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
const [selectedTaskForAnalysis, setSelectedTaskForAnalysis] = useState<TaskListItem | null>(null);
const [unanalyzedTotal, setUnanalyzedTotal] = useState<number>(0); // 未分析
const [runningTotal, setRunningTotal] = useState<number>(0); // 分析中
const [completedTotal, setCompletedTotal] = useState<number>(0); // 已完成
const [unanalyzedList, setUnanalyzedList] = useState<TaskListItem[]>([]); // 未分析
const [runningList, setRunningList] = useState<TaskListItem[]>([]); // 分析中
const [completedList, setCompletedList] = useState<TaskListItem[]>([]); // 已完成
const [unanalyzedLoading, setUnanalyzedLoading] = useState<boolean>(false); // 未分析
const [runningLoading, setRunningLoading] = useState<boolean>(false); // 分析中
const [completedLoading, setCompletedLoading] = useState<boolean>(false); // 已完成
const [presets] = useState<DateRangePickerProps['presets']>({
最近7天: [dayjs().subtract(6, 'day').toDate(), dayjs().toDate()],
最近3天: [dayjs().subtract(2, 'day').toDate(), dayjs().toDate()],
今天: [dayjs().toDate(), dayjs().toDate()],
});
const [range, setRange] = useState<DateRangeValue>([]);
const state = useRef<stateItem>({
startTime: '',
endTime: '',
task: {} as TaskListItem,
})
// 时间日期选择器查询
const setRangeChange = async (val) => {
setRange(val);
console.log('时间日期', val);
state.current.startTime = '';
state.current.endTime = '';
if (val.length > 0) {
state.current.startTime = val[0];
state.current.endTime = val[1];
}
await taskDataChange();
}
// 获取任务类型配置
const getTypeConfig = (type: TaskType) => {
if (isDark) {
switch (type) {
case 'single':
return { label: '单智能体', color: 'bg-cyan-500/20 text-cyan-400', icon: Bot };
case 'scene':
return { label: '场景执行', color: 'bg-purple-500/20 text-purple-400', icon: Workflow };
case 'consultation':
return { label: '多智能体会商', color: 'bg-orange-500/20 text-orange-400', icon: Users };
case 'multi-agent':
return { label: '多智能体协同', color: 'bg-green-500/20 text-green-400', icon: Layers };
}
} else {
switch (type) {
case 'single':
return { label: '单智能体', color: 'bg-blue-100 text-blue-600', icon: Bot };
case 'scene':
return { label: '场景执行', color: 'bg-purple-100 text-purple-600', icon: Workflow };
case 'consultation':
return { label: '多智能体会商', color: 'bg-orange-100 text-orange-600', icon: Users };
case 'multi-agent':
return { label: '多智能体协同', color: 'bg-green-100 text-green-600', icon: Layers };
}
}
};
// 获取状态配置
const getStatusConfig = (status: TaskStatus, column: ColumnType) => {
if (column === 'unanalyzed') {
switch (status) {
case 'draft':
return { label: '草稿', color: isDark ? 'bg-slate-500/20 text-slate-400' : 'bg-gray-100 text-gray-600' };
case 'pending':
return { label: '待启动', color: isDark ? 'bg-slate-500/20 text-slate-400' : 'bg-gray-100 text-gray-600' };
default:
return { label: '待配置', color: isDark ? 'bg-yellow-500/20 text-yellow-400' : 'bg-yellow-100 text-yellow-600' };
}
} else if (column === 'analyzing') {
switch (status) {
case 'running':
return { label: '运行中', color: isDark ? 'bg-blue-500/20 text-blue-400' : 'bg-red-100 text-red-600' };
case 'paused':
return { label: '已暂停', color: isDark ? 'bg-yellow-500/20 text-yellow-400' : 'bg-yellow-100 text-yellow-600' };
case 'blocked':
return { label: '阻塞中', color: isDark ? 'bg-red-500/20 text-red-400' : 'bg-orange-100 text-orange-600' };
default:
return { label: '待处理', color: isDark ? 'bg-purple-500/20 text-purple-400' : 'bg-purple-100 text-purple-600' };
}
} else {
return { label: '已完成', color: isDark ? 'bg-green-500/20 text-green-400' : 'bg-green-100 text-green-600' };
}
};
// 筛选任务
const filteredTasks = tasks.filter(task => {
const matchesSearch = task.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
task.id.toLowerCase().includes(searchQuery.toLowerCase());
const matchesMode = modeFilter === 'all' || task.type === modeFilter;
return matchesSearch && matchesMode;
});
const unanalyzedTasks = filteredTasks.filter(t => t.column === 'unanalyzed');
const analyzingTasks = filteredTasks.filter(t => t.column === 'analyzing');
const completedTasks = filteredTasks.filter(t => t.column === 'completed');
// 统计数据
const stats = {
total: tasks.length,
unanalyzed: unanalyzedTasks.length,
analyzing: analyzingTasks.length,
completed: completedTasks.length,
};
// 处理开始分析按钮点击
const handleStartAnalysis = (task: TaskListItem) => {
setSelectedTaskForAnalysis(task);
state.current.task = task;
setShowAnalysisModal(true);
};
// 处理分析模式选择
const handleSelectAnalysisMode = (mode: TaskType) => {
setShowAnalysisModal(false);
console.log('mode', mode);
if (mode === 'single') {
onNavigate('task-single');
} else if (mode === 'scene') {
onNavigate('task-scene');
} else if (mode === 'consultation') {
console.log('state.current.task', state.current.task)
const sceneItem = JSON.parse(state.current.task.scene);
if (sceneItem.taskJson) {
sceneItem.taskJson = JSON.parse(sceneItem.taskJson);
setStartConfig({
...sceneItem.taskJson,
llm_preset_id: 'BAILING',
consistency_mode: 'soft',
phase_config: {diverge_rounds: 3},
task_context: state.current.task.overview
});
router('/qa-center/mope-chat')
}
console.log('sceneItem', sceneItem);
// onNavigate('task-consult-create');
// window.open(`http://ch1.b.uat.4.cn/mopeweb/#/mope?taskOverview=${state.current.task.overview}`)
} else if (mode === 'multi-agent') {
onNavigate('multi-agent-qa');
} else if (mode === 'task-search') {
window.open(`https://ch1.b.uat.4.cn/web/#/task/item-aicoh/step-info-search?id=${state.current.task.id}`)
}
};
// 处理卡片点击
const handleTaskClick = (task: Task) => {
if (task.column === 'completed') {
if (task.type === 'consultation') {
onNavigate('task-consult-result');
} else {
onNavigate('strategy-result');
}
} else if (task.column === 'analyzing') {
if (task.type === 'scene') {
onNavigate('task-scene');
} else if (task.type === 'consultation') {
onNavigate('task-consult');
} else {
onNavigate('task-single');
}
}
};
const taskListSearch = async (taskType: string, pageNum: number = 1, pageSize: number = 10) => {
let taskStatus: number[] = []
if (taskType === 'unanalyzed') { // 未分析
taskStatus = [0, 10];
setUnanalyzedLoading(true);
} else if (taskType === 'running') { // 分析中
taskStatus = [20];
setRunningLoading(true);
} else if (taskType === 'completed') { // 已完成
taskStatus = [25, 30, 40, 60, 70, 80, 90, 100, 110];
setCompletedLoading(true);
}
const param = {
content: searchQuery,
pageNum: pageNum,
pageSize: pageSize,
taskStatus: taskStatus,
startTime: state.current.startTime,
endTime: state.current.endTime,
taskDirection: ''
}
const res = await service.post(`${zncmUrl}/task/cop-tasks`, param);
console.log('任务列表', res);
const result = res.data;
result.records.forEach((item: TaskListItem) => {
item.column = taskType;
if (!item.taskLevel) item.taskLevel = 1;
})
if (taskType === 'unanalyzed') {
setUnanalyzedLoading(false);
setUnanalyzedTotal(result.total);
setUnanalyzedList(result.records);
} else if (taskType === 'running') {
setRunningLoading(false);
setRunningTotal(result.total);
setRunningList(result.records);
} else if (taskType === 'completed') {
setCompletedLoading(false);
setCompletedTotal(result.total);
setCompletedList(result.records);
}
}
/**
* 页码变化时的回调函数
* 当用户切换页码时触发,更新页码并重新获取数据
* @param value - 新的页码值
*/
const pageIndexChange = async (type: string, pageIndex: number) => {
// 更新 ref 中存储的页码值
// 更新页码状态,触发组件重新渲染
await taskListSearch(type, pageIndex);
}
const taskDataChange = async (pageNum: number = 1, pageSize: number = 10) => {
taskListSearch('unanalyzed', pageNum, pageSize);
taskListSearch('running', pageNum, pageSize);
taskListSearch('completed', pageNum, pageSize);
}
// 回车处理
const handleKeyDown = async (e) => {
if ((e.metaKey || e.ctrlKey || e.shiftKey) && e.key === "Enter") {
e.preventDefault();
} else if (e.keyCode === 13) {
e.preventDefault();
await taskDataChange();
} else if (e.key === "/") {
e.preventDefault();
}
};
useEffect(() => {
taskDataChange();
}, []);
// 渲染看板列
return (
<div className="h-full flex flex-col">
{/* 顶部工具栏 */}
<div className={`rounded-xl p-4 flex-shrink-0 mb-4 ${cardBg} border ${cardBorder}`}>
<div className="flex items-center justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<span className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-blue-500/20 text-blue-400' : 'bg-red-100 text-red-600'}`}>
{category}
</span>
<span className={`text-xs ${textMuted}`}>/</span>
<span className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-cyan-500/20 text-cyan-400' : 'bg-red-100 text-red-600'}`}>
{subCategory}
</span>
</div>
<h1 className={`text-xl font-bold ${textPrimary}`}>任务中心</h1>
</div>
<button
onClick={() => onNavigate('task-create')}
className={`flex items-center gap-2 px-4 py-2 text-white rounded-lg text-sm font-medium transition-colors ${isDark
? 'bg-blue-600 hover:bg-blue-500'
: 'bg-red-600 hover:bg-red-500 shadow-md'
}`}
>
<Plus className="w-4 h-4" />
新建任务
</button>
</div>
{/* 搜索和筛选 */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${textMuted}`} />
<input
type="text"
placeholder="搜索任务名称"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => handleKeyDown(e)}
className={`w-full pl-10 pr-4 py-2 border rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-red-500/20 transition-colors ${inputBg} ${cardBorder} ${textPrimary} placeholder:${textMuted}`}
/>
</div>
{/* <select
value={modeFilter}
onChange={(e) => setModeFilter(e.target.value)}
className={`px-4 py-2 border rounded-lg text-sm focus:outline-none cursor-pointer ${inputBg} ${cardBorder} ${textSecondary}`}
>
<option value="all">全部模式</option>
<option value="single">单智能体</option>
<option value="multi-agent">多智能体协同</option>
<option value="consultation">多智能体会商</option>
<option value="scene">场景执行</option>
</select> */}
{/* <button className={`flex items-center gap-2 px-4 py-2 border rounded-lg text-sm transition-colors ${inputBg} ${cardBorder} ${textSecondary}`}>
<Clock className="w-4 h-4" />
时间范围
</button> */}
<DateRangePicker value={range} presets={presets} onChange={(val) => setRangeChange(val)} enableTimePicker={true} clearable />
</div>
</div>
{/* 统计概览栏 */}
<div className="grid grid-cols-4 gap-4 mb-4 flex-shrink-0">
<button
onClick={() => { setModeFilter('all'); }}
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${textPrimary}`}>{unanalyzedTotal + runningTotal + completedTotal}</p>
<p className={`text-sm ${textSecondary}`}>全部任务</p>
</button>
<button
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${textPrimary}`}>{unanalyzedTotal}</p>
<p className={`text-sm ${textSecondary}`}>未分析</p>
</button>
<button
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${isDark ? 'text-blue-400' : 'text-red-600'}`}>{runningTotal}</p>
<p className={`text-sm ${textSecondary}`}>分析中</p>
</button>
<button
className={`p-4 rounded-xl border transition-all ${cardBg} ${cardBorder} hover:border-red-400`}
>
<p className={`text-2xl font-bold ${isDark ? 'text-green-400' : 'text-green-600'}`}>{completedTotal}</p>
<p className={`text-sm ${textSecondary}`}>已完成</p>
</button>
</div>
{/* 看板区 */}
<div className="flex-1 grid grid-cols-3 gap-4 min-h-0 overflow-hidden">
<Loading loading={unanalyzedLoading}>
<KanbanColumn title="未分析" taskType={"unanalyzed"} count={unanalyzedTotal} color={isDark ? 'bg-yellow-400' : 'bg-yellow-400'} tasks={unanalyzedList} onNavigate={onNavigate} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis}/>
</Loading>
<Loading loading={runningLoading}>
<KanbanColumn title="分析中" taskType="running" count={runningTotal} color={isDark ? 'bg-blue-400' : 'bg-red-400'} tasks={runningList} onNavigate={onNavigate} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis}/>
</Loading>
<Loading loading={completedLoading}>
<KanbanColumn title="已完成" taskType="completed" count={completedTotal} color={isDark ? 'bg-green-400' : 'bg-green-400'} tasks={completedList} onNavigate={onNavigate} pageIndexSearch={pageIndexChange} handleStartAnalysis={handleStartAnalysis}/>
</Loading>
</div>
{/* 选择分析模式弹窗 */}
{showAnalysisModal && selectedTaskForAnalysis && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setShowAnalysisModal(false)}>
<div
className={`w-full max-w-lg rounded-2xl p-6 ${cardBg} border ${cardBorder} shadow-xl`}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className={`text-xl font-bold ${textPrimary}`}>选择分析模式</h2>
<p className={`text-sm ${textMuted} mt-1`}>为任务「{selectedTaskForAnalysis.taskName}」选择分析方式</p>
</div>
<button
onClick={() => setShowAnalysisModal(false)}
className={`p-2 rounded-lg hover:bg-slate-700/50 ${textSecondary}`}
>
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-3">
{/* 单智能体分析 */}
{/* <button
onClick={() => handleSelectAnalysisMode('single')}
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
? 'bg-slate-700/50 border-blue-800/30 hover:border-cyan-500/50'
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-cyan-500/20' : 'bg-red-100'
}`}>
<Bot className={`w-6 h-6 ${isDark ? 'text-cyan-400' : 'text-red-500'}`} />
</div>
<div className="flex-1">
<h3 className={`font-semibold ${textPrimary}`}>单智能体分析</h3>
<p className={`text-sm ${textSecondary}`}>选择一个AI智能体完成单一维度的专项分析</p>
</div>
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
</div>
</button> */}
{/* 多智能体协同分析 */}
{/* <button
onClick={() => handleSelectAnalysisMode('multi-agent')}
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
? 'bg-slate-700/50 border-blue-800/30 hover:border-green-500/50'
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-green-500/20' : 'bg-green-100'
}`}>
<Layers className={`w-6 h-6 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
</div>
<div className="flex-1">
<h3 className={`font-semibold ${textPrimary}`}>多智能体协同分析</h3>
<p className={`text-sm ${textSecondary}`}>多个AI智能体并行分析,输出各自的判断和结论</p>
</div>
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
</div>
</button> */}
<button
onClick={() => handleSelectAnalysisMode('task-search')}
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
? 'bg-slate-700/50 border-blue-800/30 hover:border-green-500/50'
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-green-500/20' : 'bg-green-100'
}`}>
<Layers className={`w-6 h-6 ${isDark ? 'text-green-400' : 'text-green-600'}`} />
</div>
<div className="flex-1">
<h3 className={`font-semibold ${textPrimary}`}>查看任务详情</h3>
<p className={`text-sm ${textSecondary}`}>进入b1系统查看任务详情</p>
</div>
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
</div>
</button>
{/* 多智能体会商 */}
<button
onClick={() => handleSelectAnalysisMode('consultation')}
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
? 'bg-slate-700/50 border-blue-800/30 hover:border-orange-500/50'
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-orange-500/20' : 'bg-red-100'
}`}>
<Users className={`w-6 h-6 ${isDark ? 'text-orange-400' : 'text-red-500'}`} />
</div>
<div className="flex-1">
<h3 className={`font-semibold ${textPrimary}`}>多智能体会商</h3>
<p className={`text-sm ${textSecondary}`}>多个AI智能体从不同视角开展讨论并形成共识</p>
</div>
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
</div>
</button>
{/* 场景执行 */}
{/* <button
onClick={() => handleSelectAnalysisMode('scene')}
className={`w-full p-4 rounded-xl border transition-all duration-200 text-left hover:shadow-md ${isDark
? 'bg-slate-700/50 border-blue-800/30 hover:border-purple-500/50'
: 'bg-white border-red-100 hover:border-red-300 hover:shadow-md'
}`}
>
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-purple-500/20' : 'bg-red-100'
}`}>
<Workflow className={`w-6 h-6 ${isDark ? 'text-purple-400' : 'text-red-500'}`} />
</div>
<div className="flex-1">
<h3 className={`font-semibold ${textPrimary}`}>场景执行</h3>
<p className={`text-sm ${textSecondary}`}>按照预设场景模板自动执行多阶段策略分析</p>
</div>
<ArrowRight className={`w-5 h-5 ${textMuted}`} />
</div>
</button> */}
</div>
</div>
</div>
)}
</div>
);
};
export default TaskAnalyzes;
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff. Show more