Spaces:
Running
Running
| const TOKEN_KEY = 'codexmobile.deviceToken'; | |
| const ERROR_MESSAGES = { | |
| pairing_required: '需要重新配对这台 Mac。', | |
| mac_offline: 'Mac 连接器未在线。', | |
| mac_local_offline: 'Mac 本地 CodexMobile 服务未启动。', | |
| relay_request_timeout: '中转请求超时。', | |
| mac_reconnected: 'Mac 连接已刷新,请重试。', | |
| relay_body_too_large: '当前中转模式不支持这么大的内容。', | |
| relay_streaming_required: '此能力需要本地直连或后续流式中转支持。', | |
| relay_realtime_unsupported: '实时语音中转不可用。', | |
| relay_realtime_http_upgrade_required: '实时语音需要 WebSocket 连接。', | |
| relay_rate_limited: '请求过快,请稍后再试。', | |
| relay_unsupported: '此回调路径暂不支持中转模式。' | |
| }; | |
| export class ApiError extends Error { | |
| constructor(message, { status = 0, code = '', retryAfter = 0, reason = '' } = {}) { | |
| super(message); | |
| this.name = 'ApiError'; | |
| this.status = status; | |
| this.code = code; | |
| this.retryAfter = retryAfter; | |
| this.reason = reason; | |
| } | |
| } | |
| function browserLocalStorage() { | |
| return typeof localStorage === 'undefined' ? null : localStorage; | |
| } | |
| export function readStoredValue(key, fallback = '') { | |
| try { | |
| return browserLocalStorage()?.getItem(key) ?? fallback; | |
| } catch { | |
| return fallback; | |
| } | |
| } | |
| export function writeStoredValue(key, value) { | |
| const storage = browserLocalStorage(); | |
| if (!storage) { | |
| return false; | |
| } | |
| try { | |
| storage.setItem(key, value); | |
| } catch { | |
| return false; | |
| } | |
| return true; | |
| } | |
| export function removeStoredValue(key) { | |
| const storage = browserLocalStorage(); | |
| if (!storage) { | |
| return false; | |
| } | |
| try { | |
| storage.removeItem(key); | |
| } catch { | |
| return false; | |
| } | |
| return true; | |
| } | |
| export function rateLimitLockFromError(error, scope, nowMs = Date.now()) { | |
| if (!error || error.code !== 'relay_rate_limited' || !Number(error.retryAfter)) { | |
| return null; | |
| } | |
| const retryAfter = Math.max(0, Math.ceil(Number(error.retryAfter))); | |
| return { | |
| scope, | |
| retryAfter, | |
| untilMs: nowMs + retryAfter * 1000 | |
| }; | |
| } | |
| export function remainingLockSeconds(lock, nowMs = Date.now()) { | |
| if (!lock?.untilMs) { | |
| return 0; | |
| } | |
| return Math.max(0, Math.ceil((lock.untilMs - nowMs) / 1000)); | |
| } | |
| function errorMessageFor(data, fallback) { | |
| const code = String(data?.error || ''); | |
| return ERROR_MESSAGES[code] || code || fallback; | |
| } | |
| export function getToken() { | |
| return readStoredValue(TOKEN_KEY); | |
| } | |
| export function setToken(token) { | |
| if (!writeStoredValue(TOKEN_KEY, token)) { | |
| throw new Error('无法保存配对凭据,请检查浏览器存储权限。'); | |
| } | |
| } | |
| export function clearToken() { | |
| return removeStoredValue(TOKEN_KEY); | |
| } | |
| export async function requestPersistentStorage() { | |
| if (typeof navigator === 'undefined' || typeof navigator.storage?.persist !== 'function') { | |
| return false; | |
| } | |
| try { | |
| return Boolean(await navigator.storage.persist()); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| export function isPairingRequiredError(error) { | |
| const code = String(error?.code || ''); | |
| const message = String(error?.message || ''); | |
| return ( | |
| code === 'pairing_required' || | |
| code === 'Pairing required' || | |
| message === ERROR_MESSAGES.pairing_required || | |
| message === 'Pairing required' | |
| ); | |
| } | |
| export async function apiFetch(path, options = {}) { | |
| const token = getToken(); | |
| const headers = { | |
| ...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }), | |
| ...(token ? { authorization: `Bearer ${token}` } : {}), | |
| ...(options.headers || {}) | |
| }; | |
| const response = await fetch(path, { | |
| ...options, | |
| headers, | |
| body: | |
| options.body && !(options.body instanceof FormData) && typeof options.body !== 'string' | |
| ? JSON.stringify(options.body) | |
| : options.body | |
| }); | |
| const text = await response.text(); | |
| const data = text ? JSON.parse(text) : {}; | |
| if (!response.ok) { | |
| throw new ApiError(errorMessageFor(data, `Request failed: ${response.status}`), { | |
| status: response.status, | |
| code: data.error || '', | |
| retryAfter: Number(data.retryAfter || 0), | |
| reason: data.reason || '' | |
| }); | |
| } | |
| return data; | |
| } | |
| export async function apiBlobFetch(path, options = {}) { | |
| const token = getToken(); | |
| const headers = { | |
| ...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }), | |
| ...(token ? { authorization: `Bearer ${token}` } : {}), | |
| ...(options.headers || {}) | |
| }; | |
| const response = await fetch(path, { | |
| ...options, | |
| headers, | |
| body: | |
| options.body && !(options.body instanceof FormData) && typeof options.body !== 'string' | |
| ? JSON.stringify(options.body) | |
| : options.body | |
| }); | |
| if (!response.ok) { | |
| const text = await response.text(); | |
| let message = `Request failed: ${response.status}`; | |
| let data = {}; | |
| try { | |
| data = text ? JSON.parse(text) : {}; | |
| } catch { | |
| message = text || message; | |
| } | |
| throw new ApiError(errorMessageFor(data, message), { | |
| status: response.status, | |
| code: data.error || '', | |
| retryAfter: Number(data.retryAfter || 0), | |
| reason: data.reason || '' | |
| }); | |
| } | |
| return response.blob(); | |
| } | |
| export function websocketUrl() { | |
| const token = encodeURIComponent(getToken()); | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| return `${protocol}//${window.location.host}/ws?token=${token}`; | |
| } | |
| export function realtimeVoiceWebsocketUrl() { | |
| const token = encodeURIComponent(getToken()); | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| return `${protocol}//${window.location.host}/ws/realtime?token=${token}`; | |
| } | |