Files
makelore/src/lib/host-api.ts
brother7 3d9dd14918 feat: 统一 AI 设计 Workspace 与生成任务链路
需求:以设计项目组织固定设计 Agent 对话、方向确认和图片视频任务。

实现:新增 Works Square 云端适配与开发态本地适配,统一 Host API、Quote 确认、任务轮询及私有媒体 Range 代理。
2026-07-31 13:56:55 +08:00

279 lines
7.7 KiB
TypeScript

import { invokeIpc } from '@/lib/api-client';
import { trackUiEvent } from './telemetry';
import { normalizeAppError } from './error-model';
const DEFAULT_HOST_API_PORT = 13210;
const DEFAULT_HOST_API_BASE = `http://127.0.0.1:${DEFAULT_HOST_API_PORT}`;
/** Cached Host API auth token, fetched once from the main process via IPC. */
let cachedHostApiToken: string | null = null;
let cachedHostApiBase = DEFAULT_HOST_API_BASE;
let hostApiBaseResolved = false;
async function getHostApiToken(): Promise<string> {
if (cachedHostApiToken) return cachedHostApiToken;
try {
cachedHostApiToken = await invokeIpc<string>('hostapi:token');
} catch {
cachedHostApiToken = '';
}
return cachedHostApiToken ?? '';
}
export async function ensureHostApiToken(): Promise<string> {
const [token] = await Promise.all([
getHostApiToken(),
getHostApiBaseUrl(),
]);
return token;
}
async function getHostApiBaseUrl(): Promise<string> {
if (hostApiBaseResolved) return cachedHostApiBase;
try {
const value = await invokeIpc<string>('hostapi:base-url');
if (typeof value === 'string' && value.trim()) {
cachedHostApiBase = value.replace(/\/+$/, '');
}
} catch {
cachedHostApiBase = DEFAULT_HOST_API_BASE;
}
hostApiBaseResolved = true;
return cachedHostApiBase;
}
type HostApiProxyResponse = {
ok?: boolean;
data?: {
status?: number;
ok?: boolean;
json?: unknown;
text?: string;
};
error?: { message?: string } | string;
// backward compatibility fields
success: boolean;
status?: number;
json?: unknown;
text?: string;
};
type HostApiProxyData = {
status?: number;
ok?: boolean;
json?: unknown;
text?: string;
};
function headersToRecord(headers?: HeadersInit): Record<string, string> {
if (!headers) return {};
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
if (Array.isArray(headers)) return Object.fromEntries(headers);
return { ...headers };
}
async function parseResponse<T>(response: Response): Promise<T> {
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
try {
const payload = await response.json() as { error?: string };
if (payload?.error) {
message = payload.error;
}
} catch {
// ignore body parse failure
}
throw normalizeAppError(new Error(message), {
source: 'browser-fallback',
status: response.status,
});
}
if (response.status === 204) {
return undefined as T;
}
return await response.json() as T;
}
function resolveProxyErrorMessage(error: HostApiProxyResponse['error']): string {
return typeof error === 'string'
? error
: (error?.message || 'Host API proxy request failed');
}
function createProxyHttpError(data: HostApiProxyData): Error {
const payload = typeof data.json === 'object' && data.json != null
? data.json as Record<string, unknown>
: null;
const message = data.text
|| (payload && 'error' in payload
? String(payload.error)
: `HTTP ${data.status ?? 'unknown'}`);
return normalizeAppError(new Error(message), {
status: data.status,
...(payload && typeof payload.code === 'string' ? { backendCode: payload.code } : {}),
});
}
function parseUnifiedProxyResponse<T>(
response: HostApiProxyResponse,
path: string,
method: string,
startedAt: number,
): T {
if (!response.ok) {
throw new Error(resolveProxyErrorMessage(response.error));
}
const data: HostApiProxyData = response.data ?? {};
if (data.ok === false || (typeof data.status === 'number' && data.status >= 400)) {
throw createProxyHttpError(data);
}
trackUiEvent('hostapi.fetch', {
path,
method,
source: 'ipc-proxy',
durationMs: Date.now() - startedAt,
status: data.status ?? 200,
});
if (data.status === 204) return undefined as T;
if (data.json !== undefined) return data.json as T;
return data.text as T;
}
function parseLegacyProxyResponse<T>(
response: HostApiProxyResponse,
path: string,
method: string,
startedAt: number,
): T {
if (!response.success) {
throw new Error(resolveProxyErrorMessage(response.error));
}
if (!response.ok) {
throw createProxyHttpError({
status: response.status,
ok: response.ok,
json: response.json,
text: response.text,
});
}
trackUiEvent('hostapi.fetch', {
path,
method,
source: 'ipc-proxy-legacy',
durationMs: Date.now() - startedAt,
status: response.status ?? 200,
});
if (response.status === 204) return undefined as T;
if (response.json !== undefined) return response.json as T;
return response.text as T;
}
function shouldFallbackToBrowser(message: string): boolean {
const normalized = message.toLowerCase();
return normalized.includes('invalid ipc channel: hostapi:fetch')
|| normalized.includes("no handler registered for 'hostapi:fetch'")
|| normalized.includes('no handler registered for "hostapi:fetch"')
|| normalized.includes('no handler registered for hostapi:fetch')
|| normalized.includes('window is not defined');
}
function allowLocalhostFallback(): boolean {
try {
return window.localStorage.getItem('niancode:allow-localhost-fallback') === '1';
} catch {
return false;
}
}
export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise<T> {
const startedAt = Date.now();
const method = init?.method || 'GET';
// In Electron renderer, always proxy through main process to avoid CORS.
try {
const response = await invokeIpc<HostApiProxyResponse>('hostapi:fetch', {
path,
method,
headers: headersToRecord(init?.headers),
body: init?.body ?? null,
});
if (typeof response?.ok === 'boolean' && 'data' in response) {
return parseUnifiedProxyResponse<T>(response, path, method, startedAt);
}
return parseLegacyProxyResponse<T>(response, path, method, startedAt);
} catch (error) {
const normalized = normalizeAppError(error, { source: 'ipc-proxy', path, method });
const message = normalized.message;
trackUiEvent('hostapi.fetch_error', {
path,
method,
source: 'ipc-proxy',
durationMs: Date.now() - startedAt,
message,
code: normalized.code,
});
if (!shouldFallbackToBrowser(message)) {
throw normalized;
}
if (!allowLocalhostFallback()) {
trackUiEvent('hostapi.fetch_error', {
path,
method,
source: 'ipc-proxy',
durationMs: Date.now() - startedAt,
message: 'localhost fallback blocked by policy',
code: 'CHANNEL_UNAVAILABLE',
});
throw normalized;
}
}
// Browser-only fallback (non-Electron environments).
const [token, hostApiBase] = await Promise.all([
getHostApiToken(),
getHostApiBaseUrl(),
]);
const response = await fetch(`${hostApiBase}${path}`, {
...init,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
...(init?.headers || {}),
},
});
trackUiEvent('hostapi.fetch', {
path,
method,
source: 'browser-fallback',
durationMs: Date.now() - startedAt,
status: response.status,
});
try {
return await parseResponse<T>(response);
} catch (error) {
throw normalizeAppError(error, { source: 'browser-fallback', path, method });
}
}
export function createHostEventSource(path = '/api/events'): EventSource {
// EventSource does not support custom headers, so pass the auth token
// as a query parameter. The server accepts both mechanisms.
const separator = path.includes('?') ? '&' : '?';
const tokenParam = `token=${encodeURIComponent(cachedHostApiToken ?? '')}`;
return new EventSource(`${cachedHostApiBase}${path}${separator}${tokenParam}`);
}
export function getHostApiBase(): string {
return cachedHostApiBase;
}