From b60d9ed09e4c3455f52705b6656725c90c55bdfe 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 14:29:12 +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 --- frontend-web/src/core/threads/hooks.ts | 40 ++- .../src/position-roundtable/README.md | 196 ++++++++++++++ .../src/position-roundtable/api/sessions.ts | 32 ++- .../components/PositionAgentInput.tsx | 25 ++ .../components/PositionBuiltinAgentChat.tsx | 92 ++----- .../components/PositionIntentInput.tsx | 240 ++++++++++++++++++ .../components/PositionNodeChat.tsx | 34 ++- .../pages/PositionRoundtablePage.tsx | 56 +++- .../components/Step1Panel.tsx | 67 ++++- frontend-web/src/styles/globals.css | 5 + 10 files changed, 691 insertions(+), 96 deletions(-) create mode 100644 frontend-web/src/position-roundtable/README.md create mode 100644 frontend-web/src/position-roundtable/components/PositionAgentInput.tsx create mode 100644 frontend-web/src/position-roundtable/components/PositionIntentInput.tsx diff --git a/frontend-web/src/core/threads/hooks.ts b/frontend-web/src/core/threads/hooks.ts index 5d692f65..e744ced7 100644 --- a/frontend-web/src/core/threads/hooks.ts +++ b/frontend-web/src/core/threads/hooks.ts @@ -30,11 +30,20 @@ export type ToolEndEvent = { data: unknown; }; -export type ThreadStreamOptions = { - threadId?: string | null | undefined; - context: LocalSettings["context"]; - isMock?: boolean; - assistantId?: string; +export type ThreadStreamOptions = { + threadId?: string | null | undefined; + context: LocalSettings["context"]; + isMock?: boolean; + assistantId?: string; + /** + * Per-page run behaviour. Most chats keep the SDK defaults; long-running + * workspace flows can opt into a resumable stream without changing global + * chat semantics. + */ + runOptions?: { + onDisconnect?: "cancel" | "continue"; + multitaskStrategy?: "reject" | "rollback" | "interrupt" | "enqueue"; + }; /** 额外写到新建 thread 的 metadata(与 agent_id 合并)。 * 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */ threadMetadata?: Record; @@ -405,9 +414,10 @@ function lastHumanFromChatModelInput(input: unknown): string { export function useThreadStream({ threadId, context, - isMock, - assistantId, - threadMetadata, + isMock, + assistantId, + runOptions, + threadMetadata, onStart, onFinish, onToolEnd, @@ -1037,11 +1047,13 @@ export function useThreadStream({ { messages: submittedMessages, }, - { - threadId: threadId, - streamSubgraphs: true, - streamResumable: true, - config: { + { + threadId: threadId, + streamSubgraphs: true, + streamResumable: true, + onDisconnect: runOptions?.onDisconnect, + multitaskStrategy: runOptions?.multitaskStrategy, + config: { recursion_limit: 1000, }, context: { @@ -1076,7 +1088,7 @@ export function useThreadStream({ sendInFlightRef.current = false; } }, - [thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient], + [thread, _handleOnStart, t.uploads.uploadingFiles, context, localSettings.display.ragModeEnabled, queryClient, runOptions], ); // Merge thread with optimistic messages for display diff --git a/frontend-web/src/position-roundtable/README.md b/frontend-web/src/position-roundtable/README.md new file mode 100644 index 00000000..12fd6b35 --- /dev/null +++ b/frontend-web/src/position-roundtable/README.md @@ -0,0 +1,196 @@ +# 岗位多智能体会商页面开发说明 + +## 1. 页面定位 + +岗位多智能体会商将一个业务链条拆成多个“岗位视角”的单智能体问答:每个席位仍使用既有的 LangGraph 对话线程完成流式生成,岗位会商服务只负责保存任务快照、冻结后的链条、节点状态与线程索引。 + +因此它不是新的多智能体调度图,也不创建后台会商任务。页面的核心约束是: + +- 一条岗位会商会话对应一个任务和一份冻结的业务链配置; +- 一个业务链席位对应一个 `PositionRoundtableNode` 和一条直接对话线程; +- 节点写出 Markdown 交付物后才能被视为完成并解锁后序; +- 所有岗位交付完成后,测试分析岗可以依次进入“方案总结”和“行动规划”两个收口智能体; +- 页面中的文件预览沙箱只有一个实例,不会为多个节点重复打开或同时流式渲染。 + +前端入口位于 `src/pages/StrategyRoutes.tsx`:`qa/position-roundtable`。 + +## 2. URL 与会话恢复 + +推荐访问地址: + +```text +/#/page/strategy/qa/position-roundtable?taskId=<任务ID>&sessionId=<会商会话ID>&positionId=<岗位ID> +``` + +| 参数 | 是否必填 | 作用 | +| --- | --- | --- | +| `taskId` | 否 | 外部任务 ID。缺失时使用本地 mock 任务,便于演示和开发。 | +| `sessionId` | 否 | 直接恢复指定岗位会商会话;缺失时页面会按 `taskId` 查找最近一条未归档会话。 | +| `positionId` | 否 | 初始打开的岗位,同时同步顶部岗位选择器。无效值回退到 `intelligence`。 | +| `new=1` | 否 | 强制不恢复历史会话,以新会话开始。 | + +当前岗位 ID 定义在 `lib/positions.ts`: + +| ID | 页面岗位 | 职责 | +| --- | --- | --- | +| `intelligence` | 测试分析岗 | 完成任务意图识别、业务链确认,以及收口智能体入口。 | +| `planning` | 组织测试岗 | 承接上游信息并完成组织侧分析。 | +| `solution` | 方案设计岗 | 产出方案侧 Markdown 交付物。 | +| `evaluation` | 综合测试岗 | 汇总评估并给出综合建议。 | + +切换岗位时,`PositionRoundtablePage` 会关闭当前沙箱、清空当前节点/收口智能体选择,并写回 `positionId`。不要在子组件中直接修改 URL,否则容易留下上一岗位的预览状态。 + +## 3. 页面结构与职责 + +```text +PositionRoundtablePage +├─ 左侧:任务分析 +│ ├─ 任务卡片(名称、简介、来源、下发时间) +│ ├─ 意图分析 +│ └─ 内嵌业务链流程图 +├─ 中间:当前岗位的对话区 +│ ├─ 测试分析岗:Step1Panel + PositionIntentInput +│ ├─ 普通席位:PositionNodeChat + PositionAgentInput +│ └─ 收口阶段:PositionBuiltinAgentChat + PositionAgentInput +├─ 右侧:智能体配置 / 交付产物抽屉 +└─ 文件预览:唯一的 ArtifactFileDetail 沙箱实例 +``` + +主要文件: + +| 文件 | 责任 | +| --- | --- | +| `pages/PositionRoundtablePage.tsx` | 页面状态机、URL 同步、任务与会话恢复、岗位/节点切换、唯一沙箱预览。 | +| `api/sessions.ts` | 岗位会商 API 的前端类型、snake_case/camelCase 映射和请求函数。 | +| `components/PositionNodeChat.tsx` | 一个普通业务链节点的一对一对话、线程绑定、上游上下文注入与交付完成回写。 | +| `components/PositionBuiltinAgentChat.tsx` | 方案总结、行动规划的直接对话和“准备 → 线程就绪 → 发送 → 归档”流程。 | +| `components/PositionIntentInput.tsx` | 仅测试分析岗使用的意图输入框。样式、工具栏和按钮均与其他页面隔离。 | +| `components/PositionAgentInput.tsx` | 普通节点与收口智能体共用的岗位专用输入框边界。 | +| `components/PositionAgentSwitcher.tsx` | 测试分析岗中两个收口智能体的全名切换按钮。 | +| `lib/positions.ts` | 前端岗位目录和稳定岗位 ID。 | + +## 4. 状态流转 + +```text +意图识别 + └─ 确认意图并启动业务链 + ├─ 冻结 taskSnapshot / intentSnapshot / chainSnapshot + ├─ 按业务链席位创建节点 + └─ intelligence 岗节点自动标记完成 + +普通业务链节点 + └─ 直接问答并生成 Markdown + ├─ 写入 LangGraph 线程 + ├─ 回写 node.threadId + ├─ 保存 latestAnswer 与 artifactManifest + └─ Markdown 交付成功后解锁后续阶段 + +全部普通节点已交付 + └─ 方案总结(测试分析岗) + └─ 行动规划(测试分析岗) +``` + +### 4.1 意图识别与业务链启动 + +测试分析岗复用 `Step1Panel` 的消息和状态逻辑;页面通过 `inputArea` 插槽替换为 `PositionIntentInput`,因此不会改动普通会商 Step 1 的输入样式。 + +`handleActivateChain` 启动前必须同时满足: + +1. 选择了业务链; +2. 业务链至少有一个席位; +3. 每个席位已配置智能体和岗位; +4. `intent.intentReady` 存在结构化意图结果。 + +启动成功后会调用: + +1. `createPositionSession`(首次启动时); +2. `savePositionIntent`; +3. `activatePositionSession`。 + +后端会冻结链条快照并根据链条阶段创建节点。已启动后意图不可再修改;需要重新识别时必须新建会话。 + +### 4.2 普通节点问答 + +`PositionNodeChat` 在发送前调用 `getPositionNodeContext` 获取受长度控制的任务、意图和已交付上游内容,再以 `task_external_context` 作为隐藏上下文发送给当前智能体。 + +首次创建线程时,`onStart` 会调用 `bindPositionNodeThread`。组件还监听 SDK 实际线程 ID,处理“线程创建后、绑定写入前刷新页面”的竞态。 + +流结束后,组件会读取该线程的产物列表并调用 `completePositionNodeTurn`。普通节点只有交付 Markdown 文件才会变为 `done`;单纯继续追问只会保存回答,不会误解锁下游节点。 + +### 4.3 收口智能体 + +收口阶段只有测试分析岗可见: + +- 方案总结:全部岗位交付完成后可开始; +- 行动规划:方案总结完成后可开始。 + +`PositionBuiltinAgentChat` 不使用后台任务。每次发送先调用 `preparePositionSummary` 或 `preparePositionActionPlan`,从服务端得到冻结的输入提示词、来源版本和可复用线程 ID;待 `useThreadStream` 真正绑定到该线程后才发送首条消息。这个两步提交避免了界面先出现用户气泡、实际却没有启动流的竞态。 + +完成回调会调用 `completePositionSummary` 或 `completePositionActionPlan`。服务端使用 `source_revisions` 判断上游是否被重做,以避免将过期总结当作当前交付物。 + +## 5. 文件预览沙箱 + +页面只维护一个 `sandboxArtifact`。`PositionNodeChat` 和 `PositionBuiltinAgentChat` 通过 `onPreviewArtifact` 上报预览目标;父页面统一渲染 `ArtifactFileDetail`。 + +设计要点: + +- 文件正在由 `write_file` 流式生成时,优先显示源码;完成后交由预览组件切换 Markdown 预览; +- `sandboxThreadSnapshot` 有 160ms 合并节流,只保留当前打开文件所在线程的最新快照,避免流式时频繁 setState; +- 用户手动关闭后,`closedSandboxKeyRef` 与 `suppressedAutoPreviewThreadIdsRef` 阻止同一线程立即把沙箱重新打开; +- 切岗位、切节点、切收口阶段、新建会话和切换历史会话都会先调用 `closeSandbox()`。 + +不要在节点组件中直接操作全局 `ArtifactsContext` 或创建第二个 `ArtifactFileDetail`,否则会出现预览重复、关闭失效和更新深度异常。 + +## 6. 输入框隔离约定 + +岗位会商有两类输入框,均应只在 `src/position-roundtable/components/` 内调整: + +| 组件 | 使用范围 | 调整原则 | +| --- | --- | --- | +| `PositionIntentInput` | 测试分析岗意图识别 | 完整自有布局;发送和阶段按钮均在同一输入框内。 | +| `PositionAgentInput` | 普通岗位节点、方案总结、行动规划 | 目前包装全局 `InputBox`,保留全部对话能力,同时提供 `position-roundtable-agent-input` 样式边界。 | + +禁止直接修改 `components/workspace/input-box` 或默认 `Step1Panel` 的输入样式来满足岗位会商需求。这会影响普通工作区、其他业务和原始多智能体会商。 + +## 7. API 契约 + +前端基地址:`/api/position-roundtable`。消息内容和真实产物仍由既有 LangGraph/API 保存,以下接口只保存岗位会商索引与快照。 + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| `GET` | `/sessions?external_task_id=` | 按任务查询会话,用于恢复最近会话和历史记录。 | +| `POST` | `/sessions` | 创建初始会话并保存任务快照。 | +| `GET` | `/sessions/{id}` | 恢复会话、节点和冻结快照。 | +| `PUT` | `/sessions/{id}/task` | 更新未冻结的任务快照。 | +| `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` | 保存回答、产物清单、上游版本并推进状态。 | +| `POST` | `/sessions/{id}/summary/*` | 准备、发送和完成方案总结。 | +| `POST` | `/sessions/{id}/action-plan/*` | 准备、发送和完成行动规划。 | + +后端实现在 `offline-backend-20260512/backend/app/gateway/routers/position_roundtable.py`。 + +## 8. 开发与排查清单 + +1. 新增岗位时:仅补充 `POSITION_OPTIONS`,并确保业务链席位的 `position_id` 使用稳定 ID;不要以显示名称作持久化键。 +2. 新增收口智能体时:同时补充前端 `PositionBuiltinAgentKind`、后端 prepare/complete API、解锁条件和 UI 切换项。 +3. 变更节点完成条件时:前后端必须一致。当前普通节点的完成条件是 Markdown 交付物,而不是“有一条回答”。 +4. 改预览逻辑时:始终通过父页面的 `onPreviewArtifact` / `closeSandbox` 入口;不要在子组件里新增沙箱状态。 +5. 变更输入框视觉时:只改对应的岗位专用输入组件,不要向全局 `InputBox` 添加岗位样式。 +6. 验证最少覆盖:新建会话、刷新恢复、切岗位、单节点多智能体切换、生成 Markdown、关闭后再次对话、历史会话、方案总结、行动规划。 + +## 9. 构建验证 + +```powershell +cd frontend-web +pnpm build +``` + +提交前还应执行: + +```powershell +git diff --check +``` diff --git a/frontend-web/src/position-roundtable/api/sessions.ts b/frontend-web/src/position-roundtable/api/sessions.ts index 753edc47..3e6cc070 100644 --- a/frontend-web/src/position-roundtable/api/sessions.ts +++ b/frontend-web/src/position-roundtable/api/sessions.ts @@ -7,6 +7,10 @@ import { apiFetch } from "@/core/api/fetch-client"; import { getBackendBaseURL } from "@/core/config"; +/** + * 节点状态由岗位会商服务推进;前端只据此决定可否发送、展示进度, + * 不应在组件内自行把节点改成 done 或 ready。 + */ export type PositionNodeStatus = | "locked" | "ready" @@ -16,6 +20,12 @@ export type PositionNodeStatus = | "rejected" | "error"; +/** + * 冻结业务链中的一个席位。 + * + * `threadId` 指向真实的 LangGraph 对话;节点表只承担“席位 ↔ 线程”索引、 + * 上游版本与交付清单的职责,绝不保存完整聊天消息。 + */ export interface PositionRoundtableNode { id: string; sessionId: string; @@ -37,6 +47,12 @@ export interface PositionRoundtableNode { updatedAt?: string; } +/** + * 岗位会商会话的可恢复快照。 + * + * 任务、意图与链条在启动后被分别冻结,避免外部任务或链条配置发生变化时 + * 改写已经开始的协同过程。`nodes` 只在详情接口中携带。 + */ export interface PositionRoundtableSession { id: string; externalTaskId: string | null; @@ -104,6 +120,10 @@ async function readJson(response: Response, label: string): Promise { return response.json() as Promise; } +/** + * API 使用 snake_case,页面使用 camelCase。所有字段映射集中在这里, + * 组件禁止直接依赖后端字段名,避免 API 演进时遗漏状态字段。 + */ function toNode(node: ServerNode): PositionRoundtableNode { return { id: node.id, @@ -127,6 +147,7 @@ function toNode(node: ServerNode): PositionRoundtableNode { }; } +/** 同上:会话快照在这一层完成默认值填充与字段转换。 */ function toSession(session: ServerSession): PositionRoundtableSession { const result: PositionRoundtableSession = { id: session.id, @@ -222,6 +243,7 @@ export async function activatePositionSession( sessionId: string, chainId: string, ): Promise { + // 启动即冻结链条快照并创建节点;后续页面不得用当前编辑中的链条替换它。 const result = await readJson( await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, { method: "POST", @@ -262,7 +284,12 @@ export async function bindPositionNodeThread( return toNode(result); } -/** Fetch the bounded hidden task/intent/upstream handoff before one node turn. */ +/** + * 在普通节点发送前取得受长度限制的隐藏交接上下文。 + * + * 该上下文会作为 `task_external_context` 发送给 LangGraph,而不是渲染在聊天 + * 消息列表中;这样既能让节点承接上游,又不会把整段交接材料重复展示给用户。 + */ export async function getPositionNodeContext( sessionId: string, nodeKey: string, @@ -285,6 +312,7 @@ export async function completePositionNodeTurn( upstreamRevision?: Record | null; }, ): Promise { + // 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。 const result = await readJson( await apiFetch( `${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/complete-turn`, @@ -358,6 +386,7 @@ async function prepareBuiltinRun( kind: BuiltinKind, message = "", ): Promise { + // prepare 阶段固定本轮的上游版本与隐藏提示词,避免总结/行动规划引用漂移数据。 const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare"; const result = await readJson( await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { @@ -381,6 +410,7 @@ async function completeBuiltinRun( model?: string; }, ): Promise { + // complete 阶段会校验 sourceRevisions;上游已变化时不能把旧结果覆盖为最新交付。 const path = kind === "summary" ? "summary/complete" : "action-plan/complete"; const result = await readJson( await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, { diff --git a/frontend-web/src/position-roundtable/components/PositionAgentInput.tsx b/frontend-web/src/position-roundtable/components/PositionAgentInput.tsx new file mode 100644 index 00000000..371c6948 --- /dev/null +++ b/frontend-web/src/position-roundtable/components/PositionAgentInput.tsx @@ -0,0 +1,25 @@ +import type { ComponentProps } from "react"; + +import { InputBox } from "@/components/workspace/input-box"; +import { cn } from "@/lib/utils"; + +/** + * 岗位会商的岗位智能体输入框。 + * + * 目前保留 InputBox 的完整交互能力;后续本页面需要独立调整视觉或工具栏时, + * 只改这个组件即可,不会影响普通对话、工作区或其他业务页面。 + */ +export function PositionAgentInput({ + className, + ...props +}: ComponentProps) { + // 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. + return ( + + ); +} diff --git a/frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx b/frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx index 843c7d35..b95285e2 100644 --- a/frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx +++ b/frontend-web/src/position-roundtable/components/PositionBuiltinAgentChat.tsx @@ -4,7 +4,6 @@ import { Bot, CheckCircle2, FileText, LockKeyhole, Sparkles, Target } from "luci import { toast } from "sonner"; import type { PromptInputMessage } from "@/components/ai-elements/prompt-input"; -import { InputBox } from "@/components/workspace/input-box"; import { MessageList } from "@/components/workspace/messages"; import { ThreadContext } from "@/components/workspace/messages/context"; import { useAgent } from "@/core/agents"; @@ -19,6 +18,7 @@ import { PositionBuiltinAgentTabs, type PositionBuiltinAgentSwitchOption, } from "@/position-roundtable/components/PositionAgentSwitcher"; +import { PositionAgentInput } from "@/position-roundtable/components/PositionAgentInput"; import { completePositionActionPlan, completePositionSummary, @@ -76,6 +76,12 @@ 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; @@ -166,40 +172,6 @@ function artifactDisplayName(pathOrUrl: string): string { return path.split("/").filter(Boolean).at(-1) || "Markdown 报告"; } -function buildWriteFileVirtualUrl(path: string, messageId: string, toolCallId: string): string { - return new URL( - `write-file:${path}?message_id=${encodeURIComponent( - messageId, - )}&tool_call_id=${encodeURIComponent(toolCallId)}`, - ).toString(); -} - -function latestStreamingWriteFile( - messages: AgentThreadState["messages"], -): { path: string; name: string; mimeType: string | null } | null { - for (let messageIndex = messages.length - 1; messageIndex >= 0; messageIndex -= 1) { - const message = messages[messageIndex] as (typeof messages)[number] & { - id?: string; - tool_calls?: Array<{ id?: string; name?: string; args?: Record }>; - }; - if (message.type !== "ai" || !message.id || !Array.isArray(message.tool_calls)) continue; - for (let toolIndex = message.tool_calls.length - 1; toolIndex >= 0; toolIndex -= 1) { - const toolCall = message.tool_calls[toolIndex]; - if (toolCall?.name !== "write_file" || !toolCall.id) continue; - const rawPath = toolCall.args?.path; - if (typeof rawPath !== "string" || !rawPath.trim()) continue; - const content = toolCall.args?.content; - if (typeof content !== "string" || content.length === 0) continue; - return { - path: buildWriteFileVirtualUrl(rawPath, message.id, toolCall.id), - name: artifactDisplayName(rawPath), - mimeType: rawPath.toLowerCase().endsWith(".md") ? "text/markdown" : null, - }; - } - } - return null; -} - function latestAssistantText(messages: AgentThreadState["messages"]): string { for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; @@ -261,7 +233,6 @@ export function PositionBuiltinAgentChat({ const [pendingSubmission, setPendingSubmission] = useState(null); const pendingSubmissionRef = useRef(false); - const autoPreviewedArtifactKeyRef = useRef(null); const handledAutoStartRequestRef = useRef(undefined); useEffect(() => { @@ -272,7 +243,6 @@ export function PositionBuiltinAgentChat({ pendingRunRef.current = null; pendingSubmissionRef.current = false; setPendingSubmission(null); - autoPreviewedArtifactKeyRef.current = null; handledAutoStartRequestRef.current = undefined; }, [fallbackAgentId, kind, persistedThreadId, session?.id]); @@ -294,6 +264,7 @@ export function PositionBuiltinAgentChat({ const [thread, sendMessage, , actualThreadId] = useThreadStream({ threadId: boundThreadId, assistantId, + runOptions: POSITION_BUILTIN_RUN_OPTIONS, context: { ...settings.context, agent_id: assistantId, @@ -314,8 +285,8 @@ export function PositionBuiltinAgentChat({ onFinish: (state) => { const pending = pendingRunRef.current; if (!pending || !session) return; - const latestAnswer = latestAssistantText(state.messages ?? []); - if (!latestAnswer) return; + const content = latestAssistantText(state.messages ?? []); + if (!content) return; pendingRunRef.current = null; void (async () => { let artifactManifest: Array> = []; @@ -323,23 +294,22 @@ export function PositionBuiltinAgentChat({ const artifacts = await listSessionArtifacts(pending.threadId); artifactManifest = manifestFromArtifacts(artifacts.files); } catch { - // Keep the text turn usable even if the artifact endpoint is briefly - // unavailable; the completion API will surface a clearer validation - // message if an output file is genuinely missing. + // Completion still receives the visible answer; it will give a + // clearer error if a required deliverable is genuinely missing. } try { const next = kind === "summary" ? await completePositionSummary(session.id, { threadId: pending.threadId, - content: latestAnswer, + content, artifactManifest, sourceRevisions: pending.sourceRevisions, model: pending.model, }) : await completePositionActionPlan(session.id, { threadId: pending.threadId, - content: latestAnswer, + content, artifactManifest, sourceRevisions: pending.sourceRevisions, model: pending.model, @@ -374,25 +344,6 @@ export function PositionBuiltinAgentChat({ onThreadSnapshot?.({ threadId: activeThreadId, thread }); }, [activeThreadId, onThreadSnapshot, thread]); - const streamingArtifact = useMemo( - () => (thread.isLoading ? latestStreamingWriteFile(thread.messages) : null), - [thread.isLoading, thread.messages], - ); - - useEffect(() => { - if (!thread.isLoading || !streamingArtifact) return; - const artifactKey = `${activeThreadId}:${streamingArtifact.path}`; - if (autoPreviewedArtifactKeyRef.current === artifactKey) return; - autoPreviewedArtifactKeyRef.current = artifactKey; - onPreviewArtifact?.({ - threadId: activeThreadId, - path: streamingArtifact.path, - name: streamingArtifact.name, - mimeType: streamingArtifact.mimeType, - auto: true, - }); - }, [activeThreadId, onPreviewArtifact, streamingArtifact, thread.isLoading]); - const ensureBuiltinThread = useCallback( async (preparedThreadId: string | null, preparedAgentId: string) => { if (preparedThreadId) { @@ -437,10 +388,9 @@ export function PositionBuiltinAgentChat({ sourceRevisions: preparation.sourceRevisions, model: selectedModel || undefined, }; - // `useThreadStream` is still attached to the previous (or empty) - // thread during this render. Queue the first turn and dispatch it - // only after the hook has rebound to the prepared thread; otherwise - // the UI can show the optimistic user bubble without opening a run. + // The standard chat stream must first bind to the prepared thread. + // The effect below then submits the same visible message a manual + // click would send, so this flow has no separate presentation layer. setPendingSubmission({ threadId: builtinThread.threadId, agentId: preparation.agentId, @@ -449,9 +399,10 @@ export function PositionBuiltinAgentChat({ sourceRevisions: preparation.sourceRevisions, }); } catch (error) { + const message = error instanceof Error ? error.message : String(error); pendingRunRef.current = null; pendingSubmissionRef.current = false; - toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`); + toast.error(`发送失败:${message}`); } }, [ @@ -538,6 +489,9 @@ export function PositionBuiltinAgentChat({ ) { return; } + // The request key changes only after an explicit page action. Keeping the + // handled key locally prevents route/session re-renders from auto-sending + // the first question again. handledAutoStartRequestRef.current = autoStartRequestKey; void handleSubmit({ text: defaultStartText(kind), files: [] }); }, [autoStartRequestKey, canWrite, handleSubmit, kind, snapshotDone, thread.isLoading]); @@ -655,7 +609,7 @@ export function PositionBuiltinAgentChat({ )}
- (null); + + return ( +
+
+ {attachedFiles.length > 0 && ( +
+ {attachedFiles.map((file, index) => ( + + + + {file.name} + + {formatFileSize(file.size)} + + + ))} +
+ )} + + onValueChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + onSend(); + } + }} + placeholder={ + pendingClarification + ? "也可在此输入自定义回答(Enter 发送 / Shift+Enter 换行)" + : "请输入补充信息 / 回答问题 / 继续澄清(Enter 发送 / Shift+Enter 换行)" + } + className="min-h-[74px] w-full max-h-80 bg-transparent px-3 py-2.5 text-sm font-normal leading-relaxed text-slate-700 placeholder:text-slate-400 focus:outline-none dark:text-[#e7f0ff] dark:placeholder:text-[#7e9bc5]" + maxHeight={320} + maxLength={500} + /> + +
+
+ + { + onAddFiles(Array.from(event.target.files ?? [])); + event.target.value = ""; + }} + /> + +
+ {charactersLeft}/500 +
+ {/* + All stage actions intentionally stay inside the same composer frame. + Parent pages can still hide optional actions by their stable ids + without changing this component's layout or global input styles. + */} +
+
+ { + onAddFiles(Array.from(event.target.files ?? [])); + event.target.value = ""; + }} + /> + +
+
+ + + {isSending && ( + + )} + + + + + + {footerAction ?? + (!hideEnterRoundtable && ( + + ))} + +
+
+
+
+ ); +} diff --git a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx index 06d05185..fe39f266 100644 --- a/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx +++ b/frontend-web/src/position-roundtable/components/PositionNodeChat.tsx @@ -1,10 +1,9 @@ import type { BaseStream } from "@langchain/langgraph-sdk/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Bot, FileText, LockKeyhole } from "lucide-react"; +import { Bot, FileText, LockKeyhole, Settings } from "lucide-react"; import { toast } from "sonner"; -import { Select as TSelect } from "tdesign-react"; +import { Button as TButton, Select as TSelect } from "tdesign-react"; 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 { useAgent } from "@/core/agents"; @@ -26,6 +25,7 @@ import { type PositionBuiltinAgentKind, type PositionBuiltinAgentSwitchOption, } from "@/position-roundtable/components/PositionAgentSwitcher"; +import { PositionAgentInput } from "@/position-roundtable/components/PositionAgentInput"; interface PositionNodeSwitchOption { node: PositionRoundtableNode; @@ -45,6 +45,8 @@ interface PositionNodeChatProps { onSelectPositionNode?: (node: PositionRoundtableNode) => void; builtinAgentOptions?: PositionBuiltinAgentSwitchOption[]; onSelectBuiltinAgent?: (kind: PositionBuiltinAgentKind) => void; + /** Rendered in the header so the wide same-position selector never overlaps it. */ + onOpenConfiguration?: () => void; onNodeUpdate: (node: PositionRoundtableNode) => void; /** The workspace collapses the configuration panel on the first real turn. */ onConversationStart?: () => void; @@ -183,7 +185,7 @@ function latestStreamingWriteFile( * Direct single-agent chat for one frozen business-chain node. * * It deliberately uses the regular `useThreadStream` + `MessageList` + - * `InputBox` stack. The position Session merely records which existing thread + * `PositionAgentInput` stack. The position Session merely records which existing thread * belongs to the node; it is not a second message persistence mechanism. */ export function PositionNodeChat({ @@ -198,6 +200,7 @@ export function PositionNodeChat({ onSelectPositionNode, builtinAgentOptions, onSelectBuiltinAgent, + onOpenConfiguration, onNodeUpdate, onConversationStart, onPreviewArtifact, @@ -233,6 +236,8 @@ export function PositionNodeChat({ const bindCreatedThread = useCallback( (threadId: string) => { + // Persist as soon as the SDK exposes an id. A refresh after this point + // can restore the exact seat thread instead of starting a second chat. boundThreadIdRef.current = threadId; setBoundThreadId(threadId); void bindPositionNodeThread(sessionId, node.nodeKey, threadId) @@ -265,6 +270,9 @@ export function PositionNodeChat({ }, onStart: bindCreatedThread, onFinish: (state) => { + // A turn is not considered delivered by the UI alone. The persisted + // completion call records the answer, file manifest and upstream version + // so the service can decide whether this seat unlocks its downstream. const threadId = boundThreadIdRef.current; const latestAi = [...(state.messages ?? [])] .reverse() @@ -364,6 +372,8 @@ export function PositionNodeChat({ useEffect(() => { if (!thread.isLoading || !streamingArtifact) return; + // One write_file call should open at most one preview. The parent further + // guards manual close state, because it owns the page-wide singleton sandbox. const artifactKey = `${activeThreadId}:${streamingArtifact.path}`; if (autoPreviewedArtifactKeyRef.current === artifactKey) return; autoPreviewedArtifactKeyRef.current = artifactKey; @@ -400,6 +410,9 @@ export function PositionNodeChat({ } try { onConversationStart?.(); + // Fetch handoff immediately before sending rather than when the page + // mounts. This guarantees the prompt sees the newest completed upstream + // delivery and captures the matching revision for completion checks. const taskExternalContext = await getPositionNodeContext(sessionId, node.nodeKey); const upstreamRevision = taskExternalContext.upstream_revision; upstreamRevisionRef.current = @@ -491,6 +504,17 @@ export function PositionNodeChat({ onSelect={onSelectBuiltinAgent} /> )} + {onOpenConfiguration && ( + } + onClick={onOpenConfiguration} + title="打开右侧配置" + /> + )} {nodeArtifacts.length > 0 && ( @@ -555,7 +579,7 @@ export function PositionNodeChat({ )}
- position.id === value) + ? (value as PositionId) + : DEFAULT_POSITION_ID; +} + function normalizePreviewArtifactPath(path: string): string { let normalized = path.trim(); if (normalized.startsWith("write-file:")) { @@ -479,7 +490,8 @@ export default function PositionRoundtablePage({ const [searchParams, setSearchParams] = useSearchParams(); const { theme } = useTheme(); const isDark = theme === "dark"; - const [positionId, setPositionId] = useState(DEFAULT_POSITION_ID); + const positionIdFromUrl = resolvePositionId(searchParams.get("positionId")); + const [positionId, setPositionId] = useState(() => positionIdFromUrl); const taskIdFromUrl = searchParams.get("taskId") ?? DEFAULT_MOCK_POSITION_TASK_ID; const searchParamsString = searchParams.toString(); const fallbackTask = useMemo(() => getMockPositionTask(taskIdFromUrl), [taskIdFromUrl]); @@ -495,6 +507,9 @@ export default function PositionRoundtablePage({ const [historyMenuOpen, setHistoryMenuOpen] = useState(false); const [historySessions, setHistorySessions] = useState([]); const [historyLoading, setHistoryLoading] = useState(false); + // The page owns exactly one artifact preview. Node and built-in chat panels + // only report preview intents upward; this prevents two sandboxes from + // rendering the same streaming file and avoids close/reopen feedback loops. const artifactsCtx = useArtifactsOptional(); const artifactsCtxRef = useRef(artifactsCtx); const [sandboxArtifact, setSandboxArtifact] = useState(null); @@ -1112,6 +1127,9 @@ export default function PositionRoundtablePage({ }, [intent]); const handleActivateChain = useCallback(async () => { + // Activation is the transaction boundary between editable intent discovery + // and a frozen execution graph. Validate all local configuration first so + // users receive a clear configuration error instead of a failed node chat. if (startingChain) return; if (!selectedChain) { toast.warning("请先选择业务链条"); @@ -1239,6 +1257,8 @@ export default function PositionRoundtablePage({ ); const handlePreviewArtifact = useCallback((artifact: PositionArtifactPreview) => { + // Manual opens clear the suppression marker; auto opens respect it so a + // user-closed sandbox is not reopened by the next streaming state update. if (!artifact.threadId || !artifact.path) { toast.warning("该文件缺少沙箱预览所需的信息"); return; @@ -1382,6 +1402,8 @@ export default function PositionRoundtablePage({ }, []); const handleSelectBuiltinStage = useCallback( (kind: ClosureStageKind) => { + // The closing agents are part of the testing-analysis workspace only; + // ordinary positions keep a single business-chain node conversation. if (positionId !== DEFAULT_POSITION_ID) { toast.info("收口智能体仅在测试分析岗中开放使用。"); return; @@ -1412,6 +1434,18 @@ export default function PositionRoundtablePage({ }, [closeSandbox], ); + useEffect(() => { + // Support both an initial deep link and browser URL changes. Reset every + // view-specific selection so no node, drawer or sandbox leaks across jobs. + if (positionIdFromUrl === positionId) return; + closeSandbox(); + setPositionId(positionIdFromUrl); + setActiveBuiltinKind(null); + setSelectedAgentId(null); + setActiveNodeKey(null); + setRightPanelOpen(positionIdFromUrl === DEFAULT_POSITION_ID); + setLeftPanelCollapsed(false); + }, [closeSandbox, positionId, positionIdFromUrl]); const handlePositionChange = useCallback( (value: unknown) => { // 右侧详情只属于刚才的岗位;切岗后回到该岗位的智能体列表, @@ -1425,8 +1459,11 @@ export default function PositionRoundtablePage({ setActiveNodeKey(null); setRightPanelOpen(nextPositionId === DEFAULT_POSITION_ID); setLeftPanelCollapsed(false); + const next = new URLSearchParams(searchParamsString); + next.set("positionId", nextPositionId); + setSearchParams(next, { replace: true }); }, - [closeSandbox], + [closeSandbox, searchParamsString, setSearchParams], ); const renderIntentChatPanel = (chainStarted: boolean) => (
@@ -1438,6 +1475,8 @@ export default function PositionRoundtablePage({
)} + {/* Step1Panel retains intent state; only the composer is replaced so this + workspace can evolve its visual language without changing other Step 1 pages. */} @@ -1484,7 +1523,7 @@ export default function PositionRoundtablePage({ type="button" onClick={() => handleSelectBuiltinStage(summaryDone ? "action_plan" : "summary")} className={cn( - "flex min-h-[44px] items-center gap-1.5 rounded-xl px-4 py-2.5 text-xs font-black text-white shadow-md transition active:scale-95", + "flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] px-3 text-xs font-black text-white shadow-md transition active:scale-[0.98]", summaryDone ? "bg-sky-600 shadow-sky-500/10 hover:bg-sky-700" : "bg-emerald-600 shadow-emerald-500/10 hover:bg-emerald-700", @@ -1499,6 +1538,7 @@ export default function PositionRoundtablePage({ ) : undefined } + inputArea={(inputProps) => } onManualSave={() => undefined} onTriggerToast={triggerToast} submitIntentClarifications={intent.submitIntentClarifications} @@ -1654,7 +1694,7 @@ export default function PositionRoundtablePage({
{/* Bottom input area */} + {inputArea ? ( + inputArea({ + value: inputText, + onValueChange: setInputText, + attachedFiles, + onAddFiles: addAttachedFiles, + onRemoveFile: removeAttachedFile, + charactersLeft, + isSending: isIntentSending, + canSend: !isIntentSending && Boolean(intentThreadId), + pendingClarification: Boolean(pendingIntentClarificationMsgId), + onSend: handleSend, + onStop: onStopIntent, + onFollowUp, + onSkip: onSkipQuestion, + onEnterRoundtable, + enterButtonLabel, + intentReady, + footerAction, + hideEnterRoundtable, + onManualSave, + onTriggerToast, + }) + ) : (
{/* 已选附件预览(点发送时随消息上传) */} @@ -366,6 +428,7 @@ export function Step1Panel(props: Step1PanelProps) {
+ )}
); } diff --git a/frontend-web/src/styles/globals.css b/frontend-web/src/styles/globals.css index c42fcc42..e03014a3 100644 --- a/frontend-web/src/styles/globals.css +++ b/frontend-web/src/styles/globals.css @@ -616,6 +616,11 @@ padding-box; } + /* 岗位会商左侧总面板:仅收窄滚动条宽度,沿用全局既有的滑块颜色、圆角和轨道样式。 */ + .position-roundtable-sidebar-scrollbar::-webkit-scrollbar { + width: 8px; + } + .workspace-shell { background: #ffffff; }