This commit is contained in:
xuhd committed 2026-05-14 20:45:02 +08:00
commit 9a67ca7c03
97 files changed
+20759 -306

No files matched your search

+3
View File
@@ -74,3 +74,6 @@ offline-backend-20260512/backend/.deer-flow/*
# Per-user skill_evolution overrides — runtime data written by the API # Per-user skill_evolution overrides — runtime data written by the API
offline-backend-20260512/backend/skill_evolution.json offline-backend-20260512/backend/skill_evolution.json
skills/
hermes-agent-main/
.gitignore
+318 -5
View File
@@ -61,8 +61,10 @@
"ogl": "^1.0.11", "ogl": "^1.0.11",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.4.1", "react-resizable-panels": "^4.4.1",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.9.4",
"react-syntax-highlighter": "^16.1.1",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
@@ -71,6 +73,7 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"streamdown": "1.4.0", "streamdown": "1.4.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tdesign-react": "^1.12.3",
"tokenlens": "^1.3.1", "tokenlens": "^1.3.1",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"use-stick-to-bottom": "^1.1.1", "use-stick-to-bottom": "^1.1.1",
@@ -1665,6 +1668,15 @@
"node": ">=8.0.0" "node": ">=8.0.0"
} }
}, },
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@radix-ui/number": { "node_modules/@radix-ui/number": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -4615,6 +4627,11 @@
"undici-types": "~6.21.0" "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": { "node_modules/@types/react": {
"version": "19.2.13", "version": "19.2.13",
"license": "MIT", "license": "MIT",
@@ -4630,6 +4647,16 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/sortablejs": {
"version": "1.15.9",
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz",
"integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ=="
},
"node_modules/@types/tinycolor2": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz",
"integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw=="
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -4641,6 +4668,11 @@
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
}, },
"node_modules/@types/validator": {
"version": "13.15.10",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA=="
},
"node_modules/@uiw/codemirror-extensions-basic-setup": { "node_modules/@uiw/codemirror-extensions-basic-setup": {
"version": "4.25.4", "version": "4.25.4",
"resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.4.tgz", "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.4.tgz",
@@ -5091,6 +5123,11 @@
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==" "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="
}, },
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"license": "MIT", "license": "MIT",
@@ -5773,6 +5810,15 @@
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz",
"integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==" "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="
}, },
"node_modules/dom-helpers": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
"dependencies": {
"@babel/runtime": "^7.8.7",
"csstype": "^3.0.2"
}
},
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.4.2", "version": "3.4.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
@@ -5940,6 +5986,18 @@
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" "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": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -5957,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": { "node_modules/framer-motion": {
"version": "12.38.0", "version": "12.38.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
@@ -6264,6 +6330,32 @@
"url": "https://opencollective.com/unified" "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",
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
"dependencies": {
"react-is": "^16.7.0"
}
},
"node_modules/hoist-non-react-statics/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/html-url-attributes": { "node_modules/html-url-attributes": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -6403,8 +6495,7 @@
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
"dev": true
}, },
"node_modules/jsesc": { "node_modules/jsesc": {
"version": "3.1.0", "version": "3.1.0",
@@ -6790,6 +6881,30 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"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": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -6801,7 +6916,8 @@
}, },
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "0.562.0", "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": { "peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
@@ -7706,6 +7822,11 @@
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
}, },
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
},
"node_modules/mlly": { "node_modules/mlly": {
"version": "1.8.2", "version": "1.8.2",
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
@@ -7797,6 +7918,14 @@
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
"dev": true "dev": true
}, },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ogl": { "node_modules/ogl": {
"version": "1.0.11", "version": "1.0.11",
"license": "Unlicense" "license": "Unlicense"
@@ -7918,6 +8047,11 @@
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
}, },
"node_modules/performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -8005,11 +8139,34 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "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": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
}, },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dependencies": {
"loose-envify": "^1.4.0",
"object-assign": "^4.1.1",
"react-is": "^16.13.1"
}
},
"node_modules/prop-types/node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/property-information": { "node_modules/property-information": {
"version": "7.1.0", "version": "7.1.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
@@ -8019,6 +8176,14 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/raf": {
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
"dependencies": {
"performance-now": "^2.1.0"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.4", "version": "19.2.4",
"license": "MIT", "license": "MIT",
@@ -8036,6 +8201,16 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-fast-compare": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
},
"node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
},
"node_modules/react-markdown": { "node_modules/react-markdown": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
@@ -8180,6 +8355,40 @@
} }
} }
}, },
"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",
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
"dependencies": {
"@babel/runtime": "^7.5.5",
"dom-helpers": "^5.0.1",
"loose-envify": "^1.4.0",
"prop-types": "^15.6.2"
},
"peerDependencies": {
"react": ">=16.6.0",
"react-dom": ">=16.6.0"
}
},
"node_modules/readable-stream": { "node_modules/readable-stream": {
"version": "2.3.8", "version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
@@ -8194,6 +8403,26 @@
"util-deprecate": "~1.0.1" "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",
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
"node_modules/regex": { "node_modules/regex": {
"version": "6.1.0", "version": "6.1.0",
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
@@ -8242,7 +8471,8 @@
}, },
"node_modules/rehype-raw": { "node_modules/rehype-raw": {
"version": "7.0.0", "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": { "dependencies": {
"@types/hast": "^3.0.0", "@types/hast": "^3.0.0",
"hast-util-raw": "^9.0.0", "hast-util-raw": "^9.0.0",
@@ -8255,7 +8485,8 @@
}, },
"node_modules/remark-gfm": { "node_modules/remark-gfm": {
"version": "4.0.1", "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": { "dependencies": {
"@types/mdast": "^4.0.0", "@types/mdast": "^4.0.0",
"mdast-util-gfm": "^3.0.0", "mdast-util-gfm": "^3.0.0",
@@ -8457,6 +8688,11 @@
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
} }
}, },
"node_modules/sortablejs": {
"version": "1.15.7",
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A=="
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -8578,6 +8814,75 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/tdesign-icons-react": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/tdesign-icons-react/-/tdesign-icons-react-0.5.0.tgz",
"integrity": "sha512-Gpl1Kxkb8+YXYjMsSW5W3kWy/drVB/huFLIHWhmJdUnEwUAW0Il+nDyb/BsRi51jLnalBwjYbOELEggH2qp6FQ==",
"dependencies": {
"@babel/runtime": "^7.16.5",
"classnames": "^2.2.6"
},
"peerDependencies": {
"react": ">=16.13.1",
"react-dom": ">=16.13.1"
}
},
"node_modules/tdesign-react": {
"version": "1.12.3",
"resolved": "https://registry.npmjs.org/tdesign-react/-/tdesign-react-1.12.3.tgz",
"integrity": "sha512-zpGPUrxoGvK3ZMFIAM1GmFI2LEYzPfQSALK0egR/RSGuVaQmNSqgonGATz0N1/quAqlZjdRqVH5w6IOJ5ruyKA==",
"dependencies": {
"@babel/runtime": "~7.26.7",
"@popperjs/core": "~2.11.2",
"@types/sortablejs": "^1.10.7",
"@types/tinycolor2": "^1.4.3",
"@types/validator": "^13.1.3",
"classnames": "~2.5.1",
"dayjs": "1.11.10",
"hoist-non-react-statics": "~3.3.2",
"lodash-es": "^4.17.21",
"mitt": "^3.0.0",
"raf": "~3.4.1",
"react-fast-compare": "^3.2.2",
"react-is": "^18.2.0",
"react-transition-group": "~4.4.1",
"sortablejs": "^1.15.0",
"tdesign-icons-react": "0.5.0",
"tinycolor2": "^1.4.2",
"tslib": "~2.3.1",
"validator": "~13.15.0"
},
"peerDependencies": {
"react": ">=16.13.1",
"react-dom": ">=16.13.1"
}
},
"node_modules/tdesign-react/node_modules/@babel/runtime": {
"version": "7.26.10",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
"dependencies": {
"regenerator-runtime": "^0.14.0"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/tdesign-react/node_modules/dayjs": {
"version": "1.11.10",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
"integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
},
"node_modules/tdesign-react/node_modules/tslib": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
},
"node_modules/tinycolor2": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="
},
"node_modules/tinyexec": { "node_modules/tinyexec": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
@@ -8882,6 +9187,14 @@
"uuid": "dist-node/bin/uuid" "uuid": "dist-node/bin/uuid"
} }
}, },
"node_modules/validator": {
"version": "13.15.35",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
"integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vfile": { "node_modules/vfile": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+3
View File
@@ -63,8 +63,10 @@
"ogl": "^1.0.11", "ogl": "^1.0.11",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.4.1", "react-resizable-panels": "^4.4.1",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.9.4",
"react-syntax-highlighter": "^16.1.1",
"rehype-katex": "^7.0.1", "rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
@@ -73,6 +75,7 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"streamdown": "1.4.0", "streamdown": "1.4.0",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tdesign-react": "^1.12.3",
"tokenlens": "^1.3.1", "tokenlens": "^1.3.1",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"use-stick-to-bottom": "^1.1.1", "use-stick-to-bottom": "^1.1.1",
+7 -1
View File
@@ -5,6 +5,8 @@ import { ThemeProvider as NextThemesProvider } from "next-themes";
import { QueryClientProvider } from "@/components/query-client-provider"; import { QueryClientProvider } from "@/components/query-client-provider";
import { I18nProvider } from "@/core/i18n/context"; import { I18nProvider } from "@/core/i18n/context";
import { detectLocale } from "@/core/i18n/locale"; import { detectLocale } from "@/core/i18n/locale";
import { ChatProvider } from "@/contexts/ChatContext";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { HashRouter, useLocation } from "react-router-dom"; import { HashRouter, useLocation } from "react-router-dom";
@@ -26,7 +28,11 @@ function AppProviders({ children }: { children: React.ReactNode }) {
forcedTheme={forcedTheme} forcedTheme={forcedTheme}
> >
<I18nProvider initialLocale={locale}> <I18nProvider initialLocale={locale}>
<QueryClientProvider>{children}</QueryClientProvider> <ThemeProvider>
<ChatProvider>
<QueryClientProvider>{children}</QueryClientProvider>
</ChatProvider>
</ThemeProvider>
</I18nProvider> </I18nProvider>
</NextThemesProvider> </NextThemesProvider>
); );
+86 -76
View File
@@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import type { ElementType } from "react"; import type { ElementType } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { import {
BarChart3, BarChart3,
Bot, Bot,
@@ -38,6 +39,7 @@ type MenuItem = {
id: string; id: string;
label: string; label: string;
icon: ElementType; icon: ElementType;
path?: string;
children?: MenuItem[]; children?: MenuItem[];
}; };
@@ -49,173 +51,173 @@ type PageSidebarProps = {
const menuItems: MenuItem[] = [ const menuItems: MenuItem[] = [
{ {
id: "qa-center", id: "qa-center",
label: "QA Center", label: "问答中心",
icon: HelpCircle, icon: HelpCircle,
children: [ children: [
{ id: "new-qa", label: "New QA", icon: Plus }, { id: "new-qa", label: "新建问答", icon: Plus },
{ id: "general-qa", label: "General QA", icon: CircleHelp }, { id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/strategy/qa/general" },
{ id: "agent-qa", label: "Agent QA", icon: Bot }, { id: "agent-qa", label: "智能体问答", icon: Bot },
{ id: "multi-agent-qa", label: "Multi Agent", icon: Layers }, { id: "multi-agent-qa", label: "多智能体", icon: Layers },
{ id: "sixstep-chat", label: "Six Step Chat", icon: Workflow }, { id: "sixstep-chat", label: "六步对话", icon: Workflow },
{ id: "qa-records", label: "QA Records", icon: History }, { id: "qa-records", label: "问答记录", icon: History },
{ id: "template-editor", label: "Template Config", icon: Settings }, { id: "template-editor", label: "模板配置", icon: Settings },
], ],
}, },
{ {
id: "light-app-center", id: "light-app-center",
label: "Light Apps", label: "轻应用",
icon: Grid3X3, icon: Grid3X3,
children: [ children: [
{ id: "light-app-square", label: "App Square", icon: Layout }, { id: "light-app-square", label: "应用广场", icon: Layout },
{ id: "light-app-records", label: "Run Records", icon: History }, { id: "light-app-records", label: "运行记录", icon: History },
], ],
}, },
{ {
id: "task-management", id: "task-management",
label: "Task Management", label: "任务管理",
icon: Target, icon: Target,
children: [ children: [
{ id: "dashboard", label: "Dashboard", icon: BarChart3 }, { id: "dashboard", label: "数据看板", icon: BarChart3 },
{ {
id: "xk-task", id: "xk-task",
label: "XK Tasks", label: "XK任务",
icon: Folder, icon: Folder,
children: [ children: [
{ id: "xk-task-jc", label: "Task JC", icon: ListTodo }, { id: "xk-task-jc", label: "任务JC", icon: ListTodo },
{ id: "xk-task-dispatch", label: "Task Dispatch", icon: ListTodo }, { id: "xk-task-dispatch", label: "任务调度", icon: ListTodo },
], ],
}, },
{ {
id: "plan-task", id: "plan-task",
label: "Planning Tasks", label: "规划任务",
icon: Folder, icon: Folder,
children: [ children: [
{ id: "plan-task-fx", label: "Task Analysis", icon: ListTodo }, { id: "plan-task-fx", label: "任务分析", icon: ListTodo },
{ id: "plan-task-action", label: "Action Strategy", icon: ListTodo }, { id: "plan-task-action", label: "行动策略", icon: ListTodo },
], ],
}, },
{ {
id: "mnty-task", id: "mnty-task",
label: "MNTY Tasks", label: "MNTY任务",
icon: Folder, icon: Folder,
children: [ children: [
{ id: "mnty-task-mechanism", label: "Mechanism Study", icon: ListTodo }, { id: "mnty-task-mechanism", label: "机制研究", icon: ListTodo },
{ id: "mnty-task-plan", label: "Plan Task", icon: ListTodo }, { id: "mnty-task-plan", label: "规划任务", icon: ListTodo },
{ id: "mnty-task-zsty", label: "ZSTY Task", icon: ListTodo }, { id: "mnty-task-zsty", label: "ZSTY任务", icon: ListTodo },
], ],
}, },
{ {
id: "effect-task", id: "effect-task",
label: "Effect PG Tasks", label: "效果PG任务",
icon: Folder, icon: Folder,
children: [ children: [
{ id: "effect-task-pg", label: "Task PG", icon: ListTodo }, { id: "effect-task-pg", label: "任务PG", icon: ListTodo },
{ id: "effect-action-pg", label: "Action PG", icon: ListTodo }, { id: "effect-action-pg", label: "行动PG", icon: ListTodo },
{ id: "effect-info-pg", label: "Info PG", icon: ListTodo }, { id: "effect-info-pg", label: "信息PG", icon: ListTodo },
{ id: "effect-account-pg", label: "Account PG", icon: ListTodo }, { id: "effect-account-pg", label: "账号PG", icon: ListTodo },
], ],
}, },
{ id: "task-create", label: "Create Task", icon: Plus }, { id: "task-create", label: "创建任务", icon: Plus },
{ id: "task-single", label: "Single Agent Task", icon: Bot }, { id: "task-single", label: "单智能体任务", icon: Bot },
{ id: "task-scene", label: "Scene Task", icon: Workflow }, { id: "task-scene", label: "场景任务", icon: Workflow },
{ id: "task-consult-create", label: "Multi Agent Consult", icon: MessageSquare }, { id: "task-consult-create", label: "多智能体协商", icon: MessageSquare },
{ id: "final-plan", label: "Final Output", icon: FileOutput }, { id: "final-plan", label: "最终输出", icon: FileOutput },
], ],
}, },
{ id: "scene-list", label: "Scene Management", icon: Layers }, { id: "scene-list", label: "场景管理", icon: Layers },
{ {
id: "agent-list", id: "agent-list",
label: "Agent Management", label: "智能体管理",
icon: Bot, icon: Bot,
children: [ children: [
{ id: "agent-overview", label: "Agent List", icon: Users }, { id: "agent-overview", label: "智能体列表", icon: Users, path: "/page/strategy/agents" },
{ id: "agent-trial", label: "Single Agent Trial", icon: PlayCircle }, { id: "agent-trial", label: "单智能体试用", icon: PlayCircle },
], ],
}, },
{ {
id: "result-management", id: "result-management",
label: "Result Management", label: "结果管理",
icon: BarChart3, icon: BarChart3,
children: [ children: [
{ {
id: "result-planning", id: "result-planning",
label: "Planning Results", label: "规划结果",
icon: Target, icon: Target,
children: [ children: [
{ id: "result-planning-all", label: "All", icon: BarChart3 }, { id: "result-planning-all", label: "全部", icon: BarChart3 },
{ id: "result-planning-goal", label: "Goal Analysis", icon: Target }, { id: "result-planning-goal", label: "目标分析", icon: Target },
{ id: "result-planning-brand", label: "Brand Strategy", icon: Target }, { id: "result-planning-brand", label: "品牌策略", icon: Target },
{ id: "result-planning-content", label: "Content Planning", icon: Target }, { id: "result-planning-content", label: "内容规划", icon: Target },
{ id: "result-planning-channel", label: "Channel Planning", icon: Target }, { id: "result-planning-channel", label: "渠道规划", icon: Target },
{ id: "result-planning-narrative", label: "Narrative Strategy", icon: Target }, { id: "result-planning-narrative", label: "叙事策略", icon: Target },
], ],
}, },
{ {
id: "result-mnty", id: "result-mnty",
label: "MNTY Results", label: "MNTY结果",
icon: Brain, icon: Brain,
children: [ children: [
{ id: "result-mnty-all", label: "All", icon: BarChart3 }, { id: "result-mnty-all", label: "全部", icon: BarChart3 },
{ id: "result-mnty-mechanism", label: "Mechanism Research", icon: Brain }, { id: "result-mnty-mechanism", label: "机制研究", icon: Brain },
{ id: "result-mnty-plan", label: "Plan Design", icon: Brain }, { id: "result-mnty-plan", label: "规划设计", icon: Brain },
{ id: "result-mnty-zsty", label: "ZSTY Analysis", icon: Brain }, { id: "result-mnty-zsty", label: "ZSTY分析", icon: Brain },
], ],
}, },
{ {
id: "result-effect-pg", id: "result-effect-pg",
label: "Effect PG Results", label: "效果PG结果",
icon: TrendingUp, icon: TrendingUp,
children: [ children: [
{ id: "result-effect-pg-all", label: "All", icon: BarChart3 }, { id: "result-effect-pg-all", label: "全部", icon: BarChart3 },
{ id: "result-effect-pg-behavior", label: "Behavior PG", icon: TrendingUp }, { id: "result-effect-pg-behavior", label: "行为PG", icon: TrendingUp },
{ id: "result-effect-pg-evaluation", label: "Effect Review", icon: TrendingUp }, { id: "result-effect-pg-evaluation", label: "效果复盘", icon: TrendingUp },
{ id: "result-effect-pg-info", label: "Info PG", icon: TrendingUp }, { id: "result-effect-pg-info", label: "信息PG", icon: TrendingUp },
{ id: "result-effect-pg-account", label: "Account PG", icon: TrendingUp }, { id: "result-effect-pg-account", label: "账号PG", icon: TrendingUp },
], ],
}, },
{ {
id: "result-xk", id: "result-xk",
label: "XK Results", label: "XK结果",
icon: Compass, icon: Compass,
children: [ children: [
{ id: "result-xk-all", label: "All", icon: BarChart3 }, { id: "result-xk-all", label: "全部", icon: BarChart3 },
{ id: "result-xk-jc", label: "Task JC", icon: Compass }, { id: "result-xk-jc", label: "任务JC", icon: Compass },
{ id: "result-xk-dispatch", label: "Task Dispatch", icon: Compass }, { id: "result-xk-dispatch", label: "任务调度", icon: Compass },
], ],
}, },
{ {
id: "result-dq", id: "result-dq",
label: "DQ Results", label: "DQ结果",
icon: Database, icon: Database,
children: [ children: [
{ id: "result-dq-all", label: "All", icon: BarChart3 }, { id: "result-dq-all", label: "全部", icon: BarChart3 },
{ id: "result-dq-audience", label: "Audience Analysis", icon: Database }, { id: "result-dq-audience", label: "受众分析", icon: Database },
{ id: "result-dq-kb", label: "Knowledge Research", icon: Database }, { id: "result-dq-kb", label: "知识研究", icon: Database },
{ id: "result-dq-trend", label: "Trend Research", icon: Database }, { id: "result-dq-trend", label: "趋势研究", icon: Database },
], ],
}, },
{ {
id: "result-other", id: "result-other",
label: "Other Results", label: "其他结果",
icon: MoreHorizontal, icon: MoreHorizontal,
children: [ children: [
{ id: "result-other-all", label: "All", icon: BarChart3 }, { id: "result-other-all", label: "全部", icon: BarChart3 },
{ id: "result-other-general", label: "General Analysis", icon: MoreHorizontal }, { id: "result-other-general", label: "通用分析", icon: MoreHorizontal },
{ id: "result-other-temp", label: "Temp Tasks", icon: MoreHorizontal }, { id: "result-other-temp", label: "临时任务", icon: MoreHorizontal },
{ id: "result-other-other", label: "Other", icon: MoreHorizontal }, { id: "result-other-other", label: "其他", icon: MoreHorizontal },
], ],
}, },
], ],
}, },
{ {
id: "resource-management", id: "resource-management",
label: "Resource Management", label: "资源管理",
icon: Database, icon: Database,
children: [ children: [
{ id: "resource-zz", label: "ZZ Resources", icon: FileBox }, { id: "resource-zz", label: "ZZ资源", icon: FileBox },
{ id: "resource-network-actor", label: "Network Actor", icon: Network }, { id: "resource-network-actor", label: "网络达人", icon: Network },
{ id: "resource-knowledge-base", label: "Knowledge Base", icon: Library }, { id: "resource-knowledge-base", label: "知识库", icon: Library },
{ id: "resource-evaluation-index", label: "Evaluation Index", icon: Scale }, { id: "resource-evaluation-index", label: "评估指标", icon: Scale },
], ],
}, },
]; ];
@@ -233,6 +235,8 @@ export function PageSidebar({
onToggleCollapsed, onToggleCollapsed,
}: PageSidebarProps) { }: PageSidebarProps) {
const [expandedIds, setExpandedIds] = useState<Set<string>>(defaultExpandedIds); const [expandedIds, setExpandedIds] = useState<Set<string>>(defaultExpandedIds);
const navigate = useNavigate();
const { pathname } = useLocation();
const iconButtonClass = 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"; "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 renderLeaf = (item: MenuItem, depth = 0) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = !!item.path && pathname === item.path;
return ( return (
<button <button
key={item.id} key={item.id}
type="button" type="button"
title={collapsed && depth === 0 ? item.label : undefined} 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" 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`} /> <Icon className={`${depth === 0 ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out`} />
+8 -3
View File
@@ -53,6 +53,10 @@ function useSidebar() {
return context; return context;
} }
function useSidebarSafe() {
return React.useContext(SidebarContext);
}
function SidebarProvider({ function SidebarProvider({
defaultOpen = true, defaultOpen = true,
open: openProp, open: openProp,
@@ -139,7 +143,7 @@ function SidebarProvider({
} as React.CSSProperties } as React.CSSProperties
} }
className={cn( className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full", "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-full w-full",
className, className,
)} )}
{...props} {...props}
@@ -229,7 +233,7 @@ function Sidebar({
<div <div
data-slot="sidebar-container" data-slot="sidebar-container"
className={cn( className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex", "fixed bottom-0 z-10 hidden w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex [top:var(--header-height,4rem)]",
side === "left" side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]" ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
@@ -309,7 +313,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
<main <main
data-slot="sidebar-inset" data-slot="sidebar-inset"
className={cn( className={cn(
"bg-[#fffdfa] relative flex w-full flex-1 flex-col", "bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2", "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className, className,
)} )}
@@ -723,4 +727,5 @@ export {
SidebarSeparator, SidebarSeparator,
SidebarTrigger, SidebarTrigger,
useSidebar, useSidebar,
useSidebarSafe,
}; };
@@ -6,7 +6,7 @@ import {
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { useSidebar } from "@/components/ui/sidebar"; import { useSidebarSafe } from "@/components/ui/sidebar";
import { env } from "@/env"; import { env } from "@/env";
export interface ArtifactsContextType { export interface ArtifactsContextType {
@@ -39,19 +39,19 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true", env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
); );
const [autoOpen, setAutoOpen] = useState(true); const [autoOpen, setAutoOpen] = useState(true);
const { setOpen: setSidebarOpen } = useSidebar(); const sidebar = useSidebarSafe();
const select = useCallback( const select = useCallback(
(artifact: string, autoSelect = false) => { (artifact: string, autoSelect = false) => {
setSelectedArtifact(artifact); setSelectedArtifact(artifact);
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") { if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
setSidebarOpen(false); sidebar?.setOpen(false);
} }
if (!autoSelect) { if (!autoSelect) {
setAutoSelect(false); setAutoSelect(false);
} }
}, },
[setSidebarOpen, setSelectedArtifact, setAutoSelect], [sidebar, setSelectedArtifact, setAutoSelect],
); );
const deselect = useCallback(() => { const deselect = useCallback(() => {
@@ -80,12 +80,16 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "../ui/dropdown-menu"; } from "../ui/dropdown-menu";
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
import { useThread } from "./messages/context"; import { useThread } from "./messages/context";
import { ModeHoverGuide } from "./mode-hover-guide"; import { ModeHoverGuide } from "./mode-hover-guide";
import { Tooltip } from "./tooltip"; import { Tooltip } from "./tooltip";
type InputMode = "flash" | "thinking" | "pro" | "ultra"; type InputMode = "flash" | "thinking" | "pro" | "ultra";
function getResolvedMode( function getResolvedMode(
mode: InputMode | undefined, mode: InputMode | undefined,
supportsThinking: boolean, supportsThinking: boolean,
@@ -161,6 +165,8 @@ export function InputBox({
null, null,
); );
const { questions: recommendedQuestions } = useRecommendedQuestions();
useEffect(() => { useEffect(() => {
if (models.length === 0) { if (models.length === 0) {
return; return;
@@ -430,7 +436,32 @@ export function InputBox({
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]); }, [context.model_name, disabled, isMock, status, thread.messages, threadId]);
return ( return (
<div ref={promptRootRef} className="relative flex flex-col gap-4"> <div ref={promptRootRef} className="relative flex flex-col gap-6">
{extraHeader && (
<div className="flex items-center justify-center">
{extraHeader}
</div>
)}
{isNewThread && searchParams.get("mode") !== "skill" && recommendedQuestions.length > 0 && (
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
{recommendedQuestions.map((q) => (
<button
key={q.id}
type="button"
className="rounded-full border border-[#e4e4e7] bg-white px-4 py-2 text-sm text-[#18181b] shadow-none transition-colors hover:bg-[#f4f4f5] cursor-pointer dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
onClick={() => {
textInput.setInput(q.content);
setFollowupsHidden(true);
setTimeout(() => requestFormSubmit(), 0);
}}
>
{q.title}
</button>
))}
</div>
)}
{showFollowups && ( {showFollowups && (
<div className="flex items-center justify-center pb-2"> <div className="flex items-center justify-center pb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -464,7 +495,7 @@ export function InputBox({
)} )}
<PromptInput <PromptInput
className={cn( className={cn(
"rounded-[1.85rem] border border-[#eadfce] bg-white shadow-[0_24px_70px_-34px_rgba(120,83,42,0.32)] ring-1 ring-[#f6efe5] transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-[1.85rem]", "rounded-[1.85rem] border border-[#eadfce] bg-white shadow-[0_24px_70px_-34px_rgba(120,83,42,0.32)] ring-1 ring-[#f6efe5] transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-[1.85rem] dark:bg-slate-800 dark:border-slate-700 dark:ring-slate-700 dark:shadow-none",
className, className,
)} )}
disabled={disabled} disabled={disabled}
@@ -473,20 +504,13 @@ export function InputBox({
onSubmit={handleSubmit} onSubmit={handleSubmit}
{...props} {...props}
> >
{extraHeader && (
<div className="absolute top-0 right-0 left-0 z-10">
<div className="absolute right-0 bottom-0 left-0 flex items-center justify-center">
{extraHeader}
</div>
</div>
)}
<PromptInputAttachments> <PromptInputAttachments>
{(attachment) => <PromptInputAttachment data={attachment} />} {(attachment) => <PromptInputAttachment data={attachment} />}
</PromptInputAttachments> </PromptInputAttachments>
<PromptInputBody className="absolute top-0 right-0 left-0 z-3"> <PromptInputBody className="absolute top-0 right-0 left-0 z-3">
<PromptInputTextarea <PromptInputTextarea
className={cn( className={cn(
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-[#b7ab9a]", "size-full px-2 py-1 text-[15px] leading-7 placeholder:text-[#b7ab9a] dark:placeholder:text-slate-500",
)} )}
disabled={disabled} disabled={disabled}
placeholder={t.inputBox.placeholder} placeholder={t.inputBox.placeholder}
@@ -494,7 +518,7 @@ export function InputBox({
defaultValue={initialValue} defaultValue={initialValue}
/> />
</PromptInputBody> </PromptInputBody>
<PromptInputFooter className="flex items-center border-t border-[#f4ede4] px-2 pt-2.5 pb-1.5"> <PromptInputFooter className="flex items-center border-t border-[#f4ede4] dark:border-slate-700 px-2 pt-2.5 pb-1.5">
<PromptInputTools className="gap-1.5"> <PromptInputTools className="gap-1.5">
{/* TODO: Add more connectors here {/* TODO: Add more connectors here
<PromptInputActionMenu> <PromptInputActionMenu>
@@ -505,7 +529,7 @@ export function InputBox({
/> />
</PromptInputActionMenuContent> </PromptInputActionMenuContent>
</PromptInputActionMenu> */} </PromptInputActionMenu> */}
<AddAttachmentsButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-2.5! shadow-none hover:bg-white" /> <AddAttachmentsButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-2.5! shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700" />
<PromptInputActionMenu> <PromptInputActionMenu>
<ModeHoverGuide <ModeHoverGuide
mode={ mode={
@@ -517,7 +541,7 @@ export function InputBox({
: "flash" : "flash"
} }
> >
<PromptInputActionMenuTrigger className="gap-1.5! rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3! text-[#7e6a57] shadow-none hover:bg-white"> <PromptInputActionMenuTrigger className="gap-1.5! rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3! text-[#7e6a57] shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700">
<div> <div>
{context.mode === "flash" && <ZapIcon className="size-3" />} {context.mode === "flash" && <ZapIcon className="size-3" />}
{context.mode === "thinking" && ( {context.mode === "thinking" && (
@@ -679,7 +703,7 @@ export function InputBox({
</PromptInputActionMenu> </PromptInputActionMenu>
{supportReasoningEffort && context.mode !== "flash" && ( {supportReasoningEffort && context.mode !== "flash" && (
<PromptInputActionMenu> <PromptInputActionMenu>
<PromptInputActionMenuTrigger className="gap-1.5! rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3! text-[#7e6a57] shadow-none hover:bg-white"> <PromptInputActionMenuTrigger className="gap-1.5! rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3! text-[#7e6a57] shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700">
<div className="text-xs font-normal"> <div className="text-xs font-normal">
{t.inputBox.reasoningEffort}: {t.inputBox.reasoningEffort}:
{context.reasoning_effort === "minimal" && {context.reasoning_effort === "minimal" &&
@@ -800,7 +824,7 @@ export function InputBox({
onOpenChange={setModelDialogOpen} onOpenChange={setModelDialogOpen}
> >
<ModelSelectorTrigger asChild> <ModelSelectorTrigger asChild>
<PromptInputButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3 shadow-none hover:bg-white"> <PromptInputButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3 shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700">
<div className="flex min-w-0 flex-col items-start text-left"> <div className="flex min-w-0 flex-col items-start text-left">
<ModelSelectorName className="text-xs font-normal"> <ModelSelectorName className="text-xs font-normal">
{selectedModel?.display_name} {selectedModel?.display_name}
@@ -834,7 +858,7 @@ export function InputBox({
</ModelSelectorContent> </ModelSelectorContent>
</ModelSelector> </ModelSelector>
<PromptInputSubmit <PromptInputSubmit
className="rounded-full border-[#e7d9c7] bg-white shadow-[0_10px_24px_-18px_rgba(120,83,42,0.34)]" className="rounded-full border-[#e7d9c7] bg-white shadow-[0_10px_24px_-18px_rgba(120,83,42,0.34)] dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none"
disabled={disabled} disabled={disabled}
variant="outline" variant="outline"
status={status} status={status}
@@ -842,16 +866,10 @@ export function InputBox({
</PromptInputTools> </PromptInputTools>
</PromptInputFooter> </PromptInputFooter>
{!isNewThread && ( {!isNewThread && (
<div className="absolute right-0 -bottom-[17px] left-0 z-0 h-4 bg-[#fffdfa]"></div> <div className="absolute right-0 -bottom-[17px] left-0 z-0 h-4 bg-[#fffdfa] dark:bg-background"></div>
)} )}
</PromptInput> </PromptInput>
{isNewThread && searchParams.get("mode") !== "skill" && (
<div className="flex items-center justify-center pt-2">
<SuggestionList />
</div>
)}
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}> <Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -68,7 +68,7 @@ export function MessageList({
<Conversation <Conversation
className={cn("flex size-full flex-col justify-center", className)} className={cn("flex size-full flex-col justify-center", className)}
> >
<ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-8 pt-12"> <ConversationContent className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12">
{groupMessages(messages, (group) => { {groupMessages(messages, (group) => {
if (group.type === "human" || group.type === "assistant") { if (group.type === "human" || group.type === "assistant") {
return group.messages.map((msg) => { return group.messages.map((msg) => {
@@ -454,6 +454,7 @@ function ClarificationCard({
const payload = getClarificationPayload(message); const payload = getClarificationPayload(message);
const [customAnswer, setCustomAnswer] = useState(""); const [customAnswer, setCustomAnswer] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const options = useMemo( const options = useMemo(
() => () =>
@@ -473,16 +474,17 @@ function ClarificationCard({
); );
} }
const disabled = isLoading || submitting || !onSubmit; const disabled = false;
async function submitAnswer(answer: string) { async function submitAnswer(answer: string) {
const trimmed = answer.trim(); const trimmed = answer.trim();
if (!trimmed || disabled) { if (!trimmed) {
return; return;
} }
setSubmitting(true); setSubmitting(true);
try { try {
await onSubmit?.(trimmed); await onSubmit?.(trimmed);
setSubmitted(true);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@@ -24,7 +24,7 @@ function SkeletonBar({
export function MessageListSkeleton() { export function MessageListSkeleton() {
let index = 0; let index = 0;
return ( return (
<div className="flex w-full max-w-(--container-width-md) flex-col gap-12 p-8 pt-16"> <div className="flex w-full max-w-(--container-width-lg) flex-col gap-12 p-8 pt-16">
<div <div
role="human-message" role="human-message"
className="flex w-[50%] flex-col items-end gap-2 self-end" className="flex w-[50%] flex-col items-end gap-2 self-end"
@@ -50,23 +50,23 @@ export function Welcome({
</div> </div>
{searchParams.get("mode") === "skill" ? ( {searchParams.get("mode") === "skill" ? (
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">
{t.welcome.createYourOwnSkillDescription.includes("\n") ? ( {/* {t.welcome.createYourOwnSkillDescription.includes("\n") ? (
<pre className="font-sans whitespace-pre"> <pre className="font-sans whitespace-pre">
{t.welcome.createYourOwnSkillDescription} {t.welcome.createYourOwnSkillDescription}
</pre> </pre>
) : ( ) : (
<p>{t.welcome.createYourOwnSkillDescription}</p> <p>{t.welcome.createYourOwnSkillDescription}</p>
)} )} */}
</div> </div>
) : ( ) : (
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">
{t.welcome.description.includes("\n") ? ( {/* {t.welcome.description.includes("\n") ? (
<pre className="font-sans whitespace-pre"> <pre className="font-sans whitespace-pre">
{t.welcome.description} {t.welcome.description}
</pre> </pre>
) : ( ) : (
<p>{t.welcome.description}</p> <p>{t.welcome.description}</p>
)} )} */}
</div> </div>
)} )}
</div> </div>
@@ -1,6 +1,6 @@
"use client"; "use client";
import { ChevronsUpDown, Settings2Icon, SettingsIcon } from "lucide-react"; import { ChevronsUpDown, MessageSquarePlusIcon, Settings2Icon, SettingsIcon } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { import {
@@ -17,9 +17,12 @@ import {
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { NotificationBell } from "./notification-bell"; import { NotificationBell } from "./notification-bell";
import { useNavigate } from "react-router-dom";
import { SettingsDialog } from "./settings"; import { SettingsDialog } from "./settings";
function NavMenuButtonContent({ function NavMenuButtonContent({
@@ -43,9 +46,11 @@ function NavMenuButtonContent({
} }
export function WorkspaceNavMenu() { export function WorkspaceNavMenu() {
const navigate = useNavigate();
const isAdmin = getStoredAuth()?.system_role === "admin";
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsDefaultSection, setSettingsDefaultSection] = useState< const [settingsDefaultSection, setSettingsDefaultSection] = useState<
"appearance" | "memory" | "tools" | "skills" | "notification" | "about" "appearance" | "memory" | "tools" | "notification" | "about"
>("appearance"); >("appearance");
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const { open: isSidebarOpen } = useSidebar(); const { open: isSidebarOpen } = useSidebar();
@@ -92,6 +97,16 @@ export function WorkspaceNavMenu() {
<Settings2Icon /> <Settings2Icon />
{t.common.settings} {t.common.settings}
</DropdownMenuItem> </DropdownMenuItem>
{isAdmin && (
<DropdownMenuItem
onClick={() =>
navigate("/page/workspace/settings/recommended-questions")
}
>
<MessageSquarePlusIcon />
推荐问题配置
</DropdownMenuItem>
)}
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
</DropdownMenuContent> </DropdownMenuContent>
+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}`);
}
+81
View File
@@ -0,0 +1,81 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
export interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: number;
type?: 'general' | 'agent' | 'compare';
agentName?: string;
}
interface ChatContextType {
messages: ChatMessage[];
addMessage: (role: 'user' | 'assistant', content: string, type?: 'general' | 'agent' | 'compare', agentName?: string) => void;
clearMessages: () => void;
deleteMessage: (id: string) => void;
}
const ChatContext = createContext<ChatContextType | undefined>(undefined);
export function ChatProvider({ children }: { children: ReactNode }) {
const [messages, setMessages] = useState<ChatMessage[]>(() => {
try {
const stored = localStorage.getItem('qa-chat-history');
return stored ? JSON.parse(stored) : [];
} catch {
return [];
}
});
const saveToStorage = useCallback((newMessages: ChatMessage[]) => {
try {
localStorage.setItem('qa-chat-history', JSON.stringify(newMessages));
} catch (error) {
console.error('Failed to save chat history:', error);
}
}, []);
const addMessage = useCallback((role: 'user' | 'assistant', content: string, type?: 'general' | 'agent' | 'compare', agentName?: string) => {
const newMessage: ChatMessage = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
role,
content,
timestamp: Date.now(),
type,
agentName,
};
setMessages((prev) => {
const updated = [...prev, newMessage];
saveToStorage(updated);
return updated;
});
}, [saveToStorage]);
const clearMessages = useCallback(() => {
setMessages([]);
localStorage.removeItem('qa-chat-history');
}, []);
const deleteMessage = useCallback((id: string) => {
setMessages((prev) => {
const updated = prev.filter((msg) => msg.id !== id);
saveToStorage(updated);
return updated;
});
}, [saveToStorage]);
return (
<ChatContext.Provider value={{ messages, addMessage, clearMessages, deleteMessage }}>
{children}
</ChatContext.Provider>
);
}
export function useChatContext() {
const context = useContext(ChatContext);
if (context === undefined) {
throw new Error('useChatContext must be used within a ChatProvider');
}
return context;
}
+145
View File
@@ -0,0 +1,145 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
// 主题类型
export type ThemeType = 'dark' | 'light';
// 主题配置
export interface ThemeConfig {
type: ThemeType;
name: string;
colors: {
background: string;
backgroundSecondary: string;
text: string;
textSecondary: string;
primary: string;
primaryHover: string;
border: string;
accent: string;
success: string;
warning: string;
error: string;
card: string;
cardHover: string;
input: string;
inputBorder: string;
overlay: string;
};
}
// 主题配置
const themes: Record<ThemeType, ThemeConfig> = {
dark: {
type: 'dark',
name: '深色主题',
colors: {
background: 'slate-900',
backgroundSecondary: 'slate-800',
text: 'white',
textSecondary: 'slate-400',
primary: 'blue-500',
primaryHover: 'blue-400',
border: 'slate-700',
accent: 'blue-600',
success: 'emerald-500',
warning: 'amber-500',
error: 'rose-500',
card: 'slate-800',
cardHover: 'slate-700',
input: 'slate-800',
inputBorder: 'slate-600',
overlay: 'slate-900/80',
},
},
light: {
type: 'light',
name: '红白主题',
colors: {
background: 'white',
backgroundSecondary: 'red-50',
text: 'gray-900',
textSecondary: 'gray-600',
primary: 'red-600',
primaryHover: 'red-500',
border: 'red-200',
accent: 'red-700',
success: 'green-600',
warning: 'amber-500',
error: 'red-600',
card: 'white',
cardHover: 'red-50',
input: 'white',
inputBorder: 'red-200',
overlay: 'black/30',
},
},
};
// 主题上下文
interface ThemeContextType {
theme: ThemeType;
themeConfig: ThemeConfig;
toggleTheme: () => void;
setTheme: (theme: ThemeType) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
// 主题提供者
interface ThemeProviderProps {
children: ReactNode;
}
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const [theme, setThemeState] = useState<ThemeType>(() => {
// 从 localStorage 读取保存的主题
const saved = localStorage.getItem('strategy-theme');
return (saved as ThemeType) || 'dark';
});
// 切换主题
const toggleTheme = () => {
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
// 设置特定主题
const setTheme = (newTheme: ThemeType) => {
setThemeState(newTheme);
};
// 保存到 localStorage
useEffect(() => {
localStorage.setItem('strategy-theme', theme);
// 更新文档类名
document.documentElement.classList.remove('dark', 'light');
document.documentElement.classList.add(theme);
}, [theme]);
const themeConfig = themes[theme];
return (
<ThemeContext.Provider value={{ theme, themeConfig, toggleTheme, setTheme }}>
{children}
</ThemeContext.Provider>
);
};
// 使用主题的 Hook
export const useTheme = (): ThemeContextType => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
// 主题工具函数 - 生成动态类名
export const getThemeClasses = (colorKey: keyof ThemeConfig['colors']): string => {
const { themeConfig } = useTheme();
return themeConfig.colors[colorKey];
};
// 组合类名工具函数
export const cn = (...classes: (string | undefined | null | false)[]): string => {
return classes.filter(Boolean).join(' ');
};
+1 -1
View File
@@ -59,7 +59,7 @@ export const zhCN: Translations = {
// Welcome // Welcome
welcome: { welcome: {
greeting: "你好,欢迎回来!", greeting: "你好,欢迎使用CM助手",
description: description:
"欢迎使用 CM 助手", "欢迎使用 CM 助手",
@@ -0,0 +1,66 @@
import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
CreateQuestionRequest,
RecommendedQuestion,
UpdateQuestionRequest,
} from "./index";
const BASE = () => `${getBackendBaseURL()}/api/recommended-questions`;
export async function listRecommendedQuestions(): Promise<RecommendedQuestion[]> {
const res = await apiFetch(BASE());
if (!res.ok) throw new Error(`Failed to load questions: ${res.statusText}`);
const data = (await res.json()) as { questions: RecommendedQuestion[] };
return data.questions;
}
export async function createRecommendedQuestion(
body: CreateQuestionRequest,
): Promise<RecommendedQuestion> {
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to create question: ${res.statusText}`);
}
return res.json() as Promise<RecommendedQuestion>;
}
export async function updateRecommendedQuestion(
id: string,
body: UpdateQuestionRequest,
): Promise<RecommendedQuestion> {
const res = await apiFetch(`${BASE()}/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
const err = (await res.json().catch(() => ({}))) as { detail?: string };
throw new Error(err.detail ?? `Failed to update question: ${res.statusText}`);
}
return res.json() as Promise<RecommendedQuestion>;
}
export async function deleteRecommendedQuestion(id: string): Promise<void> {
const res = await apiFetch(`${BASE()}/${id}`, { method: "DELETE" });
if (!res.ok) throw new Error(`Failed to delete question: ${res.statusText}`);
}
export async function reorderRecommendedQuestions(
ids: string[],
): Promise<RecommendedQuestion[]> {
const res = await apiFetch(`${getBackendBaseURL()}/api/recommended-questions-reorder`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
});
if (!res.ok) throw new Error(`Failed to reorder questions: ${res.statusText}`);
const data = (await res.json()) as { questions: RecommendedQuestion[] };
return data.questions;
}
@@ -0,0 +1,53 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createRecommendedQuestion,
deleteRecommendedQuestion,
listRecommendedQuestions,
reorderRecommendedQuestions,
updateRecommendedQuestion,
} from "./api";
import type { CreateQuestionRequest, UpdateQuestionRequest } from "./index";
const QK = ["recommended-questions"] as const;
export function useRecommendedQuestions() {
const { data, isLoading, error } = useQuery({
queryKey: QK,
queryFn: listRecommendedQuestions,
});
return { questions: data ?? [], isLoading, error };
}
export function useCreateRecommendedQuestion() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: CreateQuestionRequest) => createRecommendedQuestion(body),
onSuccess: () => void qc.invalidateQueries({ queryKey: QK }),
});
}
export function useUpdateRecommendedQuestion() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: string; body: UpdateQuestionRequest }) =>
updateRecommendedQuestion(id, body),
onSuccess: () => void qc.invalidateQueries({ queryKey: QK }),
});
}
export function useDeleteRecommendedQuestion() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteRecommendedQuestion(id),
onSuccess: () => void qc.invalidateQueries({ queryKey: QK }),
});
}
export function useReorderRecommendedQuestions() {
const qc = useQueryClient();
return useMutation({
mutationFn: (ids: string[]) => reorderRecommendedQuestions(ids),
onSuccess: () => void qc.invalidateQueries({ queryKey: QK }),
});
}
@@ -0,0 +1,20 @@
export interface RecommendedQuestion {
id: string;
title: string;
content: string;
sort_order: number;
created_at?: string;
updated_at?: string;
}
export interface CreateQuestionRequest {
title: string;
content: string;
sort_order?: number;
}
export interface UpdateQuestionRequest {
title?: string;
content?: string;
sort_order?: number;
}
+24
View File
@@ -0,0 +1,24 @@
import { createContext, useCallback, useContext } from "react";
import { useNavigate, type NavigateOptions } from "react-router-dom";
export const RouteBaseContext = createContext("/page/workspace");
export const RouteBaseProvider = RouteBaseContext.Provider;
export function useRouteBase() {
return useContext(RouteBaseContext);
}
export function useRouteNavigate() {
const navigate = useNavigate();
const base = useRouteBase();
return useCallback(
(path: string | number, opts?: NavigateOptions) => {
if (typeof path === "string") {
navigate(path.replace("/page/workspace", base), opts);
} else {
navigate(path);
}
},
[navigate, base],
);
}
+11 -2
View File
@@ -1,5 +1,7 @@
import { useContext } from "react";
import type { Message } from "@langchain/langgraph-sdk"; import type { Message } from "@langchain/langgraph-sdk";
import { RouteBaseContext } from "@/core/route-base";
import type { AgentThread, AgentThreadContext } from "./types"; import type { AgentThread, AgentThreadContext } from "./types";
type ThreadAgentRef = Pick<AgentThreadContext, "agent_id" | "agent_name">; type ThreadAgentRef = Pick<AgentThreadContext, "agent_id" | "agent_name">;
@@ -28,6 +30,7 @@ function pickAgentRef(
export function pathOfThread( export function pathOfThread(
thread: ThreadRouteTarget, thread: ThreadRouteTarget,
context?: ThreadAgentRef | null, context?: ThreadAgentRef | null,
base = "/page/workspace",
) { ) {
const threadId = typeof thread === "string" ? thread : thread.thread_id; const threadId = typeof thread === "string" ? thread : thread.thread_id;
const agentRef = const agentRef =
@@ -36,8 +39,14 @@ export function pathOfThread(
: pickAgentRef(thread.context, thread.metadata); : pickAgentRef(thread.context, thread.metadata);
return agentRef return agentRef
? `/page/workspace/agents/${encodeURIComponent(agentRef)}/chats/${threadId}` ? `${base}/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
: `/page/workspace/chats/${threadId}`; : `${base}/chats/${threadId}`;
}
export function usePathOfThread() {
const base = useContext(RouteBaseContext);
return (thread: ThreadRouteTarget, context?: ThreadAgentRef | null) =>
pathOfThread(thread, context, base);
} }
export function textOfMessage(message: Message) { export function textOfMessage(message: Message) {
+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;
}
+12 -1
View File
@@ -2,6 +2,7 @@
import { BotIcon, PlusSquare } from "lucide-react"; import { BotIcon, PlusSquare } from "lucide-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { useRouteBase } from "@/core/route-base";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
@@ -32,6 +33,7 @@ import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export default function AgentChatPage() { export default function AgentChatPage() {
const routeBase = useRouteBase();
const { t } = useI18n(); const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false); const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false); const [hasStartedConversation, setHasStartedConversation] = useState(false);
@@ -66,7 +68,7 @@ export default function AgentChatPage() {
history.replaceState( history.replaceState(
null, null,
"", "",
`${window.location.pathname}${window.location.search}#/page/workspace/agents/${agent_id}/chats/${createdThreadId}`, `${window.location.pathname}${window.location.search}#${routeBase}/agents/${agent_id}/chats/${createdThreadId}`,
); );
}, },
onFinish: (state) => { onFinish: (state) => {
@@ -98,6 +100,14 @@ export default function AgentChatPage() {
[sendMessage, threadId, agent_id], [sendMessage, threadId, agent_id],
); );
const handleClarificationSubmit = useCallback(
(answer: string) => {
setHasStartedConversation(true);
void sendMessage(threadId, { text: answer, files: [] }, { agent_id });
},
[sendMessage, threadId, agent_id],
);
const handleStop = useCallback(async () => { const handleStop = useCallback(async () => {
await thread.stop(); await thread.stop();
}, [thread]); }, [thread]);
@@ -160,6 +170,7 @@ export default function AgentChatPage() {
thread={thread} thread={thread}
paddingBottom={messageListPaddingBottom} paddingBottom={messageListPaddingBottom}
tokenUsageEnabled={tokenUsageEnabled} tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
/> />
</div> </div>
+14 -3
View File
@@ -26,11 +26,13 @@ import { useModels } from "@/core/models/hooks";
import { useNotification } from "@/core/notification/hooks"; import { useNotification } from "@/core/notification/hooks";
import { useThreadSettings } from "@/core/settings"; import { useThreadSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks"; import { useThreadStream } from "@/core/threads/hooks";
import { useRouteBase } from "@/core/route-base";
import { textOfMessage } from "@/core/threads/utils"; import { textOfMessage } from "@/core/threads/utils";
import { env } from "@/env"; import { env } from "@/env";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export default function ChatPage() { export default function ChatPage() {
const routeBase = useRouteBase();
const { t } = useI18n(); const { t } = useI18n();
const [showFollowups, setShowFollowups] = useState(false); const [showFollowups, setShowFollowups] = useState(false);
const [hasStartedConversation, setHasStartedConversation] = useState(false); const [hasStartedConversation, setHasStartedConversation] = useState(false);
@@ -64,7 +66,7 @@ export default function ChatPage() {
history.replaceState( history.replaceState(
null, null,
"", "",
`${window.location.pathname}${window.location.search}#/page/workspace/chats/${createdThreadId}`, `${window.location.pathname}${window.location.search}#${routeBase}/chats/${createdThreadId}`,
); );
}, },
onFinish: (state) => { onFinish: (state) => {
@@ -95,6 +97,14 @@ export default function ChatPage() {
}, },
[sendMessage, threadId], [sendMessage, threadId],
); );
const handleClarificationSubmit = useCallback(
(answer: string) => {
setHasStartedConversation(true);
void sendMessage(threadId, { text: answer, files: [] });
},
[sendMessage, threadId],
);
const handleStop = useCallback(async () => { const handleStop = useCallback(async () => {
await thread.stop(); await thread.stop();
}, [thread]); }, [thread]);
@@ -137,6 +147,7 @@ export default function ChatPage() {
thread={thread} thread={thread}
paddingBottom={messageListPaddingBottom} paddingBottom={messageListPaddingBottom}
tokenUsageEnabled={tokenUsageEnabled} tokenUsageEnabled={tokenUsageEnabled}
onClarificationSubmit={handleClarificationSubmit}
/> />
</div> </div>
<div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4"> <div className="absolute right-0 bottom-0 left-0 z-30 flex justify-center px-4">
@@ -145,8 +156,8 @@ export default function ChatPage() {
"relative w-full", "relative w-full",
isLandingState && "-translate-y-[calc(50vh-96px)]", isLandingState && "-translate-y-[calc(50vh-96px)]",
isLandingState isLandingState
? "max-w-(--container-width-sm)" ? "max-w-(--container-width-md)"
: "max-w-(--container-width-md)", : "max-w-(--container-width-lg)",
)} )}
> >
<div className="absolute -top-4 right-0 left-0 z-0"> <div className="absolute -top-4 right-0 left-0 z-0">
+2 -1
View File
@@ -12,10 +12,11 @@ import {
} from "@/components/workspace/workspace-container"; } from "@/components/workspace/workspace-container";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useThreads } from "@/core/threads/hooks"; import { useThreads } from "@/core/threads/hooks";
import { pathOfThread, titleOfThread } from "@/core/threads/utils"; import { usePathOfThread, titleOfThread } from "@/core/threads/utils";
import { formatTimeAgo } from "@/core/utils/datetime"; import { formatTimeAgo } from "@/core/utils/datetime";
export default function ChatsPage() { export default function ChatsPage() {
const pathOfThread = usePathOfThread();
const { t } = useI18n(); const { t } = useI18n();
const { data: threads } = useThreads(); const { data: threads } = useThreads();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
+101 -80
View File
@@ -17,6 +17,13 @@ import {
} from "@/components/ai-elements/prompt-input"; } from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ArtifactsProvider } from "@/components/workspace/artifacts"; import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { import {
@@ -30,37 +37,18 @@ import {
AgentNameCheckError, AgentNameCheckError,
checkAgentName, checkAgentName,
createAgent, createAgent,
getAgent,
updateAgent, updateAgent,
} from "@/core/agents/api"; } from "@/core/agents/api";
import { useI18n } from "@/core/i18n/hooks"; import { useI18n } from "@/core/i18n/hooks";
import { useModels } from "@/core/models/hooks";
import type { Skill } from "@/core/skills"; import type { Skill } from "@/core/skills";
import { useSkills } from "@/core/skills/hooks"; import { useSkills } from "@/core/skills/hooks";
import { useThreadStream } from "@/core/threads/hooks"; import { useThreadStream } from "@/core/threads/hooks";
import { uuid } from "@/core/utils/uuid"; import { uuid } from "@/core/utils/uuid";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const AGENT_READ_RETRY_DELAYS_MS = [200, 500, 1_000, 2_000];
const DEFAULT_SELECTED_SKILL_COUNT = 3; const DEFAULT_SELECTED_SKILL_COUNT = 3;
function wait(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
async function getAgentWithRetry(agentId: string) {
for (const delay of [0, ...AGENT_READ_RETRY_DELAYS_MS]) {
if (delay > 0) {
await wait(delay);
}
try {
return await getAgent(agentId);
} catch {
// Retry until the file/database write settles.
}
}
return null;
}
function getCreateAgentErrorMessage( function getCreateAgentErrorMessage(
error: unknown, error: unknown,
networkErrorMessage: string, networkErrorMessage: string,
@@ -83,6 +71,8 @@ export default function NewAgentPage() {
const [nameInput, setNameInput] = useState(""); const [nameInput, setNameInput] = useState("");
const [descriptionInput, setDescriptionInput] = useState(""); const [descriptionInput, setDescriptionInput] = useState("");
const [soulInput, setSoulInput] = useState(""); const [soulInput, setSoulInput] = useState("");
const [modelInput, setModelInput] = useState<string>("");
const [chatModelInput, setChatModelInput] = useState<string>("");
const [formError, setFormError] = useState(""); const [formError, setFormError] = useState("");
const [agentId, setAgentId] = useState(""); const [agentId, setAgentId] = useState("");
const [agent, setAgent] = useState<Agent | null>(null); const [agent, setAgent] = useState<Agent | null>(null);
@@ -92,6 +82,7 @@ export default function NewAgentPage() {
const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false); const [isSkillSelectionTouched, setIsSkillSelectionTouched] = useState(false);
const [threadPrepared, setThreadPrepared] = useState(false); const [threadPrepared, setThreadPrepared] = useState(false);
const { skills, isLoading: isSkillsLoading } = useSkills(); const { skills, isLoading: isSkillsLoading } = useSkills();
const { models, isLoading: isModelsLoading } = useModels();
const enabledSkills = useMemo( const enabledSkills = useMemo(
() => skills.filter((skill) => skill.enabled), () => skills.filter((skill) => skill.enabled),
@@ -109,26 +100,30 @@ export default function NewAgentPage() {
); );
}, [enabledSkills, isSkillSelectionTouched]); }, [enabledSkills, isSkillSelectionTouched]);
// Seed both model selectors with the first available model the first time
// models load. The user can still change either independently afterwards.
useEffect(() => {
if (models.length === 0) {
return;
}
const first = models[0]!.name;
setModelInput((prev) => prev || first);
setChatModelInput((prev) => prev || first);
}, [models]);
const [thread, sendMessage] = useThreadStream({ const [thread, sendMessage] = useThreadStream({
threadId, threadId,
assistantId: agentId || undefined, assistantId: agentId || undefined,
context: { context: {
mode: "flash", mode: "flash",
is_bootstrap: true, is_bootstrap: true,
model_name: chatModelInput || undefined,
}, },
onToolEnd({ name }) { onToolEnd() {
if (name !== "setup_agent" || !agentId) { // setup_agent never actually executes in this flow — it gets
return; // intercepted by SetupAgentApprovalMiddleware and the form is filled
} // from the approval ToolMessage payload (handleApproveSetupAgent).
void getAgentWithRetry(agentId).then((fetched) => { // No agent record is fetched/refreshed during chat.
if (fetched) {
setAgent(fetched);
setDescriptionInput(fetched.description ?? "");
setSoulInput(fetched.soul ?? "");
setSelectedSkills(fetched.skills ?? []);
toast.success("专家配置已创建");
}
});
}, },
}); });
@@ -159,12 +154,17 @@ export default function NewAgentPage() {
} }
let name = provided; let name = provided;
if (!name) { if (!name) {
// Names are display-only and may collide, so a timestamped placeholder is // Names are display-only and may collide, so a timestamped placeholder
// safe. Reflect it in the form so the user sees what got created. // is safe to send to the backend just to satisfy the required-field
// validation. Intentionally NOT written back into the visible input —
// the user wants the name box to stay blank while they chat, and we
// refill it later (with the model's proposed name) on setup_agent
// approval.
name = `未命名专家-${Date.now().toString(36)}`; name = `未命名专家-${Date.now().toString(36)}`;
setNameInput(name);
} }
const modelOverride = modelInput.trim() || null;
if (agentId) { if (agentId) {
setIsSavingDraft(true); setIsSavingDraft(true);
try { try {
@@ -173,6 +173,7 @@ export default function NewAgentPage() {
description: descriptionInput, description: descriptionInput,
soul: soulInput, soul: soulInput,
skills: selectedSkills, skills: selectedSkills,
model: modelOverride,
}); });
setAgent(updated); setAgent(updated);
return updated; return updated;
@@ -192,6 +193,7 @@ export default function NewAgentPage() {
description: descriptionInput, description: descriptionInput,
soul: soulInput, soul: soulInput,
skills: selectedSkills, skills: selectedSkills,
model: modelOverride,
}); });
setAgentId(created.id); setAgentId(created.id);
setAgent(created); setAgent(created);
@@ -218,6 +220,7 @@ export default function NewAgentPage() {
}, [ }, [
agentId, agentId,
descriptionInput, descriptionInput,
modelInput,
selectedSkills, selectedSkills,
soulInput, soulInput,
t.agents.nameStepCheckError, t.agents.nameStepCheckError,
@@ -239,63 +242,37 @@ export default function NewAgentPage() {
if (!trimmed || thread.isLoading) { if (!trimmed || thread.isLoading) {
return; return;
} }
// Lazily create the agent on first message so the user can just start // Chat is purely a discussion channel. The agent record is only
// typing — ensureAgent generates a placeholder name when none is provided. // created when the user explicitly clicks Save (top-right). If the
let activeAgentId = agentId; // model proposes a config via setup_agent, our approval handler
if (!activeAgentId) { // prefills the form locally — it does NOT persist anything.
const created = await ensureAgent();
if (!created) {
return;
}
activeAgentId = created.id;
}
await ensureThreadExists(); await ensureThreadExists();
await sendMessage( await sendMessage(threadId, { text: trimmed, files: [] });
threadId,
{ text: trimmed, files: [] },
{ agent_id: activeAgentId },
);
}, },
[agentId, ensureAgent, ensureThreadExists, sendMessage, thread.isLoading, threadId], [ensureThreadExists, sendMessage, thread.isLoading, threadId],
); );
const handleApproveSetupAgent = useCallback( const handleApproveSetupAgent = useCallback(
async (payload: SetupAgentApprovalPayload) => { async (payload: SetupAgentApprovalPayload) => {
if (!agentId) { // Approving the model's setup_agent proposal **does not** persist
return; // anything — it only mirrors the proposal into the form so the user
} // can review it. Creation/update happens later when the user clicks
// Approval persists directly to the backend — no LLM round-trip. The // the top-right Save button.
// backend's PUT /api/agents/{id} writes both the DB record and SOUL.md /
// config.yaml on disk, which is exactly what setup_agent_tool would have
// done if we'd let the model re-invoke it.
const proposedName = payload.name?.trim() ?? ""; const proposedName = payload.name?.trim() ?? "";
const newDescription = payload.description ?? ""; const newDescription = payload.description ?? "";
const newSoul = payload.soul ?? ""; const newSoul = payload.soul ?? "";
const newSkills = payload.skills ?? []; const newSkills = payload.skills ?? [];
// Prefer the model's proposed name when present; fall back to whatever if (proposedName) {
// the user already typed, or the existing agent record. setNameInput(proposedName);
const resolvedName = proposedName || nameInput.trim() || agent?.name || agentId; }
setNameInput(resolvedName);
setDescriptionInput(newDescription); setDescriptionInput(newDescription);
setSoulInput(newSoul); setSoulInput(newSoul);
setSelectedSkills(newSkills); setSelectedSkills(newSkills);
setIsSkillSelectionTouched(true); setIsSkillSelectionTouched(true);
toast.success("已套用模型建议,可继续编辑或点保存");
try {
const updated = await updateAgent(agentId, {
name: resolvedName,
description: newDescription,
soul: newSoul,
skills: newSkills,
});
setAgent(updated);
toast.success("专家配置已保存");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
}, },
[agent, agentId, nameInput], [],
); );
const handleRejectSetupAgent = useCallback( const handleRejectSetupAgent = useCallback(
@@ -356,7 +333,28 @@ export default function NewAgentPage() {
onSetupAgentReject={handleRejectSetupAgent} onSetupAgentReject={handleRejectSetupAgent}
/> />
</div> </div>
<div className="bg-background shrink-0 border-t px-4 py-4"> <div className="bg-background shrink-0 space-y-2 border-t px-4 py-4">
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">
当前对话模型
</span>
<Select
value={chatModelInput}
onValueChange={setChatModelInput}
disabled={isModelsLoading || thread.isLoading}
>
<SelectTrigger className="h-7 w-auto min-w-[10rem] gap-2 px-2 text-xs">
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.name} value={model.name}>
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<PromptInput <PromptInput
onSubmit={({ text }) => void handleChatSubmit(text)} onSubmit={({ text }) => void handleChatSubmit(text)}
> >
@@ -405,13 +403,36 @@ export default function NewAgentPage() {
/> />
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium">模型</label>
<Select
value={modelInput}
onValueChange={setModelInput}
disabled={isModelsLoading}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择模型" />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.name} value={model.name}>
{model.display_name || model.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">
该专家被使用时调用的模型。
</p>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium">SOUL.md</label> <label className="text-sm font-medium">SOUL.md</label>
<Textarea <Textarea
placeholder="写下专家的人设、边界、工作方式和输出要求" placeholder="写下专家的人设、边界、工作方式和输出要求"
value={soulInput} value={soulInput}
onChange={(event) => setSoulInput(event.currentTarget.value)} onChange={(event) => setSoulInput(event.currentTarget.value)}
className="min-h-48 resize-y font-mono text-xs" className="min-h-48 max-h-[400px] resize-y overflow-y-auto font-mono text-xs"
/> />
</div> </div>
@@ -422,7 +443,7 @@ export default function NewAgentPage() {
已选 {selectedSkills.length} 个 已选 {selectedSkills.length} 个
</span> </span>
</div> </div>
<div className="rounded-md border p-2"> <div className="max-h-[240px] overflow-y-auto rounded-md border p-2">
{isSkillsLoading ? ( {isSkillsLoading ? (
<p className="text-muted-foreground text-sm">技能加载中...</p> <p className="text-muted-foreground text-sm">技能加载中...</p>
) : enabledSkills.length === 0 ? ( ) : enabledSkills.length === 0 ? (
+33 -16
View File
@@ -4,29 +4,46 @@ import { Navigate, Route, Routes } from "react-router-dom";
import { PageSidebar } from "@/components/page-sidebar"; import { PageSidebar } from "@/components/page-sidebar";
import WorkspaceRoutes from "./WorkspaceRoutes"; import WorkspaceRoutes from "./WorkspaceRoutes";
import StrategyRoutes from "./StrategyRoutes";
import Header from "@/strategy-components/Header";
// 设为 true 时隐藏侧边栏(开发中,暂未完成)
const SIDEBAR_ENABLED = false;
function PageLayout() { function PageLayout() {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
return ( return (
<div className="min-h-screen bg-muted/20"> <div className="h-screen flex bg-muted/20">
<PageSidebar {SIDEBAR_ENABLED && (
collapsed={collapsed} <PageSidebar
onToggleCollapsed={() => setCollapsed((prev) => !prev)} collapsed={collapsed}
/> onToggleCollapsed={() => setCollapsed((prev) => !prev)}
<main />
className={`min-h-screen transition-[padding] duration-300 ${ )}
collapsed ? "pl-16" : "pl-16 md:pl-64" <div
className={`flex flex-col flex-1 overflow-hidden transition-[padding] duration-300 ${
!SIDEBAR_ENABLED ? "" : collapsed ? "pl-16" : "pl-64"
}`} }`}
> >
<Routes> {SIDEBAR_ENABLED && <Header />}
<Route index element={<Navigate to="workspace" replace />} /> <main className="flex-1 overflow-hidden">
<Route <Routes>
path="workspace/*" <Route index element={<Navigate to="workspace" replace />} />
element={<WorkspaceRoutes pageSidebarCollapsed={collapsed} />} <Route
/> path="workspace/*"
</Routes> element={
</main> <WorkspaceRoutes
pageSidebarCollapsed={collapsed}
pageSidebarHidden={!SIDEBAR_ENABLED}
headerHidden={!SIDEBAR_ENABLED}
/>
}
/>
<Route path="strategy/*" element={<StrategyRoutes />} />
</Routes>
</main>
</div>
</div> </div>
); );
} }
@@ -0,0 +1,299 @@
"use client";
import { useState } from "react";
import {
GripVerticalIcon,
Loader2Icon,
PencilIcon,
PlusIcon,
Trash2Icon,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
useCreateRecommendedQuestion,
useDeleteRecommendedQuestion,
useRecommendedQuestions,
useReorderRecommendedQuestions,
useUpdateRecommendedQuestion,
} from "@/core/recommended-questions/hooks";
import type { RecommendedQuestion } from "@/core/recommended-questions";
type DialogState =
| { mode: "add" }
| { mode: "edit"; question: RecommendedQuestion }
| null;
export default function RecommendedQuestionsPage() {
const { questions, isLoading, error } = useRecommendedQuestions();
const createMutation = useCreateRecommendedQuestion();
const updateMutation = useUpdateRecommendedQuestion();
const deleteMutation = useDeleteRecommendedQuestion();
const reorderMutation = useReorderRecommendedQuestions();
const [dialog, setDialog] = useState<DialogState>(null);
const [formTitle, setFormTitle] = useState("");
const [formContent, setFormContent] = useState("");
const [deleteTarget, setDeleteTarget] = useState<RecommendedQuestion | null>(null);
// drag state
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
function openAdd() {
setFormTitle("");
setFormContent("");
setDialog({ mode: "add" });
}
function openEdit(q: RecommendedQuestion) {
setFormTitle(q.title);
setFormContent(q.content);
setDialog({ mode: "edit", question: q });
}
function closeDialog() {
setDialog(null);
}
async function handleSave() {
const title = formTitle.trim();
const content = formContent.trim();
if (!title || !content) {
toast.error("标题和内容不能为空");
return;
}
try {
if (dialog?.mode === "add") {
await createMutation.mutateAsync({
title,
content,
sort_order: questions.length,
});
toast.success("问题已添加");
} else if (dialog?.mode === "edit") {
await updateMutation.mutateAsync({
id: dialog.question.id,
body: { title, content },
});
toast.success("问题已更新");
}
closeDialog();
} catch (e) {
toast.error(e instanceof Error ? e.message : "操作失败");
}
}
async function handleDelete(q: RecommendedQuestion) {
try {
await deleteMutation.mutateAsync(q.id);
setDeleteTarget(null);
toast.success("问题已删除");
} catch (e) {
toast.error(e instanceof Error ? e.message : "删除失败");
}
}
function handleDragStart(index: number) {
setDragIndex(index);
}
function handleDragOver(e: React.DragEvent, index: number) {
e.preventDefault();
setDragOverIndex(index);
}
async function handleDrop(toIndex: number) {
if (dragIndex === null || dragIndex === toIndex) {
setDragIndex(null);
setDragOverIndex(null);
return;
}
const next = [...questions];
const [moved] = next.splice(dragIndex, 1);
next.splice(toIndex, 0, moved!);
setDragIndex(null);
setDragOverIndex(null);
try {
await reorderMutation.mutateAsync(next.map((q) => q.id));
} catch {
toast.error("排序保存失败");
}
}
const isSaving =
createMutation.isPending ||
updateMutation.isPending ||
deleteMutation.isPending ||
reorderMutation.isPending;
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-6 py-10">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold">推荐问题配置</h1>
<p className="text-muted-foreground mt-1 text-sm">
管理首页展示的推荐问题,点击后直接发送到对话。拖拽可调整顺序。
</p>
</div>
<Button onClick={openAdd} className="shrink-0" disabled={isLoading}>
<PlusIcon className="mr-1.5 size-4" />
添加问题
</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2Icon className="text-muted-foreground size-6 animate-spin" />
</div>
) : error ? (
<div className="text-destructive flex flex-col items-center gap-2 rounded-xl border border-dashed py-16 text-sm">
<p>加载失败:{error.message}</p>
</div>
) : questions.length === 0 ? (
<div className="text-muted-foreground flex flex-col items-center gap-3 rounded-xl border border-dashed py-16 text-sm">
<p>暂无推荐问题</p>
<Button variant="outline" size="sm" onClick={openAdd}>
<PlusIcon className="mr-1.5 size-4" />
添加第一个问题
</Button>
</div>
) : (
<div className="flex flex-col gap-2">
{questions.map((q, index) => (
<div
key={q.id}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={() => handleDrop(index)}
onDragEnd={() => {
setDragIndex(null);
setDragOverIndex(null);
}}
className={`flex items-start gap-3 rounded-xl border bg-white px-4 py-3.5 transition-colors ${
dragOverIndex === index && dragIndex !== index
? "border-blue-400 bg-blue-50"
: "border-[#e4e4e7]"
}`}
>
<GripVerticalIcon className="text-muted-foreground/40 mt-0.5 size-4 shrink-0 cursor-grab active:cursor-grabbing" />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-[#18181b]">
{q.title}
</div>
<div className="text-muted-foreground mt-0.5 line-clamp-2 text-xs">
{q.content}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => openEdit(q)}
disabled={isSaving}
>
<PencilIcon className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive size-7"
onClick={() => setDeleteTarget(q)}
disabled={isSaving}
>
<Trash2Icon className="size-3.5" />
</Button>
</div>
</div>
))}
</div>
)}
{/* 添加 / 编辑弹窗 */}
<Dialog open={dialog !== null} onOpenChange={(o) => !o && closeDialog()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{dialog?.mode === "add" ? "添加推荐问题" : "编辑推荐问题"}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">卡片标题</p>
<Input
placeholder="例:市场分析报告"
value={formTitle}
onChange={(e) => setFormTitle(e.target.value)}
/>
</div>
<div className="flex flex-col gap-1.5">
<p className="text-sm font-medium">发送内容</p>
<Textarea
placeholder="点击卡片后实际发送给 AI 的消息内容"
rows={4}
value={formContent}
onChange={(e) => setFormContent(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={closeDialog} disabled={isSaving}>
取消
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving && <Loader2Icon className="mr-1.5 size-4 animate-spin" />}
{dialog?.mode === "add" ? "添加" : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认弹窗 */}
<Dialog
open={deleteTarget !== null}
onOpenChange={(o) => !o && setDeleteTarget(null)}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>确认删除</DialogTitle>
</DialogHeader>
<p className="text-muted-foreground text-sm">
确定要删除「{deleteTarget?.title}」吗?此操作不可撤销。
</p>
<DialogFooter>
<Button
variant="outline"
onClick={() => setDeleteTarget(null)}
disabled={isSaving}
>
取消
</Button>
<Button
variant="destructive"
disabled={isSaving}
onClick={() => deleteTarget && handleDelete(deleteTarget)}
>
{deleteMutation.isPending && (
<Loader2Icon className="mr-1.5 size-4 animate-spin" />
)}
删除
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -2,7 +2,8 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { ChevronDown, Download, FileText, ListChecks, Trash2 } from "lucide-react"; import { ChevronDown, Download, FileText, ListChecks, Trash2 } from "lucide-react";
import { useNavigate, useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useRouteNavigate as useNavigate } from "@/core/route-base";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -17,7 +17,7 @@ import {
RefreshCw, RefreshCw,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { useNavigate } from "react-router-dom"; import { useRouteNavigate as useNavigate } from "@/core/route-base";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
+95
View File
@@ -0,0 +1,95 @@
import { Navigate, Route, Routes } from "react-router-dom";
import type { ReactNode } from "react";
import { Toaster } from "sonner";
import { getStoredAuth } from "@/core/auth";
import { RouteBaseProvider } from "@/core/route-base";
import { SubtasksProvider } from "@/core/tasks/context";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import GeneralQA from "@/strategy-components/components/qa/GeneralQA";
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 (
<RouteBaseProvider value="/page/strategy">
<div className="h-full w-full overflow-hidden">
<Routes>
<Route index element={<Navigate to="qa/general" replace />} />
{/* 问答中心 */}
<Route path="qa/general" element={<GeneralQA />} />
{/* 对话 */}
<Route path="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>
<Toaster position="top-center" />
</RouteBaseProvider>
);
}
+49 -13
View File
@@ -1,6 +1,8 @@
import type { CSSProperties, ReactNode } from "react"; import type { CSSProperties, ReactNode } from "react";
import { Navigate, Route, Routes } from "react-router-dom"; import { Navigate, Route, Routes } from "react-router-dom";
import { getStoredAuth } from "@/core/auth";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input"; import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar"; import { WorkspaceSidebar } from "@/components/workspace/workspace-sidebar";
@@ -9,6 +11,8 @@ import { Toaster } from "sonner";
import { SubtasksProvider } from "@/core/tasks/context"; import { SubtasksProvider } from "@/core/tasks/context";
import { ArtifactsProvider } from "@/components/workspace/artifacts"; import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { RouteBaseProvider } from "@/core/route-base";
import ChatsPage from "./ChatsPage"; import ChatsPage from "./ChatsPage";
import ChatPage from "./ChatPage"; import ChatPage from "./ChatPage";
import AgentsPage from "./AgentsPage"; import AgentsPage from "./AgentsPage";
@@ -19,20 +23,32 @@ import ScheduledTasksPage from "./ScheduledTasksPage";
import SkillEvolutionPage from "./SkillEvolutionPage"; import SkillEvolutionPage from "./SkillEvolutionPage";
import SkillsPage from "./SkillsPage"; import SkillsPage from "./SkillsPage";
import UsersPage from "./UsersPage"; import UsersPage from "./UsersPage";
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
function WorkspaceLayout({ export function WorkspaceLayout({
children, children,
pageSidebarCollapsed, pageSidebarCollapsed,
pageSidebarHidden = false,
headerHidden = false,
}: { }: {
children: ReactNode; children: ReactNode;
pageSidebarCollapsed: boolean; pageSidebarCollapsed: boolean;
pageSidebarHidden?: boolean;
headerHidden?: boolean;
}) { }) {
const pageSidebarWidth = pageSidebarHidden
? "0rem"
: pageSidebarCollapsed
? "4rem"
: "16rem";
return ( return (
<SidebarProvider <SidebarProvider
className="h-screen w-full overflow-x-hidden [&_[data-slot=sidebar-container]]:left-[var(--page-sidebar-width)]" className="h-full w-full overflow-x-hidden [&_[data-slot=sidebar-container]]:left-[var(--page-sidebar-width)]"
style={ style={
{ {
"--page-sidebar-width": pageSidebarCollapsed ? "4rem" : "16rem", "--page-sidebar-width": pageSidebarWidth,
"--header-height": headerHidden ? "0rem" : "4rem",
} as CSSProperties } as CSSProperties
} }
// Keep the fixed workspace sidebar aligned to the outer page sidebar width. // Keep the fixed workspace sidebar aligned to the outer page sidebar width.
@@ -60,16 +76,23 @@ function ChatRuntime({ children }: { children: ReactNode }) {
export default function WorkspaceRoutes({ export default function WorkspaceRoutes({
pageSidebarCollapsed = false, pageSidebarCollapsed = false,
pageSidebarHidden = false,
headerHidden = false,
}: { }: {
pageSidebarCollapsed?: boolean; pageSidebarCollapsed?: boolean;
pageSidebarHidden?: boolean;
headerHidden?: boolean;
}) { }) {
const layoutProps = { pageSidebarCollapsed, pageSidebarHidden, headerHidden };
return ( return (
<RouteBaseProvider value="/page/workspace">
<Routes> <Routes>
<Route index element={<Navigate to="chats/new" replace />} /> <Route index element={<Navigate to="chats/new" replace />} />
<Route <Route
path="chats" path="chats"
element={ element={
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}> <WorkspaceLayout {...layoutProps}>
<ChatsPage /> <ChatsPage />
</WorkspaceLayout> </WorkspaceLayout>
} }
@@ -77,7 +100,7 @@ export default function WorkspaceRoutes({
<Route <Route
path="chats/:thread_id" path="chats/:thread_id"
element={ element={
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}> <WorkspaceLayout {...layoutProps}>
<ChatRuntime> <ChatRuntime>
<ChatPage /> <ChatPage />
</ChatRuntime> </ChatRuntime>
@@ -87,7 +110,7 @@ export default function WorkspaceRoutes({
<Route <Route
path="agents" path="agents"
element={ element={
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}> <WorkspaceLayout {...layoutProps}>
<AgentsPage /> <AgentsPage />
</WorkspaceLayout> </WorkspaceLayout>
} }
@@ -95,7 +118,7 @@ export default function WorkspaceRoutes({
<Route <Route
path="agents/new" path="agents/new"
element={ element={
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}> <WorkspaceLayout {...layoutProps}>
<NewAgentPage /> <NewAgentPage />
</WorkspaceLayout> </WorkspaceLayout>
} }
@@ -107,7 +130,7 @@ export default function WorkspaceRoutes({
<Route <Route
path="agents/:agent_id/chats/:thread_id" path="agents/:agent_id/chats/:thread_id"
element={ element={
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}> <WorkspaceLayout {...layoutProps}>
<ChatRuntime> <ChatRuntime>
<AgentChatPage /> <AgentChatPage />
</ChatRuntime> </ChatRuntime>
@@ -117,7 +140,7 @@ export default function WorkspaceRoutes({
<Route <Route
path="skills" path="skills"
element={ element={
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}> <WorkspaceLayout {...layoutProps}>
<SkillsPage /> <SkillsPage />
</WorkspaceLayout> </WorkspaceLayout>
} }
@@ -125,7 +148,7 @@ export default function WorkspaceRoutes({
<Route <Route
path="skills/evolution" path="skills/evolution"
element={ element={
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}> <WorkspaceLayout {...layoutProps}>
<SkillEvolutionPage /> <SkillEvolutionPage />
</WorkspaceLayout> </WorkspaceLayout>
} }
@@ -137,7 +160,7 @@ export default function WorkspaceRoutes({
<Route <Route
path="scheduled/tasks" path="scheduled/tasks"
element={ element={
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}> <WorkspaceLayout {...layoutProps}>
<ScheduledTasksPage /> <ScheduledTasksPage />
</WorkspaceLayout> </WorkspaceLayout>
} }
@@ -145,7 +168,7 @@ export default function WorkspaceRoutes({
<Route <Route
path="scheduled/runs/:run_id" path="scheduled/runs/:run_id"
element={ element={
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}> <WorkspaceLayout {...layoutProps}>
<ScheduledTaskRunDetailPage /> <ScheduledTaskRunDetailPage />
</WorkspaceLayout> </WorkspaceLayout>
} }
@@ -153,11 +176,24 @@ export default function WorkspaceRoutes({
<Route <Route
path="admin/users" path="admin/users"
element={ element={
<WorkspaceLayout pageSidebarCollapsed={pageSidebarCollapsed}> <WorkspaceLayout {...layoutProps}>
<UsersPage /> <UsersPage />
</WorkspaceLayout> </WorkspaceLayout>
} }
/> />
<Route
path="settings/recommended-questions"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<RecommendedQuestionsPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
</Routes> </Routes>
</RouteBaseProvider>
); );
} }
+9 -4
View File
@@ -1,4 +1,4 @@
import { useMemo } from "react"; import { useContext, useMemo } from "react";
import { import {
useLocation, useLocation,
useNavigate, useNavigate,
@@ -6,19 +6,24 @@ import {
useSearchParams as useRouterSearchParams, useSearchParams as useRouterSearchParams,
} from "react-router-dom"; } from "react-router-dom";
import { RouteBaseContext } from "@/core/route-base";
export function useRouter() { export function useRouter() {
const navigate = useNavigate(); const navigate = useNavigate();
const base = useContext(RouteBaseContext);
return useMemo( return useMemo(
() => ({ () => ({
push: (href: string) => navigate(href), push: (href: string) =>
replace: (href: string) => navigate(href, { replace: true }), navigate(href.replace("/page/workspace", base)),
replace: (href: string) =>
navigate(href.replace("/page/workspace", base), { replace: true }),
back: () => navigate(-1), back: () => navigate(-1),
forward: () => navigate(1), forward: () => navigate(1),
refresh: () => window.location.reload(), refresh: () => window.location.reload(),
prefetch: async () => {}, prefetch: async () => {},
}), }),
[navigate], [navigate, base],
); );
} }
@@ -0,0 +1,77 @@
import React from 'react';
import { Bell, Search, User, ChevronDown, Moon, Palette } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext';
const Header: React.FC = () => {
const { theme, toggleTheme } = useTheme();
const isDark = theme === 'dark';
return (
<header className={`h-16 flex items-center justify-between px-6 theme-transition ${
isDark
? 'bg-slate-900/50 backdrop-blur-xl border-b border-blue-800/30'
: 'bg-red-600 border-b border-red-700'
}`}>
{/* 左侧 - 搜索框 */}
<div className="flex items-center gap-4">
<div className="relative">
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${isDark ? 'text-slate-500' : 'text-red-200'}`} />
<input
type="text"
placeholder="搜索场景、智能体、任务..."
className={`w-80 pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-white/20 transition-all duration-200 theme-transition ${
isDark
? 'bg-slate-800/50 border border-blue-800/30 text-white placeholder-slate-500'
: 'bg-red-700 border border-red-800 text-white placeholder-red-200'
}`}
/>
</div>
</div>
{/* 右侧 - 用户信息和通知 */}
<div className="flex items-center gap-4">
{/* 主题切换按钮 */}
<button
onClick={toggleTheme}
className={`p-2 rounded-lg transition-all duration-200 ${
isDark
? 'hover:bg-slate-800/50 text-slate-400 hover:text-white'
: 'hover:bg-red-500 text-white'
}`}
title={isDark ? '切换到红白主题' : '切换到深色主题'}
>
{isDark ? <Palette className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
</button>
{/* 通知 */}
<button className={`relative p-2 rounded-lg transition-colors ${
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
}`}>
<Bell className={`w-5 h-5 ${isDark ? 'text-slate-400' : 'text-white'}`} />
<span className={`absolute top-1 right-1 w-2 h-2 rounded-full ${isDark ? 'bg-cyan-500' : 'bg-white'}`} />
</button>
{/* 分隔线 */}
<div className={`w-px h-8 ${isDark ? 'bg-blue-800/30' : 'bg-red-500'}`} />
{/* 用户信息 */}
<button className={`flex items-center gap-3 px-3 py-1.5 rounded-lg transition-colors ${
isDark ? 'hover:bg-slate-800/50' : 'hover:bg-red-500'
}`}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
isDark ? 'bg-gradient-to-br from-blue-500 to-cyan-400' : 'bg-white'
}`}>
<User className={`w-4 h-4 ${isDark ? 'text-white' : 'text-red-600'}`} />
</div>
<div className="text-left">
<p className="text-sm font-medium text-white">张策略</p>
<p className={`text-xs ${isDark ? 'text-blue-400/60' : 'text-red-200'}`}>策略分析师</p>
</div>
<ChevronDown className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-white'}`} />
</button>
</div>
</header>
);
};
export default Header;
@@ -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;
@@ -0,0 +1,575 @@
import React, { useState } from 'react';
import {
Search,
History,
Bot,
Sparkles,
Database,
Layers,
FileText,
ChevronRight,
ChevronLeft,
Cpu,
Target,
Zap,
CheckCircle,
} from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
import { ChatMessage, EmptyChatState, QAComposer, TypingIndicator } from './QACommon';
interface Agent {
id: string;
name: string;
category: string;
tags: string[];
description: string;
skills: string[];
dataSources: string[];
functions: string[];
outputFields: string[];
}
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
agentName?: string;
skills?: string[];
dataSources?: string[];
functions?: string[];
structuredOutput?: Record<string, any>;
}
/**
* 智能体问答组件
* 基于特定智能体的专业能力进行问答,支持选择不同领域的智能体
*/
const AgentQA: React.FC = () => {
const { theme } = useTheme();
const isDark = theme === 'dark';
// 模拟智能体列表
const agents: Agent[] = [
{
id: 'audience-agent',
name: 'AudienceAgent',
category: '受众分析',
tags: ['受众分层', '画像抽取', '行为预测'],
description: '专业的受众分析智能体,能够识别目标受众特征并生成画像',
skills: ['受众分层', '核心受众识别', '画像抽取', '行为预测'],
dataSources: ['知识库', 'Y系统', 'A系统'],
functions: ['信息抽取器', '标签聚类器', '结构化输出器'],
outputFields: ['core_audience', 'key_traits', 'barriers', 'opportunities'],
},
{
id: 'brand-agent',
name: 'BrandAgent',
category: '品牌分析',
tags: ['品牌定位', '传播策略', '调性分析'],
description: '专注品牌建设与传播策略的智能体',
skills: ['品牌定位分析', '传播路径规划', '品牌调性设计'],
dataSources: ['知识库', '行业数据库'],
functions: ['品牌诊断器', '策略生成器'],
outputFields: ['brand_position', 'communication_path', 'tone_guide'],
},
{
id: 'content-agent',
name: 'ContentAgent',
category: '内容创作',
tags: ['文案撰写', '创意生成', '多形式内容'],
description: '智能内容创作助手,支持多种形式的内容生成',
skills: ['文案撰写', '创意生成', '多形式内容', 'A/B测试文案'],
dataSources: ['知识库', '优秀案例库'],
functions: ['文案生成器', '风格适配器', '质量评估器'],
outputFields: ['headlines', 'body_copy', 'call_to_action', 'variants'],
},
{
id: 'channel-agent',
name: 'ChannelAgent',
category: '渠道规划',
tags: ['渠道选择', '投放策略', '效果评估'],
description: '专业的渠道分析与规划智能体',
skills: ['渠道选择', '投放策略规划', '效果评估', '预算分配'],
dataSources: ['Y系统', 'A系统', '行业数据'],
functions: ['渠道分析器', '投放优化器', 'ROI预测器'],
outputFields: ['recommended_channels', 'budget_allocation', 'expected_roi'],
},
];
// 状态管理
// 当前选中的智能体
const [selectedAgent, setSelectedAgent] = useState<Agent>(agents[0]);
// 智能体搜索关键词
const [searchQuery, setSearchQuery] = useState('');
// 聊天消息列表
const [messages, setMessages] = useState<Message[]>([]);
// 输入框内容
const [inputValue, setInputValue] = useState('');
// 加载状态
const [isLoading, setIsLoading] = useState(false);
// 左侧侧边栏折叠状态
const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false);
// 过滤智能体
const filteredAgents = agents.filter(agent =>
agent.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
agent.category.toLowerCase().includes(searchQuery.toLowerCase()) ||
agent.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
);
/**
* 处理发送消息
* 发送用户消息并模拟智能体响应
*/
const handleSend = () => {
if (!inputValue.trim()) return;
setIsLoading(true);
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: inputValue,
timestamp: new Date(),
};
setMessages([...messages, userMessage]);
setInputValue('');
// 模拟AI响应
setTimeout(() => {
const response: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: `基于${selectedAgent.name}的专业能力,我来为您分析:\n\n根据您的问题,我识别到核心受众特征如下:\n\n**核心受众识别**:\n- 主要年龄段:25-35岁\n- 职业特征:都市白领\n- 消费能力:中高端\n- 关注点:品质生活、个人成长\n\n**关键特质**:\n1. 追求个性化表达\n2. 注重社交认同\n3. 愿意为品质付费\n\n**可能存在的阻力**:\n- 价格敏感度较高\n- 对品牌忠诚度有限\n\n以上分析是否满足您的需求?如需调整维度,请继续补充信息。`,
timestamp: new Date(),
agentName: selectedAgent.name,
skills: selectedAgent.skills,
dataSources: selectedAgent.dataSources,
functions: selectedAgent.functions,
structuredOutput: {
core_audience: '25-35岁都市白领',
key_traits: ['追求个性化', '注重社交认同', '品质导向'],
barriers: ['价格敏感', '品牌忠诚度有限'],
opportunities: ['社交媒体影响大', '口碑传播效果好'],
},
};
setMessages(prev => [...prev, response]);
setIsLoading(false);
}, 1500);
};
/**
* 处理停止操作
* 停止当前正在进行的智能体响应
*/
const handleStop = () => {
setIsLoading(false);
};
/**
* 处理键盘事件
* 按Enter键发送消息(不按Shift)
*/
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
/**
* 保存问答记录
*/
const handleSaveRecord = () => {
alert('问答记录已保存');
};
/**
* 收藏当前对话
*/
const handleCollect = () => {
alert('已添加到收藏');
};
return (
<div className={`h-[calc(100vh-120px)] flex flex-col theme-bg rounded-xl ${isDark ? 'bg-slate-800/50' : 'bg-white'} border ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
{/* 顶部工具栏 */}
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className="flex items-center gap-4">
<h1 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
智能体问答
</h1>
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg ${isDark ? 'bg-slate-700/50' : 'bg-red-50'}`}>
<Bot className={`w-4 h-4 ${isDark ? 'text-blue-400' : 'text-red-500'}`} />
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
当前智能体:
</span>
<span className={`text-sm font-medium ${isDark ? 'text-blue-400' : 'text-red-600'}`}>
{selectedAgent.name}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
isDark
? 'bg-slate-700/50 text-slate-300 hover:bg-slate-600/50'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<History className="w-4 h-4" />
历史记录
</button>
</div>
</div>
{/* 主体内容区 */}
<div className="flex-1 flex overflow-hidden">
{/* 左侧智能体区 */}
<div className={`${isLeftSidebarCollapsed ? 'w-14 p-2' : 'w-72'} border-r flex-shrink-0 overflow-y-auto transition-all duration-300 ${isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/50'}`}>
<div className={`flex items-center ${isLeftSidebarCollapsed ? 'justify-center' : 'justify-between'} p-2 ${isLeftSidebarCollapsed ? '' : 'px-4 pt-4 pb-0'}`}>
{!isLeftSidebarCollapsed && (
<span className={`text-xs font-medium uppercase tracking-wider ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
智能体配置
</span>
)}
<button
type="button"
onClick={() => setIsLeftSidebarCollapsed(!isLeftSidebarCollapsed)}
className={`p-2 rounded-lg transition-all ${
isDark
? 'text-slate-400 hover:text-white hover:bg-slate-700/50'
: 'text-gray-500 hover:text-red-600 hover:bg-red-50'
}`}
title={isLeftSidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}
>
{isLeftSidebarCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
</div>
{!isLeftSidebarCollapsed && (<>
{/* 智能体搜索 */}
<div className="p-4">
<div className={`relative ${isDark ? 'bg-slate-700/50' : 'bg-white'} rounded-lg`}>
<Search className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${isDark ? 'text-slate-400' : 'text-gray-400'}`} />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索智能体..."
className={`w-full pl-10 pr-4 py-2 rounded-lg text-sm focus:outline-none ${
isDark
? 'bg-transparent text-white placeholder-slate-500'
: 'bg-transparent text-gray-800 placeholder-gray-400'
}`}
/>
</div>
</div>
{/* 智能体列表 */}
<div className="px-4 pb-4">
<h3 className={`text-xs font-medium uppercase tracking-wider mb-3 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
智能体列表
</h3>
<div className="space-y-2">
{filteredAgents.map((agent) => (
<button
key={agent.id}
onClick={() => setSelectedAgent(agent)}
className={`w-full text-left p-3 rounded-lg transition-all ${
selectedAgent.id === agent.id
? isDark
? 'bg-blue-600/20 border border-blue-500/50'
: 'bg-red-50 border border-red-200'
: isDark
? 'bg-slate-700/30 border border-transparent hover:bg-slate-700/50'
: 'bg-white border border-gray-200 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${
selectedAgent.id === agent.id
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'bg-slate-600 text-slate-300'
: 'bg-gray-100 text-gray-600'
}`}>
<Bot className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-gray-900'}`}>
{agent.name}
</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{agent.category}
</div>
</div>
<ChevronRight className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-gray-400'}`} />
</div>
</button>
))}
</div>
</div>
{/* 当前智能体能力摘要 */}
<div className={`p-4 border-t ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<h3 className={`text-xs font-medium uppercase tracking-wider mb-3 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
能力摘要
</h3>
{/* 核心职责 */}
<div className="mb-4">
<div className={`flex items-center gap-2 mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Target className="w-4 h-4" />
<span className="text-xs">核心职责</span>
</div>
<p className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
{selectedAgent.description}
</p>
</div>
{/* 已启用技能 */}
<div className="mb-4">
<div className={`flex items-center gap-2 mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Sparkles className="w-4 h-4" />
<span className="text-xs">已启用技能</span>
</div>
<div className="flex flex-wrap gap-1">
{selectedAgent.skills.map((skill, idx) => (
<span
key={idx}
className={`px-2 py-0.5 rounded text-xs ${
isDark ? 'bg-blue-600/30 text-blue-400' : 'bg-blue-100 text-blue-600'
}`}
>
{skill}
</span>
))}
</div>
</div>
{/* 可访问数据源 */}
<div className="mb-4">
<div className={`flex items-center gap-2 mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Database className="w-4 h-4" />
<span className="text-xs">可访问数据源</span>
</div>
<div className="flex flex-wrap gap-1">
{selectedAgent.dataSources.map((source, idx) => (
<span
key={idx}
className={`px-2 py-0.5 rounded text-xs ${
isDark ? 'bg-green-600/30 text-green-400' : 'bg-green-100 text-green-600'
}`}
>
{source}
</span>
))}
</div>
</div>
{/* 可调用功能 */}
<div>
<div className={`flex items-center gap-2 mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Layers className="w-4 h-4" />
<span className="text-xs">可调用功能</span>
</div>
<div className="flex flex-wrap gap-1">
{selectedAgent.functions.map((func, idx) => (
<span
key={idx}
className={`px-2 py-0.5 rounded text-xs ${
isDark ? 'bg-purple-600/30 text-purple-400' : 'bg-purple-100 text-purple-600'
}`}
>
{func}
</span>
))}
</div>
</div>
</div>
</>)}
</div>
{/* 中间对话区 */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* 消息列表 */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 ? (
<EmptyChatState
icon={<Bot className="w-11 h-11 opacity-60" />}
title={`开始与${selectedAgent.name}对话`}
description="基于智能体专业能力获取精准回答"
/>
) : (
messages.map((msg) => (
<ChatMessage
key={msg.id}
message={msg}
assistantLabel={msg.agentName || selectedAgent.name}
renderAssistantExtra={(assistantMessage) => (
<div className={`ml-12 p-3 rounded-xl ${isDark ? 'bg-slate-800/50' : 'bg-gray-50'}`}>
<div className="mb-3">
<h4 className={`text-xs font-medium mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>本轮启用技能</h4>
<div className="flex flex-wrap gap-1">
{assistantMessage.skills?.map((skill, idx) => (
<span key={idx} className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-blue-600/30 text-blue-400' : 'bg-blue-100 text-blue-600'}`}>
{skill}
</span>
))}
</div>
</div>
<div className="mb-3">
<h4 className={`text-xs font-medium mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>本轮访问数据源</h4>
<div className="flex flex-wrap gap-1">
{assistantMessage.dataSources?.map((source, idx) => (
<span key={idx} className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-green-600/30 text-green-400' : 'bg-green-100 text-green-600'}`}>
{source}
</span>
))}
</div>
</div>
<div className="mb-3">
<h4 className={`text-xs font-medium mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>本轮调用功能</h4>
<div className="flex flex-wrap gap-1">
{assistantMessage.functions?.map((func, idx) => (
<span key={idx} className={`px-2 py-0.5 rounded text-xs ${isDark ? 'bg-purple-600/30 text-purple-400' : 'bg-purple-100 text-purple-600'}`}>
{func}
</span>
))}
</div>
</div>
{assistantMessage.structuredOutput && (
<div>
<h4 className={`text-xs font-medium mb-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>结构化输出</h4>
<div className={`p-3 rounded-lg ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
{Object.entries(assistantMessage.structuredOutput).map(([key, value]) => (
<div key={key} className="flex items-start gap-2 mb-1 last:mb-0">
<span className={`text-xs font-mono ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>{key}:</span>
<span className={`text-xs ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>
{Array.isArray(value) ? value.join(', ') : String(value)}
</span>
</div>
))}
</div>
</div>
)}
</div>
)}
/>
))
)}
{isLoading && <TypingIndicator label={`${selectedAgent.name} 正在调用能力生成回复`} assistantLabel={selectedAgent.name} />}
</div>
{/* 底部输入区 */}
<QAComposer
value={inputValue}
onChange={setInputValue}
onKeyDown={handleKeyDown}
onSubmit={handleSend}
onStop={handleStop}
onSave={handleSaveRecord}
onCollect={handleCollect}
isLoading={isLoading}
placeholder="输入问题或任务要求..."
showUpload={false}
/>
</div>
{/* 右侧执行详情区 */}
<div className={`w-72 border-l flex-shrink-0 overflow-y-auto p-4 ${
isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/50'
}`}>
<h3 className={`text-sm font-medium mb-4 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>
执行详情
</h3>
{/* 本轮启用技能 */}
<div className="mb-6">
<div className={`flex items-center gap-2 mb-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Sparkles className="w-4 h-4" />
<span className="text-xs font-medium">本轮启用技能</span>
</div>
<div className={`p-3 rounded-xl ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<div className="space-y-2">
{selectedAgent.skills.map((skill, idx) => (
<div key={idx} className="flex items-center gap-2">
<CheckCircle className={`w-3 h-3 ${isDark ? 'text-blue-400' : 'text-green-500'}`} />
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>{skill}</span>
</div>
))}
</div>
</div>
</div>
{/* 本轮访问数据源 */}
<div className="mb-6">
<div className={`flex items-center gap-2 mb-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Database className="w-4 h-4" />
<span className="text-xs font-medium">本轮访问数据源</span>
</div>
<div className={`p-3 rounded-xl ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<div className="space-y-2">
{selectedAgent.dataSources.map((source, idx) => (
<div key={idx} className="flex items-center gap-2">
<Database className={`w-3 h-3 ${isDark ? 'text-green-400' : 'text-green-500'}`} />
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>{source}</span>
</div>
))}
</div>
</div>
</div>
{/* 本轮调用功能 */}
<div className="mb-6">
<div className={`flex items-center gap-2 mb-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Layers className="w-4 h-4" />
<span className="text-xs font-medium">本轮调用功能</span>
</div>
<div className={`p-3 rounded-xl ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<div className="space-y-2">
{selectedAgent.functions.map((func, idx) => (
<div key={idx} className="flex items-center gap-2">
<Cpu className={`w-3 h-3 ${isDark ? 'text-purple-400' : 'text-purple-500'}`} />
<span className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>{func}</span>
</div>
))}
</div>
</div>
</div>
{/* 结构化输出字段 */}
<div>
<div className={`flex items-center gap-2 mb-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<FileText className="w-4 h-4" />
<span className="text-xs font-medium">输出字段</span>
</div>
<div className={`p-3 rounded-xl ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<div className="space-y-2">
{selectedAgent.outputFields.map((field, idx) => (
<div key={idx} className="flex items-center gap-2">
<Zap className={`w-3 h-3 ${isDark ? 'text-yellow-400' : 'text-yellow-500'}`} />
<span className={`text-xs font-mono ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>{field}</span>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default AgentQA;
@@ -0,0 +1,185 @@
import React, { useState } from 'react';
import { CheckCircle, ChevronLeft, ChevronRight, History, MessageCircle, Users } from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
import { ChatMessage, EmptyChatState, QAComposer, TypingIndicator } from './QACommon';
interface CollaborativeQAProps {}
interface CollaborativeMessage {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
agentName?: string;
}
const collaborativeAgents = [
{ id: 'goal-agent', name: 'GoalAgent', category: '目标规划', color: 'blue' },
{ id: 'audience-agent', name: 'AudienceAgent', category: '受众分析', color: 'green' },
{ id: 'risk-agent', name: 'RiskAgent', category: '风险识别', color: 'purple' },
];
/**
* 多智能体协同问答组件
* 多个智能体从不同视角联合分析问题,生成综合建议
* @param onNavigate - 页面导航函数
*/
const CollaborativeQA: React.FC<CollaborativeQAProps> = () => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [messages, setMessages] = useState<CollaborativeMessage[]>([]);
const [inputValue, setInputValue] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [selectedAgents, setSelectedAgents] = useState(collaborativeAgents.map((agent) => agent.id));
const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false);
const activeAgents = collaborativeAgents.filter((agent) => selectedAgents.includes(agent.id));
const handleSend = () => {
if (!inputValue.trim()) return;
const userMessage: CollaborativeMessage = {
id: `user-${Date.now()}`,
role: 'user',
content: inputValue,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInputValue('');
setIsLoading(true);
setTimeout(() => {
const agentNames = activeAgents.map((agent) => agent.name).join('、');
const assistantMessage: CollaborativeMessage = {
id: `assistant-${Date.now()}`,
role: 'assistant',
content: `已组织 ${agentNames || '默认协同组'} 完成一轮联合研判:\n\n1. GoalAgent 建议先明确任务目标与阶段性 KPI,避免讨论发散。\n2. AudienceAgent 认为应优先锁定核心受众画像,并补充行为触点数据。\n3. RiskAgent 提醒需要同步评估内容合规、舆情扩散和资源约束。\n\n综合建议:先形成“目标-受众-风险”三列表,再进入方案细化与执行拆解。`,
timestamp: new Date(),
agentName: '协同编排助手',
};
setMessages((prev) => [...prev, assistantMessage]);
setIsLoading(false);
}, 1800);
};
const handleStop = () => {
setIsLoading(false);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const toggleAgent = (agentId: string) => {
setSelectedAgents((prev) =>
prev.includes(agentId) ? prev.filter((id) => id !== agentId) : [...prev, agentId]
);
};
return (
<div className={`h-[calc(100vh-120px)] flex flex-col theme-bg rounded-xl ${isDark ? 'bg-slate-800/50' : 'bg-white'} border ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className="flex items-center gap-4">
<h1 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>多智能体协同问答</h1>
<span className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>多角色联合研判、共识汇总与风险补充</span>
</div>
<button
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
isDark ? 'bg-slate-700/50 text-slate-300 hover:bg-slate-600/50' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<History className="w-4 h-4" />
历史记录
</button>
</div>
<div className="flex-1 flex overflow-hidden">
<aside className={`${isLeftSidebarCollapsed ? 'w-14 p-2' : 'w-72 p-4'} border-r flex-shrink-0 overflow-y-auto transition-all duration-300 ${isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/50'}`}>
<div className={`flex items-center ${isLeftSidebarCollapsed ? 'justify-center' : 'justify-between'} mb-4`}>
{!isLeftSidebarCollapsed && (
<h3 className={`text-sm font-medium ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>参与智能体</h3>
)}
<button
type="button"
onClick={() => setIsLeftSidebarCollapsed(!isLeftSidebarCollapsed)}
className={`p-2 rounded-lg transition-all ${
isDark
? 'text-slate-400 hover:text-white hover:bg-slate-700/50'
: 'text-gray-500 hover:text-red-600 hover:bg-red-50'
}`}
title={isLeftSidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}
>
{isLeftSidebarCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
</div>
{!isLeftSidebarCollapsed && (<>
<div className="space-y-2">
{collaborativeAgents.map((agent) => {
const active = selectedAgents.includes(agent.id);
return (
<button
key={agent.id}
onClick={() => toggleAgent(agent.id)}
className={`w-full p-3 rounded-xl text-left border transition-all ${
active
? isDark ? 'bg-blue-600/20 border-blue-500/50' : 'bg-red-50 border-red-200'
: isDark ? 'bg-slate-700/30 border-transparent hover:bg-slate-700/50' : 'bg-white border-gray-200 hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-2xl flex items-center justify-center ${active ? 'bg-gradient-to-br from-red-500 to-orange-400 text-white' : isDark ? 'bg-slate-600 text-slate-300' : 'bg-gray-100 text-gray-600'}`}>
<Users className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-gray-900'}`}>{agent.name}</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{agent.category}</div>
</div>
{active && <CheckCircle className={`w-4 h-4 ${isDark ? 'text-blue-400' : 'text-red-500'}`} />}
</div>
</button>
);
})}
</div>
</>)}
</aside>
<section className="flex-1 flex flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.length === 0 ? (
<EmptyChatState
icon={<MessageCircle className="w-11 h-11 opacity-60" />}
title="发起一次协同会商"
description="输入任务背景,多个智能体会从不同视角生成联合建议"
/>
) : (
messages.map((message) => (
<ChatMessage key={message.id} message={message} assistantLabel={message.agentName || '协同编排助手'} />
))
)}
{isLoading && <TypingIndicator label="多智能体正在协同分析并汇总观点" assistantLabel="协同编排助手" />}
</div>
<QAComposer
value={inputValue}
onChange={setInputValue}
onKeyDown={handleKeyDown}
onSubmit={handleSend}
onStop={handleStop}
onSave={() => alert('问答记录已保存')}
onCollect={() => alert('已添加到收藏')}
isLoading={isLoading}
placeholder="输入协同研判主题或任务背景..."
showUpload={false}
/>
</section>
</div>
</div>
);
};
export default CollaborativeQA;
@@ -0,0 +1,584 @@
import type { QASingleRagTrace, QAThinkingTrace } from './QACommon';
import type { ComparePane, KnowledgeBaseChunk, Message, QAMode, SourceItem } from './GeneralQA.types';
/**
* 解析单轮 RAG 接口的 `<think>...</think>` 流式文本。
*
* 后端会把“思考过程”和“最终回答”混在同一个 answer 字段里返回,
* UI 需要分别展示在 `SingleRagAnswer` 的思考区和答案区。这个函数只做纯文本拆分,
* 不依赖 React 状态,因此既可用于模拟数据,也可作为对比模式里的兜底空 trace。
*/
export const parseSingleRagStream = (chunks: { answer: string }[]): QASingleRagTrace => {
let thinkingMarkdown = '';
let answerMarkdown = '';
let inThink = false;
let thinkingComplete = false;
const appendToken = (token: string) => {
let remaining = token;
while (remaining) {
if (inThink) {
const endIdx = remaining.indexOf('</think>');
if (endIdx === -1) {
thinkingMarkdown += remaining;
break;
}
thinkingMarkdown += remaining.slice(0, endIdx);
inThink = false;
thinkingComplete = true;
remaining = remaining.slice(endIdx + '</think>'.length);
} else {
const startIdx = remaining.indexOf('<think>');
if (startIdx === -1) {
answerMarkdown += remaining;
break;
}
answerMarkdown += remaining.slice(0, startIdx);
inThink = true;
remaining = remaining.slice(startIdx + '<think>'.length);
}
}
};
chunks.forEach((chunk) => appendToken(chunk.answer));
return {
title: '单轮 RAG 思考过程',
thinkingMarkdown,
answerMarkdown,
thinkingComplete,
};
};
/**
* 构造单轮 RAG 的演示 trace。
*
* 首屏默认需要一条完整的示例问答,帮助用户理解“思考过程 + 答案 + 引用”如何展示。
* 这里保留原组件中的演示内容,避免拆分后默认展示行为发生变化。
*/
export const createSingleRagTrace = (): QASingleRagTrace => parseSingleRagStream([
{ answer: '<think>' },
{ answer: '需要回答用户关于 **RAG 工作原理** 的问题。\n\n' },
{ answer: '先根据知识库召回内容组织核心流程:\n\n' },
{ answer: '- 检索阶段:查找相关知识片段\n' },
{ answer: '- 增强阶段:将证据注入提示词\n' },
{ answer: '- 生成阶段:模型基于上下文生成答案\n\n' },
{ answer: '回答需要保持结构清晰,并说明它为什么能降低幻觉。' },
{ answer: '</think>' },
{ answer: 'RAG(Retrieval-Augmented Generation,检索增强生成)是一种把 **知识库检索** 和 **大模型生成** 结合起来的问答方式。\n\n' },
{ answer: '### 工作流程\n\n' },
{ answer: '1. **问题理解**:系统接收用户问题并生成检索查询。\n' },
{ answer: '2. **知识检索**:从知识库中召回相关文档片段。\n' },
{ answer: '3. **上下文增强**:把检索到的内容和用户问题一起放入 Prompt。\n' },
{ answer: '4. **模型生成**:大模型基于增强后的上下文生成回答。\n\n' },
{ answer: '这种方式可以让回答更贴近已有资料,同时提升可追溯性。' },
]);
/**
* 构造多轮/深度检索的演示思考链。
*
* 真实多轮请求会通过 SSE 持续补齐 rounds;深度检索当前仍走本地模拟,
* 因此这个函数保留不同模式下的轮次差异,用于未接入真实接口时的展示。
*/
export const createThinkingTrace = (mode: QAMode): QAThinkingTrace => {
// 初始化轮次数组,第一轮总是基础检索
const rounds = [
{
id: 'round-1', // 第一轮唯一标识
title: '理解问题并拆解检索方向', // 本轮主题:理解问题并确定检索方向
thoughtMarkdown: '用户询问 **RAG 技术工作原理**。需要先明确核心链路:\n\n- 问题理解与向量化\n- 知识库召回\n- 上下文增强\n- 大模型生成\n\n第一轮优先检索基础定义和主流程。', // 模型思考过程,展示分析步骤
query: 'RAG 检索增强生成 工作原理 基础流程', // 发送给检索系统的查询词
retrievals: [ // 第一轮检索返回的资料列表
{
id: 'r1-1',
title: 'RAG技术白皮书',
content: 'RAG 将外部知识检索结果作为上下文注入生成模型,能够降低幻觉并提升答案的时效性和可追溯性。',
source: '知识库 / RAG技术白皮书.pdf',
},
{
id: 'r1-2',
title: 'LLM应用实践:检索增强生成',
content: '典型 RAG 流程包括查询改写、向量检索、片段重排、Prompt 组装和答案生成。',
source: '知识库 / LLM应用实践.docx',
},
],
},
];
// 多轮模式和深度检索模式需要第二轮检索,补充更多细节
if (mode !== 'single-round') {
// 添加第二轮检索:补充多轮检索与重排机制的差异
rounds.push({
id: 'round-2', // 第二轮的唯一标识
title: '补充多轮检索与重排机制', // 本轮检索的主题
thoughtMarkdown: '基础链路已经明确,继续检索 **多轮 RAG** 和 **深度检索** 的差异:\n\n1. 是否需要 Query 改写\n2. 是否需要对子问题分别召回\n3. 是否需要结果重排与证据融合',
query: '多轮 RAG Query 改写 子问题检索 结果重排', // 本轮发送给检索系统的查询词
// 本轮检索返回的资料列表
retrievals: [
{
id: 'r2-1',
title: '高级RAG技术指南',
content: '复杂问题通常需要分解为多个子问题,并对每个子问题分别检索,再通过重排模型筛选高相关证据。',
source: '资料库 / 高级RAG技术指南.md',
},
{
id: 'r2-2',
title: 'RAG召回与重排最佳实践',
content: '重排阶段可使用 Cross Encoder 或 LLM 评分,按相关性、覆盖度和可信度筛选最终上下文。',
source: '行业资料 / RAG最佳实践.html',
},
{
id: 'r2-3',
title: '检索增强系统评测报告',
content: '多阶段检索可提升复杂问题的召回率,但会增加延迟和计算成本,需要在效果与响应速度之间平衡。',
source: '评测库 / 检索增强系统评测报告.xlsx',
},
],
});
}
// 深度检索模式需要第三轮,综合所有证据并形成最终回答结构
if (mode === 'deep-search') {
rounds.push({
id: 'round-3', // 第三轮的唯一标识
title: '综合证据并形成回答结构', // 本轮的主题:整合前面检索到的所有证据,组织成完整的回答
thoughtMarkdown: '当前证据已覆盖定义、流程和优化机制。最后需要组织成清晰结构:\n\n- 一句话定义\n- 分阶段流程\n- 技术价值\n- 适用场景与注意事项',
query: 'RAG 技术价值 幻觉 时效性 可追溯性 应用场景', // 本轮查询:关于 RAG 价值和应用场景的信息
retrievals: [
{
id: 'r3-1',
title: '企业知识问答系统建设方案',
content: 'RAG 在企业知识问答中常用于连接内部文档、制度库和业务数据库,使回答具备来源依据。',
source: '方案库 / 企业知识问答系统建设方案.pptx',
},
{
id: 'r3-2',
title: '生成式AI落地风险控制',
content: 'RAG 不能完全消除幻觉,仍需要上下文质量控制、引用展示、权限过滤和答案审核机制。',
source: '风控库 / 生成式AI落地风险控制.pdf',
},
],
});
}
return {
title: mode === 'single-round' ? '单轮检索思考过程' : mode === 'multi-round' ? '多轮检索思考过程' : '深度检索思考过程',
rounds,
finalAnswerMarkdown: 'RAG(Retrieval-Augmented Generation,检索增强生成)是一种把 **外部知识检索** 和 **大模型生成** 结合起来的技术架构。\n\n### 核心流程\n\n1. **理解问题**:系统解析用户问题,并可能进行 Query 改写或问题拆解。\n2. **检索证据**:从知识库、文档库或业务数据源中召回相关片段。\n3. **筛选重排**:对召回片段按相关性和可信度排序,保留高质量上下文。\n4. **增强生成**:将问题和检索证据一起发送给大模型,生成回答。\n5. **引用溯源**:展示答案依据,便于用户核验。\n\n### 主要价值\n\n- 降低模型幻觉\n- 引入最新或私有知识\n- 提升回答可解释性和可追溯性\n- 适合企业知识问答、政策解读、资料分析等场景',
};
};
/**
* 构造流式多轮检索的初始空 trace。
*
* 请求刚建立时还没有真正的工具调用结果,先渲染一个“正在准备检索”的轮次,
* 可以让用户立即看到助手进入工作状态,后续 SSE 事件会继续更新这个 trace。
*/
export const createMultiRoundStreamTrace = (): QAThinkingTrace => ({
title: '多轮检索思考过程',
rounds: [{
id: `round-${Date.now()}`,
title: '正在准备检索',
thoughtMarkdown: '',
query: '',
queryLoading: true,
retrievals: [],
}],
finalAnswerMarkdown: '',
});
/**
* 为对比模式创建左右面板的初始状态。
*
* 对比模式会并发运行两个不同的问答模式;每个面板需要独立维护 loading、trace 和 sources。
* 该工厂函数把不同模式的初始 trace 统一封装,避免页面组件重复判断。
*/
export const createComparePane = (mode: QAMode): ComparePane => ({
mode,
loading: true,
singleRagTrace: mode === 'single-round'
? {
title: '单轮 RAG 思考过程',
thinkingMarkdown: '',
answerMarkdown: '',
thinkingComplete: false,
}
: undefined,
thinkingTrace: mode === 'single-round' ? undefined : createMultiRoundStreamTrace(),
sources: [],
});
/**
* 首屏演示消息。
*
* 页面首次进入时,如果没有用户消息,会展示两轮预置问答,说明引用、思考过程和追问的展示形态。
* 拆分时完整保留原始 mock 数据,确保首屏体验不变。
*/
export const createMockSingleRagMessages = (): Message[] => ([
{
id: 'mock-user-code-preview',
role: 'user',
content: '帮我看一下 Markdown 代码块的展示效果',
timestamp: new Date(),
},
{
id: 'mock-assistant-code-preview',
role: 'assistant',
content: '',
timestamp: new Date(),
singleRagTrace: {
title: '代码块渲染预览',
thinkingMarkdown: '这条模拟数据用于检查 MarkdownRenderer 的代码块样式:多行代码应该展示为带语言标签和复制按钮的彩色代码块,短单行代码例如 `const ok = true` 应该保持行内展示。',
answerMarkdown: [
'下面是几段模拟代码,用来预览不同语言的渲染效果。',
'',
'短单行代码:`const count = 1`,这一段应该显示为行内代码,而不是大代码块。',
'',
'### TSX 示例',
'',
'```tsx',
'type UserCardProps = {',
' name: string;',
' role: string;',
'};',
'',
'export function UserCard({ name, role }: UserCardProps) {',
' return (',
' <section className="rounded-lg border p-4">',
' <h3>{name}</h3>',
' <p>{role}</p>',
' </section>',
' );',
'}',
'```',
'',
'### Vue 示例',
'',
'```vue',
'<script setup lang="ts">',
'import { ref } from "vue";',
'',
'const keyword = ref("strategy");',
'</script>',
'',
'<template>',
' <button @click="keyword = keyword.toUpperCase()">',
' {{ keyword }}',
' </button>',
'</template>',
'```',
'',
'### Java 示例',
'',
'```java',
'public class StrategyScore {',
' public static int normalize(int value) {',
' return Math.max(0, Math.min(100, value));',
' }',
'',
' public static void main(String[] args) {',
' System.out.println(normalize(128));',
' }',
'}',
'```',
'',
'引用按钮也顺便保留测试:[[1]]',
].join('\n'),
thinkingComplete: true,
},
sources: [
{
title: 'MarkdownRenderer mock preview',
type: '本地模拟数据',
snippet: '用于预览 tsx、vue、java 代码块高亮,以及短单行代码的行内展示。',
source: 'GeneralQA.mock.ts',
time: '2026-04-30',
content: '这是一条本地模拟引用数据,用于测试 [[1]] 引用按钮点击后的详情展示。',
},
],
},
{
id: 'mock-user-1',
role: 'user',
content: 'RAG 在企业知识问答里具体怎么落地?',
timestamp: new Date(),
},
{
id: 'mock-assistant-1',
role: 'assistant',
content: '',
timestamp: new Date(),
singleRagTrace: {
title: '单轮 RAG 思考过程',
thinkingMarkdown: '先明确用户要的是“可落地方案”,所以需要回答:\n\n1. 数据准备与切片策略\n2. 检索与重排链路\n3. 答案引用与可追溯性\n\n优先给出一套可直接上线的最小闭环。',
answerMarkdown: '可以按“**数据层 → 检索层 → 生成层 → 评估层**”四步落地:\n\n1. **数据层**:把制度、FAQ、SOP 文档切片并向量化,按业务域建索引。[[1]]\n2. **检索层**:先向量召回,再用重排模型做精排,提升命中质量。[[2]]\n3. **生成层**:将 TopK 证据注入 Prompt,要求回答必须带引用编号。[[3]]\n4. **评估层**:持续监控命中率、引用准确率和用户满意度。\n\n如果你要,我可以下一步给你一份“可直接给研发执行”的字段级配置清单。',
thinkingComplete: true,
},
sources: [
{
title: '企业知识问答平台建设指南',
type: '知识库',
snippet: '建议按业务域拆分索引,并保持文档版本可追溯。',
source: '内部架构组',
time: '2026-03-18',
url: 'https://example.com/rag-enterprise-guide',
content: '企业落地 RAG 时,建议先从高频问答域切入,采用“文档切片 + 向量索引 + 重排”三层方案,确保上线初期可控。',
},
{
title: 'RAG 重排实践报告',
type: '技术报告',
snippet: '仅向量召回会引入噪声,重排可显著提升答案相关性。',
source: 'NLP 团队',
time: '2026-01-09',
url: 'https://example.com/rag-rerank-report',
content: '在 20 万条文档规模下,向量 Top50 + CrossEncoder 重排到 Top6,可使人工评测正确率提升约 12%。',
},
{
title: '可追溯生成规范',
type: '规范',
snippet: '回答输出应绑定证据编号,便于用户核验。',
source: 'AI 治理委员会',
time: '2025-12-22',
url: 'https://example.com/traceable-generation-spec',
content: '推荐在答案中输出 [[n]] 形式的引用标识,并支持点击查看证据明细、来源与时间信息。',
},
],
},
{
id: 'mock-user-2',
role: 'user',
content: '那单轮和多轮检索的差异是什么?',
timestamp: new Date(),
},
{
id: 'mock-assistant-2',
role: 'assistant',
content: '',
timestamp: new Date(),
singleRagTrace: {
title: '单轮 RAG 思考过程',
thinkingMarkdown: '用户在做方案选型,回答要突出“复杂问题覆盖率”和“时延成本”两个维度。',
answerMarkdown: '**单轮检索**更快,适合事实性问题;**多轮检索**覆盖更全,适合复杂推理问题。通常实践里会先单轮,召回不足再升级多轮。[[1]][[2]]',
thinkingComplete: true,
},
sources: [
{
title: 'RAG 模式选型对比',
type: '白皮书',
snippet: '单轮偏性能,多轮偏完整性。',
source: '智能搜索实验室',
time: '2026-02-11',
url: 'https://example.com/rag-mode-comparison',
content: '复杂查询在多轮检索中召回覆盖更高,但会增加 20%-60% 延迟,需按业务 SLA 选择。',
},
{
title: '检索系统时延评测',
type: '评测',
snippet: '多轮策略会增加查询与重排调用次数。',
source: '平台性能组',
time: '2026-02-27',
url: 'https://example.com/rag-latency-benchmark',
content: '在同等资源下,多轮检索平均时延高于单轮检索,但对于复杂问题的回答完整度更高。',
},
],
},
{
id: 'mock-user-multi-1',
role: 'user',
content: '我想了解多轮检索的工作机制,以及它在大规模企业知识库中的实际表现如何?',
timestamp: new Date(),
},
{
id: 'mock-assistant-multi-1',
role: 'assistant',
content: '',
timestamp: new Date(),
thinkingTrace: createThinkingTrace('multi-round'),
sources: [
{
title: '多轮RAG检索白皮书',
type: '知识库',
snippet: '多轮检索通过逐轮改写查询、逐步聚焦,能在复杂问题上比单轮检索提升30%以上的召回覆盖。',
source: '内部架构组',
time: '2026-04-15',
content: '多轮检索的核心在于每轮检索后根据已召回内容改写查询,逐步缩小语义范围,最终实现精准覆盖。',
},
{
title: '企业知识库检索时延对比',
type: '技术报告',
snippet: '两轮检索在10万级文档库中平均增加40%延迟,但答案完整度提升显著。',
source: 'NLP团队',
time: '2026-03-22',
content: '实测数据:单轮检索平均时延1.2s,两轮检索平均时延1.7s,三轮检索平均时延2.3s。建议根据SLA选择合适轮次。',
},
],
},
{
id: 'mock-user-multi-2',
role: 'user',
content: '企业知识问答系统的多轮检索需要做哪些工程化优化?',
timestamp: new Date(),
},
{
id: 'mock-assistant-multi-2',
role: 'assistant',
content: '',
timestamp: new Date(),
thinkingTrace: {
title: '多轮检索工程化优化思考过程',
rounds: [
{
id: 'round-opt-1',
title: '分析工程化优化方向',
thoughtMarkdown: '用户关心的是工程层面的优化,需要从以下几个维度回答:\n\n1. 查询改写策略的缓存与复用\n2. 检索结果去重与增量更新\n3. 并发检索与超时控制\n4. 检索质量的在线评估机制',
query: '多轮检索 工程化优化 查询改写 缓存 去重 并发',
retrievals: [
{
id: 'ro1-1',
title: '查询改写缓存策略实践',
content: '对于相似查询,可通过向量相似度匹配复用上一轮的改写结果,减少调用大模型的次数和延迟。',
source: '知识库 / 查询优化实践指南.md',
},
{
id: 'ro1-2',
title: '检索结果去重方案',
content: '多轮检索容易出现重复文档,需基于文档ID或内容哈希进行去重,并在每轮检索时排除已召回文档。',
source: '知识库 / 检索去重技术方案.pdf',
},
],
},
{
id: 'round-opt-2',
title: '检索性能与质量保障',
thoughtMarkdown: '第二轮聚焦性能优化:\n\n1. 并发检索多个知识库以降低延迟\n2. 设置合理的检索超时防止长尾请求\n3. 引入在线相关性评分作为质量护栏',
query: '并发检索 超时控制 在线评估 知识库评分',
retrievals: [
{
id: 'ro2-1',
title: '知识库并发检索架构设计',
content: '采用异步并发查询多个索引,配合全局超时(建议3-5s),保证用户体验的同时最大化召回。',
source: '架构库 / 并发检索设计文档.docx',
},
{
id: 'ro2-2',
title: '检索质量在线评估方法',
content: '通过小模型对每轮召回结果做相关性打分,低于阈值时自动触发查询改写或切换检索策略。',
source: '评测库 / 在线质量评估方案.md',
},
{
id: 'ro2-3',
title: '多轮检索时延优化总结',
content: '工程实践中建议两轮为上限,超过时需评估能否合并为单轮。首批结果可先展示,第二轮做增量补充。',
source: '性能库 / 时延优化白皮书.pdf',
},
],
},
],
finalAnswerMarkdown: '多轮检索的工程化优化主要围绕四个方向:\n\n### 1. 查询改写优化\n- 缓存相似查询的改写结果,通过向量相似度匹配复用\n- 降低大模型调用频次,减少时延和成本\n\n### 2. 结果去重与增量更新\n- 基于文档ID或内容哈希去重\n- 每轮检索时排除已召回文档,避免重复返回\n- 增量模式:先返回首批结果,后续轮次做补充\n\n### 3. 并发与超时控制\n- 异步并发查询多个知识库索引\n- 设置全局超时(建议3-5秒),避免长尾请求\n- 首批结果到达后立即展示,后续增量追加\n\n### 4. 在线质量评估\n- 用小模型对召回结果做相关性打分\n- 低于阈值时自动触发查询改写或切换策略\n- 持续监控命中率和引用准确率\n\n> 建议:实际工程中两轮检索已能覆盖大多数复杂场景,超过两轮应重新评估是否可以通过更好的查询改写来合并。',
},
sources: [
{
title: '多轮检索工程化最佳实践',
type: '技术方案',
snippet: '两轮检索配合增量更新,可在延迟和完整度之间取得最优平衡。',
source: '工程团队',
time: '2026-05-01',
content: '总结了过去一年的多轮检索优化经验,涵盖查询改写缓存、结果去重、并发检索和在线评估四大方向。',
},
],
},
]);
/**
* 将多轮检索接口返回的 references 项映射为通用引用来源。
*/
export const mapReferenceToSource = (item: { title?: string; content?: string; snippet?: string }): SourceItem => ({
title: item.title || '未命名来源',
type: '参考资料',
snippet: item.snippet || item.content || '',
content: item.content || item.snippet || '',
});
/**
* 将知识库单轮接口返回的 docs 映射为页面引用来源。
*/
export const mapDocsToSources = (docs: KnowledgeBaseChunk['docs']): SourceItem[] => {
const normalizedDocs = Array.isArray(docs) ? docs : docs ? [docs] : [];
return normalizedDocs.map((item, idx) => {
if (typeof item === 'string') {
return {
title: `参考资料 ${idx + 1}`,
type: '知识库',
snippet: item,
source: '',
time: '',
content: item,
};
}
return {
title: item.title || item.source || `参考资料 ${idx + 1}`,
type: '知识库',
snippet: item.snippet || item.content || item.url || '',
source: item.source || item.title || '',
time: '',
url: item.url,
content: item.content || item.snippet || '',
};
});
};
/**
* 从流式文本中提取完整 JSON 对象。
*
* SSE 响应可能把一个 JSON 切成多段返回;该函数通过花括号深度和字符串状态,
* 找到已经完整闭合的对象,并把未闭合尾巴放回 `rest` 等待下一批数据拼接。
*/
export const extractJsonObjects = (raw: string): { objects: string[]; rest: string } => {
const objects: string[] = [];
let depth = 0;
let stringQuote: '"' | "'" | null = null;
let escaped = false;
let start = -1;
for (let i = 0; i < raw.length; i += 1) {
const ch = raw[i];
if (stringQuote) {
if (escaped) {
escaped = false;
} else if (ch === '\\') {
escaped = true;
} else if (ch === stringQuote) {
stringQuote = null;
}
continue;
}
if (ch === '"' || ch === "'") {
stringQuote = ch;
continue;
}
if (ch === '{') {
if (depth === 0) start = i;
depth += 1;
continue;
}
if (ch === '}') {
depth -= 1;
if (depth === 0 && start !== -1) {
objects.push(raw.slice(start, i + 1));
start = -1;
}
}
}
if (depth > 0 && start !== -1) {
return { objects, rest: raw.slice(start) };
}
return { objects, rest: '' };
};
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,74 @@
import type { QASingleRagTrace, QAThinkingTrace } from './QACommon';
export type QAMode = 'single-round' | 'multi-round' | 'deep-search';
export type ViewMode = 'single' | 'compare';
export type KnowledgeScope = 'all' | 'specific-kb' | 'specific-domain';
export type AnswerStyle = 'concise' | 'detailed';
export type CompareLayout = 'horizontal' | 'vertical';
export interface SourceItem {
title: string;
type: string;
snippet: string;
source?: string;
time?: string;
url?: string;
content?: string;
}
export interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
singleRagTrace?: QASingleRagTrace;
thinkingTrace?: QAThinkingTrace;
sources?: SourceItem[];
}
export interface ToolCallData {
tool_name: string;
tool_input: {
text?: string;
result?: string;
};
}
export interface KnowledgeBaseDoc {
title?: string;
content?: string;
source?: string;
url?: string;
snippet?: string;
}
export interface KnowledgeBaseChunk {
answer?: string;
docs?: KnowledgeBaseDoc[] | KnowledgeBaseDoc | string;
}
export interface ComparePane {
mode: QAMode;
loading: boolean;
singleRagTrace?: QASingleRagTrace;
thinkingTrace?: QAThinkingTrace;
sources: SourceItem[];
}
export interface CompareResult {
left: ComparePane;
right: ComparePane;
differences?: {
consensus: string;
differences: string;
leftAdvantage: string;
rightAdvantage: string;
};
}
export interface SearchProcessState {
currentMode: string;
rounds: number;
hits: number;
queryRewrite: string;
}
@@ -0,0 +1,249 @@
import { BookOpen, Brain, ChevronLeft, ChevronRight, Sparkles } from 'lucide-react';
import type { AnswerStyle, CompareLayout, KnowledgeScope, QAMode, ViewMode } from './GeneralQA.types';
/**
* 左侧配置栏 Props。
*
* 组件内不保存业务状态,所有选中值和 setter 都来自父组件。
* 这样父组件可以统一处理“生成中切换需要确认停止”的逻辑,配置栏只负责触发用户意图。
*/
type Props = {
isDark: boolean; // 当前是否为暗黑主题,用于样式适配
viewMode: ViewMode; // 当前视图模式:'single' 单一模式 或 'compare' 对比模式
qaMode: QAMode; // 单一模式下的问答模式:'single-round' 单轮RAG / 'multi-round' 多轮RAG / 'deep-search' 深度检索
compareLeftMode: QAMode; // 对比模式下左侧的问答模式
compareRightMode: QAMode; // 对比模式下右侧的问答模式
compareLayout: CompareLayout; // 对比模式的布局方式:'horizontal' 水平 或 'vertical' 垂直
knowledgeScope: KnowledgeScope; // 知识范围设置:'all' 全部 / 'specific-kb' 指定知识库 / 'specific-domain' 指定领域
answerStyle: AnswerStyle; // 回答风格偏好:'concise' 简洁 或 'detailed' 详细
showSources: boolean; // 是否在回答中展示引用来源
showProcess: boolean; // 是否展示模型的思考过程
autoSummarizeDiff: boolean; // 对比模式下是否自动总结左右差异
recommendBetter: boolean; // 对比模式下是否推荐更好的回答方案
isCollapsed: boolean; // 左侧配置面板是否折叠
setQaMode: (mode: QAMode) => void; // 设置单一模式的问答模式
setCompareLeftMode: (mode: QAMode) => void; // 设置对比模式左侧的模式
setCompareRightMode: (mode: QAMode) => void; // 设置对比模式右侧的模式
setCompareLayout: (layout: CompareLayout) => void; // 设置对比布局方式
setKnowledgeScope: (scope: KnowledgeScope) => void; // 设置知识范围
setAnswerStyle: (style: AnswerStyle) => void; // 设置回答风格
setShowSources: (show: boolean) => void; // 设置是否展示引用来源
setShowProcess: (show: boolean) => void; // 设置是否展示思考过程
setAutoSummarizeDiff: (enabled: boolean) => void; // 设置是否自动总结差异
setRecommendBetter: (enabled: boolean) => void; // 设置是否推荐更好的方案
setIsCollapsed: (collapsed: boolean) => void; // 设置配置面板折叠状态
};
/**
* 单一模式下可选的问答模式。
*
* 每个选项同时提供展示文案、图标和简短说明,便于配置栏用统一结构渲染卡片。
*/
const qaModeOptions = [
{ value: 'single-round' as const, label: '单轮RAG', icon: Sparkles, desc: '快速直接' },
{ value: 'multi-round' as const, label: '多轮RAG', icon: Brain, desc: '深入理解' },
{ value: 'deep-search' as const, label: '深度检索', icon: BookOpen, desc: '全面分析' },
];
/**
* 知识范围选项。
*
* 当前只影响 UI 配置状态,后续接入真实知识库筛选时可以直接把该值映射到请求参数。
*/
const knowledgeScopeOptions = [
{ value: 'all' as const, label: '全部知识源' },
{ value: 'specific-kb' as const, label: '指定知识库' },
{ value: 'specific-domain' as const, label: '指定资源域' },
];
/**
* 左侧问答配置栏。
*
* 该组件集中承载页面的“模式选择、知识范围、回答偏好、对比设置”等表单控件。
* 父组件仍是唯一状态源,配置栏通过 setter 把用户选择同步回父组件;这样不会改变原有请求逻辑,
* 同时把 300 多行配置 JSX 从 `GeneralQA.tsx` 中移出,便于后续维护表单项和样式。
*/
export default function GeneralQAConfigSidebar({
isDark,
viewMode,
qaMode,
compareLeftMode,
compareRightMode,
compareLayout,
knowledgeScope,
answerStyle,
showSources,
showProcess,
autoSummarizeDiff,
recommendBetter,
isCollapsed,
setQaMode,
setCompareLeftMode,
setCompareRightMode,
setCompareLayout,
setKnowledgeScope,
setAnswerStyle,
setShowSources,
setShowProcess,
setAutoSummarizeDiff,
setRecommendBetter,
setIsCollapsed,
}: Props): JSX.Element {
/**
* 自定义勾选图标。
*
* 原生 checkbox 被隐藏后,所有勾选项都通过这个 SVG 保持一致的视觉风格。
*/
const renderCheckIcon = () => (
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" />
</svg>
);
return (
<div className={`${isCollapsed ? 'w-14 p-2' : 'w-72 p-4'} border-r flex-shrink-0 overflow-y-auto transition-all duration-300 ${
isDark ? 'border-slate-700/50 bg-slate-800/30' : 'border-red-100 bg-gray-50/50'
}`}>
<div className={`flex items-center ${isCollapsed ? 'justify-center' : 'justify-between'} mb-4`}>
{!isCollapsed && (
<span className={`text-xs font-medium uppercase tracking-wider ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
问答配置
</span>
)}
<button
type="button"
onClick={() => setIsCollapsed(!isCollapsed)}
className={`p-2 rounded-lg transition-all ${
isDark
? 'text-slate-400 hover:text-white hover:bg-slate-700/50'
: 'text-gray-500 hover:text-red-600 hover:bg-red-50'
}`}
title={isCollapsed ? '展开侧边栏' : '收起侧边栏'}
>
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
</div>
{!isCollapsed && (
<>
{viewMode === 'single' ? (
<div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>问答模式</h3>
<div className="space-y-2">
{qaModeOptions.map((mode) => (
<button
key={mode.value}
onClick={() => setQaMode(mode.value)}
className={`w-full flex items-center gap-3 p-3 rounded-lg transition-all text-left ${
qaMode === mode.value
? isDark
? 'bg-blue-600/20 border border-blue-500/50 text-blue-400'
: 'bg-red-50 border border-red-200 text-red-600'
: isDark
? 'bg-slate-700/30 border border-transparent text-slate-400 hover:bg-slate-700/50'
: 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50'
}`}
>
<mode.icon className="w-5 h-5" />
<div className="flex-1">
<div className="text-sm font-medium">{mode.label}</div>
<div className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{mode.desc}</div>
</div>
</button>
))}
</div>
</div>
) : (
<>
<div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>对比模式设置</h3>
<div className="mb-4">
<label className={`text-xs mb-2 block ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>模式A</label>
<select value={compareLeftMode} onChange={(e) => setCompareLeftMode(e.target.value as QAMode)} className={`w-full px-3 py-2 rounded-lg text-sm ${isDark ? 'bg-slate-700/50 border border-slate-600 text-white' : 'bg-white border border-gray-200 text-gray-700'}`}>
<option value="single-round">单轮RAG</option>
<option value="multi-round">多轮RAG</option>
<option value="deep-search">深度检索</option>
</select>
</div>
<div className={`text-center my-2 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}><span className="text-lg">VS</span></div>
<div className="mb-4">
<label className={`text-xs mb-2 block ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>模式B</label>
<select value={compareRightMode} onChange={(e) => setCompareRightMode(e.target.value as QAMode)} className={`w-full px-3 py-2 rounded-lg text-sm ${isDark ? 'bg-slate-700/50 border border-slate-600 text-white' : 'bg-white border border-gray-200 text-gray-700'}`}>
<option value="single-round">单轮RAG</option>
<option value="multi-round">多轮RAG</option>
<option value="deep-search">深度检索</option>
</select>
</div>
</div>
<div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>对比布局</h3>
<div className="flex gap-2">
{(['horizontal', 'vertical'] as const).map((layout) => (
<button key={layout} onClick={() => setCompareLayout(layout)} className={`flex-1 px-3 py-2 rounded-lg text-sm transition-all ${compareLayout === layout ? isDark ? 'bg-blue-600 text-white' : 'bg-red-500 text-white' : isDark ? 'bg-slate-700/50 text-slate-300' : 'bg-gray-100 text-gray-600'}`}>
{layout === 'horizontal' ? '左右对比' : '上下对比'}
</button>
))}
</div>
</div>
</>
)}
<div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>知识范围</h3>
<div className="space-y-2">
{knowledgeScopeOptions.map((option) => (
<label key={option.value} className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${knowledgeScope === option.value ? isDark ? 'bg-blue-600/20 text-blue-400' : 'bg-red-50 text-red-600' : isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
<input type="radio" name="knowledge-scope" checked={knowledgeScope === option.value} onChange={() => setKnowledgeScope(option.value)} className="sr-only" />
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${knowledgeScope === option.value ? isDark ? 'border-blue-500 bg-blue-500' : 'border-red-500 bg-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>
{knowledgeScope === option.value && <div className="w-1.5 h-1.5 rounded-full bg-white" />}
</div>
<span className="text-sm">{option.label}</span>
</label>
))}
</div>
</div>
<div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>回答偏好</h3>
<div className="space-y-3">
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
<input type="checkbox" checked={answerStyle === 'detailed'} onChange={() => setAnswerStyle(answerStyle === 'detailed' ? 'concise' : 'detailed')} className="sr-only" />
<div className={`w-4 h-4 rounded border flex items-center justify-center ${answerStyle === 'detailed' ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{answerStyle === 'detailed' && renderCheckIcon()}</div>
<span className="text-sm">详细回答</span>
</label>
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
<input type="checkbox" checked={showSources} onChange={() => setShowSources(!showSources)} className="sr-only" />
<div className={`w-4 h-4 rounded border flex items-center justify-center ${showSources ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{showSources && renderCheckIcon()}</div>
<span className="text-sm">展示引用来源</span>
</label>
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
<input type="checkbox" checked={showProcess} onChange={() => setShowProcess(!showProcess)} className="sr-only" />
<div className={`w-4 h-4 rounded border flex items-center justify-center ${showProcess ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{showProcess && renderCheckIcon()}</div>
<span className="text-sm">展示检索过程</span>
</label>
</div>
</div>
{viewMode === 'compare' && (
<div className="mb-6">
<h3 className={`text-sm font-medium mb-3 ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>对比设置</h3>
<div className="space-y-3">
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
<input type="checkbox" checked={autoSummarizeDiff} onChange={() => setAutoSummarizeDiff(!autoSummarizeDiff)} className="sr-only" />
<div className={`w-4 h-4 rounded border flex items-center justify-center ${autoSummarizeDiff ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{autoSummarizeDiff && renderCheckIcon()}</div>
<span className="text-sm">自动总结差异</span>
</label>
<label className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer transition-all ${isDark ? 'text-slate-400 hover:bg-slate-700/30' : 'text-gray-600 hover:bg-gray-50'}`}>
<input type="checkbox" checked={recommendBetter} onChange={() => setRecommendBetter(!recommendBetter)} className="sr-only" />
<div className={`w-4 h-4 rounded border flex items-center justify-center ${recommendBetter ? isDark ? 'bg-blue-500 border-blue-500' : 'bg-red-500 border-red-500' : isDark ? 'border-slate-500' : 'border-gray-300'}`}>{recommendBetter && renderCheckIcon()}</div>
<span className="text-sm">推荐更优答案</span>
</label>
</div>
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,80 @@
import { History } from 'lucide-react';
import type { ViewMode } from './GeneralQA.types';
/**
* 顶部栏 Props。
*
* `setViewMode` 由父组件传入,父组件会在生成中切换时做确认拦截;
* Header 本身不直接感知 loading,保持纯展示和事件转发。
*/
type Props = {
isDark: boolean; // 当前是否为暗黑主题,用于样式适配
viewMode: ViewMode; // 当前视图模式:'single' 单一模式 或 'compare' 对比模式
setViewMode: (mode: ViewMode) => void; // 设置视图模式回调函数
};
/**
* 通用问答页面顶部栏。
*
* 该组件只负责页面标题、模式切换和历史记录入口,所有业务状态仍由父组件维护。
* 这样拆分后,顶部导航的样式和交互不会和中间问答流处理逻辑混在一起,后续如果要新增
* “保存模板”“导出记录”等入口,也可以只改这个文件。
*/
export default function GeneralQAHeader({ isDark, viewMode, setViewMode }: Props): JSX.Element {
return (
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className="flex items-center gap-4">
<h1 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
通用问答
</h1>
<span className={`text-sm ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
支持单轮RAG、多轮RAG、深度检索及结果对比
</span>
</div>
<div className="flex items-center gap-2">
<div className={`flex rounded-lg p-1 ${isDark ? 'bg-slate-700/50' : 'bg-red-50'}`}>
<button
onClick={() => setViewMode('single')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
viewMode === 'single'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
单一模式
</button>
<button
onClick={() => setViewMode('compare')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-all ${
viewMode === 'compare'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
对比模式
</button>
</div>
<button
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all ${
isDark
? 'bg-slate-700/50 text-slate-300 hover:bg-slate-600/50'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
<History className="w-4 h-4" />
历史记录
</button>
</div>
</div>
);
}
@@ -0,0 +1,410 @@
.mope-chat {
--bg: #ffffff;
--surface: #ffffff;
--surface-raised: #ffffff;
--card: #ffffff;
--border: #fee2e2;
--border-strong: #fecaca;
--text: #111827;
--text-muted: #6b7280;
--accent: #dc2626;
--accent-hover: #ef4444;
--green: #16a34a;
--yellow: #f59e0b;
--red: #dc2626;
--purple: #7c3aed;
--button-text: #ffffff;
--surface-hover: rgba(220, 38, 38, 0.04);
--surface-hover-strong: rgba(220, 38, 38, 0.08);
--accent-soft: rgba(220, 38, 38, 0.08);
--accent-soft-strong: rgba(220, 38, 38, 0.16);
--danger-soft: rgba(220, 38, 38, 0.10);
--shadow-soft: 0 14px 40px rgba(15, 23, 42, 0.07);
--shadow-card: 0 8px 24px rgba(15, 23, 42, 0.05);
--text-primary: var(--text);
--text-secondary: var(--text-muted);
--border-color: var(--border);
--bg-secondary: var(--surface);
--accent-blue: var(--accent);
--phase-starting: var(--accent);
--phase-diverging: var(--yellow);
--phase-closing: var(--green);
--phase-done: var(--purple);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: 14px;
line-height: 1.6;
color: var(--text);
background: var(--bg);
}
.mope-chat.mope-chat-dark {
--bg: #0f172a;
--surface: #1e293b;
--surface-raised: #111827;
--card: #0f172a;
--border: rgba(30, 64, 175, 0.45);
--border-strong: rgba(59, 130, 246, 0.45);
--text: #f8fafc;
--text-muted: #94a3b8;
--accent: #3b82f6;
--accent-hover: #60a5fa;
--green: #10b981;
--yellow: #f59e0b;
--red: #f43f5e;
--purple: #8b5cf6;
--button-text: #ffffff;
--surface-hover: rgba(255, 255, 255, 0.03);
--surface-hover-strong: rgba(255, 255, 255, 0.06);
--accent-soft: rgba(59, 130, 246, 0.14);
--accent-soft-strong: rgba(59, 130, 246, 0.22);
--danger-soft: rgba(244, 63, 94, 0.12);
--shadow-soft: 0 18px 45px rgba(0, 0, 0, 0.28);
--shadow-card: 0 12px 28px rgba(0, 0, 0, 0.22);
}
.mope-chat,
.mope-chat * {
box-sizing: border-box;
}
.mope-chat {
color: var(--text);
min-height: 100%;
transition: background 0.2s ease, color 0.2s ease;
}
.mope-chat input,
.mope-chat textarea,
.mope-chat select,
.mope-chat button {
font: inherit;
}
.mope-chat p,
.mope-chat h1,
.mope-chat h2,
.mope-chat h3,
.mope-chat h4,
.mope-chat pre {
margin-top: 0;
}
.mope-chat ::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.mope-chat ::-webkit-scrollbar-track {
background: var(--surface);
}
.mope-chat ::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
.mope-chat ::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Idle: centered form */
.mope-chat.app-idle {
display: flex;
align-items: center;
justify-content: center;
min-height: calc(100vh - 96px);
padding: 36px 40px 42px;
background:
radial-gradient(circle at top center, var(--accent-soft) 0, transparent 34%),
var(--bg);
}
.mope-chat.app-idle-scroll {
align-items: flex-start;
overflow-y: auto;
}
/* Running / done: two-column */
.mope-chat.app-running {
display: flex;
flex-direction: column;
height: calc(100vh - 96px);
overflow: hidden;
background: var(--surface-raised);
border-radius: 18px;
border: 1px solid var(--border);
box-shadow: var(--shadow-card);
}
.mope-chat .app-header {
background: color-mix(in srgb, var(--surface) 92%, var(--accent) 8%);
border-bottom: 1px solid var(--border);
padding: 14px 18px;
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.mope-chat .app-header-logo {
font-weight: 700;
font-size: 15px;
color: var(--accent);
letter-spacing: 0.5px;
}
.mope-chat .app-header-task {
flex: 1;
color: var(--text-muted);
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mope-chat .app-header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.mope-chat .app-body {
display: flex;
flex: 1;
overflow: hidden;
}
.mope-chat .app-sidebar {
width: 250px;
flex-shrink: 0;
overflow-y: auto;
border-right: 1px solid var(--border);
background: color-mix(in srgb, var(--surface) 96%, var(--accent) 4%);
}
.mope-chat .app-main {
flex: 1;
overflow-y: auto;
padding: 24px;
background: var(--bg);
}
.mope-chat .app-progress-sidebar {
width: 320px;
flex-shrink: 0;
overflow-y: auto;
border-left: 1px solid var(--border);
background: color-mix(in srgb, var(--surface) 96%, var(--accent) 4%);
}
.mope-chat .app-side-panel {
position: relative;
transition: width 0.22s ease, background 0.2s ease;
}
.mope-chat .app-side-panel.collapsed {
width: 44px;
overflow: hidden;
}
.mope-chat .app-side-toggle {
position: sticky;
top: 10px;
z-index: 2;
width: 28px;
height: 28px;
min-width: 28px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--surface-raised);
color: var(--text-muted);
box-shadow: var(--shadow-card);
}
.mope-chat .app-side-toggle:hover:not(:disabled) {
color: var(--accent);
border-color: var(--border-strong);
background: var(--surface-hover-strong);
transform: translateY(-1px);
}
.mope-chat .app-side-toggle-left {
float: right;
margin: 10px 10px 0 0;
}
.mope-chat .app-side-toggle-right {
float: left;
margin: 10px 0 0 10px;
}
.mope-chat .app-side-panel.collapsed .app-side-toggle-left,
.mope-chat .app-side-panel.collapsed .app-side-toggle-right {
float: none;
margin: 10px auto 0;
}
/* Buttons */
.mope-chat button {
cursor: pointer;
border: none;
border-radius: 10px;
padding: 8px 16px;
font-size: 13px;
font-family: inherit;
transition: transform 0.15s, box-shadow 0.15s, opacity 0.15s, background 0.15s, border-color 0.15s, color 0.15s;
}
.mope-chat button:hover:not(:disabled) {
transform: translateY(-1px);
}
.mope-chat button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.mope-chat .btn-primary {
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
color: var(--button-text);
font-weight: 600;
box-shadow: 0 10px 22px var(--accent-soft-strong);
}
.mope-chat .btn-primary:hover:not(:disabled) {
opacity: 0.85;
}
.mope-chat .btn-danger {
background: transparent;
color: var(--red);
border: 1px solid var(--red);
}
.mope-chat .btn-danger:hover:not(:disabled) {
background: var(--danger-soft);
}
.mope-chat .btn-secondary {
background: var(--surface-raised);
color: var(--text-muted);
border: 1px solid var(--border);
}
.mope-chat .btn-secondary:hover:not(:disabled) {
color: var(--text);
border-color: var(--border-strong);
background: var(--surface-hover);
}
.mope-chat .btn-ghost {
background: transparent;
color: var(--text-muted);
padding: 4px 8px;
font-size: 12px;
}
.mope-chat .btn-ghost:hover {
color: var(--text);
}
/* Badges */
.mope-chat .badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.3px;
}
.mope-chat .badge-starting { background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); }
.mope-chat .badge-diverging { background: color-mix(in srgb, var(--yellow) 15%, transparent); color: var(--yellow); }
.mope-chat .badge-closing { background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); }
.mope-chat .badge-done { background: color-mix(in srgb, var(--purple) 15%, transparent); color: var(--purple); }
.mope-chat .badge-default { background: color-mix(in srgb, var(--text-muted) 15%, transparent); color: var(--text-muted); }
/* Spinner */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.mope-chat .spinner {
display: inline-block;
animation: spin 1s linear infinite;
}
/* Reference cite */
.mope-chat .ref-cite {
display: inline-block;
background: var(--accent-soft-strong);
color: var(--accent);
border-radius: 4px;
padding: 0 3px;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 11px;
cursor: pointer;
position: relative;
}
.mope-chat .ref-cite:hover::after {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 4px);
left: 50%;
transform: translateX(-50%);
background: var(--card);
border: 1px solid var(--border);
color: var(--text);
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
white-space: nowrap;
z-index: 100;
font-family: inherit;
pointer-events: none;
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
}
/* Error banner */
.mope-chat .error-banner {
background: var(--danger-soft);
border: 1px solid var(--red);
border-radius: 6px;
padding: 8px 12px;
color: var(--red);
font-size: 13px;
margin-bottom: 12px;
}
@media (max-width: 1200px) {
.mope-chat .app-progress-sidebar {
width: 260px;
}
}
@media (max-width: 960px) {
.mope-chat .app-body {
flex-direction: column;
}
.mope-chat .app-sidebar,
.mope-chat .app-progress-sidebar {
width: 100%;
border-right: none;
border-left: none;
border-bottom: 1px solid var(--border);
max-height: 260px;
}
.mope-chat .app-main {
padding: 14px;
}
}
@@ -0,0 +1,215 @@
import React, { useEffect, useState } from 'react';
import { useSession } from '@/hooks/useSession';
import ConfigPanel from './components/ConfigPanel';
import Sidebar from './components/Sidebar';
import Timeline from './components/Timeline';
import ProgressSidebar from './components/ProgressSidebar';
import ScenarioConfigPage from './components/ScenarioConfigPage';
import './MopeChat.css';
/**
* 从 localStorage 读取用户偏好的主题色,默认为 light
*/
function getInitialTheme() {
if (typeof window === 'undefined') return 'light';
return window.localStorage.getItem('mope-theme') || 'light';
}
/**
* 应用根组件
*
* 负责整个页面的布局切换:
* - idle 状态:显示配置面板或场景配置页(启动前)
* - running / done / error 状态:显示三栏分析界面(左侧进度 → 中间时间线 → 右侧状态)
*
* 通过 useSession hook 管理所有分析会话的状态和事件流
*/
export default function MopeChat() {
const session = useSession();
const [taskContext, setTaskContext] = useState('');
const [idlePage, setIdlePage] = useState('home');
const [theme, setTheme] = useState(getInitialTheme);
// 解构 session hook 返回的所有状态和方法
// status: 'idle' | 'running' | 'waiting_human' | 'done' | 'error'
// phase: 'starting' | 'diverging' | 'closing' | 'done' → 当前分析阶段
// coordinatorMode: 'auto' | 'human_review' → 协调者运行模式
const {
status,
phase,
coordinatorMode,
currentRound,
totalRounds,
divergeRounds,
reportSchema,
agents,
agentStates,
timeline,
coordinatorUpdates,
latestDraft,
sharedMemory,
finalReportPartial,
currentFinalField,
pendingReview,
reviewSubmitting,
reviewRevising,
errors,
startSession,
stopSession,
approveCoordinator,
reviseCoordinator,
reset,
} = session;
// 主题切换时同步到 DOM 和 localStorage
useEffect(() => {
document.documentElement.dataset.theme = theme;
window.localStorage.setItem('mope-theme', theme);
}, [theme]);
/**
* 用户点击"开始分析"时触发
* @param {Object} config - 表单配置(任务背景、模板、智能体等)
* @param {string} backendUrl - 后端 API 地址
*/
const handleStart = (config, backendUrl) => {
setTaskContext(config.task_context || '');
startSession(config, backendUrl);
};
/** 用户点击"停止"时触发,中断当前 SSE 连接 */
const handleStop = () => {
stopSession();
};
/** 用户点击"重新开始"时触发,重置所有状态回到 idle 页 */
const handleReset = () => {
reset();
setTaskContext('');
};
/** 切换明/暗主题 */
const handleThemeToggle = () => {
setTheme(current => (current === 'light' ? 'dark' : 'light'));
};
// ========== idle 状态下的页面 ==========
// 场景配置页(idle 下的二级页面)
if (status === 'idle') {
if (idlePage === 'scenario_config') {
return (
<div className="app-idle app-idle-scroll">
<button className="theme-toggle" onClick={handleThemeToggle}>
{theme === 'light' ? '切换暗色' : '切换亮色'}
</button>
<ScenarioConfigPage onBack={() => setIdlePage('home')} />
</div>
);
}
// 首页:配置表单
return (
<div className="app-idle">
<button className="theme-toggle" onClick={handleThemeToggle}>
{theme === 'light' ? '切换暗色' : '切换亮色'}
</button>
<ConfigPanel
onStart={handleStart}
isRunning={false}
onStop={handleStop}
onOpenScenarioConfig={() => setIdlePage('scenario_config')}
/>
</div>
);
}
// ========== 运行中 / 已完成 / 错误 状态下的三栏布局 ==========
return (
<div className="app-running">
{/* 主题切换按钮(运行状态下固定在 header 区域) */}
<button className="theme-toggle theme-toggle-running" onClick={handleThemeToggle}>
{theme === 'light' ? '切换暗色' : '切换亮色'}
</button>
{/* 顶部状态栏:显示项目名、任务摘要、运行模式、操作按钮 */}
<header className="app-header">
<span className="app-header-logo">MOPE</span>
<span className="app-header-task">
{taskContext ? `"${taskContext.slice(0, 80)}${taskContext.length > 80 ? '…' : ''}"` : '分析中…'}
</span>
<span className="badge badge-default">
{coordinatorMode === 'human_review' ? '人工确认' : '全自动'}
</span>
{status === 'waiting_human' && (
<span className="badge badge-diverging">等待人工确认</span>
)}
<div className="app-header-actions">
{(status === 'running' || status === 'waiting_human') && (
<button className="btn-danger" onClick={handleStop}>
停止
</button>
)}
{(status === 'done' || status === 'error') && (
<button className="btn-secondary" onClick={handleReset}>
重新开始
</button>
)}
</div>
</header>
{/* 三栏主体区域 */}
<div className="app-body">
{/* 左侧栏:阶段进度 + 智能体列表(可点击跳转到对应输出) */}
<aside className="app-sidebar">
<Sidebar
agents={agents}
agentStates={agentStates}
phase={phase}
currentRound={currentRound}
divergeRounds={divergeRounds}
totalRounds={totalRounds}
/>
</aside>
{/* 中间主区域:错误横幅 + 时间线(所有 agent 输出和协调者指令按时间排列) */}
<main className="app-main">
{errors.length > 0 && errors.map((e, i) => (
<div key={i} className="error-banner">
{e.agent_id ? `[${e.agent_id}] ` : ''}{e.error}
</div>
))}
<Timeline
timeline={timeline}
agentStates={agentStates}
reportSchema={reportSchema}
pendingReview={pendingReview}
onApproveReview={approveCoordinator}
onReviseReview={reviseCoordinator}
reviewSubmitting={reviewSubmitting}
reviewRevising={reviewRevising}
/>
</main>
{/* 右侧栏:方案进度、共享记忆、字段/角色记忆 */}
<aside className="app-progress-sidebar">
<ProgressSidebar
phase={phase}
status={status}
currentRound={currentRound}
divergeRounds={divergeRounds}
reportSchema={reportSchema}
coordinatorUpdates={coordinatorUpdates}
pendingReview={pendingReview}
latestDraft={latestDraft}
sharedMemory={sharedMemory}
finalReportPartial={finalReportPartial}
currentFinalField={currentFinalField}
agentStates={agentStates}
/>
</aside>
</div>
</div>
);
}
@@ -0,0 +1,437 @@
export const MOCK_TASK_CONTEXT =
'某头部科技公司发生大规模用户数据泄露事件,涉及约 500 万用户信息,已有多家媒体报道,需在 48 小时内完成舆论应对策略制定。';
const REPORT_SCHEMA = {
core_strategy: '核心传播策略与定调方向',
key_messages: '核心话术与传播口径',
audience_map: '目标受众画像与触达路径',
risk_warnings: '舆论风险预警与应对预案',
action_plan: '具体行动计划与时间轴',
};
const AGENTS = [
{ id: 'narrative', name: '叙事分析师', dimension: '叙事框架' },
{ id: 'audience', name: '受众洞察师', dimension: '受众分析' },
{ id: 'risk', name: '风险评估师', dimension: '风险研判' },
{ id: 'channel', name: '渠道策略师', dimension: '渠道传播' },
];
const AGENT_STATES = {
narrative: { status: 'done', name: '叙事分析师', dimension: '叙事框架' },
audience: { status: 'done', name: '受众洞察师', dimension: '受众分析' },
risk: { status: 'done', name: '风险评估师', dimension: '风险研判' },
channel: { status: 'done', name: '渠道策略师', dimension: '渠道传播' },
};
const SHARED_MEMORY = {
stable_claims: [
'公众对"企业主动披露"的容忍度明显高于"被动曝光",定调应以主动承担为主旋律',
'数据泄露事件中"用户利益优先"叙事比"技术故障"叙事更能有效降低负面评价',
'科技公司危机中 KOL 态度在 6 小时内形成,此后难以扭转',
],
key_evidence: [
'微博相关话题已有 2300 万阅读,评论情绪负向占比 67%',
'同类事件(2022 年某平台泄露)采用主动道歉策略后 NPS 下跌幅度仅为被动回应的 40%',
'核心受众 25–35 岁用户对隐私保护敏感度评分 8.3/10,为最高优先级群体',
],
conflicts: [
'风险师认为应延迟发声等待内部核查完毕;叙事师认为 6 小时黄金窗口不可错过',
'渠道师建议优先微博大 V 背书;受众师认为私域触达更能稳住核心用户',
],
open_questions: [
'泄露数据具体范围是否已完全确认?影响最终话术精准度',
'监管部门是否已介入?需提前准备合规声明版本',
],
field_memory: {
core_strategy: {
title: '核心策略',
status: 'confirmed',
current_takeaways: [
'以"主动承担、用户优先"为核心定调,避免技术性推诿',
'第一时间启动 CEO 署名声明,彰显最高层重视',
],
missing_points: [],
},
risk_warnings: {
title: '风险预警',
status: 'partial',
current_takeaways: ['次生舆情风险:监管追责、竞品借势炒作', '情绪峰值预计在 T+12h 出现'],
missing_points: ['灰色产业链二次利用风险尚未评估'],
},
},
agent_memory: {
narrative: {
name: '叙事分析师',
latest_position: '建议采用"危机—承担—改变"三段式叙事结构,第一阶段不超过 200 字',
useful_findings: [
'历史数据显示"技术故障"措辞会使负面情绪扩大 23%',
'"补偿方案"在声明中同步出现可降低 31% 的愤怒情绪',
],
open_issues: ['CEO 是否愿意出面公开致歉,将直接影响叙事策略选择'],
},
risk: {
name: '风险评估师',
latest_position: '最大风险是监管介入与集体诉讼,需提前准备法务声明独立版本',
useful_findings: ['同类事件 80% 在 T+24h 出现监管询问,需预留合规响应通道'],
open_issues: [],
},
},
};
const TIMELINE = [
// ─── 启动阶段 ───────────────────────────────────────
{ type: 'round_start', round: 1, phase: 'starting' },
{
type: 'agent_contribution',
agentId: 'narrative',
round: 1,
phase: 'starting',
data: {
thinking:
'事件已进入舆论快速扩散阶段,需要判断最佳叙事切入点。从历史案例来看,科技公司数据泄露的舆论走向主要受两个变量影响:一是企业回应速度,二是叙事框架选择。当前已有媒体报道,说明被动曝光已经发生,但官方尚未表态,仍有 6 小时内抢占定性框架的窗口。',
content:
'## 叙事框架分析\n\n当前事件已进入**被动曝光**阶段,官方沉默每延迟 1 小时将使负面叙事主导权进一步转移至媒体和用户。\n\n**推荐框架:危机承担型**\n\n> 我们深感抱歉,这不应该发生。我们在 [时间] 发现了问题,[具体措施],我们承诺……\n\n此框架相比"技术故障型"在情绪疏导上效率高出 40%。核心要点:\n1. **主语明确**:以企业 / CEO 第一人称,避免被动语态\n2. **事实先行**:已知信息直接披露,未知信息注明"正在调查"\n3. **补偿同步**:补救措施与声明同时发出,不事后补充',
},
},
{
type: 'agent_contribution',
agentId: 'audience',
round: 1,
phase: 'starting',
data: {
thinking:
'需要先切分受众结构。数据泄露涉及 500 万用户,但情绪爆发的核心驱动者通常只占 5% 左右。我需要识别出哪些群体的愤怒会向外扩散,哪些群体是可稳定的沉默多数。',
content:
'## 受众结构分析\n\n| 受众类型 | 规模估算 | 关注核心 | 应对优先级 |\n|---|---|---|---|\n| 直接受损用户 | ~50 万 | 个人信息安全、补偿 | ★★★★★ |\n| 隐私敏感用户 | ~150 万 | 是否受影响、如何自保 | ★★★★ |\n| 科技媒体 & KOL | < 500 人 | 技术细节、企业诚信 | ★★★★ |\n| 监管与法律关注者 | 小众但高影响 | 合规与处罚 | ★★★ |\n| 普通围观用户 | 多数 | 吃瓜情绪,跟随舆论 | ★★ |\n\n**战略建议**:优先稳住直接受损用户的情绪,通过私域渠道一对一触达;KOL 需在 T+2h 内通过公关渠道给予独家信息简报。',
dimensionOutput: {
核心受众: '直接受损用户(25–35 岁,高隐私敏感度)',
情绪驱动因子: '不安全感 > 愤怒 > 补偿诉求',
次级受众: '科技媒体与隐私倡导 KOL',
},
},
},
{
type: 'agent_contribution',
agentId: 'risk',
round: 1,
phase: 'starting',
data: {
content:
'## 风险研判(T+0 至 T+48h)\n\n**当前风险等级:高(4/5)**\n\n### 短期风险(T+0 ~ T+6h)\n- 媒体二次跟进,细节失控叙事\n- 竞品借势营销,转化离网用户\n- 用户自发传播截图,情绪放大\n\n### 中期风险(T+6h ~ T+24h)\n- 监管部门询问(概率 ~80%)\n- 集体诉讼舆论造势\n- KOL 发酵,话题进入热搜\n\n### 应对红线\n> ❌ 绝对不能:否认泄露规模、质疑用户截图真实性、强调技术复杂性而回避责任',
},
},
{
type: 'agent_contribution',
agentId: 'channel',
round: 1,
phase: 'starting',
data: {
content:
'## 渠道策略(首 6 小时)\n\n**渠道优先级排序:**\n\n1. **官方微博 / 公众号** — 主声明发布,需 T+2h 内上线\n2. **App 内 Push / 站内信** — 直达受损用户,降低流失率\n3. **客服热线预案** — 预计来电量激增 300%,需扩容预案\n4. **媒体渠道** — 给核心科技媒体记者 1 小时独家简报\n5. **KOL 沟通** — T+3h 前给隐私/科技类 KOL 发送详细说明',
},
},
{
type: 'coordinator',
data: {
divergeRound: null,
totalDivergeRounds: 3,
nextSpeakers: ['narrative', 'risk'],
focus: {
narrative: '深化叙事结构,给出 CEO 声明草稿(200 字以内)',
risk: '量化各风险节点的时间窗口和触发阈值',
},
draft: {
core_strategy: '主动承担型叙事,CEO 第一人称,T+2h 内首发声明',
key_messages: null,
audience_map: '直接受损用户优先,私域触达 + KOL 独家简报',
risk_warnings: null,
action_plan: null,
},
},
thinking:
'启动阶段各维度已基本就位。叙事师和受众师的框架高度吻合,渠道策略也清晰。接下来最需要深化的是风险时间轴的精确度和 CEO 声明的具体文本。',
},
// ─── 辩论阶段 第 1 轮 ─────────────────────────────
{ type: 'round_start', round: 2, phase: 'diverging' },
{
type: 'agent_contribution',
agentId: 'narrative',
round: 2,
phase: 'diverging',
data: {
thinking: '需要给出一个实际可用的 CEO 声明草稿。200 字以内,需要包含:承认事实、表达歉意、说明措施、给出补偿方案。',
content:
'## CEO 声明草稿(200 字)\n\n---\n\n> 我代表公司,向所有受影响的用户致以最诚挚的歉意。\n>\n> [日期],我们发现系统存在安全漏洞,部分用户数据遭到未经授权的访问,涉及用户约 500 万。我们在确认情况后第一时间启动了应急响应,已封堵漏洞,并向监管部门报告。\n>\n> 我们将在 24 小时内通过站内信向每位受影响用户发出个人通知,提供免费的一年身份保护服务,并设立专项补偿通道。\n>\n> 这是我们的责任。我们会把每一步进展告诉你。\n\n---\n\n**注**:声明中故意不使用"技术故障"措辞,也不使用"不可抗力",全程第一人称承担。',
},
},
{
type: 'agent_contribution',
agentId: 'risk',
round: 2,
phase: 'diverging',
data: {
content:
'## 风险时间轴(精化版)\n\n```\nT+0h ── 事件媒体首报(已触发)\nT+2h ── 官方声明发布窗口(最晚截止)\nT+4h ── 微博热搜触发阈值(~5000 评论)\nT+6h ── KOL 立场形成(此后成本 ×3)\nT+12h ── 情绪峰值预测(负面评论密度最高)\nT+18h ── 监管询问概率 80%(需准备合规版声明)\nT+24h ── 集体诉讼舆论造势窗口\nT+48h ── 舆情自然衰减期(若前期处置得当)\n```\n\n**最关键节点**:T+2h 声明发布。每延迟 1 小时,负面情绪扩散系数约增加 0.3σ。\n\n**法务建议**:声明中"约 500 万用户"措辞需法务确认,避免后续数字出入引发新一轮信任危机。',
},
},
{
type: 'coordinator',
data: {
divergeRound: 1,
totalDivergeRounds: 3,
nextSpeakers: ['audience', 'channel'],
focus: {
audience: '给出分层受众的具体触达话术,特别是直接受损用户的私域文案',
channel: '细化 T+0 至 T+6h 各渠道的具体执行 SOP,要有责任人和时间节点',
},
draft: {
core_strategy: '主动承担型叙事,CEO 第一人称署名,T+2h 发出首版声明',
key_messages:
'第一条:承认事实 + 道歉;第二条:已采取措施;第三条:补偿方案;第四条:持续透明沟通承诺',
audience_map: '直接受损用户(私域专属通知)> 媒体 KOL(独家简报)> 公域大众(官方声明)',
risk_warnings:
'T+2h 声明是关键节点;T+6h KOL 立场冻结;T+18h 监管接触概率高',
action_plan: null,
},
},
thinking:
'叙事师的 CEO 草稿质量很高,风险师的时间轴也很精确。现在需要补全受众触达的具体话术和渠道 SOP,才能形成可执行方案。',
},
// ─── 辩论阶段 第 2 轮 ─────────────────────────────
{ type: 'round_start', round: 3, phase: 'diverging' },
{
type: 'agent_contribution',
agentId: 'audience',
round: 3,
phase: 'diverging',
data: {
content:
'## 分层触达话术\n\n### 直接受损用户(站内信 / Push)\n\n> 您好,您的账号涉及本次安全事件。我们已完成漏洞修复,您的密码和支付信息**未受影响**。您可以在「设置 > 安全中心」查看详细报告,并领取您的专属保障服务。我们为您提前道歉。\n\n### 未受影响用户(公众号推文)\n\n> 关于近期数据安全事件,我们想向您说清楚……(附常见问题 FAQ,覆盖"我受影响吗""我的数据安全吗""我能做什么"三类高频问题)\n\n### 科技媒体 KOL(邮件 / 微信)\n\n> 提供独家:1) 技术修复细节简报;2) 受影响数据字段明细;3) CEO 电话采访机会(限 3 家)',
dimensionOutput: {
直接受损用户核心诉求: '我的信息具体泄露了什么?我怎么保护自己?我能得到什么补偿?',
最高情绪风险点: '若发现公司早知道却没告知,信任崩溃将不可逆',
'KOL 沟通关键': '给技术细节,不给"公关说辞",换取客观报道',
},
},
},
{
type: 'agent_contribution',
agentId: 'channel',
round: 3,
phase: 'diverging',
data: {
content:
'## 渠道执行 SOP(T+0 ~ T+6h)\n\n| 时间 | 渠道 | 动作 | 责任人 |\n|---|---|---|---|\n| T+0.5h | 内部 | 危机小组集结,信息核查 | CEO / PR 总监 |\n| T+1h | 法务 | 声明文本法务审核 | 法务负责人 |\n| T+1.5h | 媒体 | 独家简报发送至核心媒体 | PR 经理 |\n| T+2h | 官方微博 / 公众号 | 首版声明发布 | PR 总监 |\n| T+2h | App Push / 站内信 | 受损用户专属通知推送 | 产品负责人 |\n| T+3h | KOL 微信群 | 科技 / 隐私类 KOL 定向沟通 | KOL 运营 |\n| T+4h | 客服热线 | 扩容至 2× 正常容量 | 客服总监 |\n| T+6h | 官方微博 | 第二轮进展更新 | PR 总监 |\n\n**备注**:声明发出前严格管控内部信息,防止员工个人账号提前扩散造成口径不一。',
},
},
{
type: 'coordinator',
data: {
divergeRound: 2,
totalDivergeRounds: 3,
nextSpeakers: ['narrative', 'risk', 'audience', 'channel'],
focus: {
narrative: '结合受众话术,给出完整的多版本话术库(含 FAQ 20 条)',
risk: '补充监管应对预案和集体诉讼舆论防御策略',
audience: '量化各渠道触达转化率预期,给出效果评估指标',
channel: '补充 T+6h 之后的渠道持续运营节奏(T+24h、T+48h)',
},
draft: {
core_strategy: '主动承担型叙事,CEO 第一人称署名,T+2h 发出首版声明,配合多渠道协同执行',
key_messages:
'① 承认事实 + 道歉;② 已修复 + 措施说明;③ 补偿方案(1 年身份保护服务);④ 持续透明沟通承诺',
audience_map:
'直接受损用户(专属 Push/站内信)→ 科技媒体 KOL(独家简报)→ 公域大众(官方声明 + FAQ)',
risk_warnings:
'关键节点:T+2h 声明、T+6h KOL 立场、T+18h 监管询问。红线:不否认规模、不质疑用户、不推卸技术责任',
action_plan: '见渠道 SOP;T+2h 首轮声明,T+6h 进展更新,T+24h 详细报告,T+48h 补偿通道上线',
},
},
thinking:
'方案已基本成型,所有五个报告字段都有实质内容。第三轮将进行质量收敛,特别是风险监管预案和效果指标的补全。',
},
// ─── 辩论阶段 第 3 轮 ─────────────────────────────
{ type: 'round_start', round: 4, phase: 'diverging' },
{
type: 'agent_contribution',
agentId: 'risk',
round: 4,
phase: 'diverging',
data: {
content:
'## 监管应对预案\n\n### 合规声明独立版本\n需准备区别于公众声明的**监管版声明**,包含:\n- 精确影响范围(字段级别)\n- 漏洞技术原因(可向监管披露的版本)\n- 已通知/将通知的时间节点\n- 补救措施完成时间承诺\n\n### 集体诉讼舆论防御\n- 避免"私了"暗示:公开补偿方案优先于私下协商\n- 主动设立补偿通道比等待诉讼更能降低法律风险\n- 关键话术:**"我们的补偿方案对每位用户平等有效,无需诉讼即可申请"**',
},
},
{
type: 'agent_contribution',
agentId: 'audience',
round: 4,
phase: 'diverging',
data: {
content:
'## 效果评估指标体系\n\n| 指标 | 目标值 | 监测节点 |\n|---|---|---|\n| 微博话题情绪负向比 | < 50%(当前 67%) | T+6h、T+12h、T+24h |\n| 官方声明互动率 | > 3%(转发+评论/阅读) | T+4h |\n| 受损用户 App 卸载率 | < 5%(7 日内) | T+168h |\n| KOL 正向报道占比 | > 60% | T+24h |\n| 客服解决率 | > 85% | T+48h |\n\n**预警机制**:若 T+6h 负向情绪比仍 > 60%,需触发 Plan B(CEO 视频道歉 + 补偿方案提级)。',
},
},
{
type: 'coordinator',
data: {
divergeRound: 3,
totalDivergeRounds: 3,
nextSpeakers: [],
focus: {},
draft: {
core_strategy:
'主动承担型叙事,CEO 第一人称署名,T+2h 发出首版声明,配合多渠道协同执行,核心定调:承认→道歉→修复→补偿→持续透明',
key_messages:
'① 承认事实 + 真诚道歉;② 已修复漏洞 + 措施说明;③ 一年免费身份保护服务;④ 24h 内个人通知;⑤ 持续透明沟通承诺',
audience_map:
'第一梯队:直接受损 500 万用户(专属 Push/站内信);第二梯队:科技媒体 KOL(独家简报 + CEO 采访);第三梯队:公域大众(官方声明 + FAQ)',
risk_warnings:
'关键节点:T+2h 声明(最高优先)、T+6h KOL 立场冻结、T+18h 监管询问(概率 80%)。Plan B 触发条件:T+6h 负向情绪 > 60%。红线三条:不否认规模、不质疑用户、不推卸技术责任',
action_plan:
'T+0.5h 危机小组集结 → T+1h 法务审核 → T+1.5h 媒体独家简报 → T+2h 官方声明 + Push → T+3h KOL 定向沟通 → T+6h 第二轮更新 → T+24h 详细报告 → T+48h 补偿通道上线',
},
},
thinking: '三轮辩论已完成,所有字段已确认,方案逻辑自洽。进入收敛阶段生成最终报告。',
},
// ─── 收敛阶段 ─────────────────────────────────────
{ type: 'round_start', round: 5, phase: 'closing' },
{
type: 'agent_contribution',
agentId: 'narrative',
round: 5,
phase: 'closing',
data: {
content:
'## 最终确认:叙事框架\n\n完整话术库已整理,包含:\n- CEO 主声明(200 字正式版)\n- 用户 FAQ(20 条,覆盖 95% 预期问题)\n- KOL 独家简报模板\n- 监管合规声明模板\n\n所有版本均已通过"不否认、不推卸、不沉默"三条红线审核。',
},
},
{
type: 'agent_contribution',
agentId: 'channel',
round: 5,
phase: 'closing',
data: {
content:
'## 最终确认:渠道执行\n\nSOP 已锁定,T+0 ~ T+48h 全流程节点清晰,责任人到位。补充:T+72h 需发布第三方安全审计结果公告,进一步夯实信任修复。',
},
},
// ─── 最终报告 ──────────────────────────────────────
{
type: 'final_report',
data: {
report: {
core_strategy:
'**核心定调:主动承担,用户优先**\n\n本次危机响应采用"危机—承担—改变"三段式叙事结构,以 CEO 第一人称署名声明为核心,在媒体首报后 2 小时内完成官方表态,抢占舆论定性窗口。\n\n核心原则:\n- **承认事实**:不回避泄露规模,不使用"技术故障"等推卸性措辞\n- **承担责任**:以企业主体第一人称,而非被动语态\n- **行动优先**:补偿方案与声明同步发出,不事后补充\n- **持续透明**:承诺 24/48/72 小时三轮进展更新',
key_messages:
'**核心话术五条(按优先级)**\n\n1. **道歉承担**:"我们深感抱歉,这不应该发生,这是我们的责任。"\n2. **已采取行动**:"漏洞已于 [时间] 修复完毕,您的密码和支付信息未受影响。"\n3. **补偿承诺**:"我们将为每位受影响用户提供一年免费身份保护服务,并设立专项补偿通道。"\n4. **个人通知**:"我们将在 24 小时内通过站内信向每位受影响用户发出个人通知。"\n5. **透明承诺**:"我们会把每一步进展告诉你,包括监管沟通结果和第三方审计报告。"',
audience_map:
'**受众分层触达策略**\n\n**第一梯队:直接受损用户(约 500 万)**\n- 触达渠道:App Push + 站内信(T+2h 同步发出)\n- 核心内容:个人影响说明 + 补偿申请入口 + 自保指南\n- 效果指标:7 日卸载率 < 5%\n\n**第二梯队:科技媒体 & 隐私 KOL(< 500 人)**\n- 触达渠道:邮件 / 微信独家简报(T+1.5h),CEO 采访机会(限 3 家)\n- 核心内容:技术细节简报(非公关口径)+ 后续透明节奏\n- 效果指标:T+24h 正向报道占比 > 60%\n\n**第三梯队:公域大众**\n- 触达渠道:官方微博 / 公众号 + FAQ 长图文\n- 效果指标:T+12h 情绪负向比 < 50%',
risk_warnings:
'**关键风险节点与应对预案**\n\n| 时间节点 | 风险 | 触发阈值 | 预案 |\n|---|---|---|---|\n| T+2h | 声明延迟 | 未能按时发出 | 临时简短声明"正在调查,1h内更新" |\n| T+6h | KOL 立场冻结 | 负向情绪 > 60% | 触发 Plan B:CEO 视频道歉 |\n| T+18h | 监管询问 | 概率 80% | 合规版声明已备,法务直接响应 |\n| T+24h | 集体诉讼舆论 | 法律类话题进热搜 | 公开补偿通道,阻断诉讼动机 |\n\n**绝对红线(不可触碰):**\n- ❌ 否认或缩小泄露规模\n- ❌ 质疑用户截图或举报真实性\n- ❌ 以技术复杂性规避问责',
action_plan:
'**48 小时行动计划**\n\n```\nT+0.5h 危机小组集结(CEO / PR总监 / 法务 / 产品)\nT+1h 声明文本完成法务审核\nT+1.5h 核心媒体独家简报发出(3家)\nT+2h ★ 官方声明发布(微博 + 公众号)\n + App Push / 站内信 推送受损用户\nT+3h KOL 微信定向沟通(20人名单)\nT+4h 客服热线扩容至 2× 正常容量\nT+6h 第二轮进展更新发布\nT+18h 监管询问响应准备就位\nT+24h 详细技术说明 + 影响范围报告发布\nT+48h 专项补偿通道正式上线\nT+72h 第三方安全审计启动公告\n```',
},
dimensions: {
叙事框架: {
label: '叙事分析师',
latest_analysis:
'采用"危机承担型"框架,CEO 署名,200 字主声明 + 话术库覆盖全场景。关键洞察:补偿与声明同步发出可降低 31% 愤怒情绪。',
},
受众分析: {
label: '受众洞察师',
latest_analysis:
'直接受损用户为最高优先级(隐私敏感度 8.3/10),KOL 态度在 6h 内冻结。分层触达策略已量化,7 日卸载率目标 < 5%。',
},
风险研判: {
label: '风险评估师',
latest_analysis:
'T+2h 为最关键窗口,每延迟 1h 负面情绪扩散系数增加 0.3σ。T+18h 监管询问概率 80%,合规预案已备。',
},
渠道传播: {
label: '渠道策略师',
latest_analysis:
'五路渠道协同(官方微博/公众号、App Push、媒体简报、KOL 沟通、客服扩容),SOP 已锁定到人到时间。',
},
},
meta: {
total_rounds: 5,
diverge_rounds: 3,
agent_count: 4,
},
},
},
];
export const MOCK_SESSION = {
taskContext: MOCK_TASK_CONTEXT,
status: 'waiting_human',
phase: 'diverging',
coordinatorMode: 'human_review',
currentRound: 4,
divergeRounds: 3,
reportSchema: REPORT_SCHEMA,
agents: AGENTS,
agentStates: AGENT_STATES,
timeline: TIMELINE,
sharedMemory: SHARED_MEMORY,
finalReportPartial: {
core_strategy: '主动承担型叙事,CEO 第一人称署名,T+2h 发出首版声明',
key_messages: '① 道歉承担;② 已修复;③ 补偿方案;④ 个人通知;⑤ 透明承诺',
audience_map: '直接受损用户(私域)> 媒体 KOL(独家)> 公域大众(声明)',
risk_warnings: 'T+2h 声明关键节点;Plan B 触发条件;三条红线',
action_plan: 'T+2h 声明 → T+6h 更新 → T+24h 报告 → T+48h 补偿通道',
},
currentFinalField: null,
pendingReview: {
divergeRound: 2,
totalDivergeRounds: 3,
nextSpeakers: ['narrative', 'risk'],
focus: {
narrative: '深化叙事结构,给出 CEO 声明草稿(200 字以内),避免"技术故障"措辞',
risk: '量化各风险节点的时间窗口和触发阈值,补充监管应对预案',
},
draft: {
core_strategy: '主动承担型叙事,CEO 第一人称署名,T+2h 发出首版声明',
key_messages: '① 道歉承担;② 已修复;③ 补偿方案;④ 个人通知;⑤ 透明承诺',
audience_map: '直接受损用户(私域)> 媒体 KOL(独家)> 公域大众(声明)',
risk_warnings: null,
action_plan: null,
},
gaps: [
'CEO 是否愿意出面公开致歉尚未确认,影响叙事策略选择',
'泄露数据的具体字段范围尚未完全核查,影响话术精准度',
'监管是否已介入需在声明前确认,需准备合规版本',
],
},
reviewSubmitting: false,
reviewRevising: false,
errors: [],
startSession: () => {},
stopSession: () => {},
approveCoordinator: async () => {},
reviseCoordinator: async () => {},
hydrateFromSnapshot: () => {},
reset: () => {},
};
@@ -0,0 +1,309 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { useSession } from '@/hooks/useSession';
import { useTheme } from '../../../contexts/ThemeContext';
import ConfigPanel from './components/ConfigPanel';
import Sidebar from './components/Sidebar';
import Timeline from './components/Timeline';
import ProgressSidebar from './components/ProgressSidebar';
import ScenarioConfigPage from './components/ScenarioConfigPage';
import './MopeChat.css';
type StartConfig = {
task_context?: string;
[key: string]: any;
};
type SessionError = {
agent_id?: string;
error?: string;
};
type SessionHistoryPayload = {
typeName: string;
templateName: string;
resultJson: Record<string, any>;
};
const MOPE_HISTORY_API_BASE = '/api/mope/history';
/**
* 保存模型完整生成结果。
*
* 接口暂未提供,先把调用方法和请求体结构固定下来:
* - typeName:类型名字,目前使用用户输入的任务背景。
* - templateName:用户选择的生成模板名称。
* - resultJson:完整结果 JSON,包含 timeline、agentStates、reportSchema、finalReport 等恢复页面所需数据。
*/
async function saveMopeHistory(payload: SessionHistoryPayload) {
const response = await fetch(MOPE_HISTORY_API_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`保存 MOPE 历史失败:HTTP ${response.status}`);
}
return response.json().catch(() => null);
}
/**
* 获取当前用户最近一次 MOPE 生成历史。
*
* 接口暂未提供,先约定返回值就是保存时的 resultJson。
* 如果接口没有历史数据,应返回 null / 空对象 / 404,页面会保持当前未生成状态。
*/
async function fetchMopeHistory(): Promise<Record<string, any> | null> {
const response = await fetch(MOPE_HISTORY_API_BASE, { method: 'GET' });
if (response.status === 404 || response.status === 204) return null;
if (!response.ok) {
throw new Error(`获取 MOPE 历史失败:HTTP ${response.status}`);
}
const data = await response.json();
return data?.resultJson || data || null;
}
export default function MopeChat() {
const session = useSession() as any;
const { theme } = useTheme() as { theme?: string };
const isDark = theme === 'dark';
const [taskContext, setTaskContext] = useState<string>('');
const [idlePage, setIdlePage] = useState<string>('home');
const [isLeftSidebarCollapsed, setIsLeftSidebarCollapsed] = useState(false);
const [isRightSidebarCollapsed, setIsRightSidebarCollapsed] = useState(false);
const {
status,
phase,
coordinatorMode,
currentRound,
divergeRounds,
reportSchema,
agents,
agentStates,
timeline,
sharedMemory,
finalReportPartial,
currentFinalField,
pendingReview,
reviewSubmitting,
reviewRevising,
errors,
startSession,
stopSession,
approveCoordinator,
reviseCoordinator,
hydrateFromSnapshot,
reset,
} = session;
const historyLoadedRef = useRef(false);
const savedHistoryKeyRef = useRef<string | null>(null);
const currentConfigRef = useRef<StartConfig | null>(null);
const buildHistorySnapshot = useCallback(() => ({
status,
phase,
coordinatorMode,
currentRound,
divergeRounds,
reportSchema,
agents,
agentStates,
timeline,
sharedMemory,
finalReportPartial,
currentFinalField,
config: currentConfigRef.current,
}), [
status,
phase,
coordinatorMode,
currentRound,
divergeRounds,
reportSchema,
agents,
agentStates,
timeline,
sharedMemory,
finalReportPartial,
currentFinalField,
]);
const handleStart = (config: StartConfig, backendUrl: string) => {
currentConfigRef.current = config;
savedHistoryKeyRef.current = null;
setTaskContext(config.task_context || '');
startSession(config, backendUrl);
};
const handleStop = () => {
stopSession();
};
const handleReset = () => {
reset();
currentConfigRef.current = null;
savedHistoryKeyRef.current = null;
setTaskContext('');
};
useEffect(() => {
if (historyLoadedRef.current) return;
historyLoadedRef.current = true;
void fetchMopeHistory()
.then((snapshot) => {
if (!snapshot || !Array.isArray(snapshot.timeline) || snapshot.timeline.length === 0) return;
currentConfigRef.current = snapshot.config || null;
setTaskContext(snapshot.config?.task_context || snapshot.typeName || '');
hydrateFromSnapshot(snapshot);
})
.catch((error) => {
console.warn('MOPE 历史记录获取失败,保持默认空状态:', error);
});
}, [hydrateFromSnapshot]);
useEffect(() => {
if (status !== 'done' || !timeline?.length) return;
const snapshot = buildHistorySnapshot();
const templateName = currentConfigRef.current?.template_name || '未命名模板';
const typeName = taskContext || currentConfigRef.current?.task_context || '未命名任务';
const saveKey = `${typeName}::${templateName}::${timeline.length}`;
if (savedHistoryKeyRef.current === saveKey) return;
savedHistoryKeyRef.current = saveKey;
void saveMopeHistory({
typeName,
templateName,
resultJson: {
...snapshot,
typeName,
templateName,
savedAt: new Date().toISOString(),
},
}).catch((error) => {
console.warn('MOPE 历史记录保存失败:', error);
savedHistoryKeyRef.current = null;
});
}, [status, timeline, taskContext, buildHistorySnapshot]);
if (status === 'idle') {
if (idlePage === 'scenario_config') {
return (
<div className={`mope-chat app-idle app-idle-scroll ${isDark ? 'mope-chat-dark' : 'mope-chat-light'}`}>
<ScenarioConfigPage onBack={() => setIdlePage('home')} />
</div>
);
}
return (
<div className={`mope-chat app-idle ${isDark ? 'mope-chat-dark' : 'mope-chat-light'}`}>
<ConfigPanel
onStart={handleStart}
isRunning={false}
onStop={handleStop}
onOpenScenarioConfig={() => setIdlePage('scenario_config')}
/>
</div>
);
}
return (
<div className={`mope-chat app-running ${isDark ? 'mope-chat-dark' : 'mope-chat-light'}`}>
<header className="app-header">
<span className="app-header-logo">MOPE</span>
<span className="app-header-task">
{taskContext ? `"${taskContext.slice(0, 80)}${taskContext.length > 80 ? '…' : ''}"` : '分析中…'}
</span>
<span className="badge badge-default">
{coordinatorMode === 'human_review' ? '人工确认' : '全自动'}
</span>
{status === 'waiting_human' && (
<span className="badge badge-diverging">等待人工确认</span>
)}
<div className="app-header-actions">
{(status === 'running' || status === 'waiting_human') && (
<button className="btn-danger" onClick={handleStop}>
停止
</button>
)}
{(status === 'done' || status === 'error') && (
<button className="btn-secondary" onClick={handleReset}>
重新开始
</button>
)}
</div>
</header>
<div className="app-body">
<aside className={`app-sidebar app-side-panel${isLeftSidebarCollapsed ? ' collapsed' : ''}`}>
<button
type="button"
className="app-side-toggle app-side-toggle-left"
onClick={() => setIsLeftSidebarCollapsed(v => !v)}
aria-label={isLeftSidebarCollapsed ? '展开左侧栏' : '收起左侧栏'}
title={isLeftSidebarCollapsed ? '展开左侧栏' : '收起左侧栏'}
>
{isLeftSidebarCollapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
</button>
{!isLeftSidebarCollapsed && (
<Sidebar
agents={agents}
agentStates={agentStates}
phase={phase}
currentRound={currentRound}
divergeRounds={divergeRounds}
/>
)}
</aside>
<main className="app-main">
{errors.length > 0 && errors.map((e: SessionError, i: number) => (
<div key={i} className="error-banner">
{e.agent_id ? `[${e.agent_id}] ` : ''}{e.error}
</div>
))}
<Timeline
timeline={timeline}
agentStates={agentStates}
reportSchema={reportSchema}
pendingReview={pendingReview}
onApproveReview={approveCoordinator}
onReviseReview={reviseCoordinator}
reviewSubmitting={reviewSubmitting}
reviewRevising={reviewRevising}
/>
</main>
<aside className={`app-progress-sidebar app-side-panel${isRightSidebarCollapsed ? ' collapsed' : ''}`}>
<button
type="button"
className="app-side-toggle app-side-toggle-right"
onClick={() => setIsRightSidebarCollapsed(v => !v)}
aria-label={isRightSidebarCollapsed ? '展开右侧栏' : '收起右侧栏'}
title={isRightSidebarCollapsed ? '展开右侧栏' : '收起右侧栏'}
>
{isRightSidebarCollapsed ? <ChevronLeft size={16} /> : <ChevronRight size={16} />}
</button>
{!isRightSidebarCollapsed && (
<ProgressSidebar
phase={phase}
status={status}
currentRound={currentRound}
divergeRounds={divergeRounds}
reportSchema={reportSchema}
sharedMemory={sharedMemory}
finalReportPartial={finalReportPartial}
currentFinalField={currentFinalField}
/>
)}
</aside>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,9 @@
import { Outlet } from 'react-router-dom';
export default function QACenterPage() {
return (
<div>
<Outlet />
</div>
);
}
@@ -0,0 +1,782 @@
import React, { useState } from 'react';
import { Textarea } from 'tdesign-react';
import {
Bookmark,
BrainCircuit,
Bot,
ChevronDown,
FileSearch,
Loader2,
RotateCcw,
Search,
Send,
Settings,
Sparkles,
Square,
Copy,
Trash2,
Edit3,
Upload,
UserRound,
} from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
import MarkdownRenderer from '../../MarkdownRenderer';
/**
* 单条检索结果。
*
* 多轮检索过程中每一轮可能返回多条资料,这里只保留 UI 展示所需的标题、摘要和来源。
*/
export interface QARetrievalItem {
id: string;
title: string;
content: string;
source: string;
}
/**
* 多轮检索中的单轮思考节点。
*
* `queryLoading` 控制该轮检索结果是否仍显示 loading;retrievals 到达后再渲染资料列表。
*/
export interface QAThinkingRound {
id: string; // 唯一标识符,用于列表渲染时的 key
title: string; // 该轮检索的标题,如"第1轮检索"或"背景资料收集"
thoughtMarkdown: string; // 模型思考过程的 Markdown 内容,展示思考步骤和推理逻辑
query: string; // 该轮发送给检索系统的查询语句
queryLoading?: boolean; // 该轮检索是否仍在进行中,true 时显示加载动画
retrievals: QARetrievalItem[]; // 该轮返回的检索结果列表
}
/**
* 多轮/深度检索的完整思考链。
*
* rounds 展示检索过程,finalAnswerMarkdown 展示最终回答;两者分开便于流式过程中逐步更新。
*/
export interface QAThinkingTrace {
title?: string; // 整体思考链的标题
rounds: QAThinkingRound[]; // 多轮检索过程中的所有轮次,每轮包含思考和检索结果
finalAnswerMarkdown: string; // 基于多轮检索结果生成的最终回答内容
}
/**
* 单轮 RAG 的回答结构。
*
* 后端可能把 `<think>...</think>` 和答案混在同一个 answer 流中,解析后分别放到 thinkingMarkdown 与 answerMarkdown。
*/
export interface QASingleRagTrace {
title?: string; // 思考过程的标题,如"单轮 RAG 思考过程"
thinkingMarkdown: string; // 模型在生成回答前的思考过程内容,使用 Markdown 格式
answerMarkdown: string; // 最终生成的回答内容,使用 Markdown 格式
thinkingComplete?: boolean; // 思考过程是否已完成,true 时思考区域可折叠
}
export interface QACommonMessage {
id: string; // 消息唯一标识符
role: 'user' | 'assistant'; // 消息发送者角色:user 为用户消息,assistant 为 AI 回复
content: string; // 消息的纯文本内容(当使用 thinkingTrace 或 singleRagTrace 时为空)
timestamp?: Date; // 消息发送时间
thinkingTrace?: QAThinkingTrace; // 多轮/深度检索模式的思考链,包含多轮检索过程和最终回答
sources?: { // 消息引用的参考资料列表
title: string; // 参考资料标题
type: string; // 资料类型,如"文档"、"网页"等
snippet: string; // 资料摘要内容
source?: string; // 来源名称或 URL
time?: string; // 发布时间
url?: string; // 资料链接
content?: string; // 完整内容(可能用于详情弹窗)
}[];
}
export interface QACommonMessage {
singleRagTrace?: QASingleRagTrace; // 单轮 RAG 模式的思考过程和回答结构
}
interface QAAvatarProps {
role: 'user' | 'assistant';
isActive?: boolean;
label?: string;
variant?: 'default' | 'blue' | 'purple';
}
const getAssistantAvatarClass = (variant: QAAvatarProps['variant'], isDark: boolean) => {
switch (variant) {
case 'blue':
return isDark
? 'bg-gradient-to-br from-blue-400 via-blue-600 to-cyan-700 text-white'
: 'bg-gradient-to-br from-blue-300 via-blue-500 to-cyan-500 text-white';
case 'purple':
return isDark
? 'bg-gradient-to-br from-purple-400 via-violet-600 to-fuchsia-700 text-white'
: 'bg-gradient-to-br from-purple-300 via-violet-500 to-fuchsia-500 text-white';
default:
return isDark
? 'bg-gradient-to-br from-cyan-500 via-blue-600 to-violet-600 text-white'
: 'bg-gradient-to-br from-red-400 via-rose-500 to-orange-400 text-white';
}
};
/**
* 统一头像组件。
*
* 单一模式使用默认助手色;对比模式可通过 variant 指定蓝/紫色系,让头像与左右面板标签保持一致。
* `isActive` 会触发呼吸和星光动画,用来提示当前助手正在生成。
*/
export const QAAvatar: React.FC<QAAvatarProps> = ({ role, isActive = false, label, variant = 'default' }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const Icon = role === 'assistant' ? Bot : UserRound;
return (
<div
className={`relative w-9 h-9 rounded-2xl flex items-center justify-center flex-shrink-0 overflow-hidden shadow-sm ${
role === 'assistant'
? getAssistantAvatarClass(variant, isDark)
: isDark
? 'bg-gradient-to-br from-slate-500 via-slate-700 to-slate-900 text-white'
: 'bg-gradient-to-br from-sky-400 via-indigo-500 to-violet-500 text-white'
} ${isActive ? 'qa-avatar-active' : ''}`}
title={label}
>
<div className="absolute inset-0 bg-white/20 opacity-0 qa-avatar-shine" />
<Icon className={`w-5 h-5 relative z-10 ${isActive ? 'qa-avatar-icon-active' : ''}`} />
{role === 'assistant' && isActive && (
<Sparkles className="absolute -right-0.5 -top-0.5 w-3.5 h-3.5 text-yellow-200 qa-avatar-spark" />
)}
</div>
);
};
interface TypingIndicatorProps {
label?: string;
assistantLabel?: string;
}
/**
* 助手预连接/生成中的占位提示。
*
* 用于请求已经发出但还没有生成可展示消息时,给用户明确的等待反馈。
*/
export const TypingIndicator: React.FC<TypingIndicatorProps> = ({ label = '正在思考并生成回复', assistantLabel = 'AI 助手' }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="flex gap-3">
<QAAvatar role="assistant" isActive label={assistantLabel} />
<div className={`max-w-[70%] rounded-2xl px-4 py-3 shadow-sm border ${
isDark ? 'bg-slate-700/60 border-slate-600/50 text-slate-200' : 'bg-white border-red-100 text-gray-700'
}`}>
<div className="flex items-center gap-3">
<Loader2 className={`w-4 h-4 animate-spin ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
<span
className={`text-sm font-medium qa-generating-marquee ${
isDark ? 'qa-generating-marquee-dark' : 'qa-generating-marquee-light'
}`}
>
{label}
</span>
<div className="flex gap-1">
{[0, 1, 2].map((idx) => (
<span
key={idx}
className={`w-1.5 h-1.5 rounded-full qa-typing-dot ${isDark ? 'bg-blue-300' : 'bg-red-400'}`}
style={{ animationDelay: `${idx * 160}ms` }}
/>
))}
</div>
</div>
</div>
</div>
);
};
interface ChatMessageProps<T extends QACommonMessage> {
message: T;
showSources?: boolean;
assistantLabel?: string;
assistantActive?: boolean;
onCitationClick?: (message: T, citationIndex: number) => void;
onCopyAssistant?: (message: T) => void;
onDeleteAssistant?: (message: T) => void;
onEditAssistant?: (message: T) => void;
onResendAssistant?: (message: T) => void;
renderAssistantExtra?: (message: T) => React.ReactNode;
}
/**
* 聊天消息渲染器。
*
* 根据消息类型自动选择普通文本、单轮 RAG 回答或多轮检索思考链。
* sources 区域独立折叠展示,避免引用资料过长时挤占主回答阅读空间。
*/
export function ChatMessage<T extends QACommonMessage>({
message,
showSources = true,
assistantLabel = 'AI 助手',
assistantActive = false,
onCitationClick,
onCopyAssistant,
onDeleteAssistant,
onEditAssistant,
onResendAssistant,
renderAssistantExtra,
}: ChatMessageProps<T>) {
const { theme } = useTheme();
const isDark = theme === 'dark';
const isUser = message.role === 'user';
const hasTrace = !isUser && !!(message.singleRagTrace || message.thinkingTrace);
return (
<div className="space-y-3">
<div className={`flex gap-3 ${isUser ? 'justify-end' : ''}`}>
{!isUser && <QAAvatar role="assistant" isActive={assistantActive} label={assistantLabel} />}
<div className={`${hasTrace ? 'max-w-[85%] flex-1' : 'max-w-[72%]'} rounded-2xl px-4 py-3 shadow-sm ${
isUser
? isDark
? 'bg-blue-600/70 text-white'
: 'bg-gradient-to-r from-red-500 to-rose-500 text-white'
: message.singleRagTrace || message.thinkingTrace
? isDark
? 'bg-slate-800/40 text-slate-100 border border-slate-600/40'
: 'bg-transparent text-gray-800 border border-transparent shadow-none px-0 py-0'
: isDark
? 'bg-slate-700/60 text-slate-100 border border-slate-600/40'
: 'bg-white text-gray-800 border border-gray-100'
}`}>
{message.singleRagTrace && !isUser ? (
<SingleRagAnswer
trace={message.singleRagTrace}
onCitationClick={(citationIndex) => onCitationClick?.(message, citationIndex)}
/>
) : message.thinkingTrace && !isUser ? (
<RetrievalThinkingTrace
trace={message.thinkingTrace}
onCitationClick={(citationIndex) => onCitationClick?.(message, citationIndex)}
/>
) : (
<p className="whitespace-pre-wrap leading-relaxed">{message.content}</p>
)}
{!isUser && (onCopyAssistant || onDeleteAssistant || onEditAssistant || onResendAssistant) && (
<div className={`mt-2 flex items-center justify-end gap-1.5 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
{onCopyAssistant && (
<button type="button" onClick={() => onCopyAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-slate-100' : 'hover:bg-gray-100 hover:text-gray-700'}`} title="复制回答">
<Copy className="w-3.5 h-3.5" />
</button>
)}
{onDeleteAssistant && (
<button type="button" onClick={() => onDeleteAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-rose-300' : 'hover:bg-red-50 hover:text-red-600'}`} title="删除问答">
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
{onEditAssistant && (
<button type="button" onClick={() => onEditAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-blue-300' : 'hover:bg-blue-50 hover:text-blue-600'}`} title="编辑回答和思考过程">
<Edit3 className="w-3.5 h-3.5" />
</button>
)}
{onResendAssistant && (
<button type="button" onClick={() => onResendAssistant(message)} className={`p-1.5 rounded-md transition-colors ${isDark ? 'hover:bg-slate-700 hover:text-emerald-300' : 'hover:bg-emerald-50 hover:text-emerald-600'}`} title="重新发送">
<RotateCcw className="w-3.5 h-3.5" />
</button>
)}
</div>
)}
</div>
{isUser && <QAAvatar role="user" label="用户" />}
</div>
{!isUser && message.sources && showSources && (
<CollapsibleSection
defaultOpen={false}
className={`ml-12 ${hasTrace ? 'max-w-[85%]' : 'max-w-[72%]'} ${isDark ? 'bg-slate-800/50 border-slate-700/50' : 'bg-gray-50 border-gray-200'}`}
title={(
<div className="flex items-center gap-2">
<BrainCircuit className={`w-3.5 h-3.5 ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
<span className={`text-xs font-medium ${isDark ? 'text-slate-300' : 'text-gray-700'}`}>引用来源</span>
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>({message.sources.length})</span>
</div>
)}
>
<div className="space-y-2">
{message.sources.map((source, idx) => (
<div key={idx} className={`text-xs p-2 rounded-[4px] border shadow-sm transition-all duration-200 ease-out cursor-pointer hover:-translate-y-0.5 hover:scale-[1.01] ${isDark ? 'bg-slate-700/50 border-slate-600/50 shadow-black/10 hover:bg-slate-700/80 hover:border-blue-400/40 hover:shadow-lg hover:shadow-blue-950/20' : 'bg-white border-gray-100 shadow-gray-200/60 hover:border-red-200 hover:shadow-md hover:shadow-red-100/70'}`}>
<div className="flex items-center gap-2 mb-1 min-w-0">
<BrainCircuit className={`w-3 h-3 shrink-0 ${isDark ? 'text-blue-400' : 'text-red-400'}`} />
<span className={`font-medium truncate min-w-0 flex-1 ${isDark ? 'text-slate-300' : 'text-gray-700'}`} title={source.title}>{source.title}</span>
<span className={`shrink-0 px-1.5 py-0.5 rounded text-xs ${isDark ? 'bg-slate-600 text-slate-300' : 'bg-gray-200 text-gray-500'}`}>
{source.type}
</span>
</div>
<p className={`line-clamp-3 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{source.snippet}</p>
</div>
))}
</div>
</CollapsibleSection>
)}
{!isUser && renderAssistantExtra?.(message)}
</div>
);
}
interface CollapsibleSectionProps {
title: React.ReactNode;
children: React.ReactNode;
defaultOpen?: boolean;
className?: string;
headerClassName?: string;
}
/**
* 通用折叠面板。
*
* 思考过程、检索结果、引用来源都复用该组件,保证展开/收起动画和深浅色主题一致。
*/
const CollapsibleSection: React.FC<CollapsibleSectionProps> = ({
title,
children,
defaultOpen = true,
className = '',
headerClassName = '',
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [open, setOpen] = useState(defaultOpen);
return (
<div className={`rounded-2xl border overflow-hidden ${isDark ? 'border-slate-700/60 bg-slate-900/30' : 'border-red-100 bg-white'} ${className}`}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
className={`w-full flex items-center justify-between gap-3 px-4 py-3 text-left transition-colors ${
isDark ? 'hover:bg-slate-800/70' : 'hover:bg-red-50/60'
} ${headerClassName}`}
>
<div className="flex-1 min-w-0">{title}</div>
<ChevronDown
className={`w-4 h-4 flex-shrink-0 transition-transform duration-300 ease-out ${
open ? 'rotate-0' : '-rotate-90'
} ${isDark ? 'text-slate-400' : 'text-gray-500'}`}
/>
</button>
<div className={`grid transition-[grid-template-rows,opacity] duration-300 ease-out ${open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'}`}>
<div className="overflow-hidden">
<div className={`px-4 pb-4 transition-transform duration-300 ease-out ${open ? 'translate-y-0' : '-translate-y-2'} ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>
{children}
</div>
</div>
</div>
</div>
);
};
interface SingleRagAnswerProps {
trace: QASingleRagTrace;
onCitationClick?: (citationIndex: number) => void;
}
/**
* 单轮 RAG 回答展示。
*
* 思考内容和最终回答分成两个区域:思考区可折叠,回答区始终展示。
* 这样既保留模型推理过程,也不会影响用户优先阅读最终答案。
*/
export const SingleRagAnswer: React.FC<SingleRagAnswerProps> = ({ trace, onCitationClick }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<CollapsibleSection
defaultOpen
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
title={
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${
isDark ? 'bg-violet-500/15 text-violet-300' : 'bg-orange-50 text-orange-500'
}`}>
<Sparkles className="w-5 h-5" />
</div>
<div>
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>{trace.title || '思考过程'}</div>
{trace.thinkingComplete ? (
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
思考已完成,可展开查看
</div>
) : (
<div className={`text-xs qa-generating-marquee ${isDark ? 'qa-generating-marquee-dark text-slate-300' : 'qa-generating-marquee-light text-gray-600'}`}>
正在接收 &lt;think&gt; 流式思考内容
</div>
)}
</div>
</div>
}
>
{trace.thinkingMarkdown ? (
<MarkdownRenderer
content={trace.thinkingMarkdown}
className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
) : (
<div className={`text-sm flex items-center gap-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Loader2 className="w-4 h-4 animate-spin" />
等待模型输出思考内容...
</div>
)}
</CollapsibleSection>
<div className={`rounded-2xl border p-4 ${isDark ? 'bg-slate-700/60 border-slate-600/40' : 'bg-white border-gray-100'}`}>
<div className={`text-sm font-semibold mb-3 ${isDark ? 'text-white' : 'text-gray-900'}`}>生成的回答</div>
{trace.answerMarkdown ? (
<MarkdownRenderer
content={trace.answerMarkdown}
className={`text-sm ${isDark ? 'text-slate-200' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
) : (
<div className={`text-sm flex items-center gap-2 ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
<Loader2 className="w-4 h-4 animate-spin" />
{trace.thinkingComplete ? '正在流式生成回答...' : '思考结束后开始生成回答...'}
</div>
)}
</div>
</div>
);
};
interface RetrievalListProps {
items: QARetrievalItem[];
}
/**
* 检索结果列表。
*
* 每条资料默认折叠,首条默认展开,便于用户快速看到最相关资料,同时保留完整资料明细。
*/
const RetrievalList: React.FC<RetrievalListProps> = ({ items }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="space-y-2">
{items.map((item, index) => (
<CollapsibleSection
key={item.id}
defaultOpen={index === 0}
className={isDark ? 'bg-slate-800/40' : 'bg-gray-50/70'}
title={
<div className="flex items-center gap-2">
<span className={`w-6 h-6 rounded-lg flex items-center justify-center text-xs font-semibold ${
isDark ? 'bg-blue-500/20 text-blue-300' : 'bg-red-100 text-red-600'
}`}>{index + 1}</span>
<div className="min-w-0">
<div className={`text-sm font-medium truncate ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{item.title}</div>
<div className={`text-xs truncate ${isDark ? 'text-slate-500' : 'text-gray-500'}`}>来源:{item.source}</div>
</div>
</div>
}
>
<p className={`text-sm leading-relaxed ${isDark ? 'text-slate-300' : 'text-gray-600'}`}>{item.content}</p>
</CollapsibleSection>
))}
</div>
);
};
interface SearchQueryBadgeProps {
query: string;
loading?: boolean;
}
/**
* 检索词徽标。
*
* 多轮检索每一轮都会产生一个 query;loading 状态下展示旋转图标,表示该 query 正在召回资料。
*/
const SearchQueryBadge: React.FC<SearchQueryBadgeProps> = ({ query, loading = false }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`inline-flex max-w-full items-center gap-2 rounded-full px-3 py-1.5 border ${
isDark ? 'bg-blue-500/10 border-blue-400/20 text-blue-200' : 'bg-red-50 border-red-100 text-red-600'
}`}>
{loading ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
<span className="text-xs flex-shrink-0">检索词</span>
<span className={`text-xs font-medium truncate qa-generating-marquee ${isDark ? 'qa-generating-marquee-dark' : 'qa-generating-marquee-light'}`}>
{query}
</span>
</div>
);
};
interface RetrievalThinkingTraceProps {
trace: QAThinkingTrace;
onCitationClick?: (citationIndex: number) => void;
}
/**
* 多轮/深度检索思考链展示。
*
* 组件按轮次渲染思考内容、检索词和检索结果,底部展示最终回答。
* 它只消费已经整理好的 trace 数据,不关心数据来自真实 SSE 还是本地模拟。
*/
export const RetrievalThinkingTrace: React.FC<RetrievalThinkingTraceProps> = ({ trace, onCitationClick }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="w-full min-w-0 max-w-5xl space-y-4 overflow-hidden">
<CollapsibleSection
defaultOpen
className={isDark ? 'bg-slate-800/60 shadow-lg shadow-black/10' : 'bg-white shadow-sm'}
title={
<div className="flex items-center gap-3">
<div className={`w-9 h-9 rounded-2xl flex items-center justify-center ${
isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-50 text-red-500'
}`}>
<BrainCircuit className="w-5 h-5" />
</div>
<div>
<div className={`text-sm font-semibold ${isDark ? 'text-white' : 'text-gray-900'}`}>
{trace.title || '思考过程'}
</div>
<div className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>
共 {trace.rounds.length} 轮推理与检索,可展开查看细节
</div>
</div>
</div>
}
>
<div className="space-y-3 pt-1">
{trace.rounds.map((round, index) => (
<div key={round.id} className={`relative pl-5 ${index < trace.rounds.length - 1 ? 'pb-2' : ''}`}>
{index < trace.rounds.length - 1 && (
<div className={`absolute left-[7px] top-8 bottom-0 w-px ${isDark ? 'bg-slate-700' : 'bg-red-100'}`} />
)}
<div className={`absolute left-0 top-4 w-3.5 h-3.5 rounded-full border-2 ${
isDark ? 'bg-slate-900 border-cyan-400' : 'bg-white border-red-400'
}`} />
<CollapsibleSection
defaultOpen
className={isDark ? 'bg-slate-900/40' : 'bg-red-50/30'}
title={
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${
isDark ? 'bg-cyan-500/15 text-cyan-300' : 'bg-red-100 text-red-600'
}`}>第 {index + 1} 轮</span>
<span className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{round.title}</span>
</div>
}
>
<div className="space-y-3">
<CollapsibleSection
defaultOpen
className={isDark ? 'bg-slate-800/50' : 'bg-white/80'}
title={
<div className="flex items-center gap-2">
<Sparkles className={`w-4 h-4 ${isDark ? 'text-violet-300' : 'text-orange-500'}`} />
<span className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-800'}`}>思考内容</span>
</div>
}
>
<MarkdownRenderer
content={round.thoughtMarkdown}
className={`text-sm ${isDark ? 'text-slate-300' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
</CollapsibleSection>
<div className="flex items-center">
<SearchQueryBadge query={round.query} loading={round.queryLoading} />
</div>
<CollapsibleSection
defaultOpen={!round.queryLoading}
className={isDark ? 'bg-slate-800/50' : 'bg-white/80'}
title={
<div className="flex items-center gap-2">
<FileSearch className={`w-4 h-4 ${isDark ? 'text-blue-300' : 'text-red-500'}`} />
<span className={`text-sm font-medium ${isDark ? 'text-slate-100' : 'text-gray-800'}`}>检索内容</span>
<span className={`text-xs ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{round.retrievals.length} 条</span>
</div>
}
>
{round.queryLoading ? (
<div className={`rounded-xl px-3 py-4 text-sm flex items-center gap-2 ${isDark ? 'bg-slate-900/60 text-slate-400' : 'bg-gray-50 text-gray-500'}`}>
<Loader2 className="w-4 h-4 animate-spin" />
正在召回相关内容...
</div>
) : (
<RetrievalList items={round.retrievals} />
)}
</CollapsibleSection>
</div>
</CollapsibleSection>
</div>
))}
</div>
</CollapsibleSection>
<div className={`rounded-2xl border p-4 ${isDark ? 'bg-slate-700/60 border-slate-600/40' : 'bg-white border-gray-100'}`}>
<div className={`text-sm font-semibold mb-3 ${isDark ? 'text-white' : 'text-gray-900'}`}>最终回答</div>
<MarkdownRenderer
content={trace.finalAnswerMarkdown}
className={`text-sm ${isDark ? 'text-slate-200' : 'text-gray-700'}`}
onCitationClick={onCitationClick}
/>
</div>
</div>
);
};
interface EmptyChatStateProps {
icon?: React.ReactNode;
title: string;
description: string;
}
/**
* 空状态展示。
*
* 当当前模式没有任何消息时使用,提示用户可以开始输入问题。
*/
export const EmptyChatState: React.FC<EmptyChatStateProps> = ({ icon, title, description }) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className={`h-full flex flex-col items-center justify-center ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>
<div className={`w-20 h-20 rounded-3xl mb-5 flex items-center justify-center ${
isDark ? 'bg-slate-700/40' : 'bg-gradient-to-br from-red-50 to-orange-50'
}`}>
{icon ?? <Bot className="w-11 h-11 opacity-60" />}
</div>
<p className="text-lg font-medium">{title}</p>
<p className="text-sm mt-2">{description}</p>
</div>
);
};
interface QAComposerProps {
value: string;
placeholder: string;
isLoading: boolean;
canSubmit?: boolean;
onChange: (value: string) => void;
onSubmit: () => void;
onStop: () => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onUpload?: () => void;
onReset?: () => void;
onSave?: () => void;
onCollect?: () => void;
onOpenSettings?: () => void;
showUpload?: boolean;
showReset?: boolean;
showSave?: boolean;
showCollect?: boolean;
}
/**
* 问答输入区。
*
* 负责输入、发送/停止、附件、重置、保存、收藏和模型参数设置入口。
* 具体业务动作通过 props 回调交给父组件处理,保证输入区本身不依赖某个问答模式。
*/
export const QAComposer: React.FC<QAComposerProps> = ({
value,
placeholder,
isLoading,
canSubmit,
onChange,
onSubmit,
onStop,
onKeyDown,
onUpload,
onReset,
onSave,
onCollect,
onOpenSettings,
showUpload = true,
showReset = false,
showSave = true,
showCollect = true,
}) => {
const { theme } = useTheme();
const isDark = theme === 'dark';
const submitEnabled = canSubmit ?? Boolean(value.trim());
const iconButtonClass = `p-2 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-600 text-slate-400' : 'hover:bg-gray-200 text-gray-500'}`;
return (
<div className={`border-t p-4 ${isDark ? 'border-slate-700/50' : 'border-red-100'}`}>
<div className={`flex items-end gap-3 rounded-2xl p-3 shadow-inner ${isDark ? 'bg-slate-700/50' : 'bg-gray-100'}`}>
<Textarea
value={value}
onChange={(nextValue) => onChange(String(nextValue))}
onKeyDown={onKeyDown}
placeholder={placeholder}
autosize={{ minRows: 1, maxRows: 15 }}
disabled={isLoading}
className={`flex-1 !bg-transparent !border-0 !shadow-none qa-composer-textarea [&]:!border-0 [&]:!shadow-none [&]:!outline-none [&]:focus:!border-0 [&]:focus:!shadow-none [&]:focus:!outline-none [&_.t-textarea]:!border-0 [&_.t-textarea]:!shadow-none [&_.t-textarea]:!outline-none [&_.t-textarea:hover]:!border-0 [&_.t-textarea:hover]:!shadow-none [&_.t-textarea--focused]:!border-0 [&_.t-textarea--focused]:!shadow-none [&_.t-is-focused]:!border-0 [&_.t-is-focused]:!shadow-none [&_.t-textarea__inner]:!bg-transparent [&_.t-textarea__inner]:!border-0 [&_.t-textarea__inner]:!shadow-none [&_.t-textarea__inner]:!outline-none [&_.t-textarea__inner]:!resize-none [&_.t-textarea__inner]:!min-h-[40px] [&_.t-textarea__inner]:!p-0 [&_.t-textarea__inner:hover]:!border-0 [&_.t-textarea__inner:hover]:!shadow-none [&_.t-textarea__inner:focus]:!border-0 [&_.t-textarea__inner:focus]:!shadow-none [&_.t-textarea__inner:focus]:!outline-none [&_.t-textarea__inner]:disabled:!cursor-not-allowed ${
isDark
? '[&_.t-textarea__inner]:!text-white [&_.t-textarea__inner]:placeholder:!text-slate-500'
: '[&_.t-textarea__inner]:!text-gray-800 [&_.t-textarea__inner]:placeholder:!text-gray-400'
}`}
/>
<div className="flex items-center gap-2">
{showUpload && (
<button onClick={onUpload} className={iconButtonClass} title="上传附件" type="button">
<Upload className="w-5 h-5" />
</button>
)}
{showReset && (
<button onClick={onReset} className={iconButtonClass} title="重新提问" type="button">
<RotateCcw className="w-5 h-5" />
</button>
)}
{showSave && (
<button onClick={onSave} className={iconButtonClass} title="保存记录" type="button">
<Bookmark className="w-5 h-5" />
</button>
)}
{showCollect && (
<button onClick={onCollect} className={iconButtonClass} title="加入收藏" type="button">
<Bookmark className="w-5 h-5" />
</button>
)}
{onOpenSettings && (
<button onClick={onOpenSettings} className={iconButtonClass} title="模型参数设置" type="button">
<Settings className="w-5 h-5" />
</button>
)}
<button
onClick={isLoading ? onStop : onSubmit}
disabled={!isLoading && !submitEnabled}
className={`px-4 py-2 rounded-xl font-medium transition-all flex items-center gap-2 shadow-sm ${
isLoading
? isDark
? 'bg-rose-600 text-white qa-stop-breathing'
: 'bg-red-600 text-white qa-stop-breathing'
: submitEnabled
? isDark
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-red-500 hover:bg-red-600 text-white'
: isDark
? 'bg-slate-600 text-slate-400 cursor-not-allowed'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
type="button"
>
{isLoading ? <Square className="w-4 h-4 fill-current" /> : <Send className="w-4 h-4" />}
{isLoading ? '停止' : '发送'}
</button>
</div>
</div>
</div>
);
};
@@ -0,0 +1,5 @@
import { Outlet, Navigate } from 'react-router-dom';
export default function QALayout() {
return <Outlet />;
}
@@ -0,0 +1,121 @@
import { CircleHelp, Bot, Layers } from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
type QAProvider = 'general' | 'agent' | 'multi-agent';
type ViewMode = 'single' | 'compare';
interface QATypeSwitchProps {
qaProvider: QAProvider; // 当前问答模式:'general' 通用问答 / 'agent' 智能体问答 / 'multi-agent' 协同分析
setQaProvider: (provider: QAProvider) => void; // 设置问答模式的回调函数
selectedAgentName?: string; // 当选择智能体问答时,当前选中的智能体名称
viewMode?: ViewMode; // 当前视图模式(仅通用问答支持):'single' 单一模式 或 'compare' 对比模式
setViewMode?: (mode: ViewMode) => void; // 设置视图模式的回调函数(仅通用问答可用)
}
export default function QATypeSwitch({
qaProvider,
setQaProvider,
selectedAgentName,
viewMode = 'single',
setViewMode,
}: QATypeSwitchProps) {
const { theme } = useTheme();
const isDark = theme === 'dark';
return (
<div className="flex items-center gap-4">
{/* 问答模式切换 */}
<div className={`flex rounded-xl p-1 ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<button
onClick={() => setQaProvider('general')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${
qaProvider === 'general'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
<CircleHelp className="w-4 h-4" />
通用问答
</button>
<button
onClick={() => setQaProvider('agent')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${
qaProvider === 'agent'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
<Bot className="w-4 h-4" />
智能体问答
</button>
<button
onClick={() => setQaProvider('multi-agent')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${
qaProvider === 'multi-agent'
? isDark
? 'bg-green-600 text-white'
: 'bg-green-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-green-600'
}`}
>
<Layers className="w-4 h-4" />
协同分析
</button>
</div>
{/* 当前智能体指示 */}
{qaProvider === 'agent' && selectedAgentName && (
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-full ${isDark ? 'bg-blue-600/20' : 'bg-blue-50'}`}>
<Bot className={`w-4 h-4 ${isDark ? 'text-blue-400' : 'text-blue-600'}`} />
<span className={`text-sm font-medium ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>
{selectedAgentName}
</span>
</div>
)}
{/* 对比模式切换 - 仅通用问答 */}
{qaProvider === 'general' && setViewMode && (
<div className={`flex rounded-xl p-1 ${isDark ? 'bg-slate-700/50' : 'bg-white'}`}>
<button
onClick={() => setViewMode('single')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
viewMode === 'single'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
单一模式
</button>
<button
onClick={() => setViewMode('compare')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
viewMode === 'compare'
? isDark
? 'bg-blue-600 text-white'
: 'bg-red-500 text-white'
: isDark
? 'text-slate-400 hover:text-white'
: 'text-gray-600 hover:text-red-600'
}`}
>
对比模式
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,783 @@
import React, { useState, useEffect, useMemo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { useTheme } from '../../../contexts/ThemeContext';
import {
Edit2, Plus, Trash2, Check, X, ChevronDown, ChevronRight,
Target, Users, Activity, BookOpen, Zap, AlertTriangle,
Settings, FileText, Info, RefreshCw, AlertCircle, Brain, Save,
} from 'lucide-react';
interface FieldRequirement {
title: string;
goal: string;
must_cover: string[];
done_criteria: string[];
blockers: string[];
dependencies: string[];
evidence_needs: string[];
preferred_agents: string[];
style_notes: string;
min_chars?: number;
min_items?: number;
}
interface AgentData {
id: string;
name: string;
dimension: string;
expertise: string;
relevant_fields: string[];
analysis_contract: string[];
}
interface CoordinatorConfig {
system_prompt_prefix?: string;
round1_guidance?: string;
middle_round_guidance?: string;
final_round_guidance?: string;
speaker_selection_rules?: string;
focus_instruction_rules?: string;
dependency_driving_rules?: string[];
consistency_check_rules?: string[];
revise_prompt_prefix?: string;
}
interface TemplateData {
template_id: string;
template_name: string;
template_brief: string;
usage_notes: string[];
report_schema: Record<string, string>;
report_examples: Record<string, string>;
field_requirements: Record<string, FieldRequirement>;
agents: AgentData[];
coordinator_config: CoordinatorConfig;
}
const FIELD_LABELS: Record<string, string> = {
template_id: '模板ID',
template_name: '模板名称',
template_brief: '模板简介',
usage_notes: '使用说明',
report_schema: '报告字段结构',
report_examples: '报告字段示例',
field_requirements: '字段要求配置',
agents: '智能体配置',
coordinator_config: '协调者配置',
title: '字段标题',
goal: '字段目标',
must_cover: '必须覆盖内容',
done_criteria: '完成标准',
blockers: '阻塞条件',
dependencies: '依赖字段',
evidence_needs: '证据需求',
preferred_agents: '推荐智能体',
style_notes: '风格说明',
min_chars: '最小字符数',
min_items: '最小条目数',
id: '智能体ID',
name: '智能体名称',
dimension: '分析维度',
expertise: '专业能力',
relevant_fields: '关联字段',
analysis_contract: '分析契约',
system_prompt_prefix: '系统提示词前缀',
round1_guidance: '第一轮指导',
middle_round_guidance: '中间轮指导',
final_round_guidance: '最终轮指导',
speaker_selection_rules: '发言者选择规则',
focus_instruction_rules: '焦点指令规则',
dependency_driving_rules: '依赖驱动规则',
consistency_check_rules: '一致性检查规则',
revise_prompt_prefix: '修改提示前缀',
};
const REPORT_SCHEMA_LABELS: Record<string, string> = {
objective_analysis: '一、目的分析',
target_actor_analysis: '二、目标行为体分析',
expected_behavior_drivers: '三、预期行为及行为驱动因素分析',
narrative_theme_design: '四、叙事主题设计',
action_plan_list: '五、认知行动方案列表',
};
interface AgentVisual {
Icon: React.ElementType;
lightCard: string;
darkCard: string;
iconLight: string;
iconDark: string;
tagLight: string;
tagDark: string;
}
const AGENT_VISUALS: Record<string, AgentVisual> = {
objective: {
Icon: Target,
lightCard: 'from-blue-50 to-sky-50 border-blue-200 hover:border-blue-400 hover:shadow-blue-200/60',
darkCard: 'from-slate-900/80 to-blue-950/20 border-slate-700/70 hover:border-blue-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-blue-100 text-blue-600',
iconDark: 'bg-slate-800/90 text-blue-300 ring-1 ring-blue-500/20',
tagLight: 'bg-blue-100 text-blue-700 border-blue-200',
tagDark: 'bg-slate-800/70 text-blue-200 border-blue-500/25',
},
actor: {
Icon: Users,
lightCard: 'from-emerald-50 to-teal-50 border-emerald-200 hover:border-emerald-400 hover:shadow-emerald-200/60',
darkCard: 'from-slate-900/80 to-emerald-950/20 border-slate-700/70 hover:border-emerald-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-emerald-100 text-emerald-600',
iconDark: 'bg-slate-800/90 text-emerald-300 ring-1 ring-emerald-500/20',
tagLight: 'bg-emerald-100 text-emerald-700 border-emerald-200',
tagDark: 'bg-slate-800/70 text-emerald-200 border-emerald-500/25',
},
behavior: {
Icon: Activity,
lightCard: 'from-amber-50 to-orange-50 border-amber-200 hover:border-amber-400 hover:shadow-amber-200/60',
darkCard: 'from-slate-900/80 to-amber-950/20 border-slate-700/70 hover:border-amber-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-amber-100 text-amber-600',
iconDark: 'bg-slate-800/90 text-amber-300 ring-1 ring-amber-500/20',
tagLight: 'bg-amber-100 text-amber-700 border-amber-200',
tagDark: 'bg-slate-800/70 text-amber-200 border-amber-500/25',
},
narrative: {
Icon: BookOpen,
lightCard: 'from-purple-50 to-violet-50 border-purple-200 hover:border-purple-400 hover:shadow-purple-200/60',
darkCard: 'from-slate-900/80 to-purple-950/20 border-slate-700/70 hover:border-purple-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-purple-100 text-purple-600',
iconDark: 'bg-slate-800/90 text-purple-300 ring-1 ring-purple-500/20',
tagLight: 'bg-purple-100 text-purple-700 border-purple-200',
tagDark: 'bg-slate-800/70 text-purple-200 border-purple-500/25',
},
action: {
Icon: Zap,
lightCard: 'from-indigo-50 to-slate-50 border-indigo-200 hover:border-indigo-400 hover:shadow-indigo-200/60',
darkCard: 'from-slate-900/80 to-indigo-950/20 border-slate-700/70 hover:border-indigo-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-indigo-100 text-indigo-600',
iconDark: 'bg-slate-800/90 text-indigo-300 ring-1 ring-indigo-500/20',
tagLight: 'bg-indigo-100 text-indigo-700 border-indigo-200',
tagDark: 'bg-slate-800/70 text-indigo-200 border-indigo-500/25',
},
risk: {
Icon: AlertTriangle,
lightCard: 'from-rose-50 to-pink-50 border-rose-200 hover:border-rose-400 hover:shadow-rose-200/60',
darkCard: 'from-slate-900/80 to-rose-950/20 border-slate-700/70 hover:border-rose-500/40 hover:shadow-slate-950/50',
iconLight: 'bg-rose-100 text-rose-600',
iconDark: 'bg-slate-800/90 text-rose-300 ring-1 ring-rose-500/20',
tagLight: 'bg-rose-100 text-rose-700 border-rose-200',
tagDark: 'bg-slate-800/70 text-rose-200 border-rose-500/25',
},
};
const DEFAULT_VISUAL: AgentVisual = {
Icon: Brain,
lightCard: 'from-gray-50 to-slate-50 border-gray-200 hover:border-gray-400 hover:shadow-gray-200/60',
darkCard: 'from-slate-900/80 to-slate-800/50 border-slate-700/70 hover:border-slate-500/50 hover:shadow-slate-950/50',
iconLight: 'bg-gray-100 text-gray-600',
iconDark: 'bg-slate-800/90 text-slate-300 ring-1 ring-slate-500/20',
tagLight: 'bg-gray-100 text-gray-700 border-gray-200',
tagDark: 'bg-slate-800/70 text-slate-200 border-slate-500/25',
};
const mapFieldLabel = (v: string) => REPORT_SCHEMA_LABELS[v] || v;
function getValueType(value: unknown) {
if (Array.isArray(value)) return 'array';
if (value === null) return 'null';
return typeof value;
}
function compareJsonShape(reference: unknown, current: unknown, path = 'root') {
const issues: string[] = [];
const refType = getValueType(reference);
const curType = getValueType(current);
if (refType !== curType) {
issues.push(`${path}: 类型不一致,期望 ${refType},实际 ${curType}`);
return issues;
}
if (refType === 'array') {
const refArr = reference as unknown[];
const curArr = current as unknown[];
if (refArr.length > 0 && curArr.length > 0) {
issues.push(...compareJsonShape(refArr[0], curArr[0], `${path}[]`));
}
return issues;
}
if (refType === 'object') {
const refObj = reference as Record<string, unknown>;
const curObj = current as Record<string, unknown>;
const refKeys = Object.keys(refObj);
const curKeys = Object.keys(curObj);
refKeys.filter((key) => !(key in curObj)).forEach((key) => issues.push(`${path}.${key}: 缺少字段`));
curKeys.filter((key) => !(key in refObj)).forEach((key) => issues.push(`${path}.${key}: 多余字段`));
refKeys
.filter((key) => key in curObj)
.forEach((key) => issues.push(...compareJsonShape(refObj[key], curObj[key], `${path}.${key}`)));
}
return issues;
}
function EditableText({
value,
onChange,
isDark,
multiline = false,
}: {
value: string;
onChange: (v: string) => void;
isDark: boolean;
multiline?: boolean;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
const confirm = () => { onChange(draft); setEditing(false); };
const cancel = () => { setDraft(value); setEditing(false); };
const inputCls = isDark
? 'bg-slate-800 border-blue-500/60 text-slate-100 focus:border-blue-400'
: 'bg-white border-blue-300 text-gray-800 focus:border-blue-500';
if (editing) {
return (
<div className="space-y-2">
{multiline ? (
<textarea
autoFocus
value={draft}
onChange={e => setDraft(e.target.value)}
rows={5}
className={`w-full px-3 py-2 text-sm rounded-lg border outline-none resize-y font-mono leading-relaxed transition-colors ${inputCls}`}
/>
) : (
<input
autoFocus
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') confirm(); if (e.key === 'Escape') cancel(); }}
className={`w-full px-3 py-1.5 text-sm rounded-lg border outline-none transition-colors ${inputCls}`}
/>
)}
<div className="flex gap-2">
<button onClick={confirm} className="flex items-center gap-1 px-3 py-1 text-xs rounded-lg bg-green-500 hover:bg-green-400 text-white transition-colors">
<Check className="w-3 h-3" /> 确认
</button>
<button onClick={cancel} className={`flex items-center gap-1 px-3 py-1 text-xs rounded-lg border transition-colors ${isDark ? 'border-slate-600 text-slate-400 hover:text-slate-200' : 'border-gray-300 text-gray-500 hover:text-gray-700'}`}>
<X className="w-3 h-3" /> 取消
</button>
</div>
</div>
);
}
return (
<div className="group/editable flex items-start gap-2 rounded-lg px-2 py-1.5 -mx-2 -my-1.5 transition-all duration-200 hover:bg-black/5 dark:hover:bg-white/5 cursor-pointer" onClick={() => { setDraft(value); setEditing(true); }}>
<span className={`flex-1 text-sm leading-relaxed break-words ${isDark ? 'text-slate-300' : 'text-gray-700'} ${multiline ? 'whitespace-pre-wrap font-mono text-xs' : ''}`}>
{value || <span className={isDark ? 'text-slate-600 italic' : 'text-gray-300 italic'}>(空)</span>}
</span>
<Edit2 className={`flex-shrink-0 w-3.5 h-3.5 mt-0.5 opacity-0 group-hover/editable:opacity-100 transition-all duration-200 ${isDark ? 'text-blue-400' : 'text-blue-500'}`} />
</div>
);
}
function EditableMarkdown({
value,
onChange,
isDark,
}: {
value: string;
onChange: (v: string) => void;
isDark: boolean;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
if (editing) {
return (
<div className="space-y-2">
<textarea
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={10}
className={`w-full px-3 py-2 text-sm rounded-lg border outline-none resize-y font-mono leading-relaxed transition-colors ${
isDark ? 'bg-slate-800 border-blue-500/60 text-slate-100 focus:border-blue-400' : 'bg-white border-blue-300 text-gray-800 focus:border-blue-500'
}`}
/>
<div className="flex gap-2">
<button
onClick={() => {
onChange(draft);
setEditing(false);
}}
className="flex items-center gap-1 px-3 py-1 text-xs rounded-lg bg-green-500 hover:bg-green-400 text-white transition-colors"
>
<Check className="w-3 h-3" /> 确认
</button>
<button
onClick={() => {
setDraft(value);
setEditing(false);
}}
className={`flex items-center gap-1 px-3 py-1 text-xs rounded-lg border transition-colors ${
isDark ? 'border-slate-600 text-slate-400 hover:text-slate-200' : 'border-gray-300 text-gray-500 hover:text-gray-700'
}`}
>
<X className="w-3 h-3" /> 取消
</button>
</div>
</div>
);
}
return (
<div
className={`group/markdown relative rounded-lg border px-3 py-3 transition-colors cursor-pointer ${
isDark ? 'border-slate-700/50 bg-slate-900/20 hover:border-slate-600' : 'border-gray-200 bg-white hover:border-gray-300'
}`}
onClick={() => {
setDraft(value);
setEditing(true);
}}
>
<Edit2 className={`absolute top-2 right-2 w-4 h-4 opacity-0 group-hover/markdown:opacity-100 transition-opacity ${isDark ? 'text-blue-400' : 'text-blue-500'}`} />
<div className={`prose prose-sm max-w-none ${isDark ? 'prose-invert' : ''}`}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{value}</ReactMarkdown>
</div>
</div>
);
}
function TagList({
tags,
onChange,
isDark,
tagLight = 'bg-sky-50 text-sky-700 border-sky-200',
tagDark = 'bg-slate-800/70 text-sky-200 border-sky-500/25',
}: {
tags: string[];
onChange: (tags: string[]) => void;
isDark: boolean;
tagLight?: string;
tagDark?: string;
}) {
const [editIdx, setEditIdx] = useState<number | null>(null);
const [editVal, setEditVal] = useState('');
const [adding, setAdding] = useState(false);
const [addVal, setAddVal] = useState('');
const tagCls = isDark ? tagDark : tagLight;
const inputCls = isDark
? 'bg-slate-800 border-blue-500/60 text-slate-100 placeholder-slate-500 focus:border-blue-400'
: 'bg-white border-blue-400 text-gray-800 placeholder-gray-400 focus:border-blue-500';
const confirmEdit = () => {
if (editIdx === null) return;
const next = [...tags];
if (editVal.trim()) next[editIdx] = editVal.trim();
else next.splice(editIdx, 1);
onChange(next);
setEditIdx(null);
};
const confirmAdd = () => {
if (addVal.trim()) onChange([...tags, addVal.trim()]);
setAdding(false);
setAddVal('');
};
return (
<div className="flex flex-wrap gap-1.5 items-center">
{tags.map((tag, i) =>
editIdx === i ? (
<span key={i} className="inline-flex items-center gap-1">
<input
autoFocus
value={editVal}
onChange={e => setEditVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') confirmEdit(); if (e.key === 'Escape') setEditIdx(null); }}
className={`px-2 py-0.5 text-xs rounded border outline-none w-36 transition-colors ${inputCls}`}
/>
<button onClick={confirmEdit} className="text-green-500 hover:text-green-400 transition-colors"><Check className="w-3 h-3" /></button>
<button onClick={() => setEditIdx(null)} className="text-red-400 hover:text-red-300 transition-colors"><X className="w-3 h-3" /></button>
</span>
) : (
<span key={i} className="group/tag inline-flex items-center gap-1">
<span
onClick={() => { setEditIdx(i); setEditVal(tag); setAdding(false); }}
className={`inline-flex items-center gap-1 px-2.5 py-0.5 text-xs rounded-full border cursor-pointer transition-all duration-200 hover:scale-105 hover:opacity-90 ${tagCls}`}
>
{tag}
<button
onClick={e => { e.stopPropagation(); onChange(tags.filter((_, j) => j !== i)); }}
className="opacity-0 group-hover/tag:opacity-100 transition-opacity hover:text-red-500 ml-0.5"
>
<X className="w-2.5 h-2.5" />
</button>
</span>
</span>
)
)}
{adding ? (
<span className="inline-flex items-center gap-1">
<input
autoFocus
value={addVal}
onChange={e => setAddVal(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') confirmAdd(); if (e.key === 'Escape') { setAdding(false); setAddVal(''); } }}
placeholder="输入内容…"
className={`px-2 py-0.5 text-xs rounded border outline-none w-36 transition-colors ${inputCls}`}
/>
<button onClick={confirmAdd} className="text-green-500 hover:text-green-400 transition-colors"><Check className="w-3 h-3" /></button>
<button onClick={() => { setAdding(false); setAddVal(''); }} className="text-red-400 hover:text-red-300 transition-colors"><X className="w-3 h-3" /></button>
</span>
) : (
<button
onClick={() => { setAdding(true); setEditIdx(null); }}
className={`inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full border border-dashed transition-all duration-200 hover:scale-105 ${isDark ? 'border-slate-600 text-slate-500 hover:border-blue-500 hover:text-blue-400' : 'border-gray-300 text-gray-400 hover:border-blue-400 hover:text-blue-500'}`}
>
<Plus className="w-3 h-3" /> 添加
</button>
)}
</div>
);
}
function AgentCard({
agent,
onUpdate,
onDelete,
isDark,
}: {
agent: AgentData;
onUpdate: (a: AgentData) => void;
onDelete: () => void;
isDark: boolean;
}) {
const [editing, setEditing] = useState(false);
const [hovered, setHovered] = useState(false);
const vis = AGENT_VISUALS[agent.id] ?? DEFAULT_VISUAL;
const { Icon } = vis;
const cardCls = isDark ? vis.darkCard : vis.lightCard;
const iconCls = isDark ? vis.iconDark : vis.iconLight;
const tagCls = isDark ? vis.tagDark : vis.tagLight;
const handleFieldChange = (field: keyof AgentData, value: unknown) => {
onUpdate({ ...agent, [field]: value });
};
return (
<>
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
className={`relative rounded-xl border p-4 bg-gradient-to-br transition-all duration-300 ${cardCls} ${hovered ? 'scale-[1.02] shadow-lg -translate-y-0.5' : 'scale-100 shadow-sm'}`}
>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0 transition-transform duration-300 ${iconCls} ${hovered ? 'scale-110 rotate-3' : ''}`}>
<Icon className="w-5 h-5" />
</div>
<div>
<p className={`font-semibold text-sm ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{agent.name}</p>
<p className={`text-xs mt-0.5 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{agent.dimension}</p>
</div>
</div>
<div className={`flex items-center gap-1 transition-opacity duration-200 ${hovered ? 'opacity-100' : 'opacity-0'}`}>
<button
onClick={() => setEditing(true)}
className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-700 text-slate-400 hover:text-blue-300' : 'hover:bg-white/70 text-gray-400 hover:text-blue-500'}`}
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={onDelete}
className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-700 text-slate-400 hover:text-red-400' : 'hover:bg-white/70 text-gray-400 hover:text-red-500'}`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<code className={`inline-block text-xs px-2 py-0.5 rounded-full border mb-2 font-mono ${tagCls}`}>{agent.id}</code>
<div className="mb-3">
<p className={`text-xs leading-relaxed mb-1.5 line-clamp-2 ${isDark ? 'text-slate-400' : 'text-gray-600'}`}>{agent.expertise.slice(0, 80)}</p>
</div>
{agent.relevant_fields.length > 0 && (
<div className="mb-2">
<p className={`text-xs font-medium mb-1.5 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.relevant_fields}</p>
<div className="flex flex-wrap gap-1">
{agent.relevant_fields.map(f => (
<span key={f} className={`text-xs px-2 py-0.5 rounded-full border ${tagCls}`}>{f}</span>
))}
</div>
</div>
)}
</div>
{editing && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={() => setEditing(false)}>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<div
className={`relative w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-2xl border shadow-2xl ${isDark ? 'bg-slate-900 border-slate-700' : 'bg-white border-gray-200'}`}
onClick={e => e.stopPropagation()}
>
<div className={`flex items-center justify-between px-6 py-4 border-b ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
<h2 className={`font-semibold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>编辑智能体</h2>
<button onClick={() => setEditing(false)} className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-slate-800 text-slate-400' : 'hover:bg-gray-100 text-gray-500'}`}><X className="w-4 h-4" /></button>
</div>
<div className="p-6 space-y-4">
{(['id', 'name', 'dimension'] as const).map(k => (
<div key={k} className="space-y-1">
<label className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS[k]}</label>
<EditableText value={agent[k]} onChange={v => handleFieldChange(k, v)} isDark={isDark} />
</div>
))}
<div className="space-y-1">
<label className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.expertise}</label>
<EditableText value={agent.expertise} onChange={v => handleFieldChange('expertise', v)} isDark={isDark} multiline />
</div>
<div className="space-y-1.5">
<label className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.relevant_fields}</label>
<TagList tags={agent.relevant_fields} onChange={v => handleFieldChange('relevant_fields', v)} isDark={isDark} tagLight="bg-blue-50 text-blue-700 border-blue-200" tagDark="bg-slate-800/70 text-blue-200 border-blue-500/25" />
</div>
<div className="space-y-1.5">
<label className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.analysis_contract}</label>
<TagList tags={agent.analysis_contract} onChange={v => handleFieldChange('analysis_contract', v)} isDark={isDark} tagLight="bg-violet-50 text-violet-700 border-violet-200" tagDark="bg-slate-800/70 text-violet-200 border-violet-500/25" />
</div>
</div>
<div className={`flex justify-end gap-3 px-6 py-4 border-t ${isDark ? 'border-slate-700' : 'border-gray-200'}`}>
<button onClick={() => setEditing(false)} className={`px-4 py-2 text-sm rounded-lg border transition-colors ${isDark ? 'border-slate-600 text-slate-400 hover:text-slate-200' : 'border-gray-300 text-gray-500 hover:text-gray-700'}`}>取消</button>
<button onClick={() => setEditing(false)} className="px-4 py-2 text-sm rounded-lg bg-blue-500 hover:bg-blue-400 text-white transition-colors flex items-center gap-1.5">
<Check className="w-3.5 h-3.5" /> 保存
</button>
</div>
</div>
</div>
)}
</>
);
}
function SectionWrapper({
title,
icon: Icon,
children,
isDark,
defaultOpen = true,
}: {
title: string;
icon: React.ElementType;
children: React.ReactNode;
isDark: boolean;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<div className={`rounded-xl border overflow-hidden transition-all duration-200 ${isDark ? 'bg-slate-800/40 border-slate-700/60' : 'bg-white border-gray-200 shadow-sm'}`}>
<button
onClick={() => setOpen(o => !o)}
className={`w-full flex items-center justify-between px-5 py-4 transition-colors duration-200 ${isDark ? 'hover:bg-slate-700/30' : 'hover:bg-gray-50'}`}
>
<div className="flex items-center gap-2.5">
<div className={`w-7 h-7 rounded-lg flex items-center justify-center ${isDark ? 'bg-slate-700' : 'bg-red-50'}`}>
<Icon className={`w-4 h-4 ${isDark ? 'text-blue-400' : 'text-red-500'}`} />
</div>
<span className={`font-semibold text-sm ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>{title}</span>
</div>
{open
? <ChevronDown className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-gray-400'}`} />
: <ChevronRight className={`w-4 h-4 ${isDark ? 'text-slate-500' : 'text-gray-400'}`} />}
</button>
<div className={`grid transition-all duration-300 ease-in-out ${open ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-70'}`}>
<div className="overflow-hidden">
<div className="px-5 pb-5 space-y-4">{children}</div>
</div>
</div>
</div>
);
}
export default function Template6BEditor() {
const { theme } = useTheme();
const isDark = theme === 'dark';
const [template, setTemplate] = useState<TemplateData | null>(null);
const [originalTemplate, setOriginalTemplate] = useState<TemplateData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dirty, setDirty] = useState(false);
useEffect(() => {
fetch('/templates/6b_template.json')
.then(r => r.json())
.then((data: TemplateData) => { setTemplate(data); setOriginalTemplate(data); setLoading(false); })
.catch(e => { setError(e.message); setLoading(false); });
}, []);
const update = <K extends keyof TemplateData>(key: K, value: TemplateData[K]) => {
setTemplate(prev => prev ? { ...prev, [key]: value } : null);
setDirty(true);
};
const handleSave = () => {
if (!template || !originalTemplate) return;
const issues = compareJsonShape(originalTemplate, template);
const valid = issues.length === 0;
const result = {
valid,
message: valid ? '保存校验通过:字段和类型与 public/templates/6b_template.json 一致。' : '保存校验失败:字段或类型不一致。',
issues,
data: template,
};
console.log('[Template6BEditor 保存结果]', result);
console.log('[Template6BEditor 保存 JSON]', JSON.stringify(template, null, 2));
window.alert(result.message + ' 详情已打印到控制台。');
if (valid) setDirty(false);
};
if (loading) {
return (
<div className={`flex items-center justify-center min-h-64 ${isDark ? 'text-slate-400' : 'text-gray-400'}`}>
<RefreshCw className="w-5 h-5 animate-spin mr-2" />
<span className="text-sm">加载模板中…</span>
</div>
);
}
if (error || !template) {
return (
<div className={`flex items-center gap-2 p-4 rounded-xl ${isDark ? 'bg-red-950/30 text-red-400 border border-red-900/50' : 'bg-red-50 text-red-600 border border-red-200'}`}>
<AlertCircle className="w-5 h-5 flex-shrink-0" />
<span className="text-sm">模板加载失败:{error}</span>
</div>
);
}
return (
<div className="h-[calc(100vh-24px)] max-h-[calc(100vh-24px)] flex flex-col max-w-5xl mx-auto px-1 pb-3">
<div className={`flex-shrink-0 flex items-center justify-between gap-4 py-3 border-b ${isDark ? 'border-slate-800' : 'border-gray-200'}`}>
<div>
<h1 className={`text-xl font-bold ${isDark ? 'text-slate-100' : 'text-gray-900'}`}>模板配置编辑器</h1>
<p className={`text-sm mt-0.5 ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>六步行动方案 · {template.template_id}</p>
</div>
<div className="flex items-center gap-3">
{dirty && (
<div className={`flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border ${isDark ? 'bg-amber-900/20 border-amber-700/40 text-amber-300' : 'bg-amber-50 border-amber-200 text-amber-600'}`}>
<Info className="w-3.5 h-3.5" />
已有未保存修改
</div>
)}
<button
onClick={handleSave}
className={`inline-flex items-center gap-2 px-4 py-2 rounded-xl border shadow-sm transition-all duration-200 hover:scale-[1.02] ${isDark ? 'bg-slate-900 border-slate-700 text-slate-100 hover:border-blue-500/40' : 'bg-white border-gray-200 text-gray-800 hover:border-blue-300'}`}
>
<Save className="w-4 h-4" /> 保存
</button>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto py-3 pr-1 space-y-4">
<SectionWrapper title="基本信息" icon={FileText} isDark={isDark}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{(['template_id', 'template_name'] as const).map(k => (
<div key={k} className="space-y-1.5">
<span className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS[k]}</span>
<EditableText value={template[k]} onChange={v => update(k, v)} isDark={isDark} />
</div>
))}
</div>
<div className="space-y-1.5">
<span className={`text-xs font-semibold uppercase tracking-wide ${isDark ? 'text-slate-500' : 'text-gray-400'}`}>{FIELD_LABELS.template_brief}</span>
<EditableText value={template.template_brief} onChange={v => update('template_brief', v)} isDark={isDark} multiline />
</div>
</SectionWrapper>
<SectionWrapper title={FIELD_LABELS.usage_notes} icon={Info} isDark={isDark}>
<TagList
tags={template.usage_notes}
onChange={v => update('usage_notes', v)}
isDark={isDark}
tagLight="bg-amber-50 text-amber-700 border-amber-200"
tagDark="bg-slate-800/70 text-amber-200 border-amber-500/25"
/>
</SectionWrapper>
<SectionWrapper title={FIELD_LABELS.report_schema} icon={Settings} isDark={isDark}>
<div className="space-y-3">
{Object.entries(template.report_schema).map(([key, desc]) => (
<div key={key} className={`group/row rounded-lg border px-4 py-3 transition-all duration-200 hover:scale-[1.005] ${isDark ? 'border-slate-700/50 bg-slate-800/30 hover:border-slate-600' : 'border-gray-200 bg-gray-50/50 hover:border-gray-300 hover:shadow-sm'}`}>
<div className="flex items-center gap-2 mb-1.5">
<code className={`text-xs px-2 py-0.5 rounded font-mono ${isDark ? 'bg-slate-700 text-slate-400' : 'bg-gray-200 text-gray-500'}`}>{key}</code>
<span className={`text-sm font-medium ${isDark ? 'text-slate-200' : 'text-gray-700'}`}>{REPORT_SCHEMA_LABELS[key] || key}</span>
</div>
<EditableText
value={desc}
onChange={v => update('report_schema', { ...template.report_schema, [key]: v })}
isDark={isDark}
multiline
/>
</div>
))}
</div>
</SectionWrapper>
<SectionWrapper title={FIELD_LABELS.report_examples} icon={BookOpen} isDark={isDark} defaultOpen={false}>
<div className="space-y-3">
{Object.entries(template.report_examples).map(([key, example]) => (
<div
key={key}
className={`rounded-lg border overflow-hidden ${isDark ? 'border-slate-700/50' : 'border-gray-200'}`}
>
<div className={`flex items-center gap-2 px-4 py-2 border-b ${isDark ? 'border-slate-700/50 bg-slate-800/50' : 'border-gray-200 bg-gray-50'}`}>
<code className={`text-xs px-2 py-0.5 rounded font-mono ${isDark ? 'bg-slate-700 text-slate-400' : 'bg-gray-200 text-gray-500'}`}>{key}</code>
<span className={`text-xs ${isDark ? 'text-slate-400' : 'text-gray-500'}`}>{REPORT_SCHEMA_LABELS[key] || key}</span>
</div>
<div className="px-4 py-3">
<EditableMarkdown
value={example}
onChange={(v) => update('report_examples', { ...template.report_examples, [key]: v })}
isDark={isDark}
/>
</div>
</div>
))}
</div>
</SectionWrapper>
<SectionWrapper title={FIELD_LABELS.agents} icon={Brain} isDark={isDark}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{template.agents.map((agent, i) => (
<AgentCard
key={agent.id + i}
agent={agent}
onUpdate={updated => {
const next = [...template.agents];
next[i] = updated;
update('agents', next);
}}
onDelete={() => {
if (template.agents.length <= 1) {
window.alert('智能体最少必须保留 1 个');
return;
}
update('agents', template.agents.filter((_, j) => j !== i));
}}
isDark={isDark}
/>
))}
</div>
</SectionWrapper>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,235 @@
.agent-card {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 14px;
margin-bottom: 16px;
overflow: hidden;
box-shadow: var(--shadow-card);
}
.agent-card-header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
background: var(--surface-hover);
}
.agent-card-name {
font-weight: 700;
font-size: 14px;
color: var(--text);
}
.agent-card-badges {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-left: auto;
}
/* Card sections */
.agent-card-section {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.agent-card-section:last-child {
border-bottom: none;
}
.agent-card-section-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--text-muted);
margin-bottom: 8px;
}
/* Streaming card highlight */
.agent-card-streaming {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
/* Streaming badge */
.badge-streaming {
background: var(--accent-soft);
color: var(--accent);
border: 1px solid var(--accent-soft-strong);
border-radius: 4px;
padding: 1px 6px;
font-size: 10px;
font-weight: 600;
animation: pulse-badge 1.5s ease-in-out infinite;
}
@keyframes pulse-badge {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* Streaming plain-text area */
.streaming-text {
font-size: 13px;
color: var(--text);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.7;
}
/* Blinking cursor */
.streaming-cursor {
display: inline-block;
width: 2px;
height: 0.9em;
background: var(--accent);
margin-left: 2px;
vertical-align: text-bottom;
animation: blink-cursor 0.9s step-end infinite;
}
@keyframes blink-cursor {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
/* Content */
.agent-card-content-text {
font-size: 13px;
color: var(--text);
}
/* Thinking section */
.agent-card-thinking-toggle {
display: flex;
align-items: center;
gap: 6px;
background: transparent;
border: none;
padding: 0;
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
margin-bottom: 8px;
}
.agent-card-thinking-toggle:hover {
color: var(--text);
}
.agent-card-thinking-text {
font-size: 12px;
color: var(--text-muted);
background: rgba(139, 148, 158, 0.06);
border-left: 2px solid var(--border);
padding: 8px 12px;
border-radius: 0 4px 4px 0;
}
.agent-card-thinking-text .markdown-body {
color: var(--text-muted);
font-size: 12px;
font-style: italic;
}
.agent-card-thinking-text .markdown-body strong {
color: var(--text-muted);
}
/* Dimension output */
.dim-output-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.dim-output-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.dim-output-key {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--accent);
}
.dim-output-value {
font-size: 13px;
color: var(--text);
}
/* Retrieval */
.retrieval-source-group {
margin-bottom: 10px;
}
.retrieval-source-group:last-child {
margin-bottom: 0;
}
.retrieval-query-label {
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 6px;
}
.retrieval-adopted-badge {
background: rgba(63, 185, 80, 0.15);
color: var(--green);
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
}
.retrieval-not-adopted-badge {
background: rgba(139, 148, 158, 0.1);
color: var(--text-muted);
border-radius: 3px;
padding: 1px 5px;
font-size: 10px;
}
.retrieval-item {
display: flex;
gap: 6px;
margin-bottom: 4px;
align-items: flex-start;
}
.retrieval-item-num {
flex-shrink: 0;
background: var(--accent-soft);
color: var(--accent);
border-radius: 3px;
padding: 0 4px;
font-size: 10px;
font-family: monospace;
line-height: 18px;
margin-top: 1px;
}
.retrieval-item-text {
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
word-break: break-word;
}
.retrieval-source-label {
font-size: 10px;
color: var(--text-muted);
opacity: 0.7;
margin-left: 4px;
}
@@ -0,0 +1,204 @@
import React, { useState, useEffect, useRef } from 'react';
import './AgentCard.css';
import MarkdownContent from './MarkdownContent';
/**
* 根据阶段返回对应的 CSS badge class
*/
function getPhaseBadgeClass(phase) {
switch (phase) {
case 'starting': return 'badge badge-starting';
case 'diverging': return 'badge badge-diverging';
case 'closing': return 'badge badge-closing';
case 'done': return 'badge badge-done';
default: return 'badge badge-default';
}
}
/** 阶段中文映射表 */
const PHASE_LABELS = {
starting: '启动',
diverging: '辩论',
closing: '收敛',
done: '完成',
};
/**
* 智能体输出卡片
*
* 展示单个智能体在一轮中的完整输出,包含以下区块:
* 1. 头部:智能体名称、分析维度、轮次、阶段、流式状态
* 2. 思考过程(可折叠):流式时自动展开显示实时 token,完成后用 Markdown 渲染
* 3. 分析内容:流式时显示原始 token + 光标动画,完成后用 Markdown 渲染(带引用高亮)
* 4. 维度输出:结构化数据展示
* 5. 检索信息:该智能体使用的搜索/检索结果及来源
*
* @param {string} agentId - 智能体唯一 ID
* @param {string} agentName - 显示名称
* @param {string} dimension - 分析维度
* @param {number} round - 当前轮次
* @param {string} phase - 当前阶段
* @param {string} content - 分析文本(流式时为累积 token)
* @param {string} thinking - 思考过程文本
* @param {Object} dimensionOutput - 结构化维度输出
* @param {Array} retrievalLog - 检索日志
* @param {number} retrievalCount - 检索条目数
* @param {Object} referenceMap - 引用编号到源 ID 的映射
* @param {boolean} streaming - 是否正在流式输出中
*/
export default function AgentCard({
agentId,
agentName,
dimension,
round,
phase,
content,
thinking,
dimensionOutput,
retrievalLog,
retrievalCount,
referenceMap,
streaming = false,
}) {
const [thinkingOpen, setThinkingOpen] = useState(false);
const autoOpenedRef = useRef(false);
// 当 thinking 在流式过程中开始有内容时自动展开一次
useEffect(() => {
if (streaming && thinking && thinking.length > 0 && !autoOpenedRef.current) {
setThinkingOpen(true);
autoOpenedRef.current = true;
}
}, [streaming, thinking]);
// 流式输出阶段判断:有 content → 正在输出分析内容;否则正在输出思考过程
const streamingPhase = streaming
? (content && content.length > 0 ? 'content' : 'thinking')
: null;
const hasThinking = thinking && thinking.trim().length > 0;
const hasRetrieval = retrievalCount > 0 && retrievalLog && retrievalLog.length > 0;
const hasDimOutput = dimensionOutput && Object.keys(dimensionOutput).length > 0;
return (
<div className={`agent-card${streaming ? ' agent-card-streaming' : ''}`}>
{/* ====== 头部信息 ====== */}
<div className="agent-card-header">
<span className="agent-card-name">{agentName || agentId}</span>
<div className="agent-card-badges">
{dimension && (
<span className="badge badge-default">{dimension}</span>
)}
<span className="badge badge-default">第 {round} 轮</span>
<span className={getPhaseBadgeClass(phase)}>
{PHASE_LABELS[phase] || phase}
</span>
{streaming && (
<span className="badge badge-streaming">生成中</span>
)}
</div>
</div>
{/* ====== 思考过程(可折叠) ====== */}
{/* 流式时自动展开显示纯文本;完成后可折叠,用 Markdown 渲染 */}
{(hasThinking || streamingPhase === 'thinking') && (
<div className="agent-card-section">
<button
className="agent-card-thinking-toggle"
onClick={() => setThinkingOpen(o => !o)}
>
<span>{thinkingOpen ? '▾' : '▸'}</span>
<span>思考过程</span>
{streamingPhase === 'thinking' && (
<span className="streaming-cursor" style={{ marginLeft: 6 }} />
)}
</button>
{thinkingOpen && (
<div className="agent-card-thinking-text">
{streaming ? (
<span className="streaming-text" style={{ fontStyle: 'italic', fontSize: 12 }}>
{thinking}
</span>
) : (
<MarkdownContent content={thinking} />
)}
</div>
)}
</div>
)}
{/* ====== 分析内容 ====== */}
{(content || streamingPhase === 'content') && (
<div className="agent-card-section">
<div className="agent-card-section-title">分析内容</div>
<div className="agent-card-content-text">
{streaming ? (
<div className="streaming-text">
{content}
{streamingPhase === 'content' && <span className="streaming-cursor" />}
</div>
) : (
<MarkdownContent content={content} referenceMap={referenceMap} />
)}
</div>
</div>
)}
{/* ====== 维度输出 ====== */}
{hasDimOutput && (
<div className="agent-card-section">
<div className="agent-card-section-title">维度输出</div>
<div className="dim-output-grid">
{Object.entries(dimensionOutput).map(([key, value]) => (
<div key={key} className="dim-output-item">
<div className="dim-output-key">{key}</div>
<div className="dim-output-value">
<MarkdownContent
content={typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
/>
</div>
</div>
))}
</div>
</div>
)}
{/* ====== 检索信息 ====== */}
{hasRetrieval && (
<div className="agent-card-section">
<div className="agent-card-section-title">检索信息 ({retrievalCount})</div>
{retrievalLog.map((log, logIdx) => (
<div key={logIdx} className="retrieval-source-group">
<div className="retrieval-query-label">
<span>"{log.query}"</span>
{(log.source_name || log.source_id || log.source) && (
<span className="retrieval-source-label">{log.source_name || log.source_id || log.source}</span>
)}
{log.adopted !== undefined && (
<span className={log.adopted ? 'retrieval-adopted-badge' : 'retrieval-not-adopted-badge'}>
{log.adopted ? '已采用' : '未采用'}
</span>
)}
</div>
{/* 检索结果条目,关联引用编号 */}
{log.content && log.content.map((item, itemIdx) => {
const refNum = log.content_ids ? log.content_ids[itemIdx] : null;
const refKey = refNum ? Object.entries(referenceMap || {}).find(([k, v]) => v === refNum)?.[0] : null;
return (
<div key={itemIdx} className="retrieval-item">
{refKey && <span className="retrieval-item-num">[{refKey}]</span>}
<div className="retrieval-item-text">
{typeof item === 'string'
? (item.length > 200 ? item.slice(0, 200) + '…' : item)
: JSON.stringify(item).slice(0, 200)}
</div>
</div>
);
})}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,193 @@
import React, { useState, useEffect, useRef } from 'react';
import './AgentCard.css';
import MarkdownContent from './MarkdownContent';
function getPhaseBadgeClass(phase?: string): string {
switch (phase) {
case 'starting': return 'badge badge-starting';
case 'diverging': return 'badge badge-diverging';
case 'closing': return 'badge badge-closing';
case 'done': return 'badge badge-done';
default: return 'badge badge-default';
}
}
const PHASE_LABELS: Record<string, string> = {
starting: '启动',
diverging: '辩论',
closing: '收敛',
done: '完成',
};
type RetrievalLogItem = {
query?: string;
source_name?: string;
source_id?: string;
source?: string;
adopted?: boolean;
content?: any[];
content_ids?: string[];
};
type Props = {
agentId: string;
agentName?: string;
dimension?: string;
round: number;
phase: string;
content?: string;
thinking?: string;
dimensionOutput?: Record<string, any>;
retrievalLog?: RetrievalLogItem[];
retrievalCount?: number;
referenceMap?: Record<string, string>;
streaming?: boolean;
};
export default function AgentCard({
agentId,
agentName,
dimension,
round,
phase,
content,
thinking,
dimensionOutput,
retrievalLog,
retrievalCount = 0,
referenceMap,
streaming = false,
}: Props): JSX.Element {
const [thinkingOpen, setThinkingOpen] = useState(false);
const autoOpenedRef = useRef(false);
useEffect(() => {
if (streaming && thinking && thinking.length > 0 && !autoOpenedRef.current) {
setThinkingOpen(true);
autoOpenedRef.current = true;
}
}, [streaming, thinking]);
const streamingPhase = streaming
? (content && content.length > 0 ? 'content' : 'thinking')
: null;
const hasThinking = thinking && thinking.trim().length > 0;
const hasRetrieval = retrievalCount > 0 && retrievalLog && retrievalLog.length > 0;
const hasDimOutput = dimensionOutput && Object.keys(dimensionOutput).length > 0;
return (
<div className={`agent-card${streaming ? ' agent-card-streaming' : ''}`}>
<div className="agent-card-header">
<span className="agent-card-name">{agentName || agentId}</span>
<div className="agent-card-badges">
{dimension && (
<span className="badge badge-default">{dimension}</span>
)}
<span className="badge badge-default">第 {round} 轮</span>
<span className={getPhaseBadgeClass(phase)}>
{PHASE_LABELS[phase] || phase}
</span>
{streaming && (
<span className="badge badge-streaming">生成中</span>
)}
</div>
</div>
{(hasThinking || streamingPhase === 'thinking') && (
<div className="agent-card-section">
<button
className="agent-card-thinking-toggle"
onClick={() => setThinkingOpen(o => !o)}
>
<span>{thinkingOpen ? '▾' : '▸'}</span>
<span>思考过程</span>
{streamingPhase === 'thinking' && (
<span className="streaming-cursor" style={{ marginLeft: 6 }} />
)}
</button>
{thinkingOpen && (
<div className="agent-card-thinking-text">
{streaming ? (
<span className="streaming-text" style={{ fontStyle: 'italic', fontSize: 12 }}>
{thinking}
</span>
) : (
<MarkdownContent content={thinking} />
)}
</div>
)}
</div>
)}
{(content || streamingPhase === 'content') && (
<div className="agent-card-section">
<div className="agent-card-section-title">分析内容</div>
<div className="agent-card-content-text">
{streaming ? (
<div className="streaming-text">
{content}
{streamingPhase === 'content' && <span className="streaming-cursor" />}
</div>
) : (
<MarkdownContent content={content} referenceMap={referenceMap} />
)}
</div>
</div>
)}
{hasDimOutput && (
<div className="agent-card-section">
<div className="agent-card-section-title">维度输出</div>
<div className="dim-output-grid">
{Object.entries(dimensionOutput).map(([key, value]) => (
<div key={key} className="dim-output-item">
<div className="dim-output-key">{key}</div>
<div className="dim-output-value">
<MarkdownContent
content={typeof value === 'string' ? value : JSON.stringify(value, null, 2)}
/>
</div>
</div>
))}
</div>
</div>
)}
{hasRetrieval && (
<div className="agent-card-section">
<div className="agent-card-section-title">检索信息 ({retrievalCount})</div>
{retrievalLog.map((log, logIdx) => (
<div key={logIdx} className="retrieval-source-group">
<div className="retrieval-query-label">
<span>"{log.query}"</span>
{(log.source_name || log.source_id || log.source) && (
<span className="retrieval-source-label">{log.source_name || log.source_id || log.source}</span>
)}
{log.adopted !== undefined && (
<span className={log.adopted ? 'retrieval-adopted-badge' : 'retrieval-not-adopted-badge'}>
{log.adopted ? '已采用' : '未采用'}
</span>
)}
</div>
{log.content && log.content.map((item, itemIdx) => {
const refNum = log.content_ids ? log.content_ids[itemIdx] : null;
const refKey = refNum ? Object.entries(referenceMap || {}).find(([, v]) => v === refNum)?.[0] : null;
return (
<div key={itemIdx} className="retrieval-item">
{refKey && <span className="retrieval-item-num">[{refKey}]</span>}
<div className="retrieval-item-text">
{typeof item === 'string'
? (item.length > 200 ? item.slice(0, 200) + '…' : item)
: JSON.stringify(item).slice(0, 200)}
</div>
</div>
);
})}
</div>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,467 @@
.config-panel {
width: 100%;
max-width: 1080px;
margin: 0 auto;
}
.config-panel-title {
text-align: center;
margin-bottom: 24px;
padding-top: 4px;
}
.config-title-eyebrow {
display: inline-flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
padding: 4px 12px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--surface-raised);
color: var(--text-muted);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
text-transform: uppercase;
}
.config-panel-title h1 {
font-size: 32px;
line-height: 1.1;
font-weight: 800;
color: var(--accent);
letter-spacing: 1px;
margin-bottom: 8px;
}
.config-panel-title p {
color: var(--text-muted);
font-size: 14px;
}
.config-layout {
display: flex;
flex-direction: column;
gap: 18px;
}
.config-task-section,
.config-card,
.config-advanced-section {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 18px;
box-shadow: var(--shadow-card);
}
.config-task-section {
padding: 24px;
}
.config-task-section label {
display: block;
font-weight: 700;
font-size: 13px;
color: var(--text);
margin-bottom: 10px;
}
.config-task-textarea,
.config-textarea,
.config-input,
.config-json-editor {
width: 100%;
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
color: var(--text);
font-family: inherit;
font-size: 13px;
outline: none;
box-shadow: inset 0 1px 2px rgba(15, 23, 42, 0.03);
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
.config-task-textarea,
.config-textarea,
.config-json-editor {
padding: 14px 16px;
resize: vertical;
line-height: 1.7;
}
.config-task-textarea {
min-height: 150px;
}
.config-textarea {
min-height: 108px;
}
.config-textarea-sm {
min-height: 78px;
}
.config-input {
padding: 11px 13px;
}
.config-json-editor {
min-height: 320px;
font-family: 'SFMono-Regular', Consolas, monospace;
}
.config-task-textarea:focus,
.config-textarea:focus,
.config-input:focus,
.config-json-editor:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
background: var(--surface-raised);
}
.config-submit-row {
display: flex;
align-items: center;
gap: 12px;
justify-content: flex-end;
margin-top: 18px;
flex-wrap: wrap;
padding-top: 4px;
}
.config-mode-switch {
display: inline-flex;
background: color-mix(in srgb, var(--surface) 96%, var(--accent) 4%);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
padding: 3px;
flex-shrink: 0;
}
.config-mode-option {
background: transparent;
color: var(--text-muted);
border: none;
border-radius: 9px;
padding: 8px 14px;
font-size: 12px;
font-weight: 700;
}
.config-mode-option.active {
background: var(--accent);
color: var(--button-text);
box-shadow: 0 8px 18px var(--accent-soft-strong);
}
.config-mode-option:not(.active):hover:not(:disabled) {
background: var(--surface-hover-strong);
color: var(--text);
}
.config-run-mode-row {
display: grid;
grid-template-columns: auto minmax(260px, 1fr) auto;
align-items: center;
gap: 14px;
margin-top: 16px;
}
.config-run-mode-row .config-field-hint {
margin-top: 0;
min-width: 0;
}
.config-mode-hint {
padding: 8px 12px;
border-radius: 10px;
background: var(--surface-hover);
}
.config-scenario-button {
flex-shrink: 0;
white-space: nowrap;
}
.config-run-options-row {
display: grid;
grid-template-columns: 80px minmax(120px, 1fr) minmax(260px, 1.2fr);
align-items: center;
gap: 12px;
margin-top: 14px;
}
.config-run-options-row label {
margin-bottom: 0;
min-width: 0;
color: var(--text-muted);
}
.config-number-input {
width: 100%;
max-width: 96px;
}
.config-run-options-row .config-field-hint {
margin-top: 0;
min-width: 0;
padding-left: 2px;
}
.btn-start {
background: linear-gradient(135deg, var(--accent), var(--accent-hover));
color: var(--button-text);
font-weight: 800;
font-size: 14px;
padding: 11px 30px;
border-radius: 12px;
letter-spacing: 0.3px;
box-shadow: 0 12px 26px var(--accent-soft-strong);
}
.btn-start:hover:not(:disabled) {
opacity: 0.85;
}
.config-card-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.config-card {
padding: 22px 24px;
}
.config-card-header {
margin-bottom: 14px;
}
.config-card-header h3 {
font-size: 18px;
color: var(--text);
margin-bottom: 6px;
}
.config-card-header p {
font-size: 12px;
color: var(--text-muted);
line-height: 1.6;
}
.config-field {
margin-top: 14px;
}
.config-field:first-child {
margin-top: 0;
}
.config-field label {
display: block;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.config-field-hint {
margin-top: 6px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.6;
}
.config-task-textarea::placeholder,
.config-textarea::placeholder,
.config-input::placeholder,
.config-json-editor::placeholder {
color: color-mix(in srgb, var(--text-muted) 72%, transparent);
}
.config-fixed-mode {
margin-right: auto;
color: var(--text-muted);
font-size: 12px;
line-height: 1.5;
}
.config-agents,
.config-report-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.config-agent-card,
.config-report-card {
background: var(--surface-hover);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
}
.config-agents.readonly,
.config-report-grid.readonly {
margin-top: 10px;
}
.config-report-card p,
.config-agent-card p {
color: var(--text-muted);
font-size: 12px;
line-height: 1.65;
margin: 0;
}
.config-report-card pre,
.config-readonly-block pre {
margin: 12px 0 0;
max-height: 260px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-muted);
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 11px;
line-height: 1.55;
padding: 10px;
}
.config-readonly-block {
margin-top: 18px;
}
.config-readonly-block:first-child {
margin-top: 0;
}
.config-readonly-title {
color: var(--text);
font-size: 13px;
font-weight: 700;
margin-bottom: 10px;
}
.config-readonly-content {
color: var(--text-muted);
}
.config-readonly-meta {
margin-top: 8px !important;
color: var(--accent) !important;
}
.config-empty-detail {
color: var(--text-muted);
font-size: 13px;
padding: 12px 0 0;
}
.config-agent-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.config-agent-head span {
font-size: 14px;
font-weight: 700;
color: var(--text);
}
.config-agent-head code {
color: var(--accent);
background: var(--accent-soft);
border-radius: 999px;
padding: 3px 8px;
font-size: 11px;
}
.config-subgrid {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.config-advanced-section {
overflow: hidden;
}
.config-advanced-toggle {
width: 100%;
background: transparent;
border: none;
padding: 16px 20px;
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
font-size: 13px;
font-weight: 700;
cursor: pointer;
text-align: left;
border-radius: 0;
}
.config-advanced-toggle:hover {
color: var(--accent);
background: var(--surface-hover);
}
.config-advanced-chevron {
margin-left: auto;
transition: transform 0.2s;
}
.config-advanced-chevron.open {
transform: rotate(180deg);
}
.config-advanced-body {
padding: 0 20px 20px;
border-top: 1px solid var(--border);
}
@media (max-width: 900px) {
.config-task-section,
.config-card {
padding: 18px;
}
.config-run-mode-row,
.config-run-options-row {
grid-template-columns: 1fr;
align-items: stretch;
}
.config-mode-switch {
width: 100%;
}
.config-mode-option {
flex: 1;
}
.config-scenario-button,
.config-number-input {
width: 100%;
max-width: none;
}
.config-card-grid,
.config-agents,
.config-report-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,459 @@
import React, { useEffect, useMemo, useState } from 'react';
import { DEFAULT_BACKEND_URL, DEFAULT_CONFIG } from '@/config/defaultConfig';
import { clone, hasTemplateOverride, loadTemplateByMeta, loadTemplateIndex } from '@/config/templateStore';
import './ConfigPanel.css';
/**
* 卡片布局子组件
* 带标题和可选描述的通用卡片容器
*/
function Card({ title, description, children }) {
return (
<section className="config-card">
<div className="config-card-header">
<h3>{title}</h3>
{description && <p>{description}</p>}
</div>
{children}
</section>
);
}
/**
* 只读内容块子组件
* 用于展示模板的只读配置(字段定义、智能体信息等)
*/
function ReadOnlyBlock({ title, children }) {
return (
<div className="config-readonly-block">
<div className="config-readonly-title">{title}</div>
<div className="config-readonly-content">{children}</div>
</div>
);
}
/**
* 配置面板组件
*
* 应用首页的核心表单,用户在启动分析前进行配置:
* 1. 任务背景:描述事件背景、传播目标和限制条件
* 2. 运行模式:自动完成 / 人工逐轮确认
* 3. 讨论轮次:控制辩论轮数(1-8)
* 4. 运行模型:从后端 /config/options 获取 LLM 预设列表
* 5. 一致性模式:free(自由)或 soft(软约束)
* 6. 报告模板:从后端 /templates/index.json 加载模板列表
*
* 提交时调用 onStart(config, backendUrl) 启动 SSE 分析会话。
*
* @param {Function} onStart - 开始分析回调 (config, backendUrl)
* @param {boolean} isRunning - 是否正在运行中(禁用表单)
* @param {Function} onStop - 停止分析回调
* @param {Function} onOpenScenarioConfig - 打开场景配置页回调
*/
export default function ConfigPanel({ onStart, isRunning, onStop, onOpenScenarioConfig }) {
const [taskContext, setTaskContext] = useState('');
const backendUrl = DEFAULT_BACKEND_URL;
const [config, setConfig] = useState(() => clone(DEFAULT_CONFIG));
const [rawOpen, setRawOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [templates, setTemplates] = useState([]);
const [templateLoading, setTemplateLoading] = useState(false);
const [templateError, setTemplateError] = useState('');
const [llmPresets, setLlmPresets] = useState([]);
const [llmLoading, setLlmLoading] = useState(false);
const [llmError, setLlmError] = useState('');
/** 安全更新配置对象(深拷贝后修改,避免直接 mutate) */
const updateConfig = (updater) => {
setConfig(prev => {
const next = clone(prev);
updater(next);
return next;
});
};
const setCoordinatorMode = (mode) => {
updateConfig(next => {
next.coordinator_mode = mode;
});
};
/** 设置辩论轮数(限制范围 1-8) */
const setDivergeRounds = (value) => {
const parsed = Number.parseInt(value, 10);
const rounds = Number.isFinite(parsed) ? Math.min(8, Math.max(1, parsed)) : 3;
updateConfig(next => {
next.phase_config = {
...(next.phase_config || {}),
diverge_rounds: rounds,
};
});
};
const setLlmPreset = (presetId) => {
updateConfig(next => {
next.llm_preset_id = presetId;
});
};
const setConsistencyMode = (mode) => {
updateConfig(next => {
next.consistency_mode = mode;
});
};
/** 提交表单:验证任务背景和模板 ID 后启动分析 */
const handleSubmit = (e) => {
e.preventDefault();
if (!taskContext.trim() || !config.template_id) return;
onStart({
...config,
task_context: taskContext.trim(),
coordinator_mode: config.coordinator_mode || 'auto',
}, backendUrl);
};
/** 从后端加载模板列表 */
const loadTemplates = async () => {
setTemplateLoading(true);
setTemplateError('');
try {
setTemplates(await loadTemplateIndex());
} catch (error) {
setTemplateError(error.message || '模板列表加载失败');
} finally {
setTemplateLoading(false);
}
};
/** 从后端 /config/options 加载 LLM 预设和默认值 */
const loadConfigOptions = async () => {
setLlmLoading(true);
setLlmError('');
try {
const response = await fetch(`${backendUrl}/config/options`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const presets = data.llm_presets || [];
setLlmPresets(presets);
updateConfig(next => {
if (!next.llm_preset_id) {
next.llm_preset_id = data.default_llm_preset_id || presets.find(item => item.is_default)?.id || '';
}
});
} catch (error) {
setLlmError(error.message || '模型配置加载失败');
} finally {
setLlmLoading(false);
}
};
/** 应用选中的模板:将模板的字段、智能体、协调者策略写入 config */
const applyTemplate = async (templateId) => {
if (!templateId) return;
const meta = templates.find(item => item.template_id === templateId);
setTemplateLoading(true);
setTemplateError('');
try {
const template = await loadTemplateByMeta(meta || templateId);
updateConfig(next => {
next.template_id = template.template_id || templateId;
next.template_name = template.template_name || '';
next.template_brief = template.template_brief || '';
next.consistency_mode = template.consistency_mode || next.consistency_mode || 'free';
next.report_schema = clone(template.report_schema || {});
next.report_examples = clone(template.report_examples || {});
next.field_requirements = clone(template.field_requirements || {});
next.agents = clone(template.agents || next.agents || []);
next.coordinator_mode = next.coordinator_mode || 'auto';
next.coordinator_config = clone(template.coordinator_config || DEFAULT_CONFIG.coordinator_config);
next.agent_adaptation_policy = clone(template.agent_adaptation_policy || {});
});
} catch (error) {
setTemplateError(error.message || '模板加载失败');
} finally {
setTemplateLoading(false);
}
};
// 初始化:加载模板列表和 LLM 预设
useEffect(() => {
if (!backendUrl) return;
loadTemplates();
loadConfigOptions();
}, [backendUrl]);
/** 当前选中的 LLM 预设对象 */
const selectedLlmPreset = useMemo(
() => llmPresets.find(item => item.id === config.llm_preset_id) || null,
[llmPresets, config.llm_preset_id]
);
/** 原始配置 JSON 预览 */
const rawConfig = useMemo(() => JSON.stringify({ ...config, task_context: '<在上方输入>' }, null, 2), [config]);
const canStart = taskContext.trim() && config.template_id;
return (
<div className="config-panel">
{/* 页面标题 */}
<div className="config-panel-title">
<h1>MOPE</h1>
<p>多智能体公关规划系统</p>
<button
type="button"
className="btn-secondary config-title-action"
onClick={onOpenScenarioConfig}
disabled={isRunning}
>
场景配置
</button>
</div>
<form onSubmit={handleSubmit} className="config-layout">
{/* ====== 任务背景输入区 ====== */}
<div className="config-task-section">
<label htmlFor="task-context">任务背景</label>
<textarea
id="task-context"
className="config-task-textarea"
value={taskContext}
onChange={e => setTaskContext(e.target.value)}
placeholder="请描述事件背景、传播目标、限制条件和你关心的判断标准。"
disabled={isRunning}
/>
{/* 协调者运行模式切换 */}
<div className="config-run-mode-row">
<div className="config-mode-switch" role="radiogroup" aria-label="协调者模式">
<button
type="button"
className={`config-mode-option ${(config.coordinator_mode || 'auto') === 'auto' ? 'active' : ''}`}
onClick={() => setCoordinatorMode('auto')}
disabled={isRunning}
>
自动完成
</button>
<button
type="button"
className={`config-mode-option ${config.coordinator_mode === 'human_review' ? 'active' : ''}`}
onClick={() => setCoordinatorMode('human_review')}
disabled={isRunning}
>
人工逐轮确认
</button>
</div>
<div className="config-field-hint">
自动完成会由协调者连续推进;人工逐轮确认会在每轮协调者提案后等待确认或修改。
</div>
</div>
{/* 讨论轮次设置 */}
<div className="config-run-options-row">
<label htmlFor="diverge-rounds">讨论轮次</label>
<input
id="diverge-rounds"
className="config-input config-number-input"
type="number"
min="1"
max="8"
step="1"
value={config.phase_config?.diverge_rounds || 3}
onChange={e => setDivergeRounds(e.target.value)}
disabled={isRunning}
/>
<div className="config-field-hint">
控制协调者推进的中间讨论轮数;轮次越多,材料更充分但耗时更长。
</div>
</div>
{/* 运行模型选择 */}
<div className="config-run-options-row">
<label htmlFor="llm-preset">运行模型</label>
<select
id="llm-preset"
className="config-input"
value={config.llm_preset_id || ''}
onChange={e => setLlmPreset(e.target.value)}
disabled={isRunning || llmLoading || llmPresets.length === 0}
>
<option value="">选择模型...</option>
{llmPresets.map(preset => (
<option key={preset.id} value={preset.id}>
{preset.label}
{preset.is_default ? '(默认)' : ''}
</option>
))}
</select>
<div className="config-field-hint">
{selectedLlmPreset
? `${selectedLlmPreset.description || '后端预设模型'} · ${selectedLlmPreset.model_name}`
: '模型预设由后端 config.py 管理,前端只选择预设。'}
</div>
</div>
{/* 一致性模式 */}
<div className="config-run-options-row">
<label htmlFor="consistency-mode">一致性模式</label>
<select
id="consistency-mode"
className="config-input"
value={config.consistency_mode || 'free'}
onChange={e => setConsistencyMode(e.target.value)}
disabled={isRunning}
>
<option value="free">free</option>
<option value="soft">soft</option>
</select>
<div className="config-field-hint">
{config.consistency_mode === 'soft'
? 'soft 会优先承接依赖字段、统一命名口径,并在最终成文时增强跨模块衔接。'
: 'free 只做基础一致性控制,适合依赖关系较弱或开放式的场景模板。'}
</div>
</div>
{/* 提交按钮区 */}
<div className="config-submit-row">
<div className="config-fixed-mode">报告结构、智能体和协调者策略由所选模板固定。</div>
{isRunning ? (
<button type="button" className="btn-danger" onClick={onStop}>停止分析</button>
) : (
<button type="submit" className="btn-start" disabled={!canStart}>
{config.coordinator_mode === 'human_review' ? '开始并逐轮确认' : '开始分析'}
</button>
)}
</div>
{!config.template_id && (
<div className="config-field-hint">开始前需要先选择一个报告模板。</div>
)}
{llmError && (
<div className="human-review-error">模型配置加载失败:{llmError}</div>
)}
</div>
{/* ====== 报告模板选择区 ====== */}
<Card title="报告模板" description="选择后会自动固定报告字段、字段标准、参与智能体和协调者策略。">
<div className="config-submit-row">
<select
className="config-input"
value={config.template_id || ''}
onChange={e => applyTemplate(e.target.value)}
disabled={isRunning || templateLoading}
>
<option value="">选择模板...</option>
{templates.map(template => (
<option key={template.template_id} value={template.template_id}>
{template.template_name || template.template_id}
{hasTemplateOverride(template.template_id) ? '(已修改)' : ''}
</option>
))}
</select>
<button
type="button"
className="btn-primary"
onClick={loadTemplates}
disabled={isRunning || templateLoading}
>
{templateLoading ? '加载中' : '刷新模板'}
</button>
</div>
{config.template_name && (
<div className="config-field-hint">
当前模板:{config.template_name}
{config.template_brief ? ` · ${config.template_brief}` : ''}
</div>
)}
{templateError && (
<div className="human-review-error">模板加载失败:{templateError}</div>
)}
</Card>
<div className="config-field-hint">
运行模型预设由后端 `config.py` 管理;前端只选择预设 id,不接触密钥。
</div>
{/* 模板详情(可折叠,只读) */}
<div className="config-advanced-section">
<button
type="button"
className="config-advanced-toggle"
onClick={() => setDetailOpen(open => !open)}
>
<span>模板详情(只读)</span>
<span className={`config-advanced-chevron ${detailOpen ? 'open' : ''}`}>▾</span>
</button>
{detailOpen && (
<div className="config-advanced-body">
{!config.template_id ? (
<div className="config-empty-detail">选择模板后可查看字段、智能体和协调者策略。</div>
) : (
<>
{/* 报告字段 */}
<ReadOnlyBlock title="报告字段">
<div className="config-report-grid readonly">
{Object.entries(config.report_schema || {}).map(([field, desc]) => (
<div key={field} className="config-report-card">
<div className="config-agent-head">
<span>{field}</span>
</div>
<p>{desc}</p>
{config.field_requirements?.[field] && (
<pre>{JSON.stringify(config.field_requirements[field], null, 2)}</pre>
)}
</div>
))}
</div>
</ReadOnlyBlock>
{/* 参与智能体 */}
<ReadOnlyBlock title="参与智能体">
<div className="config-agents readonly">
{(config.agents || []).map(agent => (
<div key={agent.id} className="config-agent-card">
<div className="config-agent-head">
<span>{agent.name}</span>
<code>{agent.id}</code>
</div>
<p className="config-readonly-meta">{agent.dimension}</p>
<p>{agent.expertise || agent.description || agent.skill}</p>
{(agent.relevant_fields || []).length > 0 && (
<p className="config-readonly-meta">关联字段:{agent.relevant_fields.join('、')}</p>
)}
</div>
))}
</div>
</ReadOnlyBlock>
{/* 协调者策略 */}
<ReadOnlyBlock title="协调者策略">
<pre>{JSON.stringify(config.coordinator_config || {}, null, 2)}</pre>
</ReadOnlyBlock>
</>
)}
</div>
)}
</div>
{/* 原始配置 JSON 预览 */}
<div className="config-advanced-section">
<button
type="button"
className="config-advanced-toggle"
onClick={() => setRawOpen(open => !open)}
>
<span>原始配置预览</span>
<span className={`config-advanced-chevron ${rawOpen ? 'open' : ''}`}>▾</span>
</button>
{rawOpen && (
<div className="config-advanced-body">
<textarea className="config-json-editor" value={rawConfig} readOnly spellCheck={false} />
</div>
)}
</div>
</form>
</div>
);
}
@@ -0,0 +1,413 @@
// @ts-nocheck
import React, { useEffect, useMemo, useState } from 'react';
import { DEFAULT_BACKEND_URL, DEFAULT_CONFIG } from '@/config/defaultConfig';
import { clone, hasTemplateOverride, loadTemplateByMeta, loadTemplateIndex } from '@/config/templateStore';
import './ConfigPanel.css';
function Card({ title, description, children }) {
return (
<section className="config-card">
<div className="config-card-header">
<h3>{title}</h3>
{description && <p>{description}</p>}
</div>
{children}
</section>
);
}
function ReadOnlyBlock({ title, children }) {
return (
<div className="config-readonly-block">
<div className="config-readonly-title">{title}</div>
<div className="config-readonly-content">{children}</div>
</div>
);
}
export default function ConfigPanel({ onStart, isRunning, onStop, onOpenScenarioConfig }) {
const [taskContext, setTaskContext] = useState('');
const backendUrl = DEFAULT_BACKEND_URL;
const [config, setConfig] = useState(() => clone(DEFAULT_CONFIG));
const [rawOpen, setRawOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [templates, setTemplates] = useState([]);
const [templateLoading, setTemplateLoading] = useState(false);
const [templateError, setTemplateError] = useState('');
const [llmPresets, setLlmPresets] = useState([]);
const [llmLoading, setLlmLoading] = useState(false);
const [llmError, setLlmError] = useState('');
const updateConfig = (updater) => {
setConfig(prev => {
const next = clone(prev);
updater(next);
return next;
});
};
const setCoordinatorMode = (mode) => {
updateConfig(next => {
next.coordinator_mode = mode;
});
};
const setDivergeRounds = (value) => {
const parsed = Number.parseInt(value, 10);
const rounds = Number.isFinite(parsed) ? Math.min(8, Math.max(1, parsed)) : 3;
updateConfig(next => {
next.phase_config = {
...(next.phase_config || {}),
diverge_rounds: rounds,
};
});
};
const setLlmPreset = (presetId) => {
updateConfig(next => {
next.llm_preset_id = presetId;
});
};
const setConsistencyMode = (mode) => {
updateConfig(next => {
next.consistency_mode = mode;
});
};
const handleSubmit = (e) => {
e.preventDefault();
if (!taskContext.trim() || !config.template_id) return;
onStart({
...config,
task_context: taskContext.trim(),
coordinator_mode: config.coordinator_mode || 'auto',
}, backendUrl);
};
const loadTemplates = async () => {
setTemplateLoading(true);
setTemplateError('');
try {
setTemplates(await loadTemplateIndex());
} catch (error) {
setTemplateError(error.message || '模板列表加载失败');
} finally {
setTemplateLoading(false);
}
};
const loadConfigOptions = async () => {
setLlmLoading(true);
setLlmError('');
try {
const response = await fetch(`${backendUrl}/config/options`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const presets = data.llm_presets || [];
setLlmPresets(presets);
updateConfig(next => {
if (!next.llm_preset_id) {
next.llm_preset_id = data.default_llm_preset_id || presets.find(item => item.is_default)?.id || '';
}
});
} catch (error) {
setLlmError(error.message || '模型配置加载失败');
} finally {
setLlmLoading(false);
}
};
const applyTemplate = async (templateId) => {
if (!templateId) return;
const meta = templates.find(item => item.template_id === templateId);
setTemplateLoading(true);
setTemplateError('');
try {
const template = await loadTemplateByMeta(meta || templateId);
updateConfig(next => {
next.template_id = template.template_id || templateId;
next.template_name = template.template_name || '';
next.template_brief = template.template_brief || '';
next.consistency_mode = template.consistency_mode || next.consistency_mode || 'free';
next.report_schema = clone(template.report_schema || {});
next.report_examples = clone(template.report_examples || {});
next.field_requirements = clone(template.field_requirements || {});
next.agents = clone(template.agents || next.agents || []);
next.coordinator_mode = next.coordinator_mode || 'auto';
next.coordinator_config = clone(template.coordinator_config || DEFAULT_CONFIG.coordinator_config);
next.agent_adaptation_policy = clone(template.agent_adaptation_policy || {});
});
} catch (error) {
setTemplateError(error.message || '模板加载失败');
} finally {
setTemplateLoading(false);
}
};
useEffect(() => {
if (!backendUrl) return;
loadTemplates();
loadConfigOptions();
}, [backendUrl]);
const selectedLlmPreset = useMemo(
() => llmPresets.find(item => item.id === config.llm_preset_id) || null,
[llmPresets, config.llm_preset_id]
);
const rawConfig = useMemo(() => JSON.stringify({ ...config, task_context: '<在上方输入>' }, null, 2), [config]);
const canStart = taskContext.trim() && config.template_id;
return (
<div className="config-panel">
<div className="config-panel-title">
<div className="config-title-eyebrow">Multi-Agent Opinion Planning Engine</div>
<h1>MOPE</h1>
<p>多智能体公共舆论研判系统</p>
</div>
<form onSubmit={handleSubmit} className="config-layout">
<div className="config-task-section">
<label htmlFor="task-context">任务背景</label>
<textarea
id="task-context"
className="config-task-textarea"
value={taskContext}
onChange={e => setTaskContext(e.target.value)}
placeholder="请描述事件背景、传播目标、限制条件和你关心的判断标准。"
disabled={isRunning}
/>
<div className="config-run-mode-row">
<div className="config-mode-switch" role="radiogroup" aria-label="协调者模式">
<button
type="button"
className={`config-mode-option ${(config.coordinator_mode || 'auto') === 'auto' ? 'active' : ''}`}
onClick={() => setCoordinatorMode('auto')}
disabled={isRunning}
>
自动完成
</button>
<button
type="button"
className={`config-mode-option ${config.coordinator_mode === 'human_review' ? 'active' : ''}`}
onClick={() => setCoordinatorMode('human_review')}
disabled={isRunning}
>
人工逐轮确认
</button>
</div>
<div className="config-field-hint config-mode-hint">
自动完成会由协调者连续推进;人工逐轮确认会在每轮协调者提案后等待确认或修改。
</div>
<button
type="button"
className="btn-secondary config-scenario-button"
onClick={onOpenScenarioConfig}
disabled={isRunning}
>
场景配置
</button>
</div>
<div className="config-run-options-row">
<label htmlFor="diverge-rounds">讨论轮次</label>
<input
id="diverge-rounds"
className="config-input config-number-input"
type="number"
min="1"
max="8"
step="1"
value={config.phase_config?.diverge_rounds || 3}
onChange={e => setDivergeRounds(e.target.value)}
disabled={isRunning}
/>
<div className="config-field-hint">
控制协调者推进的中间讨论轮数;轮次越多,材料更充分但耗时更长。
</div>
</div>
<div className="config-run-options-row">
<label htmlFor="llm-preset">运行模型</label>
<select
id="llm-preset"
className="config-input"
value={config.llm_preset_id || ''}
onChange={e => setLlmPreset(e.target.value)}
disabled={isRunning || llmLoading || llmPresets.length === 0}
>
<option value="">选择模型...</option>
{llmPresets.map(preset => (
<option key={preset.id} value={preset.id}>
{preset.label}
{preset.is_default ? '(默认)' : ''}
</option>
))}
</select>
<div className="config-field-hint">
{selectedLlmPreset
? `${selectedLlmPreset.description || '后端预设模型'} · ${selectedLlmPreset.model_name}`
: '模型预设由后端 config.py 管理,前端只选择预设。'}
</div>
</div>
<div className="config-run-options-row">
<label htmlFor="consistency-mode">一致性模式</label>
<select
id="consistency-mode"
className="config-input"
value={config.consistency_mode || 'free'}
onChange={e => setConsistencyMode(e.target.value)}
disabled={isRunning}
>
<option value="free">free</option>
<option value="soft">soft</option>
</select>
<div className="config-field-hint">
{config.consistency_mode === 'soft'
? 'soft 会优先承接依赖字段、统一命名口径,并在最终成文时增强跨模块衔接。'
: 'free 只做基础一致性控制,适合依赖关系较弱或开放式的场景模板。'}
</div>
</div>
<div className="config-submit-row">
<div className="config-fixed-mode">报告结构、智能体和协调者策略由所选模板固定。</div>
{isRunning ? (
<button type="button" className="btn-danger" onClick={onStop}>停止分析</button>
) : (
<button type="submit" className="btn-start" disabled={!canStart}>
{config.coordinator_mode === 'human_review' ? '开始并逐轮确认' : '开始分析'}
</button>
)}
</div>
{!config.template_id && (
<div className="config-field-hint">开始前需要先选择一个报告模板。</div>
)}
{llmError && (
<div className="human-review-error">模型配置加载失败:{llmError}</div>
)}
</div>
<Card title="报告模板" description="选择后会自动固定报告字段、字段标准、参与智能体和协调者策略。">
<div className="config-submit-row">
<select
className="config-input"
value={config.template_id || ''}
onChange={e => applyTemplate(e.target.value)}
disabled={isRunning || templateLoading}
>
<option value="">选择模板...</option>
{templates.map(template => (
<option key={template.template_id} value={template.template_id}>
{template.template_name || template.template_id}
{hasTemplateOverride(template.template_id) ? '(已修改)' : ''}
</option>
))}
</select>
<button
type="button"
className="btn-primary"
onClick={loadTemplates}
disabled={isRunning || templateLoading}
>
{templateLoading ? '加载中' : '刷新模板'}
</button>
</div>
{config.template_name && (
<div className="config-field-hint">
当前模板:{config.template_name}
{config.template_brief ? ` · ${config.template_brief}` : ''}
</div>
)}
{templateError && (
<div className="human-review-error">模板加载失败:{templateError}</div>
)}
</Card>
<div className="config-field-hint">
运行模型预设由后端 `config.py` 管理;前端只选择预设 id,不接触密钥。
</div>
<div className="config-advanced-section">
<button
type="button"
className="config-advanced-toggle"
onClick={() => setDetailOpen(open => !open)}
>
<span>模板详情(只读)</span>
<span className={`config-advanced-chevron ${detailOpen ? 'open' : ''}`}>▾</span>
</button>
{detailOpen && (
<div className="config-advanced-body">
{!config.template_id ? (
<div className="config-empty-detail">选择模板后可查看字段、智能体和协调者策略。</div>
) : (
<>
<ReadOnlyBlock title="报告字段">
<div className="config-report-grid readonly">
{Object.entries(config.report_schema || {}).map(([field, desc]) => (
<div key={field} className="config-report-card">
<div className="config-agent-head">
<span>{field}</span>
</div>
<p>{desc}</p>
{config.field_requirements?.[field] && (
<pre>{JSON.stringify(config.field_requirements[field], null, 2)}</pre>
)}
</div>
))}
</div>
</ReadOnlyBlock>
<ReadOnlyBlock title="参与智能体">
<div className="config-agents readonly">
{(config.agents || []).map(agent => (
<div key={agent.id} className="config-agent-card">
<div className="config-agent-head">
<span>{agent.name}</span>
<code>{agent.id}</code>
</div>
<p className="config-readonly-meta">{agent.dimension}</p>
<p>{agent.expertise || agent.description || agent.skill}</p>
{(agent.relevant_fields || []).length > 0 && (
<p className="config-readonly-meta">关联字段:{agent.relevant_fields.join('、')}</p>
)}
</div>
))}
</div>
</ReadOnlyBlock>
<ReadOnlyBlock title="协调者策略">
<pre>{JSON.stringify(config.coordinator_config || {}, null, 2)}</pre>
</ReadOnlyBlock>
</>
)}
</div>
)}
</div>
<div className="config-advanced-section">
<button
type="button"
className="config-advanced-toggle"
onClick={() => setRawOpen(open => !open)}
>
<span>原始配置预览</span>
<span className={`config-advanced-chevron ${rawOpen ? 'open' : ''}`}>▾</span>
</button>
{rawOpen && (
<div className="config-advanced-body">
<textarea className="config-json-editor" value={rawConfig} readOnly spellCheck={false} />
</div>
)}
</div>
</form>
</div>
);
}
@@ -0,0 +1,167 @@
.final-report {
background: var(--surface-raised);
border: 1px solid var(--green);
border-radius: 16px;
overflow: hidden;
margin-top: 24px;
box-shadow: var(--shadow-card);
}
.final-report-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--border);
background: rgba(63, 185, 80, 0.05);
}
.final-report-icon {
font-size: 20px;
}
.final-report-title {
font-size: 18px;
font-weight: 700;
color: var(--green);
}
.final-report-meta {
margin-left: auto;
font-size: 12px;
color: var(--text-muted);
}
/* Report sections */
.final-report-sections {
padding: 20px;
display: flex;
flex-direction: column;
gap: 20px;
}
.final-report-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.final-report-section-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
color: var(--green);
padding-bottom: 6px;
border-bottom: 1px solid rgba(63, 185, 80, 0.2);
}
.final-report-section-content {
font-size: 14px;
color: var(--text);
}
.final-report-section-content .markdown-body {
font-size: 14px;
}
/* Dimensions section */
.final-report-dimensions-toggle {
width: 100%;
background: transparent;
border: none;
border-top: 1px solid var(--border);
padding: 12px 20px;
display: flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
cursor: pointer;
text-align: left;
border-radius: 0;
transition: color 0.15s, background 0.15s;
}
.final-report-dimensions-toggle:hover {
color: var(--text);
background: var(--surface-hover);
}
.final-report-dimensions-chevron {
margin-left: auto;
transition: transform 0.2s;
}
.final-report-dimensions-chevron.open {
transform: rotate(180deg);
}
.final-report-dimensions-body {
padding: 16px 20px 20px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 20px;
}
.dim-detail {
display: flex;
flex-direction: column;
gap: 8px;
}
.dim-detail-label {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--accent);
display: flex;
align-items: center;
gap: 8px;
}
.dim-detail-sublabel {
font-size: 11px;
color: var(--text-muted);
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
.dim-detail-analysis {
font-size: 13px;
color: var(--text);
}
.dim-detail-output {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 4px;
padding: 8px 12px;
background: var(--surface-hover);
border-radius: 6px;
border-left: 2px solid var(--accent);
}
.dim-detail-output-item {
display: flex;
flex-direction: column;
gap: 2px;
}
.dim-detail-output-key {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.dim-detail-output-value {
font-size: 12px;
color: var(--text);
}
@@ -0,0 +1,114 @@
import React, { useState } from 'react';
import './FinalReport.css';
import MarkdownContent from './MarkdownContent';
/** 报告字段中文标签映射表 */
const REPORT_FIELD_LABELS = {
core_strategy: '核心策略',
action_plan: '行动计划',
key_messages: '核心话术',
risk_warnings: '风险预警',
};
/**
* 最终报告组件
*
* 在所有分析轮次结束后显示完整的最终报告。
* 包含两部分:
* 1. 报告主体:按 reportSchema 定义的字段顺序渲染各章节,支持 Markdown
* 2. 维度详情(可折叠):每个智能体分析维度的详细输出
*
* @param {Object} output - 报告数据 { report: { field: content }, dimensions: { dim: data }, meta: { total_rounds } }
* @param {Object} reportSchema - 报告字段定义,用于获取字段顺序和中文标签
*/
export default function FinalReport({ output, reportSchema }) {
const [dimOpen, setDimOpen] = useState(false);
if (!output) return null;
const { report, dimensions, meta } = output;
// 优先使用 reportSchema 的 key 顺序,否则用 report 自身的 key
const reportKeys = reportSchema && Object.keys(reportSchema).length > 0
? Object.keys(reportSchema)
: (report ? Object.keys(report) : []);
return (
<div className="final-report">
{/* 报告标题区 */}
<div className="final-report-header">
<span className="final-report-icon">✦</span>
<span className="final-report-title">最终方案</span>
{meta && meta.total_rounds != null && (
<span className="final-report-meta">共 {meta.total_rounds} 轮分析</span>
)}
</div>
{/* 报告主体:按字段顺序渲染 */}
{report && reportKeys.length > 0 && (
<div className="final-report-sections">
{reportKeys.map(key => {
const content = report[key];
if (content == null) return null;
// 优先用 reportSchema 中的字段描述作为标题,其次用中文映射表,兜底用 key
const label = (reportSchema && reportSchema[key])
? reportSchema[key]
: (REPORT_FIELD_LABELS[key] || key);
return (
<div key={key} className="final-report-section">
<div className="final-report-section-label">{label}</div>
<div className="final-report-section-content">
<MarkdownContent
content={typeof content === 'string' ? content : JSON.stringify(content, null, 2)}
/>
</div>
</div>
);
})}
</div>
)}
{/* 维度详情(可折叠) */}
{dimensions && Object.keys(dimensions).length > 0 && (
<>
<button
className="final-report-dimensions-toggle"
onClick={() => setDimOpen(o => !o)}
>
<span>维度详情</span>
<span className={`final-report-dimensions-chevron ${dimOpen ? 'open' : ''}`}>▾</span>
</button>
{dimOpen && (
<div className="final-report-dimensions-body">
{Object.entries(dimensions).map(([dim, dimData]) => (
<div key={dim} className="dim-detail">
<div className="dim-detail-label">
<span>{dim}</span>
{typeof dimData === 'object' && dimData?.label && (
<span className="dim-detail-sublabel">{dimData.label}</span>
)}
</div>
{/* 字符串类型的维度分析 */}
{typeof dimData === 'string' && (
<div className="dim-detail-analysis">
<MarkdownContent content={dimData} />
</div>
)}
{/* 对象类型的维度分析,取 latest_analysis 字段 */}
{typeof dimData === 'object' && dimData?.latest_analysis && (
<div className="dim-detail-analysis">
<MarkdownContent content={dimData.latest_analysis} />
</div>
)}
</div>
))}
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,98 @@
import React, { useState } from 'react';
import './FinalReport.css';
import MarkdownContent from './MarkdownContent';
const REPORT_FIELD_LABELS: Record<string, string> = {
core_strategy: '核心策略',
action_plan: '行动计划',
key_messages: '核心话术',
risk_warnings: '风险预警',
};
type Props = {
output?: any;
reportSchema?: Record<string, string>;
};
export default function FinalReport({ output, reportSchema }: Props): JSX.Element | null {
const [dimOpen, setDimOpen] = useState(false);
if (!output) return null;
const { report, dimensions, meta } = output;
const reportKeys = reportSchema && Object.keys(reportSchema).length > 0
? Object.keys(reportSchema)
: (report ? Object.keys(report) : []);
return (
<div className="final-report">
<div className="final-report-header">
<span className="final-report-icon">✦</span>
<span className="final-report-title">最终方案</span>
{meta && meta.total_rounds != null && (
<span className="final-report-meta">共 {meta.total_rounds} 轮分析</span>
)}
</div>
{report && reportKeys.length > 0 && (
<div className="final-report-sections">
{reportKeys.map(key => {
const content = report[key];
if (content == null) return null;
const label = (reportSchema && reportSchema[key])
? reportSchema[key]
: (REPORT_FIELD_LABELS[key] || key);
return (
<div key={key} className="final-report-section">
<div className="final-report-section-label">{label}</div>
<div className="final-report-section-content">
<MarkdownContent
content={typeof content === 'string' ? content : JSON.stringify(content, null, 2)}
/>
</div>
</div>
);
})}
</div>
)}
{dimensions && Object.keys(dimensions).length > 0 && (
<>
<button
className="final-report-dimensions-toggle"
onClick={() => setDimOpen(o => !o)}
>
<span>维度详情</span>
<span className={`final-report-dimensions-chevron ${dimOpen ? 'open' : ''}`}>▾</span>
</button>
{dimOpen && (
<div className="final-report-dimensions-body">
{Object.entries(dimensions).map(([dim, dimData]: [string, any]) => (
<div key={dim} className="dim-detail">
<div className="dim-detail-label">
<span>{dim}</span>
{typeof dimData === 'object' && dimData?.label && (
<span className="dim-detail-sublabel">{dimData.label}</span>
)}
</div>
{typeof dimData === 'string' && (
<div className="dim-detail-analysis">
<MarkdownContent content={dimData} />
</div>
)}
{typeof dimData === 'object' && dimData?.latest_analysis && (
<div className="dim-detail-analysis">
<MarkdownContent content={dimData.latest_analysis} />
</div>
)}
</div>
))}
</div>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,131 @@
.markdown-body {
line-height: 1.7;
color: var(--text-primary);
word-break: break-word;
}
.markdown-body > *:first-child { margin-top: 0; }
.markdown-body > *:last-child { margin-bottom: 0; }
/* Headings */
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
margin: 1em 0 0.4em;
font-weight: 600;
line-height: 1.3;
color: var(--text-primary);
}
.markdown-body h1 { font-size: 1.3em; border-bottom: 1px solid var(--border-color); padding-bottom: 0.3em; }
.markdown-body h2 { font-size: 1.15em; border-bottom: 1px solid var(--border-color); padding-bottom: 0.2em; }
.markdown-body h3 { font-size: 1.05em; }
.markdown-body h4 { font-size: 0.95em; }
/* Paragraphs */
.markdown-body p {
margin: 0.5em 0;
}
/* Lists */
.markdown-body ul,
.markdown-body ol {
margin: 0.5em 0;
padding-left: 1.6em;
}
.markdown-body li {
margin: 0.25em 0;
}
.markdown-body li > ul,
.markdown-body li > ol {
margin: 0.2em 0;
}
/* Bold & italic */
.markdown-body strong { font-weight: 600; color: var(--text-primary); }
.markdown-body em { font-style: italic; color: var(--text-secondary); }
/* Inline code */
.markdown-body code {
background: rgba(110, 118, 129, 0.15);
border: 1px solid var(--border-color);
border-radius: 4px;
padding: 0.1em 0.4em;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.875em;
color: var(--red);
}
/* Code blocks */
.markdown-body pre {
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1em;
overflow-x: auto;
margin: 0.6em 0;
}
.markdown-body pre code {
background: none;
border: none;
padding: 0;
font-size: 0.85em;
color: var(--text-primary);
}
/* Blockquote */
.markdown-body blockquote {
border-left: 3px solid var(--accent-blue);
margin: 0.5em 0;
padding: 0.3em 0.8em;
color: var(--text-secondary);
background: rgba(88, 166, 255, 0.05);
border-radius: 0 4px 4px 0;
}
.markdown-body blockquote p { margin: 0; }
/* Horizontal rule */
.markdown-body hr {
border: none;
border-top: 1px solid var(--border-color);
margin: 1em 0;
}
/* Links */
.markdown-body a {
color: var(--accent-blue);
text-decoration: none;
}
.markdown-body a:hover {
text-decoration: underline;
}
/* Tables (remark-gfm) */
.markdown-body table {
border-collapse: collapse;
width: 100%;
margin: 0.6em 0;
font-size: 0.9em;
}
.markdown-body th,
.markdown-body td {
border: 1px solid var(--border-color);
padding: 0.4em 0.7em;
text-align: left;
}
.markdown-body th {
background: var(--bg-secondary);
font-weight: 600;
color: var(--text-primary);
}
.markdown-body tr:nth-child(even) td {
background: var(--surface-hover);
}
/* Task lists (remark-gfm) */
.markdown-body input[type='checkbox'] {
margin-right: 0.4em;
accent-color: var(--accent-blue);
}
@@ -0,0 +1,84 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import './MarkdownContent.css';
/**
* Rehype 插件:高亮 Markdown 文本中的引用标记 [n]
*
* 遍历 hast 树中的所有文本节点,将匹配 [数字] 格式的片段
* 替换为带 tooltip 的 <span class="ref-cite"> 元素。
* 在 AgentCard 的最终输出中使用,帮助追溯检索到的源材料。
*
* @param {Object} referenceMap - 引用编号到源 ID 的映射,如 { "1": "src_abc123", "2": "src_def456" }
* @returns {Function} rehype 插件函数
*/
function rehypeHighlightRefs(referenceMap) {
return function (tree) {
function walk(node) {
if (!node.children) return;
const next = [];
for (const child of node.children) {
if (child.type === 'text') {
const parts = child.value.split(/(\[\d+\])/g);
if (parts.length > 1) {
for (const part of parts) {
if (!part) continue;
const m = part.match(/^\[(\d+)\]$/);
if (m) {
const num = m[1];
const itemId = referenceMap?.[num];
next.push({
type: 'element',
tagName: 'span',
properties: {
className: 'ref-cite',
'data-tooltip': itemId ? `ID: ${itemId}` : `引用 ${num}`,
},
children: [{ type: 'text', value: part }],
});
} else {
next.push({ type: 'text', value: part });
}
}
} else {
next.push(child);
}
} else {
walk(child);
next.push(child);
}
}
node.children = next;
}
walk(tree);
};
}
/**
* Markdown 渲染组件
*
* 封装 react-markdown,提供统一的 Markdown 渲染体验:
* - 支持 GFM(表格、删除线、任务列表等)
* - 自动高亮引用标记 [n] 为可点击的引用标签
*
* @param {string} content - Markdown 文本内容
* @param {Object} referenceMap - 引用编号映射(可选)
* @param {string} className - 额外的 CSS 类名
*/
export default function MarkdownContent({ content, referenceMap, className = '' }) {
if (!content) return null;
const rehypePlugins = [() => rehypeHighlightRefs(referenceMap)];
return (
<div className={`markdown-body ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={rehypePlugins}
>
{content}
</ReactMarkdown>
</div>
);
}
@@ -0,0 +1,68 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import './MarkdownContent.css';
type Props = {
content?: string;
referenceMap?: Record<string, string>;
className?: string;
};
function rehypeHighlightRefs(referenceMap?: Record<string, string>) {
return function (tree: any) {
function walk(node: any) {
if (!node.children) return;
const next = [];
for (const child of node.children) {
if (child.type === 'text') {
const parts = child.value.split(/(\[\d+\])/g);
if (parts.length > 1) {
for (const part of parts) {
if (!part) continue;
const m = part.match(/^\[(\d+)\]$/);
if (m) {
const num = m[1];
const itemId = referenceMap?.[num];
next.push({
type: 'element',
tagName: 'span',
properties: {
className: 'ref-cite',
'data-tooltip': itemId ? `ID: ${itemId}` : `引用 ${num}`,
},
children: [{ type: 'text', value: part }],
});
} else {
next.push({ type: 'text', value: part });
}
}
} else {
next.push(child);
}
} else {
walk(child);
next.push(child);
}
}
node.children = next;
}
walk(tree);
};
}
export default function MarkdownContent({ content, referenceMap, className = '' }: Props): JSX.Element | null {
if (!content) return null;
const rehypePlugins = [() => rehypeHighlightRefs(referenceMap)];
return (
<div className={`markdown-body ${className}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={rehypePlugins}
>
{content}
</ReactMarkdown>
</div>
);
}
@@ -0,0 +1,123 @@
.progress-sidebar {
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.progress-card {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 14px;
padding: 12px;
box-shadow: var(--shadow-card);
}
.progress-title {
font-size: 12px;
font-weight: 700;
color: var(--text);
margin-bottom: 8px;
}
.progress-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
}
.progress-phase,
.progress-status,
.progress-text,
.field-current,
.empty-hint,
.memory-subtitle,
.field-memory-status {
font-size: 11px;
color: var(--text-muted);
}
.progress-line {
height: 6px;
border-radius: 999px;
background: var(--surface);
border: 1px solid var(--border);
overflow: hidden;
}
.progress-line-fill {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--green));
transition: width 0.25s ease;
}
.progress-text,
.field-current {
margin-top: 8px;
}
.field-current {
color: var(--accent);
}
.memory-list,
.field-memory-list,
.agent-memory-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.memory-item,
.agent-memory-card,
.field-memory-card {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px;
}
.memory-item {
font-size: 11px;
color: var(--text-muted);
line-height: 1.55;
}
.memory-subtitle {
margin: 8px 0 6px;
}
.field-memory-card.status-filled {
border-left: 2px solid var(--green);
}
.field-memory-card.status-partial {
border-left: 2px solid var(--yellow);
}
.field-memory-card.status-blocked {
border-left: 2px solid var(--red);
}
.field-memory-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.field-memory-title,
.agent-memory-name {
font-size: 12px;
font-weight: 700;
color: var(--text);
}
.agent-memory-position {
font-size: 11px;
color: var(--text);
line-height: 1.55;
}
@@ -0,0 +1,156 @@
import React from 'react';
import './ProgressSidebar.css';
/**
* 通用列表子组件
* 当 items 为空时显示 emptyText 提示,否则逐条渲染。
*/
function MemoryList({ items, emptyText }) {
if (!items || items.length === 0) {
return <div className="empty-hint">{emptyText}</div>;
}
return (
<div className="memory-list">
{items.map((item, index) => (
<div key={`${index}-${String(item).slice(0, 20)}`} className="memory-item">{item}</div>
))}
</div>
);
}
/**
* 右侧进度侧边栏
*
* 显示分析过程中的实时进度和共享记忆:
* - 方案进度:阶段标识 + 最终报告字段填充进度条
* - 稳定判断:协调者确认的跨智能体一致结论
* - 关键依据:沉淀下来的核心证据
* - 冲突与待核:已识别的分歧和待验证问题
* - 字段记忆:每个报告字段的当前判断和待补内容
* - 角色记忆:每个智能体的最新立场和有效发现
*
* @param {string} phase - 当前阶段
* @param {string} status - 会话状态
* @param {number} currentRound - 当前轮次
* @param {number} divergeRounds - 辩论轮总数
* @param {Object} reportSchema - 报告字段定义
* @param {Object} sharedMemory - 共享记忆对象
* @param {Object} finalReportPartial - 最终报告的部分字段内容
* @param {string} currentFinalField - 当前正在生成的最终报告字段
*/
export default function ProgressSidebar({
phase,
status,
currentRound,
divergeRounds,
reportSchema,
sharedMemory,
finalReportPartial,
currentFinalField,
}) {
const fields = Object.keys(reportSchema || {});
// 统计已填充(内容非空)的字段数
const finalFilled = fields.filter(field => (finalReportPartial?.[field] || '').trim()).length;
const progressPercent = fields.length > 0 ? Math.round((finalFilled / fields.length) * 100) : 0;
const fieldMemoryEntries = Object.entries(sharedMemory?.field_memory || {});
const agentMemoryEntries = Object.entries(sharedMemory?.agent_memory || {});
return (
<div className="progress-sidebar">
{/* 方案进度卡片 */}
<div className="progress-card">
<div className="progress-title">方案进度</div>
<div className="progress-meta">
<span className={`progress-phase phase-${phase || 'starting'}`}>{phase || 'starting'}</span>
<span className="progress-status">{status}</span>
</div>
<div className="progress-line">
<div className="progress-line-fill" style={{ width: `${progressPercent}%` }} />
</div>
<div className="progress-text">第 {currentRound || 0} 轮 / 发散 {divergeRounds || 0} 轮</div>
<div className="progress-text">最终报告已生成字段 {finalFilled}/{fields.length || 0}</div>
{currentFinalField && <div className="field-current">当前字段:{currentFinalField}</div>}
</div>
{/* 稳定判断卡片 */}
<div className="progress-card">
<div className="progress-title">稳定判断</div>
<MemoryList items={sharedMemory?.stable_claims || []} emptyText="尚未形成稳定判断" />
</div>
{/* 关键依据卡片 */}
<div className="progress-card">
<div className="progress-title">关键依据</div>
<MemoryList items={sharedMemory?.key_evidence || []} emptyText="尚未沉淀关键依据" />
</div>
{/* 冲突与待核卡片 */}
<div className="progress-card">
<div className="progress-title">冲突与待核</div>
<MemoryList items={sharedMemory?.conflicts || []} emptyText="当前没有显式冲突" />
<div className="memory-subtitle">待核问题</div>
<MemoryList items={sharedMemory?.open_questions || []} emptyText="当前没有待核问题" />
</div>
{/* 字段记忆卡片 */}
<div className="progress-card">
<div className="progress-title">字段记忆</div>
{fieldMemoryEntries.length === 0 ? (
<div className="empty-hint">尚未形成字段记忆</div>
) : (
<div className="field-memory-list">
{fieldMemoryEntries.map(([field, item]) => (
<div key={field} className={`field-memory-card status-${item.status || 'partial'}`}>
<div className="field-memory-head">
<span className="field-memory-title">{item.title || field}</span>
<span className="field-memory-status">{item.status || 'partial'}</span>
</div>
{item.current_takeaways?.length > 0 && (
<>
<div className="memory-subtitle">当前判断</div>
<MemoryList items={item.current_takeaways} emptyText="" />
</>
)}
{item.missing_points?.length > 0 && (
<>
<div className="memory-subtitle">待补内容</div>
<MemoryList items={item.missing_points} emptyText="" />
</>
)}
</div>
))}
</div>
)}
</div>
{/* 角色记忆卡片 */}
<div className="progress-card">
<div className="progress-title">角色记忆</div>
{agentMemoryEntries.length === 0 ? (
<div className="empty-hint">尚未形成角色记忆</div>
) : (
<div className="agent-memory-list">
{agentMemoryEntries.map(([agentId, item]) => (
<div key={agentId} className="agent-memory-card">
<div className="agent-memory-name">{item.name || agentId}</div>
{item.latest_position && <div className="agent-memory-position">{item.latest_position}</div>}
{item.useful_findings?.length > 0 && (
<>
<div className="memory-subtitle">有效发现</div>
<MemoryList items={item.useful_findings} emptyText="" />
</>
)}
{item.open_issues?.length > 0 && (
<>
<div className="memory-subtitle">待继续澄清</div>
<MemoryList items={item.open_issues} emptyText="" />
</>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,141 @@
import './ProgressSidebar.css';
type MemoryListProps = {
items?: any[];
emptyText: string;
};
type Props = {
phase?: string;
status?: string;
currentRound?: number;
divergeRounds?: number;
reportSchema?: Record<string, any>;
sharedMemory?: Record<string, any>;
finalReportPartial?: Record<string, string>;
currentFinalField?: string;
};
function MemoryList({ items, emptyText }: MemoryListProps): JSX.Element {
if (!items || items.length === 0) {
return <div className="empty-hint">{emptyText}</div>;
}
return (
<div className="memory-list">
{items.map((item, index) => (
<div key={`${index}-${String(item).slice(0, 20)}`} className="memory-item">{item}</div>
))}
</div>
);
}
export default function ProgressSidebar({
phase,
status,
currentRound,
divergeRounds,
reportSchema,
sharedMemory,
finalReportPartial,
currentFinalField,
}: Props): JSX.Element {
const fields = Object.keys(reportSchema || {});
const finalFilled = fields.filter(field => (finalReportPartial?.[field] || '').trim()).length;
const progressPercent = fields.length > 0 ? Math.round((finalFilled / fields.length) * 100) : 0;
const fieldMemoryEntries = Object.entries(sharedMemory?.field_memory || {});
const agentMemoryEntries = Object.entries(sharedMemory?.agent_memory || {});
const displayStatus = status || '等待中';
return (
<div className="progress-sidebar">
<div className="progress-card">
<div className="progress-title">方案进度</div>
<div className="progress-meta">
<span className={`progress-phase phase-${phase || 'starting'}`}>{phase || 'starting'}</span>
<span className="progress-status">{displayStatus}</span>
</div>
<div className="progress-line">
<div className="progress-line-fill" style={{ width: `${progressPercent}%` }} />
</div>
<div className="progress-text">第 {currentRound || 0} 轮 / 发散 {divergeRounds || 0} 轮</div>
<div className="progress-text">最终报告已生成字段 {finalFilled}/{fields.length || 0}</div>
{currentFinalField && <div className="field-current">当前字段:{currentFinalField}</div>}
</div>
<div className="progress-card">
<div className="progress-title">稳定判断</div>
<MemoryList items={sharedMemory?.stable_claims || []} emptyText="尚未形成稳定判断" />
</div>
<div className="progress-card">
<div className="progress-title">关键依据</div>
<MemoryList items={sharedMemory?.key_evidence || []} emptyText="尚未沉淀关键依据" />
</div>
<div className="progress-card">
<div className="progress-title">冲突与待核</div>
<MemoryList items={sharedMemory?.conflicts || []} emptyText="当前没有显式冲突" />
<div className="memory-subtitle">待核问题</div>
<MemoryList items={sharedMemory?.open_questions || []} emptyText="当前没有待核问题" />
</div>
<div className="progress-card">
<div className="progress-title">字段记忆</div>
{fieldMemoryEntries.length === 0 ? (
<div className="empty-hint">尚未形成字段记忆</div>
) : (
<div className="field-memory-list">
{fieldMemoryEntries.map(([field, item]: [string, any]) => (
<div key={field} className={`field-memory-card status-${item.status || 'partial'}`}>
<div className="field-memory-head">
<span className="field-memory-title">{item.title || field}</span>
<span className="field-memory-status">{item.status || 'partial'}</span>
</div>
{item.current_takeaways?.length > 0 && (
<>
<div className="memory-subtitle">当前判断</div>
<MemoryList items={item.current_takeaways} emptyText="" />
</>
)}
{item.missing_points?.length > 0 && (
<>
<div className="memory-subtitle">待补内容</div>
<MemoryList items={item.missing_points} emptyText="" />
</>
)}
</div>
))}
</div>
)}
</div>
<div className="progress-card">
<div className="progress-title">角色记忆</div>
{agentMemoryEntries.length === 0 ? (
<div className="empty-hint">尚未形成角色记忆</div>
) : (
<div className="agent-memory-list">
{agentMemoryEntries.map(([agentId, item]: [string, any]) => (
<div key={agentId} className="agent-memory-card">
<div className="agent-memory-name">{item.name || agentId}</div>
{item.latest_position && <div className="agent-memory-position">{item.latest_position}</div>}
{item.useful_findings?.length > 0 && (
<>
<div className="memory-subtitle">有效发现</div>
<MemoryList items={item.useful_findings} emptyText="" />
</>
)}
{item.open_issues?.length > 0 && (
<>
<div className="memory-subtitle">待继续澄清</div>
<MemoryList items={item.open_issues} emptyText="" />
</>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,337 @@
.scenario-page {
width: 100%;
max-width: 1280px;
min-height: 100vh;
padding: 28px;
}
.scenario-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 20px;
margin-bottom: 18px;
}
.scenario-kicker {
color: var(--accent);
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
}
.scenario-header h1 {
color: var(--text);
font-size: 30px;
margin: 4px 0 6px;
}
.scenario-header p,
.scenario-card-desc {
color: var(--text-muted);
font-size: 13px;
}
.scenario-header-actions {
display: flex;
gap: 10px;
flex-shrink: 0;
}
.scenario-toolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px;
margin-bottom: 14px;
}
.scenario-select,
.scenario-input,
.scenario-textarea,
.scenario-json {
background: var(--card);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-family: inherit;
font-size: 13px;
outline: none;
}
.scenario-select {
min-width: 280px;
padding: 9px 12px;
flex: 1;
}
.scenario-select-inline {
min-width: 0;
width: 100%;
flex: 0 0 auto;
margin-top: 6px;
}
.scenario-input {
width: 100%;
padding: 10px 12px;
}
.scenario-textarea,
.scenario-json {
width: 100%;
padding: 12px;
line-height: 1.65;
resize: vertical;
}
.scenario-textarea {
min-height: 120px;
}
.scenario-textarea-sm {
min-height: 80px;
}
.scenario-textarea-lg {
min-height: 180px;
}
.scenario-json {
min-height: 460px;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 12px;
}
.scenario-select:focus,
.scenario-input:focus,
.scenario-textarea:focus,
.scenario-json:focus {
border-color: var(--accent);
}
.scenario-message,
.scenario-error,
.scenario-empty {
border-radius: 10px;
padding: 10px 14px;
margin-bottom: 14px;
font-size: 13px;
}
.scenario-message {
background: rgba(63, 185, 80, 0.1);
border: 1px solid rgba(63, 185, 80, 0.45);
color: var(--green);
}
.scenario-error {
background: rgba(248, 81, 73, 0.1);
border: 1px solid rgba(248, 81, 73, 0.45);
color: var(--red);
}
.scenario-empty {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-muted);
}
.scenario-layout {
display: flex;
flex-direction: column;
gap: 16px;
}
.scenario-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
padding: 18px;
}
.scenario-card-title {
color: var(--text);
font-size: 16px;
font-weight: 700;
margin-bottom: 12px;
}
.scenario-card-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 14px;
}
.scenario-card-head .scenario-card-title {
margin-bottom: 2px;
}
.scenario-grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.scenario-card label {
display: block;
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
margin-top: 14px;
}
.scenario-card label:first-child {
margin-top: 0;
}
.scenario-card label > input,
.scenario-card label > textarea {
margin-top: 6px;
}
.scenario-inline-hint {
margin-top: 6px;
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
line-height: 1.55;
}
.scenario-field-key {
color: var(--accent);
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 11px;
font-weight: 500;
margin-left: 8px;
}
.scenario-coordinator-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.scenario-agent-layout {
display: grid;
grid-template-columns: 260px 1fr;
gap: 16px;
}
.scenario-agent-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.scenario-agent-tab {
background: var(--card);
border: 1px solid var(--border);
color: var(--text-muted);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 10px 12px;
text-align: left;
}
.scenario-agent-tab.active {
border-color: var(--accent);
background: rgba(88, 166, 255, 0.1);
color: var(--text);
}
.scenario-agent-tab span {
font-weight: 700;
}
.scenario-agent-tab code {
color: var(--accent);
font-size: 11px;
}
.scenario-agent-editor {
background: rgba(255,255,255,0.02);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px;
}
.scenario-field-picker {
margin-top: 6px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.scenario-field-option {
display: flex !important;
gap: 10px;
align-items: flex-start;
background: var(--card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px;
margin-top: 0 !important;
cursor: pointer;
}
.scenario-field-option.selected {
border-color: var(--accent);
background: rgba(88, 166, 255, 0.08);
}
.scenario-field-option input[type='checkbox'] {
margin-top: 2px;
accent-color: var(--accent);
}
.scenario-field-option-body {
min-width: 0;
}
.scenario-field-option-title {
color: var(--text);
font-size: 12px;
font-weight: 700;
}
.scenario-field-option-key {
color: var(--accent);
font-size: 11px;
font-family: 'SFMono-Regular', Consolas, monospace;
margin-top: 2px;
}
.scenario-field-option-desc {
color: var(--text-muted);
font-size: 11px;
line-height: 1.5;
margin-top: 5px;
}
@media (max-width: 960px) {
.scenario-page {
padding: 18px;
}
.scenario-header,
.scenario-card-head {
flex-direction: column;
}
.scenario-grid-2,
.scenario-coordinator-grid,
.scenario-agent-layout,
.scenario-field-picker {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,417 @@
// @ts-nocheck
import React, { useEffect, useMemo, useState } from 'react';
import {
clearTemplateOverride,
clone,
hasTemplateOverride,
loadOriginalTemplate,
loadTemplateByMeta,
loadTemplateIndex,
saveTemplateOverride,
} from '@/config/templateStore';
import './ScenarioConfigPage.css';
const COORDINATOR_FIELDS = [
['system_prompt_prefix', '协调者全局规则'],
['round1_guidance', '首轮策略'],
['middle_round_guidance', '中间轮策略'],
['final_round_guidance', '末轮策略'],
['speaker_selection_rules', '选人规则'],
['focus_instruction_rules', '任务指令规则'],
['dependency_driving_rules', '依赖推进规则'],
['consistency_check_rules', '一致性/冲突检查规则'],
['revise_prompt_prefix', '人工修改解释规则'],
];
function updateAtIndex(items, index, updater) {
return items.map((item, current) => current === index ? updater(clone(item)) : item);
}
function linesToList(value) {
return value
.split('\n')
.map(item => item.trim())
.filter(Boolean);
}
export default function ScenarioConfigPage({ onBack }) {
const [templates, setTemplates] = useState([]);
const [selectedId, setSelectedId] = useState('');
const [draft, setDraft] = useState(null);
const [rawJson, setRawJson] = useState('');
const [activeAgentIndex, setActiveAgentIndex] = useState(0);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const selectedMeta = useMemo(
() => templates.find(item => item.template_id === selectedId),
[templates, selectedId],
);
const fieldOptions = useMemo(() => {
const schema = draft?.report_schema || {};
const requirements = draft?.field_requirements || {};
return Object.keys(schema).map(field => ({
key: field,
title: requirements?.[field]?.title || field,
description: schema[field] || '',
}));
}, [draft]);
const loadTemplates = async () => {
setLoading(true);
setError('');
try {
const list = await loadTemplateIndex();
setTemplates(list);
if (!selectedId && list[0]) {
setSelectedId(list[0].template_id);
}
} catch (err) {
setError(err.message || '模板列表加载失败');
} finally {
setLoading(false);
}
};
const loadTemplate = async (templateId) => {
if (!templateId) return;
const meta = templates.find(item => item.template_id === templateId) || templateId;
setLoading(true);
setError('');
setMessage('');
try {
const template = await loadTemplateByMeta(meta);
setDraft(template);
setRawJson(JSON.stringify(template, null, 2));
setActiveAgentIndex(0);
} catch (err) {
setError(err.message || '模板加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadTemplates();
}, []);
useEffect(() => {
if (selectedId && templates.length > 0) {
loadTemplate(selectedId);
}
}, [selectedId, templates.length]);
const syncDraft = (updater) => {
setDraft(prev => {
const next = clone(prev);
updater(next);
setRawJson(JSON.stringify(next, null, 2));
return next;
});
setMessage('');
};
const setCoordinatorField = (key, value) => {
syncDraft(next => {
next.coordinator_config = {
...(next.coordinator_config || {}),
[key]: value,
};
});
};
const setAgentField = (index, key, value) => {
syncDraft(next => {
next.agents = updateAtIndex(next.agents || [], index, agent => ({
...agent,
[key]: value,
}));
});
};
const applyRawJson = () => {
setError('');
setMessage('');
try {
const parsed = JSON.parse(rawJson);
if (!parsed.template_id) throw new Error('JSON 中缺少 template_id');
setDraft(parsed);
setSelectedId(parsed.template_id);
setMessage('完整 JSON 已应用,保存后生效。');
} catch (err) {
setError(err.message || 'JSON 解析失败');
}
};
const saveDraft = () => {
if (!draft?.template_id) return;
try {
saveTemplateOverride(draft.template_id, draft);
setMessage('已保存到浏览器本地覆盖。返回首页选择该模板即可使用修改后的提示。');
setError('');
} catch (err) {
setError(err.message || '保存失败');
}
};
const resetTemplate = async () => {
if (!selectedId) return;
setLoading(true);
setMessage('');
setError('');
try {
clearTemplateOverride(selectedId);
const original = await loadOriginalTemplate(selectedMeta || selectedId);
setDraft(original);
setRawJson(JSON.stringify(original, null, 2));
setMessage('已恢复为静态模板原始内容。');
} catch (err) {
setError(err.message || '恢复失败');
} finally {
setLoading(false);
}
};
const activeAgent = draft?.agents?.[activeAgentIndex];
const activeRelevantFields = new Set(activeAgent?.relevant_fields || []);
return (
<div className="scenario-page">
<header className="scenario-header">
<div>
<div className="scenario-kicker">场景配置</div>
<h1>模板提示资产</h1>
<p>查看和修改场景模板对应的协调者提示、推荐智能体配置及智能体提示。新增模板暂不支持。</p>
</div>
<div className="scenario-header-actions">
<button type="button" className="btn-secondary" onClick={onBack}>返回首页</button>
</div>
</header>
<section className="scenario-toolbar">
<select
className="scenario-select"
value={selectedId}
onChange={event => setSelectedId(event.target.value)}
disabled={loading}
>
<option value="">选择场景模板...</option>
{templates.map(template => (
<option key={template.template_id} value={template.template_id}>
{template.template_name || template.template_id}
{hasTemplateOverride(template.template_id) ? '(已修改)' : ''}
</option>
))}
</select>
<button type="button" className="btn-primary" disabled>新增模板(暂不支持)</button>
<button type="button" className="btn-secondary" onClick={loadTemplates} disabled={loading}>
刷新
</button>
<button type="button" className="btn-secondary" onClick={resetTemplate} disabled={!draft || loading}>
恢复原始模板
</button>
<button type="button" className="btn-primary" onClick={saveDraft} disabled={!draft || loading}>
保存修改
</button>
</section>
{message && <div className="scenario-message">{message}</div>}
{error && <div className="scenario-error">{error}</div>}
{!draft ? (
<div className="scenario-empty">请选择一个模板。</div>
) : (
<main className="scenario-layout">
<section className="scenario-card scenario-template-card">
<div className="scenario-card-title">模板信息</div>
<div className="scenario-grid-2">
<label>
模板 ID
<input className="scenario-input" value={draft.template_id || ''} readOnly />
</label>
<label>
模板名称
<input
className="scenario-input"
value={draft.template_name || ''}
onChange={event => syncDraft(next => { next.template_name = event.target.value; })}
/>
</label>
</div>
<label>
模板说明
<textarea
className="scenario-textarea scenario-textarea-sm"
value={draft.template_brief || ''}
onChange={event => syncDraft(next => { next.template_brief = event.target.value; })}
/>
</label>
<label>
一致性模式
<select
className="scenario-select scenario-select-inline"
value={draft.consistency_mode || 'free'}
onChange={event => syncDraft(next => { next.consistency_mode = event.target.value; })}
>
<option value="free">free</option>
<option value="soft">soft</option>
</select>
<div className="scenario-inline-hint">
`free` 适合开放式模板;`soft` 会要求后续字段尽量承接前文依赖和命名。
</div>
</label>
{draft.usage_notes && (
<label>
使用说明
<textarea
className="scenario-textarea"
value={draft.usage_notes || ''}
onChange={event => syncDraft(next => { next.usage_notes = event.target.value; })}
/>
</label>
)}
</section>
<section className="scenario-card">
<div className="scenario-card-title">协调者提示</div>
<div className="scenario-coordinator-grid">
{COORDINATOR_FIELDS.map(([key, label]) => (
<label key={key}>
{label}
<span className="scenario-field-key">{key}</span>
<textarea
className="scenario-textarea"
value={draft.coordinator_config?.[key] || ''}
onChange={event => setCoordinatorField(key, event.target.value)}
/>
</label>
))}
</div>
</section>
<section className="scenario-card">
<div className="scenario-card-head">
<div>
<div className="scenario-card-title">推荐智能体及提示</div>
<div className="scenario-card-desc">当前支持修改已有智能体,不支持新增或删除。</div>
</div>
<button type="button" className="btn-primary" disabled>新增智能体(暂不支持)</button>
</div>
<div className="scenario-agent-layout">
<div className="scenario-agent-list">
{(draft.agents || []).map((agent, index) => (
<button
type="button"
key={agent.id || index}
className={`scenario-agent-tab ${index === activeAgentIndex ? 'active' : ''}`}
onClick={() => setActiveAgentIndex(index)}
>
<span>{agent.name || agent.id || `agent-${index + 1}`}</span>
<code>{agent.id}</code>
</button>
))}
</div>
{activeAgent && (
<div className="scenario-agent-editor">
<div className="scenario-grid-2">
<label>
ID
<input className="scenario-input" value={activeAgent.id || ''} readOnly />
</label>
<label>
名称
<input
className="scenario-input"
value={activeAgent.name || ''}
onChange={event => setAgentField(activeAgentIndex, 'name', event.target.value)}
/>
</label>
</div>
<label>
分析维度
<input
className="scenario-input"
value={activeAgent.dimension || ''}
onChange={event => setAgentField(activeAgentIndex, 'dimension', event.target.value)}
/>
</label>
<label>
智能体提示 / 专家角色
<textarea
className="scenario-textarea scenario-textarea-lg"
value={activeAgent.expertise || ''}
onChange={event => setAgentField(activeAgentIndex, 'expertise', event.target.value)}
/>
</label>
<label>
分析契约(每行一条)
<textarea
className="scenario-textarea"
value={(activeAgent.analysis_contract || []).join('\n')}
onChange={event => setAgentField(activeAgentIndex, 'analysis_contract', linesToList(event.target.value))}
/>
</label>
<label>
关联字段
<div className="scenario-field-picker">
{fieldOptions.map(option => (
<label key={option.key} className={`scenario-field-option ${activeRelevantFields.has(option.key) ? 'selected' : ''}`}>
<input
type="checkbox"
checked={activeRelevantFields.has(option.key)}
onChange={event => {
const current = activeAgent.relevant_fields || [];
const next = event.target.checked
? [...current, option.key]
: current.filter(item => item !== option.key);
setAgentField(activeAgentIndex, 'relevant_fields', next);
}}
/>
<div className="scenario-field-option-body">
<div className="scenario-field-option-title">{option.title}</div>
<div className="scenario-field-option-key">{option.key}</div>
{option.description && (
<div className="scenario-field-option-desc">{option.description}</div>
)}
</div>
</label>
))}
</div>
</label>
<label>
检索补充说明
<textarea
className="scenario-textarea scenario-textarea-sm"
value={activeAgent.retrieval_instruction || ''}
onChange={event => setAgentField(activeAgentIndex, 'retrieval_instruction', event.target.value)}
/>
</label>
</div>
)}
</div>
</section>
<section className="scenario-card">
<div className="scenario-card-head">
<div>
<div className="scenario-card-title">完整模板 JSON</div>
<div className="scenario-card-desc">用于修改页面暂未拆出的提示资产,例如字段要求、样例和报告结构。</div>
</div>
<button type="button" className="btn-secondary" onClick={applyRawJson}>应用 JSON</button>
</div>
<textarea
className="scenario-json"
value={rawJson}
onChange={event => setRawJson(event.target.value)}
spellCheck={false}
/>
</section>
</main>
)}
</div>
);
}
@@ -0,0 +1,238 @@
.sidebar {
padding: 18px 14px;
}
.sidebar-section {
margin-bottom: 24px;
}
.sidebar-section-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
margin-bottom: 12px;
padding: 0 4px;
}
/* Phase stepper */
.phase-stepper {
display: flex;
flex-direction: column;
gap: 0;
}
.phase-step {
display: flex;
align-items: flex-start;
gap: 10px;
position: relative;
}
.phase-step:not(:last-child) .phase-step-line {
position: absolute;
left: 11px;
top: 24px;
bottom: 0;
width: 2px;
background: var(--border);
}
.phase-step-dot-col {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
padding-top: 3px;
}
.phase-step-dot {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid var(--border);
background: var(--surface);
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
transition: all 0.2s;
position: relative;
z-index: 1;
}
.phase-step.active .phase-step-dot {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--accent);
}
.phase-step.active.phase-diverging .phase-step-dot {
border-color: var(--yellow);
background: rgba(210, 153, 34, 0.15);
color: var(--yellow);
}
.phase-step.active.phase-closing .phase-step-dot {
border-color: var(--green);
background: rgba(63, 185, 80, 0.15);
color: var(--green);
}
.phase-step.done .phase-step-dot {
border-color: var(--green);
background: rgba(63, 185, 80, 0.15);
color: var(--green);
}
.phase-step-content {
flex: 1;
padding-bottom: 16px;
}
.phase-step-label {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
line-height: 22px;
transition: color 0.2s;
}
.phase-step.active .phase-step-label {
color: var(--text);
}
.phase-step.done .phase-step-label {
color: var(--text-muted);
}
.phase-step-sublabel {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
/* Diverge round sub-steps */
.diverge-rounds {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: 6px;
}
.diverge-round-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-muted);
padding: 2px 0;
}
.diverge-round-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--border);
flex-shrink: 0;
}
.diverge-round-item.active .diverge-round-dot {
background: var(--yellow);
box-shadow: 0 0 6px rgba(210, 153, 34, 0.5);
}
.diverge-round-item.done .diverge-round-dot {
background: var(--green);
}
.diverge-round-item.active {
color: var(--yellow);
}
.diverge-round-item.done {
color: var(--text-muted);
}
/* Connector line between phase steps */
.phase-connector {
width: 2px;
height: 8px;
background: var(--border);
margin-left: 10px;
}
/* Agent roster */
.agent-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.agent-item {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 10px;
border-radius: 10px;
transition: background 0.15s, color 0.15s, transform 0.15s;
background: transparent;
}
.agent-item-clickable {
cursor: pointer;
}
.agent-item-clickable:hover {
background: var(--surface-raised);
transform: translateX(2px);
}
.agent-item-active {
background: var(--accent-soft);
border-left: 2px solid var(--accent);
padding-left: 6px;
}
.agent-status-icon {
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
flex-shrink: 0;
}
.agent-status-idle {
color: var(--border);
}
.agent-status-thinking {
color: var(--yellow);
}
.agent-status-done {
color: var(--green);
}
.agent-item-info {
flex: 1;
overflow: hidden;
}
.agent-item-name {
font-size: 12px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-item-dim {
font-size: 10px;
color: var(--text-muted);
}
@@ -0,0 +1,187 @@
import React from 'react';
import './Sidebar.css';
/**
* 分析阶段定义
* - starting: 启动轮,协调者分配初始任务
* - diverging: 辩论轮,智能体并行输出
* - closing: 收敛轮,协调者整合草稿
* - done: 完成
*/
const PHASES = [
{ id: 'starting', label: '启动', dotLabel: '1' },
{ id: 'diverging', label: '辩论', dotLabel: '2' },
{ id: 'closing', label: '收敛', dotLabel: '3' },
{ id: 'done', label: '完成', dotLabel: '✓' },
];
/** 阶段顺序数组,用于判断当前阶段索引 */
const PHASE_ORDER = ['starting', 'diverging', 'closing', 'done'];
/**
* 获取阶段在 PHASE_ORDER 中的索引,用于判断"已完成"状态
*/
function getPhaseIndex(phase) {
return PHASE_ORDER.indexOf(phase);
}
/**
* 在 .app-main 中查找目标元素并平滑滚动到其位置
*
* 使用 getBoundingClientRect() 计算视口相对位置,
* 避免因 DOM 层级嵌套导致的 offsetTop 参照系不一致问题。
* 滚动前设置 window.__mopeProgrammaticScroll 计数器,
* 防止 Timeline 的 scroll 监听器误判为用户手动滚动。
*
* @param {string} selector - CSS 选择器,如 '[data-phase="diverging"][data-round="2"]'
*/
function scrollToTimelineElement(selector) {
const appMain = document.querySelector('.app-main');
if (!appMain) return;
const el = appMain.querySelector(selector);
if (!el) return;
const targetRect = el.getBoundingClientRect();
const containerRect = appMain.getBoundingClientRect();
const targetScrollTop = appMain.scrollTop + targetRect.top - containerRect.top - 20;
window.__mopeProgrammaticScroll = (window.__mopeProgrammaticScroll || 0) + 1;
appMain.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
}
/**
* 左侧边栏组件
*
* 显示两大部分:
* 1. 阶段进度条 — 当前分析走到了哪个阶段(启动/辩论/收敛/完成),
* 辩论阶段展开显示每轮的进度(已完成/进行中/待进行)。
* 每个阶段和轮次均可点击,自动滚动中间 Timeline 到对应位置。
* 2. 智能体列表 — 显示所有参与分析的智能体及其状态,
* 点击可跳转到该智能体最新的输出位置。
*
* @param {Array} agents - 智能体配置列表
* @param {Object} agentStates - 智能体运行时状态 { agentId: { name, dimension, status, rounds } }
* @param {string} phase - 当前阶段
* @param {number} currentRound - 当前轮次编号
* @param {number} divergeRounds - 辩论轮总数
* @param {number} totalRounds - 总轮数
*/
export default function Sidebar({
agents,
agentStates,
phase,
currentRound,
divergeRounds,
totalRounds,
}) {
const currentPhaseIdx = getPhaseIndex(phase);
return (
<div className="sidebar">
{/* ====== 阶段进度条 ====== */}
<div className="sidebar-section">
<div className="sidebar-section-title">进度</div>
<div className="phase-stepper">
{PHASES.map((p, idx) => {
const isActive = phase === p.id;
const isDone = currentPhaseIdx > idx;
const stepClass = `phase-step phase-${p.id}${isActive ? ' active' : ''}${isDone ? ' done' : ''}`;
return (
<div
key={p.id}
className={stepClass}
onClick={() => scrollToTimelineElement(`[data-phase="${p.id}"]`)}
title={`点击跳转到${p.label}阶段`}
style={{ cursor: 'pointer' }}
>
{/* 阶段连接线 */}
{idx < PHASES.length - 1 && <div className="phase-step-line" />}
<div className="phase-step-dot-col">
<div className="phase-step-dot">
{isDone ? '✓' : p.dotLabel}
</div>
</div>
<div className="phase-step-content">
<div className="phase-step-label">{p.label}</div>
{/* 辩论阶段的轮次展开 */}
{p.id === 'diverging' && (isDone || isActive) && divergeRounds > 0 && (
<div className="diverge-rounds">
{Array.from({ length: divergeRounds }, (_, i) => {
const roundNum = i + 1;
// 辩论轮从轮次 2 开始(轮次 1 是启动轮)
const isActiveDiverge = isActive && roundNum === (currentRound - 1);
const isDoneDiverge = isDone || (isActive && (currentRound - 1) > roundNum);
const itemClass = `diverge-round-item${isActiveDiverge ? ' active' : ''}${isDoneDiverge ? ' done' : ''}`;
return (
<div
key={roundNum}
className={itemClass}
onClick={(e) => {
e.stopPropagation(); // 防止冒泡触发父级阶段点击
scrollToTimelineElement(`[data-phase="diverging"][data-round="${roundNum + 1}"]`);
}}
title={`点击跳转到辩论轮 ${roundNum}`}
style={{ cursor: 'pointer' }}
>
<div className="diverge-round-dot" />
<span>辩论轮 {roundNum}</span>
</div>
);
})}
</div>
)}
{/* 辩论阶段尚未开始时只显示轮次数 */}
{p.id === 'diverging' && !isDone && !isActive && divergeRounds > 0 && (
<div className="phase-step-sublabel">{divergeRounds} 轮</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* ====== 智能体列表 ====== */}
{agents && agents.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">智能体</div>
<div className="agent-list">
{agents.map(agent => {
const agentState = agentStates[agent.id] || {};
const status = agentState.status || 'idle';
/** 点击跳转到该智能体最新的输出卡片 */
const handleClick = () => {
const cards = document.querySelectorAll(`[data-agent-id="${agent.id}"]`);
if (cards.length > 0) {
cards[cards.length - 1].scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
return (
<div
key={agent.id}
className={`agent-item agent-item-clickable${status === 'thinking' ? ' agent-item-active' : ''}`}
onClick={handleClick}
title="点击跳转到最新输出"
>
{/* 状态图标:○ 空闲 / ⟳ 思考中 / ✓ 完成 */}
<div className="agent-status-icon">
{status === 'idle' && <span className="agent-status-idle">○</span>}
{status === 'thinking' && <span className="agent-status-thinking spinner">⟳</span>}
{status === 'done' && <span className="agent-status-done">✓</span>}
</div>
<div className="agent-item-info">
<div className="agent-item-name">{agent.name || agent.id}</div>
<div className="agent-item-dim">{agent.dimension}</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,174 @@
import './Sidebar.css';
type Agent = {
id: string;
name?: string;
dimension?: string;
};
type AgentState = {
status?: string;
name?: string;
dimension?: string;
};
type Props = {
agents: Agent[];
agentStates: Record<string, AgentState>;
phase: string;
currentRound: number;
divergeRounds: number;
};
const PHASES = [
{ id: 'starting', label: '启动', dotLabel: '1' },
{ id: 'diverging', label: '辩论', dotLabel: '2' },
{ id: 'closing', label: '收敛', dotLabel: '3' },
{ id: 'done', label: '完成', dotLabel: '✓' },
];
const PHASE_ORDER = ['starting', 'diverging', 'closing', 'done'];
function getPhaseIndex(phase: string): number {
return PHASE_ORDER.indexOf(phase);
}
function scrollToTimelineElement(selector: string): void {
const appMain = document.querySelector('.app-main') as HTMLElement | null;
if (!appMain) return;
const el = appMain.querySelector(selector) as HTMLElement | null;
if (!el) return;
const targetRect = el.getBoundingClientRect();
const containerRect = appMain.getBoundingClientRect();
const targetScrollTop = appMain.scrollTop + targetRect.top - containerRect.top - 20;
(window as any).__mopeProgrammaticScroll = ((window as any).__mopeProgrammaticScroll || 0) + 1;
appMain.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
}
function scrollToDivergeRound(roundNum: number): void {
const appMain = document.querySelector('.app-main') as HTMLElement | null;
if (!appMain) return;
const divergeRounds = appMain.querySelectorAll('[data-phase="diverging"][data-round]');
const target = divergeRounds[roundNum - 1] as HTMLElement | undefined;
if (!target) return;
const targetRect = target.getBoundingClientRect();
const containerRect = appMain.getBoundingClientRect();
const targetScrollTop = appMain.scrollTop + targetRect.top - containerRect.top - 20;
(window as any).__mopeProgrammaticScroll = ((window as any).__mopeProgrammaticScroll || 0) + 1;
appMain.scrollTo({ top: targetScrollTop, behavior: 'smooth' });
}
export default function Sidebar({
agents,
agentStates,
phase,
currentRound,
divergeRounds,
}: Props): JSX.Element {
const currentPhaseIdx = getPhaseIndex(phase);
return (
<div className="sidebar">
<div className="sidebar-section">
<div className="sidebar-section-title">进度</div>
<div className="phase-stepper">
{PHASES.map((p, idx) => {
const isActive = phase === p.id;
const isDone = currentPhaseIdx > idx;
const stepClass = `phase-step phase-${p.id}${isActive ? ' active' : ''}${isDone ? ' done' : ''}`;
return (
<div
key={p.id}
className={stepClass}
onClick={() => scrollToTimelineElement(`[data-phase="${p.id}"]`)}
title={`点击跳转到${p.label}阶段`}
style={{ cursor: 'pointer' }}
>
{idx < PHASES.length - 1 && <div className="phase-step-line" />}
<div className="phase-step-dot-col">
<div className="phase-step-dot">
{isDone ? '✓' : p.dotLabel}
</div>
</div>
<div className="phase-step-content">
<div className="phase-step-label">{p.label}</div>
{p.id === 'diverging' && (isDone || isActive) && divergeRounds > 0 && (
<div className="diverge-rounds">
{Array.from({ length: divergeRounds }, (_, i) => {
const roundNum = i + 1;
const isActiveDiverge = isActive && roundNum === (currentRound - 1);
const isDoneDiverge = isDone || (isActive && (currentRound - 1) > roundNum);
const itemClass = `diverge-round-item${isActiveDiverge ? ' active' : ''}${isDoneDiverge ? ' done' : ''}`;
return (
<div
key={roundNum}
className={itemClass}
onClick={(e) => {
e.stopPropagation();
scrollToDivergeRound(roundNum);
}}
title={`点击跳转到辩论轮 ${roundNum}`}
style={{ cursor: 'pointer' }}
>
<div className="diverge-round-dot" />
<span>辩论轮 {roundNum}</span>
</div>
);
})}
</div>
)}
{p.id === 'diverging' && !isDone && !isActive && divergeRounds > 0 && (
<div className="phase-step-sublabel">{divergeRounds} 轮</div>
)}
</div>
</div>
);
})}
</div>
</div>
{agents && agents.length > 0 && (
<div className="sidebar-section">
<div className="sidebar-section-title">智能体</div>
<div className="agent-list">
{agents.map(agent => {
const agentState = agentStates[agent.id] || {};
const status = agentState.status || 'idle';
const handleClick = () => {
const cards = document.querySelectorAll(`[data-agent-id="${agent.id}"]`);
if (cards.length > 0) {
(cards[cards.length - 1] as HTMLElement).scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
return (
<div
key={agent.id}
className={`agent-item agent-item-clickable${status === 'thinking' ? ' agent-item-active' : ''}`}
onClick={handleClick}
title="点击跳转到最新输出"
>
<div className="agent-status-icon">
{status === 'idle' && <span className="agent-status-idle">○</span>}
{status === 'thinking' && <span className="agent-status-thinking spinner">⟳</span>}
{status === 'done' && <span className="agent-status-done">✓</span>}
</div>
<div className="agent-item-info">
<div className="agent-item-name">{agent.name || agent.id}</div>
<div className="agent-item-dim">{agent.dimension}</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,670 @@
.timeline {
display: flex;
flex-direction: column;
gap: 0;
max-width: 920px;
margin: 0 auto;
padding-bottom: 16px;
}
/* Empty state */
.timeline-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
color: var(--text-muted);
gap: 12px;
}
.timeline-empty-spinner {
font-size: 28px;
animation: spin 1.2s linear infinite;
}
/* Round header */
.round-header {
display: flex;
align-items: center;
gap: 10px;
margin: 20px 0 12px;
padding: 0 2px;
}
.round-header:first-child {
margin-top: 0;
}
.round-header-line {
flex: 1;
height: 1px;
background: var(--border);
}
.round-header-label {
font-size: 12px;
font-weight: 700;
color: var(--text-muted);
white-space: nowrap;
letter-spacing: 0.3px;
}
.round-header-badge {
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 700;
}
/* Background retrieval card */
.bg-retrieval-card {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: 14px;
margin-bottom: 18px;
overflow: hidden;
box-shadow: var(--shadow-card);
}
.bg-retrieval-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
cursor: pointer;
background: var(--surface-hover);
user-select: none;
}
.bg-retrieval-header:hover {
background: var(--surface-hover-strong);
}
.bg-retrieval-title {
font-size: 13px;
font-weight: 700;
color: var(--text);
}
.bg-retrieval-meta {
font-size: 12px;
color: var(--text-muted);
margin-left: auto;
}
.bg-retrieval-chevron {
color: var(--text-muted);
font-size: 12px;
transition: transform 0.2s;
}
.bg-retrieval-chevron.open {
transform: rotate(180deg);
}
.bg-retrieval-body {
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 14px;
}
.bg-retrieval-dim {
display: flex;
flex-direction: column;
gap: 6px;
}
.bg-retrieval-dim-label {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--accent);
}
.bg-retrieval-result {
margin-bottom: 8px;
}
.bg-retrieval-query {
font-size: 11px;
color: var(--text-muted);
margin-bottom: 4px;
font-style: italic;
}
.bg-retrieval-items {
display: flex;
flex-direction: column;
gap: 4px;
}
.bg-retrieval-item {
font-size: 12px;
color: var(--text);
line-height: 1.5;
padding: 4px 8px;
background: var(--surface-hover);
border-radius: 4px;
border-left: 2px solid var(--border);
}
/* Coordinator card */
.coordinator-card {
background: rgba(137, 87, 229, 0.06);
border: 1px solid rgba(137, 87, 229, 0.25);
border-radius: 14px;
padding: 14px 16px;
margin-bottom: 14px;
box-shadow: var(--shadow-card);
}
.coordinator-card-title {
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--purple);
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 6px;
}
.coordinator-speakers {
font-size: 13px;
color: var(--text);
margin-bottom: 8px;
}
.coordinator-speaker-tag {
display: inline-block;
background: rgba(137, 87, 229, 0.15);
color: var(--purple);
border-radius: 4px;
padding: 1px 6px;
font-size: 12px;
margin: 0 3px;
font-weight: 600;
}
.coordinator-focus {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 6px;
}
.coordinator-focus-item {
display: flex;
gap: 8px;
align-items: flex-start;
font-size: 12px;
}
.coordinator-focus-agent {
font-weight: 700;
color: var(--purple);
flex-shrink: 0;
min-width: 60px;
}
.coordinator-focus-directive {
color: var(--text-muted);
line-height: 1.5;
}
.coordinator-round-badge {
font-weight: 400;
color: var(--text-muted);
text-transform: none;
letter-spacing: 0;
font-size: 11px;
}
.coordinator-speakers-label {
color: var(--text-muted);
margin-right: 4px;
}
/* 草稿进展区块 */
.coordinator-draft-section {
margin-bottom: 10px;
border: 1px solid rgba(137, 87, 229, 0.15);
border-radius: 6px;
overflow: hidden;
}
.coordinator-draft-header {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
background: rgba(137, 87, 229, 0.08);
cursor: pointer;
user-select: none;
}
.coordinator-draft-header:hover {
background: rgba(137, 87, 229, 0.13);
}
.coordinator-draft-title {
font-size: 12px;
font-weight: 600;
color: var(--purple);
}
.coordinator-draft-progress {
font-size: 11px;
color: var(--text-muted);
margin-left: auto;
}
.coordinator-draft-chevron {
font-size: 12px;
color: var(--text-muted);
transition: transform 0.15s;
}
.coordinator-draft-chevron.open {
transform: rotate(180deg);
}
.coordinator-draft-body {
padding: 8px 10px;
display: flex;
flex-direction: column;
gap: 6px;
}
.coordinator-draft-field {
display: flex;
flex-direction: column;
gap: 2px;
padding: 5px 8px;
border-radius: 4px;
font-size: 12px;
}
.coordinator-draft-field.filled {
background: rgba(34, 197, 94, 0.06);
border-left: 2px solid rgba(34, 197, 94, 0.5);
}
.coordinator-draft-field.empty {
background: rgba(239, 68, 68, 0.05);
border-left: 2px solid rgba(239, 68, 68, 0.3);
}
.coordinator-draft-field-name {
font-weight: 600;
color: var(--text-muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.coordinator-draft-field-content {
color: var(--text);
line-height: 1.5;
}
.coordinator-draft-field-empty {
color: var(--text-muted);
font-style: italic;
}
/* 协调员流式动画点 */
.coordinator-streaming-dot {
color: var(--purple);
font-size: 10px;
animation: pulse 1s ease-in-out infinite;
margin-left: 6px;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
/* 协调员思考过程 */
.coordinator-thinking-section {
margin-bottom: 10px;
border: 1px solid rgba(137, 87, 229, 0.15);
border-radius: 6px;
overflow: hidden;
}
.coordinator-thinking-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: rgba(137, 87, 229, 0.06);
cursor: pointer;
user-select: none;
}
.coordinator-thinking-header:hover {
background: rgba(137, 87, 229, 0.1);
}
.coordinator-thinking-label {
font-size: 11px;
font-weight: 600;
color: var(--purple);
}
.coordinator-thinking-body {
padding: 8px 10px;
font-size: 12px;
color: var(--text-muted);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
}
.coordinator-status-banner {
display: inline-flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 7px 10px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent);
font-size: 12px;
font-weight: 600;
}
.coordinator-status-banner-tail {
display: flex;
width: 100%;
margin-top: 10px;
margin-bottom: 0;
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
box-shadow: 0 8px 18px color-mix(in srgb, var(--accent) 10%, transparent);
}
.coordinator-status-spinner {
display: inline-block;
animation: spin 1s linear infinite;
}
/* 活跃字段(正在生成)高亮 */
.coordinator-draft-field.active {
background: rgba(137, 87, 229, 0.08);
border-left: 2px solid rgba(137, 87, 229, 0.6);
}
/* 缺口列表 */
.coordinator-gaps {
margin-bottom: 10px;
}
.coordinator-gaps-title {
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.3px;
color: #f59e0b;
margin-bottom: 4px;
}
.coordinator-gap-item {
font-size: 12px;
color: var(--text-muted);
line-height: 1.5;
padding: 1px 0;
}
.human-review-card {
background: rgba(88, 166, 255, 0.08);
border: 1px solid rgba(88, 166, 255, 0.28);
border-radius: 10px;
padding: 14px;
margin-bottom: 16px;
}
.human-review-title {
display: flex;
align-items: center;
gap: 8px;
color: var(--accent);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
margin-bottom: 8px;
}
.human-review-hint {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 12px;
}
.human-review-chatbox {
min-height: 120px;
}
.human-review-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.human-review-field {
margin-bottom: 12px;
}
.human-review-field label {
display: block;
font-size: 11px;
font-weight: 700;
color: var(--text-muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.human-review-input,
.human-review-textarea {
width: 100%;
background: var(--card);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
padding: 10px;
font-family: inherit;
font-size: 13px;
outline: none;
}
.human-review-speaker-select {
width: 100%;
}
.human-review-speaker-select .t-select__wrap,
.human-review-speaker-select .t-input,
.human-review-speaker-select .t-tag-input {
width: 100%;
}
.human-review-speaker-select .t-input,
.human-review-speaker-select .t-tag-input {
border-color: var(--border);
border-radius: 6px;
background: var(--card);
color: var(--text);
}
.human-review-speaker-select .t-input:hover,
.human-review-speaker-select .t-tag-input:hover,
.human-review-speaker-select .t-input.t-is-focused,
.human-review-speaker-select .t-tag-input.t-is-focused {
border-color: var(--accent);
}
.human-review-speaker-select .t-tag {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--border);
}
.human-review-textarea {
min-height: 220px;
resize: vertical;
font-family: 'SFMono-Regular', Consolas, monospace;
line-height: 1.5;
}
.human-review-textarea-sm {
min-height: 120px;
}
.human-review-input:focus,
.human-review-textarea:focus {
border-color: var(--accent);
}
.human-review-meta {
margin-top: 6px;
font-size: 11px;
color: var(--text-muted);
line-height: 1.5;
}
.human-review-actions {
display: flex;
justify-content: flex-end;
}
.human-review-actions-left {
justify-content: flex-start;
margin-top: 8px;
}
.human-review-error {
color: var(--red);
font-size: 12px;
margin-bottom: 10px;
}
@media (max-width: 900px) {
.human-review-grid {
grid-template-columns: 1fr;
}
}
/* 下滑按钮 */
.scroll-to-bottom-button {
position: fixed;
bottom: 34px;
left: 50%;
transform: translateX(-50%);
width: 48px;
height: 48px;
border-radius: 50%;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.34);
background:
radial-gradient(circle at 30% 22%, rgba(255, 255, 255, 0.42), transparent 34%),
linear-gradient(135deg, rgba(59, 130, 246, 0.46), rgba(124, 58, 237, 0.42));
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow:
0 16px 40px rgba(37, 99, 235, 0.28),
0 6px 18px rgba(15, 23, 42, 0.16),
inset 0 1px 0 rgba(255, 255, 255, 0.24);
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
background 0.2s ease,
border-color 0.2s ease;
z-index: 1000;
backdrop-filter: blur(16px) saturate(1.2);
-webkit-backdrop-filter: blur(16px) saturate(1.2);
animation: scrollBtnIn 0.26s ease-out;
pointer-events: auto;
}
.scroll-to-bottom-button::before {
content: '';
position: absolute;
inset: -6px;
border-radius: inherit;
background: rgba(99, 102, 241, 0.12);
border: 1px solid rgba(255, 255, 255, 0.12);
pointer-events: none;
}
.scroll-to-bottom-button::after {
content: '';
position: absolute;
inset: 4px;
border-radius: inherit;
border: 1px solid rgba(255, 255, 255, 0.2);
pointer-events: none;
}
.scroll-to-bottom-button:hover {
background:
radial-gradient(circle at 30% 22%, rgba(255, 255, 255, 0.55), transparent 34%),
linear-gradient(135deg, rgba(59, 130, 246, 0.62), rgba(124, 58, 237, 0.58));
transform: translateX(-50%) translateY(-3px) scale(1.04);
box-shadow:
0 20px 48px rgba(37, 99, 235, 0.36),
0 10px 26px rgba(15, 23, 42, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.32);
border-color: rgba(255, 255, 255, 0.52);
}
.scroll-to-bottom-button:active {
transform: translateX(-50%) translateY(-1px) scale(0.97);
box-shadow:
0 10px 28px rgba(37, 99, 235, 0.28),
0 4px 14px rgba(15, 23, 42, 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.22);
}
@keyframes scrollBtnIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(12px) scale(0.86);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
}
.scroll-to-bottom-button .scroll-arrow {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
font-size: 24px;
line-height: 1;
font-weight: 800;
text-shadow: 0 2px 8px rgba(15, 23, 42, 0.32);
animation: scrollArrowBounce 1.35s ease-in-out infinite;
}
@keyframes scrollArrowBounce {
0%, 100% { transform: translateY(-1px); }
50% { transform: translateY(3px); }
}
@@ -0,0 +1,641 @@
import React, { useEffect, useRef, useState } from 'react';
import AgentCard from './AgentCard';
import FinalReport from './FinalReport';
import './Timeline.css';
/**
* 阶段中文映射表
* - starting: 启动轮,协调者/智能体初始化
* - diverging: 辩论轮,多智能体并行输出、互相交锋
* - closing: 收敛轮,协调者整合草稿
* - done: 完成,最终报告已生成
*/
const PHASE_LABELS = {
starting: '启动',
diverging: '辩论',
closing: '收敛',
done: '完成',
};
/**
* 根据阶段返回对应的 CSS badge class
*/
function getPhaseBadgeClass(phase) {
switch (phase) {
case 'starting': return 'badge badge-starting';
case 'diverging': return 'badge badge-diverging';
case 'closing': return 'badge badge-closing';
case 'done': return 'badge badge-done';
default: return 'badge badge-default';
}
}
/**
* 背景检索卡片
*
* 显示 session 启动时从知识库/搜索引擎检索到的背景资料。
* 按维度分组展示,每个维度下有多条检索结果,点击标题可展开/折叠。
*
* @param {Object} data - 包含 results、dimensions、total_records
*/
function BackgroundRetrievalCard({ data }) {
const [open, setOpen] = useState(false);
const { results, dimensions, total_records } = data;
return (
<div className="bg-retrieval-card">
<div className="bg-retrieval-header" onClick={() => setOpen(o => !o)}>
<span className="bg-retrieval-title">背景检索完成</span>
<span className="bg-retrieval-meta">
{dimensions && dimensions.length > 0 ? `${dimensions.length} 维度` : ''}
{total_records != null ? ` · ${total_records} 条` : ''}
</span>
<span className={`bg-retrieval-chevron ${open ? 'open' : ''}`}>▾</span>
</div>
{open && results && (
<div className="bg-retrieval-body">
{Object.entries(results).map(([dim, entries]) => (
<div key={dim} className="bg-retrieval-dim">
<div className="bg-retrieval-dim-label">{dim}</div>
{Array.isArray(entries) && entries.map((entry, i) => (
<div key={i} className="bg-retrieval-result">
{entry.query && (
<div className="bg-retrieval-query">查询: "{entry.query}"</div>
)}
<div className="bg-retrieval-items">
{Array.isArray(entry.content) && entry.content.map((item, j) => (
<div key={j} className="bg-retrieval-item">
{typeof item === 'string'
? (item.length > 250 ? item.slice(0, 250) + '…' : item)
: JSON.stringify(item).slice(0, 250)}
</div>
))}
</div>
</div>
))}
</div>
))}
</div>
)}
</div>
);
}
/**
* 协调员流式卡片
*
* 显示协调者正在生成的内容(思考过程、状态消息、最终报告字段实时填充)。
* 与 CoordinatorCard 的区别:此卡片在流式输出期间使用,带脉冲动画点。
*
* @param {Object} item - 包含 phase、divergeRound、thinking、content、statusMessage、currentField、reportPartial
* @param {Object} reportSchema - 报告结构定义
*/
function CoordinatorStreamingCard({ item, reportSchema }) {
const { phase, divergeRound, totalDivergeRounds, thinking, content, statusMessage, currentField, reportPartial } = item;
const [thinkingOpen, setThinkingOpen] = useState(false);
const isFinalReport = phase === 'final_report';
const reportFields = reportSchema ? Object.keys(reportSchema) : [];
return (
<div className="coordinator-card coordinator-card-streaming">
<div className="coordinator-card-title">
<span>协调员</span>
{!isFinalReport && divergeRound != null && (
<span className="coordinator-round-badge">
辩论轮 {divergeRound}/{totalDivergeRounds}
</span>
)}
{isFinalReport && (
<span className="coordinator-round-badge">生成最终方案</span>
)}
<span className="coordinator-streaming-dot">●</span>
</div>
{/* 思考过程(可折叠,带脉冲动画) */}
{thinking && (
<div className="coordinator-thinking-section">
<div className="coordinator-thinking-header" onClick={() => setThinkingOpen(o => !o)}>
<span className="coordinator-thinking-label">思考中</span>
<span className={`coordinator-draft-chevron ${thinkingOpen ? 'open' : ''}`}>▾</span>
</div>
{thinkingOpen && (
<div className="coordinator-thinking-body">{thinking}</div>
)}
</div>
)}
{/* 最终报告字段实时生成状态 */}
{isFinalReport && reportFields.length > 0 && (
<div className="coordinator-draft-body" style={{ padding: '8px 0 0' }}>
{reportFields.map(field => {
const done = !!reportPartial[field];
const isActive = currentField === field && !done;
return (
<div key={field} className={`coordinator-draft-field ${done ? 'filled' : isActive ? 'active' : 'empty'}`}>
<span className="coordinator-draft-field-name">
{field}
{isActive && <span className="coordinator-streaming-dot" style={{ marginLeft: 6 }}>●</span>}
</span>
{done
? <span className="coordinator-draft-field-content">{reportPartial[field].slice(0, 200)}{reportPartial[field].length > 200 ? '…' : ''}</span>
: isActive && content
? <span className="coordinator-draft-field-content">{content.slice(-400)}</span>
: <span className="coordinator-draft-field-empty">{isActive ? '生成中…' : '等待中'}</span>
}
</div>
);
})}
</div>
)}
{/* 非最终报告阶段的流式内容 */}
{!isFinalReport && content && (
<div className="coordinator-thinking-body" style={{ marginTop: 6 }}>{content.slice(-600)}</div>
)}
{/* 状态横幅(如"正在评估字段完成度…") */}
{statusMessage && !isFinalReport && (
<div className="coordinator-status-banner coordinator-status-banner-tail">
<span className="coordinator-status-spinner">⟳</span>
<span>{statusMessage}</span>
</div>
)}
</div>
);
}
/**
* 协调员完成卡片
*
* 在一轮辩论结束后显示协调者的完整提案:
* - 思考过程(可折叠)
* - 草稿进展(各字段填充情况)
* - 下一轮发言者名单
* - 各智能体的具体任务指令(focus)
*
* @param {Object} data - 协调者提案数据(round、divergeRound、nextSpeakers、focus、draft)
* @param {Object} agentStates - 智能体状态映射
* @param {string} thinking - 协调者推理文本
*/
function CoordinatorCard({ data, agentStates, thinking }) {
const { round, divergeRound, totalDivergeRounds, nextSpeakers, focus, draft } = data;
const [draftOpen, setDraftOpen] = useState(false);
const [thinkingOpen, setThinkingOpen] = useState(false);
const draftEntries = draft ? Object.entries(draft) : [];
const filledCount = draftEntries.filter(([, v]) => v && v.trim()).length;
return (
<div className="coordinator-card">
<div className="coordinator-card-title">
<span>协调员</span>
{divergeRound != null && totalDivergeRounds != null && (
<span className="coordinator-round-badge">
辩论轮 {divergeRound}/{totalDivergeRounds}
</span>
)}
</div>
{/* 协调员思考过程(可折叠) */}
{thinking && thinking.trim() && (
<div className="coordinator-thinking-section">
<div className="coordinator-thinking-header" onClick={() => setThinkingOpen(o => !o)}>
<span className="coordinator-thinking-label">思考过程</span>
<span className={`coordinator-draft-chevron ${thinkingOpen ? 'open' : ''}`}>▾</span>
</div>
{thinkingOpen && (
<div className="coordinator-thinking-body">{thinking}</div>
)}
</div>
)}
{/* 草稿进展 */}
{draftEntries.length > 0 && (
<div className="coordinator-draft-section">
<div
className="coordinator-draft-header"
onClick={() => setDraftOpen(o => !o)}
>
<span className="coordinator-draft-title">方案草稿</span>
<span className="coordinator-draft-progress">
{filledCount}/{draftEntries.length} 字段已填写
</span>
<span className={`coordinator-draft-chevron ${draftOpen ? 'open' : ''}`}>▾</span>
</div>
{draftOpen && (
<div className="coordinator-draft-body">
{draftEntries.map(([field, content]) => (
<div key={field} className={`coordinator-draft-field ${content ? 'filled' : 'empty'}`}>
<span className="coordinator-draft-field-name">{field}</span>
{content
? <span className="coordinator-draft-field-content">{content.slice(0, 300)}{content.length > 300 ? '…' : ''}</span>
: <span className="coordinator-draft-field-empty">空缺</span>
}
</div>
))}
</div>
)}
</div>
)}
{/* 下一轮发言者 */}
{nextSpeakers && nextSpeakers.length > 0 && (
<div className="coordinator-speakers">
<span className="coordinator-speakers-label">下一轮:</span>
{nextSpeakers.map(id => {
const name = agentStates?.[id]?.name || id;
return <span key={id} className="coordinator-speaker-tag">{name}</span>;
})}
</div>
)}
{/* 具体任务指令 */}
{focus && Object.keys(focus).length > 0 && (
<div className="coordinator-focus">
{Object.entries(focus).map(([agentId, directive]) => {
const name = agentStates?.[agentId]?.name || agentId;
return (
<div key={agentId} className="coordinator-focus-item">
<span className="coordinator-focus-agent">{name}</span>
<span className="coordinator-focus-directive">{directive}</span>
</div>
);
})}
</div>
)}
</div>
);
}
/**
* 人工确认卡片
*
* 当 coordinatorMode === 'human_review' 时,每轮协调者提案完成后,
* 用户需在此卡片中确认或修改提案内容后再继续。
* 提供两种操作:
* - "让协调员修改":用自然语言告诉协调员怎么改,后端重新生成
* - "确认并继续":直接应用当前提案进入下一轮
*
* @param {Object} pendingReview - 待审核的协调者提案数据
* @param {Object} agentStates - 可选智能体列表
* @param {Function} onApprove - 确认回调
* @param {Function} onRevise - 修改回调
* @param {boolean} submitting - 是否正在提交
* @param {boolean} revising - 是否正在修改
*/
function HumanReviewCard({ pendingReview, agentStates, onApprove, onRevise, submitting, revising }) {
const [nextSpeakersText, setNextSpeakersText] = useState((pendingReview.nextSpeakers || []).join(', '));
const [focusText, setFocusText] = useState(JSON.stringify(pendingReview.focus || {}, null, 2));
const [draftText, setDraftText] = useState(JSON.stringify(pendingReview.draft || {}, null, 2));
const [gapsText, setGapsText] = useState(JSON.stringify(pendingReview.gaps || [], null, 2));
const [instruction, setInstruction] = useState('');
const [error, setError] = useState('');
// 当 pendingReview 变化时同步表单状态
useEffect(() => {
setNextSpeakersText((pendingReview.nextSpeakers || []).join(', '));
setFocusText(JSON.stringify(pendingReview.focus || {}, null, 2));
setDraftText(JSON.stringify(pendingReview.draft || {}, null, 2));
setGapsText(JSON.stringify(pendingReview.gaps || [], null, 2));
}, [pendingReview]);
/** 确认提案:解析用户修改的 JSON,组装后提交 */
const handleSubmit = async () => {
try {
const parsedFocus = JSON.parse(focusText || '{}');
const parsedDraft = JSON.parse(draftText || '{}');
const parsedGaps = JSON.parse(gapsText || '[]');
const nextSpeakers = nextSpeakersText
.split(',')
.map(item => item.trim())
.filter(Boolean);
setError('');
await onApprove({
nextSpeakers,
focus: parsedFocus,
draft: parsedDraft,
gaps: parsedGaps,
});
} catch (err) {
setError(err.message || '提交失败');
}
};
/** 发送修改指令给后端,让协调员重新生成提案 */
const handleRevise = async () => {
if (!instruction.trim()) {
setError('请先输入你希望协调员如何修改');
return;
}
try {
setError('');
await onRevise(instruction.trim());
setInstruction('');
} catch (err) {
setError(err.message || '修改失败');
}
};
return (
<div className="human-review-card">
<div className="human-review-title">
<span>人工确认</span>
<span className="coordinator-round-badge">
辩论轮 {pendingReview.divergeRound}/{pendingReview.totalDivergeRounds}
</span>
</div>
<div className="human-review-hint">
优先用自然语言告诉协调员怎么改;如果需要,也可以直接微调下方结构化内容。
</div>
{/* 自然语言修改指令 */}
<div className="human-review-field">
<label>对协调员说</label>
<textarea
className="human-review-textarea human-review-chatbox"
value={instruction}
onChange={e => setInstruction(e.target.value)}
placeholder="例如:不要让 risk 发言,改成 audience 和 narrative。把 focus 改得更具体,要求 narrative 给出 2 条可以直接发微博的文案。draft 里的 audience_map 先不要定死,保留为待验证。"
disabled={submitting || revising}
/>
<div className="human-review-actions human-review-actions-left">
<button className="btn-secondary" onClick={handleRevise} disabled={submitting || revising}>
{revising ? '协调员修改中…' : '让协调员修改'}
</button>
</div>
</div>
{/* 下一轮发言者(逗号分隔) */}
<div className="human-review-field">
<label>下一轮发言者</label>
<input
className="human-review-input"
value={nextSpeakersText}
onChange={e => setNextSpeakersText(e.target.value)}
placeholder="用逗号分隔 agent id,例如 audience, narrative"
disabled={submitting || revising}
/>
<div className="human-review-meta">
可选 agent: {Object.entries(agentStates || {}).map(([id, agent]) => `${agent.name || id}(${id})`).join(' / ')}
</div>
</div>
{/* 任务指令 + 草稿(并排布局) */}
<div className="human-review-grid">
<div className="human-review-field">
<label>任务指令 focus(JSON)</label>
<textarea
className="human-review-textarea"
value={focusText}
onChange={e => setFocusText(e.target.value)}
disabled={submitting || revising}
spellCheck={false}
/>
</div>
<div className="human-review-field">
<label>草稿 draft(JSON)</label>
<textarea
className="human-review-textarea"
value={draftText}
onChange={e => setDraftText(e.target.value)}
disabled={submitting || revising}
spellCheck={false}
/>
</div>
</div>
{/* 缺口列表 */}
<div className="human-review-field">
<label>当前缺口 gaps(JSON 数组)</label>
<textarea
className="human-review-textarea human-review-textarea-sm"
value={gapsText}
onChange={e => setGapsText(e.target.value)}
disabled={submitting || revising}
spellCheck={false}
/>
</div>
{error && <div className="human-review-error">{error}</div>}
<div className="human-review-actions">
<button className="btn-primary" onClick={handleSubmit} disabled={submitting || revising}>
{submitting ? '提交中…' : '确认并继续'}
</button>
</div>
</div>
);
}
/**
* 时间线组件
*
* 应用的核心展示区域,按时间顺序渲染分析过程中的所有事件:
* - background_retrieval: 背景检索结果
* - round_start: 新轮次开始的分隔标题
* - agent_contribution: 智能体输出卡片(包含流式 token 追加)
* - coordinator_streaming: 协调员流式输出
* - coordinator: 协调员完成的提案
* - final_report: 最终报告
*
* 滚动行为:
* - 默认自动滚动到底部(跟随流式输出)
* - 用户手动滚动后停止自动滚动,出现下滑按钮
* - 点击下滑按钮恢复自动滚动
* - 使用 window.__mopeProgrammaticScroll 计数器区分程序化滚动与用户滚动
*
* @param {Array} timeline - 时间线条目数组
* @param {Object} agentStates - 智能体状态映射 { agentId: { name, status, rounds } }
* @param {Object} reportSchema - 报告结构定义
* @param {Object|null} pendingReview - 待审核的协调者提案(非 null 表示等待人工确认)
* @param {Function} onApproveReview - 确认提案回调
* @param {Function} onReviseReview - 修改提案回调
* @param {boolean} reviewSubmitting - 是否正在提交审核
* @param {boolean} reviewRevising - 是否正在修改中
*/
export default function Timeline({ timeline, agentStates, reportSchema, pendingReview, onApproveReview, onReviseReview, reviewSubmitting, reviewRevising }) {
const endRef = useRef(null);
const [showScrollButton, setShowScrollButton] = useState(false);
const [isAutoScrolling, setIsAutoScrolling] = useState(true);
/**
* 程序化滚动到底部
* 设置 window.__mopeProgrammaticScroll 计数器,
* 每次 scroll 事件触发时递减,防止被误判为用户手动滚动
*/
const doScrollToBottom = () => {
const appMainElement = document.querySelector('.app-main');
if (!appMainElement) return;
window.__mopeProgrammaticScroll = (window.__mopeProgrammaticScroll || 0) + 1;
appMainElement.scrollTop = appMainElement.scrollHeight;
};
// 监听 .app-main 的滚动事件
// 当用户手动滚动时:显示下滑按钮 + 关闭自动滚动
// 程序化滚动时:跳过,不改变自动滚动状态
useEffect(() => {
const handleScroll = () => {
const appMainElement = document.querySelector('.app-main');
if (!appMainElement) return;
const { scrollTop, scrollHeight, clientHeight } = appMainElement;
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
setShowScrollButton(distanceToBottom > 50);
// 程序化滚动标记有效,跳过本次事件
if (window.__mopeProgrammaticScroll > 0) {
window.__mopeProgrammaticScroll--;
return;
}
// 真正的用户手动滚动 → 关闭自动滚动
setIsAutoScrolling(false);
};
const appMainElement = document.querySelector('.app-main');
if (appMainElement) {
appMainElement.addEventListener('scroll', handleScroll);
}
return () => {
if (appMainElement) {
appMainElement.removeEventListener('scroll', handleScroll);
}
};
}, []);
// 当 timeline 或 pendingReview 变化(新内容到达)时自动滚动到底部
useEffect(() => {
if (isAutoScrolling) {
doScrollToBottom();
}
}, [timeline, pendingReview, isAutoScrolling]);
/** 点击下滑按钮:滚动到底部并恢复自动滚动 */
const scrollToBottom = () => {
doScrollToBottom();
setIsAutoScrolling(true);
setShowScrollButton(false);
};
// 空状态:等待后端启动
if (!timeline || timeline.length === 0) {
return (
<div className="timeline">
<div className="timeline-empty">
<div className="timeline-empty-spinner">⟳</div>
<span>等待智能体启动…</span>
</div>
</div>
);
}
return (
<div className="timeline">
{timeline.map((item, idx) => {
// 背景检索卡片
if (item.type === 'background_retrieval') {
return <BackgroundRetrievalCard key={idx} data={item.data} />;
}
// 轮次分隔标题(如"第 1 轮 · 辩论")
if (item.type === 'round_start') {
return (
<div key={idx} className="round-header" data-phase={item.phase} data-round={item.round}>
<div className="round-header-line" />
<span className="round-header-label">第 {item.round} 轮</span>
<span className={getPhaseBadgeClass(item.phase)}>
{PHASE_LABELS[item.phase] || item.phase}
</span>
<div className="round-header-line" />
</div>
);
}
// 智能体输出卡片
if (item.type === 'agent_contribution') {
const { agentId, round, phase, data, streaming } = item;
const agentState = agentStates[agentId] || {};
return (
<div key={`${agentId}-${round}`} data-agent-id={agentId}>
<AgentCard
agentId={agentId}
agentName={agentState.name || agentId}
dimension={agentState.dimension || agentId}
round={round}
phase={phase}
content={data.content}
thinking={data.thinking}
dimensionOutput={data.dimensionOutput}
retrievalLog={data.retrievalLog}
retrievalCount={data.retrievalCount}
referenceMap={data.referenceMap}
streaming={!!streaming}
/>
</div>
);
}
// 协调员流式输出卡片
if (item.type === 'coordinator_streaming') {
return (
<div key={idx} data-phase={item.phase || 'closing'}>
<CoordinatorStreamingCard item={item} reportSchema={reportSchema} />
</div>
);
}
// 协调员完成提案卡片
if (item.type === 'coordinator') {
return (
<div key={idx} data-phase={item.data?.phase || 'closing'}>
<CoordinatorCard data={item.data} agentStates={agentStates} thinking={item.thinking} />
</div>
);
}
// 最终报告
if (item.type === 'final_report') {
return (
<div key={idx} data-phase="done">
<FinalReport output={item.data} reportSchema={reportSchema} />
</div>
);
}
return null;
})}
{/* 人工确认卡片(仅在等待确认时渲染) */}
{pendingReview && (
<HumanReviewCard
pendingReview={pendingReview}
agentStates={agentStates}
onApprove={onApproveReview}
onRevise={onReviseReview}
submitting={reviewSubmitting}
revising={reviewRevising}
/>
)}
{/* 滚动锚点:用于 scrollIntoView / 距离计算 */}
<div ref={endRef} />
{/* 下滑按钮:用户滚动出底部时出现,点击后恢复自动滚动 */}
{showScrollButton && (
<button
className="scroll-to-bottom-button"
onClick={scrollToBottom}
aria-label="滚动到底部"
>
<span className="scroll-arrow">↓</span>
</button>
)}
</div>
);
}
@@ -0,0 +1,567 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Select } from 'tdesign-react';
import AgentCard from './AgentCard';
import FinalReport from './FinalReport';
import './Timeline.css';
const PHASE_LABELS: Record<string, string> = {
starting: '启动',
diverging: '辩论',
closing: '收敛',
done: '完成',
};
function getPhaseBadgeClass(phase?: string): string {
switch (phase) {
case 'starting': return 'badge badge-starting';
case 'diverging': return 'badge badge-diverging';
case 'closing': return 'badge badge-closing';
case 'done': return 'badge badge-done';
default: return 'badge badge-default';
}
}
function BackgroundRetrievalCard({ data }: { data: any }): JSX.Element {
const [open, setOpen] = useState(false);
const { results, dimensions, total_records } = data;
return (
<div className="bg-retrieval-card">
<div className="bg-retrieval-header" onClick={() => setOpen(o => !o)}>
<span className="bg-retrieval-title">背景检索完成</span>
<span className="bg-retrieval-meta">
{dimensions && dimensions.length > 0 ? `${dimensions.length} 维度` : ''}
{total_records != null ? ` · ${total_records} 条` : ''}
</span>
<span className={`bg-retrieval-chevron ${open ? 'open' : ''}`}>▾</span>
</div>
{open && results && (
<div className="bg-retrieval-body">
{Object.entries(results).map(([dim, entries]) => (
<div key={dim} className="bg-retrieval-dim">
<div className="bg-retrieval-dim-label">{dim}</div>
{Array.isArray(entries) && entries.map((entry: any, i: number) => (
<div key={i} className="bg-retrieval-result">
{entry.query && (
<div className="bg-retrieval-query">查询: "{entry.query}"</div>
)}
<div className="bg-retrieval-items">
{Array.isArray(entry.content) && entry.content.map((item: any, j: number) => (
<div key={j} className="bg-retrieval-item">
{typeof item === 'string'
? (item.length > 250 ? item.slice(0, 250) + '…' : item)
: JSON.stringify(item).slice(0, 250)}
</div>
))}
</div>
</div>
))}
</div>
))}
</div>
)}
</div>
);
}
function CoordinatorStreamingCard({ item, reportSchema }: { item: any; reportSchema: Record<string, any> }): JSX.Element {
const { phase, divergeRound, totalDivergeRounds, thinking, content, statusMessage, currentField, reportPartial } = item;
const [thinkingOpen, setThinkingOpen] = useState(false);
const isFinalReport = phase === 'final_report';
const reportFields = reportSchema ? Object.keys(reportSchema) : [];
const thinkingBodyRef = useRef<HTMLDivElement>(null);
const contentBodyRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (thinkingBodyRef.current && thinkingOpen) {
thinkingBodyRef.current.scrollTop = thinkingBodyRef.current.scrollHeight;
}
}, [thinking, thinkingOpen]);
useEffect(() => {
if (contentBodyRef.current && !isFinalReport) {
contentBodyRef.current.scrollTop = contentBodyRef.current.scrollHeight;
}
}, [content, isFinalReport]);
return (
<div className="coordinator-card coordinator-card-streaming">
<div className="coordinator-card-title">
<span>协调员</span>
{!isFinalReport && divergeRound != null && (
<span className="coordinator-round-badge">
辩论轮 {divergeRound}/{totalDivergeRounds}
</span>
)}
{isFinalReport && (
<span className="coordinator-round-badge">生成最终方案</span>
)}
<span className="coordinator-streaming-dot">●</span>
</div>
{thinking && (
<div className="coordinator-thinking-section">
<div className="coordinator-thinking-header" onClick={() => setThinkingOpen(o => !o)}>
<span className="coordinator-thinking-label">思考中</span>
<span className={`coordinator-draft-chevron ${thinkingOpen ? 'open' : ''}`}>▾</span>
</div>
{thinkingOpen && (
<div className="coordinator-thinking-body" ref={thinkingBodyRef}>{thinking}</div>
)}
</div>
)}
{isFinalReport && reportFields.length > 0 && (
<div className="coordinator-draft-body" style={{ padding: '8px 0 0' }}>
{reportFields.map(field => {
const done = !!reportPartial[field];
const isActive = currentField === field && !done;
return (
<div key={field} className={`coordinator-draft-field ${done ? 'filled' : isActive ? 'active' : 'empty'}`}>
<span className="coordinator-draft-field-name">
{field}
{isActive && <span className="coordinator-streaming-dot" style={{ marginLeft: 6 }}>●</span>}
</span>
{done
? <span className="coordinator-draft-field-content">{reportPartial[field].slice(0, 200)}{reportPartial[field].length > 200 ? '…' : ''}</span>
: isActive && content
? <span className="coordinator-draft-field-content" style={{ maxHeight: 150, overflowY: 'auto', display: 'block' }}>{content}</span>
: <span className="coordinator-draft-field-empty">{isActive ? '生成中…' : '等待中'}</span>
}
</div>
);
})}
</div>
)}
{!isFinalReport && content && (
<div className="coordinator-thinking-body" style={{ marginTop: 6 }} ref={contentBodyRef}>{content}</div>
)}
{statusMessage && !isFinalReport && (
<div className="coordinator-status-banner coordinator-status-banner-tail">
<span className="coordinator-status-spinner">⟳</span>
<span>{statusMessage}</span>
</div>
)}
</div>
);
}
function CoordinatorCard({ data, agentStates, thinking }: { data: any; agentStates: Record<string, any>; thinking?: string }): JSX.Element {
const { divergeRound, totalDivergeRounds, nextSpeakers, focus, draft } = data;
const [draftOpen, setDraftOpen] = useState(false);
const [thinkingOpen, setThinkingOpen] = useState(false);
const draftEntries = draft ? Object.entries(draft) : [];
const filledCount = draftEntries.filter(([, v]: [string, any]) => v && String(v).trim()).length;
return (
<div className="coordinator-card">
<div className="coordinator-card-title">
<span>协调员</span>
{divergeRound != null && totalDivergeRounds != null && (
<span className="coordinator-round-badge">
辩论轮 {divergeRound}/{totalDivergeRounds}
</span>
)}
</div>
{thinking && thinking.trim() && (
<div className="coordinator-thinking-section">
<div className="coordinator-thinking-header" onClick={() => setThinkingOpen(o => !o)}>
<span className="coordinator-thinking-label">思考过程</span>
<span className={`coordinator-draft-chevron ${thinkingOpen ? 'open' : ''}`}>▾</span>
</div>
{thinkingOpen && (
<div className="coordinator-thinking-body">{thinking}</div>
)}
</div>
)}
{draftEntries.length > 0 && (
<div className="coordinator-draft-section">
<div
className="coordinator-draft-header"
onClick={() => setDraftOpen(o => !o)}
>
<span className="coordinator-draft-title">方案草稿</span>
<span className="coordinator-draft-progress">
{filledCount}/{draftEntries.length} 字段已填写
</span>
<span className={`coordinator-draft-chevron ${draftOpen ? 'open' : ''}`}>▾</span>
</div>
{draftOpen && (
<div className="coordinator-draft-body">
{draftEntries.map(([field, content]: [string, any]) => (
<div key={field} className={`coordinator-draft-field ${content ? 'filled' : 'empty'}`}>
<span className="coordinator-draft-field-name">{field}</span>
{content
? <span className="coordinator-draft-field-content">{String(content).slice(0, 300)}{String(content).length > 300 ? '…' : ''}</span>
: <span className="coordinator-draft-field-empty">空缺</span>
}
</div>
))}
</div>
)}
</div>
)}
{nextSpeakers && nextSpeakers.length > 0 && (
<div className="coordinator-speakers">
<span className="coordinator-speakers-label">下一轮:</span>
{nextSpeakers.map((id: string) => {
const name = agentStates?.[id]?.name || id;
return <span key={id} className="coordinator-speaker-tag">{name}</span>;
})}
</div>
)}
{focus && Object.keys(focus).length > 0 && (
<div className="coordinator-focus">
{Object.entries(focus).map(([agentId, directive]) => {
const name = agentStates?.[agentId]?.name || agentId;
return (
<div key={agentId} className="coordinator-focus-item">
<span className="coordinator-focus-agent">{name}</span>
<span className="coordinator-focus-directive">{String(directive)}</span>
</div>
);
})}
</div>
)}
</div>
);
}
function HumanReviewCard({ pendingReview, agentStates, onApprove, onRevise, submitting, revising }: any) {
const [nextSpeakers, setNextSpeakers] = useState<string[]>(pendingReview.nextSpeakers || []);
const [focusText, setFocusText] = useState(JSON.stringify(pendingReview.focus || {}, null, 2));
const [draftText, setDraftText] = useState(JSON.stringify(pendingReview.draft || {}, null, 2));
const [gapsText, setGapsText] = useState(JSON.stringify(pendingReview.gaps || [], null, 2));
const [instruction, setInstruction] = useState('');
const [error, setError] = useState('');
useEffect(() => {
setNextSpeakers(pendingReview.nextSpeakers || []);
setFocusText(JSON.stringify(pendingReview.focus || {}, null, 2));
setDraftText(JSON.stringify(pendingReview.draft || {}, null, 2));
setGapsText(JSON.stringify(pendingReview.gaps || [], null, 2));
}, [pendingReview]);
const agentOptions = Object.entries(agentStates || {}).map(([id, agent]: [string, any]) => ({
label: agent.name || id,
value: id,
}));
const handleSpeakerChange = (value: any) => {
const values = Array.isArray(value) ? value : [];
setNextSpeakers(values.map(item => String(item)));
};
const handleSubmit = async () => {
try {
const parsedFocus = JSON.parse(focusText || '{}');
const parsedDraft = JSON.parse(draftText || '{}');
const parsedGaps = JSON.parse(gapsText || '[]');
const nextSpeakerIds = nextSpeakers.map(item => item.trim()).filter(Boolean);
setError('');
await onApprove({
nextSpeakers: nextSpeakerIds,
focus: parsedFocus,
draft: parsedDraft,
gaps: parsedGaps,
});
} catch (err: any) {
setError(err?.message || '提交失败');
}
};
const handleRevise = async () => {
if (!instruction.trim()) {
setError('请先输入你希望协调员如何修改');
return;
}
try {
setError('');
await onRevise(instruction.trim());
setInstruction('');
} catch (err: any) {
setError(err?.message || '修改失败');
}
};
return (
<div className="human-review-card">
<div className="human-review-title">
<span>人工确认</span>
<span className="coordinator-round-badge">
辩论轮 {pendingReview.divergeRound}/{pendingReview.totalDivergeRounds}
</span>
</div>
<div className="human-review-hint">
优先用自然语言告诉协调员怎么改;如果需要,也可以直接微调下方结构化内容。
</div>
<div className="human-review-field">
<label>对协调员说</label>
<textarea
className="human-review-textarea human-review-chatbox"
value={instruction}
onChange={e => setInstruction(e.target.value)}
placeholder="例如:不要让 risk 发言,改成 audience 和 narrative。把 focus 改得更具体,要求 narrative 给出 2 条可以直接发微博的文案。draft 里的 audience_map 先不要定死,保留为待验证。"
disabled={submitting || revising}
/>
<div className="human-review-actions human-review-actions-left">
<button className="btn-secondary" onClick={handleRevise} disabled={submitting || revising}>
{revising ? '协调员修改中…' : '让协调员修改'}
</button>
</div>
</div>
<div className="human-review-field">
<label>下一轮发言者</label>
<Select
className="human-review-speaker-select"
value={nextSpeakers}
options={agentOptions}
multiple
clearable
filterable
minCollapsedNum={3}
placeholder="请选择下一轮发言者"
disabled={submitting || revising}
onChange={handleSpeakerChange}
/>
<div className="human-review-meta">
可选 agent: {agentOptions.map(option => `${option.label}(${option.value})`).join(' / ')}
</div>
</div>
<div className="human-review-grid">
<div className="human-review-field">
<label>任务指令 focus(JSON)</label>
<textarea
className="human-review-textarea"
value={focusText}
onChange={e => setFocusText(e.target.value)}
disabled={submitting || revising}
spellCheck={false}
/>
</div>
<div className="human-review-field">
<label>草稿 draft(JSON)</label>
<textarea
className="human-review-textarea"
value={draftText}
onChange={e => setDraftText(e.target.value)}
disabled={submitting || revising}
spellCheck={false}
/>
</div>
</div>
<div className="human-review-field">
<label>当前缺口 gaps(JSON 数组)</label>
<textarea
className="human-review-textarea human-review-textarea-sm"
value={gapsText}
onChange={e => setGapsText(e.target.value)}
disabled={submitting || revising}
spellCheck={false}
/>
</div>
{error && <div className="human-review-error">{error}</div>}
<div className="human-review-actions">
<button className="btn-primary" onClick={handleSubmit} disabled={submitting || revising}>
{submitting ? '提交中…' : '确认并继续'}
</button>
</div>
</div>
);
}
type Props = {
timeline: any[];
agentStates: Record<string, any>;
reportSchema: Record<string, any>;
pendingReview: any;
onApproveReview: (payload: any) => Promise<void>;
onReviseReview: (instruction: string) => Promise<void>;
reviewSubmitting: boolean;
reviewRevising: boolean;
};
export default function Timeline({ timeline, agentStates, reportSchema, pendingReview, onApproveReview, onReviseReview, reviewSubmitting, reviewRevising }: Props): JSX.Element {
const endRef = useRef<HTMLDivElement | null>(null);
const programmaticScrollUntilRef = useRef(0);
const [showScrollButton, setShowScrollButton] = useState(false);
const [isAutoScrolling, setIsAutoScrolling] = useState(true);
const doScrollToBottom = useCallback((behavior: ScrollBehavior = 'auto') => {
const appMainElement = document.querySelector('.app-main') as HTMLElement | null;
if (!appMainElement) return;
programmaticScrollUntilRef.current = Date.now() + (behavior === 'smooth' ? 700 : 120);
appMainElement.scrollTo({ top: appMainElement.scrollHeight, behavior });
}, []);
useEffect(() => {
const handleScroll = () => {
const appMainElement = document.querySelector('.app-main') as HTMLElement | null;
if (!appMainElement) return;
const { scrollTop, scrollHeight, clientHeight } = appMainElement;
const distanceToBottom = scrollHeight - scrollTop - clientHeight;
const isNearBottom = distanceToBottom < 24;
const isProgrammaticScroll = Date.now() < programmaticScrollUntilRef.current;
setShowScrollButton(!isNearBottom);
if (isNearBottom) {
setIsAutoScrolling(true);
return;
}
if (!isProgrammaticScroll) {
setIsAutoScrolling(false);
}
};
const appMainElement = document.querySelector('.app-main') as HTMLElement | null;
if (appMainElement) {
appMainElement.addEventListener('scroll', handleScroll, { passive: true });
}
return () => {
if (appMainElement) {
appMainElement.removeEventListener('scroll', handleScroll);
}
};
}, []);
useEffect(() => {
if (isAutoScrolling) {
doScrollToBottom('auto');
}
}, [timeline, pendingReview, isAutoScrolling, doScrollToBottom]);
const scrollToBottom = () => {
setIsAutoScrolling(true);
setShowScrollButton(false);
doScrollToBottom('smooth');
};
if (!timeline || timeline.length === 0) {
return (
<div className="timeline">
<div className="timeline-empty">
<div className="timeline-empty-spinner">⟳</div>
<span>等待智能体启动…</span>
</div>
</div>
);
}
return (
<div className="timeline">
{timeline.map((item, idx) => {
if (item.type === 'background_retrieval') {
return <BackgroundRetrievalCard key={idx} data={item.data} />;
}
if (item.type === 'round_start') {
return (
<div key={idx} className="round-header" data-phase={item.phase} data-round={item.round}>
<div className="round-header-line" />
<span className="round-header-label">第 {item.round} 轮</span>
<span className={getPhaseBadgeClass(item.phase)}>
{PHASE_LABELS[item.phase] || item.phase}
</span>
<div className="round-header-line" />
</div>
);
}
if (item.type === 'agent_contribution') {
const { agentId, round, phase, data, streaming } = item;
const agentState = agentStates[agentId] || {};
return (
<div key={`${agentId}-${round}`} data-agent-id={agentId}>
<AgentCard
agentId={agentId}
agentName={agentState.name || agentId}
dimension={agentState.dimension || agentId}
round={round}
phase={phase}
content={data.content}
thinking={data.thinking}
dimensionOutput={data.dimensionOutput}
retrievalLog={data.retrievalLog}
retrievalCount={data.retrievalCount}
referenceMap={data.referenceMap}
streaming={!!streaming}
/>
</div>
);
}
if (item.type === 'coordinator_streaming') {
return (
<div key={idx} data-phase={item.phase || 'closing'}>
<CoordinatorStreamingCard item={item} reportSchema={reportSchema} />
</div>
);
}
if (item.type === 'coordinator') {
return (
<div key={idx} data-phase={item.data?.phase || 'closing'}>
<CoordinatorCard data={item.data} agentStates={agentStates} thinking={item.thinking} />
</div>
);
}
if (item.type === 'final_report') {
return (
<div key={idx} data-phase="done">
<FinalReport output={item.data} reportSchema={reportSchema} />
</div>
);
}
return null;
})}
{pendingReview && (
<HumanReviewCard
pendingReview={pendingReview}
agentStates={agentStates}
onApprove={onApproveReview}
onRevise={onReviseReview}
submitting={reviewSubmitting}
revising={reviewRevising}
/>
)}
<div ref={endRef} />
{showScrollButton && (
<button
className="scroll-to-bottom-button"
onClick={scrollToBottom}
aria-label="滚动到底部并开启自动跟随"
title="回到底部并继续自动下滑"
>
<span className="scroll-arrow">↓</span>
</button>
)}
</div>
);
}
+35 -27
View File
@@ -243,7 +243,7 @@
--workspace-brand: #d92121; --workspace-brand: #d92121;
--workspace-brand-foreground: #ffffff; --workspace-brand-foreground: #ffffff;
--radius: 0.75rem; --radius: 0.75rem;
--background: oklch(0.985 0.004 95); --background: oklch(1 0 0);
--foreground: oklch(0.22 0.02 25); --foreground: oklch(0.22 0.02 25);
--card: oklch(1 0.002 95); --card: oklch(1 0.002 95);
--card-foreground: oklch(0.22 0.02 25); --card-foreground: oklch(0.22 0.02 25);
@@ -277,39 +277,39 @@
} }
.dark { .dark {
--workspace-brand: #e11d1d; --workspace-brand: #3b82f6;
--workspace-brand-foreground: #ffffff; --workspace-brand-foreground: #ffffff;
--background: oklch(0.22 0.015 25); --background: oklch(0.208 0.042 264);
--foreground: oklch(0.96 0.01 95); --foreground: oklch(0.96 0.005 240);
--card: oklch(0.26 0.018 25); --card: oklch(0.265 0.04 260);
--card-foreground: oklch(0.96 0.01 95); --card-foreground: oklch(0.96 0.005 240);
--popover: oklch(0.24 0.018 25); --popover: oklch(0.245 0.04 262);
--popover-foreground: oklch(0.96 0.01 95); --popover-foreground: oklch(0.96 0.005 240);
--primary: oklch(0.62 0.17 27); --primary: oklch(0.623 0.214 259);
--primary-foreground: oklch(0.12 0.02 25); --primary-foreground: oklch(0.99 0 0);
--secondary: oklch(0.32 0.02 25); --secondary: oklch(0.32 0.038 260);
--secondary-foreground: oklch(0.96 0.01 95); --secondary-foreground: oklch(0.96 0.005 240);
--muted: oklch(0.3 0.02 25); --muted: oklch(0.30 0.035 260);
--muted-foreground: oklch(0.72 0.02 95); --muted-foreground: oklch(0.68 0.02 240);
--accent: oklch(0.34 0.06 25); --accent: oklch(0.35 0.06 260);
--accent-foreground: oklch(0.96 0.01 95); --accent-foreground: oklch(0.96 0.005 240);
--destructive: oklch(0.704 0.191 22.216); --destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 12%); --border: oklch(1 0 0 / 12%);
--input: oklch(1 0 0 / 14%); --input: oklch(1 0 0 / 14%);
--ring: oklch(0.62 0.17 27 / 0.45); --ring: oklch(0.623 0.214 259 / 0.45);
--chart-1: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48); --chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08); --chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9); --chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439); --chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.2 0.02 25); --sidebar: oklch(0.185 0.04 265);
--sidebar-foreground: oklch(0.94 0.01 95); --sidebar-foreground: oklch(0.94 0.005 240);
--sidebar-primary: oklch(0.62 0.17 27); --sidebar-primary: oklch(0.623 0.214 259);
--sidebar-primary-foreground: oklch(0.99 0 0); --sidebar-primary-foreground: oklch(0.99 0 0);
--sidebar-accent: oklch(0.3 0.05 25); --sidebar-accent: oklch(0.30 0.055 260);
--sidebar-accent-foreground: oklch(0.96 0.01 95); --sidebar-accent-foreground: oklch(0.96 0.005 240);
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.62 0.17 27 / 0.45); --sidebar-ring: oklch(0.623 0.214 259 / 0.45);
font-weight: 300; font-weight: 300;
} }
@@ -430,19 +430,27 @@
} }
.workspace-shell { .workspace-shell {
background: background: #ffffff;
radial-gradient(circle at top right, rgb(255 214 214 / 0.72), transparent 18%), }
linear-gradient(180deg, #fbf7f4 0%, #f6efea 100%);
.dark .workspace-shell {
background: var(--background);
} }
.workspace-panel { .workspace-panel {
border: 1px solid #f0d7d0; border: 1px solid #f0d7d0;
background: linear-gradient(180deg, rgb(255 253 251 / 0.98), rgb(255 247 244 / 0.98)); background: #ffffff;
box-shadow: box-shadow:
0 32px 72px -46px rgb(98 63 42 / 0.35), 0 32px 72px -46px rgb(98 63 42 / 0.35),
0 12px 28px -22px rgb(170 91 75 / 0.24); 0 12px 28px -22px rgb(170 91 75 / 0.24);
} }
.dark .workspace-panel {
border: 1px solid oklch(1 0 0 / 10%);
background: var(--card);
box-shadow: none;
}
.workspace-user-bubble { .workspace-user-bubble {
max-width: min(82%, 42rem); max-width: min(82%, 42rem);
border: 1px solid rgb(255 255 255 / 0.14); border: 1px solid rgb(255 255 255 / 0.14);
@@ -31,6 +31,7 @@ from app.gateway.routers import (
memory, memory,
models, models,
notifications, notifications,
recommended_questions,
runs, runs,
scheduled_tasks, scheduled_tasks,
skills, skills,
@@ -562,6 +563,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Agents API is mounted at /api/agents # Agents API is mounted at /api/agents
app.include_router(agents.router) app.include_router(agents.router)
# Recommended Questions API is mounted at /api/recommended-questions
app.include_router(recommended_questions.router)
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions # Suggestions API is mounted at /api/threads/{thread_id}/suggestions
app.include_router(suggestions.router) app.include_router(suggestions.router)
@@ -109,6 +109,9 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
# Make the sender helper (harness layer) able to write without going # Make the sender helper (harness layer) able to write without going
# through FastAPI request context — used by cascade.py. # through FastAPI request context — used by cascade.py.
set_notification_store(app.state.notification_store) set_notification_store(app.state.notification_store)
from deerflow.persistence.recommended_questions import make_recommended_question_store
app.state.recommended_question_store = make_recommended_question_store(sf)
# Run event store (has its own factory with config-driven backend selection) # Run event store (has its own factory with config-driven backend selection)
run_events_config = getattr(config, "run_events", None) run_events_config = getattr(config, "run_events", None)
@@ -1,3 +1,3 @@
from . import artifacts, assistants_compat, mcp, models, skills, suggestions, thread_runs, threads, uploads from . import artifacts, assistants_compat, mcp, models, recommended_questions, skills, suggestions, thread_runs, threads, uploads
__all__ = ["artifacts", "assistants_compat", "mcp", "models", "skills", "suggestions", "threads", "thread_runs", "uploads"] __all__ = ["artifacts", "assistants_compat", "mcp", "models", "recommended_questions", "skills", "suggestions", "threads", "thread_runs", "uploads"]
@@ -0,0 +1,101 @@
"""CRUD API for recommended questions."""
from __future__ import annotations
import logging
from datetime import datetime
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api", tags=["recommended-questions"])
class RecommendedQuestionResponse(BaseModel):
id: str
title: str
content: str
sort_order: int = 0
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class RecommendedQuestionsListResponse(BaseModel):
questions: list[RecommendedQuestionResponse]
class RecommendedQuestionCreateRequest(BaseModel):
title: str = Field(..., min_length=1, max_length=256)
content: str = Field(..., min_length=1)
sort_order: int = Field(default=0)
class RecommendedQuestionUpdateRequest(BaseModel):
title: str | None = Field(default=None, max_length=256)
content: str | None = None
sort_order: int | None = None
class ReorderRequest(BaseModel):
ids: list[str] = Field(..., description="Question IDs in the desired order")
def _get_store(request: Request):
store = getattr(request.app.state, "recommended_question_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Recommended question store not available")
return store
@router.get("/recommended-questions", response_model=RecommendedQuestionsListResponse)
async def list_recommended_questions(request: Request) -> RecommendedQuestionsListResponse:
store = _get_store(request)
rows = await store.list_all()
return RecommendedQuestionsListResponse(questions=[RecommendedQuestionResponse(**r) for r in rows])
@router.post("/recommended-questions", response_model=RecommendedQuestionResponse, status_code=201)
async def create_recommended_question(request: Request, body: RecommendedQuestionCreateRequest) -> RecommendedQuestionResponse:
store = _get_store(request)
data = {
"id": str(uuid4()),
"title": body.title.strip(),
"content": body.content.strip(),
"sort_order": body.sort_order,
}
row = await store.create(data)
return RecommendedQuestionResponse(**row)
@router.put("/recommended-questions/{question_id}", response_model=RecommendedQuestionResponse)
async def update_recommended_question(request: Request, question_id: str, body: RecommendedQuestionUpdateRequest) -> RecommendedQuestionResponse:
store = _get_store(request)
data: dict = {}
if body.title is not None:
data["title"] = body.title.strip()
if body.content is not None:
data["content"] = body.content.strip()
if body.sort_order is not None:
data["sort_order"] = body.sort_order
row = await store.update(question_id, data)
if row is None:
raise HTTPException(status_code=404, detail="Question not found")
return RecommendedQuestionResponse(**row)
@router.delete("/recommended-questions/{question_id}", status_code=204)
async def delete_recommended_question(request: Request, question_id: str) -> None:
store = _get_store(request)
deleted = await store.delete(question_id)
if not deleted:
raise HTTPException(status_code=404, detail="Question not found")
@router.put("/recommended-questions-reorder", response_model=RecommendedQuestionsListResponse)
async def reorder_recommended_questions(request: Request, body: ReorderRequest) -> RecommendedQuestionsListResponse:
store = _get_store(request)
rows = await store.reorder(body.ids)
return RecommendedQuestionsListResponse(questions=[RecommendedQuestionResponse(**r) for r in rows])
@@ -3,16 +3,13 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from collections.abc import Callable
from hashlib import sha256 from hashlib import sha256
from typing import Any, override from typing import Any, override
from langchain.agents import AgentState from langchain.agents import AgentState
from langchain.agents.middleware import AgentMiddleware from langchain.agents.middleware import AgentMiddleware, hook_config
from langchain_core.messages import ToolMessage from langchain_core.messages import AIMessage, ToolMessage
from langgraph.graph import END from langgraph.runtime import Runtime
from langgraph.prebuilt.tool_node import ToolCallRequest
from langgraph.types import Command
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -26,15 +23,22 @@ class SetupAgentApprovalMiddlewareState(AgentState):
class SetupAgentApprovalMiddleware(AgentMiddleware[SetupAgentApprovalMiddlewareState]): class SetupAgentApprovalMiddleware(AgentMiddleware[SetupAgentApprovalMiddlewareState]):
"""Stop before ``setup_agent`` writes files and ask the user to approve. """Stop before ``setup_agent`` writes files and ask the user to approve.
The first ``setup_agent`` call is converted into a structured tool message The interception runs in ``after_model``: as soon as the model emits an
and the run ends. A subsequent request can set ``setup_agent_approved`` in AIMessage that contains a ``setup_agent`` tool call (and the run has not
runtime context to allow the tool to execute. been pre-approved via ``setup_agent_approved=True`` in runtime context),
we append a structured approval ``ToolMessage`` and return
``jump_to="end"`` to terminate the LangGraph run *before* the tool node
executes. This is the LangChain-recommended way to short-circuit after
the model; using ``wrap_tool_call`` + ``Command(goto=END)`` proved
unreliable when the AIMessage contained multiple parallel tool calls
(the ``Command`` got mixed with normal tool outputs and the graph
looped back to the model instead of terminating).
""" """
state_schema = SetupAgentApprovalMiddlewareState state_schema = SetupAgentApprovalMiddlewareState
def _is_approved(self, request: ToolCallRequest) -> bool: def _is_approved(self, runtime: Runtime) -> bool:
context = getattr(request.runtime, "context", None) or {} context = getattr(runtime, "context", None) or {}
if not isinstance(context, dict): if not isinstance(context, dict):
return False return False
return context.get("setup_agent_approved") is True return context.get("setup_agent_approved") is True
@@ -54,37 +58,57 @@ class SetupAgentApprovalMiddleware(AgentMiddleware[SetupAgentApprovalMiddlewareS
"skills": skills if isinstance(skills, list) else [], "skills": skills if isinstance(skills, list) else [],
} }
def _handle_setup_agent(self, request: ToolCallRequest) -> Command: def _intercept(self, last: AIMessage) -> dict[str, Any] | None:
args = request.tool_call.get("args", {}) or {} """Return a state update if setup_agent must be gated; None to passthrough."""
tool_call_id = request.tool_call.get("id", "") tool_calls = list(last.tool_calls or [])
setup_call = next((tc for tc in tool_calls if tc.get("name") == "setup_agent"), None)
if setup_call is None:
return None
args = setup_call.get("args") or {}
tool_call_id = setup_call.get("id") or ""
payload = self._build_payload(args) payload = self._build_payload(args)
logger.info("Setup agent approval requested before writing agent files") logger.info("Setup agent approval requested before writing agent files")
message = ToolMessage( approval_msg = ToolMessage(
id=self._stable_message_id(tool_call_id, args), id=self._stable_message_id(tool_call_id, args),
content="请确认是否创建/更新这个专家配置。", content="请确认是否创建/更新这个专家配置。",
tool_call_id=tool_call_id, tool_call_id=tool_call_id,
name="setup_agent", name="setup_agent",
additional_kwargs={"setup_agent_approval": payload}, additional_kwargs={"setup_agent_approval": payload},
) )
return Command(update={"messages": [message]}, goto=END)
@override # Any other tool_calls on the same AIMessage are left dangling; the
def wrap_tool_call( # existing DanglingToolCallMiddleware will inject placeholder
self, # ToolMessages on the next model invocation, which matches the
request: ToolCallRequest, # desired semantics: every action pauses until the user approves.
handler: Callable[[ToolCallRequest], ToolMessage | Command], return {
) -> ToolMessage | Command: "messages": [approval_msg],
if request.tool_call.get("name") != "setup_agent" or self._is_approved(request): "jump_to": "end",
return handler(request) }
return self._handle_setup_agent(request)
@hook_config(can_jump_to=["end"])
@override @override
async def awrap_tool_call( def after_model(
self, self,
request: ToolCallRequest, state: SetupAgentApprovalMiddlewareState,
handler: Callable[[ToolCallRequest], ToolMessage | Command], runtime: Runtime,
) -> ToolMessage | Command: ) -> dict[str, Any] | None:
if request.tool_call.get("name") != "setup_agent" or self._is_approved(request): if self._is_approved(runtime):
return await handler(request) return None
return self._handle_setup_agent(request) messages = state.get("messages") or []
if not messages:
return None
last = messages[-1]
if not isinstance(last, AIMessage):
return None
return self._intercept(last)
@hook_config(can_jump_to=["end"])
@override
async def aafter_model(
self,
state: SetupAgentApprovalMiddlewareState,
runtime: Runtime,
) -> dict[str, Any] | None:
return self.after_model(state, runtime)
@@ -0,0 +1,44 @@
"""Create recommended_questions table.
Revision ID: 20260514_01
Revises: 20260513_02
Create Date: 2026-05-14
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
revision = "20260514_01"
down_revision = "20260513_02"
branch_labels = None
depends_on = None
def _has_table(table_name: str) -> bool:
inspector = inspect(op.get_bind())
return table_name in inspector.get_table_names()
def upgrade() -> None:
if _has_table("recommended_questions"):
return
op.create_table(
"recommended_questions",
sa.Column("id", sa.String(64), primary_key=True),
sa.Column("title", sa.String(256), nullable=False),
sa.Column("content", sa.Text(), nullable=False),
sa.Column("sort_order", sa.Integer(), nullable=False, server_default="0"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_recommended_questions_sort_order", "recommended_questions", ["sort_order"])
def downgrade() -> None:
if _has_table("recommended_questions"):
op.drop_table("recommended_questions")
@@ -0,0 +1,18 @@
"""Recommended questions persistence."""
from __future__ import annotations
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
from deerflow.persistence.recommended_questions.sql import RecommendedQuestionRepository
__all__ = [
"RecommendedQuestionRepository",
"RecommendedQuestionRow",
"make_recommended_question_store",
]
def make_recommended_question_store(session_factory):
if session_factory is None:
return None
return RecommendedQuestionRepository(session_factory)
@@ -0,0 +1,21 @@
"""ORM model for recommended questions."""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
class RecommendedQuestionRow(Base):
__tablename__ = "recommended_questions"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
title: Mapped[str] = mapped_column(String(256), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
@@ -0,0 +1,85 @@
"""SQLAlchemy-backed recommended questions repository."""
from __future__ import annotations
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.recommended_questions.model import RecommendedQuestionRow
def _row_to_dict(row: RecommendedQuestionRow) -> dict[str, Any]:
data = row.to_dict()
for key in ("created_at", "updated_at"):
value = data.get(key)
if isinstance(value, datetime):
data[key] = value.isoformat()
return data
class RecommendedQuestionRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def list_all(self) -> list[dict[str, Any]]:
stmt = select(RecommendedQuestionRow).order_by(RecommendedQuestionRow.sort_order.asc(), RecommendedQuestionRow.created_at.asc())
async with self._sf() as session:
result = await session.execute(stmt)
return [_row_to_dict(row) for row in result.scalars()]
async def get(self, question_id: str) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(RecommendedQuestionRow, question_id)
return _row_to_dict(row) if row is not None else None
async def create(self, data: dict[str, Any]) -> dict[str, Any]:
now = datetime.now(UTC)
row = RecommendedQuestionRow(
id=data["id"],
title=data["title"],
content=data["content"],
sort_order=data.get("sort_order", 0),
created_at=now,
updated_at=now,
)
async with self._sf() as session:
session.add(row)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def update(self, question_id: str, data: dict[str, Any]) -> dict[str, Any] | None:
async with self._sf() as session:
row = await session.get(RecommendedQuestionRow, question_id)
if row is None:
return None
for key in ("title", "content", "sort_order"):
if key in data:
setattr(row, key, data[key])
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return _row_to_dict(row)
async def delete(self, question_id: str) -> bool:
async with self._sf() as session:
row = await session.get(RecommendedQuestionRow, question_id)
if row is None:
return False
await session.delete(row)
await session.commit()
return True
async def reorder(self, ordered_ids: list[str]) -> list[dict[str, Any]]:
async with self._sf() as session:
now = datetime.now(UTC)
for index, question_id in enumerate(ordered_ids):
row = await session.get(RecommendedQuestionRow, question_id)
if row is not None:
row.sort_order = index
row.updated_at = now
await session.commit()
return await self.list_all()
+113
View File
@@ -0,0 +1,113 @@
# 后端服务启停说明(Windows)
你当前是 **Windows + PowerShell** 环境,下面这份是可直接用的后端启停命令。
> 说明:仓库里的 `scripts/*.sh` 是 Bash 脚本,Windows 原生 PowerShell 不能直接执行,所以会出现“启动不了”。
## 一、后端目录
后端目录:
```text
offline-backend-20260512/backend/
```
下面命令默认都在这个目录执行。
---
## 二、Windows 下推荐启动方式
### 1) 开发模式启动(热重载)
```powershell
uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --reload
```
### 2) 普通模式启动(无热重载)
```powershell
uv run uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001
```
> 如果你习惯 `make dev / make gateway`,Windows 上通常还需要额外安装 make,建议直接用上面两条 `uv run uvicorn` 更稳。
---
## 三、停止后端
如果后端在当前窗口前台运行:
- 直接按 `Ctrl + C`
如果窗口已经关了,或你想强制结束 8001 端口进程:
```powershell
netstat -ano | findstr :8001
```
记下最后一列 PID 后执行:
```powershell
taskkill /PID <PID> /F
```
---
## 四、首次安装依赖(只需一次)
在 `offline-backend-20260512/backend` 目录执行:
```powershell
uv sync
```
如果 `uv` 命令不存在,先安装(任选其一):
```powershell
pip install uv
```
或(已安装 winget 时):
```powershell
winget install --id Astral-sh.uv -e
```
---
## 五、快速自检
启动后浏览器访问:
- [http://localhost:8001/health](http://localhost:8001/health)
返回健康状态即表示后端正常。
---
## 六、常见问题
### 1) 报错:端口 8001 被占用
先执行:
```powershell
netstat -ano | findstr :8001
```
然后 `taskkill` 对应 PID,再重启后端。
### 2) 报错:`uv` 不是内部或外部命令
先安装 `uv`(见上面的“首次安装依赖”),安装后重开一个 PowerShell 窗口再试。
### 3) 报错:模块/依赖缺失
重新执行:
```powershell
uv sync
```
再启动。