Files
Endoleap/src/lib/api.ts
T

155 lines
11 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.
/**
* 应用通用JSON/FormData请求与登录跳转辅助。请求默认经过同源Foundry代理,错误封装为ApiError,
* 界面通过errorText映射中文说明;业务schema和账户代次等更强校验由各领域适配器处理。
* 本函数本身不设置超时或自动重试,取消依赖传入signal,代理传输时限在对应路由/响应封装中管理。
*/
/** 账户资料响应的静态类型,不能用它代替登录会话有效性或权限校验。 */
export type Profile = {
id: string;
username: string;
display_name: string | null;
email: string | null;
avatar_url?: string | null;
};
/** 保留HTTP状态、服务details和原Retry-After头,message使用稳定错误码,供上层决定显示/重试。 */
export class ApiError extends Error {
readonly status: number;
readonly details?: unknown;
readonly retryAfter?: string | null;
/** 以Error兼容方式封装服务错误,不把details自动拼进面向用户文案。 */
constructor(code: string, status: number, details?: unknown, retryAfter?: string | null) {
super(code); this.name = "ApiError"; this.status = status; this.details = details; this.retryAfter = retryAfter;
}
}
/** 泛型T只是调用方的响应形状声明,不执行运行时schema检查。
* 204返回null;其它响应必须可解析JSON,非2xx统一抛ApiError,成功body直接交业务层验证。 */
export async function api<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
// fetch 本身拒绝(网络失败/发出前取消)时原异常直接传播;下面的 ApiError 包装只覆盖响应读取与 HTTP 错误。
const res = await fetch("/api/foundry/" + path, {
...options,
// FormData让浏览器生成boundary,其它请求默认JSON;这里只展开 headers 的自有可枚举字段。
// 普通对象可覆盖同名默认字段;Headers 实例/二元组数组不会在这里被规范化成对象键值。
headers: { ...(options.body instanceof FormData ? {} : { "Content-Type": "application/json" }), ...options.headers },
});
let body: unknown;
if (res.status === 204) body = null;
else {
try { body = await res.json(); }
catch (error) {
// 消费body失败时优先传播明确取消原因;语法坏JSON与传输中断使用不同错误码。
if (options.signal?.aborted) throw options.signal.reason;
const malformed = error instanceof SyntaxError;
throw new ApiError(malformed ? "invalid_api_response" : "response_interrupted", res.ok ? 502 : res.status);
}
}
const payload = body as { error?: string | { code?: string }; details?: unknown } | null;
// 服务error兼容字符串或{code},无可用码则request_failed,并保留Retry-After原值供上层解释。
if (!res.ok) {
const code = typeof payload?.error === "object" ? payload.error?.code : payload?.error;
throw new ApiError(typeof code === "string" ? code : "request_failed", res.status, payload?.details, res.headers.get("retry-after"));
}
// 204 的 null 同样通过类型断言返回;调用者选 T 时须包含真实的空响应约定。
return body as T;
}
/** 公开项目标识,来自构建时NEXT_PUBLIC配置;不是密码或用户账户身份。 */
export const PROJECT = process.env.NEXT_PUBLIC_FOUNDRY_PROJECT || "endoleap";
/** 创建PKCE verifier和SHA-256 challenge:verifier留在当前标签页sessionStorage,
* 只把challenge带到同源OAuth入口,后续交换流程负责取回verifier。此处不直接取得令牌。 */
export async function startOAuth(provider: "google" | "slack") {
const bytes = crypto.getRandomValues(new Uint8Array(32));
// 32随机字节编码为无填充base64url,避免普通base64字符影响URL参数。
const verifier = btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
);
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
// 页面跳转前保存原verifier;使用单一固定键,同标签页后一次发起会替换上一次值。
sessionStorage.setItem("foundry_verifier", verifier);
window.location.assign("/api/oauth/" + provider + "?challenge=" + challenge);
}
/** 应用错误码→用户文案适配,不改变任务状态或执行恢复;未知错误给通用提示,不直接泄漏内部异常文本。 */
export const errorText = (error: unknown) => {
const code = error instanceof Error ? error.message : "";
// 文案只解释既有领域状态;“保留/暂停”等实际行为须由触发错误的模块保证,不能由映射表证明。
const messages: Record<string, string> = {
invalid_model_stream: "画布返回的数据无法解析,请重新加载。",
incomplete_model_stream: "画布数据未加载完整,请重新加载。",
unsupported_model_stream: "当前画布加载格式暂不受支持,请更新后重试。",
model_stream_budget: "画布数据量超过当前加载上限,请联系支持。",
load_snapshot_changed: "加载期间画布快照发生变化,请重新加载。",
invalid_model_identity: "画布的元素身份清单不一致,请重新加载。",
loading_reference_incomplete: "相关引用尚未完整加载,暂时无法修改。",
invalid_model_coverage: "画布的加载范围信息不一致,请重新加载。",
loading_waiting_for_receipts: "正在确认已发送的修改,请稍后重试。",
invalid_loading_asset: "图片任务的加载范围记录不一致,已保留本地内容并停止同步。",
loading_asset_interpreter_unavailable: "此图片任务需要更新版本后才能恢复。",
asset_source_changed: "图片资源的地址规则发生变化,请使用原配置恢复后重试。",
invalid_model_topology: "画布的分区关系不一致,请重新加载。",
loading_scope_incomplete: "所需分区或上级引用尚未完整加载,请稍后重试。",
invalid_loading_import: "导入任务的加载范围记录不完整,任务已停止。",
loading_copy_scope_changed: "复制源的范围校验失败,请重新复制。",
loading_import_interpreter_unavailable: "此导入任务需要更新版本后才能恢复。",
model_loading: "画布尚在加载,请等待加载完成后编辑。",
identity_changed: "账户已变化,当前画布已停止同步,请重新打开。",
engine_closing: "画布正在关闭,本地修改会保留用于恢复。",
storage_scope_in_use: "这张图谱已在另一个标签页中打开,请先关闭那里的编辑器。",
persistent_storage_unavailable: "浏览器无法保存本地恢复记录,请检查存储权限后重试。",
workspace_close_failed: "本地修改尚未保存成功,请释放浏览器存储空间后重试退出。",
host_close_failed: "部分本地修改尚未保存成功,请释放浏览器存储空间后重试退出。",
resource_bytes_unavailable: "此图片的本地文件暂不可用,相关修改已保留,请重新选择图片。",
recovery_checksum_mismatch: "本地恢复记录校验失败,原记录已保留,请先备份再处理。",
recovery_journal_gap: "本地恢复记录不完整,原记录已保留,请先备份再处理。",
unsupported_component: "这个元素需要更新版本或安装对应功能后才能编辑。",
invalid_credentials: "邮箱或密码不正确,或邮箱尚未验证。",
rate_limited: "请求太频繁,请稍后再试。",
invalid_session: "登录已过期,请重新登录。",
workspace_not_loaded: "空间列表尚未加载成功,请刷新页面后重试。",
workspace_required: "请先创建或选择一个工作空间。",
invalid_workspace: "工作空间名称不符合要求,请修改后重试。",
invalid_title: "名称不能为空,且不能超过 300 字节(约 100 个汉字)。",
version_conflict: "画布已更新,本地草稿已保留,请导出备份或重新读取后处理冲突。",
invalid_profile: "请检查显示名称,以及用户名是否为 3–40 位小写字母、数字、下划线或连字符。",
forbidden: "你没有在此工作空间创建或编辑图谱的权限。",
invalid_canvas: "画布数据未通过服务端校验,请导出备份后检查节点内容。",
response_interrupted: "画布数据传输中断或超时,请重新加载;当前内容未被覆盖。",
invalid_api_response: "接口返回了不完整或无法解析的数据,请重新加载。",
asset_target_changed: "目标分区已变化,请重新选择添加位置。",
asset_authorization_failed: "暂时无法读取这张图片,请确认仍有原画布的访问权限。",
invalid_asset: "图片格式无法识别,请选择有效的 PNG、JPEG、WebP 或 GIF。",
asset_too_large: "图片超出上传限制,请选择小于 20 MiB 的文件。",
invalid_asset_response: "图片上传结果无效,画布内容未替换,请重试。",
invalid_canvas_snapshot: "服务端画布数据格式无效,请重新读取或联系维护者。",
canvas_schema_upgrade_required: "这张画布已升级,请刷新以加载新版客户端。",
undo_receipt_expired: "撤销记录已过期,当前内容已保留。",
draft_recovery_required: "请先处理未同步的本地草稿。",
canvas_busy: "画布正在同步,请稍后重试。",
node_busy: "这个元素正在生成,请等待完成或先取消。",
model_unavailable: "模型暂时不可用,请重新选择。",
model_incompatible: "这个模型不支持当前元素的输出类型。",
ownership_conflict: "子画布关系已变化,请重新读取画布。",
execution_cycle: "执行路径中有循环,请先断开循环连接。",
queue_full: "生成队列已满,请稍后重试。",
context_too_large: "输入内容超出模型容量,请减少输入。",
canvas_too_large: "画布超过 5 MiB,请减少图片或拆分画布后重试。",
body_too_large: "画布内容过大,请减少图片或拆分画布。",
invalid_material: "画布名称或内容不符合要求,请修改后重试。",
invalid_reference: "工作空间不存在或当前账户无权访问,请刷新空间列表。",
upstream_timeout: "连接服务超时,请稍后再试。",
upstream_unavailable: "暂时无法连接服务,请稍后重试。",
not_found: "接口暂未上线,请稍后重试。",
invalid_verification: "验证链接无效或已过期,请重新注册以获取邮件。",
slack_not_configured: "Slack 登录尚未配置完成。",
};
return messages[code] || "操作未完成,请稍后重试。";
};