代码提交
This commit is contained in:
1 parent
f49def09ea
commit
45df500a9c
55 files changed
+3390
-461
No files matched your search
@@ -37,6 +37,10 @@
|
||||
# 默认真实接口(未设/false);改成 true 时该接口走本地假数据(consumer 外网不可达时调试用)。
|
||||
# 与 VITE_USE_REAL_DATA 独立。改后需重启 dev / 重新打包生效。
|
||||
# VITE_MOCK_COP_TASK_DETAIL=false
|
||||
# VITE_POSITION_ROUNDTABLE_DEMO_MODE=false
|
||||
# true=免登录直达岗位分析,完全使用前端 JSON,不请求任何后台接口。
|
||||
# 仅限离线演示环境;生产环境请保持 false。
|
||||
# VITE_POSITION_ROUNDTABLE_STANDALONE_MODE=false
|
||||
|
||||
# MaxKey 鉴权根地址(无界 onlineTicket 换 token)
|
||||
# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
|
||||
|
||||
@@ -27,6 +27,25 @@
|
||||
|
||||
设计收敛:当前版本以节点的 `artifact_manifest`、`latest_answer`、版本、驳回记录及 Session 内置快照作为唯一事实来源,已能覆盖统一产物卡片和级联逻辑,因此不新增重复的 `position_roundtable_deliverables` 表。若后续需要“单个文件独立驳回、跨会话检索或完整版本对比”,再将清单归一化为独立产物表,避免现在的双写一致性风险。
|
||||
|
||||
## 数据持久化与灾难恢复(2026-07-23)
|
||||
|
||||
岗位会商现在使用“两层持久化”,两层职责不同:
|
||||
|
||||
1. LangGraph checkpoint/thread 是智能体继续推理与流式问答的执行源。
|
||||
2. `position_roundtable_sessions` / `position_roundtable_nodes` 是页面历史的数据库恢复源。
|
||||
|
||||
数据库恢复源会保存:
|
||||
|
||||
- 情报分析岗完整消息、步骤时间线和未发送输入草稿;
|
||||
- 每个业务链岗位的完整消息和未发送输入草稿;
|
||||
- 方案总结、行动规划两个收口智能体的完整消息和未发送输入草稿;
|
||||
- 已确认意图、冻结任务与业务链快照、节点状态、版本、驳回记录;
|
||||
- Markdown / JSON 等文本产物正文(单文件最多 400 万字符)和文件元数据。
|
||||
|
||||
因此即使线上重启后 checkpoint 或沙箱 outputs 卷丢失,历史页仍能从数据库展示对话与产物正文,后续收口智能体也能继续读取数据库中的有效上游交付。新任务在意图识别开始前就会创建 Session,用户不必等到“启动业务链”才获得持久化保护。历史下拉支持删除,删除会显式清理 Session 与全部节点恢复快照,不依赖 SQLite 外键开关。
|
||||
|
||||
对应迁移:`20260723_05_position_roundtable_conversations.py`。部署时必须执行到 Alembic head。
|
||||
|
||||
## 0. 核心结论
|
||||
|
||||
- 方案总结智能体建议复用现有会商里的 `roundtable-summary` 能力,但不要直接搬整套 Step 3 页面流程。
|
||||
|
||||
Generated
+96
-144
@@ -70,6 +70,7 @@
|
||||
"hast": "^1.0.0",
|
||||
"katex": "^0.16.28",
|
||||
"lucide-react": "^0.562.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.26.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
@@ -104,6 +105,7 @@
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"lightningcss": "^1.30.2",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.0.15",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
@@ -460,37 +462,11 @@
|
||||
"resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
|
||||
"integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="
|
||||
},
|
||||
"node_modules/@chevrotain/cst-dts-gen": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz",
|
||||
"integrity": "sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==",
|
||||
"dependencies": {
|
||||
"@chevrotain/gast": "12.0.0",
|
||||
"@chevrotain/types": "12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/gast": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-12.0.0.tgz",
|
||||
"integrity": "sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==",
|
||||
"dependencies": {
|
||||
"@chevrotain/types": "12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@chevrotain/regexp-to-ast": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz",
|
||||
"integrity": "sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA=="
|
||||
},
|
||||
"node_modules/@chevrotain/types": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-12.0.0.tgz",
|
||||
"integrity": "sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA=="
|
||||
},
|
||||
"node_modules/@chevrotain/utils": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-12.0.0.tgz",
|
||||
"integrity": "sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA=="
|
||||
"version": "11.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@chevrotain/types/-/types-11.1.2.tgz",
|
||||
"integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.1",
|
||||
@@ -1732,11 +1708,12 @@
|
||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="
|
||||
},
|
||||
"node_modules/@mermaid-js/parser": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz",
|
||||
"integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==",
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/@mermaid-js/parser/-/parser-1.2.0.tgz",
|
||||
"integrity": "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"langium": "^4.0.0"
|
||||
"@chevrotain/types": "~11.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
@@ -4274,6 +4251,66 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.7.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.7.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
|
||||
@@ -5650,32 +5687,6 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/chevrotain": {
|
||||
"version": "12.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-12.0.0.tgz",
|
||||
"integrity": "sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==",
|
||||
"dependencies": {
|
||||
"@chevrotain/cst-dts-gen": "12.0.0",
|
||||
"@chevrotain/gast": "12.0.0",
|
||||
"@chevrotain/regexp-to-ast": "12.0.0",
|
||||
"@chevrotain/types": "12.0.0",
|
||||
"@chevrotain/utils": "12.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chevrotain-allstar": {
|
||||
"version": "0.4.3",
|
||||
"resolved": "https://registry.npmjs.org/chevrotain-allstar/-/chevrotain-allstar-0.4.3.tgz",
|
||||
"integrity": "sha512-2X4mkroolSMKqW+H22pyPMUVDqYZzPhephTmg/NODKb1IGYPHfxfhcW0EjS7wcPJNbze2i4vBWT7zT5FKF2lrQ==",
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.18.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"chevrotain": "^12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/class-variance-authority": {
|
||||
"version": "0.7.1",
|
||||
"license": "Apache-2.0",
|
||||
@@ -6547,6 +6558,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.49.0",
|
||||
"resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.49.0.tgz",
|
||||
"integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -7354,7 +7375,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.28",
|
||||
"version": "0.16.47",
|
||||
"resolved": "https://registry.npmmirror.com/katex/-/katex-0.16.47.tgz",
|
||||
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
@@ -7372,23 +7395,6 @@
|
||||
"resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz",
|
||||
"integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="
|
||||
},
|
||||
"node_modules/langium": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/langium/-/langium-4.2.3.tgz",
|
||||
"integrity": "sha512-sOPIi4hISFnY7twwV97ca1TsxpBtXq0URu/LL1AvxwccPG/RIBBlKS7a/f/EL6w8lTNaS0EFs/F+IdSOaqYpng==",
|
||||
"dependencies": {
|
||||
"@chevrotain/regexp-to-ast": "~12.0.0",
|
||||
"chevrotain": "~12.0.0",
|
||||
"chevrotain-allstar": "~0.4.3",
|
||||
"vscode-languageserver": "~9.0.1",
|
||||
"vscode-languageserver-textdocument": "~1.0.11",
|
||||
"vscode-uri": "~3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.10.0",
|
||||
"npm": ">=10.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/langsmith": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/langsmith/-/langsmith-0.6.0.tgz",
|
||||
@@ -8100,43 +8106,32 @@
|
||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="
|
||||
},
|
||||
"node_modules/mermaid": {
|
||||
"version": "11.14.0",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
|
||||
"integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==",
|
||||
"version": "11.16.0",
|
||||
"resolved": "https://registry.npmmirror.com/mermaid/-/mermaid-11.16.0.tgz",
|
||||
"integrity": "sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@braintree/sanitize-url": "^7.1.1",
|
||||
"@braintree/sanitize-url": "^7.1.2",
|
||||
"@iconify/utils": "^3.0.2",
|
||||
"@mermaid-js/parser": "^1.1.0",
|
||||
"@mermaid-js/parser": "^1.2.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@upsetjs/venn.js": "^2.0.0",
|
||||
"cytoscape": "^3.33.1",
|
||||
"cytoscape": "^3.33.3",
|
||||
"cytoscape-cose-bilkent": "^4.1.0",
|
||||
"cytoscape-fcose": "^2.2.0",
|
||||
"d3": "^7.9.0",
|
||||
"d3-sankey": "^0.12.3",
|
||||
"dagre-d3-es": "7.0.14",
|
||||
"dayjs": "^1.11.19",
|
||||
"dompurify": "^3.3.1",
|
||||
"katex": "^0.16.25",
|
||||
"dayjs": "^1.11.20",
|
||||
"dompurify": "^3.3.3",
|
||||
"es-toolkit": "^1.45.1",
|
||||
"katex": "^0.16.45",
|
||||
"khroma": "^2.1.0",
|
||||
"lodash-es": "^4.17.23",
|
||||
"marked": "^16.3.0",
|
||||
"roughjs": "^4.6.6",
|
||||
"stylis": "^4.3.6",
|
||||
"ts-dedent": "^2.2.0",
|
||||
"uuid": "^11.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mermaid/node_modules/uuid": {
|
||||
"version": "11.1.1",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
|
||||
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"bin": {
|
||||
"uuid": "dist/esm/bin/uuid"
|
||||
"uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/micromark": {
|
||||
@@ -10414,49 +10409,6 @@
|
||||
"vite": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-jsonrpc": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz",
|
||||
"integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==",
|
||||
"dependencies": {
|
||||
"vscode-languageserver-protocol": "3.17.5"
|
||||
},
|
||||
"bin": {
|
||||
"installServerIntoExtension": "bin/installServerIntoExtension"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-protocol": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
|
||||
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
|
||||
"dependencies": {
|
||||
"vscode-jsonrpc": "8.2.0",
|
||||
"vscode-languageserver-types": "3.17.5"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-languageserver-textdocument": {
|
||||
"version": "1.0.12",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
|
||||
"integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="
|
||||
},
|
||||
"node_modules/vscode-languageserver-types": {
|
||||
"version": "3.17.5",
|
||||
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
|
||||
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="
|
||||
},
|
||||
"node_modules/vscode-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
|
||||
@@ -109,6 +109,13 @@ window.__CM_RUNTIME_CONFIG__ = {
|
||||
// 登录深链任务详情是否使用本地 mock:false=真实接口;true=本地 mock。
|
||||
VITE_MOCK_COP_TASK_DETAIL: "false",
|
||||
|
||||
// 岗位会商完整演示数据:true=启动时幂等写入后端;后端不可用时直接使用前端 JSON 兜底。
|
||||
// 默认关闭,避免演示历史记录出现在正常生产账号中。
|
||||
VITE_POSITION_ROUNDTABLE_DEMO_MODE: "false",
|
||||
// 纯前端岗位分析演示:跳过登录,直接进入岗位分析页,并禁止所有后台请求。
|
||||
// 仅用于离线展台/演示环境,生产环境必须保持 false。
|
||||
VITE_POSITION_ROUNDTABLE_STANDALONE_MODE: "false",
|
||||
|
||||
// 大屏绘制 selectSqReport 是否使用本地 mock:不在运行时强制覆盖,
|
||||
// 由各环境的 .env 文件决定(开发环境为 true,生产环境为 false)。
|
||||
|
||||
|
||||
@@ -37,6 +37,11 @@ import PublicRoundtableSharePage from "./pages/PublicRoundtableSharePage";
|
||||
import EmbedChatPage from "./pages/EmbedChatPage";
|
||||
import EmbedRoundtablePage from "./pages/EmbedRoundtablePage";
|
||||
import DashboardStandalonePage from "@/roundtable-planning/pages/DashboardStandalonePage";
|
||||
import PositionRoundtablePage from "@/position-roundtable/pages/PositionRoundtablePage";
|
||||
import {
|
||||
isPositionRoundtableStandaloneMode,
|
||||
POSITION_ROUNDTABLE_ROUTE,
|
||||
} from "@/position-roundtable/demo/position-roundtable-demo-config";
|
||||
import { ChatRuntime } from "./pages/WorkspaceRoutes";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
|
||||
@@ -46,6 +51,7 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
const locale = useMemo(() => detectLocale(), []);
|
||||
const location = useLocation();
|
||||
const publicEmbedTheme = usePublicEmbedTheme();
|
||||
const positionStandaloneMode = isPositionRoundtableStandaloneMode();
|
||||
// iframe 嵌入会话(按标签页 sessionStorage)集中在此同步:任何带 `&embed=1` 的入口(深链登录页
|
||||
// 或直接深链路由)一进来就启用、`embed=0` 关闭,后续干净路由靠会话维持去 chrome + 强制主题。
|
||||
useEffect(() => {
|
||||
@@ -54,8 +60,9 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
// 操作日志(Y-Log):启动早期预热上报配置,使 writeYLog 同步读缓存即可工作
|
||||
// (刷新后 module 缓存清空,token 登录用户也能继续上报)。失败静默。
|
||||
useEffect(() => {
|
||||
if (positionStandaloneMode) return;
|
||||
void fetchYLogConfig();
|
||||
}, []);
|
||||
}, [positionStandaloneMode]);
|
||||
const forcedTheme = useMemo(() => {
|
||||
// iframe 嵌入模式(外部系统 `&embed=1`)最高优先:按本标签页的嵌入主题强制明/暗。
|
||||
// 走 next-themes forcedTheme(不写 localStorage)→ 同浏览器另开的正常系统页不受影响。
|
||||
@@ -86,7 +93,7 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
attribute="class"
|
||||
storageKey="strategy-theme"
|
||||
defaultTheme="light"
|
||||
themes={["light", "dark", "lightblue", "lightgreen"]}
|
||||
themes={["light", "dark", "lightblue", "lightgreen", "iceblue"]}
|
||||
enableSystem={false}
|
||||
disableTransitionOnChange
|
||||
forcedTheme={forcedTheme}
|
||||
@@ -95,8 +102,8 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
<AuthSyncWatcher />
|
||||
<DesensitizeWordsLoader />
|
||||
{!positionStandaloneMode && <AuthSyncWatcher />}
|
||||
{!positionStandaloneMode && <DesensitizeWordsLoader />}
|
||||
<FontScaleApplier />
|
||||
{children}
|
||||
</ChatProvider>
|
||||
@@ -114,17 +121,38 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
*/
|
||||
function RootRedirect() {
|
||||
const { search } = useLocation();
|
||||
if (isPositionRoundtableStandaloneMode()) {
|
||||
return <Navigate to={POSITION_ROUNDTABLE_ROUTE} replace />;
|
||||
}
|
||||
return <Navigate to={`/login${search}`} replace />;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const positionStandaloneMode = isPositionRoundtableStandaloneMode();
|
||||
const standaloneLoginRedirect = (
|
||||
<Navigate to={POSITION_ROUNDTABLE_ROUTE} replace />
|
||||
);
|
||||
const pageElement = positionStandaloneMode ? (
|
||||
<ChatRuntime>
|
||||
<PositionRoundtablePage compactChrome />
|
||||
</ChatRuntime>
|
||||
) : (
|
||||
<PageRoutes />
|
||||
);
|
||||
|
||||
return (
|
||||
<HashRouter>
|
||||
<AppProviders>
|
||||
<Routes>
|
||||
<Route path="/" element={<RootRedirect />} />
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/login/:username" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/login"
|
||||
element={positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/login/:username"
|
||||
element={positionStandaloneMode ? standaloneLoginRedirect : <LoginPage />}
|
||||
/>
|
||||
<Route path="/landing" element={<LandingPage />} />
|
||||
<Route path="/share/:shareCode" element={<PublicSharePage />} />
|
||||
<Route path="/public/leaderboard" element={<PublicLeaderboardPage />} />
|
||||
@@ -161,7 +189,7 @@ export function App() {
|
||||
path="/roundtable-dashboard"
|
||||
element={<DashboardStandalonePage />}
|
||||
/>
|
||||
<Route path="/page/*" element={<PageRoutes />} />
|
||||
<Route path="/page/*" element={pageElement} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</AppProviders>
|
||||
|
||||
@@ -564,6 +564,22 @@
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
/* Ice blue has a deliberately light banner. Keep top-level navigation and
|
||||
its active marker dark enough to remain readable against #9BCEF1. */
|
||||
html.iceblue .light-topnav-item {
|
||||
color: rgba(24, 50, 74, 0.78);
|
||||
}
|
||||
|
||||
html.iceblue .light-topnav-item:hover,
|
||||
html.iceblue .light-topnav-item--active,
|
||||
html.iceblue .light-topnav-item--active:hover {
|
||||
color: #18324a;
|
||||
}
|
||||
|
||||
html.iceblue .light-topnav-item--active::after {
|
||||
background: #3d6e96;
|
||||
}
|
||||
|
||||
/* ── 侧边栏:二级图标卡片列(96px)+ 右侧三级列(展开时整体变宽,带过渡)──────── */
|
||||
.light-sidebar {
|
||||
position: fixed;
|
||||
|
||||
@@ -28,11 +28,12 @@ import { buildLightAppUrl } from "@/strategy-components/lib/light-app";
|
||||
*/
|
||||
export type AppearanceControlTone = "banner-dark" | "banner-light" | "content";
|
||||
|
||||
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),深蓝色=深色主题(dark)
|
||||
// 换肤色板:红色=红白主题(light),蓝色=浅蓝主题(lightblue),冰川蓝=柔和浅蓝主题(iceblue),深蓝色=深色主题(dark)
|
||||
export const THEME_SWATCHES: { id: ThemeType; label: string; color: string }[] = [
|
||||
{ id: "light", label: "红色", color: "#cf1322" },
|
||||
{ id: "lightgreen", label: "绿色", color: "#07c05f" },
|
||||
{ id: "lightblue", label: "蓝色", color: "#0062d9" },
|
||||
{ id: "iceblue", label: "冰川蓝", color: "#9BCEF1" },
|
||||
{ id: "dark", label: "深蓝色", color: "#002466" },
|
||||
];
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
MoonIcon,
|
||||
PanelLeftIcon,
|
||||
Plus,
|
||||
SnowflakeIcon,
|
||||
SunIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
@@ -213,7 +214,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
};
|
||||
|
||||
const handleSetGlobalDefault = async (
|
||||
value: "light" | "dark" | "lightblue" | "lightgreen",
|
||||
value: "light" | "dark" | "lightblue" | "lightgreen" | "iceblue",
|
||||
) => {
|
||||
if (value === globalDefault) return;
|
||||
try {
|
||||
@@ -225,7 +226,9 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
? "深色"
|
||||
: value === "lightgreen"
|
||||
? "绿色"
|
||||
: "浅蓝";
|
||||
: value === "iceblue"
|
||||
? "冰川蓝"
|
||||
: "浅蓝";
|
||||
toast.success(`已将系统默认主题设置为${label}`);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "保存失败");
|
||||
@@ -387,6 +390,12 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
description: t.settings.appearance.lightgreenDescription,
|
||||
icon: LeafIcon,
|
||||
},
|
||||
{
|
||||
id: "iceblue",
|
||||
label: t.settings.appearance.iceblue,
|
||||
description: t.settings.appearance.iceblueDescription,
|
||||
icon: SnowflakeIcon,
|
||||
},
|
||||
],
|
||||
[
|
||||
t.settings.appearance.dark,
|
||||
@@ -397,6 +406,8 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
t.settings.appearance.lightblueDescription,
|
||||
t.settings.appearance.lightgreen,
|
||||
t.settings.appearance.lightgreenDescription,
|
||||
t.settings.appearance.iceblue,
|
||||
t.settings.appearance.iceblueDescription,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -414,7 +425,7 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
active={currentTheme === option.id}
|
||||
mode={option.id as "light" | "dark" | "lightblue" | "lightgreen"}
|
||||
mode={option.id as "light" | "dark" | "lightblue" | "lightgreen" | "iceblue"}
|
||||
onSelect={(value) => setTheme(value)}
|
||||
/>
|
||||
))}
|
||||
@@ -576,6 +587,16 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
<LeafIcon className="mr-1.5 size-4" />
|
||||
绿色
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={globalDefault === "iceblue" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => void handleSetGlobalDefault("iceblue")}
|
||||
disabled={updateAppearance.isPending}
|
||||
>
|
||||
<SnowflakeIcon className="mr-1.5 size-4" />
|
||||
冰川蓝
|
||||
</Button>
|
||||
<p className="text-muted-foreground ml-2 text-xs">
|
||||
当前系统默认:
|
||||
{globalDefault === "light"
|
||||
@@ -584,7 +605,9 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) {
|
||||
? "深色"
|
||||
: globalDefault === "lightgreen"
|
||||
? "绿色"
|
||||
: "浅蓝"}
|
||||
: globalDefault === "iceblue"
|
||||
? "冰川蓝"
|
||||
: "浅蓝"}
|
||||
</p>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
@@ -1101,8 +1124,8 @@ function ThemePreviewCard({
|
||||
label: string;
|
||||
description: string;
|
||||
active: boolean;
|
||||
mode: "light" | "dark" | "lightblue" | "lightgreen";
|
||||
onSelect: (mode: "light" | "dark" | "lightblue" | "lightgreen") => void;
|
||||
mode: "light" | "dark" | "lightblue" | "lightgreen" | "iceblue";
|
||||
onSelect: (mode: "light" | "dark" | "lightblue" | "lightgreen" | "iceblue") => void;
|
||||
}) {
|
||||
const previewMode = mode;
|
||||
return (
|
||||
@@ -1134,8 +1157,10 @@ function ThemePreviewCard({
|
||||
? "border-neutral-800 bg-neutral-900 text-neutral-200"
|
||||
: previewMode === "lightblue"
|
||||
? "border-blue-200 bg-white text-slate-900"
|
||||
: previewMode === "lightgreen"
|
||||
? "border-emerald-200 bg-white text-emerald-950"
|
||||
: previewMode === "lightgreen"
|
||||
? "border-emerald-200 bg-white text-emerald-950"
|
||||
: previewMode === "iceblue"
|
||||
? "border-sky-200 bg-[#F7FBFE] text-[#18324A]"
|
||||
: "border-slate-200 bg-white text-slate-900",
|
||||
)}
|
||||
>
|
||||
@@ -1147,8 +1172,10 @@ function ThemePreviewCard({
|
||||
? "bg-emerald-400"
|
||||
: previewMode === "lightblue"
|
||||
? "bg-blue-500"
|
||||
: previewMode === "lightgreen"
|
||||
? "bg-[#07c05f]"
|
||||
: previewMode === "lightgreen"
|
||||
? "bg-[#07c05f]"
|
||||
: previewMode === "iceblue"
|
||||
? "bg-[#9BCEF1]"
|
||||
: "bg-[#cf1322]",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -8,9 +8,10 @@ import React, {
|
||||
import { useTheme as useNextTheme } from 'next-themes';
|
||||
|
||||
import { useAppearanceSettings } from '@/core/system-settings/hooks';
|
||||
import { isPositionRoundtableStandaloneMode } from '@/position-roundtable/demo/position-roundtable-demo-config';
|
||||
|
||||
// 主题类型
|
||||
export type ThemeType = 'dark' | 'light' | 'lightblue' | 'lightgreen';
|
||||
export type ThemeType = 'dark' | 'light' | 'lightblue' | 'lightgreen' | 'iceblue';
|
||||
|
||||
// 主题配置
|
||||
export interface ThemeConfig {
|
||||
@@ -126,6 +127,30 @@ const themes: Record<ThemeType, ThemeConfig> = {
|
||||
overlay: 'black/30',
|
||||
},
|
||||
},
|
||||
iceblue: {
|
||||
type: 'iceblue',
|
||||
name: '冰川蓝主题',
|
||||
colors: {
|
||||
background: 'sky-50',
|
||||
backgroundSecondary: 'sky-100',
|
||||
text: 'slate-800',
|
||||
textSecondary: 'slate-600',
|
||||
// The CSS --primary token is exactly #9BCEF1. Darker blues are used
|
||||
// only for readable text, hover and focus states.
|
||||
primary: 'sky-200',
|
||||
primaryHover: 'sky-300',
|
||||
border: 'sky-200',
|
||||
accent: 'sky-200',
|
||||
success: 'emerald-600',
|
||||
warning: 'amber-600',
|
||||
error: 'red-600',
|
||||
card: 'white',
|
||||
cardHover: 'sky-50',
|
||||
input: 'white',
|
||||
inputBorder: 'sky-200',
|
||||
overlay: 'slate-900/30',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'strategy-theme';
|
||||
@@ -144,6 +169,7 @@ function readPersistedTheme(): ThemeType {
|
||||
if (saved === 'dark') return 'dark';
|
||||
if (saved === 'lightblue') return 'lightblue';
|
||||
if (saved === 'lightgreen') return 'lightgreen';
|
||||
if (saved === 'iceblue') return 'iceblue';
|
||||
return 'light';
|
||||
}
|
||||
|
||||
@@ -184,7 +210,9 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
// a theme yet, this value drives the rendered theme — and re-syncs on
|
||||
// every mount so an admin change propagates without users clearing
|
||||
// localStorage by hand.
|
||||
const { data: appearance } = useAppearanceSettings();
|
||||
const { data: appearance } = useAppearanceSettings({
|
||||
enabled: !isPositionRoundtableStandaloneMode(),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -203,7 +231,8 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
current !== 'light' &&
|
||||
current !== 'dark' &&
|
||||
current !== 'lightblue' &&
|
||||
current !== 'lightgreen'
|
||||
current !== 'lightgreen' &&
|
||||
current !== 'iceblue'
|
||||
) {
|
||||
// Garbage value — wipe it so the boot-sync below can take over.
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
@@ -242,7 +271,9 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
? 'lightblue'
|
||||
: effectiveTheme === 'lightgreen'
|
||||
? 'lightgreen'
|
||||
: 'light'
|
||||
: effectiveTheme === 'iceblue'
|
||||
? 'iceblue'
|
||||
: 'light'
|
||||
: readPersistedTheme();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -260,7 +291,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
root.removeAttribute('theme-mode');
|
||||
root.classList.remove('dark');
|
||||
|
||||
if (theme === 'lightblue' || theme === 'lightgreen') {
|
||||
if (theme === 'lightblue' || theme === 'lightgreen' || theme === 'iceblue') {
|
||||
root.setAttribute('data-theme', theme);
|
||||
} else {
|
||||
root.removeAttribute('data-theme');
|
||||
|
||||
@@ -150,12 +150,15 @@ export function useAllAgentsAdmin(enabled: boolean) {
|
||||
return { agents: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
export function useAgent(id: string | null | undefined) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", id],
|
||||
queryFn: () => getAgent(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
export function useAgent(
|
||||
id: string | null | undefined,
|
||||
{ enabled = true }: { enabled?: boolean } = {},
|
||||
) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", id],
|
||||
queryFn: () => getAgent(id!),
|
||||
enabled: enabled && !!id,
|
||||
});
|
||||
return { agent: data ?? null, isLoading, error };
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@ import fallbackLogoLight from "@/assets/img/brand-logo-light.png";
|
||||
import fallbackLogoLightblue from "@/assets/img/brand-logo-lightblue.png";
|
||||
import { readRuntimeEnv } from "@/env";
|
||||
|
||||
export type BrandTheme = "light" | "lightblue" | "lightgreen" | "dark";
|
||||
export type BrandTheme = "light" | "lightblue" | "lightgreen" | "iceblue" | "dark";
|
||||
|
||||
const headerFallbackAssets: Record<BrandTheme, string> = {
|
||||
light: fallbackLogoLight,
|
||||
lightblue: fallbackLogoLightblue,
|
||||
lightgreen: fallbackLogoLight,
|
||||
iceblue: fallbackLogoLightblue,
|
||||
dark: fallbackLogoDark,
|
||||
};
|
||||
|
||||
@@ -16,6 +17,7 @@ const headerRuntimeKeys: Record<BrandTheme, string> = {
|
||||
light: "VITE_BRAND_LOGO_LIGHT_URL",
|
||||
lightblue: "VITE_BRAND_LOGO_LIGHTBLUE_URL",
|
||||
lightgreen: "VITE_BRAND_LOGO_LIGHTGREEN_URL",
|
||||
iceblue: "VITE_BRAND_LOGO_ICEBLUE_URL",
|
||||
dark: "VITE_BRAND_LOGO_DARK_URL",
|
||||
};
|
||||
|
||||
@@ -23,6 +25,7 @@ const welcomeRuntimeKeys: Record<BrandTheme, string> = {
|
||||
light: "VITE_BRAND_WELCOME_LIGHT_URL",
|
||||
lightblue: "VITE_BRAND_WELCOME_LIGHTBLUE_URL",
|
||||
lightgreen: "VITE_BRAND_WELCOME_LIGHTGREEN_URL",
|
||||
iceblue: "VITE_BRAND_WELCOME_ICEBLUE_URL",
|
||||
dark: "VITE_BRAND_WELCOME_DARK_URL",
|
||||
};
|
||||
|
||||
|
||||
@@ -450,11 +450,13 @@ export const enUS: Translations = {
|
||||
dark: "Dark",
|
||||
lightblue: "Light Blue",
|
||||
lightgreen: "Green",
|
||||
iceblue: "Glacier Blue",
|
||||
systemDescription: "Match the operating system preference automatically.",
|
||||
lightDescription: "Bright palette with higher contrast for daytime.",
|
||||
darkDescription: "Dim palette that reduces glare for focus.",
|
||||
lightblueDescription: "A crisp light palette built around blue.",
|
||||
lightgreenDescription: "A crisp light palette built around WeKnora green.",
|
||||
iceblueDescription: "An airy palette built around #9BCEF1 with accessible blue actions.",
|
||||
languageTitle: "Language",
|
||||
languageDescription: "Switch between languages.",
|
||||
},
|
||||
|
||||
@@ -385,11 +385,13 @@ export interface Translations {
|
||||
dark: string;
|
||||
lightblue: string;
|
||||
lightgreen: string;
|
||||
iceblue: string;
|
||||
systemDescription: string;
|
||||
lightDescription: string;
|
||||
darkDescription: string;
|
||||
lightblueDescription: string;
|
||||
lightgreenDescription: string;
|
||||
iceblueDescription: string;
|
||||
languageTitle: string;
|
||||
languageDescription: string;
|
||||
};
|
||||
|
||||
@@ -431,11 +431,13 @@ export const zhCN: Translations = {
|
||||
dark: "深色",
|
||||
lightblue: "浅蓝",
|
||||
lightgreen: "绿色",
|
||||
iceblue: "冰川蓝",
|
||||
systemDescription: "自动跟随系统主题。",
|
||||
lightDescription: "更明亮的配色,适合日间使用。",
|
||||
darkDescription: "更暗的配色,减少眩光方便专注。",
|
||||
lightblueDescription: "以蓝色为主色的清爽浅色配色。",
|
||||
lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。",
|
||||
iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。",
|
||||
languageTitle: "语言",
|
||||
languageDescription: "在不同语言之间切换。",
|
||||
},
|
||||
|
||||
@@ -276,7 +276,7 @@ export async function updatePublishSquares(
|
||||
return data;
|
||||
}
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "lightblue" | "lightgreen";
|
||||
export type ThemeMode = "light" | "dark" | "lightblue" | "lightgreen" | "iceblue";
|
||||
export type ViewMode = "card" | "list";
|
||||
export type LayoutMode = "full" | "compact";
|
||||
export type CompactShortcutParamType = "login" | "theme";
|
||||
|
||||
@@ -111,10 +111,13 @@ export function useUpdatePublishSquares() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useAppearanceSettings() {
|
||||
export function useAppearanceSettings(
|
||||
{ enabled = true }: { enabled?: boolean } = {},
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: APPEARANCE_KEY,
|
||||
queryFn: () => loadAppearanceSettings(),
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
// The provider runs even on the login page (where the user has no auth)
|
||||
|
||||
@@ -27,6 +27,8 @@ export default function LightAppIframeView() {
|
||||
? "green"
|
||||
: theme === "lightblue"
|
||||
? "blue"
|
||||
: theme === "iceblue"
|
||||
? "iceblue"
|
||||
: "red";
|
||||
const src = useMemo(
|
||||
() => (app ? buildLightAppUrl(app, undefined, themeColor) : ""),
|
||||
|
||||
@@ -87,6 +87,17 @@ function getLightAccent(theme: ThemeType) {
|
||||
button: "bg-[#0062D9] shadow-[0_1px_3px_rgba(0,98,217,0.2)] hover:bg-[#0079FE]",
|
||||
};
|
||||
}
|
||||
if (theme === "iceblue") {
|
||||
return {
|
||||
dot: "bg-[#9BCEF1]",
|
||||
badge: "border-[#3D6E96]/15 bg-[#E1F1FB] text-[#2D587A]",
|
||||
focus: "focus:border-[#3D6E96]/60 focus:ring-[#3D6E96]/20",
|
||||
text: "text-[#2D587A]",
|
||||
hoverText: "group-hover:text-[#2D587A]",
|
||||
link: "text-[#2D587A] hover:text-[#1E405C]",
|
||||
button: "bg-[#9BCEF1] text-[#18324A] shadow-[0_1px_3px_rgba(155,206,241,0.42)] hover:bg-[#B7DCF5]",
|
||||
};
|
||||
}
|
||||
return {
|
||||
dot: "bg-[#CF1322]",
|
||||
badge: "border-[#CF1322]/15 bg-[#FFF1F0] text-[#CF1322]",
|
||||
@@ -346,7 +357,7 @@ function AppCard({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpen}
|
||||
className={cn("flex items-center justify-center gap-1.5 rounded-[5px] px-3 py-2 text-xs font-semibold text-white transition active:scale-[0.98] dark:bg-[#1677FF] dark:shadow-none dark:hover:bg-[#0958D9]", lightAccent.button)}
|
||||
className={cn("flex items-center justify-center gap-1.5 rounded-[5px] px-3 py-2 text-xs font-semibold text-white transition active:scale-[0.98] dark:bg-[#1677FF] dark:text-white dark:shadow-none dark:hover:bg-[#0958D9]", lightAccent.button)}
|
||||
>
|
||||
<span>进入应用</span>
|
||||
<ArrowUpRight className="size-3.5" />
|
||||
|
||||
@@ -9,7 +9,7 @@ import { cn } from "@/lib/utils";
|
||||
export default function LightAppNoteQAPage() {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const isLightBlue = theme === 'lightblue';
|
||||
const isLightBlue = theme === 'lightblue' || theme === 'iceblue';
|
||||
const isLightGreen = theme === "lightgreen";
|
||||
const [question, setQuestion] = useState("");
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import AIWritingPage from '@/open-canvas/pages/AIWritingPage';
|
||||
import { getNotebookApiBaseURL } from '@/core/config';
|
||||
import { useTheme, type ThemeType } from '@/contexts/ThemeContext';
|
||||
|
||||
const VALID_THEMES: ThemeType[] = ['dark', 'light', 'lightblue', 'lightgreen'];
|
||||
const VALID_THEMES: ThemeType[] = ['dark', 'light', 'lightblue', 'lightgreen', 'iceblue'];
|
||||
|
||||
interface NotebookTokenData {
|
||||
doc_ids: number[];
|
||||
|
||||
@@ -689,6 +689,59 @@
|
||||
background-color: #0056C6;
|
||||
}
|
||||
|
||||
/* Ice blue theme — use #9BCEF1 for surfaces and #3D6E96 for actions so
|
||||
text on primary controls remains readable. */
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-primary-btn,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog-save,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-switch-on {
|
||||
background-color: #9BCEF1;
|
||||
}
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-primary-btn,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog-save {
|
||||
color: #18324A;
|
||||
box-shadow: 0 2px 6px rgba(155, 206, 241, 0.42);
|
||||
}
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-primary-btn:hover,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog-save:hover {
|
||||
background-color: #B7DCF5;
|
||||
}
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-primary-btn:active,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog-save:active {
|
||||
background-color: #84B9DE;
|
||||
}
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-search-input:focus {
|
||||
border-color: rgba(61, 110, 150, 0.5);
|
||||
box-shadow: 0 0 0 4px rgba(155, 206, 241, 0.42);
|
||||
}
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-view-toggle-btn.is-active,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-cat-toggle-btn.is-active,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-count strong,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-table tr:hover .lam-row-name,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-param-popover-close,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-param-key,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-card:hover .lam-card-title,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog-close:hover,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-add-param-btn:hover {
|
||||
color: #2D587A;
|
||||
}
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-add-param-btn:hover,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-input:focus,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog input[type="text"]:focus {
|
||||
border-color: #5B91B9;
|
||||
}
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-input:focus,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog input[type="text"]:focus,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog [data-slot="input"]:focus-visible,
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog textarea:focus-visible,
|
||||
[data-theme="iceblue"] .light-app-management-page .t-select.t-is-focused .t-input,
|
||||
[data-theme="iceblue"] .light-app-management-page .t-select.t-is-focused .t-input__wrap {
|
||||
box-shadow: 0 0 0 2px rgba(155, 206, 241, 0.58);
|
||||
}
|
||||
[data-theme="iceblue"] .light-app-management-page .lam-dialog-icon {
|
||||
background-color: #E1F1FB;
|
||||
color: #2D587A;
|
||||
}
|
||||
|
||||
/* WeKnora green theme. */
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-primary-btn,
|
||||
[data-theme="lightgreen"] .light-app-management-page .lam-dialog-save,
|
||||
|
||||
@@ -154,26 +154,58 @@ PositionRoundtablePage
|
||||
|
||||
## 7. API 契约
|
||||
|
||||
前端基地址:`/api/position-roundtable`。消息内容和真实产物仍由既有 LangGraph/API 保存,以下接口只保存岗位会商索引与快照。
|
||||
前端基地址:`/api/position-roundtable`。LangGraph 负责实时执行;SQL 同时保存岗位会商索引、结构化状态、完整对话恢复快照和产物正文副本,避免应用或 checkpoint 卷重启后页面变空。
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `GET` | `/sessions?external_task_id=` | 按任务查询会话,用于恢复最近会话和历史记录。 |
|
||||
| `POST` | `/sessions` | 创建初始会话并保存任务快照。 |
|
||||
| `GET` | `/sessions/{id}` | 恢复会话、节点和冻结快照。 |
|
||||
| `DELETE` | `/sessions/{id}` | 删除一条岗位会商历史及其节点;删除当前记录时前端会先屏蔽并发恢复请求。 |
|
||||
| `PUT` | `/sessions/{id}/task` | 更新未冻结的任务快照。 |
|
||||
| `PUT` | `/sessions/{id}/intent` | 保存意图线程 ID 和结构化意图。 |
|
||||
| `PUT` | `/sessions/{id}/intent` | 保存意图线程 ID、结构化意图和完整意图对话快照。 |
|
||||
| `POST` | `/sessions/{id}/activate` | 冻结业务链并创建节点。 |
|
||||
| `POST` | `/sessions/{id}/archive` | 归档会话,后续仅允许查看。 |
|
||||
| `POST` | `/sessions/{id}/nodes/{nodeKey}/thread` | 绑定普通节点的 LangGraph 线程。 |
|
||||
| `POST` | `/sessions/{id}/nodes/{nodeKey}/context` | 取得当前轮隐藏交接上下文。 |
|
||||
| `POST` | `/sessions/{id}/nodes/{nodeKey}/complete-turn` | 保存回答、产物清单、上游版本并推进状态。 |
|
||||
| `PUT` | `/sessions/{id}/nodes/{nodeKey}/conversation` | 保存普通岗位节点的消息与未发送草稿。 |
|
||||
| `POST` | `/sessions/{id}/summary/*` | 准备、发送和完成方案总结。 |
|
||||
| `POST` | `/sessions/{id}/action-plan/*` | 准备、发送和完成行动规划。 |
|
||||
| `PUT` | `/sessions/{id}/{summary|action-plan}/conversation` | 保存两个收口智能体的消息与未发送草稿。 |
|
||||
| `POST` | `/demo/seed` | 按用户和 `demoKey` 幂等初始化完整演示会话。 |
|
||||
|
||||
后端实现在 `offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py`。
|
||||
|
||||
## 8. 开发与排查清单
|
||||
## 8. 演示数据与免登录独立模式
|
||||
|
||||
完整演示数据只有一份事实源:
|
||||
|
||||
```text
|
||||
src/position-roundtable/demo/position-roundtable-full.json
|
||||
```
|
||||
|
||||
它包含任务、意图识别、业务链、全部岗位节点对话、岗位产物正文、方案总结和行动规划。修改数据结构时需要同步更新 `demoKey`,避免已初始化的旧 SQL 演示记录与新结构混用。
|
||||
|
||||
两个运行时开关用途不同:
|
||||
|
||||
| 配置 | 默认值 | 行为 |
|
||||
| --- | --- | --- |
|
||||
| `VITE_POSITION_ROUNDTABLE_DEMO_MODE` | `false` | 正常登录和正常后台请求;进入演示任务时先调用 `/demo/seed` 幂等写入数据库,失败则读取前端 JSON 只读兜底。 |
|
||||
| `VITE_POSITION_ROUNDTABLE_STANDALONE_MODE` | `false` | 纯前端展台模式;跳过登录并直达岗位分析页,不加载全局后台配置,不请求模型、业务链、智能体、会话或 LangGraph 接口,只读渲染同一份 JSON。 |
|
||||
|
||||
部署后可直接修改 `dist/runtime-config.js`(容器部署通常挂载外部 `runtime-config.js`),无需重新构建。独立模式会绕过鉴权,只允许在隔离的演示环境开启,生产环境必须保持 `false`。
|
||||
|
||||
自动意图线程的时序约束:
|
||||
|
||||
1. 外部页面只传 `taskId` 时,先完成历史发现或创建 SQL 会话行;
|
||||
2. 是否初始化智能体只看真实 `intentThreadId`,不能用 URL 是否已有 `sessionId` 代替;
|
||||
3. `initIntent` 返回真实线程前,不保存“无线程欢迎语快照”;
|
||||
4. 绑定线程后立即保存线程 ID,再合并数据库中的恢复消息和草稿。
|
||||
|
||||
这四步用于避免新建会话的恢复 effect 抢先覆盖并取消正在进行的线程初始化。
|
||||
|
||||
## 9. 开发与排查清单
|
||||
|
||||
1. 新增岗位时:仅补充 `POSITION_OPTIONS`,并确保业务链席位的 `position_id` 使用稳定 ID;不要以显示名称作持久化键。
|
||||
2. 新增收口智能体时:同时补充前端 `PositionBuiltinAgentKind`、后端 prepare/complete API、解锁条件和 UI 切换项。
|
||||
@@ -182,7 +214,7 @@ PositionRoundtablePage
|
||||
5. 变更输入框视觉时:只改对应的岗位专用输入组件,不要向全局 `InputBox` 添加岗位样式。
|
||||
6. 验证最少覆盖:新建会话、刷新恢复、切岗位、单节点多智能体切换、生成 Markdown、关闭后再次对话、历史会话、方案总结、行动规划。
|
||||
|
||||
## 9. 构建验证
|
||||
## 10. 构建验证
|
||||
|
||||
```powershell
|
||||
cd frontend-web
|
||||
|
||||
@@ -20,6 +20,18 @@ export type PositionNodeStatus =
|
||||
| "rejected"
|
||||
| "error";
|
||||
|
||||
/**
|
||||
* SQL-owned recovery copy for a chat surface. LangGraph remains the execution
|
||||
* source, but this snapshot keeps history and an unsent composer draft
|
||||
* recoverable when a deployment replaces the checkpoint or sandbox volume.
|
||||
*/
|
||||
export interface PositionConversationSnapshot {
|
||||
messages: Array<Record<string, unknown>>;
|
||||
timelineEntries?: Array<Record<string, unknown>>;
|
||||
inputDraft?: string;
|
||||
savedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 冻结业务链中的一个席位。
|
||||
*
|
||||
@@ -38,6 +50,7 @@ export interface PositionRoundtableNode {
|
||||
status: PositionNodeStatus;
|
||||
latestAnswer: string | null;
|
||||
artifactManifest: Array<Record<string, unknown>>;
|
||||
conversationSnapshot: PositionConversationSnapshot | null;
|
||||
revision: number;
|
||||
upstreamRevision: Record<string, unknown> | null;
|
||||
rejectionCount: number;
|
||||
@@ -61,10 +74,13 @@ export interface PositionRoundtableSession {
|
||||
chainSnapshot: Record<string, unknown> | null;
|
||||
intentThreadId: string | null;
|
||||
intentSnapshot: Record<string, unknown> | null;
|
||||
intentConversation: PositionConversationSnapshot | null;
|
||||
summaryThreadId: string | null;
|
||||
summarySnapshot: Record<string, unknown> | null;
|
||||
summaryConversation: PositionConversationSnapshot | null;
|
||||
actionPlanThreadId: string | null;
|
||||
actionPlanSnapshot: Record<string, unknown> | null;
|
||||
actionPlanConversation: PositionConversationSnapshot | null;
|
||||
status: "intent_pending" | "active" | "completed" | "archived";
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
@@ -83,6 +99,7 @@ interface ServerNode {
|
||||
status: PositionNodeStatus;
|
||||
latest_answer: string | null;
|
||||
artifact_manifest?: Array<Record<string, unknown>>;
|
||||
conversation_snapshot?: PositionConversationSnapshot | null;
|
||||
revision?: number;
|
||||
upstream_revision?: Record<string, unknown> | null;
|
||||
rejection_count?: number;
|
||||
@@ -100,10 +117,13 @@ interface ServerSession {
|
||||
chain_snapshot?: Record<string, unknown> | null;
|
||||
intent_thread_id: string | null;
|
||||
intent_snapshot?: Record<string, unknown> | null;
|
||||
intent_conversation?: PositionConversationSnapshot | null;
|
||||
summary_thread_id?: string | null;
|
||||
summary_snapshot?: Record<string, unknown> | null;
|
||||
summary_conversation?: PositionConversationSnapshot | null;
|
||||
action_plan_thread_id?: string | null;
|
||||
action_plan_snapshot?: Record<string, unknown> | null;
|
||||
action_plan_conversation?: PositionConversationSnapshot | null;
|
||||
status: PositionRoundtableSession["status"];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
@@ -120,6 +140,27 @@ async function readJson<T>(response: Response, label: string): Promise<T> {
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A missing Session is a recoverable navigation state, not a generic gateway
|
||||
* failure. It commonly occurs when another tab (or the history menu) deletes
|
||||
* the Session that is still present in the current URL.
|
||||
*/
|
||||
export class PositionSessionNotFoundError extends Error {
|
||||
readonly sessionId: string;
|
||||
|
||||
constructor(sessionId: string) {
|
||||
super(`Position roundtable session not found: ${sessionId}`);
|
||||
this.name = "PositionSessionNotFoundError";
|
||||
this.sessionId = sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
export function isPositionSessionNotFoundError(
|
||||
error: unknown,
|
||||
): error is PositionSessionNotFoundError {
|
||||
return error instanceof PositionSessionNotFoundError;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 使用 snake_case,页面使用 camelCase。所有字段映射集中在这里,
|
||||
* 组件禁止直接依赖后端字段名,避免 API 演进时遗漏状态字段。
|
||||
@@ -137,6 +178,7 @@ function toNode(node: ServerNode): PositionRoundtableNode {
|
||||
status: node.status,
|
||||
latestAnswer: node.latest_answer,
|
||||
artifactManifest: node.artifact_manifest ?? [],
|
||||
conversationSnapshot: node.conversation_snapshot ?? null,
|
||||
revision: node.revision ?? 0,
|
||||
upstreamRevision: node.upstream_revision ?? null,
|
||||
rejectionCount: node.rejection_count ?? 0,
|
||||
@@ -157,10 +199,13 @@ function toSession(session: ServerSession): PositionRoundtableSession {
|
||||
chainSnapshot: session.chain_snapshot ?? null,
|
||||
intentThreadId: session.intent_thread_id,
|
||||
intentSnapshot: session.intent_snapshot ?? null,
|
||||
intentConversation: session.intent_conversation ?? null,
|
||||
summaryThreadId: session.summary_thread_id ?? null,
|
||||
summarySnapshot: session.summary_snapshot ?? null,
|
||||
summaryConversation: session.summary_conversation ?? null,
|
||||
actionPlanThreadId: session.action_plan_thread_id ?? null,
|
||||
actionPlanSnapshot: session.action_plan_snapshot ?? null,
|
||||
actionPlanConversation: session.action_plan_conversation ?? null,
|
||||
status: session.status,
|
||||
createdAt: session.created_at,
|
||||
updatedAt: session.updated_at,
|
||||
@@ -199,10 +244,14 @@ export async function createPositionSession(input: {
|
||||
}
|
||||
|
||||
export async function getPositionSession(sessionId: string): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`),
|
||||
"get position session",
|
||||
);
|
||||
const response = await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`);
|
||||
if (response.status === 404) {
|
||||
// Consume the body so the connection can be reused, then expose a typed
|
||||
// error that the page can resolve by selecting another history record.
|
||||
await response.text().catch(() => "");
|
||||
throw new PositionSessionNotFoundError(sessionId);
|
||||
}
|
||||
const result = await readJson<ServerSession>(response, "get position session");
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
@@ -223,7 +272,11 @@ export async function updatePositionTaskSnapshot(
|
||||
|
||||
export async function savePositionIntent(
|
||||
sessionId: string,
|
||||
input: { intentThreadId?: string | null; intentSnapshot: object | null },
|
||||
input: {
|
||||
intentThreadId?: string | null;
|
||||
intentSnapshot: object | null;
|
||||
conversationSnapshot?: PositionConversationSnapshot | null;
|
||||
},
|
||||
): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/intent`, {
|
||||
@@ -232,6 +285,7 @@ export async function savePositionIntent(
|
||||
body: JSON.stringify({
|
||||
intent_thread_id: input.intentThreadId || null,
|
||||
intent_snapshot: input.intentSnapshot,
|
||||
conversation_snapshot: input.conversationSnapshot ?? null,
|
||||
}),
|
||||
}),
|
||||
"save position intent",
|
||||
@@ -265,6 +319,19 @@ export async function archivePositionSession(sessionId: string): Promise<Positio
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export async function deletePositionSession(sessionId: string): Promise<void> {
|
||||
const response = await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
// DELETE is deliberately idempotent from the UI's perspective. A stale
|
||||
// history row that is already gone still needs to be removed locally.
|
||||
if (response.status === 404) {
|
||||
await response.text().catch(() => "");
|
||||
return;
|
||||
}
|
||||
await readJson<{ deleted: boolean }>(response, "delete position session");
|
||||
}
|
||||
|
||||
export async function bindPositionNodeThread(
|
||||
sessionId: string,
|
||||
nodeKey: string,
|
||||
@@ -284,6 +351,81 @@ export async function bindPositionNodeThread(
|
||||
return toNode(result);
|
||||
}
|
||||
|
||||
export async function savePositionNodeConversation(
|
||||
sessionId: string,
|
||||
nodeKey: string,
|
||||
input: {
|
||||
threadId?: string | null;
|
||||
conversationSnapshot: PositionConversationSnapshot;
|
||||
},
|
||||
): Promise<PositionRoundtableNode> {
|
||||
const result = await readJson<ServerNode>(
|
||||
await apiFetch(
|
||||
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/state`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
thread_id: input.threadId || null,
|
||||
conversation_snapshot: input.conversationSnapshot,
|
||||
}),
|
||||
},
|
||||
),
|
||||
"save position node conversation",
|
||||
);
|
||||
return toNode(result);
|
||||
}
|
||||
|
||||
export async function savePositionBuiltinConversation(
|
||||
sessionId: string,
|
||||
kind: "summary" | "action_plan",
|
||||
input: {
|
||||
threadId?: string | null;
|
||||
conversationSnapshot: PositionConversationSnapshot;
|
||||
},
|
||||
): Promise<PositionRoundtableSession> {
|
||||
const path = kind === "summary" ? "summary/state" : "action-plan/state";
|
||||
const result = await readJson<ServerSession>(
|
||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
thread_id: input.threadId || null,
|
||||
conversation_snapshot: input.conversationSnapshot,
|
||||
}),
|
||||
}),
|
||||
`save position ${kind} conversation`,
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
export interface PositionDemoSeedPayload {
|
||||
demo_key: string;
|
||||
session: Record<string, unknown>;
|
||||
nodes: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Import the canonical frontend demo fixture into the normal SQL tables.
|
||||
*
|
||||
* The backend makes this operation idempotent per account and demo key. It
|
||||
* returns the same Session shape as a real workflow, so no demo-only rendering
|
||||
* branch is needed after a successful import.
|
||||
*/
|
||||
export async function seedPositionDemoSession(
|
||||
payload: PositionDemoSeedPayload,
|
||||
): Promise<PositionRoundtableSession> {
|
||||
const result = await readJson<ServerSession>(
|
||||
await apiFetch(`${BASE()}/demo/seed`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
"seed position demo session",
|
||||
);
|
||||
return toSession(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 在普通节点发送前取得受长度限制的隐藏交接上下文。
|
||||
*
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { useEffect, useRef, type ComponentProps } from "react";
|
||||
|
||||
import { InputBox } from "@/components/workspace/input-box";
|
||||
import { usePromptInputController } from "@/components/ai-elements/prompt-input";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PositionAgentInputProps = ComponentProps<typeof InputBox> & {
|
||||
/** Stable node/builtin key used to restore the matching SQL-backed draft. */
|
||||
draftKey?: string;
|
||||
draftValue?: string;
|
||||
onDraftChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 岗位会商的岗位智能体输入框。
|
||||
*
|
||||
@@ -11,8 +19,38 @@ import { cn } from "@/lib/utils";
|
||||
*/
|
||||
export function PositionAgentInput({
|
||||
className,
|
||||
draftKey = "",
|
||||
draftValue = "",
|
||||
onDraftChange,
|
||||
...props
|
||||
}: ComponentProps<typeof InputBox>) {
|
||||
}: PositionAgentInputProps) {
|
||||
const { textInput } = usePromptInputController();
|
||||
const activeDraftKeyRef = useRef("");
|
||||
const restoringValueRef = useRef<string | null>(null);
|
||||
const textInputRef = useRef(textInput);
|
||||
textInputRef.current = textInput;
|
||||
|
||||
useEffect(() => {
|
||||
activeDraftKeyRef.current = draftKey;
|
||||
if (textInputRef.current.value !== draftValue) {
|
||||
restoringValueRef.current = draftValue;
|
||||
textInputRef.current.setInput(draftValue);
|
||||
} else {
|
||||
restoringValueRef.current = null;
|
||||
}
|
||||
}, [draftKey, draftValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeDraftKeyRef.current !== draftKey) return;
|
||||
if (restoringValueRef.current !== null) {
|
||||
if (textInput.value === restoringValueRef.current) {
|
||||
restoringValueRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
onDraftChange?.(textInput.value);
|
||||
}, [draftKey, onDraftChange, textInput.value]);
|
||||
|
||||
// Keep the page-specific class on the wrapper rather than adding a variant
|
||||
// to InputBox. Node chat and built-in chat can evolve together while every
|
||||
// other workspace keeps the global composer untouched.
|
||||
|
||||
@@ -25,8 +25,15 @@ import {
|
||||
completePositionSummary,
|
||||
preparePositionActionPlan,
|
||||
preparePositionSummary,
|
||||
savePositionBuiltinConversation,
|
||||
type PositionRoundtableSession,
|
||||
} from "@/position-roundtable/api/sessions";
|
||||
import {
|
||||
buildConversationSnapshot,
|
||||
mergeConversationMessages,
|
||||
readConversationDraft,
|
||||
readConversationMessages,
|
||||
} from "@/position-roundtable/lib/conversation-snapshot";
|
||||
import {
|
||||
initPositionActionPlanThread,
|
||||
initSummaryThread,
|
||||
@@ -56,6 +63,8 @@ interface PositionBuiltinAgentChatProps {
|
||||
allDelivered: boolean;
|
||||
summaryDone: boolean;
|
||||
selectedModel: string;
|
||||
/** Frontend-only demo: render the persisted snapshot without agent lookups. */
|
||||
offlineDemo?: boolean;
|
||||
/** Increments only for an explicit flow/dropdown selection that should begin a fresh first turn. */
|
||||
autoStartRequestKey?: number;
|
||||
builtinAgentOptions?: PositionBuiltinAgentSwitchOption[];
|
||||
@@ -75,8 +84,13 @@ interface PositionBuiltinAgentChatProps {
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const SUMMARY_AGENT_ID = "roundtable-summary";
|
||||
const ACTION_PLAN_AGENT_ID = "position-action-planner";
|
||||
/**
|
||||
* 收口阶段不是业务链里的普通席位,但仍是可独立配置的智能体。
|
||||
* 导出这两个固定 id,供测试分析岗右侧的「智能体配置」精确展示,避免
|
||||
* 又把当前业务链席位误当成收口阶段的配置项。
|
||||
*/
|
||||
export const SUMMARY_AGENT_ID = "roundtable-summary";
|
||||
export const ACTION_PLAN_AGENT_ID = "position-action-planner";
|
||||
|
||||
function builtinAgentId(kind: PositionBuiltinAgentKind) {
|
||||
return kind === "summary" ? SUMMARY_AGENT_ID : ACTION_PLAN_AGENT_ID;
|
||||
@@ -196,6 +210,7 @@ export function PositionBuiltinAgentChat({
|
||||
allDelivered,
|
||||
summaryDone,
|
||||
selectedModel,
|
||||
offlineDemo = false,
|
||||
autoStartRequestKey,
|
||||
builtinAgentOptions,
|
||||
onSelectBuiltinAgent,
|
||||
@@ -212,10 +227,19 @@ export function PositionBuiltinAgentChat({
|
||||
(kind === "summary" ? session?.summaryThreadId : session?.actionPlanThreadId) ||
|
||||
recordString(snapshot, "thread_id") ||
|
||||
null;
|
||||
const persistedConversation =
|
||||
kind === "summary" ? session?.summaryConversation : session?.actionPlanConversation;
|
||||
const persistedMessages = useMemo(
|
||||
() => readConversationMessages(persistedConversation),
|
||||
[persistedConversation],
|
||||
);
|
||||
const [inputDraft, setInputDraft] = useState(() =>
|
||||
readConversationDraft(persistedConversation),
|
||||
);
|
||||
const snapshotDone = snapshotStatus(snapshot) === "done";
|
||||
const snapshotStale = snapshotStatus(snapshot) === "stale";
|
||||
const [assistantId, setAssistantId] = useState(fallbackAgentId);
|
||||
const { agent } = useAgent(assistantId);
|
||||
const { agent } = useAgent(assistantId, { enabled: !offlineDemo });
|
||||
const agentDisplayName = desensitize(agent?.name || builtinAgentFallbackName(kind));
|
||||
const [boundThreadId, setBoundThreadId] = useState<string | null>(persistedThreadId);
|
||||
const boundThreadIdRef = useRef<string | null>(persistedThreadId);
|
||||
@@ -234,6 +258,7 @@ export function PositionBuiltinAgentChat({
|
||||
setAssistantId(fallbackAgentId);
|
||||
setBoundThreadId(persistedThreadId);
|
||||
boundThreadIdRef.current = persistedThreadId;
|
||||
setInputDraft(readConversationDraft(persistedConversation));
|
||||
draftThreadIdRef.current = uuid();
|
||||
pendingRunRef.current = null;
|
||||
pendingSubmissionRef.current = false;
|
||||
@@ -327,11 +352,89 @@ export function PositionBuiltinAgentChat({
|
||||
|
||||
const activeThreadId =
|
||||
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
|
||||
const conversationThreadId =
|
||||
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
|
||||
const displayThreadId = boundThreadId ?? `position-builtin-${kind}-${session?.id ?? "draft"}`;
|
||||
const threadContextValue = useMemo(() => ({ thread }), [thread]);
|
||||
const recoveredMessages = useMemo(
|
||||
() => mergeConversationMessages(persistedMessages, thread.messages),
|
||||
[persistedMessages, thread.messages],
|
||||
);
|
||||
const displayThread = useMemo(
|
||||
() =>
|
||||
({
|
||||
...thread,
|
||||
messages: recoveredMessages,
|
||||
}) as BaseStream<AgentThreadState>,
|
||||
[recoveredMessages, thread],
|
||||
);
|
||||
const threadContextValue = useMemo(() => ({ thread: displayThread }), [displayThread]);
|
||||
const isSubmitting = pendingSubmission !== null;
|
||||
const status = thread.error ? "error" : thread.isLoading || isSubmitting ? "streaming" : "ready";
|
||||
const persistedArtifacts = useMemo(() => readBuiltinArtifacts(snapshot), [snapshot]);
|
||||
const pendingConversationRef = useRef<{
|
||||
signature: string;
|
||||
threadId: string | null;
|
||||
snapshot: ReturnType<typeof buildConversationSnapshot>;
|
||||
} | null>(null);
|
||||
const lastPersistedConversationRef = useRef("");
|
||||
const conversationSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const flushConversationSnapshot = useCallback(() => {
|
||||
const currentSessionId = session?.id;
|
||||
const pending = pendingConversationRef.current;
|
||||
if (!currentSessionId || !pending) return;
|
||||
pendingConversationRef.current = null;
|
||||
if (conversationSaveTimerRef.current) {
|
||||
clearTimeout(conversationSaveTimerRef.current);
|
||||
conversationSaveTimerRef.current = null;
|
||||
}
|
||||
lastPersistedConversationRef.current = pending.signature;
|
||||
void savePositionBuiltinConversation(currentSessionId, kind, {
|
||||
threadId: pending.threadId,
|
||||
conversationSnapshot: pending.snapshot,
|
||||
})
|
||||
.then(onSessionUpdate)
|
||||
.catch((error) => {
|
||||
lastPersistedConversationRef.current = "";
|
||||
console.warn(`Failed to persist position ${kind} conversation snapshot`, error);
|
||||
});
|
||||
}, [kind, onSessionUpdate, session?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session || session.status === "archived") return;
|
||||
const conversationSnapshot = buildConversationSnapshot(recoveredMessages, inputDraft);
|
||||
const signature = JSON.stringify([
|
||||
conversationThreadId,
|
||||
conversationSnapshot.messages,
|
||||
conversationSnapshot.inputDraft ?? "",
|
||||
]);
|
||||
if (signature === lastPersistedConversationRef.current) return;
|
||||
pendingConversationRef.current = {
|
||||
signature,
|
||||
threadId: conversationThreadId,
|
||||
snapshot: conversationSnapshot,
|
||||
};
|
||||
if (conversationSaveTimerRef.current) clearTimeout(conversationSaveTimerRef.current);
|
||||
conversationSaveTimerRef.current = setTimeout(flushConversationSnapshot, 650);
|
||||
return () => {
|
||||
if (conversationSaveTimerRef.current) {
|
||||
clearTimeout(conversationSaveTimerRef.current);
|
||||
conversationSaveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
conversationThreadId,
|
||||
flushConversationSnapshot,
|
||||
inputDraft,
|
||||
recoveredMessages,
|
||||
session,
|
||||
]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
flushConversationSnapshot();
|
||||
},
|
||||
[flushConversationSnapshot],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!actualThreadId || actualThreadId === boundThreadIdRef.current) return;
|
||||
@@ -570,11 +673,11 @@ export function PositionBuiltinAgentChat({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{boundThreadId ? (
|
||||
{boundThreadId || recoveredMessages.length > 0 ? (
|
||||
<MessageList
|
||||
className="min-h-0 flex-1"
|
||||
threadId={boundThreadId}
|
||||
thread={thread}
|
||||
threadId={boundThreadId ?? displayThreadId}
|
||||
thread={displayThread}
|
||||
paddingBottom={24}
|
||||
scrollButtonClassName="bottom-6"
|
||||
// Use the built-in agent's regular submit path. This keeps a
|
||||
@@ -622,6 +725,9 @@ export function PositionBuiltinAgentChat({
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={!canWrite || isSubmitting}
|
||||
draftKey={`${session?.id ?? "draft"}:${kind}`}
|
||||
draftValue={inputDraft}
|
||||
onDraftChange={setInputDraft}
|
||||
hideAdminRecommendedQuestions
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onSubmit={(message) => void handleSubmit(message)}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
bindPositionNodeThread,
|
||||
completePositionNodeTurn,
|
||||
getPositionNodeContext,
|
||||
savePositionNodeConversation,
|
||||
type PositionRoundtableNode,
|
||||
} from "@/position-roundtable/api/sessions";
|
||||
import { listSessionArtifacts } from "@/roundtable-planning/api/multi-agent";
|
||||
@@ -33,6 +34,12 @@ import {
|
||||
type PositionBuiltinAgentSwitchOption,
|
||||
} from "@/position-roundtable/components/PositionAgentSwitcher";
|
||||
import { PositionAgentInput } from "@/position-roundtable/components/PositionAgentInput";
|
||||
import {
|
||||
buildConversationSnapshot,
|
||||
mergeConversationMessages,
|
||||
readConversationDraft,
|
||||
readConversationMessages,
|
||||
} from "@/position-roundtable/lib/conversation-snapshot";
|
||||
|
||||
interface PositionNodeSwitchOption {
|
||||
node: PositionRoundtableNode;
|
||||
@@ -47,6 +54,8 @@ interface PositionNodeChatProps {
|
||||
node: PositionRoundtableNode;
|
||||
currentPositionId: string;
|
||||
sessionStatus: "intent_pending" | "active" | "completed" | "archived";
|
||||
/** Frontend-only demo: render the SQL snapshot without resolving agent APIs. */
|
||||
offlineDemo?: boolean;
|
||||
agentName?: string | null;
|
||||
positionNodeOptions?: PositionNodeSwitchOption[];
|
||||
onSelectPositionNode?: (node: PositionRoundtableNode) => void;
|
||||
@@ -217,8 +226,9 @@ function writeFileCallKeys(messages: AgentThreadState["messages"]): Set<string>
|
||||
* Direct single-agent chat for one frozen business-chain node.
|
||||
*
|
||||
* It deliberately uses the regular `useThreadStream` + `MessageList` +
|
||||
* `PositionAgentInput` stack. The position Session merely records which existing thread
|
||||
* belongs to the node; it is not a second message persistence mechanism.
|
||||
* `PositionAgentInput` stack. LangGraph remains the live-chat source of truth, while
|
||||
* the position session also keeps a JSON recovery snapshot so a replaced checkpoint
|
||||
* volume does not turn an existing history into a blank page.
|
||||
*/
|
||||
export function PositionNodeChat({
|
||||
sessionId,
|
||||
@@ -227,6 +237,7 @@ export function PositionNodeChat({
|
||||
node,
|
||||
currentPositionId,
|
||||
sessionStatus,
|
||||
offlineDemo = false,
|
||||
agentName,
|
||||
positionNodeOptions,
|
||||
onSelectPositionNode,
|
||||
@@ -240,7 +251,7 @@ export function PositionNodeChat({
|
||||
}: PositionNodeChatProps) {
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
const [rejectionReasonOpen, setRejectionReasonOpen] = useState(false);
|
||||
const { agent } = useAgent(node.agentId);
|
||||
const { agent } = useAgent(node.agentId, { enabled: !offlineDemo });
|
||||
const agentDisplayName = desensitize(agentName || agent?.name || node.agentId);
|
||||
const lastRejection = node.status === "rejected" ? node.lastRejection : null;
|
||||
const rejectionReason =
|
||||
@@ -261,6 +272,13 @@ export function PositionNodeChat({
|
||||
const autoPreviewRunArmedRef = useRef(false);
|
||||
const editMessage = useEditMessage();
|
||||
const [messageContentOverrides, setMessageContentOverrides] = useState<Record<string, string>>({});
|
||||
const persistedMessages = useMemo(
|
||||
() => readConversationMessages(node.conversationSnapshot),
|
||||
[node.conversationSnapshot],
|
||||
);
|
||||
const [inputDraft, setInputDraft] = useState(() =>
|
||||
readConversationDraft(node.conversationSnapshot),
|
||||
);
|
||||
const nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]);
|
||||
const canWrite =
|
||||
sessionStatus !== "archived" &&
|
||||
@@ -272,9 +290,13 @@ export function PositionNodeChat({
|
||||
useEffect(() => {
|
||||
setBoundThreadId(node.threadId);
|
||||
boundThreadIdRef.current = node.threadId;
|
||||
setInputDraft(readConversationDraft(node.conversationSnapshot));
|
||||
setMessageContentOverrides({});
|
||||
autoPreviewBaselineCallKeysRef.current = new Set();
|
||||
autoPreviewRunArmedRef.current = false;
|
||||
// The component is keyed by nodeKey in the page. This reset is therefore
|
||||
// only a defensive guard for callers that reuse the component instance.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [node.nodeKey, node.threadId]);
|
||||
|
||||
const bindCreatedThread = useCallback(
|
||||
@@ -356,6 +378,82 @@ export function PositionNodeChat({
|
||||
|
||||
const activeThreadId =
|
||||
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? draftThreadIdRef.current;
|
||||
const persistedThreadId =
|
||||
boundThreadIdRef.current ?? actualThreadId ?? boundThreadId ?? null;
|
||||
const recoveredMessages = useMemo(
|
||||
() => mergeConversationMessages(persistedMessages, thread.messages),
|
||||
[persistedMessages, thread.messages],
|
||||
);
|
||||
const displayThread = useMemo(
|
||||
() =>
|
||||
({
|
||||
...thread,
|
||||
messages: recoveredMessages,
|
||||
}) as BaseStream<AgentThreadState>,
|
||||
[recoveredMessages, thread],
|
||||
);
|
||||
const pendingConversationRef = useRef<{
|
||||
signature: string;
|
||||
threadId: string | null;
|
||||
snapshot: ReturnType<typeof buildConversationSnapshot>;
|
||||
} | null>(null);
|
||||
const lastPersistedConversationRef = useRef("");
|
||||
const conversationSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const flushConversationSnapshot = useCallback(() => {
|
||||
const pending = pendingConversationRef.current;
|
||||
if (!pending) return;
|
||||
pendingConversationRef.current = null;
|
||||
if (conversationSaveTimerRef.current) {
|
||||
clearTimeout(conversationSaveTimerRef.current);
|
||||
conversationSaveTimerRef.current = null;
|
||||
}
|
||||
lastPersistedConversationRef.current = pending.signature;
|
||||
void savePositionNodeConversation(sessionId, node.nodeKey, {
|
||||
threadId: pending.threadId,
|
||||
conversationSnapshot: pending.snapshot,
|
||||
})
|
||||
.then(onNodeUpdate)
|
||||
.catch((error) => {
|
||||
// Draft autosave is deliberately quiet during typing, but leave the
|
||||
// signature retryable so the next edit/stream delta writes it again.
|
||||
lastPersistedConversationRef.current = "";
|
||||
console.warn("Failed to persist position node conversation snapshot", error);
|
||||
});
|
||||
}, [node.nodeKey, onNodeUpdate, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionStatus === "archived") return;
|
||||
const snapshot = buildConversationSnapshot(recoveredMessages, inputDraft);
|
||||
const signature = JSON.stringify([
|
||||
persistedThreadId,
|
||||
snapshot.messages,
|
||||
snapshot.inputDraft ?? "",
|
||||
]);
|
||||
if (signature === lastPersistedConversationRef.current) return;
|
||||
pendingConversationRef.current = { signature, threadId: persistedThreadId, snapshot };
|
||||
if (conversationSaveTimerRef.current) clearTimeout(conversationSaveTimerRef.current);
|
||||
conversationSaveTimerRef.current = setTimeout(flushConversationSnapshot, 650);
|
||||
return () => {
|
||||
if (conversationSaveTimerRef.current) {
|
||||
clearTimeout(conversationSaveTimerRef.current);
|
||||
conversationSaveTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
flushConversationSnapshot,
|
||||
inputDraft,
|
||||
persistedThreadId,
|
||||
recoveredMessages,
|
||||
sessionStatus,
|
||||
]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
// A quick position switch must not discard the last <650 ms of typing.
|
||||
flushConversationSnapshot();
|
||||
},
|
||||
[flushConversationSnapshot],
|
||||
);
|
||||
const latestAiMessageId = useMemo(() => {
|
||||
for (let index = thread.messages.length - 1; index >= 0; index -= 1) {
|
||||
const message = thread.messages[index];
|
||||
@@ -556,7 +654,7 @@ export function PositionNodeChat({
|
||||
|
||||
const status = thread.error ? "error" : thread.isLoading ? "streaming" : "ready";
|
||||
const displayThreadId = boundThreadId ?? `position-node-${node.id}`;
|
||||
const threadContextValue = useMemo(() => ({ thread }), [thread]);
|
||||
const threadContextValue = useMemo(() => ({ thread: displayThread }), [displayThread]);
|
||||
const readOnlyHint =
|
||||
sessionStatus === "archived"
|
||||
? "该会话已归档,仅可查看历史对话和产物"
|
||||
@@ -678,11 +776,11 @@ export function PositionNodeChat({
|
||||
</section>
|
||||
)}
|
||||
|
||||
{boundThreadId ? (
|
||||
{boundThreadId || recoveredMessages.length > 0 ? (
|
||||
<MessageList
|
||||
className="min-h-0 flex-1"
|
||||
threadId={boundThreadId}
|
||||
thread={thread}
|
||||
threadId={boundThreadId ?? displayThreadId}
|
||||
thread={displayThread}
|
||||
paddingBottom={24}
|
||||
scrollButtonClassName="bottom-6"
|
||||
// Clarification cards are rendered by MessageList, but position
|
||||
@@ -722,6 +820,9 @@ export function PositionNodeChat({
|
||||
status={status}
|
||||
context={settings.context}
|
||||
disabled={!canWrite}
|
||||
draftKey={`${sessionId}:${node.nodeKey}`}
|
||||
draftValue={inputDraft}
|
||||
onDraftChange={setInputDraft}
|
||||
hideAdminRecommendedQuestions
|
||||
onContextChange={(context) => setSettings("context", context)}
|
||||
onSubmit={(message) => void handleSubmit(message)}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { readRuntimeFlag } from "@/env";
|
||||
|
||||
/**
|
||||
* Seeds the canonical demo into SQL when the gateway is available and falls
|
||||
* back to the same JSON snapshot when it is not.
|
||||
*/
|
||||
export const POSITION_ROUNDTABLE_DEMO_FLAG =
|
||||
"VITE_POSITION_ROUNDTABLE_DEMO_MODE";
|
||||
|
||||
/**
|
||||
* Kiosk/standalone switch. This is deliberately separate from demo seeding:
|
||||
* when enabled the app bypasses login, renders only the position-roundtable
|
||||
* workspace and must not issue any backend request.
|
||||
*/
|
||||
export const POSITION_ROUNDTABLE_STANDALONE_FLAG =
|
||||
"VITE_POSITION_ROUNDTABLE_STANDALONE_MODE";
|
||||
|
||||
export const POSITION_ROUNDTABLE_ROUTE =
|
||||
"/page/strategy/qa/position-roundtable";
|
||||
|
||||
export function isPositionRoundtableStandaloneMode(): boolean {
|
||||
return readRuntimeFlag(POSITION_ROUNDTABLE_STANDALONE_FLAG);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone mode always consumes the full demo fixture, so operators only
|
||||
* need to enable the standalone flag (the SQL-seeding flag may remain false).
|
||||
*/
|
||||
export function isPositionRoundtableDemoEnabled(): boolean {
|
||||
return (
|
||||
isPositionRoundtableStandaloneMode() ||
|
||||
readRuntimeFlag(POSITION_ROUNDTABLE_DEMO_FLAG)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
seedPositionDemoSession,
|
||||
type PositionDemoSeedPayload,
|
||||
type PositionRoundtableNode,
|
||||
type PositionRoundtableSession,
|
||||
} from "@/position-roundtable/api/sessions";
|
||||
import type { PositionTask } from "@/position-roundtable/lib/mock-task-source";
|
||||
import {
|
||||
isPositionRoundtableDemoEnabled,
|
||||
isPositionRoundtableStandaloneMode,
|
||||
POSITION_ROUNDTABLE_DEMO_FLAG,
|
||||
POSITION_ROUNDTABLE_ROUTE,
|
||||
POSITION_ROUNDTABLE_STANDALONE_FLAG,
|
||||
} from "./position-roundtable-demo-config";
|
||||
|
||||
import fixture from "./position-roundtable-full.json";
|
||||
|
||||
export const POSITION_ROUNDTABLE_DEMO_SESSION_PREFIX = "local-demo-position-roundtable-";
|
||||
export {
|
||||
isPositionRoundtableDemoEnabled,
|
||||
isPositionRoundtableStandaloneMode,
|
||||
POSITION_ROUNDTABLE_DEMO_FLAG,
|
||||
POSITION_ROUNDTABLE_ROUTE,
|
||||
POSITION_ROUNDTABLE_STANDALONE_FLAG,
|
||||
};
|
||||
|
||||
type DemoFixture = {
|
||||
demoKey: string;
|
||||
session: PositionRoundtableSession;
|
||||
};
|
||||
|
||||
const canonicalFixture = fixture as unknown as DemoFixture;
|
||||
let initialization: Promise<PositionRoundtableSession> | null = null;
|
||||
|
||||
function clone<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(value)) as T;
|
||||
}
|
||||
|
||||
function nodeSeed(node: PositionRoundtableNode): Record<string, unknown> {
|
||||
return {
|
||||
node_key: node.nodeKey,
|
||||
stage_index: node.stageIndex,
|
||||
seat_index: node.seatIndex,
|
||||
agent_id: node.agentId,
|
||||
position_id: node.positionId,
|
||||
thread_id: node.threadId,
|
||||
status: node.status,
|
||||
latest_answer: node.latestAnswer,
|
||||
artifact_manifest: node.artifactManifest,
|
||||
conversation_snapshot: node.conversationSnapshot,
|
||||
revision: node.revision,
|
||||
upstream_revision: node.upstreamRevision,
|
||||
rejection_count: node.rejectionCount,
|
||||
last_rejection: node.lastRejection,
|
||||
invalidated_by: node.invalidatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function seedPayload(session: PositionRoundtableSession): PositionDemoSeedPayload {
|
||||
return {
|
||||
demo_key: canonicalFixture.demoKey,
|
||||
session: {
|
||||
external_task_id: session.externalTaskId,
|
||||
task_snapshot: session.taskSnapshot,
|
||||
chain_id: session.chainId,
|
||||
chain_snapshot: session.chainSnapshot,
|
||||
intent_thread_id: session.intentThreadId,
|
||||
intent_snapshot: session.intentSnapshot,
|
||||
intent_conversation: session.intentConversation,
|
||||
summary_thread_id: session.summaryThreadId,
|
||||
summary_snapshot: session.summarySnapshot,
|
||||
summary_conversation: session.summaryConversation,
|
||||
action_plan_thread_id: session.actionPlanThreadId,
|
||||
action_plan_snapshot: session.actionPlanSnapshot,
|
||||
action_plan_conversation: session.actionPlanConversation,
|
||||
status: session.status,
|
||||
},
|
||||
nodes: (session.nodes ?? []).map(nodeSeed),
|
||||
};
|
||||
}
|
||||
|
||||
export function isLocalPositionDemoSession(sessionId: string | null | undefined): boolean {
|
||||
return Boolean(sessionId?.startsWith(POSITION_ROUNDTABLE_DEMO_SESSION_PREFIX));
|
||||
}
|
||||
|
||||
/** Return an isolated copy so page edits never mutate the imported JSON module. */
|
||||
export function getLocalPositionDemoSession(): PositionRoundtableSession {
|
||||
return clone(canonicalFixture.session);
|
||||
}
|
||||
|
||||
export function getPositionDemoTask(): PositionTask {
|
||||
const task = canonicalFixture.session.taskSnapshot;
|
||||
return {
|
||||
id: String(task.id ?? canonicalFixture.session.externalTaskId ?? "demo-position-task"),
|
||||
title: String(task.title ?? "岗位会商演示任务"),
|
||||
description: String(task.description ?? ""),
|
||||
direction: String(task.direction ?? ""),
|
||||
source: String(task.source ?? "演示数据"),
|
||||
issuedAt: String(task.issuedAt ?? ""),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed SQL when possible; otherwise return the exact same fixture locally.
|
||||
*
|
||||
* A module-level promise prevents StrictMode and multiple page effects from
|
||||
* creating parallel requests. Failures are deliberately non-fatal because
|
||||
* the demo must remain viewable with the gateway, database or network offline.
|
||||
*/
|
||||
export function initializePositionRoundtableDemo(): Promise<PositionRoundtableSession> {
|
||||
if (isPositionRoundtableStandaloneMode()) {
|
||||
return Promise.resolve(getLocalPositionDemoSession());
|
||||
}
|
||||
if (!initialization) {
|
||||
const localSession = getLocalPositionDemoSession();
|
||||
initialization = seedPositionDemoSession(seedPayload(localSession)).catch((error) => {
|
||||
console.warn(
|
||||
"Position roundtable demo backend seed failed; using local JSON fallback",
|
||||
error,
|
||||
);
|
||||
return localSession;
|
||||
});
|
||||
}
|
||||
return initialization;
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
{
|
||||
"demoKey": "summer-camp-full-workflow-v1",
|
||||
"session": {
|
||||
"id": "local-demo-position-roundtable-full-v1",
|
||||
"externalTaskId": "demo-position-task-summer-camp-v1",
|
||||
"taskSnapshot": {
|
||||
"id": "demo-position-task-summer-camp-v1",
|
||||
"title": "暑期游泳夏令营宣传",
|
||||
"description": "围绕暑期游泳夏令营开展招生宣传,建立家长信任并提升有效报名转化。",
|
||||
"direction": "暑期活动",
|
||||
"source": "演示数据",
|
||||
"issuedAt": "2026-07-23 09:00:00"
|
||||
},
|
||||
"chainId": "demo-summer-camp-chain-v1",
|
||||
"chainSnapshot": {
|
||||
"id": "demo-summer-camp-chain-v1",
|
||||
"title": "暑期活动全流程分析链",
|
||||
"description": "从意图识别、目的分析、行为分析、预期评估到组织协同的完整岗位会商流程。",
|
||||
"seats": [
|
||||
{
|
||||
"agent_id": "demo-intelligence-agent",
|
||||
"name": "情报分析智能体",
|
||||
"description": "识别任务目标、约束与关键假设",
|
||||
"position_id": "intelligence"
|
||||
},
|
||||
{
|
||||
"agent_id": "demo-md-analysis-agent",
|
||||
"name": "MD分析智能体",
|
||||
"description": "分析宣传目的并筛选核心目标",
|
||||
"position_id": "planning"
|
||||
},
|
||||
{
|
||||
"agent_id": "demo-xwt-analysis-agent",
|
||||
"name": "XWT分析智能体",
|
||||
"description": "分析关键行为体与协同关系",
|
||||
"position_id": "solution"
|
||||
},
|
||||
{
|
||||
"agent_id": "demo-behavior-analysis-agent",
|
||||
"name": "预期行为与QDY分析智能体",
|
||||
"description": "推演预期行为、驱动因素与风险",
|
||||
"position_id": "evaluation"
|
||||
},
|
||||
{
|
||||
"agent_id": "demo-xs-analysis-agent",
|
||||
"name": "XS分析智能体",
|
||||
"description": "形成组织协同与执行保障建议",
|
||||
"position_id": "planning"
|
||||
}
|
||||
],
|
||||
"stages": [
|
||||
["demo-intelligence-agent"],
|
||||
["demo-md-analysis-agent"],
|
||||
["demo-xwt-analysis-agent"],
|
||||
["demo-behavior-analysis-agent"],
|
||||
["demo-xs-analysis-agent"]
|
||||
],
|
||||
"stageGoals": [
|
||||
"确认任务意图、关键约束与假设",
|
||||
"筛选宣传任务的核心目的",
|
||||
"识别关键行为体及协同关系",
|
||||
"推演预期行为、驱动因素和主要风险",
|
||||
"形成组织协同、资源配置和执行保障建议"
|
||||
],
|
||||
"coordinatorPrompt": "按阶段顺序承接上游有效交付,每个岗位必须输出完整 Markdown 报告。",
|
||||
"seatMode": "pro",
|
||||
"gatherFirst": false
|
||||
},
|
||||
"intentThreadId": null,
|
||||
"intentSnapshot": {
|
||||
"objective": "围绕暑期游泳夏令营开展持续宣传推广,建立家长信任并提升有效报名转化。",
|
||||
"constraints": [
|
||||
"总预算不少于 15 万元",
|
||||
"宣传周期不少于 2 个月",
|
||||
"重点覆盖青少年及其家长",
|
||||
"宣传方案需在暑期开始前落地"
|
||||
],
|
||||
"assumptions": [
|
||||
"夏令营已有明确课程内容、师资与场地",
|
||||
"可组合使用线上内容投放、私域运营和线下活动",
|
||||
"报名承接页面和咨询团队能够配合宣传节奏"
|
||||
]
|
||||
},
|
||||
"intentConversation": {
|
||||
"messages": [
|
||||
{
|
||||
"id": 1,
|
||||
"sender": "agent",
|
||||
"senderName": "RW理解智能体",
|
||||
"content": "我已读取任务信息。为了形成可执行的宣传方案,请补充预算、宣传周期和目标受众。",
|
||||
"time": "09:02"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"sender": "user",
|
||||
"senderName": "用户",
|
||||
"content": "预算不少于15万元,宣传周期两个月以上,主要面向青少年及其家长。",
|
||||
"time": "09:04"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"sender": "agent",
|
||||
"senderName": "RW理解智能体",
|
||||
"content": "信息已经充分。我会把报名转化与家长信任建设作为核心目标,并将周期、预算和落地时间作为硬约束。",
|
||||
"time": "09:05"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"sender": "agent",
|
||||
"senderName": "RW理解智能体",
|
||||
"content": "✅ 任务意图已明确\n\n核心目标:围绕暑期游泳夏令营开展持续宣传推广,建立家长信任并提升有效报名转化。\n\n关键约束:预算不少于15万元;宣传周期不少于2个月;重点覆盖青少年及其家长;暑期开始前落地。",
|
||||
"time": "09:06"
|
||||
}
|
||||
],
|
||||
"timelineEntries": [
|
||||
{
|
||||
"id": 1,
|
||||
"time": "09:02",
|
||||
"title": "读取任务",
|
||||
"desc": "已载入暑期游泳夏令营宣传任务"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"time": "09:04",
|
||||
"title": "补齐约束",
|
||||
"desc": "确认预算、周期与目标受众"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"time": "09:06",
|
||||
"title": "意图确认",
|
||||
"desc": "形成可执行的结构化任务意图"
|
||||
}
|
||||
],
|
||||
"inputDraft": "",
|
||||
"savedAt": "2026-07-23T09:06:00+08:00"
|
||||
},
|
||||
"summaryThreadId": null,
|
||||
"summarySnapshot": {
|
||||
"status": "done",
|
||||
"title": "暑期游泳夏令营岗位会商方案总结报告.md",
|
||||
"content": "方案围绕“报名转化、品牌信任、私域沉淀”三项目标,采用预热、爆发、冲刺、沉淀四阶段推进,并建立跨岗位协同和风险校正机制。",
|
||||
"artifact_manifest": [
|
||||
{
|
||||
"name": "暑期游泳夏令营岗位会商方案总结报告.md",
|
||||
"path": "mnt/user-data/outputs/暑期游泳夏令营岗位会商方案总结报告.md",
|
||||
"mime_type": "text/markdown",
|
||||
"content": "# 暑期游泳夏令营岗位会商方案总结报告\n\n## 一、方案结论\n\n本方案以**报名转化**为主目标,以**家长信任建设**为关键支撑,以**私域用户沉淀**为长期资产,形成“认知—信任—咨询—报名—复购”的完整链路。\n\n## 二、核心策略\n\n1. **预热期(第1—2周)**:完成品牌内容、师资安全背书、报名页面和咨询话术准备。\n2. **蓄水期(第3—4周)**:通过短视频、公众号、家长社群和异业合作积累意向线索。\n3. **爆发期(第5—6周)**:集中投放高转化素材,配合公开课、场馆体验日推动咨询转报名。\n4. **冲刺沉淀期(第7—8周)**:针对未转化线索分层跟进,同时沉淀家长私域资产。\n\n## 三、资源建议\n\n- 媒体与内容投放:40%\n- 线下体验及异业合作:25%\n- 内容制作与品牌背书:20%\n- 私域运营及咨询承接:10%\n- 风险与机动预算:5%\n\n## 四、关键风险\n\n- 安全信息表达不充分会直接影响家长决策。\n- 投放与咨询承接不同步会造成线索浪费。\n- 暑期档期变化需要保留可调整的活动与预算窗口。\n\n## 五、建议指标\n\n关注曝光到咨询率、咨询到体验率、体验到报名率、单个有效报名成本、家长私域留存率五项核心指标。"
|
||||
}
|
||||
],
|
||||
"primary_artifact": {
|
||||
"name": "暑期游泳夏令营岗位会商方案总结报告.md",
|
||||
"path": "mnt/user-data/outputs/暑期游泳夏令营岗位会商方案总结报告.md",
|
||||
"mime_type": "text/markdown",
|
||||
"content": "# 暑期游泳夏令营岗位会商方案总结报告\n\n## 一、方案结论\n\n本方案以**报名转化**为主目标,以**家长信任建设**为关键支撑,以**私域用户沉淀**为长期资产,形成“认知—信任—咨询—报名—复购”的完整链路。\n\n## 二、核心策略\n\n1. 预热期:完成品牌内容、安全背书与承接准备。\n2. 蓄水期:多渠道积累意向线索。\n3. 爆发期:集中投放并推动体验转报名。\n4. 冲刺沉淀期:分层跟进并沉淀私域资产。\n\n## 三、风险提示\n\n安全表达、咨询承接和暑期档期是需要持续监测的三项风险。"
|
||||
},
|
||||
"source_revisions": {
|
||||
"stage-1-seat-1-demo-intelligence-agent": 1,
|
||||
"stage-2-seat-1-demo-md-analysis-agent": 1,
|
||||
"stage-3-seat-1-demo-xwt-analysis-agent": 1,
|
||||
"stage-4-seat-1-demo-behavior-analysis-agent": 1,
|
||||
"stage-5-seat-1-demo-xs-analysis-agent": 1
|
||||
},
|
||||
"generated_at": "2026-07-23T10:20:00+08:00"
|
||||
},
|
||||
"summaryConversation": {
|
||||
"messages": [
|
||||
{
|
||||
"id": "demo-summary-human-1",
|
||||
"type": "human",
|
||||
"content": "请基于当前所有岗位交付,开始生成方案总结。"
|
||||
},
|
||||
{
|
||||
"id": "demo-summary-ai-1",
|
||||
"type": "ai",
|
||||
"content": "已汇总全部岗位材料。最终方案以报名转化为主线、家长信任为支撑、私域沉淀为长期资产,并按照预热、蓄水、爆发、冲刺沉淀四阶段执行。\n\n完整 Markdown 报告已经交付。"
|
||||
}
|
||||
],
|
||||
"inputDraft": "",
|
||||
"savedAt": "2026-07-23T10:20:00+08:00"
|
||||
},
|
||||
"actionPlanThreadId": null,
|
||||
"actionPlanSnapshot": {
|
||||
"status": "done",
|
||||
"title": "暑期游泳夏令营行动规划.md",
|
||||
"content": "行动规划按8周拆解任务、责任岗位、交付物和验收指标,确保内容、投放、体验活动、咨询承接与复盘同步推进。",
|
||||
"artifact_manifest": [
|
||||
{
|
||||
"name": "暑期游泳夏令营行动规划.md",
|
||||
"path": "mnt/user-data/outputs/暑期游泳夏令营行动规划.md",
|
||||
"mime_type": "text/markdown",
|
||||
"content": "# 暑期游泳夏令营行动规划\n\n## 第1—2周:准备与预热\n\n- 明确招生目标、班型、名额和价格策略。\n- 完成安全保障、师资介绍、课程亮点三类核心素材。\n- 上线报名页面、咨询SOP和数据看板。\n\n## 第3—4周:线索蓄水\n\n- 启动短视频、公众号、社群和本地生活平台内容投放。\n- 联合学校周边商户、亲子机构和社区开展异业引流。\n- 每周复盘渠道线索质量,淘汰低效素材。\n\n## 第5—6周:转化爆发\n\n- 举办场馆开放日和体验课。\n- 对高意向家庭安排一对一咨询与限时权益。\n- 保证线索在30分钟内首次响应、24小时内二次跟进。\n\n## 第7—8周:冲刺与沉淀\n\n- 对未报名家庭按顾虑类型进行分层触达。\n- 完成报名用户入群、行前说明和安全告知。\n- 输出渠道ROI、转化漏斗和下一周期优化清单。\n\n## 责任分工\n\n| 任务 | 责任角色 | 验收标准 |\n|---|---|---|\n| 内容与品牌背书 | 内容运营 | 三类核心素材按期上线 |\n| 媒体投放 | 增长运营 | 每周完成渠道ROI复盘 |\n| 体验活动 | 活动执行 | 到场率和报名转化达标 |\n| 咨询承接 | 招生顾问 | 首次响应不超过30分钟 |\n| 数据复盘 | 项目负责人 | 每周输出行动校正清单 |"
|
||||
}
|
||||
],
|
||||
"primary_artifact": {
|
||||
"name": "暑期游泳夏令营行动规划.md",
|
||||
"path": "mnt/user-data/outputs/暑期游泳夏令营行动规划.md",
|
||||
"mime_type": "text/markdown",
|
||||
"content": "# 暑期游泳夏令营行动规划\n\n## 8周执行节奏\n\n- 第1—2周:准备品牌素材、报名承接和安全背书。\n- 第3—4周:多渠道蓄水并持续优化线索质量。\n- 第5—6周:体验活动与高意向咨询集中转化。\n- 第7—8周:冲刺报名、用户入群和数据复盘。\n\n每项任务均配置责任角色、时间节点和验收标准。"
|
||||
},
|
||||
"source_revisions": {
|
||||
"stage-1-seat-1-demo-intelligence-agent": 1,
|
||||
"stage-2-seat-1-demo-md-analysis-agent": 1,
|
||||
"stage-3-seat-1-demo-xwt-analysis-agent": 1,
|
||||
"stage-4-seat-1-demo-behavior-analysis-agent": 1,
|
||||
"stage-5-seat-1-demo-xs-analysis-agent": 1
|
||||
},
|
||||
"generated_at": "2026-07-23T10:35:00+08:00"
|
||||
},
|
||||
"actionPlanConversation": {
|
||||
"messages": [
|
||||
{
|
||||
"id": "demo-action-human-1",
|
||||
"type": "human",
|
||||
"content": "请基于当前方案总结,生成可执行的行动规划。"
|
||||
},
|
||||
{
|
||||
"id": "demo-action-ai-1",
|
||||
"type": "ai",
|
||||
"content": "行动规划已按8周拆解为准备预热、线索蓄水、转化爆发、冲刺沉淀四个阶段,并补充责任角色、验收标准和风险校正节点。\n\n完整 Markdown 行动规划已经交付。"
|
||||
}
|
||||
],
|
||||
"inputDraft": "",
|
||||
"savedAt": "2026-07-23T10:35:00+08:00"
|
||||
},
|
||||
"status": "completed",
|
||||
"createdAt": "2026-07-23T09:00:00+08:00",
|
||||
"updatedAt": "2026-07-23T10:35:00+08:00",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "local-demo-node-intelligence",
|
||||
"sessionId": "local-demo-position-roundtable-full-v1",
|
||||
"nodeKey": "stage-1-seat-1-demo-intelligence-agent",
|
||||
"stageIndex": 0,
|
||||
"seatIndex": 0,
|
||||
"agentId": "demo-intelligence-agent",
|
||||
"positionId": "intelligence",
|
||||
"threadId": null,
|
||||
"status": "done",
|
||||
"latestAnswer": "情报分析岗已完成任务意图识别,确认核心目标、预算周期约束和关键假设。",
|
||||
"artifactManifest": [],
|
||||
"conversationSnapshot": {
|
||||
"messages": [
|
||||
{
|
||||
"id": "demo-intelligence-human-1",
|
||||
"type": "human",
|
||||
"content": "请确认当前任务意图和关键约束。"
|
||||
},
|
||||
{
|
||||
"id": "demo-intelligence-ai-1",
|
||||
"type": "ai",
|
||||
"content": "任务意图已经确认:围绕暑期游泳夏令营开展持续宣传推广,核心是建立家长信任并提升有效报名转化。预算不少于15万元,周期不少于2个月。"
|
||||
}
|
||||
],
|
||||
"inputDraft": "",
|
||||
"savedAt": "2026-07-23T09:06:00+08:00"
|
||||
},
|
||||
"revision": 1,
|
||||
"upstreamRevision": {},
|
||||
"rejectionCount": 0,
|
||||
"lastRejection": null,
|
||||
"invalidatedBy": null,
|
||||
"createdAt": "2026-07-23T09:06:00+08:00",
|
||||
"updatedAt": "2026-07-23T09:06:00+08:00"
|
||||
},
|
||||
{
|
||||
"id": "local-demo-node-md",
|
||||
"sessionId": "local-demo-position-roundtable-full-v1",
|
||||
"nodeKey": "stage-2-seat-1-demo-md-analysis-agent",
|
||||
"stageIndex": 1,
|
||||
"seatIndex": 0,
|
||||
"agentId": "demo-md-analysis-agent",
|
||||
"positionId": "planning",
|
||||
"threadId": null,
|
||||
"status": "done",
|
||||
"latestAnswer": "目的分析已完成,筛选出报名转化、品牌信任建设和私域用户沉淀三项核心目的。",
|
||||
"artifactManifest": [
|
||||
{
|
||||
"name": "目的分析报告-暑期游泳夏令营宣传.md",
|
||||
"path": "mnt/user-data/outputs/目的分析报告-暑期游泳夏令营宣传.md",
|
||||
"mime_type": "text/markdown",
|
||||
"size_bytes": 2468,
|
||||
"modified_at": "2026-07-23T09:25:00+08:00",
|
||||
"content": "# 目的分析报告:暑期游泳夏令营宣传\n\n## Top 1:报名转化\n\n通过精准触达、体验活动和高效咨询承接,最大化有效报名人数。核心指标包括有效线索数、咨询率、体验到场率和报名转化率。\n\n## Top 2:品牌信任建设\n\n围绕安全保障、专业师资、课程设计和场馆资质建立家长信任,降低家长在安全性与课程价值上的决策顾虑。\n\n## Top 3:私域用户沉淀\n\n将咨询家庭沉淀到家长社群和企业微信,形成可持续触达的用户资产,为后续续班、转介绍和其他课程推广提供基础。\n\n## 目的协同关系\n\n三项目的形成“转化主线 + 信任支撑 + 私域副线”:信任建设提升报名转化,报名过程同步沉淀私域,私域运营进一步促进转介绍。"
|
||||
}
|
||||
],
|
||||
"conversationSnapshot": {
|
||||
"messages": [
|
||||
{
|
||||
"id": "demo-md-human-1",
|
||||
"type": "human",
|
||||
"content": "请基于已确认意图分析宣传任务的核心目的,并形成正式报告。"
|
||||
},
|
||||
{
|
||||
"id": "demo-md-ai-1",
|
||||
"type": "ai",
|
||||
"content": "目的分析已完成。我从候选目的中筛选出报名转化、品牌信任建设和私域用户沉淀三项核心目的,并说明了三者的协同关系。完整 Markdown 报告已交付。"
|
||||
}
|
||||
],
|
||||
"inputDraft": "",
|
||||
"savedAt": "2026-07-23T09:25:00+08:00"
|
||||
},
|
||||
"revision": 1,
|
||||
"upstreamRevision": {
|
||||
"stage-1-seat-1-demo-intelligence-agent": 1
|
||||
},
|
||||
"rejectionCount": 0,
|
||||
"lastRejection": null,
|
||||
"invalidatedBy": null,
|
||||
"createdAt": "2026-07-23T09:10:00+08:00",
|
||||
"updatedAt": "2026-07-23T09:25:00+08:00"
|
||||
},
|
||||
{
|
||||
"id": "local-demo-node-xwt",
|
||||
"sessionId": "local-demo-position-roundtable-full-v1",
|
||||
"nodeKey": "stage-3-seat-1-demo-xwt-analysis-agent",
|
||||
"stageIndex": 2,
|
||||
"seatIndex": 0,
|
||||
"agentId": "demo-xwt-analysis-agent",
|
||||
"positionId": "solution",
|
||||
"threadId": null,
|
||||
"status": "done",
|
||||
"latestAnswer": "行为体分析已完成,明确家长、青少年、招生顾问、教练团队和合作渠道的角色及协同关系。",
|
||||
"artifactManifest": [
|
||||
{
|
||||
"name": "行为体分析报告-暑期游泳夏令营宣传.md",
|
||||
"path": "mnt/user-data/outputs/行为体分析报告-暑期游泳夏令营宣传.md",
|
||||
"mime_type": "text/markdown",
|
||||
"size_bytes": 2812,
|
||||
"modified_at": "2026-07-23T09:42:00+08:00",
|
||||
"content": "# 行为体分析报告:暑期游泳夏令营宣传\n\n## 核心行为体\n\n| 行为体 | 角色 | 关键关注 |\n|---|---|---|\n| 家长 | 决策者与付款者 | 安全、师资、课程价值、时间安排 |\n| 青少年学员 | 使用者与影响者 | 趣味性、同伴体验、成长获得感 |\n| 招生顾问 | 转化承接者 | 线索质量、响应效率、标准话术 |\n| 教练团队 | 专业与安全背书 | 教学质量、安全规范、服务体验 |\n| 合作渠道 | 触达与增信伙伴 | 用户匹配、合作收益、品牌风险 |\n\n## 关键关系\n\n- 家长通过教练团队和场馆资质判断安全与专业程度。\n- 青少年的体验反馈显著影响家长最终决策。\n- 招生顾问需要承接投放线索,并把高频顾虑反馈给内容团队。\n- 合作渠道负责扩大本地触达,同时为活动提供第三方信任背书。\n\n## 协同建议\n\n建立“内容投放—咨询承接—体验活动—报名转化—私域沉淀”的闭环,每周同步线索质量、家长顾虑和转化数据。"
|
||||
}
|
||||
],
|
||||
"conversationSnapshot": {
|
||||
"messages": [
|
||||
{
|
||||
"id": "demo-xwt-human-1",
|
||||
"type": "human",
|
||||
"content": "请识别宣传任务中的关键行为体、角色关系与协同机制。"
|
||||
},
|
||||
{
|
||||
"id": "demo-xwt-ai-1",
|
||||
"type": "ai",
|
||||
"content": "行为体分析已完成。家长是核心决策者,青少年是体验影响者,招生顾问和教练团队负责转化与专业背书,合作渠道扩大本地触达。完整 Markdown 报告已交付。"
|
||||
}
|
||||
],
|
||||
"inputDraft": "",
|
||||
"savedAt": "2026-07-23T09:42:00+08:00"
|
||||
},
|
||||
"revision": 1,
|
||||
"upstreamRevision": {
|
||||
"stage-1-seat-1-demo-intelligence-agent": 1,
|
||||
"stage-2-seat-1-demo-md-analysis-agent": 1
|
||||
},
|
||||
"rejectionCount": 0,
|
||||
"lastRejection": null,
|
||||
"invalidatedBy": null,
|
||||
"createdAt": "2026-07-23T09:26:00+08:00",
|
||||
"updatedAt": "2026-07-23T09:42:00+08:00"
|
||||
},
|
||||
{
|
||||
"id": "local-demo-node-behavior",
|
||||
"sessionId": "local-demo-position-roundtable-full-v1",
|
||||
"nodeKey": "stage-4-seat-1-demo-behavior-analysis-agent",
|
||||
"stageIndex": 3,
|
||||
"seatIndex": 0,
|
||||
"agentId": "demo-behavior-analysis-agent",
|
||||
"positionId": "evaluation",
|
||||
"threadId": null,
|
||||
"status": "done",
|
||||
"latestAnswer": "预期行为分析已完成,形成家长咨询、学员体验、顾问跟进和渠道协同的行为预期及风险校正建议。",
|
||||
"artifactManifest": [
|
||||
{
|
||||
"name": "预期行为与驱动因素分析报告-暑期游泳夏令营宣传.md",
|
||||
"path": "mnt/user-data/outputs/预期行为与驱动因素分析报告-暑期游泳夏令营宣传.md",
|
||||
"mime_type": "text/markdown",
|
||||
"size_bytes": 3016,
|
||||
"modified_at": "2026-07-23T09:58:00+08:00",
|
||||
"content": "# 预期行为与驱动因素分析报告\n\n## 家长预期行为\n\n家长将先浏览安全、师资和课程内容,再进行价格与时间比较;高意向家庭通常会咨询细节、参加体验活动并在获得安全承诺后完成报名。\n\n**主要驱动因素**:孩子兴趣、场馆安全、教练资质、同龄口碑、时间便利性。\n\n## 学员预期行为\n\n青少年更容易被趣味课程、同伴互动、进阶成果和可视化荣誉吸引,体验活动后的正向反馈会显著影响家长决策。\n\n## 招生顾问预期行为\n\n顾问需要快速响应、识别顾虑、推荐合适班型,并在体验后24小时内完成重点跟进。\n\n## 风险与校正\n\n- 安全信息不清:增加安全流程视频、保险与资质说明。\n- 咨询响应慢:设置30分钟首响和未接通回访机制。\n- 体验到场低:活动前24小时二次提醒并提供路线指引。\n- 投放线索质量低:每周按渠道计算有效线索成本并动态调配预算。"
|
||||
}
|
||||
],
|
||||
"conversationSnapshot": {
|
||||
"messages": [
|
||||
{
|
||||
"id": "demo-behavior-human-1",
|
||||
"type": "human",
|
||||
"content": "请推演各关键行为体的预期行为、驱动因素和风险。"
|
||||
},
|
||||
{
|
||||
"id": "demo-behavior-ai-1",
|
||||
"type": "ai",
|
||||
"content": "已完成家长、学员、招生顾问和合作渠道的行为推演,并给出安全表达、咨询响应、体验到场和渠道质量四类风险的校正建议。完整 Markdown 报告已交付。"
|
||||
}
|
||||
],
|
||||
"inputDraft": "",
|
||||
"savedAt": "2026-07-23T09:58:00+08:00"
|
||||
},
|
||||
"revision": 1,
|
||||
"upstreamRevision": {
|
||||
"stage-1-seat-1-demo-intelligence-agent": 1,
|
||||
"stage-2-seat-1-demo-md-analysis-agent": 1,
|
||||
"stage-3-seat-1-demo-xwt-analysis-agent": 1
|
||||
},
|
||||
"rejectionCount": 0,
|
||||
"lastRejection": null,
|
||||
"invalidatedBy": null,
|
||||
"createdAt": "2026-07-23T09:43:00+08:00",
|
||||
"updatedAt": "2026-07-23T09:58:00+08:00"
|
||||
},
|
||||
{
|
||||
"id": "local-demo-node-xs",
|
||||
"sessionId": "local-demo-position-roundtable-full-v1",
|
||||
"nodeKey": "stage-5-seat-1-demo-xs-analysis-agent",
|
||||
"stageIndex": 4,
|
||||
"seatIndex": 0,
|
||||
"agentId": "demo-xs-analysis-agent",
|
||||
"positionId": "planning",
|
||||
"threadId": null,
|
||||
"status": "done",
|
||||
"latestAnswer": "组织协同分析已完成,明确项目负责人、内容、投放、活动执行、招生顾问和数据复盘的职责与协作节奏。",
|
||||
"artifactManifest": [
|
||||
{
|
||||
"name": "组织协同分析报告-暑期游泳夏令营宣传.md",
|
||||
"path": "mnt/user-data/outputs/组织协同分析报告-暑期游泳夏令营宣传.md",
|
||||
"mime_type": "text/markdown",
|
||||
"size_bytes": 2634,
|
||||
"modified_at": "2026-07-23T10:12:00+08:00",
|
||||
"content": "# 组织协同分析报告:暑期游泳夏令营宣传\n\n## 组织结构\n\n- **项目负责人**:统一目标、预算、节奏和跨岗位决策。\n- **内容运营**:生产安全、师资、课程与学员成长内容。\n- **投放运营**:负责渠道组合、素材测试和预算调整。\n- **活动执行**:组织公开课、体验日和现场服务。\n- **招生顾问**:承接线索、完成咨询和报名转化。\n- **数据负责人**:维护漏斗看板并输出每周复盘。\n\n## 协同机制\n\n1. 每周一确认目标、预算和重点素材。\n2. 每周三复盘线索质量与家长高频顾虑。\n3. 每周五复盘渠道ROI、体验到场率和报名转化率。\n4. 重大安全或舆情问题由项目负责人在2小时内组织处置。\n\n## 交付与验收\n\n所有岗位使用统一任务清单,明确负责人、截止时间、交付物和验收标准;未通过验收的交付进入修订队列,不直接推进到下游。"
|
||||
}
|
||||
],
|
||||
"conversationSnapshot": {
|
||||
"messages": [
|
||||
{
|
||||
"id": "demo-xs-human-1",
|
||||
"type": "human",
|
||||
"content": "请形成组织协同、资源配置和执行保障建议。"
|
||||
},
|
||||
{
|
||||
"id": "demo-xs-ai-1",
|
||||
"type": "ai",
|
||||
"content": "组织协同方案已完成,明确项目负责人、内容、投放、活动、招生和数据岗位的责任边界,并建立周一计划、周三线索复盘、周五转化复盘的协同节奏。完整 Markdown 报告已交付。"
|
||||
}
|
||||
],
|
||||
"inputDraft": "",
|
||||
"savedAt": "2026-07-23T10:12:00+08:00"
|
||||
},
|
||||
"revision": 1,
|
||||
"upstreamRevision": {
|
||||
"stage-1-seat-1-demo-intelligence-agent": 1,
|
||||
"stage-2-seat-1-demo-md-analysis-agent": 1,
|
||||
"stage-3-seat-1-demo-xwt-analysis-agent": 1,
|
||||
"stage-4-seat-1-demo-behavior-analysis-agent": 1
|
||||
},
|
||||
"rejectionCount": 0,
|
||||
"lastRejection": null,
|
||||
"invalidatedBy": null,
|
||||
"createdAt": "2026-07-23T09:59:00+08:00",
|
||||
"updatedAt": "2026-07-23T10:12:00+08:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { AgentThreadState } from "@/core/threads/types";
|
||||
import type { PositionConversationSnapshot } from "@/position-roundtable/api/sessions";
|
||||
|
||||
type ThreadMessage = AgentThreadState["messages"][number];
|
||||
|
||||
export function readConversationMessages(
|
||||
snapshot: PositionConversationSnapshot | null | undefined,
|
||||
): AgentThreadState["messages"] {
|
||||
return Array.isArray(snapshot?.messages)
|
||||
? (snapshot.messages as unknown as AgentThreadState["messages"])
|
||||
: [];
|
||||
}
|
||||
|
||||
export function readConversationDraft(
|
||||
snapshot: PositionConversationSnapshot | null | undefined,
|
||||
): string {
|
||||
return typeof snapshot?.inputDraft === "string" ? snapshot.inputDraft : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer live messages while retaining SQL-only history when the LangGraph
|
||||
* checkpoint volume was unavailable. Stable message ids remove duplicates
|
||||
* after the checkpoint becomes readable again.
|
||||
*/
|
||||
export function mergeConversationMessages(
|
||||
persisted: AgentThreadState["messages"],
|
||||
live: AgentThreadState["messages"],
|
||||
): AgentThreadState["messages"] {
|
||||
if (persisted.length === 0) return live;
|
||||
if (live.length === 0) return persisted;
|
||||
|
||||
const merged: ThreadMessage[] = [...persisted];
|
||||
const indexById = new Map<string, number>();
|
||||
merged.forEach((message, index) => {
|
||||
if (message.id) indexById.set(String(message.id), index);
|
||||
});
|
||||
for (const message of live) {
|
||||
const id = message.id ? String(message.id) : "";
|
||||
const existingIndex = id ? indexById.get(id) : undefined;
|
||||
if (existingIndex === undefined) {
|
||||
if (id) indexById.set(id, merged.length);
|
||||
merged.push(message);
|
||||
} else {
|
||||
merged[existingIndex] = message;
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function buildConversationSnapshot(
|
||||
messages: AgentThreadState["messages"],
|
||||
inputDraft: string,
|
||||
): PositionConversationSnapshot {
|
||||
// SDK messages are JSON data at the network boundary. Round-trip them here
|
||||
// to strip any accidental prototype values before sending the SQL snapshot.
|
||||
let serializable: Array<Record<string, unknown>> = [];
|
||||
try {
|
||||
serializable = JSON.parse(JSON.stringify(messages)) as Array<Record<string, unknown>>;
|
||||
} catch {
|
||||
serializable = messages.map((message) => ({
|
||||
id: message.id ? String(message.id) : undefined,
|
||||
type: message.type,
|
||||
content: message.content,
|
||||
}));
|
||||
}
|
||||
return {
|
||||
messages: serializable,
|
||||
inputDraft,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -11,7 +11,7 @@
|
||||
* **不要**在这里再 fetch / 建 thread / 跑 SSE —— 这些逻辑都在 `useStep1Intent`,
|
||||
* 本组件保持纯展示 + 透传事件。
|
||||
*/
|
||||
import { useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
CheckCircle,
|
||||
FileText,
|
||||
@@ -128,6 +128,10 @@ export interface Step1PanelProps {
|
||||
* 统一管理,因此岗位会商定制样式时不会分叉或复制 Step 1 的业务逻辑。
|
||||
*/
|
||||
inputArea?: (props: Step1InputAreaProps) => React.ReactNode;
|
||||
/** Optional database-backed composer state used by the position workspace. */
|
||||
inputDraftKey?: string;
|
||||
initialInputText?: string;
|
||||
onInputTextChange?: (value: string) => void;
|
||||
onManualSave: () => void;
|
||||
onTriggerToast: (msg: string) => void;
|
||||
submitIntentClarifications: (
|
||||
@@ -157,14 +161,20 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
footerAction,
|
||||
hideEnterRoundtable = false,
|
||||
inputArea,
|
||||
inputDraftKey,
|
||||
initialInputText = "",
|
||||
onInputTextChange,
|
||||
onManualSave,
|
||||
onTriggerToast,
|
||||
submitIntentClarifications,
|
||||
} = props;
|
||||
|
||||
const step1ScrollRef = useRef<HTMLDivElement>(null);
|
||||
const step1ContentRef = useRef<HTMLDivElement>(null);
|
||||
const { atBottom: step1AtBottom, scrollToBottom: scrollStep1ToBottom } =
|
||||
useAutoScroll(step1ScrollRef, [interactiveMessages]);
|
||||
useAutoScroll(step1ScrollRef, [interactiveMessages], {
|
||||
contentRef: step1ContentRef,
|
||||
});
|
||||
|
||||
// 「回答速度指示器」—— 与主页面共用全局开关 streamSpeedMeterEnabled。
|
||||
const [localSettings] = useLocalSettings();
|
||||
@@ -173,6 +183,16 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
|
||||
// 输入框完全是本地状态 —— 提到 Page 层会导致每次打字都重渲整页。
|
||||
const [inputText, setInputText] = useState<string>("");
|
||||
useEffect(() => {
|
||||
setInputText(initialInputText);
|
||||
}, [inputDraftKey, initialInputText]);
|
||||
const handleInputTextChange = useCallback(
|
||||
(value: string) => {
|
||||
setInputText(value);
|
||||
onInputTextChange?.(value);
|
||||
},
|
||||
[onInputTextChange],
|
||||
);
|
||||
// 待发送的本地附件(点发送时连同文本一起上传)。
|
||||
const [attachedFiles, setAttachedFiles] = useState<File[]>([]);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -202,7 +222,7 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
const handleSend = () => {
|
||||
if (!inputText.trim() && attachedFiles.length === 0) return;
|
||||
onSendMessage(inputText, attachedFiles.length > 0 ? attachedFiles : undefined);
|
||||
setInputText("");
|
||||
handleInputTextChange("");
|
||||
setAttachedFiles([]);
|
||||
};
|
||||
|
||||
@@ -234,21 +254,23 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
<div
|
||||
ref={step1ScrollRef}
|
||||
// overflow-x-hidden:消息列表只竖向滚(见 Step2Panel 同处注释),避免误触横向拖动抢滑块。
|
||||
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden p-6 space-y-4 bg-white"
|
||||
className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden bg-white"
|
||||
>
|
||||
{interactiveMessages.map((msg) => (
|
||||
<Step1Message
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
metric={speedMeterEnabled ? messageMetrics.get(msg.id) : undefined}
|
||||
intentReady={intentReady}
|
||||
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
|
||||
intentClarificationSelections={intentClarificationSelections}
|
||||
setIntentClarificationSelections={setIntentClarificationSelections}
|
||||
isIntentSending={isIntentSending}
|
||||
submitIntentClarifications={submitIntentClarifications}
|
||||
/>
|
||||
))}
|
||||
<div ref={step1ContentRef} className="space-y-4 p-6">
|
||||
{interactiveMessages.map((msg) => (
|
||||
<Step1Message
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
metric={speedMeterEnabled ? messageMetrics.get(msg.id) : undefined}
|
||||
intentReady={intentReady}
|
||||
pendingIntentClarificationMsgId={pendingIntentClarificationMsgId}
|
||||
intentClarificationSelections={intentClarificationSelections}
|
||||
setIntentClarificationSelections={setIntentClarificationSelections}
|
||||
isIntentSending={isIntentSending}
|
||||
submitIntentClarifications={submitIntentClarifications}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{!step1AtBottom && <ScrollToBottomButton onClick={scrollStep1ToBottom} />}
|
||||
</div>
|
||||
@@ -257,7 +279,7 @@ export function Step1Panel(props: Step1PanelProps) {
|
||||
{inputArea ? (
|
||||
inputArea({
|
||||
value: inputText,
|
||||
onValueChange: setInputText,
|
||||
onValueChange: handleInputTextChange,
|
||||
attachedFiles,
|
||||
onAddFiles: addAttachedFiles,
|
||||
onRemoveFile: removeAttachedFile,
|
||||
|
||||
@@ -16,6 +16,12 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
options: {
|
||||
/** How close to the bottom (px) still counts as "at bottom". */
|
||||
threshold?: number;
|
||||
/**
|
||||
* Optional inner content element. Streamed Markdown, clarification cards
|
||||
* and expandable tool steps can change height without replacing the outer
|
||||
* message array; observing this node keeps bottom-following reliable.
|
||||
*/
|
||||
contentRef?: RefObject<HTMLElement | null>;
|
||||
} = {},
|
||||
): {
|
||||
/** True when the user is at the bottom — drives the button's visibility. */
|
||||
@@ -24,6 +30,7 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
scrollToBottom: () => void;
|
||||
} {
|
||||
const threshold = options.threshold ?? 32;
|
||||
const contentRef = options.contentRef;
|
||||
// Ref mirrors state so the deps-effect can read the latest value without
|
||||
// re-subscribing when atBottom flips. State is for the button's render.
|
||||
const atBottomRef = useRef(true);
|
||||
@@ -35,7 +42,10 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
// - 任何其它 scroll 事件视为用户主动滚动,按位置判定 atBottom。
|
||||
// 这是修「鼠标拖滚动条没出现按钮」的关键:之前依赖 wheel/touchmove,
|
||||
// 但鼠标拖滚动条**只触发 scroll 事件**,wheel/touchmove 完全不会触发。
|
||||
const programmaticScrollRef = useRef(false);
|
||||
// Keep the expected destination rather than a boolean. Setting scrollTop to
|
||||
// its current value emits no event; a boolean would remain set and swallow
|
||||
// the user's next real upward scroll.
|
||||
const programmaticScrollTargetRef = useRef<number | null>(null);
|
||||
// 自动跟随 effect 和 onScroll 共享的"上次观察到的 scrollTop"。
|
||||
// 用来在 effect 里区分两种 distance 暴涨的情况:
|
||||
// - currentScrollTop < lastObservedScrollTopRef → 用户主动向上滚 → 退出跟随
|
||||
@@ -52,9 +62,13 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
const distanceFromBottom = el.scrollHeight - currentScrollTop - el.clientHeight;
|
||||
const isAtBottom = distanceFromBottom <= threshold;
|
||||
|
||||
if (programmaticScrollRef.current) {
|
||||
const programmaticTarget = programmaticScrollTargetRef.current;
|
||||
if (
|
||||
programmaticTarget !== null &&
|
||||
Math.abs(currentScrollTop - programmaticTarget) <= 1
|
||||
) {
|
||||
// 这是我们刚 scrollTop=scrollHeight 触发的回调,跳过,但仍刷新 prev 用于下次比较。
|
||||
programmaticScrollRef.current = false;
|
||||
programmaticScrollTargetRef.current = null;
|
||||
prevScrollTop = currentScrollTop;
|
||||
lastObservedScrollTopRef.current = currentScrollTop;
|
||||
// 程序滚动后通常落在底部,把 atBottom 维持 true。
|
||||
@@ -64,6 +78,9 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (programmaticTarget !== null) {
|
||||
programmaticScrollTargetRef.current = null;
|
||||
}
|
||||
|
||||
// 用户主动滚动:**任何向上滚的 scrollTop 减少都立刻退出自动跟随**,
|
||||
// 不等位置算到底部 32px 之外才翻。这是按钮"滚动一点点就显示"的核心。
|
||||
@@ -115,19 +132,56 @@ export function useAutoScroll<T extends HTMLElement>(
|
||||
return;
|
||||
}
|
||||
// 在底部 (或仅是内容刚增长) → 同步拉到新底部。
|
||||
programmaticScrollRef.current = true;
|
||||
programmaticScrollTargetRef.current = Math.max(
|
||||
0,
|
||||
el.scrollHeight - el.clientHeight,
|
||||
);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
lastObservedScrollTopRef.current = el.scrollTop;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
// Some message UI grows after the React state update that triggered the
|
||||
// dependency effect (Markdown layout, clarification cards, collapsed step
|
||||
// details). Follow the actual content height as well, but only while the
|
||||
// user has not intentionally scrolled upward.
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
const content = contentRef?.current;
|
||||
if (!el || !content || typeof ResizeObserver === "undefined") return;
|
||||
|
||||
let frameId: number | null = null;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!atBottomRef.current) return;
|
||||
if (frameId !== null) window.cancelAnimationFrame(frameId);
|
||||
frameId = window.requestAnimationFrame(() => {
|
||||
frameId = null;
|
||||
if (!atBottomRef.current) return;
|
||||
programmaticScrollTargetRef.current = Math.max(
|
||||
0,
|
||||
el.scrollHeight - el.clientHeight,
|
||||
);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
lastObservedScrollTopRef.current = el.scrollTop;
|
||||
});
|
||||
});
|
||||
observer.observe(content);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (frameId !== null) window.cancelAnimationFrame(frameId);
|
||||
};
|
||||
}, [contentRef, ref]);
|
||||
|
||||
// 按钮点击是用户主动行为,保留 smooth 给视觉反馈;期间用户若立刻又拖动,
|
||||
// onScroll 的方向检测仍会把 atBottom 翻回 false,不会卡住。
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
// 标记程序滚动:smooth 动画过程中浏览器派发的 scroll 事件不应被误判为用户主动滚动。
|
||||
programmaticScrollRef.current = true;
|
||||
programmaticScrollTargetRef.current = Math.max(
|
||||
0,
|
||||
el.scrollHeight - el.clientHeight,
|
||||
);
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
atBottomRef.current = true;
|
||||
setAtBottom(true);
|
||||
|
||||
@@ -647,17 +647,27 @@ export function useStep1Intent(options: UseStep1IntentOptions): UseStep1IntentRe
|
||||
[intentClarificationSelections, sendIntentMessage, triggerToast],
|
||||
);
|
||||
|
||||
const hydrateFromDraft = useCallback((snapshot: Step1Snapshot) => {
|
||||
setIntentThreadId(snapshot.intentThreadId);
|
||||
setIntentReady(snapshot.intentReady);
|
||||
setIntentInitError(null);
|
||||
setIntentInitErrorDetail(null);
|
||||
setInteractiveMessages(snapshot.interactiveMessages);
|
||||
setTimelineEntries(snapshot.timelineEntries);
|
||||
step1InitRef.current = true;
|
||||
// 让任何在途的旧 init 在 resolve 时被识别为过期,丢弃 setState。
|
||||
step1InitGenRef.current += 1;
|
||||
}, []);
|
||||
const hydrateFromDraft = useCallback(
|
||||
(snapshot: Step1Snapshot) => {
|
||||
// 切换历史会话时,旧 thread 可能仍在流式回复。先终止它并清掉仅属于
|
||||
// 上一个会话的“发送中/待澄清”瞬时状态,避免新会话输入框被锁住,
|
||||
// 或继续显示旧会话的澄清选择。
|
||||
stopIntentStream();
|
||||
setIsIntentSending(false);
|
||||
setPendingIntentClarificationMsgId(null);
|
||||
setIntentClarificationSelections(new Map());
|
||||
setIntentThreadId(snapshot.intentThreadId);
|
||||
setIntentReady(snapshot.intentReady);
|
||||
setIntentInitError(null);
|
||||
setIntentInitErrorDetail(null);
|
||||
setInteractiveMessages(snapshot.interactiveMessages);
|
||||
setTimelineEntries(snapshot.timelineEntries);
|
||||
step1InitRef.current = true;
|
||||
// 让任何在途的旧 init 在 resolve 时被识别为过期,丢弃 setState。
|
||||
step1InitGenRef.current += 1;
|
||||
},
|
||||
[stopIntentStream],
|
||||
);
|
||||
|
||||
const appendLocalMessage = useCallback((msg: InteractiveMessage) => {
|
||||
setInteractiveMessages((prev) => [...prev, msg]);
|
||||
|
||||
@@ -29,12 +29,14 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
import { useTheme } from "../contexts/ThemeContext";
|
||||
|
||||
const iconButtonClass = (isDark: boolean) =>
|
||||
const iconButtonClass = (isDark: boolean, isIceBlue: boolean) =>
|
||||
cn(
|
||||
"size-8 shrink-0 rounded-sm",
|
||||
isDark
|
||||
? "text-slate-400 hover:bg-slate-800/50 hover:text-white"
|
||||
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
|
||||
: isIceBlue
|
||||
? "text-[#18324A] hover:bg-white/55 hover:text-[#18324A] active:bg-white/75"
|
||||
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
|
||||
);
|
||||
|
||||
// 深色顶栏菜单左右分组(设计稿:左 问答中心/任务管理/应用中心,右 综合管理/资源管理)
|
||||
@@ -48,6 +50,7 @@ type HeaderProps = {
|
||||
const Header = ({ collapsed }: HeaderProps) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === "dark";
|
||||
const isIceBlue = theme === "iceblue";
|
||||
const darkLogoPosition =
|
||||
readRuntimeEnv("VITE_DARK_LOGO_POSITION") === "left" ? "left" : "center";
|
||||
const isDarkLogoOnLeft = darkLogoPosition === "left";
|
||||
@@ -149,11 +152,11 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
// 右侧操作区(换肤/通知/用户)—— 深浅两套布局共用
|
||||
const actionCluster = (
|
||||
<>
|
||||
<CompactShortcutButtons tone={isDark ? "banner-dark" : "banner-light"} />
|
||||
<ThemeSwitcher tone={isDark ? "banner-dark" : "banner-light"} />
|
||||
<CompactShortcutButtons tone={isDark ? "banner-dark" : isIceBlue ? "content" : "banner-light"} />
|
||||
<ThemeSwitcher tone={isDark ? "banner-dark" : isIceBlue ? "content" : "banner-light"} />
|
||||
|
||||
<LayoutModeSwitcher
|
||||
tone={isDark ? "banner-dark" : "banner-light"}
|
||||
tone={isDark ? "banner-dark" : isIceBlue ? "content" : "banner-light"}
|
||||
fallback="full"
|
||||
/>
|
||||
|
||||
@@ -162,7 +165,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => setNotificationOpen(true)}
|
||||
className={cn(iconButtonClass(isDark), "relative")}
|
||||
className={cn(iconButtonClass(isDark, isIceBlue), "relative")}
|
||||
title="通知"
|
||||
>
|
||||
<Bell className="size-4" />
|
||||
@@ -183,7 +186,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
<div
|
||||
className={cn(
|
||||
"mx-1 h-8 w-px",
|
||||
isDark ? "bg-blue-800/30" : "bg-white/30",
|
||||
isDark ? "bg-blue-800/30" : isIceBlue ? "bg-[#3D6E96]/25" : "bg-white/30",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
@@ -195,7 +198,9 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
"h-8 gap-2 rounded-sm px-2",
|
||||
isDark
|
||||
? "text-slate-200 hover:bg-slate-800/50"
|
||||
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
|
||||
: isIceBlue
|
||||
? "text-[#18324A] hover:bg-white/55 active:bg-white/75"
|
||||
: "text-white hover:bg-[var(--brand-950)] active:bg-[#8a0618]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -214,11 +219,11 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden text-left sm:block">
|
||||
<p className="text-sm leading-tight font-medium text-white">
|
||||
<p className={cn("text-sm leading-tight font-medium", isIceBlue ? "text-[#18324A]" : "text-white")}>
|
||||
{displayName}
|
||||
</p>
|
||||
{positionName && (
|
||||
<p className="text-[11px] leading-tight text-white/70">
|
||||
<p className={cn("text-[11px] leading-tight", isIceBlue ? "text-[#315B7B]" : "text-white/70")}>
|
||||
{positionName}
|
||||
</p>
|
||||
)}
|
||||
@@ -226,7 +231,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 shrink-0",
|
||||
isDark ? "text-slate-500" : "text-white/80",
|
||||
isDark ? "text-slate-500" : isIceBlue ? "text-[#315B7B]" : "text-white/80",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
@@ -318,7 +323,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
onError={handleBrandLogoError}
|
||||
/>
|
||||
</div>
|
||||
<span className="whitespace-nowrap text-lg font-semibold text-white">
|
||||
<span className={cn("whitespace-nowrap text-lg font-semibold", isIceBlue ? "text-[#18324A]" : "text-white")}>
|
||||
{desensitizeText("CM业务中心")}
|
||||
</span>
|
||||
</div>
|
||||
@@ -339,7 +344,9 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
"flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg",
|
||||
isDark
|
||||
? "bg-gradient-to-br from-blue-500 to-cyan-400"
|
||||
: "bg-white/20",
|
||||
: isIceBlue
|
||||
? "bg-white/55"
|
||||
: "bg-white/20",
|
||||
)}
|
||||
>
|
||||
<img
|
||||
@@ -351,7 +358,7 @@ const Header = ({ collapsed }: HeaderProps) => {
|
||||
/>
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="text-sm font-semibold text-white">
|
||||
<span className={cn("text-sm font-semibold", isIceBlue ? "text-[#18324A]" : "text-white")}>
|
||||
{desensitizeText("CM业务中心")}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ActionAnalyzes: React.FC<ActionAnalyzesProps> = ({ title, category,
|
||||
const isDark = theme === 'dark';
|
||||
// 主题颜色变量
|
||||
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
|
||||
const isLightBlue = theme === 'lightblue';
|
||||
const isLightBlue = theme === 'lightblue' || theme === 'iceblue';
|
||||
const cardBorder = isDark ? 'border-blue-800/30' : isLightBlue ? 'border-[#D4E9FF]' : 'border-red-100';
|
||||
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
||||
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
|
||||
|
||||
@@ -28,7 +28,7 @@ export const ActionColumn: React.FC<ActionColumnProps> = ({ title, category, sub
|
||||
const isDark = theme === 'dark';
|
||||
// 主题颜色变量
|
||||
const cardBg = isDark ? 'bg-slate-800/30' : 'bg-white';
|
||||
const isLightBlue = theme === 'lightblue';
|
||||
const isLightBlue = theme === 'lightblue' || theme === 'iceblue';
|
||||
const cardBorder = isDark ? 'border-blue-800/30' : isLightBlue ? 'border-[#D4E9FF]' : 'border-red-100';
|
||||
const textPrimary = isDark ? 'text-white' : 'text-gray-900';
|
||||
const textSecondary = isDark ? 'text-slate-400' : 'text-gray-600';
|
||||
|
||||
@@ -28,7 +28,7 @@ export const KanbanColumn: React.FC<KanbanColumnProps> = ({
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
const isLightBlue = theme === 'lightblue';
|
||||
const isLightBlue = theme === 'lightblue' || theme === 'iceblue';
|
||||
const [pageNum, setPageNum] = useState(1);
|
||||
|
||||
const handlePageChange = async (value: number) => {
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ const SentimentAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCa
|
||||
const { theme } = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const isDark = theme === 'dark';
|
||||
const isLightBlue = theme === 'lightblue';
|
||||
const isLightBlue = theme === 'lightblue' || theme === 'iceblue';
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
|
||||
|
||||
@@ -34,7 +34,7 @@ const TaskAnalyzes: React.FC<TaskListPageProps> = ({ title, category, subCategor
|
||||
const { theme } = useTheme();
|
||||
const navigate = useNavigate();
|
||||
const isDark = theme === 'dark';
|
||||
const isLightBlue = theme === 'lightblue';
|
||||
const isLightBlue = theme === 'lightblue' || theme === 'iceblue';
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showAnalysisModal, setShowAnalysisModal] = useState(false);
|
||||
|
||||
@@ -55,7 +55,7 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
||||
const { setStartConfig } = useChatContext();
|
||||
const navigate = useNavigate();
|
||||
const isDark = theme === 'dark';
|
||||
const isLightBlue = theme === 'lightblue';
|
||||
const isLightBlue = theme === 'lightblue' || theme === 'iceblue';
|
||||
|
||||
const typeConfig = getTaskTypeConfig(task.taskType);
|
||||
const TypeIcon = typeConfig.icon;
|
||||
|
||||
+16
-2
@@ -8,6 +8,7 @@
|
||||
/* ── CSS variables (light - red) ─────────────────────────────────────── */
|
||||
.strategy-data {
|
||||
--color-main: #cf1322;
|
||||
--color-main-foreground: #ffffff;
|
||||
--bg-color: #fef6f6;
|
||||
--card-border: #fa9196;
|
||||
--text-color: #222222;
|
||||
@@ -30,6 +31,19 @@
|
||||
--input-border: 211 100% 92%;
|
||||
}
|
||||
|
||||
/* Ice blue uses the global #9BCEF1 primary with a dark selected-state label. */
|
||||
[data-theme="iceblue"] .strategy-data {
|
||||
--color-main: #9BCEF1;
|
||||
--color-main-foreground: #18324A;
|
||||
--bg-color: #E1F1FB;
|
||||
--card-border: #9BCEF1;
|
||||
--text-color: #18324A;
|
||||
|
||||
--card-hover: 201 68% 96%;
|
||||
--border-color: 204 50% 90%;
|
||||
--input-border: 204 50% 90%;
|
||||
}
|
||||
|
||||
/* ── CSS variables (dark) ────────────────────────────────────────────── */
|
||||
.dark .strategy-data {
|
||||
--color-main: var(--color-primary);
|
||||
@@ -310,7 +324,7 @@ html[theme-mode='dark'] .sentiment-date-range-popup .t-button {
|
||||
|
||||
.strategy-data .task-item-active:hover .item-active {
|
||||
background: var(--color-main);
|
||||
color: #ffffff;
|
||||
color: var(--color-main-foreground);
|
||||
}
|
||||
|
||||
.dark .strategy-data .dark-theme:hover {
|
||||
@@ -336,7 +350,7 @@ html[theme-mode='dark'] .sentiment-date-range-popup .t-button {
|
||||
|
||||
.strategy-data .task-active .item-active {
|
||||
background: var(--color-main);
|
||||
color: #ffffff;
|
||||
color: var(--color-main-foreground);
|
||||
}
|
||||
|
||||
.strategy-data .task-tab-color {
|
||||
|
||||
@@ -42,13 +42,14 @@ export const LOGIN_SOURCE_OPTIONS: { label: string; value: string }[] = [
|
||||
* 我方主题色键。当前启用 red(浅色) / dark-blue(深色);blue 为后续将新增的主题。
|
||||
* 主题参数的映射以这些键为左侧 key(如 `dark-blue=darkBlue`)。
|
||||
*/
|
||||
export type AppThemeColor = "red" | "green" | "blue" | "dark-blue";
|
||||
export type AppThemeColor = "red" | "green" | "blue" | "iceblue" | "dark-blue";
|
||||
|
||||
/** 我方全部主题色(「应用管理」主题参数逐项配置目标值时遍历;dot 为配色小圆点)。 */
|
||||
export const APP_THEME_COLORS: { key: AppThemeColor; label: string; dot: string }[] = [
|
||||
{ key: "green", label: "绿色", dot: "#07C05F" },
|
||||
{ key: "red", label: "红色", dot: "#F5222D" },
|
||||
{ key: "blue", label: "蓝色", dot: "#1677FF" },
|
||||
{ key: "iceblue", label: "冰川蓝", dot: "#9BCEF1" },
|
||||
{ key: "dark-blue", label: "深蓝色", dot: "#002B5C" },
|
||||
];
|
||||
|
||||
@@ -60,6 +61,7 @@ const THEME_KEY_ALIASES: Record<AppThemeColor, string[]> = {
|
||||
red: ["red", "light"],
|
||||
green: ["green", "lightgreen"],
|
||||
blue: ["blue"],
|
||||
iceblue: ["iceblue", "ice-blue"],
|
||||
"dark-blue": ["dark-blue", "darkblue", "dark"],
|
||||
};
|
||||
|
||||
@@ -73,6 +75,7 @@ export function getCurrentTheme(): AppThemeColor {
|
||||
if (classList.contains("dark")) return "dark-blue";
|
||||
if (classList.contains("lightgreen")) return "green";
|
||||
if (classList.contains("lightblue")) return "blue";
|
||||
if (classList.contains("iceblue")) return "iceblue";
|
||||
}
|
||||
return "red";
|
||||
}
|
||||
|
||||
@@ -465,6 +465,66 @@
|
||||
--legacy-workspace-brand-rgb: 7 192 95;
|
||||
}
|
||||
|
||||
/* Ice blue: #9BCEF1 is the signature hue. The darker #3D6E96 action token
|
||||
keeps white labels, focus states, and compact controls accessible. */
|
||||
.iceblue {
|
||||
--workspace-brand: #9bcef1;
|
||||
--workspace-brand-foreground: #18324a;
|
||||
--brand-200: #e1f1fb;
|
||||
--brand-900: #3d6e96;
|
||||
--brand-950: #2d587a;
|
||||
--background: #f7fbfe;
|
||||
--foreground: #18324a;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #18324a;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #18324a;
|
||||
/* The requested primary token: all standard primary controls inherit #9BCEF1. */
|
||||
--primary: #9BCEF1;
|
||||
--primary-foreground: #18324a;
|
||||
--secondary: #eff7fc;
|
||||
--secondary-foreground: #2d587a;
|
||||
--muted: #edf5fa;
|
||||
--muted-foreground: #58718a;
|
||||
--accent: #e1f1fb;
|
||||
--accent-foreground: #2d587a;
|
||||
--destructive: #d14343;
|
||||
--border: #cfe2f0;
|
||||
--input: #c5dcea;
|
||||
--ring: rgb(155 206 241 / 0.72);
|
||||
--chart-1: #3d6e96;
|
||||
--chart-2: #2e95a8;
|
||||
--chart-3: #6c7fa2;
|
||||
--chart-4: #d58a49;
|
||||
--chart-5: #3a9d7c;
|
||||
--sidebar: #f9fcfe;
|
||||
--sidebar-foreground: #18324a;
|
||||
--sidebar-primary: #9BCEF1;
|
||||
--sidebar-primary-foreground: #18324a;
|
||||
--sidebar-accent: #e1f1fb;
|
||||
--sidebar-accent-foreground: #2d587a;
|
||||
--sidebar-border: #dceaf3;
|
||||
--sidebar-ring: rgb(155 206 241 / 0.72);
|
||||
--legacy-accent-rgb: 225 241 251;
|
||||
--legacy-background-rgb: 247 251 254;
|
||||
--legacy-border-rgb: 207 226 240;
|
||||
--legacy-brand-200-rgb: 225 241 251;
|
||||
--legacy-card-rgb: 255 255 255;
|
||||
--legacy-destructive-rgb: 209 67 67;
|
||||
--legacy-foreground-rgb: 24 50 74;
|
||||
--legacy-input-rgb: 197 220 234;
|
||||
--legacy-muted-rgb: 237 245 250;
|
||||
--legacy-muted-foreground-rgb: 88 113 138;
|
||||
--legacy-primary-rgb: 155 206 241;
|
||||
--legacy-primary-foreground-rgb: 24 50 74;
|
||||
--legacy-ring-rgb: 155 206 241;
|
||||
--legacy-secondary-rgb: 239 247 252;
|
||||
--legacy-sidebar-accent-rgb: 225 241 251;
|
||||
--legacy-sidebar-border-rgb: 220 234 243;
|
||||
--legacy-sidebar-foreground-rgb: 24 50 74;
|
||||
--legacy-workspace-brand-rgb: 155 206 241;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
@@ -1374,6 +1434,43 @@ html.lightgreen .tiptap-writing-editor .ProseMirror ::selection {
|
||||
background: color-mix(in srgb, #07c05f 35%, transparent);
|
||||
}
|
||||
|
||||
html.iceblue .strategy-app-header {
|
||||
background-color: #9bcef1;
|
||||
background-image: linear-gradient(105deg, #4d7da4 0%, #9bcef1 52%, #d8effd 100%);
|
||||
}
|
||||
|
||||
html.iceblue *::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgb(177 220 246 / 0.86), rgb(61 110 150 / 0.9))
|
||||
padding-box;
|
||||
}
|
||||
|
||||
html.iceblue .workspace-panel {
|
||||
border: 1px solid #cfe2f0;
|
||||
box-shadow:
|
||||
0 32px 72px -46px rgb(49 101 143 / 0.3),
|
||||
0 12px 28px -22px rgb(115 171 210 / 0.2);
|
||||
}
|
||||
|
||||
html.iceblue .workspace-user-bubble {
|
||||
border: 1px solid rgb(255 255 255 / 0.2);
|
||||
background: linear-gradient(135deg, #78afd4 0%, #568bb2 58%, #3d6e96 100%);
|
||||
box-shadow:
|
||||
0 22px 40px -28px rgb(61 110 150 / 0.52),
|
||||
0 10px 20px -16px rgb(45 88 122 / 0.36);
|
||||
}
|
||||
|
||||
html.iceblue .workspace-assistant-card {
|
||||
border: 1px solid #d7e8f3;
|
||||
}
|
||||
|
||||
html.iceblue .workspace-assistant-card::before {
|
||||
background: linear-gradient(180deg, rgb(155 206 241 / 0.82), rgb(155 206 241 / 0.12));
|
||||
}
|
||||
|
||||
html.iceblue .tiptap-writing-editor .ProseMirror ::selection {
|
||||
background: rgb(155 206 241 / 0.5);
|
||||
}
|
||||
|
||||
/* ── 来源上传弹窗:A系统基因库的分段单选条(custom-radio) ─────────────── */
|
||||
/* 外层容器,模拟 nz-radio-group */
|
||||
.custom-radio-group {
|
||||
@@ -1475,6 +1572,17 @@ html.lightgreen .tiptap-writing-editor .ProseMirror ::selection {
|
||||
border-color: rgb(7 192 95 / 0.5);
|
||||
}
|
||||
|
||||
.iceblue .agent-avatar {
|
||||
background-color: #e1f1fb;
|
||||
color: #2d587a;
|
||||
box-shadow: inset 0 0 0 1px rgb(61 110 150 / 0.16);
|
||||
}
|
||||
|
||||
.iceblue .agent-sidebar-card--active {
|
||||
background-color: #e1f1fb;
|
||||
border-color: rgb(61 110 150 / 0.5);
|
||||
}
|
||||
|
||||
@supports not (color: oklch(1 0 0)) {
|
||||
.bg-primary\/5 { background-color: rgba(190, 35, 35, 0.05); }
|
||||
.bg-primary\/8 { background-color: rgba(190, 35, 35, 0.08); }
|
||||
@@ -1537,4 +1645,16 @@ html.lightgreen .tiptap-writing-editor .ProseMirror ::selection {
|
||||
.lightgreen .bg-primary\/60 { background-color: rgba(7, 192, 95, 0.60); }
|
||||
.lightgreen .bg-primary\/70 { background-color: rgba(7, 192, 95, 0.70); }
|
||||
.lightgreen .bg-primary\/90 { background-color: rgba(7, 192, 95, 0.90); }
|
||||
|
||||
.iceblue .bg-primary\/5 { background-color: rgba(155, 206, 241, 0.05); }
|
||||
.iceblue .bg-primary\/8 { background-color: rgba(155, 206, 241, 0.08); }
|
||||
.iceblue .bg-primary\/10 { background-color: rgba(155, 206, 241, 0.10); }
|
||||
.iceblue .bg-primary\/14 { background-color: rgba(155, 206, 241, 0.14); }
|
||||
.iceblue .bg-primary\/15 { background-color: rgba(155, 206, 241, 0.15); }
|
||||
.iceblue .bg-primary\/20 { background-color: rgba(155, 206, 241, 0.20); }
|
||||
.iceblue .bg-primary\/25 { background-color: rgba(155, 206, 241, 0.25); }
|
||||
.iceblue .bg-primary\/30 { background-color: rgba(155, 206, 241, 0.30); }
|
||||
.iceblue .bg-primary\/60 { background-color: rgba(155, 206, 241, 0.60); }
|
||||
.iceblue .bg-primary\/70 { background-color: rgba(155, 206, 241, 0.70); }
|
||||
.iceblue .bg-primary\/90 { background-color: rgba(155, 206, 241, 0.90); }
|
||||
}
|
||||
@@ -195,6 +195,39 @@
|
||||
--td-bg-color-secondarycontainer-active: #eaf4ff;
|
||||
}
|
||||
|
||||
/* Ice blue: #9BCEF1 is the primary token; darker blues support readable text
|
||||
and interaction states. */
|
||||
:root[data-theme="iceblue"] {
|
||||
--td-brand-color-1: #f7fbfe;
|
||||
--td-brand-color-2: #eff8fc;
|
||||
--td-brand-color-3: #e1f1fb;
|
||||
--td-brand-color-4: #cbe7f7;
|
||||
--td-brand-color-5: #9BCEF1;
|
||||
--td-brand-color-6: #9BCEF1;
|
||||
--td-brand-color-7: #84b9de;
|
||||
--td-brand-color-8: #5b91b9;
|
||||
--td-brand-color-9: #3d6e96;
|
||||
--td-brand-color-10: #1e405c;
|
||||
|
||||
--td-brand-color: #9BCEF1;
|
||||
--td-brand-color-hover: #b7dcf5;
|
||||
--td-brand-color-focus: #e1f1fb;
|
||||
--td-brand-color-active: #84b9de;
|
||||
--td-brand-color-disabled: #d4eaf8;
|
||||
--td-brand-color-light: #e1f1fb;
|
||||
--td-brand-color-light-hover: #cbe7f7;
|
||||
--td-text-color-primary: #18324a;
|
||||
--td-text-color-secondary: #58718a;
|
||||
--td-text-color-brand: #2d587a;
|
||||
--td-text-color-link: #2d587a;
|
||||
--td-text-color-anti: #18324a;
|
||||
--td-bg-color-container-hover: #f7fbfe;
|
||||
--td-bg-color-container-active: #e1f1fb;
|
||||
--td-bg-color-secondarycontainer: #eff7fc;
|
||||
--td-bg-color-secondarycontainer-hover: #e1f1fb;
|
||||
--td-bg-color-secondarycontainer-active: #cbe7f7;
|
||||
}
|
||||
|
||||
/* WeKnora green: selected by ThemeContext through data-theme="lightgreen". */
|
||||
:root[data-theme="lightgreen"] {
|
||||
--td-brand-color-1: #e9f8ec;
|
||||
|
||||
@@ -107,6 +107,19 @@ CI runs these regression tests for every pull request via [.github/workflows/bac
|
||||
|
||||
## Architecture
|
||||
|
||||
### Position roundtable recovery persistence
|
||||
|
||||
`/api/position-roundtable` uses LangGraph threads as the live execution source
|
||||
and stores an additional SQL recovery copy in
|
||||
`position_roundtable_sessions`/`position_roundtable_nodes`. The recovery copy
|
||||
contains complete intent/node/summary/action-plan conversations, unsent
|
||||
composer drafts, workflow snapshots, and bounded full text for generated
|
||||
Markdown/JSON artifacts. This lets history and downstream closing agents keep
|
||||
working when container-local checkpoint or sandbox volumes are replaced.
|
||||
Revision `20260723_05` adds the conversation columns. Session deletion
|
||||
explicitly removes child nodes so SQLite deployments do not depend on FK
|
||||
cascade pragmas.
|
||||
|
||||
### Harness / App Split
|
||||
|
||||
The backend is split into two layers with a strict dependency direction:
|
||||
|
||||
@@ -64,7 +64,7 @@ CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run uvicorn app.gateway.app:app
|
||||
|
||||
# ── Stage 3: Runtime ──────────────────────────────────────────────────────────
|
||||
# Clean image without build-essential — reduces size (~200 MB) and attack surface.
|
||||
FROM python:3.12-slim-bookworm
|
||||
FROM python:3.12-slim-bookworm AS runtime
|
||||
|
||||
# Copy Node.js runtime from builder (provides npx for MCP servers)
|
||||
COPY --from=builder /usr/bin/node /usr/bin/node
|
||||
|
||||
@@ -449,3 +449,11 @@ See the [LICENSE](../LICENSE) file in the project root.
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines.
|
||||
## Position roundtable durable history
|
||||
|
||||
The position-roundtable workflow stores database-owned recovery snapshots for
|
||||
Step-1 intent chat, every position-node chat, the summary/action-plan chats,
|
||||
unsent composer drafts, and generated text deliverables. LangGraph checkpoints
|
||||
remain the execution source, but SQL history continues to render after a
|
||||
checkpoint or sandbox-output volume is replaced. Apply Alembic revision
|
||||
`20260723_05` (or simply upgrade to `head`) before deploying this version.
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
Unlike the existing multi-agent roundtable, this feature has no coordinator and
|
||||
no background roundtable job. The API only persists the task/intent/chain
|
||||
snapshot and indexes one direct-agent node per business-chain seat. Actual chat
|
||||
messages and artifacts stay in the established LangGraph thread stores.
|
||||
snapshot and indexes one direct-agent node per business-chain seat. LangGraph
|
||||
remains the live execution source, while SQL also stores recovery copies of
|
||||
all conversations, drafts and delivered artifact bodies so checkpoint-volume
|
||||
replacement cannot turn an existing collaboration history into a blank page.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -25,6 +27,11 @@ NODE_STATUSES = {"locked", "ready", "running", "done", "stale", "rejected", "err
|
||||
_HANDOFF_TEXT_BUDGET = 12_000
|
||||
_MAX_INLINE_ARTIFACTS_PER_NODE = 4
|
||||
_MAX_INLINE_ARTIFACT_CHARS = 1_800
|
||||
# The handoff prompt remains intentionally small, but the recovery copy stored
|
||||
# in SQL must retain a complete normal Markdown/JSON deliverable. The ceiling
|
||||
# protects the database from an accidental multi-gigabyte log while covering
|
||||
# substantially larger reports than this workspace normally produces.
|
||||
_MAX_PERSISTED_ARTIFACT_CHARS = 4_000_000
|
||||
_BUILTIN_MATERIAL_BUDGET = 24_000
|
||||
SUMMARY_AGENT_ID = "roundtable-summary"
|
||||
ACTION_PLAN_AGENT_ID = "position-action-planner"
|
||||
@@ -58,6 +65,7 @@ class NodeResponse(BaseModel):
|
||||
status: str
|
||||
latest_answer: str | None = None
|
||||
artifact_manifest: list[dict[str, Any]] = Field(default_factory=list)
|
||||
conversation_snapshot: dict[str, Any] | None = None
|
||||
revision: int = 0
|
||||
upstream_revision: dict[str, Any] | None = None
|
||||
rejection_count: int = 0
|
||||
@@ -75,10 +83,13 @@ class SessionResponse(BaseModel):
|
||||
chain_snapshot: dict[str, Any] | None = None
|
||||
intent_thread_id: str | None = None
|
||||
intent_snapshot: dict[str, Any] | None = None
|
||||
intent_conversation: dict[str, Any] | None = None
|
||||
summary_thread_id: str | None = None
|
||||
summary_snapshot: dict[str, Any] | None = None
|
||||
summary_conversation: dict[str, Any] | None = None
|
||||
action_plan_thread_id: str | None = None
|
||||
action_plan_snapshot: dict[str, Any] | None = None
|
||||
action_plan_conversation: dict[str, Any] | None = None
|
||||
status: str
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
@@ -100,6 +111,14 @@ class TaskSnapshotUpdateRequest(BaseModel):
|
||||
class IntentUpdateRequest(BaseModel):
|
||||
intent_thread_id: str | None = Field(default=None, max_length=128)
|
||||
intent_snapshot: dict[str, Any] | None = None
|
||||
conversation_snapshot: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class ConversationStateUpdateRequest(BaseModel):
|
||||
"""Durable UI recovery state written while a normal chat is in progress."""
|
||||
|
||||
thread_id: str | None = Field(default=None, max_length=128)
|
||||
conversation_snapshot: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ActivateRequest(BaseModel):
|
||||
@@ -146,6 +165,14 @@ class CompleteBuiltinRunRequest(BaseModel):
|
||||
model: str | None = Field(default=None, max_length=256)
|
||||
|
||||
|
||||
class DemoSeedRequest(BaseModel):
|
||||
"""Canonical frontend fixture imported into the normal SQL session model."""
|
||||
|
||||
demo_key: str = Field(min_length=1, max_length=96)
|
||||
session: dict[str, Any] = Field(default_factory=dict)
|
||||
nodes: list[dict[str, Any]] = Field(default_factory=list, max_length=32)
|
||||
|
||||
|
||||
def _current_user_id(request: Request) -> str:
|
||||
user = getattr(request.state, "user", None)
|
||||
return str(user.id) if user is not None else get_effective_user_id()
|
||||
@@ -515,13 +542,20 @@ def _artifact_refs(artifacts: Any) -> list[dict[str, Any]]:
|
||||
for artifact in artifacts[:20]:
|
||||
if not isinstance(artifact, dict):
|
||||
continue
|
||||
refs.append(
|
||||
{
|
||||
"name": artifact.get("name") or artifact.get("filename") or "未命名产物",
|
||||
"path": artifact.get("path") or artifact.get("artifact_url") or "",
|
||||
"mime_type": artifact.get("mimeType") or artifact.get("mime_type") or None,
|
||||
}
|
||||
)
|
||||
ref = {
|
||||
"name": artifact.get("name") or artifact.get("filename") or "未命名产物",
|
||||
"path": artifact.get("path") or artifact.get("artifact_url") or "",
|
||||
"mime_type": artifact.get("mimeType") or artifact.get("mime_type") or None,
|
||||
}
|
||||
# A database recovery payload is already constrained when it is first
|
||||
# persisted. Preserve it here so downstream agents can still consume
|
||||
# an upstream report after the owning sandbox/output disk is gone.
|
||||
content = artifact.get("content")
|
||||
if isinstance(content, str):
|
||||
ref["content"] = content
|
||||
if artifact.get("content_truncated"):
|
||||
ref["content_truncated"] = True
|
||||
refs.append(ref)
|
||||
return refs
|
||||
|
||||
|
||||
@@ -567,7 +601,7 @@ async def _artifact_refs_with_content(
|
||||
never be allowed to select another thread's user-data directory.
|
||||
"""
|
||||
refs = _artifact_refs(artifacts)
|
||||
if not thread_id or text_budget <= 0:
|
||||
if text_budget <= 0:
|
||||
return refs, 0
|
||||
|
||||
consumed = 0
|
||||
@@ -587,6 +621,18 @@ async def _artifact_refs_with_content(
|
||||
if available <= 0:
|
||||
break
|
||||
excerpt_limit = min(_MAX_INLINE_ARTIFACT_CHARS, available)
|
||||
persisted_content = ref.get("content")
|
||||
if isinstance(persisted_content, str):
|
||||
content = _truncate_text(persisted_content, excerpt_limit)
|
||||
ref["content"] = content
|
||||
ref["content_truncated"] = bool(
|
||||
ref.get("content_truncated") or len(persisted_content) > len(content)
|
||||
)
|
||||
consumed += len(content)
|
||||
inline_count += 1
|
||||
continue
|
||||
if not thread_id:
|
||||
continue
|
||||
try:
|
||||
actual_path = await aresolve_thread_virtual_path(thread_id, normalized_path)
|
||||
if not await asyncio.to_thread(actual_path.is_file):
|
||||
@@ -718,7 +764,7 @@ def _artifact_signature(artifact: dict[str, Any]) -> tuple[str, str, str]:
|
||||
|
||||
|
||||
def _artifact_manifest_refs(artifacts: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Persist only the display metadata received from the thread artifact API."""
|
||||
"""Persist display metadata plus any database recovery payload."""
|
||||
refs: list[dict[str, Any]] = []
|
||||
for artifact in artifacts:
|
||||
if not isinstance(artifact, dict):
|
||||
@@ -726,18 +772,59 @@ def _artifact_manifest_refs(artifacts: list[dict[str, Any]]) -> list[dict[str, A
|
||||
path = str(artifact.get("path") or artifact.get("artifact_url") or "").strip()
|
||||
if not path.replace("\\", "/").lstrip("/").startswith("mnt/user-data/outputs/"):
|
||||
continue
|
||||
refs.append(
|
||||
{
|
||||
"name": str(artifact.get("name") or artifact.get("filename") or path.rsplit("/", 1)[-1]),
|
||||
"path": path,
|
||||
"mime_type": artifact.get("mime_type") or artifact.get("mimeType") or None,
|
||||
"modified_at": artifact.get("modified_at") or artifact.get("modifiedAt") or None,
|
||||
"size_bytes": artifact.get("size_bytes") or artifact.get("sizeBytes") or None,
|
||||
}
|
||||
)
|
||||
ref = {
|
||||
"name": str(artifact.get("name") or artifact.get("filename") or path.rsplit("/", 1)[-1]),
|
||||
"path": path,
|
||||
"mime_type": artifact.get("mime_type") or artifact.get("mimeType") or None,
|
||||
"modified_at": artifact.get("modified_at") or artifact.get("modifiedAt") or None,
|
||||
"size_bytes": artifact.get("size_bytes") or artifact.get("sizeBytes") or None,
|
||||
}
|
||||
content = artifact.get("content")
|
||||
if isinstance(content, str):
|
||||
ref["content"] = content
|
||||
if artifact.get("content_truncated"):
|
||||
ref["content_truncated"] = True
|
||||
refs.append(ref)
|
||||
return refs
|
||||
|
||||
|
||||
async def _persist_artifact_contents(
|
||||
thread_id: str | None,
|
||||
artifacts: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Copy text deliverables into SQL so history survives ephemeral disks."""
|
||||
refs = _artifact_manifest_refs(artifacts)
|
||||
if not thread_id:
|
||||
return refs
|
||||
|
||||
enriched: list[dict[str, Any]] = []
|
||||
for artifact in refs:
|
||||
path = str(artifact.get("path") or "")
|
||||
suffix = Path(path).suffix.lower()
|
||||
if suffix not in _TEXT_ARTIFACT_SUFFIXES:
|
||||
enriched.append(artifact)
|
||||
continue
|
||||
try:
|
||||
resolved = await aresolve_thread_virtual_path(thread_id, path)
|
||||
content = await asyncio.to_thread(resolved.read_text, encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
# Artifact enrichment is recovery hardening, not a reason to fail
|
||||
# an otherwise successful delivery. Keep the manifest metadata if
|
||||
# the sandbox disappeared or its resolver/provider is unavailable.
|
||||
enriched.append(artifact)
|
||||
continue
|
||||
if len(content) > _MAX_PERSISTED_ARTIFACT_CHARS:
|
||||
artifact = {
|
||||
**artifact,
|
||||
"content": content[:_MAX_PERSISTED_ARTIFACT_CHARS],
|
||||
"content_truncated": True,
|
||||
}
|
||||
else:
|
||||
artifact = {**artifact, "content": content}
|
||||
enriched.append(artifact)
|
||||
return enriched
|
||||
|
||||
|
||||
async def _write_builtin_markdown_fallback(
|
||||
*, thread_id: str, kind: str, content: str
|
||||
) -> dict[str, Any]:
|
||||
@@ -784,7 +871,8 @@ async def _effective_delivery_material(
|
||||
|
||||
Stale/rejected rows are deliberately excluded. The persisted node answer
|
||||
provides a quick synopsis; the Markdown files provide the actual
|
||||
deliverable body, read through the owning thread's virtual-path resolver.
|
||||
deliverable body, preferably from its database recovery copy and otherwise
|
||||
through the owning thread's virtual-path resolver.
|
||||
"""
|
||||
remaining = _BUILTIN_MATERIAL_BUDGET
|
||||
lines: list[str] = ["【岗位有效交付】"]
|
||||
@@ -920,6 +1008,30 @@ def _build_builtin_snapshot(
|
||||
}
|
||||
|
||||
|
||||
@router.post("/demo/seed", response_model=SessionDetailResponse)
|
||||
async def seed_demo_session(
|
||||
request: Request, body: DemoSeedRequest
|
||||
) -> SessionDetailResponse:
|
||||
"""Idempotently materialise the frontend demo JSON for the current user.
|
||||
|
||||
The endpoint intentionally accepts a complete recovery snapshot instead of
|
||||
inventing a second demo-only schema. Imported rows therefore exercise the
|
||||
same history, position switching, artifact and conversation code paths as
|
||||
real collaboration sessions.
|
||||
"""
|
||||
|
||||
row, nodes = await _get_store(request).ensure_demo_session(
|
||||
_current_user_id(request),
|
||||
demo_key=body.demo_key,
|
||||
session_payload=body.session,
|
||||
nodes=body.nodes,
|
||||
)
|
||||
return SessionDetailResponse(
|
||||
**row,
|
||||
nodes=[_project_node(node) for node in nodes],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=list[SessionResponse])
|
||||
async def list_sessions(request: Request, external_task_id: str | None = None) -> list[SessionResponse]:
|
||||
rows = await _get_store(request).list_sessions(
|
||||
@@ -964,6 +1076,39 @@ async def update_intent_snapshot(
|
||||
user_id,
|
||||
intent_thread_id=body.intent_thread_id,
|
||||
intent_snapshot=body.intent_snapshot,
|
||||
intent_conversation=body.conversation_snapshot,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||
return _project_session(row)
|
||||
|
||||
|
||||
@router.put("/sessions/{session_id}/summary/state", response_model=SessionResponse)
|
||||
async def update_summary_conversation(
|
||||
request: Request, session_id: str, body: ConversationStateUpdateRequest
|
||||
) -> SessionResponse:
|
||||
store, user_id, _ = await _require_writable_session(request, session_id)
|
||||
row = await store.update_session(
|
||||
session_id,
|
||||
user_id,
|
||||
summary_thread_id=body.thread_id,
|
||||
summary_conversation=body.conversation_snapshot,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||
return _project_session(row)
|
||||
|
||||
|
||||
@router.put("/sessions/{session_id}/action-plan/state", response_model=SessionResponse)
|
||||
async def update_action_plan_conversation(
|
||||
request: Request, session_id: str, body: ConversationStateUpdateRequest
|
||||
) -> SessionResponse:
|
||||
store, user_id, _ = await _require_writable_session(request, session_id)
|
||||
row = await store.update_session(
|
||||
session_id,
|
||||
user_id,
|
||||
action_plan_thread_id=body.thread_id,
|
||||
action_plan_conversation=body.conversation_snapshot,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||
@@ -1016,6 +1161,16 @@ async def archive_session(request: Request, session_id: str) -> SessionResponse:
|
||||
return _project_session(row)
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}")
|
||||
async def delete_session(request: Request, session_id: str) -> dict[str, bool]:
|
||||
deleted = await _get_store(request).delete_session(
|
||||
session_id, _current_user_id(request)
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable session not found")
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/nodes/{node_key}", response_model=NodeResponse)
|
||||
async def get_node(request: Request, session_id: str, node_key: str) -> NodeResponse:
|
||||
row = await _get_store(request).get_node(session_id, node_key, _current_user_id(request))
|
||||
@@ -1048,6 +1203,38 @@ async def bind_node_thread(
|
||||
return _project_node(row)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/sessions/{session_id}/nodes/{node_key}/state",
|
||||
response_model=NodeResponse,
|
||||
)
|
||||
async def update_node_conversation(
|
||||
request: Request,
|
||||
session_id: str,
|
||||
node_key: str,
|
||||
body: ConversationStateUpdateRequest,
|
||||
) -> NodeResponse:
|
||||
store, user_id, _ = await _require_writable_session(request, session_id)
|
||||
current = await store.get_node(session_id, node_key, user_id)
|
||||
if current is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
||||
if (
|
||||
body.thread_id
|
||||
and current.get("thread_id")
|
||||
and current["thread_id"] != body.thread_id
|
||||
):
|
||||
raise HTTPException(status_code=409, detail="Position node is bound to another thread")
|
||||
row = await store.update_node(
|
||||
session_id,
|
||||
node_key,
|
||||
user_id,
|
||||
thread_id=body.thread_id if body.thread_id else current.get("thread_id"),
|
||||
conversation_snapshot=body.conversation_snapshot,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Position roundtable node not found")
|
||||
return _project_node(row)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/nodes/{node_key}/context")
|
||||
async def build_node_context(request: Request, session_id: str, node_key: str) -> dict[str, Any]:
|
||||
"""Return the bounded hidden task/intent/upstream context for a node turn."""
|
||||
@@ -1069,6 +1256,11 @@ async def complete_node_turn(
|
||||
# Artifact listing is best-effort on the client. Never erase a previously
|
||||
# delivered report merely because one listing request transiently failed.
|
||||
effective_artifacts = body.artifact_manifest or list(node.get("artifact_manifest") or [])
|
||||
if body.artifact_manifest:
|
||||
effective_artifacts = await _persist_artifact_contents(
|
||||
node.get("thread_id"),
|
||||
effective_artifacts,
|
||||
)
|
||||
has_markdown = any(_is_markdown_artifact(item) for item in effective_artifacts)
|
||||
markdown_changed = _markdown_delivery_changed(node.get("artifact_manifest"), effective_artifacts)
|
||||
requires_fresh_delivery = node.get("status") in {"rejected", "stale"}
|
||||
@@ -1240,7 +1432,7 @@ async def _complete_builtin_run(
|
||||
if body.source_revisions != current_revisions:
|
||||
raise HTTPException(status_code=409, detail="岗位产物版本已变化,请基于最新交付重新生成")
|
||||
|
||||
artifacts = _artifact_manifest_refs(body.artifact_manifest)
|
||||
artifacts = await _persist_artifact_contents(body.thread_id, body.artifact_manifest)
|
||||
markdown = [artifact for artifact in artifacts if _is_markdown_artifact(artifact)]
|
||||
if not markdown:
|
||||
# The report text is already visible in the chat stream. Do not lose
|
||||
@@ -1252,6 +1444,7 @@ async def _complete_builtin_run(
|
||||
kind=kind,
|
||||
content=body.content,
|
||||
)
|
||||
fallback_markdown["content"] = body.content
|
||||
artifacts.append(fallback_markdown)
|
||||
markdown = [fallback_markdown]
|
||||
if kind == "action_plan" and not any(_is_json_artifact(artifact) for artifact in artifacts):
|
||||
|
||||
@@ -536,8 +536,8 @@ async def update_publish_squares(body: PublishSquaresUpdateRequest, request: Req
|
||||
class AppearanceResponse(BaseModel):
|
||||
"""Effective system-wide appearance defaults."""
|
||||
|
||||
default_theme: Literal["light", "dark", "lightblue", "lightgreen"] = Field(
|
||||
..., description="System default theme — light, dark, lightblue or lightgreen"
|
||||
default_theme: Literal["light", "dark", "lightblue", "lightgreen", "iceblue"] = Field(
|
||||
..., description="System default theme — light, dark, lightblue, lightgreen or iceblue"
|
||||
)
|
||||
default_skill_view: Literal["card", "list"] = Field(..., description="System default skill list display mode — card or list")
|
||||
default_agent_view: Literal["card", "list"] = Field(..., description="System default agent list display mode — card or list")
|
||||
@@ -546,7 +546,7 @@ class AppearanceResponse(BaseModel):
|
||||
|
||||
|
||||
class AppearanceUpdateRequest(BaseModel):
|
||||
default_theme: Literal["light", "dark", "lightblue", "lightgreen"] | None = Field(default=None)
|
||||
default_theme: Literal["light", "dark", "lightblue", "lightgreen", "iceblue"] | None = Field(default=None)
|
||||
default_skill_view: Literal["card", "list"] | None = Field(default=None)
|
||||
default_agent_view: Literal["card", "list"] | None = Field(default=None)
|
||||
default_layout_mode: Literal["full", "compact"] | None = Field(default=None)
|
||||
|
||||
+2
-2
@@ -102,9 +102,9 @@ class AppearanceSettings(BaseModel):
|
||||
toggle, and any future first-time loads.
|
||||
"""
|
||||
|
||||
default_theme: Literal["light", "dark", "lightblue", "lightgreen"] = Field(
|
||||
default_theme: Literal["light", "dark", "lightblue", "lightgreen", "iceblue"] = Field(
|
||||
default="light",
|
||||
description="Theme applied for users who haven't picked their own (light / dark / lightblue / lightgreen)",
|
||||
description="Theme applied for users who haven't picked their own (light / dark / lightblue / lightgreen / iceblue)",
|
||||
)
|
||||
default_skill_view: Literal["card", "list"] = Field(
|
||||
default="list",
|
||||
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
"""Persist complete position-roundtable conversation recovery snapshots.
|
||||
|
||||
Revision ID: 20260723_05
|
||||
Revises: 20260721_04
|
||||
Create Date: 2026-07-23
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy.dialects.mysql import LONGTEXT
|
||||
|
||||
revision = "20260723_05"
|
||||
down_revision = "20260721_04"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _has_table(table_name: str) -> bool:
|
||||
return table_name in inspect(op.get_bind()).get_table_names()
|
||||
|
||||
|
||||
def _has_column(table_name: str, column_name: str) -> bool:
|
||||
if not _has_table(table_name):
|
||||
return False
|
||||
return column_name in {
|
||||
column["name"] for column in inspect(op.get_bind()).get_columns(table_name)
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
|
||||
session_table = "position_roundtable_sessions"
|
||||
if _has_table(session_table):
|
||||
for column in (
|
||||
"intent_conversation",
|
||||
"summary_conversation",
|
||||
"action_plan_conversation",
|
||||
):
|
||||
if not _has_column(session_table, column):
|
||||
op.add_column(
|
||||
session_table,
|
||||
sa.Column(column, long_text, nullable=True),
|
||||
)
|
||||
|
||||
node_table = "position_roundtable_nodes"
|
||||
if _has_table(node_table) and not _has_column(node_table, "conversation_snapshot"):
|
||||
op.add_column(
|
||||
node_table,
|
||||
sa.Column("conversation_snapshot", long_text, nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
node_table = "position_roundtable_nodes"
|
||||
if _has_table(node_table) and _has_column(node_table, "conversation_snapshot"):
|
||||
op.drop_column(node_table, "conversation_snapshot")
|
||||
|
||||
session_table = "position_roundtable_sessions"
|
||||
if _has_table(session_table):
|
||||
for column in (
|
||||
"action_plan_conversation",
|
||||
"summary_conversation",
|
||||
"intent_conversation",
|
||||
):
|
||||
if _has_column(session_table, column):
|
||||
op.drop_column(session_table, column)
|
||||
+16
-4
@@ -2,9 +2,10 @@
|
||||
|
||||
This deliberately does **not** reuse ``roundtable_drafts`` or
|
||||
``roundtable_jobs``. A session captures the selected business chain and the
|
||||
confirmed task intent; every chain seat then has one durable node record. The
|
||||
node points at an existing LangGraph thread, which continues to own the actual
|
||||
message history and generated artifacts.
|
||||
confirmed task intent; every chain seat then has one durable node record.
|
||||
LangGraph threads remain the execution source, while these tables also retain
|
||||
database-owned recovery copies of conversations and text deliverables so the
|
||||
workspace survives replacement of container-local checkpoint/output volumes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -31,13 +32,20 @@ class PositionRoundtableSessionRow(Base):
|
||||
chain_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
intent_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
intent_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
# Database-owned recovery copy for the complete Step-1 conversation. The
|
||||
# normal LangGraph checkpoint remains the execution source, while this
|
||||
# snapshot lets the position workspace recover after a checkpoint volume
|
||||
# or container-local store has been replaced.
|
||||
intent_conversation: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
# The two built-in closing agents own normal LangGraph threads too. Keep
|
||||
# their ids on the position session so a refresh can continue the same
|
||||
# report/planning dialogue instead of silently starting a fresh context.
|
||||
summary_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
summary_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
summary_conversation: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
action_plan_thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
action_plan_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
action_plan_conversation: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
# intent_pending -> active -> completed / archived. The router owns legal
|
||||
# transitions; the DB keeps it as a concise, portable string.
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="intent_pending", index=True)
|
||||
@@ -71,12 +79,16 @@ class PositionRoundtableNodeRow(Base):
|
||||
seat_index: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
agent_id: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||
position_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
# The real messages remain in the standard LangGraph/checkpoint store.
|
||||
# The LangGraph/checkpoint store remains the execution source for messages.
|
||||
thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True, unique=True)
|
||||
# locked / ready / running / done / stale / rejected / error
|
||||
status: Mapped[str] = mapped_column(String(32), nullable=False, default="locked", index=True)
|
||||
latest_answer: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
artifact_manifest: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
# A complete, JSON-serialisable copy of the node chat plus its composer
|
||||
# draft. It is intentionally colocated with the node so deleting a
|
||||
# position Session removes every recovery snapshot through the FK cascade.
|
||||
conversation_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
revision: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
upstream_revision: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
|
||||
rejection_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
|
||||
|
||||
+180
-2
@@ -5,9 +5,10 @@ from __future__ import annotations
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from uuid import NAMESPACE_URL, uuid4, uuid5
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from deerflow.persistence.position_roundtable.model import (
|
||||
@@ -55,10 +56,13 @@ class PositionRoundtableRepository:
|
||||
"chain_snapshot": _loads(row.chain_snapshot),
|
||||
"intent_thread_id": row.intent_thread_id,
|
||||
"intent_snapshot": _loads(row.intent_snapshot),
|
||||
"intent_conversation": _loads(row.intent_conversation),
|
||||
"summary_thread_id": row.summary_thread_id,
|
||||
"summary_snapshot": _loads(row.summary_snapshot),
|
||||
"summary_conversation": _loads(row.summary_conversation),
|
||||
"action_plan_thread_id": row.action_plan_thread_id,
|
||||
"action_plan_snapshot": _loads(row.action_plan_snapshot),
|
||||
"action_plan_conversation": _loads(row.action_plan_conversation),
|
||||
"status": row.status,
|
||||
"created_at": _time(row.created_at),
|
||||
"updated_at": _time(row.updated_at),
|
||||
@@ -78,6 +82,7 @@ class PositionRoundtableRepository:
|
||||
"status": row.status,
|
||||
"latest_answer": row.latest_answer,
|
||||
"artifact_manifest": _loads(row.artifact_manifest) or [],
|
||||
"conversation_snapshot": _loads(row.conversation_snapshot),
|
||||
"revision": row.revision,
|
||||
"upstream_revision": _loads(row.upstream_revision),
|
||||
"rejection_count": row.rejection_count or 0,
|
||||
@@ -110,6 +115,134 @@ class PositionRoundtableRepository:
|
||||
await session.refresh(row)
|
||||
return self._session_dict(row)
|
||||
|
||||
async def ensure_demo_session(
|
||||
self,
|
||||
user_id: str,
|
||||
*,
|
||||
demo_key: str,
|
||||
session_payload: dict[str, Any],
|
||||
nodes: list[dict[str, Any]],
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||
"""Create one deterministic, user-scoped demonstration session.
|
||||
|
||||
The browser owns the canonical JSON fixture so the same payload can be
|
||||
rendered locally when the gateway is unavailable. When the gateway is
|
||||
reachable this method imports that payload exactly once. Stable ids
|
||||
make repeated initialisation and React StrictMode double effects safe,
|
||||
while the user id in every UUID seed avoids cross-account collisions
|
||||
on the globally unique LangGraph thread columns.
|
||||
"""
|
||||
|
||||
def stable_id(label: str) -> str:
|
||||
return uuid5(
|
||||
NAMESPACE_URL,
|
||||
f"deerflow:position-roundtable-demo:{user_id}:{demo_key}:{label}",
|
||||
).hex
|
||||
|
||||
session_id = stable_id("session")
|
||||
existing = await self.get_session(session_id, user_id)
|
||||
if existing is not None:
|
||||
return existing, (await self.list_nodes(session_id, user_id)) or []
|
||||
|
||||
now = datetime.now(UTC)
|
||||
intent_thread_id = (
|
||||
stable_id("intent-thread") if session_payload.get("intent_thread_id") else None
|
||||
)
|
||||
summary_thread_id = (
|
||||
stable_id("summary-thread") if session_payload.get("summary_thread_id") else None
|
||||
)
|
||||
action_plan_thread_id = (
|
||||
stable_id("action-plan-thread")
|
||||
if session_payload.get("action_plan_thread_id")
|
||||
else None
|
||||
)
|
||||
|
||||
def with_thread_id(value: Any, thread_id: str | None) -> Any:
|
||||
if not isinstance(value, dict) or not thread_id:
|
||||
return value
|
||||
return {**value, "thread_id": thread_id}
|
||||
|
||||
row = PositionRoundtableSessionRow(
|
||||
id=session_id,
|
||||
user_id=user_id,
|
||||
external_task_id=session_payload.get("external_task_id") or None,
|
||||
task_snapshot=_dumps(session_payload.get("task_snapshot") or {}),
|
||||
chain_id=session_payload.get("chain_id") or None,
|
||||
chain_snapshot=_dumps(session_payload.get("chain_snapshot")),
|
||||
intent_thread_id=intent_thread_id,
|
||||
intent_snapshot=_dumps(session_payload.get("intent_snapshot")),
|
||||
intent_conversation=_dumps(session_payload.get("intent_conversation")),
|
||||
summary_thread_id=summary_thread_id,
|
||||
summary_snapshot=_dumps(
|
||||
with_thread_id(session_payload.get("summary_snapshot"), summary_thread_id)
|
||||
),
|
||||
summary_conversation=_dumps(session_payload.get("summary_conversation")),
|
||||
action_plan_thread_id=action_plan_thread_id,
|
||||
action_plan_snapshot=_dumps(
|
||||
with_thread_id(
|
||||
session_payload.get("action_plan_snapshot"), action_plan_thread_id
|
||||
)
|
||||
),
|
||||
action_plan_conversation=_dumps(
|
||||
session_payload.get("action_plan_conversation")
|
||||
),
|
||||
status=str(session_payload.get("status") or "completed"),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
|
||||
node_rows: list[PositionRoundtableNodeRow] = []
|
||||
for index, node in enumerate(nodes):
|
||||
node_key = str(node.get("node_key") or f"demo-node-{index + 1}")
|
||||
node_rows.append(
|
||||
PositionRoundtableNodeRow(
|
||||
id=stable_id(f"node:{node_key}"),
|
||||
session_id=session_id,
|
||||
node_key=node_key,
|
||||
stage_index=int(node.get("stage_index") or 0),
|
||||
seat_index=int(node.get("seat_index") or 0),
|
||||
agent_id=str(node.get("agent_id") or f"demo-agent-{index + 1}"),
|
||||
position_id=node.get("position_id") or None,
|
||||
thread_id=(
|
||||
stable_id(f"node-thread:{node_key}")
|
||||
if node.get("thread_id")
|
||||
else None
|
||||
),
|
||||
status=str(node.get("status") or "done"),
|
||||
latest_answer=node.get("latest_answer") or None,
|
||||
artifact_manifest=_dumps(node.get("artifact_manifest") or []),
|
||||
conversation_snapshot=_dumps(node.get("conversation_snapshot")),
|
||||
revision=int(node.get("revision") or 0),
|
||||
upstream_revision=_dumps(node.get("upstream_revision")),
|
||||
rejection_count=int(node.get("rejection_count") or 0),
|
||||
last_rejection=_dumps(node.get("last_rejection")),
|
||||
invalidated_by=node.get("invalidated_by") or None,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
async with self._sf() as session:
|
||||
session.add(row)
|
||||
session.add_all(node_rows)
|
||||
await session.commit()
|
||||
await session.refresh(row)
|
||||
for node_row in node_rows:
|
||||
await session.refresh(node_row)
|
||||
except IntegrityError:
|
||||
# Another tab may have seeded the same user/demo key between the
|
||||
# initial lookup and commit. The deterministic row is the desired
|
||||
# result, so recover it instead of returning a spurious 500.
|
||||
existing = await self.get_session(session_id, user_id)
|
||||
if existing is None:
|
||||
raise
|
||||
return existing, (await self.list_nodes(session_id, user_id)) or []
|
||||
|
||||
return self._session_dict(row), [
|
||||
self._node_dict(node_row) for node_row in node_rows
|
||||
]
|
||||
|
||||
async def list_sessions(
|
||||
self, user_id: str, *, external_task_id: str | None = None, limit: int = 50
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -136,10 +269,13 @@ class PositionRoundtableRepository:
|
||||
task_snapshot: dict[str, Any] | object = _UNSET,
|
||||
intent_thread_id: str | None | object = _UNSET,
|
||||
intent_snapshot: dict[str, Any] | None | object = _UNSET,
|
||||
intent_conversation: dict[str, Any] | None | object = _UNSET,
|
||||
summary_thread_id: str | None | object = _UNSET,
|
||||
summary_snapshot: dict[str, Any] | None | object = _UNSET,
|
||||
summary_conversation: dict[str, Any] | None | object = _UNSET,
|
||||
action_plan_thread_id: str | None | object = _UNSET,
|
||||
action_plan_snapshot: dict[str, Any] | None | object = _UNSET,
|
||||
action_plan_conversation: dict[str, Any] | None | object = _UNSET,
|
||||
status: str | object = _UNSET,
|
||||
) -> dict[str, Any] | None:
|
||||
async with self._sf() as session:
|
||||
@@ -152,14 +288,20 @@ class PositionRoundtableRepository:
|
||||
row.intent_thread_id = intent_thread_id or None
|
||||
if intent_snapshot is not _UNSET:
|
||||
row.intent_snapshot = _dumps(intent_snapshot)
|
||||
if intent_conversation is not _UNSET:
|
||||
row.intent_conversation = _dumps(intent_conversation)
|
||||
if summary_thread_id is not _UNSET:
|
||||
row.summary_thread_id = summary_thread_id or None
|
||||
if summary_snapshot is not _UNSET:
|
||||
row.summary_snapshot = _dumps(summary_snapshot)
|
||||
if summary_conversation is not _UNSET:
|
||||
row.summary_conversation = _dumps(summary_conversation)
|
||||
if action_plan_thread_id is not _UNSET:
|
||||
row.action_plan_thread_id = action_plan_thread_id or None
|
||||
if action_plan_snapshot is not _UNSET:
|
||||
row.action_plan_snapshot = _dumps(action_plan_snapshot)
|
||||
if action_plan_conversation is not _UNSET:
|
||||
row.action_plan_conversation = _dumps(action_plan_conversation)
|
||||
if status is not _UNSET:
|
||||
row.status = str(status)
|
||||
row.updated_at = datetime.now(UTC)
|
||||
@@ -265,6 +407,7 @@ class PositionRoundtableRepository:
|
||||
status: str | object = _UNSET,
|
||||
latest_answer: str | None | object = _UNSET,
|
||||
artifact_manifest: list[dict[str, Any]] | object = _UNSET,
|
||||
conversation_snapshot: dict[str, Any] | None | object = _UNSET,
|
||||
upstream_revision: dict[str, Any] | object = _UNSET,
|
||||
last_rejection: dict[str, Any] | None | object = _UNSET,
|
||||
invalidated_by: str | None | object = _UNSET,
|
||||
@@ -293,6 +436,8 @@ class PositionRoundtableRepository:
|
||||
row.latest_answer = latest_answer
|
||||
if artifact_manifest is not _UNSET:
|
||||
row.artifact_manifest = _dumps(artifact_manifest)
|
||||
if conversation_snapshot is not _UNSET:
|
||||
row.conversation_snapshot = _dumps(conversation_snapshot)
|
||||
if upstream_revision is not _UNSET:
|
||||
row.upstream_revision = _dumps(upstream_revision)
|
||||
if last_rejection is not _UNSET:
|
||||
@@ -392,5 +537,38 @@ class PositionRoundtableRepository:
|
||||
async def archive_session(self, session_id: str, user_id: str) -> dict[str, Any] | None:
|
||||
return await self.update_session(session_id, user_id, status="archived")
|
||||
|
||||
async def delete_session(self, session_id: str, user_id: str) -> bool:
|
||||
"""Delete one user-owned history record and all of its node snapshots."""
|
||||
async with self._sf() as session:
|
||||
parent = (
|
||||
await session.execute(
|
||||
select(PositionRoundtableSessionRow)
|
||||
.where(
|
||||
PositionRoundtableSessionRow.id == session_id,
|
||||
PositionRoundtableSessionRow.user_id == user_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if parent is None:
|
||||
return False
|
||||
|
||||
# Do not rely on database-specific FK cascade settings. SQLite
|
||||
# deployments may not have PRAGMA foreign_keys enabled, which
|
||||
# would otherwise leave orphaned conversation/artifact snapshots.
|
||||
await session.execute(
|
||||
delete(PositionRoundtableNodeRow).where(
|
||||
PositionRoundtableNodeRow.session_id == session_id
|
||||
)
|
||||
)
|
||||
await session.execute(
|
||||
delete(PositionRoundtableSessionRow).where(
|
||||
PositionRoundtableSessionRow.id == session_id,
|
||||
PositionRoundtableSessionRow.user_id == user_id,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return True
|
||||
|
||||
async def list_results(self, session_id: str, user_id: str) -> list[dict[str, Any]] | None:
|
||||
return await self.list_nodes(session_id, user_id)
|
||||
@@ -89,6 +89,200 @@ async def test_session_activation_freezes_chain_and_creates_nodes_once(store):
|
||||
assert [node["node_key"] for node in repeated_nodes] == [node["node_key"] for node in created_nodes]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_conversation_snapshots_round_trip_and_history_delete_removes_nodes(store):
|
||||
"""数据库恢复副本必须覆盖三个对话阶段、岗位节点和未发送草稿。"""
|
||||
created = await store.create_session(
|
||||
"user-a",
|
||||
external_task_id="task-persisted",
|
||||
task_snapshot={"title": "持久化测试"},
|
||||
)
|
||||
intent_conversation = {
|
||||
"messages": [{"id": "intent-user-1", "type": "human", "content": "请分析任务"}],
|
||||
"timeline": [{"kind": "message", "message_id": "intent-user-1"}],
|
||||
"draft": "尚未发送的意图补充",
|
||||
}
|
||||
summary_conversation = {
|
||||
"messages": [{"id": "summary-ai-1", "type": "ai", "content": "方案总结"}],
|
||||
"draft": "总结修改意见",
|
||||
}
|
||||
action_conversation = {
|
||||
"messages": [{"id": "action-ai-1", "type": "ai", "content": "行动规划"}],
|
||||
"draft": "",
|
||||
}
|
||||
updated = await store.update_session(
|
||||
created["id"],
|
||||
"user-a",
|
||||
intent_thread_id="intent-thread",
|
||||
intent_conversation=intent_conversation,
|
||||
summary_conversation=summary_conversation,
|
||||
action_plan_conversation=action_conversation,
|
||||
)
|
||||
assert updated is not None
|
||||
assert updated["intent_conversation"] == intent_conversation
|
||||
assert updated["summary_conversation"] == summary_conversation
|
||||
assert updated["action_plan_conversation"] == action_conversation
|
||||
|
||||
_, nodes = await store.activate_session(
|
||||
created["id"],
|
||||
"user-a",
|
||||
chain_id="chain-a",
|
||||
chain_snapshot={"id": "chain-a"},
|
||||
nodes=[
|
||||
{
|
||||
"node_key": "stage-1-seat-1-agent-a",
|
||||
"stage_index": 0,
|
||||
"seat_index": 0,
|
||||
"agent_id": "agent-a",
|
||||
"position_id": "planning",
|
||||
"status": "ready",
|
||||
}
|
||||
],
|
||||
)
|
||||
node_conversation = {
|
||||
"messages": [{"id": "node-ai-1", "type": "ai", "content": "岗位答复"}],
|
||||
"draft": "岗位草稿",
|
||||
}
|
||||
saved_node = await store.update_node(
|
||||
created["id"],
|
||||
nodes[0]["node_key"],
|
||||
"user-a",
|
||||
conversation_snapshot=node_conversation,
|
||||
)
|
||||
assert saved_node is not None
|
||||
assert saved_node["conversation_snapshot"] == node_conversation
|
||||
|
||||
# Ownership checks apply to delete as well.
|
||||
assert not await store.delete_session(created["id"], "user-b")
|
||||
assert await store.get_session(created["id"], "user-a") is not None
|
||||
assert await store.delete_session(created["id"], "user-a")
|
||||
assert await store.get_session(created["id"], "user-a") is None
|
||||
assert await store.list_nodes(created["id"], "user-a") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_demo_seed_is_complete_idempotent_and_user_scoped(store):
|
||||
payload = {
|
||||
"external_task_id": "position-demo-full-v1",
|
||||
"task_snapshot": {"title": "暑期活动"},
|
||||
"chain_id": "position-demo-chain",
|
||||
"chain_snapshot": {
|
||||
"id": "position-demo-chain",
|
||||
"seats": [
|
||||
{
|
||||
"agent_id": "demo-agent",
|
||||
"name": "演示分析智能体",
|
||||
"position_id": "planning",
|
||||
}
|
||||
],
|
||||
},
|
||||
"intent_thread_id": "frontend-placeholder",
|
||||
"intent_snapshot": {
|
||||
"objective": "形成完整宣传方案",
|
||||
"constraints": ["预算不少于 15 万元"],
|
||||
},
|
||||
"intent_conversation": {
|
||||
"messages": [{"id": 1, "sender": "agent", "content": "意图已确认"}],
|
||||
"inputDraft": "",
|
||||
},
|
||||
"summary_thread_id": "frontend-placeholder",
|
||||
"summary_snapshot": {"status": "done", "content": "方案总结"},
|
||||
"summary_conversation": {
|
||||
"messages": [{"id": "summary-ai", "type": "ai", "content": "方案总结"}]
|
||||
},
|
||||
"action_plan_thread_id": "frontend-placeholder",
|
||||
"action_plan_snapshot": {"status": "done", "content": "行动规划"},
|
||||
"action_plan_conversation": {
|
||||
"messages": [{"id": "action-ai", "type": "ai", "content": "行动规划"}]
|
||||
},
|
||||
"status": "completed",
|
||||
}
|
||||
nodes = [
|
||||
{
|
||||
"node_key": "stage-1-seat-1-demo-agent",
|
||||
"stage_index": 0,
|
||||
"seat_index": 0,
|
||||
"agent_id": "demo-agent",
|
||||
"position_id": "planning",
|
||||
"thread_id": "frontend-placeholder",
|
||||
"status": "done",
|
||||
"latest_answer": "报告已完成",
|
||||
"artifact_manifest": [
|
||||
{
|
||||
"name": "演示报告.md",
|
||||
"path": "mnt/user-data/outputs/演示报告.md",
|
||||
"mime_type": "text/markdown",
|
||||
"content": "# 演示报告\n\n完整正文",
|
||||
}
|
||||
],
|
||||
"conversation_snapshot": {
|
||||
"messages": [{"id": "node-ai", "type": "ai", "content": "报告已完成"}],
|
||||
"inputDraft": "",
|
||||
},
|
||||
"revision": 1,
|
||||
}
|
||||
]
|
||||
|
||||
first, first_nodes = await store.ensure_demo_session(
|
||||
"user-a",
|
||||
demo_key="full-v1",
|
||||
session_payload=payload,
|
||||
nodes=nodes,
|
||||
)
|
||||
repeated, repeated_nodes = await store.ensure_demo_session(
|
||||
"user-a",
|
||||
demo_key="full-v1",
|
||||
session_payload={**payload, "task_snapshot": {"title": "不应覆盖"}},
|
||||
nodes=[],
|
||||
)
|
||||
other_user, other_nodes = await store.ensure_demo_session(
|
||||
"user-b",
|
||||
demo_key="full-v1",
|
||||
session_payload=payload,
|
||||
nodes=nodes,
|
||||
)
|
||||
|
||||
assert first["id"] == repeated["id"]
|
||||
assert first["task_snapshot"]["title"] == "暑期活动"
|
||||
assert repeated["task_snapshot"]["title"] == "暑期活动"
|
||||
assert first["intent_conversation"] == payload["intent_conversation"]
|
||||
assert first["summary_snapshot"]["thread_id"] == first["summary_thread_id"]
|
||||
assert first["action_plan_snapshot"]["thread_id"] == first["action_plan_thread_id"]
|
||||
assert first_nodes[0]["artifact_manifest"][0]["content"] == "# 演示报告\n\n完整正文"
|
||||
assert repeated_nodes[0]["id"] == first_nodes[0]["id"]
|
||||
assert other_user["id"] != first["id"]
|
||||
assert other_user["intent_thread_id"] != first["intent_thread_id"]
|
||||
assert other_nodes[0]["thread_id"] != first_nodes[0]["thread_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embedded_artifact_content_survives_missing_sandbox(monkeypatch):
|
||||
"""后续岗位优先读取 SQL 正文,不能因沙箱卷丢失而只拿到文件名。"""
|
||||
async def unexpected_resolve(*_args, **_kwargs):
|
||||
raise AssertionError("embedded database content should avoid sandbox resolution")
|
||||
|
||||
monkeypatch.setattr(
|
||||
position_roundtable_router,
|
||||
"aresolve_thread_virtual_path",
|
||||
unexpected_resolve,
|
||||
)
|
||||
artifacts, consumed = await position_roundtable_router._artifact_refs_with_content(
|
||||
"removed-thread",
|
||||
[
|
||||
{
|
||||
"name": "report.md",
|
||||
"path": "mnt/user-data/outputs/report.md",
|
||||
"mime_type": "text/markdown",
|
||||
"content": "# 数据库恢复报告\n\n完整正文",
|
||||
}
|
||||
],
|
||||
10_000,
|
||||
)
|
||||
|
||||
assert artifacts[0]["content"] == "# 数据库恢复报告\n\n完整正文"
|
||||
assert consumed == len("# 数据库恢复报告\n\n完整正文")
|
||||
|
||||
|
||||
def test_task_external_context_reaches_agent_runtime_context():
|
||||
"""岗位会商的左侧任务快照不能在网关白名单中丢失。"""
|
||||
task_context = {
|
||||
|
||||
Reference in new issue
Block a user