From 108d04398bcbb158560957f52bbeed9d3330aeff Mon Sep 17 00:00:00 2001 From: zhayujie Date: Sat, 20 Jun 2026 16:35:06 +0800 Subject: [PATCH] feat(desktop): implement Knowledge Base page --- desktop/src/renderer/src/App.tsx | 4 +- .../src/components/KnowledgeGraph.tsx | 407 ++++++++++++++++++ desktop/src/renderer/src/i18n.ts | 30 ++ .../src/renderer/src/pages/KnowledgePage.tsx | 385 +++++++++++++++++ desktop/src/renderer/src/types.ts | 19 +- 5 files changed, 836 insertions(+), 9 deletions(-) create mode 100644 desktop/src/renderer/src/components/KnowledgeGraph.tsx create mode 100644 desktop/src/renderer/src/pages/KnowledgePage.tsx 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 ( +
+ onPointerUp(e)} + > + + {links.map(({ key, a, b }) => { + const active = isLinkActive(a.id, b.id) + return ( + { + 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 ( + { + 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)} + > + + {(hover === n.id || n.degree >= 3) && ( + + {n.label.length > 15 ? n.label.slice(0, 14) + '…' : n.label} + + )} + + ) + })} + + + + {/* 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 }