ci / vet-and-test (pull_request) Successful in 42s
Why: Generative UI U3 - the agent needs a real submission path and the next turn needs to see prior pages: a built-in in-process plugin registers generative-ui.present (exposeAsTool) carrying the catalog (prompt=validation single source) with a schema-repair strategy measured against real MiniMax-M3 deviations; the command bus gains an owner-scoped tool gate so a disabled plugin stops injecting its tool (execution guard remains in surfacesvc); ctxasm projects saved surfaces back into context so the model can revise existing structure; doc-refs are authorized against the owner's real artifacts. Evidence: unit suites green (genui guards/epoch/doc-ref, gate fail-closed, projection content); full go test ./... green; cmdbus/ctxasm also green under AGENT_PG_TEST_DSN; real-model closed loop (env-gated TestRealModelSurfaceLoop) passes: task->spec->save->projection->revise with revision 1->2 and structure change, CNY 0.08 total. Impact: New internal/genui package; additive cmdbus/ctxasm/runmgr/ runtime hooks; no storage or contract changes (U2 layer untouched). Refs: Documents proposal r2 U3; Core PR #25/#26; Backend #143; Dev task feature/generative-ui-u3
283 lines
10 KiB
Go
283 lines
10 KiB
Go
// Package genui 是 Generative UI 的内置进程内插件(提案 U3):
|
||
// 当前 Agent 经 exposeAsTool 的 generative-ui.present 命令提交声明式页面,
|
||
// 宿主(EXT-17 SurfaceHost)校验并原子发布;目录=提示=校验三同源。
|
||
// 不接管对话循环、模型凭据或任务执行权(KTD2);不另造 Agent runtime。
|
||
package genui
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"agent-system-backend/pkg/extension"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
// PluginID 是插件身份(activation 台账与命令前缀共用)。
|
||
const PluginID = "generative-ui"
|
||
|
||
// CommandPresent 是唯一提交命令 id(revise 即带 surfaceId+baseRevision 的再次 present)。
|
||
const CommandPresent = PluginID + ".present"
|
||
|
||
// Catalog 是组件目录与纪律(U1 试验 CATALOG_PROMPT 的 Go 权威版;工具描述携带,
|
||
// 模型与校验器看到同一份规则)。修改需同步 core.ValidateSurfaceSpec 与目录版本。
|
||
const Catalog = `可用节点类型(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。
|
||
最小完整示例(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))
|
||
return reg.AddCommandHandler(spec, present)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
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
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
repairPairs(m)
|
||
return m
|
||
}
|
||
|
||
// 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
|
||
}
|