-
交付产物列表
-
按岗位节点与生成时间展示
+ <>
+
+
+
+
交付产物列表
+
按岗位节点与生成时间展示
+
+
+ {visibleItems.length}/{resultItems.length} 项结果
+
-
- {visibleItems.length}/{resultItems.length} 项结果
-
-
-
- setPositionFilter(String(value))} />
- setNodeFilter(String(value))} />
- setKindFilter(String(value) as "all" | ResultKind)}
- />
-
- {visibleItems.length === 0 ? (
-
- 当前筛选条件下没有结果
+
+ setPositionFilter(String(value))} />
+ setNodeFilter(String(value))} />
+ setKindFilter(String(value) as "all" | ResultKind)}
+ />
- ) : (
- visibleItems.map((item) => {
- const displayedTime = item.updatedAt
- ? new Intl.DateTimeFormat("zh-CN", {
- month: "2-digit",
- day: "2-digit",
- hour: "2-digit",
- minute: "2-digit",
- hour12: false,
- }).format(new Date(item.updatedAt))
- : intentCompletedAt || "刚刚";
- const stale = item.node?.status === "stale";
- const status = item.status ? nodeStatusMeta(item.status) : null;
- const StatusIcon = status?.Icon;
- const contents = (
- <>
-
-
- {item.kind === "artifact" ? : }
-
-
-
-
{desensitize(item.agentName)}
- {status && StatusIcon && (
-
-
- {status.label}
-
- )}
- {stale && (
-
- 上游已更新
-
- )}
-
-
- {desensitize(item.productName)}
-
-
- {desensitize(item.summary)}
-
-
+ {nodes.length > 0 && (
+
+
+
+
+ 内置收口智能体
+
+
+ 全部岗位交付后生成方案总结,再生成行动规划
+
-
- >
- );
- return item.node ? (
-
- ) : (
-
- {contents}
-
- );
- })
+
+ {nodes.filter((node) => node.status === "done").length}/{nodes.length} 已交付
+
+
+
+ }
+ onClick={() => void handleGenerateSummary()}
+ >
+ {summaryDone ? "重新生成方案总结" : summaryStale ? "重新生成失效总结" : "生成方案总结"}
+
+ }
+ onClick={() => void handleGenerateActionPlan()}
+ >
+ {actionPlanDone ? "重新生成行动规划" : actionPlanStale ? "重新生成失效规划" : "生成行动规划"}
+
+
+
+ )}
+ {visibleItems.length === 0 ? (
+
+ 当前筛选条件下没有结果
+
+ ) : (
+ visibleItems.map((item) => {
+ const displayedTime = item.updatedAt
+ ? new Intl.DateTimeFormat("zh-CN", {
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ }).format(new Date(item.updatedAt))
+ : intentCompletedAt || "刚刚";
+ const stale = item.node?.status === "stale";
+ const invalidated = Boolean(item.node?.invalidatedBy);
+ const status = item.status ? nodeStatusMeta(item.status) : null;
+ const StatusIcon = status?.Icon;
+ const contents = (
+ <>
+
+
+ {item.kind === "artifact" ? : }
+
+
+
+
{desensitize(item.agentName)}
+ {status && StatusIcon && (
+
+
+ {status.label}
+
+ )}
+ {(stale || invalidated) && (
+
+ 上游已更新
+
+ )}
+
+
+ {desensitize(item.productName)}
+
+
+ {desensitize(item.summary)}
+
+
+
+
+ >
+ );
+ return item.node ? (
+
+ ) : (
+
+ );
+ })
+ )}
+
+
+
{
+ if (!open) setDetailItem(null);
+ }}
+ onReject={(node) => openRejectDialog(node)}
+ onPreviewArtifact={(item) => {
+ if (!item.node?.threadId || !item.artifactPath) {
+ toast.info("该文件产物暂无可预览路径");
+ return;
+ }
+ setPreviewArtifact({
+ threadId: item.node.threadId,
+ path: item.artifactPath,
+ name: item.productName,
+ mimeType: item.artifactMimeType ?? null,
+ });
+ }}
+ />
+
+ {
+ if (!open && !rejectSubmitting) {
+ setRejectNode(null);
+ setRejectTitle("");
+ setRejectReason("");
+ }
+ }}
+ onTitleChange={setRejectTitle}
+ onReasonChange={setRejectReason}
+ onSubmit={() => void handleRejectSubmit()}
+ />
+
+ {previewArtifact && (
+ setPreviewArtifact(null)}
+ />
)}
-
+ >
+ );
+}
+
+function DeliverableDetailDialog({
+ item,
+ onOpenChange,
+ onReject,
+ onPreviewArtifact,
+}: {
+ item: ResultItem | null;
+ onOpenChange: (open: boolean) => void;
+ onReject: (node: PositionRoundtableNode) => void;
+ onPreviewArtifact: (item: ResultItem) => void;
+}) {
+ const status = item?.status ? nodeStatusMeta(item.status) : null;
+ const StatusIcon = status?.Icon;
+ const canReject = item?.node ? canRejectNodeDelivery(item.node) : false;
+ const rejectionTitle = item?.node ? nodeRejectionTitle(item.node) : "";
+ const rejectionReason = item?.node ? nodeRejectionReason(item.node) : "";
+ const displayedTime = item?.updatedAt
+ ? new Intl.DateTimeFormat("zh-CN", {
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ }).format(new Date(item.updatedAt))
+ : "";
+
+ return (
+
+ );
+}
+
+function RejectDeliveryDialog({
+ node,
+ title,
+ reason,
+ submitting,
+ onOpenChange,
+ onTitleChange,
+ onReasonChange,
+ onSubmit,
+}: {
+ node: PositionRoundtableNode | null;
+ title: string;
+ reason: string;
+ submitting: boolean;
+ onOpenChange: (open: boolean) => void;
+ onTitleChange: (value: string) => void;
+ onReasonChange: (value: string) => void;
+ onSubmit: () => void;
+}) {
+ return (
+
);
}
diff --git a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py
index 808e608f..781b8f07 100644
--- a/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py
+++ b/offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py
@@ -9,6 +9,7 @@ messages and artifacts stay in the established LangGraph thread stores.
from __future__ import annotations
import asyncio
+from datetime import UTC, datetime
from pathlib import Path
from typing import Any
@@ -52,6 +53,9 @@ class NodeResponse(BaseModel):
artifact_manifest: list[dict[str, Any]] = Field(default_factory=list)
revision: int = 0
upstream_revision: dict[str, Any] | None = None
+ rejection_count: int = 0
+ last_rejection: dict[str, Any] | None = None
+ invalidated_by: str | None = None
created_at: str | None = None
updated_at: str | None = None
@@ -64,6 +68,8 @@ class SessionResponse(BaseModel):
chain_snapshot: dict[str, Any] | None = None
intent_thread_id: str | None = None
intent_snapshot: dict[str, Any] | None = None
+ summary_snapshot: dict[str, Any] | None = None
+ action_plan_snapshot: dict[str, Any] | None = None
status: str
created_at: str | None = None
updated_at: str | None = None
@@ -101,6 +107,11 @@ class CompleteTurnRequest(BaseModel):
upstream_revision: dict[str, Any] | None = None
+class RejectNodeRequest(BaseModel):
+ title: str = Field(min_length=1, max_length=120)
+ reason: str = Field(min_length=1, max_length=5_000)
+
+
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()
@@ -229,6 +240,175 @@ def _truncate_text(value: Any, limit: int) -> str:
return f"{text[:limit]}\n…(已按交接上下文长度截断)"
+def _text_from_keys(source: dict[str, Any], keys: list[str], fallback: str = "") -> str:
+ for key in keys:
+ value = source.get(key)
+ if isinstance(value, str) and value.strip():
+ return value.strip()
+ return fallback
+
+
+def _seat_meta(session: dict[str, Any], node: dict[str, Any]) -> tuple[str, str]:
+ chain = session.get("chain_snapshot") if isinstance(session.get("chain_snapshot"), dict) else {}
+ seats = chain.get("seats") if isinstance(chain.get("seats"), list) else []
+ seat = next(
+ (
+ item
+ for item in seats
+ if isinstance(item, dict) and str(item.get("agent_id") or "") == str(node.get("agent_id") or "")
+ ),
+ {},
+ )
+ agent_name = str(seat.get("name") or node.get("agent_id") or "未命名智能体")
+ position_id = str(node.get("position_id") or seat.get("position_id") or "未配置岗位")
+ return agent_name, position_id
+
+
+def _delivered_nodes(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
+ return [node for node in nodes if node.get("status") == "done"]
+
+
+def _all_nodes_delivered(nodes: list[dict[str, Any]]) -> bool:
+ return bool(nodes) and all(node.get("status") == "done" for node in nodes)
+
+
+def _artifact_lines(node: dict[str, Any]) -> list[str]:
+ artifacts = node.get("artifact_manifest")
+ if not isinstance(artifacts, list) or not artifacts:
+ return []
+ lines: list[str] = []
+ for artifact in artifacts[:8]:
+ if not isinstance(artifact, dict):
+ continue
+ name = artifact.get("name") or artifact.get("filename") or artifact.get("path") or "未命名产物"
+ path = artifact.get("path") or artifact.get("artifact_url") or ""
+ lines.append(f" - {name}{f'({path})' if path else ''}")
+ return lines
+
+
+def _build_summary_snapshot(session: dict[str, Any], nodes: list[dict[str, Any]]) -> dict[str, Any]:
+ now = datetime.now(UTC).isoformat()
+ task = session.get("task_snapshot") if isinstance(session.get("task_snapshot"), dict) else {}
+ intent = session.get("intent_snapshot") if isinstance(session.get("intent_snapshot"), dict) else {}
+ title = _text_from_keys(task, ["title", "taskName", "name"], "未命名任务")
+ direction = _text_from_keys(task, ["direction", "taskDirection", "category"], "未填写")
+ description = _text_from_keys(task, ["description", "taskContent", "overview", "content"], "暂无任务描述")
+ objective = _text_from_keys(intent, ["objective", "goal"], "暂无意图结论")
+ constraints = intent.get("constraints") if isinstance(intent.get("constraints"), list) else []
+
+ lines = [
+ "# 岗位会商方案总结报告",
+ "",
+ f"生成时间:{now}",
+ "",
+ "## 一、任务概况",
+ "",
+ f"- 任务名称:{title}",
+ f"- 任务方向:{direction}",
+ f"- 任务描述:{description}",
+ "",
+ "## 二、意图理解",
+ "",
+ f"- 核心目标:{objective}",
+ ]
+ if constraints:
+ lines.append("- 关键约束:")
+ lines.extend(f" - {item}" for item in constraints if isinstance(item, str) and item.strip())
+ lines.extend(["", "## 三、岗位交付汇总", ""])
+ for index, node in enumerate(_delivered_nodes(nodes), start=1):
+ agent_name, position_id = _seat_meta(session, node)
+ answer = _truncate_text(node.get("latest_answer"), 1_200) or "暂无文本回答"
+ lines.extend(
+ [
+ f"### {index}. {agent_name}",
+ "",
+ f"- 岗位:{position_id}",
+ f"- 节点版本:第 {node.get('revision', 0)} 版",
+ "",
+ answer,
+ "",
+ ]
+ )
+ artifacts = _artifact_lines(node)
+ if artifacts:
+ lines.extend(["产物文件:", *artifacts, ""])
+ lines.extend(
+ [
+ "## 四、综合判断",
+ "",
+ "本报告基于当前所有有效岗位交付产物自动汇总生成,可作为后续行动规划的输入材料。若任一前序岗位产物被驳回,本报告将自动标记为失效并需要重新生成。",
+ ]
+ )
+ return {
+ "kind": "summary",
+ "status": "done",
+ "title": "岗位会商方案总结报告",
+ "content": "\n".join(lines),
+ "generated_at": now,
+ "source_revisions": {node["node_key"]: node.get("revision", 0) for node in _delivered_nodes(nodes)},
+ }
+
+
+def _build_action_plan_snapshot(session: dict[str, Any], nodes: list[dict[str, Any]]) -> dict[str, Any]:
+ now = datetime.now(UTC).isoformat()
+ summary = session.get("summary_snapshot") if isinstance(session.get("summary_snapshot"), dict) else {}
+ delivered = _delivered_nodes(nodes)
+ tasks: list[dict[str, Any]] = []
+ for index, node in enumerate(delivered, start=1):
+ agent_name, position_id = _seat_meta(session, node)
+ tasks.append(
+ {
+ "id": f"subtask-{index:02d}",
+ "title": f"落实{agent_name}交付建议",
+ "owner_position": position_id,
+ "basis_node": node.get("node_key"),
+ "deliverable": f"{agent_name}整改/执行清单",
+ "acceptance": "补齐输入依据,形成可验证的阶段成果并回填岗位会商产物。",
+ }
+ )
+ lines = [
+ "# 岗位会商行动规划",
+ "",
+ f"生成时间:{now}",
+ "",
+ "## 一、规划依据",
+ "",
+ f"- 方案总结:{summary.get('title') or '岗位会商方案总结报告'}",
+ f"- 有效岗位产物数量:{len(delivered)}",
+ "",
+ "## 二、子任务拆解",
+ "",
+ ]
+ for task in tasks:
+ lines.extend(
+ [
+ f"### {task['id']} {task['title']}",
+ "",
+ f"- 建议责任岗位:{task['owner_position']}",
+ f"- 输入依据节点:{task['basis_node']}",
+ f"- 交付物:{task['deliverable']}",
+ f"- 验收标准:{task['acceptance']}",
+ "",
+ ]
+ )
+ lines.extend(
+ [
+ "## 三、执行提示",
+ "",
+ "若前序岗位产物被驳回,本行动规划会自动失效;请先完成被驳回节点及后续节点的重新交付,再重新生成行动规划。",
+ ]
+ )
+ return {
+ "kind": "action_plan",
+ "status": "done",
+ "title": "岗位会商行动规划",
+ "content": "\n".join(lines),
+ "generated_at": now,
+ "source_revisions": {node["node_key"]: node.get("revision", 0) for node in delivered},
+ "subtasks": tasks,
+ }
+
+
def _artifact_refs(artifacts: Any) -> list[dict[str, Any]]:
if not isinstance(artifacts, list):
return []
@@ -342,6 +522,8 @@ async def _handoff_context(request: Request, session_id: str, node_key: str) ->
raise HTTPException(status_code=404, detail="Position roundtable node not found")
if current["status"] == "locked":
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
+ if current.get("invalidated_by"):
+ raise HTTPException(status_code=409, detail="上游产物已被驳回或重做,当前节点需等待前序节点重新完成")
# Same-stage nodes are intentionally excluded. The business chain only
# hands off completed previous stages, in stage/seat order.
@@ -350,7 +532,7 @@ async def _handoff_context(request: Request, session_id: str, node_key: str) ->
node
for node in nodes
if node["stage_index"] < current["stage_index"]
- and node["status"] in {"done", "stale"}
+ and node["status"] == "done"
]
upstream_nodes: list[dict[str, Any]] = []
for index, node in enumerate(upstream_candidates):
@@ -509,6 +691,8 @@ async def bind_node_thread(
raise HTTPException(status_code=404, detail="Position roundtable node not found")
if current["status"] == "locked":
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
+ if current.get("invalidated_by"):
+ raise HTTPException(status_code=409, detail="上游产物已被驳回或重做,当前节点需等待前序节点重新完成")
if current.get("thread_id") and current["thread_id"] != body.thread_id:
raise HTTPException(status_code=409, detail="该节点已绑定另一条对话线程")
row = await store.update_node(
@@ -537,6 +721,8 @@ async def complete_node_turn(
raise HTTPException(status_code=404, detail="Position roundtable node not found")
if node["status"] == "locked":
raise HTTPException(status_code=409, detail="上游节点尚未完成,当前节点未解锁")
+ if node.get("invalidated_by"):
+ raise HTTPException(status_code=409, detail="上游产物已被驳回或重做,当前节点需等待前序节点重新完成")
row = await store.update_node(
session_id,
node_key,
@@ -545,6 +731,7 @@ async def complete_node_turn(
latest_answer=body.latest_answer,
artifact_manifest=body.artifact_manifest,
upstream_revision=body.upstream_revision,
+ invalidated_by=None,
increment_revision=True,
)
if row is None:
@@ -562,13 +749,19 @@ async def complete_node_turn(
for downstream in nodes:
if (
downstream["stage_index"] > row["stage_index"]
- and downstream["status"] in {"ready", "running", "done", "stale"}
+ and downstream["status"] in {"ready", "running", "done", "stale", "rejected", "error"}
):
+ has_material = (
+ downstream.get("revision", 0) > 0
+ or bool(downstream.get("latest_answer"))
+ or bool(downstream.get("artifact_manifest"))
+ )
await store.update_node(
session_id,
downstream["node_key"],
user_id,
- status="stale",
+ status="stale" if has_material else "locked",
+ invalidated_by=row["node_key"],
)
nodes = await store.list_nodes(session_id, user_id) or []
@@ -579,16 +772,93 @@ async def complete_node_turn(
if current_stage and all(item["status"] == "done" for item in current_stage) and later_stages:
next_stage = later_stages[0]
for next_node in nodes:
- if next_node["stage_index"] == next_stage and next_node["status"] == "locked":
+ if next_node["stage_index"] == next_stage and next_node["status"] in {"locked", "stale"}:
await store.update_node(
session_id,
next_node["node_key"],
user_id,
- status="ready",
+ status="ready" if next_node["status"] == "locked" else "stale",
+ invalidated_by=None,
)
return _project_node(row)
+@router.post("/sessions/{session_id}/nodes/{node_key}/reject", response_model=SessionDetailResponse)
+async def reject_node(
+ request: Request, session_id: str, node_key: str, body: RejectNodeRequest
+) -> SessionDetailResponse:
+ title = body.title.strip()
+ reason = body.reason.strip()
+ if not title or not reason:
+ raise HTTPException(status_code=400, detail="驳回标题和理由不能为空")
+ 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 current["status"] == "locked":
+ raise HTTPException(status_code=409, detail="当前节点尚未解锁,暂无可驳回的交付产物")
+ if current.get("revision", 0) <= 0 and not current.get("latest_answer") and not current.get("artifact_manifest"):
+ raise HTTPException(status_code=409, detail="当前节点暂无可驳回的交付产物")
+ rejected = await store.reject_node(
+ session_id,
+ node_key,
+ user_id,
+ title=title,
+ reason=reason,
+ rejected_by=user_id,
+ )
+ if rejected is None:
+ raise HTTPException(status_code=404, detail="Position roundtable node not found")
+ row, node_rows = rejected
+ return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
+
+
+@router.post("/sessions/{session_id}/summary/start", response_model=SessionDetailResponse)
+async def start_summary(request: Request, session_id: str) -> SessionDetailResponse:
+ store, user_id, session = await _require_writable_session(request, session_id)
+ nodes = await store.list_nodes(session_id, user_id)
+ if nodes is None:
+ raise HTTPException(status_code=404, detail="Position roundtable session not found")
+ if not _all_nodes_delivered(nodes):
+ raise HTTPException(status_code=409, detail="所有岗位产物完成后才能生成方案总结")
+ snapshot = _build_summary_snapshot(session, nodes)
+ row = await store.update_session(
+ session_id,
+ user_id,
+ summary_snapshot=snapshot,
+ action_plan_snapshot=None,
+ status="active",
+ )
+ if row is None:
+ raise HTTPException(status_code=404, detail="Position roundtable session not found")
+ node_rows = await store.list_nodes(session_id, user_id) or []
+ return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
+
+
+@router.post("/sessions/{session_id}/action-plan/start", response_model=SessionDetailResponse)
+async def start_action_plan(request: Request, session_id: str) -> SessionDetailResponse:
+ store, user_id, session = await _require_writable_session(request, session_id)
+ nodes = await store.list_nodes(session_id, user_id)
+ if nodes is None:
+ raise HTTPException(status_code=404, detail="Position roundtable session not found")
+ if not _all_nodes_delivered(nodes):
+ raise HTTPException(status_code=409, detail="所有岗位产物完成后才能生成行动规划")
+ summary = session.get("summary_snapshot")
+ if not isinstance(summary, dict) or summary.get("status") != "done":
+ raise HTTPException(status_code=409, detail="请先生成有效的方案总结")
+ snapshot = _build_action_plan_snapshot(session, nodes)
+ row = await store.update_session(
+ session_id,
+ user_id,
+ action_plan_snapshot=snapshot,
+ status="completed",
+ )
+ if row is None:
+ raise HTTPException(status_code=404, detail="Position roundtable session not found")
+ node_rows = await store.list_nodes(session_id, user_id) or []
+ return SessionDetailResponse(**row, nodes=[_project_node(node) for node in node_rows])
+
+
@router.get("/sessions/{session_id}/results", response_model=list[NodeResponse])
async def list_results(request: Request, session_id: str) -> list[NodeResponse]:
rows = await _get_store(request).list_results(session_id, _current_user_id(request))
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_02_position_roundtable_rejections.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_02_position_roundtable_rejections.py
new file mode 100644
index 00000000..eea6f562
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_02_position_roundtable_rejections.py
@@ -0,0 +1,54 @@
+"""Add rejection metadata to position roundtable nodes.
+
+Revision ID: 20260721_02
+Revises: 20260721_01
+Create Date: 2026-07-21
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy import inspect
+from sqlalchemy.dialects.mysql import LONGTEXT
+
+
+revision = "20260721_02"
+down_revision = "20260721_01"
+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:
+ table = "position_roundtable_nodes"
+ if not _has_table(table):
+ return
+ long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
+ if not _has_column(table, "rejection_count"):
+ op.add_column(
+ table,
+ sa.Column("rejection_count", sa.Integer(), nullable=False, server_default="0"),
+ )
+ if not _has_column(table, "last_rejection"):
+ op.add_column(table, sa.Column("last_rejection", long_text, nullable=True))
+ if not _has_column(table, "invalidated_by"):
+ op.add_column(table, sa.Column("invalidated_by", sa.String(256), nullable=True))
+
+
+def downgrade() -> None:
+ table = "position_roundtable_nodes"
+ if not _has_table(table):
+ return
+ for column in ("invalidated_by", "last_rejection", "rejection_count"):
+ if _has_column(table, column):
+ op.drop_column(table, column)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_03_position_roundtable_builtin_snapshots.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_03_position_roundtable_builtin_snapshots.py
new file mode 100644
index 00000000..22a1c27d
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_03_position_roundtable_builtin_snapshots.py
@@ -0,0 +1,49 @@
+"""Add builtin summary/action-plan snapshots to position roundtable sessions.
+
+Revision ID: 20260721_03
+Revises: 20260721_02
+Create Date: 2026-07-21
+"""
+
+from __future__ import annotations
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy import inspect
+from sqlalchemy.dialects.mysql import LONGTEXT
+
+
+revision = "20260721_03"
+down_revision = "20260721_02"
+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:
+ table = "position_roundtable_sessions"
+ if not _has_table(table):
+ return
+ long_text = LONGTEXT() if op.get_bind().dialect.name == "mysql" else sa.Text()
+ if not _has_column(table, "summary_snapshot"):
+ op.add_column(table, sa.Column("summary_snapshot", long_text, nullable=True))
+ if not _has_column(table, "action_plan_snapshot"):
+ op.add_column(table, sa.Column("action_plan_snapshot", long_text, nullable=True))
+
+
+def downgrade() -> None:
+ table = "position_roundtable_sessions"
+ if not _has_table(table):
+ return
+ for column in ("action_plan_snapshot", "summary_snapshot"):
+ if _has_column(table, column):
+ op.drop_column(table, column)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py
index 3ca7a2a3..83638ac3 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/model.py
@@ -31,6 +31,8 @@ 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)
+ summary_snapshot: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
+ action_plan_snapshot: 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)
@@ -66,12 +68,15 @@ class PositionRoundtableNodeRow(Base):
position_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
# The real messages remain in the standard LangGraph/checkpoint store.
thread_id: Mapped[str | None] = mapped_column(String(128), nullable=True, unique=True)
- # locked / ready / running / done / stale / error
+ # 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)
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")
+ last_rejection: Mapped[str | None] = mapped_column(PortableLongText, nullable=True)
+ invalidated_by: Mapped[str | None] = mapped_column(String(256), nullable=True)
created_at: Mapped[datetime] = mapped_column(
BeijingDateTime(), nullable=False, default=lambda: datetime.now(UTC)
)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py
index 368d535f..3043b796 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/position_roundtable/sql.py
@@ -37,6 +37,10 @@ def _time(value: Any) -> str | Any:
return value.isoformat() if isinstance(value, datetime) else value
+def _has_material(row: PositionRoundtableNodeRow) -> bool:
+ return row.revision > 0 or bool(row.latest_answer) or bool(_loads(row.artifact_manifest))
+
+
class PositionRoundtableRepository:
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
@@ -51,6 +55,8 @@ class PositionRoundtableRepository:
"chain_snapshot": _loads(row.chain_snapshot),
"intent_thread_id": row.intent_thread_id,
"intent_snapshot": _loads(row.intent_snapshot),
+ "summary_snapshot": _loads(row.summary_snapshot),
+ "action_plan_snapshot": _loads(row.action_plan_snapshot),
"status": row.status,
"created_at": _time(row.created_at),
"updated_at": _time(row.updated_at),
@@ -72,6 +78,9 @@ class PositionRoundtableRepository:
"artifact_manifest": _loads(row.artifact_manifest) or [],
"revision": row.revision,
"upstream_revision": _loads(row.upstream_revision),
+ "rejection_count": row.rejection_count or 0,
+ "last_rejection": _loads(row.last_rejection),
+ "invalidated_by": row.invalidated_by,
"created_at": _time(row.created_at),
"updated_at": _time(row.updated_at),
}
@@ -125,6 +134,8 @@ class PositionRoundtableRepository:
task_snapshot: dict[str, Any] | object = _UNSET,
intent_thread_id: str | None | object = _UNSET,
intent_snapshot: dict[str, Any] | None | object = _UNSET,
+ summary_snapshot: dict[str, Any] | None | object = _UNSET,
+ action_plan_snapshot: dict[str, Any] | None | object = _UNSET,
status: str | object = _UNSET,
) -> dict[str, Any] | None:
async with self._sf() as session:
@@ -137,6 +148,10 @@ class PositionRoundtableRepository:
row.intent_thread_id = intent_thread_id or None
if intent_snapshot is not _UNSET:
row.intent_snapshot = _dumps(intent_snapshot)
+ if summary_snapshot is not _UNSET:
+ row.summary_snapshot = _dumps(summary_snapshot)
+ if action_plan_snapshot is not _UNSET:
+ row.action_plan_snapshot = _dumps(action_plan_snapshot)
if status is not _UNSET:
row.status = str(status)
row.updated_at = datetime.now(UTC)
@@ -239,7 +254,10 @@ class PositionRoundtableRepository:
latest_answer: str | None | object = _UNSET,
artifact_manifest: list[dict[str, Any]] | object = _UNSET,
upstream_revision: dict[str, Any] | object = _UNSET,
+ last_rejection: dict[str, Any] | None | object = _UNSET,
+ invalidated_by: str | None | object = _UNSET,
increment_revision: bool = False,
+ increment_rejection: bool = False,
) -> dict[str, Any] | None:
async with self._sf() as session:
stmt = (
@@ -265,13 +283,100 @@ class PositionRoundtableRepository:
row.artifact_manifest = _dumps(artifact_manifest)
if upstream_revision is not _UNSET:
row.upstream_revision = _dumps(upstream_revision)
+ if last_rejection is not _UNSET:
+ row.last_rejection = _dumps(last_rejection)
+ if invalidated_by is not _UNSET:
+ row.invalidated_by = invalidated_by or None
if increment_revision:
row.revision += 1
+ if increment_rejection:
+ row.rejection_count += 1
row.updated_at = datetime.now(UTC)
await session.commit()
await session.refresh(row)
return self._node_dict(row)
+ async def reject_node(
+ self,
+ session_id: str,
+ node_key: str,
+ user_id: str,
+ *,
+ title: str,
+ reason: str,
+ rejected_by: str,
+ ) -> tuple[dict[str, Any], list[dict[str, Any]]] | None:
+ """Reject one delivered node and invalidate every later stage.
+
+ Historical answers and artifact manifests are deliberately preserved.
+ ``invalidated_by`` gates downstream rework until the rejected upstream
+ stage is completed again.
+ """
+ now = datetime.now(UTC)
+ async with self._sf() as session:
+ parent = await session.get(PositionRoundtableSessionRow, session_id)
+ if parent is None or parent.user_id != user_id:
+ return None
+ rows = (
+ await session.execute(
+ select(PositionRoundtableNodeRow)
+ .where(PositionRoundtableNodeRow.session_id == session_id)
+ .order_by(PositionRoundtableNodeRow.stage_index, PositionRoundtableNodeRow.seat_index)
+ )
+ ).scalars().all()
+ target = next((row for row in rows if row.node_key == node_key), None)
+ if target is None:
+ return None
+
+ rejection = {
+ "title": title,
+ "reason": reason,
+ "rejected_by": rejected_by,
+ "rejected_at": now.isoformat(),
+ }
+ summary_snapshot = _loads(parent.summary_snapshot)
+ if isinstance(summary_snapshot, dict) and summary_snapshot.get("status") == "done":
+ summary_snapshot = {
+ **summary_snapshot,
+ "status": "stale",
+ "invalidated_by": target.node_key,
+ "invalidated_at": now.isoformat(),
+ }
+ parent.summary_snapshot = _dumps(summary_snapshot)
+ action_plan_snapshot = _loads(parent.action_plan_snapshot)
+ if isinstance(action_plan_snapshot, dict) and action_plan_snapshot.get("status") == "done":
+ action_plan_snapshot = {
+ **action_plan_snapshot,
+ "status": "stale",
+ "invalidated_by": target.node_key,
+ "invalidated_at": now.isoformat(),
+ }
+ parent.action_plan_snapshot = _dumps(action_plan_snapshot)
+ if parent.status == "completed":
+ parent.status = "active"
+ target.status = "rejected"
+ target.last_rejection = _dumps(rejection)
+ target.rejection_count += 1
+ target.invalidated_by = None
+ target.updated_at = now
+
+ for row in rows:
+ if row.stage_index <= target.stage_index:
+ continue
+ if _has_material(row) or row.status in {"running", "done", "stale", "rejected", "error"}:
+ row.status = "stale"
+ else:
+ row.status = "locked"
+ row.invalidated_by = target.node_key
+ row.updated_at = now
+
+ parent.updated_at = now
+ await session.commit()
+ await session.refresh(parent)
+ for row in rows:
+ await session.refresh(row)
+ return self._session_dict(parent), [self._node_dict(row) for row in rows]
+
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")
diff --git a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py
index a0ea1335..aecdea17 100644
--- a/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py
+++ b/offline-backend-20260512/backend/tests/test_position_roundtable_sessions.py
@@ -139,6 +139,176 @@ async def test_node_updates_are_user_scoped_and_keep_thread_out_of_messages(stor
assert await store.get_node(created["id"], nodes[0]["node_key"], "user-b") is None
+@pytest.mark.asyncio
+async def test_reject_node_marks_current_and_invalidates_later_stages(store):
+ created = await store.create_session("user-a", external_task_id=None, task_snapshot={})
+ _, 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_key": "stage-2-seat-1-agent-b",
+ "stage_index": 1,
+ "seat_index": 0,
+ "agent_id": "agent-b",
+ "position_id": "solution",
+ "status": "ready",
+ },
+ {
+ "node_key": "stage-3-seat-1-agent-c",
+ "stage_index": 2,
+ "seat_index": 0,
+ "agent_id": "agent-c",
+ "position_id": "command",
+ "status": "ready",
+ },
+ ],
+ )
+ await store.update_node(
+ created["id"],
+ nodes[0]["node_key"],
+ "user-a",
+ status="done",
+ latest_answer="第一阶段产物",
+ increment_revision=True,
+ )
+ await store.update_node(
+ created["id"],
+ nodes[1]["node_key"],
+ "user-a",
+ status="done",
+ latest_answer="第二阶段产物",
+ increment_revision=True,
+ )
+
+ rejected = await store.reject_node(
+ created["id"],
+ nodes[0]["node_key"],
+ "user-a",
+ title="依据不足",
+ reason="第一阶段判断依据需要补充。",
+ rejected_by="user-a",
+ )
+
+ assert rejected is not None
+ _, rejected_nodes = rejected
+ by_key = {node["node_key"]: node for node in rejected_nodes}
+ first = by_key[nodes[0]["node_key"]]
+ second = by_key[nodes[1]["node_key"]]
+ third = by_key[nodes[2]["node_key"]]
+ assert first["status"] == "rejected"
+ assert first["rejection_count"] == 1
+ assert first["last_rejection"]["title"] == "依据不足"
+ assert first["invalidated_by"] is None
+ assert second["status"] == "stale"
+ assert second["latest_answer"] == "第二阶段产物"
+ assert second["invalidated_by"] == nodes[0]["node_key"]
+ assert third["status"] == "locked"
+ assert third["invalidated_by"] == nodes[0]["node_key"]
+
+
+@pytest.mark.asyncio
+async def test_builtin_summary_and_action_plan_snapshots_are_invalidated_by_reject(store):
+ created = await store.create_session(
+ "user-a",
+ external_task_id=None,
+ task_snapshot={
+ "title": "测试任务",
+ "description": "形成协同方案",
+ "direction": "态势研判",
+ },
+ )
+ await store.update_session(
+ created["id"],
+ "user-a",
+ intent_snapshot={"objective": "明确处置意图", "constraints": ["时间紧"]},
+ )
+ chain = {
+ "id": "chain-a",
+ "seats": [
+ {"agent_id": "agent-a", "name": "研判智能体", "position_id": "planning"},
+ {"agent_id": "agent-b", "name": "处置智能体", "position_id": "solution"},
+ ],
+ }
+ _, nodes = await store.activate_session(
+ created["id"],
+ "user-a",
+ chain_id="chain-a",
+ chain_snapshot=chain,
+ 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_key": "stage-2-seat-1-agent-b",
+ "stage_index": 1,
+ "seat_index": 0,
+ "agent_id": "agent-b",
+ "position_id": "solution",
+ "status": "ready",
+ },
+ ],
+ )
+ for node in nodes:
+ await store.update_node(
+ created["id"],
+ node["node_key"],
+ "user-a",
+ status="done",
+ latest_answer=f"{node['agent_id']} 产物",
+ increment_revision=True,
+ )
+ session = await store.get_session(created["id"], "user-a")
+ assert session is not None
+ nodes = await store.list_nodes(created["id"], "user-a") or []
+ summary = position_roundtable_router._build_summary_snapshot(session, nodes)
+ assert "岗位会商方案总结报告" in summary["content"]
+ assert "研判智能体" in summary["content"]
+ session = await store.update_session(created["id"], "user-a", summary_snapshot=summary)
+ assert session is not None
+ action_plan = position_roundtable_router._build_action_plan_snapshot(session, nodes)
+ session = await store.update_session(
+ created["id"],
+ "user-a",
+ action_plan_snapshot=action_plan,
+ status="completed",
+ )
+ assert session is not None
+ assert session["summary_snapshot"]["status"] == "done"
+ assert session["action_plan_snapshot"]["status"] == "done"
+
+ rejected = await store.reject_node(
+ created["id"],
+ nodes[0]["node_key"],
+ "user-a",
+ title="重做第一阶段",
+ reason="前序依据变化",
+ rejected_by="user-a",
+ )
+ assert rejected is not None
+ rejected_session, _ = rejected
+ assert rejected_session["status"] == "active"
+ assert rejected_session["summary_snapshot"]["status"] == "stale"
+ assert rejected_session["summary_snapshot"]["invalidated_by"] == nodes[0]["node_key"]
+ assert rejected_session["action_plan_snapshot"]["status"] == "stale"
+ assert rejected_session["action_plan_snapshot"]["invalidated_by"] == nodes[0]["node_key"]
+
+
@pytest.mark.asyncio
async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_path, monkeypatch):
artifact = tmp_path / "handoff.md"
@@ -188,6 +358,18 @@ async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_p
"latest_answer": None,
"artifact_manifest": [],
},
+ {
+ "node_key": "stage-1-seat-2-agent-stale",
+ "stage_index": 0,
+ "seat_index": 1,
+ "agent_id": "agent-stale",
+ "position_id": "planning",
+ "thread_id": "stale-thread",
+ "status": "stale",
+ "revision": 1,
+ "latest_answer": "失效上游产物",
+ "artifact_manifest": [],
+ },
]
async def resolve(thread_id: str, path: str):
@@ -207,6 +389,7 @@ async def test_handoff_context_inlines_only_upstream_thread_text_artifacts(tmp_p
)
upstream = context["upstream_nodes"][0]
+ assert len(context["upstream_nodes"]) == 1
assert upstream["latest_answer"] == "上游问答结果"
assert upstream["artifacts"][0]["content"] == "上游产物正文:可供下一岗位直接参考。"
assert "content" not in upstream["artifacts"][1]
From 1bc203b5cde80f82b6dabcf1591fb22db6baea61 Mon Sep 17 00:00:00 2001
From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com>
Date: Wed, 22 Jul 2026 15:40:00 +0800
Subject: [PATCH 6/7] =?UTF-8?q?=E5=B2=97=E4=BD=8D=E4=BC=9A=E5=95=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...ition-roundtable-delivery-workflow-plan.md | 75 +-
.../workspace/workspace-nav-chat-list.tsx | 14 +-
frontend-web/src/pages/WorkspaceRoutes.tsx | 11 +
.../src/position-roundtable/api/sessions.ts | 112 +-
.../components/PositionNodeChat.tsx | 116 +-
.../pages/PositionRoundtablePage.tsx | 1095 ++++++++++++++---
.../src/roundtable-planning/api/intent.ts | 12 +-
.../roundtable-planning/api/multi-agent.ts | 30 +
.../components/Step1Panel.tsx | 5 +
.../hooks/useStep1Intent.ts | 22 +-
.../app/gateway/roundtable_run_policy.py | 13 +-
.../routers/_builtin_agent_tag_seed.py | 1 +
.../app/gateway/routers/_roundtable_seed.py | 4 +
.../position-action-planner/SOUL.md | 48 +
.../position-action-planner/config.yaml | 3 +
.../backend/app/gateway/routers/intent.py | 41 +-
.../app/gateway/routers/multi_agent.py | 37 +-
.../gateway/routers/position_roundtable.py | 762 +++++++++---
.../backend/app/gateway/services.py | 7 +
..._04_position_roundtable_builtin_threads.py | 47 +
.../persistence/position_roundtable/model.py | 5 +
.../persistence/position_roundtable/sql.py | 12 +
.../test_position_roundtable_sessions.py | 159 ++-
23 files changed, 2230 insertions(+), 401 deletions(-)
create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/position-action-planner/SOUL.md
create mode 100644 offline-backend-20260512/backend/app/gateway/routers/_roundtable_seed_assets/position-action-planner/config.yaml
create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260721_04_position_roundtable_builtin_threads.py
diff --git a/frontend-web/docs/position-roundtable-delivery-workflow-plan.md b/frontend-web/docs/position-roundtable-delivery-workflow-plan.md
index ee3cfd84..75c06b4c 100644
--- a/frontend-web/docs/position-roundtable-delivery-workflow-plan.md
+++ b/frontend-web/docs/position-roundtable-delivery-workflow-plan.md
@@ -4,6 +4,29 @@
>
> 目标:在现有岗位会商基础上补齐“产物交付、查看、驳回、方案总结、行动规划”的完整闭环,同时继续保持原多智能体会商页面不受影响。
+## 实施核对(2026-07-21)
+
+本计划已按“岗位会商独立路由、无总控、各席位直接问答”的边界完成第一轮闭环。实际流程调整为:
+
+```text
+任务信息 → 情报分析岗识别并确认意图 → 启动校验并冻结业务链
+→ 分阶段席位直接问答/交付 Markdown → 全部有效交付
+→ 方案总结智能体(roundtable-summary)→ 行动规划智能体(position-action-planner)
+→ 可随时返回交付产物驳回 → 后续阶段及内置产物级联失效 → 重做后重新收口
+```
+
+- [x] 情报意图、业务链选择、席位岗位归属和席位智能体配置均在启动前校验;启动后保存业务链快照。
+- [x] 普通席位直接调用各自分配的智能体,并注入任务、意图及已完成上游席位的问答/产物;不经过总控智能体。
+- [x] 普通席位必须在 `outputs` 中交付 Markdown 才会完成;情报分析岗以确认的结构化意图完成为准。
+- [x] 节点、交付卡片、流程图和大图弹窗均展示 `locked / ready / running / done / rejected / stale / error` 状态,并可回看节点会话和产物。
+- [x] 驳回要求标题和理由;当前节点转为 `rejected`,后续阶段按依赖转为 `stale/locked`,总结和行动规划同时失效,历史内容保留可查看。
+- [x] 被驳回或失效的普通席位必须写入一份**新的或已改写** Markdown 才能再次完成;单纯继续问答且没有新交付不会误使下游产物失效。
+- [x] 方案总结复用 `roundtable-summary` 的真实流式线程;行动规划新增 `position-action-planner`,强制输出 Markdown 报告和 `action-plan-subtasks.json`。
+- [x] 两个内置智能体的线程、产物快照和来源节点版本栅栏均持久化;流式期间若上游版本变化,旧运行不能覆盖最新状态。
+- [x] 右侧“交付产物”可查看内置节点结果、继续同一线程问答/修改交付,并在打开 Markdown 沙箱时自动收起右栏。
+
+设计收敛:当前版本以节点的 `artifact_manifest`、`latest_answer`、版本、驳回记录及 Session 内置快照作为唯一事实来源,已能覆盖统一产物卡片和级联逻辑,因此不新增重复的 `position_roundtable_deliverables` 表。若后续需要“单个文件独立驳回、跨会话检索或完整版本对比”,再将清单归一化为独立产物表,避免现在的双写一致性风险。
+
## 0. 核心结论
- 方案总结智能体建议复用现有会商里的 `roundtable-summary` 能力,但不要直接搬整套 Step 3 页面流程。
@@ -75,32 +98,32 @@
### 3.1 Session 扩展
-`position_roundtable_sessions` 建议增加:
+`position_roundtable_sessions` 已使用下列持久化字段:
| 字段 | 含义 |
| --- | --- |
-| `workflow_status` | `intent_pending / active / summarizing / planning / completed / archived` |
+| `status` | `intent_pending / active / completed / archived`;总结/规划运行态由前端瞬时状态与对应快照的 `status` 表示,避免一次运行中断后留下伪运行态 |
| `summary_thread_id` | 方案总结智能体线程 |
| `summary_snapshot` | 最新有效总结报告快照 |
| `action_plan_thread_id` | 行动规划智能体线程 |
| `action_plan_snapshot` | 最新有效行动规划快照 |
-| `invalidated_at` | 最近一次级联失效时间,可选 |
+| `invalidated_at` | 记录在 `summary_snapshot` / `action_plan_snapshot` 内,包含 `invalidated_by`,无需重复写 Session 字段 |
### 3.2 Node 扩展
-`position_roundtable_nodes` 建议增加:
+`position_roundtable_nodes` 已使用下列字段(展示状态由前端按状态映射):
| 字段 | 含义 |
| --- | --- |
-| `display_status` | 面向前端的聚合状态,可后端计算也可前端映射 |
+| `status` | `locked / ready / running / done / stale / rejected / error`,前端直接映射为展示状态 |
| `rejection_count` | 当前节点累计驳回次数 |
| `last_rejection` | 最新驳回标题、理由、操作者、时间 |
-| `valid_revision` | 当前有效交付版本 |
+| `revision` | 当前有效交付版本 |
| `invalidated_by` | 导致当前节点失效的上游节点 key |
-### 3.3 Deliverable 新表
+### 3.3 Deliverable 新表(后续可选)
-建议新增 `position_roundtable_deliverables`,不要只依赖 `artifact_manifest`。
+当需要“单个文件独立驳回、跨会话检索或完整版本对比”时,再新增 `position_roundtable_deliverables`,不要只依赖 `artifact_manifest`。
| 字段 | 含义 |
| --- | --- |
@@ -121,7 +144,7 @@
| `created_at` | 创建时间 |
| `updated_at` | 更新时间 |
-这样右侧产物卡片、驳回记录、总结输入材料都能从同一张表聚合。
+当前第一轮不新增这张表:右侧产物卡片、驳回记录、总结输入材料从节点 `artifact_manifest`、节点版本/驳回记录和 Session 快照聚合,避免节点表与产物表双写。后续引入该表时,应把它提升为唯一事实来源,而不是并行维护两套状态。
## 4. 驳回与级联失效规则
@@ -173,25 +196,27 @@
| 方法 | 路径 | 用途 |
| --- | --- | --- |
-| `GET` | `/api/position-roundtable/sessions/{id}/deliverables` | 获取右侧产物列表 |
-| `GET` | `/api/position-roundtable/deliverables/{deliverable_id}` | 获取产物详情 |
-| `POST` | `/api/position-roundtable/deliverables/{deliverable_id}/reject` | 驳回产物并触发级联失效 |
-| `POST` | `/api/position-roundtable/nodes/{node_id}/complete-turn` | 节点回答结束后同步回答与产物 |
+| `GET` | `/api/position-roundtable/sessions/{id}/results` | 获取右侧统一产物来源节点(前端聚合文件与内置快照) |
+| `GET` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}` | 获取节点详情;文件正文由既有线程产物预览接口读取 |
+| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/reject` | 驳回当前节点交付并触发级联失效 |
+| `POST` | `/api/position-roundtable/sessions/{id}/nodes/{node_key}/complete-turn` | 节点回答结束后同步回答与产物 |
### 5.3 方案总结与行动规划
| 方法 | 路径 | 用途 |
| --- | --- | --- |
-| `POST` | `/api/position-roundtable/sessions/{id}/summary/start` | 启动或重启方案总结 |
-| `POST` | `/api/position-roundtable/sessions/{id}/summary/ask` | 对方案总结继续提问或修改 |
-| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/start` | 基于总结和产物生成行动规划 |
-| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/ask` | 对行动规划继续提问或修改 |
+| `POST` | `/api/position-roundtable/sessions/{id}/summary/prepare` | 服务端准备版本锁定的总结材料和线程信息 |
+| `POST` | `/api/position-roundtable/sessions/{id}/summary/ask` | 服务端准备总结的同线程续问材料 |
+| `POST` | `/api/position-roundtable/sessions/{id}/summary/complete` | 校验 Markdown 交付与来源版本后持久化总结快照 |
+| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/prepare` | 服务端准备行动规划材料和线程信息 |
+| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/ask` | 服务端准备行动规划同线程续问材料 |
+| `POST` | `/api/position-roundtable/sessions/{id}/action-plan/complete` | 校验 Markdown + JSON 交付与来源版本后持久化行动规划快照 |
可以复用现有 `streamMultiAgent` 流式协议,但建议在岗位会商下包一层 API,由后端负责拼装岗位会商材料,前端不直接拼长 prompt。
## 6. 前端实施步骤
-### 阶段 1:状态与启动校验
+### 阶段 1:状态与启动校验(已完成)
- 扩展 `PositionNodeStatus` 和状态显示映射。
- “确认意图并启动业务链”前补齐校验提示。
@@ -201,7 +226,7 @@
验收标准:未选择业务链、岗位未配齐、意图未完成时都能准确阻止启动;启动后流程图能显示各节点状态。
-### 阶段 2:流程图弹窗
+### 阶段 2:流程图弹窗(已完成)
- 左侧保留小型从上到下流程图。
- 流程图右上角增加“查看”按钮。
@@ -210,7 +235,7 @@
验收标准:小图不挤占左侧空间,大图能完整展示业务链和两个内置收口节点。
-### 阶段 3:产物卡片与预览
+### 阶段 3:产物卡片与预览(已完成)
- 右侧新增或改造“分析结果”Tab 为“交付产物”列表。
- 卡片展示智能体名称、产物名称、岗位名称、时间、状态。
@@ -220,7 +245,7 @@
验收标准:节点回答和文件产物能进入统一产物列表;点击可查看详情。
-### 阶段 4:驳回弹窗与级联失效
+### 阶段 4:驳回弹窗与级联失效(已完成)
- 新增 `RejectDeliverableDialog`。
- 驳回表单包含标题和理由,均必填。
@@ -230,7 +255,7 @@
验收标准:驳回第一阶段任意节点后,第二阶段及之后产物都显示未完成或已失效;旧内容可查看但不能作为总结输入。
-### 阶段 5:方案总结智能体
+### 阶段 5:方案总结智能体(已完成)
- 封装 `usePositionSummary`,内部复用 `roundtable-summary` 的线程初始化、流式输出和 Markdown 产物预览能力。
- 输入材料改为岗位会商材料:
@@ -245,7 +270,7 @@
验收标准:总结报告使用现有方案总结能力生成 Markdown;刷新页面后可恢复;上游驳回后总结标记失效。
-### 阶段 6:行动规划智能体
+### 阶段 6:行动规划智能体(已完成)
- 新增内置智能体 ID,建议先命名为 `position-action-planner`。
- 新增行动规划 prompt:
@@ -261,7 +286,7 @@
验收标准:行动规划能生成可读报告和结构化子任务;刷新可恢复;上游驳回后自动失效。
-### 阶段 7:返回修改与重新生成
+### 阶段 7:返回修改与重新生成(已完成)
- 用户在方案总结或行动规划视图中仍可切回“交付产物”。
- 驳回后自动显示需要重做的节点。
@@ -270,7 +295,7 @@
验收标准:不会因为进入总结/规划阶段而锁死前序产物;前序重做后能形成新版本闭环。
-### 阶段 8:测试与回归
+### 阶段 8:测试与回归(已完成)
- 后端测试:
- activate 校验。
diff --git a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx
index 469a50fc..58505ae7 100644
--- a/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx
+++ b/frontend-web/src/components/workspace/workspace-nav-chat-list.tsx
@@ -14,7 +14,7 @@ import {
} from "@/components/ui/sidebar";
import { useI18n } from "@/core/i18n/hooks";
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
-import { BookOpen, BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
+import { BookOpen, BotIcon, BrainIcon, BriefcaseBusiness, ListChecks, MessageSquarePlus, MessagesSquare, PenLine, Plus, SparklesIcon, Users } from "lucide-react";
import { KnowledgeRagToggle } from "./knowledge-rag-toggle";
import { SidebarCollapseButton } from "./workspace-header";
@@ -184,6 +184,18 @@ export function WorkspaceNavChatList({
+
+
+
+
+ 岗位会商
+
+
+
}
/>
+
+
+
+
+
+ }
+ />
| null;
intentThreadId: string | null;
intentSnapshot: Record | null;
+ summaryThreadId: string | null;
summarySnapshot: Record | null;
+ actionPlanThreadId: string | null;
actionPlanSnapshot: Record | null;
status: "intent_pending" | "active" | "completed" | "archived";
createdAt?: string;
@@ -82,7 +84,9 @@ interface ServerSession {
chain_snapshot?: Record | null;
intent_thread_id: string | null;
intent_snapshot?: Record | null;
+ summary_thread_id?: string | null;
summary_snapshot?: Record | null;
+ action_plan_thread_id?: string | null;
action_plan_snapshot?: Record | null;
status: PositionRoundtableSession["status"];
created_at?: string;
@@ -132,7 +136,9 @@ function toSession(session: ServerSession): PositionRoundtableSession {
chainSnapshot: session.chain_snapshot ?? null,
intentThreadId: session.intent_thread_id,
intentSnapshot: session.intent_snapshot ?? null,
+ summaryThreadId: session.summary_thread_id ?? null,
summarySnapshot: session.summary_snapshot ?? null,
+ actionPlanThreadId: session.action_plan_thread_id ?? null,
actionPlanSnapshot: session.action_plan_snapshot ?? null,
status: session.status,
createdAt: session.created_at,
@@ -301,22 +307,104 @@ export async function rejectPositionNode(
return toSession(result);
}
-export async function generatePositionSummary(sessionId: string): Promise {
- const result = await readJson(
- await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/summary/start`, {
+export interface PositionBuiltinRunPreparation {
+ agentId: string;
+ displayName: string;
+ threadId: string | null;
+ prompt: string;
+ sourceRevisions: Record;
+}
+
+interface ServerBuiltinRunPreparation {
+ agent_id: string;
+ display_name: string;
+ thread_id?: string | null;
+ prompt: string;
+ source_revisions?: Record;
+}
+
+function toBuiltinRunPreparation(value: ServerBuiltinRunPreparation): PositionBuiltinRunPreparation {
+ return {
+ agentId: value.agent_id,
+ displayName: value.display_name,
+ threadId: value.thread_id ?? null,
+ prompt: value.prompt,
+ sourceRevisions: value.source_revisions ?? {},
+ };
+}
+
+type BuiltinKind = "summary" | "action_plan";
+
+async function prepareBuiltinRun(
+ sessionId: string,
+ kind: BuiltinKind,
+ message = "",
+): Promise {
+ const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare";
+ const result = await readJson(
+ await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ message }),
}),
- "generate position summary",
+ `prepare position ${kind}`,
+ );
+ return toBuiltinRunPreparation(result);
+}
+
+async function completeBuiltinRun(
+ sessionId: string,
+ kind: BuiltinKind,
+ input: {
+ threadId: string;
+ content: string;
+ artifactManifest: Array>;
+ sourceRevisions: Record;
+ model?: string;
+ },
+): Promise {
+ const path = kind === "summary" ? "summary/complete" : "action-plan/complete";
+ const result = await readJson(
+ await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ thread_id: input.threadId,
+ content: input.content,
+ artifact_manifest: input.artifactManifest,
+ source_revisions: input.sourceRevisions,
+ model: input.model || null,
+ }),
+ }),
+ `complete position ${kind}`,
);
return toSession(result);
}
-export async function generatePositionActionPlan(sessionId: string): Promise {
- const result = await readJson(
- await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/action-plan/start`, {
- method: "POST",
- }),
- "generate position action plan",
- );
- return toSession(result);
+export function preparePositionSummary(
+ sessionId: string,
+ message = "",
+): Promise {
+ return prepareBuiltinRun(sessionId, "summary", message);
+}
+
+export function completePositionSummary(
+ sessionId: string,
+ input: Parameters[2],
+): Promise {
+ return completeBuiltinRun(sessionId, "summary", input);
+}
+
+export function preparePositionActionPlan(
+ sessionId: string,
+ message = "",
+): Promise {
+ return prepareBuiltinRun(sessionId, "action_plan", message);
+}
+
+export function completePositionActionPlan(
+ sessionId: string,
+ input: Parameters[2],
+): Promise {
+ return completeBuiltinRun(sessionId, "action_plan", input);
}
diff --git a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx
index f329916c..c5b99f42 100644
--- a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx
+++ b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { Bot, FileText, LockKeyhole, Loader2 } from "lucide-react";
+import { Bot, FileText, LockKeyhole } from "lucide-react";
import { toast } from "sonner";
-
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
import { InputBox } from "@/components/workspace/input-box";
import { ThreadContext } from "@/components/workspace/messages/context";
import { MessageList } from "@/components/workspace/messages";
+import { useArtifactsOptional } from "@/components/workspace/artifacts";
+import { useAgent } from "@/core/agents";
import { useLocalSettings } from "@/core/settings";
import { useThreadStream } from "@/core/threads/hooks";
import { textOfMessage } from "@/core/threads/utils";
@@ -17,15 +18,26 @@ import {
type PositionRoundtableNode,
} from "@/position-roundtable/api/sessions";
import { listSessionArtifacts } from "@/roundtable-planning/api/multi-agent";
-import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
+import { desensitize } from "@/lib/desensitize";
interface PositionNodeChatProps {
sessionId: string;
+ /** Parent task id: all node threads are indexed under this task. */
+ taskId: string;
chainId: string | null;
node: PositionRoundtableNode;
currentPositionId: string;
sessionStatus: "intent_pending" | "active" | "completed" | "archived";
+ agentName?: string | null;
onNodeUpdate: (node: PositionRoundtableNode) => void;
+ /** The workspace collapses the configuration panel on the first real turn. */
+ onConversationStart?: () => void;
+ onPreviewArtifact?: (artifact: {
+ threadId: string;
+ path: string;
+ name: string;
+ mimeType: string | null;
+ }) => void;
}
interface NodeArtifact {
@@ -52,6 +64,26 @@ function readNodeArtifacts(manifest: Array>): NodeArtifa
});
}
+function hasMarkdownArtifact(manifest: Array>): boolean {
+ return manifest.some((artifact) => {
+ const path = typeof artifact.path === "string" ? artifact.path.toLowerCase() : "";
+ const name = typeof artifact.name === "string" ? artifact.name.toLowerCase() : "";
+ const mime =
+ typeof artifact.mime_type === "string"
+ ? artifact.mime_type.toLowerCase()
+ : typeof artifact.mimeType === "string"
+ ? artifact.mimeType.toLowerCase()
+ : "";
+ return (
+ mime.includes("markdown") ||
+ path.endsWith(".md") ||
+ path.endsWith(".markdown") ||
+ name.endsWith(".md") ||
+ name.endsWith(".markdown")
+ );
+ });
+}
+
/**
* Direct single-agent chat for one frozen business-chain node.
*
@@ -61,18 +93,24 @@ function readNodeArtifacts(manifest: Array>): NodeArtifa
*/
export function PositionNodeChat({
sessionId,
+ taskId,
chainId,
node,
currentPositionId,
sessionStatus,
+ agentName,
onNodeUpdate,
+ onConversationStart,
+ onPreviewArtifact,
}: PositionNodeChatProps) {
const [settings, setSettings] = useLocalSettings();
+ const { agent } = useAgent(node.agentId);
+ const agentDisplayName = desensitize(agentName || agent?.name || node.agentId);
+ const artifactsCtx = useArtifactsOptional();
const [boundThreadId, setBoundThreadId] = useState(node.threadId);
const boundThreadIdRef = useRef(node.threadId);
const draftThreadIdRef = useRef(node.threadId ?? uuid());
const upstreamRevisionRef = useRef | null>(null);
- const [previewArtifact, setPreviewArtifact] = useState(null);
const nodeArtifacts = useMemo(() => readNodeArtifacts(node.artifactManifest), [node.artifactManifest]);
const canWrite =
sessionStatus !== "archived" &&
@@ -81,6 +119,19 @@ export function PositionNodeChat({
node.status !== "error" &&
!node.invalidatedBy;
+ useEffect(() => {
+ if (artifactsCtx?.open && artifactsCtx.selectedArtifact) {
+ onPreviewArtifact?.({
+ threadId: boundThreadIdRef.current ?? boundThreadId ?? draftThreadIdRef.current,
+ path: artifactsCtx.selectedArtifact,
+ name: artifactsCtx.selectedArtifact.split("/").filter(Boolean).at(-1) || "Markdown 报告",
+ mimeType: artifactsCtx.selectedArtifact.toLowerCase().endsWith(".md")
+ ? "text/markdown"
+ : null,
+ });
+ }
+ }, [artifactsCtx?.open, artifactsCtx?.selectedArtifact, boundThreadId, onPreviewArtifact]);
+
useEffect(() => {
setBoundThreadId(node.threadId);
boundThreadIdRef.current = node.threadId;
@@ -99,14 +150,21 @@ export function PositionNodeChat({
[node.nodeKey, onNodeUpdate, sessionId],
);
- const [thread, sendMessage] = useThreadStream({
+ const [thread, sendMessage, , actualThreadId] = useThreadStream({
threadId: boundThreadId,
assistantId: node.agentId,
context: { ...settings.context, agent_id: node.agentId },
threadMetadata: {
thread_type: "position_roundtable",
+ // ``taskId`` is the shared task-workspace convention. The two
+ // position-specific identifiers retain the exact child node for audits
+ // and restore, even when one position owns more than one chain seat.
+ taskId,
+ position_roundtable_task_id: taskId,
+ position_task_id: `${taskId}:${node.positionId ?? "unassigned"}:${node.nodeKey}`,
position_session_id: sessionId,
position_node_id: node.id,
+ position_node_key: node.nodeKey,
position_id: node.positionId,
chain_id: chainId,
agent_id: node.agentId,
@@ -128,6 +186,7 @@ export function PositionNodeChat({
path: file.path,
mime_type: file.mimeType,
modified_at: file.modifiedAt,
+ size_bytes: file.sizeBytes,
}));
} catch {
// Artifact listing is best-effort: a valid text answer must still be
@@ -140,6 +199,9 @@ export function PositionNodeChat({
upstreamRevision: upstreamRevisionRef.current,
});
onNodeUpdate(updated);
+ if (updated.status !== "done" && !hasMarkdownArtifact(artifactManifest)) {
+ toast.info("已保存本轮回答;该席位需生成并交付一份 Markdown 报告文档后才会标记完成。");
+ }
} catch (error) {
toast.error(`节点结果保存失败:${error instanceof Error ? error.message : String(error)}`);
}
@@ -147,6 +209,14 @@ export function PositionNodeChat({
},
});
+ // SDK thread creation is asynchronous. ``onStart`` normally binds it, but
+ // watching the actual stream id as well closes the last race where a page
+ // refresh could happen between creation and the async node-index write.
+ useEffect(() => {
+ if (!actualThreadId || actualThreadId === boundThreadIdRef.current) return;
+ bindCreatedThread(actualThreadId);
+ }, [actualThreadId, bindCreatedThread]);
+
const handleSubmit = useCallback(
async (message: PromptInputMessage) => {
if (!canWrite) {
@@ -162,6 +232,7 @@ export function PositionNodeChat({
return;
}
try {
+ onConversationStart?.();
const taskExternalContext = await getPositionNodeContext(sessionId, node.nodeKey);
const upstreamRevision = taskExternalContext.upstream_revision;
upstreamRevisionRef.current =
@@ -176,7 +247,16 @@ export function PositionNodeChat({
toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`);
}
},
- [canWrite, node.agentId, node.nodeKey, node.status, sendMessage, sessionId, sessionStatus],
+ [
+ canWrite,
+ node.agentId,
+ node.nodeKey,
+ node.status,
+ onConversationStart,
+ sendMessage,
+ sessionId,
+ sessionStatus,
+ ],
);
const status = thread.error ? "error" : thread.isLoading ? "streaming" : "ready";
@@ -201,13 +281,12 @@ export function PositionNodeChat({
- {node.agentId}
+ {agentDisplayName}
{readOnlyHint || `节点状态:${node.status}`}
- {thread.isLoading && }
{nodeArtifacts.length > 0 && (
@@ -225,7 +304,16 @@ export function PositionNodeChat({
key={artifact.path}
type="button"
title={`预览 ${artifact.name}`}
- onClick={() => setPreviewArtifact(artifact)}
+ onClick={() => {
+ if (boundThreadId && onPreviewArtifact) {
+ onPreviewArtifact({
+ threadId: boundThreadId,
+ path: artifact.path,
+ name: artifact.name,
+ mimeType: artifact.mimeType,
+ });
+ }
+ }}
className="inline-flex max-w-full items-center gap-1 rounded-md border border-slate-200 bg-white px-2 py-1 text-left text-[11px] text-slate-600 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#0a315d]/80 dark:text-[#c5cfe0]"
>
@@ -242,6 +330,7 @@ export function PositionNodeChat({
threadId={boundThreadId}
thread={thread}
paddingBottom={24}
+ scrollButtonClassName="bottom-6"
onEditResubmit={(text) => void handleSubmit({ text, files: [] })}
/>
) : (
@@ -275,15 +364,6 @@ export function PositionNodeChat({
onStop={() => void thread.stop()}
/>