// @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 续答)。
正在思考…

创建新角色

🌐 自定义翻译(-)

现实词会按表替换成扮演词交给角色(引擎自动);双向模式再把角色回复中的扮演词翻译回现实词。长词优先匹配。

⛔ 成人内容确认

本页面提供角色扮演内容,部分角色/对话可能包含成人向(NSFW)内容。

依据国家法律法规与灵枢协议(对人类的保护):
① 仅限已满 18 周岁的用户使用成人向内容;
② 仅限个人对话场景(本页面为本地一对一,不公开);
③ 灵枢拒绝一切涉及未成年人的性内容——此类请求会被直接拒绝并记录。

点击「我已满 18 周岁」表示你确认符合上述条件。

角色详情 · -

记忆=背景设定;锚点=不可遗忘的自我核心;价值观=扮演的立场准则(带条件)。保存后立即生效(引擎每次重读角色 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('', `${btn}\n`); })); } ctx.logger.info(`dsh-memory: 角色扮演网页已挂载 /roleplay(data_dir=${roleDataDir},入口按钮=${config.roleplayEntryButton ? '显示(可拖动)' : '隐藏'})`); } catch (err) { ctx.logger.warn(`dsh-memory: 角色扮演网页挂载失败(不影响其他功能): ${String(err && err.message || err)}`); } }