Merge branch 'dhk-1' into 'master'
Dhk 1 See merge request knowledge-base-bj/deerflow-server!65
This commit is contained in:
commit
445865e422
8 files changed
+1464
-200
No files matched your search
@@ -0,0 +1,207 @@
|
|||||||
|
# 圆桌 Step 2 沙箱预览 · 开发进度
|
||||||
|
|
||||||
|
> 最后更新:2026-05-27
|
||||||
|
> 关联文档:`multi-agent-frontend-dev.md`(总览)、`multi-agent-backend-dev.md`(后端)、`multi-agent-api.md`(接口)
|
||||||
|
> 对照参考:主聊天 `/page/workspace/chats/:threadId`(`ChatBox` + `ArtifactsProvider` + `message-group.tsx`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
在 Step 2「多智能体圆桌研讨」中,当子智能体调用 `write_file` / `str_replace` / `present_files` 时,**行为与主聊天一致**:
|
||||||
|
|
||||||
|
| 能力 | 主聊天 | Step 2 目标 |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| `write_file` 流式期间自动打开右侧沙箱 | ✅ | ✅ 虚拟 URL |
|
||||||
|
| 步骤卡里点击文件路径打开沙箱 | ✅ | ✅ |
|
||||||
|
| 消息下方文件卡片(`present_files`)点击打开 | ✅ | ✅ `PresentFilesRow` |
|
||||||
|
| 沙箱打开时收起 workspace 左侧栏 | ✅(`ArtifactsProvider.select`) | ✅ Step 2 左栏联动 |
|
||||||
|
| 60/40 对话 \| 产物预览分栏 | ✅ `ChatBox` | ✅ `Step2SandboxLayout` |
|
||||||
|
| 流式 MD 从 tool_call.args.content 预览 | ✅ 虚拟 URL + stub messages | ✅ `seatStubMessages`(双通道:`onPhaseChange` + `onUpstreamFrame`) |
|
||||||
|
| 落盘后 HTTP 拉取真实文件 | ✅ artifacts API | ✅ `present_files` 卡片走 HTTP |
|
||||||
|
| 草稿 reload 后历史文件可预览 | ✅ | ✅ `seatStubMessages` 一并入草稿 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 已完成
|
||||||
|
|
||||||
|
### 2.1 Step 2 布局调整
|
||||||
|
|
||||||
|
| 项 | 状态 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| 推理模型下拉 | ✅ | 从右侧栏迁至 Step 2 顶栏「多智能体圆桌会商中心」右侧 |
|
||||||
|
| 意图理解 | ✅ | 移至左侧栏顶部,支持展开/收起;收起时仅显示「目标」 |
|
||||||
|
| 任务概览 | ✅ | 放在「人工参与」下方 |
|
||||||
|
| 右侧栏 Step 2/3 隐藏 | ✅ | `RightSidebar` 仅在 `currentStep === 1` 渲染 |
|
||||||
|
| 左侧栏整体收起 | ✅ | 宽度 56px;展开按钮在顶部;研讨中角色 ring + ping 动画 |
|
||||||
|
| 沙箱打开 → 左栏收起 | ✅ | `Step2SandboxSidebarSync` 监听 `useArtifacts().open` |
|
||||||
|
|
||||||
|
**主要文件**:`RoundtablePlanningPage.tsx`(`step2SidebarExpanded`、`LeftSidebar`、`Step2SandboxSidebarSync`)
|
||||||
|
|
||||||
|
### 2.2 沙箱基础设施(Phase 1)
|
||||||
|
|
||||||
|
| 项 | 状态 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| 路由包 `ChatRuntime` | ✅ | `WorkspaceRoutes.tsx` → `roundtable/planning` 外包 `ArtifactsProvider` |
|
||||||
|
| 60/40 分栏壳 | ✅ | `Step2SandboxLayout.tsx`,对齐 `chat-box.tsx` |
|
||||||
|
| `ArtifactFileDetail` / `ArtifactFileList` | ✅ | 嵌在右栏 ResizablePanel |
|
||||||
|
| `ThreadContext` stub | ✅ | 最小 `BaseStream` stub,供 `useArtifactContent` 工作 |
|
||||||
|
|
||||||
|
### 2.3 沙箱打开逻辑(Phase 2)
|
||||||
|
|
||||||
|
| 项 | 状态 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| `write_file` 自动打开 | ✅ | `handleArtifactToolPhase` 在 `tool_calling` + 完整 `args.path` 时触发 |
|
||||||
|
| 步骤卡点击打开 | ✅ | `MessageStepsCard` → `openArtifactFromStep` |
|
||||||
|
| 虚拟 URL 机制 | ✅ | `write-file:<path>?message_id=...&tool_call_id=...`(对齐主聊天) |
|
||||||
|
| Stub messages 注入 | ✅ | `seatStubMessages` + `upsertSeatToolCall` → `loadArtifactContentFromToolCall` |
|
||||||
|
| 重置清理 | ✅ | `resetStep2State` 清空 artifacts / stub / deselect |
|
||||||
|
| `present_files` 步骤点击 | ✅ | 走真实路径 `openArtifactPreview`(HTTP API) |
|
||||||
|
|
||||||
|
### 2.4 上游帧兜底注入(Phase 3)
|
||||||
|
|
||||||
|
| 项 | 状态 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| `onPhaseChange` 主通道 | ✅ | `tool_calling` 阶段每帧把完整 `args` 喂进 stub;首次出现 `path` 即触发自动打开 |
|
||||||
|
| `onUpstreamFrame` 兜底通道 | ✅ | `handleUpstreamFrame` 扫描 messages-tuple 帧的 `tool_calls[]`,对 `write_file`/`str_replace` 持续 upsert `args.content`;覆盖 phase 已切走但 tool_calls 又回包的边界 |
|
||||||
|
| 双通道幂等 | ✅ | `upsertSeatToolCall` 按 `(toolName, path)` 去重,两个通道并发写入安全 |
|
||||||
|
|
||||||
|
### 2.5 `present_files` 文件卡(Phase 4)
|
||||||
|
|
||||||
|
| 项 | 状态 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| 气泡下方文件卡行 | ✅ | `Step2Message` → `collectPresentedFiles` → `PresentFilesRow` |
|
||||||
|
| 卡片样式 | ✅ | 文件名 + 扩展名徽章(FILE_EXT_LABELS),最小宽度 30/最大 50(hover 边框亮色) |
|
||||||
|
| 点击 → HTTP 预览 | ✅ | `openArtifactPreview(agentThreadId, path)`,与步骤卡 `present_files` 走同一通道 |
|
||||||
|
| 席位 thread 缺失保护 | ✅ | 没有 `agentThreadId` 时 disabled + tooltip |
|
||||||
|
| 同气泡多次 present_files | ✅ | `filepaths` 顺序去重,全部聚合到一行卡片 |
|
||||||
|
|
||||||
|
### 2.6 草稿持久化(Phase 5)
|
||||||
|
|
||||||
|
| 项 | 状态 | 说明 |
|
||||||
|
|----|------|------|
|
||||||
|
| `seatStubMessages` 进 `Step2Snapshot` | ✅ | `useStep2Orchestration.ts` Step2Snapshot 新增字段 |
|
||||||
|
| `seatStubMessages` 进 `DraftStep2Snapshot` | ✅ | `drafts.ts` 加 optional 字段,老草稿 reload 时回退到 `{}` |
|
||||||
|
| `hydrateFromDraft` 还原 stub | ✅ | 同步写 `seatStubMessagesRef` + state,加载草稿后点击历史 write_file 步骤可直接预览 |
|
||||||
|
| `getStep2Snapshot` 透出 stub | ✅ | `RoundtablePlanningPage.tsx` 给 `useDraftPersistence` 的 getter 加上该字段 |
|
||||||
|
|
||||||
|
**主要文件**:
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend-web/src/pages/WorkspaceRoutes.tsx
|
||||||
|
frontend-web/src/roundtable-planning/hooks/useStep2Orchestration.ts
|
||||||
|
frontend-web/src/roundtable-planning/components/Step2SandboxLayout.tsx
|
||||||
|
frontend-web/src/roundtable-planning/components/Step2Panel.tsx
|
||||||
|
frontend-web/src/roundtable-planning/components/MessageStepsCard.tsx
|
||||||
|
frontend-web/src/roundtable-planning/pages/RoundtablePlanningPage.tsx
|
||||||
|
frontend-web/src/roundtable-planning/utils/drafts.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.7 与主聊天对齐的核心数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
special seat SSE (streamMultiAgent)
|
||||||
|
├── onPhaseChange("tool_calling", "write_file", args)
|
||||||
|
│ ↓
|
||||||
|
│ upsertSeatToolCall(seatThreadId, "write_file", args)
|
||||||
|
│ → seatStubMessages[seatThreadId] = [{ type:"ai", tool_calls:[{ id, name, args }] }]
|
||||||
|
│ ↓
|
||||||
|
│ buildWriteFileVirtualUrl(path, messageId, toolCallId)
|
||||||
|
│ → "write-file:/mnt/user-data/outputs/foo.md?message_id=...&tool_call_id=..."
|
||||||
|
│ ↓
|
||||||
|
│ openSeatVirtualArtifact(seatThreadId, virtualUrl, auto=true)
|
||||||
|
│ → selectArtifact(virtualUrl) + setArtifactsOpen(true)
|
||||||
|
│
|
||||||
|
└── onUpstreamFrame(frame) ← 兜底通道
|
||||||
|
↓
|
||||||
|
handleUpstreamFrame —— 持续 upsert tool_calls 里完整的 args(不打开沙箱)
|
||||||
|
|
||||||
|
Step2SandboxLayout
|
||||||
|
ThreadContext.thread.messages = seatStubMessages[activeThreadId]
|
||||||
|
ArtifactFileDetail(filepath=virtualUrl, threadId=seatThreadId)
|
||||||
|
↓
|
||||||
|
useArtifactContent → loadArtifactContentFromToolCall
|
||||||
|
→ 从 thread.messages 读 tool_call.args.content(不发 HTTP)
|
||||||
|
```
|
||||||
|
|
||||||
|
**为何不用真实 path 直接预览**:流式期间文件可能尚未落盘,直接调 `/api/threads/:id/artifacts/...` 会返回 `404 {"detail":"Not Found"}`。主聊天同样用虚拟 URL 绕过此问题。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 未完成 / 待验证
|
||||||
|
|
||||||
|
### 3.1 功能缺口(按优先级)
|
||||||
|
|
||||||
|
| 优先级 | 项 | 状态 | 说明 |
|
||||||
|
|--------|----|------|------|
|
||||||
|
| P0 | 端到端回归 | ⚠️ 待验证 | 虚拟 URL + 双通道 + 文件卡 + 草稿持久化全链路尚未在浏览器里跑一遍 |
|
||||||
|
| P2 | `str_replace` 流式编辑预览 | ❌ | 主聊天 `applyPendingTextEdit` 依赖完整 message 链;stub 未模拟 tool result / 多轮 str_replace 累加 |
|
||||||
|
| P2 | 沙箱 `artifacts[]` 列表与虚拟 URL 混排 | ❌ | 虚拟 URL 不写入 `setArtifacts([])`;多文件切换、文件名下拉体验未完全对齐主聊天 |
|
||||||
|
| P3 | Step 3 与沙箱衔接 | ❌ | Step 3 仍用 `HighFidelityReport` + `listSessionArtifacts`;与 Step 2 沙箱选中态无联动 |
|
||||||
|
| P3 | 文档同步 | ❌ | `multi-agent-frontend-dev.md` 仍描述旧布局(右侧栏模型选择器等),需更新 § 布局 + 沙箱章节 |
|
||||||
|
|
||||||
|
### 3.2 已知风险 / 边界
|
||||||
|
|
||||||
|
1. **打开时机**:仅在 SSE 进入 `tool_calling` 且 `tool_calls[0].args` 为完整对象时触发自动打开(`multi-agent.ts` 刻意忽略 `tool_call_chunks` 的拼接 args)。若模型只发 chunks、迟迟不发完整 tool_calls,自动打开会延迟到 args 完整那一帧——与主聊天一致。
|
||||||
|
2. **多 write_file 同气泡**:`upsertSeatToolCall` 按 `(toolName, path)` 幂等;同 path 多次 write 会更新 args,不会新建多条 stub tool_call。
|
||||||
|
3. **leader 席位**:当前仅 special 流绑定 `agentThreadId` 并接 `onUpstreamFrame`;总控若直接 write_file,需单独接 thread id 与帧回调。
|
||||||
|
4. **`present_files` 文件大小**:流式期间 backend 尚未给出真实大小;卡片只显示扩展名徽章,不显示 size。落盘后用户点开走 HTTP API 自然能看到完整内容。
|
||||||
|
5. **调试日志**:控制台 `[roundtable-sandbox]` 前缀;生产环境可考虑加 `window.__ROUNDTABLE_SANDBOX_DEBUG__` 门控。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 分阶段计划(原始规划 vs 现状)
|
||||||
|
|
||||||
|
| 阶段 | 内容 | 状态 |
|
||||||
|
|------|------|------|
|
||||||
|
| Phase 1 | `ChatRuntime` + 分栏壳 + 侧栏联动 | ✅ 完成 |
|
||||||
|
| Phase 2 | `write_file` 自动/点击打开 + 虚拟 URL 预览 | ✅ 完成 |
|
||||||
|
| Phase 3 | `onUpstreamFrame` 注入完整 LangGraph messages | ✅ 完成(双通道兜底) |
|
||||||
|
| Phase 4 | `present_files` 卡片 + Step 3 文件区衔接 | ⚠️ 卡片完成;Step 3 衔接遗留 |
|
||||||
|
| Phase 5 | 草稿 stub 持久化 + 文档更新 | ✅ stub 完成;本文档同步更新 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 测试清单
|
||||||
|
|
||||||
|
在 `/page/workspace/roundtable/planning` Step 2 下逐项勾选:
|
||||||
|
|
||||||
|
- [ ] 子智能体 `write_file` 进入 `tool_calling` 后,右侧沙箱 **自动** 打开(约 100ms 延迟,对齐主聊天)
|
||||||
|
- [ ] 沙箱标题为文件名(如 `sample-paper.md`),**不是** `{"detail":"Not Found"}`
|
||||||
|
- [ ] 流式期间 MD 内容随 `args.content` 更新(双通道:phase 切换帧 + 兜底帧)
|
||||||
|
- [ ] 点击 ChainOfThought 步骤「写入文件」/ 路径 chip → 手动打开同一文件
|
||||||
|
- [ ] `present_files` 后气泡下方出现文件卡(带扩展名徽章),点击 → HTTP 预览
|
||||||
|
- [ ] 沙箱打开时左侧「意图理解」栏 **自动收起**
|
||||||
|
- [ ] 关闭沙箱(X)后对话区恢复全宽
|
||||||
|
- [ ] 「新建任务」后 artifacts / 沙箱 / `seatStubMessages` 全部清空
|
||||||
|
- [ ] 「保存草稿」→「加载草稿」后,点击历史 `write_file` 步骤可立即预览(stub 已还原)
|
||||||
|
- [ ] 与主聊天 `/page/workspace/chats/:threadId` 对比:同一 write_file 行为一致
|
||||||
|
|
||||||
|
**调试**:DevTools Console 过滤 `[roundtable-sandbox]` 或 `[stream:special:...]`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 关键代码索引
|
||||||
|
|
||||||
|
| 用途 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| 主聊天 write_file 虚拟 URL + 自动打开 | `src/components/workspace/messages/message-group.tsx` |
|
||||||
|
| 主聊天 `RichFileCard`(present_files) | `src/components/workspace/messages/message-list-item.tsx` |
|
||||||
|
| 主聊天 60/40 分栏 | `src/components/workspace/chats/chat-box.tsx` |
|
||||||
|
| 虚拟 URL 内容加载 | `src/core/artifacts/loader.ts` → `loadArtifactContentFromToolCall` |
|
||||||
|
| Artifacts 上下文 | `src/components/workspace/artifacts/context.tsx` |
|
||||||
|
| Step 2 编排 + 沙箱状态 + 草稿快照 | `src/roundtable-planning/hooks/useStep2Orchestration.ts` |
|
||||||
|
| Step 2 沙箱布局 | `src/roundtable-planning/components/Step2SandboxLayout.tsx` |
|
||||||
|
| Step 2 文件卡 + 步骤渲染 | `src/roundtable-planning/components/Step2Panel.tsx` |
|
||||||
|
| SSE phase 推断 + `onUpstreamFrame` | `src/roundtable-planning/api/multi-agent.ts` → `setPhase` |
|
||||||
|
| 主页面侧栏 + 联动 | `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` |
|
||||||
|
| 草稿持久化 | `src/roundtable-planning/utils/drafts.ts`、`hooks/useDraftPersistence.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 下一步建议(给接续开发者)
|
||||||
|
|
||||||
|
1. **先跑通 P0 测试清单**,确认 Phase 3/4/5 改造后无回归;重点观察双通道并发写入 stub 时 args 是否被旧帧覆盖(理论上 LangGraph 完整 tool_calls 是单调累积的,应该没问题)。
|
||||||
|
2. **Phase 4 收尾**:把 Step 3 的 `listSessionArtifacts` / `HighFidelityReport` 接进沙箱选中态——Step 3 进入时复用 Step 2 的 `ArtifactsProvider`,让用户在 Step 3 也能预览 Step 2 留下的产物。
|
||||||
|
3. **Phase 2 边界**:`str_replace` 多轮编辑的 stub 模拟(追加 tool result 消息让 `applyPendingTextEdit` 能 replay edit 序列),优先级低。
|
||||||
|
4. **更新** `multi-agent-frontend-dev.md`:Step 2 布局、沙箱架构、`seatStubMessages` 数据流,避免与本文档重复——可在总览文档加一节「见 `roundtable-step2-sandbox-progress.md`」。
|
||||||
@@ -217,7 +217,9 @@ export default function WorkspaceRoutes({
|
|||||||
path="roundtable/planning"
|
path="roundtable/planning"
|
||||||
element={
|
element={
|
||||||
<WorkspaceLayout {...layoutProps}>
|
<WorkspaceLayout {...layoutProps}>
|
||||||
<RoundtablePlanningPage />
|
<ChatRuntime>
|
||||||
|
<RoundtablePlanningPage />
|
||||||
|
</ChatRuntime>
|
||||||
</WorkspaceLayout>
|
</WorkspaceLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export interface MessageStepsCardProps {
|
|||||||
/** 是否流式中 —— 决定最后一步显示 `active` 还是 `complete`。 */
|
/** 是否流式中 —— 决定最后一步显示 `active` 还是 `complete`。 */
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
/** 点击 write_file / present_files 等产物相关步骤时打开沙箱。 */
|
||||||
|
onArtifactStepClick?: (step: BubbleStep) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */
|
/** 工具调用阶段的 step 才进入折叠卡;非工具步骤(connecting/streaming/tool_result)归到「思考」段。 */
|
||||||
@@ -51,7 +53,12 @@ function isToolStep(step: BubbleStep): boolean {
|
|||||||
return step.key.startsWith("tool_calling:") && step.key.length > "tool_calling:".length;
|
return step.key.startsWith("tool_calling:") && step.key.length > "tool_calling:".length;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageStepsCard({ steps, streaming, className }: MessageStepsCardProps) {
|
export function MessageStepsCard({
|
||||||
|
steps,
|
||||||
|
streaming,
|
||||||
|
className,
|
||||||
|
onArtifactStepClick,
|
||||||
|
}: MessageStepsCardProps) {
|
||||||
// 把步骤拆成「工具调用」与「思考过程」两类 —— 与主聊天 MessageGroup 的 `lastToolCallStep` /
|
// 把步骤拆成「工具调用」与「思考过程」两类 —— 与主聊天 MessageGroup 的 `lastToolCallStep` /
|
||||||
// `lastReasoningStep` 划分对齐。
|
// `lastReasoningStep` 划分对齐。
|
||||||
const toolSteps = steps.filter(isToolStep);
|
const toolSteps = steps.filter(isToolStep);
|
||||||
@@ -119,11 +126,17 @@ export function MessageStepsCard({ steps, streaming, className }: MessageStepsCa
|
|||||||
<ChainOfThoughtContent className="px-3 pb-2">
|
<ChainOfThoughtContent className="px-3 pb-2">
|
||||||
{showAbove &&
|
{showAbove &&
|
||||||
priorToolSteps.map((s) => (
|
priorToolSteps.map((s) => (
|
||||||
<ToolStep key={s.id} step={s} status="complete" />
|
<ToolStep
|
||||||
|
key={s.id}
|
||||||
|
step={s}
|
||||||
|
status="complete"
|
||||||
|
onArtifactStepClick={onArtifactStepClick}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
<ToolStep
|
<ToolStep
|
||||||
step={lastToolStep}
|
step={lastToolStep}
|
||||||
status={streaming && trailingReasoning.length === 0 ? "active" : "complete"}
|
status={streaming && trailingReasoning.length === 0 ? "active" : "complete"}
|
||||||
|
onArtifactStepClick={onArtifactStepClick}
|
||||||
/>
|
/>
|
||||||
</ChainOfThoughtContent>
|
</ChainOfThoughtContent>
|
||||||
)}
|
)}
|
||||||
@@ -141,16 +154,30 @@ export function MessageStepsCard({ steps, streaming, className }: MessageStepsCa
|
|||||||
function ToolStep({
|
function ToolStep({
|
||||||
step,
|
step,
|
||||||
status,
|
status,
|
||||||
|
onArtifactStepClick,
|
||||||
}: {
|
}: {
|
||||||
step: BubbleStep;
|
step: BubbleStep;
|
||||||
status: "complete" | "active";
|
status: "complete" | "active";
|
||||||
|
onArtifactStepClick?: (step: BubbleStep) => void;
|
||||||
}) {
|
}) {
|
||||||
const display = getStepDisplay(step.key, step.label, step.args);
|
const display = getStepDisplay(step.key, step.label, step.args);
|
||||||
const argNode = renderStepArgs(step.key, step.args);
|
const argNode = renderStepArgs(step.key, step.args);
|
||||||
|
const [, toolName] = step.key.split(":");
|
||||||
|
const isArtifactStep =
|
||||||
|
toolName === "write_file" ||
|
||||||
|
toolName === "str_replace" ||
|
||||||
|
toolName === "present_files";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
icon={display.icon}
|
icon={display.icon}
|
||||||
status={status}
|
status={status}
|
||||||
|
className={isArtifactStep && onArtifactStepClick ? "cursor-pointer" : undefined}
|
||||||
|
onClick={
|
||||||
|
isArtifactStep && onArtifactStepClick
|
||||||
|
? () => onArtifactStepClick(step)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
label={
|
label={
|
||||||
<span className="text-[12px] text-slate-700">
|
<span className="text-[12px] text-slate-700">
|
||||||
<span className="font-medium">{display.label}</span>
|
<span className="font-medium">{display.label}</span>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Step 2 主面板 —— 多智能体圆桌研讨。
|
* Step 2 主面板 —— 多智能体圆桌研讨。
|
||||||
*
|
*
|
||||||
* 包含 4 块:
|
* 包含 4 块:
|
||||||
* 1) 顶部 Headline + 挂起/恢复按钮
|
* 1) 顶部 Headline + 推理模型下拉 + 挂起/恢复按钮
|
||||||
* 2) Persona 抽屉(点击席位头像后展开,见 `PersonaDrawer.tsx`)
|
* 2) Persona 抽屉(点击席位头像后展开,见 `PersonaDrawer.tsx`)
|
||||||
* 3) 对话流(初始化占位 + 气泡列表 + ChainOfThought 折叠卡 + markdown)
|
* 3) 对话流(初始化占位 + 气泡列表 + ChainOfThought 折叠卡 + markdown)
|
||||||
* 4) 底部澄清条 + 用户干预输入 + 「达成共识」入口
|
* 4) 底部澄清条 + 用户干预输入 + 「达成共识」入口
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Cpu,
|
Cpu,
|
||||||
|
FileIcon,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
Pause,
|
Pause,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
|
|
||||||
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
|
import { PersonaDrawer } from "@/roundtable-planning/components/PersonaDrawer";
|
||||||
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
|
||||||
|
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
|
||||||
import { getAvatar } from "@/roundtable-planning/components/Avatars";
|
import { getAvatar } from "@/roundtable-planning/components/Avatars";
|
||||||
import {
|
import {
|
||||||
MarkdownContent,
|
MarkdownContent,
|
||||||
@@ -68,6 +70,15 @@ export interface Step2PanelProps {
|
|||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
availableModels: ModelOption[];
|
availableModels: ModelOption[];
|
||||||
modelsLoading: boolean;
|
modelsLoading: boolean;
|
||||||
|
setSelectedModel: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
activeArtifactThreadId: string | null;
|
||||||
|
seatStubMessages: Record<string, unknown[]>;
|
||||||
|
openArtifactFromStep: (
|
||||||
|
dialogue: Step2Dialogue,
|
||||||
|
step: NonNullable<Step2Dialogue["steps"]>[number],
|
||||||
|
) => void;
|
||||||
|
/** 主聊天里 `present_files` 会在气泡下方渲染卡片;Step 2 沿用同一交互。 */
|
||||||
|
openArtifactPreview: (threadId: string, path: string) => void;
|
||||||
// ─── Setters ──────────────────────────────────────────────────
|
// ─── Setters ──────────────────────────────────────────────────
|
||||||
setIsRoundTableRunning: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsRoundTableRunning: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setClarificationDraft: React.Dispatch<React.SetStateAction<string>>;
|
setClarificationDraft: React.Dispatch<React.SetStateAction<string>>;
|
||||||
@@ -100,6 +111,11 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
selectedModel,
|
selectedModel,
|
||||||
availableModels,
|
availableModels,
|
||||||
modelsLoading,
|
modelsLoading,
|
||||||
|
setSelectedModel,
|
||||||
|
activeArtifactThreadId,
|
||||||
|
seatStubMessages,
|
||||||
|
openArtifactFromStep,
|
||||||
|
openArtifactPreview,
|
||||||
setIsRoundTableRunning,
|
setIsRoundTableRunning,
|
||||||
setClarificationDraft,
|
setClarificationDraft,
|
||||||
setCustomAgentInput,
|
setCustomAgentInput,
|
||||||
@@ -116,8 +132,12 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
|
const { atBottom: step2AtBottom, scrollToBottom: scrollStep2ToBottom } =
|
||||||
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
|
useAutoScroll(step2ScrollRef, [step2RoundtableDialogues]);
|
||||||
|
|
||||||
return (
|
const isSandboxStreaming =
|
||||||
<div className="flex-1 flex flex-col min-h-0 bg-slate-50/20">
|
isOrchestrating ||
|
||||||
|
step2RoundtableDialogues.some((dialogue) => dialogue.streaming === true);
|
||||||
|
|
||||||
|
const chatBody = (
|
||||||
|
<>
|
||||||
{/* Headline */}
|
{/* Headline */}
|
||||||
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm">
|
<div className="shrink-0 px-6 py-4.5 border-b border-slate-100 flex items-center justify-between bg-white shadow-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -129,19 +149,44 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5 shrink-0">
|
||||||
{isRoundTableRunning && isOrchestrating ? (
|
<select
|
||||||
|
id="step2-global-model-select"
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={(e) => setSelectedModel(e.target.value)}
|
||||||
|
disabled={modelsLoading || availableModels.length === 0}
|
||||||
|
title={
|
||||||
|
modelsLoading
|
||||||
|
? "正在加载模型列表…"
|
||||||
|
: availableModels.length === 0
|
||||||
|
? "未在 config.yaml 中配置任何模型"
|
||||||
|
: "切换全局推理模型"
|
||||||
|
}
|
||||||
|
className="max-w-[220px] bg-slate-50/50 border border-slate-200 rounded-xl px-3 py-1.5 text-xs font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
|
||||||
|
>
|
||||||
|
{availableModels.length === 0 && (
|
||||||
|
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
|
||||||
|
)}
|
||||||
|
{availableModels.map((m) => (
|
||||||
|
<option key={m.name} value={m.name}>
|
||||||
|
{m.display_name && m.display_name !== m.name
|
||||||
|
? `${m.display_name} (${m.name})`
|
||||||
|
: m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{isRoundTableRunning ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsRoundTableRunning(false);
|
setIsRoundTableRunning(false);
|
||||||
onTriggerToast("⏸️ 会商模拟暂挂!智能体挂起...");
|
onTriggerToast("⏸️ 会商已暂停");
|
||||||
}}
|
}}
|
||||||
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-amber-200/40 shadow-sm"
|
className="bg-amber-100 hover:bg-amber-200 text-amber-800 text-[10px] font-bold px-2.5 py-1 rounded-xl flex items-center gap-1 transition border border-amber-200/40 shadow-sm"
|
||||||
>
|
>
|
||||||
<Pause className="w-3.5 h-3.5" />
|
<Pause className="w-3.5 h-3.5" />
|
||||||
挂起协商
|
暂停会商
|
||||||
</button>
|
</button>
|
||||||
) : !isRoundTableRunning ? (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsRoundTableRunning(true);
|
setIsRoundTableRunning(true);
|
||||||
@@ -152,7 +197,7 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
<Play className="w-3.5 h-3.5 fill-emerald-800" />
|
<Play className="w-3.5 h-3.5 fill-emerald-800" />
|
||||||
恢复会商
|
恢复会商
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -186,7 +231,16 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{step2RoundtableDialogues.map((msg) => (
|
{step2RoundtableDialogues.map((msg) => (
|
||||||
<Step2Message key={msg.id} msg={msg} />
|
<Step2Message
|
||||||
|
key={msg.id}
|
||||||
|
msg={msg}
|
||||||
|
onArtifactStepClick={(step) => openArtifactFromStep(msg, step)}
|
||||||
|
onFileCardClick={(path) => {
|
||||||
|
if (msg.agentThreadId) {
|
||||||
|
openArtifactPreview(msg.agentThreadId, path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
|
{!step2AtBottom && <ScrollToBottomButton onClick={scrollStep2ToBottom} />}
|
||||||
@@ -301,7 +355,21 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
达成共识生成全案成果 >
|
达成共识生成全案成果 >
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeSeatMessages = activeArtifactThreadId
|
||||||
|
? seatStubMessages[activeArtifactThreadId]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Step2SandboxLayout
|
||||||
|
activeThreadId={activeArtifactThreadId}
|
||||||
|
isStreaming={isSandboxStreaming}
|
||||||
|
seatMessages={activeSeatMessages}
|
||||||
|
>
|
||||||
|
<div className="flex h-full min-h-0 flex-col bg-slate-50/20">{chatBody}</div>
|
||||||
|
</Step2SandboxLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -315,9 +383,18 @@ export function Step2Panel(props: Step2PanelProps) {
|
|||||||
*/
|
*/
|
||||||
interface Step2MessageProps {
|
interface Step2MessageProps {
|
||||||
msg: Step2Dialogue;
|
msg: Step2Dialogue;
|
||||||
|
onArtifactStepClick?: (
|
||||||
|
step: NonNullable<Step2Dialogue["steps"]>[number],
|
||||||
|
) => void;
|
||||||
|
/** 点击气泡下方 `present_files` 文件卡时打开沙箱(走真实 HTTP 路径)。 */
|
||||||
|
onFileCardClick?: (path: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Step2Message({ msg }: Step2MessageProps) {
|
function Step2Message({
|
||||||
|
msg,
|
||||||
|
onArtifactStepClick,
|
||||||
|
onFileCardClick,
|
||||||
|
}: Step2MessageProps) {
|
||||||
const isInjected = msg.sender === "用户 (插话干预)";
|
const isInjected = msg.sender === "用户 (插话干预)";
|
||||||
const isStreaming = msg.streaming === true;
|
const isStreaming = msg.streaming === true;
|
||||||
// 抽出 `<think>...</think>` 作 reasoning;剩下的是要渲染的正文。
|
// 抽出 `<think>...</think>` 作 reasoning;剩下的是要渲染的正文。
|
||||||
@@ -326,6 +403,7 @@ function Step2Message({ msg }: Step2MessageProps) {
|
|||||||
const rawContent = msg.content ?? "";
|
const rawContent = msg.content ?? "";
|
||||||
const { content, reasoning } = splitInlineReasoning(rawContent);
|
const { content, reasoning } = splitInlineReasoning(rawContent);
|
||||||
const hasContent = content.length > 0;
|
const hasContent = content.length > 0;
|
||||||
|
const presentedFiles = collectPresentedFiles(msg.steps);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageBubble
|
<MessageBubble
|
||||||
@@ -340,7 +418,11 @@ function Step2Message({ msg }: Step2MessageProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{Array.isArray(msg.steps) && msg.steps.length > 0 && (
|
{Array.isArray(msg.steps) && msg.steps.length > 0 && (
|
||||||
<MessageStepsCard steps={msg.steps} streaming={isStreaming} />
|
<MessageStepsCard
|
||||||
|
steps={msg.steps}
|
||||||
|
streaming={isStreaming}
|
||||||
|
onArtifactStepClick={onArtifactStepClick}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{reasoning && (
|
{reasoning && (
|
||||||
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
|
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
|
||||||
@@ -351,6 +433,122 @@ function Step2Message({ msg }: Step2MessageProps) {
|
|||||||
<MarkdownContent>{content}</MarkdownContent>
|
<MarkdownContent>{content}</MarkdownContent>
|
||||||
{isStreaming && <StreamingCursor />}
|
{isStreaming && <StreamingCursor />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{presentedFiles.length > 0 && (
|
||||||
|
<PresentFilesRow
|
||||||
|
files={presentedFiles}
|
||||||
|
onClick={onFileCardClick}
|
||||||
|
disabled={!msg.agentThreadId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</MessageBubble>
|
</MessageBubble>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描所有 `tool_calling:present_files` 步骤里的 `filepaths`,按出现顺序去重。
|
||||||
|
* 同一气泡可能多次 present_files(总控分批交付),都聚合到一行卡片里。
|
||||||
|
*/
|
||||||
|
function collectPresentedFiles(
|
||||||
|
steps: Step2Dialogue["steps"],
|
||||||
|
): string[] {
|
||||||
|
if (!Array.isArray(steps) || steps.length === 0) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const step of steps) {
|
||||||
|
if (step.key !== "tool_calling:present_files") continue;
|
||||||
|
const fps = step.args?.filepaths;
|
||||||
|
if (!Array.isArray(fps)) continue;
|
||||||
|
for (const fp of fps) {
|
||||||
|
if (typeof fp !== "string" || !fp || seen.has(fp)) continue;
|
||||||
|
seen.add(fp);
|
||||||
|
out.push(fp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PresentFilesRow({
|
||||||
|
files,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
files: string[];
|
||||||
|
onClick?: (path: string) => void;
|
||||||
|
disabled: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{files.map((path) => (
|
||||||
|
<PresentFileCard
|
||||||
|
key={path}
|
||||||
|
path={path}
|
||||||
|
disabled={disabled || !onClick}
|
||||||
|
onClick={() => onClick?.(path)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILE_EXT_LABELS: Record<string, string> = {
|
||||||
|
md: "Markdown",
|
||||||
|
json: "JSON",
|
||||||
|
csv: "CSV",
|
||||||
|
txt: "TXT",
|
||||||
|
py: "Python",
|
||||||
|
js: "JS",
|
||||||
|
ts: "TS",
|
||||||
|
tsx: "TSX",
|
||||||
|
jsx: "JSX",
|
||||||
|
html: "HTML",
|
||||||
|
css: "CSS",
|
||||||
|
xml: "XML",
|
||||||
|
yaml: "YAML",
|
||||||
|
yml: "YAML",
|
||||||
|
pdf: "PDF",
|
||||||
|
png: "PNG",
|
||||||
|
jpg: "JPG",
|
||||||
|
jpeg: "JPEG",
|
||||||
|
svg: "SVG",
|
||||||
|
zip: "ZIP",
|
||||||
|
};
|
||||||
|
|
||||||
|
function PresentFileCard({
|
||||||
|
path,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
path: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
const filename = path.split("/").pop() ?? path;
|
||||||
|
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
const typeLabel = FILE_EXT_LABELS[ext] ?? (ext.toUpperCase() || "FILE");
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
title={disabled ? "席位线程未就绪,暂无法预览" : path}
|
||||||
|
className="flex max-w-50 min-w-30 flex-col gap-1 rounded-lg border border-slate-200 bg-white p-3 text-left shadow-sm transition hover:border-primary/50 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
|
||||||
|
<span
|
||||||
|
className="truncate text-[12px] font-medium text-slate-700"
|
||||||
|
title={filename}
|
||||||
|
>
|
||||||
|
{filename}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-mono font-semibold text-slate-500">
|
||||||
|
{typeLabel}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400">点击预览</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* Step 2 沙箱分栏 —— 对齐主聊天 `ChatBox` 的 60/40 对话 | 产物预览布局。
|
||||||
|
*
|
||||||
|
* 圆桌 Step 2 走自定义 SSE,没有 LangGraph SDK thread;这里用最小 ThreadContext stub
|
||||||
|
* 供 `ArtifactFileDetail` / `useArtifactContent` 工作(真实 path 走 artifacts API 轮询)。
|
||||||
|
*/
|
||||||
|
import type { BaseStream } from "@langchain/langgraph-sdk/react";
|
||||||
|
import { FilesIcon, XIcon } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||||
|
|
||||||
|
import { ConversationEmptyState } from "@/components/ai-elements/conversation";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
ResizableHandle,
|
||||||
|
ResizablePanel,
|
||||||
|
ResizablePanelGroup,
|
||||||
|
} from "@/components/ui/resizable";
|
||||||
|
import {
|
||||||
|
ArtifactFileDetail,
|
||||||
|
ArtifactFileList,
|
||||||
|
useArtifacts,
|
||||||
|
} from "@/components/workspace/artifacts";
|
||||||
|
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||||
|
import type { AgentThreadState } from "@/core/threads";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const CLOSE_MODE = { chat: 100, artifacts: 0 };
|
||||||
|
const OPEN_MODE = { chat: 60, artifacts: 40 };
|
||||||
|
|
||||||
|
interface Step2SandboxLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** 当前预览文件所属的席位 thread id。 */
|
||||||
|
activeThreadId: string | null;
|
||||||
|
/** 是否有流式 run 进行中 —— 开启 artifacts API 轮询。 */
|
||||||
|
isStreaming?: boolean;
|
||||||
|
/**
|
||||||
|
* 当前 active 席位的 stub ai messages —— 让 useArtifactContent 通过虚拟 URL
|
||||||
|
* `write-file:...?message_id=...&tool_call_id=...` 直接读 tool_call.args.content,
|
||||||
|
* 对齐主聊天 chats/:threadId 的实时预览体验。
|
||||||
|
*/
|
||||||
|
seatMessages?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step2SandboxLayout({
|
||||||
|
children,
|
||||||
|
activeThreadId,
|
||||||
|
isStreaming = false,
|
||||||
|
seatMessages,
|
||||||
|
}: Step2SandboxLayoutProps) {
|
||||||
|
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||||
|
const {
|
||||||
|
artifacts,
|
||||||
|
open: artifactsOpen,
|
||||||
|
setOpen: setArtifactsOpen,
|
||||||
|
selectedArtifact,
|
||||||
|
} = useArtifacts();
|
||||||
|
|
||||||
|
const artifactPanelOpen = artifactsOpen;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (layoutRef.current) {
|
||||||
|
layoutRef.current.setLayout(
|
||||||
|
artifactPanelOpen ? OPEN_MODE : CLOSE_MODE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [artifactPanelOpen]);
|
||||||
|
|
||||||
|
const threadStub = useMemo(() => {
|
||||||
|
const messages = seatMessages ?? [];
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
isLoading: isStreaming,
|
||||||
|
isThreadLoading: false,
|
||||||
|
values: {
|
||||||
|
title: "",
|
||||||
|
messages,
|
||||||
|
artifacts,
|
||||||
|
},
|
||||||
|
} as unknown as BaseStream<AgentThreadState>;
|
||||||
|
}, [artifacts, isStreaming, seatMessages]);
|
||||||
|
|
||||||
|
const previewThreadId = activeThreadId ?? "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
|
||||||
|
<ResizablePanelGroup
|
||||||
|
id="roundtable-step2-panels"
|
||||||
|
orientation="horizontal"
|
||||||
|
defaultLayout={{ chat: 100, artifacts: 0 }}
|
||||||
|
groupRef={layoutRef}
|
||||||
|
className="h-full min-h-0 flex-1"
|
||||||
|
>
|
||||||
|
<ResizablePanel className="relative" defaultSize={100} id="chat">
|
||||||
|
{children}
|
||||||
|
</ResizablePanel>
|
||||||
|
<ResizableHandle
|
||||||
|
id="roundtable-step2-separator"
|
||||||
|
className={cn(
|
||||||
|
"opacity-33 hover:opacity-100",
|
||||||
|
!artifactPanelOpen && "pointer-events-none opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<ResizablePanel
|
||||||
|
className={cn(
|
||||||
|
"bg-[#fffdfa] transition-all duration-300 ease-in-out",
|
||||||
|
!artifactsOpen && "opacity-0",
|
||||||
|
)}
|
||||||
|
id="artifacts"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full bg-[#fffdfa] p-4 transition-transform duration-300 ease-in-out",
|
||||||
|
artifactPanelOpen ? "translate-x-0" : "translate-x-full",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedArtifact && previewThreadId ? (
|
||||||
|
<ArtifactFileDetail
|
||||||
|
className="size-full"
|
||||||
|
filepath={selectedArtifact}
|
||||||
|
threadId={previewThreadId}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="relative flex size-full justify-center">
|
||||||
|
<div className="absolute top-1 right-1 z-30">
|
||||||
|
<Button
|
||||||
|
size="icon-sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setArtifactsOpen(false)}
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{artifacts.length === 0 ? (
|
||||||
|
<ConversationEmptyState
|
||||||
|
icon={<FilesIcon />}
|
||||||
|
title="未选择文件"
|
||||||
|
description="智能体写入文件后将在此预览"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex size-full max-w-(--container-width-sm) flex-col justify-center p-4 pt-8">
|
||||||
|
<header className="shrink-0">
|
||||||
|
<h2 className="text-lg font-medium">产出文件</h2>
|
||||||
|
</header>
|
||||||
|
<main className="min-h-0 grow">
|
||||||
|
{previewThreadId ? (
|
||||||
|
<ArtifactFileList
|
||||||
|
className="max-w-(--container-width-sm) p-4 pt-12"
|
||||||
|
files={artifacts}
|
||||||
|
threadId={previewThreadId}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</ThreadContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
|
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||||
import {
|
import {
|
||||||
initMultiAgent,
|
initMultiAgent,
|
||||||
streamMultiAgent,
|
streamMultiAgent,
|
||||||
@@ -44,6 +45,8 @@ export type Step2Dialogue = {
|
|||||||
time: string;
|
time: string;
|
||||||
confidence: string;
|
confidence: string;
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
|
/** special 席位气泡对应的 LangGraph thread,用于沙箱预览。 */
|
||||||
|
agentThreadId?: string;
|
||||||
steps?: Array<{
|
steps?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
key: string;
|
key: string;
|
||||||
@@ -53,6 +56,24 @@ export type Step2Dialogue = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub AI message —— 用于把 special seat SSE 拿到的工具调用注入到 ThreadContext
|
||||||
|
* 的 `thread.messages` 里,让 `useArtifactContent` 在沙箱里通过虚拟 URL
|
||||||
|
* `write-file:<path>?message_id=...&tool_call_id=...` 直接读 `args.content`,
|
||||||
|
* 完全对齐主聊天 `chats/:threadId` 的 write_file 实时预览体验。
|
||||||
|
*/
|
||||||
|
export interface StubToolCall {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
export interface StubAiMessage {
|
||||||
|
id: string;
|
||||||
|
type: "ai";
|
||||||
|
content: string;
|
||||||
|
tool_calls: StubToolCall[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Persona 抽屉的 per-seat 覆盖(prompt / temp 仅前端记录,model 真实生效)。 */
|
/** Persona 抽屉的 per-seat 覆盖(prompt / temp 仅前端记录,model 真实生效)。 */
|
||||||
export type AgentConfig = {
|
export type AgentConfig = {
|
||||||
model: string;
|
model: string;
|
||||||
@@ -70,6 +91,12 @@ export type Step2Snapshot = {
|
|||||||
hasConsensus: boolean;
|
hasConsensus: boolean;
|
||||||
consensusPercentage: number;
|
consensusPercentage: number;
|
||||||
budgetLimit: number;
|
budgetLimit: number;
|
||||||
|
/**
|
||||||
|
* 每个 seat thread 的 stub ai messages —— 加载草稿后点击历史 write_file 步骤,
|
||||||
|
* 沙箱要能复读 args.content。不存这部分 stub 的话,虚拟 URL 解析会拿不到内容。
|
||||||
|
* 老草稿可能没有这字段,反序列化时按空对象兜底。
|
||||||
|
*/
|
||||||
|
seatStubMessages: Record<string, StubAiMessage[]>;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -158,6 +185,24 @@ export interface UseStep2OrchestrationReturn {
|
|||||||
hydrateFromDraft: (snapshot: Step2Snapshot) => void;
|
hydrateFromDraft: (snapshot: Step2Snapshot) => void;
|
||||||
/** 「新建任务」清空 Step 2 数据(threadIds / dialogues / 共识等)。 */
|
/** 「新建任务」清空 Step 2 数据(threadIds / dialogues / 共识等)。 */
|
||||||
resetStep2State: () => void;
|
resetStep2State: () => void;
|
||||||
|
/** 当前沙箱预览绑定的席位 thread id。 */
|
||||||
|
activeArtifactThreadId: string | null;
|
||||||
|
/**
|
||||||
|
* 各 special seat 的 stub messages —— 注入到沙箱 ThreadContext.thread.messages,
|
||||||
|
* 让 `useArtifactContent` 通过虚拟 URL 直接读 `args.content`(对齐主聊天)。
|
||||||
|
*/
|
||||||
|
seatStubMessages: Record<string, StubAiMessage[]>;
|
||||||
|
/** 从工具步骤或流式 write_file 打开沙箱预览。 */
|
||||||
|
openArtifactPreview: (
|
||||||
|
threadId: string,
|
||||||
|
path: string,
|
||||||
|
options?: { auto?: boolean },
|
||||||
|
) => void;
|
||||||
|
/** 点击气泡内工具步骤时打开沙箱。 */
|
||||||
|
openArtifactFromStep: (
|
||||||
|
dialogue: Step2Dialogue,
|
||||||
|
step: NonNullable<Step2Dialogue["steps"]>[number],
|
||||||
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStep2Orchestration(
|
export function useStep2Orchestration(
|
||||||
@@ -172,7 +217,26 @@ export function useStep2Orchestration(
|
|||||||
loadedFromDraftRef,
|
loadedFromDraftRef,
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
|
const {
|
||||||
|
artifacts,
|
||||||
|
setArtifacts,
|
||||||
|
select: selectArtifact,
|
||||||
|
setOpen: setArtifactsOpen,
|
||||||
|
deselect: deselectArtifact,
|
||||||
|
} = useArtifacts();
|
||||||
|
const artifactPathsRef = useRef<string[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
artifactPathsRef.current = artifacts;
|
||||||
|
}, [artifacts]);
|
||||||
|
|
||||||
// ─── State ─────────────────────────────────────────────────────────
|
// ─── State ─────────────────────────────────────────────────────────
|
||||||
|
const [activeArtifactThreadId, setActiveArtifactThreadId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [seatStubMessages, setSeatStubMessages] = useState<
|
||||||
|
Record<string, StubAiMessage[]>
|
||||||
|
>({});
|
||||||
|
const seatStubMessagesRef = useRef<Record<string, StubAiMessage[]>>({});
|
||||||
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(
|
const [selectedAgents, setSelectedAgents] = useState<SelectedAgent[]>(
|
||||||
DEFAULT_SELECTED_AGENTS,
|
DEFAULT_SELECTED_AGENTS,
|
||||||
);
|
);
|
||||||
@@ -222,6 +286,9 @@ export function useStep2Orchestration(
|
|||||||
const orchestrationCancelRef = useRef<boolean>(false);
|
const orchestrationCancelRef = useRef<boolean>(false);
|
||||||
const step2InitRef = useRef<boolean>(false);
|
const step2InitRef = useRef<boolean>(false);
|
||||||
const activeAbortersRef = useRef<Set<AbortController>>(new Set());
|
const activeAbortersRef = useRef<Set<AbortController>>(new Set());
|
||||||
|
/** 干预研审:暂停当前轮次后待执行的 leader prompt。 */
|
||||||
|
const pendingInjectPromptRef = useRef<string | null>(null);
|
||||||
|
const [injectFlushToken, setInjectFlushToken] = useState(0);
|
||||||
// selectedModel / agentConfigs / agentNameToRole / threadIds / coordinatorName
|
// selectedModel / agentConfigs / agentNameToRole / threadIds / coordinatorName
|
||||||
// 等等被 runOrchestration 在循环里反复读取。把它们打到 ref 上,可以让
|
// 等等被 runOrchestration 在循环里反复读取。把它们打到 ref 上,可以让
|
||||||
// runOrchestration 的 useCallback deps 大幅收窄,避免每次模型切换、agentConfigs
|
// runOrchestration 的 useCallback deps 大幅收窄,避免每次模型切换、agentConfigs
|
||||||
@@ -251,6 +318,241 @@ export function useStep2Orchestration(
|
|||||||
isOrchestratingRef.current = isOrchestrating;
|
isOrchestratingRef.current = isOrchestrating;
|
||||||
}, [isOrchestrating]);
|
}, [isOrchestrating]);
|
||||||
|
|
||||||
|
const resolveSeatThreadId = useCallback((ids: ThreadIdMap, agentName: string) => {
|
||||||
|
return ids[agentName] ?? ids[agentName.toLowerCase()];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把工具调用幂等地写进 seat 的 stub ai message —— **同 (toolName, path)** 视为同一次
|
||||||
|
* 调用,后续帧只更新 args(LangGraph 流式期间会多次发送完整 tool_calls 覆盖 args)。
|
||||||
|
* 返回的 `messageId`/`toolCallId` 用于拼出 `write-file:` 虚拟 URL,沙箱 useArtifactContent
|
||||||
|
* 据此从 thread.messages 取出 args.content,完全绕过 HTTP。
|
||||||
|
*/
|
||||||
|
const upsertSeatToolCall = useCallback(
|
||||||
|
(
|
||||||
|
seatThreadId: string,
|
||||||
|
toolName: string,
|
||||||
|
args: Record<string, unknown>,
|
||||||
|
): { messageId: string; toolCallId: string } => {
|
||||||
|
const path = typeof args.path === "string" ? args.path : "";
|
||||||
|
const current = seatStubMessagesRef.current[seatThreadId] ?? [];
|
||||||
|
const existing = current[0];
|
||||||
|
const baseMsg: StubAiMessage = existing ?? {
|
||||||
|
id: `roundtable-stub-${seatThreadId}`,
|
||||||
|
type: "ai",
|
||||||
|
content: "",
|
||||||
|
tool_calls: [],
|
||||||
|
};
|
||||||
|
const idx = baseMsg.tool_calls.findIndex(
|
||||||
|
(tc) =>
|
||||||
|
tc.name === toolName &&
|
||||||
|
String((tc.args as { path?: unknown })?.path ?? "") === path,
|
||||||
|
);
|
||||||
|
const toolCallId =
|
||||||
|
idx >= 0
|
||||||
|
? baseMsg.tool_calls[idx].id
|
||||||
|
: `tc-${seatThreadId}-${baseMsg.tool_calls.length}`;
|
||||||
|
const nextToolCall: StubToolCall = {
|
||||||
|
id: toolCallId,
|
||||||
|
name: toolName,
|
||||||
|
args,
|
||||||
|
};
|
||||||
|
const nextToolCalls =
|
||||||
|
idx >= 0
|
||||||
|
? baseMsg.tool_calls.map((tc, i) =>
|
||||||
|
i === idx ? nextToolCall : tc,
|
||||||
|
)
|
||||||
|
: [...baseMsg.tool_calls, nextToolCall];
|
||||||
|
const nextAiMsg: StubAiMessage = {
|
||||||
|
...baseMsg,
|
||||||
|
tool_calls: nextToolCalls,
|
||||||
|
};
|
||||||
|
const nextMap = {
|
||||||
|
...seatStubMessagesRef.current,
|
||||||
|
[seatThreadId]: [nextAiMsg],
|
||||||
|
};
|
||||||
|
seatStubMessagesRef.current = nextMap;
|
||||||
|
setSeatStubMessages(nextMap);
|
||||||
|
return { messageId: nextAiMsg.id, toolCallId };
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用「虚拟 URL」(write-file: scheme)打开沙箱 —— 内容来自 stub messages 的 args.content,
|
||||||
|
* 不污染 `artifacts[]` 真实路径列表(列表后续可由 present_files / 显式 setArtifacts 填充)。
|
||||||
|
*/
|
||||||
|
const openSeatVirtualArtifact = useCallback(
|
||||||
|
(seatThreadId: string, virtualUrl: string, auto: boolean) => {
|
||||||
|
setActiveArtifactThreadId(seatThreadId);
|
||||||
|
const open = () => {
|
||||||
|
selectArtifact(virtualUrl, auto);
|
||||||
|
setArtifactsOpen(true);
|
||||||
|
};
|
||||||
|
if (auto) {
|
||||||
|
window.setTimeout(open, 100);
|
||||||
|
} else {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectArtifact, setArtifactsOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const buildWriteFileVirtualUrl = useCallback(
|
||||||
|
(path: string, messageId: string, toolCallId: string) =>
|
||||||
|
new URL(
|
||||||
|
`write-file:${path}?message_id=${encodeURIComponent(
|
||||||
|
messageId,
|
||||||
|
)}&tool_call_id=${encodeURIComponent(toolCallId)}`,
|
||||||
|
).toString(),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `openArtifactPreview` —— 走真实路径(present_files 等已落盘场景),走 HTTP API 加载。
|
||||||
|
* 沙箱 write_file 实时预览请用 `openSeatVirtualArtifact`。
|
||||||
|
*/
|
||||||
|
const openArtifactPreview = useCallback(
|
||||||
|
(threadId: string, path: string, options?: { auto?: boolean }) => {
|
||||||
|
const normalized = path.trim();
|
||||||
|
if (!normalized || !threadId) return;
|
||||||
|
|
||||||
|
setActiveArtifactThreadId(threadId);
|
||||||
|
const prev = artifactPathsRef.current;
|
||||||
|
if (!prev.includes(normalized)) {
|
||||||
|
const next = [...prev, normalized];
|
||||||
|
artifactPathsRef.current = next;
|
||||||
|
setArtifacts(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const openPanel = () => {
|
||||||
|
selectArtifact(normalized, options?.auto ?? false);
|
||||||
|
setArtifactsOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options?.auto) {
|
||||||
|
window.setTimeout(openPanel, 100);
|
||||||
|
} else {
|
||||||
|
openPanel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectArtifact, setArtifacts, setArtifactsOpen],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openArtifactFromStep = useCallback(
|
||||||
|
(
|
||||||
|
dialogue: Step2Dialogue,
|
||||||
|
step: NonNullable<Step2Dialogue["steps"]>[number],
|
||||||
|
) => {
|
||||||
|
const seatThreadId = dialogue.agentThreadId;
|
||||||
|
if (!seatThreadId) return;
|
||||||
|
const [, toolName] = step.key.split(":");
|
||||||
|
if (toolName === "write_file" || toolName === "str_replace") {
|
||||||
|
const path = step.args?.path;
|
||||||
|
if (typeof path !== "string" || !step.args) return;
|
||||||
|
const { messageId, toolCallId } = upsertSeatToolCall(
|
||||||
|
seatThreadId,
|
||||||
|
toolName,
|
||||||
|
step.args,
|
||||||
|
);
|
||||||
|
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
|
||||||
|
openSeatVirtualArtifact(seatThreadId, virtualUrl, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (toolName === "present_files") {
|
||||||
|
const filepaths = step.args?.filepaths;
|
||||||
|
if (Array.isArray(filepaths)) {
|
||||||
|
const first = filepaths.find(
|
||||||
|
(p): p is string => typeof p === "string" && p.length > 0,
|
||||||
|
);
|
||||||
|
if (first) openArtifactPreview(seatThreadId, first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
buildWriteFileVirtualUrl,
|
||||||
|
openArtifactPreview,
|
||||||
|
openSeatVirtualArtifact,
|
||||||
|
upsertSeatToolCall,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleArtifactToolPhase = useCallback(
|
||||||
|
(
|
||||||
|
phase: StreamPhase,
|
||||||
|
toolName: string | undefined,
|
||||||
|
args: Record<string, unknown> | undefined,
|
||||||
|
seatThreadId: string | undefined,
|
||||||
|
) => {
|
||||||
|
if (phase !== "tool_calling") return;
|
||||||
|
if (!seatThreadId) return;
|
||||||
|
const isArtifactTool =
|
||||||
|
toolName === "write_file" || toolName === "str_replace";
|
||||||
|
if (!isArtifactTool) return;
|
||||||
|
const path = args?.path;
|
||||||
|
if (typeof path !== "string" || !path || !args) {
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
console.debug(
|
||||||
|
"[roundtable-sandbox] tool_calling without path yet",
|
||||||
|
{ toolName, args },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { messageId, toolCallId } = upsertSeatToolCall(
|
||||||
|
seatThreadId,
|
||||||
|
toolName as string,
|
||||||
|
args,
|
||||||
|
);
|
||||||
|
const virtualUrl = buildWriteFileVirtualUrl(path, messageId, toolCallId);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
console.debug(
|
||||||
|
"[roundtable-sandbox] auto-open from phase",
|
||||||
|
{ toolName, path, seatThreadId, virtualUrl },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
openSeatVirtualArtifact(seatThreadId, virtualUrl, true);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
buildWriteFileVirtualUrl,
|
||||||
|
openSeatVirtualArtifact,
|
||||||
|
upsertSeatToolCall,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兜底:从 LangGraph messages-tuple 帧里扫描完整 tool_calls,把 args 持续灌进 stub。
|
||||||
|
* 与 `handleArtifactToolPhase` 相比:
|
||||||
|
* - 后者依赖 `onPhaseChange` 在 phase==="tool_calling" 时回调,首次出现 path 即触发自动打开;
|
||||||
|
* - 前者覆盖「phase 已切到 tool_result / streaming 后,后续帧又带回完整 tool_calls」的边界,
|
||||||
|
* 保证沙箱 MD 始终拿到 args.content 的最新版本(LangGraph 在收口阶段仍可能再发一次)。
|
||||||
|
* 不做自动打开 —— 那是 handleArtifactToolPhase 的职责;这里只做 args 同步。
|
||||||
|
*/
|
||||||
|
const handleUpstreamFrame = useCallback(
|
||||||
|
(frame: unknown, seatThreadId: string | undefined) => {
|
||||||
|
if (!seatThreadId) return;
|
||||||
|
if (!Array.isArray(frame) || frame.length === 0) return;
|
||||||
|
const chunk = frame[0];
|
||||||
|
if (!chunk || typeof chunk !== "object") return;
|
||||||
|
const obj = chunk as Record<string, unknown>;
|
||||||
|
const toolCalls = obj.tool_calls;
|
||||||
|
if (!Array.isArray(toolCalls) || toolCalls.length === 0) return;
|
||||||
|
for (const tc of toolCalls) {
|
||||||
|
if (!tc || typeof tc !== "object") continue;
|
||||||
|
const tcObj = tc as Record<string, unknown>;
|
||||||
|
const name = typeof tcObj.name === "string" ? tcObj.name : "";
|
||||||
|
if (name !== "write_file" && name !== "str_replace") continue;
|
||||||
|
const tcArgs = tcObj.args;
|
||||||
|
if (!tcArgs || typeof tcArgs !== "object") continue;
|
||||||
|
const argsObj = tcArgs as Record<string, unknown>;
|
||||||
|
const path = argsObj.path;
|
||||||
|
if (typeof path !== "string" || !path) continue;
|
||||||
|
upsertSeatToolCall(seatThreadId, name, argsObj);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[upsertSeatToolCall],
|
||||||
|
);
|
||||||
|
|
||||||
// ─── Dialogue / Speaker helpers ────────────────────────────────────
|
// ─── Dialogue / Speaker helpers ────────────────────────────────────
|
||||||
const appendDialogue = useCallback(
|
const appendDialogue = useCallback(
|
||||||
(card: {
|
(card: {
|
||||||
@@ -349,6 +651,7 @@ export function useStep2Orchestration(
|
|||||||
avatarType: string;
|
avatarType: string;
|
||||||
initialContent?: string;
|
initialContent?: string;
|
||||||
confidence?: string;
|
confidence?: string;
|
||||||
|
agentThreadId?: string;
|
||||||
}): number => {
|
}): number => {
|
||||||
const id = Date.now() + Math.random();
|
const id = Date.now() + Math.random();
|
||||||
setStep2RoundtableDialogues((prev) => [
|
setStep2RoundtableDialogues((prev) => [
|
||||||
@@ -362,6 +665,7 @@ export function useStep2Orchestration(
|
|||||||
confidence: card.confidence ?? "正在输出…",
|
confidence: card.confidence ?? "正在输出…",
|
||||||
streaming: true,
|
streaming: true,
|
||||||
steps: [],
|
steps: [],
|
||||||
|
agentThreadId: card.agentThreadId,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
return id;
|
return id;
|
||||||
@@ -587,11 +891,13 @@ export function useStep2Orchestration(
|
|||||||
? agentConfigsRef.current[meta.speakerId]?.model
|
? agentConfigsRef.current[meta.speakerId]?.model
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const seatThreadId = resolveSeatThreadId(ids, subName);
|
||||||
const subBubbleId = appendStreamingDialogue({
|
const subBubbleId = appendStreamingDialogue({
|
||||||
sender: meta?.displayName ?? subName,
|
sender: meta?.displayName ?? subName,
|
||||||
avatarType: meta?.avatarType ?? "blue",
|
avatarType: meta?.avatarType ?? "blue",
|
||||||
initialContent: "",
|
initialContent: "",
|
||||||
confidence: "调度中",
|
confidence: "调度中",
|
||||||
|
agentThreadId: seatThreadId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const subAborter = new AbortController();
|
const subAborter = new AbortController();
|
||||||
@@ -616,7 +922,15 @@ export function useStep2Orchestration(
|
|||||||
`${phase}:${toolName ?? ""}`,
|
`${phase}:${toolName ?? ""}`,
|
||||||
args,
|
args,
|
||||||
);
|
);
|
||||||
|
handleArtifactToolPhase(
|
||||||
|
phase,
|
||||||
|
toolName,
|
||||||
|
args,
|
||||||
|
seatThreadId,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
|
onUpstreamFrame: (frame) =>
|
||||||
|
handleUpstreamFrame(frame, seatThreadId),
|
||||||
});
|
});
|
||||||
if (orchestrationCancelRef.current) {
|
if (orchestrationCancelRef.current) {
|
||||||
finalizeDialogue(
|
finalizeDialogue(
|
||||||
@@ -684,6 +998,9 @@ export function useStep2Orchestration(
|
|||||||
markSpeakerIdle,
|
markSpeakerIdle,
|
||||||
clearAllStreamingSpeakers,
|
clearAllStreamingSpeakers,
|
||||||
triggerToast,
|
triggerToast,
|
||||||
|
handleArtifactToolPhase,
|
||||||
|
handleUpstreamFrame,
|
||||||
|
resolveSeatThreadId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -771,7 +1088,11 @@ export function useStep2Orchestration(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentStep !== 2) return;
|
if (currentStep !== 2) return;
|
||||||
if (!isRoundTableRunning) {
|
if (!isRoundTableRunning) {
|
||||||
stopOrchestration();
|
// 干预研审会先写入 pending 再置 false;此时 stop 已在 handler 里触发,
|
||||||
|
// 避免 effect 再次 stop 误杀随后 flush 启动的 inject 编排。
|
||||||
|
if (!pendingInjectPromptRef.current) {
|
||||||
|
stopOrchestration();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
@@ -789,6 +1110,21 @@ export function useStep2Orchestration(
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isRoundTableRunning]);
|
}, [isRoundTableRunning]);
|
||||||
|
|
||||||
|
// 干预研审:当前轮次停止后 flush 待执行的 inject 编排(编排期间 UI 仍为「恢复会商」)。
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOrchestrating) return;
|
||||||
|
const pending = pendingInjectPromptRef.current;
|
||||||
|
if (!pending) return;
|
||||||
|
if (!threadIdsRef.current || !coordinatorNameRef.current) return;
|
||||||
|
pendingInjectPromptRef.current = null;
|
||||||
|
triggerToast("您的人工纠偏指令已同步至圆桌节点!");
|
||||||
|
void runOrchestration(
|
||||||
|
pending,
|
||||||
|
threadIdsRef.current,
|
||||||
|
coordinatorNameRef.current,
|
||||||
|
);
|
||||||
|
}, [isOrchestrating, injectFlushToken, runOrchestration, triggerToast]);
|
||||||
|
|
||||||
// ─── Public handlers ─────────────────────────────────────────────
|
// ─── Public handlers ─────────────────────────────────────────────
|
||||||
const handleInjectToRoundtable = useCallback(() => {
|
const handleInjectToRoundtable = useCallback(() => {
|
||||||
if (!customAgentInput.trim()) return;
|
if (!customAgentInput.trim()) return;
|
||||||
@@ -796,28 +1132,31 @@ export function useStep2Orchestration(
|
|||||||
triggerToast("⚠️ 圆桌尚未就绪,请稍候");
|
triggerToast("⚠️ 圆桌尚未就绪,请稍候");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isOrchestratingRef.current) {
|
|
||||||
triggerToast("⏳ 当前轮次仍在进行中,请等待或暂停后再发送");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userText = customAgentInput.trim();
|
const userText = customAgentInput.trim();
|
||||||
setCustomAgentInput("");
|
setCustomAgentInput("");
|
||||||
|
|
||||||
|
const prompt = `用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`;
|
||||||
|
pendingInjectPromptRef.current = prompt;
|
||||||
|
|
||||||
|
// 与 Step2Panel 右上角「暂停会商 / 恢复会商」联动:立即进入挂起态。
|
||||||
|
setIsRoundTableRunning(false);
|
||||||
|
stopOrchestration();
|
||||||
|
|
||||||
appendDialogue({
|
appendDialogue({
|
||||||
sender: "用户 (插话干预)",
|
sender: "用户 (插话干预)",
|
||||||
avatarType: "human",
|
avatarType: "human",
|
||||||
content: `💡 (人工指令介入) ${userText}`,
|
content: `💡 (人工指令介入) ${userText}`,
|
||||||
confidence: "最高指令",
|
confidence: "最高指令",
|
||||||
});
|
});
|
||||||
triggerToast("您的人工纠偏指令已同步至圆桌节点!");
|
|
||||||
|
|
||||||
void runOrchestration(
|
if (isOrchestratingRef.current) {
|
||||||
`用户人工干预新指令:${userText}\n\n请结合当前讨论状态,决定是否继续派活或直接给出更新后的结论。`,
|
triggerToast("⏸️ 会商已暂停,当前轮次结束后将处理您的干预指令");
|
||||||
threadIdsRef.current,
|
} else {
|
||||||
coordinatorNameRef.current,
|
triggerToast("⏸️ 会商已暂停,正在处理您的干预指令…");
|
||||||
);
|
}
|
||||||
}, [appendDialogue, customAgentInput, runOrchestration, triggerToast]);
|
setInjectFlushToken((n) => n + 1);
|
||||||
|
}, [appendDialogue, customAgentInput, stopOrchestration, triggerToast]);
|
||||||
|
|
||||||
const submitClarification = useCallback(
|
const submitClarification = useCallback(
|
||||||
(rawAnswer: string) => {
|
(rawAnswer: string) => {
|
||||||
@@ -939,6 +1278,9 @@ export function useStep2Orchestration(
|
|||||||
setHasConsensus(snapshot.hasConsensus);
|
setHasConsensus(snapshot.hasConsensus);
|
||||||
setConsensusPercentage(snapshot.consensusPercentage);
|
setConsensusPercentage(snapshot.consensusPercentage);
|
||||||
setBudgetLimit(snapshot.budgetLimit);
|
setBudgetLimit(snapshot.budgetLimit);
|
||||||
|
const restoredStubs = snapshot.seatStubMessages ?? {};
|
||||||
|
seatStubMessagesRef.current = restoredStubs;
|
||||||
|
setSeatStubMessages(restoredStubs);
|
||||||
// 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上,
|
// 调用方根据 furthestStep 决定是否置 init 闸门为已 init;这里默认置上,
|
||||||
// 调用方需要的话再 resetInitGate()。
|
// 调用方需要的话再 resetInitGate()。
|
||||||
step2InitRef.current = true;
|
step2InitRef.current = true;
|
||||||
@@ -960,8 +1302,15 @@ export function useStep2Orchestration(
|
|||||||
setLastLeaderContent("");
|
setLastLeaderContent("");
|
||||||
setHasConsensus(false);
|
setHasConsensus(false);
|
||||||
setConsensusPercentage(0);
|
setConsensusPercentage(0);
|
||||||
|
setActiveArtifactThreadId(null);
|
||||||
|
artifactPathsRef.current = [];
|
||||||
|
setArtifacts([]);
|
||||||
|
seatStubMessagesRef.current = {};
|
||||||
|
setSeatStubMessages({});
|
||||||
|
pendingInjectPromptRef.current = null;
|
||||||
|
deselectArtifact();
|
||||||
step2InitRef.current = false;
|
step2InitRef.current = false;
|
||||||
}, [abortAllInFlight]);
|
}, [abortAllInFlight, deselectArtifact, setArtifacts]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectedAgents,
|
selectedAgents,
|
||||||
@@ -1003,5 +1352,9 @@ export function useStep2Orchestration(
|
|||||||
handleSaveAgentAndRecalculate,
|
handleSaveAgentAndRecalculate,
|
||||||
hydrateFromDraft,
|
hydrateFromDraft,
|
||||||
resetStep2State,
|
resetStep2State,
|
||||||
|
activeArtifactThreadId,
|
||||||
|
seatStubMessages,
|
||||||
|
openArtifactPreview,
|
||||||
|
openArtifactFromStep,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
*
|
*
|
||||||
* 重构后的主文件只负责"组合 + 编排":
|
* 重构后的主文件只负责"组合 + 编排":
|
||||||
* - 顶栏(步骤切换 + 草稿菜单 + 新建任务 + 设置)
|
* - 顶栏(步骤切换 + 草稿菜单 + 新建任务 + 设置)
|
||||||
* - 左侧栏(任务概览 + 角色面板 + 人工参与)
|
* - 左侧栏:Step1/2 为任务概览 + 角色 + 人工参与;Step2 侧栏可整体收起为图标条,含可收起的意图理解
|
||||||
* - 中间视区:Step1Panel / Step2Panel / Step3Panel(`currentStep` 切换)
|
* - 中间视区:Step1Panel / Step2Panel / Step3Panel(`currentStep` 切换);Step2 顶栏含推理模型下拉
|
||||||
* - 右侧栏(推理模型 / 当前意图摘要 / 澄清记录)
|
* - 右侧栏:仅 Step1(推理模型 / 当前意图摘要 / 澄清记录);Step2/3 隐藏以让中间区自适应
|
||||||
* - 推荐弹窗 + Toast
|
* - 推荐弹窗 + Toast
|
||||||
*
|
*
|
||||||
* 业务状态全部下沉到 hook:
|
* 业务状态全部下沉到 hook:
|
||||||
@@ -27,6 +27,10 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
PanelLeftClose,
|
||||||
|
PanelLeftOpen,
|
||||||
Clipboard,
|
Clipboard,
|
||||||
Clock,
|
Clock,
|
||||||
Cpu,
|
Cpu,
|
||||||
@@ -42,6 +46,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useTheme } from "@/contexts/ThemeContext";
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
import { useArtifacts } from "@/components/workspace/artifacts";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useModels } from "@/core/models/hooks";
|
import { useModels } from "@/core/models/hooks";
|
||||||
|
|
||||||
@@ -77,6 +82,7 @@ export default function RoundtablePlanningPage() {
|
|||||||
const [showToast, setShowToast] = useState<string | null>(null);
|
const [showToast, setShowToast] = useState<string | null>(null);
|
||||||
const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState<boolean>(false);
|
const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState<boolean>(false);
|
||||||
const [selectedModel, setSelectedModel] = useState<string>("");
|
const [selectedModel, setSelectedModel] = useState<string>("");
|
||||||
|
const [step2SidebarExpanded, setStep2SidebarExpanded] = useState(true);
|
||||||
|
|
||||||
// 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在
|
// 角色面板(LeftSidebar)。原页面把它当成两步共用的展示数据,这里继续放在
|
||||||
// 主页 —— Step 1 / Step 2 / Step 3 都会读 / 写它,任何 hook 单独持有都不合适。
|
// 主页 —— Step 1 / Step 2 / Step 3 都会读 / 写它,任何 hook 单独持有都不合适。
|
||||||
@@ -188,6 +194,7 @@ export default function RoundtablePlanningPage() {
|
|||||||
hasConsensus: step2.hasConsensus,
|
hasConsensus: step2.hasConsensus,
|
||||||
consensusPercentage: step2.consensusPercentage,
|
consensusPercentage: step2.consensusPercentage,
|
||||||
budgetLimit: step2.budgetLimit,
|
budgetLimit: step2.budgetLimit,
|
||||||
|
seatStubMessages: step2.seatStubMessages,
|
||||||
}),
|
}),
|
||||||
hydrateStep1: step1.hydrateFromDraft,
|
hydrateStep1: step1.hydrateFromDraft,
|
||||||
hydrateStep2: step2.hydrateFromDraft,
|
hydrateStep2: step2.hydrateFromDraft,
|
||||||
@@ -345,37 +352,41 @@ export default function RoundtablePlanningPage() {
|
|||||||
新建任务
|
新建任务
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
{currentStep !== 2 && (
|
||||||
id="start-clear-btn"
|
<>
|
||||||
onClick={() => {
|
<button
|
||||||
setCurrentStep(1);
|
id="start-clear-btn"
|
||||||
triggerToast("已切换至第一阶段:人机问答意图澄清");
|
onClick={() => {
|
||||||
}}
|
setCurrentStep(1);
|
||||||
className="bg-primary text-white hover:bg-[var(--brand-900)] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all shadow-sm shadow-primary/10 active:scale-95"
|
triggerToast("已切换至第一阶段:人机问答意图澄清");
|
||||||
>
|
}}
|
||||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
|
className="bg-primary text-white hover:bg-[var(--brand-900)] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all shadow-sm shadow-primary/10 active:scale-95"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
>
|
||||||
</svg>
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
|
||||||
开始澄清
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
</button>
|
</svg>
|
||||||
|
开始澄清
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="human-intervention-btn"
|
id="human-intervention-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRoles((prev) =>
|
setRoles((prev) =>
|
||||||
prev.map((r) =>
|
prev.map((r) =>
|
||||||
r.id === 1
|
r.id === 1
|
||||||
? { ...r, status: r.status === "在线" ? "等待激活" : "在线" }
|
? { ...r, status: r.status === "在线" ? "等待激活" : "在线" }
|
||||||
: r,
|
: r,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
triggerToast("指挥专家状态变更!人工介入切换");
|
triggerToast("指挥专家状态变更!人工介入切换");
|
||||||
}}
|
}}
|
||||||
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
|
className="bg-white hover:bg-slate-50 border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition-all dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
|
||||||
>
|
>
|
||||||
<Users className="w-3.5 h-3.5 text-slate-500 dark:text-muted-foreground" />
|
<Users className="w-3.5 h-3.5 text-slate-500 dark:text-muted-foreground" />
|
||||||
人工介入
|
人工介入
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
id="save-draft-header-btn"
|
id="save-draft-header-btn"
|
||||||
@@ -415,6 +426,11 @@ export default function RoundtablePlanningPage() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* MAIN */}
|
{/* MAIN */}
|
||||||
|
<Step2SandboxSidebarSync
|
||||||
|
currentStep={currentStep}
|
||||||
|
sidebarExpanded={step2SidebarExpanded}
|
||||||
|
setSidebarExpanded={setStep2SidebarExpanded}
|
||||||
|
/>
|
||||||
<main className="flex-1 overflow-hidden p-4 flex gap-4 min-h-0">
|
<main className="flex-1 overflow-hidden p-4 flex gap-4 min-h-0">
|
||||||
{/* LEFT SIDEBAR */}
|
{/* LEFT SIDEBAR */}
|
||||||
<LeftSidebar
|
<LeftSidebar
|
||||||
@@ -423,6 +439,9 @@ export default function RoundtablePlanningPage() {
|
|||||||
agentNameToRole={step2.agentNameToRole}
|
agentNameToRole={step2.agentNameToRole}
|
||||||
selectedAgents={step2.selectedAgents}
|
selectedAgents={step2.selectedAgents}
|
||||||
budgetLimit={step2.budgetLimit}
|
budgetLimit={step2.budgetLimit}
|
||||||
|
intentReady={step1.intentReady}
|
||||||
|
sidebarExpanded={step2SidebarExpanded}
|
||||||
|
setSidebarExpanded={setStep2SidebarExpanded}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* MIDDLE VIEWPORT */}
|
{/* MIDDLE VIEWPORT */}
|
||||||
@@ -472,6 +491,11 @@ export default function RoundtablePlanningPage() {
|
|||||||
selectedModel={selectedModel}
|
selectedModel={selectedModel}
|
||||||
availableModels={availableModels}
|
availableModels={availableModels}
|
||||||
modelsLoading={modelsLoading}
|
modelsLoading={modelsLoading}
|
||||||
|
setSelectedModel={setSelectedModel}
|
||||||
|
activeArtifactThreadId={step2.activeArtifactThreadId}
|
||||||
|
seatStubMessages={step2.seatStubMessages}
|
||||||
|
openArtifactFromStep={step2.openArtifactFromStep}
|
||||||
|
openArtifactPreview={step2.openArtifactPreview}
|
||||||
setIsRoundTableRunning={step2.setIsRoundTableRunning}
|
setIsRoundTableRunning={step2.setIsRoundTableRunning}
|
||||||
setClarificationDraft={step2.setClarificationDraft}
|
setClarificationDraft={step2.setClarificationDraft}
|
||||||
setCustomAgentInput={step2.setCustomAgentInput}
|
setCustomAgentInput={step2.setCustomAgentInput}
|
||||||
@@ -787,6 +811,35 @@ interface LeftSidebarProps {
|
|||||||
>;
|
>;
|
||||||
selectedAgents: SelectedAgent[];
|
selectedAgents: SelectedAgent[];
|
||||||
budgetLimit: number;
|
budgetLimit: number;
|
||||||
|
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
|
||||||
|
sidebarExpanded: boolean;
|
||||||
|
setSidebarExpanded: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step2SandboxSidebarSync({
|
||||||
|
currentStep,
|
||||||
|
sidebarExpanded,
|
||||||
|
setSidebarExpanded,
|
||||||
|
}: {
|
||||||
|
currentStep: number;
|
||||||
|
sidebarExpanded: boolean;
|
||||||
|
setSidebarExpanded: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}) {
|
||||||
|
const { open } = useArtifacts();
|
||||||
|
const prevOpenRef = useRef(open);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentStep !== 2) {
|
||||||
|
prevOpenRef.current = open;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (open && !prevOpenRef.current && sidebarExpanded) {
|
||||||
|
setSidebarExpanded(false);
|
||||||
|
}
|
||||||
|
prevOpenRef.current = open;
|
||||||
|
}, [currentStep, open, setSidebarExpanded, sidebarExpanded]);
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LeftSidebar({
|
function LeftSidebar({
|
||||||
@@ -795,12 +848,32 @@ function LeftSidebar({
|
|||||||
agentNameToRole,
|
agentNameToRole,
|
||||||
selectedAgents,
|
selectedAgents,
|
||||||
budgetLimit: _budgetLimit,
|
budgetLimit: _budgetLimit,
|
||||||
|
intentReady,
|
||||||
|
sidebarExpanded,
|
||||||
|
setSidebarExpanded,
|
||||||
}: LeftSidebarProps) {
|
}: LeftSidebarProps) {
|
||||||
|
const isStep2 = currentStep === 2;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id="left-sidebar"
|
id="left-sidebar"
|
||||||
className="w-[280px] shrink-0 flex flex-col gap-4 overflow-y-auto pr-1"
|
className={cn(
|
||||||
|
"shrink-0 flex flex-col gap-3 overflow-y-auto pr-1 transition-[width] duration-200 ease-out",
|
||||||
|
isStep2 && !sidebarExpanded ? "w-[56px]" : "w-[280px]",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
|
{isStep2 && !sidebarExpanded && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSidebarExpanded(true)}
|
||||||
|
title="展开侧栏"
|
||||||
|
aria-expanded={false}
|
||||||
|
className="w-full shrink-0 flex h-10 items-center justify-center rounded-2xl border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors shadow-[0_2px_8px_rgba(0,0,0,0.01)]"
|
||||||
|
>
|
||||||
|
<PanelLeftOpen className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{currentStep === 3 ? (
|
{currentStep === 3 ? (
|
||||||
<>
|
<>
|
||||||
{/* Step 3 时只保留「参与与结论贡献」面板;原文件里其它两个 hidden 块未启用,
|
{/* Step 3 时只保留「参与与结论贡献」面板;原文件里其它两个 hidden 块未启用,
|
||||||
@@ -849,41 +922,67 @@ function LeftSidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : currentStep === 2 ? (
|
||||||
<>
|
sidebarExpanded ? (
|
||||||
{/* 任务概览 */}
|
<>
|
||||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
<IntentSummaryCard
|
||||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
intentReady={intentReady}
|
||||||
<Clipboard className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
title="意图理解"
|
||||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">任务概览</h2>
|
collapsible
|
||||||
</div>
|
sidebarToggle={{
|
||||||
<div className="space-y-3">
|
expanded: sidebarExpanded,
|
||||||
<div>
|
onToggle: () => setSidebarExpanded((v) => !v),
|
||||||
<span className="text-[10px] uppercase tracking-wider text-slate-400 font-bold block mb-1">
|
}}
|
||||||
当前节点
|
/>
|
||||||
</span>
|
|
||||||
<div className="flex items-center justify-between gap-2.5">
|
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[240px]">
|
||||||
<span className="text-sm font-bold text-slate-800">初始任务</span>
|
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
|
||||||
<span
|
<Users className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||||
className={`text-[10px] font-bold px-2 py-0.5 rounded-md border shrink-0 ${
|
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">参与角色</h2>
|
||||||
currentStep === 1
|
</div>
|
||||||
? "text-[#ff9500] bg-orange-50 border-orange-200/50"
|
<div className="space-y-2.5 overflow-y-auto flex-1 pr-1 custom-scrollbar">
|
||||||
: currentStep === 2
|
{roles.map((role) => (
|
||||||
? "text-[var(--brand-900)] bg-[var(--brand-200)] border-red-200/50"
|
<RoleRow key={role.id} role={role} agentNameToRole={agentNameToRole} />
|
||||||
: "text-emerald-600 bg-emerald-50 border-emerald-200/50"
|
))}
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{currentStep === 1 ? "待澄清" : currentStep === 2 ? "研讨中" : "已规划交付"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500 mt-2.5 leading-relaxed bg-slate-50 border border-slate-100 p-2 rounded-lg">
|
|
||||||
围绕某复杂任务制定规划方案
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 参与角色 */}
|
<HumanParticipationCard />
|
||||||
|
<TaskOverviewCard currentStep={currentStep} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CollapsedSidebarSection title="意图理解">
|
||||||
|
<IntentSectionIcon className="h-5 w-5" />
|
||||||
|
</CollapsedSidebarSection>
|
||||||
|
|
||||||
|
<CollapsedSidebarSection title="参与角色" className="flex-1 min-h-[120px]">
|
||||||
|
<div className="flex flex-col items-center gap-2 overflow-y-auto max-h-[320px] custom-scrollbar">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<RoleAvatarBadge
|
||||||
|
key={role.id}
|
||||||
|
role={role}
|
||||||
|
agentNameToRole={agentNameToRole}
|
||||||
|
size="h-8 w-8"
|
||||||
|
showSpeakingAnimation
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CollapsedSidebarSection>
|
||||||
|
|
||||||
|
<CollapsedSidebarSection title="人工参与">
|
||||||
|
<Man1Avatar size="h-8 w-8" />
|
||||||
|
</CollapsedSidebarSection>
|
||||||
|
|
||||||
|
<CollapsedSidebarSection title="任务概览">
|
||||||
|
<Clipboard className="h-5 w-5 text-primary" strokeWidth={2.5} />
|
||||||
|
</CollapsedSidebarSection>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<TaskOverviewCard currentStep={currentStep} />
|
||||||
|
|
||||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[300px]">
|
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[300px]">
|
||||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
|
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-3">
|
||||||
<Users className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
<Users className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||||
@@ -896,32 +995,317 @@ function LeftSidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 人工参与 */}
|
<HumanParticipationCard />
|
||||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
|
||||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
|
||||||
<Activity className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
|
||||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">人工参与</h2>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between p-2 rounded-xl bg-slate-50 border border-slate-100">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<Man1Avatar size="h-7 w-7" />
|
|
||||||
<span className="text-xs font-bold text-slate-800">用户</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="flex h-1.5 w-1.5 relative">
|
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
|
||||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500"></span>
|
|
||||||
</span>
|
|
||||||
<span className="text-[10px] font-bold text-emerald-600">在线</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TaskOverviewCard({ currentStep }: { currentStep: number }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
||||||
|
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
||||||
|
<Clipboard className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||||
|
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">任务概览</h2>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-slate-400 font-bold block mb-1">
|
||||||
|
当前节点
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center justify-between gap-2.5">
|
||||||
|
<span className="text-sm font-bold text-slate-800">初始任务</span>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-bold px-2 py-0.5 rounded-md border shrink-0 ${
|
||||||
|
currentStep === 1
|
||||||
|
? "text-[#ff9500] bg-orange-50 border-orange-200/50"
|
||||||
|
: currentStep === 2
|
||||||
|
? "text-[var(--brand-900)] bg-[var(--brand-200)] border-red-200/50"
|
||||||
|
: "text-emerald-600 bg-emerald-50 border-emerald-200/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{currentStep === 1 ? "待澄清" : currentStep === 2 ? "研讨中" : "已规划交付"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-2.5 leading-relaxed bg-slate-50 border border-slate-100 p-2 rounded-lg">
|
||||||
|
围绕某复杂任务制定规划方案
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HumanParticipationCard() {
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
||||||
|
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
||||||
|
<Activity className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||||
|
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">人工参与</h2>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-2 rounded-xl bg-slate-50 border border-slate-100">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<Man1Avatar size="h-7 w-7" />
|
||||||
|
<span className="text-xs font-bold text-slate-800">用户</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="flex h-1.5 w-1.5 relative">
|
||||||
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500"></span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-bold text-emerald-600">在线</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModelSelectorCardProps {
|
||||||
|
selectedModel: string;
|
||||||
|
setSelectedModel: React.Dispatch<React.SetStateAction<string>>;
|
||||||
|
availableModels: ReadonlyArray<{ name: string; display_name?: string }>;
|
||||||
|
modelsLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelSelectorCard({
|
||||||
|
selectedModel,
|
||||||
|
setSelectedModel,
|
||||||
|
availableModels,
|
||||||
|
modelsLoading,
|
||||||
|
}: ModelSelectorCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
||||||
|
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
||||||
|
<Cpu className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
||||||
|
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">推理模型</h2>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
id="global-model-select"
|
||||||
|
value={selectedModel}
|
||||||
|
onChange={(e) => setSelectedModel(e.target.value)}
|
||||||
|
disabled={modelsLoading || availableModels.length === 0}
|
||||||
|
title={
|
||||||
|
modelsLoading
|
||||||
|
? "正在加载模型列表…"
|
||||||
|
: availableModels.length === 0
|
||||||
|
? "未在 config.yaml 中配置任何模型"
|
||||||
|
: "切换全局推理模型;不影响 Persona 抽屉里已设置的 per-seat 覆盖"
|
||||||
|
}
|
||||||
|
className="w-full bg-slate-50/50 border border-slate-100 rounded-xl px-3 py-2 text-xs font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
|
||||||
|
>
|
||||||
|
{availableModels.length === 0 && (
|
||||||
|
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
|
||||||
|
)}
|
||||||
|
{availableModels.map((m) => (
|
||||||
|
<option key={m.name} value={m.name}>
|
||||||
|
{m.display_name && m.display_name !== m.name
|
||||||
|
? `${m.display_name} (${m.name})`
|
||||||
|
: m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IntentSummaryCardProps {
|
||||||
|
intentReady: ReturnType<typeof useStep1Intent>["intentReady"];
|
||||||
|
title?: string;
|
||||||
|
/** Step 2 侧栏: 收起时仅展示目标,展开后显示约束与假设。 */
|
||||||
|
collapsible?: boolean;
|
||||||
|
/** 侧栏整体收起按钮,放在标题栏右侧。 */
|
||||||
|
sidebarToggle?: {
|
||||||
|
expanded: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntentSummaryCard({
|
||||||
|
intentReady,
|
||||||
|
title = "当前意图摘要",
|
||||||
|
collapsible = false,
|
||||||
|
sidebarToggle,
|
||||||
|
}: IntentSummaryCardProps) {
|
||||||
|
const [expanded, setExpanded] = useState(!collapsible);
|
||||||
|
const showFullDetail = !collapsible || expanded;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<IntentSectionIcon className="w-4 h-4" />
|
||||||
|
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest truncate">{title}</h2>
|
||||||
|
</div>
|
||||||
|
{sidebarToggle && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={sidebarToggle.onToggle}
|
||||||
|
title={sidebarToggle.expanded ? "收起侧栏" : "展开侧栏"}
|
||||||
|
aria-expanded={sidebarToggle.expanded}
|
||||||
|
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 hover:text-primary hover:bg-slate-50 transition-colors"
|
||||||
|
>
|
||||||
|
{sidebarToggle.expanded ? (
|
||||||
|
<PanelLeftClose className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<PanelLeftOpen className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-3 bg-slate-50/50 border border-slate-100 rounded-xl space-y-3 mr-0.5">
|
||||||
|
{intentReady ? (
|
||||||
|
<>
|
||||||
|
<ul className="text-xs text-slate-700 space-y-2.5 font-normal">
|
||||||
|
<li className="flex items-baseline gap-2 leading-relaxed">
|
||||||
|
<span className="text-primary text-base leading-none">•</span>
|
||||||
|
<span>
|
||||||
|
<b className="text-slate-900 font-bold">目标</b>: {intentReady.objective}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{collapsible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
className="flex items-center gap-0.5 text-[10px] font-bold text-slate-500 hover:text-primary transition-colors"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<>
|
||||||
|
收起
|
||||||
|
<ChevronUp className="w-3.5 h-3.5" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
展开
|
||||||
|
<ChevronDown className="w-3.5 h-3.5" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showFullDetail && (
|
||||||
|
<ul className="text-xs text-slate-700 space-y-2.5 font-normal">
|
||||||
|
{intentReady.constraints.map((c, i) => (
|
||||||
|
<li key={`c-${i}`} className="flex items-baseline gap-2 leading-relaxed">
|
||||||
|
<span className="text-primary text-base leading-none">•</span>
|
||||||
|
<span>
|
||||||
|
<b className="text-slate-900 font-bold">约束</b>: {c}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{(intentReady.assumptions ?? []).map((a, i) => (
|
||||||
|
<li key={`a-${i}`} className="flex items-baseline gap-2 leading-relaxed">
|
||||||
|
<span className="text-slate-400 text-base leading-none">•</span>
|
||||||
|
<span className="text-slate-500">
|
||||||
|
<b className="text-slate-700 font-bold">假设</b>: {a}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-slate-400 italic leading-relaxed">
|
||||||
|
等待任务理解智能体澄清完成...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntentSectionIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={cn("text-primary shrink-0", className)}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.5"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<circle cx="12" cy="12" r="6" />
|
||||||
|
<circle cx="12" cy="12" r="2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CollapsedSidebarSection({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
title={title}
|
||||||
|
className={cn(
|
||||||
|
"w-full bg-white border border-slate-200/50 rounded-2xl p-2 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex flex-col items-center justify-center",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRoleAvatar(
|
||||||
|
role: RoleEntry,
|
||||||
|
agentNameToRole: Record<
|
||||||
|
string,
|
||||||
|
{ speakerId: number; avatarType: string; displayName: string }
|
||||||
|
>,
|
||||||
|
size: string,
|
||||||
|
) {
|
||||||
|
if (role.type === "human") {
|
||||||
|
return <Man1Avatar size={size} />;
|
||||||
|
}
|
||||||
|
return getAvatar(
|
||||||
|
role.agentId ? agentNameToRole[role.agentId]?.avatarType ?? "blue" : "blue",
|
||||||
|
size,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleAvatarBadge({
|
||||||
|
role,
|
||||||
|
agentNameToRole,
|
||||||
|
size = "h-7 w-7",
|
||||||
|
showSpeakingAnimation = false,
|
||||||
|
}: {
|
||||||
|
role: RoleEntry;
|
||||||
|
agentNameToRole: Record<
|
||||||
|
string,
|
||||||
|
{ speakerId: number; avatarType: string; displayName: string }
|
||||||
|
>;
|
||||||
|
size?: string;
|
||||||
|
showSpeakingAnimation?: boolean;
|
||||||
|
}) {
|
||||||
|
const isOngoing = role.status === "研讨中";
|
||||||
|
const animate = showSpeakingAnimation && isOngoing;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
title={`${role.name} · ${role.status}`}
|
||||||
|
className={cn(
|
||||||
|
"relative shrink-0 rounded-full",
|
||||||
|
animate && "ring-2 ring-primary/50 ring-offset-2 ring-offset-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{animate && (
|
||||||
|
<span className="pointer-events-none absolute inset-0 rounded-full border-2 border-primary/35 animate-ping" />
|
||||||
|
)}
|
||||||
|
<div className={cn("relative rounded-full", animate && "animate-pulse")}>
|
||||||
|
{renderRoleAvatar(role, agentNameToRole, size)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RoleRow({
|
function RoleRow({
|
||||||
role,
|
role,
|
||||||
agentNameToRole,
|
agentNameToRole,
|
||||||
@@ -949,14 +1333,7 @@ function RoleRow({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
{role.type === "human" ? (
|
<RoleAvatarBadge role={role} agentNameToRole={agentNameToRole} />
|
||||||
<Man1Avatar size="h-7 w-7" />
|
|
||||||
) : (
|
|
||||||
getAvatar(
|
|
||||||
role.agentId ? agentNameToRole[role.agentId]?.avatarType ?? "blue" : "blue",
|
|
||||||
"h-7 w-7",
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs font-bold text-slate-800 tracking-tight">{role.name}</span>
|
<span className="text-xs font-bold text-slate-800 tracking-tight">{role.name}</span>
|
||||||
{role.type === "agent" && (
|
{role.type === "agent" && (
|
||||||
@@ -1024,89 +1401,21 @@ function RightSidebar({
|
|||||||
timelineEntries,
|
timelineEntries,
|
||||||
triggerToast,
|
triggerToast,
|
||||||
}: RightSidebarProps) {
|
}: RightSidebarProps) {
|
||||||
if (currentStep === 3) return null;
|
// Step 2 侧栏内容已迁至左侧;Step 3 沿用原逻辑不展示右侧栏。
|
||||||
|
if (currentStep !== 1) return null;
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id="right-sidebar"
|
id="right-sidebar"
|
||||||
className="w-[300px] shrink-0 flex flex-col gap-4 overflow-y-auto pl-1"
|
className="w-[300px] shrink-0 flex flex-col gap-4 overflow-y-auto pl-1"
|
||||||
>
|
>
|
||||||
{/* 推理模型选择器 —— 全局,与 Persona 抽屉的 per-seat 覆盖叠加(后者优先)。 */}
|
<ModelSelectorCard
|
||||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
selectedModel={selectedModel}
|
||||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
setSelectedModel={setSelectedModel}
|
||||||
<Cpu className="w-4 h-4 text-primary" strokeWidth={2.5} />
|
availableModels={availableModels}
|
||||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">推理模型</h2>
|
modelsLoading={modelsLoading}
|
||||||
</div>
|
/>
|
||||||
<select
|
|
||||||
id="global-model-select"
|
|
||||||
value={selectedModel}
|
|
||||||
onChange={(e) => setSelectedModel(e.target.value)}
|
|
||||||
disabled={modelsLoading || availableModels.length === 0}
|
|
||||||
title={
|
|
||||||
modelsLoading
|
|
||||||
? "正在加载模型列表…"
|
|
||||||
: availableModels.length === 0
|
|
||||||
? "未在 config.yaml 中配置任何模型"
|
|
||||||
: "切换全局推理模型;不影响 Persona 抽屉里已设置的 per-seat 覆盖"
|
|
||||||
}
|
|
||||||
className="w-full bg-slate-50/50 border border-slate-100 rounded-xl px-3 py-2 text-xs font-bold text-slate-700 cursor-pointer focus:outline-none focus:border-primary focus:ring-1 focus:ring-[var(--brand-200)]/50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-secondary dark:text-foreground dark:border-border"
|
|
||||||
>
|
|
||||||
{availableModels.length === 0 && (
|
|
||||||
<option value="">{modelsLoading ? "加载中…" : "未配置模型"}</option>
|
|
||||||
)}
|
|
||||||
{availableModels.map((m) => (
|
|
||||||
<option key={m.name} value={m.name}>
|
|
||||||
{m.display_name && m.display_name !== m.name
|
|
||||||
? `${m.display_name} (${m.name})`
|
|
||||||
: m.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 当前意图摘要 —— 由 intent agent 输出的结构化 summary 驱动。 */}
|
<IntentSummaryCard intentReady={intentReady} />
|
||||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
|
|
||||||
<div className="flex items-center gap-2 border-b border-slate-100 pb-2.5 mb-2.5">
|
|
||||||
<svg className="w-4 h-4 text-primary" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<circle cx="12" cy="12" r="6" />
|
|
||||||
<circle cx="12" cy="12" r="2" />
|
|
||||||
</svg>
|
|
||||||
<h2 className="text-xs font-bold text-slate-900 uppercase tracking-widest">当前意图摘要</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 bg-slate-50/50 border border-slate-100 rounded-xl space-y-3 mr-0.5">
|
|
||||||
{intentReady ? (
|
|
||||||
<ul className="text-xs text-slate-700 space-y-2.5 font-normal">
|
|
||||||
<li className="flex items-baseline gap-2 leading-relaxed">
|
|
||||||
<span className="text-primary text-base leading-none">•</span>
|
|
||||||
<span>
|
|
||||||
<b className="text-slate-900 font-bold">目标</b>: {intentReady.objective}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
{intentReady.constraints.map((c, i) => (
|
|
||||||
<li key={`c-${i}`} className="flex items-baseline gap-2 leading-relaxed">
|
|
||||||
<span className="text-primary text-base leading-none">•</span>
|
|
||||||
<span>
|
|
||||||
<b className="text-slate-900 font-bold">约束</b>: {c}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{(intentReady.assumptions ?? []).map((a, i) => (
|
|
||||||
<li key={`a-${i}`} className="flex items-baseline gap-2 leading-relaxed">
|
|
||||||
<span className="text-slate-400 text-base leading-none">•</span>
|
|
||||||
<span className="text-slate-500">
|
|
||||||
<b className="text-slate-700 font-bold">假设</b>: {a}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
) : (
|
|
||||||
<p className="text-xs text-slate-400 italic leading-relaxed">
|
|
||||||
等待任务理解智能体澄清完成...
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 澄清记录 Timeline */}
|
{/* 澄清记录 Timeline */}
|
||||||
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[300px]">
|
<div className="bg-white border border-slate-200/50 rounded-2xl p-4.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 flex flex-col min-h-[300px]">
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ export interface DraftStep2Snapshot {
|
|||||||
hasConsensus: boolean;
|
hasConsensus: boolean;
|
||||||
consensusPercentage: number;
|
consensusPercentage: number;
|
||||||
budgetLimit: number;
|
budgetLimit: number;
|
||||||
|
/**
|
||||||
|
* Per-seat stub AI messages used by the sandbox `write-file:` virtual URL
|
||||||
|
* loader. Older drafts won't have this — callers should default to `{}`.
|
||||||
|
*/
|
||||||
|
seatStubMessages?: Record<string, unknown[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DraftRecord {
|
export interface DraftRecord {
|
||||||
|
|||||||
Reference in new issue
Block a user