mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
feat(desktop): implement Knowledge Base page
This commit is contained in:
@@ -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} />} />
|
||||
|
||||
407
desktop/src/renderer/src/components/KnowledgeGraph.tsx
Normal file
407
desktop/src/renderer/src/components/KnowledgeGraph.tsx
Normal 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
|
||||
@@ -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',
|
||||
|
||||
385
desktop/src/renderer/src/pages/KnowledgePage.tsx
Normal file
385
desktop/src/renderer/src/pages/KnowledgePage.tsx
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user