feat(*): 模型渠道 - 支持系统模型图标并升级 Agnes 视频协议

This commit is contained in:
ddCat
2026-08-27 23:31:38 +08:00
parent 7cb6aed27d
commit b3e977a985
30 changed files with 861 additions and 44 deletions

View File

@@ -320,6 +320,18 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = "
video.watermark = { supported: true, default: false };
video.operations.push("reference_to_video");
}
if (protocol === "agnes-video" && ["agnes-video-2.5", "agnes-video-2.5-flash"].includes(model.trim().toLowerCase())) {
const flash = model.trim().toLowerCase() === "agnes-video-2.5-flash";
video.references.maxImages = flash ? 5 : 9;
video.references.maxVideos = flash ? 0 : 3;
video.references.maxAudios = 3;
video.duration = { selection: "range", min: 4, max: 12, step: 1, default: 5 };
video.ratios = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"];
video.defaultRatio = "16:9";
video.resolutions = flash ? ["720P"] : ["720P", "960P", "2K"];
video.defaultResolution = "720P";
video.operations.push("reference_to_video", "audio_to_video");
}
return { version: 1, text, image: defaultImageCapabilityConfig(protocol, model), video };
}

View File

@@ -171,7 +171,7 @@ function systemChannelModelChannels(channels: PublicChannelCatalog[]): ModelChan
model: model.modelKey,
displayName: model.displayName,
description: "",
icon: "",
icon: model.icon || "",
capability: model.capability as ModelCapability,
protocol: model.protocol as any,
pricePolicy: "channel" as const,

View File

