路由调整
This commit is contained in:
1 parent
7a7c99f752
commit
543d78e040
21 files changed
+1869
-490
No files matched your search
Generated
+101
-3
@@ -61,8 +61,10 @@
|
||||
"ogl": "^1.0.11",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
@@ -4625,6 +4627,11 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prismjs": {
|
||||
"version": "1.26.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
|
||||
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.13",
|
||||
"license": "MIT",
|
||||
@@ -5979,6 +5986,18 @@
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
|
||||
},
|
||||
"node_modules/fault": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
|
||||
"integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
|
||||
"dependencies": {
|
||||
"format": "^0.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -5996,6 +6015,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/format": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
|
||||
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
|
||||
"engines": {
|
||||
"node": ">=0.4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
|
||||
@@ -6303,6 +6330,19 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "10.7.3",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
|
||||
"integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/highlightjs-vue": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
|
||||
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
@@ -6852,6 +6892,19 @@
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lowlight": {
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
|
||||
"integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
|
||||
"dependencies": {
|
||||
"fault": "^1.0.0",
|
||||
"highlight.js": "~10.7.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -6863,7 +6916,8 @@
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.562.0",
|
||||
"license": "ISC",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
|
||||
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
@@ -8085,6 +8139,14 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prismjs": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
@@ -8293,6 +8355,25 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-syntax-highlighter": {
|
||||
"version": "16.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz",
|
||||
"integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"highlight.js": "^10.4.1",
|
||||
"highlightjs-vue": "^1.0.0",
|
||||
"lowlight": "^1.17.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"refractor": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.20.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 0.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
@@ -8322,6 +8403,21 @@
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/refractor": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
|
||||
"integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/prismjs": "^1.0.0",
|
||||
"hastscript": "^9.0.0",
|
||||
"parse-entities": "^4.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
@@ -8375,7 +8471,8 @@
|
||||
},
|
||||
"node_modules/rehype-raw": {
|
||||
"version": "7.0.0",
|
||||
"license": "MIT",
|
||||
"resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
|
||||
"integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"hast-util-raw": "^9.0.0",
|
||||
@@ -8388,7 +8485,8 @@
|
||||
},
|
||||
"node_modules/remark-gfm": {
|
||||
"version": "4.0.1",
|
||||
"license": "MIT",
|
||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
||||
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-gfm": "^3.0.0",
|
||||
|
||||
@@ -63,8 +63,10 @@
|
||||
"ogl": "^1.0.11",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import type { ElementType } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
BarChart3,
|
||||
Bot,
|
||||
@@ -38,6 +39,7 @@ type MenuItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ElementType;
|
||||
path?: string;
|
||||
children?: MenuItem[];
|
||||
};
|
||||
|
||||
@@ -53,7 +55,7 @@ const menuItems: MenuItem[] = [
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "new-qa", label: "新建问答", icon: Plus },
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp },
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/strategy/qa/general" },
|
||||
{ id: "agent-qa", label: "智能体问答", icon: Bot },
|
||||
{ id: "multi-agent-qa", label: "多智能体", icon: Layers },
|
||||
{ id: "sixstep-chat", label: "六步对话", icon: Workflow },
|
||||
@@ -128,7 +130,7 @@ const menuItems: MenuItem[] = [
|
||||
label: "智能体管理",
|
||||
icon: Bot,
|
||||
children: [
|
||||
{ id: "agent-overview", label: "智能体列表", icon: Users },
|
||||
{ id: "agent-overview", label: "智能体列表", icon: Users, path: "/page/strategy/agents" },
|
||||
{ id: "agent-trial", label: "单智能体试用", icon: PlayCircle },
|
||||
],
|
||||
},
|
||||
@@ -233,6 +235,8 @@ export function PageSidebar({
|
||||
onToggleCollapsed,
|
||||
}: PageSidebarProps) {
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(defaultExpandedIds);
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const iconButtonClass =
|
||||
"rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600";
|
||||
@@ -256,14 +260,20 @@ export function PageSidebar({
|
||||
|
||||
const renderLeaf = (item: MenuItem, depth = 0) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = !!item.path && pathname === item.path;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
title={collapsed && depth === 0 ? item.label : undefined}
|
||||
className={`flex w-full items-center rounded-lg py-2 text-left text-gray-600 transition-all duration-300 ease-out hover:-translate-y-0.5 hover:bg-red-50 hover:text-red-600 ${
|
||||
onClick={() => item.path && navigate(item.path)}
|
||||
className={`flex w-full items-center rounded-lg py-2 text-left transition-all duration-300 ease-out hover:-translate-y-0.5 ${
|
||||
collapsed && depth === 0 ? "justify-center px-2" : "gap-2 px-3"
|
||||
} ${
|
||||
isActive
|
||||
? "bg-red-50 text-red-600 font-semibold"
|
||||
: "text-gray-600 hover:bg-red-50 hover:text-red-600"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`${depth === 0 ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out`} />
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 后端 API 默认地址
|
||||
*/
|
||||
export const DEFAULT_BACKEND_URL = 'http://10.26.128.38:8030';
|
||||
|
||||
/**
|
||||
* 默认前端配置
|
||||
*
|
||||
* 包含:
|
||||
* - coordinator_config: 协调者各阶段策略提示
|
||||
* - agents: 默认四智能体(背景研判师、目的策略师、受众分析师、叙事策划师)
|
||||
* - report_schema: 六字段报告结构定义
|
||||
* - report_examples: 各字段的示例输出(问界 M7 事故场景)
|
||||
* - field_requirements: 字段依赖关系定义
|
||||
*/
|
||||
export const DEFAULT_CONFIG = {
|
||||
task_context: "",
|
||||
llm_preset_id: "",
|
||||
consistency_mode: "soft",
|
||||
coordinator_mode: "auto",
|
||||
coordinator_config: {
|
||||
system_prompt_prefix: "",
|
||||
round1_guidance: `这是发散阶段第1轮,草稿刚起步。
|
||||
策略:优先填写草稿中空缺最关键的字段,同时识别智能体之间最尖锐的分歧——
|
||||
这些分歧往往意味着草稿中某个判断还无法做出,需要通过交锋来解决。`,
|
||||
middle_round_guidance: `这是发散阶段的中间轮次。
|
||||
策略:审查草稿现状,找出仍然太模糊或有冲突的字段,
|
||||
选择能推进这些字段的智能体,给出让草稿变得更具体的任务。`,
|
||||
final_round_guidance: `这是发散阶段最后一轮。
|
||||
策略:草稿必须在这轮后达到可以收尾的状态,但仍要求智能体给出完整材料包,
|
||||
包括明确判断、依据链、反例、风险边界和可核验线索;最终压缩、取舍和成文由协调者完成。`,
|
||||
speaker_selection_rules: "优先选择最能扩充 partial/blocked 字段材料、解决关键冲突、补充证据或论证链的智能体。",
|
||||
focus_instruction_rules: "focus 必须写成内容型任务书,不能只催促 agent 继续分析。每条至少包含:目标字段、已有材料或草稿判断、真正缺口、为什么该缺口会阻塞报告、需要补充的具体材料类型、建议切入角度、预期输出形态、风险或禁止事项。指令应鼓励 agent 展开材料、证据线索、论证链、反例、分歧和边界条件,而不是只给短结论。",
|
||||
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 },
|
||||
template_id: "",
|
||||
template_name: "",
|
||||
template_brief: "",
|
||||
report_schema: {
|
||||
situation_assessment: "背景研判:当前舆论场/市场/竞争格局的战略研判。包含现有几条并行叙事及其势头、可以被打散的认知锚点、竞争对手动作的逻辑漏洞、可操作的叙事空间。不是损失报告,是战略判断。",
|
||||
communication_objectives: "传播目标:2-4个本方案期望在受众中建立的具体认知或情绪,用目标受众的第一人称内心独白表达。不写商业结果,不写指标,只写认知和情绪目标。可含竞争性目标。",
|
||||
audience_map: "核心受众:2-4个具体受众群体,每个包含:(1)人口画像——年龄段、性别、城市层级、具体特征;(2)当前心理状态——用第一人称内心独白;(3)关键信息节点——具体平台和账号/社区名称,不接受泛化描述。",
|
||||
narrative_plan: "叙事方案:2-3个叙事支柱,每个包含:(1)具体内容——可执行的内容方向或文案草稿;(2)为什么这么设计——预期心理机制;(3)预期效果——目标受众的认知变化;(4)风险与局限——失效条件。叙事框架和消息合并在一起。",
|
||||
action_plan: "分阶段行动计划:按时间线的3-4个阶段,每个行动具体到:做什么、谁执行(官方/第三方/独立账号)、何时。",
|
||||
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: `叙事支柱一:工况重构——把"电动门锁缺陷"扩展为"追尾高速起火"议题
|
||||
|
||||
具体内容:发布或推动媒体发布《追尾高速事故生存数据分析》,引用可验证的事故数据:中国高速追尾事故中燃油车起火比例、燃油车在高速追尾后因油箱破裂导致无法逃生的案例、断电门锁问题在行业内的普遍性(其他品牌纯电车是否存在类似设计)。
|
||||
|
||||
为什么这么设计:公众对"电动车起火"的恐惧,部分来自"燃油车才是安全默认值"的认知锚点。此叙事目标不是洗白问界,而是打散原有的比较基准——把"问界门锁失效"从"品牌失职"变成"高速追尾这一极端工况下的行业性挑战"。
|
||||
|
||||
预期效果:目标受众在搜索"问界安全"时,搜到的不是事故视频,而是工况对比分析,认知从"问界车会困死人"转向"高速追尾是极端事故,概率极低"。
|
||||
|
||||
风险与局限:如果数据无法支持"燃油车风险不低于电动车"的结论,此叙事会反噬——数据必须真实可查。部分受众会将此解读为"转移焦点"。`,
|
||||
|
||||
action_plan: `第一阶段:止血(D+0 至 D+3)
|
||||
- D+0:余承东删除原有争议微博。AITO官方发布声明,核心措辞:承认断电门锁释放机制有改进空间,公布将与第三方机构联合调查,暂停全部品牌宣传广告
|
||||
- D+1:通过APP+服务顾问向全体问界M7车主发送一对一信息,提供免费安全检测预约和免费代步车
|
||||
- D+2:联系@42号车库、@老司机测评,提供完整的碰撞技术资料(不要求立场,只提供信息)
|
||||
|
||||
第二阶段:叙事建立(第1-4周)
|
||||
- 第1周:推动@42号车库发布《追尾高速起火,燃油车和电动车谁更危险?》技术向内容,问界提供数据但不参与内容制作
|
||||
- 第2周:举办技术说明会,邀请10家垂直媒体,公布OTA升级方案和物理把手加装方案详细规格
|
||||
- 第3周:中国汽研开始改进后车辆的独立测试,全程开放媒体记者跟拍`,
|
||||
|
||||
risk_playbook: `风险一:12周内发生第二起相似事故
|
||||
- 触发条件:同类门锁失效导致的伤亡事故再次出现
|
||||
- 应对步骤:①立即暂停全部销售;②主动召回所有已售问界M7,不等监管要求;③停止所有叙事支柱三(竞品对比)内容的推进
|
||||
- 明确禁止:不得发布任何暗示"概率极低"的安抚性内容
|
||||
|
||||
风险二:数据核查显示燃油车追尾起火比例低于电动车
|
||||
- 触发条件:第三方核查发现叙事支柱一中引用的数据不成立
|
||||
- 应对步骤:①立即停止涉及数据比较的传播;②调整叙事回归"我们改进了"主线,放弃工况比较维度
|
||||
- 明确禁止:不得继续引用已被质疑的数据,不得删除原文(会被截图放大)`
|
||||
},
|
||||
field_requirements: {
|
||||
communication_objectives: {
|
||||
title: "传播目标",
|
||||
dependencies: ["situation_assessment"],
|
||||
},
|
||||
audience_map: {
|
||||
title: "核心受众",
|
||||
dependencies: ["communication_objectives"],
|
||||
},
|
||||
narrative_plan: {
|
||||
title: "叙事方案",
|
||||
dependencies: ["communication_objectives", "audience_map"],
|
||||
},
|
||||
action_plan: {
|
||||
title: "分阶段行动计划",
|
||||
dependencies: ["communication_objectives", "audience_map", "narrative_plan"],
|
||||
},
|
||||
risk_playbook: {
|
||||
title: "风险预案",
|
||||
dependencies: ["narrative_plan", "action_plan"],
|
||||
},
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
/** localStorage 存储前缀,用于模板覆盖数据的 key */
|
||||
const OVERRIDE_PREFIX = 'mope_template_override:';
|
||||
|
||||
/**
|
||||
* 深拷贝对象
|
||||
* 使用 JSON 序列化/反序列化实现简单深拷贝,
|
||||
* 适用于纯数据的配置对象(不含函数、循环引用等)。
|
||||
*/
|
||||
export function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从后端加载模板索引列表
|
||||
* @returns {Promise<Array>} 模板摘要数组 [{ template_id, template_name, file }]
|
||||
*/
|
||||
export async function loadTemplateIndex() {
|
||||
const response = await fetch('/templates/index.json');
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
return data.templates || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载完整模板数据
|
||||
* 优先使用 localStorage 中的用户覆盖版本,
|
||||
* 否则从后端静态文件加载。
|
||||
*
|
||||
* @param {string|Object} metaOrId - 模板 ID 字符串或包含 template_id/file 的元数据对象
|
||||
* @returns {Promise<Object>} 完整模板对象
|
||||
*/
|
||||
export async function loadTemplateByMeta(metaOrId) {
|
||||
const templateId = typeof metaOrId === 'string' ? metaOrId : metaOrId?.template_id;
|
||||
const file = typeof metaOrId === 'string' ? `/templates/${templateId}.json` : metaOrId?.file;
|
||||
const response = await fetch(file || `/templates/${templateId}.json`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const template = await response.json();
|
||||
const override = getTemplateOverride(template.template_id || templateId);
|
||||
return override ? clone(override) : template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载原始模板(忽略用户覆盖)
|
||||
* @param {string|Object} metaOrId - 模板标识
|
||||
* @returns {Promise<Object>} 后端静态模板
|
||||
*/
|
||||
export async function loadOriginalTemplate(metaOrId) {
|
||||
const templateId = typeof metaOrId === 'string' ? metaOrId : metaOrId?.template_id;
|
||||
const file = typeof metaOrId === 'string' ? `/templates/${templateId}.json` : metaOrId?.file;
|
||||
const response = await fetch(file || `/templates/${templateId}.json`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户在 localStorage 中保存的模板覆盖数据
|
||||
* @param {string} templateId - 模板 ID
|
||||
* @returns {Object|null} 覆盖的模板对象,未覆盖时返回 null
|
||||
*/
|
||||
export function getTemplateOverride(templateId) {
|
||||
if (!templateId) return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(`${OVERRIDE_PREFIX}${templateId}`);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断指定模板是否已被用户覆盖修改
|
||||
* @param {string} templateId - 模板 ID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasTemplateOverride(templateId) {
|
||||
return Boolean(getTemplateOverride(templateId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存模板覆盖到 localStorage
|
||||
* @param {string} templateId - 模板 ID
|
||||
* @param {Object} template - 修改后的模板对象
|
||||
*/
|
||||
export function saveTemplateOverride(templateId, template) {
|
||||
if (!templateId) throw new Error('缺少 template_id');
|
||||
localStorage.setItem(`${OVERRIDE_PREFIX}${templateId}`, JSON.stringify(template));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定模板的覆盖数据,恢复为后端原始版本
|
||||
* @param {string} templateId - 模板 ID
|
||||
*/
|
||||
export function clearTemplateOverride(templateId) {
|
||||
if (!templateId) return;
|
||||
localStorage.removeItem(`${OVERRIDE_PREFIX}${templateId}`);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { useLocalStorage } from './useLocalStorage';
|
||||
export { useWindowSize, useIsMobile } from './useWindowSize';
|
||||
export { useDebounce, useDebounceCallback } from './useDebounce';
|
||||
export { useToggle } from './useToggle';
|
||||
export { useAsync } from './useAsync';
|
||||
export { useChatHistory } from './useChatHistory';
|
||||
export { useExpertAuthBootstrap } from './useExpertAuthBootstrap';
|
||||
export type { LlmItem, CurrentUser } from './useExpertAuthBootstrap';
|
||||
export type { ChatMessage } from './useChatHistory';
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface AsyncState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function useAsync<T>() {
|
||||
const [state, setState] = useState<AsyncState<T>>({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const execute = useCallback(async (asyncFunction: () => Promise<T>) => {
|
||||
setState({ data: null, loading: true, error: null });
|
||||
try {
|
||||
const data = await asyncFunction();
|
||||
setState({ data, loading: false, error: null });
|
||||
return data;
|
||||
} catch (error) {
|
||||
setState({ data: null, loading: false, error: error as Error });
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({ data: null, loading: false, error: null });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
execute,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface UseChatHistoryReturn {
|
||||
messages: ChatMessage[];
|
||||
addMessage: (role: 'user' | 'assistant', content: string) => void;
|
||||
clearMessages: () => void;
|
||||
deleteMessage: (id: string) => void;
|
||||
updateMessage: (id: string, content: string) => void;
|
||||
}
|
||||
|
||||
export function useChatHistory(storageKey: string = 'chat-history'): UseChatHistoryReturn {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const saveToStorage = useCallback((newMessages: ChatMessage[]) => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(newMessages));
|
||||
} catch (error) {
|
||||
console.error('Failed to save chat history:', error);
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
const addMessage = useCallback((role: 'user' | 'assistant', content: string) => {
|
||||
const newMessage: ChatMessage = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
role,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev, newMessage];
|
||||
saveToStorage(updated);
|
||||
return updated;
|
||||
});
|
||||
}, [saveToStorage]);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
localStorage.removeItem(storageKey);
|
||||
}, [storageKey]);
|
||||
|
||||
const deleteMessage = useCallback((id: string) => {
|
||||
setMessages((prev) => {
|
||||
const updated = prev.filter((msg) => msg.id !== id);
|
||||
saveToStorage(updated);
|
||||
return updated;
|
||||
});
|
||||
}, [saveToStorage]);
|
||||
|
||||
const updateMessage = useCallback((id: string, content: string) => {
|
||||
setMessages((prev) => {
|
||||
const updated = prev.map((msg) =>
|
||||
msg.id === id ? { ...msg, content } : msg
|
||||
);
|
||||
saveToStorage(updated);
|
||||
return updated;
|
||||
});
|
||||
}, [saveToStorage]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
addMessage,
|
||||
clearMessages,
|
||||
deleteMessage,
|
||||
updateMessage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number = 300): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export function useDebounceCallback<T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
delay: number = 300
|
||||
): T {
|
||||
const callbackRef = useRef(callback);
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
const debouncedCallback = useCallback((...args: Parameters<T>) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callbackRef.current(...args);
|
||||
}, delay);
|
||||
}, [delay]) as T;
|
||||
|
||||
return debouncedCallback;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export interface LlmItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
is_verified: boolean;
|
||||
pages_limit: number;
|
||||
pages_used: number;
|
||||
}
|
||||
|
||||
type RequestInfoLike = Parameters<typeof fetch>[0];
|
||||
type RequestInitLike = Parameters<typeof fetch>[1];
|
||||
|
||||
const LOGIN_URL = 'http://47.88.25.99:8000/auth/jwt/login';
|
||||
const LLMS_URL = 'http://47.88.25.99:8000/api/v1/expert-agents/llms';
|
||||
const ME_URL = 'http://47.88.25.99:8000/users/me';
|
||||
|
||||
export function useExpertAuthBootstrap() {
|
||||
const [llmList, setLlmList] = useState<LlmItem[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const authedFetch = useCallback(async (input: RequestInfoLike, init?: RequestInitLike) => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const headers = new Headers(init?.headers);
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return fetch(input, { ...init, headers });
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('username', 'dhk@qq.com');
|
||||
formData.append('password', '1qaz@WSX');
|
||||
formData.append('grant_type', 'password');
|
||||
|
||||
const res = await fetch(LOGIN_URL, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`登录失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data?.access_token) {
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchLlmList = useCallback(async () => {
|
||||
const res = await authedFetch(LLMS_URL, { method: 'GET' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`llms 接口失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const data: unknown = await res.json();
|
||||
const list = Array.isArray(data) ? (data as LlmItem[]) : [];
|
||||
setLlmList(list);
|
||||
return list;
|
||||
}, [authedFetch]);
|
||||
|
||||
const fetchCurrentUser = useCallback(async () => {
|
||||
const res = await authedFetch(ME_URL, { method: 'GET' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`users/me 接口失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const data: unknown = await res.json();
|
||||
const user = (data as CurrentUser) ?? null;
|
||||
setCurrentUser(user);
|
||||
return user;
|
||||
}, [authedFetch]);
|
||||
|
||||
const bootstrap = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await login();
|
||||
await Promise.all([fetchLlmList(), fetchCurrentUser()]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchCurrentUser, fetchLlmList, login]);
|
||||
|
||||
useEffect(() => {
|
||||
void bootstrap().catch((error) => {
|
||||
console.error('专家中心初始化失败:', error);
|
||||
});
|
||||
}, [bootstrap]);
|
||||
|
||||
return {
|
||||
llmList,
|
||||
currentUser,
|
||||
loading,
|
||||
authedFetch,
|
||||
login,
|
||||
fetchLlmList,
|
||||
fetchCurrentUser,
|
||||
bootstrap,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T) {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
console.error('Error reading from localStorage:', error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
const setValue = (value: T | ((val: T) => T)) => {
|
||||
try {
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||
setStoredValue(valueToStore);
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
} catch (error) {
|
||||
console.error('Error writing to localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return [storedValue, setValue] as const;
|
||||
}
|
||||
@@ -0,0 +1,852 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* 会话初始状态
|
||||
*
|
||||
* status 枚举:
|
||||
* - 'idle': 未启动
|
||||
* - 'running': 正在运行(流式输出中)
|
||||
* - 'waiting_human': 等待人工确认(仅 human_review 模式)
|
||||
* - 'done': 分析完成
|
||||
* - 'error': 发生错误
|
||||
*
|
||||
* phase 枚举:
|
||||
* - 'starting': 启动阶段(第 1 轮,协调者分配初始任务)
|
||||
* - 'diverging': 辩论阶段(多智能体并行输出)
|
||||
* - 'closing': 收敛阶段(协调者整合草稿)
|
||||
* - 'done': 完成
|
||||
*/
|
||||
const initialState = {
|
||||
status: 'idle',
|
||||
sessionId: null,
|
||||
backendUrl: '',
|
||||
coordinatorMode: 'auto',
|
||||
phase: null,
|
||||
currentRound: 0,
|
||||
totalRounds: 0,
|
||||
divergeRounds: 0,
|
||||
reportSchema: {},
|
||||
agents: [],
|
||||
agentStates: {},
|
||||
coordinatorUpdates: [],
|
||||
latestDraft: {},
|
||||
sharedMemory: null,
|
||||
backgroundRetrieval: null,
|
||||
finalReport: null,
|
||||
finalReportPartial: {},
|
||||
currentFinalField: null,
|
||||
pendingReview: null,
|
||||
reviewSubmitting: false,
|
||||
reviewRevising: false,
|
||||
errors: [],
|
||||
timeline: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* 规范化运行时智能体配置
|
||||
*
|
||||
* 将模板中的原始 agents 数组转换为运行时格式,
|
||||
* 确保每个 agent 都包含所有必需字段(id、name、dimension、expertise 等)。
|
||||
*
|
||||
* @param {Array} agents - 原始智能体配置数组
|
||||
* @param {Object} reportSchema - 报告结构定义,用于推断 relevant_fields
|
||||
* @returns {Array} 规范化后的智能体数组
|
||||
*/
|
||||
function normalizeRuntimeAgents(agents = [], reportSchema = {}) {
|
||||
const allFields = Object.keys(reportSchema || {});
|
||||
|
||||
return agents.map((agent, index) => {
|
||||
const id = agent.id || `agent_${index + 1}`;
|
||||
const description = agent.description || agent.expertise || agent.dimension || '';
|
||||
const skill = agent.skill || agent.expertise || description;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: agent.name || id,
|
||||
description,
|
||||
skill,
|
||||
dimension: agent.dimension || agent.name || id,
|
||||
scenario_role: agent.scenario_role || agent.expertise || description,
|
||||
relevant_fields: Array.isArray(agent.relevant_fields) && agent.relevant_fields.length > 0
|
||||
? agent.relevant_fields
|
||||
: allFields,
|
||||
analysis_contract: Array.isArray(agent.analysis_contract)
|
||||
? agent.analysis_contract
|
||||
: [
|
||||
'输出自然语言分析,不输出 JSON。',
|
||||
'充分展开材料、依据、反例、分歧和边界条件,最终收缩由协调者完成。',
|
||||
],
|
||||
retrieval_mode: agent.retrieval_mode || 'always',
|
||||
allowed_source_ids: agent.allowed_source_ids || [],
|
||||
retrieval_instruction: agent.retrieval_instruction || '优先检索与本轮任务相关的最新新闻、公开事实和可核验线索。',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建发送给后端的模板对象
|
||||
*/
|
||||
function buildTemplate(config) {
|
||||
return {
|
||||
template_id: config.template_id,
|
||||
template_name: config.template_name || '',
|
||||
template_brief: config.template_brief || '',
|
||||
consistency_mode: config.consistency_mode || 'free',
|
||||
report_schema: config.report_schema || {},
|
||||
report_examples: config.report_examples || {},
|
||||
field_requirements: config.field_requirements || {},
|
||||
coordinator_config: config.coordinator_config || {},
|
||||
agent_adaptation_policy: config.agent_adaptation_policy || {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建发送给后端的完整 session 请求体
|
||||
*/
|
||||
function buildSessionPayload(config) {
|
||||
const reportSchema = config.report_schema || {};
|
||||
const reviewMode = config.coordinator_mode || config.review_mode || 'auto';
|
||||
|
||||
return {
|
||||
task_context: config.task_context || '',
|
||||
template: buildTemplate(config),
|
||||
agents: normalizeRuntimeAgents(config.agents || [], reportSchema),
|
||||
llm_preset_id: config.llm_preset_id || '',
|
||||
phase_config: config.phase_config || { diverge_rounds: 3 },
|
||||
review_mode: reviewMode,
|
||||
retrieval: config.retrieval || config.retrieve,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端 proposal 数据转换为前端 CoordinatorCard 所需格式
|
||||
*
|
||||
* @param {Object} proposal - 后端提案对象
|
||||
* @param {Object} previousDraft - 上一轮的草稿(用于合并增量更新)
|
||||
* @returns {Object} 前端协调者数据
|
||||
*/
|
||||
function proposalToCoordData(proposal, previousDraft = {}) {
|
||||
const draftUpdates = proposal?.draft_updates || {};
|
||||
return {
|
||||
round: proposal?.round_index,
|
||||
divergeRound: proposal?.diverge_round,
|
||||
totalDivergeRounds: proposal?.total_diverge_rounds,
|
||||
nextSpeakers: proposal?.next_speakers || [],
|
||||
focus: proposal?.focus || {},
|
||||
draft: { ...previousDraft, ...draftUpdates },
|
||||
draftUpdates,
|
||||
gaps: proposal?.gaps || [],
|
||||
fieldAssessment: proposal?.field_assessment || {},
|
||||
sharedMemory: proposal?.shared_memory || null,
|
||||
reasoning: proposal?.reasoning || '',
|
||||
proposal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 SSE (Server-Sent Events) 数据行
|
||||
*
|
||||
* 格式: "data: {...}" 或 "data: [DONE]"
|
||||
* @param {string} line - SSE 行文本
|
||||
* @returns {Object|null} 解析后的 JSON 对象,无效行返回 null
|
||||
*/
|
||||
function parseSsePayload(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('data:')) return null;
|
||||
const jsonStr = trimmed.slice(5).trim();
|
||||
if (!jsonStr || jsonStr === '[DONE]') return null;
|
||||
return JSON.parse(jsonStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* useSession hook
|
||||
*
|
||||
* 管理整个分析会话的生命周期:
|
||||
* - startSession: 发送 POST /sessions/start 创建会话,然后连接 SSE 事件流
|
||||
* - stopSession: 中断当前连接
|
||||
* - reset: 重置所有状态
|
||||
* - approveCoordinator / reviseCoordinator: 人工确认 / 修改提案
|
||||
*
|
||||
* SSE 事件类型及处理:
|
||||
* - session_started: 初始化阶段、智能体、轮次
|
||||
* - agent_started: 在 timeline 中插入 agent_contribution(streaming=true)
|
||||
* - agent_token: 追加 token 到对应 agent_contribution 的 content/thinking
|
||||
* - agent_done: 将对应 agent_contribution 的 streaming 标记为 false
|
||||
* - coordinator_started: 插入 coordinator_streaming 卡片
|
||||
* - coordinator_token: 追加 coordinator 的思考过程或内容 token
|
||||
* - coordinator_status: 更新状态横幅
|
||||
* - coordinator_proposal: 替换 coordinator_streaming 为 coordinator 完成卡片
|
||||
* - human_review_required: 切换状态为 waiting_human
|
||||
* - final_report_token: 追加最终报告 token
|
||||
* - final_report_done: 插入 final_report 卡片,状态切换为 done
|
||||
* - error: 追加错误到 errors 数组
|
||||
*
|
||||
* @returns {Object} 会话状态 + 操作方法
|
||||
*/
|
||||
export function useSession() {
|
||||
const [state, setState] = useState(initialState);
|
||||
const abortControllerRef = useRef(null);
|
||||
const timelineRef = useRef([]); // 用 ref 保存 timeline,避免闭包过期
|
||||
const configRef = useRef(null); // 保存启动时的配置引用
|
||||
|
||||
/** 重置所有状态 */
|
||||
const reset = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
timelineRef.current = [];
|
||||
configRef.current = null;
|
||||
setState(initialState);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 从历史记录 JSON 恢复会话展示状态。
|
||||
*
|
||||
* 后端历史接口未来会直接返回保存时的 snapshot;这里把 snapshot 写回 state 和 timelineRef,
|
||||
* 让用户进入页面后无需重新生成,也能直接看到 Timeline、左右侧进度和最终报告。
|
||||
*/
|
||||
const hydrateFromSnapshot = useCallback((snapshot = {}) => {
|
||||
const nextTimeline = Array.isArray(snapshot.timeline) ? snapshot.timeline : [];
|
||||
timelineRef.current = nextTimeline;
|
||||
configRef.current = snapshot.config || null;
|
||||
setState({
|
||||
...initialState,
|
||||
...snapshot,
|
||||
status: snapshot.status || (nextTimeline.length > 0 ? 'done' : 'idle'),
|
||||
phase: snapshot.phase || (nextTimeline.length > 0 ? 'done' : null),
|
||||
timeline: nextTimeline,
|
||||
errors: Array.isArray(snapshot.errors) ? snapshot.errors : [],
|
||||
agentStates: snapshot.agentStates || {},
|
||||
agents: Array.isArray(snapshot.agents) ? snapshot.agents : [],
|
||||
reportSchema: snapshot.reportSchema || snapshot.config?.report_schema || {},
|
||||
finalReportPartial: snapshot.finalReportPartial || snapshot.finalReport?.report || {},
|
||||
pendingReview: null,
|
||||
reviewSubmitting: false,
|
||||
reviewRevising: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** 停止当前会话(中断 SSE 连接) */
|
||||
const stopSession = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
setState(prev => ({ ...prev, status: 'idle' }));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* SSE 事件处理器
|
||||
*
|
||||
* 根据 event 类型分发处理,修改 timelineRef 和 state。
|
||||
* 使用 timelineRef(而非 state.timeline)进行链表式修改,
|
||||
* 避免在快速 token 到来时因 React 批量更新导致丢帧。
|
||||
*/
|
||||
const handleEvent = useCallback((parsed, backendUrl) => {
|
||||
const { event, data } = parsed;
|
||||
if (!event) return;
|
||||
|
||||
switch (event) {
|
||||
// ====== 会话启动 ======
|
||||
case 'session_started': {
|
||||
const agents = data.agents || [];
|
||||
const agentStates = {};
|
||||
agents.forEach(agent => {
|
||||
agentStates[agent.id] = {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
dimension: agent.dimension,
|
||||
status: 'idle',
|
||||
rounds: [],
|
||||
};
|
||||
});
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
sessionId: prev.sessionId,
|
||||
backendUrl,
|
||||
phase: 'starting',
|
||||
agents,
|
||||
agentStates,
|
||||
totalRounds: (configRef.current?.phase_config?.diverge_rounds || 3) + 1,
|
||||
divergeRounds: configRef.current?.phase_config?.diverge_rounds || 3,
|
||||
reportSchema: configRef.current?.report_schema || {},
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 智能体开始输出 ======
|
||||
case 'agent_started': {
|
||||
const agentId = data.agent_id;
|
||||
const round = data.round_index || 0;
|
||||
const phase = data.phase || 'starting';
|
||||
|
||||
// 检查是否已有该轮次的 round_start,没有则插入
|
||||
const hasRoundStart = timelineRef.current.some(
|
||||
item => item.type === 'round_start' && item.round === round && item.phase === phase
|
||||
);
|
||||
|
||||
if (!hasRoundStart) {
|
||||
timelineRef.current = [
|
||||
...timelineRef.current,
|
||||
{ type: 'round_start', round, phase, agentIds: [agentId] },
|
||||
];
|
||||
} else {
|
||||
timelineRef.current = timelineRef.current.map(item =>
|
||||
item.type === 'round_start' && item.round === round && item.phase === phase
|
||||
? { ...item, agentIds: [...new Set([...(item.agentIds || []), agentId])] }
|
||||
: item
|
||||
);
|
||||
}
|
||||
|
||||
// 插入流式 agent_contribution
|
||||
timelineRef.current = [
|
||||
...timelineRef.current,
|
||||
{
|
||||
type: 'agent_contribution',
|
||||
agentId,
|
||||
round,
|
||||
phase,
|
||||
streaming: true,
|
||||
data: {
|
||||
round,
|
||||
phase,
|
||||
content: '',
|
||||
thinking: '',
|
||||
retrievalLog: [],
|
||||
retrievalCount: 0,
|
||||
referenceMap: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
setState(prev => {
|
||||
const updatedAgentStates = { ...prev.agentStates };
|
||||
updatedAgentStates[agentId] = {
|
||||
...(updatedAgentStates[agentId] || { id: agentId, name: data.agent_name, dimension: agentId, rounds: [] }),
|
||||
status: 'thinking',
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
phase,
|
||||
currentRound: Math.max(prev.currentRound, round),
|
||||
agentStates: updatedAgentStates,
|
||||
timeline: timelineRef.current,
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 智能体完成输出 ======
|
||||
case 'agent_done': {
|
||||
const agentId = data.agent_id;
|
||||
const round = data.round_index || 0;
|
||||
const phase = data.phase || 'starting';
|
||||
const retrievalLog = data.retrieval_log || [];
|
||||
const roundData = {
|
||||
round,
|
||||
phase,
|
||||
content: data.content || '',
|
||||
thinking: data.thinking || '',
|
||||
retrievalLog,
|
||||
retrievalCount: retrievalLog.length,
|
||||
referenceMap: data.reference_map || {},
|
||||
metadata: data.metadata || {},
|
||||
};
|
||||
|
||||
// 将对应的流式条目转为完成状态
|
||||
timelineRef.current = timelineRef.current.map(item =>
|
||||
item.type === 'agent_contribution' && item.agentId === agentId && item.round === round && item.streaming
|
||||
? { ...item, streaming: false, data: roundData }
|
||||
: item
|
||||
);
|
||||
|
||||
setState(prev => {
|
||||
const updatedAgentStates = { ...prev.agentStates };
|
||||
const existing = updatedAgentStates[agentId] || {
|
||||
id: agentId,
|
||||
name: data.agent_name,
|
||||
dimension: agentId,
|
||||
status: 'idle',
|
||||
rounds: [],
|
||||
};
|
||||
updatedAgentStates[agentId] = {
|
||||
...existing,
|
||||
name: data.agent_name,
|
||||
status: 'done',
|
||||
rounds: [...(existing.rounds || []), roundData],
|
||||
};
|
||||
return { ...prev, agentStates: updatedAgentStates, timeline: timelineRef.current };
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 智能体 token(流式追加) ======
|
||||
case 'agent_token': {
|
||||
const agentId = data.agent_id;
|
||||
const round = data.round_index || 0;
|
||||
const token = data.token || '';
|
||||
const tokenType = data.token_type || 'content';
|
||||
|
||||
// 找到对应的 streaming agent_contribution 并追加 token
|
||||
timelineRef.current = timelineRef.current.map(item => {
|
||||
if (item.type !== 'agent_contribution' || item.agentId !== agentId || item.round !== round || !item.streaming) {
|
||||
return item;
|
||||
}
|
||||
if (tokenType === 'thinking') {
|
||||
return { ...item, data: { ...item.data, thinking: (item.data.thinking || '') + token } };
|
||||
}
|
||||
return { ...item, data: { ...item.data, content: (item.data.content || '') + token } };
|
||||
});
|
||||
setState(prev => ({ ...prev, timeline: timelineRef.current }));
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 协调员开始流式输出 ======
|
||||
case 'coordinator_started': {
|
||||
timelineRef.current = [
|
||||
...timelineRef.current,
|
||||
{
|
||||
type: 'coordinator_streaming',
|
||||
phase: data.phase || 'diverging',
|
||||
divergeRound: data.diverge_round,
|
||||
totalDivergeRounds: data.total_diverge_rounds,
|
||||
thinking: '',
|
||||
content: '协调者正在评估字段完成度、更新草稿并选择下一轮发言者。',
|
||||
statusMessage: '协调草案生成中…',
|
||||
currentField: null,
|
||||
reportPartial: {},
|
||||
},
|
||||
];
|
||||
setState(prev => ({ ...prev, phase: data.phase || prev.phase, timeline: timelineRef.current }));
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 协调员 token(流式追加) ======
|
||||
case 'coordinator_token': {
|
||||
const token = data.token || '';
|
||||
const tokenType = data.token_type || 'thinking';
|
||||
const phase = data.phase || 'diverging';
|
||||
|
||||
// 找到最后一个 coordinator_streaming 项并追加
|
||||
const lastIdx = timelineRef.current.reduce((last, item, idx) => (
|
||||
item.type === 'coordinator_streaming' ? idx : last
|
||||
), -1);
|
||||
|
||||
if (lastIdx === -1) {
|
||||
// 尚未创建,新建一个
|
||||
timelineRef.current = [
|
||||
...timelineRef.current,
|
||||
{
|
||||
type: 'coordinator_streaming',
|
||||
phase,
|
||||
divergeRound: data.diverge_round,
|
||||
totalDivergeRounds: data.total_diverge_rounds,
|
||||
thinking: tokenType === 'thinking' ? token : '',
|
||||
content: tokenType === 'content' ? token : '',
|
||||
statusMessage: '',
|
||||
currentField: data.field || null,
|
||||
reportPartial: {},
|
||||
},
|
||||
];
|
||||
} else {
|
||||
timelineRef.current = timelineRef.current.map((item, idx) => {
|
||||
if (idx !== lastIdx || item.type !== 'coordinator_streaming') return item;
|
||||
return {
|
||||
...item,
|
||||
currentField: data.field || item.currentField,
|
||||
thinking: tokenType === 'thinking' ? (item.thinking || '') + token : item.thinking,
|
||||
content: tokenType === 'content' ? (item.content || '') + token : item.content,
|
||||
statusMessage: '',
|
||||
};
|
||||
});
|
||||
}
|
||||
setState(prev => ({ ...prev, timeline: timelineRef.current }));
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 协调员状态更新 ======
|
||||
case 'coordinator_status': {
|
||||
const message = data.message || '';
|
||||
const phase = data.phase || 'diverging';
|
||||
|
||||
const lastIdx = timelineRef.current.reduce((last, item, idx) => (
|
||||
item.type === 'coordinator_streaming' ? idx : last
|
||||
), -1);
|
||||
|
||||
if (lastIdx === -1) {
|
||||
timelineRef.current = [
|
||||
...timelineRef.current,
|
||||
{
|
||||
type: 'coordinator_streaming',
|
||||
phase,
|
||||
divergeRound: data.diverge_round,
|
||||
totalDivergeRounds: data.total_diverge_rounds,
|
||||
thinking: '',
|
||||
content: '',
|
||||
statusMessage: message,
|
||||
currentField: null,
|
||||
reportPartial: {},
|
||||
},
|
||||
];
|
||||
} else {
|
||||
timelineRef.current = timelineRef.current.map((item, idx) => {
|
||||
if (idx !== lastIdx || item.type !== 'coordinator_streaming') return item;
|
||||
return { ...item, statusMessage: message };
|
||||
});
|
||||
}
|
||||
|
||||
setState(prev => ({ ...prev, timeline: timelineRef.current }));
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 协调员提案完成 ======
|
||||
case 'coordinator_proposal': {
|
||||
setState(prev => {
|
||||
const coordData = proposalToCoordData(data.proposal, prev.latestDraft);
|
||||
let replaced = false;
|
||||
|
||||
// 将最后一个 coordinator_streaming 替换为 coordinator 完成卡片
|
||||
timelineRef.current = timelineRef.current.map((item, idx) => {
|
||||
if (item.type !== 'coordinator_streaming' || replaced) return item;
|
||||
const lastIdx = timelineRef.current.reduce((last, it, i) => it.type === 'coordinator_streaming' ? i : last, -1);
|
||||
if (idx !== lastIdx) return item;
|
||||
replaced = true;
|
||||
return {
|
||||
type: 'coordinator',
|
||||
data: coordData,
|
||||
thinking: coordData.reasoning,
|
||||
};
|
||||
});
|
||||
|
||||
if (!replaced) {
|
||||
timelineRef.current = [...timelineRef.current, { type: 'coordinator', data: coordData, thinking: coordData.reasoning }];
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
status: data.revised ? 'running' : prev.status,
|
||||
coordinatorUpdates: [...prev.coordinatorUpdates, coordData],
|
||||
latestDraft: coordData.draft,
|
||||
sharedMemory: coordData.sharedMemory,
|
||||
pendingReview: data.revised ? null : prev.pendingReview,
|
||||
reviewRevising: false,
|
||||
timeline: timelineRef.current,
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 需要人工确认(仅 human_review 模式) ======
|
||||
case 'human_review_required': {
|
||||
setState(prev => {
|
||||
const coordData = proposalToCoordData(data.proposal, prev.latestDraft);
|
||||
return {
|
||||
...prev,
|
||||
status: 'waiting_human',
|
||||
pendingReview: {
|
||||
...coordData,
|
||||
proposal: data.proposal,
|
||||
},
|
||||
sharedMemory: coordData.sharedMemory,
|
||||
reviewSubmitting: false,
|
||||
reviewRevising: false,
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 最终报告生成完成 ======
|
||||
case 'final_report_done': {
|
||||
const output = {
|
||||
report: data.report || {},
|
||||
dimensions: data.dimensions || {},
|
||||
meta: data.metadata || {},
|
||||
};
|
||||
timelineRef.current = [...timelineRef.current, { type: 'final_report', data: output }];
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: 'done',
|
||||
finalReport: output,
|
||||
finalReportPartial: output.report || {},
|
||||
currentFinalField: null,
|
||||
phase: 'done',
|
||||
pendingReview: null,
|
||||
reviewSubmitting: false,
|
||||
reviewRevising: false,
|
||||
timeline: timelineRef.current,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 最终报告 token(流式追加) ======
|
||||
case 'final_report_token': {
|
||||
const token = data.token || '';
|
||||
const field = data.field || null;
|
||||
const reportPartial = data.report_partial || {};
|
||||
|
||||
const lastIdx = timelineRef.current.reduce((last, item, idx) => (
|
||||
item.type === 'coordinator_streaming' ? idx : last
|
||||
), -1);
|
||||
|
||||
if (lastIdx === -1) {
|
||||
timelineRef.current = [
|
||||
...timelineRef.current,
|
||||
{
|
||||
type: 'coordinator_streaming',
|
||||
phase: 'final_report',
|
||||
thinking: '',
|
||||
content: token,
|
||||
statusMessage: '',
|
||||
currentField: field,
|
||||
reportPartial,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
timelineRef.current = timelineRef.current.map((item, idx) => {
|
||||
if (idx !== lastIdx || item.type !== 'coordinator_streaming') return item;
|
||||
const fieldChanged = field && field !== item.currentField;
|
||||
return {
|
||||
...item,
|
||||
phase: 'final_report',
|
||||
content: fieldChanged ? token : (item.content || '') + token,
|
||||
currentField: field || item.currentField,
|
||||
reportPartial,
|
||||
};
|
||||
});
|
||||
}
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
finalReportPartial: reportPartial,
|
||||
currentFinalField: field,
|
||||
timeline: timelineRef.current,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 错误 ======
|
||||
case 'error': {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: prev.status === 'waiting_human' ? prev.status : 'error',
|
||||
errors: [...prev.errors, { error: data.message || data.error || JSON.stringify(data), ...data }],
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 读取 SSE 事件流
|
||||
*
|
||||
* 使用 fetch + ReadableStream reader 逐行解析,
|
||||
* 支持 AbortController 中断。
|
||||
*
|
||||
* @param {string} url - SSE 端点 URL
|
||||
* @param {string} backendUrl - 后端基础地址
|
||||
* @param {AbortController} abortController - 中断控制器
|
||||
*/
|
||||
const readEventStream = useCallback(async (url, backendUrl, abortController) => {
|
||||
const response = await fetch(url, { signal: abortController.signal });
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done || abortController.signal.aborted) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = parseSsePayload(line);
|
||||
if (parsed) handleEvent(parsed, backendUrl);
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse SSE line:', line, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [handleEvent]);
|
||||
|
||||
/**
|
||||
* 启动分析会话
|
||||
*
|
||||
* 1. 发送 POST /sessions/start 创建会话
|
||||
* 2. 获取 session_id
|
||||
* 3. 通过 GET /sessions/{id}/events 订阅 SSE 事件流
|
||||
*
|
||||
* @param {Object} config - 页面配置(任务背景、模板、智能体等)
|
||||
* @param {string} backendUrl - 后端 API 地址
|
||||
*/
|
||||
const startSession = useCallback(async (config, backendUrl) => {
|
||||
timelineRef.current = [];
|
||||
configRef.current = config;
|
||||
setState({
|
||||
...initialState,
|
||||
status: 'running',
|
||||
backendUrl,
|
||||
coordinatorMode: config.coordinator_mode || 'auto',
|
||||
reportSchema: config.report_schema || {},
|
||||
});
|
||||
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
const payload = buildSessionPayload(config);
|
||||
const response = await fetch(`${backendUrl}/sessions/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const sessionId = result.session_id;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
sessionId,
|
||||
status: result.status === 'created' ? 'running' : result.status,
|
||||
}));
|
||||
|
||||
await readEventStream(`${backendUrl}/sessions/${sessionId}/events`, backendUrl, abortController);
|
||||
|
||||
// SSE 流结束后,设置最终状态(除非已是 error/waiting_human/done)
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: prev.status === 'error' || prev.status === 'waiting_human' || prev.status === 'done'
|
||||
? prev.status
|
||||
: 'done',
|
||||
}));
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error('Session error:', err);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: 'error',
|
||||
errors: [...prev.errors, { error: err.message }],
|
||||
}));
|
||||
}
|
||||
}, [readEventStream]);
|
||||
|
||||
/**
|
||||
* 人工确认协调者提案
|
||||
*
|
||||
* 在 human_review 模式下,用户修改提案内容后提交确认。
|
||||
* 发送 POST /sessions/{id}/coordinator/approve。
|
||||
*
|
||||
* @param {Object} payload - { nextSpeakers, focus, draft, gaps }
|
||||
*/
|
||||
const approveCoordinator = useCallback(async (payload) => {
|
||||
if (!state.sessionId || !state.backendUrl) {
|
||||
throw new Error('session 未初始化');
|
||||
}
|
||||
|
||||
const baseProposal = state.pendingReview?.proposal || {};
|
||||
const proposal = {
|
||||
...baseProposal,
|
||||
next_speakers: payload.nextSpeakers,
|
||||
focus: payload.focus,
|
||||
draft_updates: payload.draft,
|
||||
gaps: payload.gaps,
|
||||
};
|
||||
|
||||
setState(prev => ({ ...prev, reviewSubmitting: true }));
|
||||
try {
|
||||
const response = await fetch(`${state.backendUrl}/sessions/${state.sessionId}/coordinator/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ proposal }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const msg = await response.text();
|
||||
throw new Error(msg || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: 'running',
|
||||
pendingReview: null,
|
||||
reviewSubmitting: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('approve error:', err);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
reviewSubmitting: false,
|
||||
errors: [...prev.errors, { error: err.message }],
|
||||
}));
|
||||
}
|
||||
}, [state.sessionId, state.backendUrl, state.pendingReview]);
|
||||
|
||||
/**
|
||||
* 发送修改指令,让协调员重新生成提案
|
||||
*
|
||||
* @param {string} instruction - 用户的修改指令(自然语言)
|
||||
*/
|
||||
const reviseCoordinator = useCallback(async (instruction) => {
|
||||
if (!state.sessionId || !state.backendUrl) {
|
||||
throw new Error('session 未初始化');
|
||||
}
|
||||
|
||||
setState(prev => ({ ...prev, reviewRevising: true }));
|
||||
try {
|
||||
const response = await fetch(`${state.backendUrl}/sessions/${state.sessionId}/coordinator/revise`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ instruction }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const msg = await response.text();
|
||||
throw new Error(msg || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// 后端会在修订完成后通过 SSE 推送新的 coordinator_proposal
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
reviewRevising: false,
|
||||
pendingReview: null,
|
||||
status: 'running',
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('revise error:', err);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
reviewRevising: false,
|
||||
errors: [...prev.errors, { error: err.message }],
|
||||
}));
|
||||
}
|
||||
}, [state.sessionId, state.backendUrl]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
hydrateFromSnapshot,
|
||||
reset,
|
||||
startSession,
|
||||
stopSession,
|
||||
approveCoordinator,
|
||||
reviseCoordinator,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export function useToggle(initialValue: boolean = false): [boolean, () => void, (value: boolean) => void] {
|
||||
const [value, setValue] = useState<boolean>(initialValue);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setValue((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const setToggle = useCallback((newValue: boolean) => {
|
||||
setValue(newValue);
|
||||
}, []);
|
||||
|
||||
return [value, toggle, setToggle];
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface WindowSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function useWindowSize() {
|
||||
const [windowSize, setWindowSize] = useState<WindowSize>({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
setWindowSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [handleResize]);
|
||||
|
||||
return windowSize;
|
||||
}
|
||||
|
||||
export function useIsMobile() {
|
||||
const { width } = useWindowSize();
|
||||
return width < 768;
|
||||
}
|
||||
@@ -1,11 +1,30 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
|
||||
|
||||
import QA from "@/strategy-components/components/qa/QA";
|
||||
import GeneralQA from "@/strategy-components/components/qa/GeneralQA";
|
||||
import AgentQA from "@/strategy-components/components/qa/AgentQA";
|
||||
import CollaborativeQA from "@/strategy-components/components/qa/CollaborativeQA";
|
||||
import MopeChat from "@/strategy-components/components/qa/MopeChat";
|
||||
import TemplateEditor from "@/strategy-components/components/qa/TemplateEditor";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
import ChatsPage from "./ChatsPage";
|
||||
import ChatPage from "./ChatPage";
|
||||
import NewAgentPage from "./NewAgentPage";
|
||||
import AgentChatPage from "./AgentChatPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
|
||||
function ChatRuntime({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SubtasksProvider>
|
||||
<ArtifactsProvider>
|
||||
<PromptInputProvider>{children}</PromptInputProvider>
|
||||
</ArtifactsProvider>
|
||||
</SubtasksProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StrategyRoutes() {
|
||||
return (
|
||||
@@ -14,15 +33,57 @@ export default function StrategyRoutes() {
|
||||
<Route index element={<Navigate to="qa/general" replace />} />
|
||||
|
||||
{/* 问答中心 */}
|
||||
<Route path="qa" element={<Navigate to="qa/general" replace />} />
|
||||
<Route path="qa/new" element={<QA initialMode="new" />} />
|
||||
<Route path="qa/general" element={<GeneralQA />} />
|
||||
<Route path="qa/agent" element={<AgentQA />} />
|
||||
<Route path="qa/multi-agent" element={<CollaborativeQA />} />
|
||||
<Route path="qa/mope" element={<MopeChat />} />
|
||||
<Route path="qa/template" element={<TemplateEditor />} />
|
||||
|
||||
<Route path="*" element={<Navigate to="qa/general" replace />} />
|
||||
{/* 对话 */}
|
||||
<Route path="chats" element={<ChatsPage />} />
|
||||
<Route
|
||||
path="chats/:thread_id"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<ChatPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 智能体管理 */}
|
||||
<Route path="agents" element={<AgentsPage />} />
|
||||
<Route path="agents/new" element={<NewAgentPage />} />
|
||||
<Route
|
||||
path="agents/:agent_id/chats"
|
||||
element={<Navigate to="/page/strategy/agents" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="agents/:agent_id/chats/:thread_id"
|
||||
element={
|
||||
<ChatRuntime>
|
||||
<AgentChatPage />
|
||||
</ChatRuntime>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 定时任务 */}
|
||||
<Route
|
||||
path="scheduled"
|
||||
element={<Navigate to="/page/strategy/scheduled/tasks" replace />}
|
||||
/>
|
||||
<Route path="scheduled/tasks" element={<ScheduledTasksPage />} />
|
||||
<Route
|
||||
path="scheduled/runs/:run_id"
|
||||
element={<ScheduledTaskRunDetailPage />}
|
||||
/>
|
||||
|
||||
{/* 设置 */}
|
||||
<Route
|
||||
path="settings/recommended-questions"
|
||||
element={
|
||||
getStoredAuth()?.system_role === "admin" ? (
|
||||
<RecommendedQuestionsPage />
|
||||
) : (
|
||||
<Navigate to="/page/strategy/chats" replace />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -20,7 +20,7 @@ import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
|
||||
function WorkspaceLayout({
|
||||
export function WorkspaceLayout({
|
||||
children,
|
||||
pageSidebarCollapsed,
|
||||
pageSidebarHidden = false,
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import React, { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import bash from 'react-syntax-highlighter/dist/esm/languages/prism/bash';
|
||||
import css from 'react-syntax-highlighter/dist/esm/languages/prism/css';
|
||||
import java from 'react-syntax-highlighter/dist/esm/languages/prism/java';
|
||||
import javascript from 'react-syntax-highlighter/dist/esm/languages/prism/javascript';
|
||||
import json from 'react-syntax-highlighter/dist/esm/languages/prism/json';
|
||||
import jsx from 'react-syntax-highlighter/dist/esm/languages/prism/jsx';
|
||||
import markup from 'react-syntax-highlighter/dist/esm/languages/prism/markup';
|
||||
import sql from 'react-syntax-highlighter/dist/esm/languages/prism/sql';
|
||||
import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
|
||||
import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
|
||||
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
onCitationClick?: (index: number) => void;
|
||||
}
|
||||
|
||||
SyntaxHighlighter.registerLanguage('bash', bash);
|
||||
SyntaxHighlighter.registerLanguage('css', css);
|
||||
SyntaxHighlighter.registerLanguage('html', markup);
|
||||
SyntaxHighlighter.registerLanguage('java', java);
|
||||
SyntaxHighlighter.registerLanguage('javascript', javascript);
|
||||
SyntaxHighlighter.registerLanguage('json', json);
|
||||
SyntaxHighlighter.registerLanguage('jsx', jsx);
|
||||
SyntaxHighlighter.registerLanguage('markup', markup);
|
||||
SyntaxHighlighter.registerLanguage('sql', sql);
|
||||
SyntaxHighlighter.registerLanguage('tsx', tsx);
|
||||
SyntaxHighlighter.registerLanguage('typescript', typescript);
|
||||
SyntaxHighlighter.registerLanguage('vue', markup);
|
||||
|
||||
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 getLanguageFromClassName = (className = '') => (
|
||||
className.match(/language-([\w-]+)/)?.[1]?.toLowerCase() || 'text'
|
||||
);
|
||||
|
||||
const languageLabels: Record<string, string> = {
|
||||
js: 'javascript',
|
||||
jsx: 'jsx',
|
||||
ts: 'typescript',
|
||||
tsx: 'tsx',
|
||||
vue: 'vue',
|
||||
java: 'java',
|
||||
py: 'python',
|
||||
sh: 'shell',
|
||||
bash: 'bash',
|
||||
json: 'json',
|
||||
css: 'css',
|
||||
html: 'html',
|
||||
xml: 'xml',
|
||||
sql: 'sql',
|
||||
text: 'text',
|
||||
};
|
||||
|
||||
const InlineCode: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className = '' }) => (
|
||||
<code
|
||||
className={`rounded-md border border-gray-200 bg-gray-100 px-1.5 py-0.5 font-mono text-[0.92em] text-rose-700 dark:border-slate-700 dark:bg-slate-800 dark:text-cyan-200 ${className}`}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
|
||||
const CodeBlock: React.FC<{ children: React.ReactNode; className?: string }> = ({ children, className = '' }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const codeText = extractTextFromNode(children).replace(/\n$/, '');
|
||||
const language = getLanguageFromClassName(className);
|
||||
const languageLabel = languageLabels[language] || language;
|
||||
|
||||
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-slate-200 bg-[#282c34] shadow-sm dark:border-slate-700">
|
||||
<div className="flex items-center justify-between border-b border-white/10 bg-black/20 px-3 py-2">
|
||||
<span className="font-mono text-xs font-semibold uppercase tracking-wide text-slate-300">
|
||||
{languageLabel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-white/10 bg-white/10 px-2.5 py-1.5 text-xs font-medium text-slate-200 transition-colors hover:bg-white/15"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? '已复制' : '复制'}
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={oneDark}
|
||||
PreTag="div"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '16px',
|
||||
background: 'transparent',
|
||||
fontSize: '13px',
|
||||
lineHeight: 1.7,
|
||||
}}
|
||||
codeTagProps={{
|
||||
style: {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{codeText}
|
||||
</SyntaxHighlighter>
|
||||
</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="mb-4 text-2xl font-bold" {...props} />,
|
||||
h2: ({ node, ...props }) => <h2 className="mb-3 text-xl font-bold" {...props} />,
|
||||
h3: ({ node, ...props }) => <h3 className="mb-2 text-lg font-bold" {...props} />,
|
||||
h4: ({ node, ...props }) => <h4 className="mb-2 text-base font-bold" {...props} />,
|
||||
h5: ({ node, ...props }) => <h5 className="mb-2 text-sm font-bold" {...props} />,
|
||||
h6: ({ node, ...props }) => <h6 className="mb-2 text-xs font-bold" {...props} />,
|
||||
p: ({ node, ...props }) => <p className="mb-4 leading-relaxed" {...props} />,
|
||||
ul: ({ node, ...props }) => <ul className="mb-4 ml-4 list-inside list-disc" {...props} />,
|
||||
ol: ({ node, ...props }) => <ol className="mb-4 ml-4 list-inside list-decimal" {...props} />,
|
||||
li: ({ node, ...props }) => <li className="mb-1" {...props} />,
|
||||
code: ({ node, inline, className, children, ...props }: any) => {
|
||||
const codeText = extractTextFromNode(children).trim();
|
||||
const isShortSingleLineCode = !codeText.includes('\n') && codeText.length <= 80;
|
||||
|
||||
if (inline || isShortSingleLineCode) {
|
||||
return (
|
||||
<InlineCode className={className || ''} {...props}>
|
||||
{children}
|
||||
</InlineCode>
|
||||
);
|
||||
}
|
||||
|
||||
return <CodeBlock className={className}>{children}</CodeBlock>;
|
||||
},
|
||||
pre: ({ node, children }) => <>{children}</>,
|
||||
blockquote: ({ node, ...props }) => (
|
||||
<blockquote className="my-4 border-l-4 border-gray-300 pl-4 italic dark:border-gray-600" {...props} />
|
||||
),
|
||||
a: ({ node, ...props }) => (
|
||||
<a className="text-blue-600 hover:underline dark:text-blue-400" {...props} />
|
||||
),
|
||||
table: ({ node, ...props }) => (
|
||||
<div className="mb-4 overflow-x-auto">
|
||||
<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;
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { useChatContext } from '../../contexts/ChatContext';
|
||||
import { useChatContext } from '../../../contexts/ChatContext';
|
||||
import QATypeSwitch from './QATypeSwitch';
|
||||
|
||||
type QAMode = 'single-round' | 'multi-round' | 'deep-search';
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import MarkdownRenderer from '../MarkdownRenderer';
|
||||
import MarkdownRenderer from '../../MarkdownRenderer';
|
||||
|
||||
/**
|
||||
* 单条检索结果。
|
||||
|
||||
@@ -1,469 +0,0 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
clearTemplateOverride,
|
||||
clone,
|
||||
hasTemplateOverride,
|
||||
loadOriginalTemplate,
|
||||
loadTemplateByMeta,
|
||||
loadTemplateIndex,
|
||||
saveTemplateOverride,
|
||||
} from '@/config/templateStore';
|
||||
import './ScenarioConfigPage.css';
|
||||
|
||||
/**
|
||||
* 协调者配置字段定义
|
||||
* 每个字段包含 [key, 中文标签],对应 coordinator_config 对象中的属性
|
||||
*/
|
||||
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', '人工修改解释规则'],
|
||||
];
|
||||
|
||||
/**
|
||||
* 不可变更新数组中的指定索引项
|
||||
* @param {Array} items - 原数组
|
||||
* @param {number} index - 目标索引
|
||||
* @param {Function} updater - 接收克隆后的项,就地修改
|
||||
* @returns {Array} 新数组
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 场景配置页面
|
||||
*
|
||||
* 用于查看和编辑模板中的提示资产(Prompt Assets):
|
||||
* - 模板信息:ID、名称、说明、一致性模式
|
||||
* - 协调者提示:各阶段的协调者策略文本
|
||||
* - 推荐智能体:每个智能体的名称、维度、专家提示、分析契约、关联字段等
|
||||
* - 完整模板 JSON:原始 JSON 的直接编辑
|
||||
*
|
||||
* 修改后的模板通过 localStorage 覆盖保存('mope_template_override:' 前缀),
|
||||
* 不影响后端静态文件。可在首页选择"已修改"标记的模板使用。
|
||||
*
|
||||
* 暂不支持新增模板和新增智能体。
|
||||
*
|
||||
* @param {Function} onBack - 返回首页回调
|
||||
*/
|
||||
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],
|
||||
);
|
||||
|
||||
/** 从 report_schema 和 field_requirements 生成字段选项列表 */
|
||||
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]);
|
||||
|
||||
/**
|
||||
* 同步更新 draft 状态
|
||||
* 深拷贝后通过 updater 修改,并同步更新 rawJson 预览
|
||||
*/
|
||||
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,
|
||||
}));
|
||||
});
|
||||
};
|
||||
|
||||
/** 从 rawJson 文本框解析并应用 JSON */
|
||||
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 解析失败');
|
||||
}
|
||||
};
|
||||
|
||||
/** 保存当前修改到 localStorage */
|
||||
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">
|
||||
{/* 智能体列表(Tab 切换) */}
|
||||
<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>
|
||||
|
||||
{/* ====== 完整模板 JSON ====== */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user