diff --git a/index.js b/index.js
index 3fe5d91..80a6c4e 100644
--- a/index.js
+++ b/index.js
@@ -29,6 +29,7 @@ import {
getNode,
} from "./graph.js";
import { estimateTokens, formatInjection } from "./injector.js";
+import { testLLMConnection } from "./llm.js";
import { retrieve } from "./retriever.js";
import { DEFAULT_NODE_SCHEMA, validateSchema } from "./schema.js";
@@ -64,6 +65,11 @@ const defaultSettings = {
vectorWeight: 0.3,
importanceWeight: 0.1,
+ // 记忆 LLM(留空时复用当前酒馆模型)
+ llmApiUrl: "",
+ llmApiKey: "",
+ llmModel: "",
+
// Embedding API 配置
embeddingApiUrl: "",
embeddingApiKey: "",
@@ -217,6 +223,14 @@ function getEmbeddingConfig() {
};
}
+function updateModuleSettings(patch = {}) {
+ const settings = getSettings();
+ Object.assign(settings, patch);
+ extension_settings[MODULE_NAME] = settings;
+ saveSettingsDebounced();
+ return settings;
+}
+
// ==================== 图状态持久化 ====================
function loadGraphFromChat() {
@@ -759,6 +773,17 @@ async function onTestEmbedding() {
}
}
+async function onTestMemoryLLM() {
+ toastr.info("正在测试记忆 LLM 连通性...");
+ const result = await testLLMConnection();
+
+ if (result.success) {
+ toastr.success(`连接成功!模式: ${result.mode}`);
+ } else {
+ toastr.error(`连接失败: ${result.error}`);
+ }
+}
+
async function onManualExtract() {
if (isExtracting) return;
if (!currentGraph) currentGraph = createEmptyGraph();
@@ -1160,6 +1185,14 @@ function bindSettingsUI() {
getLastExtract: () => lastExtractedItems,
getLastRecall: () => lastRecalledItems,
getLastInjection: () => lastInjectionContent,
+ updateSettings: (patch) => {
+ const settings = updateModuleSettings(patch);
+ if (Object.prototype.hasOwnProperty.call(patch, "panelTheme")) {
+ _themesModule?.applyTheme(settings.panelTheme || "crimson");
+ _panelModule?.updatePanelTheme(settings.panelTheme || "crimson");
+ }
+ return settings;
+ },
actions: {
extract: onManualExtract,
compress: onManualCompress,
@@ -1169,6 +1202,8 @@ function bindSettingsUI() {
import: onImportGraph,
rebuild: onRebuild,
evolve: onManualEvolve,
+ testEmbedding: onTestEmbedding,
+ testMemoryLLM: onTestMemoryLLM,
},
});
@@ -1199,12 +1234,9 @@ function bindSettingsUI() {
.val(settings.panelTheme || 'crimson')
.on('change', function () {
const theme = $(this).val();
- const s = getSettings();
- s.panelTheme = theme;
- extension_settings[MODULE_NAME].panelTheme = theme;
+ updateModuleSettings({ panelTheme: theme });
_themesModule?.applyTheme(theme);
_panelModule?.updatePanelTheme(theme);
- saveSettingsDebounced();
});
// 打开面板按钮
diff --git a/llm.js b/llm.js
index 2344118..5984b6a 100644
--- a/llm.js
+++ b/llm.js
@@ -1,8 +1,73 @@
// ST-BME: LLM 调用封装
// 包装 ST 的 sendOpenAIRequest,提供结构化 JSON 输出和重试机制
+import { extension_settings } from "../../../extensions.js";
import { sendOpenAIRequest } from "../../../openai.js";
+const MODULE_NAME = "st_bme";
+
+function getMemoryLLMConfig() {
+ const settings = extension_settings[MODULE_NAME] || {};
+ return {
+ apiUrl: String(settings.llmApiUrl || '').trim(),
+ apiKey: String(settings.llmApiKey || '').trim(),
+ model: String(settings.llmModel || '').trim(),
+ };
+}
+
+function hasDedicatedLLMConfig(config = getMemoryLLMConfig()) {
+ return Boolean(config.apiUrl && config.model);
+}
+
+async function callDedicatedOpenAICompatible(messages, { signal } = {}) {
+ const config = getMemoryLLMConfig();
+ if (!hasDedicatedLLMConfig(config)) {
+ 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, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify({
+ model: config.model,
+ messages,
+ temperature: 0.2,
+ 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 data = await response.json();
+ const content = data?.choices?.[0]?.message?.content;
+ if (typeof content === 'string') {
+ return content;
+ }
+
+ if (Array.isArray(content)) {
+ return content
+ .map((item) => item?.text || item?.content || '')
+ .join('')
+ .trim();
+ }
+
+ throw new Error('Memory LLM API returned an unexpected response format');
+}
+
/**
* 调用 LLM 并期望返回结构化 JSON
*
@@ -21,7 +86,7 @@ export async function callLLMForJSON({ systemPrompt, userPrompt, maxRetries = 2
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
- const response = await sendOpenAIRequest('quiet', messages);
+ const response = await callDedicatedOpenAICompatible(messages);
if (!response || typeof response !== 'string') {
console.warn(`[ST-BME] LLM 返回空响应 (尝试 ${attempt + 1})`);
@@ -63,7 +128,7 @@ export async function callLLM(systemPrompt, userPrompt) {
];
try {
- const response = await sendOpenAIRequest('quiet', messages);
+ const response = await callDedicatedOpenAICompatible(messages);
return response || null;
} catch (e) {
console.error('[ST-BME] LLM 调用失败:', e);
@@ -71,6 +136,32 @@ export async function callLLM(systemPrompt, userPrompt) {
}
}
+/**
+ * 测试记忆 LLM 连通性
+ * 若未配置独立记忆 LLM,则测试当前 SillyTavern 聊天模型。
+ *
+ * @returns {Promise<{success: boolean, mode: string, error: string}>}
+ */
+export async function testLLMConnection() {
+ const config = getMemoryLLMConfig();
+ const mode = hasDedicatedLLMConfig(config)
+ ? `dedicated:${config.model}`
+ : 'sillytavern-current-model';
+
+ try {
+ const response = await callLLM(
+ '你是一个连接测试助手。请只回答 OK。',
+ '请只回复 OK',
+ );
+ if (typeof response === 'string' && response.trim().length > 0) {
+ return { success: true, mode, error: '' };
+ }
+ return { success: false, mode, error: 'API 返回空结果' };
+ } catch (e) {
+ return { success: false, mode, error: String(e) };
+ }
+}
+
/**
* 从 LLM 响应文本中提取 JSON 对象
* 处理各种常见格式:纯 JSON、markdown 代码块、混合文本等
diff --git a/panel.html b/panel.html
index 12915d2..f1761cc 100644
--- a/panel.html
+++ b/panel.html
@@ -33,6 +33,10 @@
操作
+
@@ -135,6 +139,88 @@
+
+
+
+
+
+
+ 这里配置的是 ST-BME 的第二套记忆 LLM。留空时,提取/召回/概要/反思会复用当前 SillyTavern 聊天模型。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 为空时使用内置默认提取系统提示词。这里只覆盖“记忆提取”提示词,召回/概要/反思仍走内置模板。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -201,6 +287,10 @@
操作
+
diff --git a/panel.js b/panel.js
index bf132c2..1e5dd54 100644
--- a/panel.js
+++ b/panel.js
@@ -15,6 +15,7 @@ let _getSettings = null;
let _getLastExtract = null;
let _getLastRecall = null;
let _getLastInjection = null;
+let _updateSettings = null;
let _actionHandlers = {};
async function loadLocalTemplate(templateName) {
@@ -35,6 +36,7 @@ export async function initPanel({
getLastExtract,
getLastRecall,
getLastInjection,
+ updateSettings,
actions,
}) {
_getGraph = getGraph;
@@ -42,6 +44,7 @@ export async function initPanel({
_getLastExtract = getLastExtract;
_getLastRecall = getLastRecall;
_getLastInjection = getLastInjection;
+ _updateSettings = updateSettings;
_actionHandlers = actions || {};
overlayEl = document.getElementById("st-bme-panel-overlay");
@@ -61,6 +64,7 @@ export async function initPanel({
_bindClose();
_bindGraphControls();
_bindActions();
+ _bindConfigControls();
}
/**
@@ -89,6 +93,7 @@ export function openPanel() {
_refreshDashboard();
_refreshGraph();
_buildLegend();
+ _refreshConfigTab();
}
/**
@@ -138,6 +143,9 @@ function _switchTab(tabId) {
case "injection":
void _refreshInjectionPreview();
break;
+ case "config":
+ _refreshConfigTab();
+ break;
default:
break;
}
@@ -429,6 +437,98 @@ function _bindActions() {
}
}
+function _refreshConfigTab() {
+ const settings = _getSettings?.() || {};
+
+ _setInputValue("bme-setting-llm-url", settings.llmApiUrl || "");
+ _setInputValue("bme-setting-llm-key", settings.llmApiKey || "");
+ _setInputValue("bme-setting-llm-model", settings.llmModel || "");
+ _setCheckboxValue("bme-setting-recall-llm", settings.recallEnableLLM ?? true);
+ _setInputValue("bme-setting-recall-max-nodes", settings.recallMaxNodes ?? 8);
+
+ _setInputValue("bme-setting-embed-url", settings.embeddingApiUrl || "");
+ _setInputValue("bme-setting-embed-key", settings.embeddingApiKey || "");
+ _setInputValue(
+ "bme-setting-embed-model",
+ settings.embeddingModel || "text-embedding-3-small",
+ );
+
+ _setInputValue("bme-setting-extract-prompt", settings.extractPrompt || "");
+ _setInputValue("bme-setting-panel-theme", settings.panelTheme || "crimson");
+}
+
+function _bindConfigControls() {
+ if (!panelEl || panelEl.dataset.bmeConfigBound === "true") return;
+
+ bindText("bme-setting-llm-url", (value) =>
+ _updateSettings?.({ llmApiUrl: value.trim() }),
+ );
+ bindText("bme-setting-llm-key", (value) =>
+ _updateSettings?.({ llmApiKey: value.trim() }),
+ );
+ bindText("bme-setting-llm-model", (value) =>
+ _updateSettings?.({ llmModel: value.trim() }),
+ );
+ bindCheckbox("bme-setting-recall-llm", (checked) =>
+ _updateSettings?.({ recallEnableLLM: checked }),
+ );
+ bindNumber("bme-setting-recall-max-nodes", 8, 1, 50, (value) =>
+ _updateSettings?.({ recallMaxNodes: value }),
+ );
+
+ bindText("bme-setting-embed-url", (value) =>
+ _updateSettings?.({ embeddingApiUrl: value.trim() }),
+ );
+ bindText("bme-setting-embed-key", (value) =>
+ _updateSettings?.({ embeddingApiKey: value.trim() }),
+ );
+ bindText("bme-setting-embed-model", (value) =>
+ _updateSettings?.({ embeddingModel: value.trim() }),
+ );
+ bindText("bme-setting-extract-prompt", (value) =>
+ _updateSettings?.({ extractPrompt: value }),
+ );
+ bindText("bme-setting-panel-theme", (value) =>
+ _updateSettings?.({ panelTheme: value }),
+ );
+
+ document.getElementById("bme-test-llm")?.addEventListener("click", async () => {
+ await _actionHandlers.testMemoryLLM?.();
+ });
+ document.getElementById("bme-test-embedding")?.addEventListener("click", async () => {
+ await _actionHandlers.testEmbedding?.();
+ });
+
+ panelEl.dataset.bmeConfigBound = "true";
+}
+
+function bindText(id, onChange) {
+ const element = document.getElementById(id);
+ if (!element || element.dataset.bmeBound === "true") return;
+ element.addEventListener("input", () => onChange(element.value));
+ element.addEventListener("change", () => onChange(element.value));
+ element.dataset.bmeBound = "true";
+}
+
+function bindCheckbox(id, onChange) {
+ const element = document.getElementById(id);
+ if (!element || element.dataset.bmeBound === "true") return;
+ element.addEventListener("change", () => onChange(Boolean(element.checked)));
+ element.dataset.bmeBound = "true";
+}
+
+function bindNumber(id, fallback, min, max, onChange) {
+ const element = document.getElementById(id);
+ if (!element || element.dataset.bmeBound === "true") return;
+ element.addEventListener("input", () => {
+ let value = Number.parseInt(element.value, 10);
+ if (!Number.isFinite(value)) value = fallback;
+ value = Math.min(max, Math.max(min, value));
+ onChange(value);
+ });
+ element.dataset.bmeBound = "true";
+}
+
// ==================== 工具函数 ====================
function _setText(id, text) {
@@ -436,6 +536,20 @@ function _setText(id, text) {
if (el) el.textContent = String(text);
}
+function _setInputValue(id, value) {
+ const el = document.getElementById(id);
+ if (el && el.value !== String(value ?? "")) {
+ el.value = String(value ?? "");
+ }
+}
+
+function _setCheckboxValue(id, checked) {
+ const el = document.getElementById(id);
+ if (el) {
+ el.checked = Boolean(checked);
+ }
+}
+
function _escHtml(str) {
const div = document.createElement("div");
div.textContent = String(str ?? "");
diff --git a/style.css b/style.css
index dfc483c..6f38566 100644
--- a/style.css
+++ b/style.css
@@ -674,6 +674,71 @@
background: rgba(255, 82, 82, 0.1);
}
+/* --- Config Tab --- */
+.bme-config-card {
+ background: var(--bme-surface-low);
+ border: 1px solid var(--bme-border);
+ border-radius: 8px;
+ padding: 12px;
+ margin-bottom: 10px;
+}
+
+.bme-config-help {
+ font-size: 11px;
+ line-height: 1.5;
+ color: var(--bme-on-surface-dim);
+ margin-bottom: 10px;
+}
+
+.bme-config-row {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ margin-bottom: 10px;
+}
+
+.bme-config-row.inline {
+ flex-direction: row;
+ align-items: center;
+}
+
+.bme-config-row label {
+ font-size: 11px;
+ color: var(--bme-on-surface);
+}
+
+.bme-config-input,
+.bme-config-textarea {
+ width: 100%;
+ background: var(--bme-surface-lowest);
+ border: 1px solid var(--bme-border);
+ border-radius: 6px;
+ padding: 8px 10px;
+ color: var(--bme-on-surface);
+ font-size: 12px;
+ outline: none;
+ transition: border-color 0.15s, box-shadow 0.15s;
+}
+
+.bme-config-input:focus,
+.bme-config-textarea:focus {
+ border-color: var(--bme-primary);
+ box-shadow: 0 0 0 2px var(--bme-primary-dim);
+}
+
+.bme-config-textarea {
+ min-height: 140px;
+ resize: vertical;
+ line-height: 1.5;
+ font-family: 'Cascadia Code', 'Fira Code', monospace;
+}
+
+.bme-config-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
/* --- Mobile Bottom Tab Bar --- */
.bme-panel-tabbar {
display: none;