feat(desktop): implement Knowledge Base page

This commit is contained in:
zhayujie
2026-06-20 16:35:06 +08:00
parent c9c16298ec
commit 108d04398b
5 changed files with 836 additions and 9 deletions

View File

@@ -12,12 +12,12 @@ import apiClient from './api/client'
import { t } from './i18n'
import ChatPage from './pages/ChatPage'
import SettingsPage from './pages/SettingsPage'
import KnowledgePage from './pages/KnowledgePage'
import SkillsPage from './pages/SkillsPage'
import MemoryPage from './pages/MemoryPage'
import ChannelsPage from './pages/ChannelsPage'
import TasksPage from './pages/TasksPage'
import LogsPage from './pages/LogsPage'
import PlaceholderPage from './pages/PlaceholderPage'
const App: React.FC = () => {
const backend = useBackend()
@@ -65,7 +65,7 @@ const App: React.FC = () => {
<div className="flex-1 flex flex-col min-h-0 overflow-hidden bg-base">
<Routes>
<Route path="/" element={<ChatPage baseUrl={backend.baseUrl} />} />
<Route path="/knowledge" element={<PlaceholderPage title={t('menu_knowledge')} />} />
<Route path="/knowledge" element={<KnowledgePage baseUrl={backend.baseUrl} />} />
<Route path="/memory" element={<MemoryPage baseUrl={backend.baseUrl} />} />
<Route path="/skills" element={<SkillsPage baseUrl={backend.baseUrl} />} />
<Route path="/channels" element={<ChannelsPage baseUrl={backend.baseUrl} />} />

View File

@@ -0,0 +1,407 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import type { KnowledgeGraph as KnowledgeGraphData } from '../types'
interface SimNode {
id: string
label: string
category: string
x: number
y: number
vx: number
vy: number
fx: number | null
fy: number | null
degree: number
}
interface KnowledgeGraphProps {
data: KnowledgeGraphData
onSelect: (id: string, label: string) => void
}
// d3.schemeTableau10 — keep the web client's palette for visual parity.
const TABLEAU10 = [
'#4e79a7',
'#f28e2c',
'#e15759',
'#76b7b2',
'#59a14f',
'#edc949',
'#af7aa1',
'#ff9da7',
'#9c755f',
'#bab0ab',
]
const nodeRadius = (degree: number) => Math.max(4, Math.min(12, 4 + degree * 1.4))
// A dependency-free force-directed graph with wheel zoom, canvas pan and node
// drag. The physics loop writes positions DIRECTLY to the DOM (like d3) instead
// of calling setState per frame, so React never re-renders during the
// simulation — this is what keeps it from flickering.
const KnowledgeGraph: React.FC<KnowledgeGraphProps> = ({ data, onSelect }) => {
const wrapRef = useRef<HTMLDivElement>(null)
const svgRef = useRef<SVGSVGElement>(null)
const sizeRef = useRef({ w: 800, h: 560 })
const [hover, setHover] = useState<string | null>(null)
// Bumping this re-arms the physics loop (used while dragging) without
// rebuilding the model, preserving current node positions.
const [warmTick, setWarmTick] = useState(0)
// View transform (pan + zoom).
const viewRef = useRef({ k: 1, x: 0, y: 0 })
// Build the immutable model once per data change.
const model = useMemo(() => {
const degree = new Map<string, number>()
data.links.forEach((l) => {
degree.set(l.source, (degree.get(l.source) || 0) + 1)
degree.set(l.target, (degree.get(l.target) || 0) + 1)
})
const categories = Array.from(new Set(data.nodes.map((n) => n.category || 'default')))
const colorOf = (cat: string) => TABLEAU10[categories.indexOf(cat) % TABLEAU10.length]
const n = data.nodes.length || 1
const { w, h } = sizeRef.current
const cx = w / 2
const cy = h / 2
const nodes: SimNode[] = data.nodes.map((nd, i) => {
const angle = (i / n) * Math.PI * 2
const radius = Math.min(w, h) * 0.32
return {
id: nd.id,
label: nd.label,
category: nd.category || 'default',
x: cx + Math.cos(angle) * radius,
y: cy + Math.sin(angle) * radius,
vx: 0,
vy: 0,
fx: null,
fy: null,
degree: degree.get(nd.id) || 0,
}
})
const valid = new Set(nodes.map((x) => x.id))
const byId = new Map(nodes.map((x) => [x.id, x]))
const links = data.links
.filter((l) => valid.has(l.source) && valid.has(l.target))
.map((l, i) => ({ key: i, a: byId.get(l.source)!, b: byId.get(l.target)! }))
const adjacency = new Map<string, Set<string>>()
data.links.forEach((l) => {
if (!valid.has(l.source) || !valid.has(l.target)) return
if (!adjacency.has(l.source)) adjacency.set(l.source, new Set())
if (!adjacency.has(l.target)) adjacency.set(l.target, new Set())
adjacency.get(l.source)!.add(l.target)
adjacency.get(l.target)!.add(l.source)
})
return { nodes, links, adjacency, categories, colorOf, byId }
}, [data])
// DOM refs for imperative position updates.
const rootRef = useRef<SVGGElement>(null)
const lineEls = useRef(new Map<number, SVGLineElement>())
const groupEls = useRef(new Map<string, SVGGElement>())
// Track container size in a ref; never triggers a re-render on its own.
useEffect(() => {
const el = wrapRef.current
if (!el) return
const apply = () => {
sizeRef.current = { w: el.clientWidth || 800, h: el.clientHeight || 560 }
}
apply()
const ro = new ResizeObserver(apply)
ro.observe(el)
return () => ro.disconnect()
}, [])
// Physics loop. Restarts only when the model (data) changes. Writes to DOM.
// Uses d3-style alpha cooling so it always settles and stops the rAF.
useEffect(() => {
const { nodes, links } = model
if (nodes.length === 0) return
let raf = 0
let alive = true
// Global cooling factor; decays toward 0 and scales how far nodes move.
let alpha = 1
const alphaDecay = 0.018
const alphaMin = 0.005
const paint = () => {
links.forEach(({ key, a, b }) => {
const el = lineEls.current.get(key)
if (!el) return
el.setAttribute('x1', String(a.x))
el.setAttribute('y1', String(a.y))
el.setAttribute('x2', String(b.x))
el.setAttribute('y2', String(b.y))
})
nodes.forEach((node) => {
const el = groupEls.current.get(node.id)
if (el) el.setAttribute('transform', `translate(${node.x},${node.y})`)
})
}
const step = () => {
if (!alive) return
const { w, h } = sizeRef.current
const cx = w / 2
const cy = h / 2
const repulsion = 9000
const springLen = 80
const spring = 0.04
const centering = 0.012
const dragging = nodes.some((node) => node.fx != null)
// Reset accumulated velocity each tick (alpha-scaled displacement) so the
// system can't accumulate energy and oscillate.
nodes.forEach((node) => {
node.vx = 0
node.vy = 0
})
// Repulsion + collision: nodes push apart, never overlap their radii.
for (let i = 0; i < nodes.length; i++) {
const a = nodes[i]
const ra = nodeRadius(a.degree)
for (let j = i + 1; j < nodes.length; j++) {
const b = nodes[j]
let dx = a.x - b.x
let dy = a.y - b.y
let d2 = dx * dx + dy * dy
if (d2 < 0.01) {
dx = Math.random() - 0.5
dy = Math.random() - 0.5
d2 = 0.01
}
let d = Math.sqrt(d2)
let f = repulsion / d2
// Hard collision: strongly separate if closer than combined radii.
const minDist = ra + nodeRadius(b.degree) + 14
if (d < minDist) f += (minDist - d) * 0.6
a.vx += (dx / d) * f
a.vy += (dy / d) * f
b.vx -= (dx / d) * f
b.vy -= (dy / d) * f
}
}
links.forEach(({ a, b }) => {
const dx = b.x - a.x
const dy = b.y - a.y
const d = Math.sqrt(dx * dx + dy * dy) || 1
const f = (d - springLen) * spring
a.vx += (dx / d) * f
a.vy += (dy / d) * f
b.vx -= (dx / d) * f
b.vy -= (dy / d) * f
})
// Weak centering so the whole graph stays in view without collapsing.
nodes.forEach((node) => {
node.vx += (cx - node.x) * centering
node.vy += (cy - node.y) * centering
})
// Apply alpha-scaled displacement; pinned nodes stay put. Cap per-tick
// movement so strong initial forces don't fling nodes off-screen.
const maxStep = 30
nodes.forEach((node) => {
if (node.fx != null) {
node.x = node.fx
node.y = node.fy as number
return
}
let dx = node.vx * alpha
let dy = node.vy * alpha
const m = Math.hypot(dx, dy)
if (m > maxStep) {
dx = (dx / m) * maxStep
dy = (dy / m) * maxStep
}
node.x += dx
node.y += dy
})
paint()
alpha += (0 - alpha) * alphaDecay
// Keep running while cooling, or while a node is being dragged.
if (alpha > alphaMin || dragging) {
raf = requestAnimationFrame(step)
}
}
raf = requestAnimationFrame(step)
return () => {
alive = false
cancelAnimationFrame(raf)
}
// warmTick re-arms the loop on demand (e.g. while dragging) without
// rebuilding the model, so positions are preserved.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [model, warmTick])
// Apply the view transform imperatively (no re-render needed).
const applyView = () => {
const v = viewRef.current
if (rootRef.current) rootRef.current.setAttribute('transform', `translate(${v.x},${v.y}) scale(${v.k})`)
}
useEffect(applyView)
// Convert a pointer event to graph (pre-transform) coordinates.
const toGraph = (clientX: number, clientY: number) => {
const rect = svgRef.current!.getBoundingClientRect()
const v = viewRef.current
return { x: (clientX - rect.left - v.x) / v.k, y: (clientY - rect.top - v.y) / v.k }
}
// Wheel zoom centered on the cursor (matches d3.zoom scaleExtent [0.2, 5]).
const onWheel = (e: React.WheelEvent) => {
e.preventDefault()
const v = viewRef.current
const rect = svgRef.current!.getBoundingClientRect()
const px = e.clientX - rect.left
const py = e.clientY - rect.top
const factor = Math.exp(-e.deltaY * 0.0015)
const k = Math.min(5, Math.max(0.2, v.k * factor))
viewRef.current = { k, x: px - ((px - v.x) / v.k) * k, y: py - ((py - v.y) / v.k) * k }
applyView()
}
// Drag: on a node moves the node, on background pans the canvas.
const dragRef = useRef<
| { mode: 'node'; node: SimNode; moved: boolean }
| { mode: 'pan'; startX: number; startY: number; ox: number; oy: number }
| null
>(null)
const kick = () => setWarmTick((v) => v + 1)
const onPointerDownNode = (e: React.PointerEvent, node: SimNode) => {
e.stopPropagation()
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
const p = toGraph(e.clientX, e.clientY)
node.fx = p.x
node.fy = p.y
dragRef.current = { mode: 'node', node, moved: false }
kick()
}
const onPointerDownBg = (e: React.PointerEvent) => {
;(e.currentTarget as Element).setPointerCapture(e.pointerId)
const v = viewRef.current
dragRef.current = { mode: 'pan', startX: e.clientX, startY: e.clientY, ox: v.x, oy: v.y }
}
const onPointerMove = (e: React.PointerEvent) => {
const drag = dragRef.current
if (!drag) return
if (drag.mode === 'node') {
const p = toGraph(e.clientX, e.clientY)
drag.node.fx = p.x
drag.node.fy = p.y
drag.moved = true
// Keep the loop warm for live dragging.
const el = groupEls.current.get(drag.node.id)
if (el) el.setAttribute('transform', `translate(${p.x},${p.y})`)
} else {
viewRef.current = { k: viewRef.current.k, x: drag.ox + (e.clientX - drag.startX), y: drag.oy + (e.clientY - drag.startY) }
applyView()
}
}
const onPointerUp = (e: React.PointerEvent, node?: SimNode) => {
const drag = dragRef.current
if (drag?.mode === 'node') {
drag.node.fx = null
drag.node.fy = null
if (node && !drag.moved) onSelect(node.id, node.label)
kick()
}
dragRef.current = null
try {
;(e.target as Element).releasePointerCapture(e.pointerId)
} catch {
/* noop */
}
}
const { nodes, links, adjacency, categories, colorOf } = model
const { w, h } = sizeRef.current
const isDimmed = (id: string) => hover != null && hover !== id && !adjacency.get(hover)?.has(id)
const isLinkActive = (aId: string, bId: string) => hover === aId || hover === bId
return (
<div ref={wrapRef} className="w-full h-full relative overflow-hidden">
<svg
ref={svgRef}
width={w}
height={h}
className="select-none block cursor-grab active:cursor-grabbing"
onWheel={onWheel}
onPointerDown={onPointerDownBg}
onPointerMove={onPointerMove}
onPointerUp={(e) => onPointerUp(e)}
>
<g ref={rootRef}>
{links.map(({ key, a, b }) => {
const active = isLinkActive(a.id, b.id)
return (
<line
key={key}
ref={(el) => {
if (el) lineEls.current.set(key, el)
else lineEls.current.delete(key)
}}
x1={a.x}
y1={a.y}
x2={b.x}
y2={b.y}
stroke="#94a3b8"
strokeOpacity={hover ? (active ? 0.8 : 0.1) : 0.3}
strokeWidth={1}
/>
)
})}
{nodes.map((n) => {
const r = nodeRadius(n.degree)
const dim = isDimmed(n.id)
return (
<g
key={n.id}
ref={(el) => {
if (el) groupEls.current.set(n.id, el)
else groupEls.current.delete(n.id)
}}
transform={`translate(${n.x},${n.y})`}
className="cursor-pointer"
opacity={dim ? 0.2 : 1}
onMouseEnter={() => setHover(n.id)}
onMouseLeave={() => setHover(null)}
onPointerDown={(e) => onPointerDownNode(e, n)}
onPointerUp={(e) => onPointerUp(e, n)}
>
<circle r={r} fill={colorOf(n.category)} stroke="#fff" strokeWidth={1.5} />
{(hover === n.id || n.degree >= 3) && (
<text x={r + 4} y={3} className="fill-content-secondary" fontSize={9} style={{ pointerEvents: 'none' }}>
{n.label.length > 15 ? n.label.slice(0, 14) + '…' : n.label}
</text>
)}
</g>
)
})}
</g>
</svg>
{/* Category legend, mirrors the web client. */}
{categories.length > 0 && (
<div className="absolute bottom-3 left-3 flex flex-wrap gap-x-3 gap-y-1 max-w-[60%] rounded-lg bg-surface px-3 py-2 border border-subtle shadow-sm">
{categories.map((cat) => (
<span key={cat} className="inline-flex items-center gap-1.5 text-[11px] text-content-secondary">
<span className="w-2.5 h-2.5 rounded-full" style={{ background: colorOf(cat) }} />
{cat}
</span>
))}
</div>
)}
</div>
)
}
export default KnowledgeGraph

View File

@@ -14,6 +14,21 @@ const translations: Record<string, Record<string, string>> = {
menu_models: '模型',
menu_knowledge: '知识',
menu_settings: '设置',
// knowledge
knowledge_title: '知识库',
knowledge_desc: '浏览和探索你的知识库',
knowledge_tab_docs: '文档',
knowledge_tab_graph: '图谱',
knowledge_search: '搜索文档...',
knowledge_stats: '{pages} 篇 · {size}',
knowledge_select_hint: '从左侧选择一个文档查看',
knowledge_empty: '知识库还是空的',
knowledge_empty_guide: '在对话中发送文档、链接或主题给 Agent它会自动整理到你的知识库中',
knowledge_go_chat: '开始对话',
knowledge_loading: '加载知识库中...',
knowledge_graph_empty: '暂无关联图谱',
knowledge_disabled: '知识库未启用',
knowledge_doc_load_error: '文档加载失败',
nav_expand: '展开侧栏',
nav_collapse: '收起侧栏',
sessions_title: '会话',
@@ -192,6 +207,21 @@ const translations: Record<string, Record<string, string>> = {
menu_logs: 'Logs',
menu_models: 'Models',
menu_knowledge: 'Knowledge',
// knowledge
knowledge_title: 'Knowledge Base',
knowledge_desc: 'Browse and explore your knowledge base',
knowledge_tab_docs: 'Documents',
knowledge_tab_graph: 'Graph',
knowledge_search: 'Search documents...',
knowledge_stats: '{pages} pages · {size}',
knowledge_select_hint: 'Select a document to view',
knowledge_empty: 'Your knowledge base is empty',
knowledge_empty_guide: 'Send documents, links or topics to the agent in chat — it will organize them into your knowledge base',
knowledge_go_chat: 'Start chatting',
knowledge_loading: 'Loading knowledge base...',
knowledge_graph_empty: 'No graph available',
knowledge_disabled: 'Knowledge base is disabled',
knowledge_doc_load_error: 'Failed to load document',
menu_settings: 'Settings',
nav_expand: 'Expand sidebar',
nav_collapse: 'Collapse sidebar',

View File

@@ -0,0 +1,385 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import {
Loader2,
Search,
FileText,
ChevronRight,
ChevronDown,
MessageSquarePlus,
Network,
Files,
} from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { t } from '../i18n'
import apiClient from '../api/client'
import type { KnowledgeDir, KnowledgeFile, KnowledgeList, KnowledgeGraph as KnowledgeGraphData } from '../types'
import Markdown from '../components/Markdown'
import KnowledgeGraph from '../components/KnowledgeGraph'
interface KnowledgePageProps {
baseUrl: string
}
type Tab = 'docs' | 'graph'
const formatSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
// Find the first document (root files first, then a DFS over the tree).
function firstFile(list: KnowledgeList): { path: string; title: string } | null {
const root = list.root_files?.[0]
if (root) return { path: root.name, title: root.title || root.name }
const walk = (dir: KnowledgeDir, prefix: string): { path: string; title: string } | null => {
const dirPath = prefix ? `${prefix}/${dir.dir}` : dir.dir
const f = dir.files[0]
if (f) return { path: `${dirPath}/${f.name}`, title: f.title || f.name }
for (const c of dir.children) {
const hit = walk(c, dirPath)
if (hit) return hit
}
return null
}
for (const d of list.tree || []) {
const hit = walk(d, '')
if (hit) return hit
}
return null
}
const KnowledgePage: React.FC<KnowledgePageProps> = ({ baseUrl }) => {
const navigate = useNavigate()
const [tab, setTab] = useState<Tab>('docs')
const [data, setData] = useState<KnowledgeList | null>(null)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [activePath, setActivePath] = useState<string | null>(null)
const [docTitle, setDocTitle] = useState('')
const [content, setContent] = useState('')
const [docLoading, setDocLoading] = useState(false)
const [graph, setGraph] = useState<KnowledgeGraphData | null>(null)
const [graphLoading, setGraphLoading] = useState(false)
const openDoc = useCallback(async (path: string, title: string) => {
setActivePath(path)
setDocTitle(title)
setDocLoading(true)
setContent('')
try {
const res = await apiClient.readKnowledge(path)
setContent(res.content || '')
} catch {
setContent(`> ${t('knowledge_doc_load_error')}`)
} finally {
setDocLoading(false)
}
}, [])
useEffect(() => {
apiClient.setBaseUrl(baseUrl)
let cancelled = false
;(async () => {
try {
setLoading(true)
const fresh = await apiClient.getKnowledgeList()
if (cancelled) return
setData(fresh)
// Auto-open the first document so the viewer isn't empty on entry.
const first = firstFile(fresh)
if (first) void openDoc(first.path, first.title)
} catch (e) {
console.error('Failed to load knowledge:', e)
} finally {
if (!cancelled) setLoading(false)
}
})()
return () => {
cancelled = true
}
}, [baseUrl, openDoc])
const loadGraph = useCallback(async () => {
if (graph) return
setGraphLoading(true)
try {
setGraph(await apiClient.getKnowledgeGraph())
} catch (e) {
console.error('Failed to load graph:', e)
setGraph({ nodes: [], links: [] })
} finally {
setGraphLoading(false)
}
}, [graph])
const switchTab = (next: Tab) => {
setTab(next)
if (next === 'graph') void loadGraph()
}
// Jump from a graph node to its document.
const onGraphSelect = useCallback(
(id: string, label: string) => {
setTab('docs')
void openDoc(id, label)
},
[openDoc]
)
const totalPages = data?.stats?.pages ?? 0
const statsLabel = useMemo(() => {
if (!data) return ''
return t('knowledge_stats')
.replace('{pages}', String(data.stats?.pages ?? 0))
.replace('{size}', formatSize(data.stats?.size ?? 0))
}, [data])
if (loading) {
return (
<div className="flex-1 flex items-center justify-center text-content-tertiary">
<Loader2 size={18} className="animate-spin mr-2" />
{t('knowledge_loading')}
</div>
)
}
const isEmpty = !data || totalPages === 0
if (isEmpty) {
return (
<div className="flex-1 flex flex-col items-center justify-center px-6 text-center">
<div className="w-14 h-14 rounded-2xl bg-accent-soft text-accent flex items-center justify-center mb-5">
<Files size={26} />
</div>
<h2 className="text-lg font-semibold text-content mb-2">
{data?.enabled === false ? t('knowledge_disabled') : t('knowledge_empty')}
</h2>
<p className="text-sm text-content-tertiary max-w-md mb-6">{t('knowledge_empty_guide')}</p>
<button
onClick={() => navigate('/')}
className="inline-flex items-center gap-2 px-4 py-2 rounded-btn bg-accent text-accent-contrast hover:bg-accent-hover text-sm font-medium cursor-pointer transition-colors"
>
<MessageSquarePlus size={15} />
{t('knowledge_go_chat')}
</button>
</div>
)
}
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Header */}
<div className="flex items-center justify-between px-6 pt-5 pb-3 flex-shrink-0">
<div>
<h2 className="text-xl font-bold text-content">{t('knowledge_title')}</h2>
<p className="text-xs text-content-tertiary mt-1">{statsLabel}</p>
</div>
<div className="flex items-center gap-1 bg-inset rounded-btn p-0.5">
<TabBtn icon={Files} label={t('knowledge_tab_docs')} active={tab === 'docs'} onClick={() => switchTab('docs')} />
<TabBtn
icon={Network}
label={t('knowledge_tab_graph')}
active={tab === 'graph'}
onClick={() => switchTab('graph')}
/>
</div>
</div>
{tab === 'docs' ? (
<div className="flex-1 flex min-h-0 border-t border-default">
{/* Tree sidebar */}
<div className="w-72 flex-shrink-0 flex flex-col border-r border-default min-h-0">
<div className="p-3 flex-shrink-0">
<div className="relative">
<Search size={14} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-content-tertiary" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('knowledge_search')}
className="w-full pl-8 pr-3 py-2 rounded-btn border border-strong bg-inset text-sm text-content placeholder:text-content-tertiary focus:outline-none focus:border-accent transition-colors"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-2 pb-3">
<Tree
data={data}
search={search.trim().toLowerCase()}
activePath={activePath}
onOpen={openDoc}
/>
</div>
</div>
{/* Document viewer */}
<div className="flex-1 min-w-0 overflow-y-auto">
{!activePath ? (
<div className="h-full flex flex-col items-center justify-center text-content-tertiary">
<FileText size={28} className="mb-3 opacity-50" />
<p className="text-sm">{t('knowledge_select_hint')}</p>
</div>
) : (
<div className="max-w-3xl mx-auto px-6 py-6">
<h1 className="text-lg font-semibold text-content mb-1">{docTitle}</h1>
<p className="text-xs text-content-tertiary mb-5 font-mono">{activePath}</p>
{docLoading ? (
<div className="flex items-center text-content-tertiary py-8">
<Loader2 size={16} className="animate-spin mr-2" />
</div>
) : (
<Markdown content={content} />
)}
</div>
)}
</div>
</div>
) : (
<div className="flex-1 min-h-0 border-t border-default relative">
{graphLoading ? (
<div className="absolute inset-0 flex items-center justify-center text-content-tertiary">
<Loader2 size={18} className="animate-spin mr-2" />
</div>
) : graph && graph.nodes.length > 0 ? (
<KnowledgeGraph data={graph} onSelect={onGraphSelect} />
) : (
<div className="absolute inset-0 flex items-center justify-center text-content-tertiary text-sm">
{t('knowledge_graph_empty')}
</div>
)}
</div>
)}
</div>
)
}
const TabBtn: React.FC<{
icon: LucideIcon
label: string
active: boolean
onClick: () => void
}> = ({ icon: Icon, label, active, onClick }) => (
<button
onClick={onClick}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-[6px] text-sm font-medium cursor-pointer transition-colors ${
active ? 'bg-surface text-content shadow-sm' : 'text-content-tertiary hover:text-content-secondary'
}`}
>
<Icon size={14} />
{label}
</button>
)
// ---- Tree rendering --------------------------------------------------------
const Tree: React.FC<{
data: KnowledgeList
search: string
activePath: string | null
onOpen: (path: string, title: string) => void
}> = ({ data, search, activePath, onOpen }) => {
const matches = (f: KnowledgeFile) => !search || f.title.toLowerCase().includes(search) || f.name.toLowerCase().includes(search)
return (
<div className="space-y-0.5">
{(data.root_files || []).filter(matches).map((f) => (
<FileLeaf
key={f.name}
path={f.name}
title={f.title || f.name}
active={activePath === f.name}
onOpen={onOpen}
/>
))}
{(data.tree || []).map((dir) => (
<DirNode key={dir.dir} dir={dir} prefix="" search={search} activePath={activePath} onOpen={onOpen} />
))}
</div>
)
}
// Count files in a dir subtree that match the search.
function countMatches(dir: KnowledgeDir, search: string): number {
const own = dir.files.filter(
(f) => !search || f.title.toLowerCase().includes(search) || f.name.toLowerCase().includes(search)
).length
return own + dir.children.reduce((acc, c) => acc + countMatches(c, search), 0)
}
const DirNode: React.FC<{
dir: KnowledgeDir
prefix: string
search: string
activePath: string | null
onOpen: (path: string, title: string) => void
}> = ({ dir, prefix, search, activePath, onOpen }) => {
const dirPath = prefix ? `${prefix}/${dir.dir}` : dir.dir
const [open, setOpen] = useState(true)
const matchCount = search ? countMatches(dir, search) : dir.files.length + dir.children.length
if (search && matchCount === 0) return null
const visibleFiles = dir.files.filter(
(f) => !search || f.title.toLowerCase().includes(search) || f.name.toLowerCase().includes(search)
)
const expanded = open || !!search
return (
<div>
<button
onClick={() => setOpen((v) => !v)}
className="w-full flex items-center gap-1 px-2 py-1.5 rounded-btn text-sm text-content-secondary hover:bg-surface-2 cursor-pointer transition-colors"
>
{expanded ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
<span className="truncate font-medium">{dir.dir}</span>
<span className="ml-auto text-xs text-content-tertiary">{matchCount}</span>
</button>
{expanded && (
<div className="ml-3 border-l border-default pl-1.5 space-y-0.5">
{visibleFiles.map((f) => {
const fpath = `${dirPath}/${f.name}`
return (
<FileLeaf
key={fpath}
path={fpath}
title={f.title || f.name}
active={activePath === fpath}
onOpen={onOpen}
/>
)
})}
{dir.children.map((c) => (
<DirNode
key={c.dir}
dir={c}
prefix={dirPath}
search={search}
activePath={activePath}
onOpen={onOpen}
/>
))}
</div>
)}
</div>
)
}
const FileLeaf: React.FC<{
path: string
title: string
active: boolean
onOpen: (path: string, title: string) => void
}> = ({ path, title, active, onOpen }) => (
<button
onClick={() => onOpen(path, title)}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-btn text-sm cursor-pointer transition-colors text-left ${
active ? 'bg-accent-soft text-accent' : 'text-content-secondary hover:bg-surface-2'
}`}
>
<FileText size={13} className="flex-shrink-0 opacity-70" />
<span className="truncate">{title}</span>
</button>
)
export default KnowledgePage

View File

@@ -376,17 +376,22 @@ export interface MemoryPage {
// Knowledge
// ============================================================
export interface KnowledgeNode {
export interface KnowledgeFile {
name: string
path: string
type: 'category' | 'file'
children?: KnowledgeNode[]
count?: number
title: string
size: number
}
// A directory node in the knowledge tree (recursive).
export interface KnowledgeDir {
dir: string
files: KnowledgeFile[]
children: KnowledgeDir[]
}
export interface KnowledgeList {
root_files?: KnowledgeNode[]
tree: KnowledgeNode[]
root_files?: KnowledgeFile[]
tree: KnowledgeDir[]
stats: { pages: number; size: number }
enabled: boolean
}