diff --git a/landing/app/mira/mira.css b/landing/app/mira/mira.css index 4ddff7e..ee29904 100644 --- a/landing/app/mira/mira.css +++ b/landing/app/mira/mira.css @@ -755,3 +755,23 @@ .mira-app .mira-rel-time { cursor: default; } + +/* ── Wiki detail (7B.3g 樹狀渲染) ── */ +/* .mira-md 已在 §190+ 定義整套 markdown 樣式(河道+wiki 共用),這裡只加 wiki 細節頁的「不破版」保護 */ +.mira-app .mira-wiki-detail { + overflow-wrap: break-word; + word-break: break-word; +} + +/* 防止長 cypher binding 字串 / URL 撐爆右邊界 */ +.mira-app .mira-wiki-detail .mira-md li, +.mira-app .mira-wiki-detail .mira-md p { + overflow-wrap: break-word; + word-break: break-word; +} + +/* wiki h2/h3 加底線分隔,更像文章 */ +.mira-app .mira-wiki-detail .mira-md h2 { + border-bottom: 1px solid var(--mira-line-soft); + padding-bottom: 4px; +} diff --git a/landing/app/mira/wiki/[pageName]/page.tsx b/landing/app/mira/wiki/[pageName]/page.tsx index acb1daa..b5e8e7a 100644 --- a/landing/app/mira/wiki/[pageName]/page.tsx +++ b/landing/app/mira/wiki/[pageName]/page.tsx @@ -3,12 +3,13 @@ export const runtime = 'edge'; // Mira Wiki 單篇頁 -// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.2 + §3.5.7 -// 對應 task: 7C.2 +// SDD: polaris/mira/.agents/specs/mira-app/design.md §5.2 + §3.5.12 +// 對應 task: 7C.2 + 7B.3g // 路由:/mira/wiki/[pageName] -// 顯示單一 wiki block + 它的 children(wiki-paragraph) +// 顯示:wiki-page parent → wiki-paragraph children (按 facet 分區) → triplet grandchildren +// 7B.3g 升級:樹狀渲染 + 折疊 + triplet 跨 wiki 連結化 -import { useEffect, useState, use } from 'react'; +import { useEffect, useMemo, useState, use } from 'react'; import Link from 'next/link'; import { MarkdownView } from '../../_shared/markdown'; import '../../mira.css'; @@ -18,15 +19,24 @@ const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? 'https://cypher.arcrun.dev' type Block = { id: string; - page_name: 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, }: { @@ -34,8 +44,12 @@ export default function WikiPagePage({ }) { const { pageName } = use(params); const decodedName = decodeURIComponent(pageName); + const [block, setBlock] = useState(null); - const [siblings, setSiblings] = useState([]); + 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); @@ -48,25 +62,61 @@ export default function WikiPagePage({ const me = (await meRes.json()) as { api_key: string }; const headers = { Authorization: `Bearer ${me.api_key}` }; - const res = await fetch( + // 1. 抓 wiki-page parent block by page_name + const pageRes = await fetch( `${KBDB_BASE}/blocks?page_name=${encodeURIComponent(decodedName)}&limit=1`, { headers }, ); - if (!res.ok) throw new Error(`KBDB ${res.status}`); - const data = await res.json(); - const found: Block | undefined = data.blocks?.[0]; + 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 (!found) { + if (!wikiPage) { setError(`找不到 wiki page:${decodedName}`); return; } - setBlock(found); + setBlock(wikiPage); - // 若這是個 child block,撈 parent 下其他 siblings 給導航用 - if (found.parent_id) { - // KBDB 沒 children endpoint,用 page_name 找不到 siblings;先略過 - setSiblings([]); + // 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 { @@ -79,56 +129,79 @@ export default function WikiPagePage({ }; }, [decodedName]); - const tags = parseTags(block?.tags_json); - const subtype = tags - .find((t) => t.startsWith('subtype:')) - ?.replace('subtype:', ''); + // 按 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 entity = block?.content?.trim() || decodedName.replace(/^wiki-/, ''); + const isWikiPage = block?.type === 'wiki-page'; + + function toggleCollapse(key: string) { + setCollapsed((c) => ({ ...c, [key]: !c[key] })); + } return (
-
+
-
- - ← Wiki 索引 - -
-

- {decodedName} + + ← Wiki 索引 + +

+ {entity}

- {subtype && ( -
- - subtype: {subtype} - + {block && ( +
+ {block.type} ・ updated {new Date(block.updated_at * 1000).toLocaleString('zh-TW')}
)}
{loading &&
載入中⋯
} - {error && ( -
{error}
- )} + {error &&
{error}
} {block && !loading && !error && ( <> -
- -
+ {/* wiki-page tree view */} + {isWikiPage && facetGroups.length > 0 && ( +
+ {facetGroups.map((group) => ( + + ))} +
+ )} - {/* 7C.3 contribution log placeholder(此頁是 schema/index/log infra 而非 wiki-page,先不顯示) */} + {/* 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}
)} - {tags.length > 0 &&
tags: {tags.join(', ')}
} -
updated: {new Date(block.updated_at * 1000).toLocaleString('zh-TW')}
+ {paragraphs.length > 0 && ( +
+ {paragraphs.length} paragraph(s) ・ {triplets.length} triplet(s) +
+ )}
)} @@ -155,11 +232,159 @@ export default function WikiPagePage({ ); } -function parseTags(tags_json: string | null | undefined): string[] { - if (!tags_json) return []; +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 { - return JSON.parse(tags_json) as string[]; + const tags = JSON.parse(tags_json) as string[]; + const facetTag = tags.find((t) => t.startsWith('facet:')); + return facetTag ? facetTag.slice(6) : null; } catch { - return []; + return null; } } diff --git a/landing/app/mira/wiki/page.tsx b/landing/app/mira/wiki/page.tsx index 744a17b..b2ef637 100644 --- a/landing/app/mira/wiki/page.tsx +++ b/landing/app/mira/wiki/page.tsx @@ -41,15 +41,27 @@ export default function WikiIndexPage() { const me = (await meRes.json()) as { api_key: string }; const headers = { Authorization: `Bearer ${me.api_key}` }; - // 用 tag=mira-wiki 一次撈所有相關 blocks(KBDB list endpoint 2026-05-07 加了 tag filter) + // 撈所有 type=wiki-page,再 client 端過濾 tags 含 'mira-wiki' + // 原本 ?tag=mira-wiki 撞 KBDB worker D1 bug(malformed JSON),改 type filter + // 待 KBDB 修 tag filter 後可改回(SDD 待開 kbdb-tag-filter-fix) const res = await fetch( - `${KBDB_BASE}/blocks?tag=mira-wiki&limit=200`, + `${KBDB_BASE}/blocks?type=wiki-page&limit=200`, { headers }, ); if (!res.ok) throw new Error(`KBDB ${res.status}`); const data = await res.json(); if (cancelled) return; - const blocks: Block[] = data.blocks ?? []; + const allWikiBlocks: Block[] = data.blocks ?? []; + // Client 端過濾:只留 tags 含 'mira-wiki' + const blocks: Block[] = allWikiBlocks.filter((b) => { + if (!b.tags_json) return false; + try { + const tags = JSON.parse(b.tags_json) as string[]; + return tags.includes('mira-wiki'); + } catch { + return false; + } + }); const tagsOf = (b: Block): string[] => { if (!b.tags_json) return []; @@ -175,8 +187,12 @@ export default function WikiIndexPage() { ))}