sciagent code + Gitea Actions CI/CD
CI/CD / backend (push) Failing after 2m8s
CI/CD / frontend (push) Failing after 1m40s
CI/CD / deploy (push) Has been skipped

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thinh Lam
2026-06-30 09:38:30 +07:00
commit 688fac73e9
1167 changed files with 158244 additions and 0 deletions
+318
View File
@@ -0,0 +1,318 @@
/**
* API Client
* Centralized HTTP client with interceptors, error handling, and type safety.
* (Ported from fe0/src/shared/api/client.ts — only the two intra-package import paths changed.)
*/
import axios, {
AxiosInstance,
AxiosError,
InternalAxiosRequestConfig,
AxiosRequestConfig,
} from 'axios';
import { env } from '../config/env';
import { getAccessToken, clearAccessToken, setAccessToken } from '../utils/token';
export interface ApiError {
message: string;
status: number;
data?: any;
code?: string;
}
/** Thrown by {@link ApiClient.postArrayBuffer} on HTTP error responses so callers can inspect status (e.g. 501). */
export class ApiArrayBufferRequestError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'ApiArrayBufferRequestError';
this.status = status;
}
}
/** True when the client rejected a response with 404 (Axios or normalized {@link ApiError}). */
export function isApiNotFoundError(err: unknown): boolean {
if (err instanceof AxiosError && err.response?.status === 404) return true;
if (err && typeof err === 'object') {
const s = (err as Partial<ApiError>).status;
if (typeof s === 'number' && s === 404) return true;
}
return false;
}
/** Used by QueryClient retry: never backoff-retry auth failures (avoids poll storms on expired sessions). */
export function isAuthQueryError(err: unknown): boolean {
if (err instanceof AxiosError) {
const s = err.response?.status;
return s === 401 || s === 403;
}
if (err && typeof err === 'object') {
const s = (err as Partial<ApiError>).status;
if (typeof s === 'number') return s === 401 || s === 403;
}
return false;
}
function summarizeResponseDataForDevLog(data: unknown): unknown {
if (data == null) return data;
if (typeof data !== 'object') return data;
const rec = data as Record<string, unknown>;
if (Array.isArray(rec.data)) {
return { ...rec, data: `[${rec.data.length} items]` };
}
return data;
}
function stringifyFastApiDetail(detail: unknown): string | undefined {
if (detail == null) return undefined;
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) {
const parts = detail
.map((x) =>
typeof x === 'object' && x && 'msg' in x ? String((x as { msg: string }).msg) : String(x),
)
.filter(Boolean);
return parts.length ? parts.join(' ') : undefined;
}
return undefined;
}
/** True for the synthetic "HTTP <status>" message the interceptor attaches to 4xx rejections. */
function isSyntheticHttpMessage(message: unknown): boolean {
return typeof message === 'string' && /^HTTP \d+$/.test(message.trim());
}
/** Human-readable message from ApiClient errors, ApiError-shaped objects, or raw AxiosErrors. */
export function detailFromApiError(err: unknown, fallback = 'Lỗi không xác định.'): string {
if (err && typeof err === 'object') {
const e = err as ApiError & {
response?: { data?: { detail?: unknown; message?: string } };
};
// Prefer the FastAPI `{detail}` — on a raw AxiosError it lives at err.response.data.detail;
// on a normalized ApiError it lives at err.data.detail. Either beats the synthetic "HTTP <status>".
const detail = stringifyFastApiDetail(e.response?.data?.detail ?? e.data?.detail);
if (detail) return detail;
const respMessage = e.response?.data?.message;
if (typeof respMessage === 'string' && respMessage.trim()) return respMessage;
if (typeof e.message === 'string' && e.message.trim() && !isSyntheticHttpMessage(e.message)) {
return e.message;
}
}
if (err instanceof Error && err.message.trim() && !isSyntheticHttpMessage(err.message)) {
return err.message;
}
return fallback;
}
class ApiClient {
private client: AxiosInstance;
constructor() {
if (import.meta.env.DEV) {
console.log('API Client initialized with baseURL:', env.API_URL);
}
this.client = axios.create({
baseURL: env.API_URL,
timeout: 60000, // 60s for Ollama responses
headers: {
'Content-Type': 'application/json',
},
withCredentials: true,
validateStatus: (status) => status < 500, // Don't throw on 4xx errors
});
this.setupInterceptors();
}
private setupInterceptors(): void {
this.client.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const token = getAccessToken();
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
// Multipart uploads: let the browser set Content-Type (boundary). A global
// application/json default breaks FastAPI's UploadFile / Form parsing.
if (config.data instanceof FormData && config.headers) {
delete (config.headers as Record<string, unknown>)['Content-Type'];
}
return config;
},
(error) => Promise.reject(error),
);
this.client.interceptors.response.use(
(response) => {
// Default validateStatus allows 4xx; reject here so `.then(res => res.data)` never treats
// FastAPI `{ detail }` bodies as successful entity payloads.
if (response.status >= 400) {
const err = new AxiosError(
`HTTP ${response.status}`,
AxiosError.ERR_BAD_REQUEST,
response.config,
response.request,
response,
);
return Promise.reject(err);
}
if (import.meta.env.DEV) {
console.debug('✅ API OK', {
url: response.config.url,
status: response.status,
data: summarizeResponseDataForDevLog(response.data),
});
}
return response;
},
async (error: AxiosError) => {
if (import.meta.env.DEV) {
console.error('❌ API Error:', {
message: error.message,
code: error.code,
response: error.response
? { status: error.response.status, data: error.response.data }
: null,
config: {
url: error.config?.url,
baseURL: error.config?.baseURL,
method: error.config?.method,
},
});
}
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
// Handle 401 - Unauthorized → try one refresh.
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
originalRequest._retry = true;
try {
const newToken = await this.refreshToken();
if (newToken && originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return this.client(originalRequest);
}
} catch (refreshError) {
clearAccessToken();
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
return Promise.reject(refreshError);
}
}
return Promise.reject(this.handleError(error));
},
);
}
private async refreshToken(): Promise<string | null> {
try {
const response = await axios.post(
`${env.API_URL}/api/v1/auth/refresh`,
{},
{ withCredentials: true, timeout: 15_000 },
);
const { accessToken } = response.data;
setAccessToken(accessToken);
return accessToken;
} catch (error) {
if (import.meta.env.DEV) {
console.error('Token refresh failed:', error);
}
return null;
}
}
private handleError(error: AxiosError): ApiError {
if (error.response) {
const data = error.response.data as any;
const detailStr = stringifyFastApiDetail(data?.detail);
return {
message: data?.message || detailStr || 'An error occurred',
status: error.response.status,
data,
code: data?.code,
};
} else if (error.request) {
return {
message: `Network error. Please check your connection. URL: ${error.config?.baseURL}${error.config?.url}`,
status: 0,
code: 'NETWORK_ERROR',
};
}
return {
message: error.message || 'An unexpected error occurred',
status: 0,
code: 'UNKNOWN_ERROR',
};
}
public get<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.client.get<T>(url, config).then((res) => res.data);
}
public post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return this.client.post<T>(url, data, config).then((res) => res.data);
}
/** Binary POST (e.g. .docx). On 4xx/5xx, attempts to read JSON or text from the error body. */
public postArrayBuffer(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<ArrayBuffer> {
const decodeErrorBody = (raw: ArrayBuffer, status: number) => {
const text = new TextDecoder().decode(new Uint8Array(raw));
let message = `HTTP ${status}`;
try {
const j = JSON.parse(text) as { detail?: unknown; message?: string };
const d = j.detail;
if (typeof d === 'string') message = d;
else if (Array.isArray(d) && d[0] && typeof (d[0] as { msg?: string }).msg === 'string') {
message = (d[0] as { msg: string }).msg;
} else if (j.message) message = j.message;
} catch {
if (text.trim()) message = text;
}
return new ApiArrayBufferRequestError(message, status);
};
return this.client
.post(url, data, { ...config, responseType: 'arraybuffer' })
.then((res) => {
if (res.status >= 400) {
return Promise.reject(decodeErrorBody(res.data as ArrayBuffer, res.status));
}
return res.data as ArrayBuffer;
})
.catch((err: unknown) => {
const ax = err as { response?: { status?: number; data?: ArrayBuffer } };
if (ax?.response?.data instanceof ArrayBuffer && ax.response?.status) {
return Promise.reject(decodeErrorBody(ax.response.data, ax.response.status));
}
if (err && typeof err === 'object' && 'status' in err) {
const ae = err as ApiError;
if (typeof ae.status === 'number' && ae.status >= 400) {
const msg =
typeof ae.message === 'string' && ae.message.trim()
? ae.message
: stringifyFastApiDetail(ae.data?.detail) || `HTTP ${ae.status}`;
return Promise.reject(new ApiArrayBufferRequestError(msg, ae.status));
}
}
return Promise.reject(err);
});
}
public put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return this.client.put<T>(url, data, config).then((res) => res.data);
}
public delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
return this.client.delete<T>(url, config).then((res) => res.data);
}
public patch<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
return this.client.patch<T>(url, data, config).then((res) => res.data);
}
}
export const apiClient = new ApiClient();