Spaces:
Running
Running
File size: 5,688 Bytes
90f0300 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 | 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}`;
}
|