737 lines
25 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.

// @/agents/outlineScript.ts
import u from "@/utils";
import { EventEmitter } from "events";
import { tool, ModelMessage } from "ai";
import { z } from "zod";
import type { DB } from "@/types/database";
// ==================== 类型定义 ====================
type AgentType = "AI1" | "AI2" | "director";
type AssetType = "角色" | "道具" | "场景";
type RefreshEvent = "storyline" | "outline" | "assets";
interface AssetItem {
name: string;
description: string;
}
interface EpisodeData {
episodeIndex: number;
title: string;
chapterRange: number[];
scenes: AssetItem[]; // 按 outline 出场顺序排列
characters: AssetItem[]; // 按 outline 出场顺序排列
props: AssetItem[]; // 按 outline 出场顺序排列
coreConflict: string;
outline: string; // 最高优先级,剧本生成的唯一权威
openingHook: string; // outline 第一句话的视觉化,开篇第一个镜头
keyEvents: string[]; // 4个元素[起, 承, 转, 合],严格按 outline 顺序
emotionalCurve: string; // 对应 keyEvents 各阶段
visualHighlights: string[]; // 按 outline 顺序排列的标志性镜头
endingHook: string; // outline 之后的悬念延伸
classicQuotes: string[];
}
// ==================== Schema 定义 ====================
const sceneItemSchema = z.object({
name: z.string().describe("场景名称,如'五星酒店宴会厅'、'老旧出租屋'"),
description: z.string().describe("环境描写:空间结构、光线氛围、装饰陈设、环境细节"),
});
const characterItemSchema = z.object({
name: z.string().describe("角色姓名(必须是具体人名,禁止'众人'、'群众'等集合描述)"),
description: z.string().describe("人设样貌:年龄体态、五官特征、发型妆容、服装配饰、气质神态"),
});
const propItemSchema = z.object({
name: z.string().describe("道具名称"),
description: z.string().describe("样式描写:材质质感、颜色图案、形状尺寸、磨损痕迹、特殊标记"),
});
const episodeSchema = z.object({
episodeIndex: z.number().describe("集数索引从1开始递增"),
title: z.string().describe("8字内标题疑问/感叹句,含情绪爆点"),
chapterRange: z.array(z.number()).describe("关联章节号数组"),
scenes: z.array(sceneItemSchema).describe("场景列表,按 outline 出场顺序排列"),
characters: z.array(characterItemSchema).describe("角色列表,按 outline 出场顺序排列,必须是独立个体"),
props: z.array(propItemSchema).describe("道具列表,按 outline 出场顺序排列至少3个"),
coreConflict: z.string().describe("核心矛盾A想要X vs B阻碍X"),
outline: z.string().describe("100-300字剧情主干最高优先级剧本生成的唯一权威按时间顺序完整叙述"),
openingHook: z.string().describe("开场镜头outline 第一句话的视觉化,必须作为剧本第一个镜头"),
keyEvents: z.array(z.string()).length(4).describe("4个元素的数组[起, 承, 转, 合],严格按 outline 顺序从中提取"),
emotionalCurve: z.string().describe("情绪曲线2(压抑)→5(反抗)→9(爆发)→3(余波),对应 keyEvents 各阶段"),
visualHighlights: z.array(z.string()).describe("3-5个标志性镜头按 outline 叙事顺序排列"),
endingHook: z.string().describe("结尾悬念outline 之后的延伸,勾引下集"),
classicQuotes: z.array(z.string()).describe("1-2句金句每句≤15字必须从原文提取"),
});
// ==================== 常量配置 ====================
// ==================== 主类 ====================
export default class OutlineScript {
private readonly projectId: number;
readonly emitter = new EventEmitter();
history: Array<ModelMessage> = [];
novelChapters: DB["t_novel"][] = [];
constructor(projectId: number) {
this.projectId = projectId;
}
// ==================== 公共方法 ====================
get events() {
return this.emitter;
}
setNovel(chapters: DB["t_novel"][]) {
this.novelChapters = chapters;
}
// ==================== 私有工具方法 ====================
private emit(event: string, data?: any) {
this.emitter.emit(event, data);
}
private refresh(type: RefreshEvent) {
this.emit("refresh", type);
}
private log(action: string, detail?: string) {
const msg = detail ? `${action}: ${detail}` : action;
console.log(`\n[${new Date().toLocaleTimeString()}] ${msg}\n`);
}
private safeParseJson<T>(str: string, fallback: T): T {
try {
return JSON.parse(str);
} catch {
return fallback;
}
}
private uniqueByName<T extends { name: string }>(items: T[]): T[] {
return Array.from(new Map(items.map((item) => [item.name, item])).values());
}
// ==================== 数据库操作 ====================
private async getProjectInfo(): Promise<any> {
return u.db("t_project").where({ id: this.projectId }).first();
}
private async getNovelInfo(asString = false): Promise<any> {
const info = await this.getProjectInfo();
if (!info) return asString ? "未查询到项目信息" : null;
if (asString) {
const fields = [
`小说名称: ${info.name}`,
`小说简介: ${info.intro}`,
`小说类型: ${info.type}`,
`目标短剧类型: ${info.artStyle}`,
`短剧画幅: ${info.videoRatio}`,
];
return fields.join("\n");
}
return info;
}
// ==================== 故事线操作 ====================
private async findStoryline() {
return u.db("t_storyline").where({ projectId: this.projectId }).first();
}
private async upsertStorylineContent(content: string) {
const existing = await this.findStoryline();
if (existing) {
await u.db("t_storyline").where({ projectId: this.projectId }).update({ content });
} else {
await u.db("t_storyline").insert({ projectId: this.projectId, content });
}
this.refresh("storyline");
}
private async deleteStorylineContent() {
const deleted = await u.db("t_storyline").where({ projectId: this.projectId }).del();
this.refresh("storyline");
return deleted;
}
// ==================== 大纲操作 ====================
private async findOutlines() {
return u.db("t_outline").where({ projectId: this.projectId }).orderBy("episode", "asc");
}
private async findOutlineById(id: number) {
return u.db("t_outline").where({ id, projectId: this.projectId }).first();
}
private async getMaxEpisode(): Promise<number> {
const result: any = await u.db("t_outline").where({ projectId: this.projectId }).max("episode as max").first();
return result?.max ?? 0;
}
private async clearOutlinesAndScripts() {
const outlines = await u.db("t_outline").select("id").where({ projectId: this.projectId });
if (outlines.length === 0) return 0;
const outlineIds = outlines.map((o) => o.id);
await u.db("t_script").whereIn("outlineId", outlineIds).del();
await u.db("t_outline").where({ projectId: this.projectId }).del();
return outlines.length;
}
private async insertOutlines(episodes: EpisodeData[], startEpisode: number) {
const insertList = episodes.map((ep, idx) => ({
projectId: this.projectId,
data: JSON.stringify({ ...ep, episodeIndex: startEpisode + idx }),
episode: startEpisode + idx,
}));
await u.db("t_outline").insert(insertList);
return insertList.length;
}
private async createEmptyScripts(outlineIds: Array<{ id: number; data: string }>) {
const scripts = outlineIds.map((item) => {
const data = this.safeParseJson<Partial<EpisodeData>>(item.data, {});
return {
name: `${data.episodeIndex ?? ""}`,
content: "",
projectId: this.projectId,
outlineId: item.id,
};
});
if (scripts.length > 0) {
await u.db("t_script").insert(scripts);
}
return scripts.length;
}
private async saveOutlineData(episodes: EpisodeData[], overwrite: boolean, startEpisode?: number) {
if (overwrite) {
const cleared = await this.clearOutlinesAndScripts();
if (cleared > 0) {
this.log("清理旧数据", `删除了 ${cleared} 条大纲及关联剧本`);
}
}
const actualStart = overwrite ? 1 : (startEpisode ?? (await this.getMaxEpisode()) + 1);
const insertedCount = await this.insertOutlines(episodes, actualStart);
const newOutlines = await u
.db("t_outline")
.select("id", "data")
.where({ projectId: this.projectId })
.orderBy("episode", "desc")
.limit(insertedCount);
const scriptCount = await this.createEmptyScripts(newOutlines as Array<{ id: number; data: string }>);
this.refresh("outline");
return { insertedCount, scriptCount };
}
private async updateOutlineData(id: number, data: EpisodeData) {
const existing = await this.findOutlineById(id);
if (!existing) return false;
await u
.db("t_outline")
.where({ id })
.update({ data: JSON.stringify(data) });
this.refresh("outline");
return true;
}
private async deleteOutlineData(ids: number[]) {
const results = await Promise.allSettled(ids.map((id) => u.deleteOutline(id, this.projectId)));
this.refresh("outline");
return results;
}
private formatOutlineDetail(ep: any): string {
const formatList = (items: any[], formatter: (item: any) => string) =>
items?.map((item, i) => ` ${i + 1}. ${formatter(item)}`).join("\n") || " 无";
// keyEvents 按顺序显示:起、承、转、合
const keyEventsLabels = ["起", "承", "转", "合"];
const formatKeyEvents = (events: string[]) => events?.map((e, i) => `${keyEventsLabels[i] || i + 1}${e}`).join("\n") || " 无";
return `
大纲ID: ${ep.id}
${ep.episodeIndex} 集: ${ep.title || ""}
${"=".repeat(50)}
章节范围: ${ep.chapterRange?.join(", ") || ""}
核心矛盾: ${ep.coreConflict || ""}
【剧情主干】(最高优先级,剧本生成的唯一权威):
${ep.outline || "无"}
【开场镜头】(必须作为剧本第一个镜头):
${ep.openingHook || "无"}
【剧情节点】(严格按顺序:起→承→转→合):
${formatKeyEvents(ep.keyEvents)}
情绪曲线: ${ep.emotionalCurve || ""}
【视觉重点】(按剧情主干顺序排列):
${formatList(ep.visualHighlights, (v) => v)}
【结尾悬念】:
${ep.endingHook || "无"}
【经典台词】:
${formatList(ep.classicQuotes, (q) => q)}
角色(按出场顺序): ${ep.characters?.map((c: AssetItem) => `${c.name}(${c.description})`).join("; ") || "无"}
场景(按出场顺序): ${ep.scenes?.map((s: AssetItem) => `${s.name}(${s.description})`).join("; ") || "无"}
道具(按出场顺序): ${ep.props?.map((p: AssetItem) => `${p.name}(${p.description})`).join("; ") || "无"}`;
}
private async getOutlineText(simplified: boolean): Promise<string> {
const records = await this.findOutlines();
if (!records.length) return "当前项目暂无大纲";
const episodes = records.map((r) => ({
id: r.id,
episode: r.episode,
...this.safeParseJson<Partial<EpisodeData>>(r.data ?? "{}", {}),
}));
if (simplified) {
const list = episodes.map((ep) => `${ep.episodeIndex ?? ep.episode} 集 (id=${ep.id})`).join("\n");
return `项目大纲 (共 ${episodes.length} 集):\n${list}`;
}
const details = episodes.map((ep) => this.formatOutlineDetail(ep)).join("\n");
return `项目大纲 (共 ${episodes.length} 集)\n\n${details}`;
}
// ==================== 资产操作 ====================
private async findAssetByTypeAndName(type: AssetType, name: string) {
return u.db("t_assets").where({ projectId: this.projectId, type, name }).first();
}
private async upsertAsset(type: AssetType, item: AssetItem): Promise<"inserted" | "updated" | "skipped"> {
const existing = await this.findAssetByTypeAndName(type, item.name);
if (!existing) {
await u.db("t_assets").insert({
projectId: this.projectId,
type,
name: item.name,
intro: item.description,
prompt: item.description,
});
return "inserted";
}
if (existing.intro !== item.description) {
await u.db("t_assets").where({ id: existing.id }).update({
intro: item.description,
prompt: item.description,
});
return "updated";
}
return "skipped";
}
private extractAssetsFromOutlines(outlines: Array<{ data?: string | null | undefined }>): {
characters: AssetItem[];
props: AssetItem[];
scenes: AssetItem[];
} {
const result = { characters: [] as AssetItem[], props: [] as AssetItem[], scenes: [] as AssetItem[] };
for (const outline of outlines) {
const data = this.safeParseJson<Partial<EpisodeData>>(outline.data ?? "{}", {});
if (data.characters) result.characters.push(...data.characters);
if (data.props) result.props.push(...data.props);
if (data.scenes) result.scenes.push(...data.scenes);
}
return {
characters: this.uniqueByName(result.characters),
props: this.uniqueByName(result.props),
scenes: this.uniqueByName(result.scenes),
};
}
private async generateAssetsFromOutlines() {
const outlines = await u.db("t_outline").select("data").where({ projectId: this.projectId });
if (!outlines.length) return { inserted: 0, updated: 0, skipped: 0 };
const { characters, props, scenes } = this.extractAssetsFromOutlines(outlines);
// 只做新增和更新,不做删除
const stats = { inserted: 0, updated: 0, skipped: 0 };
const processItems = async (items: AssetItem[], type: AssetType) => {
for (const item of items) {
const result = await this.upsertAsset(type, item);
stats[result]++;
}
};
await processItems(characters, "角色");
await processItems(props, "道具");
await processItems(scenes, "场景");
this.refresh("assets");
return { ...stats };
}
// ==================== Tool 定义:故事线 ====================
getStoryline = tool({
title: "getStoryline",
description: "Get the weather in a location",
inputSchema: z.object({}),
execute: async () => {
this.log("获取故事线");
const storyline = await this.findStoryline();
return storyline?.content ?? "当前项目暂无故事线";
},
});
saveStoryline = tool({
title: "saveStoryline",
description: "保存或更新当前项目的故事线,会覆盖已有内容",
inputSchema: z.object({
content: z.string().describe("故事线完整内容"),
}),
execute: async ({ content }) => {
this.log("保存故事线");
await this.upsertStorylineContent(content);
return "故事线保存成功";
},
});
deleteStoryline = tool({
title: "deleteStoryline",
description: "删除当前项目的故事线",
inputSchema: z.object({}),
execute: async () => {
this.log("删除故事线");
const deleted = await this.deleteStorylineContent();
return deleted > 0 ? "故事线删除成功" : "当前项目没有故事线";
},
});
// ==================== Tool 定义:大纲 ====================
getOutline = tool({
title: "getOutline",
description: "获取项目大纲。simplified=true返回简化列表false返回完整内容",
inputSchema: z.object({
simplified: z.boolean().default(false).describe("是否返回简化版本"),
}),
execute: async ({ simplified }) => {
this.log("获取大纲", `简化模式: ${simplified}`);
return this.getOutlineText(simplified);
},
});
saveOutline = tool({
title: "saveOutline",
description: "保存大纲数据。overwrite=true会清空现有大纲后写入false则追加到末尾",
inputSchema: z.object({
episodes: z.array(episodeSchema).min(1).describe("大纲数据数组"),
overwrite: z.boolean().default(true).describe("是否覆盖现有大纲"),
startEpisode: z.number().optional().describe("追加模式下的起始集数(不填则自动递增)"),
}),
execute: async ({ episodes, overwrite = true, startEpisode }) => {
this.log("保存大纲", `覆盖模式: ${overwrite}, 集数: ${episodes.length}`);
const { insertedCount, scriptCount } = await this.saveOutlineData(episodes as EpisodeData[], overwrite, startEpisode);
return `大纲保存成功:插入 ${insertedCount} 集大纲,创建 ${scriptCount} 个剧本记录`;
},
});
updateOutline = tool({
title: "updateOutline",
description: "更新指定ID的单集大纲内容",
inputSchema: z.object({
id: z.number().describe("大纲ID"),
data: episodeSchema.describe("更新后的大纲数据"),
}),
execute: async ({ id, data }) => {
this.log("更新大纲", `ID: ${id}`);
const success = await this.updateOutlineData(id, data as EpisodeData);
return success ? `大纲ID ${id} 更新成功` : `未找到大纲ID: ${id}`;
},
});
deleteOutline = tool({
title: "deleteOutline",
description: "根据大纲ID删除指定大纲及关联数据",
inputSchema: z.object({
ids: z.array(z.number()).min(1).describe("要删除的大纲ID数组"),
}),
execute: async ({ ids }) => {
this.log("删除大纲", `IDs: ${ids.join(", ")}`);
const results = await this.deleteOutlineData(ids);
const summary = results.map((r, i) => `ID ${ids[i]}: ${r.status === "fulfilled" ? "成功" : "失败"}`).join(", ");
return `删除结果: ${summary}`;
},
});
// ==================== Tool 定义:章节 ====================
getChapter = tool({
title: "getChapter",
description: "根据章节编号获取小说章节的完整原文内容,支持批量获取",
inputSchema: z.object({
chapterNumbers: z.array(z.number()).min(1).describe("章节编号数组"),
}),
execute: async ({ chapterNumbers }) => {
this.log("获取章节", `章节号: ${chapterNumbers.join(", ")}`);
const results = await Promise.all(
chapterNumbers.map(async (num) => {
const chapter = await u
.db("t_novel")
.where({ projectId: this.projectId, chapterIndex: num })
.select("chapterData", "chapterIndex", "chapter")
.first();
if (chapter) {
return `\n【第${chapter.chapterIndex}${chapter.chapter || ""}\n${chapter.chapterData}`;
}
return `\n【第${num}章】未找到`;
}),
);
return results.join("\n\n---\n");
},
});
// ==================== Tool 定义:资产 ====================
generateAssets = tool({
title: "generateAssets",
description: "从当前项目的所有大纲中提取并生成角色、道具、场景资产,自动去重并清理冗余",
inputSchema: z.object({}),
execute: async () => {
this.log("生成资产");
const stats = await this.generateAssetsFromOutlines();
if (stats.inserted === 0 && stats.updated === 0 && stats.skipped === 0) {
return "当前项目没有大纲数据,无法生成资产";
}
return `资产生成完成:新增 ${stats.inserted},更新 ${stats.updated},保持 ${stats.skipped}`;
},
});
// ==================== 上下文构建 ====================
private getChapterContext(): string {
if (!this.novelChapters.length) return "无章节数据";
return this.novelChapters.map((c) => `章节号:${c.chapterIndex},分卷:${c.reel},章节名:${c.chapter}`).join("\n");
}
private async buildEnvironmentContext(): Promise<string> {
const [novelInfo, storyline, outlineCount] = await Promise.all([
this.getNovelInfo(true),
this.findStoryline(),
u.db("t_outline").where({ projectId: this.projectId }).count("id as count").first() as any,
]);
return `<环境信息>
项目ID: ${this.projectId}
系统时间: ${new Date().toLocaleString()}
${novelInfo}
已加载章节列表:
${this.getChapterContext()}
故事线状态: ${storyline ? "已生成" : "未生成"}
大纲状态: 共 ${outlineCount?.count ?? 0}
可用工具:
- getChapter: 获取章节原文
- getStoryline/saveStoryline/deleteStoryline: 故事线操作
- getOutline/saveOutline/updateOutline/deleteOutline: 大纲操作
- generateAssets: 从大纲生成资产
</环境信息>`;
}
private buildConversationHistory(): string {
if (!this.history.length) return "无对话历史";
return this.history.map(({ role, content }) => `${role}: ${content}`).join("\n\n");
}
private async buildFullContext(task: string): Promise<string> {
const env = await this.buildEnvironmentContext();
const history = this.buildConversationHistory();
return `${env}
<对话历史>
${history}
</对话历史>
<当前任务>
${task}
</当前任务>`;
}
// ==================== Sub-Agent ====================
private getSubAgentTools() {
return {
getChapter: this.getChapter,
getStoryline: this.getStoryline,
saveStoryline: this.saveStoryline,
getOutline: this.getOutline,
saveOutline: this.saveOutline,
updateOutline: this.updateOutline,
};
}
/**
* 调用 Sub-Agent流式传输
*/
private async invokeSubAgent(agentType: AgentType, task: string): Promise<string> {
this.emit("transfer", { to: agentType });
this.log(`Sub-Agent 调用`, agentType);
const promptsList = await u.db("t_prompts").where("code", "in", ["outlineScript-a1", "outlineScript-a2", "outlineScript-director"]);
const promptConfig = await u.getPromptAi("outlineScriptAgent");
const errPrompts = "不论用户说什么请直接输出Agent配置异常";
const getAiPromptConfig = (code: string) => {
const item = promptsList.find((p) => p.code === code);
return item?.customValue || item?.defaultValue || errPrompts;
};
const a1Prompt = getAiPromptConfig("outlineScript-a1");
const a2Prompt = getAiPromptConfig("outlineScript-a2");
const directorPrompt = getAiPromptConfig("outlineScript-director");
const SYSTEM_PROMPTS = {
AI1: a1Prompt,
AI2: a2Prompt,
director: directorPrompt,
};
const context = await this.buildFullContext(task);
const { fullStream } = await u.ai.text.stream(
{
system: SYSTEM_PROMPTS[agentType],
tools: this.getSubAgentTools(),
messages: [{ role: "user", content: context }],
maxStep: 100,
},
promptConfig,
);
let fullResponse = "";
for await (const item of fullStream) {
if (item.type == "tool-call") {
this.emit("toolCall", { agent: "main", name: item.title, args: null });
}
if (item.type == "text-delta") {
fullResponse += item.text;
this.emit("subAgentStream", { agent: agentType, text: item.text });
}
}
this.emit("subAgentEnd", { agent: agentType });
this.history.push({
role: "assistant",
content: fullResponse,
});
this.log(`Sub-Agent 完成`, agentType);
return fullResponse ?? `${agentType}已完成任务`;
}
private createSubAgentTool(agentType: AgentType, description: string) {
return tool({
title: agentType,
description,
inputSchema: z.object({
taskDescription: z.string().describe("具体的任务描述,包含章节范围、修改要求等详细信息"),
}),
execute: async ({ taskDescription }) => this.invokeSubAgent(agentType, taskDescription),
});
}
// ==================== 主入口 ====================
private getAllTools() {
return {
AI1: this.createSubAgentTool("AI1", "调用故事师。负责分析小说原文并生成故事线,会自行调用 saveStoryline 保存结果。"),
AI2: this.createSubAgentTool("AI2", "调用大纲师。负责根据故事线生成剧集大纲,会自行调用 saveOutline 保存结果。"),
director: this.createSubAgentTool("director", "调用导演。负责审核故事线和大纲,会自行调用 updateOutline 或 saveStoryline 进行修改。"),
getChapter: this.getChapter,
getStoryline: this.getStoryline,
saveStoryline: this.saveStoryline,
deleteStoryline: this.deleteStoryline,
getOutline: this.getOutline,
saveOutline: this.saveOutline,
updateOutline: this.updateOutline,
deleteOutline: this.deleteOutline,
generateAssets: this.generateAssets,
};
}
async call(msg: string): Promise<string> {
this.history.push({
role: "user",
content: msg,
});
const envContext = await this.buildEnvironmentContext();
const prompts = await u.db("t_prompts").where("code", "outlineScript-main").first();
const promptConfig = await u.getPromptAi("outlineScriptAgent");
const mainPrompts = prompts?.customValue || prompts?.defaultValue || "不论用户说什么请直接输出Agent配置异常";
const { fullStream } = await u.ai.text.stream(
{
system: `${envContext}\n${mainPrompts}`,
tools: this.getAllTools(),
messages: this.history,
maxStep: 100,
},
promptConfig,
);
let fullResponse = "";
for await (const item of fullStream) {
if (item.type == "tool-call") {
this.emit("toolCall", { agent: "main", name: item.title, args: null });
}
if (item.type == "text-delta") {
fullResponse += item.text;
this.emit("data", item.text);
}
}
this.history.push({
role: "assistant",
content: fullResponse,
});
this.emit("response", fullResponse);
return fullResponse;
}
}