Files
deerflow-code/frontend-web/src/report-collaboration/api/client.ts
T
董宏凯andCursor f109a95535 feat: 新增报告协作页,并强化 Deep Research 运行诊断与报告兜底
支持浏览器侧素材快照撰写、拒绝报告读时修复、Mermaid PNG 导出与 Word 导出加固。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 11:16:50 +08:00

143 lines
4.6 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.
/**
* 报告协作 API 基础客户端:鉴权、幂等键、乐观锁与统一错误映射。
*
* - 写请求自动携带 `X-Idempotency-Key` 与 `X-Expected-Revision`。
* - 409 → ConflictError(刷新快照后由用户重新确认);422 → ValidationError;
* 429 → RateLimitError(预算/并发限制,不自动重试);5xx → ServerError
* (保留输入,允许同一幂等键重试)。
*/
import { apiFetch } from "@/core/api/fetch-client";
import { getBackendBaseURL } from "@/core/config";
import type { ApiErrorDetail } from "./types";
export const REPORT_COLLABORATION_BASE = () =>
`${getBackendBaseURL()}/api/report-collaboration`;
export class ApiError extends Error {
readonly status: number;
readonly code?: string;
readonly detail?: unknown;
constructor(status: number, message: string, code?: string, detail?: unknown) {
super(message);
this.name = "ApiError";
this.status = status;
this.code = code;
this.detail = detail;
}
}
/** 409:期望 revision 已过期(或 active run 冲突),不能静默覆盖。 */
export class ConflictError extends ApiError {}
/** 422:请求参数/语义错误,需在对应操作附近展示。 */
export class ValidationError extends ApiError {}
/** 429:预算、并发或速率限制,不自动重试。 */
export class RateLimitError extends ApiError {}
/** 5xx:服务端错误,可带同一幂等键重试。 */
export class ServerError extends ApiError {}
/** 网络层错误(fetch reject)。 */
export class NetworkError extends ApiError {
constructor(message: string) {
super(0, message, "network");
}
}
export function newIdempotencyKey(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return `rc-${crypto.randomUUID()}`;
}
return `rc-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
async function parseErrorDetail(res: Response): Promise<ApiErrorDetail | null> {
try {
const data = (await res.json()) as ApiErrorDetail | { detail?: unknown };
const source = "message" in data ? data : null;
if (source) return source;
if (typeof data.detail === "string") {
return { message: data.detail };
}
return null;
} catch {
return null;
}
}
function errorForStatus(res: Response, message: string, code?: string, detail?: unknown) {
if (res.status === 409) return new ConflictError(409, message, code, detail);
if (res.status === 422) return new ValidationError(422, message, code, detail);
if (res.status === 429) return new RateLimitError(429, message, code, detail);
if (res.status >= 500) return new ServerError(res.status, message, code, detail);
return new ApiError(res.status, message, code, detail);
}
export interface RcRequestInit extends Omit<RequestInit, "body"> {
body?: unknown;
idempotencyKey?: string;
expectedRevision?: number | null;
/** 404 时返回 null 而不是抛错。 */
nullOn404?: boolean;
signal?: AbortSignal;
}
export async function rcRequest<T>(path: string, init: RcRequestInit = {}): Promise<T | null> {
const headers = new Headers();
headers.set("Accept", "application/json");
if (init.body !== undefined) {
headers.set("Content-Type", "application/json");
}
if (init.idempotencyKey) {
headers.set("X-Idempotency-Key", init.idempotencyKey);
}
if (init.expectedRevision !== undefined && init.expectedRevision !== null) {
headers.set("X-Expected-Revision", String(init.expectedRevision));
}
let res: Response;
try {
res = await apiFetch(`${REPORT_COLLABORATION_BASE()}${path}`, {
method: init.method ?? (init.body !== undefined ? "POST" : "GET"),
headers,
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
credentials: "include",
signal: init.signal,
});
} catch (err) {
throw new NetworkError(err instanceof Error ? err.message : String(err));
}
if (res.status === 404 && init.nullOn404) {
return null;
}
if (!res.ok) {
const detail = await parseErrorDetail(res);
throw errorForStatus(
res,
detail?.message ?? `request failed (${res.status})`,
detail?.code,
detail?.detail,
);
}
if (res.status === 204) {
return null;
}
const text = await res.text();
if (!text) {
return null;
}
return JSON.parse(text) as T;
}
/** GET 便捷封装(非空断言版)。 */
export async function rcGet<T>(path: string, signal?: AbortSignal): Promise<T> {
const data = await rcRequest<T>(path, { method: "GET", signal });
if (data === null) {
throw new ApiError(404, `resource not found: ${path}`);
}
return data;
}