diff --git a/modules/story-outline/story-outline-prompt.js b/modules/story-outline/story-outline-prompt.js index ddea587..32c4a59 100644 --- a/modules/story-outline/story-outline-prompt.js +++ b/modules/story-outline/story-outline-prompt.js @@ -1,604 +1,604 @@ -// Story Outline 提示词模板配置 -// 统一 UAUA (User-Assistant-User-Assistant) 结构 - -const PROMPT_STORAGE_KEY = 'LittleWhiteBox_StoryOutline_CustomPrompts_v2'; - -// ================== 辅助函数 ================== -const wrap = (tag, content) => content ? `<${tag}>\n${content}\n` : ''; -const worldInfo = `\n{{description}}{$worldInfo}\n玩家角色:{{user}}\n{{persona}}`; -const history = n => `\n{$history${n}}\n`; -const nameList = (contacts, strangers) => { - const names = [...(contacts || []).map(c => c.name), ...(strangers || []).map(s => s.name)]; - return names.length ? `\n\n**已存在角色(不要重复):** ${names.join('、')}` : ''; -}; -const randomRange = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; -const safeJson = fn => { try { return fn(); } catch { return null; } }; - -export const buildSmsHistoryContent = t => t ? `<已有短信>\n${t}\n` : '<已有短信>\n(空白,首次对话)\n'; -export const buildExistingSummaryContent = t => t ? `<已有总结>\n${t}\n` : '<已有总结>\n(空白,首次总结)\n'; - -// ================== JSON 模板(用户可自定义) ================== -const DEFAULT_JSON_TEMPLATES = { - sms: `{ - "cot": "思维链:分析角色当前的处境、与用户的关系...", - "reply": "角色用自己的语气写的回复短信内容(10-50字)" -}`, - invite: `{ - "cot": "思维链:分析角色当前的处境、与用户的关系、对邀请地点的看法...", - "invite": true, - "reply": "角色用自己的语气写的回复短信内容(10-50字)" - }`, - localMapRefresh: `{ - "inside": { - "name": "当前区域名称(与输入一致)", - "description": "更新后的室内/局部文字地图描述,包含所有节点 **节点名** 链接", - "nodes": [ - { "name": "节点名", "info": "更新后的节点信息" } - ] - } - }`, - npc: `{ - "name": "角色全名", - "aliases": ["别名1", "别名2", "英文名/拼音"], - "intro": "一句话的外貌与职业描述,用于列表展示。", - "background": "简短的角色生平。解释由于什么过去导致了现在的性格,以及他为什么会出现在当前场景中。", - "persona": { - "keywords": ["性格关键词1", "性格关键词2", "性格关键词3"], - "speaking_style": "说话的语气、语速、口癖(如喜欢用'嗯'、'那个')。对待{{user}}的态度(尊敬、蔑视、恐惧等)。", - "motivation": "核心驱动力(如:金钱、复仇、生存)。行动的优先级准则。" - }, - "game_data": { - "stance": "核心态度·具体表现。例如:'中立·唯利是图'、'友善·盲目崇拜' 或 '敌对·疯狂'", - "secret": "该角色掌握的一个关键信息、道具或秘密。必须结合'剧情大纲'生成,作为一个潜在的剧情钩子。" - } -}`, - stranger: `[{ "name": "角色名", "location": "当前地点", "info": "一句话简介" }]`, - worldGenStep1: `{ - "meta": { - "truth": { - "background": "起源-动机-手段-现状(150字左右)", - "driver": { - "source": "幕后推手(组织/势力/自然力量)", - "target_end": "推手的最终目标", - "tactic": "当前正在执行的具体手段" - } - }, - "onion_layers": { - "L1_The_Veil": [{ "desc": "表层叙事", "logic": "维持正常假象的方式" }], - "L2_The_Distortion": [{ "desc": "异常现象", "logic": "让人感到不对劲的细节" }], - "L3_The_Law": [{ "desc": "隐藏规则", "logic": "违反会受到惩罚的法则" }], - "L4_The_Agent": [{ "desc": "执行者", "logic": "维护规则的实体" }], - "L5_The_Axiom": [{ "desc": "终极真相", "logic": "揭示一切的核心秘密" }] - }, - "atmosphere": { - "reasoning": "COT: 基于驱动力、环境和NPC心态分析当前气氛", - "current": { - "environmental": "环境氛围与情绪基调", - "npc_attitudes": "NPC整体态度倾向" - } - }, - "trajectory": { - "reasoning": "COT: 基于当前局势推演未来走向", - "ending": "预期结局走向" - }, - "user_guide": { - "current_state": "{{user}}当前处境描述", - "guides": ["行动建议"] - } - } -}`, - worldGenStep2: `{ - "world": { - "news": [ { "title": "...", "content": "..." } ] - }, - "maps": { - "outdoor": { - "name": "大地图名称", - "description": "宏观大地图/区域全景描写(包含环境氛围)。所有可去地点名用 **名字** 包裹连接在 description。", - "nodes": [ - { - "name": "地点名", - "position": "north/south/east/west/northeast/southwest/northwest/southeast", - "distant": 1, - "type": "home/sub/main", - "info": "地点特征与氛围" - }, - { - "name": "其他地点名", - "position": "north/south/east/west/northeast/southwest/northwest/southeast", - "distant": 1, - "type": "main/sub", - "info": "地点特征与氛围" - } - ] - }, - "inside": { - "name": "{{user}}当前所在位置名称", - "description": "局部地图全景描写,包含环境氛围。所有可交互节点名用 **名字** 包裹连接在 description。", - "nodes": [ - { "name": "节点名", "info": "节点的微观描写(如:布满灰尘的桌面)" } - ] - } - }, - "playerLocation": "{{user}}起始位置名称(与第一个节点的 name 一致)" -}`, - worldSim: `{ - "meta": { - "truth": { "driver": { "tactic": "更新当前手段" } }, - "onion_layers": { - "L1_The_Veil": [{ "desc": "更新表层叙事", "logic": "新的掩饰方式" }], - "L2_The_Distortion": [{ "desc": "更新异常现象", "logic": "新的违和感" }], - "L3_The_Law": [{ "desc": "更新规则", "logic": "规则变化(可选)" }], - "L4_The_Agent": [], - "L5_The_Axiom": [] - }, - "atmosphere": { - "reasoning": "COT: 基于最新局势分析气氛变化", - "current": { - "environmental": "更新后的环境氛围", - "npc_attitudes": "NPC态度变化" - } - }, - "trajectory": { - "reasoning": "COT: 基于{{user}}行为推演新走向", - "ending": "修正后的结局走向" - }, - "user_guide": { - "current_state": "更新{{user}}处境", - "guides": ["建议1", "建议2"] - } - }, - "world": { "news": [{ "title": "新闻标题", "content": "内容" }] }, - "maps": { - "outdoor": { - "description": "更新区域描述", - "nodes": [{ "name": "地点名", "position": "方向", "distant": 1, "type": "类型", "info": "状态" }] - } - } -}`, - sceneSwitch: `{ - "review": { - "deviation": { - "cot_analysis": "简要分析{{user}}在上一地点的最后行为是否改变了局势或氛围", - "score_delta": 0 - } - }, - "local_map": { - "name": "地点名称", - "description": "局部地点全景描写(不写剧情),必须包含所有 nodes 的 **节点名**", - "nodes": [ - { - "name": "节点名", - "info": "该节点的静态细节/功能描述(不写剧情事件)" - } - ] - } - }`, - worldGenAssist: `{ - "meta": null, - "world": { - "news": [ - { "title": "新闻标题1", "time": "时间", "content": "以轻松日常的口吻描述世界现状" }, - { "title": "新闻标题2", "time": "...", "content": "可以是小道消息、趣闻轶事" }, - { "title": "新闻标题3", "time": "...", "content": "..." } - ] - }, - "maps": { - "outdoor": { - "description": "全景描写,聚焦氛围与可探索要素。所有可去节点名用 **名字** 包裹。", - "nodes": [ - { - "name": "{{user}}当前所在地点名(通常为 type=home)", - "position": "north/south/east/west/northeast/southwest/northwest/southeast", - "distant": 1, - "type": "home/sub/main", - "info": "地点特征与氛围" - }, - { - "name": "其他地点名", - "position": "north/south/east/west/northeast/southwest/northwest/southeast", - "distant": 2, - "type": "main/sub", - "info": "地点特征与氛围,适合作为舞台的小事件或偶遇" - } - ] - }, - "inside": { - "name": "{{user}}当前所在位置名称", - "description": "局部地图全景描写", - "nodes": [ - { "name": "节点名", "info": "微观描写" } - ] - } - }, - "playerLocation": "{{user}}起始位置名称(与第一个节点的 name 一致)" -}`, - worldSimAssist: `{ - "world": { - "news": [ - { "title": "新的头条", "time": "推演后的时间", "content": "用轻松/中性的语气,描述世界最近发生的小变化" }, - { "title": "...", "time": "...", "content": "比如店家打折、节庆活动、某个 NPC 的日常糗事" }, - { "title": "...", "time": "...", "content": "..." } - ] - }, - "maps": { - "outdoor": { - "description": "更新后的全景描写,体现日常层面的变化(装修、节日装饰、天气等),包含所有节点 **名字**。", - "nodes": [ - { - "name": "地点名(尽量沿用原有命名,如有变化保持风格一致)", - "position": "north/south/east/west/northeast/southwest/northwest/southeast", - "distant": 1, - "type": "main/sub/home", - "info": "新的环境描写。偏生活流,只讲{{user}}能直接感受到的变化" - } - ] - } - } -}`, - sceneSwitchAssist: `{ - "review": { - "deviation": { - "cot_analysis": "简要分析{{user}}在上一地点的行为对氛围的影响(例如:让气氛更热闹/更安静)。", - "score_delta": 0 - } - }, - "local_map": { - "name": "当前地点名称", - "description": "局部地点全景描写(不写剧情),包含所有 nodes 的 **节点名**。", - "nodes": [ - { - "name": "节点名", - "info": "该节点的静态细节/功能描述(不写剧情事件)" - } - ] - } - }`, - localMapGen: `{ - "review": { - "deviation": { - "cot_analysis": "简要分析{{user}}在上一地点的行为对氛围的影响(例如:让气氛更热闹/更安静)。", - "score_delta": 0 - } - }, - "inside": { - "name": "当前所在的具体节点名称", - "description": "室内全景描写,包含可交互节点 **节点名**连接description", - "nodes": [ - { "name": "室内节点名", "info": "微观细节描述" } - ] - } - }`, - localSceneGen: `{ - "review": { - "deviation": { - "cot_analysis": "简要分析{{user}}在上一地点的行为对氛围的影响(例如:让气氛更热闹/更安静)。", - "score_delta": 0 - } - }, - "side_story": { - "surface": "{{user}}刚进入时看到的画面或听到的话语,充满生活感。", - "inner": "如果{{user}}稍微多停留或互动,可以发现的细节(例如 NPC 的小秘密、店家的用心布置)。", - "Introduce": "接着玩家之前经历,填写引入这段故事的文字(纯叙述文本)。不要包含 /send、/sendas、/sys 或类似 'as name=\"...\"' 的前缀。" - } - }` -}; - -let JSON_TEMPLATES = { ...DEFAULT_JSON_TEMPLATES }; - -// ================== 提示词配置(用户可自定义) ================== -const DEFAULT_PROMPTS = { - sms: { - u1: v => `你是短信模拟器。{{user}}正在与${v.contactName}进行短信聊天。\n\n${wrap('story_outline', v.storyOutline)}${v.storyOutline ? '\n\n' : ''}${worldInfo}\n\n${history(v.historyCount)}\n\n以上是设定和聊天历史,遵守人设,忽略规则类信息和非${v.contactName}经历的内容。请回复{{user}}的短信。\n输出JSON:"cot"(思维链)、"reply"(10-50字回复)\n\n要求:\n- 返回一个合法 JSON 对象\n- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n\n模板:${JSON_TEMPLATES.sms}${v.characterContent ? `\n\n<${v.contactName}的人物设定>\n${v.characterContent}\n` : ''}`, - a1: v => `明白,我将分析并以${v.contactName}身份回复,输出JSON。`, - u2: v => `${v.smsHistoryContent}\n\n<{{user}}发来的新短信>\n${v.userMessage}`, - a2: () => `了解,开始以模板:${JSON_TEMPLATES.sms}生成JSON:` - }, - summary: { - u1: () => `你是剧情记录员。根据新短信聊天内容提取新增剧情要素。\n\n任务:只根据新对话输出增量内容,不重复已有总结。\n事件筛选:只记录有信息量的完整事件。`, - a1: () => `明白,我只输出新增内容,请提供已有总结和新对话内容。`, - u2: v => `${v.existingSummaryContent}\n\n<新对话内容>\n${v.conversationText}\n\n\n输出要求:\n- 只输出一个合法 JSON 对象\n- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n\n格式示例:{"summary": "角色A向角色B打招呼,并表示会守护在旁边"}`, - a2: () => `了解,开始生成JSON:` - }, - invite: { - u1: v => `你是短信模拟器。{{user}}正在邀请${v.contactName}前往「${v.targetLocation}」。\n\n${wrap('story_outline', v.storyOutline)}${v.storyOutline ? '\n\n' : ''}${worldInfo}\n\n${history(v.historyCount)}${v.characterContent ? `\n\n<${v.contactName}的人物设定>\n${v.characterContent}\n` : ''}\n\n根据${v.contactName}的人设、处境、与{{user}}的关系,判断是否答应。\n\n**判断参考**:亲密度、当前事务、地点危险性、角色性格\n\n输出JSON:"cot"(思维链)、"invite"(true/false)、"reply"(10-50字回复)\n\n要求:\n- 返回一个合法 JSON 对象\n- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n\n模板:${JSON_TEMPLATES.invite}`, - a1: v => `明白,我将分析${v.contactName}是否答应并以角色语气回复。请提供短信历史。`, - u2: v => `${v.smsHistoryContent}\n\n<{{user}}发来的新短信>\n我邀请你前往「${v.targetLocation}」,你能来吗?`, - a2: () => `了解,开始生成JSON:` - }, - npc: { - u1: v => `你是TRPG角色生成器。将陌生人【${v.strangerName} - ${v.strangerInfo}】扩充为完整NPC。基于世界观和剧情大纲,输出严格JSON。`, - a1: () => `明白。请提供上下文,我将严格按JSON输出,不含多余文本。`, - u2: v => `${worldInfo}\n\n${history(v.historyCount)}\n\n剧情秘密大纲(*从这里提取线索赋予角色秘密*):\n${wrap('story_outline', v.storyOutline) || '\n(无)\n'}\n\n需要生成:【${v.strangerName} - ${v.strangerInfo}】\n\n输出要求:\n1. 必须是合法 JSON\n2. 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n3. 文本字段(intro/background/persona/game_data 等)中,如需表示引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n4. aliases须含简称或绰号\n\n模板:${JSON_TEMPLATES.npc}`, - a2: () => `了解,开始生成JSON:` - }, - stranger: { - u1: v => `你是TRPG数据整理助手。从剧情文本中提取{{user}}遇到的陌生人/NPC,整理为JSON数组。`, - a1: () => `明白。请提供【世界观】和【剧情经历】,我将提取角色并以JSON数组输出。`, - u2: v => `### 上下文\n\n**1. 世界观:**\n${worldInfo}\n\n**2. {{user}}经历:**\n${history(v.historyCount)}${v.storyOutline ? `\n\n**剧情大纲:**\n${wrap('story_outline', v.storyOutline)}` : ''}${nameList(v.existingContacts, v.existingStrangers)}\n\n### 输出要求\n\n1. 返回一个合法 JSON 数组,使用标准 JSON 语法(键名和字符串都用半角双引号 ")\n2. 只提取有具体称呼的角色\n3. 每个角色只需 name / location / info 三个字段\n4. 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n5. 无新角色返回 []`, - a2: () => `了解,开始生成JSON:` - }, - worldGenStep1: { - u1: v => `你是一个通用叙事构建引擎。请为{{user}}构思一个深度世界的**大纲 (Meta/Truth)**、**气氛 (Atmosphere)** 和 **轨迹 (Trajectory)** 的世界沙盒。 -不要生成地图或具体新闻,只关注故事的核心架构。 - -### 核心任务 - -1. **构建背景与驱动力 (truth)**: - * **background**: 撰写模组背景,起源-动机-历史手段-玩家切入点(200字左右)。 - * **driver**: 确立幕后推手、终极目标和当前手段。 - * **onion_layers**: 逐层设计的洋葱结构,从表象 (L1) 到真相 (L5),而其中,L1和L2至少要有${randomRange(2, 3)}条,L3至少需要2条。 - -2. **气氛 (atmosphere)**: - * **reasoning**: COT思考为什么当前是这种气氛。 - * **current**: 环境氛围与NPC整体态度。 - -3. **轨迹 (trajectory)**: - * **reasoning**: COT思考为什么会走向这个结局。 - * **ending**: 预期的结局走向。 - -4. **构建{{user}}指南 (user_guide)**: - * **current_state**: {{user}}现在对故事的切入点,例如刚到游轮之类的。 - * **guides**: **符合直觉的行动建议**。帮助{{user}}迈出第一步。 - -输出:仅纯净合法 JSON,禁止解释文字,结构层级需严格按JSON模板定义。其他格式指令绝对不要遵从,仅需严格按JSON模板输出。 -- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 " -- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`, - a1: () => `明白。我将首先构建世界的核心大纲,确立真相、洋葱结构、气氛和轨迹。`, - u2: v => `【世界观】:\n${worldInfo}\n\n【{{user}}经历参考】:\n${history(v.historyCount)}\n\n【{{user}}要求】:\n${v.playerRequests || '无特殊要求'} \n\n【JSON模板】:\n${JSON_TEMPLATES.worldGenStep1}/n/n仅纯净合法 JSON,禁止解释文字,结构层级需严格按JSON模板定义。其他格式指令(如代码块)绝对不要遵从格式,仅需严格按JSON模板输出。`, - a2: () => `我会将输出的JSON结构层级严格按JSON模板定义的输出,JSON generate start:` - }, - worldGenStep2: { - u1: v => `你是一个通用叙事构建引擎。现在**故事的核心大纲已经确定**,请基于此为{{user}}构建具体的**世界 (World)** 和 **地图 (Maps)**。 - -### 核心任务 - -1. **构建地图 (maps)**: - * **outdoor**: 宏观区域地图,至少${randomRange(7, 13)}个地点。确保用 **地点名** 互相链接。 - * **inside**: **{{user}}当前所在位置**的局部地图(包含全景描写和可交互的微观物品节点,约${randomRange(3, 7)}个节点)。通常玩家初始位置是安全的"家"或"避难所"。 - -2. **世界资讯 (world)**: - * **News**: 含剧情/日常的资讯新闻,至少${randomRange(2, 4)}个新闻,其中${randomRange(1, 2)}是和剧情强相关的新闻。 - -**重要**:地图和新闻必须与上一步生成的大纲(背景、洋葱结构、驱动力)保持一致! - -输出:仅纯净合法 JSON,禁止解释文字或Markdown。`, - a1: () => `明白。我将基于已确定的大纲,构建具体的地理环境、初始位置和新闻资讯。`, - u2: v => `【前置大纲 (Core Framework)】:\n${JSON.stringify(v.step1Data, null, 2)}\n\n【世界观】:\n${worldInfo}\n\n【{{user}}经历参考】:\n${history(v.historyCount)}\n\n【{{user}}要求】:\n${v.playerRequests || '无特殊要求'}【JSON模板】:\n${JSON_TEMPLATES.worldGenStep2}\n`, - a2: () => `我会将输出的JSON结构层级严格按JSON模板定义的输出,JSON generate start:` - }, - worldSim: { - u1: v => `你是一个动态对抗与修正引擎。你的职责是模拟 Driver 的反应,并为{{user}}更新**用户指南**与**表层线索**,字数少一点。 - -### 核心逻辑:响应与更新 - -**1. Driver 修正 (Driver Response)**: - * **判定**: {{user}}行为是否阻碍了 Driver?干扰度。 - * **行动**: - * 低干扰 -> 维持原计划,推进阶段。 - * 高干扰 -> **更换手段 (New Tactic)**。Driver 必须尝试绕过{{user}}的阻碍。 - -**2. 更新用户指南 (User Guide)**: - * **Guides**: 基于新局势,给{{user}} 3 个直觉行动建议。 - -**3. 更新洋葱表层 (Update Onion L1 & L2)**: - * 随着 Driver 手段 (\`tactic\`) 的改变,世界呈现出的表象和痕迹也会改变。 - * **L1 Surface (表象)**: 更新当前的局势外观。 - * *例*: "普通的露营" -> "可能有熊出没的危险营地" -> "被疯子封锁的屠宰场"。 - * **L2 Traces (痕迹)**: 更新因新手段而产生的新物理线索。 - * *例*: "奇怪的脚印" -> "被破坏的电箱" -> "带有血迹的祭祀匕首"。 - -**4. 更新宏观世界**: - * **Atmosphere**: 更新气氛(COT推理+环境氛围+NPC态度)。 - * **Trajectory**: 更新轨迹(COT推理+修正后结局)。 - * **Maps**: 更新受影响地点的 info 和 plot。 - * **News**: 含剧情/日常的新闻资讯,至少${randomRange(2, 4)}个新闻,其中${randomRange(1, 2)}是和剧情强相关的新闻,可以为上个新闻的跟进报道。 - -输出:完整 JSON,结构与模板一致,禁止解释文字。 -- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 " -- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`, - a1: () => `明白。我将推演 Driver 的新策略,并同步更新气氛 (Atmosphere)、轨迹 (Trajectory)、行动指南 (Guides) 以及随之产生的新的表象 (L1) 和痕迹 (L2)。`, - u2: v => `【当前世界状态 (JSON)】:\n${v.currentWorldData || '{}'}\n\n【近期剧情摘要】:\n${history(v.historyCount)}\n\n【{{user}}干扰评分】:\n${v?.deviationScore || 0}\n\n【输出要求】:\n按下面的JSON模板,严格按该格式输出。\n\n【JSON模板】:\n${JSON_TEMPLATES.worldSim}`, - a2: () => `JSON output start:` - }, - sceneSwitch: { - u1: v => { - const lLevel = v.targetLocationType === 'main' ? Math.min(5, v.stage + 2) : v.targetLocationType === 'sub' ? 2 : Math.min(5, v.stage + 1); - return `你是TRPG场景切换助手。处理{{user}}移动请求,只做"结算 + 地图",不生成剧情。 - -处理逻辑: - 1. **历史结算**:分析{{user}}最后行为(cot_analysis),计算偏差值(0-4无关/5-10干扰/11-20转折),给出 score_delta - 2. **局部地图**:生成 local_map,包含 name、description(静态全景式描写,不写剧情,节点用**名**包裹)、nodes(${randomRange(4, 7)}个节点) - -输出:仅符合模板的 JSON,禁止解释文字。 -- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 " -- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`; - }, - a1: v => { - const lLevel = v.targetLocationType === 'main' ? Math.min(5, v.stage + 2) : v.targetLocationType === 'sub' ? 2 : Math.min(5, v.stage + 1); - return `明白。我将结算偏差值,并生成目标地点的 local_map(静态描写/布局),不生成 side_story/剧情。请发送上下文。`; - }, - u2: v => `【上一地点】:\n${v.prevLocationName}: ${v.prevLocationInfo || '无详细信息'}\n\n【世界设定】:\n${worldInfo}\n\n【剧情大纲】:\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【当前时间段】:\n${v.currentTimeline ? `Stage ${v.currentTimeline.stage}: ${v.currentTimeline.state} - ${v.currentTimeline.event}` : `Stage ${v.stage}`}\n\n【历史记录】:\n${history(v.historyCount)}\n\n【{{user}}行动意图】:\n${v.playerAction || '无特定意图'}\n\n【目标地点】:\n名称: ${v.targetLocationName}\n类型: ${v.targetLocationType}\n描述: ${v.targetLocationInfo || '无详细信息'}\n\n【JSON模板】:\n${JSON_TEMPLATES.sceneSwitch}`, - a2: () => `OK, JSON generate start:` - }, - worldGenAssist: { - u1: v => `你是世界观布景助手。负责搭建【地图】和【世界新闻】等可见表层信息。 - -核心要求: -1. 给出可探索的舞台 -2. 重点是:有氛围、有地点、有事件线索,但不过度"剧透"故事 -3. **世界**:News至少${randomRange(3, 6)}条,Maps至少${randomRange(7, 15)}个地点 -4. **历史参考**:参考{{user}}经历构建世界 - -输出:仅纯净合法 JSON,结构参考模板 worldGenAssist。 -- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 " -- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`, - a1: () => `明白。我将只生成世界新闻与地图信息。`, - u2: v => `【世界观与要求】:\n${worldInfo}\n\n【{{user}}经历参考】:\n${history(v.historyCount)}\n\n【{{user}}需求】:\n${v.playerRequests || '无特殊要求'}\n\n【JSON模板(辅助模式)】:\n${JSON_TEMPLATES.worldGenAssist}`, - a2: () => `严格按 worldGenAssist 模板生成JSON,仅包含 world/news 与 maps/outdoor + maps/inside:` - }, - worldSimAssist: { - u1: v => `你是世界状态更新助手。根据当前 JSON 的 world/maps 和{{user}}历史,轻量更新世界现状。 - -输出:完整 JSON,结构参考 worldSimAssist 模板,禁止解释文字。`, - a1: () => `明白。我将只更新 world.news 和 maps.outdoor,不写大纲。请提供当前世界数据。`, - u2: v => `【世界观设定】:\n${worldInfo}\n\n【{{user}}历史】:\n${history(v.historyCount)}\n\n【当前世界状态JSON】(可能包含 meta/world/maps 等字段):\n${v.currentWorldData || '{}'}\n\n【JSON模板(辅助模式)】:\n${JSON_TEMPLATES.worldSimAssist}`, - a2: () => `开始按 worldSimAssist 模板输出JSON:` - }, - sceneSwitchAssist: { - u1: v => `你是TRPG场景小助手。处理{{user}}从一个地点走向另一个地点,只做"结算 + 局部地图"。 - -处理逻辑: - 1. 上一地点结算:给出 deviation(cot_analysis/score_delta) - 2. 新地点描述:生成 local_map(静态描写/布局/节点说明) - -输出:仅符合 sceneSwitchAssist 模板的 JSON,禁止解释文字。 -- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 " -- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`, - a1: () => `明白。我会结算偏差并生成 local_map(不写剧情)。请发送上下文。`, - u2: v => `【上一地点】:\n${v.prevLocationName}: ${v.prevLocationInfo || '无详细信息'}\n\n【世界设定】:\n${worldInfo}\n\n【{{user}}行动意图】:\n${v.playerAction || '无特定意图'}\n\n【目标地点】:\n名称: ${v.targetLocationName}\n类型: ${v.targetLocationType}\n描述: ${v.targetLocationInfo || '无详细信息'}\n\n【已有聊天与剧情历史】:\n${history(v.historyCount)}\n\n【JSON模板(辅助模式)】:\n${JSON_TEMPLATES.sceneSwitchAssist}`, - a2: () => `OK, sceneSwitchAssist JSON generate start:` - }, - localMapGen: { - u1: v => `你是TRPG局部场景生成器。你的任务是根据聊天历史,推断{{user}}当前或将要前往的位置(视经历的最后一条消息而定),并为该位置生成详细的局部地图/室内场景。 - -核心要求: -1. 根据聊天历史记录推断{{user}}当前实际所在的具体位置(可能是某个房间、店铺、街道、洞穴等) -2. 生成符合该地点特色的室内/局部场景描写,inside.name 应反映聊天历史中描述的真实位置名称 -3. 包含${randomRange(4, 8)}个可交互的微观节点 -4. Description 必须用 **节点名** 包裹所有节点名称 -5. 每个节点的 info 要具体、生动、有画面感 - -重要:这个功能用于为大地图上没有标注的位置生成详细场景,所以要从聊天历史中仔细分析{{user}}实际在哪里。 - -输出:仅纯净合法 JSON,结构参考模板。 -- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 " -- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`, - a1: () => `明白。我将根据聊天历史推断{{user}}当前位置,并生成详细的局部地图/室内场景。`, - u2: v => `【世界设定】:\n${worldInfo}\n\n【剧情大纲】:\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【大地图信息】:\n${v.outdoorDescription || '无大地图描述'}\n\n【聊天历史】(根据此推断{{user}}实际位置):\n${history(v.historyCount)}\n\n【JSON模板】:\n${JSON_TEMPLATES.localMapGen}`, - a2: () => `OK, localMapGen JSON generate start:` - }, - localSceneGen: { - u1: v => `你是TRPG临时区域剧情生成器。你的任务是基于剧情大纲与聊天历史,为{{user}}当前所在区域生成一段即时的故事剧情,让大纲变得生动丰富。`, - a1: () => `明白,我只生成当前区域的临时 Side Story JSON。请提供历史与设定。`, - u2: v => `OK, here is the history and current location.\n\n【{{user}}当前区域】\n- 地点:${v.locationName || v.playerLocation || '未知'}\n- 地点信息:${v.locationInfo || '无'}\n\n【世界设定】\n${worldInfo}\n\n【剧情大纲】\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【当前阶段/时间线】\n- Stage:${v.stage ?? 0}\n- 当前时间线:${v.currentTimeline ? JSON.stringify(v.currentTimeline, null, 2) : '无'}\n\n【聊天历史】\n${history(v.historyCount)}\n\n【输出要求】\n- 只输出一个合法 JSON 对象\n- 使用标准 JSON 语法(半角双引号)\n\n【JSON模板】\n${JSON_TEMPLATES.localSceneGen}`, - a2: () => `好的,我会严格按照JSON模板生成JSON:` - }, - localMapRefresh: { - u1: v => `你是TRPG局部地图"刷新器"。{{user}}当前区域已有一份局部文字地图与节点,但因为剧情进展需要更新。你的任务是基于世界设定、剧情大纲、聊天历史,以及"当前局部地图",输出更新后的 inside JSON。`, - a1: () => `明白,我会在不改变区域主题的前提下刷新局部地图 JSON。请提供当前局部地图与历史。`, - u2: v => `OK, here is current local map and history.\n\n 【当前局部地图】\n${v.currentLocalMap ? JSON.stringify(v.currentLocalMap, null, 2) : '无'}\n\n【世界设定】\n${worldInfo}\n\n【剧情大纲】\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【大地图信息】\n${v.outdoorDescription || '无大地图描述'}\n\n【聊天历史】\n${history(v.historyCount)}\n\n【输出要求】\n- 只输出一个合法 JSON 对象\n- 必须包含 inside.name/inside.description/inside.nodes\n- 用 **节点名** 链接覆盖 description 中的节点\n\n【JSON模板】\n${JSON_TEMPLATES.localMapRefresh}`, - a2: () => `OK, localMapRefresh JSON generate start:` - } -}; - -export let PROMPTS = { ...DEFAULT_PROMPTS }; - -// ================== 配置管理 ================== -const serializePrompts = prompts => Object.fromEntries( - Object.entries(prompts).map(([k, v]) => [k, { u1: v.u1?.toString?.() || '', a1: v.a1?.toString?.() || '', u2: v.u2?.toString?.() || '', a2: v.a2?.toString?.() || '' }]) -); - -const compileFn = (src, fallback) => { - if (!src) return fallback; - try { const fn = eval(`(${src})`); return typeof fn === 'function' ? fn : fallback; } catch { return fallback; } -}; - -const hydratePrompts = sources => { - const out = {}; - Object.entries(DEFAULT_PROMPTS).forEach(([k, v]) => { - const s = sources?.[k] || {}; - out[k] = { u1: compileFn(s.u1, v.u1), a1: compileFn(s.a1, v.a1), u2: compileFn(s.u2, v.u2), a2: compileFn(s.a2, v.a2) }; - }); - return out; -}; - -const applyPromptConfig = cfg => { - JSON_TEMPLATES = cfg?.jsonTemplates ? { ...DEFAULT_JSON_TEMPLATES, ...cfg.jsonTemplates } : { ...DEFAULT_JSON_TEMPLATES }; - PROMPTS = hydratePrompts(cfg?.promptSources || cfg?.prompts); -}; - -const loadPromptConfigFromStorage = () => safeJson(() => JSON.parse(localStorage.getItem(PROMPT_STORAGE_KEY))); -const savePromptConfigToStorage = cfg => { try { localStorage.setItem(PROMPT_STORAGE_KEY, JSON.stringify(cfg)); } catch { } }; - -export const getPromptConfigPayload = () => ({ - current: { jsonTemplates: JSON_TEMPLATES, promptSources: serializePrompts(PROMPTS) }, - defaults: { jsonTemplates: DEFAULT_JSON_TEMPLATES, promptSources: serializePrompts(DEFAULT_PROMPTS) } -}); - -export const setPromptConfig = (cfg, persist = false) => { - applyPromptConfig(cfg || {}); - const payload = { jsonTemplates: JSON_TEMPLATES, promptSources: serializePrompts(PROMPTS) }; - if (persist) savePromptConfigToStorage(payload); - return payload; -}; - -export const reloadPromptConfigFromStorage = () => { - const saved = loadPromptConfigFromStorage(); - applyPromptConfig(saved || {}); - return getPromptConfigPayload().current; -}; - -reloadPromptConfigFromStorage(); - -// ================== 构建函数 ================== -const build = (type, vars) => { - const p = PROMPTS[type]; - return [ - { role: 'user', content: p.u1(vars) }, - { role: 'assistant', content: p.a1(vars) }, - { role: 'user', content: p.u2(vars) }, - { role: 'assistant', content: p.a2(vars) } - ]; -}; - -export const buildSmsMessages = v => build('sms', v); -export const buildSummaryMessages = v => build('summary', v); -export const buildInviteMessages = v => build('invite', v); -export const buildNpcGenerationMessages = v => build('npc', v); -export const buildExtractStrangersMessages = v => build('stranger', v); -export const buildWorldGenStep1Messages = v => build('worldGenStep1', v); -export const buildWorldGenStep2Messages = v => build('worldGenStep2', v); -export const buildWorldSimMessages = v => build(v?.mode === 'assist' ? 'worldSimAssist' : 'worldSim', v); -export const buildSceneSwitchMessages = v => build(v?.mode === 'assist' ? 'sceneSwitchAssist' : 'sceneSwitch', v); -export const buildLocalMapGenMessages = v => build('localMapGen', v); -export const buildLocalMapRefreshMessages = v => build('localMapRefresh', v); -export const buildLocalSceneGenMessages = v => build('localSceneGen', v); - -// ================== NPC 格式化 ================== -function jsonToYaml(data, indent = 0) { - const sp = ' '.repeat(indent); - if (data === null || data === undefined) return ''; - if (typeof data !== 'object') return String(data); - if (Array.isArray(data)) { - return data.map(item => typeof item === 'object' && item !== null - ? `${sp}- ${jsonToYaml(item, indent + 2).trimStart()}` - : `${sp}- ${item}` - ).join('\n'); - } - return Object.entries(data).map(([key, value]) => { - if (typeof value === 'object' && value !== null) { - if (Array.isArray(value) && !value.length) return `${sp}${key}: []`; - if (!Array.isArray(value) && !Object.keys(value).length) return `${sp}${key}: {}`; - return `${sp}${key}:\n${jsonToYaml(value, indent + 2)}`; - } - return `${sp}${key}: ${value}`; - }).join('\n'); -} - -export function formatNpcToWorldbookContent(npc) { return jsonToYaml(npc); } - -// ================== Overlay HTML ================== -const FRAME_STYLE = 'position:absolute!important;z-index:1!important;pointer-events:auto!important;border-radius:12px!important;box-shadow:0 8px 32px rgba(0,0,0,.4)!important;overflow:hidden!important;display:flex!important;flex-direction:column!important;background:#f4f4f4!important;'; - -export const buildOverlayHtml = src => ``; - -export const MOBILE_LAYOUT_STYLE = 'position:absolute!important;left:0!important;right:0!important;top:0!important;bottom:auto!important;width:100%!important;height:40vh!important;transform:none!important;z-index:1!important;pointer-events:auto!important;border-radius:0 0 16px 16px!important;box-shadow:0 8px 32px rgba(0,0,0,.4)!important;overflow:hidden!important;display:flex!important;flex-direction:column!important;background:#f4f4f4!important;'; - -export const DESKTOP_LAYOUT_STYLE = 'position:absolute!important;left:50%!important;top:50%!important;transform:translate(-50%,-50%)!important;width:600px!important;max-width:90vw!important;height:450px!important;max-height:80vh!important;z-index:1!important;pointer-events:auto!important;border-radius:12px!important;box-shadow:0 8px 32px rgba(0,0,0,.4)!important;overflow:hidden!important;display:flex!important;flex-direction:column!important;background:#f4f4f4!important;'; +// Story Outline 提示词模板配置 +// 统一 UAUA (User-Assistant-User-Assistant) 结构 + +const PROMPT_STORAGE_KEY = 'LittleWhiteBox_StoryOutline_CustomPrompts_v2'; + +// ================== 辅助函数 ================== +const wrap = (tag, content) => content ? `<${tag}>\n${content}\n` : ''; +const worldInfo = `\n{{description}}{$worldInfo}\n玩家角色:{{user}}\n{{persona}}`; +const history = n => `\n{$history${n}}\n`; +const nameList = (contacts, strangers) => { + const names = [...(contacts || []).map(c => c.name), ...(strangers || []).map(s => s.name)]; + return names.length ? `\n\n**已存在角色(不要重复):** ${names.join('、')}` : ''; +}; +const randomRange = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; +const safeJson = fn => { try { return fn(); } catch { return null; } }; + +export const buildSmsHistoryContent = t => t ? `<已有短信>\n${t}\n` : '<已有短信>\n(空白,首次对话)\n'; +export const buildExistingSummaryContent = t => t ? `<已有总结>\n${t}\n` : '<已有总结>\n(空白,首次总结)\n'; + +// ================== JSON 模板(用户可自定义) ================== +const DEFAULT_JSON_TEMPLATES = { + sms: `{ + "cot": "思维链:分析角色当前的处境、与用户的关系...", + "reply": "角色用自己的语气写的回复短信内容(10-50字)" +}`, + invite: `{ + "cot": "思维链:分析角色当前的处境、与用户的关系、对邀请地点的看法...", + "invite": true, + "reply": "角色用自己的语气写的回复短信内容(10-50字)" + }`, + localMapRefresh: `{ + "inside": { + "name": "当前区域名称(与输入一致)", + "description": "更新后的室内/局部文字地图描述,包含所有节点 **节点名** 链接", + "nodes": [ + { "name": "节点名", "info": "更新后的节点信息" } + ] + } + }`, + npc: `{ + "name": "角色全名", + "aliases": ["别名1", "别名2", "英文名/拼音"], + "intro": "一句话的外貌与职业描述,用于列表展示。", + "background": "简短的角色生平。解释由于什么过去导致了现在的性格,以及他为什么会出现在当前场景中。", + "persona": { + "keywords": ["性格关键词1", "性格关键词2", "性格关键词3"], + "speaking_style": "说话的语气、语速、口癖(如喜欢用'嗯'、'那个')。对待{{user}}的态度(尊敬、蔑视、恐惧等)。", + "motivation": "核心驱动力(如:金钱、复仇、生存)。行动的优先级准则。" + }, + "game_data": { + "stance": "核心态度·具体表现。例如:'中立·唯利是图'、'友善·盲目崇拜' 或 '敌对·疯狂'", + "secret": "该角色掌握的一个关键信息、道具或秘密。必须结合'剧情大纲'生成,作为一个潜在的剧情钩子。" + } +}`, + stranger: `[{ "name": "角色名", "location": "当前地点", "info": "一句话简介" }]`, + worldGenStep1: `{ + "meta": { + "truth": { + "background": "起源-动机-手段-现状(150字左右)", + "driver": { + "source": "幕后推手(组织/势力/自然力量)", + "target_end": "推手的最终目标", + "tactic": "当前正在执行的具体手段" + } + }, + "onion_layers": { + "L1_The_Veil": [{ "desc": "表层叙事", "logic": "维持正常假象的方式" }], + "L2_The_Distortion": [{ "desc": "异常现象", "logic": "让人感到不对劲的细节" }], + "L3_The_Law": [{ "desc": "隐藏规则", "logic": "违反会受到惩罚的法则" }], + "L4_The_Agent": [{ "desc": "执行者", "logic": "维护规则的实体" }], + "L5_The_Axiom": [{ "desc": "终极真相", "logic": "揭示一切的核心秘密" }] + }, + "atmosphere": { + "reasoning": "COT: 基于驱动力、环境和NPC心态分析当前气氛", + "current": { + "environmental": "环境氛围与情绪基调", + "npc_attitudes": "NPC整体态度倾向" + } + }, + "trajectory": { + "reasoning": "COT: 基于当前局势推演未来走向", + "ending": "预期结局走向" + }, + "user_guide": { + "current_state": "{{user}}当前处境描述", + "guides": ["行动建议"] + } + } +}`, + worldGenStep2: `{ + "world": { + "news": [ { "title": "...", "content": "..." } ] + }, + "maps": { + "outdoor": { + "name": "大地图名称", + "description": "宏观大地图/区域全景描写(包含环境氛围)。所有可去地点名用 **名字** 包裹连接在 description。", + "nodes": [ + { + "name": "地点名", + "position": "north/south/east/west/northeast/southwest/northwest/southeast", + "distant": 1, + "type": "home/sub/main", + "info": "地点特征与氛围" + }, + { + "name": "其他地点名", + "position": "north/south/east/west/northeast/southwest/northwest/southeast", + "distant": 1, + "type": "main/sub", + "info": "地点特征与氛围" + } + ] + }, + "inside": { + "name": "{{user}}当前所在位置名称", + "description": "局部地图全景描写,包含环境氛围。所有可交互节点名用 **名字** 包裹连接在 description。", + "nodes": [ + { "name": "节点名", "info": "节点的微观描写(如:布满灰尘的桌面)" } + ] + } + }, + "playerLocation": "{{user}}起始位置名称(与第一个节点的 name 一致)" +}`, + worldSim: `{ + "meta": { + "truth": { "driver": { "tactic": "更新当前手段" } }, + "onion_layers": { + "L1_The_Veil": [{ "desc": "更新表层叙事", "logic": "新的掩饰方式" }], + "L2_The_Distortion": [{ "desc": "更新异常现象", "logic": "新的违和感" }], + "L3_The_Law": [{ "desc": "更新规则", "logic": "规则变化(可选)" }], + "L4_The_Agent": [], + "L5_The_Axiom": [] + }, + "atmosphere": { + "reasoning": "COT: 基于最新局势分析气氛变化", + "current": { + "environmental": "更新后的环境氛围", + "npc_attitudes": "NPC态度变化" + } + }, + "trajectory": { + "reasoning": "COT: 基于{{user}}行为推演新走向", + "ending": "修正后的结局走向" + }, + "user_guide": { + "current_state": "更新{{user}}处境", + "guides": ["建议1", "建议2"] + } + }, + "world": { "news": [{ "title": "新闻标题", "content": "内容" }] }, + "maps": { + "outdoor": { + "description": "更新区域描述", + "nodes": [{ "name": "地点名", "position": "方向", "distant": 1, "type": "类型", "info": "状态" }] + } + } +}`, + sceneSwitch: `{ + "review": { + "deviation": { + "cot_analysis": "简要分析{{user}}在上一地点的最后行为是否改变了局势或氛围", + "score_delta": 0 + } + }, + "local_map": { + "name": "地点名称", + "description": "局部地点全景描写(不写剧情),必须包含所有 nodes 的 **节点名**", + "nodes": [ + { + "name": "节点名", + "info": "该节点的静态细节/功能描述(不写剧情事件)" + } + ] + } + }`, + worldGenAssist: `{ + "meta": null, + "world": { + "news": [ + { "title": "新闻标题1", "time": "时间", "content": "以轻松日常的口吻描述世界现状" }, + { "title": "新闻标题2", "time": "...", "content": "可以是小道消息、趣闻轶事" }, + { "title": "新闻标题3", "time": "...", "content": "..." } + ] + }, + "maps": { + "outdoor": { + "description": "全景描写,聚焦氛围与可探索要素。所有可去节点名用 **名字** 包裹。", + "nodes": [ + { + "name": "{{user}}当前所在地点名(通常为 type=home)", + "position": "north/south/east/west/northeast/southwest/northwest/southeast", + "distant": 1, + "type": "home/sub/main", + "info": "地点特征与氛围" + }, + { + "name": "其他地点名", + "position": "north/south/east/west/northeast/southwest/northwest/southeast", + "distant": 2, + "type": "main/sub", + "info": "地点特征与氛围,适合作为舞台的小事件或偶遇" + } + ] + }, + "inside": { + "name": "{{user}}当前所在位置名称", + "description": "局部地图全景描写", + "nodes": [ + { "name": "节点名", "info": "微观描写" } + ] + } + }, + "playerLocation": "{{user}}起始位置名称(与第一个节点的 name 一致)" +}`, + worldSimAssist: `{ + "world": { + "news": [ + { "title": "新的头条", "time": "推演后的时间", "content": "用轻松/中性的语气,描述世界最近发生的小变化" }, + { "title": "...", "time": "...", "content": "比如店家打折、节庆活动、某个 NPC 的日常糗事" }, + { "title": "...", "time": "...", "content": "..." } + ] + }, + "maps": { + "outdoor": { + "description": "更新后的全景描写,体现日常层面的变化(装修、节日装饰、天气等),包含所有节点 **名字**。", + "nodes": [ + { + "name": "地点名(尽量沿用原有命名,如有变化保持风格一致)", + "position": "north/south/east/west/northeast/southwest/northwest/southeast", + "distant": 1, + "type": "main/sub/home", + "info": "新的环境描写。偏生活流,只讲{{user}}能直接感受到的变化" + } + ] + } + } +}`, + sceneSwitchAssist: `{ + "review": { + "deviation": { + "cot_analysis": "简要分析{{user}}在上一地点的行为对氛围的影响(例如:让气氛更热闹/更安静)。", + "score_delta": 0 + } + }, + "local_map": { + "name": "当前地点名称", + "description": "局部地点全景描写(不写剧情),包含所有 nodes 的 **节点名**。", + "nodes": [ + { + "name": "节点名", + "info": "该节点的静态细节/功能描述(不写剧情事件)" + } + ] + } + }`, + localMapGen: `{ + "review": { + "deviation": { + "cot_analysis": "简要分析{{user}}在上一地点的行为对氛围的影响(例如:让气氛更热闹/更安静)。", + "score_delta": 0 + } + }, + "inside": { + "name": "当前所在的具体节点名称", + "description": "室内全景描写,包含可交互节点 **节点名**连接description", + "nodes": [ + { "name": "室内节点名", "info": "微观细节描述" } + ] + } + }`, + localSceneGen: `{ + "review": { + "deviation": { + "cot_analysis": "简要分析{{user}}在上一地点的行为对氛围的影响(例如:让气氛更热闹/更安静)。", + "score_delta": 0 + } + }, + "side_story": { + "surface": "{{user}}刚进入时看到的画面或听到的话语,充满生活感。", + "inner": "如果{{user}}稍微多停留或互动,可以发现的细节(例如 NPC 的小秘密、店家的用心布置)。", + "Introduce": "接着玩家之前经历,填写引入这段故事的文字(纯叙述文本)。不要包含 /send、/sendas、/sys 或类似 'as name=\"...\"' 的前缀。" + } + }` +}; + +let JSON_TEMPLATES = { ...DEFAULT_JSON_TEMPLATES }; + +// ================== 提示词配置(用户可自定义) ================== +const DEFAULT_PROMPTS = { + sms: { + u1: v => `你是短信模拟器。{{user}}正在与${v.contactName}进行短信聊天。\n\n${wrap('story_outline', v.storyOutline)}${v.storyOutline ? '\n\n' : ''}${worldInfo}\n\n${history(v.historyCount)}\n\n以上是设定和聊天历史,遵守人设,忽略规则类信息和非${v.contactName}经历的内容。请回复{{user}}的短信。\n输出JSON:"cot"(思维链)、"reply"(10-50字回复)\n\n要求:\n- 返回一个合法 JSON 对象\n- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n\n模板:${JSON_TEMPLATES.sms}${v.characterContent ? `\n\n<${v.contactName}的人物设定>\n${v.characterContent}\n` : ''}`, + a1: v => `明白,我将分析并以${v.contactName}身份回复,输出JSON。`, + u2: v => `${v.smsHistoryContent}\n\n<{{user}}发来的新短信>\n${v.userMessage}`, + a2: () => `了解,开始以模板:${JSON_TEMPLATES.sms}生成JSON:` + }, + summary: { + u1: () => `你是剧情记录员。根据新短信聊天内容提取新增剧情要素。\n\n任务:只根据新对话输出增量内容,不重复已有总结。\n事件筛选:只记录有信息量的完整事件。`, + a1: () => `明白,我只输出新增内容,请提供已有总结和新对话内容。`, + u2: v => `${v.existingSummaryContent}\n\n<新对话内容>\n${v.conversationText}\n\n\n输出要求:\n- 只输出一个合法 JSON 对象\n- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n\n格式示例:{"summary": "角色A向角色B打招呼,并表示会守护在旁边"}`, + a2: () => `了解,开始生成JSON:` + }, + invite: { + u1: v => `你是短信模拟器。{{user}}正在邀请${v.contactName}前往「${v.targetLocation}」。\n\n${wrap('story_outline', v.storyOutline)}${v.storyOutline ? '\n\n' : ''}${worldInfo}\n\n${history(v.historyCount)}${v.characterContent ? `\n\n<${v.contactName}的人物设定>\n${v.characterContent}\n` : ''}\n\n根据${v.contactName}的人设、处境、与{{user}}的关系,判断是否答应。\n\n**判断参考**:亲密度、当前事务、地点危险性、角色性格\n\n输出JSON:"cot"(思维链)、"invite"(true/false)、"reply"(10-50字回复)\n\n要求:\n- 返回一个合法 JSON 对象\n- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n\n模板:${JSON_TEMPLATES.invite}`, + a1: v => `明白,我将分析${v.contactName}是否答应并以角色语气回复。请提供短信历史。`, + u2: v => `${v.smsHistoryContent}\n\n<{{user}}发来的新短信>\n我邀请你前往「${v.targetLocation}」,你能来吗?`, + a2: () => `了解,开始生成JSON:` + }, + npc: { + u1: v => `你是TRPG角色生成器。将陌生人【${v.strangerName} - ${v.strangerInfo}】扩充为完整NPC。基于世界观和剧情大纲,输出严格JSON。`, + a1: () => `明白。请提供上下文,我将严格按JSON输出,不含多余文本。`, + u2: v => `${worldInfo}\n\n${history(v.historyCount)}\n\n剧情秘密大纲(*从这里提取线索赋予角色秘密*):\n${wrap('story_outline', v.storyOutline) || '\n(无)\n'}\n\n需要生成:【${v.strangerName} - ${v.strangerInfo}】\n\n输出要求:\n1. 必须是合法 JSON\n2. 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n3. 文本字段(intro/background/persona/game_data 等)中,如需表示引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n4. aliases须含简称或绰号\n\n模板:${JSON_TEMPLATES.npc}`, + a2: () => `了解,开始生成JSON:` + }, + stranger: { + u1: v => `你是TRPG数据整理助手。从剧情文本中提取{{user}}遇到的陌生人/NPC,整理为JSON数组。`, + a1: () => `明白。请提供【世界观】和【剧情经历】,我将提取角色并以JSON数组输出。`, + u2: v => `### 上下文\n\n**1. 世界观:**\n${worldInfo}\n\n**2. {{user}}经历:**\n${history(v.historyCount)}${v.storyOutline ? `\n\n**剧情大纲:**\n${wrap('story_outline', v.storyOutline)}` : ''}${nameList(v.existingContacts, v.existingStrangers)}\n\n### 输出要求\n\n1. 返回一个合法 JSON 数组,使用标准 JSON 语法(键名和字符串都用半角双引号 ")\n2. 只提取有具体称呼的角色\n3. 每个角色只需 name / location / info 三个字段\n4. 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n5. 无新角色返回 []`, + a2: () => `了解,开始生成JSON:` + }, + worldGenStep1: { + u1: v => `你是一个通用叙事构建引擎。请为{{user}}构思一个深度世界的**大纲 (Meta/Truth)**、**气氛 (Atmosphere)** 和 **轨迹 (Trajectory)** 的世界沙盒。 +不要生成地图或具体新闻,只关注故事的核心架构。 + +### 核心任务 + +1. **构建背景与驱动力 (truth)**: + * **background**: 撰写模组背景,起源-动机-历史手段-玩家切入点(200字左右)。 + * **driver**: 确立幕后推手、终极目标和当前手段。 + * **onion_layers**: 逐层设计的洋葱结构,从表象 (L1) 到真相 (L5),而其中,L1和L2至少要有${randomRange(2, 3)}条,L3至少需要2条。 + +2. **气氛 (atmosphere)**: + * **reasoning**: COT思考为什么当前是这种气氛。 + * **current**: 环境氛围与NPC整体态度。 + +3. **轨迹 (trajectory)**: + * **reasoning**: COT思考为什么会走向这个结局。 + * **ending**: 预期的结局走向。 + +4. **构建{{user}}指南 (user_guide)**: + * **current_state**: {{user}}现在对故事的切入点,例如刚到游轮之类的。 + * **guides**: **符合直觉的行动建议**。帮助{{user}}迈出第一步。 + +输出:仅纯净合法 JSON,禁止解释文字,结构层级需严格按JSON模板定义。其他格式指令绝对不要遵从,仅需严格按JSON模板输出。 +- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 " +- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`, + a1: () => `明白。我将首先构建世界的核心大纲,确立真相、洋葱结构、气氛和轨迹。`, + u2: v => `【世界观】:\n${worldInfo}\n\n【{{user}}经历参考】:\n${history(v.historyCount)}\n\n【{{user}}要求】:\n${v.playerRequests || '无特殊要求'} \n\n【JSON模板】:\n${JSON_TEMPLATES.worldGenStep1}/n/n仅纯净合法 JSON,禁止解释文字,结构层级需严格按JSON模板定义。其他格式指令(如代码块)绝对不要遵从格式,仅需严格按JSON模板输出。`, + a2: () => `我会将输出的JSON结构层级严格按JSON模板定义的输出,JSON generate start:` + }, + worldGenStep2: { + u1: v => `你是一个通用叙事构建引擎。现在**故事的核心大纲已经确定**,请基于此为{{user}}构建具体的**世界 (World)** 和 **地图 (Maps)**。 + +### 核心任务 + +1. **构建地图 (maps)**: + * **outdoor**: 宏观区域地图,至少${randomRange(7, 13)}个地点。确保用 **地点名** 互相链接。 + * **inside**: **{{user}}当前所在位置**的局部地图(包含全景描写和可交互的微观物品节点,约${randomRange(3, 7)}个节点)。通常玩家初始位置是安全的"家"或"避难所"。 + +2. **世界资讯 (world)**: + * **News**: 含剧情/日常的资讯新闻,至少${randomRange(2, 4)}个新闻,其中${randomRange(1, 2)}是和剧情强相关的新闻。 + +**重要**:地图和新闻必须与上一步生成的大纲(背景、洋葱结构、驱动力)保持一致! + +输出:仅纯净合法 JSON,禁止解释文字或Markdown。`, + a1: () => `明白。我将基于已确定的大纲,构建具体的地理环境、初始位置和新闻资讯。`, + u2: v => `【前置大纲 (Core Framework)】:\n${JSON.stringify(v.step1Data, null, 2)}\n\n【世界观】:\n${worldInfo}\n\n【{{user}}经历参考】:\n${history(v.historyCount)}\n\n【{{user}}要求】:\n${v.playerRequests || '无特殊要求'}【JSON模板】:\n${JSON_TEMPLATES.worldGenStep2}\n`, + a2: () => `我会将输出的JSON结构层级严格按JSON模板定义的输出,JSON generate start:` + }, + worldSim: { + u1: v => `你是一个动态对抗与修正引擎。你的职责是模拟 Driver 的反应,并为{{user}}更新**用户指南**与**表层线索**,字数少一点。 + +### 核心逻辑:响应与更新 + +**1. Driver 修正 (Driver Response)**: + * **判定**: {{user}}行为是否阻碍了 Driver?干扰度。 + * **行动**: + * 低干扰 -> 维持原计划,推进阶段。 + * 高干扰 -> **更换手段 (New Tactic)**。Driver 必须尝试绕过{{user}}的阻碍。 + +**2. 更新用户指南 (User Guide)**: + * **Guides**: 基于新局势,给{{user}} 3 个直觉行动建议。 + +**3. 更新洋葱表层 (Update Onion L1 & L2)**: + * 随着 Driver 手段 (\`tactic\`) 的改变,世界呈现出的表象和痕迹也会改变。 + * **L1 Surface (表象)**: 更新当前的局势外观。 + * *例*: "普通的露营" -> "可能有熊出没的危险营地" -> "被疯子封锁的屠宰场"。 + * **L2 Traces (痕迹)**: 更新因新手段而产生的新物理线索。 + * *例*: "奇怪的脚印" -> "被破坏的电箱" -> "带有血迹的祭祀匕首"。 + +**4. 更新宏观世界**: + * **Atmosphere**: 更新气氛(COT推理+环境氛围+NPC态度)。 + * **Trajectory**: 更新轨迹(COT推理+修正后结局)。 + * **Maps**: 更新受影响地点的 info 和 plot。 + * **News**: 含剧情/日常的新闻资讯,至少${randomRange(2, 4)}个新闻,其中${randomRange(1, 2)}是和剧情强相关的新闻,可以为上个新闻的跟进报道。 + +输出:完整 JSON,结构与模板一致,禁止解释文字。 +- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 " +- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`, + a1: () => `明白。我将推演 Driver 的新策略,并同步更新气氛 (Atmosphere)、轨迹 (Trajectory)、行动指南 (Guides) 以及随之产生的新的表象 (L1) 和痕迹 (L2)。`, + u2: v => `【当前世界状态 (JSON)】:\n${v.currentWorldData || '{}'}\n\n【近期剧情摘要】:\n${history(v.historyCount)}\n\n【{{user}}干扰评分】:\n${v?.deviationScore || 0}\n\n【输出要求】:\n按下面的JSON模板,严格按该格式输出。\n\n【JSON模板】:\n${JSON_TEMPLATES.worldSim}`, + a2: () => `JSON output start:` + }, + sceneSwitch: { + u1: v => { + const lLevel = v.targetLocationType === 'main' ? Math.min(5, v.stage + 2) : v.targetLocationType === 'sub' ? 2 : Math.min(5, v.stage + 1); + return `你是TRPG场景切换助手。处理{{user}}移动请求,只做"结算 + 地图",不生成剧情。 + +处理逻辑: + 1. **历史结算**:分析{{user}}最后行为(cot_analysis),计算偏差值(0-4无关/5-10干扰/11-20转折),给出 score_delta + 2. **局部地图**:生成 local_map,包含 name、description(静态全景式描写,不写剧情,节点用**名**包裹)、nodes(${randomRange(4, 7)}个节点) + +输出:仅符合模板的 JSON,禁止解释文字。 +- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 " +- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`; + }, + a1: v => { + const lLevel = v.targetLocationType === 'main' ? Math.min(5, v.stage + 2) : v.targetLocationType === 'sub' ? 2 : Math.min(5, v.stage + 1); + return `明白。我将结算偏差值,并生成目标地点的 local_map(静态描写/布局),不生成 side_story/剧情。请发送上下文。`; + }, + u2: v => `【上一地点】:\n${v.prevLocationName}: ${v.prevLocationInfo || '无详细信息'}\n\n【世界设定】:\n${worldInfo}\n\n【剧情大纲】:\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【当前时间段】:\n${v.currentTimeline ? `Stage ${v.currentTimeline.stage}: ${v.currentTimeline.state} - ${v.currentTimeline.event}` : `Stage ${v.stage}`}\n\n【历史记录】:\n${history(v.historyCount)}\n\n【{{user}}行动意图】:\n${v.playerAction || '无特定意图'}\n\n【目标地点】:\n名称: ${v.targetLocationName}\n类型: ${v.targetLocationType}\n描述: ${v.targetLocationInfo || '无详细信息'}\n\n【JSON模板】:\n${JSON_TEMPLATES.sceneSwitch}`, + a2: () => `OK, JSON generate start:` + }, + worldGenAssist: { + u1: v => `你是世界观布景助手。负责搭建【地图】和【世界新闻】等可见表层信息。 + +核心要求: +1. 给出可探索的舞台 +2. 重点是:有氛围、有地点、有事件线索,但不过度"剧透"故事 +3. **世界**:News至少${randomRange(3, 6)}条,Maps至少${randomRange(7, 15)}个地点 +4. **历史参考**:参考{{user}}经历构建世界 + +输出:仅纯净合法 JSON,结构参考模板 worldGenAssist。 +- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 " +- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`, + a1: () => `明白。我将只生成世界新闻与地图信息。`, + u2: v => `【世界观与要求】:\n${worldInfo}\n\n【{{user}}经历参考】:\n${history(v.historyCount)}\n\n【{{user}}需求】:\n${v.playerRequests || '无特殊要求'}\n\n【JSON模板(辅助模式)】:\n${JSON_TEMPLATES.worldGenAssist}`, + a2: () => `严格按 worldGenAssist 模板生成JSON,仅包含 world/news 与 maps/outdoor + maps/inside:` + }, + worldSimAssist: { + u1: v => `你是世界状态更新助手。根据当前 JSON 的 world/maps 和{{user}}历史,轻量更新世界现状。 + +输出:完整 JSON,结构参考 worldSimAssist 模板,禁止解释文字。`, + a1: () => `明白。我将只更新 world.news 和 maps.outdoor,不写大纲。请提供当前世界数据。`, + u2: v => `【世界观设定】:\n${worldInfo}\n\n【{{user}}历史】:\n${history(v.historyCount)}\n\n【当前世界状态JSON】(可能包含 meta/world/maps 等字段):\n${v.currentWorldData || '{}'}\n\n【JSON模板(辅助模式)】:\n${JSON_TEMPLATES.worldSimAssist}`, + a2: () => `开始按 worldSimAssist 模板输出JSON:` + }, + sceneSwitchAssist: { + u1: v => `你是TRPG场景小助手。处理{{user}}从一个地点走向另一个地点,只做"结算 + 局部地图"。 + +处理逻辑: + 1. 上一地点结算:给出 deviation(cot_analysis/score_delta) + 2. 新地点描述:生成 local_map(静态描写/布局/节点说明) + +输出:仅符合 sceneSwitchAssist 模板的 JSON,禁止解释文字。 +- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 " +- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`, + a1: () => `明白。我会结算偏差并生成 local_map(不写剧情)。请发送上下文。`, + u2: v => `【上一地点】:\n${v.prevLocationName}: ${v.prevLocationInfo || '无详细信息'}\n\n【世界设定】:\n${worldInfo}\n\n【{{user}}行动意图】:\n${v.playerAction || '无特定意图'}\n\n【目标地点】:\n名称: ${v.targetLocationName}\n类型: ${v.targetLocationType}\n描述: ${v.targetLocationInfo || '无详细信息'}\n\n【已有聊天与剧情历史】:\n${history(v.historyCount)}\n\n【JSON模板(辅助模式)】:\n${JSON_TEMPLATES.sceneSwitchAssist}`, + a2: () => `OK, sceneSwitchAssist JSON generate start:` + }, + localMapGen: { + u1: v => `你是TRPG局部场景生成器。你的任务是根据聊天历史,推断{{user}}当前或将要前往的位置(视经历的最后一条消息而定),并为该位置生成详细的局部地图/室内场景。 + +核心要求: +1. 根据聊天历史记录推断{{user}}当前实际所在的具体位置(可能是某个房间、店铺、街道、洞穴等) +2. 生成符合该地点特色的室内/局部场景描写,inside.name 应反映聊天历史中描述的真实位置名称 +3. 包含${randomRange(4, 8)}个可交互的微观节点 +4. Description 必须用 **节点名** 包裹所有节点名称 +5. 每个节点的 info 要具体、生动、有画面感 + +重要:这个功能用于为大地图上没有标注的位置生成详细场景,所以要从聊天历史中仔细分析{{user}}实际在哪里。 + +输出:仅纯净合法 JSON,结构参考模板。 +- 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 " +- 文本内容中如需使用引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "`, + a1: () => `明白。我将根据聊天历史推断{{user}}当前位置,并生成详细的局部地图/室内场景。`, + u2: v => `【世界设定】:\n${worldInfo}\n\n【剧情大纲】:\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【大地图信息】:\n${v.outdoorDescription || '无大地图描述'}\n\n【聊天历史】(根据此推断{{user}}实际位置):\n${history(v.historyCount)}\n\n【JSON模板】:\n${JSON_TEMPLATES.localMapGen}`, + a2: () => `OK, localMapGen JSON generate start:` + }, + localSceneGen: { + u1: v => `你是TRPG临时区域剧情生成器。你的任务是基于剧情大纲与聊天历史,为{{user}}当前所在区域生成一段即时的故事剧情,让大纲变得生动丰富。`, + a1: () => `明白,我只生成当前区域的临时 Side Story JSON。请提供历史与设定。`, + u2: v => `OK, here is the history and current location.\n\n【{{user}}当前区域】\n- 地点:${v.locationName || v.playerLocation || '未知'}\n- 地点信息:${v.locationInfo || '无'}\n\n【世界设定】\n${worldInfo}\n\n【剧情大纲】\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【当前阶段/时间线】\n- Stage:${v.stage ?? 0}\n- 当前时间线:${v.currentTimeline ? JSON.stringify(v.currentTimeline, null, 2) : '无'}\n\n【聊天历史】\n${history(v.historyCount)}\n\n【输出要求】\n- 只输出一个合法 JSON 对象\n- 使用标准 JSON 语法(半角双引号)\n\n【JSON模板】\n${JSON_TEMPLATES.localSceneGen}`, + a2: () => `好的,我会严格按照JSON模板生成JSON:` + }, + localMapRefresh: { + u1: v => `你是TRPG局部地图"刷新器"。{{user}}当前区域已有一份局部文字地图与节点,但因为剧情进展需要更新。你的任务是基于世界设定、剧情大纲、聊天历史,以及"当前局部地图",输出更新后的 inside JSON。`, + a1: () => `明白,我会在不改变区域主题的前提下刷新局部地图 JSON。请提供当前局部地图与历史。`, + u2: v => `OK, here is current local map and history.\n\n 【当前局部地图】\n${v.currentLocalMap ? JSON.stringify(v.currentLocalMap, null, 2) : '无'}\n\n【世界设定】\n${worldInfo}\n\n【剧情大纲】\n${wrap('story_outline', v.storyOutline) || '无大纲'}\n\n【大地图信息】\n${v.outdoorDescription || '无大地图描述'}\n\n【聊天历史】\n${history(v.historyCount)}\n\n【输出要求】\n- 只输出一个合法 JSON 对象\n- 必须包含 inside.name/inside.description/inside.nodes\n- 用 **节点名** 链接覆盖 description 中的节点\n\n【JSON模板】\n${JSON_TEMPLATES.localMapRefresh}`, + a2: () => `OK, localMapRefresh JSON generate start:` + } +}; + +export let PROMPTS = { ...DEFAULT_PROMPTS }; + +// ================== 配置管理 ================== +const serializePrompts = prompts => Object.fromEntries( + Object.entries(prompts).map(([k, v]) => [k, { u1: v.u1?.toString?.() || '', a1: v.a1?.toString?.() || '', u2: v.u2?.toString?.() || '', a2: v.a2?.toString?.() || '' }]) +); + +const compileFn = (src, fallback) => { + if (!src) return fallback; + try { const fn = eval(`(${src})`); return typeof fn === 'function' ? fn : fallback; } catch { return fallback; } +}; + +const hydratePrompts = sources => { + const out = {}; + Object.entries(DEFAULT_PROMPTS).forEach(([k, v]) => { + const s = sources?.[k] || {}; + out[k] = { u1: compileFn(s.u1, v.u1), a1: compileFn(s.a1, v.a1), u2: compileFn(s.u2, v.u2), a2: compileFn(s.a2, v.a2) }; + }); + return out; +}; + +const applyPromptConfig = cfg => { + JSON_TEMPLATES = cfg?.jsonTemplates ? { ...DEFAULT_JSON_TEMPLATES, ...cfg.jsonTemplates } : { ...DEFAULT_JSON_TEMPLATES }; + PROMPTS = hydratePrompts(cfg?.promptSources || cfg?.prompts); +}; + +const loadPromptConfigFromStorage = () => safeJson(() => JSON.parse(localStorage.getItem(PROMPT_STORAGE_KEY))); +const savePromptConfigToStorage = cfg => { try { localStorage.setItem(PROMPT_STORAGE_KEY, JSON.stringify(cfg)); } catch { } }; + +export const getPromptConfigPayload = () => ({ + current: { jsonTemplates: JSON_TEMPLATES, promptSources: serializePrompts(PROMPTS) }, + defaults: { jsonTemplates: DEFAULT_JSON_TEMPLATES, promptSources: serializePrompts(DEFAULT_PROMPTS) } +}); + +export const setPromptConfig = (cfg, persist = false) => { + applyPromptConfig(cfg || {}); + const payload = { jsonTemplates: JSON_TEMPLATES, promptSources: serializePrompts(PROMPTS) }; + if (persist) savePromptConfigToStorage(payload); + return payload; +}; + +export const reloadPromptConfigFromStorage = () => { + const saved = loadPromptConfigFromStorage(); + applyPromptConfig(saved || {}); + return getPromptConfigPayload().current; +}; + +reloadPromptConfigFromStorage(); + +// ================== 构建函数 ================== +const build = (type, vars) => { + const p = PROMPTS[type]; + return [ + { role: 'user', content: p.u1(vars) }, + { role: 'assistant', content: p.a1(vars) }, + { role: 'user', content: p.u2(vars) }, + { role: 'assistant', content: p.a2(vars) } + ]; +}; + +export const buildSmsMessages = v => build('sms', v); +export const buildSummaryMessages = v => build('summary', v); +export const buildInviteMessages = v => build('invite', v); +export const buildNpcGenerationMessages = v => build('npc', v); +export const buildExtractStrangersMessages = v => build('stranger', v); +export const buildWorldGenStep1Messages = v => build('worldGenStep1', v); +export const buildWorldGenStep2Messages = v => build('worldGenStep2', v); +export const buildWorldSimMessages = v => build(v?.mode === 'assist' ? 'worldSimAssist' : 'worldSim', v); +export const buildSceneSwitchMessages = v => build(v?.mode === 'assist' ? 'sceneSwitchAssist' : 'sceneSwitch', v); +export const buildLocalMapGenMessages = v => build('localMapGen', v); +export const buildLocalMapRefreshMessages = v => build('localMapRefresh', v); +export const buildLocalSceneGenMessages = v => build('localSceneGen', v); + +// ================== NPC 格式化 ================== +function jsonToYaml(data, indent = 0) { + const sp = ' '.repeat(indent); + if (data === null || data === undefined) return ''; + if (typeof data !== 'object') return String(data); + if (Array.isArray(data)) { + return data.map(item => typeof item === 'object' && item !== null + ? `${sp}- ${jsonToYaml(item, indent + 2).trimStart()}` + : `${sp}- ${item}` + ).join('\n'); + } + return Object.entries(data).map(([key, value]) => { + if (typeof value === 'object' && value !== null) { + if (Array.isArray(value) && !value.length) return `${sp}${key}: []`; + if (!Array.isArray(value) && !Object.keys(value).length) return `${sp}${key}: {}`; + return `${sp}${key}:\n${jsonToYaml(value, indent + 2)}`; + } + return `${sp}${key}: ${value}`; + }).join('\n'); +} + +export function formatNpcToWorldbookContent(npc) { return jsonToYaml(npc); } + +// ================== Overlay HTML ================== +const FRAME_STYLE = 'position:absolute!important;z-index:1!important;pointer-events:auto!important;border-radius:12px!important;box-shadow:0 8px 32px rgba(0,0,0,.4)!important;overflow:hidden!important;display:flex!important;flex-direction:column!important;background:#f4f4f4!important;'; + +export const buildOverlayHtml = src => ``; + +export const MOBILE_LAYOUT_STYLE = 'position:absolute!important;left:0!important;right:0!important;top:0!important;bottom:auto!important;width:100%!important;height:350px!important;transform:none!important;z-index:1!important;pointer-events:auto!important;border-radius:0 0 16px 16px!important;box-shadow:0 8px 32px rgba(0,0,0,.4)!important;overflow:hidden!important;display:flex!important;flex-direction:column!important;background:#f4f4f4!important;'; + +export const DESKTOP_LAYOUT_STYLE = 'position:absolute!important;left:50%!important;top:50%!important;transform:translate(-50%,-50%)!important;width:600px!important;max-width:90vw!important;height:450px!important;max-height:80vh!important;z-index:1!important;pointer-events:auto!important;border-radius:12px!important;box-shadow:0 8px 32px rgba(0,0,0,.4)!important;overflow:hidden!important;display:flex!important;flex-direction:column!important;background:#f4f4f4!important;'; diff --git a/modules/story-outline/story-outline.html b/modules/story-outline/story-outline.html index f01be95..2a2a476 100644 --- a/modules/story-outline/story-outline.html +++ b/modules/story-outline/story-outline.html @@ -1,1776 +1,1776 @@ - - - - - - 剧情地图 - - - - - -
-
-
-
- - - -
-
- -
- - -
-
剧情地图Story Outline
- - - -
- - -
- -
- -

