ai-chatbot/lib/video-agent/storyboard-command-interpreter.ts

379 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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", "vip98k"].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,
};
}