纳入小白板内容+世界书读取逻辑修正 (#23) (#25)

* Strip everything before and including </think> (handles unclosed think blocks)

* Log 样式优化

* Log样式优化

* 小白板内容曝露给ena-planner

* 小白板内容曝露给ena-planner

* 修正世界书宏读取问题

* 修正summary触发绿灯的问题

* 向量存储到ST端

* 向量存储到ST端

* 向量到ST服务器

* 向量存储到ST端

* backup file名称修正

* 存取向量逻辑修正

* 切聊天时清掉旧 summary

* 新增向量备份管理 UI(清单 + Modal)

- vector-io.js:新增 fetchManifest / upsertManifestEntry / deleteServerBackup 等清单管理函数;backupToServer 成功后自动写入 LWB_BackupManifest.json
- story-summary.html:在服务器 IO 区域新增「管理」按钮及独立 Modal 弹窗
- story-summary-ui.js:新增备份列表渲染、删除确认、只读模式降级逻辑
- story-summary.js:新增 VECTOR_LIST_BACKUPS / VECTOR_DELETE_BACKUP 消息处理



* 备份管理 Modal 移至父窗口,修复层级与配色问题

- Modal 从 iframe 移到父窗口 DOM(z-index:100000),不再被 settings modal 遮挡
- 改为白底深色文字,配色清晰可读
- 删除逻辑直接在父窗口调用,无需跨帧消息
- 简化 story-summary-ui.js,移除 modal 相关代码



* 删除聊天时自动清理服务器向量备份

- vector-io.js:导出 getBackupFilename
- story-summary.js:监听 CHAT_DELETED / GROUP_CHAT_DELETED,静默删除对应 zip 和清单条目



* 修复 serverPath 含前导斜杠导致删除失败的问题

buildSafeServerPath 比较前 strip 前导 /,upsertManifestEntry 写入前同样 normalize,
确保清单和校验逻辑使用统一格式



* normalizeManifestEntry 读取时同步 strip serverPath 前导斜杠

补全斜杠 normalize 的覆盖点:写入(upsertManifestEntry)、校验(buildSafeServerPath)、
读取(normalizeManifestEntry)三处统一,旧清单条目自动修正



* 重要NPC生成路径:拆分添加按钮 + 完整角色档案模板

- 陌路人卡片"添加"按钮拆为"重要"(importantNpc)和"背景板"(npc)两个
- 新增 importantNpc 生成路径,传递 npcType 贯穿 genAddCt → CHECK_STRANGER_WORLDBOOK_RESULT → GENERATE_NPC_RESULT
- 新增 importantNpc JSON 模板:白描外貌、世界观适配、性格调色盘+衍生、台词示例、结构化二次解释
- 新增 importantNpc UAUA 提示词:内嵌白描规则+正反示范、调色盘衍生写法指导



* 高级设置模板编辑器加注授权声明



* 授权声明仅在重要NPC生成模板下显示



---------

Co-authored-by: Hao19911125 <99091644+Hao19911125@users.noreply.github.com>
Co-authored-by: LittleWhiteBox Dev <dev@littlewhitebox.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
RT15548
2026-03-19 00:50:14 +08:00
committed by GitHub
parent 80f7e37843
commit 11e48f8dc5
8 changed files with 861 additions and 7 deletions

View File

@@ -54,6 +54,52 @@ const DEFAULT_JSON_TEMPLATES = {
"stance": "核心态度·具体表现。例如:'中立·唯利是图'、'友善·盲目崇拜' 或 '敌对·疯狂'",
"secret": "该角色掌握的一个关键信息、道具或秘密。必须结合'剧情大纲'生成,作为一个潜在的剧情钩子。"
}
}`,
importantNpc: `{
"name": "角色全名",
"aliases": ["别名1", "别名2", "英文名/拼音"],
"intro": "白描一句话:外貌+身份。仅用名词和动词,禁止形容词和比喻。例:'黑色长直发过腰,左眼下泪痣,着灰色风衣的赏金猎人。'",
"appearance": {
"build": "体型白描(如:比{{user}}高一个头。宽肩,窄腰。)",
"face": "面部白描(如:颧骨高,下颌线锐利。左眉尾有一道旧疤。)",
"hair_and_eyes": "发型发色、瞳色",
"marks": "显著标记——疤痕、痣、纹身等,无则写'无'",
"attire": "当前穿着"
},
"background": "角色来历与当前处境。必须交代因果链什么过去→塑造了什么性格→为什么出现在当前场景。200字左右。",
"world_adaptation": {},
"personality_palette": {
"base_color": "底色——驱动一切行为的最底层核心性格(如:恐惧、控制欲、孤独)",
"main_colors": ["主色调1", "主色调2——日常最常表现出的性格"],
"accents": ["点缀——不常见但在特定情境下浮现的性格"],
"derivatives": [
"[主色调1]衍生一:(写具体场景+具体行为,不是定义。错误:'她很温柔';正确:'会在{{user}}加班时默默端一杯温水放在桌上,不说话,放下就走'",
"[主色调1]衍生二:(另一个场景的表现,衍生之间可以互相矛盾——这才是真实的人)",
"[主色调2]衍生一:...",
"[底色]衍生一:(底色通常不轻易暴露,写什么条件下会泄漏出来)",
"[点缀]衍生一:..."
]
},
"speaking": {
"style": "语气、语速、口癖、惯用词",
"samples": ["台词示例1——展现主色调", "台词示例2——展现底色泄漏", "台词示例3——展现对{{user}}的态度"],
"attitude_to_user": "对{{user}}的态度及其原因"
},
"understanding": [
{
"about": "某个性格特质或行为模式",
"clarification": "这个特质的真正含义是……不是……在什么情况下会……常见误读是……正确理解是……"
},
{
"about": "另一个容易被AI误读的特质",
"clarification": "解释动机而非重复描述。预判AI可能的补全方向并提前纠正。"
}
],
"game_data": {
"stance": "核心态度·具体表现(如:'中立·唯利是图'、'友善·盲目崇拜'、'敌对·疯狂'",
"secret": "角色掌握的一个关键秘密/信息/道具。必须结合剧情大纲生成,作为剧情钩子。",
"motivation": "核心驱动力与行动优先级准则"
}
}`,
stranger: `[{ "name": "角色名", "location": "当前地点", "info": "一句话简介" }]`,
worldGenStep1: `{
@@ -258,6 +304,31 @@ const DEFAULT_PROMPTS = {
u2: v => `${worldInfo}\n\n${history(v.historyCount)}\n\n剧情秘密大纲(*从这里提取线索赋予角色秘密*\n${wrap('story_outline', v.storyOutline) || '<story_outline>\n(无)\n</story_outline>'}\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:`
},
importantNpc: {
u1: v => `你是TRPG重要角色档案生成器。将陌生人【${v.strangerName} - ${v.strangerInfo}】扩充为剧情核心角色的完整档案。
核心写作原则:
1. **基础信息用绝对零度白描**:只写客观事实,不用形容词/比喻/模糊词(似乎、仿佛、宛如)。用名词和动词直接呈现。
× "她有一头好看柔顺的黑色长发" → √ "黑色长直发,过腰。瞳色黑。"
× "他身材魁梧,给人压迫感" → √ "身高比{{user}}高一个头。宽肩,厚背。"
2. **性格用调色盘+衍生展开**:人的性格像调色盘,底色是最深层驱动力,主色调是日常表现,点缀是偶尔闪现的侧面。每种性格必须通过"衍生"展开为具体场景行为——不是贴标签,是写"在什么情况下会做什么"。衍生之间可以互相矛盾,这才是真实的人。
× "温柔衍生:她很温柔,对人很好。"(标签重复)
√ "温柔衍生:生气时——真正生气基本都和{{user}}有关。当有人欺负{{user}},她会一把拉住{{user}}让其靠自己,然后用冰冷目光看对方。"
3. **台词示例**3句具体台词分别展现主色调、底色泄漏、对{{user}}的态度。
4. **二次解释understanding数组**逐条针对角色最容易被误读的性格特质写结构化纠偏。每条包含about哪个特质和clarification真正含义、不是什么、在什么情况下怎样。至少2条。这不是重复调色盘是解释动机和预判误读。
× "关于温柔:她很温柔,对人好。"(重复调色盘)
√ "关于乐观的双重性:和{{user}}在一起时是真实的,和其他人相处时是维持人设的假象。脆弱时只会在{{user}}面前表现。"
5. **世界观适配world_adaptation对象**:根据故事世界观动态生成键值对。修仙世界→灵根、境界、功法等字段;赛博世界→义体部位、型号、副作用等字段;现代世界→可留空对象。不预设固定字段,由你根据世界观判断需要什么。
基于世界观、剧情大纲和现有角色关系输出严格JSON。`,
a1: () => `明白。我将严格遵循白描原则和调色盘衍生写法按JSON模板输出完整角色档案不含多余文本。`,
u2: v => `${worldInfo}\n\n${history(v.historyCount)}\n\n剧情秘密大纲(*从这里提取线索赋予角色秘密和动机*\n${wrap('story_outline', v.storyOutline) || '<story_outline>\n(无)\n</story_outline>'}\n\n需要生成:【${v.strangerName} - ${v.strangerInfo}\n\n输出要求:\n1. 必须是合法 JSON\n2. 使用标准 JSON 语法:所有键名和字符串都使用半角双引号 "\n3. 文本字段中如需表示引号,请使用单引号或中文引号「」或"",不要使用半角双引号 "\n4. aliases须含简称或绰号\n5. personality_palette.derivatives 至少5条每条都是具体场景+具体行为\n6. speaking.samples 须3句具体台词\n7. understanding数组至少2条每条须含about和clarification\n8. world_adaptation根据世界观动态生成键值对无特殊体系则输出空对象{}\n9. 总输出约800-1500字\n\n模板:${JSON_TEMPLATES.importantNpc}`,
a2: () => `了解,开始以白描+调色盘衍生法生成重要角色档案JSON:`
},
stranger: {
u1: v => `你是TRPG数据整理助手。从剧情文本中提取{{user}}遇到的陌生人/NPC整理为JSON数组。`,
a1: () => `明白。请提供【世界观】和【剧情经历】我将提取角色并以JSON数组输出。`,
@@ -585,6 +656,7 @@ 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 buildImportantNpcGenerationMessages = v => build('importantNpc', v);
export const buildExtractStrangersMessages = v => build('stranger', v);
export const buildWorldGenStep1Messages = v => build('worldGenStep1', v);
export const buildWorldGenStep2Messages = v => build('worldGenStep2', v);

File diff suppressed because one or more lines are too long

View File

@@ -32,7 +32,7 @@ import { StoryOutlineStorage } from "../../core/server-storage.js";
import { promptManager } from "../../../../../openai.js";
import {
buildSmsMessages, buildSummaryMessages, buildSmsHistoryContent, buildExistingSummaryContent,
buildNpcGenerationMessages, formatNpcToWorldbookContent, buildExtractStrangersMessages,
buildNpcGenerationMessages, buildImportantNpcGenerationMessages, formatNpcToWorldbookContent, buildExtractStrangersMessages,
buildWorldGenStep1Messages, buildWorldGenStep2Messages, buildWorldSimMessages, buildSceneSwitchMessages,
buildInviteMessages, buildLocalMapGenMessages, buildLocalMapRefreshMessages, buildLocalSceneGenMessages,
buildOverlayHtml, MOBILE_LAYOUT_STYLE, DESKTOP_LAYOUT_STYLE, getPromptConfigPayload, setPromptConfig
@@ -874,14 +874,17 @@ async function handleCheckStrangerWb({ requestId, 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 }) {
async function handleGenNpc({ requestId, strangerName, strangerInfo, npcType = 'npc' }) {
try {
const comm = getCommSettings();
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 msgs = buildNpcGenerationMessages(getCommonPromptVars({ strangerName, strangerInfo: strangerInfo || '(无描述)' }));
const vars = getCommonPromptVars({ strangerName, strangerInfo: strangerInfo || '(无描述)' });
const msgs = npcType === 'importantNpc'
? buildImportantNpcGenerationMessages(vars)
: buildNpcGenerationMessages(vars);
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}`);