diff --git a/frontend-web/.env.example b/frontend-web/.env.example index eea12c4c..68e79a97 100644 --- a/frontend-web/.env.example +++ b/frontend-web/.env.example @@ -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 diff --git a/frontend-web/docs/position-roundtable-delivery-workflow-plan.md b/frontend-web/docs/position-roundtable-delivery-workflow-plan.md index 75c06b4c..62503eed 100644 --- a/frontend-web/docs/position-roundtable-delivery-workflow-plan.md +++ b/frontend-web/docs/position-roundtable-delivery-workflow-plan.md @@ -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 页面流程。 diff --git a/frontend-web/package-lock.json b/frontend-web/package-lock.json index 4b51ccb8..a590dbe8 100644 --- a/frontend-web/package-lock.json +++ b/frontend-web/package-lock.json @@ -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", diff --git a/frontend-web/public/runtime-config.js b/frontend-web/public/runtime-config.js index 85cc850a..02984b26 100644 --- a/frontend-web/public/runtime-config.js +++ b/frontend-web/public/runtime-config.js @@ -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)。 diff --git a/frontend-web/src/App.tsx b/frontend-web/src/App.tsx index e3978689..13ac398c 100644 --- a/frontend-web/src/App.tsx +++ b/frontend-web/src/App.tsx @@ -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 }) { - - + {!positionStandaloneMode && } + {!positionStandaloneMode && } {children} @@ -114,17 +121,38 @@ function AppProviders({ children }: { children: React.ReactNode }) { */ function RootRedirect() { const { search } = useLocation(); + if (isPositionRoundtableStandaloneMode()) { + return ; + } return ; } export function App() { + const positionStandaloneMode = isPositionRoundtableStandaloneMode(); + const standaloneLoginRedirect = ( + + ); + const pageElement = positionStandaloneMode ? ( + + + + ) : ( + + ); + return ( } /> - } /> - } /> + } + /> + } + /> } /> } /> } /> @@ -161,7 +189,7 @@ export function App() { path="/roundtable-dashboard" element={} /> - } /> + } /> diff --git a/frontend-web/src/components/page-sdebar.css b/frontend-web/src/components/page-sdebar.css index e6eb7677..8737cbe7 100644 --- a/frontend-web/src/components/page-sdebar.css +++ b/frontend-web/src/components/page-sdebar.css @@ -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; diff --git a/frontend-web/src/components/workspace/appearance-controls.tsx b/frontend-web/src/components/workspace/appearance-controls.tsx index 47d53b75..192fc33c 100644 --- a/frontend-web/src/components/workspace/appearance-controls.tsx +++ b/frontend-web/src/components/workspace/appearance-controls.tsx @@ -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" }, ]; diff --git a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx index 657cb6f9..b73fbba2 100644 --- a/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx +++ b/frontend-web/src/components/workspace/settings/appearance-settings-page.tsx @@ -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 }) { 绿色 +

当前系统默认: {globalDefault === "light" @@ -584,7 +605,9 @@ export function AppearanceSettingsPage({ onClose }: { onClose?: () => void }) { ? "深色" : globalDefault === "lightgreen" ? "绿色" - : "浅蓝"} + : globalDefault === "iceblue" + ? "冰川蓝" + : "浅蓝"}

@@ -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]", )} /> diff --git a/frontend-web/src/contexts/ThemeContext.tsx b/frontend-web/src/contexts/ThemeContext.tsx index ece09321..f44e7ad7 100644 --- a/frontend-web/src/contexts/ThemeContext.tsx +++ b/frontend-web/src/contexts/ThemeContext.tsx @@ -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 = { 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 = ({ 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 = ({ 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 = ({ children }) => { ? 'lightblue' : effectiveTheme === 'lightgreen' ? 'lightgreen' - : 'light' + : effectiveTheme === 'iceblue' + ? 'iceblue' + : 'light' : readPersistedTheme(); useEffect(() => { @@ -260,7 +291,7 @@ export const ThemeProvider: React.FC = ({ 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'); diff --git a/frontend-web/src/core/agents/hooks.ts b/frontend-web/src/core/agents/hooks.ts index 46e70ca0..04dcec18 100644 --- a/frontend-web/src/core/agents/hooks.ts +++ b/frontend-web/src/core/agents/hooks.ts @@ -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 }; } diff --git a/frontend-web/src/core/branding/brand-assets.ts b/frontend-web/src/core/branding/brand-assets.ts index 127075e5..77182eba 100644 --- a/frontend-web/src/core/branding/brand-assets.ts +++ b/frontend-web/src/core/branding/brand-assets.ts @@ -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 = { light: fallbackLogoLight, lightblue: fallbackLogoLightblue, lightgreen: fallbackLogoLight, + iceblue: fallbackLogoLightblue, dark: fallbackLogoDark, }; @@ -16,6 +17,7 @@ const headerRuntimeKeys: Record = { 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 = { 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", }; diff --git a/frontend-web/src/core/i18n/locales/en-US.ts b/frontend-web/src/core/i18n/locales/en-US.ts index cc28ffee..396a828b 100644 --- a/frontend-web/src/core/i18n/locales/en-US.ts +++ b/frontend-web/src/core/i18n/locales/en-US.ts @@ -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.", }, diff --git a/frontend-web/src/core/i18n/locales/types.ts b/frontend-web/src/core/i18n/locales/types.ts index 29783587..64dc4f67 100644 --- a/frontend-web/src/core/i18n/locales/types.ts +++ b/frontend-web/src/core/i18n/locales/types.ts @@ -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; }; diff --git a/frontend-web/src/core/i18n/locales/zh-CN.ts b/frontend-web/src/core/i18n/locales/zh-CN.ts index 5e62a6d9..5f54be64 100644 --- a/frontend-web/src/core/i18n/locales/zh-CN.ts +++ b/frontend-web/src/core/i18n/locales/zh-CN.ts @@ -431,11 +431,13 @@ export const zhCN: Translations = { dark: "深色", lightblue: "浅蓝", lightgreen: "绿色", + iceblue: "冰川蓝", systemDescription: "自动跟随系统主题。", lightDescription: "更明亮的配色,适合日间使用。", darkDescription: "更暗的配色,减少眩光方便专注。", lightblueDescription: "以蓝色为主色的清爽浅色配色。", lightgreenDescription: "采用 WeKnora 绿色的清爽浅色配色。", + iceblueDescription: "以 #9BCEF1 为主色,搭配冰蓝留白与沉稳交互色。", languageTitle: "语言", languageDescription: "在不同语言之间切换。", }, diff --git a/frontend-web/src/core/system-settings/api.ts b/frontend-web/src/core/system-settings/api.ts index d1be600e..0ae6d5fa 100644 --- a/frontend-web/src/core/system-settings/api.ts +++ b/frontend-web/src/core/system-settings/api.ts @@ -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"; diff --git a/frontend-web/src/core/system-settings/hooks.ts b/frontend-web/src/core/system-settings/hooks.ts index b21902f7..5ec98457 100644 --- a/frontend-web/src/core/system-settings/hooks.ts +++ b/frontend-web/src/core/system-settings/hooks.ts @@ -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) diff --git a/frontend-web/src/pages/LightAppIframeView.tsx b/frontend-web/src/pages/LightAppIframeView.tsx index aa687b50..91aa1c28 100644 --- a/frontend-web/src/pages/LightAppIframeView.tsx +++ b/frontend-web/src/pages/LightAppIframeView.tsx @@ -27,6 +27,8 @@ export default function LightAppIframeView() { ? "green" : theme === "lightblue" ? "blue" + : theme === "iceblue" + ? "iceblue" : "red"; const src = useMemo( () => (app ? buildLightAppUrl(app, undefined, themeColor) : ""), diff --git a/frontend-web/src/pages/LightAppListPage.tsx b/frontend-web/src/pages/LightAppListPage.tsx index b97ffe5e..1f6da0b0 100644 --- a/frontend-web/src/pages/LightAppListPage.tsx +++ b/frontend-web/src/pages/LightAppListPage.tsx @@ -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({ ); })} @@ -1828,19 +2458,20 @@ export default function PositionRoundtablePage({ /> )}
- {activeBuiltinKind && positionSession ? ( + {activeBuiltinKind && conversationSession ? ( setSelectedAgentId(null)} intentObjective={frozenIntent?.objective ?? intent.intentReady?.objective} intentCompletedAt={intentCompletedAt} - assignedAgentIds={assignedAgentIds} - hasSelectedChain={Boolean(selectedChain)} + // 测试分析岗不配置业务链席位;右侧配置只面向两个收口智能体, + // 即方案总结与行动规划。其他岗位仍保持按当前岗位过滤的席位配置。 + assignedAgentIds={configurationAgentIds} + hasSelectedChain={showClosureAgents || Boolean(selectedChain)} + closureAgentConfiguration={showClosureAgents} session={positionSession} onConfigureChain={ - selectedChain ? () => openPositionConfig(selectedChain.id) : undefined + !showClosureAgents && selectedChain + ? () => openPositionConfig(selectedChain.id) + : undefined } nodes={positionSession?.nodes ?? []} chain={activeChain} - onSessionUpdate={setPositionSession} + onSessionUpdate={handlePositionSessionUpdate} onCollapse={() => setRightPanelOpen(false)} onPreviewArtifact={handlePreviewArtifact} onOpenBuiltinChat={showClosureAgents ? handleSelectBuiltinStage : undefined} tab={rightTab} onTabChange={setRightTab} + standaloneDemo={standaloneDemoMode} /> )} @@ -1926,20 +2564,24 @@ export default function PositionRoundtablePage({ onBackToAgents={() => setSelectedAgentId(null)} intentObjective={frozenIntent?.objective ?? intent.intentReady?.objective} intentCompletedAt={intentCompletedAt} - assignedAgentIds={assignedAgentIds} - hasSelectedChain={Boolean(selectedChain)} + assignedAgentIds={configurationAgentIds} + hasSelectedChain={showClosureAgents || Boolean(selectedChain)} + closureAgentConfiguration={showClosureAgents} session={positionSession} onConfigureChain={ - selectedChain ? () => openPositionConfig(selectedChain.id) : undefined + !showClosureAgents && selectedChain + ? () => openPositionConfig(selectedChain.id) + : undefined } nodes={positionSession?.nodes ?? []} chain={activeChain} - onSessionUpdate={setPositionSession} + onSessionUpdate={handlePositionSessionUpdate} onCollapse={() => setRightPanelOpen(false)} onPreviewArtifact={handlePreviewArtifact} onOpenBuiltinChat={showClosureAgents ? handleSelectBuiltinStage : undefined} tab={rightTab} onTabChange={setRightTab} + standaloneDemo={standaloneDemoMode} />
@@ -4007,6 +4649,7 @@ function RightWorkspace({ intentCompletedAt, assignedAgentIds, hasSelectedChain, + closureAgentConfiguration = false, session, onConfigureChain, nodes, @@ -4017,6 +4660,7 @@ function RightWorkspace({ onOpenBuiltinChat, tab, onTabChange, + standaloneDemo = false, }: { selectedAgent: Agent | null; selectedAgentId: string | null; @@ -4026,6 +4670,8 @@ function RightWorkspace({ intentCompletedAt: string; assignedAgentIds: Set; hasSelectedChain: boolean; + /** 测试分析岗只展示方案总结、行动规划两个收口智能体。 */ + closureAgentConfiguration?: boolean; session: PositionRoundtableSession | null; onConfigureChain?: () => void; nodes: PositionRoundtableNode[]; @@ -4036,19 +4682,28 @@ function RightWorkspace({ onOpenBuiltinChat?: (kind: ClosureStageKind) => void; tab: WorkspaceTab; onTabChange: (tab: WorkspaceTab) => void; + /** Read-only frontend fixture: do not mount configuration/API consumers. */ + standaloneDemo?: boolean; }) { + const visibleTab: WorkspaceTab = standaloneDemo ? "results" : tab; return (
{/* 与课题研究 / ai写作一致的分段切换样式。 */}
-
+
+ {!standaloneDemo && ( + )}
- {tab === "agents" ? ( + {visibleTab === "agents" ? (
{selectedAgentId ? ( @@ -4115,6 +4771,7 @@ function RightWorkspace({ assignedAgentIds={assignedAgentIds} hasSelectedChain={hasSelectedChain} onConfigureChain={onConfigureChain} + scope={closureAgentConfiguration ? "closure" : "position"} /> )}
@@ -4130,6 +4787,7 @@ function RightWorkspace({ onSessionUpdate={onSessionUpdate} onPreviewArtifact={onPreviewArtifact} onOpenBuiltinChat={onOpenBuiltinChat} + readOnly={standaloneDemo} />
)} @@ -4143,21 +4801,47 @@ function PositionAgentList({ assignedAgentIds, hasSelectedChain, onConfigureChain, + scope = "position", }: { onSelect: (agentId: string) => void; assignedAgentIds: Set; hasSelectedChain: boolean; onConfigureChain?: () => void; + /** 收口阶段不受业务链标签过滤,始终只展示固定的两个内置智能体。 */ + scope?: "position" | "closure"; }) { const { theme } = useTheme(); const isDark = theme === "dark"; - const { groups, isLoading } = useAgentModuleSidebarData("position-roundtable"); + const { groups, isLoading: moduleLoading } = useAgentModuleSidebarData("position-roundtable"); + // 内置收口智能体可能不会出现在当前账号的“全量智能体”可见列表中, + // 但它们的详情接口仍是本页面真正使用的配置源。固定按 id 读取,避免 + // 受发布范围、标签分组或列表权限影响而偶发显示空白。 + const { agent: summaryAgent, isLoading: summaryAgentLoading } = useAgent( + scope === "closure" ? SUMMARY_AGENT_ID : null, + ); + const { agent: actionPlanAgent, isLoading: actionPlanAgentLoading } = useAgent( + scope === "closure" ? ACTION_PLAN_AGENT_ID : null, + ); const [query, setQuery] = useState(""); const [collapsed, setCollapsed] = useState>(() => new Set()); + const sourceGroups = useMemo(() => { + if (scope !== "closure") return groups; + const closureAgents = [summaryAgent, actionPlanAgent].filter( + (agent): agent is Agent => Boolean(agent), + ); + return closureAgents.length > 0 + ? [{ key: "position-roundtable-closure", label: "收口智能体", agents: closureAgents }] + : []; + }, [actionPlanAgent, groups, scope, summaryAgent]); + const isLoading = + scope === "closure" + ? summaryAgentLoading || actionPlanAgentLoading + : moduleLoading; + const visibleGroups = useMemo(() => { const keyword = query.trim().toLowerCase(); - return groups + return sourceGroups .map((group) => ({ ...group, agents: group.agents.filter((agent) => { @@ -4166,7 +4850,7 @@ function PositionAgentList({ }), })) .filter((group) => group.agents.length > 0); - }, [assignedAgentIds, groups, query]); + }, [assignedAgentIds, query, sourceGroups]); const toggleGroup = (key: string) => { setCollapsed((current) => { @@ -4182,9 +4866,13 @@ function PositionAgentList({
-

智能体类型

+

+ {scope === "closure" ? "收口智能体" : "智能体类型"} +

- 选择智能体后查看配置 + {scope === "closure" + ? "配置方案总结与行动规划两个收口阶段" + : "选择智能体后查看配置"}

@@ -4209,7 +4897,13 @@ function PositionAgentList({
) : visibleGroups.length === 0 ? (
-

{hasSelectedChain ? "当前岗位暂未分配智能体" : "请先选择业务链条"}

+

+ {scope === "closure" + ? "未找到方案总结或行动规划智能体配置" + : hasSelectedChain + ? "当前岗位暂未分配智能体" + : "请先选择业务链条"} +

{hasSelectedChain && onConfigureChain && ( | null | undefined): return recordString(artifact, "mime_type") || recordString(artifact, "mimeType") || null; } +function artifactPersistedContent( + artifact: Record | null | undefined, +): string | null { + const content = recordString(artifact, "content"); + return content.trim() ? content : null; +} + function isArtifactErrorContent(content: string | null | undefined): boolean { const trimmed = content?.trim(); if (!trimmed || !trimmed.startsWith("{") || !trimmed.endsWith("}")) { @@ -4368,12 +5074,13 @@ function isArtifactErrorContent(content: string | null | undefined): boolean { } function deliverableFallbackContent(item: ResultItem | null): string { + const persistedArtifact = item?.artifactContent?.trim(); const nodeAnswer = item?.node?.latestAnswer?.trim(); const summary = item?.summary?.trim(); if (item?.kind === "artifact") { - return nodeAnswer || summary || "暂无产物正文"; + return persistedArtifact || nodeAnswer || summary || "暂无产物正文"; } - return summary || nodeAnswer || "暂无产物正文"; + return persistedArtifact || summary || nodeAnswer || "暂无产物正文"; } function buildIntentResultItem( @@ -4448,6 +5155,7 @@ function buildNodeResultItem( artifactPath: artifactPath(artifact), artifactMimeType: artifactMimeType(artifact), artifactThreadId: node.threadId, + artifactContent: artifactPersistedContent(artifact), }; } @@ -4490,6 +5198,7 @@ function buildBuiltinResultItem( snapshotText(snapshot, "thread_id") || (isSummary ? session?.summaryThreadId : session?.actionPlanThreadId) || null, + artifactContent: artifactPersistedContent(artifact), }; } @@ -4502,6 +5211,7 @@ function AnalysisResults({ onSessionUpdate, onPreviewArtifact, onOpenBuiltinChat, + readOnly = false, }: { intentObjective?: string; intentCompletedAt: string; @@ -4511,6 +5221,7 @@ function AnalysisResults({ onSessionUpdate: (session: PositionRoundtableSession) => void; onPreviewArtifact: (artifact: PositionArtifactPreview) => void; onOpenBuiltinChat?: (kind: ClosureStageKind) => void; + readOnly?: boolean; }) { const [positionFilter, setPositionFilter] = useState("all"); const [nodeFilter, setNodeFilter] = useState("all"); @@ -4621,6 +5332,7 @@ function AnalysisResults({ artifactPath: path, artifactMimeType: mimeType, artifactThreadId: node.threadId, + artifactContent: artifactPersistedContent(artifact), }); }); } @@ -4644,6 +5356,7 @@ function AnalysisResults({ artifactPath: artifact ? recordString(artifact, "path") : undefined, artifactMimeType: artifact ? recordString(artifact, "mime_type") || null : null, artifactThreadId: snapshotText(summarySnapshot, "thread_id") || session?.summaryThreadId || null, + artifactContent: artifactPersistedContent(artifact), }); } const actionPlanSnapshot = session?.actionPlanSnapshot; @@ -4667,6 +5380,7 @@ function AnalysisResults({ artifactPath: artifact ? recordString(artifact, "path") : undefined, artifactMimeType: artifact ? recordString(artifact, "mime_type") || null : null, artifactThreadId: snapshotText(actionPlanSnapshot, "thread_id") || session?.actionPlanThreadId || null, + artifactContent: artifactPersistedContent(artifact), }); if (subtasksArtifact) { const path = recordString(subtasksArtifact, "path"); @@ -4683,6 +5397,7 @@ function AnalysisResults({ artifactPath: path || undefined, artifactMimeType: recordString(subtasksArtifact, "mime_type") || "application/json", artifactThreadId: snapshotText(actionPlanSnapshot, "thread_id") || session?.actionPlanThreadId || null, + artifactContent: artifactPersistedContent(subtasksArtifact), }); } } @@ -4732,12 +5447,16 @@ function AnalysisResults({ ); const openRejectDialog = useCallback((node: PositionRoundtableNode) => { + if (readOnly) { + toast.info("当前为纯前端演示数据,产物仅供查看"); + return; + } // Close the high-z-index product dialog first, then open the rejection // dialog on the next render. This prevents an invisible, unclickable // dialog from being rendered under the product detail modal. setDetailItem(null); setPendingRejectNode(node); - }, []); + }, [readOnly]); useEffect(() => { if (!pendingRejectNode || detailItem || rejectSubmitting) return; @@ -4963,6 +5682,7 @@ function AnalysisResults({ { if (!open) setDetailItem(null); }} @@ -5050,7 +5770,9 @@ function DeliverableDetailDialog({ const artifactContent = loadedArtifactContent.trim() && !loadedArtifactIsError ? loadedArtifactContent : null; const displayedContent = artifactContent || deliverableFallbackContent(item); - const artifactLoadFailed = artifactContentQuery.isError || loadedArtifactIsError; + const artifactLoadFailed = + (artifactContentQuery.isError || loadedArtifactIsError) && + !item?.artifactContent?.trim(); const builtinKind = showBuiltinAsk && onAskBuiltin && (item?.kind === "summary" || item?.kind === "action_plan") ? item.kind diff --git a/frontend-web/src/roundtable-planning/components/Step1Panel.tsx b/frontend-web/src/roundtable-planning/components/Step1Panel.tsx index c0da7f87..64db8d7d 100644 --- a/frontend-web/src/roundtable-planning/components/Step1Panel.tsx +++ b/frontend-web/src/roundtable-planning/components/Step1Panel.tsx @@ -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(null); + const step1ContentRef = useRef(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(""); + useEffect(() => { + setInputText(initialInputText); + }, [inputDraftKey, initialInputText]); + const handleInputTextChange = useCallback( + (value: string) => { + setInputText(value); + onInputTextChange?.(value); + }, + [onInputTextChange], + ); // 待发送的本地附件(点发送时连同文本一起上传)。 const [attachedFiles, setAttachedFiles] = useState([]); const fileInputRef = useRef(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) {
- {interactiveMessages.map((msg) => ( - - ))} +
+ {interactiveMessages.map((msg) => ( + + ))} +
{!step1AtBottom && }
@@ -257,7 +279,7 @@ export function Step1Panel(props: Step1PanelProps) { {inputArea ? ( inputArea({ value: inputText, - onValueChange: setInputText, + onValueChange: handleInputTextChange, attachedFiles, onAddFiles: addAttachedFiles, onRemoveFile: removeAttachedFile, diff --git a/frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts b/frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts index 2679036a..dbea17c5 100644 --- a/frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts +++ b/frontend-web/src/roundtable-planning/hooks/useAutoScroll.ts @@ -16,6 +16,12 @@ export function useAutoScroll( 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; } = {}, ): { /** True when the user is at the bottom — drives the button's visibility. */ @@ -24,6 +30,7 @@ export function useAutoScroll( 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( // - 任何其它 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(null); // 自动跟随 effect 和 onScroll 共享的"上次观察到的 scrollTop"。 // 用来在 effect 里区分两种 distance 暴涨的情况: // - currentScrollTop < lastObservedScrollTopRef → 用户主动向上滚 → 退出跟随 @@ -52,9 +62,13 @@ export function useAutoScroll( 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( } return; } + if (programmaticTarget !== null) { + programmaticScrollTargetRef.current = null; + } // 用户主动滚动:**任何向上滚的 scrollTop 减少都立刻退出自动跟随**, // 不等位置算到底部 32px 之外才翻。这是按钮"滚动一点点就显示"的核心。 @@ -115,19 +132,56 @@ export function useAutoScroll( 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); diff --git a/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts b/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts index 689f3f03..e019d21a 100644 --- a/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts +++ b/frontend-web/src/roundtable-planning/hooks/useStep1Intent.ts @@ -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]); diff --git a/frontend-web/src/strategy-components/Header.tsx b/frontend-web/src/strategy-components/Header.tsx index cd102e60..b3095393 100644 --- a/frontend-web/src/strategy-components/Header.tsx +++ b/frontend-web/src/strategy-components/Header.tsx @@ -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 = ( <> - - + + @@ -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="通知" > @@ -183,7 +186,7 @@ const Header = ({ collapsed }: HeaderProps) => {
@@ -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]", )} >
{ />
-

+

{displayName}

{positionName && ( -

+

{positionName}

)} @@ -226,7 +231,7 @@ const Header = ({ collapsed }: HeaderProps) => { @@ -318,7 +323,7 @@ const Header = ({ collapsed }: HeaderProps) => { onError={handleBrandLogoError} />
- + {desensitizeText("CM业务中心")}
@@ -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", )} > { />
{!collapsed && ( - + {desensitizeText("CM业务中心")} )} diff --git a/frontend-web/src/strategy-components/components/task-components/ActionAnalyzes.tsx b/frontend-web/src/strategy-components/components/task-components/ActionAnalyzes.tsx index 1678e3c3..72b19877 100644 --- a/frontend-web/src/strategy-components/components/task-components/ActionAnalyzes.tsx +++ b/frontend-web/src/strategy-components/components/task-components/ActionAnalyzes.tsx @@ -24,7 +24,7 @@ export const ActionAnalyzes: React.FC = ({ 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'; diff --git a/frontend-web/src/strategy-components/components/task-components/ActionColumn.tsx b/frontend-web/src/strategy-components/components/task-components/ActionColumn.tsx index d319a166..4d2d420d 100644 --- a/frontend-web/src/strategy-components/components/task-components/ActionColumn.tsx +++ b/frontend-web/src/strategy-components/components/task-components/ActionColumn.tsx @@ -28,7 +28,7 @@ export const ActionColumn: React.FC = ({ 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'; diff --git a/frontend-web/src/strategy-components/components/task-components/KanbanColumn.tsx b/frontend-web/src/strategy-components/components/task-components/KanbanColumn.tsx index eacf4464..25d49df9 100644 --- a/frontend-web/src/strategy-components/components/task-components/KanbanColumn.tsx +++ b/frontend-web/src/strategy-components/components/task-components/KanbanColumn.tsx @@ -28,7 +28,7 @@ export const KanbanColumn: React.FC = ({ }) => { 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) => { diff --git a/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx b/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx index ca361a8e..76e8d848 100644 --- a/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx +++ b/frontend-web/src/strategy-components/components/task-components/SentimentAnalyzes.tsx @@ -34,7 +34,7 @@ const SentimentAnalyzes: React.FC = ({ 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); diff --git a/frontend-web/src/strategy-components/components/task-components/TaskAnalyzes.tsx b/frontend-web/src/strategy-components/components/task-components/TaskAnalyzes.tsx index e5470632..5540b2e9 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskAnalyzes.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskAnalyzes.tsx @@ -34,7 +34,7 @@ const TaskAnalyzes: React.FC = ({ 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); diff --git a/frontend-web/src/strategy-components/components/task-components/TaskCard.tsx b/frontend-web/src/strategy-components/components/task-components/TaskCard.tsx index ad6d2f0e..3f2b2b00 100644 --- a/frontend-web/src/strategy-components/components/task-components/TaskCard.tsx +++ b/frontend-web/src/strategy-components/components/task-components/TaskCard.tsx @@ -55,7 +55,7 @@ export const TaskCard: React.FC = ({ 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; diff --git a/frontend-web/src/strategy-components/components/task-components/css/strategy-data.css b/frontend-web/src/strategy-components/components/task-components/css/strategy-data.css index 8687eb0c..a6a06e42 100644 --- a/frontend-web/src/strategy-components/components/task-components/css/strategy-data.css +++ b/frontend-web/src/strategy-components/components/task-components/css/strategy-data.css @@ -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 { diff --git a/frontend-web/src/strategy-components/lib/light-app.ts b/frontend-web/src/strategy-components/lib/light-app.ts index 493e0f98..e55463a0 100644 --- a/frontend-web/src/strategy-components/lib/light-app.ts +++ b/frontend-web/src/strategy-components/lib/light-app.ts @@ -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 = { 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"; } diff --git a/frontend-web/src/styles/globals.css b/frontend-web/src/styles/globals.css index 3f56ed9c..85531329 100644 --- a/frontend-web/src/styles/globals.css +++ b/frontend-web/src/styles/globals.css @@ -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); } } diff --git a/frontend-web/src/styles/tdesign-theme.css b/frontend-web/src/styles/tdesign-theme.css index fabb26c3..23e73c62 100644 --- a/frontend-web/src/styles/tdesign-theme.css +++ b/frontend-web/src/styles/tdesign-theme.css @@ -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; diff --git a/offline-backend-20260512/backend/CLAUDE.md b/offline-backend-20260512/backend/CLAUDE.md index a1825b30..bd87e867 100644 --- a/offline-backend-20260512/backend/CLAUDE.md +++ b/offline-backend-20260512/backend/CLAUDE.md @@ -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: diff --git a/offline-backend-20260512/backend/Dockerfile b/offline-backend-20260512/backend/Dockerfile index 94cfbe5f..06b45d29 100644 --- a/offline-backend-20260512/backend/Dockerfile +++ b/offline-backend-20260512/backend/Dockerfile @@ -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 diff --git a/offline-backend-20260512/backend/README.md b/offline-backend-20260512/backend/README.md index de6552e3..15c11d38 100644 --- a/offline-backend-20260512/backend/README.md +++ b/offline-backend-20260512/backend/README.md @@ -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. diff --git a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py index f2322383..fee1a881 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py +++ b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py @@ -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): diff --git a/offline-backend-20260512/backend/app/gateway/routers/system_settings.py b/offline-backend-20260512/backend/app/gateway/routers/system_settings.py index 98589761..a9a5fe8c 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/system_settings.py +++ b/offline-backend-20260512/backend/app/gateway/routers/system_settings.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py index a84712cc..e4512a49 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py @@ -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", diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260723_05_position_roundtable_conversations.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260723_05_position_roundtable_conversations.py new file mode 100644 index 00000000..88609009 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260723_05_position_roundtable_conversations.py @@ -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) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py index 64aeb63c..fee04be5 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py @@ -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") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py index 05c7215b..11dfd969 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py @@ -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) diff --git a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py index 1732dda5..797295c5 100644 --- a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py +++ b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py @@ -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 = {