Files
pikasTech-HWLAB/web/hwlab-cloud-web/src/services/api/client.ts
T
2026-06-05 15:55:18 +08:00

274 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
AgentChatResponse,
AgentChatResultResponse,
AccessDevicePodRelation,
AccessToolId,
AccessUserRole,
AccessUserStatus,
AdminAccessMatrixResponse,
AdminAccessMutationResponse,
AdminAccessSummaryResponse,
AdminAccessUsersResponse,
ApiResult,
ConversationRecord,
DevicePodEventsResponse,
DevicePodListResponse,
DevicePodStatusResponse,
LiveBuildsPayload,
LiveProbePayload,
WebPerformanceSummaryResponse,
SkillsResponse,
SkillFileResponse,
SkillTreeResponse,
WorkspaceRecord
} from "../../types/domain";
import { recordApiTiming } from "../performance/rum";
export interface ActivityRef {
lastActivityAt: number;
lastActivityIso?: string;
waitingFor?: string | null;
lastEventLabel?: string | null;
}
export type ActivityRefSource = (() => ActivityRef | null) | null;
export interface ApiRequestOptions extends RequestInit {
timeoutMs?: number;
timeoutName?: string;
/**
* Inactivity-timeout reference. When supplied, the abort is re-evaluated
* against `Date.now() - lastActivityAt`, so a long-running fetch stays
* alive as long as upstream keeps emitting trace events. Mirrors the
* pre-React `app-device-pod.ts:fetchJson` activityRef contract so the
* Code Agent / trace / probe endpoints share the same model.
*/
activityRef?: ActivityRef | ActivityRefSource;
}
const ACTIVITY_RESCHEDULER_MS = 1000;
const DEFAULT_TIMEOUT_MS = 4500;
export async function fetchJson<T>(path: string, options: ApiRequestOptions = {}): Promise<ApiResult<T>> {
const controller = new AbortController();
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const activitySource = resolveActivitySource(options.activityRef);
let timer: number | null = null;
const inactivityRef: { current: { idleMs: number; lastActivityAt: number; lastActivityIso?: string; waitingFor?: string | null; lastEventLabel?: string | null } | null } = { current: null };
const startedAt = Date.now();
const method = typeof options.method === "string" ? options.method : "GET";
function readActivity(): { lastActivityAt: number; lastActivityIso?: string; waitingFor?: string | null; lastEventLabel?: string | null } {
const fallback = { lastActivityAt: startedAt, lastActivityIso: new Date(startedAt).toISOString() };
if (!activitySource) return fallback;
const ref = activitySource();
if (!ref || !Number.isFinite(ref.lastActivityAt) || ref.lastActivityAt <= 0) return fallback;
return ref;
}
function schedule(): void {
if (timer !== null) {
window.clearTimeout(timer);
timer = null;
}
if (!activitySource) {
timer = window.setTimeout(() => controller.abort(), timeoutMs);
return;
}
const activity = readActivity();
const idleMs = Math.max(0, Date.now() - activity.lastActivityAt);
const remaining = timeoutMs - idleMs;
if (remaining <= 0) {
inactivityRef.current = { idleMs, lastActivityAt: activity.lastActivityAt, lastActivityIso: activity.lastActivityIso, waitingFor: activity.waitingFor, lastEventLabel: activity.lastEventLabel };
controller.abort();
return;
}
timer = window.setTimeout(schedule, Math.max(250, Math.min(remaining, ACTIVITY_RESCHEDULER_MS)));
}
schedule();
try {
const response = await fetch(path, {
...options,
credentials: options.credentials ?? "same-origin",
headers: {
accept: "application/json",
...(options.body ? { "content-type": "application/json" } : {}),
...(options.headers ?? {})
},
signal: controller.signal
});
const payload = await response.json().catch(() => null) as T | null;
recordApiTiming({ path, method, status: response.status, durationMs: Date.now() - startedAt, outcome: response.ok ? "ok" : "http_error" });
return {
ok: response.ok,
status: response.status,
data: payload,
error: response.ok ? null : errorMessage(payload, `HTTP ${response.status}`)
};
} catch (error) {
recordApiTiming({ path, method, status: 0, durationMs: Date.now() - startedAt, outcome: error instanceof DOMException && error.name === "AbortError" ? "timeout" : "network_error" });
if (error instanceof DOMException && error.name === "AbortError") {
if (inactivityRef.current) {
const detail = inactivityRef.current;
const idleSeconds = Math.round(detail.idleMs / 1000);
return {
ok: false,
status: 0,
data: null,
error: `${options.timeoutName ?? path} 超过 ${timeoutMs}ms 无新活动(idle ${idleSeconds}swaitingFor=${detail.waitingFor ?? "unknown"}lastEventLabel=${detail.lastEventLabel ?? "none"}`
};
}
return {
ok: false,
status: 0,
data: null,
error: `${options.timeoutName ?? path} 超时`
};
}
return {
ok: false,
status: 0,
data: null,
error: error instanceof Error ? error.message : String(error)
};
} finally {
if (timer !== null) window.clearTimeout(timer);
}
}
function resolveActivitySource(ref: ApiRequestOptions["activityRef"]): ActivityRefSource {
if (typeof ref === "function") return ref;
if (ref && typeof ref === "object") return () => ref;
return null;
}
export async function fetchText(path: string, options: ApiRequestOptions = {}): Promise<ApiResult<string>> {
const controller = new AbortController();
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const activitySource = resolveActivitySource(options.activityRef);
let timer: number | null = null;
const inactivityRef: { current: { idleMs: number; lastActivityAt: number } | null } = { current: null };
const startedAt = Date.now();
const method = typeof options.method === "string" ? options.method : "GET";
function readActivity(): number {
if (!activitySource) return startedAt;
const ref = activitySource();
if (!ref || !Number.isFinite(ref.lastActivityAt) || ref.lastActivityAt <= 0) return startedAt;
return ref.lastActivityAt;
}
function schedule(): void {
if (timer !== null) window.clearTimeout(timer);
if (!activitySource) {
timer = window.setTimeout(() => controller.abort(), timeoutMs);
return;
}
const idleMs = Math.max(0, Date.now() - readActivity());
const remaining = timeoutMs - idleMs;
if (remaining <= 0) {
inactivityRef.current = { idleMs, lastActivityAt: readActivity() };
controller.abort();
return;
}
timer = window.setTimeout(schedule, Math.max(250, Math.min(remaining, ACTIVITY_RESCHEDULER_MS)));
}
schedule();
try {
const response = await fetch(path, { ...options, credentials: options.credentials ?? "same-origin", signal: controller.signal });
const text = await response.text();
recordApiTiming({ path, method, status: response.status, durationMs: Date.now() - startedAt, outcome: response.ok ? "ok" : "http_error" });
return { ok: response.ok, status: response.status, data: text, error: response.ok ? null : `HTTP ${response.status}` };
} catch (error) {
recordApiTiming({ path, method, status: 0, durationMs: Date.now() - startedAt, outcome: error instanceof DOMException && error.name === "AbortError" ? "timeout" : "network_error" });
if (error instanceof DOMException && error.name === "AbortError") {
if (inactivityRef.current) {
const detail = inactivityRef.current;
return { ok: false, status: 0, data: null, error: `${options.timeoutName ?? path} 超过 ${timeoutMs}ms 无新活动(idle ${Math.round(detail.idleMs / 1000)}s` };
}
return { ok: false, status: 0, data: null, error: `${options.timeoutName ?? path} 超时` };
}
return { ok: false, status: 0, data: null, error: error instanceof Error ? error.message : String(error) };
} finally {
if (timer !== null) window.clearTimeout(timer);
}
}
function errorMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === "object") {
const record = payload as Record<string, unknown>;
const nested = record.error && typeof record.error === "object" ? record.error as Record<string, unknown> : null;
const candidate = nested?.message ?? nested?.code ?? record.message ?? record.reason ?? record.error;
if (typeof candidate === "string" && candidate.trim()) return candidate;
}
return fallback;
}
export const api = {
session: (): Promise<ApiResult<{ authenticated?: boolean; user?: unknown; actor?: unknown; expiresAt?: string }>> => fetchJson("/auth/session", { timeoutName: "auth session" }),
authBootstrap: (): Promise<ApiResult<{ authenticated?: boolean; user?: unknown; actor?: unknown; expiresAt?: string }>> => fetchJson("/auth/bootstrap", { timeoutName: "auth bootstrap" }),
login: (username: string, password: string): Promise<ApiResult<{ authenticated?: boolean; user?: unknown; actor?: unknown; expiresAt?: string }>> => fetchJson("/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
timeoutName: "auth login"
}),
logout: (): Promise<ApiResult<{ ok?: boolean }>> => fetchJson("/auth/logout", { method: "POST", body: JSON.stringify({}) }),
healthLive: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health/live", { timeoutMs: 12000, timeoutName: "health/live" }),
liveBuilds: (): Promise<ApiResult<LiveBuildsPayload>> => fetchJson("/v1/live-builds", { timeoutMs: 12000, timeoutName: "live builds" }),
health: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health", { timeoutMs: 12000, timeoutName: "health" }),
restIndex: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/v1", { timeoutMs: 12000, timeoutName: "REST index" }),
adapter: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/v1/rpc/system.health", { method: "POST", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "adapter" }),
devicePods: (): Promise<ApiResult<DevicePodListResponse>> => fetchJson("/v1/device-pods", { timeoutMs: 12000, timeoutName: "Device Pods" }),
devicePodStatus: (devicePodId: string): Promise<ApiResult<DevicePodStatusResponse>> => fetchJson(`/v1/device-pods/${encodeURIComponent(devicePodId)}/status`, { timeoutMs: 12000, timeoutName: "Device Pod status" }),
devicePodEvents: (devicePodId: string): Promise<ApiResult<DevicePodEventsResponse>> => fetchJson(`/v1/device-pods/${encodeURIComponent(devicePodId)}/events?limit=120`, { timeoutMs: 12000, timeoutName: "Device Pod events" }),
adminAccessSummary: (): Promise<ApiResult<AdminAccessSummaryResponse>> => fetchJson("/v1/admin/access/summary", { timeoutMs: 12000, timeoutName: "admin access summary" }),
adminAccessUsers: (): Promise<ApiResult<AdminAccessUsersResponse>> => fetchJson("/v1/admin/access/users", { timeoutMs: 12000, timeoutName: "admin access users" }),
adminAccessUser: (userId: string): Promise<ApiResult<AdminAccessMatrixResponse>> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}`, { timeoutMs: 12000, timeoutName: "admin access user" }),
updateAdminAccessUser: (userId: string, payload: { role?: AccessUserRole; status?: AccessUserStatus }): Promise<ApiResult<AdminAccessMutationResponse>> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 12000, timeoutName: "admin access user update" }),
setAdminAccessDevicePod: (userId: string, devicePodId: string, relation: AccessDevicePodRelation, allowed: boolean): Promise<ApiResult<AdminAccessMutationResponse>> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}/device-pods/${encodeURIComponent(devicePodId)}/${encodeURIComponent(relation)}`, { method: allowed ? "PUT" : "DELETE", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "admin access device-pod" }),
setAdminAccessTool: (userId: string, toolId: AccessToolId, allowed: boolean): Promise<ApiResult<AdminAccessMutationResponse>> => fetchJson(`/v1/admin/access/users/${encodeURIComponent(userId)}/tools/${encodeURIComponent(toolId)}/can-use`, { method: allowed ? "PUT" : "DELETE", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "admin access tool" }),
workspace: (projectId: string): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "workspace" }),
updateWorkspace: (workspaceId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}`, { method: "PATCH", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "workspace update" }),
selectConversation: (workspaceId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/workbench/workspace/${encodeURIComponent(workspaceId)}/select-conversation`, { method: "POST", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "select conversation" }),
conversations: (projectId: string): Promise<ApiResult<{ conversations?: ConversationRecord[] }>> => fetchJson(`/v1/agent/conversations?projectId=${encodeURIComponent(projectId)}`, { timeoutMs: 8000, timeoutName: "conversations" }),
deleteConversation: (conversationId: string, payload: Record<string, unknown>): Promise<ApiResult<{ workspace?: WorkspaceRecord }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "DELETE", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "delete conversation" }),
saveConversation: (conversationId: string, payload: Record<string, unknown>): Promise<ApiResult<{ ok?: boolean }>> => fetchJson(`/v1/agent/conversations/${encodeURIComponent(conversationId)}`, { method: "PUT", body: JSON.stringify(payload), timeoutMs: 8000, timeoutName: "save conversation" }),
sendAgentMessage: (payload: Record<string, unknown>, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent", activityRef }),
steerAgentMessage: (payload: Record<string, unknown>, timeoutMs: number, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResponse>> => fetchJson("/v1/agent/chat/steer", { method: "POST", body: JSON.stringify(payload), timeoutMs, timeoutName: "Code Agent steer", activityRef }),
getAgentChatResult: (resultUrl: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResultResponse>> => fetchJson(resultUrl, { timeoutMs, timeoutName: "Code Agent result", activityRef }),
cancelAgentMessage: (payload: Record<string, unknown>): Promise<ApiResult<{ ok?: boolean; canceled?: boolean; error?: { code?: string; message?: string; userMessage?: string } }>> => fetchJson("/v1/agent/chat/cancel", { method: "POST", body: JSON.stringify(payload), timeoutMs: 30000, timeoutName: "Code Agent cancel" }),
gateDiagnostics: (): Promise<ApiResult<GateDiagnosticsResponse>> => fetchJson("/v1/diagnostics/gate", { timeoutMs: 12000, timeoutName: "gate diagnostics" }),
webPerformanceSummary: (): Promise<ApiResult<WebPerformanceSummaryResponse>> => fetchJson("/v1/web-performance/summary", { timeoutMs: 12000, timeoutName: "web performance summary" }),
skills: (): Promise<ApiResult<SkillsResponse>> => fetchJson("/v1/skills", { timeoutMs: 12000, timeoutName: "skills" }),
uploadSkills: (files: Array<{ relativePath: string; sizeBytes: number; contentBase64: string }>): Promise<ApiResult<{ skill?: { id?: string } }>> => fetchJson("/v1/skills/uploads", { method: "POST", body: JSON.stringify({ files }), timeoutMs: 60000, timeoutName: "skill upload" }),
skillTree: (skillId: string): Promise<ApiResult<SkillTreeResponse>> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/tree`, { timeoutMs: 12000, timeoutName: "skill tree" }),
skillFile: (skillId: string, relativePath: string): Promise<ApiResult<SkillFileResponse>> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/files?path=${encodeURIComponent(relativePath)}`, { timeoutMs: 12000, timeoutName: "skill file" })
};
export interface GateDiagnosticsResponse {
serviceId?: string;
route?: string;
contractVersion?: string;
status?: string;
sourceKind?: string;
liveBackend?: boolean;
observedAt?: string;
rowCount?: number;
rowsCount?: number;
columns?: string[];
rows?: GateDiagnosticsRow[];
error?: string;
}
export interface GateDiagnosticsRow {
category?: string;
check?: string;
status?: string;
statusKey?: string;
owner?: string;
detail?: string;
evidence?: string[];
updatedAt?: string;
next?: string;
sourceKind?: string;
}