feat(hololake): ship language platform 0.7.0

This commit is contained in:
冰朔 2026-08-09 03:37:41 +08:00
commit 5105ec5e32
47 changed files with 4566 additions and 398 deletions

View file

@ -1,34 +1,88 @@
import { useState, useEffect, useCallback } from 'react';
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
import { api, DocTreeNode, DocContent } from './api';
import { DocTree } from './components/DocTree';
import { Editor } from './components/Editor';
import { SearchBar } from './components/SearchBar';
import { VersionHistory } from './components/VersionHistory';
import AgentChat from './components/AgentChat';
import { PlatformNavigation } from './components/PlatformNavigation';
import { StorageLocationSheet } from './components/StorageLocationSheet';
import { HumanSettings, HumanPreferences, loadHumanPreferences } from './components/HumanSettings';
import { DomainSurface } from './components/DomainSurface';
import { cleanDisplayText } from './presentation';
type View = 'editor' | 'history';
type RouteId = 'fifth' | 'main' | 'sub' | 'zero' | 'zero-sense';
type ModuleId = 'knowledge' | 'education';
interface RepositoryStatus {
branch: string;
head: string;
clean: boolean;
ahead: number;
behind: number;
remote: { name: string; url: string } | null;
}
interface ServerSession {
authenticated: boolean;
nodeId: string;
username?: string;
}
interface ServerProfile {
id: string;
name: string;
purpose: 'personal-fifth-domain' | 'enterprise-lighthouse';
channelTitle?: string;
channelSubtitle?: string;
}
function findFirstDocument(nodes: DocTreeNode[]): string | null {
for (const node of nodes) {
if (node.type === 'document') return node.path;
const child = findFirstDocument(node.children || []);
if (child) return child;
}
return null;
}
const routeCopy: Record<RouteId, { title: string; body: string }> = {
fifth: { title: '我的第五域', body: '当前账号的个人第五域频道。模块在这里按需挂载、收起和组合。' },
main: { title: '光湖主域', body: '公共产品事实、发布与公告入口。当前客户端尚未取得该域的操作权限。' },
sub: { title: '光湖分域', body: '行业入口与初始化频道目录。教育行业与网文行业从这里进入。' },
zero: { title: '光湖零域', body: '实验、模块试装、对比与质量验证入口。' },
'zero-sense': { title: '光湖零感域', body: '治理、部署审批、事故、回滚与审计入口。' },
};
export default function App() {
const agentApiBase = window.location.protocol === 'file:' ? 'http://127.0.0.1:3890' : '';
const [tree, setTree] = useState<DocTreeNode[]>([]);
const [treeLoaded, setTreeLoaded] = useState(false);
const [currentDoc, setCurrentDoc] = useState<DocContent | null>(null);
const [currentPath, setCurrentPath] = useState<string>('');
const [currentPath, setCurrentPath] = useState('');
const [view, setView] = useState<View>('editor');
const [sidebarOpen, setSidebarOpen] = useState(true);
const [moduleCollapsed, setModuleCollapsed] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [agentPanelOpen, setAgentPanelOpen] = useState(true);
const [agentPanelOpen, setAgentPanelOpen] = useState(false);
const [importing, setImporting] = useState(false);
const [importMessage, setImportMessage] = useState<string | null>(null);
const [emptyDismissed, setEmptyDismissed] = useState(false);
const [activeRoute, setActiveRoute] = useState<RouteId>('fifth');
const [activeModule, setActiveModule] = useState<ModuleId>('knowledge');
const [storageSheetOpen, setStorageSheetOpen] = useState(false);
const [storageSheetInitialMode, setStorageSheetInitialMode] = useState<'local' | 'server' | undefined>();
const [serverSession, setServerSession] = useState<ServerSession>({ authenticated: false, nodeId: '' });
const [serverProfiles, setServerProfiles] = useState<ServerProfile[]>([]);
const [repositoryStatus, setRepositoryStatus] = useState<RepositoryStatus | null>(null);
const [humanSettingsOpen, setHumanSettingsOpen] = useState(false);
const [humanPreferences, setHumanPreferences] = useState<HumanPreferences>(() => loadHumanPreferences());
const [agentRevision, setAgentRevision] = useState(0);
const refreshTree = useCallback(async () => {
try {
const t = await api.getTree();
setTree(t);
} catch (err: any) {
setError(`加载文档树失败: ${err.message}`);
}
}, []);
const storageMode = repositoryStatus?.remote ? 'server' : 'local';
const storageLabel = storageMode === 'server' ? '服务器已托管' : '仅本机';
const openDoc = useCallback(async (docPath: string) => {
setLoading(true);
@ -38,6 +92,9 @@ export default function App() {
setCurrentDoc(doc);
setCurrentPath(docPath);
setView('editor');
setActiveRoute('fifth');
setActiveModule('knowledge');
setModuleCollapsed(false);
} catch (err: any) {
setError(`加载文档失败: ${err.message}`);
} finally {
@ -45,6 +102,62 @@ export default function App() {
}
}, []);
const refreshTree = useCallback(async () => {
try {
const nextTree = await api.getTree();
setTree(nextTree);
setTreeLoaded(true);
} catch (err: any) {
setError(`加载文档树失败: ${err.message}`);
setTreeLoaded(true);
}
}, []);
const refreshRepositoryStatus = useCallback(async () => {
try {
const response = await fetch(`${agentApiBase}/api/forgejo/status`);
const data = await response.json();
if (data.ok) setRepositoryStatus(data.status);
} catch {
setRepositoryStatus(null);
}
}, [agentApiBase]);
const refreshServerSession = useCallback(async () => {
const server = (window as any).hololake?.server;
if (!server?.session) return;
try {
const profiles = await server.list() as ServerProfile[];
setServerProfiles(profiles);
const personal = profiles.find(profile => profile.purpose === 'personal-fifth-domain');
if (personal) {
try { await server.connect(personal.id); } catch { /* 会话状态继续按真实结果显示 */ }
}
setServerSession(await server.session(personal?.id));
} catch {
setServerSession({ authenticated: false, nodeId: '' });
}
}, []);
useEffect(() => {
refreshTree();
refreshRepositoryStatus();
refreshServerSession();
}, [refreshTree, refreshRepositoryStatus, refreshServerSession]);
useEffect(() => {
const resolvedLanguage = humanPreferences.language === 'system'
? navigator.language
: humanPreferences.language;
document.documentElement.lang = resolvedLanguage;
}, [humanPreferences.language]);
useEffect(() => {
if (!treeLoaded || currentPath || loading) return;
const firstDocument = findFirstDocument(tree);
if (firstDocument) openDoc(firstDocument);
}, [tree, treeLoaded, currentPath, loading, openDoc]);
const saveDoc = useCallback(async (title: string, body: string) => {
if (!currentPath) return;
setLoading(true);
@ -68,6 +181,7 @@ export default function App() {
const doc = await api.createDoc(docPath, title, `# ${title}\n\n在这里开始写作...\n`);
setCurrentDoc(doc);
setCurrentPath(docPath);
setEmptyDismissed(false);
await refreshTree();
} catch (err: any) {
setError(`创建失败: ${err.message}`);
@ -75,8 +189,7 @@ export default function App() {
}, [refreshTree]);
const deleteDoc = useCallback(async () => {
if (!currentPath) return;
if (!confirm(`确认删除 ${currentPath}`)) return;
if (!currentPath || !confirm(`确认删除 ${currentPath}`)) return;
try {
await api.deleteDoc(currentPath);
setCurrentDoc(null);
@ -88,8 +201,8 @@ export default function App() {
}, [currentPath, refreshTree]);
const importFolder = useCallback(async () => {
const bridge = (window as any).hololake?.knowledge;
if (!bridge?.importFolder) {
const knowledge = (window as any).hololake?.knowledge;
if (!knowledge?.importFolder) {
setError('本地文件夹导入只在 HoloLake 桌面 App 中提供');
return;
}
@ -97,21 +210,24 @@ export default function App() {
setError(null);
setImportMessage(null);
try {
const result = await bridge.importFolder();
if (result.cancelled) return;
const result = await knowledge.importFolder();
if (result.cancelled) {
setImportMessage('已取消导入,现有知识库没有变化');
return;
}
if (!result.imported) {
setImportMessage(`没有找到可导入的文档;已跳过 ${result.skipped || 0} 个文件`);
return;
}
await refreshTree();
if (result.firstDocument) await openDoc(result.firstDocument);
const details = [
setEmptyDismissed(false);
setImportMessage([
`已导入 ${result.imported} 篇文档`,
result.assets ? `${result.assets} 个图片资源` : '',
result.skipped ? `跳过 ${result.skipped} 个暂不支持的文件` : '',
result.failed?.length ? `${result.failed.length} 个文件失败` : '',
].filter(Boolean).join(' · ');
setImportMessage(details);
].filter(Boolean).join(' · '));
} catch (err: any) {
setError(`导入失败: ${err.message}`);
} finally {
@ -119,125 +235,198 @@ export default function App() {
}
}, [openDoc, refreshTree]);
useEffect(() => {
refreshTree();
}, [refreshTree]);
const activeRouteCopy = routeCopy[activeRoute];
const currentTitle = cleanDisplayText(currentDoc?.meta.title || '知识库');
const contentVisible = activeRoute === 'fifth' && activeModule === 'knowledge' && !moduleCollapsed;
const breadcrumb = useMemo(
() => currentPath ? currentPath.split('/').map(cleanDisplayText).join(' / ') : '知识库',
[currentPath],
);
const language = humanPreferences.language === 'system'
? (navigator.language.toLowerCase().startsWith('zh') ? 'zh-CN' : 'en')
: humanPreferences.language;
const fontFamily = humanPreferences.font === 'serif'
? 'ui-serif, "Songti SC", Georgia, serif'
: humanPreferences.font === 'accessible'
? 'Arial, "PingFang SC", sans-serif'
: 'Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif';
const personalServer = serverProfiles.find(profile => profile.purpose === 'personal-fifth-domain');
const channelTitle = personalServer?.channelTitle || '我的第五域';
const channelSubtitle = personalServer?.channelSubtitle || '当前频道';
return (
<div className="kb-app">
{/* 顶栏 */}
<header className="kb-header">
<div className="kb-header-left">
<button
className="kb-btn-icon"
onClick={() => setSidebarOpen(!sidebarOpen)}
title={sidebarOpen ? '收起侧栏' : '展开侧栏'}
>
{sidebarOpen ? '◀' : '▶'}
</button>
<div className="kb-brand-mark" aria-hidden="true" />
<div>
<h1 className="kb-title">HoloLake</h1>
<div className="kb-subtitle"></div>
</div>
</div>
<div className="kb-header-center">
<SearchBar onSelect={openDoc} />
</div>
<div className="kb-header-right">
{currentDoc && (
<div
className="hololake-shell"
data-language={language}
data-appearance={humanPreferences.appearance}
style={{ '--human-font': fontFamily, '--reading-size': `${humanPreferences.readingSize}px` } as CSSProperties}
>
<header className="platform-topbar">
<div className="topbar-spacer" />
<div className="topbar-channel">{channelTitle} · {channelSubtitle}</div>
<div className="topbar-search"><SearchBar onSelect={openDoc} /></div>
<button className={`fifth-domain-session ${serverSession.authenticated ? 'authenticated' : ''}`} type="button" onClick={() => {
setStorageSheetInitialMode('server');
setStorageSheetOpen(true);
}}>
<span className="session-status-dot" aria-hidden="true" />
<span><strong>{serverSession.authenticated ? serverSession.username : (personalServer ? '登录第五域' : '配置我的服务器')}</strong><small>{personalServer?.id || '本机私有配置'}</small></span>
</button>
</header>
<div className="platform-body">
<PlatformNavigation
channelTitle={channelTitle}
channelSubtitle={channelSubtitle}
activeRoute={activeRoute}
knowledgeSelected={activeRoute === 'fifth' && activeModule === 'knowledge'}
onRouteSelect={route => {
setActiveRoute(route);
setModuleCollapsed(false);
}}
onKnowledgeSelect={() => {
setActiveRoute('fifth');
setActiveModule('knowledge');
setModuleCollapsed(false);
}}
onEducationSelect={() => {
setActiveRoute('sub');
setActiveModule('education');
setModuleCollapsed(false);
}}
onSettingsOpen={() => setHumanSettingsOpen(true)}
onAccountOpen={() => {
setStorageSheetInitialMode('server');
setStorageSheetOpen(true);
}}
/>
<section className="platform-workspace">
{!contentVisible ? (
moduleCollapsed && activeRoute === 'fifth' ? (
<div className="route-surface">
<h1></h1>
<p></p>
<button className="primary-button" onClick={() => setModuleCollapsed(false)}></button>
</div>
) : (
<DomainSurface
activeDomain={(activeRoute === 'fifth' ? 'sub' : activeRoute) as 'main' | 'sub' | 'zero' | 'zero-sense'}
educationSelected={activeModule === 'education'}
onOpenSettings={() => setHumanSettingsOpen(true)}
/>
)
) : (
<>
<button
className={`kb-btn-tab ${view === 'editor' ? 'active' : ''}`}
onClick={() => setView('editor')}
>
</button>
<button
className={`kb-btn-tab ${view === 'history' ? 'active' : ''}`}
onClick={() => setView('history')}
>
</button>
<button className="kb-btn-icon kb-btn-danger" onClick={deleteDoc} title="删除">
🗑
<div className="knowledge-tabbar">
<button className="sidebar-toggle" onClick={() => setSidebarOpen(!sidebarOpen)} aria-label={sidebarOpen ? '收起页面栏' : '展开页面栏'}>
<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2" /><path d="M9 4v16" /></svg>
</button>
<div className="document-tab"><span className="document-tab-icon" />{currentTitle}<button aria-label="关闭当前页面">×</button></div>
<button className="new-tab-button" onClick={() => createDoc('')} aria-label="新建页面"></button>
</div>
<div className="knowledge-module-bar">
<button className="storage-state-button" onClick={() => {
setStorageSheetInitialMode(undefined);
setStorageSheetOpen(true);
}}>
<svg viewBox="0 0 24 24" aria-hidden="true"><ellipse cx="12" cy="5.5" rx="7.5" ry="3" /><path d="M4.5 5.5v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6M4.5 11.5v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6" /></svg>
<span>{storageLabel}</span>
<strong>{storageMode === 'local' ? '托管到我的服务器' : repositoryStatus?.remote?.url.split('/').slice(-2).join(' / ').replace(/\.git$/, '')}</strong>
</button>
<div className="knowledge-module-actions">
{currentDoc && <>
<button className={view === 'editor' ? 'selected' : ''} onClick={() => setView('editor')}></button>
<button className={view === 'history' ? 'selected' : ''} onClick={() => setView('history')}></button>
<button className="danger-action" onClick={deleteDoc} aria-label="删除当前页面"></button>
</>}
<button onClick={() => setModuleCollapsed(true)}></button>
</div>
</div>
<div className="knowledge-layout">
{sidebarOpen && (
<aside className="knowledge-sidebar">
<div className="knowledge-sidebar-heading"><span></span><button onClick={() => createDoc('')} aria-label="新建页面"></button></div>
<div className="knowledge-sidebar-actions">
<button className="import-button" onClick={importFolder} disabled={importing}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 16V4m0 0L7.5 8.5M12 4l4.5 4.5M5 14v5h14v-5" /></svg>
{importing ? '正在导入…' : '导入本地文件夹'}
</button>
</div>
{importMessage && <div className="import-message">{importMessage}<button onClick={() => setImportMessage(null)}>×</button></div>}
<DocTree nodes={tree} currentPath={currentPath} onSelect={openDoc} onCreate={createDoc} />
</aside>
)}
<main className="knowledge-content">
{error && <div className="kb-error">{error}<button onClick={() => setError(null)}>×</button></div>}
{loading && <div className="kb-loading"></div>}
{!currentDoc && treeLoaded && !loading && !emptyDismissed && (
<div className="knowledge-empty">
<button className="empty-close" onClick={() => setEmptyDismissed(true)} aria-label="关闭导入引导">×</button>
<div className="route-orbit" aria-hidden="true"><span /></div>
<h2></h2>
<p></p>
<div className="empty-actions">
<button className="primary-button" onClick={importFolder} disabled={importing}>{importing ? '正在导入…' : '选择本地文件夹'}</button>
<button className="secondary-button" onClick={() => createDoc('')}></button>
</div>
<small> MarkdownTXTCSVJSON YAML</small>
</div>
)}
{!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 === 'history' && <VersionHistory docPath={currentPath} />}
{currentDoc && <div className="document-path">{breadcrumb}</div>}
</main>
</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)}>
<span className="launcher-orbit" aria-hidden="true" />
{agentPanelOpen ? '收起 HoloLake' : '询问 HoloLake'}
</button>
</>
)}
<button
className={`kb-btn-tab kb-btn-agent ${agentPanelOpen ? 'active' : ''}`}
onClick={() => setAgentPanelOpen(!agentPanelOpen)}
title={agentPanelOpen ? '收起 Agent' : '展开 Agent'}
>
HoloLake
</button>
</div>
</header>
{/* 主体 */}
<div className="kb-body">
{/* 侧栏 */}
{sidebarOpen && (
<aside className="kb-sidebar">
<div className="kb-workspace-label"></div>
<div className="kb-sidebar-actions">
<button className="kb-btn-import" onClick={importFolder} disabled={importing}>
<span className="kb-btn-import-icon"></span>
{importing ? '正在导入…' : '导入本地文件夹'}
</button>
<button className="kb-btn-new" onClick={() => createDoc('')}>
<span></span>
</button>
</div>
{importMessage && <div className="kb-import-message">{importMessage}</div>}
<div className="kb-tree-heading"></div>
<DocTree
nodes={tree}
currentPath={currentPath}
onSelect={openDoc}
onCreate={createDoc}
/>
</aside>
)}
{/* 内容区 */}
<main className="kb-content">
{error && (
<div className="kb-error">
{error}
<button onClick={() => setError(null)}></button>
</div>
)}
{loading && <div className="kb-loading">...</div>}
{!currentDoc && !loading && (
<div className="kb-empty">
<div className="kb-empty-card">
<div className="kb-empty-orbit"><span /></div>
<p className="kb-empty-eyebrow">HOLOLAKE KNOWLEDGE</p>
<h2></h2>
<p>HoloLake Git </p>
<button className="kb-empty-primary" onClick={importFolder} disabled={importing}>
{importing ? '正在导入…' : '选择本地文件夹'}
</button>
<div className="kb-empty-support"> MarkdownTXTCSVJSON YAML</div>
</div>
</div>
)}
{currentDoc && view === 'editor' && (
<Editor doc={currentDoc} onSave={saveDoc} />
)}
{currentDoc && view === 'history' && (
<VersionHistory docPath={currentPath} />
)}
</main>
{/* Agent 面板 */}
{agentPanelOpen && (
<aside className="kb-agent-panel">
<AgentChat apiBase={agentApiBase} onDocSelect={openDoc} />
</aside>
)}
</section>
</div>
<StorageLocationSheet
open={storageSheetOpen}
apiBase={agentApiBase}
currentRemote={repositoryStatus?.remote?.url}
initialMode={storageSheetInitialMode}
onClose={() => setStorageSheetOpen(false)}
onApplied={() => {
refreshRepositoryStatus();
refreshServerSession();
}}
/>
<HumanSettings
open={humanSettingsOpen}
preferences={humanPreferences}
onClose={() => setHumanSettingsOpen(false)}
onChange={setHumanPreferences}
onAgentChanged={() => setAgentRevision(revision => revision + 1)}
onManageServer={() => {
setHumanSettingsOpen(false);
setStorageSheetInitialMode('server');
setStorageSheetOpen(true);
}}
/>
</div>
);
}