Add Generative UI auto-present opt-in with budget and cooldown #168

Merged
weichen merged 1 commits from feature/genui-auto-trigger-optin into main 2026-10-12 03:45:03 +00:00
6 changed files with 279 additions and 1 deletions
+13
View File
@@ -546,6 +546,19 @@ func Run(opt Options) error {
row, err := surfaceSvc.Activation(context.Background(), owner, genui.PluginID)
return err == nil && row.Enabled
})
// 自动建议页面 opt-in:settings ∧ activation;预算/冷却在 genui.Auto。
mgr.SetGenUIAuto(genui.NewAuto(
func(owner string) bool {
row, err := surfaceSvc.Activation(context.Background(), owner, genui.PluginID)
return err == nil && row.Enabled
},
func(owner string) bool {
return setSvc.Effective(owner, "").GetBool(genui.SettingAutoPresent, false)
},
func(owner, kind, detail string) {
_ = st.AppendAuditEvent(store.AuditEvent{Owner: owner, Action: kind, Evidence: detail})
},
))
bus.SetPendingStore(st)
pluginHost.OnCommands = bus.RegisterPluginCommands
+95
View File
@@ -0,0 +1,95 @@
package genui
import (
"sync"
"time"
)
// AutoBudgetPerHour 是每任务(conversation)滑动一小时内允许的自动建议次数。
const AutoBudgetPerHour = 3
// AutoCooldown 是同会话两次自动触发的最小间隔。
const AutoCooldown = 60 * time.Second
// SettingAutoPresent 是 owner 级 opt-in 设置键(默认 false;与 activation 分离)。
const SettingAutoPresent = "plugins.generative-ui.autoPresent"
// AutoReminder 是注入 system-reminder 的建议文案(只引导 present,禁止改任务/点动作)。
const AutoReminder = "任务语义事件已发生(阶段或材料/工件完成)。若 generative-ui 已启用,请调用一次 generative-ui.present 生成或修订建议页,按当前任务阶段组织信息。不要点击页面 actions,不要改写 plan/todo/任务完成状态;任务事实只能来自真实任务记录。"
// ActivationLookup 查询 owner 的 Surface activation(generative-ui)。
type ActivationLookup func(owner string) (enabled bool)
// SettingsLookup 查询 owner 的 autoPresent opt-in。
type SettingsLookup func(owner string) (enabled bool)
// Auditor 写审计事件(耗尽等);nil = 跳过。
type Auditor func(owner, kind, detail string)
// Auto 是自动触发闸门:opt-in ∧ activation ∧ 预算 ∧ 冷却。
// 触发源由 runmgr 显式调用;present/surface/action 路径不得调用。
type Auto struct {
activation ActivationLookup
settings SettingsLookup
audit Auditor
now func() time.Time
mu sync.Mutex
budget map[string][]time.Time // convID → 触发时刻
last map[string]time.Time // convID → 上次成功放行
}
// NewAuto 构造闸门(lookup 不得为 nil)。
func NewAuto(activation ActivationLookup, settings SettingsLookup, audit Auditor) *Auto {
return &Auto{
activation: activation,
settings: settings,
audit: audit,
now: func() time.Time { return time.Now().UTC() },
budget: map[string][]time.Time{},
last: map[string]time.Time{},
}
}
// Try 尝试放行一次自动建议。ok=false 时 skip 含原因(供测试);ok=true 时调用方应注入 AutoReminder。
func (a *Auto) Try(owner, convID, reason string) (ok bool, skip string) {
if a == nil || a.activation == nil || a.settings == nil {
return false, "unconfigured"
}
if owner == "" || convID == "" {
return false, "missing_ids"
}
if !a.settings(owner) {
return false, "opt_in_off"
}
if !a.activation(owner) {
return false, "activation_off"
}
now := a.now()
a.mu.Lock()
defer a.mu.Unlock()
if t, hit := a.last[convID]; hit && now.Sub(t) < AutoCooldown {
return false, "cooldown"
}
cutoff := now.Add(-time.Hour)
kept := a.budget[convID][:0]
for _, t := range a.budget[convID] {
if t.After(cutoff) {
kept = append(kept, t)
}
}
a.budget[convID] = kept
if len(kept) >= AutoBudgetPerHour {
if a.audit != nil {
a.audit(owner, "genui.auto_present_exhausted", "conversation="+convID+" reason="+reason)
}
return false, "budget"
}
a.budget[convID] = append(kept, now)
a.last[convID] = now
return true, ""
}
+53
View File
@@ -0,0 +1,53 @@
package genui
import (
"testing"
"time"
)
func TestAutoDefaultOff(t *testing.T) {
a := NewAuto(func(string) bool { return true }, func(string) bool { return false }, nil)
if ok, skip := a.Try("alice", "c1", "plan"); ok || skip != "opt_in_off" {
t.Fatalf("want opt_in_off, got ok=%v skip=%q", ok, skip)
}
}
func TestAutoRequiresActivation(t *testing.T) {
a := NewAuto(func(string) bool { return false }, func(string) bool { return true }, nil)
if ok, skip := a.Try("alice", "c1", "plan"); ok || skip != "activation_off" {
t.Fatalf("want activation_off, got ok=%v skip=%q", ok, skip)
}
}
func TestAutoBudgetAndCooldown(t *testing.T) {
var exhausted int
now := time.Unix(1_700_000_000, 0).UTC()
a := NewAuto(func(string) bool { return true }, func(string) bool { return true },
func(owner, kind, detail string) {
if kind == "genui.auto_present_exhausted" {
exhausted++
}
})
a.now = func() time.Time { return now }
for i := 0; i < AutoBudgetPerHour; i++ {
ok, skip := a.Try("alice", "c1", "plan")
if !ok {
t.Fatalf("shot %d should pass, skip=%q", i, skip)
}
now = now.Add(AutoCooldown + time.Second)
}
if ok, skip := a.Try("alice", "c1", "plan"); ok || skip != "budget" || exhausted != 1 {
t.Fatalf("want budget exhausted, ok=%v skip=%q exhausted=%d", ok, skip, exhausted)
}
// 冷却:同一时刻第二次拒绝
a2 := NewAuto(func(string) bool { return true }, func(string) bool { return true }, nil)
a2.now = func() time.Time { return now }
if ok, _ := a2.Try("alice", "c2", "art"); !ok {
t.Fatal("first should pass")
}
if ok, skip := a2.Try("alice", "c2", "art"); ok || skip != "cooldown" {
t.Fatalf("want cooldown, got ok=%v skip=%q", ok, skip)
}
}
+84
View File
@@ -0,0 +1,84 @@
package runmgr
import (
"agent-system-backend/internal/genui"
core "agent-system-core"
)
// SetGenUIAuto 注入自动建议闸门(组合根装配;nil 关闭)。
func (m *Manager) SetGenUIAuto(a GenUIAutoTrigger) { m.genuiAuto = a }
func genUIAutoReminderText() string { return genui.AutoReminder }
// maybeGenUIAuto:语义事件钩子入口。会话忙碌时只追加 system 消息供下一轮消费;
// 空闲时启动短 notice-run 让模型有机会立即 present。
func (m *Manager) maybeGenUIAuto(owner, convID, reason string) {
if m == nil || m.genuiAuto == nil {
return
}
ok, _ := m.genuiAuto.Try(owner, convID, reason)
if !ok {
return
}
go m.deliverGenUIAutoReminder(owner, convID, reason)
}
func (m *Manager) deliverGenUIAutoReminder(owner, convID, reason string) {
text := "<system-reminder>\n" + genUIAutoReminderText() + "\n(trigger=" + reason + ")\n</system-reminder>"
msg := core.Message{
ConversationID: convID, Role: core.RoleSystem,
Parts: []core.Part{{Type: core.PartText, Text: text}},
}
if err := m.st.AppendMessage(&msg); err != nil {
return
}
m.convMu.Lock()
busy := m.convActive[convID]
m.convMu.Unlock()
if busy {
return
}
conv, err := m.st.GetConversation(owner, convID)
if err != nil {
return
}
if err := m.claimConversation(convID); err != nil {
return
}
if _, err := m.startClaimedRun(conv, msg, nil, nil, nil); err != nil {
m.releaseConversation(convID)
}
}
func planStageChanged(prev, next core.PlanSnapshot) bool {
pm := map[string]string{}
for _, s := range prev.Steps {
pm[s.ID] = string(s.Status)
}
for _, s := range next.Steps {
if pm[s.ID] != string(s.Status) {
return true
}
}
// 新增步骤也视为阶段组织变化
if len(next.Steps) > len(prev.Steps) {
return true
}
return false
}
func todoStageChanged(prev, next core.TodoList) bool {
pm := map[string]string{}
for _, t := range prev.Items {
pm[t.ID] = string(t.Status)
}
for _, t := range next.Items {
if pm[t.ID] != string(t.Status) {
return true
}
}
if len(next.Items) > len(prev.Items) {
return true
}
return false
}
+27
View File
@@ -96,6 +96,15 @@ type Manager struct {
allowMu sync.Mutex
sessionAllow map[string]map[string]bool
// genuiAuto:Generative UI 自动建议闸门(nil = 未接线;默认关闭行为)。
// 仅由任务阶段/材料工件语义事件调用;present/surface/action 不得接入。
genuiAuto GenUIAutoTrigger
}
// GenUIAutoTrigger 自动建议页面闸门(由 genui.Auto 实现)。
type GenUIAutoTrigger interface {
Try(owner, convID, reason string) (ok bool, skip string)
}
const maxBackgroundChildren = 3
@@ -677,11 +686,15 @@ func (c *runCtx) Subhint() string { return c.subHint }
func (c *runCtx) Submodel() string { return c.subModel }
func (c *runCtx) UpdatePlan(p core.PlanSnapshot) error {
prev, _ := c.mgr.st.GetPlan(c.run.ConversationID)
p.PlanID = c.run.ConversationID
if err := c.mgr.st.SavePlan(c.run.ConversationID, p); err != nil {
return err
}
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvPlanUpdate, core.PlanUpdatePayload{Plan: p})
if err == nil && planStageChanged(prev, p) {
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "plan_stage")
}
return err
}
@@ -704,11 +717,15 @@ func (c *runCtx) Plan() (core.PlanSnapshot, bool) {
}
func (c *runCtx) UpdateTodos(l core.TodoList) error {
prev, _ := c.mgr.st.GetTodos(c.run.ConversationID)
l.ConversationID = c.run.ConversationID
if err := c.mgr.st.SaveTodos(c.run.ConversationID, l); err != nil {
return err
}
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvTodoUpdate, core.TodoUpdatePayload{List: l})
if err == nil && todoStageChanged(prev, l) {
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "todo_stage")
}
return err
}
@@ -832,6 +849,9 @@ func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Ar
if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvArtifactCreated, core.ArtifactPayload{Artifact: art}); err != nil {
return art, err
}
if kind == "document" {
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "artifact_document")
}
// 工件文本进检索索引。二进制 Office/PDF 先经 ingest 抽出纯文本,抽到才入库;
// 抽不到则跳过,避免把 ZIP 字节当文本索引。
if kind == "document" && len(content) < 200000 {
@@ -2151,6 +2171,13 @@ func (m *Manager) startNoticeRun(convID string, items []bgNotice) error {
}
if materialSeen {
b.WriteString("\n这批材料的系统整理已完成。处理方式:\n1) 先用不超过 5 行汇报整理结果要点(来自上面的摘要)。\n2) 然后立即调用 confirm_choice 工具发起交付物选择卡:问题如「基于整理结果,接下来生成什么?」;选项取整理结果中的「建议后续处理」(3-4 项,description 写一句材料依据,最贴合的一项设 recommended),允许自由输入。\n3) 用户答复后按工具结果指引,用 delegate_task 的 background=true 启动对应后台任务。\n不要重复派发材料整理任务。\n")
if m.genuiAuto != nil {
if ok, _ := m.genuiAuto.Try(owner, convID, "material_done"); ok {
b.WriteString("\n")
b.WriteString(genUIAutoReminderText())
b.WriteString("\n")
}
}
}
b.WriteString("</system-reminder>")
msg := core.Message{
+7 -1
View File
@@ -65,7 +65,12 @@ func SystemDefaults() map[string]any {
"accentColor": "", // 空 = 主题默认主色;#RRGGBB 或 RRGGBB
"templateArtifactId": "", // 非空 = 该工件作为默认模板,落到 inputs/_default-template*
},
"plugins": map[string]any{"dir": "./plugins"},
"plugins": map[string]any{
"dir": "./plugins",
"generative-ui": map[string]any{
"autoPresent": false, // 自动建议页面:默认关;需同时开启 Surface activation
},
},
}
}
@@ -126,6 +131,7 @@ func Schema() []core.SettingDesc {
{Key: "deliverables.accentColor", Group: "deliverables", Label: "交付物强调色", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "6 位十六进制,如 1B3A4B。空 = 使用样式自带主色"},
{Key: "deliverables.templateArtifactId", Group: "deliverables", Label: "默认模板工件 ID", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "填写已上传模板的工件 ID。每次运行会复制到会话 inputs/_default-template"},
{Key: "plugins.dir", Group: "plugins", Label: "插件目录", Type: core.SettingString, Default: "./plugins", Effect: core.EffectRestart},
{Key: "plugins.generative-ui.autoPresent", Group: "plugins", Label: "Generative UI 自动建议页面", Type: core.SettingBool, Default: false, Effect: core.EffectHot, Description: "开启后在任务阶段变化或材料/工件完成时建议调用 generative-ui.present(默认关)。须同时启用该插件的 Surface activation;有预算与冷却,不会因页面修订自激"},
}
}