Draw a picture in a chat: force_tool image_generate on POST /chats and /messages
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s

A switch among a chat's chips (not an agent chat's), offered where /me says
the server draws for the account, asks for the next message's reply to be a
picture; it goes off once the message is sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 14:31:02 +00:00
1 parent df27ca82fa
commit 2cdd4e5355
5 files changed
+110 -2

No files matched your search

+3
View File
@@ -57,6 +57,9 @@ All notable changes to LLeMbas Desktop. The format follows
is away or does not accept scheduled work. A run waiting for its computer says so. The form
offers "Where it runs" where the server can: this computer or another linked one that accepts
scheduled work, its folder (this computer's agent folder to begin with), mode and wait.
- **A picture as the reply, in a chat.** Where the server draws for the account, a Draw a picture
switch beside the model asks for the next message's reply to be a picture drawn from it — in a
new chat (its first message) as in an open one. It goes off once the message is sent.
### Fixed
+10
View File
@@ -541,6 +541,8 @@ KEYS = (
"This computer (%(name)s)",
"Waiting for the computer",
"approvals wait %(n)s min",
# chat.js: a picture as the next reply
"The reply to the next message is a picture drawn from it.",
)
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
@@ -820,6 +822,7 @@ OWN: dict[str, dict[str, str]] = {
"This computer (%(name)s)": "Tento počítač (%(name)s)",
"Waiting for the computer": "Čeká na počítač",
"approvals wait %(n)s min": "schválení čeká %(n)s min",
"The reply to the next message is a picture drawn from it.": "Odpovědí na další zprávu bude obrázek nakreslený podle ní.",
},
"de": {
"Sign in again: the server no longer accepts this app's sign-in.": "Melden Sie sich erneut an: Der Server nimmt die Anmeldung dieser App nicht mehr an.",
@@ -1094,6 +1097,7 @@ OWN: dict[str, dict[str, str]] = {
"This computer (%(name)s)": "Dieser Computer (%(name)s)",
"Waiting for the computer": "Wartet auf den Computer",
"approvals wait %(n)s min": "Freigaben warten %(n)s Min.",
"The reply to the next message is a picture drawn from it.": "Die Antwort auf die nächste Nachricht ist ein danach gezeichnetes Bild.",
},
"es": {
"Sign in again: the server no longer accepts this app's sign-in.": "Vuelva a iniciar sesión: el servidor ya no acepta el inicio de sesión de esta aplicación.",
@@ -1368,6 +1372,7 @@ OWN: dict[str, dict[str, str]] = {
"This computer (%(name)s)": "Este ordenador (%(name)s)",
"Waiting for the computer": "Esperando al ordenador",
"approvals wait %(n)s min": "las aprobaciones esperan %(n)s min",
"The reply to the next message is a picture drawn from it.": "La respuesta al siguiente mensaje es una imagen dibujada a partir de él.",
},
"fr": {
"Sign in again: the server no longer accepts this app's sign-in.": "Reconnectez-vous : le serveur n’accepte plus la connexion de cette application.",
@@ -1642,6 +1647,7 @@ OWN: dict[str, dict[str, str]] = {
"This computer (%(name)s)": "Cet ordinateur (%(name)s)",
"Waiting for the computer": "En attente de l’ordinateur",
"approvals wait %(n)s min": "les approbations attendent %(n)s min",
"The reply to the next message is a picture drawn from it.": "La réponse au prochain message est une image dessinée d’après lui.",
},
"hu": {
"Sign in again: the server no longer accepts this app's sign-in.": "Jelentkezzen be újra: a szerver már nem fogadja el ennek az alkalmazásnak a bejelentkezését.",
@@ -1916,6 +1922,7 @@ OWN: dict[str, dict[str, str]] = {
"This computer (%(name)s)": "Ez a számítógép (%(name)s)",
"Waiting for the computer": "A számítógépre vár",
"approvals wait %(n)s min": "a jóváhagyások %(n)s percig várnak",
"The reply to the next message is a picture drawn from it.": "A következő üzenetre adott válasz egy abból rajzolt kép lesz.",
},
"it": {
"Sign in again: the server no longer accepts this app's sign-in.": "Accedi di nuovo: il server non accetta più l’accesso di questa app.",
@@ -2190,6 +2197,7 @@ OWN: dict[str, dict[str, str]] = {
"This computer (%(name)s)": "Questo computer (%(name)s)",
"Waiting for the computer": "In attesa del computer",
"approvals wait %(n)s min": "le approvazioni attendono %(n)s min",
"The reply to the next message is a picture drawn from it.": "La risposta al prossimo messaggio è un’immagine disegnata a partire da esso.",
},
"pl": {
"Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.",
@@ -2464,6 +2472,7 @@ OWN: dict[str, dict[str, str]] = {
"This computer (%(name)s)": "Ten komputer (%(name)s)",
"Waiting for the computer": "Czeka na komputer",
"approvals wait %(n)s min": "zatwierdzenia czekają %(n)s min",
"The reply to the next message is a picture drawn from it.": "Odpowiedzią na następną wiadomość będzie obraz narysowany na jej podstawie.",
},
"sk": {
"Sign in again: the server no longer accepts this app's sign-in.": "Prihláste sa znova: server už neprijíma prihlásenie tejto aplikácie.",
@@ -2738,6 +2747,7 @@ OWN: dict[str, dict[str, str]] = {
"This computer (%(name)s)": "Tento počítač (%(name)s)",
"Waiting for the computer": "Čaká na počítač",
"approvals wait %(n)s min": "schválenia čakajú %(n)s min",
"The reply to the next message is a picture drawn from it.": "Odpoveďou na ďalšiu správu bude obrázok nakreslený podľa nej.",
},
}
+39
View File
@@ -1682,6 +1682,45 @@ test("L8: at 390 px the form with a computer chosen stays inside the window", as
assert.deepEqual(p.errors, [])
})
// M8: a picture as the reply (force_tool "image_generate" on POST /chats and /messages).
test("M8: Draw a picture asks for a picture with the next message only, in a new chat and an open one", async () => {
const cfg = i8()
Object.assign(cfg.routes, {
"POST /chats": { chat: chatRow({ id: "chat7" }), message: userMessage("m1", "a lembas loaf"), reply: null },
"POST /chats/chat7/messages": { message: userMessage("m2", "and another"), reply: null },
})
const p = await page(cfg)
await p.waitForFunction(() => window.__mock.api("GET", "/me").length === 1)
await p.click(".kind__tab >> text=Chat")
await p.click(".cchip--picture")
assert.equal(await p.getAttribute(".cchip--picture", "aria-pressed"), "true")
await shot(p, "m8-picture-1280")
await p.fill("#composer-text", "a lembas loaf")
await p.keyboard.press("Enter")
await p.waitForFunction(() => window.__mock.api("POST", "/chats").length === 1)
assert.equal((await p.evaluate(() => window.__mock.api("POST", "/chats")))[0].args.body.force_tool, "image_generate")
await p.waitForSelector('.cchip--picture[aria-pressed="false"]')
await p.fill("#composer-text", "and another")
await p.keyboard.press("Enter")
await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat7/messages").length === 1)
assert.equal((await p.evaluate(() => window.__mock.api("POST", "/chats/chat7/messages")))[0].args.body.force_tool, undefined)
assert.deepEqual(p.errors, [])
})
test("M8: no picture switch where the server cannot draw, or in an agent chat", async () => {
const p = await page(i8({ images: false }))
await p.waitForFunction(() => window.__mock.api("GET", "/me").length === 1)
await p.click(".kind__tab >> text=Chat")
assert.equal(await p.locator(".cchip--picture").count(), 0)
await p.context().close()
const q = await page(i8())
await q.waitForFunction(() => window.__mock.api("GET", "/me").length === 1)
await q.click(".kind__tab >> text=Agent")
assert.equal(await q.locator(".cchip--picture").count(), 0)
assert.deepEqual(q.errors, [])
})
// --- Run ----------------------------------------------------------------------------------------
const only = process.argv[2] || ""
+8
View File
@@ -377,6 +377,7 @@ const LEMBAS_I18N = {
"The previous command (Ctrl+Shift+↑)": "Předchozí příkaz (Ctrl+Shift+↑)",
"The release that goes with the server's version. This is %(current)s.": "Vydání, které patří k verzi serveru. Toto je %(current)s.",
"The reply is ready.": "Odpověď je hotová.",
"The reply to the next message is a picture drawn from it.": "Odpovědí na další zprávu bude obrázek nakreslený podle ní.",
"The result is no longer there.": "Výsledek už tu není.",
"The server refused this computer's link: %(why)s": "Server odmítl propojení tohoto počítače: %(why)s",
"The server's answer had no picture in it.": "V odpovědi serveru nebyl žádný obrázek.",
@@ -861,6 +862,7 @@ const LEMBAS_I18N = {
"The previous command (Ctrl+Shift+↑)": "Vorheriger Befehl (Ctrl+Shift+↑)",
"The release that goes with the server's version. This is %(current)s.": "Die Version, die zur Version des Servers passt. Dies ist %(current)s.",
"The reply is ready.": "Die Antwort ist fertig.",
"The reply to the next message is a picture drawn from it.": "Die Antwort auf die nächste Nachricht ist ein danach gezeichnetes Bild.",
"The result is no longer there.": "Das Ergebnis ist nicht mehr da.",
"The server refused this computer's link: %(why)s": "Der Server hat die Verknüpfung dieses Computers abgelehnt: %(why)s",
"The server's answer had no picture in it.": "Die Antwort des Servers enthielt kein Bild.",
@@ -1345,6 +1347,7 @@ const LEMBAS_I18N = {
"The previous command (Ctrl+Shift+↑)": "El comando anterior (Ctrl+Shift+↑)",
"The release that goes with the server's version. This is %(current)s.": "La versión que corresponde a la del servidor. Esta es %(current)s.",
"The reply is ready.": "La respuesta está lista.",
"The reply to the next message is a picture drawn from it.": "La respuesta al siguiente mensaje es una imagen dibujada a partir de él.",
"The result is no longer there.": "El resultado ya no está.",
"The server refused this computer's link: %(why)s": "El servidor rechazó la vinculación de este equipo: %(why)s",
"The server's answer had no picture in it.": "La respuesta del servidor no traía ninguna imagen.",
@@ -1829,6 +1832,7 @@ const LEMBAS_I18N = {
"The previous command (Ctrl+Shift+↑)": "La commande précédente (Ctrl+Shift+↑)",
"The release that goes with the server's version. This is %(current)s.": "La version qui va avec celle du serveur. Celle-ci est la %(current)s.",
"The reply is ready.": "La réponse est prête.",
"The reply to the next message is a picture drawn from it.": "La réponse au prochain message est une image dessinée d’après lui.",
"The result is no longer there.": "Le résultat n'est plus là.",
"The server refused this computer's link: %(why)s": "Le serveur a refusé la liaison de cet ordinateur : %(why)s",
"The server's answer had no picture in it.": "La réponse du serveur ne contenait aucune image.",
@@ -2313,6 +2317,7 @@ const LEMBAS_I18N = {
"The previous command (Ctrl+Shift+↑)": "Előző parancs (Ctrl+Shift+↑)",
"The release that goes with the server's version. This is %(current)s.": "A szerver verziójához tartozó kiadás. Ez a %(current)s.",
"The reply is ready.": "A válasz kész.",
"The reply to the next message is a picture drawn from it.": "A következő üzenetre adott válasz egy abból rajzolt kép lesz.",
"The result is no longer there.": "Az eredmény már nincs meg.",
"The server refused this computer's link: %(why)s": "A szerver elutasította ennek a számítógépnek a kapcsolatát: %(why)s",
"The server's answer had no picture in it.": "A szerver válaszában nem volt kép.",
@@ -2797,6 +2802,7 @@ const LEMBAS_I18N = {
"The previous command (Ctrl+Shift+↑)": "Il comando precedente (Ctrl+Shift+↑)",
"The release that goes with the server's version. This is %(current)s.": "La versione che va con quella del server. Questa è la %(current)s.",
"The reply is ready.": "La risposta è pronta.",
"The reply to the next message is a picture drawn from it.": "La risposta al prossimo messaggio è un’immagine disegnata a partire da esso.",
"The result is no longer there.": "Il risultato non c’è più.",
"The server refused this computer's link: %(why)s": "Il server ha rifiutato il collegamento di questo computer: %(why)s",
"The server's answer had no picture in it.": "La risposta del server non conteneva immagini.",
@@ -3281,6 +3287,7 @@ const LEMBAS_I18N = {
"The previous command (Ctrl+Shift+↑)": "Poprzednie polecenie (Ctrl+Shift+↑)",
"The release that goes with the server's version. This is %(current)s.": "Wydanie pasujące do wersji serwera. To jest %(current)s.",
"The reply is ready.": "Odpowiedź jest gotowa.",
"The reply to the next message is a picture drawn from it.": "Odpowiedzią na następną wiadomość będzie obraz narysowany na jej podstawie.",
"The result is no longer there.": "Wyniku już nie ma.",
"The server refused this computer's link: %(why)s": "Serwer odrzucił połączenie tego komputera: %(why)s",
"The server's answer had no picture in it.": "W odpowiedzi serwera nie było obrazu.",
@@ -3765,6 +3772,7 @@ const LEMBAS_I18N = {
"The previous command (Ctrl+Shift+↑)": "Predchádzajúci príkaz (Ctrl+Shift+↑)",
"The release that goes with the server's version. This is %(current)s.": "Vydanie, ktoré patrí k verzii servera. Toto je %(current)s.",
"The reply is ready.": "Odpoveď je hotová.",
"The reply to the next message is a picture drawn from it.": "Odpoveďou na ďalšiu správu bude obrázok nakreslený podľa nej.",
"The result is no longer there.": "Výsledok už tu nie je.",
"The server refused this computer's link: %(why)s": "Server odmietol prepojenie tohto počítača: %(why)s",
"The server's answer had no picture in it.": "V odpovedi servera nebol žiadny obrázok.",
+50 -2
View File
@@ -789,6 +789,47 @@ const MODE_WORDS = {
plan: () => t("Plan — changes nothing"),
}
// --- Draw a picture: the next message's reply is a picture (`force_tool: "image_generate"`) -------
let wantPicture = false // the next message, in a chat (not an agent chat), asks for a picture
/** Whether a chat here may be asked for a picture: the server draws for this account. */
function picturesOffered() {
const me = ctx.me()
return !!me?.services?.images && me?.permissions?.["tools.image"] !== false
}
function pictureChip() {
const b = chip({
ico: ICONS.picture,
label: t("Draw a picture"),
title: t("The reply to the next message is a picture drawn from it."),
menuish: false,
cls: "cchip--picture",
onclick: () => {
wantPicture = !wantPicture
b.setAttribute("aria-pressed", String(wantPicture))
$("composer-text").placeholder = wantPicture ? t("What to draw…") : t("Message")
$("composer-text").focus()
},
})
b.setAttribute("aria-pressed", String(wantPicture))
return b
}
/** The message's body, asking for a picture while the switch is on. */
function takePicture(body) {
return wantPicture ? { ...body, force_tool: "image_generate" } : body
}
/** The message went: the switch goes off with it. */
function pictureSent() {
if (!wantPicture) return
wantPicture = false
$("composer-text").placeholder = t("Message")
document.querySelector(".cchip--picture")?.setAttribute("aria-pressed", "false")
}
function modeChip(value, onChange, disabled) {
return chip({
ico: ICONS.shield,
@@ -953,6 +994,8 @@ async function renderChips() {
renderChips()
}, mayPick))
if (model) parts.push(effortChip(model, draft.effort, (l) => ((draft.effort = l), renderChips())))
if (draft.kind === "chat" && picturesOffered()) parts.push(pictureChip())
else wantPicture = false
if (draft.kind === "chat" && teamPossible(model)) parts.push(agentsChip(draft.agents, (a) => ((draft.agents = a), renderChips()), { cap: subagentsCap(), own: namedOnly(model?.subagents) }))
// An agent chat's subagents run on its device: offered as the server says for that device.
const device = draft.kind === "agent" ? draftDevice() : ""
@@ -998,6 +1041,8 @@ async function renderChips() {
if (chat.kind === "agent") parts.push(modeChip(chat.mode || "edit", (m) => save({ mode: m })))
if (chat.model) parts.push(modelChip(models, chat.model, (id) => save({ model: id }), mayPick))
parts.push(effortChip(current, chat.effort || levelsOf(current).start, (l) => save({ effort: l })))
if (chat.kind === "chat" && picturesOffered()) parts.push(pictureChip())
else wantPicture = false
box.replaceChildren(...parts.filter(Boolean))
const offer = await teamOffered(chat)
if (!offer || drawing !== chipsDrawn || open?.chat?.id !== chat.id) return
@@ -1368,7 +1413,8 @@ async function createChat(text, attachments) {
}
Object.assign(body, { kind: "agent", device, directory: draft.dir, mode: draft.mode || "edit" })
}
const r = await post("/chats", body)
const r = await post("/chats", draft.kind === "chat" ? takePicture(body) : body)
pictureSent()
draft = null
$("new-chat").hidden = true
$("main").dataset.new = "false"
@@ -1405,9 +1451,11 @@ async function submit() {
try {
if (draft) await createChat(text, files)
else if (open?.chat?.id) {
const body = { text }
let body = { text }
if (files.length) body.attachments = files
if (open.chat.kind === "chat") body = takePicture(body)
const r = await post(`/chats/${encodeURIComponent(open.chat.id)}/messages`, body)
pictureSent()
if (r.message) $("messages").append(userMessage(r.message))
if (r.reply) follower(open.chat, r.reply.id)
} else return