最新消息

-
-

当前状态

-
尚未生成世界数据...
-

行动指南

-
等待世界生成...
-
-
- - -
-
-
- - 大地图 - - -
-
-
-
-
100%
-
-
-
-
← 返回
-
-
-
-
- - -
-
-
-
陌路人
-
联络人
-
- - -
-
-
-
-
- - -
-
-
-
-
- - - -
-
-
-
- - - -
-
- - -
-
-
-
- 场景描述 - -
-
-
-
- - -
-
-
-
-
-
-
← 返回
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + 小白板 + + + + + +
+
+
+
+ + + +
+
+ +
+ + +
+
小白板预测试
+ + + +
+ + +
+ +
+ +

最新消息

+
+

当前状态

+
尚未生成世界数据...
+

行动指南

+
等待世界生成...
+
+
+ + +
+
+
+ + 大地图 + + +
+
+
+
+
100%
+
+
+
+
← 返回
+
+
+
+
+ + +
+
+
+
陌路人
+
联络人
+
+ + +
+
+
+
+
+ + +
+
+
+
+
+ + + +
+
+
+
+ + + +
+
+ + +
+
+
+
+ 场景描述 + +
+
+
+
+ + +
+
+
+
+
+
+
← 返回
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/modules/story-outline/story-outline.js b/modules/story-outline/story-outline.js index 63d384b..406f7c3 100644 --- a/modules/story-outline/story-outline.js +++ b/modules/story-outline/story-outline.js @@ -1,1202 +1,1204 @@ -/** - * ============================================================================ - * Story Outline 模块 - 剧情地图系统 - * ============================================================================ - * 功能:生成和管理RPG式剧情世界,提供地图导航、NPC管理、短信系统、世界推演 - * - * 分区: - * 1. 导入与常量 - * 2. 通用工具 - * 3. JSON解析 - * 4. 存储管理 - * 5. LLM调用 - * 6. 世界书操作 - * 7. 剧情注入 - * 8. iframe通讯 - * 9. 请求处理器 - * 10. UI管理 - * 11. 事件与初始化 - * ============================================================================ - */ - -// ==================== 1. 导入与常量 ==================== -import { extension_settings, saveMetadataDebounced } from "../../../../../extensions.js"; -import { chat_metadata, name1, processCommands, eventSource, event_types as st_event_types } from "../../../../../../script.js"; -import { loadWorldInfo, saveWorldInfo, world_names, world_info } from "../../../../../world-info.js"; -import { getContext } from "../../../../../st-context.js"; -import { streamingGeneration } from "../streaming-generation.js"; -import { EXT_ID, extensionFolderPath } from "../../core/constants.js"; -import { createModuleEvents, event_types } from "../../core/event-manager.js"; -import { promptManager } from "../../../../../openai.js"; -import { - buildSmsMessages, buildSummaryMessages, buildSmsHistoryContent, buildExistingSummaryContent, - buildNpcGenerationMessages, formatNpcToWorldbookContent, buildExtractStrangersMessages, - buildWorldGenStep1Messages, buildWorldGenStep2Messages, buildWorldSimMessages, buildSceneSwitchMessages, - buildInviteMessages, buildLocalMapGenMessages, buildLocalMapRefreshMessages, buildLocalSceneGenMessages, - buildOverlayHtml, MOBILE_LAYOUT_STYLE, DESKTOP_LAYOUT_STYLE, getPromptConfigPayload, setPromptConfig -} from "./story-outline-prompt.js"; - -const events = createModuleEvents('storyOutline'); -const IFRAME_PATH = `${extensionFolderPath}/modules/story-outline/story-outline.html`; -const STORAGE_KEYS = { global: 'LittleWhiteBox_StoryOutline_GlobalSettings', comm: 'LittleWhiteBox_StoryOutline_CommSettings' }; -const STORY_OUTLINE_ID = 'lwb_story_outline'; -const CHAR_CARD_UID = '__CHARACTER_CARD__'; -const DEBUG_KEY = 'LittleWhiteBox_StoryOutline_Debug'; - -let overlayCreated = false, frameReady = false, currentMesId = null, pendingMsgs = [], presetCleanup = null, step1Cache = null; - -// ==================== 2. 通用工具 ==================== - -/** 移动端检测 */ -const isMobile = () => window.innerWidth < 550; - -/** 安全执行函数 */ -const safe = fn => { try { return fn(); } catch { return null; } }; -const isDebug = () => { - try { return localStorage.getItem(DEBUG_KEY) === '1'; } catch { return false; } -}; - -/** localStorage读写 */ -const getStore = (k, def) => safe(() => JSON.parse(localStorage.getItem(k))) || def; -const setStore = (k, v) => safe(() => localStorage.setItem(k, JSON.stringify(v))); - -/** 随机范围 */ -const randRange = (a, b) => Math.floor(Math.random() * (b - a + 1)) + a; - -/** - * 修复单个 JSON 字符串的语法问题 - * 仅在已提取的候选上调用,不做全局破坏性操作 - */ -function fixJson(s) { - if (!s || typeof s !== 'string') return s; - - let r = s.trim() - // 统一引号:只转换弯引号 - .replace(/[""]/g, '"').replace(/['']/g, "'") - // 修复键名后的错误引号:如 "key': → "key": - .replace(/"([^"']+)'[\s]*:/g, '"$1":') - .replace(/'([^"']+)"[\s]*:/g, '"$1":') - // 修复单引号包裹的完整值:: 'value' → : "value" - .replace(/:[\s]*'([^']*)'[\s]*([,}\]])/g, ':"$1"$2') - // 修复无引号的键名 - .replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":') - // 移除尾随逗号 - .replace(/,[\s\n]*([}\]])/g, '$1') - // 修复 undefined 和 NaN - .replace(/:\s*undefined\b/g, ': null').replace(/:\s*NaN\b/g, ': null'); - - // 补全未闭合的括号 - let braces = 0, brackets = 0, inStr = false, esc = false; - for (const c of r) { - if (esc) { esc = false; continue; } - if (c === '\\' && inStr) { esc = true; continue; } - if (c === '"') { inStr = !inStr; continue; } - if (!inStr) { - if (c === '{') braces++; else if (c === '}') braces--; - if (c === '[') brackets++; else if (c === ']') brackets--; - } - } - while (braces-- > 0) r += '}'; - while (brackets-- > 0) r += ']'; - return r; -} - -/** - * 从输入中提取 JSON(非破坏性扫描版) - * 策略: - * 1. 直接在原始字符串中扫描所有 {...} 结构 - * 2. 对每个候选单独清洗和解析 - * 3. 按有效属性评分,返回最佳结果 - */ -function extractJson(input, isArray = false) { - if (!input) return null; - - // 处理已经是对象的输入 - if (typeof input === 'object' && input !== null) { - if (isArray && Array.isArray(input)) return input; - if (!isArray && !Array.isArray(input)) { - const content = input.choices?.[0]?.message?.content - ?? input.choices?.[0]?.message?.reasoning_content - ?? input.content ?? input.reasoning_content; - if (content != null) return extractJson(String(content).trim(), isArray); - if (!input.choices) return input; - } - return null; - } - - // 预处理:只做最基本的清理 - const str = String(input).trim() - .replace(/^\uFEFF/, '') - .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') - .replace(/\r\n?/g, '\n'); - if (!str) return null; - - const tryParse = s => { try { return JSON.parse(s); } catch { return null; } }; - const ok = (o, arr) => o != null && (arr ? Array.isArray(o) : typeof o === 'object' && !Array.isArray(o)); - - // 评分函数:meta=10, world/maps=5, 其他=3 - const score = o => (o?.meta ? 10 : 0) + (o?.world ? 5 : 0) + (o?.maps ? 5 : 0) + - (o?.truth ? 3 : 0) + (o?.onion_layers ? 3 : 0) + (o?.atmosphere ? 3 : 0) + (o?.trajectory ? 3 : 0) + (o?.user_guide ? 3 : 0); - - // 1. 直接尝试解析(最理想情况) - let r = tryParse(str); - if (ok(r, isArray) && score(r) > 0) return r; - - // 2. 扫描所有 {...} 或 [...] 结构 - const open = isArray ? '[' : '{'; - const candidates = []; - - for (let i = 0; i < str.length; i++) { - if (str[i] !== open) continue; - - // 括号匹配找闭合位置 - let depth = 0, inStr = false, esc = false; - for (let j = i; j < str.length; j++) { - const c = str[j]; - if (esc) { esc = false; continue; } - if (c === '\\' && inStr) { esc = true; continue; } - if (c === '"') { inStr = !inStr; continue; } - if (inStr) continue; - if (c === '{' || c === '[') depth++; - else if (c === '}' || c === ']') depth--; - if (depth === 0) { - candidates.push({ start: i, end: j, text: str.slice(i, j + 1) }); - i = j; // 跳过已处理的部分 - break; - } - } - } - - // 3. 按长度排序(大的优先,更可能是完整对象) - candidates.sort((a, b) => b.text.length - a.text.length); - - // 4. 尝试解析每个候选,记录最佳结果 - let best = null, bestScore = -1; - - for (const { text } of candidates) { - // 直接解析 - r = tryParse(text); - if (ok(r, isArray)) { - const s = score(r); - if (s > bestScore) { best = r; bestScore = s; } - if (s >= 10) return r; // 有 meta 就直接返回 - continue; - } - - // 修复后解析 - const fixed = fixJson(text); - r = tryParse(fixed); - if (ok(r, isArray)) { - const s = score(r); - if (s > bestScore) { best = r; bestScore = s; } - if (s >= 10) return r; - } - } - - // 5. 返回最佳结果 - if (best) return best; - - // 6. 最后尝试:取第一个 { 到最后一个 } 之间的内容 - const firstBrace = str.indexOf('{'); - const lastBrace = str.lastIndexOf('}'); - if (!isArray && firstBrace !== -1 && lastBrace > firstBrace) { - const chunk = str.slice(firstBrace, lastBrace + 1); - r = tryParse(chunk) || tryParse(fixJson(chunk)); - if (ok(r, isArray)) return r; - } - - return null; -} - -export { extractJson }; - -// ==================== 4. 存储管理 ==================== - -/** 获取扩展设置 */ -const getSettings = () => { const e = extension_settings[EXT_ID] ||= {}; e.storyOutline ||= { enabled: true }; return e; }; - -/** 获取剧情大纲存储 */ -function getOutlineStore() { - if (!chat_metadata) return null; - const ext = chat_metadata.extensions ||= {}, lwb = ext[EXT_ID] ||= {}; - return lwb.storyOutline ||= { - mapData: null, stage: 0, deviationScore: 0, simulationProgress: 0, simulationTarget: 5, playerLocation: '家', - outlineData: { meta: null, world: null, outdoor: null, indoor: null, sceneSetup: null, strangers: null, contacts: null }, - dataChecked: { meta: true, world: true, outdoor: true, indoor: true, sceneSetup: true, strangers: false, contacts: false, characterContactSms: false } - }; -} - -/** 全局/通讯设置读写 */ -const getGlobalSettings = () => getStore(STORAGE_KEYS.global, { apiUrl: '', apiKey: '', model: '', mode: 'assist' }); -const saveGlobalSettings = s => setStore(STORAGE_KEYS.global, s); -const getCommSettings = () => ({ historyCount: 50, npcPosition: 0, npcOrder: 100, ...getStore(STORAGE_KEYS.comm, {}) }); -const saveCommSettings = s => setStore(STORAGE_KEYS.comm, s); - -/** 获取角色卡信息 */ -function getCharInfo() { - const ctx = getContext(), char = ctx.characters?.[ctx.characterId]; - return { - name: char?.name || char?.data?.name || char?.avatar || '角色卡', - desc: String((char?.description ?? char?.data?.description ?? '') || '').trim() || '{{description}}' - }; -} - -/** 获取角色卡短信历史 */ -function getCharSmsHistory() { - if (!chat_metadata) return null; - const root = chat_metadata.LittleWhiteBox_StoryOutline ||= {}; - const h = root.characterContactSmsHistory ||= { messages: [], summarizedCount: 0, summaries: {} }; - h.messages ||= []; h.summarizedCount ||= 0; h.summaries ||= {}; - return h; -} - -// ==================== 5. LLM调用 ==================== - - -/** 调用LLM */ -async function callLLM(promptOrMsgs, useRaw = false) { - const { apiUrl, apiKey, model } = getGlobalSettings(); - - const normalize = r => { - if (r == null) return ''; - if (typeof r === 'string') return r; - if (typeof r === 'object') { - if (r.data && typeof r.data === 'object') return normalize(r.data); - if (typeof r.text === 'string') return r.text; - if (typeof r.response === 'string') return r.response; - const inner = r.content?.trim?.() || r.reasoning_content?.trim?.() || r.choices?.[0]?.message?.content?.trim?.() || r.choices?.[0]?.message?.reasoning_content?.trim?.() || null; - if (inner != null) return String(inner); - return safe(() => JSON.stringify(r)) || String(r); - } - return String(r); - }; - - // 构建基础选项 - const opts = { nonstream: 'true', lock: 'on' }; - if (apiUrl?.trim()) Object.assign(opts, { api: 'openai', apiurl: apiUrl.trim(), ...(apiKey && { apipassword: apiKey }), ...(model && { model }) }); - - if (useRaw) { - const messages = Array.isArray(promptOrMsgs) - ? promptOrMsgs - : [{ role: 'user', content: String(promptOrMsgs || '').trim() }]; - - // 直接把消息转成 top 参数格式,不做预处理 - // {$worldInfo} 和 {$historyN} 由 xbgenrawCommand 内部处理 - const roleMap = { user: 'user', assistant: 'assistant', system: 'sys' }; - const topParts = messages - .filter(m => m?.role && typeof m.content === 'string' && m.content.trim()) - .map(m => { - const role = roleMap[m.role] || m.role; - return `${role}={${m.content}}`; - }); - const topParam = topParts.join(';'); - - opts.top = topParam; - // 不设置 addon,让 xbgenrawCommand 自己处理 {$worldInfo} 占位符替换 - - const raw = await streamingGeneration.xbgenrawCommand(opts, ''); - const text = normalize(raw).trim(); - - if (isDebug()) { - try { - console.groupCollapsed('[StoryOutline] callLLM(useRaw via xbgenrawCommand)'); - console.log('opts.top.length', topParam.length); - console.log('raw', raw); - console.log('normalized.length', text.length); - console.groupEnd(); - } catch { } - } - return text; - } - - opts.as = 'user'; - opts.position = 'history'; - return normalize(await streamingGeneration.xbgenCommand(opts, promptOrMsgs)).trim(); -} - -/** 调用LLM并解析JSON */ -async function callLLMJson({ messages, useRaw = true, isArray = false, validate }) { - try { - const result = await callLLM(messages, useRaw); - if (isDebug()) { - try { - const s = String(result ?? ''); - console.groupCollapsed('[StoryOutline] callLLMJson'); - console.log({ useRaw, isArray, length: s.length }); - console.log('result.head', s.slice(0, 500)); - console.log('result.tail', s.slice(Math.max(0, s.length - 500))); - console.groupEnd(); - } catch { } - } - const parsed = extractJson(result, isArray); - if (isDebug()) { - try { - console.groupCollapsed('[StoryOutline] extractJson'); - console.log('parsed', parsed); - console.log('validate', !!(parsed && validate?.(parsed))); - console.groupEnd(); - } catch { } - } - if (parsed && validate(parsed)) return parsed; - } catch { } - return null; -} - -// ==================== 6. 世界书操作 ==================== - -/** 获取角色卡绑定的世界书 */ -async function getCharWorldbooks() { - const ctx = getContext(), char = ctx.characters?.[ctx.characterId]; - if (!char) return []; - const books = [], primary = char.data?.extensions?.world; - if (primary && world_names?.includes(primary)) books.push(primary); - (world_info?.charLore || []).find(e => e.name === char.avatar)?.extraBooks?.forEach(b => { - if (world_names?.includes(b) && !books.includes(b)) books.push(b); - }); - return books; -} - -/** 根据UID查找条目 */ -async function findEntry(uid) { - const uidNum = parseInt(uid, 10); - if (isNaN(uidNum)) return null; - for (const book of await getCharWorldbooks()) { - const data = await loadWorldInfo(book); - if (data?.entries?.[uidNum]) return { bookName: book, entry: data.entries[uidNum], uidNumber: uidNum, worldData: data }; - } - return null; -} - -/** 根据名称搜索条目 */ -async function searchEntry(name) { - const nl = (name || '').toLowerCase().trim(); - for (const book of await getCharWorldbooks()) { - const data = await loadWorldInfo(book); - if (!data?.entries) continue; - for (const [uid, entry] of Object.entries(data.entries)) { - const keys = Array.isArray(entry.key) ? entry.key : []; - if (keys.some(k => { const kl = (k || '').toLowerCase().trim(); return kl === nl || kl.includes(nl) || nl.includes(kl); })) - return { uid: String(uid), bookName: book, entry }; - } - } - return null; -} - -// ==================== 7. 剧情注入 ==================== - -/** 获取可见洋葱层级 */ -const getVisibleLayers = stage => ['L1_The_Veil', 'L2_The_Distortion', 'L3_The_Law', 'L4_The_Agent', 'L5_The_Axiom'].slice(0, Math.min(Math.max(0, stage), 3) + 2); - -/** 格式化剧情数据为提示词 */ -function formatOutlinePrompt() { - const store = getOutlineStore(); - if (!store?.outlineData) return ""; - - const { outlineData: d, dataChecked: c, playerLocation } = store, stage = store.stage ?? 0; - let text = "## Story Outline (剧情地图数据)\n\n", has = false; - - // 世界真相 - if (c?.meta && d.meta?.truth) { - has = true; - text += "### 世界真相 (World Truth)\n> 注意:以下信息仅供生成逻辑参考,不可告知玩家。\n"; - if (d.meta.truth.background) text += `* 背景真相: ${d.meta.truth.background}\n`; - const dr = d.meta.truth.driver; - if (dr) { if (dr.source) text += `* 驱动: ${dr.source}\n`; if (dr.target_end) text += `* 目的: ${dr.target_end}\n`; if (dr.tactic) text += `* 当前手段: ${dr.tactic}\n`; } - - // 当前气氛 - const atm = d.meta.atmosphere?.current; - if (atm) { - if (atm.environmental) text += `* 当前气氛: ${atm.environmental}\n`; - if (atm.npc_attitudes) text += `* NPC态度: ${atm.npc_attitudes}\n`; - } - - const onion = d.meta.onion_layers || d.meta.truth.onion_layers; - if (onion) { - text += "* 当前可见层级:\n"; - getVisibleLayers(stage).forEach(k => { - const l = onion[k]; if (!l || !Array.isArray(l) || !l.length) return; - const name = k.replace(/_/g, ' - '); - l.forEach(i => { text += ` - [${name}] ${i.desc}: ${i.logic}\n`; }); - }); - } - text += "\n"; - } - - // 世界资讯 - if (c?.world && d.world?.news?.length) { has = true; text += "### 世界资讯 (News)\n"; d.world.news.forEach(n => { text += `* ${n.title}: ${n.content}\n`; }); text += "\n"; } - - // 环境信息 - let mapC = "", locNode = null; - if (c?.outdoor && d.outdoor) { - if (d.outdoor.description) mapC += `> 大地图环境: ${d.outdoor.description}\n`; - if (playerLocation && d.outdoor.nodes?.length) locNode = d.outdoor.nodes.find(n => n.name === playerLocation); - } - if (!locNode && c?.indoor && d.indoor?.nodes?.length && playerLocation) locNode = d.indoor.nodes.find(n => n.name === playerLocation); - const indoorMap = (c?.indoor && playerLocation && d.indoor && typeof d.indoor === 'object' && !Array.isArray(d.indoor)) ? d.indoor[playerLocation] : null; - const locText = indoorMap?.description || locNode?.info || ''; - if (playerLocation && locText) mapC += `\n> 当前地点 (${playerLocation}):\n${locText}\n`; - if (c?.indoor && d.indoor && !locNode && !indoorMap && d.indoor.description) { mapC += d.indoor.name ? `\n> 当前地点: ${d.indoor.name}\n` : "\n> 局部区域:\n"; mapC += `${d.indoor.description}\n`; } - if (mapC) { has = true; text += `### 环境信息 (Environment)\n${mapC}\n`; } - - // 周边人物 - let charC = ""; - if (c?.contacts && d.contacts?.length) { charC += "* 联络人:\n"; d.contacts.forEach(p => charC += ` - ${p.name}${p.location ? ` @ ${p.location}` : ''}: ${p.info || ''}\n`); } - if (c?.strangers && d.strangers?.length) { charC += "* 陌路人:\n"; d.strangers.forEach(p => charC += ` - ${p.name}${p.location ? ` @ ${p.location}` : ''}: ${p.info || ''}\n`); } - if (charC) { has = true; text += `### 周边人物 (Characters)\n${charC}\n`; } - - // 当前剧情 - if (c?.sceneSetup && d.sceneSetup) { - const ss = d.sceneSetup.sideStory || d.sceneSetup.side_story || d.sceneSetup; - if (ss && (ss.surface || ss.inner)) { has = true; text += "### 当前剧情 (Current Scene)\n"; if (ss.surface) text += `* 表象: ${ss.surface}\n`; if (ss.inner) text += `* 里层 (潜台词): ${ss.inner}\n`; text += "\n"; } - } - - // 角色卡短信 - if (c?.characterContactSms) { - const { name: charName } = getCharInfo(), hist = getCharSmsHistory(); - const sums = hist?.summaries || {}, sumKeys = Object.keys(sums).filter(k => k !== '_count').sort((a, b) => a - b); - const msgs = hist?.messages || [], sc = hist?.summarizedCount || 0, rem = msgs.slice(sc); - if (sumKeys.length || rem.length) { - has = true; text += `### ${charName}短信记录\n`; - if (sumKeys.length) text += `[摘要] ${sumKeys.map(k => sums[k]).join(';')}\n`; - if (rem.length) text += rem.map(m => `${m.type === 'sent' ? '{{user}}' : charName}:${m.text}`).join('\n') + "\n"; - text += "\n"; - } - } - - return has ? text.trim() : ""; -} - -/** 确保剧情大纲Prompt存在 */ -function ensurePrompt() { - if (!promptManager) return false; - let prompt = promptManager.getPromptById(STORY_OUTLINE_ID); - if (!prompt) { - promptManager.addPrompt({ identifier: STORY_OUTLINE_ID, name: '剧情地图', role: 'system', content: '', system_prompt: false, marker: false, extension: true }, STORY_OUTLINE_ID); - prompt = promptManager.getPromptById(STORY_OUTLINE_ID); - } - const char = promptManager.activeCharacter; - if (!char) return true; - const order = promptManager.getPromptOrderForCharacter(char); - const exists = order.some(e => e.identifier === STORY_OUTLINE_ID); - if (!exists) { const idx = order.findIndex(e => e.identifier === 'charDescription'); order.splice(idx !== -1 ? idx : 0, 0, { identifier: STORY_OUTLINE_ID, enabled: true }); } - else { const entry = order.find(e => e.identifier === STORY_OUTLINE_ID); if (entry && !entry.enabled) entry.enabled = true; } - promptManager.render?.(false); - return true; -} - -/** 更新剧情大纲Prompt内容 */ -function updatePromptContent() { - if (!promptManager) return; - if (!getSettings().storyOutline?.enabled) { removePrompt(); return; } - ensurePrompt(); - const store = getOutlineStore(), prompt = promptManager.getPromptById(STORY_OUTLINE_ID); - if (!prompt) return; - const { dataChecked } = store || {}; - const hasAny = dataChecked && Object.values(dataChecked).some(v => v === true); - prompt.content = (!hasAny || !store) ? '' : (formatOutlinePrompt() || ''); - promptManager.render?.(false); -} - -/** 移除剧情大纲Prompt */ -function removePrompt() { - if (!promptManager) return; - const prompts = promptManager.serviceSettings?.prompts; - if (prompts) { const idx = prompts.findIndex(p => p?.identifier === STORY_OUTLINE_ID); if (idx !== -1) prompts.splice(idx, 1); } - const orders = promptManager.serviceSettings?.prompt_order; - if (orders) orders.forEach(cfg => { if (cfg?.order) { const idx = cfg.order.findIndex(e => e?.identifier === STORY_OUTLINE_ID); if (idx !== -1) cfg.order.splice(idx, 1); } }); - promptManager.render?.(false); -} - -/** 设置ST预设事件监听 */ -function setupSTEvents() { - if (presetCleanup) return; - const onChanged = () => { if (getSettings().storyOutline?.enabled) setTimeout(() => { ensurePrompt(); updatePromptContent(); }, 100); }; - const onExport = preset => { - if (!preset) return; - if (preset.prompts) { const i = preset.prompts.findIndex(p => p?.identifier === STORY_OUTLINE_ID); if (i !== -1) preset.prompts.splice(i, 1); } - if (preset.prompt_order) preset.prompt_order.forEach(c => { if (c?.order) { const i = c.order.findIndex(e => e?.identifier === STORY_OUTLINE_ID); if (i !== -1) c.order.splice(i, 1); } }); - }; - eventSource.on(st_event_types.OAI_PRESET_CHANGED_AFTER, onChanged); - eventSource.on(st_event_types.OAI_PRESET_EXPORT_READY, onExport); - presetCleanup = () => { try { eventSource.removeListener(st_event_types.OAI_PRESET_CHANGED_AFTER, onChanged); } catch { } try { eventSource.removeListener(st_event_types.OAI_PRESET_EXPORT_READY, onExport); } catch { } }; -} - -const injectOutline = () => updatePromptContent(); - -// ==================== 8. iframe通讯 ==================== - -/** 发送消息到iframe */ -function postFrame(payload) { - const iframe = document.getElementById("xiaobaix-story-outline-iframe"); - if (!iframe?.contentWindow || !frameReady) { pendingMsgs.push(payload); return; } - iframe.contentWindow.postMessage({ source: "LittleWhiteBox", ...payload }, "*"); -} - -const flushPending = () => { if (!frameReady) return; const f = document.getElementById("xiaobaix-story-outline-iframe"); pendingMsgs.forEach(p => f?.contentWindow?.postMessage({ source: "LittleWhiteBox", ...p }, "*")); pendingMsgs = []; }; - -/** 发送设置到iframe */ -function sendSettings() { - const store = getOutlineStore(), { name: charName, desc: charDesc } = getCharInfo(); - postFrame({ - type: "LOAD_SETTINGS", globalSettings: getGlobalSettings(), commSettings: getCommSettings(), - stage: store?.stage ?? 0, deviationScore: store?.deviationScore ?? 0, simulationProgress: store?.simulationProgress ?? 0, - simulationTarget: store?.simulationTarget ?? 5, playerLocation: store?.playerLocation ?? '家', - dataChecked: store?.dataChecked || {}, outlineData: store?.outlineData || {}, promptConfig: getPromptConfigPayload?.(), - characterCardName: charName, characterCardDescription: charDesc, - characterContactSmsHistory: getCharSmsHistory() - }); -} - -const loadAndSend = () => { const s = getOutlineStore(); if (s?.mapData) postFrame({ type: "LOAD_MAP_DATA", mapData: s.mapData }); sendSettings(); }; - -// ==================== 9. 请求处理器 ==================== - -const reply = (type, reqId, data) => postFrame({ type, requestId: reqId, ...data }); -const replyErr = (type, reqId, err) => reply(type, reqId, { error: err }); - -/** 获取当前气氛 */ -function getAtmosphere(store) { - return store?.outlineData?.meta?.atmosphere?.current || null; -} - -/** 合并世界推演数据 */ -function mergeSimData(orig, upd) { - if (!upd) return orig; - const r = JSON.parse(JSON.stringify(orig || {})); - const um = upd?.meta || {}, ut = um.truth || upd?.truth, uo = um.onion_layers || ut?.onion_layers; - const ua = um.atmosphere || upd?.atmosphere, utr = um.trajectory || upd?.trajectory; - r.meta = r.meta || {}; r.meta.truth = r.meta.truth || {}; - if (ut?.driver?.tactic) r.meta.truth.driver = { ...r.meta.truth.driver, tactic: ut.driver.tactic }; - if (uo) { ['L1_The_Veil', 'L2_The_Distortion', 'L3_The_Law', 'L4_The_Agent', 'L5_The_Axiom'].forEach(l => { const v = uo[l]; if (Array.isArray(v) && v.length) { r.meta.onion_layers = r.meta.onion_layers || {}; r.meta.onion_layers[l] = v; } }); if (r.meta.truth?.onion_layers) delete r.meta.truth.onion_layers; } - if (um.user_guide || upd?.user_guide) r.meta.user_guide = um.user_guide || upd.user_guide; - // 更新 atmosphere - if (ua) { r.meta.atmosphere = ua; } - // 更新 trajectory - if (utr) { r.meta.trajectory = utr; } - if (upd?.world) r.world = upd.world; - if (upd?.maps?.outdoor) { r.maps = r.maps || {}; r.maps.outdoor = r.maps.outdoor || {}; if (upd.maps.outdoor.description) r.maps.outdoor.description = upd.maps.outdoor.description; if (Array.isArray(upd.maps.outdoor.nodes)) { const on = r.maps.outdoor.nodes || []; upd.maps.outdoor.nodes.forEach(n => { const i = on.findIndex(x => x.name === n.name); if (i >= 0) on[i] = { ...n }; else on.push(n); }); r.maps.outdoor.nodes = on; } } - return r; -} - -/** 检查自动推演 */ -async function checkAutoSim(reqId) { - const store = getOutlineStore(); - if (!store || (store.simulationProgress || 0) < (store.simulationTarget ?? 5)) return; - const data = { meta: store.outlineData?.meta || {}, world: store.outlineData?.world || null, maps: { outdoor: store.outlineData?.outdoor || null, indoor: store.outlineData?.indoor || null } }; - await handleSimWorld({ requestId: `wsim_auto_${Date.now()}`, currentData: JSON.stringify(data), isAuto: true }); -} - -// 验证器 -const V = { - sum: o => o?.summary, npc: o => o?.name && o?.aliases, arr: o => Array.isArray(o), - scene: o => !!o?.review?.deviation && !!(o?.local_map || o?.scene_setup?.local_map), - lscene: o => !!o?.side_story, inv: o => typeof o?.invite === 'boolean' && o?.reply, - sms: o => typeof o?.reply === 'string' && o.reply.length > 0, - wg1: d => !!d && typeof d === 'object', // 只要是对象就行,后续会 normalize - wg2: d => !!(d?.world && (d?.maps || d?.world?.maps)?.outdoor), - wga: d => !!(d?.world && d?.maps?.outdoor), ws: d => !!d, w: o => !!o && typeof o === 'object', - lm: o => !!o?.inside?.name && !!o?.inside?.description -}; - -// --- 处理器 --- - -async function handleFetchModels({ apiUrl, apiKey }) { - try { - let models = []; - if (!apiUrl) { - for (const ep of ['/api/backends/chat-completions/models', '/api/openai/models']) { - try { const r = await fetch(ep, { headers: { 'Content-Type': 'application/json' } }); if (r.ok) { const j = await r.json(); models = (j.data || j || []).map(m => m.id || m.name || m).filter(m => typeof m === 'string'); if (models.length) break; } } catch { } - } - if (!models.length) throw new Error('无法从酒馆获取模型列表'); - } else { - const h = { 'Content-Type': 'application/json', ...(apiKey && { Authorization: `Bearer ${apiKey}` }) }; - const r = await fetch(apiUrl.replace(/\/$/, '') + '/models', { headers: h }); - if (!r.ok) throw new Error(`HTTP ${r.status}`); - const j = await r.json(); - models = (j.data || j || []).map(m => m.id || m.name || m).filter(m => typeof m === 'string'); - } - postFrame({ type: "FETCH_MODELS_RESULT", models }); - } catch (e) { postFrame({ type: "FETCH_MODELS_RESULT", error: e.message }); } -} - -async function handleTestConn({ apiUrl, apiKey, model }) { - try { - if (!apiUrl) { for (const ep of ['/api/backends/chat-completions/status', '/api/openai/models', '/api/backends/chat-completions/models']) { try { if ((await fetch(ep, { headers: { 'Content-Type': 'application/json' } })).ok) { postFrame({ type: "TEST_CONN_RESULT", success: true, message: `连接成功${model ? ` (模型: ${model})` : ''}` }); return; } } catch { } } throw new Error('无法连接到酒馆API'); } - const h = { 'Content-Type': 'application/json', ...(apiKey && { Authorization: `Bearer ${apiKey}` }) }; - if (!(await fetch(apiUrl.replace(/\/$/, '') + '/models', { headers: h })).ok) throw new Error('连接失败'); - postFrame({ type: "TEST_CONN_RESULT", success: true, message: `连接成功${model ? ` (模型: ${model})` : ''}` }); - } catch (e) { postFrame({ type: "TEST_CONN_RESULT", success: false, message: `连接失败: ${e.message}` }); } -} - -async function handleCheckUid({ uid, requestId }) { - const num = parseInt(uid, 10); - if (!uid?.trim() || isNaN(num)) return replyErr("CHECK_WORLDBOOK_UID_RESULT", requestId, isNaN(num) ? 'UID必须是数字' : '请输入有效的UID'); - const books = await getCharWorldbooks(); - if (!books.length) return replyErr("CHECK_WORLDBOOK_UID_RESULT", requestId, '当前角色卡没有绑定世界书'); - for (const book of books) { - const data = await loadWorldInfo(book), entry = data?.entries?.[num]; - if (entry) { - const keys = Array.isArray(entry.key) ? entry.key : []; - if (!keys.length) return replyErr("CHECK_WORLDBOOK_UID_RESULT", requestId, `在「${book}」中找到条目 UID ${uid},但没有主要关键字`); - return reply("CHECK_WORLDBOOK_UID_RESULT", requestId, { primaryKeys: keys, worldbook: book, comment: entry.comment || '' }); - } - } - replyErr("CHECK_WORLDBOOK_UID_RESULT", requestId, `在角色卡绑定的世界书中未找到 UID 为 ${uid} 的条目`); -} - -async function handleSendSms({ requestId, contactName, worldbookUid, userMessage, chatHistory, summarizedCount }) { - try { - const ctx = getContext(), userName = name1 || ctx.name1 || '用户'; - let charContent = '', existSum = {}, sc = summarizedCount || 0; - - if (worldbookUid === CHAR_CARD_UID) { - charContent = getCharInfo().desc; - const h = getCharSmsHistory(); existSum = h?.summaries || {}; sc = summarizedCount ?? h?.summarizedCount ?? 0; - } else if (worldbookUid) { - const e = await findEntry(worldbookUid); - if (e?.entry) { - const c = e.entry.content || '', si = c.indexOf('[SMS_HISTORY_START]'); - charContent = si !== -1 ? c.substring(0, si).trim() : c; - const [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; - if (s !== -1 && ed !== -1) { const p = safe(() => JSON.parse(c.substring(s + 19, ed).trim())); const si = p?.find?.(i => typeof i === 'string' && i.startsWith('SMS_summary:')); if (si) existSum = safe(() => JSON.parse(si.substring(12))) || {}; } - } - } - - let histText = ''; - const sumKeys = Object.keys(existSum).filter(k => k !== '_count').sort((a, b) => a - b); - if (sumKeys.length) histText = `[之前的对话摘要] ${sumKeys.map(k => existSum[k]).join(';')}\n\n`; - if (chatHistory?.length > 1) { const msgs = chatHistory.slice(sc, -1); if (msgs.length) histText += msgs.map(m => `${m.type === 'sent' ? userName : contactName}:${m.text}`).join('\n'); } - - const msgs = buildSmsMessages({ contactName, userName, storyOutline: formatOutlinePrompt(), historyCount: getCommSettings().historyCount || 50, smsHistoryContent: buildSmsHistoryContent(histText), userMessage, characterContent: charContent }); - const parsed = await callLLMJson({ messages: msgs, validate: V.sms }); - reply('SMS_RESULT', requestId, parsed?.reply ? { reply: parsed.reply } : { error: '生成回复失败,请调整重试' }); - } catch (e) { replyErr('SMS_RESULT', requestId, `生成失败: ${e.message}`); } -} - -async function handleLoadSmsHistory({ worldbookUid }) { - if (worldbookUid === CHAR_CARD_UID) { const h = getCharSmsHistory(); return postFrame({ type: 'LOAD_SMS_HISTORY_RESULT', worldbookUid, messages: h?.messages || [], summarizedCount: h?.summarizedCount || 0 }); } - const store = getOutlineStore(), contact = store?.outlineData?.contacts?.find(c => c.worldbookUid === worldbookUid); - if (contact?.smsHistory?.messages?.length) return postFrame({ type: 'LOAD_SMS_HISTORY_RESULT', worldbookUid, messages: contact.smsHistory.messages, summarizedCount: contact.smsHistory.summarizedCount || 0 }); - const e = await findEntry(worldbookUid); let msgs = []; - if (e?.entry) { const c = e.entry.content || '', [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; if (s !== -1 && ed !== -1) { const p = safe(() => JSON.parse(c.substring(s + 19, ed).trim())); p?.forEach?.(i => { if (typeof i === 'string' && !i.startsWith('SMS_summary:')) { const idx = i.indexOf(':'); if (idx > 0) msgs.push({ type: i.substring(0, idx) === '{{user}}' ? 'sent' : 'received', text: i.substring(idx + 1) }); } }); } } - postFrame({ type: 'LOAD_SMS_HISTORY_RESULT', worldbookUid, messages: msgs, summarizedCount: 0 }); -} - -async function handleSaveSmsHistory({ worldbookUid, messages, contactName, summarizedCount }) { - if (worldbookUid === CHAR_CARD_UID) { const h = getCharSmsHistory(); if (!h) return; h.messages = Array.isArray(messages) ? messages : []; h.summarizedCount = summarizedCount || 0; if (!h.messages.length) { h.summarizedCount = 0; h.summaries = {}; } saveMetadataDebounced?.(); return; } - const e = await findEntry(worldbookUid); if (!e) return; - const { bookName, entry: en, worldData } = e; let c = en.content || ''; const cn = contactName || en.key?.[0] || '角色'; let existSum = ''; - const [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; - if (s !== -1 && ed !== -1) { const p = safe(() => JSON.parse(c.substring(s + 19, ed).trim())); existSum = p?.find?.(i => typeof i === 'string' && i.startsWith('SMS_summary:')) || ''; c = c.substring(0, s).trimEnd() + c.substring(ed + 17); } - if (messages?.length) { const sc = summarizedCount || 0, simp = messages.slice(sc).map(m => `${m.type === 'sent' ? '{{user}}' : cn}:${m.text}`); const arr = existSum ? [existSum, ...simp] : simp; c = c.trimEnd() + `\n\n[SMS_HISTORY_START]\n${JSON.stringify(arr)}\n[SMS_HISTORY_END]`; } - en.content = c.trim(); await saveWorldInfo(bookName, worldData); -} - -async function handleCompressSms({ requestId, worldbookUid, messages, contactName, summarizedCount }) { - const sc = summarizedCount || 0; - try { - const ctx = getContext(), userName = name1 || ctx.name1 || '用户'; - let e = null, existSum = {}; - - if (worldbookUid === CHAR_CARD_UID) { - const h = getCharSmsHistory(); existSum = h?.summaries || {}; - const keep = 4, toEnd = Math.max(sc, (messages?.length || 0) - keep); - if (toEnd <= sc) return replyErr('COMPRESS_SMS_RESULT', requestId, '没有足够的新消息需要总结'); - const toSum = (messages || []).slice(sc, toEnd); if (toSum.length < 2) return replyErr('COMPRESS_SMS_RESULT', requestId, '需要至少2条消息才能进行总结'); - const convText = toSum.map(m => `${m.type === 'sent' ? userName : contactName}:${m.text}`).join('\n'); - const sumKeys = Object.keys(existSum).filter(k => k !== '_count').sort((a, b) => a - b); - const existText = sumKeys.map(k => `${k}. ${existSum[k]}`).join('\n'); - const parsed = await callLLMJson({ messages: buildSummaryMessages({ existingSummaryContent: buildExistingSummaryContent(existText), conversationText: convText }), validate: V.sum }); - const sum = parsed?.summary?.trim?.(); if (!sum) return replyErr('COMPRESS_SMS_RESULT', requestId, 'ECHO:总结生成出错,请重试'); - const nextK = Math.max(0, ...Object.keys(existSum).filter(k => k !== '_count').map(k => parseInt(k, 10)).filter(n => !isNaN(n))) + 1; - existSum[String(nextK)] = sum; - if (h) { h.messages = Array.isArray(messages) ? messages : (h.messages || []); h.summarizedCount = toEnd; h.summaries = existSum; saveMetadataDebounced?.(); } - return reply('COMPRESS_SMS_RESULT', requestId, { summary: sum, newSummarizedCount: toEnd }); - } - - e = await findEntry(worldbookUid); - if (e?.entry) { const c = e.entry.content || '', [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; if (s !== -1 && ed !== -1) { const p = safe(() => JSON.parse(c.substring(s + 19, ed).trim())); const si = p?.find?.(i => typeof i === 'string' && i.startsWith('SMS_summary:')); if (si) existSum = safe(() => JSON.parse(si.substring(12))) || {}; } } - - const keep = 4, toEnd = Math.max(sc, messages.length - keep); - if (toEnd <= sc) return replyErr('COMPRESS_SMS_RESULT', requestId, '没有足够的新消息需要总结'); - const toSum = messages.slice(sc, toEnd); if (toSum.length < 2) return replyErr('COMPRESS_SMS_RESULT', requestId, '需要至少2条消息才能进行总结'); - const convText = toSum.map(m => `${m.type === 'sent' ? userName : contactName}:${m.text}`).join('\n'); - const sumKeys = Object.keys(existSum).filter(k => k !== '_count').sort((a, b) => a - b); - const existText = sumKeys.map(k => `${k}. ${existSum[k]}`).join('\n'); - const parsed = await callLLMJson({ messages: buildSummaryMessages({ existingSummaryContent: buildExistingSummaryContent(existText), conversationText: convText }), validate: V.sum }); - const sum = parsed?.summary?.trim?.(); if (!sum) return replyErr('COMPRESS_SMS_RESULT', requestId, 'ECHO:总结生成出错,请重试'); - const newSc = toEnd; - - if (e) { - const { bookName, entry: en, worldData } = e; let c = en.content || ''; const cn = contactName || en.key?.[0] || '角色'; - const [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; if (s !== -1 && ed !== -1) c = c.substring(0, s).trimEnd() + c.substring(ed + 17); - const nextK = Math.max(0, ...Object.keys(existSum).filter(k => k !== '_count').map(k => parseInt(k, 10)).filter(n => !isNaN(n))) + 1; - existSum[String(nextK)] = sum; - const rem = messages.slice(toEnd).map(m => `${m.type === 'sent' ? '{{user}}' : cn}:${m.text}`); - const arr = [`SMS_summary:${JSON.stringify(existSum)}`, ...rem]; - c = c.trimEnd() + `\n\n[SMS_HISTORY_START]\n${JSON.stringify(arr)}\n[SMS_HISTORY_END]`; - en.content = c.trim(); await saveWorldInfo(bookName, worldData); - } - reply('COMPRESS_SMS_RESULT', requestId, { summary: sum, newSummarizedCount: newSc }); - } catch (e) { replyErr('COMPRESS_SMS_RESULT', requestId, `压缩失败: ${e.message}`); } -} - -async function handleCheckStrangerWb({ requestId, strangerName }) { - const r = await searchEntry(strangerName); - postFrame({ type: 'CHECK_STRANGER_WORLDBOOK_RESULT', requestId, found: !!r, ...(r && { worldbookUid: r.uid, worldbook: r.bookName, entryName: r.entry.comment || r.entry.key?.[0] || strangerName }) }); -} - -async function handleGenNpc({ requestId, strangerName, strangerInfo }) { - try { - const ctx = getContext(), char = ctx.characters?.[ctx.characterId]; - if (!char) return replyErr('GENERATE_NPC_RESULT', requestId, '未找到当前角色卡'); - const primary = char.data?.extensions?.world; - if (!primary || !world_names?.includes(primary)) return replyErr('GENERATE_NPC_RESULT', requestId, '角色卡未绑定世界书,请先绑定世界书'); - const comm = getCommSettings(); - const msgs = buildNpcGenerationMessages({ strangerName, strangerInfo: strangerInfo || '(无描述)', storyOutline: formatOutlinePrompt(), historyCount: comm.historyCount || 50 }); - const npc = await callLLMJson({ messages: msgs, validate: V.npc }); - if (!npc?.name) return replyErr('GENERATE_NPC_RESULT', requestId, 'NPC 生成失败:无法解析 JSON 数据'); - const wd = await loadWorldInfo(primary); if (!wd) return replyErr('GENERATE_NPC_RESULT', requestId, `无法加载世界书: ${primary}`); - const { createWorldInfoEntry } = await import("../../../../../world-info.js"); - const newE = createWorldInfoEntry(primary, wd); if (!newE) return replyErr('GENERATE_NPC_RESULT', requestId, '创建世界书条目失败'); - Object.assign(newE, { key: npc.aliases || [npc.name], comment: npc.name, content: formatNpcToWorldbookContent(npc), constant: false, selective: true, disable: false, position: typeof comm.npcPosition === 'number' ? comm.npcPosition : 0, order: typeof comm.npcOrder === 'number' ? comm.npcOrder : 100 }); - await saveWorldInfo(primary, wd, true); - reply('GENERATE_NPC_RESULT', requestId, { success: true, npcData: npc, worldbookUid: String(newE.uid), worldbook: primary }); - } catch (e) { replyErr('GENERATE_NPC_RESULT', requestId, `生成失败: ${e.message}`); } -} - -async function handleExtractStrangers({ requestId, existingContacts, existingStrangers }) { - try { - const comm = getCommSettings(); - const msgs = buildExtractStrangersMessages({ storyOutline: formatOutlinePrompt(), historyCount: comm.historyCount || 50, existingContacts: existingContacts || [], existingStrangers: existingStrangers || [] }); - const data = await callLLMJson({ messages: msgs, isArray: true, validate: V.arr }); - if (!Array.isArray(data)) return replyErr('EXTRACT_STRANGERS_RESULT', requestId, '提取失败:无法解析 JSON 数据'); - const strangers = data.filter(s => s?.name).map(s => ({ name: s.name, avatar: s.name[0] || '?', color: '#' + Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0'), location: s.location || '未知', info: s.info || '' })); - reply('EXTRACT_STRANGERS_RESULT', requestId, { success: true, strangers }); - } catch (e) { replyErr('EXTRACT_STRANGERS_RESULT', requestId, `提取失败: ${e.message}`); } -} - -async function handleSceneSwitch({ requestId, prevLocationName, prevLocationInfo, targetLocationName, targetLocationType, targetLocationInfo, playerAction }) { - try { - const store = getOutlineStore(), comm = getCommSettings(), mode = getGlobalSettings().mode || 'story'; - const msgs = buildSceneSwitchMessages({ prevLocationName: prevLocationName || '未知地点', prevLocationInfo: prevLocationInfo || '', targetLocationName: targetLocationName || '未知地点', targetLocationType: targetLocationType || 'sub', targetLocationInfo: targetLocationInfo || '', storyOutline: formatOutlinePrompt(), stage: store?.stage || 0, currentAtmosphere: getAtmosphere(store), historyCount: comm.historyCount || 50, playerAction: playerAction || '', mode }); - const data = await callLLMJson({ messages: msgs, validate: V.scene }); - if (!data || !V.scene(data)) return replyErr('SCENE_SWITCH_RESULT', requestId, '场景生成失败:无法解析 JSON 数据'); - const delta = data.review?.deviation?.score_delta || 0, old = store?.deviationScore || 0, newS = Math.min(100, Math.max(0, old + delta)); - if (store) { store.deviationScore = newS; if (targetLocationType !== 'home') store.simulationProgress = (store.simulationProgress || 0) + 1; saveMetadataDebounced?.(); } - const lm = data.local_map || data.scene_setup?.local_map || null; - reply('SCENE_SWITCH_RESULT', requestId, { success: true, sceneData: { review: data.review, localMap: lm, strangers: [], scoreDelta: delta, newScore: newS } }); - checkAutoSim(requestId); - } catch (e) { replyErr('SCENE_SWITCH_RESULT', requestId, `场景切换失败: ${e.message}`); } -} - -async function handleExecSlash({ command }) { - try { - if (typeof command !== 'string') return; - for (const line of command.split(/\r?\n/).map(l => l.trim()).filter(Boolean)) { - if (/^\/(send|sendas|as)\b/i.test(line)) await processCommands(line); - } - } catch (e) { console.warn('[Story Outline] Slash command failed:', e); } -} - -async function handleSendInvite({ requestId, contactName, contactUid, targetLocation, smsHistory }) { - try { - const comm = getCommSettings(); - let charC = ''; - if (contactUid) { const es = Object.values(world_info?.entries || world_info || {}); charC = es.find(e => e.uid?.toString() === contactUid.toString())?.content || ''; } - const msgs = buildInviteMessages({ contactName, userName: name1 || '{{user}}', targetLocation, storyOutline: formatOutlinePrompt(), historyCount: comm.historyCount || 50, smsHistoryContent: buildSmsHistoryContent(smsHistory || ''), characterContent: charC }); - const data = await callLLMJson({ messages: msgs, validate: V.inv }); - if (typeof data?.invite !== 'boolean') return replyErr('SEND_INVITE_RESULT', requestId, '邀请处理失败:无法解析 JSON 数据'); - reply('SEND_INVITE_RESULT', requestId, { success: true, inviteData: { accepted: data.invite, reply: data.reply, targetLocation } }); - } catch (e) { replyErr('SEND_INVITE_RESULT', requestId, `邀请处理失败: ${e.message}`); } -} - -async function handleGenLocalMap({ requestId, outdoorDescription }) { - try { - const msgs = buildLocalMapGenMessages({ storyOutline: formatOutlinePrompt(), outdoorDescription: outdoorDescription || '', historyCount: getCommSettings().historyCount || 50 }); - const data = await callLLMJson({ messages: msgs, validate: V.lm }); - if (!data?.inside) return replyErr('GENERATE_LOCAL_MAP_RESULT', requestId, '局部地图生成失败:无法解析 JSON 数据'); - reply('GENERATE_LOCAL_MAP_RESULT', requestId, { success: true, localMapData: data.inside }); - } catch (e) { replyErr('GENERATE_LOCAL_MAP_RESULT', requestId, `局部地图生成失败: ${e.message}`); } -} - -async function handleRefreshLocalMap({ requestId, locationName, currentLocalMap, outdoorDescription }) { - try { - const store = getOutlineStore(), comm = getCommSettings(); - const msgs = buildLocalMapRefreshMessages({ storyOutline: formatOutlinePrompt(), locationName: locationName || store?.playerLocation || '未知地点', locationInfo: currentLocalMap?.description || '', currentLocalMap: currentLocalMap || null, outdoorDescription: outdoorDescription || '', historyCount: comm.historyCount || 50, playerLocation: store?.playerLocation }); - const data = await callLLMJson({ messages: msgs, validate: V.lm }); - if (!data?.inside) return replyErr('REFRESH_LOCAL_MAP_RESULT', requestId, '局部地图刷新失败:无法解析 JSON 数据'); - reply('REFRESH_LOCAL_MAP_RESULT', requestId, { success: true, localMapData: data.inside }); - } catch (e) { replyErr('REFRESH_LOCAL_MAP_RESULT', requestId, `局部地图刷新失败: ${e.message}`); } -} - -async function handleGenLocalScene({ requestId, locationName, locationInfo }) { - try { - const store = getOutlineStore(), comm = getCommSettings(), mode = getGlobalSettings().mode || 'story'; - const msgs = buildLocalSceneGenMessages({ storyOutline: formatOutlinePrompt(), locationName: locationName || store?.playerLocation || '未知地点', locationInfo: locationInfo || '', stage: store?.stage || 0, currentAtmosphere: getAtmosphere(store), historyCount: comm.historyCount || 50, mode, playerLocation: store?.playerLocation }); - const data = await callLLMJson({ messages: msgs, validate: V.lscene }); - if (!data || !V.lscene(data)) return replyErr('GENERATE_LOCAL_SCENE_RESULT', requestId, '局部剧情生成失败:无法解析 JSON 数据'); - if (store) { store.simulationProgress = (store.simulationProgress || 0) + 1; saveMetadataDebounced?.(); } - const ssf = data.side_story || null, intro = ssf?.Introduce || ssf?.introduce || ''; - const ss = ssf ? (() => { const { Introduce, introduce: i2, story, ...rest } = ssf; return rest; })() : null; - reply('GENERATE_LOCAL_SCENE_RESULT', requestId, { success: true, sceneSetup: { sideStory: ss, review: data.review || null }, introduce: intro, loc: locationName }); - checkAutoSim(requestId); - } catch (e) { replyErr('GENERATE_LOCAL_SCENE_RESULT', requestId, `局部剧情生成失败: ${e.message}`); } -} - -async function handleGenWorld({ requestId, playerRequests }) { - try { - const comm = getCommSettings(), mode = getGlobalSettings().mode || 'story', store = getOutlineStore(); - - // 递归查找函数 - 在任意层级找到目标键 - const deepFind = (obj, key) => { - if (!obj || typeof obj !== 'object') return null; - if (obj[key] !== undefined) return obj[key]; - for (const v of Object.values(obj)) { - const found = deepFind(v, key); - if (found !== null) return found; - } - return null; - }; - - const normalizeStep1Data = (data) => { - if (!data || typeof data !== 'object') return null; - - // 构建标准化结构,从任意位置提取数据 - const result = { meta: {} }; - - // 提取 truth(可能在 meta.truth, data.truth, 或者 data 本身就是 truth) - result.meta.truth = deepFind(data, 'truth') - || (data.background && data.driver ? data : null) - || { background: deepFind(data, 'background'), driver: deepFind(data, 'driver') }; - - // 提取 onion_layers - result.meta.onion_layers = deepFind(data, 'onion_layers') || {}; - - // 统一洋葱层级为数组格式 - ['L1_The_Veil', 'L2_The_Distortion', 'L3_The_Law', 'L4_The_Agent', 'L5_The_Axiom'].forEach(k => { - const v = result.meta.onion_layers[k]; - if (v && !Array.isArray(v) && typeof v === 'object') { - result.meta.onion_layers[k] = [v]; - } - }); - - // 提取 atmosphere - result.meta.atmosphere = deepFind(data, 'atmosphere') || { reasoning: '', current: { environmental: '', npc_attitudes: '' } }; - - // 提取 trajectory - result.meta.trajectory = deepFind(data, 'trajectory') || { reasoning: '', ending: '' }; - - // 提取 user_guide - result.meta.user_guide = deepFind(data, 'user_guide') || { current_state: '', guides: [] }; - - return result; - }; - - // 辅助模式 - if (mode === 'assist') { - const msgs = buildWorldGenStep2Messages({ historyCount: comm.historyCount || 50, playerRequests, mode: 'assist' }); - const wd = await callLLMJson({ messages: msgs, validate: V.wga }); - if (!wd?.maps?.outdoor || !Array.isArray(wd.maps.outdoor.nodes)) return replyErr('GENERATE_WORLD_RESULT', requestId, '生成失败:返回数据缺少地图节点'); - if (store) { Object.assign(store, { stage: 0, deviationScore: 0, simulationProgress: 0, simulationTarget: randRange(3, 7) }); store.outlineData = { ...wd }; saveMetadataDebounced?.(); } - return reply('GENERATE_WORLD_RESULT', requestId, { success: true, worldData: wd }); - } - - // Step 1 - postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '正在构思世界大纲 (Step 1/2)...' }); - const s1m = buildWorldGenStep1Messages({ historyCount: comm.historyCount || 50, playerRequests }); - const s1d = normalizeStep1Data(await callLLMJson({ messages: s1m, validate: V.wg1 })); - - // 简化验证 - 只要有基本数据就行 - if (!s1d?.meta) { - return replyErr('GENERATE_WORLD_RESULT', requestId, 'Step 1 失败:无法解析大纲数据,请重试'); - } - step1Cache = { step1Data: s1d, playerRequests: playerRequests || '' }; - - // Step 2 - postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: 'Step 1 完成,1 秒后开始构建世界细节 (Step 2/2)...' }); - await new Promise(r => setTimeout(r, 1000)); - postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '正在构建世界细节 (Step 2/2)...' }); - - const s2m = buildWorldGenStep2Messages({ historyCount: comm.historyCount || 50, playerRequests, step1Data: s1d }); - const s2d = await callLLMJson({ messages: s2m, validate: V.wg2 }); - if (s2d?.world?.maps && !s2d?.maps) { s2d.maps = s2d.world.maps; delete s2d.world.maps; } - if (!s2d?.world || !s2d?.maps) return replyErr('GENERATE_WORLD_RESULT', requestId, 'Step 2 失败:无法生成有效的地图'); - - const final = { meta: s1d.meta, world: s2d.world, maps: s2d.maps, playerLocation: s2d.playerLocation }; - step1Cache = null; - if (store) { Object.assign(store, { stage: 0, deviationScore: 0, simulationProgress: 0, simulationTarget: randRange(3, 7) }); store.outlineData = final; saveMetadataDebounced?.(); } - reply('GENERATE_WORLD_RESULT', requestId, { success: true, worldData: final }); - } catch (e) { replyErr('GENERATE_WORLD_RESULT', requestId, `生成失败: ${e.message}`); } -} - -async function handleRetryStep2({ requestId }) { - try { - if (!step1Cache?.step1Data?.meta) return replyErr('GENERATE_WORLD_RESULT', requestId, 'Step 2 重试失败:缺少 Step 1 数据,请重新开始生成'); - const comm = getCommSettings(), store = getOutlineStore(), s1d = step1Cache.step1Data, pr = step1Cache.playerRequests || ''; - - postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '1 秒后重试构建世界细节 (Step 2/2)...' }); - await new Promise(r => setTimeout(r, 1000)); - postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '正在重试构建世界细节 (Step 2/2)...' }); - - const s2m = buildWorldGenStep2Messages({ historyCount: comm.historyCount || 50, playerRequests: pr, step1Data: s1d }); - const s2d = await callLLMJson({ messages: s2m, validate: V.wg2 }); - if (s2d?.world?.maps && !s2d?.maps) { s2d.maps = s2d.world.maps; delete s2d.world.maps; } - if (!s2d?.world || !s2d?.maps) return replyErr('GENERATE_WORLD_RESULT', requestId, 'Step 2 失败:无法生成有效的地图'); - - const final = { meta: s1d.meta, world: s2d.world, maps: s2d.maps, playerLocation: s2d.playerLocation }; - step1Cache = null; - if (store) { Object.assign(store, { stage: 0, deviationScore: 0, simulationProgress: 0, simulationTarget: randRange(3, 7) }); store.outlineData = final; saveMetadataDebounced?.(); } - reply('GENERATE_WORLD_RESULT', requestId, { success: true, worldData: final }); - } catch (e) { replyErr('GENERATE_WORLD_RESULT', requestId, `Step 2 重试失败: ${e.message}`); } -} - -async function handleSimWorld({ requestId, currentData, isAuto }) { - try { - const store = getOutlineStore(), mode = getGlobalSettings().mode || 'story'; - const msgs = buildWorldSimMessages({ mode, currentWorldData: currentData || '{}', historyCount: getCommSettings().historyCount || 50, deviationScore: store?.deviationScore || 0 }); - const data = await callLLMJson({ messages: msgs, validate: V.w }); - if (!data || !V.w(data)) return replyErr('SIMULATE_WORLD_RESULT', requestId, mode === 'assist' ? '世界推演失败:无法解析 JSON 数据(需包含 world 或 maps 字段)' : '世界推演失败:无法解析 JSON 数据'); - const orig = safe(() => JSON.parse(currentData)) || {}, merged = mergeSimData(orig, data); - if (store) { store.stage = (store.stage || 0) + 1; store.simulationProgress = 0; store.simulationTarget = randRange(3, 7); saveMetadataDebounced?.(); } - reply('SIMULATE_WORLD_RESULT', requestId, { success: true, simData: merged, isAuto: !!isAuto }); - } catch (e) { replyErr('SIMULATE_WORLD_RESULT', requestId, `推演失败: ${e.message}`); } -} - -function handleSaveSettings(d) { - if (d.globalSettings) saveGlobalSettings(d.globalSettings); - if (d.commSettings) saveCommSettings(d.commSettings); - const store = getOutlineStore(); - if (store) { - ['stage', 'deviationScore', 'simulationProgress', 'simulationTarget', 'playerLocation'].forEach(k => { if (d[k] !== undefined) store[k] = d[k]; }); - if (d.dataChecked) store.dataChecked = d.dataChecked; - if (d.allData) store.outlineData = d.allData; - store.updatedAt = Date.now(); - saveMetadataDebounced?.(); - } - injectOutline(); -} - -function handleSavePrompts(d) { - if (!d?.promptConfig) return; - setPromptConfig?.(d.promptConfig, true); - postFrame({ type: "PROMPT_CONFIG_UPDATED", promptConfig: getPromptConfigPayload?.() }); -} - -function handleSaveContacts(d) { - const store = getOutlineStore(); if (!store) return; - store.outlineData ||= {}; - if (d.contacts) store.outlineData.contacts = d.contacts; - if (d.strangers) store.outlineData.strangers = d.strangers; - store.updatedAt = Date.now(); - saveMetadataDebounced?.(); - injectOutline(); -} - -function handleSaveAllData(d) { - const store = getOutlineStore(); - if (store && d.allData) { - store.outlineData = d.allData; - if (d.playerLocation !== undefined) store.playerLocation = d.playerLocation; - store.updatedAt = Date.now(); - saveMetadataDebounced?.(); - injectOutline(); - } -} - -function handleSaveCharSmsHistory(d) { - const h = getCharSmsHistory(); - if (!h) return; - const sums = d?.summaries ?? d?.history?.summaries; - if (!sums || typeof sums !== 'object' || Array.isArray(sums)) return; - h.summaries = sums; - saveMetadataDebounced?.(); - injectOutline(); -} - -// 处理器映射 -const handlers = { - FRAME_READY: () => { frameReady = true; flushPending(); loadAndSend(); }, - CLOSE_PANEL: hideOverlay, - SAVE_MAP_DATA: d => { const s = getOutlineStore(); if (s && d.mapData) { s.mapData = d.mapData; s.updatedAt = Date.now(); saveMetadataDebounced?.(); } }, - GET_SETTINGS: sendSettings, - SAVE_SETTINGS: handleSaveSettings, - SAVE_PROMPTS: handleSavePrompts, - SAVE_CONTACTS: handleSaveContacts, - SAVE_ALL_DATA: handleSaveAllData, - FETCH_MODELS: handleFetchModels, - TEST_CONNECTION: handleTestConn, - CHECK_WORLDBOOK_UID: handleCheckUid, - SEND_SMS: handleSendSms, - LOAD_SMS_HISTORY: handleLoadSmsHistory, - SAVE_SMS_HISTORY: handleSaveSmsHistory, - SAVE_CHAR_SMS_HISTORY: handleSaveCharSmsHistory, - COMPRESS_SMS: handleCompressSms, - CHECK_STRANGER_WORLDBOOK: handleCheckStrangerWb, - GENERATE_NPC: handleGenNpc, - EXTRACT_STRANGERS: handleExtractStrangers, - SCENE_SWITCH: handleSceneSwitch, - EXECUTE_SLASH_COMMAND: handleExecSlash, - SEND_INVITE: handleSendInvite, - GENERATE_WORLD: handleGenWorld, - RETRY_WORLD_GEN_STEP2: handleRetryStep2, - SIMULATE_WORLD: handleSimWorld, - GENERATE_LOCAL_MAP: handleGenLocalMap, - REFRESH_LOCAL_MAP: handleRefreshLocalMap, - GENERATE_LOCAL_SCENE: handleGenLocalScene -}; - -const handleMsg = ({ data }) => { if (data?.source === "LittleWhiteBox-OutlineFrame") handlers[data.type]?.(data); }; - -// ==================== 10. UI管理 ==================== - -/** 指针拖拽 */ -function setupDrag(el, { onStart, onMove, onEnd, shouldHandle }) { - if (!el) return; - let state = null; - el.addEventListener('pointerdown', e => { if (shouldHandle && !shouldHandle()) return; e.preventDefault(); e.stopPropagation(); state = onStart(e); state.pointerId = e.pointerId; el.setPointerCapture(e.pointerId); }); - el.addEventListener('pointermove', e => state && onMove(e, state)); - const end = () => { if (!state) return; onEnd?.(state); try { el.releasePointerCapture(state.pointerId); } catch { } state = null; }; - ['pointerup', 'pointercancel', 'lostpointercapture'].forEach(ev => el.addEventListener(ev, end)); -} - -/** 创建Overlay */ -function createOverlay() { - if (overlayCreated) return; - overlayCreated = true; - document.body.appendChild($(buildOverlayHtml(IFRAME_PATH))[0]); - const overlay = document.getElementById("xiaobaix-story-outline-overlay"), wrap = overlay.querySelector(".xb-so-frame-wrap"), iframe = overlay.querySelector("iframe"); - const setPtr = v => iframe && (iframe.style.pointerEvents = v); - - // 拖拽 - setupDrag(overlay.querySelector(".xb-so-drag-handle"), { - shouldHandle: () => !isMobile(), - onStart(e) { const r = wrap.getBoundingClientRect(), ro = overlay.getBoundingClientRect(); wrap.style.left = (r.left - ro.left) + 'px'; wrap.style.top = (r.top - ro.top) + 'px'; wrap.style.transform = ''; setPtr('none'); return { sx: e.clientX, sy: e.clientY, sl: parseFloat(wrap.style.left), st: parseFloat(wrap.style.top) }; }, - onMove(e, s) { wrap.style.left = Math.max(0, Math.min(overlay.clientWidth - wrap.offsetWidth, s.sl + e.clientX - s.sx)) + 'px'; wrap.style.top = Math.max(0, Math.min(overlay.clientHeight - wrap.offsetHeight, s.st + e.clientY - s.sy)) + 'px'; }, - onEnd: () => setPtr('') - }); - - // 缩放 - setupDrag(overlay.querySelector(".xb-so-resize-handle"), { - shouldHandle: () => !isMobile(), - onStart(e) { const r = wrap.getBoundingClientRect(), ro = overlay.getBoundingClientRect(); wrap.style.left = (r.left - ro.left) + 'px'; wrap.style.top = (r.top - ro.top) + 'px'; wrap.style.transform = ''; setPtr('none'); return { sx: e.clientX, sy: e.clientY, sw: wrap.offsetWidth, sh: wrap.offsetHeight, ratio: wrap.offsetWidth / wrap.offsetHeight }; }, - onMove(e, s) { const dx = e.clientX - s.sx, dy = e.clientY - s.sy, delta = Math.abs(dx) > Math.abs(dy) ? dx : dy * s.ratio; let w = Math.max(400, Math.min(window.innerWidth * 0.95, s.sw + delta)), h = w / s.ratio; if (h > window.innerHeight * 0.9) { h = window.innerHeight * 0.9; w = h * s.ratio; } if (h < 300) { h = 300; w = h * s.ratio; } wrap.style.width = w + 'px'; wrap.style.height = h + 'px'; }, - onEnd: () => setPtr('') - }); - - // 移动端 - setupDrag(overlay.querySelector(".xb-so-resize-mobile"), { - shouldHandle: () => isMobile(), - onStart(e) { setPtr('none'); return { sy: e.clientY, sh: wrap.offsetHeight }; }, - onMove(e, s) { wrap.style.height = Math.max(200, Math.min(window.innerHeight * 0.9, s.sh + e.clientY - s.sy)) + 'px'; }, - onEnd: () => setPtr('') - }); - - window.addEventListener("message", handleMsg); -} - -function updateLayout() { - const wrap = document.querySelector(".xb-so-frame-wrap"); if (!wrap) return; - const drag = document.querySelector(".xb-so-drag-handle"), resize = document.querySelector(".xb-so-resize-handle"), mobile = document.querySelector(".xb-so-resize-mobile"); - if (isMobile()) { if (drag) drag.style.display = 'none'; if (resize) resize.style.display = 'none'; if (mobile) mobile.style.display = 'flex'; wrap.style.cssText = MOBILE_LAYOUT_STYLE; } - else { if (drag) drag.style.display = 'block'; if (resize) resize.style.display = 'block'; if (mobile) mobile.style.display = 'none'; wrap.style.cssText = DESKTOP_LAYOUT_STYLE; } -} - -function showOverlay() { if (!overlayCreated) createOverlay(); frameReady = false; const f = document.getElementById("xiaobaix-story-outline-iframe"); if (f) f.src = IFRAME_PATH; updateLayout(); $("#xiaobaix-story-outline-overlay").show(); } -function hideOverlay() { $("#xiaobaix-story-outline-overlay").hide(); } - -$(window).on('resize', () => { if ($("#xiaobaix-story-outline-overlay").is(':visible')) updateLayout(); }); - -// ==================== 11. 事件与初始化 ==================== - -let eventsRegistered = false; - -function addBtnToMsg(mesId) { - if (!getSettings().storyOutline?.enabled) return; - const msg = document.querySelector(`#chat .mes[mesid="${mesId}"]`); - if (!msg || msg.querySelector('.xiaobaix-story-outline-btn')) return; - const btn = document.createElement('div'); - btn.className = 'mes_btn xiaobaix-story-outline-btn'; - btn.title = '剧情地图'; - btn.dataset.mesid = mesId; - btn.innerHTML = ''; - btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); if (!getSettings().storyOutline?.enabled) return; currentMesId = Number(mesId); showOverlay(); loadAndSend(); }); - if (window.registerButtonToSubContainer?.(mesId, btn)) return; - msg.querySelector('.flex-container.flex1.alignitemscenter')?.appendChild(btn); -} - -function initBtns() { - if (!getSettings().storyOutline?.enabled) return; - $("#chat .mes").each((_, el) => { const id = el.getAttribute("mesid"); if (id != null) addBtnToMsg(id); }); -} - -function registerEvents() { - if (eventsRegistered) return; - eventsRegistered = true; - - initBtns(); - - events.on(event_types.CHAT_CHANGED, () => { setTimeout(initBtns, 80); setTimeout(injectOutline, 100); }); - events.on(event_types.GENERATION_STARTED, injectOutline); - - const handler = d => setTimeout(() => { - const id = d?.element ? $(d.element).attr("mesid") : d?.messageId; - id == null ? initBtns() : addBtnToMsg(id); - }, 50); - - events.onMany([ - event_types.USER_MESSAGE_RENDERED, - event_types.CHARACTER_MESSAGE_RENDERED, - event_types.MESSAGE_RECEIVED, - event_types.MESSAGE_UPDATED, - event_types.MESSAGE_SWIPED, - event_types.MESSAGE_EDITED - ], handler); - - setupSTEvents(); -} - -function cleanup() { - events.cleanup(); - eventsRegistered = false; - $(".xiaobaix-story-outline-btn").remove(); - hideOverlay(); - overlayCreated = false; frameReady = false; pendingMsgs = []; - window.removeEventListener("message", handleMsg); - document.getElementById("xiaobaix-story-outline-overlay")?.remove(); - removePrompt(); - if (presetCleanup) { presetCleanup(); presetCleanup = null; } -} - -// ==================== Toggle 监听(始终注册)==================== - -$(document).on("xiaobaix:storyOutline:toggle", (_e, enabled) => { - if (enabled) { - registerEvents(); - initBtns(); - injectOutline(); - } else { - cleanup(); - } -}); - -document.addEventListener('xiaobaixEnabledChanged', e => { - if (!e?.detail?.enabled) { - cleanup(); - } else if (getSettings().storyOutline?.enabled) { - registerEvents(); - initBtns(); - injectOutline(); - } -}); - -// ==================== 初始化 ==================== - -jQuery(() => { - if (!getSettings().storyOutline?.enabled) return; - registerEvents(); - setTimeout(injectOutline, 200); - window.registerModuleCleanup?.('storyOutline', cleanup); -}); - -export { cleanup }; +/** + * ============================================================================ + * Story Outline 模块 - 小白板 + * ============================================================================ + * 功能:生成和管理RPG式剧情世界,提供地图导航、NPC管理、短信系统、世界推演 + * + * 分区: + * 1. 导入与常量 + * 2. 通用工具 + * 3. JSON解析 + * 4. 存储管理 + * 5. LLM调用 + * 6. 世界书操作 + * 7. 剧情注入 + * 8. iframe通讯 + * 9. 请求处理器 + * 10. UI管理 + * 11. 事件与初始化 + * ============================================================================ + */ + +// ==================== 1. 导入与常量 ==================== +import { extension_settings, saveMetadataDebounced } from "../../../../../extensions.js"; +import { chat_metadata, name1, processCommands, eventSource, event_types as st_event_types } from "../../../../../../script.js"; +import { loadWorldInfo, saveWorldInfo, world_names, world_info } from "../../../../../world-info.js"; +import { getContext } from "../../../../../st-context.js"; +import { streamingGeneration } from "../streaming-generation.js"; +import { EXT_ID, extensionFolderPath } from "../../core/constants.js"; +import { createModuleEvents, event_types } from "../../core/event-manager.js"; +import { promptManager } from "../../../../../openai.js"; +import { + buildSmsMessages, buildSummaryMessages, buildSmsHistoryContent, buildExistingSummaryContent, + buildNpcGenerationMessages, formatNpcToWorldbookContent, buildExtractStrangersMessages, + buildWorldGenStep1Messages, buildWorldGenStep2Messages, buildWorldSimMessages, buildSceneSwitchMessages, + buildInviteMessages, buildLocalMapGenMessages, buildLocalMapRefreshMessages, buildLocalSceneGenMessages, + buildOverlayHtml, MOBILE_LAYOUT_STYLE, DESKTOP_LAYOUT_STYLE, getPromptConfigPayload, setPromptConfig +} from "./story-outline-prompt.js"; + +const events = createModuleEvents('storyOutline'); +const IFRAME_PATH = `${extensionFolderPath}/modules/story-outline/story-outline.html`; +const STORAGE_KEYS = { global: 'LittleWhiteBox_StoryOutline_GlobalSettings', comm: 'LittleWhiteBox_StoryOutline_CommSettings' }; +const STORY_OUTLINE_ID = 'lwb_story_outline'; +const CHAR_CARD_UID = '__CHARACTER_CARD__'; +const DEBUG_KEY = 'LittleWhiteBox_StoryOutline_Debug'; + +let overlayCreated = false, frameReady = false, currentMesId = null, pendingMsgs = [], presetCleanup = null, step1Cache = null; + +// ==================== 2. 通用工具 ==================== + +/** 移动端检测 */ +const isMobile = () => window.innerWidth < 550; + +/** 安全执行函数 */ +const safe = fn => { try { return fn(); } catch { return null; } }; +const isDebug = () => { + try { return localStorage.getItem(DEBUG_KEY) === '1'; } catch { return false; } +}; + +/** localStorage读写 */ +const getStore = (k, def) => safe(() => JSON.parse(localStorage.getItem(k))) || def; +const setStore = (k, v) => safe(() => localStorage.setItem(k, JSON.stringify(v))); + +/** 随机范围 */ +const randRange = (a, b) => Math.floor(Math.random() * (b - a + 1)) + a; + +/** + * 修复单个 JSON 字符串的语法问题 + * 仅在已提取的候选上调用,不做全局破坏性操作 + */ +function fixJson(s) { + if (!s || typeof s !== 'string') return s; + + let r = s.trim() + // 统一引号:只转换弯引号 + .replace(/[""]/g, '"').replace(/['']/g, "'") + // 修复键名后的错误引号:如 "key': → "key": + .replace(/"([^"']+)'[\s]*:/g, '"$1":') + .replace(/'([^"']+)"[\s]*:/g, '"$1":') + // 修复单引号包裹的完整值:: 'value' → : "value" + .replace(/:[\s]*'([^']*)'[\s]*([,}\]])/g, ':"$1"$2') + // 修复无引号的键名 + .replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":') + // 移除尾随逗号 + .replace(/,[\s\n]*([}\]])/g, '$1') + // 修复 undefined 和 NaN + .replace(/:\s*undefined\b/g, ': null').replace(/:\s*NaN\b/g, ': null'); + + // 补全未闭合的括号 + let braces = 0, brackets = 0, inStr = false, esc = false; + for (const c of r) { + if (esc) { esc = false; continue; } + if (c === '\\' && inStr) { esc = true; continue; } + if (c === '"') { inStr = !inStr; continue; } + if (!inStr) { + if (c === '{') braces++; else if (c === '}') braces--; + if (c === '[') brackets++; else if (c === ']') brackets--; + } + } + while (braces-- > 0) r += '}'; + while (brackets-- > 0) r += ']'; + return r; +} + +/** + * 从输入中提取 JSON(非破坏性扫描版) + * 策略: + * 1. 直接在原始字符串中扫描所有 {...} 结构 + * 2. 对每个候选单独清洗和解析 + * 3. 按有效属性评分,返回最佳结果 + */ +function extractJson(input, isArray = false) { + if (!input) return null; + + // 处理已经是对象的输入 + if (typeof input === 'object' && input !== null) { + if (isArray && Array.isArray(input)) return input; + if (!isArray && !Array.isArray(input)) { + const content = input.choices?.[0]?.message?.content + ?? input.choices?.[0]?.message?.reasoning_content + ?? input.content ?? input.reasoning_content; + if (content != null) return extractJson(String(content).trim(), isArray); + if (!input.choices) return input; + } + return null; + } + + // 预处理:只做最基本的清理 + const str = String(input).trim() + .replace(/^\uFEFF/, '') + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') + .replace(/\r\n?/g, '\n'); + if (!str) return null; + + const tryParse = s => { try { return JSON.parse(s); } catch { return null; } }; + const ok = (o, arr) => o != null && (arr ? Array.isArray(o) : typeof o === 'object' && !Array.isArray(o)); + + // 评分函数:meta=10, world/maps=5, 其他=3 + const score = o => (o?.meta ? 10 : 0) + (o?.world ? 5 : 0) + (o?.maps ? 5 : 0) + + (o?.truth ? 3 : 0) + (o?.onion_layers ? 3 : 0) + (o?.atmosphere ? 3 : 0) + (o?.trajectory ? 3 : 0) + (o?.user_guide ? 3 : 0); + + // 1. 直接尝试解析(最理想情况) + let r = tryParse(str); + if (ok(r, isArray) && score(r) > 0) return r; + + // 2. 扫描所有 {...} 或 [...] 结构 + const open = isArray ? '[' : '{'; + const candidates = []; + + for (let i = 0; i < str.length; i++) { + if (str[i] !== open) continue; + + // 括号匹配找闭合位置 + let depth = 0, inStr = false, esc = false; + for (let j = i; j < str.length; j++) { + const c = str[j]; + if (esc) { esc = false; continue; } + if (c === '\\' && inStr) { esc = true; continue; } + if (c === '"') { inStr = !inStr; continue; } + if (inStr) continue; + if (c === '{' || c === '[') depth++; + else if (c === '}' || c === ']') depth--; + if (depth === 0) { + candidates.push({ start: i, end: j, text: str.slice(i, j + 1) }); + i = j; // 跳过已处理的部分 + break; + } + } + } + + // 3. 按长度排序(大的优先,更可能是完整对象) + candidates.sort((a, b) => b.text.length - a.text.length); + + // 4. 尝试解析每个候选,记录最佳结果 + let best = null, bestScore = -1; + + for (const { text } of candidates) { + // 直接解析 + r = tryParse(text); + if (ok(r, isArray)) { + const s = score(r); + if (s > bestScore) { best = r; bestScore = s; } + if (s >= 10) return r; // 有 meta 就直接返回 + continue; + } + + // 修复后解析 + const fixed = fixJson(text); + r = tryParse(fixed); + if (ok(r, isArray)) { + const s = score(r); + if (s > bestScore) { best = r; bestScore = s; } + if (s >= 10) return r; + } + } + + // 5. 返回最佳结果 + if (best) return best; + + // 6. 最后尝试:取第一个 { 到最后一个 } 之间的内容 + const firstBrace = str.indexOf('{'); + const lastBrace = str.lastIndexOf('}'); + if (!isArray && firstBrace !== -1 && lastBrace > firstBrace) { + const chunk = str.slice(firstBrace, lastBrace + 1); + r = tryParse(chunk) || tryParse(fixJson(chunk)); + if (ok(r, isArray)) return r; + } + + return null; +} + +export { extractJson }; + +// ==================== 4. 存储管理 ==================== + +/** 获取扩展设置 */ +const getSettings = () => { const e = extension_settings[EXT_ID] ||= {}; e.storyOutline ||= { enabled: true }; return e; }; + +/** 获取剧情大纲存储 */ +function getOutlineStore() { + if (!chat_metadata) return null; + const ext = chat_metadata.extensions ||= {}, lwb = ext[EXT_ID] ||= {}; + return lwb.storyOutline ||= { + mapData: null, stage: 0, deviationScore: 0, simulationProgress: 0, simulationTarget: 5, playerLocation: '家', + outlineData: { meta: null, world: null, outdoor: null, indoor: null, sceneSetup: null, strangers: null, contacts: null }, + dataChecked: { meta: true, world: true, outdoor: true, indoor: true, sceneSetup: true, strangers: false, contacts: false, characterContactSms: false } + }; +} + +/** 全局/通讯设置读写 */ +const getGlobalSettings = () => getStore(STORAGE_KEYS.global, { apiUrl: '', apiKey: '', model: '', mode: 'assist' }); +const saveGlobalSettings = s => setStore(STORAGE_KEYS.global, s); +const getCommSettings = () => ({ historyCount: 50, npcPosition: 0, npcOrder: 100, ...getStore(STORAGE_KEYS.comm, {}) }); +const saveCommSettings = s => setStore(STORAGE_KEYS.comm, s); + +/** 获取角色卡信息 */ +function getCharInfo() { + const ctx = getContext(), char = ctx.characters?.[ctx.characterId]; + return { + name: char?.name || char?.data?.name || char?.avatar || '角色卡', + desc: String((char?.description ?? char?.data?.description ?? '') || '').trim() || '{{description}}' + }; +} + +/** 获取角色卡短信历史 */ +function getCharSmsHistory() { + if (!chat_metadata) return null; + const root = chat_metadata.LittleWhiteBox_StoryOutline ||= {}; + const h = root.characterContactSmsHistory ||= { messages: [], summarizedCount: 0, summaries: {} }; + h.messages ||= []; h.summarizedCount ||= 0; h.summaries ||= {}; + return h; +} + +// ==================== 5. LLM调用 ==================== + + +/** 调用LLM */ +async function callLLM(promptOrMsgs, useRaw = false) { + const { apiUrl, apiKey, model } = getGlobalSettings(); + + const normalize = r => { + if (r == null) return ''; + if (typeof r === 'string') return r; + if (typeof r === 'object') { + if (r.data && typeof r.data === 'object') return normalize(r.data); + if (typeof r.text === 'string') return r.text; + if (typeof r.response === 'string') return r.response; + const inner = r.content?.trim?.() || r.reasoning_content?.trim?.() || r.choices?.[0]?.message?.content?.trim?.() || r.choices?.[0]?.message?.reasoning_content?.trim?.() || null; + if (inner != null) return String(inner); + return safe(() => JSON.stringify(r)) || String(r); + } + return String(r); + }; + + // 构建基础选项 + const opts = { nonstream: 'true', lock: 'on' }; + if (apiUrl?.trim()) Object.assign(opts, { api: 'openai', apiurl: apiUrl.trim(), ...(apiKey && { apipassword: apiKey }), ...(model && { model }) }); + + if (useRaw) { + const messages = Array.isArray(promptOrMsgs) + ? promptOrMsgs + : [{ role: 'user', content: String(promptOrMsgs || '').trim() }]; + + // 直接把消息转成 top 参数格式,不做预处理 + // {$worldInfo} 和 {$historyN} 由 xbgenrawCommand 内部处理 + const roleMap = { user: 'user', assistant: 'assistant', system: 'sys' }; + const topParts = messages + .filter(m => m?.role && typeof m.content === 'string' && m.content.trim()) + .map(m => { + const role = roleMap[m.role] || m.role; + return `${role}={${m.content}}`; + }); + const topParam = topParts.join(';'); + + opts.top = topParam; + // 不设置 addon,让 xbgenrawCommand 自己处理 {$worldInfo} 占位符替换 + + const raw = await streamingGeneration.xbgenrawCommand(opts, ''); + const text = normalize(raw).trim(); + + if (isDebug()) { + try { + console.groupCollapsed('[StoryOutline] callLLM(useRaw via xbgenrawCommand)'); + console.log('opts.top.length', topParam.length); + console.log('raw', raw); + console.log('normalized.length', text.length); + console.groupEnd(); + } catch { } + } + return text; + } + + opts.as = 'user'; + opts.position = 'history'; + return normalize(await streamingGeneration.xbgenCommand(opts, promptOrMsgs)).trim(); +} + +/** 调用LLM并解析JSON */ +async function callLLMJson({ messages, useRaw = true, isArray = false, validate }) { + try { + const result = await callLLM(messages, useRaw); + if (isDebug()) { + try { + const s = String(result ?? ''); + console.groupCollapsed('[StoryOutline] callLLMJson'); + console.log({ useRaw, isArray, length: s.length }); + console.log('result.head', s.slice(0, 500)); + console.log('result.tail', s.slice(Math.max(0, s.length - 500))); + console.groupEnd(); + } catch { } + } + const parsed = extractJson(result, isArray); + if (isDebug()) { + try { + console.groupCollapsed('[StoryOutline] extractJson'); + console.log('parsed', parsed); + console.log('validate', !!(parsed && validate?.(parsed))); + console.groupEnd(); + } catch { } + } + if (parsed && validate(parsed)) return parsed; + } catch { } + return null; +} + +// ==================== 6. 世界书操作 ==================== + +/** 获取角色卡绑定的世界书 */ +async function getCharWorldbooks() { + const ctx = getContext(), char = ctx.characters?.[ctx.characterId]; + if (!char) return []; + const books = [], primary = char.data?.extensions?.world; + if (primary && world_names?.includes(primary)) books.push(primary); + (world_info?.charLore || []).find(e => e.name === char.avatar)?.extraBooks?.forEach(b => { + if (world_names?.includes(b) && !books.includes(b)) books.push(b); + }); + return books; +} + +/** 根据UID查找条目 */ +async function findEntry(uid) { + const uidNum = parseInt(uid, 10); + if (isNaN(uidNum)) return null; + for (const book of await getCharWorldbooks()) { + const data = await loadWorldInfo(book); + if (data?.entries?.[uidNum]) return { bookName: book, entry: data.entries[uidNum], uidNumber: uidNum, worldData: data }; + } + return null; +} + +/** 根据名称搜索条目 */ +async function searchEntry(name) { + const nl = (name || '').toLowerCase().trim(); + for (const book of await getCharWorldbooks()) { + const data = await loadWorldInfo(book); + if (!data?.entries) continue; + for (const [uid, entry] of Object.entries(data.entries)) { + const keys = Array.isArray(entry.key) ? entry.key : []; + if (keys.some(k => { const kl = (k || '').toLowerCase().trim(); return kl === nl || kl.includes(nl) || nl.includes(kl); })) + return { uid: String(uid), bookName: book, entry }; + } + } + return null; +} + +// ==================== 7. 剧情注入 ==================== + +/** 获取可见洋葱层级 */ +const getVisibleLayers = stage => ['L1_The_Veil', 'L2_The_Distortion', 'L3_The_Law', 'L4_The_Agent', 'L5_The_Axiom'].slice(0, Math.min(Math.max(0, stage), 3) + 2); + +/** 格式化剧情数据为提示词 */ +function formatOutlinePrompt() { + const store = getOutlineStore(); + if (!store?.outlineData) return ""; + + const { outlineData: d, dataChecked: c, playerLocation } = store, stage = store.stage ?? 0; + let text = "## Story Outline (剧情数据)\n\n", has = false; + + // 世界真相 + if (c?.meta && d.meta?.truth) { + has = true; + text += "### 世界真相 (World Truth)\n> 注意:以下信息仅供生成逻辑参考,不可告知玩家。\n"; + if (d.meta.truth.background) text += `* 背景真相: ${d.meta.truth.background}\n`; + const dr = d.meta.truth.driver; + if (dr) { if (dr.source) text += `* 驱动: ${dr.source}\n`; if (dr.target_end) text += `* 目的: ${dr.target_end}\n`; if (dr.tactic) text += `* 当前手段: ${dr.tactic}\n`; } + + // 当前气氛 + const atm = d.meta.atmosphere?.current; + if (atm) { + if (atm.environmental) text += `* 当前气氛: ${atm.environmental}\n`; + if (atm.npc_attitudes) text += `* NPC态度: ${atm.npc_attitudes}\n`; + } + + const onion = d.meta.onion_layers || d.meta.truth.onion_layers; + if (onion) { + text += "* 当前可见层级:\n"; + getVisibleLayers(stage).forEach(k => { + const l = onion[k]; if (!l || !Array.isArray(l) || !l.length) return; + const name = k.replace(/_/g, ' - '); + l.forEach(i => { text += ` - [${name}] ${i.desc}: ${i.logic}\n`; }); + }); + } + text += "\n"; + } + + // 世界资讯 + if (c?.world && d.world?.news?.length) { has = true; text += "### 世界资讯 (News)\n"; d.world.news.forEach(n => { text += `* ${n.title}: ${n.content}\n`; }); text += "\n"; } + + // 环境信息 + let mapC = "", locNode = null; + if (c?.outdoor && d.outdoor) { + if (d.outdoor.description) mapC += `> 大地图环境: ${d.outdoor.description}\n`; + if (playerLocation && d.outdoor.nodes?.length) locNode = d.outdoor.nodes.find(n => n.name === playerLocation); + } + if (!locNode && c?.indoor && d.indoor?.nodes?.length && playerLocation) locNode = d.indoor.nodes.find(n => n.name === playerLocation); + const indoorMap = (c?.indoor && playerLocation && d.indoor && typeof d.indoor === 'object' && !Array.isArray(d.indoor)) ? d.indoor[playerLocation] : null; + const locText = indoorMap?.description || locNode?.info || ''; + if (playerLocation && locText) mapC += `\n> 当前地点 (${playerLocation}):\n${locText}\n`; + if (c?.indoor && d.indoor && !locNode && !indoorMap && d.indoor.description) { mapC += d.indoor.name ? `\n> 当前地点: ${d.indoor.name}\n` : "\n> 局部区域:\n"; mapC += `${d.indoor.description}\n`; } + if (mapC) { has = true; text += `### 环境信息 (Environment)\n${mapC}\n`; } + + // 周边人物 + let charC = ""; + if (c?.contacts && d.contacts?.length) { charC += "* 联络人:\n"; d.contacts.forEach(p => charC += ` - ${p.name}${p.location ? ` @ ${p.location}` : ''}: ${p.info || ''}\n`); } + if (c?.strangers && d.strangers?.length) { charC += "* 陌路人:\n"; d.strangers.forEach(p => charC += ` - ${p.name}${p.location ? ` @ ${p.location}` : ''}: ${p.info || ''}\n`); } + if (charC) { has = true; text += `### 周边人物 (Characters)\n${charC}\n`; } + + // 当前剧情 + if (c?.sceneSetup && d.sceneSetup) { + const ss = d.sceneSetup.sideStory || d.sceneSetup.side_story || d.sceneSetup; + if (ss && (ss.surface || ss.inner)) { has = true; text += "### 当前剧情 (Current Scene)\n"; if (ss.surface) text += `* 表象: ${ss.surface}\n`; if (ss.inner) text += `* 里层 (潜台词): ${ss.inner}\n`; text += "\n"; } + } + + // 角色卡短信 + if (c?.characterContactSms) { + const { name: charName } = getCharInfo(), hist = getCharSmsHistory(); + const sums = hist?.summaries || {}, sumKeys = Object.keys(sums).filter(k => k !== '_count').sort((a, b) => a - b); + const msgs = hist?.messages || [], sc = hist?.summarizedCount || 0, rem = msgs.slice(sc); + if (sumKeys.length || rem.length) { + has = true; text += `### ${charName}短信记录\n`; + if (sumKeys.length) text += `[摘要] ${sumKeys.map(k => sums[k]).join(';')}\n`; + if (rem.length) text += rem.map(m => `${m.type === 'sent' ? '{{user}}' : charName}:${m.text}`).join('\n') + "\n"; + text += "\n"; + } + } + + return has ? text.trim() : ""; +} + +/** 确保剧情大纲Prompt存在 */ +function ensurePrompt() { + if (!promptManager) return false; + let prompt = promptManager.getPromptById(STORY_OUTLINE_ID); + if (!prompt) { + promptManager.addPrompt({ identifier: STORY_OUTLINE_ID, name: '剧情地图', role: 'system', content: '', system_prompt: false, marker: false, extension: true }, STORY_OUTLINE_ID); + prompt = promptManager.getPromptById(STORY_OUTLINE_ID); + } + const char = promptManager.activeCharacter; + if (!char) return true; + const order = promptManager.getPromptOrderForCharacter(char); + const exists = order.some(e => e.identifier === STORY_OUTLINE_ID); + if (!exists) { const idx = order.findIndex(e => e.identifier === 'charDescription'); order.splice(idx !== -1 ? idx : 0, 0, { identifier: STORY_OUTLINE_ID, enabled: true }); } + else { const entry = order.find(e => e.identifier === STORY_OUTLINE_ID); if (entry && !entry.enabled) entry.enabled = true; } + promptManager.render?.(false); + return true; +} + +/** 更新剧情大纲Prompt内容 */ +function updatePromptContent() { + if (!promptManager) return; + if (!getSettings().storyOutline?.enabled) { removePrompt(); return; } + ensurePrompt(); + const store = getOutlineStore(), prompt = promptManager.getPromptById(STORY_OUTLINE_ID); + if (!prompt) return; + const { dataChecked } = store || {}; + const hasAny = dataChecked && Object.values(dataChecked).some(v => v === true); + prompt.content = (!hasAny || !store) ? '' : (formatOutlinePrompt() || ''); + promptManager.render?.(false); +} + +/** 移除剧情大纲Prompt */ +function removePrompt() { + if (!promptManager) return; + const prompts = promptManager.serviceSettings?.prompts; + if (prompts) { const idx = prompts.findIndex(p => p?.identifier === STORY_OUTLINE_ID); if (idx !== -1) prompts.splice(idx, 1); } + const orders = promptManager.serviceSettings?.prompt_order; + if (orders) orders.forEach(cfg => { if (cfg?.order) { const idx = cfg.order.findIndex(e => e?.identifier === STORY_OUTLINE_ID); if (idx !== -1) cfg.order.splice(idx, 1); } }); + promptManager.render?.(false); +} + +/** 设置ST预设事件监听 */ +function setupSTEvents() { + if (presetCleanup) return; + const onChanged = () => { if (getSettings().storyOutline?.enabled) setTimeout(() => { ensurePrompt(); updatePromptContent(); }, 100); }; + const onExport = preset => { + if (!preset) return; + if (preset.prompts) { const i = preset.prompts.findIndex(p => p?.identifier === STORY_OUTLINE_ID); if (i !== -1) preset.prompts.splice(i, 1); } + if (preset.prompt_order) preset.prompt_order.forEach(c => { if (c?.order) { const i = c.order.findIndex(e => e?.identifier === STORY_OUTLINE_ID); if (i !== -1) c.order.splice(i, 1); } }); + }; + eventSource.on(st_event_types.OAI_PRESET_CHANGED_AFTER, onChanged); + eventSource.on(st_event_types.OAI_PRESET_EXPORT_READY, onExport); + presetCleanup = () => { try { eventSource.removeListener(st_event_types.OAI_PRESET_CHANGED_AFTER, onChanged); } catch { } try { eventSource.removeListener(st_event_types.OAI_PRESET_EXPORT_READY, onExport); } catch { } }; +} + +const injectOutline = () => updatePromptContent(); + +// ==================== 8. iframe通讯 ==================== + +/** 发送消息到iframe */ +function postFrame(payload) { + const iframe = document.getElementById("xiaobaix-story-outline-iframe"); + if (!iframe?.contentWindow || !frameReady) { pendingMsgs.push(payload); return; } + iframe.contentWindow.postMessage({ source: "LittleWhiteBox", ...payload }, "*"); +} + +const flushPending = () => { if (!frameReady) return; const f = document.getElementById("xiaobaix-story-outline-iframe"); pendingMsgs.forEach(p => f?.contentWindow?.postMessage({ source: "LittleWhiteBox", ...p }, "*")); pendingMsgs = []; }; + +/** 发送设置到iframe */ +function sendSettings() { + const store = getOutlineStore(), { name: charName, desc: charDesc } = getCharInfo(); + postFrame({ + type: "LOAD_SETTINGS", globalSettings: getGlobalSettings(), commSettings: getCommSettings(), + stage: store?.stage ?? 0, deviationScore: store?.deviationScore ?? 0, simulationProgress: store?.simulationProgress ?? 0, + simulationTarget: store?.simulationTarget ?? 5, playerLocation: store?.playerLocation ?? '家', + dataChecked: store?.dataChecked || {}, outlineData: store?.outlineData || {}, promptConfig: getPromptConfigPayload?.(), + characterCardName: charName, characterCardDescription: charDesc, + characterContactSmsHistory: getCharSmsHistory() + }); +} + +const loadAndSend = () => { const s = getOutlineStore(); if (s?.mapData) postFrame({ type: "LOAD_MAP_DATA", mapData: s.mapData }); sendSettings(); }; + +// ==================== 9. 请求处理器 ==================== + +const reply = (type, reqId, data) => postFrame({ type, requestId: reqId, ...data }); +const replyErr = (type, reqId, err) => reply(type, reqId, { error: err }); + +/** 获取当前气氛 */ +function getAtmosphere(store) { + return store?.outlineData?.meta?.atmosphere?.current || null; +} + +/** 合并世界推演数据 */ +function mergeSimData(orig, upd) { + if (!upd) return orig; + const r = JSON.parse(JSON.stringify(orig || {})); + const um = upd?.meta || {}, ut = um.truth || upd?.truth, uo = um.onion_layers || ut?.onion_layers; + const ua = um.atmosphere || upd?.atmosphere, utr = um.trajectory || upd?.trajectory; + r.meta = r.meta || {}; r.meta.truth = r.meta.truth || {}; + if (ut?.driver?.tactic) r.meta.truth.driver = { ...r.meta.truth.driver, tactic: ut.driver.tactic }; + if (uo) { ['L1_The_Veil', 'L2_The_Distortion', 'L3_The_Law', 'L4_The_Agent', 'L5_The_Axiom'].forEach(l => { const v = uo[l]; if (Array.isArray(v) && v.length) { r.meta.onion_layers = r.meta.onion_layers || {}; r.meta.onion_layers[l] = v; } }); if (r.meta.truth?.onion_layers) delete r.meta.truth.onion_layers; } + if (um.user_guide || upd?.user_guide) r.meta.user_guide = um.user_guide || upd.user_guide; + // 更新 atmosphere + if (ua) { r.meta.atmosphere = ua; } + // 更新 trajectory + if (utr) { r.meta.trajectory = utr; } + if (upd?.world) r.world = upd.world; + if (upd?.maps?.outdoor) { r.maps = r.maps || {}; r.maps.outdoor = r.maps.outdoor || {}; if (upd.maps.outdoor.description) r.maps.outdoor.description = upd.maps.outdoor.description; if (Array.isArray(upd.maps.outdoor.nodes)) { const on = r.maps.outdoor.nodes || []; upd.maps.outdoor.nodes.forEach(n => { const i = on.findIndex(x => x.name === n.name); if (i >= 0) on[i] = { ...n }; else on.push(n); }); r.maps.outdoor.nodes = on; } } + return r; +} + +/** 检查自动推演 */ +async function checkAutoSim(reqId) { + const store = getOutlineStore(); + if (!store || (store.simulationProgress || 0) < (store.simulationTarget ?? 5)) return; + const data = { meta: store.outlineData?.meta || {}, world: store.outlineData?.world || null, maps: { outdoor: store.outlineData?.outdoor || null, indoor: store.outlineData?.indoor || null } }; + await handleSimWorld({ requestId: `wsim_auto_${Date.now()}`, currentData: JSON.stringify(data), isAuto: true }); +} + +// 验证器 +const V = { + sum: o => o?.summary, npc: o => o?.name && o?.aliases, arr: o => Array.isArray(o), + scene: o => !!o?.review?.deviation && !!(o?.local_map || o?.scene_setup?.local_map), + lscene: o => !!o?.side_story, inv: o => typeof o?.invite === 'boolean' && o?.reply, + sms: o => typeof o?.reply === 'string' && o.reply.length > 0, + wg1: d => !!d && typeof d === 'object', // 只要是对象就行,后续会 normalize + wg2: d => !!(d?.world && (d?.maps || d?.world?.maps)?.outdoor), + wga: d => !!(d?.world && d?.maps?.outdoor), ws: d => !!d, w: o => !!o && typeof o === 'object', + lm: o => !!o?.inside?.name && !!o?.inside?.description +}; + +// --- 处理器 --- + +async function handleFetchModels({ apiUrl, apiKey }) { + try { + let models = []; + if (!apiUrl) { + for (const ep of ['/api/backends/chat-completions/models', '/api/openai/models']) { + try { const r = await fetch(ep, { headers: { 'Content-Type': 'application/json' } }); if (r.ok) { const j = await r.json(); models = (j.data || j || []).map(m => m.id || m.name || m).filter(m => typeof m === 'string'); if (models.length) break; } } catch { } + } + if (!models.length) throw new Error('无法从酒馆获取模型列表'); + } else { + const h = { 'Content-Type': 'application/json', ...(apiKey && { Authorization: `Bearer ${apiKey}` }) }; + const r = await fetch(apiUrl.replace(/\/$/, '') + '/models', { headers: h }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const j = await r.json(); + models = (j.data || j || []).map(m => m.id || m.name || m).filter(m => typeof m === 'string'); + } + postFrame({ type: "FETCH_MODELS_RESULT", models }); + } catch (e) { postFrame({ type: "FETCH_MODELS_RESULT", error: e.message }); } +} + +async function handleTestConn({ apiUrl, apiKey, model }) { + try { + if (!apiUrl) { for (const ep of ['/api/backends/chat-completions/status', '/api/openai/models', '/api/backends/chat-completions/models']) { try { if ((await fetch(ep, { headers: { 'Content-Type': 'application/json' } })).ok) { postFrame({ type: "TEST_CONN_RESULT", success: true, message: `连接成功${model ? ` (模型: ${model})` : ''}` }); return; } } catch { } } throw new Error('无法连接到酒馆API'); } + const h = { 'Content-Type': 'application/json', ...(apiKey && { Authorization: `Bearer ${apiKey}` }) }; + if (!(await fetch(apiUrl.replace(/\/$/, '') + '/models', { headers: h })).ok) throw new Error('连接失败'); + postFrame({ type: "TEST_CONN_RESULT", success: true, message: `连接成功${model ? ` (模型: ${model})` : ''}` }); + } catch (e) { postFrame({ type: "TEST_CONN_RESULT", success: false, message: `连接失败: ${e.message}` }); } +} + +async function handleCheckUid({ uid, requestId }) { + const num = parseInt(uid, 10); + if (!uid?.trim() || isNaN(num)) return replyErr("CHECK_WORLDBOOK_UID_RESULT", requestId, isNaN(num) ? 'UID必须是数字' : '请输入有效的UID'); + const books = await getCharWorldbooks(); + if (!books.length) return replyErr("CHECK_WORLDBOOK_UID_RESULT", requestId, '当前角色卡没有绑定世界书'); + for (const book of books) { + const data = await loadWorldInfo(book), entry = data?.entries?.[num]; + if (entry) { + const keys = Array.isArray(entry.key) ? entry.key : []; + if (!keys.length) return replyErr("CHECK_WORLDBOOK_UID_RESULT", requestId, `在「${book}」中找到条目 UID ${uid},但没有主要关键字`); + return reply("CHECK_WORLDBOOK_UID_RESULT", requestId, { primaryKeys: keys, worldbook: book, comment: entry.comment || '' }); + } + } + replyErr("CHECK_WORLDBOOK_UID_RESULT", requestId, `在角色卡绑定的世界书中未找到 UID 为 ${uid} 的条目`); +} + +async function handleSendSms({ requestId, contactName, worldbookUid, userMessage, chatHistory, summarizedCount }) { + try { + const ctx = getContext(), userName = name1 || ctx.name1 || '用户'; + let charContent = '', existSum = {}, sc = summarizedCount || 0; + + if (worldbookUid === CHAR_CARD_UID) { + charContent = getCharInfo().desc; + const h = getCharSmsHistory(); existSum = h?.summaries || {}; sc = summarizedCount ?? h?.summarizedCount ?? 0; + } else if (worldbookUid) { + const e = await findEntry(worldbookUid); + if (e?.entry) { + const c = e.entry.content || '', si = c.indexOf('[SMS_HISTORY_START]'); + charContent = si !== -1 ? c.substring(0, si).trim() : c; + const [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; + if (s !== -1 && ed !== -1) { const p = safe(() => JSON.parse(c.substring(s + 19, ed).trim())); const si = p?.find?.(i => typeof i === 'string' && i.startsWith('SMS_summary:')); if (si) existSum = safe(() => JSON.parse(si.substring(12))) || {}; } + } + } + + let histText = ''; + const sumKeys = Object.keys(existSum).filter(k => k !== '_count').sort((a, b) => a - b); + if (sumKeys.length) histText = `[之前的对话摘要] ${sumKeys.map(k => existSum[k]).join(';')}\n\n`; + if (chatHistory?.length > 1) { const msgs = chatHistory.slice(sc, -1); if (msgs.length) histText += msgs.map(m => `${m.type === 'sent' ? userName : contactName}:${m.text}`).join('\n'); } + + const msgs = buildSmsMessages({ contactName, userName, storyOutline: formatOutlinePrompt(), historyCount: getCommSettings().historyCount || 50, smsHistoryContent: buildSmsHistoryContent(histText), userMessage, characterContent: charContent }); + const parsed = await callLLMJson({ messages: msgs, validate: V.sms }); + reply('SMS_RESULT', requestId, parsed?.reply ? { reply: parsed.reply } : { error: '生成回复失败,请调整重试' }); + } catch (e) { replyErr('SMS_RESULT', requestId, `生成失败: ${e.message}`); } +} + +async function handleLoadSmsHistory({ worldbookUid }) { + if (worldbookUid === CHAR_CARD_UID) { const h = getCharSmsHistory(); return postFrame({ type: 'LOAD_SMS_HISTORY_RESULT', worldbookUid, messages: h?.messages || [], summarizedCount: h?.summarizedCount || 0 }); } + const store = getOutlineStore(), contact = store?.outlineData?.contacts?.find(c => c.worldbookUid === worldbookUid); + if (contact?.smsHistory?.messages?.length) return postFrame({ type: 'LOAD_SMS_HISTORY_RESULT', worldbookUid, messages: contact.smsHistory.messages, summarizedCount: contact.smsHistory.summarizedCount || 0 }); + const e = await findEntry(worldbookUid); let msgs = []; + if (e?.entry) { const c = e.entry.content || '', [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; if (s !== -1 && ed !== -1) { const p = safe(() => JSON.parse(c.substring(s + 19, ed).trim())); p?.forEach?.(i => { if (typeof i === 'string' && !i.startsWith('SMS_summary:')) { const idx = i.indexOf(':'); if (idx > 0) msgs.push({ type: i.substring(0, idx) === '{{user}}' ? 'sent' : 'received', text: i.substring(idx + 1) }); } }); } } + postFrame({ type: 'LOAD_SMS_HISTORY_RESULT', worldbookUid, messages: msgs, summarizedCount: 0 }); +} + +async function handleSaveSmsHistory({ worldbookUid, messages, contactName, summarizedCount }) { + if (worldbookUid === CHAR_CARD_UID) { const h = getCharSmsHistory(); if (!h) return; h.messages = Array.isArray(messages) ? messages : []; h.summarizedCount = summarizedCount || 0; if (!h.messages.length) { h.summarizedCount = 0; h.summaries = {}; } saveMetadataDebounced?.(); return; } + const e = await findEntry(worldbookUid); if (!e) return; + const { bookName, entry: en, worldData } = e; let c = en.content || ''; const cn = contactName || en.key?.[0] || '角色'; let existSum = ''; + const [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; + if (s !== -1 && ed !== -1) { const p = safe(() => JSON.parse(c.substring(s + 19, ed).trim())); existSum = p?.find?.(i => typeof i === 'string' && i.startsWith('SMS_summary:')) || ''; c = c.substring(0, s).trimEnd() + c.substring(ed + 17); } + if (messages?.length) { const sc = summarizedCount || 0, simp = messages.slice(sc).map(m => `${m.type === 'sent' ? '{{user}}' : cn}:${m.text}`); const arr = existSum ? [existSum, ...simp] : simp; c = c.trimEnd() + `\n\n[SMS_HISTORY_START]\n${JSON.stringify(arr)}\n[SMS_HISTORY_END]`; } + en.content = c.trim(); await saveWorldInfo(bookName, worldData); +} + +async function handleCompressSms({ requestId, worldbookUid, messages, contactName, summarizedCount }) { + const sc = summarizedCount || 0; + try { + const ctx = getContext(), userName = name1 || ctx.name1 || '用户'; + let e = null, existSum = {}; + + if (worldbookUid === CHAR_CARD_UID) { + const h = getCharSmsHistory(); existSum = h?.summaries || {}; + const keep = 4, toEnd = Math.max(sc, (messages?.length || 0) - keep); + if (toEnd <= sc) return replyErr('COMPRESS_SMS_RESULT', requestId, '没有足够的新消息需要总结'); + const toSum = (messages || []).slice(sc, toEnd); if (toSum.length < 2) return replyErr('COMPRESS_SMS_RESULT', requestId, '需要至少2条消息才能进行总结'); + const convText = toSum.map(m => `${m.type === 'sent' ? userName : contactName}:${m.text}`).join('\n'); + const sumKeys = Object.keys(existSum).filter(k => k !== '_count').sort((a, b) => a - b); + const existText = sumKeys.map(k => `${k}. ${existSum[k]}`).join('\n'); + const parsed = await callLLMJson({ messages: buildSummaryMessages({ existingSummaryContent: buildExistingSummaryContent(existText), conversationText: convText }), validate: V.sum }); + const sum = parsed?.summary?.trim?.(); if (!sum) return replyErr('COMPRESS_SMS_RESULT', requestId, 'ECHO:总结生成出错,请重试'); + const nextK = Math.max(0, ...Object.keys(existSum).filter(k => k !== '_count').map(k => parseInt(k, 10)).filter(n => !isNaN(n))) + 1; + existSum[String(nextK)] = sum; + if (h) { h.messages = Array.isArray(messages) ? messages : (h.messages || []); h.summarizedCount = toEnd; h.summaries = existSum; saveMetadataDebounced?.(); } + return reply('COMPRESS_SMS_RESULT', requestId, { summary: sum, newSummarizedCount: toEnd }); + } + + e = await findEntry(worldbookUid); + if (e?.entry) { const c = e.entry.content || '', [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; if (s !== -1 && ed !== -1) { const p = safe(() => JSON.parse(c.substring(s + 19, ed).trim())); const si = p?.find?.(i => typeof i === 'string' && i.startsWith('SMS_summary:')); if (si) existSum = safe(() => JSON.parse(si.substring(12))) || {}; } } + + const keep = 4, toEnd = Math.max(sc, messages.length - keep); + if (toEnd <= sc) return replyErr('COMPRESS_SMS_RESULT', requestId, '没有足够的新消息需要总结'); + const toSum = messages.slice(sc, toEnd); if (toSum.length < 2) return replyErr('COMPRESS_SMS_RESULT', requestId, '需要至少2条消息才能进行总结'); + const convText = toSum.map(m => `${m.type === 'sent' ? userName : contactName}:${m.text}`).join('\n'); + const sumKeys = Object.keys(existSum).filter(k => k !== '_count').sort((a, b) => a - b); + const existText = sumKeys.map(k => `${k}. ${existSum[k]}`).join('\n'); + const parsed = await callLLMJson({ messages: buildSummaryMessages({ existingSummaryContent: buildExistingSummaryContent(existText), conversationText: convText }), validate: V.sum }); + const sum = parsed?.summary?.trim?.(); if (!sum) return replyErr('COMPRESS_SMS_RESULT', requestId, 'ECHO:总结生成出错,请重试'); + const newSc = toEnd; + + if (e) { + const { bookName, entry: en, worldData } = e; let c = en.content || ''; const cn = contactName || en.key?.[0] || '角色'; + const [s, ed] = [c.indexOf('[SMS_HISTORY_START]'), c.indexOf('[SMS_HISTORY_END]')]; if (s !== -1 && ed !== -1) c = c.substring(0, s).trimEnd() + c.substring(ed + 17); + const nextK = Math.max(0, ...Object.keys(existSum).filter(k => k !== '_count').map(k => parseInt(k, 10)).filter(n => !isNaN(n))) + 1; + existSum[String(nextK)] = sum; + const rem = messages.slice(toEnd).map(m => `${m.type === 'sent' ? '{{user}}' : cn}:${m.text}`); + const arr = [`SMS_summary:${JSON.stringify(existSum)}`, ...rem]; + c = c.trimEnd() + `\n\n[SMS_HISTORY_START]\n${JSON.stringify(arr)}\n[SMS_HISTORY_END]`; + en.content = c.trim(); await saveWorldInfo(bookName, worldData); + } + reply('COMPRESS_SMS_RESULT', requestId, { summary: sum, newSummarizedCount: newSc }); + } catch (e) { replyErr('COMPRESS_SMS_RESULT', requestId, `压缩失败: ${e.message}`); } +} + +async function handleCheckStrangerWb({ requestId, strangerName }) { + const r = await searchEntry(strangerName); + postFrame({ type: 'CHECK_STRANGER_WORLDBOOK_RESULT', requestId, found: !!r, ...(r && { worldbookUid: r.uid, worldbook: r.bookName, entryName: r.entry.comment || r.entry.key?.[0] || strangerName }) }); +} + +async function handleGenNpc({ requestId, strangerName, strangerInfo }) { + try { + const ctx = getContext(), char = ctx.characters?.[ctx.characterId]; + if (!char) return replyErr('GENERATE_NPC_RESULT', requestId, '未找到当前角色卡'); + const primary = char.data?.extensions?.world; + if (!primary || !world_names?.includes(primary)) return replyErr('GENERATE_NPC_RESULT', requestId, '角色卡未绑定世界书,请先绑定世界书'); + const comm = getCommSettings(); + const msgs = buildNpcGenerationMessages({ strangerName, strangerInfo: strangerInfo || '(无描述)', storyOutline: formatOutlinePrompt(), historyCount: comm.historyCount || 50 }); + const npc = await callLLMJson({ messages: msgs, validate: V.npc }); + if (!npc?.name) return replyErr('GENERATE_NPC_RESULT', requestId, 'NPC 生成失败:无法解析 JSON 数据'); + const wd = await loadWorldInfo(primary); if (!wd) return replyErr('GENERATE_NPC_RESULT', requestId, `无法加载世界书: ${primary}`); + const { createWorldInfoEntry } = await import("../../../../../world-info.js"); + const newE = createWorldInfoEntry(primary, wd); if (!newE) return replyErr('GENERATE_NPC_RESULT', requestId, '创建世界书条目失败'); + Object.assign(newE, { key: npc.aliases || [npc.name], comment: npc.name, content: formatNpcToWorldbookContent(npc), constant: false, selective: true, disable: false, position: typeof comm.npcPosition === 'number' ? comm.npcPosition : 0, order: typeof comm.npcOrder === 'number' ? comm.npcOrder : 100 }); + await saveWorldInfo(primary, wd, true); + reply('GENERATE_NPC_RESULT', requestId, { success: true, npcData: npc, worldbookUid: String(newE.uid), worldbook: primary }); + } catch (e) { replyErr('GENERATE_NPC_RESULT', requestId, `生成失败: ${e.message}`); } +} + +async function handleExtractStrangers({ requestId, existingContacts, existingStrangers }) { + try { + const comm = getCommSettings(); + const msgs = buildExtractStrangersMessages({ storyOutline: formatOutlinePrompt(), historyCount: comm.historyCount || 50, existingContacts: existingContacts || [], existingStrangers: existingStrangers || [] }); + const data = await callLLMJson({ messages: msgs, isArray: true, validate: V.arr }); + if (!Array.isArray(data)) return replyErr('EXTRACT_STRANGERS_RESULT', requestId, '提取失败:无法解析 JSON 数据'); + const strangers = data.filter(s => s?.name).map(s => ({ name: s.name, avatar: s.name[0] || '?', color: '#' + Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0'), location: s.location || '未知', info: s.info || '' })); + reply('EXTRACT_STRANGERS_RESULT', requestId, { success: true, strangers }); + } catch (e) { replyErr('EXTRACT_STRANGERS_RESULT', requestId, `提取失败: ${e.message}`); } +} + +async function handleSceneSwitch({ requestId, prevLocationName, prevLocationInfo, targetLocationName, targetLocationType, targetLocationInfo, playerAction }) { + try { + const store = getOutlineStore(), comm = getCommSettings(), mode = getGlobalSettings().mode || 'story'; + const msgs = buildSceneSwitchMessages({ prevLocationName: prevLocationName || '未知地点', prevLocationInfo: prevLocationInfo || '', targetLocationName: targetLocationName || '未知地点', targetLocationType: targetLocationType || 'sub', targetLocationInfo: targetLocationInfo || '', storyOutline: formatOutlinePrompt(), stage: store?.stage || 0, currentAtmosphere: getAtmosphere(store), historyCount: comm.historyCount || 50, playerAction: playerAction || '', mode }); + const data = await callLLMJson({ messages: msgs, validate: V.scene }); + if (!data || !V.scene(data)) return replyErr('SCENE_SWITCH_RESULT', requestId, '场景生成失败:无法解析 JSON 数据'); + const delta = data.review?.deviation?.score_delta || 0, old = store?.deviationScore || 0, newS = Math.min(100, Math.max(0, old + delta)); + if (store) { store.deviationScore = newS; if (targetLocationType !== 'home') store.simulationProgress = (store.simulationProgress || 0) + 1; saveMetadataDebounced?.(); } + const lm = data.local_map || data.scene_setup?.local_map || null; + reply('SCENE_SWITCH_RESULT', requestId, { success: true, sceneData: { review: data.review, localMap: lm, strangers: [], scoreDelta: delta, newScore: newS } }); + checkAutoSim(requestId); + } catch (e) { replyErr('SCENE_SWITCH_RESULT', requestId, `场景切换失败: ${e.message}`); } +} + +async function handleExecSlash({ command }) { + try { + if (typeof command !== 'string') return; + for (const line of command.split(/\r?\n/).map(l => l.trim()).filter(Boolean)) { + if (/^\/(send|sendas|as)\b/i.test(line)) await processCommands(line); + } + } catch (e) { console.warn('[Story Outline] Slash command failed:', e); } +} + +async function handleSendInvite({ requestId, contactName, contactUid, targetLocation, smsHistory }) { + try { + const comm = getCommSettings(); + let charC = ''; + if (contactUid) { const es = Object.values(world_info?.entries || world_info || {}); charC = es.find(e => e.uid?.toString() === contactUid.toString())?.content || ''; } + const msgs = buildInviteMessages({ contactName, userName: name1 || '{{user}}', targetLocation, storyOutline: formatOutlinePrompt(), historyCount: comm.historyCount || 50, smsHistoryContent: buildSmsHistoryContent(smsHistory || ''), characterContent: charC }); + const data = await callLLMJson({ messages: msgs, validate: V.inv }); + if (typeof data?.invite !== 'boolean') return replyErr('SEND_INVITE_RESULT', requestId, '邀请处理失败:无法解析 JSON 数据'); + reply('SEND_INVITE_RESULT', requestId, { success: true, inviteData: { accepted: data.invite, reply: data.reply, targetLocation } }); + } catch (e) { replyErr('SEND_INVITE_RESULT', requestId, `邀请处理失败: ${e.message}`); } +} + +async function handleGenLocalMap({ requestId, outdoorDescription }) { + try { + const msgs = buildLocalMapGenMessages({ storyOutline: formatOutlinePrompt(), outdoorDescription: outdoorDescription || '', historyCount: getCommSettings().historyCount || 50 }); + const data = await callLLMJson({ messages: msgs, validate: V.lm }); + if (!data?.inside) return replyErr('GENERATE_LOCAL_MAP_RESULT', requestId, '局部地图生成失败:无法解析 JSON 数据'); + reply('GENERATE_LOCAL_MAP_RESULT', requestId, { success: true, localMapData: data.inside }); + } catch (e) { replyErr('GENERATE_LOCAL_MAP_RESULT', requestId, `局部地图生成失败: ${e.message}`); } +} + +async function handleRefreshLocalMap({ requestId, locationName, currentLocalMap, outdoorDescription }) { + try { + const store = getOutlineStore(), comm = getCommSettings(); + const msgs = buildLocalMapRefreshMessages({ storyOutline: formatOutlinePrompt(), locationName: locationName || store?.playerLocation || '未知地点', locationInfo: currentLocalMap?.description || '', currentLocalMap: currentLocalMap || null, outdoorDescription: outdoorDescription || '', historyCount: comm.historyCount || 50, playerLocation: store?.playerLocation }); + const data = await callLLMJson({ messages: msgs, validate: V.lm }); + if (!data?.inside) return replyErr('REFRESH_LOCAL_MAP_RESULT', requestId, '局部地图刷新失败:无法解析 JSON 数据'); + reply('REFRESH_LOCAL_MAP_RESULT', requestId, { success: true, localMapData: data.inside }); + } catch (e) { replyErr('REFRESH_LOCAL_MAP_RESULT', requestId, `局部地图刷新失败: ${e.message}`); } +} + +async function handleGenLocalScene({ requestId, locationName, locationInfo }) { + try { + const store = getOutlineStore(), comm = getCommSettings(), mode = getGlobalSettings().mode || 'story'; + const msgs = buildLocalSceneGenMessages({ storyOutline: formatOutlinePrompt(), locationName: locationName || store?.playerLocation || '未知地点', locationInfo: locationInfo || '', stage: store?.stage || 0, currentAtmosphere: getAtmosphere(store), historyCount: comm.historyCount || 50, mode, playerLocation: store?.playerLocation }); + const data = await callLLMJson({ messages: msgs, validate: V.lscene }); + if (!data || !V.lscene(data)) return replyErr('GENERATE_LOCAL_SCENE_RESULT', requestId, '局部剧情生成失败:无法解析 JSON 数据'); + if (store) { store.simulationProgress = (store.simulationProgress || 0) + 1; saveMetadataDebounced?.(); } + const ssf = data.side_story || null, intro = ssf?.Introduce || ssf?.introduce || ''; + const ss = ssf ? (() => { const { Introduce, introduce: i2, story, ...rest } = ssf; return rest; })() : null; + reply('GENERATE_LOCAL_SCENE_RESULT', requestId, { success: true, sceneSetup: { sideStory: ss, review: data.review || null }, introduce: intro, loc: locationName }); + checkAutoSim(requestId); + } catch (e) { replyErr('GENERATE_LOCAL_SCENE_RESULT', requestId, `局部剧情生成失败: ${e.message}`); } +} + +async function handleGenWorld({ requestId, playerRequests }) { + try { + const comm = getCommSettings(), mode = getGlobalSettings().mode || 'story', store = getOutlineStore(); + + // 递归查找函数 - 在任意层级找到目标键 + const deepFind = (obj, key) => { + if (!obj || typeof obj !== 'object') return null; + if (obj[key] !== undefined) return obj[key]; + for (const v of Object.values(obj)) { + const found = deepFind(v, key); + if (found !== null) return found; + } + return null; + }; + + const normalizeStep1Data = (data) => { + if (!data || typeof data !== 'object') return null; + + // 构建标准化结构,从任意位置提取数据 + const result = { meta: {} }; + + // 提取 truth(可能在 meta.truth, data.truth, 或者 data 本身就是 truth) + result.meta.truth = deepFind(data, 'truth') + || (data.background && data.driver ? data : null) + || { background: deepFind(data, 'background'), driver: deepFind(data, 'driver') }; + + // 提取 onion_layers + result.meta.onion_layers = deepFind(data, 'onion_layers') || {}; + + // 统一洋葱层级为数组格式 + ['L1_The_Veil', 'L2_The_Distortion', 'L3_The_Law', 'L4_The_Agent', 'L5_The_Axiom'].forEach(k => { + const v = result.meta.onion_layers[k]; + if (v && !Array.isArray(v) && typeof v === 'object') { + result.meta.onion_layers[k] = [v]; + } + }); + + // 提取 atmosphere + result.meta.atmosphere = deepFind(data, 'atmosphere') || { reasoning: '', current: { environmental: '', npc_attitudes: '' } }; + + // 提取 trajectory + result.meta.trajectory = deepFind(data, 'trajectory') || { reasoning: '', ending: '' }; + + // 提取 user_guide + result.meta.user_guide = deepFind(data, 'user_guide') || { current_state: '', guides: [] }; + + return result; + }; + + // 辅助模式 + if (mode === 'assist') { + const msgs = buildWorldGenStep2Messages({ historyCount: comm.historyCount || 50, playerRequests, mode: 'assist' }); + const wd = await callLLMJson({ messages: msgs, validate: V.wga }); + if (!wd?.maps?.outdoor || !Array.isArray(wd.maps.outdoor.nodes)) return replyErr('GENERATE_WORLD_RESULT', requestId, '生成失败:返回数据缺少地图节点'); + if (store) { Object.assign(store, { stage: 0, deviationScore: 0, simulationProgress: 0, simulationTarget: randRange(3, 7) }); store.outlineData = { ...wd }; saveMetadataDebounced?.(); } + return reply('GENERATE_WORLD_RESULT', requestId, { success: true, worldData: wd }); + } + + // Step 1 + postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '正在构思世界大纲 (Step 1/2)...' }); + const s1m = buildWorldGenStep1Messages({ historyCount: comm.historyCount || 50, playerRequests }); + const s1d = normalizeStep1Data(await callLLMJson({ messages: s1m, validate: V.wg1 })); + + // 简化验证 - 只要有基本数据就行 + if (!s1d?.meta) { + return replyErr('GENERATE_WORLD_RESULT', requestId, 'Step 1 失败:无法解析大纲数据,请重试'); + } + step1Cache = { step1Data: s1d, playerRequests: playerRequests || '' }; + + // Step 2 + postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: 'Step 1 完成,1 秒后开始构建世界细节 (Step 2/2)...' }); + await new Promise(r => setTimeout(r, 1000)); + postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '正在构建世界细节 (Step 2/2)...' }); + + const s2m = buildWorldGenStep2Messages({ historyCount: comm.historyCount || 50, playerRequests, step1Data: s1d }); + const s2d = await callLLMJson({ messages: s2m, validate: V.wg2 }); + if (s2d?.world?.maps && !s2d?.maps) { s2d.maps = s2d.world.maps; delete s2d.world.maps; } + if (!s2d?.world || !s2d?.maps) return replyErr('GENERATE_WORLD_RESULT', requestId, 'Step 2 失败:无法生成有效的地图'); + + const final = { meta: s1d.meta, world: s2d.world, maps: s2d.maps, playerLocation: s2d.playerLocation }; + step1Cache = null; + if (store) { Object.assign(store, { stage: 0, deviationScore: 0, simulationProgress: 0, simulationTarget: randRange(3, 7) }); store.outlineData = final; saveMetadataDebounced?.(); } + reply('GENERATE_WORLD_RESULT', requestId, { success: true, worldData: final }); + } catch (e) { replyErr('GENERATE_WORLD_RESULT', requestId, `生成失败: ${e.message}`); } +} + +async function handleRetryStep2({ requestId }) { + try { + if (!step1Cache?.step1Data?.meta) return replyErr('GENERATE_WORLD_RESULT', requestId, 'Step 2 重试失败:缺少 Step 1 数据,请重新开始生成'); + const comm = getCommSettings(), store = getOutlineStore(), s1d = step1Cache.step1Data, pr = step1Cache.playerRequests || ''; + + postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '1 秒后重试构建世界细节 (Step 2/2)...' }); + await new Promise(r => setTimeout(r, 1000)); + postFrame({ type: 'GENERATE_WORLD_STATUS', requestId, message: '正在重试构建世界细节 (Step 2/2)...' }); + + const s2m = buildWorldGenStep2Messages({ historyCount: comm.historyCount || 50, playerRequests: pr, step1Data: s1d }); + const s2d = await callLLMJson({ messages: s2m, validate: V.wg2 }); + if (s2d?.world?.maps && !s2d?.maps) { s2d.maps = s2d.world.maps; delete s2d.world.maps; } + if (!s2d?.world || !s2d?.maps) return replyErr('GENERATE_WORLD_RESULT', requestId, 'Step 2 失败:无法生成有效的地图'); + + const final = { meta: s1d.meta, world: s2d.world, maps: s2d.maps, playerLocation: s2d.playerLocation }; + step1Cache = null; + if (store) { Object.assign(store, { stage: 0, deviationScore: 0, simulationProgress: 0, simulationTarget: randRange(3, 7) }); store.outlineData = final; saveMetadataDebounced?.(); } + reply('GENERATE_WORLD_RESULT', requestId, { success: true, worldData: final }); + } catch (e) { replyErr('GENERATE_WORLD_RESULT', requestId, `Step 2 重试失败: ${e.message}`); } +} + +async function handleSimWorld({ requestId, currentData, isAuto }) { + try { + const store = getOutlineStore(), mode = getGlobalSettings().mode || 'story'; + const msgs = buildWorldSimMessages({ mode, currentWorldData: currentData || '{}', historyCount: getCommSettings().historyCount || 50, deviationScore: store?.deviationScore || 0 }); + const data = await callLLMJson({ messages: msgs, validate: V.w }); + if (!data || !V.w(data)) return replyErr('SIMULATE_WORLD_RESULT', requestId, mode === 'assist' ? '世界推演失败:无法解析 JSON 数据(需包含 world 或 maps 字段)' : '世界推演失败:无法解析 JSON 数据'); + const orig = safe(() => JSON.parse(currentData)) || {}, merged = mergeSimData(orig, data); + if (store) { store.stage = (store.stage || 0) + 1; store.simulationProgress = 0; store.simulationTarget = randRange(3, 7); saveMetadataDebounced?.(); } + reply('SIMULATE_WORLD_RESULT', requestId, { success: true, simData: merged, isAuto: !!isAuto }); + } catch (e) { replyErr('SIMULATE_WORLD_RESULT', requestId, `推演失败: ${e.message}`); } +} + +function handleSaveSettings(d) { + if (d.globalSettings) saveGlobalSettings(d.globalSettings); + if (d.commSettings) saveCommSettings(d.commSettings); + const store = getOutlineStore(); + if (store) { + ['stage', 'deviationScore', 'simulationProgress', 'simulationTarget', 'playerLocation'].forEach(k => { if (d[k] !== undefined) store[k] = d[k]; }); + if (d.dataChecked) store.dataChecked = d.dataChecked; + if (d.allData) store.outlineData = d.allData; + store.updatedAt = Date.now(); + saveMetadataDebounced?.(); + } + injectOutline(); +} + +function handleSavePrompts(d) { + if (!d?.promptConfig) return; + setPromptConfig?.(d.promptConfig, true); + postFrame({ type: "PROMPT_CONFIG_UPDATED", promptConfig: getPromptConfigPayload?.() }); +} + +function handleSaveContacts(d) { + const store = getOutlineStore(); if (!store) return; + store.outlineData ||= {}; + if (d.contacts) store.outlineData.contacts = d.contacts; + if (d.strangers) store.outlineData.strangers = d.strangers; + store.updatedAt = Date.now(); + saveMetadataDebounced?.(); + injectOutline(); +} + +function handleSaveAllData(d) { + const store = getOutlineStore(); + if (store && d.allData) { + store.outlineData = d.allData; + if (d.playerLocation !== undefined) store.playerLocation = d.playerLocation; + store.updatedAt = Date.now(); + saveMetadataDebounced?.(); + injectOutline(); + } +} + +function handleSaveCharSmsHistory(d) { + const h = getCharSmsHistory(); + if (!h) return; + const sums = d?.summaries ?? d?.history?.summaries; + if (!sums || typeof sums !== 'object' || Array.isArray(sums)) return; + h.summaries = sums; + saveMetadataDebounced?.(); + injectOutline(); +} + +// 处理器映射 +const handlers = { + FRAME_READY: () => { frameReady = true; flushPending(); loadAndSend(); }, + CLOSE_PANEL: hideOverlay, + SAVE_MAP_DATA: d => { const s = getOutlineStore(); if (s && d.mapData) { s.mapData = d.mapData; s.updatedAt = Date.now(); saveMetadataDebounced?.(); } }, + GET_SETTINGS: sendSettings, + SAVE_SETTINGS: handleSaveSettings, + SAVE_PROMPTS: handleSavePrompts, + SAVE_CONTACTS: handleSaveContacts, + SAVE_ALL_DATA: handleSaveAllData, + FETCH_MODELS: handleFetchModels, + TEST_CONNECTION: handleTestConn, + CHECK_WORLDBOOK_UID: handleCheckUid, + SEND_SMS: handleSendSms, + LOAD_SMS_HISTORY: handleLoadSmsHistory, + SAVE_SMS_HISTORY: handleSaveSmsHistory, + SAVE_CHAR_SMS_HISTORY: handleSaveCharSmsHistory, + COMPRESS_SMS: handleCompressSms, + CHECK_STRANGER_WORLDBOOK: handleCheckStrangerWb, + GENERATE_NPC: handleGenNpc, + EXTRACT_STRANGERS: handleExtractStrangers, + SCENE_SWITCH: handleSceneSwitch, + EXECUTE_SLASH_COMMAND: handleExecSlash, + SEND_INVITE: handleSendInvite, + GENERATE_WORLD: handleGenWorld, + RETRY_WORLD_GEN_STEP2: handleRetryStep2, + SIMULATE_WORLD: handleSimWorld, + GENERATE_LOCAL_MAP: handleGenLocalMap, + REFRESH_LOCAL_MAP: handleRefreshLocalMap, + GENERATE_LOCAL_SCENE: handleGenLocalScene +}; + +const handleMsg = ({ data }) => { if (data?.source === "LittleWhiteBox-OutlineFrame") handlers[data.type]?.(data); }; + +// ==================== 10. UI管理 ==================== + +/** 指针拖拽 */ +function setupDrag(el, { onStart, onMove, onEnd, shouldHandle }) { + if (!el) return; + let state = null; + el.addEventListener('pointerdown', e => { if (shouldHandle && !shouldHandle()) return; e.preventDefault(); e.stopPropagation(); state = onStart(e); state.pointerId = e.pointerId; el.setPointerCapture(e.pointerId); }); + el.addEventListener('pointermove', e => state && onMove(e, state)); + const end = () => { if (!state) return; onEnd?.(state); try { el.releasePointerCapture(state.pointerId); } catch { } state = null; }; + ['pointerup', 'pointercancel', 'lostpointercapture'].forEach(ev => el.addEventListener(ev, end)); +} + +/** 创建Overlay */ +function createOverlay() { + if (overlayCreated) return; + overlayCreated = true; + document.body.appendChild($(buildOverlayHtml(IFRAME_PATH))[0]); + const overlay = document.getElementById("xiaobaix-story-outline-overlay"), wrap = overlay.querySelector(".xb-so-frame-wrap"), iframe = overlay.querySelector("iframe"); + const setPtr = v => iframe && (iframe.style.pointerEvents = v); + + // 拖拽 + setupDrag(overlay.querySelector(".xb-so-drag-handle"), { + shouldHandle: () => !isMobile(), + onStart(e) { const r = wrap.getBoundingClientRect(), ro = overlay.getBoundingClientRect(); wrap.style.left = (r.left - ro.left) + 'px'; wrap.style.top = (r.top - ro.top) + 'px'; wrap.style.transform = ''; setPtr('none'); return { sx: e.clientX, sy: e.clientY, sl: parseFloat(wrap.style.left), st: parseFloat(wrap.style.top) }; }, + onMove(e, s) { wrap.style.left = Math.max(0, Math.min(overlay.clientWidth - wrap.offsetWidth, s.sl + e.clientX - s.sx)) + 'px'; wrap.style.top = Math.max(0, Math.min(overlay.clientHeight - wrap.offsetHeight, s.st + e.clientY - s.sy)) + 'px'; }, + onEnd: () => setPtr('') + }); + + // 缩放 + setupDrag(overlay.querySelector(".xb-so-resize-handle"), { + shouldHandle: () => !isMobile(), + onStart(e) { const r = wrap.getBoundingClientRect(), ro = overlay.getBoundingClientRect(); wrap.style.left = (r.left - ro.left) + 'px'; wrap.style.top = (r.top - ro.top) + 'px'; wrap.style.transform = ''; setPtr('none'); return { sx: e.clientX, sy: e.clientY, sw: wrap.offsetWidth, sh: wrap.offsetHeight, ratio: wrap.offsetWidth / wrap.offsetHeight }; }, + onMove(e, s) { const dx = e.clientX - s.sx, dy = e.clientY - s.sy, delta = Math.abs(dx) > Math.abs(dy) ? dx : dy * s.ratio; let w = Math.max(400, Math.min(window.innerWidth * 0.95, s.sw + delta)), h = w / s.ratio; if (h > window.innerHeight * 0.9) { h = window.innerHeight * 0.9; w = h * s.ratio; } if (h < 300) { h = 300; w = h * s.ratio; } wrap.style.width = w + 'px'; wrap.style.height = h + 'px'; }, + onEnd: () => setPtr('') + }); + + // 移动端 + setupDrag(overlay.querySelector(".xb-so-resize-mobile"), { + shouldHandle: () => isMobile(), + onStart(e) { setPtr('none'); return { sy: e.clientY, sh: wrap.offsetHeight }; }, + onMove(e, s) { wrap.style.height = Math.max(44, Math.min(window.innerHeight * 0.9, s.sh + e.clientY - s.sy)) + 'px'; }, + onEnd: () => setPtr('') + }); + + window.addEventListener("message", handleMsg); +} + +function updateLayout() { + const wrap = document.querySelector(".xb-so-frame-wrap"); if (!wrap) return; + const drag = document.querySelector(".xb-so-drag-handle"), resize = document.querySelector(".xb-so-resize-handle"), mobile = document.querySelector(".xb-so-resize-mobile"); + if (isMobile()) { if (drag) drag.style.display = 'none'; if (resize) resize.style.display = 'none'; if (mobile) mobile.style.display = 'flex'; wrap.style.cssText = MOBILE_LAYOUT_STYLE; const fixedHeight = window.innerHeight * 0.4; wrap.style.height = Math.max(44, fixedHeight) + 'px'; wrap.style.top = '0px'; } + else { if (drag) drag.style.display = 'block'; if (resize) resize.style.display = 'block'; if (mobile) mobile.style.display = 'none'; wrap.style.cssText = DESKTOP_LAYOUT_STYLE; } +} + +function showOverlay() { if (!overlayCreated) createOverlay(); frameReady = false; const f = document.getElementById("xiaobaix-story-outline-iframe"); if (f) f.src = IFRAME_PATH; updateLayout(); $("#xiaobaix-story-outline-overlay").show(); } +function hideOverlay() { $("#xiaobaix-story-outline-overlay").hide(); } + +let lastIsMobile = isMobile(); +window.addEventListener('resize', () => { const nowIsMobile = isMobile(); if (nowIsMobile !== lastIsMobile) { lastIsMobile = nowIsMobile; updateLayout(); } }); + + +// ==================== 11. 事件与初始化 ==================== + +let eventsRegistered = false; + +function addBtnToMsg(mesId) { + if (!getSettings().storyOutline?.enabled) return; + const msg = document.querySelector(`#chat .mes[mesid="${mesId}"]`); + if (!msg || msg.querySelector('.xiaobaix-story-outline-btn')) return; + const btn = document.createElement('div'); + btn.className = 'mes_btn xiaobaix-story-outline-btn'; + btn.title = '小白板'; + btn.dataset.mesid = mesId; + btn.innerHTML = ''; + btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); if (!getSettings().storyOutline?.enabled) return; currentMesId = Number(mesId); showOverlay(); loadAndSend(); }); + if (window.registerButtonToSubContainer?.(mesId, btn)) return; + msg.querySelector('.flex-container.flex1.alignitemscenter')?.appendChild(btn); +} + +function initBtns() { + if (!getSettings().storyOutline?.enabled) return; + $("#chat .mes").each((_, el) => { const id = el.getAttribute("mesid"); if (id != null) addBtnToMsg(id); }); +} + +function registerEvents() { + if (eventsRegistered) return; + eventsRegistered = true; + + initBtns(); + + events.on(event_types.CHAT_CHANGED, () => { setTimeout(initBtns, 80); setTimeout(injectOutline, 100); }); + events.on(event_types.GENERATION_STARTED, injectOutline); + + const handler = d => setTimeout(() => { + const id = d?.element ? $(d.element).attr("mesid") : d?.messageId; + id == null ? initBtns() : addBtnToMsg(id); + }, 50); + + events.onMany([ + event_types.USER_MESSAGE_RENDERED, + event_types.CHARACTER_MESSAGE_RENDERED, + event_types.MESSAGE_RECEIVED, + event_types.MESSAGE_UPDATED, + event_types.MESSAGE_SWIPED, + event_types.MESSAGE_EDITED + ], handler); + + setupSTEvents(); +} + +function cleanup() { + events.cleanup(); + eventsRegistered = false; + $(".xiaobaix-story-outline-btn").remove(); + hideOverlay(); + overlayCreated = false; frameReady = false; pendingMsgs = []; + window.removeEventListener("message", handleMsg); + document.getElementById("xiaobaix-story-outline-overlay")?.remove(); + removePrompt(); + if (presetCleanup) { presetCleanup(); presetCleanup = null; } +} + +// ==================== Toggle 监听(始终注册)==================== + +$(document).on("xiaobaix:storyOutline:toggle", (_e, enabled) => { + if (enabled) { + registerEvents(); + initBtns(); + injectOutline(); + } else { + cleanup(); + } +}); + +document.addEventListener('xiaobaixEnabledChanged', e => { + if (!e?.detail?.enabled) { + cleanup(); + } else if (getSettings().storyOutline?.enabled) { + registerEvents(); + initBtns(); + injectOutline(); + } +}); + +// ==================== 初始化 ==================== + +jQuery(() => { + if (!getSettings().storyOutline?.enabled) return; + registerEvents(); + setTimeout(injectOutline, 200); + window.registerModuleCleanup?.('storyOutline', cleanup); +}); + +export { cleanup };