Replace @vercel/postgres, Blob, and Upstash with lib/sql.js, MinIO object storage, and CT 102 Redis rate limits. Add Dockerfile for Dokploy deploy, homelab runbooks, Neon data-copy helper, and point CI smoke/visual at the homelab URL instead of Vercel previews. Co-authored-by: Cursor <cursoragent@cursor.com>
114 lines
3.2 KiB
JavaScript
114 lines
3.2 KiB
JavaScript
const GATEWAY_EMBED_URL = 'https://ai-gateway.vercel.sh/v1/embeddings';
|
|
const DEFAULT_EMBED_MODEL = process.env.SCAN_EMBED_MODEL || 'cohere/embed-v4.0';
|
|
|
|
/** Output dimension — must match migrations/1782000000001_add-card-embeddings.js. */
|
|
export const EMBED_DIMENSION = Number(process.env.SCAN_EMBED_DIMENSION || 1024);
|
|
|
|
export class EmbedApiError extends Error {
|
|
constructor(message, { status } = {}) {
|
|
super(message);
|
|
this.name = 'EmbedApiError';
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
function parseEmbeddingResponse(data) {
|
|
const embedding =
|
|
data?.data?.[0]?.embedding ||
|
|
data?.embeddings?.[0]?.values ||
|
|
data?.embeddings?.[0] ||
|
|
data?.embedding;
|
|
|
|
if (!Array.isArray(embedding) || embedding.length === 0) {
|
|
throw new EmbedApiError('Embedding API returned no vector');
|
|
}
|
|
|
|
return embedding.map((value) => Number(value));
|
|
}
|
|
|
|
function isValidEmbedInput(value) {
|
|
if (typeof value !== 'string' || !value.trim()) {
|
|
return false;
|
|
}
|
|
|
|
if (value.startsWith('data:')) {
|
|
return value.includes(',');
|
|
}
|
|
|
|
return value.startsWith('http://') || value.startsWith('https://');
|
|
}
|
|
|
|
function buildEmbedInput(value) {
|
|
if (typeof value === 'string') {
|
|
return value;
|
|
}
|
|
|
|
if (value?.imageUrl) {
|
|
return value.imageUrl;
|
|
}
|
|
|
|
if (value?.imageDataUrl) {
|
|
return value.imageDataUrl;
|
|
}
|
|
|
|
throw new EmbedApiError('Invalid embed input');
|
|
}
|
|
|
|
function normalizeEmbeddingDimensions(embedding) {
|
|
if (embedding.length === EMBED_DIMENSION) {
|
|
return embedding;
|
|
}
|
|
|
|
if (embedding.length > EMBED_DIMENSION) {
|
|
// Gateway ignores matryoshka `dimensions` for multimodal — truncate to catalog width.
|
|
return embedding.slice(0, EMBED_DIMENSION);
|
|
}
|
|
|
|
throw new EmbedApiError(
|
|
`Embedding API returned ${embedding.length} dimensions; expected ${EMBED_DIMENSION}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Embed image content via Vercel AI Gateway (server-only).
|
|
* Accepts a JPEG data URL string, HTTPS image URL string, or { imageUrl } / { imageDataUrl }.
|
|
*/
|
|
export async function embedCardImage(input) {
|
|
const apiKey = process.env.AI_GATEWAY_API_KEY;
|
|
if (!apiKey) {
|
|
throw new EmbedApiError('AI_GATEWAY_API_KEY is not configured on the server');
|
|
}
|
|
|
|
const payloadInput = buildEmbedInput(input);
|
|
if (!isValidEmbedInput(payloadInput)) {
|
|
throw new EmbedApiError('Invalid image data format');
|
|
}
|
|
|
|
const response = await fetch(GATEWAY_EMBED_URL, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${apiKey}`,
|
|
},
|
|
body: JSON.stringify({
|
|
model: DEFAULT_EMBED_MODEL,
|
|
input: payloadInput,
|
|
dimensions: EMBED_DIMENSION,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({}));
|
|
const message = errorData.error?.message || errorData.message || 'Unknown error';
|
|
throw new EmbedApiError(`Embedding API error: ${response.status} - ${message}`, {
|
|
status: response.status,
|
|
});
|
|
}
|
|
|
|
return normalizeEmbeddingDimensions(parseEmbeddingResponse(await response.json()));
|
|
}
|
|
|
|
/** Format a float array for pgvector tagged-template queries. */
|
|
export function formatEmbeddingForPg(embedding) {
|
|
return `[${embedding.map((value) => Number(value).toFixed(8)).join(',')}]`;
|
|
}
|