代码提交

This commit is contained in:
fengxiangdebaozi committed 2026-06-04 18:50:31 +08:00
1 parent 47217e9158
commit 9d863f63e5
33 files changed
+2878 -163

No files matched your search

+9 -5
View File
@@ -24,10 +24,16 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 我的笔记前端
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
@@ -35,5 +41,3 @@ VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
# VITE_CANVAS_LANGGRAPH_BASE_URL=
# 我的笔记是否显示,侧边栏
VITE_STUDIO_ENABLED=false
@@ -15,10 +15,15 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# VITE_NOTE_API_BASE_URL=http://127.0.0.1:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
+3
View File
@@ -21,6 +21,9 @@
# VITE_BACKEND_BASE_URL=https://ch1.b.uat.4.cn/deerflow
# VITE_LANGGRAPH_BASE_URL=https://ch1.b.uat.4.cn/deerflow/api
# VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
# VITE_STUDIO_ENABLED=true # 仅控制“工作室”入口显示,列表内容仍依赖 notebook 接口数据
# --- 生产打包(见 .env.production)---
# 部署前修改 .env.production 中的地址,再执行 pnpm build
+32 -21
View File
@@ -1,32 +1,43 @@
# 内网 / 局域网生产打包(npm run build:network 或 pnpm build:network)
# 打包前请将 192.168.1.100 改为实际服务器 IP;变量会写入 dist,运行时不可再改
# 本地开发(pnpm dev)— 与原先 .env 保持一致
# 个人覆盖请新建 .env.development.local(已 gitignore,优先级更高)
# Gateway / DeerFlow API(与 offline-backend DEPLOY 文档一致)
# Gateway / DeerFlow API
VITE_BACKEND_BASE_URL=http://47.88.25.99:7001
VITE_LANGGRAPH_BASE_URL=http://47.88.25.99:7001/api
# 轻应用「新建笔记」系列接口
VITE_NOTE_API_BASE_URL=http://192.168.1.100:8000
# CMZS 后端接口
VITE_API_BASE_URL=http://192.168.1.100/zncm
VITE_NOTE_API_BASE_URL=http://47.88.25.99:8000
# CMZS后端接口
VITE_API_BASE_URL=https://ch1.b.uat.4.cn/zncm
# 佳来模型对话接口
VITE_AIPI_CHAT_URL=http://192.168.1.100/gpt
VITE_AIPI_CHAT_URL=https://ch1.b.uat.4.cn/gpt
# 多轮检索接口
VITE_AIPI_GENERATE_CHAT_URL=http://192.168.1.100/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=http://192.168.1.100/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=http://192.168.1.100/knowlibrary
VITE_AIPI_B1COH_URL=http://192.168.1.100/web
VITE_AIPI_B2ACTOR_URL=http://192.168.1.100
VITE_AIPI_GENERATE_CHAT_URL=https://ch1.b.uat.4.cn/jialai_b1_chat
VITE_AIPI_TASK_CHAT_URL=https://ch1.b.uat.4.cn/qq_b1_chat
VITE_AIPI_KNOWLEDGE_URL=https://chlib.b.uat.4.cn/knowlibrary
VITE_AIPI_B1COH_URL=https://ch1.b.uat.4.cn/web
VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=http://192.168.1.100/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=http://192.168.1.100/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://192.168.1.100
VITE_AIPI_CONSUMER_URL=http://192.168.1.100/consumer
# MaxKey 鉴权(getTokenByOnlineTicket,内网部署请改为实际地址)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=http://192.168.1.100
VITE_AIPI_XKLINKTO_URL=http://192.168.1.100
VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://47.88.25.99:8000
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
# VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_NOTEBOOK_API_BASE_URL=http://47.88.25.99:8000
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
VITE_AIPI_FRONTEND_URL=http://47.88.25.99:3000
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# 可选:Canvas 独立 LangGraph 地址
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
# VITE_CANVAS_LANGGRAPH_BASE_URL=
+4
View File
@@ -19,13 +19,17 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.uat.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -3,3 +3,6 @@
# VITE_BACKEND_BASE_URL=https://your-intranet-host/deerflow
# VITE_LANGGRAPH_BASE_URL=https://your-intranet-host/deerflow/api
# VITE_NOTE_API_BASE_URL=https://your-intranet-host/note-api
# VITE_AIPI_BACKENP_URL=https://your-notebook-backend-host
# VITE_AIPI_FRONTEND_URL=https://your-notebook-frontend-host
# VITE_STUDIO_ENABLED=true
+5
View File
@@ -19,12 +19,17 @@ VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.sit.4.cn/knowes/api/iframeKnowledgeUrl
VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope
# 工作室/笔记本后端接口(侧边栏“工作室”列表、笔记本详情等都走这里,不是 VITE_NOTE_API_BASE_URL)
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer
# MaxKey 鉴权(getTokenByOnlineTicket)
VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.sit.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.sit.4.cn
# 工作室/笔记本前端地址(用于 auth/callback 等跳转)
VITE_AIPI_FRONTEND_URL=https://knowledgetoolserver.b.sit.4.cn
# 工作室侧边栏显示开关;仅控制入口显示,展开内容仍依赖 notebook 接口成功返回数据
VITE_STUDIO_ENABLED=true
# 可选:Canvas 独立 LangGraph 地址
# VITE_CANVAS_LANGGRAPH_BASE_URL=
@@ -85,10 +85,13 @@ export function ArtifactFileDetail({
className,
filepath: filepathFromProps,
threadId,
onClose,
}: {
className?: string;
filepath: string;
threadId: string;
/** 覆盖默认关闭行为(仅 setOpen(false));圆桌 Step3 文件预览用于退出沙箱模式。 */
onClose?: () => void;
}) {
const { t } = useI18n();
const { artifacts, setOpen, select } = useArtifacts();
@@ -623,7 +626,7 @@ export function ArtifactFileDetail({
className="hover:bg-background"
icon={XIcon}
label={t.common.close}
onClick={() => setOpen(false)}
onClick={() => (onClose ? onClose() : setOpen(false))}
tooltip={t.common.close}
/>
</ArtifactActions>
@@ -17,6 +17,7 @@ import { getBackendBaseURL } from "@/core/config";
import type { IntentSummary } from "@/roundtable-planning/api/intent";
import type { ThreadIdMap } from "@/roundtable-planning/api/multi-agent";
import type { JobStatus } from "@/roundtable-planning/lib/job-types";
/**
* Step 1 snapshot. Shapes for the chat / timeline lists stay loose (`unknown[]`)
@@ -80,6 +81,13 @@ export interface DraftMeta {
furthestStep: 1 | 2 | 3;
createdAt: string;
updatedAt: string;
/**
* Latest background-job status for this draft (Phase 6 history-dropdown
* loading badge). Undefined/null when the draft has never been backgrounded.
*/
status?: JobStatus | null;
/** Latest background job id (for opening the dispatch-chain modal / 查看). */
jobId?: string | null;
}
/** Full record including the step snapshots. */
@@ -129,6 +137,8 @@ interface ServerDraftMeta {
furthest_step: number;
created_at: string | null;
updated_at: string | null;
status?: string | null;
job_id?: string | null;
}
interface ServerDraftFull extends ServerDraftMeta {
@@ -145,6 +155,8 @@ function toMeta(s: ServerDraftMeta): DraftMeta {
furthestStep: step,
createdAt: s.created_at ?? "",
updatedAt: s.updated_at ?? "",
status: (s.status as JobStatus | null | undefined) ?? null,
jobId: s.job_id ?? null,
};
}
@@ -0,0 +1,195 @@
/**
* 圆桌后台作业 API 客户端(Phase 4)。
*
* 对接后端 `/api/roundtable-jobs`(Phase 3a):启动「后台挂起」作业、查快照、订阅
* SSE 进度、回答澄清续跑、取消。后端响应已是 camelCase(`RoundtableJobResponse`),
* 故无需 snake_case 转换;唯一加工是给 `dispatchChain` 节点补 `avatarType`(后端不下发,
* 由前端 `getAvatarTypeFor(agentId, order)` 派生,避免把配色表移植到后端而漂移)。
*
* SSE 走「后端轮询 job 表后下发整份快照」的模型(见后端注释):每帧 `data: {event:
* "progress", ...job}`,前端拿到的就是最新整份 `RoundtableJob`,直接渲染即可,无需做
* 增量事件归并。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import { getAvatarTypeFor } from "../lib/roundtable-constants";
import {
isTerminalJobStatus,
type DispatchChainNode,
type RoundtableJob,
type StartJobPayload,
} from "../lib/job-types";
const BASE = () => `${getBackendBaseURL()}/api/roundtable-jobs`;
async function readJson<T>(res: Response, ctx: string): Promise<T> {
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`${ctx} failed (${res.status}): ${text}`);
}
return (await res.json()) as T;
}
/** 给链路节点补 avatarType(后端不下发)。 */
function withAvatar(nodes: unknown): DispatchChainNode[] {
if (!Array.isArray(nodes)) return [];
return nodes.map((n, i) => {
const node = (n ?? {}) as Partial<DispatchChainNode> & { agentId?: string; order?: number };
const agentId = String(node.agentId ?? "");
const order = typeof node.order === "number" ? node.order : i + 1;
return {
agentId,
name: String(node.name ?? agentId),
order,
state: (node.state as DispatchChainNode["state"]) ?? "pending",
avatarType: node.avatarType || getAvatarTypeFor(agentId, order - 1),
...(node.lastSpokeCycle != null ? { lastSpokeCycle: node.lastSpokeCycle } : {}),
};
});
}
/** 后端快照 → 前端 RoundtableJob(容错 + 补 avatarType)。 */
function mapJob(raw: Record<string, unknown>): RoundtableJob {
return {
id: String(raw.id ?? ""),
draftId: (raw.draftId as string | null) ?? null,
status: (raw.status as RoundtableJob["status"]) ?? "queued",
phase: (raw.phase as RoundtableJob["phase"]) ?? "initializing",
cycle: typeof raw.cycle === "number" ? raw.cycle : 0,
dispatchChain: withAvatar(raw.dispatchChain),
activeAgentId: (raw.activeAgentId as string | null) ?? null,
consensusPercentage: typeof raw.consensusPercentage === "number" ? raw.consensusPercentage : 0,
pendingClarification: (raw.pendingClarification as string | null) ?? null,
orchestrationMode: (raw.orchestrationMode as RoundtableJob["orchestrationMode"]) ?? "recommend",
chain: (raw.chain as RoundtableJob["chain"]) ?? null,
error: (raw.error as string | null) ?? null,
createdAt: (raw.createdAt as string | null) ?? null,
updatedAt: (raw.updatedAt as string | null) ?? null,
};
}
function toStartBody(p: StartJobPayload) {
return {
draftId: p.draftId ?? null,
intent: p.intent,
agents: p.agents,
model: p.model,
orchestrationMode: p.orchestrationMode ?? "recommend",
chain: p.chain ?? null,
seedMessage: p.seedMessage,
threadIds: p.threadIds ?? null,
coordinatorName: p.coordinatorName ?? "roundtable-coordinator",
};
}
// ── CRUD / 控制 ───────────────────────────────────────────────────────────────
/** 启动一个后台作业。 */
export async function startJob(payload: StartJobPayload): Promise<RoundtableJob> {
const res = await apiFetch(BASE(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(toStartBody(payload)),
});
return mapJob(await readJson<Record<string, unknown>>(res, "start job"));
}
/** 查作业快照。404 → null。 */
export async function getJob(jobId: string): Promise<RoundtableJob | null> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}`, { method: "GET" });
if (res.status === 404) return null;
return mapJob(await readJson<Record<string, unknown>>(res, "get job"));
}
/** 查某草稿最近一个作业。无 → null。 */
export async function getJobByDraft(draftId: string): Promise<RoundtableJob | null> {
const res = await apiFetch(`${BASE()}?draft_id=${encodeURIComponent(draftId)}`, { method: "GET" });
const data = await readJson<Record<string, unknown> | null>(res, "get job by draft");
return data ? mapJob(data) : null;
}
/** 回答澄清后续跑。 */
export async function resumeJob(jobId: string, answer: string): Promise<RoundtableJob> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/resume`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ answer }),
});
return mapJob(await readJson<Record<string, unknown>>(res, "resume job"));
}
/** 取消作业。 */
export async function cancelJob(jobId: string): Promise<RoundtableJob> {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/cancel`, { method: "POST" });
return mapJob(await readJson<Record<string, unknown>>(res, "cancel job"));
}
// ── SSE 进度订阅 ─────────────────────────────────────────────────────────────
export interface StreamJobHandlers {
/** 每收到一帧整份快照就回调(含首帧)。 */
onProgress: (job: RoundtableJob) => void;
/** 终态(done/error/cancelled)时回调一次(在对应的 onProgress 之后)。 */
onDone?: (job: RoundtableJob) => void;
/** 流错误 / 作业不存在。 */
onError?: (detail: string) => void;
}
/**
* 订阅作业 SSE 进度。返回 unsubscribe(中断连接)。
*
* 终态帧后后端会主动关闭流;本函数也在收到终态快照时调 onDone,调用方可据此 unsubscribe。
*/
export function streamJob(jobId: string, handlers: StreamJobHandlers): () => void {
const controller = new AbortController();
void (async () => {
try {
const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/stream`, {
method: "GET",
signal: controller.signal,
});
if (!res.ok || !res.body) {
handlers.onError?.(`stream failed (${res.status})`);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const raw of lines) {
const line = raw.trim();
if (!line.startsWith("data:")) continue;
const json = line.slice("data:".length).trim();
if (!json) continue;
let frame: Record<string, unknown>;
try {
frame = JSON.parse(json) as Record<string, unknown>;
} catch {
continue;
}
if (frame.event === "error") {
handlers.onError?.(String(frame.detail ?? "unknown stream error"));
continue;
}
const job = mapJob(frame);
handlers.onProgress(job);
if (isTerminalJobStatus(job.status)) handlers.onDone?.(job);
}
}
} catch (err) {
if (!controller.signal.aborted) {
handlers.onError?.(err instanceof Error ? err.message : String(err));
}
}
})();
return () => controller.abort();
}
@@ -0,0 +1,59 @@
import { FileText, Loader2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { downloadMarkdownDocxFromBackend } from "@/core/api/markdown-docx-export";
import { cn } from "@/lib/utils";
export function ConsensusDocxDownloadButton({
markdown,
title = "圆桌总控最终结论",
className,
size = "sm",
}: {
markdown: string;
title?: string;
className?: string;
size?: "sm" | "md";
}) {
const [busy, setBusy] = useState(false);
const canExport = Boolean(markdown?.trim());
const handleClick = async () => {
if (!canExport) {
toast.error("暂无可导出的结论内容");
return;
}
setBusy(true);
try {
await downloadMarkdownDocxFromBackend({ title, markdown });
toast.success("Word 文件已生成");
} catch (error) {
toast.error(error instanceof Error ? error.message : "导出 Word 失败");
} finally {
setBusy(false);
}
};
return (
<button
type="button"
onClick={() => void handleClick()}
disabled={!canExport || busy}
title={canExport ? "下载为 Word" : "暂无结论内容"}
className={cn(
"inline-flex items-center gap-1 rounded-xl border border-slate-200 bg-white font-bold text-slate-700 transition",
"hover:bg-slate-50 hover:border-primary/30 disabled:cursor-not-allowed disabled:opacity-50",
size === "sm" ? "px-2.5 py-1 text-[10px]" : "px-3 py-1.5 text-[10.5px]",
className,
)}
>
{busy ? (
<Loader2 className={cn("animate-spin", size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5")} />
) : (
<FileText className={cn(size === "sm" ? "h-3 w-3" : "h-3.5 w-3.5", "text-primary")} />
)}
下载 Word
</button>
);
}
@@ -0,0 +1,92 @@
/**
* 派活链路流程图弹窗 · 真后台作业容器(Phase 6/8)。
*
* 给定 ``jobId``:先拉一帧快照让弹窗立刻有内容,随后订阅 SSE(``streamJob``)实时刷新,
* 把最新 ``RoundtableJob`` 喂给纯展示组件 ``DispatchChainModal``。关闭/卸载时退订。
*
* ``onView`` 透传给 footer「查看」按钮(Phase 8 接「进入正在进行的任务」)。
*/
import { useEffect, useState } from "react";
import { DispatchChainModal } from "./DispatchChainModal";
import { cancelJob, getJob, resumeJob, streamJob } from "../api/roundtable-jobs";
import type { RoundtableJob } from "../lib/job-types";
export interface DispatchChainJobModalProps {
jobId: string | null;
open: boolean;
onOpenChange: (open: boolean) => void;
title?: string;
onView?: (job: RoundtableJob) => void;
}
export function DispatchChainJobModal({
jobId,
open,
onOpenChange,
title,
onView,
}: DispatchChainJobModalProps) {
const [job, setJob] = useState<RoundtableJob | null>(null);
useEffect(() => {
if (!open || !jobId) {
setJob(null);
return;
}
let cancelled = false;
// 首帧快照:弹窗一打开就有内容,不必等 SSE 第一帧。
void getJob(jobId)
.then((j) => {
if (!cancelled && j) setJob(j);
})
.catch(() => {});
const unsubscribe = streamJob(jobId, {
onProgress: (j) => {
if (!cancelled) setJob(j);
},
onError: () => {
/* 流错误静默:保留已拿到的最后一帧;可后续加 toast */
},
});
return () => {
cancelled = true;
unsubscribe();
};
}, [open, jobId]);
return (
<DispatchChainModal
open={open}
onOpenChange={onOpenChange}
title={title}
status={job?.status ?? "queued"}
phase={job?.phase ?? "initializing"}
cycle={job?.cycle ?? 0}
nodes={job?.dispatchChain ?? []}
consensusPercentage={job?.consensusPercentage ?? 0}
onView={job && onView ? () => onView(job) : undefined}
error={job?.error}
pendingClarification={job?.pendingClarification}
onResume={
jobId
? (answer) => {
// 乐观更新为 running,随后 SSE 会推真状态续上。
void resumeJob(jobId, answer)
.then((j) => setJob(j))
.catch(() => {});
}
: undefined
}
onCancel={
jobId
? () => {
void cancelJob(jobId)
.then((j) => setJob(j))
.catch(() => {});
}
: undefined
}
/>
);
}
@@ -14,8 +14,8 @@
* (business-chain.ts)。连线动画 CSS 在 `styles/roundtable-planning.css` 全局段
* (Radix Dialog portal 到 body,不在 .roundtable-planning 作用域内,故用全局类 + 语义 token)。
*/
import { Fragment } from "react";
import { CheckIcon, ChevronRightIcon, EyeIcon, Loader2Icon } from "lucide-react";
import { Fragment, useState } from "react";
import { CheckIcon, ChevronRightIcon, EyeIcon, Loader2Icon, SendIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
@@ -52,10 +52,20 @@ export interface DispatchChainModalProps {
nodes: DispatchChainNode[];
/** 共识百分比(0..100)。 */
consensusPercentage: number;
/** 「查看」按钮回调 → 进入正在进行的任务。 */
/** 「查看」按钮回调 → 进入正在进行的任务 / 查看报告。 */
onView?: () => void;
/** 命中澄清时的问题文本(status==='awaiting_input')。 */
pendingClarification?: string | null;
/** 提交澄清回答 → 续跑。 */
onResume?: (answer: string) => void;
/** 停止/取消作业。 */
onCancel?: () => void;
/** 出错详情(status==='error')。 */
error?: string | null;
}
const _NON_TERMINAL: ReadonlySet<JobStatus> = new Set<JobStatus>(["queued", "running", "awaiting_input"]);
const STATUS_LABEL: Record<JobStatus, string> = {
queued: "排队中",
running: "进行中",
@@ -85,7 +95,12 @@ export function DispatchChainModal({
nodes,
consensusPercentage,
onView,
pendingClarification,
onResume,
onCancel,
error,
}: DispatchChainModalProps) {
const [answer, setAnswer] = useState("");
// 总控节点:初始化 / 决策派活阶段高亮
const coordinatorState: ChainNodeState =
phase === "initializing" || phase === "leader_thinking" ? "active" : "done";
@@ -177,6 +192,47 @@ export function DispatchChainModal({
</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">
{error}
</div>
)}
{/* 澄清作答(status==='awaiting_input')→ resumeJob 续跑 */}
{status === "awaiting_input" && pendingClarification && onResume && (
<div className="shrink-0 pt-3">
<div className="rounded-xl border border-amber-300/50 bg-amber-50/60 p-3 dark:border-amber-700/40 dark:bg-amber-950/20">
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold text-amber-600 dark:text-amber-400">
<span className="inline-block size-1.5 rounded-full bg-amber-400 animate-pulse" />
总控需要补充信息,请回答后继续
</div>
<p className="mb-2 whitespace-pre-wrap text-sm text-foreground/80">{pendingClarification}</p>
<textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
rows={2}
placeholder="输入你的回答…"
className="mb-2 w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:border-primary focus:ring-1 focus:ring-primary/30"
/>
<div className="flex justify-end">
<Button
size="sm"
disabled={!answer.trim()}
onClick={() => {
onResume(answer.trim());
setAnswer("");
}}
className="gap-1.5"
>
<SendIcon className="size-3.5" />
提交并继续
</Button>
</div>
</div>
</div>
)}
<DialogFooter className="shrink-0 flex-row items-center justify-between gap-2 border-t pt-3 sm:justify-between">
<span className="text-xs text-muted-foreground">
轮次 {Math.min(cycle, maxCycles)}/{maxCycles}
@@ -185,10 +241,17 @@ export function DispatchChainModal({
<span className="mx-2 text-muted-foreground/40">·</span>
{PHASE_LABEL[phase]}
</span>
<Button onClick={onView} disabled={!onView} className="gap-1.5">
<EyeIcon className="size-4" />
查看
</Button>
<div className="flex items-center gap-2">
{onCancel && _NON_TERMINAL.has(status) && (
<Button variant="outline" size="sm" onClick={onCancel} className="text-muted-foreground">
停止
</Button>
)}
<Button onClick={onView} disabled={!onView} className="gap-1.5">
<EyeIcon className="size-4" />
{status === "done" ? "查看报告" : "查看"}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -21,7 +21,8 @@ import {
type SessionArtifactFile,
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { Step3FileBrowseMode } from "@/roundtable-planning/components/Step3FileBrowseMode";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import {
Step3ReportMode,
@@ -30,20 +31,6 @@ import {
import type { DraftStep3Snapshot } from "@/roundtable-planning/api/drafts";
import type { ReportIntent } from "@/roundtable-planning/hooks/useStep3Report";
/**
* Text-ish files we render in an in-app preview modal. Other types (pdf,
* images, html) keep the original window.open behaviour so the browser's
* native viewer handles them.
*/
function shouldPreviewInModal(file: { name: string; mimeType: string | null }): boolean {
const mime = file.mimeType ?? "";
if (mime === "text/markdown" || mime === "text/plain" || mime === "text/csv" || mime === "application/json") {
return true;
}
if (mime.startsWith("text/")) return true;
return /\.(md|markdown|txt|json|csv|log|yml|yaml|xml)$/i.test(file.name);
}
interface SeatDelivery {
/** Display name of the seat (e.g. "情报收集"). */
sender: string;
@@ -78,6 +65,8 @@ interface HighFidelityReportProps {
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
/** Resets state and navigates back to Step 1. */
onReset: () => void;
openArtifactPreview: (threadId: string, path: string) => void;
seatStubMessages: Record<string, unknown[]>;
}
interface SeatArtifacts {
@@ -116,9 +105,23 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
savedReport,
onReportSaved,
onReset,
openArtifactPreview,
seatStubMessages,
}) => {
// 「结果绘制」模式:把现有报告内容收起到左栏,右侧由方案可视化智能体绘制图表 HTML。
const consensusDocTitle = useMemo(() => {
const objective = intentReady?.objective?.trim();
if (!objective) return "圆桌总控最终结论";
const short = objective.length > 40 ? `${objective.slice(0, 40)}…` : objective;
return `圆桌总控结论-${short}`;
}, [intentReady?.objective]);
const [drawMode, setDrawMode] = useState(false);
const [fileSandboxMode, setFileSandboxMode] = useState(false);
const [fileSandboxTarget, setFileSandboxTarget] = useState<{
threadId: string;
file: SessionArtifactFile;
} | null>(null);
// 进入第三步即关闭并清空第二步遗留的沙箱(artifacts 面板全局共享,第二步打开后
// 不会自动关)。点「结果绘制」后 Step3ReportMode 会按需开自己的沙箱,互不串扰。
@@ -151,10 +154,6 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
const [artifactsBySeat, setArtifactsBySeat] = useState<SeatArtifacts[]>([]);
const [expandedSeats, setExpandedSeats] = useState<Set<string>>(new Set());
const [reloadKey, setReloadKey] = useState(0);
// Active preview target — non-null means the modal is open. We carry the
// owning threadId so the modal can fetch + build download URLs without
// needing a second prop drill.
const [previewing, setPreviewing] = useState<{ threadId: string; file: SessionArtifactFile } | null>(null);
const generatedAt = useMemo(() => formatGeneratedAt(new Date()), []);
// Fetch artifact listings for every seat thread in parallel. Re-runs when
@@ -222,23 +221,16 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
});
}, []);
const handlePreview = useCallback(
(threadId: string, file: SessionArtifactFile) => {
// Text-ish artifacts (markdown / txt / json / csv / yaml / ...) open
// in our in-app modal so the user can read the content without leaving
// the planning page. Binary or active content (pdf / png / html) still
// hands off to the browser's native viewer via the original tab.
if (shouldPreviewInModal({ name: file.name, mimeType: file.mimeType })) {
setPreviewing({ threadId, file });
return;
}
if (typeof window !== "undefined") {
const url = buildArtifactDownloadUrl(threadId, file.path);
window.open(url, "_blank", "noopener,noreferrer");
}
},
[],
);
const handleViewInSandbox = useCallback((threadId: string, file: SessionArtifactFile) => {
setFileSandboxTarget({ threadId, file });
setFileSandboxMode(true);
}, []);
const handleBackFromFileSandbox = useCallback(() => {
setFileSandboxMode(false);
setFileSandboxTarget(null);
deselectArtifact();
}, [deselectArtifact]);
const handleDownload = useCallback(
(threadId: string, file: SessionArtifactFile) => {
@@ -250,6 +242,22 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
[],
);
if (fileSandboxMode && fileSandboxTarget) {
return (
<Step3FileBrowseMode
consensusText={consensusText}
hasConsensus={hasConsensus}
consensusDocTitle={consensusDocTitle}
seatDeliveries={seatDeliveries}
artifactsBySeat={artifactsBySeat}
seatStubMessages={seatStubMessages}
openArtifactPreview={openArtifactPreview}
initialFile={fileSandboxTarget}
onBack={handleBackFromFileSandbox}
/>
);
}
if (drawMode) {
return (
<Step3ReportMode
@@ -351,10 +359,17 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
<div className="p-6 space-y-6">
{/* Section 1: Coordinator consensus conclusion */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-500" />
总控最终结论
</h4>
<div className="mb-3 flex items-center justify-between gap-2">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest flex items-center gap-2 min-w-0">
<CheckCircle className="w-4 h-4 text-emerald-500 shrink-0" />
<span className="truncate">总控最终结论</span>
</h4>
<ConsensusDocxDownloadButton
markdown={consensusText}
title={consensusDocTitle}
className="shrink-0"
/>
</div>
{consensusText ? (
<div className="text-xs text-slate-700 leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
@@ -420,7 +435,7 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
)}
</section>
{/* Section 3: Generated files per seat —— 各席位产出的 md 文件,点击预览弹窗查看内容 */}
{/* Section 3: Generated files per seat —— 点击「查看」进入沙箱分栏预览 */}
<section className="bg-white border border-slate-200 rounded-2xl p-5 shadow-[0_2px_8px_rgba(0,0,0,0.02)]">
<h4 className="text-xs font-black text-slate-900 uppercase tracking-widest mb-3 flex items-center gap-2">
<Download className="w-4 h-4 text-primary" />
@@ -475,9 +490,9 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
</span>
<button
type="button"
onClick={() => handlePreview(seat.threadId, f)}
onClick={() => handleViewInSandbox(seat.threadId, f)}
className="text-slate-600 hover:text-primary transition p-1 rounded hover:bg-slate-100"
title="预览"
title="在沙箱中查看"
>
<Eye className="w-3.5 h-3.5" />
</button>
@@ -515,15 +530,6 @@ export const HighFidelityReport: React.FC<HighFidelityReportProps> = ({
</div>
</div>
{previewing && (
<ArtifactPreviewModal
threadId={previewing.threadId}
path={previewing.file.path}
name={previewing.file.name}
mimeType={previewing.file.mimeType}
onClose={() => setPreviewing(null)}
/>
)}
</div>
);
};
@@ -23,6 +23,7 @@ import {
Loader2,
Pause,
Play,
Rocket,
Sparkles,
} from "lucide-react";
@@ -98,6 +99,8 @@ export interface Step2PanelProps {
onSubmitClarification: (answer: string) => void;
onSavePersona: (id: number) => void;
onProceedToStep3: () => void;
/** 「后台挂起」:把当前研讨转入后端自动跑到结果绘制完成。 */
onBackgroundSuspend: () => void;
onTriggerToast: (msg: string) => void;
}
@@ -133,6 +136,7 @@ export function Step2Panel(props: Step2PanelProps) {
onSubmitClarification,
onSavePersona,
onProceedToStep3,
onBackgroundSuspend,
onTriggerToast,
} = props;
@@ -365,6 +369,16 @@ export function Step2Panel(props: Step2PanelProps) {
<div className="h-6 w-[1px] bg-slate-200 shrink-0" />
<button
type="button"
onClick={onBackgroundSuspend}
title="把当前研讨转入后台自动跑到结果绘制完成;可离开页面,进度在历史记录里查看"
className="bg-white border border-slate-200 hover:bg-slate-50 text-slate-700 text-xs font-bold px-4 py-2.5 rounded-xl transition shrink-0 flex items-center gap-1 active:scale-95 cursor-pointer dark:bg-secondary dark:text-foreground dark:border-border"
>
<Rocket className="w-3.5 h-3.5 text-primary" />
后台挂起
</button>
<button
type="button"
onClick={onProceedToStep3}
@@ -27,11 +27,14 @@ import { cn } from "@/lib/utils";
const CLOSE_MODE = { chat: 100, artifacts: 0 };
const OPEN_MODE = { chat: 60, artifacts: 40 };
interface Step2SandboxLayoutProps {
children: React.ReactNode;
children?: React.ReactNode;
/** 当前预览文件所属的席位 thread id。 */
activeThreadId: string | null;
/** 为 true 时右侧区域仅展示沙箱预览,占满可用宽度(无中间说明/对话区)。 */
artifactsFill?: boolean;
/** artifactsFill 模式下用户点击沙箱关闭时的回调(退出预览、收起沙箱)。 */
onArtifactsClose?: () => void;
/** 是否有流式 run 进行中 —— 开启 artifacts API 轮询。 */
isStreaming?: boolean;
/**
@@ -43,10 +46,12 @@ interface Step2SandboxLayoutProps {
}
export function Step2SandboxLayout({
children,
children = null,
activeThreadId,
isStreaming = false,
seatMessages,
artifactsFill = false,
onArtifactsClose,
}: Step2SandboxLayoutProps) {
const layoutRef = useRef<GroupImperativeHandle>(null);
const {
@@ -54,18 +59,22 @@ export function Step2SandboxLayout({
open: artifactsOpen,
setOpen: setArtifactsOpen,
selectedArtifact,
deselect,
} = useArtifacts();
const artifacts = rawArtifacts ?? [];
const artifactPanelOpen = artifactsOpen;
useEffect(() => {
if (layoutRef.current) {
layoutRef.current.setLayout(
artifactPanelOpen ? OPEN_MODE : CLOSE_MODE,
);
}
}, [artifactPanelOpen]);
if (artifactsFill || !layoutRef.current) return;
layoutRef.current.setLayout(artifactPanelOpen ? OPEN_MODE : CLOSE_MODE);
}, [artifactPanelOpen, artifactsFill]);
const handleArtifactsClose = () => {
deselect();
setArtifactsOpen(false);
onArtifactsClose?.();
};
const threadStub = useMemo(() => {
const messages = seatMessages ?? [];
@@ -83,6 +92,70 @@ export function Step2SandboxLayout({
const previewThreadId = activeThreadId ?? "";
const artifactPanelBody = (
<div
className={cn(
"h-full bg-[#fffdfa] p-4",
!artifactsFill &&
"transition-transform duration-300 ease-in-out",
!artifactsFill && (artifactPanelOpen ? "translate-x-0" : "translate-x-full"),
)}
>
{selectedArtifact && previewThreadId ? (
<ArtifactFileDetail
className="size-full"
filepath={selectedArtifact}
threadId={previewThreadId}
onClose={artifactsFill && onArtifactsClose ? handleArtifactsClose : undefined}
/>
) : (
<div className="relative flex size-full justify-center">
{!artifactsFill && (
<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>
);
if (artifactsFill) {
return (
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
<div className="h-full min-h-0 flex-1 bg-[#fffdfa]">{artifactPanelBody}</div>
</ThreadContext.Provider>
);
}
return (
<ThreadContext.Provider value={{ thread: threadStub, isMock: false }}>
<ResizablePanelGroup
@@ -109,54 +182,7 @@ export function Step2SandboxLayout({
)}
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>
{artifactPanelBody}
</ResizablePanel>
</ResizablePanelGroup>
</ThreadContext.Provider>
@@ -0,0 +1,351 @@
/**
* Step 3「文件沙箱预览」模式 —— 布局对齐「结果绘制」:左栏研讨内容 + 文件清单,右栏 Step 2 沙箱。
*/
import { useCallback, useEffect, useMemo, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import {
ArrowLeft,
CheckCircle,
Download,
Eye,
FileText,
Loader2,
PanelLeftClose,
PanelLeftOpen,
} from "lucide-react";
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { cn } from "@/lib/utils";
import {
buildArtifactDownloadUrl,
type SessionArtifactFile,
} from "@/roundtable-planning/api/multi-agent";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { markdownComponents } from "@/roundtable-planning/components/markdown-components";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
interface SeatDelivery {
sender: string;
content: string;
}
interface SeatArtifacts {
agentName: string;
displayName: string;
threadId: string;
files: SessionArtifactFile[];
loading: boolean;
error: string | null;
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export interface Step3FileBrowseModeProps {
consensusText: string;
hasConsensus: boolean;
consensusDocTitle: string;
seatDeliveries: SeatDelivery[];
artifactsBySeat: SeatArtifacts[];
seatStubMessages: Record<string, unknown[]>;
openArtifactPreview: (threadId: string, path: string) => void;
/** 进入模式时默认打开的文件(由「查看」触发)。 */
initialFile: { threadId: string; file: SessionArtifactFile };
onBack: () => void;
}
export function Step3FileBrowseMode({
consensusText,
hasConsensus,
consensusDocTitle,
seatDeliveries,
artifactsBySeat,
seatStubMessages,
openArtifactPreview,
initialFile,
onBack,
}: Step3FileBrowseModeProps) {
const [activeFile, setActiveFile] = useState(initialFile);
const [leftCollapsed, setLeftCollapsed] = useState(false);
useEffect(() => {
openArtifactPreview(activeFile.threadId, activeFile.file.path);
}, [activeFile.file.path, activeFile.threadId, openArtifactPreview]);
const handleSelectFile = useCallback(
(threadId: string, file: SessionArtifactFile) => {
setActiveFile({ threadId, file });
openArtifactPreview(threadId, file.path);
},
[openArtifactPreview],
);
const handleDownload = useCallback((threadId: string, file: SessionArtifactFile) => {
const url = buildArtifactDownloadUrl(threadId, file.path, { download: true });
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
}, []);
const activeSeatMessages = useMemo(
() => seatStubMessages[activeFile.threadId] ?? [],
[seatStubMessages, activeFile.threadId],
);
const activeDisplayName = useMemo(() => {
const seat = artifactsBySeat.find((s) => s.threadId === activeFile.threadId);
return seat?.displayName ?? "席位";
}, [artifactsBySeat, activeFile.threadId]);
return (
<div className="flex h-full min-h-0 flex-col text-slate-800">
<div className="shrink-0 flex items-center justify-between gap-3 border-b border-slate-100 bg-gradient-to-r from-slate-50 to-white px-6 py-3.5 flex-wrap">
<div className="flex items-center gap-2.5 min-w-0">
<FileText className="w-5 h-5 text-primary shrink-0" />
<h3 className="text-sm font-black text-slate-900 tracking-tight truncate">
文件预览 · 沙箱查看
</h3>
<span className="text-[10px] font-mono text-slate-500 bg-slate-100 px-2 py-0.5 rounded-lg truncate max-w-[240px]">
{activeDisplayName} / {activeFile.file.name}
</span>
</div>
<button
type="button"
onClick={onBack}
className="bg-white border border-slate-200 text-slate-700 hover:bg-slate-50 text-[10.5px] font-bold px-3 py-1.5 rounded-xl flex items-center gap-1 transition shrink-0"
>
<ArrowLeft className="w-3.5 h-3.5 text-slate-500" />
返回报告
</button>
</div>
<div className="flex-1 min-h-0 p-3">
{leftCollapsed ? (
<div className="h-full min-h-0 flex gap-3">
<aside className="w-[52px] shrink-0 flex flex-col gap-3 overflow-y-auto pr-0.5">
<button
type="button"
onClick={() => setLeftCollapsed(false)}
title="展开侧栏"
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>
<CollapsedIconButton
icon={<CheckCircle className="w-4 h-4 text-emerald-500" />}
title="总控最终结论"
onClick={() => setLeftCollapsed(false)}
/>
<CollapsedIconButton
icon={<FileText className="w-4 h-4 text-primary" />}
title="生成的报告文件"
onClick={() => setLeftCollapsed(false)}
/>
</aside>
<section className="flex-1 min-w-0 rounded-2xl border border-slate-200/60 bg-[#fffdfa] overflow-hidden">
<Step2SandboxLayout
artifactsFill
activeThreadId={activeFile.threadId}
seatMessages={activeSeatMessages}
onArtifactsClose={onBack}
/>
</section>
</div>
) : (
<ResizablePanelGroup
orientation="horizontal"
className="h-full min-h-0 gap-3"
autoSaveId="roundtable-step3-file-browse-layout"
>
<ResizablePanel defaultSize={42} minSize={26} className="min-w-0">
<aside className="h-full min-h-0 flex flex-col gap-3 overflow-y-auto pr-0.5">
<div className="shrink-0 flex items-center justify-between gap-2 px-1">
<span className="text-[11px] font-bold text-slate-500 uppercase tracking-widest">
方案内容
</span>
<button
type="button"
onClick={() => setLeftCollapsed(true)}
title="收起侧栏"
className="shrink-0 flex h-7 w-7 items-center justify-center rounded-lg border border-slate-200/50 bg-white text-slate-500 hover:text-primary hover:border-primary/30 transition-colors"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</button>
</div>
<section className="rounded-2xl border border-slate-200/60 bg-white p-3.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<div className="mb-2 flex items-center justify-between gap-2">
<h4 className="text-xs font-bold text-slate-900 flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-emerald-500" />
总控最终结论
</h4>
{hasConsensus && (
<span className="text-[9px] font-bold text-emerald-700 bg-emerald-50 border border-emerald-200 px-1.5 py-0.5 rounded-full">
已共识
</span>
)}
</div>
<div className="mb-2 flex justify-end">
<ConsensusDocxDownloadButton
markdown={consensusText}
title={consensusDocTitle}
size="sm"
/>
</div>
{consensusText ? (
<div className="max-h-32 overflow-y-auto text-[11px] text-slate-700 leading-relaxed">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText}
</ReactMarkdown>
</div>
) : (
<p className="text-[11px] text-slate-400 italic">暂无总控结论。</p>
)}
</section>
{seatDeliveries.length > 0 && (
<section className="rounded-2xl border border-slate-200/60 bg-white p-3.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)]">
<h4 className="text-xs font-bold text-slate-900 mb-2">
各席位交付
<span className="ml-1.5 text-[10px] font-mono text-slate-400">
{seatDeliveries.length}
</span>
</h4>
<ul className="space-y-1 max-h-24 overflow-y-auto">
{seatDeliveries.map((s, i) => (
<li key={`${s.sender}-${i}`} className="text-[11px] text-slate-600 truncate">
{s.sender}
</li>
))}
</ul>
</section>
)}
<section className="rounded-2xl border border-slate-200/60 bg-white p-3.5 shadow-[0_2px_8px_rgba(0,0,0,0.01)] flex-1 min-h-0 flex flex-col">
<h4 className="text-xs font-bold text-slate-900 mb-2 flex items-center gap-1.5 shrink-0">
<Download className="w-3.5 h-3.5 text-primary" />
生成的报告文件
</h4>
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
{artifactsBySeat.map((seat) => (
<div key={seat.agentName} className="border border-slate-150 rounded-xl overflow-hidden">
<div className="px-2.5 py-1.5 bg-slate-50 border-b border-slate-100 text-[10.5px] font-bold text-slate-800 truncate">
{seat.displayName}
</div>
{seat.loading ? (
<div className="flex items-center gap-1.5 px-2.5 py-2 text-[10px] text-slate-500">
<Loader2 className="w-3 h-3 animate-spin" />
加载中…
</div>
) : seat.error ? (
<p className="px-2.5 py-2 text-[10px] text-rose-600">{seat.error}</p>
) : seat.files.length === 0 ? (
<p className="px-2.5 py-2 text-[10px] text-slate-400 italic">无文件</p>
) : (
<ul className="divide-y divide-slate-100">
{seat.files.map((f) => {
const isActive =
activeFile.threadId === seat.threadId &&
activeFile.file.path === f.path;
return (
<li key={f.path}>
<div
className={cn(
"px-2 py-1.5 flex items-center justify-between gap-1",
isActive && "bg-[var(--brand-200)]/25",
)}
>
<button
type="button"
onClick={() => handleSelectFile(seat.threadId, f)}
className="flex items-center gap-1.5 min-w-0 text-left flex-1"
title={f.name}
>
<FileText className="w-3 h-3 text-slate-400 shrink-0" />
<span className="text-[10.5px] font-medium text-slate-800 truncate">
{f.name}
</span>
</button>
<div className="flex items-center gap-0.5 shrink-0">
<button
type="button"
onClick={() => handleSelectFile(seat.threadId, f)}
className={cn(
"p-1 rounded transition",
isActive
? "text-primary bg-white/80"
: "text-slate-500 hover:text-primary hover:bg-slate-100",
)}
title="在沙箱中查看"
>
<Eye className="w-3 h-3" />
</button>
<button
type="button"
onClick={() => handleDownload(seat.threadId, f)}
className="text-slate-500 hover:text-primary p-1 rounded hover:bg-slate-100 transition"
title="下载"
>
<Download className="w-3 h-3" />
</button>
</div>
</div>
</li>
);
})}
</ul>
)}
</div>
))}
</div>
</section>
</aside>
</ResizablePanel>
<ResizableHandle className="w-2 rounded-full bg-slate-200/70 hover:bg-slate-300 transition" />
<ResizablePanel defaultSize={58} minSize={30} className="min-w-0">
<section className="h-full min-h-0 rounded-2xl border border-slate-200/60 bg-[#fffdfa] overflow-hidden">
<Step2SandboxLayout
artifactsFill
activeThreadId={activeFile.threadId}
seatMessages={activeSeatMessages}
onArtifactsClose={onBack}
/>
</section>
</ResizablePanel>
</ResizablePanelGroup>
)}
</div>
</div>
);
}
function CollapsedIconButton({
icon,
title,
onClick,
}: {
icon: React.ReactNode;
title: string;
onClick: () => void;
}) {
return (
<button
type="button"
title={title}
onClick={onClick}
className="w-full shrink-0 flex items-center justify-center rounded-2xl border border-slate-200/50 bg-white p-2.5 text-slate-500 shadow-[0_2px_8px_rgba(0,0,0,0.01)] transition hover:text-primary hover:border-primary/30"
>
{icon}
</button>
);
}
@@ -35,6 +35,9 @@ export interface Step3PanelProps {
savedReport?: DraftStep3Snapshot | null;
onReportSaved?: (snapshot: DraftStep3Snapshot) => void;
onReset: () => void;
/** Step 2 沙箱预览产出文件(与第二步相同)。 */
openArtifactPreview: (threadId: string, path: string) => void;
seatStubMessages: Record<string, unknown[]>;
}
export function Step3Panel(props: Step3PanelProps) {
@@ -53,6 +56,8 @@ export function Step3Panel(props: Step3PanelProps) {
savedReport,
onReportSaved,
onReset,
openArtifactPreview,
seatStubMessages,
} = props;
const seatDeliveries = step2RoundtableDialogues
@@ -83,6 +88,8 @@ export function Step3Panel(props: Step3PanelProps) {
savedReport={savedReport}
onReportSaved={onReportSaved}
onReset={onReset}
openArtifactPreview={openArtifactPreview}
seatStubMessages={seatStubMessages}
/>
);
}
@@ -36,6 +36,7 @@ import {
type ThreadIdMap,
} from "@/roundtable-planning/api/multi-agent";
import { ArtifactPreviewModal } from "@/roundtable-planning/components/ArtifactPreviewModal";
import { ConsensusDocxDownloadButton } from "@/roundtable-planning/components/ConsensusDocxDownloadButton";
import { ScrollToBottomButton } from "@/roundtable-planning/components/ScrollToBottomButton";
import { Step2Message } from "@/roundtable-planning/components/Step2Panel";
import { Step2SandboxLayout } from "@/roundtable-planning/components/Step2SandboxLayout";
@@ -100,6 +101,13 @@ export function Step3ReportMode({
[seatDeliveries],
);
const consensusDocTitle = useMemo(() => {
const objective = intentReady?.objective?.trim();
if (!objective) return "圆桌总控最终结论";
const short = objective.length > 40 ? `${objective.slice(0, 40)}…` : objective;
return `圆桌总控结论-${short}`;
}, [intentReady?.objective]);
const {
phase,
error,
@@ -316,7 +324,16 @@ export function Step3ReportMode({
{/* 弹窗 */}
{modal === "consensus" && (
<ContentModal title="总控最终结论" onClose={() => setModal(null)}>
<ContentModal
title="总控最终结论"
onClose={() => setModal(null)}
headerAction={
<ConsensusDocxDownloadButton
markdown={consensusText}
title={consensusDocTitle}
/>
}
>
{consensusText ? (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
{consensusText}
@@ -543,10 +560,12 @@ function ContentModal({
title,
children,
onClose,
headerAction,
}: {
title: string;
children: React.ReactNode;
onClose: () => void;
headerAction?: React.ReactNode;
}) {
return (
<div
@@ -557,15 +576,18 @@ function ContentModal({
className="bg-white border border-slate-200 rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
<div className="shrink-0 flex items-center justify-between px-5 py-3.5 border-b border-slate-100 bg-slate-50">
<h3 className="text-sm font-bold text-slate-900">{title}</h3>
<button
type="button"
onClick={onClose}
className="p-1 text-slate-400 hover:text-slate-600 rounded-md hover:bg-slate-100"
>
<X className="w-4 h-4" />
</button>
<div className="shrink-0 flex items-center justify-between gap-2 px-5 py-3.5 border-b border-slate-100 bg-slate-50">
<h3 className="text-sm font-bold text-slate-900 min-w-0 truncate">{title}</h3>
<div className="flex items-center gap-2 shrink-0">
{headerAction}
<button
type="button"
onClick={onClose}
className="p-1 text-slate-400 hover:text-slate-600 rounded-md hover:bg-slate-100"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto p-5">{children}</div>
</div>
@@ -29,6 +29,7 @@ import {
updateDraft,
type DraftStep3Snapshot,
} from "@/roundtable-planning/api/drafts";
import type { JobStatus } from "@/roundtable-planning/lib/job-types";
/** 历史下拉渲染用的轻量列表项(`name` 映射自后端 `title`)。 */
export interface DraftListItem {
@@ -37,6 +38,10 @@ export interface DraftListItem {
furthestStep: 1 | 2 | 3;
createdAt: string;
updatedAt: string;
/** 最近一次后台作业状态(Phase 6 历史下拉「进行中」loading)。 */
status?: JobStatus | null;
/** 最近一次后台作业 id(点击进行中项打开流程图弹窗用)。 */
jobId?: string | null;
}
export interface UseDraftPersistenceOptions {
@@ -101,6 +106,8 @@ export interface UseDraftPersistenceReturn {
* 推荐弹窗打开前调用,确保推荐历史有 draft_id 可归属。
*/
ensureDraft: (furthestStep?: 1 | 2 | 3) => Promise<string | null>;
/** 强制刷新草稿列表。 */
refresh: () => Promise<void>;
}
export function useDraftPersistence(
@@ -165,6 +172,8 @@ export function useDraftPersistence(
furthestStep: m.furthestStep,
createdAt: m.createdAt,
updatedAt: m.updatedAt,
status: m.status ?? null,
jobId: m.jobId ?? null,
})),
);
} catch (err) {
@@ -457,5 +466,7 @@ export function useDraftPersistence(
handleCommitRename,
handleCancelRename,
ensureDraft,
/** 强制刷新草稿列表(如后台挂起后让「进行中」状态立刻出现)。 */
refresh: refreshList,
};
}
@@ -45,6 +45,48 @@ export interface DispatchChainNode {
lastSpokeCycle?: number;
}
/** 一条后台作业的完整快照(§2.5)——对齐后端 `RoundtableJobResponse`(camelCase)。 */
export interface RoundtableJob {
id: string;
draftId: string | null;
status: JobStatus;
phase: JobPhase;
cycle: number;
dispatchChain: DispatchChainNode[];
activeAgentId: string | null;
consensusPercentage: number;
pendingClarification: string | null;
orchestrationMode: "recommend" | "chain";
chain: { id: string; title: string } | null;
error: string | null;
createdAt: string | null;
updatedAt: string | null;
}
/** 启动一个后台作业的入参(对齐后端 `StartJobRequest`)。 */
export interface StartJobPayload {
draftId?: string | null;
/** 任务意图;缺省 seedMessage 时用它作第一轮 leader prompt。 */
intent: string;
/** 有序席位(recommend=选择顺序 / chain=链条顺序)。 */
agents: Array<{ agentId: string; name: string }>;
model?: string;
orchestrationMode?: "recommend" | "chain";
chain?: { id: string; title: string } | null;
/** 显式第一轮 leader prompt(覆盖 intent)。 */
seedMessage?: string;
/** 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。 */
threadIds?: Record<string, string> | null;
coordinatorName?: string;
}
/** 终态集合——SSE/轮询判定作业是否结束。 */
export const TERMINAL_JOB_STATUSES: readonly JobStatus[] = ["done", "error", "cancelled"];
export function isTerminalJobStatus(status: JobStatus): boolean {
return TERMINAL_JOB_STATUSES.includes(status);
}
/** 归一化进度事件(§2.4)——SSE 推送。Phase 7 暂未用到,先占位。 */
export type JobProgressEvent =
| { type: "status"; status: JobStatus }
@@ -56,6 +56,7 @@ import { useModels } from "@/core/models/hooks";
import { getAvatar, Man1Avatar, RobotAvatar } from "@/roundtable-planning/components/Avatars";
import { RecommendAgentsDialog } from "@/roundtable-planning/components/RecommendAgentsDialog";
import { BusinessChainListDialog } from "@/roundtable-planning/components/BusinessChainListDialog";
import { DispatchChainJobModal } from "@/roundtable-planning/components/DispatchChainJobModal";
import { Step1Panel } from "@/roundtable-planning/components/Step1Panel";
import { Step2Panel } from "@/roundtable-planning/components/Step2Panel";
import { Step3Panel } from "@/roundtable-planning/components/Step3Panel";
@@ -74,6 +75,7 @@ import {
type SelectedAgent,
} from "@/roundtable-planning/lib/roundtable-constants";
import type { SeatSelectionResult } from "@/roundtable-planning/lib/business-chain";
import { getJobByDraft, startJob } from "@/roundtable-planning/api/roundtable-jobs";
export default function RoundtablePlanningPage() {
const { theme } = useTheme();
@@ -88,6 +90,8 @@ export default function RoundtablePlanningPage() {
const [isRecommendDialogOpen, setIsRecommendDialogOpen] = useState<boolean>(false);
// 「业务链条」按钮 → 打开链条列表弹窗(弹窗里再决定进配置页 / 切到某条链条)。
const [isChainListOpen, setIsChainListOpen] = useState<boolean>(false);
// 历史下拉点击「进行中」任务 → 打开派活链路流程图弹窗(订阅该作业 SSE)。
const [chainModalJobId, setChainModalJobId] = useState<string | null>(null);
const [selectedModel, setSelectedModel] = useState<string>("");
// Step 2 编排模式:recommend = 总控自由派活;chain = 总控按业务链条顺序派活。
// 由推荐弹窗确认时的 SeatSelectionResult.mode 决定(见 handleConfirmAgents)。
@@ -321,7 +325,11 @@ export default function RoundtablePlanningPage() {
[step2, triggerToast],
);
const handleStartNewTask = useCallback(() => {
// 把页面重置回「第一步全新任务」——新建任务 + 后台挂起后都复用。
// 注意:只清前端状态,不删后端已建的草稿 / 已挂起的后台作业(它们继续在后台跑,
// 在历史记录里可见)。
const resetToStep1 = useCallback(() => {
step2.stopOrchestration();
step2.resetStep2State();
setOrchestrationMode("recommend");
setChainMeta(null);
@@ -333,8 +341,61 @@ export default function RoundtablePlanningPage() {
// 同步触发 Step 1 init,而不是依赖 useEffect —— 已经在 Step 1 时
// currentStep 不变,useEffect 不会 re-fire。
step1.runStep1Init();
}, [drafts, step1, step2]);
const handleStartNewTask = useCallback(() => {
resetToStep1();
triggerToast("✨ 已开始新任务,请在第一步与任务理解智能体澄清需求");
}, [drafts, step1, step2, triggerToast]);
}, [resetToStep1, triggerToast]);
// 「后台挂起」:把当前研讨交给后端自动跑到 Step3 结果绘制完成;模型沿用第二步选择。
// 挂起后页面像「新建任务」一样重置回第一步(后台作业继续在历史记录里跑)。
const handleBackgroundSuspend = useCallback(async () => {
const agents = step2.selectedAgents.map((a) => ({ agentId: a.agent_id, name: a.name }));
if (agents.length === 0) {
triggerToast("⚠️ 请先选择研讨席位再后台挂起");
return;
}
try {
// 确保后端存在对应草稿(作业按 draftId 关联,历史下拉据此显示「进行中」)。
const draftId = await drafts.ensureDraft(2);
// 防重复:该草稿已有在跑作业就不再新建(避免重复作业 / 前后端双跑)。
const existing = draftId ? await getJobByDraft(draftId).catch(() => null) : null;
const alreadyRunning =
existing &&
(existing.status === "running" ||
existing.status === "awaiting_input" ||
existing.status === "queued");
if (!alreadyRunning) {
await startJob({
draftId: draftId ?? undefined,
intent: buildInitialIntent(),
agents,
model: selectedModel || undefined, // 模型沿用第二步选择
orchestrationMode,
chain: chainMeta,
threadIds: step2.threadIds ?? undefined,
coordinatorName: step2.coordinatorName ?? undefined,
});
}
} catch (err) {
triggerToast(`❌ 后台挂起失败:${(err as Error).message}`);
return;
}
// 重置回第一步(同新建任务);后台作业在历史记录里继续跑、可点开看进度。
resetToStep1();
triggerToast("🚀 已转入后台,任务会自动跑到结果绘制完成;可在「历史记录」查看进度");
void drafts.refresh();
}, [
step2,
drafts,
selectedModel,
orchestrationMode,
chainMeta,
buildInitialIntent,
resetToStep1,
triggerToast,
]);
// 本地 demo handler:继续追问 / 跳过此项 —— 不发请求,只本地 push 一条气泡。
const handleSkipQuestion = useCallback(() => {
@@ -390,6 +451,26 @@ export default function RoundtablePlanningPage() {
}}
/>
{/* 派活链路流程图弹窗(订阅进行中作业的真实 SSE 进度) */}
<DispatchChainJobModal
jobId={chainModalJobId}
open={chainModalJobId !== null}
onOpenChange={(o) => {
if (!o) setChainModalJobId(null);
}}
onView={(job) => {
// 「查看」:进入该任务。done → 加载草稿(含 step3)落到 Step3 看报告;
// 进行中 / 待澄清 → 进去后置暂停态,避免前后端「双跑」同一批 thread。
setChainModalJobId(null);
if (!job.draftId) return;
if (job.status === "running" || job.status === "awaiting_input") {
step2.setIsRoundTableRunning(false);
step2.stopOrchestration();
}
drafts.handleLoadDraft(job.draftId);
}}
/>
{showToast && (
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 bg-primary text-white text-xs px-4 py-2.5 rounded-xl shadow-lg border border-primary/40 font-medium flex items-center gap-2 animate-bounce">
<Sparkles className="h-4 w-4 shrink-0" />
@@ -483,6 +564,11 @@ export default function RoundtablePlanningPage() {
renameDraftValue={drafts.renameDraftValue}
setRenameDraftValue={drafts.setRenameDraftValue}
onLoad={drafts.handleLoadDraft}
onOpen={() => void drafts.refresh()}
onOpenJob={(jobId) => {
drafts.setIsHistoryOpen(false);
setChainModalJobId(jobId);
}}
onDelete={drafts.handleDeleteDraft}
onStartRename={drafts.handleStartRename}
onCommitRename={drafts.handleCommitRename}
@@ -593,6 +679,7 @@ export default function RoundtablePlanningPage() {
"🎉 多角色智能体博弈共识达成,最终交付全套路线成果文档及方案图!",
);
}}
onBackgroundSuspend={handleBackgroundSuspend}
onTriggerToast={triggerToast}
/>
)}
@@ -616,6 +703,8 @@ export default function RoundtablePlanningPage() {
setStep3SavedTick((t) => t + 1);
}}
onReset={handleStartNewTask}
openArtifactPreview={step2.openArtifactPreview}
seatStubMessages={step2.seatStubMessages}
/>
)}
</section>
@@ -762,6 +851,10 @@ interface HistoryDropdownProps {
renameDraftValue: string;
setRenameDraftValue: React.Dispatch<React.SetStateAction<string>>;
onLoad: (id: string) => void;
/** 下拉展开时回调(刷新列表拿最新作业状态)。 */
onOpen?: () => void;
/** 点击「进行中 / 待澄清」的任务 → 打开派活链路流程图弹窗。 */
onOpenJob: (jobId: string) => void;
onDelete: (id: string) => void;
onStartRename: (d: ReturnType<typeof useDraftPersistence>["drafts"][number]) => void;
onCommitRename: (id: string) => void;
@@ -779,6 +872,8 @@ function HistoryDropdown(props: HistoryDropdownProps) {
renameDraftValue,
setRenameDraftValue,
onLoad,
onOpen,
onOpenJob,
onDelete,
onStartRename,
onCommitRename,
@@ -790,7 +885,13 @@ function HistoryDropdown(props: HistoryDropdownProps) {
<button
id="history-dropdown-btn"
type="button"
onClick={() => setIsOpen((open) => !open)}
onClick={() =>
setIsOpen((open) => {
const next = !open;
if (next) onOpen?.();
return next;
})
}
className="bg-white hover:bg-slate-50 border border-[#e2e8f0] text-[#334155] text-xs font-bold px-3 py-2 rounded-xl flex items-center gap-1.5 transition dark:border-border dark:bg-secondary dark:text-foreground dark:hover:bg-accent"
>
<History className="w-3.5 h-3.5 text-slate-500" />
@@ -815,6 +916,11 @@ function HistoryDropdown(props: HistoryDropdownProps) {
{drafts.map((d) => {
const isCurrent = d.id === currentDraftId;
const isRenaming = d.id === renamingDraftId;
// 「进行中 / 待澄清」且有 jobId → 点击打开流程图弹窗,而非加载草稿。
const isActiveJob =
(d.status === "running" || d.status === "awaiting_input") && !!d.jobId;
const openOrLoad = () =>
isActiveJob && d.jobId ? onOpenJob(d.jobId) : onLoad(d.id);
return (
<li key={d.id} className={`px-3 py-2.5 ${isCurrent ? "bg-emerald-50/40" : ""}`}>
<div className="flex items-start gap-2">
@@ -835,15 +941,30 @@ function HistoryDropdown(props: HistoryDropdownProps) {
) : (
<button
type="button"
onClick={() => onLoad(d.id)}
onClick={openOrLoad}
className="block w-full text-left"
title="点击加载"
title={isActiveJob ? "点击查看进度" : "点击加载"}
>
<span className="text-[11.5px] font-semibold text-slate-800 line-clamp-2 dark:text-foreground">
{d.name}
</span>
<span className="text-[10px] text-slate-400 mt-0.5 block font-mono">
Step {d.furthestStep} ·{" "}
<span className="text-[10px] text-slate-400 mt-0.5 flex items-center gap-1 font-mono">
{d.status === "running" ? (
<span className="inline-flex items-center gap-1 text-primary font-semibold">
<Loader2 className="w-3 h-3 animate-spin" />
进行中
</span>
) : d.status === "awaiting_input" ? (
<span className="inline-flex items-center gap-1 text-amber-500 font-semibold">
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-400 animate-pulse" />
待澄清
</span>
) : d.status === "error" ? (
<span className="text-rose-500 font-semibold">出错</span>
) : (
<span>Step {d.furthestStep}</span>
)}
<span className="text-slate-300">·</span>
{new Date(d.updatedAt).toLocaleString("zh-CN", { hour12: false })}
</span>
</button>
@@ -854,11 +975,15 @@ function HistoryDropdown(props: HistoryDropdownProps) {
<>
<button
type="button"
onClick={() => onLoad(d.id)}
onClick={openOrLoad}
className="p-1 text-slate-500 hover:text-primary hover:bg-slate-100 rounded transition"
title="加载"
title={isActiveJob ? "查看进度" : "加载"}
>
<Upload className="w-3.5 h-3.5" />
{isActiveJob ? (
<Activity className="w-3.5 h-3.5 text-primary" />
) : (
<Upload className="w-3.5 h-3.5" />
)}
</button>
<button
type="button"
@@ -46,6 +46,7 @@ from app.gateway.routers import (
recommended_questions,
roundtable_chains,
roundtable_drafts,
roundtable_jobs,
runs,
scheduled_tasks,
skills,
@@ -863,6 +864,9 @@ This gateway provides custom endpoints for models, MCP configuration, skills, an
# Roundtable-planning business chains at /api/roundtable-chains
app.include_router(roundtable_chains.router)
# Roundtable-planning background jobs at /api/roundtable-jobs
app.include_router(roundtable_jobs.router)
# Suggestions API is mounted at /api/threads/{thread_id}/suggestions
app.include_router(suggestions.router)
@@ -8,6 +8,7 @@ Initialization is handled directly in ``app.py`` via :class:`AsyncExitStack`.
from __future__ import annotations
import logging
from collections.abc import AsyncGenerator, Callable
from contextlib import AsyncExitStack, asynccontextmanager
from typing import TYPE_CHECKING, TypeVar, cast
@@ -21,6 +22,8 @@ from deerflow.runtime import RunContext, RunManager, StreamBridge
from deerflow.runtime.events.store.base import RunEventStore
from deerflow.runtime.runs.store.base import RunStore
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from app.gateway.auth.local_provider import LocalAuthProvider
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
@@ -178,6 +181,42 @@ async def langgraph_runtime(app: FastAPI) -> AsyncGenerator[None, None]:
# (Phase 2 后台编排图写进度用)。
_register_roundtable_job_default_store(app.state.roundtable_job_store)
# 圆桌后台作业执行器:把编排引擎作为后台任务跑。
# 网关由环境变量 ``ROUNDTABLE_JOB_GATEWAY`` 选择:
# - "inprocess"(Phase 3b):进程内驱动真模型(leader/seat/report);
# - 其它/缺省(Phase 3a):模拟网关,产生真实进度但不调真模型,供前端联调。
# 默认 simulated —— 真网关需在能起完整后端栈 + 真模型的环境验证后再开。
if app.state.roundtable_job_store is not None:
import os
from app.gateway.roundtable_job_executor import RoundtableJobExecutor
gateway_mode = (os.getenv("ROUNDTABLE_JOB_GATEWAY") or "simulated").strip().lower()
if gateway_mode == "inprocess":
from app.gateway.roundtable_inprocess_gateway import InProcessRoundtableGateway
_app = app # 闭包捕获 app,供网关访问 app.state(checkpointer/thread_store)
gateway_factory = lambda p: InProcessRoundtableGateway( # noqa: E731
_app, user_id=p.user_id, agents=p.agents, model=p.model
)
logger.info("[roundtable-jobs] gateway = inprocess (real models)")
else:
from deerflow.agents.roundtable_orchestrator import SimulatedRoundtableGateway
# delay=3s/步:让模拟后台任务有可观察的进行中时长(切页/重连仍在跑)。
gateway_factory = lambda p: SimulatedRoundtableGateway( # noqa: E731
p.agents, mode=p.mode, delay=3.0
)
logger.info("[roundtable-jobs] gateway = simulated")
app.state.roundtable_job_executor = RoundtableJobExecutor(
app.state.roundtable_job_store,
gateway_factory=gateway_factory,
draft_store=getattr(app.state, "roundtable_draft_store", None),
)
else:
app.state.roundtable_job_executor = None
# Run event store (has its own factory with config-driven backend selection)
run_events_config = getattr(config, "run_events", None)
app.state.run_event_store = make_run_event_store(run_events_config)
@@ -0,0 +1,272 @@
"""圆桌真网关(Phase 3b)—— 进程内驱动真实 leader / seat / report agent run。
实现 ``RoundtableGateway`` 协议(定义在 harness 的 roundtable_orchestrator.types),
但**进程内**跑真模型:复用 ``services.start_run``(scheduled task 同款)驱动一轮 agent,
跑完从 checkpointer 读最终 messages,用 ``multi_agent`` 的纯解析函数解析派活 / 澄清。
不走 HTTP、不需要 Request(用 ``SimpleNamespace(app=app)`` 当虚拟 request,
``set_current_user`` 由执行器设好)。
本网关位于 app 层(依赖 app.state + multi_agent helpers),实现 harness 定义的协议——
不违反 harness→app 边界(harness 永不 import app;app import harness 合法)。
⚠️ 需在能起完整后端栈 + 真模型的环境验证(见 scripts/probe_roundtable_job.py)。
"""
from __future__ import annotations
import logging
import uuid
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
from langgraph.checkpoint.base import empty_checkpoint
from deerflow.agents.roundtable_orchestrator.types import (
LeaderTurn,
ReportResult,
SeatRef,
SeatTurn,
)
from deerflow.config.paths import get_paths
from deerflow.runtime.serialization import serialize_channel_values
from app.gateway.routers._roundtable_seed import (
COORDINATOR_AGENT_ID,
REPORT_AGENT_ID,
ensure_roundtable_functional_agents,
)
logger = logging.getLogger(__name__)
_ROUNDTABLE_THREAD_METADATA: dict[str, Any] = {"thread_type": "roundtable", "system": True}
_REPORT_FILE = "方案总览.html"
# 各角色的工具裁剪(镜像 multi_agent 的 _leader_run / _special_run)。
_LEADER_EXCLUDED = ["web_search"]
_LEADER_SKILL_STOP = ["agent_orchestration"]
_SEAT_EXCLUDED = ["ask_clarification", "present_files", "view_image", "agent_orchestration"]
_REPORT_EXCLUDED = [*_SEAT_EXCLUDED, "bash", "str_replace"]
# ── 进程内底层操作 ───────────────────────────────────────────────────────────
async def _create_thread(app) -> str:
"""同进程建一个带圆桌 metadata 的 thread + 空 checkpoint(镜像 _create_thread_direct)。"""
checkpointer = app.state.checkpointer
thread_store = app.state.thread_store
thread_id = str(uuid.uuid4())
await thread_store.create(thread_id, assistant_id=None, metadata=dict(_ROUNDTABLE_THREAD_METADATA))
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
await checkpointer.aput(
config,
empty_checkpoint(),
{"step": -1, "source": "input", "writes": None, "parents": {}, "created_at": datetime.now(UTC).isoformat()},
{},
)
return thread_id
async def _read_thread_messages(app, thread_id: str) -> list[dict[str, Any]]:
"""读 thread 最新 checkpoint 的 messages(已 serialize 成 dict 列表)。"""
checkpointer = app.state.checkpointer
config = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
ct = await checkpointer.aget_tuple(config)
if ct is None:
return []
checkpoint = getattr(ct, "checkpoint", {}) or {}
values = serialize_channel_values(checkpoint.get("channel_values", {}) or {})
return values.get("messages") or []
def _latest_ai_message(messages: list[dict[str, Any]]) -> dict[str, Any] | None:
for m in reversed(messages):
if not isinstance(m, dict):
continue
if (m.get("type") or m.get("role")) in {"ai", "assistant"}:
return m
return None
def _message_text(message: dict[str, Any] | None) -> str:
if not message:
return ""
content = message.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
parts = [p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text"]
return "\n".join(parts)
return ""
async def _run_agent_turn(
app,
*,
thread_id: str,
agent_name: str,
message: str,
model: str | None,
excluded_tools: list[str],
skill_stop_names: list[str],
thinking_enabled: bool,
reasoning_effort: str,
) -> list[dict[str, Any]]:
"""进程内驱动 agent_name 在 thread_id 上跑一轮(发 message),阻塞到完成,返回最终 messages。
镜像 multi_agent ``_run_payload`` 的 body 形状:agent 通过 ``context.agent_name`` 选,
assistant_id 固定 lead_agent;关闭 memory recall / subagent / plan mode(临时研讨任务)。
"""
from app.gateway.routers.thread_runs import RunCreateRequest
from app.gateway.services import start_run
body = RunCreateRequest(
assistant_id="lead_agent",
input={"messages": [{"type": "human", "content": [{"type": "text", "text": message}], "additional_kwargs": {}}]},
config={"recursion_limit": 1000},
context={
"agent_name": agent_name,
"model_name": model or "",
"mode": "pro",
"reasoning_effort": reasoning_effort,
"thinking_enabled": thinking_enabled,
"is_plan_mode": False,
"subagent_enabled": False,
"thread_id": thread_id,
"memory_recall_disabled": True,
},
excluded_tools=excluded_tools,
skill_stop_names=skill_stop_names,
on_disconnect="continue",
multitask_strategy="reject",
)
request = SimpleNamespace(app=app)
record = await start_run(body, thread_id, request) # type: ignore[arg-type]
if getattr(record, "task", None) is not None:
await record.task # 阻塞到这轮 agent 跑完,checkpoint 已落盘
return await _read_thread_messages(app, thread_id)
def _read_report_html(app, thread_id: str, user_id: str) -> str | None:
"""从 report thread 的 outputs/方案总览.html 读出报告 HTML。"""
try:
outputs_dir = get_paths().sandbox_outputs_dir(thread_id, user_id=user_id)
path = outputs_dir / _REPORT_FILE
if path.exists():
return path.read_text(encoding="utf-8")
except Exception: # noqa: BLE001
logger.warning("read report html failed for thread %s", thread_id, exc_info=True)
return None
def _build_seat_intro(agents: list[SeatRef]) -> str:
"""镜像 init 注入 coordinator 的「可调度席位清单」。"""
lines = [f"- agent_name: {a.agent_id}({a.name})" for a in agents]
return (
"【圆桌系统初始化】本次研讨可调度的席位清单如下,请严格按 agent_name 通过 "
"agent_orchestration 技能派活,不要派给不在清单内的 agent:\n" + "\n".join(lines)
)
# ── 网关实现 ─────────────────────────────────────────────────────────────────
class InProcessRoundtableGateway:
"""进程内真网关。executor 先调 ``init_threads`` 建好线程,再跑编排引擎。"""
def __init__(self, app, *, user_id: str, agents: list[SeatRef], model: str | None = None) -> None:
self._app = app
self._user_id = user_id
self._agents = list(agents)
self._model = model
self._seat_intro = ""
self._first_leader = True
async def init_threads(self) -> tuple[dict[str, str], str]:
"""建 coordinator + 各席位 thread,返回 (thread_ids, coordinator_name)。
席位清单通过「首轮 leader 消息前缀」注入(coordinator thread 跨轮累积历史,
首轮带上即长期可见),无需单独写 checkpoint。
"""
ensure_roundtable_functional_agents()
thread_ids: dict[str, str] = {}
for a in self._agents:
thread_ids[a.agent_id] = await _create_thread(self._app)
thread_ids[COORDINATOR_AGENT_ID] = await _create_thread(self._app)
self._seat_intro = _build_seat_intro(self._agents)
return thread_ids, COORDINATOR_AGENT_ID
async def run_leader(self, *, thread_ids, coordinator_name, message, model) -> LeaderTurn:
msg = message
if self._first_leader and self._seat_intro:
msg = self._seat_intro + "\n\n" + message
self._first_leader = False
messages = await _run_agent_turn(
self._app,
thread_id=thread_ids[coordinator_name],
agent_name=coordinator_name,
message=msg,
model=model or self._model,
excluded_tools=_LEADER_EXCLUDED,
skill_stop_names=_LEADER_SKILL_STOP,
thinking_enabled=False,
reasoning_effort="low",
)
ai = _latest_ai_message(messages)
final_text = _message_text(ai)
tool_calls = (ai.get("tool_calls") if ai else None) or []
# 澄清优先
clar = next((tc for tc in tool_calls if (tc.get("name") or "").lower() == "ask_clarification"), None)
if clar is not None:
cargs = clar.get("args") or {}
question = str(cargs.get("question") or "").strip() or "总控需要补充信息,请补充后继续。"
return LeaderTurn(final_text=final_text, clarification=question)
# 派活解析(复用 multi_agent 纯函数)
from app.gateway.routers.multi_agent import _extract_dispatch
dispatched: list[tuple[str, str]] = []
for tc in tool_calls:
try:
name, task = _extract_dispatch(tc)
if name and task:
dispatched.append((name, task))
except Exception: # noqa: BLE001
logger.warning("dispatch parse failed for tool_call", exc_info=True)
return LeaderTurn(final_text=final_text, dispatched=dispatched)
async def run_seat(self, *, thread_ids, agent_id, task, model) -> SeatTurn:
messages = await _run_agent_turn(
self._app,
thread_id=thread_ids[agent_id],
agent_name=agent_id,
message=task,
model=model or self._model,
excluded_tools=_SEAT_EXCLUDED,
skill_stop_names=[],
thinking_enabled=True,
reasoning_effort="medium",
)
return SeatTurn(final_text=_message_text(_latest_ai_message(messages)))
async def create_report_thread(self) -> str:
ensure_roundtable_functional_agents()
return await _create_thread(self._app)
async def run_report(self, *, report_thread_id, prompt, model) -> ReportResult:
messages = await _run_agent_turn(
self._app,
thread_id=report_thread_id,
agent_name=REPORT_AGENT_ID,
message=prompt,
model=model or self._model,
excluded_tools=_REPORT_EXCLUDED,
skill_stop_names=[],
thinking_enabled=True,
reasoning_effort="medium",
)
summary = _message_text(_latest_ai_message(messages))
html = _read_report_html(self._app, report_thread_id, self._user_id) or summary
return ReportResult(html=html, summary=summary, model=model or self._model)
@@ -0,0 +1,152 @@
"""圆桌后台作业执行器(Phase 3a)。
把 Phase 2 的编排引擎(``run_orchestration``)作为**后台 asyncio 任务**跑起来,
survive 发起请求的生命周期。每个任务:
1. ``set_current_user``:用作业的 user_id 设进程内用户上下文,让引擎里的进度落库
(``JobProgressWriter`` → ``RoundtableJobRepository.update_progress(user_id=AUTO)``)
正确归属(与 scheduled task 后台执行同一套做法);
2. 用 ``gateway_factory`` 构造一个 ``RoundtableGateway``(Phase 3a 默认模拟网关,
Phase 3b 换成进程内真网关),跑完整编排(循环 → 共识 → 报告)。
任务句柄登记在 ``_tasks`` 里,供 ``cancel`` 用;完成后自动摘除。
"""
from __future__ import annotations
import asyncio
import logging
from dataclasses import dataclass, field
from typing import Callable
from deerflow.agents.roundtable_orchestrator import (
JobProgressWriter,
SeatRef,
run_orchestration,
)
from deerflow.runtime.user_context import reset_current_user, set_current_user
logger = logging.getLogger(__name__)
class _JobUser:
"""最小 CurrentUser:满足 user_context 的 ``.id`` 结构协议。"""
def __init__(self, user_id: str) -> None:
self.id = user_id
@dataclass
class StartParams:
job_id: str
user_id: str
agents: list[SeatRef]
seed_message: str
model: str | None = None
mode: str = "recommend"
intent_text: str = ""
thread_ids: dict[str, str] | None = None
coordinator_name: str = "roundtable-coordinator"
# 关联草稿:作业完成后把 step3 报告写回该草稿,使「查看报告」可用。
draft_id: str | None = None
# 网关工厂:给定一次启动参数,返回一个 RoundtableGateway 实例。
GatewayFactory = Callable[[StartParams], object]
class RoundtableJobExecutor:
def __init__(self, store, *, gateway_factory: GatewayFactory, draft_store=None) -> None:
self._store = store
self._gateway_factory = gateway_factory
# 草稿仓储(可选):作业完成后把 step3 写回草稿,供前端「查看报告」。
self._draft_store = draft_store
self._tasks: dict[str, asyncio.Task] = {}
def start_job(self, params: StartParams) -> bool:
"""启动一个后台编排任务。同一 job 已在跑则忽略(幂等)。返回是否新启动。"""
if params.job_id in self._tasks and not self._tasks[params.job_id].done():
return False
task = asyncio.create_task(self._run(params))
self._tasks[params.job_id] = task
task.add_done_callback(lambda t, jid=params.job_id: self._tasks.pop(jid, None))
return True
async def _run(self, params: StartParams) -> None:
token = set_current_user(_JobUser(params.user_id))
try:
gateway = self._gateway_factory(params)
thread_ids = params.thread_ids or {}
coordinator_name = params.coordinator_name
# 真网关需要先进程内建好 leader/seat 线程;模拟网关无 init_threads。
# 仅首启建线程(resume 时 params.thread_ids 已带,复用、不重建)。
init = getattr(gateway, "init_threads", None)
if init is not None and not thread_ids:
thread_ids, coordinator_name = await init()
try:
await self._store.update_progress(
params.job_id, thread_ids=thread_ids, coordinator_name=coordinator_name
)
except Exception: # noqa: BLE001
logger.warning("persist thread_ids for job %s failed", params.job_id, exc_info=True)
progress = JobProgressWriter(self._store, job_id=params.job_id, agents=params.agents)
result = await run_orchestration(
gateway=gateway,
progress=progress,
agents=params.agents,
thread_ids=thread_ids,
coordinator_name=coordinator_name,
model=params.model,
seed_message=params.seed_message,
mode=params.mode,
intent_text=params.intent_text,
)
# 完成且有报告:写回关联草稿的 step3 + 推进 furthest_step=3,
# 这样用户从历史「查看报告」加载草稿即可看到最终方案(沿用既有 hydrateStep3 路径)。
if (
result.status == "done"
and result.step3
and params.draft_id
and self._draft_store is not None
):
try:
await self._draft_store.update_draft(
params.draft_id,
params.user_id,
step3=result.step3,
furthest_step=3,
)
except Exception: # noqa: BLE001 — 写报告失败不影响作业本身已 done
logger.warning("persist step3 to draft %s failed", params.draft_id, exc_info=True)
except asyncio.CancelledError:
# 干净停止即可,**不在被取消的任务里写 DB**:取消态下二次 await 会再抛
# CancelledError、并可能打断进行中的 sqlite 连接。``cancelled`` 终态由
# 路由的 cancel 端点(executor.cancel + store.update_progress)权威写入。
logger.info("roundtable job %s cancelled", params.job_id)
raise
except Exception: # noqa: BLE001 — 顶层兜底:run_orchestration 内部已尽量标 error
logger.exception("roundtable job %s crashed", params.job_id)
finally:
reset_current_user(token)
def cancel(self, job_id: str) -> bool:
"""请求取消一个在跑的作业。返回是否命中在跑任务。"""
task = self._tasks.get(job_id)
if task is None or task.done():
return False
task.cancel()
return True
def is_running(self, job_id: str) -> bool:
task = self._tasks.get(job_id)
return task is not None and not task.done()
async def await_job(self, job_id: str) -> None:
"""等待某作业任务结束(测试用;生产不调用)。"""
task = self._tasks.get(job_id)
if task is not None:
try:
await task
except (Exception, asyncio.CancelledError): # noqa: BLE001 — 取消是 BaseException
pass
@@ -0,0 +1,341 @@
"""圆桌后台作业控制 API(Phase 3a)。
把 Step 2「后台挂起」的任务交给后端编排引擎跑,并暴露进度订阅 / 续跑 / 取消。
Routes(prefix ``/api/roundtable-jobs``)::
POST "" 启动一个后台作业(创建 job 行 + 拉起执行器任务)
GET ""?draft_id= 查某草稿最近一个作业
GET "/{job_id}" 作业快照
GET "/{job_id}/stream" SSE 进度流(轮询 job 表,多 worker 安全)
POST "/{job_id}/resume" 回答澄清后续跑
POST "/{job_id}/cancel" 取消
进度数据源是 ``roundtable_jobs`` 表(Phase 1),SSE 走 DB 轮询而非内存 pub/sub —— 这样
刷新 / 换 worker / 重连都能继续收到进度,规避 ai_writing 踩过的「per-worker 内存 dict
丢会话」坑。响应统一 camelCase(对齐前端 §2.5)。
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from datetime import UTC, datetime, timedelta
from typing import Any
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from deerflow.agents.roundtable_orchestrator import SeatRef
from deerflow.runtime.user_context import get_effective_user_id
from app.gateway.roundtable_job_executor import StartParams
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/roundtable-jobs", tags=["roundtable-jobs"])
_TERMINAL = {"done", "error", "cancelled"}
# SSE 轮询间隔与最长存活(对齐 ai_writing:长连接最多挂 30 分钟)。
_POLL_INTERVAL_S = 0.7
_MAX_STREAM_TICKS = int(30 * 60 / _POLL_INTERVAL_S)
# 续跑澄清时拼给 leader 的 prompt 前缀。
_RESUME_PREFIX = "用户已回复你刚才的澄清问题,回答如下,请据此继续派活或给出结论:\n"
# ── schemas ──────────────────────────────────────────────────────────────────
class JobAgent(BaseModel):
agentId: str
name: str = ""
class StartJobRequest(BaseModel):
draftId: str | None = Field(default=None, max_length=64)
intent: str = ""
agents: list[JobAgent] = Field(default_factory=list)
model: str | None = None
orchestrationMode: str = "recommend"
chain: dict[str, Any] | None = None
# 第一轮 leader prompt;缺省用 intent。
seedMessage: str | None = None
# 预建好的 thread 映射(Phase 3b 真网关用;模拟网关忽略)。
threadIds: dict[str, str] | None = None
coordinatorName: str = "roundtable-coordinator"
class ResumeJobRequest(BaseModel):
answer: str = ""
class CleanupRequest(BaseModel):
retention_days: int = Field(default=7, ge=0, description="保留多少天内的作业;0 = 不保留")
only_finished: bool = Field(default=True, description="仅清终态(done/error/cancelled),保护在跑作业")
batch_size: int = Field(default=500, ge=1, le=5000)
class CleanupResponse(BaseModel):
deleted: int
elapsed_seconds: float
params: dict[str, Any]
class RoundtableJobResponse(BaseModel):
id: str
draftId: str | None = None
status: str = "queued"
phase: str = "initializing"
cycle: int = 0
dispatchChain: list[dict[str, Any]] = Field(default_factory=list)
activeAgentId: str | None = None
consensusPercentage: int = 0
pendingClarification: str | None = None
orchestrationMode: str = "recommend"
chain: dict[str, Any] | None = None
error: str | None = None
createdAt: datetime | str | None = None
updatedAt: datetime | str | None = None
# ── helpers ──────────────────────────────────────────────────────────────────
def _current_user_id(request: Request) -> str:
user = getattr(request.state, "user", None)
if user is not None:
return str(user.id)
return get_effective_user_id()
def _get_store(request: Request):
store = getattr(request.app.state, "roundtable_job_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Roundtable job store not available")
return store
def _get_executor(request: Request):
executor = getattr(request.app.state, "roundtable_job_executor", None)
if executor is None:
raise HTTPException(status_code=503, detail="Roundtable job executor not available")
return executor
def _pending_nodes(agents: list[JobAgent]) -> list[dict[str, Any]]:
"""启动即落一份「全 pending」的派活链路,使 GET / 首帧 SSE 立刻能渲染流程图。"""
return [
{"agentId": a.agentId, "name": a.name or a.agentId, "order": i + 1, "state": "pending"}
for i, a in enumerate(agents)
]
def _to_response(row: dict[str, Any]) -> RoundtableJobResponse:
return RoundtableJobResponse(
id=row["id"],
draftId=row.get("draft_id"),
status=row.get("status") or "queued",
phase=row.get("phase") or "initializing",
cycle=int(row.get("cycle") or 0),
dispatchChain=row.get("dispatch_chain") or [],
activeAgentId=row.get("active_agent_id"),
consensusPercentage=int(row.get("consensus_percentage") or 0),
pendingClarification=row.get("pending_clarification"),
orchestrationMode=row.get("orchestration_mode") or "recommend",
chain=row.get("chain"),
error=row.get("error"),
createdAt=row.get("created_at"),
updatedAt=row.get("updated_at"),
)
def _seat_refs_from_nodes(nodes: list[dict[str, Any]] | None) -> list[SeatRef]:
out: list[SeatRef] = []
for n in nodes or []:
aid = str(n.get("agentId") or "")
if aid:
out.append(SeatRef(aid, str(n.get("name") or aid)))
return out
# ── routes ───────────────────────────────────────────────────────────────────
@router.post("", response_model=RoundtableJobResponse, status_code=201)
async def start_job(request: Request, body: StartJobRequest) -> RoundtableJobResponse:
store = _get_store(request)
executor = _get_executor(request)
user_id = _current_user_id(request)
if not body.agents:
raise HTTPException(status_code=400, detail="agents is required")
agents = [SeatRef(a.agentId, a.name or a.agentId) for a in body.agents]
job_id = uuid4().hex
row = await store.create(
id=job_id,
draft_id=body.draftId,
user_id=user_id,
orchestration_mode=body.orchestrationMode,
coordinator_name=body.coordinatorName,
thread_ids=body.threadIds,
chain=body.chain,
dispatch_chain=_pending_nodes(body.agents),
status="queued",
)
executor.start_job(
StartParams(
job_id=job_id,
user_id=user_id,
agents=agents,
seed_message=(body.seedMessage or body.intent or "").strip(),
model=body.model,
mode=body.orchestrationMode,
intent_text=body.intent,
thread_ids=body.threadIds,
coordinator_name=body.coordinatorName,
draft_id=body.draftId,
)
)
return _to_response(row)
@router.get("", response_model=RoundtableJobResponse | None)
async def get_job_by_draft(request: Request, draft_id: str) -> RoundtableJobResponse | None:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.get_by_draft(draft_id, user_id=user_id)
return _to_response(row) if row is not None else None
@router.get("/{job_id}", response_model=RoundtableJobResponse)
async def get_job(request: Request, job_id: str) -> RoundtableJobResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.get(job_id, user_id=user_id)
if row is None:
raise HTTPException(status_code=404, detail="Job not found")
return _to_response(row)
@router.get("/{job_id}/stream")
async def stream_job(request: Request, job_id: str) -> StreamingResponse:
store = _get_store(request)
user_id = _current_user_id(request)
def _frame(payload: dict[str, Any]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
async def gen():
for _ in range(_MAX_STREAM_TICKS):
if await request.is_disconnected():
return
row = await store.get(job_id, user_id=user_id)
if row is None:
yield _frame({"event": "error", "detail": "Job not found"})
return
yield _frame({"event": "progress", **_to_response(row).model_dump(mode="json")})
if (row.get("status") or "") in _TERMINAL:
return
await asyncio.sleep(_POLL_INTERVAL_S)
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no", "Connection": "keep-alive"},
)
@router.post("/{job_id}/resume", response_model=RoundtableJobResponse)
async def resume_job(request: Request, job_id: str, body: ResumeJobRequest) -> RoundtableJobResponse:
store = _get_store(request)
executor = _get_executor(request)
user_id = _current_user_id(request)
row = await store.get(job_id, user_id=user_id)
if row is None:
raise HTTPException(status_code=404, detail="Job not found")
if (row.get("status") or "") != "awaiting_input":
raise HTTPException(status_code=409, detail="Job is not awaiting input")
answer = body.answer.strip()
if not answer:
raise HTTPException(status_code=400, detail="answer is required")
updated = await store.update_progress(
job_id, user_id=user_id, status="running", pending_clarification=None
)
executor.start_job(
StartParams(
job_id=job_id,
user_id=user_id,
agents=_seat_refs_from_nodes(row.get("dispatch_chain")),
seed_message=_RESUME_PREFIX + answer,
model=None,
mode=row.get("orchestration_mode") or "recommend",
intent_text=answer,
thread_ids=row.get("thread_ids"),
coordinator_name=row.get("coordinator_name") or "roundtable-coordinator",
draft_id=row.get("draft_id"),
)
)
return _to_response(updated or row)
async def _require_admin(request: Request) -> None:
"""放行 admin / auth-disabled,其余 403(与 ai_writing / scheduled_tasks 同套语义)。"""
from app.gateway.deps import get_optional_user_from_request
user = await get_optional_user_from_request(request)
if user is None:
return
if getattr(user, "system_role", None) != "admin":
raise HTTPException(status_code=403, detail="圆桌作业清理管理接口仅限管理员")
@router.post("/admin/cleanup", response_model=CleanupResponse)
async def admin_cleanup(request: Request, body: CleanupRequest) -> CleanupResponse:
"""清理过期作业行(活跃作业受 only_finished 保护,永不误删)。
分批 ``list_older_than`` → ``delete_by_ids``,避免大事务。``retention_days=0``
一刷清空所有终态作业(调试用)。
"""
await _require_admin(request)
store = _get_store(request)
cutoff = datetime.now(UTC) - timedelta(days=body.retention_days)
t0 = time.perf_counter()
deleted = 0
while True:
ids = await store.list_older_than(cutoff, only_finished=body.only_finished, limit=body.batch_size)
if not ids:
break
deleted += await store.delete_by_ids(ids)
if len(ids) < body.batch_size:
break
return CleanupResponse(
deleted=deleted,
elapsed_seconds=round(time.perf_counter() - t0, 3),
params={"retention_days": body.retention_days, "only_finished": body.only_finished, "batch_size": body.batch_size},
)
@router.post("/{job_id}/cancel", response_model=RoundtableJobResponse)
async def cancel_job(request: Request, job_id: str) -> RoundtableJobResponse:
store = _get_store(request)
executor = _get_executor(request)
user_id = _current_user_id(request)
row = await store.get(job_id, user_id=user_id)
if row is None:
raise HTTPException(status_code=404, detail="Job not found")
executor.cancel(job_id)
updated = await store.update_progress(job_id, user_id=user_id, status="cancelled")
return _to_response(updated or row)
@@ -0,0 +1,48 @@
"""圆桌总控编排引擎(Phase 2)。
把前端 useStep2Orchestration 的编排循环移植到后端,作为可后台跑、可单测的纯引擎。
Phase 3 会提供具体的 ``RoundtableGateway`` 实现(复用 multi_agent.py 内部函数)+
执行器 / LangGraph 注册,把引擎接到真实的 LLM / 线程上。
公开入口:
- ``run_orchestration`` —— 跑一次完整编排(循环 → 共识 → 报告绘制)。
- ``JobProgressWriter`` —— 进度落库 + 可选 SSE 外发。
- 协议 / 数据类(``RoundtableGateway`` / ``LeaderTurn`` / ``SeatTurn`` /
``ReportResult`` / ``SeatRef``)。
"""
from __future__ import annotations
from .engine import OrchestrationResult, build_report_prompt, run_orchestration
from .progress import JobProgressWriter
from .simulated_gateway import SimulatedRoundtableGateway
from .types import (
CHAIN_CLOSEOUT_PROMPT,
CONTINUE_PROMPT,
MAX_CYCLES,
LeaderTurn,
ReportResult,
RoundtableGateway,
SeatRef,
SeatTurn,
build_chain_leader_task,
pick_chain_task,
)
__all__ = [
"run_orchestration",
"OrchestrationResult",
"build_report_prompt",
"JobProgressWriter",
"SimulatedRoundtableGateway",
"RoundtableGateway",
"LeaderTurn",
"SeatTurn",
"ReportResult",
"SeatRef",
"MAX_CYCLES",
"CONTINUE_PROMPT",
"CHAIN_CLOSEOUT_PROMPT",
"build_chain_leader_task",
"pick_chain_task",
]
@@ -0,0 +1,299 @@
"""圆桌总控编排引擎 —— 后台跑的编排循环(移植自前端 useStep2Orchestration.ts)。
纯逻辑:通过 ``RoundtableGateway`` 协议驱动「单轮 leader / seat / report」,通过
``JobProgressWriter`` 落库进度。两种模式:
- recommend(``runOrchestration``):总控自由派活循环,leader → 顺序派 special →
leader …,直到 ``dispatched==[]``(共识)或达到 ``MAX_CYCLES``;
- chain(``runChainOrchestration``):总控**按席位顺序**逐位派活,走完整链后由
leader 收口产出最终结论。
共识达成(或 max cycles 强制收口)后进入 Step3 报告绘制,写出 ``方案总览.html``,
返回 ``DraftStep3Snapshot`` 形状的 step3 快照。命中澄清则返回 ``awaiting_input``
(thread 已 checkpoint,resume 时由调用方用用户答案再发起一轮即可续跑)。
**不并行 special**(与前端同一不变量):顺序 await 每个席位,避免 token 级抖动 +
保证派活链路游标严格推进。
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import UTC, datetime
from .progress import JobProgressWriter
from .types import (
CHAIN_CLOSEOUT_PROMPT,
CONTINUE_PROMPT,
MAX_CYCLES,
ReportResult,
RoundtableGateway,
SeatRef,
build_chain_leader_task,
pick_chain_task,
)
logger = logging.getLogger(__name__)
@dataclass
class OrchestrationResult:
"""引擎一次运行的终态。"""
status: str # "done" | "awaiting_input" | "error"
consensus: bool = False
step3: dict | None = None
pending_clarification: str | None = None
error: str | None = None
last_leader_content: str = ""
@dataclass
class _LoopOutcome:
"""内部:编排循环(未进报告阶段)的结果。"""
kind: str # "consensus" | "clarification" | "max_cycles"
question: str | None = None
last_leader_content: str = ""
# {agent_id: 最近一次交付正文}
deliveries: dict[str, str] = field(default_factory=dict)
async def run_orchestration(
*,
gateway: RoundtableGateway,
progress: JobProgressWriter,
agents: list[SeatRef],
thread_ids: dict[str, str],
coordinator_name: str,
model: str | None,
seed_message: str,
mode: str = "recommend",
intent_text: str = "",
) -> OrchestrationResult:
"""运行一次完整编排(循环 → 共识 → 报告绘制)。
Args:
gateway: 单轮执行抽象出口。
progress: 进度写入器(落库 + 可选 SSE 外发)。
agents: 有序席位(recommend=选择顺序 / chain=链条顺序)。
thread_ids: {agent_id / coordinator_name: thread_id},预先建好。
coordinator_name: 总控 agent_id(如 "roundtable-coordinator")。
model: 选定模型(None/"" 走后端默认)。
seed_message: 第一轮 leader 的 prompt(启动=初始意图;resume=用户答案续轮)。
mode: "recommend" | "chain"。
intent_text: 任务意图,用于报告 prompt(缺省退回 seed_message)。
"""
try:
await progress.begin()
if mode == "chain":
outcome = await _run_chain(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message)
else:
outcome = await _run_recommend(gateway, progress, agents, thread_ids, coordinator_name, model, seed_message)
if outcome.kind == "clarification":
question = outcome.question or "总控需要补充信息,请补充后继续。"
await progress.awaiting_input(question)
return OrchestrationResult(
status="awaiting_input",
pending_clarification=question,
last_leader_content=outcome.last_leader_content,
)
# consensus 或 max_cycles:均收口 → 报告绘制
await progress.consensus(100)
step3 = await _run_report(
gateway,
progress,
agents=agents,
model=model,
intent_text=intent_text or seed_message,
consensus_text=outcome.last_leader_content,
deliveries=outcome.deliveries,
)
await progress.done()
return OrchestrationResult(
status="done",
consensus=True,
step3=step3,
last_leader_content=outcome.last_leader_content,
)
except Exception as exc: # noqa: BLE001 — 顶层兜底:标记 error 并返回
logger.exception("roundtable orchestration failed")
await progress.error(str(exc))
return OrchestrationResult(status="error", error=str(exc))
# ── recommend 模式 ───────────────────────────────────────────────────────────
async def _run_recommend(
gateway: RoundtableGateway,
progress: JobProgressWriter,
agents: list[SeatRef],
thread_ids: dict[str, str],
coordinator_name: str,
model: str | None,
seed_message: str,
) -> _LoopOutcome:
deliveries: dict[str, str] = {}
last_leader = ""
message = seed_message
for cycle in range(1, MAX_CYCLES + 1):
await progress.leader_thinking(cycle)
turn = await gateway.run_leader(
thread_ids=thread_ids,
coordinator_name=coordinator_name,
message=message,
model=model,
)
if turn.final_text:
last_leader = turn.final_text
if turn.is_clarification:
return _LoopOutcome("clarification", question=turn.clarification, last_leader_content=last_leader, deliveries=deliveries)
if turn.is_consensus:
return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries)
# **顺序**派活 —— 不要并行
await progress.dispatching(cycle)
for agent_id, task in turn.dispatched:
await progress.seat_active(agent_id)
seat = await gateway.run_seat(thread_ids=thread_ids, agent_id=agent_id, task=task, model=model)
deliveries[agent_id] = seat.final_text
await progress.seat_done(agent_id, cycle)
await progress.bump_consensus()
message = CONTINUE_PROMPT
# 达到最大轮次:强制收口(对齐前端 toast「已达到最大研讨轮次」)
logger.info("roundtable recommend loop hit MAX_CYCLES=%d, forcing consensus", MAX_CYCLES)
return _LoopOutcome("max_cycles", last_leader_content=last_leader, deliveries=deliveries)
# ── chain 模式 ───────────────────────────────────────────────────────────────
async def _run_chain(
gateway: RoundtableGateway,
progress: JobProgressWriter,
agents: list[SeatRef],
thread_ids: dict[str, str],
coordinator_name: str,
model: str | None,
seed_message: str,
) -> _LoopOutcome:
deliveries: dict[str, str] = {}
last_leader = ""
total = len(agents)
for i, seat in enumerate(agents):
# (a) 总控按链条顺序只给当前席位派活
await progress.leader_thinking(i + 1)
leader_task = build_chain_leader_task(
seed_message=seed_message,
position=i,
total=total,
seat_agent_id=seat.agent_id,
seat_name=seat.name,
is_first=(i == 0),
)
turn = await gateway.run_leader(
thread_ids=thread_ids,
coordinator_name=coordinator_name,
message=leader_task,
model=model,
)
if turn.final_text:
last_leader = turn.final_text
if turn.is_clarification:
return _LoopOutcome("clarification", question=turn.clarification, last_leader_content=last_leader, deliveries=deliveries)
task = pick_chain_task(turn.dispatched, turn.final_text, seat.agent_id, seat.name)
# (b) 当前席位交付(顺序,不并行)
await progress.seat_active(seat.agent_id)
seat_turn = await gateway.run_seat(thread_ids=thread_ids, agent_id=seat.agent_id, task=task, model=model)
deliveries[seat.agent_id] = seat_turn.final_text
await progress.seat_done(seat.agent_id, i + 1)
if total > 0:
await progress.set_consensus_pct(round((i + 1) / total * 90))
# (c) 总控收口:综合全链交付,产出最终结论
if total > 0:
await progress.leader_thinking(total + 1)
closeout = await gateway.run_leader(
thread_ids=thread_ids,
coordinator_name=coordinator_name,
message=CHAIN_CLOSEOUT_PROMPT,
model=model,
)
if closeout.final_text:
last_leader = closeout.final_text
return _LoopOutcome("consensus", last_leader_content=last_leader, deliveries=deliveries)
# ── 报告阶段 ─────────────────────────────────────────────────────────────────
async def _run_report(
gateway: RoundtableGateway,
progress: JobProgressWriter,
*,
agents: list[SeatRef],
model: str | None,
intent_text: str,
consensus_text: str,
deliveries: dict[str, str],
) -> dict:
await progress.report_drawing()
report_thread_id = await gateway.create_report_thread()
prompt = build_report_prompt(intent_text, agents, deliveries, consensus_text)
report: ReportResult = await gateway.run_report(report_thread_id=report_thread_id, prompt=prompt, model=model)
return {
"html": report.html,
"generatedAt": report.generated_at or datetime.now(UTC).isoformat(),
"model": report.model or (model or ""),
"summary": report.summary,
}
def build_report_prompt(
intent_text: str,
agents: list[SeatRef],
deliveries: dict[str, str],
consensus_text: str,
) -> str:
"""拼装 Step3 报告绘制 prompt:意图 + 各席位交付 + 总控共识。
席位交付按 ``agents`` 顺序列出(缺交付的席位跳过)。报告智能体据此用 write_file
写出自包含的 ``outputs/方案总览.html``(具体绘制规则在 roundtable-report 的 SOUL)。
"""
lines: list[str] = [
"【圆桌最终成果汇总 —— 请据此绘制方案总览 HTML 看板】",
"",
"## 任务意图",
(intent_text or "").strip() or "(未提供)",
"",
"## 各席位交付",
]
name_by_id = {a.agent_id: a.name for a in agents}
has_delivery = False
for a in agents:
text = (deliveries.get(a.agent_id) or "").strip()
if not text:
continue
has_delivery = True
lines.append(f"### {name_by_id.get(a.agent_id, a.agent_id)}")
lines.append(text)
lines.append("")
if not has_delivery:
lines.append("(各席位暂无可用交付,请基于任务意图与总控结论绘制。)")
lines.append("")
lines.append("## 总控最终结论")
lines.append((consensus_text or "").strip() or "(未提供)")
return "\n".join(lines)
@@ -0,0 +1,142 @@
"""编排进度写入器 —— 把引擎的状态流转持久化进 ``roundtable_jobs`` 表。
引擎只调本类的语义方法(leader_thinking / seat_active / seat_done / consensus / …),
由它维护「派活链路各节点状态」并落库(best-effort,失败不打断编排)。落库的
``dispatch_chain`` 即 Phase 7 流程图弹窗 / Phase 6 历史下拉的数据源。
进度同时可通过可选的 ``event_sink`` 回调外发(Phase 3 的 SSE 流用),引擎逻辑不感知。
"""
from __future__ import annotations
import logging
from typing import Any, Awaitable, Callable
from .types import SeatRef
logger = logging.getLogger(__name__)
# 节点状态机:pending → active → done。
_PENDING = "pending"
_ACTIVE = "active"
_DONE = "done"
EventSink = Callable[[dict[str, Any]], Awaitable[None]] | None
class JobProgressWriter:
"""跟踪派活链路节点状态并持久化进度。
Args:
store: ``RoundtableJobRepository``(或任何提供 ``update_progress`` 的对象);
传 None 时所有持久化变 no-op(引擎仍可纯跑,便于干测)。
job_id: 目标作业 id。
agents: 有序席位列表(recommend=选择顺序 / chain=链条顺序)→ 决定节点 order。
event_sink: 可选异步回调,每次进度变更时透传一份归一化事件(Phase 3 SSE)。
"""
def __init__(
self,
store: Any | None,
*,
job_id: str,
agents: list[SeatRef],
event_sink: EventSink = None,
) -> None:
self._store = store
self._job_id = job_id
self._agents = list(agents)
self._event_sink = event_sink
self._state: dict[str, str] = {a.agent_id: _PENDING for a in agents}
self._spoke_cycle: dict[str, int] = {}
self._consensus = 0
# ── 节点快照 ──────────────────────────────────────────────────────────
def _nodes(self) -> list[dict[str, Any]]:
"""构造 dispatch_chain 节点数组(§2.3)。
不含 avatarType —— 颜色由前端 ``getAvatarTypeFor(agentId, order)`` 派生,
避免把前端的 BUILTIN_AGENT_META 配色表重复移植到后端(易漂移)。
"""
out: list[dict[str, Any]] = []
for i, a in enumerate(self._agents):
node: dict[str, Any] = {
"agentId": a.agent_id,
"name": a.name,
"order": i + 1,
"state": self._state.get(a.agent_id, _PENDING),
}
if a.agent_id in self._spoke_cycle:
node["lastSpokeCycle"] = self._spoke_cycle[a.agent_id]
out.append(node)
return out
async def _persist(self, **fields: Any) -> None:
"""落库(始终带最新 dispatch_chain + consensus)+ 外发事件,均 best-effort。"""
fields.setdefault("dispatch_chain", self._nodes())
fields.setdefault("consensus_percentage", self._consensus)
if self._store is not None:
try:
await self._store.update_progress(self._job_id, **fields)
except Exception: # noqa: BLE001 — 进度落库失败不能打断编排
logger.warning("roundtable job progress persist failed", exc_info=True)
if self._event_sink is not None:
try:
await self._event_sink({"job_id": self._job_id, **fields})
except Exception: # noqa: BLE001
logger.warning("roundtable job progress event sink failed", exc_info=True)
# ── 语义方法(引擎调用)─────────────────────────────────────────────────
async def begin(self) -> None:
await self._persist(status="running", phase="initializing", cycle=0, active_agent_id=None)
async def leader_thinking(self, cycle: int) -> None:
await self._persist(phase="leader_thinking", cycle=cycle, active_agent_id=None)
async def dispatching(self, cycle: int) -> None:
await self._persist(phase="dispatching", cycle=cycle)
async def seat_active(self, agent_id: str) -> None:
if agent_id in self._state:
self._state[agent_id] = _ACTIVE
await self._persist(phase="dispatching", active_agent_id=agent_id)
async def seat_done(self, agent_id: str, cycle: int) -> None:
if agent_id in self._state:
self._state[agent_id] = _DONE
self._spoke_cycle[agent_id] = cycle
await self._persist(active_agent_id=None)
async def bump_consensus(self) -> None:
"""recommend 每轮派活后 +8(封顶 95),对齐前端。"""
self._consensus = min(95, self._consensus + 8)
await self._persist()
async def set_consensus_pct(self, pct: int) -> None:
"""chain 每席位后设进度(封顶 95),对齐前端 round((i+1)/total*90)。"""
self._consensus = min(95, int(pct))
await self._persist()
async def consensus(self, pct: int = 100) -> None:
self._consensus = int(pct)
await self._persist(phase="consensus", active_agent_id=None)
async def report_drawing(self) -> None:
await self._persist(phase="report_drawing", active_agent_id=None)
async def done(self) -> None:
self._consensus = 100
await self._persist(status="done", phase="report_done", active_agent_id=None)
async def awaiting_input(self, question: str) -> None:
await self._persist(
status="awaiting_input",
phase="leader_thinking",
active_agent_id=None,
pending_clarification=question,
)
async def error(self, message: str) -> None:
await self._persist(status="error", error=message)
@@ -0,0 +1,64 @@
"""模拟网关 —— 产生真实的编排进度但**不调真模型 / 真 LangGraph server**。
用途:
- Phase 3a:让 ``/api/roundtable-jobs`` 控制面(路由 + 执行器 + SSE)端到端跑起来,
前端 Phase 4-8 可对着真实 API + 真实 job 表 + 真实 SSE 联调,无需真模型;
- 单测:``delay=0`` 时瞬时跑完,验证执行器 / 进度落库 / 路由生命周期。
真网关 ``InProcessRoundtableGateway``(进程内驱动 leader/seat/report run,复用
multi_agent 的纯解析函数 + ``set_current_user`` 进程内调 agent runtime)是 Phase 3b,
需在 live stack 上验证。本模拟器与真网关实现同一个 ``RoundtableGateway`` 协议,可直接替换。
"""
from __future__ import annotations
import asyncio
from .types import LeaderTurn, ReportResult, SeatRef, SeatTurn
class SimulatedRoundtableGateway:
"""脚本化的假网关。
recommend:第 1 轮把所有席位派活一遍 → 第 2 轮共识;
chain:每个 per-seat leader 调用不显式派活(引擎据链条顺序强制跑席位)→ 收口。
"""
def __init__(self, agents: list[SeatRef], *, delay: float = 0.6, mode: str = "recommend") -> None:
self._agents = list(agents)
self._delay = delay
self._mode = mode
self._leader_calls = 0
async def _tick(self) -> None:
if self._delay > 0:
await asyncio.sleep(self._delay)
async def run_leader(self, *, thread_ids, coordinator_name, message, model) -> LeaderTurn:
await self._tick()
self._leader_calls += 1
if self._mode == "chain":
# chain 模式由引擎按链条顺序强制跑席位,leader 只给说明、不派活。
return LeaderTurn(final_text="(模拟)总控:请当前席位按职责给出分析。", dispatched=[])
# recommend:首轮派活全部席位,次轮共识。
if self._leader_calls == 1:
return LeaderTurn(
final_text="(模拟)总控首轮派活:已为各席位分配本轮任务。",
dispatched=[(a.agent_id, f"请「{a.name}」就任务从你的专业职责给出分析与建议。") for a in self._agents],
)
return LeaderTurn(final_text="(模拟)各席位已充分研讨,结论收敛,达成共识。", dispatched=[])
async def run_seat(self, *, thread_ids, agent_id, task, model) -> SeatTurn:
await self._tick()
return SeatTurn(final_text=f"(模拟){agent_id} 的交付:已基于任务背景完成本轮分析。")
async def create_report_thread(self) -> str:
return "sim-report-thread"
async def run_report(self, *, report_thread_id, prompt, model) -> ReportResult:
await self._tick()
html = (
"<!doctype html><html><head><meta charset='utf-8'><title>方案总览(模拟)</title></head>"
"<body><h1>方案总览(模拟)</h1><p>这是模拟网关生成的占位报告,用于前端联调。</p></body></html>"
)
return ReportResult(html=html, summary="(模拟)方案已生成", model=model)
@@ -0,0 +1,191 @@
"""圆桌总控编排引擎 —— 共享类型、常量与纯函数。
把前端 ``useStep2Orchestration.ts`` 的编排循环(``runOrchestration`` /
``runChainOrchestration``)忠实移植到后端。本文件只放:
- ``RoundtableGateway`` 协议:引擎驱动「单轮 leader / seat / report」的抽象出口,
把对 FastAPI ``Request`` / ``multi_agent.py`` 内部函数的依赖隔离到 Phase 3 的
具体实现里(引擎本身纯逻辑、可用假网关单测);
- ``LeaderTurn`` / ``SeatTurn`` / ``ReportResult`` / ``SeatRef`` 数据类;
- 常量(MAX_CYCLES、各轮 prompt)与 chain 模式的 prompt 构造纯函数。
对应 ``frontend-web/docs/multi-agent-background-run-dev.md`` §Phase 2。
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Protocol, runtime_checkable
# 编排循环最大轮次,与前端 roundtable-constants.ts MAX_CYCLES 对齐。
MAX_CYCLES = 8
# recommend 模式每轮派活后、回到 leader 的「续轮」prompt(逐字对齐前端)。
CONTINUE_PROMPT = (
"各子智能体已就上一轮派活完成交付,请你结合最新结论判断是否仍需继续派活;"
"若信息已足够,请直接输出最终方案并停止派活。"
)
# chain 模式全链走完后、总控收口的 prompt(逐字对齐前端)。
CHAIN_CLOSEOUT_PROMPT = (
"各链条席位已按顺序完成发言,请你综合所有席位的交付,输出最终方案与结论。"
"不要再派活、不要再向任何席位分配任务。"
)
# ── 数据类 ──────────────────────────────────────────────────────────────────
@dataclass(frozen=True)
class SeatRef:
"""一个研讨席位(智能体)。chain 模式下 ``agents`` 的顺序即链条顺序。"""
agent_id: str
name: str
@dataclass
class LeaderTurn:
"""总控一轮执行的归一化结果(由 gateway.run_leader 产出)。"""
final_text: str = ""
# [(agent_id, task), ...];空 = 共识(不再派活)。
dispatched: list[tuple[str, str]] = field(default_factory=list)
# 命中澄清时的问题文本;非 None 即 clarification 分支。
clarification: str | None = None
@property
def is_clarification(self) -> bool:
return bool(self.clarification)
@property
def is_consensus(self) -> bool:
return not self.dispatched and not self.is_clarification
@dataclass
class SeatTurn:
"""某席位一轮交付的归一化结果(由 gateway.run_seat 产出)。"""
final_text: str = ""
@dataclass
class ReportResult:
"""Step3 报告绘制结果(由 gateway.run_report 产出)。"""
html: str = ""
summary: str | None = None
model: str | None = None
generated_at: str | None = None
# ── 网关协议 ────────────────────────────────────────────────────────────────
@runtime_checkable
class RoundtableGateway(Protocol):
"""引擎驱动「单轮执行」的抽象出口。
Phase 3 提供具体实现(``LoopbackRoundtableGateway``):内部复用
``multi_agent.py`` 的 ``_leader_run`` / ``_special_run`` / ``_create_thread_direct``
等(它们依赖 ``Request``,由 Phase 3 的启动上下文注入)。引擎只面向本协议,
因此可用假网关脱离真模型 / 真 LangGraph server 单测。
线程约定:席位 + 总控 thread 由调用方预先建好并通过 ``thread_ids`` 传入
(key = agent_id / coordinator_name,value = thread_id);report thread 由引擎在
进入报告阶段时通过 ``create_report_thread`` 现建。
"""
async def run_leader(
self,
*,
thread_ids: dict[str, str],
coordinator_name: str,
message: str,
model: str | None,
) -> LeaderTurn: ...
async def run_seat(
self,
*,
thread_ids: dict[str, str],
agent_id: str,
task: str,
model: str | None,
) -> SeatTurn: ...
async def create_report_thread(self) -> str: ...
async def run_report(
self,
*,
report_thread_id: str,
prompt: str,
model: str | None,
) -> ReportResult: ...
# ── chain 模式 prompt 构造(移植自前端 buildChainLeaderTask / pickChainTask)────
def build_chain_leader_task(
*,
seed_message: str,
position: int,
total: int,
seat_agent_id: str,
seat_name: str,
is_first: bool,
) -> str:
"""构造 chain 模式下「让总控只给当前席位派活」的 leader prompt。"""
ordinal = f"{position + 1}/{total}"
if is_first:
return "\n".join(
[
"这是一次【按业务链条顺序】进行的多智能体圆桌研讨。任务背景如下:",
seed_message,
"",
f"本次研讨严格按预设链条顺序进行,共 {total} 个席位,现在是第 {ordinal} 个席位。",
f"请你作为总控,通过 agent_orchestration 技能**仅**向智能体「{seat_name}」"
f"(agent_name: {seat_agent_id})派活,结合任务背景为它分配聚焦其职责的本轮任务。",
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。",
]
)
return "\n".join(
[
f"链条研讨继续,现在轮到第 {ordinal} 个席位。",
f"请你作为总控,结合此前各席位已完成的交付,通过 agent_orchestration 技能**仅**向智能体"
f"「{seat_name}」(agent_name: {seat_agent_id})派活,为它分配承接前序结论、聚焦其职责的本轮任务。",
"不要派给其他任何智能体,也不要自己直接作答或给出最终结论。",
]
)
def pick_chain_task(
dispatched: list[tuple[str, str]],
leader_content: str,
seat_agent_id: str,
seat_name: str,
) -> str:
"""从 leader 终态里取「派给当前链条席位」的任务文本,带纠偏(移植自前端 pickChainTask)。
1) 命中 agent_id 完全一致的派活 → 用它的 task;
2) 总控派了活但写偏 id → 仍用它给出的 task 文本(强制跑正确席位);
3) 总控只写了说明没派活 → 用 leader 正文,再不行回退到模板任务。
"""
wanted = seat_agent_id.lower()
for name, task in dispatched:
if name.lower() == wanted and (task or "").strip():
return task
for _name, task in dispatched:
if (task or "").strip():
return task
if (leader_content or "").strip():
return (
f"请你作为「{seat_name}」,基于以下总控说明与此前各席位的交付,"
f"从你的专业职责出发给出你的分析与交付:\n{leader_content.strip()}"
)
return (
f"请你作为「{seat_name}」,基于任务背景与此前各席位的交付,"
"从你的专业职责出发给出你的分析与交付。"
)
@@ -0,0 +1,100 @@
"""圆桌后台作业真网关 —— headless 验证脚本(Phase 3b)。
用法(在能起完整后端栈 + 配好真模型的环境):
1) 用真网关启动后端:
ROUNDTABLE_JOB_GATEWAY=inprocess make gateway
(或 docker 里给 gateway 容器加该环境变量)
2) 跑本脚本,它会:POST 启动一个作业 → 轮询 GET 打印进度 → 跑到 done 打印报告长度。
PYTHONPATH=. uv run python scripts/probe_roundtable_job.py
环境变量:
GATEWAY_URL 默认 http://127.0.0.1:8001
AUTH_COOKIE 若后端开启鉴权,传 "session=xxx" 之类的 Cookie 头
PROBE_INTENT 自定义任务意图
PROBE_MODE recommend(默认)| chain
说明:鉴权关闭的 dev 模式无需 AUTH_COOKIE。真网关会进程内驱动 roundtable-coordinator /
各席位 / roundtable-report 真模型,耗时取决于模型,可能数分钟。
"""
from __future__ import annotations
import asyncio
import os
import time
import httpx
GATEWAY_URL = os.getenv("GATEWAY_URL", "http://127.0.0.1:8001").rstrip("/")
AUTH_COOKIE = os.getenv("AUTH_COOKIE", "")
INTENT = os.getenv("PROBE_INTENT", "为一家中型制造企业设计一套数字化转型的整体方案")
MODE = os.getenv("PROBE_MODE", "recommend")
# 用 3 个内置圆桌席位(确保可见、已 seed)。
AGENTS = [
{"agentId": "roundtable-intelligence", "name": "情报收集"},
{"agentId": "roundtable-solution-design", "name": "方案设计"},
{"agentId": "roundtable-summary", "name": "总结陈述"},
]
TERMINAL = {"done", "error", "cancelled"}
def _headers() -> dict[str, str]:
h = {"Content-Type": "application/json"}
if AUTH_COOKIE:
h["Cookie"] = AUTH_COOKIE
return h
async def main() -> None:
async with httpx.AsyncClient(timeout=30.0, trust_env=False) as client:
print(f"[probe] POST {GATEWAY_URL}/api/roundtable-jobs mode={MODE}")
resp = await client.post(
f"{GATEWAY_URL}/api/roundtable-jobs",
headers=_headers(),
json={"intent": INTENT, "agents": AGENTS, "orchestrationMode": MODE},
)
if resp.status_code != 201:
print(f"[probe] start failed: HTTP {resp.status_code}: {resp.text[:500]}")
return
job = resp.json()
job_id = job["id"]
print(f"[probe] started job {job_id}")
last = ""
t0 = time.perf_counter()
while True:
await asyncio.sleep(2.0)
r = await client.get(f"{GATEWAY_URL}/api/roundtable-jobs/{job_id}", headers=_headers())
if r.status_code != 200:
print(f"[probe] get failed: HTTP {r.status_code}: {r.text[:300]}")
return
j = r.json()
line = (
f"status={j['status']} phase={j['phase']} cycle={j['cycle']} "
f"active={j.get('activeAgentId')} consensus={j['consensusPercentage']}%"
)
if line != last:
print(f"[probe] +{time.perf_counter() - t0:5.0f}s {line}")
last = line
if j["status"] in TERMINAL:
print(f"[probe] terminal: {j['status']}")
if j["status"] == "error":
print(f"[probe] error detail: {j.get('error')}")
# 读草稿 step3 验证报告已落库
if j.get("draftId"):
dr = await client.get(
f"{GATEWAY_URL}/api/roundtable-drafts/{j['draftId']}", headers=_headers()
)
if dr.status_code == 200:
step3 = dr.json().get("step3")
html_len = len((step3 or {}).get("html") or "")
print(f"[probe] draft.step3 html length = {html_len}")
return
if __name__ == "__main__":
asyncio.run(main())