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/docs/multi-agent-background-run-dev.md b/frontend-web/docs/multi-agent-background-run-dev.md new file mode 100644 index 00000000..03e62dc3 --- /dev/null +++ b/frontend-web/docs/multi-agent-background-run-dev.md @@ -0,0 +1,514 @@ +# 多智能体规划 · 后台挂起执行 + 进度可视化 —— 分步实现方案 + +> 目标:在「多智能体规划」第二步增加**后台挂起**能力——把当前任务挂到后台,自动一路跑到 +> 最后一步(Step3 结果绘制完成)为止;历史记录下拉框里进行中的任务显示加载动画;点击进行中 +> 的任务弹出一个**大弹窗**,里面是**横向的智能体派活链路流程图**(节点 = 各席位智能体,图标 + +> 名称,不同颜色,正在发言的节点和它的连接线带加载动画);弹窗里有「查看」按钮,点击进入正在 +> 进行中的任务实时观察。 + +--- + +## 0. 现状与架构事实(动手前必读) + +研究自现有代码,决定了整套方案的形态。**关键结论:当前编排循环跑在前端,离开页面即中断。** + +### 0.1 前端现状 + +| 事实 | 位置 | +|------|------| +| Step2 编排循环(leader → 顺序派 special → leader … 直到 `dispatched===[]` 共识 / `MAX_CYCLES=8`)**完全由前端 hook 驱动** | `src/roundtable-planning/hooks/useStep2Orchestration.ts:992-1204`(recommend)、`1228-1439`(chain) | +| 循环由 `currentStep===2` 的 `useEffect` 启停;离开 Step2 的 cleanup 会 `orchestrationCancelRef.current=true` + `abortAllInFlight()` 中断 | `useStep2Orchestration.ts:1468-1541`(尤其 cleanup `1534-1537`、`1471-1477`) | +| 共识判定:`leaderRes.dispatched.length===0` → `setHasConsensus(true)` | `useStep2Orchestration.ts:1091-1099` | +| 进入 Step3:手动点按钮,前置 `hasConsensus===true` | `Step2Panel.tsx:370-381`、`RoundtablePlanningPage.tsx:586-595` | +| Step3「结果绘制」:前端流式调 `roundtable-report` 智能体,写出 `outputs/方案总览.html`,`phase→"done"` 即完成 | `src/roundtable-planning/hooks/useStep3Report.ts:36,51,165,560-596,636-652` | +| 草稿持久化:`/api/roundtable-drafts`,含 step1/step2/step3 三段快照 + `furthestStep(1|2|3)`,**无显式 status 字段** | `src/roundtable-planning/api/drafts.ts:25-110,112,184-225` | +| 历史下拉 UI(触发按钮 + 列表 + 重命名/删除) | `RoundtablePlanningPage.tsx:771-892` | +| 自动保存触发点(Step1 回复完 / Step2 每轮气泡收尾 / Step3 报告保存 / 步骤前进) | `useDraftPersistence.ts:248-316` | +| 加载草稿恢复:`handleLoadDraft` → `getDraft` → `cancelAllStreams` → hydrate → `setCurrentStep(furthestStep)` | `useDraftPersistence.ts:308-368` | + +### 0.2 后端现状 + +| 事实 | 位置 | +|------|------| +| `/api/multi-agent/init`:为各席位并行建 thread + 单例总控 `roundtable-coordinator`,SSE 返回 `seat_ready`/`coordinator_ready`/`init_done(thread_ids)` | `app/gateway/routers/multi_agent.py:647-823` | +| `/api/multi-agent/run/stream`:驱动**单轮** leader 或 special 的 LLM 执行,末尾 JSON 帧 `status`(派活列表 / `[]` 共识 / `clarification` / `error`) | `multi_agent.py:1076-1142` | +| `/api/multi-agent/report/init`:为 Step3 报告建 thread | `multi_agent.py`(report/init) | +| **后端不存在编排循环**——leader 派活由前端解释后再逐个发 `run/stream`;后端只做单轮执行 + 把派活/完成「广播」追加进各 thread 历史消息 | `multi_agent.py:24-29,487-502,1108-1142` | +| 派活格式:`agent_orchestration` 工具调用 `{agent_name, task}`(旧式 bash 解析兼容) | `multi_agent.py:525-552` | + +### 0.3 可复用模板:AI 写作的「后台多阶段流程」 + +AI 写作已经把「后台跑多阶段流程 + 前端 SSE 订阅 + 可 resume + 定时清理」跑通了,**圆桌后台作业直接照搬这套**: + +| 模板要素 | 位置 | +|------|------| +| LangGraph 图驱动多阶段(researching/writing_outline/writing_draft/reviewing/…),条件路由推进,`interrupt()` 暂停点等用户输入 | `packages/harness/deerflow/agents/ai_writing/graph.py`、`nodes/*`(`pause_material_confirm` 等 `45-290`) | +| Session 持久化表 `ai_writing_sessions`:`id/user_id/title/status/draft_*/transcript(LONGTEXT)/...`,`status∈{in_progress,done,error}` | `packages/harness/deerflow/persistence/ai_writing_sessions/model.py:14-39` | +| `transcript` 用 `PortableLongText`(MySQL LONGTEXT,避开 64KB),时间用 `BeijingDateTime` | 同上 `27-38` | +| 进度走 LangGraph Server 标准流 `stream_mode=["messages-tuple","values"]`,**graph 在 langgraph runtime 里跑,任意 worker 可 join stream**(这是后台健壮性的关键) | `ai_writing.py:1-17`、`langgraph.json:16-18` | +| Checkpointer 共享 `AsyncSqliteSaver`,落 `.deer-flow/data/checkpoints.db`(需挂数据卷) | `runtime/checkpointer/async_provider.py` | +| Cleanup cron(leader 文件锁、活跃保护、先删 CP 再删 DB、admin 手动触发) | `app/gateway/ai_writing_cleanup.py:26,34-59,111-135,193-215,307-367` | +| **多 worker 坑**:模块级内存 dict(`_event_queues`)每 worker 一份会丢会话;SSE 进程内直调会整段 buffer。解法:状态进 DB+checkpointer、SSE 走真 TCP loopback `127.0.0.1`(`trust_env=False` 禁代理) | `multi_agent.py:271-293`、`AIWritingPage.tsx` 文件头注释 §3/§5 | + +### 0.4 决策(已与产品确认) + +1. **后端真后台执行**:把编排循环搬到后端 run,刷新 / 关页 / 换设备都不中断。 +2. **流程图节点 = 智能体派活链路**:横向排列的席位节点,正在发言的节点 + 连接线加载动画。 + +> ⚠️ **本方案最大的工作量在 Phase 2**:把目前跑在前端 TS 里的编排循环(`runOrchestration`/`runChainOrchestration`) +> 移植成一个后端 **LangGraph「圆桌总控编排图」**。这是与 AI 写作一致、可在 LangGraph runtime 里 +> 后台跑且天然 checkpoint 的健壮路径。下文每个阶段都给出验收标准,可独立提测。 + +--- + +## 1. 总体架构 + +``` +┌─────────────────────────────── 前端 (frontend-web) ───────────────────────────────┐ +│ Step2Panel ──「后台挂起」按钮──▶ POST /api/roundtable-jobs (start) │ +│ HistoryDropdown ── 进行中任务显示 spinner ──▶ 点击 ──▶ DispatchChainModal(大弹窗) │ +│ DispatchChainModal ── 订阅 GET /api/roundtable-jobs/{id}/stream(SSE) │ +│ 横向派活链路流程图(节点=席位, 活动节点+连线动画) ──「查看」──▶ 进入任务实时观察 │ +└───────────────────────────────────────────────────────────────────────────────────┘ + │ SSE 进度 / 快照 +┌─────────────────────────────── 后端 (backend) ─────────────────────────────────────┐ +│ /api/roundtable-jobs 路由层(start/get/stream/resume/cancel) ── 仿 ai_writing.py │ +│ │ 启动后台 run │ +│ LangGraph「圆桌总控编排图」 roundtable_orchestrator ── 仿 ai_writing graph │ +│ init → leader_call → parse_dispatch → dispatch_seats(顺序) → loop ↺ │ +│ → consensus → report_draw(写 HTML) → done (clarification 点 interrupt()) │ +│ │ 每次状态转移持久化进度 │ +│ roundtable_jobs 表(status/phase/dispatch_chain/active_speaker/...) + 共享 checkpointer│ +│ cleanup cron (仿 ai_writing_cleanup.py) │ +└───────────────────────────────────────────────────────────────────────────────────┘ +``` + +**核心思路**:后台作业 = 一个 LangGraph 图的一次 run。图节点内部复用现有的单轮执行能力 +(`multi_agent.py` 里 leader/special 的 run 逻辑),把「解释派活 → 顺序派活 → 回到 leader」的 +循环从前端搬进图节点。前端从「驱动者」退化为「观察者」:通过 SSE 订阅进度渲染。 + +--- + +## 2. 数据契约(前后端共享,先定义后实现) + +### 2.1 作业状态机 `JobStatus` + +``` +queued # 已创建,等待 worker 拉起 +running # 编排进行中(Step2 派活循环 / Step3 绘制) +awaiting_input # 命中 clarification,等用户回答(interrupt 挂起) +done # 已跑到 Step3 绘制完成 +error # 出错终止 +cancelled # 用户取消 +``` + +### 2.2 作业阶段 `JobPhase`(细化给流程图/进度条用) + +``` +initializing # 建 thread / 总控就绪 +leader_thinking # 总控在决策派活 +dispatching # 正在顺序派活给席位(配合 active_speaker) +consensus # 共识达成(dispatched=[]) +report_drawing # Step3 报告绘制中 +report_done # 绘制完成 +``` + +### 2.3 派活链路节点 `DispatchChainNode`(流程图核心数据) + +```ts +interface DispatchChainNode { + agentId: string; // 席位 agent_id(小写) + name: string; // 显示名 + avatarType: string; // 复用 getAvatarTypeFor(agentId, index) → 决定颜色 + order: number; // 横向排列次序(chain 模式=链条顺序;recommend 模式=席位/发言顺序) + state: 'pending' | 'active' | 'done'; // 节点状态(驱动颜色/动画) + lastSpokeCycle?: number; // 最近发言轮次(可选,做 tooltip) +} +``` + +### 2.4 进度事件 `JobProgressEvent`(SSE 推送的归一化事件) + +```ts +type JobProgressEvent = + | { type: 'status'; status: JobStatus } + | { type: 'phase'; phase: JobPhase; cycle: number } + | { type: 'chain'; nodes: DispatchChainNode[] } // 派活链路全量/增量 + | { type: 'active'; agentId: string | null } // 当前发言席位(null=总控/无) + | { type: 'consensus'; percentage: number } + | { type: 'dialogue'; bubble: Step2DialogueLike } // 新增/收尾的对话气泡(供「查看」实时渲染) + | { type: 'clarification'; question: string } // 命中澄清 + | { type: 'done'; step3: DraftStep3Snapshot } // 终态:最终报告快照 + | { type: 'error'; detail: string }; +``` + +> SSE 通道可直接复用 LangGraph Server 的 `values` 流(图状态快照),由前端 hook 把 `values` +> 映射成上述归一化事件;也可在路由层做映射后再下发。**推荐前端映射**(少一层后端耦合)。 + +### 2.5 作业记录 `RoundtableJob`(持久化 + 列表展示) + +```ts +interface RoundtableJob { + id: string; + draftId: string; // 关联的草稿 + userId: string; + status: JobStatus; + phase: JobPhase; + cycle: number; // 当前轮次(0..MAX_CYCLES) + dispatchChain: DispatchChainNode[]; + activeAgentId: string | null; + consensusPercentage: number; + pendingClarification: string | null; + threadIds: Record | null; // 复用 ThreadIdMap + coordinatorName: string | null; + orchestrationMode: 'recommend' | 'chain'; + chain?: { id: string; title: string } | null; + error: string | null; + createdAt: string; + updatedAt: string; +} +``` + +--- + +## Phase 1 —— 后端:圆桌作业持久化模型 + +**目标**:建一张 `roundtable_jobs` 表(仿 `ai_writing_sessions`),存作业状态/阶段/派活链路, +并在 `roundtable_drafts` 列表里能 join 出当前作业状态。 + +**涉及文件(新增/改)** +- 新增 `packages/harness/deerflow/persistence/roundtable_jobs/model.py`(ORM 模型,仿 `ai_writing_sessions/model.py:14-39`) +- 新增 `.../roundtable_jobs/sql.py`(Repository:`create / get / update_progress / list_by_user / list_older_than / delete`,仿 `ai_writing_sessions/sql.py`) +- 新增 alembic 迁移 `persistence/migrations/versions/_create_roundtable_jobs.py` +- 改 `roundtable_drafts` 的列表查询:返回里带上 `status`/`jobId`(LEFT JOIN roundtable_jobs,或在 draft 行上冗余一个 `status` 字段,二选一,见下) + +**步骤** +1. 字段设计(对齐 §2.5):`id(PK)`、`draft_id`、`user_id`、`status`、`phase`、`cycle(Int)`、 + `dispatch_chain(PortableLongText/JSON)`、`active_agent_id`、`consensus_percentage(Int)`、 + `pending_clarification(Text)`、`thread_ids(JSON)`、`coordinator_name`、`orchestration_mode`、 + `chain(JSON)`、`error(Text)`、`created_at/updated_at(BeijingDateTime)`。 +2. `dispatch_chain` / `thread_ids` / `chain` 用 `PortableLongText` 存 JSON 字符串,repo 层序列化。 +3. 列表 join:给 `DraftMeta` 增加 `status?: JobStatus` 与 `jobId?: string`。 + **推荐方案**:草稿与作业 1:1(一个草稿最多一个活跃作业),在 draft 列表查询 LEFT JOIN + `roundtable_jobs` 取最新作业的 `status`。 +4. 索引:`(user_id, updated_at)`、`(draft_id)`、`(status)`(cleanup/列表用)。 + +**验收** +- 迁移可 `alembic upgrade head` 跑通,MySQL 上 `dispatch_chain` 为 `longtext`。 +- 单测:create→update_progress→get 往返;`list_by_user` 按 `updated_at` 倒序;`list_older_than` 分批。 + +--- + +## Phase 2 —— 后端:圆桌总控编排 LangGraph 图(核心 / 最大工作量) + +**目标**:把前端的编排循环移植成一个后端 LangGraph 图,可在 LangGraph runtime 里后台跑、天然 +checkpoint,一路跑到 Step3 绘制完成。 + +**涉及文件(新增)** +- `packages/harness/deerflow/agents/roundtable_orchestrator/graph.py`(图装配,仿 `ai_writing/graph.py`) +- `.../roundtable_orchestrator/state.py`(图状态 schema:intent、selectedAgents、threadIds、coordinatorName、 + mode/chain、dialogues、dispatchChain、activeAgentId、cycle、consensus、phase、step3 等) +- `.../roundtable_orchestrator/nodes/*.py`(各节点) +- 在 `langgraph.json` 注册新 assistant `roundtable_orchestrator`(仿 ai_writing assistant 注册,参考 `langgraph.json:16-18`) + +**图节点设计(把前端循环逐段翻译过来)** + +| 节点 | 职责 | 对应前端逻辑 | +|------|------|------| +| `init` | 复用 `_create_thread_direct` 批量建席位 thread + 总控就绪(或接收前端已建好的 `threadIds` 直接用) | `useStep2Orchestration.ts:1485-1531` / `multi_agent.py:647-823` | +| `leader_call` | 跑一轮总控(agent_type=leader),拿 `status`(派活列表 / `[]` / `clarification`) | 单轮逻辑 `multi_agent.py:1076-1142` | +| `route_after_leader`(条件边) | `clarification`→`interrupt`;有派活→`dispatch_seats`;`[]`→`consensus` | `useStep2Orchestration.ts:1091-1181` | +| `dispatch_seats` | **顺序**派活:对 dispatched 里每个席位依次跑 special run;每开始一个就更新 `activeAgentId` + 进度持久化 | `useStep2Orchestration.ts:1101-1181`(务必串行,勿并行,详见前端 §5.4 注释) | +| `clarification_pause` | `interrupt()` 挂起,`status→awaiting_input`,写 `pending_clarification` | 仿 ai_writing `pause_*` `45-290` | +| `consensus` | `setHasConsensus`、`consensus=100`,落库后进入 `report_draw` | `1091-1099` | +| `report_draw` | 跑 `roundtable-report`(先 report/init 建 thread),流式写 `outputs/方案总览.html`,产出 `DraftStep3Snapshot` | `useStep3Report.ts:91-149,636-652,560-596` | +| `done` | `status→done`、写 `step3` 进作业 + 回写草稿 step3 快照 | `RoundtablePlanningPage.tsx:613-617` | + +**关键实现要点** +1. **复用单轮执行**:节点内部不要重写 LLM 调用,抽出 `multi_agent.py` 里 leader/special/report 的 + 单轮 run 为可被图节点直接 `await` 的内部函数(去掉 HTTP 层,进程内调 LangGraph)。 +2. **派活广播**:保留现有把「总控给 X 派了活 / X 完成」追加进各 thread 历史的广播 + (`multi_agent.py:487-502`),让席位读到全局上下文。 +3. **MAX_CYCLES 兜底**:图里维护 `cycle`,≥8 强制收敛(对齐 `roundtable-constants.ts:13`)。 +4. **chain 模式**:`orchestration_mode==='chain'` 时按 `chain.seats` 固定顺序派活,跳过总控自由决策的 + 部分(对齐 `runChainOrchestration` `1228-1439`)。 +5. **进度落库**:每个节点结束 `update_progress(jobId, {phase, cycle, dispatchChain, activeAgentId, + consensus, dialogues_delta})`,前端 SSE 才有实时数据。 +6. **dialogues 累积**:把每轮气泡写进图状态 `dialogues`,既供「查看」实时渲染,也供作业结束回写草稿 step2。 + +**验收** +- 用脚本(仿 `scripts/probe_ai_writing_assistant.py`)直接对图发起一次 run,不开前端,能从 intent + + selectedAgents 一路跑到写出 `方案总览.html`,作业 `status` 终态 `done`。 +- 命中 clarification 时图 `interrupt`,`status=awaiting_input`,`pending_clarification` 有值。 +- chain 模式按链条顺序派活,recommend 模式由总控自由派活,两条路径都能收敛。 + +--- + +## Phase 3 —— 后端:作业控制 + 进度 SSE 接口 + +**目标**:暴露 start/get/stream/resume/cancel,后台 run 走 LangGraph Server(任意 worker 可 join)。 + +**涉及文件(新增/改)** +- 新增 `app/gateway/routers/roundtable_jobs.py`(仿 `ai_writing.py` 的 CRUD + 控制壳) +- 在 gateway `app.py` 注册路由 +- 新增 `app/gateway/roundtable_jobs_cleanup.py`(仿 `ai_writing_cleanup.py`)+ 在启动处挂 cron + +**接口契约** + +| 方法 | 路径 | 说明 | +|------|------|------| +| `POST` | `/api/roundtable-jobs` | 入参:`{draftId, intent, selectedAgents, threadIds?, coordinatorName?, model, orchestrationMode, chain?}`。建作业记录(`queued`) + 启动图 run(LangGraph Server thread),返回 `RoundtableJob` | +| `GET` | `/api/roundtable-jobs/{id}` | 作业快照(轮询兜底 / 弹窗首帧) | +| `GET` | `/api/roundtable-jobs/{id}/stream` | SSE 进度流:透传图 `values`/`messages-tuple`,或映射成 §2.4 事件下发。**走真 TCP loopback**(`multi_agent.py:271-293`),禁代理 | +| `POST` | `/api/roundtable-jobs/{id}/resume` | 入参 `{answer}`,回填 clarification → 图 resume,`status→running` | +| `POST` | `/api/roundtable-jobs/{id}/cancel` | 取消图 run,`status→cancelled` | +| `GET` | `/api/roundtable-jobs?draftId=` | 按草稿查活跃作业(页面恢复用) | +| `POST` | `/api/roundtable-jobs/admin/cleanup` | 管理员手动清理(仿 ai_writing admin cleanup `307-367`) | + +**多 worker 健壮性(务必遵守,照搬 ai_writing 教训)** +- 作业状态只存 **DB + checkpointer**,**不要**用模块级内存 dict 存 per-session 状态。 +- SSE 必须经真 TCP `127.0.0.1` loopback,不能 `httpx.ASGITransport(app=...)` 进程内直调(会整段 buffer)。 +- 图 run 跑在 LangGraph runtime(独立于处理 HTTP 的 worker),刷新/换 worker 都能重新 join stream。 +- 数据卷:`.deer-flow/data/checkpoints.db` 必须挂出,否则容器重启丢状态。 + +**验收** +- `POST /start` 后立刻 `GET /{id}` 能拿到 `queued/running`;关掉 SSE 再重连能继续收进度。 +- 杀掉发起 start 的 worker(或多 worker 轮询),`resume`/`stream` 仍命中同一作业。 +- cleanup 干跑:活跃作业永不删,先删 CP 再删 DB。 + +--- + +## Phase 4 —— 前端:作业 API 客户端 + 数据模型扩展 + +**目标**:前端有一层干净的作业 API 与类型,历史列表能拿到 status。 + +**涉及文件(新增/改)** +- 新增 `src/roundtable-planning/api/roundtable-jobs.ts`: + ```ts + export async function startJob(payload: StartJobPayload): Promise + export async function getJob(id: string): Promise + export function streamJob(id: string, on: (e: JobProgressEvent) => void): () => void // 返回 unsubscribe + export async function resumeJob(id: string, answer: string): Promise + export async function cancelJob(id: string): Promise + ``` + - `streamJob` 复用项目现有 SSE/`streamMultiAgent` 的封装方式(`api/multi-agent.ts:403+`),把 + LangGraph `values` 映射成 §2.4 的归一化事件。 +- 改 `src/roundtable-planning/api/drafts.ts`:`DraftMeta` 增 `status?: JobStatus; jobId?: string` + (`drafts.ts:76-83` + 字段映射 `122-178` 加 snake_case 转换)。 +- 新增 `src/roundtable-planning/lib/job-types.ts`:把 §2.1–2.5 的类型集中导出。 + +**验收**:`pnpm typecheck` 通过;`getJob`/`streamJob` 能拉到 Phase 3 的数据。 + +--- + +## Phase 5 —— 前端:Step2「后台挂起」按钮 + +**目标**:Step2 顶栏/底栏加按钮,点击把当前任务交给后端后台跑,前端停止本地循环、转为观察者。 + +**涉及文件(改)** +- `src/roundtable-planning/components/Step2Panel.tsx`(按钮放在现有「进入第三步」附近 `370-381`) +- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx`(接线 + 观察者模式) +- `src/roundtable-planning/hooks/useStep2Orchestration.ts`(暴露「停止本地循环并交后台」的方法) + +**步骤** +1. Step2Panel 加「⏼ 后台挂起」按钮,`onBackgroundSuspend` 回调。 +2. 页面 `handleBackgroundSuspend`: + - `ensureDraft(2)` 拿到 `draftId`(`useDraftPersistence.ts` ensureDraft)。 + - 收集当前状态:`intentReady`、`selectedAgents`、`threadIds`、`coordinatorName`、`model`、 + `orchestrationMode`、`chain`(全部来自 `step2`/`step1` 现有状态)。 + - `startJob(...)` → 拿到 `jobId`。 + - **停止前端本地编排**:调用 `step2.stopOrchestration()` + 置一个 `backgroundedJobId` 标记, + 让 `currentStep===2` 的 useEffect **不再自启本地循环**(在 `useStep2Orchestration.ts:1468-1541` + 的启动条件里加 `&& !backgrounded`)。 + - toast「已转入后台,可离开页面,任务会自动跑到结果绘制完成」。 + - 刷新历史列表(`drafts` 重新拉,进行中项即出现 spinner)。 +3. **观察者模式**:若当前停留在 Step2 且该 draft 有 running 作业,订阅 `streamJob`,把进度事件渲染成 + 对话气泡/活动席位(复用现有 Step2 渲染),而不是本地驱动。 + +**注意:避免「双跑」**——后台已接管后,前端绝不能再并行驱动同一批 thread。用 `backgroundedJobId` +互斥;加载草稿时若发现有 running 作业,直接进观察者模式。 + +**验收**:点「后台挂起」→ 可立即切到别的任务/路由;回来仍在跑;不会出现前后端同时派活。 + +--- + +## Phase 6 —— 前端:历史下拉框「进行中」加载动画 + +**目标**:历史下拉里 `status==='running'`(或 `awaiting_input`)的草稿条目显示加载动画;点击进行中 +条目不直接 load,而是打开流程图弹窗。 + +**涉及文件(改)** +- `src/roundtable-planning/pages/RoundtablePlanningPage.tsx` 的 `HistoryDropdown`(`771-892`) + +**步骤** +1. 列表项左侧名称前/后,按 `d.status` 渲染状态标记: + - `running` → 旋转的 `Loader2`(`animate-spin`)+「进行中」。 + - `awaiting_input` → 呼吸点 +「待澄清」(琥珀色)。 + - `done`/无 → 维持现状(显示 `Step {furthestStep}`)。 + - `error` → 红点 +「出错」。 +2. 点击行为分流: + - `running`/`awaiting_input` → `openDispatchChainModal(d.jobId)`(Phase 7),**不**直接 `onLoad`。 + - 其它 → 维持 `onLoad(id)`。 +3. 列表需要 `status`,确保 Phase 4 的 `DraftMeta.status` 已就位;下拉打开时 `refresh()` 拉最新。 + +**验收**:后台挂起后,历史下拉对应条目转圈;点击弹出流程图弹窗。 + +--- + +## Phase 7 —— 前端:派活链路流程图弹窗(横向 / 大弹窗 / 动画) + +**目标**:大弹窗,横向排列的智能体派活链路。节点 = 席位(图标 + 名称),不同颜色;正在发言的 +节点和它的连接线带加载动画;含「查看」按钮。 + +**涉及文件(新增)** +- `src/roundtable-planning/components/DispatchChainModal.tsx` + +**实现选型**:链路是「总控 → 席位序列」的线性结构 + 需要对**单个节点和单条连线**做精确动画, +**推荐手写 flex 横向布局 + CSS 动画连接线**(轻、可控)。项目已装 `@xyflow/react@12`(带 `animated` +边)作为备选,但手写对本场景的「节点 loading + 连线流动」更直接。 + +**结构(手写方案草图)** +```tsx + + {/* 大弹窗 */} + … 任务标题 + 阶段标签(leader_thinking/dispatching/report_drawing…) … + + {/* 横向链路:总控 ──▶ 席位1 ──▶ 席位2 ──▶ … ──▶ [共识] ──▶ [报告绘制] */} +
+
+ + {nodes.map((n,i)=>( + + {/* 活动连线:流动虚线动画 */} + {/* 活动节点:彩色+脉冲光环 */} + + ))} + + + + +
+
+ + + 轮次 {cycle}/8 · {statusLabel} + {/* Phase 8 */} + +
+
+``` + +**节点颜色**:`avatarType = getAvatarTypeFor(agentId, order)` → `chainTagClasses(avatarType)` +(复用 `lib/business-chain.ts:56-72` + `lib/roundtable-constants.ts`),与业务链条卡片同一套色。 + +**节点状态样式** +- `pending`:灰色描边、低透明度。 +- `active`:席位色填充 + 外圈 `animate-ping`/脉冲光环 + 图标旋转或呼吸。 +- `done`:席位色实心 + 右上角 ✓。 + +**连接线加载动画(active 连线)**:CSS 流动虚线(marching-ants)或渐变扫光: +```css +/* roundtable-planning.css 追加 */ +@keyframes chain-flow { to { background-position: 20px 0; } } +.chain-connector--active { + background-image: linear-gradient(90deg, var(--brand-500) 50%, transparent 50%); + background-size: 20px 100%; + animation: chain-flow 0.6s linear infinite; +} +``` + +**数据来源**:弹窗 mount 时 `getJob(jobId)` 取首帧,随后 `streamJob(jobId, onEvent)` 实时更新 +`nodes`/`activeAgentId`/`phase`/`consensus`/`cycle`;`done` 事件后停止动画、全节点置 `done`。 +弹窗关闭时 `unsubscribe`。 + +**验收**:弹窗够大、横向滚动;活动席位节点和它左侧连线在动;阶段推进时高亮顺移;`done` 后静止。 + +--- + +## Phase 8 —— 前端:「查看」进入进行中任务并实时观察 + +**目标**:弹窗「查看」→ 进入该任务,停在 Step2 实时看后台进度(气泡/活动席位随 SSE 刷新), +跑完自动呈现 Step3 结果。 + +**涉及文件(改)** +- `RoundtablePlanningPage.tsx`(接 `onView`) +- `useDraftPersistence.ts`(`handleLoadDraft` 增「带作业」分支) +- `useStep2Orchestration.ts`(观察者渲染) + +**步骤** +1. `onView`:关弹窗 → `handleLoadDraft(draftId)` 恢复该草稿到 Step2(`useDraftPersistence.ts:308-368`), + 但**跳过本地编排自启**(识别到有 running 作业 → 进观察者模式,对齐 Phase 5 的互斥标记)。 +2. 订阅 `streamJob(jobId)`,把 `dialogue` 事件 append 进 `step2RoundtableDialogues`、`active` 事件 + 驱动 `activeSpeakerId` 高亮、`consensus` 更新百分比。 +3. 命中 `awaiting_input`:在 Step2 弹出澄清输入(复用现有 `pendingClarification` UI),用户回答 → + `resumeJob(jobId, answer)`(而非本地 `submitClarification`)。 +4. `done` 事件:用返回的 `DraftStep3Snapshot` 走 `hydrateStep3` + 允许切到 Step3 查看报告 + (`useStep3Report.hydrateSaved` `708-767`);作业列表该项变「已完成」。 + +**验收**:从历史「查看」进入能实时看到后台在跑;澄清能在前端回答并续跑;跑完能看到最终报告。 + +--- + +## Phase 9 —— 边界、健壮性与收尾 + +1. **双跑互斥**:任一 draft 同一时刻只允许一个执行者(前端本地 or 后台作业)。加载草稿/进入 Step2 + 前先 `GET /api/roundtable-jobs?draftId=`,有 running 就进观察者模式。 +2. **后台命中澄清**:`awaiting_input` 时历史下拉显示「待澄清」徽标;流程图弹窗显示「等待澄清」; + 只有「查看」进去才能回答(或在弹窗里直接给一个回答框,调用 `resumeJob`)。 +3. **取消**:流程图弹窗/Step2 提供「停止后台」→ `cancelJob`,`status→cancelled`,前端可恢复本地编辑。 +4. **错误恢复**:`error` 事件展示 detail;允许「重试」= 重新 `startJob`(沿用已建 threadIds)。 +5. **MAX_CYCLES / 预算**:后台图内兜底(对齐前端 `MAX_CYCLES=8`),到顶强制收敛并照常进 Step3。 +6. **多 worker**:严格遵守 Phase 3 的「状态进 DB+checkpointer、SSE 走 loopback」;部署若多 worker + 需 sticky 或全靠 DB 态(推荐后者)。 +7. **清理**:`roundtable_jobs` cleanup cron(活跃保护、先删 CP 再删 DB),与 draft 生命周期对齐。 +8. **历史列表性能**:draft 列表 join 作业 status 时注意 N+1;用一次 LEFT JOIN 取最新作业。 + +--- + +## 端到端验证清单(提测前逐条过) + +- [ ] Step2 点「后台挂起」→ 立刻可离开页面 / 切到别的任务,后台继续跑。 +- [ ] 历史下拉里该任务显示加载动画(转圈)。 +- [ ] 点击进行中任务 → 大弹窗、横向派活链路、活动节点+连线在动、节点不同颜色。 +- [ ] 阶段推进(leader→派活→共识→报告绘制)时弹窗高亮顺移。 +- [ ] 弹窗「查看」→ 进入任务实时观察气泡/活动席位。 +- [ ] 后台命中澄清 → 列表「待澄清」+ 弹窗提示 + 可回答续跑(`resumeJob`)。 +- [ ] 一路自动跑到 Step3「绘制完成」,最终报告可在 Step3 查看,作业 `done`。 +- [ ] 浏览器刷新 / 关页重开后,任务仍在后台跑、状态可恢复(真后台验证)。 +- [ ] 取消后台 → `cancelled`,无残留双跑。 +- [ ] 多 worker(或杀 worker)下 stream/resume 不丢会话。 +- [ ] cleanup 干跑不误删活跃作业。 + +--- + +## 工作量与排期建议 + +| 阶段 | 内容 | 粗估 | 依赖 | +|------|------|------|------| +| P0 | 数据契约定型(本文 §2) | 0.5d | — | +| P1 | 后端作业表 + repo + 迁移 | 1d | P0 | +| **P2** | **后端编排图(移植循环)** | **3–5d(核心难点)** | P0,P1 | +| P3 | 后端控制 + SSE + cleanup | 1.5d | P1,P2 | +| P4 | 前端作业 API + 类型 + draft status | 0.5d | P3 | +| P5 | Step2 后台挂起按钮 + 互斥 | 1d | P4 | +| P6 | 历史下拉 loading 动画 | 0.5d | P4 | +| P7 | 派活链路流程图弹窗 | 1.5d | P4 | +| P8 | 「查看」实时观察 + 澄清续跑 | 1.5d | P5,P7 | +| P9 | 边界/健壮性/清理 | 1d | 全部 | + +> 建议先打通 **P1→P2→P3** 的后端最小闭环(脚本验证图能后台跑到出报告),再做前端 P4–P8。 +> P2 是整条链路成败的关键,务必先用脱离前端的探针脚本(仿 `scripts/probe_ai_writing_assistant.py`)跑通。 + +--- + +## 附:关键参考代码索引 + +- 前端编排循环(待移植到后端):`useStep2Orchestration.ts:992-1204,1228-1439,1468-1541` +- 共识判定 / 进 Step3:`useStep2Orchestration.ts:1091-1099`、`Step2Panel.tsx:370-381`、`RoundtablePlanningPage.tsx:586-595` +- Step3 绘制完成标志:`useStep3Report.ts:51,165,560-596,636-652` +- 草稿模型 / API / 自动保存 / 加载恢复:`api/drafts.ts:25-110,184-225`、`useDraftPersistence.ts:248-316,308-368` +- 历史下拉 UI:`RoundtablePlanningPage.tsx:771-892` +- 后端单轮执行 / 派活 / 广播:`multi_agent.py:647-823,1076-1142,525-552,487-502`、loopback `271-293` +- AI 写作模板:`agents/ai_writing/graph.py`、`ai_writing_sessions/model.py:14-39`、`ai_writing.py`、`ai_writing_cleanup.py:307-367`、`AIWritingPage.tsx` 文件头注释 +- 颜色体系复用:`lib/business-chain.ts:56-72`、`lib/roundtable-constants.ts`(`getAvatarTypeFor`) +- 流程图备选库:`@xyflow/react@12`(已安装) +``` 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 0628cf72..726c5196 100644 --- a/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx +++ b/frontend-web/src/components/workspace/artifacts/artifact-file-detail.tsx @@ -44,18 +44,23 @@ import { useArtifactContent, useUpdateArtifactContent, } from "@/core/artifacts/hooks"; -import { urlOfArtifact } from "@/core/artifacts/utils"; +import { resolveArtifactURL, urlOfArtifact } from "@/core/artifacts/utils"; import { useI18n } from "@/core/i18n/hooks"; import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx"; import { installSkill } from "@/core/skills/api"; import { streamdownPlugins } from "@/core/streamdown"; -import { checkCodeFile, getFileName } from "@/core/utils/files"; -import { - createMarkdownAnchorResolver, - injectInPageAnchorHandler, - isInPageAnchor, - scrollToInPageAnchor, -} from "@/core/utils/markdown"; +import { + checkCodeFile, + getFileName, + isImageFile, +} from "@/core/utils/files"; +import { + createMarkdownAnchorResolver, + injectInPageAnchorHandler, + isInPageAnchor, + scrollToInPageAnchor, +} from "@/core/utils/markdown"; +import { getBackendBaseURL } from "@/core/config"; import { env } from "@/env"; import { desensitize } from "@/lib/desensitize"; import { cn } from "@/lib/utils"; @@ -98,29 +103,36 @@ 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(); const { isMock, thread, publicShareCode } = useThread(); - const isWriteFile = useMemo(() => { + const isWriteFileVirtual = useMemo(() => { return filepathFromProps.startsWith("write-file:"); }, [filepathFromProps]); const filepath = useMemo(() => { - if (isWriteFile) { + if (isWriteFileVirtual) { const url = new URL(filepathFromProps); return decodeURIComponent(url.pathname); } return filepathFromProps; - }, [filepathFromProps, isWriteFile]); + }, [filepathFromProps, isWriteFileVirtual]); + /** write_file 流式预览走 tool args 文本;图片等二进制产物始终走 artifacts API。 */ + const useWriteFileStreaming = useMemo(() => { + return isWriteFileVirtual && !isImageFile(filepath); + }, [filepath, isWriteFileVirtual]); const isSkillFile = useMemo(() => { return filepath.endsWith(".skill"); }, [filepath]); const { isCodeFile, language } = useMemo(() => { - if (isWriteFile) { + if (useWriteFileStreaming) { let nextLanguage = checkCodeFile(filepath).language; nextLanguage ??= "text"; return { isCodeFile: true, language: nextLanguage }; @@ -129,18 +141,21 @@ export function ArtifactFileDetail({ return { isCodeFile: true, language: "markdown" }; } return checkCodeFile(filepath); - }, [filepath, isWriteFile, isSkillFile]); + }, [filepath, useWriteFileStreaming, isSkillFile]); const isSupportPreview = useMemo(() => { return language === "html" || language === "markdown"; }, [language]); const { content, isApplyingEdit } = useArtifactContent({ threadId, filepath: filepathFromProps, - enabled: isCodeFile && !isWriteFile, + enabled: isCodeFile && !useWriteFileStreaming, }); const canEditArtifact = - language === "markdown" && !isWriteFile && !isSkillFile && !isMock; + language === "markdown" && + !useWriteFileStreaming && + !isSkillFile && + !isMock; const [draftContent, setDraftContent] = useState(""); const [hasLocalEdits, setHasLocalEdits] = useState(false); const updateArtifact = useUpdateArtifactContent(); @@ -148,12 +163,13 @@ export function ArtifactFileDetail({ const isHtmlStreaming = language === "html" && thread.isLoading; const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1; const fileTypeLabel = useMemo(() => { + if (isImageFile(filepath)) return "IMG"; if (language === "html") return "HTML"; if (language === "markdown") return "MD"; if (isSkillFile) return "SKILL"; if (language) return language.toUpperCase(); return "FILE"; - }, [isSkillFile, language]); + }, [filepath, isSkillFile, language]); const [viewMode, setViewMode] = useState<"code" | "preview">("code"); const [allowAutoViewSwitch, setAllowAutoViewSwitch] = useState(true); @@ -463,7 +479,7 @@ export function ArtifactFileDetail({ {fileTypeLabel} - {isWriteFile ? ( + {useWriteFileStreaming ? (
{getFileName(filepath)}
) : hasMultipleArtifacts ? (