/**不同类型 将有不同元数据 */ export enum ScriptSliceType { /** 文本 */ text = "text", /**角色 */ role = "role", /** 场景 */ scene = "scene", } /** * 剧本片段值对象 * @description 代表剧本中的一个片段,按值相等判断,不可变 */ export class ScriptSlice { /**唯一标识符 - 仅作为局部唯一性标识,不作为全局Entity id */ readonly id: string; /** 类型 */ readonly type: ScriptSliceType; /** 剧本内容 */ readonly text: string; /** 元数据 */ metaData: any; constructor( id: string, type: ScriptSliceType, text: string, metaData: any = {} ) { this.id = id; this.type = type; this.text = text; this.metaData = metaData; } /** * 值对象相等性比较 * @param other 另一个ScriptSlice实例 * @returns 是否相等 */ equals(other: ScriptSlice): boolean { return ( this.type === other.type && this.text === other.text && JSON.stringify(this.metaData) === JSON.stringify(other.metaData) ); } } /** * 对话内容项值对象 * @description 代表对话中的一个内容项,按值相等判断,不可变 */ export interface ContentItem { /** 角色名称 */ roleName: string; /** 对话内容 */ content: string; } /** * 镜头值对象 * @description 代表镜头信息,按值相等判断,不可变 */ export class LensType { /**镜头名称 */ readonly name: string; /**镜头描述 */ readonly script: string; /**对话内容 */ readonly content: ContentItem[]; constructor(name: string, script: string, content: ContentItem[]) { this.name = name; this.script = script; this.content = content; } } /** * 标签实体接口 */ export interface TagValueObject { /** 唯一标识 */ readonly id: string; /** 标签名称 */ name: string; /** 内容标签类型 */ content: number | string; /** 颜色 */ color?: string; } /** * 这是一个符合DDD概念的值对象,代表故事的核心细节。 * 它封装了故事的四个关键元素:梗概、分类、主角和框架内容。 * - 值对象是不可变的,一旦创建,其内部属性不会改变。 * - 构造函数是私有的,强制使用工厂方法创建实例,以确保数据处理的封装性。 */ export class StoryDetails { /** 故事梗概 */ public synopsis: string = ""; /** 故事分类标签 */ public categories: string[] = []; /** 主角名称 */ public protagonist: string = ""; /** 激励事件 */ public incitingIncident: string = ""; /** 问题与新目标 */ public problem: string = ""; /** 冲突与障碍 */ public conflict: string = ""; /** 赌注 */ public stakes: string = ""; /** 人物弧线完成 */ public characterArc: string = ""; /** * 更新剧本 * @param text 剧本文本 */ updateScript(text: string) { console.log('text.length', text.length) const scriptObject = this.createFromText(text); console.log('scriptObject', scriptObject) this.setProperties(scriptObject); return scriptObject; } setProperties(properties: { synopsis: string; categories: string[]; protagonist: string; incitingIncident: string; problem: string; conflict: string; stakes: string; characterArc: string; }) { this.synopsis = properties.synopsis; this.categories = properties.categories; this.protagonist = properties.protagonist; this.incitingIncident = properties.incitingIncident; this.problem = properties.problem; this.conflict = properties.conflict; this.stakes = properties.stakes; this.characterArc = properties.characterArc; } constructor(text: string) { this.updateScript(text); } /** * 工厂方法:从原始英文文本中提取并创建 StoryDetails 值对象。 * 该方法负责所有的解析逻辑,包括智能提取梗概和去除格式标签。 * @param text 包含故事所有细节的原始Markdown或富文本。 */ public createFromText(text: string) { try { // --- 智能解析梗概 --- // 梗概通常位于“Core Elements”部分,描述主角和初始事件。 // 我们提取“Protagonist”和“The Inciting Incident”的描述,并组合起来。 const synopsis = this.extractContentByHeader(text, "Logline"); const incitingIncidentText = this.extractContentByHeader( text, "The Inciting Incident", ); // --- 其他字段的提取保持不变,使用标题进行查找 --- const categories = this.extractContentByHeader(text, "GENRE") .split(",") .map((s) => s.trim()) .filter((s) => s.length > 0); const protagonist = this.extractContentByHeader(text, "Core Identity:"); const problem = this.extractContentByHeader(text, "The Problem & New Goal"); const conflict = this.extractContentByHeader(text, "Conflict & Obstacles"); const stakes = this.extractContentByHeader(text, "The Stakes"); const characterArc = this.extractContentByHeader( text, "Character Arc Accomplished" ); return { synopsis, categories, protagonist, incitingIncident: incitingIncidentText, problem, conflict, stakes, characterArc, }; } catch (error) { return { synopsis: "", categories: [], protagonist: "", incitingIncident: "", problem: "", conflict: "", stakes: "", characterArc: "", }; } } /** * 辅助方法:根据标题提取其下的内容,并去除所有Markdown标签。 * @param fullText 完整的源文本。 * @param headerName 要提取内容所属的标题名(不区分大小写)。 * @returns 提取出的纯文本内容。 */ public extractContentByHeader( fullText: string, headerName: string, debug: boolean = false ): string { try { debug && console.log(`正在查找标题: "${headerName}"`); // 转义正则表达式中的特殊字符 const escapeRegex = (text: string): string => { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }; // 清理Markdown格式和多余的空白字符 const cleanMarkdownContent = (content: string): string => { return ( content // 移除markdown的粗体标记 .replace(/\*\*([^*]+)\*\*/g, "$1") // 移除markdown的斜体标记 .replace(/\*([^*]+)\*/g, "$1") // 移除列表标记(- 或 * 开头的行) .replace(/^\s*[-*]\s+/gm, "") // 移除代码块 .replace(/```[\s\S]*?```/g, "") // 移除行内代码 .replace(/`([^`]+)`/g, "$1") // 规范化空白字符:将多个连续空白(包括换行)替换为单个空格 .replace(/\s+/g, " ") // 移除引号 .replace(/["""'']/g, "") // 最终清理首尾空白 .trim() ); }; // 标准化headerName,移除末尾的冒号和空格 const normalizedHeaderName = headerName.replace(/:?\s*$/, "").trim(); const escapedHeaderName = escapeRegex(normalizedHeaderName); let content = ""; // 定义多种匹配模式,按优先级排序 const patterns = [ // 1. 匹配带编号的主标题:数字. **标题:** 格式 new RegExp( `\\d+\\.\\s*\\*\\*${escapedHeaderName}:?\\*\\*\\s*([\\s\\S]*?)(?=\\d+\\.\\s*\\*\\*[^*]+:?\\*\\*|\\*\\*[A-Z]+:\\*\\*|---|\$)`, "i" ), // 2. 匹配子标题:*标题:* 格式(在主标题下的子项) new RegExp( `\\*\\s*\\*\\*${escapedHeaderName}:?\\*\\*\\s*([\\s\\S]*?)(?=\\*\\s*\\*\\*[^*]+:?\\*\\*|\\d+\\.\\s*\\*\\*[^*]+:?\\*\\*|\\*\\*[A-Z]+:\\*\\*|---|\$)`, "i" ), // 3. 匹配独立的粗体标题:**标题:** 格式 new RegExp( `^\\s*\\*\\*${escapedHeaderName}:?\\*\\*\\s*([\\s\\S]*?)(?=^\\s*\\*\\*[^*]+:?\\*\\*|\\d+\\.\\s*\\*\\*[^*]+:?\\*\\*|^\\s*\\*\\*[A-Z]+:\\*\\*|^---|\$)`, "im" ), // 4. 匹配简单的粗体键值对格式:**标题:** 值(适用于简单的键值对,如 **GENRE:** Drama) new RegExp(`\\*\\*${escapedHeaderName}:?\\*\\*\\s*([^\\n]+)`, "i"), // 5. 匹配简单的**标题:**格式(可能在段落中) new RegExp( `\\*\\*${escapedHeaderName}:?\\*\\*\\s*([\\s\\S]*?)(?=\\*\\*[^*]+:?\\*\\*|\\d+\\.\\s*\\*\\*[^*]+:?\\*\\*|\\n\\n|---|\$)`, "i" ), // 6. 匹配markdown标题:## 标题 格式 new RegExp( `^#{1,6}\\s*${escapedHeaderName}:?\\s*\\n([\\s\\S]*?)(?=^#{1,6}\\s|^\\*\\*[^*]+:?\\*\\*|^---|\$)`, "im" ), // 7. 匹配冒号后的内容:标题: 内容(适用于简单的键值对格式) new RegExp( `^\\s*${escapedHeaderName}:?\\s*([\\s\\S]*?)(?=^\\s*[A-Za-z][^:]*:|^\\*\\*[^*]+:?\\*\\*|^---|\$)`, "im" ), ]; // 尝试每种模式 for (let i = 0; i < patterns.length; i++) { const regex = patterns[i]; const match = fullText.match(regex); if (match && match[1] && match[1].trim()) { content = match[1].trim(); debug && console.log(`使用模式 ${i + 1} 匹配成功`); debug && console.log(`匹配的完整内容: "${match[0].substring(0, 150)}..."`); break; } } // console.log(headerName, content) if (!content) { debug && console.log(`没有找到标题 "${headerName}" 对应的内容`); // 尝试更宽松的匹配作为最后手段 const loosePattern = new RegExp( `${escapedHeaderName}[:\\s]*([\\s\\S]{1,500}?)(?=\\n\\n|\\*\\*[^*]+\\*\\*|#{1,6}\\s|---|\$)`, "i" ); const looseMatch = fullText.match(loosePattern); if (looseMatch && looseMatch[1]) { content = looseMatch[1].trim(); debug && console.log("使用宽松匹配模式找到内容"); } else { return ""; } } debug && console.log(`原始匹配内容: "${content.substring(0, 100)}..."`); // 清理内容格式 content = cleanMarkdownContent(content); // 如果内容太短,可能是匹配错误,返回空 if (content.length < 20&&headerName!=='GENRE') { debug && console.log("匹配到的内容太短,可能匹配错误"); return ""; } debug && console.log(`清理后的内容: "${content.substring(0, 100)}..."`); return content; } catch (error) { console.log("内容提取出错:", error); return ""; } } toObject() { return { synopsis: this.synopsis, categories: this.categories, protagonist: this.protagonist, incitingIncident: this.incitingIncident, problem: this.problem, conflict: this.conflict, stakes: this.stakes, characterArc: this.characterArc, }; } } /** * 剧本聚合根 * @description 作为聚合根,封装ScriptSlice的管理与行为 */ export class ScriptValueObject { /** 剧本片段数组 - 值对象数组 */ private readonly _scriptSlices: ScriptSlice[] = []; /** * 获取剧本片段数组的只读副本 */ get scriptSlices(): readonly ScriptSlice[] { return [...this._scriptSlices]; } scriptText: string = ""; storyDetails: StoryDetails; /** * @description: 构造函数,初始化剧本 * @param scriptText 剧本文本字符串 */ constructor(scriptText?: string) { this.storyDetails = new StoryDetails(""); if (scriptText) { this.parseFromString(scriptText); } } /** * @description: 从字符串解析剧本片段 * @param scriptText 剧本文本字符串 */ parseFromString(scriptText: string) { this.scriptText += scriptText; return this.storyDetails.updateScript(this.scriptText); } /** * @description: 将剧本片段转换为字符串 * @returns string */ toString(): string { return this.scriptText; } } /** * 角色简单数据接口 * @description 包含角色姓名、图片和描述 */ export interface SimpleCharacter { /** 姓名 */ name: string; /** 图片URL */ imageUrl: string; /** 角色描述 */ description: string; }