// 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.`; // Added to the prompt only when a run may rename models. const NAME_PROMPT = ` Also include "name": the model's name as a clear title. If the current name already says clearly what the model is, return it exactly as it is. Otherwise write a short title-case name (under 60 characters) saying what it is, using the picture and details: drop file-name leftovers (underscores, stray spaces, version numbers like "v4", "final", file extensions), fix typos, and remove site boilerplate like "Free 3D Print Model - MakerWorld". Keep designer, character or product names that are part of what it is.`; 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 = [], suggestName = false } = {}) { 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 + (suggestName ? NAME_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); } const comparable = (s) => String(s || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ''); // A suggested name, tidied; null when there isn't one or it's the current name // written differently. function cleanName(value, currentName) { if (typeof value !== 'string') return null; const name = value .replace(/^["'“”‘’]+|["'“”‘’]+$/g, '') .replace(/\.(stl|3mf|obj|fbx|step|stp|3dm|gcode|zip|7z|rar)$/i, '') .replace(/\s+/g, ' ') .trim() .slice(0, 80) .trim(); return name && comparable(name) !== comparable(currentName) ? name : null; } // The model's reply -> { tags, description, name }. Tolerates code fences or // prose around the JSON. name is only read when { currentName } is given (the // prompt asked for one). function parseReply(content, options = {}) { 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'); const name = Object.prototype.hasOwnProperty.call(options, 'currentName') ? cleanName(data.name, options.currentName) : null; return { tags, description, name }; } 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 = [], suggestName = false } = {}) { 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, suggestName }), 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, suggestName ? { currentName: model.name } : {}), 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, };