路由调整

This commit is contained in:
fengxiangdebaozi committed 2026-05-14 17:24:45 +08:00
1 parent 7a7c99f752
commit 543d78e040
21 files changed
+1869 -490

No files matched your search

+101 -3
View File
@@ -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",
+2
View File
@@ -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",
+13 -3
View File
@@ -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`} />
+158
View File
@@ -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"],
},
}
};
+96
View File
@@ -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}`);
}
+9
View File
@@ -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';
+19
View File
@@ -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
}
+37
View File
@@ -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,
};
}
+80
View File
@@ -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,
};
}
+40
View File
@@ -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,
};
}
+25
View File
@@ -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;
}
+852
View File
@@ -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,
};
}
+15
View File
@@ -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];
}
+32
View File
@@ -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;
}
+73 -12
View File
@@ -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>
);
+1 -1
View File
@@ -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>
);
}