379 lines
11 KiB
TypeScript
379 lines
11 KiB
TypeScript
import "server-only";
|
||
|
||
import { generateText } from "ai";
|
||
import { getLanguageModelById } from "@/lib/ai/dynamic-provider";
|
||
import { getAgentAiModelWithProvider, getEnabledAiModelsWithProvider } from "@/lib/db/queries";
|
||
|
||
export type StoryboardCommandIntent = "confirm" | "revise" | "cancel" | "unclear";
|
||
|
||
export type StoryboardRevisionCommand =
|
||
| {
|
||
type: "update_prompt";
|
||
shotIndex: number;
|
||
prompt: string;
|
||
}
|
||
| {
|
||
type: "regenerate_hd";
|
||
shotIndex: number;
|
||
}
|
||
| null;
|
||
|
||
export interface StoryboardCommandContextItem {
|
||
index: number;
|
||
title?: string;
|
||
summary?: string;
|
||
}
|
||
|
||
const CONFIRM_PATTERNS = [
|
||
/确认开始生成视频/,
|
||
/确认开始生成/,
|
||
/按这个分镜生成/,
|
||
/就按这个生成/,
|
||
/按这个来生成/,
|
||
/开始生成视频/,
|
||
/开始吧/,
|
||
/可以开始了/,
|
||
/可以执行了/,
|
||
/直接生成吧/,
|
||
/直接开始吧/,
|
||
/先这样/,
|
||
/没问题开始/,
|
||
/确认无误/,
|
||
/就这样吧/,
|
||
];
|
||
|
||
const CANCEL_PATTERNS = [
|
||
/取消/,
|
||
/先不要/,
|
||
/暂停/,
|
||
/停止/,
|
||
/先别生成/,
|
||
/先别做/,
|
||
/不用生成了/,
|
||
/不用做了/,
|
||
];
|
||
|
||
const REVISE_HINT_PATTERNS = [
|
||
/修改/,
|
||
/调整/,
|
||
/改成/,
|
||
/改为/,
|
||
/换成/,
|
||
/换成/,
|
||
/做成/,
|
||
/重生/,
|
||
/重做/,
|
||
/重新生成/,
|
||
/重新做/,
|
||
/不要/,
|
||
/太糊/,
|
||
/太平/,
|
||
/太死板/,
|
||
];
|
||
|
||
function normalizeCompactText(text: string) {
|
||
return String(text || "").toLowerCase().replace(/\s+/g, "").trim();
|
||
}
|
||
|
||
function chineseNumberToInt(source: string): number | null {
|
||
const normalized = String(source || "").trim();
|
||
if (!normalized) return null;
|
||
if (/^\d+$/.test(normalized)) {
|
||
const parsed = Number(normalized);
|
||
return Number.isFinite(parsed) ? parsed : null;
|
||
}
|
||
|
||
const map: Record<string, number> = {
|
||
一: 1,
|
||
二: 2,
|
||
两: 2,
|
||
三: 3,
|
||
四: 4,
|
||
五: 5,
|
||
六: 6,
|
||
七: 7,
|
||
八: 8,
|
||
九: 9,
|
||
十: 10,
|
||
};
|
||
|
||
if (normalized === "十") return 10;
|
||
if (normalized.length === 1) {
|
||
return map[normalized] ?? null;
|
||
}
|
||
if (/^十[一二三四五六七八九]$/.test(normalized)) {
|
||
return 10 + (map[normalized.slice(1)] || 0);
|
||
}
|
||
if (/^[一二三四五六七八九]十$/.test(normalized)) {
|
||
return (map[normalized[0]!] || 0) * 10;
|
||
}
|
||
if (/^[一二三四五六七八九]十[一二三四五六七八九]$/.test(normalized)) {
|
||
return (map[normalized[0]!] || 0) * 10 + (map[normalized[2]!] || 0);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function resolveShotIndexFromText(text: string, totalShots?: number): number | null {
|
||
const normalized = String(text || "").trim();
|
||
if (!normalized) return null;
|
||
|
||
const explicitPatterns = [
|
||
/第\s*([一二三四五六七八九十两\d]{1,3})\s*(?:张|格|图|镜头|段)/i,
|
||
/把\s*([一二三四五六七八九十两\d]{1,3})\s*(?:张|格|图|镜头|段)/i,
|
||
/([一二三四五六七八九十两\d]{1,3})\s*(?:号)?(?:张|格|图|镜头|段)/i,
|
||
];
|
||
|
||
for (const pattern of explicitPatterns) {
|
||
const match = normalized.match(pattern);
|
||
if (!match?.[1]) continue;
|
||
const resolved = chineseNumberToInt(match[1]);
|
||
if (resolved && resolved >= 1 && (!totalShots || resolved <= totalShots)) {
|
||
return resolved;
|
||
}
|
||
}
|
||
|
||
if (totalShots && /最后一(?:张|格|图|镜头|段)/.test(normalized)) {
|
||
return totalShots;
|
||
}
|
||
|
||
if (totalShots) {
|
||
const reverseMatch = normalized.match(/倒数第\s*([一二三四五六七八九十两\d]{1,3})\s*(?:张|格|图|镜头|段)/i);
|
||
if (reverseMatch?.[1]) {
|
||
const reversed = chineseNumberToInt(reverseMatch[1]);
|
||
if (reversed && reversed >= 1 && reversed <= totalShots) {
|
||
return totalShots - reversed + 1;
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
function extractRevisionPrompt(text: string): string {
|
||
return String(text || "")
|
||
.replace(/^(?:请)?(?:把)?\s*第?\s*[一二三四五六七八九十两\d]{1,3}\s*(?:张|格|图|镜头|段)(?:动作)?\s*(?:修改|调整|改成|改为|换成|做成)?\s*[::]?\s*/i, "")
|
||
.replace(/^(?:请)?(?:把)?\s*最后一(?:张|格|图|镜头|段)\s*(?:修改|调整|改成|改为|换成|做成)?\s*[::]?\s*/i, "")
|
||
.trim();
|
||
}
|
||
|
||
export function detectStoryboardCommandIntent(text: string): StoryboardCommandIntent {
|
||
const normalized = normalizeCompactText(text);
|
||
if (!normalized) return "unclear";
|
||
if (CANCEL_PATTERNS.some((pattern) => pattern.test(normalized))) return "cancel";
|
||
if (CONFIRM_PATTERNS.some((pattern) => pattern.test(normalized))) return "confirm";
|
||
if (REVISE_HINT_PATTERNS.some((pattern) => pattern.test(normalized))) return "revise";
|
||
if (resolveShotIndexFromText(normalized) !== null) return "revise";
|
||
return "unclear";
|
||
}
|
||
|
||
function parseJsonBlock<T = Record<string, unknown>>(text: string): T | null {
|
||
const match = String(text || "").match(/\{[\s\S]*\}/);
|
||
if (!match) return null;
|
||
try {
|
||
return JSON.parse(match[0]) as T;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
type EnabledModelWithProvider = Awaited<
|
||
ReturnType<typeof import("@/lib/db/queries")["getEnabledAiModelsWithProvider"]>
|
||
>[number];
|
||
|
||
function isAgentGenerateTextProviderType(providerType: string): boolean {
|
||
return ["zhipu", "alibaba", "openai", "custom", "local"].includes(providerType);
|
||
}
|
||
|
||
function isAgentGenerateTextCompatibleModel(item: EnabledModelWithProvider) {
|
||
if (!item?.model?.isEnabled || item.model.isSubModel) return false;
|
||
return isAgentGenerateTextProviderType(String(item.provider?.type || ""));
|
||
}
|
||
|
||
function pickReasoningModel(
|
||
configuredAgentModel: EnabledModelWithProvider | undefined,
|
||
models: EnabledModelWithProvider[],
|
||
) {
|
||
if (
|
||
configuredAgentModel &&
|
||
isAgentGenerateTextCompatibleModel(configuredAgentModel)
|
||
) {
|
||
return configuredAgentModel;
|
||
}
|
||
|
||
const candidates = models.filter((item) => isAgentGenerateTextCompatibleModel(item));
|
||
const zhipuModel = candidates.find((item) => item.provider.type === "zhipu");
|
||
return zhipuModel || candidates[0];
|
||
}
|
||
|
||
async function interpretStoryboardCommandWithLLM(params: {
|
||
text: string;
|
||
totalShots?: number;
|
||
items?: StoryboardCommandContextItem[];
|
||
}): Promise<{ intent: StoryboardCommandIntent; revision: StoryboardRevisionCommand } | null> {
|
||
try {
|
||
const allModels = await getEnabledAiModelsWithProvider();
|
||
const configuredAgentModel = await getAgentAiModelWithProvider();
|
||
const reasoningModelConfig = pickReasoningModel(configuredAgentModel, allModels);
|
||
if (!reasoningModelConfig) return null;
|
||
|
||
const model = await getLanguageModelById(reasoningModelConfig.model.id);
|
||
if (!model) return null;
|
||
|
||
const contextLines = (params.items || [])
|
||
.slice(0, 12)
|
||
.map((item) => `- 镜头${item.index}:${item.summary || item.title || "无描述"}`)
|
||
.join("\n");
|
||
|
||
const { text } = await generateText({
|
||
model,
|
||
system: `你是一个分镜任务指令解释器。
|
||
你需要判断用户当前这句话,是在:
|
||
- confirm:确认开始执行
|
||
- revise:修改某个镜头或重生某张高清图
|
||
- cancel:取消当前流程
|
||
- unclear:无法判断
|
||
|
||
只输出 JSON,不要输出解释:
|
||
{
|
||
"intent": "confirm|revise|cancel|unclear",
|
||
"action": "update_prompt|regenerate_hd|null",
|
||
"shotIndex": 1,
|
||
"prompt": "修改后的镜头文案,没有就空字符串"
|
||
}
|
||
|
||
规则:
|
||
1. 用户表达“开始、确认、就这样、按这个生成”时,intent=confirm。
|
||
2. 用户表达“取消、暂停、先别生成”时,intent=cancel。
|
||
3. 用户表达“改一下、调整、换成、重生、重做、重新生成”且指向某镜头时,intent=revise。
|
||
4. 如果用户说“最后一张/最后一格/最后一个镜头”,shotIndex 取最后一个。
|
||
5. 如果用户说“倒数第二张/倒数第二个镜头”,按总镜头数推算。
|
||
6. 如果 intent=revise 且是文字修改,action=update_prompt。
|
||
7. 如果 intent=revise 且是重生高清预览,action=regenerate_hd。
|
||
8. 如果无法确定具体镜头,intent=unclear。`,
|
||
prompt: [
|
||
`总镜头数:${Math.max(0, Number(params.totalShots || 0))}`,
|
||
contextLines ? `当前镜头草案:\n${contextLines}` : "",
|
||
`用户输入:${params.text}`,
|
||
]
|
||
.filter(Boolean)
|
||
.join("\n\n"),
|
||
temperature: 0.1,
|
||
maxTokens: 200,
|
||
abortSignal: AbortSignal.timeout(12000),
|
||
} as any);
|
||
|
||
const parsed = parseJsonBlock<{
|
||
intent?: string;
|
||
action?: string | null;
|
||
shotIndex?: number;
|
||
prompt?: string;
|
||
}>(text);
|
||
if (!parsed) return null;
|
||
|
||
const intent =
|
||
parsed.intent === "confirm" ||
|
||
parsed.intent === "revise" ||
|
||
parsed.intent === "cancel" ||
|
||
parsed.intent === "unclear"
|
||
? parsed.intent
|
||
: "unclear";
|
||
|
||
if (intent !== "revise") {
|
||
return { intent, revision: null };
|
||
}
|
||
|
||
const shotIndex = Math.max(1, Number(parsed.shotIndex || 0));
|
||
if (!Number.isFinite(shotIndex) || shotIndex <= 0) {
|
||
return { intent: "unclear", revision: null };
|
||
}
|
||
|
||
if (parsed.action === "regenerate_hd") {
|
||
return {
|
||
intent,
|
||
revision: {
|
||
type: "regenerate_hd",
|
||
shotIndex,
|
||
},
|
||
};
|
||
}
|
||
|
||
const prompt = String(parsed.prompt || "").trim();
|
||
if (!prompt) {
|
||
return { intent: "unclear", revision: null };
|
||
}
|
||
|
||
return {
|
||
intent,
|
||
revision: {
|
||
type: "update_prompt",
|
||
shotIndex,
|
||
prompt,
|
||
},
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export function parseStoryboardRevisionCommand(params: {
|
||
text: string;
|
||
totalShots?: number;
|
||
}): StoryboardRevisionCommand {
|
||
const source = String(params.text || "").trim();
|
||
if (!source) return null;
|
||
|
||
const shotIndex = resolveShotIndexFromText(source, params.totalShots);
|
||
if (!shotIndex) return null;
|
||
|
||
if (
|
||
/重生|重做|重新生成|重新做|再生成一版|再来一版|重新出图|重出高清图|高清图重来/i.test(source)
|
||
) {
|
||
return {
|
||
type: "regenerate_hd",
|
||
shotIndex,
|
||
};
|
||
}
|
||
|
||
const prompt = extractRevisionPrompt(source);
|
||
if (!prompt) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
type: "update_prompt",
|
||
shotIndex,
|
||
prompt,
|
||
};
|
||
}
|
||
|
||
export async function interpretStoryboardCommand(params: {
|
||
text: string;
|
||
totalShots?: number;
|
||
items?: StoryboardCommandContextItem[];
|
||
}): Promise<{ intent: StoryboardCommandIntent; revision: StoryboardRevisionCommand }> {
|
||
const heuristicIntent = detectStoryboardCommandIntent(params.text);
|
||
const heuristicRevision = parseStoryboardRevisionCommand({
|
||
text: params.text,
|
||
totalShots: params.totalShots,
|
||
});
|
||
|
||
if (heuristicIntent === "confirm" || heuristicIntent === "cancel") {
|
||
return { intent: heuristicIntent, revision: null };
|
||
}
|
||
|
||
if (heuristicIntent === "revise" && heuristicRevision) {
|
||
return { intent: heuristicIntent, revision: heuristicRevision };
|
||
}
|
||
|
||
const llmInterpretation = await interpretStoryboardCommandWithLLM(params);
|
||
if (llmInterpretation) {
|
||
return llmInterpretation;
|
||
}
|
||
|
||
return {
|
||
intent: heuristicIntent,
|
||
revision: heuristicRevision,
|
||
};
|
||
}
|