普通用户建应用的契约骨架(docs/design/normal-user-apps.md 第五部分):蓝图、蓝图留痕、发布单、文件、记录表的模型和迁移,应用的 scope / owner / blueprint_id;运行来源加 trial、personal_app;应用描述的固定版本可以没有版本号(试运行派生的草稿)或改钉蓝图(个人应用),视图来源加 exploratory,组件加 hidden,表单加文件控件;agentlab.blueprint/1 与 bp_* 操作、agentlab.template/1 与关节类型的定义;实例化、试运行、发布单、个人应用、记录表、文件、白话层的接口签名;蓝图、模板、文件、个人应用、蓝图助手的路由桩(501);前端约定 typesBlueprint.ts、接口桩、新建应用与蓝图页的占位路由、termsBlueprint.ts

This commit is contained in:
ThearchyHelios
2026-10-15 09:49:45 +08:00
parent 69c5c74806
commit 7088f4557e
35 changed files with 1144 additions and 17 deletions
+71
View File
@@ -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)
+25
View File
@@ -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, "尚未实现")
+24
View File
@@ -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, "尚未实现")
+43
View File
@@ -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)
+13
View File
@@ -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, "尚未实现")
+25 -4
View File
@@ -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:<id> | 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)
+4
View File
@@ -0,0 +1,4 @@
"""普通用户建应用(docs/design/normal-user-apps.md):蓝图、场景模板、实例化、试运行、发布单、个人应用。
一条链路两种终点(N1):说需求 → 蓝图 → 模板填关节 → 试运行 →(搭建者)发布单 /(使用者)个人应用。
"""
+32
View File
@@ -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
+28
View File
@@ -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
+31
View File
@@ -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
+39
View File
@@ -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
+129
View File
@@ -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)
@@ -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
+19
View File
@@ -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
+4
View File
@@ -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 不碰已有的表,表上新列的索引也就不会建。
+3
View File
@@ -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,
)
+5
View File
@@ -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):
+134
View File
@@ -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 位>/<sha>(只读),这里记元数据和绑定。
提交表单时绑到一次运行(一次性)或一行记录;没绑定的上传按时清理。"""
__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/<table_key>.v<version>.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="")
+2
View File
@@ -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 里能声明的来源
+25
View File
@@ -0,0 +1,25 @@
"""文件(docs/design/normal-user-apps.md N9、§3.8):按 sha256 内容寻址存放(data/files/<前 2 位>/<sha>,只读),
两步上传(先拿引用再提交表单),提交时绑到一次运行(一次性),引用连同 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
+12
View File
@@ -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),
+46
View File
@@ -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}}
+19
View File
@@ -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")
+7
View File
@@ -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() {
<Route path="/schedules/:id" element={<SchedulesPage />} />
{/* 应用:固定路径(inbox)写在 /apps/:appId 前面,读的人一眼看出它不是应用 id */}
<Route path="/apps" element={<AppsHome />} />
{/* 要在 /apps/:appId 之前:create 不是应用 id */}
<Route path="/apps/create" element={<NewAppStart />} />
<Route path="/blueprints/:blueprintId" element={<BlueprintPage />} />
<Route path="/blueprints/:blueprintId/:section" element={<BlueprintPage />} />
<Route path="/apps/inbox/:taskId" element={<TaskPage />} />
<Route path="/apps/:appId" element={<AppPage />} />
<Route path="/apps/:appId/p/:pageId" element={<AppPage />} />
+58
View File
@@ -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<Blueprint>('/blueprints', body),
list: (q: { scope?: BlueprintScope } = {}) => get<{ items: Blueprint[] }>(`/blueprints${qs(q)}`),
get: (id: string) => get<Blueprint>(`/blueprints/${encodeURIComponent(id)}`),
/** 卡片上的修改:bp_* 操作,来源记成 user */
patch: (id: string, body: { ops?: BpOp[]; name?: string }) => patch<Blueprint>(`/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<string, unknown> } = {}) =>
post<TrialStart>(`/blueprints/${encodeURIComponent(id)}/trial`, body),
preview: (id: string, q: { page?: string; run?: string } = {}) =>
get<TrialPage>(`/blueprints/${encodeURIComponent(id)}/preview${qs(q)}`),
publishPlan: (id: string, body: { level: 'published' | 'governed'; first_run?: boolean }) =>
post<PublishPlanRow>(`/blueprints/${encodeURIComponent(id)}/publish-plan`, body),
plan: (planId: string) => get<PublishPlanRow>(`/publish-plans/${encodeURIComponent(planId)}`),
runPlan: (planId: string) => post<PublishPlanRow>(`/publish-plans/${encodeURIComponent(planId)}/run`, {}),
resumePlan: (planId: string) => post<PublishPlanRow>(`/publish-plans/${encodeURIComponent(planId)}/resume`, {}),
// ---- 个人应用(N7)
savePersonal: (id: string) => post<{ app_id: string; blueprint: Blueprint }>(`/blueprints/${encodeURIComponent(id)}/save-personal`, {}),
copyForReview: (id: string) => post<Blueprint>(`/blueprints/${encodeURIComponent(id)}/copy`, {}),
}
export const templates = {
list: () => get<{ items: TemplateInfo[] }>('/templates'),
}
export const files = {
/** 先上传拿引用,再把引用作为表单值提交(N9) */
upload: (file: File, context: Record<string, string>, opts?: UploadOptions) => {
const form = new FormData()
form.append('file', file)
form.append('context', JSON.stringify(context))
return upload<FileRef>('/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<RecordsPage>(`/apps/${encodeURIComponent(appId)}/records${qs(q)}`),
}
+8
View File
@@ -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,
}
/**
+1
View File
@@ -5736,3 +5736,4 @@ export const CAPABILITY_REVIEW_TEXT: typeof CATALOG_REVIEW_TEXT = {
// 应用平台使用面的文案(FE-5 维护)。调用方照旧从 lib/terms 导入
export * from './termsApp'
export * from './termsBlueprint'
+12
View File
@@ -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: '应用蓝图',
}
+2 -1
View File
@@ -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 })
}
@@ -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() {
<select className="field h-7 py-0 text-xs" value={v.source.pin ?? ''} aria-label={T.viewPin} title={APP_TERM_TEXT.pin}
onChange={(e) => set(key, { source: { ...v.source, pin: e.target.value || null } })}>
<option value="">{T.viewPin}</option>
{draft.pins.map((p) => <option key={p.alias} value={p.alias}>{p.alias} · v{p.version}</option>)}
{draft.pins.map((p) => <option key={p.alias} value={p.alias}>{p.alias}{p.version != null ? ` · v${p.version}` : ''}</option>)}
</select>
)}
{v.source.kind === 'latest_sealed' && (
+6 -3
View File
@@ -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) => <input {...p} className="field mono text-xs" value={pin.alias} onChange={(e) => update(i, { alias: e.target.value })} />}
</Field>
<div className="min-w-0 pb-1.5 text-xs">
<div className="truncate font-medium">{nameOf(pin.workflow_id)}</div>
<div className="truncate font-medium">{pin.workflow_id ? nameOf(pin.workflow_id) : '—'}</div>
<div className="mono text-2xs text-faint">{T.pinHash(pin.graph_hash || '')}</div>
</div>
<Field label={T.pickVersion}>
{() => (
<PublishedVersionPicker compact workflowId={pin.workflow_id} value={pin.version}
<PublishedVersionPicker compact workflowId={pin.workflow_id ?? ''} value={pin.version}
onChange={(v) => v && update(i, { version: v.version, graph_hash: v.graph_hash ?? '',
level: v.level ?? null })} />
)}
@@ -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 <EmptyState title={BLUEPRINT_TEXT.title} body={blueprintId ?? ''} />
}
@@ -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 <EmptyState title={NEW_APP_TEXT.title} body="" />
}
+17 -5
View File
@@ -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
+190
View File
@@ -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<T = unknown> {
value: T | null
source: ItemSource
status: ItemStatus
why?: string | null
}
export interface BpDataItem {
id: string
label: BpItem<string>
unit: BpItem<string>
/** 对到 {source, table} */
table: BpItem<{ source: string; table: string }>
measure: BpItem<string>
date: BpItem<{ column: string; rule?: string }>
filter: BpItem<string>
grain: BpItem<string>
/** 复用已发布的口径卡 */
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<string>
who: BpItem<BpPrincipal[]>
policy: BpItem<{ kind: 'any' | 'all' | 'quorum'; m?: number }>
due: BpItem<string>
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<Record<string, any>>; sensitive: boolean }
export interface BpWriteback {
capability: BpItem<string>
mapping: BpItem<Record<string, string>>
confirm: BpItem<boolean>
}
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<string>
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<string, JointState>
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<string, unknown> | 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<string, unknown>
}
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] }