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
+84 -26
View File
@@ -1,3 +1,21 @@
// Package service 实现 OpenAI-compatible 流式 AI 调用(Server-Sent Events)。
//
// # 流式输出设计
//
// 本模块使用"流式请求 + Channel 推送"模式,而非"等待完整响应",原因:
// 1. 用户体验:DeepSeek/GPT 生成一条回复通常需要 3-30 秒,
// 流式输出让用户看到"逐字打印"效果,而非盯着空白等待;
// 2. 内存效率:完整回复可能超过 4096 token,流式逐块处理不会
// 在内存中积累超大字符串;
// 3. 可中断性:配合 context.WithCancel,用户随时可点击"停止"按钮
// 立即中断生成,而"一次性请求"无法在途中取消。
//
// # OpenAI 兼容协议
//
// DeepSeek、通义千问、ERNIE 等国内模型均提供"OpenAI 兼容接口"
// 支持完全相同的请求格式(/v1/chat/completions)和 SSE 响应格式。
// 本模块通过 AICallConfig.BaseURL 支持任意兼容端点,
// 用户只需在设置页填入对应的 API 地址即可切换模型,无需改代码。
package service
import (
@@ -12,29 +30,46 @@ import (
"time"
)
// AICallConfig holds the resolved configuration for a single AI API call.
// AICallConfig 封装单次 AI 调用所需的全部配置,由 ResolveAIConfig() 在调用前动态解析。
//
// 之所以用 struct 而非全局变量,是为了让每次调用都能独立配置,
// 方便未来扩展"对话级 prompt 覆盖"或"A/B 测试不同模型"而不互相干扰。
type AICallConfig struct {
BaseURL string
APIKey string
Model string
MaxTokens int
// BaseURL 是 API 端点,如 "https://api.deepseek.com/chat/completions"。
// 支持自定义,兼容所有 OpenAI-compatible 路由。
BaseURL string
// APIKey 对应 HTTP Header: Authorization: Bearer <APIKey>。
APIKey string
// Model 是模型标识符,如 "deepseek-chat" 或 "gpt-4o"。
Model string
// MaxTokens 限制单次生成的最大 token 数,防止意外超长输出(也控制费用)。
MaxTokens int
// SystemPrompt 是用户在设置页自定义的系统提示词,
// 覆盖 BuildRAGMessages 中的内置模板。
SystemPrompt string
}
// ── Request / Response types (OpenAI-compatible format) ──────────────────────
// ── OpenAI-compatible 请求/响应结构体 ─────────────────────────────────────────
// dsMessage 对应 OpenAI messages 数组中的单条消息。
// Role: "system" | "user" | "assistant"
type dsMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// dsRequest 是发送给 API 的完整请求体。
// Stream:true 触发服务端以 text/event-stream 格式逐块返回,而非一次性 JSON。
type dsRequest struct {
Model string `json:"model"`
Messages []dsMessage `json:"messages"`
Stream bool `json:"stream"`
MaxTokens int `json:"max_tokens,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"` // omitempty:0 时省略字段,用服务端默认值
}
// dsDelta / dsChoice / dsSSELine 是 SSE 流中每个 data: {...} 行的反序列化目标。
// 每行格式:{"choices":[{"delta":{"content":"你好"},"finish_reason":null}]}
// finish_reason 非 null 时表示生成完成,但我们用 [DONE] 标记作为终止信号更可靠。
type dsDelta struct {
Content string `json:"content"`
}
@@ -46,39 +81,42 @@ type dsSSELine struct {
Choices []dsChoice `json:"choices"`
}
// ── Public API ────────────────────────────────────────────────────────────────
// CallDeepSeekStream sends a messages list to any OpenAI-compatible endpoint
// defined in cfg, with stream:true. Pushes each delta content chunk to streamCh.
// CallDeepSeekStream 向任意 OpenAI-compatible 端点发起流式 Chat 请求。
//
// 参数说明:
// - ctx: 携带取消信号,用户点击"停止"时 context 被取消,HTTP 请求立即中止;
// - cfg: 本次调用的 AI 配置(BaseURL/APIKey/Model),由调用方从数据库解析;
// - messages: OpenAI 格式的对话历史,包含 system 和 user 两条消息;
// - streamCh: 写端 channel,每解析到一个字符片段就推送进去;
// 接收端(handler/expert.go)通过 runtime.EventsEmit 转发给前端。
//
// 返回 nil 表示流正常结束(收到 [DONE]),返回 error 表示网络或协议错误。
func CallDeepSeekStream(ctx context.Context, cfg AICallConfig, messages []dsMessage, streamCh chan<- string) error {
if cfg.APIKey == "" {
return fmt.Errorf("API key 未配置,请在设置中填写或联系管理员")
}
payload := dsRequest{
Model: cfg.Model,
Messages: messages,
Stream: true,
MaxTokens: cfg.MaxTokens,
}
payload := dsRequest{Model: cfg.Model, Messages: messages, Stream: true, MaxTokens: cfg.MaxTokens}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
// Timeout 60s:流式请求通常在 30s 内完成,60s 留出余量;
// 不使用无限超时,防止网络异常时 goroutine 永久挂起
client := &http.Client{Timeout: 60 * time.Second}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.BaseURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Accept", "text/event-stream") // 告知服务端客户端期望 SSE 格式
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
resp, err := client.Do(req)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
return ctx.Err() // 优先返回 context 取消错误,便于上层区分"用户停止"
}
return fmt.Errorf("http request: %w", err)
}
@@ -86,24 +124,35 @@ func CallDeepSeekStream(ctx context.Context, cfg AICallConfig, messages []dsMess
if resp.StatusCode != http.StatusOK {
errBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("upstream status %d: %s", resp.StatusCode, string(errBody))
return fmt.Errorf("上游返回 %d: %s", resp.StatusCode, string(errBody))
}
return parseDeepSeekSSE(ctx, resp.Body, streamCh)
}
// BuildRAGMessages constructs the OpenAI-compatible messages slice.
// If customSystemPrompt is non-empty, it replaces the built-in RAG template.
// BuildRAGMessages 构造 OpenAI-compatible messages 数组,实现 RAG 增强。
//
// # RAG(检索增强生成)原理
//
// 传统:直接把用户问题发给 AI → AI 靠训练知识回答(可能不准确)。
// RAG:先在本地知识库检索相关内容 → 将检索结果放入 system prompt →
//
// AI 优先参考本地知识回答 → 答案更贴合业务场景。
//
// knowledgeContext 是检索到的本地问答片段(由 buildKnowledgeContext 生成)。
// customSystemPrompt 非空时替换内置的客服模板,让用户完全自定义 AI 人设。
func BuildRAGMessages(knowledgeContext, userQuery, customSystemPrompt string) []dsMessage {
var systemContent string
if customSystemPrompt != "" {
// 用户自定义 prompt 优先,但仍然追加本地知识作为参考
systemContent = customSystemPrompt
if knowledgeContext != "" && knowledgeContext != "(无相关本地知识)" {
systemContent += "\n\n以下是本地知识库中的相关内容供参考:\n---\n" + knowledgeContext + "\n---"
}
} else {
// 内置模板:默认为"客服顾问"角色,优先参考本地知识库内容
systemContent = fmt.Sprintf(
"你是一位专业的植物养护和客服顾问,擅长用温暖、自然、有亲和力的语气沟通。\n\n"+
"你是一位专业的客服顾问,擅长用温暖、自然、有亲和力的语气沟通。\n\n"+
"以下是来自本地知识库的相关内容,请优先参考:\n\n---\n%s\n---\n\n"+
"根据以上知识润色话术,直接输出内容,不加前缀或解释。",
knowledgeContext,
@@ -115,13 +164,22 @@ func BuildRAGMessages(knowledgeContext, userQuery, customSystemPrompt string) []
}
}
// parseDeepSeekSSE 逐行解析 SSEServer-Sent Events)流,
// 提取每个 delta.content 片段并推入 channel。
//
// SSE 协议规则(OpenAI 子集):
// - 以 "data: " 开头的行包含 JSON payload
// - "data: [DONE]" 是终止标记,收到后停止扫描;
// - 其他行(空行、": comment" 等)直接忽略。
//
// bufio.Scanner 缓冲区设置为 64 KB,防止超长单行(如图片 base64)超出默认的 64 KB 限制。
func parseDeepSeekSSE(ctx context.Context, body io.Reader, ch chan<- string) error {
scanner := bufio.NewScanner(body)
scanner.Buffer(make([]byte, 64*1024), 64*1024)
for scanner.Scan() {
if ctx.Err() != nil {
return ctx.Err()
return ctx.Err() // 检查取消信号,及时退出扫描循环
}
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
@@ -133,11 +191,11 @@ func parseDeepSeekSSE(ctx context.Context, body io.Reader, ch chan<- string) err
}
var event dsSSELine
if err := json.Unmarshal([]byte(data), &event); err != nil {
continue
continue // 解析失败的行静默跳过,不中断整个流
}
if len(event.Choices) > 0 {
if chunk := event.Choices[0].Delta.Content; chunk != "" {
ch <- chunk
ch <- chunk // 推入 channel,由 handler 层转发给前端
}
}
}
+45 -13
View File
@@ -1,3 +1,9 @@
// Package service 提供 CSV 格式的知识库导入功能。
//
// CSV 被选为首要导入格式,原因:
// 1. 轻量、无格式依赖,可用记事本/Excel/Numbers 等任意工具创建;
// 2. Go 标准库 encoding/csv 原生支持,无需引入任何第三方依赖;
// 3. 对于知识库数据(纯文本问答),CSV 已足够表达所有字段。
package service
import (
@@ -11,19 +17,41 @@ import (
"AI-Expert-Sidebar/internal/models"
)
// ImportResult summarises the outcome of a CSV import.
// ImportResult 是导入操作的结果摘要,返回给前端显示 Toast 通知。
// 同时用于 CSV 和 Excel 导入,两者共用此结构体。
type ImportResult struct {
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Error string `json:"error,omitempty"`
// Imported 是成功写入数据库的行数。
Imported int `json:"imported"`
// Skipped 是被跳过的行数(空行、缺字段、写入失败等)。
Skipped int `json:"skipped"`
// Error 非空时表示导入整体失败(文件不存在、格式错误等),
// 此时 Imported/Skipped 没有意义。
Error string `json:"error,omitempty"`
}
// ImportCSV reads a CSV file and inserts records into the active knowledge library.
// ImportCSV 读取 CSV 文件并将合法行批量插入到当前活跃知识库。
//
// Required columns (case-insensitive): keyword, question, answer
// Optional column: category (defaults to "通用")
// # 期望的 CSV 格式
//
// The first row must be the header.
// 第一行必须是表头(顺序任意,大小写无关):
//
// keyword,question,answer,category
// 浇水频率,多肉多久浇一次水,10-14天一次,浇水
//
// required 列:keyword / question / answer(三者缺一则整批失败)
// optional 列:category(缺失时默认为 "通用")
//
// # 容错策略
//
// 单行解析失败(字段为空、列数不足)时只 skipped++,不中断整个导入。
// 这样一份有 5% 脏数据的 CSV 依然能有效导入 95% 的正常数据,
// 比"遇到错误立即中止"的方案用户体验好很多。
//
// # 为什么用流式读取(csv.Reader)而不是一次性读入内存
//
// 对于超大 CSV(数万条),一次性 ioutil.ReadAll 会占用大量内存;
// csv.Reader 逐行读取,内存消耗恒定(约等于单行大小),
// 且在写入失败时可以立即停止。
func ImportCSV(filePath string) ImportResult {
f, err := os.Open(filePath)
if err != nil {
@@ -33,14 +61,14 @@ func ImportCSV(filePath string) ImportResult {
db := database.Get()
if db == nil {
return ImportResult{Error: "知识库未初始化"}
return ImportResult{Error: "知识库未初始化,请先创建或选择知识库"}
}
r := csv.NewReader(f)
r.TrimLeadingSpace = true
r.LazyQuotes = true
r.TrimLeadingSpace = true // 自动去除字段前后的空格
r.LazyQuotes = true // 宽松解析:允许字段内出现未转义的引号
// Read and normalise header
// 读取并标准化表头行,构建列名→列序号的映射
header, err := r.Read()
if err != nil {
return ImportResult{Error: fmt.Sprintf("读取表头失败: %v", err)}
@@ -49,9 +77,10 @@ func ImportCSV(filePath string) ImportResult {
for i, h := range header {
colIdx[strings.ToLower(strings.TrimSpace(h))] = i
}
// 严格校验必需列是否存在,给出明确错误信息而非 index out of range panic
for _, required := range []string{"keyword", "question", "answer"} {
if _, ok := colIdx[required]; !ok {
return ImportResult{Error: fmt.Sprintf("CSV 缺少必需列: %q (需要: keyword, question, answer)", required)}
return ImportResult{Error: fmt.Sprintf("CSV 缺少必需列: %q需要: keyword, question, answer", required)}
}
}
catIdx, hasCat := colIdx["category"]
@@ -63,12 +92,14 @@ func ImportCSV(filePath string) ImportResult {
break
}
if err != nil {
// 单行解析错误(如奇数引号):跳过该行,继续下一行
skipped++
continue
}
keyword := strings.TrimSpace(row[colIdx["keyword"]])
question := strings.TrimSpace(row[colIdx["question"]])
answer := strings.TrimSpace(row[colIdx["answer"]])
// 三个核心字段任一为空则视为无效行
if keyword == "" || question == "" || answer == "" {
skipped++
continue
@@ -81,6 +112,7 @@ func ImportCSV(filePath string) ImportResult {
}
entry := models.Entry{Keyword: keyword, Question: question, Answer: answer, Category: cat}
if err := db.Create(&entry).Error; err != nil {
// 单条写入失败(如约束冲突)不影响其他行
skipped++
} else {
imported++
+125
View File
@@ -0,0 +1,125 @@
// Package service 提供 Excel (.xlsx) 格式的知识库导入功能。
//
// # 为什么单独一个文件
//
// Excel 导入依赖 github.com/xuri/excelize/v2 这个较重的第三方库(约 5 MB)。
// 将其单独放在 import_excel.go 而非并入 import.go,原因是:
// 1. 将来若需要 go build tag 条件编译(如精简版不含 Excel 支持),
// 只需在此文件头加一行 //go:build !lite 即可;
// 2. 代码审查时,Excel 相关逻辑和 CSV 逻辑不混杂,更清晰。
package service
import (
"fmt"
"strings"
"github.com/xuri/excelize/v2"
"AI-Expert-Sidebar/internal/database"
"AI-Expert-Sidebar/internal/models"
)
// ImportExcel 读取 .xlsx 文件第一个工作表,并将合法行插入当前活跃知识库。
//
// # 期望的 Excel 格式
//
// A1 起始的第一行为表头(列顺序任意,大小写无关):
//
// keyword | question | answer | category
//
// required 列:keyword / question / answer
// optional 列:category(缺失默认 "通用"
//
// # 与 ImportCSV 的设计对比
//
// 两者共享相同的"表头自动检测 + 逐行容错"策略,
// 不同之处在于:
// - excelize.GetRows 一次性将整个 Sheet 读入内存([][]string),
// 而 CSV 是逐行流式读取;
// - Excel 因格式复杂(合并单元格/公式/样式),
// 一次性读取后统一处理更稳定,遇到短行也能安全截断。
//
// # 为什么只读第一个工作表
//
// 大多数用户的知识库 Excel 只有一个 Sheet。
// 若多 Sheet 都读取,反而容易把"说明"或"示例"Sheet 的数据误导入。
// 当前选择保守策略:仅读 Sheet[0],未来有需求可扩展为让用户选择 Sheet。
func ImportExcel(filePath string) ImportResult {
db := database.Get()
if db == nil {
return ImportResult{Error: "知识库未初始化,请先创建或选择知识库"}
}
// excelize.OpenFile 会完整解析 .xlsxZIP 格式),
// 遇到损坏文件会返回 error 而非 panic
f, err := excelize.OpenFile(filePath)
if err != nil {
return ImportResult{Error: fmt.Sprintf("无法打开 Excel 文件: %v", err)}
}
defer f.Close() // 释放 excelize 内部持有的文件句柄和内存
sheets := f.GetSheetList()
if len(sheets) == 0 {
return ImportResult{Error: "Excel 文件中没有工作表"}
}
// GetRows 返回 [][]string,每个元素是一行,每行是一个字段切片
rows, err := f.GetRows(sheets[0])
if err != nil {
return ImportResult{Error: fmt.Sprintf("读取工作表失败: %v", err)}
}
if len(rows) < 2 {
// rows[0] 是表头,至少需要 1 行数据
return ImportResult{Error: "工作表没有数据行(至少需要表头行 + 一行数据)"}
}
// 与 CSV 导入相同的表头自动检测逻辑,不要求列的固定顺序
colIdx := make(map[string]int)
for i, h := range rows[0] {
colIdx[strings.ToLower(strings.TrimSpace(h))] = i
}
for _, required := range []string{"keyword", "question", "answer"} {
if _, ok := colIdx[required]; !ok {
return ImportResult{Error: fmt.Sprintf("缺少必需列: %q(需要: keyword, question, answer", required)}
}
}
catIdx, hasCat := colIdx["category"]
var imported, skipped int
for _, row := range rows[1:] {
// Excel 中某些行尾部的空单元格会被 excelize 截断,
// 需要防止 index out of range,先计算需要的最大列索引
maxIdx := colIdx["keyword"]
if colIdx["question"] > maxIdx {
maxIdx = colIdx["question"]
}
if colIdx["answer"] > maxIdx {
maxIdx = colIdx["answer"]
}
if len(row) <= maxIdx {
skipped++
continue
}
keyword := strings.TrimSpace(row[colIdx["keyword"]])
question := strings.TrimSpace(row[colIdx["question"]])
answer := strings.TrimSpace(row[colIdx["answer"]])
if keyword == "" || question == "" || answer == "" {
skipped++
continue
}
cat := "通用"
if hasCat && catIdx < len(row) {
if v := strings.TrimSpace(row[catIdx]); v != "" {
cat = v
}
}
entry := models.Entry{Keyword: keyword, Question: question, Answer: answer, Category: cat}
if err := db.Create(&entry).Error; err != nil {
skipped++
} else {
imported++
}
}
return ImportResult{Imported: imported, Skipped: skipped}
}
+58
View File
@@ -0,0 +1,58 @@
// Package service 提供知识库条目的增删操作。
// 此文件专注于"破坏性"操作(删除、清空),与搜索/导入分开放置,
// 便于代码审计时快速定位所有写入操作。
package service
import (
"fmt"
"AI-Expert-Sidebar/internal/database"
"AI-Expert-Sidebar/internal/models"
)
// DeleteItems 从当前活跃知识库中物理删除指定 ID 的条目。
//
// # 设计决策
//
// - 使用"物理删除"而非"软删除"deleted_at 字段):
// 本项目定位为本地隐私工具,用户期望删除就是彻底删除,
// 不需要回收站或审计日志,软删除只会增加查询复杂度。
//
// - ids 为空时提前返回 nil,避免 GORM 生成 "DELETE ... WHERE id IN ()"
// 这样的非法 SQL 语句。
//
// - 使用 db.Delete(&models.Entry{}, ids) 的原因:
// GORM 会展开 ids 为 "WHERE id IN (?,?,?)",一次网络往返完成批量删除,
// 比循环单条删除效率高出 N 倍。
func DeleteItems(ids []uint) error {
if len(ids) == 0 {
return nil
}
db := database.Get()
if db == nil {
return fmt.Errorf("知识库未初始化,请先选择或创建一个知识库")
}
return db.Delete(&models.Entry{}, ids).Error
}
// ClearDatabase 清空当前活跃知识库的所有条目,但保留 entries 表结构。
//
// # 与"删除知识库"的区别
//
// ClearDatabase 只删除行数据,表和 .db 文件依然存在,
// 用户可以立刻向空库重新导入新的 CSV/Excel 数据。
// 对比 DeleteLibrary(删除整个 .db 文件),此操作更轻量,
// 适合"重置知识库内容但保留库名"的场景。
//
// # WHERE 子句说明
//
// SQLite 的 GORM 驱动需要显式 WHERE 条件才能执行全表删除,
// 否则会因缺少 WHERE 子句而报错(与 MySQL 行为不同)。
// "WHERE id > 0" 是符合 SQL 标准的"全匹配"惯用写法。
func ClearDatabase() error {
db := database.Get()
if db == nil {
return fmt.Errorf("知识库未初始化,请先选择或创建一个知识库")
}
return db.Where("id > 0").Delete(&models.Entry{}).Error
}
+64 -22
View File
@@ -1,3 +1,5 @@
// Package service 管理知识库文件的完整生命周期:
// 注册、创建、切换、删除以及启动恢复。
package service
import (
@@ -11,65 +13,91 @@ import (
"AI-Expert-Sidebar/internal/models"
)
// ListLibraries returns all registered knowledge libraries with entry counts.
// ListLibraries 返回所有已注册的知识库,并为每个库填充实时条目数。
//
// 条目数通过 countEntries 只读方式查询各 .db 文件,不会影响活跃库的连接。
// "注册表在 settings.db,内容在各 *.db" 的分离设计使此操作天然并发安全。
func ListLibraries() ([]models.Library, error) {
sdb := database.GetSettings()
if sdb == nil {
return nil, fmt.Errorf("settings DB not ready")
return nil, fmt.Errorf("设置数据库未就绪")
}
var libs []models.Library
if err := sdb.Order("created_at asc").Find(&libs).Error; err != nil {
return nil, err
}
// Populate entry count for each library
// 填充每个库的当前条目数(前端展示用,不参与业务逻辑)
for i, lib := range libs {
libs[i].EntryCount = countEntries(lib.FilePath)
}
return libs, nil
}
// CreateLibrary registers a new knowledge library and creates its SQLite file.
// CreateLibrary 在应用数据目录下创建一个新的 .db 文件,并在 settings.db 中注册。
//
// # 文件命名策略
//
// 将知识库名(如"植物百科")直接作为文件名(植物百科.db),
// 方便用户在 Finder 中识别。若同名文件已存在,追加 Unix 时间戳保证唯一性。
// 字符过滤(sanitizeFileName)防止名称中含有 / \ : 等非法文件系统字符。
//
// # 事务性保证
//
// 先创建 .db 文件,再写入 settings.db 注册记录。
// 若数据库写入失败,立即删除已创建的 .db 文件(回滚),
// 避免"有文件无注册"的孤儿状态。
func CreateLibrary(name, description string) (*models.Library, error) {
sdb := database.GetSettings()
dir := database.DataDir
fileName := sanitizeFileName(name) + ".db"
filePath := filepath.Join(dir, fileName)
// Ensure uniqueness of file path
// 若文件已存在(同名库被删除后只去注册没删文件),追加时间戳避免覆盖
if _, err := os.Stat(filePath); err == nil {
filePath = filepath.Join(dir, sanitizeFileName(name)+"_"+fmt.Sprintf("%d", time.Now().Unix())+".db")
}
// 先建文件(含表结构),确保文件合法后才写注册表
if err := database.NewLibraryDB(filePath); err != nil {
return nil, fmt.Errorf("create library DB: %w", err)
return nil, fmt.Errorf("创建知识库文件失败: %w", err)
}
lib := models.Library{Name: name, Description: description, FilePath: filePath}
if err := sdb.Create(&lib).Error; err != nil {
os.Remove(filePath) // rollback file
os.Remove(filePath) // 注册失败则回滚文件创建
return nil, err
}
log.Printf("[Library] Created: %s → %s", name, filePath)
log.Printf("[Library] 已创建: %s → %s", name, filePath)
return &lib, nil
}
// SwitchLibrary makes the named library active.
// SwitchLibrary 将名为 name 的知识库设置为当前活跃库。
//
// 配合 database.OpenLibrary 完成:
// 1. 打开新的 SQLite 连接;
// 2. AutoMigrate(保证旧版 .db 文件的 schema 更新);
// 3. 更新 settings.db 中的 "active_library" 偏好键。
func SwitchLibrary(name string) error {
sdb := database.GetSettings()
var lib models.Library
if err := sdb.Where("name = ?", name).First(&lib).Error; err != nil {
return fmt.Errorf("library %q not found", name)
return fmt.Errorf("知识库 %q 未找到", name)
}
return database.OpenLibrary(lib)
}
// DeleteLibrary removes a library from the registry (and optionally its file).
// DeleteLibrary 从 settings.db 中删除知识库的注册记录。
//
// deleteFile=false 时只删注册,.db 文件保留在磁盘,
// 用户可以日后重新注册(或手动备份)。
// deleteFile=true 时物理删除文件,数据不可恢复。
//
// 注意:调用方应在删除前检查当前活跃库(不能删除正在使用的库)。
func DeleteLibrary(name string, deleteFile bool) error {
sdb := database.GetSettings()
var lib models.Library
if err := sdb.Where("name = ?", name).First(&lib).Error; err != nil {
return fmt.Errorf("library %q not found", name)
return fmt.Errorf("知识库 %q 未找到", name)
}
if err := sdb.Delete(&lib).Error; err != nil {
return err
@@ -80,31 +108,42 @@ func DeleteLibrary(name string, deleteFile bool) error {
return nil
}
// InitLibraries restores the last active library or creates the default one.
// InitLibraries 在应用启动时恢复上次使用的知识库,
// 若无历史记录则自动创建"默认知识库"。
//
// # 自愈能力
//
// 若 settings.db 中记录的知识库文件已被用户手动删除,
// SwitchLibrary 会返回 errorInitLibraries 捕获此 error 后
// 转而查找下一个可用库,或创建默认库,
// 确保应用启动不崩溃、不卡死("自动修复")。
func InitLibraries() error {
sdb := database.GetSettings()
// Check active_library preference
// 尝试恢复上次的活跃库偏好
var setting models.AppSetting
if sdb.Where("key = ?", "active_library").First(&setting).Error == nil {
if err := SwitchLibrary(setting.Value); err == nil {
return nil // restored successfully
return nil // 成功恢复
}
// 上次的库文件可能已被删除,继续往下走
}
// No preference or stale — find first library
// 没有偏好或偏好指向的文件已消失:打开注册表中的第一个
var lib models.Library
if sdb.Order("created_at asc").First(&lib).Error == nil {
return database.OpenLibrary(lib)
}
// No libraries at all — create default
lib2, err := CreateLibrary("默认知识库", "自动创建的默认知识库")
// 注册表也为空:首次启动,创建默认库
lib2, err := CreateLibrary("默认知识库", "程序自动创建的默认知识库")
if err != nil {
return err
}
return database.OpenLibrary(*lib2)
}
// ── helpers ───────────────────────────────────────────────────────────────────
// ── 内部辅助函数 ───────────────────────────────────────────────────────────────
// countEntries 以只读方式打开指定 .db 文件,统计 entries 表的行数。
// 返回 -1 表示文件无法打开(已被删除等情况),前端可据此显示"?"。
func countEntries(filePath string) int {
db, err := database.NewLibraryDBReadOnly(filePath)
if err != nil {
@@ -115,17 +154,20 @@ func countEntries(filePath string) int {
return int(count)
}
// sanitizeFileName 将知识库名称转换为合法的文件名,
// 替换 Windows/macOS/Linux 都不允许的字符(/ \ : * ? " < > |)为下划线。
func sanitizeFileName(name string) string {
result := make([]rune, 0, len(name))
for _, r := range name {
if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' {
if r == '/' || r == '\\' || r == ':' || r == '*' ||
r == '?' || r == '"' || r == '<' || r == '>' || r == '|' {
result = append(result, '_')
} else {
result = append(result, r)
}
}
if len(result) == 0 {
return "library"
return "library" // 防止空名称
}
return string(result)
}
+60 -11
View File
@@ -1,3 +1,5 @@
// Package service 提供知识库的搜索逻辑。
// 搜索是本项目最核心的功能,此文件实现了"关键词模糊匹配 + 降级兜底"策略。
package service
import (
@@ -8,21 +10,58 @@ import (
"AI-Expert-Sidebar/internal/models"
)
// maxResults 限制每次搜索最多返回的条目数。
// 侧边栏 UI 空间有限,超过 5 条会让用户滚动,体验下降;
// 同时限制条数也控制了传给 AI 的 Context 长度,避免超出 Token 限制。
const maxResults = 5
// ErrDBUnavailable 是数据库未就绪时返回的哨兵错误。
// 前端可以检查此错误来决定是否显示"数据库初始化中"提示,
// 而不是和其他内部错误混为一谈。
var ErrDBUnavailable = errors.New("database unavailable")
// SearchResult is the DTO returned to the frontend.
// SearchResult 是从 service 层返回给 handler/前端的搜索结果 DTO。
// 使用 DTO(数据传输对象)而非直接返回 models.Entry,原因:
// 1. 可以附加计算字段(Score、IsFallback)而不污染数据库模型;
// 2. 解耦:前端字段名(snake_case JSON)与数据库字段名可以独立演化;
// 3. 敏感字段(如 UpdatedAt)不会意外暴露给前端。
type SearchResult struct {
ID uint `json:"id"`
Question string `json:"question"`
Answer string `json:"answer"`
Category string `json:"category"`
Score int `json:"score"` // 2=keyword, 1=question, 0=fallback
IsFallback bool `json:"is_fallback"`
ID uint `json:"id"`
Question string `json:"question"`
Answer string `json:"answer"`
Category string `json:"category"`
// Score 表示匹配精度:2=关键词命中, 1=问题文本命中, 0=降级兜底。
// 前端用此值决定显示 "⚡精准匹配" 还是 "🔥热门推荐" 徽章。
Score int `json:"score"` // 2=keyword, 1=question, 0=fallback
// IsFallback 为 true 时,表示搜索无命中,结果是随机推荐的热门问答。
// 前端据此显示提示横幅,避免用户误以为这是精准答案。
IsFallback bool `json:"is_fallback"`
}
// SearchKnowledge performs fuzzy search in the active knowledge library.
// SearchKnowledge 在当前活跃知识库中执行模糊关键词搜索,并实现降级兜底。
//
// # 搜索策略(两阶段)
//
// 阶段 1 — 精准模糊匹配:
//
// 在 keyword 和 question 列上执行 LIKE '%query%' 查询。
// SQLite 的 LIKE 运算符对 ASCII 字符不区分大小写,
// 中文字符则依赖 GBK/UTF-8 字节比较(本项目全 UTF-8,无问题)。
// 结果按 updated_at DESC 排序,使最近更新的内容优先展示。
//
// 阶段 2 — 降级兜底(Fallback):
//
// 若阶段 1 无任何结果,改为返回最近更新的前 3 条记录,
// 并标记 IsFallback=true。
// 这样用户永远不会看到"空结果",体验更好;
// 前端会显示"未找到精确匹配"提示,不造成误导。
//
// # 为什么不用 FTS5(全文搜索)
//
// SQLite FTS5 提供更强大的全文检索,但需要额外建表/触发器,
// 对中文效果也不理想(需要分词插件)。
// 简单 LIKE 对本项目知识库规模(通常 < 1000 条)已完全足够,
// 引入 FTS5 会大幅增加代码复杂度,得不偿失。
func SearchKnowledge(query string) ([]SearchResult, error) {
db := database.Get()
if db == nil {
@@ -32,6 +71,7 @@ func SearchKnowledge(query string) ([]SearchResult, error) {
return nil, nil
}
// 用 channel + goroutine 封装查询,使 handler 层可以安全地配合 context 取消
type res struct {
rows []SearchResult
err error
@@ -39,6 +79,7 @@ func SearchKnowledge(query string) ([]SearchResult, error) {
ch := make(chan res, 1)
go func() {
// 检查库是否为空;空库时直接返回,避免无意义的 LIKE 查询
var total int64
db.Model(&models.Entry{}).Count(&total)
if total == 0 {
@@ -55,20 +96,22 @@ func SearchKnowledge(query string) ([]SearchResult, error) {
return
}
// 阶段 2:降级兜底
isFallback := len(rows) == 0
if isFallback {
log.Printf("[Search] No match for %q, returning fallback", query)
log.Printf("[Search] 关键词 %q 无匹配,启用降级兜底", query)
db.Order("updated_at DESC").Limit(3).Find(&rows)
}
// 将数据库模型转换为前端 DTO,计算匹配分
out := make([]SearchResult, 0, len(rows))
for _, r := range rows {
score := 0
if !isFallback {
if containsIgnoreCase(r.Keyword, query) {
score = 2
score = 2 // 关键词精准命中,优先级最高
} else if containsIgnoreCase(r.Question, query) {
score = 1
score = 1 // 问题文本命中,次优
}
}
out = append(out, SearchResult{
@@ -83,6 +126,11 @@ func SearchKnowledge(query string) ([]SearchResult, error) {
return r.rows, r.err
}
// containsIgnoreCase 检查字符串 s 是否包含子串 sub(忽略 ASCII 大小写)。
//
// 使用手写 rune 循环而非 strings.ToLower + strings.Contains 的原因:
// 避免为每次比较创建两个临时字符串,对频繁调用的搜索路径更节省内存。
// 注意:仅 A-Z 做大小写转换,中文字符原样比较(中文无大小写概念)。
func containsIgnoreCase(s, sub string) bool {
if len(sub) == 0 {
return true
@@ -106,6 +154,7 @@ func containsIgnoreCase(s, sub string) bool {
return false
}
// toLower 将 ASCII 大写字母转为小写,其余字符不变。
func toLower(r rune) rune {
if r >= 'A' && r <= 'Z' {
return r + 32
+57 -11
View File
@@ -1,7 +1,14 @@
// Package service 负责管理用户的本地 AI 配置(API Key、模型选择等)。
//
// 所有的配置都持久化存储在 settings.db 中。
// 这里的配置与 config.yaml 的职责有本质区别:
// - config.yaml 是"应用的公共回退配置"(开发者提供);
// - settings.db 是"用户自己配置的 API Key"(数据主权归用户)。
package service
import (
"fmt"
"strconv"
"AI-Expert-Sidebar/internal/config"
"AI-Expert-Sidebar/internal/crypto"
@@ -9,7 +16,8 @@ import (
"AI-Expert-Sidebar/internal/models"
)
// SettingsDTO is what the frontend reads and writes.
// SettingsDTO 是在前后端之间传递配置信息的"数据传输对象"。
// 它将散落在 settings.db KV 表里的各条记录聚合为一个结构体,方便前端 React 渲染表单。
type SettingsDTO struct {
AIProvider string `json:"ai_provider"`
BaseURL string `json:"base_url"`
@@ -17,15 +25,19 @@ type SettingsDTO struct {
Model string `json:"model"`
SystemPrompt string `json:"system_prompt"`
MaxTokens int `json:"max_tokens"`
UsePublicKey bool `json:"use_public_key"`
// UsePublicKey: true 表示用户不想用自己的 Key,而是使用软件内置的 Key。
UsePublicKey bool `json:"use_public_key"`
}
// GetSettings reads AI config from settings.db key-value store.
// GetSettings 从 settings.db 读取所有 AI 配置,并组装为 DTO 返回给前端。
// 如果发现 api_key 是被加密过的,会在此处进行解密。
func GetSettings() *SettingsDTO {
sdb := database.GetSettings()
if sdb == nil {
return defaultDTO()
}
// 将 KV 表一次性读取到 map 中
var rows []models.AppSetting
sdb.Find(&rows)
m := make(map[string]string, len(rows))
@@ -33,42 +45,57 @@ func GetSettings() *SettingsDTO {
m[r.Key] = r.Value
}
// 尝试解密 API Key,若尚未配置或密文损坏,则返回空字符串
apiKey, _ := crypto.DecryptAPIKey(m["api_key_encrypted"])
maxTokens := 1024
fmt.Sscanf(m["max_tokens"], "%d", &maxTokens)
if maxTokens <= 0 {
maxTokens = 1024
if mt, err := strconv.Atoi(m["max_tokens"]); err == nil && mt > 0 {
maxTokens = mt
}
return &SettingsDTO{
AIProvider: strOr(m["ai_provider"], "deepseek"),
AIProvider: strOr(m["ai_provider"], "deepseek"), // 默认厂商为 deepseek
BaseURL: m["base_url"],
APIKey: apiKey,
APIKey: apiKey, // 传递给前端的是明文
Model: strOr(m["model"], "deepseek-chat"),
SystemPrompt: m["system_prompt"],
MaxTokens: maxTokens,
UsePublicKey: m["use_public_key"] != "false",
UsePublicKey: m["use_public_key"] != "false", // 默认 true
}
}
// SaveSettings persists AI config into settings.db.
// SaveSettings 接收前端传来的 DTO 并持久化到 settings.db
//
// # 数据安全
// 为了保护用户的 API Key,APIKey 字段在入库前会被强制进行 AES-256 加密,
// 所以数据库里只会写入 api_key_encrypted。
func SaveSettings(dto SettingsDTO) error {
sdb := database.GetSettings()
if sdb == nil {
return fmt.Errorf("settings DB not ready")
}
// 定义 UPSERT 闭包:使用 GORM 的原生 SQL,如果 key 已存在则更新 value,不存在则插入
upsert := func(k, v string) {
sdb.Exec("INSERT INTO app_settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", k, v)
}
upsert("ai_provider", dto.AIProvider)
upsert("base_url", dto.BaseURL)
upsert("model", dto.Model)
upsert("system_prompt", dto.SystemPrompt)
upsert("max_tokens", fmt.Sprintf("%d", dto.MaxTokens))
usePublic := "true"
if !dto.UsePublicKey {
usePublic = "false"
}
upsert("use_public_key", usePublic)
// 如果用户选择使用私有 Key,并且确实输入了 Key,则对其加密后存储。
// 如果前端传来了空字符串(可能是用户想清空),这里不会主动覆盖旧密文,
// 需要增强逻辑:本系统目前只有覆盖更新,不提供独立删除 Key 按钮,
// 留空即代表不更新原本缓存的 Key 密文。
if !dto.UsePublicKey && dto.APIKey != "" {
enc, err := crypto.EncryptAPIKey(dto.APIKey)
if err != nil {
@@ -79,24 +106,39 @@ func SaveSettings(dto SettingsDTO) error {
return nil
}
// ResolveAIConfig returns the effective AI call config (local settings or global fallback).
// ResolveAIConfig 是 AI 调用的前置拦截器,决定最终到底使用哪个 API Key 和地址。
//
// # 动态回退策略
// 1. 如果用户勾选了 "使用公共线路" 或是没填过自己的 API Key:
// 直接短路,返回 config.yaml 里打包的公共 Key 和端点;
// 2. 如果用户提供了私有 Key,优先使用用户的 Key;
// 3. 对 BaseURL 进行缺省补全:如果选择了特定厂商(如 deepseek)但没填 URL
// 代码会自动填充标准官方 URL,极大简化了用户的配置门槛。
func ResolveAIConfig() AICallConfig {
// 默认配置回滚:来源于 config.yaml
base := AICallConfig{
BaseURL: "https://api.deepseek.com/chat/completions",
APIKey: config.Global.DeepSeek.APIKey,
Model: config.Global.DeepSeek.Model,
MaxTokens: config.Global.DeepSeek.MaxTokens,
}
dto := GetSettings()
if dto == nil {
return base
}
// 无论使用什么 Key,用户的自定义系统提示词均生效
base.SystemPrompt = dto.SystemPrompt
// 若用户指定使用公共线路,或用户的私钥实际上为空,触发降级
if dto.UsePublicKey || dto.APIKey == "" {
return base
}
providerURL := dto.BaseURL
if providerURL == "" {
// 常见厂商的默认 API 路由表补全
switch dto.AIProvider {
case "deepseek":
providerURL = "https://api.deepseek.com/chat/completions"
@@ -106,10 +148,13 @@ func ResolveAIConfig() AICallConfig {
providerURL = "https://api.x.ai/v1/chat/completions"
}
}
maxTok := dto.MaxTokens
if maxTok <= 0 {
maxTok = 1024
}
// 返回拼接好的终端可直接消费的 AI 配置
return AICallConfig{
BaseURL: strOr(providerURL, base.BaseURL),
APIKey: dto.APIKey,
@@ -119,6 +164,7 @@ func ResolveAIConfig() AICallConfig {
}
}
// strOr 是一个简单的 fallback 辅助函数。
func strOr(v, def string) string {
if v == "" {
return def