/** * 应用通用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( path: string, options: RequestInit = {}, ): Promise { 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 = { 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] || "操作未完成,请稍后重试。"; };