diff --git a/product-source/hololake-clean-desktop/audit/FIDELITY-LEDGER-20260903.md b/product-source/hololake-clean-desktop/audit/FIDELITY-LEDGER-20260903.md index fc208e28f..1ceec6861 100644 --- a/product-source/hololake-clean-desktop/audit/FIDELITY-LEDGER-20260903.md +++ b/product-source/hololake-clean-desktop/audit/FIDELITY-LEDGER-20260903.md @@ -17,4 +17,4 @@ ## 知识库人类视觉渲染层纠正 -冰朔指出首轮只有 Markdown 文本编辑器,不能称为完整知识库模块。现已从只读旧产品供体精确拆入 `knowledge-render` 切片,并在新壳中重新接线:目录、阅读渲染、frontmatter、标签、大纲、wikilink、callout、代码块、表格、来源证据、编辑/阅读切换均可见。验收截图为 `design/concepts/hololake-knowledge-human-render-1536x1024.png`;旧产品其他模块未合并。 +冰朔指出首轮只有 Markdown 文本编辑器,不能称为完整知识库模块;第二次又指出初步渲染仍缺旧版颜色、分类、阅读时间和滚动大纲细节。现已从只读旧产品供体精确拆入 `knowledge-render` 切片,并在新壳中重新接线:文件夹分类树、阅读渲染、frontmatter、稳定标签配色、阅读时间、正文滚动同步高亮大纲、wikilink、callout、代码块、表格、来源证据、编辑/阅读切换均可见。浏览器真实测试在滚动后读回大纲活动项“分类与颜色”;验收截图为 `design/concepts/hololake-knowledge-human-render-1536x1024.png`。旧产品其他模块未合并。 diff --git a/product-source/hololake-clean-desktop/audit/HOLOLAKE-CLEAN-V1-LOCAL-ACCEPTANCE-20260903.json b/product-source/hololake-clean-desktop/audit/HOLOLAKE-CLEAN-V1-LOCAL-ACCEPTANCE-20260903.json index 62d68c098..621575dd0 100644 --- a/product-source/hololake-clean-desktop/audit/HOLOLAKE-CLEAN-V1-LOCAL-ACCEPTANCE-20260903.json +++ b/product-source/hololake-clean-desktop/audit/HOLOLAKE-CLEAN-V1-LOCAL-ACCEPTANCE-20260903.json @@ -18,13 +18,13 @@ "rust_clippy_denied_warnings": "PASS_100", "production_dependency_audit": "0_VULNERABILITIES", "browser_channel_create_message_separation_knowledge_create_edit_save": "PASS_100", - "knowledge_human_visual_rendering_frontmatter_outline_wikilink_callout_table_code_evidence": "PASS_100", + "knowledge_human_visual_rendering_frontmatter_classification_colors_reading_time_scroll_outline_wikilink_callout_table_code_evidence": "PASS_100", "responsive_viewports": ["1536x1024_PASS", "820x900_PASS"], "developer_id_signature": "PASS_100", "installed_application": "/Applications/HoloLake.app", "installed_version": "1.0.0", - "installed_binary_sha256": "50e6206be32ce7c70d42998b6217956aaa99687976e3d9ee383d3671e03c250d", - "updater_archive_sha256": "b126efa723415beacfad916621dbecceff7ea3ff24c79e6adacabd5bb343736a", + "installed_binary_sha256": "b8ba374fd9e97f318b27dd34d71e02d59637b458d5b8fa0a3cc7b649c1ed6636", + "updater_archive_sha256": "bb3405ba849aee9d7dccf71fa4d215fef0bea41dabb910257507c861d24d3b36", "installed_process_readback": "PASS_100", "updater_artifact_signature": "PASS_100", "update_endpoint_http": 204, diff --git a/product-source/hololake-clean-desktop/design/concepts/hololake-knowledge-human-render-1536x1024.png b/product-source/hololake-clean-desktop/design/concepts/hololake-knowledge-human-render-1536x1024.png index 6a9609500..579050bb7 100644 Binary files a/product-source/hololake-clean-desktop/design/concepts/hololake-knowledge-human-render-1536x1024.png and b/product-source/hololake-clean-desktop/design/concepts/hololake-knowledge-human-render-1536x1024.png differ diff --git a/product-source/hololake-clean-desktop/docs/ARCHITECTURE.md b/product-source/hololake-clean-desktop/docs/ARCHITECTURE.md index 565951112..02b68469e 100644 --- a/product-source/hololake-clean-desktop/docs/ARCHITECTURE.md +++ b/product-source/hololake-clean-desktop/docs/ARCHITECTURE.md @@ -18,7 +18,7 @@ HoloLake 是公众个人初始化频道语言驱动操作系统。第一版只 - 对话和执行事实使用不同来源类型,不把系统文字塞进用户或人格消息。 - 公共四域由企业门户和企业服务器承载,不嵌入个人频道。 - 第五域、冰朔私人核 `TCS-iZero∞` 及私人语料不进入公众发行。 -- 当前知识库具备人类视觉阅读层:安全 Markdown 渲染、frontmatter、页面大纲、callout、表格、代码块与 wikilink 跳转;可处理 Markdown 和纯文本文件夹。PDF、Office、网页结构化转换尚未实现。 +- 当前知识库具备完整人类视觉阅读层:安全 Markdown 渲染、frontmatter、文件夹分类树、稳定标签配色、阅读时间、随正文滚动同步高亮的大纲、callout、表格、代码块与 wikilink 跳转;可处理 Markdown 和纯文本文件夹并保留目录分类。PDF、Office、网页结构化转换尚未实现。 - 当前外部 AI 入口是本机来源明确的 inbox/outbox;实时加密通信尚未实现。 - 更新客户端只信任光湖控制的 HTTPS 清单与签名;服务器发行清单尚未发布。 diff --git a/product-source/hololake-clean-desktop/registries/module-registry.json b/product-source/hololake-clean-desktop/registries/module-registry.json index 2e210533e..6edb9d534 100644 --- a/product-source/hololake-clean-desktop/registries/module-registry.json +++ b/product-source/hololake-clean-desktop/registries/module-registry.json @@ -17,7 +17,7 @@ "kind": "PREINSTALLED_USER_MODULE", "state": "LOCAL_INSTALLED_VERIFIED", "source": "src-tauri/src/lib.rs#knowledge", - "capabilities": ["CREATE", "READ", "EDIT", "HUMAN_MARKDOWN_RENDERING", "FRONTMATTER", "OUTLINE", "WIKILINK_NAVIGATION", "IMPORT_FOLDER", "EXPORT", "MOVE_TO_TRASH", "PRIVATE_GIT_COMMIT"] + "capabilities": ["CREATE", "READ", "EDIT", "HUMAN_MARKDOWN_RENDERING", "FRONTMATTER", "FOLDER_CLASSIFICATION_TREE", "STABLE_TAG_COLORS", "READING_TIME", "SCROLL_SYNCHRONIZED_OUTLINE", "WIKILINK_NAVIGATION", "IMPORT_FOLDER", "EXPORT", "MOVE_TO_TRASH", "PRIVATE_GIT_COMMIT"] }, { "module_id": "HLP-MOD-GLP-BRIDGE-0001", diff --git a/product-source/hololake-clean-desktop/src-tauri/src/lib.rs b/product-source/hololake-clean-desktop/src-tauri/src/lib.rs index 366ce4d9c..e552e1acc 100644 --- a/product-source/hololake-clean-desktop/src-tauri/src/lib.rs +++ b/product-source/hololake-clean-desktop/src-tauri/src/lib.rs @@ -200,7 +200,14 @@ fn copy_import(src: &Path, dst: &Path, count: &mut usize) -> Result<(), String> continue; } if p.is_dir() { - copy_import(&p, dst, count)?; + let folder_name = clean_title( + p.file_name() + .and_then(|value| value.to_str()) + .unwrap_or("未命名分类"), + )?; + let child = dst.join(folder_name); + fs::create_dir_all(&child).map_err(|e| e.to_string())?; + copy_import(&p, &child, count)?; continue; } let ext = p @@ -243,7 +250,12 @@ async fn import_knowledge_folder(app: AppHandle) -> Result, _>>() + .unwrap(); + assert_eq!(imported.len(), 1); + assert_eq!( + imported[0] + .path() + .extension() + .and_then(|value| value.to_str()), + Some("md") + ); + } } diff --git a/product-source/hololake-clean-desktop/src/App.tsx b/product-source/hololake-clean-desktop/src/App.tsx index e1b0cfeb1..9843ed211 100644 --- a/product-source/hololake-clean-desktop/src/App.tsx +++ b/product-source/hololake-clean-desktop/src/App.tsx @@ -1,9 +1,14 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { check } from "@tauri-apps/plugin-updater"; import { relaunch } from "@tauri-apps/plugin-process"; import { Icon } from "./icons"; import * as api from "./runtime"; -import type { KnowledgeDocument, SourceKind, SystemSnapshot } from "./types"; +import type { + KnowledgeDocument, + KnowledgeSummary, + SourceKind, + SystemSnapshot, +} from "./types"; import { MarkdownDocument, documentOutline, @@ -304,6 +309,120 @@ function ReceiptStream({ data }: { data: SystemSnapshot }) { ); } +const tagTints = ["tag-sky", "tag-violet", "tag-amber", "tag-mint", "tag-rose"]; +function tagTint(tag: string) { + let hash = 0; + for (const char of tag) hash = (hash * 31 + (char.codePointAt(0) || 0)) % 997; + return tagTints[hash % tagTints.length]; +} + +interface KnowledgeTreeNode { + name: string; + key: string; + folders: Map; + documents: KnowledgeSummary[]; +} +function buildKnowledgeTree(documents: KnowledgeSummary[]) { + const root: KnowledgeTreeNode = { + name: "知识", + key: "", + folders: new Map(), + documents: [], + }; + for (const document of documents) { + const segments = document.path.split("/").filter(Boolean); + segments.pop(); + let cursor = root; + for (const segment of segments) { + const key = cursor.key ? `${cursor.key}/${segment}` : segment; + if (!cursor.folders.has(segment)) + cursor.folders.set(segment, { + name: segment, + key, + folders: new Map(), + documents: [], + }); + cursor = cursor.folders.get(segment)!; + } + cursor.documents.push(document); + } + return root; +} +function treeCount(node: KnowledgeTreeNode): number { + return ( + node.documents.length + + [...node.folders.values()].reduce((sum, child) => sum + treeCount(child), 0) + ); +} +function KnowledgeTree({ + node, + depth, + expanded, + active, + onToggle, + onOpen, +}: { + node: KnowledgeTreeNode; + depth: number; + expanded: Set; + active: string | null; + onToggle: (key: string) => void; + onOpen: (path: string) => void; +}) { + const folders = [...node.folders.values()].sort((a, b) => + a.name.localeCompare(b.name, "zh-CN"), + ); + const documents = [...node.documents].sort((a, b) => + a.title.localeCompare(b.title, "zh-CN"), + ); + return ( + <> + {folders.map((folder) => { + const open = depth === 0 || expanded.has(folder.key); + return ( +
+ + {open && ( + + )} +
+ ); + })} + {documents.map((document) => ( + + ))} + + ); +} + function Knowledge({ data, refresh, @@ -319,13 +438,17 @@ function Knowledge({ [query, setQuery] = useState(""), [newTitle, setNewTitle] = useState(null), [editing, setEditing] = useState(false), - [inspectorOpen, setInspectorOpen] = useState(true); + [inspectorOpen, setInspectorOpen] = useState(true), + [expanded, setExpanded] = useState>(new Set(["导入"])), + [activeHeadingId, setActiveHeadingId] = useState(null); + const headingScrollTimer = useRef(null); useEffect(() => { if (selected) api.readDocument(selected).then((d) => { setDoc(d); setBody(d.body); setEditing(false); + setActiveHeadingId(null); }); }, [selected]); const list = useMemo( @@ -335,6 +458,7 @@ function Knowledge({ ), [data.documents, query], ); + const tree = useMemo(() => buildKnowledgeTree(list), [list]); const create = async () => { const title = newTitle?.trim(); if (title) { @@ -364,8 +488,33 @@ function Knowledge({ 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)) }; + return { chars, minutes: Math.max(1, Math.ceil(chars / 400)) }; }, [parsed.content]); + useEffect(() => { + if (!activeHeadingId) return; + document + .querySelector(".outline-list button.active") + ?.scrollIntoView({ block: "nearest" }); + }, [activeHeadingId]); + const handleReaderScroll = (event: React.UIEvent) => { + const container = event.currentTarget; + if (headingScrollTimer.current !== null) return; + headingScrollTimer.current = window.setTimeout(() => { + headingScrollTimer.current = null; + const containerTop = container.getBoundingClientRect().top; + let current: string | null = null; + for (const heading of Array.from( + container.querySelectorAll( + "h1[id], h2[id], h3[id], h4[id]", + ), + )) { + if (heading.getBoundingClientRect().top - containerTop <= 28) + current = heading.id; + else break; + } + setActiveHeadingId(current); + }, 100); + }; const openWiki = (target: string) => { const normalized = target.replace(/[\s·•・\-_|*`]/g, "").toLowerCase(); const hit = data.documents.find( @@ -410,18 +559,23 @@ function Knowledge({ {list.length === 0 ? (

暂无知识页

) : ( - list.map((d) => ( - - )) +
+ + setExpanded((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }) + } + onOpen={setSelected} + /> +
)}
@@ -465,7 +619,7 @@ function Knowledge({ onChange={(e) => setBody(e.target.value)} /> ) : ( -
+

{doc.title}

@@ -475,10 +629,23 @@ function Knowledge({ {parsed.tags.length > 0 && (

{parsed.tags.map((tag) => ( - {tag} + + {tag} + ))}
)} +
+ {Object.entries(parsed.metadata) + .filter(([key]) => key !== "tags") + .slice(0, 6) + .map(([key, value]) => ( + + {key} + {value} + + ))} +
( diff --git a/product-source/hololake-clean-desktop/src/icons.tsx b/product-source/hololake-clean-desktop/src/icons.tsx index a0f7a4954..27160a510 100644 --- a/product-source/hololake-clean-desktop/src/icons.tsx +++ b/product-source/hololake-clean-desktop/src/icons.tsx @@ -1,12 +1,63 @@ import type { SVGProps } from "react"; -export function Icon({name,...props}:{name:string}&SVGProps){ - const paths:Record={ - channel:<>, - book:<>, - cube:<>, - history:<>, - building:<>, - settings:<> - }; - return +export function Icon({ + name, + ...props +}: { name: string } & SVGProps) { + const paths: Record = { + channel: ( + <> + + + + ), + book: ( + <> + + + ), + cube: ( + <> + + + + ), + history: ( + <> + + + + ), + building: ( + <> + + + ), + settings: ( + <> + + + + ), + folder: , + file: ( + <> + + + + ), + }; + return ( + + ); } diff --git a/product-source/hololake-clean-desktop/src/styles.css b/product-source/hololake-clean-desktop/src/styles.css index b9f1aadca..7d910a19b 100644 --- a/product-source/hololake-clean-desktop/src/styles.css +++ b/product-source/hololake-clean-desktop/src/styles.css @@ -485,6 +485,71 @@ main { font-size: 10px; color: #6f879e; } +.knowledge-tree { + padding: 9px 0 18px; +} +.tree-branch { + position: relative; +} +.tree-folder, +.tree-document { + width: 100%; + min-height: 38px; + display: grid; + align-items: center; + gap: 7px; + border: 0 !important; + border-radius: 8px !important; + background: transparent !important; + color: #8fa5ba; + text-align: left; +} +.tree-folder { + grid-template-columns: 13px 18px minmax(0, 1fr) auto; + font-size: 13px; + font-weight: 590; +} +.tree-document { + grid-template-columns: 18px minmax(0, 1fr); + font-size: 12.5px; + font-weight: 480; +} +.tree-folder svg, +.tree-document svg { + width: 16px; + height: 16px; + color: #78bce6; +} +.tree-folder span, +.tree-document span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tree-folder em { + padding-right: 7px; + color: #617a91; + font-size: 10px; + font-style: normal; +} +.tree-folder:hover, +.tree-document:hover, +.tree-document.active { + color: #eef6ff; + background: rgba(78, 144, 194, 0.12) !important; +} +.tree-document.active { + box-shadow: inset 2px 0 #74bee9; +} +.tree-chevron { + display: grid; + place-items: center; + font-size: 18px; + transition: transform 0.14s ease; +} +.tree-chevron.open { + transform: rotate(90deg); +} .knowledge-workspace > aside { border-right: 1px solid var(--line); padding: 13px; @@ -593,6 +658,45 @@ main { background: rgba(71, 139, 188, 0.14); font-size: 10px; } +.meta-tags .tag-sky { + color: #a9dcff; + background: rgba(57, 145, 205, 0.18); +} +.meta-tags .tag-violet { + color: #d3b8ff; + background: rgba(128, 79, 190, 0.2); +} +.meta-tags .tag-amber { + color: #f6d28a; + background: rgba(188, 130, 35, 0.2); +} +.meta-tags .tag-mint { + color: #9fe2ba; + background: rgba(48, 148, 91, 0.2); +} +.meta-tags .tag-rose { + color: #ffb2ba; + background: rgba(185, 65, 81, 0.2); +} +.meta-properties { + display: flex; + flex-wrap: wrap; + gap: 7px; + margin-top: 10px; +} +.meta-properties span { + display: inline-flex; + gap: 6px; + padding: 5px 8px; + border-radius: 6px; + color: #93a9bd; + background: rgba(255, 255, 255, 0.035); + font-size: 10px; +} +.meta-properties b { + color: #c5d4e1; + font-weight: 620; +} .markdown-document { color: #cbd8e6; font-size: 15px; @@ -726,6 +830,11 @@ main { line-height: 1.45; padding-block: 7px; } +.outline-list button.active { + color: #f5d791; + background: rgba(198, 146, 49, 0.12); + box-shadow: inset 2px 0 var(--gold); +} .outline-list p { color: #72899f; font-size: 11px; diff --git a/routing/hololake-current-architecture.json b/routing/hololake-current-architecture.json index 330c5c9ee..2d8b01863 100644 --- a/routing/hololake-current-architecture.json +++ b/routing/hololake-current-architecture.json @@ -10,7 +10,7 @@ "canonical_tcs": "product-source/hololake-clean-desktop/language/HOLOLAKE-CLEAN-V1-FOUNDATION.tcs", "old_product_role": "READ_ONLY_DONOR_NO_WHOLE_MERGE", "first_preinstalled_module": "HLP-MOD-KB-0001", - "knowledge_module_state": "LOCAL_COMPLETE_HUMAN_VISUAL_RENDERING_CREATE_READ_EDIT_IMPORT_EXPORT_TRASH_PRIVATE_GIT", + "knowledge_module_state": "LOCAL_COMPLETE_HUMAN_VISUAL_RENDERING_WITH_CLASSIFICATION_COLORS_READING_TIME_SCROLL_OUTLINE_CREATE_READ_EDIT_IMPORT_EXPORT_TRASH_PRIVATE_GIT", "knowledge_human_rendering_source": "product-source/hololake-clean-desktop/src/modules/knowledge-render/index.tsx", "typed_language_sources": ["USER_MESSAGE", "PERSONA_RESPONSE", "SYSTEM_CONTEXT", "PROTOCOL_EVENT", "AGENT_ACTION", "TOOL_RESULT", "SYSTEM_RECEIPT"], "public_update_endpoint": "https://guanghulab.com/hololake/releases/latest.json",