import { API_URL, API_KEY, PRO_URL, PRO_FRONTEND_TIMEOUT_MS, TTS_URL, TTS_STATUS_URL, TTS_CONFIG_URL, COMPRESS_SUBMIT_URL, COMPRESS_STATUS_URL, JOB_LOAD_URL, } from './config.js' import { useSettingsStore } from '../stores/settings' function generateRequestId() { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID() } return `${Date.now()}-${Math.random().toString(16).slice(2)}` } function normalizeAbortReason(reason) { if (typeof reason === 'string' && reason.trim()) { return reason.trim().slice(0, 64) } return 'abort' } function parseSseEvent(rawEvent) { const lines = String(rawEvent || '').replace(/\r/g, '').split('\n') let event = 'message' const dataLines = [] for (const line of lines) { if (!line) continue if (line.startsWith('event:')) { event = line.slice(6).trim() || 'message' continue } if (line.startsWith('data:')) { dataLines.push(line.slice(5).trimStart()) } } return { event, data: dataLines.join('\n'), } } function createAbortError(message = 'Request aborted') { const error = new Error(message) error.name = 'AbortError' return error } async function sendCancelRequest(cancelUrl, requestId, reason) { try { await fetch(cancelUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY, }, body: JSON.stringify({ request_id: requestId, reason, }), }) } catch { // Best-effort cancel only } } function getCancelUrl(apiUrl) { const normalized = String(apiUrl || '').replace(/\/+$/, '') if (/\/v1\/pro\/completions$/i.test(normalized)) { return normalized.replace(/\/v1\/pro\/completions$/i, '/v1/pro/completions/cancel') } if (/\/v1\/completions$/i.test(normalized)) { return normalized.replace(/\/v1\/completions$/i, '/v1/completions/cancel') } return `${normalized}/cancel` } function buildCompletionBody(settings, prefix, suffix, languageId, extra = {}) { return { prefix, suffix, languageId, model_thinking: settings.modelThinking, privacy_mode: settings.privacyMode, user_preferences: { language: settings.language, currency: settings.currency, timezone: settings.detectedTimezone, }, ...extra, } } async function consumeSseJson({ url, body, requestId, signal, timeoutMs, onChunk, onEvent, onDone, }) { const requestController = new AbortController() const timeoutId = timeoutMs ? setTimeout(() => requestController.abort('timeout'), timeoutMs) : null const cancelUrl = getCancelUrl(url) const relayAbort = () => { requestController.abort(signal?.reason || 'abort') } const onAbort = () => { const reason = normalizeAbortReason(requestController.signal.reason) void sendCancelRequest(cancelUrl, requestId, reason) } requestController.signal.addEventListener('abort', onAbort, { once: true }) if (signal) { if (signal.aborted) { relayAbort() } else { signal.addEventListener('abort', relayAbort, { once: true }) } } try { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Request-Id': requestId, 'X-API-Key': API_KEY, }, body: JSON.stringify(body), signal: requestController.signal, }) if (!res.ok) { const errorText = await res.text() throw new Error(`HTTP ${res.status}: ${errorText}`) } if (!res.body) { throw new Error('流式响应不可用') } const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' let finalPayload = null while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) let boundary = buffer.indexOf('\n\n') while (boundary >= 0) { const chunk = buffer.slice(0, boundary) buffer = buffer.slice(boundary + 2) const parsed = parseSseEvent(chunk) const data = parsed.data ? JSON.parse(parsed.data) : {} onEvent?.(parsed.event, data) if (parsed.event === 'result') { onChunk?.(data) } else if (parsed.event === 'done') { finalPayload = data.result || data return onDone ? onDone(finalPayload) : finalPayload } else if (parsed.event === 'error') { throw new Error(String(data.error || '请求失败')) } else if (parsed.event === 'cancelled') { throw createAbortError('请求已取消') } boundary = buffer.indexOf('\n\n') } } if (requestController.signal.aborted) { throw createAbortError('请求已中止') } return onDone ? onDone(finalPayload || {}) : finalPayload } finally { if (timeoutId) clearTimeout(timeoutId) requestController.signal.removeEventListener('abort', onAbort) if (signal) { signal.removeEventListener('abort', relayAbort) } } } export async function fetchSuggestion(prefix, suffix, languageId, signal, apiUrl = API_URL) { let normalizedLanguageId = 'markdown' if (typeof languageId === 'string' && languageId.trim()) { normalizedLanguageId = languageId.trim() } else if (languageId && typeof languageId === 'object' && 'aborted' in languageId) { signal = languageId } if (typeof signal === 'string') { apiUrl = signal signal = undefined } const settings = useSettingsStore() const requestId = generateRequestId() let finalContent = '' const result = await consumeSseJson({ url: apiUrl, body: buildCompletionBody(settings, prefix, suffix, normalizedLanguageId), requestId, signal, onChunk(data) { if (typeof data.content === 'string') { finalContent = data.content } else if (typeof data.delta === 'string') { finalContent += data.delta } }, onDone(data) { return typeof data.content === 'string' ? data.content : finalContent }, }) return result || '' } export async function fetchProSuggestionStream(payload, apiUrl = PRO_URL) { const { prefix = '', suffix = '', languageId = 'markdown', instruction = '', signal, timeoutMs = PRO_FRONTEND_TIMEOUT_MS, onChunk, onEvent, } = payload || {} const settings = useSettingsStore() const requestId = generateRequestId() let finalContent = '' return consumeSseJson({ url: apiUrl, requestId, signal, timeoutMs, body: { prefix, suffix, languageId: String(languageId || 'markdown').trim() || 'markdown', instruction, pro_thinking: settings.proThinking || 'medium', privacy_mode: settings.privacyMode, user_preferences: { language: settings.language, currency: settings.currency, timezone: settings.detectedTimezone, }, }, onChunk(data) { const delta = String(data.delta || data.content || '') if (delta) { finalContent += delta onChunk?.(delta) } }, onEvent(event, data) { if (event === 'progress' && data.phase === 'thinking') { onEvent?.('thinking', data) return } if (event === 'queued' || event === 'started' || event === 'resource') { onEvent?.(event, data) } }, onDone(data) { return String(data.content || finalContent || '') }, }) } export async function fetchTTS(text, instruct = '', apiUrl = TTS_URL) { const requestId = generateRequestId() return consumeSseJson({ url: apiUrl, requestId, body: { text, instruct, speaker: 'Vivian', format: 'wav' }, onDone(data) { return data }, }) } export async function fetchTTSStatus(apiUrl = TTS_STATUS_URL) { const res = await fetch(apiUrl, { headers: { 'X-API-Key': API_KEY }, }) if (!res.ok) throw new Error(`TTS Status HTTP ${res.status}`) return res.json() } export async function fetchTTSConfig(apiUrl = TTS_CONFIG_URL) { const res = await fetch(apiUrl, { headers: { 'X-API-Key': API_KEY }, }) if (!res.ok) throw new Error(`TTS Config HTTP ${res.status}`) return res.json() } export async function submitCompress(content, docType = 'txt', apiUrl = COMPRESS_SUBMIT_URL) { const res = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY, }, body: JSON.stringify({ content, docType }), }) if (!res.ok) { const errorText = await res.text() throw new Error(`压缩提交失败 HTTP ${res.status}: ${errorText}`) } return res.json() } export function pollCompressStatus(taskId, onStateChange, apiUrl = COMPRESS_STATUS_URL) { let consecutiveErrors = 0 const interval = setInterval(async () => { try { const res = await fetch(`${apiUrl}?task_id=${encodeURIComponent(taskId)}`, { headers: { 'X-API-Key': API_KEY }, }) if (!res.ok) { consecutiveErrors++ if (consecutiveErrors >= 5) { clearInterval(interval) onStateChange('error', '', `请求失败 HTTP ${res.status}`) } return } consecutiveErrors = 0 const data = await res.json() onStateChange(data.status, data.content || '', data.message, data) if (['completed', 'error', 'cancelled'].includes(data.status)) { clearInterval(interval) } } catch (err) { consecutiveErrors++ if (consecutiveErrors >= 5) { clearInterval(interval) onStateChange('error', '', `网络异常: ${err.message || err}`) } } }, 1000) return { stop: () => clearInterval(interval) } } export async function fetchJobLoad(apiUrl = JOB_LOAD_URL) { const res = await fetch(apiUrl, { headers: { 'X-API-Key': API_KEY }, }) if (!res.ok) { throw new Error(`Job Load HTTP ${res.status}`) } return res.json() }