printhive/lib/ai.js
dlawler489 9d7a978f03 AI collection suggestions, pick-what-to-do runs, and a retry for bad replies
- Runs can now do any mix of: tags & descriptions, clearer names, and
  collection suggestions. A run without tags doesn't mark models as tagged.
- Collection ideas reuse your collections and earlier ideas where they fit and
  wait on the AI page, grouped by name, until you create/add or dismiss them.
  Dismissed ideas aren't suggested for that model again; spelling variants
  group together under the capitalised spelling.
- A reply that isn't the JSON asked for is asked again once; both requests
  count towards the run's tokens and cost.

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

321 lines
15 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, 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));
// 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');
}
// 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 : '');
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 = 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, '');
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 = stripQuotes(value)
.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;
}
// 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 = stripQuotes(raw).replace(/\s+/g, ' ').trim().slice(0, 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' ? data.description.trim().slice(0, 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,
};