Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
360a8a8946 | ||
|
|
452daf3ffa | ||
|
|
626f8e1062 |
@@ -61,6 +61,9 @@ const (
|
|||||||
EvCardUpdated StreamEventType = "card.updated"
|
EvCardUpdated StreamEventType = "card.updated"
|
||||||
EvCardStatus StreamEventType = "card.status"
|
EvCardStatus StreamEventType = "card.status"
|
||||||
EvCardResolved StreamEventType = "card.resolved"
|
EvCardResolved StreamEventType = "card.resolved"
|
||||||
|
// EvSurfaceUpdated:Generative UI Surface 快照保存后的投影事件(提案 U2;
|
||||||
|
// 载荷 core.SurfaceSnapshot,按会话流发布,前端消费在 U4)。
|
||||||
|
EvSurfaceUpdated StreamEventType = "surface.updated"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ProtocolVersion 是事件信封与 payload 词汇的当前版本。
|
// ProtocolVersion 是事件信封与 payload 词汇的当前版本。
|
||||||
|
|||||||
+224
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -184,6 +184,9 @@ export type UINodeType =
|
|||||||
|
|
||||||
export interface UINode {
|
export interface UINode {
|
||||||
type: UINodeType;
|
type: UINodeType;
|
||||||
|
/** Surface 层节点级标注(Generative UI U2):整页唯一稳定 id 与事实来源标识,旧路径不填。 */
|
||||||
|
id?: string;
|
||||||
|
source?: string[];
|
||||||
title?: string;
|
title?: string;
|
||||||
items?: string[];
|
items?: string[];
|
||||||
kv?: [string, string][];
|
kv?: [string, string][];
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ const (
|
|||||||
|
|
||||||
type UINode struct {
|
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"`
|
Title string `json:"title,omitempty"`
|
||||||
Items []string `json:"items,omitempty"` // list
|
Items []string `json:"items,omitempty"` // list
|
||||||
KV [][2]string `json:"kv,omitempty"` // kv
|
KV [][2]string `json:"kv,omitempty"` // kv
|
||||||
|
|||||||
Reference in New Issue
Block a user