diff --git a/app/(chat)/api/chat/_lib/stream.ts b/app/(chat)/api/chat/_lib/stream.ts new file mode 100644 index 000000000..02fe5c313 --- /dev/null +++ b/app/(chat)/api/chat/_lib/stream.ts @@ -0,0 +1,416 @@ +import { JsonToSseTransformStream } from "ai"; +import { extractImageUrlsFromText } from "@/lib/image-url"; +import type { + ResolvedChatMediaInput, + ResolvedImageRef, + ResolvedTargetAssets, +} from "@/lib/ai/context-memory/types"; +import { generateUUID } from "@/lib/utils"; + +export type StreamWriter = { + write: (chunk: any) => void; +}; + +export function safeWriteStreamChunk( + writer: StreamWriter, + chunk: any, + label: string +): boolean { + try { + writer.write(chunk); + return true; + } catch (error) { + console.warn(`[Chat API] Stream write skipped (${label})`, error); + return false; + } +} + +export function writeAssistantTextChunk( + writer: StreamWriter, + text: string, + label: string, + messageId = generateUUID() +) { + safeWriteStreamChunk( + writer, + { + type: "data-appendMessage", + data: JSON.stringify({ + id: messageId, + role: "assistant", + parts: [{ type: "text", text }], + }), + }, + label + ); + return messageId; +} + +export function writeStructuredDataChunk( + writer: StreamWriter, + type: string, + data: unknown, + label: string +) { + safeWriteStreamChunk( + writer, + { + type, + data: JSON.stringify(data), + }, + label + ); +} + +export function writeProtocolDebugChunk( + writer: StreamWriter, + enabled: boolean, + data: unknown, + label: string +) { + if (!enabled) return; + writeStructuredDataChunk(writer, "data-protocolDebug", data, label); +} + +function normalizeImageLoadingProviderType(providerType?: string | null): string { + const normalized = String(providerType || "").trim().toLowerCase(); + if (normalized === "gemini-image" || normalized === "huanke-gemini") { + return "gemini"; + } + if ( + normalized === "alibaba-image" || + normalized === "bfl-image" || + normalized === "ruxa-image" || + normalized === "fal-image" || + normalized === "openai-image" || + normalized === "stepfun-image" || + normalized === "midjourney" || + normalized === "runway" || + normalized === "krea" || + normalized === "volcano-vision" || + normalized === "gemini" + ) { + return normalized; + } + return "alibaba-image"; +} + +export function writeImageGenerationStatusChunk( + writer: StreamWriter, + params: { + phase: "queued" | "processing" | "finalizing" | "failed" | "completed"; + providerType?: string | null; + modelName?: string | null; + imageCount?: number | null; + queuePosition?: number | null; + sameModelRunningCount?: number | null; + concurrencyLimit?: number | null; + estimatedWaitMs?: number | null; + }, + label: string +) { + writeStructuredDataChunk( + writer, + "data-imageGenerationStatus", + { + phase: params.phase, + providerType: normalizeImageLoadingProviderType(params.providerType), + modelName: String(params.modelName || "").trim() || undefined, + imageCount: + Number.isFinite(Number(params.imageCount)) && Number(params.imageCount) > 0 + ? Number(params.imageCount) + : undefined, + queuePosition: + Number.isFinite(Number(params.queuePosition)) && + Number(params.queuePosition) >= 0 + ? Number(params.queuePosition) + : undefined, + sameModelRunningCount: + Number.isFinite(Number(params.sameModelRunningCount)) && + Number(params.sameModelRunningCount) >= 0 + ? Number(params.sameModelRunningCount) + : undefined, + concurrencyLimit: + Number.isFinite(Number(params.concurrencyLimit)) && + Number(params.concurrencyLimit) > 0 + ? Number(params.concurrencyLimit) + : undefined, + estimatedWaitMs: + Number.isFinite(Number(params.estimatedWaitMs)) && + Number(params.estimatedWaitMs) >= 0 + ? Number(params.estimatedWaitMs) + : undefined, + }, + label + ); +} + +export type AgentProgressStreamPayload = { + stage: "planning" | "clarify" | "executing" | "completed" | "failed"; + currentStep: + | "lock_target" + | "analyze" + | "select_tool" + | "execute" + | "finalize"; + title?: string; + message?: string; + toolName?: string; + capability?: string; +}; + +export function writeAgentProgressChunk( + writer: StreamWriter, + payload: AgentProgressStreamPayload, + label: string +) { + writeStructuredDataChunk( + writer, + "data-agentProgress", + { + stage: payload.stage, + currentStep: payload.currentStep, + title: String(payload.title || "").trim() || undefined, + message: String(payload.message || "").trim() || undefined, + toolName: String(payload.toolName || "").trim() || undefined, + capability: String(payload.capability || "").trim() || undefined, + }, + label + ); +} + +export function writeCanvasMediaChunk( + writer: StreamWriter, + params: { + messageId: string; + text?: string; + imageUrls?: string[]; + videoUrls?: string[]; + prompt?: string | null; + sourceImageUrls?: string[] | null; + agentPresetId?: string | null; + agentPresetName?: string | null; + agentCategoryId?: string | null; + agentCategoryName?: string | null; + agentExecutionSnapshotId?: string | null; + }, + label: string +) { + const imageUrls = Array.isArray(params.imageUrls) + ? params.imageUrls + : extractImageUrlsFromText(String(params.text || "")); + const videoUrls = Array.isArray(params.videoUrls) ? params.videoUrls : []; + const normalizedImageUrls = Array.from( + new Set( + imageUrls + .map((url) => String(url || "").trim()) + .filter((url) => /^https?:\/\//i.test(url)) + ) + ); + const normalizedVideoUrls = Array.from( + new Set( + videoUrls + .map((url) => String(url || "").trim()) + .filter((url) => /^https?:\/\//i.test(url)) + ) + ); + + if (normalizedImageUrls.length === 0 && normalizedVideoUrls.length === 0) { + return; + } + + writeStructuredDataChunk( + writer, + "data-canvasMedia", + { + messageId: params.messageId, + imageUrls: normalizedImageUrls, + videoUrls: normalizedVideoUrls, + prompt: String(params.prompt || "").trim() || undefined, + sourceImageUrls: Array.isArray(params.sourceImageUrls) + ? params.sourceImageUrls + .map((url) => String(url || "").trim()) + .filter((url) => /^https?:\/\//i.test(url)) + : [], + agentPresetId: String(params.agentPresetId || "").trim() || undefined, + agentPresetName: String(params.agentPresetName || "").trim() || undefined, + agentCategoryId: String(params.agentCategoryId || "").trim() || undefined, + agentCategoryName: String(params.agentCategoryName || "").trim() || undefined, + agentExecutionSnapshotId: + String(params.agentExecutionSnapshotId || "").trim() || undefined, + }, + label + ); +} + +type AgentTargetLockRefPreview = { + url: string; + title?: string; + nodeId?: string; + assetId?: string; + source: ResolvedImageRef["source"]; + role: ResolvedImageRef["role"]; + isSelected?: boolean; + positionLabel?: string; +}; + +export type AgentTargetLockStreamPayload = { + stage: "locked" | "clarify" | "unresolved"; + resolutionMode: ResolvedTargetAssets["resolutionMode"]; + confidence: number; + reason: string; + needsClarification: boolean; + clarificationMessage?: string; + targetCount: number; + candidateCount: number; + targets: AgentTargetLockRefPreview[]; + candidates: AgentTargetLockRefPreview[]; +}; + +function sortTargetLockRefsForCanvasOrdering(refs: ResolvedImageRef[]) { + return [...refs].sort((left, right) => { + const leftY = Number(left.y ?? 0); + const rightY = Number(right.y ?? 0); + if (Math.abs(leftY - rightY) > 12) { + return leftY - rightY; + } + return Number(left.x ?? 0) - Number(right.x ?? 0); + }); +} + +function getTargetLockPositionLabel( + ref: ResolvedImageRef, + refs: ResolvedImageRef[] +): string { + if (refs.length <= 1) return "中间"; + const xs = refs.map((item) => Number(item.x ?? 0)); + const ys = refs.map((item) => Number(item.y ?? 0)); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + const spanX = Math.max(1, maxX - minX); + const spanY = Math.max(1, maxY - minY); + const relX = (Number(ref.x ?? 0) - minX) / spanX; + const relY = (Number(ref.y ?? 0) - minY) / spanY; + + const horizontal = relX <= 0.33 ? "左" : relX >= 0.66 ? "右" : "中"; + const vertical = relY <= 0.33 ? "上" : relY >= 0.66 ? "下" : "中"; + + if (horizontal === "中" && vertical === "中") return "中间"; + return `${vertical}${horizontal}`; +} + +function serializeAgentTargetLockRefs(refs: ResolvedImageRef[]) { + const orderedRefs = sortTargetLockRefsForCanvasOrdering(refs).slice(0, 4); + return orderedRefs.map((ref) => ({ + url: ref.url, + title: String(ref.title || "").trim() || undefined, + nodeId: ref.nodeId, + assetId: ref.assetId, + source: ref.source, + role: ref.role, + isSelected: ref.isSelected === true, + positionLabel: getTargetLockPositionLabel(ref, orderedRefs), + })) satisfies AgentTargetLockRefPreview[]; +} + +export function buildAgentTargetLockPayload(params: { + requestTargetResolution: ResolvedTargetAssets; + requestResolvedMediaInput: ResolvedChatMediaInput; + needsClarification: boolean; + clarificationMessage?: string; +}): AgentTargetLockStreamPayload { + const stage = params.needsClarification + ? "clarify" + : params.requestTargetResolution.targetRefs.length > 0 + ? "locked" + : "unresolved"; + + return { + stage, + resolutionMode: params.requestTargetResolution.resolutionMode, + confidence: Number(params.requestTargetResolution.confidence || 0), + reason: params.requestTargetResolution.reason, + needsClarification: params.needsClarification, + clarificationMessage: params.clarificationMessage?.trim() || undefined, + targetCount: params.requestTargetResolution.targetRefs.length, + candidateCount: params.requestTargetResolution.candidateRefs.length, + targets: serializeAgentTargetLockRefs(params.requestTargetResolution.targetRefs), + candidates: serializeAgentTargetLockRefs( + params.requestTargetResolution.candidateRefs.length > 0 + ? params.requestTargetResolution.candidateRefs + : params.requestResolvedMediaInput.imageRefs + ), + }; +} + +export function writeAgentTargetLockChunk( + writer: StreamWriter, + payload: AgentTargetLockStreamPayload, + label: string +) { + writeStructuredDataChunk(writer, "data-agentTargetLock", payload, label); +} + +export function createSseResponse(stream: ReadableStream) { + return new Response(stream.pipeThrough(new JsonToSseTransformStream()), { + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} + +export function startStreamKeepalive( + writer: StreamWriter, + label: string, + intervalMs = 15_000 +) { + const timer = setInterval(() => { + safeWriteStreamChunk( + writer, + { + type: "data-keepalive", + data: JSON.stringify({ ts: Date.now(), label }), + }, + `${label}:keepalive` + ); + }, intervalMs); + + return () => clearInterval(timer); +} + +export async function runBoundedParallel( + items: T[], + workerCount: number, + worker: (item: T, index: number) => Promise +) { + const maxWorkers = Math.max(1, Math.min(workerCount, items.length)); + let cursor = 0; + let firstError: unknown = null; + + const workers = Array.from({ length: maxWorkers }, async () => { + while (cursor < items.length) { + const index = cursor; + cursor += 1; + if (firstError) { + return; + } + + try { + await worker(items[index]!, index); + } catch (error) { + firstError = firstError || error; + } + } + }); + + await Promise.all(workers); + + if (firstError) { + throw firstError; + } +} diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 69fb07ea3..69e2397e9 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -3,7 +3,6 @@ import { convertToModelMessages, createUIMessageStream, generateText, - JsonToSseTransformStream, smoothStream, stepCountIs, streamText, @@ -120,7 +119,6 @@ import { resolveChatMediaInput } from "@/lib/ai/context-memory/media-input"; import { resolveTargetAssets } from "@/lib/ai/context-memory/target-resolution"; import type { ResolvedChatMediaInput, - ResolvedImageRef, ResolvedTargetAssets, } from "@/lib/ai/context-memory/types"; import { ChatSDKError } from "@/lib/errors"; @@ -249,6 +247,20 @@ import type { import { generateTitleFromUserMessage } from "../../actions"; import { type PostRequestBody, postRequestBodySchema } from "./schema"; import { isAiModelMultimodal } from "@/lib/db/ai-model-flags"; +import { + buildAgentTargetLockPayload, + createSseResponse, + runBoundedParallel, + safeWriteStreamChunk, + startStreamKeepalive, + type StreamWriter, + writeAgentProgressChunk, + writeAgentTargetLockChunk, + writeAssistantTextChunk, + writeCanvasMediaChunk, + writeImageGenerationStatusChunk, + writeProtocolDebugChunk, +} from "./_lib/stream"; const AGENT_REPLACEMENT_PARALLEL_WORKER_COUNT = Math.max( 1, @@ -412,402 +424,6 @@ function injectOptimizedPromptSection(params: { return `${message.trim()}\n\n${optimizedPromptSection}`; } -type StreamWriter = { - write: (chunk: any) => void; -}; - -function safeWriteStreamChunk(writer: StreamWriter, chunk: any, label: string): boolean { - try { - writer.write(chunk); - return true; - } catch (error) { - console.warn(`[Chat API] Stream write skipped (${label})`, error); - return false; - } -} - -function writeAssistantTextChunk( - writer: StreamWriter, - text: string, - label: string, - messageId = generateUUID(), -) { - safeWriteStreamChunk( - writer, - { - type: "data-appendMessage", - data: JSON.stringify({ - id: messageId, - role: "assistant", - parts: [{ type: "text", text }], - }), - }, - label, - ); - return messageId; -} - -function writeStructuredDataChunk( - writer: StreamWriter, - type: string, - data: unknown, - label: string, -) { - safeWriteStreamChunk( - writer, - { - type, - data: JSON.stringify(data), - }, - label, - ); -} - -function writeProtocolDebugChunk( - writer: StreamWriter, - enabled: boolean, - data: unknown, - label: string, -) { - if (!enabled) return; - writeStructuredDataChunk(writer, "data-protocolDebug", data, label); -} - -function normalizeImageLoadingProviderType(providerType?: string | null): string { - const normalized = String(providerType || "").trim().toLowerCase(); - if (normalized === "gemini-image" || normalized === "huanke-gemini") { - return "gemini"; - } - if ( - normalized === "alibaba-image" || - normalized === "bfl-image" || - normalized === "ruxa-image" || - normalized === "fal-image" || - normalized === "openai-image" || - normalized === "stepfun-image" || - normalized === "midjourney" || - normalized === "runway" || - normalized === "krea" || - normalized === "volcano-vision" || - normalized === "gemini" - ) { - return normalized; - } - return "alibaba-image"; -} - -function writeImageGenerationStatusChunk( - writer: StreamWriter, - params: { - phase: "queued" | "processing" | "finalizing" | "failed" | "completed"; - providerType?: string | null; - modelName?: string | null; - imageCount?: number | null; - queuePosition?: number | null; - sameModelRunningCount?: number | null; - concurrencyLimit?: number | null; - estimatedWaitMs?: number | null; - }, - label: string, -) { - writeStructuredDataChunk( - writer, - "data-imageGenerationStatus", - { - phase: params.phase, - providerType: normalizeImageLoadingProviderType(params.providerType), - modelName: String(params.modelName || "").trim() || undefined, - imageCount: - Number.isFinite(Number(params.imageCount)) && Number(params.imageCount) > 0 - ? Number(params.imageCount) - : undefined, - queuePosition: - Number.isFinite(Number(params.queuePosition)) && Number(params.queuePosition) >= 0 - ? Number(params.queuePosition) - : undefined, - sameModelRunningCount: - Number.isFinite(Number(params.sameModelRunningCount)) && - Number(params.sameModelRunningCount) >= 0 - ? Number(params.sameModelRunningCount) - : undefined, - concurrencyLimit: - Number.isFinite(Number(params.concurrencyLimit)) && - Number(params.concurrencyLimit) > 0 - ? Number(params.concurrencyLimit) - : undefined, - estimatedWaitMs: - Number.isFinite(Number(params.estimatedWaitMs)) && Number(params.estimatedWaitMs) >= 0 - ? Number(params.estimatedWaitMs) - : undefined, - }, - label, - ); -} - -type AgentProgressStreamPayload = { - stage: "planning" | "clarify" | "executing" | "completed" | "failed"; - currentStep: - | "lock_target" - | "analyze" - | "select_tool" - | "execute" - | "finalize"; - title?: string; - message?: string; - toolName?: string; - capability?: string; -}; - -function writeAgentProgressChunk( - writer: StreamWriter, - payload: AgentProgressStreamPayload, - label: string, -) { - writeStructuredDataChunk( - writer, - "data-agentProgress", - { - stage: payload.stage, - currentStep: payload.currentStep, - title: String(payload.title || "").trim() || undefined, - message: String(payload.message || "").trim() || undefined, - toolName: String(payload.toolName || "").trim() || undefined, - capability: String(payload.capability || "").trim() || undefined, - }, - label, - ); -} - -function writeCanvasMediaChunk( - writer: StreamWriter, - params: { - messageId: string; - text?: string; - imageUrls?: string[]; - videoUrls?: string[]; - prompt?: string | null; - sourceImageUrls?: string[] | null; - agentPresetId?: string | null; - agentPresetName?: string | null; - agentCategoryId?: string | null; - agentCategoryName?: string | null; - agentExecutionSnapshotId?: string | null; - }, - label: string, -) { - const imageUrls = Array.isArray(params.imageUrls) - ? params.imageUrls - : extractImageUrlsFromText(String(params.text || "")); - const videoUrls = Array.isArray(params.videoUrls) ? params.videoUrls : []; - const normalizedImageUrls = Array.from( - new Set( - imageUrls - .map((url) => String(url || "").trim()) - .filter((url) => /^https?:\/\//i.test(url)), - ), - ); - const normalizedVideoUrls = Array.from( - new Set( - videoUrls - .map((url) => String(url || "").trim()) - .filter((url) => /^https?:\/\//i.test(url)), - ), - ); - - if (normalizedImageUrls.length === 0 && normalizedVideoUrls.length === 0) { - return; - } - - writeStructuredDataChunk( - writer, - "data-canvasMedia", - { - messageId: params.messageId, - imageUrls: normalizedImageUrls, - videoUrls: normalizedVideoUrls, - prompt: String(params.prompt || "").trim() || undefined, - sourceImageUrls: Array.isArray(params.sourceImageUrls) - ? params.sourceImageUrls - .map((url) => String(url || "").trim()) - .filter((url) => /^https?:\/\//i.test(url)) - : [], - agentPresetId: String(params.agentPresetId || "").trim() || undefined, - agentPresetName: String(params.agentPresetName || "").trim() || undefined, - agentCategoryId: String(params.agentCategoryId || "").trim() || undefined, - agentCategoryName: String(params.agentCategoryName || "").trim() || undefined, - agentExecutionSnapshotId: - String(params.agentExecutionSnapshotId || "").trim() || undefined, - }, - label, - ); -} - -type AgentTargetLockRefPreview = { - url: string; - title?: string; - nodeId?: string; - assetId?: string; - source: ResolvedImageRef["source"]; - role: ResolvedImageRef["role"]; - isSelected?: boolean; - positionLabel?: string; -}; - -type AgentTargetLockStreamPayload = { - stage: "locked" | "clarify" | "unresolved"; - resolutionMode: ResolvedTargetAssets["resolutionMode"]; - confidence: number; - reason: string; - needsClarification: boolean; - clarificationMessage?: string; - targetCount: number; - candidateCount: number; - targets: AgentTargetLockRefPreview[]; - candidates: AgentTargetLockRefPreview[]; -}; - -function sortTargetLockRefsForCanvasOrdering(refs: ResolvedImageRef[]) { - return [...refs].sort((left, right) => { - const leftY = Number(left.y ?? 0); - const rightY = Number(right.y ?? 0); - if (Math.abs(leftY - rightY) > 12) { - return leftY - rightY; - } - return Number(left.x ?? 0) - Number(right.x ?? 0); - }); -} - -function getTargetLockPositionLabel(ref: ResolvedImageRef, refs: ResolvedImageRef[]): string { - if (refs.length <= 1) return "中间"; - const xs = refs.map((item) => Number(item.x ?? 0)); - const ys = refs.map((item) => Number(item.y ?? 0)); - const minX = Math.min(...xs); - const maxX = Math.max(...xs); - const minY = Math.min(...ys); - const maxY = Math.max(...ys); - const spanX = Math.max(1, maxX - minX); - const spanY = Math.max(1, maxY - minY); - const relX = (Number(ref.x ?? 0) - minX) / spanX; - const relY = (Number(ref.y ?? 0) - minY) / spanY; - - const horizontal = relX <= 0.33 ? "左" : relX >= 0.66 ? "右" : "中"; - const vertical = relY <= 0.33 ? "上" : relY >= 0.66 ? "下" : "中"; - - if (horizontal === "中" && vertical === "中") return "中间"; - return `${vertical}${horizontal}`; -} - -function serializeAgentTargetLockRefs(refs: ResolvedImageRef[]) { - const orderedRefs = sortTargetLockRefsForCanvasOrdering(refs).slice(0, 4); - return orderedRefs.map((ref) => ({ - url: ref.url, - title: String(ref.title || "").trim() || undefined, - nodeId: ref.nodeId, - assetId: ref.assetId, - source: ref.source, - role: ref.role, - isSelected: ref.isSelected === true, - positionLabel: getTargetLockPositionLabel(ref, orderedRefs), - })) satisfies AgentTargetLockRefPreview[]; -} - -function buildAgentTargetLockPayload(params: { - requestTargetResolution: ResolvedTargetAssets; - requestResolvedMediaInput: ResolvedChatMediaInput; - needsClarification: boolean; - clarificationMessage?: string; -}): AgentTargetLockStreamPayload { - const stage = - params.needsClarification - ? "clarify" - : params.requestTargetResolution.targetRefs.length > 0 - ? "locked" - : "unresolved"; - - return { - stage, - resolutionMode: params.requestTargetResolution.resolutionMode, - confidence: Number(params.requestTargetResolution.confidence || 0), - reason: params.requestTargetResolution.reason, - needsClarification: params.needsClarification, - clarificationMessage: params.clarificationMessage?.trim() || undefined, - targetCount: params.requestTargetResolution.targetRefs.length, - candidateCount: params.requestTargetResolution.candidateRefs.length, - targets: serializeAgentTargetLockRefs(params.requestTargetResolution.targetRefs), - candidates: serializeAgentTargetLockRefs( - params.requestTargetResolution.candidateRefs.length > 0 - ? params.requestTargetResolution.candidateRefs - : params.requestResolvedMediaInput.imageRefs, - ), - }; -} - -function writeAgentTargetLockChunk( - writer: StreamWriter, - payload: AgentTargetLockStreamPayload, - label: string, -) { - writeStructuredDataChunk(writer, "data-agentTargetLock", payload, label); -} - -function createSseResponse(stream: ReadableStream) { - return new Response(stream.pipeThrough(new JsonToSseTransformStream()), { - headers: { - "Content-Type": "text/event-stream; charset=utf-8", - "Cache-Control": "no-cache, no-transform", - Connection: "keep-alive", - "X-Accel-Buffering": "no", - }, - }); -} - -function startStreamKeepalive(writer: StreamWriter, label: string, intervalMs = 15000) { - const timer = setInterval(() => { - safeWriteStreamChunk( - writer, - { - type: "data-keepalive", - data: JSON.stringify({ ts: Date.now(), label }), - }, - `${label}:keepalive`, - ); - }, intervalMs); - - return () => clearInterval(timer); -} - -async function runBoundedParallel( - items: T[], - workerCount: number, - worker: (item: T, index: number) => Promise -) { - const maxWorkers = Math.max(1, Math.min(workerCount, items.length)); - let cursor = 0; - let firstError: unknown = null; - - const workers = Array.from({ length: maxWorkers }, async () => { - while (cursor < items.length) { - const index = cursor; - cursor += 1; - if (firstError) { - return; - } - - try { - await worker(items[index]!, index); - } catch (error) { - firstError = firstError || error; - } - } - }); - - await Promise.all(workers); - - if (firstError) { - throw firstError; - } -} - type VideoModeType = "text-to-video" | "first-frame" | "first-last-frame" | "reference-images"; function isVideoProviderType(