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 }