feat: 添加注释

This commit is contained in:
Blizzard
2026-04-01 15:29:35 +08:00
parent aef2e152dc
commit 6162c9110c
28 changed files with 1293 additions and 298 deletions
+40 -10
View File
@@ -1,3 +1,5 @@
// Package handler 实现了面向 Wails 前端的中间件逻辑。
// Expert handler 专门处理 "搜索" 与 "AI 回答" 的请求。
package handler
import (
@@ -13,17 +15,21 @@ import (
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// Expert handles search and AI streaming for the active library.
// Expert 是负责搜索与处理大型 RAG 对话链路的控制器结构。
type Expert struct {
ctx context.Context
stopMu sync.Mutex
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 fuzzy-searches the active knowledge library.
// SearchExpert 是核心的关键词输入搜索,它在活跃的本地 SQLite 库中采用 OR LIKE 的朴素算法进行查询。
// 若无结果直接返回空数组,并不引起任何异常。
// 这里的空结果触发降级推荐,是由 service 层自行封装处理后抛出来的(带有 IsFallback=true)。
func (e *Expert) SearchExpert(query string) []service.SearchResult {
results, err := service.SearchKnowledge(query)
if err != nil {
@@ -33,28 +39,41 @@ func (e *Expert) SearchExpert(query string) []service.SearchResult {
return results
}
// AskDeepSeek performs RAG + streaming AI call.
// 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)
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)
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 {
@@ -64,23 +83,28 @@ func (e *Expert) AskDeepSeek(query, rawAnswer string) string {
}
}()
// 主控:消费 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)
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()
@@ -90,11 +114,16 @@ func (e *Expert) StopGeneration() {
}
}
// 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 {
@@ -112,11 +141,12 @@ func (e *Expert) buildKnowledgeContext(query string) string {
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() // 确保上一次的上下文被干掉防止泄露
}
e.stopCancel = fn
}
+41
View File
@@ -0,0 +1,41 @@
// Package handler 中的 KnowledgeOps 是专门用于单条删除与数据库整体清空的业务处理入口。
// 为何我们要把它和 `LibraryHandler` 拆开?
// 因为 `LibraryHandler` 管的是**外部库的结构**(例如建库、换库、改设置)。
// 而 `KnowledgeOps` 关注的是**目前活跃库内的具体行内容**的管理,职责隔离可以降低每个文件的行数和混淆度。
package handler
import (
"context"
"AI-Expert-Sidebar/internal/service"
)
// KnowledgeOps 提供删除单个/多个记录以及清空所有记录的 Wails 绑定方法。
type KnowledgeOps struct{ ctx context.Context }
func NewKnowledgeOps() *KnowledgeOps { return &KnowledgeOps{} }
// SetContext 自动注入 ctx,供后续可能弹框等需上下文的系统调用预留。
func (k *KnowledgeOps) SetContext(ctx context.Context) { k.ctx = ctx }
// DeleteItems 根据提供的 ID 切片去批量移除当前活跃库内对应的记录(物理删除)。
//
// 使用批量(`ids []uint`)而不是单条 `Delete(id uint)` 防止在全选状态下向后端发射几十上百次 RPC 请求。
// 后端批量生成 `DELETE FROM ... WHERE ID in (...)` 使耗时极大减少。
// 错误结果若有,即转为 String 报错,无错误返回空(用于在 JS 做 Promise 级 error catch)。
func (k *KnowledgeOps) DeleteItems(ids []uint) string {
if err := service.DeleteItems(ids); err != nil {
return err.Error()
}
return ""
}
// ClearDatabase 用于快速将当前工作库恢复空状态。
// 它并不会摧毁这个库在设置 `settings.db` 中的记录(库还没死),但库里面所有的数据会瞬间化整为零。
// 这个功能非常适合测试批量导入文件之前快速清空旧脏数据的手动调整需求。
func (k *KnowledgeOps) ClearDatabase() string {
if err := service.ClearDatabase(); err != nil {
return err.Error()
}
return ""
}
+53 -11
View File
@@ -1,3 +1,11 @@
// Package handler 将 service 层的能力暴露为 Wails 绑定方法。
//
// Handler 层的职责非常单一:
// 1. 调用 Wails runtime API(文件对话框、事件等);
// 2. 将 service 返回值转换为前端友好的格式(string 错误消息等);
// 3. 不包含任何业务逻辑,所有逻辑都在 service 层。
//
// 此文件专门处理"知识库 CRUD + 文件导入"的 Wails 绑定。
package handler
import (
@@ -9,13 +17,15 @@ import (
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// LibraryHandler exposes library management and CSV import via Wails bindings.
// LibraryHandler 是知识库管理功能的 Wails 绑定集合。
// ctx 在 startup 时由 Wails 注入,用于调用 runtime API(如文件对话框)。
type LibraryHandler struct{ ctx context.Context }
func NewLibraryHandler() *LibraryHandler { return &LibraryHandler{} }
func (h *LibraryHandler) SetContext(ctx context.Context) { h.ctx = ctx }
// ListLibraries returns all registered knowledge libraries.
// ListLibraries 返回所有已注册知识库的列表,含实时条目数和"是否活跃"标志。
// is_active 由当前活跃库名称与各库名称对比判断(在 handler 层计算,service 层不感知"活跃"概念)。
func (h *LibraryHandler) ListLibraries() []LibraryInfo {
libs, err := service.ListLibraries()
if err != nil {
@@ -25,32 +35,38 @@ func (h *LibraryHandler) ListLibraries() []LibraryInfo {
for i, l := range libs {
out[i] = LibraryInfo{
ID: l.ID, Name: l.Name, Description: l.Description,
EntryCount: l.EntryCount, IsActive: l.Name == database.GetActiveLibName(),
EntryCount: l.EntryCount,
IsActive: l.Name == database.GetActiveLibName(),
}
}
return out
}
// GetActiveLibrary returns the name of the currently active library.
// GetActiveLibrary 返回当前活跃知识库的名称,用于前端 LibraryBar 标题显示。
func (h *LibraryHandler) GetActiveLibrary() string {
return database.GetActiveLibName()
}
// CreateLibrary registers a new knowledge library.
// CreateLibrary 创建新知识库,并自动切换到它。
//
// 创建后立即切换的原因:用户刚创建的库通常就是下一步要操作的目标,
// 省去一次额外的"切换"操作。
// 返回空字符串表示成功,否则返回中文错误信息供前端 Toast 显示。
func (h *LibraryHandler) CreateLibrary(name, description string) string {
if name == "" {
return "名称不能为空"
return "知识库名称不能为空"
}
lib, err := service.CreateLibrary(name, description)
if err != nil {
return err.Error()
}
// Auto-switch to newly created library
// 忽略切换错误(文件刚创建,极少失败),用户可手动重新切换
service.SwitchLibrary(lib.Name) //nolint
return ""
}
// SwitchLibrary makes the named library active.
// SwitchLibrary 将指定名称的知识库激活为当前工作库。
// 返回空字符串表示成功,否则返回错误信息。
func (h *LibraryHandler) SwitchLibrary(name string) string {
if err := service.SwitchLibrary(name); err != nil {
return err.Error()
@@ -58,7 +74,10 @@ func (h *LibraryHandler) SwitchLibrary(name string) string {
return ""
}
// DeleteLibrary removes a library from the registry (file is kept).
// DeleteLibrary 从注册表中移除知识库(不删除 .db 文件)。
//
// 在删除前强制检查:不能删除当前正在使用的库,
// 因为删除后活跃连接会变成悬空引用,后续写入会 panic。
func (h *LibraryHandler) DeleteLibrary(name string) string {
if name == database.GetActiveLibName() {
return "不能删除当前使用中的知识库,请先切换到其他库"
@@ -69,7 +88,13 @@ func (h *LibraryHandler) DeleteLibrary(name string) string {
return ""
}
// ImportCSV opens a native file dialog then imports CSV data into the active library.
// ImportCSV 调起系统原生文件选择对话框,让用户选取 CSV 文件后导入。
//
// 使用 Wails runtime.OpenFileDialog 而非让前端传入路径的原因:
// 1. 安全性:前端(WebView)无法直接访问本地文件系统,
// 必须通过 Wails 桥接调用原生对话框;
// 2. 体验:原生对话框支持文件类型过滤(*.csv),
// 比任何 HTML <input type="file"> 都更流畅。
func (h *LibraryHandler) ImportCSV() service.ImportResult {
filePath, err := runtime.OpenFileDialog(h.ctx, runtime.OpenDialogOptions{
Title: "选择 CSV 文件",
@@ -84,7 +109,24 @@ func (h *LibraryHandler) ImportCSV() service.ImportResult {
return service.ImportCSV(filePath)
}
// LibraryInfo is the frontend-facing representation of a library.
// ImportExcel 调起原生文件对话框,让用户选取 .xlsx 文件后导入。
// 逻辑与 ImportCSV 完全对称,仅文件过滤器和 service 调用不同。
func (h *LibraryHandler) ImportExcel() service.ImportResult {
filePath, err := runtime.OpenFileDialog(h.ctx, runtime.OpenDialogOptions{
Title: "选择 Excel 文件",
Filters: []runtime.FileFilter{
{DisplayName: "Excel 文件", Pattern: "*.xlsx"},
{DisplayName: "所有文件", Pattern: "*"},
},
})
if err != nil || filePath == "" {
return service.ImportResult{Error: "已取消"}
}
return service.ImportExcel(filePath)
}
// LibraryInfo 是 LibraryHandler 向前端暴露的知识库信息 DTO。
// 相比 models.Library,额外计算了 IsActive 字段,并去掉了 FilePath(不暴露内部路径)。
type LibraryInfo struct {
ID uint `json:"id"`
Name string `json:"name"`
+16 -10
View File
@@ -1,3 +1,5 @@
// Package handler 定义了给前端(Wails / JS)直接调用的底层接口。
// 此文件负责 Settings 模块暴露的三个接口操作。
package handler
import (
@@ -6,19 +8,19 @@ import (
"AI-Expert-Sidebar/internal/service"
)
// SettingsHandler exposes AI settings CRUD via Wails bindings.
// SettingsHandler 是配置模块的 Wails 绑定,前端通过 `window.go.main.App.SaveSettings` 等访问。
type SettingsHandler struct{ ctx context.Context }
func NewSettingsHandler() *SettingsHandler { return &SettingsHandler{} }
func (s *SettingsHandler) SetContext(ctx context.Context) { s.ctx = ctx }
// GetSettings returns the current local AI settings.
// GetSettings 返回目前所有的 AI 配置给前端以渲染 SettingsModal。
func (s *SettingsHandler) GetSettings() *service.SettingsDTO {
return service.GetSettings()
}
// SaveSettings persists AI config to local settings.db.
// Returns empty string on success, error message on failure.
// SaveSettings 接收前端传回来的设置 DTO。
// 返回一个字符串,如果为空("")表示成功,如果不为空说明发生了 error 并将文本传回供前端报错。
func (s *SettingsHandler) SaveSettings(dto service.SettingsDTO) string {
if err := service.SaveSettings(dto); err != nil {
return err.Error()
@@ -26,7 +28,11 @@ func (s *SettingsHandler) SaveSettings(dto service.SettingsDTO) string {
return ""
}
// GetProviders returns built-in AI provider presets for the frontend dropdown.
// GetProviders 返回软件内置支持的一个预设选项列表。
//
// 为什么要把这个配置硬编码在 Go 后端而不是前端 React 里?
// 因为这样保证了如果后期要追加新的知名模型提供商(如 Kimi、Moonshot),
// 只需要在此修改 Go 代码,且能跟 ResolveAIConfig 逻辑完全同步绑定,而不必在前后端各改一遍。
func (s *SettingsHandler) GetProviders() []ProviderPreset {
return []ProviderPreset{
{ID: "deepseek", Label: "DeepSeek", BaseURL: "https://api.deepseek.com/chat/completions", DefaultModel: "deepseek-chat"},
@@ -36,10 +42,10 @@ func (s *SettingsHandler) GetProviders() []ProviderPreset {
}
}
// ProviderPreset describes a known AI provider with preset URL and model.
// ProviderPreset 将被转换成 JS 对象 `ProviderPreset` 供前端生成下拉框的 options。
type ProviderPreset struct {
ID string `json:"id"`
Label string `json:"label"`
BaseURL string `json:"base_url"`
DefaultModel string `json:"default_model"`
ID string `json:"id"` // 提供商内置映射 ID
Label string `json:"label"` // 页面显示名称
BaseURL string `json:"base_url"` // 建议默认请求端点
DefaultModel string `json:"default_model"` // 建议默认使用的模型哈希
}