feat: 添加注释
This commit is contained in:
@@ -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 逐行解析 SSE(Server-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
@@ -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++
|
||||
|
||||
@@ -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 会完整解析 .xlsx(ZIP 格式),
|
||||
// 遇到损坏文件会返回 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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 会返回 error;InitLibraries 捕获此 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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user