diff --git a/desktop/src/renderer/src/App.tsx b/desktop/src/renderer/src/App.tsx
index 9e6ff77a..5e746074 100644
--- a/desktop/src/renderer/src/App.tsx
+++ b/desktop/src/renderer/src/App.tsx
@@ -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 = () => {
} />
- } />
+ } />
} />
} />
} />
diff --git a/desktop/src/renderer/src/components/KnowledgeGraph.tsx b/desktop/src/renderer/src/components/KnowledgeGraph.tsx
new file mode 100644
index 00000000..cd508c52
--- /dev/null
+++ b/desktop/src/renderer/src/components/KnowledgeGraph.tsx
@@ -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 = ({ data, onSelect }) => {
+ const wrapRef = useRef(null)
+ const svgRef = useRef(null)
+ const sizeRef = useRef({ w: 800, h: 560 })
+ const [hover, setHover] = useState(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()
+ 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>()
+ 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(null)
+ const lineEls = useRef(new Map())
+ const groupEls = useRef(new Map())
+
+ // 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 (
+
+
+
+ {/* Category legend, mirrors the web client. */}
+ {categories.length > 0 && (
+
+ {categories.map((cat) => (
+
+
+ {cat}
+
+ ))}
+
+ )}
+
+ )
+}
+
+export default KnowledgeGraph
diff --git a/desktop/src/renderer/src/i18n.ts b/desktop/src/renderer/src/i18n.ts
index 0acc408e..d90cdb94 100644
--- a/desktop/src/renderer/src/i18n.ts
+++ b/desktop/src/renderer/src/i18n.ts
@@ -14,6 +14,21 @@ const translations: Record> = {
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> = {
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',
diff --git a/desktop/src/renderer/src/pages/KnowledgePage.tsx b/desktop/src/renderer/src/pages/KnowledgePage.tsx
new file mode 100644
index 00000000..7c91c0eb
--- /dev/null
+++ b/desktop/src/renderer/src/pages/KnowledgePage.tsx
@@ -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 = ({ baseUrl }) => {
+ const navigate = useNavigate()
+ const [tab, setTab] = useState('docs')
+ const [data, setData] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [search, setSearch] = useState('')
+
+ const [activePath, setActivePath] = useState(null)
+ const [docTitle, setDocTitle] = useState('')
+ const [content, setContent] = useState('')
+ const [docLoading, setDocLoading] = useState(false)
+
+ const [graph, setGraph] = useState(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 (
+
+
+ {t('knowledge_loading')}
+
+ )
+ }
+
+ const isEmpty = !data || totalPages === 0
+
+ if (isEmpty) {
+ return (
+
+
+
+
+
+ {data?.enabled === false ? t('knowledge_disabled') : t('knowledge_empty')}
+
+
{t('knowledge_empty_guide')}
+
+
+ )
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
{t('knowledge_title')}
+
{statsLabel}
+
+
+ switchTab('docs')} />
+ switchTab('graph')}
+ />
+
+
+
+ {tab === 'docs' ? (
+
+ {/* Tree sidebar */}
+
+
+
+
+ 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"
+ />
+
+
+
+
+
+
+
+ {/* Document viewer */}
+
+ {!activePath ? (
+
+
+
{t('knowledge_select_hint')}
+
+ ) : (
+
+
{docTitle}
+
{activePath}
+ {docLoading ? (
+
+
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ ) : (
+
+ {graphLoading ? (
+
+
+
+ ) : graph && graph.nodes.length > 0 ? (
+
+ ) : (
+
+ {t('knowledge_graph_empty')}
+
+ )}
+
+ )}
+
+ )
+}
+
+const TabBtn: React.FC<{
+ icon: LucideIcon
+ label: string
+ active: boolean
+ onClick: () => void
+}> = ({ icon: Icon, label, active, onClick }) => (
+
+)
+
+// ---- 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 (
+
+ {(data.root_files || []).filter(matches).map((f) => (
+
+ ))}
+ {(data.tree || []).map((dir) => (
+
+ ))}
+
+ )
+}
+
+// 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 (
+
+
+ {expanded && (
+
+ {visibleFiles.map((f) => {
+ const fpath = `${dirPath}/${f.name}`
+ return (
+
+ )
+ })}
+ {dir.children.map((c) => (
+
+ ))}
+
+ )}
+
+ )
+}
+
+const FileLeaf: React.FC<{
+ path: string
+ title: string
+ active: boolean
+ onOpen: (path: string, title: string) => void
+}> = ({ path, title, active, onOpen }) => (
+
+)
+
+export default KnowledgePage
diff --git a/desktop/src/renderer/src/types.ts b/desktop/src/renderer/src/types.ts
index 0b85e072..f9e49afe 100644
--- a/desktop/src/renderer/src/types.ts
+++ b/desktop/src/renderer/src/types.ts
@@ -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
}