diff --git a/frontend/src/api/wiki/index.ts b/frontend/src/api/wiki/index.ts index 0c8ee2839..bb62d8feb 100644 --- a/frontend/src/api/wiki/index.ts +++ b/frontend/src/api/wiki/index.ts @@ -37,9 +37,19 @@ export interface WikiPageListResponse { total_pages: number; } +export interface WikiGraphMeta { + mode: 'overview' | 'ego' | string; + total: number; + returned: number; + truncated: boolean; + center?: string; + depth?: number; +} + export interface WikiGraphData { nodes: { slug: string; title: string; page_type: string; link_count: number }[]; edges: { source: string; target: string }[]; + meta: WikiGraphMeta; } export interface WikiStats { @@ -113,8 +123,32 @@ export function getWikiLog(kbId: string) { return get(`/api/v1/knowledgebase/${kbId}/wiki/log`); } -export function getWikiGraph(kbId: string) { - return get(`/api/v1/knowledgebase/${kbId}/wiki/graph`); +export interface WikiGraphQueryParams { + mode?: 'overview' | 'ego'; + center?: string; + depth?: number; + types?: string[]; + limit?: number; +} + +// getWikiGraph fetches a slice of the wiki link graph. Without params the +// backend returns the top-500 most-connected pages (overview mode). Pass +// `mode: 'ego', center: ` to drill into a specific page's neighborhood. +// For knowledge bases with tens of thousands of pages the overview cap is +// what prevents the browser from choking on a 30MB payload / 100k SVG nodes. +export function getWikiGraph(kbId: string, params?: WikiGraphQueryParams) { + const query = new URLSearchParams(); + if (params) { + if (params.mode) query.set('mode', params.mode); + if (params.center) query.set('center', params.center); + if (params.depth !== undefined) query.set('depth', String(params.depth)); + if (params.limit !== undefined) query.set('limit', String(params.limit)); + if (params.types && params.types.length > 0) { + query.set('types', params.types.join(',')); + } + } + const qs = query.toString(); + return get(`/api/v1/knowledgebase/${kbId}/wiki/graph${qs ? '?' + qs : ''}`); } export function getWikiStats(kbId: string) { diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 8c776ae37..faa3db370 100755 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -1709,6 +1709,38 @@ export default { graphNoData: 'No graph data yet. Upload documents first.', showArrows: 'Show arrows', hideArrows: 'Hide arrows', + expandNeighbors: 'Expand neighbors', + bloomNeighbors: 'Bloom neighbors', + growFrontier: 'Grow frontier ({count})', + growFrontierTitle: 'Expand all {count} dashed-ring nodes in one click', + cardEgoTitle: 'Current focus', + cardOverviewTitle: 'KB overview', + cardOverviewPrimary: '{returned} / {total} nodes', + cardOverviewHintTruncated: 'Double-click any node to focus on its subgraph', + cardOverviewHintFull: 'Showing every node in the knowledge base', + cardRelatedNodes: '{count} related nodes', + helpButtonTitle: 'Help', + helpTitle: 'Canvas actions', + helpClickAction: 'Click', + helpClickDesc: 'Open node details', + helpDblClickAction: 'Double-click', + helpDblClickDesc: 'Focus on this node', + helpShiftClickAction: 'Shift + click', + helpShiftClickDesc: 'Bloom neighbors onto canvas', + helpHoverPlusAction: 'Hover → ⊕', + helpHoverPlusDesc: 'Same as Shift + click', + helpDragAction: 'Drag node', + helpDragDesc: 'Reposition the node', + helpPanAction: 'Drag empty space', + helpPanDesc: 'Pan the canvas', + helpZoomAction: 'Scroll wheel', + helpZoomDesc: 'Zoom the canvas', + neighborsProgress: 'Showing {visible} of {total} neighbors ({hidden} not loaded)', + neighborsAllShown: 'All {total} neighbors are already on the canvas (click Expand to focus on this node’s subgraph)', + neighborsNone: 'This page has no linked neighbors', + neighborsCenterUnreachable: 'Showing {visible} of {total} neighbors ({hidden} unreachable: dead links, filtered, or deleted)', + neighborsOverviewHidden: 'Showing {visible} of {total} neighbors ({hidden} outside the overview — click Expand to pivot here)', + backToOverview: 'Back to overview', healthCheck: 'Health Check', queueStatus: '{count} pending tasks in Wiki queue', issueTitle: 'This page has {count} pending knowledge conflicts or errors', diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts index 43b105b33..8a9a24987 100755 --- a/frontend/src/i18n/locales/ko-KR.ts +++ b/frontend/src/i18n/locales/ko-KR.ts @@ -2321,6 +2321,38 @@ export default { graphNoData: '그래프 데이터가 없습니다. 먼저 문서를 업로드하세요.', showArrows: '화살표 표시', hideArrows: '화살표 숨기기', + expandNeighbors: '이웃 확장', + bloomNeighbors: '이웃 추가', + growFrontier: '경계 확장 ({count})', + growFrontierTitle: '점선 링이 있는 {count}개 노드를 한 번에 확장', + cardEgoTitle: '현재 초점', + cardOverviewTitle: 'KB 개요', + cardOverviewPrimary: '{returned} / {total} 개 노드', + cardOverviewHintTruncated: '노드를 더블클릭하여 서브그래프에 집중', + cardOverviewHintFull: '지식 베이스의 모든 노드 표시 중', + cardRelatedNodes: '관련 노드 {count}개', + helpButtonTitle: '도움말', + helpTitle: '캔버스 작업', + helpClickAction: '클릭', + helpClickDesc: '노드 상세 보기', + helpDblClickAction: '더블클릭', + helpDblClickDesc: '이 노드에 집중', + helpShiftClickAction: 'Shift + 클릭', + helpShiftClickDesc: '이웃을 캔버스에 추가', + helpHoverPlusAction: '호버 → ⊕', + helpHoverPlusDesc: 'Shift + 클릭과 동일', + helpDragAction: '노드 드래그', + helpDragDesc: '노드 위치 조정', + helpPanAction: '빈 공간 드래그', + helpPanDesc: '캔버스 이동', + helpZoomAction: '스크롤 휠', + helpZoomDesc: '캔버스 확대/축소', + neighborsProgress: '이웃 {visible}/{total} 표시됨 ({hidden}개 미로딩)', + neighborsAllShown: '모든 {total}개 이웃이 이미 캔버스에 있습니다 (확장을 클릭하여 이 노드의 서브그래프에 집중)', + neighborsNone: '이 페이지에는 연결된 이웃이 없습니다', + neighborsCenterUnreachable: '이웃 {visible}/{total} 표시됨 ({hidden}개 연결 불가: 끊긴 링크, 필터됨 또는 삭제됨)', + neighborsOverviewHidden: '이웃 {visible}/{total} 표시됨 ({hidden}개는 개요 범위 밖 — 확장을 클릭하여 이곳으로 전환)', + backToOverview: '개요로 돌아가기', healthCheck: "상태 점검", queueStatus: "Wiki 대기열에 {count}개의 보류 중인 작업", issueTitle: "이 페이지에는 {count}개의 대기 중인 지식 충돌 또는 오류가 있습니다", diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts index d9d2ef433..c615c9f70 100755 --- a/frontend/src/i18n/locales/ru-RU.ts +++ b/frontend/src/i18n/locales/ru-RU.ts @@ -1931,6 +1931,38 @@ export default { graphNoData: 'Нет данных графа. Сначала загрузите документы.', showArrows: 'Показать стрелки', hideArrows: 'Скрыть стрелки', + expandNeighbors: 'Развернуть соседей', + bloomNeighbors: 'Добавить соседей', + growFrontier: 'Расширить границу ({count})', + growFrontierTitle: 'Развернуть все {count} узлов с пунктирным кольцом за один клик', + cardEgoTitle: 'Текущий фокус', + cardOverviewTitle: 'Обзор БЗ', + cardOverviewPrimary: '{returned} / {total} узлов', + cardOverviewHintTruncated: 'Дважды щёлкните узел, чтобы сфокусироваться на его подграфе', + cardOverviewHintFull: 'Показаны все узлы базы знаний', + cardRelatedNodes: '{count} связанных узлов', + helpButtonTitle: 'Справка', + helpTitle: 'Действия на холсте', + helpClickAction: 'Клик', + helpClickDesc: 'Открыть детали узла', + helpDblClickAction: 'Двойной клик', + helpDblClickDesc: 'Сфокусироваться на узле', + helpShiftClickAction: 'Shift + клик', + helpShiftClickDesc: 'Добавить соседей на холст', + helpHoverPlusAction: 'Наведение → ⊕', + helpHoverPlusDesc: 'То же, что Shift + клик', + helpDragAction: 'Перетащить узел', + helpDragDesc: 'Изменить положение узла', + helpPanAction: 'Перетащить пустое место', + helpPanDesc: 'Переместить холст', + helpZoomAction: 'Колесо мыши', + helpZoomDesc: 'Масштабировать холст', + neighborsProgress: 'Показано {visible} из {total} соседей (еще {hidden} не загружено)', + neighborsAllShown: 'Все {total} соседей уже на холсте (нажмите «Развернуть», чтобы сфокусироваться на подграфе этого узла)', + neighborsNone: 'У этой страницы нет связанных соседей', + neighborsCenterUnreachable: 'Показано {visible} из {total} соседей ({hidden} недоступны: мёртвые ссылки, отфильтрованы или удалены)', + neighborsOverviewHidden: 'Показано {visible} из {total} соседей ({hidden} вне обзора — нажмите «Развернуть», чтобы переключиться сюда)', + backToOverview: 'Вернуться к обзору', healthCheck: 'Проверка здоровья', queueStatus: '{count} ожидающих задач в очереди Wiki', issueTitle: 'На этой странице есть {count} неразрешенных конфликтов или ошибок знаний', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 6ea5ba247..262d08a5e 100755 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -2292,6 +2292,38 @@ export default { graphNoData: '暂无图谱数据,请先上传文档', showArrows: '显示箭头', hideArrows: '隐藏箭头', + expandNeighbors: '展开邻居', + bloomNeighbors: '叠加邻居', + growFrontier: '扩张边缘 ({count})', + growFrontierTitle: '一键展开当前所有 {count} 个带虚线环的节点', + cardEgoTitle: '当前焦点', + cardOverviewTitle: '全库概览', + cardOverviewPrimary: '{returned} / {total} 个节点', + cardOverviewHintTruncated: '双击任意节点聚焦到该节点的子图', + cardOverviewHintFull: '已展示知识库全部节点', + cardRelatedNodes: '{count} 个相关节点', + helpButtonTitle: '操作帮助', + helpTitle: '画布操作', + helpClickAction: '单击', + helpClickDesc: '打开节点详情', + helpDblClickAction: '双击', + helpDblClickDesc: '以该节点为中心聚焦', + helpShiftClickAction: 'Shift + 单击', + helpShiftClickDesc: '叠加该节点邻居到画布', + helpHoverPlusAction: '悬浮 → ⊕', + helpHoverPlusDesc: '同 Shift + 单击', + helpDragAction: '拖拽节点', + helpDragDesc: '手动调整节点位置', + helpPanAction: '拖拽空白', + helpPanDesc: '平移画布', + helpZoomAction: '滚轮', + helpZoomDesc: '缩放画布', + neighborsProgress: '已显示邻居 {visible}/{total}(还有 {hidden} 个未加载)', + neighborsAllShown: '全部 {total} 个邻居已在图中(可点"展开邻居"聚焦到该节点子图)', + neighborsNone: '该节点没有链接邻居', + neighborsCenterUnreachable: '已显示邻居 {visible}/{total}(还有 {hidden} 个无法连通:可能是死链、被过滤或已删除)', + neighborsOverviewHidden: '已显示邻居 {visible}/{total}({hidden} 个不在概览范围,点"展开邻居"以此为中心查看)', + backToOverview: '返回概览', healthCheck: "健康检查", queueStatus: "Wiki 队列中 {count} 个待处理任务", issueTitle: "此页面存在 {count} 个待处理的知识冲突或错误", diff --git a/frontend/src/views/knowledge/wiki/WikiBrowser.vue b/frontend/src/views/knowledge/wiki/WikiBrowser.vue index 1c3294bea..7ad9e7908 100644 --- a/frontend/src/views/knowledge/wiki/WikiBrowser.vue +++ b/frontend/src/views/knowledge/wiki/WikiBrowser.vue @@ -11,7 +11,9 @@ {{ showArrows ? $t('knowledgeEditor.wikiBrowser.hideArrows') : $t('knowledgeEditor.wikiBrowser.showArrows') }} +
+ + {{ $t('knowledgeEditor.wikiBrowser.growFrontier', { count: graphFrontierCount }) }} +
+
+ + {{ $t('knowledgeEditor.wikiBrowser.backToOverview') }} +
+ +
+ ? + {{ $t('knowledgeEditor.wikiBrowser.helpButtonTitle') }} +
+ +
+
@@ -106,11 +158,35 @@ class="wiki-graph-drawer" > @@ -476,6 +552,17 @@ const showArrows = ref(true) // Graph filtering const graphFilterTypes = ref>(new Set(['summary', 'entity', 'concept', 'synthesis', 'comparison', 'index', 'log'])) +// Graph slicing state. The backend caps an overview fetch at 500 nodes — +// tens-of-thousands-page wikis would otherwise crash the browser trying to +// render 100k SVG elements. `graphMode` tracks whether we're on the +// overview landing or drilled into an ego neighborhood so the UI can offer +// "back to overview" and show the truncation hint. +const graphMode = ref<'overview' | 'ego'>('overview') +const graphCenter = ref('') +const GRAPH_OVERVIEW_LIMIT = 500 +const GRAPH_EGO_LIMIT = 500 +const GRAPH_EGO_DEFAULT_DEPTH = 1 + watch(showGlobalIssuesDrawer, async (val) => { if (val) { try { @@ -510,7 +597,15 @@ async function handleGlobalIssueIgnore(issueId: string) { } } -function toggleGraphFilterType(type: string) { +// toggleGraphFilterType flips a page_type in the active allow-list and +// refetches the graph from the server. Client-side DOM hiding used to +// suffice when the canvas contained every page, but once we cap the +// overview at top-500 by link_count, hiding the "summary" type just +// blanks out most of the canvas without surfacing the next 500 nodes +// that would qualify under the narrowed filter. Re-asking the server +// keeps the top-N always relevant to what the user said they wanted to +// see, at the cost of one network round-trip per toggle. +async function toggleGraphFilterType(type: string) { const newSet = new Set(graphFilterTypes.value) if (newSet.has(type)) { newSet.delete(type) @@ -518,51 +613,34 @@ function toggleGraphFilterType(type: string) { newSet.add(type) } graphFilterTypes.value = newSet - applyGraphFilters() + + // Dismiss any highlight/drawer that no longer matches the new filter + // before we repaint, otherwise the old selection can linger against + // freshly-rendered elements that were never built for it. + graphHighlightSlug.value = null + if (graphSelectedSlug.value && !newSet.has( + graphData.value?.nodes.find(n => n.slug === graphSelectedSlug.value)?.page_type || '' + )) { + graphSelectedSlug.value = null + graphDrawerVisible.value = false + } + + if (graphMode.value === 'ego' && graphCenter.value) { + await loadEgoGraph(graphCenter.value) + } else { + await loadGraph() + } } +// applyGraphFilters is retained as a no-op for compatibility with a +// handful of callers that used to nudge the client-side hide/show state +// (e.g. handleGraphSearchSelect re-enabling a filtered-out type before +// centering on it). With server-side filtering the allow-list change +// itself triggers a refetch via the watcher in toggleGraphFilterType, +// so this function no longer has to do anything. function applyGraphFilters() { - if (!graphReady.value) return - - // Build a map for O(1) lookups - const nodeMap = new Map() - for (const n of graphNodes) { - nodeMap.set(n.slug, n) - } - - // Only show nodes whose type is in the active filter set - for (const { g, node } of graphNodeElsRef) { - if (graphFilterTypes.value.has(node.type)) { - g.style.display = '' - } else { - g.style.display = 'none' - } - } - - // Only show edges where BOTH source and target are visible - for (const { line, source, target } of graphEdgeElsRef) { - const sNode = nodeMap.get(source) - const tNode = nodeMap.get(target) - - if (sNode && tNode && graphFilterTypes.value.has(sNode.type) && graphFilterTypes.value.has(tNode.type)) { - line.style.display = '' - } else { - line.style.display = 'none' - } - } - - // Clear any existing highlight when filtering changes - if (graphHighlightSlug.value || graphSelectedSlug.value) { - const selectedStillVisible = graphSelectedSlug.value && - graphFilterTypes.value.has(nodeMap.get(graphSelectedSlug.value)?.type || '') - - if (!selectedStillVisible) { - graphSelectedSlug.value = null - graphHighlightSlug.value = null - graphDrawerVisible.value = false - } - clearHighlight(graphNodeElsRef, graphEdgeElsRef) - } + // Intentionally empty: server-side filtering handles the actual + // node/edge membership when the allow-list changes. } // Fit graph to view @@ -578,8 +656,8 @@ function fitGraphToView() { let visibleCount = 0 for (const node of graphNodes) { - if (!graphFilterTypes.value.has(node.type)) continue - + // Every node in graphNodes is a visible candidate now that filtering + // is server-side — no need to recheck the client-side allow-list. minX = Math.min(minX, node.x) minY = Math.min(minY, node.y) maxX = Math.max(maxX, node.x) @@ -675,6 +753,215 @@ const graphDrawerContent = computed(() => { return renderMarkdown(graphDrawerPage.value.content) }) +// graphDrawerNeighborStatus describes, for the currently open drawer page, +// how the canvas relates to the KB-wide neighborhood of the node. The +// accounting is subtler than a simple "shown vs link_count" because three +// different situations produce different interpretations of a gap: +// +// ego center — the backend already returned every neighbor reachable +// through BFS at depth 1+. Any difference between `link_count` and +// the visible degree is pages that couldn't be traversed (dead refs, +// type-filtered pages, soft-deleted neighbors), NOT pages we can +// still fetch. Expanding or blooming from the center does nothing +// useful, so we flag it as fullyExplored and disable the buttons. +// +// ego non-center — difference IS "neighbors not yet loaded". The user +// can bloom to pull them in. This is the main signal for the dashed +// expansion ring. +// +// overview — difference is "neighbors that didn't make top-500", and +// the fix isn't bloom (overview doesn't bloom) but pivoting to ego. +// We still disable Bloom (it's an ego-only op) but leave Expand +// enabled so the user can drill down. +const graphDrawerNeighborStatus = computed(() => { + const page = graphDrawerPage.value + if (!page) return null + const data = graphData.value + if (!data) return null + const node = data.nodes.find(n => n.slug === page.slug) + if (!node) { + // Drawer is open on a page that isn't currently on the canvas (e.g. + // the user just clicked a wiki-link that triggered an ego pivot and + // we're between data update and re-render). Treat as unknown so the + // button stays enabled — the pivot will populate neighbors shortly. + return null + } + // Undirected degree within the current subgraph. Both incoming and + // outgoing edges count toward a visible neighbor, matching how + // link_count is computed server-side (in+out). + const neighbors = new Set() + for (const e of data.edges) { + if (e.source === page.slug) neighbors.add(e.target) + else if (e.target === page.slug) neighbors.add(e.source) + } + const visible = neighbors.size + const total = node.link_count || 0 + // hidden can go negative in a rare corner case — a neighbor might be + // visible via an edge that the link_count counter didn't know about + // (e.g. a broken-link cleanup happened after the snapshot). Clamp. + const hidden = Math.max(0, total - visible) + const isEgoCenter = data.meta?.mode === 'ego' && data.meta.center === page.slug + const isOverview = data.meta?.mode === 'overview' + return { + visible, + total, + hidden, + isEgoCenter, + isOverview, + // fullyExplored drives the disabled state of the expand/bloom buttons. + // True when either there's genuinely nothing to load, or when we're + // on the ego center and any remaining gap is unreachable (dead refs + // / filtered out). + fullyExplored: total === 0 || visible >= total || isEgoCenter, + } +}) + +const graphDrawerNeighborHint = computed(() => { + const status = graphDrawerNeighborStatus.value + if (!status) return '' + if (status.total === 0) { + return t('knowledgeEditor.wikiBrowser.neighborsNone') + } + if (status.visible >= status.total) { + // All neighbors already visible. Expand still does something useful + // though — it pivots the canvas to just this node's neighborhood, + // giving the user a focused N-node view instead of wading through + // the 500-node overview. Say so rather than sounding like a dead end. + return t('knowledgeEditor.wikiBrowser.neighborsAllShown', { total: status.total }) + } + if (status.isEgoCenter) { + // hidden > 0 but can't be loaded — distinguish from "未加载". + return t('knowledgeEditor.wikiBrowser.neighborsCenterUnreachable', { + visible: status.visible, + total: status.total, + hidden: status.hidden, + }) + } + if (status.isOverview) { + // hidden means "not in the top-500 subgraph"; bloom doesn't help + // here, expand/pivot does. + return t('knowledgeEditor.wikiBrowser.neighborsOverviewHidden', { + visible: status.visible, + total: status.total, + hidden: status.hidden, + }) + } + return t('knowledgeEditor.wikiBrowser.neighborsProgress', { + visible: status.visible, + total: status.total, + hidden: status.hidden, + }) +}) + +// graphDrawerCanBloom is true when clicking Bloom would actually add +// new nodes to the canvas. Bloom is additive so we only disable it when +// there's nothing to add: either the node is the ego center (BFS already +// gave us everything reachable) or every one of its neighbors is already +// on screen. +const graphDrawerCanBloom = computed(() => { + const status = graphDrawerNeighborStatus.value + if (!status) return true + if (status.isEgoCenter) return false + return status.hidden > 0 +}) + +// graphFrontierCount powers the legend's "Grow frontier (N)" button. It +// counts nodes on the current ego canvas that the user can still expand +// outward from — matches the filter used by growFrontier() itself so +// the button count can never disagree with what the click actually +// expands. Hidden when 0 so the button disappears once the local +// neighborhood is fully explored (or only Index/Log super-nodes remain). +const graphFrontierCount = computed(() => { + const data = graphData.value + if (!data || data.meta?.mode !== 'ego') return 0 + const visibleDegree = new Map() + for (const e of data.edges) { + visibleDegree.set(e.source, (visibleDegree.get(e.source) ?? 0) + 1) + visibleDegree.set(e.target, (visibleDegree.get(e.target) ?? 0) + 1) + } + let count = 0 + const centerSlug = data.meta?.center || '' + for (const n of data.nodes) { + if (isFrontierCandidate(n, centerSlug, visibleDegree.get(n.slug) ?? 0)) { + count += 1 + } + } + return count +}) + +// graphStatusCard drives the little summary panel below the legend. +// +// The old design ("以 A 为中心 · 1 跳 · 7 个节点" / "showing 500 / 40000, +// click a node to expand neighbors") crammed four pieces of info into a +// single line of running prose — the most important bit (what page the +// user is focused on) got lost between the jargon ("1 跳") and the +// imperative tail ("click a node..."). +// +// The card version separates the three jobs into visible slots: +// header → icon + short mode name, tells the user "am I looking at +// the whole wiki or at one page's neighborhood" +// primary → the noun that identifies the current view (page title in +// ego mode, "X / Y 个节点" in overview) +// secondary → optional subline with type badge / hint / progress +// +// We also resolve `meta.center` (a slug) to the actual page title via +// graphData.nodes so users see "北京市昌职…" instead of "entity/beijing-..." +// — a common complaint with the old hint. +// graphHelpRows is the content of the ? popup. Keeping it in a computed +// rather than the template lets us i18n each action/description in one +// place and also makes it trivially extensible — new shortcuts land as +// one row addition each rather than a full template rewrite. The order +// below is "most common → rarest"; users don't typically read past the +// first few rows. +const graphHelpRows = computed(() => [ + { action: t('knowledgeEditor.wikiBrowser.helpClickAction'), desc: t('knowledgeEditor.wikiBrowser.helpClickDesc') }, + { action: t('knowledgeEditor.wikiBrowser.helpDblClickAction'), desc: t('knowledgeEditor.wikiBrowser.helpDblClickDesc') }, + { action: t('knowledgeEditor.wikiBrowser.helpShiftClickAction'), desc: t('knowledgeEditor.wikiBrowser.helpShiftClickDesc') }, + { action: t('knowledgeEditor.wikiBrowser.helpHoverPlusAction'), desc: t('knowledgeEditor.wikiBrowser.helpHoverPlusDesc') }, + { action: t('knowledgeEditor.wikiBrowser.helpDragAction'), desc: t('knowledgeEditor.wikiBrowser.helpDragDesc') }, + { action: t('knowledgeEditor.wikiBrowser.helpPanAction'), desc: t('knowledgeEditor.wikiBrowser.helpPanDesc') }, + { action: t('knowledgeEditor.wikiBrowser.helpZoomAction'), desc: t('knowledgeEditor.wikiBrowser.helpZoomDesc') }, +]) + +const graphStatusCard = computed((): { icon: string; title: string; primary: string; secondary: string } | null => { + const data = graphData.value + if (!data?.meta) return null + const meta = data.meta + if (meta.mode === 'ego' && meta.center) { + const centerNode = data.nodes.find(n => n.slug === meta.center) + const centerTitle = centerNode?.title || meta.center + const typeLabel = centerNode ? getTypeLabel(centerNode.page_type) : '' + // Subtract 1 so the count means "related nodes" (excluding the + // center itself) — matches how users count "connections". If the + // count is 0 the center is an isolated page. + const relatedCount = Math.max(0, meta.returned - 1) + const secondaryParts: string[] = [] + if (typeLabel) secondaryParts.push(typeLabel) + secondaryParts.push(t('knowledgeEditor.wikiBrowser.cardRelatedNodes', { count: relatedCount })) + return { + icon: 'focus', + title: t('knowledgeEditor.wikiBrowser.cardEgoTitle'), + primary: centerTitle, + secondary: secondaryParts.join(' · '), + } + } + if (meta.mode === 'overview') { + const secondary = meta.truncated + ? t('knowledgeEditor.wikiBrowser.cardOverviewHintTruncated') + : t('knowledgeEditor.wikiBrowser.cardOverviewHintFull') + return { + icon: 'chart-bubble', + title: t('knowledgeEditor.wikiBrowser.cardOverviewTitle'), + primary: t('knowledgeEditor.wikiBrowser.cardOverviewPrimary', { + returned: meta.returned, + total: meta.total, + }), + secondary, + } + } + return null +}) + const imagePreviewVisible = ref(false) const imagePreviewUrl = ref('') @@ -870,12 +1157,56 @@ async function refreshSelectedPage() { } } +// graphFilterTypesToArray returns the active allow-list as an array, or +// `undefined` when every known type is selected (in which case we want +// the backend to rank over the full page population, not a subset). +// Callers must check for "no types selected at all" separately and avoid +// the fetch — passing an empty string list to the backend is ambiguous +// there (empty == no filter == return everything, the opposite of what +// the user meant). +function graphFilterTypesToArray(): string[] | undefined { + const all = ['summary', 'entity', 'concept', 'synthesis', 'comparison', 'index', 'log'] + if (all.every(t => graphFilterTypes.value.has(t))) { + return undefined + } + return Array.from(graphFilterTypes.value) +} + +function graphFilterSelectsNothing(): boolean { + return graphFilterTypes.value.size === 0 +} + async function loadGraph() { graphLoading.value = true graphReady.value = false + graphMode.value = 'overview' + graphCenter.value = '' + if (graphFilterSelectsNothing()) { + // User has deselected every type — render an empty canvas without + // hitting the backend. + graphData.value = { nodes: [], edges: [], meta: { mode: 'overview', total: 0, returned: 0, truncated: false } } + await nextTick() + renderGraph() + graphLoading.value = false + return + } try { - const res = await getWikiGraph(props.knowledgeBaseId) + const res = await getWikiGraph(props.knowledgeBaseId, { + mode: 'overview', + limit: GRAPH_OVERVIEW_LIMIT, + types: graphFilterTypesToArray(), + }) graphData.value = (res as any).data || res as any + // Seed the search dropdown's empty-state with this overview snapshot + // so opening the select without typing shows the top-500 by link_count + // — matching what the old client-filter dropdown used to surface. + // We re-seed on every overview load so filter toggles / KB changes + // propagate; ego loads intentionally skip seeding so drilling into a + // neighborhood doesn't shrink the default dropdown to a 20-node subgraph. + setGraphSearchDefaultFromNodes(graphData.value?.nodes) + // Returning to overview clears accumulated bloom state; the next ego + // dive should start fresh rather than inherit an orphan generation map. + resetBloomGenerations(graphData.value?.nodes) await nextTick() renderGraph() if (route.query.slug && typeof route.query.slug === 'string') { @@ -891,6 +1222,326 @@ async function loadGraph() { } } +// loadEgoGraph fetches the neighborhood around a center slug and re-renders +// the canvas. Invoked when the user clicks "expand neighbors" in the drawer +// so they can drill into a page on a 4万+ wiki without ever having to +// download the full graph. Returning to the global top-N view is handled by +// loadGraph() again. +async function loadEgoGraph(slug: string, depth = GRAPH_EGO_DEFAULT_DEPTH) { + if (!slug) return + graphLoading.value = true + graphReady.value = false + if (graphFilterSelectsNothing()) { + graphData.value = { nodes: [], edges: [], meta: { mode: 'ego', total: 0, returned: 0, truncated: false, center: slug, depth } } + graphMode.value = 'ego' + graphCenter.value = slug + resetBloomGenerations(graphData.value.nodes) + await nextTick() + renderGraph() + graphLoading.value = false + return + } + try { + const res = await getWikiGraph(props.knowledgeBaseId, { + mode: 'ego', + center: slug, + depth, + limit: GRAPH_EGO_LIMIT, + types: graphFilterTypesToArray(), + }) + graphData.value = (res as any).data || res as any + graphMode.value = 'ego' + graphCenter.value = slug + // Entering (or re-entering) a fresh ego view resets the bloom + // generation counter — we're no longer accumulating on top of the + // previous canvas, so every node belongs to generation 0. + resetBloomGenerations(graphData.value?.nodes) + await nextTick() + renderGraph() + // After a fresh ego render, preselect the center so the highlight / + // drawer context matches what the user just asked for. + graphSelectedSlug.value = slug + } catch (e) { + console.error(`Failed to load ego graph for ${slug}:`, e) + } finally { + graphLoading.value = false + } +} + +// ─── Bloom: additive neighbor expansion ────────────────────────────────── +// +// While loadEgoGraph replaces the canvas with a fresh ego view, bloom lets +// the user add a second (or Nth) ego around a neighbor WITHOUT losing the +// nodes already on screen. This matches how humans explore a knowledge +// graph interactively — "show me what's around A", "now also show me +// what's around B, but keep A visible for context". +// +// Three pieces of state cooperate: +// - bloomGenerations: slug -> generation number. Generation 0 is the +// initial ego view; each bloom increments a counter and tags the +// newly arrived nodes with that generation. LRU eviction walks by +// generation, oldest first. +// - BLOOM_MAX_NODES: hard cap on rendered nodes. Past this point each +// bloom triggers LRU eviction to keep the force simulation responsive. +// - We reuse the existing graphData.value as the accumulator — new ego +// responses are merged into it in place, then handed back to renderGraph +// in preserveLayout mode. +const BLOOM_MAX_NODES = 1500 +const bloomGenerations = new Map() +let bloomCurrentGeneration = 0 + +function resetBloomGenerations(nodes: { slug: string }[] | undefined) { + bloomGenerations.clear() + bloomCurrentGeneration = 0 + if (!nodes) return + for (const n of nodes) { + bloomGenerations.set(n.slug, 0) + } +} + +async function loadBloomNeighbors(anchorSlug: string, depth = GRAPH_EGO_DEFAULT_DEPTH) { + if (!anchorSlug) return + if (!graphData.value) return + if (graphMode.value !== 'ego') { + // Bloom only makes sense on top of an ego view. If we're still on the + // overview, reuse loadEgoGraph to pivot cleanly — that's a less + // surprising outcome than no-oping. + await loadEgoGraph(anchorSlug, depth) + return + } + graphLoading.value = true + try { + const res = await getWikiGraph(props.knowledgeBaseId, { + mode: 'ego', + center: anchorSlug, + depth, + limit: GRAPH_EGO_LIMIT, + types: graphFilterTypesToArray(), + }) + const incoming = (res as any).data || res as any + if (!incoming || !Array.isArray(incoming.nodes)) return + + bloomCurrentGeneration += 1 + const merged = mergeGraphData(graphData.value, incoming, bloomCurrentGeneration) + // Evict the oldest bloom generations if we've blown through the cap. + // We never evict the ego center (the original anchor of the session), + // the most recent bloom anchor, or the currently selected node — the + // user's mental anchors must stay on screen. + const protect = new Set([ + graphCenter.value, + anchorSlug, + graphSelectedSlug.value || '', + ].filter(Boolean)) + evictBloomOverflow(merged, protect) + + graphData.value = merged + await nextTick() + renderGraph({ preserveLayout: true, anchorSlug }) + } catch (e) { + console.error(`Failed to bloom neighbors for ${anchorSlug}:`, e) + } finally { + graphLoading.value = false + } +} + +// mergeGraphData folds `incoming` into `base` in-place-style (returns a +// new object for Vue reactivity but shares page node shape). Dedupes +// nodes by slug and edges by (source, target). New node slugs are tagged +// with `gen` so LRU knows which generation they belong to. +function mergeGraphData( + base: WikiGraphData, + incoming: WikiGraphData, + gen: number, +): WikiGraphData { + const nodeBySlug = new Map() + for (const n of base.nodes) nodeBySlug.set(n.slug, n) + for (const n of incoming.nodes) { + if (!nodeBySlug.has(n.slug)) { + nodeBySlug.set(n.slug, n) + bloomGenerations.set(n.slug, gen) + } + } + const edgeKey = (e: { source: string; target: string }) => `${e.source}→${e.target}` + const edgeSeen = new Set() + const edges: WikiGraphData['edges'] = [] + for (const e of base.edges) { + const k = edgeKey(e) + if (!edgeSeen.has(k)) { edgeSeen.add(k); edges.push(e) } + } + for (const e of incoming.edges) { + const k = edgeKey(e) + if (!edgeSeen.has(k)) { edgeSeen.add(k); edges.push(e) } + } + return { + nodes: Array.from(nodeBySlug.values()), + edges, + meta: { + // Meta from the latest ego response describes the most recent + // bloom, but we keep the overview denominator so the truncation + // hint still reflects the KB-wide total. + ...incoming.meta, + returned: nodeBySlug.size, + }, + } +} + +// evictBloomOverflow walks generations oldest-first and drops nodes +// (plus their incident edges) until the total fits under BLOOM_MAX_NODES. +// `protect` holds slugs that must never be evicted (current center, most +// recent bloom anchor, current selection). Generation-0 nodes are +// protected too — those are the original ego view the user started with. +function evictBloomOverflow(data: WikiGraphData, protect: Set) { + if (data.nodes.length <= BLOOM_MAX_NODES) return + + // Group slugs by generation descending-safe: we only evict gen >= 1. + const byGen = new Map() + for (const n of data.nodes) { + const g = bloomGenerations.get(n.slug) ?? 0 + if (g === 0) continue + if (protect.has(n.slug)) continue + if (!byGen.has(g)) byGen.set(g, []) + byGen.get(g)!.push(n.slug) + } + const gens = Array.from(byGen.keys()).sort((a, b) => a - b) + + const toRemove = new Set() + let remaining = data.nodes.length + for (const g of gens) { + if (remaining <= BLOOM_MAX_NODES) break + for (const slug of byGen.get(g)!) { + if (remaining <= BLOOM_MAX_NODES) break + toRemove.add(slug) + remaining -= 1 + } + } + if (toRemove.size === 0) return + + data.nodes = data.nodes.filter(n => !toRemove.has(n.slug)) + data.edges = data.edges.filter(e => !toRemove.has(e.source) && !toRemove.has(e.target)) + for (const slug of toRemove) bloomGenerations.delete(slug) +} + +// GROW_FRONTIER_CONCURRENCY is the number of parallel ego fetches we +// allow when the user asks us to expand the whole frontier at once. A +// 4万-page wiki can have ~100 frontier nodes; firing all 100 requests in +// parallel would hammer the backend and most responses would compete for +// the same DB connection pool anyway. 6 is chosen empirically: it keeps +// latency for the "whole frontier" op under ~2s for typical frontiers +// without spiking DB CPU. +const GROW_FRONTIER_CONCURRENCY = 6 + +// GRAPH_SYSTEM_PAGE_TYPES are wiki page types that act as index-of-the- +// whole-KB rather than content nodes. They link out to every document +// page by design, so treating them as part of the frontier would cause +// one "Grow frontier" click to dump the entire wiki onto the canvas — +// exactly the opposite of what the user asked for ("show me more of the +// interesting neighborhood"). We keep them visible and individually +// expandable (double-click / shift-click / ⊕ all still work), but they +// don't participate in batch expansion. +const GRAPH_SYSTEM_PAGE_TYPES = new Set(['index', 'log']) + +function isFrontierCandidate( + node: { slug: string; page_type: string; link_count: number }, + centerSlug: string, + visibleDegree: number, +): boolean { + if (node.slug === centerSlug) return false + if (GRAPH_SYSTEM_PAGE_TYPES.has(node.page_type)) return false + return (node.link_count || 0) > visibleDegree +} + +// growFrontier is the "one-click expand everything" operator. It finds +// every visible node that currently has an expansion ring (visible < link_count, +// not the ego center, not an Index/Log super-node), fires parallel ego +// fetches for them, merges all responses together and repaints the canvas +// preserving layout. This is the batch cousin of loadBloomNeighbors — +// one click grows the canvas along every branch instead of 100 individual +// click-by-click iterations. +async function growFrontier() { + if (!graphData.value) return + if (graphMode.value !== 'ego') { + // Frontier expansion only makes sense on top of an ego layout. + // Overview has its own pivot mechanism (expand a single node). + return + } + if (graphFilterSelectsNothing()) return + + // Collect frontier nodes: visible degree < link_count AND not the ego + // center AND not a system super-node. We compute visible degree inline + // from edges so we don't depend on the stale adjacency snapshot from + // the last render. + const visibleDegree = new Map() + for (const e of graphData.value.edges) { + visibleDegree.set(e.source, (visibleDegree.get(e.source) ?? 0) + 1) + visibleDegree.set(e.target, (visibleDegree.get(e.target) ?? 0) + 1) + } + const frontier: string[] = [] + for (const n of graphData.value.nodes) { + if (isFrontierCandidate(n, graphCenter.value, visibleDegree.get(n.slug) ?? 0)) { + frontier.push(n.slug) + } + } + if (frontier.length === 0) return + + graphLoading.value = true + try { + // Concurrency-limited fan-out. We collect responses in order of + // completion (doesn't matter — merge is commutative on the edge / + // node sets) and ignore individual failures so one slow/broken node + // doesn't sink the whole batch. + const responses: WikiGraphData[] = [] + let cursor = 0 + async function worker() { + while (cursor < frontier.length) { + const idx = cursor++ + const slug = frontier[idx] + try { + const res = await getWikiGraph(props.knowledgeBaseId, { + mode: 'ego', + center: slug, + depth: GRAPH_EGO_DEFAULT_DEPTH, + limit: GRAPH_EGO_LIMIT, + types: graphFilterTypesToArray(), + }) + const data = (res as any).data || res as any + if (data?.nodes) responses.push(data) + } catch (e) { + console.error(`growFrontier: ego fetch failed for ${slug}:`, e) + } + } + } + const workers: Promise[] = [] + const workerCount = Math.min(GROW_FRONTIER_CONCURRENCY, frontier.length) + for (let i = 0; i < workerCount; i++) workers.push(worker()) + await Promise.all(workers) + + if (responses.length === 0) return + + // All new arrivals belong to a single bloom generation — the user + // performed one logical action, so LRU should evict them together. + bloomCurrentGeneration += 1 + const gen = bloomCurrentGeneration + let merged = graphData.value + for (const incoming of responses) { + merged = mergeGraphData(merged, incoming, gen) + } + const protect = new Set([ + graphCenter.value, + graphSelectedSlug.value || '', + ].filter(Boolean)) + evictBloomOverflow(merged, protect) + + graphData.value = merged + await nextTick() + // anchorSlug intentionally omitted — new nodes have no single natural + // landing point, so we fall back to random canvas-center placement + // and let the force simulation untangle them. + renderGraph({ preserveLayout: true }) + } finally { + graphLoading.value = false + } +} + async function loadPageIssues(slug: string) { try { const res = await listWikiIssues(props.knowledgeBaseId, slug, 'pending') @@ -1075,7 +1726,23 @@ const nodeColorMap: Record = { synthesis: '#0594fa', comparison: '#d54941', index: '#8c8c8c', log: '#8c8c8c', } -function renderGraph() { +// RenderGraphOpts tweaks how renderGraph initializes node positions when +// repainting the canvas. The default (no opts) does a full layout reset — +// every node gets a fresh circular starting position and the force +// simulation runs from scratch. With `preserveLayout: true` we reuse the +// x/y/vx/vy of any node that already existed in the previous graphNodes +// list, and only new nodes get initial positions. This is what the +// "bloom neighbors" interaction needs: when the user expands a second +// ego around a neighbor, the nodes they already see don't jump to new +// positions — only the newly arrived neighbors fly in. +interface RenderGraphOpts { + preserveLayout?: boolean + // anchorSlug: if set and the node is new, it is placed near the anchor + // with a small random jitter so related nodes visually land together. + anchorSlug?: string +} + +function renderGraph(opts: RenderGraphOpts = {}) { const container = graphRef.value const data = graphData.value if (!container) return @@ -1091,6 +1758,16 @@ function renderGraph() { const width = container.clientWidth || 800 const height = container.clientHeight || 600 + // Snapshot prior node coordinates before we rebuild graphNodes. Used + // when preserveLayout is true to avoid the whole canvas jumping during + // an incremental bloom. + const priorCoords = new Map() + if (opts.preserveLayout) { + for (const n of graphNodes) { + priorCoords.set(n.slug, { x: n.x, y: n.y, vx: n.vx, vy: n.vy, pinned: n.pinned }) + } + } + // Create SVG const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') svg.setAttribute('viewBox', `0 0 ${width} ${height}`) @@ -1122,17 +1799,55 @@ function renderGraph() { adjacency.get(edge.target)!.add(edge.source) } + // Locate the anchor's prior coordinates so new nodes land near it in + // bloom mode. Falls back to canvas center if the anchor is itself new + // (e.g. ego was pivoted rather than bloomed). + const anchorCoord = opts.anchorSlug ? priorCoords.get(opts.anchorSlug) : undefined + const anchorX = anchorCoord?.x ?? width / 2 + const anchorY = anchorCoord?.y ?? height / 2 + // Build nodes const nodeMap = new Map() graphNodes = data.nodes.map((n, i) => { - const angle = (2 * Math.PI * i) / data.nodes.length - const r = Math.min(width, height) * 0.35 + const prior = opts.preserveLayout ? priorCoords.get(n.slug) : undefined + let x: number + let y: number + let vx: number + let vy: number + let pinned: boolean + if (prior) { + // Reuse the node's existing position so the user's mental map of + // the canvas stays stable across bloom iterations. + x = prior.x + y = prior.y + vx = prior.vx + vy = prior.vy + pinned = prior.pinned + } else if (opts.preserveLayout && opts.anchorSlug) { + // New node arriving during a bloom — spawn it right next to the + // anchor with a small random kick so the force simulation pushes + // it into place alongside its siblings. + const jitterR = 40 + const angle = Math.random() * Math.PI * 2 + x = anchorX + jitterR * Math.cos(angle) + y = anchorY + jitterR * Math.sin(angle) + vx = 0 + vy = 0 + pinned = false + } else { + // Full repaint — classic circular layout. + const angle = (2 * Math.PI * i) / data.nodes.length + const r = Math.min(width, height) * 0.35 + x = width / 2 + r * Math.cos(angle) + (Math.random() - 0.5) * 50 + y = height / 2 + r * Math.sin(angle) + (Math.random() - 0.5) * 50 + vx = 0 + vy = 0 + pinned = false + } const node: GNode = { - x: width / 2 + r * Math.cos(angle) + (Math.random() - 0.5) * 50, - y: height / 2 + r * Math.sin(angle) + (Math.random() - 0.5) * 50, - vx: 0, vy: 0, + x, y, vx, vy, slug: n.slug, title: n.title, type: n.page_type, - linkCount: n.link_count || 0, pinned: false, + linkCount: n.link_count || 0, pinned, } nodeMap.set(n.slug, node) return node @@ -1244,6 +1959,37 @@ function renderGraph() { const r = nodeRadius(n) + // Expansion hint ring — dashed outer circle that appears when the + // node has neighbors the user hasn't loaded yet. Without this signal + // users have no way to tell a fully-explored node from one that's + // still hiding 80 more connections just out of view, so they either + // click "bloom" on everything (wasteful) or on nothing (miss the + // interesting pages). adjacency here is the undirected neighbor set + // we've already built from data.edges; link_count is the KB-wide + // in+out degree reported by the backend. Diff > 0 means there's + // more to fetch. + // + // Exception: the ego-mode center node already received every + // reachable neighbor from the BFS expansion, so any remaining gap + // against link_count is dead refs / filtered pages, NOT loadable + // neighbors. Drawing a dashed ring there would mislead users into + // thinking there's something to click. + const visibleNeighbors = adjacency.get(n.slug)?.size ?? 0 + const hiddenNeighbors = Math.max(0, n.linkCount - visibleNeighbors) + const isEgoCenter = data.meta?.mode === 'ego' && data.meta.center === n.slug + const showExpansionRing = hiddenNeighbors > 0 && !isEgoCenter + const expansionRing = document.createElementNS('http://www.w3.org/2000/svg', 'circle') + expansionRing.setAttribute('r', String(r + 3)) + expansionRing.setAttribute('fill', 'none') + expansionRing.setAttribute('stroke', nodeColorMap[n.type] || '#8c8c8c') + expansionRing.setAttribute('stroke-width', '1.5') + expansionRing.setAttribute('stroke-dasharray', '3 3') + expansionRing.setAttribute('pointer-events', 'none') + expansionRing.style.opacity = showExpansionRing ? '0.55' : '0' + expansionRing.style.transition = 'opacity 0.2s' + expansionRing.classList.add('node-expansion-ring') + g.appendChild(expansionRing) + // Pulse ring for selected state const activeRing = document.createElementNS('http://www.w3.org/2000/svg', 'circle') activeRing.setAttribute('r', String(r + 5)) @@ -1279,6 +2025,67 @@ function renderGraph() { text.textContent = n.title.length > 14 ? n.title.substring(0, 14) + '…' : n.title g.appendChild(text) + // Hover bloom button — the ⊕ badge floating off the node's upper-right. + // Invisible by default; fades in on mouseenter when bloom would + // actually do something (node has hidden neighbors and isn't the ego + // center / isn't on overview). Clicking it skips the drawer round-trip + // and pulls the neighbors straight onto the canvas. + // + // Stacking order note: this element has to come AFTER text so SVG's + // painter's algorithm draws it on top; the node-shadow filter and + // the drawer cover it otherwise. + let bloomBtn: SVGGElement | null = null + const bloomBtnEligible = !isEgoCenter && data.meta?.mode === 'ego' && hiddenNeighbors > 0 + if (bloomBtnEligible) { + bloomBtn = document.createElementNS('http://www.w3.org/2000/svg', 'g') + bloomBtn.classList.add('node-bloom-btn') + bloomBtn.style.opacity = '0' + bloomBtn.style.transition = 'opacity 0.15s' + bloomBtn.style.pointerEvents = 'none' // lit up only on hover + bloomBtn.style.cursor = 'pointer' + // Position at 45° up-right of the node center, just past the + // expansion ring so it doesn't overlap the node glyph. + const btnOffset = r + 6 + const btnX = Math.SQRT1_2 * btnOffset + const btnY = -Math.SQRT1_2 * btnOffset + + const btnBg = document.createElementNS('http://www.w3.org/2000/svg', 'circle') + btnBg.setAttribute('cx', String(btnX)) + btnBg.setAttribute('cy', String(btnY)) + btnBg.setAttribute('r', '8') + btnBg.setAttribute('fill', 'var(--td-bg-color-container, #fff)') + btnBg.setAttribute('stroke', 'var(--td-brand-color, #0052d9)') + btnBg.setAttribute('stroke-width', '1.5') + bloomBtn.appendChild(btnBg) + + // ⊕ drawn as two short lines — cross-browser-safer than a text glyph + const btnCrossV = document.createElementNS('http://www.w3.org/2000/svg', 'line') + btnCrossV.setAttribute('x1', String(btnX)) + btnCrossV.setAttribute('x2', String(btnX)) + btnCrossV.setAttribute('y1', String(btnY - 4)) + btnCrossV.setAttribute('y2', String(btnY + 4)) + btnCrossV.setAttribute('stroke', 'var(--td-brand-color, #0052d9)') + btnCrossV.setAttribute('stroke-width', '1.8') + btnCrossV.setAttribute('stroke-linecap', 'round') + bloomBtn.appendChild(btnCrossV) + + const btnCrossH = document.createElementNS('http://www.w3.org/2000/svg', 'line') + btnCrossH.setAttribute('x1', String(btnX - 4)) + btnCrossH.setAttribute('x2', String(btnX + 4)) + btnCrossH.setAttribute('y1', String(btnY)) + btnCrossH.setAttribute('y2', String(btnY)) + btnCrossH.setAttribute('stroke', 'var(--td-brand-color, #0052d9)') + btnCrossH.setAttribute('stroke-width', '1.8') + btnCrossH.setAttribute('stroke-linecap', 'round') + bloomBtn.appendChild(btnCrossH) + + bloomBtn.addEventListener('click', (e) => { + e.stopPropagation() + loadBloomNeighbors(n.slug) + }) + g.appendChild(bloomBtn) + } + // Hover highlight // We debounce the "leave" side so that quickly sliding the pointer from // one node to the next doesn't flash through the fully-unhighlighted state @@ -1288,6 +2095,10 @@ function renderGraph() { clearTimeout(graphHoverLeaveTimer) graphHoverLeaveTimer = null } + if (bloomBtn) { + bloomBtn.style.opacity = '1' + bloomBtn.style.pointerEvents = 'auto' + } if (!graphSelectedSlug.value) { if (graphHighlightSlug.value === n.slug) return graphHighlightSlug.value = n.slug @@ -1300,6 +2111,10 @@ function renderGraph() { }) g.addEventListener('mouseleave', () => { if (graphHoverLeaveTimer) clearTimeout(graphHoverLeaveTimer) + if (bloomBtn) { + bloomBtn.style.opacity = '0' + bloomBtn.style.pointerEvents = 'none' + } graphHoverLeaveTimer = setTimeout(() => { graphHoverLeaveTimer = null if (!graphSelectedSlug.value) { @@ -1312,29 +2127,69 @@ function renderGraph() { }, 60) }) - // Click to select & open drawer directly + // Single-click behaviour + keyboard-modifier shortcuts to skip the + // drawer round-trip for power-user navigation: + // + // plain click → select & open drawer (original behaviour) + // shift+click → bloom this node's neighbors onto the canvas + // double-click → pivot to this node as the new ego center + // + // Drawer is by far the slower path (page fetch + render), so adding + // canvas-direct expand / bloom removes a 2-3 second round-trip from + // every exploration step. We still want shift+click to be + // discoverable, so the drawer's buttons remain — they're the + // keyboard-free fallback. + // + // Implementation note: we listen to click AND dblclick. The browser + // fires both click events of a dblclick too, but we debounce via + // `pendingSingleClick` — the first click sets a 220ms timer to open + // the drawer; dblclick arriving inside that window cancels the + // timer and runs expand instead. `event.detail` (click count) is + // less portable across synthetic events, so we track state explicitly. + let pendingSingleClick: ReturnType | null = null g.addEventListener('click', (e) => { e.stopPropagation() - - // Select and highlight - graphSelectedSlug.value = n.slug - applyHighlight(n.slug, adjacency, nodeEls, edgeEls) - - // Auto pan to center the node, shifted left for drawer - if (graphPanZoomRef) { - const container = graphRef.value - if (container) { - const width = container.clientWidth - const height = container.clientHeight - graphPanZoomRef.flyTo( - width / 2 - n.x * graphPanZoomRef.getScale() - 240, - height / 2 - n.y * graphPanZoomRef.getScale() - ) - } + + if (e.shiftKey) { + // Shift = Bloom. Skip the drawer entirely, skip selection — the + // user's intent is "bring in the neighbors", not "read this page". + // Center / isOverview cases are handled inside loadBloomNeighbors + // (center no-ops, overview pivots to ego). + if (pendingSingleClick) { clearTimeout(pendingSingleClick); pendingSingleClick = null } + loadBloomNeighbors(n.slug) + return } - - // Open drawer (it will handle drawer visibility and fetching content) - openGraphDrawer(n.slug) + + if (pendingSingleClick) clearTimeout(pendingSingleClick) + pendingSingleClick = setTimeout(() => { + pendingSingleClick = null + + // Select and highlight + graphSelectedSlug.value = n.slug + applyHighlight(n.slug, adjacency, nodeEls, edgeEls) + + // Auto pan to center the node, shifted left for drawer + if (graphPanZoomRef) { + const container = graphRef.value + if (container) { + const width = container.clientWidth + const height = container.clientHeight + graphPanZoomRef.flyTo( + width / 2 - n.x * graphPanZoomRef.getScale() - 240, + height / 2 - n.y * graphPanZoomRef.getScale() + ) + } + } + + // Open drawer (it will handle drawer visibility and fetching content) + openGraphDrawer(n.slug) + }, 220) + }) + + g.addEventListener('dblclick', (e) => { + e.stopPropagation() + if (pendingSingleClick) { clearTimeout(pendingSingleClick); pendingSingleClick = null } + loadEgoGraph(n.slug) }) // Drag support @@ -1759,33 +2614,111 @@ function clearHighlight( } } -const graphSearchOptions = computed(() => { - if (!graphData.value?.nodes) return [] - return graphData.value.nodes.map(n => ({ - label: n.title, - value: n.slug - })) +// graphSearchOptions drives the search select dropdown. When the input is +// empty we fall back to the overview top-500 snapshot so users can still +// browse the most-connected pages without typing — matching the old +// client-filter UX. Once the user types we switch to a remote full-text +// search against the wiki API so the dropdown can reach pages that sit +// outside the canvas (up to the whole 4万-page KB). +const graphSearchOptions = ref<{ label: string; value: string }[]>([]) +const graphSearchLoading = ref(false) +let graphSearchDebounce: ReturnType | null = null +let graphSearchSeq = 0 + +// graphSearchDefaultOptions is the snapshot of "global top-500 by link_count" +// used as the empty-keyword default. We populate it lazily from the first +// overview fetch and keep it across ego-mode navigations so drilling into +// a neighborhood doesn't shrink the search surface back to the ego subgraph. +const graphSearchDefaultOptions = ref<{ label: string; value: string }[]>([]) + +// Expose the empty-state list to the template too, so the initial popup +// open (before the user types) renders the snapshot immediately. Using a +// computed keeps graphSearchOptions.value representing "current keyword +// results" without having to remember which list is active. +const graphSearchEffectiveOptions = computed(() => { + return graphSearchOptions.value.length > 0 + ? graphSearchOptions.value + : graphSearchDefaultOptions.value }) +function setGraphSearchDefaultFromNodes(nodes: { slug: string; title: string }[] | undefined) { + if (!nodes) return + graphSearchDefaultOptions.value = nodes.map(n => ({ label: n.title, value: n.slug })) +} + +async function handleGraphRemoteSearch(keyword: string) { + const q = (keyword || '').trim() + if (graphSearchDebounce) { + clearTimeout(graphSearchDebounce) + graphSearchDebounce = null + } + if (!q) { + // No keyword — clear keyword-specific results; the computed + // graphSearchEffectiveOptions will fall back to the top-500 snapshot. + graphSearchOptions.value = [] + graphSearchLoading.value = false + return + } + graphSearchLoading.value = true + // Snapshot a monotonic sequence number so stale responses (user kept + // typing while an earlier request was still in flight) don't overwrite + // newer results with older ones. + const seq = ++graphSearchSeq + graphSearchDebounce = setTimeout(async () => { + try { + const res = await searchWikiPages(props.knowledgeBaseId, q, 20) + if (seq !== graphSearchSeq) return + const pages: WikiPage[] = (res as any)?.data?.pages || (res as any)?.pages || [] + graphSearchOptions.value = pages.map(p => ({ label: p.title, value: p.slug })) + } catch (e) { + if (seq !== graphSearchSeq) return + console.error('Wiki search failed:', e) + graphSearchOptions.value = [] + } finally { + if (seq === graphSearchSeq) graphSearchLoading.value = false + } + }, 200) +} + let graphNodeElsRef: { g: SVGGElement; circle: SVGCircleElement; text: SVGTextElement; activeRing: SVGCircleElement; node: GNode }[] = [] let graphEdgeElsRef: { line: SVGLineElement; source: string; target: string; bidir: boolean }[] = [] let graphAdjacencyRef = new Map>() -function handleGraphSearchSelect(value: string) { +// handleGraphSearchSelect is the single entry point every "jump to this +// slug" path funnels through — the graph search select, drawer wiki-link +// clicks, the ?slug= query param, and the global issues "去处理" button. +// On a 4万-page wiki, the current render contains at most GRAPH_OVERVIEW_LIMIT +// (500) nodes, so most of the wiki is NOT on screen at any given moment. +// If the requested slug is missing from the current canvas we reload the +// graph as an ego view centered on that slug, then finish the highlight +// and drawer flow once the new render is ready. This guarantees any +// navigable link can actually reach its destination regardless of where +// the target sits in the link_count ranking. +async function handleGraphSearchSelect(value: string) { if (!value) return - - // Find node coordinates - const node = graphNodes.find(n => n.slug === value) - - // If the node's type is currently filtered out, re-enable it so it becomes visible - if (node && !graphFilterTypes.value.has(node.type)) { - const newSet = new Set(graphFilterTypes.value) - newSet.add(node.type) - graphFilterTypes.value = newSet - applyGraphFilters() + + let node = graphNodes.find(n => n.slug === value) + if (!node) { + // Target is outside the current subgraph — pivot to an ego view. + // loadEgoGraph repopulates graphNodes as a side effect. + await loadEgoGraph(value) + node = graphNodes.find(n => n.slug === value) + if (!node) { + // The slug truly does not exist in the KB (e.g. stale URL, deleted + // page). loadEgoGraph will have surfaced the backend error in the + // console; still open the drawer so the user sees the not-found + // page body rather than a silent no-op. + openGraphDrawer(value) + setTimeout(() => { graphSearchValue.value = '' }, 300) + return + } } - if (node && graphPanZoomRef) { + // Under server-side filtering, every node currently in graphNodes has + // already passed the active type filter — there is no longer a path + // where we need to re-enable a filter to make the target visible. + + if (graphPanZoomRef) { const container = graphRef.value if (container) { const width = container.clientWidth @@ -1813,18 +2746,32 @@ function handleGraphSearchSelect(value: string) { setTimeout(() => { graphSearchValue.value = '' }, 300) } -function handleGraphSearchEnter(context: { inputValue: string }) { +async function handleGraphSearchEnter(context: { inputValue: string }) { const value = context.inputValue?.trim() if (!value) return - - // Try to find exact or partial match - const match = graphSearchOptions.value.find(opt => - opt.label.toLowerCase().includes(value.toLowerCase()) || + + // First try the already-loaded remote suggestions — if the user picked a + // keyword whose results are on screen, fire the first match immediately. + const match = graphSearchOptions.value.find(opt => + opt.label.toLowerCase().includes(value.toLowerCase()) || opt.value.toLowerCase().includes(value.toLowerCase()) ) - if (match) { handleGraphSearchSelect(match.value) + return + } + + // Fallback: user hit Enter before suggestions came back (fast typing / + // network still pending). Run a one-shot search so Enter still navigates + // somewhere useful rather than silently doing nothing. + try { + const res = await searchWikiPages(props.knowledgeBaseId, value, 1) + const pages: WikiPage[] = (res as any)?.data?.pages || (res as any)?.pages || [] + if (pages.length > 0) { + handleGraphSearchSelect(pages[0].slug) + } + } catch (e) { + console.error('Wiki search failed on enter:', e) } } @@ -2479,6 +3426,54 @@ onUnmounted(() => { background: var(--td-bg-color-container); } +.help-glyph-icon { + font-size: 14px !important; + font-weight: 600; + line-height: 14px !important; + text-align: center; + width: 14px; + color: inherit; +} + +.wiki-graph-help { + min-width: 240px; + max-width: 320px; + + .help-section-title { + font-size: 11px; + line-height: 14px; + color: var(--td-text-color-placeholder); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 8px; + user-select: none; + } + + .help-rows { + display: flex; + flex-direction: column; + gap: 6px; + } + + .help-row { + display: grid; + grid-template-columns: 110px 1fr; + gap: 12px; + font-size: 12px; + line-height: 16px; + } + + .help-key { + color: var(--td-text-color-primary); + font-weight: 500; + white-space: nowrap; + } + + .help-desc { + color: var(--td-text-color-secondary); + } +} + .wiki-graph-search-container { position: absolute; top: 16px; @@ -2598,7 +3593,7 @@ onUnmounted(() => { color: var(--td-brand-color); } } - + &.active { color: var(--td-brand-color); .legend-action-icon { @@ -2607,6 +3602,61 @@ onUnmounted(() => { } } +.wiki-graph-truncation-hint { + font-size: 11px; + line-height: 14px; + color: var(--td-text-color-placeholder); + user-select: none; + max-width: 280px; +} + +.wiki-graph-status-card { + display: flex; + flex-direction: column; + gap: 4px; + max-width: 240px; + padding: 8px 2px 2px; + user-select: none; + + .status-card-header { + display: flex; + align-items: center; + gap: 4px; + font-size: 11px; + line-height: 14px; + color: var(--td-text-color-placeholder); + text-transform: uppercase; + letter-spacing: 0.04em; + + .t-icon { + font-size: 12px; + } + } + + .status-card-primary { + font-size: 13px; + font-weight: 600; + line-height: 18px; + color: var(--td-text-color-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .status-card-secondary { + font-size: 11px; + line-height: 14px; + color: var(--td-text-color-secondary); + } +} + +.wiki-drawer-neighbor-hint { + font-size: 12px; + line-height: 16px; + color: var(--td-text-color-secondary); + user-select: none; +} + .legend-action-icon { display: inline-flex; align-items: center; diff --git a/internal/application/repository/wiki_page.go b/internal/application/repository/wiki_page.go index f1686b8a4..506fd55a1 100644 --- a/internal/application/repository/wiki_page.go +++ b/internal/application/repository/wiki_page.go @@ -329,6 +329,19 @@ func escapeLikePattern(s string) string { // Search performs case-insensitive POSIX regex search on wiki pages within a knowledge base. // The query is interpreted as a PostgreSQL regular expression (via ~*). +// +// Results are ranked by where the query hit, highest-relevance first: +// +// title hit → rank 4 (most obvious intent: user typed what the page is called) +// slug hit → rank 3 (url-like identifiers, direct jump) +// summary hit → rank 2 (short authored abstract) +// content hit → rank 1 (body mention — often surfaces unrelated pages whose +// prose merely mentions the query as trivia) +// +// Without this ranking, a user searching for "王新" on a 4万-page wiki will +// see pages like "华为" or "Index" ahead of the actual 王新 page just +// because they mention 王新 in their body and were updated more recently. +// updated_at stays as the tiebreaker so same-rank ties stay deterministic. func (r *wikiPageRepository) Search(ctx context.Context, kbID string, query string, limit int) ([]*types.WikiPage, error) { if limit <= 0 { limit = 10 @@ -337,12 +350,24 @@ func (r *wikiPageRepository) Search(ctx context.Context, kbID string, query stri limit = 50 } + // CASE expression is evaluated per-row during SELECT; we order by the + // alias so the DB only computes the rank once. Parameterized four + // times with the same regex to avoid coupling to GORM's positional + // arg rewriting quirks. + rankExpr := "CASE " + + "WHEN title ~* ? THEN 4 " + + "WHEN slug ~* ? THEN 3 " + + "WHEN summary ~* ? THEN 2 " + + "WHEN content ~* ? THEN 1 " + + "ELSE 0 END AS match_rank" + var pages []*types.WikiPage if err := r.db.WithContext(ctx). + Select("*, "+rankExpr, query, query, query, query). Where("knowledge_base_id = ? AND (title ~* ? OR content ~* ? OR summary ~* ? OR slug ~* ?)", kbID, query, query, query, query). Where("status != ?", "archived"). - Order("updated_at DESC"). + Order("match_rank DESC, updated_at DESC"). Limit(limit). Find(&pages).Error; err != nil { return nil, err diff --git a/internal/application/service/wiki_lint.go b/internal/application/service/wiki_lint.go index b06a38b77..b7012c9ed 100644 --- a/internal/application/service/wiki_lint.go +++ b/internal/application/service/wiki_lint.go @@ -14,12 +14,12 @@ import ( type WikiLintIssueType string const ( - LintIssueOrphanPage WikiLintIssueType = "orphan_page" - LintIssueBrokenLink WikiLintIssueType = "broken_link" - LintIssueStaleRef WikiLintIssueType = "stale_ref" + LintIssueOrphanPage WikiLintIssueType = "orphan_page" + LintIssueBrokenLink WikiLintIssueType = "broken_link" + LintIssueStaleRef WikiLintIssueType = "stale_ref" LintIssueMissingCrossRef WikiLintIssueType = "missing_cross_ref" - LintIssueEmptyContent WikiLintIssueType = "empty_content" - LintIssueDuplicateSlug WikiLintIssueType = "duplicate_slug" + LintIssueEmptyContent WikiLintIssueType = "empty_content" + LintIssueDuplicateSlug WikiLintIssueType = "duplicate_slug" ) // WikiLintIssueSeverity defines the severity of a lint issue @@ -90,8 +90,14 @@ func (s *WikiLintService) RunLint(ctx context.Context, kbID string) (*WikiLintRe return nil, fmt.Errorf("get stats: %w", err) } - // Get graph for link analysis - graph, err := s.wikiService.GetGraph(ctx, kbID) + // Get graph for link analysis. Lint needs the FULL graph to detect + // orphans and broken links across every page, so we pass Limit=0 + // which the service treats as "no cap". + graph, err := s.wikiService.GetGraph(ctx, &types.WikiGraphRequest{ + KnowledgeBaseID: kbID, + Mode: types.WikiGraphModeOverview, + Limit: 0, + }) if err != nil { return nil, fmt.Errorf("get graph: %w", err) } diff --git a/internal/application/service/wiki_page.go b/internal/application/service/wiki_page.go index 20a4cd085..c7e4176f9 100644 --- a/internal/application/service/wiki_page.go +++ b/internal/application/service/wiki_page.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "regexp" + "sort" "strings" "time" @@ -258,50 +259,258 @@ func (s *wikiPageService) GetLog(ctx context.Context, kbID string) (*types.WikiP return page, nil } -// GetGraph returns the link graph data for visualization -func (s *wikiPageService) GetGraph(ctx context.Context, kbID string) (*types.WikiGraphData, error) { - pages, err := s.repo.ListAll(ctx, kbID) +// GetGraph returns a slice of the wiki link graph for visualization. +// +// Two modes are supported: +// +// - WikiGraphModeOverview (default): returns the top `Limit` pages sorted +// by link_count (in+out), plus every edge that connects two surviving +// nodes. This is what the frontend fetches on the first graph open — +// 4万-page wikis would otherwise ship ~30MB of JSON and crash the +// browser trying to render 100k SVG elements. +// +// - WikiGraphModeEgo: returns the BFS neighborhood of `Center` up to +// `Depth` undirected hops, capped at `Limit` total nodes. The +// frontend uses this to drill down when the user clicks / searches a +// node in the overview. +// +// `Types` is an optional page_type allow-list applied to both the candidate +// node set and (in ego mode) the frontier expansion. Leaving it empty means +// no type filter. +// +// `Limit <= 0` disables the cap entirely and is reserved for internal +// callers like the lint service that need to walk every page. The HTTP +// handler always clamps Limit into a safe range so external traffic can +// never opt out of truncation. +// +// Implementation note: pages are still fetched via repo.ListAll. At 4万 +// pages that's ~10MB of rows + deserialization, which is already on the +// expensive side but still tractable and keeps the repository interface +// unchanged. Pushing the filter/top-N down into SQL is a follow-up step +// (cache layer + DB-side projection) — see CLAUDE.md plan. +func (s *wikiPageService) GetGraph(ctx context.Context, req *types.WikiGraphRequest) (*types.WikiGraphData, error) { + if req == nil { + return nil, errors.New("wiki graph request is required") + } + + pages, err := s.repo.ListAll(ctx, req.KnowledgeBaseID) if err != nil { return nil, err } + return computeGraphSubset(pages, req) +} - nodeMap := make(map[string]*types.WikiGraphNode) - var edges []types.WikiGraphEdge +// computeGraphSubset is the pure I/O-free core of GetGraph. It takes the +// full page list and a request description and returns the subgraph the +// caller asked for. Extracted from GetGraph so tests can exercise the +// mode/limit/type-filter behavior without plumbing a full repository mock. +func computeGraphSubset(pages []*types.WikiPage, req *types.WikiGraphRequest) (*types.WikiGraphData, error) { + mode := req.Mode + if mode == "" { + mode = types.WikiGraphModeOverview + } - // Build nodes + // Pre-compute link_count and the type allow-list used for candidate + // filtering. We keep the full page list around so ego mode can still + // traverse through neighbors whose type is in the allow-list. + typeAllow := make(map[string]bool, len(req.Types)) + for _, t := range req.Types { + if t != "" { + typeAllow[t] = true + } + } + hasTypeFilter := len(typeAllow) > 0 + + pageBySlug := make(map[string]*types.WikiPage, len(pages)) + linkCount := make(map[string]int, len(pages)) for _, p := range pages { - linkCount := len(p.InLinks) + len(p.OutLinks) - nodeMap[p.Slug] = &types.WikiGraphNode{ - Slug: p.Slug, - Title: p.Title, - PageType: p.PageType, - LinkCount: linkCount, + pageBySlug[p.Slug] = p + linkCount[p.Slug] = len(p.InLinks) + len(p.OutLinks) + } + + // Select the node slug set for the requested slice. + var selected map[string]struct{} + switch mode { + case types.WikiGraphModeEgo: + if req.Center == "" { + return nil, errors.New("ego graph requires a center slug") + } + if _, ok := pageBySlug[req.Center]; !ok { + return nil, fmt.Errorf("ego center slug %q not found", req.Center) + } + depth := req.Depth + if depth < 1 { + depth = 1 + } + selected = bfsEgoSlugs(pageBySlug, req.Center, depth, typeAllow, req.Limit) + default: + // overview: keep only type-allowed candidates, sort by link_count desc, cap. + candidates := make([]*types.WikiPage, 0, len(pages)) + for _, p := range pages { + if hasTypeFilter && !typeAllow[p.PageType] { + continue + } + candidates = append(candidates, p) + } + sort.SliceStable(candidates, func(i, j int) bool { + li := linkCount[candidates[i].Slug] + lj := linkCount[candidates[j].Slug] + if li != lj { + return li > lj + } + // Stable tiebreaker keeps the API deterministic between calls. + return candidates[i].Slug < candidates[j].Slug + }) + if req.Limit > 0 && len(candidates) > req.Limit { + candidates = candidates[:req.Limit] + } + selected = make(map[string]struct{}, len(candidates)) + for _, p := range candidates { + selected[p.Slug] = struct{}{} } } - // Build edges from outbound links + // Build nodes from the selected set. + nodes := make([]types.WikiGraphNode, 0, len(selected)) + for slug := range selected { + p := pageBySlug[slug] + nodes = append(nodes, types.WikiGraphNode{ + Slug: p.Slug, + Title: p.Title, + PageType: p.PageType, + LinkCount: linkCount[slug], + }) + } + // Deterministic node ordering — the map iteration above is random. + sort.Slice(nodes, func(i, j int) bool { + if nodes[i].LinkCount != nodes[j].LinkCount { + return nodes[i].LinkCount > nodes[j].LinkCount + } + return nodes[i].Slug < nodes[j].Slug + }) + + // Build edges, keeping only edges whose endpoints both survived selection. + var edges []types.WikiGraphEdge for _, p := range pages { + if _, ok := selected[p.Slug]; !ok { + continue + } for _, target := range p.OutLinks { - if _, exists := nodeMap[target]; exists { - edges = append(edges, types.WikiGraphEdge{ - Source: p.Slug, - Target: target, - }) + if _, ok := selected[target]; !ok { + continue + } + edges = append(edges, types.WikiGraphEdge{ + Source: p.Slug, + Target: target, + }) + } + } + + // total is the count of candidate nodes before truncation — i.e. the + // population the frontend would need to fetch if it asked for the + // whole graph. For overview this respects the type filter; for ego + // it is the total KB page count (the user still sees "X of Y" based + // on the full wiki, not a filtered denominator). + total := len(pages) + if mode == types.WikiGraphModeOverview && hasTypeFilter { + total = 0 + for _, p := range pages { + if typeAllow[p.PageType] { + total++ } } } - nodes := make([]types.WikiGraphNode, 0, len(nodeMap)) - for _, n := range nodeMap { - nodes = append(nodes, *n) + meta := types.WikiGraphMeta{ + Mode: mode, + Total: total, + Returned: len(nodes), + Truncated: len(nodes) < total, + } + if mode == types.WikiGraphModeEgo { + meta.Center = req.Center + meta.Depth = req.Depth + if meta.Depth < 1 { + meta.Depth = 1 + } } return &types.WikiGraphData{ Nodes: nodes, Edges: edges, + Meta: meta, }, nil } +// bfsEgoSlugs computes the undirected BFS neighborhood of `center` up to +// `depth` hops using both inbound and outbound links. Type-filtered pages +// are excluded from the result but are also NOT traversed through — so a +// filter that hides "index" pages will not leak the whole wiki via the +// index. The caller guarantees center exists in pageBySlug. +func bfsEgoSlugs( + pageBySlug map[string]*types.WikiPage, + center string, + depth int, + typeAllow map[string]bool, + limit int, +) map[string]struct{} { + hasTypeFilter := len(typeAllow) > 0 + centerPage, ok := pageBySlug[center] + if !ok { + return map[string]struct{}{} + } + // If the center itself fails the type filter we honor the filter and + // return an empty set — the handler will surface Returned=0. + if hasTypeFilter && !typeAllow[centerPage.PageType] { + return map[string]struct{}{} + } + + visited := map[string]struct{}{center: {}} + frontier := []string{center} + + for hop := 0; hop < depth; hop++ { + if limit > 0 && len(visited) >= limit { + break + } + next := make([]string, 0, len(frontier)) + for _, slug := range frontier { + p, ok := pageBySlug[slug] + if !ok { + continue + } + neighbors := make([]string, 0, len(p.OutLinks)+len(p.InLinks)) + neighbors = append(neighbors, p.OutLinks...) + neighbors = append(neighbors, p.InLinks...) + for _, nb := range neighbors { + if _, seen := visited[nb]; seen { + continue + } + np, exists := pageBySlug[nb] + if !exists { + continue + } + if hasTypeFilter && !typeAllow[np.PageType] { + continue + } + visited[nb] = struct{}{} + next = append(next, nb) + if limit > 0 && len(visited) >= limit { + break + } + } + if limit > 0 && len(visited) >= limit { + break + } + } + frontier = next + if len(frontier) == 0 { + break + } + } + + return visited +} + // GetStats returns aggregate statistics about the wiki func (s *wikiPageService) GetStats(ctx context.Context, kbID string) (*types.WikiStats, error) { counts, err := s.repo.CountByType(ctx, kbID) diff --git a/internal/application/service/wiki_page_test.go b/internal/application/service/wiki_page_test.go index 3ba03366a..0bb071c49 100644 --- a/internal/application/service/wiki_page_test.go +++ b/internal/application/service/wiki_page_test.go @@ -128,3 +128,250 @@ func TestRemoveString(t *testing.T) { t.Errorf("Expected 4 items (nothing removed), got %d", len(result2)) } } + +// makeGraphFixture builds a small synthetic wiki for GetGraph tests. +// +// Edges (directed): +// +// hub -> a, hub -> b, hub -> c, hub -> d +// a -> hub +// b -> hub +// c -> d +// x -> y (isolated 2-node cluster, disconnected from hub) +// +// Page types are chosen so type-filter tests can exclude specific nodes. +func makeGraphFixture() []*types.WikiPage { + return []*types.WikiPage{ + { + Slug: "hub", + Title: "Hub", + PageType: types.WikiPageTypeSummary, + OutLinks: types.StringArray{"a", "b", "c", "d"}, + InLinks: types.StringArray{"a", "b"}, + }, + { + Slug: "a", + Title: "A", + PageType: types.WikiPageTypeEntity, + OutLinks: types.StringArray{"hub"}, + InLinks: types.StringArray{"hub"}, + }, + { + Slug: "b", + Title: "B", + PageType: types.WikiPageTypeEntity, + OutLinks: types.StringArray{"hub"}, + InLinks: types.StringArray{"hub"}, + }, + { + Slug: "c", + Title: "C", + PageType: types.WikiPageTypeConcept, + OutLinks: types.StringArray{"d"}, + InLinks: types.StringArray{"hub"}, + }, + { + Slug: "d", + Title: "D", + PageType: types.WikiPageTypeConcept, + OutLinks: types.StringArray{}, + InLinks: types.StringArray{"hub", "c"}, + }, + { + Slug: "x", + Title: "X", + PageType: types.WikiPageTypeEntity, + OutLinks: types.StringArray{"y"}, + InLinks: types.StringArray{}, + }, + { + Slug: "y", + Title: "Y", + PageType: types.WikiPageTypeEntity, + OutLinks: types.StringArray{}, + InLinks: types.StringArray{"x"}, + }, + } +} + +func nodeSlugs(data *types.WikiGraphData) map[string]bool { + out := make(map[string]bool, len(data.Nodes)) + for _, n := range data.Nodes { + out[n.Slug] = true + } + return out +} + +// TestComputeGraphSubset_OverviewTruncatesByLinkCount verifies that overview +// mode returns the most-connected nodes first and reports truncation +// honestly in Meta. At 4万 pages this is the path that must NOT return the +// full graph — the cap is what keeps the response size and frontend +// rendering tractable. +func TestComputeGraphSubset_OverviewTruncatesByLinkCount(t *testing.T) { + pages := makeGraphFixture() + got, err := computeGraphSubset(pages, &types.WikiGraphRequest{ + Mode: types.WikiGraphModeOverview, + Limit: 3, + }) + if err != nil { + t.Fatalf("computeGraphSubset: %v", err) + } + + if len(got.Nodes) != 3 { + t.Errorf("want 3 nodes, got %d (%v)", len(got.Nodes), nodeSlugs(got)) + } + slugs := nodeSlugs(got) + // hub has link_count 6 (4 out + 2 in), must survive the cap. + if !slugs["hub"] { + t.Errorf("expected hub to survive the top-3 cap, got %v", slugs) + } + if !got.Meta.Truncated { + t.Errorf("expected Meta.Truncated=true when returned < total") + } + if got.Meta.Total != len(pages) { + t.Errorf("Meta.Total = %d, want %d", got.Meta.Total, len(pages)) + } + if got.Meta.Returned != len(got.Nodes) { + t.Errorf("Meta.Returned mismatch: %d vs %d", got.Meta.Returned, len(got.Nodes)) + } + + // Every returned edge must connect two surviving nodes. + for _, e := range got.Edges { + if !slugs[e.Source] || !slugs[e.Target] { + t.Errorf("edge %s->%s references a non-returned node", e.Source, e.Target) + } + } +} + +// TestComputeGraphSubset_OverviewUncapped ensures the Limit<=0 escape hatch +// still works for internal callers (wiki lint) that need every page. +func TestComputeGraphSubset_OverviewUncapped(t *testing.T) { + pages := makeGraphFixture() + got, err := computeGraphSubset(pages, &types.WikiGraphRequest{ + Mode: types.WikiGraphModeOverview, + Limit: 0, + }) + if err != nil { + t.Fatalf("computeGraphSubset: %v", err) + } + if len(got.Nodes) != len(pages) { + t.Errorf("want %d nodes (uncapped), got %d", len(pages), len(got.Nodes)) + } + if got.Meta.Truncated { + t.Errorf("expected Meta.Truncated=false when nothing was dropped") + } +} + +// TestComputeGraphSubset_OverviewTypeFilter ensures the type filter applies +// to the candidate set (not just post-hoc), so "total" reflects the +// filter-aware denominator the frontend shows to the user. +func TestComputeGraphSubset_OverviewTypeFilter(t *testing.T) { + pages := makeGraphFixture() + got, err := computeGraphSubset(pages, &types.WikiGraphRequest{ + Mode: types.WikiGraphModeOverview, + Types: []string{types.WikiPageTypeEntity}, + Limit: 100, + }) + if err != nil { + t.Fatalf("computeGraphSubset: %v", err) + } + slugs := nodeSlugs(got) + // Only entity-typed pages: a, b, x, y. + for _, expected := range []string{"a", "b", "x", "y"} { + if !slugs[expected] { + t.Errorf("expected entity %q to be returned, got %v", expected, slugs) + } + } + for _, forbidden := range []string{"hub", "c", "d"} { + if slugs[forbidden] { + t.Errorf("%q should have been filtered out by type, got %v", forbidden, slugs) + } + } + if got.Meta.Total != 4 { + t.Errorf("Meta.Total should equal entity-typed page count (4), got %d", got.Meta.Total) + } +} + +// TestComputeGraphSubset_EgoDepth1 checks that depth=1 returns center + +// immediate neighbors only, with no transitive hops. +func TestComputeGraphSubset_EgoDepth1(t *testing.T) { + pages := makeGraphFixture() + got, err := computeGraphSubset(pages, &types.WikiGraphRequest{ + Mode: types.WikiGraphModeEgo, + Center: "hub", + Depth: 1, + Limit: 100, + }) + if err != nil { + t.Fatalf("computeGraphSubset: %v", err) + } + slugs := nodeSlugs(got) + // hub's direct neighbors: a, b, c, d (out) ∪ a, b (in) = {a, b, c, d} + hub. + want := []string{"hub", "a", "b", "c", "d"} + for _, s := range want { + if !slugs[s] { + t.Errorf("expected %q in ego depth=1 of hub, got %v", s, slugs) + } + } + // x and y are in a disconnected cluster and must not leak in. + if slugs["x"] || slugs["y"] { + t.Errorf("disconnected nodes leaked into ego graph: %v", slugs) + } + if got.Meta.Mode != types.WikiGraphModeEgo || got.Meta.Center != "hub" || got.Meta.Depth != 1 { + t.Errorf("Meta not populated correctly for ego: %+v", got.Meta) + } +} + +// TestComputeGraphSubset_EgoDepth2 checks that an extra hop reaches pages +// only accessible through a one-hop neighbor (c -> d becomes reachable from +// "a" at depth 2 because a -> hub -> c -> d ... wait, that's 3 hops. Use +// a clearer case: from "a", depth=2 should reach hub's neighbors.) +func TestComputeGraphSubset_EgoDepth2ExpandsFrontier(t *testing.T) { + pages := makeGraphFixture() + depth1, err := computeGraphSubset(pages, &types.WikiGraphRequest{ + Mode: types.WikiGraphModeEgo, + Center: "a", + Depth: 1, + Limit: 100, + }) + if err != nil { + t.Fatalf("depth=1: %v", err) + } + depth2, err := computeGraphSubset(pages, &types.WikiGraphRequest{ + Mode: types.WikiGraphModeEgo, + Center: "a", + Depth: 2, + Limit: 100, + }) + if err != nil { + t.Fatalf("depth=2: %v", err) + } + if len(depth2.Nodes) <= len(depth1.Nodes) { + t.Errorf("depth=2 should expand the frontier beyond depth=1 (%d <= %d)", + len(depth2.Nodes), len(depth1.Nodes)) + } + // At depth 2 from "a": a -> hub -> {b, c, d}. So b, c, d must appear. + slugs := nodeSlugs(depth2) + for _, s := range []string{"a", "hub", "b", "c", "d"} { + if !slugs[s] { + t.Errorf("expected %q at depth=2 from a, got %v", s, slugs) + } + } +} + +// TestComputeGraphSubset_EgoRejectsMissingCenter ensures we fail fast +// rather than returning an empty graph when the caller points at a +// non-existent slug — an empty result here would look identical to "your +// wiki has no links to that page" and hide the real bug. +func TestComputeGraphSubset_EgoRejectsMissingCenter(t *testing.T) { + pages := makeGraphFixture() + _, err := computeGraphSubset(pages, &types.WikiGraphRequest{ + Mode: types.WikiGraphModeEgo, + Center: "does-not-exist", + Depth: 1, + Limit: 100, + }) + if err == nil { + t.Fatalf("expected error for missing center slug") + } +} diff --git a/internal/handler/wiki_page.go b/internal/handler/wiki_page.go index e24f35884..90cf94181 100644 --- a/internal/handler/wiki_page.go +++ b/internal/handler/wiki_page.go @@ -323,12 +323,33 @@ func (h *WikiPageHandler) GetLog(c *gin.Context) { c.JSON(http.StatusOK, page) } +// Graph query parameter bounds. The defaults cap an `overview` request at +// 500 nodes — comfortably renderable in the frontend's hand-rolled SVG +// force simulation — while the hard max of 2000 is the upper bound a +// power user can opt into before rendering gets choppy. Ego depth is +// capped at 3 hops because the node population grows super-linearly with +// depth and wider searches are better served by repeated ego jumps. +const ( + wikiGraphDefaultLimit = 500 + wikiGraphMaxLimit = 2000 + wikiGraphMaxDepth = 3 + wikiGraphDefaultDepth = 1 +) + // GetGraph godoc // @Summary Get wiki link graph -// @Description Returns the wiki link graph data for visualization +// @Description Returns a slice of the wiki link graph for visualization. Supports +// @Description `mode=overview` (top-N most-connected pages, default) and +// @Description `mode=ego` (BFS neighborhood of a center slug) to keep response +// @Description size tractable for knowledge bases with tens of thousands of pages. // @Tags Wiki // @Produce json -// @Param kb_id path string true "Knowledge base ID" +// @Param kb_id path string true "Knowledge base ID" +// @Param mode query string false "overview (default) | ego" +// @Param center query string false "Center slug for ego mode" +// @Param depth query int false "Ego BFS depth (1-3, default 1)" +// @Param types query string false "Comma-separated page_type allow-list" +// @Param limit query int false "Max nodes to return (default 500, max 2000)" // @Success 200 {object} types.WikiGraphData // @Security Bearer // @Router /api/v1/knowledgebase/{kb_id}/wiki/graph [get] @@ -339,7 +360,67 @@ func (h *WikiPageHandler) GetGraph(c *gin.Context) { return } - graph, err := h.wikiService.GetGraph(c.Request.Context(), kbID) + mode := strings.TrimSpace(c.Query("mode")) + if mode == "" { + mode = types.WikiGraphModeOverview + } + if mode != types.WikiGraphModeOverview && mode != types.WikiGraphModeEgo { + c.JSON(http.StatusBadRequest, gin.H{"error": "mode must be 'overview' or 'ego'"}) + return + } + + center := strings.TrimSpace(c.Query("center")) + if mode == types.WikiGraphModeEgo && center == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "center is required when mode=ego"}) + return + } + + depth := wikiGraphDefaultDepth + if v := c.Query("depth"); v != "" { + parsed, parseErr := strconv.Atoi(v) + if parseErr != nil || parsed < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "depth must be a positive integer"}) + return + } + if parsed > wikiGraphMaxDepth { + parsed = wikiGraphMaxDepth + } + depth = parsed + } + + limit := wikiGraphDefaultLimit + if v := c.Query("limit"); v != "" { + parsed, parseErr := strconv.Atoi(v) + if parseErr != nil || parsed < 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": "limit must be a positive integer"}) + return + } + if parsed > wikiGraphMaxLimit { + parsed = wikiGraphMaxLimit + } + limit = parsed + } + + var typesFilter []string + if v := strings.TrimSpace(c.Query("types")); v != "" { + for _, t := range strings.Split(v, ",") { + t = strings.TrimSpace(t) + if t != "" { + typesFilter = append(typesFilter, t) + } + } + } + + req := &types.WikiGraphRequest{ + KnowledgeBaseID: kbID, + Mode: mode, + Center: center, + Depth: depth, + Types: typesFilter, + Limit: limit, + } + + graph, err := h.wikiService.GetGraph(c.Request.Context(), req) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return diff --git a/internal/types/interfaces/wiki_page.go b/internal/types/interfaces/wiki_page.go index ca10dd68a..d878f2588 100644 --- a/internal/types/interfaces/wiki_page.go +++ b/internal/types/interfaces/wiki_page.go @@ -56,8 +56,13 @@ type WikiPageService interface { // Creates a default one if it doesn't exist. GetLog(ctx context.Context, kbID string) (*types.WikiPage, error) - // GetGraph returns the link graph data for visualization. - GetGraph(ctx context.Context, kbID string) (*types.WikiGraphData, error) + // GetGraph returns the link graph data for visualization. The caller + // supplies a WikiGraphRequest describing the desired slice of the graph + // (overview top-N or ego neighborhood around a center slug). Callers + // that need the full graph (e.g. wiki lint) can set Limit <= 0 to + // disable the node cap; the HTTP handler always clamps Limit to a + // safe range before invoking the service. + GetGraph(ctx context.Context, req *types.WikiGraphRequest) (*types.WikiGraphData, error) // GetStats returns aggregate statistics about the wiki. GetStats(ctx context.Context, kbID string) (*types.WikiStats, error) diff --git a/internal/types/wiki_page.go b/internal/types/wiki_page.go index 28b91541d..2f4de58ee 100644 --- a/internal/types/wiki_page.go +++ b/internal/types/wiki_page.go @@ -198,10 +198,50 @@ type WikiPageListResponse struct { TotalPages int `json:"total_pages"` } -// WikiGraphData represents the link graph structure for visualization +// WikiGraphMode enumerates the graph query modes exposed to the API. +const ( + // WikiGraphModeOverview returns the top-N most-connected pages as an + // overview of the knowledge base. Intended for the first graph open. + WikiGraphModeOverview = "overview" + // WikiGraphModeEgo returns the neighborhood around a center page up to a + // configurable depth. Intended for drill-down interactions. + WikiGraphModeEgo = "ego" +) + +// WikiGraphRequest is the service-layer input for graph queries. It is +// populated by the HTTP handler from query params and passed down to the +// service, which is responsible for enforcing mode-specific semantics. +// +// Limit policy: a non-positive `Limit` means "no cap" and is reserved for +// internal callers (e.g. wiki lint) that need the full graph. The HTTP +// handler always clamps `Limit` into a safe range before calling the +// service so external traffic can never request an uncapped graph. +type WikiGraphRequest struct { + KnowledgeBaseID string + Mode string // "overview" (default) | "ego" + Center string // ego mode center slug (required when Mode == "ego") + Depth int // ego mode BFS depth, >= 1 + Types []string // optional page_type filter; empty = no filter + Limit int // max nodes to return; <= 0 means uncapped +} + +// WikiGraphData represents the link graph structure for visualization. type WikiGraphData struct { Nodes []WikiGraphNode `json:"nodes"` Edges []WikiGraphEdge `json:"edges"` + Meta WikiGraphMeta `json:"meta"` +} + +// WikiGraphMeta describes how the returned subgraph relates to the full +// knowledge base graph. The frontend uses `Truncated` to decide whether to +// surface a "showing X of Y" hint and to enable ego-expansion UI. +type WikiGraphMeta struct { + Mode string `json:"mode"` + Total int `json:"total"` // total node count in the KB before filtering/limit + Returned int `json:"returned"` // number of nodes actually returned + Truncated bool `json:"truncated"` // true when Returned < Total (after filters) + Center string `json:"center,omitempty"` // populated in ego mode + Depth int `json:"depth,omitempty"` // populated in ego mode } // WikiGraphNode represents a node in the wiki link graph