'use client'; export const runtime = 'edge'; // Mira Wiki 單篇頁 // SDD: polaris/mira/.agents/specs/mira-app/design.md §5.2 + §3.5.12 // 對應 task: 7C.2 + 7B.3g // 路由:/mira/wiki/[pageName] // 顯示:wiki-page parent → wiki-paragraph children (按 facet 分區) → triplet grandchildren // 7B.3g 升級:樹狀渲染 + 折疊 + triplet 跨 wiki 連結化 import { useEffect, useMemo, useState, use } from 'react'; import Link from 'next/link'; import { MarkdownView } from '../../_shared/markdown'; import '../../mira.css'; const KBDB_BASE = 'https://kbdb.finally.click'; const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev'; type Block = { id: string; page_name: string | null; content: string; type: string; parent_id: string | null; tags_json: string | null; source: string | null; created_at: number; updated_at: number; }; type FacetGroup = { facet: string; paragraphs: Array<{ block: Block; triplets: Block[]; }>; }; export default function WikiPagePage({ params, }: { params: Promise<{ pageName: string }>; }) { const { pageName } = use(params); const decodedName = decodeURIComponent(pageName); const [block, setBlock] = useState(null); const [paragraphs, setParagraphs] = useState([]); const [triplets, setTriplets] = useState([]); const [entitySet, setEntitySet] = useState>(new Set()); const [collapsed, setCollapsed] = useState>({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; async function load() { try { const meRes = await fetch(`${API_BASE}/me`, { credentials: 'include' }); if (!meRes.ok) throw new Error('未登入'); const me = (await meRes.json()) as { api_key: string }; const headers = { Authorization: `Bearer ${me.api_key}` }; // 1. 抓 wiki-page parent block by page_name const pageRes = await fetch( `${KBDB_BASE}/blocks?page_name=${encodeURIComponent(decodedName)}&limit=1`, { headers }, ); if (!pageRes.ok) throw new Error(`KBDB ${pageRes.status}`); const pageData = await pageRes.json(); const wikiPage: Block | undefined = pageData.blocks?.[0]; if (cancelled) return; if (!wikiPage) { setError(`找不到 wiki page:${decodedName}`); return; } setBlock(wikiPage); // 2. 平行撈所有 wiki-paragraph + triplet + wiki-page(跨 wiki 連結用),客戶端 filter by parent_id // KBDB 沒 parent_id server filter(兼 tag filter 還有 KI-3 bug),用 source+type 取再 client-side filter const [paraRes, tripRes, pageListRes] = await Promise.all([ fetch(`${KBDB_BASE}/blocks?source=ai-canon-wiki&type=wiki-paragraph&limit=500`, { headers }), fetch(`${KBDB_BASE}/blocks?source=ai-canon-wiki&type=triplet&limit=1000`, { headers }), fetch(`${KBDB_BASE}/blocks?source=ai-canon-wiki&type=wiki-page&limit=500`, { headers }), ]); if (!paraRes.ok || !tripRes.ok || !pageListRes.ok) { throw new Error('KBDB tree fetch failed'); } const paraData = await paraRes.json(); const tripData = await tripRes.json(); const pageListData = await pageListRes.json(); if (cancelled) return; const allParas: Block[] = paraData.blocks ?? []; const allTrips: Block[] = tripData.blocks ?? []; const allPages: Block[] = pageListData.blocks ?? []; // 該 wiki-page 的 paragraphs const myParas = allParas .filter((p) => p.parent_id === wikiPage.id) .sort((a, b) => a.created_at - b.created_at); setParagraphs(myParas); // 該 wiki-page 範圍內所有 paragraph 的 triplets const paraIdSet = new Set(myParas.map((p) => p.id)); const myTrips = allTrips.filter((t) => t.parent_id && paraIdSet.has(t.parent_id)); setTriplets(myTrips); // 跨 wiki 連結用:所有 wiki-page 的 entity 名稱(content 就是 entity) // 額外把 page_name 也加入(page_name=wiki-{entity}) const eset = new Set(); for (const p of allPages) { if (p.content) eset.add(p.content.trim()); if (p.page_name?.startsWith('wiki-')) { eset.add(p.page_name.slice(5).trim()); } } setEntitySet(eset); } catch (e: any) { if (!cancelled) setError(e?.message ?? 'load failed'); } finally { if (!cancelled) setLoading(false); } } load(); return () => { cancelled = true; }; }, [decodedName]); // 按 facet 分區 const facetGroups = useMemo(() => { const groups: Map> = new Map(); for (const p of paragraphs) { const facet = extractFacet(p.tags_json) ?? '未分類'; const myTrips = triplets.filter((t) => t.parent_id === p.id); if (!groups.has(facet)) groups.set(facet, []); groups.get(facet)!.push({ block: p, triplets: myTrips }); } return Array.from(groups.entries()).map(([facet, paragraphs]) => ({ facet, paragraphs })); }, [paragraphs, triplets]); const isWikiPage = block?.type === 'wiki-page'; // 標題:wiki-page 用 content(entity 名稱),其他(index-entry/schema/log/...)用 page_name 剝 prefix // 修 bug:原本一律用 block.content,但 index-entry 的 content 是整篇 markdown,會把整個 content render 成 h1 const entity = isWikiPage ? (block?.content?.trim() || decodedName.replace(/^wiki-/, '')) : decodedName.replace(/^(wiki|index)-/, ''); function toggleCollapse(key: string) { setCollapsed((c) => ({ ...c, [key]: !c[key] })); } return (
← Wiki 索引

