fix(hololake): ship dynamic persona routes and coherent workspace

This commit is contained in:
冰朔 2026-08-09 10:43:56 +08:00
commit 787ba2ad3f
21 changed files with 1111 additions and 326 deletions

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
import { useCallback, useEffect, useMemo, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react';
import { api, DocTreeNode, DocContent } from './api';
import { DocTree } from './components/DocTree';
import { Editor } from './components/Editor';
@ -80,6 +80,8 @@ export default function App() {
const [humanSettingsOpen, setHumanSettingsOpen] = useState(false);
const [humanPreferences, setHumanPreferences] = useState<HumanPreferences>(() => loadHumanPreferences());
const [agentRevision, setAgentRevision] = useState(0);
const [treeWidth, setTreeWidth] = useState(() => Number(localStorage.getItem('hololake.layout.tree-width')) || 258);
const [agentWidth, setAgentWidth] = useState(() => Number(localStorage.getItem('hololake.layout.agent-width')) || 460);
const storageMode = repositoryStatus?.remote ? 'server' : 'local';
const storageLabel = storageMode === 'server' ? '服务器已托管' : '仅本机';
@ -113,6 +115,23 @@ export default function App() {
}
}, []);
const openWikiTarget = useCallback(async (rawTarget: string) => {
const target = rawTarget.trim().replace(/^\/+/, '');
const normalized = target.toLocaleLowerCase().replace(/\.md$/u, '');
const flatten = (nodes: DocTreeNode[]): DocTreeNode[] => nodes.flatMap(node => [node, ...(node.children ? flatten(node.children) : [])]);
const match = flatten(tree).find(node => {
if (node.type !== 'document') return false;
const path = node.path.toLocaleLowerCase().replace(/\.md$/u, '');
const name = node.name.toLocaleLowerCase().replace(/\.md$/u, '');
return path === normalized || name === normalized || path.endsWith(`/${normalized}`);
});
if (match) {
await openDoc(match.path);
return;
}
setError(`没有找到知识页面:${target}`);
}, [openDoc, tree]);
const refreshRepositoryStatus = useCallback(async () => {
try {
const response = await fetch(`${agentApiBase}/api/forgejo/status`);
@ -152,6 +171,10 @@ export default function App() {
document.documentElement.lang = resolvedLanguage;
}, [humanPreferences.language]);
useEffect(() => {
localStorage.setItem('hololake.human-preferences.v1', JSON.stringify(humanPreferences));
}, [humanPreferences]);
useEffect(() => {
if (!treeLoaded || currentPath || loading) return;
const firstDocument = findFirstDocument(tree);
@ -254,6 +277,30 @@ export default function App() {
const channelTitle = personalServer?.channelTitle || '我的第五域';
const channelSubtitle = personalServer?.channelSubtitle || '当前频道';
const startResize = useCallback((kind: 'tree' | 'agent', event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault();
const startX = event.clientX;
const startWidth = kind === 'tree' ? treeWidth : agentWidth;
document.body.classList.add('is-resizing');
const move = (moveEvent: PointerEvent) => {
if (kind === 'tree') {
setTreeWidth(Math.max(210, Math.min(420, startWidth + moveEvent.clientX - startX)));
} else {
setAgentWidth(Math.max(360, Math.min(Math.max(360, window.innerWidth * 0.52), startWidth - moveEvent.clientX + startX)));
}
};
const stop = () => {
document.body.classList.remove('is-resizing');
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', stop);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', stop, { once: true });
}, [treeWidth, agentWidth]);
useEffect(() => { localStorage.setItem('hololake.layout.tree-width', String(Math.round(treeWidth))); }, [treeWidth]);
useEffect(() => { localStorage.setItem('hololake.layout.agent-width', String(Math.round(agentWidth))); }, [agentWidth]);
return (
<div
className="hololake-shell"
@ -265,6 +312,9 @@ export default function App() {
<div className="topbar-spacer" />
<div className="topbar-channel">{channelTitle} · {channelSubtitle}</div>
<div className="topbar-search"><SearchBar onSelect={openDoc} /></div>
<button className="theme-quick-toggle" type="button" onClick={() => setHumanPreferences(current => ({ ...current, appearance: current.appearance === 'mist-light' ? 'lake-night' : 'mist-light' }))}>
{humanPreferences.appearance === 'mist-light' ? '雾白' : '湖夜'}
</button>
<button className={`fifth-domain-session ${serverSession.authenticated ? 'authenticated' : ''}`} type="button" onClick={() => {
setStorageSheetInitialMode('server');
setStorageSheetOpen(true);
@ -282,6 +332,7 @@ export default function App() {
knowledgeSelected={activeRoute === 'fifth' && activeModule === 'knowledge'}
onRouteSelect={route => {
setActiveRoute(route);
setActiveModule('knowledge');
setModuleCollapsed(false);
}}
onKnowledgeSelect={() => {
@ -312,7 +363,7 @@ export default function App() {
) : (
<DomainSurface
activeDomain={(activeRoute === 'fifth' ? 'sub' : activeRoute) as 'main' | 'sub' | 'zero' | 'zero-sense'}
educationSelected={activeModule === 'education'}
educationSelected={activeRoute === 'sub' && activeModule === 'education'}
onOpenSettings={() => setHumanSettingsOpen(true)}
/>
)
@ -345,7 +396,10 @@ export default function App() {
</div>
</div>
<div className="knowledge-layout">
<div
className={`knowledge-workbench ${sidebarOpen ? 'has-tree' : ''} ${agentPanelOpen ? 'has-agent' : ''}`}
style={{ '--tree-width': `${treeWidth}px`, '--agent-width': `${agentWidth}px` } as CSSProperties}
>
{sidebarOpen && (
<aside className="knowledge-sidebar">
<div className="knowledge-sidebar-heading"><span></span><button onClick={() => createDoc('')} aria-label="新建页面"></button></div>
@ -360,6 +414,8 @@ export default function App() {
</aside>
)}
{sidebarOpen && <button type="button" className="workspace-resizer tree-resizer" onPointerDown={event => startResize('tree', event)} aria-label="调整知识树宽度" />}
<main className="knowledge-content">
{error && <div className="kb-error">{error}<button onClick={() => setError(null)}>×</button></div>}
{loading && <div className="kb-loading"></div>}
@ -379,26 +435,31 @@ export default function App() {
{!currentDoc && emptyDismissed && !loading && (
<div className="quiet-empty"><h2></h2><p>使</p><button className="secondary-button" onClick={() => setEmptyDismissed(false)}></button></div>
)}
{currentDoc && view === 'editor' && <Editor doc={currentDoc} onSave={saveDoc} />}
{currentDoc && view === 'editor' && <Editor doc={currentDoc} onSave={saveDoc} onWikiSelect={openWikiTarget} />}
{currentDoc && view === 'history' && <VersionHistory docPath={currentPath} />}
{currentDoc && <div className="document-path">{breadcrumb}</div>}
</main>
{agentPanelOpen && <button type="button" className="workspace-resizer agent-resizer" onPointerDown={event => startResize('agent', event)} aria-label="调整 HoloLake 宽度" />}
{agentPanelOpen && (
<aside className="agent-drawer">
<div className="agent-drawer-scope"><span></span><strong> · {currentTitle}</strong></div>
<AgentChat apiBase={agentApiBase} runtimeRevision={agentRevision} onClose={() => setAgentPanelOpen(false)} />
</aside>
)}
</div>
</>
)}
{contentVisible && (
<>
{agentPanelOpen && (
<aside className="agent-drawer">
<div className="agent-drawer-scope"><span></span><strong> · {currentTitle}</strong></div>
<AgentChat apiBase={agentApiBase} onDocSelect={openDoc} runtimeRevision={agentRevision} />
</aside>
)}
<button className={`agent-launcher ${agentPanelOpen ? 'open' : ''}`} onClick={() => setAgentPanelOpen(!agentPanelOpen)}>
{!agentPanelOpen && <button className="agent-launcher" onClick={() => {
if (window.innerWidth < 1500) setSidebarOpen(false);
setAgentPanelOpen(true);
}}>
<span className="launcher-orbit" aria-hidden="true" />
{agentPanelOpen ? '收起 HoloLake' : '询问 HoloLake'}
</button>
HoloLake
</button>}
</>
)}
</section>