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");