feat: 添加图床归档任务支持,优化归档逻辑和错误处理
This commit is contained in:
+48
-38
@@ -5,6 +5,8 @@ import type { BaseOptions, IImageGenerateData } from "#shared/types/openai";
|
||||
/** 图像生成改走 Chat Completions 流式接口,通常能更快拿到中转平台返回的图片地址 */
|
||||
const CHAT_COMPLETIONS_URL = "https://api.qflink.xyz/v1/chat/completions";
|
||||
const IMAGE_GENERATION_MODEL = "gpt-image-2";
|
||||
const DEFAULT_MAX_SSE_DATA_LINE_BYTES = 1024 * 1024;
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
/** Chat Completions SSE 每个 data chunk 的最小结构 */
|
||||
interface IChatCompletionStreamChunk {
|
||||
@@ -28,15 +30,9 @@ interface IChatCompletionStreamChunk {
|
||||
interface IImageStreamUpstreamResponse {
|
||||
content: string;
|
||||
imageUrl?: string;
|
||||
chunks: Array<{
|
||||
id?: string;
|
||||
created?: number;
|
||||
model?: string;
|
||||
content?: string;
|
||||
finishReason?: string | null;
|
||||
metadata?: unknown;
|
||||
hasUsage: boolean;
|
||||
}>;
|
||||
contentLength: number;
|
||||
chunkCount: number;
|
||||
finishReason?: string | null;
|
||||
usage?: unknown;
|
||||
}
|
||||
|
||||
@@ -221,11 +217,14 @@ const readChatCompletionStream = async (
|
||||
): Promise<IImageStreamUpstreamResponse> => {
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
// TODO: 这里会把完整 SSE 文本和 chunk 元数据都累计到当前 Node 进程内存里,
|
||||
// 高并发或超长响应下可能带来明显的事件循环抖动;后续可改为限制缓存窗口或只保留摘要。
|
||||
const chunks: IImageStreamUpstreamResponse["chunks"] = [];
|
||||
const maxDataLineBytes = getPositiveIntegerEnv(
|
||||
"IMAGE_STREAM_MAX_SSE_LINE_BYTES",
|
||||
DEFAULT_MAX_SSE_DATA_LINE_BYTES
|
||||
);
|
||||
let buffer = "";
|
||||
let content = "";
|
||||
let chunkCount = 0;
|
||||
let finishReason: string | null | undefined;
|
||||
let usage: unknown;
|
||||
|
||||
while (true) {
|
||||
@@ -233,12 +232,14 @@ const readChatCompletionStream = async (
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
assertSseBufferSize(buffer, maxDataLineBytes);
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const chunk = parseSseDataLine(line);
|
||||
const chunk = parseSseDataLine(line, maxDataLineBytes);
|
||||
if (!chunk) continue;
|
||||
chunkCount += 1;
|
||||
|
||||
const deltaContent = collectDeltaContent(chunk);
|
||||
if (deltaContent) {
|
||||
@@ -249,23 +250,17 @@ const readChatCompletionStream = async (
|
||||
usage = chunk.usage;
|
||||
}
|
||||
|
||||
chunks.push({
|
||||
id: chunk.id,
|
||||
created: chunk.created,
|
||||
model: chunk.model,
|
||||
content: deltaContent || undefined,
|
||||
finishReason: chunk.choices?.find((choice) => choice.finish_reason)
|
||||
?.finish_reason,
|
||||
metadata: chunk.metadata,
|
||||
hasUsage: Boolean(chunk.usage)
|
||||
});
|
||||
finishReason =
|
||||
chunk.choices?.find((choice) => choice.finish_reason)
|
||||
?.finish_reason ?? finishReason;
|
||||
}
|
||||
}
|
||||
|
||||
const finalText = buffer + decoder.decode();
|
||||
for (const line of finalText.split(/\r?\n/)) {
|
||||
const chunk = parseSseDataLine(line);
|
||||
const chunk = parseSseDataLine(line, maxDataLineBytes);
|
||||
if (!chunk) continue;
|
||||
chunkCount += 1;
|
||||
|
||||
const deltaContent = collectDeltaContent(chunk);
|
||||
if (deltaContent) {
|
||||
@@ -276,35 +271,35 @@ const readChatCompletionStream = async (
|
||||
usage = chunk.usage;
|
||||
}
|
||||
|
||||
chunks.push({
|
||||
id: chunk.id,
|
||||
created: chunk.created,
|
||||
model: chunk.model,
|
||||
content: deltaContent || undefined,
|
||||
finishReason: chunk.choices?.find((choice) => choice.finish_reason)
|
||||
?.finish_reason,
|
||||
metadata: chunk.metadata,
|
||||
hasUsage: Boolean(chunk.usage)
|
||||
});
|
||||
finishReason =
|
||||
chunk.choices?.find((choice) => choice.finish_reason)?.finish_reason ??
|
||||
finishReason;
|
||||
}
|
||||
|
||||
return {
|
||||
content,
|
||||
chunks,
|
||||
contentLength: content.length,
|
||||
chunkCount,
|
||||
finishReason,
|
||||
usage
|
||||
};
|
||||
};
|
||||
|
||||
/** 解析单行 SSE data,跳过空行和 [DONE] */
|
||||
const parseSseDataLine = (line: string): IChatCompletionStreamChunk | null => {
|
||||
const parseSseDataLine = (
|
||||
line: string,
|
||||
maxDataLineBytes: number
|
||||
): IChatCompletionStreamChunk | null => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("data:")) return null;
|
||||
|
||||
const data = trimmed.slice("data:".length).trim();
|
||||
if (!data || data === "[DONE]") return null;
|
||||
|
||||
// TODO: JSON.parse 仍运行在主线程;如果后续接入更大的 SSE chunk,需要增加单行大小保护,
|
||||
// 避免异常大包在单个 tick 内长时间占用事件循环。
|
||||
if (textEncoder.encode(data).byteLength > maxDataLineBytes) {
|
||||
throw new Error("图片生成失败:流式响应过大");
|
||||
}
|
||||
|
||||
return JSON.parse(data) as IChatCompletionStreamChunk;
|
||||
};
|
||||
|
||||
@@ -322,3 +317,18 @@ const collectDeltaContent = (chunk: IChatCompletionStreamChunk) => {
|
||||
const cleanupImageUrl = (url: string) => {
|
||||
return url.replace(/[,.!?,。!?]+$/u, "");
|
||||
};
|
||||
|
||||
/** 限制尚未切分出换行的 SSE 缓冲区,避免异常长 data 行持续堆在内存里 */
|
||||
const assertSseBufferSize = (buffer: string, maxDataLineBytes: number) => {
|
||||
if (
|
||||
!buffer.includes("\n") &&
|
||||
textEncoder.encode(buffer).byteLength > maxDataLineBytes
|
||||
) {
|
||||
throw new Error("图片生成失败:流式响应过大");
|
||||
}
|
||||
};
|
||||
|
||||
const getPositiveIntegerEnv = (name: string, fallback: number) => {
|
||||
const value = Number.parseInt(process.env[name] ?? "", 10);
|
||||
return Number.isInteger(value) && value > 0 ? value : fallback;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user