diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 900564ca..ca447870 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -727,7 +727,13 @@ "filterByStatus": "Filter by status", "entries": "entries", "empty": "No knowledge entries yet", - "emptyDescription": "Entries will be auto-extracted from incidents, or you can create them manually" + "emptyDescription": "Entries will be auto-extracted from incidents, or you can create them manually", + "semanticSearchPlaceholder": "Enter semantic search query...", + "semanticOn": "Semantic", + "semanticOff": "Semantic", + "switchToSemantic": "Switch to semantic search (pgvector)", + "switchToKeyword": "Switch to keyword search", + "semanticSearchHint": "Enter a query to search with AI vector similarity" }, "monitoring": { "healthy": "Healthy", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 66ed3fc4..7a6e0a49 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -728,7 +728,13 @@ "filterByStatus": "篩選狀態", "entries": "筆", "empty": "尚未建立任何知識條目", - "emptyDescription": "知識庫將自動從 Incident 中萃取案例,你也可以手動新增" + "emptyDescription": "知識庫將自動從 Incident 中萃取案例,你也可以手動新增", + "semanticSearchPlaceholder": "輸入語意搜尋查詢...", + "semanticOn": "語意", + "semanticOff": "語意", + "switchToSemantic": "切換至語意搜尋 (pgvector)", + "switchToKeyword": "切換至關鍵字搜尋", + "semanticSearchHint": "輸入查詢內容,使用 AI 向量搜尋相關知識" }, "monitoring": { "healthy": "正常", diff --git a/apps/web/src/app/[locale]/knowledge-base/page.tsx b/apps/web/src/app/[locale]/knowledge-base/page.tsx index 758840f7..ab2b1c0c 100644 --- a/apps/web/src/app/[locale]/knowledge-base/page.tsx +++ b/apps/web/src/app/[locale]/knowledge-base/page.tsx @@ -17,7 +17,7 @@ import { useTranslations } from 'next-intl' import { cn } from '@/lib/utils' import { Search, BookOpen, FileText, Shield, Cpu, - Server, Eye, Bot, ChevronRight, Plus, + Server, Eye, Bot, ChevronRight, Plus, Sparkles, } from 'lucide-react' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -105,6 +105,9 @@ export default function KnowledgeBasePage({ const [selectedCategory, setSelectedCategory] = useState(null) const [selectedType, setSelectedType] = useState(null) const [searchQuery, setSearchQuery] = useState('') + const [semanticMode, setSemanticMode] = useState(false) + const [semanticResults, setSemanticResults] = useState<(KnowledgeEntry & { score: number })[]>([]) + const [semanticLoading, setSemanticLoading] = useState(false) const [selectedEntry, setSelectedEntry] = useState(null) // KB-D: approve/archive state (2026-04-03 ogt) @@ -135,8 +138,35 @@ export default function KnowledgeBasePage({ }, [selectedCategory, selectedType, searchQuery]) useEffect(() => { - fetchEntries() - }, [fetchEntries]) + if (!semanticMode) fetchEntries() + }, [fetchEntries, semanticMode]) + + // KB Phase 2: 語意搜尋 (2026-04-04 Claude Code) + const fetchSemanticResults = useCallback(async (q: string) => { + if (!q.trim()) { setSemanticResults([]); return } + setSemanticLoading(true) + try { + const apiBase = process.env.NEXT_PUBLIC_API_URL ?? '' + const res = await fetch(`${apiBase}/api/v1/knowledge/semantic-search?q=${encodeURIComponent(q)}&limit=20`) + if (res.ok) { + const data = await res.json() + setSemanticResults(data) + } + } catch (err) { + console.error('Semantic search failed', err) + } finally { + setSemanticLoading(false) + } + }, []) + + useEffect(() => { + if (semanticMode && searchQuery) { + const timer = setTimeout(() => fetchSemanticResults(searchQuery), 500) + return () => clearTimeout(timer) + } else if (semanticMode && !searchQuery) { + setSemanticResults([]) + } + }, [semanticMode, searchQuery, fetchSemanticResults]) // KB-D: Approve / Archive handlers (2026-04-03 ogt) const handleApprove = useCallback(async () => { @@ -235,16 +265,39 @@ export default function KnowledgeBasePage({ {/* 頂部搜尋 + 篩選 */}
- {/* Search */} -
- - setSearchQuery(e.target.value)} - placeholder={t('searchPlaceholder')} - className="w-full pl-8 pr-3 py-1.5 text-sm font-body bg-nothing-gray-50 border border-nothing-gray-200 rounded-md focus:outline-none focus:border-claw-blue/50 transition-colors" - /> + {/* Search + Semantic Toggle */} +
+
+ {semanticMode + ? + : + } + setSearchQuery(e.target.value)} + placeholder={semanticMode ? t('semanticSearchPlaceholder') : t('searchPlaceholder')} + className={cn( + "w-full pl-8 pr-3 py-1.5 text-sm font-body bg-nothing-gray-50 border rounded-md focus:outline-none transition-colors", + semanticMode + ? "border-claw-blue/50 focus:border-claw-blue" + : "border-nothing-gray-200 focus:border-claw-blue/50" + )} + /> +
+
{/* Type filter */} @@ -273,11 +326,21 @@ export default function KnowledgeBasePage({ {/* 結果列表 */}
- {loading ? ( + {(semanticMode ? semanticLoading : loading) ? (
{tCommon('loading')}
- ) : entries.length === 0 ? ( + ) : semanticMode && !searchQuery ? ( +
+ +

{t('semanticSearchHint')}

+
+ ) : semanticMode && semanticResults.length === 0 ? ( +
+ +

{t('empty')}

+
+ ) : !semanticMode && entries.length === 0 ? (

{t('empty')}

@@ -285,7 +348,7 @@ export default function KnowledgeBasePage({
) : (
- {entries.map(entry => ( + {(semanticMode ? semanticResults : entries).map(entry => (
- {/* Right: view count + chevron */} + {/* Right: similarity score (semantic) or view count + chevron */}
-
- - {entry.view_count} -
+ {semanticMode && 'score' in entry ? ( +
+ + {Math.round((entry as { score: number }).score * 100)}% +
+ ) : ( +
+ + {entry.view_count} +
+ )}