From 50ee8cc8ed985c8a6cda95180f8dd1527a61b673 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Tue, 24 Mar 2026 12:38:46 +0800 Subject: [PATCH] Implement vector recovery and refresh docs --- README.md | 1191 +++++++++++++++++-------------------- compressor.js | 3 +- evolution.js | 27 +- extractor.js | 46 +- graph.js | 73 ++- index.js | 794 +++++++++++++++++++++---- panel.html | 90 ++- panel.js | 95 +++ retriever.js | 24 +- runtime-state.js | 352 +++++++++++ tests/runtime-history.mjs | 66 ++ tests/vector-config.mjs | 71 +++ vector-index.js | 641 ++++++++++++++++++++ 13 files changed, 2668 insertions(+), 805 deletions(-) create mode 100644 runtime-state.js create mode 100644 tests/runtime-history.mjs create mode 100644 tests/vector-config.mjs create mode 100644 vector-index.js diff --git a/README.md b/README.md index 31b9309..b52ccce 100644 --- a/README.md +++ b/README.md @@ -1,169 +1,209 @@ -# ST-BME +# ST-BME Memory Graph -> 面向 [SillyTavern](https://github.com/SillyTavern/SillyTavern) 的图谱记忆扩展。它把聊天过程转成结构化记忆图,再在生成前按场景召回并注入,服务于长对话、角色扮演、持续剧情和世界观管理。 +> 面向 SillyTavern 第三方扩展体系的图谱记忆插件。 +> 它把聊天历史转换成结构化记忆图,在生成前按场景召回并注入,服务于长期 RP、持续剧情、角色状态维护和多轮世界观演化。 -## 项目概览 +## 1. 项目定位 -ST-BME 的全称是 **ST-Bionic-Memory-Ecology**。它不是一个独立的后端服务,也不是一个通用数据库,而是一个运行在 SillyTavern 第三方扩展体系内的前端记忆层。 +ST-BME 的核心目标不是“把所有聊天全文塞回上下文”,而是把对话中的长期信息拆成可维护、可更新、可压缩、可检索的记忆图。 -它主要做两件事: +当前版本坚持 3 个原则: -1. **写入**:从聊天内容中抽取结构化记忆,存入当前聊天对应的知识图谱。 -2. **读取**:在下一次生成前,从图谱中找出与当前输入最相关的记忆,并把它们整理成适合模型理解的上下文片段。 +1. **不改酒馆本体** + - 作为可发布的第三方扩展运行。 + - 不要求用户给 SillyTavern 打补丁。 -项目目标不是替代大模型的原生上下文能力,而是为长期互动补上一层可累计、可更新、可压缩、可检索的外部记忆系统。 +2. **图谱真相源留在聊天元数据** + - 图谱主体继续跟随当前聊天保存在 `chat_metadata.st_bme_graph`。 + - 不把图谱主数据迁移到独立数据库或服务器文件。 -## 这个项目解决什么问题 +3. **向量与图谱解耦** + - 图谱负责结构、时序、压缩、召回分层。 + - 向量只负责语义候选预筛,可以走后端索引,也可以走独立直连兜底。 -在长期 RP 或持续陪伴式对话里,模型通常会遇到几类典型问题: +--- -- 早期剧情、角色状态、地点细节很快被上下文窗口挤掉。 -- 模型会记得“有这回事”,但不容易稳定地记住“谁在什么时候做了什么、现在又变成什么状态”。 -- 角色状态和地点状态会随剧情变化,旧信息与新信息容易混杂。 -- 世界规则、主线目标、前情提要常常需要持续注入,但又不能把所有历史全文塞回 prompt。 +## 2. 当前能力概览 -ST-BME 的思路是把“聊天历史”变成“图谱化记忆”: +### 2.1 写入能力 -- 事件、角色、地点、规则、主线、概要、反思都以节点表示。 -- 节点之间通过关系边连接,形成可扩散的结构。 -- 重要、常驻的信息直接进入 Core 注入。 -- 与当前用户输入强相关的状态和补充记忆再走召回注入。 +- assistant 回复后自动提取未处理楼层 +- LLM 结构化输出 `create / update / delete` +- 支持事件、角色、地点、规则、主线、概要、反思节点 +- 支持时序边、更新边、矛盾边 +- 支持 Mem0 风格近邻对照,减少重复写入 +- 支持 A-MEM 风格记忆进化 +- 支持全局概要生成 +- 支持反思条目生成 +- 支持主动遗忘 +- 支持层级压缩 -## 项目边界 +### 2.2 读取能力 -当前实现很明确地有以下边界: +- 生成前自动召回记忆 +- 向量预筛 +- 图扩散 +- 混合评分 +- 可选 LLM 精确召回 +- 场景重构 +- 分桶注入:状态记忆 / 情景事件 / 反思锚点 / 规则约束 -- **单聊天作用域**:每个聊天维护一份独立图谱,图状态挂在当前聊天 `chat_metadata` 下。 -- **无独立数据库**:没有额外服务端存储层,所有持久化都依赖 SillyTavern 的聊天元数据保存机制。 -- **LLM 与 Embedding 分离**: - - 结构化提取、精确召回、压缩、进化、概要、反思都通过 ST 内部的 `sendOpenAIRequest('quiet', ...)` 调用聊天模型。 - - 向量检索依赖单独配置的 OpenAI 兼容 Embedding API。 -- **图谱是工程化记忆,不是事实真相库**:它依赖 LLM 的结构化输出质量,因此仍然存在抽取偏差、更新遗漏、关系误判等风险。 +### 2.3 运维与安全能力 -## 运行依赖 +- 新聊天、分支聊天、切换聊天自动隔离 +- 支持图谱导入导出 +- 支持图谱全量重建 +- 支持向量全量重建 +- 支持向量范围重建 +- 支持直连模式全量重嵌 +- 支持历史回退检测与自动恢复 -| 依赖 | 是否必需 | 作用 | +--- + +## 3. 系统架构 + +### 3.1 三层结构 + +ST-BME 当前可以理解成三层: + +1. **图谱真相层** + - 存储在当前聊天 `chat_metadata.st_bme_graph` + - 保存节点、边、层级压缩关系、上次召回结果等 + +2. **运行时状态层** + - 仍然跟随图谱一起保存在 `chat_metadata` + - 保存历史处理指针、楼层 hash、脏区、向量索引映射、batch journal + +3. **向量候选层** + - `backend` 模式:使用酒馆现成 `/api/vector/*` + - `direct` 模式:使用插件自己的 OpenAI-compatible Embedding 直连 + +### 3.2 为什么不把图谱挪到服务器 + +因为插件发布要求不能改 SillyTavern 本体,而当前插件最重要的“聊天作用域绑定、聊天分支隔离、导入导出图谱、随着聊天一起迁移”这几件事,天然都和 `chat_metadata` 更契合。 + +所以当前设计是: + +- **图谱本体**:继续在 `chat_metadata` +- **插件设置**:保存到服务器文件 `st-bme-settings.json` +- **向量索引**:按模式选择后端索引或前端直连 + +--- + +## 4. 与 SillyTavern 的集成方式 + +主入口在 [index.js](./index.js)。 + +插件不是轮询式运行,而是挂在 SillyTavern 的事件周期上: + +| ST 事件 | 插件逻辑 | 作用 | | --- | --- | --- | -| SillyTavern 第三方扩展系统 | 必需 | 提供事件钩子、设置存储、聊天上下文、Prompt 注入接口 | -| 当前可用的聊天模型 | 必需 | 用于提取、精确召回、压缩、进化、概要、反思等所有 LLM 子任务 | -| OpenAI 兼容 Embedding API | 向量检索相关功能必需 | 用于节点 embedding、向量预筛、Mem0 风格近邻对照、记忆进化近邻搜索 | -| 当前聊天元数据 | 必需 | 存储图谱状态、最后处理楼层、最后召回结果 | +| `CHAT_CHANGED` | `onChatChanged()` | 切换聊天时重新加载该聊天图谱与运行时状态 | +| `GENERATION_AFTER_COMMANDS` | `runExtraction()` | assistant 回复后提取新记忆 | +| `GENERATE_BEFORE_COMBINE_PROMPTS` | `runRecall()` | 生成前召回并注入 | +| `MESSAGE_RECEIVED` | `onMessageReceived()` | 新消息到达时保存图状态 | +| `MESSAGE_DELETED` / `MESSAGE_EDITED` / `MESSAGE_SWIPED` / `MESSAGE_UPDATED` | 历史变动检测 | 触发“先止损,再恢复” | -## 系统总览 +这意味着: + +- **写入发生在回复之后** +- **读取发生在下一轮生成之前** +- **删楼层、编辑、切 swipe** 会被当作历史变动,而不是简单忽略 + +--- + +## 5. 数据结构 + +### 5.1 图谱主键 + +图谱键名固定为: ```text -聊天消息 - ├─ assistant 回复完成后 - │ └─ ST-BME 提取未处理片段 - │ ├─ LLM 生成 create/update/delete 操作 - │ ├─ 执行图谱写入 - │ ├─ 生成缺失 embedding - │ ├─ 可选执行进化 / 概要 / 反思 / 遗忘 / 压缩 - │ └─ 保存回 chat_metadata - │ - └─ 下次生成前 - └─ ST-BME 检索当前图谱 - ├─ 可见性过滤 - ├─ 向量预筛 - ├─ 图扩散 - ├─ 混合评分 - ├─ 可选 LLM 精确召回 - ├─ 场景重构 - └─ 格式化为注入文本并送入 prompt +chat_metadata.st_bme_graph ``` -## 与 SillyTavern 的集成方式 - -ST-BME 的主入口在 [index.js](./index.js)。它不是轮询式工作的,而是绑定在 SillyTavern 的事件生命周期上: - -| ST 事件 | 对应逻辑 | 作用 | -| --- | --- | --- | -| `CHAT_CHANGED` | `onChatChanged()` | 切换聊天时重新加载该聊天的图谱 | -| `GENERATION_AFTER_COMMANDS` | `runExtraction()` | assistant 回复完成后,处理尚未提取的内容 | -| `GENERATE_BEFORE_COMBINE_PROMPTS` | `runRecall()` | 下一轮生成前召回记忆并注入 | -| `MESSAGE_RECEIVED` | `onMessageReceived()` | 新消息到达时保存当前图状态 | - -这意味着 ST-BME 的运行时机非常清楚: - -- **写入发生在回复之后**,记录刚刚发生了什么。 -- **读取发生在下一次生成之前**,决定接下来模型应该看见哪些记忆。 - -## 数据存储与持久化 - -图谱键名固定为 `st_bme_graph`,存储在当前聊天的 `chat_metadata` 中。 - -图状态的核心结构如下: +### 5.2 图状态核心字段 | 字段 | 含义 | | --- | --- | -| `version` | 图数据版本号,当前实现为 v3 | -| `lastProcessedSeq` | 已处理到的聊天楼层索引 | -| `nodes` | 全部节点,包括活跃和归档节点 | -| `edges` | 全部关系边,包括失效边和历史边 | -| `lastRecallResult` | 最近一次召回选中的节点 ID 列表 | +| `version` | 图数据版本,当前为 v4 | +| `lastProcessedSeq` | 兼容字段,表示已处理到的 assistant 楼层 | +| `nodes` | 全部节点 | +| `edges` | 全部关系边 | +| `lastRecallResult` | 最近一次召回节点 ID | +| `historyState` | 历史处理与恢复状态 | +| `vectorIndexState` | 向量索引状态 | +| `batchJournal` | 批次恢复日志 | -图数据由 [graph.js](./graph.js) 管理,支持: +### 5.3 historyState -- 空图创建 -- 节点/边增删改查 -- 时序链表维护 -- 时序边失效处理 -- 版本迁移与兼容反序列化 -- 导入导出 - -### 节点公共字段 - -所有节点都会带有一套统一元数据: - -| 字段 | 说明 | +| 字段 | 含义 | | --- | --- | -| `id` | UUID | -| `type` | 节点类型 | -| `level` | 压缩层级,原始节点为 0 | -| `parentId` / `childIds` | 压缩层级父子关系 | -| `seq` | 该节点对应的主楼层索引 | -| `seqRange` | 节点覆盖的楼层范围 | -| `archived` | 是否归档 | -| `fields` | 业务字段主体 | -| `embedding` | 向量表示 | -| `importance` | 重要性,范围 0-10 | -| `accessCount` | 被召回/注入的访问次数 | -| `lastAccessTime` | 最近被访问时间 | -| `createdTime` | 节点创建时间 | -| `prevId` / `nextId` | 同类型节点的时间链表 | -| `clusters` | 额外标签/聚类信息 | +| `chatId` | 当前聊天标识 | +| `lastProcessedAssistantFloor` | 已处理到的 assistant 楼层 | +| `processedMessageHashes` | 已处理区间的楼层 hash 快照 | +| `historyDirtyFrom` | 检测到历史变动后的最早脏楼层 | +| `lastMutationReason` | 最近一次脏化原因 | +| `lastRecoveryResult` | 最近一次恢复结果 | -### 边公共字段 +### 5.4 vectorIndexState -| 字段 | 说明 | +| 字段 | 含义 | | --- | --- | -| `id` | UUID | -| `fromId` / `toId` | 边起点和终点 | -| `relation` | 关系类型 | -| `strength` | 边强度,范围 0-1 | -| `edgeType` | 边类型标记,`255` 表示抑制边 | -| `createdTime` | 创建时间 | -| `validAt` | 生效时间 | -| `invalidAt` | 失效时间 | -| `expiredAt` | 系统标记过期时间 | +| `mode` | `backend` 或 `direct` | +| `collectionId` | 当前聊天的向量集合 ID,固定为 `st-bme::` | +| `source` | 当前向量源 | +| `modelScope` | 当前向量模型作用域签名 | +| `hashToNodeId` | 向量 hash -> 节点 ID 映射 | +| `nodeToHash` | 节点 ID -> 向量 hash 映射 | +| `dirty` | 当前向量索引是否待重建 | +| `lastSyncAt` | 上次同步时间 | +| `lastStats` | 向量状态统计 | +| `lastWarning` | 最近一次向量告警 | -其中 `contradicts` 关系会被映射成抑制边,后续在扩散阶段会传递负能量。 +### 5.5 batchJournal -## 默认 Schema +每次写入批次都会记录恢复信息。它不是审计日志,而是为了在历史回退时执行“受影响后缀回滚 + 重放”。 -默认 Schema 定义在 [schema.js](./schema.js)。它不仅定义了字段,还定义了注入策略、更新策略和压缩策略。 +当前 journal 包含: -| 类型 | 作用 | `alwaysInject` | `latestOnly` | 压缩 | 说明 | -| --- | --- | --- | --- | --- | --- | -| `event` | 事件、动作、阶段推进 | 是 | 否 | 分层压缩 | 当前实现里属于 Core 常驻注入 | -| `character` | 角色状态快照 | 否 | 是 | 不压缩 | 同名 `create` 会转成 `update` | -| `location` | 地点状态快照 | 否 | 是 | 不压缩 | 同名 `create` 会转成 `update` | -| `rule` | 规则、约束、世界设定 | 是 | 否 | 不压缩 | 常驻注入 | -| `thread` | 主线或任务线 | 是 | 否 | 分层压缩 | 常驻注入 | -| `synopsis` | 全局前情提要 | 是 | 是 | 不压缩 | 只保留最新一条概要 | -| `reflection` | 高层反思与长期提示 | 否 | 否 | 分层压缩 | 通过召回进入上下文 | +- `processedRange` +- `createdNodeIds` +- `createdEdgeIds` +- `updatedNodeSnapshots` +- `archivedNodeSnapshots` +- `invalidatedEdgeSnapshots` +- `vectorHashesInserted` +- `postProcessArtifacts` +- `snapshotBefore` -### 关系类型 +其中 `postProcessArtifacts` 用于标记该批次是否额外触发了: + +- `evolution` +- `synopsis` +- `reflection` +- `sleep` +- `compression` + +--- + +## 6. 节点与关系 + +默认 Schema 定义在 [schema.js](./schema.js)。 + +### 6.1 节点类型 + +| 类型 | 用途 | 常驻注入 | 备注 | +| --- | --- | --- | --- | +| `event` | 事件、动作、剧情推进 | 是 | 支持层级压缩 | +| `character` | 角色状态 | 否 | 同名会优先 update | +| `location` | 地点状态 | 否 | 同名会优先 update | +| `rule` | 世界规则、约束 | 是 | 常驻注入 | +| `thread` | 主线/任务线 | 是 | 支持层级压缩 | +| `synopsis` | 全局前情提要 | 是 | 只保留最新 | +| `reflection` | 反思与长期锚点 | 否 | 支持层级压缩 | + +### 6.2 关系类型 默认关系类型包括: @@ -176,329 +216,119 @@ ST-BME 的主入口在 [index.js](./index.js)。它不是轮询式工作的, - `evolves` - `temporal_update` -## 写入链路详解 +其中: -写入逻辑主要集中在 [index.js](./index.js) 和 [extractor.js](./extractor.js)。 +- `contradicts` 用于矛盾/冲突 +- `updates` 与 `temporal_update` 用于状态更新和时序替代 +- `evolves` 用于新信息影响旧记忆的理解 -### 1. 提取触发条件 +--- -ST-BME 只统计 **assistant 消息** 来决定何时提取。 +## 7. 写入流程 -默认策略: +写入主流程分为 6 步。 -- `extractEvery = 1` 时,每 1 条 assistant 回复提取一次。 -- `lastProcessedSeq` 记录的是聊天数组索引,因此它用的是“楼层”语义,而不是消息 ID。 +### 7.1 触发 -如果开启 `enableSmartTrigger`,则会在普通频率判断外再做一次轻量触发评分。评分来源包括: +默认按 assistant 楼层触发: -- 命中默认关键词 -- 命中自定义正则 -- 用户与助手多轮往返 -- 感叹号/问号等情绪波动 -- 疑似新实体/新地点 +- `extractEvery = 1`:每 1 条 assistant 回复提取一次 +- 若启用 `enableSmartTrigger`,则可提前触发 -当评分达到阈值时,即使未到 `extractEvery`,也会直接处理所有待处理 assistant 楼层。 +### 7.2 上下文打包 -### 2. 提取上下文打包 - -真正送去提取的不是单条消息,而是一段上下文窗口。 - -当前实现会: - -- 找到本批 assistant 楼层的起止索引 `startIdx` / `endIdx` -- 从 `startIdx - extractContextTurns * 2` 开始回溯 -- 取到 `endIdx` 为止的非系统消息 -- 用 `#楼层 [role]: content` 的形式拼成对话文本 - -这样做的目的,是让 LLM 在提取时看到足够的上下文因果关系,而不是孤立处理单条回复。 - -### 3. LLM 结构化提取 - -提取调用在 [llm.js](./llm.js) 中统一封装,要求模型返回严格 JSON,核心产物是: - -```json -{ - "thought": "对这一批对话的理解", - "operations": [ - { - "action": "create", - "type": "event", - "fields": { - "summary": "......" - } - } - ] -} -``` - -当前默认提示词会约束模型: - -- 支持的节点类型必须来自当前 Schema -- 每批对话最多创建 1 个事件节点 -- 角色/地点优先更新已有同名节点,而不是无脑新建 -- 关系类型必须来自允许列表 -- `importance` 落在 1-10 -- `summary` 应该是抽象摘要,而非原文复制 - -### 4. Mem0 风格精确对照 - -如果开启 `enablePreciseConflict` 且配置了 Embedding API,则在正式执行操作前,会先对所有 `create` 操作做近邻对照: - -1. 对新事实文本生成 embedding。 -2. 在已有活跃节点中搜最相似近邻。 -3. 若最高相似度超过阈值,则调用 LLM 判断: - - `add` - - `update` - - `skip` - -这一步的目标是降低重复节点、弱冲突节点和应该被视作“状态更新”的伪新增节点。 - -### 5. 操作执行语义 - -#### `create` - -- 正常创建新节点。 -- 若类型是 `latestOnly` 且存在同名旧节点,则自动转为更新旧节点。 -- 同批次创建的节点支持通过 `ref` / `targetRef` 建立链接。 - -#### `update` - -更新的处理比普通字段覆盖更复杂,当前实现还会补出一层“可追踪更新语义”: - -- 合并字段更新。 -- 刷新 `seq` 与 `seqRange`。 -- 清空 embedding,等待后续重建。 -- 失效掉旧的 `updates` / `temporal_update` 关系边。 -- 若存在 `sourceNodeId`,则补建新的 `temporal_update` 边。 -- 根据字段差异自动生成一条新的 `event` 节点,摘要形如 `field: before -> after`。 -- 再用 `updates` 边把这个事件挂回被更新节点。 - -这让“状态变化”不只是覆盖写入,还能留下被检索的更新痕迹。 - -#### `delete` - -当前是**软删除**,不会真的移除节点,只是把节点标记为 `archived = true`。 - -### 6. Embedding 补齐 - -提取结束后,系统会为所有缺少 embedding 的活跃节点批量生成向量。拼接文本时优先使用: - -- `summary` -- `name` -- `title` -- `traits` -- `state` -- `constraint` - -如果这些字段都没有,就退化为节点类型名。 - -### 7. 提取后增强流程 - -在一次成功提取后,当前实现还可能继续执行以下步骤: - -#### 记忆进化 `enableEvolution` - -新节点写入后,[evolution.js](./evolution.js) 会: - -1. 为新节点找近邻旧节点。 -2. 调用 LLM 判断新信息是否改变了对旧记忆的理解。 -3. 若需要: - - 给新旧节点补链接 - - 回溯更新旧节点的 `state` / `summary` / `core_note` - - 更新旧节点的 `clusters` - - 记录 `_evolutionHistory` - -#### 全局概要 `enableSynopsis` - -每 `synopsisEveryN` 次提取后,会基于: - -- 事件时间线 -- 角色状态 -- 主线状态 - -生成或更新一个 `synopsis` 节点,用于充当前情提要锚点。 - -#### 反思条目 `enableReflection` - -每 `reflectEveryN` 次提取后,会基于: - -- 最近事件 -- 近期角色状态 -- 当前主线 -- 已知矛盾边 - -生成 `reflection` 节点,并用 `evolves` 边连接到最近事件。 - -#### 主动遗忘 `enableSleepCycle` - -[compressor.js](./compressor.js) 中的 `sleepCycle()` 会按保留价值归档低价值节点。当前保留价值大致由以下因素决定: - -- 重要性 -- 新近性 -- 访问频率 - -规则、概要、主线和高重要性节点默认不会被遗忘。 - -#### 层级压缩 - -对支持分层压缩的类型,系统会: - -- 从最低层级开始扫描 -- 只压缩超过阈值且不属于“最近保留叶子”的旧节点 -- 以 `fanIn` 为批次调用 LLM 总结 -- 新建更高层级节点 -- 把被压缩子节点归档,并建立 `parentId` / `childIds` - -当前默认支持分层压缩的类型是: - -- `event` -- `thread` -- `reflection` - -## 读取链路详解 - -读取逻辑主要集中在 [retriever.js](./retriever.js) 和 [injector.js](./injector.js)。 - -### 1. 活跃节点筛选 - -读取开始前会先获取当前图中的活跃节点,并过滤掉: - -- `archived = true` 的节点 -- `seqRange` 不完整的异常节点 - -如果启用了 `enableVisibility`,还会根据节点 `fields.visibility` 进行认知边界过滤: - -- 支持数组形式 -- 支持逗号分隔字符串 -- 支持 `*` 通配 -- 当前视角默认取 `context.name2` - -### 2. 自适应检索策略 - -检索策略会根据活跃节点规模自动调整: - -| 活跃节点数 | 检索策略 | -| --- | --- | -| `< 20` | 不做向量预筛,所有节点参与评分,可选直接走 LLM 精确召回 | -| `20 - 200` | 向量预筛 + 图扩散 + 混合评分,默认不走 LLM 精确召回 | -| `> 200` | 向量预筛 + 图扩散 + 混合评分 + LLM 精确召回 | - -这套阈值定义在 [retriever.js](./retriever.js) 的 `STRATEGY_THRESHOLDS` 中。 - -### 3. 向量预筛 - -如果图规模达到阈值且配置了 Embedding API,会: - -1. 对当前用户输入生成 query embedding。 -2. 对已有节点 embedding 做暴力余弦相似度检索。 -3. 取 Top-K 作为候选。 - -当前实现明确采用暴力搜索,而不是 HNSW/ANN,因为它假设 ST 使用场景通常是中小图规模。 - -### 4. 实体锚点与交叉检索 - -系统会额外从用户输入中做一层简单实体锚定: - -- 如果消息里直接出现了某个节点的 `name` 或 `title` -- 就把它视为一个高能量种子 - -如果开启 `enableCrossRecall`,则会进一步: - -- 沿着这些实体节点的有效边展开 -- 找到相邻的 `event` 节点 -- 把它们也作为附加扩散种子 - -这一步更偏向“场景联想”,而不是单纯语义相似。 - -### 5. 图扩散 - -[diffusion.js](./diffusion.js) 实现了一个轻量版 PEDSA 扩散引擎: - -- 从种子节点出发传播能量 -- 每步乘衰减因子 -- 只保留 Top-K 活跃节点 -- 抑制边会传递负能量 -- 能量值会被钳位到固定区间 - -当前默认配置: - -- 最多 2 步扩散 -- 衰减系数 0.6 -- 每步最多保留 100 个活跃节点 - -### 6. 混合评分 - -混合评分公式定义在 [dynamics.js](./dynamics.js): +插件不是只看一条回复,而是从本批次 assistant 楼层向前回看若干轮,把非系统消息整理成: ```text -FinalScore = (GraphScore * alpha + VectorScore * beta + ImportanceNorm * gamma) * TimeDecay +#12 [user]: ... +#13 [assistant]: ... ``` -默认权重为: +### 7.3 结构化提取 -- `graphWeight = 0.6` -- `vectorWeight = 0.3` -- `importanceWeight = 0.1` +提取器要求记忆 LLM 返回严格 JSON: -时间衰减采用对数衰减,而不是快速指数衰减,目的是让久远但重要的记忆不要掉得太快。 +- `create` +- `update` +- `delete` -### 7. LLM 精确召回 +如果 LLM 返回非法 JSON,会自动重试。 -在小图或大图场景下,如果开启 `recallEnableLLM`,系统会: +### 7.4 精确对照 -1. 先把候选节点按混合得分排好。 -2. 取前 30 个以内节点作为候选池。 -3. 把最近对话、用户最新输入、候选节点字段摘要一起喂给 LLM。 -4. 让 LLM 输出最终选中的节点 ID 列表。 +若向量配置可用,会对新建记忆做近邻对照: -如果 LLM 召回失败,则回退到纯评分排序结果。 +- 完全重复:跳过 +- 是旧记忆修正:转成 `update` +- 真正新信息:保留 `create` -### 8. 场景重构 +### 7.5 图谱副作用 -在得到初始召回节点后,系统不会立刻结束,而是还会做一次“场景补全”: +提取完成后,可能继续触发: -- 若命中的是 `event`,会补入与该事件直接相关的角色、地点、主线、反思节点,以及时间上最邻近的事件。 -- 若命中的是 `character` / `location`,会先找其关联事件,再围绕这些事件继续补场景。 +- 记忆进化 +- 全局概要 +- 反思条目 +- 主动遗忘 +- 层级压缩 -这一步的目标,是避免只召回一个孤立节点,尽量把一个能被模型理解的局部情境一起带回来。 +### 7.6 写入日志与向量同步 -### 9. 概率触发回忆 +批次完成后会: -如果开启 `enableProbRecall`,系统还会从未选中的高重要性节点里抽少量候选,并按概率追加进结果。这更像是“偶发闪回”,用于给长期剧情增加一点远程记忆回流。 +1. 同步向量状态 +2. 记录 `batchJournal` +3. 更新已处理楼层与楼层 hash +4. 保存回 `chat_metadata` -### 10. 访问强化 +--- -被最终选中的节点会执行访问强化: +## 8. 读取流程 -- `accessCount + 1` -- `importance + 0.1` -- 更新时间 `lastAccessTime` +召回逻辑主要在 [retriever.js](./retriever.js)。 -这使得经常被召回、反复证明有用的节点,后续更容易继续存活和命中。 +### 8.1 总体流程 -## 注入策略 +```text +用户输入 + -> 向量候选预筛 + -> 图扩散 + -> 混合评分 + -> 可选 LLM 精排 + -> 场景重构 + -> 注入格式化 +``` -注入文本由 [injector.js](./injector.js) 生成,格式是 Markdown 表格,主要分为两部分: +### 8.2 候选预筛 -### 1. Core 常驻注入 +这里是本次版本最重要的变化之一: -凡是 Schema 中 `alwaysInject = true` 的类型,都会直接进入 Core: +- `backend` 模式:通过酒馆 `/api/vector/query` +- `direct` 模式:插件自己请求 Embedding API,再做余弦相似度 -- `event` -- `rule` -- `thread` -- `synopsis` +两种模式都会把结果统一成 `[{ nodeId, score }]`,后续流程不区分。 -这意味着当前默认设计并不是“所有东西都走检索”,而是: +### 8.3 图扩散与混合评分 -- **叙事主干**直接常驻 -- **状态与补充记忆**按需召回 +候选节点进入图扩散后,会结合: -这是当前实现最值得注意的一个架构选择。 +- 图扩散能量 +- 向量得分 +- 节点重要性 +- 时间衰减 -### 2. Recalled 召回注入 +最后得到综合排序。 -非 `alwaysInject` 且被选中的节点会进入召回区,并按桶组织: +### 8.4 注入格式 + +注入模块在 [injector.js](./injector.js)。 + +它会把结果分成: + +- `Core` 常驻注入 +- `Recalled` 动态召回注入 + +并进一步分桶为: - 当前状态记忆 - 情景事件记忆 @@ -506,270 +336,361 @@ FinalScore = (GraphScore * alpha + VectorScore * beta + ImportanceNorm * gamma) - 规则与约束 - 其他关联记忆 -在默认 Schema 下,召回区最常见的其实是: +--- -- `character` -- `location` -- `reflection` +## 9. 向量模式 -因为事件、规则、主线、概要默认都属于 Core。 +当前版本支持两种 Embedding 工作模式。 -### 3. Token 估算 +### 9.1 backend 模式 -注入完成后,系统会做一个粗略 token 估算,便于观察注入体积。当前估算规则大致是: +适用场景: -- 2 个中文字符约等于 1 token -- 4 个英文字符约等于 1 token +- 希望尽量走酒馆后端 +- 希望发布后少受浏览器跨域限制 +- 向量 provider 在酒馆现成支持范围内 -## 一个完整运行示例 +支持来源: -下面用一个简化示例说明从聊天到图谱、再到召回的大致闭环: +- `openai` +- `openrouter` +- `cohere` +- `mistral` +- `electronhub` +- `chutes` +- `nanogpt` +- `ollama` +- `llamacpp` +- `vllm` -1. 用户说:“我们先去钟楼看看,之前失踪案很可能和那里有关。” -2. 助手回复了一段剧情,描述角色艾琳进入钟楼,发现地下暗门。 -3. 这轮回复结束后,提取器可能产出: - - 一个 `event`:艾琳在钟楼发现地下入口 - - 一个 `location`:钟楼,状态为存在隐藏入口 - - 一个 `thread`:失踪案调查,状态推进 -4. 如果图中本来就有“钟楼”地点节点,则该地点不会重复创建,而会变成更新。 -5. 新节点生成后,系统补 embedding,并可能触发: - - 记忆进化:修正旧事件对钟楼的理解 - - 全局概要:更新前情提要 -6. 下一轮用户问:“地下入口会不会和之前失踪的人有关?” -7. 召回阶段会: - - 命中“地下入口”“失踪”等语义相关节点 - - 把钟楼、相关事件、最近主线等一起拉回 - - 再用注入表格告诉模型当前关键情境 -8. 模型在生成时,就不只是看当前一句话,而是能同时看到: - - 最近核心事件 - - 当前地点/角色状态 - - 当前主线和概要 +实现方式: -## 功能清单与成熟度 +- 使用酒馆 `/api/vector/insert` +- 使用酒馆 `/api/vector/query` +- 使用酒馆 `/api/vector/delete` +- 使用酒馆 `/api/vector/purge` -### 已实现主链路 +说明: -| 功能 | 当前状态 | 说明 | -| --- | --- | --- | -| 聊天级图谱持久化 | 已实现 | 图谱跟随当前聊天保存与切换 | -| LLM 结构化提取 | 已实现 | 支持 `create/update/delete` | -| 节点 embedding 生成 | 已实现 | 依赖外部 Embedding API | -| 向量预筛 | 已实现 | 余弦相似度暴力检索 | -| 图扩散排序 | 已实现 | PEDSA 风格轻量扩散 | -| 混合评分 | 已实现 | 图分、向量分、重要性、时间衰减 | -| LLM 精确召回 | 已实现 | 小图/大图场景触发 | -| 场景重构 | 已实现 | 围绕事件和实体补上下文 | -| 层级压缩 | 已实现 | 事件/主线/反思支持 | -| 记忆进化 | 已实现 | 基于近邻与 LLM 回溯更新 | -| 全局概要 | 已实现 | 周期生成 `synopsis` | -| 反思条目 | 已实现 | 周期生成 `reflection` | -| 主动遗忘 | 已实现 | 按保留价值归档 | -| 导入/导出 | 已实现 | 导出时去掉 embedding | +- `openai/openrouter/cohere/...` 这类 provider 依赖宿主已有 provider/secret 体系 +- `ollama/llamacpp/vllm` 这类 provider 需要额外填写地址 -### 实验性能力 +### 9.2 direct 模式 -| 功能 | 当前状态 | 备注 | -| --- | --- | --- | -| 精确对照(Mem0 风格) | 实验性 | 对不同剧情密度的收益仍需更多验证 | -| 认知边界过滤 | 实验性 | 依赖节点 `visibility` 字段质量 | -| 交叉检索 | 实验性 | 更像场景增强,不一定总是增益 | -| 概率触发回忆 | 实验性 | 可能提升“闪回感”,也可能增加噪声 | -| 反思节点召回策略 | 实验性 | 当前以结构就绪为主,策略仍可细化 | +适用场景: -### 已有实现但未完全打通的预留项 +- 你需要完全独立的第二 Embedding URL/Key/Model +- 目标服务不在酒馆现成 provider 边界内 -下面这些字段或配置已经出现在代码中,但当前还不应在 README 中当作完整能力宣传: +实现方式: -| 项 | 当前情况 | +- 插件直接请求你配置的 OpenAI-compatible `/embeddings` +- 节点 embedding 继续保存在图节点里 + +### 9.3 模式切换行为 + +切换以下任一项时,向量状态都会被标记为 `dirty`: + +- `mode` +- `source` +- `model` +- `apiUrl` +- `autoSuffix` +- 导入图谱 + +之后: + +- 召回前会自动修复索引 +- 或者你也可以手动点击“重建向量” + +--- + +## 10. 历史回退恢复 + +这是本版本与旧实现最大的行为升级之一。 + +### 10.1 旧问题 + +旧实现只能线性推进 `lastProcessedSeq`。 +一旦用户: + +- 删除旧楼层 +- 编辑旧楼层 +- 切换 swipe + +图谱和已处理指针就可能和真实聊天历史不一致。 + +### 10.2 新策略:先止损,再恢复 + +插件会在历史变动事件发生时: + +1. 比对已处理楼层的消息 hash +2. 找出最早受影响楼层 +3. 立刻清空旧注入、停止本轮继续推进 +4. 记录 `historyDirtyFrom` +5. 在下一次提取或召回前自动恢复 + +### 10.3 恢复方式 + +优先策略: + +- 从 `batchJournal` 找到受影响前的恢复点 +- 回滚受影响后缀 +- 删除对应向量 hash +- 从脏楼层重新提取和后处理 + +兜底策略: + +- 如果 journal 缺失或损坏 +- 直接按当前聊天全文重建图谱与向量索引 + +### 10.4 不是 Engram 式的“只对齐指针” + +这里必须强调: + +ST-BME 当前的恢复不是简单地把“上次提取楼层”对齐到当前楼层然后跳过。 + +因为 ST-BME 的写入副作用很多: + +- 更新节点 +- 压缩节点 +- 概要 +- 反思 +- 进化 +- 迁移边 + +所以必须做真正的“回滚 + 重放”,否则图谱会留下脏状态。 + +--- + +## 11. 面板与操作 + +图谱面板现在主要分 5 个区域: + +- 总览 +- 记忆浏览 +- 注入预览 +- 操作 +- 配置 + +### 11.1 新增运行状态 + +总览页会显示: + +- 当前聊天 `chatId` +- 历史状态 +- 向量状态 +- 最近恢复结果 + +### 11.2 手动操作 + +当前支持: + +- 手动提取 +- 手动压缩 +- 执行遗忘 +- 更新概要 +- 导出图谱 +- 导入图谱 +- 重建图谱 +- 强制进化 +- 重建向量 +- 范围重建 +- 直连重嵌 + +说明: + +- “重建图谱”会按当前聊天重放整个提取流程 +- “重建向量”会重建当前聊天全部向量 +- “范围重建”只重建与指定楼层范围相交的节点向量 +- “直连重嵌”仅在 `direct` 模式下有意义 + +--- + +## 12. 设置说明 + +### 12.1 记忆 LLM + +这套配置用于: + +- 提取 +- 精确召回 +- 压缩 +- 进化 +- 概要 +- 反思 + +实现方式: + +- 留空:复用当前 SillyTavern 聊天模型 +- 填写后:通过酒馆现成后端代理转发到你配置的 OpenAI-compatible 聊天接口 + +### 12.2 Embedding + +当前设置项分成两组: + +#### 后端模式相关 + +| 字段 | 作用 | | --- | --- | -| `nodeTypeSchema` | 设置层支持,但当前没有现成 UI 做 Schema 编辑 | -| `extractPrompt` | 设置层支持,但当前没有现成 UI 暴露自定义提取提示词 | -| `injectPosition` / `injectRole` | 默认设置存在,但实际注入调用当前只使用 `injectDepth` | -| `evoConsolidateEvery` | 设置项存在,但当前没有真正的“进化后整理”执行逻辑 | -| `forceUpdate` | Schema 元数据存在,但当前运行期没有用它强制产出节点 | +| `embeddingTransportMode` | `backend` / `direct` | +| `embeddingBackendSource` | 后端向量源 | +| `embeddingBackendModel` | 后端模型 | +| `embeddingBackendApiUrl` | 仅部分后端源需要 | +| `embeddingAutoSuffix` | 自动补全后缀 | -## 配置说明 +#### 直连模式相关 -设置面板定义在 [settings.html](./settings.html),逻辑绑定在 [index.js](./index.js)。 - -### 基础与召回配置 - -| 配置项 | 默认值 | 作用 | -| --- | --- | --- | -| `enabled` | `false` | 总开关 | -| `extractEvery` | `1` | 每 N 条 assistant 回复提取一次 | -| `extractContextTurns` | `2` | 提取时往前带多少轮上下文 | -| `recallEnabled` | `true` | 是否启用生成前记忆注入 | -| `recallTopK` | `15` | 评分后的候选上限 | -| `recallMaxNodes` | `8` | LLM 精确召回最多选多少节点 | -| `recallEnableLLM` | `true` | 是否启用 LLM 精确召回 | -| `injectDepth` | `4` | 注入深度 | - -### 混合评分权重 - -| 配置项 | 默认值 | 说明 | -| --- | --- | --- | -| `graphWeight` | `0.6` | 图扩散得分权重 | -| `vectorWeight` | `0.3` | 向量相似度权重 | -| `importanceWeight` | `0.1` | 节点重要性权重 | - -### v2 增强功能配置 - -| 配置项 | 默认值 | 说明 | -| --- | --- | --- | -| `enableEvolution` | `true` | 开启记忆进化 | -| `evoNeighborCount` | `5` | 进化近邻搜索数量 | -| `enablePreciseConflict` | `true` | 开启精确对照 | -| `conflictThreshold` | `0.85` | 触发精确对照的相似度阈值 | -| `enableSynopsis` | `true` | 开启全局概要 | -| `synopsisEveryN` | `5` | 每 N 次提取更新概要 | -| `enableVisibility` | `false` | 开启认知边界过滤 | -| `enableCrossRecall` | `false` | 开启交叉检索 | -| `enableSmartTrigger` | `false` | 开启轻量触发提取 | -| `triggerPatterns` | `""` | 自定义关键词或正则 | -| `smartTriggerThreshold` | `2` | 智能触发阈值 | -| `enableSleepCycle` | `false` | 开启主动遗忘 | -| `forgetThreshold` | `0.5` | 节点保留价值阈值 | -| `sleepEveryN` | `10` | 每 N 次提取执行一次遗忘 | -| `enableProbRecall` | `false` | 开启概率回忆 | -| `probRecallChance` | `0.15` | 概率回忆触发概率 | -| `enableReflection` | `false` | 开启反思条目 | -| `reflectEveryN` | `10` | 每 N 次提取生成反思 | - -### Embedding 配置 - -| 配置项 | 默认值 | 说明 | -| --- | --- | --- | -| `embeddingApiUrl` | `""` | OpenAI 兼容 API 基地址 | -| `embeddingApiKey` | `""` | API Key | -| `embeddingModel` | `text-embedding-3-small` | embedding 模型名 | - -## 推荐使用方式 - -### 起步建议 - -如果你是第一次用这个扩展,建议先用最保守的组合: - -- 开启 `enabled` -- 保持 `extractEvery = 1` -- 开启 `recallEnabled` -- 开启 `recallEnableLLM` -- 开启 `enableEvolution` -- 开启 `enableSynopsis` -- 暂时关闭 `enableVisibility`、`enableCrossRecall`、`enableProbRecall` -- `enableReflection` 可以先关闭,等剧情稳定后再打开 - -### 成本敏感场景 - -如果更在意 API 成本,可以尝试: - -- `extractEvery = 2` 或 `3` -- 关闭 `recallEnableLLM` -- 提高 `synopsisEveryN` -- 关闭 `enableReflection` -- 仅保留 Embedding 相关能力 - -### 长剧情 / 高连续性场景 - -如果是重剧情、重状态变化的 RP: - -- 保留 `enableEvolution` -- 保留 `enableSynopsis` -- 在确认节点 `visibility` 字段可控后,再测试 `enableVisibility` -- 对多地点、多人物切换频繁的剧情,可逐步开启 `enableCrossRecall` - -## 操作面板 - -当前 UI 已经提供以下手动操作: - -| 按钮 | 作用 | +| 字段 | 作用 | | --- | --- | -| 查看图谱 | 显示活跃/归档节点数、边数、类型分布、最后处理楼层 | -| 查看注入 | 直接查看最近一次生成前的注入文本 | -| 重建图谱 | 清空当前聊天图谱,下次生成重新抽取 | -| 手动压缩 | 对当前图谱执行压缩 | -| 导出 | 导出图谱 JSON,不包含 embedding | -| 导入 | 导入图谱 JSON,导入后会清空所有 embedding | -| 测试连接 | 测试 Embedding API 是否可用 | +| `embeddingApiUrl` | 直连 Embedding API 地址 | +| `embeddingApiKey` | 直连 API Key | +| `embeddingModel` | 直连模型 | -## 目录与模块职责 +--- + +## 13. 导入导出与兼容 + +### 13.1 导出 + +导出时会主动剥离: + +- 节点 embedding +- 向量索引映射 +- batch journal + +这样导出的文件仍然是“轻量图谱文件”,而不是整段运行时缓存快照。 + +### 13.2 导入 + +导入后会: + +- 保留图谱结构 +- 清空节点 embedding +- 清空 batch journal +- 标记向量状态为 `dirty` + +也就是说: + +- 图谱可以立即查看 +- 向量需要后续重建 + +### 13.3 旧图谱迁移 + +当前版本会把旧版图谱自动迁移到 v4,并补出: + +- `historyState` +- `vectorIndexState` +- `batchJournal` + +迁移后默认会提示需要重建向量运行时状态。 + +--- + +## 14. 文件结构 + +这里列出最重要的模块: | 文件 | 作用 | | --- | --- | -| [manifest.json](./manifest.json) | 扩展清单 | -| [index.js](./index.js) | 扩展入口、事件绑定、设置管理、总流程调度 | -| [settings.html](./settings.html) | 设置面板 UI | -| [style.css](./style.css) | 扩展样式 | -| [graph.js](./graph.js) | 图数据结构、时序边、序列化、导入导出 | -| [schema.js](./schema.js) | 默认 Schema 与关系类型定义 | -| [extractor.js](./extractor.js) | 写入路径、精确对照、概要、反思 | -| [retriever.js](./retriever.js) | 读取路径、图扩散、混合评分、精确召回 | -| [injector.js](./injector.js) | 注入文本组织与格式化 | -| [embedding.js](./embedding.js) | Embedding API 调用、向量相似度检索 | -| [llm.js](./llm.js) | LLM 请求与 JSON 解析封装 | -| [diffusion.js](./diffusion.js) | PEDSA 风格扩散引擎 | -| [dynamics.js](./dynamics.js) | 时间衰减、访问强化、混合评分 | +| [index.js](./index.js) | 主入口,事件绑定、主流程调度、历史恢复、向量同步 | +| [graph.js](./graph.js) | 图数据模型、序列化、版本迁移、导入导出 | +| [extractor.js](./extractor.js) | 结构化提取、冲突对照、概要、反思 | +| [retriever.js](./retriever.js) | 向量候选、图扩散、混合评分、LLM 精排 | +| [runtime-state.js](./runtime-state.js) | 历史 hash、dirty 标记、journal、恢复点定位 | +| [vector-index.js](./vector-index.js) | backend/direct 向量模式与索引同步 | +| [llm.js](./llm.js) | 记忆 LLM 封装,支持酒馆后端代理 | +| [embedding.js](./embedding.js) | 直连 Embedding API 封装 | | [compressor.js](./compressor.js) | 层级压缩与主动遗忘 | -| [evolution.js](./evolution.js) | 记忆进化引擎 | -| [tests/](./tests) | 当前已有的轻量本地测试 | +| [evolution.js](./evolution.js) | 记忆进化 | +| [panel.html](./panel.html) / [panel.js](./panel.js) | 记忆图谱操控面板 | -## 测试与验证 +--- -当前仓库内已有的测试比较轻量,主要覆盖部分核心逻辑: +## 15. 已知边界 -```bash +当前版本已经解决了“不能改酒馆本体”的发布问题,但仍有一些边界需要明确: + +1. **backend Embedding 不是任意 URL/Key 全兼容** + - 它只能落在酒馆现成 `/api/vector/*` 已支持的 provider 边界内。 + +2. **direct 模式仍然受浏览器环境限制** + - 例如 CORS、Mixed Content、远程访问时 `127.0.0.1` 指向错误等。 + +3. **历史恢复正确性优先于性能** + - 当 journal 不可用时,会退化为当前聊天全量重建。 + +4. **图谱仍然依赖 LLM 提取质量** + - 结构化输出如果失真,图谱也会跟着失真。 + +--- + +## 16. 测试 + +当前仓库内已有并正在使用的检查包括: + +```powershell +node --check index.js +node --check extractor.js +node --check retriever.js +node --check graph.js +node --check runtime-state.js +node --check vector-index.js +node --check panel.js +``` + +测试脚本: + +```powershell node tests/smart-trigger.mjs node tests/graph-retrieval.mjs node tests/injector-format.mjs +node tests/runtime-history.mjs +node tests/vector-config.mjs ``` -它们分别验证: +其中新增测试覆盖了: -- 智能触发评分逻辑 -- 时序边过滤与图扩散基础行为 -- 注入文本格式化流程 +- 历史 hash 检测 +- journal 恢复点定位 +- 向量模式配置归一化 +- backend/direct 基本配置校验 -当前**尚未**覆盖的重点包括: +--- -- 真实 LLM 提取质量 -- 真实 Embedding API 行为 -- 完整的 ST 生命周期集成 -- 大图规模下的性能与稳定性 -- 导入导出后的重建与回归 +## 17. 适合的使用方式 -## 已知限制 +如果你的目标是: -截至当前代码实现,建议明确接受以下限制: +- 长期 RP +- 世界观持续累积 +- 多角色状态维护 +- 任务线/主线长期跟踪 +- 对话发生删改时尽量不留下脏记忆 -1. 这是一个**聊天内图谱**,不是跨聊天统一记忆库。 -2. 导入图谱后,所有节点 embedding 会被清空;当前没有单独的“全量重建 embedding”按钮,向量能力需要后续写入或额外处理来逐步恢复。 -3. LLM 子任务很多,结构化输出质量会直接影响图谱质量。 -4. 当前没有内建图谱可视化界面,调试主要依赖统计信息、日志和注入文本。 -5. 默认 `event` / `rule` / `thread` / `synopsis` 都是 Core 常驻注入,项目当前更偏向“主干常驻 + 状态召回”,而不是纯检索式记忆架构。 -6. 实验性功能已经接入主流程,但仍缺少更系统的 benchmark 和回归验证。 +那么当前 ST-BME 已经比最早版本更适合作为“长期记忆图谱层”使用。 -## 设计来源与参考 +推荐默认用法: -ST-BME 不是这些项目的直接移植,而是结合 SillyTavern 扩展场景做的工程化整合。当前设计大致受以下项目启发: +1. 记忆 LLM:可独立配置,也可复用当前酒馆模型 +2. 向量:优先 `backend` +3. 只有当你明确需要第二套完全独立 Embedding URL/Key/Model 时,再切到 `direct` -| 参考项目 | 启发点 | -| --- | --- | -| `A-MEM` | 记忆进化、基于近邻的回溯修正 | -| `EM-LLM` | 惊奇度触发、段落边界与提取时机 | -| `Graphiti` | 时序边、关系有效性和图建模思路 | -| `Mem0` | 新旧记忆对照、增量更新决策 | -| `RoleRAG` | 认知边界过滤 | -| `AriGraph` | 沿图边展开的交叉检索 | -| `MemoRAG` | 全局概要作为长期锚点 | -| `SleepGate` | 主动遗忘与保留价值评估 | -| `Reflexion` | 反思条目方向 | -| `PeroCore` | 图扩散、记忆动力学、向量检索策略 | +--- -## 当前版本 +## 18. 总结 -- 扩展版本:`0.1.0` -- 清单文件:[`manifest.json`](./manifest.json) +当前 ST-BME 已经不是“只会抽点节点再注入”的原型版本,而是一套更完整的插件内记忆层: -## 许可证 +- 图谱仍然和聊天强绑定 +- 发布形态仍然是纯第三方扩展 +- 向量层支持后端索引优先 +- 历史变动支持真正恢复,而不只是指针对齐 +- UI 里可以直接看到当前聊天、向量和恢复状态 -本项目采用 AFPL License,详见 [LICENSE](./LICENSE)。 +如果你希望它继续往更重型方向发展,下一步最自然的演进会是: + +- 扩展更细的恢复测试 +- 增加范围级重放面板 +- 增加 provider 级能力说明与自动诊断 +- 继续压缩 `batchJournal` 的体积成本 diff --git a/compressor.js b/compressor.js index b8e3036..8ea9909 100644 --- a/compressor.js +++ b/compressor.js @@ -4,6 +4,7 @@ import { createNode, addNode, createEdge, addEdge, getActiveNodes, getNode } from './graph.js'; import { callLLMForJSON } from './llm.js'; import { embedText } from './embedding.js'; +import { isDirectVectorConfig } from './vector-index.js'; /** * 对指定类型执行层级压缩 @@ -94,7 +95,7 @@ async function compressLevel({ graph, typeDef, level, embeddingConfig, force }) compressedNode.childIds = batch.map(n => n.id); // 生成 embedding - if (embeddingConfig?.apiUrl && summaryResult.fields.summary) { + if (isDirectVectorConfig(embeddingConfig) && summaryResult.fields.summary) { const vec = await embedText(summaryResult.fields.summary, embeddingConfig); if (vec) compressedNode.embedding = Array.from(vec); } diff --git a/evolution.js b/evolution.js index 3b199e9..86d5631 100644 --- a/evolution.js +++ b/evolution.js @@ -2,8 +2,12 @@ // 新节点写入后触发,回溯更新相关旧节点的 context/tags/links import { getActiveNodes, getNode, createEdge, addEdge } from './graph.js'; -import { searchSimilar } from './embedding.js'; import { callLLMForJSON } from './llm.js'; +import { + buildNodeVectorText, + findSimilarNodesByText, + validateVectorConfig, +} from './vector-index.js'; /** * 进化系统提示词 @@ -57,8 +61,8 @@ export async function evolveMemories({ const stats = { evolved: 0, connections: 0, updates: 0 }; if (!newNodeIds || newNodeIds.length === 0) return stats; - if (!embeddingConfig?.apiUrl) { - console.log('[ST-BME] 记忆进化跳过:未配置 Embedding API'); + if (!validateVectorConfig(embeddingConfig).valid) { + console.log('[ST-BME] 记忆进化跳过:向量配置不可用'); return stats; } @@ -67,16 +71,21 @@ export async function evolveMemories({ for (const newId of newNodeIds) { const newNode = getNode(graph, newId); - if (!newNode || !newNode.embedding) continue; + if (!newNode) continue; - // 找最近邻(排除自身) - const candidates = activeNodes - .filter(n => n.id !== newId && n.embedding) - .map(n => ({ nodeId: n.id, embedding: n.embedding })); + const queryText = buildNodeVectorText(newNode); + if (!queryText) continue; + const candidates = activeNodes.filter(n => n.id !== newId); if (candidates.length === 0) continue; - const neighbors = searchSimilar(newNode.embedding, candidates, neighborCount); + const neighbors = await findSimilarNodesByText( + graph, + queryText, + embeddingConfig, + neighborCount, + candidates, + ); if (neighbors.length === 0) continue; // 构建 LLM 上下文 diff --git a/extractor.js b/extractor.js index fcc3e1f..6f08f76 100644 --- a/extractor.js +++ b/extractor.js @@ -2,7 +2,7 @@ // 分析对话 → 提取节点和关系 → 更新图谱 // v2: 融合 Mem0 精确对照 + Graphiti 时序边 + MemoRAG 全局概要 -import { embedBatch, embedText, searchSimilar } from "./embedding.js"; +import { embedBatch } from "./embedding.js"; import { addEdge, addNode, @@ -16,6 +16,12 @@ import { } from "./graph.js"; import { callLLMForJSON } from "./llm.js"; import { RELATION_TYPES } from "./schema.js"; +import { + buildNodeVectorText, + findSimilarNodesByText, + isDirectVectorConfig, + validateVectorConfig, +} from "./vector-index.js"; /** * 对未处理的对话楼层执行记忆提取 @@ -122,7 +128,7 @@ export async function extractMemories({ } // ========== v2: Mem0 精确对照阶段 ========== - if (enablePreciseConflict && embeddingConfig?.apiUrl) { + if (enablePreciseConflict && validateVectorConfig(embeddingConfig).valid) { await mem0ConflictCheck( graph, result.operations, @@ -411,7 +417,7 @@ function handleLinks(graph, sourceId, links, refMap, stats) { * 为缺少 embedding 的节点生成向量 */ async function generateNodeEmbeddings(graph, embeddingConfig) { - if (!embeddingConfig?.apiUrl) return; + if (!isDirectVectorConfig(embeddingConfig)) return; const needsEmbedding = graph.nodes.filter( (n) => @@ -420,17 +426,7 @@ async function generateNodeEmbeddings(graph, embeddingConfig) { if (needsEmbedding.length === 0) return; - const texts = needsEmbedding.map((n) => { - // 用主要字段拼文本 - const parts = []; - if (n.fields.summary) parts.push(n.fields.summary); - if (n.fields.name) parts.push(n.fields.name); - if (n.fields.title) parts.push(n.fields.title); - if (n.fields.traits) parts.push(n.fields.traits); - if (n.fields.state) parts.push(n.fields.state); - if (n.fields.constraint) parts.push(n.fields.constraint); - return parts.join(" | ") || n.type; - }); + const texts = needsEmbedding.map((node) => buildNodeVectorText(node) || node.type); console.log(`[ST-BME] 为 ${texts.length} 个节点生成 embedding`); @@ -543,9 +539,10 @@ async function mem0ConflictCheck( threshold, fallbackSeq, ) { - const activeNodes = getActiveNodes(graph).filter( - (n) => Array.isArray(n.embedding) && n.embedding.length > 0, - ); + const activeNodes = getActiveNodes(graph).filter((node) => { + const text = buildNodeVectorText(node); + return typeof text === "string" && text.length > 0; + }); if (activeNodes.length === 0) return; for (const op of operations) { @@ -556,14 +553,13 @@ async function mem0ConflictCheck( if (!factText) continue; try { - const factVec = await embedText(factText, embeddingConfig); - if (!factVec) continue; - - const candidates = activeNodes.map((n) => ({ - nodeId: n.id, - embedding: n.embedding, - })); - const similar = searchSimilar(factVec, candidates, 3); + const similar = await findSimilarNodesByText( + graph, + factText, + embeddingConfig, + 3, + activeNodes, + ); if (similar.length > 0 && similar[0].score > threshold) { const topMatch = graph.nodes.find((n) => n.id === similar[0].nodeId); diff --git a/graph.js b/graph.js index a8a22f1..73c7436 100644 --- a/graph.js +++ b/graph.js @@ -1,10 +1,17 @@ // ST-BME: 图数据模型 // 管理节点、边的 CRUD 操作,以及序列化到 chat_metadata +import { + createDefaultBatchJournal, + createDefaultHistoryState, + createDefaultVectorIndexState, + normalizeGraphRuntimeState, +} from "./runtime-state.js"; + /** * 图状态版本号 */ -const GRAPH_VERSION = 3; +const GRAPH_VERSION = 4; /** * 生成 UUID v4 @@ -22,13 +29,16 @@ function uuid() { * @returns {GraphState} */ export function createEmptyGraph() { - return { + return normalizeGraphRuntimeState({ version: GRAPH_VERSION, lastProcessedSeq: -1, nodes: [], edges: [], lastRecallResult: null, - }; + historyState: createDefaultHistoryState(), + vectorIndexState: createDefaultVectorIndexState(), + batchJournal: createDefaultBatchJournal(), + }); } // ==================== 节点操作 ==================== @@ -481,6 +491,25 @@ export function deserializeGraph(json) { } } + if (data.version < 4) { + data.historyState = { + ...createDefaultHistoryState(), + ...(data.historyState || {}), + lastProcessedAssistantFloor: Number.isFinite(data.lastProcessedSeq) + ? data.lastProcessedSeq + : -1, + }; + data.vectorIndexState = { + ...createDefaultVectorIndexState(), + ...(data.vectorIndexState || {}), + dirty: true, + lastWarning: "旧版本图谱已迁移,需要重建向量运行时状态", + }; + data.batchJournal = Array.isArray(data.batchJournal) + ? data.batchJournal + : createDefaultBatchJournal(); + } + data.version = GRAPH_VERSION; } @@ -513,8 +542,24 @@ export function deserializeGraph(json) { data.lastRecallResult = Array.isArray(data.lastRecallResult) ? data.lastRecallResult : null; + data.historyState = { + ...createDefaultHistoryState(), + ...(data.historyState || {}), + lastProcessedAssistantFloor: Number.isFinite( + data?.historyState?.lastProcessedAssistantFloor, + ) + ? data.historyState.lastProcessedAssistantFloor + : data.lastProcessedSeq, + }; + data.vectorIndexState = { + ...createDefaultVectorIndexState(data?.historyState?.chatId || ""), + ...(data.vectorIndexState || {}), + }; + data.batchJournal = Array.isArray(data.batchJournal) + ? data.batchJournal + : createDefaultBatchJournal(); - return data; + return normalizeGraphRuntimeState(data, data?.historyState?.chatId || ""); } catch (e) { console.error("[ST-BME] 图反序列化失败:", e); return createEmptyGraph(); @@ -529,6 +574,17 @@ export function deserializeGraph(json) { export function exportGraph(graph) { const exportData = { ...graph, + historyState: { + ...createDefaultHistoryState(graph?.historyState?.chatId || ""), + lastProcessedAssistantFloor: + graph?.historyState?.lastProcessedAssistantFloor ?? graph?.lastProcessedSeq ?? -1, + }, + vectorIndexState: { + ...createDefaultVectorIndexState(graph?.historyState?.chatId || ""), + dirty: true, + lastWarning: "导出图谱不包含运行时向量索引", + }, + batchJournal: createDefaultBatchJournal(), nodes: graph.nodes.map((n) => ({ ...n, embedding: null })), }; return JSON.stringify(exportData, null, 2); @@ -540,10 +596,17 @@ export function exportGraph(graph) { * @returns {GraphState} */ export function importGraph(json) { - const graph = deserializeGraph(json); + const graph = normalizeGraphRuntimeState(deserializeGraph(json)); // 导入的节点需要重新生成 embedding for (const node of graph.nodes) { node.embedding = null; } + graph.batchJournal = createDefaultBatchJournal(); + graph.historyState.processedMessageHashes = {}; + graph.historyState.historyDirtyFrom = null; + graph.vectorIndexState.hashToNodeId = {}; + graph.vectorIndexState.nodeToHash = {}; + graph.vectorIndexState.dirty = true; + graph.vectorIndexState.lastWarning = "导入图谱后需要重建向量索引"; return graph; } diff --git a/index.js b/index.js index 627d16c..13c697b 100644 --- a/index.js +++ b/index.js @@ -14,7 +14,6 @@ import { } from "../../../extensions.js"; import { compressAll, sleepCycle } from "./compressor.js"; -import { testConnection as testEmbeddingConnection } from "./embedding.js"; import { evolveMemories } from "./evolution.js"; import { extractMemories, @@ -32,7 +31,29 @@ import { import { estimateTokens, formatInjection } from "./injector.js"; import { testLLMConnection } from "./llm.js"; import { retrieve } from "./retriever.js"; +import { + appendBatchJournal, + buildRecoveryResult, + clearHistoryDirty, + cloneGraphSnapshot, + createBatchJournalEntry, + detectHistoryMutation, + findJournalRecoveryPoint, + markHistoryDirty, + normalizeGraphRuntimeState, + snapshotProcessedMessageHashes, +} from "./runtime-state.js"; import { DEFAULT_NODE_SCHEMA, validateSchema } from "./schema.js"; +import { + BACKEND_VECTOR_SOURCES, + getVectorConfigFromSettings, + getVectorIndexStats, + isBackendVectorConfig, + isDirectVectorConfig, + syncGraphVectorIndex, + testVectorConnection, + validateVectorConfig, +} from "./vector-index.js"; // 操控面板模块(动态加载,防止加载失败崩溃整个扩展) let _panelModule = null; @@ -77,6 +98,11 @@ const defaultSettings = { embeddingApiUrl: "", embeddingApiKey: "", embeddingModel: "text-embedding-3-small", + embeddingTransportMode: "backend", + embeddingBackendSource: "openai", + embeddingBackendModel: "text-embedding-3-small", + embeddingBackendApiUrl: "", + embeddingAutoSuffix: true, // Schema nodeTypeSchema: null, // null 表示使用默认 @@ -136,6 +162,8 @@ let lastExtractedItems = []; // 最近提取的节点(面板展示用) let lastRecalledItems = []; // 最近召回的节点(面板展示用) let extractionCount = 0; // v2: 提取次数计数器(定期触发概要/遗忘/反思) let serverSettingsSaveTimer = null; +let isRecoveringHistory = false; +let lastHistoryWarningAt = 0; function getNodeDisplayName(node) { return ( @@ -219,12 +247,185 @@ function getSchema() { } function getEmbeddingConfig() { - const settings = getSettings(); - return { - apiUrl: settings.embeddingApiUrl, - apiKey: settings.embeddingApiKey, - model: settings.embeddingModel, + return getVectorConfigFromSettings(getSettings()); +} + +function getCurrentChatId(context = getContext()) { + return String( + context?.chatId || + context?.getCurrentChatId?.() || + "", + ); +} + +function ensureCurrentGraphRuntimeState() { + if (!currentGraph) { + currentGraph = createEmptyGraph(); + } + + currentGraph = normalizeGraphRuntimeState(currentGraph, getCurrentChatId()); + return currentGraph; +} + +function clearInjectionState() { + lastInjectionContent = ""; + lastRecalledItems = []; + + try { + const context = getContext(); + context.setExtensionPrompt(MODULE_NAME, "", 1, 0); + } catch (error) { + console.warn("[ST-BME] 清理旧注入失败:", error); + } +} + +async function recordGraphMutation({ + beforeSnapshot, + processedRange = null, + artifactTags = [], + syncRange = null, +} = {}) { + ensureCurrentGraphRuntimeState(); + const vectorSync = await syncVectorState({ + force: true, + purge: isBackendVectorConfig(getEmbeddingConfig()) && !syncRange, + range: syncRange, + }); + const afterSnapshot = cloneGraphSnapshot(currentGraph); + const effectiveRange = Array.isArray(processedRange) + ? processedRange + : [ + getLastProcessedAssistantFloor(), + getLastProcessedAssistantFloor(), + ]; + + appendBatchJournal( + currentGraph, + createBatchJournalEntry(beforeSnapshot, afterSnapshot, { + processedRange: effectiveRange, + postProcessArtifacts: computePostProcessArtifacts( + beforeSnapshot, + afterSnapshot, + artifactTags, + ), + vectorHashesInserted: vectorSync?.insertedHashes || [], + }), + ); + saveGraphToChat(); + return vectorSync; +} + +function markVectorStateDirty(reason = "向量状态已标记为待重建") { + if (!currentGraph) return; + ensureCurrentGraphRuntimeState(); + currentGraph.vectorIndexState.dirty = true; + currentGraph.vectorIndexState.lastWarning = reason; +} + +function updateProcessedHistorySnapshot(chat, lastProcessedAssistantFloor) { + ensureCurrentGraphRuntimeState(); + currentGraph.historyState.lastProcessedAssistantFloor = lastProcessedAssistantFloor; + currentGraph.historyState.processedMessageHashes = snapshotProcessedMessageHashes( + chat, + lastProcessedAssistantFloor, + ); + currentGraph.lastProcessedSeq = lastProcessedAssistantFloor; +} + +function computePostProcessArtifacts(beforeSnapshot, afterSnapshot, extraTags = []) { + const beforeNodeIds = new Set((beforeSnapshot?.nodes || []).map((node) => node.id)); + const afterNodes = afterSnapshot?.nodes || []; + const tags = new Set(extraTags.filter(Boolean)); + + for (const node of afterNodes) { + if (!beforeNodeIds.has(node.id)) { + if (node.type === "synopsis") tags.add("synopsis"); + if (node.type === "reflection") tags.add("reflection"); + if (node.level > 0) tags.add("compression"); + } + } + + const beforeNodes = new Map((beforeSnapshot?.nodes || []).map((node) => [node.id, node])); + for (const node of afterNodes) { + const beforeNode = beforeNodes.get(node.id); + if (!beforeNode) continue; + if (!beforeNode.archived && node.archived) { + tags.add(node.level > 0 ? "compression-archive" : "sleep/archive"); + } + } + + return [...tags]; +} + +async function syncVectorState({ + force = false, + purge = false, + range = null, +} = {}) { + ensureCurrentGraphRuntimeState(); + const config = getEmbeddingConfig(); + const validation = validateVectorConfig(config); + + if (!validation.valid) { + currentGraph.vectorIndexState.lastWarning = validation.error; + currentGraph.vectorIndexState.dirty = true; + return { + insertedHashes: [], + stats: getVectorIndexStats(currentGraph), + error: validation.error, + }; + } + + try { + return await syncGraphVectorIndex(currentGraph, config, { + chatId: getCurrentChatId(), + force, + purge, + range, + }); + } catch (error) { + markVectorStateDirty(error?.message || "向量同步失败"); + console.error("[ST-BME] 向量同步失败:", error); + return { + insertedHashes: [], + stats: getVectorIndexStats(currentGraph), + error: String(error), + }; + } +} + +async function ensureVectorReadyIfNeeded(reason = "vector-ready-check") { + if (!currentGraph) return; + ensureCurrentGraphRuntimeState(); + + if (!currentGraph.vectorIndexState?.dirty) return; + + const config = getEmbeddingConfig(); + const validation = validateVectorConfig(config); + if (!validation.valid) return; + + const result = await syncVectorState({ + force: true, + purge: isBackendVectorConfig(config), + }); + currentGraph.vectorIndexState.lastWarning = ""; + saveGraphToChat(); + console.log("[ST-BME] 向量状态已自动修复:", reason, result.stats); +} + +async function resetVectorStateForConfigChange(reason = "向量配置已变更") { + if (!currentGraph) return; + ensureCurrentGraphRuntimeState(); + markVectorStateDirty(reason); + currentGraph.vectorIndexState.hashToNodeId = {}; + currentGraph.vectorIndexState.nodeToHash = {}; + currentGraph.vectorIndexState.lastStats = { + total: 0, + indexed: 0, + stale: 0, + pending: 0, }; + saveGraphToChat(); } function getPersistedSettingsSnapshot(settings = getSettings()) { @@ -316,6 +517,16 @@ function scheduleServerSettingsSave() { } function updateModuleSettings(patch = {}) { + const vectorConfigKeys = new Set([ + "embeddingApiUrl", + "embeddingApiKey", + "embeddingModel", + "embeddingTransportMode", + "embeddingBackendSource", + "embeddingBackendModel", + "embeddingBackendApiUrl", + "embeddingAutoSuffix", + ]); const settings = getSettings(); Object.assign(settings, patch); extension_settings[MODULE_NAME] = settings; @@ -335,6 +546,10 @@ function updateModuleSettings(patch = {}) { } } + if (Object.keys(patch).some((key) => vectorConfigKeys.has(key))) { + void resetVectorStateForConfigChange("Embedding 配置已变更,向量索引待重建"); + } + scheduleServerSettingsSave(); return settings; } @@ -343,8 +558,9 @@ function updateModuleSettings(patch = {}) { function loadGraphFromChat() { const context = getContext(); + const chatId = getCurrentChatId(context); if (!context.chatMetadata) { - currentGraph = createEmptyGraph(); + currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), chatId); lastExtractedItems = []; lastRecalledItems = []; lastInjectionContent = ""; @@ -353,12 +569,13 @@ function loadGraphFromChat() { const savedData = context.chatMetadata[GRAPH_METADATA_KEY]; if (savedData) { - currentGraph = deserializeGraph(savedData); + currentGraph = normalizeGraphRuntimeState(deserializeGraph(savedData), chatId); console.log("[ST-BME] 从聊天数据加载图谱:", getGraphStats(currentGraph)); } else { - currentGraph = createEmptyGraph(); + currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), chatId); } + extractionCount = 0; lastExtractedItems = []; updateLastRecalledItems(currentGraph.lastRecallResult || []); lastInjectionContent = ""; @@ -368,6 +585,7 @@ function saveGraphToChat() { const context = getContext(); if (!context.chatMetadata || !currentGraph) return; + ensureCurrentGraphRuntimeState(); context.chatMetadata[GRAPH_METADATA_KEY] = currentGraph; saveMetadataDebounced(); } @@ -493,6 +711,7 @@ function getCurrentChatSeq(context = getContext()) { } async function handleExtractionSuccess(result, endIdx, settings) { + const postProcessArtifacts = []; extractionCount++; updateLastExtractedItems(result.newNodeIds || []); @@ -504,6 +723,7 @@ async function handleExtractionSuccess(result, endIdx, settings) { embeddingConfig: getEmbeddingConfig(), options: { neighborCount: settings.evoNeighborCount }, }); + postProcessArtifacts.push("evolution"); } catch (e) { console.error("[ST-BME] 记忆进化失败:", e); } @@ -516,6 +736,7 @@ async function handleExtractionSuccess(result, endIdx, settings) { schema: getSchema(), currentSeq: endIdx, }); + postProcessArtifacts.push("synopsis"); } catch (e) { console.error("[ST-BME] 概要生成失败:", e); } @@ -530,6 +751,7 @@ async function handleExtractionSuccess(result, endIdx, settings) { graph: currentGraph, currentSeq: endIdx, }); + postProcessArtifacts.push("reflection"); } catch (e) { console.error("[ST-BME] 反思生成失败:", e); } @@ -538,13 +760,322 @@ async function handleExtractionSuccess(result, endIdx, settings) { if (settings.enableSleepCycle && extractionCount % settings.sleepEveryN === 0) { try { sleepCycle(currentGraph, settings); + postProcessArtifacts.push("sleep"); } catch (e) { console.error("[ST-BME] 主动遗忘失败:", e); } } - await compressAll(currentGraph, getSchema(), getEmbeddingConfig()); + const compressionResult = await compressAll( + currentGraph, + getSchema(), + getEmbeddingConfig(), + ); + if (compressionResult.created > 0 || compressionResult.archived > 0) { + postProcessArtifacts.push("compression"); + } + + const vectorSync = await syncVectorState(); + return { + postProcessArtifacts, + vectorHashesInserted: vectorSync?.insertedHashes || [], + vectorStats: vectorSync?.stats || getVectorIndexStats(currentGraph), + }; +} + +function getAssistantTurns(chat) { + const assistantTurns = []; + for (let index = 0; index < chat.length; index++) { + if (chat[index].is_user === false && !chat[index].is_system) { + assistantTurns.push(index); + } + } + return assistantTurns; +} + +function buildExtractionMessages(chat, startIdx, endIdx, settings) { + const contextTurns = clampInt(settings.extractContextTurns, 2, 0, 20); + const contextStart = Math.max(0, startIdx - contextTurns * 2); + const messages = []; + + for (let index = contextStart; index <= endIdx && index < chat.length; index++) { + const msg = chat[index]; + if (msg.is_system) continue; + messages.push({ + seq: index, + role: msg.is_user ? "user" : "assistant", + content: msg.mes || "", + }); + } + + return messages; +} + +function getLastProcessedAssistantFloor() { + ensureCurrentGraphRuntimeState(); + return Number.isFinite(currentGraph?.historyState?.lastProcessedAssistantFloor) + ? currentGraph.historyState.lastProcessedAssistantFloor + : -1; +} + +function notifyHistoryDirty(dirtyFrom, reason) { + const now = Date.now(); + if (now - lastHistoryWarningAt < 3000) return; + lastHistoryWarningAt = now; + toastr.warning( + `检测到楼层历史变化,将从楼层 ${dirtyFrom} 之后自动恢复图谱`, + reason || "ST-BME 历史回退保护", + ); +} + +function inspectHistoryMutation(trigger = "history-change") { + if (!currentGraph) return { dirty: false, earliestAffectedFloor: null, reason: "" }; + + ensureCurrentGraphRuntimeState(); + const context = getContext(); + const chat = context?.chat; + const detection = detectHistoryMutation(chat, currentGraph.historyState); + + if (detection.dirty) { + clearInjectionState(); + markHistoryDirty( + currentGraph, + detection.earliestAffectedFloor, + detection.reason || trigger, + ); + saveGraphToChat(); + notifyHistoryDirty(detection.earliestAffectedFloor, detection.reason); + return detection; + } + + if (trigger === "message-edited" || trigger === "message-swiped") { + clearInjectionState(); + } + + return detection; +} + +async function purgeCurrentVectorCollection() { + if (!currentGraph?.vectorIndexState?.collectionId) return; + + const response = await fetch("/api/vector/purge", { + method: "POST", + headers: getRequestHeaders(), + body: JSON.stringify({ + collectionId: currentGraph.vectorIndexState.collectionId, + }), + }); + + if (!response.ok) { + const message = await response.text().catch(() => response.statusText); + throw new Error(message || `HTTP ${response.status}`); + } +} + +async function prepareVectorStateForReplay(fullReset = false) { + ensureCurrentGraphRuntimeState(); + const config = getEmbeddingConfig(); + + if (isBackendVectorConfig(config)) { + try { + await purgeCurrentVectorCollection(); + } catch (error) { + console.warn("[ST-BME] 清理后端向量索引失败,继续本地恢复:", error); + } + currentGraph.vectorIndexState.hashToNodeId = {}; + currentGraph.vectorIndexState.nodeToHash = {}; + currentGraph.vectorIndexState.dirty = true; + currentGraph.vectorIndexState.lastWarning = "历史恢复后需要重建后端向量索引"; + return; + } + + if (fullReset) { + currentGraph.vectorIndexState.hashToNodeId = {}; + currentGraph.vectorIndexState.nodeToHash = {}; + currentGraph.vectorIndexState.dirty = true; + currentGraph.vectorIndexState.lastWarning = "历史恢复后需要重嵌当前聊天向量"; + } +} + +async function executeExtractionBatch({ + chat, + startIdx, + endIdx, + settings, + smartTriggerDecision = null, +} = {}) { + ensureCurrentGraphRuntimeState(); + const lastProcessed = getLastProcessedAssistantFloor(); + const beforeSnapshot = cloneGraphSnapshot(currentGraph); + const messages = buildExtractionMessages(chat, startIdx, endIdx, settings); + + console.log( + `[ST-BME] 开始提取: 楼层 ${startIdx}-${endIdx}` + + (smartTriggerDecision?.triggered + ? ` [智能触发 score=${smartTriggerDecision.score}; ${smartTriggerDecision.reasons.join(" / ")}]` + : ""), + ); + + const result = await extractMemories({ + graph: currentGraph, + messages, + startSeq: startIdx, + endSeq: endIdx, + lastProcessedSeq: lastProcessed, + schema: getSchema(), + embeddingConfig: getEmbeddingConfig(), + extractPrompt: settings.extractPrompt || undefined, + v2Options: { + enablePreciseConflict: settings.enablePreciseConflict, + conflictThreshold: settings.conflictThreshold, + }, + }); + + if (!result.success) { + return { success: false, result, effects: null }; + } + + const effects = await handleExtractionSuccess(result, endIdx, settings); + updateProcessedHistorySnapshot(chat, endIdx); + + const afterSnapshot = cloneGraphSnapshot(currentGraph); + const postProcessArtifacts = computePostProcessArtifacts( + beforeSnapshot, + afterSnapshot, + effects?.postProcessArtifacts || [], + ); + appendBatchJournal( + currentGraph, + createBatchJournalEntry(beforeSnapshot, afterSnapshot, { + processedRange: [startIdx, endIdx], + postProcessArtifacts, + vectorHashesInserted: effects?.vectorHashesInserted || [], + }), + ); saveGraphToChat(); + + return { success: true, result, effects }; +} + +async function replayExtractionFromHistory(chat, settings) { + let replayedBatches = 0; + + while (true) { + const pendingAssistantTurns = getAssistantTurns(chat).filter( + (index) => index > getLastProcessedAssistantFloor(), + ); + if (pendingAssistantTurns.length === 0) break; + + const extractEvery = clampInt(settings.extractEvery, 1, 1, 50); + const batchAssistantTurns = pendingAssistantTurns.slice(0, extractEvery); + const startIdx = batchAssistantTurns[0]; + const endIdx = batchAssistantTurns[batchAssistantTurns.length - 1]; + + const batchResult = await executeExtractionBatch({ + chat, + startIdx, + endIdx, + settings, + }); + + if (!batchResult.success) { + throw new Error("历史恢复回放过程中出现提取失败"); + } + + replayedBatches++; + } + + return replayedBatches; +} + +async function recoverHistoryIfNeeded(trigger = "history-recovery") { + if (!currentGraph || isRecoveringHistory) { + return !isRecoveringHistory; + } + + ensureCurrentGraphRuntimeState(); + const context = getContext(); + const chat = context?.chat; + if (!Array.isArray(chat)) return true; + + const detection = inspectHistoryMutation(trigger); + const dirtyFrom = currentGraph?.historyState?.historyDirtyFrom; + if (!detection.dirty && !Number.isFinite(dirtyFrom)) { + return true; + } + + isRecoveringHistory = true; + clearInjectionState(); + + const chatId = getCurrentChatId(context); + const settings = getSettings(); + const initialDirtyFrom = Number.isFinite(dirtyFrom) + ? dirtyFrom + : detection.earliestAffectedFloor; + let replayedBatches = 0; + let usedFullRebuild = false; + + try { + const recoveryPoint = findJournalRecoveryPoint(currentGraph, initialDirtyFrom); + if (recoveryPoint) { + currentGraph = normalizeGraphRuntimeState( + recoveryPoint.snapshotBefore, + chatId, + ); + } else { + currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), chatId); + usedFullRebuild = true; + } + + await prepareVectorStateForReplay(usedFullRebuild); + replayedBatches = await replayExtractionFromHistory(chat, settings); + + clearHistoryDirty( + currentGraph, + buildRecoveryResult(usedFullRebuild ? "full-rebuild" : "replayed", { + fromFloor: initialDirtyFrom, + batches: replayedBatches, + reason: detection.reason || currentGraph?.historyState?.lastMutationReason || trigger, + }), + ); + saveGraphToChat(); + + toastr.success( + usedFullRebuild + ? "历史变化已触发全量重建" + : "历史变化已完成受影响后缀恢复", + ); + return true; + } catch (error) { + console.error("[ST-BME] 历史恢复失败,尝试全量重建:", error); + + try { + currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), chatId); + await prepareVectorStateForReplay(true); + replayedBatches = await replayExtractionFromHistory(chat, settings); + clearHistoryDirty( + currentGraph, + buildRecoveryResult("full-rebuild", { + fromFloor: 0, + batches: replayedBatches, + reason: `恢复失败后兜底全量重建: ${error?.message || error}`, + }), + ); + saveGraphToChat(); + toastr.warning("历史恢复已退化为全量重建"); + return true; + } catch (fallbackError) { + currentGraph.historyState.lastRecoveryResult = buildRecoveryResult("failed", { + fromFloor: initialDirtyFrom, + reason: String(fallbackError), + }); + saveGraphToChat(); + toastr.error(`历史恢复失败: ${fallbackError?.message || fallbackError}`); + return false; + } + } finally { + isRecoveringHistory = false; + } } /** @@ -555,22 +1086,15 @@ async function runExtraction() { const settings = getSettings(); if (!settings.enabled) return; + if (!(await recoverHistoryIfNeeded("auto-extract"))) return; + await ensureVectorReadyIfNeeded("pre-extract"); const context = getContext(); const chat = context.chat; if (!chat || chat.length === 0) return; - // lastProcessedSeq / startSeq / endSeq 统一使用 chat 数组索引语义 - const assistantTurns = []; - for (let i = 0; i < chat.length; i++) { - if (chat[i].is_user === false && !chat[i].is_system) { - assistantTurns.push(i); - } - } - - const lastProcessed = Number.isFinite(currentGraph.lastProcessedSeq) - ? currentGraph.lastProcessedSeq - : -1; + const assistantTurns = getAssistantTurns(chat); + const lastProcessed = getLastProcessedAssistantFloor(); const unprocessedAssistantTurns = assistantTurns.filter( (i) => i > lastProcessed, ); @@ -598,43 +1122,16 @@ async function runExtraction() { isExtracting = true; try { - const contextTurns = clampInt(settings.extractContextTurns, 2, 0, 20); - const contextStart = Math.max(0, startIdx - contextTurns * 2); - const messages = []; - for (let i = contextStart; i <= endIdx && i < chat.length; i++) { - const msg = chat[i]; - if (msg.is_system) continue; - messages.push({ - seq: i, - role: msg.is_user ? "user" : "assistant", - content: msg.mes || "", - }); - } - - console.log( - `[ST-BME] 开始提取: 楼层 ${startIdx}-${endIdx}` + - (smartTriggerDecision.triggered - ? ` [智能触发 score=${smartTriggerDecision.score}; ${smartTriggerDecision.reasons.join(" / ")}]` - : ""), - ); - - const result = await extractMemories({ - graph: currentGraph, - messages, - startSeq: startIdx, - endSeq: endIdx, - lastProcessedSeq: lastProcessed, - schema: getSchema(), - embeddingConfig: getEmbeddingConfig(), - extractPrompt: settings.extractPrompt || undefined, - v2Options: { - enablePreciseConflict: settings.enablePreciseConflict, - conflictThreshold: settings.conflictThreshold, - }, + const batchResult = await executeExtractionBatch({ + chat, + startIdx, + endIdx, + settings, + smartTriggerDecision, }); - if (result.success) { - await handleExtractionSuccess(result, endIdx, settings); + if (!batchResult.success) { + console.warn("[ST-BME] 提取批次未返回有效结果"); } } catch (e) { console.error("[ST-BME] 提取失败:", e); @@ -651,6 +1148,9 @@ async function runRecall() { const settings = getSettings(); if (!settings.enabled || !settings.recallEnabled) return; + if (!(await recoverHistoryIfNeeded("pre-recall"))) return; + + await ensureVectorReadyIfNeeded("pre-recall"); const context = getContext(); const chat = context.chat; @@ -737,13 +1237,19 @@ async function runRecall() { function onChatChanged() { loadGraphFromChat(); - lastInjectionContent = ""; - try { - const context = getContext(); - context.setExtensionPrompt(MODULE_NAME, "", 1, 0); - } catch (error) { - console.warn("[ST-BME] 清理旧注入失败:", error); - } + clearInjectionState(); +} + +function onMessageDeleted() { + inspectHistoryMutation("message-deleted"); +} + +function onMessageEdited() { + inspectHistoryMutation("message-edited"); +} + +function onMessageSwiped() { + inspectHistoryMutation("message-swiped"); } async function onGenerationAfterCommands() { @@ -787,17 +1293,33 @@ async function onViewGraph() { async function onRebuild() { if (!confirm("确定要从当前聊天重建图谱?这将清除现有图谱数据。")) return; - currentGraph = createEmptyGraph(); - lastExtractedItems = []; - lastRecalledItems = []; - lastInjectionContent = ""; - saveGraphToChat(); + const context = getContext(); + const chat = context?.chat; + if (!Array.isArray(chat)) { + toastr.warning("当前聊天上下文不可用,无法重建"); + return; + } - toastr.info("图谱已重置,将在下次生成时重新提取"); + currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), getCurrentChatId()); + currentGraph.batchJournal = []; + clearInjectionState(); + await prepareVectorStateForReplay(true); + await replayExtractionFromHistory(chat, getSettings()); + clearHistoryDirty( + currentGraph, + buildRecoveryResult("full-rebuild", { + fromFloor: 0, + batches: currentGraph.batchJournal.length, + reason: "用户手动触发全量重建", + }), + ); + saveGraphToChat(); + toastr.success("图谱与向量索引已按当前聊天全量重建"); } async function onManualCompress() { if (!currentGraph) return; + const beforeSnapshot = cloneGraphSnapshot(currentGraph); const result = await compressAll( currentGraph, @@ -805,7 +1327,10 @@ async function onManualCompress() { getEmbeddingConfig(), false, ); - saveGraphToChat(); + await recordGraphMutation({ + beforeSnapshot, + artifactTags: ["compression"], + }); toastr.info(`压缩完成: 新建 ${result.created}, 归档 ${result.archived}`); } @@ -835,10 +1360,15 @@ async function onImportGraph() { try { const text = await file.text(); - currentGraph = importGraph(text); + currentGraph = normalizeGraphRuntimeState( + importGraph(text), + getCurrentChatId(), + ); + markVectorStateDirty("导入图谱后需要重建向量索引"); + extractionCount = 0; lastExtractedItems = []; updateLastRecalledItems(currentGraph.lastRecallResult || []); - lastInjectionContent = ""; + clearInjectionState(); saveGraphToChat(); toastr.success("图谱已导入"); } catch (err) { @@ -872,13 +1402,14 @@ async function onViewLastInjection() { async function onTestEmbedding() { const config = getEmbeddingConfig(); - if (!config.apiUrl || !config.model) { - toastr.warning("请先配置 Embedding API 地址和模型"); + const validation = validateVectorConfig(config); + if (!validation.valid) { + toastr.warning(validation.error); return; } toastr.info("正在测试 Embedding API 连通性..."); - const result = await testEmbeddingConnection(config); + const result = await testVectorConnection(config, getCurrentChatId()); if (result.success) { toastr.success(`连接成功!向量维度: ${result.dimensions}`); @@ -900,7 +1431,9 @@ async function onTestMemoryLLM() { async function onManualExtract() { if (isExtracting) return; - if (!currentGraph) currentGraph = createEmptyGraph(); + if (!(await recoverHistoryIfNeeded("manual-extract"))) return; + await ensureVectorReadyIfNeeded("manual-extract"); + if (!currentGraph) currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), getCurrentChatId()); const context = getContext(); const chat = context.chat; @@ -909,16 +1442,8 @@ async function onManualExtract() { return; } - const assistantTurns = []; - for (let i = 0; i < chat.length; i++) { - if (chat[i].is_user === false && !chat[i].is_system) { - assistantTurns.push(i); - } - } - - const lastProcessed = Number.isFinite(currentGraph.lastProcessedSeq) - ? currentGraph.lastProcessedSeq - : -1; + const assistantTurns = getAssistantTurns(chat); + const lastProcessed = getLastProcessedAssistantFloor(); const pendingAssistantTurns = assistantTurns.filter((i) => i > lastProcessed); if (pendingAssistantTurns.length === 0) { toastr.info("没有待提取的新回复"); @@ -928,45 +1453,22 @@ async function onManualExtract() { const startIdx = pendingAssistantTurns[0]; const endIdx = pendingAssistantTurns[pendingAssistantTurns.length - 1]; const settings = getSettings(); - const contextTurns = clampInt(settings.extractContextTurns, 2, 0, 20); - const contextStart = Math.max(0, startIdx - contextTurns * 2); - const messages = []; - - for (let i = contextStart; i <= endIdx && i < chat.length; i++) { - const msg = chat[i]; - if (msg.is_system) continue; - messages.push({ - seq: i, - role: msg.is_user ? "user" : "assistant", - content: msg.mes || "", - }); - } - isExtracting = true; try { - const result = await extractMemories({ - graph: currentGraph, - messages, - startSeq: startIdx, - endSeq: endIdx, - lastProcessedSeq: lastProcessed, - schema: getSchema(), - embeddingConfig: getEmbeddingConfig(), - extractPrompt: settings.extractPrompt || undefined, - v2Options: { - enablePreciseConflict: settings.enablePreciseConflict, - conflictThreshold: settings.conflictThreshold, - }, + const batchResult = await executeExtractionBatch({ + chat, + startIdx, + endIdx, + settings, }); - if (!result.success) { + if (!batchResult.success) { toastr.warning("手动提取未返回有效结果"); return; } - await handleExtractionSuccess(result, endIdx, settings); toastr.success( - `提取完成:新建 ${result.newNodes},更新 ${result.updatedNodes},新边 ${result.newEdges}`, + `提取完成:新建 ${batchResult.result.newNodes},更新 ${batchResult.result.updatedNodes},新边 ${batchResult.result.newEdges}`, ); } catch (e) { console.error("[ST-BME] 手动提取失败:", e); @@ -978,19 +1480,27 @@ async function onManualExtract() { async function onManualSleep() { if (!currentGraph) return; + const beforeSnapshot = cloneGraphSnapshot(currentGraph); const result = sleepCycle(currentGraph, getSettings()); - saveGraphToChat(); + await recordGraphMutation({ + beforeSnapshot, + artifactTags: ["sleep"], + }); toastr.info(`执行完成:归档 ${result.forgotten} 个节点`); } async function onManualSynopsis() { if (!currentGraph) return; + const beforeSnapshot = cloneGraphSnapshot(currentGraph); await generateSynopsis({ graph: currentGraph, schema: getSchema(), currentSeq: getCurrentChatSeq(), }); - saveGraphToChat(); + await recordGraphMutation({ + beforeSnapshot, + artifactTags: ["synopsis"], + }); toastr.success("概要生成完成"); } @@ -1003,18 +1513,55 @@ async function onManualEvolve() { return; } + const beforeSnapshot = cloneGraphSnapshot(currentGraph); const result = await evolveMemories({ graph: currentGraph, newNodeIds: candidateIds, embeddingConfig: getEmbeddingConfig(), options: { neighborCount: getSettings().evoNeighborCount }, }); - saveGraphToChat(); + await recordGraphMutation({ + beforeSnapshot, + artifactTags: ["evolution"], + }); toastr.success( `进化完成:${result.evolved} 次进化,${result.connections} 条链接,${result.updates} 个回溯更新`, ); } +async function onRebuildVectorIndex(range = null) { + ensureCurrentGraphRuntimeState(); + const config = getEmbeddingConfig(); + const validation = validateVectorConfig(config); + if (!validation.valid) { + toastr.warning(validation.error); + return; + } + + const result = await syncVectorState({ + force: true, + purge: isBackendVectorConfig(config) && !range, + range, + }); + + saveGraphToChat(); + toastr.success( + range + ? `范围向量重建完成:indexed=${result.stats.indexed}, pending=${result.stats.pending}` + : `当前聊天向量重建完成:indexed=${result.stats.indexed}, pending=${result.stats.pending}`, + ); +} + +async function onReembedDirect() { + const config = getEmbeddingConfig(); + if (!isDirectVectorConfig(config)) { + toastr.info("当前不是直连模式,无需执行重嵌"); + return; + } + + await onRebuildVectorIndex(); +} + // ==================== 设置 UI ==================== function bindSettingsUI() { @@ -1279,6 +1826,12 @@ function bindSettingsUI() { onBeforeCombinePrompts, ); eventSource.on(event_types.MESSAGE_RECEIVED, onMessageReceived); + eventSource.on(event_types.MESSAGE_DELETED, onMessageDeleted); + eventSource.on(event_types.MESSAGE_EDITED, onMessageEdited); + eventSource.on(event_types.MESSAGE_SWIPED, onMessageSwiped); + if (event_types.MESSAGE_UPDATED) { + eventSource.on(event_types.MESSAGE_UPDATED, onMessageEdited); + } // 加载当前聊天的图谱 loadGraphFromChat(); @@ -1320,6 +1873,9 @@ function bindSettingsUI() { evolve: onManualEvolve, testEmbedding: onTestEmbedding, testMemoryLLM: onTestMemoryLLM, + rebuildVectorIndex: () => onRebuildVectorIndex(), + rebuildVectorRange: (range) => onRebuildVectorIndex(range), + reembedDirect: onReembedDirect, }, }); diff --git a/panel.html b/panel.html index 3696c63..99fe6c2 100644 --- a/panel.html +++ b/panel.html @@ -61,6 +61,26 @@ +
+
运行状态
+
+ +
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
@@ -137,6 +157,32 @@ 强制进化 + + + +
+
+
范围重建
+
+ 仅重建与指定楼层范围相交的节点向量。留空时默认按整段聊天处理。 +
+
+ + +
+
+ + +
@@ -207,7 +253,49 @@
Embedding
- 图谱向量仍使用 OpenAI 兼容的 /v1/embeddings 接口。当前发布版不改酒馆本体,因此这里不会依赖额外宿主补丁;若目标服务不支持浏览器直连,请改用支持 CORS 的服务或本地可直连端点。 + 向量支持两种模式:后端索引优先,以及完全独立的直连兜底。后端模式会优先复用酒馆现成 provider;直连模式则继续使用你自己的第二套 URL/Key/Model。 +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ 如果当前页面是 HTTPS 而你填的是 HTTP,本地浏览器可能会拦截混合内容;远程部署时请优先使用 HTTPS 或宿主可访问的同源入口。 +
+
+ 直连模式会使用下面这组独立配置:
diff --git a/panel.js b/panel.js index 66ca95d..8ce2c1e 100644 --- a/panel.js +++ b/panel.js @@ -3,6 +3,10 @@ import { renderTemplateAsync } from "../../../templates.js"; import { GraphRenderer } from "./graph-renderer.js"; import { getNodeColors } from "./themes.js"; +import { + getSuggestedBackendModel, + getVectorIndexStats, +} from "./vector-index.js"; let panelEl = null; let overlayEl = null; @@ -171,6 +175,32 @@ function _refreshDashboard() { `NODES: ${activeNodes.length} | EDGES: ${graph.edges.length}`, ); + const chatId = graph?.historyState?.chatId || "—"; + const lastProcessed = graph?.historyState?.lastProcessedAssistantFloor ?? -1; + const dirtyFrom = graph?.historyState?.historyDirtyFrom; + const vectorStats = getVectorIndexStats(graph); + const vectorMode = graph?.vectorIndexState?.mode || "—"; + const vectorSource = graph?.vectorIndexState?.source || "—"; + const recovery = graph?.historyState?.lastRecoveryResult; + + _setText("bme-status-chat-id", chatId); + _setText( + "bme-status-history", + Number.isFinite(dirtyFrom) + ? `脏区从楼层 ${dirtyFrom} 开始,已处理到 ${lastProcessed}` + : `干净,已处理到楼层 ${lastProcessed}`, + ); + _setText( + "bme-status-vector", + `${vectorMode}/${vectorSource} · total ${vectorStats.total} · indexed ${vectorStats.indexed} · stale ${vectorStats.stale} · pending ${vectorStats.pending}`, + ); + _setText( + "bme-status-recovery", + recovery + ? `${recovery.status} · from ${recovery.fromFloor ?? "—"} · ${recovery.reason || "—"}` + : "暂无恢复记录", + ); + _renderRecentList("bme-recent-extract", _getLastExtract?.() || []); _renderRecentList("bme-recent-recall", _getLastRecall?.() || []); } @@ -413,6 +443,8 @@ function _bindActions() { "bme-act-import": "import", "bme-act-rebuild": "rebuild", "bme-act-evolve": "evolve", + "bme-act-vector-rebuild": "rebuildVectorIndex", + "bme-act-vector-reembed": "reembedDirect", }; for (const [elementId, actionKey] of Object.entries(bindings)) { @@ -435,6 +467,22 @@ function _bindActions() { } }); } + + document.getElementById("bme-act-vector-range")?.addEventListener("click", async () => { + try { + const start = _parseOptionalInt(document.getElementById("bme-range-start")?.value); + const end = _parseOptionalInt(document.getElementById("bme-range-end")?.value); + await _actionHandlers.rebuildVectorRange?.( + Number.isFinite(start) && Number.isFinite(end) + ? { start, end } + : null, + ); + _refreshDashboard(); + _refreshGraph(); + } catch (error) { + console.error("[ST-BME] Action rebuildVectorRange failed:", error); + } + }); } function _refreshConfigTab() { @@ -461,6 +509,26 @@ function _refreshConfigTab() { "bme-setting-embed-model", settings.embeddingModel || "text-embedding-3-small", ); + _setInputValue( + "bme-setting-embed-mode", + settings.embeddingTransportMode || "backend", + ); + _setInputValue( + "bme-setting-embed-backend-source", + settings.embeddingBackendSource || "openai", + ); + _setInputValue( + "bme-setting-embed-backend-model", + settings.embeddingBackendModel || getSuggestedBackendModel(settings.embeddingBackendSource || "openai"), + ); + _setInputValue( + "bme-setting-embed-backend-url", + settings.embeddingBackendApiUrl || "", + ); + _setCheckboxValue( + "bme-setting-embed-auto-suffix", + settings.embeddingAutoSuffix !== false, + ); _setInputValue("bme-setting-extract-prompt", settings.extractPrompt || ""); _setInputValue("bme-setting-panel-theme", settings.panelTheme || "crimson"); @@ -510,6 +578,28 @@ function _bindConfigControls() { bindText("bme-setting-embed-model", (value) => _updateSettings?.({ embeddingModel: value.trim() }), ); + bindText("bme-setting-embed-mode", (value) => + _updateSettings?.({ embeddingTransportMode: value }), + ); + bindText("bme-setting-embed-backend-source", (value) => { + const patch = { embeddingBackendSource: value }; + const settings = _getSettings?.() || {}; + const suggestedModel = getSuggestedBackendModel(value); + if (!settings.embeddingBackendModel || settings.embeddingBackendModel === getSuggestedBackendModel(settings.embeddingBackendSource || "openai")) { + patch.embeddingBackendModel = suggestedModel; + } + _updateSettings?.(patch); + _setInputValue("bme-setting-embed-backend-model", patch.embeddingBackendModel || settings.embeddingBackendModel || ""); + }); + bindText("bme-setting-embed-backend-model", (value) => + _updateSettings?.({ embeddingBackendModel: value.trim() }), + ); + bindText("bme-setting-embed-backend-url", (value) => + _updateSettings?.({ embeddingBackendApiUrl: value.trim() }), + ); + bindCheckbox("bme-setting-embed-auto-suffix", (checked) => + _updateSettings?.({ embeddingAutoSuffix: checked }), + ); bindText("bme-setting-extract-prompt", (value) => _updateSettings?.({ extractPrompt: value }), ); @@ -575,6 +665,11 @@ function _setCheckboxValue(id, checked) { } } +function _parseOptionalInt(value) { + const parsed = Number.parseInt(String(value ?? "").trim(), 10); + return Number.isFinite(parsed) ? parsed : null; +} + function _escHtml(str) { const div = document.createElement("div"); div.textContent = String(str ?? ""); diff --git a/retriever.js b/retriever.js index ef97b99..5abfd7b 100644 --- a/retriever.js +++ b/retriever.js @@ -4,7 +4,6 @@ import { diffuseAndRank } from "./diffusion.js"; import { hybridScore, reinforceAccessBatch } from "./dynamics.js"; -import { embedText, searchSimilar } from "./embedding.js"; import { buildTemporalAdjacencyMap, getActiveNodes, @@ -12,6 +11,7 @@ import { getNodeEdges, } from "./graph.js"; import { callLLMForJSON } from "./llm.js"; +import { findSimilarNodesByText, validateVectorConfig } from "./vector-index.js"; /** * 自适应阈值 @@ -82,9 +82,13 @@ export async function retrieve({ } // ========== 第 1 层:向量预筛 ========== - if (nodeCount >= STRATEGY_THRESHOLDS.SMALL && embeddingConfig?.apiUrl) { + if ( + nodeCount >= STRATEGY_THRESHOLDS.SMALL && + validateVectorConfig(embeddingConfig).valid + ) { console.log("[ST-BME] 第1层: 向量预筛"); vectorResults = await vectorPreFilter( + graph, userMessage, activeNodes, embeddingConfig, @@ -270,20 +274,20 @@ export async function retrieve({ * 向量预筛选 */ async function vectorPreFilter( + graph, userMessage, activeNodes, embeddingConfig, topK, ) { try { - const queryVec = await embedText(userMessage, embeddingConfig); - if (!queryVec) return []; - - const candidates = activeNodes - .filter((n) => Array.isArray(n.embedding) && n.embedding.length > 0) - .map((n) => ({ nodeId: n.id, embedding: n.embedding })); - - return searchSimilar(queryVec, candidates, topK); + return await findSimilarNodesByText( + graph, + userMessage, + embeddingConfig, + topK, + activeNodes, + ); } catch (e) { console.error("[ST-BME] 向量预筛失败:", e); return []; diff --git a/runtime-state.js b/runtime-state.js new file mode 100644 index 0000000..796dcb9 --- /dev/null +++ b/runtime-state.js @@ -0,0 +1,352 @@ +// ST-BME: 运行时状态与历史恢复辅助 + +const BATCH_JOURNAL_LIMIT = 24; + +export function buildVectorCollectionId(chatId) { + return `st-bme::${chatId || "unknown-chat"}`; +} + +export function createDefaultHistoryState(chatId = "") { + return { + chatId, + lastProcessedAssistantFloor: -1, + processedMessageHashes: {}, + historyDirtyFrom: null, + lastMutationReason: "", + lastRecoveryResult: null, + }; +} + +export function createDefaultVectorIndexState(chatId = "") { + return { + mode: "backend", + collectionId: buildVectorCollectionId(chatId), + source: "", + modelScope: "", + hashToNodeId: {}, + nodeToHash: {}, + dirty: false, + lastSyncAt: 0, + lastStats: { + total: 0, + indexed: 0, + stale: 0, + pending: 0, + }, + lastWarning: "", + }; +} + +export function createDefaultBatchJournal() { + return []; +} + +export function normalizeGraphRuntimeState(graph, chatId = "") { + if (!graph || typeof graph !== "object") { + return graph; + } + + const historyState = { + ...createDefaultHistoryState(chatId), + ...(graph.historyState || {}), + }; + const vectorIndexState = { + ...createDefaultVectorIndexState(chatId), + ...(graph.vectorIndexState || {}), + }; + + historyState.chatId = chatId || historyState.chatId || ""; + if (!Number.isFinite(historyState.lastProcessedAssistantFloor)) { + historyState.lastProcessedAssistantFloor = Number.isFinite(graph.lastProcessedSeq) + ? graph.lastProcessedSeq + : -1; + } + + if ( + !historyState.processedMessageHashes || + typeof historyState.processedMessageHashes !== "object" || + Array.isArray(historyState.processedMessageHashes) + ) { + historyState.processedMessageHashes = {}; + } + + if ( + !vectorIndexState.hashToNodeId || + typeof vectorIndexState.hashToNodeId !== "object" || + Array.isArray(vectorIndexState.hashToNodeId) + ) { + vectorIndexState.hashToNodeId = {}; + } + if ( + !vectorIndexState.nodeToHash || + typeof vectorIndexState.nodeToHash !== "object" || + Array.isArray(vectorIndexState.nodeToHash) + ) { + vectorIndexState.nodeToHash = {}; + } + if (!vectorIndexState.lastStats || typeof vectorIndexState.lastStats !== "object") { + vectorIndexState.lastStats = createDefaultVectorIndexState(chatId).lastStats; + } + + const previousCollectionId = vectorIndexState.collectionId; + vectorIndexState.collectionId = buildVectorCollectionId(chatId || historyState.chatId); + + if (previousCollectionId && previousCollectionId !== vectorIndexState.collectionId) { + vectorIndexState.hashToNodeId = {}; + vectorIndexState.nodeToHash = {}; + vectorIndexState.dirty = true; + vectorIndexState.lastWarning = "聊天标识变化,向量索引已标记为待重建"; + } + + graph.historyState = historyState; + graph.vectorIndexState = vectorIndexState; + graph.batchJournal = Array.isArray(graph.batchJournal) + ? graph.batchJournal.slice(-BATCH_JOURNAL_LIMIT) + : createDefaultBatchJournal(); + graph.lastProcessedSeq = historyState.lastProcessedAssistantFloor; + return graph; +} + +export function cloneGraphSnapshot(graph) { + const snapshot = JSON.parse(JSON.stringify(graph)); + + if (Array.isArray(snapshot.batchJournal)) { + snapshot.batchJournal = snapshot.batchJournal.map((journal) => { + if (!journal?.snapshotBefore) return journal; + return { + ...journal, + snapshotBefore: { + ...journal.snapshotBefore, + batchJournal: [], + }, + }; + }); + } + + return snapshot; +} + +export function stableHashString(text) { + let hash = 2166136261; + for (const char of String(text || "")) { + hash ^= char.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return Math.abs(hash >>> 0); +} + +export function buildMessageHash(message) { + const swipeId = Number.isFinite(message?.swipe_id) ? message.swipe_id : null; + const payload = JSON.stringify({ + isUser: Boolean(message?.is_user), + isSystem: Boolean(message?.is_system), + text: String(message?.mes || ""), + swipeId, + }); + return String(stableHashString(payload)); +} + +export function snapshotProcessedMessageHashes(chat, lastProcessedAssistantFloor) { + const result = {}; + if (!Array.isArray(chat) || lastProcessedAssistantFloor < 0) { + return result; + } + + const upperBound = Math.min(lastProcessedAssistantFloor, chat.length - 1); + for (let index = 0; index <= upperBound; index++) { + result[index] = buildMessageHash(chat[index]); + } + return result; +} + +export function detectHistoryMutation(chat, historyState) { + const lastProcessedAssistantFloor = + historyState?.lastProcessedAssistantFloor ?? -1; + const processedMessageHashes = historyState?.processedMessageHashes || {}; + + if (!Array.isArray(chat) || lastProcessedAssistantFloor < 0) { + return { dirty: false, earliestAffectedFloor: null, reason: "" }; + } + + const trackedFloors = Object.keys(processedMessageHashes) + .map((value) => Number.parseInt(value, 10)) + .filter(Number.isFinite) + .sort((a, b) => a - b); + + if (trackedFloors.length === 0) { + return { dirty: false, earliestAffectedFloor: null, reason: "" }; + } + + for (const floor of trackedFloors) { + if (floor >= chat.length) { + return { + dirty: true, + earliestAffectedFloor: floor, + reason: `楼层 ${floor} 已不存在,检测到历史删除/截断`, + }; + } + + const currentHash = buildMessageHash(chat[floor]); + if (currentHash !== processedMessageHashes[floor]) { + return { + dirty: true, + earliestAffectedFloor: floor, + reason: `楼层 ${floor} 内容或 swipe 已变化`, + }; + } + } + + if (lastProcessedAssistantFloor >= chat.length) { + return { + dirty: true, + earliestAffectedFloor: chat.length, + reason: "已处理楼层超出当前聊天长度,检测到历史截断", + }; + } + + return { dirty: false, earliestAffectedFloor: null, reason: "" }; +} + +export function markHistoryDirty(graph, floor, reason = "") { + normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || ""); + const currentDirtyFrom = graph.historyState.historyDirtyFrom; + + if (!Number.isFinite(floor)) { + floor = graph.historyState.lastProcessedAssistantFloor; + } + + graph.historyState.historyDirtyFrom = Number.isFinite(currentDirtyFrom) + ? Math.min(currentDirtyFrom, floor) + : floor; + graph.historyState.lastMutationReason = String(reason || "").trim(); + graph.historyState.lastRecoveryResult = { + status: "pending", + at: Date.now(), + fromFloor: graph.historyState.historyDirtyFrom, + reason: graph.historyState.lastMutationReason, + }; +} + +export function clearHistoryDirty(graph, result = null) { + normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || ""); + graph.historyState.historyDirtyFrom = null; + graph.historyState.lastMutationReason = ""; + if (result) { + graph.historyState.lastRecoveryResult = result; + } +} + +function buildNodeMap(nodes = []) { + return new Map(nodes.map((node) => [node.id, node])); +} + +function buildEdgeMap(edges = []) { + return new Map(edges.map((edge) => [edge.id, edge])); +} + +function hasMeaningfulNodeChange(beforeNode, afterNode) { + return JSON.stringify(beforeNode) !== JSON.stringify(afterNode); +} + +function hasMeaningfulEdgeChange(beforeEdge, afterEdge) { + return JSON.stringify(beforeEdge) !== JSON.stringify(afterEdge); +} + +export function createBatchJournalEntry(snapshotBefore, snapshotAfter, meta = {}) { + const beforeNodes = buildNodeMap(snapshotBefore?.nodes || []); + const afterNodes = buildNodeMap(snapshotAfter?.nodes || []); + const beforeEdges = buildEdgeMap(snapshotBefore?.edges || []); + const afterEdges = buildEdgeMap(snapshotAfter?.edges || []); + + const createdNodeIds = []; + const createdEdgeIds = []; + const updatedNodeSnapshots = []; + const archivedNodeSnapshots = []; + const invalidatedEdgeSnapshots = []; + + for (const [nodeId, afterNode] of afterNodes.entries()) { + if (!beforeNodes.has(nodeId)) { + createdNodeIds.push(nodeId); + continue; + } + + const beforeNode = beforeNodes.get(nodeId); + if (!hasMeaningfulNodeChange(beforeNode, afterNode)) continue; + updatedNodeSnapshots.push(cloneGraphSnapshot(beforeNode)); + + if (beforeNode.archived !== afterNode.archived) { + archivedNodeSnapshots.push(cloneGraphSnapshot(beforeNode)); + } + } + + for (const [edgeId, afterEdge] of afterEdges.entries()) { + if (!beforeEdges.has(edgeId)) { + createdEdgeIds.push(edgeId); + continue; + } + + const beforeEdge = beforeEdges.get(edgeId); + if (!hasMeaningfulEdgeChange(beforeEdge, afterEdge)) continue; + if ( + beforeEdge.invalidAt !== afterEdge.invalidAt || + beforeEdge.expiredAt !== afterEdge.expiredAt + ) { + invalidatedEdgeSnapshots.push(cloneGraphSnapshot(beforeEdge)); + } + } + + return { + id: `batch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + createdAt: Date.now(), + processedRange: meta.processedRange || [-1, -1], + createdNodeIds, + createdEdgeIds, + updatedNodeSnapshots, + archivedNodeSnapshots, + invalidatedEdgeSnapshots, + vectorHashesInserted: Array.isArray(meta.vectorHashesInserted) + ? [...new Set(meta.vectorHashesInserted)] + : [], + postProcessArtifacts: Array.isArray(meta.postProcessArtifacts) + ? meta.postProcessArtifacts + : [], + snapshotBefore, + }; +} + +export function appendBatchJournal(graph, entry) { + normalizeGraphRuntimeState(graph, graph?.historyState?.chatId || ""); + graph.batchJournal.push(entry); + if (graph.batchJournal.length > BATCH_JOURNAL_LIMIT) { + graph.batchJournal = graph.batchJournal.slice(-BATCH_JOURNAL_LIMIT); + } +} + +export function findJournalRecoveryPoint(graph, dirtyFromFloor) { + const journals = Array.isArray(graph?.batchJournal) ? graph.batchJournal : []; + const affectedIndex = journals.findIndex((journal) => { + const range = Array.isArray(journal?.processedRange) + ? journal.processedRange + : [-1, -1]; + return Number.isFinite(range[1]) && range[1] >= dirtyFromFloor; + }); + + if (affectedIndex < 0) return null; + + const journal = journals[affectedIndex]; + if (!journal?.snapshotBefore) return null; + + return { + affectedIndex, + journal, + snapshotBefore: cloneGraphSnapshot(journal.snapshotBefore), + }; +} + +export function buildRecoveryResult(status, extra = {}) { + return { + status, + at: Date.now(), + ...extra, + }; +} diff --git a/tests/runtime-history.mjs b/tests/runtime-history.mjs new file mode 100644 index 0000000..92e5f96 --- /dev/null +++ b/tests/runtime-history.mjs @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { + appendBatchJournal, + cloneGraphSnapshot, + createBatchJournalEntry, + detectHistoryMutation, + findJournalRecoveryPoint, + snapshotProcessedMessageHashes, +} from "../runtime-state.js"; +import { createEmptyGraph } from "../graph.js"; + +const chat = [ + { is_user: true, mes: "你好" }, + { is_user: false, mes: "我记住了。" }, + { is_user: true, mes: "继续" }, + { is_user: false, mes: "新的回复" }, +]; + +const hashes = snapshotProcessedMessageHashes(chat, 3); +const cleanDetection = detectHistoryMutation(chat, { + lastProcessedAssistantFloor: 3, + processedMessageHashes: hashes, +}); +assert.equal(cleanDetection.dirty, false); + +const editedChat = structuredClone(chat); +editedChat[1].mes = "我改过内容了。"; +const editedDetection = detectHistoryMutation(editedChat, { + lastProcessedAssistantFloor: 3, + processedMessageHashes: hashes, +}); +assert.equal(editedDetection.dirty, true); +assert.equal(editedDetection.earliestAffectedFloor, 1); + +const truncatedChat = chat.slice(0, 2); +const truncatedDetection = detectHistoryMutation(truncatedChat, { + lastProcessedAssistantFloor: 3, + processedMessageHashes: hashes, +}); +assert.equal(truncatedDetection.dirty, true); +assert.equal(truncatedDetection.earliestAffectedFloor, 2); + +const graph = createEmptyGraph(); +graph.historyState.chatId = "chat-history-test"; +const beforeSnapshot = cloneGraphSnapshot(graph); +graph.lastProcessedSeq = 3; +graph.historyState.lastProcessedAssistantFloor = 3; +const afterSnapshot = cloneGraphSnapshot(graph); +appendBatchJournal( + graph, + createBatchJournalEntry(beforeSnapshot, afterSnapshot, { + processedRange: [1, 3], + postProcessArtifacts: ["compression"], + vectorHashesInserted: [1234], + }), +); + +const recoveryPoint = findJournalRecoveryPoint(graph, 2); +assert.ok(recoveryPoint); +assert.equal(recoveryPoint.journal.processedRange[1], 3); +assert.equal( + recoveryPoint.snapshotBefore.historyState.lastProcessedAssistantFloor, + -1, +); + +console.log("runtime-history tests passed"); diff --git a/tests/vector-config.mjs b/tests/vector-config.mjs new file mode 100644 index 0000000..b96b93b --- /dev/null +++ b/tests/vector-config.mjs @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import vm from "node:vm"; + +async function loadVectorHelpers() { + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const sourcePath = path.resolve(__dirname, "../vector-index.js"); + const source = await fs.readFile(sourcePath, "utf8"); + + const pieces = [ + source.match(/export const BACKEND_VECTOR_SOURCES = \[[\s\S]*?\];/m)?.[0], + source.match(/export const BACKEND_DEFAULT_MODELS = \{[\s\S]*?\};/m)?.[0], + source.match(/const BACKEND_SOURCES_REQUIRING_API_URL = new Set\([\s\S]*?\);/m)?.[0], + source.match(/export function normalizeOpenAICompatibleBaseUrl\(value, autoSuffix = true\) \{[\s\S]*?^\}/m)?.[0], + source.match(/export function getVectorConfigFromSettings\(settings = \{\}\) \{[\s\S]*?^\}/m)?.[0], + source.match(/export function isBackendVectorConfig\(config\) \{[\s\S]*?^\}/m)?.[0], + source.match(/export function isDirectVectorConfig\(config\) \{[\s\S]*?^\}/m)?.[0], + source.match(/export function validateVectorConfig\(config\) \{[\s\S]*?^\}/m)?.[0], + ].filter(Boolean); + + if (pieces.length < 8) { + throw new Error("无法从 vector-index.js 提取向量配置辅助函数"); + } + + const context = vm.createContext({}); + const script = new vm.Script(` +${pieces.join("\n\n").replaceAll("export ", "")} +this.getVectorConfigFromSettings = getVectorConfigFromSettings; +this.validateVectorConfig = validateVectorConfig; + `); + script.runInContext(context); + return { + getVectorConfigFromSettings: context.getVectorConfigFromSettings, + validateVectorConfig: context.validateVectorConfig, + }; +} + +const { getVectorConfigFromSettings, validateVectorConfig } = + await loadVectorHelpers(); + +const backendConfig = getVectorConfigFromSettings({ + embeddingTransportMode: "backend", + embeddingBackendSource: "openai", + embeddingBackendModel: "", +}); +assert.equal(backendConfig.mode, "backend"); +assert.equal(backendConfig.source, "openai"); +assert.equal(backendConfig.model, "text-embedding-3-small"); +assert.equal(validateVectorConfig(backendConfig).valid, true); + +const directConfig = getVectorConfigFromSettings({ + embeddingTransportMode: "direct", + embeddingApiUrl: "https://example.com/v1/embeddings", + embeddingApiKey: "sk-test", + embeddingModel: "text-embedding-3-small", +}); +assert.equal(directConfig.mode, "direct"); +assert.equal(directConfig.apiUrl, "https://example.com/v1"); +assert.equal(validateVectorConfig(directConfig).valid, true); + +const invalidBackendConfig = getVectorConfigFromSettings({ + embeddingTransportMode: "backend", + embeddingBackendSource: "vllm", + embeddingBackendApiUrl: "", + embeddingBackendModel: "BAAI/bge-m3", +}); +assert.equal(validateVectorConfig(invalidBackendConfig).valid, false); + +console.log("vector-config tests passed"); diff --git a/vector-index.js b/vector-index.js new file mode 100644 index 0000000..60bd177 --- /dev/null +++ b/vector-index.js @@ -0,0 +1,641 @@ +// ST-BME: 向量模式、后端索引与直连兜底 + +import { getRequestHeaders } from "../../../../script.js"; +import { embedBatch, embedText, searchSimilar } from "./embedding.js"; +import { getActiveNodes } from "./graph.js"; +import { + buildVectorCollectionId, + stableHashString, +} from "./runtime-state.js"; + +export const BACKEND_VECTOR_SOURCES = [ + "openai", + "openrouter", + "cohere", + "mistral", + "electronhub", + "chutes", + "nanogpt", + "ollama", + "llamacpp", + "vllm", +]; + +const BACKEND_SOURCES_REQUIRING_API_URL = new Set([ + "ollama", + "llamacpp", + "vllm", +]); + +export const BACKEND_DEFAULT_MODELS = { + openai: "text-embedding-3-small", + openrouter: "openai/text-embedding-3-small", + cohere: "embed-multilingual-v3.0", + mistral: "mistral-embed", + electronhub: "text-embedding-3-small", + chutes: "chutes-qwen-qwen3-embedding-8b", + nanogpt: "text-embedding-3-small", + ollama: "nomic-embed-text", + llamacpp: "text-embedding-3-small", + vllm: "BAAI/bge-m3", +}; + +export function normalizeOpenAICompatibleBaseUrl(value, autoSuffix = true) { + let normalized = String(value || "") + .trim() + .replace(/\/+(chat\/completions|embeddings)$/i, "") + .replace(/\/+$/, ""); + + if (autoSuffix && normalized && !/\/v\d+$/i.test(normalized)) { + normalized = normalized; + } + + return normalized; +} + +export function getVectorConfigFromSettings(settings = {}) { + const mode = + settings.embeddingTransportMode === "direct" ? "direct" : "backend"; + const autoSuffix = settings.embeddingAutoSuffix !== false; + + if (mode === "direct") { + return { + mode, + source: "direct", + apiUrl: normalizeOpenAICompatibleBaseUrl(settings.embeddingApiUrl, autoSuffix), + apiKey: String(settings.embeddingApiKey || "").trim(), + model: String(settings.embeddingModel || "").trim(), + autoSuffix, + }; + } + + const source = BACKEND_VECTOR_SOURCES.includes(settings.embeddingBackendSource) + ? settings.embeddingBackendSource + : "openai"; + + return { + mode, + source, + apiUrl: normalizeOpenAICompatibleBaseUrl( + settings.embeddingBackendApiUrl, + autoSuffix, + ), + apiKey: "", + model: String( + settings.embeddingBackendModel || BACKEND_DEFAULT_MODELS[source] || "", + ).trim(), + autoSuffix, + }; +} + +export function getSuggestedBackendModel(source) { + return BACKEND_DEFAULT_MODELS[source] || "text-embedding-3-small"; +} + +export function isBackendVectorConfig(config) { + return config?.mode === "backend"; +} + +export function isDirectVectorConfig(config) { + return config?.mode === "direct"; +} + +export function getVectorModelScope(config) { + if (!config) return ""; + + if (isDirectVectorConfig(config)) { + return [ + "direct", + normalizeOpenAICompatibleBaseUrl(config.apiUrl, config.autoSuffix), + config.model || "", + ].join("|"); + } + + return [ + "backend", + config.source || "", + normalizeOpenAICompatibleBaseUrl(config.apiUrl, config.autoSuffix), + config.model || "", + ].join("|"); +} + +export function validateVectorConfig(config) { + if (!config) { + return { valid: false, error: "未找到向量配置" }; + } + + if (isDirectVectorConfig(config)) { + if (!config.apiUrl) { + return { valid: false, error: "请填写直连 Embedding API 地址" }; + } + if (!config.model) { + return { valid: false, error: "请填写直连 Embedding 模型" }; + } + return { valid: true, error: "" }; + } + + if (!config.model) { + return { valid: false, error: "请填写后端向量模型" }; + } + + if ( + BACKEND_SOURCES_REQUIRING_API_URL.has(config.source) && + !config.apiUrl + ) { + return { valid: false, error: "当前后端向量源需要填写 API 地址" }; + } + + return { valid: true, error: "" }; +} + +export function buildNodeVectorText(node) { + const fields = node?.fields || {}; + const preferredKeys = [ + "summary", + "insight", + "title", + "name", + "state", + "traits", + "constraint", + "goal", + "participants", + "suggestion", + "status", + "scope", + ]; + + const parts = []; + + for (const key of preferredKeys) { + const value = fields[key]; + if (value == null || value === "") continue; + if (Array.isArray(value)) { + if (value.length > 0) parts.push(value.join(", ")); + } else if (typeof value === "object") { + parts.push(JSON.stringify(value)); + } else { + parts.push(String(value)); + } + } + + for (const [key, value] of Object.entries(fields)) { + if (preferredKeys.includes(key) || value == null || value === "") continue; + if (key === "embedding") continue; + if (Array.isArray(value)) { + if (value.length > 0) parts.push(`${key}: ${value.join(", ")}`); + continue; + } + if (typeof value === "object") { + parts.push(`${key}: ${JSON.stringify(value)}`); + continue; + } + parts.push(`${key}: ${value}`); + } + + return parts.join(" | ").trim(); +} + +export function buildNodeVectorHash(node, config) { + const text = buildNodeVectorText(node); + const seqEnd = node?.seqRange?.[1] ?? node?.seq ?? 0; + const payload = [ + node?.id || "", + text, + String(seqEnd), + getVectorModelScope(config), + ].join("::"); + return stableHashString(payload); +} + +function buildBackendSourceRequest(config) { + const body = { + source: config.source, + model: config.model, + }; + + if (BACKEND_SOURCES_REQUIRING_API_URL.has(config.source)) { + body.apiUrl = config.apiUrl; + } + + if (config.source === "ollama") { + body.keep = false; + } + + return body; +} + +function getEligibleVectorNodes(graph, range = null) { + let nodes = getActiveNodes(graph).filter((node) => !node.archived); + + if (range && Number.isFinite(range.start) && Number.isFinite(range.end)) { + const start = Math.min(range.start, range.end); + const end = Math.max(range.start, range.end); + nodes = nodes.filter((node) => { + const seqStart = node?.seqRange?.[0] ?? node?.seq ?? -1; + const seqEnd = node?.seqRange?.[1] ?? node?.seq ?? -1; + return seqEnd >= start && seqStart <= end; + }); + } + + return nodes.filter((node) => buildNodeVectorText(node).length > 0); +} + +function buildDesiredVectorEntries(graph, config, range = null) { + return getEligibleVectorNodes(graph, range).map((node) => { + const hash = buildNodeVectorHash(node, config); + return { + nodeId: node.id, + hash, + text: buildNodeVectorText(node), + index: node?.seqRange?.[1] ?? node?.seq ?? 0, + }; + }); +} + +function computeVectorStats(graph, desiredEntries) { + const state = graph.vectorIndexState || {}; + const desiredByNodeId = new Map(desiredEntries.map((entry) => [entry.nodeId, entry])); + const nodeToHash = state.nodeToHash || {}; + const hashToNodeId = state.hashToNodeId || {}; + + let indexed = 0; + let pending = 0; + + for (const entry of desiredEntries) { + if (nodeToHash[entry.nodeId] === entry.hash) { + indexed++; + } else { + pending++; + } + } + + let stale = 0; + for (const [nodeId, hash] of Object.entries(nodeToHash)) { + const desired = desiredByNodeId.get(nodeId); + if (!desired || desired.hash !== hash || hashToNodeId[hash] !== nodeId) { + stale++; + } + } + + return { + total: desiredEntries.length, + indexed, + stale, + pending, + }; +} + +async function purgeVectorCollection(collectionId) { + const response = await fetch("/api/vector/purge", { + method: "POST", + headers: getRequestHeaders(), + body: JSON.stringify({ collectionId }), + }); + + if (!response.ok) { + const message = await response.text().catch(() => response.statusText); + throw new Error(message || `HTTP ${response.status}`); + } +} + +async function deleteVectorHashes(collectionId, config, hashes) { + if (!Array.isArray(hashes) || hashes.length === 0) return; + + const response = await fetch("/api/vector/delete", { + method: "POST", + headers: getRequestHeaders(), + body: JSON.stringify({ + collectionId, + hashes, + ...buildBackendSourceRequest(config), + }), + }); + + if (!response.ok) { + const message = await response.text().catch(() => response.statusText); + throw new Error(message || `HTTP ${response.status}`); + } +} + +async function insertVectorEntries(collectionId, config, entries) { + if (!Array.isArray(entries) || entries.length === 0) return; + + const response = await fetch("/api/vector/insert", { + method: "POST", + headers: getRequestHeaders(), + body: JSON.stringify({ + collectionId, + items: entries.map((entry) => ({ + hash: entry.hash, + text: entry.text, + index: entry.index, + })), + ...buildBackendSourceRequest(config), + }), + }); + + if (!response.ok) { + const message = await response.text().catch(() => response.statusText); + throw new Error(message || `HTTP ${response.status}`); + } +} + +function resetVectorMappings(graph, config, chatId) { + graph.vectorIndexState.mode = config.mode; + graph.vectorIndexState.source = config.source || ""; + graph.vectorIndexState.modelScope = getVectorModelScope(config); + graph.vectorIndexState.collectionId = buildVectorCollectionId(chatId); + graph.vectorIndexState.hashToNodeId = {}; + graph.vectorIndexState.nodeToHash = {}; +} + +export async function syncGraphVectorIndex( + graph, + config, + { + chatId = "", + purge = false, + force = false, + range = null, + } = {}, +) { + if (!graph || !config) { + return { insertedHashes: [], stats: { total: 0, indexed: 0, stale: 0, pending: 0 } }; + } + + const validation = validateVectorConfig(config); + if (!validation.valid) { + graph.vectorIndexState.lastWarning = validation.error; + graph.vectorIndexState.dirty = true; + return { insertedHashes: [], stats: graph.vectorIndexState.lastStats }; + } + + const state = graph.vectorIndexState; + const collectionId = buildVectorCollectionId(chatId || graph?.historyState?.chatId); + const desiredEntries = buildDesiredVectorEntries(graph, config, range); + const desiredByNodeId = new Map(desiredEntries.map((entry) => [entry.nodeId, entry])); + const insertedHashes = []; + const hasConcreteRange = + range && + Number.isFinite(range.start) && + Number.isFinite(range.end); + const rangedNodeIds = new Set(desiredEntries.map((entry) => entry.nodeId)); + + if (isBackendVectorConfig(config)) { + const scopeChanged = + state.mode !== "backend" || + state.source !== config.source || + state.modelScope !== getVectorModelScope(config) || + state.collectionId !== collectionId; + const fullReset = purge || state.dirty || scopeChanged || (force && !hasConcreteRange); + + if (fullReset) { + await purgeVectorCollection(collectionId); + resetVectorMappings(graph, config, chatId); + await insertVectorEntries(collectionId, config, desiredEntries); + for (const entry of desiredEntries) { + state.hashToNodeId[entry.hash] = entry.nodeId; + state.nodeToHash[entry.nodeId] = entry.hash; + insertedHashes.push(entry.hash); + } + } else { + const hashesToDelete = []; + const entriesToInsert = []; + + if (force && hasConcreteRange) { + for (const entry of desiredEntries) { + const currentHash = state.nodeToHash[entry.nodeId]; + if (currentHash) { + hashesToDelete.push(currentHash); + delete state.hashToNodeId[currentHash]; + delete state.nodeToHash[entry.nodeId]; + } + entriesToInsert.push(entry); + } + } + + for (const [nodeId, hash] of Object.entries(state.nodeToHash)) { + if (hasConcreteRange && !rangedNodeIds.has(nodeId)) { + continue; + } + const desired = desiredByNodeId.get(nodeId); + if (!desired || desired.hash !== hash) { + hashesToDelete.push(hash); + delete state.nodeToHash[nodeId]; + delete state.hashToNodeId[hash]; + } + } + + for (const entry of desiredEntries) { + if (force && hasConcreteRange) continue; + if (state.nodeToHash[entry.nodeId] === entry.hash) continue; + entriesToInsert.push(entry); + } + + await deleteVectorHashes(collectionId, config, hashesToDelete); + await insertVectorEntries(collectionId, config, entriesToInsert); + + for (const entry of entriesToInsert) { + state.hashToNodeId[entry.hash] = entry.nodeId; + state.nodeToHash[entry.nodeId] = entry.hash; + insertedHashes.push(entry.hash); + } + } + + for (const node of graph.nodes || []) { + if (Array.isArray(node.embedding) && node.embedding.length > 0) { + node.embedding = null; + } + } + } else { + const entriesToEmbed = []; + const hashByNodeId = {}; + + for (const entry of desiredEntries) { + hashByNodeId[entry.nodeId] = entry.hash; + const currentHash = state.nodeToHash?.[entry.nodeId]; + const node = graph.nodes.find((candidate) => candidate.id === entry.nodeId); + const hasEmbedding = Array.isArray(node?.embedding) && node.embedding.length > 0; + + if (!force && !currentHash && hasEmbedding) { + state.hashToNodeId[entry.hash] = entry.nodeId; + state.nodeToHash[entry.nodeId] = entry.hash; + continue; + } + + if (force || purge || currentHash !== entry.hash || !hasEmbedding) { + entriesToEmbed.push(entry); + } + } + + if (purge || state.mode !== "direct") { + resetVectorMappings(graph, config, chatId); + } else { + for (const [nodeId, hash] of Object.entries(state.nodeToHash || {})) { + if (hasConcreteRange && !rangedNodeIds.has(nodeId)) { + continue; + } + if (!hashByNodeId[nodeId]) { + delete state.nodeToHash[nodeId]; + delete state.hashToNodeId[hash]; + } + } + } + + if (entriesToEmbed.length > 0) { + const embeddings = await embedBatch( + entriesToEmbed.map((entry) => entry.text), + config, + ); + + for (let index = 0; index < entriesToEmbed.length; index++) { + const entry = entriesToEmbed[index]; + const node = graph.nodes.find((candidate) => candidate.id === entry.nodeId); + if (!node) continue; + + if (embeddings[index]) { + node.embedding = Array.from(embeddings[index]); + state.hashToNodeId[entry.hash] = entry.nodeId; + state.nodeToHash[entry.nodeId] = entry.hash; + insertedHashes.push(entry.hash); + } + } + } + + state.mode = "direct"; + state.source = "direct"; + state.modelScope = getVectorModelScope(config); + state.collectionId = collectionId; + } + + state.dirty = false; + state.lastWarning = ""; + state.lastSyncAt = Date.now(); + state.lastStats = computeVectorStats(graph, buildDesiredVectorEntries(graph, config)); + + return { + insertedHashes, + stats: state.lastStats, + }; +} + +export async function findSimilarNodesByText( + graph, + text, + config, + topK = 10, + candidates = null, +) { + if (!text || !graph || !config) return []; + + const candidateNodes = Array.isArray(candidates) + ? candidates + : getEligibleVectorNodes(graph); + + if (candidateNodes.length === 0) return []; + + if (isDirectVectorConfig(config)) { + const queryVec = await embedText(text, config); + if (!queryVec) return []; + + return searchSimilar( + queryVec, + candidateNodes + .filter((node) => Array.isArray(node.embedding) && node.embedding.length > 0) + .map((node) => ({ + nodeId: node.id, + embedding: node.embedding, + })), + topK, + ); + } + + const validation = validateVectorConfig(config); + if (!validation.valid) return []; + + const response = await fetch("/api/vector/query", { + method: "POST", + headers: getRequestHeaders(), + body: JSON.stringify({ + collectionId: graph.vectorIndexState.collectionId, + searchText: text, + topK, + threshold: 0, + ...buildBackendSourceRequest(config), + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + console.warn("[ST-BME] 后端向量查询失败:", errorText); + return []; + } + + const data = await response.json().catch(() => ({ hashes: [] })); + const hashes = Array.isArray(data?.hashes) ? data.hashes : []; + const nodeIdByHash = graph.vectorIndexState?.hashToNodeId || {}; + const allowedIds = new Set(candidateNodes.map((node) => node.id)); + + return hashes + .map((hash, index) => ({ + nodeId: nodeIdByHash[hash], + score: Math.max(0.01, 1 - index / Math.max(1, hashes.length)), + })) + .filter((entry) => entry.nodeId && allowedIds.has(entry.nodeId)) + .slice(0, topK); +} + +export async function testVectorConnection(config, chatId = "connection-test") { + const validation = validateVectorConfig(config); + if (!validation.valid) { + return { success: false, dimensions: 0, error: validation.error }; + } + + if (isDirectVectorConfig(config)) { + try { + const vec = await embedText("test connection", config); + if (vec) { + return { success: true, dimensions: vec.length, error: "" }; + } + return { success: false, dimensions: 0, error: "API 返回空结果" }; + } catch (error) { + return { success: false, dimensions: 0, error: String(error) }; + } + } + + try { + const response = await fetch("/api/vector/query", { + method: "POST", + headers: getRequestHeaders(), + body: JSON.stringify({ + collectionId: buildVectorCollectionId(chatId), + searchText: "test connection", + topK: 1, + threshold: 0, + ...buildBackendSourceRequest(config), + }), + }); + + const payload = await response.text().catch(() => ""); + if (!response.ok) { + return { + success: false, + dimensions: 0, + error: payload || response.statusText, + }; + } + + return { success: true, dimensions: 0, error: "" }; + } catch (error) { + return { success: false, dimensions: 0, error: String(error) }; + } +} + +export function getVectorIndexStats(graph) { + const state = graph?.vectorIndexState; + if (!state) { + return { total: 0, indexed: 0, stale: 0, pending: 0 }; + } + return state.lastStats || { total: 0, indexed: 0, stale: 0, pending: 0 }; +}