Files
deerflow-code/offline-backend-20260512/backend/app/gateway/routers/roundtable_chains.py
T
fengxiangdebaoziandClaude Opus 4.8 3907034a1d feat(roundtable): 第二步席位执行模式(快速/思考/专业问答/多智能体)+ 子智能体任务卡片 + 后台完成跨页通知 + 链条默认模式持久化
- 席位执行模式:选择参会智能体弹窗加模式选择器,映射 thinking/reasoning/subagent
  透传 recommend/chain/dag 三条编排;后端 _special_run 覆盖 seat policy(仅席位,
  总控不变);ultra 解锁席位 task 子代理(custom 流回传 task_running 实时进度)。
- 子智能体任务卡片:复用主聊天 SubtaskCard + SubtaskContext,从席位流解析 task
  调用/结果,在 Step2 气泡渲染多张任务卡片(覆盖业务链条路径)。
- 后台挂起也按所选模式跑(StartParams→inprocess gateway→run_seat 覆盖 policy)。
- 后台作业完成全工作区跨页通知:RoundtableJobNotifier 挂在 PageLayout 常驻层,
  作业终态弹「达成共识」同款浮窗。
- 业务链条可配「默认执行模式」并持久化(roundtable_chains.seat_mode;选链时弹窗
  默认用它,仍可临时改)。
- 修复后台 Step3 HTML 看板概率截断:deepseek max_tokens=4096 单轮写不完长 HTML,
  产物自愈重试上限 2→5,让 append 续写写完整(同惠及 summary/dashboard)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 18:20:20 +08:00

