代码修改
This commit is contained in:
1 parent
56740ecf56
commit
b60d9ed09e
10 files changed
+691
-96
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -616,6 +616,11 @@
|
||||
padding-box;
|
||||
}
|
||||
|
||||
/* 岗位会商左侧总面板:仅收窄滚动条宽度,沿用全局既有的滑块颜色、圆角和轨道样式。 */
|
||||
.position-roundtable-sidebar-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.workspace-shell {
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user