printhive/lib/ai.js
dlawler489 0f49fe8c1a AI tags and descriptions, search by meaning, and automatic tags
- AI tagging through any OpenAI-compatible API: DeepSeek by default, or
  OpenRouter, OpenAI, or a local server (Ollama, LM Studio). Settings → AI
  with presets, prices and "Save & test connection"; the key never leaves
  the server except to the provider.
- Runs over untagged models, all models, or one model from its page: live
  progress, tokens and cost, stop/resume (retrying failures), an optional
  spending limit, and paused rather than resumed after a restart. Adapts to
  models without JSON mode or image input; stops on a rejected key or no
  credit. Tags are only added; descriptions filled only where empty unless
  asked.
- Search by meaning: local bge-small-en-v1.5 embeddings (transformers.js),
  indexed in the background whenever a model's text changes; Words/Meaning
  switch on the search box. Stored as REAL[] in Postgres, no pgvector needed.
- Automatic tags: strict whole-word rules (part types, mechanisms, materials,
  techniques) over names, file names and archive contents, plus your own
  word -> tag rules; on/off (optionally removing its tags) and re-apply.
- Tags remember where they came from (user / rule / ai); edits keep that,
  rule and AI passes never touch your own tags, and the model page marks them.
- Docker: ships the embedding model and only Linux onnxruntime binaries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 11:12:58 +10:00

