diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e924c7..0f92480 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/scripts/gen-i18n.py b/scripts/gen-i18n.py index 5c0a9eb..21314e4 100755 --- a/scripts/gen-i18n.py +++ b/scripts/gen-i18n.py @@ -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.", }, } diff --git a/tests/ui/run.mjs b/tests/ui/run.mjs index 30f5b3b..290506d 100644 --- a/tests/ui/run.mjs +++ b/tests/ui/run.mjs @@ -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] || "" diff --git a/ui/i18n.js b/ui/i18n.js index 969b588..3685fa4 100644 --- a/ui/i18n.js +++ b/ui/i18n.js @@ -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.", diff --git a/ui/js/chat.js b/ui/js/chat.js index 0b18fb7..139d485 100644 --- a/ui/js/chat.js +++ b/ui/js/chat.js @@ -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