// @/agents/outlineScript.ts import u from "@/utils"; import { createAgent } from "langchain"; import { EventEmitter } from "events"; import { openAI } from "@/agents/models"; import { z } from "zod"; import { tool } from "@langchain/core/tools"; 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<[string, string]> = []; novelChapters: DB["t_novel"][] = []; modelName = "gpt-4.1"; apiKey = ""; baseURL = ""; 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(str: string, fallback: T): T { try { return JSON.parse(str); } catch { return fallback; } } private uniqueByName(items: T[]): T[] { return Array.from(new Map(items.map((item) => [item.name, item])).values()); } // ==================== 数据库操作 ==================== private async getProjectInfo(): Promise { return u.db("t_project").where({ id: this.projectId }).first(); } private async getNovelInfo(asString = false): Promise { 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 { 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>(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 { const records = await this.findOutlines(); if (!records.length) return "当前项目暂无大纲"; const episodes = records.map((r) => ({ id: r.id, episode: r.episode, ...this.safeParseJson>(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>(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( async () => { this.log("获取故事线"); const storyline = await this.findStoryline(); return storyline?.content ?? "当前项目暂无故事线"; }, { name: "getStoryline", description: "获取当前项目的故事线内容", schema: z.object({}), verboseParsingErrors: true, }, ); saveStoryline = tool( async ({ content }) => { this.log("保存故事线"); await this.upsertStorylineContent(content); return "故事线保存成功"; }, { name: "saveStoryline", description: "保存或更新当前项目的故事线,会覆盖已有内容", schema: z.object({ content: z.string().describe("故事线完整内容"), }), verboseParsingErrors: true, }, ); deleteStoryline = tool( async () => { this.log("删除故事线"); const deleted = await this.deleteStorylineContent(); return deleted > 0 ? "故事线删除成功" : "当前项目没有故事线"; }, { name: "deleteStoryline", description: "删除当前项目的故事线", schema: z.object({}), verboseParsingErrors: true, }, ); // ==================== Tool 定义:大纲 ==================== getOutline = tool( async ({ simplified = false }) => { this.log("获取大纲", `简化模式: ${simplified}`); return this.getOutlineText(simplified); }, { name: "getOutline", description: "获取项目大纲。simplified=true返回简化列表,false返回完整内容", schema: z.object({ simplified: z.boolean().default(false).describe("是否返回简化版本"), }), verboseParsingErrors: true, }, ); saveOutline = tool( async ({ episodes, overwrite = true, startEpisode }) => { this.log("保存大纲", `覆盖模式: ${overwrite}, 集数: ${episodes.length}`); const { insertedCount, scriptCount } = await this.saveOutlineData(episodes as EpisodeData[], overwrite, startEpisode); return `大纲保存成功:插入 ${insertedCount} 集大纲,创建 ${scriptCount} 个剧本记录`; }, { name: "saveOutline", description: "保存大纲数据。overwrite=true会清空现有大纲后写入,false则追加到末尾", schema: z.object({ episodes: z.array(episodeSchema).min(1).describe("大纲数据数组"), overwrite: z.boolean().default(true).describe("是否覆盖现有大纲"), startEpisode: z.number().optional().describe("追加模式下的起始集数(不填则自动递增)"), }), verboseParsingErrors: true, }, ); updateOutline = tool( async ({ id, data }) => { this.log("更新大纲", `ID: ${id}`); const success = await this.updateOutlineData(id, data as EpisodeData); return success ? `大纲ID ${id} 更新成功` : `未找到大纲ID: ${id}`; }, { name: "updateOutline", description: "更新指定ID的单集大纲内容", schema: z.object({ id: z.number().describe("大纲ID"), data: episodeSchema.describe("更新后的大纲数据"), }), verboseParsingErrors: true, }, ); deleteOutline = tool( 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}`; }, { name: "deleteOutline", description: "根据大纲ID删除指定大纲及关联数据", schema: z.object({ ids: z.array(z.number()).min(1).describe("要删除的大纲ID数组"), }), verboseParsingErrors: true, }, ); // ==================== Tool 定义:章节 ==================== getChapter = tool( 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"); }, { name: "getChapter", description: "根据章节编号获取小说章节的完整原文内容,支持批量获取", schema: z.object({ chapterNumbers: z.array(z.number()).min(1).describe("章节编号数组"), }), verboseParsingErrors: true, }, ); // ==================== Tool 定义:资产 ==================== generateAssets = tool( 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}`; }, { name: "generateAssets", description: "从当前项目的所有大纲中提取并生成角色、道具、场景资产,自动去重并清理冗余", schema: z.object({}), verboseParsingErrors: true, }, ); // ==================== 上下文构建 ==================== 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 { 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 { const env = await this.buildEnvironmentContext(); const history = this.buildConversationHistory(); return `${env} <对话历史> ${history} <当前任务> ${task} `; } // ==================== Sub-Agent ==================== private getSubAgentTools() { return [this.getChapter, this.getStoryline, this.saveStoryline, this.getOutline, this.saveOutline, this.updateOutline]; } private createModel() { return openAI({ modelName: this.modelName, configuration: { apiKey: this.apiKey, baseURL: this.baseURL }, }); } /** * 调用 Sub-Agent(流式传输) */ private async invokeSubAgent(agentType: AgentType, task: string): Promise { 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 a1Prompt = promptsList.find((p) => p.code === "outlineScript-a1"); const a2Prompt = promptsList.find((p) => p.code === "outlineScript-a2"); const directorPrompt = promptsList.find((p) => p.code === "outlineScript-director"); const errPrompts = "不论用户说什么,请直接输出Agent配置异常"; const SYSTEM_PROMPTS: Record = { AI1: a1Prompt?.customValue || a1Prompt?.defaultValue || errPrompts, AI2: a2Prompt?.customValue || a2Prompt?.defaultValue || errPrompts, director: directorPrompt?.customValue || directorPrompt?.defaultValue || errPrompts, }; const context = await this.buildFullContext(task); const agent = createAgent({ model: this.createModel(), systemPrompt: SYSTEM_PROMPTS[agentType], tools: this.getSubAgentTools(), }); const stream = await agent.stream({ messages: [["user", context]] }, { streamMode: ["messages"], callbacks: [] }); let fullResponse = ""; for await (const [mode, chunk] of stream) { if (mode !== "messages") continue; const [token] = chunk as any; const block = token.contentBlocks?.[0]; // 处理 AI 文本流 if (token.type === "ai" && block?.text) { fullResponse += block.text; this.emit("subAgentStream", { agent: agentType, text: block.text }); } // 处理 tool 调用 if (token.type === "ai" && token.tool_calls?.length) { for (const toolCall of token.tool_calls) { this.emit("toolCall", { agent: agentType, name: toolCall.name, args: toolCall.args }); } } } this.emit("subAgentEnd", { agent: agentType }); this.history.push(["ai", fullResponse]); this.log(`Sub-Agent 完成`, agentType); return fullResponse ?? `${agentType}已完成任务`; } private createSubAgentTool(agentType: AgentType, description: string) { return tool(async ({ taskDescription }) => this.invokeSubAgent(agentType, taskDescription), { name: agentType, description, schema: z.object({ taskDescription: z.string().describe("具体的任务描述,包含章节范围、修改要求等详细信息"), }), }); } // ==================== 主入口 ==================== private getAllTools() { return [ this.createSubAgentTool("AI1", "调用故事师。负责分析小说原文并生成故事线,会自行调用 saveStoryline 保存结果。"), this.createSubAgentTool("AI2", "调用大纲师。负责根据故事线生成剧集大纲,会自行调用 saveOutline 保存结果。"), this.createSubAgentTool("director", "调用导演。负责审核故事线和大纲,会自行调用 updateOutline 或 saveStoryline 进行修改。"), this.getChapter, this.getStoryline, this.saveStoryline, this.deleteStoryline, this.getOutline, this.saveOutline, this.updateOutline, this.deleteOutline, this.generateAssets, ]; } async call(msg: string): Promise { this.history.push(["user", msg]); const envContext = await this.buildEnvironmentContext(); const prompts = await u.db("t_prompts").where("code", "outlineScript-main").first(); const mainPrompts = prompts?.customValue || prompts?.defaultValue || "不论用户说什么,请直接输出Agent配置异常"; const mainAgent = createAgent({ model: this.createModel(), tools: this.getAllTools(), systemPrompt: `${envContext}\n${mainPrompts}`, }); const stream = await mainAgent.stream({ messages: this.history }, { streamMode: ["messages"], callbacks: [] }); let fullResponse = ""; for await (const [mode, chunk] of stream) { if (mode !== "messages") continue; const [token] = chunk as any; const block = token.contentBlocks?.[0]; // 处理 AI 文本流 if (token.type === "ai" && block?.text) { fullResponse += block.text; this.emit("data", block.text); } // 处理 tool 调用 if (token.type === "ai" && token.tool_calls?.length) { for (const toolCall of token.tool_calls) { this.emit("toolCall", { agent: "main", name: toolCall.name, args: toolCall.args }); } } } this.history.push(["assistant", fullResponse]); this.emit("response", fullResponse); return fullResponse; } }