Compare commits

...
Author SHA1 Message Date
weichen 360a8a8946 Add the optional node id/source fields to the TS contract authority
Why: PR #25 added the fields to the Go UINode; ts/types.ts is the
single authority that make contracts mirrors into the frontend and UI
repos, so the fields must originate here or the sync reverts manual
mirror edits (CI build caught exactly that).

Evidence: Regenerated mirrors and combined make test run in the Dev
workspace after re-pinning.

Impact: Type-only additive change.

Refs: Core PR #25; Dev task feature/generative-ui-u2
2026-10-11 08:44:00 +00:00
weichen 452daf3ffa Merge pull request 'Add the Surface contract types, node ids, and spec validation' (#25) from feature/generative-ui-u2-surface into main 2026-10-11 08:15:26 +00:00
weichen 626f8e1062 Add the Surface contract types, node ids, and spec validation
Why: The Generative UI proposal U2 needs a versioned, host-validated
envelope for agent-authored pages before any backend surface exists:
identity/compat/version envelopes, stable per-node ids with source
annotations, catalog-driven spec validation, and the surface.updated
projection event.

Evidence: go vet + go test in this repo (table tests cover a realistic
valid spec from the U1 trial shape, violations, limits, and old-payload
backward compatibility).

Impact: Additive only - UINode gains optional id/source (omitempty),
old card/form paths unaffected; no existing symbol changes.

Refs: Documents proposal 2026-10-10-generative-ui-plugin.md (r2, U2/U1
sections); Dev task feature/generative-ui-u2
2026-10-11 08:15:10 +00:00
5 changed files with 373 additions and 1 deletions
+3
View File
@@ -61,6 +61,9 @@ const (
EvCardUpdated StreamEventType = "card.updated"
EvCardStatus StreamEventType = "card.status"
EvCardResolved StreamEventType = "card.resolved"
// EvSurfaceUpdated:Generative UI Surface 快照保存后的投影事件(提案 U2;
// 载荷 core.SurfaceSnapshot,按会话流发布,前端消费在 U4)。
EvSurfaceUpdated StreamEventType = "surface.updated"
)
// ProtocolVersion 是事件信封与 payload 词汇的当前版本。
+224
View File
@@ -0,0 +1,224 @@
package core
import "fmt"
// Surface 契约(Generative UI 提案 U2;依据 r2「U1 结果与 r2 修订」):
// 有版本的呈现产物信封 + 页面规格校验。信封与版本字段由服务端分配/校验,
// 模型只产出 SurfaceSpec;节点级稳定 ID 与来源标注由 UINode 的可选字段承载
// (ui.go),旧卡片/表单路径零影响。
const (
// SurfaceSchemaVersion 是 Surface 信封/规格的结构版本。
SurfaceSchemaVersion = 1
// SurfaceCatalogVersion 是当前认可的组件目录版本(现有 16 类 UINode 词表)。
SurfaceCatalogVersion = "ui-v1"
// SurfaceMaxNodes / SurfaceMaxDepth / SurfaceMaxBytes 是 U1 试验采纳的资源
// 预算(提案 KTD5):超限整页拒绝,不静默裁剪。
SurfaceMaxNodes = 200
SurfaceMaxDepth = 8
SurfaceMaxBytes = 256 * 1024
)
// SurfaceCompat 兼容标识(KTD3):由宿主与已安装组件协商记录,不采信自报值。
type SurfaceCompat struct {
SchemaVersion int `json:"schemaVersion"`
CatalogVersion string `json:"catalogVersion"`
PluginVersion string `json:"pluginVersion,omitempty"`
}
// SurfaceIdentity 身份信封:全部由服务端分配或从当前请求绑定;提交方不能自报。
type SurfaceIdentity struct {
SurfaceID ID `json:"surfaceId"`
Owner string `json:"owner"`
ProjectID ID `json:"projectId,omitempty"`
ConversationID ID `json:"conversationId,omitempty"`
SourceRunID ID `json:"sourceRunId,omitempty"`
PluginID string `json:"pluginId"`
}
// SurfaceSpec 是模型可产出的页面规格(KTD3「页面」层)。
type SurfaceSpec struct {
Title string `json:"title"`
Nodes []UINode `json:"nodes"`
}
// SurfaceSnapshot 是已保存的呈现产物:信封+兼容+版本+页面+回放摘要。
// Revision 单调递增;更新必须携带 BaseRevision,冲突由服务端拒绝(不自动覆盖)。
type SurfaceSnapshot struct {
Identity SurfaceIdentity `json:"identity"`
Compat SurfaceCompat `json:"compat"`
Revision int `json:"revision"`
BaseRevision int `json:"baseRevision,omitempty"`
ContextVersion string `json:"contextVersion,omitempty"`
ActivationEpoch int64 `json:"activationEpoch"`
Spec SurfaceSpec `json:"spec"`
SemanticText string `json:"semanticText,omitempty"`
}
// SurfaceMeta 是列表投影(不携带整页规格)。
type SurfaceMeta struct {
SurfaceID ID `json:"surfaceId"`
Title string `json:"title"`
PluginID string `json:"pluginId"`
Revision int `json:"revision"`
ConversationID ID `json:"conversationId,omitempty"`
UpdatedAt string `json:"updatedAt"`
}
// ValidateSurfaceSpec 按目录纪律校验页面规格,返回违规清单(空即合法)。
// knownSources 为 nil 表示本层不校验来源授权(U3 数据绑定层职责),仅做格式
// 检查;非空时引用/doc-ref 未知来源即为违规。未知类型、非法 props、重复 ID、
// 超限均拒绝(U1 试验校验器的 Go 版本:目录=提示=校验三同源)。
func ValidateSurfaceSpec(spec SurfaceSpec, knownSources map[string]bool) []string {
var errs []string
seen := map[string]bool{}
nodes := 0
var walk func(n UINode, path string, depth int)
walk = func(n UINode, path string, depth int) {
nodes++
if nodes > SurfaceMaxNodes {
errs = append(errs, fmt.Sprintf("%s: 节点数超过 %d", path, SurfaceMaxNodes))
return
}
if depth > SurfaceMaxDepth {
errs = append(errs, fmt.Sprintf("%s: 嵌套深度超过 %d", path, SurfaceMaxDepth))
return
}
if n.ID != "" {
if seen[n.ID] {
errs = append(errs, fmt.Sprintf("%s: 重复节点 id %q", path, n.ID))
}
seen[n.ID] = true
}
for _, s := range n.Source {
if s == "" {
errs = append(errs, path+": source 含空标识")
continue
}
if knownSources != nil && !knownSources[s] {
errs = append(errs, fmt.Sprintf("%s: 引用未知来源 %q", path, s))
}
}
switch n.Type {
case UISection:
for i, c := range n.Children {
walk(c, fmt.Sprintf("%s/child[%d]", path, i), depth+1)
}
case UIList:
if n.Items == nil {
errs = append(errs, path+": list 需要 items")
}
case UIKV:
if n.KV == nil {
errs = append(errs, path+": kv 需要键值对数组")
}
case UIForm:
if len(n.Fields) == 0 {
errs = append(errs, path+": form.fields 需非空")
}
for _, f := range n.Fields {
switch f.Type {
case "text", "textarea", "number", "boolean", "select":
default:
errs = append(errs, fmt.Sprintf("%s: 字段 %q 非法 type %q", path, f.Key, f.Type))
}
if f.Type == "select" && len(f.Options) == 0 {
errs = append(errs, fmt.Sprintf("%s: select 字段 %q 缺 options", path, f.Key))
}
}
case UIMarkdown:
if n.Markdown == "" {
errs = append(errs, path+": markdown 需要正文")
}
case UIDocRef:
if n.DocID == "" {
errs = append(errs, path+": doc-ref 需要 docId")
} else if knownSources != nil && !knownSources[string(n.DocID)] {
errs = append(errs, fmt.Sprintf("%s: doc-ref 引用未知工件 %q", path, n.DocID))
}
case UIActions:
if len(n.Actions) == 0 {
errs = append(errs, path+": actions 需非空")
}
for _, a := range n.Actions {
if a.ID == "" || a.Label == "" {
errs = append(errs, fmt.Sprintf("%s: action 缺 id/label", path))
}
switch a.Style {
case "", "primary", "default", "danger":
default:
errs = append(errs, fmt.Sprintf("%s: action %q 非法 style %q", path, a.ID, a.Style))
}
}
case UIChartLite:
if n.Chart == nil || (n.Chart.Kind != "bar" && n.Chart.Kind != "line") ||
len(n.Chart.Labels) != len(n.Chart.Series) {
errs = append(errs, path+": chart 需要 kind∈{bar,line} 且 labels/series 等长")
}
case UIAvatar:
if n.Avatar == nil || n.Avatar.Name == "" {
errs = append(errs, path+": avatar 需要 name")
}
case UIProgress:
if n.Progress == nil || n.Progress.Value < 0 || n.Progress.Value > 1 {
errs = append(errs, path+": progress.value 需在 0~1")
}
case UICompare:
if n.Compare == nil {
errs = append(errs, path+": compare 需要 left/right")
} else {
for side, sd := range map[string]UICompareSide{"left": n.Compare.Left, "right": n.Compare.Right} {
for i, c := range sd.Nodes {
walk(c, fmt.Sprintf("%s/%s[%d]", path, side, i), depth+1)
}
}
}
case UITree:
if n.Tree == nil || len(n.Tree.Nodes) == 0 {
errs = append(errs, path+": tree.nodes 需非空")
}
case UITable:
if n.Table == nil || len(n.Table.Columns) == 0 {
errs = append(errs, path+": table.columns 需非空")
} else {
for i, r := range n.Table.Rows {
if len(r.Cells) != len(n.Table.Columns) {
errs = append(errs, fmt.Sprintf("%s: 行[%d] cells 与 columns 不等长", path, i))
}
}
}
case UISteps:
if n.Steps == nil || len(n.Steps.Steps) == 0 || n.Steps.Current < 0 || n.Steps.Current >= len(n.Steps.Steps) {
errs = append(errs, path+": steps 需非空且 current 在范围内")
}
case UITabs:
if n.Tabs == nil || len(n.Tabs.Tabs) == 0 {
errs = append(errs, path+": tabs.tabs 需非空")
} else {
for i, tb := range n.Tabs.Tabs {
for j, c := range tb.Nodes {
walk(c, fmt.Sprintf("%s/tab[%d][%d]", path, i, j), depth+1)
}
}
}
case UITimeline:
if n.Timeline == nil || len(n.Timeline.Items) == 0 {
errs = append(errs, path+": timeline.items 需非空")
}
default:
errs = append(errs, fmt.Sprintf("%s: 未知节点类型 %q", path, n.Type))
}
}
if spec.Title == "" {
errs = append(errs, "$: title 必填")
}
if len(spec.Nodes) == 0 {
errs = append(errs, "$: nodes 需非空")
}
for i, n := range spec.Nodes {
walk(n, fmt.Sprintf("$[%d]", i), 1)
}
return errs
}
+137
View File
@@ -0,0 +1,137 @@
package core
import (
"encoding/json"
"strings"
"testing"
)
func validSpec() SurfaceSpec {
return SurfaceSpec{
Title: "三家候选工具选型对比",
Nodes: []UINode{
{Type: UISection, ID: "n1", Title: "一、概览", Children: []UINode{
{Type: UIMarkdown, ID: "n1a", Source: []string{"doc:eval/rc-01/vendor1"}, Markdown: "本期评估三家候选。"},
}},
{Type: UITable, ID: "n2", Source: []string{"doc:eval/rc-01/vendor1", "doc:eval/rc-01/vendor2"}, Table: &UITableSpec{
Columns: []UITableColumn{{Key: "k", Label: "维度"}, {Key: "v", Label: "值"}},
Rows: []UITableRow{{ID: "r1", Cells: [][2]string{{"k", "价格"}, {"v", "¥8/人/天"}}}},
}},
{Type: UISteps, ID: "n3", Steps: &UIStepsSpec{Current: 1, Steps: []UIStep{
{Label: "需求冻结", Status: "done"}, {Label: "契约定义", Status: "active"},
}}},
{Type: UIForm, ID: "n4", Fields: []UIField{
{Key: "years", Label: "归档年限", Type: "select", Options: []string{"3", "5"}, Required: true},
}},
},
}
}
func TestValidateSurfaceSpecValid(t *testing.T) {
if errs := ValidateSurfaceSpec(validSpec(), nil); len(errs) != 0 {
t.Fatalf("合法规格不应有违规: %v", errs)
}
// JSON 往返后仍合法(信封序列化面)。
b, err := json.Marshal(validSpec())
if err != nil {
t.Fatal(err)
}
var back SurfaceSpec
if err := json.Unmarshal(b, &back); err != nil {
t.Fatal(err)
}
if errs := ValidateSurfaceSpec(back, nil); len(errs) != 0 {
t.Fatalf("JSON 往返后不应有违规: %v", errs)
}
}
func TestValidateSurfaceSpecViolations(t *testing.T) {
cases := []struct {
name string
mut func(*SurfaceSpec)
want string
}{
{"未知类型", func(s *SurfaceSpec) { s.Nodes[0].Type = " hologram" }, "未知节点类型"},
{"重复节点 id", func(s *SurfaceSpec) { s.Nodes[1].ID = "n1" }, "重复节点 id"},
{"表格行列不齐", func(s *SurfaceSpec) {
s.Nodes[1].Table.Rows = []UITableRow{{Cells: [][2]string{{"k", "只有一列"}}}}
}, "不等长"},
{"select 缺 options", func(s *SurfaceSpec) {
s.Nodes[3].Fields[0].Options = nil
}, "缺 options"},
{"steps 越界", func(s *SurfaceSpec) { s.Nodes[2].Steps.Current = 5 }, "current 在范围内"},
{"空 title", func(s *SurfaceSpec) { s.Title = "" }, "title 必填"},
{"空 nodes", func(s *SurfaceSpec) { s.Nodes = nil }, "nodes 需非空"},
{"markdown 空", func(s *SurfaceSpec) {
s.Nodes[0].Children[0].Markdown = ""
}, "markdown 需要正文"},
{"progress 越界", func(s *SurfaceSpec) {
s.Nodes = append(s.Nodes, UINode{Type: UIProgress, Progress: &UIProgressSpec{Value: 1.5}})
}, "progress.value"},
{"未知来源", func(s *SurfaceSpec) {}, ""},
}
for _, c := range cases {
s := validSpec()
c.mut(&s)
errs := ValidateSurfaceSpec(s, nil)
if c.want == "" {
continue
}
found := false
for _, e := range errs {
if strings.Contains(e, c.want) {
found = true
break
}
}
if !found {
t.Errorf("%s: 期望包含 %q,得到 %v", c.name, c.want, errs)
}
}
// knownSources 非空时:未知来源拒绝、已知来源放行。
s := validSpec()
errs := ValidateSurfaceSpec(s, map[string]bool{"doc:eval/rc-01/vendor1": true, "doc:eval/rc-01/vendor2": true})
if len(errs) != 0 {
t.Fatalf("已知来源全引用不应违规: %v", errs)
}
s.Nodes[0].Children[0].Source = append(s.Nodes[0].Children[0].Source, "doc:eval/rc-99/ghost")
errs = ValidateSurfaceSpec(s, map[string]bool{"doc:eval/rc-01/vendor1": true})
if len(errs) == 0 {
t.Fatal("引用未知来源应拒绝")
}
}
func TestValidateSurfaceSpecLimits(t *testing.T) {
deep := UINode{Type: UISection, ID: "d0"}
cur := &deep
for i := 1; i <= SurfaceMaxDepth+1; i++ {
cur.Children = []UINode{{Type: UISection, ID: "d" + string(rune('0'+i%10))}}
cur = &cur.Children[0]
}
if errs := ValidateSurfaceSpec(SurfaceSpec{Title: "t", Nodes: []UINode{deep}}, nil); len(errs) == 0 {
t.Fatal("超过嵌套深度应拒绝")
}
many := make([]UINode, SurfaceMaxNodes+1)
for i := range many {
many[i] = UINode{Type: UIMarkdown, Markdown: "x"}
}
if errs := ValidateSurfaceSpec(SurfaceSpec{Title: "t", Nodes: many}, nil); len(errs) == 0 {
t.Fatal("超过节点数应拒绝")
}
}
func TestUINodeBackwardCompat(t *testing.T) {
// 旧卡片路径的 UINode JSON(无 id/source)解出后字段为零值,不受影响。
var n UINode
if err := json.Unmarshal([]byte(`{"type":"list","items":["a"]}`), &n); err != nil {
t.Fatal(err)
}
if n.ID != "" || n.Source != nil || n.Type != UIList || len(n.Items) != 1 {
t.Fatalf("旧负载解引用异常: %+v", n)
}
// 新字段双向序列化。
b, _ := json.Marshal(UINode{Type: UIMarkdown, ID: "m1", Source: []string{"s1"}, Markdown: "x"})
if !strings.Contains(string(b), `"id":"m1"`) || !strings.Contains(string(b), `"source":["s1"]`) {
t.Fatalf("新字段序列化缺失: %s", b)
}
}
+3
View File
@@ -184,6 +184,9 @@ export type UINodeType =
export interface UINode {
type: UINodeType;
/** Surface 层节点级标注(Generative UI U2):整页唯一稳定 id 与事实来源标识,旧路径不填。 */
id?: string;
source?: string[];
title?: string;
items?: string[];
kv?: [string, string][];
+5
View File
@@ -29,6 +29,11 @@ const (
type UINode struct {
Type UINodeType `json:"type"`
// ID / Source 是 Generative UI Surface 层的节点级标注(提案 U2):ID 整页
// 唯一且稳定(交互与修订对位);Source 为支撑该节点事实内容的来源标识,
// 授权校验在数据绑定层(U3)。均为可选:旧卡片/表单路径不填不受影响。
ID string `json:"id,omitempty"`
Source []string `json:"source,omitempty"`
Title string `json:"title,omitempty"`
Items []string `json:"items,omitempty"` // list
KV [][2]string `json:"kv,omitempty"` // kv