From 28fdc6d7ea87d155d229ae09a50c8b38d7512857 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Mon, 23 Mar 2026 13:37:24 +0800 Subject: [PATCH] feat: update smart trigger and graph retrieval --- diffusion.js | 172 ++++++------ extractor.js | 102 ++++++-- graph.js | 535 ++++++++++++++++++++++---------------- index.js | 154 +++++++---- injector.js | 25 +- retriever.js | 135 ++++++---- schema.js | 433 ++++++++++++++++-------------- settings.html | 73 +++++- tests/graph-retrieval.mjs | 76 ++++++ tests/smart-trigger.mjs | 143 ++++------ 10 files changed, 1126 insertions(+), 722 deletions(-) create mode 100644 tests/graph-retrieval.mjs diff --git a/diffusion.js b/diffusion.js index 1d40495..78dfa6e 100644 --- a/diffusion.js +++ b/diffusion.js @@ -32,12 +32,12 @@ const INHIBIT_EDGE_TYPE = 255; * 默认配置 */ const DEFAULT_OPTIONS = { - maxSteps: 2, // 最大扩散步数 - decayFactor: 0.6, // 每步衰减因子 - topK: 100, // 每步保留的最大活跃节点数 - minEnergy: 0.01, // 最小有效能量(低于此值视为不活跃) - maxEnergy: 2.0, // 能量上限 - minEnergy_clamp: -2.0, // 能量下限(抑制) + maxSteps: 2, // 最大扩散步数 + decayFactor: 0.6, // 每步衰减因子 + topK: 100, // 每步保留的最大活跃节点数 + minEnergy: 0.01, // 最小有效能量(低于此值视为不活跃) + maxEnergy: 2.0, // 能量上限 + minEnergy_clamp: -2.0, // 能量下限(抑制) }; /** @@ -58,85 +58,88 @@ const DEFAULT_OPTIONS = { * nodeId → energy(正值=激活,负值=抑制) */ export function propagateActivation(adjacencyMap, seedNodes, options = {}) { - const opts = { ...DEFAULT_OPTIONS, ...options }; + const opts = { ...DEFAULT_OPTIONS, ...options }; - // Step 0: 初始化能量表 + /** @type {Map} */ + let currentEnergy = new Map(); + + for (const seed of seedNodes || []) { + if (!seed?.id) continue; + const clamped = clampEnergy(Number(seed.energy) || 0, opts); + if (Math.abs(clamped) >= opts.minEnergy) { + const existing = currentEnergy.get(seed.id) || 0; + currentEnergy.set(seed.id, clampEnergy(existing + clamped, opts)); + } + } + + // 累积结果(所有步骤的最大能量) + /** @type {Map} */ + const result = new Map(currentEnergy); + + // Step 1~N: 逐步扩散 + for (let step = 0; step < opts.maxSteps; step++) { /** @type {Map} */ - let currentEnergy = new Map(); + const nextEnergy = new Map(); - for (const seed of seedNodes) { - const clamped = clampEnergy(seed.energy, opts); - if (Math.abs(clamped) >= opts.minEnergy) { - currentEnergy.set(seed.id, clamped); + // 对每个当前活跃节点,传播能量到邻居 + for (const [nodeId, energy] of currentEnergy) { + const neighbors = adjacencyMap.get(nodeId); + if (!Array.isArray(neighbors) || neighbors.length === 0) continue; + + for (const neighbor of neighbors) { + if (!neighbor?.targetId) continue; + let propagated = + energy * (Number(neighbor.strength) || 0) * opts.decayFactor; + + // 抑制边:传递负能量 + if (neighbor.edgeType === INHIBIT_EDGE_TYPE) { + propagated = -Math.abs(propagated); } + + // 累加到邻居节点 + const existing = nextEnergy.get(neighbor.targetId) || 0; + nextEnergy.set(neighbor.targetId, existing + propagated); + } } - // 累积结果(所有步骤的最大能量) - /** @type {Map} */ - const result = new Map(currentEnergy); - - // Step 1~N: 逐步扩散 - for (let step = 0; step < opts.maxSteps; step++) { - /** @type {Map} */ - const nextEnergy = new Map(); - - // 对每个当前活跃节点,传播能量到邻居 - for (const [nodeId, energy] of currentEnergy) { - const neighbors = adjacencyMap.get(nodeId); - if (!neighbors) continue; - - for (const neighbor of neighbors) { - // 计算传播能量 - let propagated = energy * neighbor.strength * opts.decayFactor; - - // 抑制边:传递负能量 - if (neighbor.edgeType === INHIBIT_EDGE_TYPE) { - propagated = -Math.abs(propagated); - } - - // 累加到邻居节点 - const existing = nextEnergy.get(neighbor.targetId) || 0; - nextEnergy.set(neighbor.targetId, existing + propagated); - } - } - - // 钳位 + 过滤低能量 - for (const [nodeId, energy] of nextEnergy) { - const clamped = clampEnergy(energy, opts); - if (Math.abs(clamped) < opts.minEnergy) { - nextEnergy.delete(nodeId); - } else { - nextEnergy.set(nodeId, clamped); - } - } - - // 动态剪枝:只保留 Top-K - if (nextEnergy.size > opts.topK) { - const sorted = [...nextEnergy.entries()] - .sort((a, b) => Math.abs(b[1]) - Math.abs(a[1])); - - nextEnergy.clear(); - for (let i = 0; i < opts.topK && i < sorted.length; i++) { - nextEnergy.set(sorted[i][0], sorted[i][1]); - } - } - - // 更新累积结果(取各步骤最大绝对值能量) - for (const [nodeId, energy] of nextEnergy) { - const existing = result.get(nodeId) || 0; - if (Math.abs(energy) > Math.abs(existing)) { - result.set(nodeId, energy); - } - } - - // 准备下一步 - currentEnergy = nextEnergy; - - // 如果没有活跃节点了,提前终止 - if (currentEnergy.size === 0) break; + // 钳位 + 过滤低能量 + for (const [nodeId, energy] of nextEnergy) { + const clamped = clampEnergy(energy, opts); + if (Math.abs(clamped) < opts.minEnergy) { + nextEnergy.delete(nodeId); + } else { + nextEnergy.set(nodeId, clamped); + } } - return result; + // 动态剪枝:只保留 Top-K + if (nextEnergy.size > opts.topK) { + const sorted = [...nextEnergy.entries()].sort( + (a, b) => Math.abs(b[1]) - Math.abs(a[1]), + ); + + nextEnergy.clear(); + for (let i = 0; i < opts.topK && i < sorted.length; i++) { + nextEnergy.set(sorted[i][0], sorted[i][1]); + } + } + + // 更新累积结果(取各步骤最大绝对值能量) + for (const [nodeId, energy] of nextEnergy) { + const existing = result.get(nodeId) || 0; + if (Math.abs(energy) > Math.abs(existing)) { + result.set(nodeId, energy); + } + } + + // 准备下一步 + currentEnergy = nextEnergy; + + // 如果没有活跃节点了,提前终止 + if (currentEnergy.size === 0) break; + } + + return result; } /** @@ -146,7 +149,7 @@ export function propagateActivation(adjacencyMap, seedNodes, options = {}) { * @returns {number} */ function clampEnergy(energy, opts) { - return Math.max(opts.minEnergy_clamp, Math.min(opts.maxEnergy, energy)); + return Math.max(opts.minEnergy_clamp, Math.min(opts.maxEnergy, energy)); } /** @@ -158,10 +161,13 @@ function clampEnergy(energy, opts) { * @returns {Array<{nodeId: string, energy: number}>} 按能量降序排列 */ export function diffuseAndRank(adjacencyMap, seeds, options = {}) { - const energyMap = propagateActivation(adjacencyMap, seeds, options); + const energyMap = propagateActivation(adjacencyMap, seeds, options); - return [...energyMap.entries()] - .filter(([_, energy]) => energy > 0) // 只返回正能量(被激活的) - .map(([nodeId, energy]) => ({ nodeId, energy })) - .sort((a, b) => b.energy - a.energy); + return [...energyMap.entries()] + .filter(([_, energy]) => energy > 0) + .map(([nodeId, energy]) => ({ nodeId, energy })) + .sort((a, b) => { + if (b.energy !== a.energy) return b.energy - a.energy; + return String(a.nodeId).localeCompare(String(b.nodeId)); + }); } diff --git a/extractor.js b/extractor.js index 80e6a74..fcc3e1f 100644 --- a/extractor.js +++ b/extractor.js @@ -22,18 +22,22 @@ import { RELATION_TYPES } from "./schema.js"; * * @param {object} params * @param {object} params.graph - 当前图状态 - * @param {Array<{role: string, content: string}>} params.messages - 要处理的对话消息 - * @param {number} params.startSeq - 起始楼层号 + * @param {Array<{seq?: number, role: string, content: string}>} params.messages - 要处理的对话消息 + * @param {number} params.startSeq - 本批处理的首个 assistant 消息 chat 索引 + * @param {number} params.endSeq - 本批处理的末个 assistant 消息 chat 索引 + * @param {number} [params.lastProcessedSeq] - 上次处理到的 chat 索引 * @param {object[]} params.schema - 节点类型 Schema * @param {object} params.embeddingConfig - Embedding API 配置 * @param {string} [params.extractPrompt] - 自定义提取提示词 * @param {object} [params.v2Options] - v2 增强选项 - * @returns {Promise<{success: boolean, newNodes: number, updatedNodes: number, newEdges: number, newNodeIds: string[]}>} + * @returns {Promise<{success: boolean, newNodes: number, updatedNodes: number, newEdges: number, newNodeIds: string[], processedRange: [number, number]}>} */ export async function extractMemories({ graph, messages, startSeq, + endSeq, + lastProcessedSeq = -1, schema, embeddingConfig, extractPrompt, @@ -46,17 +50,33 @@ export async function extractMemories({ updatedNodes: 0, newEdges: 0, newNodeIds: [], + processedRange: [lastProcessedSeq, lastProcessedSeq], }; } const enablePreciseConflict = v2Options.enablePreciseConflict ?? true; const conflictThreshold = v2Options.conflictThreshold ?? 0.85; - console.log(`[ST-BME] 提取开始: 楼层 ${startSeq}, ${messages.length} 条消息`); + const effectiveStartSeq = Number.isFinite(startSeq) + ? startSeq + : (messages.find((m) => Number.isFinite(m.seq))?.seq ?? + lastProcessedSeq + 1); + const effectiveEndSeq = Number.isFinite(endSeq) + ? endSeq + : ([...messages].reverse().find((m) => Number.isFinite(m.seq))?.seq ?? + effectiveStartSeq); + const currentSeq = effectiveEndSeq; + + console.log( + `[ST-BME] 提取开始: chat[${effectiveStartSeq}..${effectiveEndSeq}], ${messages.length} 条消息`, + ); // 构建对话文本 const dialogueText = messages - .map((m) => `[${m.role}]: ${m.content}`) + .map((m) => { + const seqLabel = Number.isFinite(m.seq) ? `#${m.seq}` : "#?"; + return `${seqLabel} [${m.role}]: ${m.content}`; + }) .join("\n\n"); // 构建当前图概览(让 LLM 知道已有哪些节点,避免重复) @@ -89,7 +109,7 @@ export async function extractMemories({ maxRetries: 2, }); - if (!result || !result.operations) { + if (!result || !Array.isArray(result.operations)) { console.warn("[ST-BME] 提取 LLM 未返回有效操作"); return { success: false, @@ -97,6 +117,7 @@ export async function extractMemories({ updatedNodes: 0, newEdges: 0, newNodeIds: [], + processedRange: [lastProcessedSeq, lastProcessedSeq], }; } @@ -107,6 +128,7 @@ export async function extractMemories({ result.operations, embeddingConfig, conflictThreshold, + effectiveEndSeq, ); } @@ -122,7 +144,7 @@ export async function extractMemories({ const createdId = handleCreate( graph, op, - startSeq, + currentSeq, schema, refMap, stats, @@ -131,7 +153,7 @@ export async function extractMemories({ break; } case "update": - handleUpdate(graph, op, stats); + handleUpdate(graph, op, currentSeq, stats); break; case "delete": handleDelete(graph, op, stats); @@ -150,15 +172,22 @@ export async function extractMemories({ // 为新建节点生成 embedding await generateNodeEmbeddings(graph, embeddingConfig); - // 更新处理进度 - graph.lastProcessedSeq = - startSeq + messages.filter((m) => m.role === "assistant").length; - - console.log( - `[ST-BME] 提取完成: 新建 ${stats.newNodes}, 更新 ${stats.updatedNodes}, 新边 ${stats.newEdges}`, + // 更新处理进度:统一记录为已处理到的末个 chat 索引 + graph.lastProcessedSeq = Math.max( + graph.lastProcessedSeq ?? -1, + effectiveEndSeq, ); - return { success: true, ...stats, newNodeIds }; + console.log( + `[ST-BME] 提取完成: 新建 ${stats.newNodes}, 更新 ${stats.updatedNodes}, 新边 ${stats.newEdges}, lastProcessedSeq=${graph.lastProcessedSeq}`, + ); + + return { + success: true, + ...stats, + newNodeIds, + processedRange: [effectiveStartSeq, effectiveEndSeq], + }; } /** @@ -217,7 +246,7 @@ function handleCreate(graph, op, seq, schema, refMap, stats) { /** * 处理 update 操作 */ -function handleUpdate(graph, op, stats) { +function handleUpdate(graph, op, currentSeq, stats) { if (!op.nodeId) { console.warn("[ST-BME] update 操作缺少 nodeId"); return; @@ -233,8 +262,10 @@ function handleUpdate(graph, op, stats) { const nextFields = { ...previousFields, ...(op.fields || {}) }; const changeSummary = buildFieldChangeSummary(previousFields, nextFields); + const updateSeq = Number.isFinite(op.seq) ? op.seq : currentSeq; const updated = updateNode(graph, op.nodeId, { fields: op.fields || {}, + seq: Math.max(previousNode.seq || 0, updateSeq), }); if (updated) { @@ -242,19 +273,22 @@ function handleUpdate(graph, op, stats) { const node = getNode(graph, op.nodeId); if (node) { node.embedding = null; - node.seq = Math.max(node.seq || 0, op.seq || 0); + node.seq = Math.max(node.seq || 0, updateSeq); node.seqRange = [ - Math.min(node.seqRange?.[0] ?? node.seq, op.seq || node.seq), - Math.max(node.seqRange?.[1] ?? node.seq, op.seq || node.seq), + Math.min(node.seqRange?.[0] ?? node.seq, updateSeq), + Math.max(node.seqRange?.[1] ?? node.seq, updateSeq), ]; } // v2 Graphiti: 标记旧的 updates/temporal_update 边为失效 const oldEdges = graph.edges.filter( (e) => - e.toId === op.nodeId && - (e.relation === "updates" || e.relation === "temporal_update") && - !e.invalidAt, + !e.invalidAt && + ((e.relation === "updates" && e.toId === op.nodeId) || + (e.relation === "temporal_update" && + e.toId === op.nodeId && + op.sourceNodeId && + e.fromId === op.sourceNodeId)), ); for (const e of oldEdges) { invalidateEdge(e); @@ -284,7 +318,7 @@ function handleUpdate(graph, op, stats) { previousNode.id, status: "resolved", }, - seq: op.seq || previousNode.seq || 0, + seq: updateSeq, importance: Math.max( 4, Math.min(8, op.importance ?? previousNode.importance ?? 5), @@ -379,7 +413,10 @@ function handleLinks(graph, sourceId, links, refMap, stats) { async function generateNodeEmbeddings(graph, embeddingConfig) { if (!embeddingConfig?.apiUrl) return; - const needsEmbedding = graph.nodes.filter((n) => !n.embedding && !n.archived); + const needsEmbedding = graph.nodes.filter( + (n) => + !n.archived && (!Array.isArray(n.embedding) || n.embedding.length === 0), + ); if (needsEmbedding.length === 0) return; @@ -410,7 +447,9 @@ async function generateNodeEmbeddings(graph, embeddingConfig) { * 构建图谱概览文本(给 LLM 看) */ function buildGraphOverview(graph, schema) { - const activeNodes = graph.nodes.filter((n) => !n.archived); + const activeNodes = graph.nodes + .filter((n) => !n.archived) + .sort((a, b) => (a.seq || 0) - (b.seq || 0)); if (activeNodes.length === 0) return ""; const lines = []; @@ -502,8 +541,11 @@ async function mem0ConflictCheck( operations, embeddingConfig, threshold, + fallbackSeq, ) { - const activeNodes = getActiveNodes(graph).filter((n) => n.embedding); + const activeNodes = getActiveNodes(graph).filter( + (n) => Array.isArray(n.embedding) && n.embedding.length > 0, + ); if (activeNodes.length === 0) return; for (const op of operations) { @@ -553,7 +595,8 @@ async function mem0ConflictCheck( ); op.action = "update"; op.nodeId = decision.targetId; - op.sourceNodeId = topMatch.id; + op.sourceNodeId = op.sourceNodeId || topMatch.id; + op.seq = Number.isFinite(op.seq) ? op.seq : fallbackSeq; if (decision.mergedFields) { op.fields = { ...op.fields, ...decision.mergedFields }; } @@ -627,7 +670,12 @@ export async function generateSynopsis({ graph, schema, currentSeq }) { if (existingSynopsis) { updateNode(graph, existingSynopsis.id, { fields: { summary: result.summary, scope: `楼 1 ~ ${currentSeq}` }, + seq: Math.max(existingSynopsis.seq || 0, currentSeq), }); + existingSynopsis.seqRange = [ + Math.min(existingSynopsis.seqRange?.[0] ?? currentSeq, currentSeq), + Math.max(existingSynopsis.seqRange?.[1] ?? currentSeq, currentSeq), + ]; existingSynopsis.embedding = null; console.log("[ST-BME] 全局概要已更新"); } else { diff --git a/graph.js b/graph.js index 81e80f9..1100d73 100644 --- a/graph.js +++ b/graph.js @@ -4,17 +4,17 @@ /** * 图状态版本号 */ -const GRAPH_VERSION = 2; +const GRAPH_VERSION = 3; /** * 生成 UUID v4 */ function uuid() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { - const r = (Math.random() * 16) | 0; - const v = c === 'x' ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); } /** @@ -22,13 +22,13 @@ function uuid() { * @returns {GraphState} */ export function createEmptyGraph() { - return { - version: GRAPH_VERSION, - lastProcessedSeq: 0, - nodes: [], - edges: [], - lastRecallResult: null, - }; + return { + version: GRAPH_VERSION, + lastProcessedSeq: -1, + nodes: [], + edges: [], + lastRecallResult: null, + }; } // ==================== 节点操作 ==================== @@ -39,33 +39,33 @@ export function createEmptyGraph() { * @returns {object} 新节点 */ export function createNode({ - type, - fields = {}, - seq = 0, - seqRange = null, - importance = 5.0, - clusters = [], + type, + fields = {}, + seq = 0, + seqRange = null, + importance = 5.0, + clusters = [], }) { - const now = Date.now(); - return { - id: uuid(), - type, - level: 0, - parentId: null, - childIds: [], - seq, - seqRange: seqRange || [seq, seq], - archived: false, - fields, - embedding: null, - importance: Math.max(0, Math.min(10, importance)), - accessCount: 0, - lastAccessTime: now, - createdTime: now, - prevId: null, - nextId: null, - clusters, - }; + const now = Date.now(); + return { + id: uuid(), + type, + level: 0, + parentId: null, + childIds: [], + seq, + seqRange: seqRange || [seq, seq], + archived: false, + fields, + embedding: null, + importance: Math.max(0, Math.min(10, importance)), + accessCount: 0, + lastAccessTime: now, + createdTime: now, + prevId: null, + nextId: null, + clusters, + }; } /** @@ -75,19 +75,19 @@ export function createNode({ * @returns {object} 添加的节点 */ export function addNode(graph, node) { - // 同类型节点的时间链表:连接到最后一个同类型节点 - const sameTypeNodes = graph.nodes - .filter(n => n.type === node.type && !n.archived && n.level === 0) - .sort((a, b) => a.seq - b.seq); + // 同类型节点的时间链表:连接到最后一个同类型节点 + const sameTypeNodes = graph.nodes + .filter((n) => n.type === node.type && !n.archived && n.level === 0) + .sort((a, b) => a.seq - b.seq); - if (sameTypeNodes.length > 0) { - const lastNode = sameTypeNodes[sameTypeNodes.length - 1]; - lastNode.nextId = node.id; - node.prevId = lastNode.id; - } + if (sameTypeNodes.length > 0) { + const lastNode = sameTypeNodes[sameTypeNodes.length - 1]; + lastNode.nextId = node.id; + node.prevId = lastNode.id; + } - graph.nodes.push(node); - return node; + graph.nodes.push(node); + return node; } /** @@ -97,7 +97,7 @@ export function addNode(graph, node) { * @returns {object|null} */ export function getNode(graph, nodeId) { - return graph.nodes.find(n => n.id === nodeId) || null; + return graph.nodes.find((n) => n.id === nodeId) || null; } /** @@ -108,16 +108,16 @@ export function getNode(graph, nodeId) { * @returns {boolean} 是否找到并更新 */ export function updateNode(graph, nodeId, updates) { - const node = getNode(graph, nodeId); - if (!node) return false; + const node = getNode(graph, nodeId); + if (!node) return false; - if (updates.fields) { - node.fields = { ...node.fields, ...updates.fields }; - delete updates.fields; - } + if (updates.fields) { + node.fields = { ...node.fields, ...updates.fields }; + delete updates.fields; + } - Object.assign(node, updates); - return true; + Object.assign(node, updates); + return true; } /** @@ -127,39 +127,41 @@ export function updateNode(graph, nodeId, updates) { * @returns {boolean} */ export function removeNode(graph, nodeId) { - const node = getNode(graph, nodeId); - if (!node) return false; + const node = getNode(graph, nodeId); + if (!node) return false; - // 修复时间链表 - if (node.prevId) { - const prev = getNode(graph, node.prevId); - if (prev) prev.nextId = node.nextId; - } - if (node.nextId) { - const next = getNode(graph, node.nextId); - if (next) next.prevId = node.prevId; + // 修复时间链表 + if (node.prevId) { + const prev = getNode(graph, node.prevId); + if (prev) prev.nextId = node.nextId; + } + if (node.nextId) { + const next = getNode(graph, node.nextId); + if (next) next.prevId = node.prevId; + } + + // 递归删除子节点 + for (const childId of node.childIds) { + removeNode(graph, childId); + } + + // 从父节点中移除引用 + if (node.parentId) { + const parent = getNode(graph, node.parentId); + if (parent) { + parent.childIds = parent.childIds.filter((id) => id !== nodeId); } + } - // 递归删除子节点 - for (const childId of node.childIds) { - removeNode(graph, childId); - } + // 删除相关边 + graph.edges = graph.edges.filter( + (e) => e.fromId !== nodeId && e.toId !== nodeId, + ); - // 从父节点中移除引用 - if (node.parentId) { - const parent = getNode(graph, node.parentId); - if (parent) { - parent.childIds = parent.childIds.filter(id => id !== nodeId); - } - } + // 删除节点本身 + graph.nodes = graph.nodes.filter((n) => n.id !== nodeId); - // 删除相关边 - graph.edges = graph.edges.filter(e => e.fromId !== nodeId && e.toId !== nodeId); - - // 删除节点本身 - graph.nodes = graph.nodes.filter(n => n.id !== nodeId); - - return true; + return true; } /** @@ -169,11 +171,11 @@ export function removeNode(graph, nodeId) { * @returns {object[]} */ export function getActiveNodes(graph, typeFilter = null) { - let nodes = graph.nodes.filter(n => !n.archived); - if (typeFilter) { - nodes = nodes.filter(n => n.type === typeFilter); - } - return nodes; + let nodes = graph.nodes.filter((n) => !n.archived); + if (typeFilter) { + nodes = nodes.filter((n) => n.type === typeFilter); + } + return nodes; } /** @@ -184,12 +186,20 @@ export function getActiveNodes(graph, typeFilter = null) { * @param {string} primaryKeyField - 主键字段名(默认 'name') * @returns {object|null} */ -export function findLatestNode(graph, type, primaryKeyValue, primaryKeyField = 'name') { - const candidates = graph.nodes.filter( - n => n.type === type && !n.archived && n.fields[primaryKeyField] === primaryKeyValue, - ); - if (candidates.length === 0) return null; - return candidates.sort((a, b) => b.seq - a.seq)[0]; +export function findLatestNode( + graph, + type, + primaryKeyValue, + primaryKeyField = "name", +) { + const candidates = graph.nodes.filter( + (n) => + n.type === type && + !n.archived && + n.fields[primaryKeyField] === primaryKeyValue, + ); + if (candidates.length === 0) return null; + return candidates.sort((a, b) => b.seq - a.seq)[0]; } // ==================== 边操作 ==================== @@ -199,20 +209,26 @@ export function findLatestNode(graph, type, primaryKeyValue, primaryKeyField = ' * @param {object} params * @returns {object} 新边 */ -export function createEdge({ fromId, toId, relation = 'related', strength = 0.8, edgeType = 0 }) { - return { - id: uuid(), - fromId, - toId, - relation, - strength: Math.max(0, Math.min(1, strength)), - edgeType, - createdTime: Date.now(), - // Graphiti 启发的时序字段 - validAt: Date.now(), // 关系生效时间 - invalidAt: null, // 关系失效时间(null = 当前有效) - expiredAt: null, // 系统标记过期时间 - }; +export function createEdge({ + fromId, + toId, + relation = "related", + strength = 0.8, + edgeType = 0, +}) { + return { + id: uuid(), + fromId, + toId, + relation, + strength: Math.max(0, Math.min(1, strength)), + edgeType, + createdTime: Date.now(), + // Graphiti 启发的时序字段 + validAt: Date.now(), // 关系生效时间 + invalidAt: null, // 关系失效时间(null = 当前有效) + expiredAt: null, // 系统标记过期时间 + }; } /** @@ -222,23 +238,42 @@ export function createEdge({ fromId, toId, relation = 'related', strength = 0.8, * @returns {object|null} 添加的边或 null */ export function addEdge(graph, edge) { - const from = getNode(graph, edge.fromId); - const to = getNode(graph, edge.toId); - if (!from || !to) return null; - if (edge.fromId === edge.toId) return null; + const from = getNode(graph, edge.fromId); + const to = getNode(graph, edge.toId); + if (!from || !to) return null; + if (edge.fromId === edge.toId) return null; - // 检查重复边 - const existing = graph.edges.find( - e => e.fromId === edge.fromId && e.toId === edge.toId && e.relation === edge.relation, + const isCurrentEdgeValid = (candidate) => { + if (candidate.invalidAt) return false; + if (candidate.expiredAt) return false; + return true; + }; + + // 对当前有效边去重;历史边保留,避免历史污染当前检索 + const existing = graph.edges.find( + (e) => + e.fromId === edge.fromId && + e.toId === edge.toId && + e.relation === edge.relation && + isCurrentEdgeValid(e), + ); + if (existing) { + existing.strength = Math.max(existing.strength, edge.strength ?? 0); + existing.validAt = Math.max( + existing.validAt || 0, + edge.validAt || Date.now(), ); - if (existing) { - // 更新已有边的强度 - existing.strength = Math.max(existing.strength, edge.strength); - return existing; + if (edge.invalidAt) { + existing.invalidAt = edge.invalidAt; } + if (edge.expiredAt) { + existing.expiredAt = edge.expiredAt; + } + return existing; + } - graph.edges.push(edge); - return edge; + graph.edges.push(edge); + return edge; } /** @@ -248,10 +283,10 @@ export function addEdge(graph, edge) { * @returns {boolean} */ export function removeEdge(graph, edgeId) { - const idx = graph.edges.findIndex(e => e.id === edgeId); - if (idx === -1) return false; - graph.edges.splice(idx, 1); - return true; + const idx = graph.edges.findIndex((e) => e.id === edgeId); + if (idx === -1) return false; + graph.edges.splice(idx, 1); + return true; } /** @@ -261,7 +296,7 @@ export function removeEdge(graph, edgeId) { * @returns {object[]} */ export function getOutEdges(graph, nodeId) { - return graph.edges.filter(e => e.fromId === nodeId); + return graph.edges.filter((e) => e.fromId === nodeId); } /** @@ -271,7 +306,7 @@ export function getOutEdges(graph, nodeId) { * @returns {object[]} */ export function getInEdges(graph, nodeId) { - return graph.edges.filter(e => e.toId === nodeId); + return graph.edges.filter((e) => e.toId === nodeId); } /** @@ -281,7 +316,7 @@ export function getInEdges(graph, nodeId) { * @returns {object[]} */ export function getNodeEdges(graph, nodeId) { - return graph.edges.filter(e => e.fromId === nodeId || e.toId === nodeId); + return graph.edges.filter((e) => e.fromId === nodeId || e.toId === nodeId); } // ==================== 查询辅助 ==================== @@ -292,27 +327,27 @@ export function getNodeEdges(graph, nodeId) { * @returns {Map>} */ export function buildAdjacencyMap(graph) { - const adj = new Map(); + const adj = new Map(); - for (const edge of graph.edges) { - // 正向 - if (!adj.has(edge.fromId)) adj.set(edge.fromId, []); - adj.get(edge.fromId).push({ - targetId: edge.toId, - strength: edge.strength, - edgeType: edge.edgeType, - }); + for (const edge of graph.edges) { + if (!isEdgeActive(edge)) continue; - // 反向(图扩散是双向的) - if (!adj.has(edge.toId)) adj.set(edge.toId, []); - adj.get(edge.toId).push({ - targetId: edge.fromId, - strength: edge.strength, - edgeType: edge.edgeType, - }); - } + if (!adj.has(edge.fromId)) adj.set(edge.fromId, []); + adj.get(edge.fromId).push({ + targetId: edge.toId, + strength: edge.strength, + edgeType: edge.edgeType, + }); - return adj; + if (!adj.has(edge.toId)) adj.set(edge.toId, []); + adj.get(edge.toId).push({ + targetId: edge.fromId, + strength: edge.strength, + edgeType: edge.edgeType, + }); + } + + return adj; } /** @@ -322,32 +357,34 @@ export function buildAdjacencyMap(graph) { * @returns {Map} */ export function buildTemporalAdjacencyMap(graph) { - const adj = new Map(); - const now = Date.now(); + const adj = new Map(); - for (const edge of graph.edges) { - // 跳过已失效的边 - if (edge.invalidAt && edge.invalidAt <= now) continue; - if (edge.expiredAt) continue; + for (const edge of graph.edges) { + if (!isEdgeActive(edge)) continue; - // 正向 - if (!adj.has(edge.fromId)) adj.set(edge.fromId, []); - adj.get(edge.fromId).push({ - targetId: edge.toId, - strength: edge.strength, - edgeType: edge.edgeType, - }); + if (!adj.has(edge.fromId)) adj.set(edge.fromId, []); + adj.get(edge.fromId).push({ + targetId: edge.toId, + strength: edge.strength, + edgeType: edge.edgeType, + }); - // 反向 - if (!adj.has(edge.toId)) adj.set(edge.toId, []); - adj.get(edge.toId).push({ - targetId: edge.fromId, - strength: edge.strength, - edgeType: edge.edgeType, - }); - } + if (!adj.has(edge.toId)) adj.set(edge.toId, []); + adj.get(edge.toId).push({ + targetId: edge.fromId, + strength: edge.strength, + edgeType: edge.edgeType, + }); + } - return adj; + return adj; +} + +function isEdgeActive(edge, now = Date.now()) { + if (!edge) return false; + if (edge.invalidAt && edge.invalidAt <= now) return false; + if (edge.expiredAt && edge.expiredAt <= now) return false; + return true; } /** @@ -355,7 +392,10 @@ export function buildTemporalAdjacencyMap(graph) { * @param {object} edge */ export function invalidateEdge(edge) { + if (!edge) return; + if (!edge.invalidAt) { edge.invalidAt = Date.now(); + } } /** @@ -364,21 +404,21 @@ export function invalidateEdge(edge) { * @returns {object} */ export function getGraphStats(graph) { - const activeNodes = graph.nodes.filter(n => !n.archived); - const archivedNodes = graph.nodes.filter(n => n.archived); - const typeCounts = {}; - for (const node of activeNodes) { - typeCounts[node.type] = (typeCounts[node.type] || 0) + 1; - } + const activeNodes = graph.nodes.filter((n) => !n.archived); + const archivedNodes = graph.nodes.filter((n) => n.archived); + const typeCounts = {}; + for (const node of activeNodes) { + typeCounts[node.type] = (typeCounts[node.type] || 0) + 1; + } - return { - totalNodes: graph.nodes.length, - activeNodes: activeNodes.length, - archivedNodes: archivedNodes.length, - totalEdges: graph.edges.length, - lastProcessedSeq: graph.lastProcessedSeq, - typeCounts, - }; + return { + totalNodes: graph.nodes.length, + activeNodes: activeNodes.length, + archivedNodes: archivedNodes.length, + totalEdges: graph.edges.length, + lastProcessedSeq: graph.lastProcessedSeq, + typeCounts, + }; } // ==================== 序列化 ==================== @@ -389,7 +429,7 @@ export function getGraphStats(graph) { * @returns {string} */ export function serializeGraph(graph) { - return JSON.stringify(graph); + return JSON.stringify(graph); } /** @@ -398,40 +438,75 @@ export function serializeGraph(graph) { * @returns {GraphState} */ export function deserializeGraph(json) { - try { - const data = typeof json === 'string' ? JSON.parse(json) : json; + try { + const data = typeof json === "string" ? JSON.parse(json) : json; - if (!data || data.version === undefined) { - return createEmptyGraph(); - } - - // 版本迁移 - if (data.version < GRAPH_VERSION) { - console.log(`[ST-BME] 图版本迁移 v${data.version} → v${GRAPH_VERSION}`); - - // v1→v2 迁移:给旧边补充时序字段 - if (data.version < 2 && data.edges) { - for (const edge of data.edges) { - if (edge.validAt === undefined) edge.validAt = edge.createdTime || Date.now(); - if (edge.invalidAt === undefined) edge.invalidAt = null; - if (edge.expiredAt === undefined) edge.expiredAt = null; - } - } - - data.version = GRAPH_VERSION; - } - - // 确保字段完整 - data.nodes = data.nodes || []; - data.edges = data.edges || []; - data.lastProcessedSeq = data.lastProcessedSeq || 0; - data.lastRecallResult = data.lastRecallResult || null; - - return data; - } catch (e) { - console.error('[ST-BME] 图反序列化失败:', e); - return createEmptyGraph(); + if (!data || data.version === undefined) { + return createEmptyGraph(); } + + if (data.version < GRAPH_VERSION) { + console.log(`[ST-BME] 图版本迁移 v${data.version} → v${GRAPH_VERSION}`); + + if (data.version < 2 && data.edges) { + for (const edge of data.edges) { + if (edge.validAt === undefined) + edge.validAt = edge.createdTime || Date.now(); + if (edge.invalidAt === undefined) edge.invalidAt = null; + if (edge.expiredAt === undefined) edge.expiredAt = null; + } + } + + if (data.version < 3) { + if (typeof data.lastProcessedSeq !== "number") { + data.lastProcessedSeq = -1; + } + for (const node of data.nodes || []) { + if (!Array.isArray(node.seqRange)) { + const seq = Number.isFinite(node.seq) ? node.seq : 0; + node.seqRange = [seq, seq]; + } + } + } + + data.version = GRAPH_VERSION; + } + + data.nodes = (data.nodes || []).map((node) => { + const seq = Number.isFinite(node.seq) ? node.seq : 0; + return { + level: 0, + parentId: null, + childIds: [], + accessCount: 0, + lastAccessTime: node.createdTime || Date.now(), + prevId: null, + nextId: null, + clusters: [], + ...node, + seq, + seqRange: Array.isArray(node.seqRange) ? node.seqRange : [seq, seq], + }; + }); + data.edges = (data.edges || []).map((edge) => ({ + createdTime: Date.now(), + validAt: edge?.createdTime || Date.now(), + invalidAt: null, + expiredAt: null, + ...edge, + })); + data.lastProcessedSeq = Number.isFinite(data.lastProcessedSeq) + ? data.lastProcessedSeq + : -1; + data.lastRecallResult = Array.isArray(data.lastRecallResult) + ? data.lastRecallResult + : null; + + return data; + } catch (e) { + console.error("[ST-BME] 图反序列化失败:", e); + return createEmptyGraph(); + } } /** @@ -440,11 +515,11 @@ export function deserializeGraph(json) { * @returns {string} JSON 字符串 */ export function exportGraph(graph) { - const exportData = { - ...graph, - nodes: graph.nodes.map(n => ({ ...n, embedding: null })), - }; - return JSON.stringify(exportData, null, 2); + const exportData = { + ...graph, + nodes: graph.nodes.map((n) => ({ ...n, embedding: null })), + }; + return JSON.stringify(exportData, null, 2); } /** @@ -453,10 +528,10 @@ export function exportGraph(graph) { * @returns {GraphState} */ export function importGraph(json) { - const graph = deserializeGraph(json); - // 导入的节点需要重新生成 embedding - for (const node of graph.nodes) { - node.embedding = null; - } - return graph; + const graph = deserializeGraph(json); + // 导入的节点需要重新生成 embedding + for (const node of graph.nodes) { + node.embedding = null; + } + return graph; } diff --git a/index.js b/index.js index b4778b6..96c97fa 100644 --- a/index.js +++ b/index.js @@ -30,7 +30,7 @@ import { } from "./graph.js"; import { estimateTokens, formatInjection } from "./injector.js"; import { retrieve } from "./retriever.js"; -import { DEFAULT_NODE_SCHEMA } from "./schema.js"; +import { DEFAULT_NODE_SCHEMA, validateSchema } from "./schema.js"; const MODULE_NAME = "st_bme"; const GRAPH_METADATA_KEY = "st_bme_graph"; @@ -121,15 +121,23 @@ let extractionCount = 0; // v2: 提取次数计数器(定期触发概要/遗 // ==================== 设置管理 ==================== function getSettings() { - if (!extension_settings[MODULE_NAME]) { - extension_settings[MODULE_NAME] = { ...defaultSettings }; - } - return extension_settings[MODULE_NAME]; + const mergedSettings = { + ...defaultSettings, + ...(extension_settings[MODULE_NAME] || {}), + }; + extension_settings[MODULE_NAME] = mergedSettings; + return mergedSettings; } function getSchema() { const settings = getSettings(); - return settings.nodeTypeSchema || DEFAULT_NODE_SCHEMA; + const schema = settings.nodeTypeSchema || DEFAULT_NODE_SCHEMA; + const validation = validateSchema(schema); + if (!validation.valid) { + console.warn("[ST-BME] Schema 非法,回退到默认 Schema:", validation.errors); + return DEFAULT_NODE_SCHEMA; + } + return schema; } function getEmbeddingConfig() { @@ -192,14 +200,15 @@ const DEFAULT_TRIGGER_KEYWORDS = [ "来到", ]; -function getSmartTriggerDecision(chat, lastProcessed, settings) { +export function getSmartTriggerDecision(chat, lastProcessed, settings) { const pendingMessages = chat - .slice(lastProcessed + 1) + .slice(Math.max(0, (lastProcessed ?? -1) + 1)) .filter((msg) => !msg.is_system) .map((msg) => ({ role: msg.is_user ? "user" : "assistant", content: msg.mes || "", - })); + })) + .filter((msg) => msg.content.trim().length > 0); if (pendingMessages.length === 0) { return { triggered: false, score: 0, reasons: [] }; @@ -266,6 +275,18 @@ function getSmartTriggerDecision(chat, lastProcessed, settings) { }; } +function clampInt(value, fallback, min = 0, max = Number.MAX_SAFE_INTEGER) { + const num = Number.parseInt(value, 10); + if (!Number.isFinite(num)) return fallback; + return Math.min(max, Math.max(min, num)); +} + +function clampFloat(value, fallback, min = 0, max = 1) { + const num = Number.parseFloat(value); + if (!Number.isFinite(num)) return fallback; + return Math.min(max, Math.max(min, num)); +} + /** * 提取管线:处理未提取的对话楼层 */ @@ -279,7 +300,7 @@ async function runExtraction() { const chat = context.chat; if (!chat || chat.length === 0) return; - // 找出 assistant 楼层序号 + // lastProcessedSeq / startSeq / endSeq 统一使用 chat 数组索引语义 const assistantTurns = []; for (let i = 0; i < chat.length; i++) { if (chat[i].is_user === false && !chat[i].is_system) { @@ -287,40 +308,44 @@ async function runExtraction() { } } - const lastProcessed = currentGraph.lastProcessedSeq; - const unprocessedStarts = assistantTurns.filter((i) => i > lastProcessed); + const lastProcessed = Number.isFinite(currentGraph.lastProcessedSeq) + ? currentGraph.lastProcessedSeq + : -1; + const unprocessedAssistantTurns = assistantTurns.filter( + (i) => i > lastProcessed, + ); - if (unprocessedStarts.length === 0) return; + if (unprocessedAssistantTurns.length === 0) return; + const extractEvery = clampInt(settings.extractEvery, 1, 1, 50); const smartTriggerDecision = settings.enableSmartTrigger ? getSmartTriggerDecision(chat, lastProcessed, settings) : { triggered: false, score: 0, reasons: [] }; - // 按 extractEvery 批次处理;若启用智能触发,则允许提前提取 if ( - unprocessedStarts.length < settings.extractEvery && + unprocessedAssistantTurns.length < extractEvery && !smartTriggerDecision.triggered ) { return; } + const batchAssistantTurns = smartTriggerDecision.triggered + ? unprocessedAssistantTurns + : unprocessedAssistantTurns.slice(0, extractEvery); + const startIdx = batchAssistantTurns[0]; + const endIdx = batchAssistantTurns[batchAssistantTurns.length - 1]; + isExtracting = true; try { - // 收集要处理的消息 - const startIdx = unprocessedStarts[0]; - const endIdx = unprocessedStarts[unprocessedStarts.length - 1]; - - // 包含上下文 - const contextStart = Math.max( - 0, - startIdx - settings.extractContextTurns * 2, - ); + 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 || "", }); @@ -336,7 +361,9 @@ async function runExtraction() { const result = await extractMemories({ graph: currentGraph, messages, - startSeq: endIdx, + startSeq: startIdx, + endSeq: endIdx, + lastProcessedSeq: lastProcessed, schema: getSchema(), embeddingConfig: getEmbeddingConfig(), extractPrompt: settings.extractPrompt || undefined, @@ -478,7 +505,7 @@ async function runRecall() { }); // 格式化注入文本 - const injectionText = formatInjection(result, getSchema()); + const injectionText = formatInjection(result, getSchema()).trim(); lastInjectionContent = injectionText; if (injectionText) { @@ -486,16 +513,16 @@ async function runRecall() { console.log( `[ST-BME] 注入 ${tokens} 估算 tokens, Core=${result.stats.coreCount}, Recall=${result.stats.recallCount}`, ); - - // 使用 ST 的 extension prompt API 注入 - context.setExtensionPrompt( - MODULE_NAME, - injectionText, - 1, // extension_prompt_types.IN_PROMPT - settings.injectDepth, - ); } + // 无结果时也要清空旧注入,避免脏 prompt 残留 + context.setExtensionPrompt( + MODULE_NAME, + injectionText, + 1, // extension_prompt_types.IN_PROMPT + clampInt(settings.injectDepth, 4, 0, 9999), + ); + // 保存召回结果和访问强化 currentGraph.lastRecallResult = result.selectedNodeIds; saveGraphToChat(); @@ -665,7 +692,13 @@ function bindSettingsUI() { $("#st_bme_extract_every") .val(settings.extractEvery) .on("input", function () { - settings.extractEvery = Math.max(1, parseInt($(this).val()) || 1); + settings.extractEvery = clampInt($(this).val(), 1, 1, 50); + saveSettingsDebounced(); + }); + $("#st_bme_extract_context_turns") + .val(settings.extractContextTurns) + .on("input", function () { + settings.extractContextTurns = clampInt($(this).val(), 2, 0, 20); saveSettingsDebounced(); }); @@ -685,11 +718,24 @@ function bindSettingsUI() { saveSettingsDebounced(); }); + $("#st_bme_recall_top_k") + .val(settings.recallTopK) + .on("input", function () { + settings.recallTopK = clampInt($(this).val(), 15, 1, 100); + saveSettingsDebounced(); + }); + $("#st_bme_recall_max_nodes") + .val(settings.recallMaxNodes) + .on("input", function () { + settings.recallMaxNodes = clampInt($(this).val(), 8, 1, 50); + saveSettingsDebounced(); + }); + // 注入深度 $("#st_bme_inject_depth") .val(settings.injectDepth) .on("input", function () { - settings.injectDepth = Math.max(0, parseInt($(this).val()) || 4); + settings.injectDepth = clampInt($(this).val(), 4, 0, 9999); saveSettingsDebounced(); }); @@ -697,19 +743,19 @@ function bindSettingsUI() { $("#st_bme_graph_weight") .val(settings.graphWeight) .on("input", function () { - settings.graphWeight = parseFloat($(this).val()) || 0.6; + settings.graphWeight = clampFloat($(this).val(), 0.6, 0, 1); saveSettingsDebounced(); }); $("#st_bme_vector_weight") .val(settings.vectorWeight) .on("input", function () { - settings.vectorWeight = parseFloat($(this).val()) || 0.3; + settings.vectorWeight = clampFloat($(this).val(), 0.3, 0, 1); saveSettingsDebounced(); }); $("#st_bme_importance_weight") .val(settings.importanceWeight) .on("input", function () { - settings.importanceWeight = parseFloat($(this).val()) || 0.1; + settings.importanceWeight = clampFloat($(this).val(), 0.1, 0, 1); saveSettingsDebounced(); }); @@ -754,7 +800,13 @@ function bindSettingsUI() { $("#st_bme_evo_neighbors") .val(settings.evoNeighborCount) .on("input", function () { - settings.evoNeighborCount = Math.max(1, parseInt($(this).val()) || 5); + settings.evoNeighborCount = clampInt($(this).val(), 5, 1, 20); + saveSettingsDebounced(); + }); + $("#st_bme_evo_consolidate_every") + .val(settings.evoConsolidateEvery) + .on("input", function () { + settings.evoConsolidateEvery = clampInt($(this).val(), 50, 1, 500); saveSettingsDebounced(); }); @@ -768,7 +820,7 @@ function bindSettingsUI() { $("#st_bme_conflict_threshold") .val(settings.conflictThreshold) .on("input", function () { - settings.conflictThreshold = parseFloat($(this).val()) || 0.85; + settings.conflictThreshold = clampFloat($(this).val(), 0.85, 0.5, 0.99); saveSettingsDebounced(); }); @@ -782,7 +834,7 @@ function bindSettingsUI() { $("#st_bme_synopsis_every") .val(settings.synopsisEveryN) .on("input", function () { - settings.synopsisEveryN = Math.max(1, parseInt($(this).val()) || 5); + settings.synopsisEveryN = clampInt($(this).val(), 5, 1, 100); saveSettingsDebounced(); }); @@ -815,6 +867,12 @@ function bindSettingsUI() { settings.triggerPatterns = $(this).val(); saveSettingsDebounced(); }); + $("#st_bme_smart_trigger_threshold") + .val(settings.smartTriggerThreshold) + .on("input", function () { + settings.smartTriggerThreshold = clampInt($(this).val(), 2, 1, 10); + saveSettingsDebounced(); + }); // P2: 主动遗忘 $("#st_bme_sleep_cycle") @@ -826,7 +884,13 @@ function bindSettingsUI() { $("#st_bme_forget_threshold") .val(settings.forgetThreshold) .on("input", function () { - settings.forgetThreshold = parseFloat($(this).val()) || 0.5; + settings.forgetThreshold = clampFloat($(this).val(), 0.5, 0.1, 1); + saveSettingsDebounced(); + }); + $("#st_bme_sleep_every") + .val(settings.sleepEveryN) + .on("input", function () { + settings.sleepEveryN = clampInt($(this).val(), 10, 1, 200); saveSettingsDebounced(); }); @@ -840,7 +904,7 @@ function bindSettingsUI() { $("#st_bme_prob_chance") .val(settings.probRecallChance) .on("input", function () { - settings.probRecallChance = parseFloat($(this).val()) || 0.15; + settings.probRecallChance = clampFloat($(this).val(), 0.15, 0.01, 0.5); saveSettingsDebounced(); }); @@ -854,7 +918,7 @@ function bindSettingsUI() { $("#st_bme_reflect_every") .val(settings.reflectEveryN) .on("input", function () { - settings.reflectEveryN = Math.max(3, parseInt($(this).val()) || 10); + settings.reflectEveryN = clampInt($(this).val(), 10, 1, 200); saveSettingsDebounced(); }); } diff --git a/injector.js b/injector.js index 0026e11..129c011 100644 --- a/injector.js +++ b/injector.js @@ -13,6 +13,7 @@ import { getSchemaType } from "./schema.js"; export function formatInjection(retrievalResult, schema) { const { coreNodes, recallNodes, groupedRecallNodes } = retrievalResult; const parts = []; + const appended = new Set(); // ========== Core 常驻注入 ========== if (coreNodes.length > 0) { @@ -24,7 +25,7 @@ export function formatInjection(retrievalResult, schema) { const typeDef = getSchemaType(schema, typeId); if (!typeDef) continue; - const table = formatTable(nodes, typeDef); + const table = formatTable(nodes, typeDef, appended); if (table) parts.push(table); } } @@ -90,7 +91,7 @@ function appendBucket(parts, title, nodes, schema) { const typeDef = getSchemaType(schema, typeId); if (!typeDef) continue; - const table = formatTable(groupedNodes, typeDef); + const table = formatTable(groupedNodes, typeDef, appended); if (table) parts.push(table); } } @@ -98,12 +99,22 @@ function appendBucket(parts, title, nodes, schema) { /** * 将同类型节点格式化为 Markdown 表格 */ -function formatTable(nodes, typeDef) { - if (nodes.length === 0) return ""; +function formatTable(nodes, typeDef, appended = new Set()) { + if (!Array.isArray(nodes) || nodes.length === 0) return ""; + + const uniqueNodes = nodes.filter((node) => { + if (!node?.id || appended.has(node.id)) return false; + appended.add(node.id); + return true; + }); + + if (uniqueNodes.length === 0) return ""; // 确定要展示的列(有实际数据的列) const activeCols = typeDef.columns.filter((col) => - nodes.some((n) => n.fields[col.name]), + uniqueNodes.some( + (n) => n.fields?.[col.name] != null && n.fields[col.name] !== "", + ), ); if (activeCols.length === 0) return ""; @@ -113,9 +124,9 @@ function formatTable(nodes, typeDef) { const separator = `| ${activeCols.map(() => "---").join(" | ")} |`; // 数据行 - const rows = nodes.map((node) => { + const rows = uniqueNodes.map((node) => { const cells = activeCols.map((col) => { - const val = node.fields[col.name] || ""; + const val = node.fields?.[col.name] ?? ""; // 转义管道符,限制单元格长度 return String(val) .replace(/\|/g, "\\|") diff --git a/retriever.js b/retriever.js index 6ec10ca..ef97b99 100644 --- a/retriever.js +++ b/retriever.js @@ -54,7 +54,12 @@ export async function retrieve({ const enableProbRecall = options.enableProbRecall ?? false; const probRecallChance = options.probRecallChance ?? 0.15; - let activeNodes = getActiveNodes(graph); + let activeNodes = getActiveNodes(graph).filter( + (node) => + !node.archived && + Array.isArray(node.seqRange) && + Number.isFinite(node.seqRange[1]), + ); // v2 ⑦: 认知边界过滤(RoleRAG 启发) if (enableVisibility && visibilityFilter) { @@ -62,6 +67,8 @@ export async function retrieve({ } const nodeCount = activeNodes.length; + const normalizedTopK = Math.max(1, topK); + const normalizedMaxRecallNodes = Math.max(1, maxRecallNodes); console.log( `[ST-BME] 检索开始: ${nodeCount} 个活跃节点${enableVisibility ? " (认知边界已启用)" : ""}`, ); @@ -81,7 +88,7 @@ export async function retrieve({ userMessage, activeNodes, embeddingConfig, - topK, + normalizedTopK, ); } @@ -129,6 +136,9 @@ export async function retrieve({ maxSteps: 2, decayFactor: 0.6, topK: 100, + }).filter((item) => { + const node = getNode(graph, item.nodeId); + return node && !node.archived; }); } } @@ -203,14 +213,19 @@ export async function retrieve({ candidateNodes, graph, schema, - maxRecallNodes, + normalizedMaxRecallNodes, ); } else { - // 中等图:直接取 Top-N - selectedNodeIds = scoredNodes.slice(0, topK).map((s) => s.nodeId); + selectedNodeIds = scoredNodes + .slice(0, Math.min(normalizedTopK, scoredNodes.length)) + .map((s) => s.nodeId); } - selectedNodeIds = reconstructSceneNodeIds(graph, selectedNodeIds, topK + 6); + selectedNodeIds = reconstructSceneNodeIds( + graph, + selectedNodeIds, + normalizedTopK + 6, + ); // 访问强化 const selectedNodes = selectedNodeIds @@ -225,15 +240,19 @@ export async function retrieve({ // 未被选中的高重要性节点有概率随机激活 if (enableProbRecall && probRecallChance > 0) { const selectedSet = new Set(selectedNodeIds); - const candidates = activeNodes.filter( - (n) => - !selectedSet.has(n.id) && - n.importance >= 6 && - n.type !== "synopsis" && - n.type !== "rule", - ); + const probability = Math.max(0.01, Math.min(0.5, probRecallChance)); + const candidates = activeNodes + .filter( + (n) => + !selectedSet.has(n.id) && + n.importance >= 6 && + n.type !== "synopsis" && + n.type !== "rule", + ) + .sort((a, b) => (b.importance || 0) - (a.importance || 0)) + .slice(0, 3); for (const c of candidates) { - if (Math.random() < probRecallChance) { + if (Math.random() < probability) { selectedNodeIds.push(c.id); console.log( `[ST-BME] 概率触发: ${c.fields?.name || c.fields?.summary || c.id}`, @@ -261,7 +280,7 @@ async function vectorPreFilter( if (!queryVec) return []; const candidates = activeNodes - .filter((n) => n.embedding) + .filter((n) => Array.isArray(n.embedding) && n.embedding.length > 0) .map((n) => ({ nodeId: n.id, embedding: n.embedding })); return searchSimilar(queryVec, candidates, topK); @@ -277,19 +296,21 @@ async function vectorPreFilter( */ function extractEntityAnchors(userMessage, activeNodes) { const anchors = []; + const seen = new Set(); for (const node of activeNodes) { - // 检查 name 字段 - const name = node.fields?.name; - if (name && userMessage.includes(name)) { - anchors.push({ nodeId: node.id, entity: name }); - continue; - } + const candidates = [node.fields?.name, node.fields?.title] + .filter((value) => typeof value === "string") + .map((value) => value.trim()) + .filter((value) => value.length >= 2); - // 检查 title 字段 - const title = node.fields?.title; - if (title && userMessage.includes(title)) { - anchors.push({ nodeId: node.id, entity: title }); + for (const candidate of candidates) { + if (!userMessage.includes(candidate)) continue; + const key = `${node.id}:${candidate}`; + if (seen.has(key)) continue; + seen.add(key); + anchors.push({ nodeId: node.id, entity: candidate }); + break; } } @@ -370,16 +391,20 @@ async function llmRecall( * @returns {object[]} */ function filterByVisibility(nodes, characterName) { + if (!characterName || typeof characterName !== "string") return nodes; return nodes.filter((node) => { - // 没有 visibility 字段 → 对所有人可见 if (!node.fields?.visibility) return true; - // visibility 是数组 → 检查当前角色是否在列表中 if (Array.isArray(node.fields.visibility)) { - return node.fields.visibility.includes(characterName); + return ( + node.fields.visibility.includes(characterName) || + node.fields.visibility.includes("*") + ); } - // visibility 是字符串(逗号分隔)→ 解析后检查 if (typeof node.fields.visibility === "string") { - const visibleTo = node.fields.visibility.split(",").map((s) => s.trim()); + const visibleTo = node.fields.visibility + .split(",") + .map((s) => s.trim()) + .filter(Boolean); return visibleTo.includes(characterName) || visibleTo.includes("*"); } return true; @@ -391,15 +416,16 @@ function filterByVisibility(nodes, characterName) { * 分离常驻注入(Core)和召回注入(Recall) */ function buildResult(graph, selectedNodeIds, schema) { - const coreNodes = []; // 常驻注入 - const recallNodes = []; // 召回注入 + const coreNodes = []; + const recallNodes = []; + const selectedSet = new Set(uniqueNodeIds(selectedNodeIds)); // 常驻注入节点(alwaysInject=true 的类型) const alwaysInjectTypes = new Set( schema.filter((s) => s.alwaysInject).map((s) => s.id), ); - const activeNodes = getActiveNodes(graph); + const activeNodes = getActiveNodes(graph).filter((node) => !node.archived); for (const node of activeNodes) { if (alwaysInjectTypes.has(node.type)) { @@ -407,21 +433,23 @@ function buildResult(graph, selectedNodeIds, schema) { } } - for (const nodeId of selectedNodeIds) { + for (const nodeId of selectedSet) { const node = getNode(graph, nodeId); - if (!node) continue; + if (!node || node.archived) continue; if (!alwaysInjectTypes.has(node.type)) { recallNodes.push(node); } } + coreNodes.sort(compareNodeRecallOrder); + recallNodes.sort(compareNodeRecallOrder); const groupedRecallNodes = groupRecallNodes(recallNodes); return { coreNodes, recallNodes, groupedRecallNodes, - selectedNodeIds: [...selectedNodeIds], + selectedNodeIds: [...selectedSet], stats: { totalActive: activeNodes.length, coreCount: coreNodes.length, @@ -439,14 +467,15 @@ function reconstructSceneNodeIds(graph, seedNodeIds, limit = 16) { const seen = new Set(); function push(nodeId) { - if (!nodeId || seen.has(nodeId)) return; + if (!nodeId || seen.has(nodeId) || selected.length >= limit) return; const node = getNode(graph, nodeId); if (!node || node.archived) return; seen.add(nodeId); selected.push(nodeId); } - for (const nodeId of seedNodeIds) { + for (const nodeId of uniqueNodeIds(seedNodeIds)) { + if (selected.length >= limit) break; push(nodeId); const node = getNode(graph, nodeId); if (!node) continue; @@ -455,26 +484,24 @@ function reconstructSceneNodeIds(graph, seedNodeIds, limit = 16) { expandEventScene(graph, node, push); } else if (node.type === "character" || node.type === "location") { const relatedEvents = getNodeEdges(graph, node.id) - .filter((e) => !e.invalidAt) + .filter(isUsableSceneEdge) .map((e) => (e.fromId === node.id ? e.toId : e.fromId)) .map((id) => getNode(graph, id)) - .filter((n) => n && n.type === "event") - .sort((a, b) => b.seq - a.seq) + .filter((n) => n && !n.archived && n.type === "event") + .sort(compareNodeRecallOrder) .slice(0, 2); for (const eventNode of relatedEvents) { push(eventNode.id); expandEventScene(graph, eventNode, push); } } - - if (selected.length >= limit) break; } return selected.slice(0, limit); } function expandEventScene(graph, eventNode, push) { - const edges = getNodeEdges(graph, eventNode.id).filter((e) => !e.invalidAt); + const edges = getNodeEdges(graph, eventNode.id).filter(isUsableSceneEdge); for (const edge of edges) { const neighborId = edge.fromId === eventNode.id ? edge.toId : edge.fromId; const neighbor = getNode(graph, neighborId); @@ -501,11 +528,27 @@ function expandEventScene(graph, eventNode, push) { function getTemporalNeighborEvents(graph, seq, excludeId) { return getActiveNodes(graph, "event") - .filter((n) => n.id !== excludeId) - .sort((a, b) => Math.abs(a.seq - seq) - Math.abs(b.seq - seq)) + .filter((n) => n.id !== excludeId && !n.archived) + .sort((a, b) => { + const distance = + Math.abs((a.seq || 0) - seq) - Math.abs((b.seq || 0) - seq); + if (distance !== 0) return distance; + return (b.seq || 0) - (a.seq || 0); + }) .slice(0, 2); } +function isUsableSceneEdge(edge) { + return edge && !edge.invalidAt && !edge.expiredAt; +} + +function compareNodeRecallOrder(a, b) { + const aSeq = a?.seqRange?.[1] ?? a?.seq ?? 0; + const bSeq = b?.seqRange?.[1] ?? b?.seq ?? 0; + if (bSeq !== aSeq) return bSeq - aSeq; + return (b.importance || 0) - (a.importance || 0); +} + function groupRecallNodes(nodes) { return { state: nodes.filter((n) => n.type === "character" || n.type === "location"), diff --git a/schema.js b/schema.js index a3c2ff9..df47d61 100644 --- a/schema.js +++ b/schema.js @@ -5,8 +5,8 @@ * 压缩模式 */ export const COMPRESSION_MODE = { - NONE: 'none', - HIERARCHICAL: 'hierarchical', + NONE: "none", + HIERARCHICAL: "hierarchical", }; /** @@ -22,172 +22,189 @@ export const COMPRESSION_MODE = { * - compression: 压缩配置 */ export const DEFAULT_NODE_SCHEMA = [ - { - id: 'event', - label: '事件', - tableName: 'event_table', - columns: [ - { name: 'summary', hint: '事件摘要,包含因果关系和结果', required: true }, - { name: 'participants', hint: '参与角色名,逗号分隔', required: false }, - { name: 'status', hint: '事件状态:ongoing/resolved/blocked', required: false }, - ], - alwaysInject: true, - latestOnly: false, - forceUpdate: true, - compression: { - mode: COMPRESSION_MODE.HIERARCHICAL, - threshold: 9, - fanIn: 3, - maxDepth: 10, - keepRecentLeaves: 6, - instruction: '将事件节点压缩为高价值的剧情里程碑摘要。保留因果关系、不可逆结果和未解决的伏笔。', - }, + { + id: "event", + label: "事件", + tableName: "event_table", + columns: [ + { name: "summary", hint: "事件摘要,包含因果关系和结果", required: true }, + { name: "participants", hint: "参与角色名,逗号分隔", required: false }, + { + name: "status", + hint: "事件状态:ongoing/resolved/blocked", + required: false, + }, + ], + alwaysInject: true, + latestOnly: false, + forceUpdate: true, + compression: { + mode: COMPRESSION_MODE.HIERARCHICAL, + threshold: 9, + fanIn: 3, + maxDepth: 10, + keepRecentLeaves: 6, + instruction: + "将事件节点压缩为高价值的剧情里程碑摘要。保留因果关系、不可逆结果和未解决的伏笔。", }, - { - id: 'character', - label: '角色', - tableName: 'character_table', - columns: [ - { name: 'name', hint: '角色名(仅规范名称)', required: true }, - { name: 'traits', hint: '稳定的性格特征和外貌标记', required: false }, - { name: 'state', hint: '当前状态或处境', required: false }, - { name: 'goal', hint: '当前目标或动机', required: false }, - { name: 'inventory', hint: '携带或拥有的关键物品', required: false }, - { name: 'core_note', hint: '值得长期记住的关键备注', required: false }, - ], - alwaysInject: false, - latestOnly: true, - forceUpdate: false, - compression: { - mode: COMPRESSION_MODE.NONE, - threshold: 0, - fanIn: 0, - maxDepth: 0, - keepRecentLeaves: 0, - instruction: '', - }, + }, + { + id: "character", + label: "角色", + tableName: "character_table", + columns: [ + { name: "name", hint: "角色名(仅规范名称)", required: true }, + { name: "traits", hint: "稳定的性格特征和外貌标记", required: false }, + { name: "state", hint: "当前状态或处境", required: false }, + { name: "goal", hint: "当前目标或动机", required: false }, + { name: "inventory", hint: "携带或拥有的关键物品", required: false }, + { name: "core_note", hint: "值得长期记住的关键备注", required: false }, + ], + alwaysInject: false, + latestOnly: true, + forceUpdate: false, + compression: { + mode: COMPRESSION_MODE.NONE, + threshold: 0, + fanIn: 0, + maxDepth: 0, + keepRecentLeaves: 0, + instruction: "", }, - { - id: 'location', - label: '地点', - tableName: 'location_table', - columns: [ - { name: 'name', hint: '地点名称(仅规范名称)', required: true }, - { name: 'state', hint: '当前状态或环境条件', required: false }, - { name: 'features', hint: '重要特征、资源或服务', required: false }, - { name: 'danger', hint: '危险等级或威胁', required: false }, - ], - alwaysInject: false, - latestOnly: true, - forceUpdate: false, - compression: { - mode: COMPRESSION_MODE.NONE, - threshold: 0, - fanIn: 0, - maxDepth: 0, - keepRecentLeaves: 0, - instruction: '', - }, + }, + { + id: "location", + label: "地点", + tableName: "location_table", + columns: [ + { name: "name", hint: "地点名称(仅规范名称)", required: true }, + { name: "state", hint: "当前状态或环境条件", required: false }, + { name: "features", hint: "重要特征、资源或服务", required: false }, + { name: "danger", hint: "危险等级或威胁", required: false }, + ], + alwaysInject: false, + latestOnly: true, + forceUpdate: false, + compression: { + mode: COMPRESSION_MODE.NONE, + threshold: 0, + fanIn: 0, + maxDepth: 0, + keepRecentLeaves: 0, + instruction: "", }, - { - id: 'rule', - label: '规则', - tableName: 'rule_table', - columns: [ - { name: 'title', hint: '简短规则名', required: true }, - { name: 'constraint', hint: '不可违反的规则内容', required: true }, - { name: 'scope', hint: '适用范围/场景', required: false }, - { name: 'status', hint: '当前有效性:active/suspended/revoked', required: false }, - ], - alwaysInject: true, - latestOnly: false, - forceUpdate: false, - compression: { - mode: COMPRESSION_MODE.NONE, - threshold: 0, - fanIn: 0, - maxDepth: 0, - keepRecentLeaves: 0, - instruction: '', - }, + }, + { + id: "rule", + label: "规则", + tableName: "rule_table", + columns: [ + { name: "title", hint: "简短规则名", required: true }, + { name: "constraint", hint: "不可违反的规则内容", required: true }, + { name: "scope", hint: "适用范围/场景", required: false }, + { + name: "status", + hint: "当前有效性:active/suspended/revoked", + required: false, + }, + ], + alwaysInject: true, + latestOnly: false, + forceUpdate: false, + compression: { + mode: COMPRESSION_MODE.NONE, + threshold: 0, + fanIn: 0, + maxDepth: 0, + keepRecentLeaves: 0, + instruction: "", }, - { - id: 'thread', - label: '主线', - tableName: 'thread_table', - columns: [ - { name: 'title', hint: '主线名称', required: true }, - { name: 'summary', hint: '当前进展摘要', required: false }, - { name: 'status', hint: '状态:active/completed/abandoned', required: false }, - ], - alwaysInject: true, - latestOnly: false, - forceUpdate: false, - compression: { - mode: COMPRESSION_MODE.HIERARCHICAL, - threshold: 6, - fanIn: 3, - maxDepth: 5, - keepRecentLeaves: 3, - instruction: '将主线节点压缩为阶段性进展摘要。保留关键转折和当前目标。', - }, + }, + { + id: "thread", + label: "主线", + tableName: "thread_table", + columns: [ + { name: "title", hint: "主线名称", required: true }, + { name: "summary", hint: "当前进展摘要", required: false }, + { + name: "status", + hint: "状态:active/completed/abandoned", + required: false, + }, + ], + alwaysInject: true, + latestOnly: false, + forceUpdate: false, + compression: { + mode: COMPRESSION_MODE.HIERARCHICAL, + threshold: 6, + fanIn: 3, + maxDepth: 5, + keepRecentLeaves: 3, + instruction: "将主线节点压缩为阶段性进展摘要。保留关键转折和当前目标。", }, - // ====== v2 新增节点类型 ====== - { - id: 'synopsis', - label: '全局概要', - tableName: 'synopsis_table', - columns: [ - { name: 'summary', hint: '当前故事的全局概要(前情提要)', required: true }, - { name: 'scope', hint: '概要覆盖的楼层范围', required: false }, - ], - alwaysInject: true, // 常驻注入(MemoRAG 启发) - latestOnly: true, // 只保留最新版本 - forceUpdate: false, - compression: { - mode: COMPRESSION_MODE.NONE, - threshold: 0, - fanIn: 0, - maxDepth: 0, - keepRecentLeaves: 0, - instruction: '', - }, + }, + // ====== v2 新增节点类型 ====== + { + id: "synopsis", + label: "全局概要", + tableName: "synopsis_table", + columns: [ + { + name: "summary", + hint: "当前故事的全局概要(前情提要)", + required: true, + }, + { name: "scope", hint: "概要覆盖的楼层范围", required: false }, + ], + alwaysInject: true, // 常驻注入(MemoRAG 启发) + latestOnly: true, // 只保留最新版本 + forceUpdate: false, + compression: { + mode: COMPRESSION_MODE.NONE, + threshold: 0, + fanIn: 0, + maxDepth: 0, + keepRecentLeaves: 0, + instruction: "", }, - { - id: 'reflection', - label: '反思', - tableName: 'reflection_table', - columns: [ - { name: 'insight', hint: '对角色行为或情节的元认知反思', required: true }, - { name: 'trigger', hint: '触发反思的事件/矛盾', required: false }, - { name: 'suggestion', hint: '对后续叙事的建议', required: false }, - ], - alwaysInject: false, // 需要被召回 - latestOnly: false, - forceUpdate: false, - compression: { - mode: COMPRESSION_MODE.HIERARCHICAL, - threshold: 6, - fanIn: 3, - maxDepth: 3, - keepRecentLeaves: 3, - instruction: '将反思条目合并为高层次的叙事指导原则。', - }, + }, + { + id: "reflection", + label: "反思", + tableName: "reflection_table", + columns: [ + { name: "insight", hint: "对角色行为或情节的元认知反思", required: true }, + { name: "trigger", hint: "触发反思的事件/矛盾", required: false }, + { name: "suggestion", hint: "对后续叙事的建议", required: false }, + ], + alwaysInject: false, // 需要被召回 + latestOnly: false, + forceUpdate: false, + compression: { + mode: COMPRESSION_MODE.HIERARCHICAL, + threshold: 6, + fanIn: 3, + maxDepth: 3, + keepRecentLeaves: 3, + instruction: "将反思条目合并为高层次的叙事指导原则。", }, + }, ]; /** * 规范化的关系类型 */ export const RELATION_TYPES = [ - 'related', // 一般关联 - 'involved_in', // 参与事件 - 'occurred_at', // 发生于地点 - 'advances', // 推进主线 - 'updates', // 更新实体状态 - 'contradicts', // 矛盾/冲突(用于抑制边) - 'evolves', // A-MEM 进化链接(新→旧) - 'temporal_update', // 时序更新(Graphiti:新状态替代旧状态) + "related", // 一般关联 + "involved_in", // 参与事件 + "occurred_at", // 发生于地点 + "advances", // 推进主线 + "updates", // 更新实体状态 + "contradicts", // 矛盾/冲突(用于抑制边) + "evolves", // A-MEM 进化链接(新→旧) + "temporal_update", // 时序更新(Graphiti:新状态替代旧状态) ]; /** @@ -196,46 +213,80 @@ export const RELATION_TYPES = [ * @returns {{valid: boolean, errors: string[]}} */ export function validateSchema(schema) { - const errors = []; + const errors = []; - if (!Array.isArray(schema) || schema.length === 0) { - errors.push('Schema 必须是非空数组'); - return { valid: false, errors }; + if (!Array.isArray(schema) || schema.length === 0) { + errors.push("Schema 必须是非空数组"); + return { valid: false, errors }; + } + + const ids = new Set(); + const tableNames = new Set(); + + for (const type of schema) { + if (!type || typeof type !== "object") { + errors.push("Schema 类型定义必须是对象"); + continue; } - const ids = new Set(); - const tableNames = new Set(); - - for (const type of schema) { - if (!type.id || typeof type.id !== 'string') { - errors.push('每种类型必须有 id'); - continue; - } - - if (ids.has(type.id)) { - errors.push(`类型 ID 重复:${type.id}`); - } - ids.add(type.id); - - if (!type.tableName || typeof type.tableName !== 'string') { - errors.push(`类型 ${type.id}:缺少 tableName`); - } else if (tableNames.has(type.tableName)) { - errors.push(`表名重复:${type.tableName}`); - } else { - tableNames.add(type.tableName); - } - - if (!Array.isArray(type.columns) || type.columns.length === 0) { - errors.push(`类型 ${type.id}:至少需要一个列`); - } - - const hasRequired = type.columns?.some(c => c.required); - if (!hasRequired) { - errors.push(`类型 ${type.id}:至少需要一个 required 列`); - } + if (!type.id || typeof type.id !== "string") { + errors.push("每种类型必须有 id"); + continue; } - return { valid: errors.length === 0, errors }; + if (ids.has(type.id)) { + errors.push(`类型 ID 重复:${type.id}`); + } + ids.add(type.id); + + if (!type.label || typeof type.label !== "string") { + errors.push(`类型 ${type.id}:缺少 label`); + } + + if (!type.tableName || typeof type.tableName !== "string") { + errors.push(`类型 ${type.id}:缺少 tableName`); + } else if (tableNames.has(type.tableName)) { + errors.push(`表名重复:${type.tableName}`); + } else { + tableNames.add(type.tableName); + } + + if (!Array.isArray(type.columns) || type.columns.length === 0) { + errors.push(`类型 ${type.id}:至少需要一个列`); + continue; + } + + const columnNames = new Set(); + for (const column of type.columns) { + if (!column?.name || typeof column.name !== "string") { + errors.push(`类型 ${type.id}:存在缺少 name 的列定义`); + continue; + } + if (columnNames.has(column.name)) { + errors.push(`类型 ${type.id}:列名重复 ${column.name}`); + } + columnNames.add(column.name); + } + + const hasRequired = type.columns.some((c) => c?.required); + if (!hasRequired) { + errors.push(`类型 ${type.id}:至少需要一个 required 列`); + } + + if (type.latestOnly) { + const hasPrimaryLikeField = ["name", "title", "summary"].some( + (fieldName) => + type.columns.some((column) => column?.name === fieldName), + ); + if (!hasPrimaryLikeField) { + errors.push( + `类型 ${type.id}:latestOnly 类型至少需要 name/title/summary 之一作为主标识字段`, + ); + } + } + } + + return { valid: errors.length === 0, errors }; } /** @@ -245,5 +296,5 @@ export function validateSchema(schema) { * @returns {object|null} */ export function getSchemaType(schema, typeId) { - return schema.find(t => t.id === typeId) || null; + return schema.find((t) => t.id === typeId) || null; } diff --git a/settings.html b/settings.html index 765dfd7..0657f5d 100644 --- a/settings.html +++ b/settings.html @@ -23,10 +23,22 @@ id="st_bme_extract_every" class="text_pole" min="1" - max="10" + max="50" value="1" /> + +
+ + +

@@ -49,6 +61,30 @@ +
+ + +
+ +
+ + +
+
+
+ + +
+
+
+ + +
+
+
+ + +