344 lines
9.7 KiB
TypeScript
344 lines
9.7 KiB
TypeScript
import type {TextStreamOptions} from './aiTypes';
|
|
import { fetchWithApiAuth } from './apiClient';
|
|
|
|
const ENV: Partial<ImportMetaEnv> = import.meta.env ?? {};
|
|
|
|
type NodeProcessLike = {
|
|
env?: Record<string, string | undefined>;
|
|
};
|
|
|
|
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<string, string> = {};
|
|
|
|
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);
|
|
}
|
|
}
|