代码修改

This commit is contained in:
董宏凯 committed 2026-07-23 14:29:12 +08:00
1 parent 56740ecf56
commit b60d9ed09e
10 files changed
+691 -96

No files matched your search

+26 -14
View File
@@ -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<string, unknown>;
@@ -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
@@ -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
```
@@ -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<T>(response: Response, label: string): Promise<T> {
return response.json() as Promise<T>;
}
/**
* 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<PositionRoundtableSession> {
// 启动即冻结链条快照并创建节点;后续页面不得用当前编辑中的链条替换它。
const result = await readJson<ServerSession>(
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<string, unknown> | null;
},
): Promise<PositionRoundtableNode> {
// 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。
const result = await readJson<ServerNode>(
await apiFetch(
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/complete-turn`,
@@ -358,6 +386,7 @@ async function prepareBuiltinRun(
kind: BuiltinKind,
message = "",
): Promise<PositionBuiltinRunPreparation> {
// prepare 阶段固定本轮的上游版本与隐藏提示词,避免总结/行动规划引用漂移数据。
const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare";
const result = await readJson<ServerBuiltinRunPreparation>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
@@ -381,6 +410,7 @@ async function completeBuiltinRun(
model?: string;
},
): Promise<PositionRoundtableSession> {
// complete 阶段会校验 sourceRevisions;上游已变化时不能把旧结果覆盖为最新交付。
const path = kind === "summary" ? "summary/complete" : "action-plan/complete";
const result = await readJson<ServerSession>(
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
@@ -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<typeof InputBox>) {
// 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 (
<InputBox
{...props}
className={cn("position-roundtable-agent-input w-full", className)}
/>
);
}
@@ -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<string, unknown> }>;
};
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<PendingBuiltinSubmission | null>(null);
const pendingSubmissionRef = useRef(false);
const autoPreviewedArtifactKeyRef = useRef<string | null>(null);
const handledAutoStartRequestRef = useRef<number | undefined>(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<Record<string, unknown>> = [];
@@ -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({
)}
<div className="shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
<InputBox
<PositionAgentInput
className="w-full"
isNewThread={!boundThreadId}
threadId={displayThreadId}
@@ -0,0 +1,240 @@
import { useRef } from "react";
import {
FileText,
Loader2,
Paperclip,
Pause,
Repeat,
Save,
Send,
Smile,
Sparkles,
X,
} from "lucide-react";
import { AutoGrowTextarea } from "@/components/ui/auto-grow-textarea";
import type { Step1InputAreaProps } from "@/roundtable-planning/components/Step1Panel";
/** Keep attachment metadata presentation local to this position-only composer. */
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/**
* 岗位会商的意图理解输入框。
*
* 它只服务岗位会商页,通过 Step1Panel 的输入插槽接入;视觉调整不会回流到
* 原有多智能体会商 Step 1 页面。
*/
export function PositionIntentInput({
value,
onValueChange,
attachedFiles,
onAddFiles,
onRemoveFile,
charactersLeft,
isSending,
canSend,
pendingClarification,
onSend,
onStop,
onFollowUp,
onSkip,
onEnterRoundtable,
enterButtonLabel,
intentReady,
footerAction,
hideEnterRoundtable,
onManualSave,
onTriggerToast,
}: Step1InputAreaProps) {
// Files are owned by Step1Panel. This ref only opens the native picker and
// forwards its selection through onAddFiles, so replacement layouts do not
// accidentally create a second attachment state.
const fileInputRef = useRef<HTMLInputElement>(null);
return (
<div className="position-roundtable-intent-input shrink-0 border-t border-slate-200 bg-[#f7faff] px-4 py-3 dark:border-[#2b8ffe]/35 dark:bg-[#071a38]">
<div className="relative overflow-hidden rounded-[4px] border border-slate-300 bg-white shadow-[0_2px_10px_rgba(15,23,42,0.08)] transition-all focus-within:border-primary focus-within:ring-2 focus-within:ring-[var(--brand-200)]/45 dark:border-[#2b8ffe]/55 dark:bg-[#0d2855] dark:shadow-none dark:focus-within:border-[#56baff] dark:focus-within:ring-[#2b8ffe]/25">
{attachedFiles.length > 0 && (
<div className="mx-3 mb-2 flex flex-wrap gap-1.5 border-b border-slate-100 pb-2 pt-2.5 dark:border-[#2b8ffe]/25">
{attachedFiles.map((file, index) => (
<span
key={`${file.name}-${file.size}-${index}`}
className="inline-flex max-w-[200px] items-center gap-1.5 rounded-[4px] border border-slate-200 bg-slate-50 py-1 pl-2 pr-1 text-[11px] text-slate-600 dark:border-[#2b8ffe]/30 dark:bg-[#102f63] dark:text-[#c4dcff]"
>
<FileText className="size-3 shrink-0 text-primary" />
<span className="truncate font-medium" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-slate-400">{formatFileSize(file.size)}</span>
<button
type="button"
onClick={() => onRemoveFile(index)}
className="shrink-0 rounded-[3px] p-0.5 text-slate-400 transition hover:bg-slate-200 hover:text-slate-600 dark:text-[#8aaee0] dark:hover:bg-[#21447c] dark:hover:text-white"
title="移除"
>
<X className="size-3" />
</button>
</span>
))}
</div>
)}
<AutoGrowTextarea
id="input-text-area"
value={value}
onChange={(event) => 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}
/>
<div className="hidden">
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => onTriggerToast("表情包功能仍在微调状态(仅供演示)")}
aria-label="打开表情"
className="rounded-[3px] p-1.5 transition hover:bg-white hover:text-primary dark:hover:bg-[#16386e] dark:hover:text-[#73c8ff]"
>
<Smile className="size-4" />
</button>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
onAddFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="上传参考文件(智能体将读取文件内容辅助理解任务)"
className="rounded-[3px] p-1.5 transition hover:bg-white hover:text-primary dark:hover:bg-[#16386e] dark:hover:text-[#73c8ff]"
>
<Paperclip className="size-4" />
</button>
</div>
<span className="font-mono text-[10px] text-slate-400">{charactersLeft}/500</span>
</div>
{/*
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.
*/}
<div className="flex flex-wrap items-center gap-3 border-t border-slate-100 bg-white px-3 py-2.5 dark:border-[#2b8ffe]/25 dark:bg-[#0b234c]">
<div className="flex shrink-0 items-center">
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(event) => {
onAddFiles(Array.from(event.target.files ?? []));
event.target.value = "";
}}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
title="上传参考文件"
aria-label="上传参考文件"
className="flex size-10 items-center justify-center rounded-[4px] border border-slate-200 bg-white text-slate-500 transition hover:border-primary/45 hover:text-primary dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b] dark:hover:text-[#73c8ff]"
>
<Paperclip className="size-4" />
</button>
</div>
<div className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-1.5">
<button
id="send-message-btn"
type="button"
onClick={onSend}
disabled={!canSend}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] bg-primary px-3 text-xs font-bold text-white shadow-[0_2px_8px_rgba(245,34,45,0.15)] transition-all active:scale-[0.98] hover:bg-[var(--brand-900)] disabled:cursor-not-allowed disabled:bg-slate-300 dark:bg-[#1478e8] dark:hover:bg-[#2995ff] dark:disabled:bg-[#28476f]"
>
{isSending ? <Loader2 className="size-3 animate-spin" /> : <Send className="size-3" />}
{isSending ? "发送中" : "发送"}
</button>
{isSending && (
<button
id="pause-intent-btn"
type="button"
onClick={onStop}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-amber-200/40 bg-amber-100 px-3 text-xs font-bold text-amber-800 shadow-sm transition active:scale-[0.98] hover:bg-amber-200 dark:border-amber-300/30 dark:bg-amber-400/15 dark:text-amber-200 dark:hover:bg-amber-400/25"
>
<Pause className="size-3" />
暂停
</button>
)}
<button
id="suggest-followup-btn"
type="button"
onClick={onFollowUp}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
>
<Repeat className="size-3 text-slate-500" />
继续追问
</button>
<button
id="skip-question-btn"
type="button"
onClick={onSkip}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
>
<X className="size-3 text-slate-500" strokeWidth={2.5} />
跳过此项
</button>
{footerAction ??
(!hideEnterRoundtable && (
<button
id="enter-roundtable-btn"
type="button"
onClick={onEnterRoundtable}
disabled={!intentReady}
title={intentReady ? undefined : "请先完成任务意图澄清"}
className={`flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] px-3 text-xs font-black transition active:scale-[0.98] shadow-md shadow-emerald-500/10 ${
intentReady
? "cursor-pointer bg-emerald-600 text-white hover:bg-emerald-700"
: "cursor-not-allowed bg-emerald-600/40 text-white"
}`}
>
<Sparkles className="size-3.5 text-emerald-100" />
{enterButtonLabel}
</button>
))}
<button
id="save-draft-btn"
type="button"
onClick={onManualSave}
className="flex h-10 shrink-0 items-center gap-1.5 rounded-[4px] border border-slate-200 bg-white px-2.5 text-xs font-bold text-slate-700 transition hover:bg-slate-50 dark:border-[#2b8ffe]/35 dark:bg-[#102f63] dark:text-[#b9d3f7] dark:hover:bg-[#1a417b]"
>
<Save className="size-3 text-slate-500" />
保存草稿
</button>
</div>
</div>
</div>
</div>
);
}
@@ -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 && (
<TButton
size="small"
variant="outline"
shape="circle"
className="!h-8 !w-8 !min-w-8 !shrink-0 !p-0 !bg-white/90 dark:!border-[#2b8ffe]/55 dark:!bg-[#082d5f]/90"
icon={<Settings className="!size-4 !shrink-0" />}
onClick={onOpenConfiguration}
title="打开右侧配置"
/>
)}
</header>
{nodeArtifacts.length > 0 && (
@@ -555,7 +579,7 @@ export function PositionNodeChat({
)}
<div className="shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
<InputBox
<PositionAgentInput
className="w-full"
isNewThread={!boundThreadId}
threadId={displayThreadId}
@@ -73,6 +73,7 @@ import { cn } from "@/lib/utils";
import expandLogo from "@/assets/position-roundtable/expand-logo.svg";
import { listChains } from "@/roundtable-planning/api/chains";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
import { PositionNodeChat } from "@/position-roundtable/components/PositionNodeChat";
import { getAvatar } from "@/roundtable-planning/components/Avatars";
import { useStep1Intent, type InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent";
@@ -144,6 +145,16 @@ type SandboxThreadSnapshot = {
signature: string;
};
/**
* URL parameters are externally supplied, so never cast the raw value into
* page state without checking it against the position directory first.
*/
function resolvePositionId(value: string | null): PositionId {
return POSITIONS.some((position) => 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<PositionId>(DEFAULT_POSITION_ID);
const positionIdFromUrl = resolvePositionId(searchParams.get("positionId"));
const [positionId, setPositionId] = useState<PositionId>(() => 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<PositionRoundtableSession[]>([]);
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<PositionArtifactPreview | null>(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) => (
<div className="contents">
@@ -1438,6 +1475,8 @@ export default function PositionRoundtablePage({
</TButton>
</div>
)}
{/* Step1Panel retains intent state; only the composer is replaced so this
workspace can evolve its visual language without changing other Step 1 pages. */}
<Step1Panel
headerLeftSlot={
<div className="flex min-w-0 items-center gap-2">
@@ -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({
</button>
) : undefined
}
inputArea={(inputProps) => <PositionIntentInput {...inputProps} />}
onManualSave={() => undefined}
onTriggerToast={triggerToast}
submitIntentClarifications={intent.submitIntentClarifications}
@@ -1654,7 +1694,7 @@ export default function PositionRoundtablePage({
<aside
aria-hidden={leftSidebarHidden || undefined}
className={cn(
"custom-scrollbar relative flex min-h-0 shrink-0 flex-col gap-3 overflow-y-auto rounded-2xl border border-slate-200 bg-white shadow-sm transition-[width,opacity,transform,padding,border-width] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none dark:rounded-none dark:border-0 dark:bg-transparent dark:text-[#c5cfe0] dark:shadow-none",
"position-roundtable-sidebar-scrollbar custom-scrollbar relative flex min-h-0 shrink-0 flex-col gap-3 overflow-y-auto rounded-2xl border border-slate-200 bg-white shadow-sm transition-[width,opacity,transform,padding,border-width] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none dark:rounded-none dark:border-0 dark:bg-transparent dark:text-[#c5cfe0] dark:shadow-none",
leftSidebarHidden
? "w-0 translate-x-4 !border-0 !p-0 opacity-0 pointer-events-none"
: "w-[360px] translate-x-0 p-3 opacity-100 dark:p-0",
@@ -1714,7 +1754,10 @@ export default function PositionRoundtablePage({
)}
<section className="relative flex min-w-[420px] flex-1 flex-col overflow-hidden rounded-[4px] border border-slate-200 bg-white shadow-sm dark:border-[#2b8ffe]/45 dark:bg-[#032650]/80 dark:shadow-none dark:backdrop-blur-md">
{!isIntelligenceDeliveryOverview && !rightPanelOpen && !sandboxOpen && (
{!isIntelligenceDeliveryOverview &&
!rightPanelOpen &&
!sandboxOpen &&
!(positionSession && activeNode && !activeBuiltinKind) && (
<TButton
size="small"
variant="outline"
@@ -1759,6 +1802,9 @@ export default function PositionRoundtablePage({
onSelectPositionNode={handleSelectPositionNode}
builtinAgentOptions={showClosureAgents ? builtinAgentOptions : undefined}
onSelectBuiltinAgent={showClosureAgents ? handleSelectBuiltinStage : undefined}
onOpenConfiguration={
!rightPanelOpen && !sandboxOpen ? () => setRightPanelOpen(true) : undefined
}
onNodeUpdate={handleNodeUpdate}
onConversationStart={() => setRightPanelOpen(false)}
onPreviewArtifact={handlePreviewArtifact}
@@ -65,6 +65,35 @@ import type {
} from "@/roundtable-planning/api/multi-agent";
import type { IntentSummary } from "@/roundtable-planning/api/intent";
/**
* Step 1 输入区的受控数据与行为契约。
*
* 默认输入区继续由 Step1Panel 渲染;嵌入式页面可通过 `inputArea` 复用相同的
* 发送、附件和阶段按钮行为,同时拥有完全独立的 DOM 与视觉样式。
*/
export interface Step1InputAreaProps {
value: string;
onValueChange: (value: string) => void;
attachedFiles: File[];
onAddFiles: (files: File[]) => void;
onRemoveFile: (index: number) => void;
charactersLeft: number;
isSending: boolean;
canSend: boolean;
pendingClarification: boolean;
onSend: () => void;
onStop: () => void;
onFollowUp: () => void;
onSkip: () => void;
onEnterRoundtable: () => void;
enterButtonLabel: string;
intentReady: object | null;
footerAction?: React.ReactNode;
hideEnterRoundtable: boolean;
onManualSave: () => void;
onTriggerToast: (message: string) => void;
}
export interface Step1PanelProps {
// ─── State ────────────────────────────────────────────────────
/**
@@ -94,6 +123,11 @@ export interface Step1PanelProps {
footerAction?: React.ReactNode;
/** 已进入后续工作流时,隐藏默认的「进入研讨」动作。 */
hideEnterRoundtable?: boolean;
/**
* 仅替换输入区的渲染插槽。消息列表、意图状态及附件状态仍由 Step1Panel
* 统一管理,因此岗位会商定制样式时不会分叉或复制 Step 1 的业务逻辑。
*/
inputArea?: (props: Step1InputAreaProps) => React.ReactNode;
onManualSave: () => void;
onTriggerToast: (msg: string) => void;
submitIntentClarifications: (
@@ -122,6 +156,7 @@ export function Step1Panel(props: Step1PanelProps) {
enterButtonLabel = "进入多智能体研讨 >",
footerAction,
hideEnterRoundtable = false,
inputArea,
onManualSave,
onTriggerToast,
submitIntentClarifications,
@@ -144,8 +179,7 @@ export function Step1Panel(props: Step1PanelProps) {
const charactersLeft = inputText.length;
const handlePickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
const picked = Array.from(e.target.files ?? []);
const addAttachedFiles = (picked: File[]) => {
if (picked.length > 0) {
// 去重(按 名称+大小),追加到已选列表。
setAttachedFiles((prev) => {
@@ -153,6 +187,10 @@ export function Step1Panel(props: Step1PanelProps) {
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
});
}
};
const handlePickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
addAttachedFiles(Array.from(e.target.files ?? []));
// 清空 input 值,允许再次选同名文件。
e.target.value = "";
};
@@ -216,6 +254,30 @@ export function Step1Panel(props: Step1PanelProps) {
</div>
{/* 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,
})
) : (
<div className="shrink-0 p-4 border-t border-slate-100 bg-[#fcfcfd]">
<div className="relative border border-slate-200 focus-within:border-primary focus-within:ring-2 focus-within:ring-[var(--brand-200)]/50 transition-all rounded-xl overflow-hidden bg-white px-3 py-2.5">
{/* 已选附件预览(点发送时随消息上传) */}
@@ -366,6 +428,7 @@ export function Step1Panel(props: Step1PanelProps) {
</button>
</div>
</div>
)}
</div>
);
}
+5
View File
@@ -616,6 +616,11 @@
padding-box;
}
/* 岗位会商左侧总面板:仅收窄滚动条宽度,沿用全局既有的滑块颜色、圆角和轨道样式。 */
.position-roundtable-sidebar-scrollbar::-webkit-scrollbar {
width: 8px;
}
.workspace-shell {
background: #ffffff;
}