酒馆拆解的维护记录
这是酒馆 SillyTavern背后的事。正常阅读不用看,要对照上游更新、核对出处、重跑实验时再看。
版本和素材
- 上游:SillyTavern/SillyTavern,AGPL-3.0 许可,许可全文随源码存档;文档仓库 SillyTavern/SillyTavern-Docs 只当线索读
- 读的版本:
release分支06bde93(1.19.0,2026-09-14 提交),2026-10-20 读;文档仓库ed5423d。各页不逐处标版本,上游更新了再对照这一个点 - 源码存档:
assets/st-src/是这个版本里细讲页引到的文件,逐字未改,目录结构和原仓库一致,行号按存档算 - 素材:随包的角色卡 Seraphina(
default/content/下的 PNG)和世界书 Eldoria,本站手译成中文(塞拉菲娜、埃尔多利亚),结构、宏、字段、关键词数量一概不动;酒馆自带的英文提示词(主提示、末尾指令、群聊提示、摘要指令等)保持原文。世界书原件在assets/st-src/default/content/Eldoria.json,从卡里读出的英文 JSON 和两份译本在assets/sim/material/
实验设置
细讲页里标着「效果」的块,来自 2026-10-20 的一次实跑,材料在 assets/sim/:
- 怎么跑的:没起酒馆,在 Node 里用加载钩子原样加载酒馆的前端模块:世界书、Prompt Manager、
openai.js、作者注释、宏引擎、正则引擎、instruct、bookmarks.js等 33 个(清单见harness/real.txt),src/prompt-converters.js和服务端组 DeepSeek 请求的那段也原样调用。其余模块换成桩,桩的导出名按全仓库实际 import 了哪些名字自动生成 script.js和group-chats.js:这两个文件一加载就要整个前端,不能直接 import。make-stubs.mjs按名字把用得到的五十来个函数原样切出来(getCharacterCardFields、cleanUpMessage、saveChat、syncMesToSwipe、群聊调度等),拼成替身模块,函数体一字不改,切出的范围写在生成文件的头注释里;只有渲染消息、图片附件这类纯界面函数换成空函数- 胶水:
lib.mjs照Generate()在 Chat Completion 路径上的顺序依次调这些函数,只有调用顺序是写的,拼接、筛选、预算、清洗都是酒馆的原函数。界面上的 swipe、群聊的逐个生成、摘要扩展的手动触发,各照原函数对数据做的几步改,脚本注释里写了对应的行号 - 桩被碰到的地方:主线跑完统计过(
out/main-stub-hits.json),只有斜杠命令注册、DOM 查询、getPresetManager三类,拼 prompt 的路径没碰到桩。getPresetManager返回空,等于预设没带正则脚本,和出厂一致 - token 计数:前端本来发给服务端数,这里在同一进程里用酒馆服务端同款的 DeepSeek 分词器数
- 设置:新装酒馆的出厂设置,只在连接面板上改了三项:来源 DeepSeek、模型
deepseek-flash、关流式。出厂上下文 4095、回复 300、温度 1、世界书深度 2、预算 25%、递归开,取自default/content/settings.json - 改过出厂设置的地方:摘要来源从已废弃的 Extras 切到主模型;第二次摘要把「覆盖回复长度」调到 1024。其余实验里调过的设置(作者注释、角色笔记、深度注入、Join、正则脚本、Text Completion 的 ChatML)都在页面上注明了
- 模型调用:酒馆服务端会把请求发到
https://api.deepseek.com/beta/chat/completions,脚本只把域名换成本站的 DeepSeek 中转,路径和请求体一字不改 - 构造的输入:第二个角色洛恩、完整字段版塞拉菲娜(原卡只有描述和开场白,示范对话拆到自己的字段,补了性格、场景、卡内 system prompt、末尾指令、备选开场白、角色笔记)、世界书各项实验的条目、状态栏消息、文件头里的好感变量、监听器插的那条 system,页面上都注明了
- 随机数:群聊调度和开场白用固定种子;第一轮群聊的种子
group-r1-1是挑过的,为了让两人都开口。{{random}}用的是酒馆自带的熵源,重跑结果会变;{{pick}}以聊天 ID 为种子,可复现 - 跑了什么:扮演主线五轮,含一次 swipe、两次 continue、一次 impersonate;摘要两次(出厂、1024);群聊两轮三次生成;其余实验不调模型
- 用量:主线 12 次调用共 24488 输入 token,其中 20224 命中前缀缓存,输出 3294;群聊 3 次共 1938 输入、493 输出。主线用量表里第 10 条是摘要脚本改版前多跑的一次出厂摘要,输出已被后两次覆盖,只留了用量
- 隔离:酒馆的代码只在一个没有 API key 的 Node 进程里跑(启动时环境变量里带 KEY、TOKEN、SECRET 的全部去掉),模型请求由另一个 Python 进程发
重跑:把上游克隆到本地,ST_ROOT 指过去,在 harness/ 里装好 package.json 的依赖,跑 run_all.sh,再跑 pack.py 生成页面上的效果块。脚本里的输出目录是本机路径,换机器要改。
实跑脚本
hooks.mjs:加载钩子,指定模块原样加载,其余换桩
// Node 模块加载钩子:原样加载酒馆的源码文件,只把指定的依赖模块换成桩。
// 桩模块的导出名 = 全仓库实际 import 了它哪些名字;stubs/<相对 public 的路径>.mjs 里手写了的用手写的,其余给空桩。
import { pathToFileURL, fileURLToPath } from 'node:url';
import fs from 'node:fs';
import path from 'node:path';
const HARNESS = path.dirname(fileURLToPath(import.meta.url));
const ST = process.env.ST_ROOT;
const PUB = path.join(ST, 'public');
const SRC = path.join(ST, 'src');
const STUB_DIR = path.join(HARNESS, 'stubs');
const SHIMS_URL = pathToFileURL(path.join(HARNESS, 'shims.mjs')).href;
const BARE_PARENT = pathToFileURL(path.join(HARNESS, 'package.json')).href;
// public/ 下只有 ST_REAL 列出的模块原样加载,其余一律换成桩;src/ 下只有 ST_STUBS 列出的换成桩
const REAL_FILE = path.join(HARNESS, 'real.txt');
const REAL = new Set([
...(process.env.ST_REAL || '').split(','),
...(fs.existsSync(REAL_FILE) ? fs.readFileSync(REAL_FILE, 'utf8').split('\n').map(s => s.replace(/#.*$/, '').trim()) : []),
].filter(Boolean));
const SRC_STUBS = new Set(['src/util.js', ...(process.env.ST_STUBS || '').split(',').filter(Boolean)]);
const STUBBED = {
has(rel) {
if (rel.startsWith('public' + path.sep)) return !REAL.has(rel);
return SRC_STUBS.has(rel);
},
};
function isBare(s) {
return !(s.startsWith('.') || s.startsWith('/') || s.startsWith('node:') || s.startsWith('file:') || s.startsWith('data:'));
}
// ---------- 扫描:每个模块被 import 了哪些名字 ----------
let importMap = null;
function walk(dir, out = []) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p, out);
else if (e.name.endsWith('.js')) out.push(p);
}
return out;
}
function resolveSpec(fromFile, spec) {
if (spec.startsWith('/')) return path.join(PUB, spec);
if (spec.startsWith('.')) return path.resolve(path.dirname(fromFile), spec);
return null;
}
function buildImportMap() {
const map = new Map();
const add = (target, name) => {
if (!map.has(target)) map.set(target, new Set());
map.get(target).add(name);
};
const files = [...walk(path.join(PUB, 'scripts')), path.join(PUB, 'script.js'), ...walk(SRC)];
const re = /(?:import|export)\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"]/g;
for (const f of files) {
const text = fs.readFileSync(f, 'utf8');
for (const m of text.matchAll(re)) {
const clause = m[1];
const target = resolveSpec(f, m[2]);
if (!target) continue;
if (/^[\s\S]*\*\s+as\s+\w+/.test(clause) && !clause.includes('{')) { add(target, '*'); continue; }
const braces = clause.match(/\{([\s\S]*?)\}/);
if (braces) {
for (let part of braces[1].split(',')) {
part = part.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '').trim();
if (!part) continue;
part = part.replace(/^type\s+/, '');
const name = part.split(/\s+as\s+/)[0].trim();
if (/^[A-Za-z_$][\w$]*$/.test(name)) add(target, name === 'default' ? 'default' : name);
}
}
const head = clause.replace(/\{[\s\S]*?\}/, '').replace(/,/g, ' ').trim();
if (/^[A-Za-z_$][\w$]*$/.test(head) && !clause.startsWith('*') && m[0].startsWith('import')) add(target, 'default');
}
}
return map;
}
function exportedNames(file, seen = new Set()) {
const names = new Set();
if (!fs.existsSync(file) || seen.has(file)) return names;
seen.add(file);
const text = fs.readFileSync(file, 'utf8');
for (const m of text.matchAll(/export\s+(?:async\s+)?(?:const|let|var|function\*?|class)\s+([A-Za-z_$][\w$]*)/g)) names.add(m[1]);
for (const m of text.matchAll(/export\s*\{([^}]*)\}(?!\s*from)/g)) {
for (const p of m[1].split(',')) {
const n = p.trim().split(/\s+as\s+/).pop().trim();
if (n) names.add(n);
}
}
for (const m of text.matchAll(/export\s*\{([^}]*)\}\s*from/g)) {
for (const p of m[1].split(',')) {
const n = p.trim().split(/\s+as\s+/).pop().trim();
if (n) names.add(n);
}
}
for (const m of text.matchAll(/export\s*\*\s*from\s*['"]([^'"]+)['"]/g)) {
const target = m[1].startsWith('.') ? path.resolve(path.dirname(file), m[1]) : m[1];
for (const n of exportedNames(target, seen)) if (n !== 'default') names.add(n);
}
if (/export\s+default\s/.test(text)) names.add('default');
return names;
}
function wantedMissing(target) {
importMap ??= buildImportMap();
const rel = path.relative(ST, target);
const overrideFile = path.join(STUB_DIR, rel + '.mjs');
const have = exportedNames(overrideFile);
const wanted = importMap.get(target) || new Set();
return { rel, overrideFile, have, missing: [...wanted].filter(n => n !== '*' && n !== 'default' && !have.has(n)), wantsDefault: wanted.has('default') };
}
function genStub(target) {
const { rel, overrideFile, have, wantsDefault } = wantedMissing(target);
const lines = [];
if (fs.existsSync(overrideFile)) lines.push(`export * from ${JSON.stringify(pathToFileURL(overrideFile).href)};`);
lines.push(`export * from ${JSON.stringify('ststub-auto:' + target)};`);
if (have.has('default')) lines.push(`export { default } from ${JSON.stringify(pathToFileURL(overrideFile).href)};`);
else if (wantsDefault) lines.push(`export { default } from ${JSON.stringify('ststub-auto:' + target)};`);
return lines.join('\n');
}
// 自动空桩单独成模块,只依赖 shims:循环依赖里先被读到也不会卡在初始化之前
function genAuto(target) {
const { rel, have, missing, wantsDefault } = wantedMissing(target);
const lines = [`import { anyStub as __any } from ${JSON.stringify(SHIMS_URL)};`];
for (const n of missing) lines.push(`export const ${n} = __any(${JSON.stringify(rel + ':' + n)});`);
if (wantsDefault && !have.has('default')) lines.push(`export default __any(${JSON.stringify(rel + ':default')});`);
return lines.join('\n');
}
export async function resolve(specifier, context, nextResolve) {
if (specifier.startsWith('ststub-auto:')) return { url: specifier, shortCircuit: true, format: 'module' };
const parent = context.parentURL?.startsWith('file:') ? fileURLToPath(context.parentURL) : '';
const inST = parent.startsWith(ST);
if (inST && specifier.startsWith('/') && !specifier.startsWith(ST)) {
specifier = pathToFileURL(path.join(PUB, specifier)).href;
}
if ((inST || parent.startsWith(STUB_DIR)) && isBare(specifier)) {
return nextResolve(specifier, { ...context, parentURL: BARE_PARENT });
}
const r = await nextResolve(specifier, context);
if (r.url.startsWith('file:')) {
const file = fileURLToPath(r.url);
if (file.startsWith(ST) && STUBBED.has(path.relative(ST, file))) {
return { url: 'ststub:' + file, shortCircuit: true, format: 'module' };
}
}
return r;
}
export async function load(url, context, nextLoad) {
if (url.startsWith('ststub-auto:')) {
return { format: 'module', source: genAuto(url.slice('ststub-auto:'.length)), shortCircuit: true };
}
if (url.startsWith('ststub:')) {
return { format: 'module', source: genStub(url.slice('ststub:'.length)), shortCircuit: true };
}
return nextLoad(url, context);
}
make-stubs.mjs:从 script.js、group-chats.js 按名字原样切函数
// 生成三个桩模块,里面的函数全部从酒馆源码按名字原样切出(extract.mjs),一字不改:
// stubs/public/script-state.mjs script.js 的常量和模块级状态(单独成模块,没有 import,循环依赖时先初始化)
// stubs/public/script.js.mjs script.js 里用得到的函数;纯界面的几个(渲染消息、图片附件)换成空函数
// stubs/public/scripts/group-chats.js.mjs 群聊调度函数(原文件没有导出它们)
// script.js / group-chats.js 一加载就要整个前端(顶层等 document ready、操作 DOM),所以不能直接 import。
import fs from 'node:fs';
import path from 'node:path';
import { extractDecls } from './extract.mjs';
const ST = process.env.ST_ROOT;
const PUB = path.join(ST, 'public');
const HERE = path.dirname(new URL(import.meta.url).pathname);
const P = (rel) => JSON.stringify(path.join(PUB, rel));
const listing = (file, decls) => `// 原样切自 ${file}(行号是原文件的):\n` + decls.map(d => `// ${d.name} ${d.startLine}-${d.endLine}`).join('\n');
// ---------- script-state.mjs ----------
const CONSTS = ['neutralCharacterName', 'extension_prompt_types', 'extension_prompt_roles', 'MAX_INJECTION_DEPTH', 'talkativeness_default',
'depth_prompt_depth_default', 'depth_prompt_role_default', 'default_avatar', 'system_avatar'];
const constDecls = [
...extractDecls(path.join(PUB, 'scripts/system-messages.js'), ['system_message_types']),
...extractDecls(path.join(PUB, 'script.js'), CONSTS),
];
const STATE = {
name1: '\'User\'', name2: '\'SillyTavern System\'', chat: '[]', characters: '[]', this_chid: 'undefined',
chat_metadata: '{}', extension_prompts: '{}', main_api: '\'openai\'', online_status: '\'no_connection\'',
user_avatar: '\'user-default.png\'', is_send_press: 'false', generation_started: 'new Date()',
max_context: '8192', amount_gen: '350',
};
fs.writeFileSync(path.join(HERE, 'stubs/public/script-state.mjs'),
`// 自动生成,勿手改。script.js 的常量和模块级状态;实跑脚本用 __set 改状态。\n${listing('public/script.js 与 public/scripts/system-messages.js', constDecls)}\n`
+ constDecls.map(d => d.text).join('\n') + '\n\n'
+ Object.entries(STATE).map(([k, v]) => `export let ${k} = ${v};`).join('\n') + '\n'
+ `export function __set(key, value) {\n switch (key) {\n${Object.keys(STATE).map(k => ` case '${k}': ${k} = value; break;`).join('\n')}\n default: throw new Error('unknown key ' + key);\n }\n}\n`);
// ---------- script.js.mjs ----------
const fromScript = extractDecls(path.join(PUB, 'script.js'), [
'getCurrentChatId', 'substituteParams', 'addPersonaDescriptionExtensionPrompt', 'getAllExtensionPrompts',
'getExtensionPromptByName', 'getExtensionPromptMaxDepth', 'getExtensionPrompt', 'baseChatReplace',
'createLazyFields', 'getCharacterCardFieldsLazy', 'getCharacterCardFields', 'parseMesExamples',
'extractMessageBias', 'getBiasStrings', 'flushWIInjections', 'setExtensionPrompt',
'getExtensionPromptRoleByName', 'removeDepthPrompts', 'syncMesToSwipe', 'syncSwipeToMes', 'ensureSwipes',
'getMaxContextTokens', 'getMaxResponseTokens', 'getMaxPromptTokens',
'saveReply', 'cleanUpMessage', 'cleanGroupMessage', 'getStoppingStrings', 'extractMessageFromData',
'getGeneratingApi', 'getGeneratingModel', 'doChatInject', 'getFirstMessage',
'substituteParamsExtended', 'getMediaDisplay', 'getMediaIndex', 'substituteParamsLegacy',
'saveChat', 'getCurrentChatDetails', 'updateMessage', 'removeMacros',
]);
const scriptHeader = `// 自动生成,勿手改。
${listing('public/script.js', fromScript)}
export * from './script-state.mjs';
import { ${[...CONSTS, 'system_message_types', ...Object.keys(STATE)].join(', ')} } from './script-state.mjs';
import { Handlebars } from ${P('lib.js')};
import { power_user, collapseNewlines, fixMarkdown, getCustomStoppingStrings } from ${P('scripts/power-user.js')};
import { persona_description_positions } from ${P('scripts/personas.js')};
import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from ${P('scripts/authors-note.js')};
import { extension_settings } from ${P('scripts/extensions.js')};
import { selected_group, groups, getGroupCharacterCardsLazy, getGroupNames, group_generation_id } from ${P('scripts/group-chats.js')};
import { inject_ids, MEDIA_DISPLAY } from ${P('scripts/constants.js')};
import { MacroEnvBuilder } from ${P('scripts/macros/engine/MacroEnvBuilder.js')};
import { MacroEngine } from ${P('scripts/macros/engine/MacroEngine.js')};
import { getChatCompletionModel, oai_settings } from ${P('scripts/openai.js')};
import { getRegexedString, regex_placement } from ${P('scripts/extensions/regex/engine.js')};
import { escapeRegex, trimToEndSentence } from ${P('scripts/utils.js')};
import { PromptReasoning, parseReasoningInSwipes } from ${P('scripts/reasoning.js')};
import { getInstructStoppingSequences } from ${P('scripts/instruct-mode.js')};
import { getMessageTimeStamp } from ${P('scripts/RossAscends-mods.js')};
import { getTokenCountAsync } from ${P('scripts/tokenizers.js')};
import { eventSource, event_types } from ${P('scripts/events.js')};
import { evaluateMacros } from ${P('scripts/macros.js')};
import { compressRequest } from ${P('scripts/request-compression.js')};
import { uuidv4 } from ${P('scripts/utils.js')};
export { eventSource, event_types };
// ---- 纯界面的函数换成空函数:渲染消息、图片附件、头像缩略图 ----
function addOneMessage() {}
async function processImageAttachment() {}
function getThumbnailUrl() { return ''; }
function statMesProcess() {}
// ---- 其余 script.js 导出,实跑用不到具体行为 ----
export function getRequestHeaders() { return { 'Content-Type': 'application/json' }; }
export function saveSettingsDebounced() {}
export async function saveChatConditional() {}
export async function saveMetadata() {}
export function getCharacterAvatar() { return ''; }
`;
fs.writeFileSync(path.join(HERE, 'stubs/public/script.js.mjs'), scriptHeader + '\n'
+ fromScript.map(d => d.text).join('\n\n')
+ '\nexport { flushWIInjections, addPersonaDescriptionExtensionPrompt, getAllExtensionPrompts, cleanGroupMessage, doChatInject, getFirstMessage, updateMessage };\n');
// ---------- group-chats.js.mjs ----------
const fromGroup = extractDecls(path.join(PUB, 'scripts/group-chats.js'), [
'group_activation_strategy', 'group_generation_mode',
'getGroupNames', 'getGroupDepthPrompts', 'getGroupCharacterCardsLazy',
'activateImpersonate', 'activateSwipe', 'activateListOrder', 'activatePooledOrder', 'activateNaturalOrder',
'getFirstCharacterMessage',
]);
fs.mkdirSync(path.join(HERE, 'stubs/public/scripts'), { recursive: true });
fs.writeFileSync(path.join(HERE, 'stubs/public/scripts/group-chats.js.mjs'), `// 自动生成,勿手改。
${listing('public/scripts/group-chats.js', fromGroup)}
import { characters, chat, substituteParams, baseChatReplace, getExtensionPromptRoleByName, talkativeness_default, depth_prompt_depth_default, depth_prompt_role_default, createLazyFields, chat_metadata, name1, name2, eventSource, event_types, default_avatar } from ${P('script.js')};
import { getMessageTimeStamp } from ${P('scripts/RossAscends-mods.js')};
const getThumbnailUrl = () => '';
import { shuffle, extractAllWords, onlyUnique } from ${P('scripts/utils.js')};
import { power_user } from ${P('scripts/power-user.js')};
import { extension_settings } from ${P('scripts/extensions.js')};
export let groups = [];
export let selected_group = null;
export function __setGroup(list, id) { groups = list; selected_group = id; }
export let group_generation_id = null;
export function __setGroupGenId(v) { group_generation_id = v; }
` + fromGroup.map(d => d.text).join('\n\n')
+ '\nexport { activateImpersonate, activateSwipe, activateListOrder, activatePooledOrder, activateNaturalOrder, getFirstCharacterMessage };\n');
// ---------- server-count.mjs:服务端按 DeepSeek 分词器数 token 的那几行 ----------
const fromTok = extractDecls(path.join(ST, 'src/endpoints/tokenizers.js'), ['countWebTokenizerTokens']);
fs.writeFileSync(path.join(HERE, 'stubs/server-count.mjs'), `// 自动生成,勿手改。\n${listing('src/endpoints/tokenizers.js', fromTok)}\n`
+ `function guesstimate(str) { return Math.ceil(new TextEncoder().encode(str).length / 3.35); }\n` + fromTok.map(d => d.text).join('\n') + '\n');
console.log('ok', constDecls.length, fromScript.length, fromGroup.length);
// ---------- memory-ext.mjs:摘要扩展里决定「何时摘要、提示词是什么、摘要放哪」的几段 ----------
const MEM = path.join(PUB, 'scripts/extensions/memory/index.js');
const fromMem = extractDecls(MEM, ['MODULE_NAME', 'summary_sources', 'prompt_builders', 'defaultPrompt', 'defaultTemplate', 'defaultSettings',
'formatMemoryValue', 'getSummaryPromptForNow', 'setMemoryContext']);
fs.writeFileSync(path.join(HERE, 'stubs/memory-ext.mjs'), `// 自动生成,勿手改。\n${listing('public/scripts/extensions/memory/index.js', fromMem)}
import { substituteParamsExtended, setExtensionPrompt, extension_prompt_types, extension_prompt_roles, is_send_press } from ${P('script.js')};
import { extension_settings, getContext } from ${P('scripts/extensions.js')};
import { selected_group, is_group_generating } from ${P('scripts/group-chats.js')};
import { waitUntilCondition, extractAllWords } from ${P('scripts/utils.js')};
const saveChatDebounced = () => {};
` + fromMem.map(d => d.text).join('\n\n') + '\nexport { MODULE_NAME, defaultSettings, formatMemoryValue, getSummaryPromptForNow, setMemoryContext };\n');
extract.mjs:按名字切出函数声明的扫描器
// 按名字从酒馆源码里原样切出顶层声明(函数、常量),返回 { name, start, end, text }。
// 切的时候跳过字符串、模板串、注释里的括号;切出来的文本一字不改。
import fs from 'node:fs';
function findEnd(src, from, untilSemicolon = false) {
let i = from, depth = 0, started = false;
const stack = [];
while (i < src.length) {
const c = src[i], n = src[i + 1];
if (c === '/' && n === '/') { i = src.indexOf('\n', i); if (i < 0) return src.length; continue; }
if (c === '/' && n === '*') { i = src.indexOf('*/', i + 2) + 2; continue; }
if (c === '/') {
// 正则字面量:斜杠前是运算符、开括号、逗号等,按正则跳过(含字符类里的斜杠)
let k = i - 1;
while (k >= 0 && /\s/.test(src[k])) k--;
if (k < 0 || '(,=:[!&|?{};+-*%<>~^'.includes(src[k]) || /\b(return|typeof|case|in|of)$/.test(src.slice(Math.max(0, k - 6), k + 1))) {
i++;
let inClass = false;
while (i < src.length) {
if (src[i] === '\\') { i += 2; continue; }
if (src[i] === '[') inClass = true;
else if (src[i] === ']') inClass = false;
else if (src[i] === '/' && !inClass) break;
else if (src[i] === '\n') break;
i++;
}
i++;
continue;
}
}
if (c === '\'' || c === '"') {
i++;
while (i < src.length && src[i] !== c) { if (src[i] === '\\') i++; i++; }
i++; continue;
}
if (c === '`') {
i++;
while (i < src.length && src[i] !== '`') {
if (src[i] === '\\') { i += 2; continue; }
if (src[i] === '$' && src[i + 1] === '{') {
// 模板里的 ${...}:递归找配对
const end = findEnd(src, i + 1);
i = end; continue;
}
i++;
}
i++; continue;
}
if (c === '{' || c === '(' || c === '[') { depth++; started = true; stack.push(c); }
else if (c === '}' || c === ')' || c === ']') {
depth--; stack.pop();
if (started && depth === 0 && !untilSemicolon) {
// 声明到 } 为止;常量声明要吃掉结尾的分号
let j = i + 1;
if (src[j] === ';') j++;
return j;
}
} else if (c === ';' && depth === 0 && (untilSemicolon || !started)) { return i + 1; }
i++;
}
return src.length;
}
export function extractDecls(file, names) {
const src = fs.readFileSync(file, 'utf8');
const lines = src.split('\n');
const out = [];
for (const name of names) {
const re = new RegExp(`^(export\\s+)?(async\\s+)?(function\\*?\\s+${name}\\s*\\(|(const|let|var)\\s+${name}\\s*=)`, 'm');
const m = re.exec(src);
if (!m) throw new Error(`没找到 ${name}`);
const start = m.index;
let end;
if (m[3].startsWith('function')) {
// 跳过参数表,再找函数体
const paren = src.indexOf('(', start);
const afterParams = findEnd(src, paren);
const brace = src.indexOf('{', afterParams);
end = findEnd(src, brace);
} else {
end = findEnd(src, start + m[0].length, true);
}
const startLine = src.slice(0, start).split('\n').length;
const endLine = src.slice(0, end).split('\n').length;
out.push({ name, startLine, endLine, text: lines.slice(startLine - 1, endLine).join('\n') });
}
return out;
}
lib.mjs:按 Generate() 的顺序调酒馆函数
// 实跑用的酒馆会话。照 public/script.js 里 Generate() 在 Chat Completion(main_api = 'openai')路径上的顺序,
// 依次调酒馆自己的函数:世界书、作者注释、Prompt Manager、预算、服务端的 DeepSeek 后处理。
// 这里只有「按顺序调用」的胶水,拼接、筛选、预算、清洗全是酒馆的原函数。注释里的行号都指 public/script.js,另注明的除外。
// 进程里没有 API key:generate() 只产出请求体,由另一个进程发给模型,回包再交给 receive()。
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import { fileURLToPath } from 'node:url';
const ST = process.env.ST_ROOT;
const PUB = path.join(ST, 'public');
const HERE = path.dirname(fileURLToPath(import.meta.url));
export const S = await import(path.join(PUB, 'script.js'));
export const OAI = await import(path.join(PUB, 'scripts/openai.js'));
export const PM = await import(path.join(PUB, 'scripts/PromptManager.js'));
export const WI = await import(path.join(PUB, 'scripts/world-info.js'));
export const PU = await import(path.join(PUB, 'scripts/power-user.js'));
export const AN = await import(path.join(PUB, 'scripts/authors-note.js'));
export const MS = await import(path.join(PUB, 'scripts/macros/macro-system.js'));
export const C = await import(path.join(PUB, 'scripts/constants.js'));
export const RX = await import(path.join(PUB, 'scripts/extensions/regex/engine.js'));
export const RS = await import(path.join(PUB, 'scripts/reasoning.js'));
export const G = await import(path.join(PUB, 'scripts/group-chats.js'));
export const EXT = await import(path.join(PUB, 'scripts/extensions.js'));
export const PC = await import(path.join(ST, 'src/prompt-converters.js'));
const { countWebTokenizerTokens } = await import(path.join(HERE, 'stubs/server-count.mjs'));
export const MEM = await import(path.join(HERE, 'stubs/memory-ext.mjs'));
// ---------- token 计数:前端 countTokensOpenAIAsync 发 /api/tokenizers/openai/count,这里在本进程里答 ----------
// 服务端对 DeepSeek 用 SillyTavern-Tokenizers 仓库的 deepseek.json(src/endpoints/tokenizers.js:264、990-995)
const tokenizerFile = path.join(HERE, '../tokenizers/deepseek.json.gz');
// 这个包看到全局有 window/document 就按浏览器加载 wasm,建分词器时临时移开前端的全局桩
const dsTokenizer = await (async () => {
const saved = { window: globalThis.window, document: globalThis.document };
delete globalThis.window; delete globalThis.document;
try {
const { Tokenizer } = await import('@agnai/web-tokenizers');
return await Tokenizer.fromJSON(zlib.gunzipSync(fs.readFileSync(tokenizerFile)));
} finally { Object.assign(globalThis, saved); }
})();
globalThis.__ajax = async (opts) => {
if (String(opts.url).startsWith('/api/tokenizers/openai/count')) {
const messages = JSON.parse(opts.data);
return { token_count: countWebTokenizerTokens(dsTokenizer, messages) };
}
throw new Error('unexpected ajax ' + opts.url);
};
// ---------- 服务端的两个聊天接口:前端 fetch 到这里,按服务端的写法落盘 ----------
// /api/chats/save:src/endpoints/chats.js:545-575 → trySaveChat(532-543)把数组逐条 JSON.stringify、按行拼成 JSONL
// /api/characters/chats:列出这张卡已有的聊天文件名
export const DATA_DIR = process.env.ST_DATA || path.join(HERE, '../out/data');
const realFetch = globalThis.fetch;
globalThis.fetch = async (url, init = {}) => {
const u = String(url);
if (u === '/api/chats/save') {
const body = JSON.parse(init.body);
const cardName = String(body.avatar_url).replace('.png', '');
const dir = path.join(DATA_DIR, 'chats', cardName);
fs.mkdirSync(dir, { recursive: true });
const jsonlData = body.chat?.map(m => JSON.stringify(m)).join('\n');
fs.writeFileSync(path.join(dir, `${body.file_name}.jsonl`), jsonlData);
return new Response(JSON.stringify({ ok: true }), { status: 200 });
}
if (u === '/api/characters/chats') {
const body = JSON.parse(init.body);
const dir = path.join(DATA_DIR, 'chats', String(body.avatar_url).replace('.png', ''));
const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter(f => f.endsWith('.jsonl')) : [];
return new Response(JSON.stringify(files.map(f => ({ file_name: f }))), { status: 200 });
}
if (u.startsWith('/')) throw new Error('unexpected fetch ' + u);
return realFetch(url, init);
};
export function countTokens(text) { return dsTokenizer.encode(text).length; }
// 世界书每轮激活了哪些条目:getWorldInfoPrompt 发 WORLD_INFO_ACTIVATED(world-info.js:900-903),这里接住
let lastActivated = [];
let lastPromptReasoning = null; // receive 里 continue 要用它切掉思考前缀(5509-5512)
S.eventSource.on(S.event_types.WORLD_INFO_ACTIVATED, (entries) => { lastActivated = entries; });
const settingsJson = () => JSON.parse(fs.readFileSync(path.join(ST, 'default/content/settings.json'), 'utf8'));
let initialized = false;
let wiInitialized = false;
let baseOai = null;
/**
* 新装酒馆 + 一张卡 + 若干本世界书,开一段新聊天。
* oaiPatch:用户在连接面板上改的设置(换成 DeepSeek、关流式);其余全是 default/content/settings.json 的出厂值。
*/
export async function init({ card, books = {}, name1 = 'User', persona = '', oaiPatch = {}, wiPatch = {}, powerUserPatch = {}, chatMetadata = {}, globalBooks = [] }) {
const settings = settingsJson();
// power_user:出厂设置覆盖模块里的默认值(power-user.js 的 loadPowerUserSettings 做的是同一件事)
Object.assign(PU.power_user, structuredClone(settings.power_user), powerUserPatch);
PU.power_user.persona_description = persona;
if (!initialized) {
MS.initRegisterMacros(); // 新宏引擎注册内置宏(macro-system.js:66)
AN.initAuthorsNote(); // 作者注释:CHAT_CHANGED 时载入默认值(authors-note.js:476、582)
initialized = true;
}
// 聊天补全设置:Default 预设 + 出厂设置,再叠用户在连接面板上的改动(openai.js:4320 loadOpenAISettings)
// loadOpenAISettings 和 Prompt Manager 的初始化都是启动时调一次的;之后每次 init 从第一次的快照还原,再叠这次的改动
if (!baseOai) {
const preset = fs.readFileSync(path.join(ST, 'default/content/presets/openai/Default.json'), 'utf8');
OAI.loadOpenAISettings({ openai_setting_names: ['Default'], openai_settings: [preset] }, { ...settings.oai_settings });
OAI.setupChatCompletionPromptManager(OAI.oai_settings);
baseOai = structuredClone(OAI.oai_settings);
}
for (const [k, v] of Object.entries(baseOai)) OAI.oai_settings[k] = structuredClone(v);
Object.assign(OAI.oai_settings, structuredClone(oaiPatch));
// 扩展设置回到出厂(作者注释、摘要、正则)
EXT.extension_settings.note = { default: '', chara: [], wiAddition: [], allowWIScan: false };
EXT.extension_settings.memory = {};
EXT.extension_settings.regex = [];
// 世界书:出厂设置 + 已有的书(world-info.js:917 setWorldInfoSettings)
// setWorldInfoSettings 是启动时调一次的(会追加已选的书、注册事件),之后换设置用 updateWorldInfoSettings(world-info.js:819)
const wiSettings = { ...structuredClone(settings.world_info_settings), ...wiPatch };
if (!wiInitialized) {
WI.setWorldInfoSettings({ ...wiSettings, world_info: { globalSelect: [] } }, { world_names: Object.keys(books) });
wiInitialized = true;
}
WI.updateWorldInfoSettings(wiSettings, globalBooks);
WI.worldInfoCache.clear?.();
for (const [name, book] of Object.entries(books)) WI.worldInfoCache.set(name, structuredClone(book));
// 角色和聊天
S.__set('characters', [{ ...structuredClone(card), avatar: 'seraphina.png', chat: `${card.name} - 实跑` }]);
S.__set('this_chid', 0);
S.__set('name1', name1);
S.__set('name2', card.name);
S.__set('chat_metadata', structuredClone(chatMetadata));
S.__set('chat', []);
S.__set('extension_prompts', {});
G.__setGroup([], null);
// getChatResult(7684-7706):空聊天先放开场白
const first = S.getFirstMessage();
if (first.mes) S.chat.push(first);
await S.eventSource.emit(S.event_types.CHAT_CHANGED, S.getCurrentChatId());
}
/** 群聊:多张卡,selected_group 指向一个群(group-chats.js 的数据形状) */
export function setGroup(cards, group) {
S.__set('characters', cards.map(c => structuredClone(c)));
G.__setGroup([group], group.id);
}
export function setCharacter(chid) {
S.__set('this_chid', chid);
S.__set('name2', S.characters[chid].name);
}
/**
* Generate(type) 在 Chat Completion 路径上的拼装。返回发往服务端的 generate_data、服务端发给 DeepSeek 的请求体,
* 以及这一轮的中间产物(世界书结果、注入表、每条消息的 token 数)。
*/
export async function generate({ type = 'normal', text = '', dryRun = false, quietPrompt = '', skipWIAN = false } = {}) {
const isContinue = type == 'continue';
const isImpersonate = type == 'impersonate';
const chat = S.chat;
// 4405-4412:regenerate 先删掉最后一条非用户消息
if (type === 'regenerate' && chat.length && !chat[chat.length - 1].is_user) chat.pop();
// 4433:bias
const { messageBias, promptBias } = S.getBiasStrings(text, type);
// 4447-4454 → sendMessageAsUser(5874-5923):用户消息先进 chat
if (text && !['regenerate', 'swipe', 'impersonate', 'quiet', 'continue'].includes(type) && !dryRun) {
const messageText = RX.getRegexedString(text, RX.regex_placement.USER_INPUT);
const message = {
name: S.name1,
is_user: true,
is_system: false,
send_date: new Date().toISOString(),
mes: S.substituteParams(messageText),
extra: { isSmallSys: false },
};
if (messageBias) message.extra.bias = messageBias;
S.chat_metadata.tainted = true;
chat.push(message);
}
// 4460-4470:卡字段
let { description, personality, persona, scenario, mesExamples, system, jailbreak, charDepthPrompt, creatorNotes } = S.getCharacterCardFields();
// 4472-4486:角色笔记(Character's Note)注册成 IN_CHAT 注入
S.removeDepthPrompts();
const groupDepthPrompts = G.getGroupDepthPrompts(G.selected_group, Number(S.this_chid));
if (G.selected_group && Array.isArray(groupDepthPrompts) && groupDepthPrompts.length > 0) {
groupDepthPrompts.forEach((value, index) => {
const role = S.getExtensionPromptRoleByName(value.role);
S.setExtensionPrompt(C.inject_ids.DEPTH_PROMPT_INDEX(index), value.text, S.extension_prompt_types.IN_CHAT, value.depth, EXT.extension_settings.note.allowWIScan, role);
});
} else {
const depthPromptText = charDepthPrompt || '';
const depthPromptDepth = S.characters[S.this_chid]?.data?.extensions?.depth_prompt?.depth ?? S.depth_prompt_depth_default;
const depthPromptRole = S.getExtensionPromptRoleByName(S.characters[S.this_chid]?.data?.extensions?.depth_prompt?.role ?? S.depth_prompt_role_default);
S.setExtensionPrompt(C.inject_ids.DEPTH_PROMPT, depthPromptText, S.extension_prompt_types.IN_CHAT, depthPromptDepth, EXT.extension_settings.note.allowWIScan, depthPromptRole);
}
// 4488-4491:开场白每轮重算宏
if (chat.length) chat[0].mes = S.substituteParams(chat[0].mes);
// 4493-4529:参与拼装的消息——隐藏的(is_system)去掉;swipe 时去掉当前这条
let coreChat = chat.filter(x => !x.is_system);
if (type === 'swipe') coreChat.pop();
coreChat = coreChat.map((chatItem, index) => {
const regexType = chatItem.is_user ? RX.regex_placement.USER_INPUT : RX.regex_placement.AI_OUTPUT;
const options = { isPrompt: true, depth: (coreChat.length - index - (isContinue ? 2 : 1)) };
return { ...chatItem, mes: RX.getRegexedString(chatItem.mes, regexType, options), index };
});
// 4530-4557:思考内容回放(默认 add_to_prompts 关,只是走一遍)
const promptReasoning = new RS.PromptReasoning();
lastPromptReasoning = promptReasoning;
for (let i = coreChat.length - 1; i >= 0; i--) {
const depth = coreChat.length - i - (isContinue ? 2 : 1);
const isPrefix = isContinue && i === coreChat.length - 1;
coreChat[i] = { ...coreChat[i], mes: promptReasoning.addToMessage(coreChat[i].mes, String(coreChat[i].extra?.reasoning ?? ''), isPrefix, coreChat[i].extra?.reasoning_duration) };
if (promptReasoning.isLimitReached()) break;
}
// 4559-4560:上下文上限(Chat Completion 下 = openai_max_context − openai_max_tokens)
const this_max_context = S.getMaxPromptTokens();
// 4562-4573:生成拦截器——这次没装扩展,跳过
// 4616:示范对话按 <START> 切块
let mesExamplesArray = S.parseMesExamples(mesExamples, false);
// 4619:作者注释
AN.setFloatingPrompt();
// 4621-4636:世界书
S.setExtensionPrompt(C.inject_ids.QUIET_PROMPT, quietPrompt || '', S.extension_prompt_types.IN_PROMPT, 0, true);
const chatForWI = coreChat.map(x => WI.world_info_include_names ? `${x.name}: ${x.mes}` : x.mes).reverse();
const globalScanData = {
personaDescription: persona,
characterDescription: description,
characterPersonality: personality,
characterDepthPrompt: charDepthPrompt,
scenario: scenario,
creatorNotes: creatorNotes,
trigger: C.GENERATION_TYPE_TRIGGERS.includes(type) ? type : 'normal',
};
lastActivated = [];
const wiResult = await WI.getWorldInfoPrompt(chatForWI, this_max_context, dryRun, globalScanData);
const { worldInfoBefore, worldInfoAfter, worldInfoExamples, worldInfoDepth, outletEntries } = wiResult;
S.setExtensionPrompt(C.inject_ids.QUIET_PROMPT, '', S.extension_prompt_types.IN_PROMPT, 0, true);
// 4638-4655:世界书里「示范对话前 / 后」的条目并进示范对话
for (const example of worldInfoExamples) {
const exampleMessage = example.content;
if (exampleMessage.length === 0) continue;
const cleanedExample = S.parseMesExamples(S.baseChatReplace(exampleMessage), false);
if (example.position === WI.wi_anchor_position.before) mesExamplesArray.unshift(...cleanedExample);
else mesExamplesArray.push(...cleanedExample);
}
// 4664-4681:世界书 @depth 条目注册成 IN_CHAT 注入
if (skipWIAN !== true) {
S.flushWIInjections();
if (Array.isArray(worldInfoDepth)) {
worldInfoDepth.forEach((e) => {
const joinedEntries = e.entries.join('\n');
S.setExtensionPrompt(C.inject_ids.CUSTOM_WI_DEPTH_ROLE(e.depth, e.role), joinedEntries, S.extension_prompt_types.IN_CHAT, e.depth, false, e.role);
});
}
if (outletEntries && typeof outletEntries === 'object' && Object.keys(outletEntries).length > 0) {
Object.entries(outletEntries).forEach(([key, value]) => {
S.setExtensionPrompt(C.inject_ids.CUSTOM_WI_OUTLET(key), value.join('\n'), S.extension_prompt_types.NONE, 0);
});
}
}
// 4684:用户人设
S.addPersonaDescriptionExtensionPrompt();
// 4767-4780、4866-4871、4976-4980:continue 时被续写的那条单独拿出来
let cyclePrompt = '';
if (isContinue && coreChat.length) {
cyclePrompt = coreChat[coreChat.length - 1].mes;
if (!cyclePrompt.endsWith(' ')) cyclePrompt += OAI.oai_settings.continue_postfix;
}
// 4833-4836:转成 Chat Completion 的消息
const oaiMessages = OAI.setOpenAIMessages(coreChat);
const oaiMessageExamples = OAI.setOpenAIMessageExamples(mesExamplesArray);
// 5288-5303 → openai.js:1542 prepareOpenAIMessages:槽位、注入、预算
const extensionPromptsSnapshot = structuredClone(Object.fromEntries(Object.entries(S.extension_prompts).map(([k, v]) => [k, { ...v, filter: undefined }])));
const [prompt, counts] = await OAI.prepareOpenAIMessages({
name2: S.name2,
charDescription: description,
charPersonality: personality,
scenario: scenario,
worldInfoBefore: worldInfoBefore,
worldInfoAfter: worldInfoAfter,
extensionPrompts: S.extension_prompts,
bias: promptBias,
type: type,
quietPrompt: quietPrompt,
quietImage: null,
cyclePrompt: cyclePrompt,
systemPromptOverride: system,
jailbreakPromptOverride: jailbreak,
messages: oaiMessages,
messageExamples: oaiMessageExamples,
}, dryRun);
// 每条进了上下文的消息的 identifier 和 token 数(ChatCompletion 记账,供 Prompt Manager 显示)
const cc = OAI.promptManager.messages;
const ledger = cc ? flattenLedger(cc) : null;
// openai.js:3138 sendOpenAIRequest → 2691 createGenerationParameters
const model = OAI.getChatCompletionModel(OAI.oai_settings);
const { generate_data } = await OAI.createGenerationParameters(OAI.oai_settings, model, type, prompt);
// 服务端 src/endpoints/backends/chat-completions.js:1071-1139 sendDeepSeekRequest 拼请求体
const deepseek = buildDeepSeekRequest(generate_data);
return {
type, coreChatLength: coreChat.length, this_max_context,
wi: summarizeWI(wiResult, lastActivated), chatForWI,
extensionPrompts: extensionPromptsSnapshot,
prompt, counts, ledger, generate_data, deepseek,
continueMag: isContinue ? cyclePrompt : '',
};
}
function flattenLedger(collection) {
const out = [];
const walk = (item) => {
if (item && Array.isArray(item.collection)) {
for (const x of item.collection) walk(x);
return;
}
if (item && item.role) out.push({ identifier: item.identifier, role: item.role, tokens: item.tokens, content: item.content });
};
walk(collection);
return out;
}
function summarizeWI(r, activated) {
return {
before: r.worldInfoBefore, after: r.worldInfoAfter,
examples: r.worldInfoExamples, depth: r.worldInfoDepth,
anBefore: r.anBefore, anAfter: r.anAfter, outlets: r.outletEntries,
activated: activated.map(e => ({ world: e.world, uid: e.uid, comment: e.comment, key: e.key, position: e.position, depth: e.depth, order: e.order })),
};
}
/** chat-completions.js:1071-1139 里 sendDeepSeekRequest 拼请求体的部分,原样照抄;发请求那步交给别的进程 */
export function buildDeepSeekRequest(body) {
const request = { body: structuredClone(body) };
const API_DEEPSEEK = 'https://api.deepseek.com/beta'; // chat-completions.js:86
const apiUrl = new URL(request.body.reverse_proxy || API_DEEPSEEK).toString();
let bodyParams = {};
if (request.body.logprobs > 0) {
bodyParams['top_logprobs'] = request.body.logprobs;
bodyParams['logprobs'] = true;
}
const processedMessages = PC.addAssistantPrefix(PC.postProcessPrompt(request.body.messages, PC.PROMPT_PROCESSING_TYPE.SEMI_TOOLS, PC.getPromptNames(request)), bodyParams.tools, 'prefix');
PC.addReasoningContentToToolCalls(processedMessages);
if (request.body.include_reasoning && request.body.reasoning_effort) {
bodyParams['reasoning_effort'] = request.body.reasoning_effort;
}
const requestBody = {
'messages': processedMessages,
'model': request.body.model,
'temperature': request.body.temperature,
'max_tokens': request.body.max_tokens,
'stream': request.body.stream,
'presence_penalty': request.body.presence_penalty,
'frequency_penalty': request.body.frequency_penalty,
'top_p': request.body.top_p,
'stop': request.body.stop,
'seed': request.body.seed,
'thinking': { type: request.body.include_reasoning ? 'enabled' : 'disabled' },
...bodyParams,
};
return { url: apiUrl + '/chat/completions', body: JSON.parse(JSON.stringify(requestBody)) };
}
/** Generate 的 onSuccess(5486-5535):取正文和思考、清洗、存进 chat */
export async function receive({ type = 'normal', data, continueMag = '' }) {
const isImpersonate = type == 'impersonate';
const isContinue = type == 'continue';
let getMessage = S.extractMessageFromData(data);
let reasoning = RS.extractReasoningFromData(data);
getMessage = S.cleanUpMessage({ getMessage, isImpersonate, isContinue, displayIncompleteSentences: false });
reasoning = RX.getRegexedString(reasoning, RX.regex_placement.REASONING);
if (PU.power_user.trim_spaces) reasoning = reasoning.trim();
if (isContinue) {
continueMag = lastPromptReasoning ? lastPromptReasoning.removePrefix(continueMag) : continueMag;
getMessage = continueMag + getMessage;
}
getMessage = S.cleanUpMessage({ getMessage, isImpersonate, isContinue, displayIncompleteSentences: type === 'quiet' });
if (isImpersonate || type === 'quiet') return { type, getMessage, reasoning };
const res = await S.saveReply({ type: isContinue ? 'appendFinal' : type, getMessage, reasoning });
return { ...res, reasoning, message: S.chat[S.chat.length - 1] };
}
export function snapshot() {
return { chat: structuredClone(S.chat), chat_metadata: structuredClone(S.chat_metadata) };
}
export function stubHits() {
return Object.fromEntries([...globalThis.__stubHits.entries()].filter(([k]) => !k.startsWith('$(') && !k.startsWith('toastr')));
}
stlab.py:主控,启动无密钥的 Node 进程,模型请求在这里发
"""主控:酒馆的代码在一个没有 API key 的 Node 进程里跑(server.mjs),模型请求由本进程发。
酒馆服务端把请求发到 https://api.deepseek.com/beta/chat/completions;这里只把域名换成本站的 DeepSeek 中转,
路径、请求体一字不改。
"""
import json, os, subprocess, sys, time, urllib.request
HARNESS = os.path.dirname(os.path.abspath(__file__))
ST_ROOT = os.environ.get('ST_ROOT', '/data2/work/lht/study/26.06/_ext/st/SillyTavern')
PROXY = 'https://deepseek-proxy.liuhetian.work'
ORIGIN = 'https://api.deepseek.com'
def _key():
key = os.environ.get('MY_API_KEY')
if key:
return key
for line in open('/data2/work/lht/study/26.06/zensical-wiki/.env'):
if line.startswith('MY_API_KEY='):
return line.split('=', 1)[1].strip().strip('"\'')
sys.exit('MY_API_KEY 没配')
class Lab:
def __init__(self):
env = {k: v for k, v in os.environ.items() if 'KEY' not in k and 'TOKEN' not in k and 'SECRET' not in k}
env['ST_ROOT'] = ST_ROOT
self.p = subprocess.Popen(['node', '--import', './register.mjs', 'server.mjs'], cwd=HARNESS, env=env,
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, bufsize=1)
self.n = 0
ready = json.loads(self.p.stdout.readline())
assert ready['result'] == 'ready', ready
def call(self, cmd, **args):
self.n += 1
self.p.stdin.write(json.dumps({'id': self.n, 'cmd': cmd, 'args': args}, ensure_ascii=False) + '\n')
self.p.stdin.flush()
r = json.loads(self.p.stdout.readline())
if not r['ok']:
raise RuntimeError(r['error'])
return r.get('result')
def eval(self, code, **args):
return self.call('eval', code=code, args=args)
def close(self):
self.p.stdin.close()
self.p.wait(timeout=30)
def send(deepseek, log=None):
"""把酒馆服务端拼好的请求发出去;url 只换域名。返回 (回包, 耗时秒)。"""
url = deepseek['url'].replace(ORIGIN, PROXY)
data = json.dumps(deepseek['body'], ensure_ascii=False).encode()
req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json', 'Authorization': 'Bearer ' + _key()})
t = time.time()
for attempt in range(3):
try:
with urllib.request.urlopen(req, timeout=600) as r:
resp = json.loads(r.read())
break
except Exception as e: # noqa: BLE001
if attempt == 2:
raise
time.sleep(3)
if log is not None:
log.append({'url': deepseek['url'], 'request': deepseek['body'], 'response': resp})
return resp, round(time.time() - t, 1)
run_main.py、run_memory.py、run_group.py:扮演主线、摘要、群聊
"""扮演主线:中文版塞拉菲娜,4 轮,中间做一次 swipe、一次 continue、一次 impersonate,最后用摘要扩展的提示词跑一次摘要。
每一步存三样:酒馆拼出的中间产物(世界书结果、注入表、每条消息的 token 数、发往服务端的 generate_data)、
服务端发给 DeepSeek 的请求体、DeepSeek 的回包。全部拼接都在 server.mjs 里调酒馆的函数,本进程只发请求。
"""
import json, os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from stlab import Lab, send
OUT = '/data2/work/lht/study/26.06/_ext/st-lab/out/main'
M = '/data2/work/lht/study/26.06/_ext/st-lab/out/material/'
os.makedirs(OUT, exist_ok=True)
CONNECTION = { # 用户在连接面板上改的:换成 DeepSeek、关流式;其余全是出厂设置
'chat_completion_source': 'deepseek',
'deepseek_model': 'deepseek-flash',
'stream_openai': False,
}
lab = Lab()
card = json.load(open(M + 'seraphina.zh.json'))
book = json.load(open(M + 'eldoria.zh.json'))
lab.call('init', card=card, books={'埃尔多利亚': book}, name1='旅人', oaiPatch=CONNECTION)
json.dump(lab.call('snapshot'), open(f'{OUT}/00-init.json', 'w'), ensure_ascii=False, indent=1)
usage = []
def step(tag, type_, text=''):
g = lab.call('generate', type=type_, text=text)
log = []
resp, sec = send(g['deepseek'], log)
rec = lab.call('receive', type=type_, data=resp, continueMag=g.get('continueMag', ''))
usage.append({'step': tag, 'type': type_, 'seconds': sec, 'usage': resp.get('usage')})
json.dump({'user': text, 'generate': g, 'response': resp, 'received': rec}, open(f'{OUT}/{tag}.json', 'w'), ensure_ascii=False, indent=1)
snap = lab.call('snapshot')
json.dump(snap, open(f'{OUT}/{tag}-chat.json', 'w'), ensure_ascii=False, indent=1)
msg = resp['choices'][0]['message']
print(f'== {tag} [{type_}] {sec}s finish={resp["choices"][0]["finish_reason"]} usage={resp.get("usage", {}).get("completion_tokens")}')
print('WI:', [a['comment'] for a in g['wi']['activated']])
print((rec.get('getMessage') or '')[:400])
return g, resp, rec
step('r1', 'normal', '*我撑起身子,接过茶杯* 谢谢你……这片森林里到底有什么怪物?')
step('r2', 'normal', '你的魔法真厉害。我的伤要多久才能好?我还得赶去北边的镇子。')
# swipe:右滑到头生成新候选。界面上的 swipe()(script.js:9954 起)对数据只做三步:syncMesToSwipe 把当前候选写回
# swipes(10340);swipe_id 指到新槽位(10396-10400);clearMessageData 清掉消息上的附加数据(10411、10134-10150)。
# 然后 Generate('swipe')。这里照这三步改数据,syncMesToSwipe 是原样切出的。
lab.eval('''
const chat = L.S.chat; const mesId = chat.length - 1; const m = chat[mesId];
L.S.syncMesToSwipe(mesId);
m.swipe_id = m.swipes.length;
for (const k of ['memory', 'display_text', 'media', 'inline_image', 'files', 'fileLength', 'generationType', 'negative', 'title', 'append_title']) delete m.extra?.[k];
delete m.gen_started; delete m.gen_finished;
''')
step('r2-swipe', 'swipe')
step('r3', 'normal', '你一个人守在这里多久了?给我讲讲你是怎么成为森林守护者的吧。')
step('r3-continue', 'continue')
g, resp, rec = step('r4-impersonate', 'impersonate')
step('r4', 'normal', rec['getMessage'])
step('r4-continue', 'continue') # r4 顶到了 300 token 的回复上限,点「继续」
step('r5', 'normal', '……谢谢你愿意跟我说这些。等伤好了,我想陪你去点亮更多的灯。')
json.dump(lab.call('stubHits'), open(f'{OUT}/stub-hits.json', 'w'), ensure_ascii=False, indent=1)
json.dump(usage, open(f'{OUT}/usage.json', 'w'), ensure_ascii=False, indent=1)
lab.close()
"""聊久了:接着扮演主线 r5 之后的状态,跑摘要扩展。
摘要扩展的 Classic 方式 = 带完整上下文做一次 quiet 生成,摘要指令放最后(extensions/memory/index.js:681-753 → generateQuietPrompt)。
先照出厂设置跑一次(摘要的回复长度跟主回复一样是 300);再像用户那样把摘要的「覆盖回复长度」调到 1024 跑一次——
generateQuietPrompt 用 TempResponseLength(script.js:4153-4200)临时改 oai_settings.openai_max_tokens,跑完还原,这里照做。
"""
import json, os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from stlab import Lab, send
OUT = '/data2/work/lht/study/26.06/_ext/st-lab/out/main'
M = '/data2/work/lht/study/26.06/_ext/st-lab/out/material/'
CONNECTION = {'chat_completion_source': 'deepseek', 'deepseek_model': 'deepseek-flash', 'stream_openai': False}
lab = Lab()
lab.call('init', card=json.load(open(M + 'seraphina.zh.json')), books={'埃尔多利亚': json.load(open(M + 'eldoria.zh.json'))}, name1='旅人', oaiPatch=CONNECTION)
state = json.load(open(f'{OUT}/r5-chat.json'))
lab.eval('L.S.__set("chat", args.chat); L.S.__set("chat_metadata", args.meta);', chat=state['chat'], meta=state['chat_metadata'])
usage = json.load(open(f'{OUT}/usage.json'))
def summarize(tag, override):
# 用户在摘要扩展里把来源从 Extras 切到主模型(出厂是 extras,没装 Extras 时什么也不做),覆盖回复长度按参数设
lab.eval('L.EXT.extension_settings.memory = { ...structuredClone(L.MEM.defaultSettings), source: "main", overrideResponseLength: args.o };', o=override)
# 触发判断原样调 getSummaryPromptForNow(memory/index.js:565-626):距上次摘要满 10 条才给提示词
prompt = lab.eval('return await L.MEM.getSummaryPromptForNow(L.EXT.getContext(), false);')
saved = lab.eval('const o = L.OAI.oai_settings.openai_max_tokens; if (args.o > 0) L.OAI.oai_settings.openai_max_tokens = args.o; return o;', o=override)
g = lab.call('generate', type='quiet', quietPrompt=prompt)
lab.eval('L.OAI.oai_settings.openai_max_tokens = args.v;', v=saved)
resp, sec = send(g['deepseek'])
rec = lab.call('receive', type='quiet', data=resp)
summary = lab.eval('return L.RS.removeReasoningFromString(args.s);', s=rec['getMessage'])
usage.append({'step': tag, 'type': 'quiet', 'seconds': sec, 'usage': resp.get('usage')})
json.dump({'prompt': prompt, 'overrideResponseLength': override, 'generate': g, 'response': resp, 'received': rec, 'summary': summary},
open(f'{OUT}/{tag}.json', 'w'), ensure_ascii=False, indent=1)
print(f'== {tag} max_tokens={g["deepseek"]["body"]["max_tokens"]} finish={resp["choices"][0]["finish_reason"]} usage={resp.get("usage")}')
print(summary)
return summary
summarize('summary-factory', 0)
summary = summarize('summary', 1024)
# 存摘要:setMemoryContext(memory/index.js:964-985)——注册 1_memory 注入,并挂在倒数第二条消息的 extra.memory 上
lab.eval('L.MEM.setMemoryContext(args.s, true);', s=summary)
json.dump(lab.call('snapshot'), open(f'{OUT}/summary-chat.json', 'w'), ensure_ascii=False, indent=1)
# 摘要之后下一轮拼成什么样(dry run,不发请求)
json.dump(lab.call('generate', type='normal', dryRun=True), open(f'{OUT}/r6-dry.json', 'w'), ensure_ascii=False, indent=1)
# /hide 0-4:前 5 条只留给人看(chats.js:147-169 hideChatMessageRange 把 is_system 置 true)
lab.eval('for (let i = 0; i <= 4; i++) L.S.chat[i].is_system = true;')
json.dump(lab.call('generate', type='normal', dryRun=True), open(f'{OUT}/r6-hide-dry.json', 'w'), ensure_ascii=False, indent=1)
json.dump(usage, open(f'{OUT}/usage.json', 'w'), ensure_ascii=False, indent=1)
lab.close()
"""群聊实跑:塞拉菲娜(译本)+ 洛恩(构造),Natural 调度、Swap 拼卡(出厂默认),两轮。
generateGroupWrapper(group-chats.js:945-1091)的流程:先用调度函数选出这轮发言的人,再逐个切换当前角色、调一次普通的 Generate;
用户这句只在第一个人生成时进聊天。这里照这个顺序调;调度用带种子的随机数,可复现。
"""
import json, os, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from stlab import Lab, send
OUT = '/data2/work/lht/study/26.06/_ext/st-lab/out/group'
M = '/data2/work/lht/study/26.06/_ext/st-lab/out/material/'
os.makedirs(OUT, exist_ok=True)
CONNECTION = {'chat_completion_source': 'deepseek', 'deepseek_model': 'deepseek-flash', 'stream_openai': False}
lab = Lab()
sera = json.load(open(M + 'seraphina.zh.json')); sera['avatar'] = 'seraphina.png'
lorne = json.load(open(M + 'lorne.constructed.json')); lorne['avatar'] = 'lorne.png'
book = json.load(open(M + 'eldoria.zh.json'))
GROUP = {'id': '1', 'name': '林间空地', 'members': ['seraphina.png', 'lorne.png'], 'activation_strategy': 0, 'generation_mode': 0,
'disabled_members': [], 'allow_self_responses': False, 'chat_id': '林间空地 - 实跑', 'chats': ['林间空地 - 实跑']}
lab.call('init', card=sera, books={'埃尔多利亚': book}, name1='旅人', oaiPatch=CONNECTION)
# 新开的群聊:每个成员按顺序发一条开场白(group-chats.js:281-301 → getFirstCharacterMessage 572-608)
lab.eval('''
const seedrandom = (await import('seedrandom')).default;
L.setGroup(args.cards, args.group);
L.S.__set('chat', []);
Math.random = seedrandom('group-greeting');
for (const member of args.group.members) {
const character = L.S.characters.find(x => x.avatar === member);
const mes = await L.G.getFirstCharacterMessage(character);
if (mes?.mes) L.S.chat.push(mes);
}
''', cards=[sera, lorne], group=GROUP)
json.dump(lab.call('snapshot'), open(f'{OUT}/g-00-greetings.json', 'w'), ensure_ascii=False, indent=1)
usage = []
def group_round(tag, text, seed):
act = lab.eval('''
const seedrandom = (await import('seedrandom')).default;
Math.random = seedrandom(args.seed);
const g = L.G.groups[0];
const lastMessage = L.S.chat[L.S.chat.length - 1];
const enabled = g.members.filter(x => !g.disabled_members.includes(x));
const ids = L.G.activateNaturalOrder(enabled, args.text, lastMessage, g.allow_self_responses, true);
L.G.__setGroupGenId(Date.now()); // group-chats.js:992 group_generation_id = Date.now()
return ids.map(i => ({ id: i, name: L.S.characters[i].name }));
''', text=text, seed=seed)
print(f'== {tag} activated:', [a['name'] for a in act])
first = True
for a in act:
lab.eval('L.setCharacter(args.id);', id=a['id'])
g = lab.call('generate', type='normal', text=text if first else '')
first = False
log = []
resp, sec = send(g['deepseek'], log)
rec = lab.call('receive', type='normal', data=resp)
usage.append({'step': f'{tag}-{a["name"]}', 'seconds': sec, 'usage': resp.get('usage')})
json.dump({'user': text, 'activated': act, 'speaker': a['name'], 'generate': g, 'response': resp, 'received': rec},
open(f'{OUT}/g-{tag}-{a["name"]}.json', 'w'), ensure_ascii=False, indent=1)
print(f' [{a["name"]}] {sec}s finish={resp["choices"][0]["finish_reason"]}')
print(' ', (rec.get('getMessage') or '')[:300])
json.dump(lab.call('snapshot'), open(f'{OUT}/g-{tag}-chat.json', 'w'), ensure_ascii=False, indent=1)
group_round('r1', '你们两位认识很久了吗?', 'group-r1-1') # 种子挑过:让第一轮两人都开口
group_round('r2', '洛恩,你守在森林边上,见过影牙吗?', 'group-r2')
json.dump(usage, open(f'{OUT}/usage.json', 'w'), ensure_ascii=False, indent=1)
lab.close()
不调模型的六组实验
// 世界书实验:全部直接调 world-info.js 的 checkWorldInfo / getWorldInfoPrompt,不调模型。
// 判定过程取自酒馆自己打的 [WI] 调试日志(world-info.js 的 console.debug),这里只是接住、去掉条目对象本身。
// 随机数(概率、互斥组)换成带种子的 seedrandom,结果可复现。
import fs from 'node:fs';
import seedrandom from 'seedrandom';
console.error = (...a) => process.stderr.write('ERR ' + a.map(x => x?.stack ?? (typeof x === 'object' ? JSON.stringify(x)?.slice(0,300) : String(x))).join(' ').slice(0, 2000) + '\n');
const L = await import('./lib.mjs');
const OUT = '/data2/work/lht/study/26.06/_ext/st-lab/out/lorebook';
const M = '/data2/work/lht/study/26.06/_ext/st-lab/out/material/';
fs.mkdirSync(OUT, { recursive: true });
const card = JSON.parse(fs.readFileSync(M + 'seraphina.zh.json'));
const eldoria = JSON.parse(fs.readFileSync(M + 'eldoria.zh.json'));
const TEMPLATE = eldoria.entries['0'];
const CONN = { chat_completion_source: 'deepseek', deepseek_model: 'deepseek-flash', stream_openai: false };
function entry(uid, o) {
return { ...structuredClone(TEMPLATE), uid, displayIndex: uid, key: [], keysecondary: [], comment: '', content: '', ...o };
}
function book(entries) { return { entries: Object.fromEntries(entries.map(e => [String(e.uid), e])) }; }
// 接住 [WI] 调试日志:只留字符串部分
async function traced(fn) {
const lines = [];
const prev = console.debug;
const prevLog = console.log;
const fmt = (args) => args.filter(a => typeof a !== 'object' || a === null).map(String).join(' ');
console.debug = (...a) => { if (String(a[0]).startsWith('[WI]')) lines.push(fmt(a)); };
console.log = (...a) => { if (String(a[0]).startsWith('[WI]')) lines.push(fmt(a)); };
try { return { result: await fn(), trace: lines }; } finally { console.debug = prev; console.log = prevLog; }
}
function seeded(seed) { const rng = seedrandom(seed); Math.random = rng; }
// 构造演示用解绑了世界书的卡,只让演示书生效
const bareCard = structuredClone(card); bareCard.data.extensions.world = '';
async function setup({ books, globalBooks = [], wiPatch = {}, chat = null, chatMetadata = {} }) {
await L.init({ card: globalBooks.length ? bareCard : card, books, name1: '旅人', oaiPatch: CONN, wiPatch, globalBooks, chatMetadata });
if (chat) L.S.__set('chat', structuredClone(chat));
}
// 用和 Generate 相同的方式取扫描文本(script.js:4624)
const chatForWI = () => L.S.chat.filter(x => !x.is_system).map(x => L.WI.world_info_include_names ? `${x.name}: ${x.mes}` : x.mes).reverse();
async function check({ dry = true } = {}) {
const max = L.S.getMaxPromptTokens();
return traced(() => L.WI.checkWorldInfo(chatForWI(), max, dry, { trigger: 'normal' }));
}
const names = (r) => [...r.allActivatedEntries.values()].map(e => e.comment);
const save = (name, obj) => fs.writeFileSync(`${OUT}/${name}.json`, JSON.stringify(obj, null, 1));
const userMsg = (mes) => ({ name: '旅人', is_user: true, is_system: false, send_date: '', mes, extra: {} });
const charMsg = (mes) => ({ name: '塞拉菲娜', is_user: false, is_system: false, send_date: '', mes, extra: {}, swipe_id: 0, swipes: [mes], swipe_info: [{}] });
// ---------- A. 真实输入:第一轮,扫描最近 2 条(开场白 + 用户这句) ----------
const main = JSON.parse(fs.readFileSync('/data2/work/lht/study/26.06/_ext/st-lab/out/main/r1.json'));
const r1chat = JSON.parse(fs.readFileSync('/data2/work/lht/study/26.06/_ext/st-lab/out/main/00-init.json')).chat;
r1chat.push(userMsg(main.user));
{
await setup({ books: { '埃尔多利亚': eldoria }, chat: r1chat });
const on = await check();
await setup({ books: { '埃尔多利亚': eldoria }, chat: r1chat, wiPatch: { world_info_recursive: false } });
const off = await check();
save('A-real-r1', { scanText: chatForWI().slice(0, 2), recursiveOn: { activated: names(on.result), trace: on.trace }, recursiveOff: { activated: names(off.result), trace: off.trace } });
}
// ---------- B. 次关键词:同一个「苹果」,按次关键词分开 ----------
{
const b = book([
entry(0, { comment: '苹果公司', key: ['苹果'], keysecondary: ['手机', '发布会'], selectiveLogic: 0, content: '苹果是一家做手机和电脑的公司。' }), // AND_ANY
entry(1, { comment: '苹果树', key: ['苹果'], keysecondary: ['手机', '发布会'], selectiveLogic: 2, content: '院子里那棵苹果树是外婆种的。' }), // NOT_ANY
]);
const out = {};
for (const text of ['新款苹果手机什么时候发布?', '院子里的苹果熟了,摘几个吧。']) {
await setup({ books: { '演示·次关键词': b }, globalBooks: ['演示·次关键词'], chat: [userMsg(text)] });
const r = await check();
out[text] = { activated: names(r.result), trace: r.trace };
}
save('B-secondary', { entries: Object.values(b.entries).map(e => ({ comment: e.comment, key: e.key, keysecondary: e.keysecondary, selectiveLogic: e.selectiveLogic })), runs: out });
}
// ---------- C. 递归:条目内容里的词再去触发别的条目;preventRecursion、delayUntilRecursion ----------
{
const mk = (prevent = false) => book([
entry(0, { comment: '银溪谷', key: ['银溪谷'], content: '银溪谷在空地北边,如今荆棘魔盘踞在谷口。', order: 100, preventRecursion: prevent }),
entry(1, { comment: '荆棘魔', key: ['荆棘魔'], content: '荆棘魔是被影牙诅咒的树精,怕火。', order: 90 }),
entry(2, { comment: '影牙的来历', key: ['影牙'], content: '影牙来自森林深处的裂谷,三十年前第一次出现。', order: 80, delayUntilRecursion: true }),
]);
const chat = [userMsg('明天我想从银溪谷绕过去。')];
const out = {};
for (const [tag, b] of [['默认', mk(false)], ['银溪谷设 preventRecursion', mk(true)]]) {
await setup({ books: { '演示·递归': b }, globalBooks: ['演示·递归'], chat });
const r = await check();
out[tag] = { activated: names(r.result), trace: r.trace };
}
// 延迟到递归:用户直接说「影牙」,第一轮扫描不放行这条
await setup({ books: { '演示·递归': mk(false) }, globalBooks: ['演示·递归'], chat: [userMsg('影牙到底是什么东西?')] });
const d = await check();
out['直接提到影牙'] = { activated: names(d.result), trace: d.trace };
save('C-recursion', { runs: out });
}
// ---------- D. 预算:真实世界书 + 第一轮的真实聊天,用户把世界书预算从 25% 调到 10% ----------
{
const out = {};
for (const pct of [25, 10]) {
await setup({ books: { '埃尔多利亚': eldoria }, chat: r1chat, wiPatch: { world_info_budget: pct } });
const r = await check();
out[`${pct}%`] = { activated: names(r.result), trace: r.trace.filter(l => /Budget|budget|activation successful|Context size|LOOP #|Successfully/.test(l)) };
}
save('D-budget', out);
}
// ---------- E. 互斥组和概率:同组只留一条,按权重抽;概率条目掷骰 ----------
{
const b = book([
entry(0, { comment: '天气·晴', key: ['出门', '天气'], group: '天气', groupWeight: 60, content: '今天是晴天。' }),
entry(1, { comment: '天气·雨', key: ['出门', '天气'], group: '天气', groupWeight: 30, content: '今天在下雨。' }),
entry(2, { comment: '天气·雾', key: ['出门', '天气'], group: '天气', groupWeight: 10, content: '林子里起了大雾。' }),
entry(3, { comment: '偶遇商队', key: ['出门'], probability: 30, useProbability: true, content: '路上遇见一支往北走的商队。' }),
]);
const runs = [];
for (let seed = 1; seed <= 10; seed++) {
await setup({ books: { '演示·互斥组': b }, globalBooks: ['演示·互斥组'], chat: [userMsg('我们出门走走吧。')] });
seeded('wi-' + seed);
const r = await check();
runs.push({ seed: 'wi-' + seed, activated: names(r.result) });
}
Math.random = seedrandom(); // 恢复
save('E-group', { weights: { 晴: 60, 雨: 30, 雾: 10 }, 商队概率: 30, runs });
}
// ---------- F. 定时效果:sticky / cooldown / delay,按聊天条数计时;swipe 时作废 ----------
{
const b = book([
entry(0, { comment: '地牢规则', key: ['地牢'], sticky: 2, cooldown: 3, content: '地牢里不能点火。' }),
entry(1, { comment: '伏兵', key: ['走廊'], delay: 6, content: '走廊尽头埋伏着两个守卫。' }),
]);
const lines = [
userMsg('我们从这道暗门下到地牢。'),
charMsg('石阶又湿又滑,她举着荧光走在前面。'),
userMsg('前面有条走廊。'),
charMsg('走廊很长,尽头隐约有火光。'),
userMsg('继续往前,沿着地牢的走廊走。'),
charMsg('脚步声在石壁间回荡。'),
userMsg('还在地牢里吗?'),
charMsg('是的,四周仍是潮湿的石墙。'),
];
await setup({ books: { '演示·定时': b }, globalBooks: ['演示·定时'], chat: [] });
const steps = [];
for (let i = 0; i < lines.length; i++) {
L.S.chat.push(structuredClone(lines[i]));
const r = await check({ dry: false });
steps.push({ chatLength: L.S.chat.length, last: lines[i].mes, activated: names(r.result), timed: structuredClone(L.S.chat_metadata.timedWorldInfo ?? {}) });
}
// swipe:最后一条重新生成——拼装时这条被去掉,扫描的聊天没有变长
const swipeChat = L.S.chat.slice(0, 1);
await setup({ books: { '演示·定时': b }, globalBooks: ['演示·定时'], chat: swipeChat });
const s1 = await check({ dry: false });
const after1 = structuredClone(L.S.chat_metadata.timedWorldInfo ?? {});
// 同样长度再扫一次(swipe / regenerate 时聊天没有前进)
L.S.chat.push(charMsg('(被 swipe 掉的这条回复)'));
L.S.chat.pop();
const s2 = await check({ dry: false });
save('F-timed', { steps, swipe: { first: { activated: names(s1.result), timed: after1 }, again: { activated: names(s2.result), timed: structuredClone(L.S.chat_metadata.timedWorldInfo ?? {}), trace: s2.trace.filter(l => /sticky|Sticky|cooldown|delay|timed|Timed/.test(l)) } } });
}
// ---------- G. 插到哪:几条蓝灯条目各放一个位置,整轮拼一次(Generate 的真实流程,dry run) ----------
{
const b = book([
entry(0, { comment: '角色定义前', constant: true, position: 0, order: 100, content: '【位置 0:角色定义前】' }),
entry(1, { comment: '角色定义后', constant: true, position: 1, order: 100, content: '【位置 1:角色定义后】' }),
entry(2, { comment: '作者注释顶', constant: true, position: 2, order: 100, content: '【位置 2:作者注释顶部】' }),
entry(3, { comment: '@深度 0 user', constant: true, position: 4, depth: 0, role: 1, order: 100, content: '【位置 4:@深度 0,user】' }),
entry(4, { comment: '@深度 2 system', constant: true, position: 4, depth: 2, role: 0, order: 100, content: '【位置 4:@深度 2,system】' }),
entry(5, { comment: '示范对话前', constant: true, position: 5, order: 100, content: '<START>\n{{user}}: 【位置 5:示范对话前】\n{{char}}: 好。' }),
]);
const chat5 = JSON.parse(fs.readFileSync('/data2/work/lht/study/26.06/_ext/st-lab/out/main/r2-chat.json')).chat;
await setup({ books: { '演示·位置': b }, globalBooks: ['演示·位置'], chat: chat5, chatMetadata: { note_prompt: '(作者注释)塞拉菲娜说话要简短。' } });
const g = await L.generate({ type: 'normal', dryRun: true });
save('G-positions', { ledger: g.ledger.map(x => ({ identifier: x.identifier, role: x.role, content: x.content })), prompt: g.prompt, extensionPrompts: g.extensionPrompts });
}
process.stdout.write('done\n');
process.exit(0);
// 角色卡(第 1 条线)和每轮现拼(第 3 条线)的实验:全部调酒馆的函数,不调模型。
import fs from 'node:fs';
import path from 'node:path';
import seedrandom from 'seedrandom';
console.error = (...a) => process.stderr.write('ERR ' + a.map(x => x?.stack ?? String(x)).join(' ').slice(0, 1500) + '\n');
const L = await import('./lib.mjs');
const ST = process.env.ST_ROOT;
const OUT = '/data2/work/lht/study/26.06/_ext/st-lab/out/assembly';
const M = '/data2/work/lht/study/26.06/_ext/st-lab/out/material/';
const MAIN = '/data2/work/lht/study/26.06/_ext/st-lab/out/main/';
fs.mkdirSync(OUT, { recursive: true });
const save = (name, obj) => fs.writeFileSync(`${OUT}/${name}.json`, JSON.stringify(obj, null, 1));
const card = JSON.parse(fs.readFileSync(M + 'seraphina.zh.json'));
const eldoria = JSON.parse(fs.readFileSync(M + 'eldoria.zh.json'));
const CONN = { chat_completion_source: 'deepseek', deepseek_model: 'deepseek-flash', stream_openai: false };
const books = { '埃尔多利亚': eldoria };
const chatOf = (f) => JSON.parse(fs.readFileSync(MAIN + f)).chat;
const ledger = (g) => g.ledger.map(x => ({ identifier: x.identifier, role: x.role, tokens: x.tokens, content: x.content }));
// ---------- 构造的「完整版」卡:在译本上补齐各字段,一次看清每个字段落进哪个槽 ----------
const full = structuredClone(card);
const [traits, ...rest] = card.data.description.split('<START>');
const examplesBlock = '<START>' + rest.join('<START>');
const exampleOnly = examplesBlock.slice(0, examplesBlock.lastIndexOf('\r\n[类型'));
for (const o of [full, full.data]) {
o.description = traits.trim();
o.personality = '温柔、护短,总觉得是自己没把人照顾好。';
o.scenario = '旅人在塞拉菲娜的林间空地里养伤,窗外的森林被影牙占据。';
o.mes_example = exampleOnly.trim();
}
full.data.system_prompt = '{{original}}\n塞拉菲娜说话时常带一点歉意。';
full.data.post_history_instructions = '用第三人称描写{{char}}的动作,不替{{user}}说话或行动。';
full.data.extensions.depth_prompt = { prompt: '(夜里塞拉菲娜的魔法会变弱,她不愿让人知道。)', depth: 4, role: 'system' };
full.data.alternate_greetings = ['*你醒来时,屋里只有炉火的噼啪声。门口站着一个粉发的身影,背对着你望向黑暗的林子。* “醒了?别急着起身。”'];
fs.writeFileSync(`${OUT}/card-full.zh.json`, JSON.stringify(full, null, 2));
// ---------- 1. 卡是一张 PNG:用服务端的 character-card-parser.js 把中文卡写进随包头像,再读回来 ----------
{
const parser = await import(path.join(ST, 'src/character-card-parser.js'));
const { default: extract } = await import('png-chunks-extract');
const { default: PNGtext } = await import('png-chunk-text');
const png = fs.readFileSync(path.join(ST, 'default/content/default_Seraphina.png'));
const out = parser.write(png, JSON.stringify(card));
fs.writeFileSync(`${OUT}/seraphina.zh.png`, out);
const chunks = extract(new Uint8Array(out)).map(c => {
if (c.name !== 'tEXt') return { name: c.name, bytes: c.data.length };
const t = PNGtext.decode(c.data);
const json = JSON.parse(Buffer.from(t.text, 'base64').toString('utf8'));
return { name: c.name, keyword: t.keyword, base64Chars: t.text.length, spec: json.spec, spec_version: json.spec_version, name_in_card: json.data?.name };
});
const back = JSON.parse(parser.read(out));
save('card-png', { chunks, readBack: { spec: back.spec, name: back.data.name, fields: Object.keys(back.data) } });
}
// ---------- 2. 字段落进哪个槽:完整版卡,第二轮的真实聊天,dry run ----------
{
await L.init({ card: full, books, name1: '旅人', oaiPatch: CONN });
L.S.__set('chat', chatOf('r2-chat.json'));
const g = await L.generate({ type: 'normal', dryRun: true });
save('card-slots', { ledger: ledger(g), extensionPrompts: g.extensionPrompts });
}
// ---------- 3. 备选开场白:开场白和 alternate_greetings 一起成为第 0 条消息的候选 ----------
{
await L.init({ card: full, books, name1: '旅人', oaiPatch: CONN });
const m = L.S.chat[0];
save('card-greetings', { swipe_id: m.swipe_id, swipes: m.swipes, mes: m.mes });
}
// ---------- 4. 用户人设:放在 prompt 里 vs 深度 2 ----------
{
const persona = '{{user}}是二十出头的猎人,左腿被影牙咬伤,说话直来直去。';
const out = {};
for (const [tag, pos] of [['IN_PROMPT', 0], ['AT_DEPTH', 4]]) {
await L.init({ card, books, name1: '旅人', persona, oaiPatch: CONN, powerUserPatch: { persona_description_position: pos, persona_description_depth: 2, persona_description_role: 0 } });
L.S.__set('chat', chatOf('r2-chat.json'));
const g = await L.generate({ type: 'normal', dryRun: true });
out[tag] = ledger(g).map(x => ({ identifier: x.identifier, role: x.role, content: x.content.slice(0, 60) }));
}
save('persona', out);
}
// ---------- 5. 宏:{{random}} 每次重抽,{{pick}} 同一个聊天里不变;换个聊天再抽 ----------
{
await L.init({ card, books, name1: '旅人', oaiPatch: CONN });
Math.random = seedrandom('macro');
const tpl = { random: '{{random::金发::红发::银发}}', pick: '{{pick::金发::红发::银发}}' };
const out = { names: L.S.substituteParams('{{char}} 对 {{user}} 说话'), sameChat: {}, otherChat: {} };
for (const k of Object.keys(tpl)) out.sameChat[k] = [1, 2, 3, 4].map(() => L.S.substituteParams(tpl[k]));
for (const other of ['塞拉菲娜 - 第二局', '塞拉菲娜 - 第三局', '塞拉菲娜 - 第四局']) {
L.S.characters[0].chat = other;
L.S.__set('chat_metadata', {});
out.otherChat[other] = {};
for (const k of Object.keys(tpl)) out.otherChat[other][k] = [1, 2, 3].map(() => L.S.substituteParams(tpl[k]));
}
Math.random = seedrandom();
save('macros', out);
}
// ---------- 6. 槽位的顺序和「放进聊天里」:加一条自定义提示词,先按顺序放,再改成 In-Chat 深度 1 ----------
{
const out = {};
for (const [tag, pos] of [['relative', 0], ['in-chat@1', 1]]) {
await L.init({ card, books, name1: '旅人', oaiPatch: CONN });
L.S.__set('chat', chatOf('r2-chat.json'));
const s = L.OAI.oai_settings;
s.prompts.push({ identifier: 'writing-rules', name: '写作规则', role: 'system', content: '每次回复不超过三段。', system_prompt: false, marker: false, injection_position: pos, injection_depth: 1, injection_order: 100 });
const order = s.prompt_order.find(x => String(x.character_id) === '100001').order;
order.splice(order.findIndex(x => x.identifier === 'main') + 1, 0, { identifier: 'writing-rules', enabled: true });
const g = await L.generate({ type: 'normal', dryRun: true });
out[tag] = ledger(g).map(x => ({ identifier: x.identifier, role: x.role, content: x.content.slice(0, 40) }));
}
save('order', { defaultOrder: L.OAI.oai_settings.prompt_order.find(x => String(x.character_id) === '100001').order.map(x => `${x.identifier}${x.enabled ? '' : '(关)'}`), runs: out });
}
// ---------- 7. 深度注入:同一深度几样东西怎么排;完整版卡 + 作者注释 + 世界书 @深度 + /inject + 人设 @深度 ----------
{
const wiBook = structuredClone(eldoria);
wiBook.entries['9'] = { ...structuredClone(eldoria.entries['0']), uid: 9, displayIndex: 9, comment: '天色', key: [], constant: true, position: 4, depth: 4, role: 0, content: '(世界书 @深度 4:天快黑了。)' };
wiBook.entries['10'] = { ...structuredClone(eldoria.entries['0']), uid: 10, displayIndex: 10, comment: '旅人的念头', key: [], constant: true, position: 4, depth: 4, role: 1, content: '(世界书 @深度 4,user:我得早点上路。)' };
await L.init({
card: full, books: { '埃尔多利亚': wiBook }, name1: '旅人', oaiPatch: CONN,
persona: '{{user}}是二十出头的猎人。', powerUserPatch: { persona_description_position: 4, persona_description_depth: 2, persona_description_role: 0 },
chatMetadata: { note_prompt: '(作者注释:塞拉菲娜这几轮话少一些。)' },
});
L.S.__set('chat', chatOf('r5-chat.json'));
// /inject id=伏笔 position=chat depth=4 role=assistant(slash-commands.js:3780-3846 最终也是调 setExtensionPrompt)
const { generate } = L;
const origSet = L.S.setExtensionPrompt;
const g0 = await (async () => {
L.S.setExtensionPrompt('script_inject_伏笔', '(/inject:窗外有什么东西在看着这间屋子。)', L.S.extension_prompt_types.IN_CHAT, 4, false, L.S.extension_prompt_roles.ASSISTANT);
return generate({ type: 'normal', dryRun: true });
})();
save('injections', { ledger: ledger(g0).map(x => ({ identifier: x.identifier, role: x.role, content: x.content })), extensionPrompts: g0.extensionPrompts });
}
// ---------- 8. 预算:最后的真实聊天,出厂 4095 / 用户调成 2048;完整版卡带示范对话 ----------
{
const out = {};
for (const ctx of [4095, 2048, 1400]) {
await L.init({ card: full, books, name1: '旅人', oaiPatch: { ...CONN, openai_max_context: ctx } });
L.S.__set('chat', chatOf('r5-chat.json'));
const g = await L.generate({ type: 'normal', dryRun: true });
const lg = ledger(g);
out[ctx] = {
budget: ctx - L.OAI.oai_settings.openai_max_tokens,
total: lg.reduce((a, x) => a + (x.tokens || 0), 0),
messagesInContext: L.OAI.openai_messages_count,
chatLength: L.S.chat.length,
rows: lg.map(x => ({ identifier: x.identifier, role: x.role, tokens: x.tokens, head: x.content.slice(0, 24) })),
};
}
save('budget', out);
}
process.stdout.write('done\n');
process.exit(0);
// 一份内容喂各种模型(第 4 条线):服务端各供应商的转换函数、后处理几档、continue 的两种写法、Text Completion 的 instruct 拼法。
import fs from 'node:fs';
import path from 'node:path';
console.error = (...a) => process.stderr.write('ERR ' + a.map(x => x?.stack ?? String(x)).join(' ').slice(0, 1500) + '\n');
const L = await import('./lib.mjs');
const ST = process.env.ST_ROOT;
const OUT = '/data2/work/lht/study/26.06/_ext/st-lab/out/adapt';
const M = '/data2/work/lht/study/26.06/_ext/st-lab/out/material/';
const MAIN = '/data2/work/lht/study/26.06/_ext/st-lab/out/main/';
fs.mkdirSync(OUT, { recursive: true });
const save = (name, obj) => fs.writeFileSync(`${OUT}/${name}.json`, JSON.stringify(obj, null, 1));
const card = JSON.parse(fs.readFileSync(M + 'seraphina.zh.json'));
const eldoria = JSON.parse(fs.readFileSync(M + 'eldoria.zh.json'));
const CONN = { chat_completion_source: 'deepseek', deepseek_model: 'deepseek-flash', stream_openai: false };
const PC = L.PC;
const brief = (msgs) => msgs.map(m => ({ role: m.role, ...(m.name ? { name: m.name } : {}), ...(m.prefix ? { prefix: m.prefix } : {}), content: typeof m.content === 'string' ? m.content : JSON.stringify(m.content) }));
// 第二轮前端拼好的消息数组(发往服务端之前),加上服务端从请求里取的名字
const r2 = JSON.parse(fs.readFileSync(MAIN + 'r2.json'));
const msgs = r2.generate.prompt;
const names = PC.getPromptNames({ body: r2.generate.generate_data });
// ---------- 1. 后处理几档:同一份消息数组 ----------
{
const out = { input: brief(msgs) };
for (const t of ['', 'merge', 'semi', 'strict', 'single']) {
out[t || 'none'] = brief(PC.postProcessPrompt(structuredClone(msgs), t, names));
}
save('postprocess', out);
}
// ---------- 2. Claude、Gemini 的转换函数(只跑转换,不发请求) ----------
{
const claude = PC.convertClaudeMessages(structuredClone(msgs), '', true, false, names);
const gemini = PC.convertGooglePrompt(structuredClone(msgs), 'gemini-2.5-pro', true, names);
save('providers', { claude, gemini });
}
// ---------- 3. continue 的两种写法:出厂的「续写提示」vs 打开 continue_prefill ----------
{
const r3chat = JSON.parse(fs.readFileSync(MAIN + 'r3-chat.json')).chat;
const out = {};
for (const [tag, prefill] of [['nudge(出厂)', false], ['continue_prefill', true]]) {
await L.init({ card, books: { '埃尔多利亚': eldoria }, name1: '旅人', oaiPatch: { ...CONN, continue_prefill: prefill } });
L.S.__set('chat', structuredClone(r3chat));
const g = await L.generate({ type: 'continue', dryRun: true });
out[tag] = { frontend: brief(g.prompt).slice(-3), deepseek: brief(g.deepseek.body.messages).slice(-3) };
}
save('continue', out);
}
// ---------- 4. 主线里实际发出的几种请求的末尾(真实请求体) ----------
{
const tail = (f, n = 3) => brief(JSON.parse(fs.readFileSync(MAIN + f)).generate.deepseek.body.messages).slice(-n);
save('tails', { 'r3-continue': tail('r3-continue.json'), 'r4-impersonate': tail('r4-impersonate.json'), 'r1': tail('r1.json', 3), r1Params: Object.fromEntries(Object.entries(JSON.parse(fs.readFileSync(MAIN + 'r1.json')).generate.deepseek.body).filter(([k]) => k !== 'messages')) });
}
// ---------- 5. Text Completion 一瞥:同一段聊天按 ChatML 模板拼成一条字符串 ----------
{
await L.init({ card, books: { '埃尔多利亚': eldoria }, name1: '旅人', oaiPatch: CONN });
const r2chat = JSON.parse(fs.readFileSync(MAIN + 'r2-chat.json')).chat;
L.S.__set('chat', structuredClone(r2chat));
L.S.__set('main_api', 'textgenerationwebui');
const IM = await import(path.join(ST, 'public/scripts/instruct-mode.js'));
const instruct = JSON.parse(fs.readFileSync(path.join(ST, 'default/content/presets/instruct/ChatML.json')));
const context = JSON.parse(fs.readFileSync(path.join(ST, 'default/content/presets/context/ChatML.json')));
Object.assign(L.PU.power_user.instruct, instruct, { enabled: true });
Object.assign(L.PU.power_user.context, context);
const f = L.S.getCharacterCardFields();
const story = L.PU.renderStoryString({ description: f.description, personality: f.personality, scenario: f.scenario, system: L.PU.power_user.sysprompt?.content ? L.S.substituteParams(L.PU.power_user.sysprompt.content) : '', persona: '', char: L.S.name2, user: L.S.name1, wiBefore: '', wiAfter: '', loreBefore: '', loreAfter: '', anchorBefore: '', anchorAfter: '', mesExamples: '', mesExamplesRaw: '' });
const storyWrapped = IM.formatInstructModeStoryString(story);
const history = r2chat.map(m => IM.formatInstructModeChat(m.name, m.mes, m.is_user, false, '', L.S.name1, L.S.name2, false));
const lastLine = IM.formatInstructModePrompt(L.S.name2, false, '', L.S.name1, L.S.name2, false, false);
const stops = IM.getInstructStoppingSequences();
save('textcompletion', { instructPreset: instruct, storyWrapped, history, lastLine, stops, joined: storyWrapped + history.join('') + lastLine });
L.S.__set('main_api', 'openai');
}
process.stdout.write('done\n');
process.exit(0);
// 重来、分叉、改历史(第 5 条线):swipe 的数据形状、编辑、隐藏、开分支。分支用 bookmarks.js 的 createBranch 原样跑,
// 它调的 saveChat 是从 script.js 原样切出的;落盘那一步由 lib.mjs 里的 fetch 拦截按服务端写法写成 JSONL。
import fs from 'node:fs';
import path from 'node:path';
console.error = (...a) => process.stderr.write('ERR ' + a.map(x => x?.stack ?? String(x)).join(' ').slice(0, 1500) + '\n');
const OUT = '/data2/work/lht/study/26.06/_ext/st-lab/out/branch';
process.env.ST_DATA = OUT + '/data';
fs.rmSync(OUT, { recursive: true, force: true });
fs.mkdirSync(OUT, { recursive: true });
const L = await import('./lib.mjs');
const ST = process.env.ST_ROOT;
const M = '/data2/work/lht/study/26.06/_ext/st-lab/out/material/';
const MAIN = '/data2/work/lht/study/26.06/_ext/st-lab/out/main/';
const save = (name, obj) => fs.writeFileSync(`${OUT}/${name}.json`, JSON.stringify(obj, null, 1));
const card = JSON.parse(fs.readFileSync(M + 'seraphina.zh.json'));
const eldoria = JSON.parse(fs.readFileSync(M + 'eldoria.zh.json'));
const CONN = { chat_completion_source: 'deepseek', deepseek_model: 'deepseek-flash', stream_openai: false };
const BM = await import(path.join(ST, 'public/scripts/bookmarks.js'));
const brief = (g) => g.ledger.filter(x => x.identifier.startsWith('chatHistory')).map(x => `${x.role}: ${x.content.slice(0, 30)}`);
// 主线最后的状态(摘要之后)
const final = JSON.parse(fs.readFileSync(MAIN + 'summary-chat.json'));
async function load() {
await L.init({ card, books: { '埃尔多利亚': eldoria }, name1: '旅人', oaiPatch: CONN });
L.S.__set('chat', structuredClone(final.chat));
L.S.__set('chat_metadata', structuredClone(final.chat_metadata));
// 打开聊天时没有 integrity 就补一个(script.js:7665-7667);再放一个本地变量,看分支会不会带走(构造:相当于 /setvar key=好感 35)
L.S.chat_metadata.integrity ??= 'parent-integrity-uuid';
L.S.chat_metadata.variables = { 好感: 35 };
}
// ---------- 1. swipe:第 4 条消息有两个候选,下一轮只带选中的那个 ----------
{
const r3 = JSON.parse(fs.readFileSync(MAIN + 'r3.json'));
const m = final.chat[4];
const sent = r3.generate.prompt.map(x => x.content);
save('swipe', {
message4: { swipe_id: m.swipe_id, swipes: m.swipes.map(s => s.slice(0, 40)), swipe_info: m.swipe_info.map(i => ({ send_date: i.send_date, model: i.extra?.model })), mes: m.mes.slice(0, 40) },
nextRoundHasSwipe0: sent.some(c => c === m.swipes[0]),
nextRoundHasSwipe1: sent.some(c => c === m.swipes[1]),
});
}
// ---------- 2. 编辑:改第 2 条(塞拉菲娜的回复)里的一句,updateMessage 原样跑 ----------
{
await load();
const mesId = 2;
const before = L.S.chat[mesId].mes;
const edited = before.replace('影牙……它们是这片森林里最危险的怪物。', '影牙……它们是这片森林里最危险的怪物,怕光。');
// updateMessage 从界面元素上取编辑框文字和消息序号;给它一个只回答这两样的替身
const div = { closest: (sel) => sel === '.mes_block' ? { find: () => ({ val: () => edited, text: () => before }) } : { attr: () => String(mesId) } };
L.S.updateMessage(div);
const m = L.S.chat[mesId];
const g = await L.generate({ type: 'normal', dryRun: true });
save('edit', { mes: m.mes.slice(0, 60), swipeSynced: m.swipes[m.swipe_id] === m.mes, tainted: L.S.chat_metadata.tainted, inPrompt: g.prompt.some(x => x.content.includes('怕光')) });
}
// ---------- 3. 开分支:存主聊天,再从第 4 条开分支,并选它被放弃的那个候选(swipe 0) ----------
{
await load();
await L.S.saveChat({}); // 主聊天落盘
const name = await BM.createBranch(4, { swipeId: 0 }); // bookmarks.js:187-245
const dir = path.join(OUT, 'data/chats/seraphina');
const read = (f) => fs.readFileSync(path.join(dir, f + '.jsonl'), 'utf8').split('\n').map(l => JSON.parse(l));
const parent = read(L.S.characters[0].chat);
const branch = read(name);
const head = branch[0];
save('branch', {
files: fs.readdirSync(dir),
branchName: name,
parentLines: parent.length, branchLines: branch.length,
branchHeader: head,
branchMessage4: branch[5].mes.slice(0, 60),
parentMessage4: parent[5].mes.slice(0, 60),
parentBranchesField: L.S.chat[4].extra.branches,
});
fs.copyFileSync(path.join(dir, name + '.jsonl'), path.join(OUT, 'branch.jsonl'));
}
process.stdout.write('done\n');
process.exit(0);
// 群聊(第 7 条线)里不调模型的部分:发言调度函数(group-chats.js 原样切出)和 Join 模式的拼卡。随机数用带种子的 seedrandom。
import fs from 'node:fs';
import seedrandom from 'seedrandom';
console.error = (...a) => process.stderr.write('ERR ' + a.map(x => x?.stack ?? String(x)).join(' ').slice(0, 1500) + '\n');
const L = await import('./lib.mjs');
const OUT = '/data2/work/lht/study/26.06/_ext/st-lab/out/group';
const M = '/data2/work/lht/study/26.06/_ext/st-lab/out/material/';
fs.mkdirSync(OUT, { recursive: true });
const save = (name, obj) => fs.writeFileSync(`${OUT}/${name}.json`, JSON.stringify(obj, null, 1));
const sera = { ...JSON.parse(fs.readFileSync(M + 'seraphina.zh.json')), avatar: 'seraphina.png' };
const lorne = { ...JSON.parse(fs.readFileSync(M + 'lorne.constructed.json')), avatar: 'lorne.png' };
const CONN = { chat_completion_source: 'deepseek', deepseek_model: 'deepseek-flash', stream_openai: false };
const group = { id: '1', name: '林间空地', members: ['seraphina.png', 'lorne.png'], activation_strategy: 0, generation_mode: 0, disabled_members: [], allow_self_responses: false, chat_id: '林间空地 - 实跑', chats: ['林间空地 - 实跑'] };
await L.init({ card: sera, books: {}, name1: '旅人', oaiPatch: CONN });
function runNatural(cards, input, lastMessage, isUserInput, seeds = 6) {
L.setGroup(cards, group);
const out = [];
for (let s = 1; s <= seeds; s++) {
Math.random = seedrandom('group-' + s);
const ids = L.G.activateNaturalOrder(group.members, input, lastMessage, false, isUserInput);
out.push(ids.map(i => L.S.characters[i].name));
}
Math.random = seedrandom();
return out;
}
const en = (c, name) => ({ ...structuredClone(c), name });
const mute = (c) => ({ ...structuredClone(c), talkativeness: '0' });
const runs = {
'中文名点名:「洛恩,你怎么看?」': runNatural([sera, lorne], '洛恩,你怎么看?', null, true),
'英文名点名:「Lorne, what do you think?」(构造:两人改用英文名)': runNatural([en(sera, 'Seraphina'), en(lorne, 'Lorne')], 'Lorne, what do you think?', null, true),
'没点名:「你们两位认识很久了吗?」': runNatural([sera, lorne], '你们两位认识很久了吗?', null, true),
'洛恩话痨度改成 0、也没点名(构造)': runNatural([sera, mute(lorne)], '你们两位认识很久了吗?', null, true),
'自动模式、上一条是洛恩说的': runNatural([sera, lorne], '', { name: '洛恩', is_user: false, mes: '哼。' }, false),
};
save('scheduler', { talkativeness: { 塞拉菲娜: sera.talkativeness, 洛恩: lorne.talkativeness }, runs });
// Join 模式:几个成员的描述拼成一份(getGroupCharacterCardsLazy),dry run 看 charDescription 槽
{
const out = {};
for (const [tag, mode, prefix] of [['swap(出厂)', 0, ''], ['join', 1, ''], ['join + 前缀(构造)', 1, '【{{char}}】']]) {
await L.init({ card: sera, books: {}, name1: '旅人', oaiPatch: CONN });
L.setGroup([sera, lorne], { ...group, generation_mode: mode, generation_mode_join_prefix: prefix, generation_mode_join_suffix: '' });
L.setCharacter(1);
L.S.__set('chat', [L.S.chat[0] ?? { name: '塞拉菲娜', is_user: false, mes: '……', extra: {} }]);
const g = await L.generate({ type: 'normal', dryRun: true });
out[tag] = g.ledger.filter(x => ['charDescription', 'main', 'newMainChat'].includes(x.identifier) || x.identifier.startsWith('groupNudge') || x.identifier === 'chatHistory-1').map(x => ({ identifier: x.identifier, content: x.content }));
}
save('join', out);
}
process.stdout.write('done\n');
process.exit(0);
// 用户自己加规则(第 8 条线):正则把显示、存储、发给模型拆开;变量存在聊天文件头;事件总线可以原地改 prompt。
import fs from 'node:fs';
console.error = (...a) => process.stderr.write('ERR ' + a.map(x => x?.stack ?? String(x)).join(' ').slice(0, 1500) + '\n');
const L = await import('./lib.mjs');
const OUT = '/data2/work/lht/study/26.06/_ext/st-lab/out/rules';
const M = '/data2/work/lht/study/26.06/_ext/st-lab/out/material/';
const MAIN = '/data2/work/lht/study/26.06/_ext/st-lab/out/main/';
fs.mkdirSync(OUT, { recursive: true });
const save = (name, obj) => fs.writeFileSync(`${OUT}/${name}.json`, JSON.stringify(obj, null, 1));
const card = JSON.parse(fs.readFileSync(M + 'seraphina.zh.json'));
const eldoria = JSON.parse(fs.readFileSync(M + 'eldoria.zh.json'));
const CONN = { chat_completion_source: 'deepseek', deepseek_model: 'deepseek-flash', stream_openai: false };
const init = () => L.init({ card, books: { '埃尔多利亚': eldoria }, name1: '旅人', oaiPatch: CONN });
// ---------- 1. 正则:状态栏只给人看渲染后的样子;发给模型时只留最新一条的状态栏 ----------
{
await init();
// 构造:前两轮的回复末尾各带一个状态栏(相当于卡要求模型每条输出一段状态)
const chat = JSON.parse(fs.readFileSync(MAIN + 'r2-chat.json')).chat.slice(0, 5);
const bars = { 0: '好感:20|伤势:昏迷刚醒', 2: '好感:30|伤势:左腿未愈', 4: '好感:35|伤势:左腿未愈' };
for (const [i, bar] of Object.entries(bars)) {
chat[i].mes += `\n<状态>${bar}</状态>`;
if (Array.isArray(chat[i].swipes)) chat[i].swipes[chat[i].swipe_id ?? 0] = chat[i].mes;
}
L.S.__set('chat', chat);
// 脚本字段照 regex 扩展新建脚本的默认值(regex/index.js),只改名字、正则、替换串和三个开关
const base = { disabled: false, trimStrings: [], placement: [2], substituteRegex: 0, runOnEdit: true, minDepth: null, maxDepth: null, markdownOnly: false, promptOnly: false };
L.EXT.extension_settings.regex = [
{ ...base, id: 'r1', scriptName: '状态栏渲染', findRegex: '/<状态>([\\s\\S]*?)<\\/状态>/g', replaceString: '<div class="status">$1</div>', markdownOnly: true },
{ ...base, id: 'r2', scriptName: '旧状态栏不发给模型', findRegex: '/\\n?<状态>[\\s\\S]*?<\\/状态>/g', replaceString: '', promptOnly: true, minDepth: 1 },
];
const n = chat.length;
// 显示:messageFormatting 里按「倒数第几条」算 depth、isMarkdown(script.js:1851-1860)
const display = Object.keys(bars).map(i => ({ index: Number(i), depth: n - 1 - Number(i), shown: L.RX.getRegexedString(chat[i].mes, L.RX.regex_placement.AI_OUTPUT, { isMarkdown: true, depth: n - 1 - Number(i) }).slice(-40) }));
const stored = Object.keys(bars).map(i => ({ index: Number(i), stored: chat[i].mes.slice(-30) }));
const g = await L.generate({ type: 'normal', dryRun: true });
const sent = g.prompt.filter(m => m.role === 'assistant').map(m => m.content.slice(-40));
save('regex', { scripts: L.EXT.extension_settings.regex.map(({ scriptName, findRegex, replaceString, markdownOnly, promptOnly, minDepth }) => ({ scriptName, findRegex, replaceString, markdownOnly, promptOnly, minDepth })), stored, display, sentToModel: sent });
}
// ---------- 2. 变量:宏读写,存进 chat_metadata.variables ----------
{
await init();
const a = L.S.substituteParams('{{setvar::好感::35}}好感是 {{getvar::好感}}');
const b = L.S.substituteParams('{{addvar::好感::5}}现在是 {{getvar::好感}}');
save('variables', { first: a, second: b, chat_metadata_variables: L.S.chat_metadata.variables });
}
// ---------- 3. 事件总线:监听 CHAT_COMPLETION_PROMPT_READY,原地往消息数组里塞一条 ----------
{
await init();
L.S.__set('chat', JSON.parse(fs.readFileSync(MAIN + 'r2-chat.json')).chat);
const handler = (data) => {
data.chat.splice(data.chat.length - 1, 0, { role: 'system', content: '(扩展插入:外面下起了雨。)' });
};
L.S.eventSource.on(L.S.event_types.CHAT_COMPLETION_PROMPT_READY, handler);
const g = await L.generate({ type: 'normal', dryRun: true });
L.S.eventSource.removeListener(L.S.event_types.CHAT_COMPLETION_PROMPT_READY, handler);
save('event', { tail: g.deepseek.body.messages.slice(-3).map(m => ({ role: m.role, content: m.content.slice(0, 50) })) });
}
process.stdout.write('done\n');
process.exit(0);
其余文件(shims.mjs、server.mjs、手写的桩、translate.py、pack.py、run_all.sh)在 assets/sim/harness/ 里,不展开。