feat: 添加图床归档任务支持,优化归档逻辑和错误处理

This commit is contained in:
2026-04-26 13:20:14 +08:00
parent be4c27532f
commit 3559e8adc6
10 changed files with 667 additions and 138 deletions
+48 -38
View File
@@ -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;
};