feat(knowledge): restore complete human rendering layer

This commit is contained in:
冰朔 2026-09-03 16:25:43 +08:00
commit 3466b507c6
15 changed files with 665 additions and 23 deletions

View file

@ -4,6 +4,12 @@ import { relaunch } from "@tauri-apps/plugin-process";
import { Icon } from "./icons";
import * as api from "./runtime";
import type { KnowledgeDocument, SourceKind, SystemSnapshot } from "./types";
import {
MarkdownDocument,
documentOutline,
jumpToHeading,
splitFrontmatter,
} from "./modules/knowledge-render";
type View =
| "channel"
@ -248,9 +254,10 @@ function Channel({
<article>
<h2></h2>
<strong>
{data.publicDistributionState === "SIGNED_UPDATE_FEED_READY_NO_RELEASE"
? "更新入口在线 · 暂无发行"
: data.publicDistributionState}
{data.publicDistributionState ===
"SIGNED_UPDATE_FEED_READY_NO_RELEASE"
? "更新入口在线 · 暂无发行"
: data.publicDistributionState}
</strong>
<p> Git </p>
</article>
@ -310,12 +317,15 @@ function Knowledge({
[doc, setDoc] = useState<KnowledgeDocument | null>(null),
[body, setBody] = useState(""),
[query, setQuery] = useState(""),
[newTitle, setNewTitle] = useState<string | null>(null);
[newTitle, setNewTitle] = useState<string | null>(null),
[editing, setEditing] = useState(false),
[inspectorOpen, setInspectorOpen] = useState(true);
useEffect(() => {
if (selected)
api.readDocument(selected).then((d) => {
setDoc(d);
setBody(d.body);
setEditing(false);
});
}, [selected]);
const list = useMemo(
@ -337,6 +347,8 @@ function Knowledge({
const save = async () => {
if (doc) {
await api.saveDocument(doc.path, body);
setDoc({ ...doc, body });
setEditing(false);
refresh();
}
};
@ -348,6 +360,20 @@ function Knowledge({
refresh();
}
};
const parsed = useMemo(() => splitFrontmatter(doc?.body ?? ""), [doc?.body]);
const outline = useMemo(() => documentOutline(doc?.body ?? ""), [doc?.body]);
const stats = useMemo(() => {
const chars = parsed.content.replace(/\s/g, "").length;
return { chars, minutes: Math.max(1, Math.ceil(chars / 450)) };
}, [parsed.content]);
const openWiki = (target: string) => {
const normalized = target.replace(/[\s·•・\-_|*`]/g, "").toLowerCase();
const hit = data.documents.find(
(item) =>
item.title.replace(/[\s·•・\-_|*`]/g, "").toLowerCase() === normalized,
);
if (hit) setSelected(hit.path);
};
return (
<div className="page knowledge-page">
<header>
@ -368,8 +394,14 @@ function Knowledge({
<button onClick={() => setNewTitle("")}></button>
</div>
</header>
<div className="knowledge-workspace">
<aside>
<div
className={`knowledge-workspace complete ${inspectorOpen ? "" : "inspector-closed"}`}
>
<aside className="knowledge-browser">
<div className="browser-title">
<strong></strong>
<span>{data.documents.length} </span>
</div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
@ -392,25 +424,69 @@ function Knowledge({
))
)}
</aside>
<section>
<section className="document-workspace">
{doc ? (
<>
<div className="editor-tools">
<strong>{doc.title}</strong>
<span />
<button onClick={() => setInspectorOpen((value) => !value)}>
{inspectorOpen ? "收起大纲" : "展开大纲"}
</button>
<button onClick={() => api.exportDocument(doc.path)}>
</button>
<button className="danger" onClick={del}>
</button>
<button onClick={save}></button>
{editing ? (
<>
<button
onClick={() => {
setBody(doc.body);
setEditing(false);
}}
>
</button>
<button onClick={save}></button>
</>
) : (
<>
<button className="danger" onClick={del}>
</button>
<button onClick={() => setEditing(true)}></button>
</>
)}
</div>
<textarea
className="editor"
value={body}
onChange={(e) => setBody(e.target.value)}
/>
{editing ? (
<textarea
className="editor"
aria-label="Markdown 编辑器"
value={body}
onChange={(e) => setBody(e.target.value)}
/>
) : (
<div className="document-scroll">
<header className="reader-heading">
<h1>{doc.title}</h1>
<p>
{stats.chars.toLocaleString()} · {stats.minutes}{" "}
</p>
{parsed.tags.length > 0 && (
<div className="meta-tags">
{parsed.tags.map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
)}
</header>
<MarkdownDocument
body={doc.body}
knownTitles={data.documents.map((item) => item.title)}
onWiki={openWiki}
/>
</div>
)}
</>
) : (
<div className="welcome">
@ -421,6 +497,53 @@ function Knowledge({
</div>
)}
</section>
{inspectorOpen && (
<aside className="document-inspector">
<header>
<strong></strong>
<span></span>
</header>
{doc ? (
<>
<nav className="outline-list">
{outline.length ? (
outline.map((item) => (
<button
key={item.id}
style={{ paddingLeft: 12 + (item.level - 1) * 10 }}
onClick={() => jumpToHeading(item.id)}
>
{item.title}
</button>
))
) : (
<p></p>
)}
</nav>
<dl className="document-evidence">
<div>
<dt></dt>
<dd> Git</dd>
</div>
<div>
<dt></dt>
<dd>{doc.path}</dd>
</div>
<div>
<dt></dt>
<dd>{doc.sha256.slice(0, 16)}</dd>
</div>
<div>
<dt></dt>
<dd> · </dd>
</div>
</dl>
</>
) : (
<p className="empty"></p>
)}
</aside>
)}
</div>
{newTitle !== null && (
<div className="modal-backdrop" onMouseDown={() => setNewTitle(null)}>

View file

@ -0,0 +1,223 @@
//! 知识渲染件 · 渲染入口(常驻模块 · 插座口 knowledge-render
//!
//! 职责:把仓库里的 Markdown 文件变成可读的页面——frontmatter 解析、
//! Notion/Outline 导出兼容、标题锚点、大纲同源、跳转。
import { useMemo } from "react";
import DOMPurify from "dompurify";
import { marked, Parser, type Tokens } from "marked";
export { knowledgeRenderManifest } from "./manifest";
function escapeHtml(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
export function splitFrontmatter(body: string) {
const normalized = body.replace(/\r\n/g, "\n");
if (!normalized.startsWith("---\n"))
return {
content: normalized,
metadata: {} as Record<string, string>,
tags: [] as string[],
};
const closing = normalized.indexOf("\n---\n", 4);
if (closing < 0)
return {
content: normalized,
metadata: {} as Record<string, string>,
tags: [] as string[],
};
const metadata: Record<string, string> = {};
const tags: string[] = [];
let pendingListKey = "";
for (const line of normalized.slice(4, closing).split("\n")) {
const listItem = /^\s+-\s+(.+)$/.exec(line);
if (listItem && pendingListKey) {
if (pendingListKey === "tags")
tags.push(listItem[1].trim().replace(/^['"]|['"]$/g, ""));
continue;
}
const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
if (!match) continue;
pendingListKey = match[2] === "" ? match[1] : "";
const value = match[2].replace(/^['"]|['"]$/g, "");
metadata[match[1]] = value;
if (match[1] === "tags") {
const inline = /^\[(.*)\]$/.exec(match[2].trim());
const source = inline ? inline[1] : value;
source
.split(",")
.map((part) => part.trim().replace(/^['"]|['"]$/g, ""))
.filter(Boolean)
.forEach((tag) => tags.push(tag));
}
}
return { content: normalized.slice(closing + 5), metadata, tags };
}
function stripNotionLinks(line: string) {
// Notion 页面链接 → 只留链接文字(跳转关系按谕旨失效)
return line.replace(
/\[([^\]]*)\]\(([^)]*)\)/g,
(whole, text: string, href: string) =>
/notion\.(?:so|site)/.test(href) ? text : whole,
);
}
export function notionCompat(content: string) {
// 存量旧库文件尚未洗净:渲染时按"进门即洗"同规矩兜底过滤一遍。
// 内容一字不改,只把外来知识库的外壳换成光湖原生 Markdown冰朔 2026-08-15 谕:不兼容,进门就转)。
const result: string[] = [];
for (const line of content.replace(/\r\n/g, "\n").split("\n")) {
const trimmed = line.trim();
if (trimmed === "<aside>" || trimmed === "</aside>") continue;
if (trimmed.startsWith(":::toggle")) {
const title = trimmed.slice(":::toggle".length).trim();
result.push(title ? `**▸ ${title}**` : "**▸ 详情**");
continue;
}
if (trimmed === ":::" || trimmed.startsWith(":::toc")) continue;
result.push(stripNotionLinks(line.replace(/<br\s*\/?>/g, "\n")));
}
// Notion 粗体写法(**文字:**紧接正文)不合 CommonMark给收尾星号补一口气
return result
.join("\n")
.replace(/(\*\*[^*\n]+\*\*)(?=[^\s.,;:!?,。;:!?、)\]"'])/g, "$1 ");
}
const CALLOUT_TINTS: [RegExp, string][] = [
[/📌|🌌|🧭|💜|🗂|🏷|📚/, "callout-lavender"],
[/💡|⚡|🌟|☀|✨|🔑/, "callout-amber"],
[/⚠|🔥|❗|🚨|❌/, "callout-rose"],
[/✅|🌿|🍀|💚|✔/, "callout-mint"],
[/🌊|💧|🔵|❄|🧊/, "callout-sky"],
];
function normalizeTitle(value: string) {
return value.replace(/[\s·•・\-_|*`]/g, "").toLowerCase();
}
function calloutTint(text: string) {
for (const [pattern, tint] of CALLOUT_TINTS)
if (pattern.test(text)) return tint;
return "callout-slate";
}
export function markdownHtml(
body: string,
knownTitles: readonly string[] = [],
) {
const compat = notionCompat(body);
const titleMap = new Map<string, string>();
for (const title of knownTitles) titleMap.set(normalizeTitle(title), title);
const withWikiLinks = compat.replace(
/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(_whole, target: string, label?: string) =>
`<button class="wiki-link" type="button" data-wiki="${escapeHtml(target.trim())}">${escapeHtml((label || target).trim())}</button>`,
);
let headingIndex = 0;
const renderer = new marked.Renderer();
renderer.heading = (token) => {
const id = `heading-${headingIndex}`;
headingIndex += 1;
const inlineHtml = token.tokens.length
? Parser.parseInline(token.tokens)
: escapeHtml(token.text);
return `<h${token.depth} id="${id}">${inlineHtml}</h${token.depth}>\n`;
};
renderer.blockquote = (token) => {
const inner = Parser.parse(token.tokens) as string;
return `<blockquote class="callout ${calloutTint(token.raw)}">${inner}</blockquote>\n`;
};
renderer.link = (token) => {
const inner = token.tokens.length
? Parser.parseInline(token.tokens)
: escapeHtml(token.text);
const href = token.href || "";
if (/notion\.(?:so|site)/i.test(href)) {
// Notion 残留链接:认得出是自家页面就转成内部跳转,认不出标"外来页面"。
const hit = titleMap.get(normalizeTitle(token.text));
if (hit)
return `<button class="wiki-link" type="button" data-wiki="${escapeHtml(hit)}">${inner}</button>`;
return `<a class="external-link" href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${inner}<span class="external-mark">外来页面</span></a>`;
}
if (/^https?:\/\//i.test(href))
return `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">${inner}</a>`;
return `<a href="${escapeHtml(href)}">${inner}</a>`;
};
const html = marked.parse(withWikiLinks, {
async: false,
gfm: true,
breaks: false,
renderer,
}) as string;
return DOMPurify.sanitize(html, {
ADD_ATTR: ["data-wiki", "id", "target", "rel"],
ADD_TAGS: ["button"],
});
}
export function documentOutline(body: string) {
const headings: { id: string; level: number; title: string }[] = [];
const plain = (tokens: unknown): string =>
(tokens as { type: string; text?: string; tokens?: unknown[] }[])
.map((token) =>
token.tokens && token.tokens.length
? plain(token.tokens)
: token.text || "",
)
.join("");
marked
.lexer(splitFrontmatter(body).content, { gfm: true })
.filter((token): token is Tokens.Heading => token.type === "heading")
.forEach((token, index) => {
if (index >= 24) return;
headings.push({
id: `heading-${index}`,
level: token.depth,
title: plain(token.tokens).trim(),
});
});
return headings;
}
export function jumpToHeading(id: string) {
const element = document.querySelector<HTMLElement>(
`.document-scroll [id="${id}"]`,
);
if (!element) return;
element.scrollIntoView({ behavior: "smooth", block: "start" });
element.classList.remove("heading-flash");
void element.offsetWidth;
element.classList.add("heading-flash");
}
export function MarkdownDocument({
body,
knownTitles,
onWiki,
}: {
body: string;
knownTitles?: readonly string[];
onWiki?: (target: string) => void;
}) {
const parsed = useMemo(() => splitFrontmatter(body), [body]);
const html = useMemo(
() => markdownHtml(parsed.content, knownTitles),
[parsed.content, knownTitles],
);
return (
<article
className="markdown-document"
onClick={(event) => {
const element = (event.target as HTMLElement).closest<HTMLElement>(
"[data-wiki]",
);
if (element?.dataset.wiki && onWiki) onWiki(element.dataset.wiki);
}}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}

View file

@ -0,0 +1,27 @@
//! 模块形状声明 · 插座制(冰朔 2026-08-15 架构谕)
//!
//! 软件只认这一个插座形状:每个模块 = manifest我是谁/插哪个口/从哪来)
//! + index渲染入口导出。常驻模块住在频道里将来 origin='repo' 的
//! 模块躺代码仓库,人格体按需拉取部署。
export const knowledgeRenderManifest = {
moduleId: "hololake.knowledge-render",
name: "知识渲染件",
version: "0.1.0",
slot: "knowledge-render",
origin: "resident",
exports: [
"splitFrontmatter",
"documentOutline",
"jumpToHeading",
"MarkdownDocument",
],
} as const;
export type ModuleManifest = {
moduleId: string;
name: string;
version: string;
slot: string;
origin: "resident" | "repo";
exports: readonly string[];
};

View file

@ -459,6 +459,32 @@ main {
overflow: hidden;
background: rgba(5, 17, 31, 0.76);
}
.knowledge-workspace.complete {
grid-template-columns: 260px minmax(0, 1fr) 232px;
height: min(720px, calc(100vh - 175px));
min-height: 560px;
}
.knowledge-workspace.complete.inspector-closed {
grid-template-columns: 260px minmax(0, 1fr);
}
.knowledge-browser {
min-height: 0;
overflow: auto;
}
.browser-title {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 2px 13px;
}
.browser-title strong {
font-size: 12px;
color: #cad8e6;
}
.browser-title span {
font-size: 10px;
color: #6f879e;
}
.knowledge-workspace > aside {
border-right: 1px solid var(--line);
padding: 13px;
@ -532,6 +558,197 @@ main {
font-family: "SFMono-Regular", Menlo, monospace;
font-size: 14px;
}
.document-workspace {
min-height: 0;
}
.document-scroll {
min-height: 0;
overflow: auto;
padding: 42px clamp(28px, 5vw, 70px) 70px;
}
.reader-heading {
padding-bottom: 25px;
margin-bottom: 25px;
border-bottom: 1px solid var(--line);
}
.reader-heading h1 {
font-size: 34px;
margin: 0 0 10px;
letter-spacing: -0.025em;
}
.reader-heading p {
color: #8097ad;
font-size: 12px;
margin: 0;
}
.meta-tags {
display: flex;
gap: 7px;
margin-top: 14px;
}
.meta-tags span {
padding: 4px 8px;
border-radius: 7px;
color: #b8d8ef;
background: rgba(71, 139, 188, 0.14);
font-size: 10px;
}
.markdown-document {
color: #cbd8e6;
font-size: 15px;
line-height: 1.9;
overflow-wrap: anywhere;
}
.markdown-document h1,
.markdown-document h2,
.markdown-document h3,
.markdown-document h4 {
color: #f0f5fb;
line-height: 1.35;
scroll-margin-top: 24px;
}
.markdown-document h1 {
font-size: 28px;
margin: 1.4em 0 0.6em;
}
.markdown-document h2 {
font-size: 22px;
margin: 1.7em 0 0.55em;
}
.markdown-document h3 {
font-size: 18px;
margin: 1.5em 0 0.5em;
}
.markdown-document p {
margin: 0 0 1.1em;
}
.markdown-document a,
.markdown-document .wiki-link {
color: #84c9f3;
}
.markdown-document .wiki-link {
display: inline;
border: 0;
padding: 0 2px;
background: transparent;
text-decoration: underline;
text-underline-offset: 3px;
}
.markdown-document blockquote.callout {
margin: 1.3em 0;
padding: 14px 17px;
border-left: 3px solid #74bee9;
border-radius: 0 10px 10px 0;
background: rgba(72, 137, 181, 0.1);
}
.markdown-document blockquote.callout-amber {
border-left-color: var(--gold);
background: rgba(205, 153, 64, 0.1);
}
.markdown-document blockquote.callout-rose {
border-left-color: #df8991;
background: rgba(190, 83, 94, 0.1);
}
.markdown-document blockquote.callout-mint {
border-left-color: var(--green);
background: rgba(81, 170, 119, 0.1);
}
.markdown-document pre {
overflow: auto;
padding: 16px;
border: 1px solid var(--line);
border-radius: 12px;
background: rgba(1, 7, 14, 0.65);
}
.markdown-document code {
font-family: "SFMono-Regular", Menlo, monospace;
font-size: 0.9em;
}
.markdown-document table {
width: 100%;
border-collapse: collapse;
margin: 1.4em 0;
}
.markdown-document th,
.markdown-document td {
padding: 9px 11px;
border-bottom: 1px solid var(--line);
text-align: left;
}
.markdown-document img {
max-width: 100%;
border-radius: 12px;
}
.heading-flash {
animation: headingFlash 1.2s ease;
}
@keyframes headingFlash {
0% {
color: var(--gold);
text-shadow: 0 0 18px rgba(240, 197, 107, 0.4);
}
}
.document-inspector {
min-height: 0;
overflow: auto;
border-left: 1px solid var(--line);
border-right: 0 !important;
padding: 0 !important;
}
.document-inspector > header {
padding: 18px 16px;
border-bottom: 1px solid var(--line);
}
.document-inspector > header strong,
.document-inspector > header span {
display: block;
}
.document-inspector > header strong {
font-size: 13px;
}
.document-inspector > header span {
color: #72899f;
font-size: 10px;
margin-top: 4px;
}
.outline-list {
padding: 10px 8px 18px;
border-bottom: 1px solid var(--line);
}
.outline-list button {
display: block;
width: 100%;
border: 0;
background: transparent;
text-align: left;
color: #9fb2c5;
font-size: 11px;
line-height: 1.45;
padding-block: 7px;
}
.outline-list p {
color: #72899f;
font-size: 11px;
padding: 8px;
}
.document-evidence {
padding: 14px 16px;
margin: 0;
}
.document-evidence div {
margin-bottom: 13px;
}
.document-evidence dt {
color: #6f879e;
font-size: 10px;
}
.document-evidence dd {
color: #bdcddd;
font-size: 11px;
line-height: 1.5;
margin: 4px 0 0;
word-break: break-all;
}
.split-list {
display: grid;
grid-template-columns: 1fr 1fr;
@ -848,6 +1065,13 @@ main {
.knowledge-workspace {
grid-template-columns: 210px 1fr;
}
.knowledge-workspace.complete,
.knowledge-workspace.complete.inspector-closed {
grid-template-columns: 210px minmax(0, 1fr);
}
.document-inspector {
display: none;
}
.split-list {
grid-template-columns: 1fr;
}