From 1d07025f74db0c111ae9a247843a75f48561b833 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Thu, 23 Jul 2026 15:08:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/position-roundtable/README.md | 2 +- .../src/position-roundtable/api/sessions.ts | 8 +- .../components/PositionBuiltinAgentChat.tsx | 20 +-- .../components/PositionNodeChat.tsx | 45 +++++++ .../pages/PositionRoundtablePage.tsx | 112 +++++++++++----- offline-backend-20260512/backend/CLAUDE.md | 11 ++ offline-backend-20260512/backend/README.md | 2 +- .../gateway/routers/position_roundtable.py | 66 +++++++++- .../test_position_roundtable_sessions.py | 124 ++++++++++++++++++ 9 files changed, 337 insertions(+), 53 deletions(-) diff --git a/frontend-web/src/position-roundtable/README.md b/frontend-web/src/position-roundtable/README.md index 12fd6b35..fefc9e79 100644 --- a/frontend-web/src/position-roundtable/README.md +++ b/frontend-web/src/position-roundtable/README.md @@ -126,7 +126,7 @@ PositionRoundtablePage `PositionBuiltinAgentChat` 不使用后台任务。每次发送先调用 `preparePositionSummary` 或 `preparePositionActionPlan`,从服务端得到冻结的输入提示词、来源版本和可复用线程 ID;待 `useThreadStream` 真正绑定到该线程后才发送首条消息。这个两步提交避免了界面先出现用户气泡、实际却没有启动流的竞态。 -完成回调会调用 `completePositionSummary` 或 `completePositionActionPlan`。服务端使用 `source_revisions` 判断上游是否被重做,以避免将过期总结当作当前交付物。 +完成回调会调用 `completePositionSummary` 或 `completePositionActionPlan`。服务端使用 `source_revisions` 判断上游是否被重做,以避免将过期总结当作当前交付物。方案总结(以及行动规划的 Markdown 部分)若模型只返回了对话正文、遗漏 `write_file`,服务端会将该正文自动写入对应线程的 `outputs/` 目录并登记为 Markdown 产物,因此用户始终同时获得聊天正文与可预览、下载的报告文件;行动规划仍必须另外生成 `action-plan-subtasks.json`。 ## 5. 文件预览沙箱 diff --git a/frontend-web/src/position-roundtable/api/sessions.ts b/frontend-web/src/position-roundtable/api/sessions.ts index 3e6cc070..ac3f03e5 100644 --- a/frontend-web/src/position-roundtable/api/sessions.ts +++ b/frontend-web/src/position-roundtable/api/sessions.ts @@ -334,7 +334,7 @@ export async function completePositionNodeTurn( export async function rejectPositionNode( sessionId: string, nodeKey: string, - input: { title: string; reason: string }, + input: { reason?: string; title?: string }, ): Promise { const result = await readJson( await apiFetch( @@ -343,8 +343,10 @@ export async function rejectPositionNode( method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - title: input.title, - reason: input.reason, + // ``title`` is retained for older callers. The current position + // workspace deliberately sends only an optional reason. + ...(input.title ? { title: input.title } : {}), + reason: input.reason ?? "", }), }, ), diff --git a/frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx b/frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx index b95285e2..f6703a7b 100644 --- a/frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx +++ b/frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx @@ -76,12 +76,6 @@ interface PositionBuiltinAgentChatProps { const SUMMARY_AGENT_ID = "roundtable-summary"; const ACTION_PLAN_AGENT_ID = "position-action-planner"; -// 方案总结 / 行动规划仍使用普通的 thread 流;首次自动提交期间若组件因 -// thread 绑定而短暂重渲染,保留服务端运行并允许恢复 SSE,避免首轮被取消。 -const POSITION_BUILTIN_RUN_OPTIONS = { - onDisconnect: "continue", - multitaskStrategy: "rollback", -} as const; function builtinAgentId(kind: PositionBuiltinAgentKind) { return kind === "summary" ? SUMMARY_AGENT_ID : ACTION_PLAN_AGENT_ID; @@ -244,7 +238,12 @@ export function PositionBuiltinAgentChat({ pendingSubmissionRef.current = false; setPendingSubmission(null); handledAutoStartRequestRef.current = undefined; - }, [fallbackAgentId, kind, persistedThreadId, session?.id]); + // Do not depend on `persistedThreadId` here. Preparing the automatic + // first question can persist a thread id while the page's initial + // session-request is still resolving. Treating that update as a new + // session used to clear `pendingSubmission` before the normal send path + // ran; a manual click only worked because that request had already ended. + }, [fallbackAgentId, kind, session?.id]); const canWrite = Boolean(session) && @@ -264,7 +263,6 @@ export function PositionBuiltinAgentChat({ const [thread, sendMessage, , actualThreadId] = useThreadStream({ threadId: boundThreadId, assistantId, - runOptions: POSITION_BUILTIN_RUN_OPTIONS, context: { ...settings.context, agent_id: assistantId, @@ -578,6 +576,12 @@ export function PositionBuiltinAgentChat({ thread={thread} paddingBottom={24} scrollButtonClassName="bottom-6" + // Use the built-in agent's regular submit path. This keeps a + // clarification answer on the same persisted thread and retains + // the summary/action-plan preparation and completion lifecycle. + onClarificationSubmit={(answer) => + handleSubmit({ text: answer, files: [] }) + } onEditResubmit={(text) => void handleSubmit({ text, files: [] })} onOpenArtifact={handleOpenMessageArtifact} /> diff --git a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx index fe39f266..1c55c461 100644 --- a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx +++ b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx @@ -4,6 +4,13 @@ import { Bot, FileText, LockKeyhole, Settings } from "lucide-react"; import { toast } from "sonner"; import { Button as TButton, Select as TSelect } from "tdesign-react"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { ThreadContext } from "@/components/workspace/messages/context"; import { MessageList } from "@/components/workspace/messages"; import { useAgent } from "@/core/agents"; @@ -207,8 +214,12 @@ export function PositionNodeChat({ onThreadSnapshot, }: PositionNodeChatProps) { const [settings, setSettings] = useLocalSettings(); + const [rejectionReasonOpen, setRejectionReasonOpen] = useState(false); const { agent } = useAgent(node.agentId); const agentDisplayName = desensitize(agentName || agent?.name || node.agentId); + const lastRejection = node.status === "rejected" ? node.lastRejection : null; + const rejectionReason = + lastRejection && typeof lastRejection.reason === "string" ? lastRejection.reason.trim() : ""; const switchOptions = useMemo( () => positionNodeOptions?.length @@ -504,6 +515,17 @@ export function PositionNodeChat({ onSelect={onSelectBuiltinAgent} /> )} + {lastRejection && ( + setRejectionReasonOpen(true)} + > + 查看驳回理由 + + )} {onOpenConfiguration && ( + handleSubmit({ text: answer, files: [] }) + } onEditResubmit={(text) => void handleSubmit({ text, files: [] })} onOpenArtifact={handleOpenMessageArtifact} /> @@ -593,6 +622,22 @@ export function PositionNodeChat({ onStop={() => void thread.stop()} /> + + + + + 驳回理由 + +
+ {rejectionReason || "本次驳回未填写具体理由。"} +
+ + setRejectionReasonOpen(false)}> + 关闭 + + +
+
); diff --git a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx index 87b6d8b4..38536cdb 100644 --- a/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx +++ b/frontend-web/src/position-roundtable/pages/PositionRoundtablePage.tsx @@ -808,6 +808,11 @@ export default function PositionRoundtablePage({ const showClosureAgents = positionId === DEFAULT_POSITION_ID; const [activeBuiltinKind, setActiveBuiltinKind] = useState(null); const [builtinAutoStartRequestKey, setBuiltinAutoStartRequestKey] = useState(0); + // Stage selection only arms the automatic first question. The key is + // incremented after the page has finished restoring its Session so the + // ordinary chat component never submits against a thread being rebound. + const [pendingBuiltinAutoStartKind, setPendingBuiltinAutoStartKind] = + useState(null); const builtinAgentOptions = useMemo( () => [ { @@ -834,7 +839,27 @@ export default function PositionRoundtablePage({ useEffect(() => { setActiveBuiltinKind(null); setBuiltinAutoStartRequestKey(0); + setPendingBuiltinAutoStartKind(null); }, [positionSession?.id]); + useEffect(() => { + if ( + !pendingBuiltinAutoStartKind || + pendingBuiltinAutoStartKind !== activeBuiltinKind || + !positionSession || + sessionLoading + ) { + return; + } + + // Let the ordinary chat mount and finish the current page's session + // restoration first. The callback below only increments a key; the child + // still uses its standard manual-submit path to create the run. + const timer = window.setTimeout(() => { + setBuiltinAutoStartRequestKey((current) => current + 1); + setPendingBuiltinAutoStartKind(null); + }, 0); + return () => window.clearTimeout(timer); + }, [activeBuiltinKind, pendingBuiltinAutoStartKind, positionSession?.id, sessionLoading]); const assignedAgentIds = useMemo( () => new Set( @@ -1421,7 +1446,7 @@ export default function PositionRoundtablePage({ setActiveNodeKey(null); setRightPanelOpen(false); setActiveBuiltinKind(kind); - setBuiltinAutoStartRequestKey((current) => current + 1); + setPendingBuiltinAutoStartKind(kind); }, [allPositionDeliveriesDone, closeSandbox, positionId, summaryDone], ); @@ -4454,8 +4479,12 @@ function AnalysisResults({ const [nodeFilter, setNodeFilter] = useState("all"); const [kindFilter, setKindFilter] = useState<"all" | ResultKind>("all"); const [detailItem, setDetailItem] = useState(null); + // Rejecting starts from the artifact detail dialog. Keep the requested node + // separate until that dialog has actually unmounted; otherwise Radix places + // the reason dialog below the existing deliverable dialog and the click + // appears to do nothing. + const [pendingRejectNode, setPendingRejectNode] = useState(null); const [rejectNode, setRejectNode] = useState(null); - const [rejectTitle, setRejectTitle] = useState(""); const [rejectReason, setRejectReason] = useState(""); const [rejectSubmitting, setRejectSubmitting] = useState(false); const seatsByAgentId = useMemo( @@ -4666,37 +4695,41 @@ function AnalysisResults({ ); const openRejectDialog = useCallback((node: PositionRoundtableNode) => { - setRejectNode(node); - setRejectTitle(nodeRejectionTitle(node)); - setRejectReason(""); + // Close the high-z-index product dialog first, then open the rejection + // dialog on the next render. This prevents an invisible, unclickable + // dialog from being rendered under the product detail modal. + setDetailItem(null); + setPendingRejectNode(node); }, []); + useEffect(() => { + if (!pendingRejectNode || detailItem || rejectSubmitting) return; + setRejectNode(pendingRejectNode); + setRejectReason(""); + setPendingRejectNode(null); + }, [detailItem, pendingRejectNode, rejectSubmitting]); + const handleRejectSubmit = useCallback(async () => { if (!rejectNode) return; - const title = rejectTitle.trim(); const reason = rejectReason.trim(); - if (!title || !reason) { - toast.warning("请填写驳回标题和理由"); - return; - } setRejectSubmitting(true); try { const session = await rejectPositionNode(rejectNode.sessionId, rejectNode.nodeKey, { - title, reason, }); + // The endpoint returns the complete session, including every downstream + // node invalidated by this rejection. Replacing the parent snapshot here + // updates the flow chart, delivery drawer and the active chat together. onSessionUpdate(session); setRejectNode(null); - setRejectTitle(""); setRejectReason(""); - setDetailItem(null); - toast.success("已驳回该产物,后续阶段已标记为需重新完成"); + toast.success("产物已驳回,相关后续阶段已同步标记为待重做"); } catch (error) { toast.error(`驳回失败:${error instanceof Error ? error.message : String(error)}`); } finally { setRejectSubmitting(false); } - }, [onSessionUpdate, rejectNode, rejectReason, rejectTitle]); + }, [onSessionUpdate, rejectNode, rejectReason]); if (resultItems.length === 0) { return ( @@ -4914,20 +4947,19 @@ function AnalysisResults({ { if (!open && !rejectSubmitting) { setRejectNode(null); - setRejectTitle(""); + setPendingRejectNode(null); setRejectReason(""); } }} - onTitleChange={setRejectTitle} onReasonChange={setRejectReason} onSubmit={() => void handleRejectSubmit()} /> + {rejectSubmitting && } ); } @@ -5143,26 +5175,25 @@ function DeliverableDetailDialog({ 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 ( - + @@ -5171,22 +5202,14 @@ function RejectDeliveryDialog({
- 驳回后,该节点会回到需重做状态;其后续阶段的产物会一起失效,但历史内容仍可查看。 + 驳回后,该节点会回到待重做状态;已受其影响的后续交付会同步失效,待上游重新完成后再依次生成。
-