feat(knowledge): 前端語意搜尋 UI — 切換按鈕 + 相似度分數顯示
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
- 搜尋欄旁新增語意/關鍵字切換按鈕 (Sparkles icon, claw-blue 高亮) - 語意模式下呼叫 GET /api/v1/knowledge/semantic-search (500ms debounce) - 條目卡片右側:語意模式顯示相似度百分比,關鍵字模式顯示 view_count - 空態:語意模式未輸入時顯示提示文字 - i18n: zh-TW + en 新增 6 個 key Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -728,7 +728,13 @@
|
||||
"filterByStatus": "篩選狀態",
|
||||
"entries": "筆",
|
||||
"empty": "尚未建立任何知識條目",
|
||||
"emptyDescription": "知識庫將自動從 Incident 中萃取案例,你也可以手動新增"
|
||||
"emptyDescription": "知識庫將自動從 Incident 中萃取案例,你也可以手動新增",
|
||||
"semanticSearchPlaceholder": "輸入語意搜尋查詢...",
|
||||
"semanticOn": "語意",
|
||||
"semanticOff": "語意",
|
||||
"switchToSemantic": "切換至語意搜尋 (pgvector)",
|
||||
"switchToKeyword": "切換至關鍵字搜尋",
|
||||
"semanticSearchHint": "輸入查詢內容,使用 AI 向量搜尋相關知識"
|
||||
},
|
||||
"monitoring": {
|
||||
"healthy": "正常",
|
||||
|
||||
@@ -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<string | null>(null)
|
||||
const [selectedType, setSelectedType] = useState<string | null>(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<KnowledgeEntry | null>(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({
|
||||
|
||||
{/* 頂部搜尋 + 篩選 */}
|
||||
<div className="flex items-center gap-3 px-4 py-3 border-b border-nothing-gray-200/50">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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 */}
|
||||
<div className="flex items-center gap-2 flex-1 max-w-lg">
|
||||
<div className="relative flex-1">
|
||||
{semanticMode
|
||||
? <Sparkles className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-claw-blue" />
|
||||
: <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted" />
|
||||
}
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => 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"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setSemanticMode(m => !m); setSearchQuery('') }}
|
||||
title={semanticMode ? t('switchToKeyword') : t('switchToSemantic')}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-label rounded-md border transition-colors shrink-0",
|
||||
semanticMode
|
||||
? "bg-claw-blue text-white border-claw-blue"
|
||||
: "bg-nothing-gray-50 text-secondary border-nothing-gray-200 hover:border-claw-blue/50"
|
||||
)}
|
||||
>
|
||||
<Sparkles className="w-3 h-3" />
|
||||
{semanticMode ? t('semanticOn') : t('semanticOff')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Type filter */}
|
||||
@@ -273,11 +326,21 @@ export default function KnowledgeBasePage({
|
||||
|
||||
{/* 結果列表 */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
{(semanticMode ? semanticLoading : loading) ? (
|
||||
<div className="flex items-center justify-center h-40">
|
||||
<span className="text-sm text-muted font-body">{tCommon('loading')}</span>
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
) : semanticMode && !searchQuery ? (
|
||||
<div className="flex flex-col items-center justify-center h-60 gap-2">
|
||||
<Sparkles className="w-10 h-10 text-claw-blue/30" />
|
||||
<p className="text-sm text-secondary font-body">{t('semanticSearchHint')}</p>
|
||||
</div>
|
||||
) : semanticMode && semanticResults.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-60 gap-2">
|
||||
<BookOpen className="w-10 h-10 text-nothing-gray-300" />
|
||||
<p className="text-sm text-secondary font-body">{t('empty')}</p>
|
||||
</div>
|
||||
) : !semanticMode && entries.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-60 gap-2">
|
||||
<BookOpen className="w-10 h-10 text-nothing-gray-300" />
|
||||
<p className="text-sm text-secondary font-body">{t('empty')}</p>
|
||||
@@ -285,7 +348,7 @@ export default function KnowledgeBasePage({
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-nothing-gray-100">
|
||||
{entries.map(entry => (
|
||||
{(semanticMode ? semanticResults : entries).map(entry => (
|
||||
<button
|
||||
key={entry.id}
|
||||
onClick={() => setSelectedEntry(entry)}
|
||||
@@ -336,12 +399,19 @@ export default function KnowledgeBasePage({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: view count + chevron */}
|
||||
{/* Right: similarity score (semantic) or view count + chevron */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-1 text-xs text-muted">
|
||||
<Eye className="w-3 h-3" />
|
||||
<span>{entry.view_count}</span>
|
||||
</div>
|
||||
{semanticMode && 'score' in entry ? (
|
||||
<div className="flex items-center gap-1 text-xs text-claw-blue font-label">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
<span>{Math.round((entry as { score: number }).score * 100)}%</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-xs text-muted">
|
||||
<Eye className="w-3 h-3" />
|
||||
<span>{entry.view_count}</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-nothing-gray-300" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user