// AI tags, descriptions, names and collection ideas 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: '' }, }; // The system prompt is assembled from the parts a run asks for. const BASE_PROMPT = `You catalogue 3D-printable models in a maker's library. Reply with JSON only, as one object containing exactly the fields described below.`; const DESCRIBE_PROMPT = ` "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 NAME_PROMPT = ` "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 COLLECTIONS_PROMPT = ` "collections": 0 to 3 collections this model belongs in: broad groups someone would browse a library by, such as "Bookends & Book Holders", "Desk Organizers", "Pokémon" or "Halloween", never a name for this one model. When one of the library's existing collections fits, use its name exactly as written. Return [] when nothing fits well.`; 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 } } // The provider answered, but not with the JSON asked for. class ReplyError extends Error {} const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // Cuts text to at most max UTF-16 units without splitting an emoji in half. A lone // half (surrogate) is still sent as JSON, but some APIs (DeepSeek) refuse to parse it. function clip(text, max) { const s = String(text); if (s.length <= max) return s; const cut = s.slice(0, max); return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut; } // 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: ${clip(model.description.trim(), 800)}`); if (knownTags.length) lines.push(`Tags already used in this library: ${knownTags.join(', ')}`); return lines.join('\n'); } // Which fields to ask for: describe (tags + description, the default), a clearer // name, and collection ideas. function buildMessages(model, { image = null, knownTags = [], describe = true, suggestName = false, suggestCollections = false, collectionNames = [], } = {}) { let text = modelSummary(model, describe ? knownTags : []); if (suggestCollections) { text += collectionNames.length ? `\nExisting collections in this library: ${collectionNames.join(', ')}` : '\nThis library has no collections yet.'; } const system = BASE_PROMPT + (describe ? DESCRIBE_PROMPT : '') + (suggestName ? NAME_PROMPT : '') + (suggestCollections ? COLLECTIONS_PROMPT : ''); // Stored names and descriptions may already hold a broken emoji; send a stand-in instead. text = text.toWellFormed(); const content = image ? [{ type: 'text', text }, { type: 'image_url', image_url: { url: image } }] : text; return [{ role: 'system', content: system }, { 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 = clip(String(raw).toLowerCase() .replace(/^#+/, '') .replace(/[_\s]+/g, ' ') .replace(/[^\p{L}\p{N} +&'-]/gu, '') .trim(), 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, ''); const stripQuotes = (s) => String(s).replace(/^["'“”‘’]+|["'“”‘’]+$/g, ''); // 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 = clip(stripQuotes(value) .replace(/\.(stl|3mf|obj|fbx|step|stp|3dm|gcode|zip|7z|rar)$/i, '') .replace(/\s+/g, ' ') .trim(), 80) .trim(); return name && comparable(name) !== comparable(currentName) ? name : null; } // Up to 3 collection names, tidied, without repeats that differ only in case or punctuation. function normalizeCollections(value) { const list = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : []; const names = []; for (const raw of list) { const name = clip(stripQuotes(raw).replace(/\s+/g, ' ').trim(), 60).trim(); if (name && !names.some((n) => comparable(n) === comparable(name))) names.push(name); } return names.slice(0, 3); } // The model's reply -> { tags, description, name, collections }. Tolerates code // fences or prose around the JSON. Only the fields asked for are read: name when // { currentName } is given, collections when { collections: true }. function parseReply(content, options = {}) { const { describe = true, collections = false } = 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 ReplyError('The AI reply wasn’t the JSON asked for'); const tags = describe ? normalizeTags(data.tags) : []; const description = describe && typeof data.description === 'string' ? clip(data.description.trim(), 600) : ''; if (describe && !tags.length && !description) throw new ReplyError('The AI reply had no tags or description'); return { tags, description, name: Object.prototype.hasOwnProperty.call(options, 'currentName') ? cleanName(data.name, options.currentName) : null, collections: collections ? normalizeCollections(data.collections) : [], }; } 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; } // Adds one response's usage to a running total, so retried requests are still counted. function addUsage(total, usage) { const { prompt, completion } = usageOf(usage); total.prompt_tokens += prompt; total.completion_tokens += completion; if (usage && typeof usage.cost === 'number') total.cost = (total.cost || 0) + usage.cost; } 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; } // Asks about one model: tags and a description, and optionally a clearer name and // collection ideas. 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). A reply that isn't the JSON asked for is asked again once. // Rate limits and server errors are retried with backoff. async function describeModel(config, model, { image = null, knownTags = [], describe = true, suggestName = false, suggestCollections = false, collectionNames = [], } = {}) { let useImage = Boolean(image); let useJson = true; let imageRejected = false; let replyRetried = false; const spent = { prompt_tokens: 0, completion_tokens: 0, cost: null }; const want = { describe, collections: suggestCollections, ...(suggestName ? { currentName: model.name } : {}) }; for (let attempt = 0; ; attempt++) { const body = { model: config.model, messages: buildMessages(model, { image: useImage ? image : null, knownTags, describe, suggestName, suggestCollections, collectionNames, }), 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); addUsage(spent, data.usage); let reply; try { reply = parseReply((data.choices[0].message || {}).content, want); } catch (err) { // Usually a one-off: ask once more before giving up on this model. if (err instanceof ReplyError && !replyRetried) { replyRetried = true; continue; } throw err; } return { ...reply, usage: usageOf(spent), cost: costOf(spent, config.prices), imageUsed: useImage, imageRejected, replyRetried, }; } 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 `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, ReplyError, configFrom, missingConfig, buildMessages, parseReply, normalizeTags, normalizeCollections, costOf, describeModel, fatalMessage, estimate, };