feat: 多功能更新
This commit is contained in:
+188
-28
@@ -2,27 +2,47 @@
|
||||
import OpenAI from "openai";
|
||||
import type { BaseOptions, IImageGenerateData } from "#shared/types/openai";
|
||||
|
||||
const IMAGE_GENERATION_URL = "https://api.qflink.xyz/v1/images/generations";
|
||||
/** 图像生成改走 Chat Completions 流式接口,通常能更快拿到中转平台返回的图片地址 */
|
||||
const CHAT_COMPLETIONS_URL = "https://api.qflink.xyz/v1/chat/completions";
|
||||
const IMAGE_GENERATION_MODEL = "gpt-image-2";
|
||||
const IMAGE_GENERATION_SIZE = "1024x1024";
|
||||
|
||||
interface IImageGenerationResponse {
|
||||
/** 上游创建时间 */
|
||||
/** Chat Completions SSE 每个 data chunk 的最小结构 */
|
||||
interface IChatCompletionStreamChunk {
|
||||
id?: string;
|
||||
object?: string;
|
||||
created?: number;
|
||||
/** 上游图片生成结果列表 */
|
||||
data?: Array<{
|
||||
/** 上游返回的修订提示词,可能为空 */
|
||||
revised_prompt?: string;
|
||||
/** 生成图片地址 */
|
||||
url?: string;
|
||||
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;
|
||||
}>;
|
||||
/** 上游 token 用量 */
|
||||
usage?: unknown;
|
||||
}
|
||||
|
||||
export interface IAskImageResult extends IImageGenerateData {
|
||||
/** 完整上游生图接口返回结果,仅服务端内部保存 */
|
||||
upstreamResponse: IImageGenerationResponse;
|
||||
upstreamResponse: unknown;
|
||||
}
|
||||
|
||||
/** 通用 AI 调用函数(支持文本 / 图文 / 多模态) */
|
||||
@@ -65,8 +85,7 @@ export const askText = async (
|
||||
* |------|--------------------------|
|
||||
* | low | 低分辨率(更快、更省成本) |
|
||||
* | high | 高分辨率(更精准) |
|
||||
* | auto | 自动选择 |
|
||||
*
|
||||
* | auto | 自动选择 |
|
||||
*/
|
||||
export const askVision = async (
|
||||
options: BaseOptions & {
|
||||
@@ -107,40 +126,77 @@ export const askVision = async (
|
||||
return res.output_text;
|
||||
};
|
||||
|
||||
/** 调用图片生成接口,完整 API Key 只在服务端使用 */
|
||||
export const askImg = async ({
|
||||
/** 调用流式图片生成接口,完整 API Key 只在服务端使用 */
|
||||
export const askImgStream = async ({
|
||||
apiKey,
|
||||
prompt
|
||||
}: {
|
||||
apiKey: string;
|
||||
prompt: string;
|
||||
}): Promise<IAskImageResult> => {
|
||||
const result = await $fetch<IImageGenerationResponse>(IMAGE_GENERATION_URL, {
|
||||
const response = await fetch(CHAT_COMPLETIONS_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: {
|
||||
body: JSON.stringify({
|
||||
model: IMAGE_GENERATION_MODEL,
|
||||
prompt,
|
||||
size: IMAGE_GENERATION_SIZE
|
||||
}
|
||||
stream: true,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: prompt
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
const image = result.data?.[0];
|
||||
if (!image?.url) {
|
||||
throw new Error("图片生成失败");
|
||||
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: image.url,
|
||||
revisedPrompt: image.revised_prompt,
|
||||
upstreamResponse: result
|
||||
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"];
|
||||
@@ -158,3 +214,107 @@ export const askStream = async (
|
||||
|
||||
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, "");
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user