- 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>
321 lines
15 KiB
JavaScript
321 lines
15 KiB
JavaScript
// 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,
|
||
};
|