feat: 添加注释
This commit is contained in:
@@ -1,3 +1,22 @@
|
||||
// Package crypto 提供本项目所有加密/解密能力。
|
||||
//
|
||||
// # 加密方案选型
|
||||
//
|
||||
// 使用 AES-256-GCM(Galois/Counter Mode)对称加密,原因:
|
||||
// 1. GCM 是认证加密(AEAD),同时提供机密性和完整性验证,
|
||||
// 可以检测密文被篡改的情况(返回 error 而非静默解密乱码)。
|
||||
// 2. AES-256 是 NIST 标准,Go 标准库原生支持,无需第三方依赖。
|
||||
// 3. 相比 RSA 等非对称方案,AES 性能更高、密钥更短,
|
||||
// 且本场景(本地加密本地解密)不需要非对称性。
|
||||
//
|
||||
// # 密钥派生
|
||||
//
|
||||
// 不使用用户输入的密码派生密钥(避免需要"主密码"的 UX 摩擦),
|
||||
// 而是使用固化在应用中的 appSecret 做 SHA-256 哈希得到 32 字节密钥。
|
||||
//
|
||||
// 安全边界:此方案防止的是"直接读取 settings.db 文件就能看到 API Key 明文",
|
||||
// 不能防止"有完整应用二进制 + 数据文件的攻击者",
|
||||
// 这对于本地隐私工具已经足够。
|
||||
package crypto
|
||||
|
||||
import (
|
||||
@@ -10,15 +29,30 @@ import (
|
||||
"io"
|
||||
)
|
||||
|
||||
// appSecret 是应用级固定密钥种子。
|
||||
// 这个字符串不会被写入数据库,仅在运行时存于内存。
|
||||
// 即便攻击者拿到了 settings.db,没有此二进制也无法解密。
|
||||
const appSecret = "ai-expert-sidebar-v1-local-©2026"
|
||||
|
||||
// deriveKey produces a 32-byte AES key from the application secret.
|
||||
// deriveKey 将 appSecret 通过 SHA-256 压缩为固定的 32 字节切片,
|
||||
// 作为 AES-256 的原始密钥(AES-256 要求密钥恰好为 32 字节)。
|
||||
// SHA-256 是单向函数,无法从输出反推 appSecret。
|
||||
func deriveKey() []byte {
|
||||
sum := sha256.Sum256([]byte(appSecret))
|
||||
return sum[:]
|
||||
return sum[:] // 将 [32]byte 数组转为 []byte 切片
|
||||
}
|
||||
|
||||
// EncryptAPIKey encrypts a plaintext API key. Returns "" for empty input.
|
||||
// EncryptAPIKey 使用 AES-256-GCM 加密用户输入的 API Key 明文,
|
||||
// 返回 base64 编码的密文字符串(方便存入 SQLite TEXT 列)。
|
||||
//
|
||||
// 空字符串返回空字符串,调用方无需特殊处理。
|
||||
//
|
||||
// # Nonce 设计
|
||||
//
|
||||
// 每次加密随机生成一个新 nonce(GCM 标准大小:12 字节),
|
||||
// 并将其前置在密文中(nonce || ciphertext)一起 base64。
|
||||
// 这样同一个 API Key 每次加密结果都不同,防止重放攻击,
|
||||
// 且解密时只需从密文头部截取 nonce,无需额外存储。
|
||||
func EncryptAPIKey(plaintext string) (string, error) {
|
||||
if plaintext == "" {
|
||||
return "", nil
|
||||
@@ -31,15 +65,23 @@ func EncryptAPIKey(plaintext string) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 使用密码学安全的随机数生成器(crypto/rand,非 math/rand)产生 nonce
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Seal 将 nonce 作为前缀附加到加密后的密文
|
||||
// 格式:[nonce(12 bytes)] [ciphertext+tag]
|
||||
sealed := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(sealed), nil
|
||||
}
|
||||
|
||||
// DecryptAPIKey decrypts a base64-encoded AES-256-GCM ciphertext. Returns "" for empty input.
|
||||
// DecryptAPIKey 解密 EncryptAPIKey 产生的 base64 密文,
|
||||
// 返回原始 API Key 明文。
|
||||
//
|
||||
// 空字符串输入返回空字符串(用户尚未配置 Key 的状态)。
|
||||
// 若密文被篡改或密钥不匹配,GCM 认证会失败,
|
||||
// 返回 error 而非乱码明文。
|
||||
func DecryptAPIKey(ciphertext64 string) (string, error) {
|
||||
if ciphertext64 == "" {
|
||||
return "", nil
|
||||
@@ -58,8 +100,9 @@ func DecryptAPIKey(ciphertext64 string) (string, error) {
|
||||
}
|
||||
ns := gcm.NonceSize()
|
||||
if len(data) < ns {
|
||||
return "", fmt.Errorf("ciphertext too short")
|
||||
return "", fmt.Errorf("密文过短,可能已损坏")
|
||||
}
|
||||
// 从头部取出 nonce,剩余部分为真正的密文+认证标签
|
||||
plain, err := gcm.Open(nil, data[:ns], data[ns:], nil)
|
||||
return string(plain), err
|
||||
}
|
||||
|
||||
+85
-14
@@ -1,3 +1,23 @@
|
||||
// Package database 封装了本项目所有 SQLite 数据库的生命周期管理。
|
||||
//
|
||||
// # 架构设计说明
|
||||
//
|
||||
// 本项目采用"双 DB 模式",而非传统的"单一数据库":
|
||||
//
|
||||
// - settings.db — 全局设置库(AI 配置键值对 + 知识库注册表)
|
||||
// 存储在 os.UserConfigDir()/AI-Expert-Sidebar/ 下,随应用永久保留。
|
||||
//
|
||||
// - *.db — 知识库文件(每个业务话题一个独立 .db 文件)
|
||||
// 用户可随时创建/切换,彼此完全物理隔离,互不干扰。
|
||||
//
|
||||
// 这样设计的原因:传统单库方案依赖 user_id 做逻辑隔离,
|
||||
// 一旦查询漏加 WHERE 条件就会数据泄露;物理隔离则从根本上消除此风险,
|
||||
// 同时让用户可以直接拷贝/备份/分享单个 .db 文件,操作极为直观。
|
||||
//
|
||||
// # 并发安全
|
||||
//
|
||||
// 所有全局变量通过 sync.RWMutex 保护。读操作使用 RLock,
|
||||
// 仅写(切换库)时用 Lock,保证多 goroutine 并发安全。
|
||||
package database
|
||||
|
||||
import (
|
||||
@@ -15,20 +35,42 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.RWMutex
|
||||
settingsDB *gorm.DB
|
||||
activeLib *gorm.DB
|
||||
// mu 保护下面所有包级变量的并发读写。
|
||||
mu sync.RWMutex
|
||||
|
||||
// settingsDB 是全局设置数据库,存储 AppSetting 和 Library 两张表。
|
||||
// 生命周期与应用相同,Init() 调用后即可使用。
|
||||
settingsDB *gorm.DB
|
||||
|
||||
// activeLib 指向当前正在使用的知识库 SQLite 连接。
|
||||
// 通过 OpenLibrary() 切换,Get() 读取。
|
||||
activeLib *gorm.DB
|
||||
|
||||
// activeLibNm 缓存当前活跃知识库的显示名称,供前端展示。
|
||||
// 避免每次都查数据库。
|
||||
activeLibNm string
|
||||
DataDir string
|
||||
|
||||
// DataDir 是应用数据目录的绝对路径。
|
||||
// 暴露给 service 层用于拼接新知识库 .db 文件路径。
|
||||
DataDir string
|
||||
)
|
||||
|
||||
// Init opens/creates the settings database ($HOME/Library/Application Support/AI-Expert-Sidebar/settings.db).
|
||||
// Init 初始化全局设置数据库。
|
||||
//
|
||||
// 具体操作:
|
||||
// 1. 调用 appDataDir() 确定存储目录(macOS: ~/Library/Application Support/AI-Expert-Sidebar/);
|
||||
// 2. 若目录不存在则创建(权限 0750,防止其他用户读取 API Key);
|
||||
// 3. 打开/创建 settings.db,并执行 AutoMigrate 建表;
|
||||
// 4. 若 settings.db 损坏或被删除,GORM 会重新创建,程序不会崩溃。
|
||||
//
|
||||
// 设计原则:Init 只负责"基础设施",不做业务初始化(那是 service.InitLibraries 的职责)。
|
||||
func Init() error {
|
||||
dir, err := appDataDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
DataDir = dir
|
||||
// 0o750: 当前用户 rwx,组 r-x,其他无权限 —— 保护含密钥的目录
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return fmt.Errorf("create data dir: %w", err)
|
||||
}
|
||||
@@ -37,6 +79,7 @@ func Init() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("open settings.db: %w", err)
|
||||
}
|
||||
// AutoMigrate 是幂等的:若表已存在则只做增量列变更,不会删数据
|
||||
if err := db.AutoMigrate(&models.AppSetting{}, &models.Library{}); err != nil {
|
||||
return fmt.Errorf("migrate settings schema: %w", err)
|
||||
}
|
||||
@@ -47,12 +90,19 @@ func Init() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenLibrary switches the active knowledge library.
|
||||
// OpenLibrary 切换当前活跃的知识库到指定的 Library 记录所对应的 .db 文件。
|
||||
//
|
||||
// 调用时机:用户在 LibraryBar 下拉框中选择了某个知识库,
|
||||
// 或程序启动时 InitLibraries 恢复上次的选择。
|
||||
//
|
||||
// 注意:OpenLibrary 不关闭旧连接,GORM 底层连接池会自动管理;
|
||||
// 这使切换库操作无需等待旧连接关闭,响应更快。
|
||||
func OpenLibrary(lib models.Library) error {
|
||||
db, err := openSQLite(lib.FilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open library %q: %w", lib.Name, err)
|
||||
}
|
||||
// 每次打开都 AutoMigrate,保证旧版本数据库在新版 Entry schema 下能正常工作
|
||||
if err := db.AutoMigrate(&models.Entry{}); err != nil {
|
||||
return fmt.Errorf("migrate library %q: %w", lib.Name, err)
|
||||
}
|
||||
@@ -60,7 +110,7 @@ func OpenLibrary(lib models.Library) error {
|
||||
activeLib = db
|
||||
activeLibNm = lib.Name
|
||||
mu.Unlock()
|
||||
// Persist preference
|
||||
// 持久化"上次活跃库"偏好,使用 UPSERT 避免 duplicate key 错误
|
||||
settingsDB.Exec(
|
||||
"INSERT INTO app_settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
"active_library", lib.Name,
|
||||
@@ -69,7 +119,10 @@ func OpenLibrary(lib models.Library) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewLibraryDB creates a fresh SQLite DB at path and migrates the Entry schema.
|
||||
// NewLibraryDB 在指定路径创建一个全新的知识库 SQLite 文件并建表。
|
||||
//
|
||||
// 物理创建而非逻辑创建——每个知识库是真实独立的文件,
|
||||
// 天然隔离,用户可直接在 Finder 中看到、备份或删除。
|
||||
func NewLibraryDB(path string) error {
|
||||
db, err := openSQLite(path)
|
||||
if err != nil {
|
||||
@@ -78,47 +131,65 @@ func NewLibraryDB(path string) error {
|
||||
return db.AutoMigrate(&models.Entry{})
|
||||
}
|
||||
|
||||
// NewLibraryDBReadOnly opens an existing SQLite DB read-only (for counting etc).
|
||||
// NewLibraryDBReadOnly 以只读模式打开一个已有的知识库文件。
|
||||
//
|
||||
// 专用于"条目计数"等查询场景,避免只读操作意外创建 WAL 日志文件
|
||||
// 或触发写锁,适合在列出知识库列表时并发调用。
|
||||
func NewLibraryDBReadOnly(path string) (*gorm.DB, error) {
|
||||
// ?mode=ro 是 SQLite URI 参数,让驱动以 SQLITE_OPEN_READONLY 打开
|
||||
return openSQLite(path + "?mode=ro")
|
||||
}
|
||||
|
||||
// GetSettings returns the global settings DB (AppSetting + Library tables).
|
||||
// GetSettings 返回全局设置数据库的 *gorm.DB 实例。
|
||||
// 调用方应先检查返回值是否为 nil(Init 尚未调用时可能为 nil)。
|
||||
func GetSettings() *gorm.DB {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return settingsDB
|
||||
}
|
||||
|
||||
// Get returns the active knowledge library DB.
|
||||
// Get 返回当前活跃知识库的 *gorm.DB 实例。
|
||||
// 在任何 CRUD 操作前都应先调用此函数,若返回 nil 则知识库尚未选定。
|
||||
func Get() *gorm.DB {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return activeLib
|
||||
}
|
||||
|
||||
// GetActiveLibName returns the display name of the currently open library.
|
||||
// GetActiveLibName 返回当前活跃知识库的显示名称(空字符串表示尚未打开)。
|
||||
func GetActiveLibName() string {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return activeLibNm
|
||||
}
|
||||
|
||||
// IsReady reports whether both the settings DB and an active library are open.
|
||||
// IsReady 报告系统是否"就绪":设置库已初始化 且 有活跃知识库。
|
||||
// 前端通过 GetDBStatus() 轮询此函数,决定是否显示"本地 SQLite"绿点。
|
||||
func IsReady() bool {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
return settingsDB != nil && activeLib != nil
|
||||
}
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||
// ── 内部辅助函数 ───────────────────────────────────────────────────────────────
|
||||
|
||||
// openSQLite 使用 glebarez/sqlite 纯 Go 驱动打开 SQLite 文件。
|
||||
//
|
||||
// 选用 glebarez/sqlite(而非 mattn/go-sqlite3)的原因:
|
||||
// 纯 Go 实现,无需 CGO,可以 GOOS=darwin/linux/windows 直接交叉编译,
|
||||
// 大幅简化 CI/CD 和分发流程,且兼容 GORM 接口。
|
||||
func openSQLite(path string) (*gorm.DB, error) {
|
||||
return gorm.Open(sqlite.Open(path), &gorm.Config{
|
||||
// Warn 级别:只记录慢查询和错误,避免调试日志污染生产输出
|
||||
Logger: logger.Default.LogMode(logger.Warn),
|
||||
})
|
||||
}
|
||||
|
||||
// appDataDir 返回应用推荐的数据目录。
|
||||
// macOS: ~/Library/Application Support/AI-Expert-Sidebar/
|
||||
// Linux: ~/.config/AI-Expert-Sidebar/
|
||||
// Windows: %AppData%\AI-Expert-Sidebar\
|
||||
// 使用 os.UserConfigDir() 而非硬编码路径,保证跨平台兼容。
|
||||
func appDataDir() (string, error) {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
|
||||
+40
-10
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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"` // 建议默认使用的模型哈希
|
||||
}
|
||||
|
||||
@@ -1,16 +1,48 @@
|
||||
// Package models 定义知识库 .db 文件中的核心业务表。
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Entry is a single Q&A row in a knowledge library .db file.
|
||||
// Entry 是知识库中的一条问答记录,存储于各知识库 .db 文件的 entries 表。
|
||||
//
|
||||
// # 设计说明
|
||||
//
|
||||
// Entry 不包含 user_id 字段——这是本架构与传统多用户方案的核心区别。
|
||||
// 数据隔离通过"切换不同 .db 文件"实现(物理隔离),而非逻辑隔离。
|
||||
// 这意味着:
|
||||
// - 每个知识库文件就是一个完全独立的数据源;
|
||||
// - 任何针对 Entry 的查询都自动限定在当前活跃的知识库内;
|
||||
// - 不存在因遗漏 WHERE user_id = ? 而导致跨库数据泄露的风险。
|
||||
//
|
||||
// # 字段说明
|
||||
//
|
||||
// Keyword 是搜索的主要匹配目标(经过 INDEX 加速),
|
||||
// Question 存放完整的问题文本(兼做二级匹配),
|
||||
// Answer 存放答案全文(供 AI 润色时作为原始素材)。
|
||||
type Entry struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Keyword string `gorm:"index;size:255;not null" json:"keyword"`
|
||||
Question string `gorm:"type:text;not null" json:"question"`
|
||||
Answer string `gorm:"type:text;not null" json:"answer"`
|
||||
Category string `gorm:"size:100;default:'通用'" json:"category"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
|
||||
// Keyword 是精简的检索关键词,例如 "退款政策"、"浇水频率"。
|
||||
// 添加了 GORM 索引(B-Tree),使 LIKE '%keyword%' 查询更快。
|
||||
// size:255 对应 TEXT NOT NULL,防止存入过长字符串或空值。
|
||||
Keyword string `gorm:"index;size:255;not null" json:"keyword"`
|
||||
|
||||
// Question 是完整的问题描述,作为搜索的二级匹配字段。
|
||||
// type:text 映射到 SQLite TEXT 类型,无长度限制。
|
||||
Question string `gorm:"type:text;not null" json:"question"`
|
||||
|
||||
// Answer 是对应答案全文,也是传给 AI 做"润色"的原始素材。
|
||||
Answer string `gorm:"type:text;not null" json:"answer"`
|
||||
|
||||
// Category 用于前端分类展示和颜色区分(例如 "浇水"、"病虫害")。
|
||||
// default:'通用' 保证没填分类时有默认值,避免前端渲染空标签。
|
||||
Category string `gorm:"size:100;default:'通用'" json:"category"`
|
||||
|
||||
// GORM 约定字段:自动维护创建时间和最后更新时间。
|
||||
// UpdatedAt 用于在搜索无结果时排序返回"最近更新"的热门问答。
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 显式指定表名为 "entries"(避免 GORM 默认复数化为 "entries" / "entris")。
|
||||
func (Entry) TableName() string { return "entries" }
|
||||
|
||||
+44
-11
@@ -1,25 +1,58 @@
|
||||
// Package models 定义所有 GORM 数据模型(对应 SQLite 数据表)。
|
||||
//
|
||||
// 本文件包含两个存在于 settings.db 中的模型:
|
||||
// - AppSetting: 简单的键值配置表,用于存 AI 参数和用户偏好。
|
||||
// - Library: 知识库注册表,每行指向一个独立的 .db 文件。
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// AppSetting is a key-value store for global application settings
|
||||
// (AI provider, endpoint, API key, etc.) in settings.db.
|
||||
// AppSetting 是存储于 settings.db 的全局键值对配置表。
|
||||
//
|
||||
// 选择键值对(而非定义强类型结构体)的原因:
|
||||
// 1. 配置项未来可能扩展(新增 AI 提供商、新增偏好项),KV 表无需迁移。
|
||||
// 2. 加密后的 API Key(二进制)和普通字符串值可以统一存储。
|
||||
// 3. UPSERT(INSERT ... ON CONFLICT DO UPDATE)操作极为简单。
|
||||
//
|
||||
// 典型键名:
|
||||
// - "ai_provider" / "base_url" / "model"
|
||||
// - "api_key_encrypted"(AES-256-GCM 密文 base64)
|
||||
// - "system_prompt" / "max_tokens" / "use_public_key"
|
||||
// - "active_library" (上次使用的知识库名,用于启动时恢复)
|
||||
type AppSetting struct {
|
||||
Key string `gorm:"primaryKey;size:100" json:"key"`
|
||||
// Key 使用 primaryKey,保证同名键全局唯一,UPSERT 可直接依赖此约束。
|
||||
Key string `gorm:"primaryKey;size:100" json:"key"`
|
||||
// Value 使用 text 而非 varchar,支持存储较长内容(如系统提示词)。
|
||||
Value string `gorm:"type:text" json:"value"`
|
||||
}
|
||||
|
||||
// TableName 显式声明表名,避免 GORM 的复数化规则(AppSettings → app_settings)
|
||||
// 在不同 GORM 版本间产生歧义。
|
||||
func (AppSetting) TableName() string { return "app_settings" }
|
||||
|
||||
// Library represents a registered knowledge library in settings.db.
|
||||
// Each library is a separate SQLite file.
|
||||
// Library 是知识库注册表,存储于 settings.db。
|
||||
//
|
||||
// 每条 Library 记录描述一个独立的知识库 .db 文件。
|
||||
//
|
||||
// # 物理隔离设计
|
||||
//
|
||||
// 传统多租户方案在同一张表里用 user_id 或 namespace 做逻辑隔离,
|
||||
// 一旦查询遗漏 WHERE 条件就会产生跨库数据泄露。
|
||||
// 本项目改用"一个知识库 = 一个独立 .db 文件"的物理隔离方案:
|
||||
// - 数据泄露风险从根本上消除;
|
||||
// - 用户可以在 Finder 中直接拷贝/分享单个 .db 文件;
|
||||
// - 切换知识库等同于更换数据库连接,极为清晰。
|
||||
type Library struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
Name string `gorm:"uniqueIndex;size:100;not null" json:"name"`
|
||||
Description string `gorm:"size:255" json:"description"`
|
||||
FilePath string `gorm:"size:1024;not null" json:"file_path"`
|
||||
EntryCount int `gorm:"-" json:"entry_count"` // populated on read
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
// Name 添加 uniqueIndex 防止重复注册同名知识库。
|
||||
Name string `gorm:"uniqueIndex;size:100;not null" json:"name"`
|
||||
Description string `gorm:"size:255" json:"description"`
|
||||
// FilePath 存储 .db 文件的绝对路径,程序通过此路径打开对应 SQLite 连接。
|
||||
FilePath string `gorm:"size:1024;not null" json:"file_path"`
|
||||
// EntryCount 使用 gorm:"-" 标签,表示此字段不映射到数据库列,
|
||||
// 而是在 service 层查询后动态填充,供前端展示。
|
||||
EntryCount int `gorm:"-" json:"entry_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (Library) TableName() string { return "libraries" }
|
||||
|
||||
@@ -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