// @ts-nocheck
// @ts-nocheck
// @ts-nocheck
/**
* dsh-memory 扩展:角色扮演网页(同源挂载到 webServer /roleplay 前缀)。
*
* 职责边界(三层拆分后):
* · 大脑(mdcg):角色定义 / 对话转录显式落认知图(md_cg = 唯一真源)——
* 转录、历史、翻译、落图全部不依赖「身体」,始终可用。
* · 身体(可选 capability 后端):生成能力 roleplay_chat / role_create /
* role_import 属「身」,主仓默认不挂载;未挂载时这些接口 **fail-closed**
* 返回明确原因(不编造回复)。启用见 config.capability(dsh/cordis.yml.example)。
*/
import { join, dirname } from 'node:path';
import { readFileSync, mkdirSync, appendFileSync, existsSync, writeFileSync, renameSync } from 'node:fs';
const HTML = `
灵枢 · 角色扮演
← 返回 DSH
灵枢 · 角色扮演
白箱 · 扮演论 v3.3
选择角色,开始对话。角色回应由灵枢角色扮演引擎生成(白箱优先,LLM 续答)。
正在思考…
角色详情 · -
记忆=背景设定;锚点=不可遗忘的自我核心;价值观=扮演的立场准则(带条件)。保存后立即生效(引擎每次重读角色 meta)。
`;
/** 在 webServer 上挂载 /roleplay 前缀路由(页面 + API)。失败不影响插件主体。
*
* @param capability 「身体」能力后端桥(可选);为 null 时生成接口 fail-closed。
* @param mdcg 大脑客户端(认知图,唯一真源);为 null 时落图静默跳过。
*/
export async function installRoleplayWeb(ctx, capability, config, disposers, mdcg: any = null) {
// 身体能力后端未接入时的统一提示(fail-closed:不编造回复)。
const CAP_ABSENT_MSG = '角色生成能力未接入:该能力属「身体」(AEIS 能力库),'
+ '主仓已剥离,仅保留转录/历史/翻译/落图。'
+ '如需启用,在配置中打开 capability.enabled 并给出 capability.args。';
const capReady = () => !!(capability && typeof capability.isReady === 'function' && capability.isReady());
try {
// 注意:不能直接读 ctx.webServer——Cordis 未声明 inject 的属性访问会抛
// "cannot get property without inject";ctx.get() 安全返回 undefined。
const webServer = ctx.get('webServer');
if (!webServer) {
ctx.logger.warn('dsh-memory: webServer 服务不可用,跳过角色扮演网页挂载');
return;
}
// 认知图落图(显式调用 md_cg;md_cg = 唯一真源,AEIS 仅负责生成)。
// 显式映射:MdcgClient.writeTranscript / writeRole → MCP mdcg_remember
// 见 docs/功能调用映射表_v0.1.md。落图失败不影响对话主流程。
const toGraph = async (label, fn) => {
if (!mdcg || !mdcg.isReady())
return;
try {
await fn(mdcg);
}
catch (err) {
ctx.logger.warn(`dsh-memory: 角色扮演落图失败(${label}):${String(err)}`);
}
};
// P1 修复(GPT 审查):
// ① 请求体大小限制(此前 for-await 无限累加,恶意请求可制造内存压力)
const readBody = async (req, maxBytes = 1_000_000) => {
// P2-12(批次 30):Buffer 收集后统一 utf-8 解码——旧写法
// body += chunk 在多字节字符跨 chunk 时产生替换符截断。
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > maxBytes) {
const err = new Error('请求体过大(>1MB)');
err.status = 413;
throw err;
}
chunks.push(chunk);
}
return Buffer.concat(chunks).toString('utf8');
};
// ② 编辑鉴权(P1-2,批次 26 外部审查报告):fail-closed——未配置
// ROLEPLAY_EDIT_KEY 时**拒绝全部写操作**(旧语义「未配置=开放」是
// 本地开发便利,但网页面板是唯一对浏览器开放的面,默认无鉴权 =
// 任意来源可改写角色数据)。403 文案引导配置:设 ROLEPLAY_EDIT_KEY
// 并在请求带 x-edit-key 头即可恢复编辑。
const EDIT_KEY = process.env.ROLEPLAY_EDIT_KEY || '';
const requireEditKey = (req) => {
if (!EDIT_KEY)
return false; // P1-2:未配置 = 拒绝(fail-closed)
const h = (req.headers && req.headers['x-edit-key']) || '';
return h === EDIT_KEY;
};
// V21-1(批次 34,外部报告):真分支此前误写自调用(无限递归 → 配了
// key 反而 500 且泄露内部异常文本)。两分支都是终止文案:已配置 =
// 「缺 x-edit-key 头」;未配置 = 「服务端需配置」引导。
const editKeyHint = () => EDIT_KEY
? '缺少编辑密钥(x-edit-key 头)'
: '编辑未开放:服务端需配置环境变量 ROLEPLAY_EDIT_KEY,请求需带 x-edit-key 头(P1-2 fail-closed)';
const roleDataDir = join(dirname(config.dbPath), 'roleplay_data');
try {
mkdirSync(join(roleDataDir, 'roleplay'), { recursive: true });
}
catch { /* 忽略 */ }
// 完整对话转录(JSONL,按角色 + 客户端实例持久化;认知图只落图摘要节点,
// 完整原文在此。注意 history 接口只读这里,不读认知图)
const transcriptsDir = join(roleDataDir, 'transcripts');
try {
mkdirSync(transcriptsDir, { recursive: true });
}
catch { /* 忽略 */ }
const transcriptFile = (roleId, clientId) => join(transcriptsDir, `${roleId.replace(/[^\w.-]/g, '_')}__${clientId}.jsonl`);
const TRANSCRIPT_TAIL_MAX = 500; // P2-9(批次 31):返回最近 500 条
const readTranscript = (roleId, clientId = 'shared') => {
const f = transcriptFile(roleId, clientId);
if (!existsSync(f))
return [];
const out = [];
for (const line of readFileSync(f, 'utf8').split('\n')) {
if (!line.trim())
continue;
try {
out.push(JSON.parse(line));
}
catch { /* 跳过坏行 */ }
}
// P2-9:无上限返回会让响应体随会话长度线性增长——只回最近尾部。
return out.length > TRANSCRIPT_TAIL_MAX
? out.slice(out.length - TRANSCRIPT_TAIL_MAX) : out;
};
const appendTranscript = (roleId, entry, clientId = 'shared') => {
try {
appendFileSync(transcriptFile(roleId, clientId), JSON.stringify(entry) + '\n');
}
catch { /* 忽略 */ }
};
// 稳定会话:角色+固定会话 id,让引擎的会话上下文与记忆标签跨轮连续(无限上下文)
// P1 完善(GPT 审查·会话隔离):开源无身份认证,但必须做客户端实例隔离——
// 此前所有用户共用 session `web-main:` + 同一转录文件,多浏览器/多设备
// 对话互相串线、看到彼此历史。现在按浏览器客户端生成的 client_id(x-client-id
// 头,localStorage 持久化)隔离 session 与转录文件;无 header 的旧客户端落 shared。
const clientIdOf = (req) => {
const h = (req.headers && req.headers['x-client-id']) || '';
return /^[A-Za-z0-9_-]{1,64}$/.test(h) ? h : 'shared';
};
const sessionFor = (roleId, clientId) => `web-${clientId}:${roleId}`;
// —— 自定义翻译(现实词 ↔ 扮演词)——
// 翻译表写入角色 meta(_roles.json):引擎每次 roleplay_chat 都新建引擎并重读
// meta,因此写入立即生效(输入翻译 real→virtual + LLM 沉浸注入块都会应用)。
// P1 完善(GPT 审查·meta 嵌套格式):读取归一化——文件可能是根格式
// {r:{...}} 或嵌套格式 {meta:{r:{...}}}(外部 AEIS 可能用嵌套),统一返回
// 「角色字典」视角;写入保持原顶层结构(嵌套则写回 meta 内),读写一致——
// 此前读兼容嵌套但写永远写根,保存成功但列表读旧值。
const roleMetaFile = join(roleDataDir, 'roleplay', '_roles.json');
// P1-3(批次 26 外部审查报告):角色 id 白名单——role 从 URL 提取后
// 直接作 meta 对象键,`__proto__` 键会劫持 Object.prototype(污染插件
// 与 DSH 宿主共享的同进程对象)。白名单字符集 + 显式拒绝危险键。
const _BAD_ROLE_IDS = new Set(['__proto__', 'constructor', 'prototype']);
const validRoleId = (role) => typeof role === 'string'
&& /^[A-Za-z0-9_.-]{1,64}$/.test(role)
&& !_BAD_ROLE_IDS.has(role);
// meta 字典统一 null 原型——即使存量文件已被写入危险键,
// meta['__proto__'] 也只是普通 own 属性,不再劫持原型链。
const toNullProto = (o) => (o && typeof o === 'object')
? Object.assign(Object.create(null), o)
: o;
const readRoleMeta = () => {
try {
const raw = JSON.parse(readFileSync(roleMetaFile, 'utf8'));
if (raw && typeof raw === 'object'
&& raw.meta && typeof raw.meta === 'object')
return toNullProto(raw.meta); // 嵌套格式 → meta 内角色字典
return toNullProto(raw);
}
catch {
return Object.create(null);
}
};
const writeRoleMeta = (flat) => {
try {
let raw;
try {
raw = JSON.parse(readFileSync(roleMetaFile, 'utf8'));
}
catch {
raw = {};
}
// 保持原顶层结构:原文件嵌套 {meta:{...}} → 写回 meta 内;根格式 → 写根
const out = (raw && typeof raw === 'object'
&& raw.meta && typeof raw.meta === 'object')
? { ...raw, meta: flat } : flat;
const tmp = roleMetaFile + '.tmp';
writeFileSync(tmp, JSON.stringify(out, null, 2), 'utf8');
renameSync(tmp, roleMetaFile);
}
catch (e) {
ctx.logger.warn(`dsh-memory: 角色 meta 写入失败: ${String((e && e.message) || e)}`);
}
};
// 翻译模式(双向/仅输入)由网页层持久化
const transSettingsFile = join(transcriptsDir, 'translate_settings.json');
const readTransSettings = () => {
try {
return JSON.parse(readFileSync(transSettingsFile, 'utf8'));
}
catch {
return {};
}
};
const writeTransSettings = (s) => {
// P2-10(批次 31):tmp+rename 原子写(与 writeRoleMeta 同款)——
// 直接 writeFileSync 并发/中断会写坏 JSON,后续读静默丢配置。
try {
const tmp = transSettingsFile + '.tmp';
writeFileSync(tmp, JSON.stringify(s, null, 2), 'utf8');
renameSync(tmp, transSettingsFile);
}
catch { /* 忽略 */ }
};
const getTranslations = (roleId) => {
const meta = readRoleMeta();
const t = (meta[roleId] && meta[roleId].translations) || [];
return Array.isArray(t) ? t : [];
};
const setTranslations = (roleId, pairs) => {
if (!validRoleId(roleId))
return; // P1-3:非法角色 id 拒写(防原型污染)
const meta = readRoleMeta();
if (!meta[roleId])
meta[roleId] = { created_at: Date.now() / 1000, name: roleId };
meta[roleId].translations = pairs;
writeRoleMeta(meta);
};
// 可逆替换(长词优先,与灵枢 translate_input/translate_output 同逻辑)
const applyTranslate = (text, pairs, dir) => {
if (!text || !pairs.length)
return text;
const key = dir === 'out' ? 'virtual' : 'real';
const rep = dir === 'out' ? 'real' : 'virtual';
const sorted = pairs
.filter((p) => p[key] && p[rep])
.sort((a, b) => (b[key] || '').length - (a[key] || '').length);
let out = text;
for (const p of sorted)
out = out.split(p[key]).join(p[rep]);
return out;
};
const handler = async (req, res) => {
const url = new URL(req.url ?? '/', 'http://x');
try {
const isPage = url.pathname === '/roleplay' || url.pathname === '/roleplay/';
if (req.method === 'GET' && isPage) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(HTML);
return;
}
if (req.method === 'GET' && url.pathname === '/roleplay/api/roles') {
const rolesFile = join(roleDataDir, 'roleplay', '_roles.json');
let roles = [];
try {
const raw = JSON.parse(readFileSync(rolesFile, 'utf8'));
const meta = raw.meta && typeof raw.meta === 'object' ? raw.meta : raw;
roles = Object.entries(meta)
.filter(([k]) => k !== 'created_at' && k !== 'updated_at')
.map(([id, m]) => ({ id, name: (m && m.name) || id, scenario: (m && m.scenario) || '', first_mes: (m && m.first_mes) || '' }));
}
catch { /* 文件尚不存在 */ }
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ roles }));
return;
}
// —— 三导入(记忆/锚点/价值观):items 副本存 _roles.json + role_import 真导入引擎 ——
const rdItemsFile = (roleId, kind) => `${kind}_items`;
const getRoleItems = (roleId, kind) => {
const meta = readRoleMeta();
const arr = (meta[roleId] && meta[roleId][rdItemsFile(roleId, kind)]) || [];
return Array.isArray(arr) ? arr : [];
};
if (req.method === 'GET' && url.pathname.startsWith('/roleplay/api/roles/') && url.pathname.endsWith('/items')) {
// #490(2026-10-15):读面同款 fail-closed 闸——本端点回的是私有正文/转录/翻译表,
// 写面 P1-2 已过闸而读面漏闸=同族不对称;读侧要求与写侧同一身份(x-edit-key)。
if (!requireEditKey(req)) {
res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: editKeyHint() }));
return;
}
const role = decodeURIComponent(url.pathname.split('/')[4]);
const kind = url.searchParams.get('kind') || 'memory';
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ items: getRoleItems(role, kind) }));
return;
}
if (req.method === 'POST' && url.pathname.startsWith('/roleplay/api/roles/') && url.pathname.endsWith('/import')) {
// P1 修复(GPT 审查):写操作鉴权(ROLEPLAY_EDIT_KEY)+ 请求体大小限制
if (!requireEditKey(req)) {
res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: editKeyHint() }));
return;
}
let body;
try {
body = await readBody(req);
}
catch (e) {
res.writeHead(e.status || 400, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: String((e && e.message) || e) }));
return;
}
const p = JSON.parse(body);
const role = decodeURIComponent(url.pathname.split('/')[4]);
const kind = p.kind || 'memory';
const items = (Array.isArray(p.items) ? p.items : []).filter((x) => x && x.content);
// 副本(网页层读取用)
const meta = readRoleMeta();
if (!meta[role])
meta[role] = { created_at: Date.now() / 1000, name: role };
meta[role][rdItemsFile(role, kind)] = items;
writeRoleMeta(meta);
// 真导入引擎(记忆→知识层/锚点→SELF no_forget/价值观→STRUCTURE 带条件)
let impResult = null;
if (items.length) {
if (!capReady()) {
impResult = { error: CAP_ABSENT_MSG };
}
else {
try {
impResult = await capability.callTool('role_import', {
role_id: role, kind, items, data_dir: roleDataDir,
});
}
catch (e) {
impResult = { error: String((e && e.message) || e) };
}
}
}
// 导入项显式落认知图:memory→knowledge / anchors→self / values→structural。
// 显式映射:MdcgClient.write → MCP mdcg_remember(gated=false 直写)。
// ⚠️ anchors→self 层需 designer 权限(recorder 的 layers_allow 不含 self)。
const layerForKind = { memory: 'knowledge', anchors: 'self', values: 'structural' }[kind] || 'knowledge';
void toGraph(`import-${kind}`, (g) => Promise.all(items.map((it, i) => g.write(
typeof it === 'string' ? it : (it.text || it.content || JSON.stringify(it)),
{ node_id: `roleplay_${role}_${kind}_${i}`, layer: layerForKind,
tags: ['roleplay', `role:${role}`, `roleplay:${kind}`], importance: 0.6 },
))));
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
// P1 修复(GPT 审查):引擎导入失败不得返回 ok:true(前端会显示「已保存」)
const importOk = !(impResult && (impResult.error || impResult.isError));
res.end(JSON.stringify({ ok: importOk, kind, items, import: impResult }));
return;
}
if (req.method === 'POST' && url.pathname.startsWith('/roleplay/api/roles/') && url.pathname.endsWith('/meta')) {
// P1 修复(GPT 审查):写操作鉴权(ROLEPLAY_EDIT_KEY)+ 请求体大小限制
if (!requireEditKey(req)) {
res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: editKeyHint() }));
return;
}
let body;
try {
body = await readBody(req);
}
catch (e) {
res.writeHead(e.status || 400, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: String((e && e.message) || e) }));
return;
}
const p = JSON.parse(body);
const role = decodeURIComponent(url.pathname.split('/')[4]);
if (!validRoleId(role)) {
// P1-3:__proto__/constructor/prototype 及越界字符拒绝
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: '非法角色 id(白名单 ^[A-Za-z0-9_.-]{1,64}$)' }));
return;
}
// P2-11(批次 30 注记):本读改写段为同步执行(中间无
// await)——node 单 tick 内不会交错;若未来在此段引入
// await,须先加写队列/文件锁(并发丢更新防线)。
const meta = readRoleMeta();
if (!meta[role])
meta[role] = { created_at: Date.now() / 1000 };
if (typeof p.name === 'string')
meta[role].name = p.name;
if (typeof p.scenario === 'string')
meta[role].scenario = p.scenario;
if (typeof p.first_mes === 'string')
meta[role].first_mes = p.first_mes;
if (typeof p.nsfw === 'boolean')
meta[role].nsfw = p.nsfw;
writeRoleMeta(meta);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: true }));
return;
}
if (req.method === 'POST' && url.pathname === '/roleplay/api/chat') {
// P1 修复(GPT 审查):对话接口加请求体大小限制(用户聊天不需编辑密钥)
let body;
try {
body = await readBody(req);
}
catch (e) {
res.writeHead(e.status || 400, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: String((e && e.message) || e) }));
return;
}
const p = JSON.parse(body);
const role = p.role_id || 'protocol-guide';
// P2-23(批次 30):chat 的 role_id 进 node_id 模板——
// 纵深防御:与 /meta 同款白名单(当前模板前缀使穿越
// 不可达,但模板改动即破——入口统一卡死)
if (!validRoleId(role)) {
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: '非法角色 id(白名单 ^[A-Za-z0-9_.-]{1,64}$)' }));
return;
}
// N266:入口类型闸——message 必须是字符串。此前这段直接
// `p.message.includes(w)`:message 为数组时静默走
// Array.prototype.includes(逐元素**全等**比较),把敏感词拆进
// 不同元素即整条绕过硬拦截;数字/对象/缺省则抛 TypeError 落 500
// (内部异常文本随响应外泄)。与 role_id 同口径:非法形态在入口
// 一律 400 拒——不转发引擎、不落转录。
if (typeof p.message !== 'string') {
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, route: 'refused', refused: 'bad_message', error: 'message 必须是字符串(非字符串形态不得绕过内容硬拦截,N266)' }));
return;
}
// P1 完善(会话隔离):按客户端实例隔离 session 与转录
const cid = clientIdOf(req);
// —— 服务端内容分级硬拦截(不依赖前端,法律与协议保护)——
// 未成年 + 性 = 一律拒绝(国家法律 + 未成年人保护);NSFW 词提示年龄确认
const NSFW_WORDS = ['做爱', '性交', '性行为', '口交', '肛交', '高潮', '自慰', '射精', '色情', '裸体', '脱光', '爱抚', '挑逗', '性器官', '插入', '内射', '强暴'];
const MINOR_WORDS = ['未成年', '儿童', '小孩', '小女孩', '小男孩', '幼女', '萝莉', '幼童', '初中生', '小学生', '14岁', '13岁', '12岁', '学生妹', '女童', '男童'];
const hasNsfw = NSFW_WORDS.some(w => p.message.includes(w));
const hasMinor = MINOR_WORDS.some(w => p.message.includes(w));
if (hasMinor && hasNsfw) {
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ reply: '⛔ 已拒绝:涉及未成年人的性内容违反国家法律与灵枢协议(未成年人保护)。灵枢不提供任何涉及未成年人的性扮演内容。', route: 'refused', refused: 'minor_nsfw' }));
return;
}
if (!capReady()) {
res.writeHead(503, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ reply: '', route: 'unavailable', error: CAP_ABSENT_MSG }));
return;
}
// N266:落盘移到内容门控与就绪闸**之后**——被内容门控拒绝的、以及
// 「能力未接入」(503)的回合都从未发生,此前这两笔写在校验与
// capReady 之前,未经门控的 message 原文会被留进本地转录
//(读同一文件的历史接口随即原样回放)。
appendTranscript(role, { time: Date.now(), role: 'user', text: p.message }, cid);
void toGraph('user-turn', (g) => g.writeTranscript(role, cid, 'user', p.message));
const r = await capability.callTool('roleplay_chat', {
message: p.message, role_id: role, session_id: sessionFor(role, cid), data_dir: roleDataDir,
});
const text = typeof r === 'string' ? r : (r?.content?.[0]?.text ?? JSON.stringify(r));
let parsed;
try {
parsed = JSON.parse(text);
}
catch {
parsed = { reply: text, route: 'raw' };
}
let replyText = parsed.reply ?? '';
// 双向翻译模式:把角色的扮演词翻译回现实词(输入翻译由引擎自动执行 real→virtual)
const settings = readTransSettings();
if ((settings[role] && settings[role].mode) === 'bidirectional') {
replyText = applyTranslate(replyText, getTranslations(role), 'out');
}
appendTranscript(role, { time: Date.now(), role: 'bot', text: replyText, route: parsed.route ?? '', memories: parsed.memories ?? undefined }, cid);
void toGraph('assistant-turn', (g) => g.writeTranscript(role, cid, 'assistant', replyText, { route: parsed.route ?? '' }));
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ reply: replyText, route: parsed.route ?? '', memories: parsed.memories ?? undefined, error: parsed.error ?? undefined }));
return;
}
if (req.method === 'GET' && url.pathname === '/roleplay/api/translate') {
// #490(2026-10-15):读面同款 fail-closed 闸——本端点回的是私有正文/转录/翻译表,
// 写面 P1-2 已过闸而读面漏闸=同族不对称;读侧要求与写侧同一身份(x-edit-key)。
if (!requireEditKey(req)) {
res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: editKeyHint() }));
return;
}
const role = url.searchParams.get('role_id') || 'protocol-guide';
const settings = readTransSettings();
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ pairs: getTranslations(role), mode: (settings[role] && settings[role].mode) || 'input_only' }));
return;
}
if (req.method === 'POST' && url.pathname === '/roleplay/api/translate') {
// P1 修复(GPT 审查):写操作鉴权(ROLEPLAY_EDIT_KEY)+ 请求体大小限制
if (!requireEditKey(req)) {
res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: editKeyHint() }));
return;
}
let body;
try {
body = await readBody(req);
}
catch (e) {
res.writeHead(e.status || 400, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: String((e && e.message) || e) }));
return;
}
const p = JSON.parse(body);
const role = p.role_id || 'protocol-guide';
// P1-3 补卡(translate POST):与 /meta、/chat 同款白名单——
// 此前不校验即 setTranslations(内部白名单不匹配**静默 return**,
// 中文 id 词对永不落盘)+ settings[role] 直接键赋值('__proto__'
// 走原型 setter 连 mode 都丢),却仍回 ok:true = 虚假成功。
if (!validRoleId(role)) {
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: '非法角色 id(白名单 ^[A-Za-z0-9_.-]{1,64}$)' }));
return;
}
const pairs = (Array.isArray(p.pairs) ? p.pairs : [])
.map((x) => ({ real: String(x.real || '').trim(), virtual: String(x.virtual || '').trim(), note: String(x.note || '').trim() }))
.filter((x) => x.real && x.virtual);
setTranslations(role, pairs);
const settings = readTransSettings();
settings[role] = { mode: p.mode === 'bidirectional' ? 'bidirectional' : 'input_only' };
writeTransSettings(settings);
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: true, pairs, mode: settings[role].mode }));
return;
}
if (req.method === 'GET' && url.pathname === '/roleplay/api/history') {
// #490(2026-10-15):读面同款 fail-closed 闸——本端点回的是私有正文/转录/翻译表,
// 写面 P1-2 已过闸而读面漏闸=同族不对称;读侧要求与写侧同一身份(x-edit-key)。
if (!requireEditKey(req)) {
res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: editKeyHint() }));
return;
}
const role = url.searchParams.get('role_id') || 'protocol-guide';
// P1 完善(会话隔离):按客户端实例读各自转录
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ history: readTranscript(role, clientIdOf(req)) }));
return;
}
if (req.method === 'POST' && url.pathname === '/roleplay/api/roles') {
// P1 修复(GPT 审查):写操作鉴权(ROLEPLAY_EDIT_KEY)+ 请求体大小限制
if (!requireEditKey(req)) {
res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: editKeyHint() }));
return;
}
let body;
try {
body = await readBody(req);
}
catch (e) {
res.writeHead(e.status || 400, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: String((e && e.message) || e) }));
return;
}
const p = JSON.parse(body);
if (!capReady()) {
res.writeHead(503, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: false, error: CAP_ABSENT_MSG }));
return;
}
const r = await capability.callTool('role_create', {
role_id: p.role_id, name: p.name || '', scenario: p.scenario || '', first_mes: p.first_mes || '', data_dir: roleDataDir,
});
void toGraph('role-create', (g) => g.writeRole(p.role_id, JSON.stringify({ name: p.name || '', scenario: p.scenario || '', first_mes: p.first_mes || '' })));
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ ok: true, result: r }));
return;
}
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: 'not found' }));
}
catch (e) {
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
res.end(JSON.stringify({ error: String((e && e.message) || e) }));
}
};
disposers.push(webServer.register({ kind: 'prefix', path: '/roleplay', handler }));
// 在 GUI 首页注入浮动入口按钮(APP 内置页面内一键进入角色扮演,右上角)
// issue #8:按钮可隐藏(roleplayEntryButton=false 不注入)+ 可拖动
// (pointer 拖拽,位置记忆在 localStorage;移动 >6px 视为拖动,否则放行点击跳转)
if (config.roleplayEntryButton) {
disposers.push(webServer.tapIndex((html) => {
if (html.includes('href="/roleplay"'))
return html;
const btn = '🎭 角色扮演' +
'';
return html.replace('