import type {TextStreamOptions} from './aiTypes'; import { fetchWithApiAuth } from './apiClient'; const ENV: Partial = import.meta.env ?? {}; type NodeProcessLike = { env?: Record; }; function getNodeEnv() { if (typeof window !== 'undefined') { return {}; } return ( (globalThis as typeof globalThis & {process?: NodeProcessLike}).process?.env ?? {} ); } function normalizeBaseUrl(value: string) { return value.replace(/\/+$/u, ''); } function coerceBoolean(value: string | undefined) { return value?.trim().toLowerCase() === 'true'; } function resolveHeaders(headers?: HeadersInit) { const nextHeaders: Record = {}; if (headers instanceof Headers) { headers.forEach((value, key) => { nextHeaders[key] = value; }); } else if (Array.isArray(headers)) { for (const [key, value] of headers) { nextHeaders[key] = value; } } else if (headers) { Object.assign(nextHeaders, headers); } return nextHeaders; } const NODE_ENV = getNodeEnv(); const IS_SERVER_RUNTIME = typeof window === 'undefined'; const SERVER_API_KEY = NODE_ENV.LLM_API_KEY || NODE_ENV.ARK_API_KEY || NODE_ENV.VITE_LLM_API_KEY || ''; const API_BASE_URL = IS_SERVER_RUNTIME ? normalizeBaseUrl( NODE_ENV.LLM_BASE_URL || 'https://ark.cn-beijing.volces.com/api/v3', ) : (ENV.VITE_LLM_PROXY_BASE_URL || '/api/llm'); const MODEL = IS_SERVER_RUNTIME ? (NODE_ENV.LLM_MODEL || NODE_ENV.VITE_LLM_MODEL || 'doubao-1-5-pro-32k-character-250715') : (ENV.VITE_LLM_MODEL || 'doubao-1-5-pro-32k-character-250715'); const ENABLE_LLM_DEBUG_LOG = IS_SERVER_RUNTIME ? coerceBoolean(NODE_ENV.LLM_DEBUG_LOG) : (Boolean(ENV.DEV) || ENV.VITE_LLM_DEBUG_LOG === 'true'); export interface PlainTextCompletionOptions { timeoutMs?: number; debugLabel?: string; signal?: AbortSignal; } export class LlmConnectivityError extends Error { constructor(message: string) { super(message); this.name = 'LlmConnectivityError'; } } export class LlmTimeoutError extends LlmConnectivityError { constructor(message: string) { super(message); this.name = 'LlmTimeoutError'; } } export function resolveTimeoutMs(rawValue: string | undefined, fallback: number) { const parsed = Number(rawValue); return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback; } export const REQUEST_TIMEOUT_MS = resolveTimeoutMs( IS_SERVER_RUNTIME ? (NODE_ENV.LLM_REQUEST_TIMEOUT_MS || NODE_ENV.VITE_LLM_REQUEST_TIMEOUT_MS) : ENV.VITE_LLM_REQUEST_TIMEOUT_MS, 15000, ); export const CUSTOM_WORLD_REQUEST_TIMEOUT_MS = resolveTimeoutMs( IS_SERVER_RUNTIME ? (NODE_ENV.LLM_CUSTOM_WORLD_TIMEOUT_MS || NODE_ENV.VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS) : ENV.VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS, Math.max(REQUEST_TIMEOUT_MS, 120000), ); function logLlmDebug(title: string, payload: unknown) { if (!ENABLE_LLM_DEBUG_LOG) { return; } console.warn(title, payload); } function normalizeLlmError(error: unknown): never { if ( typeof DOMException !== 'undefined' && error instanceof DOMException && error.name === 'AbortError' ) { throw new LlmTimeoutError('The LLM request timed out. Please check the network or endpoint.'); } if (error instanceof TypeError) { throw new LlmConnectivityError('Unable to reach the LLM endpoint. The network or proxy may be unavailable.'); } throw error; } function requestLlmEndpoint(input: string, init: RequestInit = {}) { const headers = resolveHeaders(init.headers); if (IS_SERVER_RUNTIME && SERVER_API_KEY.trim()) { headers.Authorization = `Bearer ${SERVER_API_KEY.trim()}`; } const nextInit = { ...init, headers, } satisfies RequestInit; return IS_SERVER_RUNTIME ? fetch(input, nextInit) : fetchWithApiAuth(input, nextInit); } export function isLlmConnectivityError(error: unknown): error is LlmConnectivityError { return error instanceof LlmConnectivityError; } export function isLlmTimeoutError(error: unknown): error is LlmTimeoutError { return error instanceof LlmTimeoutError; } async function requestMessageContent( systemPrompt: string, userPrompt: string, options: PlainTextCompletionOptions = {}, ) { const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS; const debugLabel = options.debugLabel ?? 'chat'; const externalSignal = options.signal; const controller = new AbortController(); const handleExternalAbort = () => controller.abort(); const timeout = setTimeout(() => controller.abort(), timeoutMs); const startedAt = performance.now(); const requestBody = { model: MODEL, messages: [ {role: 'system' as const, content: systemPrompt}, {role: 'user' as const, content: userPrompt}, ], }; const rawPromptText = `[System]\n${systemPrompt}\n\n[User]\n${userPrompt}`; if (externalSignal) { if (externalSignal.aborted) { handleExternalAbort(); } else { externalSignal.addEventListener('abort', handleExternalAbort, { once: true, }); } } try { logLlmDebug(`[LLM:${debugLabel}] prompt text`, rawPromptText); const response = await requestLlmEndpoint(`${API_BASE_URL}/chat/completions`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(requestBody), signal: controller.signal, }); const rawResponseText = await response.text(); if (!response.ok) { if (response.status === 401) { throw new Error('LLM authentication failed. Check the configured API key on the dev server.'); } throw new Error(`LLM request failed: ${response.status} ${rawResponseText}`); } const data = JSON.parse(rawResponseText); const content = data?.choices?.[0]?.message?.content; if (!content || typeof content !== 'string') { throw new Error('LLM response did not include message content.'); } logLlmDebug(`[LLM:${debugLabel}] output text`, content); logLlmDebug(`[LLM:${debugLabel}] completion success`, { model: MODEL, elapsedMs: Math.round(performance.now() - startedAt), responseLength: content.length, timeoutMs, }); return content.trim(); } catch (error) { if (externalSignal?.aborted) { throw externalSignal.reason instanceof Error ? externalSignal.reason : new DOMException('The LLM request was aborted.', 'AbortError'); } console.error(`[LLM:${debugLabel}] completion failed`, { model: MODEL, elapsedMs: Math.round(performance.now() - startedAt), timeoutMs, error: error instanceof Error ? error.message : String(error), }); return normalizeLlmError(error); } finally { clearTimeout(timeout); externalSignal?.removeEventListener('abort', handleExternalAbort); } } export async function requestChatMessageContent( systemPrompt: string, userPrompt: string, options: PlainTextCompletionOptions = {}, ) { return requestMessageContent(systemPrompt, userPrompt, options); } export async function requestPlainTextCompletion( systemPrompt: string, userPrompt: string, options: PlainTextCompletionOptions = {}, ) { return requestMessageContent(systemPrompt, userPrompt, options); } export async function streamPlainTextCompletion( systemPrompt: string, userPrompt: string, options: TextStreamOptions = {}, ) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); try { const response = await requestLlmEndpoint(`${API_BASE_URL}/chat/completions`, { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ model: MODEL, stream: true, messages: [ {role: 'system' as const, content: systemPrompt}, {role: 'user' as const, content: userPrompt}, ], }), signal: controller.signal, }); if (!response.ok) { const rawResponseText = await response.text(); if (response.status === 401) { throw new Error('LLM authentication failed. Check the configured API key on the dev server.'); } throw new Error(`LLM request failed: ${response.status} ${rawResponseText}`); } if (!response.body) { const fallbackText = await requestPlainTextCompletion(systemPrompt, userPrompt); let progressiveText = ''; for (const char of fallbackText) { progressiveText += char; options.onUpdate?.(progressiveText); } return fallbackText; } const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; let accumulatedText = ''; for (;;) { const {done, value} = await reader.read(); if (done) { break; } buffer += decoder.decode(value, {stream: true}); while (buffer.includes('\n\n')) { const boundary = buffer.indexOf('\n\n'); const eventBlock = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2); for (const rawLine of eventBlock.split(/\r?\n/u)) { const line = rawLine.trim(); if (!line.startsWith('data:')) { continue; } const data = line.slice(5).trim(); if (!data || data === '[DONE]') { continue; } try { const parsed = JSON.parse(data); const delta = parsed?.choices?.[0]?.delta?.content; if (typeof delta === 'string' && delta.length > 0) { accumulatedText += delta; options.onUpdate?.(accumulatedText); } } catch { // Ignore malformed SSE frames and continue consuming the stream. } } } } return accumulatedText.trim(); } catch (error) { return normalizeLlmError(error); } finally { clearTimeout(timeout); } }