mirror of
https://github.com/Wx-2025/ST-Amily2-Chat-Optimisation.git
synced 2026-06-13 09:15:50 +00:00
### 新功能 - **翰林院向量化质量升级**: - **边界感知切块**:替换四个来源(聊天记录/小说/世界书/手动)的纯字符硬切——优先在段落边界断开,其次句末标点(含中文引号闭合),极端长串才硬切;句子/对话不再被拦腰截断,embedding 质量同步受益。仅影响新录入,已有向量无需重建 - **注入时序重排**:检索结果注入提示词前按时序重排(聊天记录按楼层、小说按卷/章/节——中文数字章节号可解析),rerank 只决定"选哪些块",不再决定呈现顺序;修复"不打不相识的剧情之后紧跟关系亲密"这类因按相关度排序导致的认知时间错乱 - **断层提示**:聊天记录相邻块楼层跳跃时自动插入"与上文相隔约 N 楼,并非连续发生"提示行,消除中间剧情缺失造成的割裂感 - **时间标识**:新录入的聊天记录块在来源标识中带上消息发送时间(ST 向量存储不持久化元数据,时间必须写入块文本才能在检索后取回;旧格式块兼容解析) - **记忆块工作流(memory-blocks)**:剧情优化新增"自定义记忆块"体系——占位符驱动的并发工作流框架 - 在剧情优化面板「匹配替换 (sulv)」下方可增删自定义块:每个块定义一个占位符,执行剧情优化时主/拦截提示词中的占位符会被块的产出替换 - **静态块**:直接输出固定内容;**AI 调用块**:用所选 API 功能槽独立请求一次,把回复(或其中指定 `<标签>` 的内容)作为替换值 - 原有 sulv1-4 速率占位符迁入同一框架,行为与旧版逐字节一致 - 块定义为纯 JSON、随设置持久化,为后续导入导出与战斗系统接入预留扩展点 - 框架层新增**顺序拼接式 Chain**(`composeChain`):与占位符替换并列的第二种组合范式——同链的块并发执行后按 `order` 排序、以 `separator` 拼接并可选 `header/footer` 包裹,产出一个完整注入块;为记忆注入合成块与战斗系统"底部战报块"预留的承载结构,本版本暂无 UI 入口 - **API 连接配置**: - 角色世界书(cwb)与一键生卡(autoCharCard)纳入旧配置自动迁移:老用户首次加载会把旧 URL / Key / 模型自动迁移为连接配置并分配槽位(一键生卡仅在规划者与执行者配置一致或规划者为空时迁移,避免悄悄改变行为) - **profile 已分配时参数控件 informational 化**:主面板 / 并发剧情优化 / 角色世界书 / 术语表的温度、maxTokens 控件在槽位分配 profile 后自动禁用并显示"由连接配置控制"提示,消除"改了没效果"的用户陷阱 - **profile 状态卡新增"本设备无 Key"警示**:API Key 仅保存在最初填写它的设备/浏览器上(安全设计,不随云端设置同步),换设备后状态卡会直接亮出警示徽标,不必等到调用报错才发现 ### 修复 - **独立聊天记忆从摆设变真功能(原作遗留坑)**:此前向量数据"随卡不随聊天"——开启"独立聊天记忆"后录入仍存进角色库、查询却去查一个从未被写入过的聊天集合、计数恒为 0,整体静默失效。现已重构为聊天级分桶: - 独立模式下,聊天记录类向量按当前聊天隔离存储与检索,同一张卡开多个聊天(不同剧情线)的记忆互不污染 - 小说 / 世界书 / 手动录入属于"知识",仍随角色卡跨聊天共享;全局库不受影响 - 知识管理列表为聊天专属库显示"聊天级"徽标;聊天级库禁止移动到全局 - 统一模式(默认关闭独立记忆)的存量数据与行为完全不变 - 已知限制:聊天专属记忆跟随聊天文件,重命名聊天文件会使其失联(与 ST 官方向量扩展同等限制) - **超级排序截断顺序修正**:开启"超级排序"时,时序重排发生在 top_n 截断之前,导致保留的是"时序最早"而非"最相关"的块,检索结果长期偏向最旧的聊天记录。现改为先按相关度截取 top_n、再做时序排序 - **翰林院向量化失败("向量化块数量不识别"反馈)**: - 一次性清洗 profile-sync 历史污染:`retrieval/rerank.apiKey` 中的掩码占位符在持久层根治(此前仅读取侧防御);`apiEndpoint` / `rerank.apiMode` 的非法值(如被旧版写入的空字符串)归一化为 `custom` - 修复 `apiEndpoint` 为空/非法时请求被硬定向到 `api.openai.com`、无视用户自定义 URL 的问题(CSP 拦截 / 401 的元凶) - 修复**本地代理(LM Studio/Ollama)模式**自始就缺少 URL 分支、同样被错误定向到 openai.com 的问题 - API 模式下拉补全 `OpenAI 官方` / `Azure` 选项;默认 API 模式改为 `custom`(与默认 URL 配套),新用户不再因选项缺失导致首次保存写入空值 - profile-sync 给下拉框赋不存在选项值的污染源头修复(影响所有模块面板,不止翰林院) - **Rerank "API Key 未提供"报错升级**:当原因是"连接配置在本设备没有可用 Key"时,报错会直接说明 Key 的设备本地性并指引到 API 连接配置重新填写(向量化 Google 直连、获取模型列表同步处理) - **旧配置迁移**:一键生卡迁移时排除掩码占位符,避免把历史污染的假 Key 迁入新连接配置 - **超级记忆稳定性专项**(针对"工作不大稳定"反馈,4 处根因一次修复): - **切聊天竞态污染**:CHAT_CHANGED 时超级记忆立即全量同步,而表格系统延迟 100ms 才加载新聊天的表格,导致【旧聊天】的表格内容被写进【新角色】的记忆世界书;两边表名不同时旧表条目无 GC 兜底会**永久残留**("记忆串台"元凶)。现 CHAT_CHANGED 只确保世界书存在,新状态同步交由 `loadTables()` 完成后的自动推送,单次且时序正确 - **死代码双轨存储拆除**:`saveStateToMetadata` / `tryRestoreStateFromMetadata` 把表格状态写到 `msg.metadata`——该字段非 ST 持久化位(同 v2.2.5 二次填表修过的坑),写入即蒸发、恢复永远为空,且每次同步还白调一次 `saveChat()`。整条链路删除,表格状态唯一信源为表格系统的 `msg.extra.amily2_tables_data` - **`awaitSync()` 穿透**:同步队列正忙时 `pushUpdate` 会用一个立即 resolve 的空 Promise 覆盖 `_syncPromise`,Pipeline Stage 4 等待形同虚设、后续阶段在同步未完成时被放行。现忙时不覆盖,正在运行的 drain 循环自然吃掉新入队项 - **开关打开不生效**:启动时若总开关为关,初始化早退且不注册监听器;此后在 UI 勾选开关只写设置,超级记忆直到刷新页面前都是死的。现勾选即触发初始化(幂等) - 附带:`forceSyncAll` 的表格角色推断改为复用 `events-schema.inferTableRole`,消除两处重复逻辑漂移风险;每次切聊天的双倍全量同步(restore 路径一次 + 显式一次)随死代码移除归一 ### 重构 - 表格核心 `manager.js` 瘦身(约 1050 → 600 行):19 个 UI 突变操作拆分至 `actions/ui-mutations.js`,SuperMemory 事件分发拆分至 `events-dispatch.js`;全部经 re-export 保持兼容,外部调用路径零改动 - 角色世界书最后 2 处散乱的厂商 URL 判断迁移至 `detectVendor` 统一入口,业务路径上不再有硬编码的 URL substring 判断
599 lines
29 KiB
JavaScript
599 lines
29 KiB
JavaScript
/**
|
||
* @file manager.js —— Phase 0 重构后承担"剩余的编排层"。
|
||
*
|
||
* 大部分功能已迁出:
|
||
* - 状态:infra/store.js (currentTablesState / highlights / updatedTables)
|
||
* - 持久化:infra/persistence.js (saveStateToMessage / commitToLastMessage)
|
||
* - 推演:actions/applyOperations.js (executor.js 改造为 legacy formatter)
|
||
* - UI 突变:actions/ui-mutations.js (addRow / addColumn / ... / clearAllTables,Phase 0.4)
|
||
* - SuperMemory 分发:events-dispatch.js (dispatchTableUpdate / dispatchAllTablesUpdate)
|
||
* - 渲染:rendering.js (3 个 toCsv)
|
||
* - 模板:templates.js
|
||
* - 预设:preset.js
|
||
*
|
||
* 本文件保留:
|
||
* - 默认表格模板 + getDefaultTables
|
||
* - triggerSync(SuperMemory 全量同步入口)
|
||
* - loadTables 的多档回退逻辑
|
||
* - updateTableFromText / updateTableFromOps 编排
|
||
* - rollbackState / rollbackAndRefill
|
||
*
|
||
* 所有原先 export 的接口一律保留兼容(移走的统一 re-export),调用方零改动。
|
||
*
|
||
* @typedef {import('./dto/Table.js').TableState} TableState
|
||
*/
|
||
|
||
import { getContext, extension_settings } from '/scripts/extensions.js';
|
||
import { saveChat } from '/script.js';
|
||
import { saveChatDebounced } from '../../utils/utils.js';
|
||
import { extensionName } from '../../utils/settings.js';
|
||
|
||
import { log } from './logger.js';
|
||
import { executeCommands } from './executor.js';
|
||
import { applyOperations } from './actions/applyOperations.js';
|
||
import { fillWithSecondaryApi } from './secondary-filler.js';
|
||
import { renderTables } from '../../ui/table-bindings.js';
|
||
import { updateOrInsertTableInChat } from '../../ui/message-table-renderer.js';
|
||
import { dispatchTableUpdate, dispatchAllTablesUpdate } from './events-dispatch.js';
|
||
|
||
// ── UI 突变(Phase 0.4 迁至 actions/ui-mutations.js,此处 re-export 保持兼容) ──
|
||
import { commitPendingDeletions } from './actions/ui-mutations.js';
|
||
export {
|
||
deleteColumn,
|
||
moveRow,
|
||
insertRow,
|
||
addRow,
|
||
addColumn,
|
||
updateHeader,
|
||
deleteRow,
|
||
restoreRow,
|
||
commitPendingDeletions,
|
||
insertColumn,
|
||
moveColumn,
|
||
deleteTable,
|
||
addTable,
|
||
renameTable,
|
||
moveTable,
|
||
updateTableRules,
|
||
updateRow,
|
||
clearAllTables,
|
||
updateColumnWidth,
|
||
} from './actions/ui-mutations.js';
|
||
|
||
// ── 新模块(IAD 拆分后的依赖) ────────────────────────────────────────────
|
||
import {
|
||
getState,
|
||
setState,
|
||
addHighlight as _storeAddHighlight,
|
||
getHighlights as _storeGetHighlights,
|
||
clearHighlights as _storeClearHighlights,
|
||
markTableUpdated,
|
||
getUpdatedTables as _storeGetUpdatedTables,
|
||
clearUpdatedTables as _storeClearUpdatedTables,
|
||
} from './infra/store.js';
|
||
|
||
import {
|
||
saveStateToMessage as _persistSaveStateToMessage,
|
||
commitToLastMessage,
|
||
TABLE_DATA_KEY,
|
||
} from './infra/persistence.js';
|
||
|
||
import {
|
||
tablesToCsv,
|
||
tablesToCsvWithSelection,
|
||
tablesToCsvContentOnly,
|
||
} from './rendering.js';
|
||
|
||
import {
|
||
getBatchFillerRuleTemplate as _tplGetBatchFillerRuleTemplate,
|
||
saveBatchFillerRuleTemplate as _tplSaveBatchFillerRuleTemplate,
|
||
getBatchFillerFlowTemplate as _tplGetBatchFillerFlowTemplate,
|
||
saveBatchFillerFlowTemplate as _tplSaveBatchFillerFlowTemplate,
|
||
getAiFlowTemplateForInjection as _tplGetAiFlowTemplateForInjection,
|
||
saveAiTemplate as _tplSaveAiTemplate,
|
||
getAiTemplate as _tplGetAiTemplate,
|
||
} from './templates.js';
|
||
|
||
import {
|
||
exportPreset as _presetExportPreset,
|
||
exportPresetFull as _presetExportPresetFull,
|
||
importPreset as _presetImportPreset,
|
||
clearGlobalPreset as _presetClearGlobalPreset,
|
||
importGlobalPreset as _presetImportGlobalPreset,
|
||
} from './preset.js';
|
||
|
||
// ── SuperMemory 同步入口 ──────────────────────────────────────────────────
|
||
|
||
/**
|
||
* 主动触发所有表格同步到 SuperMemory(外部调用入口)。
|
||
*/
|
||
export function triggerSync() {
|
||
dispatchAllTablesUpdate();
|
||
}
|
||
|
||
// ── 状态访问(store 包装层) ──────────────────────────────────────────────
|
||
|
||
export function addHighlight(tableIndex, rowIndex, colIndex) {
|
||
_storeAddHighlight(tableIndex, rowIndex, colIndex);
|
||
}
|
||
|
||
export function getHighlights() {
|
||
return _storeGetHighlights();
|
||
}
|
||
|
||
export function clearHighlights() {
|
||
_storeClearHighlights();
|
||
}
|
||
|
||
export function getUpdatedTables() {
|
||
return _storeGetUpdatedTables();
|
||
}
|
||
|
||
export function clearUpdatedTables() {
|
||
_storeClearUpdatedTables();
|
||
}
|
||
|
||
export function setMemoryState(newState) {
|
||
setState(newState);
|
||
}
|
||
|
||
export function getMemoryState() {
|
||
return getState();
|
||
}
|
||
|
||
export function loadMemoryState(state) {
|
||
if (!state) return;
|
||
setState(state);
|
||
renderTables();
|
||
updateOrInsertTableInChat();
|
||
log('[SuperMemory] 已从元数据恢复内存状态并刷新 UI。', 'info');
|
||
}
|
||
|
||
export function saveMemoryState() {
|
||
const context = getContext();
|
||
if (context.chat && context.chat.length > 0) {
|
||
const lastMessage = context.chat[context.chat.length - 1];
|
||
if (_persistSaveStateToMessage(getState(), lastMessage)) {
|
||
// 不在此处强制 saveChat,避免高频;调用方决定时机
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export function saveStateToMessage(stateToSave, targetMessage) {
|
||
return _persistSaveStateToMessage(stateToSave, targetMessage);
|
||
}
|
||
|
||
// ── 默认模板 ──────────────────────────────────────────────────────────────
|
||
|
||
const defaultTemplate = {
|
||
"tables": [
|
||
{
|
||
"name": "时空栏",
|
||
"headers": ["日期", "时段", "时间", "地点", "此地角色"],
|
||
"note": "【核心作用】此表格用于精确追踪故事发生的即时时空背景,确保时间与空间的连续性。它应该始终只包含一行,代表当前的“镜头”位置。\n【字段详解】\n- 日期: 格式为'YYYY-MM-DD'。若日期未知,请根据上下文合理推断或设定一个初始日期,如'大夏3年-9月-10日'。\n- 时段: 严格遵循规定(凌晨:0-5时;早晨:5-8时;上午:8-11时;中午:11-13时;下午:13-16时;傍晚:16-19时;晚上:19-24时)。\n- 时间: 格式为'HH:MM'。若时间未知,可根据时段估算,如'08:30'。\n- 地点: 描述当前场景发生的具体位置,应尽可能精确,例如'XX街的咖啡馆'而非'城里'。\n- 此地角色: 列出当前场景中所有在场且参与互动的主要角色,用'/'分隔。",
|
||
"rule_add": "【触发条件】当故事开始,且此表格为空时,必须立即根据初始场景创建第一行。",
|
||
"rule_delete": "【触发条件】任何时候,如果此表格的行数超过一行,必须删除旧的行,只保留最新、最准确的一行。",
|
||
"rule_update": "【触发条件】当以下任一情况发生时,必须更新此行:\n1. 时间发生显著跳跃(例如,'几小时后'、'第二天')。\n2. 角色从一个地点移动到另一个地点。\n3. 场景中关键角色的出入导致在场人员发生变化。",
|
||
"charLimitRules": {},
|
||
"rowLimitRule": 1,
|
||
"rows": []
|
||
},
|
||
{
|
||
"name": "角色栏",
|
||
"headers": ["角色名", "外貌", "身形", "衣着", "性格", "身份", "职业", "与<user>关系", "爱好", "住所", "其他重要信息"],
|
||
"note": "【核心作用】此表格是角色关系和状态的核心数据库,用于记录所有在故事中出现的重要角色的详细信息。\n【字段详解】\n- 角色名: 角色的唯一标识。\n- 外貌: 描述五官、发型、发色、肤色等面部特征。\n- 身形: 描述身高、体型、肌肉状况、特殊身体标记(如伤疤)等。\n- 衣着: 描述角色当前或标志性的穿着,包括服装、配饰等。\n- 性格: 概括角色的核心性格特质,使用1-3个关键词,如'勇敢/鲁莽/忠诚'。\n- 身份: 角色的社会背景或出身,如'贵族后裔'、'流浪者'。\n- 职业: 角色赖以谋生的工作或职责,如'佣兵'、'学者'。\n- 与<user>关系: 描述该角色与主角<user>之间的社会或情感关系,如'盟友'、'导师'、'敌人'。\n- 爱好: 角色的兴趣和消遣活动。\n- 住所: 角色的常住地。\n- 其他重要信息: 记录任何不属于以上类别但对角色至关重要的信息,如特殊能力、过去的经历等。",
|
||
"rule_add": "【触发条件】当一个有名有姓的角色首次出现,并与<user>或当前剧情发生有意义的互动时,必须为其创建新的一行。",
|
||
"rule_delete": "【触发条件】当一个角色被确认永久性死亡(非假死或失踪),且其存在不再对后续剧情有直接影响时,可以删除该行。",
|
||
"rule_update": "【触发条件】当角色的任何信息发生持久性或关键性变化时,必须更新对应单元格。例如:\n1. 外貌/身形/衣着发生永久性改变(如断肢、换上新装备)。\n2. 性格因重大事件而扭转。\n3. 身份或职业发生变更(如继承王位、被解雇)。\n4. 与<user>的关系发生根本性转变(如从敌人变为盟友)。",
|
||
"charLimitRules": { "10": 30 },
|
||
"rowLimitRule": 0,
|
||
"rows": []
|
||
},
|
||
{
|
||
"name": "关系栏",
|
||
"headers": ["主动方", "被动方", "关系", "详情"],
|
||
"columnWidths": [],
|
||
"note": "【核心作用】专门用于记录除主角<user>以外的角色之间的复杂人际关系网(NPC to NPC)。\n【字段详解】\n- 主动方: 关系的发起者或主体(例如'艾克')。\n- 被动方: 关系的接收者或对象(例如'莉娜')。\n- 关系: 用简短的词汇描述两者之间的关系本质,如'暗恋'、'世仇'、'师徒'。\n- 详情: 对这段关系的具体描述或背景补充。",
|
||
"rule_add": "【触发条件】当两个NPC之间展现出明确的、非临时性的人际关系时,应添加新行。",
|
||
"rule_delete": "【触发条件】当两个NPC之间的关系彻底断绝且不再影响剧情,或者其中一方彻底消失/死亡时,可以删除。",
|
||
"rule_update": "【触发条件】当两个NPC之间的关系性质发生转变(如从'盟友'变为'背叛者')时,必须更新。",
|
||
"charLimitRules": {},
|
||
"rowLimitRule": 0,
|
||
"rows": [],
|
||
"rowStatuses": []
|
||
},
|
||
{
|
||
"name": "任务栏",
|
||
"headers": ["任务名", "类型", "详情", "状态", "执行者", "地点", "开始时间/结束时间", "结果"],
|
||
"note": "【核心作用】追踪故事中的主要情节线、目标和挑战。只记录对剧情发展有重大影响的“任务”,忽略日常琐事。\n【字段详解】\n- 任务名: 任务的简洁概括,如'寻找失落的神器'。\n- 类型: 任务的分类,如'主线'、'支线'、'个人'、'约定'。\n- 详情: 对任务目标和背景的简要描述。\n- 状态: 任务的当前进展,如'未开始'、'进行中'、'已完成'、'已失败'、'已取消'。\n- 执行者: 负责完成此任务的角色名。\n- 地点: 任务关键环节发生的地点。\n- 开始时间/结束时间: 记录任务的起止时间,格式'YYYY-MM-DD',若未结束则结束时间留空。\n- 结果: 任务完成或失败后的最终结果。",
|
||
"rule_add": "【触发条件】当以下情况发生时,应添加新行:\n1. 角色接下一个明确的、有目标的委托或命令。\n2. 角色们达成一个具体的、需要在未来执行的约定。\n3. 角色为自己设定一个长期的、关键性的目标。",
|
||
"rule_delete": "【触发条件】当任务列表超过10行时,优先删除最早的、已经“已完成”且与当前剧情关联度最低的任务。如果存在内容完全重复的任务,应删除。",
|
||
"rule_update": "【触发条件】当任务的“状态”发生任何变化时,必须更新。例如,从'进行中'变为'已完成'。当任务的“详情”或“结果”有新的关键信息补充时,也应更新。",
|
||
"charLimitRules": {},
|
||
"rowLimitRule": 10,
|
||
"rows": []
|
||
},
|
||
{
|
||
"name": "物品栏",
|
||
"headers": ["物品名", "类型", "详情", "状态", "拥有者", "重要原因"],
|
||
"note": "【核心作用】记录那些在故事中具有特殊功能、背景或情感价值的关键物品。普通物品不应记录。\n【字段详解】\n- 物品名: 物品的名称。\n- 类型: 物品的分类,如'武器'、'道具'、'信物'、'关键物品'。\n- 详情: 描述物品的外观、材质和已知功能。\n- 状态: 物品的当前状况,如'完好'、'破损'、'能量耗尽'。\n- 拥有者: 当前持有该物品的角色名。\n- 重要原因: 解释该物品为何重要,例如'是解开谜题的钥匙'或'是母亲的遗物'。",
|
||
"rule_add": "【触发条件】当一个物品被明确赋予了特殊意义(如被赠予、在关键事件中扮演重要角色)或展示出独特功能时,应为其创建条目。",
|
||
"rule_delete": "【触发条件】当一个物品被彻底摧毁、消耗完毕或永久失去其特殊意义时,可以删除。",
|
||
"rule_update": "【触发条件】当物品的“状态”(如被损坏)、“拥有者”(如被转交或被盗)或“详情”(如发现了新功能)发生变化时,必须更新。",
|
||
"charLimitRules": {},
|
||
"rowLimitRule": 0,
|
||
"rows": []
|
||
},
|
||
{
|
||
"name": "技能栏",
|
||
"headers": ["技能名", "技能效果"],
|
||
"note": "【核心作用】专门用于记录主角<user>掌握的各种技能、魔法、被动能力或特殊专长。\n【字段详解】\n- 技能名: 技能的正式名称。\n- 技能效果: 清晰、简洁地描述该技能使用时产生的具体效果、消耗和限制条件。",
|
||
"rule_add": "【触发条件】当<user>在故事中首次成功施展或习得一个全新的、表格中未记录的技能时,必须添加。",
|
||
"rule_delete": "【触发条件】如果发现表格中存在两个描述完全相同的重复技能,应删除其中一个。如果记录了非<user>的技能,应立即删除。",
|
||
"rule_update": "【触发条件】当一个已知技能的效果发生进化、变异或被添加了新的限制/效果时(例如,技能升级),必须更新其“技能效果”描述。",
|
||
"charLimitRules": {},
|
||
"rowLimitRule": 0,
|
||
"rows": []
|
||
},
|
||
{
|
||
"name": "设定栏",
|
||
"headers": ["类型", "具体描述"],
|
||
"note": "【核心作用】此表格记录了来自<user>的、超越故事本身的“元指令”或世界观设定,拥有最高解释权。内容应被严格遵守,禁止AI自行修改。\n【字段详解】\n- 类型: 指令的分类,如'世界观设定'、'剧情走向要求'、'角色行为禁令'。\n- 具体描述: 完整、准确地记录<user>提出的具体要求。",
|
||
"rule_add": "【触发条件】当<user>通过括号、旁白或其他明确的“第四面墙”方式,提出关于故事背景、规则或未来走向的指令时,必须记录于此。",
|
||
"rule_delete": "【触发条件】只能在<user>明确表示要移除或废弃某条设定时,才能删除对应行。",
|
||
"rule_update": "【触发条件】只能在<user>明确表示要修改某条设定时,才能更新对应行的描述。",
|
||
"charLimitRules": {},
|
||
"rowLimitRule": 0,
|
||
"rows": []
|
||
}
|
||
]
|
||
};
|
||
|
||
function getDefaultTables() {
|
||
log('从预设模板生成默认表格...', 'info');
|
||
const tables = JSON.parse(JSON.stringify(defaultTemplate.tables));
|
||
tables.forEach(table => {
|
||
table.charLimitRule = { columnIndex: -1, limit: 0 };
|
||
table.rowLimitRule = 0;
|
||
table.columnWidths = [];
|
||
});
|
||
return tables;
|
||
}
|
||
|
||
// ── 加载 ──────────────────────────────────────────────────────────────────
|
||
|
||
export function loadTables(stopIndex = -1) {
|
||
const context = getContext();
|
||
|
||
// 1. 优先从聊天记录中找已存的状态
|
||
if (context && context.chat && context.chat.length > 0) {
|
||
const startIndex = (stopIndex === -1 ? context.chat.length - 1 : stopIndex - 1);
|
||
for (let i = startIndex; i >= 0; i--) {
|
||
const message = context.chat[i];
|
||
if (message.extra && message.extra[TABLE_DATA_KEY]) {
|
||
log(`在第 ${i} 条消息中找到基准表格数据。`, 'info');
|
||
let loadedState = JSON.parse(JSON.stringify(message.extra[TABLE_DATA_KEY]));
|
||
|
||
loadedState.forEach(table => {
|
||
if (table.note === undefined) table.note = '无';
|
||
if (table.rule_add === undefined) table.rule_add = '允许';
|
||
if (table.rule_delete === undefined) table.rule_delete = '允许';
|
||
if (table.rule_update === undefined) table.rule_update = '允许';
|
||
|
||
// 多列规则兼容
|
||
if (table.charLimitRule && !table.charLimitRules) {
|
||
table.charLimitRules = {};
|
||
if (table.charLimitRule.columnIndex !== -1 && table.charLimitRule.limit > 0) {
|
||
table.charLimitRules[table.charLimitRule.columnIndex] = table.charLimitRule.limit;
|
||
}
|
||
}
|
||
delete table.charLimitRule;
|
||
|
||
if (table.rowLimitRule === undefined) table.rowLimitRule = 0;
|
||
if (table.columnWidths === undefined) table.columnWidths = [];
|
||
|
||
if (!table.rowStatuses) {
|
||
table.rowStatuses = Array(table.rows.length).fill('normal');
|
||
}
|
||
});
|
||
|
||
setState(loadedState);
|
||
dispatchAllTablesUpdate();
|
||
return getState();
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. 全局预设
|
||
if (extension_settings[extensionName]?.global_table_preset) {
|
||
log('未在聊天记录中找到表格,正在加载全局预设...', 'info');
|
||
try {
|
||
const globalPreset = extension_settings[extensionName].global_table_preset;
|
||
setState(JSON.parse(JSON.stringify(globalPreset.tables)));
|
||
|
||
if (globalPreset.batchFillerRuleTemplate !== undefined) {
|
||
_tplSaveBatchFillerRuleTemplate(globalPreset.batchFillerRuleTemplate);
|
||
}
|
||
if (globalPreset.batchFillerFlowTemplate !== undefined) {
|
||
_tplSaveBatchFillerFlowTemplate(globalPreset.batchFillerFlowTemplate);
|
||
}
|
||
|
||
dispatchAllTablesUpdate();
|
||
return getState();
|
||
} catch (error) {
|
||
log(`加载全局预设失败: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// 3. 默认模板
|
||
log('未找到任何表格数据或全局预设,使用默认模板。', 'info');
|
||
setState(getDefaultTables());
|
||
dispatchAllTablesUpdate();
|
||
return getState();
|
||
}
|
||
|
||
export function saveTables(sourceAction = '未知操作') {
|
||
log(`UI操作 "${sourceAction}" 已更新内存状态。`, 'info');
|
||
return true;
|
||
}
|
||
|
||
// ── 渲染 wrapper(注入当前 state) ────────────────────────────────────────
|
||
|
||
export function convertTablesToCsvString() {
|
||
let state = getState();
|
||
if (!state) {
|
||
loadTables();
|
||
state = getState();
|
||
}
|
||
return tablesToCsv(state);
|
||
}
|
||
|
||
export function convertSelectedTablesToCsvString(selectedIndices) {
|
||
let state = getState();
|
||
if (!state) {
|
||
loadTables();
|
||
state = getState();
|
||
}
|
||
return tablesToCsvWithSelection(state, selectedIndices);
|
||
}
|
||
|
||
export function convertTablesToCsvStringForContentOnly() {
|
||
return tablesToCsvContentOnly(getState());
|
||
}
|
||
|
||
// ── 模板(re-export) ─────────────────────────────────────────────────────
|
||
|
||
export const getBatchFillerRuleTemplate = _tplGetBatchFillerRuleTemplate;
|
||
export const saveBatchFillerRuleTemplate = _tplSaveBatchFillerRuleTemplate;
|
||
export const getBatchFillerFlowTemplate = _tplGetBatchFillerFlowTemplate;
|
||
export const saveBatchFillerFlowTemplate = _tplSaveBatchFillerFlowTemplate;
|
||
export const getAiFlowTemplateForInjection = _tplGetAiFlowTemplateForInjection;
|
||
export const saveAiTemplate = _tplSaveAiTemplate;
|
||
export const getAiTemplate = _tplGetAiTemplate;
|
||
|
||
// ── 文本指令应用(updateTableFromText) ───────────────────────────────────
|
||
|
||
export async function updateTableFromText(textContent, options = {}) {
|
||
const settings = extension_settings[extensionName] || {};
|
||
if (settings.table_system_enabled === false) {
|
||
log('表格系统总开关已关闭,跳过 <Amily2Edit> 标签处理。', 'info');
|
||
return;
|
||
}
|
||
|
||
if (!textContent) {
|
||
log('AI返回内容为空,无法更新表格。', 'warn');
|
||
return;
|
||
}
|
||
|
||
const { finalState, hasChanges, changes } = executeCommands(textContent, getState());
|
||
|
||
if (!hasChanges) {
|
||
log('AI指令未产生任何实质性变更。', 'info');
|
||
return;
|
||
}
|
||
|
||
setState(finalState);
|
||
|
||
if (options.immediateDelete) {
|
||
commitPendingDeletions();
|
||
}
|
||
|
||
changes.forEach(change => {
|
||
markTableUpdated(change.tableIndex);
|
||
if (change.type === 'update' || change.type === 'insert') {
|
||
if (change.rowIndex !== undefined && change.colIndex !== undefined) {
|
||
addHighlight(change.tableIndex, change.rowIndex, change.colIndex);
|
||
}
|
||
}
|
||
});
|
||
|
||
log(`成功执行了 ${changes.length} 处变更。`, 'success');
|
||
|
||
const affectedTables = [...new Set(changes.map(c => c.tableIndex))];
|
||
affectedTables.forEach(tableIndex => dispatchTableUpdate(tableIndex));
|
||
|
||
const context = getContext();
|
||
if (context.chat && context.chat.length > 0) {
|
||
const lastMessage = context.chat[context.chat.length - 1];
|
||
if (_persistSaveStateToMessage(getState(), lastMessage)) {
|
||
await saveChat();
|
||
toastr.success('已根据AI的指示成功更新表格!', '填表完成');
|
||
document.dispatchEvent(new CustomEvent('amily2-force-ui-reload'));
|
||
return;
|
||
}
|
||
}
|
||
|
||
saveChatDebounced();
|
||
toastr.success('已根据AI的指示成功更新表格!', '填表完成');
|
||
document.dispatchEvent(new CustomEvent('amily2-force-ui-reload'));
|
||
}
|
||
|
||
/**
|
||
* 直接从 Operation[] 应用变更(Function Call 路径),跳过文本解析。
|
||
* 后续流程与 updateTableFromText 完全一致。
|
||
*
|
||
* @param {import('./dto/Operation.js').Operation[]} ops
|
||
* @param {Object} options - 同 updateTableFromText 的 options
|
||
*/
|
||
export async function updateTableFromOps(ops, options = {}) {
|
||
const settings = extension_settings[extensionName] || {};
|
||
if (settings.table_system_enabled === false) return;
|
||
|
||
if (!Array.isArray(ops) || ops.length === 0) {
|
||
log('Function Call 返回操作列表为空,无需更新表格。', 'info');
|
||
return;
|
||
}
|
||
|
||
const { state, changes } = applyOperations(getState(), ops);
|
||
|
||
if (changes.length === 0) {
|
||
log('Function Call 操作未产生任何实质性变更。', 'info');
|
||
return;
|
||
}
|
||
|
||
setState(state);
|
||
|
||
if (options.immediateDelete) {
|
||
commitPendingDeletions();
|
||
}
|
||
|
||
changes.forEach(change => {
|
||
markTableUpdated(change.tableIndex);
|
||
if (change.type === 'update' || change.type === 'insert') {
|
||
if (change.rowIndex !== undefined && change.colIndex !== undefined) {
|
||
addHighlight(change.tableIndex, change.rowIndex, change.colIndex);
|
||
}
|
||
}
|
||
});
|
||
|
||
log(`Function Call 成功执行了 ${changes.length} 处变更。`, 'success');
|
||
|
||
const affectedTables = [...new Set(changes.map(c => c.tableIndex))];
|
||
affectedTables.forEach(tableIndex => dispatchTableUpdate(tableIndex));
|
||
|
||
const context = getContext();
|
||
if (context.chat && context.chat.length > 0) {
|
||
const lastMessage = context.chat[context.chat.length - 1];
|
||
if (_persistSaveStateToMessage(getState(), lastMessage)) {
|
||
await saveChat();
|
||
toastr.success('已根据AI的指示成功更新表格!', '填表完成');
|
||
document.dispatchEvent(new CustomEvent('amily2-force-ui-reload'));
|
||
return;
|
||
}
|
||
}
|
||
|
||
saveChatDebounced();
|
||
toastr.success('已根据AI的指示成功更新表格!', '填表完成');
|
||
document.dispatchEvent(new CustomEvent('amily2-force-ui-reload'));
|
||
}
|
||
|
||
// ── 预设(re-export 或 wrapper) ─────────────────────────────────────────
|
||
|
||
export const exportPreset = _presetExportPreset;
|
||
export const exportPresetFull = _presetExportPresetFull;
|
||
export const clearGlobalPreset = _presetClearGlobalPreset;
|
||
export const importGlobalPreset = _presetImportGlobalPreset;
|
||
|
||
/**
|
||
* importPreset wrapper:在 setState 之后注入 SuperMemory 全量同步。
|
||
* 兼容旧签名 importPreset(callback) 和新 importPreset({ onAfterApply, onImported })。
|
||
*/
|
||
export function importPreset(onImportedOrHooks) {
|
||
/** @type {{ onAfterApply?: () => void, onImported?: () => void }} */
|
||
const hooks = typeof onImportedOrHooks === 'function'
|
||
? { onImported: onImportedOrHooks }
|
||
: (onImportedOrHooks || {});
|
||
|
||
return _presetImportPreset({
|
||
onAfterApply: () => {
|
||
dispatchAllTablesUpdate();
|
||
if (hooks.onAfterApply) hooks.onAfterApply();
|
||
},
|
||
onImported: hooks.onImported,
|
||
});
|
||
}
|
||
|
||
// ── 回滚 ──────────────────────────────────────────────────────────────────
|
||
|
||
export async function rollbackState() {
|
||
const context = getContext();
|
||
if (!context || !context.chat || context.chat.length < 2) {
|
||
log('无法回退:聊天记录不足。', 'warn');
|
||
toastr.warning('聊天记录不足,无法执行回退操作。');
|
||
return false;
|
||
}
|
||
|
||
const chat = context.chat;
|
||
const lastMessageIndex = chat.length - 1;
|
||
const lastMessage = chat[lastMessageIndex];
|
||
|
||
log(`正在尝试从第 ${lastMessageIndex - 1} 条消息加载表格状态...`, 'info');
|
||
const previousState = loadTables(lastMessageIndex);
|
||
|
||
if (!previousState) {
|
||
log('未能在上一楼找到可用的表格状态,无法回退。', 'error');
|
||
toastr.error('未能在上一楼找到可用的表格状态。');
|
||
return false;
|
||
}
|
||
|
||
setState(previousState);
|
||
if (_persistSaveStateToMessage(previousState, lastMessage)) {
|
||
await saveChat();
|
||
log('已成功将回退后的状态保存至最新消息。', 'success');
|
||
} else {
|
||
log('回退状态保存失败,操作中止。', 'error');
|
||
toastr.error('未能保存回退状态,操作中止。');
|
||
return false;
|
||
}
|
||
|
||
renderTables();
|
||
updateOrInsertTableInChat();
|
||
log('UI已更新以显示回退后的状态。', 'info');
|
||
return true;
|
||
}
|
||
|
||
export async function rollbackAndRefill() {
|
||
const settings = extension_settings[extensionName] || {};
|
||
if (settings.table_system_enabled === false) {
|
||
log('表格系统总开关已关闭,跳过回退填表。', 'info');
|
||
toastr.info('表格系统总开关已关闭,无法执行回退填表。');
|
||
return;
|
||
}
|
||
|
||
toastr.info('正在执行回退并重新填表...');
|
||
const rollbackSuccess = await rollbackState();
|
||
|
||
if (!rollbackSuccess) {
|
||
toastr.error('状态回退失败,已中止操作。');
|
||
return;
|
||
}
|
||
|
||
toastr.success('状态回退成功,准备重新填表...');
|
||
|
||
const context = getContext();
|
||
const lastMessage = context.chat[context.chat.length - 1];
|
||
|
||
try {
|
||
await fillWithSecondaryApi(lastMessage, true, { targetMessage: lastMessage });
|
||
log('回退并重新填表操作完成。', 'success');
|
||
} catch (error) {
|
||
log(`回退重填过程中发生错误: ${error.message}`, 'error');
|
||
toastr.error(`重新填表失败: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
// ── 杂项 ──────────────────────────────────────────────────────────────────
|
||
|
||
export function isCurrentTablesEmpty() {
|
||
const tables = getState();
|
||
if (!tables || tables.length === 0) return true;
|
||
return tables.every(table => !table.rows || table.rows.length === 0);
|
||
}
|
||
|
||
// ── 模块初始化 ─────────────────────────────────────────────────────────────
|
||
|
||
// 模块加载时执行一次初始 loadTables
|
||
loadTables();
|