Files
infinite-canvas/web/src/services/api/image.ts
T
HouYunFei 778fd065ec fix(image): 修复图片生成和编辑请求中尺寸参数传递问题
- 添加 resolveRequestSize 函数处理尺寸参数解析逻辑
- 将 pixelSize 变量重命名为 requestSize 以提高代码可读性
- 修复当尺寸为 auto 时不发送 size 参数的功能
- 确保所有非 auto 尺寸值都会正确传递到请求中
- 更新图片生成和编辑函数中的尺寸参数处理逻辑
2026-05-26 10:19:41 +08:00

286 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import axios from "axios";
import { buildApiUrl, type AiConfig } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
import { nanoid } from "nanoid";
import { dataUrlToFile } from "@/lib/image-utils";
import { imageToDataUrl } from "@/services/image-storage";
import type { ReferenceImage } from "@/types/image";
export type ChatCompletionMessage = {
role: "system" | "user" | "assistant";
content: string | Array<{ type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }>;
};
type ImageApiResponse = {
data?: Array<Record<string, unknown>>;
error?: { message?: string };
code?: number;
msg?: string;
};
const QUALITY_BASE: Record<string, number> = {
low: 1024,
medium: 2048,
high: 2880,
standard: 1024,
hd: 2048,
};
const QUALITY_ALIASES: Record<string, string> = {
"1k": "low",
"2k": "medium",
"4k": "high",
};
function normalizeQuality(quality: string) {
const value = quality.trim().toLowerCase();
const normalized = QUALITY_ALIASES[value] || value;
return QUALITY_BASE[normalized] ? normalized : undefined;
}
/** Map "quality + ratio" to an explicit pixel dimension like "3840x2160". Returns undefined when quality is auto. */
function resolveSize(quality: string, ratio: string): string | undefined {
const basePixels = QUALITY_BASE[quality];
if (!basePixels || ratio === "auto" || !ratio) return undefined;
const parts = ratio.split(":");
if (parts.length !== 2) return undefined;
const w = Number(parts[0]);
const h = Number(parts[1]);
if (!w || !h) return undefined;
const targetPixels = basePixels * basePixels;
const isLandscape = w >= h;
const longRatio = isLandscape ? w / h : h / w;
const longSideRaw = Math.sqrt(targetPixels * longRatio);
const longSide = Math.floor(longSideRaw / 16) * 16;
const shortSide = Math.round((longSide / longRatio) / 16) * 16;
const width = isLandscape ? longSide : shortSide;
const height = isLandscape ? shortSide : longSide;
return `${width}x${height}`;
}
function resolveRequestSize(quality: string | undefined, size: string) {
const value = size.trim();
if (!value || value === "auto") return undefined;
if (/^\d+x\d+$/.test(value)) return value;
return (quality && resolveSize(quality, value)) || value;
}
function resolveImageDataUrl(item: Record<string, unknown>) {
if (typeof item.b64_json === "string" && item.b64_json) {
return `data:image/png;base64,${item.b64_json}`;
}
if (typeof item.url === "string" && item.url) {
return item.url;
}
return null;
}
function parseImagePayload(payload: ImageApiResponse) {
if (typeof payload.code === "number" && payload.code !== 0) {
throw new Error(payload.msg || "请求失败");
}
const images =
payload.data
?.map(resolveImageDataUrl)
.filter((value): value is string => Boolean(value))
.map((dataUrl) => ({ id: nanoid(), dataUrl })) || [];
if (images.length === 0) {
throw new Error("接口没有返回图片");
}
return images;
}
function readAxiosError(error: unknown, fallback: string) {
if (axios.isAxiosError<{ error?: { message?: string }; msg?: string; code?: number }>(error)) {
const responseData = error.response?.data;
return responseData?.msg || responseData?.error?.message || (error.response?.status ? `${fallback}${error.response.status}` : fallback);
}
return error instanceof Error ? error.message : fallback;
}
function parseStreamChunk(chunk: string, onDelta: (value: string) => void) {
let deltaText = "";
for (const eventBlock of chunk.split("\n\n")) {
const data = eventBlock
.split("\n")
.find((line) => line.startsWith("data: "))
?.slice(6);
if (!data || data === "[DONE]") continue;
const delta = (JSON.parse(data) as { choices?: Array<{ delta?: { content?: string } }> }).choices?.[0]?.delta?.content || "";
deltaText += delta;
}
if (deltaText) onDelta(deltaText);
}
function withSystemPrompt(config: AiConfig, prompt: string) {
const systemPrompt = config.systemPrompt.trim();
return systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt;
}
function aiApiUrl(config: AiConfig, path: string) {
return config.channelMode === "remote" ? `/api/v1${path}` : buildApiUrl(config.baseUrl, path);
}
function aiHeaders(config: AiConfig, contentType?: string) {
const token = useUserStore.getState().token;
return config.channelMode === "remote"
? {
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(contentType ? { "Content-Type": contentType } : {}),
}
: {
Authorization: `Bearer ${config.apiKey}`,
...(contentType ? { "Content-Type": contentType } : {}),
};
}
function refreshRemoteUser(config: AiConfig) {
if (config.channelMode === "remote") void useUserStore.getState().hydrateUser();
}
function withSystemMessage(config: AiConfig, messages: ChatCompletionMessage[]) {
const systemPrompt = config.systemPrompt.trim();
return systemPrompt ? [{ role: "system" as const, content: systemPrompt }, ...messages] : messages;
}
export async function requestGeneration(config: AiConfig, prompt: string) {
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const quality = normalizeQuality(config.quality);
const requestSize = resolveRequestSize(quality, config.size);
try {
const response = await axios.post<ImageApiResponse>(
aiApiUrl(config, "/images/generations"),
{
model: config.model,
prompt: withSystemPrompt(config, prompt),
n,
...(quality ? { quality } : {}),
...(requestSize ? { size: requestSize } : {}),
response_format: "b64_json",
},
{
headers: aiHeaders(config, "application/json"),
},
);
const images = parseImagePayload(response.data);
refreshRemoteUser(config);
return images;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
}
export async function requestEdit(config: AiConfig, prompt: string, references: ReferenceImage[]) {
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const quality = normalizeQuality(config.quality);
const requestSize = resolveRequestSize(quality, config.size);
const formData = new FormData();
formData.set("model", config.model);
formData.set("prompt", withSystemPrompt(config, prompt));
formData.set("n", String(n));
formData.set("response_format", "b64_json");
if (quality) {
formData.set("quality", quality);
}
if (requestSize) {
formData.set("size", requestSize);
}
const files = await Promise.all(references.map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) })));
files.forEach((file) => formData.append("image", file));
try {
const response = await axios.post<ImageApiResponse>(aiApiUrl(config, "/images/edits"), formData, { headers: aiHeaders(config) });
const images = parseImagePayload(response.data);
refreshRemoteUser(config);
return images;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
}
export async function requestImageQuestion(config: AiConfig, messages: ChatCompletionMessage[], onDelta: (text: string) => void) {
let buffer = "";
let answer = "";
let processedLength = 0;
try {
const response = await axios.post(
aiApiUrl(config, "/chat/completions"),
{
model: config.model,
messages: withSystemMessage(config, messages),
stream: true,
},
{
headers: {
...aiHeaders(config, "application/json"),
} as Record<string, string>,
responseType: "text",
onDownloadProgress: (event) => {
const responseText = String(event.event?.target?.responseText || "");
const nextText = responseText.slice(processedLength);
processedLength = responseText.length;
buffer += nextText;
const chunks = buffer.split("\n\n");
buffer = chunks.pop() || "";
for (const chunk of chunks) {
parseStreamChunk(chunk, (delta) => {
answer += delta;
onDelta(answer);
});
}
},
},
);
if (typeof response.data === "object" && response.data && "code" in response.data && (response.data as { code?: number; msg?: string }).code !== 0) {
throw new Error((response.data as { msg?: string }).msg || "请求失败");
}
if (typeof response.data === "string") {
let apiError = "";
try {
const payload = JSON.parse(response.data) as { code?: number; msg?: string };
if (typeof payload.code === "number" && payload.code !== 0) {
apiError = payload.msg || "请求失败";
}
} catch {
// ignore plain text stream content
}
if (apiError) throw new Error(apiError);
}
if (buffer) {
parseStreamChunk(buffer, (delta) => {
answer += delta;
onDelta(answer);
});
}
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
refreshRemoteUser(config);
return answer || "没有返回内容";
}
export async function fetchImageModels(config: AiConfig) {
if (config.channelMode === "remote") return config.models;
try {
const response = await axios.get<{ data?: Array<{ id?: string }>; error?: { message?: string } }>(buildApiUrl(config.baseUrl, "/models"), {
headers: {
Authorization: `Bearer ${config.apiKey}`,
},
});
return (response.data.data || [])
.map((model) => model.id)
.filter((id): id is string => Boolean(id))
.sort((a, b) => a.localeCompare(b));
} catch (error) {
throw new Error(readAxiosError(error, "读取模型失败"));
}
}