Files
Agent-System-Backend/internal/genui/genui.go
weichenandCursor 5e0077dc11
ci / vet-and-test (pull_request) Successful in 48s
Whitelist B-class surface actions for help and intent
Why: Generative UI U5 left the B-class action allowlist empty; owner
authorized first entries builtin.help and system.intent.
Evidence: go test ./internal/genui/ ./internal/surfacesvc/ ./internal/server/
Impact: Surface buttons may bind help/intent; button-entrance intent
matches only and does not nest-dispatch write commands.
Refs: docs/proposals/2026-10-12-generative-ui-bclass-help-intent.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 03:18:57 +00:00

540 lines
19 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package genui 是 Generative UI 的内置进程内插件(提案 U3):
// 当前 Agent 经 exposeAsTool 的 generative-ui.present 命令提交声明式页面,
// 宿主(EXT-17 SurfaceHost)校验并原子发布;目录=提示=校验三同源。
// 不接管对话循环、模型凭据或任务执行权(KTD2);不另造 Agent runtime。
package genui
import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
// PluginID 是插件身份(activation 台账与命令前缀共用)。
const PluginID = "generative-ui"
const (
// CommandPresent 是页面提交命令(revise 即带 surfaceId+baseRevision 的再次 present)。
CommandPresent = PluginID + ".present"
// CommandSubmit 是 U5 已批准的 C 类页面动作(表单数据落插件 KV 台账;
// 不 exposeAsTool——按钮专用,模型不可直接调用)。
CommandSubmit = PluginID + ".submit"
// ActionHelp / ActionIntent 是 B 类首批加白(独立评审:builtin.help、system.intent)。
ActionHelp = "builtin.help"
ActionIntent = "system.intent"
)
// AllowedActionIDs 是 present 校验与 surfacesvc.AllowedActions 的开放动作集合(须同步)。
var AllowedActionIDs = map[string]struct{}{
CommandSubmit: {},
ActionHelp: {},
ActionIntent: {},
}
// Catalog 是组件目录与纪律(U1 试验 CATALOG_PROMPT 的 Go 权威版;工具描述携带,
// 模型与校验器看到同一份规则)。修改需同步 core.ValidateSurfaceSpec 与目录版本。
const Catalog = `何时用本工具:用户要求"整理成一页 / 做个对比 / 出一页报告 / 看板 / 时间线"这类**页面化交付**时,优先调用本工具提交声明式页面,而不是写文件工件——页面会在用户预览区直接打开并可继续修订。纯文本问答、简短列表、代码交付保持原方式,不强行出页。
任务工作页组织(用户在任务/项目工作区查看时):按任务阶段决定页面结构——需求阶段突出待澄清项与决策点(form/kv 优先);执行阶段突出步骤、进度与产物(steps/timeline/table 优先);验收阶段突出验证结果与缺项(kv+markdown 结论优先)。任务完成状态、步骤勾选等**事实字段只能来自对话上下文中的真实任务记录**(计划/待办/验证结果),你不能凭页面渲染改变任务状态——只做事实的组织与呈现。
可用节点类型(16 种;字段必须严格匹配,禁止目录外类型/字段):
section{title?,children?};list{items:string[]};kv{kv:[[k,v]]};
form{fields:[{key,label,type:text|textarea|number|boolean|select,options?(select必填),required?,placeholder?,help?,default?}]};
markdown{markdown};doc-ref{docId}(须为会话内存在的工件ID);
actions{actions:[{id,label,style?:primary|default|danger}]}(不带command);
chart-lite{chart:{kind:bar|line,labels:[],series:[]等长}};avatar{avatar:{name,sub?,tone?:ok|warn|danger|info,stack?}};
progress{progress:{label?,value:0~1}};compare{compare:{left:{title?,tone?,nodes?},right:{同left}}};
tree{tree:{nodes:[{id,label,tone?,badge?,count?,children?}]}};
table{table:{columns:[{key,label}],rows:[{id?,cells:[[key,值]与columns等长同序}]}};
steps{steps:{current:0起,steps:[{label,status?:pending|active|done|failed,tone?}]}};
tabs{tabs:{tabs:[{id,label,nodes?}],active?}};timeline{timeline:{items:[{title,time?,tone?,markdown?,ref?}]}}。
规则:每节点可带 id(整页唯一稳定)与 source[](支撑事实的来源标识);事实性内容标注来源,
分析/建议只放 markdown 且不加 source;≤200 节点、≤8 层、≤256KiB;未知类型/字段与未知
doc-ref 会被拒绝并返回字段级错误(可修正后重提一次)。结构服务目标:比较→compare/table;
风险→按严重度组织;顺序→steps/timeline;收集输入→form。
actions 节点纪律:action 的 id 仅允许开放白名单——generative-ui.submit(C 类表单提交)、
builtin.help(B 类只读帮助)、system.intent(B 类意图匹配;button 入口只返回匹配结果、
不嵌套分发业务命令)。引用其他 action id 会被拒绝;按钮点击由用户发起,宿主经命令总线分发并审计。
最小完整示例(spec 参数的形状;title 与 nodes 在顶层,source 挂在节点上):
{"title":"示例页","nodes":[
{"type":"section","id":"n1","title":"结论","children":[
{"type":"markdown","id":"n1a","source":["doc:m1"],"markdown":"结论先行。"}]},
{"type":"table","id":"n2","table":{"columns":[{"key":"k","label":"维度"},{"key":"v","label":"值"}],
"rows":[{"id":"r1","cells":[["k","报价"],["v","¥8/人/天"]]}]}},
{"type":"steps","id":"n3","steps":{"current":0,"steps":[{"label":"评审","status":"pending"}]}}]}`
func init() {
extension.RegisterCompiled(extension.InProc{ID: PluginID, Register: register})
}
func register(reg extension.Registry) error {
spec := json.RawMessage(fmt.Sprintf(`{
"id": %q,
"title": "生成页面",
"description": %q,
"paramsSchema": {
"type": "object",
"properties": {
"spec": {"type": "object", "description": "页面规格:顶层恰为 {title: string, nodes: [节点数组]}",
"properties": {"title": {"type": "string"}, "nodes": {"type": "array", "items": {"type": "object"}}},
"required": ["title", "nodes"]},
"surfaceId": {"type": "string", "description": "修订已有页面时传入"},
"baseRevision": {"type": "integer", "description": "修订基于的当前修订号"},
"summary": {"type": "string", "description": "一句话语义摘要(回放与索引用)"}
},
"required": ["spec"]
},
"exposeAsTool": {"risk": "low"}
}`, CommandPresent, "提交一页声明式页面(由宿主校验、保存并在预览区渲染)。"+Catalog))
if err := reg.AddCommandHandler(spec, present); err != nil {
return err
}
submitSpec := json.RawMessage(`{
"id": "generative-ui.submit",
"title": "提交表单",
"description": "页面动作:提交用户填写的表单数据(落插件台账;按钮触发,不经模型)",
"paramsSchema": {
"type": "object",
"properties": {
"surfaceId": {"type": "string"},
"revision": {"type": "integer"},
"requestId": {"type": "string"},
"data": {"type": "object"}
},
"required": ["surfaceId", "requestId", "data"]
}
}`)
return reg.AddCommandHandler(submitSpec, submitForm)
}
// submitForm 是 C 类动作处理器:表单数据落插件自有 KV 台账(键含 requestId
// 天然幂等;已批准范围限定 KV+任务台账,不建业务事实表)。经命令总线四入口
// 同源:审计信封与(若未来升档)高危确认卡由总线收口。
func submitForm(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
surfaceID, _ := req.Params["surfaceId"].(string)
requestID, _ := req.Params["requestId"].(string)
if surfaceID == "" || requestID == "" {
return nil, fmt.Errorf("generative-ui.submit: surfaceId and requestId required")
}
data, _ := json.Marshal(req.Params["data"])
key := "form:" + surfaceID + ":" + requestID
if err := h.KVPut(key, string(data)); err != nil {
return nil, err
}
if err := h.Audit("surface.form_submitted", surfaceID, "requestId="+requestID+" bytes="+fmt.Sprint(len(data))); err != nil {
return nil, err
}
return map[string]any{"ok": true, "stored": true, "key": key}, nil
}
// ValidateActionIDs 供 present 校验:actions 节点引用的 id 必须在已开放集合内。
func ValidateActionIDs(spec []byte) []string {
var s struct {
Nodes []core.UINode `json:"nodes"`
}
if err := json.Unmarshal(spec, &s); err != nil {
return nil
}
var errs []string
var walk func(n core.UINode, path string)
walk = func(n core.UINode, path string) {
if n.Type == core.UIActions {
for _, a := range n.Actions {
if _, ok := AllowedActionIDs[a.ID]; !ok {
errs = append(errs, fmt.Sprintf("%s: 未知动作 id %q(不在开放白名单)", path, a.ID))
}
}
}
for i, c := range n.Children {
walk(c, fmt.Sprintf("%s/child[%d]", path, i))
}
if n.Compare != nil {
for side, sd := range map[string]core.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))
}
}
}
if n.Tabs != nil {
for i, tb := range n.Tabs.Tabs {
for j, c := range tb.Nodes {
walk(c, fmt.Sprintf("%s/tab[%d][%d]", path, i, j))
}
}
}
}
for i, n := range s.Nodes {
walk(n, fmt.Sprintf("$[%d]", i))
}
return errs
}
// present 是命令处理器:经 owner 作用域 Host 的 SurfaceHost 能力提交页面。
// 能力缺失(宿主未装配)与未启用明确报错;doc-ref 经 owner 作用域工件解析做
// 真实授权(伪造引用拒绝);校验失败返回字段级清单供模型修复重提(至多一次)。
func present(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
sh, ok := h.(extension.SurfaceHost)
if !ok {
return nil, fmt.Errorf("generative-ui: surface capability not available in this host")
}
enabled, epoch, err := sh.SurfaceActivation(ctx)
if err != nil {
return nil, err
}
if !enabled {
return nil, fmt.Errorf("generative-ui: 插件未启用(owner 未开启 Surface 能力),本次提交被拒绝")
}
raw, _ := json.Marshal(normalizeSpecParam(req.Params["spec"]))
surfaceID, _ := req.Params["surfaceId"].(string)
base := 0
if v, ok := req.Params["baseRevision"].(float64); ok {
base = int(v)
}
summary, _ := req.Params["summary"].(string)
// doc-ref 真实授权:spec 里的 docId 必须是当前 owner 可解析的工件(伪造即拒)。
if errs := authorizeDocRefs(raw, h); len(errs) > 0 {
return toolError("invalid spec", errs), nil
}
// 动作白名单(U5 已批准范围):未知动作 id 拒绝并回字段级错误。
if errs := ValidateActionIDs(raw); len(errs) > 0 {
return toolError("invalid spec", errs), nil
}
snap, err := sh.SurfaceSave(ctx, extension.SurfaceSaveRequest{
SurfaceID: core.ID(surfaceID),
ConversationID: core.ID(req.ConversationID),
BaseRevision: base,
ObservedEpoch: epoch,
SemanticText: summary,
Spec: raw,
})
if err != nil {
if strings.HasPrefix(err.Error(), "surface: invalid spec:") {
details := strings.TrimSpace(strings.TrimPrefix(err.Error(), "surface: invalid spec:"))
var list []string
for _, d := range strings.Split(details, ";") {
if t := strings.TrimSpace(d); t != "" {
list = append(list, t)
}
}
return toolError("invalid spec", list), nil
}
return nil, err
}
return map[string]any{
"ok": true,
"surfaceId": string(snap.Identity.SurfaceID),
"revision": snap.Revision,
"epoch": snap.ActivationEpoch,
"summary": snap.SemanticText,
"note": "页面已保存;用户在预览区查看。修订时带 surfaceId 与 baseRevision 重提。",
}, nil
}
// toolError 是返回给模型的字段级错误(工具结果错误,非分发错误)。
type toolErrorBody struct {
OK bool `json:"ok"`
Error string `json:"error"`
Details []string `json:"details,omitempty"`
Note string `json:"note"`
}
func toolError(msg string, details []string) toolErrorBody {
return toolErrorBody{OK: false, Error: msg, Details: details,
Note: "按错误修正 spec 后可重提一次;再次失败请改用文本回答。"}
}
// normalizeSpecParam 是插件的 schema 修复策略(r1 KTD1;U3 回放实测的模型偏差
// 逐条落地):单键包装({"page":…})解包;顶层 children→nodes;kv/cells 的
// 对象形式({"k":…,"v":…}/{"key":…,"value":…})改写为二元组数组。只做结构
// 改写,不改内容语义;修复后仍过 core 校验器。
func normalizeSpecParam(v any) any {
if arr, ok := v.([]any); ok {
return map[string]any{"title": "", "nodes": arr}
}
m, ok := v.(map[string]any)
if !ok {
return v
}
if len(m) == 1 {
if inner, ok := m["page"].(map[string]any); ok {
m = inner
}
}
if _, ok := m["nodes"]; !ok {
if ch, ok := m["children"].([]any); ok {
m["nodes"] = ch
delete(m, "children")
}
}
// 页级 source(模型的常见引用写法):分发给未自带 source 的节点——保留引用
// 信息,不发明内容;节点自带 source 者不受影响。
if top, ok := m["source"].([]any); ok {
delete(m, "source")
if nodes, ok := m["nodes"].([]any); ok {
for _, n := range nodes {
if nm, ok := n.(map[string]any); ok {
if _, has := nm["source"]; !has {
cp := make([]any, len(top))
copy(cp, top)
nm["source"] = cp
}
}
}
}
}
repairChildrenShape(m)
// {"item": …} 单键包装在 children 形状修复之后剥:children 的 {"item":{node}}
// 由 repairChildrenShape 的对象映射规则兜住,先剥反而会把单子节点打散进
// 值数组;kv/cells/items/columns 等其余数组字段在这里统一解到本体。
if unwrapped, ok := repairItemWrappers(m).(map[string]any); ok {
m = unwrapped
}
repairPairs(m)
repairTableCells(m)
return m
}
// repairItemWrappers 递归剥掉 XML-RPC 风格的 {"item": X} 单键包装(2026-10-12
// dev 实测 MiniMax-M3,事件 seq 1395/2171):数组字段(kv/cells/items/columns…)
// 整字段被包一层 {"item":[…]},数组元素再被包 {"item":["k","v"]}/{"item":"v"}。
// 规范词汇无名为 item 的字段(core/ui.go 全量核对),单键 item 不承载语义,
// 安全解包;只改结构不改内容。
func repairItemWrappers(v any) any {
switch t := v.(type) {
case map[string]any:
if len(t) == 1 {
if inner, ok := t["item"]; ok {
return repairItemWrappers(inner)
}
}
out := make(map[string]any, len(t))
for k, val := range t {
out[k] = repairItemWrappers(val)
}
return out
case []any:
out := make([]any, len(t))
for i, item := range t {
out[i] = repairItemWrappers(item)
}
return out
default:
return v
}
}
// repairTableCells 表格行 cells 的裸标量数组(同场实测):cells 期望与
// columns.key 对应的 [key,value] 二元组,模型输出成与列等长的裸值数组。
// 长度一致且列键齐全时按列序配对(结构改写,不发明内容);不一致则不动,
// 交校验器给字段级错误供模型修复重提。
func repairTableCells(node any) {
m, ok := node.(map[string]any)
if !ok {
return
}
if tbl, ok := m["table"].(map[string]any); ok {
cols, colsOK := tbl["columns"].([]any)
rows, rowsOK := tbl["rows"].([]any)
if colsOK && rowsOK {
keys := make([]string, len(cols))
valid := true
for i, c := range cols {
cm, ok := c.(map[string]any)
if !ok {
valid = false
break
}
k, ok := cm["key"].(string)
if !ok || k == "" {
valid = false
break
}
keys[i] = k
}
if valid {
for _, r := range rows {
rm, ok := r.(map[string]any)
if !ok {
continue
}
cells, ok := rm["cells"].([]any)
if !ok || len(cells) != len(keys) {
continue
}
paired := make([]any, 0, len(cells))
convertible := true
for i, c := range cells {
switch cv := c.(type) {
case string:
paired = append(paired, []any{keys[i], cv})
case float64:
paired = append(paired, []any{keys[i], strconv.FormatFloat(cv, 'f', -1, 64)})
default:
convertible = false
break
}
}
if convertible {
rm["cells"] = paired
}
}
}
}
}
for _, v := range m {
if vm, ok := v.(map[string]any); ok {
repairTableCells(vm)
}
if va, ok := v.([]any); ok {
for _, item := range va {
if im, ok := item.(map[string]any); ok {
repairTableCells(im)
}
}
}
}
}
// repairChildrenShape 递归修复 children 的常见形状偏差(真实模型实测):
// children 为对象(按 key 编号的子节点映射)→ 取其值转数组;单对象 → 包一层数组。
func repairChildrenShape(node any) {
m, ok := node.(map[string]any)
if !ok {
return
}
if ch, ok := m["children"]; ok && ch != nil {
switch v := ch.(type) {
case map[string]any:
arr := make([]any, 0, len(v))
for _, val := range v {
arr = append(arr, val)
}
m["children"] = arr
case []any:
for _, c := range v {
if cm, ok := c.(map[string]any); ok {
repairChildrenShape(cm)
}
}
default:
// 单对象子节点
if cm, ok := ch.(map[string]any); ok {
m["children"] = []any{cm}
}
}
}
for _, v := range m {
if vm, ok := v.(map[string]any); ok {
repairChildrenShape(vm)
}
if va, ok := v.([]any); ok {
for _, item := range va {
if im, ok := item.(map[string]any); ok {
repairChildrenShape(im)
}
}
}
}
}
// repairPairs 递归把 "kv"/"cells" 下的对象项改写为二元组。
func repairPairs(node any) {
m, ok := node.(map[string]any)
if !ok {
return
}
for key, val := range m {
if (key == "kv" || key == "cells") && val != nil {
if arr, ok := val.([]any); ok {
fixed := make([]any, 0, len(arr))
changed := false
for _, item := range arr {
if p, ok := pairFromObject(item); ok {
fixed = append(fixed, p)
changed = true
} else {
fixed = append(fixed, item)
}
}
if changed {
m[key] = fixed
}
}
}
repairPairs(val)
}
}
// pairFromObject 从 {"k","v"}/{"key","value"}/{"name","value"} 对象提取二元组。
func pairFromObject(item any) ([2]any, bool) {
obj, ok := item.(map[string]any)
if !ok {
return [2]any{}, false
}
var k, v any
for kk, vv := range obj {
switch kk {
case "k", "key", "name":
k = vv
case "v", "value":
v = vv
default:
return [2]any{}, false
}
}
if k == nil || v == nil {
return [2]any{}, false
}
ks, ok1 := k.(string)
vs, ok2 := v.(string)
if !ok1 || !ok2 {
return [2]any{}, false
}
return [2]any{ks, vs}, true
}
// authorizeDocRefs 收集 spec 中的 doc-ref docId 并经 Host 工件面做 owner 作用域解析。
func authorizeDocRefs(raw []byte, h extension.Host) []string {
var spec struct {
Nodes []core.UINode `json:"nodes"`
}
if err := json.Unmarshal(raw, &spec); err != nil {
return []string{"spec: " + err.Error()}
}
var errs []string
var walk func(n core.UINode, path string)
walk = func(n core.UINode, path string) {
if n.Type == core.UIDocRef && n.DocID != "" {
if _, err := h.ArtifactGet(string(n.DocID)); err != nil {
errs = append(errs, fmt.Sprintf("%s: doc-ref 引用了不存在或无权限的工件 %q", path, n.DocID))
}
}
for i, c := range n.Children {
walk(c, fmt.Sprintf("%s/child[%d]", path, i))
}
if n.Compare != nil {
for side, sd := range map[string]core.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))
}
}
}
if n.Tabs != nil {
for i, tb := range n.Tabs.Tabs {
for j, c := range tb.Nodes {
walk(c, fmt.Sprintf("%s/tab[%d][%d]", path, i, j))
}
}
}
}
for i, n := range spec.Nodes {
walk(n, fmt.Sprintf("$[%d]", i))
}
return errs
}