422 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CRUD API for roundtable-planning business chains (业务链条).
A business chain is a named, reusable, *ordered* list of agents the user
assembles in the chain editor. In Step 2 the coordinator dispatches to the seats
following this order. Bound to the requesting user (not the browser), so it
follows the account across devices/sessions — same model as roundtable drafts.
Routes (prefix ``/api/roundtable-chains``):
GET "" list current user's chains (full records, incl. seats)
POST "" create a chain
GET "/{chain_id}" one chain
PUT "/{chain_id}" partial update (title / description / seats)
DELETE "/{chain_id}" delete
"""
from __future__ import annotations
import logging
from datetime import datetime
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request
from langchain_core.messages import HumanMessage, SystemMessage
from pydantic import BaseModel, Field
from app.gateway.deps import get_config, get_local_provider
from deerflow.config.app_config import AppConfig
from deerflow.models import create_chat_model
from deerflow.runtime.user_context import get_effective_user_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/roundtable-chains", tags=["roundtable-chains"])
# Seat-count bounds mirror the roundtable selection bounds (2 ≤ N ≤ 8).
MIN_SEATS = 2
MAX_SEATS = 8
# ── schemas ────────────────────────────────────────────────────────────────
class ChainSeatSchema(BaseModel):
agent_id: str = Field(min_length=1, max_length=128)
name: str = ""
description: str = ""
class ChainResponse(BaseModel):
id: str
title: str = ""
description: str | None = None
seats: list[ChainSeatSchema] = []
# Optional layered orchestration: list of stages, each a list of agent_ids
# run in parallel (stages run serially). None/absent = linear chain.
stages: list[list[str]] | None = None
# Optional per-stage goals aligned to ``stages`` (dag-only). None = no goals.
stage_goals: list[str | None] | None = None
# 写给总控的「编排提示」(dag-only):告诉总控怎么派活、做什么。None/空 = 不注入。
coordinator_prompt: str | None = None
# 默认席位执行模式(flash/thinking/pro/ultra);None = 未设,选入研讨时回退默认(pro)。
seat_mode: str | None = None
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
is_public: bool = False
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
is_mine: bool = True
# 配置者(owner)的 user_id 及解析后的可读用户名(email)。owner_name 由路由层
# 用 LocalAuthProvider 解析;解析失败时为 None,前端回退到短 id 展示。
owner_id: str | None = None
owner_name: str | None = None
created_at: datetime | str | None = None
updated_at: datetime | str | None = None
class ChainListResponse(BaseModel):
chains: list[ChainResponse]
class ChainCreateRequest(BaseModel):
# Optional client-provided id so the browser can keep one stable id across
# the create + subsequent updates without an extra round-trip.
id: str | None = Field(default=None, max_length=64)
title: str = Field(default="", max_length=512)
description: str | None = Field(default=None, max_length=2048)
seats: list[ChainSeatSchema] = []
stages: list[list[str]] | None = None
stage_goals: list[str | None] | None = None
coordinator_prompt: str | None = Field(default=None, max_length=4000)
seat_mode: str | None = Field(default=None, max_length=16)
class ChainUpdateRequest(BaseModel):
# All optional → callers send only what changed.
title: str | None = Field(default=None, max_length=512)
description: str | None = Field(default=None, max_length=2048)
seats: list[ChainSeatSchema] | None = None
stages: list[list[str]] | None = None
stage_goals: list[str | None] | None = None
coordinator_prompt: str | None = Field(default=None, max_length=4000)
seat_mode: str | None = Field(default=None, max_length=16)
# 发布开关(True 发布为公共 / False 取消发布);仅 owner 可改(update 本就 owner 限定)。
is_public: bool | 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()
async def _enrich_owner_names(rows: list[dict]) -> None:
"""把每行的 ``owner_id`` 解析成可读用户名(email),写入 ``owner_name``。
与 knowledge 路由的 ``_enrich_actor_names`` 同思路:批量解析、按 id 缓存、best-effort
(解析失败 / provider 不可用时 ``owner_name`` 留空,前端回退到短 id)。
"""
try:
provider = get_local_provider()
except Exception:
return
cache: dict[str, str | None] = {}
for row in rows:
uid = row.get("owner_id")
if not uid:
row["owner_name"] = None
continue
if uid not in cache:
try:
user = await provider.get_user(uid)
cache[uid] = user.email if user else None
except Exception:
cache[uid] = None
row["owner_name"] = cache[uid]
def _get_store(request: Request):
store = getattr(request.app.state, "roundtable_chain_store", None)
if store is None:
raise HTTPException(status_code=503, detail="Roundtable chain store not available")
return store
def _validate_seats(seats: list[ChainSeatSchema]) -> None:
"""Enforce 2 ≤ N ≤ 8 and reject duplicate agent ids within a chain."""
if len(seats) < MIN_SEATS or len(seats) > MAX_SEATS:
raise HTTPException(
status_code=400,
detail=f"业务链条席位数须在 {MIN_SEATS}–{MAX_SEATS} 之间(当前 {len(seats)})",
)
seen: set[str] = set()
for s in seats:
if s.agent_id in seen:
raise HTTPException(status_code=400, detail=f"业务链条存在重复智能体: {s.agent_id}")
seen.add(s.agent_id)
def _validate_stages(seats: list[ChainSeatSchema], stages: list[list[str]] | None) -> None:
"""Validate the optional layering. ``stages`` must partition the seats.
Rules (None = linear chain, always valid):
- at least one stage, no empty stage;
- every agent_id in a stage must be one of the chain's seats;
- no agent_id appears twice (within or across stages);
- every seat must appear in exactly one stage (full coverage), so the
stored layering and the seat list never drift apart.
"""
if stages is None:
return
if len(stages) == 0:
raise HTTPException(status_code=400, detail="编排分层不能为空(至少一个阶段)")
seat_ids = {s.agent_id for s in seats}
seen: set[str] = set()
for idx, stage in enumerate(stages):
if not stage:
raise HTTPException(status_code=400, detail=f"第 {idx + 1} 阶段为空,请删除或放入席位")
for agent_id in stage:
if agent_id not in seat_ids:
raise HTTPException(
status_code=400,
detail=f"编排分层包含不在席位列表中的智能体: {agent_id}",
)
if agent_id in seen:
raise HTTPException(
status_code=400,
detail=f"智能体 {agent_id} 在编排分层中重复出现,每个席位只能跑一次",
)
seen.add(agent_id)
if seen != seat_ids:
missing = seat_ids - seen
raise HTTPException(
status_code=400,
detail=f"编排分层未覆盖全部席位,缺少: {', '.join(sorted(missing))}",
)
# Per-goal length cap to keep injected prompts bounded.
MAX_STAGE_GOAL_CHARS = 1000
def _validate_stage_goals(
stages: list[list[str]] | None, stage_goals: list[str | None] | None
) -> None:
"""Validate the optional per-stage goals (None = no goals, always valid).
Rules:
- goals only make sense alongside a non-null layering (dag-only): reject
goals sent without ``stages``;
- length must match ``stages`` exactly (positional alignment);
- each goal (when present) must be ≤ ``MAX_STAGE_GOAL_CHARS`` chars.
"""
if stage_goals is None:
return
if stages is None:
raise HTTPException(status_code=400, detail="阶段目标需与编排分层一起提交(线性链不支持阶段目标)")
if len(stage_goals) != len(stages):
raise HTTPException(
status_code=400,
detail=f"阶段目标数量({len(stage_goals)})与编排分层阶段数({len(stages)})不一致",
)
for idx, goal in enumerate(stage_goals):
if goal is not None and len(goal) > MAX_STAGE_GOAL_CHARS:
raise HTTPException(
status_code=400,
detail=f"第 {idx + 1} 阶段目标过长(上限 {MAX_STAGE_GOAL_CHARS} 字)",
)
_VALID_SEAT_MODES = {"flash", "thinking", "pro", "ultra"}
def _validate_seat_mode(seat_mode: str | None) -> None:
"""席位执行模式只能是 flash/thinking/pro/ultra 或 None(未设)。"""
if seat_mode is not None and seat_mode not in _VALID_SEAT_MODES:
raise HTTPException(
status_code=400,
detail=f"非法席位执行模式: {seat_mode}(应为 {'/'.join(sorted(_VALID_SEAT_MODES))} 之一)",
)
# ── routes ───────────────────────────────────────────────────────────────
@router.get("", response_model=ChainListResponse)
async def list_chains(request: Request, scope: str = "mine") -> ChainListResponse:
"""列出业务链条。
``scope`` 查询参数(筛选):
- ``mine``(默认):仅当前用户自己的链条;
- ``public``:所有**已发布**的公共链条(任何账号均可查看 / 选入研讨)。
其余值按 ``mine`` 处理。
"""
normalized_scope = scope if scope in ("mine", "public") else "mine"
store = _get_store(request)
user_id = _current_user_id(request)
rows = await store.list_chains(user_id, scope=normalized_scope)
await _enrich_owner_names(rows)
return ChainListResponse(chains=[ChainResponse(**r) for r in rows])
@router.post("", response_model=ChainResponse, status_code=201)
async def create_chain(request: Request, body: ChainCreateRequest) -> ChainResponse:
_validate_seats(body.seats)
_validate_stages(body.seats, body.stages)
_validate_stage_goals(body.stages, body.stage_goals)
_validate_seat_mode(body.seat_mode)
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.create_chain(
user_id,
{
"id": (body.id or uuid4().hex),
"title": body.title.strip(),
"description": body.description,
"seats": [s.model_dump() for s in body.seats],
"stages": body.stages,
"stage_goals": body.stage_goals,
"coordinator_prompt": body.coordinator_prompt,
"seat_mode": body.seat_mode,
},
)
await _enrich_owner_names([row])
return ChainResponse(**row)
@router.get("/{chain_id}", response_model=ChainResponse)
async def get_chain(request: Request, chain_id: str) -> ChainResponse:
store = _get_store(request)
user_id = _current_user_id(request)
row = await store.get_chain(chain_id, user_id)
if row is None:
raise HTTPException(status_code=404, detail="Chain not found")
await _enrich_owner_names([row])
return ChainResponse(**row)
@router.put("/{chain_id}", response_model=ChainResponse)
async def update_chain(request: Request, chain_id: str, body: ChainUpdateRequest) -> ChainResponse:
if body.seats is not None:
_validate_seats(body.seats)
# A non-null stages layering can only be validated against seats; the editor
# always saves both together. Reject a layering update that omits seats.
if body.stages is not None:
if body.seats is None:
raise HTTPException(status_code=400, detail="更新编排分层时必须同时提交席位列表")
_validate_stages(body.seats, body.stages)
# Goals align to stages; reject goals sent without a layering, enforce length/size.
_validate_stage_goals(body.stages, body.stage_goals)
_validate_seat_mode(body.seat_mode)
store = _get_store(request)
user_id = _current_user_id(request)
# Distinguish "omitted" from "explicitly sent"; only forward provided fields.
fields = body.model_dump(exclude_unset=True)
row = await store.update_chain(chain_id, user_id, **fields)
if row is None:
raise HTTPException(status_code=404, detail="Chain not found")
await _enrich_owner_names([row])
return ChainResponse(**row)
@router.delete("/{chain_id}", status_code=204)
async def delete_chain(request: Request, chain_id: str) -> None:
store = _get_store(request)
user_id = _current_user_id(request)
deleted = await store.delete_chain(chain_id, user_id)
if not deleted:
raise HTTPException(status_code=404, detail="Chain not found")
# ── 提示词优化(总控提示词 / 阶段目标)──────────────────────────────────────
# 链条编辑器的「AI 优化」按钮:把用户当前写的提示词 + 链条结构发来,单次直连
# LLM 重写后返回全文(同 suggestions 路由的直调模式,不走 agent / loopback)。
class OptimizePromptRequest(BaseModel):
prompt_text: str = Field(min_length=1, max_length=8000, description="用户当前写的提示词原文")
kind: str = Field(default="coordinator", description="coordinator=总控提示词 | stage_goal=阶段目标")
context: str = Field(default="", max_length=8000, description="链条结构等上下文(标题 / 各 Stage 席位 / 目标)")
model_name: str | None = Field(default=None, description="可选模型覆盖,缺省用 config models[0]")
class OptimizePromptResponse(BaseModel):
optimized: str
_OPTIMIZE_SYSTEM_COORDINATOR = (
"你是「多智能体业务链条」的提示词优化专家。用户在业务链条编辑器里给总控(协调)智能体写了一段编排提示词——"
"总控会按它逐阶段给各席位智能体派活;第 3 步的方案总结报告也会把它当作最高优先级口径(决定总结的结构与重点)。\n\n"
"请在保留用户原意的前提下重写这段提示词,让它更明确、更可执行:\n"
"1. 明确派活方式:各阶段先后顺序、每阶段的产出目标、对上一阶段结论的承接要求;\n"
"2. 明确交付口径:各席位交付聚焦本职、避免重复劳动,要求显式给出结论而非泛泛而谈;\n"
"3. 若原文流露了对最终总结的期望(输出结构、重点席位、风格详略),把它整理成明确条目;若没有,不要凭空替用户添加;\n"
"4. 语言精炼、命令式、分点表述。\n\n"
"硬性要求:只输出优化后的提示词全文(纯文本,可用换行与编号),不要任何前后缀说明、不要 markdown 代码块;"
"全程简体中文;总长不超过 800 字。"
)
_OPTIMIZE_SYSTEM_STAGE_GOAL = (
"你是「多智能体业务链条」的提示词优化专家。用户在链条编辑器里为某一个 Stage(阶段)写了「本阶段目标」——"
"总控按它给本阶段的席位派活,并要求本阶段交付承接上一阶段产出。\n\n"
"请在保留用户原意的前提下重写这段阶段目标,让它更明确、更可执行:点明本阶段要产出什么(交付物 / 结论形态)、"
"怎么承接上一阶段产出、本阶段各席位分别聚焦什么(如上下文给了席位名单)。语言精炼,两到四句话即可。\n\n"
"硬性要求:只输出优化后的阶段目标全文(纯文本),不要任何前后缀说明、不要 markdown 代码块;"
"全程简体中文;总长不超过 200 字。"
)
def _extract_llm_text(content: object) -> str:
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for block in content:
if isinstance(block, str):
parts.append(block)
elif isinstance(block, dict) and block.get("type") in {"text", "output_text"}:
text = block.get("text")
if isinstance(text, str):
parts.append(text)
return "\n".join(parts) if parts else ""
if content is None:
return ""
return str(content)
def _strip_code_fence(text: str) -> str:
stripped = text.strip()
if not stripped.startswith("```"):
return stripped
lines = stripped.splitlines()
if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].startswith("```"):
return "\n".join(lines[1:-1]).strip()
return stripped
@router.post("/optimize-prompt", response_model=OptimizePromptResponse)
async def optimize_chain_prompt(
body: OptimizePromptRequest,
config: AppConfig = Depends(get_config),
) -> OptimizePromptResponse:
system = _OPTIMIZE_SYSTEM_STAGE_GOAL if body.kind == "stage_goal" else _OPTIMIZE_SYSTEM_COORDINATOR
user_parts: list[str] = []
if body.context.strip():
user_parts.append(f"【业务链条结构】\n{body.context.strip()}")
user_parts.append(f"【待优化的提示词原文】\n{body.prompt_text.strip()}")
user_parts.append("请输出优化后的提示词全文。")
try:
model = create_chat_model(name=body.model_name, thinking_enabled=False, app_config=config)
response = await model.ainvoke(
[SystemMessage(content=system), HumanMessage(content="\n\n".join(user_parts))],
config={"run_name": "chain_prompt_optimize"},
)
text = _strip_code_fence(_extract_llm_text(response.content))
except Exception as exc: # 模型 / 网络故障 → 502,前端 toast 原样展示
logger.exception("chain prompt optimize failed: %s", exc)
raise HTTPException(status_code=502, detail=f"大模型调用失败:{exc}") from exc
if not text:
raise HTTPException(status_code=502, detail="大模型未返回内容")
return OptimizePromptResponse(optimized=text)