From 0f49fe8c1aad60b97b58d14408b2ababfd4a7d32 Mon Sep 17 00:00:00 2001 From: dlawler489 <104159223@student.swin.edu.au> Date: Tue, 15 Sep 2026 11:12:58 +1000 Subject: [PATCH] AI tags and descriptions, search by meaning, and automatic tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .dockerignore | 1 + .gitignore | 1 + Dockerfile | 9 +- README.md | 10 + db/schema.sql | 54 +++ lib/ai.js | 236 +++++++++++ lib/aiRuns.js | 231 +++++++++++ lib/autotag.js | 155 +++++++ lib/embeddingModel.js | 46 +++ lib/embeddings.js | 88 ++++ lib/fingerprint.js | 8 +- lib/store.js | 70 +++- package-lock.json | 912 ++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + public/app.js | 540 ++++++++++++++++++++++++- public/index.html | 60 +++ public/styles.css | 59 ++- server.js | 185 ++++++++- test/ai.test.js | 111 +++++ test/autotag.test.js | 33 ++ 20 files changed, 2778 insertions(+), 32 deletions(-) create mode 100644 lib/ai.js create mode 100644 lib/aiRuns.js create mode 100644 lib/autotag.js create mode 100644 lib/embeddingModel.js create mode 100644 lib/embeddings.js create mode 100644 test/ai.test.js create mode 100644 test/autotag.test.js diff --git a/.dockerignore b/.dockerignore index 51ef26e..4683652 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,3 +15,4 @@ Dockerfile .dockerignore docker-compose.yml extracted +models diff --git a/.gitignore b/.gitignore index fa332d7..dd3622a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ glb/ tools/venv/ __pycache__/ extracted/ +models/ diff --git a/Dockerfile b/Dockerfile index 9b29a9e..660a485 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,11 +18,18 @@ RUN python3 -m venv /app/tools/venv \ # Node deps (cached unless package files change). COPY package.json package-lock.json ./ -RUN npm ci --omit=dev +RUN npm ci --omit=dev \ + # semantic search only needs onnxruntime's Linux binaries (saves ~130 MB) + && rm -rf node_modules/onnxruntime-node/bin/napi-v3/darwin node_modules/onnxruntime-node/bin/napi-v3/win32 # App source. COPY . . +# The embedding model for semantic search (~34 MB), fetched at build time so the +# server never has to download it. +ENV MODELS_DIR=/app/models +RUN node -e "require('./lib/embeddingModel').load().then(() => console.log('embedding model ready'))" + ENV NODE_ENV=production \ PORT=3000 EXPOSE 3000 diff --git a/README.md b/README.md index 9fd3ad1..f39f603 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX, - **Notes** — free-form notes per model, saved as you type. - **Search, filters & sorting** — search across names, descriptions, notes, tags, file names and designers; filter by format, source and license; include *or exclude* tags (match any/all); sort and group by source or format. Press / to search. - **Duplicate finder** — every file gets a SHA-256 content hash plus (for STL/OBJ/3MF) a geometry fingerprint, computed in the background. Models sharing files are grouped as *exact copies* or *same shape* (renamed, re-exported, converted — or mirrored, so check those), with a recommended copy to keep. Keeping one merges the others' tags, collections and notes into it and moves them to the trash; groups can be dismissed as "not duplicates". +- **AI tags & descriptions** — bring your own key for DeepSeek (the default), OpenRouter, OpenAI or any OpenAI-compatible server (Ollama, LM Studio). Tag the whole library, only models not yet tagged, or a single model from its page. Each model's name, file names and (optionally) thumbnail are sent; tags are only ever added and descriptions only filled where empty unless you ask. Runs show live progress, tokens and cost, can be stopped and resumed, stop at an optional spending limit, and pause (rather than keep spending) if the server restarts. "Save & test connection" in Settings checks the key, model and whether it accepts images. +- **Search by meaning** — switch the search box to *Meaning* to find models by what they are ("wall hook" finds a "coat hanger bracket"). Uses a small embedding model (bge-small-en-v1.5, ~34 MB) that runs on the server, so it's free and private; the index updates in the background as models change. +- **Automatic tags** — strict whole-word rules over names, file names and archive contents (part types, mechanisms, materials, techniques) plus your own "word → tag" rules. Switch it off (optionally removing its tags) or re-apply it any time. Tags you add yourself are never changed; AI and rule tags are marked on the model page. - **Trash** — deleting moves a model to the trash; restore it, delete it forever, or empty the trash (only then are files removed from disk). - **Settings** — API tokens / FlareSolverr URL stored in the database (with `.env` fallback), secrets masked. @@ -61,8 +64,15 @@ Settings live in the database (editable in the app's **Settings** menu) and fall | FlareSolverr URL | `FLARESOLVERR_URL` | Read Cloudflare-protected sites (MakerWorld) | | MakerWorld auth token | `MAKERWORLD_TOKEN` | MakerWorld downloads (bearer token from your session) | | Printables auth token | `PRINTABLES_TOKEN` | Optional; account-gated Printables files | +| AI provider | `AI_PROVIDER` | `deepseek` (default), `openrouter`, `openai` or `custom` | +| AI API key | `AI_API_KEY` | Your provider key (not needed for a local server) | +| AI model | `AI_MODEL` | Defaults to `deepseek-flash` for DeepSeek | +| AI API address | `AI_BASE_URL` | Defaults per provider; e.g. `http://localhost:11434/v1` for Ollama | +| AI prices | `AI_PRICE_INPUT`, `AI_PRICE_OUTPUT` | $ per million tokens, only for showing costs (DeepSeek defaults to its peak rates) | +| Send thumbnails | `AI_SEND_IMAGES` | `false` to tag from names and files only | ## Notes - Auto-download from MakerWorld/Printables relies on their private/session APIs and can break if they change; failed/partial imports can be recovered with "Download files from source". +- The embedding model for search is downloaded into `./models` on first use locally; the Docker image ships with it. It holds ~150 MB of RAM while in use and is released after 10 idle minutes. - The commercial-use badge is a best-effort read of the license — always confirm on the source page before selling. diff --git a/db/schema.sql b/db/schema.sql index 62451b5..37d0e90 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -154,3 +154,57 @@ CREATE TABLE IF NOT EXISTS duplicate_dismissals ( signature TEXT PRIMARY KEY, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); + +-- Where a model's tag came from: user (typed in), rule (automatic tagging) or ai. +-- Rule and AI passes only add or remove their own tags; yours are never touched. +ALTER TABLE model_tags ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'user'; + +-- Automatic tagging: fingerprint of the rules and names the rule tags were last +-- worked out from, so a model is re-checked only when one of those changes. +ALTER TABLE models ADD COLUMN IF NOT EXISTS autotag_key TEXT; + +-- Semantic search: a local embedding of the model's text, and the fingerprint of +-- the text (and embedding model) it was made from. +ALTER TABLE models ADD COLUMN IF NOT EXISTS embedding REAL[]; +ALTER TABLE models ADD COLUMN IF NOT EXISTS embedding_key TEXT; + +-- AI tagging runs, kept so they can be watched, stopped and resumed. +ALTER TABLE models ADD COLUMN IF NOT EXISTS ai_tagged_at TIMESTAMPTZ; + +CREATE TABLE IF NOT EXISTS ai_runs ( + id SERIAL PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'running', -- running | paused | stopped | done | failed + provider TEXT, + model TEXT, + options JSONB NOT NULL DEFAULT '{}', -- { overwriteDescriptions, maxCost, pricesKnown } + total INTEGER NOT NULL DEFAULT 0, + done INTEGER NOT NULL DEFAULT 0, + failed INTEGER NOT NULL DEFAULT 0, + prompt_tokens BIGINT NOT NULL DEFAULT 0, + completion_tokens BIGINT NOT NULL DEFAULT 0, + cost_usd NUMERIC(12, 6) NOT NULL DEFAULT 0, + error TEXT, + note TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + finished_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS ai_run_items ( + run_id INTEGER NOT NULL REFERENCES ai_runs(id) ON DELETE CASCADE, + model_id INTEGER NOT NULL REFERENCES models(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'pending', -- pending | done | error + error TEXT, + tags TEXT[], + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (run_id, model_id) +); +CREATE INDEX IF NOT EXISTS idx_ai_run_items_status ON ai_run_items(run_id, status); + +-- Your own automatic tagging rules: a word or phrase in a name -> a tag. +CREATE TABLE IF NOT EXISTS tag_rules ( + id SERIAL PRIMARY KEY, + phrase TEXT NOT NULL, + tag TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (phrase, tag) +); diff --git a/lib/ai.js b/lib/ai.js new file mode 100644 index 0000000..471ac56 --- /dev/null +++ b/lib/ai.js @@ -0,0 +1,236 @@ +// 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.`; + +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 = [] } = {}) { + 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 }, { 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); +} + +// The model's reply -> { tags, description }. Tolerates code fences or prose +// around the JSON. +function parseReply(content) { + 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'); + return { tags, description }; +} + +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 = [] } = {}) { + 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 }), + 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), + 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, +}; diff --git a/lib/aiRuns.js b/lib/aiRuns.js new file mode 100644 index 0000000..467e371 --- /dev/null +++ b/lib/aiRuns.js @@ -0,0 +1,231 @@ +// AI tagging runs: a queue of models tagged a few at a time. Progress, tokens and +// cost live in the database, so a run can be watched, stopped and resumed, and a +// server restart pauses it instead of spending on its own. +const fs = require('fs'); +const path = require('path'); +const db = require('./db'); +const store = require('./store'); +const ai = require('./ai'); + +const CONCURRENCY = 3; +const MAX_FAILURES_IN_A_ROW = 5; +const MAX_IMAGE_BYTES = 3 * 1024 * 1024; +const IMAGE_TYPES = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' }; + +let resolveConfig = async () => ai.configFrom({}); +let working = false; +let rerun = false; + +const httpError = (message, status) => Object.assign(new Error(message), { status }); + +async function init(resolver) { + resolveConfig = resolver; + await db.query( + `UPDATE ai_runs SET status = 'paused', note = 'Paused when the server restarted. Resume to carry on.' + WHERE status = 'running'`); +} + +// The model's thumbnail as a data URL, when there is one small enough to send. +function thumbnailDataUrl(model) { + if (!model.thumbnail_path) return null; + const abs = path.resolve(model.thumbnail_path); + const type = IMAGE_TYPES[path.extname(abs).toLowerCase()]; + try { + if (!type || fs.statSync(abs).size > MAX_IMAGE_BYTES) return null; + return `data:${type};base64,${fs.readFileSync(abs).toString('base64')}`; + } catch { + return null; + } +} + +// Names of the files inside a model's archives, as extra context. +async function entryNames(model) { + const { rows } = await db.query( + `SELECT e.path FROM archive_entries e JOIN files f ON f.id = e.file_id + WHERE f.model_id = $1 ORDER BY e.id LIMIT 60`, [model.id]); + return rows.map((r) => r.path.split('/').pop()).filter(Boolean); +} + +async function createRun(modelIds, options = {}) { + const config = await resolveConfig(); + const problem = ai.missingConfig(config); + if (problem) throw httpError(problem, 400); + const ids = [...new Set((modelIds || []).map(Number).filter(Number.isInteger))]; + const maxCost = Number(options.maxCost) > 0 ? Number(options.maxCost) : null; + + const run = await db.transaction(async (client) => { + const { rows: [created] } = await client.query( + `INSERT INTO ai_runs (provider, model, options) VALUES ($1, $2, $3) RETURNING id`, + [config.label, config.model, { + overwriteDescriptions: Boolean(options.overwriteDescriptions), + maxCost, + pricesKnown: config.prices.input != null || config.provider === 'openrouter', + }]); + const { rowCount } = await client.query( + `INSERT INTO ai_run_items (run_id, model_id) + SELECT $1, id FROM models WHERE id = ANY($2::int[]) AND deleted_at IS NULL`, [created.id, ids]); + if (!rowCount) throw httpError('No models to tag', 400); + await client.query('UPDATE ai_runs SET total = $2 WHERE id = $1', [created.id, rowCount]); + return created; + }); + kick(); + return getRun(run.id); +} + +async function finish(runId, status, error = null) { + await db.query( + `UPDATE ai_runs SET status = $2, error = $3, finished_at = now() WHERE id = $1 AND status = 'running'`, + [runId, status, error]); +} + +async function tagOne(runId, modelId, config, { images, knownTags, overwrite }) { + const model = await store.getModel(modelId); + if (!model || model.deleted_at) { + await db.query(`UPDATE ai_run_items SET status = 'error', error = 'The model was deleted', updated_at = now() + WHERE run_id = $1 AND model_id = $2`, [runId, modelId]); + await db.query('UPDATE ai_runs SET failed = failed + 1 WHERE id = $1', [runId]); + return { ok: true }; + } + try { + model.entryNames = await entryNames(model); + const result = await ai.describeModel(config, model, { image: images ? thumbnailDataUrl(model) : null, knownTags }); + await store.applyAiResult(modelId, { tags: result.tags, description: result.description, overwriteDescription: overwrite }); + await db.query(`UPDATE ai_run_items SET status = 'done', tags = $3, error = NULL, updated_at = now() + WHERE run_id = $1 AND model_id = $2`, [runId, modelId, result.tags]); + await db.query( + `UPDATE ai_runs SET done = done + 1, prompt_tokens = prompt_tokens + $2, + completion_tokens = completion_tokens + $3, cost_usd = cost_usd + $4 WHERE id = $1`, + [runId, result.usage.prompt, result.usage.completion, result.cost || 0]); + return { ok: true, imageRejected: result.imageRejected }; + } catch (err) { + const fatal = ai.fatalMessage(err); + if (fatal) return { ok: false, fatal }; // left pending, so resuming retries it + const message = err.message.slice(0, 300); + await db.query(`UPDATE ai_run_items SET status = 'error', error = $3, updated_at = now() + WHERE run_id = $1 AND model_id = $2`, [runId, modelId, message]); + await db.query('UPDATE ai_runs SET failed = failed + 1 WHERE id = $1', [runId]); + return { ok: false, error: message }; + } +} + +async function processRun(runId) { + const config = await resolveConfig(); + const problem = ai.missingConfig(config); + if (problem) { await finish(runId, 'failed', problem); return; } + const knownTags = await store.topTags(150); + let images = config.sendImages; + let failuresInARow = 0; + let lastError = null; + + for (;;) { + const { rows: [run] } = await db.query('SELECT status, options, cost_usd FROM ai_runs WHERE id = $1', [runId]); + if (!run || run.status !== 'running') return; // stopped, or deleted + const { maxCost, overwriteDescriptions } = run.options || {}; + const { rows: items } = await db.query( + `SELECT model_id FROM ai_run_items WHERE run_id = $1 AND status = 'pending' ORDER BY model_id LIMIT $2`, + [runId, CONCURRENCY]); + if (!items.length) { await finish(runId, 'done'); return; } + // Checked between batches, so a run can go over by up to one batch. + if (maxCost && Number(run.cost_usd) >= maxCost) { + await finish(runId, 'stopped', `Stopped at your $${maxCost} spending limit`); + return; + } + + const results = await Promise.all(items.map((item) => + tagOne(runId, item.model_id, config, { images, knownTags, overwrite: overwriteDescriptions }))); + for (const r of results) { + if (r.imageRejected && images) { + images = false; + await db.query('UPDATE ai_runs SET note = $2 WHERE id = $1', + [runId, `${config.model} doesn't accept images, so models are tagged from their names and files.`]); + } + if (r.fatal) { await finish(runId, 'failed', r.fatal); return; } + if (r.ok) failuresInARow = 0; + else { failuresInARow += 1; lastError = r.error; } + } + if (failuresInARow >= MAX_FAILURES_IN_A_ROW) { + await finish(runId, 'failed', `Stopped after ${failuresInARow} failures in a row. Last: ${lastError}`); + return; + } + } +} + +// Works through running runs, one at a time. Safe to call often. +function kick() { + if (working) { rerun = true; return; } + working = true; + (async () => { + for (;;) { + const { rows: [run] } = await db.query(`SELECT id FROM ai_runs WHERE status = 'running' ORDER BY id LIMIT 1`); + if (!run) return; + try { + await processRun(run.id); + } catch (err) { + console.error(`AI run ${run.id} failed:`, err.message); + await finish(run.id, 'failed', err.message.slice(0, 300)); + } + } + })() + .catch((err) => console.error('AI runs stopped:', err.message)) + .finally(() => { + working = false; + if (rerun) { rerun = false; kick(); } + }); +} + +const shapeRun = (r) => r && ({ + ...r, + prompt_tokens: Number(r.prompt_tokens), + completion_tokens: Number(r.completion_tokens), + cost_usd: Number(r.cost_usd), +}); + +async function listRuns(limit = 15) { + const { rows } = await db.query('SELECT * FROM ai_runs ORDER BY id DESC LIMIT $1', [limit]); + return rows.map(shapeRun); +} + +// A run with its latest results and errors, for watching it. +async function getRun(id) { + const { rows: [run] } = await db.query('SELECT * FROM ai_runs WHERE id = $1', [id]); + if (!run) return null; + const { rows: recent } = await db.query( + `SELECT i.model_id, m.name, i.status, i.tags, i.error FROM ai_run_items i + LEFT JOIN models m ON m.id = i.model_id + WHERE i.run_id = $1 AND i.status <> 'pending' ORDER BY i.updated_at DESC LIMIT 12`, [id]); + return { ...shapeRun(run), recent }; +} + +async function stop(id) { + const { rowCount } = await db.query( + `UPDATE ai_runs SET status = 'stopped', finished_at = now(), note = NULL + WHERE id = $1 AND status IN ('running', 'paused')`, [id]); + return rowCount > 0; +} + +// Carries on a paused, stopped or failed run, retrying models that errored. +async function resume(id) { + const resumed = await db.transaction(async (client) => { + const { rows: [run] } = await client.query( + `SELECT id FROM ai_runs WHERE id = $1 AND status IN ('paused', 'stopped', 'failed') FOR UPDATE`, [id]); + if (!run) return false; + const retried = await client.query( + `UPDATE ai_run_items SET status = 'pending', error = NULL WHERE run_id = $1 AND status = 'error'`, [id]); + const { rows: [left] } = await client.query( + `SELECT COUNT(*)::int AS n FROM ai_run_items WHERE run_id = $1 AND status = 'pending'`, [id]); + if (!left.n) return false; + await client.query( + `UPDATE ai_runs SET status = 'running', error = NULL, note = NULL, finished_at = NULL, + failed = GREATEST(failed - $2, 0) WHERE id = $1`, [id, retried.rowCount]); + return true; + }); + if (resumed) kick(); + return resumed; +} + +async function hasActiveRun() { + const { rows } = await db.query(`SELECT 1 FROM ai_runs WHERE status = 'running' LIMIT 1`); + return rows.length > 0; +} + +module.exports = { init, createRun, listRuns, getRun, stop, resume, kick, hasActiveRun, thumbnailDataUrl }; diff --git a/lib/autotag.js b/lib/autotag.js new file mode 100644 index 0000000..b9c35a7 --- /dev/null +++ b/lib/autotag.js @@ -0,0 +1,155 @@ +// 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, +}; diff --git a/lib/embeddingModel.js b/lib/embeddingModel.js new file mode 100644 index 0000000..0845696 --- /dev/null +++ b/lib/embeddingModel.js @@ -0,0 +1,46 @@ +// The local text-embedding model behind semantic search: bge-small-en-v1.5 +// (quantized, ~34 MB) run with transformers.js. Nothing is sent anywhere; the model +// is downloaded once into MODELS_DIR (the Docker image ships with it). +const path = require('path'); + +const MODEL = 'Xenova/bge-small-en-v1.5'; +const DIMENSIONS = 384; +const CACHE_DIR = process.env.MODELS_DIR || path.join(__dirname, '..', 'models'); +// bge models match short queries against passages best with this instruction. +const QUERY_PREFIX = 'Represent this sentence for searching relevant passages: '; +const IDLE_MS = 10 * 60 * 1000; + +let pipelinePromise = null; +let idleTimer = null; + +// Loaded on first use and released after a while unused (it holds ~150 MB). +function load() { + if (!pipelinePromise) { + pipelinePromise = (async () => { + const { pipeline, env } = await import('@huggingface/transformers'); + env.cacheDir = CACHE_DIR; + return pipeline('feature-extraction', MODEL, { dtype: 'q8' }); + })(); + pipelinePromise.catch(() => { pipelinePromise = null; }); + } + clearTimeout(idleTimer); + idleTimer = setTimeout(release, IDLE_MS); + idleTimer.unref(); + return pipelinePromise; +} + +async function release() { + const loaded = pipelinePromise; + pipelinePromise = null; + try { await (await loaded).dispose(); } catch { /* already gone */ } +} + +// texts -> unit-length vectors (arrays of numbers). +async function embed(texts) { + if (!texts.length) return []; + const extractor = await load(); + const output = await extractor(texts, { pooling: 'cls', normalize: true }); + return output.tolist(); +} + +module.exports = { MODEL, DIMENSIONS, QUERY_PREFIX, load, embed }; diff --git a/lib/embeddings.js b/lib/embeddings.js new file mode 100644 index 0000000..a9dc062 --- /dev/null +++ b/lib/embeddings.js @@ -0,0 +1,88 @@ +// Semantic search index: each model's text (name, description, notes, tags, file +// names) embedded locally, refreshed in the background whenever that text changes. +const db = require('./db'); +const embeddingModel = require('./embeddingModel'); + +const BATCH = 16; +const MAX_CHARS = 2000; // the model reads ~512 tokens; the start of the text matters most +const RETRY_AFTER_MS = 10 * 60 * 1000; +// Part of every text fingerprint, so switching embedding model re-indexes everything. +const SALT = `${embeddingModel.MODEL}:1:`; + +let lastError = null; +let retryAt = 0; +let vectors = null; // [{ id, vec: Float32Array }], rebuilt after the index changes +let vectorsLoadedAt = 0; +const VECTORS_MAX_AGE_MS = 60 * 1000; // so trashed and restored models catch up + +const DOCS = ` + SELECT m.id, m.embedding_key, + concat_ws(E'\\n', m.name, NULLIF(btrim(m.description), ''), + (SELECT string_agg(tg.name, ', ' ORDER BY tg.name) FROM model_tags mt JOIN tags tg ON tg.id = mt.tag_id WHERE mt.model_id = m.id), + NULLIF(btrim(m.notes), ''), m.source_site, d.name, + (SELECT string_agg(f.original_name, ', ' ORDER BY f.id) FROM files f WHERE f.model_id = m.id), + (SELECT string_agg(e.path, ', ' ORDER BY e.id) FROM archive_entries e JOIN files f ON f.id = e.file_id WHERE f.model_id = m.id) + ) AS text + FROM models m LEFT JOIN designers d ON d.id = m.designer_id + WHERE m.deleted_at IS NULL`; +const PENDING = `FROM (${DOCS}) doc WHERE doc.embedding_key IS DISTINCT FROM md5($1 || doc.text)`; + +async function run() { + if (Date.now() < retryAt) return false; + const { rows } = await db.query(`SELECT doc.id, doc.text ${PENDING} ORDER BY doc.id LIMIT $2`, [SALT, BATCH]); + if (!rows.length) return false; + let vecs; + try { + vecs = await embeddingModel.embed(rows.map((r) => r.text.slice(0, MAX_CHARS))); + lastError = null; + } catch (err) { + lastError = err.message; + retryAt = Date.now() + RETRY_AFTER_MS; + console.warn('Semantic search indexing paused:', err.message); + return false; + } + for (let i = 0; i < rows.length; i++) { + await db.query('UPDATE models SET embedding = $2, embedding_key = md5($3 || $4) WHERE id = $1', + [rows[i].id, vecs[i], SALT, rows[i].text]); + } + vectors = null; + return true; +} + +async function loadVectors() { + if (!vectors || Date.now() - vectorsLoadedAt > VECTORS_MAX_AGE_MS) { + const { rows } = await db.query('SELECT id, embedding FROM models WHERE embedding IS NOT NULL AND deleted_at IS NULL'); + vectors = rows.map((r) => ({ id: r.id, vec: Float32Array.from(r.embedding) })); + vectorsLoadedAt = Date.now(); + } + return vectors; +} + +// Models ranked by how close their meaning is to the query: [{ id, score }], +// score = cosine similarity (vectors are unit length). +async function search(query, limit = 200) { + const [q] = await embeddingModel.embed([embeddingModel.QUERY_PREFIX + String(query).slice(0, 300)]); + const list = await loadVectors(); + return list + .map(({ id, vec }) => { + let score = 0; + for (let i = 0; i < vec.length; i++) score += vec[i] * q[i]; + return { id, score: Math.round(score * 1000) / 1000 }; + }) + .sort((a, b) => b.score - a.score) + .slice(0, limit); +} + +async function status() { + const { rows: [counts] } = await db.query( + `SELECT (SELECT COUNT(*)::int FROM models WHERE deleted_at IS NULL AND embedding IS NOT NULL) AS indexed, + (SELECT COUNT(*)::int ${PENDING}) AS pending`, [SALT]); + return { ...counts, error: lastError }; +} + +// Something outside the worker changed which models are searchable (trash, restore). +function invalidate() { + vectors = null; +} + +module.exports = { run, search, status, invalidate }; diff --git a/lib/fingerprint.js b/lib/fingerprint.js index 949a8d0..9a8a56e 100644 --- a/lib/fingerprint.js +++ b/lib/fingerprint.js @@ -11,6 +11,8 @@ const db = require('./db'); const store = require('./store'); const archives = require('./archives'); const thumbnails = require('./thumbnails'); +const autotag = require('./autotag'); +const embeddings = require('./embeddings'); const { MODEL_FORMATS, ARCHIVE_FORMATS, formatOf } = require('./fileProcessor'); const { PYTHON, runScript } = require('./python'); @@ -209,8 +211,10 @@ async function fingerprintEntries() { async function run() { for (;;) { - // Thumbnails go last so they can use what's inside newly indexed archives. - const worked = [await fingerprintFiles(), await indexArchives(), await fingerprintEntries(), await thumbnails.generate()]; + // Thumbnails, tags and search come after indexing so they can use what's inside + // newly indexed archives. + const worked = [await fingerprintFiles(), await indexArchives(), await fingerprintEntries(), + await thumbnails.generate(), await autotag.run(), await embeddings.run()]; if (!worked.some(Boolean)) return; } } diff --git a/lib/store.js b/lib/store.js index 9fab0e4..f2a905e 100644 --- a/lib/store.js +++ b/lib/store.js @@ -5,15 +5,16 @@ const { MODEL_FORMATS, ARCHIVE_FORMATS } = require('./fileProcessor'); const MODEL_SELECT = ` SELECT m.id, m.name, m.description, m.source_site, m.source_url, m.source_id, m.license, m.commercial_use, m.thumbnail_path, m.print_settings, m.date_added, m.date_modified, - m.notes, m.deleted_at, + m.notes, m.deleted_at, m.ai_tagged_at, d.id AS designer_id, d.name AS designer_name, d.profile_url AS designer_url, COALESCE(t.tags, '[]'::json) AS tags, + COALESCE(t.tag_sources, '{}'::json) AS tag_sources, COALESCE(p.projects, '[]'::json) AS projects, COALESCE(f.files, '[]'::json) AS files FROM models m LEFT JOIN designers d ON d.id = m.designer_id LEFT JOIN LATERAL ( - SELECT json_agg(tg.name ORDER BY tg.name) AS tags + SELECT json_agg(tg.name ORDER BY tg.name) AS tags, json_object_agg(tg.name, mt.source) AS tag_sources FROM model_tags mt JOIN tags tg ON tg.id = mt.tag_id WHERE mt.model_id = m.id ) t ON true @@ -295,20 +296,73 @@ async function insertFile(client, modelId, file) { } // ---------------------------------------------------------------- tags +async function tagId(client, name) { + const { rows } = await client.query( + `INSERT INTO tags (name) VALUES ($1) + ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, [name]); + return rows[0].id; +} + +// Sets a model's tags from an edit. A tag that stays keeps where it came from +// (rule / ai); new ones are yours. async function setTags(client, modelId, tagNames) { + const { rows: before } = await client.query('SELECT tag_id, source FROM model_tags WHERE model_id = $1', [modelId]); + const sourceOf = new Map(before.map((r) => [r.tag_id, r.source])); await client.query('DELETE FROM model_tags WHERE model_id = $1', [modelId]); for (const raw of tagNames) { const name = String(raw).trim(); if (!name) continue; - const { rows } = await client.query( - `INSERT INTO tags (name) VALUES ($1) - ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, [name]); + const id = await tagId(client, name); await client.query( - 'INSERT INTO model_tags (model_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', - [modelId, rows[0].id]); + 'INSERT INTO model_tags (model_id, tag_id, source) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING', + [modelId, id, sourceOf.get(id) || 'user']); } } +// Automatic tagging: makes the model's rule tags exactly `tagNames`. A tag the +// model already has from you or the AI is left as it is. +async function replaceRuleTags(modelId, tagNames) { + await db.transaction(async (client) => { + const ids = []; + for (const name of tagNames) ids.push(await tagId(client, name)); + await client.query( + `DELETE FROM model_tags WHERE model_id = $1 AND source = 'rule' AND NOT (tag_id = ANY($2::int[]))`, [modelId, ids]); + for (const id of ids) { + await client.query(`INSERT INTO model_tags (model_id, tag_id, source) VALUES ($1,$2,'rule') ON CONFLICT DO NOTHING`, + [modelId, id]); + } + }); +} + +async function removeTagsFrom(source) { + const { rowCount } = await db.query('DELETE FROM model_tags WHERE source = $1', [source]); + return rowCount; +} + +// AI tagging: adds the suggested tags (never removes any) and fills in the +// description if it's empty, or replaces it when asked to. +async function applyAiResult(modelId, { tags, description, overwriteDescription }) { + await db.transaction(async (client) => { + for (const name of tags) { + const id = await tagId(client, name); + await client.query(`INSERT INTO model_tags (model_id, tag_id, source) VALUES ($1,$2,'ai') ON CONFLICT DO NOTHING`, + [modelId, id]); + } + await client.query( + `UPDATE models SET ai_tagged_at = now(), + description = CASE WHEN $2::text <> '' AND ($3::boolean OR btrim(description) = '') THEN $2 ELSE description END + WHERE id = $1`, [modelId, description || '', Boolean(overwriteDescription)]); + }); +} + +// The library's most used tags, so AI suggestions reuse the same words. +async function topTags(limit = 150) { + const { rows } = await db.query( + `SELECT t.name FROM tags t JOIN model_tags mt ON mt.tag_id = t.id + GROUP BY t.name ORDER BY COUNT(*) DESC, t.name LIMIT $1`, [limit]); + return rows.map((r) => r.name); +} + async function getAllTags() { const { rows } = await db.query('SELECT name FROM tags ORDER BY name'); return rows.map((r) => r.name); @@ -478,7 +532,7 @@ module.exports = { trashModels, restoreModel, trashedModelIds, countTrashed, duplicateCandidateFiles, matchableCounts, mergeAndTrash, dismissDuplicateGroup, dismissedDuplicateSignatures, getFile, getArchiveEntries, getArchiveEntry, replaceArchiveEntries, - getAllTags, + getAllTags, replaceRuleTags, removeTagsFrom, applyAiResult, topTags, getAllProjects, getProject, createProject, updateProject, deleteProject, getSetting, setSetting, countUsers, getUserByEmail, getUserById, createUser, updateUserPassword, diff --git a/package-lock.json b/package-lock.json index c829c8d..6e5bdc6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@huggingface/transformers": "^3.8.1", "7z-wasm": "^1.2.0", "adm-zip": "^0.5.17", "bcryptjs": "^3.0.3", @@ -28,6 +29,502 @@ "nodemon": "^3.0.2" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@huggingface/jinja": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.10.tgz", + "integrity": "sha512-SgS1D1bglQ94ceD4ZCL6eayUDy9uV1xuyk61OjgSxU5GJh7upqZCKII0JoTYMc6wWsg3JUgaPRX0ElQzXPk3Cw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@huggingface/transformers": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@huggingface/transformers/-/transformers-3.8.1.tgz", + "integrity": "sha512-tsTk4zVjImqdqjS8/AOZg2yNLd1z9S5v+7oUPpXaasDRwEDhB+xnglK1k5cad26lL5/ZIaeREgWWy0bs9y9pPA==", + "license": "Apache-2.0", + "dependencies": { + "@huggingface/jinja": "^0.5.3", + "onnxruntime-node": "1.21.0", + "onnxruntime-web": "1.22.0-dev.20250409-89f8206ba4", + "sharp": "^0.34.1" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -40,6 +537,72 @@ "node": ">=18.0.0" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@types/node": { + "version": "26.5.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.1.tgz", + "integrity": "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, "node_modules/7z-wasm": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/7z-wasm/-/7z-wasm-1.2.0.tgz", @@ -159,6 +722,13 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/boolean": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz", + "integrity": "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", @@ -365,6 +935,40 @@ "ms": "2.0.0" } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -384,6 +988,21 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -464,12 +1083,30 @@ "node": ">= 0.4" } }, + "node_modules/es6-error": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/es6-error/-/es6-error-4.1.1.tgz", + "integrity": "sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==", + "license": "MIT" + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -556,6 +1193,12 @@ "node": ">= 0.8" } }, + "node_modules/flatbuffers": { + "version": "25.9.23", + "resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz", + "integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==", + "license": "Apache-2.0" + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -648,6 +1291,39 @@ "node": ">= 6" } }, + "node_modules/global-agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/global-agent/-/global-agent-3.0.0.tgz", + "integrity": "sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "es6-error": "^4.1.1", + "matcher": "^3.0.0", + "roarr": "^2.15.3", + "semver": "^7.3.2", + "serialize-error": "^7.0.1" + }, + "engines": { + "node": ">=10.0" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -660,6 +1336,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/guid-typescript": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz", + "integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==", + "license": "ISC" + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -670,6 +1352,18 @@ "node": ">=4" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -800,6 +1494,12 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" + }, "node_modules/jsonwebtoken": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", @@ -891,6 +1591,24 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/matcher": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", + "integrity": "sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1152,6 +1870,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/occt-import-js": { "version": "0.0.23", "resolved": "https://registry.npmjs.org/occt-import-js/-/occt-import-js-0.0.23.tgz", @@ -1170,6 +1897,49 @@ "node": ">= 0.8" } }, + "node_modules/onnxruntime-common": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.21.0.tgz", + "integrity": "sha512-Q632iLLrtCAVOTO65dh2+mNbQir/QNTVBG3h/QdZBpns7mZ0RYbLRBgGABPbpU9351AgYy7SJf1WaeVwMrBFPQ==", + "license": "MIT" + }, + "node_modules/onnxruntime-node": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/onnxruntime-node/-/onnxruntime-node-1.21.0.tgz", + "integrity": "sha512-NeaCX6WW2L8cRCSqy3bInlo5ojjQqu2fD3D+9W5qb5irwxhEyWKXeH2vZ8W9r6VxaMPUan+4/7NDwZMtouZxEw==", + "hasInstallScript": true, + "license": "MIT", + "os": [ + "win32", + "darwin", + "linux" + ], + "dependencies": { + "global-agent": "^3.0.0", + "onnxruntime-common": "1.21.0", + "tar": "^7.0.1" + } + }, + "node_modules/onnxruntime-web": { + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-0uS76OPgH0hWCPrFKlL8kYVV7ckM7t/36HfbgoFw6Nd0CZVVbQC4PkrR8mBX8LtNUFZO25IQBqV2Hx2ho3FlbQ==", + "license": "MIT", + "dependencies": { + "flatbuffers": "^25.1.24", + "guid-typescript": "^1.0.9", + "long": "^5.2.3", + "onnxruntime-common": "1.22.0-dev.20250409-89f8206ba4", + "platform": "^1.3.6", + "protobufjs": "^7.2.4" + } + }, + "node_modules/onnxruntime-web/node_modules/onnxruntime-common": { + "version": "1.22.0-dev.20250409-89f8206ba4", + "resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.22.0-dev.20250409-89f8206ba4.tgz", + "integrity": "sha512-vDJMkfCfb0b1A836rgHj+ORuZf4B4+cc2bASQtpeoJLueuFc5DuYwjIZUBrSvx/fO5IrLjLz+oTrB3pcGlhovQ==", + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1287,6 +2057,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "license": "MIT" + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -1332,6 +2108,29 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -1440,6 +2239,23 @@ "integrity": "sha512-Q3/5iHIKJeoE5doXrRy7C1vJer4F8uMYe8K6B8VQHqhGj5BCS1L3EkqNpKyUTERoHyKh7rMmiiZpw9iEUkCnPQ==", "license": "MIT" }, + "node_modules/roarr": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz", + "integrity": "sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==", + "license": "BSD-3-Clause", + "dependencies": { + "boolean": "^3.0.1", + "detect-node": "^2.0.4", + "globalthis": "^1.0.1", + "json-stringify-safe": "^5.0.1", + "semver-compare": "^1.0.0", + "sprintf-js": "^1.1.2" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -1478,6 +2294,12 @@ "node": ">=10" } }, + "node_modules/semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==", + "license": "MIT" + }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -1508,6 +2330,21 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/serialize-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-7.0.1.tgz", + "integrity": "sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.13.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/serve-static": { "version": "1.16.3", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", @@ -1529,6 +2366,50 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, "node_modules/side-channel": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", @@ -1623,6 +2504,12 @@ "node": ">= 10.x" } }, + "node_modules/sprintf-js": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -1716,6 +2603,25 @@ "nodetouch": "bin/nodetouch.js" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/type-fest": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.13.1.tgz", + "integrity": "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1742,6 +2648,12 @@ "dev": true, "license": "MIT" }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", diff --git a/package.json b/package.json index e348bd7..b209110 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "author": "", "license": "MIT", "dependencies": { + "@huggingface/transformers": "^3.8.1", "7z-wasm": "^1.2.0", "adm-zip": "^0.5.17", "bcryptjs": "^3.0.3", diff --git a/public/app.js b/public/app.js index 576f0d4..a350a57 100644 --- a/public/app.js +++ b/public/app.js @@ -10,9 +10,9 @@ let duplicateData = null; // last /api/duplicates response let duplicatePoll = null; // View preferences, remembered per browser. -const prefs = { view: 'grid', tagMatch: 'any', format: '', source: '', license: '', sort: 'newest', group: '', slicer: '', slicerTemplate: '' }; +const prefs = { view: 'grid', tagMatch: 'any', format: '', source: '', license: '', sort: 'newest', group: '', slicer: '', slicerTemplate: '', searchMode: 'words' }; const PREFS_KEY = 'printhive.prefs'; -const SPECIAL_SCOPES = ['all', 'duplicates', 'trash']; +const SPECIAL_SCOPES = ['all', 'duplicates', 'trash', 'ai']; // DOM const fileGrid = document.getElementById('fileGrid'); @@ -181,7 +181,7 @@ async function loadData() { let backgroundPoll = null; function watchBackgroundWork() { clearTimeout(backgroundPoll); - const pendingOf = (s) => (s.fingerprintsPending || 0) + (s.thumbnailsPending || 0); + const pendingOf = (s) => (s.fingerprintsPending || 0) + (s.thumbnailsPending || 0) + (s.tagsPending || 0); const pending = pendingOf(stats); if (!pending) return; backgroundPoll = setTimeout(async () => { @@ -330,15 +330,16 @@ function scopeTitle() { if (selectedScope === 'all') return 'All Models'; if (selectedScope === 'duplicates') return 'Duplicates'; if (selectedScope === 'trash') return 'Trash'; + if (selectedScope === 'ai') return 'AI & automatic tags'; const p = projects.find((x) => String(x.id) === String(selectedScope)); return p ? p.name : 'Collection'; } function updateScopeChrome() { contentTitle.textContent = scopeTitle(); - const dupes = selectedScope === 'duplicates'; - document.getElementById('filterBar').hidden = dupes; - document.querySelector('.view-options').hidden = dupes; + const panel = selectedScope === 'duplicates' || selectedScope === 'ai'; // pages without the model grid + document.getElementById('filterBar').hidden = panel; + document.querySelector('.view-options').hidden = panel; emptyTrashBtn.hidden = !(selectedScope === 'trash' && trashModels.length); document.querySelectorAll('.project-item[data-scope]').forEach((el) => { el.classList.toggle('active', el.dataset.scope === String(selectedScope)); @@ -348,11 +349,13 @@ function updateScopeChrome() { function renderModels() { updateScopeChrome(); - if (selectedScope === 'duplicates') { renderDuplicates(); return; } + if (selectedScope === 'duplicates') { clearTimeout(aiPoll); renderDuplicates(); return; } clearTimeout(duplicatePoll); + if (selectedScope === 'ai') { renderAiPanel(); return; } + clearTimeout(aiPoll); const list = getFilteredModels(); - resultCount.textContent = `${list.length} model${list.length === 1 ? '' : 's'}`; + resultCount.textContent = `${list.length} model${list.length === 1 ? '' : 's'}${searchNote()}`; fileGrid.className = prefs.view === 'list' ? 'file-grid list-view' : 'file-grid'; if (list.length === 0) { @@ -449,13 +452,26 @@ function getFilteredModels() { if (prefs.license) result = result.filter((m) => licenseKey(m) === prefs.license); const terms = searchInput.value.toLowerCase().split(/\s+/).filter(Boolean); - if (terms.length) result = result.filter((m) => terms.every((t) => searchText(m).includes(t))); + if (terms.length) { + const wordMatch = (m) => terms.every((t) => searchText(m).includes(t)); + const meaning = meaningResults(); + if (meaning) { + // Models close in meaning, plus anything matching the words; ranked by + // closeness, with word matches first. + const best = Math.max(0, ...meaning.values()); + const cutoff = Math.max(MEANING_MIN_SCORE, best - MEANING_SPREAD); + const rank = (m) => (meaning.get(m.id) || 0) + (wordMatch(m) ? 1 : 0); + return result.filter((m) => wordMatch(m) || (meaning.get(m.id) || 0) >= cutoff).sort((a, b) => rank(b) - rank(a)); + } + result = result.filter(wordMatch); + } return [...result].sort(SORTS[prefs.sort] || SORTS.newest); } function updateCounts() { document.getElementById('allFilesCount').textContent = models.length; + document.getElementById('aiBusy').hidden = !stats.aiRunning; document.getElementById('trashCount').textContent = stats.trash ?? 0; document.getElementById('duplicatesCount').textContent = stats.duplicateGroups ?? 0; } @@ -740,6 +756,32 @@ function setupEventListeners() { item.plate = chip.dataset.plate === 'all' ? null : Number(chip.dataset.plate); loadViewer(item); }); + document.getElementById('searchMode').addEventListener('click', (e) => { + const btn = e.target.closest('button[data-mode]'); + if (!btn) return; + prefs.searchMode = btn.dataset.mode; + savePrefs(); + syncSearchMode(); + renderModels(); + searchInput.focus(); + }); + syncSearchMode(); + document.getElementById('aiTagBtn').addEventListener('click', handleAiTagModel); + document.getElementById('aiTestBtn').addEventListener('click', handleAiTest); + document.getElementById('aiProvider').addEventListener('change', () => { + // A new provider starts from its own defaults. + ['aiModel', 'aiBaseUrl', 'aiPriceInput', 'aiPriceOutput'].forEach((id) => { document.getElementById(id).value = ''; }); + syncAiSettingsHints(); + }); + fileGrid.addEventListener('click', handleAiPanelClick); + fileGrid.addEventListener('submit', (e) => { + if (e.target.id === 'aiStartForm') handleAiStart(e); + if (e.target.id === 'ruleForm') handleAddRule(e); + }); + fileGrid.addEventListener('change', (e) => { + if (e.target.name === 'aiScope') updateAiEstimate(); + if (e.target.id === 'autotagEnabled') handleAutotagToggle(e.target.checked); + }); document.getElementById('openInSlicerBtn').addEventListener('click', () => { const item = detailItems.find((i) => itemKey(i) === activeItemKey); if (item) openInSlicer(item); @@ -916,6 +958,8 @@ async function openSettingsModal() { const s = settings[input.dataset.setting]; if (s && !s.secret && s.value) input.value = s.value; }); + document.getElementById('aiTestResult').textContent = ''; + await syncAiSettingsHints(); await renderLicenseRules(); } catch (err) { document.getElementById('settingsStatus').textContent = 'Could not load settings: ' + err.message; @@ -975,8 +1019,7 @@ function openModelDetail(modelId) { } // Tags / projects / print settings - document.getElementById('detailTags').innerHTML = (model.tags && model.tags.length) - ? model.tags.map((t) => `${escapeHtml(t)}`).join('') : 'No tags'; + renderDetailTags(model); const ps = model.projects || []; document.getElementById('detailProjects').innerHTML = ps.length ? ps.map((p) => `
${escapeHtml(p.name)}
`).join('') : 'Not in any collection'; @@ -1132,6 +1175,463 @@ async function loadArchiveEntries(modelId, fileId) { } } +// ---------------------------------------------------------------- JSON helpers +async function getJson(url, options) { + const res = await fetch(url, options); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); + return data; +} + +function postJson(url, body = {}, method = 'POST') { + return getJson(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +const money = (n) => (n == null ? '—' : n > 0 && n < 0.01 ? `$${n.toFixed(4)}` : `$${n.toFixed(2)}`); +const count = (n) => Number(n || 0).toLocaleString(); +const plural = (n, word) => `${count(n)} ${word}${Number(n) === 1 ? '' : 's'}`; + +// ---------------------------------------------------------------- search by meaning +// Meaning search asks the server, which ranks models with a local embedding model. +// Word matches show while it answers. +const MEANING_MIN_SCORE = 0.55; +const MEANING_SPREAD = 0.1; // how far below the best match results may go +let meaningSearch = { query: '', results: null, error: null }; +let meaningPending = null; +let meaningTimer = null; + +function syncSearchMode() { + document.querySelectorAll('#searchMode button').forEach((b) => b.classList.toggle('active', b.dataset.mode === prefs.searchMode)); + searchInput.placeholder = prefs.searchMode === 'meaning' + ? 'Describe what you’re looking for, e.g. “wall hook for headphones” ( / )' + : 'Search names, tags, notes, files, designers… ( / )'; +} + +function meaningResults() { + const q = searchInput.value.trim(); + if (prefs.searchMode !== 'meaning' || !q) return null; + if (meaningSearch.query === q) return meaningSearch.results; // null after an error: words only + if (meaningPending !== q) { + meaningPending = q; + clearTimeout(meaningTimer); + meaningTimer = setTimeout(async () => { + try { + const data = await getJson(`/api/search?q=${encodeURIComponent(q)}`); + meaningSearch = { query: q, results: new Map(data.results.map((r) => [r.id, r.score])), error: null }; + } catch (err) { + meaningSearch = { query: q, results: null, error: err.message }; + } + meaningPending = null; + if (searchInput.value.trim() === q) renderModels(); + }, 250); + } + return null; +} + +function searchNote() { + const q = searchInput.value.trim(); + if (prefs.searchMode !== 'meaning' || !q) return ''; + if (meaningSearch.query !== q) return ' · searching by meaning…'; + return meaningSearch.error ? ' · meaning search unavailable, matching words' : ' · by meaning'; +} + +// ---------------------------------------------------------------- AI & automatic tags +let aiPoll = null; +let aiState = { runs: [] }; +let aiProviders = null; +const AI_KEY_LINKS = { + deepseek: 'https://platform.deepseek.com/api_keys', + openrouter: 'https://openrouter.ai/settings/keys', + openai: 'https://platform.openai.com/api-keys', +}; +const RUN_STATUS = { running: 'Tagging', paused: 'Paused', stopped: 'Stopped', done: 'Finished', failed: 'Stopped' }; + +function renderDetailTags(model) { + const sources = model.tag_sources || {}; + const titles = { ai: 'Added by AI', rule: 'Added by an automatic tag rule' }; + document.getElementById('detailTags').innerHTML = (model.tags && model.tags.length) + ? model.tags.map((t) => { + const source = sources[t]; + return `${escapeHtml(t)}`; + }).join('') + : 'No tags'; +} + +async function syncAiSettingsHints() { + try { + if (!aiProviders) aiProviders = (await getJson('/api/ai/status')).providers; + } catch { return; } + const key = document.getElementById('aiProvider').value; + const preset = aiProviders[key] || {}; + const prices = preset.prices || {}; + document.getElementById('aiModel').placeholder = preset.model + || (key === 'openrouter' ? 'provider/model, from openrouter.ai/models' : key === 'custom' ? 'e.g. llava' : 'Model name'); + document.getElementById('aiBaseUrl').placeholder = preset.baseUrl || 'e.g. http://localhost:11434/v1'; + document.getElementById('aiPriceInput').placeholder = prices.input != null ? String(prices.input) : 'optional'; + document.getElementById('aiPriceOutput').placeholder = prices.output != null ? String(prices.output) : 'optional'; + const link = document.getElementById('aiKeyLink'); + link.hidden = !AI_KEY_LINKS[key]; + if (AI_KEY_LINKS[key]) { + link.href = AI_KEY_LINKS[key]; + link.textContent = `Get your ${preset.label} key.`; + } +} + +async function handleAiTest() { + const out = document.getElementById('aiTestResult'); + const btn = document.getElementById('aiTestBtn'); + out.className = 'form-hint ai-test-result'; + out.textContent = 'Saving and asking the AI to tag a sample model…'; + btn.disabled = true; + try { + await postJson('/api/settings', settingsPayload(), 'PUT'); + const r = await postJson('/api/ai/test'); + out.classList.add('is-ok'); + out.innerHTML = `✓ Working: ${escapeHtml(r.model)} answered in ${(r.ms / 1000).toFixed(1)} s with + ${r.tags.map(escapeHtml).join(', ') || 'no tags'}. Images: ${escapeHtml(r.images)}. + ${r.cost != null ? `That cost ${money(r.cost)}.` : ''}`; + } catch (err) { + out.classList.add('is-error'); + out.textContent = `✗ ${err.message}`; + } finally { + btn.disabled = false; + } +} + +// Tags the open model on its own, then refreshes its tags and description. +async function handleAiTagModel() { + const id = Number(fileDetailModal.dataset.currentModelId); + const btn = document.getElementById('aiTagBtn'); + btn.disabled = true; + btn.textContent = 'Tagging…'; + try { + let run = await postJson('/api/ai/runs', { scope: 'ids', ids: [id] }); + const started = Date.now(); + while (run.status === 'running') { + if (Date.now() - started > 180000) throw new Error('It’s taking a while; follow it on the AI & auto-tags page.'); + await sleep(1500); + run = await getJson(`/api/ai/runs/${run.id}`); + } + const item = (run.recent || []).find((i) => i.model_id === id); + if (run.error) throw new Error(run.error); + if (item && item.status === 'error') throw new Error(item.error); + await loadData(); + const model = findModel(id); + if (model && String(fileDetailModal.dataset.currentModelId) === String(id)) { + renderDetailTags(model); + document.getElementById('detailDescription').textContent = model.description || 'No description'; + } + } catch (err) { + alert(`AI tagging didn’t work: ${err.message}`); + } finally { + btn.disabled = false; + btn.textContent = '✦ Tag with AI'; + } +} + +function aiPanelHtml() { + return ` +
+

