diff --git a/internal/app/app.go b/internal/app/app.go index 5febb78..a1a1bb1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 diff --git a/internal/genui/auto.go b/internal/genui/auto.go new file mode 100644 index 0000000..81d9dcb --- /dev/null +++ b/internal/genui/auto.go @@ -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, "" +} diff --git a/internal/genui/auto_test.go b/internal/genui/auto_test.go new file mode 100644 index 0000000..f08f0f4 --- /dev/null +++ b/internal/genui/auto_test.go @@ -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) + } +} diff --git a/internal/runmgr/genui_auto.go b/internal/runmgr/genui_auto.go new file mode 100644 index 0000000..4de7367 --- /dev/null +++ b/internal/runmgr/genui_auto.go @@ -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 := "\n" + genUIAutoReminderText() + "\n(trigger=" + reason + ")\n" + 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 +} diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index fcdd23c..51ae71a 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -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("") msg := core.Message{ diff --git a/internal/settings/settings.go b/internal/settings/settings.go index 3fcd55d..8c839f3 100644 --- a/internal/settings/settings.go +++ b/internal/settings/settings.go @@ -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;有预算与冷却,不会因页面修订自激"}, } }