diff --git a/index.js b/index.js index 32d1fed..58bdf22 100644 --- a/index.js +++ b/index.js @@ -84,6 +84,7 @@ const defaultSettings = { recallEnableGraphDiffusion: true, // 是否启用图扩散 recallDiffusionTopK: 100, // 图扩散阶段保留的候选上限 recallLlmCandidatePool: 30, // 传给 LLM 精排的候选池大小 + recallLlmContextMessages: 4, // 传给 LLM 精排的最近非系统消息数 // 注入设置 injectPosition: "atDepth", // 注入位置 @@ -166,10 +167,16 @@ let isRecalling = false; let lastInjectionContent = ""; let lastExtractedItems = []; // 最近提取的节点(面板展示用) let lastRecalledItems = []; // 最近召回的节点(面板展示用) +let lastRecallStatus = { + text: "待命", + meta: "尚未执行召回", + level: "idle", +}; let extractionCount = 0; // v2: 提取次数计数器(定期触发概要/遗忘/反思) let serverSettingsSaveTimer = null; let isRecoveringHistory = false; let lastHistoryWarningAt = 0; +let lastRecallFallbackNoticeAt = 0; function getNodeDisplayName(node) { return ( @@ -279,6 +286,11 @@ function ensureCurrentGraphRuntimeState() { function clearInjectionState() { lastInjectionContent = ""; lastRecalledItems = []; + lastRecallStatus = { + text: "待命", + meta: "当前无有效注入内容", + level: "idle", + }; try { const context = getContext(); @@ -291,6 +303,21 @@ function clearInjectionState() { } catch (error) { console.warn("[ST-BME] 清理旧注入失败:", error); } + + refreshPanelLiveState(); +} + +function refreshPanelLiveState() { + _panelModule?.refreshLiveState?.(); +} + +function setLastRecallStatus(text, meta, level = "info") { + lastRecallStatus = { + text: String(text || "待命"), + meta: String(meta || ""), + level, + }; + refreshPanelLiveState(); } async function recordGraphMutation({ @@ -560,6 +587,12 @@ function updateModuleSettings(patch = {}) { ); lastInjectionContent = ""; lastRecalledItems = []; + lastRecallStatus = { + text: "已停用", + meta: "插件已关闭,注入内容已清空", + level: "idle", + }; + refreshPanelLiveState(); } catch (error) { console.warn("[ST-BME] 关闭插件时清理注入失败:", error); } @@ -583,6 +616,11 @@ function loadGraphFromChat() { lastExtractedItems = []; lastRecalledItems = []; lastInjectionContent = ""; + lastRecallStatus = { + text: "待命", + meta: "当前聊天尚未建立记忆图谱", + level: "idle", + }; return; } @@ -598,6 +636,11 @@ function loadGraphFromChat() { lastExtractedItems = []; updateLastRecalledItems(currentGraph.lastRecallResult || []); lastInjectionContent = ""; + lastRecallStatus = { + text: "待命", + meta: "已加载聊天图谱,等待下一次召回", + level: "idle", + }; } function saveGraphToChat() { @@ -1186,22 +1229,39 @@ async function runRecall() { // 获取最新用户消息 let userMessage = ""; const recentMessages = []; + const recentContextMessageLimit = clampInt( + settings.recallLlmContextMessages, + 4, + 0, + 20, + ); - for (let i = chat.length - 1; i >= 0 && recentMessages.length < 4; i--) { + for ( + let i = chat.length - 1; + i >= 0 && (!userMessage || recentMessages.length < recentContextMessageLimit); + i-- + ) { const msg = chat[i]; if (msg.is_system) continue; if (msg.is_user && !userMessage) { userMessage = msg.mes || ""; } - recentMessages.unshift( - `[${msg.is_user ? "user" : "assistant"}]: ${msg.mes || ""}`, - ); + if (recentMessages.length < recentContextMessageLimit) { + recentMessages.unshift( + `[${msg.is_user ? "user" : "assistant"}]: ${msg.mes || ""}`, + ); + } } if (!userMessage) return; console.log("[ST-BME] 开始召回"); + setLastRecallStatus( + "召回中", + `上下文 ${recentMessages.length} 条 · 当前用户消息长度 ${userMessage.length}`, + "running", + ); const result = await retrieve({ graph: currentGraph, @@ -1235,6 +1295,12 @@ async function runRecall() { // 格式化注入文本 const injectionText = formatInjection(result, getSchema()).trim(); lastInjectionContent = injectionText; + const retrievalMeta = result?.meta?.retrieval || {}; + const llmMeta = retrievalMeta.llm || { + status: settings.recallEnableLLM ? "unknown" : "disabled", + reason: settings.recallEnableLLM ? "未提供 LLM 状态" : "LLM 精排已关闭", + candidatePool: 0, + }; if (injectionText) { const tokens = estimateTokens(injectionText); @@ -1255,10 +1321,40 @@ async function runRecall() { currentGraph.lastRecallResult = result.selectedNodeIds; updateLastRecalledItems(result.selectedNodeIds || []); saveGraphToChat(); + + const llmLabel = + llmMeta.status === "llm" + ? "LLM 精排完成" + : llmMeta.status === "fallback" + ? "LLM 回退评分" + : llmMeta.status === "disabled" + ? "仅评分排序" + : "召回完成"; + setLastRecallStatus( + llmLabel, + `ctx ${recentMessages.length} · vector ${retrievalMeta.vectorHits ?? 0} · diffusion ${retrievalMeta.diffusionHits ?? 0} · llm pool ${llmMeta.candidatePool ?? 0} · recall ${result.stats.recallCount}`, + llmMeta.status === "fallback" ? "warning" : "success", + ); + + if (llmMeta.status === "fallback") { + const now = Date.now(); + if (now - lastRecallFallbackNoticeAt > 15000) { + lastRecallFallbackNoticeAt = now; + toastr.warning( + llmMeta.reason || "LLM 精排未返回有效结果,已回退到评分排序", + "ST-BME 召回提示", + { timeOut: 4500 }, + ); + } + } } catch (e) { console.error("[ST-BME] 召回失败:", e); + const message = e?.message || String(e); + setLastRecallStatus("召回失败", message, "error"); + toastr.error(`召回失败: ${message}`); } finally { isRecalling = false; + refreshPanelLiveState(); } } @@ -1669,6 +1765,7 @@ async function onReembedDirect() { getSettings: () => getSettings(), getLastExtract: () => lastExtractedItems, getLastRecall: () => lastRecalledItems, + getLastRecallStatus: () => lastRecallStatus, getLastInjection: () => lastInjectionContent, updateSettings: (patch) => { const settings = updateModuleSettings(patch); diff --git a/panel.html b/panel.html index f3dad13..9e36f8e 100644 --- a/panel.html +++ b/panel.html @@ -126,6 +126,10 @@
+
+ +
+
@@ -748,7 +752,7 @@
LLM 精确召回
-
控制是否启用 LLM 精排,以及传给 LLM 的候选池大小与最终保留上限。
+
控制是否启用 LLM 精排,以及传给 LLM 的上下文消息数、候选池大小与最终保留上限。
在“功能开关”中启用后生效。
@@ -756,6 +760,10 @@ 启用 LLM 精排 +
+ + +
diff --git a/panel.js b/panel.js index 743d9b6..b36384d 100644 --- a/panel.js +++ b/panel.js @@ -120,6 +120,7 @@ let _getGraph = null; let _getSettings = null; let _getLastExtract = null; let _getLastRecall = null; +let _getLastRecallStatus = null; let _getLastInjection = null; let _updateSettings = null; let _actionHandlers = {}; @@ -141,6 +142,7 @@ export async function initPanel({ getSettings, getLastExtract, getLastRecall, + getLastRecallStatus, getLastInjection, updateSettings, actions, @@ -149,6 +151,7 @@ export async function initPanel({ _getSettings = getSettings; _getLastExtract = getLastExtract; _getLastRecall = getLastRecall; + _getLastRecallStatus = getLastRecallStatus; _getLastInjection = getLastInjection; _updateSettings = updateSettings; _actionHandlers = actions || {}; @@ -176,6 +179,7 @@ export async function initPanel({ panelEl?.querySelector(".bme-tab-btn.active")?.dataset.tab || "dashboard"; _applyWorkspaceMode(); _syncConfigSectionState(); + _refreshRuntimeStatus(); } /** @@ -204,6 +208,7 @@ export function openPanel() { const activeTabId = panelEl?.querySelector(".bme-tab-btn.active")?.dataset.tab || currentTabId; _switchTab(activeTabId); + _refreshRuntimeStatus(); _refreshGraph(); _buildLegend(); } @@ -226,6 +231,27 @@ export function updatePanelTheme(themeName) { _highlightThemeChoice(themeName); } +export function refreshLiveState() { + if (!overlayEl?.classList.contains("active")) return; + _refreshRuntimeStatus(); + + switch (currentTabId) { + case "dashboard": + _refreshDashboard(); + break; + case "memory": + _refreshMemoryBrowser(); + break; + case "injection": + void _refreshInjectionPreview(); + break; + default: + break; + } + + _refreshGraph(); +} + // ==================== Tab 切换 ==================== function _bindTabs() { @@ -309,10 +335,6 @@ function _refreshDashboard() { _setText("bme-stat-edges", graph.edges.length); _setText("bme-stat-archived", archivedCount); _setText("bme-stat-frag", `${fragRate}%`); - _setText( - "bme-status-meta", - `NODES: ${activeNodes.length} | EDGES: ${graph.edges.length}`, - ); const chatId = graph?.historyState?.chatId || "—"; const lastProcessed = graph?.historyState?.lastProcessedAssistantFloor ?? -1; @@ -321,6 +343,7 @@ function _refreshDashboard() { const vectorMode = graph?.vectorIndexState?.mode || "—"; const vectorSource = graph?.vectorIndexState?.source || "—"; const recovery = graph?.historyState?.lastRecoveryResult; + const recallStatus = _getLastRecallStatus?.() || {}; _setText("bme-status-chat-id", chatId); _setText( @@ -339,6 +362,10 @@ function _refreshDashboard() { ? `${recovery.status} · from ${recovery.fromFloor ?? "—"} · ${recovery.reason || "—"}` : "暂无恢复记录", ); + _setText( + "bme-status-last-recall", + recallStatus.meta || "尚未执行召回", + ); _renderRecentList("bme-recent-extract", _getLastExtract?.() || []); _renderRecentList("bme-recent-recall", _getLastRecall?.() || []); @@ -720,6 +747,10 @@ function _refreshConfigTab() { "bme-setting-recall-llm-candidate-pool", settings.recallLlmCandidatePool ?? 30, ); + _setInputValue( + "bme-setting-recall-llm-context-messages", + settings.recallLlmContextMessages ?? 4, + ); _setInputValue("bme-setting-inject-depth", settings.injectDepth ?? 9999); _setInputValue("bme-setting-graph-weight", settings.graphWeight ?? 0.6); _setInputValue("bme-setting-vector-weight", settings.vectorWeight ?? 0.3); @@ -892,6 +923,9 @@ function _bindConfigControls() { bindNumber("bme-setting-recall-llm-candidate-pool", 30, 1, 100, (value) => _patchSettings({ recallLlmCandidatePool: value }), ); + bindNumber("bme-setting-recall-llm-context-messages", 4, 0, 20, (value) => + _patchSettings({ recallLlmContextMessages: value }), + ); bindNumber("bme-setting-inject-depth", 9999, 0, 9999, (value) => _patchSettings({ injectDepth: value }), ); @@ -1186,6 +1220,15 @@ function _setText(id, text) { if (el) el.textContent = String(text); } +function _refreshRuntimeStatus() { + const recallStatus = _getLastRecallStatus?.() || {}; + const text = recallStatus.text || "待命"; + const meta = recallStatus.meta || "尚未执行召回"; + _setText("bme-status-text", text); + _setText("bme-status-meta", meta); + _setText("bme-panel-status", text); +} + function _patchSettings(patch = {}, options = {}) { const settings = _updateSettings?.(patch) || _getSettings?.() || {}; if (options.refreshGuards) _refreshGuardedConfigStates(settings); diff --git a/retriever.js b/retriever.js index 3f6ff4d..3ada8e7 100644 --- a/retriever.js +++ b/retriever.js @@ -76,9 +76,27 @@ export async function retrieve({ let vectorResults = []; let diffusionResults = []; let useLLM = false; + let llmMeta = { + enabled: enableLLMRecall, + status: enableLLMRecall ? "pending" : "disabled", + reason: enableLLMRecall ? "" : "LLM 精排已关闭", + candidatePool: 0, + selectedSeedCount: 0, + }; if (nodeCount === 0) { - return buildResult(graph, [], schema); + return buildResult(graph, [], schema, { + retrieval: { + vectorHits: 0, + diffusionHits: 0, + scoredCandidates: 0, + llm: { + ...llmMeta, + status: enableLLMRecall ? "skipped" : "disabled", + reason: "当前没有可参与召回的活跃节点", + }, + }, + }); } // ========== 第 1 层:向量预筛 ========== @@ -208,7 +226,7 @@ export async function retrieve({ 0, Math.min(normalizedLlmCandidatePool, scoredNodes.length), ); - selectedNodeIds = await llmRecall( + const llmResult = await llmRecall( userMessage, recentMessages, candidateNodes, @@ -217,10 +235,25 @@ export async function retrieve({ normalizedMaxRecallNodes, options.recallPrompt, ); + selectedNodeIds = llmResult.selectedNodeIds; + llmMeta = { + enabled: true, + status: llmResult.status, + reason: llmResult.reason, + candidatePool: candidateNodes.length, + selectedSeedCount: llmResult.selectedNodeIds.length, + }; } else { selectedNodeIds = scoredNodes .slice(0, Math.min(normalizedTopK, scoredNodes.length)) .map((s) => s.nodeId); + llmMeta = { + enabled: false, + status: "disabled", + reason: "LLM 精排已关闭,直接采用评分排序", + candidatePool: 0, + selectedSeedCount: selectedNodeIds.length, + }; } selectedNodeIds = reconstructSceneNodeIds( @@ -265,7 +298,14 @@ export async function retrieve({ selectedNodeIds = uniqueNodeIds(selectedNodeIds); - return buildResult(graph, selectedNodeIds, schema); + return buildResult(graph, selectedNodeIds, schema, { + retrieval: { + vectorHits: vectorResults.length, + diffusionHits: diffusionResults.length, + scoredCandidates: scoredNodes.length, + llm: llmMeta, + }, + }); } /** @@ -374,14 +414,30 @@ async function llmRecall( if (result?.selected_ids && Array.isArray(result.selected_ids)) { // 校验 ID 有效性 - const validIds = result.selected_ids.filter((id) => - candidates.some((c) => c.nodeId === id), - ); - return validIds; + const validIds = uniqueNodeIds( + result.selected_ids.filter((id) => + candidates.some((c) => c.nodeId === id), + ), + ).slice(0, maxNodes); + + if (validIds.length > 0 || result.selected_ids.length === 0) { + return { + selectedNodeIds: validIds, + status: "llm", + reason: + validIds.length < result.selected_ids.length + ? "LLM 返回了部分无效或超限 ID,已自动裁剪" + : "LLM 精排完成", + }; + } } // LLM 失败时回退到纯评分排序 - return candidates.slice(0, maxNodes).map((c) => c.nodeId); + return { + selectedNodeIds: candidates.slice(0, maxNodes).map((c) => c.nodeId), + status: "fallback", + reason: "LLM 未返回有效 JSON 或有效候选,已回退到评分排序", + }; } // ==================== v2 辅助函数 ==================== @@ -418,7 +474,7 @@ function filterByVisibility(nodes, characterName) { * 构建最终检索结果 * 分离常驻注入(Core)和召回注入(Recall) */ -function buildResult(graph, selectedNodeIds, schema) { +function buildResult(graph, selectedNodeIds, schema, meta = {}) { const coreNodes = []; const recallNodes = []; const selectedSet = new Set(uniqueNodeIds(selectedNodeIds)); @@ -453,6 +509,7 @@ function buildResult(graph, selectedNodeIds, schema) { recallNodes, groupedRecallNodes, selectedNodeIds: [...selectedSet], + meta, stats: { totalActive: activeNodes.length, coreCount: coreNodes.length, diff --git a/tests/default-settings.mjs b/tests/default-settings.mjs index ef3befc..afac438 100644 --- a/tests/default-settings.mjs +++ b/tests/default-settings.mjs @@ -32,6 +32,7 @@ assert.equal(defaultSettings.recallEnableVectorPrefilter, true); assert.equal(defaultSettings.recallEnableGraphDiffusion, true); assert.equal(defaultSettings.recallDiffusionTopK, 100); assert.equal(defaultSettings.recallLlmCandidatePool, 30); +assert.equal(defaultSettings.recallLlmContextMessages, 4); assert.equal(defaultSettings.injectDepth, 9999); console.log("default-settings tests passed"); diff --git a/tests/retrieval-config.mjs b/tests/retrieval-config.mjs index 1fe3667..ea463e2 100644 --- a/tests/retrieval-config.mjs +++ b/tests/retrieval-config.mjs @@ -165,6 +165,8 @@ assert.deepEqual(state.vectorCalls, [4]); assert.equal(state.diffusionCalls.length, 0); assert.equal(state.llmCandidateCount, 2); assert.deepEqual(Array.from(llmPoolResult.selectedNodeIds), ["rule-2", "rule-1"]); +assert.equal(llmPoolResult.meta.retrieval.llm.status, "llm"); +assert.equal(llmPoolResult.meta.retrieval.llm.candidatePool, 2); state.vectorCalls.length = 0; state.diffusionCalls.length = 0; @@ -187,5 +189,6 @@ await retrieve({ assert.deepEqual(state.vectorCalls, [3]); assert.equal(state.diffusionCalls.length, 1); assert.equal(state.diffusionCalls[0].options.topK, 7); +assert.equal(noStageResult.meta.retrieval.llm.status, "disabled"); console.log("retrieval-config tests passed");