227 lines
7.4 KiB
TypeScript
227 lines
7.4 KiB
TypeScript
import { generateText } from "ai";
|
||
import { NextResponse } from "next/server";
|
||
import { z } from "zod";
|
||
import { auth } from "@/app/(auth)/auth";
|
||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||
import { getCompatibleTextGenerationTemperature } from "@/lib/ai/model-compat";
|
||
import {
|
||
isImageGenerationModelDescriptor,
|
||
isVideoModelDescriptor,
|
||
} from "@/lib/ai/model-classification";
|
||
import { getAiModelAgentCodingModelId } from "@/lib/db/ai-model-flags";
|
||
import {
|
||
getAgentAiModelWithProvider,
|
||
getEnabledAiModelsWithProvider,
|
||
getSystemSetting,
|
||
} from "@/lib/db/queries";
|
||
import { createLogger } from "@/lib/observability/logger";
|
||
import {
|
||
VIDEO_CANVAS_FUNCTION_CONFIG_KEY,
|
||
defaultVideoCanvasFunctionConfig,
|
||
normalizeVideoCanvasFunctionConfig,
|
||
} from "@/lib/video-canvas/config";
|
||
import {
|
||
buildVideoPromptStructureInstructions,
|
||
injectVideoPromptStructure,
|
||
resolveVideoPromptStructure,
|
||
} from "@/lib/video-canvas/prompt-structure";
|
||
|
||
export const runtime = "nodejs";
|
||
export const maxDuration = 60;
|
||
|
||
const logger = createLogger("video-canvas-prompt-tools");
|
||
|
||
const requestSchema = z.object({
|
||
marks: z
|
||
.array(
|
||
z.object({
|
||
id: z.string().trim().min(1).max(100),
|
||
label: z.string().trim().min(1).max(100),
|
||
})
|
||
)
|
||
.max(12)
|
||
.default([]),
|
||
mode: z.enum(["optimize", "translate"]),
|
||
prompt: z.string().trim().min(1, "请输入提示词").max(50_000, "提示词过长"),
|
||
references: z
|
||
.array(
|
||
z.object({
|
||
id: z.string().trim().min(1).max(100),
|
||
index: z.number().int().min(1).max(10),
|
||
label: z.string().trim().min(1).max(100),
|
||
mediaType: z.enum(["audio", "image", "video"]),
|
||
offset: z.number().int().min(0).max(50_000),
|
||
})
|
||
)
|
||
.max(20)
|
||
.default([]),
|
||
});
|
||
|
||
type ModelCandidate = Awaited<
|
||
ReturnType<typeof getEnabledAiModelsWithProvider>
|
||
>[number];
|
||
|
||
function isTextModelCandidate(candidate: ModelCandidate) {
|
||
const descriptor = {
|
||
capabilities: candidate.model.capabilities,
|
||
modelId: candidate.model.modelId,
|
||
providerType: candidate.provider.type,
|
||
supportsImageGen: candidate.model.supportsImageGen,
|
||
};
|
||
|
||
return (
|
||
candidate.model.isEnabled &&
|
||
candidate.provider.isEnabled &&
|
||
!candidate.model.isSubModel &&
|
||
candidate.model.healthStatus !== "auto_disabled" &&
|
||
!isImageGenerationModelDescriptor(descriptor) &&
|
||
!isVideoModelDescriptor(descriptor)
|
||
);
|
||
}
|
||
|
||
function normalizeModelRef(value: unknown) {
|
||
const normalized = String(value || "").trim();
|
||
return normalized && normalized !== "auto" ? normalized : "";
|
||
}
|
||
|
||
function normalizeGeneratedText(value: unknown) {
|
||
return String(value || "")
|
||
.trim()
|
||
.replace(/^```(?:text|markdown|md)?\s*/i, "")
|
||
.replace(/\s*```$/i, "")
|
||
.replace(/^(?:优化后的提示词|译文|translation)\s*[::]\s*/i, "")
|
||
.trim();
|
||
}
|
||
|
||
export async function POST(request: Request) {
|
||
try {
|
||
const session = await auth();
|
||
if (!session?.user?.id) {
|
||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||
}
|
||
|
||
const parsed = requestSchema.safeParse(await request.json());
|
||
if (!parsed.success) {
|
||
return NextResponse.json(
|
||
{ success: false, error: parsed.error.issues[0]?.message || "请求参数无效" },
|
||
{ status: 400 },
|
||
);
|
||
}
|
||
|
||
const setting = await getSystemSetting({ key: VIDEO_CANVAS_FUNCTION_CONFIG_KEY }).catch(
|
||
() => null,
|
||
);
|
||
const config = setting?.value
|
||
? normalizeVideoCanvasFunctionConfig(setting.value)
|
||
: defaultVideoCanvasFunctionConfig;
|
||
const functionKey =
|
||
parsed.data.mode === "optimize"
|
||
? "video-prompt-optimize"
|
||
: "video-prompt-translate";
|
||
const definition = config.functions[functionKey];
|
||
|
||
if (!definition?.enabled) {
|
||
return NextResponse.json(
|
||
{ success: false, error: `${definition?.label || "当前功能"}已停用` },
|
||
{ status: 403 },
|
||
);
|
||
}
|
||
|
||
const [allModels, agentModel] = await Promise.all([
|
||
getEnabledAiModelsWithProvider(false),
|
||
getAgentAiModelWithProvider(),
|
||
]);
|
||
const candidates = allModels.filter(isTextModelCandidate);
|
||
const preferredModelRef =
|
||
normalizeModelRef(definition.model) || normalizeModelRef(config.defaultMultimodalModel);
|
||
const preferredModel = preferredModelRef
|
||
? candidates.find(
|
||
(candidate) =>
|
||
candidate.model.id === preferredModelRef ||
|
||
candidate.model.modelId === preferredModelRef,
|
||
)
|
||
: null;
|
||
const orderedCandidates = [
|
||
preferredModel,
|
||
agentModel && isTextModelCandidate(agentModel) ? agentModel : null,
|
||
...candidates,
|
||
].filter(
|
||
(candidate, index, items): candidate is ModelCandidate =>
|
||
Boolean(candidate) &&
|
||
items.findIndex((item) => item?.model.id === candidate?.model.id) === index,
|
||
);
|
||
|
||
if (orderedCandidates.length === 0) {
|
||
return NextResponse.json(
|
||
{ success: false, error: "当前站点没有可用的提示词处理模型" },
|
||
{ status: 500 },
|
||
);
|
||
}
|
||
|
||
for (const candidate of orderedCandidates.slice(0, 3)) {
|
||
try {
|
||
const codingModelId = getAiModelAgentCodingModelId(candidate.model);
|
||
const model = await getLanguageModelById(candidate.model.id, codingModelId);
|
||
if (!model) continue;
|
||
const structuredPrompt = injectVideoPromptStructure(
|
||
parsed.data.prompt,
|
||
parsed.data.references,
|
||
parsed.data.marks,
|
||
);
|
||
const structureInstructions = buildVideoPromptStructureInstructions(
|
||
parsed.data.references,
|
||
parsed.data.marks,
|
||
);
|
||
|
||
const { text } = await generateText({
|
||
abortSignal: AbortSignal.timeout(18_000),
|
||
maxTokens: parsed.data.mode === "optimize" ? 2200 : 1600,
|
||
model,
|
||
prompt:
|
||
parsed.data.mode === "optimize"
|
||
? `原始视频提示词:\n${structuredPrompt}\n\n请按照系统规则输出优化后的提示词正文。`
|
||
: `待翻译的视频提示词:\n${structuredPrompt}\n\n请按照系统规则只输出译文。`,
|
||
system: [definition.prompt, structureInstructions].filter(Boolean).join("\n\n"),
|
||
temperature: getCompatibleTextGenerationTemperature(
|
||
candidate,
|
||
parsed.data.mode === "optimize" ? 0.65 : 0.25,
|
||
),
|
||
} as any);
|
||
const generatedText = normalizeGeneratedText(text);
|
||
if (!generatedText) continue;
|
||
const result = resolveVideoPromptStructure(
|
||
generatedText,
|
||
parsed.data.references,
|
||
parsed.data.marks,
|
||
{ flatten: parsed.data.mode === "optimize" },
|
||
);
|
||
if (!result.text) continue;
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
data: {
|
||
mentionOffsets: result.mentionOffsets,
|
||
model: candidate.model.displayName,
|
||
preservedMarkIds: result.preservedMarkIds,
|
||
text: result.text,
|
||
},
|
||
});
|
||
} catch (error) {
|
||
logger.warn("prompt model candidate failed", {
|
||
error,
|
||
modelId: candidate.model.id,
|
||
});
|
||
}
|
||
}
|
||
|
||
return NextResponse.json(
|
||
{ success: false, error: "提示词处理模型暂时不可用,请稍后重试" },
|
||
{ status: 502 },
|
||
);
|
||
} catch (error) {
|
||
logger.error("prompt tool failed", { error });
|
||
const message = error instanceof Error ? error.message : "提示词处理失败";
|
||
return NextResponse.json({ success: false, error: message }, { status: 500 });
|
||
}
|
||
}
|