Merge branch 'dhk-2' into 'master'
页面接口整体修改 See merge request knowledge-base-bj/deerflow-server!14
This commit is contained in:
commit
4e0787ec77
71 files changed
+19271
-110
No files matched your search
Generated
+318
-5
@@ -61,8 +61,10 @@
|
||||
"ogl": "^1.0.11",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
@@ -71,6 +73,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "1.4.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tdesign-react": "^1.12.3",
|
||||
"tokenlens": "^1.3.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
@@ -1665,6 +1668,15 @@
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
@@ -4615,6 +4627,11 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prismjs": {
|
||||
"version": "1.26.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
|
||||
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.13",
|
||||
"license": "MIT",
|
||||
@@ -4630,6 +4647,16 @@
|
||||
"@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": {
|
||||
"version": "2.0.7",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "4.25.4",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "2.1.1",
|
||||
"license": "MIT",
|
||||
@@ -5773,6 +5810,15 @@
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz",
|
||||
"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": {
|
||||
"version": "3.4.2",
|
||||
"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",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
|
||||
},
|
||||
"node_modules/fault": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
|
||||
"integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
|
||||
"dependencies": {
|
||||
"format": "^0.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -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": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
|
||||
@@ -6264,6 +6330,32 @@
|
||||
"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": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
@@ -6403,8 +6495,7 @@
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
@@ -6790,6 +6881,30 @@
|
||||
"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": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -6801,7 +6916,8 @@
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.562.0",
|
||||
"license": "ISC",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
|
||||
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
@@ -7706,6 +7822,11 @@
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"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": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
|
||||
@@ -7797,6 +7918,14 @@
|
||||
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
|
||||
"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": {
|
||||
"version": "1.0.11",
|
||||
"license": "Unlicense"
|
||||
@@ -7918,6 +8047,11 @@
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"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_modules/prismjs": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"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": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
||||
@@ -8019,6 +8176,14 @@
|
||||
"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": {
|
||||
"version": "19.2.4",
|
||||
"license": "MIT",
|
||||
@@ -8036,6 +8201,16 @@
|
||||
"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": {
|
||||
"version": "10.1.0",
|
||||
"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": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
@@ -8194,6 +8403,26 @@
|
||||
"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": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
|
||||
@@ -8242,7 +8471,8 @@
|
||||
},
|
||||
"node_modules/rehype-raw": {
|
||||
"version": "7.0.0",
|
||||
"license": "MIT",
|
||||
"resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
|
||||
"integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"hast-util-raw": "^9.0.0",
|
||||
@@ -8255,7 +8485,8 @@
|
||||
},
|
||||
"node_modules/remark-gfm": {
|
||||
"version": "4.0.1",
|
||||
"license": "MIT",
|
||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
||||
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-gfm": "^3.0.0",
|
||||
@@ -8457,6 +8688,11 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -8578,6 +8814,75 @@
|
||||
"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": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
|
||||
@@ -8882,6 +9187,14 @@
|
||||
"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": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||
|
||||
@@ -63,8 +63,10 @@
|
||||
"ogl": "^1.0.11",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
@@ -73,6 +75,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "1.4.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tdesign-react": "^1.12.3",
|
||||
"tokenlens": "^1.3.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import type { ElementType } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
BarChart3,
|
||||
Bot,
|
||||
@@ -38,6 +39,7 @@ type MenuItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ElementType;
|
||||
path?: string;
|
||||
children?: MenuItem[];
|
||||
};
|
||||
|
||||
@@ -49,173 +51,173 @@ type PageSidebarProps = {
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: "qa-center",
|
||||
label: "QA Center",
|
||||
label: "问答中心",
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "new-qa", label: "New QA", icon: Plus },
|
||||
{ id: "general-qa", label: "General QA", icon: CircleHelp },
|
||||
{ id: "agent-qa", label: "Agent QA", icon: Bot },
|
||||
{ id: "multi-agent-qa", label: "Multi Agent", icon: Layers },
|
||||
{ id: "sixstep-chat", label: "Six Step Chat", icon: Workflow },
|
||||
{ id: "qa-records", label: "QA Records", icon: History },
|
||||
{ id: "template-editor", label: "Template Config", icon: Settings },
|
||||
{ id: "new-qa", label: "新建问答", icon: Plus },
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/strategy/qa/general" },
|
||||
{ id: "agent-qa", label: "智能体问答", icon: Bot },
|
||||
{ id: "multi-agent-qa", label: "多智能体", icon: Layers },
|
||||
{ id: "sixstep-chat", label: "六步对话", icon: Workflow },
|
||||
{ id: "qa-records", label: "问答记录", icon: History },
|
||||
{ id: "template-editor", label: "模板配置", icon: Settings },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "light-app-center",
|
||||
label: "Light Apps",
|
||||
label: "轻应用",
|
||||
icon: Grid3X3,
|
||||
children: [
|
||||
{ id: "light-app-square", label: "App Square", icon: Layout },
|
||||
{ id: "light-app-records", label: "Run Records", icon: History },
|
||||
{ id: "light-app-square", label: "应用广场", icon: Layout },
|
||||
{ id: "light-app-records", label: "运行记录", icon: History },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "task-management",
|
||||
label: "Task Management",
|
||||
label: "任务管理",
|
||||
icon: Target,
|
||||
children: [
|
||||
{ id: "dashboard", label: "Dashboard", icon: BarChart3 },
|
||||
{ id: "dashboard", label: "数据看板", icon: BarChart3 },
|
||||
{
|
||||
id: "xk-task",
|
||||
label: "XK Tasks",
|
||||
label: "XK任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "xk-task-jc", label: "Task JC", icon: ListTodo },
|
||||
{ id: "xk-task-dispatch", label: "Task Dispatch", icon: ListTodo },
|
||||
{ id: "xk-task-jc", label: "任务JC", icon: ListTodo },
|
||||
{ id: "xk-task-dispatch", label: "任务调度", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "plan-task",
|
||||
label: "Planning Tasks",
|
||||
label: "规划任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "plan-task-fx", label: "Task Analysis", icon: ListTodo },
|
||||
{ id: "plan-task-action", label: "Action Strategy", icon: ListTodo },
|
||||
{ id: "plan-task-fx", label: "任务分析", icon: ListTodo },
|
||||
{ id: "plan-task-action", label: "行动策略", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mnty-task",
|
||||
label: "MNTY Tasks",
|
||||
label: "MNTY任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "mnty-task-mechanism", label: "Mechanism Study", icon: ListTodo },
|
||||
{ id: "mnty-task-plan", label: "Plan Task", icon: ListTodo },
|
||||
{ id: "mnty-task-zsty", label: "ZSTY Task", icon: ListTodo },
|
||||
{ id: "mnty-task-mechanism", label: "机制研究", icon: ListTodo },
|
||||
{ id: "mnty-task-plan", label: "规划任务", icon: ListTodo },
|
||||
{ id: "mnty-task-zsty", label: "ZSTY任务", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "effect-task",
|
||||
label: "Effect PG Tasks",
|
||||
label: "效果PG任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "effect-task-pg", label: "Task PG", icon: ListTodo },
|
||||
{ id: "effect-action-pg", label: "Action PG", icon: ListTodo },
|
||||
{ id: "effect-info-pg", label: "Info PG", icon: ListTodo },
|
||||
{ id: "effect-account-pg", label: "Account PG", icon: ListTodo },
|
||||
{ id: "effect-task-pg", label: "任务PG", icon: ListTodo },
|
||||
{ id: "effect-action-pg", label: "行动PG", icon: ListTodo },
|
||||
{ id: "effect-info-pg", label: "信息PG", icon: ListTodo },
|
||||
{ id: "effect-account-pg", label: "账号PG", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{ id: "task-create", label: "Create Task", icon: Plus },
|
||||
{ id: "task-single", label: "Single Agent Task", icon: Bot },
|
||||
{ id: "task-scene", label: "Scene Task", icon: Workflow },
|
||||
{ id: "task-consult-create", label: "Multi Agent Consult", icon: MessageSquare },
|
||||
{ id: "final-plan", label: "Final Output", icon: FileOutput },
|
||||
{ id: "task-create", label: "创建任务", icon: Plus },
|
||||
{ id: "task-single", label: "单智能体任务", icon: Bot },
|
||||
{ id: "task-scene", label: "场景任务", icon: Workflow },
|
||||
{ id: "task-consult-create", label: "多智能体协商", icon: MessageSquare },
|
||||
{ id: "final-plan", label: "最终输出", icon: FileOutput },
|
||||
],
|
||||
},
|
||||
{ id: "scene-list", label: "Scene Management", icon: Layers },
|
||||
{ id: "scene-list", label: "场景管理", icon: Layers },
|
||||
{
|
||||
id: "agent-list",
|
||||
label: "Agent Management",
|
||||
label: "智能体管理",
|
||||
icon: Bot,
|
||||
children: [
|
||||
{ id: "agent-overview", label: "Agent List", icon: Users },
|
||||
{ id: "agent-trial", label: "Single Agent Trial", icon: PlayCircle },
|
||||
{ id: "agent-overview", label: "智能体列表", icon: Users, path: "/page/strategy/agents" },
|
||||
{ id: "agent-trial", label: "单智能体试用", icon: PlayCircle },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-management",
|
||||
label: "Result Management",
|
||||
label: "结果管理",
|
||||
icon: BarChart3,
|
||||
children: [
|
||||
{
|
||||
id: "result-planning",
|
||||
label: "Planning Results",
|
||||
label: "规划结果",
|
||||
icon: Target,
|
||||
children: [
|
||||
{ id: "result-planning-all", label: "All", icon: BarChart3 },
|
||||
{ id: "result-planning-goal", label: "Goal Analysis", icon: Target },
|
||||
{ id: "result-planning-brand", label: "Brand Strategy", icon: Target },
|
||||
{ id: "result-planning-content", label: "Content Planning", icon: Target },
|
||||
{ id: "result-planning-channel", label: "Channel Planning", icon: Target },
|
||||
{ id: "result-planning-narrative", label: "Narrative Strategy", icon: Target },
|
||||
{ id: "result-planning-all", label: "全部", icon: BarChart3 },
|
||||
{ id: "result-planning-goal", label: "目标分析", icon: Target },
|
||||
{ id: "result-planning-brand", label: "品牌策略", icon: Target },
|
||||
{ id: "result-planning-content", label: "内容规划", icon: Target },
|
||||
{ id: "result-planning-channel", label: "渠道规划", icon: Target },
|
||||
{ id: "result-planning-narrative", label: "叙事策略", icon: Target },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-mnty",
|
||||
label: "MNTY Results",
|
||||
label: "MNTY结果",
|
||||
icon: Brain,
|
||||
children: [
|
||||
{ id: "result-mnty-all", label: "All", icon: BarChart3 },
|
||||
{ id: "result-mnty-mechanism", label: "Mechanism Research", icon: Brain },
|
||||
{ id: "result-mnty-plan", label: "Plan Design", icon: Brain },
|
||||
{ id: "result-mnty-zsty", label: "ZSTY Analysis", icon: Brain },
|
||||
{ id: "result-mnty-all", label: "全部", icon: BarChart3 },
|
||||
{ id: "result-mnty-mechanism", label: "机制研究", icon: Brain },
|
||||
{ id: "result-mnty-plan", label: "规划设计", icon: Brain },
|
||||
{ id: "result-mnty-zsty", label: "ZSTY分析", icon: Brain },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-effect-pg",
|
||||
label: "Effect PG Results",
|
||||
label: "效果PG结果",
|
||||
icon: TrendingUp,
|
||||
children: [
|
||||
{ id: "result-effect-pg-all", label: "All", icon: BarChart3 },
|
||||
{ id: "result-effect-pg-behavior", label: "Behavior PG", icon: TrendingUp },
|
||||
{ id: "result-effect-pg-evaluation", label: "Effect Review", icon: TrendingUp },
|
||||
{ id: "result-effect-pg-info", label: "Info PG", icon: TrendingUp },
|
||||
{ id: "result-effect-pg-account", label: "Account PG", icon: TrendingUp },
|
||||
{ id: "result-effect-pg-all", label: "全部", icon: BarChart3 },
|
||||
{ id: "result-effect-pg-behavior", label: "行为PG", icon: TrendingUp },
|
||||
{ id: "result-effect-pg-evaluation", label: "效果复盘", icon: TrendingUp },
|
||||
{ id: "result-effect-pg-info", label: "信息PG", icon: TrendingUp },
|
||||
{ id: "result-effect-pg-account", label: "账号PG", icon: TrendingUp },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-xk",
|
||||
label: "XK Results",
|
||||
label: "XK结果",
|
||||
icon: Compass,
|
||||
children: [
|
||||
{ id: "result-xk-all", label: "All", icon: BarChart3 },
|
||||
{ id: "result-xk-jc", label: "Task JC", icon: Compass },
|
||||
{ id: "result-xk-dispatch", label: "Task Dispatch", icon: Compass },
|
||||
{ id: "result-xk-all", label: "全部", icon: BarChart3 },
|
||||
{ id: "result-xk-jc", label: "任务JC", icon: Compass },
|
||||
{ id: "result-xk-dispatch", label: "任务调度", icon: Compass },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-dq",
|
||||
label: "DQ Results",
|
||||
label: "DQ结果",
|
||||
icon: Database,
|
||||
children: [
|
||||
{ id: "result-dq-all", label: "All", icon: BarChart3 },
|
||||
{ id: "result-dq-audience", label: "Audience Analysis", icon: Database },
|
||||
{ id: "result-dq-kb", label: "Knowledge Research", icon: Database },
|
||||
{ id: "result-dq-trend", label: "Trend Research", icon: Database },
|
||||
{ id: "result-dq-all", label: "全部", icon: BarChart3 },
|
||||
{ id: "result-dq-audience", label: "受众分析", icon: Database },
|
||||
{ id: "result-dq-kb", label: "知识研究", icon: Database },
|
||||
{ id: "result-dq-trend", label: "趋势研究", icon: Database },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-other",
|
||||
label: "Other Results",
|
||||
label: "其他结果",
|
||||
icon: MoreHorizontal,
|
||||
children: [
|
||||
{ id: "result-other-all", label: "All", icon: BarChart3 },
|
||||
{ id: "result-other-general", label: "General Analysis", icon: MoreHorizontal },
|
||||
{ id: "result-other-temp", label: "Temp Tasks", icon: MoreHorizontal },
|
||||
{ id: "result-other-other", label: "Other", icon: MoreHorizontal },
|
||||
{ id: "result-other-all", label: "全部", icon: BarChart3 },
|
||||
{ id: "result-other-general", label: "通用分析", icon: MoreHorizontal },
|
||||
{ id: "result-other-temp", label: "临时任务", icon: MoreHorizontal },
|
||||
{ id: "result-other-other", label: "其他", icon: MoreHorizontal },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "resource-management",
|
||||
label: "Resource Management",
|
||||
label: "资源管理",
|
||||
icon: Database,
|
||||
children: [
|
||||
{ id: "resource-zz", label: "ZZ Resources", icon: FileBox },
|
||||
{ id: "resource-network-actor", label: "Network Actor", icon: Network },
|
||||
{ id: "resource-knowledge-base", label: "Knowledge Base", icon: Library },
|
||||
{ id: "resource-evaluation-index", label: "Evaluation Index", icon: Scale },
|
||||
{ id: "resource-zz", label: "ZZ资源", icon: FileBox },
|
||||
{ id: "resource-network-actor", label: "网络达人", icon: Network },
|
||||
{ id: "resource-knowledge-base", label: "知识库", icon: Library },
|
||||
{ id: "resource-evaluation-index", label: "评估指标", icon: Scale },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -233,6 +235,8 @@ export function PageSidebar({
|
||||
onToggleCollapsed,
|
||||
}: PageSidebarProps) {
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(defaultExpandedIds);
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const iconButtonClass =
|
||||
"rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600";
|
||||
@@ -256,14 +260,20 @@ export function PageSidebar({
|
||||
|
||||
const renderLeaf = (item: MenuItem, depth = 0) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = !!item.path && pathname === item.path;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
title={collapsed && depth === 0 ? item.label : undefined}
|
||||
className={`flex w-full items-center rounded-lg py-2 text-left text-gray-600 transition-all duration-300 ease-out hover:-translate-y-0.5 hover:bg-red-50 hover:text-red-600 ${
|
||||
onClick={() => item.path && navigate(item.path)}
|
||||
className={`flex w-full items-center rounded-lg py-2 text-left transition-all duration-300 ease-out hover:-translate-y-0.5 ${
|
||||
collapsed && depth === 0 ? "justify-center px-2" : "gap-2 px-3"
|
||||
} ${
|
||||
isActive
|
||||
? "bg-red-50 text-red-600 font-semibold"
|
||||
: "text-gray-600 hover:bg-red-50 hover:text-red-600"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`${depth === 0 ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out`} />
|
||||
|
||||
@@ -53,6 +53,10 @@ function useSidebar() {
|
||||
return context;
|
||||
}
|
||||
|
||||
function useSidebarSafe() {
|
||||
return React.useContext(SidebarContext);
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
@@ -229,7 +233,7 @@ function Sidebar({
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed top-16 bottom-0 z-10 hidden 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"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
@@ -723,4 +727,5 @@ export {
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
useSidebarSafe,
|
||||
};
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||
import { env } from "@/env";
|
||||
|
||||
export interface ArtifactsContextType {
|
||||
@@ -39,19 +39,19 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
const [autoOpen, setAutoOpen] = useState(true);
|
||||
const { setOpen: setSidebarOpen } = useSidebar();
|
||||
const sidebar = useSidebarSafe();
|
||||
|
||||
const select = useCallback(
|
||||
(artifact: string, autoSelect = false) => {
|
||||
setSelectedArtifact(artifact);
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
|
||||
setSidebarOpen(false);
|
||||
sidebar?.setOpen(false);
|
||||
}
|
||||
if (!autoSelect) {
|
||||
setAutoSelect(false);
|
||||
}
|
||||
},
|
||||
[setSidebarOpen, setSelectedArtifact, setAutoSelect],
|
||||
[sidebar, setSelectedArtifact, setAutoSelect],
|
||||
);
|
||||
|
||||
const deselect = useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 后端 API 默认地址
|
||||
*/
|
||||
export const DEFAULT_BACKEND_URL = 'http://10.26.128.38:8030';
|
||||
|
||||
/**
|
||||
* 默认前端配置
|
||||
*
|
||||
* 包含:
|
||||
* - coordinator_config: 协调者各阶段策略提示
|
||||
* - agents: 默认四智能体(背景研判师、目的策略师、受众分析师、叙事策划师)
|
||||
* - report_schema: 六字段报告结构定义
|
||||
* - report_examples: 各字段的示例输出(问界 M7 事故场景)
|
||||
* - field_requirements: 字段依赖关系定义
|
||||
*/
|
||||
export const DEFAULT_CONFIG = {
|
||||
task_context: "",
|
||||
llm_preset_id: "",
|
||||
consistency_mode: "soft",
|
||||
coordinator_mode: "auto",
|
||||
coordinator_config: {
|
||||
system_prompt_prefix: "",
|
||||
round1_guidance: `这是发散阶段第1轮,草稿刚起步。
|
||||
策略:优先填写草稿中空缺最关键的字段,同时识别智能体之间最尖锐的分歧——
|
||||
这些分歧往往意味着草稿中某个判断还无法做出,需要通过交锋来解决。`,
|
||||
middle_round_guidance: `这是发散阶段的中间轮次。
|
||||
策略:审查草稿现状,找出仍然太模糊或有冲突的字段,
|
||||
选择能推进这些字段的智能体,给出让草稿变得更具体的任务。`,
|
||||
final_round_guidance: `这是发散阶段最后一轮。
|
||||
策略:草稿必须在这轮后达到可以收尾的状态,但仍要求智能体给出完整材料包,
|
||||
包括明确判断、依据链、反例、风险边界和可核验线索;最终压缩、取舍和成文由协调者完成。`,
|
||||
speaker_selection_rules: "优先选择最能扩充 partial/blocked 字段材料、解决关键冲突、补充证据或论证链的智能体。",
|
||||
focus_instruction_rules: "focus 必须写成内容型任务书,不能只催促 agent 继续分析。每条至少包含:目标字段、已有材料或草稿判断、真正缺口、为什么该缺口会阻塞报告、需要补充的具体材料类型、建议切入角度、预期输出形态、风险或禁止事项。指令应鼓励 agent 展开材料、证据线索、论证链、反例、分歧和边界条件,而不是只给短结论。",
|
||||
revise_prompt_prefix: ""
|
||||
},
|
||||
agents: [
|
||||
{
|
||||
id: "situation",
|
||||
name: "背景研判师",
|
||||
dimension: "背景研判",
|
||||
expertise: "你是一位资深的舆情和战略分析专家。你的任务是判断当前舆论场的格局:现在存在哪几条叙事,各自的势头如何,哪些认知锚点可以被打散,哪些比较维度可以被引入或重构,竞争对手的动作有哪些逻辑漏洞。不是做损失报告,而是做战略研判——找到可以操作的叙事空间。",
|
||||
},
|
||||
{
|
||||
id: "purpose",
|
||||
name: "目的策略师",
|
||||
dimension: "传播目标",
|
||||
expertise: "你是一位认知传播专家,专门设计可实现的传播目标——即通过传播内容能够在受众头脑中建立的具体认知或情绪。你不关心商业结果,只关心:这次传播结束后,目标受众的想法和感受会发生什么具体变化?用第一人称写出目标受众在成功传播后的内心独白。也可以设计竞争性目标(让受众对竞品产生质疑)。",
|
||||
},
|
||||
{
|
||||
id: "audience",
|
||||
name: "受众分析师",
|
||||
dimension: "受众图谱",
|
||||
expertise: "你是一位受众研究专家。你的任务是识别具体的、可操作的目标受众群体。拒绝泛化:不接受'年轻人''汽车爱好者'这类描述,必须给出年龄段、性别比例、城市层级、具体平台和具体账号/社区名称。同时要写出该群体当前的心理状态——他们现在在想什么,核心疑虑是什么,用第一人称写出他们的内心独白。",
|
||||
},
|
||||
{
|
||||
id: "narrative",
|
||||
name: "叙事策划师",
|
||||
dimension: "叙事方案",
|
||||
expertise: "你是一位顶尖的叙事战略家和内容策划师。你的任务是设计具体的叙事支柱:每个支柱必须包含(1)具体内容——发什么、怎么说,给出可执行的内容方向或文案草稿;(2)为什么这么设计——预期的心理机制;(3)预期效果——目标受众会产生什么认知变化;(4)风险与局限——在什么条件下会失效或反噬。叙事框架和具体消息写在一起,不要分开。",
|
||||
}
|
||||
],
|
||||
phase_config: { diverge_rounds: 3 },
|
||||
template_id: "",
|
||||
template_name: "",
|
||||
template_brief: "",
|
||||
report_schema: {
|
||||
situation_assessment: "背景研判:当前舆论场/市场/竞争格局的战略研判。包含现有几条并行叙事及其势头、可以被打散的认知锚点、竞争对手动作的逻辑漏洞、可操作的叙事空间。不是损失报告,是战略判断。",
|
||||
communication_objectives: "传播目标:2-4个本方案期望在受众中建立的具体认知或情绪,用目标受众的第一人称内心独白表达。不写商业结果,不写指标,只写认知和情绪目标。可含竞争性目标。",
|
||||
audience_map: "核心受众:2-4个具体受众群体,每个包含:(1)人口画像——年龄段、性别、城市层级、具体特征;(2)当前心理状态——用第一人称内心独白;(3)关键信息节点——具体平台和账号/社区名称,不接受泛化描述。",
|
||||
narrative_plan: "叙事方案:2-3个叙事支柱,每个包含:(1)具体内容——可执行的内容方向或文案草稿;(2)为什么这么设计——预期心理机制;(3)预期效果——目标受众的认知变化;(4)风险与局限——失效条件。叙事框架和消息合并在一起。",
|
||||
action_plan: "分阶段行动计划:按时间线的3-4个阶段,每个行动具体到:做什么、谁执行(官方/第三方/独立账号)、何时。",
|
||||
risk_playbook: "风险预案:3-5个风险场景,每个含触发条件、具体应对步骤、明确禁止的动作。"
|
||||
},
|
||||
report_examples: {
|
||||
situation_assessment: `事故发生后,舆论场中存在三条并行叙事:
|
||||
- 主流情绪叙事(当前占主导):"电动车关门困死人"——聚焦电动门锁这一单一缺陷,将问界塑造为主动伤人的产品
|
||||
- 竞品借势叙事:理想、小米相继发声强调"物理门把手不会消失",将安全心智差异化
|
||||
- 技术中性叙事(目前声量极弱):部分汽车工程师账号指出燃油车追尾同样可能起火且无法逃生,但未被放大
|
||||
|
||||
核心判断:目前的"关门困死人"叙事对问界的伤害远超事故本身,因为它指向的是所有电动车门锁的系统性问题,而非这次追尾的偶发性。叙事反转的关键在于:将"问界门锁缺陷"重新定框为"追尾高速起火是极端工况,不同于日常安全风险",同时打开"燃油车在同等追尾条件下结果是否更好"这一比较维度。
|
||||
|
||||
此外,竞品的"物理门把手"营销动作,实质上是在暗示所有纯电动车都有同等风险——问界可以借助行业数据,将这一模糊指控反向用于质疑竞品的安全宣传逻辑。`,
|
||||
|
||||
communication_objectives: `目标一:让现有问界车主相信此次事故属于极端偶发工况
|
||||
> "这是一辆车被高速追尾、车身严重变形后才发生的,不是日常开车会遇到的情况,和我每天通勤的安全性是两件事。"
|
||||
|
||||
目标二:让公众了解燃油车在同等追尾工况下的起火概率并不低于电动车
|
||||
> "我以为电动车特别容易起火,看了数据才知道燃油车追尾也会爆炸,而且逃生时间更短。这个问题不是问界独有的。"
|
||||
|
||||
目标三:让潜在购车者相信问界已做出真实的技术改进,而非只是公关操作
|
||||
> "他们不只是道歉,而是真的改了——门锁逻辑改了,还加了物理把手,有第三方测了。这和之前那些只道歉不改的品牌不一样。"
|
||||
|
||||
目标四(竞争性):在科技圈和华为生态用户中种下"竞品安全宣传是过度营销"的质疑
|
||||
> "理想说保留物理门把手就安全了?那燃油车追尾不也照样困死人,他们这是蹭热度。"`,
|
||||
|
||||
audience_map: `受众一:现有问界M7车主(最优先)
|
||||
- 画像:25-45岁男性为主(问界买家中男性占比约75%),华为手机深度用户,在二三线城市比例高于理想/小米,家庭用车场景为主
|
||||
- 当前心理状态:"在家人追问压力下动摇,既不想认错(承认买错了),又需要一个可以说服家人的说法"
|
||||
- 关键触点:AITO官方App推送、华为花粉俱乐部社区(注册用户2000万+)、各省市问界车主微信群
|
||||
|
||||
受众二:考虑30万内家用SUV的25-38岁已婚男性(主要流失群体)
|
||||
- 画像:集中在北京、上海、成都、杭州、武汉;在懂车帝/汽车之家浏览过问界M7、理想L7对比页面;家庭有6岁以下子女的比例约40%,配偶对选车有实质否决权
|
||||
- 当前心理状态:"事故前已接近下定,现在进入观望,主要疑虑是'安全有保障吗',在等一个可以放心的理由"
|
||||
- 关键触点:懂车帝的问界M7评测区(日均浏览约4万次)、B站@42号车库(汽车技术向,260万粉)、@老司机测评(抖音+微博,180万粉)`,
|
||||
|
||||
narrative_plan: `叙事支柱一:工况重构——把"电动门锁缺陷"扩展为"追尾高速起火"议题
|
||||
|
||||
具体内容:发布或推动媒体发布《追尾高速事故生存数据分析》,引用可验证的事故数据:中国高速追尾事故中燃油车起火比例、燃油车在高速追尾后因油箱破裂导致无法逃生的案例、断电门锁问题在行业内的普遍性(其他品牌纯电车是否存在类似设计)。
|
||||
|
||||
为什么这么设计:公众对"电动车起火"的恐惧,部分来自"燃油车才是安全默认值"的认知锚点。此叙事目标不是洗白问界,而是打散原有的比较基准——把"问界门锁失效"从"品牌失职"变成"高速追尾这一极端工况下的行业性挑战"。
|
||||
|
||||
预期效果:目标受众在搜索"问界安全"时,搜到的不是事故视频,而是工况对比分析,认知从"问界车会困死人"转向"高速追尾是极端事故,概率极低"。
|
||||
|
||||
风险与局限:如果数据无法支持"燃油车风险不低于电动车"的结论,此叙事会反噬——数据必须真实可查。部分受众会将此解读为"转移焦点"。`,
|
||||
|
||||
action_plan: `第一阶段:止血(D+0 至 D+3)
|
||||
- D+0:余承东删除原有争议微博。AITO官方发布声明,核心措辞:承认断电门锁释放机制有改进空间,公布将与第三方机构联合调查,暂停全部品牌宣传广告
|
||||
- D+1:通过APP+服务顾问向全体问界M7车主发送一对一信息,提供免费安全检测预约和免费代步车
|
||||
- D+2:联系@42号车库、@老司机测评,提供完整的碰撞技术资料(不要求立场,只提供信息)
|
||||
|
||||
第二阶段:叙事建立(第1-4周)
|
||||
- 第1周:推动@42号车库发布《追尾高速起火,燃油车和电动车谁更危险?》技术向内容,问界提供数据但不参与内容制作
|
||||
- 第2周:举办技术说明会,邀请10家垂直媒体,公布OTA升级方案和物理把手加装方案详细规格
|
||||
- 第3周:中国汽研开始改进后车辆的独立测试,全程开放媒体记者跟拍`,
|
||||
|
||||
risk_playbook: `风险一:12周内发生第二起相似事故
|
||||
- 触发条件:同类门锁失效导致的伤亡事故再次出现
|
||||
- 应对步骤:①立即暂停全部销售;②主动召回所有已售问界M7,不等监管要求;③停止所有叙事支柱三(竞品对比)内容的推进
|
||||
- 明确禁止:不得发布任何暗示"概率极低"的安抚性内容
|
||||
|
||||
风险二:数据核查显示燃油车追尾起火比例低于电动车
|
||||
- 触发条件:第三方核查发现叙事支柱一中引用的数据不成立
|
||||
- 应对步骤:①立即停止涉及数据比较的传播;②调整叙事回归"我们改进了"主线,放弃工况比较维度
|
||||
- 明确禁止:不得继续引用已被质疑的数据,不得删除原文(会被截图放大)`
|
||||
},
|
||||
field_requirements: {
|
||||
communication_objectives: {
|
||||
title: "传播目标",
|
||||
dependencies: ["situation_assessment"],
|
||||
},
|
||||
audience_map: {
|
||||
title: "核心受众",
|
||||
dependencies: ["communication_objectives"],
|
||||
},
|
||||
narrative_plan: {
|
||||
title: "叙事方案",
|
||||
dependencies: ["communication_objectives", "audience_map"],
|
||||
},
|
||||
action_plan: {
|
||||
title: "分阶段行动计划",
|
||||
dependencies: ["communication_objectives", "audience_map", "narrative_plan"],
|
||||
},
|
||||
risk_playbook: {
|
||||
title: "风险预案",
|
||||
dependencies: ["narrative_plan", "action_plan"],
|
||||
},
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
/** localStorage 存储前缀,用于模板覆盖数据的 key */
|
||||
const OVERRIDE_PREFIX = 'mope_template_override:';
|
||||
|
||||
/**
|
||||
* 深拷贝对象
|
||||
* 使用 JSON 序列化/反序列化实现简单深拷贝,
|
||||
* 适用于纯数据的配置对象(不含函数、循环引用等)。
|
||||
*/
|
||||
export function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从后端加载模板索引列表
|
||||
* @returns {Promise<Array>} 模板摘要数组 [{ template_id, template_name, file }]
|
||||
*/
|
||||
export async function loadTemplateIndex() {
|
||||
const response = await fetch('/templates/index.json');
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
return data.templates || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载完整模板数据
|
||||
* 优先使用 localStorage 中的用户覆盖版本,
|
||||
* 否则从后端静态文件加载。
|
||||
*
|
||||
* @param {string|Object} metaOrId - 模板 ID 字符串或包含 template_id/file 的元数据对象
|
||||
* @returns {Promise<Object>} 完整模板对象
|
||||
*/
|
||||
export async function loadTemplateByMeta(metaOrId) {
|
||||
const templateId = typeof metaOrId === 'string' ? metaOrId : metaOrId?.template_id;
|
||||
const file = typeof metaOrId === 'string' ? `/templates/${templateId}.json` : metaOrId?.file;
|
||||
const response = await fetch(file || `/templates/${templateId}.json`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const template = await response.json();
|
||||
const override = getTemplateOverride(template.template_id || templateId);
|
||||
return override ? clone(override) : template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载原始模板(忽略用户覆盖)
|
||||
* @param {string|Object} metaOrId - 模板标识
|
||||
* @returns {Promise<Object>} 后端静态模板
|
||||
*/
|
||||
export async function loadOriginalTemplate(metaOrId) {
|
||||
const templateId = typeof metaOrId === 'string' ? metaOrId : metaOrId?.template_id;
|
||||
const file = typeof metaOrId === 'string' ? `/templates/${templateId}.json` : metaOrId?.file;
|
||||
const response = await fetch(file || `/templates/${templateId}.json`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户在 localStorage 中保存的模板覆盖数据
|
||||
* @param {string} templateId - 模板 ID
|
||||
* @returns {Object|null} 覆盖的模板对象,未覆盖时返回 null
|
||||
*/
|
||||
export function getTemplateOverride(templateId) {
|
||||
if (!templateId) return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(`${OVERRIDE_PREFIX}${templateId}`);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断指定模板是否已被用户覆盖修改
|
||||
* @param {string} templateId - 模板 ID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasTemplateOverride(templateId) {
|
||||
return Boolean(getTemplateOverride(templateId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存模板覆盖到 localStorage
|
||||
* @param {string} templateId - 模板 ID
|
||||
* @param {Object} template - 修改后的模板对象
|
||||
*/
|
||||
export function saveTemplateOverride(templateId, template) {
|
||||
if (!templateId) throw new Error('缺少 template_id');
|
||||
localStorage.setItem(`${OVERRIDE_PREFIX}${templateId}`, JSON.stringify(template));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定模板的覆盖数据,恢复为后端原始版本
|
||||
* @param {string} templateId - 模板 ID
|
||||
*/
|
||||
export function clearTemplateOverride(templateId) {
|
||||
if (!templateId) return;
|
||||
localStorage.removeItem(`${OVERRIDE_PREFIX}${templateId}`);
|
||||
}
|
||||
@@ -0,0 +1,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],
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useContext } from "react";
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import { RouteBaseContext } from "@/core/route-base";
|
||||
import type { AgentThread, AgentThreadContext } from "./types";
|
||||
|
||||
type ThreadAgentRef = Pick<AgentThreadContext, "agent_id" | "agent_name">;
|
||||
@@ -28,6 +30,7 @@ function pickAgentRef(
|
||||
export function pathOfThread(
|
||||
thread: ThreadRouteTarget,
|
||||
context?: ThreadAgentRef | null,
|
||||
base = "/page/workspace",
|
||||
) {
|
||||
const threadId = typeof thread === "string" ? thread : thread.thread_id;
|
||||
const agentRef =
|
||||
@@ -36,8 +39,14 @@ export function pathOfThread(
|
||||
: pickAgentRef(thread.context, thread.metadata);
|
||||
|
||||
return agentRef
|
||||
? `/page/workspace/agents/${encodeURIComponent(agentRef)}/chats/${threadId}`
|
||||
: `/page/workspace/chats/${threadId}`;
|
||||
? `${base}/agents/${encodeURIComponent(agentRef)}/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) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
export { useLocalStorage } from './useLocalStorage';
|
||||
export { useWindowSize, useIsMobile } from './useWindowSize';
|
||||
export { useDebounce, useDebounceCallback } from './useDebounce';
|
||||
export { useToggle } from './useToggle';
|
||||
export { useAsync } from './useAsync';
|
||||
export { useChatHistory } from './useChatHistory';
|
||||
export { useExpertAuthBootstrap } from './useExpertAuthBootstrap';
|
||||
export type { LlmItem, CurrentUser } from './useExpertAuthBootstrap';
|
||||
export type { ChatMessage } from './useChatHistory';
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
interface AsyncState<T> {
|
||||
data: T | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function useAsync<T>() {
|
||||
const [state, setState] = useState<AsyncState<T>>({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const execute = useCallback(async (asyncFunction: () => Promise<T>) => {
|
||||
setState({ data: null, loading: true, error: null });
|
||||
try {
|
||||
const data = await asyncFunction();
|
||||
setState({ data, loading: false, error: null });
|
||||
return data;
|
||||
} catch (error) {
|
||||
setState({ data: null, loading: false, error: error as Error });
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setState({ data: null, loading: false, error: null });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
execute,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface UseChatHistoryReturn {
|
||||
messages: ChatMessage[];
|
||||
addMessage: (role: 'user' | 'assistant', content: string) => void;
|
||||
clearMessages: () => void;
|
||||
deleteMessage: (id: string) => void;
|
||||
updateMessage: (id: string, content: string) => void;
|
||||
}
|
||||
|
||||
export function useChatHistory(storageKey: string = 'chat-history'): UseChatHistoryReturn {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const saveToStorage = useCallback((newMessages: ChatMessage[]) => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(newMessages));
|
||||
} catch (error) {
|
||||
console.error('Failed to save chat history:', error);
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
const addMessage = useCallback((role: 'user' | 'assistant', content: string) => {
|
||||
const newMessage: ChatMessage = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
role,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev, newMessage];
|
||||
saveToStorage(updated);
|
||||
return updated;
|
||||
});
|
||||
}, [saveToStorage]);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
localStorage.removeItem(storageKey);
|
||||
}, [storageKey]);
|
||||
|
||||
const deleteMessage = useCallback((id: string) => {
|
||||
setMessages((prev) => {
|
||||
const updated = prev.filter((msg) => msg.id !== id);
|
||||
saveToStorage(updated);
|
||||
return updated;
|
||||
});
|
||||
}, [saveToStorage]);
|
||||
|
||||
const updateMessage = useCallback((id: string, content: string) => {
|
||||
setMessages((prev) => {
|
||||
const updated = prev.map((msg) =>
|
||||
msg.id === id ? { ...msg, content } : msg
|
||||
);
|
||||
saveToStorage(updated);
|
||||
return updated;
|
||||
});
|
||||
}, [saveToStorage]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
addMessage,
|
||||
clearMessages,
|
||||
deleteMessage,
|
||||
updateMessage,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
export function useDebounce<T>(value: T, delay: number = 300): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
export function useDebounceCallback<T extends (...args: any[]) => any>(
|
||||
callback: T,
|
||||
delay: number = 300
|
||||
): T {
|
||||
const callbackRef = useRef(callback);
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
const debouncedCallback = useCallback((...args: Parameters<T>) => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
callbackRef.current(...args);
|
||||
}, delay);
|
||||
}, [delay]) as T;
|
||||
|
||||
return debouncedCallback;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export interface LlmItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CurrentUser {
|
||||
id: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
is_verified: boolean;
|
||||
pages_limit: number;
|
||||
pages_used: number;
|
||||
}
|
||||
|
||||
type RequestInfoLike = Parameters<typeof fetch>[0];
|
||||
type RequestInitLike = Parameters<typeof fetch>[1];
|
||||
|
||||
const LOGIN_URL = 'http://47.88.25.99:8000/auth/jwt/login';
|
||||
const LLMS_URL = 'http://47.88.25.99:8000/api/v1/expert-agents/llms';
|
||||
const ME_URL = 'http://47.88.25.99:8000/users/me';
|
||||
|
||||
export function useExpertAuthBootstrap() {
|
||||
const [llmList, setLlmList] = useState<LlmItem[]>([]);
|
||||
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const authedFetch = useCallback(async (input: RequestInfoLike, init?: RequestInitLike) => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const headers = new Headers(init?.headers);
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
return fetch(input, { ...init, headers });
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('username', 'dhk@qq.com');
|
||||
formData.append('password', '1qaz@WSX');
|
||||
formData.append('grant_type', 'password');
|
||||
|
||||
const res = await fetch(LOGIN_URL, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`登录失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
if (data?.access_token) {
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchLlmList = useCallback(async () => {
|
||||
const res = await authedFetch(LLMS_URL, { method: 'GET' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`llms 接口失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const data: unknown = await res.json();
|
||||
const list = Array.isArray(data) ? (data as LlmItem[]) : [];
|
||||
setLlmList(list);
|
||||
return list;
|
||||
}, [authedFetch]);
|
||||
|
||||
const fetchCurrentUser = useCallback(async () => {
|
||||
const res = await authedFetch(ME_URL, { method: 'GET' });
|
||||
if (!res.ok) {
|
||||
throw new Error(`users/me 接口失败: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
const data: unknown = await res.json();
|
||||
const user = (data as CurrentUser) ?? null;
|
||||
setCurrentUser(user);
|
||||
return user;
|
||||
}, [authedFetch]);
|
||||
|
||||
const bootstrap = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await login();
|
||||
await Promise.all([fetchLlmList(), fetchCurrentUser()]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fetchCurrentUser, fetchLlmList, login]);
|
||||
|
||||
useEffect(() => {
|
||||
void bootstrap().catch((error) => {
|
||||
console.error('专家中心初始化失败:', error);
|
||||
});
|
||||
}, [bootstrap]);
|
||||
|
||||
return {
|
||||
llmList,
|
||||
currentUser,
|
||||
loading,
|
||||
authedFetch,
|
||||
login,
|
||||
fetchLlmList,
|
||||
fetchCurrentUser,
|
||||
bootstrap,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useLocalStorage<T>(key: string, initialValue: T) {
|
||||
const [storedValue, setStoredValue] = useState<T>(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
console.error('Error reading from localStorage:', error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
const setValue = (value: T | ((val: T) => T)) => {
|
||||
try {
|
||||
const valueToStore = value instanceof Function ? value(storedValue) : value;
|
||||
setStoredValue(valueToStore);
|
||||
window.localStorage.setItem(key, JSON.stringify(valueToStore));
|
||||
} catch (error) {
|
||||
console.error('Error writing to localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return [storedValue, setValue] as const;
|
||||
}
|
||||
@@ -0,0 +1,852 @@
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* 会话初始状态
|
||||
*
|
||||
* status 枚举:
|
||||
* - 'idle': 未启动
|
||||
* - 'running': 正在运行(流式输出中)
|
||||
* - 'waiting_human': 等待人工确认(仅 human_review 模式)
|
||||
* - 'done': 分析完成
|
||||
* - 'error': 发生错误
|
||||
*
|
||||
* phase 枚举:
|
||||
* - 'starting': 启动阶段(第 1 轮,协调者分配初始任务)
|
||||
* - 'diverging': 辩论阶段(多智能体并行输出)
|
||||
* - 'closing': 收敛阶段(协调者整合草稿)
|
||||
* - 'done': 完成
|
||||
*/
|
||||
const initialState = {
|
||||
status: 'idle',
|
||||
sessionId: null,
|
||||
backendUrl: '',
|
||||
coordinatorMode: 'auto',
|
||||
phase: null,
|
||||
currentRound: 0,
|
||||
totalRounds: 0,
|
||||
divergeRounds: 0,
|
||||
reportSchema: {},
|
||||
agents: [],
|
||||
agentStates: {},
|
||||
coordinatorUpdates: [],
|
||||
latestDraft: {},
|
||||
sharedMemory: null,
|
||||
backgroundRetrieval: null,
|
||||
finalReport: null,
|
||||
finalReportPartial: {},
|
||||
currentFinalField: null,
|
||||
pendingReview: null,
|
||||
reviewSubmitting: false,
|
||||
reviewRevising: false,
|
||||
errors: [],
|
||||
timeline: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* 规范化运行时智能体配置
|
||||
*
|
||||
* 将模板中的原始 agents 数组转换为运行时格式,
|
||||
* 确保每个 agent 都包含所有必需字段(id、name、dimension、expertise 等)。
|
||||
*
|
||||
* @param {Array} agents - 原始智能体配置数组
|
||||
* @param {Object} reportSchema - 报告结构定义,用于推断 relevant_fields
|
||||
* @returns {Array} 规范化后的智能体数组
|
||||
*/
|
||||
function normalizeRuntimeAgents(agents = [], reportSchema = {}) {
|
||||
const allFields = Object.keys(reportSchema || {});
|
||||
|
||||
return agents.map((agent, index) => {
|
||||
const id = agent.id || `agent_${index + 1}`;
|
||||
const description = agent.description || agent.expertise || agent.dimension || '';
|
||||
const skill = agent.skill || agent.expertise || description;
|
||||
|
||||
return {
|
||||
id,
|
||||
name: agent.name || id,
|
||||
description,
|
||||
skill,
|
||||
dimension: agent.dimension || agent.name || id,
|
||||
scenario_role: agent.scenario_role || agent.expertise || description,
|
||||
relevant_fields: Array.isArray(agent.relevant_fields) && agent.relevant_fields.length > 0
|
||||
? agent.relevant_fields
|
||||
: allFields,
|
||||
analysis_contract: Array.isArray(agent.analysis_contract)
|
||||
? agent.analysis_contract
|
||||
: [
|
||||
'输出自然语言分析,不输出 JSON。',
|
||||
'充分展开材料、依据、反例、分歧和边界条件,最终收缩由协调者完成。',
|
||||
],
|
||||
retrieval_mode: agent.retrieval_mode || 'always',
|
||||
allowed_source_ids: agent.allowed_source_ids || [],
|
||||
retrieval_instruction: agent.retrieval_instruction || '优先检索与本轮任务相关的最新新闻、公开事实和可核验线索。',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建发送给后端的模板对象
|
||||
*/
|
||||
function buildTemplate(config) {
|
||||
return {
|
||||
template_id: config.template_id,
|
||||
template_name: config.template_name || '',
|
||||
template_brief: config.template_brief || '',
|
||||
consistency_mode: config.consistency_mode || 'free',
|
||||
report_schema: config.report_schema || {},
|
||||
report_examples: config.report_examples || {},
|
||||
field_requirements: config.field_requirements || {},
|
||||
coordinator_config: config.coordinator_config || {},
|
||||
agent_adaptation_policy: config.agent_adaptation_policy || {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建发送给后端的完整 session 请求体
|
||||
*/
|
||||
function buildSessionPayload(config) {
|
||||
const reportSchema = config.report_schema || {};
|
||||
const reviewMode = config.coordinator_mode || config.review_mode || 'auto';
|
||||
|
||||
return {
|
||||
task_context: config.task_context || '',
|
||||
template: buildTemplate(config),
|
||||
agents: normalizeRuntimeAgents(config.agents || [], reportSchema),
|
||||
llm_preset_id: config.llm_preset_id || '',
|
||||
phase_config: config.phase_config || { diverge_rounds: 3 },
|
||||
review_mode: reviewMode,
|
||||
retrieval: config.retrieval || config.retrieve,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 将后端 proposal 数据转换为前端 CoordinatorCard 所需格式
|
||||
*
|
||||
* @param {Object} proposal - 后端提案对象
|
||||
* @param {Object} previousDraft - 上一轮的草稿(用于合并增量更新)
|
||||
* @returns {Object} 前端协调者数据
|
||||
*/
|
||||
function proposalToCoordData(proposal, previousDraft = {}) {
|
||||
const draftUpdates = proposal?.draft_updates || {};
|
||||
return {
|
||||
round: proposal?.round_index,
|
||||
divergeRound: proposal?.diverge_round,
|
||||
totalDivergeRounds: proposal?.total_diverge_rounds,
|
||||
nextSpeakers: proposal?.next_speakers || [],
|
||||
focus: proposal?.focus || {},
|
||||
draft: { ...previousDraft, ...draftUpdates },
|
||||
draftUpdates,
|
||||
gaps: proposal?.gaps || [],
|
||||
fieldAssessment: proposal?.field_assessment || {},
|
||||
sharedMemory: proposal?.shared_memory || null,
|
||||
reasoning: proposal?.reasoning || '',
|
||||
proposal,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 SSE (Server-Sent Events) 数据行
|
||||
*
|
||||
* 格式: "data: {...}" 或 "data: [DONE]"
|
||||
* @param {string} line - SSE 行文本
|
||||
* @returns {Object|null} 解析后的 JSON 对象,无效行返回 null
|
||||
*/
|
||||
function parseSsePayload(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith('data:')) return null;
|
||||
const jsonStr = trimmed.slice(5).trim();
|
||||
if (!jsonStr || jsonStr === '[DONE]') return null;
|
||||
return JSON.parse(jsonStr);
|
||||
}
|
||||
|
||||
/**
|
||||
* useSession hook
|
||||
*
|
||||
* 管理整个分析会话的生命周期:
|
||||
* - startSession: 发送 POST /sessions/start 创建会话,然后连接 SSE 事件流
|
||||
* - stopSession: 中断当前连接
|
||||
* - reset: 重置所有状态
|
||||
* - approveCoordinator / reviseCoordinator: 人工确认 / 修改提案
|
||||
*
|
||||
* SSE 事件类型及处理:
|
||||
* - session_started: 初始化阶段、智能体、轮次
|
||||
* - agent_started: 在 timeline 中插入 agent_contribution(streaming=true)
|
||||
* - agent_token: 追加 token 到对应 agent_contribution 的 content/thinking
|
||||
* - agent_done: 将对应 agent_contribution 的 streaming 标记为 false
|
||||
* - coordinator_started: 插入 coordinator_streaming 卡片
|
||||
* - coordinator_token: 追加 coordinator 的思考过程或内容 token
|
||||
* - coordinator_status: 更新状态横幅
|
||||
* - coordinator_proposal: 替换 coordinator_streaming 为 coordinator 完成卡片
|
||||
* - human_review_required: 切换状态为 waiting_human
|
||||
* - final_report_token: 追加最终报告 token
|
||||
* - final_report_done: 插入 final_report 卡片,状态切换为 done
|
||||
* - error: 追加错误到 errors 数组
|
||||
*
|
||||
* @returns {Object} 会话状态 + 操作方法
|
||||
*/
|
||||
export function useSession() {
|
||||
const [state, setState] = useState(initialState);
|
||||
const abortControllerRef = useRef(null);
|
||||
const timelineRef = useRef([]); // 用 ref 保存 timeline,避免闭包过期
|
||||
const configRef = useRef(null); // 保存启动时的配置引用
|
||||
|
||||
/** 重置所有状态 */
|
||||
const reset = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
timelineRef.current = [];
|
||||
configRef.current = null;
|
||||
setState(initialState);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 从历史记录 JSON 恢复会话展示状态。
|
||||
*
|
||||
* 后端历史接口未来会直接返回保存时的 snapshot;这里把 snapshot 写回 state 和 timelineRef,
|
||||
* 让用户进入页面后无需重新生成,也能直接看到 Timeline、左右侧进度和最终报告。
|
||||
*/
|
||||
const hydrateFromSnapshot = useCallback((snapshot = {}) => {
|
||||
const nextTimeline = Array.isArray(snapshot.timeline) ? snapshot.timeline : [];
|
||||
timelineRef.current = nextTimeline;
|
||||
configRef.current = snapshot.config || null;
|
||||
setState({
|
||||
...initialState,
|
||||
...snapshot,
|
||||
status: snapshot.status || (nextTimeline.length > 0 ? 'done' : 'idle'),
|
||||
phase: snapshot.phase || (nextTimeline.length > 0 ? 'done' : null),
|
||||
timeline: nextTimeline,
|
||||
errors: Array.isArray(snapshot.errors) ? snapshot.errors : [],
|
||||
agentStates: snapshot.agentStates || {},
|
||||
agents: Array.isArray(snapshot.agents) ? snapshot.agents : [],
|
||||
reportSchema: snapshot.reportSchema || snapshot.config?.report_schema || {},
|
||||
finalReportPartial: snapshot.finalReportPartial || snapshot.finalReport?.report || {},
|
||||
pendingReview: null,
|
||||
reviewSubmitting: false,
|
||||
reviewRevising: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
/** 停止当前会话(中断 SSE 连接) */
|
||||
const stopSession = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
setState(prev => ({ ...prev, status: 'idle' }));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* SSE 事件处理器
|
||||
*
|
||||
* 根据 event 类型分发处理,修改 timelineRef 和 state。
|
||||
* 使用 timelineRef(而非 state.timeline)进行链表式修改,
|
||||
* 避免在快速 token 到来时因 React 批量更新导致丢帧。
|
||||
*/
|
||||
const handleEvent = useCallback((parsed, backendUrl) => {
|
||||
const { event, data } = parsed;
|
||||
if (!event) return;
|
||||
|
||||
switch (event) {
|
||||
// ====== 会话启动 ======
|
||||
case 'session_started': {
|
||||
const agents = data.agents || [];
|
||||
const agentStates = {};
|
||||
agents.forEach(agent => {
|
||||
agentStates[agent.id] = {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
dimension: agent.dimension,
|
||||
status: 'idle',
|
||||
rounds: [],
|
||||
};
|
||||
});
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
sessionId: prev.sessionId,
|
||||
backendUrl,
|
||||
phase: 'starting',
|
||||
agents,
|
||||
agentStates,
|
||||
totalRounds: (configRef.current?.phase_config?.diverge_rounds || 3) + 1,
|
||||
divergeRounds: configRef.current?.phase_config?.diverge_rounds || 3,
|
||||
reportSchema: configRef.current?.report_schema || {},
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 智能体开始输出 ======
|
||||
case 'agent_started': {
|
||||
const agentId = data.agent_id;
|
||||
const round = data.round_index || 0;
|
||||
const phase = data.phase || 'starting';
|
||||
|
||||
// 检查是否已有该轮次的 round_start,没有则插入
|
||||
const hasRoundStart = timelineRef.current.some(
|
||||
item => item.type === 'round_start' && item.round === round && item.phase === phase
|
||||
);
|
||||
|
||||
if (!hasRoundStart) {
|
||||
timelineRef.current = [
|
||||
...timelineRef.current,
|
||||
{ type: 'round_start', round, phase, agentIds: [agentId] },
|
||||
];
|
||||
} else {
|
||||
timelineRef.current = timelineRef.current.map(item =>
|
||||
item.type === 'round_start' && item.round === round && item.phase === phase
|
||||
? { ...item, agentIds: [...new Set([...(item.agentIds || []), agentId])] }
|
||||
: item
|
||||
);
|
||||
}
|
||||
|
||||
// 插入流式 agent_contribution
|
||||
timelineRef.current = [
|
||||
...timelineRef.current,
|
||||
{
|
||||
type: 'agent_contribution',
|
||||
agentId,
|
||||
round,
|
||||
phase,
|
||||
streaming: true,
|
||||
data: {
|
||||
round,
|
||||
phase,
|
||||
content: '',
|
||||
thinking: '',
|
||||
retrievalLog: [],
|
||||
retrievalCount: 0,
|
||||
referenceMap: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
setState(prev => {
|
||||
const updatedAgentStates = { ...prev.agentStates };
|
||||
updatedAgentStates[agentId] = {
|
||||
...(updatedAgentStates[agentId] || { id: agentId, name: data.agent_name, dimension: agentId, rounds: [] }),
|
||||
status: 'thinking',
|
||||
};
|
||||
return {
|
||||
...prev,
|
||||
phase,
|
||||
currentRound: Math.max(prev.currentRound, round),
|
||||
agentStates: updatedAgentStates,
|
||||
timeline: timelineRef.current,
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 智能体完成输出 ======
|
||||
case 'agent_done': {
|
||||
const agentId = data.agent_id;
|
||||
const round = data.round_index || 0;
|
||||
const phase = data.phase || 'starting';
|
||||
const retrievalLog = data.retrieval_log || [];
|
||||
const roundData = {
|
||||
round,
|
||||
phase,
|
||||
content: data.content || '',
|
||||
thinking: data.thinking || '',
|
||||
retrievalLog,
|
||||
retrievalCount: retrievalLog.length,
|
||||
referenceMap: data.reference_map || {},
|
||||
metadata: data.metadata || {},
|
||||
};
|
||||
|
||||
// 将对应的流式条目转为完成状态
|
||||
timelineRef.current = timelineRef.current.map(item =>
|
||||
item.type === 'agent_contribution' && item.agentId === agentId && item.round === round && item.streaming
|
||||
? { ...item, streaming: false, data: roundData }
|
||||
: item
|
||||
);
|
||||
|
||||
setState(prev => {
|
||||
const updatedAgentStates = { ...prev.agentStates };
|
||||
const existing = updatedAgentStates[agentId] || {
|
||||
id: agentId,
|
||||
name: data.agent_name,
|
||||
dimension: agentId,
|
||||
status: 'idle',
|
||||
rounds: [],
|
||||
};
|
||||
updatedAgentStates[agentId] = {
|
||||
...existing,
|
||||
name: data.agent_name,
|
||||
status: 'done',
|
||||
rounds: [...(existing.rounds || []), roundData],
|
||||
};
|
||||
return { ...prev, agentStates: updatedAgentStates, timeline: timelineRef.current };
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 智能体 token(流式追加) ======
|
||||
case 'agent_token': {
|
||||
const agentId = data.agent_id;
|
||||
const round = data.round_index || 0;
|
||||
const token = data.token || '';
|
||||
const tokenType = data.token_type || 'content';
|
||||
|
||||
// 找到对应的 streaming agent_contribution 并追加 token
|
||||
timelineRef.current = timelineRef.current.map(item => {
|
||||
if (item.type !== 'agent_contribution' || item.agentId !== agentId || item.round !== round || !item.streaming) {
|
||||
return item;
|
||||
}
|
||||
if (tokenType === 'thinking') {
|
||||
return { ...item, data: { ...item.data, thinking: (item.data.thinking || '') + token } };
|
||||
}
|
||||
return { ...item, data: { ...item.data, content: (item.data.content || '') + token } };
|
||||
});
|
||||
setState(prev => ({ ...prev, timeline: timelineRef.current }));
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 协调员开始流式输出 ======
|
||||
case 'coordinator_started': {
|
||||
timelineRef.current = [
|
||||
...timelineRef.current,
|
||||
{
|
||||
type: 'coordinator_streaming',
|
||||
phase: data.phase || 'diverging',
|
||||
divergeRound: data.diverge_round,
|
||||
totalDivergeRounds: data.total_diverge_rounds,
|
||||
thinking: '',
|
||||
content: '协调者正在评估字段完成度、更新草稿并选择下一轮发言者。',
|
||||
statusMessage: '协调草案生成中…',
|
||||
currentField: null,
|
||||
reportPartial: {},
|
||||
},
|
||||
];
|
||||
setState(prev => ({ ...prev, phase: data.phase || prev.phase, timeline: timelineRef.current }));
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 协调员 token(流式追加) ======
|
||||
case 'coordinator_token': {
|
||||
const token = data.token || '';
|
||||
const tokenType = data.token_type || 'thinking';
|
||||
const phase = data.phase || 'diverging';
|
||||
|
||||
// 找到最后一个 coordinator_streaming 项并追加
|
||||
const lastIdx = timelineRef.current.reduce((last, item, idx) => (
|
||||
item.type === 'coordinator_streaming' ? idx : last
|
||||
), -1);
|
||||
|
||||
if (lastIdx === -1) {
|
||||
// 尚未创建,新建一个
|
||||
timelineRef.current = [
|
||||
...timelineRef.current,
|
||||
{
|
||||
type: 'coordinator_streaming',
|
||||
phase,
|
||||
divergeRound: data.diverge_round,
|
||||
totalDivergeRounds: data.total_diverge_rounds,
|
||||
thinking: tokenType === 'thinking' ? token : '',
|
||||
content: tokenType === 'content' ? token : '',
|
||||
statusMessage: '',
|
||||
currentField: data.field || null,
|
||||
reportPartial: {},
|
||||
},
|
||||
];
|
||||
} else {
|
||||
timelineRef.current = timelineRef.current.map((item, idx) => {
|
||||
if (idx !== lastIdx || item.type !== 'coordinator_streaming') return item;
|
||||
return {
|
||||
...item,
|
||||
currentField: data.field || item.currentField,
|
||||
thinking: tokenType === 'thinking' ? (item.thinking || '') + token : item.thinking,
|
||||
content: tokenType === 'content' ? (item.content || '') + token : item.content,
|
||||
statusMessage: '',
|
||||
};
|
||||
});
|
||||
}
|
||||
setState(prev => ({ ...prev, timeline: timelineRef.current }));
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 协调员状态更新 ======
|
||||
case 'coordinator_status': {
|
||||
const message = data.message || '';
|
||||
const phase = data.phase || 'diverging';
|
||||
|
||||
const lastIdx = timelineRef.current.reduce((last, item, idx) => (
|
||||
item.type === 'coordinator_streaming' ? idx : last
|
||||
), -1);
|
||||
|
||||
if (lastIdx === -1) {
|
||||
timelineRef.current = [
|
||||
...timelineRef.current,
|
||||
{
|
||||
type: 'coordinator_streaming',
|
||||
phase,
|
||||
divergeRound: data.diverge_round,
|
||||
totalDivergeRounds: data.total_diverge_rounds,
|
||||
thinking: '',
|
||||
content: '',
|
||||
statusMessage: message,
|
||||
currentField: null,
|
||||
reportPartial: {},
|
||||
},
|
||||
];
|
||||
} else {
|
||||
timelineRef.current = timelineRef.current.map((item, idx) => {
|
||||
if (idx !== lastIdx || item.type !== 'coordinator_streaming') return item;
|
||||
return { ...item, statusMessage: message };
|
||||
});
|
||||
}
|
||||
|
||||
setState(prev => ({ ...prev, timeline: timelineRef.current }));
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 协调员提案完成 ======
|
||||
case 'coordinator_proposal': {
|
||||
setState(prev => {
|
||||
const coordData = proposalToCoordData(data.proposal, prev.latestDraft);
|
||||
let replaced = false;
|
||||
|
||||
// 将最后一个 coordinator_streaming 替换为 coordinator 完成卡片
|
||||
timelineRef.current = timelineRef.current.map((item, idx) => {
|
||||
if (item.type !== 'coordinator_streaming' || replaced) return item;
|
||||
const lastIdx = timelineRef.current.reduce((last, it, i) => it.type === 'coordinator_streaming' ? i : last, -1);
|
||||
if (idx !== lastIdx) return item;
|
||||
replaced = true;
|
||||
return {
|
||||
type: 'coordinator',
|
||||
data: coordData,
|
||||
thinking: coordData.reasoning,
|
||||
};
|
||||
});
|
||||
|
||||
if (!replaced) {
|
||||
timelineRef.current = [...timelineRef.current, { type: 'coordinator', data: coordData, thinking: coordData.reasoning }];
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
status: data.revised ? 'running' : prev.status,
|
||||
coordinatorUpdates: [...prev.coordinatorUpdates, coordData],
|
||||
latestDraft: coordData.draft,
|
||||
sharedMemory: coordData.sharedMemory,
|
||||
pendingReview: data.revised ? null : prev.pendingReview,
|
||||
reviewRevising: false,
|
||||
timeline: timelineRef.current,
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 需要人工确认(仅 human_review 模式) ======
|
||||
case 'human_review_required': {
|
||||
setState(prev => {
|
||||
const coordData = proposalToCoordData(data.proposal, prev.latestDraft);
|
||||
return {
|
||||
...prev,
|
||||
status: 'waiting_human',
|
||||
pendingReview: {
|
||||
...coordData,
|
||||
proposal: data.proposal,
|
||||
},
|
||||
sharedMemory: coordData.sharedMemory,
|
||||
reviewSubmitting: false,
|
||||
reviewRevising: false,
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 最终报告生成完成 ======
|
||||
case 'final_report_done': {
|
||||
const output = {
|
||||
report: data.report || {},
|
||||
dimensions: data.dimensions || {},
|
||||
meta: data.metadata || {},
|
||||
};
|
||||
timelineRef.current = [...timelineRef.current, { type: 'final_report', data: output }];
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: 'done',
|
||||
finalReport: output,
|
||||
finalReportPartial: output.report || {},
|
||||
currentFinalField: null,
|
||||
phase: 'done',
|
||||
pendingReview: null,
|
||||
reviewSubmitting: false,
|
||||
reviewRevising: false,
|
||||
timeline: timelineRef.current,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 最终报告 token(流式追加) ======
|
||||
case 'final_report_token': {
|
||||
const token = data.token || '';
|
||||
const field = data.field || null;
|
||||
const reportPartial = data.report_partial || {};
|
||||
|
||||
const lastIdx = timelineRef.current.reduce((last, item, idx) => (
|
||||
item.type === 'coordinator_streaming' ? idx : last
|
||||
), -1);
|
||||
|
||||
if (lastIdx === -1) {
|
||||
timelineRef.current = [
|
||||
...timelineRef.current,
|
||||
{
|
||||
type: 'coordinator_streaming',
|
||||
phase: 'final_report',
|
||||
thinking: '',
|
||||
content: token,
|
||||
statusMessage: '',
|
||||
currentField: field,
|
||||
reportPartial,
|
||||
},
|
||||
];
|
||||
} else {
|
||||
timelineRef.current = timelineRef.current.map((item, idx) => {
|
||||
if (idx !== lastIdx || item.type !== 'coordinator_streaming') return item;
|
||||
const fieldChanged = field && field !== item.currentField;
|
||||
return {
|
||||
...item,
|
||||
phase: 'final_report',
|
||||
content: fieldChanged ? token : (item.content || '') + token,
|
||||
currentField: field || item.currentField,
|
||||
reportPartial,
|
||||
};
|
||||
});
|
||||
}
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
finalReportPartial: reportPartial,
|
||||
currentFinalField: field,
|
||||
timeline: timelineRef.current,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
// ====== 错误 ======
|
||||
case 'error': {
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: prev.status === 'waiting_human' ? prev.status : 'error',
|
||||
errors: [...prev.errors, { error: data.message || data.error || JSON.stringify(data), ...data }],
|
||||
}));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 读取 SSE 事件流
|
||||
*
|
||||
* 使用 fetch + ReadableStream reader 逐行解析,
|
||||
* 支持 AbortController 中断。
|
||||
*
|
||||
* @param {string} url - SSE 端点 URL
|
||||
* @param {string} backendUrl - 后端基础地址
|
||||
* @param {AbortController} abortController - 中断控制器
|
||||
*/
|
||||
const readEventStream = useCallback(async (url, backendUrl, abortController) => {
|
||||
const response = await fetch(url, { signal: abortController.signal });
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done || abortController.signal.aborted) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const parsed = parseSsePayload(line);
|
||||
if (parsed) handleEvent(parsed, backendUrl);
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse SSE line:', line, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [handleEvent]);
|
||||
|
||||
/**
|
||||
* 启动分析会话
|
||||
*
|
||||
* 1. 发送 POST /sessions/start 创建会话
|
||||
* 2. 获取 session_id
|
||||
* 3. 通过 GET /sessions/{id}/events 订阅 SSE 事件流
|
||||
*
|
||||
* @param {Object} config - 页面配置(任务背景、模板、智能体等)
|
||||
* @param {string} backendUrl - 后端 API 地址
|
||||
*/
|
||||
const startSession = useCallback(async (config, backendUrl) => {
|
||||
timelineRef.current = [];
|
||||
configRef.current = config;
|
||||
setState({
|
||||
...initialState,
|
||||
status: 'running',
|
||||
backendUrl,
|
||||
coordinatorMode: config.coordinator_mode || 'auto',
|
||||
reportSchema: config.report_schema || {},
|
||||
});
|
||||
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
const payload = buildSessionPayload(config);
|
||||
const response = await fetch(`${backendUrl}/sessions/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${text}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const sessionId = result.session_id;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
sessionId,
|
||||
status: result.status === 'created' ? 'running' : result.status,
|
||||
}));
|
||||
|
||||
await readEventStream(`${backendUrl}/sessions/${sessionId}/events`, backendUrl, abortController);
|
||||
|
||||
// SSE 流结束后,设置最终状态(除非已是 error/waiting_human/done)
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: prev.status === 'error' || prev.status === 'waiting_human' || prev.status === 'done'
|
||||
? prev.status
|
||||
: 'done',
|
||||
}));
|
||||
} catch (err) {
|
||||
if (err.name === 'AbortError') return;
|
||||
console.error('Session error:', err);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: 'error',
|
||||
errors: [...prev.errors, { error: err.message }],
|
||||
}));
|
||||
}
|
||||
}, [readEventStream]);
|
||||
|
||||
/**
|
||||
* 人工确认协调者提案
|
||||
*
|
||||
* 在 human_review 模式下,用户修改提案内容后提交确认。
|
||||
* 发送 POST /sessions/{id}/coordinator/approve。
|
||||
*
|
||||
* @param {Object} payload - { nextSpeakers, focus, draft, gaps }
|
||||
*/
|
||||
const approveCoordinator = useCallback(async (payload) => {
|
||||
if (!state.sessionId || !state.backendUrl) {
|
||||
throw new Error('session 未初始化');
|
||||
}
|
||||
|
||||
const baseProposal = state.pendingReview?.proposal || {};
|
||||
const proposal = {
|
||||
...baseProposal,
|
||||
next_speakers: payload.nextSpeakers,
|
||||
focus: payload.focus,
|
||||
draft_updates: payload.draft,
|
||||
gaps: payload.gaps,
|
||||
};
|
||||
|
||||
setState(prev => ({ ...prev, reviewSubmitting: true }));
|
||||
try {
|
||||
const response = await fetch(`${state.backendUrl}/sessions/${state.sessionId}/coordinator/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ proposal }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const msg = await response.text();
|
||||
throw new Error(msg || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
status: 'running',
|
||||
pendingReview: null,
|
||||
reviewSubmitting: false,
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('approve error:', err);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
reviewSubmitting: false,
|
||||
errors: [...prev.errors, { error: err.message }],
|
||||
}));
|
||||
}
|
||||
}, [state.sessionId, state.backendUrl, state.pendingReview]);
|
||||
|
||||
/**
|
||||
* 发送修改指令,让协调员重新生成提案
|
||||
*
|
||||
* @param {string} instruction - 用户的修改指令(自然语言)
|
||||
*/
|
||||
const reviseCoordinator = useCallback(async (instruction) => {
|
||||
if (!state.sessionId || !state.backendUrl) {
|
||||
throw new Error('session 未初始化');
|
||||
}
|
||||
|
||||
setState(prev => ({ ...prev, reviewRevising: true }));
|
||||
try {
|
||||
const response = await fetch(`${state.backendUrl}/sessions/${state.sessionId}/coordinator/revise`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ instruction }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const msg = await response.text();
|
||||
throw new Error(msg || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
// 后端会在修订完成后通过 SSE 推送新的 coordinator_proposal
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
reviewRevising: false,
|
||||
pendingReview: null,
|
||||
status: 'running',
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('revise error:', err);
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
reviewRevising: false,
|
||||
errors: [...prev.errors, { error: err.message }],
|
||||
}));
|
||||
}
|
||||
}, [state.sessionId, state.backendUrl]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
hydrateFromSnapshot,
|
||||
reset,
|
||||
startSession,
|
||||
stopSession,
|
||||
approveCoordinator,
|
||||
reviseCoordinator,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
export function useToggle(initialValue: boolean = false): [boolean, () => void, (value: boolean) => void] {
|
||||
const [value, setValue] = useState<boolean>(initialValue);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setValue((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const setToggle = useCallback((newValue: boolean) => {
|
||||
setValue(newValue);
|
||||
}, []);
|
||||
|
||||
return [value, toggle, setToggle];
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface WindowSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function useWindowSize() {
|
||||
const [windowSize, setWindowSize] = useState<WindowSize>({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
|
||||
const handleResize = useCallback(() => {
|
||||
setWindowSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [handleResize]);
|
||||
|
||||
return windowSize;
|
||||
}
|
||||
|
||||
export function useIsMobile() {
|
||||
const { width } = useWindowSize();
|
||||
return width < 768;
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { BotIcon, PlusSquare } from "lucide-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
@@ -32,6 +33,7 @@ import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function AgentChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
@@ -66,7 +68,7 @@ export default function AgentChatPage() {
|
||||
history.replaceState(
|
||||
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) => {
|
||||
|
||||
@@ -26,11 +26,13 @@ import { useModels } from "@/core/models/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useThreadSettings } from "@/core/settings";
|
||||
import { useThreadStream } from "@/core/threads/hooks";
|
||||
import { useRouteBase } from "@/core/route-base";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export default function ChatPage() {
|
||||
const routeBase = useRouteBase();
|
||||
const { t } = useI18n();
|
||||
const [showFollowups, setShowFollowups] = useState(false);
|
||||
const [hasStartedConversation, setHasStartedConversation] = useState(false);
|
||||
@@ -64,7 +66,7 @@ export default function ChatPage() {
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${window.location.pathname}${window.location.search}#/page/workspace/chats/${createdThreadId}`,
|
||||
`${window.location.pathname}${window.location.search}#${routeBase}/chats/${createdThreadId}`,
|
||||
);
|
||||
},
|
||||
onFinish: (state) => {
|
||||
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
} from "@/components/workspace/workspace-container";
|
||||
import { useI18n } from "@/core/i18n/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";
|
||||
|
||||
export default function ChatsPage() {
|
||||
const pathOfThread = usePathOfThread();
|
||||
const { t } = useI18n();
|
||||
const { data: threads } = useThreads();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { PageSidebar } from "@/components/page-sidebar";
|
||||
|
||||
import WorkspaceRoutes from "./WorkspaceRoutes";
|
||||
import StrategyRoutes from "./StrategyRoutes";
|
||||
import Header from "@/strategy-components/Header";
|
||||
|
||||
// 设为 true 时隐藏侧边栏(开发中,暂未完成)
|
||||
@@ -25,7 +26,7 @@ function PageLayout() {
|
||||
!SIDEBAR_ENABLED ? "" : collapsed ? "pl-16" : "pl-64"
|
||||
}`}
|
||||
>
|
||||
<Header />
|
||||
{SIDEBAR_ENABLED && <Header />}
|
||||
<main className="flex-1 overflow-hidden">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="workspace" replace />} />
|
||||
@@ -35,9 +36,11 @@ function PageLayout() {
|
||||
<WorkspaceRoutes
|
||||
pageSidebarCollapsed={collapsed}
|
||||
pageSidebarHidden={!SIDEBAR_ENABLED}
|
||||
headerHidden={!SIDEBAR_ENABLED}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="strategy/*" element={<StrategyRoutes />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "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 { Badge } from "@/components/ui/badge";
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { Toaster } from "sonner";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
|
||||
import { RouteBaseProvider } from "@/core/route-base";
|
||||
|
||||
import ChatsPage from "./ChatsPage";
|
||||
import ChatPage from "./ChatPage";
|
||||
import AgentsPage from "./AgentsPage";
|
||||
@@ -20,14 +22,16 @@ import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./ScheduledTasksPage";
|
||||
import RecommendedQuestionsPage from "./RecommendedQuestionsPage";
|
||||
|
||||
function WorkspaceLayout({
|
||||
export function WorkspaceLayout({
|
||||
children,
|
||||
pageSidebarCollapsed,
|
||||
pageSidebarHidden = false,
|
||||
headerHidden = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
pageSidebarCollapsed: boolean;
|
||||
pageSidebarHidden?: boolean;
|
||||
headerHidden?: boolean;
|
||||
}) {
|
||||
const pageSidebarWidth = pageSidebarHidden
|
||||
? "0rem"
|
||||
@@ -41,6 +45,7 @@ function WorkspaceLayout({
|
||||
style={
|
||||
{
|
||||
"--page-sidebar-width": pageSidebarWidth,
|
||||
"--header-height": headerHidden ? "0rem" : "4rem",
|
||||
} as CSSProperties
|
||||
}
|
||||
// Keep the fixed workspace sidebar aligned to the outer page sidebar width.
|
||||
@@ -69,13 +74,16 @@ function ChatRuntime({ children }: { children: ReactNode }) {
|
||||
export default function WorkspaceRoutes({
|
||||
pageSidebarCollapsed = false,
|
||||
pageSidebarHidden = false,
|
||||
headerHidden = false,
|
||||
}: {
|
||||
pageSidebarCollapsed?: boolean;
|
||||
pageSidebarHidden?: boolean;
|
||||
headerHidden?: boolean;
|
||||
}) {
|
||||
const layoutProps = { pageSidebarCollapsed, pageSidebarHidden };
|
||||
const layoutProps = { pageSidebarCollapsed, pageSidebarHidden, headerHidden };
|
||||
|
||||
return (
|
||||
<RouteBaseProvider value="/page/workspace">
|
||||
<Routes>
|
||||
<Route index element={<Navigate to="chats/new" replace />} />
|
||||
<Route
|
||||
@@ -159,5 +167,6 @@ export default function WorkspaceRoutes({
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</RouteBaseProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from "react";
|
||||
import { useContext, useMemo } from "react";
|
||||
import {
|
||||
useLocation,
|
||||
useNavigate,
|
||||
@@ -6,19 +6,24 @@ import {
|
||||
useSearchParams as useRouterSearchParams,
|
||||
} from "react-router-dom";
|
||||
|
||||
import { RouteBaseContext } from "@/core/route-base";
|
||||
|
||||
export function useRouter() {
|
||||
const navigate = useNavigate();
|
||||
const base = useContext(RouteBaseContext);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
push: (href: string) => navigate(href),
|
||||
replace: (href: string) => navigate(href, { replace: true }),
|
||||
push: (href: string) =>
|
||||
navigate(href.replace("/page/workspace", base)),
|
||||
replace: (href: string) =>
|
||||
navigate(href.replace("/page/workspace", base), { replace: true }),
|
||||
back: () => navigate(-1),
|
||||
forward: () => navigate(1),
|
||||
refresh: () => window.location.reload(),
|
||||
prefetch: async () => {},
|
||||
}),
|
||||
[navigate],
|
||||
[navigate, base],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Bell, Search, User, ChevronDown, Moon, Sun, Palette } from 'lucide-react';
|
||||
import { Bell, Search, User, ChevronDown, Moon, Palette } from 'lucide-react';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
const Header: React.FC = () => {
|
||||
@@ -40,11 +40,7 @@ const Header: React.FC = () => {
|
||||
}`}
|
||||
title={isDark ? '切换到红白主题' : '切换到深色主题'}
|
||||
>
|
||||
{isDark ? (
|
||||
<Palette className="w-5 h-5" />
|
||||
) : (
|
||||
<Moon className="w-5 h-5" />
|
||||
)}
|
||||
{isDark ? <Palette className="w-5 h-5" /> : <Moon className="w-5 h-5" />}
|
||||
</button>
|
||||
|
||||
{/* 通知 */}
|
||||
@@ -63,14 +59,12 @@ const Header: React.FC = () => {
|
||||
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'
|
||||
isDark ? 'bg-gradient-to-br from-blue-500 to-cyan-400' : 'bg-white'
|
||||
}`}>
|
||||
<User className={`w-4 h-4 ${isDark ? 'text-white' : 'text-red-600'}`} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-white' : 'text-white'}`}>张策略</p>
|
||||
<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'}`} />
|
||||
|
||||
@@ -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'}`}>
|
||||
正在接收 <think> 流式思考内容
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user