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 = { 一: 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>(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 >[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, }; }