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(`
+
新记忆
+ ${memHtml} +
`); + } + + // 阶段变化 + 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(`
+
关系评估
+ ${phaseHtml} +
`); + } + + // 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 = ` +
🔮
+
+ RMA 关系系统 +
+ + +
+
+
+
+
+
+
+
+
+
+
+
+ + + `; + 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 关系记忆 + 关闭 +
+ +
+
+
+ +
+ +
+ + + + + +
+ + +
+ + +
+ + +
+ + +
+
+ + + 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'), } }, };