Merge emoji-safe text for AI requests
All checks were successful
build-and-push / docker (push) Successful in 25s
All checks were successful
build-and-push / docker (push) Successful in 25s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
commit
6a3e23359f
2 changed files with 35 additions and 9 deletions
27
lib/ai.js
27
lib/ai.js
|
|
@ -40,6 +40,15 @@ class ReplyError extends Error {}
|
||||||
|
|
||||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
// Cuts text to at most max UTF-16 units without splitting an emoji in half. A lone
|
||||||
|
// half (surrogate) is still sent as JSON, but some APIs (DeepSeek) refuse to parse it.
|
||||||
|
function clip(text, max) {
|
||||||
|
const s = String(text);
|
||||||
|
if (s.length <= max) return s;
|
||||||
|
const cut = s.slice(0, max);
|
||||||
|
return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut;
|
||||||
|
}
|
||||||
|
|
||||||
// Settings values (ai_provider, ai_base_url, …) -> a client config, filling the
|
// Settings values (ai_provider, ai_base_url, …) -> a client config, filling the
|
||||||
// gaps from the provider's preset.
|
// gaps from the provider's preset.
|
||||||
function configFrom(values = {}) {
|
function configFrom(values = {}) {
|
||||||
|
|
@ -88,7 +97,7 @@ function modelSummary(model, knownTags = []) {
|
||||||
const size = modelSize(model);
|
const size = modelSize(model);
|
||||||
if (size) lines.push(`Size: ${size}`);
|
if (size) lines.push(`Size: ${size}`);
|
||||||
if (model.tags && model.tags.length) lines.push(`Tags so far: ${model.tags.join(', ')}`);
|
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 (model.description && model.description.trim()) lines.push(`Description so far: ${clip(model.description.trim(), 800)}`);
|
||||||
if (knownTags.length) lines.push(`Tags already used in this library: ${knownTags.join(', ')}`);
|
if (knownTags.length) lines.push(`Tags already used in this library: ${knownTags.join(', ')}`);
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
@ -108,6 +117,8 @@ function buildMessages(model, {
|
||||||
+ (describe ? DESCRIBE_PROMPT : '')
|
+ (describe ? DESCRIBE_PROMPT : '')
|
||||||
+ (suggestName ? NAME_PROMPT : '')
|
+ (suggestName ? NAME_PROMPT : '')
|
||||||
+ (suggestCollections ? COLLECTIONS_PROMPT : '');
|
+ (suggestCollections ? COLLECTIONS_PROMPT : '');
|
||||||
|
// Stored names and descriptions may already hold a broken emoji; send a stand-in instead.
|
||||||
|
text = text.toWellFormed();
|
||||||
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;
|
||||||
|
|
@ -118,12 +129,11 @@ function normalizeTags(value) {
|
||||||
const list = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [];
|
const list = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [];
|
||||||
const tags = [];
|
const tags = [];
|
||||||
for (const raw of list) {
|
for (const raw of list) {
|
||||||
const tag = String(raw).toLowerCase()
|
const tag = clip(String(raw).toLowerCase()
|
||||||
.replace(/^#+/, '')
|
.replace(/^#+/, '')
|
||||||
.replace(/[_\s]+/g, ' ')
|
.replace(/[_\s]+/g, ' ')
|
||||||
.replace(/[^\p{L}\p{N} +&'-]/gu, '')
|
.replace(/[^\p{L}\p{N} +&'-]/gu, '')
|
||||||
.trim()
|
.trim(), 40)
|
||||||
.slice(0, 40)
|
|
||||||
.trim();
|
.trim();
|
||||||
if (tag && !GENERIC_TAGS.has(tag) && !tags.includes(tag)) tags.push(tag);
|
if (tag && !GENERIC_TAGS.has(tag) && !tags.includes(tag)) tags.push(tag);
|
||||||
}
|
}
|
||||||
|
|
@ -137,11 +147,10 @@ const stripQuotes = (s) => String(s).replace(/^["'“”‘’]+|["'“”‘’
|
||||||
// written differently.
|
// written differently.
|
||||||
function cleanName(value, currentName) {
|
function cleanName(value, currentName) {
|
||||||
if (typeof value !== 'string') return null;
|
if (typeof value !== 'string') return null;
|
||||||
const name = stripQuotes(value)
|
const name = clip(stripQuotes(value)
|
||||||
.replace(/\.(stl|3mf|obj|fbx|step|stp|3dm|gcode|zip|7z|rar)$/i, '')
|
.replace(/\.(stl|3mf|obj|fbx|step|stp|3dm|gcode|zip|7z|rar)$/i, '')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
.trim()
|
.trim(), 80)
|
||||||
.slice(0, 80)
|
|
||||||
.trim();
|
.trim();
|
||||||
return name && comparable(name) !== comparable(currentName) ? name : null;
|
return name && comparable(name) !== comparable(currentName) ? name : null;
|
||||||
}
|
}
|
||||||
|
|
@ -151,7 +160,7 @@ function normalizeCollections(value) {
|
||||||
const list = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [];
|
const list = Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : [];
|
||||||
const names = [];
|
const names = [];
|
||||||
for (const raw of list) {
|
for (const raw of list) {
|
||||||
const name = stripQuotes(raw).replace(/\s+/g, ' ').trim().slice(0, 60).trim();
|
const name = clip(stripQuotes(raw).replace(/\s+/g, ' ').trim(), 60).trim();
|
||||||
if (name && !names.some((n) => comparable(n) === comparable(name))) names.push(name);
|
if (name && !names.some((n) => comparable(n) === comparable(name))) names.push(name);
|
||||||
}
|
}
|
||||||
return names.slice(0, 3);
|
return names.slice(0, 3);
|
||||||
|
|
@ -171,7 +180,7 @@ function parseReply(content, options = {}) {
|
||||||
}
|
}
|
||||||
if (!data || typeof data !== 'object') throw new ReplyError('The AI reply wasn’t the JSON asked for');
|
if (!data || typeof data !== 'object') throw new ReplyError('The AI reply wasn’t the JSON asked for');
|
||||||
const tags = describe ? normalizeTags(data.tags) : [];
|
const tags = describe ? normalizeTags(data.tags) : [];
|
||||||
const description = describe && typeof data.description === 'string' ? data.description.trim().slice(0, 600) : '';
|
const description = describe && typeof data.description === 'string' ? clip(data.description.trim(), 600) : '';
|
||||||
if (describe && !tags.length && !description) throw new ReplyError('The AI reply had no tags or description');
|
if (describe && !tags.length && !description) throw new ReplyError('The AI reply had no tags or description');
|
||||||
return {
|
return {
|
||||||
tags,
|
tags,
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,23 @@ test('a reply that isn’t JSON is asked again once, and both answers count towa
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('text sent to the AI never holds half an emoji', () => {
|
||||||
|
// The panda starts at unit 799, so a plain cut at 800 would split it.
|
||||||
|
const description = `${'a'.repeat(799)}🐼 red panda`;
|
||||||
|
const [, user] = ai.buildMessages({ name: 'Red panda', files: [], tags: [], description });
|
||||||
|
assert.ok(user.content.isWellFormed());
|
||||||
|
assert.ok(!user.content.includes('\uFFFD'), 'clipped before the emoji, not replaced');
|
||||||
|
assert.match(user.content, /Description so far: a{799}$/m);
|
||||||
|
|
||||||
|
// Text that's already broken in the library is made safe before it's sent.
|
||||||
|
const [, broken] = ai.buildMessages({ name: 'Bad \uD83D name', files: [], tags: ['ok'], description: '' }, { knownTags: ['tag\uDC3C'] });
|
||||||
|
assert.ok(broken.content.isWellFormed());
|
||||||
|
|
||||||
|
const reply = JSON.stringify({ name: `x${'🐼'.repeat(50)}`, tags: ['panda'], description: `x${'🐼'.repeat(400)}`, collections: [`x${'🐼'.repeat(40)}`] });
|
||||||
|
const parsed = ai.parseReply(reply, { currentName: 'Red panda', collections: true });
|
||||||
|
assert.ok(parsed.name.isWellFormed() && parsed.description.isWellFormed() && parsed.collections[0].isWellFormed());
|
||||||
|
});
|
||||||
|
|
||||||
test('a rejected key is fatal for a run; a bad reply is not', async () => {
|
test('a rejected key is fatal for a run; a bad reply is not', async () => {
|
||||||
const provider = await fakeProvider([() => [401, { error: { message: 'Authentication Fails' } }]]);
|
const provider = await fakeProvider([() => [401, { error: { message: 'Authentication Fails' } }]]);
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue