批次A admin 端点(原本 404 变 200 真实数据):
- GET /admin/llm:读 s.cfg.LLM 组 openai+anthropic 两 provider(enabled=当前 Provider、multimodal=配 VisionModel),APIKey 不进 wire 零泄露。
- GET /admin/automations:*automations.Service.List()(reminders+event rules+cron schedules,cron 加只读登记)。
- GET /admin/users:cfg.AdminUser + *auth.APIKey.List()(密钥掩码);无用户存储,恒先返回当前 admin 账号。
- GET /admin/backthink:*backthink.Backthink.List(ctx,50)。均只读、owner/admin RBAC、{data} 信封。
批次B 视觉真推理 + 语音:
- rest.go chatRequest 加 Image?;流式 publishChatStream 带 image;userChatMessages 构造 ImageMessage(model.go:71)。
- loop/vision.go:ImageResolver 可注入(MVP 先看图再对话);run.go Run 开头 resolveImages,无 resolver 透传直交主模型。
- vision/resolver.go:MessageResolver 调 vision.Router.Route——KindMain 保留图片交主模型多模态、KindVisionTool/OCRTool 经 dispatcher.Invoke 折叠成 [图片内容] 文本、KindUnsupported 记日志保留原图不崩。
- gateway/voice.go:VoiceService 接口 + SetVoice 注入点 + handleVoiceInput(未注入回 error 不崩)+ runVoiceTurn 异步 STT→loop chat→TTS→定向 voice_output 帧。
- ws_hub/ws_route:voice_input 接 handleVoiceInput;前端 ws.ts decodeBase64/playVoiceAudio/case voice_output。
第3步 wire.go 收口(唯一碰 wire.go 的一步):
- Deps 补传 Automations:a.automation / Backthink:a.backthink / Users:auth.NewAPIKey(a.cfg.Auth)(*auth.APIKey 原生满足 UserStore,避免 admin 重复的复合类型)。
- gw.SetVoice(a.voice);loop.Options.Vision = vision.NewMessageResolver(router, info, dispatcher.Invoke)(router=NewRouter(WithVisionTool/WithOCRTool)、info=cfg.LLM.VisionModel)。
实机验证:四 admin 端点 200 真实数据(llm 双 provider 脱敏、users=admin,yeij0942,owner);WS 握手正常。go build/vet/test 全绿 + 前端 tsc。wire.go 557 行。
191 lines
7.5 KiB
Go
191 lines
7.5 KiB
Go
// Package automations implements the Cyrene v2 automation engine and reminder
|
|
// scheduler (DevDocs/design/04-运行时.md §8.5). It is the "how she does things on
|
|
// her own" layer: schedules and event rules fire a named action, the action runs
|
|
// (either a plain function, a registered tool, or a prompt through the AI
|
|
// loop/agent pipeline), and every outcome is published back to the bus so upper
|
|
// layers can react or push notifications.
|
|
//
|
|
// The package is split into five files:
|
|
//
|
|
// - automations.go — shared types: Config, Deps, the Pipeline abstraction, the
|
|
// published Result and the sentinel errors.
|
|
// - scheduler.go — the cron-driven Scheduler (robfig/cron/v3).
|
|
// - rule.go — the Rule model and the event-driven Engine.
|
|
// - action.go — the Action interface plus func/tool/AI implementations.
|
|
// - reminder.go — the time-based ReminderStore.
|
|
// - service.go — the Service that composes the three for lifecycle control.
|
|
//
|
|
// Execution model: every trigger (cron fire, event match, reminder due) resolves
|
|
// to the same runAction, which executes the Action under an optional deadline and
|
|
// publishes a Result to the "automation.result" subject. This keeps reporting and
|
|
// logging consistent across all trigger kinds (DevDocs/design/04-运行时.md §8.5:
|
|
// task output returns to the message queue/agent).
|
|
//
|
|
// Identity and capability boundaries (Devedocs global red lines): automation
|
|
// never decides who is allowed to do anything. It only fires named actions;
|
|
// authorization stays in the channel/policy layer.
|
|
package automations
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
)
|
|
|
|
// Bus subjects published by the automations engine. Upper layers subscribe to
|
|
// these to observe outcomes or push notifications.
|
|
const (
|
|
// SubjectResult is the subject every executed automation publishes its
|
|
// Result to.
|
|
SubjectResult = "automation.result"
|
|
// SubjectTrigger is the subject a due Reminder is published to. Upper layers
|
|
// subscribe to it to surface the reminder to the user.
|
|
SubjectTrigger = "reminder.trigger"
|
|
)
|
|
|
|
// Sentinel errors returned by this package. Use errors.Is to test for them.
|
|
var (
|
|
// ErrNilBus is returned when a constructor receives a nil bus.
|
|
ErrNilBus = errors.New("automations: nil bus")
|
|
// ErrNilAction is returned when an action is nil or unnamed.
|
|
ErrNilAction = errors.New("automations: nil action")
|
|
// ErrInvalidRule is returned when a rule is malformed.
|
|
ErrInvalidRule = errors.New("automations: invalid rule")
|
|
// ErrInvalidCron is returned when a cron expression fails to parse.
|
|
ErrInvalidCron = errors.New("automations: invalid cron expression")
|
|
// ErrNotFound is returned when a rule or reminder is missing.
|
|
ErrNotFound = errors.New("automations: not found")
|
|
// ErrDuplicate is returned when a rule or reminder id is already registered.
|
|
ErrDuplicate = errors.New("automations: already exists")
|
|
// ErrNoPipeline is returned when an AI action is built without a pipeline.
|
|
ErrNoPipeline = errors.New("automations: no AI pipeline configured")
|
|
)
|
|
|
|
// Config configures the automations engine.
|
|
type Config struct {
|
|
// Location is the timezone used for cron schedules and reminder firing. Nil
|
|
// uses time.Local.
|
|
Location *time.Location
|
|
// WithSeconds enables 6-field cron specs (with a leading seconds field) in
|
|
// addition to the standard 5-field format.
|
|
WithSeconds bool
|
|
// ActionTimeout bounds a single action execution. Non-positive disables the
|
|
// deadline.
|
|
ActionTimeout time.Duration
|
|
// ReminderInterval is how often the reminder loop polls for due reminders.
|
|
// Non-positive selects defaultReminderInterval.
|
|
ReminderInterval time.Duration
|
|
}
|
|
|
|
// Deps is the injected dependency set for the automations engine.
|
|
type Deps struct {
|
|
// Bus is the in-process event bus. Required.
|
|
Bus bus.Bus
|
|
// Loop is the AI pipeline (the agent loop). When set, an AI action runs
|
|
// through it. Agent is used only when Loop is nil.
|
|
Loop *loop.Loop
|
|
// Agent is the single-step agent, used as the AI pipeline when Loop is nil.
|
|
Agent *agent.Agent
|
|
// Log is the base logger. A nil logger is replaced with a discard logger
|
|
// tagged with source "services/automations".
|
|
Log *slog.Logger
|
|
// Now supplies the current time for reminders. Nil uses time.Now.
|
|
Now func() time.Time
|
|
}
|
|
|
|
// Pipeline is the AI-execution surface an automation action runs through. It is
|
|
// satisfied by *loop.Loop directly, so a real pipeline can be injected without
|
|
// an adapter. Tests inject a fake to observe the prompt and return canned text.
|
|
type Pipeline interface {
|
|
Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error)
|
|
}
|
|
|
|
// AgentPipeline adapts an *agent.Agent to Pipeline by running a single step and
|
|
// returning its text as the result. It is used when only an agent (not a full
|
|
// loop) is injected.
|
|
type AgentPipeline struct {
|
|
Agent *agent.Agent
|
|
}
|
|
|
|
// Run runs one agent step and returns its text as the loop-style Result.
|
|
func (a *AgentPipeline) Run(ctx context.Context, sessionID string, messages []model.Message) (*loop.Result, error) {
|
|
if a.Agent == nil {
|
|
return nil, fmt.Errorf("automations: agent pipeline: %w", ErrNoPipeline)
|
|
}
|
|
sr, err := a.Agent.RunStep(ctx, sessionID, messages)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("automations: agent pipeline run: %w", err)
|
|
}
|
|
return &loop.Result{Text: sr.Text, StopReason: sr.StopReason, Steps: 1}, nil
|
|
}
|
|
|
|
// pipeline returns the resolved AI pipeline from deps, or nil when neither a
|
|
// loop nor an agent is configured.
|
|
func (d Deps) pipeline() Pipeline {
|
|
if d.Loop != nil {
|
|
return d.Loop
|
|
}
|
|
if d.Agent != nil {
|
|
return &AgentPipeline{Agent: d.Agent}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// AutomationRecord is the read-only summary of one automation or reminder,
|
|
// returned by Service.List for the admin panel. Kind is "cron", "event" or
|
|
// "reminder"; Cron carries the cron expression for the cron kind; LastRun and
|
|
// NextRun are zero times when the value is unknown (typically before Start).
|
|
type AutomationRecord struct {
|
|
ID string
|
|
Name string
|
|
Kind string
|
|
Cron string
|
|
Enabled bool
|
|
LastRun time.Time
|
|
NextRun time.Time
|
|
}
|
|
|
|
// Result is the structured outcome of an executed automation, published to
|
|
// SubjectResult. Upper layers subscribe to it to react or push notifications.
|
|
type Result struct {
|
|
// Kind classifies the trigger: "schedule", "event" or "reminder".
|
|
Kind string `json:"kind"`
|
|
// RuleID identifies the schedule/rule/reminder that fired.
|
|
RuleID string `json:"rule_id"`
|
|
// Action is the action name that ran.
|
|
Action string `json:"action"`
|
|
// Payload is the trigger payload (nil for a cron fire).
|
|
Payload any `json:"payload"`
|
|
// Output is the action's returned value, or nil on error.
|
|
Output any `json:"output"`
|
|
// Error carries the action's error message when the run failed.
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// sourcedLog returns a base logger tagged with the module source
|
|
// "services/automations", replacing a nil logger with a discard one so callers
|
|
// can always log safely.
|
|
func sourcedLog(l *slog.Logger) *slog.Logger {
|
|
if l == nil {
|
|
l = slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
return logger.WithSource(l, "services/automations")
|
|
}
|
|
|
|
// resolveNow returns f when non-nil, otherwise time.Now.
|
|
func resolveNow(f func() time.Time) func() time.Time {
|
|
if f == nil {
|
|
return time.Now
|
|
}
|
|
return f
|
|
}
|