init: initial commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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" }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
Reference in New Issue
Block a user