109
web/src/components/channel-headers-editor.tsx
Normal file
109
web/src/components/channel-headers-editor.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Button, Input, Tooltip } from "antd";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type { ChangeEvent } from "react";
|
||||
|
||||
import type { ChannelHeader } from "@/stores/use-config-store";
|
||||
|
||||
const DEFAULT_USER_AGENT = "InfiniteCanvas/1.0 (+https://github.com/ddcat-ai/open-ai-canvas)";
|
||||
const MAX_HEADER_COUNT = 32;
|
||||
const BLOCKED_HEADERS = new Set([
|
||||
"authorization",
|
||||
"proxy-authorization",
|
||||
"cookie",
|
||||
"set-cookie",
|
||||
"host",
|
||||
"content-length",
|
||||
"content-type",
|
||||
"accept",
|
||||
"connection",
|
||||
"proxy-connection",
|
||||
"keep-alive",
|
||||
"transfer-encoding",
|
||||
"te",
|
||||
"trailer",
|
||||
"upgrade",
|
||||
"forwarded",
|
||||
"x-goog-api-key",
|
||||
]);
|
||||
const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
||||
|
||||
type Props = {
|
||||
value?: ChannelHeader[];
|
||||
onChange?: (headers: ChannelHeader[]) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function ChannelHeadersEditor({ value = [], onChange, disabled }: Props) {
|
||||
const headers = Array.isArray(value) ? value : [];
|
||||
const validation = validateChannelHeaders(headers);
|
||||
const hasUserAgent = headers.some((header) => header.name.trim().toLowerCase() === "user-agent");
|
||||
const updateHeader = (index: number, patch: Partial<ChannelHeader>) => {
|
||||
onChange?.(headers.map((header, itemIndex) => (itemIndex === index ? { ...header, ...patch } : header)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-md border border-border/70 bg-muted/[.1] p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">自定义请求头</div>
|
||||
<div className="mt-0.5 break-all text-xs leading-5 text-foreground/50">默认发送 {DEFAULT_USER_AGENT};添加 User-Agent 后会覆盖默认值。</div>
|
||||
</div>
|
||||
{!hasUserAgent ? (
|
||||
<Button size="small" disabled={disabled || headers.length >= MAX_HEADER_COUNT} onClick={() => onChange?.([...headers, { name: "User-Agent", value: DEFAULT_USER_AGENT }])}>
|
||||
添加 User-Agent
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{headers.length ? (
|
||||
<div className="space-y-2">
|
||||
{headers.map((header, index) => {
|
||||
const inputProps = {
|
||||
"aria-label": `请求头 ${index + 1} 值`,
|
||||
disabled,
|
||||
value: header.value,
|
||||
placeholder: "Header 值",
|
||||
onChange: (event: ChangeEvent<HTMLInputElement>) => updateHeader(index, { value: event.target.value }),
|
||||
};
|
||||
return (
|
||||
<div key={index} className="grid min-w-0 grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)_32px] gap-2">
|
||||
<Input aria-label={`请求头 ${index + 1} 名称`} disabled={disabled} value={header.name} placeholder="Header 名称" onChange={(event) => updateHeader(index, { name: event.target.value })} />
|
||||
{/token|key|secret/i.test(header.name) ? <Input.Password {...inputProps} /> : <Input {...inputProps} />}
|
||||
<Tooltip title="删除请求头">
|
||||
<Button aria-label={`删除请求头 ${index + 1}`} className="size-8 p-0" disabled={disabled} icon={<Trash2 className="size-3.5" />} onClick={() => onChange?.(headers.filter((_, itemIndex) => itemIndex !== index))} />
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<Button size="small" type="dashed" icon={<Plus className="size-3.5" />} disabled={disabled || headers.length >= MAX_HEADER_COUNT} onClick={() => onChange?.([...headers, { name: "", value: "" }])}>
|
||||
添加请求头
|
||||
</Button>
|
||||
<span className={`text-xs ${validation ? "text-destructive" : "text-foreground/45"}`}>{validation || `${headers.length}/${MAX_HEADER_COUNT}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function validateChannelHeaders(headers?: ChannelHeader[]) {
|
||||
if (!headers?.length) return "";
|
||||
if (headers.length > MAX_HEADER_COUNT) return `自定义请求头最多支持 ${MAX_HEADER_COUNT} 项`;
|
||||
const seen = new Set<string>();
|
||||
let totalBytes = 0;
|
||||
for (const header of headers) {
|
||||
const name = header.name.trim();
|
||||
const value = header.value.trim();
|
||||
if (!name && !value) continue;
|
||||
if (!name || !HEADER_NAME_PATTERN.test(name)) return "请求头名称无效,请使用标准 HTTP Header 名称";
|
||||
const normalizedName = name.toLowerCase();
|
||||
if (BLOCKED_HEADERS.has(normalizedName) || normalizedName.startsWith("x-canvas-") || normalizedName.startsWith("x-forwarded-")) return `${name} 由系统管理,不允许自定义`;
|
||||
if (seen.has(normalizedName)) return `请求头不能重复:${name}`;
|
||||
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)) return `${name} 的值包含非法控制字符`;
|
||||
if (name.length > 128 || value.length > 4096) return "单个请求头名称或值过长";
|
||||
totalBytes += new TextEncoder().encode(name + value).length;
|
||||
if (totalBytes > 16 * 1024) return "自定义请求头总大小不能超过 16KB";
|
||||
seen.add(normalizedName);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
@@ -5,16 +5,17 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
import { ListToolbar, TableSurface } from "@/components/layout/workspace-page";
|
||||
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
|
||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { refreshSystemChannels } from "@/lib/user-session";
|
||||
import { createAdminChannel, deleteAdminChannel, listAdminChannels, updateAdminChannel } from "@/services/api/auth";
|
||||
import { type ModelChannel } from "@/stores/use-config-store";
|
||||
import { type ChannelHeader, type ModelChannel } from "@/stores/use-config-store";
|
||||
import { useAdminContext } from "../admin-context";
|
||||
import { AdminPageFrame } from "../components/admin-shell";
|
||||
import { AdminRowActions, AdminTableEmpty, AdminTableSkeleton, configuredSecretText } from "../components/admin-ui";
|
||||
import { ChannelModelManager } from "../components/channel-model-manager";
|
||||
|
||||
type ChannelFormValues = { name: string; baseUrl: string; apiKey?: string; secretKey?: string; useGlobalConcurrency?: boolean; concurrencyLimit?: number; enabled?: boolean };
|
||||
type ChannelFormValues = { name: string; baseUrl: string; apiKey?: string; secretKey?: string; headers?: ChannelHeader[]; useGlobalConcurrency?: boolean; concurrencyLimit?: number; enabled?: boolean };
|
||||
|
||||
export default function ChannelsPage() {
|
||||
const { message, modal } = App.useApp();
|
||||
@@ -79,7 +80,7 @@ export default function ChannelsPage() {
|
||||
const openDrawer = (channel?: ModelChannel) => {
|
||||
setEditingChannel(channel || null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue(channel ? { name: channel.name, baseUrl: channel.baseUrl, apiKey: "", secretKey: "", useGlobalConcurrency: !channel.concurrencyLimit, concurrencyLimit: channel.concurrencyLimit || undefined, enabled: channel.enabled !== false } : { name: "", baseUrl: "", apiKey: "", secretKey: "", useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true });
|
||||
form.setFieldsValue(channel ? { name: channel.name, baseUrl: channel.baseUrl, apiKey: "", secretKey: "", headers: channel.headers || [], useGlobalConcurrency: !channel.concurrencyLimit, concurrencyLimit: channel.concurrencyLimit || undefined, enabled: channel.enabled !== false } : { name: "", baseUrl: "", apiKey: "", secretKey: "", headers: [], useGlobalConcurrency: true, concurrencyLimit: undefined, enabled: true });
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
@@ -94,13 +95,18 @@ export default function ChannelsPage() {
|
||||
|
||||
const save = async () => {
|
||||
const values = await form.validateFields();
|
||||
const headerError = validateChannelHeaders(values.headers);
|
||||
if (headerError) {
|
||||
message.error(headerError);
|
||||
return;
|
||||
}
|
||||
if (!editingChannel && !values.apiKey?.trim()) {
|
||||
message.error("请填写 API Key 或 Access Key");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = { name: values.name.trim(), baseUrl: values.baseUrl.trim(), apiKey: values.apiKey?.trim() || "", secretKey: values.secretKey?.trim() || "", useGlobalConcurrency: values.useGlobalConcurrency !== false, concurrencyLimit: values.useGlobalConcurrency === false ? values.concurrencyLimit : undefined, enabled: values.enabled !== false };
|
||||
const payload = { name: values.name.trim(), baseUrl: values.baseUrl.trim(), apiKey: values.apiKey?.trim() || "", secretKey: values.secretKey?.trim() || "", headers: values.headers || [], useGlobalConcurrency: values.useGlobalConcurrency !== false, concurrencyLimit: values.useGlobalConcurrency === false ? values.concurrencyLimit : undefined, enabled: values.enabled !== false };
|
||||
await (editingChannel ? updateAdminChannel(editingChannel.id, payload) : createAdminChannel(payload));
|
||||
await syncChannels();
|
||||
setDrawerOpen(false);
|
||||
@@ -164,6 +170,7 @@ export default function ChannelsPage() {
|
||||
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请填写 Base URL" }]}><Input placeholder="填写渠道 Base URL" /></Form.Item>
|
||||
<Form.Item name="apiKey" label={editingChannel ? `API Key / Access Key(${configuredSecretText})` : "API Key / Access Key"} rules={editingChannel ? [] : [{ required: true, message: "请填写 API Key 或 Access Key" }]} extra="OpenAI 兼容协议填写 API Key;即梦官方协议填写 IAM Access Key。"><Input.Password autoComplete="new-password" placeholder={editingChannel ? "留空保留原凭证" : "API Key 或 Access Key"} /></Form.Item>
|
||||
<Form.Item name="secretKey" label={editingChannel ? `Secret Key(${channelSecretText(editingChannel)})` : "Secret Key(可选)"} extra="仅即梦官方等 AK/SK 签名协议需要;其他渠道留空。"><Input.Password autoComplete="new-password" placeholder={editingChannel ? "留空保留原 Secret Key" : "IAM Secret Key"} /></Form.Item>
|
||||
<div className="mb-6"><Form.Item name="headers" noStyle><ChannelHeadersEditor /></Form.Item></div>
|
||||
<Form.Item name="useGlobalConcurrency" label="跟随系统并发配置" valuePropName="checked"><Switch /></Form.Item>
|
||||
<Form.Item name="concurrencyLimit" label="渠道最大并发数" extra="后台任务和系统代理请求共享该渠道上限;槽位暂满时请求会等待。" rules={useGlobalConcurrency ? [] : [{ required: true, message: "请填写渠道最大并发数" }, { type: "number", min: 1, max: 999, message: "请输入 1-999 的整数" }]}><InputNumber className="w-full" min={1} max={999} precision={0} disabled={useGlobalConcurrency} placeholder={useGlobalConcurrency ? "使用系统默认值" : "1-999"} /></Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useNavigate, useSearchParams } from "react-router";
|
||||
import { UserOSSSettingsForm } from "@/components/layout/user-oss-settings-form";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
|
||||
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
|
||||
import { refreshSystemChannels } from "@/lib/user-session";
|
||||
import { MODEL_PROTOCOL_OPTIONS, modelProtocolLabel } from "@/lib/model-protocols";
|
||||
import { fetchChannelModels } from "@/services/api/image";
|
||||
@@ -391,6 +392,9 @@ export default function SettingsPage() {
|
||||
onChange={(models) => updateChannel(channel.id, { models: uniqueModels(models) })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div className="lg:col-span-12">
|
||||
<ChannelHeadersEditor value={channel.headers} onChange={(headers) => updateChannel(channel.id, { headers })} />
|
||||
</div>
|
||||
</div>
|
||||
<ChannelModelSettings channel={channel} onChange={(modelCosts) => updateChannel(channel.id, { modelCosts })} />
|
||||
</section>
|
||||
@@ -572,11 +576,11 @@ function channelConnectionError(channel: ModelChannel) {
|
||||
}
|
||||
|
||||
function channelConnectionSignature(channel: ModelChannel) {
|
||||
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.apiFormat, channel.interfaceType || "auto"].join("\n");
|
||||
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.apiFormat, channel.interfaceType || "auto", JSON.stringify(channel.headers || [])].join("\n");
|
||||
}
|
||||
|
||||
function channelValidationError(channel: ModelChannel) {
|
||||
return channelConnectionError(channel) || (!channel.models.length ? "请添加至少一个模型" : "");
|
||||
return channelConnectionError(channel) || validateChannelHeaders(channel.headers) || (!channel.models.length ? "请添加至少一个模型" : "");
|
||||
}
|
||||
|
||||
function isChannelReady(channel: ModelChannel) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { isSystemProxyBaseUrl, resolveBackendApiUrl, type AiConfig } from "@/stores/use-config-store";
|
||||
import { isSystemProxyBaseUrl, resolveBackendApiUrl, type AiConfig, type ChannelHeader } from "@/stores/use-config-store";
|
||||
|
||||
type RelayConfig = Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat">;
|
||||
type RelayConfig = Pick<AiConfig, "baseUrl" | "apiKey" | "apiFormat"> & { headers?: ChannelHeader[] };
|
||||
|
||||
export type ChannelRequest = {
|
||||
url: string;
|
||||
@@ -16,13 +16,22 @@ export function channelRequest(config: RelayConfig, upstreamUrl: string, headers
|
||||
}
|
||||
|
||||
const normalizedUpstreamUrl = new URL(upstreamUrl).toString();
|
||||
normalizedHeaders.delete("X-Canvas-Upstream-Headers");
|
||||
normalizedHeaders.delete("x-goog-api-key");
|
||||
normalizedHeaders.set("Authorization", `Bearer ${config.apiKey}`);
|
||||
normalizedHeaders.set("X-Canvas-Upstream-URL", normalizedUpstreamUrl);
|
||||
normalizedHeaders.set("X-Canvas-Upstream-Format", config.apiFormat === "gemini" ? "gemini" : "openai");
|
||||
if (config.headers?.length) normalizedHeaders.set("X-Canvas-Upstream-Headers", encodeChannelHeaders(config.headers));
|
||||
return {
|
||||
url: resolveBackendApiUrl("/api/ai/custom"),
|
||||
headers: Object.fromEntries(normalizedHeaders.entries()),
|
||||
credentials: "include",
|
||||
};
|
||||
}
|
||||
|
||||
function encodeChannelHeaders(headers: ChannelHeader[]) {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(headers));
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
@@ -755,13 +755,14 @@ async function requestGeminiImagesOnce(config: AiConfig, prompt: string, referen
|
||||
for (const image of references) {
|
||||
parts.push(toGeminiImagePart(await imageToDataUrl(image)));
|
||||
}
|
||||
const request = channelRequest(config, geminiApiUrl(config, "generateContent"), geminiHeaders(config));
|
||||
const response = await axios.post<GeminiPayload>(
|
||||
geminiApiUrl(config, "generateContent"),
|
||||
request.url,
|
||||
{
|
||||
...toGeminiBody(config, [{ role: "user", content: prompt }], { generationConfig: { responseModalities: ["TEXT", "IMAGE"] } }),
|
||||
contents: [{ role: "user", parts }],
|
||||
},
|
||||
{ headers: geminiHeaders(config), signal: options?.signal },
|
||||
{ headers: request.headers, withCredentials: request.credentials === "include", signal: options?.signal },
|
||||
);
|
||||
return parseGeminiImagePayload(response.data);
|
||||
}
|
||||
@@ -893,7 +894,8 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
||||
if (mask) formData.set("mask", dataUrlToFile(mask));
|
||||
|
||||
try {
|
||||
const response = await axios.post<ImageApiResponse>(aiApiUrl(requestConfig, "/images/edits"), formData, { headers: aiHeaders(requestConfig), signal: options?.signal });
|
||||
const request = channelRequest(requestConfig, aiApiUrl(requestConfig, "/images/edits"), aiHeaders(requestConfig));
|
||||
const response = await axios.post<ImageApiResponse>(request.url, formData, { headers: request.headers, withCredentials: request.credentials === "include", signal: options?.signal });
|
||||
const images = parseImagePayload(response.data);
|
||||
return images;
|
||||
} catch (error) {
|
||||
@@ -990,6 +992,7 @@ export async function fetchChannelModels(channel: ModelChannel, viaBackend = fal
|
||||
baseUrl: channel.baseUrl,
|
||||
apiKey: channel.apiKey,
|
||||
apiFormat: channel.apiFormat,
|
||||
headers: channel.headers,
|
||||
},
|
||||
{ withCredentials: true },
|
||||
);
|
||||
|
||||
@@ -204,7 +204,12 @@ function geminiVeoHeaders(config: ResolvedAiConfig, contentType?: string) {
|
||||
}
|
||||
|
||||
async function channelPost<T>(config: ResolvedAiConfig, upstreamUrl: string, body: unknown, options?: RequestOptions) {
|
||||
const request = channelRequest(config, upstreamUrl, { "Content-Type": "application/json" });
|
||||
const request = channelRequest(config, upstreamUrl, aiHeaders(config, "application/json"));
|
||||
return (await axios.post<T>(request.url, body, { headers: request.headers, withCredentials: request.credentials === "include", signal: options?.signal })).data;
|
||||
}
|
||||
|
||||
async function channelPostForm<T>(config: ResolvedAiConfig, upstreamUrl: string, body: FormData, options?: RequestOptions) {
|
||||
const request = channelRequest(config, upstreamUrl, aiHeaders(config));
|
||||
return (await axios.post<T>(request.url, body, { headers: request.headers, withCredentials: request.credentials === "include", signal: options?.signal })).data;
|
||||
}
|
||||
|
||||
@@ -213,6 +218,11 @@ async function channelGet<T>(config: ResolvedAiConfig, upstreamUrl: string, opti
|
||||
return (await axios.get<T>(request.url, { headers: request.headers, withCredentials: request.credentials === "include", signal: options?.signal })).data;
|
||||
}
|
||||
|
||||
async function channelGetBlob(config: ResolvedAiConfig, upstreamUrl: string, options?: RequestOptions) {
|
||||
const request = channelRequest(config, upstreamUrl);
|
||||
return (await axios.get<Blob>(request.url, { headers: request.headers, withCredentials: request.credentials === "include", responseType: "blob", signal: options?.signal })).data;
|
||||
}
|
||||
|
||||
function findGeminiVideoURL(value: unknown): string {
|
||||
if (!value || typeof value !== "object") return "";
|
||||
if (Array.isArray(value)) {
|
||||
@@ -259,7 +269,7 @@ async function createOpenAIVideoTask(config: ResolvedAiConfig, model: string, pr
|
||||
};
|
||||
try {
|
||||
const createPath = config.interfaceType === "xai-video" ? "/videos/generations" : "/videos";
|
||||
const created = unwrapVideoResponse(config.interfaceType === "xai-video" ? await channelPost<ApiVideoResponse>(config, aiApiUrl(config, createPath), payload, options) : (await axios.post<ApiVideoResponse>(aiApiUrl(config, createPath), payload, { headers: aiHeaders(config, "application/json"), signal: options?.signal })).data);
|
||||
const created = unwrapVideoResponse(await channelPost<ApiVideoResponse>(config, aiApiUrl(config, createPath), payload, options));
|
||||
const id = videoTaskId(created);
|
||||
if (!id) throw new Error("视频接口没有返回任务 ID");
|
||||
return { id, provider: "openai", model };
|
||||
@@ -277,7 +287,7 @@ async function createOpenAIVideoTask(config: ResolvedAiConfig, model: string, pr
|
||||
const files = await Promise.all(references.slice(0, 7).map(async (image) => dataUrlToFile({ ...image, dataUrl: await imageToDataUrl(image) })));
|
||||
files.forEach((file) => body.append("input_reference[]", file));
|
||||
try {
|
||||
const created = unwrapVideoResponse((await axios.post<ApiVideoResponse>(aiApiUrl(config, "/videos"), body, { headers: aiHeaders(config), signal: options?.signal })).data);
|
||||
const created = unwrapVideoResponse(await channelPostForm<ApiVideoResponse>(config, aiApiUrl(config, "/videos"), body, options));
|
||||
if (!created.id) throw new Error("视频接口没有返回任务 ID");
|
||||
return { id: created.id, provider: "openai", model };
|
||||
} catch (error) {
|
||||
@@ -287,13 +297,13 @@ async function createOpenAIVideoTask(config: ResolvedAiConfig, model: string, pr
|
||||
|
||||
async function pollOpenAIVideoTask(config: ResolvedAiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
|
||||
try {
|
||||
const video = unwrapVideoResponse(config.interfaceType === "xai-video" ? await channelGet<ApiVideoResponse>(config, aiApiUrl(config, `/videos/${task.id}`), options) : (await axios.get<ApiVideoResponse>(aiApiUrl(config, `/videos/${task.id}`), { headers: aiHeaders(config), signal: options?.signal })).data);
|
||||
const video = unwrapVideoResponse(await channelGet<ApiVideoResponse>(config, aiApiUrl(config, `/videos/${task.id}`), options));
|
||||
if (video.status === "completed" || video.status === "succeeded" || video.status === "success" || video.status === "done") {
|
||||
const resultUrl = video.video?.url || video.video_url || video.result_url;
|
||||
if (resultUrl) return { status: "completed", result: await videoResultFromUrl(resultUrl, options) };
|
||||
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${task.id}/content`), { headers: aiHeaders(config), responseType: "blob", signal: options?.signal });
|
||||
await assertVideoBlob(content.data);
|
||||
return { status: "completed", result: { blob: content.data } };
|
||||
const content = await channelGetBlob(config, aiApiUrl(config, `/videos/${task.id}/content`), options);
|
||||
await assertVideoBlob(content);
|
||||
return { status: "completed", result: { blob: content } };
|
||||
}
|
||||
if (video.status === "failed" || video.status === "cancelled") return { status: "failed", error: video.error?.message || "视频生成失败" };
|
||||
return { status: "pending" };
|
||||
@@ -312,9 +322,7 @@ async function createSeedanceTask(config: ResolvedAiConfig, model: string, promp
|
||||
: await buildSeedanceVideosPayload(config, model, prompt, references, videoReferences, audioReferences);
|
||||
|
||||
try {
|
||||
const raw = isVolcengineArk
|
||||
? await channelPost<ApiEnvelope<SeedanceTask>>(config, seedanceApiUrl(config), payload, options)
|
||||
: (await axios.post<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config), payload, { headers: aiHeaders(config, "application/json"), signal: options?.signal })).data;
|
||||
const raw = await channelPost<ApiEnvelope<SeedanceTask>>(config, seedanceApiUrl(config), payload, options);
|
||||
const created = unwrapSeedanceTask(raw);
|
||||
const id = created.id || created.task_id;
|
||||
if (!id) throw new Error("Seedance 接口没有返回任务 ID");
|
||||
@@ -326,18 +334,15 @@ async function createSeedanceTask(config: ResolvedAiConfig, model: string, promp
|
||||
|
||||
async function pollSeedanceTask(config: ResolvedAiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
|
||||
try {
|
||||
const raw =
|
||||
config.interfaceType === "volcengine-ark-video"
|
||||
? await channelGet<ApiEnvelope<SeedanceTask>>(config, seedanceApiUrl(config, task.id), options)
|
||||
: (await axios.get<ApiEnvelope<SeedanceTask>>(seedanceApiUrl(config, task.id), { headers: aiHeaders(config), signal: options?.signal })).data;
|
||||
const raw = await channelGet<ApiEnvelope<SeedanceTask>>(config, seedanceApiUrl(config, task.id), options);
|
||||
const state = unwrapSeedanceTask(raw);
|
||||
if (state.status === "succeeded" || state.status === "completed") {
|
||||
const url = state.video_url || state.content?.video_url;
|
||||
if (url) return { status: "completed", result: await videoResultFromUrl(url, options) };
|
||||
if (isArkPlanBaseUrl(config.baseUrl)) return { status: "failed", error: "Seedance 任务成功但没有返回视频 URL" };
|
||||
const content = await axios.get<Blob>(aiApiUrl(config, `/videos/${task.id}/content`), { headers: aiHeaders(config), responseType: "blob", signal: options?.signal });
|
||||
await assertVideoBlob(content.data);
|
||||
return { status: "completed", result: { blob: content.data } };
|
||||
const content = await channelGetBlob(config, aiApiUrl(config, `/videos/${task.id}/content`), options);
|
||||
await assertVideoBlob(content);
|
||||
return { status: "completed", result: { blob: content } };
|
||||
}
|
||||
if (state.status === "failed" || state.status === "cancelled" || state.status === "expired") return { status: "failed", error: seedanceErrorMessage(state) || `Seedance 视频生成${state.status === "expired" ? "超时" : "失败"}` };
|
||||
return { status: "pending" };
|
||||
|
||||
@@ -9,6 +9,7 @@ import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-ge
|
||||
|
||||
export type ApiCallFormat = "openai" | "gemini";
|
||||
export type ChannelInterfaceType = ModelProtocol;
|
||||
export type ChannelHeader = { name: string; value: string };
|
||||
|
||||
export type ModelChannel = {
|
||||
id: string;
|
||||
@@ -16,6 +17,7 @@ export type ModelChannel = {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
secretKey?: string;
|
||||
headers?: ChannelHeader[];
|
||||
apiFormat: ApiCallFormat;
|
||||
interfaceType?: ChannelInterfaceType;
|
||||
models: string[];
|
||||
@@ -298,6 +300,7 @@ export function createModelChannel(channel?: Partial<ModelChannel>): ModelChanne
|
||||
baseUrl: providedBaseUrl || (interfaceType ? defaultBaseUrlForChannelInterface(interfaceType) : defaultBaseUrlForApiFormat(apiFormat)),
|
||||
apiKey: channel?.apiKey || "",
|
||||
secretKey: channel?.secretKey || "",
|
||||
headers: Array.isArray(channel?.headers) ? channel.headers.map((header) => ({ name: String(header.name || ""), value: String(header.value || "") })) : [],
|
||||
apiFormat,
|
||||
interfaceType,
|
||||
models: uniqueRawModels(channel?.models || []),
|
||||
@@ -388,6 +391,7 @@ export function resolveModelRequestConfig(config: AiConfig, value: string) {
|
||||
baseUrl: channel.baseUrl,
|
||||
apiKey: channel.apiKey,
|
||||
secretKey: channel.secretKey,
|
||||
headers: channel.headers,
|
||||
apiFormat: interfaceType ? (interfaceType === "gemini-veo" ? "gemini" as const : "openai" as const) : channel.apiFormat,
|
||||
interfaceType,
|
||||
channelId: channel.scope === "system" ? channel.id : "",
|
||||
|
||||
Reference in New Issue
Block a user