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${tag}>` : '';
-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${v.contactName}的人物设定>` : ''}`,
- 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${v.contactName}的人物设定>` : ''}\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${tag}>` : '';
+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${v.contactName}的人物设定>` : ''}`,
+ 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${v.contactName}的人物设定>` : ''}\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 @@
-
-
-
-
-
- 剧情地图
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
当前状态
-
尚未生成世界数据...
-
行动指南
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
设置
-
-
-
-
-
全局设定
-
-
-
-
-
NPC 世界书条目
-
-
-
预设 Story Outline 数据
勾选的条目将写入预设
-
高级设置 · 自定义提示词
UAUA四段 + JSON 模板
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ 小白板
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
当前状态
+
尚未生成世界数据...
+
行动指南
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
设置
+
+
+
+
+
全局设定
+
+
+
+
+
NPC 世界书条目
+
+
+
预设 Story Outline 数据
勾选的条目将写入预设
+
高级设置 · 自定义提示词
UAUA四段 + JSON 模板
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 };