Files
Arcrun/landing/app/mira/wiki/[pageName]/page.tsx
T
Leo 3689f30409 fix(mira): [pageName] h1 顯示 entity 名 + listing dedupe by entity
兩個 leo 反饋的 UI bug:

1. wiki/[pageName] 對 index-entry 渲染時,h1 用 block.content(整篇 markdown)
   會把整個內容塞進 h1。改:wiki-page 用 content 當 entity 名;其他類型
   (index-entry/schema/log)用 page_name 剝 `wiki-` / `index-` prefix。

2. listing「Wiki Pages (21)」累積式設計造成同 entity 多版顯示為多張卡,雜亂。
   改:用 useMemo dedupe by entity(content)— 每 entity 一張卡顯示最新版,
   標題顯示「N 版累積」當 N>1。原始 21 筆 → 現在約 6-7 個 unique entity。
2026-05-16 09:03:45 +08:00

395 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<Block | null>(null);
const [paragraphs, setParagraphs] = useState<Block[]>([]);
const [triplets, setTriplets] = useState<Block[]>([]);
const [entitySet, setEntitySet] = useState<Set<string>>(new Set());
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<string>();
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<FacetGroup[]>(() => {
const groups: Map<string, Array<{ block: Block; triplets: Block[] }>> = 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 用 contententity 名稱),其他(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 (
<main className="mira-app">
<div className="mira-content mira-wiki-detail">
<header style={{ padding: '24px 0 16px', borderBottom: '1px solid #2a2a2a' }}>
<Link
href="/mira/wiki"
style={{ color: '#888', fontSize: 14, textDecoration: 'none' }}
>
Wiki 索引
</Link>
<h1 style={{ fontSize: 28, fontWeight: 700, color: '#fff', margin: '8px 0 4px' }}>
{entity}
</h1>
{block && (
<div style={{ color: '#666', fontSize: 12 }}>
{block.type} updated {new Date(block.updated_at * 1000).toLocaleString('zh-TW')}
</div>
)}
</header>
{loading && <div style={{ padding: 24, color: '#666' }}>載入中⋯</div>}
{error && <div style={{ padding: 24, color: '#e66' }}>{error}</div>}
{block && !loading && !error && (
<>
{/* wiki-page tree view */}
{isWikiPage && facetGroups.length > 0 && (
<article style={{ padding: '8px 0 24px' }}>
{facetGroups.map((group) => (
<FacetSection
key={group.facet}
group={group}
entitySet={entitySet}
collapsed={collapsed}
toggleCollapse={toggleCollapse}
/>
))}
</article>
)}
{/* wiki-page 但沒 childrenfallback render content */}
{isWikiPage && facetGroups.length === 0 && (
<article style={{ padding: '8px 0 24px', color: '#888' }}>
<em>尚無段落(wiki_synthesis 還沒跑出 children</em>
<MarkdownView text={block.content} />
</article>
)}
{/* 非 wiki-pageschema / index / log / index-entry 等):直接 render content */}
{!isWikiPage && (
<article style={{ padding: '20px 0' }}>
<MarkdownView text={block.content} />
</article>
)}
<footer
style={{
padding: '20px 0',
borderTop: '1px solid #1f1f1f',
color: '#555',
fontSize: 12,
}}
>
<div>id: <span style={{ fontFamily: 'monospace' }}>{block.id}</span></div>
<div>type: {block.type}</div>
{block.source && <div>source: {block.source}</div>}
{block.parent_id && (
<div>
parent: <span style={{ fontFamily: 'monospace' }}>{block.parent_id}</span>
</div>
)}
{paragraphs.length > 0 && (
<div>
{paragraphs.length} paragraph(s) {triplets.length} triplet(s)
</div>
)}
</footer>
</>
)}
</div>
</main>
);
}
function FacetSection({
group,
entitySet,
collapsed,
toggleCollapse,
}: {
group: FacetGroup;
entitySet: Set<string>;
collapsed: Record<string, boolean>;
toggleCollapse: (key: string) => void;
}) {
const key = `facet:${group.facet}`;
const isCollapsed = collapsed[key] ?? false; // 預設展開(leo 看一篇 wiki 時要看內容)
return (
<section style={{ margin: '16px 0', borderLeft: '3px solid #2a3a4a', paddingLeft: 14 }}>
<button
onClick={() => toggleCollapse(key)}
style={{
background: 'transparent',
border: 'none',
color: '#aab',
fontSize: 16,
fontWeight: 600,
padding: '4px 0',
cursor: 'pointer',
textAlign: 'left',
width: '100%',
}}
>
{isCollapsed ? '▸' : '▾'} {group.facet}
<span style={{ color: '#555', fontWeight: 400, fontSize: 13, marginLeft: 8 }}>
({group.paragraphs.length})
</span>
</button>
{!isCollapsed &&
group.paragraphs.map((p) => (
<ParagraphBlock
key={p.block.id}
block={p.block}
triplets={p.triplets}
entitySet={entitySet}
collapsed={collapsed}
toggleCollapse={toggleCollapse}
/>
))}
</section>
);
}
function ParagraphBlock({
block,
triplets,
entitySet,
collapsed,
toggleCollapse,
}: {
block: Block;
triplets: Block[];
entitySet: Set<string>;
collapsed: Record<string, boolean>;
toggleCollapse: (key: string) => void;
}) {
const tripKey = `trip:${block.id}`;
const tripsCollapsed = collapsed[tripKey] ?? true; // triplets 預設折疊
return (
<div style={{ margin: '12px 0 16px', paddingLeft: 4 }}>
<div style={{ color: '#ddd', lineHeight: 1.7 }}>
<MarkdownView text={block.content} />
</div>
{triplets.length > 0 && (
<div style={{ marginTop: 8 }}>
<button
onClick={() => toggleCollapse(tripKey)}
style={{
background: 'transparent',
border: 'none',
color: '#666',
fontSize: 12,
padding: '2px 0',
cursor: 'pointer',
}}
>
{tripsCollapsed ? '▸' : '▾'} 關係 ({triplets.length})
</button>
{!tripsCollapsed && (
<ul style={{ listStyle: 'none', padding: '4px 0 0 12px', margin: 0 }}>
{triplets.map((t) => (
<li
key={t.id}
style={{
color: '#888',
fontSize: 13,
padding: '2px 0',
fontFamily: 'monospace',
}}
>
<TripletRender content={t.content} entitySet={entitySet} />
</li>
))}
</ul>
)}
</div>
)}
</div>
);
}
/** Render triplet "A >> 關係 >> B" with A/B linkified if they match an existing wiki entity */
function TripletRender({
content,
entitySet,
}: {
content: string;
entitySet: Set<string>;
}) {
// 切「>>」分 A / 關係 / B
const parts = content.split('>>').map((s) => s.trim());
if (parts.length !== 3) {
return <>{content}</>;
}
const [a, rel, b] = parts;
return (
<>
<EntityLink name={a} entitySet={entitySet} />{' '}
<span style={{ color: '#666' }}>&gt;&gt; {rel} &gt;&gt;</span>{' '}
<EntityLink name={b} entitySet={entitySet} />
</>
);
}
function EntityLink({ name, entitySet }: { name: string; entitySet: Set<string> }) {
if (entitySet.has(name)) {
return (
<Link
href={`/mira/wiki/${encodeURIComponent(`wiki-${name}`)}`}
style={{ color: '#88c0ff', textDecoration: 'none' }}
>
{name}
</Link>
);
}
return <span style={{ color: '#ccc' }}>{name}</span>;
}
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;
}
}