From 9d863f63e5abc2d33c11d361fcb609459f079b28 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Thu, 4 Jun 2026 18:50:31 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend-web/.env.development | 14 +- frontend-web/.env.development.local.example | 5 + frontend-web/.env.example | 3 + frontend-web/.env.network | 53 +-- frontend-web/.env.production | 4 + frontend-web/.env.production.local.example | 3 + frontend-web/.env.sit | 5 + .../artifacts/artifact-file-detail.tsx | 5 +- .../src/roundtable-planning/api/drafts.ts | 12 + .../api/roundtable-jobs.ts | 195 ++++++++++ .../ConsensusDocxDownloadButton.tsx | 59 +++ .../components/DispatchChainJobModal.tsx | 92 +++++ .../components/DispatchChainModal.tsx | 77 +++- .../components/HighFidelityReport.tsx | 110 +++--- .../components/Step2Panel.tsx | 14 + .../components/Step2SandboxLayout.tsx | 140 ++++--- .../components/Step3FileBrowseMode.tsx | 351 ++++++++++++++++++ .../components/Step3Panel.tsx | 7 + .../components/Step3ReportMode.tsx | 42 ++- .../hooks/useDraftPersistence.ts | 11 + .../src/roundtable-planning/lib/job-types.ts | 42 +++ .../pages/RoundtablePlanningPage.tsx | 145 +++++++- .../backend/app/gateway/app.py | 4 + .../backend/app/gateway/deps.py | 39 ++ .../gateway/roundtable_inprocess_gateway.py | 272 ++++++++++++++ .../app/gateway/roundtable_job_executor.py | 152 ++++++++ .../app/gateway/routers/roundtable_jobs.py | 341 +++++++++++++++++ .../roundtable_orchestrator/__init__.py | 48 +++ .../agents/roundtable_orchestrator/engine.py | 299 +++++++++++++++ .../roundtable_orchestrator/progress.py | 142 +++++++ .../simulated_gateway.py | 64 ++++ .../agents/roundtable_orchestrator/types.py | 191 ++++++++++ .../backend/scripts/probe_roundtable_job.py | 100 +++++ 33 files changed, 2878 insertions(+), 163 deletions(-) create mode 100644 frontend-web/src/roundtable-planning/api/roundtable-jobs.ts create mode 100644 frontend-web/src/roundtable-planning/components/ConsensusDocxDownloadButton.tsx create mode 100644 frontend-web/src/roundtable-planning/components/DispatchChainJobModal.tsx create mode 100644 frontend-web/src/roundtable-planning/components/Step3FileBrowseMode.tsx create mode 100644 offline-backend-20260512/backend/app/gateway/roundtable_inprocess_gateway.py create mode 100644 offline-backend-20260512/backend/app/gateway/roundtable_job_executor.py create mode 100644 offline-backend-20260512/backend/app/gateway/routers/roundtable_jobs.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/__init__.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/engine.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/progress.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/simulated_gateway.py create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/roundtable_orchestrator/types.py create mode 100644 offline-backend-20260512/backend/scripts/probe_roundtable_job.py diff --git a/frontend-web/.env.development b/frontend-web/.env.development index 0e89436f..e8c1e371 100644 --- a/frontend-web/.env.development +++ b/frontend-web/.env.development @@ -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 diff --git a/frontend-web/.env.development.local.example b/frontend-web/.env.development.local.example index a5092803..9a45a1fe 100644 --- a/frontend-web/.env.development.local.example +++ b/frontend-web/.env.development.local.example @@ -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 diff --git a/frontend-web/.env.example b/frontend-web/.env.example index 0c211a17..f0dda406 100644 --- a/frontend-web/.env.example +++ b/frontend-web/.env.example @@ -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 diff --git a/frontend-web/.env.network b/frontend-web/.env.network index be82f968..bf2a9011 100644 --- a/frontend-web/.env.network +++ b/frontend-web/.env.network @@ -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= + diff --git a/frontend-web/.env.production b/frontend-web/.env.production index 6aa2d33d..ad196043 100644 --- a/frontend-web/.env.production +++ b/frontend-web/.env.production @@ -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= diff --git a/frontend-web/.env.production.local.example b/frontend-web/.env.production.local.example index 659844ac..f56448e0 100644 --- a/frontend-web/.env.production.local.example +++ b/frontend-web/.env.production.local.example @@ -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 diff --git a/frontend-web/.env.sit b/frontend-web/.env.sit index 913fe3a3..dbaa36d4 100644 --- a/frontend-web/.env.sit +++ b/frontend-web/.env.sit @@ -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= diff --git a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx index faa635a2..ef28a656 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -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} /> diff --git a/frontend-web/src/roundtable-planning/api/drafts.ts b/frontend-web/src/roundtable-planning/api/drafts.ts index f038503a..ed15557a 100644 --- a/frontend-web/src/roundtable-planning/api/drafts.ts +++ b/frontend-web/src/roundtable-planning/api/drafts.ts @@ -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, }; } diff --git a/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts new file mode 100644 index 00000000..ccbc09df --- /dev/null +++ b/frontend-web/src/roundtable-planning/api/roundtable-jobs.ts @@ -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(res: Response, ctx: string): Promise { + 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 & { 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): 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 { + const res = await apiFetch(BASE(), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(toStartBody(payload)), + }); + return mapJob(await readJson>(res, "start job")); +} + +/** 查作业快照。404 → null。 */ +export async function getJob(jobId: string): Promise { + const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}`, { method: "GET" }); + if (res.status === 404) return null; + return mapJob(await readJson>(res, "get job")); +} + +/** 查某草稿最近一个作业。无 → null。 */ +export async function getJobByDraft(draftId: string): Promise { + const res = await apiFetch(`${BASE()}?draft_id=${encodeURIComponent(draftId)}`, { method: "GET" }); + const data = await readJson | null>(res, "get job by draft"); + return data ? mapJob(data) : null; +} + +/** 回答澄清后续跑。 */ +export async function resumeJob(jobId: string, answer: string): Promise { + const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/resume`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ answer }), + }); + return mapJob(await readJson>(res, "resume job")); +} + +/** 取消作业。 */ +export async function cancelJob(jobId: string): Promise { + const res = await apiFetch(`${BASE()}/${encodeURIComponent(jobId)}/cancel`, { method: "POST" }); + return mapJob(await readJson>(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; + try { + frame = JSON.parse(json) as Record; + } 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(); +} diff --git a/frontend-web/src/roundtable-planning/components/ConsensusDocxDownloadButton.tsx b/frontend-web/src/roundtable-planning/components/ConsensusDocxDownloadButton.tsx new file mode 100644 index 00000000..f6d5c34a --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/ConsensusDocxDownloadButton.tsx @@ -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 ( + + ); +} diff --git a/frontend-web/src/roundtable-planning/components/DispatchChainJobModal.tsx b/frontend-web/src/roundtable-planning/components/DispatchChainJobModal.tsx new file mode 100644 index 00000000..8d0a538b --- /dev/null +++ b/frontend-web/src/roundtable-planning/components/DispatchChainJobModal.tsx @@ -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(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 ( + 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 + } + /> + ); +} diff --git a/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx b/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx index 130ef672..349500a5 100644 --- a/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx +++ b/frontend-web/src/roundtable-planning/components/DispatchChainModal.tsx @@ -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 = new Set(["queued", "running", "awaiting_input"]); + const STATUS_LABEL: Record = { 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({ + {/* 出错详情 */} + {status === "error" && error && ( +
+ {error} +
+ )} + + {/* 澄清作答(status==='awaiting_input')→ resumeJob 续跑 */} + {status === "awaiting_input" && pendingClarification && onResume && ( +
+
+
+ + 总控需要补充信息,请回答后继续 +
+

{pendingClarification}

+