diff --git a/docs/rfcs/0003-production-generative-ui.zh-CN.md b/docs/rfcs/0003-production-generative-ui.zh-CN.md new file mode 100644 index 000000000..4fb9475e7 --- /dev/null +++ b/docs/rfcs/0003-production-generative-ui.zh-CN.md @@ -0,0 +1,867 @@ +# 第三阶段系分:生产级生成式 UI + +> 状态:Draft 规划阶段:2027-01 至 2027-03 目标模块:`@ant-design/x-card`、`@ant-design/x-sdk`、`@ant-design/x` 参与工作流:Generative UI、Runtime、Interaction、Quality最后更新:2026-08-04 + +## 1. 结论摘要 + +第三阶段不在现有 `Card.tsx` 上继续叠加能力,而是引入框架无关的 Surface Runtime,并将当前 `XCard.Box` / `XCard.Card` 改造成兼容适配层和 React 渲染层。 + +本系分作出以下核心决策: + +1. A2UI v0.8 和 v0.9 先归一化为内部命令,再进入同一套事务、校验、Reducer 和渲染流程。 +2. Catalog 从模块级全局缓存升级为可注入的 `CatalogRegistry`,支持本地注册、受控远程加载、版本协商、缓存和完整性校验。 +3. 未声明 Catalog、Catalog 未注册组件、Schema 不合法属性和未注册 Action 在 GA 版本中默认拒绝。 +4. Action 只表达意图,不直接执行代码。所有 Action 必须经过参数校验、权限决策、危险操作确认、执行和审计五段式管线;本地 Action 进入宿主 handler,Agent Action 复用 SDK 的 Agent Command 通道。 +5. Surface 更新采用草稿校验后原子提交,失败时保留上一稳定版本;React 渲染异常由 Surface 级错误边界隔离并支持回滚。 +6. `@ant-design/x-sdk` Agent Event Model 升级到可识别 Surface 生命周期的协议版本,`x-card` 负责消费 A2UI 内容,SDK 不依赖 React 或 `x-card`。 +7. Surface 是 Artifact 的可交互投影。Artifact 负责生成物身份和生命周期,Surface 负责界面协议、渲染状态和交互,不复制保存两份组件树。 +8. SSR 使用实例级 Runtime、Catalog 预加载和可序列化 Snapshot;流式水合按事件游标续播,客户端不重放服务端已经消费的命令。 + +目标版本建议为 `@ant-design/x-card` 3.x。现有 2.x API 保留一条兼容路径,但生产能力只在新 Runtime 上实现。 + +## 2. 背景与现状 + +路线图第三阶段要求完成: + +- A2UI Catalog 本地注册、远程加载、缓存和版本协商。 +- JSON Schema 校验、组件白名单和属性约束。 +- Action 权限分级、危险操作确认和审计钩子。 +- Surface 生命周期、局部更新、错误边界和回滚。 +- 动态表单校验、跨卡片数据绑定和多 Surface 协作。 +- SSR、流式水合和大型 Surface 性能优化。 +- Catalog 测试工具、调试面板和可视化事件检查器。 +- 与统一 Agent Event Model 和 Artifact 系统打通。 + +### 2.1 当前代码基线 + +| 位置 | 当前实现 | 生产化缺口 | +| --- | --- | --- | +| `packages/x-card/src/A2UI/catalog.ts` | 模块级 `Map`、本地注册、直接 `fetch` 远程 Catalog、必填字段检查 | 实例隔离、并发去重、TTL/ETag、来源限制、版本协商、完整 Schema 校验均缺失 | +| `packages/x-card/src/A2UI/Box.tsx` | React Effect 扫描新增命令并加载 Catalog | Catalog 加载失败只打印日志,无 Surface 状态、重试或错误边界 | +| `packages/x-card/src/A2UI/Card.tsx` | 每个 Card 自持组件树和 dataModel,Effect 重放该 Surface 的完整命令数组 | 命令增长后接近 O(n²),没有事件幂等、事务、Snapshot 和回滚 | +| `packages/x-card/src/A2UI/utils.ts` | 未提供 Catalog 时默认通过;属性错误只返回字符串 | 不符合“未注册默认拒绝”,也无法覆盖类型、范围、组合 Schema 和数据格式 | +| `packages/x-card/src/A2UI/types` | v0.8/v0.9 两套命令在 React 层分支处理 | 协议差异侵入渲染层,后续版本难以独立演进 | +| `packages/x-sdk/src/agent` | 已有事件 Envelope、Reducer、Store、Provider | 当前协议 0.1 没有 Surface 生命周期和 Surface 状态 | +| `packages/x-sdk/src/agent/command` | 阶段二正在引入出站 Command、幂等键、能力声明和执行状态 | 尚无 `surface.action`,第三阶段需扩展而不是另建出站协议 | + +### 2.2 必须解决的根问题 + +- 安全决策发生在渲染之后,非法组件目前仍可能进入组件解析流程。 +- Catalog 是进程级共享状态,SSR 多请求和多租户场景可能相互污染。 +- React 状态既承担协议消费又承担渲染,无法在不挂载 DOM 时重放、测试、SSR 或恢复。 +- v0.8/v0.9 分支散落在 Card 内部,协议兼容成本随功能线性放大。 +- 完整命令数组由外部维护,Card 在每次变更时重新过滤和处理历史命令。 +- Action 只有 `onAction` 回调,没有身份、权限、风险、确认、执行结果和审计语义。 +- Artifact 与 Surface 没有统一身份关联,Agent 时间线无法解释动态界面从何而来、当前处于什么版本。 + +## 3. 目标与非目标 + +### 3.1 目标 + +- 让不可信 Agent 输出只能在应用明确允许的 Catalog、组件、属性和 Action 边界内运行。 +- 让 1,000 节点 Surface 的首次渲染、局部更新和交互具备可量化的性能门禁。 +- 让同一事件流可在浏览器、SSR、测试和调试器中确定性重放。 +- 让 v0.8/v0.9 兼容逻辑集中在 Adapter,核心 Runtime 和 React Renderer 不感知协议差异。 +- 让 Surface 能被 Agent Event、Artifact 和应用状态可靠关联、恢复和审计。 + +### 3.2 非目标 + +- 不执行 Agent 生成的 HTML、JavaScript、React 组件代码或远程模块。 +- 不把 `x-card` 建设成服务端 Agent 编排器或通用权限中心。 +- 不在第三阶段实现 Vue Renderer,但 Runtime 不依赖 React。 +- 不自动同步任意 Surface 的私有 dataModel,跨 Surface 共享必须显式声明。 +- 不承诺把 A2UI 未来所有版本一次性抽象完,只建立可插拔 Adapter 边界。 + +## 4. 总体架构 + +```mermaid +flowchart LR + A["Agent Runtime / Agent Event"] --> B["Protocol Adapter"] + B --> C["Normalized Surface Transaction"] + C --> D["Catalog Registry + Schema Validator"] + D --> E["Security Policy"] + E --> F["Surface Reducer"] + F --> G["Surface Store / Snapshot"] + G --> H["React Renderer"] + G --> I["SSR Renderer"] + G --> J["DevTools / Test Harness"] + H --> K["Action Request"] + K --> L["Authorize + Confirm + Execute + Audit"] + L --> A +``` + +处理顺序固定为: + +```text +decode -> normalize -> structural validate -> catalog validate + -> policy evaluate -> draft reduce -> invariant validate + -> atomic commit -> notify renderer/devtools +``` + +任何步骤失败都不得修改已提交的 Surface Snapshot。 + +## 5. 模块边界 + +建议按以下目录演进,先保留在 `@ant-design/x-card` 内,避免第三阶段立即增加新包的发布和采用成本: + +```text +packages/x-card/src/ + runtime/ + protocol/ + reducer/ + store/ + snapshot/ + data/ + adapters/ + a2ui-v0.8/ + a2ui-v0.9/ + agent-event/ + catalog/ + registry/ + loader/ + negotiation/ + validator/ + security/ + policy/ + action/ + audit/ + react/ + Provider.tsx + Surface.tsx + SurfaceErrorBoundary.tsx + devtools/ + testing/ + A2UI/ # 2.x 兼容入口,内部转发到新 Runtime +``` + +| 模块 | 职责 | 禁止承担的职责 | +| --- | --- | --- | +| Adapter | 解析具体协议并生成归一化事务 | React 渲染、远程请求、权限判断 | +| Catalog Registry | Catalog 发现、加载、协商、缓存和编译 | 执行 Action、持有 Surface 状态 | +| Surface Runtime | 生命周期、事务、图结构、dataModel、Snapshot | UI 样式、网络传输、业务权限来源 | +| Security | 合并应用策略和 Catalog 声明,输出决策 | 直接修改 Surface 或执行未注册代码 | +| React Renderer | 订阅 Snapshot 并渲染已批准节点 | 解析原始 A2UI 命令 | +| SDK Bridge | Agent Event 与 Surface 事务互转 | 依赖 `x-card` 的 React 类型 | + +如果后续 Vue 或非 DOM 消费方达到两个以上,再将 `runtime` 抽取为独立包;第三阶段不提前拆包。 + +## 6. 核心领域模型 + +### 6.1 Runtime API + +```ts +export interface SurfaceRuntimeOptions { + catalogs: CatalogRegistry; + adapters: readonly SurfaceProtocolAdapter[]; + policy?: SurfaceSecurityPolicy; + limits?: Partial; + sharedData?: Record; + onIssue?: (issue: SurfaceIssue) => void; + onAudit?: (event: SurfaceAuditEvent) => void; +} + +export interface SurfaceRuntime { + dispatch(input: SurfaceInput): Promise; + dispatchBatch(inputs: readonly SurfaceInput[]): Promise; + getSnapshot(): SurfaceRuntimeSnapshot; + getSurface(surfaceId: string): SurfaceSnapshot | undefined; + subscribe(listener: () => void): () => void; + rollback(surfaceId: string, revision?: number): SurfaceDispatchResult; + dehydrate(): DehydratedSurfaceRuntime; + dispose(): void; +} + +export interface SurfaceInput { + protocol: string; + version: string; + payload: unknown; + eventId?: string; + sequence?: number; +} + +export interface SurfaceProtocolAdapter { + protocol: string; + versions: readonly string[]; + normalize(input: SurfaceInput): SurfaceTransaction | readonly SurfaceTransaction[]; +} +``` + +公开 `dispatch` 使用异步语义,因为首次创建可能需要加载和编译 Catalog;纯 Reducer 仍保持同步。`dispatchBatch` 是跨 Surface 原子协作的基础,批次中任意操作失败时,整个批次不提交。 + +### 6.2 Surface 状态 + +```ts +export type SurfaceStatus = 'resolving-catalog' | 'ready' | 'rendering' | 'error' | 'deleted'; + +export interface SurfaceSnapshot { + id: string; + status: SurfaceStatus; + protocol: { name: 'a2ui'; version: '0.8' | '0.9' | (string & {}) }; + catalog: ResolvedCatalogRef; + revision: number; + rootId?: string; + nodes: ReadonlyMap; + dataModel: Readonly>; + lastStableRevision?: number; + issue?: SurfaceIssue; + artifactId?: string; +} +``` + +Runtime 内部保持 Map 以实现按节点更新;`dehydrate()` 输出普通对象和数组,禁止把函数、React Component、AbortController 等不可序列化值写入 Snapshot。 + +### 6.3 归一化事务 + +```ts +export interface SurfaceTransaction { + transactionId: string; + surfaceId: string; + expectedRevision?: number; + source: { + protocol: string; + version: string; + eventId?: string; + sequence?: number; + }; + operations: readonly SurfaceOperation[]; +} + +export type SurfaceOperation = + | { type: 'surface.create'; catalog: CatalogRequest; artifactId?: string } + | { type: 'node.upsert'; nodes: readonly SurfaceNodeInput[] } + | { type: 'node.remove'; nodeIds: readonly string[] } + | { type: 'data.set'; scope: 'surface' | 'shared'; path: string; value: unknown } + | { type: 'render.begin'; rootId: string } + | { type: 'surface.delete'; reason?: string }; +``` + +约束: + +- `transactionId` 和 Agent Event `eventId` 用于幂等去重。 +- `expectedRevision` 用于拒绝过期更新,拒绝后由上层恢复或重放。 +- v0.9 `updateComponents` 归一化为 `node.upsert`;不可达节点在事务提交后按策略回收。 +- v0.8 `beginRendering` 归一化为显式 `render.begin`,不再把协议状态放进 React Ref。 +- 图必须存在唯一可达根节点,不允许环、悬空引用或超出深度/节点限制。 + +## 7. Catalog 设计 + +### 7.1 Catalog 描述 + +```ts +export interface CatalogManifest { + id: string; + version: string; + supportedProtocols: readonly string[]; + components: Readonly>; + actions?: Readonly>; + dataSchema?: JsonSchema; + integrity?: string; +} + +export interface CatalogRequest { + id: string; + version?: string; + versionRange?: string; + integrity?: string; +} +``` + +Catalog Registry 中只保存声明,不保存远程可执行组件或 React 类型。组件实现由 React 层的本地 Component Registry 注册: + +```ts +catalogs.register(manifest); +const components = createComponentRegistry({ Button, Form, Input }); +``` + +只有同时满足以下条件的节点才可渲染: + +1. 组件名存在于协商后的 Catalog。 +2. 宿主注册了该组件实现。 +3. 原始属性通过 Catalog Schema。 +4. 数据绑定解析后的属性通过可选运行时 Schema。 +5. Security Policy 未拒绝该组件或属性。 + +### 7.2 加载和缓存 + +```ts +export interface CatalogLoader { + load(request: CatalogRequest, context: CatalogLoadContext): Promise; +} + +export interface CatalogLoadContext { + signal: AbortSignal; + allowedOrigins: readonly string[]; + credentials: 'omit' | 'same-origin'; +} +``` + +默认行为: + +- 本地 Catalog 优先,生产模式缺失时直接失败,不返回空 Catalog。 +- 远程加载必须配置允许的 Origin;默认只允许同源且 `credentials: 'omit'`。 +- 拒绝 `file:`、`data:`、`javascript:` 以及重定向到非允许 Origin 的地址。 +- 单个 Catalog 默认上限 1 MiB,默认超时 5 秒。 +- 相同 `id + version + integrity` 的并发请求共享同一 Promise。 +- 缓存键包含最终版本和完整性摘要,支持 ETag、TTL、LRU 和显式失效。 +- SSR Registry 为请求级实例;应用可注入只读的进程级编译缓存,但不得保存租户凭据。 + +### 7.3 版本协商 + +协商输入包括宿主支持版本、Agent 请求范围、协议版本和完整性约束。选择规则为: + +1. 过滤 Catalog `id` 不匹配的候选。 +2. 过滤不支持当前 A2UI 协议的候选。 +3. 计算宿主版本范围与 Agent 版本范围交集。 +4. 在交集中选择宿主已注册的最高稳定版本。 +5. 如果指定完整性摘要,摘要不一致立即拒绝且不降级。 +6. 无交集时返回 `catalog_version_mismatch`,不静默选择相邻版本。 + +协商结果写入 Surface Snapshot 和审计事件,保证恢复时能锁定相同版本。 + +### 7.4 Schema 校验 + +采用 JSON Schema 2020-12 和 Ajv 编译校验器。编译发生在 Catalog 注册/加载阶段,不在每个节点渲染时重复编译。 + +校验分四层: + +| 层级 | 校验内容 | 失败策略 | +| --- | --- | --- | +| Envelope | 命令类型、版本、Surface ID、载荷结构 | 拒绝整条输入 | +| Graph | 根节点、引用、环、深度、节点数、唯一 ID | 拒绝整个事务 | +| Catalog | 组件白名单、属性类型、必填、枚举、范围、`additionalProperties` | 拒绝非法节点,默认导致事务失败 | +| Data | dataModel Schema、表单字段、绑定解析值 | 拒绝数据事务并保留原值 | + +安全配置: + +- 禁止运行时从任意 URL 自动加载 `$ref`,仅允许 Catalog 内 `$defs` 和宿主预注册 Schema。 +- 校验错误使用 JSON Pointer 标识路径,不在错误信息中输出完整敏感值。 +- Path Binding 必须使用 Catalog 提供的 `Bindable` Schema,不能以 `{ path }` 绕过属性类型约束。 + +## 8. Action 安全管线 + +### 8.1 Action 声明和宿主注册 + +```ts +export type ActionRisk = 'low' | 'medium' | 'high' | 'critical'; + +export interface CatalogActionSchema { + input: JsonSchema; + risk: ActionRisk; + sideEffect: 'none' | 'local' | 'remote' | 'destructive'; + target: 'host' | 'agent'; + permission?: string; +} + +export interface ActionHandlerRegistration { + name: string; + inputSchema?: JsonSchema; + minimumRisk?: ActionRisk; + execute(request: ApprovedActionRequest): Promise | unknown; +} +``` + +最终风险取 Catalog 声明、宿主注册和应用策略三者中的最高等级。Agent 不能通过把 `risk` 写成 `low` 来降低宿主定义的风险。 + +### 8.2 决策流程 + +```mermaid +flowchart TD + A["Component emits action"] --> B{"Action declared and target supported?"} + B -- No --> X["Deny + audit"] + B -- Yes --> C{"Input schema valid?"} + C -- No --> X + C -- Yes --> D["Policy authorize"] + D -- Deny --> X + D -- Allow --> E{"Confirmation required?"} + E -- Yes --> F["Host confirmation UI"] + F -- Reject --> X + F -- Approve --> G["Build approved request"] + E -- No --> G + G --> H{"Action target"} + H -- Host --> I["Execute local handler"] + H -- Agent --> J["Dispatch surface.action command"] + I --> K["Result + audit"] + J --> K +``` + +默认策略: + +| 风险 | 默认决策 | +| -------- | ------------------------------------------------------------------------------ | +| low | 仅在宿主已注册 handler 或 Provider 声明对应 Command 能力,且 Schema 通过时允许 | +| medium | 需要应用策略显式允许 | +| high | 需要应用策略允许并由用户确认 | +| critical | 默认拒绝;应用必须同时显式允许、提供确认器和审计接收器 | + +确认 UI 复用阶段二 `Approval` 组件,不在 `x-card` 内复制视觉和交互规范。由客户端策略触发的确认只产生本地决策,确认通过后才进入本地 handler 或发送 `surface.action`;由 Agent `approval.requested` 触发的确认才通过现有 `approval.resolve` Agent Command 提交。确认文本、目标对象和最终参数必须在确认时可见,执行前再次校验确认后的参数。 + +`target: 'host'` 只调用本地注册的 Action Handler;`target: 'agent'` 在授权和确认后生成 `surface.action` Agent Command。两条路径共用同一个策略和审计模型,且都禁止 Catalog 携带可执行实现。 + +### 8.3 审计 + +每次 Action 至少发出以下审计节点: + +```text +action.requested +action.denied | action.confirmation_requested +action.confirmed | action.rejected +action.started +action.completed | action.failed +``` + +审计记录包含 action 名、Surface/Artifact/Session/Run 身份、Catalog 版本、风险、决策原因、耗时和结果状态。参数默认只记录字段名和摘要,敏感字段由 Catalog `writeOnly`、宿主脱敏器和应用策略共同处理。 + +审计 Hook 只接收结构化事件,不负责持久化;持久化和合规留存由应用接入。 + +## 9. Surface 生命周期、局部更新和回滚 + +```mermaid +stateDiagram-v2 + [*] --> ResolvingCatalog: surface.create + ResolvingCatalog --> Ready: catalog resolved + ResolvingCatalog --> Error: load/compile denied + Ready --> Rendering: render.begin + Rendering --> Rendering: valid transaction commit + Rendering --> Error: renderer failure + Error --> Rendering: retry or rollback + Ready --> Deleted: surface.delete + Rendering --> Deleted: surface.delete + Error --> Deleted: surface.delete + Deleted --> [*] +``` + +事务提交规则: + +1. 从当前 Snapshot 创建结构共享的 Draft。 +2. 在 Draft 上应用全部 operations。 +3. 执行 Schema、图不变量、绑定和权限校验。 +4. 全部通过后 revision 加一并原子替换 Snapshot。 +5. 保存最近三个稳定 revision 的轻量 Snapshot,数量可配置。 +6. 任一步失败时丢弃 Draft,返回结构化 `SurfaceIssue`,当前 UI 不闪烁、不清空。 + +React Error Boundary 只隔离渲染异常,不吞掉协议错误。发生异常时展示应用传入的 `fallback`,并提供: + +- 重试当前 revision。 +- 回滚到 `lastStableRevision`。 +- 删除 Surface。 +- 通过 `onIssue` 上报错误。 + +局部更新必须保持节点引用稳定。只订阅 dataModel 的组件在其他节点更新时不重新渲染;选择器以 `surfaceId + nodeId + binding paths` 为粒度。 + +## 10. 数据、表单和多 Surface 协作 + +### 10.1 数据域 + +Runtime 提供两个明确数据域: + +- `surface`:每个 Surface 私有,Surface 删除时释放。 +- `shared`:Box/Runtime 实例内共享,由宿主初始化和持久化。 + +绑定格式扩展为: + +```ts +type DataBinding = { + path: string; + scope?: 'surface' | 'shared'; // 默认 surface + mode?: 'read' | 'write' | 'readwrite'; +}; +``` + +禁止通过 Surface ID 直接读取另一个 Surface 的私有 dataModel。跨卡片同步必须写入 `shared` 域,并由 Catalog Schema 声明可读写路径。这样可以避免隐式循环依赖和 Surface 删除后的悬空引用。 + +### 10.2 动态表单 + +- Catalog 可提供 `dataSchema`,表单字段绑定到 dataModel JSON Pointer。 +- 字段级校验在输入和失焦时执行,提交前执行整个 `dataSchema` 校验。 +- 校验状态属于本地 UI State,不写回 Agent 生成的数据;校验结果通过明确 Action Context 上报。 +- Agent 更新正在编辑的字段时,默认保留用户脏值并产生冲突状态;宿主可配置 `agent-wins`、`user-wins` 或自定义合并。 +- 提交 Action 只有在当前 revision、数据 Schema 和 Action input Schema 同时通过后才能进入权限管线。 + +### 10.3 多 Surface 原子更新 + +`dispatchBatch` 可在一个事务中更新多个 Surface 和 shared data。典型流程为: + +```text +更新 shared/order -> 更新 checkout Surface -> 创建 result Surface -> 删除 cart Surface +``` + +任一 Catalog、Schema 或权限校验失败时,四步全部不提交。批次提交后各 Surface 独立通知订阅者,React 侧使用批处理避免中间态渲染。 + +## 11. Agent Event 和 Artifact 集成 + +### 11.1 Agent Event Model + +现有协议 0.1 的 validator 会拒绝未知事件类型,因此新增 Surface 事件需要协议版本协商,不能只向 Union 添加类型。建议新增 0.2: + +```ts +interface SurfaceAgentEventPayloadMap { + 'surface.created': { + surfaceId: string; + artifactId?: string; + mediaType: 'application/vnd.a2ui+json' | (string & {}); + protocolVersion: string; + catalog: CatalogRequest; + }; + 'surface.updated': { + surfaceId: string; + revision?: number; + delta: unknown; + }; + 'surface.deleted': { + surfaceId: string; + reason?: string; + }; +} +``` + +设计约束: + +- `x-sdk` 只校验通用 Envelope 和 Surface 身份,不解释 A2UI `delta`。 +- `x-card/adapters/agent-event` 根据 `mediaType + protocolVersion` 解析 `delta`。 +- SDK Reducer 保存 Surface 元数据、revision、状态和 Artifact 关联,不保存完整节点图。 +- 协议解码器在迁移期同时接受 0.1/0.2;Provider 在启动时声明支持的版本,不能依赖当前的单一字符串常量。 +- 0.1 客户端收到 0.2 时明确报 `unsupported_protocol_version`,不把 Surface 当普通消息吞掉。 + +### 11.2 Artifact 关系 + +Surface 是 Artifact 的交互视图,两者通过 `artifactId` 关联: + +- `artifact.created` 建立生成物身份、名称、mediaType 和生命周期。 +- `surface.created` 为 Artifact 建立可交互投影,可晚于 Artifact 创建。 +- 一个 Artifact 可以有多个 Surface,例如编辑视图和预览视图。 +- Surface 删除不等于 Artifact 删除;Artifact 完成也不强制删除 Surface。 +- Artifact 容器根据 mediaType 选择 `XCard.Surface` Renderer,未知 mediaType 使用现有自定义 Artifact Renderer。 + +阶段二 Artifact API 未稳定前,第三阶段先使用可选 `artifactId`,集成代码不得反向侵入 Runtime 核心。 + +### 11.3 Agent Command 集成 + +阶段二已经建立出站 Agent Command Envelope、`idempotencyKey`、Provider `capabilities.commands`、按 Run 串行执行和 Command 状态。Surface 的 Agent Action 必须扩展这条通道: + +```ts +interface SurfaceAgentCommandPayloadMap { + 'surface.action': { + surfaceId: string; + artifactId?: string; + action: string; + context?: unknown; + surfaceRevision: number; + catalog: { id: string; version: string }; + }; +} +``` + +约束: + +- 新类型会被 0.1 Command validator 拒绝,因此需要 Agent Command 0.2 或在协议层建立明确的扩展类型协商,不能只修改 TypeScript Union。 +- `surface.action` 复用 `commandId`、`idempotencyKey`、Session/Run 身份、Provider 能力声明和串行执行。 +- `surfaceRevision` 用于阻止用户基于过期界面提交;Provider 返回 revision 冲突时,应用先恢复 Surface 再允许重试。 +- Command 发出前 Action Policy 必须已经批准;Provider 仍需做服务端授权,前端授权不是安全边界的终点。 +- Command 产生的后续 `surface.updated`、`approval.requested`、`artifact.updated` 等 Agent Event 继续进入统一 Store,不直接回调修改 React 状态。 + +## 12. React API 与兼容层 + +建议的新 API: + +```tsx +const runtime = createSurfaceRuntime({ + catalogs, + policy, + onAudit, + onIssue, +}); + + + null} /> +; +``` + +保留兼容用法: + +```tsx + + + +``` + +兼容层内部创建 Runtime,并通过命令数组游标只消费新增命令。检测到数组替换或回退时重建 Runtime,而不是在每个 Card 内重放历史。兼容层不得绕过 GA 的安全默认值。 + +已有 API 处理: + +| 2.x API | 3.x 处理 | +| ------------------- | -------------------------------------------------------------------- | +| `registerCatalog` | 保留,注册到默认浏览器 Registry;SSR 和多租户提示改用实例 Registry | +| `loadCatalog` | 保留为兼容入口;生产远程加载要求显式配置 Origin 策略 | +| `clearCatalogCache` | 标记 deprecated,替换为 `registry.clear()` / `registry.invalidate()` | +| `validateComponent` | 标记 deprecated,替换为编译后的 `registry.validateNode()` | +| `XCard.Box/Card` | 保留至少一个大版本,内部转发 Runtime | +| `commands` | 保留;新增流式 `runtime.dispatch()`,推荐迁移 | + +## 13. SSR 与流式水合 + +SSR 流程: + +1. 每个请求创建独立 Catalog Registry 和 Surface Runtime。 +2. 根据首批 Agent Event 预加载并编译 Catalog。 +3. 服务端 dispatch 事件,得到确定性 Snapshot。 +4. React 使用 `useSyncExternalStore` 的 `getServerSnapshot` 渲染。 +5. `dehydrate()` 输出 Snapshot、已消费事件游标、Catalog ID/版本/完整性和校验器缓存键。 +6. 客户端校验 Snapshot 版本和摘要后 hydrate。 +7. 水合完成前到达的增量事件进入队列,完成后从服务端游标的下一条开始提交。 + +必须保证: + +- 相同 Snapshot 和组件注册表生成相同 DOM 结构和稳定 key。 +- 服务器不执行 Action,不持久化浏览器确认结果。 +- Snapshot 脚本采用安全 JSON 序列化,转义 `<`、U+2028、U+2029,避免脚本上下文注入。 +- Catalog 未预加载时允许输出稳定 Skeleton;不得在服务端静默使用空 Catalog 渲染。 +- Catalog 版本或完整性不匹配时放弃水合并受控重建该 Surface,不影响页面其他区域。 + +## 14. 错误模型 + +```ts +export interface SurfaceIssue { + code: + | 'unsupported_protocol' + | 'invalid_command' + | 'catalog_not_found' + | 'catalog_version_mismatch' + | 'catalog_integrity_mismatch' + | 'schema_validation_failed' + | 'component_not_allowed' + | 'action_not_allowed' + | 'revision_conflict' + | 'graph_invariant_failed' + | 'limit_exceeded' + | 'render_failed'; + phase: 'decode' | 'catalog' | 'policy' | 'reduce' | 'render' | 'action'; + surfaceId?: string; + transactionId?: string; + path?: string; + recoverable: boolean; + message: string; + cause?: unknown; +} +``` + +开发环境可以输出详细 cause;生产回调默认不包含组件 props、dataModel 和 Action 参数原文。 + +## 15. 安全和资源限制 + +默认限制建议: + +| 项目 | 默认值 | 可配置范围 | +| -------------------- | ------: | --------------: | +| 单 Surface 节点数 | 2,000 | 100 - 10,000 | +| 图最大深度 | 64 | 8 - 256 | +| 单事务 operations | 2,500 | 10 - 20,000 | +| 单次 dataModel 更新 | 256 KiB | 16 KiB - 2 MiB | +| 单 Catalog 大小 | 1 MiB | 64 KiB - 5 MiB | +| Action Context | 64 KiB | 4 KiB - 512 KiB | +| Catalog 加载超时 | 5 秒 | 1 - 30 秒 | +| 稳定 Snapshot 保留数 | 3 | 1 - 10 | + +这些限制用于阻止内存和计算资源耗尽。达到限制时拒绝当前事务,不截断数据后继续渲染。 + +额外边界: + +- 组件 props 不允许 `dangerouslySetInnerHTML`,除非宿主自定义组件自己处理且策略显式允许。 +- Catalog 不能声明事件处理函数、URL import 或脚本表达式。 +- URL、图片、下载和导航类属性由宿主 URL Policy 二次校验协议与域名。 +- 键名 `__proto__`、`prototype`、`constructor` 在 JSON Pointer 和对象更新中拒绝,防止原型污染。 +- 审计和调试器展示的数据先通过统一脱敏器。 + +## 16. 调试器与测试工具 + +### 16.1 DevTools + +调试面板提供以下视图: + +- Events:原始输入、归一化事务、revision、耗时和拒绝原因。 +- Surface Tree:节点图、根节点、不可达节点和订阅关系。 +- Data:surface/shared data、绑定路径和表单校验结果。 +- Catalog:来源、版本、缓存命中、Schema 编译和组件注册状态。 +- Security:Action 风险、权限决策、确认和审计链。 +- Performance:decode、validate、reduce、React commit 和节点重渲染计数。 + +DevTools 通过 Runtime Observer API 订阅只读事件,生产构建可 tree-shake,不允许直接修改 Runtime 状态。 + +### 16.2 测试工具 + +```ts +const harness = createSurfaceTestHarness({ catalogs, policy }); + +await harness.dispatch(commands); +expect(harness.surface('booking')).toMatchSurfaceSnapshot(); +expect(harness.issues()).toEqual([]); +expect(harness.audit()).toContainActionDecision('submit', 'allowed'); +``` + +提供: + +- `validateCatalog(manifest)`:离线结构和 Schema 编译检查。 +- `createSurfaceTestHarness()`:无 React 的命令重放和断言。 +- `toMatchSurfaceSnapshot()`:稳定序列化节点图和 dataModel。 +- Action Policy 测试构造器。 +- v0.8/v0.9 等价序列测试。 +- 非法 Schema、循环图、重复事件、乱序 revision 和资源上限的属性/模糊测试。 + +## 17. 性能方案和门禁 + +### 17.1 性能原则 + +- 命令增量消费,不扫描完整历史数组。 +- 节点 Map 结构共享,局部更新只替换受影响节点。 +- Schema 在 Catalog 注册时编译,运行时复用 validator。 +- dataModel 按绑定路径订阅,避免任何数据变化都重渲染整棵树。 +- 大批量事务在可中断调度中分段验证,但只允许最终原子提交。 +- DevTools 和审计序列化不进入生产热路径。 + +### 17.2 目标门禁 + +在固定 Playwright Chromium、4 倍 CPU 降速、无浏览器扩展的基准环境中: + +| 场景 | 目标 | +| --- | --: | +| 1,000 节点 Catalog 已编译,normalize + validate + reduce,p95 | <= 50 ms | +| 1,000 节点首次 React commit,p95 | <= 500 ms | +| 20 节点局部更新端到端,p95 | <= 50 ms | +| 表单输入交互 INP,p75 | <= 200 ms | +| 单节点 dataModel 更新 | 不得导致无关节点重渲染 | +| 相同 1,000 节点命令连续追加 100 次 | 总处理量近似线性,不得出现完整历史重放 | + +CI 同时保存绝对值和相对基线。绝对值用于验收,连续两个 PR 相对回退超过 10% 时阻断合并并人工复核。 + +## 18. A2UI v0.8/v0.9 迁移策略 + +### 18.1 归一化映射 + +| v0.8 | v0.9 | 内部 Operation | +| ----------------------------- | ----------------------------- | ---------------- | +| 首次 `surfaceUpdate` 隐式创建 | `createSurface` | `surface.create` | +| `surfaceUpdate.components` | `updateComponents.components` | `node.upsert` | +| `dataModelUpdate.contents` | `updateDataModel.path/value` | `data.set` | +| `beginRendering.root` | 首批组件到达后默认 root | `render.begin` | +| `deleteSurface` | `deleteSurface` | `surface.delete` | + +### 18.2 支持周期 + +- 3.0 同时支持 v0.8/v0.9;v0.9 是生产功能基线。 +- v0.8 进入维护模式,只修复安全和兼容问题,不增加跨 Surface、版本协商等新语义。 +- 开发环境对 v0.8 输出一次性迁移提示,DevTools 可导出等价 v0.9 命令。 +- v0.8 移除至少满足:提前两个 Minor 公告、提供自动迁移工具、官方示例完成迁移、稳定支持不少于六个月。 +- 协议未知时拒绝,不猜测为 v0.8。 + +## 19. 交付拆分 + +### 2027-01:Runtime 和安全底座 + +- 冻结 Normalized Operation、Surface Snapshot 和 Issue 类型。 +- 完成 v0.8/v0.9 Adapter、纯 Reducer、Store、幂等和 revision。 +- 完成 Catalog Registry、远程 Loader、缓存、版本协商和 Ajv 校验。 +- 建立组件/属性默认拒绝策略和资源限制。 +- `XCard.Box/Card` 接入兼容 Runtime。 +- 建立 1,000 节点基准和线性命令消费门禁。 + +退出条件:无 React 环境可重放两版 A2UI;非法 Catalog/组件/属性不会进入 Renderer;现有 Demo 通过兼容层运行。 + +### 2027-02:Action、数据和多 Surface + +- 完成 Action Policy、风险分级、Approval 接入和审计 Hook。 +- 完成 Surface 事务、稳定 Snapshot、回滚和 Error Boundary。 +- 完成 dataSchema、动态表单校验、shared data 和冲突策略。 +- 完成跨 Surface `dispatchBatch` 原子提交。 +- 接入阶段二 Artifact Renderer。 + +退出条件:越权 Action 默认拒绝,高风险 Action 必经确认;表单、共享数据、多 Surface 创建/删除具备端到端测试。 + +### 2027-03:Agent Event/Command、SSR、性能和工具链 + +- 完成 Agent Event 0.2 Surface 事件、Agent Command `surface.action`、SDK Reducer 和 `x-card` Bridge。 +- 完成 SSR、dehydrate/hydrate、流式游标续播和 Snapshot 版本检查。 +- 完成 DevTools、Catalog CLI/Test Harness 和可视化事件检查器。 +- 完成大型 Surface 性能优化、内存分析和安全模糊测试。 +- 发布 v0.8 -> v0.9 迁移文档和工具,完成 Beta/RC/GA。 + +退出条件:满足路线图四项验收标准,关键 API 经过至少 3 个真实项目试用。 + +## 20. 测试矩阵 + +| 层级 | 必测内容 | +| ------------- | ------------------------------------------------------------------------------ | +| Unit | Adapter 映射、Reducer 转换、JSON Pointer、安全键、版本协商、策略矩阵、缓存失效 | +| Contract | Catalog Schema、Agent Event 0.1/0.2、A2UI v0.8/v0.9、Snapshot 版本 | +| Integration | Catalog 加载到渲染、Action 审批、回滚、Artifact Renderer、多 Surface 事务 | +| SSR | 并发请求隔离、水合一致、Catalog 不一致降级、流式事件游标 | +| Security | 未注册组件/Action、恶意 URL、原型污染、超限载荷、Schema `$ref`、审计脱敏 | +| Performance | 1,000 节点冷启动、20 节点更新、连续流、表单输入、内存释放 | +| E2E | 客服表单、数据分析看板、研发 Artifact 三个真实 Agent 工作流 | +| Compatibility | 现有 x-card Demo、v0.8/v0.9 等价重放、2.x API 兼容告警 | + +新增代码覆盖率不低于 85%,Reducer、Security Policy、Catalog Validator 和 Snapshot 恢复分支要求 95% 以上。 + +## 21. 发布与观测 + +发布顺序: + +```text +experimental runtime -> opt-in alpha -> default strict beta -> RC -> GA +``` + +Alpha 期间可通过显式兼容选项观测被拒绝的旧输入,但该选项不得在生产模式静默放行。GA 前删除所有“记录警告后继续渲染”的默认路径。 + +建议暴露下列不绑定具体监控平台的指标 Hook: + +- Catalog 加载耗时、缓存命中、协商失败和 Schema 编译耗时。 +- Surface 创建/更新/删除、事务拒绝、回滚和渲染错误。 +- 节点数、图深度、事务大小和 React commit 耗时。 +- Action 请求、拒绝、确认、执行耗时和失败。 +- SSR Snapshot 大小、水合耗时和客户端重建率。 + +## 22. 依赖与关键路径 + +| 依赖 | 所属阶段/模块 | 处理方式 | +| --- | --- | --- | +| Agent Event 版本协商 | 阶段一 Runtime | 阶段三扩展协议解码器,不能继续单版本常量 | +| Agent Command 执行通道 | 阶段二 Runtime | 复用能力声明、幂等和串行机制,阶段三增加 `surface.action` | +| Approval 组件和状态 | 阶段二 Interaction | 作为高风险 Action 确认 UI;未就绪时只能由宿主注入确认器 | +| Artifact 容器和 Renderer Registry | 阶段二 Interaction | Surface 使用可选 `artifactId` 解耦等待 | +| 会话恢复/事件游标 | 阶段二 Runtime | SSR 流式水合与断线恢复共用游标语义 | +| Bundle Size 门禁 | Quality | Ajv、DevTools 必须拆分入口,避免全部进入主包 | + +关键路径是 Catalog/Schema -> Surface Runtime -> React 兼容层 -> Action/Approval/Agent Command -> Agent Event/Artifact -> SSR/性能。DevTools 和业务模板可以并行,不应阻塞 Runtime API 冻结。 + +## 23. 风险与应对 + +| 风险 | 影响 | 应对 | +| --- | --- | --- | +| 3.x 安全默认值破坏 2.x 宽松行为 | 迁移成本和社区反馈 | 提供兼容层、诊断工具和明确 Major 版本,不在 GA 降低安全默认值 | +| Ajv 增加包体和编译耗时 | 首屏性能 | validator 独立入口、Catalog 预编译、按需加载、建立 size-limit | +| A2UI 协议继续变化 | Adapter 反复修改 | 核心只接收 Normalized Operation,新增版本只增加 Adapter | +| Snapshot 保存导致内存增长 | 大型 Surface 崩溃 | 结构共享、限制保留数、按字节预算淘汰 | +| shared data 形成隐式耦合 | 更新环和调试困难 | 只允许显式 shared scope,事务检测循环写入 | +| 审计泄漏敏感数据 | 合规风险 | 默认摘要、Schema 标记、统一脱敏器、生产日志不输出原值 | +| 阶段二 API 未稳定 | Artifact/Approval 集成延期 | 通过 Bridge 和 Host Interface 解耦,Runtime 不依赖 UI 组件 | + +## 24. 待评审问题 + +1. Agent Event/Command 0.2 是否在实验命名空间内继续演进,还是在阶段二结束后一起转稳定入口? +2. Catalog Manifest 的版本字段沿用 SemVer,还是必须兼容 A2UI 官方 Catalog ID 的其他版本表达? +3. GA 是否完全禁止无 Catalog Surface,还是仅在明确的 `development` 模式提供不可发布的 Playground 例外? +4. Action 审计是否需要内置与阶段四 Observability 的 trace/span 关联字段? +5. shared data 是否需要首期支持应用外部 Store Adapter,还是仅支持受控 `value/onChange`? +6. Snapshot 是否包含最近稳定 revision,还是只保存当前状态并由应用持久化历史? +7. 1,000 节点基准的正式 CI 机器和浏览器版本需要 Quality 工作流冻结。 + +## 25. 系分完成门禁 + +进入编码前必须完成: + +- [ ] Generative UI、Runtime、Interaction、Quality 四方确认模块边界。 +- [ ] Normalized Operation、Surface Snapshot、Catalog Manifest 和 Action Policy 类型评审通过。 +- [ ] Agent Event 0.2 与 Artifact 关联方式完成 RFC 评审。 +- [ ] v0.8/v0.9 兼容样例各选三组,完成 Adapter 黄金用例。 +- [ ] 安全默认值、远程 Origin 策略和资源上限经过安全评审。 +- [ ] 1,000 节点基准页面、CI 环境和阈值完成基线采集。 +- [ ] 选定三个试点:动态表单、数据分析看板、Artifact 编辑器。 +- [ ] 阶段二 Approval/Artifact 尚未完成的接口以 Bridge Stub 固化,不阻塞 Runtime 开工。 diff --git a/packages/x-card/README.md b/packages/x-card/README.md index 135d81d13..e98e0a9ba 100644 --- a/packages/x-card/README.md +++ b/packages/x-card/README.md @@ -1,182 +1,118 @@ # @ant-design/x-card -React card loader for dynamic content loading and management. - -## Features - -- 🚀 **Dynamic Loading**: Load cards asynchronously with configurable concurrency -- 🔄 **Retry Mechanism**: Automatic retry with exponential backoff -- ⚡ **Performance**: Optimized for large datasets with virtual scrolling support -- 🎨 **Customizable**: Fully customizable card rendering and loading states -- 📱 **Responsive**: Mobile-friendly responsive design -- 🔧 **TypeScript**: Full TypeScript support +Render safe, declarative A2UI surfaces in React. The package supports A2UI v0.8 and v0.9 command streams, custom React component catalogs, data binding, actions, and an experimental headless Surface Runtime for validated transactional updates. ## Installation ```bash -npm install @ant-design/x-card -# or -yarn add @ant-design/x-card -# or -pnpm add @ant-design/x-card +npm install @ant-design/x-card antd ``` -## Usage +React and React DOM are peer dependencies. -### Basic Usage +## Component renderer -```tsx -import React from 'react'; -import { CardLoader } from '@ant-design/x-card'; +Use `XCard.Box` to provide the command stream and component catalog, then render one or more surfaces with `XCard.Card`. -const App = () => { - const cards = [ - { - id: '1', - title: 'Card 1', - content: 'This is card content', - }, - { - id: '2', - title: 'Card 2', - content: 'Another card content', +```tsx +import XCard, { type XAgentCommand_v0_9 } from '@ant-design/x-card'; +import { Button, Flex, Typography } from 'antd'; + +const Text = ({ text }: { text: string }) => {text}; + +const commands: XAgentCommand_v0_9[] = [ + { + version: 'v0.9', + createSurface: { surfaceId: 'welcome', catalogId: 'local://app' }, + }, + { + version: 'v0.9', + updateComponents: { + surfaceId: 'welcome', + components: [ + { id: 'root', component: 'Flex', children: ['title', 'confirm'], vertical: true }, + { id: 'title', component: 'Text', text: 'Ready to continue?' }, + { + id: 'confirm', + component: 'Button', + child: 'confirm-label', + action: { event: { name: 'confirm' } }, + }, + { id: 'confirm-label', component: 'Text', text: 'Confirm' }, + ], }, - ]; - - return ; -}; + }, +]; + +export default () => ( + console.log(action)} + > + + +); ``` -### Advanced Usage +For local or remote catalog setup, protocol command shapes, and complete demos, see the [x-card documentation](https://x.ant.design/x-cards/introduce). -```tsx -import React from 'react'; -import { CardLoader, useCardLoader } from '@ant-design/x-card'; - -const App = () => { - const { state, actions } = useCardLoader({ - config: { - maxConcurrent: 5, - retryCount: 3, - timeout: 10000, - }, - customLoader: async (card) => { - // Custom loading logic - const response = await fetch(`/api/cards/${card.id}`); - const data = await response.json(); - return data.content; +## Experimental Surface Runtime + +The headless Runtime normalizes protocol-specific commands into immutable Surface snapshots and validates every transaction against a strict catalog before commit. + +```ts +import { experimentalRuntime } from '@ant-design/x-card'; + +const catalogId = 'local://app'; +const catalogs = experimentalRuntime.createSurfaceCatalogRegistry({ + catalogs: [ + { + $id: catalogId, + components: { + Text: { + type: 'object', + required: ['text'], + properties: { text: {} }, + additionalProperties: false, + }, + }, }, - }); - - React.useEffect(() => { - actions.loadCards([ - { id: '1', title: 'Dynamic Card 1' }, - { id: '2', title: 'Dynamic Card 2' }, - ]); - }, []); - - return ( -
Loading {card.title}...
} - renderError={(error, card) =>
Error: {error.message}
} - /> - ); -}; + ], +}); + +const runtime = experimentalRuntime.createSurfaceRuntime({ + catalogs, + adapters: [experimentalRuntime.a2uiV09Adapter], +}); + +await runtime.dispatch({ + protocol: 'a2ui', + version: 'v0.9', + payload: { + version: 'v0.9', + createSurface: { surfaceId: 'welcome', catalogId }, + }, +}); ``` -### Using Hooks +The Runtime API is experimental and may change before it becomes a stable top-level export. -```tsx -import React from 'react'; -import { useCardLoader } from '@ant-design/x-card'; - -const App = () => { - const { state, actions } = useCardLoader(); - - const addNewCard = () => { - actions.addCard({ - id: Date.now().toString(), - title: 'New Card', - content: 'Dynamic content', - }); - }; - - return ( -
- - {state.cards.map((card) => ( -
-

{card.title}

-

{card.content}

-
- ))} -
- ); -}; -``` +## Exports -## API - -### CardLoader Props - -| Property | Type | Default | Description | -| ---------------- | ------------------ | ------- | ----------------------------- | -| cards | CardLoaderConfig[] | [] | Array of card configurations | -| config | CardLoaderConfig | - | Loader configuration | -| customLoader | function | - | Custom card loading function | -| renderEmpty | function | - | Custom empty state renderer | -| renderLoading | function | - | Custom loading state renderer | -| renderError | function | - | Custom error state renderer | -| onLoadingChange | function | - | Loading state change callback | -| onCardLoad | function | - | Card load success callback | -| onCardError | function | - | Card load error callback | -| onAllCardsLoaded | function | - | All cards loaded callback | - -### CardLoaderConfig - -| Property | Type | Default | Description | -| --- | --- | --- | --- | -| id | string | - | Unique card identifier | -| title | string | - | Card title | -| content | ReactNode | - | Card content | -| type | 'default' \| 'info' \| 'success' \| 'warning' \| 'error' | 'default' | Card type | -| loading | boolean | false | Loading state | -| closable | boolean | false | Whether card can be closed | -| size | 'small' \| 'middle' \| 'large' | 'middle' | Card size | -| disabled | boolean | false | Whether card is disabled | -| className | string | - | Custom CSS class | -| style | CSSProperties | - | Custom inline style | -| extra | ReactNode | - | Extra content in card header | - -### useCardLoader Hook - -Returns an object with: - -- `state`: Current loader state -- `actions`: Available actions - - `addCard(card)`: Add a new card - - `removeCard(id)`: Remove a card - - `updateCard(id, updates)`: Update a card - - `reloadCard(id)`: Reload a card - - `clearCards()`: Clear all cards - - `getCardState(id)`: Get card state - - `loadCards(cards)`: Load multiple cards +| Export | Purpose | +| --- | --- | +| `XCard`, `Box`, `Card` | React A2UI rendering components | +| `registerCatalog`, `loadCatalog`, `validateComponent` | Renderer catalog utilities | +| `XAgentCommand_v0_8`, `XAgentCommand_v0_9` | Protocol command types | +| `experimentalRuntime` | Headless adapters, catalogs, transactions, snapshots, and rollback | ## Development ```bash -# Install dependencies -npm install - -# Start development -npm run start - -# Run tests -npm test - -# Build -npm run compile +npm run tsc --workspace packages/x-card +npm test --workspace packages/x-card +npm run compile --workspace packages/x-card ``` ## License diff --git a/packages/x-card/package.json b/packages/x-card/package.json index f1d02be99..196aa1d43 100644 --- a/packages/x-card/package.json +++ b/packages/x-card/package.json @@ -1,14 +1,16 @@ { "name": "@ant-design/x-card", "version": "2.8.0", - "description": "React card loader for dynamic content loading and management", + "description": "A2UI dynamic surfaces and production runtime for React", "keywords": [ "A2UI", - "loader", + "agent UI", + "generative UI", + "runtime", "react", "ant-design" ], - "homepage": "https://x.ant.design/x-card", + "homepage": "https://x.ant.design/x-cards/introduce", "bugs": { "url": "https://github.com/ant-design/x/issues" }, diff --git a/packages/x-card/src/index.ts b/packages/x-card/src/index.ts index 2f6dfdda9..0096dda17 100644 --- a/packages/x-card/src/index.ts +++ b/packages/x-card/src/index.ts @@ -1,15 +1,16 @@ -export { default as version } from './version'; export { - default as XCard, - type XAgentCommand_v0_9, - type XAgentCommand_v0_8, type ActionPayload, type Catalog, type CatalogComponent, - registerCatalog, + clearCatalogCache, + default as XCard, loadCatalog, + registerCatalog, validateComponent, - clearCatalogCache, + type XAgentCommand_v0_8, + type XAgentCommand_v0_9, } from './A2UI'; -export { default as Card } from './A2UI/Card'; export { default as Box } from './A2UI/Box'; +export { default as Card } from './A2UI/Card'; +export * as experimentalRuntime from './runtime'; +export { default as version } from './version'; diff --git a/packages/x-card/src/runtime/__tests__/adapters.test.ts b/packages/x-card/src/runtime/__tests__/adapters.test.ts new file mode 100644 index 000000000..63594b22e --- /dev/null +++ b/packages/x-card/src/runtime/__tests__/adapters.test.ts @@ -0,0 +1,229 @@ +import { createA2UIV08Adapter } from '../adapters/a2uiV08'; +import { a2uiV09Adapter } from '../adapters/a2uiV09'; + +const input = (version: string, payload: unknown) => ({ + protocol: 'a2ui', + version, + payload, + eventId: `event-${version}`, + sequence: 1, +}); + +describe('A2UI v0.9 adapter', () => { + it('normalizes create, component, data, and delete commands', () => { + expect( + a2uiV09Adapter.normalize( + input('0.9', { + version: 'v0.9', + createSurface: { surfaceId: 'booking', catalogId: 'local://basic' }, + }), + ), + ).toMatchObject({ + surfaceId: 'booking', + operations: [{ type: 'surface.create', catalogId: 'local://basic' }], + }); + + expect( + a2uiV09Adapter.normalize( + input('0.9', { + version: 'v0.9', + updateComponents: { + surfaceId: 'booking', + components: [ + { id: 'root', component: 'Column', children: ['text'] }, + { id: 'text', component: 'Text', text: 'Hello' }, + ], + }, + }), + ), + ).toMatchObject({ + operations: [ + { + type: 'node.upsert', + nodes: [ + { id: 'root', type: 'Column', props: {}, children: ['text'] }, + { id: 'text', type: 'Text', props: { text: 'Hello' }, children: [] }, + ], + }, + { type: 'render.begin', rootId: 'root' }, + ], + }); + + expect( + a2uiV09Adapter.normalize( + input('0.9', { + version: 'v0.9', + updateDataModel: { surfaceId: 'booking', path: '/form/name', value: 'Ada' }, + }), + ), + ).toMatchObject({ + operations: [{ type: 'data.set', path: '/form/name', value: 'Ada' }], + }); + + expect( + a2uiV09Adapter.normalize( + input('0.9', { + version: 'v0.9', + deleteSurface: { surfaceId: 'booking' }, + }), + ), + ).toMatchObject({ operations: [{ type: 'surface.delete' }] }); + }); + + it('rejects malformed or unsupported commands', () => { + expect(() => a2uiV09Adapter.normalize(input('0.9', null))).toThrow('must be an object'); + expect(() => a2uiV09Adapter.normalize(input('0.9', { version: 'v0.8' }))).toThrow( + 'must declare version', + ); + expect(() => + a2uiV09Adapter.normalize( + input('0.9', { + version: 'v0.9', + updateComponents: { surfaceId: 'surface', components: {} }, + }), + ), + ).toThrow('must be an array'); + expect(() => + a2uiV09Adapter.normalize( + input('0.9', { + version: 'v0.9', + updateComponents: { + surfaceId: 'surface', + components: [{ id: 'root', component: 'Column', children: [''] }], + }, + }), + ), + ).toThrow('array of non-empty strings'); + expect(() => a2uiV09Adapter.normalize(input('0.9', { version: 'v0.9', unknown: {} }))).toThrow( + 'Unsupported', + ); + }); + + it('normalizes a single child without starting a new render', () => { + const result = a2uiV09Adapter.normalize({ + protocol: 'a2ui', + version: 'v0.9', + sequence: 8, + payload: { + version: 'v0.9', + updateComponents: { + surfaceId: 'surface', + components: [{ id: 'row', component: 'Column', child: 'text' }], + }, + }, + }); + + expect(result).toMatchObject({ + transactionId: 'a2ui:v0.9:8', + operations: [{ type: 'node.upsert', nodes: [{ id: 'row', children: ['text'] }] }], + }); + }); +}); + +describe('A2UI v0.8 adapter', () => { + it('normalizes implicit creation and explicitList children', () => { + const adapter = createA2UIV08Adapter({ catalogId: 'local://basic' }); + + expect( + adapter.normalize( + input('0.8', { + surfaceUpdate: { + surfaceId: 'booking', + components: [ + { + id: 'root', + component: { Column: { children: { explicitList: ['text'] } } }, + }, + { + id: 'text', + component: { Text: { text: { literalString: 'Hello' } } }, + }, + ], + }, + }), + ), + ).toMatchObject({ + operations: [ + { type: 'surface.create', catalogId: 'local://basic', ifAbsent: true }, + { + type: 'node.upsert', + nodes: [ + { id: 'root', type: 'Column', children: ['text'] }, + { id: 'text', type: 'Text', props: { text: { literalString: 'Hello' } } }, + ], + }, + ], + }); + }); + + it('normalizes data maps, beginRendering, and deleteSurface', () => { + const adapter = createA2UIV08Adapter({ catalogId: 'local://basic' }); + + expect( + adapter.normalize( + input('0.8', { + dataModelUpdate: { + surfaceId: 'booking', + contents: [ + { key: 'name', valueString: 'Ada' }, + { key: 'meta/data', valueMap: [{ key: 'role', valueString: 'admin' }] }, + ], + }, + }), + ), + ).toMatchObject({ + operations: [ + { type: 'data.set', path: '/name', value: 'Ada' }, + { type: 'data.set', path: '/meta~1data', value: { role: 'admin' } }, + ], + }); + expect( + adapter.normalize(input('0.8', { beginRendering: { surfaceId: 'booking', root: 'root' } })), + ).toMatchObject({ operations: [{ type: 'render.begin', rootId: 'root' }] }); + expect( + adapter.normalize(input('0.8', { deleteSurface: { surfaceId: 'booking' } })), + ).toMatchObject({ operations: [{ type: 'surface.delete' }] }); + }); + + it('requires a catalog and a single component type', () => { + expect(() => createA2UIV08Adapter({ catalogId: '' })).toThrow('requires a catalogId'); + const adapter = createA2UIV08Adapter({ catalogId: 'local://basic' }); + expect(() => + adapter.normalize( + input('0.8', { + surfaceUpdate: { + surfaceId: 'booking', + components: [{ id: 'root', component: { Column: {}, Row: {} } }], + }, + }), + ), + ).toThrow('exactly one type'); + }); + + it('rejects malformed data and unknown commands', () => { + const adapter = createA2UIV08Adapter({ catalogId: 'local://basic' }); + + expect(() => + adapter.normalize( + input('0.8', { + surfaceUpdate: { surfaceId: 'surface', components: {} }, + }), + ), + ).toThrow('must be an array'); + expect(() => + adapter.normalize( + input('0.8', { + dataModelUpdate: { surfaceId: 'surface', contents: {} }, + }), + ), + ).toThrow('must be an array'); + expect(() => + adapter.normalize( + input('0.8', { + dataModelUpdate: { surfaceId: 'surface', contents: [{ key: 'empty' }] }, + }), + ), + ).toThrow('must declare valueString or valueMap'); + expect(() => adapter.normalize(input('0.8', { unknown: {} }))).toThrow('Unsupported'); + }); +}); diff --git a/packages/x-card/src/runtime/__tests__/catalog.test.ts b/packages/x-card/src/runtime/__tests__/catalog.test.ts new file mode 100644 index 000000000..d82628196 --- /dev/null +++ b/packages/x-card/src/runtime/__tests__/catalog.test.ts @@ -0,0 +1,109 @@ +import { + CatalogRegistryError, + createSurfaceCatalogRegistry, + type SurfaceCatalog, +} from '../catalog'; + +const catalog: SurfaceCatalog = { + $id: 'local://basic', + components: { + Text: { + type: 'object', + required: ['text'], + properties: { text: {} }, + additionalProperties: false, + }, + }, +}; + +describe('SurfaceCatalogRegistry', () => { + it('registers and resolves a local catalog', async () => { + const registry = createSurfaceCatalogRegistry({ catalogs: [catalog] }); + + await expect(registry.resolve('local://basic')).resolves.toBe(catalog); + expect(registry.get('local://basic')).toBe(catalog); + }); + + it('requires a catalog id', () => { + const registry = createSurfaceCatalogRegistry(); + + expect(() => registry.register({ components: {} })).toThrow(CatalogRegistryError); + }); + + it('rejects missing catalogs without a loader', async () => { + const registry = createSurfaceCatalogRegistry(); + + await expect(registry.resolve('local://missing')).rejects.toMatchObject({ + code: 'catalog_not_found', + }); + }); + + it('deduplicates concurrent catalog loads and caches the result', async () => { + const loader = jest.fn(async () => catalog); + const registry = createSurfaceCatalogRegistry({ loader }); + + const [first, second] = await Promise.all([ + registry.resolve('local://basic'), + registry.resolve('local://basic'), + ]); + + expect(first).toBe(catalog); + expect(second).toBe(catalog); + expect(loader).toHaveBeenCalledTimes(1); + await registry.resolve('local://basic'); + expect(loader).toHaveBeenCalledTimes(1); + }); + + it('rejects a loaded catalog with a mismatched id and permits retry', async () => { + const loader = jest + .fn, [string]>() + .mockResolvedValueOnce({ $id: 'local://other', components: {} }) + .mockResolvedValueOnce(catalog); + const registry = createSurfaceCatalogRegistry({ loader }); + + await expect(registry.resolve('local://basic')).rejects.toMatchObject({ + code: 'catalog_id_mismatch', + }); + await expect(registry.resolve('local://basic')).resolves.toBe(catalog); + expect(loader).toHaveBeenCalledTimes(2); + }); + + it('strictly validates component names, required props, and extra props', () => { + const registry = createSurfaceCatalogRegistry({ catalogs: [catalog] }); + + expect( + registry.validateNode('local://basic', { + id: 'text', + type: 'Text', + props: { text: 'Hello' }, + }), + ).toEqual({ valid: true, errors: [] }); + expect( + registry.validateNode('local://basic', { id: 'text', type: 'Text', props: {} }), + ).toMatchObject({ valid: false }); + expect( + registry.validateNode('local://basic', { + id: 'text', + type: 'Text', + props: { text: 'Hello', unsafe: true }, + }), + ).toMatchObject({ valid: false }); + expect( + registry.validateNode('local://basic', { id: 'unknown', type: 'Unknown' }), + ).toMatchObject({ valid: false }); + expect(registry.validateNode('local://missing', { id: 'text', type: 'Text' })).toMatchObject({ + valid: false, + }); + }); + + it('clears registered catalogs', async () => { + const registry = createSurfaceCatalogRegistry({ catalogs: [catalog] }); + + registry.clear(); + + expect(registry.get('local://basic')).toBeUndefined(); + await expect(registry.resolve('local://basic')).rejects.toMatchObject({ + code: 'catalog_not_found', + }); + }); +}); diff --git a/packages/x-card/src/runtime/__tests__/runtime.test.ts b/packages/x-card/src/runtime/__tests__/runtime.test.ts new file mode 100644 index 000000000..47a6bf63e --- /dev/null +++ b/packages/x-card/src/runtime/__tests__/runtime.test.ts @@ -0,0 +1,482 @@ +import { createA2UIV08Adapter } from '../adapters/a2uiV08'; +import { a2uiV09Adapter } from '../adapters/a2uiV09'; +import { createSurfaceCatalogRegistry, type SurfaceCatalog } from '../catalog'; +import { type CreateSurfaceRuntimeOptions, createSurfaceRuntime } from '../createSurfaceRuntime'; +import type { SurfaceInput, SurfaceProtocolAdapter, SurfaceTransaction } from '../types'; + +const catalog: SurfaceCatalog = { + $id: 'local://basic', + components: { + Column: { type: 'object', properties: {}, additionalProperties: false }, + Text: { + type: 'object', + required: ['text'], + properties: { text: {} }, + additionalProperties: false, + }, + }, +}; + +const v09 = (eventId: string, payload: Record): SurfaceInput => ({ + protocol: 'a2ui', + version: '0.9', + eventId, + payload: { version: 'v0.9', ...payload }, +}); + +const internalAdapter: SurfaceProtocolAdapter = { + protocol: 'surface-internal', + versions: ['1'], + normalize(input) { + return input.payload as SurfaceTransaction; + }, +}; + +const transaction = ( + transactionId: string, + surfaceId: string, + operations: SurfaceTransaction['operations'], + expectedRevision?: number, +): SurfaceTransaction => ({ + transactionId, + surfaceId, + expectedRevision, + source: { protocol: 'surface-internal', version: '1', eventId: transactionId }, + operations, +}); + +const internalInput = (payload: SurfaceTransaction): SurfaceInput => ({ + protocol: 'surface-internal', + version: '1', + eventId: payload.transactionId, + payload, +}); + +const createRuntime = ({ + catalogs = createSurfaceCatalogRegistry({ catalogs: [catalog] }), + adapters = [ + a2uiV09Adapter, + createA2UIV08Adapter({ catalogId: 'local://basic' }), + internalAdapter, + ], + ...options +}: Partial = {}) => + createSurfaceRuntime({ catalogs, adapters, ...options }); + +const createV09Surface = async ( + runtime: ReturnType, + surfaceId = 'booking', +) => { + await runtime.dispatch( + v09(`create-${surfaceId}`, { + createSurface: { surfaceId, catalogId: 'local://basic' }, + }), + ); +}; + +describe('SurfaceRuntime', () => { + it('creates and renders a strict v0.9 Surface', async () => { + const runtime = createRuntime(); + + await createV09Surface(runtime); + const result = await runtime.dispatch( + v09('nodes', { + updateComponents: { + surfaceId: 'booking', + components: [ + { id: 'root', component: 'Column', children: ['text'] }, + { id: 'text', component: 'Text', text: 'Hello' }, + ], + }, + }), + ); + + expect(result.accepted).toBe(true); + expect(runtime.getSurface('booking')).toMatchObject({ + status: 'rendering', + revision: 2, + rootId: 'root', + catalog: { id: 'local://basic' }, + }); + expect(runtime.getSurface('booking')?.nodes.get('text')).toMatchObject({ + type: 'Text', + props: { text: 'Hello' }, + }); + }); + + it('updates data immutably and blocks prototype pollution', async () => { + const runtime = createRuntime(); + await createV09Surface(runtime); + const before = runtime.getSurface('booking')?.dataModel; + + const accepted = await runtime.dispatch( + v09('data', { + updateDataModel: { surfaceId: 'booking', path: '/form/name', value: 'Ada' }, + }), + ); + const rejected = await runtime.dispatch( + v09('pollution', { + updateDataModel: { + surfaceId: 'booking', + path: '/__proto__/polluted', + value: true, + }, + }), + ); + const rootPath = await runtime.dispatch( + v09('root-path', { + updateDataModel: { surfaceId: 'booking', path: '/', value: true }, + }), + ); + const invalidEscape = await runtime.dispatch( + v09('invalid-escape', { + updateDataModel: { surfaceId: 'booking', path: '/bad~2path', value: true }, + }), + ); + + expect(accepted.accepted).toBe(true); + expect(runtime.getSurface('booking')?.dataModel).toEqual({ form: { name: 'Ada' } }); + expect(runtime.getSurface('booking')?.dataModel).not.toBe(before); + expect(rejected).toMatchObject({ + accepted: false, + issue: { code: 'schema_validation_failed' }, + }); + expect(rootPath.issue?.code).toBe('schema_validation_failed'); + expect(invalidEscape.issue?.code).toBe('schema_validation_failed'); + expect(({} as Record).polluted).toBeUndefined(); + }); + + it('deduplicates event ids and emits one notification per commit', async () => { + const runtime = createRuntime(); + const listener = jest.fn(); + runtime.subscribe(listener); + const input = v09('create-once', { + createSurface: { surfaceId: 'booking', catalogId: 'local://basic' }, + }); + + const first = await runtime.dispatch(input); + const second = await runtime.dispatch(input); + + expect(first.accepted).toBe(true); + expect(second).toMatchObject({ accepted: true, duplicate: true }); + expect(second.snapshot).toBe(first.snapshot); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('rejects missing catalogs, components, and required properties by default', async () => { + const onIssue = jest.fn(); + const runtime = createRuntime({ onIssue }); + + const missingCatalog = await runtime.dispatch( + v09('missing-catalog', { + createSurface: { surfaceId: 'missing', catalogId: 'local://missing' }, + }), + ); + await createV09Surface(runtime); + const unknownComponent = await runtime.dispatch( + v09('unknown-component', { + updateComponents: { + surfaceId: 'booking', + components: [{ id: 'root', component: 'RemoteCode' }], + }, + }), + ); + const missingProp = await runtime.dispatch( + v09('missing-prop', { + updateComponents: { + surfaceId: 'booking', + components: [{ id: 'root', component: 'Text' }], + }, + }), + ); + + expect(missingCatalog.issue?.code).toBe('catalog_not_found'); + expect(unknownComponent.issue?.code).toBe('component_not_allowed'); + expect(missingProp.issue?.code).toBe('schema_validation_failed'); + expect(runtime.getSurface('booking')?.revision).toBe(1); + expect(onIssue).toHaveBeenCalledTimes(3); + }); + + it('atomically rejects an invalid batch', async () => { + const runtime = createRuntime(); + + const result = await runtime.dispatchBatch([ + v09('batch-create', { + createSurface: { surfaceId: 'booking', catalogId: 'local://basic' }, + }), + v09('batch-invalid', { + updateComponents: { + surfaceId: 'booking', + components: [{ id: 'root', component: 'Unknown' }], + }, + }), + ]); + + expect(result.accepted).toBe(false); + expect(runtime.getSurface('booking')).toBeUndefined(); + }); + + it('rejects stale revisions without changing the committed snapshot', async () => { + const runtime = createRuntime(); + const create = internalInput( + transaction('internal-create', 'booking', [ + { type: 'surface.create', catalogId: 'local://basic' }, + ]), + ); + await runtime.dispatch(create); + const committed = runtime.getSnapshot(); + + const result = await runtime.dispatch( + internalInput( + transaction( + 'stale-update', + 'booking', + [{ type: 'data.set', path: '/name', value: 'Ada' }], + 0, + ), + ), + ); + + expect(result.issue?.code).toBe('revision_conflict'); + expect(runtime.getSnapshot()).toBe(committed); + }); + + it('rejects dangling references, cycles, depth, and node limits', async () => { + const runtime = createRuntime({ limits: { maxNodesPerSurface: 2, maxDepth: 2 } }); + await createV09Surface(runtime); + + const dangling = await runtime.dispatch( + v09('dangling', { + updateComponents: { + surfaceId: 'booking', + components: [{ id: 'root', component: 'Column', children: ['missing'] }], + }, + }), + ); + const cycle = await runtime.dispatch( + v09('cycle', { + updateComponents: { + surfaceId: 'booking', + components: [{ id: 'root', component: 'Column', children: ['root'] }], + }, + }), + ); + const tooDeep = await runtime.dispatch( + v09('deep', { + updateComponents: { + surfaceId: 'booking', + components: [ + { id: 'root', component: 'Column', children: ['one'] }, + { id: 'one', component: 'Column', children: ['two'] }, + { id: 'two', component: 'Text', text: 'end' }, + ], + }, + }), + ); + + expect(dangling.issue?.code).toBe('graph_invariant_failed'); + expect(cycle.issue?.code).toBe('graph_invariant_failed'); + expect(tooDeep.issue?.code).toBe('limit_exceeded'); + expect(runtime.getSurface('booking')?.revision).toBe(1); + + const depthRuntime = createRuntime({ limits: { maxNodesPerSurface: 5, maxDepth: 2 } }); + await createV09Surface(depthRuntime, 'deep'); + const depthResult = await depthRuntime.dispatch( + v09('depth-only', { + updateComponents: { + surfaceId: 'deep', + components: [ + { id: 'root', component: 'Column', children: ['one'] }, + { id: 'one', component: 'Column', children: ['two'] }, + { id: 'two', component: 'Text', text: 'end' }, + ], + }, + }), + ); + expect(depthResult.issue?.code).toBe('limit_exceeded'); + }); + + it('validates internal operations and graph mutations', async () => { + const runtime = createRuntime({ limits: { maxOperationsPerTransaction: 1 } }); + await createV09Surface(runtime); + + const empty = await runtime.dispatch(internalInput(transaction('empty', 'booking', []))); + const tooMany = await runtime.dispatch( + internalInput( + transaction('too-many', 'booking', [ + { type: 'data.set', path: '/one', value: 1 }, + { type: 'data.set', path: '/two', value: 2 }, + ]), + ), + ); + const duplicateNodes = await runtime.dispatch( + internalInput( + transaction('duplicate-nodes', 'booking', [ + { + type: 'node.upsert', + nodes: [ + { id: 'root', type: 'Column' }, + { id: 'root', type: 'Column' }, + ], + }, + ]), + ), + ); + const invalidNode = await runtime.dispatch( + internalInput( + transaction('invalid-node', 'booking', [ + { type: 'node.upsert', nodes: [{ id: '', type: 'Column' }] }, + ]), + ), + ); + const missingRoot = await runtime.dispatch( + internalInput( + transaction('missing-root', 'booking', [{ type: 'render.begin', rootId: 'missing' }]), + ), + ); + + expect(empty.issue?.code).toBe('invalid_command'); + expect(tooMany.issue?.code).toBe('limit_exceeded'); + expect(duplicateNodes.issue?.code).toBe('schema_validation_failed'); + expect(invalidNode.issue?.code).toBe('schema_validation_failed'); + expect(missingRoot.issue?.code).toBe('graph_invariant_failed'); + expect(runtime.getSurface('booking')?.revision).toBe(1); + }); + + it('removes nodes transactionally and rejects updates after deletion', async () => { + const runtime = createRuntime(); + await createV09Surface(runtime); + await runtime.dispatch( + v09('render-removable', { + updateComponents: { + surfaceId: 'booking', + components: [ + { id: 'root', component: 'Column', children: ['text'] }, + { id: 'text', component: 'Text', text: 'Hello' }, + ], + }, + }), + ); + + const removeReferenced = await runtime.dispatch( + internalInput( + transaction('remove-referenced', 'booking', [{ type: 'node.remove', nodeIds: ['text'] }]), + ), + ); + expect(removeReferenced.issue?.code).toBe('graph_invariant_failed'); + + await runtime.dispatch(v09('delete-for-update', { deleteSurface: { surfaceId: 'booking' } })); + const updateDeleted = await runtime.dispatch( + internalInput( + transaction('update-deleted', 'booking', [ + { type: 'data.set', path: '/name', value: 'Ada' }, + ]), + ), + ); + expect(updateDeleted.issue?.code).toBe('invalid_transition'); + }); + + it('supports v0.8 implicit creation, repeated updates, data, and rendering', async () => { + const runtime = createRuntime(); + const v08 = (eventId: string, payload: unknown): SurfaceInput => ({ + protocol: 'a2ui', + version: '0.8', + eventId, + payload, + }); + + await runtime.dispatch( + v08('v08-surface', { + surfaceUpdate: { + surfaceId: 'legacy', + components: [{ id: 'root', component: { Text: { text: 'First' } } }], + }, + }), + ); + await runtime.dispatch( + v08('v08-update', { + surfaceUpdate: { + surfaceId: 'legacy', + components: [{ id: 'root', component: { Text: { text: 'Second' } } }], + }, + }), + ); + await runtime.dispatch( + v08('v08-data', { + dataModelUpdate: { + surfaceId: 'legacy', + contents: [{ key: 'name', valueString: 'Ada' }], + }, + }), + ); + const rendered = await runtime.dispatch( + v08('v08-render', { beginRendering: { surfaceId: 'legacy', root: 'root' } }), + ); + + expect(rendered.accepted).toBe(true); + expect(runtime.getSurface('legacy')).toMatchObject({ + status: 'rendering', + revision: 4, + dataModel: { name: 'Ada' }, + }); + expect(runtime.getSurface('legacy')?.nodes.get('root')?.props).toEqual({ text: 'Second' }); + }); + + it('deletes and rolls back to a stable revision with a new revision number', async () => { + const runtime = createRuntime(); + await createV09Surface(runtime); + await runtime.dispatch( + v09('data-before-delete', { + updateDataModel: { surfaceId: 'booking', path: '/name', value: 'Ada' }, + }), + ); + await runtime.dispatch(v09('delete', { deleteSurface: { surfaceId: 'booking' } })); + + expect(runtime.getSurface('booking')).toMatchObject({ status: 'deleted', revision: 3 }); + const rollback = runtime.rollback('booking', 2); + + expect(rollback.accepted).toBe(true); + expect(runtime.getSurface('booking')).toMatchObject({ + status: 'ready', + revision: 4, + dataModel: { name: 'Ada' }, + }); + + const previous = runtime.rollback('booking'); + expect(previous.accepted).toBe(true); + expect(runtime.getSurface('booking')).toMatchObject({ status: 'deleted', revision: 5 }); + }); + + it('reports unavailable rollback targets', () => { + const runtime = createRuntime(); + + expect(runtime.rollback('missing').issue?.code).toBe('missing_surface'); + }); + + it('rejects unsupported input, missing surfaces, duplicate creates, and disposed runtimes', async () => { + const runtime = createRuntime(); + const unsupported = await runtime.dispatch({ protocol: 'unknown', version: '1', payload: {} }); + const missing = await runtime.dispatch( + v09('missing-update', { + updateDataModel: { surfaceId: 'missing', path: '/name', value: 'Ada' }, + }), + ); + await createV09Surface(runtime); + const duplicate = await runtime.dispatch( + v09('create-again', { + createSurface: { surfaceId: 'booking', catalogId: 'local://basic' }, + }), + ); + runtime.dispose(); + const disposed = await runtime.dispatch( + v09('after-dispose', { + updateDataModel: { surfaceId: 'booking', path: '/name', value: 'Grace' }, + }), + ); + + expect(unsupported.issue?.code).toBe('unsupported_protocol'); + expect(missing.issue?.code).toBe('missing_surface'); + expect(duplicate.issue?.code).toBe('duplicate_surface'); + expect(disposed.issue?.code).toBe('invalid_transition'); + }); +}); diff --git a/packages/x-card/src/runtime/adapters/a2uiV08.ts b/packages/x-card/src/runtime/adapters/a2uiV08.ts new file mode 100644 index 000000000..6d6e024e7 --- /dev/null +++ b/packages/x-card/src/runtime/adapters/a2uiV08.ts @@ -0,0 +1,116 @@ +import { escapeJsonPointerSegment } from '../jsonPointer'; +import type { SurfaceProtocolAdapter } from '../types'; +import { + createNode, + createTransaction, + isRecord, + readChildren, + requireRecord, + requireString, + SurfaceAdapterError, +} from './shared'; + +export interface CreateA2UIV08AdapterOptions { + catalogId: string; +} + +export function createA2UIV08Adapter(options: CreateA2UIV08AdapterOptions): SurfaceProtocolAdapter { + if (!options.catalogId) throw new SurfaceAdapterError('A2UI v0.8 requires a catalogId.'); + + return { + protocol: 'a2ui', + versions: ['0.8', 'v0.8'], + normalize(input) { + const command = requireRecord(input.payload, 'A2UI v0.8 command'); + + if ('surfaceUpdate' in command) { + const payload = requireRecord(command.surfaceUpdate, 'surfaceUpdate'); + const surfaceId = requireString(payload.surfaceId, 'surfaceUpdate.surfaceId'); + if (!Array.isArray(payload.components)) { + throw new SurfaceAdapterError('surfaceUpdate.components must be an array.'); + } + const nodes = payload.components.map((component, index) => { + const value = requireRecord(component, `surfaceUpdate.components[${index}]`); + const id = requireString(value.id, `surfaceUpdate.components[${index}].id`); + const componentValue = requireRecord( + value.component, + `surfaceUpdate.components[${index}].component`, + ); + const entries = Object.entries(componentValue); + if (entries.length !== 1) { + throw new SurfaceAdapterError( + `surfaceUpdate.components[${index}].component must declare exactly one type.`, + ); + } + const [type, configValue] = entries[0]; + const config = requireRecord(configValue, `surfaceUpdate.components[${index}].${type}`); + const explicitList = isRecord(config.children) ? config.children.explicitList : undefined; + const children = readChildren( + config.child, + explicitList ?? config.children, + `surfaceUpdate.components[${index}].${type}`, + ); + return createNode(id, type, config, children, ['child', 'children']); + }); + return createTransaction(input, surfaceId, [ + { type: 'surface.create', catalogId: options.catalogId, ifAbsent: true }, + { type: 'node.upsert', nodes }, + ]); + } + + if ('dataModelUpdate' in command) { + const payload = requireRecord(command.dataModelUpdate, 'dataModelUpdate'); + const surfaceId = requireString(payload.surfaceId, 'dataModelUpdate.surfaceId'); + if (!Array.isArray(payload.contents)) { + throw new SurfaceAdapterError('dataModelUpdate.contents must be an array.'); + } + const operations = payload.contents.map((item, index) => { + const value = requireRecord(item, `dataModelUpdate.contents[${index}]`); + const key = requireString(value.key, `dataModelUpdate.contents[${index}].key`); + let nextValue: unknown; + if (typeof value.valueString === 'string') { + nextValue = value.valueString; + } else if (Array.isArray(value.valueMap)) { + nextValue = Object.fromEntries( + value.valueMap.map((entry, entryIndex) => { + const mapEntry = requireRecord( + entry, + `dataModelUpdate.contents[${index}].valueMap[${entryIndex}]`, + ); + return [ + requireString(mapEntry.key, 'valueMap.key'), + requireString(mapEntry.valueString, 'valueMap.valueString'), + ]; + }), + ); + } else { + throw new SurfaceAdapterError( + `dataModelUpdate.contents[${index}] must declare valueString or valueMap.`, + ); + } + return { + type: 'data.set' as const, + path: `/${escapeJsonPointerSegment(key)}`, + value: nextValue, + }; + }); + return createTransaction(input, surfaceId, operations); + } + + if ('beginRendering' in command) { + const payload = requireRecord(command.beginRendering, 'beginRendering'); + const surfaceId = requireString(payload.surfaceId, 'beginRendering.surfaceId'); + const rootId = requireString(payload.root, 'beginRendering.root'); + return createTransaction(input, surfaceId, [{ type: 'render.begin', rootId }]); + } + + if ('deleteSurface' in command) { + const payload = requireRecord(command.deleteSurface, 'deleteSurface'); + const surfaceId = requireString(payload.surfaceId, 'deleteSurface.surfaceId'); + return createTransaction(input, surfaceId, [{ type: 'surface.delete' }]); + } + + throw new SurfaceAdapterError('Unsupported A2UI v0.8 command.'); + }, + }; +} diff --git a/packages/x-card/src/runtime/adapters/a2uiV09.ts b/packages/x-card/src/runtime/adapters/a2uiV09.ts new file mode 100644 index 000000000..1e0a45e53 --- /dev/null +++ b/packages/x-card/src/runtime/adapters/a2uiV09.ts @@ -0,0 +1,71 @@ +import type { SurfaceOperation, SurfaceProtocolAdapter } from '../types'; +import { + createNode, + createTransaction, + readChildren, + requireRecord, + requireString, + SurfaceAdapterError, +} from './shared'; + +export const a2uiV09Adapter: SurfaceProtocolAdapter = { + protocol: 'a2ui', + versions: ['0.9', 'v0.9'], + normalize(input) { + const command = requireRecord(input.payload, 'A2UI v0.9 command'); + if (command.version !== 'v0.9') { + throw new SurfaceAdapterError('A2UI v0.9 command must declare version "v0.9".'); + } + + if ('createSurface' in command) { + const payload = requireRecord(command.createSurface, 'createSurface'); + const surfaceId = requireString(payload.surfaceId, 'createSurface.surfaceId'); + const catalogId = requireString(payload.catalogId, 'createSurface.catalogId'); + return createTransaction(input, surfaceId, [{ type: 'surface.create', catalogId }]); + } + + if ('updateComponents' in command) { + const payload = requireRecord(command.updateComponents, 'updateComponents'); + const surfaceId = requireString(payload.surfaceId, 'updateComponents.surfaceId'); + if (!Array.isArray(payload.components)) { + throw new SurfaceAdapterError('updateComponents.components must be an array.'); + } + const nodes = payload.components.map((component, index) => { + const value = requireRecord(component, `updateComponents.components[${index}]`); + const id = requireString(value.id, `updateComponents.components[${index}].id`); + const type = requireString( + value.component, + `updateComponents.components[${index}].component`, + ); + const children = readChildren( + value.child, + value.children, + `updateComponents.components[${index}]`, + ); + return createNode(id, type, value, children, ['id', 'component', 'child', 'children']); + }); + const operations: SurfaceOperation[] = [{ type: 'node.upsert', nodes }]; + if (nodes.some((node) => node.id === 'root')) { + operations.push({ type: 'render.begin' as const, rootId: 'root' }); + } + return createTransaction(input, surfaceId, operations); + } + + if ('updateDataModel' in command) { + const payload = requireRecord(command.updateDataModel, 'updateDataModel'); + const surfaceId = requireString(payload.surfaceId, 'updateDataModel.surfaceId'); + const path = requireString(payload.path, 'updateDataModel.path'); + return createTransaction(input, surfaceId, [ + { type: 'data.set', path, value: payload.value }, + ]); + } + + if ('deleteSurface' in command) { + const payload = requireRecord(command.deleteSurface, 'deleteSurface'); + const surfaceId = requireString(payload.surfaceId, 'deleteSurface.surfaceId'); + return createTransaction(input, surfaceId, [{ type: 'surface.delete' }]); + } + + throw new SurfaceAdapterError('Unsupported A2UI v0.9 command.'); + }, +}; diff --git a/packages/x-card/src/runtime/adapters/shared.ts b/packages/x-card/src/runtime/adapters/shared.ts new file mode 100644 index 000000000..678d6ee79 --- /dev/null +++ b/packages/x-card/src/runtime/adapters/shared.ts @@ -0,0 +1,74 @@ +import type { + SurfaceInput, + SurfaceNodeInput, + SurfaceOperation, + SurfaceTransaction, +} from '../types'; + +export class SurfaceAdapterError extends Error { + constructor(message: string) { + super(message); + this.name = 'SurfaceAdapterError'; + } +} + +export const isRecord = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value); + +export const requireRecord = (value: unknown, label: string): Record => { + if (!isRecord(value)) throw new SurfaceAdapterError(`${label} must be an object.`); + return value; +}; + +export const requireString = (value: unknown, label: string): string => { + if (typeof value !== 'string' || value === '') { + throw new SurfaceAdapterError(`${label} must be a non-empty string.`); + } + return value; +}; + +export const readChildren = ( + child: unknown, + children: unknown, + label: string, +): readonly string[] => { + if (children !== undefined) { + if (!Array.isArray(children) || children.some((item) => typeof item !== 'string' || !item)) { + throw new SurfaceAdapterError(`${label}.children must be an array of non-empty strings.`); + } + return children; + } + if (child === undefined) return []; + return [requireString(child, `${label}.child`)]; +}; + +export const createTransaction = ( + input: SurfaceInput, + surfaceId: string, + operations: readonly SurfaceOperation[], +): SurfaceTransaction => ({ + transactionId: + input.eventId ?? `${input.protocol}:${input.version}:${input.sequence ?? 'unsequenced'}`, + surfaceId, + source: { + protocol: input.protocol, + version: input.version.replace(/^v/, ''), + eventId: input.eventId, + sequence: input.sequence, + }, + operations, +}); + +export const createNode = ( + id: string, + type: string, + source: Record, + children: readonly string[], + structuralKeys: readonly string[], +): SurfaceNodeInput => { + const props: Record = {}; + for (const [key, value] of Object.entries(source)) { + if (!structuralKeys.includes(key)) props[key] = value; + } + return { id, type, props, children }; +}; diff --git a/packages/x-card/src/runtime/catalog.ts b/packages/x-card/src/runtime/catalog.ts new file mode 100644 index 000000000..76e4b8ff3 --- /dev/null +++ b/packages/x-card/src/runtime/catalog.ts @@ -0,0 +1,146 @@ +import type { SurfaceNodeInput } from './types'; + +export interface SurfaceComponentSchema { + type?: 'object'; + required?: readonly string[]; + properties?: Readonly>; + additionalProperties?: boolean; +} + +export interface SurfaceCatalog { + $id?: string; + catalogId?: string; + components?: Readonly>; +} + +export type SurfaceCatalogLoader = (catalogId: string) => Promise; + +export interface CatalogValidationResult { + valid: boolean; + errors: readonly string[]; +} + +export interface SurfaceCatalogRegistry { + register(catalog: SurfaceCatalog): void; + get(catalogId: string): SurfaceCatalog | undefined; + resolve(catalogId: string): Promise; + validateNode(catalogId: string, node: SurfaceNodeInput): CatalogValidationResult; + clear(): void; +} + +export type CatalogRegistryErrorCode = 'catalog_not_found' | 'catalog_id_mismatch'; + +export class CatalogRegistryError extends Error { + code: CatalogRegistryErrorCode; + + constructor(code: CatalogRegistryErrorCode, message: string) { + super(message); + this.name = 'CatalogRegistryError'; + this.code = code; + } +} + +export interface CreateSurfaceCatalogRegistryOptions { + catalogs?: readonly SurfaceCatalog[]; + loader?: SurfaceCatalogLoader; +} + +const getCatalogId = (catalog: SurfaceCatalog) => catalog.$id ?? catalog.catalogId; + +export function createSurfaceCatalogRegistry( + options: CreateSurfaceCatalogRegistryOptions = {}, +): SurfaceCatalogRegistry { + const catalogs = new Map(); + const pendingLoads = new Map>(); + + const register = (catalog: SurfaceCatalog) => { + const catalogId = getCatalogId(catalog); + if (!catalogId) { + throw new CatalogRegistryError( + 'catalog_id_mismatch', + 'Catalog must declare either "$id" or "catalogId".', + ); + } + catalogs.set(catalogId, catalog); + }; + + options.catalogs?.forEach(register); + + return { + register, + get(catalogId) { + return catalogs.get(catalogId); + }, + async resolve(catalogId) { + const registered = catalogs.get(catalogId); + if (registered) return registered; + + if (!options.loader) { + throw new CatalogRegistryError( + 'catalog_not_found', + `Catalog "${catalogId}" is not registered.`, + ); + } + + const pending = pendingLoads.get(catalogId); + if (pending) return pending; + + const load = options + .loader(catalogId) + .then((catalog) => { + const resolvedId = getCatalogId(catalog); + if (resolvedId !== catalogId) { + throw new CatalogRegistryError( + 'catalog_id_mismatch', + `Loaded catalog "${resolvedId ?? ''}" does not match "${catalogId}".`, + ); + } + register(catalog); + return catalog; + }) + .finally(() => { + pendingLoads.delete(catalogId); + }); + + pendingLoads.set(catalogId, load); + return load; + }, + validateNode(catalogId, node) { + const catalog = catalogs.get(catalogId); + if (!catalog) { + return { valid: false, errors: [`Catalog "${catalogId}" is not registered.`] }; + } + + const component = catalog.components?.[node.type]; + if (!component) { + return { + valid: false, + errors: [`Component "${node.type}" is not declared by catalog "${catalogId}".`], + }; + } + + const props = node.props ?? {}; + const errors: string[] = []; + for (const field of component.required ?? []) { + if (!(field in props)) { + errors.push(`Missing required property "${field}" on component "${node.type}".`); + } + } + + if (component.additionalProperties === false) { + const allowed = new Set(Object.keys(component.properties ?? {})); + for (const property of Object.keys(props)) { + if (!allowed.has(property)) { + errors.push(`Property "${property}" is not allowed on component "${node.type}".`); + } + } + } + + return { valid: errors.length === 0, errors }; + }, + clear() { + catalogs.clear(); + pendingLoads.clear(); + }, + }; +} diff --git a/packages/x-card/src/runtime/createSurfaceRuntime.ts b/packages/x-card/src/runtime/createSurfaceRuntime.ts new file mode 100644 index 000000000..2fec27dac --- /dev/null +++ b/packages/x-card/src/runtime/createSurfaceRuntime.ts @@ -0,0 +1,246 @@ +import { SurfaceAdapterError } from './adapters/shared'; +import { CatalogRegistryError, type SurfaceCatalogRegistry } from './catalog'; +import { reduceSurfaceTransaction } from './reducer'; +import type { + SurfaceDispatchResult, + SurfaceInput, + SurfaceIssue, + SurfaceLimits, + SurfaceProtocolAdapter, + SurfaceRuntimeSnapshot, + SurfaceSnapshot, + SurfaceTransaction, +} from './types'; + +const defaultLimits: SurfaceLimits = { + maxNodesPerSurface: 2_000, + maxDepth: 64, + maxOperationsPerTransaction: 2_500, + historyLimit: 3, +}; + +export interface CreateSurfaceRuntimeOptions { + catalogs: SurfaceCatalogRegistry; + adapters: readonly SurfaceProtocolAdapter[]; + limits?: Partial; + onIssue?: (issue: SurfaceIssue) => void; +} + +export interface SurfaceRuntime { + dispatch(input: SurfaceInput): Promise; + dispatchBatch(inputs: readonly SurfaceInput[]): Promise; + rollback(surfaceId: string, revision?: number): SurfaceDispatchResult; + getSnapshot(): SurfaceRuntimeSnapshot; + getSurface(surfaceId: string): SurfaceSnapshot | undefined; + subscribe(listener: () => void): () => void; + dispose(): void; +} + +const rejected = ( + snapshot: SurfaceRuntimeSnapshot, + issue: SurfaceIssue, + onIssue?: (issue: SurfaceIssue) => void, +): SurfaceDispatchResult => { + onIssue?.(issue); + return { accepted: false, issue, snapshot }; +}; + +const cloneSurface = (surface: SurfaceSnapshot): SurfaceSnapshot => ({ + ...surface, + nodes: new Map(surface.nodes), + dataModel: { ...surface.dataModel }, +}); + +export function createSurfaceRuntime(options: CreateSurfaceRuntimeOptions): SurfaceRuntime { + const limits = { ...defaultLimits, ...options.limits }; + const adapterMap = new Map(); + options.adapters.forEach((adapter) => { + adapter.versions.forEach((version) => { + adapterMap.set(`${adapter.protocol}:${version}`, adapter); + }); + }); + + let snapshot: SurfaceRuntimeSnapshot = { surfaces: new Map() }; + let history = new Map(); + let processedTransactionIds = new Set(); + let nextInputId = 0; + let disposed = false; + const listeners = new Set<() => void>(); + const emit = () => + listeners.forEach((listener) => { + listener(); + }); + + const createIssue = ( + code: SurfaceIssue['code'], + phase: SurfaceIssue['phase'], + message: string, + cause?: unknown, + ): SurfaceIssue => ({ code, phase, message, recoverable: true, cause }); + + const normalize = (inputs: readonly SurfaceInput[]) => { + const transactions: SurfaceTransaction[] = []; + for (const originalInput of inputs) { + nextInputId += 1; + const input = { + ...originalInput, + eventId: originalInput.eventId ?? `surface-runtime:${nextInputId}`, + }; + const adapter = adapterMap.get(`${input.protocol}:${input.version}`); + if (!adapter) { + throw createIssue( + 'unsupported_protocol', + 'decode', + `No Surface adapter supports ${input.protocol} ${input.version}.`, + ); + } + const normalized = adapter.normalize(input); + if ('transactionId' in normalized) transactions.push(normalized); + else transactions.push(...normalized); + } + return transactions; + }; + + const dispatchBatch = async (inputs: readonly SurfaceInput[]): Promise => { + if (disposed) { + return rejected( + snapshot, + createIssue('invalid_transition', 'reduce', 'Surface Runtime is disposed.'), + options.onIssue, + ); + } + + let transactions: SurfaceTransaction[]; + try { + transactions = normalize(inputs); + } catch (error) { + const surfaceIssue = + error && typeof error === 'object' && 'code' in error && 'phase' in error + ? (error as SurfaceIssue) + : createIssue( + 'invalid_command', + 'decode', + error instanceof Error ? error.message : 'Unable to decode Surface input.', + error instanceof SurfaceAdapterError ? undefined : error, + ); + return rejected(snapshot, surfaceIssue, options.onIssue); + } + + const seenInBatch = new Set(); + const pending = transactions.filter((transaction) => { + if ( + processedTransactionIds.has(transaction.transactionId) || + seenInBatch.has(transaction.transactionId) + ) { + return false; + } + seenInBatch.add(transaction.transactionId); + return true; + }); + if (pending.length === 0) { + return { accepted: true, duplicate: true, snapshot }; + } + + try { + const catalogIds = new Set(); + pending.forEach((transaction) => { + transaction.operations.forEach((operation) => { + if (operation.type === 'surface.create') catalogIds.add(operation.catalogId); + }); + }); + await Promise.all([...catalogIds].map((catalogId) => options.catalogs.resolve(catalogId))); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unable to resolve Surface catalog.'; + return rejected( + snapshot, + createIssue( + 'catalog_not_found', + 'catalog', + message, + error instanceof CatalogRegistryError ? undefined : error, + ), + options.onIssue, + ); + } + + let draftSnapshot = snapshot; + const draftHistory = new Map(history); + for (const transaction of pending) { + const previous = draftSnapshot.surfaces.get(transaction.surfaceId); + const result = reduceSurfaceTransaction(draftSnapshot, transaction, { + catalogs: options.catalogs, + limits, + }); + if (!result.accepted) return rejected(snapshot, result.issue, options.onIssue); + + if (previous) { + const entries = [ + ...(draftHistory.get(transaction.surfaceId) ?? []), + cloneSurface(previous), + ]; + draftHistory.set(transaction.surfaceId, entries.slice(-limits.historyLimit)); + } + draftSnapshot = result.snapshot; + } + + snapshot = draftSnapshot; + history = draftHistory; + processedTransactionIds = new Set([...processedTransactionIds, ...seenInBatch]); + emit(); + return { accepted: true, snapshot }; + }; + + return { + dispatch(input) { + return dispatchBatch([input]); + }, + dispatchBatch, + rollback(surfaceId, revision) { + const current = snapshot.surfaces.get(surfaceId); + const entries = history.get(surfaceId) ?? []; + const target = + revision === undefined + ? entries[entries.length - 1] + : entries.find((entry) => entry.revision === revision); + if (!current || !target) { + return rejected( + snapshot, + { + code: current ? 'revision_conflict' : 'missing_surface', + phase: 'reduce', + message: current + ? `Surface revision ${revision ?? ''} is not available for rollback.` + : `Surface "${surfaceId}" does not exist.`, + recoverable: true, + surfaceId, + }, + options.onIssue, + ); + } + + const surfaces = new Map(snapshot.surfaces); + surfaces.set(surfaceId, { ...cloneSurface(target), revision: current.revision + 1 }); + const nextEntries = [...entries, cloneSurface(current)].slice(-limits.historyLimit); + history = new Map(history).set(surfaceId, nextEntries); + snapshot = { surfaces }; + emit(); + return { accepted: true, snapshot }; + }, + getSnapshot() { + return snapshot; + }, + getSurface(surfaceId) { + return snapshot.surfaces.get(surfaceId); + }, + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + dispose() { + disposed = true; + listeners.clear(); + history.clear(); + processedTransactionIds.clear(); + }, + }; +} diff --git a/packages/x-card/src/runtime/index.ts b/packages/x-card/src/runtime/index.ts new file mode 100644 index 000000000..f84521a4d --- /dev/null +++ b/packages/x-card/src/runtime/index.ts @@ -0,0 +1,32 @@ +export type { CreateA2UIV08AdapterOptions } from './adapters/a2uiV08'; +export { createA2UIV08Adapter } from './adapters/a2uiV08'; +export { a2uiV09Adapter } from './adapters/a2uiV09'; +export type { + CatalogRegistryErrorCode, + CatalogValidationResult, + CreateSurfaceCatalogRegistryOptions, + SurfaceCatalog, + SurfaceCatalogLoader, + SurfaceCatalogRegistry, + SurfaceComponentSchema, +} from './catalog'; +export { CatalogRegistryError, createSurfaceCatalogRegistry } from './catalog'; +export type { CreateSurfaceRuntimeOptions, SurfaceRuntime } from './createSurfaceRuntime'; +export { createSurfaceRuntime } from './createSurfaceRuntime'; +export type { + SurfaceCatalogRef, + SurfaceDispatchResult, + SurfaceInput, + SurfaceIssue, + SurfaceIssueCode, + SurfaceLimits, + SurfaceNode, + SurfaceNodeInput, + SurfaceOperation, + SurfaceProtocolAdapter, + SurfaceRuntimeSnapshot, + SurfaceSnapshot, + SurfaceStatus, + SurfaceTransaction, + SurfaceTransactionSource, +} from './types'; diff --git a/packages/x-card/src/runtime/jsonPointer.ts b/packages/x-card/src/runtime/jsonPointer.ts new file mode 100644 index 000000000..5af786f46 --- /dev/null +++ b/packages/x-card/src/runtime/jsonPointer.ts @@ -0,0 +1,64 @@ +const blockedKeys = new Set(['__proto__', 'prototype', 'constructor']); + +export class JsonPointerError extends Error { + constructor(message: string) { + super(message); + this.name = 'JsonPointerError'; + } +} + +export function escapeJsonPointerSegment(segment: string): string { + return segment.replace(/~/g, '~0').replace(/\//g, '~1'); +} + +function parseJsonPointer(path: string): string[] { + if (!path.startsWith('/') || path === '/') { + throw new JsonPointerError(`Data path "${path}" must point to a non-root JSON Pointer.`); + } + + return path + .slice(1) + .split('/') + .map((segment) => { + if (/~(?:[^01]|$)/.test(segment)) { + throw new JsonPointerError(`Data path "${path}" contains an invalid escape sequence.`); + } + const decoded = segment.replace(/~1/g, '/').replace(/~0/g, '~'); + if (blockedKeys.has(decoded)) { + throw new JsonPointerError(`Data path "${path}" contains a blocked key.`); + } + return decoded; + }); +} + +const cloneContainer = (value: unknown): Record | unknown[] => { + if (Array.isArray(value)) return [...value]; + if (value && typeof value === 'object') return { ...(value as Record) }; + return {}; +}; + +export function setValueAtJsonPointer( + value: Readonly>, + path: string, + nextValue: unknown, +): Readonly> { + const segments = parseJsonPointer(path); + const root = cloneContainer(value) as Record; + let target: Record | unknown[] = root; + let source: unknown = value; + + for (let index = 0; index < segments.length - 1; index += 1) { + const segment = segments[index]; + const sourceChild = + source && typeof source === 'object' + ? (source as Record)[segment] + : undefined; + const child = cloneContainer(sourceChild); + (target as Record)[segment] = child; + target = child; + source = sourceChild; + } + + (target as Record)[segments[segments.length - 1]] = nextValue; + return root; +} diff --git a/packages/x-card/src/runtime/reducer.ts b/packages/x-card/src/runtime/reducer.ts new file mode 100644 index 000000000..ab012636b --- /dev/null +++ b/packages/x-card/src/runtime/reducer.ts @@ -0,0 +1,315 @@ +import type { SurfaceCatalogRegistry } from './catalog'; +import { JsonPointerError, setValueAtJsonPointer } from './jsonPointer'; +import type { + SurfaceIssue, + SurfaceLimits, + SurfaceNode, + SurfaceNodeInput, + SurfaceRuntimeSnapshot, + SurfaceSnapshot, + SurfaceTransaction, +} from './types'; + +export interface ReduceSurfaceTransactionOptions { + catalogs: SurfaceCatalogRegistry; + limits: SurfaceLimits; +} + +export type ReduceSurfaceTransactionResult = + { accepted: true; snapshot: SurfaceRuntimeSnapshot } | { accepted: false; issue: SurfaceIssue }; + +const issue = ( + transaction: SurfaceTransaction, + code: SurfaceIssue['code'], + message: string, + path?: string, + cause?: unknown, +): SurfaceIssue => ({ + code, + phase: code.startsWith('catalog') || code.includes('allowed') ? 'catalog' : 'reduce', + message, + recoverable: true, + surfaceId: transaction.surfaceId, + transactionId: transaction.transactionId, + path, + cause, +}); + +const cloneNode = (node: SurfaceNodeInput): SurfaceNode => ({ + id: node.id, + type: node.type, + props: { ...(node.props ?? {}) }, + children: [...(node.children ?? [])], +}); + +function validateGraph( + surface: SurfaceSnapshot, + transaction: SurfaceTransaction, + limits: SurfaceLimits, +): SurfaceIssue | undefined { + if (surface.nodes.size > limits.maxNodesPerSurface) { + return issue( + transaction, + 'limit_exceeded', + `Surface "${surface.id}" has ${surface.nodes.size} nodes; the limit is ${limits.maxNodesPerSurface}.`, + ); + } + if (!surface.rootId) return undefined; + if (!surface.nodes.has(surface.rootId)) { + return issue( + transaction, + 'graph_invariant_failed', + `Root node "${surface.rootId}" does not exist.`, + ); + } + + const visiting = new Set(); + const visited = new Set(); + + const visit = (nodeId: string, depth: number): SurfaceIssue | undefined => { + if (depth > limits.maxDepth) { + return issue( + transaction, + 'limit_exceeded', + `Surface "${surface.id}" exceeds the maximum depth of ${limits.maxDepth}.`, + ); + } + if (visiting.has(nodeId)) { + return issue( + transaction, + 'graph_invariant_failed', + `Surface "${surface.id}" contains a cycle at node "${nodeId}".`, + ); + } + if (visited.has(nodeId)) return undefined; + + const node = surface.nodes.get(nodeId); + if (!node) { + return issue( + transaction, + 'graph_invariant_failed', + `Surface "${surface.id}" references missing node "${nodeId}".`, + ); + } + + visiting.add(nodeId); + for (const childId of node.children) { + const childIssue = visit(childId, depth + 1); + if (childIssue) return childIssue; + } + visiting.delete(nodeId); + visited.add(nodeId); + return undefined; + }; + + return visit(surface.rootId, 1); +} + +function validateNodes( + surface: SurfaceSnapshot, + nodes: readonly SurfaceNodeInput[], + transaction: SurfaceTransaction, + catalogs: SurfaceCatalogRegistry, +): SurfaceIssue | undefined { + const nodeIds = new Set(); + for (const node of nodes) { + if (!node.id || !node.type) { + return issue( + transaction, + 'schema_validation_failed', + 'Every node must declare a non-empty id and type.', + ); + } + if (nodeIds.has(node.id)) { + return issue( + transaction, + 'schema_validation_failed', + `Node "${node.id}" appears more than once in the same operation.`, + ); + } + nodeIds.add(node.id); + + const validation = catalogs.validateNode(surface.catalog.id, node); + if (!validation.valid) { + const componentDenied = validation.errors.some((error) => error.includes('not declared')); + return issue( + transaction, + componentDenied ? 'component_not_allowed' : 'schema_validation_failed', + validation.errors.join(' '), + `/nodes/${node.id}`, + ); + } + } + return undefined; +} + +export function reduceSurfaceTransaction( + snapshot: SurfaceRuntimeSnapshot, + transaction: SurfaceTransaction, + options: ReduceSurfaceTransactionOptions, +): ReduceSurfaceTransactionResult { + if (transaction.operations.length === 0) { + return { + accepted: false, + issue: issue(transaction, 'invalid_command', 'Surface transaction has no operations.'), + }; + } + if (transaction.operations.length > options.limits.maxOperationsPerTransaction) { + return { + accepted: false, + issue: issue( + transaction, + 'limit_exceeded', + `Surface transaction has ${transaction.operations.length} operations; the limit is ${options.limits.maxOperationsPerTransaction}.`, + ), + }; + } + + const current = snapshot.surfaces.get(transaction.surfaceId); + if ( + transaction.expectedRevision !== undefined && + transaction.expectedRevision !== (current?.revision ?? 0) + ) { + return { + accepted: false, + issue: issue( + transaction, + 'revision_conflict', + `Expected Surface revision ${transaction.expectedRevision}, received ${current?.revision ?? 0}.`, + ), + }; + } + + let draft = current; + for (const operation of transaction.operations) { + if (operation.type === 'surface.create') { + if (draft && draft.status !== 'deleted') { + if (!operation.ifAbsent) { + return { + accepted: false, + issue: issue( + transaction, + 'duplicate_surface', + `Surface "${transaction.surfaceId}" already exists.`, + ), + }; + } + if (draft.catalog.id !== operation.catalogId) { + return { + accepted: false, + issue: issue( + transaction, + 'invalid_transition', + `Surface "${transaction.surfaceId}" cannot change catalogs during implicit creation.`, + ), + }; + } + continue; + } + + draft = { + id: transaction.surfaceId, + status: 'ready', + protocol: { + name: transaction.source.protocol, + version: transaction.source.version, + }, + catalog: { id: operation.catalogId }, + revision: current?.revision ?? 0, + nodes: new Map(), + dataModel: {}, + }; + continue; + } + + if (!draft) { + return { + accepted: false, + issue: issue( + transaction, + 'missing_surface', + `Surface "${transaction.surfaceId}" does not exist.`, + ), + }; + } + if (draft.status === 'deleted') { + return { + accepted: false, + issue: issue( + transaction, + 'invalid_transition', + `Surface "${transaction.surfaceId}" is deleted.`, + ), + }; + } + + if (operation.type === 'node.upsert') { + const validationIssue = validateNodes(draft, operation.nodes, transaction, options.catalogs); + if (validationIssue) return { accepted: false, issue: validationIssue }; + const nodes = new Map(draft.nodes); + operation.nodes.forEach((node) => { + nodes.set(node.id, cloneNode(node)); + }); + draft = { ...draft, nodes }; + continue; + } + + if (operation.type === 'node.remove') { + const nodes = new Map(draft.nodes); + operation.nodeIds.forEach((nodeId) => { + nodes.delete(nodeId); + }); + draft = { ...draft, nodes }; + continue; + } + + if (operation.type === 'data.set') { + try { + draft = { + ...draft, + dataModel: setValueAtJsonPointer(draft.dataModel, operation.path, operation.value), + }; + } catch (error) { + return { + accepted: false, + issue: issue( + transaction, + 'schema_validation_failed', + error instanceof Error ? error.message : 'Invalid data path.', + operation.path, + error instanceof JsonPointerError ? undefined : error, + ), + }; + } + continue; + } + + if (operation.type === 'render.begin') { + draft = { ...draft, status: 'rendering', rootId: operation.rootId }; + continue; + } + + draft = { + ...draft, + status: 'deleted', + rootId: undefined, + nodes: new Map(), + dataModel: {}, + }; + } + + if (!draft) { + return { + accepted: false, + issue: issue(transaction, 'missing_surface', 'Surface transaction did not create a Surface.'), + }; + } + + const graphIssue = + draft.status === 'deleted' ? undefined : validateGraph(draft, transaction, options.limits); + if (graphIssue) return { accepted: false, issue: graphIssue }; + + const surfaces = new Map(snapshot.surfaces); + surfaces.set(transaction.surfaceId, { ...draft, revision: (current?.revision ?? 0) + 1 }); + return { accepted: true, snapshot: { surfaces } }; +} diff --git a/packages/x-card/src/runtime/types.ts b/packages/x-card/src/runtime/types.ts new file mode 100644 index 000000000..6ec7c0a20 --- /dev/null +++ b/packages/x-card/src/runtime/types.ts @@ -0,0 +1,132 @@ +export type SurfaceStatus = 'ready' | 'rendering' | 'deleted'; + +export interface SurfaceNode { + id: string; + type: string; + props: Readonly>; + children: readonly string[]; +} + +export interface SurfaceCatalogRef { + id: string; +} + +export interface SurfaceSnapshot { + id: string; + status: SurfaceStatus; + protocol: { + name: string; + version: string; + }; + catalog: SurfaceCatalogRef; + revision: number; + rootId?: string; + nodes: ReadonlyMap; + dataModel: Readonly>; +} + +export interface SurfaceRuntimeSnapshot { + surfaces: ReadonlyMap; +} + +export interface SurfaceTransactionSource { + protocol: string; + version: string; + eventId?: string; + sequence?: number; +} + +export interface SurfaceNodeInput { + id: string; + type: string; + props?: Readonly>; + children?: readonly string[]; +} + +export type SurfaceOperation = + | { + type: 'surface.create'; + catalogId: string; + ifAbsent?: boolean; + } + | { + type: 'node.upsert'; + nodes: readonly SurfaceNodeInput[]; + } + | { + type: 'node.remove'; + nodeIds: readonly string[]; + } + | { + type: 'data.set'; + path: string; + value: unknown; + } + | { + type: 'render.begin'; + rootId: string; + } + | { + type: 'surface.delete'; + reason?: string; + }; + +export interface SurfaceTransaction { + transactionId: string; + surfaceId: string; + expectedRevision?: number; + source: SurfaceTransactionSource; + operations: readonly SurfaceOperation[]; +} + +export interface SurfaceInput { + protocol: string; + version: string; + payload: unknown; + eventId?: string; + sequence?: number; +} + +export interface SurfaceProtocolAdapter { + protocol: string; + versions: readonly string[]; + normalize(input: SurfaceInput): SurfaceTransaction | readonly SurfaceTransaction[]; +} + +export type SurfaceIssueCode = + | 'unsupported_protocol' + | 'invalid_command' + | 'catalog_not_found' + | 'component_not_allowed' + | 'schema_validation_failed' + | 'duplicate_surface' + | 'missing_surface' + | 'invalid_transition' + | 'revision_conflict' + | 'graph_invariant_failed' + | 'limit_exceeded'; + +export interface SurfaceIssue { + code: SurfaceIssueCode; + phase: 'decode' | 'catalog' | 'reduce'; + message: string; + recoverable: boolean; + surfaceId?: string; + transactionId?: string; + path?: string; + cause?: unknown; +} + +export interface SurfaceDispatchResult { + accepted: boolean; + duplicate?: boolean; + issue?: SurfaceIssue; + snapshot: SurfaceRuntimeSnapshot; +} + +export interface SurfaceLimits { + maxNodesPerSurface: number; + maxDepth: number; + maxOperationsPerTransaction: number; + historyLimit: number; +} diff --git a/packages/x/docs/x-card/a2ui-v-0-8.en-US.md b/packages/x/docs/x-card/a2ui-v-0-8.en-US.md index cbfaa9a05..5ec8d8f30 100644 --- a/packages/x/docs/x-card/a2ui-v-0-8.en-US.md +++ b/packages/x/docs/x-card/a2ui-v-0-8.en-US.md @@ -4,6 +4,7 @@ title: A2UI v0.8 --- +Production Runtime Basic Progressive Streaming @@ -13,6 +14,8 @@ title: A2UI v0.8 Form Validation Action Context Resolve +For protocol normalization, Catalog validation, atomic transactions, snapshots, and rollback in production, see [Surface Runtime](/x-cards/surface-runtime). + ## API Common props ref: [Common Props](/docs/react/common-props) diff --git a/packages/x/docs/x-card/a2ui-v-0-8.zh-CN.md b/packages/x/docs/x-card/a2ui-v-0-8.zh-CN.md index 27bfbe534..7a1f1eeff 100644 --- a/packages/x/docs/x-card/a2ui-v-0-8.zh-CN.md +++ b/packages/x/docs/x-card/a2ui-v-0-8.zh-CN.md @@ -4,6 +4,7 @@ title: A2UI v0.8 --- +生产级 Runtime 基础 渐进式 流式渲染 @@ -13,6 +14,8 @@ title: A2UI v0.8 表单验证 Action Context 解析 +生产环境的协议归一化、Catalog 校验、原子事务、快照和回滚用法见 [Surface Runtime](/x-cards/surface-runtime-cn)。 + ## API 通用属性参考:[通用属性](/docs/react/common-props) diff --git a/packages/x/docs/x-card/a2ui-v-0-9.en-US.md b/packages/x/docs/x-card/a2ui-v-0-9.en-US.md index 700b245f0..b66a9c0e3 100644 --- a/packages/x/docs/x-card/a2ui-v-0-9.en-US.md +++ b/packages/x/docs/x-card/a2ui-v-0-9.en-US.md @@ -4,6 +4,7 @@ title: A2UI v0.9 --- +Production Runtime Basic Progressive Streaming @@ -13,6 +14,8 @@ title: A2UI v0.9 Form Validation Action Context Resolve +For protocol normalization, Catalog validation, atomic transactions, snapshots, and rollback in production, see [Surface Runtime](/x-cards/surface-runtime). + ## API Common props ref: [Common Props](/docs/react/common-props) diff --git a/packages/x/docs/x-card/a2ui-v-0-9.zh-CN.md b/packages/x/docs/x-card/a2ui-v-0-9.zh-CN.md index cf939a70c..5e1c52bc7 100644 --- a/packages/x/docs/x-card/a2ui-v-0-9.zh-CN.md +++ b/packages/x/docs/x-card/a2ui-v-0-9.zh-CN.md @@ -4,6 +4,7 @@ title: A2UI v0.9 --- +生产级 Runtime 基础 渐进式 流式渲染 @@ -13,6 +14,8 @@ title: A2UI v0.9 表单验证 Action Context 解析 +生产环境的协议归一化、Catalog 校验、原子事务、快照和回滚用法见 [Surface Runtime](/x-cards/surface-runtime-cn)。 + ## API 通用属性参考:[通用属性](/docs/react/common-props) diff --git a/packages/x/docs/x-card/demo/A2UI_v0.8/runtime-production.md b/packages/x/docs/x-card/demo/A2UI_v0.8/runtime-production.md new file mode 100644 index 000000000..3530afca1 --- /dev/null +++ b/packages/x/docs/x-card/demo/A2UI_v0.8/runtime-production.md @@ -0,0 +1,7 @@ +## zh-CN + +生产级 Surface Runtime 演示。真实运行 A2UI v0.8 适配器、严格本地 Catalog、原子启动批次、`literalString` / `valueMap` 数据绑定、历史快照与回滚;通过注入未授权组件,验证旧协议输入同样会在提交前被拦截,且最后一个有效快照保持不变。 + +## en-US + +A production-oriented Surface Runtime demo using the real A2UI v0.8 adapter, a strict local Catalog, an atomic bootstrap batch, `literalString` / `valueMap` bindings, snapshot history, and rollback. Inject an unauthorized component to verify that legacy protocol input is rejected before commit while the last valid Snapshot remains active. diff --git a/packages/x/docs/x-card/demo/A2UI_v0.8/runtime-production.tsx b/packages/x/docs/x-card/demo/A2UI_v0.8/runtime-production.tsx new file mode 100644 index 000000000..38f746a5b --- /dev/null +++ b/packages/x/docs/x-card/demo/A2UI_v0.8/runtime-production.tsx @@ -0,0 +1,850 @@ +import { + CheckCircleFilled, + CloseCircleFilled, + CodeOutlined, + PlayCircleOutlined, + ReloadOutlined, + SafetyCertificateOutlined, + ThunderboltOutlined, + UndoOutlined, +} from '@ant-design/icons'; +import { experimentalRuntime } from '@ant-design/x-card'; +import { Button, Tooltip } from 'antd'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react'; + +const SURFACE_ID = 'ops-control-room'; +const CATALOG_ID = 'catalog://runtime-control-room'; + +const catalog = experimentalRuntime.createSurfaceCatalogRegistry({ + catalogs: [ + { + $id: CATALOG_ID, + components: { + Stack: { + type: 'object', + properties: { eyebrow: {}, title: {} }, + required: ['title'], + additionalProperties: false, + }, + Metric: { + type: 'object', + properties: { label: {}, value: {}, unit: {}, trend: {}, tone: {} }, + required: ['label', 'value', 'tone'], + additionalProperties: false, + }, + Signal: { + type: 'object', + properties: { label: {}, value: {}, detail: {}, tone: {} }, + required: ['label', 'value', 'tone'], + additionalProperties: false, + }, + Incident: { + type: 'object', + properties: { title: {}, status: {}, detail: {}, owner: {} }, + required: ['title', 'status', 'detail'], + additionalProperties: false, + }, + }, + }, + ], +}); + +const createRuntime = () => + experimentalRuntime.createSurfaceRuntime({ + catalogs: catalog, + adapters: [experimentalRuntime.createA2UIV08Adapter({ catalogId: CATALOG_ID })], + limits: { historyLimit: 16, maxNodesPerSurface: 64 }, + }); + +const initializedRuntimes = new WeakSet(); + +const command = (payload: unknown): experimentalRuntime.SurfaceInput => ({ + protocol: 'a2ui', + version: 'v0.8', + payload, +}); + +const toValueMap = (values: Readonly>) => + Object.entries(values).map(([key, value]) => ({ key, valueString: String(value) })); + +const initialFrame = { + throughput: 1842, + throughputTrend: '+12.4%', + latency: 86, + latencyTrend: '-8 ms', + confidence: 98.6, + confidenceTrend: '+0.7%', + policy: 'ENFORCED', + policyDetail: 'Catalog allowlist · schema strict', + incidentTitle: 'Checkout latency anomaly', + incidentStatus: 'MITIGATING', + incidentDetail: 'Traffic shifted to healthy inference pool', + owner: 'AGENT / ROUTER-02', +} as const; + +const bootstrapCommands = [ + command({ + surfaceUpdate: { + surfaceId: SURFACE_ID, + components: [ + { + id: 'root', + component: { + Stack: { + eyebrow: { literalString: 'A2UI v0.8 / COMPAT-7' }, + title: { literalString: 'Production Compatibility Surface' }, + children: { + explicitList: ['throughput', 'latency', 'confidence', 'signal', 'incident'], + }, + }, + }, + }, + { + id: 'throughput', + component: { + Metric: { + label: { literalString: 'TOKEN THROUGHPUT' }, + value: { path: '/live/throughput' }, + unit: { literalString: 'tok/s' }, + trend: { path: '/live/throughputTrend' }, + tone: { literalString: 'lime' }, + }, + }, + }, + { + id: 'latency', + component: { + Metric: { + label: { literalString: 'P95 LATENCY' }, + value: { path: '/live/latency' }, + unit: { literalString: 'ms' }, + trend: { path: '/live/latencyTrend' }, + tone: { literalString: 'cyan' }, + }, + }, + }, + { + id: 'confidence', + component: { + Metric: { + label: { literalString: 'MODEL CONFIDENCE' }, + value: { path: '/live/confidence' }, + unit: { literalString: '%' }, + trend: { path: '/live/confidenceTrend' }, + tone: { literalString: 'amber' }, + }, + }, + }, + { + id: 'signal', + component: { + Signal: { + label: { literalString: 'COMPATIBILITY GATE' }, + value: { path: '/live/policy' }, + detail: { path: '/live/policyDetail' }, + tone: { literalString: 'lime' }, + }, + }, + }, + { + id: 'incident', + component: { + Incident: { + title: { path: '/live/incidentTitle' }, + status: { path: '/live/incidentStatus' }, + detail: { path: '/live/incidentDetail' }, + owner: { path: '/live/owner' }, + }, + }, + }, + ], + }, + }), + command({ + dataModelUpdate: { + surfaceId: SURFACE_ID, + contents: [{ key: 'live', valueMap: toValueMap(initialFrame) }], + }, + }), + command({ + beginRendering: { surfaceId: SURFACE_ID, root: 'root' }, + }), +] as const; + +type TimelineTone = 'accepted' | 'rejected' | 'system'; + +interface TimelineItem { + id: number; + title: string; + detail: string; + tone: TimelineTone; + revision?: number; +} + +const streamFrames = [ + { + throughput: 2074, + throughputTrend: '+18.1%', + latency: 81, + latencyTrend: '-13 ms', + confidence: 98.9, + confidenceTrend: '+1.0%', + policy: 'ENFORCED', + policyDetail: 'Catalog allowlist · schema strict', + incidentTitle: 'Checkout latency anomaly', + incidentStatus: 'MITIGATING', + incidentDetail: 'Draining two degraded replicas', + owner: 'AGENT / ROUTER-02', + }, + { + throughput: 2388, + throughputTrend: '+26.7%', + latency: 74, + latencyTrend: '-20 ms', + confidence: 99.2, + confidenceTrend: '+1.3%', + policy: 'ENFORCED', + policyDetail: '0 unsafe nodes admitted', + incidentTitle: 'Checkout latency anomaly', + incidentStatus: 'STABILIZING', + incidentDetail: 'Error budget recovered to 99.97%', + owner: 'AGENT / ROUTER-02', + }, + { + throughput: 2614, + throughputTrend: '+31.9%', + latency: 68, + latencyTrend: '-26 ms', + confidence: 99.4, + confidenceTrend: '+1.5%', + policy: 'ENFORCED', + policyDetail: 'All transactions verified', + incidentTitle: 'Checkout latency anomaly', + incidentStatus: 'RESOLVED', + incidentDetail: 'Healthy capacity restored across all zones', + owner: 'AGENT / ROUTER-02', + }, +] as const; + +const wait = (milliseconds: number) => + new Promise((resolve) => { + window.setTimeout(resolve, milliseconds); + }); + +const readPointer = (source: Readonly>, pointer: string): unknown => { + if (pointer === '') return source; + return pointer + .slice(1) + .split('/') + .map((part) => part.replace(/~1/g, '/').replace(/~0/g, '~')) + .reduce((current, part) => { + if (!current || typeof current !== 'object') return undefined; + return (current as Record)[part]; + }, source); +}; + +const resolveValue = (value: unknown, dataModel: Readonly>): unknown => { + if (value && typeof value === 'object' && !Array.isArray(value)) { + if (typeof (value as { path?: unknown }).path === 'string') { + return readPointer(dataModel, (value as { path: string }).path); + } + if (typeof (value as { literalString?: unknown }).literalString === 'string') { + return (value as { literalString: string }).literalString; + } + } + return value; +}; + +const display = (value: unknown) => (value === undefined || value === null ? '—' : String(value)); + +interface SurfaceRendererProps { + surface?: experimentalRuntime.SurfaceSnapshot; +} + +const SurfaceRenderer: React.FC = ({ surface }) => { + if (!surface?.rootId) { + return
WAITING FOR SURFACE
; + } + + const renderNode = (nodeId: string): React.ReactNode => { + const node = surface.nodes.get(nodeId); + if (!node) return null; + const get = (key: string) => resolveValue(node.props[key], surface.dataModel); + + if (node.type === 'Stack') { + const children = node.children.map(renderNode); + return ( +
+
+ {display(get('eyebrow'))} +

{display(get('title'))}

+
+
{children.slice(0, 3)}
+
{children.slice(3)}
+
+ ); + } + + if (node.type === 'Metric') { + const tone = display(get('tone')); + const numericValue = Number(get('value')) || 0; + const barHeights = [0.42, 0.6, 0.48, 0.76, 0.64, 0.92, 0.72]; + return ( +
+
{display(get('label'))}
+
+ {numericValue.toLocaleString()} + {display(get('unit'))} +
+
+ + {display(get('trend'))} +
+
+ ); + } + + if (node.type === 'Signal') { + return ( +
+
+ +
+
+ {display(get('label'))} + {display(get('value'))} +
+ {display(get('detail'))} +
+ ); + } + + if (node.type === 'Incident') { + const status = display(get('status')); + return ( +
+
+
+ ACTIVE RESPONSE + {display(get('title'))} + {display(get('detail'))} +
+
+ {status} + {display(get('owner'))} +
+
+ ); + } + + return null; + }; + + return <>{renderNode(surface.rootId)}; +}; + +const RuntimeProductionDemo: React.FC = () => { + const [runtime, setRuntime] = useState(createRuntime); + const [timeline, setTimeline] = useState([]); + const [running, setRunning] = useState(false); + const [canRollback, setCanRollback] = useState(false); + const timelineId = useRef(0); + const runId = useRef(0); + + const snapshot = useSyncExternalStore( + runtime.subscribe, + runtime.getSnapshot, + runtime.getSnapshot, + ); + const surface = snapshot.surfaces.get(SURFACE_ID); + + const appendTimeline = useCallback( + (title: string, detail: string, tone: TimelineTone, revision?: number) => { + timelineId.current += 1; + const item = { id: timelineId.current, title, detail, tone, revision }; + setTimeline((current) => [item, ...current].slice(0, 8)); + }, + [], + ); + + useEffect(() => { + if (initializedRuntimes.has(runtime)) return; + initializedRuntimes.add(runtime); + void runtime.dispatchBatch(bootstrapCommands).then((result) => { + const revision = result.snapshot.surfaces.get(SURFACE_ID)?.revision; + appendTimeline( + 'V0.8 SURFACE COMMITTED', + `${bootstrapCommands.length} legacy commands applied atomically`, + result.accepted ? 'accepted' : 'rejected', + revision, + ); + }); + }, [appendTimeline, runtime]); + + const runStream = async () => { + const currentRun = runId.current + 1; + runId.current = currentRun; + setRunning(true); + appendTimeline('STREAM CONNECTED', 'Receiving v0.8 valueMap updates', 'system'); + + for (const [index, frame] of streamFrames.entries()) { + await wait(460); + if (runId.current !== currentRun) return; + const result = await runtime.dispatch( + command({ + dataModelUpdate: { + surfaceId: SURFACE_ID, + contents: [{ key: 'live', valueMap: toValueMap(frame) }], + }, + }), + ); + if (result.accepted) setCanRollback(true); + appendTimeline( + `PATCH ${String(index + 1).padStart(2, '0')} ACCEPTED`, + `${Object.keys(frame).length} bound values refreshed`, + result.accepted ? 'accepted' : 'rejected', + result.snapshot.surfaces.get(SURFACE_ID)?.revision, + ); + } + + if (runId.current === currentRun) { + setRunning(false); + appendTimeline('STREAM SETTLED', 'Surface reached a stable render state', 'system'); + } + }; + + const injectUnsafeNode = async () => { + const beforeRevision = runtime.getSurface(SURFACE_ID)?.revision; + const result = await runtime.dispatch( + command({ + surfaceUpdate: { + surfaceId: SURFACE_ID, + components: [ + { + id: 'remote-script', + component: { + UnsafeScript: { + source: { literalString: 'https://untrusted.example/payload.js' }, + }, + }, + }, + ], + }, + }), + ); + const afterRevision = result.snapshot.surfaces.get(SURFACE_ID)?.revision; + appendTimeline( + 'CATALOG GATE REJECTED', + `${result.issue?.code ?? 'unknown'} · revision ${beforeRevision} preserved`, + 'rejected', + afterRevision, + ); + }; + + const rollback = () => { + const result = runtime.rollback(SURFACE_ID); + appendTimeline( + result.accepted ? 'ROLLBACK COMMITTED' : 'ROLLBACK UNAVAILABLE', + result.accepted ? 'Previous immutable snapshot restored' : (result.issue?.message ?? ''), + result.accepted ? 'accepted' : 'rejected', + result.snapshot.surfaces.get(SURFACE_ID)?.revision, + ); + }; + + const reset = () => { + runId.current += 1; + setRunning(false); + setCanRollback(false); + setTimeline([]); + setRuntime((current) => { + current.dispose(); + return createRuntime(); + }); + }; + + const latestRejection = useMemo( + () => timeline.find((item) => item.tone === 'rejected'), + [timeline], + ); + + return ( +
+ +
+
+
+ +
+
+ SURFACE RUNTIME +

Control Room

+
+
+
+ + EDGE LINK ACTIVE + A2UI v0.8 +
+
+ +
+
+ + + + + + +
+
+ + STATUS {surface?.status.toUpperCase() ?? 'BOOTING'} + + + REV {String(surface?.revision ?? 0).padStart(2, '0')} + + + NODES {String(surface?.nodes.size ?? 0).padStart(2, '0')} + +
+
+ +
+
+
+ + RENDERED SNAPSHOT + + {SURFACE_ID} +
+ +
+ +
+ + {latestRejection ? 'UNTRUSTED UPDATE BLOCKED' : 'TRANSACTION GATE ARMED'} + + + {latestRejection + ? 'Last known-good Snapshot remains active' + : 'v0.8 adapter + Catalog validation before commit'} + +
+ {latestRejection ? 'STATE PRESERVED' : 'STRICT MODE'} +
+
+ + +
+
+ ); +}; + +const styles = ` +.runtime-demo { + --rt-bg: #080b0a; + --rt-panel: #0f1311; + --rt-panel-2: #131815; + --rt-line: #28302b; + --rt-muted: #7e8982; + --rt-text: #edf4ef; + --rt-lime: #b9f227; + --rt-cyan: #43d7e8; + --rt-amber: #ffbf3f; + --rt-red: #ff5d5d; + position: relative; + overflow: hidden; + color: var(--rt-text); + background: var(--rt-bg); + border: 1px solid #1d2420; + border-radius: 8px; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + letter-spacing: 0; + box-shadow: 0 22px 70px rgba(0, 0, 0, 0.26); + container-name: runtime-demo; + container-type: inline-size; +} +.runtime-demo *, .runtime-demo *::before, .runtime-demo *::after { box-sizing: border-box; } +.runtime-demo__header, .runtime-demo__toolbar, .runtime-demo__section-bar, +.runtime-demo__connection, .runtime-demo__runtime-stats, .runtime-demo__signal, +.runtime-demo__incident, .runtime-demo__security, .runtime-demo__event, +.runtime-demo__trace-footer { display: flex; align-items: center; } +.runtime-demo__header { + min-height: 78px; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--rt-line); +} +.runtime-demo__brand { display: flex; align-items: center; gap: 12px; min-width: 0; } +.runtime-demo__brand-mark { + display: grid; + width: 42px; + height: 42px; + place-items: center; + color: #080b0a; + background: var(--rt-lime); + border-radius: 6px; + font-size: 19px; + box-shadow: 0 0 24px rgba(185, 242, 39, 0.18); +} +.runtime-demo__brand span, .runtime-demo__surface-heading span, .runtime-demo__incident-copy > span { + display: block; + color: var(--rt-muted); + font: 600 10px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0; +} +.runtime-demo__brand h2 { + margin: 2px 0 0; + color: var(--rt-text); + font-size: 20px; + line-height: 1.15; + letter-spacing: 0; +} +.runtime-demo__connection { gap: 9px; color: #9ca79f; font-size: 11px; } +.runtime-demo__connection i, .runtime-demo__trace-footer i { + width: 7px; + height: 7px; + flex: none; + background: var(--rt-lime); + border-radius: 50%; + box-shadow: 0 0 0 4px rgba(185, 242, 39, 0.1); + animation: runtime-pulse 1.7s ease-in-out infinite; +} +.runtime-demo__connection code { + margin-left: 6px; + padding: 4px 7px; + color: var(--rt-cyan); + border: 1px solid #294247; + border-radius: 4px; + font-size: 10px; +} +.runtime-demo__toolbar { + min-height: 64px; + justify-content: space-between; + gap: 14px; + padding: 12px 20px; + background: #0c100e; + border-bottom: 1px solid var(--rt-line); +} +.runtime-demo__actions { display: flex; flex-wrap: wrap; gap: 8px; } +.runtime-demo .ant-btn { + height: 34px; + color: #cbd5ce; + background: #151b17; + border-color: #354038; + border-radius: 5px; + box-shadow: none; +} +.runtime-demo .ant-btn:hover:not(:disabled) { color: #fff; border-color: #68746c; background: #1b231e; } +.runtime-demo .runtime-demo__run { color: #0b100c; background: var(--rt-lime); border-color: var(--rt-lime); font-weight: 650; } +.runtime-demo .runtime-demo__run:hover:not(:disabled) { color: #0b100c; background: #d0ff51; border-color: #d0ff51; } +.runtime-demo .runtime-demo__danger { color: #ff9a9a; border-color: #6a3535; } +.runtime-demo__runtime-stats { align-self: stretch; gap: 0; border: 1px solid var(--rt-line); border-radius: 5px; } +.runtime-demo__runtime-stats span { + display: flex; + align-items: center; + gap: 8px; + height: 100%; + padding: 0 11px; + color: var(--rt-muted); + border-right: 1px solid var(--rt-line); + font: 600 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + white-space: nowrap; +} +.runtime-demo__runtime-stats span:last-child { border-right: 0; } +.runtime-demo__runtime-stats b { color: var(--rt-lime); font-size: 11px; } +.runtime-demo__workspace { display: grid; grid-template-columns: minmax(0, 1fr) 330px; min-height: 520px; } +.runtime-demo__viewport { min-width: 0; padding: 0 20px 20px; border-right: 1px solid var(--rt-line); } +.runtime-demo__section-bar { + height: 50px; + justify-content: space-between; + color: #a2ada6; + border-bottom: 1px solid var(--rt-line); + font: 600 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace; +} +.runtime-demo__section-bar span { display: flex; gap: 7px; align-items: center; } +.runtime-demo__section-bar code { max-width: 56%; overflow: hidden; color: #6e7972; text-overflow: ellipsis; } +.runtime-demo__section-bar i { + min-width: 24px; + padding: 4px; + color: var(--rt-cyan); + background: #102126; + border-radius: 4px; + font-style: normal; + text-align: center; +} +.runtime-demo__surface { padding-top: 22px; } +.runtime-demo__surface-heading { margin-bottom: 17px; } +.runtime-demo__surface-heading span { color: var(--rt-lime); } +.runtime-demo__surface-heading h3 { margin: 5px 0 0; color: var(--rt-text); font-size: 17px; line-height: 1.3; letter-spacing: 0; } +.runtime-demo__metric-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } +.runtime-demo__metric { + min-width: 0; + height: 142px; + padding: 15px; + background: var(--rt-panel); + border: 1px solid var(--rt-line); + border-top: 2px solid currentColor; + border-radius: 5px; +} +.runtime-demo__metric--lime { color: var(--rt-lime); } +.runtime-demo__metric--cyan { color: var(--rt-cyan); } +.runtime-demo__metric--amber { color: var(--rt-amber); } +.runtime-demo__metric-label { overflow: hidden; color: #77827b; font: 600 9px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; } +.runtime-demo__metric-value { display: flex; align-items: baseline; gap: 5px; margin-top: 12px; } +.runtime-demo__metric-value strong { color: var(--rt-text); font: 650 25px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0; } +.runtime-demo__metric-value span { color: var(--rt-muted); font-size: 10px; } +.runtime-demo__metric-foot { display: flex; align-items: flex-end; justify-content: space-between; height: 45px; margin-top: 8px; font: 600 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +.runtime-demo__bars { display: flex; align-items: flex-end; gap: 3px; width: 62%; height: 28px; } +.runtime-demo__bars i { width: 100%; max-height: 100%; background: currentColor; opacity: 0.55; transition: height 320ms ease; } +.runtime-demo__signal-row { display: grid; grid-template-columns: minmax(230px, 0.8fr) minmax(0, 1.2fr); gap: 10px; margin-top: 10px; } +.runtime-demo__signal, .runtime-demo__incident { min-width: 0; min-height: 86px; padding: 14px; background: var(--rt-panel); border: 1px solid var(--rt-line); border-radius: 5px; } +.runtime-demo__signal { display: grid; grid-template-columns: 34px minmax(80px, auto) minmax(0, 1fr); gap: 10px; } +.runtime-demo__signal-icon { display: grid; width: 32px; height: 32px; place-items: center; color: var(--rt-lime); background: #192217; border: 1px solid #354425; border-radius: 5px; } +.runtime-demo__signal span { display: block; color: var(--rt-muted); font: 600 9px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; } +.runtime-demo__signal strong { display: block; margin-top: 5px; color: var(--rt-lime); font-size: 12px; } +.runtime-demo__signal code { overflow: hidden; color: #8c9890; font-size: 10px; text-align: right; text-overflow: ellipsis; white-space: nowrap; } +.runtime-demo__incident { gap: 12px; } +.runtime-demo__incident-mark { width: 3px; height: 42px; flex: none; background: var(--rt-amber); box-shadow: 0 0 14px rgba(255, 191, 63, 0.25); } +.runtime-demo__incident-mark--RESOLVED { background: var(--rt-lime); box-shadow: 0 0 14px rgba(185, 242, 39, 0.25); } +.runtime-demo__incident-copy { min-width: 0; flex: 1; } +.runtime-demo__incident-copy strong, .runtime-demo__incident-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.runtime-demo__incident-copy strong { margin: 4px 0; color: #e8eee9; font-size: 12px; } +.runtime-demo__incident-copy small { color: var(--rt-muted); font-size: 10px; } +.runtime-demo__incident-owner { flex: none; text-align: right; } +.runtime-demo__incident-owner b, .runtime-demo__incident-owner span { display: block; font: 600 9px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } +.runtime-demo__incident-owner b { color: var(--rt-amber); } +.runtime-demo__incident-owner span { color: #6d7770; } +.runtime-demo__security { gap: 11px; min-height: 58px; margin-top: 10px; padding: 11px 14px; color: var(--rt-cyan); background: #0d1718; border: 1px solid #20383b; border-radius: 5px; } +.runtime-demo__security--blocked { color: var(--rt-red); background: #190f0f; border-color: #4b2929; animation: runtime-alert 360ms ease both; } +.runtime-demo__security > span { font-size: 19px; } +.runtime-demo__security div { min-width: 0; flex: 1; } +.runtime-demo__security strong, .runtime-demo__security div span { display: block; } +.runtime-demo__security strong { font: 650 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } +.runtime-demo__security div span { overflow: hidden; margin-top: 2px; color: #808b84; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.runtime-demo__security code { color: currentColor; font-size: 9px; white-space: nowrap; } +.runtime-demo__timeline { display: flex; min-width: 0; flex-direction: column; padding: 0 16px; background: #0b0e0c; } +.runtime-demo__timeline > .runtime-demo__section-bar { width: 100%; flex: none; } +.runtime-demo__timeline-list { width: 100%; min-height: 0; flex: 1; overflow: hidden; } +.runtime-demo__event { position: relative; min-height: 66px; gap: 10px; padding: 12px 3px; border-bottom: 1px solid #202621; animation: runtime-enter 280ms ease both; } +.runtime-demo__event-icon { width: 22px; flex: none; color: var(--rt-lime); text-align: center; } +.runtime-demo__event--rejected .runtime-demo__event-icon { color: var(--rt-red); } +.runtime-demo__event--system .runtime-demo__event-icon { color: var(--rt-cyan); } +.runtime-demo__event-copy { min-width: 0; flex: 1; } +.runtime-demo__event-copy strong, .runtime-demo__event-copy span { display: block; } +.runtime-demo__event-copy strong { color: #cfd7d1; font: 600 9px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } +.runtime-demo__event-copy span { overflow: hidden; margin-top: 3px; color: #707b74; font-size: 10px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } +.runtime-demo__event > code { flex: none; color: #6d7871; font-size: 9px; } +.runtime-demo__trace-footer { width: 100%; flex: none; gap: 9px; height: 48px; color: #69736d; border-top: 1px solid var(--rt-line); font: 500 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +.runtime-demo__empty { display: grid; min-height: 365px; place-items: center; color: var(--rt-muted); font: 600 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +@keyframes runtime-pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } } +@keyframes runtime-enter { from { opacity: 0; transform: translateX(8px); } to { opacity: 1; transform: translateX(0); } } +@keyframes runtime-alert { from { transform: translateX(-3px); } 50% { transform: translateX(3px); } to { transform: translateX(0); } } +@container runtime-demo (max-width: 900px) { + .runtime-demo__workspace { grid-template-columns: 1fr; } + .runtime-demo__viewport { border-right: 0; } + .runtime-demo__timeline { min-height: 300px; border-top: 1px solid var(--rt-line); } +} +@container runtime-demo (max-width: 600px) { + .runtime-demo__header, .runtime-demo__toolbar { align-items: flex-start; flex-direction: column; } + .runtime-demo__connection { align-self: stretch; } + .runtime-demo__toolbar { padding: 12px; } + .runtime-demo__actions { width: 100%; } + .runtime-demo__actions .ant-btn { flex: 1; } + .runtime-demo__actions .ant-btn:last-child { flex: none; } + .runtime-demo__runtime-stats { width: 100%; height: 36px; } + .runtime-demo__runtime-stats span { flex: 1; justify-content: center; } + .runtime-demo__viewport { padding: 0 12px 14px; border-right: 0; } + .runtime-demo__timeline { min-height: 340px; } + .runtime-demo__metric-grid { grid-template-columns: 1fr; } + .runtime-demo__metric { height: 124px; } + .runtime-demo__signal-row { grid-template-columns: 1fr; } +} +@container runtime-demo (max-width: 430px) { + .runtime-demo__header { padding: 14px 12px; } + .runtime-demo__connection code { margin-left: auto; } + .runtime-demo__actions .ant-btn { min-width: 0; flex: 1 1 calc(50% - 4px); padding-inline: 6px; font-size: 12px; } + .runtime-demo__actions .ant-btn:last-child { min-width: 34px; flex: 0 0 34px; } + .runtime-demo__runtime-stats { height: 44px; } + .runtime-demo__runtime-stats span { min-width: 0; flex-direction: column; gap: 3px; padding: 5px 3px; } + .runtime-demo__signal { grid-template-columns: 34px minmax(0, 1fr); } + .runtime-demo__signal > div, .runtime-demo__signal code { min-width: 0; } + .runtime-demo__signal code { grid-column: 1 / -1; text-align: left; } + .runtime-demo__incident { align-items: flex-start; } + .runtime-demo__incident-owner { max-width: 84px; } + .runtime-demo__security code { display: none; } +} +@media (prefers-reduced-motion: reduce) { + .runtime-demo *, .runtime-demo *::before, .runtime-demo *::after { animation: none !important; transition: none !important; } +} +`; + +export default RuntimeProductionDemo; diff --git a/packages/x/docs/x-card/demo/A2UI_v0.9/action-context-resolve.tsx b/packages/x/docs/x-card/demo/A2UI_v0.9/action-context-resolve.tsx index 5c5f18146..57bc0ff27 100644 --- a/packages/x/docs/x-card/demo/A2UI_v0.9/action-context-resolve.tsx +++ b/packages/x/docs/x-card/demo/A2UI_v0.9/action-context-resolve.tsx @@ -83,8 +83,10 @@ const FormCard: React.FC<{ children?: React.ReactNode }> = ({ children }) => ( borderRadius: 8, border: '1px solid #e8e8e8', background: '#fff', - minWidth: 280, + width: '100%', + minWidth: 0, maxWidth: 360, + boxSizing: 'border-box', }} > {children} diff --git a/packages/x/docs/x-card/demo/A2UI_v0.9/basic.tsx b/packages/x/docs/x-card/demo/A2UI_v0.9/basic.tsx index 16f6cf46d..d9bb91fee 100644 --- a/packages/x/docs/x-card/demo/A2UI_v0.9/basic.tsx +++ b/packages/x/docs/x-card/demo/A2UI_v0.9/basic.tsx @@ -134,9 +134,10 @@ const BookForm: React.FC = ({ children }) => { padding: '20px 20px 16px', background: '#fff', boxShadow: '0 2px 12px rgba(0,0,0,0.06)', - minWidth: 280, + width: '100%', marginBlock: 16, maxWidth: 400, + boxSizing: 'border-box', }} > @@ -261,7 +262,20 @@ const CoffeeList: React.FC = ({ list, description, onAction, st 📋 {description} )} + handleSelect(e.target.value)} style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 10 }} disabled={status === 'success'} @@ -272,7 +286,8 @@ const CoffeeList: React.FC = ({ list, description, onAction, st style={{ display: 'flex', alignItems: 'center', - width: 300, + flex: 1, + minWidth: 0, gap: 12, padding: '10px 12px', borderRadius: 12, @@ -308,7 +323,15 @@ const CoffeeList: React.FC = ({ list, description, onAction, st {/* Text information */}
-
+
= ({ list, description, onAction, st background: '#fff7e6', border: '1px solid #ffd591', margin: 0, + maxWidth: '100%', + whiteSpace: 'normal', }} > {item.tag} @@ -392,8 +417,9 @@ const CoffeeResultCard: React.FC = ({ overflow: 'hidden', background: 'linear-gradient(145deg, #3d1f0d 0%, #6b3520 50%, #8b5a2b 100%)', boxShadow: '0 8px 32px rgba(61,31,13,0.35)', - minWidth: 280, + width: '100%', maxWidth: 380, + boxSizing: 'border-box', position: 'relative', }} > diff --git a/packages/x/docs/x-card/demo/A2UI_v0.9/filter-search.tsx b/packages/x/docs/x-card/demo/A2UI_v0.9/filter-search.tsx index 70f3716bb..0f5b45ed6 100644 --- a/packages/x/docs/x-card/demo/A2UI_v0.9/filter-search.tsx +++ b/packages/x/docs/x-card/demo/A2UI_v0.9/filter-search.tsx @@ -262,14 +262,16 @@ const FilterPanel: React.FC = ({ Price Range: ¥{filters.priceRange[0]} - ¥{filters.priceRange[1]} - handleFilterChange({ priceRange: value as [number, number] })} - marks={{ 0: '¥0', 500: '¥500', 1000: '¥1000', 1500: '¥1500' }} - /> +
+ handleFilterChange({ priceRange: value as [number, number] })} + marks={{ 0: '¥0', 500: '¥500', 1000: '¥1000', 1500: '¥1500' }} + /> +
{/* Rating Filter */} @@ -434,12 +436,14 @@ const FilterContainer: React.FC = ({ children }) => { padding: '20px', background: '#fff', boxShadow: '0 2px 12px rgba(0,0,0,0.06)', - minWidth: 600, + width: '100%', + minWidth: 0, + boxSizing: 'border-box', }} > - + {childArray.map((child, index) => ( - + {child} ))} diff --git a/packages/x/docs/x-card/demo/A2UI_v0.9/form-validation.tsx b/packages/x/docs/x-card/demo/A2UI_v0.9/form-validation.tsx index 3a64bc58f..f9ba188cc 100644 --- a/packages/x/docs/x-card/demo/A2UI_v0.9/form-validation.tsx +++ b/packages/x/docs/x-card/demo/A2UI_v0.9/form-validation.tsx @@ -154,8 +154,10 @@ const RegistrationForm: React.FC = ({ padding: '24px', background: '#fff', boxShadow: '0 2px 12px rgba(0,0,0,0.06)', - minWidth: 480, + width: '100%', + minWidth: 0, maxWidth: 600, + boxSizing: 'border-box', }} > @@ -315,8 +317,10 @@ const SuccessCard: React.FC = ({ username, email, accountType padding: '24px', background: 'linear-gradient(135deg, #f6ffed 0%, #fff 100%)', boxShadow: '0 2px 12px rgba(82,196,26,0.15)', - minWidth: 400, + width: '100%', + minWidth: 0, maxWidth: 500, + boxSizing: 'border-box', }} >
diff --git a/packages/x/docs/x-card/demo/A2UI_v0.9/multi-card-sync.tsx b/packages/x/docs/x-card/demo/A2UI_v0.9/multi-card-sync.tsx index f32861f2b..f179ba4ff 100644 --- a/packages/x/docs/x-card/demo/A2UI_v0.9/multi-card-sync.tsx +++ b/packages/x/docs/x-card/demo/A2UI_v0.9/multi-card-sync.tsx @@ -133,7 +133,11 @@ interface ColWrapperProps { const ColWrapper: React.FC = ({ span = 8, children }) => { const numSpan = typeof span === 'string' ? parseInt(span, 10) : span; - return {children}; + return ( + + {children} + + ); }; // ─── ProductListCard Component ───────────────────────────────────────────────────── @@ -170,7 +174,7 @@ const ProductListCard: React.FC = ({ return ( + 📦 Product List {products.length} items @@ -195,14 +199,15 @@ const ProductListCard: React.FC = ({ style={{ display: 'flex', alignItems: 'center', + flexWrap: 'wrap', width: '100%', - gap: 12, + gap: 8, }} >
= ({
-
-
+
+
{product.name} @@ -235,6 +248,8 @@ const ProductListCard: React.FC = ({ lineHeight: '18px', borderRadius: 6, margin: 0, + maxWidth: '100%', + whiteSpace: 'normal', }} color="orange" > @@ -247,7 +262,7 @@ const ProductListCard: React.FC = ({
-
+
¥{product.price.toLocaleString()} @@ -589,12 +604,15 @@ const MultiCardContainer: React.FC = ({ children }) => style={{ borderRadius: 16, border: '1.5px solid #e8e8e8', - padding: '24px', + padding: 'clamp(12px, 3vw, 24px)', background: '#fff', boxShadow: '0 4px 16px rgba(0,0,0,0.06)', + width: '100%', + minWidth: 0, + boxSizing: 'border-box', }} > - + {children}
diff --git a/packages/x/docs/x-card/demo/A2UI_v0.9/nested-interaction.tsx b/packages/x/docs/x-card/demo/A2UI_v0.9/nested-interaction.tsx index 5993c060d..f748b8ba6 100644 --- a/packages/x/docs/x-card/demo/A2UI_v0.9/nested-interaction.tsx +++ b/packages/x/docs/x-card/demo/A2UI_v0.9/nested-interaction.tsx @@ -501,12 +501,16 @@ const AccordionPanel: React.FC = ({ panels, children }) => {panel.title} ), - children: {panel.content}, + children: ( + + {panel.content} + + ), style: { marginBottom: 8, borderRadius: 8 }, })); return ( -
+
= ({ children }) => { background: '#fff', boxShadow: '0 2px 12px rgba(0,0,0,0.06)', marginBlock: 16, - minWidth: 500, + width: '100%', + minWidth: 0, + boxSizing: 'border-box', }} > diff --git a/packages/x/docs/x-card/demo/A2UI_v0.9/progressive.tsx b/packages/x/docs/x-card/demo/A2UI_v0.9/progressive.tsx index 2f6c15840..034f6a275 100644 --- a/packages/x/docs/x-card/demo/A2UI_v0.9/progressive.tsx +++ b/packages/x/docs/x-card/demo/A2UI_v0.9/progressive.tsx @@ -187,14 +187,15 @@ const ProductContainer: React.FC = ({ children }) => { border: '1px solid #e8e8e8', padding: 20, background: '#fafafa', - minWidth: 320, + width: '100%', maxWidth: 720, + boxSizing: 'border-box', }} >
@@ -226,8 +227,9 @@ const LoadingIndicator: React.FC = ({ borderRadius: 12, marginBlock: 16, border: '1px solid #e8e8e8', - minWidth: 320, + width: '100%', maxWidth: 720, + boxSizing: 'border-box', }} > diff --git a/packages/x/docs/x-card/demo/A2UI_v0.9/runtime-production.md b/packages/x/docs/x-card/demo/A2UI_v0.9/runtime-production.md new file mode 100644 index 000000000..bd4da8e99 --- /dev/null +++ b/packages/x/docs/x-card/demo/A2UI_v0.9/runtime-production.md @@ -0,0 +1,7 @@ +## zh-CN + +生产级 Surface Runtime 演示。真实运行 A2UI v0.9 适配器、严格本地 Catalog、原子事务、数据绑定、历史快照与回滚;通过注入未授权组件,验证非法更新会被拒绝且最后一个有效快照保持不变。 + +## en-US + +A production-oriented Surface Runtime demo. It runs the A2UI v0.9 adapter, a strict local Catalog, atomic transactions, data binding, snapshot history, and rollback. Inject an unauthorized component to verify that invalid updates are rejected while the last valid Snapshot remains active. diff --git a/packages/x/docs/x-card/demo/A2UI_v0.9/runtime-production.tsx b/packages/x/docs/x-card/demo/A2UI_v0.9/runtime-production.tsx new file mode 100644 index 000000000..c31fe2628 --- /dev/null +++ b/packages/x/docs/x-card/demo/A2UI_v0.9/runtime-production.tsx @@ -0,0 +1,825 @@ +import { + CheckCircleFilled, + CloseCircleFilled, + CodeOutlined, + PlayCircleOutlined, + ReloadOutlined, + SafetyCertificateOutlined, + ThunderboltOutlined, + UndoOutlined, +} from '@ant-design/icons'; +import { experimentalRuntime } from '@ant-design/x-card'; +import { Button, Tooltip } from 'antd'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react'; + +const SURFACE_ID = 'ops-control-room'; +const CATALOG_ID = 'catalog://runtime-control-room'; + +const catalog = experimentalRuntime.createSurfaceCatalogRegistry({ + catalogs: [ + { + $id: CATALOG_ID, + components: { + Stack: { + type: 'object', + properties: { eyebrow: {}, title: {} }, + required: ['title'], + additionalProperties: false, + }, + Metric: { + type: 'object', + properties: { label: {}, value: {}, unit: {}, trend: {}, tone: {} }, + required: ['label', 'value', 'tone'], + additionalProperties: false, + }, + Signal: { + type: 'object', + properties: { label: {}, value: {}, detail: {}, tone: {} }, + required: ['label', 'value', 'tone'], + additionalProperties: false, + }, + Incident: { + type: 'object', + properties: { title: {}, status: {}, detail: {}, owner: {} }, + required: ['title', 'status', 'detail'], + additionalProperties: false, + }, + }, + }, + ], +}); + +const createRuntime = () => + experimentalRuntime.createSurfaceRuntime({ + catalogs: catalog, + adapters: [experimentalRuntime.a2uiV09Adapter], + limits: { historyLimit: 16, maxNodesPerSurface: 64 }, + }); + +const initializedRuntimes = new WeakSet(); + +const command = (payload: unknown): experimentalRuntime.SurfaceInput => ({ + protocol: 'a2ui', + version: 'v0.9', + payload, +}); + +const bootstrapCommands = [ + command({ + version: 'v0.9', + createSurface: { surfaceId: SURFACE_ID, catalogId: CATALOG_ID }, + }), + command({ + version: 'v0.9', + updateComponents: { + surfaceId: SURFACE_ID, + components: [ + { + id: 'root', + component: 'Stack', + eyebrow: 'A2UI / EDGE-7', + title: 'Autonomous Operations Surface', + children: ['throughput', 'latency', 'confidence', 'signal', 'incident'], + }, + { + id: 'throughput', + component: 'Metric', + label: 'TOKEN THROUGHPUT', + value: { path: '/live/throughput' }, + unit: 'tok/s', + trend: { path: '/live/throughputTrend' }, + tone: 'lime', + }, + { + id: 'latency', + component: 'Metric', + label: 'P95 LATENCY', + value: { path: '/live/latency' }, + unit: 'ms', + trend: { path: '/live/latencyTrend' }, + tone: 'cyan', + }, + { + id: 'confidence', + component: 'Metric', + label: 'MODEL CONFIDENCE', + value: { path: '/live/confidence' }, + unit: '%', + trend: { path: '/live/confidenceTrend' }, + tone: 'amber', + }, + { + id: 'signal', + component: 'Signal', + label: 'POLICY GATE', + value: { path: '/live/policy' }, + detail: { path: '/live/policyDetail' }, + tone: 'lime', + }, + { + id: 'incident', + component: 'Incident', + title: { path: '/live/incidentTitle' }, + status: { path: '/live/incidentStatus' }, + detail: { path: '/live/incidentDetail' }, + owner: { path: '/live/owner' }, + }, + ], + }, + }), + command({ + version: 'v0.9', + updateDataModel: { + surfaceId: SURFACE_ID, + path: '/live', + value: { + throughput: 1842, + throughputTrend: '+12.4%', + latency: 86, + latencyTrend: '-8 ms', + confidence: 98.6, + confidenceTrend: '+0.7%', + policy: 'ENFORCED', + policyDetail: 'Catalog allowlist · schema strict', + incidentTitle: 'Checkout latency anomaly', + incidentStatus: 'MITIGATING', + incidentDetail: 'Traffic shifted to healthy inference pool', + owner: 'AGENT / ROUTER-02', + }, + }, + }), +] as const; + +type TimelineTone = 'accepted' | 'rejected' | 'system'; + +interface TimelineItem { + id: number; + title: string; + detail: string; + tone: TimelineTone; + revision?: number; +} + +const streamFrames = [ + { + throughput: 2074, + throughputTrend: '+18.1%', + latency: 81, + latencyTrend: '-13 ms', + confidence: 98.9, + confidenceTrend: '+1.0%', + policy: 'ENFORCED', + policyDetail: 'Catalog allowlist · schema strict', + incidentTitle: 'Checkout latency anomaly', + incidentStatus: 'MITIGATING', + incidentDetail: 'Draining two degraded replicas', + owner: 'AGENT / ROUTER-02', + }, + { + throughput: 2388, + throughputTrend: '+26.7%', + latency: 74, + latencyTrend: '-20 ms', + confidence: 99.2, + confidenceTrend: '+1.3%', + policy: 'ENFORCED', + policyDetail: '0 unsafe nodes admitted', + incidentTitle: 'Checkout latency anomaly', + incidentStatus: 'STABILIZING', + incidentDetail: 'Error budget recovered to 99.97%', + owner: 'AGENT / ROUTER-02', + }, + { + throughput: 2614, + throughputTrend: '+31.9%', + latency: 68, + latencyTrend: '-26 ms', + confidence: 99.4, + confidenceTrend: '+1.5%', + policy: 'ENFORCED', + policyDetail: 'All transactions verified', + incidentTitle: 'Checkout latency anomaly', + incidentStatus: 'RESOLVED', + incidentDetail: 'Healthy capacity restored across all zones', + owner: 'AGENT / ROUTER-02', + }, +] as const; + +const wait = (milliseconds: number) => + new Promise((resolve) => { + window.setTimeout(resolve, milliseconds); + }); + +const readPointer = (source: Readonly>, pointer: string): unknown => { + if (pointer === '') return source; + return pointer + .slice(1) + .split('/') + .map((part) => part.replace(/~1/g, '/').replace(/~0/g, '~')) + .reduce((current, part) => { + if (!current || typeof current !== 'object') return undefined; + return (current as Record)[part]; + }, source); +}; + +const resolveValue = (value: unknown, dataModel: Readonly>): unknown => { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + typeof (value as { path?: unknown }).path === 'string' + ) { + return readPointer(dataModel, (value as { path: string }).path); + } + return value; +}; + +const display = (value: unknown) => (value === undefined || value === null ? '—' : String(value)); + +interface SurfaceRendererProps { + surface?: experimentalRuntime.SurfaceSnapshot; +} + +const SurfaceRenderer: React.FC = ({ surface }) => { + if (!surface?.rootId) { + return
WAITING FOR SURFACE
; + } + + const renderNode = (nodeId: string): React.ReactNode => { + const node = surface.nodes.get(nodeId); + if (!node) return null; + const get = (key: string) => resolveValue(node.props[key], surface.dataModel); + + if (node.type === 'Stack') { + const children = node.children.map(renderNode); + return ( +
+
+ {display(get('eyebrow'))} +

{display(get('title'))}

+
+
{children.slice(0, 3)}
+
{children.slice(3)}
+
+ ); + } + + if (node.type === 'Metric') { + const tone = display(get('tone')); + const numericValue = Number(get('value')) || 0; + const barHeights = [0.42, 0.6, 0.48, 0.76, 0.64, 0.92, 0.72]; + return ( +
+
{display(get('label'))}
+
+ {numericValue.toLocaleString()} + {display(get('unit'))} +
+
+ + {display(get('trend'))} +
+
+ ); + } + + if (node.type === 'Signal') { + return ( +
+
+ +
+
+ {display(get('label'))} + {display(get('value'))} +
+ {display(get('detail'))} +
+ ); + } + + if (node.type === 'Incident') { + const status = display(get('status')); + return ( +
+
+
+ ACTIVE RESPONSE + {display(get('title'))} + {display(get('detail'))} +
+
+ {status} + {display(get('owner'))} +
+
+ ); + } + + return null; + }; + + return <>{renderNode(surface.rootId)}; +}; + +const RuntimeProductionDemo: React.FC = () => { + const [runtime, setRuntime] = useState(createRuntime); + const [timeline, setTimeline] = useState([]); + const [running, setRunning] = useState(false); + const [canRollback, setCanRollback] = useState(false); + const timelineId = useRef(0); + const runId = useRef(0); + + const snapshot = useSyncExternalStore( + runtime.subscribe, + runtime.getSnapshot, + runtime.getSnapshot, + ); + const surface = snapshot.surfaces.get(SURFACE_ID); + + const appendTimeline = useCallback( + (title: string, detail: string, tone: TimelineTone, revision?: number) => { + timelineId.current += 1; + const item = { id: timelineId.current, title, detail, tone, revision }; + setTimeline((current) => [item, ...current].slice(0, 8)); + }, + [], + ); + + useEffect(() => { + if (initializedRuntimes.has(runtime)) return; + initializedRuntimes.add(runtime); + void runtime.dispatchBatch(bootstrapCommands).then((result) => { + const revision = result.snapshot.surfaces.get(SURFACE_ID)?.revision; + appendTimeline( + 'SURFACE COMMITTED', + `${bootstrapCommands.length} A2UI commands applied atomically`, + result.accepted ? 'accepted' : 'rejected', + revision, + ); + }); + }, [appendTimeline, runtime]); + + const runStream = async () => { + const currentRun = runId.current + 1; + runId.current = currentRun; + setRunning(true); + appendTimeline('STREAM CONNECTED', 'Receiving live dataModel patches', 'system'); + + for (const [index, frame] of streamFrames.entries()) { + await wait(460); + if (runId.current !== currentRun) return; + const result = await runtime.dispatch( + command({ + version: 'v0.9', + updateDataModel: { surfaceId: SURFACE_ID, path: '/live', value: frame }, + }), + ); + if (result.accepted) setCanRollback(true); + appendTimeline( + `PATCH ${String(index + 1).padStart(2, '0')} ACCEPTED`, + `${Object.keys(frame).length} bound values refreshed`, + result.accepted ? 'accepted' : 'rejected', + result.snapshot.surfaces.get(SURFACE_ID)?.revision, + ); + } + + if (runId.current === currentRun) { + setRunning(false); + appendTimeline('STREAM SETTLED', 'Surface reached a stable render state', 'system'); + } + }; + + const injectUnsafeNode = async () => { + const beforeRevision = runtime.getSurface(SURFACE_ID)?.revision; + const result = await runtime.dispatch( + command({ + version: 'v0.9', + updateComponents: { + surfaceId: SURFACE_ID, + components: [ + { + id: 'remote-script', + component: 'UnsafeScript', + source: 'https://untrusted.example/payload.js', + }, + ], + }, + }), + ); + const afterRevision = result.snapshot.surfaces.get(SURFACE_ID)?.revision; + appendTimeline( + 'CATALOG GATE REJECTED', + `${result.issue?.code ?? 'unknown'} · revision ${beforeRevision} preserved`, + 'rejected', + afterRevision, + ); + }; + + const rollback = () => { + const result = runtime.rollback(SURFACE_ID); + appendTimeline( + result.accepted ? 'ROLLBACK COMMITTED' : 'ROLLBACK UNAVAILABLE', + result.accepted ? 'Previous immutable snapshot restored' : (result.issue?.message ?? ''), + result.accepted ? 'accepted' : 'rejected', + result.snapshot.surfaces.get(SURFACE_ID)?.revision, + ); + }; + + const reset = () => { + runId.current += 1; + setRunning(false); + setCanRollback(false); + setTimeline([]); + setRuntime((current) => { + current.dispose(); + return createRuntime(); + }); + }; + + const latestRejection = useMemo( + () => timeline.find((item) => item.tone === 'rejected'), + [timeline], + ); + + return ( +
+ +
+
+
+ +
+
+ SURFACE RUNTIME +

Control Room

+
+
+
+ + EDGE LINK ACTIVE + A2UI v0.9 +
+
+ +
+
+ + + + + + +
+
+ + STATUS {surface?.status.toUpperCase() ?? 'BOOTING'} + + + REV {String(surface?.revision ?? 0).padStart(2, '0')} + + + NODES {String(surface?.nodes.size ?? 0).padStart(2, '0')} + +
+
+ +
+
+
+ + RENDERED SNAPSHOT + + {SURFACE_ID} +
+ +
+ +
+ + {latestRejection ? 'UNTRUSTED UPDATE BLOCKED' : 'TRANSACTION GATE ARMED'} + + + {latestRejection + ? 'Last known-good Snapshot remains active' + : 'Catalog + schema validation before commit'} + +
+ {latestRejection ? 'STATE PRESERVED' : 'STRICT MODE'} +
+
+ + +
+
+ ); +}; + +const styles = ` +.runtime-demo { + --rt-bg: #080b0a; + --rt-panel: #0f1311; + --rt-panel-2: #131815; + --rt-line: #28302b; + --rt-muted: #7e8982; + --rt-text: #edf4ef; + --rt-lime: #b9f227; + --rt-cyan: #43d7e8; + --rt-amber: #ffbf3f; + --rt-red: #ff5d5d; + position: relative; + overflow: hidden; + color: var(--rt-text); + background: var(--rt-bg); + border: 1px solid #1d2420; + border-radius: 8px; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + letter-spacing: 0; + box-shadow: 0 22px 70px rgba(0, 0, 0, 0.26); + container-name: runtime-demo; + container-type: inline-size; +} +.runtime-demo *, .runtime-demo *::before, .runtime-demo *::after { box-sizing: border-box; } +.runtime-demo__header, .runtime-demo__toolbar, .runtime-demo__section-bar, +.runtime-demo__connection, .runtime-demo__runtime-stats, .runtime-demo__signal, +.runtime-demo__incident, .runtime-demo__security, .runtime-demo__event, +.runtime-demo__trace-footer { display: flex; align-items: center; } +.runtime-demo__header { + min-height: 78px; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--rt-line); +} +.runtime-demo__brand { display: flex; align-items: center; gap: 12px; min-width: 0; } +.runtime-demo__brand-mark { + display: grid; + width: 42px; + height: 42px; + place-items: center; + color: #080b0a; + background: var(--rt-lime); + border-radius: 6px; + font-size: 19px; + box-shadow: 0 0 24px rgba(185, 242, 39, 0.18); +} +.runtime-demo__brand span, .runtime-demo__surface-heading span, .runtime-demo__incident-copy > span { + display: block; + color: var(--rt-muted); + font: 600 10px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0; +} +.runtime-demo__brand h2 { + margin: 2px 0 0; + color: var(--rt-text); + font-size: 20px; + line-height: 1.15; + letter-spacing: 0; +} +.runtime-demo__connection { gap: 9px; color: #9ca79f; font-size: 11px; } +.runtime-demo__connection i, .runtime-demo__trace-footer i { + width: 7px; + height: 7px; + flex: none; + background: var(--rt-lime); + border-radius: 50%; + box-shadow: 0 0 0 4px rgba(185, 242, 39, 0.1); + animation: runtime-pulse 1.7s ease-in-out infinite; +} +.runtime-demo__connection code { + margin-left: 6px; + padding: 4px 7px; + color: var(--rt-cyan); + border: 1px solid #294247; + border-radius: 4px; + font-size: 10px; +} +.runtime-demo__toolbar { + min-height: 64px; + justify-content: space-between; + gap: 14px; + padding: 12px 20px; + background: #0c100e; + border-bottom: 1px solid var(--rt-line); +} +.runtime-demo__actions { display: flex; flex-wrap: wrap; gap: 8px; } +.runtime-demo .ant-btn { + height: 34px; + color: #cbd5ce; + background: #151b17; + border-color: #354038; + border-radius: 5px; + box-shadow: none; +} +.runtime-demo .ant-btn:hover:not(:disabled) { color: #fff; border-color: #68746c; background: #1b231e; } +.runtime-demo .runtime-demo__run { color: #0b100c; background: var(--rt-lime); border-color: var(--rt-lime); font-weight: 650; } +.runtime-demo .runtime-demo__run:hover:not(:disabled) { color: #0b100c; background: #d0ff51; border-color: #d0ff51; } +.runtime-demo .runtime-demo__danger { color: #ff9a9a; border-color: #6a3535; } +.runtime-demo__runtime-stats { align-self: stretch; gap: 0; border: 1px solid var(--rt-line); border-radius: 5px; } +.runtime-demo__runtime-stats span { + display: flex; + align-items: center; + gap: 8px; + height: 100%; + padding: 0 11px; + color: var(--rt-muted); + border-right: 1px solid var(--rt-line); + font: 600 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; + white-space: nowrap; +} +.runtime-demo__runtime-stats span:last-child { border-right: 0; } +.runtime-demo__runtime-stats b { color: var(--rt-lime); font-size: 11px; } +.runtime-demo__workspace { display: grid; grid-template-columns: minmax(0, 1fr) 330px; min-height: 520px; } +.runtime-demo__viewport { min-width: 0; padding: 0 20px 20px; border-right: 1px solid var(--rt-line); } +.runtime-demo__section-bar { + height: 50px; + justify-content: space-between; + color: #a2ada6; + border-bottom: 1px solid var(--rt-line); + font: 600 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace; +} +.runtime-demo__section-bar span { display: flex; gap: 7px; align-items: center; } +.runtime-demo__section-bar code { max-width: 56%; overflow: hidden; color: #6e7972; text-overflow: ellipsis; } +.runtime-demo__section-bar i { + min-width: 24px; + padding: 4px; + color: var(--rt-cyan); + background: #102126; + border-radius: 4px; + font-style: normal; + text-align: center; +} +.runtime-demo__surface { padding-top: 22px; } +.runtime-demo__surface-heading { margin-bottom: 17px; } +.runtime-demo__surface-heading span { color: var(--rt-lime); } +.runtime-demo__surface-heading h3 { margin: 5px 0 0; color: var(--rt-text); font-size: 17px; line-height: 1.3; letter-spacing: 0; } +.runtime-demo__metric-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; } +.runtime-demo__metric { + min-width: 0; + height: 142px; + padding: 15px; + background: var(--rt-panel); + border: 1px solid var(--rt-line); + border-top: 2px solid currentColor; + border-radius: 5px; +} +.runtime-demo__metric--lime { color: var(--rt-lime); } +.runtime-demo__metric--cyan { color: var(--rt-cyan); } +.runtime-demo__metric--amber { color: var(--rt-amber); } +.runtime-demo__metric-label { overflow: hidden; color: #77827b; font: 600 9px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; text-overflow: ellipsis; white-space: nowrap; } +.runtime-demo__metric-value { display: flex; align-items: baseline; gap: 5px; margin-top: 12px; } +.runtime-demo__metric-value strong { color: var(--rt-text); font: 650 25px/1 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0; } +.runtime-demo__metric-value span { color: var(--rt-muted); font-size: 10px; } +.runtime-demo__metric-foot { display: flex; align-items: flex-end; justify-content: space-between; height: 45px; margin-top: 8px; font: 600 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +.runtime-demo__bars { display: flex; align-items: flex-end; gap: 3px; width: 62%; height: 28px; } +.runtime-demo__bars i { width: 100%; max-height: 100%; background: currentColor; opacity: 0.55; transition: height 320ms ease; } +.runtime-demo__signal-row { display: grid; grid-template-columns: minmax(230px, 0.8fr) minmax(0, 1.2fr); gap: 10px; margin-top: 10px; } +.runtime-demo__signal, .runtime-demo__incident { min-width: 0; min-height: 86px; padding: 14px; background: var(--rt-panel); border: 1px solid var(--rt-line); border-radius: 5px; } +.runtime-demo__signal { display: grid; grid-template-columns: 34px minmax(80px, auto) minmax(0, 1fr); gap: 10px; } +.runtime-demo__signal-icon { display: grid; width: 32px; height: 32px; place-items: center; color: var(--rt-lime); background: #192217; border: 1px solid #354425; border-radius: 5px; } +.runtime-demo__signal span { display: block; color: var(--rt-muted); font: 600 9px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; } +.runtime-demo__signal strong { display: block; margin-top: 5px; color: var(--rt-lime); font-size: 12px; } +.runtime-demo__signal code { overflow: hidden; color: #8c9890; font-size: 10px; text-align: right; text-overflow: ellipsis; white-space: nowrap; } +.runtime-demo__incident { gap: 12px; } +.runtime-demo__incident-mark { width: 3px; height: 42px; flex: none; background: var(--rt-amber); box-shadow: 0 0 14px rgba(255, 191, 63, 0.25); } +.runtime-demo__incident-mark--RESOLVED { background: var(--rt-lime); box-shadow: 0 0 14px rgba(185, 242, 39, 0.25); } +.runtime-demo__incident-copy { min-width: 0; flex: 1; } +.runtime-demo__incident-copy strong, .runtime-demo__incident-copy small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.runtime-demo__incident-copy strong { margin: 4px 0; color: #e8eee9; font-size: 12px; } +.runtime-demo__incident-copy small { color: var(--rt-muted); font-size: 10px; } +.runtime-demo__incident-owner { flex: none; text-align: right; } +.runtime-demo__incident-owner b, .runtime-demo__incident-owner span { display: block; font: 600 9px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } +.runtime-demo__incident-owner b { color: var(--rt-amber); } +.runtime-demo__incident-owner span { color: #6d7770; } +.runtime-demo__security { gap: 11px; min-height: 58px; margin-top: 10px; padding: 11px 14px; color: var(--rt-cyan); background: #0d1718; border: 1px solid #20383b; border-radius: 5px; } +.runtime-demo__security--blocked { color: var(--rt-red); background: #190f0f; border-color: #4b2929; animation: runtime-alert 360ms ease both; } +.runtime-demo__security > span { font-size: 19px; } +.runtime-demo__security div { min-width: 0; flex: 1; } +.runtime-demo__security strong, .runtime-demo__security div span { display: block; } +.runtime-demo__security strong { font: 650 10px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } +.runtime-demo__security div span { overflow: hidden; margin-top: 2px; color: #808b84; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; } +.runtime-demo__security code { color: currentColor; font-size: 9px; white-space: nowrap; } +.runtime-demo__timeline { display: flex; min-width: 0; flex-direction: column; padding: 0 16px; background: #0b0e0c; } +.runtime-demo__timeline > .runtime-demo__section-bar { width: 100%; flex: none; } +.runtime-demo__timeline-list { width: 100%; min-height: 0; flex: 1; overflow: hidden; } +.runtime-demo__event { position: relative; min-height: 66px; gap: 10px; padding: 12px 3px; border-bottom: 1px solid #202621; animation: runtime-enter 280ms ease both; } +.runtime-demo__event-icon { width: 22px; flex: none; color: var(--rt-lime); text-align: center; } +.runtime-demo__event--rejected .runtime-demo__event-icon { color: var(--rt-red); } +.runtime-demo__event--system .runtime-demo__event-icon { color: var(--rt-cyan); } +.runtime-demo__event-copy { min-width: 0; flex: 1; } +.runtime-demo__event-copy strong, .runtime-demo__event-copy span { display: block; } +.runtime-demo__event-copy strong { color: #cfd7d1; font: 600 9px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace; } +.runtime-demo__event-copy span { overflow: hidden; margin-top: 3px; color: #707b74; font-size: 10px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } +.runtime-demo__event > code { flex: none; color: #6d7871; font-size: 9px; } +.runtime-demo__trace-footer { width: 100%; flex: none; gap: 9px; height: 48px; color: #69736d; border-top: 1px solid var(--rt-line); font: 500 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +.runtime-demo__empty { display: grid; min-height: 365px; place-items: center; color: var(--rt-muted); font: 600 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; } +@keyframes runtime-pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } } +@keyframes runtime-enter { from { opacity: 0; transform: translateX(8px); } to { opacity: 1; transform: translateX(0); } } +@keyframes runtime-alert { from { transform: translateX(-3px); } 50% { transform: translateX(3px); } to { transform: translateX(0); } } +@container runtime-demo (max-width: 900px) { + .runtime-demo__workspace { grid-template-columns: 1fr; } + .runtime-demo__viewport { border-right: 0; } + .runtime-demo__timeline { min-height: 300px; border-top: 1px solid var(--rt-line); } +} +@container runtime-demo (max-width: 600px) { + .runtime-demo__header, .runtime-demo__toolbar { align-items: flex-start; flex-direction: column; } + .runtime-demo__connection { align-self: stretch; } + .runtime-demo__toolbar { padding: 12px; } + .runtime-demo__actions { width: 100%; } + .runtime-demo__actions .ant-btn { flex: 1; } + .runtime-demo__actions .ant-btn:last-child { flex: none; } + .runtime-demo__runtime-stats { width: 100%; height: 36px; } + .runtime-demo__runtime-stats span { flex: 1; justify-content: center; } + .runtime-demo__viewport { padding: 0 12px 14px; border-right: 0; } + .runtime-demo__timeline { min-height: 340px; } + .runtime-demo__metric-grid { grid-template-columns: 1fr; } + .runtime-demo__metric { height: 124px; } + .runtime-demo__signal-row { grid-template-columns: 1fr; } +} +@container runtime-demo (max-width: 430px) { + .runtime-demo__header { padding: 14px 12px; } + .runtime-demo__connection code { margin-left: auto; } + .runtime-demo__actions .ant-btn { min-width: 0; flex: 1 1 calc(50% - 4px); padding-inline: 6px; font-size: 12px; } + .runtime-demo__actions .ant-btn:last-child { min-width: 34px; flex: 0 0 34px; } + .runtime-demo__runtime-stats { height: 44px; } + .runtime-demo__runtime-stats span { min-width: 0; flex-direction: column; gap: 3px; padding: 5px 3px; } + .runtime-demo__signal { grid-template-columns: 34px minmax(0, 1fr); } + .runtime-demo__signal > div, .runtime-demo__signal code { min-width: 0; } + .runtime-demo__signal code { grid-column: 1 / -1; text-align: left; } + .runtime-demo__incident { align-items: flex-start; } + .runtime-demo__incident-owner { max-width: 84px; } + .runtime-demo__security code { display: none; } +} +@media (prefers-reduced-motion: reduce) { + .runtime-demo *, .runtime-demo *::before, .runtime-demo *::after { animation: none !important; transition: none !important; } +} +`; + +export default RuntimeProductionDemo; diff --git a/packages/x/docs/x-card/demo/A2UI_v0.9/streaming.tsx b/packages/x/docs/x-card/demo/A2UI_v0.9/streaming.tsx index bd6010e5c..f71aa275e 100644 --- a/packages/x/docs/x-card/demo/A2UI_v0.9/streaming.tsx +++ b/packages/x/docs/x-card/demo/A2UI_v0.9/streaming.tsx @@ -153,8 +153,9 @@ const LoadingProgress: React.FC = ({ borderRadius: 12, border: '1px solid #f0f0f0', marginBottom: 16, - minWidth: 320, + width: '100%', maxWidth: 480, + boxSizing: 'border-box', }} >
@@ -229,7 +230,7 @@ const RestaurantCard: React.FC = ({ restaurant, index = 0, }} styles={{ body: { padding: '16px 20px' } }} > -
+
{/* Left icon */}
= ({ restaurant, index = 0, {/* Right content */}
-
+
{restaurant.name} - + {restaurant.cuisine}
-
+
{restaurant.rating} @@ -279,7 +296,7 @@ const RestaurantCard: React.FC = ({ restaurant, index = 0, {restaurant.description} -
+
{restaurant.tags.map((tag, i) => ( = ({ restaurant, index = 0, background: '#f5f5f5', border: 'none', margin: 0, + maxWidth: '100%', + whiteSpace: 'normal', }} > {tag} @@ -321,7 +340,7 @@ const RestaurantList: React.FC = ({ return (
@@ -374,8 +393,9 @@ const Container: React.FC = ({ children }) => { background: '#fff', boxShadow: '0 2px 12px rgba(0,0,0,0.06)', marginBlock: 16, - minWidth: 320, + width: '100%', maxWidth: 520, + boxSizing: 'border-box', }} > {children} diff --git a/packages/x/docs/x-card/introduce.en-US.md b/packages/x/docs/x-card/introduce.en-US.md index 0c99742af..38206557f 100644 --- a/packages/x/docs/x-card/introduce.en-US.md +++ b/packages/x/docs/x-card/introduce.en-US.md @@ -86,26 +86,28 @@ sequenceDiagram | Feature | v0.8 | v0.9 | | --- | --- | --- | | **Version field** | No explicit version field | Explicit `version: 'v0.9'` field | -| **Surface creation** | Implicit (auto-created on first updateComponents) | Explicit `createSurface` command | +| **Surface creation** | Implicit (auto-created on first `surfaceUpdate`) | Explicit `createSurface` command | | **Data model update** | Uses `contents` array | Uses `path` and `value` fields | | **Component definition** | More complex nested structure | Simpler flat structure | | **Recommendation** | Deprecated, compatibility only | **Recommended** | ### v0.8 Message Format (Deprecated) -v0.8 uses implicit Surface creation — a Surface is automatically created when the Agent sends the first `updateComponents`: +v0.8 uses implicit Surface creation — a Surface is automatically created when the Agent sends the first `surfaceUpdate`: ```typescript -// v0.8 has no explicit version field +// A v0.8 payload has no explicit version field { - updateComponents: { + surfaceUpdate: { surfaceId: 'booking', - catalogId: 'https://example.com/catalogs/booking/v1/catalog.json', components: [ { id: 'root', - component: 'Column', - children: ['header', 'content'] + component: { + Column: { + children: { explicitList: ['header', 'content'] } + } + } } ] } @@ -116,19 +118,31 @@ v0.8 uses implicit Surface creation — a Surface is automatically created when ```typescript { - updateDataModel: { + dataModelUpdate: { surfaceId: 'booking', contents: [ { - op: 'replace', - path: '/reservation/guests', - value: 3 + key: 'reservation', + valueMap: [ + { key: 'guests', valueString: '3' } + ] } ] } } ``` +After components and data arrive, `beginRendering` selects the root node: + +```typescript +{ + beginRendering: { + surfaceId: 'booking', + root: 'root' + } +} +``` + ### v0.9 Message Format (Recommended) v0.9 introduces explicit version identification and a Surface creation command, making the protocol clearer and more controllable: @@ -181,8 +195,8 @@ If you are using v0.8, follow these steps to migrate to v0.9: Add `version: 'v0.9'` to all messages: ```typescript -// v0.8 -{ updateComponents: { ... } } +// v0.8 payload +{ surfaceUpdate: { ... } } // v0.9 { version: 'v0.9', updateComponents: { ... } } @@ -190,11 +204,11 @@ Add `version: 'v0.9'` to all messages: #### 2. Explicitly Create Surface -Send `createSurface` before `updateComponents`: +Send `createSurface` before `updateComponents`. A v0.8 adapter binds its Catalog on the client, while v0.9 declares it in the command: ```typescript -// v0.8: implicit creation -{ updateComponents: { surfaceId: 'booking', catalogId: '...', components: [...] } } +// v0.8: surfaceUpdate creates implicitly +{ surfaceUpdate: { surfaceId: 'booking', components: [...] } } // v0.9: explicit creation [ @@ -210,10 +224,10 @@ Replace the `contents` array with `path` + `value`: ```typescript // v0.8 { - updateDataModel: { + dataModelUpdate: { surfaceId: 'booking', contents: [ - { op: 'replace', path: '/guests', value: 3 } + { key: 'reservation', valueMap: [{ key: 'guests', valueString: '3' }] } ] } } @@ -234,10 +248,10 @@ Replace the `contents` array with `path` + `value`: v0.9 supports updating entire objects, reducing message count: ```typescript -// v0.8: multiple messages required +// v0.8: valueMap only carries string values [ - { updateDataModel: { surfaceId: 'booking', contents: [{ op: 'add', path: '/date', value: '2025-12-16' }] } }, - { updateDataModel: { surfaceId: 'booking', contents: [{ op: 'add', path: '/guests', value: 2 }] } } + { dataModelUpdate: { surfaceId: 'booking', contents: [{ key: 'date', valueString: '2025-12-16' }] } }, + { dataModelUpdate: { surfaceId: 'booking', contents: [{ key: 'guests', valueString: '2' }] } } ] // v0.9: one message is enough @@ -262,17 +276,13 @@ import type { XAgentCommand_v0_8, XAgentCommand_v0_9 } from '@ant-design/x-card' const commands: (XAgentCommand_v0_8 | XAgentCommand_v0_9)[] = [ // v0.8 message { - updateComponents: { - /* ... */ - }, + surfaceUpdate: {/* ... */}, }, // v0.9 message { version: 'v0.9', - createSurface: { - /* ... */ - }, + createSurface: {/* ... */}, }, ]; @@ -504,12 +514,8 @@ import type { XAgentCommand_v0_9, Catalog, ActionPayload } from '@ant-design/x-c const catalog: Catalog = { catalogId: 'my-app-catalog', components: { - Text: { - /* ... */ - }, - Button: { - /* ... */ - }, + Text: {/* ... */}, + Button: {/* ... */}, }, }; @@ -521,9 +527,7 @@ const commands: XAgentCommand_v0_9[] = [ version: 'v0.9', updateComponents: { surfaceId: 'booking', - components: [ - /* ... */ - ], + components: [/* ... */], }, }, ]; diff --git a/packages/x/docs/x-card/introduce.zh-CN.md b/packages/x/docs/x-card/introduce.zh-CN.md index 466feeb38..ec2ee3260 100644 --- a/packages/x/docs/x-card/introduce.zh-CN.md +++ b/packages/x/docs/x-card/introduce.zh-CN.md @@ -83,29 +83,31 @@ sequenceDiagram ### 版本对比 -| 特性 | v0.8 | v0.9 | -| ---------------- | ------------------------------------------ | ----------------------------- | -| **版本标识** | 无显式 version 字段 | 显式的 `version: 'v0.9'` 字段 | -| **Surface 创建** | 隐式创建(首个 updateComponents 自动创建) | 显式 `createSurface` 命令 | -| **数据模型更新** | 使用 `contents` 数组 | 使用 `path` 和 `value` 字段 | -| **组件定义** | 较复杂的嵌套结构 | 更简洁的扁平结构 | -| **推荐程度** | 已弃用,仅用于兼容 | **推荐使用** | +| 特性 | v0.8 | v0.9 | +| ---------------- | ----------------------------------------- | ----------------------------- | +| **版本标识** | 无显式 version 字段 | 显式的 `version: 'v0.9'` 字段 | +| **Surface 创建** | 隐式创建(首个 `surfaceUpdate` 自动创建) | 显式 `createSurface` 命令 | +| **数据模型更新** | 使用 `contents` 数组 | 使用 `path` 和 `value` 字段 | +| **组件定义** | 较复杂的嵌套结构 | 更简洁的扁平结构 | +| **推荐程度** | 已弃用,仅用于兼容 | **推荐使用** | ### v0.8 消息格式(已弃用) -v0.8 使用隐式的 Surface 创建方式,当 Agent 发送第一个 `updateComponents` 时自动创建 Surface: +v0.8 使用隐式的 Surface 创建方式,当 Agent 发送第一个 `surfaceUpdate` 时自动创建 Surface: ```typescript -// v0.8 没有显式的 version 字段 +// v0.8 payload 没有显式的 version 字段 { - updateComponents: { + surfaceUpdate: { surfaceId: 'booking', - catalogId: 'https://example.com/catalogs/booking/v1/catalog.json', components: [ { id: 'root', - component: 'Column', - children: ['header', 'content'] + component: { + Column: { + children: { explicitList: ['header', 'content'] } + } + } } ] } @@ -116,19 +118,31 @@ v0.8 使用隐式的 Surface 创建方式,当 Agent 发送第一个 `updateCom ```typescript { - updateDataModel: { + dataModelUpdate: { surfaceId: 'booking', contents: [ { - op: 'replace', - path: '/reservation/guests', - value: 3 + key: 'reservation', + valueMap: [ + { key: 'guests', valueString: '3' } + ] } ] } } ``` +组件定义和数据到达后,通过 `beginRendering` 指定根节点: + +```typescript +{ + beginRendering: { + surfaceId: 'booking', + root: 'root' + } +} +``` + ### v0.9 消息格式(推荐) v0.9 引入显式的版本标识和 Surface 创建命令,使协议更加清晰和可控: @@ -181,8 +195,8 @@ v0.9 引入显式的版本标识和 Surface 创建命令,使协议更加清晰 所有消息添加 `version: 'v0.9'` 字段: ```typescript -// v0.8 -{ updateComponents: { ... } } +// v0.8 payload +{ surfaceUpdate: { ... } } // v0.9 { version: 'v0.9', updateComponents: { ... } } @@ -190,11 +204,11 @@ v0.9 引入显式的版本标识和 Surface 创建命令,使协议更加清晰 #### 2. 显式创建 Surface -在发送 `updateComponents` 之前,先发送 `createSurface`: +在发送 `updateComponents` 之前,先发送 `createSurface`。v0.8 适配器的 Catalog 在客户端创建时绑定,v0.9 则由命令显式声明: ```typescript -// v0.8:隐式创建 -{ updateComponents: { surfaceId: 'booking', catalogId: '...', components: [...] } } +// v0.8:surfaceUpdate 隐式创建 +{ surfaceUpdate: { surfaceId: 'booking', components: [...] } } // v0.9:显式创建 [ @@ -210,10 +224,10 @@ v0.9 引入显式的版本标识和 Surface 创建命令,使协议更加清晰 ```typescript // v0.8 { - updateDataModel: { + dataModelUpdate: { surfaceId: 'booking', contents: [ - { op: 'replace', path: '/guests', value: 3 } + { key: 'reservation', valueMap: [{ key: 'guests', valueString: '3' }] } ] } } @@ -234,10 +248,10 @@ v0.9 引入显式的版本标识和 Surface 创建命令,使协议更加清晰 v0.9 支持更新整个对象,减少消息数量: ```typescript -// v0.8:需要多条消息 +// v0.8:valueMap 只承载字符串值 [ - { updateDataModel: { surfaceId: 'booking', contents: [{ op: 'add', path: '/date', value: '2025-12-16' }] } }, - { updateDataModel: { surfaceId: 'booking', contents: [{ op: 'add', path: '/guests', value: 2 }] } } + { dataModelUpdate: { surfaceId: 'booking', contents: [{ key: 'date', valueString: '2025-12-16' }] } }, + { dataModelUpdate: { surfaceId: 'booking', contents: [{ key: 'guests', valueString: '2' }] } } ] // v0.9:一条消息即可 @@ -262,17 +276,13 @@ import type { XAgentCommand_v0_8, XAgentCommand_v0_9 } from '@ant-design/x-card' const commands: (XAgentCommand_v0_8 | XAgentCommand_v0_9)[] = [ // v0.8 消息 { - updateComponents: { - /* ... */ - }, + surfaceUpdate: {/* ... */}, }, // v0.9 消息 { version: 'v0.9', - createSurface: { - /* ... */ - }, + createSurface: {/* ... */}, }, ]; @@ -697,12 +707,8 @@ import type { XAgentCommand_v0_9, Catalog, ActionPayload } from '@ant-design/x-c const catalog: Catalog = { catalogId: 'my-app-catalog', components: { - Text: { - /* ... */ - }, - Button: { - /* ... */ - }, + Text: {/* ... */}, + Button: {/* ... */}, }, }; @@ -722,9 +728,7 @@ const commands: XAgentCommand_v0_9[] = [ version: 'v0.9', updateComponents: { surfaceId: 'booking', - components: [ - /* ... */ - ], + components: [/* ... */], }, }, ]; @@ -775,15 +779,9 @@ function App() { ```json { - "user": { - /* 用户相关 */ - }, - "cart": { - /* 购物车相关 */ - }, - "ui": { - /* UI 状态 */ - } + "user": {/* 用户相关 */}, + "cart": {/* 购物车相关 */}, + "ui": {/* UI 状态 */} } ``` diff --git a/packages/x/docs/x-card/surface-runtime.en-US.md b/packages/x/docs/x-card/surface-runtime.en-US.md new file mode 100644 index 000000000..60ba39c82 --- /dev/null +++ b/packages/x/docs/x-card/surface-runtime.en-US.md @@ -0,0 +1,134 @@ +--- +order: 3 +title: Surface Runtime +--- + +`experimentalRuntime` is a protocol-independent, headless Surface state layer. It normalizes A2UI v0.8/v0.9 inputs into transactions, validates catalogs, component properties, graph invariants, and capacity limits, then commits immutable snapshots. Renderers subscribe to trusted snapshots instead of consuming Agent input directly. + +> The Runtime is currently exported under the `experimentalRuntime` namespace and may change before stabilization. + +## Quick start + +```typescript +import { experimentalRuntime } from '@ant-design/x-card'; + +const CATALOG_ID = 'local://booking'; +const catalogs = experimentalRuntime.createSurfaceCatalogRegistry({ + catalogs: [ + { + $id: CATALOG_ID, + components: { + Text: { + type: 'object', + required: ['text'], + properties: { text: {} }, + additionalProperties: false, + }, + }, + }, + ], +}); + +const runtime = experimentalRuntime.createSurfaceRuntime({ + catalogs, + adapters: [experimentalRuntime.a2uiV09Adapter], + limits: { maxNodesPerSurface: 1000, historyLimit: 8 }, + onIssue: (issue) => reportSurfaceIssue(issue), +}); + +const result = await runtime.dispatchBatch([ + { + protocol: 'a2ui', + version: 'v0.9', + payload: { + version: 'v0.9', + createSurface: { surfaceId: 'booking', catalogId: CATALOG_ID }, + }, + }, + { + protocol: 'a2ui', + version: 'v0.9', + payload: { + version: 'v0.9', + updateComponents: { + surfaceId: 'booking', + components: [{ id: 'root', component: 'Text', text: 'Ready' }], + }, + }, + }, +]); + +if (!result.accepted) console.error(result.issue); +``` + +`dispatchBatch` is atomic: if any input fails decoding, catalog validation, or state reduction, none of the batch is committed and subscribers keep the last valid snapshot. + +## A2UI v0.8 + +A v0.8 command does not carry its catalog, so bind one when creating the adapter: + +```typescript +const runtime = experimentalRuntime.createSurfaceRuntime({ + catalogs, + adapters: [experimentalRuntime.createA2UIV08Adapter({ catalogId: CATALOG_ID })], +}); + +await runtime.dispatch({ + protocol: 'a2ui', + version: 'v0.8', + payload: { + surfaceUpdate: { + surfaceId: 'booking', + components: [{ id: 'root', component: { Text: { text: { literalString: 'Ready' } } } }], + }, + }, +}); +``` + +The `version` above belongs to the Runtime input envelope and selects the adapter; the v0.8 `payload` keeps its original legacy shape. + +## Subscription and rollback + +React consumers can subscribe with `useSyncExternalStore(runtime.subscribe, runtime.getSnapshot)`. The snapshot contains `surfaces`; each Surface exposes `status`, `revision`, `rootId`, `nodes`, and `dataModel`. + +```typescript +const surface = runtime.getSurface('booking'); +const previous = runtime.rollback('booking'); +const selected = runtime.rollback('booking', 3); +``` + +Rollback restores historical content and assigns a revision greater than the current one. It never reuses an old revision number. + +## Runtime API + +| Method | Description | +| --- | --- | +| `dispatch(input)` | Normalize, validate, and commit one protocol input | +| `dispatchBatch(inputs)` | Atomically commit a group of protocol inputs | +| `rollback(surfaceId, revision?)` | Restore the previous snapshot or a selected historical revision | +| `getSnapshot()` | Read the current immutable snapshot of all Surfaces | +| `getSurface(surfaceId)` | Read one Surface snapshot | +| `subscribe(listener)` | Subscribe to successful commits and rollbacks | +| `dispose()` | Stop the Runtime and release subscriptions and history | + +## Catalog Registry API + +| Method | Description | +| --- | --- | +| `register(catalog)` | Register a local catalog | +| `get(catalogId)` | Synchronously read a registered catalog | +| `resolve(catalogId)` | Read locally or deduplicate a remote load through `loader` | +| `validateNode(catalogId, node)` | Check the component allowlist, required properties, and extra properties | +| `clear()` | Clear registrations and pending loads | + +A loaded Catalog's `$id` / `catalogId` must match the requested identifier. Property validation currently covers the allowlist, `required`, `properties`, and `additionalProperties`; it is not a complete JSON Schema implementation. + +## Issues and limits + +Rejections are returned through `SurfaceDispatchResult.issue` with a `decode`, `catalog`, or `reduce` phase. Common codes include `unsupported_protocol`, `invalid_command`, `component_not_allowed`, `schema_validation_failed`, `revision_conflict`, `graph_invariant_failed`, and `limit_exceeded`. + +Use `limits` to configure nodes per Surface, graph depth, operations per transaction, and snapshot history. Production applications should connect `onIssue` to logging or observability and provide a renderer error boundary for failed Surfaces. + +## Version choice + +Use v0.9 for new integrations. The v0.8 adapter exists for legacy traffic. Both normalize to the same `SurfaceTransaction` and `SurfaceSnapshot`, so a renderer does not need separate state models. See the complete [v0.8 Production Runtime](/x-cards/a2ui-v-0-8) and [v0.9 Production Runtime](/x-cards/a2ui-v-0-9) demos. diff --git a/packages/x/docs/x-card/surface-runtime.zh-CN.md b/packages/x/docs/x-card/surface-runtime.zh-CN.md new file mode 100644 index 000000000..02c9e7452 --- /dev/null +++ b/packages/x/docs/x-card/surface-runtime.zh-CN.md @@ -0,0 +1,134 @@ +--- +order: 3 +title: Surface Runtime +--- + +`experimentalRuntime` 是协议无关的 Headless Surface 状态层。它先把 A2UI v0.8/v0.9 输入归一化为事务,再经过 Catalog、组件属性、图结构和容量限制校验,最后以不可变快照提交。渲染层只订阅快照,不直接信任 Agent 输入。 + +> 当前 Runtime 通过 `experimentalRuntime` 命名空间导出,API 在稳定前可能调整。 + +## 快速开始 + +```typescript +import { experimentalRuntime } from '@ant-design/x-card'; + +const CATALOG_ID = 'local://booking'; +const catalogs = experimentalRuntime.createSurfaceCatalogRegistry({ + catalogs: [ + { + $id: CATALOG_ID, + components: { + Text: { + type: 'object', + required: ['text'], + properties: { text: {} }, + additionalProperties: false, + }, + }, + }, + ], +}); + +const runtime = experimentalRuntime.createSurfaceRuntime({ + catalogs, + adapters: [experimentalRuntime.a2uiV09Adapter], + limits: { maxNodesPerSurface: 1000, historyLimit: 8 }, + onIssue: (issue) => reportSurfaceIssue(issue), +}); + +const result = await runtime.dispatchBatch([ + { + protocol: 'a2ui', + version: 'v0.9', + payload: { + version: 'v0.9', + createSurface: { surfaceId: 'booking', catalogId: CATALOG_ID }, + }, + }, + { + protocol: 'a2ui', + version: 'v0.9', + payload: { + version: 'v0.9', + updateComponents: { + surfaceId: 'booking', + components: [{ id: 'root', component: 'Text', text: 'Ready' }], + }, + }, + }, +]); + +if (!result.accepted) console.error(result.issue); +``` + +`dispatchBatch` 具备原子语义:任一输入解码、Catalog 或状态转换失败,整个批次都不会提交,订阅者继续看到最后一个有效快照。 + +## A2UI v0.8 + +v0.8 的 Catalog 不在协议命令中携带,因此创建适配器时必须绑定: + +```typescript +const runtime = experimentalRuntime.createSurfaceRuntime({ + catalogs, + adapters: [experimentalRuntime.createA2UIV08Adapter({ catalogId: CATALOG_ID })], +}); + +await runtime.dispatch({ + protocol: 'a2ui', + version: 'v0.8', + payload: { + surfaceUpdate: { + surfaceId: 'booking', + components: [{ id: 'root', component: { Text: { text: { literalString: 'Ready' } } } }], + }, + }, +}); +``` + +这里的 `version` 属于 Runtime 输入信封,用于选择适配器;v0.8 的 `payload` 本身仍保持旧协议格式。 + +## 订阅与回滚 + +React 可通过 `useSyncExternalStore(runtime.subscribe, runtime.getSnapshot)` 订阅。快照包含 `surfaces`,每个 Surface 提供 `status`、`revision`、`rootId`、`nodes` 和 `dataModel`。 + +```typescript +const surface = runtime.getSurface('booking'); +const previous = runtime.rollback('booking'); +const selected = runtime.rollback('booking', 3); +``` + +回滚会恢复历史内容,并生成一个大于当前值的新 revision,不会复用旧 revision。 + +## Runtime API + +| 方法 | 说明 | +| -------------------------------- | --------------------------------- | +| `dispatch(input)` | 归一化、校验并提交一个协议输入 | +| `dispatchBatch(inputs)` | 原子提交一组协议输入 | +| `rollback(surfaceId, revision?)` | 恢复上一快照或指定历史 revision | +| `getSnapshot()` | 获取全部 Surface 的当前不可变快照 | +| `getSurface(surfaceId)` | 获取单个 Surface 快照 | +| `subscribe(listener)` | 订阅成功提交和回滚 | +| `dispose()` | 停止 Runtime 并释放订阅与历史记录 | + +## Catalog Registry API + +| 方法 | 说明 | +| ------------------------------- | ------------------------------------------------------ | +| `register(catalog)` | 注册本地 Catalog | +| `get(catalogId)` | 同步读取已注册 Catalog | +| `resolve(catalogId)` | 读取本地 Catalog,或通过 `loader` 去重加载远程 Catalog | +| `validateNode(catalogId, node)` | 校验组件白名单、必填属性和额外属性 | +| `clear()` | 清空已注册项和待处理加载 | + +Catalog Loader 返回的 `$id` / `catalogId` 必须与请求标识一致。当前属性校验覆盖白名单、`required`、`properties` 和 `additionalProperties`,不是完整 JSON Schema 实现。 + +## 错误与限制 + +拒绝结果通过 `SurfaceDispatchResult.issue` 返回,阶段分为 `decode`、`catalog` 和 `reduce`。常见错误码包括 `unsupported_protocol`、`invalid_command`、`component_not_allowed`、`schema_validation_failed`、`revision_conflict`、`graph_invariant_failed` 和 `limit_exceeded`。 + +可通过 `limits` 配置单 Surface 节点数、图深度、单事务操作数和历史长度。生产环境应将 `onIssue` 接入日志或可观测平台,并在组件层为失败 Surface 提供错误边界。 + +## 版本选择 + +新接入建议使用 v0.9。v0.8 适配器用于存量流量兼容;两者会归一化为相同的 `SurfaceTransaction` 和 `SurfaceSnapshot`,因此渲染器无需维护两套状态模型。完整交互可分别查看 [v0.8 生产级 Runtime](/x-cards/a2ui-v-0-8-cn) 和 [v0.9 生产级 Runtime](/x-cards/a2ui-v-0-9-cn)。