feat: add in-panel memory model and embedding configuration

This commit is contained in:
Youzini-afk
2026-03-24 00:00:37 +08:00
parent 5b4a88019a
commit 00ae535873
5 changed files with 398 additions and 6 deletions

View File

@@ -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();
});
// 打开面板按钮

95
llm.js
View File

@@ -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 代码块、混合文本等

View File

@@ -33,6 +33,10 @@
<i class="fa-solid fa-gear"></i>
<span>操作</span>
</button>
<button class="bme-tab-btn" data-tab="config">
<i class="fa-solid fa-sliders"></i>
<span>配置</span>
</button>
</div>
<div class="bme-tab-content">
@@ -135,6 +139,88 @@
</button>
</div>
</div>
<!-- Config Tab -->
<div class="bme-tab-pane" id="bme-pane-config">
<div class="bme-config-card">
<div class="bme-section-header">记忆 LLM</div>
<div class="bme-config-help">
这里配置的是 ST-BME 的第二套记忆 LLM。留空时提取/召回/概要/反思会复用当前 SillyTavern 聊天模型。
</div>
<div class="bme-config-row">
<label for="bme-setting-llm-url">LLM API 地址</label>
<input id="bme-setting-llm-url" class="bme-config-input" type="text" placeholder="https://api.openai.com/v1" />
</div>
<div class="bme-config-row">
<label for="bme-setting-llm-key">LLM API Key</label>
<input id="bme-setting-llm-key" class="bme-config-input" type="password" placeholder="sk-..." />
</div>
<div class="bme-config-row">
<label for="bme-setting-llm-model">LLM 模型</label>
<input id="bme-setting-llm-model" class="bme-config-input" type="text" placeholder="gpt-4.1-mini / qwen-max / deepseek-chat" />
</div>
<div class="bme-config-row inline">
<label class="checkbox_label" for="bme-setting-recall-llm">
<input id="bme-setting-recall-llm" type="checkbox" />
<span>启用 LLM 精确召回</span>
</label>
</div>
<div class="bme-config-row">
<label for="bme-setting-recall-max-nodes">LLM 精确召回上限</label>
<input id="bme-setting-recall-max-nodes" class="bme-config-input" type="number" min="1" max="50" />
</div>
<div class="bme-config-actions">
<button class="menu_button" id="bme-test-llm">
<i class="fa-solid fa-plug"></i> 测试记忆 LLM
</button>
</div>
</div>
<div class="bme-config-card">
<div class="bme-section-header">Embedding</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" />
</div>
<div class="bme-config-row">
<label for="bme-setting-embed-key">Embedding API Key</label>
<input id="bme-setting-embed-key" class="bme-config-input" type="password" placeholder="sk-..." />
</div>
<div class="bme-config-row">
<label for="bme-setting-embed-model">Embedding 模型</label>
<input id="bme-setting-embed-model" class="bme-config-input" type="text" placeholder="text-embedding-3-small" />
</div>
<div class="bme-config-actions">
<button class="menu_button" id="bme-test-embedding">
<i class="fa-solid fa-plug"></i> 测试 Embedding
</button>
</div>
</div>
<div class="bme-config-card">
<div class="bme-section-header">系统提示词</div>
<div class="bme-config-help">
为空时使用内置默认提取系统提示词。这里只覆盖“记忆提取”提示词,召回/概要/反思仍走内置模板。
</div>
<div class="bme-config-row">
<label for="bme-setting-extract-prompt">记忆提取系统提示词</label>
<textarea id="bme-setting-extract-prompt" class="bme-config-textarea" placeholder="留空则使用默认提取系统提示词"></textarea>
</div>
</div>
<div class="bme-config-card">
<div class="bme-section-header">面板外观</div>
<div class="bme-config-row">
<label for="bme-setting-panel-theme">面板主题</label>
<select id="bme-setting-panel-theme" class="bme-config-input">
<option value="crimson">Crimson Synth</option>
<option value="cyan">Neon Cyan</option>
<option value="amber">Amber Console</option>
<option value="violet">Violet Haze</option>
</select>
</div>
</div>
</div>
</div>
</div>
@@ -201,6 +287,10 @@
<i class="fa-solid fa-gear"></i>
<span>操作</span>
</button>
<button class="bme-tab-btn" data-tab="config">
<i class="fa-solid fa-sliders"></i>
<span>配置</span>
</button>
</div>
</div>
</div>

114
panel.js
View File

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

View File

@@ -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;