Fix plugin-only memory settings and UI flow

This commit is contained in:
Youzini-afk
2026-03-24 11:42:10 +08:00
parent 00ae535873
commit 7b68eebb9e
8 changed files with 276 additions and 48 deletions

View File

@@ -1,7 +1,7 @@
// ST-BME: 层级压缩引擎
// 超过阈值的节点被 LLM 总结为更高层级的压缩节点
import { createNode, addNode, getActiveNodes, getNode } from './graph.js';
import { createNode, addNode, createEdge, addEdge, getActiveNodes, getNode } from './graph.js';
import { callLLMForJSON } from './llm.js';
import { embedText } from './embedding.js';
@@ -100,6 +100,7 @@ async function compressLevel({ graph, typeDef, level, embeddingConfig, force })
}
addNode(graph, compressedNode);
migrateBatchEdges(graph, batch, compressedNode);
created++;
// 归档子节点
@@ -113,6 +114,40 @@ async function compressLevel({ graph, typeDef, level, embeddingConfig, force })
return { created, archived };
}
function migrateBatchEdges(graph, batch, compressedNode) {
const batchIds = new Set(batch.map(node => node.id));
const activeNodeIds = new Set(getActiveNodes(graph).map(node => node.id));
for (const edge of graph.edges) {
if (edge.invalidAt || edge.expiredAt) continue;
const fromInside = batchIds.has(edge.fromId);
const toInside = batchIds.has(edge.toId);
if (!fromInside && !toInside) continue;
if (fromInside && toInside) continue;
const newFromId = fromInside ? compressedNode.id : edge.fromId;
const newToId = toInside ? compressedNode.id : edge.toId;
if (newFromId === newToId) continue;
if (!activeNodeIds.has(newFromId) || !activeNodeIds.has(newToId)) continue;
if (!getNode(graph, newFromId) || !getNode(graph, newToId)) continue;
const migratedEdge = createEdge({
fromId: newFromId,
toId: newToId,
relation: edge.relation,
strength: edge.strength,
edgeType: edge.edgeType,
});
migratedEdge.validAt = edge.validAt ?? migratedEdge.validAt;
migratedEdge.invalidAt = edge.invalidAt ?? migratedEdge.invalidAt;
migratedEdge.expiredAt = edge.expiredAt ?? migratedEdge.expiredAt;
addEdge(graph, migratedEdge);
}
}
/**
* 调用 LLM 总结一批节点
*/

View File