236 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// AI tags and descriptions through any OpenAI-compatible chat API: DeepSeek by
// default, or OpenRouter, OpenAI, or a local server such as Ollama or LM Studio.
// The API key stays on the server.
const PROVIDERS = {
deepseek: { label: 'DeepSeek', baseUrl: 'https://api.deepseek.com', model: 'deepseek-flash', prices: { input: 0.3, output: 1.2 } },
openrouter: { label: 'OpenRouter', baseUrl: 'https://openrouter.ai/api/v1', model: '' },
openai: { label: 'OpenAI', baseUrl: 'https://api.openai.com/v1', model: '' },
custom: { label: 'Other (OpenAI-compatible)', baseUrl: '', model: '' },
};
const SYSTEM_PROMPT = `You catalogue 3D-printable models in a maker's library. Reply with JSON only, shaped {"tags": ["..."], "description": "..."}.
tags: 3 to 8 short lowercase tags someone would search for: what the object is, what it's for, its theme or style, and print traits (print-in-place, articulated, multicolor, needs supports) only when evident. Prefer singular nouns. When a tag already used in the library fits, use it instead of a new synonym. Never use generic tags like "3d print", "model", "stl" or "3mf".
description: one or two plain sentences on what the object is and what it's for. Don't invent sizes, print settings or claims.`;
const GENERIC_TAGS = new Set([
'3d print', '3d printing', '3d printed', '3d printable', '3d model', 'model', 'print', 'printable', 'file',
'stl', '3mf', 'obj', 'step', 'stp', 'gcode',
]);
class AiError extends Error {
constructor(message, status, kind = 'http') {
super(message);
this.status = status;
this.kind = kind; // http | network | timeout
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Settings values (ai_provider, ai_base_url, …) -> a client config, filling the
// gaps from the provider's preset.
function configFrom(values = {}) {
const provider = PROVIDERS[values.ai_provider] ? values.ai_provider : 'deepseek';
const preset = PROVIDERS[provider];
const price = (value, fallback) => (value !== undefined && value !== null && value !== '' && Number.isFinite(Number(value))
? Number(value) : fallback ?? null);
return {
provider,
label: preset.label,
baseUrl: String(values.ai_base_url || preset.baseUrl || '').trim().replace(/\/+$/, ''),
apiKey: values.ai_api_key || '',
model: String(values.ai_model || preset.model || '').trim(),
sendImages: values.ai_send_images !== 'false',
prices: {
input: price(values.ai_price_input, preset.prices && preset.prices.input),
output: price(values.ai_price_output, preset.prices && preset.prices.output),
},
};
}
// What still needs filling in before AI can run, or null.
function missingConfig(config) {
if (!config.baseUrl) return 'Add the API address in Settings → AI.';
if (!config.model) return 'Choose a model in Settings → AI.';
// Local servers (Ollama, LM Studio) usually don't need a key.
if (!config.apiKey && config.provider !== 'custom') return 'Add your API key in Settings → AI.';
return null;
}
// "120 × 80 × 40 mm" from the biggest file's bounding box, if known.
function modelSize(model) {
const dims = (model.files || [])
.map((f) => f.bbox && [f.bbox.x ?? f.bbox.width, f.bbox.y ?? f.bbox.depth, f.bbox.z ?? f.bbox.height].map(Number))
.filter((d) => d && d.every((n) => Number.isFinite(n) && n > 0))
.sort((a, b) => b[0] * b[1] * b[2] - a[0] * a[1] * a[2])[0];
return dims ? `${dims.map((n) => Math.round(n)).join(' × ')} mm` : null;
}
function modelSummary(model, knownTags = []) {
const lines = [`Name: ${model.name}`];
const files = (model.files || []).map((f) => f.originalName).filter(Boolean);
if (files.length) lines.push(`Files: ${files.slice(0, 30).join(', ')}`);
if (model.entryNames && model.entryNames.length) lines.push(`Inside archives: ${model.entryNames.slice(0, 40).join(', ')}`);
if (model.source_site) lines.push(`Source: ${model.source_site}${model.designer ? `, by ${model.designer.name}` : ''}`);
const size = modelSize(model);
if (size) lines.push(`Size: ${size}`);
if (model.tags && model.tags.length) lines.push(`Tags so far: ${model.tags.join(', ')}`);
if (model.description && model.description.trim()) lines.push(`Description so far: ${model.description.trim().slice(0, 800)}`);
if (knownTags.length) lines.push(`Tags already used in this library: ${knownTags.join(', ')}`);
return lines.join('\n');
}
function buildMessages(model, { image = null, knownTags = [] } = {}) {
const text = modelSummary(model, knownTags);
const content = image
? [{ type: 'text', text }, { type: 'image_url', image_url: { url: image } }]
: text;
return [{ role: 'system', content: SYSTEM_PROMPT }, { role: 'user', content }];
}
function normalizeTags(value) {
const list = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [];
const tags = [];
for (const raw of list) {
const tag = String(raw).toLowerCase()
.replace(/^#+/, '')
.replace(/[_\s]+/g, ' ')
.replace(/[^\p{L}\p{N} +&'-]/gu, '')
.trim()
.slice(0, 40)
.trim();
if (tag && !GENERIC_TAGS.has(tag) && !tags.includes(tag)) tags.push(tag);
}
return tags.slice(0, 8);
}
// The model's reply -> { tags, description }. Tolerates code fences or prose
// around the JSON.
function parseReply(content) {
const text = String(content || '');
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
let data = null;
if (start >= 0 && end > start) {
try { data = JSON.parse(text.slice(start, end + 1)); } catch { /* handled below */ }
}
if (!data || typeof data !== 'object') throw new Error('The AI reply wasn’t the JSON asked for');
const tags = normalizeTags(data.tags);
const description = typeof data.description === 'string' ? data.description.trim().slice(0, 600) : '';
if (!tags.length && !description) throw new Error('The AI reply had no tags or description');
return { tags, description };
}
function usageOf(usage) {
return {
prompt: Math.max(0, Math.round(Number(usage && usage.prompt_tokens) || 0)),
completion: Math.max(0, Math.round(Number(usage && usage.completion_tokens) || 0)),
};
}
// US dollars: what the provider reports (OpenRouter), else tokens × your prices
// per million tokens, else null when prices aren't known.
function costOf(usage, prices) {
if (usage && typeof usage.cost === 'number') return usage.cost;
if (!prices || prices.input == null || prices.output == null) return null;
const { prompt, completion } = usageOf(usage);
return (prompt * prices.input + completion * prices.output) / 1e6;
}
async function chat(config, body, { timeoutMs = 90000 } = {}) {
const headers = { 'Content-Type': 'application/json' };
if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`;
if (config.provider === 'openrouter') headers['X-Title'] = 'Printhive';
let res;
try {
res = await fetch(`${config.baseUrl}/chat/completions`, {
method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
if (err.name === 'TimeoutError') throw new AiError('The AI service took too long to answer', 0, 'timeout');
throw new AiError(`Couldn't reach ${config.baseUrl}: ${(err.cause && err.cause.message) || err.message}`, 0, 'network');
}
const text = await res.text();
let data = null;
try { data = JSON.parse(text); } catch { /* not JSON */ }
const detail = (data && data.error && (data.error.message || data.error)) || (data && data.message);
if (!res.ok) throw new AiError(`${res.status}: ${detail || text.slice(0, 200) || res.statusText}`, res.status);
// OpenRouter can report an upstream failure inside a 200.
if (data && data.error) throw new AiError(String(detail), Number(data.error.code) || 502);
if (!data || !Array.isArray(data.choices) || !data.choices.length) {
throw new AiError('The AI service sent back something unexpected', 502);
}
return data;
}
// Tags and a description for one model. Adapts to what the model supports: if it
// refuses JSON mode or images, the request is retried without them
// (imageRejected tells the caller to stop sending images). Rate limits and
// server errors are retried with backoff.
async function describeModel(config, model, { image = null, knownTags = [] } = {}) {
let useImage = Boolean(image);
let useJson = true;
let imageRejected = false;
for (let attempt = 0; ; attempt++) {
const body = {
model: config.model,
messages: buildMessages(model, { image: useImage ? image : null, knownTags }),
temperature: 0.2,
max_tokens: 800,
};
if (useJson) body.response_format = { type: 'json_object' };
if (config.provider === 'openrouter') body.usage = { include: true };
try {
const data = await chat(config, body);
const message = data.choices[0].message || {};
return {
...parseReply(message.content),
usage: usageOf(data.usage),
cost: costOf(data.usage, config.prices),
imageUsed: useImage,
imageRejected,
};
} catch (err) {
if (!(err instanceof AiError) || attempt >= 5) throw err;
if (err.status === 400 || err.status === 415 || err.status === 422) {
if (useImage && /image|vision|multimodal|modalit|content.?part|content type/i.test(err.message)) {
useImage = false; imageRejected = true; continue;
}
if (useJson) { useJson = false; continue; }
if (useImage) { useImage = false; imageRejected = true; continue; }
throw err;
}
if (err.status === 429 || err.status >= 500 || err.kind === 'timeout') {
await sleep(Math.min(20000, 1500 * 2 ** attempt));
continue;
}
throw err;
}
}
}
// Errors that will fail every request the same way, so a run stops instead of
// burning through the queue. null for errors worth moving past.
function fatalMessage(err) {
if (!(err instanceof AiError)) return null;
if (err.status === 401 || err.status === 403) return `Your API key was rejected (${err.message})`;
if (err.status === 402) return `Your AI account is out of credit (${err.message})`;
if (err.status === 404) return `The model or API address wasn't found (${err.message})`;
if (err.kind === 'network') return err.message;
return null;
}
// A rough cost for tagging `count` models, from typical token counts.
function estimate(count, config) {
const prompt = count * (config.sendImages ? 1400 : 500);
const completion = count * 120;
const cost = costOf({ prompt_tokens: prompt, completion_tokens: completion }, config.prices);
return { promptTokens: prompt, completionTokens: completion, cost };
}
module.exports = {
PROVIDERS, AiError, configFrom, missingConfig, buildMessages, parseReply, normalizeTags,
costOf, describeModel, fatalMessage, estimate,
};