Files
aiartstudio/server/utils/openai.ts
T
2026-04-26 01:13:38 +08:00

321 lines
8.1 KiB
TypeScript

// server/utils/openai.ts - OpenAI/NewAPI 调用工具:文本、视觉、Responses 流式和 Chat Completions 流式生图。
import OpenAI from "openai";
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";
/** Chat Completions SSE 每个 data chunk 的最小结构 */
interface IChatCompletionStreamChunk {
id?: string;
object?: string;
created?: number;
model?: string;
metadata?: unknown;
choices?: Array<{
delta?: {
content?: string;
role?: string;
};
finish_reason?: string | null;
index?: number;
}>;
usage?: unknown;
}
/** 聚合后的流式生图上游响应,供服务端入库排查使用,不包含 API Key */
interface IImageStreamUpstreamResponse {
content: string;
imageUrl?: string;
chunks: Array<{
id?: string;
created?: number;
model?: string;
content?: string;
finishReason?: string | null;
metadata?: unknown;
hasUsage: boolean;
}>;
usage?: unknown;
}
export interface IAskImageResult extends IImageGenerateData {
/** 完整上游生图接口返回结果,仅服务端内部保存 */
upstreamResponse: unknown;
}
/** 通用 AI 调用函数(支持文本 / 图文 / 多模态) */
export const askAI = async ({
apiKey,
model,
baseURL,
input
}: BaseOptions & {
input: OpenAI.Responses.ResponseCreateParams["input"];
}) => {
const client = new OpenAI({ apiKey, baseURL });
return await client.responses.create({
model,
input
});
};
/** 调用文本模型 */
export const askText = async (
options: BaseOptions & {
text: string;
}
) => {
const res = await askAI({
...options,
input: options.text
});
return res.output_text;
};
/**
* 调用视觉模型:输入「文本 + 图片」,输出文本
*
* 分辨率参数说明:
*
* | 值 | 含义 |
* |------|--------------------------|
* | low | 低分辨率(更快、更省成本) |
* | high | 高分辨率(更精准) |
* | auto | 自动选择 |
*/
export const askVision = async (
options: BaseOptions & {
text: string;
image: string;
detail?: "low" | "high" | "auto";
}
) => {
// 如果图片不是 URL,也不是 data URL,就当成 base64 处理,自动补全 data URL 前缀
const normalizeImage = (img: string) => {
if (img.startsWith("http")) return img;
if (!img.startsWith("data:")) {
return `data:image/png;base64,${img}`;
}
return img;
};
const res = await askAI({
...options,
input: [
{
role: "user",
content: [
{
type: "input_text",
text: options.text
},
{
type: "input_image",
image_url: normalizeImage(options.image),
detail: options.detail || "auto"
}
]
}
]
});
return res.output_text;
};
/** 调用流式图片生成接口,完整 API Key 只在服务端使用 */
export const askImgStream = async ({
apiKey,
prompt
}: {
apiKey: string;
prompt: string;
}): Promise<IAskImageResult> => {
const response = await fetch(CHAT_COMPLETIONS_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
model: IMAGE_GENERATION_MODEL,
stream: true,
messages: [
{
role: "user",
content: prompt
}
]
})
});
if (!response.ok) {
const message = await response.text().catch(() => "");
throw new Error(message || `图片生成失败:${response.status}`);
}
if (!response.body) {
throw new Error("图片生成失败:上游没有返回流");
}
const upstreamResponse = await readChatCompletionStream(response.body);
const imageUrl = extractImageUrlFromStreamText(upstreamResponse.content);
return {
imageUrl,
revisedPrompt: undefined,
upstreamResponse: {
...upstreamResponse,
imageUrl
}
};
};
/** 从流式累积文本中提取图片地址,兼容 Markdown 图片、常见图片 URL 和无扩展下载链接 */
export const extractImageUrlFromStreamText = (text: string): string => {
const markdownImageMatch = text.match(/!\[[^\]]*]\((https?:\/\/[^)\s]+)\)/i);
if (markdownImageMatch?.[1]) {
return cleanupImageUrl(markdownImageMatch[1]);
}
const imageUrlMatch = text.match(
/https?:\/\/[^\s)>'"]+(?:\.(?:png|jpe?g|webp|gif)(?:\?[^\s)>'"]*)?|\/file_download\/[^\s)>'"]+)/i
);
if (imageUrlMatch?.[0]) {
return cleanupImageUrl(imageUrlMatch[0]);
}
const urlMatch = text.match(/https?:\/\/[^\s)>'"]+/i);
if (urlMatch?.[0]) {
return cleanupImageUrl(urlMatch[0]);
}
throw new Error("图片生成失败:未找到图片地址");
};
/** 调用 Responses API 流式接口,保留给其他文本/多模态场景复用 */
export const askStream = async (
options: BaseOptions & {
input: OpenAI.Responses.ResponseCreateParams["input"];
}
) => {
const client = new OpenAI({
apiKey: options.apiKey,
baseURL: options.baseURL
});
const stream = await client.responses.stream({
model: options.model,
input: options.input
});
return stream;
};
/** 读取 Chat Completions SSE 流,累积 delta.content 并提取 usage/chunk 元信息 */
const readChatCompletionStream = async (
stream: ReadableStream<Uint8Array>
): Promise<IImageStreamUpstreamResponse> => {
const reader = stream.getReader();
const decoder = new TextDecoder();
const chunks: IImageStreamUpstreamResponse["chunks"] = [];
let buffer = "";
let content = "";
let usage: unknown;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? "";
for (const line of lines) {
const chunk = parseSseDataLine(line);
if (!chunk) continue;
const deltaContent = collectDeltaContent(chunk);
if (deltaContent) {
content += deltaContent;
}
if (chunk.usage) {
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)
});
}
}
const finalText = buffer + decoder.decode();
for (const line of finalText.split(/\r?\n/)) {
const chunk = parseSseDataLine(line);
if (!chunk) continue;
const deltaContent = collectDeltaContent(chunk);
if (deltaContent) {
content += deltaContent;
}
if (chunk.usage) {
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)
});
}
return {
content,
chunks,
usage
};
};
/** 解析单行 SSE data,跳过空行和 [DONE] */
const parseSseDataLine = (line: string): 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;
return JSON.parse(data) as IChatCompletionStreamChunk;
};
/** 收集一个 chunk 中所有 choice 的 delta.content */
const collectDeltaContent = (chunk: IChatCompletionStreamChunk) => {
return (
chunk.choices
?.map((choice) => choice.delta?.content || "")
.filter(Boolean)
.join("") || ""
);
};
/** 清理模型文本里 URL 后面可能粘上的句末标点 */
const cleanupImageUrl = (url: string) => {
return url.replace(/[,.!?,。!?]+$/u, "");
};