AI tags & descriptions

+

Each model's name, file names go to your AI provider, which + suggests tags and a short description. Tags are only ever added. Descriptions are filled in where empty, + unless you choose to replace them.

+
+
+
+ + +
+ + +
+ + +
+
+
+
+
+

Search by meaning

+

Switch the search box to Meaning to find models by what they are, so + “wall hook” also finds a “coat hanger bracket”. It runs on your server with a small built-in model: + free, and nothing is sent anywhere.

+
+
+
`; +} + +function runHtml(run, detail) { + const processed = run.done + run.failed; + const pct = run.total ? Math.round((processed / run.total) * 100) : 0; + const options = run.options || {}; + const cost = options.pricesKnown ? money(run.cost_usd) : 'cost unknown (add prices in Settings)'; + const canStop = run.status === 'running' || run.status === 'paused'; + const canResume = ['paused', 'stopped', 'failed'].includes(run.status) && (processed < run.total || run.failed > 0); + const feed = detail && detail.recent.length ? ` + ` : ''; + return ` +
+
+ ${RUN_STATUS[run.status] || escapeHtml(run.status)} + ${count(processed)} of ${plural(run.total, 'model')}${run.failed ? ` · ${count(run.failed)} failed` : ''} + ${escapeHtml(run.provider || '')} · ${escapeHtml(run.model || '')} · ${formatDate(run.created_at)} + + ${canStop ? `` : ''} + ${canResume ? `` : ''} + +
+
+
${count(run.prompt_tokens)} tokens in · ${count(run.completion_tokens)} out · ${cost}${options.maxCost ? ` (limit $${options.maxCost})` : ''}
+ ${run.error ? `
${escapeHtml(run.error)}
` : ''} + ${run.note ? `
${escapeHtml(run.note)}
` : ''} + ${feed} +
`; +} + +function autotagCardHtml(t) { + const groups = new Map(); + for (const rule of t.builtIn) { + if (!groups.has(rule.group)) groups.set(rule.group, []); + groups.get(rule.group).push(rule); + } + return ` +
+

