feat(hololake): ship language platform 0.7.0
This commit is contained in:
parent
a360982a9b
commit
5105ec5e32
47 changed files with 4566 additions and 398 deletions
|
|
@ -29,11 +29,136 @@ const CLIENT_PORT = 5180;
|
|||
const DATA_DIR = path.join(app.getPath('userData'), 'data');
|
||||
const KB_REPO_PATH = path.join(DATA_DIR, 'knowledge-base');
|
||||
const MODEL_CONFIG_PATH = path.join(app.getPath('userData'), 'model-config.json');
|
||||
const SERVER_AUTH_PATH = path.join(app.getPath('userData'), 'server-auth.json');
|
||||
const SERVER_PROFILES_PATH = path.join(app.getPath('userData'), 'server-profiles.json');
|
||||
const GIT_ASKPASS_PATH = path.join(app.getPath('userData'), 'hololake-git-askpass.sh');
|
||||
interface ServerProfileDefinition {
|
||||
id: string;
|
||||
physicalNodeId: string;
|
||||
name: string;
|
||||
purpose: 'personal-fifth-domain' | 'enterprise-lighthouse';
|
||||
sshAlias: string;
|
||||
tunnelPort: number;
|
||||
remoteForgejoPort: number;
|
||||
lighthouseTunnelPort?: number;
|
||||
remoteLighthousePort?: number;
|
||||
channelTitle?: string;
|
||||
channelSubtitle?: string;
|
||||
}
|
||||
|
||||
const PUBLIC_SERVER_PROFILES: Record<string, ServerProfileDefinition> = {
|
||||
'AW-GZ-001': {
|
||||
id: 'AW-GZ-001',
|
||||
physicalNodeId: 'GH-CVM-MAIN-PROD-01',
|
||||
name: '企业灯塔服务器',
|
||||
purpose: 'enterprise-lighthouse',
|
||||
sshAlias: 'gh-enterprise-main',
|
||||
tunnelPort: 13341,
|
||||
remoteForgejoPort: 3341,
|
||||
lighthouseTunnelPort: 18031,
|
||||
remoteLighthousePort: 8031,
|
||||
},
|
||||
};
|
||||
|
||||
function readServerProfiles(): Record<string, ServerProfileDefinition> {
|
||||
const profiles = { ...PUBLIC_SERVER_PROFILES };
|
||||
try {
|
||||
const localProfiles = JSON.parse(fs.readFileSync(SERVER_PROFILES_PATH, 'utf8')) as ServerProfileDefinition[];
|
||||
if (!Array.isArray(localProfiles)) return profiles;
|
||||
for (const profile of localProfiles) {
|
||||
if (
|
||||
!profile || typeof profile.id !== 'string' || typeof profile.physicalNodeId !== 'string'
|
||||
|| typeof profile.name !== 'string' || profile.purpose !== 'personal-fifth-domain'
|
||||
|| typeof profile.sshAlias !== 'string' || !Number.isInteger(profile.tunnelPort)
|
||||
|| !Number.isInteger(profile.remoteForgejoPort)
|
||||
) continue;
|
||||
profiles[profile.id] = profile;
|
||||
}
|
||||
} catch {
|
||||
// 新安装默认只知道企业公共灯塔;个人节点由本机私有配置登记,不进入安装包。
|
||||
}
|
||||
return profiles;
|
||||
}
|
||||
|
||||
function getServerProfile(nodeId: string): ServerProfileDefinition {
|
||||
const profile = readServerProfiles()[nodeId];
|
||||
if (!profile) throw new Error('未登记的服务器节点');
|
||||
return profile;
|
||||
}
|
||||
|
||||
function defaultPersonalServerId(): string | undefined {
|
||||
return Object.values(readServerProfiles()).find(profile => profile.purpose === 'personal-fifth-domain')?.id;
|
||||
}
|
||||
|
||||
function forgejoBaseUrl(profile: ServerProfileDefinition): string {
|
||||
return `http://127.0.0.1:${profile.tunnelPort}`;
|
||||
}
|
||||
|
||||
function lighthouseBaseUrl(profile: ServerProfileDefinition): string {
|
||||
if (!profile.lighthouseTunnelPort) throw new Error('该节点未登记灯塔服务');
|
||||
return `http://127.0.0.1:${profile.lighthouseTunnelPort}`;
|
||||
}
|
||||
|
||||
interface ModelConfigFile {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
encryptedKey?: string;
|
||||
verifiedAt?: string;
|
||||
}
|
||||
|
||||
interface ServerAuthFile {
|
||||
nodeId: string;
|
||||
username: string;
|
||||
encryptedToken: string;
|
||||
}
|
||||
|
||||
function writeGitAskPass(): void {
|
||||
const content = `#!/bin/sh
|
||||
case "$1" in
|
||||
*Username*) printf '%s\\n' "$HOLOLAKE_FORGEJO_USERNAME" ;;
|
||||
*) printf '%s\\n' "$HOLOLAKE_FORGEJO_TOKEN" ;;
|
||||
esac
|
||||
`;
|
||||
fs.mkdirSync(path.dirname(GIT_ASKPASS_PATH), { recursive: true });
|
||||
fs.writeFileSync(GIT_ASKPASS_PATH, content, { mode: 0o700 });
|
||||
}
|
||||
|
||||
function clearGitCredentialEnvironment(): void {
|
||||
delete process.env.HOLOLAKE_FORGEJO_USERNAME;
|
||||
delete process.env.HOLOLAKE_FORGEJO_TOKEN;
|
||||
delete process.env.GIT_ASKPASS;
|
||||
delete process.env.GIT_TERMINAL_PROMPT;
|
||||
}
|
||||
|
||||
function applyStoredServerAuth(): { nodeId: string; username: string; token: string } | null {
|
||||
try {
|
||||
if (!safeStorage.isEncryptionAvailable()) return null;
|
||||
const stored = JSON.parse(fs.readFileSync(SERVER_AUTH_PATH, 'utf8')) as ServerAuthFile;
|
||||
getServerProfile(stored.nodeId);
|
||||
if (!stored.encryptedToken) return null;
|
||||
const token = safeStorage.decryptString(Buffer.from(stored.encryptedToken, 'base64'));
|
||||
writeGitAskPass();
|
||||
process.env.HOLOLAKE_FORGEJO_USERNAME = stored.username;
|
||||
process.env.HOLOLAKE_FORGEJO_TOKEN = token;
|
||||
process.env.GIT_ASKPASS = GIT_ASKPASS_PATH;
|
||||
process.env.GIT_TERMINAL_PROMPT = '0';
|
||||
return { nodeId: stored.nodeId, username: stored.username, token };
|
||||
} catch {
|
||||
clearGitCredentialEnvironment();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveServerAuth(nodeId: string, username: string, token: string): void {
|
||||
if (!safeStorage.isEncryptionAvailable()) throw new Error('macOS 加密存储当前不可用');
|
||||
fs.mkdirSync(path.dirname(SERVER_AUTH_PATH), { recursive: true });
|
||||
const encryptedToken = safeStorage.encryptString(token).toString('base64');
|
||||
fs.writeFileSync(
|
||||
SERVER_AUTH_PATH,
|
||||
JSON.stringify({ nodeId, username, encryptedToken } satisfies ServerAuthFile),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
applyStoredServerAuth();
|
||||
}
|
||||
|
||||
function readModelConfig(): ModelConfigFile {
|
||||
|
|
@ -50,13 +175,140 @@ function applyModelConfig(): ModelConfigFile {
|
|||
process.env.HOLOLAKE_LLM_MODEL = config.model;
|
||||
if (config.encryptedKey && safeStorage.isEncryptionAvailable()) {
|
||||
process.env.HOLOLAKE_LLM_KEY = safeStorage.decryptString(Buffer.from(config.encryptedKey, 'base64'));
|
||||
} else {
|
||||
delete process.env.HOLOLAKE_LLM_KEY;
|
||||
}
|
||||
process.env.HOLOLAKE_LLM_VERIFIED = config.verifiedAt ? '1' : '0';
|
||||
return config;
|
||||
}
|
||||
|
||||
async function verifyModelConfig(): Promise<{ verifiedAt: string }> {
|
||||
const config = readModelConfig();
|
||||
if (!config.encryptedKey || !safeStorage.isEncryptionAvailable()) throw new Error('请先保存模型密钥');
|
||||
const apiKey = safeStorage.decryptString(Buffer.from(config.encryptedKey, 'base64'));
|
||||
const response = await fetch(`${config.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify({
|
||||
model: config.model,
|
||||
messages: [
|
||||
{ role: 'system', content: 'This is a HoloLake model connectivity check. Reply with OK.' },
|
||||
{ role: 'user', content: 'ping' },
|
||||
],
|
||||
max_tokens: 8,
|
||||
temperature: 0,
|
||||
}),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
});
|
||||
const data = await response.json().catch(() => null) as any;
|
||||
if (!response.ok) throw new Error(data?.error?.message || `模型服务返回 HTTP ${response.status}`);
|
||||
if (!data?.choices?.[0]?.message) throw new Error('模型服务没有返回兼容的响应');
|
||||
const verifiedAt = new Date().toISOString();
|
||||
fs.writeFileSync(MODEL_CONFIG_PATH, JSON.stringify({ ...config, verifiedAt }), { mode: 0o600 });
|
||||
applyModelConfig();
|
||||
return { verifiedAt };
|
||||
}
|
||||
|
||||
// ─── 后端服务器 ───
|
||||
|
||||
let serverProcess: ChildProcess | null = null;
|
||||
const serverTunnels = new Map<string, ChildProcess>();
|
||||
|
||||
async function isServerTunnelReady(profile: ServerProfileDefinition): Promise<boolean> {
|
||||
try {
|
||||
const checks: Promise<Response>[] = [
|
||||
fetch(`${forgejoBaseUrl(profile)}/user/login`, { signal: AbortSignal.timeout(1200) }),
|
||||
];
|
||||
if (profile.lighthouseTunnelPort) {
|
||||
checks.push(fetch(`${lighthouseBaseUrl(profile)}/health`, { signal: AbortSignal.timeout(1200) }));
|
||||
}
|
||||
const responses = await Promise.all(checks);
|
||||
return responses.every(response => response.status === 200);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureServerTunnel(profile: ServerProfileDefinition): Promise<void> {
|
||||
if (await isServerTunnelReady(profile)) return;
|
||||
const currentTunnel = serverTunnels.get(profile.id);
|
||||
if (currentTunnel && currentTunnel.exitCode === null) {
|
||||
throw new Error(`${profile.name}连接正在建立,请稍后重试`);
|
||||
}
|
||||
|
||||
const forwards = [
|
||||
'-L', `127.0.0.1:${profile.tunnelPort}:127.0.0.1:${profile.remoteForgejoPort}`,
|
||||
];
|
||||
if (profile.lighthouseTunnelPort && profile.remoteLighthousePort) {
|
||||
forwards.push('-L', `127.0.0.1:${profile.lighthouseTunnelPort}:127.0.0.1:${profile.remoteLighthousePort}`);
|
||||
}
|
||||
const tunnel = spawn('/usr/bin/ssh', [
|
||||
'-N',
|
||||
...forwards,
|
||||
'-o', 'BatchMode=yes',
|
||||
'-o', 'ExitOnForwardFailure=yes',
|
||||
'-o', 'ServerAliveInterval=30',
|
||||
'-o', 'ServerAliveCountMax=3',
|
||||
profile.sshAlias,
|
||||
], {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
|
||||
serverTunnels.set(profile.id, tunnel);
|
||||
tunnel.once('exit', () => {
|
||||
serverTunnels.delete(profile.id);
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
if (await isServerTunnelReady(profile)) return;
|
||||
if (tunnel.exitCode !== null) break;
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
}
|
||||
tunnel.kill('SIGTERM');
|
||||
serverTunnels.delete(profile.id);
|
||||
throw new Error(`无法通过本机登记密钥连接${profile.name}`);
|
||||
}
|
||||
|
||||
function stopServerTunnels(): void {
|
||||
for (const tunnel of serverTunnels.values()) tunnel.kill('SIGTERM');
|
||||
serverTunnels.clear();
|
||||
}
|
||||
|
||||
async function detachKnowledgeRemote(): Promise<void> {
|
||||
try {
|
||||
await fetch(`http://127.0.0.1:${SERVER_PORT}/api/forgejo/remote`, { method: 'DELETE' });
|
||||
} catch {
|
||||
// 本地知识库服务不可用时不阻断账号退出;下次登录仍会重新校验仓库权限。
|
||||
}
|
||||
}
|
||||
|
||||
async function forgejoRequest(
|
||||
nodeId: string,
|
||||
route: string,
|
||||
init: RequestInit = {},
|
||||
auth?: { username?: string; password?: string; token?: string },
|
||||
): Promise<{ response: Response; data: any }> {
|
||||
const profile = getServerProfile(nodeId);
|
||||
await ensureServerTunnel(profile);
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set('Accept', 'application/json');
|
||||
if (init.body) headers.set('Content-Type', 'application/json');
|
||||
if (auth?.token) headers.set('Authorization', `token ${auth.token}`);
|
||||
if (auth?.username && auth.password) {
|
||||
headers.set('Authorization', `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}`);
|
||||
}
|
||||
const response = await fetch(`${forgejoBaseUrl(profile)}${route}`, {
|
||||
...init,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
const text = await response.text();
|
||||
let data: any = null;
|
||||
if (text) {
|
||||
try { data = JSON.parse(text); } catch { data = { message: text }; }
|
||||
}
|
||||
return { response, data };
|
||||
}
|
||||
|
||||
function startServer(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
@ -119,6 +371,21 @@ function stopServer(): void {
|
|||
}
|
||||
}
|
||||
|
||||
async function waitForServerReady(): Promise<void> {
|
||||
for (let attempt = 0; attempt < 40; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${SERVER_PORT}/api/health`, {
|
||||
signal: AbortSignal.timeout(800),
|
||||
});
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
// 服务仍在启动,继续读取真实健康端点,不提前打开一个离线界面。
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error('HoloLake 本地服务未在规定时间内就绪');
|
||||
}
|
||||
|
||||
// ─── 窗口 ───
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
|
@ -131,7 +398,7 @@ function createWindow(): void {
|
|||
minHeight: 600,
|
||||
titleBarStyle: 'hiddenInset',
|
||||
trafficLightPosition: { x: 16, y: 16 },
|
||||
backgroundColor: '#f7f8fb',
|
||||
backgroundColor: '#07111d',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
|
|
@ -159,11 +426,14 @@ function createWindow(): void {
|
|||
|
||||
ipcMain.handle('get-data-path', () => DATA_DIR);
|
||||
ipcMain.handle('knowledge:import-folder', async () => {
|
||||
const selection = await dialog.showOpenDialog(mainWindow ?? undefined, {
|
||||
const options: Electron.OpenDialogOptions = {
|
||||
title: '导入本地知识文件夹',
|
||||
buttonLabel: '导入到 HoloLake',
|
||||
properties: ['openDirectory'],
|
||||
});
|
||||
};
|
||||
const selection = mainWindow
|
||||
? await dialog.showOpenDialog(mainWindow, options)
|
||||
: await dialog.showOpenDialog(options);
|
||||
if (selection.canceled || !selection.filePaths[0]) {
|
||||
return { cancelled: true, imported: 0, assets: 0, skipped: 0, failed: [] };
|
||||
}
|
||||
|
|
@ -175,6 +445,8 @@ ipcMain.handle('agent:get-config', () => {
|
|||
baseUrl: config.baseUrl,
|
||||
model: config.model,
|
||||
configured: Boolean(config.encryptedKey && safeStorage.isEncryptionAvailable()),
|
||||
operational: Boolean(config.encryptedKey && config.verifiedAt && safeStorage.isEncryptionAvailable()),
|
||||
verifiedAt: config.verifiedAt,
|
||||
};
|
||||
});
|
||||
ipcMain.handle('agent:save-config', (_event, input: { baseUrl: string; model: string; apiKey?: string }) => {
|
||||
|
|
@ -188,10 +460,207 @@ ipcMain.handle('agent:save-config', (_event, input: { baseUrl: string; model: st
|
|||
? safeStorage.encryptString(String(input.apiKey)).toString('base64')
|
||||
: previous.encryptedKey;
|
||||
if (!encryptedKey) throw new Error('请填写模型密钥');
|
||||
const verificationStillValid = !input.apiKey && previous.baseUrl === baseUrl && previous.model === model;
|
||||
fs.mkdirSync(path.dirname(MODEL_CONFIG_PATH), { recursive: true });
|
||||
fs.writeFileSync(MODEL_CONFIG_PATH, JSON.stringify({ baseUrl, model, encryptedKey }), { mode: 0o600 });
|
||||
fs.writeFileSync(MODEL_CONFIG_PATH, JSON.stringify({
|
||||
baseUrl,
|
||||
model,
|
||||
encryptedKey,
|
||||
verifiedAt: verificationStillValid ? previous.verifiedAt : undefined,
|
||||
}), { mode: 0o600 });
|
||||
applyModelConfig();
|
||||
return { baseUrl, model, configured: true };
|
||||
return { baseUrl, model, configured: true, operational: verificationStillValid && Boolean(previous.verifiedAt) };
|
||||
});
|
||||
ipcMain.handle('agent:test-config', () => verifyModelConfig());
|
||||
ipcMain.handle('server:list', async () => {
|
||||
return Promise.all(Object.values(readServerProfiles()).map(async profile => ({
|
||||
id: profile.id,
|
||||
physicalNodeId: profile.physicalNodeId,
|
||||
name: profile.name,
|
||||
purpose: profile.purpose,
|
||||
channelTitle: profile.channelTitle,
|
||||
channelSubtitle: profile.channelSubtitle,
|
||||
connected: await isServerTunnelReady(profile),
|
||||
verified: true,
|
||||
})));
|
||||
});
|
||||
ipcMain.handle('server:connect', async (_event, nodeId: string) => {
|
||||
const profile = getServerProfile(nodeId);
|
||||
await ensureServerTunnel(profile);
|
||||
return {
|
||||
id: profile.id,
|
||||
physicalNodeId: profile.physicalNodeId,
|
||||
name: profile.name,
|
||||
purpose: profile.purpose,
|
||||
channelTitle: profile.channelTitle,
|
||||
channelSubtitle: profile.channelSubtitle,
|
||||
connected: true,
|
||||
verified: true,
|
||||
};
|
||||
});
|
||||
ipcMain.handle('server:domain-registry', async () => {
|
||||
const profile = getServerProfile('AW-GZ-001');
|
||||
try {
|
||||
await ensureServerTunnel(profile);
|
||||
} catch {
|
||||
// 公共灯塔不可达时返回真实离线状态,界面仍可浏览已登记的四域结构。
|
||||
}
|
||||
const connected = await isServerTunnelReady(profile);
|
||||
let lighthouseStatus: any = null;
|
||||
let lighthouseHealth: any = null;
|
||||
if (connected) {
|
||||
const [statusResponse, healthResponse] = await Promise.all([
|
||||
fetch(`${lighthouseBaseUrl(profile)}/v1/status`, { signal: AbortSignal.timeout(3000) }),
|
||||
fetch(`${lighthouseBaseUrl(profile)}/health`, { signal: AbortSignal.timeout(3000) }),
|
||||
]);
|
||||
if (statusResponse.ok) lighthouseStatus = await statusResponse.json();
|
||||
if (healthResponse.ok) lighthouseHealth = await healthResponse.json();
|
||||
}
|
||||
const liveDomains = new Map((lighthouseStatus?.domains || []).map((domain: any) => [domain.id, domain]));
|
||||
const domain = (serverId: string, fallback: Record<string, unknown>) => ({
|
||||
...fallback,
|
||||
serverId,
|
||||
live: liveDomains.get(serverId) || null,
|
||||
});
|
||||
return {
|
||||
nodeId: profile.id,
|
||||
physicalNodeId: profile.physicalNodeId,
|
||||
connected,
|
||||
verified: Boolean(lighthouseStatus && lighthouseHealth),
|
||||
codeChannel: connected ? 'reachable' : 'not-connected',
|
||||
lighthouse: lighthouseHealth ? {
|
||||
mode: lighthouseHealth.mode,
|
||||
mapHash: lighthouseHealth.map_hash,
|
||||
execution: lighthouseHealth.execution,
|
||||
hostState: lighthouseStatus?.host_state,
|
||||
observedAt: lighthouseStatus?.observed_at,
|
||||
} : null,
|
||||
domains: [
|
||||
domain('DOMAIN-MAIN', { id: 'main', number: 'HLDP-DOMAIN-MAIN-001', name: '光湖主域', responsibility: '公共定义、版本发布、广播与模块生态状态', repository: 'domain-main' }),
|
||||
domain('DOMAIN-SUB', { id: 'sub', number: 'HLDP-DOMAIN-SUB-001', name: '光湖分域', responsibility: '行业入口、模块目录与初始化频道路由', repository: 'domain-sub' }),
|
||||
domain('DOMAIN-ZERO', { id: 'zero', number: 'HLDP-DOMAIN-ZERO-001', name: '光湖零域', responsibility: '模块试装、实验、质量验证与可回滚预览', repository: 'domain-zero' }),
|
||||
domain('DOMAIN-ZS', { id: 'zero-sense', number: 'HLDP-DOMAIN-ZEROSENSE-001', name: '光湖零感域', responsibility: '团队身份、责任、权限、审计与个人节点发现', repository: 'domain-zero-sense' }),
|
||||
],
|
||||
};
|
||||
});
|
||||
ipcMain.handle('server:session', async (_event, requestedNodeId?: string) => {
|
||||
const stored = applyStoredServerAuth();
|
||||
const profiles = readServerProfiles();
|
||||
const nodeId = requestedNodeId && profiles[requestedNodeId]
|
||||
? requestedNodeId
|
||||
: stored?.nodeId || defaultPersonalServerId() || '';
|
||||
if (!stored || stored.nodeId !== nodeId) return { authenticated: false, nodeId };
|
||||
try {
|
||||
const { response, data } = await forgejoRequest(nodeId, '/api/v1/user', {}, { token: stored.token });
|
||||
if (!response.ok) {
|
||||
fs.rmSync(SERVER_AUTH_PATH, { force: true });
|
||||
clearGitCredentialEnvironment();
|
||||
return { authenticated: false, nodeId };
|
||||
}
|
||||
return { authenticated: true, nodeId, username: data.login };
|
||||
} catch {
|
||||
return { authenticated: false, nodeId };
|
||||
}
|
||||
});
|
||||
ipcMain.handle('server:login', async (_event, input: { nodeId: string; username: string; password: string }) => {
|
||||
const username = String(input.username || '').trim();
|
||||
const password = String(input.password || '');
|
||||
getServerProfile(input.nodeId);
|
||||
if (!username || !password) throw new Error('请输入账号和密码');
|
||||
|
||||
const basicAuth = { username, password };
|
||||
const identity = await forgejoRequest(input.nodeId, '/api/v1/user', {}, basicAuth);
|
||||
if (!identity.response.ok) throw new Error('账号或密码不正确');
|
||||
|
||||
const tokenName = 'hololake-desktop';
|
||||
const existing = await forgejoRequest(input.nodeId, `/api/v1/users/${encodeURIComponent(username)}/tokens`, {}, basicAuth);
|
||||
if (existing.response.ok && Array.isArray(existing.data)) {
|
||||
for (const token of existing.data) {
|
||||
if (token?.name === tokenName && token?.id) {
|
||||
await forgejoRequest(
|
||||
input.nodeId,
|
||||
`/api/v1/users/${encodeURIComponent(username)}/tokens/${token.id}`,
|
||||
{ method: 'DELETE' },
|
||||
basicAuth,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const created = await forgejoRequest(
|
||||
input.nodeId,
|
||||
`/api/v1/users/${encodeURIComponent(username)}/tokens`,
|
||||
{ method: 'POST', body: JSON.stringify({ name: tokenName, scopes: ['all'] }) },
|
||||
basicAuth,
|
||||
);
|
||||
if (created.response.status !== 201 || !created.data?.sha1) {
|
||||
throw new Error(created.data?.message || '无法建立 HoloLake 登录令牌');
|
||||
}
|
||||
const previous = applyStoredServerAuth();
|
||||
if (!previous || previous.nodeId !== input.nodeId || previous.username !== identity.data.login) {
|
||||
await detachKnowledgeRemote();
|
||||
}
|
||||
saveServerAuth(input.nodeId, username, created.data.sha1);
|
||||
return { authenticated: true, nodeId: input.nodeId, username: identity.data.login };
|
||||
});
|
||||
ipcMain.handle('server:logout', async () => {
|
||||
const stored = applyStoredServerAuth();
|
||||
await detachKnowledgeRemote();
|
||||
fs.rmSync(SERVER_AUTH_PATH, { force: true });
|
||||
clearGitCredentialEnvironment();
|
||||
return { authenticated: false, nodeId: stored?.nodeId || defaultPersonalServerId() || '' };
|
||||
});
|
||||
ipcMain.handle('server:repositories', async () => {
|
||||
const stored = applyStoredServerAuth();
|
||||
if (!stored) throw new Error('请先登录服务器');
|
||||
const { response, data } = await forgejoRequest(stored.nodeId, '/api/v1/user/repos?limit=100', {}, { token: stored.token });
|
||||
if (!response.ok || !Array.isArray(data)) throw new Error(data?.message || '读取服务器仓库失败');
|
||||
return data.map(repo => ({
|
||||
name: repo.name,
|
||||
fullName: repo.full_name,
|
||||
private: repo.private,
|
||||
defaultBranch: repo.default_branch || 'main',
|
||||
}));
|
||||
});
|
||||
ipcMain.handle('server:create-repository', async (_event, input: { name: string; description?: string }) => {
|
||||
const stored = applyStoredServerAuth();
|
||||
if (!stored) throw new Error('请先登录服务器');
|
||||
const name = String(input.name || '').trim();
|
||||
if (!/^[A-Za-z0-9._-]{1,100}$/.test(name)) throw new Error('仓库名称只能使用字母、数字、点、短横线或下划线');
|
||||
const { response, data } = await forgejoRequest(stored.nodeId, '/api/v1/user/repos', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description: String(input.description || ''),
|
||||
private: true,
|
||||
auto_init: true,
|
||||
default_branch: 'main',
|
||||
}),
|
||||
}, { token: stored.token });
|
||||
if (response.status !== 201) throw new Error(data?.message || '创建仓库失败');
|
||||
return {
|
||||
name: data.name,
|
||||
fullName: data.full_name,
|
||||
private: data.private,
|
||||
defaultBranch: data.default_branch || 'main',
|
||||
};
|
||||
});
|
||||
ipcMain.handle('server:git-remote', async (_event, fullName: string) => {
|
||||
if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(String(fullName || ''))) {
|
||||
throw new Error('仓库路径无效');
|
||||
}
|
||||
const stored = applyStoredServerAuth();
|
||||
if (!stored) throw new Error('请先登录服务器');
|
||||
const [owner, repository] = String(fullName).split('/');
|
||||
const access = await forgejoRequest(
|
||||
stored.nodeId,
|
||||
`/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repository)}`,
|
||||
{},
|
||||
{ token: stored.token },
|
||||
);
|
||||
if (!access.response.ok || access.data?.full_name !== fullName) {
|
||||
throw new Error('当前账号无权访问该仓库');
|
||||
}
|
||||
return `${forgejoBaseUrl(getServerProfile(stored.nodeId))}/${fullName}.git`;
|
||||
});
|
||||
|
||||
// ─── 应用生命周期 ───
|
||||
|
|
@ -200,6 +669,7 @@ app.whenReady().then(async () => {
|
|||
try {
|
||||
applyModelConfig();
|
||||
await startServer();
|
||||
await waitForServerReady();
|
||||
createWindow();
|
||||
} catch (err) {
|
||||
dialog.showErrorBox('启动失败', `知识库引擎启动失败:${err}`);
|
||||
|
|
@ -218,4 +688,5 @@ app.on('window-all-closed', () => {
|
|||
|
||||
app.on('before-quit', () => {
|
||||
stopServer();
|
||||
stopServerTunnels();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import { contextBridge, ipcRenderer } from 'electron';
|
|||
contextBridge.exposeInMainWorld('hololake', {
|
||||
// 应用信息
|
||||
platform: process.platform,
|
||||
version: '0.5.1',
|
||||
version: '0.7.0',
|
||||
|
||||
// 数据目录
|
||||
getDataPath: () => ipcRenderer.invoke('get-data-path'),
|
||||
|
|
@ -24,6 +24,21 @@ contextBridge.exposeInMainWorld('hololake', {
|
|||
getConfig: () => ipcRenderer.invoke('agent:get-config'),
|
||||
saveConfig: (config: { baseUrl: string; model: string; apiKey?: string }) =>
|
||||
ipcRenderer.invoke('agent:save-config', config),
|
||||
testConfig: () => ipcRenderer.invoke('agent:test-config'),
|
||||
},
|
||||
|
||||
server: {
|
||||
list: () => ipcRenderer.invoke('server:list'),
|
||||
connect: (nodeId: string) => ipcRenderer.invoke('server:connect', nodeId),
|
||||
domainRegistry: () => ipcRenderer.invoke('server:domain-registry'),
|
||||
session: (nodeId?: string) => ipcRenderer.invoke('server:session', nodeId),
|
||||
login: (input: { nodeId: string; username: string; password: string }) =>
|
||||
ipcRenderer.invoke('server:login', input),
|
||||
logout: () => ipcRenderer.invoke('server:logout'),
|
||||
repositories: () => ipcRenderer.invoke('server:repositories'),
|
||||
createRepository: (input: { name: string; description?: string }) =>
|
||||
ipcRenderer.invoke('server:create-repository', input),
|
||||
gitRemote: (fullName: string) => ipcRenderer.invoke('server:git-remote', fullName),
|
||||
},
|
||||
|
||||
// Forgejo 通过本地知识库 API 管理;此桥只保留非敏感应用信息。
|
||||
|
|
|
|||
Loading…
Reference in a new issue