Compare commits
3 commits
78c225f232
...
c7c39f99fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7c39f99fa | ||
|
|
2796fab38c | ||
|
|
0f49fe8c1a |
20 changed files with 2779 additions and 32 deletions
|
|
@ -15,3 +15,4 @@ Dockerfile
|
||||||
.dockerignore
|
.dockerignore
|
||||||
docker-compose.yml
|
docker-compose.yml
|
||||||
extracted
|
extracted
|
||||||
|
models
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,3 +10,4 @@ glb/
|
||||||
tools/venv/
|
tools/venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
extracted/
|
extracted/
|
||||||
|
models/
|
||||||
|
|
|
||||||
10
Dockerfile
10
Dockerfile
|
|
@ -18,11 +18,19 @@ RUN python3 -m venv /app/tools/venv \
|
||||||
|
|
||||||
# Node deps (cached unless package files change).
|
# Node deps (cached unless package files change).
|
||||||
COPY package.json package-lock.json ./
|
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.
|
# App source.
|
||||||
COPY . .
|
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
|
||||||
|
# (No process.exit: forcing an exit while onnxruntime's threads are up aborts node.)
|
||||||
|
RUN node -e "require('./lib/embeddingModel').embed(['test']).then(() => console.log('embedding model ready'), (err) => { console.error(err); process.exitCode = 1; })"
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
PORT=3000
|
PORT=3000
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
|
||||||
10
README.md
10
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.
|
- **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 <kbd>/</kbd> to search.
|
- **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 <kbd>/</kbd> 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".
|
- **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).
|
- **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.
|
- **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) |
|
| FlareSolverr URL | `FLARESOLVERR_URL` | Read Cloudflare-protected sites (MakerWorld) |
|
||||||
| MakerWorld auth token | `MAKERWORLD_TOKEN` | MakerWorld downloads (bearer token from your session) |
|
| MakerWorld auth token | `MAKERWORLD_TOKEN` | MakerWorld downloads (bearer token from your session) |
|
||||||
| Printables auth token | `PRINTABLES_TOKEN` | Optional; account-gated Printables files |
|
| 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
|
## 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".
|
- 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.
|
- The commercial-use badge is a best-effort read of the license — always confirm on the source page before selling.
|
||||||
|
|
|
||||||
|
|
@ -154,3 +154,57 @@ CREATE TABLE IF NOT EXISTS duplicate_dismissals (
|
||||||
signature TEXT PRIMARY KEY,
|
signature TEXT PRIMARY KEY,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
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)
|
||||||
|
);
|
||||||
|
|
|
||||||
236
lib/ai.js
Normal file
236
lib/ai.js
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
231
lib/aiRuns.js
Normal file
231
lib/aiRuns.js
Normal file
|
|
@ -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 };
|
||||||
155
lib/autotag.js
Normal file
155
lib/autotag.js
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
46
lib/embeddingModel.js
Normal file
46
lib/embeddingModel.js
Normal file
|
|
@ -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 };
|
||||||
88
lib/embeddings.js
Normal file
88
lib/embeddings.js
Normal file
|
|
@ -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 };
|
||||||
|
|
@ -11,6 +11,8 @@ const db = require('./db');
|
||||||
const store = require('./store');
|
const store = require('./store');
|
||||||
const archives = require('./archives');
|
const archives = require('./archives');
|
||||||
const thumbnails = require('./thumbnails');
|
const thumbnails = require('./thumbnails');
|
||||||
|
const autotag = require('./autotag');
|
||||||
|
const embeddings = require('./embeddings');
|
||||||
const { MODEL_FORMATS, ARCHIVE_FORMATS, formatOf } = require('./fileProcessor');
|
const { MODEL_FORMATS, ARCHIVE_FORMATS, formatOf } = require('./fileProcessor');
|
||||||
const { PYTHON, runScript } = require('./python');
|
const { PYTHON, runScript } = require('./python');
|
||||||
|
|
||||||
|
|
@ -209,8 +211,10 @@ async function fingerprintEntries() {
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
// Thumbnails go last so they can use what's inside newly indexed archives.
|
// Thumbnails, tags and search come after indexing so they can use what's inside
|
||||||
const worked = [await fingerprintFiles(), await indexArchives(), await fingerprintEntries(), await thumbnails.generate()];
|
// 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;
|
if (!worked.some(Boolean)) return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
70
lib/store.js
70
lib/store.js
|
|
@ -5,15 +5,16 @@ const { MODEL_FORMATS, ARCHIVE_FORMATS } = require('./fileProcessor');
|
||||||
const MODEL_SELECT = `
|
const MODEL_SELECT = `
|
||||||
SELECT m.id, m.name, m.description, m.source_site, m.source_url, m.source_id,
|
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.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,
|
d.id AS designer_id, d.name AS designer_name, d.profile_url AS designer_url,
|
||||||
COALESCE(t.tags, '[]'::json) AS tags,
|
COALESCE(t.tags, '[]'::json) AS tags,
|
||||||
|
COALESCE(t.tag_sources, '{}'::json) AS tag_sources,
|
||||||
COALESCE(p.projects, '[]'::json) AS projects,
|
COALESCE(p.projects, '[]'::json) AS projects,
|
||||||
COALESCE(f.files, '[]'::json) AS files
|
COALESCE(f.files, '[]'::json) AS files
|
||||||
FROM models m
|
FROM models m
|
||||||
LEFT JOIN designers d ON d.id = m.designer_id
|
LEFT JOIN designers d ON d.id = m.designer_id
|
||||||
LEFT JOIN LATERAL (
|
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
|
FROM model_tags mt JOIN tags tg ON tg.id = mt.tag_id
|
||||||
WHERE mt.model_id = m.id
|
WHERE mt.model_id = m.id
|
||||||
) t ON true
|
) t ON true
|
||||||
|
|
@ -295,20 +296,73 @@ async function insertFile(client, modelId, file) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- tags
|
// ---------------------------------------------------------------- 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) {
|
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]);
|
await client.query('DELETE FROM model_tags WHERE model_id = $1', [modelId]);
|
||||||
for (const raw of tagNames) {
|
for (const raw of tagNames) {
|
||||||
const name = String(raw).trim();
|
const name = String(raw).trim();
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
const { rows } = await client.query(
|
const id = await tagId(client, name);
|
||||||
`INSERT INTO tags (name) VALUES ($1)
|
|
||||||
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`, [name]);
|
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO model_tags (model_id, tag_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
'INSERT INTO model_tags (model_id, tag_id, source) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING',
|
||||||
[modelId, rows[0].id]);
|
[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() {
|
async function getAllTags() {
|
||||||
const { rows } = await db.query('SELECT name FROM tags ORDER BY name');
|
const { rows } = await db.query('SELECT name FROM tags ORDER BY name');
|
||||||
return rows.map((r) => r.name);
|
return rows.map((r) => r.name);
|
||||||
|
|
@ -478,7 +532,7 @@ module.exports = {
|
||||||
trashModels, restoreModel, trashedModelIds, countTrashed,
|
trashModels, restoreModel, trashedModelIds, countTrashed,
|
||||||
duplicateCandidateFiles, matchableCounts, mergeAndTrash, dismissDuplicateGroup, dismissedDuplicateSignatures,
|
duplicateCandidateFiles, matchableCounts, mergeAndTrash, dismissDuplicateGroup, dismissedDuplicateSignatures,
|
||||||
getFile, getArchiveEntries, getArchiveEntry, replaceArchiveEntries,
|
getFile, getArchiveEntries, getArchiveEntry, replaceArchiveEntries,
|
||||||
getAllTags,
|
getAllTags, replaceRuleTags, removeTagsFrom, applyAiResult, topTags,
|
||||||
getAllProjects, getProject, createProject, updateProject, deleteProject,
|
getAllProjects, getProject, createProject, updateProject, deleteProject,
|
||||||
getSetting, setSetting,
|
getSetting, setSetting,
|
||||||
countUsers, getUserByEmail, getUserById, createUser, updateUserPassword,
|
countUsers, getUserByEmail, getUserById, createUser, updateUserPassword,
|
||||||
|
|
|
||||||
912
package-lock.json
generated
912
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -17,6 +17,7 @@
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@huggingface/transformers": "^3.8.1",
|
||||||
"7z-wasm": "^1.2.0",
|
"7z-wasm": "^1.2.0",
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.17",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "^3.0.3",
|
||||||
|
|
|
||||||
540
public/app.js
540
public/app.js
|
|
@ -10,9 +10,9 @@ let duplicateData = null; // last /api/duplicates response
|
||||||
let duplicatePoll = null;
|
let duplicatePoll = null;
|
||||||
|
|
||||||
// View preferences, remembered per browser.
|
// 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 PREFS_KEY = 'printhive.prefs';
|
||||||
const SPECIAL_SCOPES = ['all', 'duplicates', 'trash'];
|
const SPECIAL_SCOPES = ['all', 'duplicates', 'trash', 'ai'];
|
||||||
|
|
||||||
// DOM
|
// DOM
|
||||||
const fileGrid = document.getElementById('fileGrid');
|
const fileGrid = document.getElementById('fileGrid');
|
||||||
|
|
@ -181,7 +181,7 @@ async function loadData() {
|
||||||
let backgroundPoll = null;
|
let backgroundPoll = null;
|
||||||
function watchBackgroundWork() {
|
function watchBackgroundWork() {
|
||||||
clearTimeout(backgroundPoll);
|
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);
|
const pending = pendingOf(stats);
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
backgroundPoll = setTimeout(async () => {
|
backgroundPoll = setTimeout(async () => {
|
||||||
|
|
@ -330,15 +330,16 @@ function scopeTitle() {
|
||||||
if (selectedScope === 'all') return 'All Models';
|
if (selectedScope === 'all') return 'All Models';
|
||||||
if (selectedScope === 'duplicates') return 'Duplicates';
|
if (selectedScope === 'duplicates') return 'Duplicates';
|
||||||
if (selectedScope === 'trash') return 'Trash';
|
if (selectedScope === 'trash') return 'Trash';
|
||||||
|
if (selectedScope === 'ai') return 'AI & automatic tags';
|
||||||
const p = projects.find((x) => String(x.id) === String(selectedScope));
|
const p = projects.find((x) => String(x.id) === String(selectedScope));
|
||||||
return p ? p.name : 'Collection';
|
return p ? p.name : 'Collection';
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateScopeChrome() {
|
function updateScopeChrome() {
|
||||||
contentTitle.textContent = scopeTitle();
|
contentTitle.textContent = scopeTitle();
|
||||||
const dupes = selectedScope === 'duplicates';
|
const panel = selectedScope === 'duplicates' || selectedScope === 'ai'; // pages without the model grid
|
||||||
document.getElementById('filterBar').hidden = dupes;
|
document.getElementById('filterBar').hidden = panel;
|
||||||
document.querySelector('.view-options').hidden = dupes;
|
document.querySelector('.view-options').hidden = panel;
|
||||||
emptyTrashBtn.hidden = !(selectedScope === 'trash' && trashModels.length);
|
emptyTrashBtn.hidden = !(selectedScope === 'trash' && trashModels.length);
|
||||||
document.querySelectorAll('.project-item[data-scope]').forEach((el) => {
|
document.querySelectorAll('.project-item[data-scope]').forEach((el) => {
|
||||||
el.classList.toggle('active', el.dataset.scope === String(selectedScope));
|
el.classList.toggle('active', el.dataset.scope === String(selectedScope));
|
||||||
|
|
@ -348,11 +349,13 @@ function updateScopeChrome() {
|
||||||
|
|
||||||
function renderModels() {
|
function renderModels() {
|
||||||
updateScopeChrome();
|
updateScopeChrome();
|
||||||
if (selectedScope === 'duplicates') { renderDuplicates(); return; }
|
if (selectedScope === 'duplicates') { clearTimeout(aiPoll); renderDuplicates(); return; }
|
||||||
clearTimeout(duplicatePoll);
|
clearTimeout(duplicatePoll);
|
||||||
|
if (selectedScope === 'ai') { renderAiPanel(); return; }
|
||||||
|
clearTimeout(aiPoll);
|
||||||
|
|
||||||
const list = getFilteredModels();
|
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';
|
fileGrid.className = prefs.view === 'list' ? 'file-grid list-view' : 'file-grid';
|
||||||
|
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
|
|
@ -449,13 +452,26 @@ function getFilteredModels() {
|
||||||
if (prefs.license) result = result.filter((m) => licenseKey(m) === prefs.license);
|
if (prefs.license) result = result.filter((m) => licenseKey(m) === prefs.license);
|
||||||
|
|
||||||
const terms = searchInput.value.toLowerCase().split(/\s+/).filter(Boolean);
|
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);
|
return [...result].sort(SORTS[prefs.sort] || SORTS.newest);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCounts() {
|
function updateCounts() {
|
||||||
document.getElementById('allFilesCount').textContent = models.length;
|
document.getElementById('allFilesCount').textContent = models.length;
|
||||||
|
document.getElementById('aiBusy').hidden = !stats.aiRunning;
|
||||||
document.getElementById('trashCount').textContent = stats.trash ?? 0;
|
document.getElementById('trashCount').textContent = stats.trash ?? 0;
|
||||||
document.getElementById('duplicatesCount').textContent = stats.duplicateGroups ?? 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);
|
item.plate = chip.dataset.plate === 'all' ? null : Number(chip.dataset.plate);
|
||||||
loadViewer(item);
|
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', () => {
|
document.getElementById('openInSlicerBtn').addEventListener('click', () => {
|
||||||
const item = detailItems.find((i) => itemKey(i) === activeItemKey);
|
const item = detailItems.find((i) => itemKey(i) === activeItemKey);
|
||||||
if (item) openInSlicer(item);
|
if (item) openInSlicer(item);
|
||||||
|
|
@ -916,6 +958,8 @@ async function openSettingsModal() {
|
||||||
const s = settings[input.dataset.setting];
|
const s = settings[input.dataset.setting];
|
||||||
if (s && !s.secret && s.value) input.value = s.value;
|
if (s && !s.secret && s.value) input.value = s.value;
|
||||||
});
|
});
|
||||||
|
document.getElementById('aiTestResult').textContent = '';
|
||||||
|
await syncAiSettingsHints();
|
||||||
await renderLicenseRules();
|
await renderLicenseRules();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('settingsStatus').textContent = 'Could not load settings: ' + err.message;
|
document.getElementById('settingsStatus').textContent = 'Could not load settings: ' + err.message;
|
||||||
|
|
@ -975,8 +1019,7 @@ function openModelDetail(modelId) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tags / projects / print settings
|
// Tags / projects / print settings
|
||||||
document.getElementById('detailTags').innerHTML = (model.tags && model.tags.length)
|
renderDetailTags(model);
|
||||||
? model.tags.map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join('') : 'No tags';
|
|
||||||
const ps = model.projects || [];
|
const ps = model.projects || [];
|
||||||
document.getElementById('detailProjects').innerHTML = ps.length
|
document.getElementById('detailProjects').innerHTML = ps.length
|
||||||
? ps.map((p) => `<div>${escapeHtml(p.name)}</div>`).join('') : 'Not in any collection';
|
? ps.map((p) => `<div>${escapeHtml(p.name)}</div>`).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 `<span class="tag${titles[source] ? ` tag-${source}` : ''}"${titles[source] ? ` title="${titles[source]}"` : ''}>${escapeHtml(t)}</span>`;
|
||||||
|
}).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: <strong>${escapeHtml(r.model)}</strong> answered in ${(r.ms / 1000).toFixed(1)} s with
|
||||||
|
<em>${r.tags.map(escapeHtml).join(', ') || 'no tags'}</em>. 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 `
|
||||||
|
<section class="ai-card">
|
||||||
|
<div class="ai-card-head"><h3>AI tags & descriptions</h3></div>
|
||||||
|
<p class="form-hint">Each model's name, file names<span id="aiImagesNote"></span> 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.</p>
|
||||||
|
<div id="aiSetup"></div>
|
||||||
|
<form id="aiStartForm" class="ai-start">
|
||||||
|
<div class="ai-scope">
|
||||||
|
<label><input type="radio" name="aiScope" value="untagged" checked> Models not tagged by AI yet
|
||||||
|
<span class="count-inline" id="aiUntaggedCount"></span></label>
|
||||||
|
<label><input type="radio" name="aiScope" value="all"> All models
|
||||||
|
<span class="count-inline" id="aiAllCount"></span></label>
|
||||||
|
</div>
|
||||||
|
<label><input type="checkbox" id="aiOverwrite"> Replace descriptions that are already filled in</label>
|
||||||
|
<label>Stop when the run has cost
|
||||||
|
<span class="money-input">$<input type="number" id="aiMaxCost" min="0" step="0.01" placeholder="no limit"></span></label>
|
||||||
|
<div class="ai-start-row">
|
||||||
|
<button type="submit" class="btn btn-primary" id="aiStartBtn">Start tagging</button>
|
||||||
|
<span id="aiEstimate" class="form-hint"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="aiRuns" class="ai-runs"></div>
|
||||||
|
</section>
|
||||||
|
<section class="ai-card">
|
||||||
|
<div class="ai-card-head"><h3>Search by meaning</h3></div>
|
||||||
|
<p class="form-hint">Switch the search box to <strong>Meaning</strong> 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.</p>
|
||||||
|
<div id="aiSearch" class="form-hint"></div>
|
||||||
|
</section>
|
||||||
|
<section class="ai-card" id="aiRulesCard"></section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ? `
|
||||||
|
<ul class="ai-feed">${detail.recent.map((i) => `
|
||||||
|
<li>
|
||||||
|
<button type="button" class="link-btn" data-open-model="${i.model_id}">${escapeHtml(i.name || `Model ${i.model_id}`)}</button>
|
||||||
|
${i.status === 'done'
|
||||||
|
? (i.tags || []).map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join('')
|
||||||
|
: `<span class="ai-run-error">${escapeHtml(i.error || 'failed')}</span>`}
|
||||||
|
</li>`).join('')}
|
||||||
|
</ul>` : '';
|
||||||
|
return `
|
||||||
|
<div class="ai-run">
|
||||||
|
<div class="ai-run-head">
|
||||||
|
<span class="pill pill-${run.status}">${RUN_STATUS[run.status] || escapeHtml(run.status)}</span>
|
||||||
|
<span>${count(processed)} of ${plural(run.total, 'model')}${run.failed ? ` · ${count(run.failed)} failed` : ''}</span>
|
||||||
|
<span class="ai-run-meta">${escapeHtml(run.provider || '')} · ${escapeHtml(run.model || '')} · ${formatDate(run.created_at)}</span>
|
||||||
|
<span class="ai-run-actions">
|
||||||
|
${canStop ? `<button type="button" class="btn btn-secondary btn-sm" data-ai="stop" data-id="${run.id}">Stop</button>` : ''}
|
||||||
|
${canResume ? `<button type="button" class="btn btn-secondary btn-sm" data-ai="resume" data-id="${run.id}">${run.failed && processed >= run.total ? 'Retry failed' : 'Resume'}</button>` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress"><span style="width:${pct}%"></span></div>
|
||||||
|
<div class="ai-run-stats">${count(run.prompt_tokens)} tokens in · ${count(run.completion_tokens)} out · ${cost}${options.maxCost ? ` (limit $${options.maxCost})` : ''}</div>
|
||||||
|
${run.error ? `<div class="ai-run-error">${escapeHtml(run.error)}</div>` : ''}
|
||||||
|
${run.note ? `<div class="form-hint">${escapeHtml(run.note)}</div>` : ''}
|
||||||
|
${feed}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 `
|
||||||
|
<div class="ai-card-head">
|
||||||
|
<h3>Automatic tags</h3>
|
||||||
|
<label class="switch"><input type="checkbox" id="autotagEnabled"${t.enabled ? ' checked' : ''}> ${t.enabled ? 'On' : 'Off'}</label>
|
||||||
|
</div>
|
||||||
|
<p class="form-hint">Adds a tag when whole words in a model's name, file names or archive contents match a rule:
|
||||||
|
“hook” matches <em>Wall_Hooks.stl</em> but not <em>facebook.stl</em>. Tags you add yourself are never changed.</p>
|
||||||
|
<div id="autotagPending" class="form-hint"></div>
|
||||||
|
${t.enabled ? '' : `
|
||||||
|
<div class="ai-callout">Automatic tagging is off. Tags it already added are kept.
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" data-ai="remove-rule-tags">Remove those tags</button></div>`}
|
||||||
|
<div class="rule-groups">${[...groups.entries()].map(([group, rules]) => `
|
||||||
|
<div class="rule-group">
|
||||||
|
<h4>${escapeHtml(group)}</h4>
|
||||||
|
<div class="rule-tags">${rules.map((r) => `<span class="tag" title="Matches: ${escapeHtml(r.phrases.join(', '))}">${escapeHtml(r.tag)}</span>`).join('')}</div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
<h4 class="rule-subhead">Your rules</h4>
|
||||||
|
<div class="rule-list">${t.rules.length ? t.rules.map((r) => `
|
||||||
|
<div class="rule-row">
|
||||||
|
<span><strong>${escapeHtml(r.phrase)}</strong> → <span class="tag">${escapeHtml(r.tag)}</span></span>
|
||||||
|
<button type="button" class="link-btn" data-ai="delete-rule" data-id="${r.id}">remove</button>
|
||||||
|
</div>`).join('') : '<p class="form-hint">None yet. Add a word to look for and the tag it should add.</p>'}
|
||||||
|
</div>
|
||||||
|
<form id="ruleForm" class="rule-form">
|
||||||
|
<input type="text" id="rulePhrase" placeholder="Word or phrase, e.g. benchy" aria-label="Word or phrase" required>
|
||||||
|
<span aria-hidden="true">→</span>
|
||||||
|
<input type="text" id="ruleTag" placeholder="Tag, e.g. calibration" aria-label="Tag to add" required>
|
||||||
|
<button type="submit" class="btn btn-secondary btn-sm">Add rule</button>
|
||||||
|
</form>
|
||||||
|
<div class="ai-start-row">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" data-ai="reapply">Re-apply rules to every model</button>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 = '<div class="empty-state"><p>Loading…</p></div>';
|
||||||
|
try {
|
||||||
|
aiState.tagRules = await getJson('/api/autotag');
|
||||||
|
} catch (err) {
|
||||||
|
if (selectedScope === 'ai') fileGrid.innerHTML = `<div class="empty-state"><h3>Couldn’t load this page</h3><p>${escapeHtml(err.message)}</p></div>`;
|
||||||
|
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
|
||||||
|
? `<div class="ai-callout is-warning"><strong>Not set up yet.</strong> ${escapeHtml(status.problem)}
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" data-ai="settings">Open Settings</button></div>`
|
||||||
|
: `<div class="ai-callout">Using <strong>${escapeHtml(c.label)}</strong> · <code>${escapeHtml(c.model)}</code> ·
|
||||||
|
thumbnails ${c.sendImages ? 'on' : 'off'}
|
||||||
|
<button type="button" class="btn-link" data-ai="settings">Change</button></div>`;
|
||||||
|
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('')
|
||||||
|
: '<p class="form-hint">No runs yet.</p>';
|
||||||
|
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
|
// ---------------------------------------------------------------- slicer hand-off
|
||||||
// A browser can't see which apps are installed, so the slicer is chosen once per
|
// 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
|
// 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) {
|
async function handleSaveSettings(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const status = document.getElementById('settingsStatus');
|
const status = document.getElementById('settingsStatus');
|
||||||
// Only send fields the user actually typed into (blank = leave unchanged).
|
const payload = settingsPayload();
|
||||||
const payload = {};
|
|
||||||
document.querySelectorAll('#settingsForm [data-setting]').forEach((input) => {
|
|
||||||
if (input.value.trim()) payload[input.dataset.setting] = input.value.trim();
|
|
||||||
});
|
|
||||||
if (Object.keys(payload).length === 0) {
|
if (Object.keys(payload).length === 0) {
|
||||||
status.textContent = 'Nothing to save.';
|
status.textContent = 'Nothing to save.';
|
||||||
return;
|
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) {
|
async function handleClearSetting(key) {
|
||||||
if (!confirm('Clear this value?')) return;
|
if (!confirm('Clear this value?')) return;
|
||||||
const status = document.getElementById('settingsStatus');
|
const status = document.getElementById('settingsStatus');
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,10 @@
|
||||||
<span>Duplicates</span>
|
<span>Duplicates</span>
|
||||||
<span class="count" id="duplicatesCount">0</span>
|
<span class="count" id="duplicatesCount">0</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="project-item" data-scope="ai">
|
||||||
|
<span>AI & auto-tags</span>
|
||||||
|
<span class="count ai-busy" id="aiBusy" hidden title="AI tagging is running">●</span>
|
||||||
|
</div>
|
||||||
<div class="project-item" data-scope="trash">
|
<div class="project-item" data-scope="trash">
|
||||||
<span>Trash</span>
|
<span>Trash</span>
|
||||||
<span class="count" id="trashCount">0</span>
|
<span class="count" id="trashCount">0</span>
|
||||||
|
|
@ -138,6 +142,10 @@
|
||||||
<div id="filterBar" class="filter-bar">
|
<div id="filterBar" class="filter-bar">
|
||||||
<input type="search" id="searchInput" class="search-input"
|
<input type="search" id="searchInput" class="search-input"
|
||||||
placeholder="Search names, tags, notes, files, designers… ( / )" aria-label="Search models">
|
placeholder="Search names, tags, notes, files, designers… ( / )" aria-label="Search models">
|
||||||
|
<div class="seg search-mode" id="searchMode" title="Match the words you type, or find models by what they are">
|
||||||
|
<button type="button" data-mode="words">Words</button>
|
||||||
|
<button type="button" data-mode="meaning">Meaning</button>
|
||||||
|
</div>
|
||||||
<select id="filterFormat" aria-label="Filter by format"><option value="">Any format</option></select>
|
<select id="filterFormat" aria-label="Filter by format"><option value="">Any format</option></select>
|
||||||
<select id="filterSource" aria-label="Filter by source"><option value="">Any source</option></select>
|
<select id="filterSource" aria-label="Filter by source"><option value="">Any source</option></select>
|
||||||
<select id="filterLicense" aria-label="Filter by license">
|
<select id="filterLicense" aria-label="Filter by license">
|
||||||
|
|
@ -228,6 +236,57 @@
|
||||||
<button type="button" class="btn btn-secondary btn-sm" id="chooseSlicerBtn">Choose slicer…</button>
|
<button type="button" class="btn btn-secondary btn-sm" id="chooseSlicerBtn">Choose slicer…</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 class="settings-group-title" id="aiSettings">AI tagging</h3>
|
||||||
|
<div class="ai-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="aiProvider">Provider</label>
|
||||||
|
<select id="aiProvider" data-setting="ai_provider">
|
||||||
|
<option value="deepseek">DeepSeek</option>
|
||||||
|
<option value="openrouter">OpenRouter</option>
|
||||||
|
<option value="openai">OpenAI</option>
|
||||||
|
<option value="custom">Other OpenAI-compatible (Ollama, LM Studio…)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="aiApiKey">API key</label>
|
||||||
|
<input type="password" id="aiApiKey" data-setting="ai_api_key" placeholder="Paste your API key" autocomplete="off">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="aiModel">Model</label>
|
||||||
|
<input type="text" id="aiModel" data-setting="ai_model" data-clear-blank autocomplete="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="aiBaseUrl">API address</label>
|
||||||
|
<input type="text" id="aiBaseUrl" data-setting="ai_base_url" data-clear-blank autocomplete="off" spellcheck="false">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="aiPriceInput">Input price ($ per million tokens)</label>
|
||||||
|
<input type="number" id="aiPriceInput" data-setting="ai_price_input" data-clear-blank min="0" step="0.001">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="aiPriceOutput">Output price ($ per million tokens)</label>
|
||||||
|
<input type="number" id="aiPriceOutput" data-setting="ai_price_output" data-clear-blank min="0" step="0.001">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="aiSendImages">Send thumbnails</label>
|
||||||
|
<select id="aiSendImages" data-setting="ai_send_images">
|
||||||
|
<option value="true">Yes: better tags, slightly higher cost</option>
|
||||||
|
<option value="false">No: names and files only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="form-hint">
|
||||||
|
Key status: <span class="setting-status" data-status="ai_api_key">—</span>.
|
||||||
|
<a id="aiKeyLink" target="_blank" rel="noopener" hidden></a>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div class="ai-test-row">
|
||||||
|
<button type="button" class="btn btn-secondary btn-sm" id="aiTestBtn">Save & test connection</button>
|
||||||
|
<button type="button" class="btn btn-link clear-setting" data-setting="ai_api_key">Clear key</button>
|
||||||
|
</div>
|
||||||
|
<div id="aiTestResult" class="form-hint ai-test-result"></div>
|
||||||
|
|
||||||
<h3 class="settings-group-title">Import sources</h3>
|
<h3 class="settings-group-title">Import sources</h3>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
@ -474,6 +533,7 @@
|
||||||
<div class="form-actions" id="detailActions">
|
<div class="form-actions" id="detailActions">
|
||||||
<button class="btn btn-danger" id="deleteFileBtn">Move to trash</button>
|
<button class="btn btn-danger" id="deleteFileBtn">Move to trash</button>
|
||||||
<button class="btn btn-secondary" id="addFilesBtn">Add files</button>
|
<button class="btn btn-secondary" id="addFilesBtn">Add files</button>
|
||||||
|
<button class="btn btn-secondary" id="aiTagBtn" title="Suggest tags and a description with AI">✦ Tag with AI</button>
|
||||||
<button class="btn btn-primary" id="editModelBtn">Edit</button>
|
<button class="btn btn-primary" id="editModelBtn">Edit</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions" id="trashActions" hidden>
|
<div class="form-actions" id="trashActions" hidden>
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ header {
|
||||||
.tag.exclude { background: var(--danger-soft); color: var(--danger); border-color: var(--danger); text-decoration: line-through; }
|
.tag.exclude { background: var(--danger-soft); color: var(--danger); border-color: var(--danger); text-decoration: line-through; }
|
||||||
|
|
||||||
/* ---- form fields ---- */
|
/* ---- form fields ---- */
|
||||||
.search-input, input[type="text"], input[type="search"], input[type="url"], input[type="password"], input[type="file"], textarea, select {
|
.search-input, input[type="text"], input[type="search"], input[type="url"], input[type="password"], input[type="number"], input[type="file"], textarea, select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
@ -375,3 +375,60 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
|
||||||
.dup-card .btn { margin-top: 6px; }
|
.dup-card .btn { margin-top: 6px; }
|
||||||
.rec-badge { align-self: flex-start; font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 10px; background: rgba(76, 195, 138, 0.16); color: var(--success); }
|
.rec-badge { align-self: flex-start; font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 10px; background: rgba(76, 195, 138, 0.16); color: var(--success); }
|
||||||
.warn { color: var(--accent); }
|
.warn { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ---- AI & automatic tags ---- */
|
||||||
|
.ai-panel { display: flex; flex-direction: column; gap: 18px; max-width: 920px; }
|
||||||
|
.ai-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.ai-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||||
|
.ai-card h3 { font-size: 15px; font-weight: 600; }
|
||||||
|
.ai-card h4 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-dim); margin-bottom: 6px; }
|
||||||
|
.ai-card .form-hint { margin: 0; }
|
||||||
|
.ai-callout { font-size: 13px; color: var(--text-muted); background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 10px 12px; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; }
|
||||||
|
.ai-callout.is-warning { border-color: var(--accent); color: var(--text); }
|
||||||
|
.ai-callout code { font-family: var(--mono); font-size: 12px; color: var(--text); }
|
||||||
|
.ai-callout .btn-sm, .ai-run-actions .btn-sm, .ai-start-row .btn-sm, .rule-form .btn-sm, .ai-test-row .btn-sm { align-self: auto; }
|
||||||
|
.ai-start { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.ai-start label { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; color: var(--text-muted); cursor: pointer; }
|
||||||
|
.ai-scope { display: flex; flex-wrap: wrap; gap: 8px 22px; }
|
||||||
|
.ai-start input[type="radio"], .ai-start input[type="checkbox"], .switch input { width: auto; margin: 0; accent-color: var(--accent); }
|
||||||
|
.count-inline { font-family: var(--mono); font-size: 12px; color: var(--text-dim); }
|
||||||
|
.money-input { display: inline-flex; align-items: center; gap: 4px; }
|
||||||
|
.money-input input { width: 100px; padding: 6px 8px; }
|
||||||
|
.ai-start-row { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; }
|
||||||
|
.ai-runs { display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.ai-run { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; background: var(--surface-2); }
|
||||||
|
.ai-run-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 13px; color: var(--text); }
|
||||||
|
.ai-run-meta { color: var(--text-dim); font-size: 12px; }
|
||||||
|
.ai-run-actions { margin-left: auto; display: flex; gap: 8px; }
|
||||||
|
.ai-run-stats { font-family: var(--mono); font-size: 12px; color: var(--text-muted); }
|
||||||
|
.ai-run-error { color: var(--danger); font-size: 13px; }
|
||||||
|
.pill { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; padding: 3px 8px; border-radius: 10px; background: var(--surface-3); color: var(--text-muted); }
|
||||||
|
.pill-running { background: var(--accent-soft); color: var(--accent); }
|
||||||
|
.pill-done { background: rgba(76, 195, 138, 0.16); color: var(--success); }
|
||||||
|
.pill-failed { background: var(--danger-soft); color: var(--danger); }
|
||||||
|
.progress { height: 6px; background: var(--surface-3); border-radius: 3px; overflow: hidden; }
|
||||||
|
.progress span { display: block; height: 100%; background: var(--accent); transition: width 0.4s; }
|
||||||
|
.ai-feed { list-style: none; display: flex; flex-direction: column; gap: 6px; max-height: 260px; overflow-y: auto; font-size: 13px; }
|
||||||
|
.ai-feed li { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 6px; }
|
||||||
|
.ai-card .tag { cursor: default; padding: 2px 8px; font-size: 11px; }
|
||||||
|
.rule-groups { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; }
|
||||||
|
.rule-tags { display: flex; flex-wrap: wrap; gap: 5px; }
|
||||||
|
.rule-subhead { margin-top: 4px; }
|
||||||
|
.rule-list { display: flex; flex-direction: column; }
|
||||||
|
.rule-row { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 14px; color: var(--text-muted); }
|
||||||
|
.rule-form { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||||
|
.rule-form input { flex: 1 1 160px; width: auto; padding: 7px 10px; font-size: 13px; }
|
||||||
|
.switch { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-muted); cursor: pointer; }
|
||||||
|
.tag.tag-ai::before { content: "✦ "; color: var(--accent); }
|
||||||
|
.tag.tag-rule { border-style: dashed; }
|
||||||
|
.ai-busy { background: transparent !important; color: var(--accent) !important; font-size: 10px; animation: pulse 1.4s ease-in-out infinite; }
|
||||||
|
@keyframes pulse { 50% { opacity: 0.35; } }
|
||||||
|
.filter-bar .search-mode { align-self: stretch; align-items: center; }
|
||||||
|
.filter-bar .search-mode button { padding: 5px 10px; font-size: 12px; }
|
||||||
|
.ai-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
|
||||||
|
.ai-grid .form-group { margin-bottom: 12px; }
|
||||||
|
.ai-test-row { display: flex; align-items: center; gap: 12px; margin-top: 10px; }
|
||||||
|
.ai-test-result { min-height: 1em; margin-top: 8px; }
|
||||||
|
.ai-test-result.is-ok { color: var(--success); }
|
||||||
|
.ai-test-result.is-error { color: var(--danger); }
|
||||||
|
@media (max-width: 640px) { .ai-grid { grid-template-columns: 1fr; } .ai-run-actions { margin-left: 0; } }
|
||||||
|
|
|
||||||
185
server.js
185
server.js
|
|
@ -24,6 +24,10 @@ const duplicates = require('./lib/duplicates');
|
||||||
const archives = require('./lib/archives');
|
const archives = require('./lib/archives');
|
||||||
const thumbnails = require('./lib/thumbnails');
|
const thumbnails = require('./lib/thumbnails');
|
||||||
const handoff = require('./lib/handoff');
|
const handoff = require('./lib/handoff');
|
||||||
|
const ai = require('./lib/ai');
|
||||||
|
const aiRuns = require('./lib/aiRuns');
|
||||||
|
const autotag = require('./lib/autotag');
|
||||||
|
const embeddings = require('./lib/embeddings');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
@ -108,8 +112,22 @@ const SETTINGS = {
|
||||||
smtp_user: { env: 'SMTP_USER', label: 'SMTP username', secret: false },
|
smtp_user: { env: 'SMTP_USER', label: 'SMTP username', secret: false },
|
||||||
smtp_pass: { env: 'SMTP_PASS', label: 'SMTP password', secret: true },
|
smtp_pass: { env: 'SMTP_PASS', label: 'SMTP password', secret: true },
|
||||||
smtp_from: { env: 'SMTP_FROM', label: 'From address', secret: false },
|
smtp_from: { env: 'SMTP_FROM', label: 'From address', secret: false },
|
||||||
|
ai_provider: { env: 'AI_PROVIDER', label: 'AI provider', secret: false },
|
||||||
|
ai_base_url: { env: 'AI_BASE_URL', label: 'AI API address', secret: false },
|
||||||
|
ai_api_key: { env: 'AI_API_KEY', label: 'AI API key', secret: true },
|
||||||
|
ai_model: { env: 'AI_MODEL', label: 'AI model', secret: false },
|
||||||
|
ai_send_images: { env: 'AI_SEND_IMAGES', label: 'Send thumbnails to the AI', secret: false },
|
||||||
|
ai_price_input: { env: 'AI_PRICE_INPUT', label: 'Input price ($ per million tokens)', secret: false },
|
||||||
|
ai_price_output: { env: 'AI_PRICE_OUTPUT', label: 'Output price ($ per million tokens)', secret: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const AI_SETTING_KEYS = Object.keys(SETTINGS).filter((k) => k.startsWith('ai_'));
|
||||||
|
async function resolveAiConfig() {
|
||||||
|
const values = {};
|
||||||
|
for (const key of AI_SETTING_KEYS) values[key] = (await resolveSetting(key)).value;
|
||||||
|
return ai.configFrom(values);
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveSetting(key) {
|
async function resolveSetting(key) {
|
||||||
const fromDb = await store.getSetting(key);
|
const fromDb = await store.getSetting(key);
|
||||||
if (fromDb) return { value: fromDb, source: 'database' };
|
if (fromDb) return { value: fromDb, source: 'database' };
|
||||||
|
|
@ -357,6 +375,7 @@ app.put('/api/models/:id', async (req, res, next) => {
|
||||||
if (updates.license !== undefined) updates.commercial_use = await resolveCommercial(updates.license);
|
if (updates.license !== undefined) updates.commercial_use = await resolveCommercial(updates.license);
|
||||||
const model = await store.updateModel(req.params.id, updates);
|
const model = await store.updateModel(req.params.id, updates);
|
||||||
if (!model) return res.status(404).json({ error: 'Model not found' });
|
if (!model) return res.status(404).json({ error: 'Model not found' });
|
||||||
|
fingerprint.kick(); // names and tags feed automatic tags and search
|
||||||
res.json(publicModel(model));
|
res.json(publicModel(model));
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
@ -1058,9 +1077,10 @@ async function findDuplicateGroups() {
|
||||||
|
|
||||||
app.get('/api/library/stats', async (req, res, next) => {
|
app.get('/api/library/stats', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const [trash, groups, pending, thumbnailsPending] = await Promise.all([
|
const [trash, groups, pending, thumbnailsPending, tagsPending, aiRunning] = await Promise.all([
|
||||||
store.countTrashed(), findDuplicateGroups(), fingerprint.pendingCount(), thumbnails.pendingCount()]);
|
store.countTrashed(), findDuplicateGroups(), fingerprint.pendingCount(), thumbnails.pendingCount(),
|
||||||
res.json({ trash, duplicateGroups: groups.length, fingerprintsPending: pending, thumbnailsPending });
|
autotag.pendingCount(), aiRuns.hasActiveRun()]);
|
||||||
|
res.json({ trash, duplicateGroups: groups.length, fingerprintsPending: pending, thumbnailsPending, tagsPending, aiRunning });
|
||||||
} catch (e) { next(e); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1120,6 +1140,162 @@ function parseJson(value, fallback) {
|
||||||
try { return JSON.parse(value); } catch { return fallback; }
|
try { return JSON.parse(value); } catch { return fallback; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- AI tagging
|
||||||
|
// Which models a run covers: not yet tagged by AI, everything, or a chosen list.
|
||||||
|
async function aiScopeIds(scope, ids) {
|
||||||
|
if (scope === 'ids') return (Array.isArray(ids) ? ids : []).map(Number).filter(Number.isInteger);
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`SELECT id FROM models WHERE deleted_at IS NULL ${scope === 'all' ? '' : 'AND ai_tagged_at IS NULL'} ORDER BY id`);
|
||||||
|
return rows.map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/ai/status', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const config = await resolveAiConfig();
|
||||||
|
const { rows: [counts] } = await db.query(
|
||||||
|
`SELECT COUNT(*)::int AS all, COUNT(*) FILTER (WHERE ai_tagged_at IS NULL)::int AS untagged
|
||||||
|
FROM models WHERE deleted_at IS NULL`);
|
||||||
|
const { apiKey, ...safe } = config;
|
||||||
|
res.json({
|
||||||
|
config: { ...safe, keySet: Boolean(apiKey) },
|
||||||
|
problem: ai.missingConfig(config),
|
||||||
|
providers: ai.PROVIDERS,
|
||||||
|
counts,
|
||||||
|
search: await embeddings.status(),
|
||||||
|
autotag: { enabled: await autotag.isEnabled(), pending: await autotag.pendingCount() },
|
||||||
|
});
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tags a sample model with the saved settings, sending a library thumbnail too
|
||||||
|
// when images are on, to show whether the key, model and image input all work.
|
||||||
|
app.post('/api/ai/test', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const config = await resolveAiConfig();
|
||||||
|
const problem = ai.missingConfig(config);
|
||||||
|
if (problem) return res.status(400).json({ error: problem });
|
||||||
|
let image = null;
|
||||||
|
if (config.sendImages) {
|
||||||
|
const { rows } = await db.query(
|
||||||
|
`SELECT id, thumbnail_path FROM models WHERE thumbnail_path IS NOT NULL AND deleted_at IS NULL ORDER BY id DESC LIMIT 10`);
|
||||||
|
image = rows.map((m) => aiRuns.thumbnailDataUrl(m)).find(Boolean) || null;
|
||||||
|
}
|
||||||
|
const started = Date.now();
|
||||||
|
const sample = { name: 'Flexi articulated dragon', files: [{ originalName: 'flexi_dragon_print_in_place.3mf' }], tags: [] };
|
||||||
|
try {
|
||||||
|
const result = await ai.describeModel(config, sample, { image });
|
||||||
|
res.json({
|
||||||
|
ok: true, model: config.model, ms: Date.now() - started, tags: result.tags, description: result.description,
|
||||||
|
images: !image ? 'not tried' : result.imageUsed ? 'accepted' : 'not supported by this model',
|
||||||
|
usage: result.usage, cost: result.cost,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
res.status(502).json({ error: ai.fatalMessage(err) || err.message });
|
||||||
|
}
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/ai/estimate', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const ids = await aiScopeIds(req.body && req.body.scope, req.body && req.body.ids);
|
||||||
|
res.json({ count: ids.length, ...ai.estimate(ids.length, await resolveAiConfig()) });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/ai/runs', async (req, res, next) => {
|
||||||
|
try { res.json(await aiRuns.listRuns()); } catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/ai/runs', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { scope, ids, overwriteDescriptions, maxCost } = req.body || {};
|
||||||
|
const run = await aiRuns.createRun(await aiScopeIds(scope, ids), { overwriteDescriptions, maxCost });
|
||||||
|
res.status(201).json(run);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/ai/runs/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const run = await aiRuns.getRun(Number(req.params.id));
|
||||||
|
if (!run) return res.status(404).json({ error: 'Run not found' });
|
||||||
|
res.json(run);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/ai/runs/:id/stop', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!(await aiRuns.stop(Number(req.params.id)))) return res.status(409).json({ error: 'This run isn’t going' });
|
||||||
|
res.json(await aiRuns.getRun(Number(req.params.id)));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/ai/runs/:id/resume', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!(await aiRuns.resume(Number(req.params.id)))) return res.status(409).json({ error: 'Nothing left to do in this run' });
|
||||||
|
res.json(await aiRuns.getRun(Number(req.params.id)));
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- semantic search
|
||||||
|
app.get('/api/search', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const q = String(req.query.q || '').trim();
|
||||||
|
if (!q) return res.json({ results: [] });
|
||||||
|
try {
|
||||||
|
res.json({ results: await embeddings.search(q) });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(503).json({ error: `Search by meaning isn’t available: ${err.message}` });
|
||||||
|
}
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- automatic tags
|
||||||
|
async function autotagState() {
|
||||||
|
return {
|
||||||
|
enabled: await autotag.isEnabled(),
|
||||||
|
pending: await autotag.pendingCount(),
|
||||||
|
builtIn: autotag.BUILT_IN_RULES,
|
||||||
|
rules: await autotag.listRules(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/autotag', async (req, res, next) => {
|
||||||
|
try { res.json(await autotagState()); } catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/autotag', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { enabled, removeTags } = req.body || {};
|
||||||
|
const removed = await autotag.setEnabled(Boolean(enabled), { removeTags: Boolean(removeTags) });
|
||||||
|
fingerprint.kick();
|
||||||
|
res.json({ ...(await autotagState()), removed });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/autotag/rules', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await autotag.addRule(req.body && req.body.phrase, req.body && req.body.tag);
|
||||||
|
fingerprint.kick();
|
||||||
|
res.status(201).json(await autotagState());
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/autotag/rules/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await autotag.removeRule(Number(req.params.id));
|
||||||
|
fingerprint.kick();
|
||||||
|
res.json(await autotagState());
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/autotag/reapply', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await autotag.reapply();
|
||||||
|
fingerprint.kick();
|
||||||
|
res.json(await autotagState());
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
// Central error handler.
|
// Central error handler.
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
if (!err.status) console.error('Request error:', err.message);
|
if (!err.status) console.error('Request error:', err.message);
|
||||||
|
|
@ -1129,6 +1305,7 @@ app.use((err, req, res, next) => {
|
||||||
db.init()
|
db.init()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
app.listen(PORT, () => console.log(`Printhive running on http://localhost:${PORT}`));
|
app.listen(PORT, () => console.log(`Printhive running on http://localhost:${PORT}`));
|
||||||
fingerprint.kick(); // backfill fingerprints for files stored before this ran
|
fingerprint.kick(); // backfill fingerprints, tags and the search index
|
||||||
|
return aiRuns.init(resolveAiConfig);
|
||||||
})
|
})
|
||||||
.catch((err) => { console.error('Failed to start (DB init):', err); process.exit(1); });
|
.catch((err) => { console.error('Failed to start (DB init):', err); process.exit(1); });
|
||||||
|
|
|
||||||
111
test/ai.test.js
Normal file
111
test/ai.test.js
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
// AI tagging client (lib/ai.js), against a fake OpenAI-compatible server.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const http = require('http');
|
||||||
|
const ai = require('../lib/ai');
|
||||||
|
|
||||||
|
// Starts a server that answers each request with the next handler's result.
|
||||||
|
async function fakeProvider(handlers) {
|
||||||
|
const requests = [];
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c) => { body += c; });
|
||||||
|
req.on('end', () => {
|
||||||
|
const parsed = JSON.parse(body);
|
||||||
|
requests.push({ path: req.url, auth: req.headers.authorization, body: parsed });
|
||||||
|
const [status, payload] = handlers[Math.min(requests.length, handlers.length) - 1](parsed);
|
||||||
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const config = ai.configFrom({
|
||||||
|
ai_provider: 'custom', ai_base_url: `http://127.0.0.1:${server.address().port}/v1/`, ai_api_key: 'sk-test',
|
||||||
|
ai_model: 'test-model', ai_price_input: '0.3', ai_price_output: '1.2',
|
||||||
|
});
|
||||||
|
return { config, requests, close: () => new Promise((resolve) => server.close(resolve)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const reply = (content, usage = { prompt_tokens: 1000, completion_tokens: 100 }) =>
|
||||||
|
[200, { choices: [{ message: { content } }], usage }];
|
||||||
|
|
||||||
|
const MODEL = { name: 'Flexi Dragon', files: [{ originalName: 'dragon_PLA.3mf', bbox: { x: 120, y: 40, z: 30 } }], tags: ['toy'] };
|
||||||
|
|
||||||
|
test('presets fill in DeepSeek by default, and your own values win', () => {
|
||||||
|
const deepseek = ai.configFrom({ ai_api_key: 'k' });
|
||||||
|
assert.equal(deepseek.provider, 'deepseek');
|
||||||
|
assert.equal(deepseek.baseUrl, 'https://api.deepseek.com');
|
||||||
|
assert.equal(deepseek.model, 'deepseek-flash');
|
||||||
|
assert.equal(ai.missingConfig(deepseek), null);
|
||||||
|
assert.match(ai.missingConfig(ai.configFrom({})), /API key/);
|
||||||
|
|
||||||
|
const openrouter = ai.configFrom({ ai_provider: 'openrouter', ai_api_key: 'k' });
|
||||||
|
assert.match(ai.missingConfig(openrouter), /model/);
|
||||||
|
assert.equal(openrouter.prices.input, null);
|
||||||
|
// A local server needs no key.
|
||||||
|
assert.equal(ai.missingConfig(ai.configFrom({ ai_provider: 'custom', ai_base_url: 'http://localhost:11434/v1', ai_model: 'llava' })), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the prompt carries the model details and, when given, its image', () => {
|
||||||
|
const [system, user] = ai.buildMessages(MODEL, { image: 'data:image/png;base64,AAAA', knownTags: ['dragon', 'toy'] });
|
||||||
|
assert.equal(system.role, 'system');
|
||||||
|
const text = user.content[0].text;
|
||||||
|
assert.match(text, /Name: Flexi Dragon/);
|
||||||
|
assert.match(text, /Files: dragon_PLA\.3mf/);
|
||||||
|
assert.match(text, /Size: 120 × 40 × 30 mm/);
|
||||||
|
assert.match(text, /Tags already used in this library: dragon, toy/);
|
||||||
|
assert.deepEqual(user.content[1], { type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } });
|
||||||
|
assert.equal(typeof ai.buildMessages(MODEL)[1].content, 'string');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replies are read leniently and tags tidied', () => {
|
||||||
|
const r = ai.parseReply('Sure!\n```json\n{"tags": ["#Dragon", "3D print", "Print_In Place", "dragon", "a", "b", "c", "d", "e", "f"], "description": " A flexible dragon toy. "}\n```');
|
||||||
|
assert.deepEqual(r.tags, ['dragon', 'print in place', 'a', 'b', 'c', 'd', 'e', 'f']);
|
||||||
|
assert.equal(r.description, 'A flexible dragon toy.');
|
||||||
|
assert.deepEqual(ai.parseReply('{"tags": "hook, wall mount", "description": ""}').tags, ['hook', 'wall mount']);
|
||||||
|
assert.throws(() => ai.parseReply('I cannot help with that'), /JSON/);
|
||||||
|
assert.throws(() => ai.parseReply('{"tags": [], "description": ""}'), /no tags/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cost comes from the provider when reported, else from your prices', () => {
|
||||||
|
assert.equal(ai.costOf({ prompt_tokens: 1e6, completion_tokens: 1e6, cost: 0.0123 }, { input: 1, output: 1 }), 0.0123);
|
||||||
|
assert.equal(ai.costOf({ prompt_tokens: 2e6, completion_tokens: 1e6 }, { input: 0.3, output: 1.2 }), 1.8);
|
||||||
|
assert.equal(ai.costOf({ prompt_tokens: 10, completion_tokens: 10 }, { input: null, output: null }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a model that refuses JSON mode and images is retried without them', async () => {
|
||||||
|
const provider = await fakeProvider([
|
||||||
|
() => [400, { error: { message: 'response_format is not supported by this model' } }],
|
||||||
|
() => [400, { error: { message: 'This model does not support image input' } }],
|
||||||
|
() => reply('{"tags": ["dragon", "articulated"], "description": "A flexi dragon."}'),
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
const result = await ai.describeModel(provider.config, MODEL, { image: 'data:image/png;base64,AAAA' });
|
||||||
|
assert.deepEqual(result.tags, ['dragon', 'articulated']);
|
||||||
|
assert.equal(result.imageRejected, true);
|
||||||
|
assert.equal(result.imageUsed, false);
|
||||||
|
assert.deepEqual(result.usage, { prompt: 1000, completion: 100 });
|
||||||
|
assert.ok(Math.abs(result.cost - 0.00042) < 1e-9);
|
||||||
|
|
||||||
|
assert.equal(provider.requests.length, 3);
|
||||||
|
assert.equal(provider.requests[0].path, '/v1/chat/completions');
|
||||||
|
assert.equal(provider.requests[0].auth, 'Bearer sk-test');
|
||||||
|
assert.ok(provider.requests[0].body.response_format);
|
||||||
|
assert.equal(provider.requests[1].body.response_format, undefined);
|
||||||
|
assert.equal(typeof provider.requests[2].body.messages[1].content, 'string'); // no image part
|
||||||
|
} finally {
|
||||||
|
await provider.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rejected key is fatal for a run; a bad reply is not', async () => {
|
||||||
|
const provider = await fakeProvider([() => [401, { error: { message: 'Authentication Fails' } }]]);
|
||||||
|
try {
|
||||||
|
const err = await ai.describeModel(provider.config, MODEL).catch((e) => e);
|
||||||
|
assert.match(ai.fatalMessage(err), /API key was rejected/);
|
||||||
|
} finally {
|
||||||
|
await provider.close();
|
||||||
|
}
|
||||||
|
assert.equal(ai.fatalMessage(new Error('The AI reply wasn’t the JSON asked for')), null);
|
||||||
|
assert.match(ai.fatalMessage(new ai.AiError('nope', 402)), /out of credit/);
|
||||||
|
});
|
||||||
33
test/autotag.test.js
Normal file
33
test/autotag.test.js
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// Automatic tagging rules (lib/autotag.js).
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { tokenize, matchTags } = require('../lib/autotag');
|
||||||
|
|
||||||
|
test('names split into words at punctuation, camelCase and numbers', () => {
|
||||||
|
assert.deepEqual(tokenize('PETGWallHook_v2.stl'), ['petg', 'wall', 'hook', 'v', '2', 'stl']);
|
||||||
|
assert.deepEqual(tokenize('Gridfinity-6x4 baseplate'), ['gridfinity', '6', 'x', '4', 'baseplate']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rules match whole words only, plurals included', () => {
|
||||||
|
assert.deepEqual(matchTags(['Wall Hooks.stl']), ['hook']);
|
||||||
|
assert.deepEqual(matchTags(['facebook_logo.stl', 'blocked-path.3mf', 'planter.stl', 'absolute.obj']), []);
|
||||||
|
assert.deepEqual(matchTags(['gears_and_threads.3mf']).sort(), ['gear', 'threaded']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('phrases match across separators but not across different names', () => {
|
||||||
|
assert.deepEqual(matchTags(['Print-In-Place_Dragon.3mf']), ['print-in-place']);
|
||||||
|
assert.deepEqual(matchTags(['Dragon Print', 'in place']), []);
|
||||||
|
assert.deepEqual(matchTags(['Spiral Vase Mode lamp']), ['vase mode']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('materials and techniques are picked up', () => {
|
||||||
|
assert.deepEqual(matchTags(['benchy_PLA.3mf', 'TPU tyre']).sort(), ['pla', 'tpu']);
|
||||||
|
assert.deepEqual(matchTags(['MultiColour Keyring']).sort(), ['keychain', 'multicolor']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('your own rules add tags the same strict way', () => {
|
||||||
|
const rules = [{ phrase: 'benchy', tag: 'Calibration' }, { phrase: 'cable tie', tag: 'cable management' }];
|
||||||
|
assert.deepEqual(matchTags(['3DBenchy.stl'], rules), ['calibration']);
|
||||||
|
assert.deepEqual(matchTags(['cable_ties_holder.stl'], rules).sort(), ['cable management', 'holder']);
|
||||||
|
assert.deepEqual(matchTags(['benchymark.stl'], rules), []);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue