"""蓝图、试运行、发布单的接口(docs/design/normal-user-apps.md §3.5、§3.6、§3.10)。 权限在接口里判:共享蓝图要 builder(受管级发布单再要 admin),而且只给拥有者、它发布出来的应用的管理者和管理员 (blueprints/store.can_access),别的搭建者列表里看不到、直接访问 404;个人蓝图要拥有者且能用个人应用 (blueprints/personal.allowed:开了登录的搭建者及以上随时能,使用者和关登录时看管理员开没开 apps.user_personal_apps)。 看不到的蓝图一律当不存在(404)。 - 起点四个,都落到同一个蓝图(N1):conversation(空蓝图,另建一段蓝图对话)、template(按模板预置卡片)、run(问数据 的一轮回答:按 /copilot/from-run 同一个提取规则取出执行路径,模板记「自由形」)、workflow_version(已发布的版本: 图原样导入,模板记「自由形」,蓝图就挂在那个工作流上)。 - 卡片上的修改(PATCH)走 bp_* 操作(blueprints/store.apply_ops_and_save),来源记成 user。 - 实例化、试运行、预览调 blueprints/trial;发布单调 blueprints/publish_plan。 """ from __future__ import annotations import dataclasses from typing import Any, Literal from fastapi import APIRouter, Depends, Header, HTTPException from fastapi.responses import JSONResponse from pydantic import BaseModel, Field from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from app.api.coded import CodedHTTPException from app.api.deps import Principal, actor_of, current_principal from app.db.base import get_session from app.db.models_blueprints import Blueprint router = APIRouter(prefix="/api", tags=["blueprints"]) #: 机器码 NO_ACCESS = "blueprint_forbidden" BAD_SOURCE = "blueprint_source_invalid" ADMIN_ONLY = "admin_only" #: 没选模板、从已有的东西导入时记的模板 FREEFORM = "freeform" #: 蓝图对话(Conversation.kind) CONVERSATION_KIND = "blueprint" DEFAULT_NAME = "新应用" #: 蓝图列表最多列几张 _LIST_MAX = 200 # -------------------------------------------------------------------------- # 身份、取蓝图、判权限 # -------------------------------------------------------------------------- def _principal(x_actor: str | None) -> Principal: """这次请求的身份。关登录时 username 换成自报的署名(X-Actor),蓝图的拥有者、留痕都按它记(同 api/apps.py)。""" principal = current_principal() if principal.mode == "off" and principal.username is None: return dataclasses.replace(principal, username=actor_of(x_actor)) return principal def _require_builder(principal: Principal, what: str = "共享蓝图") -> None: if not principal.at_least("builder"): raise CodedHTTPException(403, f"{what}需要搭建者权限", NO_ACCESS) async def _personal_allowed(session: AsyncSession, principal: Principal) -> None: from app.blueprints.personal import PersonalRefused, ensure_allowed try: await ensure_allowed(session, principal) except PersonalRefused as e: raise CodedHTTPException(e.status_code, e.message, e.code or NO_ACCESS) from e async def _blueprint(session: AsyncSession, blueprint_id: str) -> Blueprint: """取蓝图并判权限(blueprints/store.load,蓝图接口和蓝图助手同一条规则):共享的要搭建者,且是拥有者、它发布出来的 应用的管理者或管理员;个人的要拥有者且能用个人应用。看不到的当不存在(404)。""" from app.blueprints.store import load return await load(session, blueprint_id) async def blueprint_out(session: AsyncSession, bp: Blueprint) -> dict[str, Any]: """接口形状(frontend/src/typesBlueprint.ts 的 Blueprint,blueprints/store.blueprint_view):图本身不下发(只给哈希), 关节只给状态,问题带白话,含要查库的检查(对到的数据源和表在不在)。""" from app.blueprints.store import blueprint_view return await blueprint_view(session, bp) def _refused(e: Any) -> HTTPException: """各服务的拒绝(status_code / message / code)→ HTTP(带 code 的回 {detail, code})。""" if getattr(e, "code", None): return CodedHTTPException(e.status_code, e.message, e.code) return HTTPException(e.status_code, e.message) def _blocked(e: Any) -> JSONResponse: """被检查问题挡住的拒绝(试运行遇到缺项、待填的关节):{detail, code, issues},问题带白话,界面逐条列出来。""" return JSONResponse(status_code=e.status_code, content={ "detail": e.message, **({"code": e.code} if getattr(e, "code", None) else {}), "issues": getattr(e, "issues", None) or []}) # -------------------------------------------------------------------------- # 起点 # -------------------------------------------------------------------------- class SourceIn(BaseModel): kind: Literal["conversation", "template", "run", "workflow_version"] id: str | None = None version: int | None = None class BlueprintIn(BaseModel): source: SourceIn scope: Literal["shared", "personal"] = "shared" name: str | None = Field(default=None, max_length=200) #: 从模板开始时的模板 id(也可以写在 source.id) template: str | None = None def _freeform_ref() -> dict[str, Any]: from app.blueprints.templates import get_template template = get_template(FREEFORM) return {"id": FREEFORM, "version": template.version if template is not None else 1} def _proposed(value: Any) -> dict[str, Any]: """起点带来的一项(运行时的问题、工作流的说明):是从已有的东西里取的,没经用户确认,记「待确认」。""" return {"value": value, "source": "assistant", "status": "proposed", "why": None} @router.post("/blueprints", status_code=201) async def create_blueprint(payload: BlueprintIn, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """起点:{source: {kind: conversation|template|run|workflow_version, id?, version?}, scope: shared|personal, name?}""" from app.blueprints.spec import empty_blueprint from app.blueprints.trial import record_event from app.core.artifact_store import graph_hash principal = _principal(x_actor) if payload.scope == "personal": await _personal_allowed(session, principal) else: _require_builder(principal) spec = empty_blueprint() bp = Blueprint(name=(payload.name or "").strip(), owner=principal.username, scope=payload.scope, status="drafting", source={"kind": payload.source.kind}, updated_by=principal.username) kind = payload.source.kind if kind == "conversation": from app.db.models import Conversation bp.name = bp.name or DEFAULT_NAME conversation = Conversation(kind=CONVERSATION_KIND, created_by=principal.username, title=f"「{bp.name}」的蓝图") session.add(conversation) await session.flush() bp.conversation_id = conversation.id elif kind == "template": spec = _template_spec(spec, payload.template or payload.source.id) bp.name = bp.name or DEFAULT_NAME bp.source = {"kind": kind, "id": spec["template"]["id"]} elif kind == "run": spec, graph = await _from_run(session, spec, payload.source.id, principal) bp.graph, bp.graph_hash = graph, graph_hash(graph) bp.name = bp.name or str((spec.get("purpose") or {}).get("value") or DEFAULT_NAME)[:40] bp.source = {"kind": kind, "id": payload.source.id} else: _require_builder(principal, "从已发布的工作流开始") spec, workflow, row = await _from_version(session, spec, payload.source.id, payload.source.version) bp.graph, bp.graph_hash = row.graph or {}, row.graph_hash or graph_hash(row.graph or {}) bp.workflow_id = workflow.id bp.name = bp.name or workflow.name bp.source = {"kind": kind, "id": workflow.id, "version": row.version} ref = spec.get("template") if isinstance(ref, dict): bp.template_id, bp.template_version = ref.get("id"), ref.get("version") bp.spec = spec session.add(bp) await session.flush() await record_event(session, bp, "create", {"source": bp.source, "scope": bp.scope}, principal=principal) await session.commit() await session.refresh(bp) return await blueprint_out(session, bp) def _template_spec(spec: dict[str, Any], template_id: str | None) -> dict[str, Any]: """从模板开始:按模板预置卡片(选模板走 bp_template,来源记成用户:是用户挑的模板)。""" from app.blueprints.ops import BpRefused, apply_bp_op from app.blueprints.templates import get_template template = get_template(template_id or "") if template is None: raise CodedHTTPException(422, f"没有叫「{template_id or ''}」的场景模板", BAD_SOURCE, field="template") try: return apply_bp_op(spec, {"op": "bp_template", "template": {"id": template.id, "version": template.version}}, source="user") except BpRefused as e: raise CodedHTTPException(422, e.message, e.code) from e async def _from_run(session: AsyncSession, spec: dict[str, Any], run_id: str | None, principal: Principal) -> tuple[dict[str, Any], dict[str, Any]]: """从问数据的一轮回答开始:按「提取成工作流」同一个规则取出这次运行实际走过的路径(api/copilot.extract_run_path), 不建工作流——图存在蓝图上,发布单保存版本时才建。读不到这次运行的人当它不存在。""" from app.api.copilot import PathRefused, extract_run_path from app.api.deps import can_read_run from app.db.models import Run if not run_id: raise CodedHTTPException(422, "从问数据的回答开始,需要说明是哪一次运行", BAD_SOURCE, field="source") run = await session.get(Run, run_id) if run is None or not await can_read_run(principal, run): raise HTTPException(404, "运行记录不存在,可能已被删除") try: path = await extract_run_path(session, run_id) except PathRefused as e: raise HTTPException(e.status_code, e.message) from e question = (run.input or {}).get("question") spec = {**spec, "template": _freeform_ref()} if isinstance(question, str) and question.strip(): spec["purpose"] = _proposed(question.strip()[:500]) return spec, path.graph async def _from_version(session: AsyncSession, spec: dict[str, Any], workflow_id: str | None, version: int | None) -> tuple[dict[str, Any], Any, Any]: """从已发布的工作流版本开始:图原样导入,模板记「自由形」,蓝图挂在这个工作流上(发布单按图哈希复用这一版)。""" from app.apps.versions import is_published, version_row from app.db.models import Workflow workflow = await session.get(Workflow, workflow_id or "") if workflow is None: raise HTTPException(404, "工作流不存在,可能已被删除") wanted = version or workflow.published_version row = await version_row(session, workflow.id, wanted) if wanted else None if row is None or not is_published(row, current=workflow.published_version): raise CodedHTTPException(409, f"「{workflow.name}」{f'v{wanted} ' if wanted else ''}不是在用的发布版本:" "只能从已发布、没退役的版本开始", BAD_SOURCE, field="source") spec = {**spec, "template": _freeform_ref()} if (workflow.description or "").strip(): spec["purpose"] = _proposed(workflow.description.strip()[:500]) return spec, workflow, row # -------------------------------------------------------------------------- # 列表、详情、卡片上的修改 # -------------------------------------------------------------------------- @router.get("/blueprints") async def list_blueprints(scope: Literal["shared", "personal"] | None = None, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """我能读写的共享蓝图(搭建者及以上:自己的、自己管理的应用出自的;管理员全部,blueprints/store.can_access)和我的 个人蓝图(能用个人应用时),按最近改动排。别的搭建者的共享蓝图不列。""" from app.blueprints.personal import allowed from app.blueprints.store import shared_listed principal = _principal(x_actor) rows: list[Blueprint] = [] if scope in (None, "shared") and principal.at_least("builder"): query = select(Blueprint).where(Blueprint.scope == "shared") if principal.mode != "off" and principal.role != "admin": # 候选只有自己的、发布成了应用的(要不要列看应用的管理权,shared_listed 逐个应用核对) query = query.where(or_(Blueprint.owner == (principal.username or ""), Blueprint.app_id.is_not(None))) else: query = query.order_by(Blueprint.updated_at.desc(), Blueprint.id.desc()).limit(_LIST_MAX) rows += await shared_listed(session, principal, list((await session.execute(query)).scalars())) if scope in (None, "personal") and await allowed(session, principal): mine = Blueprint.scope == "personal" if principal.mode != "off": mine = mine & (Blueprint.owner == (principal.username or "")) rows += list((await session.execute(select(Blueprint).where(mine) .order_by(Blueprint.updated_at.desc(), Blueprint.id.desc()) .limit(_LIST_MAX))).scalars()) rows.sort(key=lambda bp: bp.updated_at or bp.created_at, reverse=True) return {"items": [await blueprint_out(session, bp) for bp in rows[:_LIST_MAX]]} @router.get("/blueprints/{blueprint_id}") async def get_blueprint(blueprint_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: from app.blueprints.trial import settle bp = await _blueprint(session, blueprint_id) return await blueprint_out(session, await settle(session, bp)) @router.get("/blueprints/{blueprint_id}/conversation") async def blueprint_conversation(blueprint_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> Any: """蓝图对话的历史(ConversationDetail):权限按蓝图判(同读蓝图),不经问数据的路由——没开问数据的使用者也读得到 自己个人蓝图的对话。蓝图没有对话(从模板、运行开始的)回 404。""" from app.api.conversations import conversation_detail from app.db.models import Conversation bp = await _blueprint(session, blueprint_id) row = await session.get(Conversation, bp.conversation_id) if bp.conversation_id else None if row is None: raise HTTPException(404, "这张蓝图没有对话") return await conversation_detail(session, row) class BlueprintPatch(BaseModel): ops: list[dict[str, Any]] = Field(default_factory=list) name: str | None = Field(default=None, min_length=1, max_length=200) @router.patch("/blueprints/{blueprint_id}") async def patch_blueprint(blueprint_id: str, payload: BlueprintPatch, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """{ops: [bp_* 操作], name?}:用户在卡片上的修改,来源记成 user。一个操作被拒整批不落(422,带 code)。""" from app.blueprints.ops import BpRefused principal = _principal(x_actor) bp = await _blueprint(session, blueprint_id) if payload.name is not None and payload.name.strip() != (bp.name or ""): # 改名也留痕(P2):名字进工作流和应用的名字、口径卡的标题 from app.blueprints.trial import record_event before, bp.name = bp.name, payload.name.strip() bp.updated_by = principal.username await record_event(session, bp, "rename", {"source": "user", "before": before, "after": bp.name}, principal=principal) if payload.ops: from app.blueprints.store import apply_ops_and_save try: await apply_ops_and_save(session, bp, payload.ops, principal.username, principal.verified, "user") except BpRefused as e: await session.rollback() raise CodedHTTPException(422, e.message, e.code) from e await session.commit() await session.refresh(bp) return await blueprint_out(session, bp) # -------------------------------------------------------------------------- # 实例化、试运行、预览 # -------------------------------------------------------------------------- @router.post("/blueprints/{blueprint_id}/instantiate") async def instantiate_blueprint(blueprint_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """按模板实例化:图、关节存到蓝图上,交回应用草稿和问题(缺项、待填的关节,带白话)。不发起运行。""" from app.blueprints.trial import TrialRefused, materialize principal = _principal(x_actor) bp = await _blueprint(session, blueprint_id) try: result = await materialize(session, bp, principal=principal) except TrialRefused as e: raise _refused(e) from e return {"blueprint": await blueprint_out(session, bp), "app_spec": result.app_spec, "problems": result.problems} class TrialIn(BaseModel): inputs: dict[str, Any] = Field(default_factory=dict) @router.post("/blueprints/{blueprint_id}/trial") async def trial(blueprint_id: str, payload: TrialIn, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> Any: """用真数试运行:实例化 → 保存图 → 发起探索运行,交回 {run_id, blueprint, inputs}(inputs 是这次实际用的输入: 看板类补齐了统计期,流程类是示例表单按入口映射换成的图输入)。缺项、待填的关节挡住时回 409 和问题。""" from app.blueprints.trial import TrialRefused, start_trial from app.engine.launch import LaunchRefused principal = _principal(x_actor) bp = await _blueprint(session, blueprint_id) try: run, _ = await start_trial(session, bp, inputs=payload.inputs, principal=principal, actor=principal.username) except TrialRefused as e: if e.issues: return _blocked(e) raise _refused(e) from e except LaunchRefused as e: raise _refused(e) from e return {"run_id": run.id, "blueprint": await blueprint_out(session, bp), "inputs": dict(run.input or {})} @router.get("/blueprints/{blueprint_id}/preview") async def preview(blueprint_id: str, page: str | None = None, run: str | None = None, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """按这次试运行解析应用草稿的一页(agentlab.view/1):印章写「试运行」,流程组件对着这次运行。""" from app.apps.resolve import ViewRefused from app.blueprints.trial import TrialRefused, preview_page principal = _principal(x_actor) bp = await _blueprint(session, blueprint_id) try: return await preview_page(session, bp, page_id=page, run_id=run, principal=principal) except (TrialRefused, ViewRefused) as e: raise _refused(e) from e # -------------------------------------------------------------------------- # 发布单 # -------------------------------------------------------------------------- class PlanIn(BaseModel): level: Literal["published", "governed"] = "published" #: 跑第一次(不给时看板类为真、流程类为假) first_run: bool | None = None #: 钉住项升版时的处置 {别名: recompute|dual|incomparable} dispositions: dict[str, Literal["recompute", "dual", "incomparable"]] = Field(default_factory=dict) async def _shared_for_plan(session: AsyncSession, blueprint_id: str, principal: Principal) -> Blueprint: bp = await _blueprint(session, blueprint_id) if bp.scope != "shared": raise CodedHTTPException(409, "个人蓝图不走发布单:存成你的应用自己用,想给别人用请提交审核", "plan_state") _require_builder(principal, "发布") return bp @router.post("/blueprints/{blueprint_id}/publish-plan") async def publish_plan(blueprint_id: str, payload: PlanIn, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """出发布单(只读预检):{id, steps, summary, problems(带白话、谁来改), ok}。受管级由搭建者出单时,问题里写明要管理员。""" from app.blueprints import publish_plan as plans from app.blueprints.trial import settle principal = _principal(x_actor) bp = await settle(session, await _shared_for_plan(session, blueprint_id, principal)) try: row = await plans.plan(session, bp, level=payload.level, principal=principal, options={"dispositions": payload.dispositions, **({"first_run": payload.first_run} if payload.first_run is not None else {})}) except plans.PlanRefused as e: raise _refused(e) from e return plans.plan_out(row) async def _plan_for(session: AsyncSession, plan_id: str, principal: Principal) -> Any: from app.blueprints import publish_plan as plans try: row = await plans.get_plan(session, plan_id) except plans.PlanRefused as e: raise _refused(e) from e await _shared_for_plan(session, row.blueprint_id, principal) return row @router.get("/publish-plans/{plan_id}") async def get_plan(plan_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: from app.blueprints.publish_plan import plan_out return plan_out(await _plan_for(session, plan_id, _principal(x_actor))) async def _drive(plan_id: str, x_actor: str | None, session: AsyncSession, how: str) -> dict[str, Any]: from app.blueprints import publish_plan as plans principal = _principal(x_actor) row = await _plan_for(session, plan_id, principal) if row.level == "governed" and not principal.at_least("admin"): # 受管级只有管理员能发(Q8,同工作流发布接口):搭建者出的单可以看,执行要管理员 raise CodedHTTPException(403, "以受管级别发布需要管理员权限", ADMIN_ONLY) try: done = await (plans.run if how == "run" else plans.resume)(session, plan_id, principal=principal) except plans.PlanRefused as e: raise _refused(e) from e return plans.plan_out(done) @router.post("/publish-plans/{plan_id}/run") async def run_plan(plan_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """确认后按序执行。某步停下时 status=stopped,那一步写明原因(白话);修好后用「接着做」。""" return await _drive(plan_id, x_actor, session, "run") @router.post("/publish-plans/{plan_id}/resume") async def resume_plan(plan_id: str, session: AsyncSession = Depends(get_session), x_actor: str | None = Header(default=None)) -> dict[str, Any]: """从停下的那一步接着做:已完成的按结果里的 id 核对,不重做。""" return await _drive(plan_id, x_actor, session, "resume")