Let AI runs give models with unclear names a clearer one, with undo

A run option asks the AI for a clearer name only when the current one is messy
(file-name leftovers, typos, site boilerplate). The previous name is kept, the
run feed shows what was renamed, and the model page offers Undo. Giving a model
a name yourself clears the undo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-15 15:26:29 +10:00
parent b902ea91d6
commit 2506f4dce9
10 changed files with 110 additions and 26 deletions

View file

@ -23,7 +23,7 @@ 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. - **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. Optionally it also gives models with unclear names (file-name leftovers, typos, site boilerplate) a clearer one; the old name is kept and can be restored with Undo on the model page. 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. - **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. - **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).

View file

@ -208,3 +208,9 @@ CREATE TABLE IF NOT EXISTS tag_rules (
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (phrase, tag) UNIQUE (phrase, tag)
); );
-- AI renaming: the name a model had before AI first renamed it (for undo), and
-- what each run renamed.
ALTER TABLE models ADD COLUMN IF NOT EXISTS original_name TEXT;
ALTER TABLE ai_run_items ADD COLUMN IF NOT EXISTS renamed_from TEXT;
ALTER TABLE ai_run_items ADD COLUMN IF NOT EXISTS renamed_to TEXT;

View file

@ -13,6 +13,10 @@ const SYSTEM_PROMPT = `You catalogue 3D-printable models in a maker's library. R
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". 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.`; description: one or two plain sentences on what the object is and what it's for. Don't invent sizes, print settings or claims.`;
// Added to the prompt only when a run may rename models.
const NAME_PROMPT = `
Also include "name": the model's name as a clear title. If the current name already says clearly what the model is, return it exactly as it is. Otherwise write a short title-case name (under 60 characters) saying what it is, using the picture and details: drop file-name leftovers (underscores, stray spaces, version numbers like "v4", "final", file extensions), fix typos, and remove site boilerplate like "Free 3D Print Model - MakerWorld". Keep designer, character or product names that are part of what it is.`;
const GENERIC_TAGS = new Set([ const GENERIC_TAGS = new Set([
'3d print', '3d printing', '3d printed', '3d printable', '3d model', 'model', 'print', 'printable', 'file', '3d print', '3d printing', '3d printed', '3d printable', '3d model', 'model', 'print', 'printable', 'file',
'stl', '3mf', 'obj', 'step', 'stp', 'gcode', 'stl', '3mf', 'obj', 'step', 'stp', 'gcode',
@ -81,12 +85,12 @@ function modelSummary(model, knownTags = []) {
return lines.join('\n'); return lines.join('\n');
} }
function buildMessages(model, { image = null, knownTags = [] } = {}) { function buildMessages(model, { image = null, knownTags = [], suggestName = false } = {}) {
const text = modelSummary(model, knownTags); const text = modelSummary(model, knownTags);
const content = image const content = image
? [{ type: 'text', text }, { type: 'image_url', image_url: { url: image } }] ? [{ type: 'text', text }, { type: 'image_url', image_url: { url: image } }]
: text; : text;
return [{ role: 'system', content: SYSTEM_PROMPT }, { role: 'user', content }]; return [{ role: 'system', content: SYSTEM_PROMPT + (suggestName ? NAME_PROMPT : '') }, { role: 'user', content }];
} }
function normalizeTags(value) { function normalizeTags(value) {
@ -105,9 +109,26 @@ function normalizeTags(value) {
return tags.slice(0, 8); return tags.slice(0, 8);
} }
// The model's reply -> { tags, description }. Tolerates code fences or prose const comparable = (s) => String(s || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
// around the JSON.
function parseReply(content) { // A suggested name, tidied; null when there isn't one or it's the current name
// written differently.
function cleanName(value, currentName) {
if (typeof value !== 'string') return null;
const name = value
.replace(/^["'“”‘’]+|["'“”‘’]+$/g, '')
.replace(/\.(stl|3mf|obj|fbx|step|stp|3dm|gcode|zip|7z|rar)$/i, '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 80)
.trim();
return name && comparable(name) !== comparable(currentName) ? name : null;
}
// The model's reply -> { tags, description, name }. Tolerates code fences or
// prose around the JSON. name is only read when { currentName } is given (the
// prompt asked for one).
function parseReply(content, options = {}) {
const text = String(content || ''); const text = String(content || '');
const start = text.indexOf('{'); const start = text.indexOf('{');
const end = text.lastIndexOf('}'); const end = text.lastIndexOf('}');
@ -119,7 +140,8 @@ function parseReply(content) {
const tags = normalizeTags(data.tags); const tags = normalizeTags(data.tags);
const description = typeof data.description === 'string' ? data.description.trim().slice(0, 600) : ''; 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'); if (!tags.length && !description) throw new Error('The AI reply had no tags or description');
return { tags, description }; const name = Object.prototype.hasOwnProperty.call(options, 'currentName') ? cleanName(data.name, options.currentName) : null;
return { tags, description, name };
} }
function usageOf(usage) { function usageOf(usage) {
@ -169,14 +191,14 @@ async function chat(config, body, { timeoutMs = 90000 } = {}) {
// refuses JSON mode or images, the request is retried without them // refuses JSON mode or images, the request is retried without them
// (imageRejected tells the caller to stop sending images). Rate limits and // (imageRejected tells the caller to stop sending images). Rate limits and
// server errors are retried with backoff. // server errors are retried with backoff.
async function describeModel(config, model, { image = null, knownTags = [] } = {}) { async function describeModel(config, model, { image = null, knownTags = [], suggestName = false } = {}) {
let useImage = Boolean(image); let useImage = Boolean(image);
let useJson = true; let useJson = true;
let imageRejected = false; let imageRejected = false;
for (let attempt = 0; ; attempt++) { for (let attempt = 0; ; attempt++) {
const body = { const body = {
model: config.model, model: config.model,
messages: buildMessages(model, { image: useImage ? image : null, knownTags }), messages: buildMessages(model, { image: useImage ? image : null, knownTags, suggestName }),
temperature: 0.2, temperature: 0.2,
max_tokens: 800, max_tokens: 800,
}; };
@ -186,7 +208,7 @@ async function describeModel(config, model, { image = null, knownTags = [] } = {
const data = await chat(config, body); const data = await chat(config, body);
const message = data.choices[0].message || {}; const message = data.choices[0].message || {};
return { return {
...parseReply(message.content), ...parseReply(message.content, suggestName ? { currentName: model.name } : {}),
usage: usageOf(data.usage), usage: usageOf(data.usage),
cost: costOf(data.usage, config.prices), cost: costOf(data.usage, config.prices),
imageUsed: useImage, imageUsed: useImage,

View file

@ -59,6 +59,7 @@ async function createRun(modelIds, options = {}) {
`INSERT INTO ai_runs (provider, model, options) VALUES ($1, $2, $3) RETURNING id`, `INSERT INTO ai_runs (provider, model, options) VALUES ($1, $2, $3) RETURNING id`,
[config.label, config.model, { [config.label, config.model, {
overwriteDescriptions: Boolean(options.overwriteDescriptions), overwriteDescriptions: Boolean(options.overwriteDescriptions),
renameUnclear: Boolean(options.renameUnclear),
maxCost, maxCost,
pricesKnown: config.prices.input != null || config.provider === 'openrouter', pricesKnown: config.prices.input != null || config.provider === 'openrouter',
}]); }]);
@ -79,7 +80,7 @@ async function finish(runId, status, error = null) {
[runId, status, error]); [runId, status, error]);
} }
async function tagOne(runId, modelId, config, { images, knownTags, overwrite }) { async function tagOne(runId, modelId, config, { images, knownTags, overwrite, rename }) {
const model = await store.getModel(modelId); const model = await store.getModel(modelId);
if (!model || model.deleted_at) { if (!model || model.deleted_at) {
await db.query(`UPDATE ai_run_items SET status = 'error', error = 'The model was deleted', updated_at = now() await db.query(`UPDATE ai_run_items SET status = 'error', error = 'The model was deleted', updated_at = now()
@ -89,10 +90,13 @@ async function tagOne(runId, modelId, config, { images, knownTags, overwrite })
} }
try { try {
model.entryNames = await entryNames(model); model.entryNames = await entryNames(model);
const result = await ai.describeModel(config, model, { image: images ? thumbnailDataUrl(model) : null, knownTags }); const result = await ai.describeModel(config, model,
await store.applyAiResult(modelId, { tags: result.tags, description: result.description, overwriteDescription: overwrite }); { image: images ? thumbnailDataUrl(model) : null, knownTags, suggestName: rename });
await db.query(`UPDATE ai_run_items SET status = 'done', tags = $3, error = NULL, updated_at = now() const name = rename ? result.name : null;
WHERE run_id = $1 AND model_id = $2`, [runId, modelId, result.tags]); await store.applyAiResult(modelId, { tags: result.tags, description: result.description, overwriteDescription: overwrite, name });
await db.query(`UPDATE ai_run_items SET status = 'done', tags = $3, error = NULL, renamed_from = $4, renamed_to = $5,
updated_at = now() WHERE run_id = $1 AND model_id = $2`,
[runId, modelId, result.tags, name ? model.name : null, name]);
await db.query( await db.query(
`UPDATE ai_runs SET done = done + 1, prompt_tokens = prompt_tokens + $2, `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`, completion_tokens = completion_tokens + $3, cost_usd = cost_usd + $4 WHERE id = $1`,
@ -121,7 +125,7 @@ async function processRun(runId) {
for (;;) { for (;;) {
const { rows: [run] } = await db.query('SELECT status, options, cost_usd FROM ai_runs WHERE id = $1', [runId]); 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 if (!run || run.status !== 'running') return; // stopped, or deleted
const { maxCost, overwriteDescriptions } = run.options || {}; const { maxCost, overwriteDescriptions, renameUnclear } = run.options || {};
const { rows: items } = await db.query( 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`, `SELECT model_id FROM ai_run_items WHERE run_id = $1 AND status = 'pending' ORDER BY model_id LIMIT $2`,
[runId, CONCURRENCY]); [runId, CONCURRENCY]);
@ -133,7 +137,7 @@ async function processRun(runId) {
} }
const results = await Promise.all(items.map((item) => const results = await Promise.all(items.map((item) =>
tagOne(runId, item.model_id, config, { images, knownTags, overwrite: overwriteDescriptions }))); tagOne(runId, item.model_id, config, { images, knownTags, overwrite: overwriteDescriptions, rename: renameUnclear })));
// New tags and descriptions change a model's search text, so refresh the index. // New tags and descriptions change a model's search text, so refresh the index.
if (results.some((r) => r.ok)) fingerprint.kick(); if (results.some((r) => r.ok)) fingerprint.kick();
for (const r of results) { for (const r of results) {
@ -193,7 +197,7 @@ async function getRun(id) {
const { rows: [run] } = await db.query('SELECT * FROM ai_runs WHERE id = $1', [id]); const { rows: [run] } = await db.query('SELECT * FROM ai_runs WHERE id = $1', [id]);
if (!run) return null; if (!run) return null;
const { rows: recent } = await db.query( const { rows: recent } = await db.query(
`SELECT i.model_id, m.name, i.status, i.tags, i.error FROM ai_run_items i `SELECT i.model_id, m.name, i.status, i.tags, i.error, i.renamed_from, i.renamed_to FROM ai_run_items i
LEFT JOIN models m ON m.id = i.model_id 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]); WHERE i.run_id = $1 AND i.status <> 'pending' ORDER BY i.updated_at DESC LIMIT 12`, [id]);
return { ...shapeRun(run), recent }; return { ...shapeRun(run), recent };

View file

@ -5,7 +5,7 @@ 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.ai_tagged_at, m.notes, m.deleted_at, m.ai_tagged_at, m.original_name,
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(t.tag_sources, '{}'::json) AS tag_sources,
@ -103,6 +103,11 @@ async function updateModel(id, updates) {
for (const col of ['name', 'description', 'notes', 'license', 'commercial_use', 'thumbnail_path']) { for (const col of ['name', 'description', 'notes', 'license', 'commercial_use', 'thumbnail_path']) {
if (updates[col] !== undefined) { fields.push(`${col} = $${i++}`); values.push(updates[col]); } if (updates[col] !== undefined) { fields.push(`${col} = $${i++}`); values.push(updates[col]); }
} }
// A name you give it yourself replaces an AI rename, so there's nothing left to undo.
if (updates.name !== undefined) {
fields.push(`original_name = CASE WHEN name IS DISTINCT FROM $${i++} THEN NULL ELSE original_name END`);
values.push(updates.name);
}
if (updates.print_settings !== undefined) { if (updates.print_settings !== undefined) {
fields.push(`print_settings = $${i++}`); values.push(updates.print_settings); fields.push(`print_settings = $${i++}`); values.push(updates.print_settings);
} }
@ -339,9 +344,10 @@ async function removeTagsFrom(source) {
return rowCount; return rowCount;
} }
// AI tagging: adds the suggested tags (never removes any) and fills in the // AI tagging: adds the suggested tags (never removes any), fills in the
// description if it's empty, or replaces it when asked to. // description if it's empty (or replaces it when asked to), and renames the model
async function applyAiResult(modelId, { tags, description, overwriteDescription }) { // when given a name, keeping the name it had before AI first renamed it.
async function applyAiResult(modelId, { tags, description, overwriteDescription, name = null }) {
await db.transaction(async (client) => { await db.transaction(async (client) => {
for (const name of tags) { for (const name of tags) {
const id = await tagId(client, name); const id = await tagId(client, name);
@ -350,8 +356,10 @@ async function applyAiResult(modelId, { tags, description, overwriteDescription
} }
await client.query( await client.query(
`UPDATE models SET ai_tagged_at = now(), `UPDATE models SET ai_tagged_at = now(),
description = CASE WHEN $2::text <> '' AND ($3::boolean OR btrim(description) = '') THEN $2 ELSE description END description = CASE WHEN $2::text <> '' AND ($3::boolean OR btrim(description) = '') THEN $2 ELSE description END,
WHERE id = $1`, [modelId, description || '', Boolean(overwriteDescription)]); original_name = CASE WHEN $4::text IS NOT NULL THEN COALESCE(original_name, name) ELSE original_name END,
name = COALESCE($4::text, name)
WHERE id = $1`, [modelId, description || '', Boolean(overwriteDescription), name]);
}); });
} }

View file

@ -767,6 +767,9 @@ function setupEventListeners() {
}); });
syncSearchMode(); syncSearchMode();
document.getElementById('aiTagBtn').addEventListener('click', handleAiTagModel); document.getElementById('aiTagBtn').addEventListener('click', handleAiTagModel);
document.getElementById('detailRenamed').addEventListener('click', (e) => {
if (e.target.closest('#undoRenameBtn')) handleUndoRename();
});
document.getElementById('aiTestBtn').addEventListener('click', handleAiTest); document.getElementById('aiTestBtn').addEventListener('click', handleAiTest);
document.getElementById('aiProvider').addEventListener('change', () => { document.getElementById('aiProvider').addEventListener('change', () => {
// A new provider starts from its own defaults. // A new provider starts from its own defaults.
@ -1006,6 +1009,7 @@ function openModelDetail(modelId) {
} }
renderDetailLicense(model); renderDetailLicense(model);
renderRenamedNote(model);
document.getElementById('refreshLicenseBtn').style.display = model.source_url ? '' : 'none'; document.getElementById('refreshLicenseBtn').style.display = model.source_url ? '' : 'none';
// "Download files from source" — only for sites we can auto-download from. // "Download files from source" — only for sites we can auto-download from.
@ -1258,6 +1262,29 @@ function renderDetailTags(model) {
: 'No tags'; : 'No tags';
} }
function renderRenamedNote(model) {
const el = document.getElementById('detailRenamed');
el.hidden = !model.original_name;
el.innerHTML = model.original_name
? `✦ Renamed by AI from “${escapeHtml(model.original_name)}”. <button type="button" class="link-btn" id="undoRenameBtn">Undo</button>`
: '';
}
async function handleUndoRename() {
const id = fileDetailModal.dataset.currentModelId;
const model = findModel(id);
if (!model || !model.original_name) return;
try {
const updated = await postJson(`/api/models/${id}`, { name: model.original_name }, 'PUT');
replaceModel(updated);
document.getElementById('detailFileName').textContent = updated.name;
renderRenamedNote(updated);
renderModels();
} catch (err) {
alert(`Couldn’t undo the rename: ${err.message}`);
}
}
async function syncAiSettingsHints() { async function syncAiSettingsHints() {
try { try {
if (!aiProviders) aiProviders = (await getJson('/api/ai/status')).providers; if (!aiProviders) aiProviders = (await getJson('/api/ai/status')).providers;
@ -1321,6 +1348,8 @@ async function handleAiTagModel() {
if (model && String(fileDetailModal.dataset.currentModelId) === String(id)) { if (model && String(fileDetailModal.dataset.currentModelId) === String(id)) {
renderDetailTags(model); renderDetailTags(model);
document.getElementById('detailDescription').textContent = model.description || 'No description'; document.getElementById('detailDescription').textContent = model.description || 'No description';
document.getElementById('detailFileName').textContent = model.name;
renderRenamedNote(model);
} }
} catch (err) { } catch (err) {
alert(`AI tagging didn’t work: ${err.message}`); alert(`AI tagging didn’t work: ${err.message}`);
@ -1346,6 +1375,7 @@ function aiPanelHtml() {
<span class="count-inline" id="aiAllCount"></span></label> <span class="count-inline" id="aiAllCount"></span></label>
</div> </div>
<label><input type="checkbox" id="aiOverwrite"> Replace descriptions that are already filled in</label> <label><input type="checkbox" id="aiOverwrite"> Replace descriptions that are already filled in</label>
<label><input type="checkbox" id="aiRename"> Give models with unclear names a clearer one (undo on the model's page)</label>
<label>Stop when the run has cost <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> <span class="money-input">$<input type="number" id="aiMaxCost" min="0" step="0.01" placeholder="no limit"></span></label>
<div class="ai-start-row"> <div class="ai-start-row">
@ -1376,6 +1406,7 @@ function runHtml(run, detail) {
<ul class="ai-feed">${detail.recent.map((i) => ` <ul class="ai-feed">${detail.recent.map((i) => `
<li> <li>
<button type="button" class="link-btn" data-open-model="${i.model_id}">${escapeHtml(i.name || `Model ${i.model_id}`)}</button> <button type="button" class="link-btn" data-open-model="${i.model_id}">${escapeHtml(i.name || `Model ${i.model_id}`)}</button>
${i.renamed_to ? `<span class="renamed-from">was “${escapeHtml(i.renamed_from)}”</span>` : ''}
${i.status === 'done' ${i.status === 'done'
? (i.tags || []).map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join('') ? (i.tags || []).map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join('')
: `<span class="ai-run-error">${escapeHtml(i.error || 'failed')}</span>`} : `<span class="ai-run-error">${escapeHtml(i.error || 'failed')}</span>`}
@ -1552,6 +1583,7 @@ async function handleAiStart(e) {
await postJson('/api/ai/runs', { await postJson('/api/ai/runs', {
scope, scope,
overwriteDescriptions: document.getElementById('aiOverwrite').checked, overwriteDescriptions: document.getElementById('aiOverwrite').checked,
renameUnclear: document.getElementById('aiRename').checked,
maxCost: maxCost ? Number(maxCost) : null, maxCost: maxCost ? Number(maxCost) : null,
}); });
aiState.expectChange = true; aiState.expectChange = true;

View file

@ -490,6 +490,7 @@
<div id="slicerNotice" class="slicer-notice" role="status" hidden></div> <div id="slicerNotice" class="slicer-notice" role="status" hidden></div>
</div> </div>
<div class="file-detail-info"> <div class="file-detail-info">
<div id="detailRenamed" class="renamed-note" hidden></div>
<div class="info-section"> <div class="info-section">
<h3>Source</h3> <h3>Source</h3>
<div id="detailSource"></div> <div id="detailSource"></div>

View file

@ -434,3 +434,5 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.ai-test-result.is-ok { color: var(--success); } .ai-test-result.is-ok { color: var(--success); }
.ai-test-result.is-error { color: var(--danger); } .ai-test-result.is-error { color: var(--danger); }
@media (max-width: 640px) { .ai-grid { grid-template-columns: 1fr; } .ai-run-actions { margin-left: 0; } } @media (max-width: 640px) { .ai-grid { grid-template-columns: 1fr; } .ai-run-actions { margin-left: 0; } }
.renamed-note { font-size: 13px; color: var(--text); background: var(--accent-soft); border: 1px solid rgba(240, 151, 42, 0.35); border-radius: var(--radius-sm); padding: 8px 12px; margin-bottom: 16px; }
.renamed-from { font-size: 12px; color: var(--text-dim); }

View file

@ -1208,8 +1208,8 @@ app.get('/api/ai/runs', async (req, res, next) => {
app.post('/api/ai/runs', async (req, res, next) => { app.post('/api/ai/runs', async (req, res, next) => {
try { try {
const { scope, ids, overwriteDescriptions, maxCost } = req.body || {}; const { scope, ids, overwriteDescriptions, renameUnclear, maxCost } = req.body || {};
const run = await aiRuns.createRun(await aiScopeIds(scope, ids), { overwriteDescriptions, maxCost }); const run = await aiRuns.createRun(await aiScopeIds(scope, ids), { overwriteDescriptions, renameUnclear, maxCost });
res.status(201).json(run); res.status(201).json(run);
} catch (e) { next(e); } } catch (e) { next(e); }
}); });

View file

@ -67,6 +67,15 @@ test('replies are read leniently and tags tidied', () => {
assert.throws(() => ai.parseReply('{"tags": [], "description": ""}'), /no tags/); assert.throws(() => ai.parseReply('{"tags": [], "description": ""}'), /no tags/);
}); });
test('a clearer name is suggested only when asked, and only when it changes', () => {
assert.match(ai.buildMessages(MODEL, { suggestName: true })[0].content, /"name"/);
assert.doesNotMatch(ai.buildMessages(MODEL)[0].content, /"name"/);
const reply = '{"name": "“Articulated Dragon Pair.3mf”", "tags": ["dragon"], "description": "Two dragons."}';
assert.equal(ai.parseReply(reply, { currentName: 'Dra g _v4' }).name, 'Articulated Dragon Pair');
assert.equal(ai.parseReply('{"name": "flexi dragon!", "tags": ["dragon"]}', { currentName: 'Flexi Dragon' }).name, null);
assert.equal(ai.parseReply(reply).name, null); // not asked for
});
test('cost comes from the provider when reported, else from your prices', () => { 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: 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: 2e6, completion_tokens: 1e6 }, { input: 0.3, output: 1.2 }), 1.8);