@@ -4,6 +4,7 @@ import type { ColumnsType } from "antd/es/table";
import { FlaskConical, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
import { PaginationBar } from "@/components/layout/workspace-page";
import { ModelIconPicker } from "@/components/model-logo";
import { ModelIcon } from "@/components/model-picker";
import { ModelCapabilityEditor } from "@/components/model-capability-editor";
import { CapabilityCardPicker, ProtocolCardPicker, type ModelCapabilityChoice } from "@/components/model-protocol-picker";
@@ -21,6 +22,7 @@ type FormValues = {
modelKey: string;
providerModelKey?: string;
displayName?: string;
icon?: string;
capability: EditableCapability;
protocol?: ModelProtocol;
priceTiers: PriceTierFormValues[];
@@ -114,6 +116,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
modelKey: "",
providerModelKey: "",
displayName: "",
icon: "",
capability: "text",
protocol: availableProtocols.find((item) => item.capability === "text")?.value,
priceTiers: [defaultPriceTier()],
@@ -129,6 +132,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
modelKey: item.modelKey,
providerModelKey: item.providerModelKey || item.modelKey,
displayName: item.displayName,
icon: item.icon,
capability: item.capability || undefined,
protocol: item.protocol,
priceTiers: item.priceTiers?.length ? item.priceTiers.map(priceTierToForm) : [legacyPriceTierToForm(item)],
@@ -152,6 +156,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
modelKey: values.modelKey.trim(),
providerModelKey: upstreamModel,
displayName: values.displayName?.trim() || values.modelKey.trim(),
icon: values.icon?.trim() || "",
capability: values.capability,
protocol: values.protocol,
priceTiers: values.priceTiers.map((tier) => ({
@@ -247,7 +252,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
render: (_, item) => (
<div className="flex min-w-0 items-center gap-2.5">
<span className="grid size-8 shrink-0 place-items-center rounded-md border border-border/70 bg-muted/35">
<ModelIcon model={item.modelKey} />
<ModelIcon model={item.modelKey} icon={item.icon} />
</span>
<div className="min-w-0">
<div className="truncate font-medium">{item.displayName || item.modelKey}</div>
@@ -469,7 +474,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
</Form.Item>
<section className="admin-form-section admin-model-editor-section">
<SectionHeading title="模型身份" description="区分产品侧展示标识与上游实际调用 ID。" />
<div className="admin-model-editor-section-content admin-model-identity-grid">
<div className="admin-model-editor-section-content admin-model-identity-grid admin-model-identity-grid-with-icon">
<Form.Item name="modelKey" label="产品模型标识" rules={[{ required: true, message: "请输入产品模型标识" }]}>
<Input
prefix={
@@ -486,6 +491,9 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
<Form.Item name="displayName" label="后台显示名称">
<Input placeholder="不填则使用模型标识" />
</Form.Item>
<Form.Item name="icon" label="模型 Logo">
<ModelIconPicker />
</Form.Item>
</div>
</section>

View File

@@ -134,6 +134,7 @@ export type PublicChannelModel = {
id: string;
modelKey: string;
displayName: string;
icon: string;
capability: string;
protocol?: string;
capabilityConfig?: Record<string, any>;

View File

@@ -41,5 +41,5 @@ export type ApiEnvelope<T> = T | { code?: number; data?: T | null; msg?: string
export type RequestOptions = { signal?: AbortSignal };
export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string };
export type VideoGenerationTask = { id: string; provider: "openai" | "seedance" | "video-generations" | "gemini-veo" | "novita" | "minimax"; model: string };
export type VideoGenerationTask = { id: string; provider: "openai" | "agnes" | "seedance" | "video-generations" | "gemini-veo" | "novita" | "minimax"; model: string };
export type VideoGenerationTaskState = { status: "pending" } | { status: "completed"; result: VideoGenerationResult } | { status: "failed"; error: string };

View File

@@ -0,0 +1,179 @@
import { getResourceOSSUrl } from "@/services/api/resources";
import { buildApiUrl, isSystemProxyBaseUrl, modelOptionName } from "@/stores/use-config-store";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
import type { ApiEnvelope, RequestOptions, ResolvedAiConfig, VideoGenerationTask, VideoGenerationTaskState } from "./video-contracts";
import type { VideoProviderDeps } from "./video-provider-deps";
type AgnesTaskResponse = {
id?: string;
task_id?: string;
video_id?: string;
status?: string;
metadata?: { url?: string } | null;
error?: { message?: string; detail?: string; code?: string } | null;
message?: string;
detail?: string;
};
const AGNES_V25_MODELS = new Set(["agnes-video-2.5", "agnes-video-2.5-flash"]);
const AGNES_ASPECT_RATIOS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"] as const;
export function isAgnesConfig(config: ResolvedAiConfig) {
return config.interfaceType === "agnes-video";
}
export async function createAgnesVideoTask(
deps: VideoProviderDeps,
config: ResolvedAiConfig,
model: string,
prompt: string,
references: ReferenceImage[],
videoReferences: ReferenceVideo[],
audioReferences: ReferenceAudio[],
options?: RequestOptions,
): Promise<VideoGenerationTask> {
const modelName = modelOptionName(model).trim();
const normalizedPrompt = prompt.trim();
if (!normalizedPrompt) throw new Error("请输入 Agnes 视频提示词");
const [imageUrls, videoUrls, audioUrls] = await Promise.all([
Promise.all(references.map((item) => resolveAgnesMediaUrl(item.url || item.dataUrl, item.storageKey))),
Promise.all(videoReferences.map((item) => resolveAgnesMediaUrl(item.url, item.storageKey))),
Promise.all(audioReferences.map((item) => resolveAgnesMediaUrl(item.url, item.storageKey))),
]);
const payload = modelName === "agnes-video-v2.0"
? buildAgnesV20Payload(config, modelName, normalizedPrompt, imageUrls, videoUrls, audioUrls)
: buildAgnesV25Payload(config, modelName, normalizedPrompt, imageUrls, videoUrls, audioUrls);
try {
const raw = await deps.transport.post<ApiEnvelope<AgnesTaskResponse>>(deps.transport.apiUrl("/videos"), payload, options);
const created = deps.response.unwrapEnvelope(raw, "Agnes 接口没有返回视频任务");
const id = String(created.video_id || created.task_id || created.id || "").trim();
if (!id) throw new Error("Agnes 创建响应没有 video_id");
return { id, provider: "agnes", model };
} catch (error) {
throw new Error(deps.response.readAxiosError(error, "Agnes 视频任务创建失败"));
}
}
export async function pollAgnesVideoTask(deps: VideoProviderDeps, config: ResolvedAiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
try {
const raw = await deps.transport.get<ApiEnvelope<AgnesTaskResponse>>(agnesPollUrl(config, task.id, modelOptionName(task.model)), options);
const state = deps.response.unwrapEnvelope(raw, "Agnes 接口没有返回任务状态");
const status = String(state.status || "").trim().toLowerCase();
if (status === "completed" || status === "succeeded" || status === "success" || status === "done") {
const url = String(state.metadata?.url || "").trim();
if (!url) return { status: "failed", error: "Agnes 任务已完成但没有返回 metadata.url" };
return { status: "completed", result: await deps.response.videoResultFromUrl(url, options) };
}
if (status === "failed" || status === "cancelled" || status === "canceled") return { status: "failed", error: agnesFailureMessage(state) };
return { status: "pending" };
} catch (error) {
throw new Error(deps.response.readAxiosError(error, "Agnes 视频任务查询失败"));
}
}
export function agnesPollUrl(config: ResolvedAiConfig, videoId: string, modelName: string) {
const query = new URLSearchParams({ video_id: videoId, model_name: modelName }).toString();
const path = `/agnesapi?${query}`;
if (isSystemProxyBaseUrl(config.baseUrl)) return buildApiUrl(config.baseUrl, path);
let base: URL;
try {
base = new URL(config.baseUrl);
} catch {
throw new Error("Agnes Base URL 无效,请填写完整的 HTTP(S) 地址");
}
base.pathname = "/agnesapi";
base.search = query;
base.hash = "";
return base.toString();
}
function buildAgnesV25Payload(config: ResolvedAiConfig, model: string, prompt: string, images: string[], videos: string[], audios: string[]) {
if (!AGNES_V25_MODELS.has(model)) throw new Error(`Agnes 视频协议不支持模型 ${model}`);
const seconds = agnesSeconds(config.videoSeconds);
const size = agnesResolution(config.vquality);
if (model === "agnes-video-2.5-flash") {
if (size !== "720P") throw new Error("Agnes Video 2.5 Flash 仅支持 720P");
if (images.length > 5) throw new Error("Agnes Video 2.5 Flash 最多支持 5 张参考图");
if (videos.length) throw new Error("Agnes Video 2.5 Flash 不支持参考视频");
}
const mode = videos.length || audios.length || images.length > 2 ? "reference" : images.length ? "keyframe" : "text";
const payload: Record<string, unknown> = {
model,
prompt,
mode,
seconds,
size,
aspect_ratio: agnesAspectRatio(config.size),
n: 1,
};
if (mode === "keyframe") {
payload.first_frame = images[0];
if (images[1]) payload.last_frame = images[1];
} else if (mode === "reference") {
if (images.length) payload.images = images;
if (audios.length) payload.audios = audios;
if (videos.length) payload.videos = videos.map((url) => ({ url }));
}
return payload;
}
function buildAgnesV20Payload(config: ResolvedAiConfig, model: string, prompt: string, images: string[], videos: string[], audios: string[]) {
if (videos.length || audios.length) throw new Error("Agnes Video V2.0 不支持参考视频或音频");
const seconds = Math.max(1, Math.floor(Number(config.videoSeconds) || 5));
const frameRate = 24;
const targetFrames = seconds * frameRate;
const payload: Record<string, unknown> = {
model,
prompt,
frame_rate: frameRate,
num_frames: Math.min(441, Math.floor((targetFrames - 1 + 4) / 8) * 8 + 1),
};
if (images.length === 1) payload.image = images[0];
if (images.length > 1) payload.extra_body = { image: images, mode: "keyframes" };
return payload;
}
function agnesSeconds(value: string) {
const seconds = Math.floor(Number(value) || 5);
if (seconds < 4 || seconds > 12) throw new Error("Agnes Video 2.5 时长必须在 4–12 秒之间");
return String(seconds);
}
function agnesResolution(value: string) {
const normalized = String(value || "720").trim().toLowerCase();
if (normalized === "auto" || normalized === "720" || normalized === "720p") return "720P";
if (normalized === "960" || normalized === "960p") return "960P";
if (normalized === "2k" || normalized === "1440" || normalized === "1440p") return "2K";
throw new Error("Agnes Video 2.5 分辨率必须是 720P、960P 或 2K");
}
function agnesAspectRatio(value: string) {
const normalized = String(value || "16:9").trim().toLowerCase().replace("×", "x");
if (normalized === "auto" || !normalized) return "16:9";
const direct = AGNES_ASPECT_RATIOS.find((ratio) => ratio === normalized);
if (direct) return direct;
const dimensions = normalized.match(/^(\d+)x(\d+)$/);
if (dimensions) {
const aspect = Number(dimensions[1]) / Number(dimensions[2]);
const matched = AGNES_ASPECT_RATIOS.find((ratio) => {
const [width, height] = ratio.split(":").map(Number);
return Math.abs(width / height - aspect) < 0.01;
});
if (matched) return matched;
}
throw new Error("Agnes Video 2.5 画幅必须是 21:9、16:9、4:3、1:1、3:4 或 9:16");
}
async function resolveAgnesMediaUrl(value: string | undefined, storageKey?: string) {
if (storageKey?.startsWith("resource:")) return getResourceOSSUrl(storageKey);
if (/^https?:\/\//i.test(value || "")) return String(value);
throw new Error("Agnes 参考素材需要公网 URL;请先把素材保存到对象存储");
}
function agnesFailureMessage(state: AgnesTaskResponse) {
return String(state.error?.message || state.error?.detail || state.error?.code || state.message || state.detail || "Agnes 视频生成失败");
}

View File

@@ -8,6 +8,7 @@ import { assertVideoCapability, assertVideoConfig } from "./video-validation";
import type { RequestOptions, VideoGenerationResult, VideoGenerationTask, VideoGenerationTaskState } from "./video-contracts";
import { videoResponseTools } from "./video-response";
import type { VideoProviderDeps } from "./video-provider-deps";
import { createAgnesVideoTask, isAgnesConfig, pollAgnesVideoTask } from "./video-provider-agnes";
import { createGeminiVeoTask, pollGeminiVeoTask } from "./video-provider-gemini";
import { createMiniMaxVideoTask, pollMiniMaxVideoTask } from "./video-provider-minimax";
import { createVideoGenerationsTask, pollVideoGenerationsTask } from "./video-provider-newapi";
@@ -20,7 +21,7 @@ export type { VideoGenerationResult, VideoGenerationTask, VideoGenerationTaskSta
export async function requestVideoGeneration(config: AiConfig, prompt: string, references: ReferenceImage[] = [], videoReferences: ReferenceVideo[] = [], audioReferences: ReferenceAudio[] = [], options?: RequestOptions): Promise<VideoGenerationResult> {
const task = await createVideoGenerationTask(config, prompt, references, videoReferences, audioReferences, options);
const delayMs = task.provider === "openai" ? 2500 : 5000;
const delayMs = task.provider === "agnes" ? 1500 : task.provider === "openai" ? 2500 : 5000;
for (let attempt = 0; attempt < 120; attempt += 1) {
if (options?.signal?.aborted) throw new DOMException("Aborted", "AbortError");
const state = await pollVideoGenerationTask(config, task, options);
@@ -42,6 +43,7 @@ export async function createVideoGenerationTask(config: AiConfig, prompt: string
if (requestConfig.interfaceType === "gemini-veo") return createGeminiVeoTask(deps, requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
if (requestConfig.interfaceType === "novita-video") return createNovitaVideoTask(deps, requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
if (requestConfig.interfaceType === "minimax-video") return createMiniMaxVideoTask(deps, requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
if (isAgnesConfig(requestConfig)) return createAgnesVideoTask(deps, requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
if (isSeedanceConfig(requestConfig)) return createSeedanceTask(deps, requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
if (videoReferences.length || audioReferences.length) throw new Error("当前视频接口不支持参考视频或参考音频,请切换到 Seedance 2.0 / 火山 Agent Plan 模型,或移除参考素材");
return createOpenAIVideoTask(deps, requestConfig, selectedModel, prompt, references, options);
@@ -55,6 +57,7 @@ export async function pollVideoGenerationTask(config: AiConfig, task: VideoGener
if (task.provider === "gemini-veo") return pollGeminiVeoTask(deps, requestConfig, task, options);
if (task.provider === "novita") return pollNovitaVideoTask(deps, requestConfig, task, options);
if (task.provider === "minimax") return pollMiniMaxVideoTask(deps, requestConfig, task, options);
if (task.provider === "agnes") return pollAgnesVideoTask(deps, requestConfig, task, options);
if (task.provider === "seedance") return pollSeedanceTask(deps, requestConfig, task, options);
return pollOpenAIVideoTask(deps, task, options);
}

View File

@@ -52,6 +52,7 @@ export type ChannelModel = {
modelKey: string;
providerModelKey: string;
displayName: string;
icon: string;
capability: "text" | "image" | "video" | "audio" | "";
protocol?: import("@/lib/model-protocols").ModelProtocol;
billingMode: "fixed_request" | "per_second" | "token";
@@ -94,6 +95,7 @@ export type ChannelModelMutation = {
modelKey: string;
providerModelKey?: string;
displayName?: string;
icon?: string;
capability: ChannelModel["capability"];
protocol?: ChannelModel["protocol"];
enabled?: boolean;

View File

@@ -11527,6 +11527,10 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
gap: 16px;
}
.admin-model-identity-grid-with-icon {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.admin-model-editor-drawer .admin-capability-editor {
border-radius: 0;
background: transparent;
@@ -12013,7 +12017,8 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
.admin-model-editor-section,
.admin-capability-protocol-grid,
.admin-price-tier-card-body,
.admin-model-identity-grid {
.admin-model-identity-grid,
.admin-model-identity-grid-with-icon {
grid-template-columns: minmax(0, 1fr);
}