代码提交

This commit is contained in:
董宏凯 committed 2026-07-24 10:33:28 +08:00
1 parent f49def09ea
commit 45df500a9c
55 files changed
+3390 -461

No files matched your search

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