274 lines
16 KiB
TypeScript
274 lines
16 KiB
TypeScript
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}s;waitingFor=${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;
|
||
}
|