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 @@
+ + +
+
+
记忆 LLM
+
+ 这里配置的是 ST-BME 的第二套记忆 LLM。留空时,提取/召回/概要/反思会复用当前 SillyTavern 聊天模型。 +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ +
+
+ +
+
Embedding
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
系统提示词
+
+ 为空时使用内置默认提取系统提示词。这里只覆盖“记忆提取”提示词,召回/概要/反思仍走内置模板。 +
+
+ + +
+
+ +
+
面板外观
+
+ + +
+
+
@@ -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;