@@ -6,6 +6,13 @@
* 调用外部 API 获取文本向量,并提供暴力搜索 cosine 相似度
*/
function normalizeOpenAICompatibleBaseUrl(value) {
return String(value || '')
.trim()
.replace(/\/+(chat\/completions|embeddings)$/i, '')
.replace(/\/+$/, '');
}
/**
* 调用外部 Embedding API
*
@@ -17,19 +24,18 @@
* @returns {Promise<Float64Array|null>} 向量或 null
*/
export async function embedText(text, config) {
if (!text || !config.apiUrl || !config.apiKey || !config.model) {
const apiUrl = normalizeOpenAICompatibleBaseUrl(config?.apiUrl);
if (!text || !apiUrl || !config?.model) {
console.warn('[ST-BME] Embedding 配置不完整,跳过');
return null;
}
try {
const url = `${config.apiUrl.replace(/\/+$/, '')}/embeddings`;
const response = await fetch(url, {
const response = await fetch(`${apiUrl}/embeddings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}),
},
body: JSON.stringify({
model: config.model,
@@ -66,18 +72,17 @@ export async function embedText(text, config) {
* @returns {Promise<(Float64Array|null)[]>}
*/
export async function embedBatch(texts, config) {
if (!texts.length || !config.apiUrl || !config.apiKey || !config.model) {
const apiUrl = normalizeOpenAICompatibleBaseUrl(config?.apiUrl);
if (!texts.length || !apiUrl || !config?.model) {
return texts.map(() => null);
}
try {
const url = `${config.apiUrl.replace(/\/+$/, '')}/embeddings`;
const response = await fetch(url, {
const response = await fetch(`${apiUrl}/embeddings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
...(config.apiKey ? { Authorization: `Bearer ${config.apiKey}` } : {}),
},
body: JSON.stringify({
model: config.model,

View File

@@ -77,19 +77,22 @@ export class GraphRenderer {
*/
loadGraph(graph) {
this.nodeMap.clear();
const dpr = window.devicePixelRatio || 1;
const viewportWidth = this.canvas.width / dpr;
const viewportHeight = this.canvas.height / dpr;
// 转换节点
const activeNodes = graph.nodes.filter(n => !n.archived);
this.nodes = activeNodes.map((n, i) => {
const angle = (2 * Math.PI * i) / activeNodes.length;
const r = Math.min(this.canvas.width, this.canvas.height) * 0.3;
const r = Math.min(viewportWidth, viewportHeight) * 0.3;
const node = {
id: n.id,
type: n.type || 'event',
name: getNodeDisplayName(n),
importance: n.importance || 5,
x: this.canvas.width / 2 + r * Math.cos(angle) + (Math.random() - 0.5) * 40,
y: this.canvas.height / 2 + r * Math.sin(angle) + (Math.random() - 0.5) * 40,
x: viewportWidth / 2 + r * Math.cos(angle) + (Math.random() - 0.5) * 40,
y: viewportHeight / 2 + r * Math.sin(angle) + (Math.random() - 0.5) * 40,
vx: 0,
vy: 0,
pinned: false,

View File

@@ -328,9 +328,15 @@ export function getNodeEdges(graph, nodeId) {
*/
export function buildAdjacencyMap(graph) {
const adj = new Map();
const activeNodeIds = new Set(
graph.nodes.filter((node) => !node.archived).map((node) => node.id),
);
for (const edge of graph.edges) {
if (!isEdgeActive(edge)) continue;
if (!activeNodeIds.has(edge.fromId) || !activeNodeIds.has(edge.toId)) {
continue;
}
if (!adj.has(edge.fromId)) adj.set(edge.fromId, []);
adj.get(edge.fromId).push({
@@ -358,9 +364,15 @@ export function buildAdjacencyMap(graph) {
*/
export function buildTemporalAdjacencyMap(graph) {
const adj = new Map();
const activeNodeIds = new Set(
graph.nodes.filter((node) => !node.archived).map((node) => node.id),
);
for (const edge of graph.edges) {
if (!isEdgeActive(edge)) continue;
if (!activeNodeIds.has(edge.fromId) || !activeNodeIds.has(edge.toId)) {
continue;
}
if (!adj.has(edge.fromId)) adj.set(edge.fromId, []);
adj.get(edge.fromId).push({

133
index.js
View File

@@ -4,6 +4,7 @@
import {
eventSource,
event_types,
getRequestHeaders,
saveSettingsDebounced,
} from "../../../../script.js";
import {
@@ -39,6 +40,8 @@ let _themesModule = null;
const MODULE_NAME = "st_bme";
const GRAPH_METADATA_KEY = "st_bme_graph";
const SERVER_SETTINGS_FILENAME = "st-bme-settings.json";
const SERVER_SETTINGS_URL = `/user/files/${SERVER_SETTINGS_FILENAME}`;
// ==================== 默认设置 ====================
@@ -132,6 +135,7 @@ let lastInjectionContent = "";
let lastExtractedItems = []; // 最近提取的节点(面板展示用)
let lastRecalledItems = []; // 最近召回的节点(面板展示用)
let extractionCount = 0; // v2: 提取次数计数器(定期触发概要/遗忘/反思)
let serverSettingsSaveTimer = null;
function getNodeDisplayName(node) {
return (
@@ -223,11 +227,115 @@ function getEmbeddingConfig() {
};
}
function getPersistedSettingsSnapshot(settings = getSettings()) {
const persisted = {};
for (const key of Object.keys(defaultSettings)) {
persisted[key] = settings[key];
}
return persisted;
}
function mergePersistedSettings(loaded = {}) {
const merged = { ...defaultSettings };
for (const key of Object.keys(defaultSettings)) {
if (Object.prototype.hasOwnProperty.call(loaded, key)) {
merged[key] = loaded[key];
}
}
return merged;
}
function encodeBase64Utf8(text) {
const bytes = new TextEncoder().encode(String(text ?? ""));
const chunkSize = 0x8000;
let binary = "";
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
}
async function loadServerSettings() {
try {
const response = await fetch(
`${SERVER_SETTINGS_URL}?t=${Date.now()}`,
{ cache: "no-store" },
);
if (response.status === 404) {
return;
}
if (!response.ok) {
throw new Error(response.statusText || `HTTP ${response.status}`);
}
const loaded = await response.json();
if (loaded && typeof loaded === "object" && !Array.isArray(loaded)) {
extension_settings[MODULE_NAME] = mergePersistedSettings(loaded);
saveSettingsDebounced();
}
} catch (error) {
console.warn("[ST-BME] 读取服务端设置失败,回退到本地运行时设置:", error);
}
}
async function saveServerSettings(settings = getSettings()) {
const payload = JSON.stringify(
getPersistedSettingsSnapshot(settings),
null,
2,
);
const response = await fetch("/api/files/upload", {
method: "POST",
headers: getRequestHeaders(),
body: JSON.stringify({
name: SERVER_SETTINGS_FILENAME,
data: encodeBase64Utf8(payload),
}),
});
if (!response.ok) {
const message = await response.text().catch(() => response.statusText);
throw new Error(message || `HTTP ${response.status}`);
}
}
function scheduleServerSettingsSave() {
clearTimeout(serverSettingsSaveTimer);
serverSettingsSaveTimer = setTimeout(async () => {
try {
await saveServerSettings();
} catch (error) {
console.error("[ST-BME] 保存服务端设置失败:", error);
}
}, 300);
}
function updateModuleSettings(patch = {}) {
const settings = getSettings();
Object.assign(settings, patch);
extension_settings[MODULE_NAME] = settings;
saveSettingsDebounced();
if (
Object.prototype.hasOwnProperty.call(patch, "enabled") &&
patch.enabled === false
) {
try {
const context = getContext();
context.setExtensionPrompt(MODULE_NAME, "", 1, 0);
lastInjectionContent = "";
lastRecalledItems = [];
} catch (error) {
console.warn("[ST-BME] 关闭插件时清理注入失败:", error);
}
}
scheduleServerSettingsSave();
return settings;
}
@@ -630,6 +738,12 @@ async function runRecall() {
function onChatChanged() {
loadGraphFromChat();
lastInjectionContent = "";
try {
const context = getContext();
context.setExtensionPrompt(MODULE_NAME, "", 1, 0);
} catch (error) {
console.warn("[ST-BME] 清理旧注入失败:", error);
}
}
async function onGenerationAfterCommands() {
@@ -758,8 +872,8 @@ async function onViewLastInjection() {
async function onTestEmbedding() {
const config = getEmbeddingConfig();
if (!config.apiUrl || !config.apiKey) {
toastr.warning("请先配置 Embedding API 地址和 Key");
if (!config.apiUrl || !config.model) {
toastr.warning("请先配置 Embedding API 地址和模型");
return;
}
@@ -1152,6 +1266,8 @@ function bindSettingsUI() {
// ==================== 初始化 ====================
(async function init() {
await loadServerSettings();
// 注册事件钩子
eventSource.on(event_types.CHAT_CHANGED, onChatChanged);
eventSource.on(
@@ -1229,19 +1345,6 @@ function bindSettingsUI() {
}
}
// 主题选择绑定
$('#st_bme_panel_theme')
.val(settings.panelTheme || 'crimson')
.on('change', function () {
const theme = $(this).val();
updateModuleSettings({ panelTheme: theme });
_themesModule?.applyTheme(theme);
_panelModule?.updatePanelTheme(theme);
});
// 打开面板按钮
$('#st_bme_btn_open_panel').on('click', () => _panelModule?.openPanel());
console.log("[ST-BME] 操控面板初始化完成");
} catch (panelError) {
console.error("[ST-BME] 操控面板加载失败(核心功能不受影响):", panelError);

50
llm.js
View File

@@ -2,19 +2,27 @@
// 包装 ST 的 sendOpenAIRequest提供结构化 JSON 输出和重试机制
import { extension_settings } from "../../../extensions.js";
import { sendOpenAIRequest } from "../../../openai.js";
import { chat_completion_sources, sendOpenAIRequest } from "../../../openai.js";
import { getRequestHeaders } from "../../../../script.js";
const MODULE_NAME = "st_bme";
function getMemoryLLMConfig() {
const settings = extension_settings[MODULE_NAME] || {};
return {
apiUrl: String(settings.llmApiUrl || '').trim(),
apiUrl: normalizeOpenAICompatibleBaseUrl(settings.llmApiUrl),
apiKey: String(settings.llmApiKey || '').trim(),
model: String(settings.llmModel || '').trim(),
};
}
function normalizeOpenAICompatibleBaseUrl(value) {
return String(value || '')
.trim()
.replace(/\/+(chat\/completions|embeddings)$/i, '')
.replace(/\/+$/, '');
}
function hasDedicatedLLMConfig(config = getMemoryLLMConfig()) {
return Boolean(config.apiUrl && config.model);
}
@@ -25,34 +33,40 @@ async function callDedicatedOpenAICompatible(messages, { signal } = {}) {
return await sendOpenAIRequest('quiet', messages, signal);
}
const url = `${config.apiUrl.replace(/\/+$/, '')}/chat/completions`;
const headers = {
'Content-Type': 'application/json',
};
if (config.apiKey) {
headers.Authorization = `Bearer ${config.apiKey}`;
}
const response = await fetch(url, {
const response = await fetch('/api/backends/chat-completions/generate', {
method: 'POST',
headers,
headers: getRequestHeaders(),
body: JSON.stringify({
chat_completion_source: chat_completion_sources.OPENAI,
reverse_proxy: config.apiUrl,
proxy_password: config.apiKey || '',
model: config.model,
messages,
temperature: 0.2,
max_tokens: 1200,
max_completion_tokens: 1200,
stream: false,
}),
signal,
});
if (!response.ok) {
const errorText = await response.text().catch(() => '');
throw new Error(
`Memory LLM API error ${response.status}: ${errorText || response.statusText}`,
);
const responseText = await response.text().catch(() => '');
let data;
try {
data = responseText ? JSON.parse(responseText) : {};
} catch {
data = { error: { message: responseText || response.statusText } };
}
const data = await response.json();
if (!response.ok) {
const message = data?.error?.message || response.statusText;
throw new Error(`Memory LLM proxy error ${response.status}: ${message}`);
}
if (data?.error?.message) {
throw new Error(`Memory LLM proxy error: ${data.error.message}`);
}
const content = data?.choices?.[0]?.message?.content;
if (typeof content === 'string') {
return content;

View File

@@ -142,10 +142,38 @@
<!-- Config Tab -->
<div class="bme-tab-pane" id="bme-pane-config">
<div class="bme-config-card">
<div class="bme-section-header">基础开关</div>
<div class="bme-config-row inline">
<label class="checkbox_label" for="bme-setting-enabled">
<input id="bme-setting-enabled" type="checkbox" />
<span>启用 ST-BME 自动记忆</span>
</label>
</div>
<div class="bme-config-row inline">
<label class="checkbox_label" for="bme-setting-recall-enabled">
<input id="bme-setting-recall-enabled" type="checkbox" />
<span>启用生成前记忆召回</span>
</label>
</div>
<div class="bme-config-row">
<label for="bme-setting-extract-every">每 N 条回复提取</label>
<input id="bme-setting-extract-every" class="bme-config-input" type="number" min="1" max="50" />
</div>
<div class="bme-config-row">
<label for="bme-setting-extract-context-turns">提取上下文轮数</label>
<input id="bme-setting-extract-context-turns" class="bme-config-input" type="number" min="0" max="20" />
</div>
<div class="bme-config-row">
<label for="bme-setting-inject-depth">注入深度</label>
<input id="bme-setting-inject-depth" class="bme-config-input" type="number" min="0" max="9999" />
</div>
</div>
<div class="bme-config-card">
<div class="bme-section-header">记忆 LLM</div>
<div class="bme-config-help">
这里配置的是 ST-BME 的第二套记忆 LLM。留空时提取/召回/概要/反思会复用当前 SillyTavern 聊天模型。
这里配置的是 ST-BME 的第二套记忆 LLM。留空时提取/召回/概要/反思会复用当前 SillyTavern 聊天模型;填写后会通过 SillyTavern 现有后端代理转发到 OpenAI 兼容接口,不要求改酒馆本体
</div>
<div class="bme-config-row">
<label for="bme-setting-llm-url">LLM API 地址</label>
@@ -178,6 +206,9 @@
<div class="bme-config-card">
<div class="bme-section-header">Embedding</div>
<div class="bme-config-help">
图谱向量仍使用 OpenAI 兼容的 <code>/v1/embeddings</code> 接口。当前发布版不改酒馆本体,因此这里不会依赖额外宿主补丁;若目标服务不支持浏览器直连,请改用支持 CORS 的服务或本地可直连端点。
</div>
<div class="bme-config-row">
<label for="bme-setting-embed-url">Embedding API 地址</label>
<input id="bme-setting-embed-url" class="bme-config-input" type="text" placeholder="https://api.openai.com/v1" />

View File

@@ -440,6 +440,15 @@ function _bindActions() {
function _refreshConfigTab() {
const settings = _getSettings?.() || {};
_setCheckboxValue("bme-setting-enabled", settings.enabled ?? false);
_setCheckboxValue("bme-setting-recall-enabled", settings.recallEnabled ?? true);
_setInputValue("bme-setting-extract-every", settings.extractEvery ?? 1);
_setInputValue(
"bme-setting-extract-context-turns",
settings.extractContextTurns ?? 2,
);
_setInputValue("bme-setting-inject-depth", settings.injectDepth ?? 4);
_setInputValue("bme-setting-llm-url", settings.llmApiUrl || "");
_setInputValue("bme-setting-llm-key", settings.llmApiKey || "");
_setInputValue("bme-setting-llm-model", settings.llmModel || "");
@@ -460,6 +469,22 @@ function _refreshConfigTab() {
function _bindConfigControls() {
if (!panelEl || panelEl.dataset.bmeConfigBound === "true") return;
bindCheckbox("bme-setting-enabled", (checked) =>
_updateSettings?.({ enabled: checked }),
);
bindCheckbox("bme-setting-recall-enabled", (checked) =>
_updateSettings?.({ recallEnabled: checked }),
);
bindNumber("bme-setting-extract-every", 1, 1, 50, (value) =>
_updateSettings?.({ extractEvery: value }),
);
bindNumber("bme-setting-extract-context-turns", 2, 0, 20, (value) =>
_updateSettings?.({ extractContextTurns: value }),
);
bindNumber("bme-setting-inject-depth", 4, 0, 9999, (value) =>
_updateSettings?.({ injectDepth: value }),
);
bindText("bme-setting-llm-url", (value) =>
_updateSettings?.({ llmApiUrl: value.trim() }),
);