- 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>
236 lines
11 KiB
JavaScript
236 lines
11 KiB
JavaScript
// 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,
|
||
};
|