Files
ST-Bionic-Memory-Ecology/vector/vector-index.js
2026-04-08 01:39:45 +08:00

1057 lines
28 KiB
JavaScript

// ST-BME: 向量模式、后端索引与直连兜底
import { getRequestHeaders } from "../../../../../script.js";
import { embedBatch, embedText, searchSimilar } from "./embedding.js";
import { getActiveNodes } from "../graph/graph.js";
import { describeMemoryScope, normalizeMemoryScope } from "../graph/memory-scope.js";
import { resolveConfiguredTimeoutMs } from "../runtime/request-timeout.js";
import { buildVectorCollectionId, stableHashString } from "../runtime/runtime-state.js";
export const BACKEND_VECTOR_SOURCES = [
"openai",
"openrouter",
"cohere",
"mistral",
"electronhub",
"chutes",
"nanogpt",
"ollama",
"llamacpp",
"vllm",
];
const BACKEND_SOURCES_REQUIRING_API_URL = new Set([
"ollama",
"llamacpp",
"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 VECTOR_REQUEST_TIMEOUT_MS = 300000;
function getConfiguredTimeoutMs(config = {}) {
return typeof resolveConfiguredTimeoutMs === "function"
? resolveConfiguredTimeoutMs(config, VECTOR_REQUEST_TIMEOUT_MS)
: (() => {
const timeoutMs = Number(config?.timeoutMs);
return Number.isFinite(timeoutMs) && timeoutMs > 0
? timeoutMs
: VECTOR_REQUEST_TIMEOUT_MS;
})();
}
const BACKEND_STATUS_MODEL_SOURCES = {
openai: "openai",
cohere: "cohere",
mistral: "mistralai",
};
function isAbortError(error) {
return error?.name === "AbortError";
}
function throwIfAborted(signal) {
if (signal?.aborted) {
throw signal.reason instanceof Error
? signal.reason
: Object.assign(new Error("操作已终止"), { name: "AbortError" });
}
}
export const BACKEND_DEFAULT_MODELS = {
openai: "text-embedding-3-small",
openrouter: "openai/text-embedding-3-small",
cohere: "embed-multilingual-v3.0",
mistral: "mistral-embed",
electronhub: "text-embedding-3-small",
chutes: "chutes-qwen-qwen3-embedding-8b",
nanogpt: "text-embedding-3-small",
ollama: "nomic-embed-text",
llamacpp: "text-embedding-3-small",
vllm: "BAAI/bge-m3",
};
function createCombinedAbortSignal(...signals) {
const validSignals = signals.filter(Boolean);
if (validSignals.length <= 1) {
return validSignals[0] || undefined;
}
if (
typeof AbortSignal !== "undefined" &&
typeof AbortSignal.any === "function"
) {
return AbortSignal.any(validSignals);
}
const controller = new AbortController();
for (const signal of validSignals) {
if (signal.aborted) {
controller.abort(signal.reason);
return controller.signal;
}
signal.addEventListener("abort", () => controller.abort(signal.reason), {
once: true,
});
}
return controller.signal;
}
async function fetchWithTimeout(
url,
options = {},
timeoutMs = VECTOR_REQUEST_TIMEOUT_MS,
) {
const controller = new AbortController();
const timeout = setTimeout(
() =>
controller.abort(
new DOMException(
`向量请求超时 (${Math.round(timeoutMs / 1000)}s)`,
"AbortError",
),
),
timeoutMs,
);
const signal = options.signal
? createCombinedAbortSignal(options.signal, controller.signal)
: controller.signal;
try {
return await fetch(url, {
...options,
signal,
});
} finally {
clearTimeout(timeout);
}
}
export function normalizeOpenAICompatibleBaseUrl(value, autoSuffix = true) {
let normalized = String(value || "")
.trim()
.replace(/\/+(chat\/completions|embeddings)$/i, "")
.replace(/\/+$/, "");
if (autoSuffix && normalized && !/\/v\d+$/i.test(normalized)) {
normalized = normalized;
}
return normalized;
}
export function getVectorConfigFromSettings(settings = {}) {
const mode =
settings.embeddingTransportMode === "backend" ? "backend" : "direct";
const autoSuffix = settings.embeddingAutoSuffix !== false;
if (mode === "direct") {
return {
mode,
source: "direct",
apiUrl: normalizeOpenAICompatibleBaseUrl(
settings.embeddingApiUrl,
autoSuffix,
),
apiKey: String(settings.embeddingApiKey || "").trim(),
model: String(settings.embeddingModel || "").trim(),
autoSuffix,
timeoutMs: getConfiguredTimeoutMs(settings),
};
}
const source = BACKEND_VECTOR_SOURCES.includes(
settings.embeddingBackendSource,
)
? settings.embeddingBackendSource
: "openai";
return {
mode,
source,
apiUrl: normalizeOpenAICompatibleBaseUrl(
settings.embeddingBackendApiUrl,
autoSuffix,
),
apiKey: "",
model: String(
settings.embeddingBackendModel || BACKEND_DEFAULT_MODELS[source] || "",
).trim(),
autoSuffix,
timeoutMs: getConfiguredTimeoutMs(settings),
};
}
export function getSuggestedBackendModel(source) {
return BACKEND_DEFAULT_MODELS[source] || "text-embedding-3-small";
}
export function isBackendVectorConfig(config) {
return config?.mode === "backend";
}
export function isDirectVectorConfig(config) {
return config?.mode === "direct";
}
export function getVectorModelScope(config) {
if (!config) return "";
if (isDirectVectorConfig(config)) {
return [
"direct",
normalizeOpenAICompatibleBaseUrl(config.apiUrl, config.autoSuffix),
config.model || "",
].join("|");
}
return [
"backend",
config.source || "",
normalizeOpenAICompatibleBaseUrl(config.apiUrl, config.autoSuffix),
config.model || "",
].join("|");
}
export function validateVectorConfig(config) {
if (!config) {
return { valid: false, error: "未找到向量配置" };
}
if (isDirectVectorConfig(config)) {
if (!config.apiUrl) {
return { valid: false, error: "请填写直连 Embedding API 地址" };
}
if (!config.model) {
return { valid: false, error: "请填写直连 Embedding 模型" };
}
return { valid: true, error: "" };
}
if (!config.model) {
return { valid: false, error: "请填写后端向量模型" };
}
if (BACKEND_SOURCES_REQUIRING_API_URL.has(config.source) && !config.apiUrl) {
return { valid: false, error: "当前后端向量源需要填写 API 地址" };
}
return { valid: true, error: "" };
}
export function buildNodeVectorText(node) {
const fields = node?.fields || {};
const preferredKeys = [
"summary",
"insight",
"title",
"name",
"state",
"traits",
"constraint",
"goal",
"participants",
"suggestion",
"status",
"scope",
];
const parts = [];
for (const key of preferredKeys) {
const value = fields[key];
if (value == null || value === "") continue;
if (Array.isArray(value)) {
if (value.length > 0) parts.push(value.join(", "));
} else if (typeof value === "object") {
parts.push(JSON.stringify(value));
} else {
parts.push(String(value));
}
}
for (const [key, value] of Object.entries(fields)) {
if (preferredKeys.includes(key) || value == null || value === "") continue;
if (key === "embedding") continue;
if (Array.isArray(value)) {
if (value.length > 0) parts.push(`${key}: ${value.join(", ")}`);
continue;
}
if (typeof value === "object") {
parts.push(`${key}: ${JSON.stringify(value)}`);
continue;
}
parts.push(`${key}: ${value}`);
}
const scope = normalizeMemoryScope(node?.scope);
const scopeText = describeMemoryScope(scope);
if (scopeText) {
parts.push(`memory_scope: ${scopeText}`);
}
if (scope.regionPath.length > 0) {
parts.push(`memory_region_path: ${scope.regionPath.join(" / ")}`);
}
if (scope.regionSecondary.length > 0) {
parts.push(`memory_region_secondary: ${scope.regionSecondary.join(", ")}`);
}
return parts.join(" | ").trim();
}
export function buildNodeVectorHash(node, config) {
const text = buildNodeVectorText(node);
const seqEnd = node?.seqRange?.[1] ?? node?.seq ?? 0;
const payload = [
node?.id || "",
text,
String(seqEnd),
getVectorModelScope(config),
].join("::");
return stableHashString(payload);
}
function buildBackendSourceRequest(config) {
const body = {
source: config.source,
model: config.model,
};
if (BACKEND_SOURCES_REQUIRING_API_URL.has(config.source)) {
body.apiUrl = config.apiUrl;
}
if (config.source === "ollama") {
body.keep = false;
}
return body;
}
function getEligibleVectorNodes(graph, range = null) {
let nodes = getActiveNodes(graph).filter((node) => !node.archived);
if (range && Number.isFinite(range.start) && Number.isFinite(range.end)) {
const start = Math.min(range.start, range.end);
const end = Math.max(range.start, range.end);
nodes = nodes.filter((node) => {
const seqStart = node?.seqRange?.[0] ?? node?.seq ?? -1;
const seqEnd = node?.seqRange?.[1] ?? node?.seq ?? -1;
return seqEnd >= start && seqStart <= end;
});
}
return nodes.filter((node) => buildNodeVectorText(node).length > 0);
}
function buildDesiredVectorEntries(graph, config, range = null) {
return getEligibleVectorNodes(graph, range).map((node) => {
const hash = buildNodeVectorHash(node, config);
return {
nodeId: node.id,
hash,
text: buildNodeVectorText(node),
index: node?.seqRange?.[1] ?? node?.seq ?? 0,
};
});
}
function computeVectorStats(graph, desiredEntries) {
const state = graph.vectorIndexState || {};
const desiredByNodeId = new Map(
desiredEntries.map((entry) => [entry.nodeId, entry]),
);
const nodeToHash = state.nodeToHash || {};
const hashToNodeId = state.hashToNodeId || {};
let indexed = 0;
let pending = 0;
for (const entry of desiredEntries) {
if (nodeToHash[entry.nodeId] === entry.hash) {
indexed++;
} else {
pending++;
}
}
let stale = 0;
for (const [nodeId, hash] of Object.entries(nodeToHash)) {
const desired = desiredByNodeId.get(nodeId);
if (!desired || desired.hash !== hash || hashToNodeId[hash] !== nodeId) {
stale++;
}
}
return {
total: desiredEntries.length,
indexed,
stale,
pending,
};
}
async function purgeVectorCollection(collectionId, signal) {
throwIfAborted(signal);
const response = await fetchWithTimeout(
"/api/vector/purge",
{
method: "POST",
headers: getRequestHeaders(),
signal,
body: JSON.stringify({ collectionId }),
},
getConfiguredTimeoutMs(),
);
if (!response.ok) {
const message = await response.text().catch(() => response.statusText);
throw new Error(message || `HTTP ${response.status}`);
}
}
async function deleteVectorHashes(collectionId, config, hashes, signal) {
if (!Array.isArray(hashes) || hashes.length === 0) return;
throwIfAborted(signal);
const response = await fetchWithTimeout(
"/api/vector/delete",
{
method: "POST",
headers: getRequestHeaders(),
signal,
body: JSON.stringify({
collectionId,
hashes,
...buildBackendSourceRequest(config),
}),
},
getConfiguredTimeoutMs(config),
);
if (!response.ok) {
const message = await response.text().catch(() => response.statusText);
throw new Error(message || `HTTP ${response.status}`);
}
}
export async function deleteBackendVectorHashesForRecovery(
collectionId,
config,
hashes,
signal = undefined,
) {
if (!collectionId || !isBackendVectorConfig(config)) return;
await deleteVectorHashes(collectionId, config, hashes, signal);
}
async function insertVectorEntries(collectionId, config, entries, signal) {
if (!Array.isArray(entries) || entries.length === 0) return;
throwIfAborted(signal);
const response = await fetchWithTimeout(
"/api/vector/insert",
{
method: "POST",
headers: getRequestHeaders(),
signal,
body: JSON.stringify({
collectionId,
items: entries.map((entry) => ({
hash: entry.hash,
text: entry.text,
index: entry.index,
})),
...buildBackendSourceRequest(config),
}),
},
getConfiguredTimeoutMs(config),
);
if (!response.ok) {
const message = await response.text().catch(() => response.statusText);
throw new Error(message || `HTTP ${response.status}`);
}
}
function resetVectorMappings(graph, config, chatId) {
graph.vectorIndexState.mode = config.mode;
graph.vectorIndexState.source = config.source || "";
graph.vectorIndexState.modelScope = getVectorModelScope(config);
graph.vectorIndexState.collectionId = buildVectorCollectionId(chatId);
graph.vectorIndexState.hashToNodeId = {};
graph.vectorIndexState.nodeToHash = {};
}
export async function syncGraphVectorIndex(
graph,
config,
{
chatId = "",
purge = false,
force = false,
range = null,
signal = undefined,
} = {},
) {
if (!graph || !config) {
return {
insertedHashes: [],
stats: { total: 0, indexed: 0, stale: 0, pending: 0 },
};
}
throwIfAborted(signal);
const validation = validateVectorConfig(config);
if (!validation.valid) {
graph.vectorIndexState.lastWarning = validation.error;
graph.vectorIndexState.dirty = true;
return { insertedHashes: [], stats: graph.vectorIndexState.lastStats };
}
const state = graph.vectorIndexState;
const collectionId = buildVectorCollectionId(
chatId || graph?.historyState?.chatId,
);
const desiredEntries = buildDesiredVectorEntries(graph, config, range);
const desiredByNodeId = new Map(
desiredEntries.map((entry) => [entry.nodeId, entry]),
);
const insertedHashes = [];
const hasConcreteRange =
range && Number.isFinite(range.start) && Number.isFinite(range.end);
const rangedNodeIds = new Set(desiredEntries.map((entry) => entry.nodeId));
if (isBackendVectorConfig(config)) {
const scopeChanged =
state.mode !== "backend" ||
state.source !== config.source ||
state.modelScope !== getVectorModelScope(config) ||
state.collectionId !== collectionId;
const fullReset =
purge || state.dirty || scopeChanged || (force && !hasConcreteRange);
if (fullReset) {
await purgeVectorCollection(collectionId, signal);
resetVectorMappings(graph, config, chatId);
await insertVectorEntries(collectionId, config, desiredEntries, signal);
for (const entry of desiredEntries) {
state.hashToNodeId[entry.hash] = entry.nodeId;
state.nodeToHash[entry.nodeId] = entry.hash;
insertedHashes.push(entry.hash);
}
} else {
const hashesToDelete = [];
const entriesToInsert = [];
if (force && hasConcreteRange) {
for (const entry of desiredEntries) {
const currentHash = state.nodeToHash[entry.nodeId];
if (currentHash) {
hashesToDelete.push(currentHash);
delete state.hashToNodeId[currentHash];
delete state.nodeToHash[entry.nodeId];
}
entriesToInsert.push(entry);
}
}
for (const [nodeId, hash] of Object.entries(state.nodeToHash)) {
if (hasConcreteRange && !rangedNodeIds.has(nodeId)) {
continue;
}
const desired = desiredByNodeId.get(nodeId);
if (!desired || desired.hash !== hash) {
hashesToDelete.push(hash);
delete state.nodeToHash[nodeId];
delete state.hashToNodeId[hash];
}
}
for (const entry of desiredEntries) {
if (force && hasConcreteRange) continue;
if (state.nodeToHash[entry.nodeId] === entry.hash) continue;
entriesToInsert.push(entry);
}
await deleteVectorHashes(collectionId, config, hashesToDelete, signal);
await insertVectorEntries(collectionId, config, entriesToInsert, signal);
for (const entry of entriesToInsert) {
state.hashToNodeId[entry.hash] = entry.nodeId;
state.nodeToHash[entry.nodeId] = entry.hash;
insertedHashes.push(entry.hash);
}
}
for (const node of graph.nodes || []) {
if (Array.isArray(node.embedding) && node.embedding.length > 0) {
node.embedding = null;
}
}
} else {
const entriesToEmbed = [];
const hashByNodeId = {};
for (const entry of desiredEntries) {
hashByNodeId[entry.nodeId] = entry.hash;
const currentHash = state.nodeToHash?.[entry.nodeId];
const node = graph.nodes.find(
(candidate) => candidate.id === entry.nodeId,
);
const hasEmbedding =
Array.isArray(node?.embedding) && node.embedding.length > 0;
if (!force && !currentHash && hasEmbedding) {
state.hashToNodeId[entry.hash] = entry.nodeId;
state.nodeToHash[entry.nodeId] = entry.hash;
continue;
}
if (force || purge || currentHash !== entry.hash || !hasEmbedding) {
entriesToEmbed.push(entry);
}
}
if (purge || state.mode !== "direct") {
resetVectorMappings(graph, config, chatId);
} else {
for (const [nodeId, hash] of Object.entries(state.nodeToHash || {})) {
if (hasConcreteRange && !rangedNodeIds.has(nodeId)) {
continue;
}
if (!hashByNodeId[nodeId]) {
delete state.nodeToHash[nodeId];
delete state.hashToNodeId[hash];
}
}
}
let directSyncHadFailures = false;
if (entriesToEmbed.length > 0) {
throwIfAborted(signal);
const embeddings = await embedBatch(
entriesToEmbed.map((entry) => entry.text),
config,
{ signal },
);
for (let index = 0; index < entriesToEmbed.length; index++) {
const entry = entriesToEmbed[index];
const node = graph.nodes.find(
(candidate) => candidate.id === entry.nodeId,
);
if (!node) continue;
if (embeddings[index]) {
node.embedding = Array.from(embeddings[index]);
state.hashToNodeId[entry.hash] = entry.nodeId;
state.nodeToHash[entry.nodeId] = entry.hash;
insertedHashes.push(entry.hash);
} else {
directSyncHadFailures = true;
}
}
}
state.mode = "direct";
state.source = "direct";
state.modelScope = getVectorModelScope(config);
state.collectionId = collectionId;
state.dirty = directSyncHadFailures;
state.lastWarning = directSyncHadFailures
? "部分节点 embedding 生成失败,向量索引仍待修复"
: "";
}
if (state.mode !== "direct") {
state.dirty = false;
state.lastWarning = "";
}
state.lastSyncAt = Date.now();
state.lastStats = computeVectorStats(
graph,
buildDesiredVectorEntries(graph, config),
);
return {
insertedHashes,
stats: state.lastStats,
};
}
export async function findSimilarNodesByText(
graph,
text,
config,
topK = 10,
candidates = null,
signal = undefined,
) {
if (!text || !graph || !config) return [];
throwIfAborted(signal);
const candidateNodes = Array.isArray(candidates)
? candidates
: getEligibleVectorNodes(graph);
if (candidateNodes.length === 0) return [];
if (isDirectVectorConfig(config)) {
const queryVec = await embedText(text, config, { signal });
if (!queryVec) return [];
return searchSimilar(
queryVec,
candidateNodes
.filter(
(node) => Array.isArray(node.embedding) && node.embedding.length > 0,
)
.map((node) => ({
nodeId: node.id,
embedding: node.embedding,
})),
topK,
);
}
const validation = validateVectorConfig(config);
if (!validation.valid) return [];
const response = await fetchWithTimeout(
"/api/vector/query",
{
method: "POST",
headers: getRequestHeaders(),
signal,
body: JSON.stringify({
collectionId: graph.vectorIndexState.collectionId,
searchText: text,
topK,
threshold: 0,
...buildBackendSourceRequest(config),
}),
},
getConfiguredTimeoutMs(config),
);
if (!response.ok) {
const errorText = await response.text().catch(() => response.statusText);
console.warn("[ST-BME] 后端向量查询失败:", errorText);
return [];
}
const data = await response.json().catch(() => ({ hashes: [] }));
const hashes = Array.isArray(data?.hashes) ? data.hashes : [];
const nodeIdByHash = graph.vectorIndexState?.hashToNodeId || {};
const allowedIds = new Set(candidateNodes.map((node) => node.id));
return hashes
.map((hash, index) => ({
nodeId: nodeIdByHash[hash],
score: Math.max(0.01, 1 - index / Math.max(1, hashes.length)),
}))
.filter((entry) => entry.nodeId && allowedIds.has(entry.nodeId))
.slice(0, topK);
}
export async function testVectorConnection(config, chatId = "connection-test") {
const validation = validateVectorConfig(config);
if (!validation.valid) {
return { success: false, dimensions: 0, error: validation.error };
}
if (isDirectVectorConfig(config)) {
try {
const vec = await embedText("test connection", config);
if (vec) {
return { success: true, dimensions: vec.length, error: "" };
}
return { success: false, dimensions: 0, error: "API 返回空结果" };
} catch (error) {
return { success: false, dimensions: 0, error: String(error) };
}
}
try {
const response = await fetchWithTimeout(
"/api/vector/query",
{
method: "POST",
headers: getRequestHeaders(),
body: JSON.stringify({
collectionId: buildVectorCollectionId(chatId),
searchText: "test connection",
topK: 1,
threshold: 0,
...buildBackendSourceRequest(config),
}),
},
getConfiguredTimeoutMs(config),
);
const payload = await response.text().catch(() => "");
if (!response.ok) {
return {
success: false,
dimensions: 0,
error: payload || response.statusText,
};
}
return { success: true, dimensions: 0, error: "" };
} catch (error) {
return { success: false, dimensions: 0, error: String(error) };
}
}
export function getVectorIndexStats(graph) {
const state = graph?.vectorIndexState;
if (!state) {
return { total: 0, indexed: 0, stale: 0, pending: 0 };
}
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 fetchWithTimeout(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 fetchWithTimeout(
"/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 fetchWithTimeout(`${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 fetchWithTimeout(`${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) };
}
}