Files
Genarrative/src/services/llmClient.ts
高物 323aa94c87
Some checks failed
CI / verify (push) Has been cancelled
Merge remote-tracking branch 'origin/server_node'
2026-04-08 19:16:55 +08:00

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);
}
}