diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1dec36e..a634a55 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,26 @@
---
+## [0.6.0] - 2026-03-31
+
+### RMA 关系记忆系统(全新模块)
+- **新增**:`src/rma/` 模块(10个文件),实现完整的关系记忆架构
+ - `config-loader.js` — 从角色卡 `extensions.cola_rma_config` 加载 RMA 配置
+ - `memory-store.js` — 基于 `chat_metadata.cola_rma` 的记忆存储管理
+ - `analyzer.js` — 后处理 AI 分析引擎,独立 API 调用提取关系变化
+ - `response-hook.js` — 监听 `CHARACTER_MESSAGE_RENDERED` / `MESSAGE_DELETED` 事件
+ - `phase-manager.js` — 六阶段关系管理(stranger→bond),支持回退
+ - `worldbook-sync.js` — 世界书条目同步(阶段互斥切换、质感改写、事件解锁)
+ - `confirmation-ui.js` — 用户确认流程(每轮/仅重要/全自动三种模式)
+ - `float-panel.js` — 悬浮面板 UI(展开/半折叠/最小化三态)
+ - `timeline-view.js` — 记忆时间线视图
+- **新增**:四种记忆类型 — breakthrough(⭐)、warmth(💛)、crack(⚡)、revelation(👁)
+- **新增**:RMA 设置面板 — 启用开关、确认模式选择、面板状态偏好、独立 API 配置
+- **新增**:`ui/rma-panel.html` — RMA 悬浮面板 HTML 模板
+- **新增**:RMA 面板 CSS 样式,兼容已有多主题系统
+
+---
+
## [0.5.0] - 2025-02-13
### 总结世界书拆分功能优化
diff --git a/package-lock.json b/package-lock.json
index ba38b37..fd54118 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,13 +1,13 @@
{
"name": "memory-manager-concurrent",
- "version": "0.4.7",
+ "version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "memory-manager-concurrent",
- "version": "0.4.7",
- "license": "AGPL-3.0",
+ "version": "0.5.0",
+ "license": "CC-BY-NC-ND-4.0",
"devDependencies": {
"terser-webpack-plugin": "^5.3.10",
"webpack": "^5.104.1",
diff --git a/package.json b/package.json
index f36e561..416e255 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "memory-manager-concurrent",
- "version": "0.5.0",
+ "version": "0.6.0",
"description": "SillyTavern 记忆管理并发系统 - 智能记忆检索与注入系统",
"main": "dist/index.js",
"scripts": {
diff --git a/src/config/default-config.js b/src/config/default-config.js
index e68546d..cad73ab 100644
--- a/src/config/default-config.js
+++ b/src/config/default-config.js
@@ -113,6 +113,25 @@ export const defaultConfig = Object.freeze({
minChars: 40000, // 最小字符数(确保段落完整)
maxChars: 60000, // 最大字符数(确保段落完整)
},
+ // RMA 关系记忆系统配置
+ rmaConfig: {
+ enabled: false, // 全局开关
+ confirmationMode: 'every_turn', // every_turn | important_only | auto
+ analysisApi: {
+ apiFormat: 'openai',
+ apiUrl: '',
+ apiKey: '',
+ model: '',
+ maxTokens: 1500,
+ temperature: 0.3,
+ customTemplate: '',
+ responsePath: 'choices.0.message.content',
+ },
+ floatPanel: {
+ defaultState: 'half_collapsed', // expanded | half_collapsed | minimized
+ position: { x: 'right', y: 'top' },
+ },
+ },
},
memoryConfigs: {},
summaryConfigs: {},
diff --git a/src/index.js b/src/index.js
index 789ee32..0114485 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,6 +1,6 @@
/**
* 记忆管理并发系统 - 主入口
- * @version 0.4.9
+ * @version 0.6.0
* @author 可乐、繁华
* @license CC BY-NC-ND 4.0
* @see https://github.com/Cola-Echo/memory-manager-concurrent
@@ -69,6 +69,7 @@ import {
setMessageProgressPanel,
setOpenIndexMergeConfigModalFunction,
setOpenPlotOptimizeConfigModalFunction,
+ setOpenRmaConfigModalFunction,
setPlotPanelProgressTracker,
setPromptEditorFunctions,
setRefreshAIConfigListFunction,
@@ -96,6 +97,8 @@ import {
// 模型显示更新
updateIndexMergeModelDisplay,
updatePlotOptimizeModelDisplay,
+ // RMA 关系记忆
+ updateRmaModelDisplay,
// 总结世界书拆分配置弹窗
setSummaryPartConfigModalFunction,
showSummaryPartConfigModal,
@@ -129,8 +132,19 @@ import {
// 表格填表模块
import { initTableFiller } from "@table-filler/index";
+// RMA 关系记忆系统
+import {
+ isRmaEnabled,
+ hasRmaConfig,
+ initRmaState,
+ getCurrentRmaConfig,
+ registerRmaEventListeners,
+ initRmaPanel,
+ showRmaPanel,
+} from "@rma";
+
// 版本信息
-const VERSION = "0.4.9";
+const VERSION = "0.6.0";
// 面板状态
let isPanelVisible = false;
@@ -252,6 +266,9 @@ async function initPlugin() {
setOpenPlotOptimizeConfigModalFunction(() =>
showConfigModal("剧情优化", "plot"),
);
+ setOpenRmaConfigModalFunction(() =>
+ showConfigModal("RMA分析", "rma"),
+ );
// 设置更新列表清空函数
setClearUpdatesListFunction(clearUpdatesList);
@@ -264,6 +281,7 @@ async function initPlugin() {
updateIndexMergeModelDisplay,
updatePlotOptimizeModelDisplay,
refreshAIConfigList,
+ updateRmaModelDisplay,
);
// 设置总结世界书拆分配置弹窗函数
@@ -359,6 +377,13 @@ async function initUI() {
});
}, 3000);
+ // 初始化 RMA 关系记忆系统(延迟以确保角色数据加载完成)
+ setTimeout(() => {
+ initRmaModule().catch((e) => {
+ Logger.debug("RMA 模块初始化失败:", e);
+ });
+ }, 2000);
+
Logger.log("UI 初始化完成");
} catch (error) {
Logger.error("UI 初始化失败:", error);
@@ -431,6 +456,9 @@ function registerEventListeners() {
}
Logger.log("已注册事件监听");
+
+ // 注册 RMA 事件监听(MESSAGE_RECEIVED / MESSAGE_DELETED)
+ registerRmaEventListeners();
} else {
Logger.warn("事件系统不可用,使用延迟初始化");
// 延迟安装钩子
@@ -442,6 +470,37 @@ function registerEventListeners() {
}
}
+/**
+ * 初始化 RMA 关系记忆系统
+ */
+async function initRmaModule() {
+ try {
+ if (!isRmaEnabled()) {
+ Logger.debug("RMA 未启用");
+ return;
+ }
+
+ if (!hasRmaConfig()) {
+ Logger.debug("当前角色无 RMA 配置");
+ return;
+ }
+
+ const config = getCurrentRmaConfig();
+ if (config) {
+ // 初始化 RMA 状态(如果首次使用)
+ initRmaState(config);
+
+ // 初始化悬浮面板
+ await initRmaPanel();
+ showRmaPanel();
+
+ Logger.log("RMA 模块初始化完成");
+ }
+ } catch (e) {
+ Logger.debug("RMA 模块初始化失败:", e);
+ }
+}
+
// 启动插件
if (typeof jQuery !== "undefined") {
jQuery(async () => {
diff --git a/src/rma/analyzer.js b/src/rma/analyzer.js
new file mode 100644
index 0000000..e44d57a
--- /dev/null
+++ b/src/rma/analyzer.js
@@ -0,0 +1,263 @@
+/**
+ * RMA 后处理分析引擎
+ * 构建 prompt → 调用 AI → 解析结构化结果
+ * @module rma/analyzer
+ */
+
+import Logger from '@core/logger';
+import { getCurrentCharacterDescription } from '@core/sillytavern-api';
+import APIAdapter from '@api/adapter';
+import { getRmaAnalysisApiConfig } from './index';
+import { getCurrentRmaConfig, getSensitivities, getEmotionalProcessing, getSecrets, getPhaseDefinitions } from './config-loader';
+import { getRmaState, getRelevantMemories, getUnresolvedThreads, getCurrentTexture, getPhase } from './memory-store';
+
+const log = Logger.createModuleLogger('RMA-Analyzer');
+
+const SYSTEM_PROMPT = `你是一个角色扮演分析引擎。你的任务是分析对话中发生的事件,评估它们对角色关系和情感状态的影响。
+
+你需要输出两部分内容:
+1. 一个 JSON 代码块(用 \`\`\`json 包裹),包含结构化的记忆/状态更新
+2. 一个 [NARRATIVE] 标记后的自然语言段落,描述角色当前内心状态(200-400 token)
+
+规则:
+- 只在真正发生了对角色有意义的事时才创建记忆。日常寒暄不需要记忆。
+- 阶段变化需要积累足够的记忆支持,不要轻易改变。
+- 参考角色的敏感点来判断事件重要性。
+- 参考情绪处理模式来区分表面反应和真实感受。
+- crack 类型记忆必须说明 lasting_effect。
+- NARRATIVE 不要使用"阶段""变量""系统"等元语言,用角色本人的语气写。`;
+
+/**
+ * 构建用户 prompt
+ */
+function buildUserPrompt(recentMessages, latestResponse) {
+ const config = getCurrentRmaConfig();
+ if (!config) return '';
+
+ const charDesc = getCurrentCharacterDescription();
+ const sensitivities = getSensitivities(config);
+ const emotional = getEmotionalProcessing(config);
+ const secrets = getSecrets(config);
+ const state = getRmaState();
+ const currentPhase = getPhase();
+ const relevantMems = getRelevantMemories(latestResponse);
+ const threads = getUnresolvedThreads();
+ const texture = getCurrentTexture();
+
+ const parts = [];
+
+ parts.push(`## 角色设定\n${charDesc || '无'}`);
+
+ // 敏感点
+ if (Object.keys(sensitivities).length > 0) {
+ const lines = Object.entries(sensitivities).map(([id, s]) =>
+ `- ${id}: ${s.description}(反应: ${s.reaction},原因: ${s.why})`
+ );
+ parts.push(`## 敏感点\n${lines.join('\n')}`);
+ }
+
+ // 情感处理模式
+ if (emotional.style) {
+ parts.push(`## 情感处理模式\n风格: ${emotional.style}\n${emotional.description || ''}\n表达模式: ${emotional.expression_pattern || ''}\n防御机制: ${emotional.defense_mechanism || ''}`);
+ }
+
+ // 秘密路径
+ if (Object.keys(secrets).length > 0) {
+ const lines = Object.entries(secrets).map(([id, s]) => {
+ const currentStage = state?.secrets?.[id]?.current_stage || s.initial_stage;
+ const stages = s.stages?.map(st => st.id === currentStage ? `[${st.name}]` : st.name).join(' → ') || '';
+ return `- ${id}: ${s.description}(当前: ${currentStage},路径: ${stages})`;
+ });
+ parts.push(`## 秘密路径\n${lines.join('\n')}`);
+ }
+
+ // 当前阶段
+ const phaseDefs = getPhaseDefinitions(config);
+ const phaseNames = phaseDefs.map(p => p.name === currentPhase ? `[${p.name}]` : p.name).join(' → ');
+ parts.push(`## 当前关系阶段\n${currentPhase || '未知'}(路径: ${phaseNames})\n趋势: ${state?.phase?.tendency || 'stable'}`);
+
+ // 现有记忆
+ if (relevantMems.length > 0) {
+ const memLines = relevantMems.map(m =>
+ `- [${m.type}] 第${m.day}天: ${m.event}(影响: ${m.emotional_impact}${m.lasting_effect ? `,持续: ${m.lasting_effect}` : ''})`
+ );
+ parts.push(`## 现有记忆\n${memLines.join('\n')}`);
+ } else {
+ parts.push('## 现有记忆\n无');
+ }
+
+ // 当前质感
+ parts.push(`## 当前情感纹理\n${texture || '无'}`);
+
+ // 未解决事项
+ if (threads.length > 0) {
+ parts.push(`## 未解决的事\n${threads.map(t => `- ${t}`).join('\n')}`);
+ }
+
+ // 最近对话
+ if (recentMessages && recentMessages.length > 0) {
+ const msgLines = recentMessages.map(m =>
+ `${m.is_user ? '用户' : '角色'}: ${m.mes?.slice(0, 500) || ''}`
+ );
+ parts.push(`## 最近对话\n${msgLines.join('\n')}`);
+ }
+
+ // 最新回复
+ parts.push(`## AI 最新回复\n${latestResponse || '无'}`);
+
+ return parts.join('\n\n');
+}
+
+/**
+ * 解析 AI 输出,分离 JSON 和 NARRATIVE
+ * @param {string} text AI 原始输出
+ * @returns {{ json: object|null, narrative: string }}
+ */
+function parseAnalysisOutput(text) {
+ let json = null;
+ let narrative = '';
+
+ // 提取 JSON 代码块
+ const jsonMatch = text.match(/```json\s*([\s\S]*?)```/);
+ if (jsonMatch) {
+ try {
+ json = JSON.parse(jsonMatch[1].trim());
+ } catch (e) {
+ log.warn('JSON 解析失败:', e.message);
+ }
+ }
+
+ // 如果没有代码块格式,尝试直接找 JSON 对象
+ if (!json) {
+ const braceMatch = text.match(/\{[\s\S]*"new_memories"[\s\S]*\}/);
+ if (braceMatch) {
+ try {
+ json = JSON.parse(braceMatch[0]);
+ } catch {
+ // ignore
+ }
+ }
+ }
+
+ // 提取 NARRATIVE
+ const narrativeMatch = text.match(/\[NARRATIVE\]\s*([\s\S]*?)(?:$|```)/i);
+ if (narrativeMatch) {
+ narrative = narrativeMatch[1].trim();
+ } else {
+ // 尝试取 JSON 代码块之后的所有内容
+ const afterJson = text.replace(/```json[\s\S]*?```/, '').trim();
+ if (afterJson.length > 50) {
+ narrative = afterJson;
+ }
+ }
+
+ return { json, narrative };
+}
+
+/**
+ * 验证分析结果
+ * @param {object} json 解析后的 JSON
+ * @param {object} config RMA 配置
+ * @returns {object} 清理后的结果
+ */
+function validateAnalysisResult(json, config) {
+ if (!json) return null;
+
+ const validTypes = ['breakthrough', 'warmth', 'crack', 'revelation'];
+ const phaseNames = getPhaseDefinitions(config).map(p => p.name || p.id);
+ const secretIds = Object.keys(getSecrets(config));
+
+ // 过滤非法记忆类型
+ if (json.new_memories) {
+ json.new_memories = json.new_memories.filter(m => {
+ if (!validTypes.includes(m.type)) {
+ log.warn(`过滤非法记忆类型: ${m.type}`);
+ return false;
+ }
+ return true;
+ });
+ }
+
+ // 验证阶段名
+ if (json.phase_assessment?.new_phase) {
+ if (!phaseNames.includes(json.phase_assessment.new_phase)) {
+ log.warn(`非法阶段名: ${json.phase_assessment.new_phase},已忽略`);
+ json.phase_assessment.phase_changed = false;
+ json.phase_assessment.new_phase = null;
+ }
+ }
+
+ // 验证秘密 ID
+ if (json.secret_updates) {
+ for (const id of Object.keys(json.secret_updates)) {
+ if (!secretIds.includes(id)) {
+ log.warn(`非法秘密 ID: ${id},已移除`);
+ delete json.secret_updates[id];
+ }
+ }
+ }
+
+ return json;
+}
+
+/**
+ * 执行后处理分析
+ * @param {Array} recentMessages 最近的聊天消息
+ * @param {string} latestResponse 最新 AI 回复
+ * @returns {Promise<{json: object, narrative: string}|null>} 分析结果
+ */
+export async function analyzeResponse(recentMessages, latestResponse) {
+ const config = getCurrentRmaConfig();
+ if (!config) return null;
+
+ const apiConfig = getRmaAnalysisApiConfig();
+ if (!apiConfig?.apiUrl || !apiConfig?.model) {
+ log.warn('RMA 分析 API 未配置');
+ return null;
+ }
+
+ const userPrompt = buildUserPrompt(recentMessages, latestResponse);
+
+ let retries = 0;
+ const maxRetries = 2;
+
+ while (retries <= maxRetries) {
+ try {
+ log.log(`发送后处理分析请求 (尝试 ${retries + 1}/${maxRetries + 1})`);
+
+ const result = await APIAdapter.call(
+ apiConfig,
+ SYSTEM_PROMPT,
+ userPrompt,
+ );
+
+ if (!result) {
+ log.warn('API 返回空结果');
+ retries++;
+ continue;
+ }
+
+ const { json, narrative } = parseAnalysisOutput(result);
+
+ if (!json && retries < maxRetries) {
+ log.warn('JSON 解析失败,重试...');
+ retries++;
+ continue;
+ }
+
+ const validated = validateAnalysisResult(json, config);
+
+ return {
+ json: validated,
+ narrative: narrative || '',
+ raw: result,
+ };
+ } catch (e) {
+ log.warn(`分析请求失败 (尝试 ${retries + 1}):`, e.message);
+ retries++;
+ }
+ }
+
+ log.warn('后处理分析失败,已达到最大重试次数');
+ return null;
+}
diff --git a/src/rma/config-loader.js b/src/rma/config-loader.js
new file mode 100644
index 0000000..55500e9
--- /dev/null
+++ b/src/rma/config-loader.js
@@ -0,0 +1,140 @@
+/**
+ * RMA 配置加载器
+ * 从角色卡 extensions.cola_rma_config 读取 RMA 配置
+ * @module rma/config-loader
+ */
+
+import Logger from '@core/logger';
+import { getContext } from '@core/sillytavern-api';
+
+const log = Logger.createModuleLogger('RMA-ConfigLoader');
+
+let _cachedConfig = null;
+let _cachedCharId = null;
+
+/**
+ * 从当前角色卡加载 RMA 配置
+ * @returns {object|null} RMA 配置对象,无配置返回 null
+ */
+export function loadRmaConfigFromCharacter() {
+ try {
+ const context = getContext();
+ if (!context) return null;
+
+ const chid = context.characterId;
+ if (chid === undefined || chid < 0) return null;
+
+ // 使用缓存避免重复读取
+ if (_cachedConfig && _cachedCharId === chid) {
+ return _cachedConfig;
+ }
+
+ const charData = context.characters?.[chid]?.data;
+ if (!charData) return null;
+
+ const rmaConfig = charData.extensions?.cola_rma_config;
+ if (!rmaConfig) {
+ _cachedConfig = null;
+ _cachedCharId = chid;
+ return null;
+ }
+
+ log.log(`加载角色 RMA 配置: ${rmaConfig.character_name || '未知'} (v${rmaConfig.version || '?'})`);
+ _cachedConfig = rmaConfig;
+ _cachedCharId = chid;
+ return rmaConfig;
+ } catch (e) {
+ log.warn('加载 RMA 配置失败:', e);
+ return null;
+ }
+}
+
+/**
+ * 检测当前角色是否有 RMA 配置
+ * @returns {boolean}
+ */
+export function hasRmaConfig() {
+ return loadRmaConfigFromCharacter() !== null;
+}
+
+/**
+ * 获取缓存的 RMA 配置
+ * @returns {object|null}
+ */
+export function getCurrentRmaConfig() {
+ const context = getContext();
+ const chid = context?.characterId;
+
+ // 角色切换时清除缓存
+ if (chid !== _cachedCharId) {
+ _cachedConfig = null;
+ _cachedCharId = null;
+ }
+
+ if (!_cachedConfig) {
+ return loadRmaConfigFromCharacter();
+ }
+ return _cachedConfig;
+}
+
+/**
+ * 清除配置缓存(角色切换时调用)
+ */
+export function clearRmaConfigCache() {
+ _cachedConfig = null;
+ _cachedCharId = null;
+}
+
+/**
+ * 获取 RMA 配置中的阶段定义列表
+ * @param {object} config RMA 配置
+ * @returns {Array<{id: string, name: string, order: number}>}
+ */
+export function getPhaseDefinitions(config) {
+ return config?.phases?.definitions || [];
+}
+
+/**
+ * 获取 RMA 配置中的初始阶段
+ * @param {object} config RMA 配置
+ * @returns {string|null}
+ */
+export function getInitialPhase(config) {
+ return config?.phases?.initial_phase || null;
+}
+
+/**
+ * 获取 RMA 配置中的敏感点
+ * @param {object} config RMA 配置
+ * @returns {object}
+ */
+export function getSensitivities(config) {
+ return config?.sensitivities || {};
+}
+
+/**
+ * 获取 RMA 配置中的情感处理模式
+ * @param {object} config RMA 配置
+ * @returns {object}
+ */
+export function getEmotionalProcessing(config) {
+ return config?.emotional_processing || {};
+}
+
+/**
+ * 获取 RMA 配置中的秘密路径
+ * @param {object} config RMA 配置
+ * @returns {object}
+ */
+export function getSecrets(config) {
+ return config?.secrets || {};
+}
+
+/**
+ * 获取世界书条目映射配置
+ * @param {object} config RMA 配置
+ * @returns {object}
+ */
+export function getWorldbookEntryConfig(config) {
+ return config?.worldbook_entries || {};
+}
diff --git a/src/rma/confirmation-ui.js b/src/rma/confirmation-ui.js
new file mode 100644
index 0000000..1f0217d
--- /dev/null
+++ b/src/rma/confirmation-ui.js
@@ -0,0 +1,245 @@
+/**
+ * RMA 确认 UI
+ * 处理用户对分析结果的确认/编辑/拒绝
+ * @module rma/confirmation-ui
+ */
+
+import Logger from '@core/logger';
+import { getCurrentRmaConfig, getPhaseDefinitions } from './config-loader';
+import { addMemory, setPhase, setCurrentTexture, updateSecretStage, addThread, resolveThread, updateStoryState, saveRmaState, getPhase } from './memory-store';
+import { assessPhaseChange, executePhaseChange, updateTendency } from './phase-manager';
+import { switchPhaseEntry, rewriteTextureEntry, checkAndUnlockEntries } from './worldbook-sync';
+import { getRmaState } from './memory-store';
+import { clearPendingAnalysis } from './response-hook';
+
+const log = Logger.createModuleLogger('RMA-ConfirmationUI');
+
+let _onConfirmComplete = null;
+
+/**
+ * 设置确认完成回调
+ * @param {Function} fn
+ */
+export function setOnConfirmCompleteCallback(fn) {
+ _onConfirmComplete = fn;
+}
+
+/**
+ * 渲染待确认区域 HTML
+ * @param {object} analysisResult 分析结果 { json, narrative }
+ * @returns {string} HTML 字符串
+ */
+export function renderPendingConfirmation(analysisResult) {
+ if (!analysisResult?.json) {
+ return '
无分析结果
';
+ }
+
+ const { json, narrative } = analysisResult;
+ const config = getCurrentRmaConfig();
+ const parts = [];
+
+ // 新记忆
+ if (json.new_memories?.length > 0) {
+ const memHtml = json.new_memories.map((m, i) => {
+ const typeLabel = { breakthrough: '⭐ 破防', warmth: '💛 暖意', crack: '⚡ 裂痕', revelation: '👁 揭示' }[m.type] || m.type;
+ return `
+
${typeLabel}
+
${escapeHtml(m.event)}
+
+
+
+
+
`;
+ }).join('');
+ parts.push(``);
+ }
+
+ // 阶段变化
+ if (json.phase_assessment) {
+ const pa = json.phase_assessment;
+ const tendencyIcon = { warming: '🔥', stable: '➡️', cooling: '❄️' }[pa.tendency] || '➡️';
+ let phaseHtml = `${tendencyIcon} 趋势: ${pa.tendency || 'stable'}`;
+ if (pa.phase_changed && pa.new_phase) {
+ phaseHtml += `阶段变化: ${getPhase()} → ${pa.new_phase}
+
+
`;
+ }
+ parts.push(``);
+ }
+
+ // Narrative
+ if (narrative) {
+ parts.push(`
+
AI 将看到的情绪质感
+
${escapeHtml(narrative)}
+
`);
+ }
+
+ // 操作按钮
+ parts.push(`
+
+
+
`);
+
+ return parts.join('');
+}
+
+/**
+ * 绑定确认区域事件
+ * @param {HTMLElement} container 确认区域容器
+ * @param {object} analysisResult 分析结果
+ */
+export function bindConfirmationEvents(container, analysisResult) {
+ if (!container || !analysisResult) return;
+
+ // 删除记忆
+ container.querySelectorAll('.rma-delete-mem').forEach(btn => {
+ btn.addEventListener('click', () => {
+ const idx = parseInt(btn.dataset.index);
+ if (analysisResult.json?.new_memories) {
+ analysisResult.json.new_memories.splice(idx, 1);
+ const item = btn.closest('.rma-pending-item');
+ if (item) item.remove();
+ }
+ });
+ });
+
+ // 拒绝阶段变化
+ container.querySelector('.rma-reject-phase')?.addEventListener('click', () => {
+ if (analysisResult.json?.phase_assessment) {
+ analysisResult.json.phase_assessment.phase_changed = false;
+ analysisResult.json.phase_assessment.new_phase = null;
+ const el = container.querySelector('.rma-phase-change');
+ if (el) el.textContent = '(已拒绝阶段变化)';
+ }
+ });
+
+ // 确认按钮
+ container.querySelector('#rma-confirm-btn')?.addEventListener('click', async () => {
+ // 读取可能被编辑的 narrative
+ const narrativeEl = container.querySelector('.rma-narrative-preview');
+ const finalNarrative = narrativeEl?.textContent || analysisResult.narrative;
+ await applyConfirmedResult(analysisResult, finalNarrative);
+ });
+}
+
+/**
+ * 应用已确认的分析结果
+ * @param {object} analysisResult
+ * @param {string} finalNarrative 最终的 narrative 文本
+ */
+async function applyConfirmedResult(analysisResult, finalNarrative) {
+ const config = getCurrentRmaConfig();
+ if (!config) return;
+
+ const { json } = analysisResult;
+ const oldPhase = getPhase();
+
+ try {
+ // 1. 写入新记忆
+ let firstMemId = null;
+ if (json?.new_memories) {
+ for (const mem of json.new_memories) {
+ const id = addMemory(mem);
+ if (!firstMemId) firstMemId = id;
+ }
+ }
+
+ // 2. 处理阶段变化
+ if (json?.phase_assessment) {
+ updateTendency(json.phase_assessment.tendency);
+ if (json.phase_assessment.phase_changed && json.phase_assessment.new_phase) {
+ executePhaseChange(json.phase_assessment.new_phase, firstMemId);
+ await switchPhaseEntry(oldPhase, json.phase_assessment.new_phase);
+ }
+ }
+
+ // 3. 处理秘密更新
+ if (json?.secret_updates) {
+ for (const [id, update] of Object.entries(json.secret_updates)) {
+ if (update.stage_changed && update.new_stage) {
+ updateSecretStage(id, update.new_stage);
+ }
+ }
+ }
+
+ // 4. 处理未解决事项
+ if (json?.unresolved_threads) {
+ (json.unresolved_threads.added || []).forEach(t => addThread(t));
+ (json.unresolved_threads.resolved || []).forEach(t => resolveThread(t));
+ }
+
+ // 5. 处理故事状态
+ if (json?.story_state_updates) {
+ const updates = {};
+ for (const [key, value] of Object.entries(json.story_state_updates)) {
+ if (value !== undefined && value !== null) {
+ updates[key] = value;
+ }
+ }
+ if (Object.keys(updates).length > 0) {
+ updateStoryState(updates);
+ }
+ }
+
+ // 6. 更新质感
+ if (finalNarrative) {
+ setCurrentTexture(finalNarrative);
+ await rewriteTextureEntry(finalNarrative);
+ }
+
+ // 7. 检查事件解锁
+ const state = getRmaState();
+ if (state) {
+ await checkAndUnlockEntries(state, config);
+ }
+
+ // 8. 持久化
+ await saveRmaState();
+
+ // 清除待确认
+ clearPendingAnalysis();
+
+ log.log('确认完成,已写入所有更新');
+
+ // 通知 UI 刷新
+ if (_onConfirmComplete) {
+ _onConfirmComplete();
+ }
+ } catch (e) {
+ log.warn('应用确认结果失败:', e);
+ }
+}
+
+/**
+ * 根据确认模式判断是否需要用户确认
+ * @param {string} mode every_turn | important_only | auto
+ * @param {object} analysisJson
+ * @returns {boolean}
+ */
+export function needsConfirmation(mode, analysisJson) {
+ if (mode === 'every_turn') return true;
+ if (mode === 'auto') return false;
+
+ // important_only: 阶段变化、秘密进展、crack 需要确认
+ if (mode === 'important_only') {
+ if (analysisJson?.phase_assessment?.phase_changed) return true;
+ if (analysisJson?.secret_updates && Object.values(analysisJson.secret_updates).some(s => s.stage_changed)) return true;
+ if (analysisJson?.new_memories?.some(m => m.type === 'crack')) return true;
+ return false;
+ }
+
+ return true;
+}
+
+function escapeHtml(str) {
+ const div = document.createElement('div');
+ div.textContent = str || '';
+ return div.innerHTML;
+}
diff --git a/src/rma/float-panel.js b/src/rma/float-panel.js
new file mode 100644
index 0000000..5561ea7
--- /dev/null
+++ b/src/rma/float-panel.js
@@ -0,0 +1,373 @@
+/**
+ * RMA 悬浮面板
+ * 三态面板:展开 / 半折叠 / 最小化
+ * @module rma/float-panel
+ */
+
+import Logger from '@core/logger';
+import { getExtensionPath } from '@core/constants';
+import { getCurrentRmaConfig, getPhaseDefinitions, getSecrets } from './config-loader';
+import { getRmaState, getPhase, getRelevantMemories, getUnresolvedThreads, getStoryState, getCurrentTexture } from './memory-store';
+import { getPhaseIndex } from './phase-manager';
+import { setOnAnalysisCompleteCallback, getPendingAnalysis } from './response-hook';
+import { renderPendingConfirmation, bindConfirmationEvents, setOnConfirmCompleteCallback, needsConfirmation } from './confirmation-ui';
+import { renderTimeline, bindTimelineEvents } from './timeline-view';
+import { getRmaConfig } from './index';
+
+const log = Logger.createModuleLogger('RMA-FloatPanel');
+
+let _panelEl = null;
+let _state = 'minimized'; // expanded | half_collapsed | minimized
+let _timelineVisible = false;
+let _dragOffset = { x: 0, y: 0 };
+
+/**
+ * 初始化 RMA 悬浮面板
+ */
+export async function initRmaPanel() {
+ if (_panelEl) return;
+
+ // 加载 HTML 模板
+ try {
+ const basePath = getExtensionPath();
+ const response = await fetch(`${basePath}/ui/rma-panel.html`);
+ const html = await response.text();
+
+ const container = document.createElement('div');
+ container.innerHTML = html;
+ const panel = container.firstElementChild;
+ if (panel) {
+ document.body.appendChild(panel);
+ _panelEl = panel;
+ }
+ } catch (e) {
+ log.warn('加载 RMA 面板模板失败:', e);
+ createFallbackPanel();
+ }
+
+ if (!_panelEl) return;
+
+ // 设置初始状态
+ const config = getRmaConfig();
+ _state = config?.floatPanel?.defaultState || 'minimized';
+ applyState();
+
+ // 绑定事件
+ bindPanelEvents();
+
+ // 注册回调
+ setOnAnalysisCompleteCallback(onAnalysisComplete);
+ setOnConfirmCompleteCallback(onConfirmComplete);
+
+ log.log('RMA 悬浮面板初始化完成');
+}
+
+/**
+ * 创建 fallback 面板(模板加载失败时)
+ */
+function createFallbackPanel() {
+ const panel = document.createElement('div');
+ panel.id = 'rma-float-panel';
+ panel.className = 'rma-panel rma-panel-minimized';
+ panel.innerHTML = `
+ 🔮
+
+
+
+
+
+ `;
+ document.body.appendChild(panel);
+ _panelEl = panel;
+}
+
+/**
+ * 绑定面板事件
+ */
+function bindPanelEvents() {
+ // 头部点击切换展开/半折叠
+ _panelEl.querySelector('#rma-panel-header')?.addEventListener('click', (e) => {
+ if (e.target.closest('button')) return;
+ toggleExpandCollapse();
+ });
+
+ // 折叠按钮
+ _panelEl.querySelector('#rma-collapse-btn')?.addEventListener('click', () => {
+ _state = 'half_collapsed';
+ applyState();
+ });
+
+ // 最小化按钮
+ _panelEl.querySelector('#rma-minimize-btn')?.addEventListener('click', () => {
+ _state = 'minimized';
+ applyState();
+ });
+
+ // 最小化图标点击
+ _panelEl.querySelector('#rma-minimize-icon')?.addEventListener('click', () => {
+ _state = 'half_collapsed';
+ applyState();
+ });
+
+ // 时间线按钮
+ _panelEl.querySelector('#rma-timeline-btn')?.addEventListener('click', () => {
+ _timelineVisible = !_timelineVisible;
+ const container = _panelEl.querySelector('#rma-timeline-container');
+ if (container) {
+ if (_timelineVisible) {
+ container.innerHTML = renderTimeline();
+ container.style.display = 'block';
+ bindTimelineEvents(container);
+ } else {
+ container.style.display = 'none';
+ }
+ }
+ });
+
+ // 拖拽
+ enableDrag();
+}
+
+/**
+ * 切换展开/半折叠
+ */
+function toggleExpandCollapse() {
+ _state = _state === 'expanded' ? 'half_collapsed' : 'expanded';
+ applyState();
+}
+
+/**
+ * 应用面板状态
+ */
+function applyState() {
+ if (!_panelEl) return;
+
+ _panelEl.classList.remove('rma-panel-expanded', 'rma-panel-half', 'rma-panel-minimized');
+
+ switch (_state) {
+ case 'expanded':
+ _panelEl.classList.add('rma-panel-expanded');
+ updatePanelContent();
+ break;
+ case 'half_collapsed':
+ _panelEl.classList.add('rma-panel-half');
+ updateHalfContent();
+ break;
+ case 'minimized':
+ _panelEl.classList.add('rma-panel-minimized');
+ break;
+ }
+}
+
+/**
+ * 更新展开面板内容
+ */
+export function updatePanelContent() {
+ if (!_panelEl || _state !== 'expanded') return;
+
+ const config = getCurrentRmaConfig();
+ const state = getRmaState();
+ if (!config || !state) return;
+
+ // 阶段指示器
+ const phaseDefs = getPhaseDefinitions(config);
+ const currentPhase = getPhase();
+ const phaseIdx = getPhaseIndex(currentPhase, config);
+ const phaseHtml = phaseDefs.map((p, i) => {
+ const name = p.name || p.id;
+ const active = name === currentPhase;
+ const past = i < phaseIdx;
+ return `${name}`;
+ }).join(' → ');
+ setInner('#rma-phase-section', `${phaseHtml}
`);
+
+ // 当前感受
+ const texture = getCurrentTexture();
+ setInner('#rma-feeling-section', texture
+ ? `${escapeHtml(texture.slice(0, 100))}${texture.length > 100 ? '...' : ''}
`
+ : '');
+
+ // 秘密
+ const secrets = getSecrets(config);
+ if (Object.keys(secrets).length > 0) {
+ const secretHtml = Object.entries(secrets).map(([id, s]) => {
+ const currentStage = state.secrets?.[id]?.current_stage || s.initial_stage;
+ const stages = s.stages || [];
+ const dots = stages.map(st => st.id === currentStage ? '●' : '○').join('');
+ return `🔮 ${escapeHtml(s.description?.slice(0, 30) || id)} ${dots}
`;
+ }).join('');
+ setInner('#rma-secrets-section', `秘密
${secretHtml}`);
+ }
+
+ // 未解决事项
+ const threads = getUnresolvedThreads();
+ if (threads.length > 0) {
+ setInner('#rma-threads-section', `悬念
${threads.map(t => `⚡ ${escapeHtml(t)}
`).join('')}`);
+ }
+
+ // 故事状态
+ const storyState = getStoryState();
+ if (Object.keys(storyState).length > 0) {
+ const parts = [];
+ if (storyState.location) parts.push(`📍 ${storyState.location}`);
+ if (storyState.time_of_day) parts.push(`🕐 ${storyState.time_of_day}`);
+ if (storyState.day_count) parts.push(`📅 第${storyState.day_count}天`);
+ setInner('#rma-story-section', parts.join(' | '));
+ }
+
+ // 最近记忆
+ const memories = getRelevantMemories().slice(-3);
+ if (memories.length > 0) {
+ const memHtml = memories.map(m => {
+ const icon = { breakthrough: '⭐', warmth: '💛', crack: '⚡', revelation: '👁' }[m.type] || '📝';
+ return `${icon} ${escapeHtml(m.event?.slice(0, 50) || '')}
`;
+ }).join('');
+ setInner('#rma-memories-section', `最近记忆
${memHtml}`);
+ }
+
+ // 待确认
+ const pending = getPendingAnalysis();
+ const pendingSection = _panelEl.querySelector('#rma-pending-section');
+ if (pending && pendingSection) {
+ pendingSection.innerHTML = renderPendingConfirmation(pending);
+ bindConfirmationEvents(pendingSection, pending);
+ } else if (pendingSection) {
+ pendingSection.innerHTML = '';
+ }
+}
+
+/**
+ * 更新半折叠摘要
+ */
+function updateHalfContent() {
+ if (!_panelEl) return;
+
+ const state = getRmaState();
+ const halfEl = _panelEl.querySelector('#rma-panel-half');
+ if (!halfEl || !state) return;
+
+ const parts = [];
+ parts.push(`💭 ${state.phase?.current || '?'}`);
+
+ const storyState = getStoryState();
+ if (storyState.location) parts.push(`📍 ${storyState.location}`);
+ if (storyState.day_count) parts.push(`📅 第${storyState.day_count}天`);
+
+ const pending = getPendingAnalysis();
+ if (pending) parts.push('🔔 待确认');
+
+ halfEl.textContent = parts.join(' | ');
+}
+
+/**
+ * 分析完成回调
+ */
+function onAnalysisComplete(result) {
+ if (!result) {
+ // null = 仅刷新
+ if (_state === 'expanded') updatePanelContent();
+ else updateHalfContent();
+ return;
+ }
+
+ const config = getRmaConfig();
+ const mode = config?.confirmationMode || 'every_turn';
+
+ if (needsConfirmation(mode, result.json)) {
+ // 需要确认:展开面板
+ _state = 'expanded';
+ applyState();
+ } else {
+ // 自动确认
+ import('./confirmation-ui').then(mod => {
+ mod.applyConfirmedResult?.(result, result.narrative);
+ }).catch(() => {});
+ }
+}
+
+/**
+ * 确认完成回调
+ */
+function onConfirmComplete() {
+ updatePanelContent();
+ updateHalfContent();
+}
+
+/**
+ * 显示/隐藏面板
+ */
+export function showRmaPanel() {
+ if (_panelEl) {
+ _panelEl.style.display = '';
+ if (_state === 'minimized') {
+ _state = 'half_collapsed';
+ }
+ applyState();
+ }
+}
+
+export function hideRmaPanel() {
+ if (_panelEl) {
+ _panelEl.style.display = 'none';
+ }
+}
+
+/**
+ * 拖拽支持
+ */
+function enableDrag() {
+ const header = _panelEl?.querySelector('#rma-panel-header');
+ if (!header) return;
+
+ let isDragging = false;
+
+ header.addEventListener('mousedown', (e) => {
+ if (e.target.closest('button')) return;
+ isDragging = true;
+ _dragOffset.x = e.clientX - _panelEl.offsetLeft;
+ _dragOffset.y = e.clientY - _panelEl.offsetTop;
+ _panelEl.style.transition = 'none';
+ });
+
+ document.addEventListener('mousemove', (e) => {
+ if (!isDragging) return;
+ _panelEl.style.left = (e.clientX - _dragOffset.x) + 'px';
+ _panelEl.style.top = (e.clientY - _dragOffset.y) + 'px';
+ _panelEl.style.right = 'auto';
+ _panelEl.style.bottom = 'auto';
+ });
+
+ document.addEventListener('mouseup', () => {
+ if (isDragging) {
+ isDragging = false;
+ _panelEl.style.transition = '';
+ }
+ });
+}
+
+function setInner(selector, html) {
+ const el = _panelEl?.querySelector(selector);
+ if (el) el.innerHTML = html;
+}
+
+function escapeHtml(str) {
+ const div = document.createElement('div');
+ div.textContent = str || '';
+ return div.innerHTML;
+}
diff --git a/src/rma/index.js b/src/rma/index.js
new file mode 100644
index 0000000..a7e1eaf
--- /dev/null
+++ b/src/rma/index.js
@@ -0,0 +1,154 @@
+/**
+ * RMA 关系记忆系统 - 模块入口
+ * @module rma
+ */
+
+import { loadConfig, saveConfig } from '@config/config-manager';
+
+// 配置加载
+export {
+ loadRmaConfigFromCharacter,
+ hasRmaConfig,
+ getCurrentRmaConfig,
+ clearRmaConfigCache,
+ getPhaseDefinitions,
+ getInitialPhase,
+ getSensitivities,
+ getEmotionalProcessing,
+ getSecrets,
+ getWorldbookEntryConfig,
+} from './config-loader';
+
+// 记忆存储
+export {
+ initRmaState,
+ getRmaState,
+ saveRmaState,
+ addMemory,
+ updateMemory,
+ deleteMemory,
+ invalidateMemoriesByMessage,
+ getPhase,
+ setPhase,
+ setPhaseTendency,
+ getSecretState,
+ updateSecretStage,
+ getUnresolvedThreads,
+ addThread,
+ resolveThread,
+ getStoryState,
+ updateStoryState,
+ setCurrentTexture,
+ getCurrentTexture,
+ getRelevantMemories,
+} from './memory-store';
+
+// 分析引擎
+export { analyzeResponse } from './analyzer';
+
+// 事件监听
+export {
+ registerRmaEventListeners,
+ setOnAnalysisCompleteCallback,
+ getPendingAnalysis,
+ clearPendingAnalysis,
+} from './response-hook';
+
+// 阶段管理
+export {
+ assessPhaseChange,
+ executePhaseChange,
+ updateTendency,
+ getPhaseIndex,
+} from './phase-manager';
+
+// 世界书同步
+export {
+ findRmaEntries,
+ findRmaWorldBook,
+ switchPhaseEntry,
+ rewriteTextureEntry,
+ checkAndUnlockEntries,
+} from './worldbook-sync';
+
+// 确认 UI
+export {
+ renderPendingConfirmation,
+ bindConfirmationEvents,
+ setOnConfirmCompleteCallback,
+ needsConfirmation,
+} from './confirmation-ui';
+
+// 悬浮面板
+export {
+ initRmaPanel,
+ showRmaPanel,
+ hideRmaPanel,
+ updatePanelContent,
+} from './float-panel';
+
+// 时间线
+export {
+ renderTimeline,
+ bindTimelineEvents,
+} from './timeline-view';
+
+// ==================== 插件配置存取 ====================
+
+/**
+ * 获取 RMA 插件配置
+ * @returns {object}
+ */
+export function getRmaConfig() {
+ const config = loadConfig();
+ return config?.global?.rmaConfig || {};
+}
+
+/**
+ * 更新 RMA 插件配置
+ * @param {object} updates
+ */
+export function updateRmaConfig(updates) {
+ const config = loadConfig();
+ if (!config.global.rmaConfig) {
+ config.global.rmaConfig = {};
+ }
+ Object.assign(config.global.rmaConfig, updates);
+ saveConfig(config);
+}
+
+/**
+ * RMA 是否启用
+ * @returns {boolean}
+ */
+export function isRmaEnabled() {
+ return getRmaConfig()?.enabled === true;
+}
+
+/**
+ * 设置 RMA 启用状态
+ * @param {boolean} enabled
+ */
+export function setRmaEnabled(enabled) {
+ updateRmaConfig({ enabled });
+}
+
+/**
+ * 获取 RMA 分析 API 配置
+ * @returns {object}
+ */
+export function getRmaAnalysisApiConfig() {
+ return getRmaConfig()?.analysisApi || {};
+}
+
+/**
+ * 更新 RMA 分析 API 配置
+ * @param {object} updates
+ */
+export function updateRmaAnalysisApiConfig(updates) {
+ const config = loadConfig();
+ if (!config.global.rmaConfig) config.global.rmaConfig = {};
+ if (!config.global.rmaConfig.analysisApi) config.global.rmaConfig.analysisApi = {};
+ Object.assign(config.global.rmaConfig.analysisApi, updates);
+ saveConfig(config);
+}
diff --git a/src/rma/memory-store.js b/src/rma/memory-store.js
new file mode 100644
index 0000000..3c3a922
--- /dev/null
+++ b/src/rma/memory-store.js
@@ -0,0 +1,371 @@
+/**
+ * RMA 记忆存储管理
+ * 使用 chat_metadata.cola_rma 持久化 RMA 状态
+ * @module rma/memory-store
+ */
+
+import Logger from '@core/logger';
+import { getContext } from '@core/sillytavern-api';
+import { getInitialPhase, getSecrets } from './config-loader';
+
+const log = Logger.createModuleLogger('RMA-MemoryStore');
+
+/**
+ * 生成唯一记忆 ID
+ */
+function generateMemoryId() {
+ return 'mem_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6);
+}
+
+/**
+ * 获取 chat_metadata 引用
+ * @returns {object|null}
+ */
+function getChatMetadata() {
+ const context = getContext();
+ return context?.chat_metadata || (typeof window !== 'undefined' ? window.chat_metadata : null);
+}
+
+/**
+ * 初始化 RMA 状态结构
+ * @param {object} rmaConfig 角色卡中的 RMA 配置
+ */
+export function initRmaState(rmaConfig) {
+ const meta = getChatMetadata();
+ if (!meta) {
+ log.warn('chat_metadata 不可用');
+ return;
+ }
+
+ if (meta.cola_rma) {
+ log.log('RMA 状态已存在,跳过初始化');
+ return;
+ }
+
+ const initialPhase = getInitialPhase(rmaConfig);
+ const secrets = getSecrets(rmaConfig);
+
+ // 初始化秘密状态
+ const secretStates = {};
+ for (const [id, secret] of Object.entries(secrets)) {
+ secretStates[id] = {
+ current_stage: secret.initial_stage || secret.stages?.[0]?.id || 'unknown',
+ };
+ }
+
+ // 初始化故事状态
+ const storyState = {};
+ if (rmaConfig.story_state) {
+ for (const [id, field] of Object.entries(rmaConfig.story_state)) {
+ storyState[id] = field.initial;
+ }
+ }
+
+ meta.cola_rma = {
+ config_id: rmaConfig.config_id || '',
+ last_updated: new Date().toISOString(),
+ memories: [],
+ phase: {
+ current: initialPhase,
+ tendency: 'stable',
+ history: [],
+ },
+ secrets: secretStates,
+ story_state: storyState,
+ unresolved_threads: [],
+ current_texture: rmaConfig.initial_texture || '',
+ };
+
+ log.log(`RMA 状态初始化完成: 阶段=${initialPhase}`);
+ saveRmaState();
+}
+
+/**
+ * 获取当前 RMA 状态
+ * @returns {object|null}
+ */
+export function getRmaState() {
+ const meta = getChatMetadata();
+ return meta?.cola_rma || null;
+}
+
+/**
+ * 持久化 RMA 状态
+ */
+export async function saveRmaState() {
+ try {
+ const state = getRmaState();
+ if (state) {
+ state.last_updated = new Date().toISOString();
+ }
+ const context = getContext();
+ if (context?.saveChat) {
+ await context.saveChat();
+ }
+ } catch (e) {
+ log.warn('保存 RMA 状态失败:', e);
+ }
+}
+
+// ==================== 记忆操作 ====================
+
+/**
+ * 添加记忆
+ * @param {object} memory 记忆数据
+ * @returns {string} 记忆 ID
+ */
+export function addMemory(memory) {
+ const state = getRmaState();
+ if (!state) return null;
+
+ const id = generateMemoryId();
+ state.memories.push({
+ id,
+ type: memory.type || 'warmth',
+ day: memory.day || state.story_state?.day_count || 1,
+ time: memory.time || state.story_state?.time_of_day || '',
+ message_id: memory.message_id || null,
+ event: memory.event || '',
+ emotional_impact: memory.emotional_impact || '',
+ character_reaction: memory.character_reaction || '',
+ lasting_effect: memory.lasting_effect || '',
+ tags: memory.tags || [],
+ significance: memory.significance || 'medium',
+ invalidated: false,
+ });
+
+ return id;
+}
+
+/**
+ * 更新记忆
+ * @param {string} id 记忆 ID
+ * @param {object} updates 更新字段
+ */
+export function updateMemory(id, updates) {
+ const state = getRmaState();
+ if (!state) return;
+
+ const mem = state.memories.find(m => m.id === id);
+ if (mem) {
+ Object.assign(mem, updates);
+ }
+}
+
+/**
+ * 删除记忆
+ * @param {string} id 记忆 ID
+ */
+export function deleteMemory(id) {
+ const state = getRmaState();
+ if (!state) return;
+
+ state.memories = state.memories.filter(m => m.id !== id);
+}
+
+/**
+ * 标记与指定消息关联的记忆为无效
+ * @param {number} messageId 消息 ID
+ */
+export function invalidateMemoriesByMessage(messageId) {
+ const state = getRmaState();
+ if (!state) return;
+
+ let count = 0;
+ for (const mem of state.memories) {
+ if (mem.message_id === messageId && !mem.invalidated) {
+ mem.invalidated = true;
+ count++;
+ }
+ }
+ if (count > 0) {
+ log.log(`已标记 ${count} 条记忆为无效 (message_id=${messageId})`);
+ }
+}
+
+// ==================== 阶段操作 ====================
+
+/**
+ * 获取当前阶段
+ * @returns {string|null}
+ */
+export function getPhase() {
+ return getRmaState()?.phase?.current || null;
+}
+
+/**
+ * 设置阶段
+ * @param {string} newPhase 新阶段名
+ * @param {string} triggerMemoryId 触发该变化的记忆 ID
+ */
+export function setPhase(newPhase, triggerMemoryId = null) {
+ const state = getRmaState();
+ if (!state) return;
+
+ const oldPhase = state.phase.current;
+ if (oldPhase === newPhase) return;
+
+ state.phase.history.push({
+ from: oldPhase,
+ to: newPhase,
+ day: state.story_state?.day_count || 1,
+ trigger_memory: triggerMemoryId,
+ });
+ state.phase.current = newPhase;
+
+ log.log(`阶段切换: ${oldPhase} → ${newPhase}`);
+}
+
+/**
+ * 更新阶段趋势
+ * @param {string} tendency warming | stable | cooling
+ */
+export function setPhaseTendency(tendency) {
+ const state = getRmaState();
+ if (state?.phase) {
+ state.phase.tendency = tendency;
+ }
+}
+
+// ==================== 秘密操作 ====================
+
+/**
+ * 获取秘密状态
+ * @param {string} secretId
+ * @returns {object|null}
+ */
+export function getSecretState(secretId) {
+ return getRmaState()?.secrets?.[secretId] || null;
+}
+
+/**
+ * 更新秘密阶段
+ * @param {string} secretId
+ * @param {string} newStage
+ */
+export function updateSecretStage(secretId, newStage) {
+ const state = getRmaState();
+ if (!state?.secrets) return;
+
+ if (!state.secrets[secretId]) {
+ state.secrets[secretId] = {};
+ }
+ state.secrets[secretId].current_stage = newStage;
+ log.log(`秘密更新: ${secretId} → ${newStage}`);
+}
+
+// ==================== 线索/未解决事项 ====================
+
+/**
+ * 获取未解决事项列表
+ * @returns {Array}
+ */
+export function getUnresolvedThreads() {
+ return getRmaState()?.unresolved_threads || [];
+}
+
+/**
+ * 添加未解决事项
+ * @param {string} text
+ */
+export function addThread(text) {
+ const state = getRmaState();
+ if (!state) return;
+ if (!state.unresolved_threads.includes(text)) {
+ state.unresolved_threads.push(text);
+ }
+}
+
+/**
+ * 解决事项
+ * @param {string} text
+ */
+export function resolveThread(text) {
+ const state = getRmaState();
+ if (!state) return;
+ state.unresolved_threads = state.unresolved_threads.filter(t => t !== text);
+}
+
+// ==================== 故事状态 ====================
+
+/**
+ * 获取故事状态
+ * @returns {object}
+ */
+export function getStoryState() {
+ return getRmaState()?.story_state || {};
+}
+
+/**
+ * 更新故事状态
+ * @param {object} updates
+ */
+export function updateStoryState(updates) {
+ const state = getRmaState();
+ if (!state) return;
+ Object.assign(state.story_state, updates);
+}
+
+// ==================== 质感 ====================
+
+/**
+ * 设置当前情绪质感文本
+ * @param {string} text
+ */
+export function setCurrentTexture(text) {
+ const state = getRmaState();
+ if (state) {
+ state.current_texture = text;
+ }
+}
+
+/**
+ * 获取当前情绪质感文本
+ * @returns {string}
+ */
+export function getCurrentTexture() {
+ return getRmaState()?.current_texture || '';
+}
+
+// ==================== 记忆检索 ====================
+
+/**
+ * 按检索策略获取相关记忆
+ * 规则:最近 3-5 条 + 所有未解决 crack + top 2 breakthrough + 话题相关
+ * @param {string} currentDialogue 当前对话文本(用于关键词匹配)
+ * @returns {Array}
+ */
+export function getRelevantMemories(currentDialogue = '') {
+ const state = getRmaState();
+ if (!state?.memories?.length) return [];
+
+ const validMemories = state.memories.filter(m => !m.invalidated);
+ const selected = new Set();
+
+ // 1. 最近 5 条
+ const recent = validMemories.slice(-5);
+ recent.forEach(m => selected.add(m.id));
+
+ // 2. 所有未解决 crack
+ validMemories
+ .filter(m => m.type === 'crack' && m.lasting_effect && !m.resolved)
+ .forEach(m => selected.add(m.id));
+
+ // 3. Top 2 breakthrough(按时间降序取最近的)
+ validMemories
+ .filter(m => m.type === 'breakthrough')
+ .slice(-2)
+ .forEach(m => selected.add(m.id));
+
+ // 4. 话题相关(简单关键词匹配)
+ if (currentDialogue) {
+ const dialogueWords = currentDialogue.toLowerCase();
+ validMemories.forEach(m => {
+ if (m.tags?.some(tag => dialogueWords.includes(tag.toLowerCase()))) {
+ selected.add(m.id);
+ }
+ });
+ }
+
+ return validMemories.filter(m => selected.has(m.id));
+}
diff --git a/src/rma/phase-manager.js b/src/rma/phase-manager.js
new file mode 100644
index 0000000..78dca3d
--- /dev/null
+++ b/src/rma/phase-manager.js
@@ -0,0 +1,83 @@
+/**
+ * RMA 阶段管理器
+ * 阶段切换判断和执行
+ * @module rma/phase-manager
+ */
+
+import Logger from '@core/logger';
+import { getPhaseDefinitions } from './config-loader';
+import { getPhase, setPhase, setPhaseTendency } from './memory-store';
+
+const log = Logger.createModuleLogger('RMA-PhaseManager');
+
+/**
+ * 评估分析结果中的阶段变化
+ * @param {object} analysisJson AI 分析 JSON 结果
+ * @param {object} config RMA 配置
+ * @returns {{ shouldChange: boolean, newPhase: string|null, tendency: string }}
+ */
+export function assessPhaseChange(analysisJson, config) {
+ const assessment = analysisJson?.phase_assessment;
+ if (!assessment) {
+ return { shouldChange: false, newPhase: null, tendency: 'stable' };
+ }
+
+ const phaseDefs = getPhaseDefinitions(config);
+ const phaseNames = phaseDefs.map(p => p.name || p.id);
+ const currentPhase = getPhase();
+
+ // 更新趋势
+ const tendency = assessment.tendency || 'stable';
+
+ // 检查是否建议阶段变化
+ if (!assessment.phase_changed || !assessment.new_phase) {
+ return { shouldChange: false, newPhase: null, tendency };
+ }
+
+ const newPhase = assessment.new_phase;
+
+ // 验证阶段名合法
+ if (!phaseNames.includes(newPhase)) {
+ log.warn(`非法阶段名: ${newPhase}`);
+ return { shouldChange: false, newPhase: null, tendency };
+ }
+
+ // 不重复切换
+ if (newPhase === currentPhase) {
+ return { shouldChange: false, newPhase: null, tendency };
+ }
+
+ return { shouldChange: true, newPhase, tendency };
+}
+
+/**
+ * 执行阶段切换
+ * @param {string} newPhase 新阶段名
+ * @param {string} triggerMemoryId 触发记忆 ID
+ */
+export function executePhaseChange(newPhase, triggerMemoryId = null) {
+ const oldPhase = getPhase();
+ setPhase(newPhase, triggerMemoryId);
+ log.log(`阶段已切换: ${oldPhase} → ${newPhase}`);
+}
+
+/**
+ * 更新阶段趋势
+ * @param {string} tendency
+ */
+export function updateTendency(tendency) {
+ if (['warming', 'stable', 'cooling'].includes(tendency)) {
+ setPhaseTendency(tendency);
+ }
+}
+
+/**
+ * 获取阶段在定义中的序号(用于 UI 进度条)
+ * @param {string} phaseName
+ * @param {object} config
+ * @returns {number} 0-based index, -1 if not found
+ */
+export function getPhaseIndex(phaseName, config) {
+ const defs = getPhaseDefinitions(config);
+ return defs.findIndex(p => (p.name || p.id) === phaseName);
+}
diff --git a/src/rma/response-hook.js b/src/rma/response-hook.js
new file mode 100644
index 0000000..7e6f0d9
--- /dev/null
+++ b/src/rma/response-hook.js
@@ -0,0 +1,138 @@
+/**
+ * RMA 事件监听 — 后处理拦截
+ * 监听 MESSAGE_RECEIVED 和 MESSAGE_DELETED 事件
+ * @module rma/response-hook
+ */
+
+import Logger from '@core/logger';
+import { getContext, getEventSource, getEventTypes, getCurrentChat } from '@core/sillytavern-api';
+import { isRmaEnabled } from './index';
+import { hasRmaConfig } from './config-loader';
+import { getRmaState, invalidateMemoriesByMessage, saveRmaState } from './memory-store';
+import { analyzeResponse } from './analyzer';
+
+const log = Logger.createModuleLogger('RMA-ResponseHook');
+
+let _pendingAnalysis = null;
+let _onAnalysisComplete = null;
+
+/**
+ * 设置分析完成回调(由 float-panel 注入)
+ * @param {Function} fn
+ */
+export function setOnAnalysisCompleteCallback(fn) {
+ _onAnalysisComplete = fn;
+}
+
+/**
+ * 获取待确认的分析结果
+ * @returns {object|null}
+ */
+export function getPendingAnalysis() {
+ return _pendingAnalysis;
+}
+
+/**
+ * 清除待确认的分析结果
+ */
+export function clearPendingAnalysis() {
+ _pendingAnalysis = null;
+}
+
+/**
+ * 注册 RMA 事件监听器
+ */
+export function registerRmaEventListeners() {
+ const eventSource = getEventSource();
+ const eventTypes = getEventTypes();
+
+ if (!eventSource) {
+ log.warn('事件系统不可用');
+ return;
+ }
+
+ // 监听 AI 回复完成事件
+ const messageEvent = eventTypes.CHARACTER_MESSAGE_RENDERED || eventTypes.MESSAGE_RECEIVED;
+ if (messageEvent) {
+ eventSource.on(messageEvent, handleMessageReceived);
+ log.log(`已注册 ${messageEvent === eventTypes.CHARACTER_MESSAGE_RENDERED ? 'CHARACTER_MESSAGE_RENDERED' : 'MESSAGE_RECEIVED'} 监听`);
+ }
+
+ // 监听消息删除事件(回滚同步)
+ if (eventTypes.MESSAGE_DELETED) {
+ eventSource.on(eventTypes.MESSAGE_DELETED, handleMessageDeleted);
+ log.log('已注册 MESSAGE_DELETED 监听');
+ }
+}
+
+/**
+ * AI 回复完成后的处理
+ */
+async function handleMessageReceived(messageId) {
+ // 检查条件
+ if (!isRmaEnabled()) return;
+ if (!hasRmaConfig()) return;
+
+ const state = getRmaState();
+ if (!state) return;
+
+ try {
+ const chat = getCurrentChat();
+ if (!chat || chat.length === 0) return;
+
+ // 获取最新 AI 回复
+ const latestMsg = chat[chat.length - 1];
+ if (!latestMsg || latestMsg.is_user) return;
+
+ const latestResponse = latestMsg.mes || '';
+ if (!latestResponse.trim()) return;
+
+ // 获取最近消息(最多 10 条)
+ const recentMessages = chat.slice(-10);
+
+ log.log('触发后处理分析...');
+ const result = await analyzeResponse(recentMessages, latestResponse);
+
+ if (result) {
+ // 附加消息 ID
+ if (result.json?.new_memories) {
+ const msgIndex = chat.length - 1;
+ result.json.new_memories.forEach(m => {
+ m.message_id = msgIndex;
+ });
+ }
+
+ _pendingAnalysis = result;
+ log.log('分析完成,等待用户确认');
+
+ // 通知 UI
+ if (_onAnalysisComplete) {
+ _onAnalysisComplete(result);
+ }
+ }
+ } catch (e) {
+ log.warn('后处理分析出错:', e);
+ }
+}
+
+/**
+ * 消息删除时的回滚同步
+ */
+async function handleMessageDeleted(messageId) {
+ if (!isRmaEnabled()) return;
+ if (!hasRmaConfig()) return;
+
+ const state = getRmaState();
+ if (!state) return;
+
+ const numId = typeof messageId === 'number' ? messageId : parseInt(messageId);
+ if (isNaN(numId)) return;
+
+ invalidateMemoriesByMessage(numId);
+ await saveRmaState();
+
+ // 通知 UI 刷新
+ if (_onAnalysisComplete) {
+ _onAnalysisComplete(null); // null 表示仅刷新面板
+ }
+}
diff --git a/src/rma/timeline-view.js b/src/rma/timeline-view.js
new file mode 100644
index 0000000..d932428
--- /dev/null
+++ b/src/rma/timeline-view.js
@@ -0,0 +1,88 @@
+/**
+ * RMA 记忆时间线视图
+ * 按天分组展示记忆,支持类型图标
+ * @module rma/timeline-view
+ */
+
+import { getRmaState } from './memory-store';
+
+const TYPE_ICONS = {
+ breakthrough: '⭐',
+ warmth: '💛',
+ crack: '⚡',
+ revelation: '👁',
+};
+
+const TYPE_LABELS = {
+ breakthrough: '破防',
+ warmth: '暖意',
+ crack: '裂痕',
+ revelation: '揭示',
+};
+
+/**
+ * 渲染完整时间线 HTML
+ * @returns {string} HTML 字符串
+ */
+export function renderTimeline() {
+ const state = getRmaState();
+ if (!state?.memories?.length) {
+ return '暂无记忆
';
+ }
+
+ // 按天分组
+ const groups = {};
+ for (const mem of state.memories) {
+ const day = mem.day || '?';
+ if (!groups[day]) groups[day] = [];
+ groups[day].push(mem);
+ }
+
+ // 倒序排列(最近的在前)
+ const days = Object.keys(groups).sort((a, b) => Number(b) - Number(a));
+
+ const html = days.map(day => {
+ const memories = groups[day];
+ const memHtml = memories.map(m => {
+ const icon = TYPE_ICONS[m.type] || '📝';
+ const label = TYPE_LABELS[m.type] || m.type;
+ const invalidClass = m.invalidated ? ' rma-mem-invalid' : '';
+ return `
+
${icon}
+
+
[${label}]
+
${escapeHtml(m.event)}
+ ${m.emotional_impact ? `
${escapeHtml(m.emotional_impact)}
` : ''}
+ ${m.invalidated ? '
⚠️ 关联消息已删除
' : ''}
+
+
`;
+ }).join('');
+
+ return `
+
第 ${day} 天
+ ${memHtml}
+
`;
+ }).join('');
+
+ return `${html}
`;
+}
+
+/**
+ * 绑定时间线事件(点击展开详情)
+ * @param {HTMLElement} container
+ */
+export function bindTimelineEvents(container) {
+ if (!container) return;
+
+ container.querySelectorAll('.rma-timeline-item').forEach(item => {
+ item.addEventListener('click', () => {
+ item.classList.toggle('rma-timeline-expanded');
+ });
+ });
+}
+
+function escapeHtml(str) {
+ const div = document.createElement('div');
+ div.textContent = str || '';
+ return div.innerHTML;
+}
diff --git a/src/rma/worldbook-sync.js b/src/rma/worldbook-sync.js
new file mode 100644
index 0000000..b8596ac
--- /dev/null
+++ b/src/rma/worldbook-sync.js
@@ -0,0 +1,270 @@
+/**
+ * RMA 世界书同步
+ * 切换阶段条目(互斥)、改写质感条目、检查事件解锁
+ * @module rma/worldbook-sync
+ */
+
+import Logger from '@core/logger';
+import { getContext, loadWorldInfo } from '@core/sillytavern-api';
+import { getSecretState } from './memory-store';
+
+const log = Logger.createModuleLogger('RMA-WorldbookSync');
+
+/**
+ * 获取请求头(含 CSRF)
+ */
+function getRequestHeaders() {
+ const context = getContext();
+ if (context && typeof context.getRequestHeaders === 'function') {
+ return context.getRequestHeaders();
+ }
+ return { 'Content-Type': 'application/json' };
+}
+
+/**
+ * 保存世界书(三级 fallback)
+ */
+async function saveWorldBook(bookName, bookData) {
+ try {
+ const context = getContext();
+
+ // 方法 1: context API
+ if (context?.saveWorldInfo) {
+ await context.saveWorldInfo(bookName, bookData, true);
+ return true;
+ }
+
+ // 方法 2: 全局函数
+ if (typeof saveWorldInfo === 'function') {
+ await saveWorldInfo(bookName, bookData, true);
+ return true;
+ }
+
+ // 方法 3: REST API
+ const response = await fetch('/api/worldinfo/edit', {
+ method: 'POST',
+ headers: getRequestHeaders(),
+ body: JSON.stringify({ name: bookName, data: bookData }),
+ });
+ return response.ok;
+ } catch (e) {
+ log.warn(`保存世界书 "${bookName}" 失败:`, e);
+ return false;
+ }
+}
+
+/**
+ * 在世界书中查找 RMA 相关条目
+ * @param {object} bookData 世界书数据
+ * @returns {{ phaseEntries: object, textureEntry: object, eventEntries: object }}
+ */
+export function findRmaEntries(bookData) {
+ const phaseEntries = {}; // { phaseName: { uid, entry } }
+ const eventEntries = {}; // { comment: { uid, entry } }
+ let textureEntry = null; // { uid, entry }
+
+ if (!bookData?.entries) return { phaseEntries, textureEntry, eventEntries };
+
+ for (const [uid, entry] of Object.entries(bookData.entries)) {
+ const comment = entry.comment || '';
+
+ // [RMA:Phase:xxx]
+ const phaseMatch = comment.match(/\[RMA:Phase:(.+?)\]/);
+ if (phaseMatch) {
+ phaseEntries[phaseMatch[1]] = { uid, entry };
+ continue;
+ }
+
+ // [RMA:Texture]
+ if (comment.includes('[RMA:Texture]')) {
+ textureEntry = { uid, entry };
+ continue;
+ }
+
+ // [RMA:Secret:xxx] 或 [RMA:Event:xxx]
+ if (comment.match(/\[RMA:(Secret|Event):.+?\]/)) {
+ eventEntries[comment] = { uid, entry };
+ }
+ }
+
+ return { phaseEntries, textureEntry, eventEntries };
+}
+
+/**
+ * 在角色关联的所有世界书中查找 RMA 条目所在的世界书
+ * @returns {Promise<{bookName: string, bookData: object, rmaEntries: object}|null>}
+ */
+export async function findRmaWorldBook() {
+ try {
+ const context = getContext();
+ if (!context) return null;
+
+ // 获取角色绑定的世界书
+ const charBook = context.characters?.[context.characterId]?.data?.extensions?.world;
+ const worldNames = [];
+
+ if (charBook) worldNames.push(charBook);
+
+ // 也检查全局启用的世界书
+ const globalBooks = context.world_names || [];
+ for (const name of globalBooks) {
+ if (!worldNames.includes(name)) worldNames.push(name);
+ }
+
+ for (const bookName of worldNames) {
+ const bookData = await loadWorldInfo(bookName);
+ if (!bookData) continue;
+
+ const rmaEntries = findRmaEntries(bookData);
+ const hasRma = Object.keys(rmaEntries.phaseEntries).length > 0 || rmaEntries.textureEntry;
+
+ if (hasRma) {
+ log.log(`找到 RMA 世界书: "${bookName}"`);
+ return { bookName, bookData, rmaEntries };
+ }
+ }
+
+ return null;
+ } catch (e) {
+ log.warn('查找 RMA 世界书失败:', e);
+ return null;
+ }
+}
+
+/**
+ * 切换阶段条目(互斥:禁用旧阶段,启用新阶段)
+ * @param {string} oldPhase 旧阶段名
+ * @param {string} newPhase 新阶段名
+ * @returns {Promise}
+ */
+export async function switchPhaseEntry(oldPhase, newPhase) {
+ const wb = await findRmaWorldBook();
+ if (!wb) {
+ log.warn('未找到 RMA 世界书');
+ return false;
+ }
+
+ const { bookName, bookData, rmaEntries } = wb;
+ const { phaseEntries } = rmaEntries;
+
+ let changed = false;
+
+ // 禁用旧阶段
+ if (oldPhase && phaseEntries[oldPhase]) {
+ const { uid } = phaseEntries[oldPhase];
+ if (bookData.entries[uid]) {
+ bookData.entries[uid].enabled = false;
+ if (bookData.entries[uid].extensions) {
+ bookData.entries[uid].extensions.enabled = false;
+ }
+ changed = true;
+ log.log(`禁用阶段条目: [RMA:Phase:${oldPhase}]`);
+ }
+ }
+
+ // 启用新阶段
+ if (newPhase && phaseEntries[newPhase]) {
+ const { uid } = phaseEntries[newPhase];
+ if (bookData.entries[uid]) {
+ bookData.entries[uid].enabled = true;
+ if (bookData.entries[uid].extensions) {
+ bookData.entries[uid].extensions.enabled = true;
+ }
+ changed = true;
+ log.log(`启用阶段条目: [RMA:Phase:${newPhase}]`);
+ }
+ }
+
+ if (changed) {
+ return await saveWorldBook(bookName, bookData);
+ }
+ return true;
+}
+
+/**
+ * 改写质感条目内容
+ * @param {string} newContent 新的情绪质感文本
+ * @returns {Promise}
+ */
+export async function rewriteTextureEntry(newContent) {
+ const wb = await findRmaWorldBook();
+ if (!wb) return false;
+
+ const { bookName, bookData, rmaEntries } = wb;
+ const { textureEntry } = rmaEntries;
+
+ if (!textureEntry) {
+ log.warn('未找到 [RMA:Texture] 条目');
+ return false;
+ }
+
+ const { uid } = textureEntry;
+ bookData.entries[uid].content = newContent;
+ log.log(`更新质感条目: ${newContent.slice(0, 50)}...`);
+
+ return await saveWorldBook(bookName, bookData);
+}
+
+/**
+ * 检查并解锁事件条目
+ * @param {object} rmaState 当前 RMA 状态
+ * @param {object} config RMA 配置
+ * @returns {Promise>} 本次解锁的条目注释列表
+ */
+export async function checkAndUnlockEntries(rmaState, config) {
+ const wb = await findRmaWorldBook();
+ if (!wb) return [];
+
+ const { bookName, bookData, rmaEntries } = wb;
+ const entryConfig = config?.worldbook_entries?.event_entries || [];
+ const unlocked = [];
+ let changed = false;
+
+ for (const cfg of entryConfig) {
+ if (!cfg.unlock_condition) continue;
+
+ const entryData = rmaEntries.eventEntries[cfg.comment];
+ if (!entryData) continue;
+
+ // 已启用则跳过
+ const { uid, entry } = entryData;
+ if (entry.enabled) continue;
+
+ // 检查解锁条件
+ let shouldUnlock = false;
+ const cond = cfg.unlock_condition;
+
+ if (cond.type === 'secret_stage') {
+ const secretState = getSecretState(cond.secret);
+ if (secretState) {
+ const secretConfig = config.secrets?.[cond.secret];
+ const stages = secretConfig?.stages || [];
+ const currentIdx = stages.findIndex(s => s.id === secretState.current_stage);
+ const requiredIdx = stages.findIndex(s => s.id === cond.min_stage);
+ shouldUnlock = currentIdx >= 0 && requiredIdx >= 0 && currentIdx >= requiredIdx;
+ }
+ } else if (cond.type === 'memory_exists') {
+ shouldUnlock = rmaState.memories.some(m =>
+ m.type === cond.memory_type &&
+ m.tags?.includes(cond.secret_id) &&
+ !m.invalidated
+ );
+ }
+
+ if (shouldUnlock) {
+ bookData.entries[uid].enabled = true;
+ if (bookData.entries[uid].extensions) {
+ bookData.entries[uid].extensions.enabled = true;
+ }
+ unlocked.push(cfg.comment);
+ changed = true;
+ log.log(`解锁事件条目: ${cfg.comment}`);
+ }
+ }
+
+ if (changed) {
+ await saveWorldBook(bookName, bookData);
+ }
+
+ return unlocked;
+}
diff --git a/src/ui/events.js b/src/ui/events.js
index a35041b..deb4101 100644
--- a/src/ui/events.js
+++ b/src/ui/events.js
@@ -94,6 +94,7 @@ let loadConfigCharDescriptionFn = null;
let hasImportedSummaryBooksFn = null;
let openIndexMergeConfigModalFn = null;
let openPlotOptimizeConfigModalFn = null;
+let openRmaConfigModalFn = null;
let clearUpdatesListFn = null;
let initFlowConfigResizeFn = null;
let updateMemorySearchBadgeFn = null;
@@ -140,6 +141,7 @@ export function setLoadConfigCharDescriptionFunction(fn) { loadConfigCharDescrip
export function setHasImportedSummaryBooksFunction(fn) { hasImportedSummaryBooksFn = fn; }
export function setOpenIndexMergeConfigModalFunction(fn) { openIndexMergeConfigModalFn = fn; }
export function setOpenPlotOptimizeConfigModalFunction(fn) { openPlotOptimizeConfigModalFn = fn; }
+export function setOpenRmaConfigModalFunction(fn) { openRmaConfigModalFn = fn; }
export function setClearUpdatesListFunction(fn) { clearUpdatesListFn = fn; }
export function setInitFlowConfigResizeFunction(fn) { initFlowConfigResizeFn = fn; }
export function setLoadWorldbookControlListFunction(fn) { /* 已有本地实现 */ }
@@ -814,6 +816,87 @@ function bindSettingsEvents() {
showConfigModalFn(category);
}
});
+
+ // ==================== RMA 关系记忆系统 ====================
+
+ // RMA 折叠卡片
+ document
+ .getElementById("mm-rma-toggle")
+ ?.addEventListener("click", () => {
+ const card = document.getElementById("mm-rma-card");
+ if (card) card.classList.toggle("expanded");
+ });
+
+ // RMA 启用开关
+ document
+ .getElementById("mm-rma-enabled")
+ ?.addEventListener("change", (e) => {
+ const checked = e.target.checked;
+ import("@rma").then(({ updateRmaConfig }) => {
+ updateRmaConfig({ enabled: checked });
+ });
+ updateRmaBadge(checked);
+ if (typeof toastr !== 'undefined') {
+ toastr.success(`RMA 关系记忆系统已${checked ? "启用" : "禁用"}`, "记忆管理并发系统");
+ }
+ });
+
+ // RMA 确认模式
+ document
+ .getElementById("mm-rma-confirmation-mode")
+ ?.addEventListener("change", (e) => {
+ const mode = e.target.value;
+ import("@rma").then(({ updateRmaConfig }) => {
+ updateRmaConfig({ confirmationMode: mode });
+ });
+ });
+
+ // RMA 面板默认状态
+ document
+ .getElementById("mm-rma-panel-state")
+ ?.addEventListener("change", (e) => {
+ const state = e.target.value;
+ import("@rma").then(({ updateRmaConfig }) => {
+ updateRmaConfig({ floatPanel: { defaultState: state } });
+ });
+ });
+
+ // RMA API 配置编辑按钮
+ document
+ .getElementById("mm-rma-edit")
+ ?.addEventListener("click", () => {
+ if (openRmaConfigModalFn) openRmaConfigModalFn();
+ });
+}
+
+/**
+ * 更新 RMA 徽章状态
+ * @param {boolean} enabled
+ */
+export function updateRmaBadge(enabled) {
+ const badge = document.getElementById("mm-rma-badge");
+ if (badge) {
+ if (enabled) {
+ badge.textContent = "开启";
+ badge.classList.add("active");
+ } else {
+ badge.textContent = "关闭";
+ badge.classList.remove("active");
+ }
+ }
+}
+
+/**
+ * 更新 RMA 模型显示
+ */
+export function updateRmaModelDisplay() {
+ import("@rma").then(({ getRmaAnalysisApiConfig }) => {
+ const config = getRmaAnalysisApiConfig();
+ const displayEl = document.getElementById("mm-rma-model-display");
+ if (displayEl) {
+ displayEl.textContent = config?.model || "未配置";
+ }
+ });
}
/**
@@ -1622,6 +1705,27 @@ export function loadGlobalSettingsUI() {
// 初始化表格填表 UI
initTableFillerUI();
+
+ // ==================== RMA 关系记忆系统 ====================
+ const rmaConfig = settings.rmaConfig || {};
+ const rmaEnabledCheckbox = document.getElementById("mm-rma-enabled");
+ if (rmaEnabledCheckbox) {
+ rmaEnabledCheckbox.checked = rmaConfig.enabled === true;
+ }
+ updateRmaBadge(rmaConfig.enabled === true);
+
+ const rmaConfirmationMode = document.getElementById("mm-rma-confirmation-mode");
+ if (rmaConfirmationMode) {
+ rmaConfirmationMode.value = rmaConfig.confirmationMode || "every_turn";
+ }
+
+ const rmaPanelState = document.getElementById("mm-rma-panel-state");
+ if (rmaPanelState) {
+ rmaPanelState.value = rmaConfig.floatPanel?.defaultState || "half_collapsed";
+ }
+
+ // 更新 RMA 模型显示
+ updateRmaModelDisplay();
}
/**
diff --git a/src/ui/index.js b/src/ui/index.js
index e111375..b210bda 100644
--- a/src/ui/index.js
+++ b/src/ui/index.js
@@ -79,6 +79,7 @@ export {
setHasImportedSummaryBooksFunction,
setOpenIndexMergeConfigModalFunction,
setOpenPlotOptimizeConfigModalFunction,
+ setOpenRmaConfigModalFunction,
setClearUpdatesListFunction,
setInitFlowConfigResizeFunction,
setLoadWorldbookControlListFunction,
@@ -107,6 +108,9 @@ export {
// 模型显示更新
updateIndexMergeModelDisplay,
updatePlotOptimizeModelDisplay,
+ // RMA 关系记忆
+ updateRmaBadge,
+ updateRmaModelDisplay,
} from './events';
// 标签过滤组件
diff --git a/src/ui/modals/config-modal.js b/src/ui/modals/config-modal.js
index 0daeab2..867e4e7 100644
--- a/src/ui/modals/config-modal.js
+++ b/src/ui/modals/config-modal.js
@@ -9,6 +9,7 @@ import {
deleteSummaryConfig,
getGlobalSettings,
loadConfig,
+ saveConfig as savePluginConfig,
setMemoryConfig,
setSummaryConfig,
updateGlobalSettings,
@@ -21,19 +22,22 @@ import { refreshWorldBookList } from "@worldbook/refresh";
import { getWorldBookList, getWorldBookEntries } from "@worldbook/api";
import { analyzeSummaryContent, formatCharCount } from "@worldbook/summary-splitter";
import { getSummaryContent } from "@worldbook/parser";
+import { buildOpenAIModelsUrl } from "@utils/url-builder";
// 更新显示回调函数(将在初始化时注入)
let updateIndexMergeModelDisplayFn = null;
let updatePlotOptimizeModelDisplayFn = null;
+let updateRmaModelDisplayFn = null;
let refreshAIConfigListFn = null;
/**
* 设置更新显示函数
*/
-export function setUpdateDisplayFunctions(indexMergeFn, plotOptimizeFn, refreshConfigListFn) {
+export function setUpdateDisplayFunctions(indexMergeFn, plotOptimizeFn, refreshConfigListFn, rmaFn) {
updateIndexMergeModelDisplayFn = indexMergeFn;
updatePlotOptimizeModelDisplayFn = plotOptimizeFn;
refreshAIConfigListFn = refreshConfigListFn;
+ updateRmaModelDisplayFn = rmaFn || null;
}
// 当前编辑状态
@@ -131,6 +135,8 @@ export function showConfigModal(category, type = "memory", partInfo = null) {
itemConfig = globalSettings.indexMergeConfig || {};
} else if (type === "plot") {
itemConfig = globalSettings.plotOptimizeConfig || {};
+ } else if (type === "rma") {
+ itemConfig = globalSettings.rmaConfig?.analysisApi || {};
}
// 设置标题
@@ -411,6 +417,25 @@ export async function saveConfig() {
// 更新显示
if (updatePlotOptimizeModelDisplayFn) updatePlotOptimizeModelDisplayFn();
Logger.log(`剧情优化配置已保存`);
+ } else if (currentEditingType === "rma") {
+ const rmaAnalysisApi = {
+ apiFormat: format,
+ apiUrl: urlEl?.value || "",
+ apiKey: keyEl?.value || "",
+ model: modelEl?.value || "",
+ maxTokens: parseInt(maxTokensEl?.value || "1500", 10),
+ temperature: parseFloat(temperatureEl?.value || "0.3"),
+ customTemplate: customTemplateEl?.value || "",
+ responsePath: responsePathEl?.value || "choices.0.message.content",
+ };
+ // 使用 RMA 自带的配置更新函数
+ const config = loadConfig();
+ if (!config.global.rmaConfig) config.global.rmaConfig = {};
+ config.global.rmaConfig.analysisApi = rmaAnalysisApi;
+ savePluginConfig(config);
+ // 更新显示
+ if (updateRmaModelDisplayFn) updateRmaModelDisplayFn();
+ Logger.log(`RMA 分析配置已保存`);
}
Logger.log(`配置已保存: ${currentEditingCategory}`);
@@ -514,18 +539,8 @@ export async function fetchModels() {
return;
}
- // 自动补全 /v1/models
- let modelsUrl = apiUrl;
- if (apiUrl.endsWith("/v1") || apiUrl.endsWith("/v1/")) {
- modelsUrl = apiUrl.replace(/\/v1\/?$/, "/v1/models");
- } else if (apiUrl.includes("/v1/chat/completions")) {
- modelsUrl = apiUrl.replace("/v1/chat/completions", "/v1/models");
- } else if (apiUrl.includes("/chat/completions")) {
- modelsUrl = apiUrl.replace("/chat/completions", "/models");
- } else if (!apiUrl.includes("/models")) {
- // 尝试添加 /v1/models
- modelsUrl = apiUrl.replace(/\/?$/, "") + "/v1/models";
- }
+ // 统一的反代兼容模型列表 URL 构造
+ let modelsUrl = buildOpenAIModelsUrl(apiUrl);
// 显示加载状态
fetchBtn.classList.add("mm-loading-models");
diff --git a/style.css b/style.css
index 720b568..e25780e 100644
--- a/style.css
+++ b/style.css
@@ -9074,4 +9074,442 @@ input.mm-input-sm {
}
}
+/* ============================================================================
+ RMA 关系记忆系统 - 悬浮面板
+ ============================================================================ */
+
+.rma-panel {
+ position: fixed;
+ right: 16px;
+ top: 80px;
+ z-index: 10000;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+ font-size: 13px;
+ color: var(--mm-text, #e4e4e4);
+ transition: var(--mm-transition, all 0.3s ease);
+}
+
+/* 最小化状态 */
+.rma-panel.rma-panel-minimized .rma-panel-header,
+.rma-panel.rma-panel-minimized .rma-panel-body,
+.rma-panel.rma-panel-minimized .rma-panel-half,
+.rma-panel.rma-panel-minimized .rma-panel-footer,
+.rma-panel.rma-panel-minimized #rma-timeline-container {
+ display: none !important;
+}
+
+.rma-panel.rma-panel-minimized .rma-panel-minimized-icon {
+ display: flex;
+}
+
+.rma-panel-minimized-icon {
+ display: none;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: var(--mm-bg-card, #0f3460);
+ border: 1px solid var(--mm-border, #2a2a4a);
+ align-items: center;
+ justify-content: center;
+ font-size: 20px;
+ cursor: pointer;
+ box-shadow: var(--mm-shadow);
+ transition: transform 0.2s;
+}
+
+.rma-panel-minimized-icon:hover {
+ transform: scale(1.1);
+}
+
+/* 半折叠状态 */
+.rma-panel.rma-panel-half .rma-panel-body,
+.rma-panel.rma-panel-half .rma-panel-footer,
+.rma-panel.rma-panel-half #rma-timeline-container {
+ display: none !important;
+}
+
+.rma-panel.rma-panel-half .rma-panel-minimized-icon {
+ display: none;
+}
+
+.rma-panel.rma-panel-half .rma-panel-half {
+ display: block;
+}
+
+.rma-panel.rma-panel-half {
+ width: auto;
+ max-width: 380px;
+ background: var(--mm-bg-card, #0f3460);
+ border: 1px solid var(--mm-border, #2a2a4a);
+ border-radius: var(--mm-radius, 8px);
+ box-shadow: var(--mm-shadow);
+}
+
+.rma-panel-half {
+ display: none;
+ padding: 8px 12px;
+ font-size: 12px;
+ color: var(--mm-text-muted, #8a8a8a);
+ cursor: pointer;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* 展开状态 */
+.rma-panel.rma-panel-expanded .rma-panel-minimized-icon {
+ display: none;
+}
+
+.rma-panel.rma-panel-expanded .rma-panel-half {
+ display: none;
+}
+
+.rma-panel.rma-panel-expanded {
+ width: 320px;
+ max-height: 80vh;
+ background: var(--mm-bg-card, #0f3460);
+ border: 1px solid var(--mm-border, #2a2a4a);
+ border-radius: var(--mm-radius, 8px);
+ box-shadow: var(--mm-shadow);
+ overflow-y: auto;
+}
+
+/* 面板头部 */
+.rma-panel-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 10px 12px;
+ background: var(--mm-bg-secondary, #16213e);
+ border-bottom: 1px solid var(--mm-border, #2a2a4a);
+ border-radius: var(--mm-radius, 8px) var(--mm-radius, 8px) 0 0;
+ cursor: move;
+ user-select: none;
+}
+
+.rma-panel-title {
+ font-weight: 600;
+ font-size: 13px;
+}
+
+.rma-panel-controls {
+ display: flex;
+ gap: 4px;
+}
+
+/* 面板主体 */
+.rma-panel-body {
+ padding: 8px 12px;
+ max-height: 60vh;
+ overflow-y: auto;
+}
+
+.rma-section {
+ margin-bottom: 8px;
+}
+
+.rma-section:empty {
+ display: none;
+}
+
+/* 阶段指示器 */
+.rma-phase-bar {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex-wrap: wrap;
+ font-size: 11px;
+ padding: 6px 0;
+}
+
+.rma-phase-dot {
+ padding: 2px 6px;
+ border-radius: 10px;
+ background: var(--mm-bg-secondary, #16213e);
+ color: var(--mm-text-muted, #8a8a8a);
+}
+
+.rma-phase-dot.active {
+ background: var(--mm-primary, #4a90d9);
+ color: #fff;
+ font-weight: 600;
+}
+
+.rma-phase-dot.past {
+ opacity: 0.6;
+}
+
+/* 感受/质感 */
+.rma-feeling {
+ font-size: 12px;
+ font-style: italic;
+ color: var(--mm-text-muted, #8a8a8a);
+ padding: 4px 0;
+ line-height: 1.5;
+}
+
+/* 秘密 */
+.rma-secret-item {
+ font-size: 12px;
+ padding: 2px 0;
+}
+
+/* 未解决事项 */
+.rma-thread {
+ font-size: 12px;
+ padding: 2px 0;
+ color: var(--mm-warning, #ffc107);
+}
+
+/* 故事状态 */
+.rma-story-bar {
+ font-size: 11px;
+ color: var(--mm-text-muted, #8a8a8a);
+ padding: 4px 0;
+}
+
+/* 记忆简述 */
+.rma-mem-brief {
+ font-size: 12px;
+ padding: 2px 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* 标签 */
+.rma-pending-label {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--mm-text-muted, #8a8a8a);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ margin-bottom: 4px;
+ padding-top: 4px;
+ border-top: 1px solid var(--mm-border, #2a2a4a);
+}
+
+/* 面板底部 */
+.rma-panel-footer {
+ padding: 8px 12px;
+ border-top: 1px solid var(--mm-border, #2a2a4a);
+ text-align: center;
+}
+
+/* ---- 待确认区域 ---- */
+.rma-pending-area {
+ border: 1px solid var(--mm-warning, #ffc107);
+ border-radius: 6px;
+ padding: 8px;
+ margin-top: 8px;
+ background: rgba(255, 193, 7, 0.05);
+}
+
+.rma-pending-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 0;
+ font-size: 12px;
+}
+
+.rma-pending-event {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.rma-pending-actions {
+ display: flex;
+ gap: 2px;
+}
+
+.rma-type-badge {
+ font-size: 10px;
+ padding: 1px 6px;
+ border-radius: 8px;
+ white-space: nowrap;
+}
+
+.rma-type-breakthrough { background: rgba(255, 215, 0, 0.2); color: #ffd700; }
+.rma-type-warmth { background: rgba(255, 200, 50, 0.2); color: #ffc832; }
+.rma-type-crack { background: rgba(220, 53, 69, 0.2); color: #dc3545; }
+.rma-type-revelation { background: rgba(138, 43, 226, 0.2); color: #9b59b6; }
+
+.rma-phase-change {
+ font-size: 12px;
+ padding: 4px 0;
+ color: var(--mm-primary, #4a90d9);
+}
+
+.rma-narrative-preview {
+ font-size: 12px;
+ font-style: italic;
+ line-height: 1.6;
+ padding: 6px 8px;
+ background: var(--mm-bg-secondary, #16213e);
+ border-radius: 4px;
+ max-height: 120px;
+ overflow-y: auto;
+ outline: none;
+ border: 1px solid transparent;
+}
+
+.rma-narrative-preview:focus {
+ border-color: var(--mm-primary, #4a90d9);
+}
+
+.rma-pending-actions-bar {
+ display: flex;
+ gap: 8px;
+ justify-content: center;
+ margin-top: 8px;
+}
+
+/* ---- 按钮 ---- */
+.rma-btn {
+ padding: 6px 14px;
+ border: none;
+ border-radius: 6px;
+ font-size: 12px;
+ cursor: pointer;
+ transition: var(--mm-transition);
+}
+
+.rma-btn-confirm {
+ background: var(--mm-success, #28a745);
+ color: #fff;
+}
+
+.rma-btn-confirm:hover {
+ filter: brightness(1.15);
+}
+
+.rma-btn-reanalyze {
+ background: var(--mm-secondary, #6c757d);
+ color: #fff;
+}
+
+.rma-btn-reanalyze:hover {
+ filter: brightness(1.15);
+}
+
+.rma-btn-sm {
+ background: none;
+ border: 1px solid var(--mm-border, #2a2a4a);
+ color: var(--mm-text, #e4e4e4);
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 12px;
+ cursor: pointer;
+ transition: var(--mm-transition);
+}
+
+.rma-btn-sm:hover {
+ background: var(--mm-bg-secondary, #16213e);
+}
+
+/* ---- 时间线 ---- */
+.rma-timeline {
+ padding: 8px 12px;
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.rma-timeline-empty {
+ text-align: center;
+ color: var(--mm-text-muted, #8a8a8a);
+ padding: 20px;
+ font-size: 12px;
+}
+
+.rma-timeline-group {
+ margin-bottom: 12px;
+}
+
+.rma-timeline-day {
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--mm-primary, #4a90d9);
+ margin-bottom: 4px;
+}
+
+.rma-timeline-item {
+ display: flex;
+ gap: 8px;
+ padding: 4px 0;
+ cursor: pointer;
+ border-radius: 4px;
+ transition: background 0.15s;
+}
+
+.rma-timeline-item:hover {
+ background: rgba(255, 255, 255, 0.05);
+}
+
+.rma-timeline-icon {
+ flex-shrink: 0;
+ width: 20px;
+ text-align: center;
+}
+
+.rma-timeline-content {
+ flex: 1;
+ min-width: 0;
+}
+
+.rma-timeline-type {
+ font-size: 10px;
+ color: var(--mm-text-muted, #8a8a8a);
+}
+
+.rma-timeline-event {
+ font-size: 12px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.rma-timeline-impact {
+ display: none;
+ font-size: 11px;
+ color: var(--mm-text-muted, #8a8a8a);
+ margin-top: 2px;
+ line-height: 1.4;
+}
+
+.rma-timeline-item.rma-timeline-expanded .rma-timeline-event {
+ white-space: normal;
+}
+
+.rma-timeline-item.rma-timeline-expanded .rma-timeline-impact {
+ display: block;
+}
+
+.rma-timeline-warn {
+ font-size: 10px;
+ color: var(--mm-warning, #ffc107);
+}
+
+.rma-mem-invalid {
+ opacity: 0.5;
+}
+
+.rma-pending-empty {
+ text-align: center;
+ color: var(--mm-text-muted, #8a8a8a);
+ padding: 8px;
+ font-size: 12px;
+}
+
+/* RMA 设置面板状态行 */
+.mm-rma-status-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 12px;
+ color: var(--mm-text-secondary, #aaa);
+}
diff --git a/ui/rma-panel.html b/ui/rma-panel.html
new file mode 100644
index 0000000..f317af3
--- /dev/null
+++ b/ui/rma-panel.html
@@ -0,0 +1,49 @@
+
+
+
+
🔮
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/settings.html b/ui/settings.html
index 80e4341..85eae10 100644
--- a/ui/settings.html
+++ b/ui/settings.html
@@ -726,6 +726,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ RMA 分析
+
+ 未配置
+
+
+
+
+
+
+
+
+
diff --git a/webpack.config.js b/webpack.config.js
index 27adfce..3f0beb1 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -58,6 +58,7 @@ module.exports = (env, argv) => {
'@ui': path.resolve(__dirname, 'src/ui'),
'@utils': path.resolve(__dirname, 'src/utils'),
'@table-filler': path.resolve(__dirname, 'src/table-filler'),
+ '@rma': path.resolve(__dirname, 'src/rma'),
}
},
};