// Package handler 实现了面向 Wails 前端的中间件逻辑。 // Expert handler 专门处理 "搜索" 与 "AI 回答" 的请求。 package handler import ( "context" "fmt" "log" "strings" "sync" "AI-Expert-Sidebar/internal/database" "AI-Expert-Sidebar/internal/service" "github.com/wailsapp/wails/v2/pkg/runtime" ) // Expert 是负责搜索与处理大型 RAG 对话链路的控制器结构。 type Expert struct { ctx context.Context // stopMu: 读写锁,防止用户连续快速点击 "Stop" 导致多线程 nil pointer 异常。 stopMu sync.Mutex // stopCancel: 持有当前正在进行的 Context,可以在任意时刻中止发给 OpenAI 兼容后端的网络请求。 stopCancel context.CancelFunc } func NewExpert() *Expert { return &Expert{} } func (e *Expert) SetContext(ctx context.Context) { e.ctx = ctx } // SearchExpert 是核心的关键词输入搜索,它在活跃的本地 SQLite 库中采用 OR LIKE 的朴素算法进行查询。 // 若无结果直接返回空数组,并不引起任何异常。 // 这里的空结果触发降级推荐,是由 service 层自行封装处理后抛出来的(带有 IsFallback=true)。 func (e *Expert) SearchExpert(query string) []service.SearchResult { results, err := service.SearchKnowledge(query) if err != nil { log.Printf("[SearchExpert] %v", err) return nil } return results } // AskDeepSeek 是系统最核心的 RAG 流式问答接口。 // 它调用了 server-sent events (SSE) 进行块级读取请求。 // // 工作流设计: // 1. 将 query 丢进 buildKnowledgeContext 中提取上下文参考(这保证即使用户点击“直接提问”,AI也能带入相关的环境信息)。 // 2. ResolveAIConfig 会去 settings.db 读取用户的 API 密钥。如果他填了个空密钥,就会走全局兜底的公钥(Config.yaml 里那个)。 // 3. 开启协程发 HTTP 请求,主路在此执行 block 循环:一收到一个 channel 片段就利用 runtime.EventsEmit() 通知前端更新,达到"打字机动画"的感觉。 // 4. __STOPPED__ 与 __ERROR__ 是特殊的哨兵符号,用于控制前端何时该取消动画或者该降级展示。 func (e *Expert) AskDeepSeek(query, rawAnswer string) string { aiCfg := service.ResolveAIConfig() knowledgeCtx := e.buildKnowledgeContext(query) var userMsg string if rawAnswer != "" { // 如果有了答案(代表由点击搜索结果的"AI润色"触发),原句当作参考材料传入。 userMsg = fmt.Sprintf("用户问题:%s\n\n原始参考答案:%s", query, rawAnswer) } else { userMsg = fmt.Sprintf("用户问题:%s\n\n请直接回答上述问题。", query) } messages := service.BuildRAGMessages(knowledgeCtx, userMsg, aiCfg.SystemPrompt) streamCh := make(chan string, 64) var sb strings.Builder // 创建可取消的上下文,用于停止生成 streamCtx, cancel := context.WithCancel(e.ctx) e.setStopCancel(cancel) // 将这个 cancel 函数放进结构体中 // 开一个 goroutine 去跑耗时的 HTTP 请求。 go func() { // 结束后负责释放资源并关闭 channel 发送。 defer func() { cancel(); close(streamCh) }() if err := service.CallDeepSeekStream(streamCtx, aiCfg, messages, streamCh); err != nil { // 如果因为主动调用了 StopGeneration(手动 cancel),将进入 Canceled 判断 if streamCtx.Err() == context.Canceled { streamCh <- "__STOPPED__" } else { log.Printf("[AskDeepSeek] %v", err) streamCh <- "__ERROR__" } } }() // 主控:消费 channel 内的字符直到通道关闭返回 for chunk := range streamCh { switch chunk { case "__ERROR__": // 如果调用抛错(可能是没钱了也可以是超时网络波动),使用 fallback 向前端传递异常发生。 runtime.EventsEmit(e.ctx, "ai:fallback", rawAnswer) return rawAnswer case "__STOPPED__": runtime.EventsEmit(e.ctx, "ai:done", sb.String()) return sb.String() default: sb.WriteString(chunk) // 先存全量方便后续持久化 runtime.EventsEmit(e.ctx, "ai:chunk", chunk) // 把字往前端丢 } } // 到头了(或者服务端关连接了),抛送 done 信号表示可以关闭等待动画 runtime.EventsEmit(e.ctx, "ai:done", sb.String()) return sb.String() } // StopGeneration 安全中止生成的流程(并发安全)。 // 它的实现是直接取消底层的 HTTP Context,让 Read 立即退出不走阻塞。 func (e *Expert) StopGeneration() { e.stopMu.Lock() defer e.stopMu.Unlock() if e.stopCancel != nil { e.stopCancel() e.stopCancel = nil } } // GetDBStatus 返回当前 db 是否 ready(初始化完毕并且至少打开了一个库)。 func (e *Expert) GetDBStatus() bool { return database.IsReady() } // ToggleTopmost 在 macOS / Windows 上能直接使软件置顶 // 这保证用户在别的屏幕操作发帖或复制消息的时候 Sidebar 依然不被埋没。 func (e *Expert) ToggleTopmost(enabled bool) { runtime.WindowSetAlwaysOnTop(e.ctx, enabled) } // buildKnowledgeContext 是一个辅助函数:提取最近 3 条相关查询用于丰富 System Prompt。 func (e *Expert) buildKnowledgeContext(query string) string { results, err := service.SearchKnowledge(query) if err != nil || len(results) == 0 { return "(无相关本地知识)" } limit := 3 if len(results) < limit { limit = len(results) } var sb strings.Builder for i := 0; i < limit; i++ { r := results[i] sb.WriteString(fmt.Sprintf("%d. Q: %s\n A: %s\n 分类: %s\n", i+1, r.Question, r.Answer, r.Category)) } return sb.String() } // setStopCancel 加锁写入 cancel。 func (e *Expert) setStopCancel(fn context.CancelFunc) { e.stopMu.Lock() defer e.stopMu.Unlock() if e.stopCancel != nil { e.stopCancel() // 确保上一次的上下文被干掉防止泄露 } e.stopCancel = fn }