feat: add HoloLake personal channel module runtime

This commit is contained in:
冰朔 2026-08-09 14:14:07 +08:00
commit a106b23f9b
21 changed files with 786 additions and 31 deletions

View file

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react';
import { api, DocTreeNode, DocContent } from './api';
import { api, DocTreeNode, DocContent, type ChannelState, type ModuleManifest } from './api';
import { DocTree } from './components/DocTree';
import { Editor } from './components/Editor';
import { SearchBar } from './components/SearchBar';
@ -9,6 +9,7 @@ import { PlatformNavigation } from './components/PlatformNavigation';
import { StorageLocationSheet } from './components/StorageLocationSheet';
import { HumanSettings, HumanPreferences, loadHumanPreferences } from './components/HumanSettings';
import { DomainSurface } from './components/DomainSurface';
import { ModuleLibrarySheet } from './components/ModuleLibrarySheet';
import { cleanDisplayText } from './presentation';
type View = 'editor' | 'history';
@ -63,7 +64,6 @@ export default function App() {
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(false);
@ -82,6 +82,12 @@ export default function App() {
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 [channelState, setChannelState] = useState<ChannelState | null>(null);
const [moduleRegistry, setModuleRegistry] = useState<ModuleManifest[]>([]);
const [moduleLibraryOpen, setModuleLibraryOpen] = useState(false);
const [moduleBusy, setModuleBusy] = useState(false);
const [moduleMessage, setModuleMessage] = useState('');
const [lastChannelReceipt, setLastChannelReceipt] = useState('');
const storageMode = repositoryStatus?.remote ? 'server' : 'local';
const storageLabel = storageMode === 'server' ? '服务器已托管' : '仅本机';
@ -96,7 +102,6 @@ export default function App() {
setView('editor');
setActiveRoute('fifth');
setActiveModule('knowledge');
setModuleCollapsed(false);
} catch (err: any) {
setError(`加载文档失败: ${err.message}`);
} finally {
@ -158,11 +163,53 @@ export default function App() {
}
}, []);
const refreshChannel = useCallback(async () => {
try {
const [channel, registry] = await Promise.all([api.getChannel(), api.getModules()]);
setChannelState(channel);
setModuleRegistry(registry);
} catch (err: any) {
setError(`频道状态读取失败: ${err.message}`);
}
}, []);
useEffect(() => {
refreshTree();
refreshRepositoryStatus();
refreshServerSession();
}, [refreshTree, refreshRepositoryStatus, refreshServerSession]);
refreshChannel();
}, [refreshTree, refreshRepositoryStatus, refreshServerSession, refreshChannel]);
const changeModuleState = useCallback(async (moduleId: string, installed: boolean, mounted: boolean) => {
setModuleBusy(true);
setModuleMessage('');
try {
const result = await api.patchChannel({ operation: 'set_module_state', moduleId, installed, mounted });
setChannelState(result.channel);
setLastChannelReceipt(result.receipt.id);
setModuleMessage(installed ? (mounted ? '模块已安装并打开,已保留可撤销检查点' : '模块已收起,数据保持不变') : '模块入口已移除,知识数据与历史没有删除');
if (!installed || !mounted) setAgentPanelOpen(false);
} catch (err: any) {
setModuleMessage(err.message);
} finally {
setModuleBusy(false);
}
}, []);
const undoModuleChange = useCallback(async () => {
if (!lastChannelReceipt) return;
setModuleBusy(true);
try {
const result = await api.undoChannel(lastChannelReceipt);
setChannelState(result.channel);
setLastChannelReceipt('');
setModuleMessage('已恢复上一步频道状态;用户数据始终保留');
} catch (err: any) {
setModuleMessage(err.message);
} finally {
setModuleBusy(false);
}
}, [lastChannelReceipt]);
useEffect(() => {
const resolvedLanguage = humanPreferences.language === 'system'
@ -260,7 +307,11 @@ export default function App() {
const activeRouteCopy = routeCopy[activeRoute];
const currentTitle = cleanDisplayText(currentDoc?.meta.title || '知识库');
const contentVisible = activeRoute === 'fifth' && activeModule === 'knowledge' && !moduleCollapsed;
const knowledgeState = channelState?.modules.find(module => module.id === 'HL-MOD-KNOWLEDGE-001');
const knowledgeInstalled = knowledgeState?.installed !== false;
const knowledgeMounted = knowledgeInstalled && knowledgeState?.mounted !== false;
const moduleCollapsed = knowledgeInstalled && !knowledgeMounted;
const contentVisible = activeRoute === 'fifth' && activeModule === 'knowledge' && knowledgeMounted;
const breadcrumb = useMemo(
() => currentPath ? currentPath.split('/').map(cleanDisplayText).join(' / ') : '知识库',
[currentPath],
@ -329,27 +380,27 @@ export default function App() {
channelTitle={channelTitle}
channelSubtitle={channelSubtitle}
activeRoute={activeRoute}
knowledgeInstalled={knowledgeInstalled}
knowledgeSelected={activeRoute === 'fifth' && activeModule === 'knowledge'}
onRouteSelect={route => {
setActiveRoute(route);
setActiveModule('knowledge');
setModuleCollapsed(false);
}}
onKnowledgeSelect={() => {
setActiveRoute('fifth');
setActiveModule('knowledge');
setModuleCollapsed(false);
if (!knowledgeMounted) void changeModuleState('HL-MOD-KNOWLEDGE-001', true, true);
}}
onEducationSelect={() => {
setActiveRoute('sub');
setActiveModule('education');
setModuleCollapsed(false);
}}
onSettingsOpen={() => setHumanSettingsOpen(true)}
onAccountOpen={() => {
setStorageSheetInitialMode('server');
setStorageSheetOpen(true);
}}
onModuleLibraryOpen={() => setModuleLibraryOpen(true)}
/>
<section className="platform-workspace">
@ -358,7 +409,13 @@ export default function App() {
<div className="route-surface">
<h1></h1>
<p></p>
<button className="primary-button" onClick={() => setModuleCollapsed(false)}></button>
<button className="primary-button" onClick={() => void changeModuleState('HL-MOD-KNOWLEDGE-001', true, true)}></button>
</div>
) : activeRoute === 'fifth' && !knowledgeInstalled ? (
<div className="route-surface">
<h1></h1>
<p>使</p>
<button className="primary-button" onClick={() => setModuleLibraryOpen(true)}></button>
</div>
) : (
<DomainSurface
@ -392,7 +449,7 @@ export default function App() {
<button className={view === 'history' ? 'selected' : ''} onClick={() => setView('history')}></button>
<button className="danger-action" onClick={deleteDoc} aria-label="删除当前页面"></button>
</>}
<button onClick={() => setModuleCollapsed(true)}></button>
<button onClick={() => void changeModuleState('HL-MOD-KNOWLEDGE-001', true, false)}></button>
</div>
</div>
@ -444,7 +501,7 @@ export default function App() {
{agentPanelOpen && (
<aside className="agent-drawer">
<div className="agent-drawer-scope"><span></span><strong> · {currentTitle}</strong></div>
<AgentChat apiBase={agentApiBase} runtimeRevision={agentRevision} onClose={() => setAgentPanelOpen(false)} />
<AgentChat apiBase={agentApiBase} runtimeRevision={agentRevision} onClose={() => setAgentPanelOpen(false)} onWorldChanged={refreshChannel} />
</aside>
)}
</div>
@ -488,6 +545,17 @@ export default function App() {
setStorageSheetOpen(true);
}}
/>
<ModuleLibrarySheet
open={moduleLibraryOpen}
registry={moduleRegistry}
channel={channelState}
busy={moduleBusy}
message={moduleMessage}
canUndo={Boolean(lastChannelReceipt)}
onClose={() => setModuleLibraryOpen(false)}
onChange={(moduleId, installed, mounted) => void changeModuleState(moduleId, installed, mounted)}
onUndo={() => void undoModuleChange()}
/>
</div>
);
}

View file

@ -57,6 +57,38 @@ export interface SearchResult {
line: number;
}
export interface ModuleManifest {
id: string;
name: string;
version: string;
kind: 'native-application';
state: 'LIVE';
description: string;
capabilities: string[];
dataPolicy: string;
}
export interface ChannelModuleState {
id: string;
installed: boolean;
mounted: boolean;
order: number;
}
export interface ChannelState {
schema: 'hololake.personal-channel-state/v1';
channelId: string;
revision: number;
updatedAt: string;
modules: ChannelModuleState[];
}
export interface ChannelReceipt {
id: string;
after: ChannelState;
reversible: true;
}
export interface DiffResult {
additions: number;
deletions: number;
@ -66,6 +98,23 @@ export interface DiffResult {
// ─── API 方法 ───
export const api = {
async getModules(): Promise<ModuleManifest[]> {
const data = await request<{ registry: ModuleManifest[] }>('/modules');
return data.registry;
},
async getChannel(): Promise<ChannelState> {
const data = await request<{ channel: ChannelState }>('/channel');
return data.channel;
},
async patchChannel(input: { operation: 'set_module_state'; moduleId: string; installed?: boolean; mounted?: boolean }): Promise<{ channel: ChannelState; receipt: ChannelReceipt }> {
return request('/channel/patch', { method: 'POST', body: JSON.stringify(input) });
},
async undoChannel(receiptId: string): Promise<{ channel: ChannelState; receipt: ChannelReceipt }> {
return request(`/channel/undo/${encodeURIComponent(receiptId)}`, { method: 'POST' });
},
/** 获取文档树 */
getTree: () =>
request<{ ok: true; tree: DocTreeNode[] }>('/tree').then(d => d.tree),

View file

@ -47,9 +47,10 @@ interface Props {
apiBase: string;
runtimeRevision?: number;
onClose?: () => void;
onWorldChanged?: () => void;
}
export default function AgentChat({ apiBase, runtimeRevision = 0, onClose }: Props) {
export default function AgentChat({ apiBase, runtimeRevision = 0, onClose, onWorldChanged }: Props) {
const [messages, setMessages] = useState<Message[]>([]);
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [activeConversationId, setActiveConversationId] = useState('');
@ -172,6 +173,7 @@ export default function AgentChat({ apiBase, runtimeRevision = 0, onClose }: Pro
setPendingActions(data.actions || []);
setMessages(data.messages || messages);
await refreshConversationList();
if (decision === 'confirm') onWorldChanged?.();
} catch (error) {
setMessages(previous => [...previous, { role: 'assistant', content: `动作未执行:${error instanceof Error ? error.message : String(error)}`, timestamp: new Date().toISOString() }]);
} finally {

View file

@ -0,0 +1,55 @@
import type { ChannelState, ModuleManifest } from '../api';
interface Props {
open: boolean;
registry: ModuleManifest[];
channel: ChannelState | null;
busy: boolean;
message: string;
onClose: () => void;
onChange: (moduleId: string, installed: boolean, mounted: boolean) => void;
onUndo: () => void;
canUndo: boolean;
}
export function ModuleLibrarySheet({ open, registry, channel, busy, message, onClose, onChange, onUndo, canUndo }: Props) {
if (!open) return null;
return (
<div className="sheet-backdrop" role="presentation" onMouseDown={event => { if (event.target === event.currentTarget) onClose(); }}>
<section className="module-library-sheet" role="dialog" aria-modal="true" aria-label="频道模块">
<header>
<div><small></small><h2></h2><p></p></div>
<button type="button" onClick={onClose} aria-label="关闭">×</button>
</header>
<div className="module-library-list">
{registry.map(manifest => {
const state = channel?.modules.find(module => module.id === manifest.id);
const installed = state?.installed === true;
const mounted = installed && state?.mounted === true;
return (
<article className="module-library-card" key={manifest.id}>
<div className="module-library-icon" aria-hidden="true"><span /></div>
<div className="module-library-copy">
<div className="module-library-title"><h3>{manifest.name}</h3><span>{manifest.state === 'LIVE' ? '可用' : manifest.state}</span></div>
<code>{manifest.id}</code>
<p>{manifest.description}</p>
<small>{manifest.dataPolicy}</small>
</div>
<div className="module-library-actions">
{!installed && <button className="primary-button" disabled={busy} onClick={() => onChange(manifest.id, true, true)}></button>}
{installed && !mounted && <button className="primary-button" disabled={busy} onClick={() => onChange(manifest.id, true, true)}></button>}
{installed && mounted && <button className="secondary-button" disabled={busy} onClick={() => onChange(manifest.id, true, false)}></button>}
{installed && <button className="quiet-danger" disabled={busy} onClick={() => onChange(manifest.id, false, false)}></button>}
</div>
</article>
);
})}
</div>
<footer>
<span>{message || `频道状态修订 ${channel?.revision ?? 0}`}</span>
{canUndo && <button type="button" onClick={onUndo} disabled={busy}></button>}
</footer>
</section>
</div>
);
}

View file

@ -5,11 +5,13 @@ type RouteId = 'fifth' | 'main' | 'sub' | 'zero' | 'zero-sense';
interface Props {
activeRoute: RouteId;
knowledgeSelected: boolean;
knowledgeInstalled: boolean;
onRouteSelect: (route: RouteId) => void;
onKnowledgeSelect: () => void;
onEducationSelect: () => void;
onSettingsOpen: () => void;
onAccountOpen: () => void;
onModuleLibraryOpen: () => void;
channelTitle: string;
channelSubtitle: string;
}
@ -48,11 +50,13 @@ function ModuleIcon({ kind }: { kind: 'knowledge' | 'education' }) {
export function PlatformNavigation({
activeRoute,
knowledgeSelected,
knowledgeInstalled,
onRouteSelect,
onKnowledgeSelect,
onEducationSelect,
onSettingsOpen,
onAccountOpen,
onModuleLibraryOpen,
channelTitle,
channelSubtitle,
}: Props) {
@ -111,11 +115,12 @@ export function PlatformNavigation({
</nav>
)}
<div className="module-heading"><span></span><button type="button" aria-label="添加模块"></button></div>
<div className="module-heading"><span></span><button type="button" aria-label="添加模块" onClick={onModuleLibraryOpen}></button></div>
<nav className="platform-module-list" aria-label="已安装模块">
<button type="button" className={knowledgeSelected ? 'selected' : ''} onClick={onKnowledgeSelect}>
{knowledgeInstalled && <button type="button" className={knowledgeSelected ? 'selected' : ''} onClick={onKnowledgeSelect}>
<ModuleIcon kind="knowledge" /><span></span>
</button>
</button>}
{!knowledgeInstalled && <button type="button" className="module-empty-entry" onClick={onModuleLibraryOpen}><span></span><span></span></button>}
</nav>
<div className="platform-nav-footer">

View file

@ -1,4 +1,4 @@
/* HoloLake 0.7.1 canonical workspace layer.
/* HoloLake 0.8.0 canonical workspace layer.
* This file is intentionally loaded after the inherited component stylesheet.
* It is the single layout and theme authority for the platform shell.
*/
@ -241,6 +241,106 @@ body, #root { background: var(--lake-bg, #07111d); }
.storage-sheet, .human-settings { border-color: var(--lake-border); background: var(--lake-panel); color: var(--lake-text); }
.storage-sheet-backdrop, .human-settings-backdrop { background: rgba(2, 8, 14, .56); }
.module-empty-entry { border: 1px dashed var(--lake-border) !important; background: transparent !important; }
.module-empty-entry span:first-child { font-size: 20px; color: var(--lake-accent); }
.sheet-backdrop {
position: fixed;
inset: 0;
z-index: 1500;
display: grid;
place-items: center;
padding: 28px;
background: rgba(2, 8, 14, .62);
backdrop-filter: blur(10px);
}
.module-library-sheet {
width: min(760px, 100%);
max-height: min(760px, calc(100vh - 56px));
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--lake-border);
border-radius: 22px;
background: var(--lake-panel);
color: var(--lake-text);
box-shadow: 0 30px 90px rgba(0, 0, 0, .44);
}
.module-library-sheet > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 28px;
padding: 28px 30px 22px;
border-bottom: 1px solid var(--lake-border);
}
.module-library-sheet > header small { color: var(--lake-accent); font-weight: 700; letter-spacing: .08em; }
.module-library-sheet > header h2 { margin: 6px 0 5px; font-size: 27px; letter-spacing: -.02em; }
.module-library-sheet > header p { margin: 0; color: var(--lake-muted); }
.module-library-sheet > header > button {
width: 36px;
height: 36px;
border: 0;
border-radius: 10px;
background: var(--lake-surface-2);
color: var(--lake-muted);
font-size: 24px;
}
.module-library-list { padding: 22px; overflow: auto; }
.module-library-card {
display: grid;
grid-template-columns: 52px minmax(0, 1fr) auto;
gap: 18px;
align-items: start;
padding: 20px;
border: 1px solid var(--lake-border);
border-radius: 17px;
background: var(--lake-surface-1);
}
.module-library-icon {
width: 52px;
height: 52px;
display: grid;
place-items: center;
border: 1px solid var(--lake-border);
border-radius: 15px;
background: var(--lake-accent-soft);
}
.module-library-icon span { width: 20px; height: 25px; border: 2px solid var(--lake-accent); border-radius: 3px 7px 7px 3px; }
.module-library-title { display: flex; align-items: center; gap: 10px; }
.module-library-title h3 { margin: 0; font-size: 18px; }
.module-library-title span { padding: 3px 8px; border-radius: 999px; background: color-mix(in srgb, var(--lake-success) 14%, transparent); color: var(--lake-success); font-size: 11px; font-weight: 700; }
.module-library-copy code { display: block; margin-top: 5px; color: var(--lake-muted); font-size: 11px; }
.module-library-copy p { margin: 12px 0 7px; color: var(--lake-text); line-height: 1.55; }
.module-library-copy > small { color: var(--lake-muted); line-height: 1.5; }
.module-library-actions { min-width: 118px; display: grid; gap: 8px; }
.module-library-actions button { min-height: 36px; white-space: nowrap; }
.quiet-danger { border: 0; background: transparent; color: var(--lake-danger); }
.module-library-sheet > footer {
min-height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 14px 30px;
border-top: 1px solid var(--lake-border);
color: var(--lake-muted);
font-size: 13px;
}
.module-library-sheet > footer button { border: 1px solid var(--lake-border); border-radius: 9px; background: var(--lake-surface-2); color: var(--lake-text); padding: 8px 13px; }
@media (max-width: 700px) {
.sheet-backdrop { padding: 12px; align-items: end; }
.module-library-sheet { max-height: calc(100vh - 24px); border-radius: 20px 20px 12px 12px; }
.module-library-card { grid-template-columns: 44px minmax(0, 1fr); }
.module-library-icon { width: 44px; height: 44px; }
.module-library-actions { grid-column: 1 / -1; grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
.settings-section select, .settings-model-fields input { border-color: var(--lake-border); background: var(--lake-surface); color: var(--lake-text); }
.settings-model-card, .settings-account-card { border-color: var(--lake-border-soft); background: var(--lake-surface); }