Initial commit: OmniAI Web Frontend
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
import type { WebUserSession } from "../types";
|
||||
|
||||
export const DEFAULT_SERVER_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
|
||||
export const SERVER_SESSION_STORAGE_KEY = "omniai-web-session";
|
||||
export const SERVER_SESSION_REPLACED_EVENT = "omniai:session-replaced";
|
||||
export const SERVER_SESSION_EXPIRED_EVENT = "omniai:session-expired";
|
||||
|
||||
export type ServerConnectionState = "checking" | "connected" | "degraded";
|
||||
|
||||
export interface ServerConnectionHealth {
|
||||
state: ServerConnectionState;
|
||||
baseUrl: string;
|
||||
checkedAt: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface ServerRequestOptions {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
body?: unknown;
|
||||
token?: string;
|
||||
headers?: Record<string, string>;
|
||||
raw?: boolean;
|
||||
signal?: AbortSignal;
|
||||
/** Per-request timeout in ms. Defaults to DEFAULT_REQUEST_TIMEOUT_MS. Pass 0 to disable. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
|
||||
|
||||
export interface ServerSessionReplacedDetail {
|
||||
status?: number;
|
||||
code?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class ServerRequestError extends Error {
|
||||
status?: number;
|
||||
code?: string;
|
||||
payload?: unknown;
|
||||
|
||||
constructor(message: string, status?: number, payload?: unknown) {
|
||||
super(message);
|
||||
this.name = "ServerRequestError";
|
||||
this.status = status;
|
||||
this.payload = payload;
|
||||
|
||||
if (isRecord(payload) && typeof payload.code === "string") {
|
||||
this.code = payload.code;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function compactMessage(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim().slice(0, 240);
|
||||
}
|
||||
|
||||
export function getServerBaseUrl(): string {
|
||||
const envBaseUrl = String(
|
||||
import.meta.env.VITE_KEY_SERVER_URL ||
|
||||
import.meta.env.VITE_SERVER_BASE_URL ||
|
||||
import.meta.env.VITE_API_BASE_URL ||
|
||||
"",
|
||||
).trim();
|
||||
const shouldUseSameOriginApi =
|
||||
typeof window !== "undefined" &&
|
||||
(window.location.protocol === "https:" ||
|
||||
window.location.hostname === "omniai.net.cn" ||
|
||||
window.location.hostname === "www.omniai.net.cn");
|
||||
const rawBaseUrl = envBaseUrl || (shouldUseSameOriginApi ? "" : DEFAULT_SERVER_BASE_URL);
|
||||
if (!rawBaseUrl || rawBaseUrl.replace(/\/+$/, "").toLowerCase() === "/api") {
|
||||
return "";
|
||||
}
|
||||
return rawBaseUrl.replace(/\/+$/, "").replace(/\/api$/i, "");
|
||||
}
|
||||
|
||||
export function buildApiUrl(path: string): string {
|
||||
const cleanPath = path.replace(/^\/+/, "");
|
||||
const baseUrl = getServerBaseUrl();
|
||||
if (!baseUrl) return `/api/${cleanPath}`;
|
||||
|
||||
try {
|
||||
return new URL(`api/${cleanPath}`, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`).toString();
|
||||
} catch {
|
||||
return `${baseUrl}/api/${cleanPath}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function canUseSessionStorage(): boolean {
|
||||
return typeof window !== "undefined" && typeof window.sessionStorage !== "undefined";
|
||||
}
|
||||
|
||||
function canUseLocalStorage(): boolean {
|
||||
return typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||
}
|
||||
|
||||
function parseStoredSession(raw: string | null): WebUserSession | null {
|
||||
if (!raw) return null;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return isRecord(parsed) && typeof parsed.token === "string" && isRecord(parsed.user)
|
||||
? (parsed as unknown as WebUserSession)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readStoredSession(): WebUserSession | null {
|
||||
let fallbackSession: WebUserSession | null = null;
|
||||
|
||||
try {
|
||||
if (canUseLocalStorage()) {
|
||||
const localSession = parseStoredSession(window.localStorage.getItem(SERVER_SESSION_STORAGE_KEY));
|
||||
if (localSession) return localSession;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the legacy session-scoped copy.
|
||||
}
|
||||
|
||||
try {
|
||||
if (canUseSessionStorage()) {
|
||||
fallbackSession = parseStoredSession(window.sessionStorage.getItem(SERVER_SESSION_STORAGE_KEY));
|
||||
}
|
||||
} catch {
|
||||
fallbackSession = null;
|
||||
}
|
||||
|
||||
if (fallbackSession && canUseLocalStorage()) {
|
||||
try {
|
||||
window.localStorage.setItem(SERVER_SESSION_STORAGE_KEY, JSON.stringify(fallbackSession));
|
||||
} catch {
|
||||
// Migrating the legacy session is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
return fallbackSession;
|
||||
}
|
||||
|
||||
export function writeStoredSession(session: WebUserSession | null): void {
|
||||
try {
|
||||
if (canUseLocalStorage()) {
|
||||
if (session) {
|
||||
window.localStorage.setItem(SERVER_SESSION_STORAGE_KEY, JSON.stringify(session));
|
||||
} else {
|
||||
window.localStorage.removeItem(SERVER_SESSION_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Browser persistence is a convenience layer, not a hard dependency.
|
||||
}
|
||||
|
||||
try {
|
||||
if (canUseSessionStorage()) {
|
||||
if (session) {
|
||||
window.sessionStorage.setItem(SERVER_SESSION_STORAGE_KEY, JSON.stringify(session));
|
||||
} else {
|
||||
window.sessionStorage.removeItem(SERVER_SESSION_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep the local copy as the primary persistence layer.
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredToken(): string | null {
|
||||
return readStoredSession()?.token ?? null;
|
||||
}
|
||||
|
||||
export function buildAuthHeaders(tokenOverride?: string, extraHeaders?: Record<string, string>): Record<string, string> {
|
||||
const token = (tokenOverride ?? getStoredToken() ?? "").trim();
|
||||
return {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(extraHeaders || {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseResponseBody(text: string): unknown {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(trimmed);
|
||||
} catch {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
export function unwrapApiPayload(payload: unknown): unknown {
|
||||
if (!isRecord(payload)) return payload;
|
||||
|
||||
const nested = payload.data ?? payload.result ?? payload.payload;
|
||||
return nested === undefined ? payload : nested;
|
||||
}
|
||||
|
||||
export function getPayloadMessage(payload: unknown): string | null {
|
||||
if (typeof payload === "string") {
|
||||
const message = compactMessage(payload);
|
||||
if (/^<!doctype html|<html[\s>]|<head[\s>]|<body[\s>]|^<\?xml|<Error[\s>]/i.test(message)) {
|
||||
return null;
|
||||
}
|
||||
return message || null;
|
||||
}
|
||||
if (!isRecord(payload)) return null;
|
||||
|
||||
const message = payload.error ?? payload.message ?? payload.errorMessage;
|
||||
if (typeof message !== "string") return null;
|
||||
|
||||
const compacted = compactMessage(message);
|
||||
if (/^<!doctype html|<html[\s>]|<head[\s>]|<body[\s>]|^<\?xml|<Error[\s>]/i.test(compacted)) {
|
||||
return null;
|
||||
}
|
||||
return compacted || null;
|
||||
}
|
||||
|
||||
function getPayloadCode(payload: unknown): string | undefined {
|
||||
return isRecord(payload) && typeof payload.code === "string" ? payload.code : undefined;
|
||||
}
|
||||
|
||||
let lastSessionReplacedEventAt = 0;
|
||||
|
||||
let lastSessionExpiredEventAt = 0;
|
||||
|
||||
function notifySessionExpired(status: number, response: Response, payload: unknown): void {
|
||||
if (status !== 401 && status !== 403) return;
|
||||
if (typeof window === "undefined") return;
|
||||
// Auth endpoints (login/register/me) surface their own errors — a wrong
|
||||
// password must not be mistaken for an expired session.
|
||||
if (/\/auth\//i.test(response.url)) return;
|
||||
// SESSION_REPLACED has its own dedicated handling/modal.
|
||||
if (getPayloadCode(payload) === "SESSION_REPLACED") return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastSessionExpiredEventAt < 1500) return;
|
||||
lastSessionExpiredEventAt = now;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<ServerSessionReplacedDetail>(SERVER_SESSION_EXPIRED_EVENT, {
|
||||
detail: { status, code: getPayloadCode(payload), message: "登录状态已失效,请重新登录。" },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function notifySessionReplaced(status: number, payload: unknown, fallbackMessage: string): void {
|
||||
const code = getPayloadCode(payload);
|
||||
const message = getPayloadMessage(payload) || fallbackMessage || "您已在别处登录";
|
||||
const isSessionReplaced = code === "SESSION_REPLACED" || message.includes("您已在别处登录");
|
||||
if (!isSessionReplaced || typeof window === "undefined") return;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastSessionReplacedEventAt < 1500) return;
|
||||
lastSessionReplacedEventAt = now;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<ServerSessionReplacedDetail>(SERVER_SESSION_REPLACED_EVENT, {
|
||||
detail: { status, code, message },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error || "Unknown API error");
|
||||
}
|
||||
|
||||
export function isServerRequestError(error: unknown): error is ServerRequestError {
|
||||
return error instanceof ServerRequestError;
|
||||
}
|
||||
|
||||
export async function readJsonResponse<T>(response: Response, fallbackMessage: string): Promise<T> {
|
||||
const payload = parseResponseBody(await response.text().catch(() => ""));
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
getPayloadMessage(payload) ||
|
||||
compactMessage(response.statusText) ||
|
||||
`${fallbackMessage} (${response.status})`;
|
||||
notifySessionReplaced(response.status, payload, message);
|
||||
notifySessionExpired(response.status, response, payload);
|
||||
throw new ServerRequestError(message, response.status, payload);
|
||||
}
|
||||
|
||||
return unwrapApiPayload(payload) as T;
|
||||
}
|
||||
|
||||
export async function throwResponseError(response: Response, fallbackMessage: string): Promise<never> {
|
||||
const payload = parseResponseBody(await response.text().catch(() => ""));
|
||||
const message =
|
||||
getPayloadMessage(payload) ||
|
||||
compactMessage(response.statusText) ||
|
||||
`${fallbackMessage} (${response.status})`;
|
||||
notifySessionReplaced(response.status, payload, message);
|
||||
notifySessionExpired(response.status, response, payload);
|
||||
throw new ServerRequestError(message, response.status, payload);
|
||||
}
|
||||
|
||||
function isRetryable(error: unknown): boolean {
|
||||
if (error instanceof ServerRequestError) {
|
||||
const s = error.status;
|
||||
return s === 429 || (s !== undefined && s >= 500);
|
||||
}
|
||||
return error instanceof TypeError || (error instanceof DOMException && error.name !== "AbortError");
|
||||
}
|
||||
|
||||
function getRetryDelay(attempt: number, error: unknown): number {
|
||||
if (error instanceof ServerRequestError && error.status === 429) {
|
||||
return Math.min(5000, 2000 * attempt);
|
||||
}
|
||||
return Math.min(4000, 300 * 2 ** attempt);
|
||||
}
|
||||
|
||||
const MAX_RETRIES = 2;
|
||||
|
||||
export async function serverRequest<T>(path: string, options?: ServerRequestOptions): Promise<T> {
|
||||
let lastError: unknown;
|
||||
const timeoutMs = options?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
||||
const controller = timeoutMs > 0 ? new AbortController() : null;
|
||||
const timeoutId =
|
||||
controller && typeof window !== "undefined"
|
||||
? window.setTimeout(() => controller.abort(new DOMException("Request timed out", "TimeoutError")), timeoutMs)
|
||||
: null;
|
||||
const onCallerAbort = () => controller?.abort((options?.signal as AbortSignal)?.reason);
|
||||
if (controller && options?.signal) {
|
||||
if (options.signal.aborted) controller.abort(options.signal.reason);
|
||||
else options.signal.addEventListener("abort", onCallerAbort, { once: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = buildAuthHeaders(options?.token, options?.headers);
|
||||
const response = await fetch(buildApiUrl(path), {
|
||||
method: options?.method || "GET",
|
||||
headers,
|
||||
body: options?.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
signal: controller ? controller.signal : options?.signal,
|
||||
});
|
||||
|
||||
const payload = await readJsonResponse<unknown>(response, "Request failed");
|
||||
return (options?.raw ? payload : unwrapApiPayload(payload)) as T;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < MAX_RETRIES && isRetryable(error) && !options?.signal?.aborted) {
|
||||
await new Promise((r) => setTimeout(r, getRetryDelay(attempt, error)));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
if (timeoutId !== null) window.clearTimeout(timeoutId);
|
||||
options?.signal?.removeEventListener("abort", onCallerAbort);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function checkServerHealth(timeoutMs = 4500): Promise<ServerConnectionHealth> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
await serverRequest<unknown>("/health", { signal: controller.signal });
|
||||
return {
|
||||
state: "connected",
|
||||
baseUrl: getServerBaseUrl(),
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
state: "degraded",
|
||||
baseUrl: getServerBaseUrl(),
|
||||
checkedAt: new Date().toISOString(),
|
||||
errorMessage: getErrorMessage(error),
|
||||
};
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user