Compare commits
2 commits
b902ea91d6
...
2076717447
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2076717447 | ||
|
|
2506f4dce9 |
10 changed files with 110 additions and 26 deletions
|
|
@ -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.
|
||||
- **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".
|
||||
- **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.
|
||||
- **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).
|
||||
|
|
|
|||
|
|
@ -208,3 +208,9 @@ CREATE TABLE IF NOT EXISTS tag_rules (
|
|||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
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;
|
||||
|
|
|
|||
40
lib/ai.js
40
lib/ai.js
|
|
@ -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".
|
||||
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([
|
||||
'3d print', '3d printing', '3d printed', '3d printable', '3d model', 'model', 'print', 'printable', 'file',
|
||||
'stl', '3mf', 'obj', 'step', 'stp', 'gcode',
|
||||
|
|
@ -81,12 +85,12 @@ function modelSummary(model, knownTags = []) {
|
|||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildMessages(model, { image = null, knownTags = [] } = {}) {
|
||||
function buildMessages(model, { image = null, knownTags = [], suggestName = false } = {}) {
|
||||
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 }];
|
||||
return [{ role: 'system', content: SYSTEM_PROMPT + (suggestName ? NAME_PROMPT : '') }, { role: 'user', content }];
|
||||
}
|
||||
|
||||
function normalizeTags(value) {
|
||||
|
|
@ -105,9 +109,26 @@ function normalizeTags(value) {
|
|||
return tags.slice(0, 8);
|
||||
}
|
||||
|
||||
// The model's reply -> { tags, description }. Tolerates code fences or prose
|
||||
// around the JSON.
|
||||
function parseReply(content) {
|
||||
const comparable = (s) => String(s || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '');
|
||||
|
||||
// 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 start = text.indexOf('{');
|
||||
const end = text.lastIndexOf('}');
|
||||
|
|
@ -119,7 +140,8 @@ function parseReply(content) {
|
|||
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 };
|
||||
const name = Object.prototype.hasOwnProperty.call(options, 'currentName') ? cleanName(data.name, options.currentName) : null;
|
||||
return { tags, description, name };
|
||||
}
|
||||
|
||||
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
|
||||
// (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 = [] } = {}) {
|
||||
async function describeModel(config, model, { image = null, knownTags = [], suggestName = false } = {}) {
|
||||
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 }),
|
||||
messages: buildMessages(model, { image: useImage ? image : null, knownTags, suggestName }),
|
||||
temperature: 0.2,
|
||||
max_tokens: 800,
|
||||
};
|
||||
|
|
@ -186,7 +208,7 @@ async function describeModel(config, model, { image = null, knownTags = [] } = {
|
|||
const data = await chat(config, body);
|
||||
const message = data.choices[0].message || {};
|
||||
return {
|
||||
...parseReply(message.content),
|
||||
...parseReply(message.content, suggestName ? { currentName: model.name } : {}),
|
||||
usage: usageOf(data.usage),
|
||||
cost: costOf(data.usage, config.prices),
|
||||
imageUsed: useImage,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ async function createRun(modelIds, options = {}) {
|
|||
`INSERT INTO ai_runs (provider, model, options) VALUES ($1, $2, $3) RETURNING id`,
|
||||
[config.label, config.model, {
|
||||
overwriteDescriptions: Boolean(options.overwriteDescriptions),
|
||||
renameUnclear: Boolean(options.renameUnclear),
|
||||
maxCost,
|
||||
pricesKnown: config.prices.input != null || config.provider === 'openrouter',
|
||||
}]);
|
||||
|
|
@ -79,7 +80,7 @@ async function finish(runId, status, error = null) {
|
|||
[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);
|
||||
if (!model || model.deleted_at) {
|
||||
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 {
|
||||
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]);
|
||||
const result = await ai.describeModel(config, model,
|
||||
{ image: images ? thumbnailDataUrl(model) : null, knownTags, suggestName: rename });
|
||||
const name = rename ? result.name : null;
|
||||
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(
|
||||
`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`,
|
||||
|
|
@ -121,7 +125,7 @@ async function processRun(runId) {
|
|||
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 { maxCost, overwriteDescriptions, renameUnclear } = 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]);
|
||||
|
|
@ -133,7 +137,7 @@ async function processRun(runId) {
|
|||
}
|
||||
|
||||
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.
|
||||
if (results.some((r) => r.ok)) fingerprint.kick();
|
||||
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]);
|
||||
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
|
||||
`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
|
||||
WHERE i.run_id = $1 AND i.status <> 'pending' ORDER BY i.updated_at DESC LIMIT 12`, [id]);
|
||||
return { ...shapeRun(run), recent };
|
||||
|
|
|
|||
20
lib/store.js
20
lib/store.js
|
|
@ -5,7 +5,7 @@ const { MODEL_FORMATS, ARCHIVE_FORMATS } = require('./fileProcessor');
|
|||
const MODEL_SELECT = `
|
||||
SELECT m.id, m.name, m.description, m.source_site, m.source_url, m.source_id,
|
||||
m.license, m.commercial_use, m.thumbnail_path, m.print_settings, m.date_added, m.date_modified,
|
||||
m.notes, m.deleted_at, m.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,
|
||||
COALESCE(t.tags, '[]'::json) AS tags,
|
||||
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']) {
|
||||
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) {
|
||||
fields.push(`print_settings = $${i++}`); values.push(updates.print_settings);
|
||||
}
|
||||
|
|
@ -339,9 +344,10 @@ async function removeTagsFrom(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 }) {
|
||||
// AI tagging: adds the suggested tags (never removes any), fills in the
|
||||
// description if it's empty (or replaces it when asked to), and renames the model
|
||||
// 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) => {
|
||||
for (const name of tags) {
|
||||
const id = await tagId(client, name);
|
||||
|
|
@ -350,8 +356,10 @@ async function applyAiResult(modelId, { tags, description, overwriteDescription
|
|||
}
|
||||
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)]);
|
||||
description = CASE WHEN $2::text <> '' AND ($3::boolean OR btrim(description) = '') THEN $2 ELSE description END,
|
||||
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]);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -767,6 +767,9 @@ function setupEventListeners() {
|
|||
});
|
||||
syncSearchMode();
|
||||
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('aiProvider').addEventListener('change', () => {
|
||||
// A new provider starts from its own defaults.
|
||||
|
|
@ -1006,6 +1009,7 @@ function openModelDetail(modelId) {
|
|||
}
|
||||
|
||||
renderDetailLicense(model);
|
||||
renderRenamedNote(model);
|
||||
document.getElementById('refreshLicenseBtn').style.display = model.source_url ? '' : 'none';
|
||||
|
||||
// "Download files from source" — only for sites we can auto-download from.
|
||||
|
|
@ -1258,6 +1262,29 @@ function renderDetailTags(model) {
|
|||
: '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() {
|
||||
try {
|
||||
if (!aiProviders) aiProviders = (await getJson('/api/ai/status')).providers;
|
||||
|
|
@ -1321,6 +1348,8 @@ async function handleAiTagModel() {
|
|||
if (model && String(fileDetailModal.dataset.currentModelId) === String(id)) {
|
||||
renderDetailTags(model);
|
||||
document.getElementById('detailDescription').textContent = model.description || 'No description';
|
||||
document.getElementById('detailFileName').textContent = model.name;
|
||||
renderRenamedNote(model);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`AI tagging didn’t work: ${err.message}`);
|
||||
|
|
@ -1346,6 +1375,7 @@ function aiPanelHtml() {
|
|||
<span class="count-inline" id="aiAllCount"></span></label>
|
||||
</div>
|
||||
<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
|
||||
<span class="money-input">$<input type="number" id="aiMaxCost" min="0" step="0.01" placeholder="no limit"></span></label>
|
||||
<div class="ai-start-row">
|
||||
|
|
@ -1376,6 +1406,7 @@ function runHtml(run, detail) {
|
|||
<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.renamed_to ? `<span class="renamed-from">was “${escapeHtml(i.renamed_from)}”</span>` : ''}
|
||||
${i.status === 'done'
|
||||
? (i.tags || []).map((t) => `<span class="tag">${escapeHtml(t)}</span>`).join('')
|
||||
: `<span class="ai-run-error">${escapeHtml(i.error || 'failed')}</span>`}
|
||||
|
|
@ -1552,6 +1583,7 @@ async function handleAiStart(e) {
|
|||
await postJson('/api/ai/runs', {
|
||||
scope,
|
||||
overwriteDescriptions: document.getElementById('aiOverwrite').checked,
|
||||
renameUnclear: document.getElementById('aiRename').checked,
|
||||
maxCost: maxCost ? Number(maxCost) : null,
|
||||
});
|
||||
aiState.expectChange = true;
|
||||
|
|
|
|||
|
|
@ -490,6 +490,7 @@
|
|||
<div id="slicerNotice" class="slicer-notice" role="status" hidden></div>
|
||||
</div>
|
||||
<div class="file-detail-info">
|
||||
<div id="detailRenamed" class="renamed-note" hidden></div>
|
||||
<div class="info-section">
|
||||
<h3>Source</h3>
|
||||
<div id="detailSource"></div>
|
||||
|
|
|
|||
|
|
@ -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-error { color: var(--danger); }
|
||||
@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); }
|
||||
|
|
|
|||
|
|
@ -1208,8 +1208,8 @@ app.get('/api/ai/runs', async (req, res, next) => {
|
|||
|
||||
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 });
|
||||
const { scope, ids, overwriteDescriptions, renameUnclear, maxCost } = req.body || {};
|
||||
const run = await aiRuns.createRun(await aiScopeIds(scope, ids), { overwriteDescriptions, renameUnclear, maxCost });
|
||||
res.status(201).json(run);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -67,6 +67,15 @@ test('replies are read leniently and tags tidied', () => {
|
|||
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', () => {
|
||||
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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue