代码修改
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;
|
data: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ThreadStreamOptions = {
|
export type ThreadStreamOptions = {
|
||||||
threadId?: string | null | undefined;
|
threadId?: string | null | undefined;
|
||||||
context: LocalSettings["context"];
|
context: LocalSettings["context"];
|
||||||
isMock?: boolean;
|
isMock?: boolean;
|
||||||
assistantId?: string;
|
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 合并)。
|
/** 额外写到新建 thread 的 metadata(与 agent_id 合并)。
|
||||||
* 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */
|
* 任务深链用 `{ taskId }` 把对话挂到任务下(1:N),列表按 metadata.taskId 过滤。 */
|
||||||
threadMetadata?: Record<string, unknown>;
|
threadMetadata?: Record<string, unknown>;
|
||||||
@@ -405,9 +414,10 @@ function lastHumanFromChatModelInput(input: unknown): string {
|
|||||||
export function useThreadStream({
|
export function useThreadStream({
|
||||||
threadId,
|
threadId,
|
||||||
context,
|
context,
|
||||||
isMock,
|
isMock,
|
||||||
assistantId,
|
assistantId,
|
||||||
threadMetadata,
|
runOptions,
|
||||||
|
threadMetadata,
|
||||||
onStart,
|
onStart,
|
||||||
onFinish,
|
onFinish,
|
||||||
onToolEnd,
|
onToolEnd,
|
||||||
@@ -1037,11 +1047,13 @@ export function useThreadStream({
|
|||||||
{
|
{
|
||||||
messages: submittedMessages,
|
messages: submittedMessages,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
threadId: threadId,
|
threadId: threadId,
|
||||||
streamSubgraphs: true,
|
streamSubgraphs: true,
|
||||||
streamResumable: true,
|
streamResumable: true,
|
||||||
config: {
|
onDisconnect: runOptions?.onDisconnect,
|
||||||
|
multitaskStrategy: runOptions?.multitaskStrategy,
|
||||||
|
config: {
|
||||||
recursion_limit: 1000,
|
recursion_limit: 1000,
|
||||||
},
|
},
|
||||||
context: {
|
context: {
|
||||||
@@ -1076,7 +1088,7 @@ export function useThreadStream({
|
|||||||
sendInFlightRef.current = false;
|
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
|
// 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 { apiFetch } from "@/core/api/fetch-client";
|
||||||
import { getBackendBaseURL } from "@/core/config";
|
import { getBackendBaseURL } from "@/core/config";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点状态由岗位会商服务推进;前端只据此决定可否发送、展示进度,
|
||||||
|
* 不应在组件内自行把节点改成 done 或 ready。
|
||||||
|
*/
|
||||||
export type PositionNodeStatus =
|
export type PositionNodeStatus =
|
||||||
| "locked"
|
| "locked"
|
||||||
| "ready"
|
| "ready"
|
||||||
@@ -16,6 +20,12 @@ export type PositionNodeStatus =
|
|||||||
| "rejected"
|
| "rejected"
|
||||||
| "error";
|
| "error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 冻结业务链中的一个席位。
|
||||||
|
*
|
||||||
|
* `threadId` 指向真实的 LangGraph 对话;节点表只承担“席位 ↔ 线程”索引、
|
||||||
|
* 上游版本与交付清单的职责,绝不保存完整聊天消息。
|
||||||
|
*/
|
||||||
export interface PositionRoundtableNode {
|
export interface PositionRoundtableNode {
|
||||||
id: string;
|
id: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -37,6 +47,12 @@ export interface PositionRoundtableNode {
|
|||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 岗位会商会话的可恢复快照。
|
||||||
|
*
|
||||||
|
* 任务、意图与链条在启动后被分别冻结,避免外部任务或链条配置发生变化时
|
||||||
|
* 改写已经开始的协同过程。`nodes` 只在详情接口中携带。
|
||||||
|
*/
|
||||||
export interface PositionRoundtableSession {
|
export interface PositionRoundtableSession {
|
||||||
id: string;
|
id: string;
|
||||||
externalTaskId: string | null;
|
externalTaskId: string | null;
|
||||||
@@ -104,6 +120,10 @@ async function readJson<T>(response: Response, label: string): Promise<T> {
|
|||||||
return response.json() as Promise<T>;
|
return response.json() as Promise<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 使用 snake_case,页面使用 camelCase。所有字段映射集中在这里,
|
||||||
|
* 组件禁止直接依赖后端字段名,避免 API 演进时遗漏状态字段。
|
||||||
|
*/
|
||||||
function toNode(node: ServerNode): PositionRoundtableNode {
|
function toNode(node: ServerNode): PositionRoundtableNode {
|
||||||
return {
|
return {
|
||||||
id: node.id,
|
id: node.id,
|
||||||
@@ -127,6 +147,7 @@ function toNode(node: ServerNode): PositionRoundtableNode {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 同上:会话快照在这一层完成默认值填充与字段转换。 */
|
||||||
function toSession(session: ServerSession): PositionRoundtableSession {
|
function toSession(session: ServerSession): PositionRoundtableSession {
|
||||||
const result: PositionRoundtableSession = {
|
const result: PositionRoundtableSession = {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
@@ -222,6 +243,7 @@ export async function activatePositionSession(
|
|||||||
sessionId: string,
|
sessionId: string,
|
||||||
chainId: string,
|
chainId: string,
|
||||||
): Promise<PositionRoundtableSession> {
|
): Promise<PositionRoundtableSession> {
|
||||||
|
// 启动即冻结链条快照并创建节点;后续页面不得用当前编辑中的链条替换它。
|
||||||
const result = await readJson<ServerSession>(
|
const result = await readJson<ServerSession>(
|
||||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, {
|
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/activate`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -262,7 +284,12 @@ export async function bindPositionNodeThread(
|
|||||||
return toNode(result);
|
return toNode(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch the bounded hidden task/intent/upstream handoff before one node turn. */
|
/**
|
||||||
|
* 在普通节点发送前取得受长度限制的隐藏交接上下文。
|
||||||
|
*
|
||||||
|
* 该上下文会作为 `task_external_context` 发送给 LangGraph,而不是渲染在聊天
|
||||||
|
* 消息列表中;这样既能让节点承接上游,又不会把整段交接材料重复展示给用户。
|
||||||
|
*/
|
||||||
export async function getPositionNodeContext(
|
export async function getPositionNodeContext(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
nodeKey: string,
|
nodeKey: string,
|
||||||
@@ -285,6 +312,7 @@ export async function completePositionNodeTurn(
|
|||||||
upstreamRevision?: Record<string, unknown> | null;
|
upstreamRevision?: Record<string, unknown> | null;
|
||||||
},
|
},
|
||||||
): Promise<PositionRoundtableNode> {
|
): Promise<PositionRoundtableNode> {
|
||||||
|
// 后端依据 artifactManifest 判断是否有 Markdown 交付物,并在需要时解锁下游。
|
||||||
const result = await readJson<ServerNode>(
|
const result = await readJson<ServerNode>(
|
||||||
await apiFetch(
|
await apiFetch(
|
||||||
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/complete-turn`,
|
`${BASE()}/sessions/${encodeURIComponent(sessionId)}/nodes/${encodeURIComponent(nodeKey)}/complete-turn`,
|
||||||
@@ -358,6 +386,7 @@ async function prepareBuiltinRun(
|
|||||||
kind: BuiltinKind,
|
kind: BuiltinKind,
|
||||||
message = "",
|
message = "",
|
||||||
): Promise<PositionBuiltinRunPreparation> {
|
): Promise<PositionBuiltinRunPreparation> {
|
||||||
|
// prepare 阶段固定本轮的上游版本与隐藏提示词,避免总结/行动规划引用漂移数据。
|
||||||
const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare";
|
const path = kind === "summary" ? "summary/prepare" : "action-plan/prepare";
|
||||||
const result = await readJson<ServerBuiltinRunPreparation>(
|
const result = await readJson<ServerBuiltinRunPreparation>(
|
||||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
|
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
|
||||||
@@ -381,6 +410,7 @@ async function completeBuiltinRun(
|
|||||||
model?: string;
|
model?: string;
|
||||||
},
|
},
|
||||||
): Promise<PositionRoundtableSession> {
|
): Promise<PositionRoundtableSession> {
|
||||||
|
// complete 阶段会校验 sourceRevisions;上游已变化时不能把旧结果覆盖为最新交付。
|
||||||
const path = kind === "summary" ? "summary/complete" : "action-plan/complete";
|
const path = kind === "summary" ? "summary/complete" : "action-plan/complete";
|
||||||
const result = await readJson<ServerSession>(
|
const result = await readJson<ServerSession>(
|
||||||
await apiFetch(`${BASE()}/sessions/${encodeURIComponent(sessionId)}/${path}`, {
|
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 { toast } from "sonner";
|
||||||
|
|
||||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
import { InputBox } from "@/components/workspace/input-box";
|
|
||||||
import { MessageList } from "@/components/workspace/messages";
|
import { MessageList } from "@/components/workspace/messages";
|
||||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||||
import { useAgent } from "@/core/agents";
|
import { useAgent } from "@/core/agents";
|
||||||
@@ -19,6 +18,7 @@ import {
|
|||||||
PositionBuiltinAgentTabs,
|
PositionBuiltinAgentTabs,
|
||||||
type PositionBuiltinAgentSwitchOption,
|
type PositionBuiltinAgentSwitchOption,
|
||||||
} from "@/position-roundtable/components/PositionAgentSwitcher";
|
} from "@/position-roundtable/components/PositionAgentSwitcher";
|
||||||
|
import { PositionAgentInput } from "@/position-roundtable/components/PositionAgentInput";
|
||||||
import {
|
import {
|
||||||
completePositionActionPlan,
|
completePositionActionPlan,
|
||||||
completePositionSummary,
|
completePositionSummary,
|
||||||
@@ -76,6 +76,12 @@ interface PositionBuiltinAgentChatProps {
|
|||||||
|
|
||||||
const SUMMARY_AGENT_ID = "roundtable-summary";
|
const SUMMARY_AGENT_ID = "roundtable-summary";
|
||||||
const ACTION_PLAN_AGENT_ID = "position-action-planner";
|
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) {
|
function builtinAgentId(kind: PositionBuiltinAgentKind) {
|
||||||
return kind === "summary" ? SUMMARY_AGENT_ID : ACTION_PLAN_AGENT_ID;
|
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 报告";
|
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 {
|
function latestAssistantText(messages: AgentThreadState["messages"]): string {
|
||||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||||
const message = messages[index];
|
const message = messages[index];
|
||||||
@@ -261,7 +233,6 @@ export function PositionBuiltinAgentChat({
|
|||||||
const [pendingSubmission, setPendingSubmission] =
|
const [pendingSubmission, setPendingSubmission] =
|
||||||
useState<PendingBuiltinSubmission | null>(null);
|
useState<PendingBuiltinSubmission | null>(null);
|
||||||
const pendingSubmissionRef = useRef(false);
|
const pendingSubmissionRef = useRef(false);
|
||||||
const autoPreviewedArtifactKeyRef = useRef<string | null>(null);
|
|
||||||
const handledAutoStartRequestRef = useRef<number | undefined>(undefined);
|
const handledAutoStartRequestRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -272,7 +243,6 @@ export function PositionBuiltinAgentChat({
|
|||||||
pendingRunRef.current = null;
|
pendingRunRef.current = null;
|
||||||
pendingSubmissionRef.current = false;
|
pendingSubmissionRef.current = false;
|
||||||
setPendingSubmission(null);
|
setPendingSubmission(null);
|
||||||
autoPreviewedArtifactKeyRef.current = null;
|
|
||||||
handledAutoStartRequestRef.current = undefined;
|
handledAutoStartRequestRef.current = undefined;
|
||||||
}, [fallbackAgentId, kind, persistedThreadId, session?.id]);
|
}, [fallbackAgentId, kind, persistedThreadId, session?.id]);
|
||||||
|
|
||||||
@@ -294,6 +264,7 @@ export function PositionBuiltinAgentChat({
|
|||||||
const [thread, sendMessage, , actualThreadId] = useThreadStream({
|
const [thread, sendMessage, , actualThreadId] = useThreadStream({
|
||||||
threadId: boundThreadId,
|
threadId: boundThreadId,
|
||||||
assistantId,
|
assistantId,
|
||||||
|
runOptions: POSITION_BUILTIN_RUN_OPTIONS,
|
||||||
context: {
|
context: {
|
||||||
...settings.context,
|
...settings.context,
|
||||||
agent_id: assistantId,
|
agent_id: assistantId,
|
||||||
@@ -314,8 +285,8 @@ export function PositionBuiltinAgentChat({
|
|||||||
onFinish: (state) => {
|
onFinish: (state) => {
|
||||||
const pending = pendingRunRef.current;
|
const pending = pendingRunRef.current;
|
||||||
if (!pending || !session) return;
|
if (!pending || !session) return;
|
||||||
const latestAnswer = latestAssistantText(state.messages ?? []);
|
const content = latestAssistantText(state.messages ?? []);
|
||||||
if (!latestAnswer) return;
|
if (!content) return;
|
||||||
pendingRunRef.current = null;
|
pendingRunRef.current = null;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
let artifactManifest: Array<Record<string, unknown>> = [];
|
let artifactManifest: Array<Record<string, unknown>> = [];
|
||||||
@@ -323,23 +294,22 @@ export function PositionBuiltinAgentChat({
|
|||||||
const artifacts = await listSessionArtifacts(pending.threadId);
|
const artifacts = await listSessionArtifacts(pending.threadId);
|
||||||
artifactManifest = manifestFromArtifacts(artifacts.files);
|
artifactManifest = manifestFromArtifacts(artifacts.files);
|
||||||
} catch {
|
} catch {
|
||||||
// Keep the text turn usable even if the artifact endpoint is briefly
|
// Completion still receives the visible answer; it will give a
|
||||||
// unavailable; the completion API will surface a clearer validation
|
// clearer error if a required deliverable is genuinely missing.
|
||||||
// message if an output file is genuinely missing.
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const next =
|
const next =
|
||||||
kind === "summary"
|
kind === "summary"
|
||||||
? await completePositionSummary(session.id, {
|
? await completePositionSummary(session.id, {
|
||||||
threadId: pending.threadId,
|
threadId: pending.threadId,
|
||||||
content: latestAnswer,
|
content,
|
||||||
artifactManifest,
|
artifactManifest,
|
||||||
sourceRevisions: pending.sourceRevisions,
|
sourceRevisions: pending.sourceRevisions,
|
||||||
model: pending.model,
|
model: pending.model,
|
||||||
})
|
})
|
||||||
: await completePositionActionPlan(session.id, {
|
: await completePositionActionPlan(session.id, {
|
||||||
threadId: pending.threadId,
|
threadId: pending.threadId,
|
||||||
content: latestAnswer,
|
content,
|
||||||
artifactManifest,
|
artifactManifest,
|
||||||
sourceRevisions: pending.sourceRevisions,
|
sourceRevisions: pending.sourceRevisions,
|
||||||
model: pending.model,
|
model: pending.model,
|
||||||
@@ -374,25 +344,6 @@ export function PositionBuiltinAgentChat({
|
|||||||
onThreadSnapshot?.({ threadId: activeThreadId, thread });
|
onThreadSnapshot?.({ threadId: activeThreadId, thread });
|
||||||
}, [activeThreadId, onThreadSnapshot, 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(
|
const ensureBuiltinThread = useCallback(
|
||||||
async (preparedThreadId: string | null, preparedAgentId: string) => {
|
async (preparedThreadId: string | null, preparedAgentId: string) => {
|
||||||
if (preparedThreadId) {
|
if (preparedThreadId) {
|
||||||
@@ -437,10 +388,9 @@ export function PositionBuiltinAgentChat({
|
|||||||
sourceRevisions: preparation.sourceRevisions,
|
sourceRevisions: preparation.sourceRevisions,
|
||||||
model: selectedModel || undefined,
|
model: selectedModel || undefined,
|
||||||
};
|
};
|
||||||
// `useThreadStream` is still attached to the previous (or empty)
|
// The standard chat stream must first bind to the prepared thread.
|
||||||
// thread during this render. Queue the first turn and dispatch it
|
// The effect below then submits the same visible message a manual
|
||||||
// only after the hook has rebound to the prepared thread; otherwise
|
// click would send, so this flow has no separate presentation layer.
|
||||||
// the UI can show the optimistic user bubble without opening a run.
|
|
||||||
setPendingSubmission({
|
setPendingSubmission({
|
||||||
threadId: builtinThread.threadId,
|
threadId: builtinThread.threadId,
|
||||||
agentId: preparation.agentId,
|
agentId: preparation.agentId,
|
||||||
@@ -449,9 +399,10 @@ export function PositionBuiltinAgentChat({
|
|||||||
sourceRevisions: preparation.sourceRevisions,
|
sourceRevisions: preparation.sourceRevisions,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
pendingRunRef.current = null;
|
pendingRunRef.current = null;
|
||||||
pendingSubmissionRef.current = false;
|
pendingSubmissionRef.current = false;
|
||||||
toast.error(`发送失败:${error instanceof Error ? error.message : String(error)}`);
|
toast.error(`发送失败:${message}`);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -538,6 +489,9 @@ export function PositionBuiltinAgentChat({
|
|||||||
) {
|
) {
|
||||||
return;
|
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;
|
handledAutoStartRequestRef.current = autoStartRequestKey;
|
||||||
void handleSubmit({ text: defaultStartText(kind), files: [] });
|
void handleSubmit({ text: defaultStartText(kind), files: [] });
|
||||||
}, [autoStartRequestKey, canWrite, handleSubmit, kind, snapshotDone, thread.isLoading]);
|
}, [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">
|
<div className="shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
|
||||||
<InputBox
|
<PositionAgentInput
|
||||||
className="w-full"
|
className="w-full"
|
||||||
isNewThread={!boundThreadId}
|
isNewThread={!boundThreadId}
|
||||||
threadId={displayThreadId}
|
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 type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "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 { 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 type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||||
import { InputBox } from "@/components/workspace/input-box";
|
|
||||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||||
import { MessageList } from "@/components/workspace/messages";
|
import { MessageList } from "@/components/workspace/messages";
|
||||||
import { useAgent } from "@/core/agents";
|
import { useAgent } from "@/core/agents";
|
||||||
@@ -26,6 +25,7 @@ import {
|
|||||||
type PositionBuiltinAgentKind,
|
type PositionBuiltinAgentKind,
|
||||||
type PositionBuiltinAgentSwitchOption,
|
type PositionBuiltinAgentSwitchOption,
|
||||||
} from "@/position-roundtable/components/PositionAgentSwitcher";
|
} from "@/position-roundtable/components/PositionAgentSwitcher";
|
||||||
|
import { PositionAgentInput } from "@/position-roundtable/components/PositionAgentInput";
|
||||||
|
|
||||||
interface PositionNodeSwitchOption {
|
interface PositionNodeSwitchOption {
|
||||||
node: PositionRoundtableNode;
|
node: PositionRoundtableNode;
|
||||||
@@ -45,6 +45,8 @@ interface PositionNodeChatProps {
|
|||||||
onSelectPositionNode?: (node: PositionRoundtableNode) => void;
|
onSelectPositionNode?: (node: PositionRoundtableNode) => void;
|
||||||
builtinAgentOptions?: PositionBuiltinAgentSwitchOption[];
|
builtinAgentOptions?: PositionBuiltinAgentSwitchOption[];
|
||||||
onSelectBuiltinAgent?: (kind: PositionBuiltinAgentKind) => void;
|
onSelectBuiltinAgent?: (kind: PositionBuiltinAgentKind) => void;
|
||||||
|
/** Rendered in the header so the wide same-position selector never overlaps it. */
|
||||||
|
onOpenConfiguration?: () => void;
|
||||||
onNodeUpdate: (node: PositionRoundtableNode) => void;
|
onNodeUpdate: (node: PositionRoundtableNode) => void;
|
||||||
/** The workspace collapses the configuration panel on the first real turn. */
|
/** The workspace collapses the configuration panel on the first real turn. */
|
||||||
onConversationStart?: () => void;
|
onConversationStart?: () => void;
|
||||||
@@ -183,7 +185,7 @@ function latestStreamingWriteFile(
|
|||||||
* Direct single-agent chat for one frozen business-chain node.
|
* Direct single-agent chat for one frozen business-chain node.
|
||||||
*
|
*
|
||||||
* It deliberately uses the regular `useThreadStream` + `MessageList` +
|
* 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.
|
* belongs to the node; it is not a second message persistence mechanism.
|
||||||
*/
|
*/
|
||||||
export function PositionNodeChat({
|
export function PositionNodeChat({
|
||||||
@@ -198,6 +200,7 @@ export function PositionNodeChat({
|
|||||||
onSelectPositionNode,
|
onSelectPositionNode,
|
||||||
builtinAgentOptions,
|
builtinAgentOptions,
|
||||||
onSelectBuiltinAgent,
|
onSelectBuiltinAgent,
|
||||||
|
onOpenConfiguration,
|
||||||
onNodeUpdate,
|
onNodeUpdate,
|
||||||
onConversationStart,
|
onConversationStart,
|
||||||
onPreviewArtifact,
|
onPreviewArtifact,
|
||||||
@@ -233,6 +236,8 @@ export function PositionNodeChat({
|
|||||||
|
|
||||||
const bindCreatedThread = useCallback(
|
const bindCreatedThread = useCallback(
|
||||||
(threadId: string) => {
|
(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;
|
boundThreadIdRef.current = threadId;
|
||||||
setBoundThreadId(threadId);
|
setBoundThreadId(threadId);
|
||||||
void bindPositionNodeThread(sessionId, node.nodeKey, threadId)
|
void bindPositionNodeThread(sessionId, node.nodeKey, threadId)
|
||||||
@@ -265,6 +270,9 @@ export function PositionNodeChat({
|
|||||||
},
|
},
|
||||||
onStart: bindCreatedThread,
|
onStart: bindCreatedThread,
|
||||||
onFinish: (state) => {
|
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 threadId = boundThreadIdRef.current;
|
||||||
const latestAi = [...(state.messages ?? [])]
|
const latestAi = [...(state.messages ?? [])]
|
||||||
.reverse()
|
.reverse()
|
||||||
@@ -364,6 +372,8 @@ export function PositionNodeChat({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!thread.isLoading || !streamingArtifact) return;
|
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}`;
|
const artifactKey = `${activeThreadId}:${streamingArtifact.path}`;
|
||||||
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
|
if (autoPreviewedArtifactKeyRef.current === artifactKey) return;
|
||||||
autoPreviewedArtifactKeyRef.current = artifactKey;
|
autoPreviewedArtifactKeyRef.current = artifactKey;
|
||||||
@@ -400,6 +410,9 @@ export function PositionNodeChat({
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
onConversationStart?.();
|
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 taskExternalContext = await getPositionNodeContext(sessionId, node.nodeKey);
|
||||||
const upstreamRevision = taskExternalContext.upstream_revision;
|
const upstreamRevision = taskExternalContext.upstream_revision;
|
||||||
upstreamRevisionRef.current =
|
upstreamRevisionRef.current =
|
||||||
@@ -491,6 +504,17 @@ export function PositionNodeChat({
|
|||||||
onSelect={onSelectBuiltinAgent}
|
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>
|
</header>
|
||||||
|
|
||||||
{nodeArtifacts.length > 0 && (
|
{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">
|
<div className="shrink-0 border-t border-slate-100 px-4 py-3 dark:border-[#2b8ffe]/20">
|
||||||
<InputBox
|
<PositionAgentInput
|
||||||
className="w-full"
|
className="w-full"
|
||||||
isNewThread={!boundThreadId}
|
isNewThread={!boundThreadId}
|
||||||
threadId={displayThreadId}
|
threadId={displayThreadId}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import expandLogo from "@/assets/position-roundtable/expand-logo.svg";
|
import expandLogo from "@/assets/position-roundtable/expand-logo.svg";
|
||||||
import { listChains } from "@/roundtable-planning/api/chains";
|
import { listChains } from "@/roundtable-planning/api/chains";
|
||||||
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
|
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
|
||||||
|
import { PositionIntentInput } from "@/position-roundtable/components/PositionIntentInput";
|
||||||
import { PositionNodeChat } from "@/position-roundtable/components/PositionNodeChat";
|
import { PositionNodeChat } from "@/position-roundtable/components/PositionNodeChat";
|
||||||
import { getAvatar } from "@/roundtable-planning/components/Avatars";
|
import { getAvatar } from "@/roundtable-planning/components/Avatars";
|
||||||
import { useStep1Intent, type InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent";
|
import { useStep1Intent, type InteractiveMessage } from "@/roundtable-planning/hooks/useStep1Intent";
|
||||||
@@ -144,6 +145,16 @@ type SandboxThreadSnapshot = {
|
|||||||
signature: string;
|
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 {
|
function normalizePreviewArtifactPath(path: string): string {
|
||||||
let normalized = path.trim();
|
let normalized = path.trim();
|
||||||
if (normalized.startsWith("write-file:")) {
|
if (normalized.startsWith("write-file:")) {
|
||||||
@@ -479,7 +490,8 @@ export default function PositionRoundtablePage({
|
|||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const isDark = theme === "dark";
|
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 taskIdFromUrl = searchParams.get("taskId") ?? DEFAULT_MOCK_POSITION_TASK_ID;
|
||||||
const searchParamsString = searchParams.toString();
|
const searchParamsString = searchParams.toString();
|
||||||
const fallbackTask = useMemo(() => getMockPositionTask(taskIdFromUrl), [taskIdFromUrl]);
|
const fallbackTask = useMemo(() => getMockPositionTask(taskIdFromUrl), [taskIdFromUrl]);
|
||||||
@@ -495,6 +507,9 @@ export default function PositionRoundtablePage({
|
|||||||
const [historyMenuOpen, setHistoryMenuOpen] = useState(false);
|
const [historyMenuOpen, setHistoryMenuOpen] = useState(false);
|
||||||
const [historySessions, setHistorySessions] = useState<PositionRoundtableSession[]>([]);
|
const [historySessions, setHistorySessions] = useState<PositionRoundtableSession[]>([]);
|
||||||
const [historyLoading, setHistoryLoading] = useState(false);
|
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 artifactsCtx = useArtifactsOptional();
|
||||||
const artifactsCtxRef = useRef(artifactsCtx);
|
const artifactsCtxRef = useRef(artifactsCtx);
|
||||||
const [sandboxArtifact, setSandboxArtifact] = useState<PositionArtifactPreview | null>(null);
|
const [sandboxArtifact, setSandboxArtifact] = useState<PositionArtifactPreview | null>(null);
|
||||||
@@ -1112,6 +1127,9 @@ export default function PositionRoundtablePage({
|
|||||||
}, [intent]);
|
}, [intent]);
|
||||||
|
|
||||||
const handleActivateChain = useCallback(async () => {
|
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 (startingChain) return;
|
||||||
if (!selectedChain) {
|
if (!selectedChain) {
|
||||||
toast.warning("请先选择业务链条");
|
toast.warning("请先选择业务链条");
|
||||||
@@ -1239,6 +1257,8 @@ export default function PositionRoundtablePage({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handlePreviewArtifact = useCallback((artifact: PositionArtifactPreview) => {
|
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) {
|
if (!artifact.threadId || !artifact.path) {
|
||||||
toast.warning("该文件缺少沙箱预览所需的信息");
|
toast.warning("该文件缺少沙箱预览所需的信息");
|
||||||
return;
|
return;
|
||||||
@@ -1382,6 +1402,8 @@ export default function PositionRoundtablePage({
|
|||||||
}, []);
|
}, []);
|
||||||
const handleSelectBuiltinStage = useCallback(
|
const handleSelectBuiltinStage = useCallback(
|
||||||
(kind: ClosureStageKind) => {
|
(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) {
|
if (positionId !== DEFAULT_POSITION_ID) {
|
||||||
toast.info("收口智能体仅在测试分析岗中开放使用。");
|
toast.info("收口智能体仅在测试分析岗中开放使用。");
|
||||||
return;
|
return;
|
||||||
@@ -1412,6 +1434,18 @@ export default function PositionRoundtablePage({
|
|||||||
},
|
},
|
||||||
[closeSandbox],
|
[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(
|
const handlePositionChange = useCallback(
|
||||||
(value: unknown) => {
|
(value: unknown) => {
|
||||||
// 右侧详情只属于刚才的岗位;切岗后回到该岗位的智能体列表,
|
// 右侧详情只属于刚才的岗位;切岗后回到该岗位的智能体列表,
|
||||||
@@ -1425,8 +1459,11 @@ export default function PositionRoundtablePage({
|
|||||||
setActiveNodeKey(null);
|
setActiveNodeKey(null);
|
||||||
setRightPanelOpen(nextPositionId === DEFAULT_POSITION_ID);
|
setRightPanelOpen(nextPositionId === DEFAULT_POSITION_ID);
|
||||||
setLeftPanelCollapsed(false);
|
setLeftPanelCollapsed(false);
|
||||||
|
const next = new URLSearchParams(searchParamsString);
|
||||||
|
next.set("positionId", nextPositionId);
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
},
|
},
|
||||||
[closeSandbox],
|
[closeSandbox, searchParamsString, setSearchParams],
|
||||||
);
|
);
|
||||||
const renderIntentChatPanel = (chainStarted: boolean) => (
|
const renderIntentChatPanel = (chainStarted: boolean) => (
|
||||||
<div className="contents">
|
<div className="contents">
|
||||||
@@ -1438,6 +1475,8 @@ export default function PositionRoundtablePage({
|
|||||||
</TButton>
|
</TButton>
|
||||||
</div>
|
</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
|
<Step1Panel
|
||||||
headerLeftSlot={
|
headerLeftSlot={
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
@@ -1484,7 +1523,7 @@ export default function PositionRoundtablePage({
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSelectBuiltinStage(summaryDone ? "action_plan" : "summary")}
|
onClick={() => handleSelectBuiltinStage(summaryDone ? "action_plan" : "summary")}
|
||||||
className={cn(
|
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
|
summaryDone
|
||||||
? "bg-sky-600 shadow-sky-500/10 hover:bg-sky-700"
|
? "bg-sky-600 shadow-sky-500/10 hover:bg-sky-700"
|
||||||
: "bg-emerald-600 shadow-emerald-500/10 hover:bg-emerald-700",
|
: "bg-emerald-600 shadow-emerald-500/10 hover:bg-emerald-700",
|
||||||
@@ -1499,6 +1538,7 @@ export default function PositionRoundtablePage({
|
|||||||
</button>
|
</button>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
inputArea={(inputProps) => <PositionIntentInput {...inputProps} />}
|
||||||
onManualSave={() => undefined}
|
onManualSave={() => undefined}
|
||||||
onTriggerToast={triggerToast}
|
onTriggerToast={triggerToast}
|
||||||
submitIntentClarifications={intent.submitIntentClarifications}
|
submitIntentClarifications={intent.submitIntentClarifications}
|
||||||
@@ -1654,7 +1694,7 @@ export default function PositionRoundtablePage({
|
|||||||
<aside
|
<aside
|
||||||
aria-hidden={leftSidebarHidden || undefined}
|
aria-hidden={leftSidebarHidden || undefined}
|
||||||
className={cn(
|
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
|
leftSidebarHidden
|
||||||
? "w-0 translate-x-4 !border-0 !p-0 opacity-0 pointer-events-none"
|
? "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",
|
: "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">
|
<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
|
<TButton
|
||||||
size="small"
|
size="small"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -1759,6 +1802,9 @@ export default function PositionRoundtablePage({
|
|||||||
onSelectPositionNode={handleSelectPositionNode}
|
onSelectPositionNode={handleSelectPositionNode}
|
||||||
builtinAgentOptions={showClosureAgents ? builtinAgentOptions : undefined}
|
builtinAgentOptions={showClosureAgents ? builtinAgentOptions : undefined}
|
||||||
onSelectBuiltinAgent={showClosureAgents ? handleSelectBuiltinStage : undefined}
|
onSelectBuiltinAgent={showClosureAgents ? handleSelectBuiltinStage : undefined}
|
||||||
|
onOpenConfiguration={
|
||||||
|
!rightPanelOpen && !sandboxOpen ? () => setRightPanelOpen(true) : undefined
|
||||||
|
}
|
||||||
onNodeUpdate={handleNodeUpdate}
|
onNodeUpdate={handleNodeUpdate}
|
||||||
onConversationStart={() => setRightPanelOpen(false)}
|
onConversationStart={() => setRightPanelOpen(false)}
|
||||||
onPreviewArtifact={handlePreviewArtifact}
|
onPreviewArtifact={handlePreviewArtifact}
|
||||||
|
|||||||
@@ -65,6 +65,35 @@ import type {
|
|||||||
} from "@/roundtable-planning/api/multi-agent";
|
} from "@/roundtable-planning/api/multi-agent";
|
||||||
import type { IntentSummary } from "@/roundtable-planning/api/intent";
|
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 {
|
export interface Step1PanelProps {
|
||||||
// ─── State ────────────────────────────────────────────────────
|
// ─── State ────────────────────────────────────────────────────
|
||||||
/**
|
/**
|
||||||
@@ -94,6 +123,11 @@ export interface Step1PanelProps {
|
|||||||
footerAction?: React.ReactNode;
|
footerAction?: React.ReactNode;
|
||||||
/** 已进入后续工作流时,隐藏默认的「进入研讨」动作。 */
|
/** 已进入后续工作流时,隐藏默认的「进入研讨」动作。 */
|
||||||
hideEnterRoundtable?: boolean;
|
hideEnterRoundtable?: boolean;
|
||||||
|
/**
|
||||||
|
* 仅替换输入区的渲染插槽。消息列表、意图状态及附件状态仍由 Step1Panel
|
||||||
|
* 统一管理,因此岗位会商定制样式时不会分叉或复制 Step 1 的业务逻辑。
|
||||||
|
*/
|
||||||
|
inputArea?: (props: Step1InputAreaProps) => React.ReactNode;
|
||||||
onManualSave: () => void;
|
onManualSave: () => void;
|
||||||
onTriggerToast: (msg: string) => void;
|
onTriggerToast: (msg: string) => void;
|
||||||
submitIntentClarifications: (
|
submitIntentClarifications: (
|
||||||
@@ -122,6 +156,7 @@ export function Step1Panel(props: Step1PanelProps) {
|
|||||||
enterButtonLabel = "进入多智能体研讨 >",
|
enterButtonLabel = "进入多智能体研讨 >",
|
||||||
footerAction,
|
footerAction,
|
||||||
hideEnterRoundtable = false,
|
hideEnterRoundtable = false,
|
||||||
|
inputArea,
|
||||||
onManualSave,
|
onManualSave,
|
||||||
onTriggerToast,
|
onTriggerToast,
|
||||||
submitIntentClarifications,
|
submitIntentClarifications,
|
||||||
@@ -144,8 +179,7 @@ export function Step1Panel(props: Step1PanelProps) {
|
|||||||
|
|
||||||
const charactersLeft = inputText.length;
|
const charactersLeft = inputText.length;
|
||||||
|
|
||||||
const handlePickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const addAttachedFiles = (picked: File[]) => {
|
||||||
const picked = Array.from(e.target.files ?? []);
|
|
||||||
if (picked.length > 0) {
|
if (picked.length > 0) {
|
||||||
// 去重(按 名称+大小),追加到已选列表。
|
// 去重(按 名称+大小),追加到已选列表。
|
||||||
setAttachedFiles((prev) => {
|
setAttachedFiles((prev) => {
|
||||||
@@ -153,6 +187,10 @@ export function Step1Panel(props: Step1PanelProps) {
|
|||||||
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
|
return [...prev, ...picked.filter((f) => !seen.has(`${f.name}:${f.size}`))];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePickFiles = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
addAttachedFiles(Array.from(e.target.files ?? []));
|
||||||
// 清空 input 值,允许再次选同名文件。
|
// 清空 input 值,允许再次选同名文件。
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
};
|
};
|
||||||
@@ -216,6 +254,30 @@ export function Step1Panel(props: Step1PanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom input area */}
|
{/* 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="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">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -616,6 +616,11 @@
|
|||||||
padding-box;
|
padding-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 岗位会商左侧总面板:仅收窄滚动条宽度,沿用全局既有的滑块颜色、圆角和轨道样式。 */
|
||||||
|
.position-roundtable-sidebar-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.workspace-shell {
|
.workspace-shell {
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user