- 结果绘制跟随日/夜间主题(深色按深蓝 UI 规范令牌绘制),提示词注入真实生成时间 - 绘制支持暂停与随时插话(线程存活时免带原料续线程省 token)、失败自动重试一次、草稿主题不匹配提示条 - 总结报告优先执行用户最终总结要求(结构化输出/重点席位可整体替换默认章节) - 业务链条总控提示词与各阶段目标加「AI 优化」按钮(新接口 POST /api/roundtable-chains/optimize-prompt) - 总结报告 flow-json 结构化流程图:流式增量绘制、最新连线蚂蚁线动画、拖拽/缩放/复位/全屏、节点详情弹窗、沙箱切换按钮/无沙箱自动打开 - roundtable-report / roundtable-summary 两个内置智能体 SOUL 更新(双主题、多轮修改允许读底稿、KPI 估算标注、流程数据契约) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
370 lines
17 KiB
Python
370 lines
17 KiB
Python
"""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
|
||
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
|
||
# 发布状态:True = 公共(所有账号可在「公共」筛选查看 / 选用)。
|
||
is_public: bool = False
|
||
# 是否当前用户所有(仅本人可编辑 / 删除 / 发布;公共筛选里别人的链条 is_mine=False)。
|
||
is_mine: bool = True
|
||
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)
|
||
|
||
|
||
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)
|
||
# 发布开关(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()
|
||
|
||
|
||
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} 字)",
|
||
)
|
||
|
||
|
||
# ── 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)
|
||
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)
|
||
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,
|
||
},
|
||
)
|
||
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")
|
||
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)
|
||
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")
|
||
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)
|