ai-chatbot/lib/realtime/runware-capability.ts
Your Name f6979f0196
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: 完善无限画布3D生成与实时画布能力
2026-02-28 22:33:16 +08:00

303 lines
7.9 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.

const RUNWARE_ENDPOINT = "https://api.runware.ai/v1";
const DEFAULT_TIMEOUT_MS = 12_000;
const PROBE_IMAGE_URL =
"https://upload.wikimedia.org/wikipedia/commons/thumb/a/a9/Example.jpg/512px-Example.jpg";
const DEFAULT_MODEL_ID = "runware:101@1";
export type RunwareCapabilityCode =
| "SUPPORTED"
| "UNSUPPORTED_SEED_IMAGE"
| "MODEL_REQUIRES_MASK"
| "AUTH_FAILED"
| "MODEL_INVALID"
| "UPSTREAM_ERROR";
export type RunwareSeedImageProbeResult = {
supported: boolean;
code: RunwareCapabilityCode;
message: string;
status?: number;
};
type RunwareErrorInfo = {
code: string;
message: string;
parameter: string;
};
function toText(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function collectErrorInfos(payload: unknown): RunwareErrorInfo[] {
const infos: RunwareErrorInfo[] = [];
const visited = new Set<unknown>();
const queue: unknown[] = [payload];
while (queue.length > 0) {
const current = queue.shift();
if (!current || typeof current !== "object" || visited.has(current)) {
continue;
}
visited.add(current);
if (Array.isArray(current)) {
for (const item of current) {
queue.push(item);
}
continue;
}
const objectValue = current as Record<string, unknown>;
const message = toText(objectValue.message || objectValue.error);
const code = toText(objectValue.code);
const parameter = toText(objectValue.parameter);
const lowerCode = code.toLowerCase();
const looksLikeErrorCode = containsAny(lowerCode, [
"error",
"invalid",
"unsupported",
"unauthorized",
"forbidden",
"notfound",
"not_found",
"failed",
]);
if (message || parameter || looksLikeErrorCode) {
infos.push({ code, message, parameter });
}
const nestedErrors = objectValue.errors;
const nestedData = objectValue.data;
if (nestedErrors) {
queue.push(nestedErrors);
}
if (nestedData) {
queue.push(nestedData);
}
}
return infos;
}
function containsAny(text: string, patterns: string[]) {
return patterns.some((pattern) => text.includes(pattern));
}
function normalizeErrorText(errorInfo: RunwareErrorInfo) {
return `${errorInfo.code} ${errorInfo.parameter} ${errorInfo.message}`.toLowerCase();
}
function isUnsupportedSeedImage(errorInfo: RunwareErrorInfo) {
const text = normalizeErrorText(errorInfo);
return (
containsAny(text, [
"unsupported use of 'seedimage'",
'unsupported use of "seedimage"',
"unsupported use of seedimage",
"seedimage is not supported",
"unsupportedparameter seedimage",
"unsupported parameter seedimage",
]) ||
(text.includes("seedimage") && text.includes("unsupported"))
);
}
function isAuthFailure(status: number, errorInfo: RunwareErrorInfo) {
const text = normalizeErrorText(errorInfo);
if (status === 401 || status === 403) return true;
return containsAny(text, [
"unauthorized",
"forbidden",
"invalid api key",
"invalidapikey",
"authentication failed",
"auth failed",
"invalid credentials",
]);
}
function isModelInvalid(errorInfo: RunwareErrorInfo) {
const text = normalizeErrorText(errorInfo);
return containsAny(text, [
"invalid model",
"model not found",
"unknown model",
"model does not exist",
"invalidmodel",
]);
}
function modelRequiresMask(errorInfo: RunwareErrorInfo) {
const text = normalizeErrorText(errorInfo);
return containsAny(text, [
"missingmaskimage",
"missing maskimage",
"requires a mask image",
"requires mask image",
"requires a mask",
]);
}
function responseHasImage(payload: unknown, taskUUID: string) {
if (!payload) return false;
const list = Array.isArray(payload)
? payload
: Array.isArray((payload as Record<string, unknown>)?.data)
? ((payload as Record<string, unknown>).data as unknown[])
: [];
return list.some((item) => {
if (!item || typeof item !== "object") return false;
const row = item as Record<string, unknown>;
const image = toText(row.imageDataURI || row.imageURL || row.imageUrl);
if (!image) return false;
const rowTaskUUID = toText(row.taskUUID);
return !rowTaskUUID || rowTaskUUID === taskUUID;
});
}
function parseProbeFailure(
status: number,
modelId: string,
infos: RunwareErrorInfo[]
): RunwareSeedImageProbeResult {
const firstMessage = infos.find((info) => info.message)?.message;
if (status === 401 || status === 403) {
return {
supported: false,
code: "AUTH_FAILED",
message: "Runware API Key 无效或无权限,请检查后重试。",
status,
};
}
if (infos.some((info) => isUnsupportedSeedImage(info))) {
return {
supported: false,
code: "UNSUPPORTED_SEED_IMAGE",
message: `模型 ${modelId} 不支持 seedImage,已阻止保存。`,
status,
};
}
if (infos.some((info) => modelRequiresMask(info))) {
return {
supported: false,
code: "MODEL_REQUIRES_MASK",
message: `模型 ${modelId} 依赖 maskImage,当前实时画布链路不兼容。`,
status,
};
}
if (infos.some((info) => isAuthFailure(status, info))) {
return {
supported: false,
code: "AUTH_FAILED",
message: "Runware API Key 无效或无权限,请检查后重试。",
status,
};
}
if (infos.some((info) => isModelInvalid(info))) {
return {
supported: false,
code: "MODEL_INVALID",
message: `模型 ${modelId} 不存在或不可用,请更换模型后重试。`,
status,
};
}
return {
supported: false,
code: "UPSTREAM_ERROR",
message: firstMessage || `Runware 探针请求失败(${status || 0})`,
status,
};
}
export async function probeRunwareSeedImageSupport(params: {
apiKey: string;
modelId?: string;
timeoutMs?: number;
}): Promise<RunwareSeedImageProbeResult> {
const apiKey = params.apiKey.trim();
const modelId = (params.modelId || DEFAULT_MODEL_ID).trim() || DEFAULT_MODEL_ID;
const timeoutMs = Number.isFinite(params.timeoutMs) ? Number(params.timeoutMs) : DEFAULT_TIMEOUT_MS;
if (!apiKey) {
return {
supported: false,
code: "AUTH_FAILED",
message: "缺少 Runware API Key。",
};
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), Math.max(2_000, timeoutMs));
let status = 0;
let payload: unknown = null;
try {
const taskUUID = crypto.randomUUID();
const response = await fetch(RUNWARE_ENDPOINT, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify([
{
taskType: "imageInference",
taskUUID,
model: modelId,
positivePrompt: "probe",
seedImage: PROBE_IMAGE_URL,
width: 512,
height: 512,
strength: 0.2,
CFGScale: 7,
steps: 1,
numberResults: 1,
outputType: "dataURI",
},
]),
cache: "no-store",
signal: controller.signal,
});
status = response.status;
payload = await response.json().catch(() => null);
const infos = collectErrorInfos(payload);
if (response.ok && infos.length === 0 && responseHasImage(payload, taskUUID)) {
return {
supported: true,
code: "SUPPORTED",
message: "模型支持 seedImage。",
status,
};
}
return parseProbeFailure(status, modelId, infos);
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
return {
supported: false,
code: "UPSTREAM_ERROR",
message: "Runware 探针请求超时,请稍后重试。",
};
}
return {
supported: false,
code: "UPSTREAM_ERROR",
message: error instanceof Error ? error.message : "Runware 探针请求失败。",
};
} finally {
clearTimeout(timer);
}
}