2026-08-09 10:43:56 +08:00
|
|
|
|
import { useCallback, useEffect, useMemo, useState, type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react';
|
2026-08-09 14:14:07 +08:00
|
|
|
|
import { api, DocTreeNode, DocContent, type ChannelState, type ModuleManifest } from './api';
|
2026-08-08 07:09:54 +08:00
|
|
|
|
import { DocTree } from './components/DocTree';
|
|
|
|
|
|
import { Editor } from './components/Editor';
|
|
|
|
|
|
import { SearchBar } from './components/SearchBar';
|
|
|
|
|
|
import { VersionHistory } from './components/VersionHistory';
|
2026-08-08 07:38:09 +08:00
|
|
|
|
import AgentChat from './components/AgentChat';
|
2026-08-09 03:37:41 +08:00
|
|
|
|
import { PlatformNavigation } from './components/PlatformNavigation';
|
|
|
|
|
|
import { StorageLocationSheet } from './components/StorageLocationSheet';
|
|
|
|
|
|
import { HumanSettings, HumanPreferences, loadHumanPreferences } from './components/HumanSettings';
|
|
|
|
|
|
import { DomainSurface } from './components/DomainSurface';
|
2026-08-09 14:14:07 +08:00
|
|
|
|
import { ModuleLibrarySheet } from './components/ModuleLibrarySheet';
|
2026-08-09 03:37:41 +08:00
|
|
|
|
import { cleanDisplayText } from './presentation';
|
2026-08-10 00:52:54 +08:00
|
|
|
|
import { WorldEntry } from './components/WorldEntry';
|
2026-08-10 04:38:41 +08:00
|
|
|
|
import { DomainConnectionSheet } from './components/DomainConnectionSheet';
|
2026-08-08 07:09:54 +08:00
|
|
|
|
|
|
|
|
|
|
type View = 'editor' | 'history';
|
2026-08-09 03:37:41 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-10 01:23:52 +08:00
|
|
|
|
interface DomainAccessStatus {
|
|
|
|
|
|
blockers: string[];
|
2026-08-10 05:01:32 +08:00
|
|
|
|
domainId?: string;
|
|
|
|
|
|
nodeId?: string;
|
2026-08-10 01:23:52 +08:00
|
|
|
|
runtimeReady: boolean;
|
|
|
|
|
|
stage: 'checking' | 'login-required' | 'identity-verified' | 'runtime-ready';
|
2026-08-10 05:01:32 +08:00
|
|
|
|
trustSource?: {
|
|
|
|
|
|
reason?: 'ANCHOR_INVALID' | 'REGISTRY_INVALID' | 'SOURCE_UNAVAILABLE';
|
|
|
|
|
|
registryVersion: string | null;
|
|
|
|
|
|
signerCount: number;
|
|
|
|
|
|
sourceCommit: string | null;
|
|
|
|
|
|
status: 'CURRENT' | 'DEGRADED_LAST_KNOWN_GOOD' | 'UNAVAILABLE';
|
|
|
|
|
|
};
|
2026-08-10 01:23:52 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-09 03:37:41 +08:00
|
|
|
|
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: '治理、部署审批、事故、回滚与审计入口。' },
|
|
|
|
|
|
};
|
2026-08-08 07:09:54 +08:00
|
|
|
|
|
|
|
|
|
|
export default function App() {
|
2026-08-08 10:39:18 +08:00
|
|
|
|
const agentApiBase = window.location.protocol === 'file:' ? 'http://127.0.0.1:3890' : '';
|
2026-08-08 07:09:54 +08:00
|
|
|
|
const [tree, setTree] = useState<DocTreeNode[]>([]);
|
2026-08-09 03:37:41 +08:00
|
|
|
|
const [treeLoaded, setTreeLoaded] = useState(false);
|
2026-08-08 07:09:54 +08:00
|
|
|
|
const [currentDoc, setCurrentDoc] = useState<DocContent | null>(null);
|
2026-08-09 03:37:41 +08:00
|
|
|
|
const [currentPath, setCurrentPath] = useState('');
|
2026-08-08 07:09:54 +08:00
|
|
|
|
const [view, setView] = useState<View>('editor');
|
|
|
|
|
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
2026-08-09 03:37:41 +08:00
|
|
|
|
const [agentPanelOpen, setAgentPanelOpen] = useState(false);
|
2026-08-08 11:15:34 +08:00
|
|
|
|
const [importing, setImporting] = useState(false);
|
|
|
|
|
|
const [importMessage, setImportMessage] = useState<string | null>(null);
|
2026-08-09 03:37:41 +08:00
|
|
|
|
const [emptyDismissed, setEmptyDismissed] = useState(false);
|
|
|
|
|
|
const [activeRoute, setActiveRoute] = useState<RouteId>('fifth');
|
|
|
|
|
|
const [activeModule, setActiveModule] = useState<ModuleId>('knowledge');
|
|
|
|
|
|
const [storageSheetOpen, setStorageSheetOpen] = useState(false);
|
2026-08-10 04:38:41 +08:00
|
|
|
|
const [domainConnectionOpen, setDomainConnectionOpen] = useState(false);
|
2026-08-09 03:37:41 +08:00
|
|
|
|
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);
|
2026-08-09 10:43:56 +08:00
|
|
|
|
const [treeWidth, setTreeWidth] = useState(() => Number(localStorage.getItem('hololake.layout.tree-width')) || 258);
|
|
|
|
|
|
const [agentWidth, setAgentWidth] = useState(() => Number(localStorage.getItem('hololake.layout.agent-width')) || 460);
|
2026-08-09 14:14:07 +08:00
|
|
|
|
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('');
|
2026-08-10 00:52:54 +08:00
|
|
|
|
const [worldEntered, setWorldEntered] = useState(false);
|
2026-08-10 01:23:52 +08:00
|
|
|
|
const [domainAccess, setDomainAccess] = useState<DomainAccessStatus>({ blockers: [], runtimeReady: false, stage: 'checking' });
|
2026-08-08 07:09:54 +08:00
|
|
|
|
|
2026-08-09 03:37:41 +08:00
|
|
|
|
const storageMode = repositoryStatus?.remote ? 'server' : 'local';
|
|
|
|
|
|
const storageLabel = storageMode === 'server' ? '服务器已托管' : '仅本机';
|
2026-08-08 07:09:54 +08:00
|
|
|
|
|
|
|
|
|
|
const openDoc = useCallback(async (docPath: string) => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const doc = await api.getDoc(docPath);
|
|
|
|
|
|
setCurrentDoc(doc);
|
|
|
|
|
|
setCurrentPath(docPath);
|
|
|
|
|
|
setView('editor');
|
2026-08-09 03:37:41 +08:00
|
|
|
|
setActiveRoute('fifth');
|
|
|
|
|
|
setActiveModule('knowledge');
|
2026-08-08 07:09:54 +08:00
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
setError(`加载文档失败: ${err.message}`);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-08-09 03:37:41 +08:00
|
|
|
|
const refreshTree = useCallback(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const nextTree = await api.getTree();
|
|
|
|
|
|
setTree(nextTree);
|
|
|
|
|
|
setTreeLoaded(true);
|
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
setError(`加载文档树失败: ${err.message}`);
|
|
|
|
|
|
setTreeLoaded(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-08-09 10:43:56 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-08-09 03:37:41 +08:00
|
|
|
|
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: '' });
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-08-10 01:23:52 +08:00
|
|
|
|
const refreshDomainAccess = useCallback(async () => {
|
|
|
|
|
|
const server = (window as any).hololake?.server;
|
|
|
|
|
|
if (!server?.domainAccess) {
|
|
|
|
|
|
setDomainAccess({ blockers: ['desktop_runtime_required'], runtimeReady: false, stage: 'login-required' });
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
setDomainAccess(await server.domainAccess('DOM-FIFTH-0001'));
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
setDomainAccess({ blockers: ['domain_access_probe_failed'], runtimeReady: false, stage: 'login-required' });
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-08-09 14:14:07 +08:00
|
|
|
|
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}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-08-09 03:37:41 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
refreshTree();
|
|
|
|
|
|
refreshRepositoryStatus();
|
|
|
|
|
|
refreshServerSession();
|
2026-08-10 01:23:52 +08:00
|
|
|
|
refreshDomainAccess();
|
2026-08-09 14:14:07 +08:00
|
|
|
|
refreshChannel();
|
2026-08-10 01:23:52 +08:00
|
|
|
|
}, [refreshTree, refreshRepositoryStatus, refreshServerSession, refreshDomainAccess, refreshChannel]);
|
2026-08-09 14:14:07 +08:00
|
|
|
|
|
|
|
|
|
|
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]);
|
2026-08-09 03:37:41 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const resolvedLanguage = humanPreferences.language === 'system'
|
|
|
|
|
|
? navigator.language
|
|
|
|
|
|
: humanPreferences.language;
|
|
|
|
|
|
document.documentElement.lang = resolvedLanguage;
|
|
|
|
|
|
}, [humanPreferences.language]);
|
|
|
|
|
|
|
2026-08-09 10:43:56 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
localStorage.setItem('hololake.human-preferences.v1', JSON.stringify(humanPreferences));
|
|
|
|
|
|
}, [humanPreferences]);
|
|
|
|
|
|
|
2026-08-09 03:37:41 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!treeLoaded || currentPath || loading) return;
|
|
|
|
|
|
const firstDocument = findFirstDocument(tree);
|
|
|
|
|
|
if (firstDocument) openDoc(firstDocument);
|
|
|
|
|
|
}, [tree, treeLoaded, currentPath, loading, openDoc]);
|
|
|
|
|
|
|
2026-08-08 07:09:54 +08:00
|
|
|
|
const saveDoc = useCallback(async (title: string, body: string) => {
|
|
|
|
|
|
if (!currentPath) return;
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const doc = await api.updateDoc(currentPath, title, body);
|
|
|
|
|
|
setCurrentDoc(doc);
|
|
|
|
|
|
await refreshTree();
|
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
setError(`保存失败: ${err.message}`);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [currentPath, refreshTree]);
|
|
|
|
|
|
|
|
|
|
|
|
const createDoc = useCallback(async (parentPath: string) => {
|
|
|
|
|
|
const name = prompt('文档文件名(不含 .md):');
|
|
|
|
|
|
if (!name) return;
|
|
|
|
|
|
const title = prompt('文档标题:') || name;
|
|
|
|
|
|
const docPath = parentPath ? `${parentPath}/${name}.md` : `${name}.md`;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const doc = await api.createDoc(docPath, title, `# ${title}\n\n在这里开始写作...\n`);
|
|
|
|
|
|
setCurrentDoc(doc);
|
|
|
|
|
|
setCurrentPath(docPath);
|
2026-08-09 03:37:41 +08:00
|
|
|
|
setEmptyDismissed(false);
|
2026-08-08 07:09:54 +08:00
|
|
|
|
await refreshTree();
|
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
setError(`创建失败: ${err.message}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [refreshTree]);
|
|
|
|
|
|
|
|
|
|
|
|
const deleteDoc = useCallback(async () => {
|
2026-08-09 03:37:41 +08:00
|
|
|
|
if (!currentPath || !confirm(`确认删除 ${currentPath}?`)) return;
|
2026-08-08 07:09:54 +08:00
|
|
|
|
try {
|
|
|
|
|
|
await api.deleteDoc(currentPath);
|
|
|
|
|
|
setCurrentDoc(null);
|
|
|
|
|
|
setCurrentPath('');
|
|
|
|
|
|
await refreshTree();
|
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
setError(`删除失败: ${err.message}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [currentPath, refreshTree]);
|
|
|
|
|
|
|
2026-08-08 11:15:34 +08:00
|
|
|
|
const importFolder = useCallback(async () => {
|
2026-08-09 03:37:41 +08:00
|
|
|
|
const knowledge = (window as any).hololake?.knowledge;
|
|
|
|
|
|
if (!knowledge?.importFolder) {
|
2026-08-08 11:15:34 +08:00
|
|
|
|
setError('本地文件夹导入只在 HoloLake 桌面 App 中提供');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
setImporting(true);
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
setImportMessage(null);
|
|
|
|
|
|
try {
|
2026-08-09 03:37:41 +08:00
|
|
|
|
const result = await knowledge.importFolder();
|
|
|
|
|
|
if (result.cancelled) {
|
|
|
|
|
|
setImportMessage('已取消导入,现有知识库没有变化');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-08-08 11:15:34 +08:00
|
|
|
|
if (!result.imported) {
|
|
|
|
|
|
setImportMessage(`没有找到可导入的文档;已跳过 ${result.skipped || 0} 个文件`);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
await refreshTree();
|
|
|
|
|
|
if (result.firstDocument) await openDoc(result.firstDocument);
|
2026-08-09 03:37:41 +08:00
|
|
|
|
setEmptyDismissed(false);
|
|
|
|
|
|
setImportMessage([
|
2026-08-08 11:15:34 +08:00
|
|
|
|
`已导入 ${result.imported} 篇文档`,
|
|
|
|
|
|
result.assets ? `${result.assets} 个图片资源` : '',
|
|
|
|
|
|
result.skipped ? `跳过 ${result.skipped} 个暂不支持的文件` : '',
|
|
|
|
|
|
result.failed?.length ? `${result.failed.length} 个文件失败` : '',
|
2026-08-09 03:37:41 +08:00
|
|
|
|
].filter(Boolean).join(' · '));
|
2026-08-08 11:15:34 +08:00
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
setError(`导入失败: ${err.message}`);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setImporting(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [openDoc, refreshTree]);
|
|
|
|
|
|
|
2026-08-09 03:37:41 +08:00
|
|
|
|
const activeRouteCopy = routeCopy[activeRoute];
|
|
|
|
|
|
const currentTitle = cleanDisplayText(currentDoc?.meta.title || '知识库');
|
2026-08-09 14:14:07 +08:00
|
|
|
|
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;
|
2026-08-09 03:37:41 +08:00
|
|
|
|
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 || '当前频道';
|
2026-08-08 07:09:54 +08:00
|
|
|
|
|
2026-08-09 10:43:56 +08:00
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-08-08 07:09:54 +08:00
|
|
|
|
return (
|
2026-08-10 00:52:54 +08:00
|
|
|
|
<>
|
|
|
|
|
|
{!worldEntered && (
|
|
|
|
|
|
<WorldEntry
|
2026-08-10 01:23:52 +08:00
|
|
|
|
access={domainAccess}
|
|
|
|
|
|
onEnterFifthRuntime={() => {
|
|
|
|
|
|
setActiveRoute('fifth');
|
|
|
|
|
|
setActiveModule('knowledge');
|
|
|
|
|
|
setWorldEntered(true);
|
|
|
|
|
|
}}
|
|
|
|
|
|
onEnterLocalWorkspace={() => {
|
2026-08-10 00:52:54 +08:00
|
|
|
|
setActiveRoute('fifth');
|
|
|
|
|
|
setActiveModule('knowledge');
|
|
|
|
|
|
setWorldEntered(true);
|
|
|
|
|
|
}}
|
|
|
|
|
|
onOpenConnection={() => {
|
2026-08-10 04:38:41 +08:00
|
|
|
|
setDomainConnectionOpen(true);
|
2026-08-10 00:52:54 +08:00
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<div
|
2026-08-09 03:37:41 +08:00
|
|
|
|
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>
|
2026-08-09 10:43:56 +08:00
|
|
|
|
<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>
|
2026-08-09 03:37:41 +08:00
|
|
|
|
<button className={`fifth-domain-session ${serverSession.authenticated ? 'authenticated' : ''}`} type="button" onClick={() => {
|
|
|
|
|
|
setStorageSheetInitialMode('server');
|
|
|
|
|
|
setStorageSheetOpen(true);
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<span className="session-status-dot" aria-hidden="true" />
|
2026-08-10 04:38:41 +08:00
|
|
|
|
<span><strong>{serverSession.authenticated ? `代码频道 · ${serverSession.username}` : (personalServer ? '代码频道账号' : '配置我的服务器')}</strong><small>{personalServer?.id || '本机私有配置'}</small></span>
|
2026-08-09 03:37:41 +08:00
|
|
|
|
</button>
|
2026-08-08 07:09:54 +08:00
|
|
|
|
</header>
|
|
|
|
|
|
|
2026-08-09 03:37:41 +08:00
|
|
|
|
<div className="platform-body">
|
|
|
|
|
|
<PlatformNavigation
|
|
|
|
|
|
channelTitle={channelTitle}
|
|
|
|
|
|
channelSubtitle={channelSubtitle}
|
|
|
|
|
|
activeRoute={activeRoute}
|
2026-08-09 14:14:07 +08:00
|
|
|
|
knowledgeInstalled={knowledgeInstalled}
|
2026-08-09 03:37:41 +08:00
|
|
|
|
knowledgeSelected={activeRoute === 'fifth' && activeModule === 'knowledge'}
|
|
|
|
|
|
onRouteSelect={route => {
|
|
|
|
|
|
setActiveRoute(route);
|
2026-08-09 10:43:56 +08:00
|
|
|
|
setActiveModule('knowledge');
|
2026-08-09 03:37:41 +08:00
|
|
|
|
}}
|
|
|
|
|
|
onKnowledgeSelect={() => {
|
|
|
|
|
|
setActiveRoute('fifth');
|
|
|
|
|
|
setActiveModule('knowledge');
|
2026-08-09 14:14:07 +08:00
|
|
|
|
if (!knowledgeMounted) void changeModuleState('HL-MOD-KNOWLEDGE-001', true, true);
|
2026-08-09 03:37:41 +08:00
|
|
|
|
}}
|
|
|
|
|
|
onEducationSelect={() => {
|
|
|
|
|
|
setActiveRoute('sub');
|
|
|
|
|
|
setActiveModule('education');
|
|
|
|
|
|
}}
|
|
|
|
|
|
onSettingsOpen={() => setHumanSettingsOpen(true)}
|
|
|
|
|
|
onAccountOpen={() => {
|
|
|
|
|
|
setStorageSheetInitialMode('server');
|
|
|
|
|
|
setStorageSheetOpen(true);
|
|
|
|
|
|
}}
|
2026-08-09 14:14:07 +08:00
|
|
|
|
onModuleLibraryOpen={() => setModuleLibraryOpen(true)}
|
2026-08-09 03:37:41 +08:00
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<section className="platform-workspace">
|
|
|
|
|
|
{!contentVisible ? (
|
|
|
|
|
|
moduleCollapsed && activeRoute === 'fifth' ? (
|
|
|
|
|
|
<div className="route-surface">
|
|
|
|
|
|
<h1>知识库已收起</h1>
|
|
|
|
|
|
<p>模块仍安装在永恒湖心频道中,重新打开不会改变本地或服务器数据。</p>
|
2026-08-09 14:14:07 +08:00
|
|
|
|
<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>
|
2026-08-09 03:37:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<DomainSurface
|
|
|
|
|
|
activeDomain={(activeRoute === 'fifth' ? 'sub' : activeRoute) as 'main' | 'sub' | 'zero' | 'zero-sense'}
|
2026-08-09 10:43:56 +08:00
|
|
|
|
educationSelected={activeRoute === 'sub' && activeModule === 'education'}
|
2026-08-09 03:37:41 +08:00
|
|
|
|
onOpenSettings={() => setHumanSettingsOpen(true)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<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>
|
2026-08-08 11:15:34 +08:00
|
|
|
|
</button>
|
2026-08-09 03:37:41 +08:00
|
|
|
|
<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>
|
2026-08-08 11:15:34 +08:00
|
|
|
|
</div>
|
2026-08-09 03:37:41 +08:00
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</>}
|
2026-08-09 14:14:07 +08:00
|
|
|
|
<button onClick={() => void changeModuleState('HL-MOD-KNOWLEDGE-001', true, false)}>收起模块</button>
|
2026-08-09 03:37:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-08-09 10:43:56 +08:00
|
|
|
|
<div
|
|
|
|
|
|
className={`knowledge-workbench ${sidebarOpen ? 'has-tree' : ''} ${agentPanelOpen ? 'has-agent' : ''}`}
|
|
|
|
|
|
style={{ '--tree-width': `${treeWidth}px`, '--agent-width': `${agentWidth}px` } as CSSProperties}
|
|
|
|
|
|
>
|
2026-08-09 03:37:41 +08:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-08-09 10:43:56 +08:00
|
|
|
|
{sidebarOpen && <button type="button" className="workspace-resizer tree-resizer" onPointerDown={event => startResize('tree', event)} aria-label="调整知识树宽度" />}
|
|
|
|
|
|
|
2026-08-09 03:37:41 +08:00
|
|
|
|
<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>支持 Markdown、TXT、CSV、JSON 与 YAML</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{!currentDoc && emptyDismissed && !loading && (
|
|
|
|
|
|
<div className="quiet-empty"><h2>知识库已准备好</h2><p>导入文件夹或新建页面开始使用。</p><button className="secondary-button" onClick={() => setEmptyDismissed(false)}>打开导入入口</button></div>
|
|
|
|
|
|
)}
|
2026-08-09 10:43:56 +08:00
|
|
|
|
{currentDoc && view === 'editor' && <Editor doc={currentDoc} onSave={saveDoc} onWikiSelect={openWikiTarget} />}
|
2026-08-09 03:37:41 +08:00
|
|
|
|
{currentDoc && view === 'history' && <VersionHistory docPath={currentPath} />}
|
|
|
|
|
|
{currentDoc && <div className="document-path">{breadcrumb}</div>}
|
|
|
|
|
|
</main>
|
2026-08-09 10:43:56 +08:00
|
|
|
|
|
|
|
|
|
|
{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>
|
2026-08-09 14:14:07 +08:00
|
|
|
|
<AgentChat apiBase={agentApiBase} runtimeRevision={agentRevision} onClose={() => setAgentPanelOpen(false)} onWorldChanged={refreshChannel} />
|
2026-08-09 10:43:56 +08:00
|
|
|
|
</aside>
|
|
|
|
|
|
)}
|
2026-08-09 03:37:41 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
2026-08-08 07:09:54 +08:00
|
|
|
|
)}
|
2026-08-09 03:37:41 +08:00
|
|
|
|
|
|
|
|
|
|
{contentVisible && (
|
|
|
|
|
|
<>
|
2026-08-09 10:43:56 +08:00
|
|
|
|
{!agentPanelOpen && <button className="agent-launcher" onClick={() => {
|
|
|
|
|
|
if (window.innerWidth < 1500) setSidebarOpen(false);
|
|
|
|
|
|
setAgentPanelOpen(true);
|
|
|
|
|
|
}}>
|
2026-08-09 03:37:41 +08:00
|
|
|
|
<span className="launcher-orbit" aria-hidden="true" />
|
2026-08-09 10:43:56 +08:00
|
|
|
|
询问 HoloLake
|
|
|
|
|
|
</button>}
|
2026-08-09 03:37:41 +08:00
|
|
|
|
</>
|
2026-08-08 07:09:54 +08:00
|
|
|
|
)}
|
2026-08-09 03:37:41 +08:00
|
|
|
|
</section>
|
2026-08-08 07:09:54 +08:00
|
|
|
|
</div>
|
2026-08-09 03:37:41 +08:00
|
|
|
|
|
|
|
|
|
|
<StorageLocationSheet
|
|
|
|
|
|
open={storageSheetOpen}
|
|
|
|
|
|
apiBase={agentApiBase}
|
|
|
|
|
|
currentRemote={repositoryStatus?.remote?.url}
|
|
|
|
|
|
initialMode={storageSheetInitialMode}
|
|
|
|
|
|
onClose={() => setStorageSheetOpen(false)}
|
|
|
|
|
|
onApplied={() => {
|
|
|
|
|
|
refreshRepositoryStatus();
|
|
|
|
|
|
refreshServerSession();
|
2026-08-10 01:23:52 +08:00
|
|
|
|
refreshDomainAccess();
|
2026-08-09 03:37:41 +08:00
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-08-10 04:38:41 +08:00
|
|
|
|
<DomainConnectionSheet
|
|
|
|
|
|
access={domainAccess}
|
|
|
|
|
|
open={domainConnectionOpen}
|
|
|
|
|
|
onClose={() => setDomainConnectionOpen(false)}
|
|
|
|
|
|
onEnterRuntime={() => {
|
|
|
|
|
|
if (!domainAccess.runtimeReady) return;
|
|
|
|
|
|
setActiveRoute('fifth');
|
|
|
|
|
|
setActiveModule('knowledge');
|
|
|
|
|
|
setWorldEntered(true);
|
|
|
|
|
|
setDomainConnectionOpen(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
onOpenCodeChannel={() => {
|
|
|
|
|
|
setDomainConnectionOpen(false);
|
|
|
|
|
|
setStorageSheetInitialMode('server');
|
|
|
|
|
|
setStorageSheetOpen(true);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-08-09 03:37:41 +08:00
|
|
|
|
<HumanSettings
|
|
|
|
|
|
open={humanSettingsOpen}
|
|
|
|
|
|
preferences={humanPreferences}
|
|
|
|
|
|
onClose={() => setHumanSettingsOpen(false)}
|
|
|
|
|
|
onChange={setHumanPreferences}
|
|
|
|
|
|
onAgentChanged={() => setAgentRevision(revision => revision + 1)}
|
|
|
|
|
|
onManageServer={() => {
|
|
|
|
|
|
setHumanSettingsOpen(false);
|
|
|
|
|
|
setStorageSheetInitialMode('server');
|
|
|
|
|
|
setStorageSheetOpen(true);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-08-09 14:14:07 +08:00
|
|
|
|
<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()}
|
|
|
|
|
|
/>
|
2026-08-10 00:52:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
2026-08-08 07:09:54 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|