后台挂起

This commit is contained in:
fengxiangdebaozi committed 2026-06-04 20:00:58 +08:00
1 parent 788118ce3d
commit 19399482e1
21 files changed
+626 -50

No files matched your search

@@ -0,0 +1,404 @@
# 多智能体会商 Step2 多结果线方案
## 背景
当前「多智能体会商 / 多智能体规划」里,一个历史草稿只保存一份 `step2` 快照。用户从历史记录加载草稿后,如果再次点击「智能体推荐」并在弹窗里点击「进入研讨」,新的 Step2 研讨会继续写入同一份 `draft.step2`,结果是第一次生成内容会被第二次生成内容覆盖,或者页面只能展示最新一次结果。
产品期望是:
- 用户从历史记录进入已有草稿时,进入第二步后,左下角能看到第一次生成结果。
- 用户再次点击「智能体推荐」并点击「进入研讨」时,要开启一条新的研讨结果线。
- 第二次生成结果显示在左下角,但不能影响第一次生成结果。
- 用户之后再次查看同一个历史草稿时,可以分别查看第一次生成结果和第二次生成结果。
因此,这个需求的核心不是简单增加一个展示区域,而是把 Step2 从「单结果」升级为「一个草稿下多条研讨 run」。
## 现状
相关位置:
- `src/roundtable-planning/api/drafts.ts`
- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx`
- `src/roundtable-planning/hooks/useStep2Orchestration.ts`
- `src/roundtable-planning/components/Step2Panel.tsx`
- `src/roundtable-planning/components/RecommendAgentsDialog.tsx`
当前 `DraftStep2Snapshot` 结构大致是:
```ts
export interface DraftStep2Snapshot {
selectedAgents?: Array<{ agent_id: string; name: string }>;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
seatStubMessages?: Record<string, unknown[]>;
orchestrationMode?: "recommend" | "chain";
chain?: { id: string; title: string } | null;
}
```
这意味着一个草稿只有一份:
- 参会智能体
- 多智能体 thread 映射
- 研讨气泡
- 总控最终内容
- 共识状态
- 沙箱产物 stub
- 编排模式和业务链条
当前 `RoundtablePlanningPage.tsx` 里的 `handleConfirmAgents` 在用户点击「进入研讨」后会:
1. 规范化选中的 agents。
2. 写入 `step2.selectedAgents`。
3. 调用 `step2.resetInitGate()`。
4. 设置 `orchestrationMode` / `chainMeta`。
5. 切到 `currentStep = 2`。
它没有先把当前 Step2 结果归档成「第一次生成结果」,也没有为新的研讨创建独立 run。因此第二次研讨天然会复用同一个 `draft.step2` 写入位置。
## 目标模型
建议引入 Step2 run 概念:一个草稿可以有多条 Step2 结果线,每条结果线保存一份完整的 Step2 快照。
```ts
interface Step2RunSnapshot {
id: string;
title: string;
createdAt: string;
source: "initial" | "recommend" | "chain" | "manual";
selectedAgents?: Array<{ agent_id: string; name: string }>;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
seatStubMessages?: Record<string, unknown[]>;
orchestrationMode?: "recommend" | "chain";
chain?: { id: string; title: string } | null;
step3?: DraftStep3Snapshot | null;
}
```
`DraftStep2Snapshot` 保留现有字段作为当前激活 run 的镜像,同时新增 `runs` 和 `activeRunId`:
```ts
export interface DraftStep2Snapshot {
activeRunId?: string;
runs?: Step2RunSnapshot[];
selectedAgents?: Array<{ agent_id: string; name: string }>;
threadIds: ThreadIdMap | null;
coordinatorName: string | null;
step2RoundtableDialogues: unknown[];
lastLeaderContent: string;
hasConsensus: boolean;
consensusPercentage: number;
budgetLimit: number;
seatStubMessages?: Record<string, unknown[]>;
orchestrationMode?: "recommend" | "chain";
chain?: { id: string; title: string } | null;
}
```
这样做的好处:
- 老代码仍然可以从 `draft.step2.step2RoundtableDialogues` 读取当前结果。
- 新 UI 可以从 `draft.step2.runs` 展示第一次、第二次、第三次生成结果。
- 老草稿没有 `runs` 时,可以在 hydrate 时把原来的 `step2` 包装成一条默认 run。
- 暂时不需要改数据库表结构,因为 `step2` 本身就是 JSON 快照。
## 行为设计
### 1. 历史记录加载
用户点击历史记录加载草稿时:
1. 拉取完整草稿。
2. 如果 `draft.step2.runs` 存在,使用 `activeRunId` 找到当前结果线。
3. 如果 `draft.step2.runs` 不存在,但旧字段里有 `step2RoundtableDialogues`,自动包装成一条 run:
```ts
{
id: "legacy-run",
title: "第一次生成结果",
createdAt: draft.updatedAt,
source: "initial",
...draft.step2
}
```
4. hydrate 当前 active run 到 Step2 runtime。
5. 左下角展示 run 列表。
### 2. 再次点击智能体推荐进入研讨
用户在已有历史草稿中再次点击「智能体推荐」并确认进入研讨时:
1. 先把当前页面里的 Step2 状态保存为当前 active run。
2. 创建一条新的 run:
```ts
{
id: crypto.randomUUID(),
title: `第 ${runs.length + 1} 次生成结果`,
createdAt: new Date().toISOString(),
source: result.mode,
selectedAgents: normalizedAgents,
threadIds: null,
coordinatorName: null,
step2RoundtableDialogues: [],
lastLeaderContent: "",
hasConsensus: false,
consensusPercentage: 0,
budgetLimit: DEFAULT_BUDGET,
seatStubMessages: {},
orchestrationMode: result.mode,
chain: result.mode === "chain" ? result.chain ?? null : null
}
```
3. 将 `activeRunId` 切到新 run。
4. 清空 Step2 runtime。
5. 设置新选中的 agents / mode / chain。
6. `resetInitGate()`,进入 Step2 后重新创建一组独立 thread。
7. 自动保存草稿。
关键点:第二次 run 必须使用新的 `threadIds`,不能沿用第一次 run 的 `threadIds`,否则后端 thread 历史会混在一起。
### 3. 左下角结果列表
左下角建议展示一个「生成结果」区域:
- 第一次生成结果
- 第二次生成结果
- 第三次生成结果
每条展示:
- 标题
- 创建时间
- 状态:研讨中 / 已达成共识 / 未完成 / 已生成报告
- 使用模式:智能体推荐 / 业务链条
点击某条结果时:
1. 保存当前 active run。
2. 切换 `activeRunId`。
3. hydrate 被点击的 run。
4. 中间 Step2 对话区显示该 run 的研讨气泡。
5. 如果该 run 已有 `step3`,进入 Step3 时展示对应报告。
### 4. Step3 结果归属
目前 `draft.step3` 是草稿级别的一份报告。如果只保留这一份,第二次结果生成报告后仍会覆盖第一次报告。
建议:
- `draft.step3` 继续作为当前 active run 的镜像,兼容现有 Step3 页面。
- 每条 `Step2RunSnapshot.step3` 保存自己的报告。
- `onReportSaved` 时同时写入当前 active run 的 `step3`。
这样用户切换到第一次生成结果时,可以看到第一次的 Step3 报告;切换到第二次生成结果时,可以看到第二次的 Step3 报告。
## 建议改动点
### 1. `api/drafts.ts`
扩展类型:
- 新增 `Step2RunSnapshot`。
- `DraftStep2Snapshot` 增加 `activeRunId?: string` 和 `runs?: Step2RunSnapshot[]`。
同时保留旧字段,避免影响现有保存、加载和 Step3。
### 2. `useStep2Orchestration.ts`
新增或暴露两个能力:
- `getCurrentRunSnapshot()`:从当前 Step2 runtime 生成一条 run 快照。
- `hydrateRunSnapshot(run)`:把某条 run 恢复到 Step2 runtime。
`hydrateFromDraft` 继续存在,但内部可以优先选择 active run 来 hydrate。
### 3. `RoundtablePlanningPage.tsx`
新增页面级状态:
```ts
const [step2Runs, setStep2Runs] = useState<Step2RunSnapshot[]>([]);
const [activeStep2RunId, setActiveStep2RunId] = useState<string | null>(null);
```
调整几个流程:
- `getStep2Snapshot` 输出 `runs` 和 `activeRunId`。
- `hydrateStep2` 兼容旧草稿并恢复 runs。
- `handleConfirmAgents` 在新建研讨前先归档当前 active run,再创建新 run。
- `onReportSaved` 把 Step3 报告写回当前 active run。
### 4. `Step2Panel.tsx`
新增左下角结果列表展示。建议作为一个独立组件,避免 Step2Panel 继续变大:
```tsx
<Step2RunSwitcher
runs={step2Runs}
activeRunId={activeStep2RunId}
onSelect={handleSelectStep2Run}
/>
```
组件职责只做展示和选择,不直接读写 draft。
### 5. `Step3Panel.tsx`
传入当前 active run 的报告:
- 如果当前 run 有 `step3`,展示它。
- 如果没有,按现有逻辑生成新报告。
- 生成完成后通过 `onReportSaved` 写回 run。
## 兼容策略
老草稿没有 `runs` 字段时,不做数据迁移,前端加载时即时包装。
判断规则:
```ts
function normalizeStep2Runs(step2: DraftStep2Snapshot): {
runs: Step2RunSnapshot[];
activeRunId: string | null;
} {
if (step2.runs?.length) {
return {
runs: step2.runs,
activeRunId: step2.activeRunId ?? step2.runs[0].id,
};
}
const hasLegacyContent =
step2.step2RoundtableDialogues?.length > 0 ||
Boolean(step2.threadIds) ||
Boolean(step2.lastLeaderContent);
if (!hasLegacyContent) {
return { runs: [], activeRunId: null };
}
const legacyRun: Step2RunSnapshot = {
id: "legacy-run",
title: "第一次生成结果",
createdAt: new Date().toISOString(),
source: step2.orchestrationMode ?? "initial",
selectedAgents: step2.selectedAgents,
threadIds: step2.threadIds,
coordinatorName: step2.coordinatorName,
step2RoundtableDialogues: step2.step2RoundtableDialogues,
lastLeaderContent: step2.lastLeaderContent,
hasConsensus: step2.hasConsensus,
consensusPercentage: step2.consensusPercentage,
budgetLimit: step2.budgetLimit,
seatStubMessages: step2.seatStubMessages,
orchestrationMode: step2.orchestrationMode,
chain: step2.chain,
};
return { runs: [legacyRun], activeRunId: legacyRun.id };
}
```
## 风险点
### 1. 自动保存覆盖
Step2 现在会在每个气泡收尾时自动保存。如果引入多 run,自动保存必须先把当前 runtime 合并回 active run,再写入 `draft.step2.runs`。
否则会出现 UI 切到第二次结果,但保存时仍更新第一次 run 的情况。
### 2. threadIds 混用
每条 run 必须拥有自己的 `threadIds`。新建 run 时要清空 `threadIds` 并让 Step2 重新 init。
如果复用旧 thread,会导致两次研讨的后端上下文互相污染。
### 3. Step3 报告覆盖
如果只保存草稿级 `step3`,多 run 的报告无法分别查看。
建议把报告写入当前 run,同时保留草稿级 `step3` 作为 active run 镜像。
### 4. 正在运行中的 run
如果配合后台任务能力,run 还需要带 job 信息:
```ts
jobId?: string | null;
jobStatus?: JobStatus | null;
```
这样左下角可以显示「第二次生成结果正在运行」,并打开派活链路弹窗。
## 分阶段实现建议
### P0:纯前端多 run 快照
目标:先解决“第一次 / 第二次结果分别可查看”。
- 扩展 `DraftStep2Snapshot` 类型。
- 页面维护 `step2Runs` / `activeStep2RunId`。
- `handleConfirmAgents` 新建 run。
- 左下角展示 run 列表。
- 切换 run 能恢复不同 Step2 对话。
不改后端表结构。
### P1:Step3 绑定 run
目标:每次研讨结果对应自己的报告。
- `Step2RunSnapshot.step3` 保存报告。
- `onReportSaved` 写回 active run。
- 切换 run 时同步 Step3 显示。
### P2:后台任务绑定 run
目标:和后台挂起执行方案打通。
- run 增加 `jobId` / `jobStatus`。
- 历史列表和左下角都能显示某条 run 正在执行。
- 点击正在执行的 run 打开派活链路弹窗。
### P3:后端 run 表
如果后续一个草稿下 run 数量很多,或者需要服务端按 run 查询、删除、重命名,再考虑新增后端表:
- `roundtable_runs`
- `draft_id`
- `run_id`
- `title`
- `status`
- `step2_snapshot`
- `step3_snapshot`
- `created_at`
- `updated_at`
短期不建议一开始就上表,先用现有 `step2` JSON 快照落地更快。
## 验收清单
- 从历史记录加载旧草稿时,左下角出现「第一次生成结果」。
- 点击「第一次生成结果」能看到原来的 Step2 研讨气泡。
- 在历史草稿中重新点击「智能体推荐」并「进入研讨」后,左下角新增「第二次生成结果」。
- 第二次研讨开始时,Step2 对话区为空或只显示新 run 初始化内容。
- 第二次 run 使用新的 `threadIds`,不会复用第一次 run 的后端线程。
- 切回「第一次生成结果」时,第一次的对话、共识状态、沙箱产物仍可查看。
- 切回「第二次生成结果」时,第二次的对话继续显示。
- 刷新页面后再次加载该历史草稿,两条结果线都还在。
- 如果两次都生成了 Step3 报告,切换不同结果线时能看到各自对应的报告。
@@ -18,6 +18,7 @@ import { getAvatarTypeFor } from "../lib/roundtable-constants";
import {
isTerminalJobStatus,
type DispatchChainNode,
type JobDialogue,
type RoundtableJob,
type StartJobPayload,
} from "../lib/job-types";
@@ -65,6 +66,7 @@ function mapJob(raw: Record<string, unknown>): RoundtableJob {
orchestrationMode: (raw.orchestrationMode as RoundtableJob["orchestrationMode"]) ?? "recommend",
chain: (raw.chain as RoundtableJob["chain"]) ?? null,
error: (raw.error as string | null) ?? null,
dialogues: Array.isArray(raw.dialogues) ? (raw.dialogues as JobDialogue[]) : [],
createdAt: (raw.createdAt as string | null) ?? null,
updatedAt: (raw.updatedAt as string | null) ?? null,
};
@@ -67,6 +67,7 @@ export function DispatchChainJobModal({
consensusPercentage={job?.consensusPercentage ?? 0}
onView={job && onView ? () => onView(job) : undefined}
error={job?.error}
dialogues={job?.dialogues}
pendingClarification={job?.pendingClarification}
onResume={
jobId
@@ -14,7 +14,7 @@
* (business-chain.ts)。连线动画 CSS 在 `styles/roundtable-planning.css` 全局段
* (Radix Dialog portal 到 body,不在 .roundtable-planning 作用域内,故用全局类 + 语义 token)。
*/
import { Fragment, useState } from "react";
import { Fragment, useEffect, useRef, useState } from "react";
import { CheckIcon, ChevronRightIcon, EyeIcon, Loader2Icon, SendIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -30,9 +30,11 @@ import { cn } from "@/lib/utils";
import { getAvatar } from "./Avatars";
import { chainTagClasses } from "../lib/business-chain";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
import type {
ChainNodeState,
DispatchChainNode,
JobDialogue,
JobPhase,
JobStatus,
} from "../lib/job-types";
@@ -62,6 +64,8 @@ export interface DispatchChainModalProps {
onCancel?: () => void;
/** 出错详情(status==='error')。 */
error?: string | null;
/** 研讨对话记录(leader 说明 + 各席位交付)。 */
dialogues?: JobDialogue[];
}
const _NON_TERMINAL: ReadonlySet<JobStatus> = new Set<JobStatus>(["queued", "running", "awaiting_input"]);
@@ -99,8 +103,24 @@ export function DispatchChainModal({
onResume,
onCancel,
error,
dialogues,
}: DispatchChainModalProps) {
const [answer, setAnswer] = useState("");
const convoRef = useRef<HTMLDivElement>(null);
// 新对话进来时自动滚到底部。
const dialogueCount = dialogues?.length ?? 0;
useEffect(() => {
const el = convoRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [dialogueCount]);
// 对话气泡的头像配色:总控固定蓝;席位优先取流程图节点色,回退按 agentId 派生。
const avatarForDialogue = (d: JobDialogue): string => {
if (d.role === "leader") return "blue";
const node = nodes.find((n) => n.agentId === d.agentId);
return node?.avatarType || (d.agentId ? getAvatarTypeFor(d.agentId, node?.order ? node.order - 1 : 0) : "blue");
};
// 总控节点:初始化 / 决策派活阶段高亮
const coordinatorState: ChainNodeState =
phase === "initializing" || phase === "leader_thinking" ? "active" : "done";
@@ -147,7 +167,7 @@ export function DispatchChainModal({
</DialogHeader>
{/* 横向流程图(横向滚动) */}
<div className="custom-scrollbar min-h-0 flex-1 overflow-x-auto overflow-y-hidden py-10">
<div className="custom-scrollbar shrink-0 overflow-x-auto overflow-y-hidden py-8">
<div className="flex min-w-max items-start gap-0 px-6">
{/* 总控节点 */}
<FlowNode
@@ -192,6 +212,28 @@ export function DispatchChainModal({
</div>
</div>
{/* 研讨对话(leader 说明 + 各席位交付,真网关下即真模型真实对话) */}
{dialogues && dialogues.length > 0 && (
<div
ref={convoRef}
className="custom-scrollbar min-h-0 flex-1 overflow-y-auto border-t px-1 py-3"
>
<div className="space-y-3.5">
{dialogues.map((d) => (
<div key={d.id} className="flex gap-2.5">
<div className="shrink-0">{getAvatar(avatarForDialogue(d), "h-7 w-7")}</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-semibold text-foreground/80">{d.name}</div>
<div className="mt-0.5 whitespace-pre-wrap break-words text-sm leading-relaxed text-foreground/70">
{d.content || "…"}
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* 出错详情 */}
{status === "error" && error && (
<div className="shrink-0 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
@@ -52,16 +52,23 @@ export function MessageBubble({
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={cn("text-xs font-extrabold text-slate-900", senderNameClassName)}>
<span
className={cn(
"roundtable-chat-message font-extrabold text-slate-900",
senderNameClassName,
)}
>
{senderName}
</span>
{badge && (
<span className="text-[calc(9px*var(--ui-font-scale,1))] font-mono font-black uppercase text-[var(--brand-900)] bg-[var(--brand-200)] border border-red-200 px-1.5 py-0.2 rounded">
<span className="roundtable-chat-meta font-mono font-black uppercase text-[var(--brand-900)] bg-[var(--brand-200)] border border-red-200 px-1.5 py-0.2 rounded">
{badge}
</span>
)}
</div>
<span className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-mono font-medium shrink-0">{time}</span>
<span className="roundtable-chat-meta text-slate-400 font-mono font-medium shrink-0">
{time}
</span>
</div>
{children}
</div>
@@ -75,7 +82,7 @@ export function MessageBubble({
*/
export function MessageLoadingPlaceholder({ label }: { label?: string }) {
return (
<div className="flex items-center gap-2 py-1.5 text-xs text-slate-600">
<div className="roundtable-chat-message flex items-center gap-2 py-1.5 text-slate-600">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
<span className="font-medium">{label ?? "正在连接智能体..."}</span>
<span className="inline-flex items-center gap-0.5">
@@ -118,10 +125,10 @@ export const sharedMarkdownComponents = {
<h2 className="text-sm font-bold mt-2 mb-1">{children}</h2>
),
h3: ({ children }: { children?: React.ReactNode }) => (
<h3 className="text-xs font-bold mt-1.5 mb-0.5">{children}</h3>
<h3 className="text-sm font-bold mt-1.5 mb-0.5">{children}</h3>
),
h4: ({ children }: { children?: React.ReactNode }) => (
<h4 className="text-xs font-bold mt-1 mb-0.5">{children}</h4>
<h4 className="text-sm font-bold mt-1 mb-0.5">{children}</h4>
),
strong: ({ children }: { children?: React.ReactNode }) => (
<strong className="font-bold text-slate-900">{children}</strong>
@@ -139,13 +146,13 @@ export const sharedMarkdownComponents = {
const isBlock = /language-/.test(className ?? "");
if (isBlock) {
return (
<pre className="bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto text-[calc(11px*var(--ui-font-scale,1))] font-mono">
<pre className="roundtable-chat-message bg-slate-100 text-slate-800 rounded-md p-2 my-1 overflow-x-auto font-mono">
<code>{children}</code>
</pre>
);
}
return (
<code className="bg-slate-100 text-slate-800 rounded px-1 py-0.5 text-[calc(11px*var(--ui-font-scale,1))] font-mono">
<code className="roundtable-chat-message bg-slate-100 text-slate-800 rounded px-1 py-0.5 font-mono">
{children}
</code>
);
@@ -161,7 +168,7 @@ export const sharedMarkdownComponents = {
</a>
),
table: ({ children }: { children?: React.ReactNode }) => (
<table className="my-1 border-collapse text-[calc(11px*var(--ui-font-scale,1))]">{children}</table>
<table className="roundtable-chat-message my-1 border-collapse">{children}</table>
),
th: ({ children }: { children?: React.ReactNode }) => (
<th className="border border-slate-300 px-1.5 py-0.5 bg-slate-50 font-bold text-left">
@@ -254,7 +261,7 @@ export function ReasoningBlock({
<button
type="button"
onClick={handleToggle}
className="inline-flex items-center gap-1.5 text-[calc(11.5px*var(--ui-font-scale,1))] text-slate-500 hover:text-slate-700 transition-colors cursor-pointer"
className="roundtable-chat-message inline-flex items-center gap-1.5 text-slate-500 hover:text-slate-700 transition-colors cursor-pointer"
>
<BrainIcon className={cn("size-3.5", streaming && "text-primary animate-pulse")} />
<span className={cn(streaming && "animate-pulse")}>{headerLabel}</span>
@@ -265,7 +272,7 @@ export function ReasoningBlock({
{open && (
<div
className={cn(
"rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-[calc(11.5px*var(--ui-font-scale,1))] text-slate-600 leading-relaxed",
"roundtable-chat-message rounded-md border border-slate-200 bg-slate-50/60 px-3 py-2 text-slate-600",
contentClassName,
)}
>
@@ -86,10 +86,15 @@ export function MessageStepsCard({
const display = getStepDisplay(lastReasoning.key, lastReasoning.label, lastReasoning.args);
const Icon = display.icon;
return (
<div className={cn("flex items-center gap-2 text-[calc(11.5px*var(--ui-font-scale,1))] text-slate-500 py-0.5", className)}>
<div
className={cn(
"roundtable-chat-message flex items-center gap-2 text-slate-500 py-0.5",
className,
)}
>
<Icon className="size-3.5 text-slate-400" />
<span>{display.label}</span>
<span className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-mono">{lastReasoning.ts}</span>
<span className="roundtable-chat-meta text-slate-400 font-mono">{lastReasoning.ts}</span>
</div>
);
}
@@ -108,7 +113,7 @@ export function MessageStepsCard({
>
<ChainOfThoughtStep
label={
<span className="text-[calc(11.5px*var(--ui-font-scale,1))] opacity-60">
<span className="roundtable-chat-message opacity-60">
{showAbove ? "隐藏步骤" : `查看其他 ${priorToolSteps.length} 个步骤`}
</span>
}
@@ -189,9 +194,9 @@ function ToolStep({
: undefined
}
label={
<span className="text-[calc(12px*var(--ui-font-scale,1))] text-slate-700">
<span className="roundtable-chat-message text-slate-700">
<span className="font-medium">{display.label}</span>
<span className="ml-2 text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-mono">{step.ts}</span>
<span className="roundtable-chat-meta ml-2 text-slate-400 font-mono">{step.ts}</span>
</span>
}
>
@@ -204,10 +209,10 @@ function ReasoningStep({ step, streaming }: { step: BubbleStep; streaming: boole
const display = getStepDisplay(step.key, step.label, step.args);
const Icon = display.icon;
return (
<div className="flex items-center gap-2 text-[calc(11.5px*var(--ui-font-scale,1))] text-slate-500">
<div className="roundtable-chat-message flex items-center gap-2 text-slate-500">
<Icon className={cn("size-3.5", streaming ? "text-primary animate-pulse" : "text-slate-400")} />
<span>{display.label}</span>
<span className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400 font-mono">{step.ts}</span>
<span className="roundtable-chat-meta text-slate-400 font-mono">{step.ts}</span>
</div>
);
}
@@ -351,7 +351,7 @@ function Step1Message({
{isStreaming && !hasContent ? (
<MessageLoadingPlaceholder label={msg.phase} />
) : isAgent ? (
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words space-y-2 roundtable-md">
<div className="roundtable-chat-message text-slate-700 font-medium break-words space-y-2">
{introText && <MarkdownContent>{introText}</MarkdownContent>}
{parts.summary && <IntentReadySummaryCard summary={parts.summary} />}
{/*
@@ -422,7 +422,7 @@ function Step1Message({
)}
</div>
) : (
<div className="text-xs text-slate-700 leading-relaxed font-medium whitespace-pre-wrap break-words roundtable-md">
<div className="roundtable-chat-message text-slate-700 font-medium whitespace-pre-wrap break-words">
{msg.content}
</div>
)}
@@ -289,9 +289,9 @@ export function Step2Panel(props: Step2PanelProps) {
)}
</div>
{pendingClarification.context && (
<p className="text-[calc(11px*var(--ui-font-scale,1))] text-amber-800 italic">{pendingClarification.context}</p>
<p className="roundtable-chat-message text-amber-800 italic">{pendingClarification.context}</p>
)}
<p className="text-xs text-slate-800 leading-relaxed font-medium whitespace-pre-wrap break-words">
<p className="roundtable-chat-message text-slate-800 font-medium whitespace-pre-wrap break-words">
{pendingClarification.question}
</p>
{Array.isArray(pendingClarification.options) &&
@@ -310,7 +310,7 @@ export function Step2Panel(props: Step2PanelProps) {
key={`${idx}-${label}`}
type="button"
onClick={() => onSubmitClarification(label)}
className="text-[calc(11px*var(--ui-font-scale,1))] font-bold px-3 py-1.5 rounded-full bg-white border border-amber-300 text-amber-900 hover:bg-amber-100 transition active:scale-95 cursor-pointer"
className="roundtable-chat-message font-bold px-3 py-1.5 rounded-full bg-white border border-amber-300 text-amber-900 hover:bg-amber-100 transition active:scale-95 cursor-pointer"
>
{label}
</button>
@@ -472,7 +472,7 @@ export function Step2Message({
<ReasoningBlock streaming={isStreaming}>{reasoning}</ReasoningBlock>
)}
<div className="text-xs text-slate-700 leading-relaxed font-medium break-words roundtable-md">
<div className="roundtable-chat-message text-slate-700 font-medium break-words">
{isStreaming && !hasContent && <MessageLoadingPlaceholder label={msg.confidence} />}
<MarkdownContent>{content}</MarkdownContent>
{isStreaming && <StreamingCursor />}
@@ -594,17 +594,17 @@ function PresentFileCard({
<div className="flex min-w-0 items-start gap-2">
<FileIcon className="mt-0.5 size-4 shrink-0 text-slate-400" />
<span
className="min-w-0 flex-1 truncate text-[calc(12px*var(--ui-font-scale,1))] font-medium text-slate-700"
className="roundtable-chat-message min-w-0 flex-1 truncate font-medium text-slate-700"
title={filename}
>
{filename}
</span>
</div>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-[calc(10px*var(--ui-font-scale,1))] font-mono font-semibold text-slate-500">
<span className="roundtable-chat-meta rounded bg-slate-100 px-1.5 py-0.5 font-mono font-semibold text-slate-500">
{typeLabel}
</span>
<span className="text-[calc(10px*var(--ui-font-scale,1))] text-slate-400">点击预览</span>
<span className="roundtable-chat-meta text-slate-400">点击预览</span>
</div>
</button>
);
@@ -45,6 +45,16 @@ export interface DispatchChainNode {
lastSpokeCycle?: number;
}
/** 一条研讨对话记录(leader 说明 / 某席位交付)。 */
export interface JobDialogue {
id: number;
role: "leader" | "seat";
agentId: string | null;
name: string;
content: string;
cycle: number;
}
/** 一条后台作业的完整快照(§2.5)——对齐后端 `RoundtableJobResponse`(camelCase)。 */
export interface RoundtableJob {
id: string;
@@ -59,6 +69,8 @@ export interface RoundtableJob {
orchestrationMode: "recommend" | "chain";
chain: { id: string; title: string } | null;
error: string | null;
/** 研讨对话记录(leader 说明 + 各席位交付),跑完 / 进行中都可看真实内容。 */
dialogues: JobDialogue[];
createdAt: string | null;
updatedAt: string | null;
}
@@ -55,7 +55,7 @@ export function renderStepArgs(
<ChainOfThoughtSearchResult>{displayName}</ChainOfThoughtSearchResult>
)}
{task && (
<div className="mt-1 max-h-64 overflow-y-auto rounded-md border border-slate-200 bg-white px-2.5 py-1 text-[11px] text-slate-600 leading-relaxed">
<div className="roundtable-chat-message mt-1 max-h-64 overflow-y-auto rounded-md border border-slate-200 bg-white px-2.5 py-1 text-slate-600">
<MarkdownContent>{task}</MarkdownContent>
</div>
)}
@@ -98,7 +98,7 @@ export function renderStepArgs(
case "ask_clarification": {
const question = typeof args.question === "string" ? args.question : "";
return question ? (
<span className="block text-[11px] text-slate-600 line-clamp-2 mt-0.5">
<span className="roundtable-chat-message block text-slate-600 line-clamp-2 mt-0.5">
{question}
</span>
) : null;
@@ -374,8 +374,8 @@ export default function RoundtablePlanningPage() {
model: selectedModel || undefined, // 模型沿用第二步选择
orchestrationMode,
chain: chainMeta,
threadIds: step2.threadIds ?? undefined,
coordinatorName: step2.coordinatorName ?? undefined,
// 不传 threadIds:后台作业由真网关自建全新线程,绝不复用前端本地研讨的线程
// (那些线程可能有活跃 run,复用会撞 409)。
});
}
} catch (err) {
@@ -10,6 +10,19 @@
--rt-primary-active: var(--brand-950, #a8071a);
--rt-primary-soft: var(--brand-200, #feeded);
--rt-page-bg: #fef6f6;
/* 消息列表正文字号 —— 与右侧沙箱 CodeEditor / Markdown 预览的 text-sm 对齐 */
--roundtable-chat-font-size: var(--text-sm, 0.875rem);
--roundtable-chat-meta-font-size: var(--text-xs, 0.75rem);
}
/* 会商对话区:气泡正文、步骤卡、Markdown 块统一继承 */
.roundtable-chat-message {
font-size: var(--roundtable-chat-font-size);
line-height: 1.625;
}
.roundtable-chat-meta {
font-size: var(--roundtable-chat-meta-font-size);
}
.roundtable-planning ::-webkit-scrollbar-thumb {