diff --git a/backend/app/api/blueprints.py b/backend/app/api/blueprints.py new file mode 100644 index 00000000..d5cd1585 --- /dev/null +++ b/backend/app/api/blueprints.py @@ -0,0 +1,71 @@ +"""蓝图、试运行、发布单的接口(docs/design/normal-user-apps.md §3.5、§3.6、§3.10)。 + +权限在接口里判:共享蓝图要 builder(受管级发布单再要 admin),个人蓝图要拥有者且管理员开了 apps.user_personal_apps。 +骨架阶段全部 501,包 B2 实现(CRUD 调 blueprints/ops,试运行调 blueprints/trial,发布单调 blueprints/publish_plan)。 +""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException + +router = APIRouter(prefix="/api", tags=["blueprints"]) + +_TODO = "尚未实现" + + +@router.post("/blueprints", status_code=201) +async def create_blueprint() -> dict[str, Any]: + """起点:{source: {kind: conversation|template|run|workflow_version, id?, version?}, scope: shared|personal, name?}""" + raise HTTPException(501, _TODO) + + +@router.get("/blueprints") +async def list_blueprints() -> dict[str, Any]: + raise HTTPException(501, _TODO) + + +@router.get("/blueprints/{blueprint_id}") +async def get_blueprint(blueprint_id: str) -> dict[str, Any]: + raise HTTPException(501, _TODO) + + +@router.patch("/blueprints/{blueprint_id}") +async def patch_blueprint(blueprint_id: str) -> dict[str, Any]: + """{ops: [bp_* 操作], name?}:用户在卡片上的修改,来源记成 user。""" + raise HTTPException(501, _TODO) + + +@router.post("/blueprints/{blueprint_id}/instantiate") +async def instantiate_blueprint(blueprint_id: str) -> dict[str, Any]: + raise HTTPException(501, _TODO) + + +@router.post("/blueprints/{blueprint_id}/trial") +async def trial(blueprint_id: str) -> dict[str, Any]: + raise HTTPException(501, _TODO) + + +@router.get("/blueprints/{blueprint_id}/preview") +async def preview(blueprint_id: str) -> dict[str, Any]: + raise HTTPException(501, _TODO) + + +@router.post("/blueprints/{blueprint_id}/publish-plan") +async def publish_plan(blueprint_id: str) -> dict[str, Any]: + raise HTTPException(501, _TODO) + + +@router.get("/publish-plans/{plan_id}") +async def get_plan(plan_id: str) -> dict[str, Any]: + raise HTTPException(501, _TODO) + + +@router.post("/publish-plans/{plan_id}/run") +async def run_plan(plan_id: str) -> dict[str, Any]: + raise HTTPException(501, _TODO) + + +@router.post("/publish-plans/{plan_id}/resume") +async def resume_plan(plan_id: str) -> dict[str, Any]: + raise HTTPException(501, _TODO) diff --git a/backend/app/api/copilot_blueprint.py b/backend/app/api/copilot_blueprint.py new file mode 100644 index 00000000..5b28e2ad --- /dev/null +++ b/backend/app/api/copilot_blueprint.py @@ -0,0 +1,25 @@ +"""蓝图助手和填关节的流式接口(docs/design/normal-user-apps.md §2.2、§2.3、§2.11)。 + +- POST /api/copilot/blueprint-stream:蓝图对话。模型输出 bp_* 操作(BlueprintApplier 落地)、look、bp_ask(这一轮就此结束); +- POST /api/copilot/fill-joints-stream:按模板填生成关节(sql、metric_expr)。TemplateApplier 只收 fill_joint、只收模板声明过的 + 关节;不同 group 的关节并行填。 + +都复用 api/copilot_gen.Generation。包 B1 实现。 +""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException + +router = APIRouter(prefix="/api/copilot", tags=["copilot-blueprint"]) + + +@router.post("/blueprint-stream") +async def blueprint_stream() -> Any: + raise HTTPException(501, "尚未实现") + + +@router.post("/fill-joints-stream") +async def fill_joints_stream() -> Any: + raise HTTPException(501, "尚未实现") diff --git a/backend/app/api/files.py b/backend/app/api/files.py new file mode 100644 index 00000000..f10ea5f2 --- /dev/null +++ b/backend/app/api/files.py @@ -0,0 +1,24 @@ +"""文件的上传、下载、删除(docs/design/normal-user-apps.md §3.8)。包 B3 实现。""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException + +router = APIRouter(prefix="/api/files", tags=["files"]) + + +@router.post("", status_code=201) +async def upload() -> dict[str, Any]: + """multipart:file + context({app_id, entry} | {task_id} | {blueprint_id}),交回引用 {file_id, name, size, sha256, type}。""" + raise HTTPException(501, "尚未实现") + + +@router.get("/{file_id}") +async def download(file_id: str) -> Any: + raise HTTPException(501, "尚未实现") + + +@router.delete("/{file_id}", status_code=204) +async def delete(file_id: str) -> None: + raise HTTPException(501, "尚未实现") diff --git a/backend/app/api/personal.py b/backend/app/api/personal.py new file mode 100644 index 00000000..43c389c2 --- /dev/null +++ b/backend/app/api/personal.py @@ -0,0 +1,43 @@ +"""个人应用与审核(docs/design/normal-user-apps.md §3.9)、页面编辑的候选(§2.6),以及记录的逐行查看(§3.7 records_list)。 + +要在 apps.router 之前登记(共用 /api/apps 前缀,路径不冲突)。包 B4 实现个人应用和候选,记录列表由包 B3 实现。 +""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException + +router = APIRouter(prefix="/api", tags=["personal"]) + +_TODO = "尚未实现" + + +@router.post("/blueprints/{blueprint_id}/save-personal") +async def save_personal(blueprint_id: str) -> dict[str, Any]: + raise HTTPException(501, _TODO) + + +@router.post("/blueprints/{blueprint_id}/copy", status_code=201) +async def copy_for_review(blueprint_id: str) -> dict[str, Any]: + raise HTTPException(501, _TODO) + + +@router.post("/apps/{app_id}/refresh") +async def refresh(app_id: str) -> dict[str, Any]: + raise HTTPException(501, _TODO) + + +@router.post("/apps/{app_id}/submit-review") +async def submit_review(app_id: str) -> dict[str, Any]: + raise HTTPException(501, _TODO) + + +@router.get("/apps/{app_id}/candidates") +async def candidates(app_id: str) -> dict[str, Any]: + raise HTTPException(501, _TODO) + + +@router.get("/apps/{app_id}/records") +async def records(app_id: str) -> dict[str, Any]: + raise HTTPException(501, _TODO) diff --git a/backend/app/api/templates.py b/backend/app/api/templates.py new file mode 100644 index 00000000..440f05db --- /dev/null +++ b/backend/app/api/templates.py @@ -0,0 +1,13 @@ +"""场景模板列表(docs/design/normal-user-apps.md §3.3):GET /api/templates。包 B1 实现。""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException + +router = APIRouter(prefix="/api/templates", tags=["templates"]) + + +@router.get("") +async def list_templates() -> dict[str, Any]: + raise HTTPException(501, "尚未实现") diff --git a/backend/app/apps/spec.py b/backend/app/apps/spec.py index d882d277..592f6bd6 100644 --- a/backend/app/apps/spec.py +++ b/backend/app/apps/spec.py @@ -10,7 +10,7 @@ from __future__ import annotations from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator APP_FORMAT = "agentlab.app/1" FORM_FORMAT = "agentlab.form/1" @@ -35,9 +35,16 @@ DISPOSITIONS = ("recompute", "dual", "incomparable") FORM_WIDGETS = ( "text", "textarea", "number", "integer", "date", "datetime", "select", "radio", "multiselect", "checkbox", "principal", "note", "json", + # 文件(docs/design/normal-user-apps.md N9):值是上传后拿回的引用 {file_id, name, size, sha256, type}, + # 字段 type 为 object,只有这个控件能用 object + "file", ) #: 表单字段能用的 type FORM_TYPES = ("string", "number", "integer", "boolean", "array") +#: 文件控件(x-widget: file)的字段类型:只有这个控件能用 object(apps/forms.py 按控件放行) +FILE_FIELD_TYPE = "object" +#: 文件引用的形状:上传后拿回、提交时原样带回 +FILE_REF_KEYS = ("file_id", "name", "size", "sha256", "type") #: 表单字段能用的 format。不支持 pattern:JS 和 Python 的正则语义不同,会出现前端放行、后端拒收 FORM_FORMATS = ("date", "date-time", "email", "uri") #: 表单校验错误的 code @@ -49,12 +56,23 @@ class _Strict(BaseModel): class Pin(_Strict): + """一个钉住项。共享应用钉工作流版本;还没发布的(蓝图试运行时派生的草稿)version 为空,应用检查按 + app.pin_unpublished 拦发布。个人应用(N7)没有工作流:写 blueprint(蓝图 id),version 为空,图在蓝图上。 + workflow_id 和 blueprint 恰好写一个。""" + alias: str - workflow_id: str - version: int + workflow_id: str | None = None + version: int | None = None graph_hash: str level: str | None = None use: Literal["view", "entry", "process"] = "view" + blueprint: str | None = None + + @model_validator(mode="after") + def _one_target(self) -> Pin: + if (self.workflow_id is None) == (self.blueprint is None): + raise ValueError("固定版本要写 workflow_id 或 blueprint 其中一个") + return self class Slot(_Strict): @@ -80,7 +98,8 @@ class Entry(_Strict): class ViewSource(_Strict): - kind: Literal["latest_sealed", "on_demand", "instance"] + # exploratory 只在个人应用里合法(N7):取这个应用、这个拥有者、这张图下最近一次成功封存的探索运行 + kind: Literal["latest_sealed", "on_demand", "instance", "exploratory"] pin: str | None = None # latest_sealed:schedule: | formal from_: str | None = Field(default=None, alias="from") @@ -104,6 +123,8 @@ class Component(_Strict): visible: str | None = None # 可见性表达式里 run / metric / input 指的是哪个视图 scope: str | None = None + # 作者隐藏了它(N6):解析时跳过,编辑状态下照常画成半透明。和可见性表达式不是一回事 + hidden: bool = False children: list[Component] = Field(default_factory=list) diff --git a/backend/app/blueprints/__init__.py b/backend/app/blueprints/__init__.py new file mode 100644 index 00000000..bd0b1897 --- /dev/null +++ b/backend/app/blueprints/__init__.py @@ -0,0 +1,4 @@ +"""普通用户建应用(docs/design/normal-user-apps.md):蓝图、场景模板、实例化、试运行、发布单、个人应用。 + +一条链路两种终点(N1):说需求 → 蓝图 → 模板填关节 → 试运行 →(搭建者)发布单 /(使用者)个人应用。 +""" diff --git a/backend/app/blueprints/instantiate.py b/backend/app/blueprints/instantiate.py new file mode 100644 index 00000000..47468559 --- /dev/null +++ b/backend/app/blueprints/instantiate.py @@ -0,0 +1,32 @@ +"""按模板实例化蓝图(N3、§3.3–§3.4):确定性关节按卡片填,生成关节留给模型(TemplateApplier 的 fill_joint), +实例化后的图和骨架逐节点比对,只许关节位置不同。模型失败时生成关节留空、标「待填」(P3)。 + +包 B1 实现。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class Instantiated: + #: 实例化出来的图(GraphSpec 的 JSON)。生成关节没填的位置是占位 + graph: dict[str, Any] + #: {joint_id: {"status": filled|pending|refused, "by": template|assistant|user, "note"?: …}} + joints: dict[str, Any] + #: 按模板排布的应用草稿(agentlab.app/1),钉住项的 version 为空(还没发布) + app_spec: dict[str, Any] + #: 定时任务参数(看板类)或 None + schedule: dict[str, Any] | None + problems: list[dict[str, Any]] = field(default_factory=list) + + +def instantiate(blueprint: dict[str, Any], *, filled: dict[str, Any] | None = None) -> Instantiated: + """蓝图 + 已填的生成关节({joint_id: value})→ 图、关节状态、应用草稿、定时任务参数。纯函数,不落库。""" + raise NotImplementedError + + +def skeleton_changes(template_graph: dict[str, Any], graph: dict[str, Any], joints: list[Any]) -> list[dict[str, Any]]: + """骨架比对:除关节位置外的任何差异都交回(tpl.skeleton_changed)。""" + raise NotImplementedError diff --git a/backend/app/blueprints/ops.py b/backend/app/blueprints/ops.py new file mode 100644 index 00000000..8adc7358 --- /dev/null +++ b/backend/app/blueprints/ops.py @@ -0,0 +1,28 @@ +"""bp_* 操作的确定性落地(N2、§3.2)。模型(BlueprintApplier)和用户(PATCH /api/blueprints/{id})用同一组操作, +只是来源记成 assistant 或 user。每张卡片有白名单(例如 people[].policy.kind 只能是 any / all / quorum),越界的拒绝并照实交回。 + +包 B1 实现。 +""" +from __future__ import annotations + +from typing import Any + + +class BpRefused(Exception): + """操作被拒:message 是写给人看的一句话,code 是 bp.* 机器码。""" + + def __init__(self, message: str, code: str = "bp.refused") -> None: + super().__init__(message) + self.message = message + self.code = code + + +def apply_bp_op(spec: dict[str, Any], op: dict[str, Any], *, source: str) -> dict[str, Any]: + """把一个 bp_* 操作落到蓝图上,交回新的蓝图(不改入参)。source 是 user / assistant。不合规时抛 BpRefused。""" + raise NotImplementedError + + +def blueprint_issues(spec: dict[str, Any], *, for_: str) -> list[dict[str, Any]]: + """蓝图检查(§3.13 bp.*):for_ = trial(缺项挡试运行)/ publish(待确认项挡发布)/ personal。 + 每条 {code, level, path, message}。""" + raise NotImplementedError diff --git a/backend/app/blueprints/personal.py b/backend/app/blueprints/personal.py new file mode 100644 index 00000000..f5e2f8c2 --- /dev/null +++ b/backend/app/blueprints/personal.py @@ -0,0 +1,31 @@ +"""个人应用(N7、§3.9):使用者自己的应用。图存在蓝图上(不进工作流表)、只有拥有者看得见、永远跑探索运行 +(origin_kind=personal_app,强加 MASK_RESULTS / READ_ONLY)、受每日上限约束;想给别人用就提交审核,搭建者在共享副本上发布。 + +包 B4 实现。 +""" +from __future__ import annotations + +from typing import Any + +#: 管理员开关:使用者能不能建个人应用(apps 设置组里,默认关) +SETTING_KEY = "user_personal_apps" + + +async def save_personal(session: Any, blueprint: Any, *, principal: Any) -> Any: + """校验图满足 chat_policy(节点、工具、无人工、无写入),建或更新 scope=personal 的 App。""" + raise NotImplementedError + + +async def refresh(session: Any, app: Any, *, principal: Any) -> str: + """发起一次个人应用的刷新(探索运行),交回 run_id;受每日上限约束。""" + raise NotImplementedError + + +async def submit_review(session: Any, app: Any, *, to: list[dict[str, str]], principal: Any) -> Any: + """开一条 review 待办派给搭建者。""" + raise NotImplementedError + + +async def copy_for_review(session: Any, blueprint: Any, *, principal: Any) -> Any: + """搭建者处理审核时得到这个蓝图的共享副本(copied_from 指向原蓝图),原个人应用不动。""" + raise NotImplementedError diff --git a/backend/app/blueprints/publish_plan.py b/backend/app/blueprints/publish_plan.py new file mode 100644 index 00000000..12a42204 --- /dev/null +++ b/backend/app/blueprints/publish_plan.py @@ -0,0 +1,39 @@ +"""一次确认发布:发布单(N5、§3.6)。先算出每一步和白话摘要(只读),确认后按序执行、每步留痕,停下后从那一步接着做。 + +步骤的顺序让中途失败的状态无害:定时任务先停用、应用发布后才启用;授权放最后。 + +包 B2 实现。 +""" +from __future__ import annotations + +from typing import Any + +#: 发布单的步骤(顺序即执行顺序) +STEP_KINDS = ( + "save_version", # 保存版本(按图哈希复用已有的) + "preflight", # 全量预检(只读):工作流发布前检查 + 应用检查 + 定时任务预览 + 名录核对 + "publish_workflow", # 发布工作流(受管级只有管理员) + "create_schedule", # 建定时任务(停用) + "create_app", # 建应用、派生、写岗位绑定 + "publish_app", # 发布应用(钉住项升版时按蓝图里的处置) + "enable_schedule", # 启用定时任务 + "grant", # 授权 + "first_run", # 跑第一次(看板类默认勾选) +) +#: 问题的三类(N10 的 who) +PROBLEM_WHO = ("you", "assistant", "admin") + + +async def plan(session: Any, blueprint: Any, *, level: str, principal: Any, options: dict[str, Any] | None = None) -> Any: + """出发布单(只读预检),交回 PublishPlan(已落库,status=planned)。""" + raise NotImplementedError + + +async def run(session: Any, plan_id: str, *, principal: Any) -> Any: + """按序执行;某步失败时 status=stopped 并写明原因。""" + raise NotImplementedError + + +async def resume(session: Any, plan_id: str, *, principal: Any) -> Any: + """从停下的那一步接着做;已完成的步骤按结果里的 id 核对,不重做。""" + raise NotImplementedError diff --git a/backend/app/blueprints/spec.py b/backend/app/blueprints/spec.py new file mode 100644 index 00000000..a9e4c967 --- /dev/null +++ b/backend/app/blueprints/spec.py @@ -0,0 +1,129 @@ +"""应用蓝图 agentlab.blueprint/1(docs/design/normal-user-apps.md §2.2、§3.2)。 + +蓝图由几张「卡片」组成,每一项都是 {value, source, status}: +- source:user(用户填的)/ assistant(助手提的)/ template(模板默认)/ catalog(从数据目录对到的)/ caliber(复用已发布口径卡); +- status:confirmed(已确认)/ proposed(待确认)/ missing(缺)。试运行前「缺」都要补齐,发布前「待确认」都要确认。 + +模型和用户都只能用 bp_* 操作改蓝图(blueprints/ops.py),落地是确定性的。这里只定形状,不做业务校验。 +""" +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + +BLUEPRINT_FORMAT = "agentlab.blueprint/1" +ITEM_SOURCES = ("user", "assistant", "template", "catalog", "caliber") +ITEM_STATUSES = ("confirmed", "proposed", "missing") +#: 卡片(界面上一张卡一个),顺序就是界面上的顺序 +CARDS = ("purpose", "template", "data", "cadence", "people", "fields", "writeback", "access") +#: bp_* 操作。路径是 JSON 指针(/data/0/table) +BP_OPS = ("bp_set", "bp_add", "bp_remove", "bp_ask", "bp_template") + + +class _Strict(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + +class Item(_Strict): + """卡片上的一项:值、来源、状态,可选一句「为什么」(例如为什么对到这张表)。""" + + value: Any = None + source: Literal["user", "assistant", "template", "catalog", "caliber"] = "assistant" + status: Literal["confirmed", "proposed", "missing"] = "missing" + why: str | None = None + + +class TemplateRef(_Strict): + id: str + version: int = 1 + + +class DataItem(_Strict): + """「看哪些数」的一项。table 对到 {source, table};reuse 对到已发布口径卡 {workflow_id, version, node_id, metric_id} + 时实例化成 caliber_from;measure 是白话或简单聚合的提议,不是最终表达式(最终的在关节里)。""" + + id: str + label: Item = Field(default_factory=Item) + unit: Item = Field(default_factory=Item) + table: Item = Field(default_factory=Item) + measure: Item = Field(default_factory=Item) + date: Item = Field(default_factory=Item) + filter: Item = Field(default_factory=Item) + grain: Item = Field(default_factory=Item) + reuse: dict[str, Any] | None = None + + +class Step(_Strict): + """「谁来做」的一步:处理人([{kind: user|group|slot|initiator, name}])、几个人同意算通过、时限、超时给谁。""" + + id: str + label: Item = Field(default_factory=Item) + who: Item = Field(default_factory=Item) + policy: Item = Field(default_factory=Item) # {"kind": any|all|quorum, "m"?: int} + due: Item = Field(default_factory=Item) # ISO 8601 时长,由卡片上的「2 天」「48 小时」换算 + on_timeout: Item = Field(default_factory=Item) # {"action": remind|escalate|reject, "to"?: [...]} + threshold: Item = Field(default_factory=Item) # {"field", "op", "value"}:什么条件下走这一步(金额线) + + +class FieldItem(_Strict): + """「填什么」的一个字段:落到入口表单、待办表单、记录表的列。spec 是 agentlab.form/1 的一个属性。""" + + name: str + spec: Item = Field(default_factory=Item) + sensitive: bool = False + + +class Writeback(_Strict): + capability: Item = Field(default_factory=Item) + mapping: Item = Field(default_factory=Item) # {参数: "form.x" | "metric.y" | 常量} + confirm: Item = Field(default_factory=Item) # 要不要人确认(布尔) + + +class Access(_Strict): + use: list[dict[str, str]] = Field(default_factory=list) + observe: list[dict[str, str]] = Field(default_factory=list) + manage: list[dict[str, str]] = Field(default_factory=list) + + +class Ask(_Strict): + """助手的反问:一次问一件事,带候选。answered 之后留着做历史。""" + + id: str + question: str + options: list[str] = Field(default_factory=list) + path: str | None = None + answer: str | None = None + + +class BlueprintSpec(_Strict): + format: Literal["agentlab.blueprint/1"] = BLUEPRINT_FORMAT + purpose: Item = Field(default_factory=Item) + template: TemplateRef | None = None + data: list[DataItem] = Field(default_factory=list) + cadence: Item = Field(default_factory=Item) # {preset: daily|weekly|monthly, weekday?, day?, time, tz, period} + people: list[Step] = Field(default_factory=list) + fields: list[FieldItem] = Field(default_factory=list) + writeback: Writeback | None = None + access: Access = Field(default_factory=Access) + asks: list[Ask] = Field(default_factory=list) + + +class BpOp(_Strict): + """一个 bp_* 操作。bp_set / bp_add 带 value;bp_remove 只要 path;bp_ask 带 question / options; + bp_template 带 template({id, version})。""" + + op: Literal["bp_set", "bp_add", "bp_remove", "bp_ask", "bp_template"] + path: str | None = None + value: Any = None + question: str | None = None + options: list[str] = Field(default_factory=list) + template: TemplateRef | None = None + + +def parse_blueprint(data: Any) -> BlueprintSpec: + return BlueprintSpec.model_validate(data) + + +def empty_blueprint() -> dict[str, Any]: + return BlueprintSpec().model_dump(mode="json", by_alias=True) diff --git a/backend/app/blueprints/templates/__init__.py b/backend/app/blueprints/templates/__init__.py new file mode 100644 index 00000000..3e566cac --- /dev/null +++ b/backend/app/blueprints/templates/__init__.py @@ -0,0 +1,82 @@ +"""场景模板 agentlab.template/1(docs/design/normal-user-apps.md §2.3、§3.3、§3.4):骨架锁住,关节可填。 + +一个模板是一份登记(和组件目录同一种做法):骨架(带占位的图)、关节、应用骨架、定时任务、卡片约束、说明。 +关节分两种:确定性关节由蓝图直接填,不经模型;生成关节只有 sql 和 metric_expr 两类,模型在 TemplateApplier 下 +只能用 fill_joint 填模板声明过的关节。实例化后的图和骨架逐节点比对,只许关节位置不同(tpl.skeleton_changed)。 + +开发者扩展时,模板定义、实例化、测试三样都要给;check_registry 在启动时核对。 +""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +TEMPLATE_FORMAT = "agentlab.template/1" +#: 首批五个,外加「自由形」(从已有运行或工作流版本导入,没有关节) +TEMPLATE_IDS = ("periodic_board", "approval", "exception_handling", "records", "reconcile_writeback", "freeform") +#: 关节类型(§3.4) +JOINT_KINDS = ("sql", "metric_expr", "fields", "assignee", "policy", "due", "on_timeout", "threshold", "capability", + "contract") +#: 交给模型填的关节类型;其余由蓝图确定性地填 +GENERATED_JOINT_KINDS = ("sql", "metric_expr") + + +@dataclass(frozen=True) +class Joint: + """骨架里允许填的一个位置:node_id 节点的 field(配置里的 JSON 指针,如 /args/sql、/metrics/0/expression)。 + from_card 是确定性关节取自蓝图的哪里(JSON 指针);生成关节为 None。group 相同的生成关节一起填,不同的并行填。""" + + id: str + kind: str + node_id: str + field: str + from_card: str | None = None + group: str | None = None + label: str = "" + + +@dataclass +class TemplateDef: + id: str + version: int + label: str + description: str + #: 给助手看的适用条件(一两句话) + applies_when: str + #: 这个模板用到哪些卡片(blueprints/spec.CARDS 的子集)和各自的约束说明 + cards: dict[str, str] + #: 个人应用(使用者)能不能用、能用哪一部分(None = 不能用) + personal: str | None + #: skeleton(bp) -> (GraphSpec 的 JSON, [Joint]):按蓝图出骨架和关节(关节里的值还没填) + skeleton: Callable[[dict[str, Any]], tuple[dict[str, Any], list[Joint]]] + #: app_skeleton(bp, graph) -> agentlab.app/1:在 derive_app 的结果上按模板排布 + app_skeleton: Callable[[dict[str, Any], dict[str, Any]], dict[str, Any]] | None = None + #: schedule(bp) -> 定时任务参数 | None + schedule: Callable[[dict[str, Any]], dict[str, Any] | None] | None = None + tags: list[str] = field(default_factory=list) + + +_REGISTRY: dict[str, TemplateDef] = {} + + +def register(template: TemplateDef) -> TemplateDef: + if template.id in _REGISTRY: + raise ValueError(f"模板「{template.id}」重复登记") + _REGISTRY[template.id] = template + return template + + +def get_template(template_id: str) -> TemplateDef | None: + return _REGISTRY.get(template_id) + + +def list_templates() -> list[TemplateDef]: + return [_REGISTRY[t] for t in TEMPLATE_IDS if t in _REGISTRY] + [ + t for k, t in _REGISTRY.items() if k not in TEMPLATE_IDS] + + +def check_registry() -> list[str]: + """启动时核对:首批模板都登记了、关节类型都认得。交回问题清单(空 = 没问题)。""" + problems = [f"模板「{t}」没有登记" for t in TEMPLATE_IDS if t not in _REGISTRY] + return problems diff --git a/backend/app/blueprints/trial.py b/backend/app/blueprints/trial.py new file mode 100644 index 00000000..f4406805 --- /dev/null +++ b/backend/app/blueprints/trial.py @@ -0,0 +1,19 @@ +"""试运行(N4、§3.5):跑一次还没发布的图(探索运行,origin_kind=trial),草稿应用对着这次运行解析 +(apps/resolve.resolve_page 的 trial 旁路),印章写「试运行」。流程类的人工步骤派给试运行的人自己,写入走测试环境或演练, +调度不对 trial 的待办做超时升级。 + +包 B2 实现。 +""" +from __future__ import annotations + +from typing import Any + + +async def start_trial(session: Any, blueprint: Any, *, inputs: dict[str, Any] | None, actor: Any) -> str: + """实例化 → 保存图到蓝图 → 发起探索运行,交回 run_id。蓝图状态记 trialed、last_trial_run_id。""" + raise NotImplementedError + + +async def preview_page(session: Any, blueprint: Any, *, page_id: str, run_id: str | None, principal: Any) -> dict[str, Any]: + """按这次试运行解析应用草稿的一页(agentlab.view/1),印章 from=试运行、trial=true。""" + raise NotImplementedError diff --git a/backend/app/db/base.py b/backend/app/db/base.py index f4f3ff18..98e28bc1 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -235,6 +235,10 @@ _COLUMN_MIGRATIONS: list[tuple[str, str, str]] = [ ("mcp_servers", "endpoint_id", "VARCHAR(32)"), ("conversations", "app_id", "VARCHAR(32)"), ("conversations", "created_by", "VARCHAR(100)"), + # 普通用户建应用(docs/design/normal-user-apps.md §3.1):个人应用(scope=personal、owner)和出自哪个蓝图 + ("apps", "scope", "VARCHAR(20) DEFAULT 'shared'"), + ("apps", "owner", "VARCHAR(100)"), + ("apps", "blueprint_id", "VARCHAR(32)"), ] #: 迁移补上的列要建的索引:(索引名, 表, 列或多列)。create_all 不碰已有的表,表上新列的索引也就不会建。 diff --git a/backend/app/db/models.py b/backend/app/db/models.py index b0435b9a..21349730 100644 --- a/backend/app/db/models.py +++ b/backend/app/db/models.py @@ -977,3 +977,6 @@ from app.db.models_identity import AuditEvent, AuthSession, Group, GroupMember, from app.db.models_process import ( # noqa: E402,F401 ActionRecord, NotifyChannel, NotifyDelivery, Task, TaskAssignment, ) +from app.db.models_blueprints import ( # noqa: E402,F401 + Blueprint, BlueprintEvent, PublishPlan, RecordsTable, StoredFile, +) diff --git a/backend/app/db/models_apps.py b/backend/app/db/models_apps.py index a1c4d965..aa1f1d74 100644 --- a/backend/app/db/models_apps.py +++ b/backend/app/db/models_apps.py @@ -33,6 +33,11 @@ class App(Base, TimestampMixin): status: Mapped[str] = mapped_column(String(20), default="active") # active | archived created_by: Mapped[str | None] = mapped_column(String(100), default=None) updated_by: Mapped[str | None] = mapped_column(String(100), default=None) + # 普通用户建应用(N7):personal 是使用者自己的个人应用,只有拥有者看得见、永远跑探索运行、没有应用版本 + scope: Mapped[str] = mapped_column(String(20), default="shared") # shared | personal + owner: Mapped[str | None] = mapped_column(String(100), default=None) + # 出自哪个蓝图(共享应用经发布单生成、个人应用存蓝图时生成) + blueprint_id: Mapped[str | None] = mapped_column(String(32), default=None) class AppVersion(Base): diff --git a/backend/app/db/models_blueprints.py b/backend/app/db/models_blueprints.py new file mode 100644 index 00000000..5f057620 --- /dev/null +++ b/backend/app/db/models_blueprints.py @@ -0,0 +1,134 @@ +"""普通用户建应用(docs/design/normal-user-apps.md §3.1):蓝图、蓝图留痕、发布单、文件、记录表。 + +蓝图(agentlab.blueprint/1)是用户的话和工作流、应用之间的中间件:卡片上每一项都带来源和状态,模型和用户都只能用 +bp_* 操作改它(N2)。发布单把「保存版本 → 发布工作流 → 建应用 → 定时任务 → 授权 → 跑第一次」编成可续做的步骤(N5)。 +文件按 sha256 内容寻址存放、提交时绑到一次运行(N9);记录表是平台托管、只许追加的表(N8)。 +""" +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from sqlalchemy import ForeignKey, Index, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base, TimestampMixin, UTCDateTime, new_id, utcnow + +#: 蓝图的可见范围:个人(使用者自己的,终点是个人应用)、共享(搭建者的,终点是发布单) +BLUEPRINT_SCOPES = ("personal", "shared") +#: 蓝图的状态:起草中 → 试运行过 → 已发布(共享)/ 个人应用(个人);个人应用提交审核时为审核中 +BLUEPRINT_STATUSES = ("drafting", "trialed", "published", "personal", "in_review") +#: 蓝图从哪里来:一段对话、一个模板、一次运行(问数据的一轮回答)、一个已发布的工作流版本 +BLUEPRINT_SOURCES = ("conversation", "template", "run", "workflow_version") +#: 发布单的状态 +PLAN_STATUSES = ("planned", "running", "stopped", "done") + + +class Blueprint(Base, TimestampMixin): + __tablename__ = "blueprints" + + id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) + name: Mapped[str] = mapped_column(String(200), default="") + owner: Mapped[str | None] = mapped_column(String(100), default=None, index=True) + scope: Mapped[str] = mapped_column(String(20), default="shared") + status: Mapped[str] = mapped_column(String(20), default="drafting") + # agentlab.blueprint/1(blueprints/spec.py) + spec: Mapped[dict[str, Any]] = mapped_column(default=dict) + template_id: Mapped[str | None] = mapped_column(String(64), default=None) + template_version: Mapped[int | None] = mapped_column(Integer, default=None) + # {"kind": conversation|template|run|workflow_version, "id": …, "version"?: …} + source: Mapped[dict[str, Any]] = mapped_column(default=dict) + # 蓝图对话(kind=blueprint 的 Conversation) + conversation_id: Mapped[str | None] = mapped_column(String(32), default=None) + # 共享蓝图:实例化出来的工作流和发布出来的应用;个人蓝图:个人应用 + workflow_id: Mapped[str | None] = mapped_column(String(32), default=None, index=True) + app_id: Mapped[str | None] = mapped_column(String(32), default=None, index=True) + last_trial_run_id: Mapped[str | None] = mapped_column(String(32), default=None) + # 最近一次实例化的图(GraphSpec 的 JSON)和它的哈希。个人应用的图只存在这里(使用者没有工作流) + graph: Mapped[dict[str, Any]] = mapped_column(default=dict) + graph_hash: Mapped[str | None] = mapped_column(String(64), default=None) + # 各关节的状态 {joint_id: {"status": filled|pending|refused, "by": assistant|template|user, "note"?: …}} + joints: Mapped[dict[str, Any]] = mapped_column(default=dict) + # 审核副本指向原蓝图 + copied_from: Mapped[str | None] = mapped_column(String(32), default=None) + updated_by: Mapped[str | None] = mapped_column(String(100), default=None) + + +class BlueprintEvent(Base): + """蓝图的留痕:每一次 bp_* 操作(模型提的、用户改的)、每一次实例化和试运行。""" + + __tablename__ = "blueprint_events" + __table_args__ = (UniqueConstraint("blueprint_id", "seq"),) + + id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) + blueprint_id: Mapped[str] = mapped_column(ForeignKey("blueprints.id", ondelete="CASCADE"), index=True) + seq: Mapped[int] = mapped_column(Integer) + actor: Mapped[str | None] = mapped_column(String(100), default=None) + verified: Mapped[bool] = mapped_column(default=False) + # bp_set | bp_add | bp_remove | bp_ask | bp_template | instantiate | fill_joint | trial | publish | save_personal | copy + op: Mapped[str] = mapped_column(String(40)) + data: Mapped[dict[str, Any]] = mapped_column(default=dict) + at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow) + + +class PublishPlan(Base, TimestampMixin): + """一次确认发布(N5)。steps 是 [{kind, status: pending|done|failed|skipped, result, error_code, message, at}], + summary 是白话摘要 [{step, text}];停在某一步时 status=stopped,修好后从那一步接着做。""" + + __tablename__ = "publish_plans" + + id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) + blueprint_id: Mapped[str] = mapped_column(ForeignKey("blueprints.id", ondelete="CASCADE"), index=True) + actor: Mapped[str | None] = mapped_column(String(100), default=None) + level: Mapped[str] = mapped_column(String(20), default="published") + # 发布单针对的那张图(实例化结果的哈希):执行时图变了就拒绝,要求重新出单 + graph_hash: Mapped[str | None] = mapped_column(String(64), default=None) + options: Mapped[dict[str, Any]] = mapped_column(default=dict) + steps: Mapped[list[Any]] = mapped_column(default=list) + summary: Mapped[list[Any]] = mapped_column(default=list) + problems: Mapped[list[Any]] = mapped_column(default=list) + status: Mapped[str] = mapped_column(String(20), default="planned") + + +class StoredFile(Base): + """上传的文件(N9):内容按 sha256 存在 data/files/<前 2 位>/(只读),这里记元数据和绑定。 + 提交表单时绑到一次运行(一次性)或一行记录;没绑定的上传按时清理。""" + + __tablename__ = "files" + __table_args__ = (Index("ix_files_unbound", "bound_run_id", "uploaded_at"),) + + id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) + sha256: Mapped[str] = mapped_column(String(64), index=True) + name: Mapped[str] = mapped_column(String(300)) + size: Mapped[int] = mapped_column(Integer) + mime: Mapped[str] = mapped_column(String(120), default="application/octet-stream") + uploaded_by: Mapped[str | None] = mapped_column(String(100), default=None) + uploaded_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow) + # 上传时的上下文:{"app_id", "entry"} 或 {"task_id"} 或 {"blueprint_id"}(个人应用、试运行) + context: Mapped[dict[str, Any]] = mapped_column(default=dict) + bound_run_id: Mapped[str | None] = mapped_column(String(32), default=None) + # 附在记录上时:{"table_id", "row_id"} + bound_record: Mapped[dict[str, Any] | None] = mapped_column(default=None) + purpose: Mapped[str] = mapped_column(String(40), default="input") + + +class RecordsTable(Base, TimestampMixin): + """记录表(N8)的一个版本:平台托管的 SQLite 文件 data/records/.v.db,只许追加。 + 兼容的表单改动(只加可选字段)在同一版本上 ADD COLUMN;不兼容的出新版本,旧版本只读保留。""" + + __tablename__ = "records_tables" + __table_args__ = (UniqueConstraint("table_key", "version"),) + + id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id) + # 同一张记录表各版本共用的键 + table_key: Mapped[str] = mapped_column(String(64), index=True) + version: Mapped[int] = mapped_column(Integer, default=1) + name: Mapped[str] = mapped_column(String(200)) + app_id: Mapped[str | None] = mapped_column(String(32), default=None, index=True) + blueprint_id: Mapped[str | None] = mapped_column(String(32), default=None) + # 登记成 origin=records 的只读数据源(看板用 db_query__<名字> 查) + source_id: Mapped[str | None] = mapped_column(String(32), default=None) + # [{name, label, type, required, sensitive}] + columns: Mapped[list[Any]] = mapped_column(default=list) + retired_at: Mapped[datetime | None] = mapped_column(UTCDateTime, default=None) + note: Mapped[str] = mapped_column(Text, default="") diff --git a/backend/app/engine/origin.py b/backend/app/engine/origin.py index ed18efb6..dd50ce0a 100644 --- a/backend/app/engine/origin.py +++ b/backend/app/engine/origin.py @@ -22,6 +22,8 @@ ORIGIN_KINDS = ( "app_backfill", # 钉住项按 recompute 升版后,按历次触发的输入补算 "hook", # 外部系统经入站钩子触发 "api", # 直接调接口,没说是哪 + "trial", # 蓝图的试运行(探索运行,docs/design/normal-user-apps.md N4) + "personal_app", # 个人应用的刷新(探索运行,N7) ) #: 客户端在 POST /api/runs 里能声明的来源 diff --git a/backend/app/files/__init__.py b/backend/app/files/__init__.py new file mode 100644 index 00000000..ad60c349 --- /dev/null +++ b/backend/app/files/__init__.py @@ -0,0 +1,25 @@ +"""文件(docs/design/normal-user-apps.md N9、§3.8):按 sha256 内容寻址存放(data/files/<前 2 位>/,只读), +两步上传(先拿引用再提交表单),提交时绑到一次运行(一次性),引用连同 sha256 进运行输入、随封存固定。 +读文件:模板函数 file_text(ref);导入:平台内置能力 table_import_period(按上传源已有的配方导入一期,效果写入)。 +下载按绑定的运行或记录核对读权限。没绑定的上传 24 小时后清理。 + +包 B3 实现。 +""" +from __future__ import annotations + +#: 平台内置能力:把一个文件按上传源已有的配方导入成一期 +TABLE_IMPORT_PERIOD = "table_import_period" +#: 文件类型白名单(待拍板 Q5 按推荐) +ALLOWED_MIME_PREFIXES = ( + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "text/csv", + "text/plain", + "image/png", + "image/jpeg", +) +#: 没绑定的上传多久后清理(秒) +UNBOUND_TTL_S = 24 * 3600 diff --git a/backend/app/main.py b/backend/app/main.py index 3d0f645a..9ebe19bb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,6 +18,11 @@ from app.api import ( actions as actions_api, apps as apps_api, auth as auth_api, capabilities as capabilities_api, hooks as hooks_api, identity as identity_api, ledger as ledger_api, notify as notify_api, tasks as tasks_api, ) +# 普通用户建应用(docs/design/normal-user-apps.md §3.10):蓝图、模板、文件、个人应用。权限在接口里按蓝图的范围和拥有者判 +from app.api import ( + blueprints as blueprints_api, copilot_blueprint, files as files_api, personal as personal_api, + templates as templates_api, +) from app.api.coded import CodedHTTPException, coded_handler from app.api.deps import chat_scope, evidence_scope, require_role, role_by_method, run_scope from app.auth.middleware import AuthMiddleware @@ -221,6 +226,13 @@ for router, guard in ( (identity_api.directory_router, _BUILDER), # 能力登记、端点:搭建者只读,改动在接口里要管理员 (capabilities_api.router, _BUILDER), + # 普通用户建应用:个人应用的接口共用 /api/apps 前缀,要在 apps_api 之前登记;蓝图助手共用 /api/copilot 前缀, + # 在接口里按蓝图的范围判(共享要 builder,个人要拥有者且开了 apps.user_personal_apps) + (personal_api.router, _USER), + (blueprints_api.router, _USER), + (templates_api.router, _USER), + (files_api.router, _USER), + (copilot_blueprint.router, _USER), # 应用、待办、生效记录、通知:各自按应用授权、派给谁再判断 (apps_api.router, _USER), (tasks_api.router, _USER), diff --git a/backend/app/plain.py b/backend/app/plain.py new file mode 100644 index 00000000..8fe10d2c --- /dev/null +++ b/backend/app/plain.py @@ -0,0 +1,46 @@ +"""白话层(docs/design/normal-user-apps.md N10):问题编号 → 一句话、为什么要紧、谁来改、怎么办。 + +覆盖工作流发布门禁(contract.* / governed.* / action.* / process.*)、应用检查(app.*)、蓝图(bp.*)、模板(tpl.*)、 +个人应用(personal.*)、发布单步骤失败(plan.*)。没登记白话的照旧显示技术说法;检查脚本报「有问题编号没有白话」。 +who:you(你来定)/ assistant(助手可以修)/ admin(要找管理员),按自动修复的 kind、APP_HUMAN_ONLY、要管理员的编号判。 + +框架在这里;内容由包 B2 补齐(B1、B3、B4 新增的编号各自补进来)。 +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +WHO = ("you", "assistant", "admin") + + +@dataclass(frozen=True) +class Plain: + title: str + why: str = "" + who: str = "you" + action: str = "" + + +#: 问题编号 → 白话。前缀匹配用 PLAIN_PREFIX(如 "subgraph_version_" 一类) +PLAIN: dict[str, Plain] = {} +PLAIN_PREFIX: dict[str, Plain] = {} + + +def plain_of(code: str | None) -> Plain | None: + if not code: + return None + if code in PLAIN: + return PLAIN[code] + for prefix, plain in PLAIN_PREFIX.items(): + if code.startswith(prefix): + return plain + return None + + +def with_plain(issue: dict[str, Any]) -> dict[str, Any]: + """给一条问题补上 plain = {title, why, who, action};没登记的不补。""" + plain = plain_of(issue.get("code")) + if plain is None: + return issue + return {**issue, "plain": {"title": plain.title, "why": plain.why, "who": plain.who, "action": plain.action}} diff --git a/backend/app/records/__init__.py b/backend/app/records/__init__.py new file mode 100644 index 00000000..f8242cbc --- /dev/null +++ b/backend/app/records/__init__.py @@ -0,0 +1,19 @@ +"""记录表(docs/design/normal-user-apps.md N8、§3.7):平台托管的 SQLite、只许追加。 + +- 每行带系统列 _seq / _id / _submitted_by / _submitted_at / _run_id / _supersedes / _schema_version; +- 登记成 origin=records 的只读数据源,看板照常用 db_query__<名字> 查; +- 写入只经动作:平台内置能力 records_append(效果写入、允许无人值守、幂等键即动作键),查询能力 records_lookup; +- 钉版本:运行开始时 data_versions 记下高水位 _seq;查询连接上建同名 TEMP VIEW,只露出 _seq ≤ 高水位、没被更正过的行; +- 行级可见:同一套临时视图按人过滤(use 只看自己的,observe / manage 看全部);正式运行以服务身份跑、不过滤。 + +包 B3 实现。 +""" +from __future__ import annotations + +#: 数据源的 origin +RECORDS_ORIGIN = "records" +#: 平台内置能力(工具名,只能用字母、数字、下划线) +RECORDS_APPEND = "records_append" +RECORDS_LOOKUP = "records_lookup" +#: 系统列 +SYSTEM_COLUMNS = ("_seq", "_id", "_submitted_by", "_submitted_at", "_run_id", "_supersedes", "_schema_version") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b6fc9d74..f8abf8f7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,9 @@ import { EntryPage } from './pages/apps/EntryPage' import { InstancePage } from './pages/apps/InstancePage' import { TaskPage } from './pages/apps/TaskPage' import { AppEditor } from './pages/apps/edit/AppEditor' +// 普通用户建应用:新建应用的起点、蓝图页(F5) +import { NewAppStart } from './pages/blueprints/NewAppStart' +import { BlueprintPage } from './pages/blueprints/BlueprintPage' import { LoginPage } from './pages/LoginPage' import { Logo } from './components/Logo' import { @@ -175,6 +178,10 @@ export default function App() { } /> {/* 应用:固定路径(inbox)写在 /apps/:appId 前面,读的人一眼看出它不是应用 id */} } /> + {/* 要在 /apps/:appId 之前:create 不是应用 id */} + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/blueprints.ts b/frontend/src/api/blueprints.ts new file mode 100644 index 00000000..6a6beb56 --- /dev/null +++ b/frontend/src/api/blueprints.ts @@ -0,0 +1,58 @@ +/** + * 普通用户建应用的接口(docs/design/normal-user-apps.md §3.10):蓝图、试运行、发布单、模板、文件、个人应用、页面编辑候选、记录。 + * 约定在 typesBlueprint.ts。流式的蓝图助手、填关节走 store 里的 SSE(同 copilot),不在这里。 + */ +import type { AppSpec } from '../typesApp' +import type { + AppCandidate, Blueprint, BlueprintScope, BlueprintSourceKind, BpOp, FileRef, PlainIssue, PublishPlanRow, RecordsPage, + TemplateInfo, TrialPage, TrialStart, +} from '../typesBlueprint' +import { del, get, patch, post, qs, upload, type UploadOptions } from './http' + +export const blueprints = { + create: (body: { source: { kind: BlueprintSourceKind; id?: string; version?: number }; scope: BlueprintScope; name?: string; + template?: string }) => post('/blueprints', body), + list: (q: { scope?: BlueprintScope } = {}) => get<{ items: Blueprint[] }>(`/blueprints${qs(q)}`), + get: (id: string) => get(`/blueprints/${encodeURIComponent(id)}`), + /** 卡片上的修改:bp_* 操作,来源记成 user */ + patch: (id: string, body: { ops?: BpOp[]; name?: string }) => patch(`/blueprints/${encodeURIComponent(id)}`, body), + instantiate: (id: string) => + post<{ blueprint: Blueprint; app_spec: AppSpec; problems: PlainIssue[] }>(`/blueprints/${encodeURIComponent(id)}/instantiate`, {}), + trial: (id: string, body: { inputs?: Record } = {}) => + post(`/blueprints/${encodeURIComponent(id)}/trial`, body), + preview: (id: string, q: { page?: string; run?: string } = {}) => + get(`/blueprints/${encodeURIComponent(id)}/preview${qs(q)}`), + publishPlan: (id: string, body: { level: 'published' | 'governed'; first_run?: boolean }) => + post(`/blueprints/${encodeURIComponent(id)}/publish-plan`, body), + plan: (planId: string) => get(`/publish-plans/${encodeURIComponent(planId)}`), + runPlan: (planId: string) => post(`/publish-plans/${encodeURIComponent(planId)}/run`, {}), + resumePlan: (planId: string) => post(`/publish-plans/${encodeURIComponent(planId)}/resume`, {}), + // ---- 个人应用(N7) + savePersonal: (id: string) => post<{ app_id: string; blueprint: Blueprint }>(`/blueprints/${encodeURIComponent(id)}/save-personal`, {}), + copyForReview: (id: string) => post(`/blueprints/${encodeURIComponent(id)}/copy`, {}), +} + +export const templates = { + list: () => get<{ items: TemplateInfo[] }>('/templates'), +} + +export const files = { + /** 先上传拿引用,再把引用作为表单值提交(N9) */ + upload: (file: File, context: Record, opts?: UploadOptions) => { + const form = new FormData() + form.append('file', file) + form.append('context', JSON.stringify(context)) + return upload('/files', form, opts) + }, + url: (fileId: string) => `/api/files/${encodeURIComponent(fileId)}`, + remove: (fileId: string) => del(`/files/${encodeURIComponent(fileId)}`), +} + +export const personal = { + refresh: (appId: string) => post<{ run_id: string; remaining: number }>(`/apps/${encodeURIComponent(appId)}/refresh`, {}), + submitReview: (appId: string, body: { to: { kind: 'user' | 'group'; name: string }[]; note?: string }) => + post<{ task_id: string }>(`/apps/${encodeURIComponent(appId)}/submit-review`, body), + candidates: (appId: string) => get<{ items: AppCandidate[] }>(`/apps/${encodeURIComponent(appId)}/candidates`), + records: (appId: string, q: { table?: string; cursor?: string; history?: boolean } = {}) => + get(`/apps/${encodeURIComponent(appId)}/records${qs(q)}`), +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 14558002..960078c3 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -28,6 +28,8 @@ import { apps, ledger } from './apps' import { actions, tasks } from './tasks' import { auth, identity } from './auth' import { capabilities, endpoints, notify } from './capabilities' +// 普通用户建应用(typesBlueprint.ts 是接口约定) +import { blueprints, files, personal, templates } from './blueprints' // 请求层搬到了 http.ts(见那边的说明)。原先从这里导出的照旧从这里导出,调用方不用改; // 请求函数也一并转出,给不在 api/ 下、又想直接发请求的地方用。api/ 下的命名空间文件请直接从 http.ts 导入 @@ -865,6 +867,12 @@ export const api = { capabilities, endpoints, notify, + + // ---- 普通用户建应用(typesBlueprint.ts 是接口约定) ---- + blueprints, + templates, + files, + personal, } /** diff --git a/frontend/src/lib/terms.ts b/frontend/src/lib/terms.ts index e09fb09e..5469d568 100644 --- a/frontend/src/lib/terms.ts +++ b/frontend/src/lib/terms.ts @@ -5736,3 +5736,4 @@ export const CAPABILITY_REVIEW_TEXT: typeof CATALOG_REVIEW_TEXT = { // 应用平台使用面的文案(FE-5 维护)。调用方照旧从 lib/terms 导入 export * from './termsApp' +export * from './termsBlueprint' diff --git a/frontend/src/lib/termsBlueprint.ts b/frontend/src/lib/termsBlueprint.ts new file mode 100644 index 00000000..c04cec3c --- /dev/null +++ b/frontend/src/lib/termsBlueprint.ts @@ -0,0 +1,12 @@ +/** + * 普通用户建应用的文案(docs/design/normal-user-apps.md 第四部分):新建应用的起点、蓝图卡片、试运行、发布摘要。 + * 包 F5 独占这个文件;页面编辑、文件控件、记录列表、个人应用的文案在 termsApp.ts(包 F6)。由 terms.ts 统一再导出。 + */ + +export const NEW_APP_TEXT = { + title: '新建应用', +} + +export const BLUEPRINT_TEXT = { + title: '应用蓝图', +} diff --git a/frontend/src/pages/apps/backfill.tsx b/frontend/src/pages/apps/backfill.tsx index 8bb0b749..031a5e37 100644 --- a/frontend/src/pages/apps/backfill.tsx +++ b/frontend/src/pages/apps/backfill.tsx @@ -60,7 +60,8 @@ export function backfillTargets(spec: AppSpec, current: AppVersionRow, previous: if (!alias || v.source.kind === 'instance' || current.dispositions?.[alias] !== 'recompute') continue const now = current.pins.find((p) => p.alias === alias) const before = previous.pins.find((p) => p.alias === alias) - if (!now || !before || before.version === now.version) continue + // 已发布的应用版本里钉住项都有版本号;没有的(个人应用)谈不上升版 + if (!now || !before || now.version == null || before.version == null || before.version === now.version) continue const max = seriesDepth(spec, view) if (max > 0) out.push({ view, alias, from: before.version, to: now.version, max }) } diff --git a/frontend/src/pages/apps/edit/PagesSection.tsx b/frontend/src/pages/apps/edit/PagesSection.tsx index f18fc428..d0156cc1 100644 --- a/frontend/src/pages/apps/edit/PagesSection.tsx +++ b/frontend/src/pages/apps/edit/PagesSection.tsx @@ -361,11 +361,13 @@ function useViewNodes(view: string): { id: string; label: string }[] { const pin = draft.pins.find((p) => p.alias === source?.pin) ?? (draft.pins.length === 1 ? draft.pins[0] : undefined) const [nodes, setNodes] = useState<{ id: string; label: string }[]>([]) useEffect(() => { - if (!pin) { setNodes([]); return } - const key = `${pin.workflow_id}@${pin.version}` + // 还没发布的(试运行派生的草稿)、个人应用的钉住项没有工作流版本可取 + if (!pin || !pin.workflow_id || pin.version == null) { setNodes([]); return } + const { workflow_id: wid, version } = pin + const key = `${wid}@${version}` let p = graphCache.get(key) if (!p) { - p = api.workflows.version(pin.workflow_id, pin.version) + p = api.workflows.version(wid, version) .then((v) => (v.graph?.nodes ?? []).map((n) => ({ id: n.id, label: String(n.data?.label ?? n.id) }))) p.catch(() => graphCache.delete(key)) graphCache.set(key, p) @@ -450,7 +452,7 @@ function ViewsPanel() { )} {v.source.kind === 'latest_sealed' && ( diff --git a/frontend/src/pages/apps/edit/PinsSection.tsx b/frontend/src/pages/apps/edit/PinsSection.tsx index 1e9db229..e224b49d 100644 --- a/frontend/src/pages/apps/edit/PinsSection.tsx +++ b/frontend/src/pages/apps/edit/PinsSection.tsx @@ -76,9 +76,12 @@ export function PinsSection() { setDraft((s) => ({ ...s, pins: s.pins.map((p, j) => (j === i ? { ...p, ...patch } : p)) })) const rederive = async (pin: AppPin) => { + // 还没发布的(试运行派生的草稿)、个人应用的钉住项没有工作流版本可派生 + if (!pin.workflow_id || pin.version == null) return + const { workflow_id: wid, version } = pin if (!(await confirmDialog({ title: T.rederiveTitle, body: T.rederiveBody, confirmLabel: T.rederive, danger: true }))) return try { - const { spec } = await api.apps.derive({ workflow_id: pin.workflow_id, version: pin.version, name: draft.name }) + const { spec } = await api.apps.derive({ workflow_id: wid, version, name: draft.name }) setDraft(() => ({ ...spec, name: draft.name, description: draft.description || spec.description })) toast.ok(T.rederiveDone) } catch (e) { @@ -107,12 +110,12 @@ export function PinsSection() { {(p) => update(i, { alias: e.target.value })} />}
-
{nameOf(pin.workflow_id)}
+
{pin.workflow_id ? nameOf(pin.workflow_id) : '—'}
{T.pinHash(pin.graph_hash || '')}
{() => ( - v && update(i, { version: v.version, graph_hash: v.graph_hash ?? '', level: v.level ?? null })} /> )} diff --git a/frontend/src/pages/blueprints/BlueprintPage.tsx b/frontend/src/pages/blueprints/BlueprintPage.tsx new file mode 100644 index 00000000..82986420 --- /dev/null +++ b/frontend/src/pages/blueprints/BlueprintPage.tsx @@ -0,0 +1,12 @@ +import { useParams } from 'react-router-dom' +import { EmptyState } from '../../components/ui' +import { BLUEPRINT_TEXT } from '../../lib/terms' + +/** + * 蓝图页(docs/design/normal-user-apps.md §4.2–§4.4):左边对话、右边卡片;试运行;发布摘要(搭建者)/ 存成我的应用(使用者)。 + * 骨架阶段是占位,包 F5 实现。 + */ +export function BlueprintPage() { + const { blueprintId } = useParams() + return +} diff --git a/frontend/src/pages/blueprints/NewAppStart.tsx b/frontend/src/pages/blueprints/NewAppStart.tsx new file mode 100644 index 00000000..ae4d4ab7 --- /dev/null +++ b/frontend/src/pages/blueprints/NewAppStart.tsx @@ -0,0 +1,10 @@ +import { EmptyState } from '../../components/ui' +import { NEW_APP_TEXT } from '../../lib/terms' + +/** + * 新建应用的三个起点(docs/design/normal-user-apps.md §4.1):说说你要什么、从场景模板开始、从已有的开始。 + * 骨架阶段是占位,包 F5 实现。 + */ +export function NewAppStart() { + return +} diff --git a/frontend/src/typesApp.ts b/frontend/src/typesApp.ts index 99a0f73e..ecb4b20f 100644 --- a/frontend/src/typesApp.ts +++ b/frontend/src/typesApp.ts @@ -67,6 +67,8 @@ export interface AuditRow { export type FormWidget = | 'text' | 'textarea' | 'number' | 'integer' | 'date' | 'datetime' | 'select' | 'radio' | 'multiselect' | 'checkbox' | 'principal' | 'note' | 'json' + /** 文件(N9):字段 type 为 object,值是上传后拿回的引用 FileRef(typesBlueprint.ts) */ + | 'file' export interface FormField { type: 'string' | 'number' | 'integer' | 'boolean' | 'array' @@ -111,11 +113,14 @@ export type Disposition = 'recompute' | 'dual' | 'incomparable' export interface AppPin { alias: string - workflow_id: string - version: number + /** 共享应用钉工作流版本;个人应用(docs/design/normal-user-apps.md N7)写 blueprint,workflow_id 为空 */ + workflow_id: string | null + /** 还没发布(蓝图试运行时派生的草稿)、或个人应用时为空 */ + version: number | null graph_hash: string level?: string | null use: PinUse + blueprint?: string | null } export interface AppEntry { @@ -128,7 +133,8 @@ export interface AppEntry { } export interface AppViewSource { - kind: 'latest_sealed' | 'on_demand' | 'instance' + /** exploratory 只在个人应用里合法:最近一次成功封存的探索运行(N7) */ + kind: 'latest_sealed' | 'on_demand' | 'instance' | 'exploratory' pin?: string | null from?: string | null params?: FormSchema | { $ref: string } | null @@ -146,6 +152,8 @@ export interface AppComponent { bind?: AppBinding | null visible?: string | null scope?: string | null + /** 作者隐藏了它(N6):解析时跳过,编辑状态下画成半透明 */ + hidden?: boolean children?: AppComponent[] } @@ -216,6 +224,10 @@ export interface AppSummary { /** 当前用户的授权(关登录时为 manage) */ my_role: GrantRole | null updated_at: string + /** 个人应用(N7):只有拥有者看得见、跑探索运行;老服务端不给时按 shared */ + scope?: 'shared' | 'personal' + owner?: string | null + blueprint_id?: string | null } export interface AppDetail extends AppSummary { @@ -306,8 +318,8 @@ export interface Stamp { } export interface ViewSourceState { - /** 来源的种类:按需视图(on_demand)才有「刷新」 */ - kind?: 'latest_sealed' | 'on_demand' | 'instance' + /** 来源的种类:按需视图(on_demand)才有「刷新」;试运行解析时为 trial,个人应用为 exploratory */ + kind?: 'latest_sealed' | 'on_demand' | 'instance' | 'exploratory' | 'trial' state: 'ok' | 'empty' | 'running' | 'stale' | 'error' run_id: string | null stamp: Stamp | null diff --git a/frontend/src/typesBlueprint.ts b/frontend/src/typesBlueprint.ts new file mode 100644 index 00000000..51751a75 --- /dev/null +++ b/frontend/src/typesBlueprint.ts @@ -0,0 +1,190 @@ +/** + * 普通用户建应用的接口约定(docs/design/normal-user-apps.md 第三部分):蓝图、场景模板、试运行、发布单、文件、记录。 + * 和后端 app/blueprints/spec.py、app/db/models_blueprints.py 一一对应。改这里要同时改后端(骨架阶段冻结)。 + */ +import type { AppSpec, LintIssue, ViewModel } from './typesApp' + +// ---------------------------------------------------------------- 蓝图 agentlab.blueprint/1 + +export type ItemSource = 'user' | 'assistant' | 'template' | 'catalog' | 'caliber' +export type ItemStatus = 'confirmed' | 'proposed' | 'missing' + +/** 卡片上的一项:值、来源、状态,可选一句「为什么」 */ +export interface BpItem { + value: T | null + source: ItemSource + status: ItemStatus + why?: string | null +} + +export interface BpDataItem { + id: string + label: BpItem + unit: BpItem + /** 对到 {source, table} */ + table: BpItem<{ source: string; table: string }> + measure: BpItem + date: BpItem<{ column: string; rule?: string }> + filter: BpItem + grain: BpItem + /** 复用已发布的口径卡 */ + reuse?: { workflow_id: string; version: number; node_id: string; metric_id: string } | null +} + +export interface BpPrincipal { kind: 'user' | 'group' | 'slot' | 'initiator'; name: string } + +export interface BpStep { + id: string + label: BpItem + who: BpItem + policy: BpItem<{ kind: 'any' | 'all' | 'quorum'; m?: number }> + due: BpItem + on_timeout: BpItem<{ action: 'remind' | 'escalate' | 'reject'; to?: BpPrincipal[] }> + threshold: BpItem<{ field: string; op: string; value: number | string }> +} + +export interface BpField { name: string; spec: BpItem>; sensitive: boolean } + +export interface BpWriteback { + capability: BpItem + mapping: BpItem> + confirm: BpItem +} + +export interface BpAccess { + use: { kind: string; name: string }[] + observe: { kind: string; name: string }[] + manage: { kind: string; name: string }[] +} + +export interface BpAsk { id: string; question: string; options: string[]; path?: string | null; answer?: string | null } + +export interface BlueprintSpec { + format: 'agentlab.blueprint/1' + purpose: BpItem + template: { id: string; version: number } | null + data: BpDataItem[] + cadence: BpItem<{ preset: 'daily' | 'weekly' | 'monthly'; weekday?: number; day?: number; time: string; tz: string; period?: string }> + people: BpStep[] + fields: BpField[] + writeback: BpWriteback | null + access: BpAccess + asks: BpAsk[] +} + +export type BpOpName = 'bp_set' | 'bp_add' | 'bp_remove' | 'bp_ask' | 'bp_template' +export interface BpOp { + op: BpOpName + /** JSON 指针(/data/0/table) */ + path?: string | null + value?: unknown + question?: string | null + options?: string[] + template?: { id: string; version: number } | null +} + +export type BlueprintScope = 'personal' | 'shared' +export type BlueprintStatus = 'drafting' | 'trialed' | 'published' | 'personal' | 'in_review' +export type BlueprintSourceKind = 'conversation' | 'template' | 'run' | 'workflow_version' + +export interface JointState { status: 'filled' | 'pending' | 'refused'; by: 'template' | 'assistant' | 'user'; note?: string | null } + +export interface Blueprint { + id: string + name: string + owner: string | null + scope: BlueprintScope + status: BlueprintStatus + spec: BlueprintSpec + template_id: string | null + template_version: number | null + source: { kind: BlueprintSourceKind; id?: string | null; version?: number | null } + conversation_id: string | null + workflow_id: string | null + app_id: string | null + last_trial_run_id: string | null + graph_hash: string | null + joints: Record + copied_from: string | null + /** 蓝图检查(bp.* / tpl.*),带白话 */ + issues?: PlainIssue[] + created_at: string + updated_at: string +} + +// ---------------------------------------------------------------- 白话层 + +export type ProblemWho = 'you' | 'assistant' | 'admin' +export interface Plain { title: string; why: string; who: ProblemWho; action: string } +/** 检查问题加上白话(后端 app/plain.with_plain);没登记白话的 plain 为空,照旧显示 message */ +export type PlainIssue = LintIssue & { plain?: Plain | null } + +// ---------------------------------------------------------------- 场景模板 + +export interface TemplateCard { card: string; note: string } +export interface TemplateInfo { + id: string + version: number + label: string + description: string + applies_when: string + cards: TemplateCard[] + /** 个人应用能用哪一部分;不能用为 null */ + personal: string | null + tags: string[] +} + +// ---------------------------------------------------------------- 试运行 + +export interface TrialStart { run_id: string; blueprint: Blueprint } +/** 试运行解析出来的一页:和应用页同一个视图模型,印章 trial=true */ +export type TrialPage = ViewModel + +// ---------------------------------------------------------------- 发布单 + +export type PlanStepKind = + | 'save_version' | 'preflight' | 'publish_workflow' | 'create_schedule' | 'create_app' | 'publish_app' + | 'enable_schedule' | 'grant' | 'first_run' +export interface PlanStep { + kind: PlanStepKind + status: 'pending' | 'done' | 'failed' | 'skipped' + /** 这一步产出的对象 id(工作流版本、定时任务、应用……) */ + result?: Record | null + error_code?: string | null + message?: string | null + at?: string | null +} +export interface PublishPlanRow { + id: string + blueprint_id: string + level: 'published' | 'governed' + graph_hash: string | null + status: 'planned' | 'running' | 'stopped' | 'done' + steps: PlanStep[] + /** 白话摘要:每一步一句 */ + summary: { step: PlanStepKind; text: string }[] + problems: PlainIssue[] + options: { first_run?: boolean } +} + +// ---------------------------------------------------------------- 文件、记录 + +/** 上传后拿回的引用,作为表单值提交 */ +export interface FileRef { file_id: string; name: string; size: number; sha256: string; type: string } + +export interface RecordRow { + _id: string + _seq: number + _submitted_by: string | null + _submitted_at: string + _supersedes: string | null + /** 被更正过(有更新的一行指向它) */ + superseded?: boolean + values: Record +} +export interface RecordsPage { table: string; version: number; columns: { name: string; label: string; type: string }[]; items: RecordRow[]; mine_only: boolean } + +// ---------------------------------------------------------------- 页面编辑 + +/** 「加一张数据卡片」的候选(GET /api/apps/{id}/candidates) */ +export interface AppCandidate { kind: 'metric' | 'report' | 'table' | 'series'; label: string; group: string; component: AppSpec['pages'][number]['body'][number] }