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

@ -0,0 +1,338 @@
import { useEffect, useMemo, useState } from 'react';
interface ServerProfile {
id: string;
physicalNodeId?: string;
name: string;
purpose?: 'personal-fifth-domain' | 'enterprise-lighthouse';
connected: boolean;
verified: boolean;
}
interface ServerSession {
authenticated: boolean;
nodeId: string;
username?: string;
}
interface ServerRepository {
name: string;
fullName: string;
private: boolean;
defaultBranch: string;
}
interface Props {
open: boolean;
apiBase: string;
currentRemote?: string | null;
initialMode?: 'local' | 'server';
onClose: () => void;
onApplied: (result: { mode: 'local' | 'server'; repository?: ServerRepository }) => void;
}
type ServerBridge = {
list: () => Promise<ServerProfile[]>;
connect: (nodeId: string) => Promise<ServerProfile>;
session: (nodeId?: string) => Promise<ServerSession>;
login: (input: { nodeId: string; username: string; password: string }) => Promise<ServerSession>;
logout: () => Promise<ServerSession>;
repositories: () => Promise<ServerRepository[]>;
createRepository: (input: { name: string; description?: string }) => Promise<ServerRepository>;
gitRemote: (fullName: string) => Promise<string>;
};
function bridge(): ServerBridge | null {
return (window as any).hololake?.server || null;
}
export function StorageLocationSheet({ open, apiBase, currentRemote, initialMode, onClose, onApplied }: Props) {
const [mode, setMode] = useState<'local' | 'server'>(currentRemote ? 'server' : 'local');
const [servers, setServers] = useState<ServerProfile[]>([]);
const [selectedNode, setSelectedNode] = useState('');
const [session, setSession] = useState<ServerSession>({ authenticated: false, nodeId: selectedNode });
const [repositories, setRepositories] = useState<ServerRepository[]>([]);
const [selectedRepository, setSelectedRepository] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [creating, setCreating] = useState(false);
const [newRepository, setNewRepository] = useState('heartbeat-core-knowledge');
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const selectedServer = useMemo(
() => servers.find(server => server.id === selectedNode) || null,
[servers, selectedNode],
);
async function loadRepositories() {
const server = bridge();
if (!server) return;
const next = await server.repositories();
setRepositories(next);
setSelectedRepository(previous => previous || next[0]?.fullName || '');
}
useEffect(() => {
if (!open) return;
setMode(initialMode || (currentRemote ? 'server' : 'local'));
setMessage('');
const server = bridge();
if (!server) {
setMessage('服务器托管只在 HoloLake 桌面 App 中提供');
return;
}
server.list()
.then(async profiles => {
setServers(profiles);
const personal = profiles.find(profile => profile.purpose === 'personal-fifth-domain');
const nodeId = selectedNode || personal?.id || '';
setSelectedNode(nodeId);
const currentSession = await server.session(nodeId);
setSession(currentSession);
if (currentSession.authenticated) {
setUsername(currentSession.username || '');
await loadRepositories();
}
})
.catch(error => setMessage(error instanceof Error ? error.message : String(error)));
}, [open, currentRemote, initialMode]);
async function selectServer(nodeId: string) {
const server = bridge();
setSelectedNode(nodeId);
setRepositories([]);
setSelectedRepository('');
setPassword('');
setMessage('');
if (!server) return;
try {
const nextSession = await server.session(nodeId);
setSession(nextSession);
if (nextSession.authenticated) {
setUsername(nextSession.username || '');
await loadRepositories();
} else {
setUsername('');
}
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
}
}
async function connectServer() {
const server = bridge();
if (!server) return;
setBusy(true);
setMessage('');
try {
const profile = await server.connect(selectedNode);
setServers(previous => previous.map(item => item.id === profile.id ? profile : item));
setMessage('服务器连接已验证');
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
}
async function login() {
const server = bridge();
if (!server) return;
setBusy(true);
setMessage('');
try {
const nextSession = await server.login({ nodeId: selectedNode, username, password });
setPassword('');
setSession(nextSession);
await loadRepositories();
setMessage(`已登录 ${nextSession.username}`);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
}
async function createRepository() {
const server = bridge();
if (!server || !newRepository.trim()) return;
setBusy(true);
setMessage('');
try {
const created = await server.createRepository({
name: newRepository.trim(),
description: 'HoloLake 频道知识库',
});
setRepositories(previous => [...previous, created].sort((a, b) => a.name.localeCompare(b.name)));
setSelectedRepository(created.fullName);
setCreating(false);
setMessage(`已建立私有仓库 ${created.fullName}`);
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
}
async function logout() {
const server = bridge();
if (!server) return;
setBusy(true);
setMessage('');
try {
const nextSession = await server.logout();
setSession(nextSession);
setRepositories([]);
setSelectedRepository('');
setUsername('');
setMode('local');
onApplied({ mode: 'local' });
setMessage('已退出服务器账号,并解除当前知识库的远端连接');
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
}
async function apply() {
if (mode === 'local') {
setBusy(true);
setMessage('');
try {
if (currentRemote) {
const response = await fetch(`${apiBase}/api/forgejo/remote`, { method: 'DELETE' });
const data = await response.json();
if (!data.ok) throw new Error(data.error || '解除服务器托管失败');
}
onApplied({ mode: 'local' });
onClose();
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
return;
}
const server = bridge();
const repository = repositories.find(item => item.fullName === selectedRepository);
if (!server || !repository) {
setMessage(session.authenticated ? '请选择目标仓库' : '请先登录服务器');
return;
}
setBusy(true);
setMessage('');
try {
const remote = await server.gitRemote(repository.fullName);
const response = await fetch(`${apiBase}/api/forgejo/remote`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: remote }),
});
const data = await response.json();
if (!data.ok) throw new Error(data.error || '连接仓库失败');
onApplied({ mode: 'server', repository });
onClose();
} catch (error) {
setMessage(error instanceof Error ? error.message : String(error));
} finally {
setBusy(false);
}
}
if (!open) return null;
return (
<div className="storage-sheet-backdrop" role="presentation" onMouseDown={event => {
if (event.target === event.currentTarget) onClose();
}}>
<section className="storage-sheet" role="dialog" aria-modal="true" aria-labelledby="storage-sheet-title">
<header className="storage-sheet-header">
<h2 id="storage-sheet-title"></h2>
<button className="icon-button" onClick={onClose} aria-label="关闭保存位置设置">×</button>
</header>
<div className="storage-mode-list">
<label className={`storage-mode-row ${mode === 'local' ? 'selected' : ''}`}>
<input type="radio" checked={mode === 'local'} onChange={() => setMode('local')} />
<span><strong></strong><small>使 Git </small></span>
</label>
<label className={`storage-mode-row ${mode === 'server' ? 'selected' : ''}`}>
<input type="radio" checked={mode === 'server'} onChange={() => setMode('server')} />
<span><strong></strong><small></small></span>
</label>
</div>
{mode === 'server' && (
<div className="storage-server-area">
<p className="sheet-label"></p>
<div className="server-profile-picker">
<label className="sheet-label" htmlFor="hololake-server-profile"></label>
<select id="hololake-server-profile" value={selectedNode} onChange={event => selectServer(event.target.value)}>
{servers.map(server => (
<option key={server.id} value={server.id}>{server.name}</option>
))}
</select>
</div>
<div className="server-profile-row">
<div className="server-profile-mark" aria-hidden="true"><span /></div>
<div><strong>{selectedServer?.name || '我的第五域服务器'}</strong><small>{selectedNode}{selectedServer?.physicalNodeId && selectedServer.physicalNodeId !== selectedNode ? ` · ${selectedServer.physicalNodeId}` : ''}</small></div>
<span className="verified-state">{selectedServer?.connected ? '已连接' : '已验证'}</span>
{!selectedServer?.connected && <button className="text-button" onClick={connectServer} disabled={busy}></button>}
</div>
{!session.authenticated ? (
<div className="server-login-form">
<p className="sheet-label"></p>
<div className="login-fields">
<input value={username} onChange={event => setUsername(event.target.value)} placeholder="账号" autoComplete="username" />
<input type="password" value={password} onChange={event => setPassword(event.target.value)} placeholder="密码" autoComplete="current-password" onKeyDown={event => {
if (event.key === 'Enter') login();
}} />
<button className="secondary-button" onClick={login} disabled={busy || !username || !password}></button>
</div>
<small> HoloLake Git </small>
</div>
) : (
<div className="repository-picker">
<div className="repository-picker-heading">
<p className="sheet-label"></p>
<span> {session.username} <button className="inline-logout" onClick={logout} disabled={busy}>退</button></span>
</div>
<div className="repository-picker-row">
<select value={selectedRepository} onChange={event => setSelectedRepository(event.target.value)}>
<option value=""></option>
{repositories.map(repository => (
<option key={repository.fullName} value={repository.fullName}>{repository.fullName}</option>
))}
</select>
<button className="text-button" onClick={() => setCreating(!creating)}></button>
</div>
{creating && (
<div className="new-repository-row">
<input value={newRepository} onChange={event => setNewRepository(event.target.value)} placeholder="仓库名称" />
<button className="secondary-button" onClick={createRepository} disabled={busy}></button>
</div>
)}
</div>
)}
<p className="storage-privacy-note"></p>
</div>
)}
{message && <div className="sheet-message" role="status">{message}</div>}
<footer className="storage-sheet-footer">
<span className="storage-boundary-copy"></span>
<span />
<button className="secondary-button" onClick={onClose}></button>
<button className="primary-button" onClick={apply} disabled={busy || (mode === 'server' && !session.authenticated)}>
{busy ? '正在处理…' : '连接并继续'}
</button>
</footer>
</section>
</div>
);
}