// Package agent 是应用的业务核心:组织消息、控制抓屏监听与调用大模型。 package agent import ( "context" "errors" "fmt" "log/slog" "strings" "sync" "time" "viewsagent/internal/capture" "viewsagent/internal/config" "viewsagent/internal/llm" "viewsagent/internal/vision" ) // autoPrompt 是自动触发时附加的指令,避免模型对着同一道题反复作答。 const autoPrompt = "画面已更新。请针对当前画面中最新出现的内容作答,不要重复回答之前已经答过的题目;如果没有新内容,只回复「(无新内容)」。" // ErrBusy 表示上一条回答尚未结束。 var ErrBusy = errors.New("上一条回答还没结束,请稍候") // State 是引擎对外暴露的运行时快照。 type State struct { Watching bool Busy bool Triggers int TotalTokens int LastLatency time.Duration Region capture.Region Model string // 生效接入点里的模型名 Profile string // 生效接入点的名字 Format string // 生效接入点的接口格式 } // Sink 是引擎向界面推送事件的回调集合。 // 所有回调都可能来自任意 goroutine,实现方必须自行切换到 UI 线程。 type Sink interface { OnUserMessage(text string, withImage bool) OnAssistantStart(auto bool) OnAssistantDelta(delta llm.Delta) OnAssistantEnd(usage llm.Usage, err error) OnState(state State) } // Engine 把抓屏、意图与模型调用串起来。 type Engine struct { mu sync.Mutex cfg *config.Config store *config.Store screen capture.Screen provider llm.Provider sink Sink region capture.Region watching bool stopWatch chan struct{} busy bool triggers int lastLatency time.Duration usage llm.Usage // history 只保存纯文本,不保留历史图片,避免图片重复计费与内存膨胀。 history []llm.Message } // New 创建引擎。 func New(cfg *config.Config, store *config.Store) *Engine { e := &Engine{ cfg: cfg, store: store, region: cfg.Region, } if active, ok := cfg.ActiveModel(); ok { if prov, err := llm.New(toOptions(active)); err != nil { slog.Warn("模型不可用", "err", err) } else { e.provider = prov } } return e } func toOptions(p config.Profile) llm.Options { return llm.Options{ Name: p.Name, Format: p.Format, BaseURL: p.BaseURL, APIKey: p.APIKey, Model: p.Model, ExtraHeaders: p.ExtraHeaders, MaxTokens: p.MaxTokens, Temperature: p.Temperature, } } // SetSink 绑定事件接收方。 func (e *Engine) SetSink(s Sink) { e.mu.Lock() e.sink = s e.mu.Unlock() e.emitState() } // Config 返回当前配置。 func (e *Engine) Config() *config.Config { e.mu.Lock() defer e.mu.Unlock() return e.cfg } // Store 返回配置存储。 func (e *Engine) Store() *config.Store { return e.store } // State 返回当前状态快照。 func (e *Engine) State() State { e.mu.Lock() defer e.mu.Unlock() return e.stateLocked() } func (e *Engine) stateLocked() State { active := config.Profile{} if e.cfg != nil { active, _ = e.cfg.ActiveModel() } return State{ Watching: e.watching, Busy: e.busy, Triggers: e.triggers, TotalTokens: e.usage.TotalTokens, LastLatency: e.lastLatency, Region: e.region, Model: active.Model, Profile: active.Name, Format: active.Format, } } func (e *Engine) emitState() { e.mu.Lock() sink := e.sink st := e.stateLocked() e.mu.Unlock() if sink != nil { sink.OnState(st) } } // Region 返回当前监视区域。 func (e *Engine) Region() capture.Region { e.mu.Lock() defer e.mu.Unlock() return e.region } // SetRegion 更新监视区域并持久化。 func (e *Engine) SetRegion(r capture.Region) error { e.mu.Lock() e.region = r e.cfg.Region = r cfg := e.cfg e.mu.Unlock() e.emitState() if e.store != nil { if err := e.store.Save(cfg); err != nil { return fmt.Errorf("保存区域失败: %w", err) } } return nil } // ApplyConfig 在设置页保存后刷新引擎内部状态。 // 接入点还没填完整时不算失败:配置照样生效,等用户补齐后自动恢复。 func (e *Engine) ApplyConfig(cfg *config.Config) error { if cfg == nil { return errors.New("配置为空") } e.mu.Lock() wasWatching := e.watching // 区域只由框选修改,不被设置页的旧副本覆盖。 cfg.Region = e.region e.cfg = cfg e.mu.Unlock() var prov llm.Provider if active, ok := cfg.ActiveModel(); ok { p, err := llm.New(toOptions(active)) if err != nil { slog.Warn("模型初始化失败", "err", err) } else { prov = p } } e.mu.Lock() e.provider = prov e.mu.Unlock() if wasWatching { e.StopWatch() if err := e.StartWatch(); err != nil { slog.Warn("配置变更后恢复监听失败", "err", err) } } e.emitState() return nil } // PingModel 用给定的模型配置做一次连通性测试。 func (e *Engine) PingModel(ctx context.Context, p config.Profile) error { prov, err := llm.New(toOptions(p)) if err != nil { return err } return prov.Ping(ctx) } // PingActive 用当前生效的接入点做一次连通性测试。 func (e *Engine) PingActive(ctx context.Context) error { e.mu.Lock() p, _ := e.cfg.ActiveModel() e.mu.Unlock() return e.PingModel(ctx, p) } // EnsureReady 检查模型是否已经接好,供监听前预检。 func (e *Engine) EnsureReady() error { e.mu.Lock() m, ok := e.cfg.ActiveModel() ready := e.provider != nil e.mu.Unlock() if !ok { return errors.New("还没有接入模型,请到设置里添加接入点并填写 BaseURL 与模型名") } if m.BaseURL == "" || m.Model == "" { return errors.New("还没有接入模型,请到设置里填写 BaseURL 与模型名") } if m.APIKey == "" && !isLocalEndpoint(m.BaseURL) { return errors.New("还没有接入模型,请到设置里填写 API Key") } if !ready { return errors.New("模型初始化失败,请到设置里检查 BaseURL 与模型名") } return nil } // isLocalEndpoint 判断是不是本机服务,本机服务通常不需要 API Key。 func isLocalEndpoint(baseURL string) bool { return strings.Contains(baseURL, "localhost") || strings.Contains(baseURL, "127.0.0.1") || strings.Contains(baseURL, "[::1]") } // Snapshot 抓取当前区域的 JPEG 预览(用于界面缩略图)。 func (e *Engine) Snapshot() ([]byte, error) { e.mu.Lock() region := e.region capCfg := e.cfg.Capture e.mu.Unlock() if !region.Valid() { return nil, fmt.Errorf("还没有框选监视区域") } img, err := e.screen.Grab(region) if err != nil { return nil, err } return vision.EncodeJPEG(img, capCfg.MaxEdge, capCfg.JPEGQuality) } // Ask 发起一次手动提问。attach 为 true 时附带当前区域截图。 func (e *Engine) Ask(text string, attach bool) error { text = strings.TrimSpace(text) if text == "" { return errors.New("请输入内容") } return e.run(text, attach, false) } // StartWatch 开始按帧差自动监听。 func (e *Engine) StartWatch() error { if err := e.EnsureReady(); err != nil { return err } e.mu.Lock() if e.watching { e.mu.Unlock() return nil } if !e.region.Valid() { e.mu.Unlock() return errors.New("请先框选需要监视的屏幕区域") } stop := make(chan struct{}) e.watching = true e.stopWatch = stop e.mu.Unlock() go e.watchLoop(stop) e.emitState() return nil } // StopWatch 停止自动监听。 func (e *Engine) StopWatch() { e.mu.Lock() if !e.watching { e.mu.Unlock() return } e.watching = false if e.stopWatch != nil { close(e.stopWatch) e.stopWatch = nil } e.mu.Unlock() e.emitState() } // watchLoop 以低频抓屏 + 帧差判断是否需要提问。 func (e *Engine) watchLoop(stop chan struct{}) { e.mu.Lock() capCfg := e.cfg.Capture e.mu.Unlock() fps := capCfg.FPS if fps < 0.5 { fps = 0.5 } interval := time.Duration(float64(time.Second) / fps) ticker := time.NewTicker(interval) defer ticker.Stop() var last vision.Fingerprint var haveLast bool var candidate bool var stable int var lastFire time.Time slog.Info("开始监听区域", "interval", interval, "threshold", capCfg.DiffThreshold) for { select { case <-stop: slog.Info("停止监听") return case <-ticker.C: } e.mu.Lock() region := e.region e.mu.Unlock() if !region.Valid() { continue } img, err := e.screen.Grab(region) if err != nil { slog.Debug("抓屏失败", "err", err) continue } fp := vision.Of(img) if !haveLast { last = fp haveLast = true continue } if fp.Changed(last, capCfg.DiffThreshold, capCfg.DiffSensitivity) { candidate = true stable = 0 last = fp continue } last = fp if !candidate { continue } // 画面还在变化时先等它稳定,避免截到翻页/动画的中间帧。 stable++ if stable < capCfg.StableFrames { continue } candidate = false stable = 0 if time.Since(lastFire) < time.Duration(capCfg.CooldownMs)*time.Millisecond { continue } if e.isBusy() { continue } lastFire = time.Now() e.mu.Lock() e.triggers++ e.mu.Unlock() if err := e.run(autoPrompt, true, true); err != nil && !errors.Is(err, ErrBusy) { slog.Warn("自动提问失败", "err", err) } e.emitState() } } func (e *Engine) isBusy() bool { e.mu.Lock() defer e.mu.Unlock() return e.busy } // run 执行一次完整问答。 func (e *Engine) run(text string, attach, auto bool) error { e.mu.Lock() if e.busy { e.mu.Unlock() return ErrBusy } if e.provider == nil { e.mu.Unlock() return errors.New("还没有接入模型,请到设置里填写") } e.busy = true prov := e.provider region := e.region capCfg := e.cfg.Capture sink := e.sink system := e.cfg.Template(e.cfg.PromptTemplate).System history := append([]llm.Message(nil), e.history...) e.mu.Unlock() // 立刻广播一次,让界面在请求发出前就显示"正在回答"并禁用发送。 e.emitState() defer func() { e.mu.Lock() e.busy = false e.mu.Unlock() e.emitState() }() var img []byte if attach && region.Valid() { shot, err := e.screen.Grab(region) if err != nil { slog.Warn("抓取监视区域失败", "err", err) } else if raw, err := vision.EncodeJPEG(shot, capCfg.MaxEdge, capCfg.JPEGQuality); err != nil { slog.Warn("图像编码失败", "err", err) } else { img = raw } } if sink != nil { if !auto { sink.OnUserMessage(text, len(img) > 0) } sink.OnAssistantStart(auto) } msgs := make([]llm.Message, 0, len(history)+1) msgs = append(msgs, history...) msgs = append(msgs, llm.Message{Role: llm.RoleUser, Text: text, Image: img}) ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() var reply strings.Builder start := time.Now() usage, err := prov.Chat(ctx, system, msgs, func(delta llm.Delta) error { // 思考过程只往界面送,不进历史:历史里留正文就够了。 if delta.Text != "" { reply.WriteString(delta.Text) } if sink != nil { sink.OnAssistantDelta(delta) } return nil }) elapsed := time.Since(start) e.mu.Lock() e.usage.Add(usage) e.lastLatency = elapsed if err == nil { e.appendHistoryLocked(text, len(img) > 0, reply.String()) } e.mu.Unlock() if sink != nil { sink.OnAssistantEnd(usage, err) } if err != nil { return fmt.Errorf("调用模型失败: %w", err) } slog.Info("回答完成", "elapsed", elapsed, "tokens", usage.TotalTokens) return nil } // appendHistoryLocked 把一轮问答写入历史,图片只留占位文本。 func (e *Engine) appendHistoryLocked(question string, withImage bool, answer string) { if withImage { question += "\n[附带截图]" } e.history = append(e.history, llm.Message{Role: llm.RoleUser, Text: question}, llm.Message{Role: llm.RoleAssistant, Text: answer}, ) limit := e.cfg.Capture.HistoryTurns * 2 if limit < 2 { limit = 2 } if len(e.history) > limit { e.history = append([]llm.Message(nil), e.history[len(e.history)-limit:]...) } } // ResetHistory 清空对话上下文。 func (e *Engine) ResetHistory() { e.mu.Lock() e.history = nil e.mu.Unlock() } // Close 释放资源。 func (e *Engine) Close() { e.StopWatch() }