mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-13 18:31:16 +08:00
feat: add in-panel memory model and embedding configuration
This commit is contained in:
40
index.js
40
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();
|
||||
});
|
||||
|
||||
// 打开面板按钮
|
||||
|
||||
95
llm.js
95
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 代码块、混合文本等
|
||||
|
||||
90
panel.html
90
panel.html
@@ -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
114
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 ?? "");
|
||||
|
||||
65
style.css
65
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;
|
||||
|
||||
Reference in New Issue
Block a user