diff --git a/app.go b/app.go index c525453..b7db3e8 100644 --- a/app.go +++ b/app.go @@ -11,17 +11,19 @@ import ( ) type App struct { - ctx context.Context - expert *handler.Expert - settings *handler.SettingsHandler - library *handler.LibraryHandler + ctx context.Context + expert *handler.Expert + settings *handler.SettingsHandler + library *handler.LibraryHandler + knowledge *handler.KnowledgeOps } func NewApp() *App { return &App{ - expert: handler.NewExpert(), - settings: handler.NewSettingsHandler(), - library: handler.NewLibraryHandler(), + expert: handler.NewExpert(), + settings: handler.NewSettingsHandler(), + library: handler.NewLibraryHandler(), + knowledge: handler.NewKnowledgeOps(), } } @@ -30,12 +32,13 @@ func (a *App) startup(ctx context.Context) { a.expert.SetContext(ctx) a.settings.SetContext(ctx) a.library.SetContext(ctx) + a.knowledge.SetContext(ctx) if err := config.Load(); err != nil { log.Printf("[App] Config warning: %v", err) } if err := database.Init(); err != nil { - log.Printf("[App] DB init warning: %v", err) + log.Printf("[App] DB warning: %v", err) } if err := service.InitLibraries(); err != nil { log.Printf("[App] Library init warning: %v", err) @@ -44,51 +47,33 @@ func (a *App) startup(ctx context.Context) { // ── Library ─────────────────────────────────────────────────────────────────── -func (a *App) ListLibraries() []handler.LibraryInfo { - return a.library.ListLibraries() -} -func (a *App) GetActiveLibrary() string { - return a.library.GetActiveLibrary() -} +func (a *App) ListLibraries() []handler.LibraryInfo { return a.library.ListLibraries() } +func (a *App) GetActiveLibrary() string { return a.library.GetActiveLibrary() } func (a *App) CreateLibrary(name, description string) string { return a.library.CreateLibrary(name, description) } -func (a *App) SwitchLibrary(name string) string { - return a.library.SwitchLibrary(name) -} -func (a *App) DeleteLibrary(name string) string { - return a.library.DeleteLibrary(name) -} -func (a *App) ImportCSV() service.ImportResult { - return a.library.ImportCSV() -} +func (a *App) SwitchLibrary(name string) string { return a.library.SwitchLibrary(name) } +func (a *App) DeleteLibrary(name string) string { return a.library.DeleteLibrary(name) } +func (a *App) ImportCSV() service.ImportResult { return a.library.ImportCSV() } +func (a *App) ImportExcel() service.ImportResult { return a.library.ImportExcel() } + +// ── Knowledge ops ───────────────────────────────────────────────────────────── + +func (a *App) DeleteItems(ids []uint) string { return a.knowledge.DeleteItems(ids) } +func (a *App) ClearDatabase() string { return a.knowledge.ClearDatabase() } // ── Settings ────────────────────────────────────────────────────────────────── -func (a *App) GetSettings() *service.SettingsDTO { - return a.settings.GetSettings() -} -func (a *App) SaveSettings(dto service.SettingsDTO) string { - return a.settings.SaveSettings(dto) -} -func (a *App) GetProviders() []handler.ProviderPreset { - return a.settings.GetProviders() -} +func (a *App) GetSettings() *service.SettingsDTO { return a.settings.GetSettings() } +func (a *App) SaveSettings(dto service.SettingsDTO) string { return a.settings.SaveSettings(dto) } +func (a *App) GetProviders() []handler.ProviderPreset { return a.settings.GetProviders() } // ── Expert ──────────────────────────────────────────────────────────────────── -func (a *App) SearchExpert(query string) interface{} { - return a.expert.SearchExpert(query) -} +func (a *App) SearchExpert(query string) interface{} { return a.expert.SearchExpert(query) } func (a *App) AskDeepSeek(query, rawAnswer string) string { return a.expert.AskDeepSeek(query, rawAnswer) } -func (a *App) StopGeneration() { - a.expert.StopGeneration() -} -func (a *App) ToggleTopmost(enabled bool) { - a.expert.ToggleTopmost(enabled) -} -func (a *App) GetDBStatus() bool { - return a.expert.GetDBStatus() -} +func (a *App) StopGeneration() { a.expert.StopGeneration() } +func (a *App) ToggleTopmost(enabled bool) { a.expert.ToggleTopmost(enabled) } +func (a *App) GetDBStatus() bool { return a.expert.GetDBStatus() } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 89dc15b..8e5aa6f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from 'react'; -import { AskDeepSeek, GetActiveLibrary, GetDBStatus, StopGeneration } from 'wailsjs/go/main/App'; +import { AskDeepSeek, DeleteItems, GetActiveLibrary, GetDBStatus, StopGeneration } from 'wailsjs/go/main/App'; import { EventsOn } from 'wailsjs/runtime/runtime'; import TitleBar from './components/TitleBar'; import LibraryBar from './components/LibraryBar'; @@ -7,6 +7,8 @@ import SearchInput from './components/SearchInput'; import ResultCard from './components/ResultCard'; import AIPanel from './components/AIPanel'; import SettingsModal from './components/SettingsModal'; +import ManageModeBar from './components/ManageModeBar'; +import { Toast, useToast } from './components/Toast'; import { useSearch, type SearchResult } from './hooks/useSearch'; export default function App() { @@ -18,20 +20,26 @@ export default function App() { const [aiError, setAiError] = useState(null); const [aiTarget, setAiTarget] = useState(null); const [isFallback, setIsFallback] = useState(false); + const [managementMode, setMgmtMode] = useState(false); + const [selectedIds, setSelectedIds] = useState([]); const aiTextRef = useRef(''); - const { query, setQuery, results, loading, error } = useSearch(); + const { query, setQuery, results, setResults, loading, error } = useSearch(); + const { toasts, showToast } = useToast(); + // DB status + active library polling useEffect(() => { - const check = async () => { - try { setDbReady(await GetDBStatus()); } catch { setDbReady(false); } - }; - const loadLib = async () => { setActiveLib(await GetActiveLibrary()); }; + const check = async () => { try { setDbReady(await GetDBStatus()); } catch { setDbReady(false); } }; + const loadLib = async () => { try { setActiveLib(await GetActiveLibrary()); } catch {} }; check(); loadLib(); const id = setInterval(check, 5000); return () => clearInterval(id); }, []); + // Exit management mode when query changes + useEffect(() => { setMgmtMode(false); setSelectedIds([]); }, [query]); + + // AI event listeners useEffect(() => { const offChunk = EventsOn('ai:chunk', (c: string) => { aiTextRef.current += c; setAiText(aiTextRef.current); }); const offDone = EventsOn('ai:done', () => setAiLoading(false)); @@ -44,33 +52,53 @@ export default function App() { setAiTarget(result); setAiText(''); setAiError(null); setIsFallback(false); setAiLoading(true); aiTextRef.current = ''; try { await AskDeepSeek(result.question, result.answer); } - catch { setAiError('DeepSeek 连接失败'); setAiLoading(false); } + catch { setAiError('AI 连接失败'); setAiLoading(false); } }, []); - const handleStop = useCallback(async () => { await StopGeneration(); }, []); + const handleStop = useCallback(async () => { await StopGeneration(); }, []); + const closeAI = useCallback(() => { + handleStop(); setAiTarget(null); setAiText(''); setAiError(null); setIsFallback(false); + }, [handleStop]); const handleCopyAI = useCallback(async (text: string) => { try { await navigator.clipboard.writeText(text); } - catch { - const el = document.createElement('textarea'); el.value = text; - document.body.appendChild(el); el.select(); document.execCommand('copy'); - document.body.removeChild(el); - } + catch { const el = document.createElement('textarea'); el.value = text; document.body.appendChild(el); el.select(); document.execCommand('copy'); document.body.removeChild(el); } }, []); - const closeAI = useCallback(() => { - handleStop(); setAiTarget(null); setAiText(''); setAiError(null); setIsFallback(false); - }, [handleStop]); + // Single-item delete + const handleDelete = useCallback(async (id: number) => { + const err = await DeleteItems([id]); + if (err) { showToast(err, 'error'); return; } + setResults(prev => prev.filter(r => r.id !== id)); + showToast('已删除', 'success'); + }, [setResults, showToast]); + + // Management mode + const toggleSelect = useCallback((id: number) => { + setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]); + }, []); + const handleMgmtDeleted = useCallback((ids: number[]) => { + if (ids.length === 0) setResults([]); + else setResults(prev => prev.filter(r => !ids.includes(r.id))); + setSelectedIds([]); + if (!ids.length) setMgmtMode(false); + }, [setResults]); return (
setShowSettings(true)} /> - { setActiveLib(name); setQuery(''); }} /> - +
+
+ +
{error && (
@@ -84,9 +112,8 @@ export default function App() { 🔍

知识库暂无相关答案

)} @@ -101,16 +128,28 @@ export default function App() { 💡未找到精确匹配,展示热门问答供参考
)} - {results.map(r => )} + {results.map(r => ( + + ))}
{aiTarget && ( - + + )} + + {managementMode && ( + setSelectedIds(results.map(r => r.id))} + onDeselectAll={() => setSelectedIds([])} + onExit={() => { setMgmtMode(false); setSelectedIds([]); }} + onDeleted={handleMgmtDeleted} showToast={showToast} /> )}
+ {showSettings && setShowSettings(false)} />} +
); } diff --git a/frontend/src/components/LibraryBar.tsx b/frontend/src/components/LibraryBar.tsx index bff17d4..97239cc 100644 --- a/frontend/src/components/LibraryBar.tsx +++ b/frontend/src/components/LibraryBar.tsx @@ -1,10 +1,11 @@ import { useEffect, useRef, useState } from 'react'; -import { CreateLibrary, DeleteLibrary, ImportCSV, ListLibraries, SwitchLibrary } from 'wailsjs/go/main/App'; +import { CreateLibrary, DeleteLibrary, ImportCSV, ImportExcel, ListLibraries, SwitchLibrary } from 'wailsjs/go/main/App'; import type { handler } from 'wailsjs/go/models'; interface LibraryBarProps { activeName: string; onSwitch: (name: string) => void; + onManageMode?: () => void; } export default function LibraryBar({ activeName, onSwitch }: LibraryBarProps) { @@ -44,12 +45,21 @@ export default function LibraryBar({ activeName, onSwitch }: LibraryBarProps) { else setMsg(err); }; - const handleImport = async () => { + const handleImportCSV = async () => { setImporting(true); setMsg(''); const result = await ImportCSV(); setImporting(false); if (result.error && result.error !== '已取消') setMsg(result.error); - else if (result.imported > 0) { setMsg(`✓ 导入了 ${result.imported} 条(跳过 ${result.skipped} 条)`); load(); } + else if (result.imported > 0) { setMsg(`✓ CSV 导入了 ${result.imported} 条`); load(); } + setTimeout(() => setMsg(''), 4000); + }; + + const handleImportExcel = async () => { + setImporting(true); setMsg(''); + const result = await ImportExcel(); + setImporting(false); + if (result.error && result.error !== '已取消') setMsg(result.error); + else if (result.imported > 0) { setMsg(`✓ Excel 导入了 ${result.imported} 条`); load(); } setTimeout(() => setMsg(''), 4000); }; @@ -64,10 +74,15 @@ export default function LibraryBar({ activeName, onSwitch }: LibraryBarProps) { {activeName || '选择知识库'} {open ? '▲' : '▾'} - + diff --git a/frontend/src/components/ManageModeBar.tsx b/frontend/src/components/ManageModeBar.tsx new file mode 100644 index 0000000..e2dc39a --- /dev/null +++ b/frontend/src/components/ManageModeBar.tsx @@ -0,0 +1,70 @@ +import { ClearDatabase, DeleteItems } from 'wailsjs/go/main/App'; + +interface ManageModeBarProps { + selected: number[]; + totalCount: number; + onSelectAll: () => void; + onDeselectAll: () => void; + onExit: () => void; + onDeleted: (ids: number[]) => void; + showToast: (msg: string, type: 'success' | 'error' | 'info') => void; +} + +export default function ManageModeBar({ + selected, totalCount, onSelectAll, onDeselectAll, onExit, onDeleted, showToast, +}: ManageModeBarProps) { + const allSelected = selected.length === totalCount && totalCount > 0; + + const handleBatchDelete = async () => { + if (selected.length === 0) return; + const err = await DeleteItems(selected as unknown as number[]); + if (err) { showToast(err, 'error'); return; } + showToast(`已删除 ${selected.length} 条`, 'success'); + onDeleted(selected); + }; + + const handleClearAll = async () => { + if (!window.confirm(`确定要清空当前知识库的所有 ${totalCount} 条记录吗?此操作不可撤销。`)) return; + const err = await ClearDatabase(); + if (err) { showToast(err, 'error'); return; } + showToast('已清空知识库', 'success'); + onDeleted([]); + }; + + return ( +
+ {/* Left: count + select toggle */} + + +
+ + {/* Clear all */} + + + {/* Batch delete */} + + + {/* Exit */} + +
+ ); +} diff --git a/frontend/src/components/ResultCard.tsx b/frontend/src/components/ResultCard.tsx index 0ce0b90..fe5b2df 100644 --- a/frontend/src/components/ResultCard.tsx +++ b/frontend/src/components/ResultCard.tsx @@ -4,90 +4,126 @@ import type { SearchResult } from '../hooks/useSearch'; interface ResultCardProps { result: SearchResult; onPolish: (result: SearchResult) => void; + onDelete: (id: number) => void; + managementMode: boolean; + selected: boolean; + onToggleSelect: (id: number) => void; } const CATEGORY_COLORS: Record = { - '通用': 'bg-indigo-500/20 text-indigo-300', - '退款': 'bg-red-500/20 text-red-300', - '物流': 'bg-amber-500/20 text-amber-300', - '产品': 'bg-emerald-500/20 text-emerald-300', - '促销': 'bg-pink-500/20 text-pink-300', + '通用': 'bg-indigo-500/20 text-indigo-300', + '退款': 'bg-red-500/20 text-red-300', + '物流': 'bg-amber-500/20 text-amber-300', + '产品': 'bg-emerald-500/20 text-emerald-300', + '促销': 'bg-pink-500/20 text-pink-300', + '浇水': 'bg-blue-500/20 text-blue-300', + '施肥': 'bg-lime-500/20 text-lime-300', + '病虫害': 'bg-orange-500/20 text-orange-300', + '繁殖': 'bg-purple-500/20 text-purple-300', + '换盆': 'bg-teal-500/20 text-teal-300', + '修剪': 'bg-cyan-500/20 text-cyan-300', + '光照': 'bg-yellow-500/20 text-yellow-300', + '季节养护': 'bg-rose-500/20 text-rose-300', + '土壤': 'bg-stone-500/20 text-stone-300', }; -export default function ResultCard({ result, onPolish }: ResultCardProps) { +export default function ResultCard({ result, onPolish, onDelete, managementMode, selected, onToggleSelect }: ResultCardProps) { const [copied, setCopied] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(result.answer); - } catch { - // Wails fallback — plain execCommand - const el = document.createElement('textarea'); - el.value = result.answer; - document.body.appendChild(el); - el.select(); - document.execCommand('copy'); + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + try { await navigator.clipboard.writeText(result.answer); } + catch { + const el = document.createElement('textarea'); el.value = result.answer; + document.body.appendChild(el); el.select(); document.execCommand('copy'); document.body.removeChild(el); } setCopied(true); setTimeout(() => setCopied(false), 1000); }; + const handleCardClick = () => { + if (managementMode) { onToggleSelect(result.id); return; } + // default: copy answer + }; + const catColor = CATEGORY_COLORS[result.category] ?? 'bg-white/10 text-white/50'; return ( -
- {/* Category badge + match indicator */} -
+
+ + {/* Top row: checkbox (mgmt) | category | match badge | trash */} +
+ {managementMode && ( +
+ {selected && } +
+ )} {result.category} - {result.is_fallback ? ( - - 🔥 热门推荐 - - ) : result.score === 2 ? ( - - ⚡ 精准匹配 - - ) : null} + + {result.is_fallback ? '🔥 热门推荐' : result.score === 2 ? '⚡ 精准匹配' : null} + + + {/* Trash icon — always visible, top-right */} + {!managementMode && ( + confirmDelete ? ( +
e.stopPropagation()}> + + +
+ ) : ( + + ) + )}
{/* Question */} -

- Q: {result.question} -

+

Q: {result.question}

{/* Answer */} -

- {result.answer} -

+

{result.answer}

- {/* Hover action bar */} -
- - -
+ {/* Hover action bar (only in normal mode) */} + {!managementMode && ( +
+ + +
+ )} - {/* Copy toast */} {copied && (
- 已复制到剪贴板 ✓ + 已复制 ✓
)}
diff --git a/frontend/src/components/Toast.tsx b/frontend/src/components/Toast.tsx new file mode 100644 index 0000000..b338be1 --- /dev/null +++ b/frontend/src/components/Toast.tsx @@ -0,0 +1,49 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +export interface ToastItem { + id: number; + message: string; + type: 'success' | 'error' | 'info'; +} + +interface ToastProps { toasts: ToastItem[]; } + +export function Toast({ toasts }: ToastProps) { + return ( +
+ {toasts.map(t => ( +
+ {t.type === 'success' ? '✓ ' : t.type === 'error' ? '✕ ' : 'ℹ '}{t.message} +
+ ))} +
+ ); +} + +let _toastId = 0; + +export function useToast() { + const [toasts, setToasts] = useState([]); + const timerRef = useRef>>(new Map()); + + const showToast = useCallback((message: string, type: ToastItem['type'] = 'success', duration = 2500) => { + const id = ++_toastId; + setToasts(prev => [...prev, { id, message, type }]); + const timer = setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== id)); + timerRef.current.delete(id); + }, duration); + timerRef.current.set(id, timer); + }, []); + + useEffect(() => { + const timers = timerRef.current; + return () => { timers.forEach(t => clearTimeout(t)); }; + }, []); + + return { toasts, showToast }; +} diff --git a/frontend/src/hooks/useSearch.ts b/frontend/src/hooks/useSearch.ts index 70738aa..10f3512 100644 --- a/frontend/src/hooks/useSearch.ts +++ b/frontend/src/hooks/useSearch.ts @@ -43,5 +43,5 @@ export function useSearch(debounceMs = 300) { return () => clearTimeout(timerRef.current); }, [query, debounceMs, doSearch]); - return { query, setQuery, results, loading, error }; + return { query, setQuery, results, setResults, loading, error }; } diff --git a/frontend/src/index.css b/frontend/src/index.css index 97e481b..d9a7067 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -89,9 +89,14 @@ body { .copy-toast { animation: fadeIn 0.15s ease, fadeOut 0.15s ease 0.85s forwards; } -@keyframes fadeOut { to { opacity: 0; transform: translateY(-4px); } } -@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } } -@keyframes slideIn { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } } +@keyframes fadeOut { to { opacity: 0; transform: translateY(-4px); } } +@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } } +@keyframes slideIn { from { opacity:0; transform:translateY(10px); } to { opacity:1; transform:translateY(0); } } +@keyframes toastIn { from { opacity:0; transform:translateY(12px) scale(0.95); } to { opacity:1; transform:translateY(0) scale(1); } } +@keyframes dropIn { from { opacity:0; transform:translateY(-6px) scale(0.98); } to { opacity:1; transform:translateY(0) scale(1); } } + +.animate-toast-in { animation: toastIn 0.2s cubic-bezier(0.34,1.56,0.64,1); } +.animate-fade-in { animation: dropIn 0.15s ease; } /* ── Input ───────────────────────────────────────────── */ .search-input { diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 75dd592..ae071ac 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -5,8 +5,12 @@ import {service} from '../models'; export function AskDeepSeek(arg1:string,arg2:string):Promise; +export function ClearDatabase():Promise; + export function CreateLibrary(arg1:string,arg2:string):Promise; +export function DeleteItems(arg1:Array):Promise; + export function DeleteLibrary(arg1:string):Promise; export function GetActiveLibrary():Promise; @@ -19,6 +23,8 @@ export function GetSettings():Promise; export function ImportCSV():Promise; +export function ImportExcel():Promise; + export function ListLibraries():Promise>; export function SaveSettings(arg1:service.SettingsDTO):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 318c1d0..a535072 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -6,10 +6,18 @@ export function AskDeepSeek(arg1, arg2) { return window['go']['main']['App']['AskDeepSeek'](arg1, arg2); } +export function ClearDatabase() { + return window['go']['main']['App']['ClearDatabase'](); +} + export function CreateLibrary(arg1, arg2) { return window['go']['main']['App']['CreateLibrary'](arg1, arg2); } +export function DeleteItems(arg1) { + return window['go']['main']['App']['DeleteItems'](arg1); +} + export function DeleteLibrary(arg1) { return window['go']['main']['App']['DeleteLibrary'](arg1); } @@ -34,6 +42,10 @@ export function ImportCSV() { return window['go']['main']['App']['ImportCSV'](); } +export function ImportExcel() { + return window['go']['main']['App']['ImportExcel'](); +} + export function ListLibraries() { return window['go']['main']['App']['ListLibraries'](); } diff --git a/go.mod b/go.mod index 15e182e..450f8c9 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,12 @@ module AI-Expert-Sidebar -go 1.23.0 +go 1.24.0 require ( github.com/glebarez/sqlite v1.11.0 github.com/spf13/viper v1.21.0 github.com/wailsapp/wails/v2 v2.12.0 + github.com/xuri/excelize/v2 v2.10.1 gorm.io/gorm v1.31.1 ) @@ -35,6 +36,8 @@ require ( github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/richardlehane/mscfb v1.0.6 // indirect + github.com/richardlehane/msoleps v1.0.6 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/samber/lo v1.49.1 // indirect @@ -43,16 +46,19 @@ require ( github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect + github.com/xuri/efp v0.0.1 // indirect + github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.33.0 // indirect - golang.org/x/net v0.35.0 // indirect - golang.org/x/sys v0.30.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect modernc.org/libc v1.22.5 // indirect modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.5.0 // indirect diff --git a/go.sum b/go.sum index 740e0c7..710fa84 100644 --- a/go.sum +++ b/go.sum @@ -73,6 +73,10 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/richardlehane/mscfb v1.0.6 h1:eN3bvvZCp00bs7Zf52bxNwAx5lJDBK1tCuH19qq5aC8= +github.com/richardlehane/mscfb v1.0.6/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= +github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= +github.com/richardlehane/msoleps v1.0.6/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -96,6 +100,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/tiendc/go-deepcopy v1.7.2 h1:Ut2yYR7W9tWjTQitganoIue4UGxZwCcJy3orjrrIj44= +github.com/tiendc/go-deepcopy v1.7.2/go.mod h1:4bKjNC2r7boYOkD2IOuZpYjmlDdzjbpTRyCx+goBCJQ= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= @@ -108,25 +114,33 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= +github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8= +github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI= +github.com/xuri/excelize/v2 v2.10.1 h1:V62UlqopMqha3kOpnlHy2CcRVw1V8E63jFoWUmMzxN0= +github.com/xuri/excelize/v2 v2.10.1/go.mod h1:iG5tARpgaEeIhTqt3/fgXCGoBRt4hNXgCp3tfXKoOIc= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= +github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go index b07bbd5..89e3216 100644 --- a/internal/crypto/crypto.go +++ b/internal/crypto/crypto.go @@ -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 } diff --git a/internal/database/db.go b/internal/database/db.go index 019a0a0..a31f01b 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -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 { diff --git a/internal/handler/expert.go b/internal/handler/expert.go index 686dced..a122f08 100644 --- a/internal/handler/expert.go +++ b/internal/handler/expert.go @@ -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 } diff --git a/internal/handler/knowledge_ops.go b/internal/handler/knowledge_ops.go new file mode 100644 index 0000000..d71c672 --- /dev/null +++ b/internal/handler/knowledge_ops.go @@ -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 "" +} diff --git a/internal/handler/library.go b/internal/handler/library.go index 510ad7d..11267ff 100644 --- a/internal/handler/library.go +++ b/internal/handler/library.go @@ -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 都更流畅。 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"` diff --git a/internal/handler/settings.go b/internal/handler/settings.go index 4c0e875..f20bdf2 100644 --- a/internal/handler/settings.go +++ b/internal/handler/settings.go @@ -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"` // 建议默认使用的模型哈希 } diff --git a/internal/models/entry.go b/internal/models/entry.go index dc15912..a7999a5 100644 --- a/internal/models/entry.go +++ b/internal/models/entry.go @@ -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" } diff --git a/internal/models/library.go b/internal/models/library.go index 70596c9..3a2f28b 100644 --- a/internal/models/library.go +++ b/internal/models/library.go @@ -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" } diff --git a/internal/service/ai_bridge.go b/internal/service/ai_bridge.go index 03677f8..c6e3647 100644 --- a/internal/service/ai_bridge.go +++ b/internal/service/ai_bridge.go @@ -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 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 层转发给前端 } } } diff --git a/internal/service/import.go b/internal/service/import.go index 8a00932..697c1c8 100644 --- a/internal/service/import.go +++ b/internal/service/import.go @@ -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++ diff --git a/internal/service/import_excel.go b/internal/service/import_excel.go new file mode 100644 index 0000000..808f1c4 --- /dev/null +++ b/internal/service/import_excel.go @@ -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} +} diff --git a/internal/service/knowledge_svc.go b/internal/service/knowledge_svc.go new file mode 100644 index 0000000..e6dde5c --- /dev/null +++ b/internal/service/knowledge_svc.go @@ -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 +} diff --git a/internal/service/library_svc.go b/internal/service/library_svc.go index 61b79b8..9b65e8e 100644 --- a/internal/service/library_svc.go +++ b/internal/service/library_svc.go @@ -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) } diff --git a/internal/service/search.go b/internal/service/search.go index f69e83f..b2e1b8e 100644 --- a/internal/service/search.go +++ b/internal/service/search.go @@ -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 diff --git a/internal/service/settings_svc.go b/internal/service/settings_svc.go index 3222cd8..7b6787e 100644 --- a/internal/service/settings_svc.go +++ b/internal/service/settings_svc.go @@ -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 diff --git a/植物养护知识库.csv b/植物养护知识库.csv new file mode 100644 index 0000000..21527af --- /dev/null +++ b/植物养护知识库.csv @@ -0,0 +1,50 @@ +keyword,question,answer,category +浇水频率,多肉植物多久浇一次水,多肉植物耐旱,春秋生长期每10-14天浇一次透水;夏季高温休眠期减少至每月1次;冬季低于5℃时停止浇水。遵循"干透浇透"原则,避免盆底积水。,浇水 +浇水方法,怎么给植物正确浇水,浇水时沿盆边缓慢注入,直到水从底部排水孔流出为止,这叫"透浇"。避免只浇表面(假浇水)或向叶片喷水导致积水烂叶。最佳时间为清晨。,浇水 +叶子发黄,植物叶子变黄是什么原因,叶黄最常见原因:①浇水过多导致根部缺氧;②缺铁或缺氮等营养素;③光照不足;④温度骤变。新叶黄多为缺肥,老叶黄多为浇水问题,请对症处理。,常见问题 +叶子发蔫,植物叶子蔫了怎么办,先检查土壤:若土壤干燥则立即浇水;若土壤湿润则可能是烂根,需挖出检查根系,剪去黑腐根部,晾干后换新土重新种植。,常见问题 +施肥时间,植物什么时候施肥最好,生长旺盛的春秋季每2-3周施一次薄肥;夏季高温和冬季休眠期停止施肥。施肥宜在浇水后进行,切勿向干燥的土壤施肥,会烧伤根系。,施肥 +施肥种类,植物用什么肥料好,观叶植物用氮肥为主的复合肥;开花植物在花期前改用磷钾肥促进开花。有机肥(如腐熟鸡粪、豆饼水)效果温和持久;化肥见效快但容易过量,建议用量减半使用。,施肥 +光照需求,绿萝需要多少光照,绿萝属耐阴植物,适合放在明亮的散射光处,避免阳光直射(叶片会灼伤)。可放在离窗户1-2米的室内,或光线良好的走廊。光照不足时叶片变全绿,条纹消失。,光照 +阳光直射,哪些植物不能放在阳光直射处,不耐直射光的植物包括:绿萝、蕨类、竹芋、大岩桐、非洲紫罗兰、秋海棠。这些植物叶片薄,在强光下会出现焦边、白化或枯斑,应置于散射光环境。,光照 +换盆时机,什么时候需要给植物换盆,出现以下情况需换盆:①根系从排水孔穿出;②浇水后水立即流出不被吸收;③植物生长停滞。最佳换盆季节为春季,新盆直径比旧盆大3-5厘米即可,避免盆过大导致积水。,换盆 +换盆方法,怎么给植物换盆不伤根,换盆前1-2天停止浇水使土壤略干,方便脱盆。取出植株后轻轻抖落旧土,检查根系剪去枯根,再植入新盆新土中。换盆后浇一次定根水,放置阴凉处1-2周缓苗。,换盆 +土壤配制,多肉植物用什么土种,多肉植物专用土配方:50%颗粒土(赤玉土/浮石/珍珠岩)+ 50%泥炭土。也可直接购买多肉专用培养土。核心要求是透气、排水良好,忌用普通菜园土或黏性土壤。,土壤 +土壤选择,观叶植物用什么土壤,推荐通用配方:泥炭土60% + 珍珠岩20% + 椰糠20%。这种混合土保水性好又透气,适合绝大多数观叶植物。避免使用纯园土,板结后会影响透气性和根系生长。,土壤 +叶子晒伤,植物叶片出现白色斑点或焦边怎么办,白色斑点或焦边通常是日灼伤,由阳光过强直射造成。处理方法:立即移至散射光处;受伤叶片可剪去。防晒贴士:夏季中午前后给植物遮阴,尤其是室内植物突然移出室外时需循序渐进适应光照。,常见问题 +绿萝繁殖,绿萝怎么繁殖,绿萝最简单的繁殖方式是扦插:剪取带3-4个节的健壮枝条,去掉下部叶片,插入湿润的土壤或放入水杯中水培生根。室温20-28℃下约2-4周生根,成功率极高,适合新手。,繁殖 +叶插多肉,多肉植物叶插怎么操作,选取肥厚健壮的叶片,轻轻扭下(要带完整叶基),平放在微湿的沙土上,置于散射光处。约2-4周从叶基长出小芽和根系,待母叶干瘪后移栽。温度25℃左右成功率最高。,繁殖 +根腐病,植物根部腐烂怎么救,根腐病救治步骤:①脱盆清洗根系;②剪去所有变黑变软有异味的根;③用多菌灵溶液浸泡根系15分钟消毒;④晾干2-4小时;⑤换新土重新种植;⑥2周内减少浇水。,病虫害 +蚧壳虫,植物上有白色棉絮状的小虫怎么办,这是蚧壳虫,处理方法:少量时用棉签蘸75%酒精逐一擦除;大量时喷洒稀释的护花神或矿物油乳剂,每7天一次连续3次。同时检查所有临近植物防止蔓延。,病虫害 +红蜘蛛,叶片背面有红色小点是什么,很可能是红蜘蛛(叶螨)。症状:叶片正面出现浅黄色点状失绿,背面可见细小红点或薄蛛网。防治:用阿维菌素或哒螨灵稀释后喷洒叶片背面,每5天一次共3次。,病虫害 +白粉病,植物叶子上有白色粉末是病吗,是白粉病,由真菌引起。处理:摘除严重感染叶片;用稀释的多菌灵或小苏打水(1茶匙兑1升水)喷洒全株;改善通风,降低湿度;避免叶片沾水过夜。,病虫害 +修剪时间,植物什么时候修剪合适,最佳修剪时间是春季萌芽前(3-4月)。修剪目的:剪去枯枝、病枝、交叉枝,控制株型,促进新芽萌发。用锋利消毒的剪刀操作,剪口距节点1厘米,大剪口涂抹多菌灵或木炭粉防腐。,修剪 +徒长处理,植物徒长了茎细长怎么办,徒长原因是光照不足。处理:逐步把植物移至光线更充足处;通过修剪顶部促进分枝;剪下的徒长茎可以扦插繁殖。多肉植物徒长后需砍头重新造型。,修剪 +冬季养护,冬天植物怎么养护,冬季养护要点:①减少浇水频率(延长1-2倍周期);②停止施肥;③保持室温5℃以上(热带植物需12℃以上);④避免冷风直吹;⑤移至光照充足处。,季节养护 +夏季养护,夏天植物怎么防热,夏季高温养护:①避开12-16点强烈直射光;②增加浇水频率;③保持通风;④多肉等植物进入休眠停止施肥;⑤早晚向叶片和周围空气喷雾降温增湿。,季节养护 +空气湿度,怎么提高室内植物周围的湿度,提高湿度的方法:①在植物附近放置装水的托盘;②使用加湿器;③将多盆植物集中摆放;④用喷壶向叶片喷雾,早晨喷为佳。喜湿植物(蕨类、竹芋)尤其需要。,季节养护 +温度骤变,温差大对植物有什么影响,超过10℃的日夜温差会导致热带植物叶片焦边、落叶,甚至死亡。应避免:冬天将植物放在紧贴玻璃的窗台;用空调直吹;冷天开窗时冷风直袭植物。,季节养护 +仙人掌浇水,仙人掌多久浇一次水,春秋季:10-14天浇一次透水。夏季:每月1-2次早晨浇水。冬季:气温低于10℃时完全停止浇水,保持干燥休眠。判断标准:盆土完全干燥后再等3-5天再浇,宁干勿湿。,浇水 +滴水观音,滴水观音叶片滴水正常吗,这是植物的"吐水"现象,完全正常,是植物通过叶片排出多余水分的生理行为。但要注意:滴水观音汁液有毒,不要让宠物和儿童接触;皮肤接触后及时洗手。,常见问题 +发财树护理,发财树叶子掉了怎么办,落叶原因:①换了新环境不适应(需2-4周适应期);②浇水过多或过少;③温度太低或冷风直吹。处理:保持环境稳定停止移动;调整浇水;温度保持18℃以上;掉叶期间减少施肥。,常见问题 +蝴蝶兰护理,蝴蝶兰花谢后怎么处理,花谢后观察花梗:若变黄则从基部剪去;若保持绿色,从最后一朵花以下3-4个节处剪断,可能从侧芽再次发花。继续正常养护,等待下次花期(通常6-12个月后)。,常见问题 +绿萝叶斑,绿萝叶片出现黄色斑点怎么办,黄色斑点可能是:①浇水过多引起局部烂叶;②蚧壳虫或红蜘蛛危害;③细菌性叶斑病。处理:检查虫害;减少浇水;若是病斑则喷多菌灵并剪去病叶;加强通风。,病虫害 +多肉晒伤,夏天多肉植物叶片变白怎么办,白色或透明化的叶片是晒伤症状,由突然的强光直射造成。受损叶片无法恢复但植株会继续生长。处理:立即遮阴;下次移至室外需循序渐进,先置于半阴处2周再逐步增加光照。,病虫害 +种子发芽,播种后多久才会发芽,不同植物发芽时间差异很大:蔬菜类3-7天;草花7-14天;木本或大粒种子需1-3个月。保证发芽条件:温度20-25℃、土壤持续湿润(不能干也不积水)、避免强光直射。,繁殖 +扦插蔫叶,扦插后叶子蔫了是死了吗,扦插初期叶片发蔫属正常现象,因为枝条还没有根系无法正常吸水。保持土壤微湿,放置散射光处,1-3周后根系形成即可恢复挺立。判断成活:轻拉枝条有阻力感或发现新叶萌发,即已成活。,繁殖 +花盆选择,选择花盆有什么讲究,花盆选择原则:①排水孔必须有,防止积水;②材质:陶土盆透气适合多肉;塑料盆保水适合喜湿植物;③大小:新购植物换盆只大一号,过大盆多余湿土易腐根。,土壤 +土壤小虫,土壤里有小白虫是什么,可能是菌蚊幼虫(小白蛆),由浇水太多腐烂有机物滋生。处理:让土壤彻底干燥再浇水;表层土换新土;可使用苏云金杆菌或辣椒水灌根防治。,病虫害 +叶片清洁,怎么清洁植物叶片上的灰尘,用微湿的软布或海绵轻轻擦拭叶片正反面,可加少量牛奶增加光泽(适用于橡皮树等革质叶)。蕨类等毛茸茸叶片用软毛刷刷除。叶片清洁后光合作用效率更高,也可提前发现虫害。,季节养护 +芦荟养护,芦荟怎么养才茂盛,芦荟养护要点:①充足阳光(每天4小时以上);②严格控水(完全干透再浇,冬季每月1次);③排水良好的沙质土;④每年换盆一次。芦荟极耐旱,烂根90%是浇水过多。,常见问题 +薰衣草养护,薰衣草养不活怎么办,薰衣草死亡常见原因:①夏季高温高湿;②浇水过多;③通风不良。养护要点:全日照;沙质排水土;浇水宁少不多;花后修剪1/3。南方高温高湿地区建议作为一年生植物种植。,常见问题 +铜钱草护理,铜钱草叶子发黄怎么办,铜钱草喜湿,叶黄多是水分不足或光照过强。养护:水培最适合,水位保持盆深2/3;土培保持土壤湿润;光线明亮散射光;每月施一次薄液肥。与多肉养护方式完全相反。,浇水 +多肉浇水部位,多肉浇水沿盆边还是直接浇叶心,多肉浇水应沿盆边浇灌,避免浇入叶片中心(莲座型多肉)。叶心积水在高温潮湿时极易导致化水腐烂。若水积叶心,立即用餐巾纸吸干或用吹风机冷风吹干。,浇水 +植物怕冷,哪些植物特别怕冷需要保温,需要保温的植物(冬季最低温度参考):龟背竹≥10℃;天堂鸟≥5℃;三角梅≥5℃;热带兰花≥12℃;多肉植物(景天科)≥-5℃。北方供暖室内通常足够,南方无暖气需特别注意。,季节养护 +生根粉,扦插时要用生根粉吗,生根粉(吲哚丁酸/ABT)可提高扦插成功率,但不是必须的。容易生根的植物(绿萝、薄荷)无需使用;对难以生根的木本植物(桂花、茶花)使用效果明显,将切口蘸粉后插入湿土即可。,繁殖 +土壤板结,花盆土壤板结了怎么处理,处理方法:①用竹签在土壤表层戳洞改善透气性;②换土时混入30%珍珠岩或粗沙;③根本解决:下次换盆时使用专业透气培养土,不用纯园土。浇水时加入少量腐殖酸也有助改善土壤结构。,土壤 +水培换水,水培植物多久换一次水,水培植物换水频率:夏季每5-7天一次;冬季每10-14天一次。换水时顺便洗净瓶壁绿藻,保持水质清洁。可在水中加入几粒多菌灵或少量木炭块防腐。水温应与室温接近。,浇水 +落蕾原因,花蕾还没开就掉了是什么原因,落蕾原因:①突然移动植物(最常见);②空气过于干燥;③温度急剧变化;④浇水不当。预防:花期不移动植物,保持稳定环境,增加空气湿度,保持土壤均匀湿润。,常见问题 +植物通风,室内植物需要通风吗,通风对植物很重要:可以预防病虫害、加快土壤水分蒸发防止积水、促进叶片气体交换。建议每天开窗通风15-30分钟,但避免冷风直吹植物。夏季通风尤其重要。,季节养护 +盆底石误区,花盆底部需要垫石子吗,盆底垫石子是个误区:石子会导致毛细现象让水在石子上方积聚,反而更容易积水烂根。正确做法:选择有排水孔的花盆,铺一层纱网防止土壤流失,使用排水良好的专业培养土即可。,土壤 +氮磷钾,化肥包装上NPK数字什么意思,N(氮)-P(磷)-K(钾)是化肥三大要素。氮促进茎叶生长;磷促进根系和开花结果;钾增强抗病性和整体健康。观叶植物选高氮配方;开花植物花期前换高磷钾配方;通用型选三者均衡的配方。,施肥 +有机液肥,自制有机液肥怎么做,简单自制液肥:将淘米水、豆饼(黄豆煮剩的水)放入容器中发酵7-14天(夏季7天,冬季14天),产生气泡停止后稀释10倍浇根。香蕉皮泡水1-3天可补充钾元素,有助开花。,施肥