Files
ttstd-infinite-canvas/web/src/services/api/image.ts
VicoHu 1e228a04dc feat(plugins): 方舟 Agent Plan - 新增图片与视频协议插件
独立 /api/plan/v3 Seedream、Seedance 插件,并同步宿主能力默认值与渠道识别,避免与官方 /api/v3 凭证混用。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 17:55:12 +08:00

335 lines
18 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 { resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
import { dataUrlToFile } from "@/lib/image-utils";
import { buildImageReferencePromptText } from "@/lib/image-reference-prompt";
import { imageToDataUrl } from "@/services/image-storage";
import type { ReferenceImage } from "@/types/image";
import { withOpenAIPromptCacheKey } from "@/lib/openai-prompt-cache";
import { modelCapabilityConfigFor, normalizeImageValue } from "@/lib/model-capabilities";
import { buildGeminiImageGenerationConfig, parseGeminiImageDataUrl, type GeminiImageGenerationConfig } from "@/lib/gemini-image";
import { isVolcengineArkImageProtocol } from "@/lib/model-protocols";
import { aiApiUrl, aiHeaders, imageChannelTransport, postChannelJSON, postGeminiJSON, postVolcengineArkImage } from "@/services/api/image-transport";
const IMAGE_OUTPUT_FORMAT = "png";
import type { AiTextMessage, GeminiPart, ImageApiResponse, RequestOptions, ResponseApiPayload, ResponseFunctionTool, ResponseInputMessage, ToolChoice, ToolResponseResult } from "@/services/api/image-contracts";
import { normalizeGrokImageResolution, normalizeQuality, normalizeVolcengineArkImageSize, resolveImageRequestSize, validateImageCapability } from "@/services/api/image-validation";
import { parseGeminiImagePayload, parseImagePayload, readAxiosError } from "@/services/api/image-response";
import { toChatCompletionMessages, toChatCompletionToolChoice, toClaudeBody, toGeminiBody, toGeminiToolOptions, toResponseInput, toResponseTool, withSystemMessage } from "@/services/api/image-protocols";
import { requestGeminiStreamingResponse, requestStreamingChatCompletion, requestStreamingClaude, requestStreamingResponse } from "@/services/api/image-streaming";
export { buildBackendToolRequests } from "@/services/api/image-protocols";
export type { AiTextContentPart, AiTextMessage, ResponseFunctionTool, ResponseInputMessage, ResponseToolCall, ToolChoice, ToolResponseResult } from "@/services/api/image-contracts";
function withSystemPrompt(config: AiConfig, prompt: string) {
const systemPrompt = config.systemPrompt.trim();
return systemPrompt ? `${systemPrompt}\n\n${prompt}` : prompt;
}
function isToolChoiceCompatibilityError(error: unknown) {
const message = error instanceof Error ? error.message : "";
return /tool[_\s-]?choice|thinking\s+mode/i.test(message);
}
async function requestGeminiImages(config: AiConfig, prompt: string, references: ReferenceImage[], count: number, generationConfig: GeminiImageGenerationConfig, options?: RequestOptions) {
// 参考图先完整读取并校验一次,再复用已解析的 inlineData;不能让每个输出重新读取 storageKey,
// 否则资源缓存瞬时未命中时会出现“偶发缺参考图”或把 storageKey 当 URL 请求的问题。
const referenceParts = await Promise.all(
references.map(async (image) => {
const dataUrl = await imageToDataUrl(image);
const inlineData = parseGeminiImageDataUrl(dataUrl);
return { inlineData };
}),
);
const requests = Array.from({ length: count }, () => requestGeminiImagesOnce(config, prompt, referenceParts, generationConfig, options));
return (await Promise.all(requests)).flat();
}
async function requestGeminiImagesOnce(config: AiConfig, prompt: string, referenceParts: GeminiPart[], generationConfig: GeminiImageGenerationConfig, options?: RequestOptions) {
const parts: GeminiPart[] = [{ text: prompt }, ...referenceParts];
const response = await postGeminiJSON(
config,
{
...toGeminiBody(config, [{ role: "user", content: prompt }], { generationConfig }),
contents: [{ role: "user", parts }],
},
options,
);
return parseGeminiImagePayload(response);
}
export async function requestGeneration(config: AiConfig, prompt: string, options?: RequestOptions) {
const selectedModel = config.model || config.imageModel;
const requestConfig = resolveModelRequestConfig(config, selectedModel);
const imageProfile = modelCapabilityConfigFor(config, selectedModel).image!;
validateImageCapability(imageProfile, []);
const normalizedImage = normalizeImageValue(imageProfile, config);
const n = Number(normalizedImage.count);
if (requestConfig.interfaceType === "gemini-image") {
try {
return await requestGeminiImages(requestConfig, prompt, [], n, buildGeminiImageGenerationConfig(normalizedImage.size, normalizedImage.quality), options);
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
}
if (requestConfig.interfaceType === "grok-image") {
try {
const size = normalizedImage.size && normalizedImage.size !== "auto" ? normalizedImage.size : undefined;
const aspectRatio = size?.includes(":") ? size : undefined;
const resolution = normalizeGrokImageResolution(normalizedImage.quality);
const responseData = await postChannelJSON<ImageApiResponse>(
requestConfig,
aiApiUrl(requestConfig, "/images/generations"),
{
model: requestConfig.model,
prompt: withSystemPrompt(requestConfig, prompt),
n,
response_format: "url",
...(size ? { size } : {}),
...(aspectRatio ? { aspect_ratio: aspectRatio } : {}),
...(resolution ? { resolution } : {}),
},
options,
);
return parseImagePayload(responseData);
} catch (error) {
throw new Error(readAxiosError(error, "Grok 图片生成失败"));
}
}
const quality = imageProfile.quality.supported && normalizedImage.quality !== "auto" ? normalizeQuality(normalizedImage.quality) || normalizedImage.quality : undefined;
const requestSize = resolveImageRequestSize(imageProfile, quality, normalizedImage.size);
const isVolcengineArk = isVolcengineArkImageProtocol(requestConfig.interfaceType);
const normalizedRequestSize = requestSize?.parameter === "size" && isVolcengineArk ? { ...requestSize, value: normalizeVolcengineArkImageSize(requestSize.value)! } : requestSize;
try {
const payload = isVolcengineArk
? {
model: requestConfig.model,
prompt: withSystemPrompt(requestConfig, prompt),
n,
response_format: "b64_json",
watermark: false,
...(normalizedRequestSize ? { [normalizedRequestSize.parameter]: normalizedRequestSize.value } : {}),
}
: {
model: requestConfig.model,
prompt: withSystemPrompt(requestConfig, prompt),
n,
...(quality ? { quality } : {}),
...(requestSize ? { [requestSize.parameter]: requestSize.value } : {}),
...(imageProfile.responseFormat.supported ? { response_format: "b64_json" } : {}),
...(imageProfile.outputFormat.supported ? { output_format: IMAGE_OUTPUT_FORMAT } : {}),
...(imageProfile.transparentBackground.supported && normalizedImage.transparentBackground === "true" ? { background: "transparent" } : {}),
};
const responseData = isVolcengineArk ? await postVolcengineArkImage(requestConfig, payload, options) : await postChannelJSON<ImageApiResponse>(requestConfig, aiApiUrl(requestConfig, "/images/generations"), payload, options);
const images = parseImagePayload(responseData);
return images;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
}
async function grokImageInputURL(image: ReferenceImage) {
const candidate = image.url?.trim() || "";
if (/^https?:\/\//i.test(candidate)) return candidate;
return imageToDataUrl(image);
}
export async function requestEdit(config: AiConfig, prompt: string, references: ReferenceImage[], mask?: ReferenceImage, options?: RequestOptions) {
const selectedModel = config.model || config.imageModel;
const requestConfig = resolveModelRequestConfig(config, selectedModel);
const imageProfile = modelCapabilityConfigFor(config, selectedModel).image!;
validateImageCapability(imageProfile, references, mask);
const normalizedImage = normalizeImageValue(imageProfile, config);
const n = Number(normalizedImage.count);
const requestPrompt = buildImageReferencePromptText(prompt, references);
if (requestConfig.interfaceType === "gemini-image") {
if (mask) throw new Error("Gemini 调用格式暂不支持蒙版编辑");
try {
return await requestGeminiImages(requestConfig, requestPrompt, references, n, buildGeminiImageGenerationConfig(normalizedImage.size, normalizedImage.quality), options);
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
}
if (requestConfig.interfaceType === "grok-image") {
if (mask) throw new Error("Grok 图片协议不支持蒙版编辑,请移除蒙版后重试");
if (references.length !== 1) throw new Error("Grok 图片编辑必须提供且仅支持 1 张参考图");
try {
const imageUrl = await grokImageInputURL(references[0]);
const size = normalizedImage.size && normalizedImage.size !== "auto" ? normalizedImage.size : undefined;
const aspectRatio = size?.includes(":") ? size : undefined;
const resolution = normalizeGrokImageResolution(normalizedImage.quality);
const response = await postChannelJSON<ImageApiResponse>(
requestConfig,
aiApiUrl(requestConfig, "/images/edits"),
{
model: requestConfig.model,
prompt: withSystemPrompt(requestConfig, requestPrompt),
image: { url: imageUrl },
n,
response_format: "url",
...(size ? { size } : {}),
...(aspectRatio ? { aspect_ratio: aspectRatio } : {}),
...(resolution ? { resolution } : {}),
},
options,
);
return parseImagePayload(response);
} catch (error) {
throw new Error(readAxiosError(error, "Grok 图片编辑失败"));
}
}
if (isVolcengineArkImageProtocol(requestConfig.interfaceType)) {
if (mask) throw new Error("火山方舟图片协议不支持蒙版编辑,请移除蒙版后重试");
const quality = imageProfile.quality.supported && normalizedImage.quality !== "auto" ? normalizeQuality(normalizedImage.quality) || normalizedImage.quality : undefined;
const sizeRequest = resolveImageRequestSize(imageProfile, quality, normalizedImage.size);
const requestSize = sizeRequest?.parameter === "size" ? { ...sizeRequest, value: normalizeVolcengineArkImageSize(sizeRequest.value)! } : sizeRequest;
try {
const images = await Promise.all(references.map((image) => imageToDataUrl(image)));
const response = await postVolcengineArkImage(
requestConfig,
{
model: requestConfig.model,
prompt: withSystemPrompt(requestConfig, requestPrompt),
n,
response_format: "b64_json",
watermark: false,
...(requestSize ? { [requestSize.parameter]: requestSize.value } : {}),
...(images.length === 1 ? { image: images[0] } : images.length > 1 ? { image: images } : {}),
},
options,
);
return parseImagePayload(response);
} catch (error) {
throw new Error(readAxiosError(error, "火山方舟图片生成失败"));
}
}
const quality = imageProfile.quality.supported && normalizedImage.quality !== "auto" ? normalizeQuality(normalizedImage.quality) || normalizedImage.quality : undefined;
const requestSize = resolveImageRequestSize(imageProfile, quality, normalizedImage.size);
const formData = new FormData();
formData.set("model", requestConfig.model);
formData.set("prompt", withSystemPrompt(requestConfig, requestPrompt));
formData.set("n", String(n));
if (imageProfile.responseFormat.supported) formData.set("response_format", "b64_json");
if (imageProfile.outputFormat.supported) formData.set("output_format", IMAGE_OUTPUT_FORMAT);
if (imageProfile.transparentBackground.supported && normalizedImage.transparentBackground === "true") {
formData.set("background", "transparent");
}
if (quality) {
formData.set("quality", quality);
}
if (requestSize) {
formData.set(requestSize.parameter, requestSize.value);
}
const files = await Promise.all(references.map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) })));
files.forEach((file) => formData.append("image", file));
if (mask) formData.set("mask", dataUrlToFile(mask));
try {
const images = parseImagePayload(await imageChannelTransport(requestConfig).postForm<ImageApiResponse>(aiApiUrl(requestConfig, "/images/edits"), formData, { signal: options?.signal, headers: aiHeaders(requestConfig) }));
return images;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
}
export async function requestImageQuestion(config: AiConfig, messages: AiTextMessage[], onDelta: (text: string) => void, options?: RequestOptions) {
const requestConfig = resolveModelRequestConfig(config, config.model || config.textModel);
try {
if (requestConfig.apiFormat === "gemini") {
const answer = (await requestGeminiStreamingResponse(requestConfig, toGeminiBody(requestConfig, messages), onDelta, options)).content || "没有返回内容";
if (answer === "没有返回内容") onDelta(answer);
return answer;
}
if (requestConfig.interfaceType === "claude-api") {
const answer = (await requestStreamingClaude(requestConfig, toClaudeBody(requestConfig, messages), onDelta, options)).content || "没有返回内容";
if (answer === "没有返回内容") onDelta(answer);
return answer;
}
if (requestConfig.interfaceType === "chat-completion" || !requestConfig.interfaceType) {
const answer =
(
await requestStreamingChatCompletion(
requestConfig,
{
model: requestConfig.model,
messages: toChatCompletionMessages(withSystemMessage(requestConfig, messages)),
},
onDelta,
options,
)
).content || "没有返回内容";
if (answer === "没有返回内容") onDelta(answer);
return answer;
}
const answer =
(
await requestStreamingResponse(
requestConfig,
{
model: requestConfig.model,
input: toResponseInput(withSystemMessage(requestConfig, messages)),
},
onDelta,
options,
)
).content || "没有返回内容";
if (answer === "没有返回内容") onDelta(answer);
return answer;
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
}
export async function requestToolResponse(config: AiConfig, messages: ResponseInputMessage[], tools: ResponseFunctionTool[], toolChoice: ToolChoice = "auto", onDelta?: (text: string) => void, options?: RequestOptions): Promise<ToolResponseResult> {
const requestConfig = resolveModelRequestConfig(config, config.model || config.textModel);
try {
if (requestConfig.apiFormat === "gemini") {
return await requestGeminiStreamingResponse(requestConfig, toGeminiBody(requestConfig, messages, toGeminiToolOptions(tools, toolChoice)), onDelta, options);
}
if (requestConfig.interfaceType === "claude-api") return await requestStreamingClaude(requestConfig, toClaudeBody(requestConfig, messages, tools), onDelta, options);
if (requestConfig.interfaceType === "chat-completion" || !requestConfig.interfaceType) {
const chatPayload: Record<string, unknown> = {
model: requestConfig.model,
messages: toChatCompletionMessages(withSystemMessage(requestConfig, messages)),
tools,
tool_choice: toChatCompletionToolChoice(toolChoice),
parallel_tool_calls: false,
};
try {
return await requestStreamingChatCompletion(requestConfig, chatPayload, onDelta, options);
} catch (error) {
if (!isToolChoiceCompatibilityError(error)) throw error;
// 部分 OpenAI 兼容上游仅支持 auto,思考模式则可能要求完全省略该字段。
if (toolChoice !== "auto") {
try {
return await requestStreamingChatCompletion(requestConfig, { ...chatPayload, tool_choice: toChatCompletionToolChoice("auto") }, onDelta, options);
} catch (autoError) {
if (!isToolChoiceCompatibilityError(autoError)) throw autoError;
}
}
const { tool_choice: _ignored, ...withoutToolChoice } = chatPayload;
void _ignored;
return await requestStreamingChatCompletion(requestConfig, withoutToolChoice, onDelta, options);
}
}
return await requestStreamingResponse(
requestConfig,
withOpenAIPromptCacheKey(
{
model: requestConfig.model,
input: toResponseInput(withSystemMessage(requestConfig, messages)),
tools: tools.map(toResponseTool),
tool_choice: toolChoice,
parallel_tool_calls: false,
},
options?.promptCacheKey,
),
onDelta,
options,
);
} catch (error) {
throw new Error(readAxiosError(error, "请求失败"));
}
}
export { fetchChannelModels, fetchImageModels } from "@/services/api/image-models";
export type { ChannelModelFetchResult } from "@/services/api/image-models";