
在前後端分離的現代系統中,最常見的痛苦莫過於「API 規格漂移(API Drift)」:後端加了新欄位,前端忘記接;或是前端改了命名,後端還在傳舊版。
到了 Generative UI(生成式 UI) 時代,這個問題會被放大十倍,演變成更詭異的 「契約失諧(Contract Mismatch)」:
RadarChart(雷達圖)來展示客戶能力」,結果模型乖乖生成了 type: "RadarChart",但前端根本還沒安裝圖表套件,使用者畫面上直接噴出白屏或紅字報錯。TimelineComparison(時間軸比較器),但後端的 Catalog Prompt 始終沒更新,LLM 只能繼續吐出簡陋的條列式清單。trend 是傳布林值(true / false),前端元件卻要求字串 enum("up" | "down"),導致圖標渲染失敗。今天我們要建立一套自動化契約對帳與防漂移機制(Catalog Reconciliation),確保「AI 知道能用的」與「前端能畫出來的」100% 嚴格對齊。

在 json-render 與 embabel-generative-ui 規範中,這套契約機制由以下三大模組構成:
我們不撰寫純文字的 API 文檔,而是使用 TypeScript + Zod 定義元件。Zod 的好處是:
.describe("...") 為每個欄位加上中文語意說明,這些說明會被自動提取為 LLM 的 Prompt 指引。.default() 與 .optional(),明確告訴模型哪些是選填欄位。useCatalogReconciliation)前端在應用程式初始化或測試階段,會將前端的 Object.keys(registry) 與後端回傳的 /api/ui/catalog 進行 Diff 比對:
當遇到未定義元件或驗證失敗時,json-render 不會拋出 Uncaught Error,而是渲染一個美觀的除錯容器:
children 子元件,確保整個 Dashboard 的佈局結構不受單一元件影響。以下提供完整的生產級實裝:
// src/schema/dashboardCatalog.ts
import { z } from 'zod';
/**
* 儀表板元件型錄 (Catalog)
* 定義 AI 允許使用的元件白名單、Props 屬性與中文說明
*/
export const DashboardCatalogSchema = {
// 佈局容器
Stack: z.object({
direction: z.enum(["horizontal", "vertical"]).default("vertical").describe("排版方向"),
gap: z.number().default(4).describe("元件間距 (Tailwind gap 單位)"),
}),
// 標題
Heading: z.object({
text: z.string().describe("標題文字"),
level: z.enum(["h1", "h2", "h3"]).default("h2").describe("標題層級"),
}),
// 指標卡片
MetricCard: z.object({
title: z.string().describe("指標名稱 (如:近一年總消費)"),
value: z.union([z.string(), z.number()]).describe("指標主數值"),
trend: z.enum(["up", "down", "neutral"]).optional().describe("趨勢方向"),
change: z.string().optional().describe("變動文字說明 (如:+15%)"),
}),
// 警示橫幅
AlertBanner: z.object({
severity: z.enum(["info", "warning", "error", "success"]).describe("警告嚴重等級"),
message: z.string().describe("提示訊息內文"),
}),
// 數據表格 (資料列一律由 Java 填入)
DataTable: z.object({
columns: z.array(z.object({
key: z.string().describe("欄位鍵值"),
label: z.string().describe("欄位顯示名稱")
})).describe("表格欄位定義"),
rows: z.array(z.record(z.any())).describe("資料列清單 (由 Java 確定性生成)")
})
};
export type CatalogSchemaType = typeof DashboardCatalogSchema;
// src/components/renderer/CatalogReconciler.tsx
import React, { useEffect, useState } from 'react';
import { DashboardCatalogSchema } from '../../schema/dashboardCatalog';
interface ReconcileResult {
isAligned: boolean;
missingInFrontend: string[];
extraInFrontend: string[];
}
/**
* 前後端 Catalog 自動對帳 Hook
*/
export function useCatalogReconciliation(registeredComponentKeys: string[]): ReconcileResult {
const [result, setResult] = useState<ReconcileResult>({
isAligned: true,
missingInFrontend: [],
extraInFrontend: []
});
useEffect(() => {
const catalogKeys = Object.keys(DashboardCatalogSchema);
const registeredSet = new Set(registeredComponentKeys);
// 找出後端有定義但前端未實作的元件
const missing = catalogKeys.filter(key => !registeredSet.has(key));
// 找出前端已實作但未在 Catalog 定義的元件
const extra = registeredComponentKeys.filter(key => !catalogKeys.includes(key));
const aligned = missing.length === 0;
if (!aligned) {
console.warn("[Catalog Reconciliation] 偵測到契約漂移!後端定義但前端未實作:", missing);
}
setResult({
isAligned: aligned,
missingInFrontend: missing,
extraInFrontend: extra
});
}, [registeredComponentKeys]);
return result;
}
/**
* 智慧容錯 Fallback 元件
*/
export const SmartFallbackComponent: React.FC<{
type: string;
props: Record<string, any>;
children?: React.ReactNode;
}> = ({ type, props, children }) => {
return (
<div className="my-2 p-3 border border-amber-500/50 bg-amber-950/20 rounded-lg text-amber-200">
<div className="flex items-center gap-2 text-xs font-semibold">
<span className="w-2 h-2 rounded-full bg-amber-400 animate-pulse"></span>
<span>元件暫未支援: [{type}]</span>
</div>
<div className="mt-1 text-xs opacity-70 font-mono overflow-x-auto">
Props: {JSON.stringify(props)}
</div>
{/* 依然保留子節點渲染能力 */}
{children && <div className="mt-2 pl-2 border-l border-amber-800">{children}</div>}
</div>
);
};
package com.antechinus.travel.web;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* UI Catalog 規格查詢端點
* 供前端或治理平台查詢當前 Agent 允許生成的元件白名單
*/
@RestController
@RequestMapping("/api/ui")
public class UiCatalogController {
/**
* 取得後端宣告支援的元件清單
*/
@GetMapping("/catalog")
public ResponseEntity<Map<String, Object>> getSupportedCatalog() {
return ResponseEntity.ok(Map.of(
"version", "1.0.0",
"supportedComponents", List.of("Stack", "Heading", "MetricCard", "AlertBanner", "DataTable"),
"description", "Antechinus Travel 官方儀表板元件型錄"
));
}
}
dashboardCatalog.ts 並於前端實作完畢後,才允許放進後端 Prompt。Stack 時漏傳了 gap,前端元件直接以 undefined 拼接成 gap-undefined 樣式錯誤。.default(4),並在 React 元件內部加上解構預設值(gap = 4)。MegaCustomerDashboard 元件吃 30 個 props,失去原子化組合的彈性。MetricCard、DataTable、AlertBanner 等原子元件,由 LLM 透過 Stack 自由組合。| 治理維度 | ❌ 傳統口頭約定 (Bad) | ✅ Zod Catalog + 自動對帳 (Good) |
|---|---|---|
| 契約定義方式 | 散落在 Markdown 文件或聊天記錄中 | 單一真實來源(Single Source of Truth),Zod 程式碼化 |
| 漂移偵測時機 | 使用者回報白屏 Bug 時才發現 | 前端啟動或 CI/CD 自動化測試中即時警示 |
| 未知元件處置 | React 丟出 Uncaught TypeError 整頁白屏 | SmartFallbackComponent 容錯渲染,保留佈局與子節點 |
| Prompt 維護性 | 手動複製貼上 JSON 結構至 Prompt | 由 Schema 自動生成 Prompt 結構化指引,零維護負擔 |
實作系統的「元件型錄 → 型錄管理」頁把今天的主題做成了日常維運工具:型錄存在後端資料庫(供 LLM 生成時參考,僅「啟用」的元件會進 prompt),「前端實作」欄位即時比對該元件是否已在前端 Registry 註冊——若後端有定義但前端未實作,會標示 ⚠ 並在渲染時落到 Fallback 未知元件,缺口一眼可見。

dashboardCatalog.ts,完成 Stack、Heading、MetricCard、AlertBanner、DataTable 的 Zod 定義。useCatalogReconciliation Hook,刻意在 Catalog 中新增一個前端尚未實作的 RadarChart,驗證控制台是否如期跳出警告且畫面以 SmartFallbackComponent 優雅呈現。MetricCard 而不能生成 DataTable,這種權限控制應該在後端 GOAP Action 處理,還是在前端 Registry 處理?