{entity}

{block && (
{block.type} ・ updated {new Date(block.updated_at * 1000).toLocaleString('zh-TW')}
)}
{loading &&
載入中⋯
} {error &&
{error}
} {block && !loading && !error && ( <> {/* wiki-page tree view */} {isWikiPage && facetGroups.length > 0 && (
{facetGroups.map((group) => ( ))}
)} {/* wiki-page 但沒 children:fallback render content */} {isWikiPage && facetGroups.length === 0 && (
尚無段落(wiki_synthesis 還沒跑出 children)
)} {/* 非 wiki-page(schema / index / log / index-entry 等):直接 render content */} {!isWikiPage && (
)}
id: {block.id}
type: {block.type}
{block.source &&
source: {block.source}
} {block.parent_id && (
parent: {block.parent_id}
)} {paragraphs.length > 0 && (
{paragraphs.length} paragraph(s) ・ {triplets.length} triplet(s)
)}
)}
); } function FacetSection({ group, entitySet, collapsed, toggleCollapse, }: { group: FacetGroup; entitySet: Set; collapsed: Record; toggleCollapse: (key: string) => void; }) { const key = `facet:${group.facet}`; const isCollapsed = collapsed[key] ?? false; // 預設展開(leo 看一篇 wiki 時要看內容) return (
{!isCollapsed && group.paragraphs.map((p) => ( ))}
); } function ParagraphBlock({ block, triplets, entitySet, collapsed, toggleCollapse, }: { block: Block; triplets: Block[]; entitySet: Set; collapsed: Record; toggleCollapse: (key: string) => void; }) { const tripKey = `trip:${block.id}`; const tripsCollapsed = collapsed[tripKey] ?? true; // triplets 預設折疊 return (
{triplets.length > 0 && (
{!tripsCollapsed && (
    {triplets.map((t) => (
  • ))}
)}
)}
); } /** Render triplet "A >> 關係 >> B" with A/B linkified if they match an existing wiki entity */ function TripletRender({ content, entitySet, }: { content: string; entitySet: Set; }) { // 切「>>」分 A / 關係 / B const parts = content.split('>>').map((s) => s.trim()); if (parts.length !== 3) { return <>{content}; } const [a, rel, b] = parts; return ( <> {' '} >> {rel} >>{' '} ); } function EntityLink({ name, entitySet }: { name: string; entitySet: Set }) { if (entitySet.has(name)) { return ( {name} ); } return {name}; } function extractFacet(tags_json: string | null | undefined): string | null { if (!tags_json) return null; try { const tags = JSON.parse(tags_json) as string[]; const facetTag = tags.find((t) => t.startsWith('facet:')); return facetTag ? facetTag.slice(6) : null; } catch { return null; } }