printhive/lib/ai.js
dlawler489 2506f4dce9 Let AI runs give models with unclear names a clearer one, with undo
A run option asks the AI for a clearer name only when the current one is messy
(file-name leftovers, typos, site boilerplate). The previous name is kept, the
run feed shows what was renamed, and the model page offers Undo. Giving a model
a name yourself clears the undo.

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

258 lines
12 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.`;
// 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,
};