152 lines
10 KiB
TypeScript
152 lines
10 KiB
TypeScript
/**
|
||
* 应用通用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> {
|
||
const res = await fetch("/api/foundry/" + path, {
|
||
...options,
|
||
// FormData让浏览器生成boundary,其它请求默认JSON;调用方headers可覆盖默认Content-Type。
|
||
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"));
|
||
}
|
||
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] || "操作未完成,请稍后重试。";
|
||
};
|