代码提交

This commit is contained in:
董宏凯 committed 2026-08-10 14:26:49 +08:00
1 parent 49af497085
commit 3eb307c5c5
155 files changed
+4706 -100

No files matched your search

+19 -6
View File
@@ -24,11 +24,23 @@
| 参数 | 是否必填 | 作用 |
| --- | --- | --- |
| `taskId` | 否 | 外部任务 ID。缺失时使用本地 mock 任务,便于演示和开发。 |
| `taskId` | 否 | 外部任务 ID。传入后它是岗位会商的共享存储作用域;缺失时保持原有的个人会话逻辑。mock 仅提供任务卡片的显示兜底,绝不替换路由传入的 ID。 |
| `sessionId` | 否 | 直接恢复指定岗位会商会话;缺失时页面会按 `taskId` 查找最近一条未归档会话。 |
| `positionId` | 否 | 初始打开的岗位,同时同步顶部岗位选择器。无效值回退到 `intelligence`。 |
| `new=1` | 否 | 强制不恢复历史会话,以新会话开始。 |
传入 `taskId` 时,同一个任务下可以有多条会商会话,所有用户共享同一份历史、节点快照与 LangGraph 线程;当前 `user_id` 仅保留创建审计,不参与这些任务会话的读写过滤。页面恢复 `sessionId` 时会核验其 `externalTaskId` 与路由 `taskId` 一致,不一致会丢弃该会话并按当前任务重新发现历史。未传 `taskId` 时,会继续只读取当前用户的原有个人会话。
### 外部系统按 taskId 对接
1. 进入页面时将业务侧任务的稳定主键原样放入 URL。工作区入口使用 `/#/page/workspace/roundtable/position?taskId=<taskId>`;策略页入口也兼容 `/#/page/strategy/qa/position-roundtable?taskId=<taskId>`。不要使用任务标题、序号或每次请求都变化的临时 ID。
2. 前端先调用 `GET /api/position-roundtable/sessions?external_task_id=<taskId>`;返回的是该任务下所有用户共享的会商记录,按最近更新时间排序。选中一条后再调用 `GET /sessions/{sessionId}` 恢复完整节点和聊天快照。
3. 没有可恢复记录时,调用 `POST /sessions`,至少传入 `{ external_task_id: taskId, task_snapshot: { ...任务展示字段 } }`。服务端将 `task_scoped` 设为 `true`,并固定快照中的 `id` / `externalTaskId` 为传入的 taskId。
4. 后续所有意图、链条、节点、收口智能体和历史删除 API 只使用返回的 `sessionId`;不要在每次消息请求中重新创建会话。一个 taskId 可以有多条 sessionId。
5. 不传 `taskId`(同时省略 `external_task_id`)就是兼容模式:后端只返回和操作当前用户自己的个人会话,绝不会把历史记录升级为共享。响应中的 `task_scoped` 可用来区分两种记录。
服务端从请求中的非空 `external_task_id` 判断是否开启共享,调用方无需也不应自行传 `task_scoped`。
当前岗位 ID 定义在 `lib/positions.ts`:
| ID | 页面岗位 | 职责 |
@@ -132,8 +144,9 @@ PositionRoundtablePage
页面只维护一个 `sandboxArtifact`。`PositionNodeChat` 和 `PositionBuiltinAgentChat` 通过 `onPreviewArtifact` 上报预览目标;父页面统一渲染 `ArtifactFileDetail`。
**对接 / 重做页面 / 排障指南(接口约定、必守规则、自测与症状表):**
[`docs/沙箱打开关闭与步骤条.md`](./docs/沙箱打开关闭与步骤条.md)
**对接 / 重做页面 / 排障指南(打开关闭时机、步骤条、接口约定):**
[`../../docs/position-roundtable-sandbox/沙箱打开关闭与步骤条.md`](../../docs/position-roundtable-sandbox/沙箱打开关闭与步骤条.md)
(目录说明见 [`../../docs/position-roundtable-sandbox/README.md`](../../docs/position-roundtable-sandbox/README.md))
设计要点:
@@ -163,8 +176,8 @@ PositionRoundtablePage
| 方法 | 路径 | 用途 |
| --- | --- | --- |
| `GET` | `/sessions?external_task_id=` | 按任务查询会话,用于恢复最近会话和历史记录。 |
| `POST` | `/sessions` | 创建初始会话并保存任务快照。 |
| `GET` | `/sessions?external_task_id=<taskId>` | 传入时按任务作用域查询共享会话;不传参数时查询当前用户的个人历史。 |
| `POST` | `/sessions` | 创建会话并保存任务快照;传入非空 `external_task_id` 时服务端会将快照 ID 固定为该值并启用共享;缺失或空值时创建原有个人会话。 |
| `GET` | `/sessions/{id}` | 恢复会话、节点和冻结快照。 |
| `DELETE` | `/sessions/{id}` | 删除一条岗位会商历史及其节点;删除当前记录时前端会先屏蔽并发恢复请求。 |
| `PUT` | `/sessions/{id}/task` | 更新未冻结的任务快照。 |
@@ -178,7 +191,7 @@ PositionRoundtablePage
| `POST` | `/sessions/{id}/summary/*` | 准备、发送和完成方案总结。 |
| `POST` | `/sessions/{id}/action-plan/*` | 准备、发送和完成行动规划。 |
| `PUT` | `/sessions/{id}/{summary|action-plan}/conversation` | 保存两个收口智能体的消息与未发送草稿。 |
| `POST` | `/demo/seed` | 按用户和 `demoKey` 幂等初始化完整演示会话。 |
| `POST` | `/demo/seed` | 按 `demoKey` 幂等初始化完整演示会话。 |
后端实现在 `offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py`。
@@ -68,7 +68,10 @@ export interface PositionRoundtableNode {
*/
export interface PositionRoundtableSession {
id: string;
/** Present only when the workspace was opened with a route `taskId`. */
externalTaskId: string | null;
/** True only for sessions intentionally shared by an explicit route taskId. */
taskScoped: boolean;
taskSnapshot: Record<string, unknown>;
chainId: string | null;
chainSnapshot: Record<string, unknown> | null;
@@ -112,6 +115,7 @@ interface ServerNode {
interface ServerSession {
id: string;
external_task_id: string | null;
task_scoped?: boolean;
task_snapshot?: Record<string, unknown>;
chain_id: string | null;
chain_snapshot?: Record<string, unknown> | null;
@@ -270,6 +274,7 @@ function toSession(session: ServerSession): PositionRoundtableSession {
const result: PositionRoundtableSession = {
id: session.id,
externalTaskId: session.external_task_id,
taskScoped: session.task_scoped ?? false,
taskSnapshot: session.task_snapshot ?? {},
chainId: session.chain_id,
chainSnapshot: session.chain_snapshot ?? null,
@@ -31,16 +31,21 @@ export const MOCK_POSITION_TASKS: readonly PositionTask[] = [
export const DEFAULT_MOCK_POSITION_TASK_ID = MOCK_POSITION_TASKS[0].id;
export function getMockPositionTask(taskId?: string | null): PositionTask {
return (
const fixture =
MOCK_POSITION_TASKS.find((task) => task.id === taskId) ??
MOCK_POSITION_TASKS[0]
);
MOCK_POSITION_TASKS[0];
const scopedTaskId = taskId?.trim();
// The fixture supplies only display defaults. Preserve the caller's task
// identity so persistence, history and LangGraph thread metadata all remain
// bound to the route taskId.
return scopedTaskId ? { ...fixture, id: scopedTaskId } : fixture;
}
/** Normalizes a persisted Session task snapshot without exposing source internals. */
export function taskFromPositionSnapshot(
snapshot: Record<string, unknown> | null | undefined,
fallback: PositionTask,
taskScopeId?: string,
): PositionTask {
if (!snapshot || Object.keys(snapshot).length === 0) return fallback;
const value = (keys: string[], defaultValue: string) => {
@@ -50,7 +55,11 @@ export function taskFromPositionSnapshot(
return typeof found === "string" ? found : defaultValue;
};
return {
id: value(["id", "taskId", "externalTaskId"], fallback.id),
// Session snapshots are editable display data, while the route taskId is
// the stable shared-storage scope. Keeping them separate prevents a
// legacy/mock snapshot from moving a live external task into another
// task's history bucket.
id: taskScopeId?.trim() || value(["id", "taskId", "externalTaskId"], fallback.id),
title: value(["title", "taskName", "name"], fallback.title),
description: value(["description", "taskContent", "overview", "content"], fallback.description),
direction: value(["direction", "taskDirection", "category"], fallback.direction),
@@ -140,6 +140,7 @@ import {
getPositionSession,
isPositionSessionNotFoundError,
listPositionSessions,
PositionSessionNotFoundError,
preparePositionActionPlan,
preparePositionSummary,
rejectPositionNode,
@@ -182,6 +183,18 @@ type PositionArtifactPreview = {
previewKey?: string;
auto?: boolean;
};
function isSessionInTaskScope(
session: PositionRoundtableSession,
taskScopeId: string | null,
): boolean {
// The legacy entry has no taskId and remains user-owned. Explicit taskId
// deep links are the only shared mode and must never cross task scopes.
return taskScopeId === null
? !session.taskScoped
: session.taskScoped && session.externalTaskId === taskScopeId;
}
type ClosureStageKind = PositionBuiltinAgentKind;
type IntentEditDraft = {
objective: string;
@@ -829,18 +842,21 @@ export default function PositionRoundtablePage({
const [positionId, setPositionId] = useState<PositionId>(() => positionIdFromUrl);
const demoModeEnabled = isPositionRoundtableDemoEnabled();
const demoTask = useMemo(() => getPositionDemoTask(), []);
const taskIdFromUrl =
// A route taskId opts into shared task collaboration. Without it this page
// keeps the original personal-session behavior and uses mock data only for
// display defaults.
const taskScopeId =
standaloneDemoMode
? demoTask.id
: searchParams.get("taskId") ??
(demoModeEnabled ? demoTask.id : DEFAULT_MOCK_POSITION_TASK_ID);
: searchParams.get("taskId")?.trim() || null;
const displayTaskId = taskScopeId ?? (demoModeEnabled ? demoTask.id : DEFAULT_MOCK_POSITION_TASK_ID);
const searchParamsString = searchParams.toString();
const fallbackTask = useMemo(
() =>
demoModeEnabled && taskIdFromUrl === demoTask.id
demoModeEnabled && displayTaskId === demoTask.id
? demoTask
: getMockPositionTask(taskIdFromUrl),
[demoModeEnabled, demoTask, taskIdFromUrl],
: getMockPositionTask(displayTaskId),
[demoModeEnabled, demoTask, displayTaskId],
);
const rawSessionIdFromUrl = searchParams.get("sessionId");
const sessionIdFromUrl =
@@ -1033,8 +1049,8 @@ export default function PositionRoundtablePage({
[positionSession?.intentSnapshot],
);
const task = useMemo(
() => taskFromPositionSnapshot(positionSession?.taskSnapshot, fallbackTask),
[fallbackTask, positionSession?.taskSnapshot],
() => taskFromPositionSnapshot(positionSession?.taskSnapshot, fallbackTask, taskScopeId ?? undefined),
[fallbackTask, positionSession?.taskSnapshot, taskScopeId],
);
const intentTaskContext = useMemo(
() => ({
@@ -1144,9 +1160,10 @@ export default function PositionRoundtablePage({
positionSession?.status === "archived";
const syncSessionUrl = useCallback(
(sessionId: string, nextTaskId: string) => {
(sessionId: string, nextTaskId?: string | null) => {
const next = new URLSearchParams(searchParamsString);
next.set("taskId", nextTaskId);
if (nextTaskId) next.set("taskId", nextTaskId);
else next.delete("taskId");
next.set("sessionId", sessionId);
next.delete("new");
setSearchParams(next, { replace: true });
@@ -1162,20 +1179,20 @@ export default function PositionRoundtablePage({
const localSession = getLocalPositionDemoSession();
positionSessionRef.current = localSession;
setPositionSession(localSession);
syncSessionUrl(localSession.id, localSession.externalTaskId ?? taskSnapshot.id);
syncSessionUrl(localSession.id, taskScopeId);
return localSession;
}
if (sessionCreationRef.current) return sessionCreationRef.current;
const creation = createPositionSession({
externalTaskId: taskSnapshot.id || undefined,
taskSnapshot: { ...taskSnapshot },
externalTaskId: taskScopeId ?? undefined,
taskSnapshot: taskScopeId ? { ...taskSnapshot, id: taskScopeId } : { ...taskSnapshot },
})
.then((created) => {
// Update the ref synchronously. Several effects can observe a new
// intent thread in the same render cycle; all must share this row.
positionSessionRef.current = created;
setPositionSession(created);
syncSessionUrl(created.id, taskSnapshot.id);
syncSessionUrl(created.id, taskScopeId);
return created;
})
.finally(() => {
@@ -1186,7 +1203,7 @@ export default function PositionRoundtablePage({
sessionCreationRef.current = creation;
return creation;
},
[standaloneDemoMode, syncSessionUrl],
[standaloneDemoMode, syncSessionUrl, taskScopeId],
);
const openTaskEditor = useCallback(() => {
@@ -1207,6 +1224,10 @@ export default function PositionRoundtablePage({
}
const nextTask: PositionTask = {
...task,
// Task identity comes exclusively from the route. Task-card editing can
// change display fields but must never move this session to another
// shared task bucket.
id: taskScopeId ?? task.id,
title,
priority: taskDraft.priority?.trim() || task.priority || "紧急",
description: taskDraft.description.trim(),
@@ -1225,7 +1246,7 @@ export default function PositionRoundtablePage({
positionSessionRef.current = merged;
return merged;
});
syncSessionUrl(saved.id, nextTask.id);
syncSessionUrl(saved.id, taskScopeId);
setTaskEditorOpen(false);
toast.success("任务信息已更新");
} catch (error) {
@@ -1233,7 +1254,7 @@ export default function PositionRoundtablePage({
} finally {
setTaskSaving(false);
}
}, [ensureEditableSession, isArchivedSession, syncSessionUrl, task, taskDraft]);
}, [ensureEditableSession, isArchivedSession, syncSessionUrl, task, taskDraft, taskScopeId]);
const openIntentEditor = useCallback(() => {
if (isArchivedSession) return;
@@ -1524,7 +1545,7 @@ export default function PositionRoundtablePage({
let cancelled = false;
void getPositionSession(sessionIdFromUrl)
.then((session) => {
if (!cancelled) {
if (!cancelled && isSessionInTaskScope(session, taskScopeId)) {
positionSessionRef.current = session;
setPositionSession(session);
}
@@ -1533,7 +1554,7 @@ export default function PositionRoundtablePage({
return () => {
cancelled = true;
};
}, [positionSession, sessionIdFromUrl]);
}, [positionSession, sessionIdFromUrl, taskScopeId]);
const handleNodeUpdate = useCallback((updatedNode: PositionRoundtableNode) => {
setPositionSession((current) => {
if (!current?.nodes) return current;
@@ -1812,15 +1833,17 @@ export default function PositionRoundtablePage({
);
const openPositionConfig = useCallback(
(chainId: string) => {
const taskQuery = `taskId=${encodeURIComponent(task.id)}${forceNewSession ? "&new=1" : ""}`;
const returnTo = sessionIdFromUrl
? `/page/strategy/qa/position-roundtable?${taskQuery}&sessionId=${encodeURIComponent(sessionIdFromUrl)}`
: `/page/strategy/qa/position-roundtable?${taskQuery}`;
const returnParams = new URLSearchParams();
if (taskScopeId) returnParams.set("taskId", taskScopeId);
if (forceNewSession) returnParams.set("new", "1");
if (sessionIdFromUrl) returnParams.set("sessionId", sessionIdFromUrl);
const returnQuery = returnParams.toString();
const returnTo = `/page/strategy/qa/position-roundtable${returnQuery ? `?${returnQuery}` : ""}`;
navigate(
`/page/workspace/roundtable/chains?chainId=${encodeURIComponent(chainId)}&mode=position&returnTo=${encodeURIComponent(returnTo)}`,
);
},
[forceNewSession, navigate, sessionIdFromUrl, task.id],
[forceNewSession, navigate, sessionIdFromUrl, taskScopeId],
);
// The mock task source mirrors the future external-task contract: opening a
@@ -1843,7 +1866,7 @@ export default function PositionRoundtablePage({
const useDemoFixture = demoModeEnabled && task.id === demoTask.id;
const discovery = useDemoFixture
? initializePositionRoundtableDemo().then((session) => [session])
: listPositionSessions(task.id);
: listPositionSessions(taskScopeId ?? undefined);
void discovery
.then((sessions) => {
if (cancelled) return;
@@ -1861,7 +1884,8 @@ export default function PositionRoundtablePage({
setSessionDiscoveryDone(true);
}
const next = new URLSearchParams(searchParamsString);
next.set("taskId", task.id);
if (taskScopeId) next.set("taskId", taskScopeId);
else next.delete("taskId");
next.set("sessionId", latest.id);
setSearchParams(next, { replace: true });
})
@@ -1881,7 +1905,7 @@ export default function PositionRoundtablePage({
searchParamsString,
sessionIdFromUrl,
setSearchParams,
task.id,
taskScopeId,
]);
// Create the database row as soon as discovery confirms this is a new task
@@ -1928,6 +1952,11 @@ export default function PositionRoundtablePage({
: getPositionSession(sessionIdFromUrl);
void request
.then((session) => {
if (!isSessionInTaskScope(session, taskScopeId)) {
// A shared session id can be pasted with the wrong taskId. Treat it
// as stale navigation instead of allowing it to cross task scopes.
throw new PositionSessionNotFoundError(sessionIdFromUrl);
}
if (
!cancelled &&
!ignoredSessionLoadIdsRef.current.has(sessionIdFromUrl)
@@ -1966,7 +1995,7 @@ export default function PositionRoundtablePage({
return () => {
cancelled = true;
};
}, [searchParamsString, sessionIdFromUrl, setSearchParams]);
}, [searchParamsString, sessionIdFromUrl, setSearchParams, taskScopeId]);
const agentIdForPanel = activePosition.isIntentPosition ? INTENT_AGENT_ID : selectedAgentId;
const { agent: selectedAgent } = useAgent(agentIdForPanel, {
@@ -2061,7 +2090,8 @@ export default function PositionRoundtablePage({
setRightPanelOpen(true);
}
const next = new URLSearchParams(searchParams);
next.set("taskId", task.id);
if (taskScopeId) next.set("taskId", taskScopeId);
else next.delete("taskId");
next.set("sessionId", session.id);
next.delete("new");
setSearchParams(next, { replace: true });
@@ -2083,6 +2113,7 @@ export default function PositionRoundtablePage({
setSearchParams,
startingChain,
task,
taskScopeId,
intentPositionId,
positionId,
]);
@@ -2124,12 +2155,13 @@ export default function PositionRoundtablePage({
setBuiltinAutoStartRequestKey(0);
setIntentCompletedAt("");
const next = new URLSearchParams(searchParams);
next.set("taskId", task.id);
if (taskScopeId) next.set("taskId", taskScopeId);
else next.delete("taskId");
next.set("positionId", intentPositionId);
next.delete("sessionId");
next.set("new", "1");
setSearchParams(next);
}, [intent, intentPositionId, queueIntentConversationSave, searchParams, setSearchParams, task.id]);
}, [intent, intentPositionId, queueIntentConversationSave, searchParams, setSearchParams, taskScopeId]);
const loadHistorySessions = useCallback(() => {
if (isLocalPositionDemoSession(positionSessionRef.current?.id)) {
@@ -2138,7 +2170,7 @@ export default function PositionRoundtablePage({
return;
}
setHistoryLoading(true);
void listPositionSessions(task.id)
void listPositionSessions(taskScopeId ?? undefined)
.then((sessions) => {
setHistorySessions(
[...sessions].sort((left, right) => {
@@ -2154,7 +2186,7 @@ export default function PositionRoundtablePage({
.finally(() => {
setHistoryLoading(false);
});
}, [task.id]);
}, [taskScopeId]);
const handleHistoryMenuOpenChange = useCallback(
(open: boolean) => {
@@ -2198,7 +2230,8 @@ export default function PositionRoundtablePage({
restoringIntentSessionRef.current = null;
persistedIntentRef.current = null;
const next = new URLSearchParams(searchParams);
next.set("taskId", task.id);
if (taskScopeId) next.set("taskId", taskScopeId);
else next.delete("taskId");
next.set("sessionId", sessionId);
next.delete("new");
setSearchParams(next);
@@ -2209,7 +2242,7 @@ export default function PositionRoundtablePage({
searchParams,
sessionIdFromUrl,
setSearchParams,
task.id,
taskScopeId,
],
);
@@ -2269,7 +2302,8 @@ export default function PositionRoundtablePage({
setSelectedAgentId(null);
setHistoryMenuOpen(false);
const next = new URLSearchParams(searchParams);
next.set("taskId", task.id);
if (taskScopeId) next.set("taskId", taskScopeId);
else next.delete("taskId");
if (nextSession) {
// Prefer the most recently active remaining conversation so the
// user stays in history instead of being dropped onto a blank page.
@@ -2299,7 +2333,7 @@ export default function PositionRoundtablePage({
sessionIdFromUrl,
setSearchParams,
standaloneDemoMode,
task.id,
taskScopeId,
],
);