- AI tagging through any OpenAI-compatible API: DeepSeek by default, or OpenRouter, OpenAI, or a local server (Ollama, LM Studio). Settings → AI with presets, prices and "Save & test connection"; the key never leaves the server except to the provider. - Runs over untagged models, all models, or one model from its page: live progress, tokens and cost, stop/resume (retrying failures), an optional spending limit, and paused rather than resumed after a restart. Adapts to models without JSON mode or image input; stops on a rejected key or no credit. Tags are only added; descriptions filled only where empty unless asked. - Search by meaning: local bge-small-en-v1.5 embeddings (transformers.js), indexed in the background whenever a model's text changes; Words/Meaning switch on the search box. Stored as REAL[] in Postgres, no pgvector needed. - Automatic tags: strict whole-word rules (part types, mechanisms, materials, techniques) over names, file names and archive contents, plus your own word -> tag rules; on/off (optionally removing its tags) and re-apply. - Tags remember where they came from (user / rule / ai); edits keep that, rule and AI passes never touch your own tags, and the model page marks them. - Docker: ships the embedding model and only Linux onnxruntime binaries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
155 lines
7 KiB
JavaScript
155 lines
7 KiB
JavaScript
// Automatic tagging: strict whole-word rules over model names, file names, and the
|
|
// names (and immediate folders) of files inside archives. Rule tags are recorded
|
|
// as such (model_tags.source = 'rule'), so a pass only adds or removes its own
|
|
// tags; ones you typed are never touched.
|
|
const crypto = require('crypto');
|
|
const db = require('./db');
|
|
const store = require('./store');
|
|
|
|
// Bump when the built-in rules change, so every model is checked again.
|
|
const RULES_VERSION = 1;
|
|
|
|
const BUILT_IN_RULES = [
|
|
{ group: 'Part types', tag: 'bracket', phrases: ['bracket'] },
|
|
{ group: 'Part types', tag: 'holder', phrases: ['holder'] },
|
|
{ group: 'Part types', tag: 'mount', phrases: ['mount'] },
|
|
{ group: 'Part types', tag: 'hinge', phrases: ['hinge'] },
|
|
{ group: 'Part types', tag: 'drawer', phrases: ['drawer'] },
|
|
{ group: 'Part types', tag: 'baseplate', phrases: ['baseplate', 'base plate'] },
|
|
{ group: 'Part types', tag: 'hook', phrases: ['hook'] },
|
|
{ group: 'Part types', tag: 'clip', phrases: ['clip'] },
|
|
{ group: 'Part types', tag: 'organizer', phrases: ['organizer', 'organiser'] },
|
|
{ group: 'Part types', tag: 'keychain', phrases: ['keychain', 'key chain', 'keyring', 'key ring'] },
|
|
{ group: 'Part types', tag: 'gridfinity', phrases: ['gridfinity'] },
|
|
{ group: 'Mechanisms', tag: 'print-in-place', phrases: ['print in place', 'printinplace', 'pip'] },
|
|
{ group: 'Mechanisms', tag: 'articulated', phrases: ['articulated', 'flexi'] },
|
|
{ group: 'Mechanisms', tag: 'threaded', phrases: ['threaded', 'thread'] },
|
|
{ group: 'Mechanisms', tag: 'gear', phrases: ['gear'] },
|
|
{ group: 'Materials', tag: 'pla', phrases: ['pla'] },
|
|
{ group: 'Materials', tag: 'petg', phrases: ['petg'] },
|
|
{ group: 'Materials', tag: 'abs', phrases: ['abs'] },
|
|
{ group: 'Materials', tag: 'asa', phrases: ['asa'] },
|
|
{ group: 'Materials', tag: 'tpu', phrases: ['tpu'] },
|
|
{ group: 'Materials', tag: 'nylon', phrases: ['nylon'] },
|
|
{ group: 'Techniques', tag: 'vase mode', phrases: ['vase mode', 'vasemode', 'spiral vase'] },
|
|
{ group: 'Techniques', tag: 'fuzzy skin', phrases: ['fuzzy skin', 'fuzzyskin'] },
|
|
{ group: 'Techniques', tag: 'multicolor', phrases: ['multicolor', 'multicolour', 'multi color', 'multi colour'] },
|
|
];
|
|
|
|
// Words in a name: splits on punctuation, camelCase and letter/number boundaries.
|
|
// "PETGWallHook_v2.stl" -> ["petg", "wall", "hook", "v", "2", "stl"]
|
|
function tokenize(text) {
|
|
return String(text || '')
|
|
.replace(/(?<=[a-z])(?=[A-Z])/g, ' ')
|
|
.replace(/(?<=[A-Z])(?=[A-Z][a-z])/g, ' ')
|
|
.replace(/(?<=[A-Za-z])(?=\d)|(?<=\d)(?=[A-Za-z])/g, ' ')
|
|
.toLowerCase()
|
|
.split(/[^a-z0-9]+/)
|
|
.filter(Boolean);
|
|
}
|
|
|
|
const sameWord = (token, word) => token === word || token === `${word}s` || token === `${word}es`;
|
|
|
|
function hasPhrase(tokens, words) {
|
|
if (!words.length) return false;
|
|
for (let i = 0; i + words.length <= tokens.length; i++) {
|
|
if (words.every((w, j) => sameWord(tokens[i + j], w))) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const normalizeTag = (tag) => String(tag).trim().toLowerCase().replace(/\s+/g, ' ').slice(0, 40);
|
|
|
|
// rules: [{ tag, phrases }] -> compiled once for matching.
|
|
function compile(rules) {
|
|
return rules.map((r) => ({ tag: normalizeTag(r.tag), phrases: r.phrases.map(tokenize).filter((w) => w.length) }));
|
|
}
|
|
|
|
const COMPILED_BUILT_INS = compile(BUILT_IN_RULES);
|
|
|
|
// Tags for a set of names (each matched separately, so a phrase never spans two).
|
|
function matchTags(texts, customRules = []) {
|
|
const lists = (Array.isArray(texts) ? texts : [texts]).map(tokenize);
|
|
const rules = [...COMPILED_BUILT_INS, ...compile(customRules.map((r) => ({ tag: r.tag, phrases: [r.phrase] })))];
|
|
const tags = new Set();
|
|
for (const rule of rules) {
|
|
if (rule.tag && rule.phrases.some((words) => lists.some((tokens) => hasPhrase(tokens, words)))) tags.add(rule.tag);
|
|
}
|
|
return [...tags];
|
|
}
|
|
|
|
// ---------------------------------------------------------------- database
|
|
async function isEnabled() {
|
|
return (await store.getSetting('autotag_enabled')) !== 'false';
|
|
}
|
|
|
|
async function listRules() {
|
|
const { rows } = await db.query('SELECT id, phrase, tag FROM tag_rules ORDER BY id');
|
|
return rows;
|
|
}
|
|
|
|
const saltFor = (rules) => crypto.createHash('md5').update(JSON.stringify([RULES_VERSION, rules])).digest('hex');
|
|
|
|
// Models whose names, files or archive contents changed since their rule tags
|
|
// were worked out (or whose rules changed).
|
|
const PENDING = `
|
|
FROM (
|
|
SELECT m.id, m.name, m.autotag_key,
|
|
COALESCE((SELECT string_agg(f.original_name, E'\\n' ORDER BY f.id) FROM files f WHERE f.model_id = m.id), '') AS files,
|
|
COALESCE((SELECT string_agg(e.path, E'\\n' ORDER BY e.id)
|
|
FROM archive_entries e JOIN files f ON f.id = e.file_id WHERE f.model_id = m.id), '') AS entries
|
|
FROM models m
|
|
) d
|
|
WHERE d.autotag_key IS DISTINCT FROM md5($1 || d.name || E'\\n' || d.files || E'\\n' || d.entries)`;
|
|
|
|
async function run() {
|
|
if (!(await isEnabled())) return false;
|
|
const rules = await listRules();
|
|
const salt = saltFor(rules.map((r) => [r.phrase, r.tag]));
|
|
const { rows } = await db.query(`SELECT d.id, d.name, d.files, d.entries ${PENDING} ORDER BY d.id LIMIT 100`, [salt]);
|
|
if (!rows.length) return false;
|
|
for (const row of rows) {
|
|
const entryNames = row.entries.split('\n').filter(Boolean).map((p) => p.split('/').slice(-2).join('/'));
|
|
const texts = [row.name, ...row.files.split('\n'), ...entryNames.flatMap((p) => p.split('/'))];
|
|
await store.replaceRuleTags(row.id, matchTags(texts, rules));
|
|
await db.query(`UPDATE models SET autotag_key = md5($2 || $3 || E'\\n' || $4 || E'\\n' || $5) WHERE id = $1`,
|
|
[row.id, salt, row.name, row.files, row.entries]);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async function pendingCount() {
|
|
if (!(await isEnabled())) return 0;
|
|
const rules = await listRules();
|
|
const { rows } = await db.query(`SELECT COUNT(*)::int AS n ${PENDING}`, [saltFor(rules.map((r) => [r.phrase, r.tag]))]);
|
|
return rows[0].n;
|
|
}
|
|
|
|
// Checks every model again on the next pass.
|
|
async function reapply() {
|
|
await db.query('UPDATE models SET autotag_key = NULL');
|
|
}
|
|
|
|
// Turning it off keeps the tags already applied unless removeTags is set.
|
|
async function setEnabled(enabled, { removeTags = false } = {}) {
|
|
await store.setSetting('autotag_enabled', enabled ? 'true' : 'false');
|
|
await reapply();
|
|
return !enabled && removeTags ? store.removeTagsFrom('rule') : 0;
|
|
}
|
|
|
|
async function addRule(phrase, tag) {
|
|
const cleanPhrase = String(phrase || '').trim().slice(0, 80);
|
|
const cleanTag = normalizeTag(tag || '');
|
|
if (!tokenize(cleanPhrase).length) throw Object.assign(new Error('Enter a word or phrase to look for'), { status: 400 });
|
|
if (!cleanTag) throw Object.assign(new Error('Enter the tag to add'), { status: 400 });
|
|
await db.query('INSERT INTO tag_rules (phrase, tag) VALUES ($1, $2) ON CONFLICT DO NOTHING', [cleanPhrase, cleanTag]);
|
|
}
|
|
|
|
async function removeRule(id) {
|
|
await db.query('DELETE FROM tag_rules WHERE id = $1', [id]);
|
|
}
|
|
|
|
module.exports = {
|
|
BUILT_IN_RULES, tokenize, matchTags,
|
|
run, pendingCount, reapply, isEnabled, setEnabled, listRules, addRule, removeRule,
|
|
};
|