feat: 支持拉取记忆与嵌入模型列表

This commit is contained in:
Youzini-afk
2026-03-24 21:40:59 +08:00
parent 716e5abaf4
commit 1f03b0df4a
6 changed files with 496 additions and 3 deletions

View File

@@ -30,7 +30,7 @@ import {
getNode,
} from "./graph.js";
import { estimateTokens, formatInjection } from "./injector.js";
import { testLLMConnection } from "./llm.js";
import { fetchMemoryLLMModels, testLLMConnection } from "./llm.js";
import { retrieve } from "./retriever.js";
import {
appendBatchJournal,
@@ -51,6 +51,7 @@ import {
getVectorIndexStats,
isBackendVectorConfig,
isDirectVectorConfig,
fetchAvailableEmbeddingModels,
syncGraphVectorIndex,
testVectorConnection,
validateVectorConfig,
@@ -251,8 +252,11 @@ function getSchema() {
return schema;
}
function getEmbeddingConfig() {
return getVectorConfigFromSettings(getSettings());
function getEmbeddingConfig(mode = null) {
const settings = getSettings();
return getVectorConfigFromSettings(
mode ? { ...settings, embeddingTransportMode: mode } : settings,
);
}
function getCurrentChatId(context = getContext()) {
@@ -1454,6 +1458,41 @@ async function onTestMemoryLLM() {
}
}
async function onFetchMemoryLLMModels() {
toastr.info("正在拉取记忆 LLM 模型列表...");
const result = await fetchMemoryLLMModels();
if (result.success) {
toastr.success(`已拉取 ${result.models.length} 个记忆 LLM 模型`);
} else {
toastr.error(`拉取失败: ${result.error}`);
}
return result;
}
async function onFetchEmbeddingModels(mode = null) {
const config = getEmbeddingConfig(mode);
const targetMode = mode || config?.mode || "direct";
const validation = validateVectorConfig(config);
if (!validation.valid) {
toastr.warning(validation.error);
return { success: false, models: [], error: validation.error };
}
toastr.info("正在拉取 Embedding 模型列表...");
const result = await fetchAvailableEmbeddingModels(config);
if (result.success) {
const modeLabel = targetMode === "backend" ? "后端" : "直连";
toastr.success(`已拉取 ${result.models.length}${modeLabel} Embedding 模型`);
} else {
toastr.error(`拉取失败: ${result.error}`);
}
return result;
}
async function onManualExtract() {
if (isExtracting) return;
if (!(await recoverHistoryIfNeeded("manual-extract"))) return;
@@ -1650,6 +1689,8 @@ async function onReembedDirect() {
evolve: onManualEvolve,
testEmbedding: onTestEmbedding,
testMemoryLLM: onTestMemoryLLM,
fetchMemoryLLMModels: onFetchMemoryLLMModels,
fetchEmbeddingModels: onFetchEmbeddingModels,
rebuildVectorIndex: () => onRebuildVectorIndex(),
rebuildVectorRange: (range) => onRebuildVectorIndex(range),
reembedDirect: onReembedDirect,

68
llm.js
View File

@@ -27,6 +27,32 @@ function hasDedicatedLLMConfig(config = getMemoryLLMConfig()) {
return Boolean(config.apiUrl && config.model);
}
function normalizeModelList(items = []) {
if (!Array.isArray(items)) return [];
const seen = new Set();
const models = [];
for (const item of items) {
let id = "";
let label = "";
if (typeof item === "string") {
id = item.trim();
label = id;
} else if (item && typeof item === "object") {
id = String(item.id || item.name || item.value || item.slug || "").trim();
label = String(item.name || item.id || item.value || item.slug || "").trim();
}
if (!id || seen.has(id)) continue;
seen.add(id);
models.push({ id, label: label || id });
}
return models;
}
// 自动检测:如果 API 不支持 response_format记住并跳过
let _jsonModeSupported = true;
@@ -206,6 +232,48 @@ export async function testLLMConnection() {
}
}
export async function fetchMemoryLLMModels() {
const config = getMemoryLLMConfig();
if (!config.apiUrl) {
return {
success: false,
models: [],
error: "请先填写记忆 LLM API 地址",
};
}
try {
const response = await fetch("/api/backends/chat-completions/status", {
method: "POST",
headers: getRequestHeaders(),
body: JSON.stringify({
chat_completion_source: chat_completion_sources.OPENAI,
reverse_proxy: config.apiUrl,
proxy_password: config.apiKey || "",
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
const message = payload?.error || payload?.message || response.statusText;
return { success: false, models: [], error: message || `HTTP ${response.status}` };
}
const models = normalizeModelList(payload?.data);
if (models.length === 0) {
return {
success: false,
models: [],
error: "未拉取到可用模型,请检查接口是否支持 /models",
};
}
return { success: true, models, error: "" };
} catch (error) {
return { success: false, models: [], error: String(error) };
}
}
/**
* 从 LLM 响应文本中提取 JSON 对象
* 处理各种常见格式:纯 JSON、markdown 代码块、混合文本等

View File

@@ -367,6 +367,15 @@
placeholder="gpt-4.1-mini / qwen-max / deepseek-chat"
/>
</div>
<div class="bme-model-fetch-block">
<button class="bme-config-secondary-btn" id="bme-fetch-llm-models" type="button">
<i class="fa-solid fa-rotate"></i>
<span>拉取模型</span>
</button>
<select id="bme-select-llm-model" class="bme-config-input bme-model-select" style="display:none">
<option value="">从拉取结果中选择模型</option>
</select>
</div>
<div class="bme-config-actions">
<button class="bme-config-test-btn" id="bme-test-llm" type="button">
<i class="fa-solid fa-plug"></i>
@@ -417,6 +426,15 @@
placeholder="text-embedding-3-small / nomic-embed-text / BAAI/bge-m3"
/>
</div>
<div class="bme-model-fetch-block">
<button class="bme-config-secondary-btn" id="bme-fetch-embed-backend-models" type="button">
<i class="fa-solid fa-rotate"></i>
<span>拉取模型</span>
</button>
<select id="bme-select-embed-backend-model" class="bme-config-input bme-model-select" style="display:none">
<option value="">从拉取结果中选择模型</option>
</select>
</div>
<div class="bme-config-row">
<label for="bme-setting-embed-backend-url">后端 API 地址</label>
<input
@@ -466,6 +484,15 @@
placeholder="text-embedding-3-small"
/>
</div>
<div class="bme-model-fetch-block">
<button class="bme-config-secondary-btn" id="bme-fetch-embed-direct-models" type="button">
<i class="fa-solid fa-rotate"></i>
<span>拉取模型</span>
</button>
<select id="bme-select-embed-direct-model" class="bme-config-input bme-model-select" style="display:none">
<option value="">从拉取结果中选择模型</option>
</select>
</div>
</div>
<div class="bme-config-actions">

108
panel.js
View File

@@ -110,6 +110,9 @@ let graphRenderer = null;
let mobileGraphRenderer = null;
let currentTabId = "dashboard";
let currentConfigSectionId = "api";
let fetchedMemoryLLMModels = [];
let fetchedBackendEmbeddingModels = [];
let fetchedDirectEmbeddingModels = [];
// 由 index.js 注入的引用
@@ -795,6 +798,7 @@ function _refreshConfigTab() {
_setInputValue("bme-setting-synopsis-prompt", settings.synopsisPrompt || DEFAULT_PROMPTS.synopsis);
_setInputValue("bme-setting-reflection-prompt", settings.reflectionPrompt || DEFAULT_PROMPTS.reflection);
_refreshFetchedModelSelects(settings);
_refreshGuardedConfigStates(settings);
_refreshStageCardStates(settings);
_refreshPromptCardStates(settings);
@@ -1063,6 +1067,48 @@ function _bindConfigControls() {
document.getElementById("bme-test-embedding")?.addEventListener("click", async () => {
await _actionHandlers.testEmbedding?.();
});
document.getElementById("bme-fetch-llm-models")?.addEventListener("click", async () => {
const result = await _actionHandlers.fetchMemoryLLMModels?.();
if (!result?.success) return;
fetchedMemoryLLMModels = result.models || [];
_renderFetchedModelOptions(
"bme-select-llm-model",
fetchedMemoryLLMModels,
(_getSettings?.() || {}).llmModel || "",
);
});
document.getElementById("bme-fetch-embed-backend-models")?.addEventListener("click", async () => {
const result = await _actionHandlers.fetchEmbeddingModels?.("backend");
if (!result?.success) return;
fetchedBackendEmbeddingModels = result.models || [];
_renderFetchedModelOptions(
"bme-select-embed-backend-model",
fetchedBackendEmbeddingModels,
(_getSettings?.() || {}).embeddingBackendModel || "",
);
});
document.getElementById("bme-fetch-embed-direct-models")?.addEventListener("click", async () => {
const result = await _actionHandlers.fetchEmbeddingModels?.("direct");
if (!result?.success) return;
fetchedDirectEmbeddingModels = result.models || [];
_renderFetchedModelOptions(
"bme-select-embed-direct-model",
fetchedDirectEmbeddingModels,
(_getSettings?.() || {}).embeddingModel || "",
);
});
bindSelectModel("bme-select-llm-model", "bme-setting-llm-model", "llmModel");
bindSelectModel(
"bme-select-embed-backend-model",
"bme-setting-embed-backend-model",
"embeddingBackendModel",
);
bindSelectModel(
"bme-select-embed-direct-model",
"bme-setting-embed-model",
"embeddingModel",
);
panelEl.dataset.bmeConfigBound = "true";
}
@@ -1122,6 +1168,17 @@ function bindPromptText(id, settingKey, promptKey) {
element.dataset.bmeBound = "true";
}
function bindSelectModel(selectId, inputId, settingKey) {
const element = document.getElementById(selectId);
if (!element || element.dataset.bmeBound === "true") return;
element.addEventListener("change", () => {
if (!element.value) return;
_setInputValue(inputId, element.value);
_patchSettings({ [settingKey]: element.value });
});
element.dataset.bmeBound = "true";
}
// ==================== 工具函数 ====================
function _setText(id, text) {
@@ -1186,6 +1243,57 @@ function _refreshStageCardStates(settings = _getSettings?.() || {}) {
});
}
function _refreshFetchedModelSelects(settings = _getSettings?.() || {}) {
_renderFetchedModelOptions(
"bme-select-llm-model",
fetchedMemoryLLMModels,
settings.llmModel || "",
);
_renderFetchedModelOptions(
"bme-select-embed-backend-model",
fetchedBackendEmbeddingModels,
settings.embeddingBackendModel || "",
);
_renderFetchedModelOptions(
"bme-select-embed-direct-model",
fetchedDirectEmbeddingModels,
settings.embeddingModel || "",
);
}
function _renderFetchedModelOptions(selectId, models, currentValue = "") {
const select = document.getElementById(selectId);
if (!select) return;
const normalized = Array.isArray(models) ? models : [];
select.innerHTML = "";
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = normalized.length
? "从拉取结果中选择模型"
: "暂无已拉取模型";
select.appendChild(placeholder);
normalized.forEach((model) => {
const option = document.createElement("option");
option.value = String(model?.id || "");
option.textContent = String(model?.label || model?.id || "");
select.appendChild(option);
});
if (
currentValue &&
normalized.some((model) => String(model?.id || "") === String(currentValue))
) {
select.value = String(currentValue);
} else {
select.value = "";
}
select.style.display = normalized.length > 0 ? "" : "none";
}
function _refreshPromptCardStates(settings = _getSettings?.() || {}) {
if (!panelEl) return;
panelEl.querySelectorAll(".bme-prompt-card").forEach((card) => {

View File

@@ -930,6 +930,46 @@
margin-top: 8px;
}
.bme-model-fetch-block {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: -2px;
margin-bottom: 12px;
}
.bme-config-secondary-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: fit-content;
min-height: 38px;
padding: 0 14px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: var(--bme-on-surface);
cursor: pointer;
font-size: 12px;
font-weight: 700;
transition: border-color 0.15s, background 0.15s, color 0.15s;
}
.bme-config-secondary-btn:hover {
border-color: var(--bme-primary);
background: rgba(255, 255, 255, 0.06);
color: var(--bme-primary);
}
.bme-config-secondary-btn i {
font-size: 12px;
}
.bme-model-select {
max-width: 100%;
}
.bme-config-test-btn {
display: inline-flex !important;
flex-direction: row !important;
@@ -1408,6 +1448,10 @@
width: 100%;
}
.bme-config-secondary-btn {
width: 100%;
}
.bme-config-test-btn {
width: 100%;
}

View File

@@ -27,6 +27,19 @@ const BACKEND_SOURCES_REQUIRING_API_URL = new Set([
"vllm",
]);
const MODEL_LIST_ENDPOINTS = {
openrouter: "/api/openrouter/models/embedding",
chutes: "/api/openai/chutes/models/embedding",
nanogpt: "/api/openai/nanogpt/models/embedding",
electronhub: "/api/openai/electronhub/models",
};
const BACKEND_STATUS_MODEL_SOURCES = {
openai: "openai",
cohere: "cohere",
mistral: "mistralai",
};
export const BACKEND_DEFAULT_MODELS = {
openai: "text-embedding-3-small",
openrouter: "openai/text-embedding-3-small",
@@ -639,3 +652,195 @@ export function getVectorIndexStats(graph) {
}
return state.lastStats || { total: 0, indexed: 0, stale: 0, pending: 0 };
}
function normalizeModelOptions(items = [], { embeddingOnly = false } = {}) {
if (!Array.isArray(items)) return [];
const candidates = [];
for (const item of items) {
if (typeof item === "string") {
const id = item.trim();
if (id) candidates.push({ id, label: id, raw: item });
continue;
}
if (!item || typeof item !== "object") continue;
const id = String(item.id || item.name || item.label || item.slug || item.value || "").trim();
const label = String(item.label || item.name || item.id || item.slug || item.value || "").trim();
if (!id) continue;
if (
embeddingOnly &&
Array.isArray(item.endpoints) &&
!item.endpoints.includes("/v1/embeddings")
) {
continue;
}
candidates.push({ id, label: label || id, raw: item });
}
const embeddingRegex =
/(embed|embedding|bge|e5|gte|nomic|voyage|mxbai|jina|minilm)/i;
const embeddingTagged = candidates.filter((item) => embeddingRegex.test(item.id) || embeddingRegex.test(item.label));
const source = embeddingTagged.length > 0 ? embeddingTagged : candidates;
const seen = new Set();
return source
.filter((item) => {
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
})
.map(({ id, label }) => ({ id, label }));
}
async function fetchJsonEndpoint(url, { method = "POST" } = {}) {
const response = await fetch(url, {
method,
headers: getRequestHeaders({ omitContentType: true }),
});
const payload = await response.json().catch(() => []);
if (!response.ok) {
throw new Error(
(typeof payload === "object" && payload?.error) ||
response.statusText ||
`HTTP ${response.status}`,
);
}
return payload;
}
async function fetchBackendStatusModelList(source) {
const chatCompletionSource = BACKEND_STATUS_MODEL_SOURCES[source];
if (!chatCompletionSource) {
throw new Error("当前后端向量源暂不支持自动拉取模型,请手动填写");
}
const response = await fetch("/api/backends/chat-completions/status", {
method: "POST",
headers: getRequestHeaders(),
body: JSON.stringify({
chat_completion_source: chatCompletionSource,
}),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.error) {
throw new Error(
payload?.message || payload?.error || response.statusText || `HTTP ${response.status}`,
);
}
return normalizeModelOptions(payload?.data || payload, { embeddingOnly: false });
}
async function fetchOpenAICompatibleModelList(apiUrl, apiKey = "") {
const normalizedUrl = normalizeOpenAICompatibleBaseUrl(apiUrl);
if (!normalizedUrl) {
throw new Error("请先填写 API 地址");
}
const response = await fetch(`${normalizedUrl}/models`, {
method: "GET",
headers: {
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.error?.message || payload?.message || response.statusText);
}
return normalizeModelOptions(payload?.data || payload, { embeddingOnly: false });
}
async function fetchOllamaModelList(apiUrl) {
const normalizedUrl = normalizeOpenAICompatibleBaseUrl(apiUrl).replace(/\/v1$/i, "");
if (!normalizedUrl) {
throw new Error("请先填写 Ollama API 地址");
}
const response = await fetch(`${normalizedUrl}/api/tags`, { method: "GET" });
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload?.error || payload?.message || response.statusText);
}
return normalizeModelOptions(
Array.isArray(payload?.models)
? payload.models.map((item) => ({
id: item?.model || item?.name,
name: item?.model || item?.name,
}))
: [],
{ embeddingOnly: false },
);
}
export async function fetchAvailableEmbeddingModels(config) {
const validation = validateVectorConfig(config);
if (!validation.valid) {
return { success: false, models: [], error: validation.error };
}
try {
if (isDirectVectorConfig(config)) {
const models = normalizeModelOptions(
await fetchOpenAICompatibleModelList(config.apiUrl, config.apiKey),
);
if (models.length === 0) {
return { success: false, models: [], error: "未拉取到可用 Embedding 模型" };
}
return { success: true, models, error: "" };
}
if (config.source === "ollama") {
const models = await fetchOllamaModelList(config.apiUrl);
if (models.length === 0) {
return { success: false, models: [], error: "未拉取到可用 Ollama 模型" };
}
return { success: true, models, error: "" };
}
if (MODEL_LIST_ENDPOINTS[config.source]) {
const payload = await fetchJsonEndpoint(MODEL_LIST_ENDPOINTS[config.source]);
const models = normalizeModelOptions(payload, {
embeddingOnly: config.source === "electronhub",
});
if (models.length === 0) {
return { success: false, models: [], error: "未拉取到可用 Embedding 模型" };
}
return { success: true, models, error: "" };
}
if (BACKEND_STATUS_MODEL_SOURCES[config.source]) {
const models = await fetchBackendStatusModelList(config.source);
if (models.length === 0) {
return { success: false, models: [], error: "未拉取到可用 Embedding 模型" };
}
return { success: true, models, error: "" };
}
if (config.apiUrl) {
const models = normalizeModelOptions(
await fetchOpenAICompatibleModelList(config.apiUrl),
);
if (models.length === 0) {
return { success: false, models: [], error: "未拉取到可用 Embedding 模型" };
}
return { success: true, models, error: "" };
}
return {
success: false,
models: [],
error: "当前后端向量源暂不支持自动拉取模型,请手动填写",
};
} catch (error) {
return { success: false, models: [], error: String(error) };
}
}