init: initial commit

This commit is contained in:
Blizzard
2026-04-01 14:09:33 +08:00
commit aef2e152dc
66 changed files with 6540 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
package config
import (
"fmt"
"github.com/spf13/viper"
)
type DatabaseConfig struct {
DSN string `mapstructure:"dsn"`
}
type DeepSeekConfig struct {
APIKey string `mapstructure:"api_key"`
Model string `mapstructure:"model"`
TimeoutSeconds int `mapstructure:"timeout_seconds"`
MaxTokens int `mapstructure:"max_tokens"`
}
type AppConfig struct {
Database DatabaseConfig `mapstructure:"database"`
DeepSeek DeepSeekConfig `mapstructure:"deepseek"`
}
var Global AppConfig
func Load() error {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
viper.AddConfigPath("$HOME/.ai-expert-sidebar")
// Defaults
viper.SetDefault("database.dsn",
"root:password@tcp(127.0.0.1:3306)/ai_expert?charset=utf8mb4&parseTime=True&loc=Local")
viper.SetDefault("deepseek.api_key", "")
viper.SetDefault("deepseek.model", "deepseek-chat")
viper.SetDefault("deepseek.timeout_seconds", 60)
viper.SetDefault("deepseek.max_tokens", 1024)
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return fmt.Errorf("config read error: %w", err)
}
}
return viper.Unmarshal(&Global)
}
+65
View File
@@ -0,0 +1,65 @@
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"io"
)
const appSecret = "ai-expert-sidebar-v1-local-©2026"
// deriveKey produces a 32-byte AES key from the application secret.
func deriveKey() []byte {
sum := sha256.Sum256([]byte(appSecret))
return sum[:]
}
// EncryptAPIKey encrypts a plaintext API key. Returns "" for empty input.
func EncryptAPIKey(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
block, err := aes.NewCipher(deriveKey())
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return "", err
}
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.
func DecryptAPIKey(ciphertext64 string) (string, error) {
if ciphertext64 == "" {
return "", nil
}
data, err := base64.StdEncoding.DecodeString(ciphertext64)
if err != nil {
return "", fmt.Errorf("base64 decode: %w", err)
}
block, err := aes.NewCipher(deriveKey())
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
ns := gcm.NonceSize()
if len(data) < ns {
return "", fmt.Errorf("ciphertext too short")
}
plain, err := gcm.Open(nil, data[:ns], data[ns:], nil)
return string(plain), err
}
+128
View File
@@ -0,0 +1,128 @@
package database
import (
"fmt"
"log"
"os"
"path/filepath"
"sync"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"AI-Expert-Sidebar/internal/models"
)
var (
mu sync.RWMutex
settingsDB *gorm.DB
activeLib *gorm.DB
activeLibNm string
DataDir string
)
// Init opens/creates the settings database ($HOME/Library/Application Support/AI-Expert-Sidebar/settings.db).
func Init() error {
dir, err := appDataDir()
if err != nil {
return err
}
DataDir = dir
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("create data dir: %w", err)
}
db, err := openSQLite(filepath.Join(dir, "settings.db"))
if err != nil {
return fmt.Errorf("open settings.db: %w", err)
}
if err := db.AutoMigrate(&models.AppSetting{}, &models.Library{}); err != nil {
return fmt.Errorf("migrate settings schema: %w", err)
}
mu.Lock()
settingsDB = db
mu.Unlock()
log.Printf("[DB] Settings DB ready at %s/settings.db", dir)
return nil
}
// OpenLibrary switches the active knowledge library.
func OpenLibrary(lib models.Library) error {
db, err := openSQLite(lib.FilePath)
if err != nil {
return fmt.Errorf("open library %q: %w", lib.Name, err)
}
if err := db.AutoMigrate(&models.Entry{}); err != nil {
return fmt.Errorf("migrate library %q: %w", lib.Name, err)
}
mu.Lock()
activeLib = db
activeLibNm = lib.Name
mu.Unlock()
// Persist preference
settingsDB.Exec(
"INSERT INTO app_settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
"active_library", lib.Name,
)
log.Printf("[DB] Active library: %s", lib.Name)
return nil
}
// NewLibraryDB creates a fresh SQLite DB at path and migrates the Entry schema.
func NewLibraryDB(path string) error {
db, err := openSQLite(path)
if err != nil {
return err
}
return db.AutoMigrate(&models.Entry{})
}
// NewLibraryDBReadOnly opens an existing SQLite DB read-only (for counting etc).
func NewLibraryDBReadOnly(path string) (*gorm.DB, error) {
return openSQLite(path + "?mode=ro")
}
// GetSettings returns the global settings DB (AppSetting + Library tables).
func GetSettings() *gorm.DB {
mu.RLock()
defer mu.RUnlock()
return settingsDB
}
// Get returns the active knowledge library DB.
func Get() *gorm.DB {
mu.RLock()
defer mu.RUnlock()
return activeLib
}
// GetActiveLibName returns the display name of the currently open library.
func GetActiveLibName() string {
mu.RLock()
defer mu.RUnlock()
return activeLibNm
}
// IsReady reports whether both the settings DB and an active library are open.
func IsReady() bool {
mu.RLock()
defer mu.RUnlock()
return settingsDB != nil && activeLib != nil
}
// ── helpers ───────────────────────────────────────────────────────────────────
func openSQLite(path string) (*gorm.DB, error) {
return gorm.Open(sqlite.Open(path), &gorm.Config{
Logger: logger.Default.LogMode(logger.Warn),
})
}
func appDataDir() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("user config dir: %w", err)
}
return filepath.Join(dir, "AI-Expert-Sidebar"), nil
}
+122
View File
@@ -0,0 +1,122 @@
package handler
import (
"context"
"fmt"
"log"
"strings"
"sync"
"AI-Expert-Sidebar/internal/database"
"AI-Expert-Sidebar/internal/service"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// Expert handles search and AI streaming for the active library.
type Expert struct {
ctx context.Context
stopMu sync.Mutex
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.
func (e *Expert) SearchExpert(query string) []service.SearchResult {
results, err := service.SearchKnowledge(query)
if err != nil {
log.Printf("[SearchExpert] %v", err)
return nil
}
return results
}
// AskDeepSeek performs RAG + streaming AI call.
func (e *Expert) AskDeepSeek(query, rawAnswer string) string {
aiCfg := service.ResolveAIConfig()
knowledgeCtx := e.buildKnowledgeContext(query)
var userMsg string
if rawAnswer != "" {
userMsg = fmt.Sprintf("用户问题:%s\n\n原始参考答案:%s", query, rawAnswer)
} else {
userMsg = fmt.Sprintf("用户问题:%s\n\n请直接回答上述问题。", query)
}
messages := service.BuildRAGMessages(knowledgeCtx, userMsg, aiCfg.SystemPrompt)
streamCh := make(chan string, 64)
var sb strings.Builder
streamCtx, cancel := context.WithCancel(e.ctx)
e.setStopCancel(cancel)
go func() {
defer func() { cancel(); close(streamCh) }()
if err := service.CallDeepSeekStream(streamCtx, aiCfg, messages, streamCh); err != nil {
if streamCtx.Err() == context.Canceled {
streamCh <- "__STOPPED__"
} else {
log.Printf("[AskDeepSeek] %v", err)
streamCh <- "__ERROR__"
}
}
}()
for chunk := range streamCh {
switch chunk {
case "__ERROR__":
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)
}
}
runtime.EventsEmit(e.ctx, "ai:done", sb.String())
return sb.String()
}
func (e *Expert) StopGeneration() {
e.stopMu.Lock()
defer e.stopMu.Unlock()
if e.stopCancel != nil {
e.stopCancel()
e.stopCancel = nil
}
}
func (e *Expert) GetDBStatus() bool { return database.IsReady() }
func (e *Expert) ToggleTopmost(enabled bool) {
runtime.WindowSetAlwaysOnTop(e.ctx, enabled)
}
func (e *Expert) buildKnowledgeContext(query string) string {
results, err := service.SearchKnowledge(query)
if err != nil || len(results) == 0 {
return "(无相关本地知识)"
}
limit := 3
if len(results) < limit {
limit = len(results)
}
var sb strings.Builder
for i := 0; i < limit; i++ {
r := results[i]
sb.WriteString(fmt.Sprintf("%d. Q: %s\n A: %s\n 分类: %s\n", i+1, r.Question, r.Answer, r.Category))
}
return sb.String()
}
func (e *Expert) setStopCancel(fn context.CancelFunc) {
e.stopMu.Lock()
defer e.stopMu.Unlock()
if e.stopCancel != nil {
e.stopCancel()
}
e.stopCancel = fn
}
+94
View File
@@ -0,0 +1,94 @@
package handler
import (
"context"
"AI-Expert-Sidebar/internal/database"
"AI-Expert-Sidebar/internal/service"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// LibraryHandler exposes library management and CSV import via Wails bindings.
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.
func (h *LibraryHandler) ListLibraries() []LibraryInfo {
libs, err := service.ListLibraries()
if err != nil {
return nil
}
out := make([]LibraryInfo, len(libs))
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(),
}
}
return out
}
// GetActiveLibrary returns the name of the currently active library.
func (h *LibraryHandler) GetActiveLibrary() string {
return database.GetActiveLibName()
}
// CreateLibrary registers a new knowledge library.
func (h *LibraryHandler) CreateLibrary(name, description string) string {
if name == "" {
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.
func (h *LibraryHandler) SwitchLibrary(name string) string {
if err := service.SwitchLibrary(name); err != nil {
return err.Error()
}
return ""
}
// DeleteLibrary removes a library from the registry (file is kept).
func (h *LibraryHandler) DeleteLibrary(name string) string {
if name == database.GetActiveLibName() {
return "不能删除当前使用中的知识库,请先切换到其他库"
}
if err := service.DeleteLibrary(name, false); err != nil {
return err.Error()
}
return ""
}
// ImportCSV opens a native file dialog then imports CSV data into the active library.
func (h *LibraryHandler) ImportCSV() service.ImportResult {
filePath, err := runtime.OpenFileDialog(h.ctx, runtime.OpenDialogOptions{
Title: "选择 CSV 文件",
Filters: []runtime.FileFilter{
{DisplayName: "CSV 文件", Pattern: "*.csv"},
{DisplayName: "所有文件", Pattern: "*"},
},
})
if err != nil || filePath == "" {
return service.ImportResult{Error: "已取消"}
}
return service.ImportCSV(filePath)
}
// LibraryInfo is the frontend-facing representation of a library.
type LibraryInfo struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
EntryCount int `json:"entry_count"`
IsActive bool `json:"is_active"`
}
+45
View File
@@ -0,0 +1,45 @@
package handler
import (
"context"
"AI-Expert-Sidebar/internal/service"
)
// SettingsHandler exposes AI settings CRUD via Wails bindings.
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.
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.
func (s *SettingsHandler) SaveSettings(dto service.SettingsDTO) string {
if err := service.SaveSettings(dto); err != nil {
return err.Error()
}
return ""
}
// GetProviders returns built-in AI provider presets for the frontend dropdown.
func (s *SettingsHandler) GetProviders() []ProviderPreset {
return []ProviderPreset{
{ID: "deepseek", Label: "DeepSeek", BaseURL: "https://api.deepseek.com/chat/completions", DefaultModel: "deepseek-chat"},
{ID: "openai", Label: "OpenAI", BaseURL: "https://api.openai.com/v1/chat/completions", DefaultModel: "gpt-4o"},
{ID: "grok", Label: "Grok (xAI)", BaseURL: "https://api.x.ai/v1/chat/completions", DefaultModel: "grok-3"},
{ID: "custom", Label: "自定义", BaseURL: "", DefaultModel: ""},
}
}
// ProviderPreset describes a known AI provider with preset URL and model.
type ProviderPreset struct {
ID string `json:"id"`
Label string `json:"label"`
BaseURL string `json:"base_url"`
DefaultModel string `json:"default_model"`
}
+16
View File
@@ -0,0 +1,16 @@
package models
import "time"
// Entry is a single Q&A row in a knowledge library .db file.
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"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Entry) TableName() string { return "entries" }
+25
View File
@@ -0,0 +1,25 @@
package models
import "time"
// AppSetting is a key-value store for global application settings
// (AI provider, endpoint, API key, etc.) in settings.db.
type AppSetting struct {
Key string `gorm:"primaryKey;size:100" json:"key"`
Value string `gorm:"type:text" json:"value"`
}
func (AppSetting) TableName() string { return "app_settings" }
// Library represents a registered knowledge library in settings.db.
// Each library is a separate SQLite file.
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"`
}
func (Library) TableName() string { return "libraries" }
+145
View File
@@ -0,0 +1,145 @@
package service
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// AICallConfig holds the resolved configuration for a single AI API call.
type AICallConfig struct {
BaseURL string
APIKey string
Model string
MaxTokens int
SystemPrompt string
}
// ── Request / Response types (OpenAI-compatible format) ──────────────────────
type dsMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type dsRequest struct {
Model string `json:"model"`
Messages []dsMessage `json:"messages"`
Stream bool `json:"stream"`
MaxTokens int `json:"max_tokens,omitempty"`
}
type dsDelta struct {
Content string `json:"content"`
}
type dsChoice struct {
Delta dsDelta `json:"delta"`
FinishReason string `json:"finish_reason"`
}
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.
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,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
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("Authorization", "Bearer "+cfg.APIKey)
resp, err := client.Do(req)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("http request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("upstream status %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.
func BuildRAGMessages(knowledgeContext, userQuery, customSystemPrompt string) []dsMessage {
var systemContent string
if customSystemPrompt != "" {
systemContent = customSystemPrompt
if knowledgeContext != "" && knowledgeContext != "(无相关本地知识)" {
systemContent += "\n\n以下是本地知识库中的相关内容供参考:\n---\n" + knowledgeContext + "\n---"
}
} else {
systemContent = fmt.Sprintf(
"你是一位专业的植物养护和客服顾问,擅长用温暖、自然、有亲和力的语气沟通。\n\n"+
"以下是来自本地知识库的相关内容,请优先参考:\n\n---\n%s\n---\n\n"+
"根据以上知识润色话术,直接输出内容,不加前缀或解释。",
knowledgeContext,
)
}
return []dsMessage{
{Role: "system", Content: systemContent},
{Role: "user", Content: userQuery},
}
}
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()
}
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
break
}
var event dsSSELine
if err := json.Unmarshal([]byte(data), &event); err != nil {
continue
}
if len(event.Choices) > 0 {
if chunk := event.Choices[0].Delta.Content; chunk != "" {
ch <- chunk
}
}
}
return scanner.Err()
}
+90
View File
@@ -0,0 +1,90 @@
package service
import (
"encoding/csv"
"fmt"
"io"
"os"
"strings"
"AI-Expert-Sidebar/internal/database"
"AI-Expert-Sidebar/internal/models"
)
// ImportResult summarises the outcome of a CSV import.
type ImportResult struct {
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Error string `json:"error,omitempty"`
}
// ImportCSV reads a CSV file and inserts records into the active knowledge library.
//
// Required columns (case-insensitive): keyword, question, answer
// Optional column: category (defaults to "通用")
//
// The first row must be the header.
func ImportCSV(filePath string) ImportResult {
f, err := os.Open(filePath)
if err != nil {
return ImportResult{Error: fmt.Sprintf("无法打开文件: %v", err)}
}
defer f.Close()
db := database.Get()
if db == nil {
return ImportResult{Error: "知识库未初始化"}
}
r := csv.NewReader(f)
r.TrimLeadingSpace = true
r.LazyQuotes = true
// Read and normalise header
header, err := r.Read()
if err != nil {
return ImportResult{Error: fmt.Sprintf("读取表头失败: %v", err)}
}
colIdx := make(map[string]int)
for i, h := range header {
colIdx[strings.ToLower(strings.TrimSpace(h))] = i
}
for _, required := range []string{"keyword", "question", "answer"} {
if _, ok := colIdx[required]; !ok {
return ImportResult{Error: fmt.Sprintf("CSV 缺少必需列: %q (需要: keyword, question, answer)", required)}
}
}
catIdx, hasCat := colIdx["category"]
var imported, skipped int
for {
row, err := r.Read()
if err == io.EOF {
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
}
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}
}
+131
View File
@@ -0,0 +1,131 @@
package service
import (
"fmt"
"log"
"os"
"path/filepath"
"time"
"AI-Expert-Sidebar/internal/database"
"AI-Expert-Sidebar/internal/models"
)
// ListLibraries returns all registered knowledge libraries with entry counts.
func ListLibraries() ([]models.Library, error) {
sdb := database.GetSettings()
if sdb == nil {
return nil, fmt.Errorf("settings DB not ready")
}
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.
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)
}
lib := models.Library{Name: name, Description: description, FilePath: filePath}
if err := sdb.Create(&lib).Error; err != nil {
os.Remove(filePath) // rollback file
return nil, err
}
log.Printf("[Library] Created: %s → %s", name, filePath)
return &lib, nil
}
// SwitchLibrary makes the named library active.
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 database.OpenLibrary(lib)
}
// DeleteLibrary removes a library from the registry (and optionally its file).
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)
}
if err := sdb.Delete(&lib).Error; err != nil {
return err
}
if deleteFile {
return os.Remove(lib.FilePath)
}
return nil
}
// InitLibraries restores the last active library or creates the default one.
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
}
}
// 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("默认知识库", "自动创建的默认知识库")
if err != nil {
return err
}
return database.OpenLibrary(*lib2)
}
// ── helpers ───────────────────────────────────────────────────────────────────
func countEntries(filePath string) int {
db, err := database.NewLibraryDBReadOnly(filePath)
if err != nil {
return -1
}
var count int64
db.Model(&models.Entry{}).Count(&count)
return int(count)
}
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 == '|' {
result = append(result, '_')
} else {
result = append(result, r)
}
}
if len(result) == 0 {
return "library"
}
return string(result)
}
+114
View File
@@ -0,0 +1,114 @@
package service
import (
"errors"
"log"
"AI-Expert-Sidebar/internal/database"
"AI-Expert-Sidebar/internal/models"
)
const maxResults = 5
var ErrDBUnavailable = errors.New("database unavailable")
// SearchResult is the DTO returned to the frontend.
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"`
}
// SearchKnowledge performs fuzzy search in the active knowledge library.
func SearchKnowledge(query string) ([]SearchResult, error) {
db := database.Get()
if db == nil {
return nil, ErrDBUnavailable
}
if len(query) < 1 {
return nil, nil
}
type res struct {
rows []SearchResult
err error
}
ch := make(chan res, 1)
go func() {
var total int64
db.Model(&models.Entry{}).Count(&total)
if total == 0 {
ch <- res{[]SearchResult{}, nil}
return
}
like := "%" + query + "%"
var rows []models.Entry
err := db.Where("keyword LIKE ? OR question LIKE ?", like, like).
Order("updated_at DESC").Limit(maxResults).Find(&rows).Error
if err != nil {
ch <- res{nil, err}
return
}
isFallback := len(rows) == 0
if isFallback {
log.Printf("[Search] No match for %q, returning fallback", query)
db.Order("updated_at DESC").Limit(3).Find(&rows)
}
out := make([]SearchResult, 0, len(rows))
for _, r := range rows {
score := 0
if !isFallback {
if containsIgnoreCase(r.Keyword, query) {
score = 2
} else if containsIgnoreCase(r.Question, query) {
score = 1
}
}
out = append(out, SearchResult{
ID: r.ID, Question: r.Question, Answer: r.Answer,
Category: r.Category, Score: score, IsFallback: isFallback,
})
}
ch <- res{out, nil}
}()
r := <-ch
return r.rows, r.err
}
func containsIgnoreCase(s, sub string) bool {
if len(sub) == 0 {
return true
}
sl, subl := []rune(s), []rune(sub)
if len(sl) < len(subl) {
return false
}
for i := 0; i <= len(sl)-len(subl); i++ {
match := true
for j, c := range subl {
if toLower(sl[i+j]) != toLower(c) {
match = false
break
}
}
if match {
return true
}
}
return false
}
func toLower(r rune) rune {
if r >= 'A' && r <= 'Z' {
return r + 32
}
return r
}
+131
View File
@@ -0,0 +1,131 @@
package service
import (
"fmt"
"AI-Expert-Sidebar/internal/config"
"AI-Expert-Sidebar/internal/crypto"
"AI-Expert-Sidebar/internal/database"
"AI-Expert-Sidebar/internal/models"
)
// SettingsDTO is what the frontend reads and writes.
type SettingsDTO struct {
AIProvider string `json:"ai_provider"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key"`
Model string `json:"model"`
SystemPrompt string `json:"system_prompt"`
MaxTokens int `json:"max_tokens"`
UsePublicKey bool `json:"use_public_key"`
}
// GetSettings reads AI config from settings.db key-value store.
func GetSettings() *SettingsDTO {
sdb := database.GetSettings()
if sdb == nil {
return defaultDTO()
}
var rows []models.AppSetting
sdb.Find(&rows)
m := make(map[string]string, len(rows))
for _, r := range rows {
m[r.Key] = r.Value
}
apiKey, _ := crypto.DecryptAPIKey(m["api_key_encrypted"])
maxTokens := 1024
fmt.Sscanf(m["max_tokens"], "%d", &maxTokens)
if maxTokens <= 0 {
maxTokens = 1024
}
return &SettingsDTO{
AIProvider: strOr(m["ai_provider"], "deepseek"),
BaseURL: m["base_url"],
APIKey: apiKey,
Model: strOr(m["model"], "deepseek-chat"),
SystemPrompt: m["system_prompt"],
MaxTokens: maxTokens,
UsePublicKey: m["use_public_key"] != "false",
}
}
// SaveSettings persists AI config into settings.db.
func SaveSettings(dto SettingsDTO) error {
sdb := database.GetSettings()
if sdb == nil {
return fmt.Errorf("settings DB not ready")
}
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)
if !dto.UsePublicKey && dto.APIKey != "" {
enc, err := crypto.EncryptAPIKey(dto.APIKey)
if err != nil {
return err
}
upsert("api_key_encrypted", enc)
}
return nil
}
// ResolveAIConfig returns the effective AI call config (local settings or global fallback).
func ResolveAIConfig() AICallConfig {
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
}
base.SystemPrompt = dto.SystemPrompt
if dto.UsePublicKey || dto.APIKey == "" {
return base
}
providerURL := dto.BaseURL
if providerURL == "" {
switch dto.AIProvider {
case "deepseek":
providerURL = "https://api.deepseek.com/chat/completions"
case "openai":
providerURL = "https://api.openai.com/v1/chat/completions"
case "grok":
providerURL = "https://api.x.ai/v1/chat/completions"
}
}
maxTok := dto.MaxTokens
if maxTok <= 0 {
maxTok = 1024
}
return AICallConfig{
BaseURL: strOr(providerURL, base.BaseURL),
APIKey: dto.APIKey,
Model: strOr(dto.Model, base.Model),
MaxTokens: maxTok,
SystemPrompt: dto.SystemPrompt,
}
}
func strOr(v, def string) string {
if v == "" {
return def
}
return v
}
func defaultDTO() *SettingsDTO {
return &SettingsDTO{AIProvider: "deepseek", Model: "deepseek-chat", MaxTokens: 1024, UsePublicKey: true}
}