212 lines
6.1 KiB
JavaScript
212 lines
6.1 KiB
JavaScript
const DEFAULT_COLLECTION = 'memind_memory';
|
|
const DEFAULT_LIMIT = 8;
|
|
const DEFAULT_TIMEOUT_MS = 3000;
|
|
|
|
function normalizeUrl(value) {
|
|
const raw = String(value ?? '').trim();
|
|
if (!raw) return null;
|
|
try {
|
|
const url = new URL(raw);
|
|
if (!['http:', 'https:'].includes(url.protocol)) {
|
|
throw new Error(`Unsupported Qdrant URL protocol: ${url.protocol}`);
|
|
}
|
|
return url.toString().replace(/\/$/, '');
|
|
} catch (err) {
|
|
throw new Error(`Invalid MEMORY_QDRANT_URL: ${err instanceof Error ? err.message : err}`);
|
|
}
|
|
}
|
|
|
|
function normalizeCollection(value) {
|
|
const collection = String(value ?? DEFAULT_COLLECTION).trim();
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(collection)) {
|
|
throw new Error(`Invalid Qdrant collection name: ${collection}`);
|
|
}
|
|
return collection;
|
|
}
|
|
|
|
function normalizeVector(value) {
|
|
if (!Array.isArray(value)) return null;
|
|
const vector = value.map((item) => Number(item));
|
|
if (!vector.length || vector.some((item) => !Number.isFinite(item))) return null;
|
|
return vector;
|
|
}
|
|
|
|
function normalizePayload(payload = {}) {
|
|
const text = String(
|
|
payload.content ?? payload.text ?? payload.memory_text ?? payload.memoryText ?? '',
|
|
).trim();
|
|
if (!text) return null;
|
|
return {
|
|
id: payload.id == null ? null : String(payload.id),
|
|
label: payload.type ?? payload.label ?? 'semantic',
|
|
text,
|
|
createdAt: payload.created_at ?? payload.createdAt ?? null,
|
|
};
|
|
}
|
|
|
|
function normalizePoint(point = {}) {
|
|
const payload = normalizePayload(point.payload ?? {});
|
|
if (!payload) return null;
|
|
return {
|
|
id: point.id == null ? payload.id : String(point.id),
|
|
label: payload.label,
|
|
text: payload.text,
|
|
score: point.score == null ? null : Number(point.score),
|
|
createdAt: payload.createdAt,
|
|
};
|
|
}
|
|
|
|
function timeoutSignal(timeoutMs) {
|
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return null;
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
return {
|
|
signal: controller.signal,
|
|
clear() {
|
|
clearTimeout(timeout);
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createQdrantHttpClient({
|
|
url,
|
|
apiKey = null,
|
|
fetchImpl = globalThis.fetch,
|
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
} = {}) {
|
|
const resolvedUrl = normalizeUrl(url);
|
|
if (!resolvedUrl) throw new Error('createQdrantHttpClient requires MEMORY_QDRANT_URL');
|
|
if (typeof fetchImpl !== 'function') {
|
|
throw new Error('createQdrantHttpClient requires fetch');
|
|
}
|
|
|
|
async function requestJson(path, body) {
|
|
const timeout = timeoutSignal(Number(timeoutMs));
|
|
try {
|
|
const response = await fetchImpl(`${resolvedUrl}${path}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
...(apiKey ? { 'api-key': String(apiKey) } : {}),
|
|
},
|
|
body: JSON.stringify(body),
|
|
...(timeout?.signal ? { signal: timeout.signal } : {}),
|
|
});
|
|
if (!response?.ok) {
|
|
throw new Error(`Qdrant request failed: ${response?.status ?? 'unknown'}`);
|
|
}
|
|
return response.json();
|
|
} finally {
|
|
timeout?.clear();
|
|
}
|
|
}
|
|
|
|
return {
|
|
async search({ collection, vector, userId, limit = DEFAULT_LIMIT } = {}) {
|
|
const resolvedCollection = normalizeCollection(collection);
|
|
const resolvedVector = normalizeVector(vector);
|
|
if (!resolvedVector) return [];
|
|
const body = {
|
|
vector: resolvedVector,
|
|
limit: Math.max(1, Math.min(50, Number(limit) || DEFAULT_LIMIT)),
|
|
with_payload: true,
|
|
filter: userId
|
|
? {
|
|
must: [
|
|
{
|
|
key: 'user_id',
|
|
match: { value: String(userId) },
|
|
},
|
|
],
|
|
}
|
|
: undefined,
|
|
};
|
|
const data = await requestJson(`/collections/${resolvedCollection}/points/search`, body);
|
|
return Array.isArray(data?.result) ? data.result : [];
|
|
},
|
|
};
|
|
}
|
|
|
|
export function createQdrantMemoryBackend({
|
|
enabled = false,
|
|
url = null,
|
|
collection = DEFAULT_COLLECTION,
|
|
client = null,
|
|
embedQuery = null,
|
|
defaultLimit = DEFAULT_LIMIT,
|
|
unavailableReason = null,
|
|
} = {}) {
|
|
const resolvedUrl = normalizeUrl(url);
|
|
const resolvedCollection = normalizeCollection(collection);
|
|
const configured = Boolean(enabled && resolvedUrl && resolvedCollection);
|
|
const hasClient = Boolean(client?.search);
|
|
const hasEmbedding = typeof embedQuery === 'function';
|
|
const wired = Boolean(configured && hasClient && hasEmbedding);
|
|
const reason = unavailableReason
|
|
?? (enabled
|
|
? (
|
|
!resolvedUrl
|
|
? 'url_not_configured'
|
|
: (!hasClient ? 'client_not_configured' : 'embedding_module_not_configured')
|
|
)
|
|
: 'not_configured');
|
|
|
|
async function resolveVector(input) {
|
|
const explicit = normalizeVector(input?.embedding);
|
|
if (explicit) return explicit;
|
|
if (typeof embedQuery !== 'function' || !input?.query) return null;
|
|
return normalizeVector(await embedQuery(input.query, input));
|
|
}
|
|
|
|
return {
|
|
name: 'qdrant',
|
|
category: 'semantic',
|
|
role: 'scale-out-vector-store',
|
|
flag: 'MEMORY_QDRANT_ENABLED',
|
|
unavailableReason: reason,
|
|
url: resolvedUrl,
|
|
collection: resolvedCollection,
|
|
|
|
isAvailable() {
|
|
return Boolean(wired);
|
|
},
|
|
|
|
getUnavailableReason() {
|
|
return this.isAvailable() ? null : reason;
|
|
},
|
|
|
|
async resolve(input = {}) {
|
|
if (!this.isAvailable()) {
|
|
return {
|
|
memories: [],
|
|
semanticMemories: [],
|
|
behaviorSummary: null,
|
|
activeGoals: [],
|
|
};
|
|
}
|
|
const vector = await resolveVector(input);
|
|
if (!vector) {
|
|
return {
|
|
memories: [],
|
|
semanticMemories: [],
|
|
behaviorSummary: null,
|
|
activeGoals: [],
|
|
};
|
|
}
|
|
const points = await client.search({
|
|
collection: resolvedCollection,
|
|
vector,
|
|
userId: input.userId,
|
|
limit: input.limit ?? defaultLimit,
|
|
});
|
|
const memories = points.map((point) => normalizePoint(point)).filter(Boolean);
|
|
return {
|
|
memories,
|
|
semanticMemories: memories.map((memory) => memory.text),
|
|
behaviorSummary: null,
|
|
activeGoals: [],
|
|
};
|
|
},
|
|
};
|
|
}
|