Automatic tags

+ +
+

Adds a tag when whole words in a model's name, file names or archive contents match a rule: + “hook” matches Wall_Hooks.stl but not facebook.stl. Tags you add yourself are never changed.

+
+ ${t.enabled ? '' : ` +
Automatic tagging is off. Tags it already added are kept. +
`} +
${[...groups.entries()].map(([group, rules]) => ` +
+

${escapeHtml(group)}

+
${rules.map((r) => `${escapeHtml(r.tag)}`).join('')}
+
`).join('')} +
+

Your rules

+
${t.rules.length ? t.rules.map((r) => ` +
+ ${escapeHtml(r.phrase)} → ${escapeHtml(r.tag)} + +
`).join('') : '

None yet. Add a word to look for and the tag it should add.

'} +
+
+ + + + +
+
+ +
`; +} + +// Builds the page once; later calls (polls, data reloads) only refresh the live +// parts so nothing you're typing is lost. +async function renderAiPanel() { + fileGrid.className = 'ai-panel'; + resultCount.textContent = ''; + if (document.getElementById('aiStartForm')) { refreshAiPanel(); return; } + fileGrid.innerHTML = '

Loading…

'; + try { + aiState.tagRules = await getJson('/api/autotag'); + } catch (err) { + if (selectedScope === 'ai') fileGrid.innerHTML = `

Couldn’t load this page

${escapeHtml(err.message)}

`; + return; + } + if (selectedScope !== 'ai') return; + fileGrid.innerHTML = aiPanelHtml(); + renderRulesCard(); + await refreshAiPanel(); + updateAiEstimate(); +} + +function renderRulesCard() { + const card = document.getElementById('aiRulesCard'); + if (card && aiState.tagRules) card.innerHTML = autotagCardHtml(aiState.tagRules); + renderAiLive(); +} + +async function refreshAiPanel() { + clearTimeout(aiPoll); + if (selectedScope !== 'ai') return; + let status; + let runs; + let detail = null; + try { + [status, runs] = await Promise.all([getJson('/api/ai/status'), getJson('/api/ai/runs')]); + const focus = runs.find((r) => r.status === 'running') || runs[0]; + if (focus) detail = await getJson(`/api/ai/runs/${focus.id}`); + } catch { + aiPoll = setTimeout(refreshAiPanel, 5000); + return; + } + if (selectedScope !== 'ai' || !document.getElementById('aiRuns')) return; + const running = runs.some((r) => r.status === 'running'); + const changed = aiState.expectChange || (aiState.runs || []).some((r) => r.status === 'running'); + aiState = { ...aiState, status, runs, detail }; + renderAiLive(); + if (changed && !running) { + aiState.expectChange = false; + loadData(); // the run changed tags and descriptions + } + if (running || status.search.pending || status.autotag.pending) { + aiPoll = setTimeout(refreshAiPanel, running ? 2000 : 4000); + } +} + +function renderAiLive() { + const { status, runs = [], detail } = aiState; + if (!status || !document.getElementById('aiRuns')) return; + const c = status.config; + document.getElementById('aiSetup').innerHTML = status.problem + ? `
Not set up yet. ${escapeHtml(status.problem)} +
` + : `
Using ${escapeHtml(c.label)} · ${escapeHtml(c.model)} · + thumbnails ${c.sendImages ? 'on' : 'off'} +
`; + document.getElementById('aiImagesNote').textContent = c.sendImages ? ' and thumbnail' : ''; + document.getElementById('aiUntaggedCount').textContent = count(status.counts.untagged); + document.getElementById('aiAllCount').textContent = count(status.counts.all); + const running = runs.some((r) => r.status === 'running'); + const start = document.getElementById('aiStartBtn'); + start.disabled = Boolean(status.problem) || running; + start.title = running ? 'A run is going: wait for it, or stop it' : ''; + document.getElementById('aiRuns').innerHTML = runs.length + ? runs.slice(0, 5).map((r) => runHtml(r, detail && detail.id === r.id ? detail : null)).join('') + : '

No runs yet.

'; + const s = status.search; + document.getElementById('aiSearch').textContent = s.error + ? `Indexing is paused: ${s.error}` + : `${plural(s.indexed, 'model')} indexed${s.pending ? `, ${count(s.pending)} to go…` : '.'}`; + const pending = document.getElementById('autotagPending'); + if (pending) { + pending.textContent = status.autotag.enabled && status.autotag.pending + ? `Checking ${plural(status.autotag.pending, 'model')}…` : ''; + } +} + +async function updateAiEstimate() { + const el = document.getElementById('aiEstimate'); + const choice = fileGrid.querySelector('input[name="aiScope"]:checked'); + if (!el || !choice) return; + try { + const est = await postJson('/api/ai/estimate', { scope: choice.value }); + el.textContent = !est.count + ? 'Nothing to tag.' + : est.cost == null + ? `${plural(est.count, 'model')}. Add prices in Settings to see what it will cost.` + : `Roughly ${money(est.cost)} for ${plural(est.count, 'model')}.`; + } catch { + el.textContent = ''; + } +} + +async function handleAiStart(e) { + e.preventDefault(); + const scope = fileGrid.querySelector('input[name="aiScope"]:checked').value; + const maxCost = document.getElementById('aiMaxCost').value; + const btn = document.getElementById('aiStartBtn'); + btn.disabled = true; + try { + await postJson('/api/ai/runs', { + scope, + overwriteDescriptions: document.getElementById('aiOverwrite').checked, + maxCost: maxCost ? Number(maxCost) : null, + }); + aiState.expectChange = true; + await refreshAiPanel(); + updateAiEstimate(); + } catch (err) { + alert(err.message); + btn.disabled = false; + } +} + +async function handleAiPanelClick(e) { + const open = e.target.closest('[data-open-model]'); + if (open) { openModelDetail(Number(open.dataset.openModel)); return; } + const btn = e.target.closest('[data-ai]'); + if (!btn) return; + const id = btn.dataset.id; + try { + switch (btn.dataset.ai) { + case 'settings': + await openSettingsModal(); + document.getElementById('aiSettings').scrollIntoView({ block: 'start' }); + break; + case 'stop': + btn.disabled = true; + await postJson(`/api/ai/runs/${id}/stop`); + aiState.expectChange = true; + await refreshAiPanel(); + break; + case 'resume': + btn.disabled = true; + await postJson(`/api/ai/runs/${id}/resume`); + await refreshAiPanel(); + break; + case 'delete-rule': + aiState.tagRules = await getJson(`/api/autotag/rules/${id}`, { method: 'DELETE' }); + renderRulesCard(); + loadData(); + break; + case 'reapply': + aiState.tagRules = await postJson('/api/autotag/reapply'); + renderRulesCard(); + loadData(); + break; + case 'remove-rule-tags': + if (!confirm('Remove every tag that automatic tagging added? Tags you or the AI added stay.')) return; + aiState.tagRules = await postJson('/api/autotag', { enabled: false, removeTags: true }, 'PUT'); + renderRulesCard(); + loadData(); + break; + } + } catch (err) { + alert(err.message); + btn.disabled = false; + } +} + +async function handleAutotagToggle(enabled) { + try { + aiState.tagRules = await postJson('/api/autotag', { enabled }, 'PUT'); + renderRulesCard(); + loadData(); + } catch (err) { + alert(err.message); + } +} + +async function handleAddRule(e) { + e.preventDefault(); + const phrase = document.getElementById('rulePhrase').value; + const tag = document.getElementById('ruleTag').value; + try { + aiState.tagRules = await postJson('/api/autotag/rules', { phrase, tag }); + renderRulesCard(); + loadData(); + } catch (err) { + alert(err.message); + } +} + // ---------------------------------------------------------------- slicer hand-off // A browser can't see which apps are installed, so the slicer is chosen once per // browser. Slicers that accept an open-link download the file from a short-lived @@ -1716,11 +2216,7 @@ async function handleLicenseChange(name, value) { async function handleSaveSettings(e) { e.preventDefault(); const status = document.getElementById('settingsStatus'); - // Only send fields the user actually typed into (blank = leave unchanged). - const payload = {}; - document.querySelectorAll('#settingsForm [data-setting]').forEach((input) => { - if (input.value.trim()) payload[input.dataset.setting] = input.value.trim(); - }); + const payload = settingsPayload(); if (Object.keys(payload).length === 0) { status.textContent = 'Nothing to save.'; return; @@ -1740,6 +2236,18 @@ async function handleSaveSettings(e) { } } +// Only fields you typed into are sent (blank = leave unchanged), except +// data-clear-blank ones, where blank means "use the default". +function settingsPayload() { + const payload = {}; + document.querySelectorAll('#settingsForm [data-setting]').forEach((input) => { + const value = input.value.trim(); + if (value) payload[input.dataset.setting] = value; + else if ('clearBlank' in input.dataset) payload[input.dataset.setting] = null; + }); + return payload; +} + async function handleClearSetting(key) { if (!confirm('Clear this value?')) return; const status = document.getElementById('settingsStatus'); diff --git a/public/index.html b/public/index.html index 3ec8380..05769d1 100644 --- a/public/index.html +++ b/public/index.html @@ -94,6 +94,10 @@ Duplicates 0 +
+ AI & auto-tags + +
Trash 0 @@ -138,6 +142,10 @@
+
+ + +
+ + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

+ Key status: —. + + The key is stored on your server and only sent to the provider. Leave model, address and prices + blank to use the provider's defaults (shown greyed out). Prices are only used to show what runs cost. +

+
+ + +
+
+

Import sources

@@ -474,6 +533,7 @@
+