diff --git a/events.go b/events.go index b9e6286..5d36da4 100644 --- a/events.go +++ b/events.go @@ -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 词汇的当前版本。 diff --git a/surface.go b/surface.go new file mode 100644 index 0000000..7128e52 --- /dev/null +++ b/surface.go @@ -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 +} diff --git a/surface_test.go b/surface_test.go new file mode 100644 index 0000000..5336cd3 --- /dev/null +++ b/surface_test.go @@ -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) + } +} diff --git a/ui.go b/ui.go index 7428f43..b2cb59f 100644 --- a/ui.go +++ b/ui.go @@ -28,7 +28,12 @@ const ( ) type UINode struct { - Type UINodeType `json:"type"` + 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