333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
import { auth } from "@/app/(auth)/auth";
|
||
import {
|
||
getAgentAiModelWithProvider,
|
||
getDefaultAiModel,
|
||
getEnabledAiModelsWithProvider,
|
||
getFrontendDisplayAiModelsWithProvider,
|
||
getSiteSettings,
|
||
getSystemSetting,
|
||
getUserById,
|
||
} from "@/lib/db/queries";
|
||
import { getAiModelFrontendAvailability } from "@/lib/ai/model-frontend-availability";
|
||
import { buildVideoToolCapability } from "@/lib/ai/video-tool-capability";
|
||
import {
|
||
isImageGenerationModelDescriptor,
|
||
isVideoModelDescriptor,
|
||
} from "@/lib/ai/model-classification";
|
||
import { ChatSDKError } from "@/lib/errors";
|
||
import { isAiModelAgentAuto, isAiModelMultimodal } from "@/lib/db/ai-model-flags";
|
||
import {
|
||
AGENT_WORKFLOW_UI_CONFIG_KEY,
|
||
getAgentWorkflowBusinessModeSettings,
|
||
normalizeAgentWorkflowUiConfig,
|
||
} from "@/lib/agents/workflow-ui-config";
|
||
|
||
function isNanoBananaProModel(model: { displayName?: string | null; modelId?: string | null }) {
|
||
const displayName = String(model.displayName || "").toLowerCase();
|
||
const modelId = String(model.modelId || "").toLowerCase();
|
||
return (
|
||
displayName.includes("nano banana pro") ||
|
||
displayName.includes("nano-banana-pro") ||
|
||
modelId.includes("nano-banana-pro") ||
|
||
modelId.includes("nano_banana_pro") ||
|
||
modelId.includes("nano-banana")
|
||
);
|
||
}
|
||
|
||
function sortModelsForDisplay<T extends { sortOrder?: number | null; displayName?: string | null }>(
|
||
models: T[]
|
||
) {
|
||
return [...models].sort((a, b) => {
|
||
const leftOrder = Number.isFinite(a.sortOrder as number) ? Number(a.sortOrder) : 0;
|
||
const rightOrder = Number.isFinite(b.sortOrder as number) ? Number(b.sortOrder) : 0;
|
||
if (leftOrder !== rightOrder) return leftOrder - rightOrder;
|
||
const leftName = String(a.displayName || "");
|
||
const rightName = String(b.displayName || "");
|
||
return leftName.localeCompare(rightName, "zh-Hans-CN");
|
||
});
|
||
}
|
||
|
||
function isWorkflowImageGenerationModel(model: {
|
||
providerType?: string | null;
|
||
modelId?: string | null;
|
||
capabilities?: unknown;
|
||
supportsImageGen?: boolean | null;
|
||
}) {
|
||
return (
|
||
isImageGenerationModelDescriptor({
|
||
providerType: model.providerType,
|
||
modelId: model.modelId,
|
||
capabilities: model.capabilities,
|
||
supportsImageGen: model.supportsImageGen,
|
||
}) &&
|
||
!isVideoModelDescriptor({
|
||
providerType: model.providerType,
|
||
modelId: model.modelId,
|
||
capabilities: model.capabilities,
|
||
supportsImageGen: model.supportsImageGen,
|
||
})
|
||
);
|
||
}
|
||
|
||
// 获取前端可用的 AI 模型列表(需要登录但不需要管理员权限)
|
||
export async function GET(request: Request) {
|
||
try {
|
||
const isLocalCanvasE2E = process.env.CANVAS_E2E_LOCAL === "true";
|
||
|
||
if (isLocalCanvasE2E) {
|
||
const models = [
|
||
{
|
||
id: "local-e2e-image-model",
|
||
modelId: "nano-banana-pro-local",
|
||
displayName: "Nano Banana Pro",
|
||
avatarUrl: null,
|
||
description: "本地视频画布测试图片模型",
|
||
supportsVision: true,
|
||
supportsImageGen: true,
|
||
isMultimodal: true,
|
||
isDefault: true,
|
||
providerName: "Local E2E",
|
||
providerType: "local",
|
||
isSubModel: false,
|
||
parentModelId: null,
|
||
capabilities: { image: true },
|
||
videoToolCapability: null,
|
||
imageGenConfig: {
|
||
creditCostPerImage: 1,
|
||
creditCostPerVideo: 20,
|
||
videoDurationOptions: [5, 10],
|
||
},
|
||
sortOrder: 0,
|
||
selectable: true,
|
||
},
|
||
{
|
||
id: "local-e2e-video-model",
|
||
modelId: "veo-3.1-fast",
|
||
displayName: "Veo 3.1 Fast(本地布局测试)",
|
||
avatarUrl: null,
|
||
description: "本地视频画布测试视频模型",
|
||
supportsVision: true,
|
||
supportsImageGen: false,
|
||
isMultimodal: true,
|
||
isDefault: false,
|
||
providerName: "Local E2E",
|
||
providerType: "vip98k",
|
||
isSubModel: false,
|
||
parentModelId: null,
|
||
capabilities: { kind: "video", supportedModes: ["text-to-video", "first-frame", "first-last-frame", "reference-images"], supportedRatios:["16:9"], supportedResolutions:["720p","1080p"], durationOptions:[8], maxReferenceImages:3 },
|
||
videoToolCapability: {
|
||
supported: true,
|
||
durations: [8],
|
||
resolutions: ["720p", "1080p"],
|
||
},
|
||
imageGenConfig: {
|
||
creditCostPerVideo: 20,
|
||
videoDurationOptions: [8],
|
||
},
|
||
sortOrder: 1,
|
||
selectable: true,
|
||
},
|
||
];
|
||
|
||
return Response.json({
|
||
models,
|
||
defaultModelId: models[0].id,
|
||
agentWorkflowConfiguredDefaultModelId: null,
|
||
agentWorkflowDefaultModelId: null,
|
||
agentWorkflowConfiguredModels: [],
|
||
agentModel: null,
|
||
});
|
||
}
|
||
|
||
const session = await auth();
|
||
|
||
if (!session?.user) {
|
||
return new ChatSDKError("unauthorized:chat").toResponse();
|
||
}
|
||
|
||
const currentUser = await getUserById({ id: session.user.id });
|
||
const allowHiddenModels =
|
||
session.user.type === "admin" || Boolean(currentUser?.isHighVip);
|
||
const includeUnavailable =
|
||
new URL(request.url).searchParams.get("includeUnavailable") === "1";
|
||
|
||
// 只获取主模型(不包括子模型),子模型由后端根据 videoParams.mode 自动选择
|
||
const modelsWithProvider = includeUnavailable
|
||
? await getFrontendDisplayAiModelsWithProvider(false, true)
|
||
: await getEnabledAiModelsWithProvider(false, true);
|
||
const agentModelWithProvider = await getAgentAiModelWithProvider();
|
||
const defaultModel = await getDefaultAiModel();
|
||
const [siteSettings, agentWorkflowUiConfigSetting] = await Promise.all([
|
||
getSiteSettings(),
|
||
getSystemSetting({ key: AGENT_WORKFLOW_UI_CONFIG_KEY }).catch(() => null),
|
||
]);
|
||
type ModelWithProvider = (typeof modelsWithProvider)[number];
|
||
const serializeModel = ({ model, provider }: ModelWithProvider) => ({
|
||
id: model.id,
|
||
modelId: model.modelId,
|
||
displayName: model.displayName,
|
||
avatarUrl: model.avatarUrl,
|
||
description: model.description,
|
||
supportsVision: model.supportsVision,
|
||
supportsImageGen: model.supportsImageGen,
|
||
isMultimodal: isAiModelMultimodal(model),
|
||
isDefault: model.isDefault,
|
||
providerName: provider.name,
|
||
providerType: provider.type,
|
||
isSubModel: model.isSubModel,
|
||
parentModelId: model.parentModelId,
|
||
capabilities: model.capabilities, // 🆕 返回子模型的能力配置
|
||
videoToolCapability: buildVideoToolCapability({
|
||
providerType: provider.type,
|
||
modelId: model.modelId,
|
||
rawCapabilities: model.capabilities,
|
||
imageGenConfig: (model.imageGenConfig || null) as
|
||
| { videoDurationOptions?: number[] | null }
|
||
| null,
|
||
}),
|
||
imageGenConfig: model.imageGenConfig,
|
||
sortOrder: model.sortOrder ?? 0,
|
||
...getAiModelFrontendAvailability(model),
|
||
});
|
||
const workflowImageModelById = new Map<string, ModelWithProvider>();
|
||
modelsWithProvider.forEach((entry) => {
|
||
const { model, provider } = entry;
|
||
if (
|
||
!isAiModelAgentAuto(model) &&
|
||
isWorkflowImageGenerationModel({
|
||
providerType: provider.type,
|
||
modelId: model.modelId,
|
||
capabilities: model.capabilities,
|
||
supportsImageGen: model.supportsImageGen,
|
||
})
|
||
) {
|
||
workflowImageModelById.set(model.id, entry);
|
||
}
|
||
});
|
||
const visibleModelsWithProvider = sortModelsForDisplay(
|
||
modelsWithProvider
|
||
.filter(
|
||
({ model }) =>
|
||
!isAiModelAgentAuto(model) &&
|
||
(allowHiddenModels || !model.isHidden)
|
||
)
|
||
.map((item) => item.model)
|
||
);
|
||
const visibleModelIdSet = new Set(visibleModelsWithProvider.map((m) => m.id));
|
||
const visibleModelsWithProviderSorted = modelsWithProvider.filter(({ model }) =>
|
||
visibleModelIdSet.has(model.id)
|
||
).sort((a, b) => {
|
||
const left = visibleModelsWithProvider.findIndex((m) => m.id === a.model.id);
|
||
const right = visibleModelsWithProvider.findIndex((m) => m.id === b.model.id);
|
||
return left - right;
|
||
});
|
||
|
||
// 转换为前端需要的格式
|
||
const models = visibleModelsWithProviderSorted.map(serializeModel);
|
||
const selectableModels = models.filter((model) => model.selectable !== false);
|
||
const preferredNanoBananaModel = models.find(
|
||
(model) =>
|
||
model.selectable !== false &&
|
||
isImageGenerationModelDescriptor({
|
||
providerType: model.providerType,
|
||
modelId: model.modelId,
|
||
capabilities: model.capabilities,
|
||
supportsImageGen: model.supportsImageGen,
|
||
}) &&
|
||
!isVideoModelDescriptor({
|
||
providerType: model.providerType,
|
||
modelId: model.modelId,
|
||
capabilities: model.capabilities,
|
||
supportsImageGen: model.supportsImageGen,
|
||
}) &&
|
||
isNanoBananaProModel(model)
|
||
);
|
||
const preferredImageModel = models.find(
|
||
(model) =>
|
||
model.selectable !== false &&
|
||
isImageGenerationModelDescriptor({
|
||
providerType: model.providerType,
|
||
modelId: model.modelId,
|
||
capabilities: model.capabilities,
|
||
supportsImageGen: model.supportsImageGen,
|
||
}) &&
|
||
!isVideoModelDescriptor({
|
||
providerType: model.providerType,
|
||
modelId: model.modelId,
|
||
capabilities: model.capabilities,
|
||
supportsImageGen: model.supportsImageGen,
|
||
})
|
||
);
|
||
|
||
const resolvedDefaultModelId =
|
||
(preferredNanoBananaModel?.id ??
|
||
preferredImageModel?.id ??
|
||
(defaultModel &&
|
||
!isAiModelAgentAuto(defaultModel) &&
|
||
selectableModels.some((model) => model.id === defaultModel.id)
|
||
? defaultModel.id
|
||
: selectableModels.find((model) => model.isDefault)?.id)) ??
|
||
selectableModels[0]?.id ??
|
||
null;
|
||
const configuredAgentWorkflowModelId = String(
|
||
siteSettings?.agentWorkflowDefaultModelId || ""
|
||
).trim();
|
||
const hasConfiguredAgentWorkflowModel = Boolean(
|
||
configuredAgentWorkflowModelId &&
|
||
configuredAgentWorkflowModelId !== "auto" &&
|
||
configuredAgentWorkflowModelId !== "__auto"
|
||
);
|
||
const agentWorkflowDefaultModel = hasConfiguredAgentWorkflowModel
|
||
? workflowImageModelById.get(configuredAgentWorkflowModelId) || null
|
||
: null;
|
||
const workflowUiConfig = normalizeAgentWorkflowUiConfig(
|
||
agentWorkflowUiConfigSetting?.value
|
||
);
|
||
const workflowBusinessModeSettings = getAgentWorkflowBusinessModeSettings(
|
||
workflowUiConfig
|
||
);
|
||
const configuredWorkflowModelIds = new Set<string>();
|
||
if (agentWorkflowDefaultModel) {
|
||
configuredWorkflowModelIds.add(agentWorkflowDefaultModel.model.id);
|
||
}
|
||
workflowBusinessModeSettings.modes.forEach((mode) => {
|
||
const modelId = String(mode.defaultModelId || "").trim();
|
||
if (modelId && workflowImageModelById.has(modelId)) {
|
||
configuredWorkflowModelIds.add(modelId);
|
||
}
|
||
});
|
||
const agentWorkflowConfiguredModels = Array.from(configuredWorkflowModelIds)
|
||
.map((id) => workflowImageModelById.get(id))
|
||
.filter((entry): entry is ModelWithProvider => Boolean(entry))
|
||
.map(serializeModel);
|
||
|
||
return Response.json({
|
||
models,
|
||
defaultModelId: resolvedDefaultModelId,
|
||
agentWorkflowConfiguredDefaultModelId: hasConfiguredAgentWorkflowModel
|
||
? configuredAgentWorkflowModelId
|
||
: null,
|
||
agentWorkflowDefaultModelId: agentWorkflowDefaultModel?.model.id || null,
|
||
agentWorkflowConfiguredModels,
|
||
agentModel: agentModelWithProvider
|
||
? {
|
||
id: agentModelWithProvider.model.id,
|
||
modelId: agentModelWithProvider.model.modelId,
|
||
displayName: agentModelWithProvider.model.displayName,
|
||
avatarUrl: agentModelWithProvider.model.avatarUrl,
|
||
providerType: agentModelWithProvider.provider.type,
|
||
supportsVision: agentModelWithProvider.model.supportsVision,
|
||
supportsImageGen: agentModelWithProvider.model.supportsImageGen,
|
||
isMultimodal: isAiModelMultimodal(agentModelWithProvider.model),
|
||
imageGenConfig: agentModelWithProvider.model.imageGenConfig,
|
||
}
|
||
: null,
|
||
});
|
||
} catch (error) {
|
||
if (error instanceof ChatSDKError) {
|
||
return error.toResponse();
|
||
}
|
||
return new ChatSDKError("offline:chat").toResponse();
|
||
}
|
||
}
|