Voice (dictate, read aloud) and Draw a picture over /v1, with the app's models scope
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s

The server kept voice and images on /v1 (models scope, which the app's token
already asks for); /me's services and scopes decide what is offered. Rust
sends the recording to /v1/audio/transcriptions, reads /v1/audio/speech back
as a data: URL (CSP media-src data:), and for /v1/images/generations takes
only the id of each of the person's own files from the answer and fetches
/v1/files/<id>/content from the app's own server. The microphone is allowed
only within seconds of the page's own Dictate (voice_arm); WebKitGTK's
media stream is switched on through its settings getter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 14:02:56 +00:00
1 parent 67ce6076c5
commit 2b87fa4c29
16 files changed
+1024 -4

No files matched your search

+6
View File
@@ -38,6 +38,12 @@ All notable changes to LLeMbas Desktop. The format follows
- **A chat's tools and skills.** Tools and skills… in a chat's menu switches the tool families
(web search among them) and the skills on or off for that chat only, as the web's chat settings
do, and lists the commands it was told to always allow, with Ask me about these again.
- **Voice.** Dictate, beside Send, records a message and writes down what was said where the
cursor is, through the server's speech to text; a reply's Read this aloud reads it in the
account's own voice (without its code), and stops when pressed again. Each shows only where the
server has it on for the account. The microphone is allowed only right after Dictate is pressed.
- **Draw a picture.** From Ctrl+K, where the server can draw for the account: a prompt and a
shape, and the picture under it. Only the account's own pictures on its own server are shown.
### Fixed
+120
View File
@@ -504,6 +504,22 @@ KEYS = (
"Always allowed here",
"Ask me about these again",
"Nothing to switch in this chat.",
# voice.js, draw.js: dictation, read aloud, and drawing a picture
"Dictate a message",
"Stop dictating",
"Writing down…",
"The microphone could not be opened: %(why)s",
"Read this aloud",
"The server's answer was not audio.",
"Draw a picture",
"What to draw…",
"Shape",
"Square",
"Wide",
"Tall",
"Draw",
"Drawing… a picture can take a minute or two.",
"The server's answer had no picture in it.",
)
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
@@ -761,6 +777,19 @@ OWN: dict[str, dict[str, str]] = {
"Nothing remembered yet.": "Zatím si nic nepamatuje.",
"Tools and skills…": "Nástroje a dovednosti…",
"Nothing to switch in this chat.": "V této konverzaci není co přepnout.",
"Stop dictating": "Zastavit diktování",
"Writing down…": "Zapisuji…",
"The microphone could not be opened: %(why)s": "Mikrofon se nepodařilo otevřít: %(why)s",
"Read this aloud": "Přečíst nahlas",
"The server's answer was not audio.": "Odpověď serveru nebyla zvuk.",
"What to draw…": "Co nakreslit…",
"Shape": "Tvar",
"Square": "Čtverec",
"Wide": "Široký",
"Tall": "Vysoký",
"Draw": "Nakreslit",
"Drawing… a picture can take a minute or two.": "Kreslím… obrázek může trvat minutu nebo dvě.",
"The server's answer had no picture in it.": "V odpovědi serveru nebyl žádný obrázek.",
},
"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.",
@@ -1013,6 +1042,19 @@ OWN: dict[str, dict[str, str]] = {
"Nothing remembered yet.": "Noch nichts behalten.",
"Tools and skills…": "Werkzeuge und Fähigkeiten…",
"Nothing to switch in this chat.": "In diesem Chat gibt es nichts umzuschalten.",
"Stop dictating": "Diktieren beenden",
"Writing down…": "Wird aufgeschrieben…",
"The microphone could not be opened: %(why)s": "Das Mikrofon konnte nicht geöffnet werden: %(why)s",
"Read this aloud": "Vorlesen",
"The server's answer was not audio.": "Die Antwort des Servers war kein Audio.",
"What to draw…": "Was gezeichnet werden soll…",
"Shape": "Form",
"Square": "Quadratisch",
"Wide": "Breit",
"Tall": "Hoch",
"Draw": "Zeichnen",
"Drawing… a picture can take a minute or two.": "Wird gezeichnet… ein Bild kann ein bis zwei Minuten dauern.",
"The server's answer had no picture in it.": "Die Antwort des Servers enthielt kein 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.",
@@ -1265,6 +1307,19 @@ OWN: dict[str, dict[str, str]] = {
"Nothing remembered yet.": "Todavía no se recuerda nada.",
"Tools and skills…": "Herramientas y habilidades…",
"Nothing to switch in this chat.": "No hay nada que activar o desactivar en este chat.",
"Stop dictating": "Dejar de dictar",
"Writing down…": "Transcribiendo…",
"The microphone could not be opened: %(why)s": "No se pudo abrir el micrófono: %(why)s",
"Read this aloud": "Leer en voz alta",
"The server's answer was not audio.": "La respuesta del servidor no era audio.",
"What to draw…": "Qué dibujar…",
"Shape": "Forma",
"Square": "Cuadrada",
"Wide": "Ancha",
"Tall": "Alta",
"Draw": "Dibujar",
"Drawing… a picture can take a minute or two.": "Dibujando… una imagen puede tardar uno o dos minutos.",
"The server's answer had no picture in it.": "La respuesta del servidor no traía ninguna imagen.",
},
"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.",
@@ -1517,6 +1572,19 @@ OWN: dict[str, dict[str, str]] = {
"Nothing remembered yet.": "Rien n’est encore retenu.",
"Tools and skills…": "Outils et compétences…",
"Nothing to switch in this chat.": "Rien à activer ou désactiver dans cette conversation.",
"Stop dictating": "Arrêter la dictée",
"Writing down…": "Transcription…",
"The microphone could not be opened: %(why)s": "Le micro n’a pas pu être ouvert : %(why)s",
"Read this aloud": "Lire à voix haute",
"The server's answer was not audio.": "La réponse du serveur n’était pas de l’audio.",
"What to draw…": "Que dessiner…",
"Shape": "Forme",
"Square": "Carrée",
"Wide": "Large",
"Tall": "Haute",
"Draw": "Dessiner",
"Drawing… a picture can take a minute or two.": "Dessin en cours… une image peut prendre une minute ou deux.",
"The server's answer had no picture in it.": "La réponse du serveur ne contenait aucune image.",
},
"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.",
@@ -1769,6 +1837,19 @@ OWN: dict[str, dict[str, str]] = {
"Nothing remembered yet.": "Még nincs megjegyezve semmi.",
"Tools and skills…": "Eszközök és készségek…",
"Nothing to switch in this chat.": "Ebben a beszélgetésben nincs mit átkapcsolni.",
"Stop dictating": "Diktálás leállítása",
"Writing down…": "Leírás…",
"The microphone could not be opened: %(why)s": "A mikrofont nem sikerült megnyitni: %(why)s",
"Read this aloud": "Felolvasás",
"The server's answer was not audio.": "A szerver válasza nem hang volt.",
"What to draw…": "Mit rajzoljon…",
"Shape": "Forma",
"Square": "Négyzet",
"Wide": "Széles",
"Tall": "Magas",
"Draw": "Rajzolás",
"Drawing… a picture can take a minute or two.": "Rajzolás… egy kép egy-két percig is eltarthat.",
"The server's answer had no picture in it.": "A szerver válaszában nem volt kép.",
},
"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.",
@@ -2021,6 +2102,19 @@ OWN: dict[str, dict[str, str]] = {
"Nothing remembered yet.": "Ancora niente da ricordare.",
"Tools and skills…": "Strumenti e abilità…",
"Nothing to switch in this chat.": "Non c’è niente da attivare o disattivare in questa chat.",
"Stop dictating": "Interrompi la dettatura",
"Writing down…": "Trascrizione…",
"The microphone could not be opened: %(why)s": "Impossibile aprire il microfono: %(why)s",
"Read this aloud": "Leggi ad alta voce",
"The server's answer was not audio.": "La risposta del server non era audio.",
"What to draw…": "Cosa disegnare…",
"Shape": "Forma",
"Square": "Quadrata",
"Wide": "Larga",
"Tall": "Alta",
"Draw": "Disegna",
"Drawing… a picture can take a minute or two.": "Disegno in corso… un’immagine può richiedere un minuto o due.",
"The server's answer had no picture in it.": "La risposta del server non conteneva immagini.",
},
"pl": {
"Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.",
@@ -2273,6 +2367,19 @@ OWN: dict[str, dict[str, str]] = {
"Nothing remembered yet.": "Na razie nic nie zapamiętano.",
"Tools and skills…": "Narzędzia i umiejętności…",
"Nothing to switch in this chat.": "W tym czacie nie ma czego przełączać.",
"Stop dictating": "Zatrzymaj dyktowanie",
"Writing down…": "Zapisywanie…",
"The microphone could not be opened: %(why)s": "Nie udało się otworzyć mikrofonu: %(why)s",
"Read this aloud": "Przeczytaj na głos",
"The server's answer was not audio.": "Odpowiedź serwera nie była dźwiękiem.",
"What to draw…": "Co narysować…",
"Shape": "Kształt",
"Square": "Kwadrat",
"Wide": "Szeroki",
"Tall": "Wysoki",
"Draw": "Narysuj",
"Drawing… a picture can take a minute or two.": "Rysowanie… obraz może zająć minutę lub dwie.",
"The server's answer had no picture in it.": "W odpowiedzi serwera nie było obrazu.",
},
"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.",
@@ -2525,6 +2632,19 @@ OWN: dict[str, dict[str, str]] = {
"Nothing remembered yet.": "Zatiaľ si nič nepamätá.",
"Tools and skills…": "Nástroje a schopnosti…",
"Nothing to switch in this chat.": "V tejto konverzácii nie je čo prepnúť.",
"Stop dictating": "Zastaviť diktovanie",
"Writing down…": "Zapisujem…",
"The microphone could not be opened: %(why)s": "Mikrofón sa nepodarilo otvoriť: %(why)s",
"Read this aloud": "Prečítať nahlas",
"The server's answer was not audio.": "Odpoveď servera nebola zvuk.",
"What to draw…": "Čo nakresliť…",
"Shape": "Tvar",
"Square": "Štvorec",
"Wide": "Široký",
"Tall": "Vysoký",
"Draw": "Nakresliť",
"Drawing… a picture can take a minute or two.": "Kreslím… obrázok môže trvať minútu alebo dve.",
"The server's answer had no picture in it.": "V odpovedi servera nebol žiadny obrázok.",
},
}
+1
View File
@@ -2149,6 +2149,7 @@ dependencies = [
"tauri-plugin-updater",
"tungstenite",
"ureq",
"webkit2gtk",
]
[[package]]
+5
View File
@@ -32,6 +32,11 @@ tungstenite = { version = "0.30", default-features = false, features = ["handsha
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[target.'cfg(target_os = "linux")'.dependencies]
# The webview's own settings: getUserMedia is off in WebKitGTK until the app turns it on.
# The same crate and version wry already builds on.
webkit2gtk = { version = "2.0", features = ["v2_4"] }
[profile.release]
codegen-units = 1
lto = true
+119
View File
@@ -36,6 +36,7 @@ use serde::Serialize;
use serde_json::Value;
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::webview::{PermissionKind, PermissionResponse};
use tauri::window::Color;
use tauri::{
AppHandle, DragDropEvent, Emitter, LogicalPosition, Manager, RunEvent, Url, WebviewUrl,
@@ -122,6 +123,9 @@ struct App {
watch: watch::Watch,
/// The files last dropped on the window: what `attach_dropped` may send, and nothing else.
dropped: Mutex<Vec<PathBuf>>,
/// Until when the page's own Dictate button may have the microphone (`voice_arm`): a request
/// for it at any other moment is refused.
mic_until: Mutex<Option<std::time::Instant>>,
}
struct TrayItems {
@@ -153,6 +157,13 @@ impl App {
Some((server.app, account.token))
}
/// The server's own address and the app's token: for the `/v1` routes (voice, pictures).
fn door_v1(&self) -> Option<(String, String)> {
let server = self.server.lock().unwrap().clone()?;
let account = self.account.lock().unwrap().clone()?;
Some((server.base, account.token))
}
/// The folders the agent may use: what the explorer and the viewer may read.
fn roots(&self) -> Vec<String> {
self.remote
@@ -452,6 +463,77 @@ async fn model_image(handle: AppHandle, path: String) -> Result<String, ApiError
.map_err(|e| ApiError::new(0, e.to_string()))?
}
// --- Voice and pictures (`/v1`, the app's token holds the `models` scope) -----------------------
/// Run a `/v1` call off the page's thread, as the signed-in account; a 401 signs the app out.
async fn on_v1<T: Send + 'static>(
handle: &AppHandle,
f: impl FnOnce(&str, &str) -> Result<T, ApiError> + Send + 'static,
) -> Result<T, ApiError> {
let Some((base, token)) = handle.state::<App>().door_v1() else {
return Err(ApiError::new(401, "Not signed in."));
};
let result = tauri::async_runtime::spawn_blocking(move || f(&base, &token))
.await
.map_err(|e| ApiError::new(0, e.to_string()))?;
if matches!(&result, Err(e) if e.status == 401) {
token_refused(handle);
}
result
}
/// The page is about to ask for the microphone, because the person pressed Dictate: for the
/// next few seconds a request for it is allowed (`permission`), and at no other time.
#[tauri::command]
fn voice_arm(app: tauri::State<'_, App>) {
*app.mic_until.lock().unwrap() =
Some(std::time::Instant::now() + std::time::Duration::from_secs(10));
}
/// A recording made in the composer, in words: its bytes as the raw body of the call, its media
/// type in `x-type`.
#[tauri::command]
async fn transcribe(
handle: AppHandle,
request: tauri::ipc::Request<'_>,
) -> Result<String, ApiError> {
let tauri::ipc::InvokeBody::Raw(bytes) = request.body() else {
return Err(ApiError::new(400, "No recording."));
};
if bytes.len() as u64 > server::RECORDING_LIMIT {
return Err(ApiError::new(413, "That recording is too long."));
}
let kind = request
.headers()
.get("x-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("")
.to_string();
let bytes = bytes.clone();
on_v1(&handle, move |base, token| {
server::transcribe(base, token, &kind, &bytes)
})
.await
}
/// A reply read aloud, as a `data:` URL of its audio.
#[tauri::command]
async fn speak(handle: AppHandle, text: String) -> Result<String, ApiError> {
on_v1(&handle, move |base, token| {
server::speak(base, token, &text)
})
.await
}
/// Pictures drawn by the instance for `prompt`, as `data:` URLs.
#[tauri::command]
async fn draw(handle: AppHandle, prompt: String, size: String) -> Result<Vec<String>, ApiError> {
on_v1(&handle, move |base, token| {
server::draw(base, token, &prompt, &size)
})
.await
}
/// How the CLI is started in a terminal here (`lembas`, or the bundled one by its path): the
/// first word of "continue in a terminal" for an agent chat on this computer.
#[tauri::command]
@@ -1249,7 +1331,39 @@ fn build_main(handle: &AppHandle) -> tauri::Result<WebviewWindow> {
.decorations(false)
.background_color(theme.background())
.on_navigation(allowed)
.on_permission_request(permission)
.build()
.inspect(media_stream)
}
/// WebKitGTK answers no permission request its app does not, and has no prompt of its own. The
/// microphone is allowed when the page's own Dictate button has just asked for it (`voice_arm`,
/// which only the app's page can call: sealed frames have no bridge), once; nothing else is.
fn permission(view: tauri::Webview, kind: PermissionKind) -> PermissionResponse {
if kind != PermissionKind::Microphone {
return PermissionResponse::Default;
}
let app = view.app_handle().state::<App>();
let armed = app.mic_until.lock().unwrap().take();
if armed.is_some_and(|until| std::time::Instant::now() <= until) {
PermissionResponse::Allow
} else {
PermissionResponse::Deny
}
}
/// `getUserMedia` is off in WebKitGTK unless the app turns it on (it is on in other webviews).
/// The settings are read with their own getter: the `settings` property is write-only.
fn media_stream(window: &WebviewWindow) {
#[cfg(target_os = "linux")]
let _ = window.with_webview(|pv| {
use webkit2gtk::{SettingsExt, WebViewExt};
if let Some(settings) = pv.inner().settings() {
settings.set_enable_media_stream(true);
}
});
#[cfg(not(target_os = "linux"))]
let _ = window;
}
/// The window shows the app's own page, and the sealed frames inside it; nothing else, ever.
@@ -1710,6 +1824,10 @@ pub fn run() {
sealed_close,
attach_pick,
library_upload,
voice_arm,
transcribe,
speak,
draw,
attach_dropped,
attach_bytes,
set_theme,
@@ -1770,6 +1888,7 @@ pub fn run() {
sealed: sealed::Pages::default(),
watch: watch::Watch::default(),
dropped: Mutex::new(Vec::new()),
mic_until: Mutex::new(None),
});
// Without a tray (no AppIndicator library, no StatusNotifier host) the app still
// starts: the title bar's menu has every item the tray has.
+228 -1
View File
@@ -652,10 +652,15 @@ fn header_name(name: &str) -> String {
/// The body of a `multipart/form-data` request with one file in the field `field`.
fn multipart(boundary: &str, field: &str, name: &str, bytes: &[u8]) -> Vec<u8> {
multipart_typed(boundary, field, name, "application/octet-stream", bytes)
}
/// The same, with the file's own media type (a recording says what it is: `audio/webm`).
fn multipart_typed(boundary: &str, field: &str, name: &str, kind: &str, bytes: &[u8]) -> Vec<u8> {
let mut body = Vec::with_capacity(bytes.len() + 256);
body.extend_from_slice(
format!(
"--{boundary}\r\nContent-Disposition: form-data; name=\"{field}\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n",
"--{boundary}\r\nContent-Disposition: form-data; name=\"{field}\"; filename=\"{}\"\r\nContent-Type: {kind}\r\n\r\n",
header_name(name)
)
.as_bytes(),
@@ -707,6 +712,192 @@ pub fn upload_to_library(
send_file(app, token, &path, "files", name, bytes)
}
// --- Voice and pictures: the OpenAI-compatible routes of `/v1`, under the `models` scope ------------
/// The most a recording may be here: the server's own limit is 25 MB.
pub const RECORDING_LIMIT: u64 = 25 * 1024 * 1024;
/// The most a reply read aloud may be, as audio.
pub const SPEECH_LIMIT: u64 = 32 * 1024 * 1024;
/// The most a generated picture may be.
pub const DRAWN_LIMIT: u64 = 24 * 1024 * 1024;
/// The media types a recording may say it is; anything else goes as `audio/webm`.
const RECORDING_TYPES: [&str; 5] = [
"audio/webm",
"audio/ogg",
"audio/wav",
"audio/mpeg",
"audio/mp4",
];
/// A recording's media type as it may be sent: one of ours, its parameters (`;codecs=opus`) off.
pub fn recording_type(kind: &str) -> &'static str {
let bare = kind
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
RECORDING_TYPES
.iter()
.find(|t| **t == bare)
.copied()
.unwrap_or("audio/webm")
}
/// A recording, in words: `POST /v1/audio/transcriptions` (the instance's own model, the
/// account's language). The text the server heard.
pub fn transcribe(base: &str, token: &str, kind: &str, bytes: &[u8]) -> Result<String, ApiError> {
if bytes.is_empty() {
return Err(ApiError::new(400, "The recording was empty."));
}
let kind = recording_type(kind);
let ext = kind
.rsplit('/')
.next()
.unwrap_or("webm")
.replace("mpeg", "mp3");
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let boundary = format!("llembas-desktop-{nanos:x}");
let res = agent(Duration::from_secs(300))
.post(&format!("{base}/v1/audio/transcriptions"))
.set("authorization", &format!("Bearer {token}"))
.set("accept", "application/json")
.set(
"content-type",
&format!("multipart/form-data; boundary={boundary}"),
)
.send_bytes(&multipart_typed(
&boundary,
"file",
&format!("speech.{ext}"),
kind,
bytes,
))?;
let v: Value = res
.into_json()
.map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))?;
Ok(v.get("text")
.and_then(Value::as_str)
.unwrap_or("")
.to_string())
}
/// `bytes` of a media type of `kinds` (by its prefix), as a `data:` URL; at most `limit`.
fn as_data_url(res: ureq::Response, prefix: &str, limit: u64) -> Result<String, ApiError> {
let kind = res
.content_type()
.split(';')
.next()
.unwrap_or("")
.trim()
.to_ascii_lowercase();
let fits = if prefix == "image/" {
IMAGE_TYPES.contains(&kind.as_str())
} else {
kind.starts_with(prefix)
&& kind
.chars()
.all(|c| c.is_ascii_alphanumeric() || "/-+.".contains(c))
};
if !fits {
return Err(ApiError::new(
415,
format!("Not what was asked for: {kind}"),
));
}
let mut bytes = Vec::new();
res.into_reader()
.take(limit)
.read_to_end(&mut bytes)
.map_err(|e| ApiError::new(0, format!("The server's answer could not be read: {e}")))?;
if bytes.len() as u64 >= limit {
return Err(ApiError::new(413, "The server's answer is too large."));
}
Ok(format!("data:{kind};base64,{}", base64(&bytes)))
}
/// A reply read aloud: `POST /v1/audio/speech` in the account's own voice and speed. The audio,
/// as a `data:` URL the page plays.
pub fn speak(base: &str, token: &str, text: &str) -> Result<String, ApiError> {
let text = text.trim();
if text.is_empty() {
return Err(ApiError::new(400, "There is nothing to read aloud."));
}
let res = agent(Duration::from_secs(300))
.post(&format!("{base}/v1/audio/speech"))
.set("authorization", &format!("Bearer {token}"))
.send_json(serde_json::json!({ "input": text, "response_format": "mp3" }))?;
as_data_url(res, "audio/", SPEECH_LIMIT)
}
/// The id of one of the person's own files in a URL `/v1/images/generations` answered with:
/// `…/v1/files/<id>/content`, and nothing else. The picture is then fetched from this app's own
/// server by that id -- never from the address the answer named.
pub fn own_file_id(url: &str) -> Option<String> {
let rest = url.split(['?', '#']).next()?;
let tail = rest.strip_suffix("/content")?;
let (head, id) = tail.rsplit_once('/')?;
let ok = head.ends_with("/v1/files")
&& !id.is_empty()
&& id.len() <= 128
&& id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
ok.then(|| id.to_string())
}
/// Sizes a picture may be asked for: the instance rounds to what its workflow takes.
const SIZES: [&str; 3] = ["1024x1024", "1344x768", "768x1344"];
/// Pictures drawn by the instance: `POST /v1/images/generations` (its own workflow and default
/// checkpoint), each then read from the person's own files on this server. `data:` URLs.
pub fn draw(base: &str, token: &str, prompt: &str, size: &str) -> Result<Vec<String>, ApiError> {
let prompt = prompt.trim();
if prompt.is_empty() {
return Err(ApiError::new(400, "Say what to draw."));
}
let mut body = serde_json::json!({ "prompt": prompt, "n": 1, "response_format": "url" });
if SIZES.contains(&size) {
body["size"] = Value::String(size.to_string());
}
let res = agent(Duration::from_secs(900))
.post(&format!("{base}/v1/images/generations"))
.set("authorization", &format!("Bearer {token}"))
.set("accept", "application/json")
.send_json(body)?;
let v: Value = res
.into_json()
.map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))?;
let ids: Vec<String> = v
.get("data")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|i| i.get("url").and_then(Value::as_str).and_then(own_file_id))
.collect()
})
.unwrap_or_default();
if ids.is_empty() {
return Err(ApiError::new(
0,
"The server's answer had no picture of yours in it.",
));
}
ids.iter()
.map(|id| {
let res = agent(Duration::from_secs(60))
.get(&format!("{base}/v1/files/{id}/content"))
.set("authorization", &format!("Bearer {token}"))
.call()?;
as_data_url(res, "image/", DRAWN_LIMIT)
})
.collect()
}
/// One `multipart/form-data` POST of one file to `path` on the app's door; the JSON answer.
fn send_file(
app: &str,
@@ -985,6 +1176,42 @@ mod tests {
assert_eq!(base64(&[0xfb, 0xff]), "+/8=");
}
#[test]
fn a_drawn_picture_is_read_only_as_one_of_the_persons_own_files() {
assert_eq!(
own_file_id("https://ai.example/v1/files/abc_12-3/content").as_deref(),
Some("abc_12-3")
);
assert_eq!(
own_file_id("http://other/v1/files/x/content?t=1").as_deref(),
Some("x")
);
assert_eq!(own_file_id("https://ai.example/v1/files/../content"), None);
assert_eq!(own_file_id("https://ai.example/v1/files//content"), None);
assert_eq!(own_file_id("https://ai.example/uploads/x/content"), None);
assert_eq!(
own_file_id("https://ai.example/v1/files/a%2Fb/content"),
None
);
assert_eq!(own_file_id("javascript:alert(1)"), None);
}
#[test]
fn a_recording_says_one_of_the_known_types() {
assert_eq!(recording_type("audio/webm;codecs=opus"), "audio/webm");
assert_eq!(recording_type("Audio/OGG"), "audio/ogg");
assert_eq!(recording_type("text/html"), "audio/webm");
let body = String::from_utf8(multipart_typed(
"B",
"file",
"speech.webm",
"audio/webm",
b"x",
))
.unwrap();
assert!(body.contains("filename=\"speech.webm\"\r\nContent-Type: audio/webm\r\n"));
}
#[test]
fn a_file_goes_as_one_multipart_field() {
let body = multipart("XYZ", "file", "a\"b\r\n.txt", b"hello");
+1 -1
View File
@@ -10,7 +10,7 @@
"withGlobalTauri": true,
"windows": [],
"security": {
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src ipc: http://ipc.localhost; frame-src llembas-sealed: http://llembas-sealed.localhost; object-src 'none'; base-uri 'none'; form-action 'none'",
"csp": "default-src 'self'; img-src 'self' data:; media-src data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src ipc: http://ipc.localhost; frame-src llembas-sealed: http://llembas-sealed.localhost; object-src 'none'; base-uri 'none'; form-action 'none'",
"dangerousDisableAssetCspModification": [
"style-src"
]
+128
View File
@@ -1398,6 +1398,134 @@ test("H8: at 390 px the server's pane and the switches stay inside the window",
assert.deepEqual(p.errors, [])
})
// I8: voice (/v1/audio, through Rust) and drawing a picture (/v1/images, through Rust).
const PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
function i8({ stt = true, tts = true, images = true, scopes = ["app", "models"] } = {}, commands = {}) {
const reply = { id: "m6", role: "assistant", created_at: STAMP, complete: true, stopped: false, error: null, model: "qwen", metrics: null, steps: [{ kind: "text", markdown: "Lembas is **baked** in leaves.\n\n```\nsecret\n```" }] }
return {
status: status(),
routes: baseRoutes({
"GET /me": { id: "u1", name: "Ada Example", email: "ada@example.org", theme: "moria", language: "en", permissions: {}, services: { stt, tts, images, search: false, fetch: false }, scopes },
"GET /chats/chat1": { chat: chatRow(), messages: [userMessage("m5", "How is lembas made?"), reply], more: false, live: null },
}),
commands: { transcribe: "baked in leaves", speak: "data:audio/mpeg;base64,SUQz", draw: [PNG, "https://elsewhere.example/x.png"], ...commands },
streams: {},
}
}
// A microphone and a recorder that record two bytes: the page's own, the browser's untouched.
async function fakeMicrophone(p) {
await p.evaluate(() => {
const track = { stop() {} }
// The test's page is not a secure context, so it has no mediaDevices of its own.
if (!navigator.mediaDevices) Object.defineProperty(navigator, "mediaDevices", { value: {}, configurable: true })
navigator.mediaDevices.getUserMedia = async () => ({ getTracks: () => [track] })
window.MediaRecorder = class extends EventTarget {
static isTypeSupported(x) {
return x === "audio/webm;codecs=opus"
}
constructor(stream, options) {
super()
this.mimeType = options?.mimeType || ""
this.state = "inactive"
}
start() {
this.state = "recording"
}
stop() {
this.state = "inactive"
const data = new Event("dataavailable")
data.data = new Blob([new Uint8Array([1, 2])], { type: this.mimeType })
this.dispatchEvent(data)
this.dispatchEvent(new Event("stop"))
}
}
HTMLMediaElement.prototype.play = async function () {}
})
}
test("I8: Dictate records, asks Rust for the microphone first, and puts the words where the cursor is", async () => {
const p = await page(i8())
await p.waitForSelector("#dictate:not([hidden])")
await fakeMicrophone(p)
await p.fill("#composer-text", "Lembas is")
await p.click("#dictate")
await p.waitForSelector('#dictate.is-recording[aria-pressed="true"]')
const order = await p.evaluate(() => window.__mock.calls.map((c) => c.cmd).filter((c) => c === "voice_arm"))
assert.deepEqual(order, ["voice_arm"])
await shot(p, "i8-dictate-1280")
await p.click("#dictate")
await p.waitForFunction(() => document.getElementById("composer-text").value === "Lembas is baked in leaves")
assert.equal(await p.getAttribute("#dictate", "aria-pressed"), "false")
assert.ok(await p.evaluate(() => window.__mock.calls.some((c) => c.cmd === "transcribe")))
assert.deepEqual(p.errors, [])
})
test("I8: no Dictate, Read aloud or Draw where the server has none of them, or the token lacks models", async () => {
let p = await page(i8({ stt: false, tts: false, images: false }))
await p.click('.chatrow[data-id="chat1"]')
await p.waitForSelector('.msg--assistant[data-id="m6"]')
assert.equal(await p.isHidden("#dictate"), true)
assert.equal(await p.locator(".msg__speak").count(), 0)
await p.keyboard.press("Control+k")
assert.equal(await p.locator(".palette__item >> text=Draw a picture").count(), 0)
await p.context().close()
p = await page(i8({ scopes: ["app"] }))
await p.waitForSelector("#composer")
await p.waitForFunction(() => window.__mock.api("GET", "/me").length === 1)
assert.equal(await p.isHidden("#dictate"), true)
assert.deepEqual(p.errors, [])
})
test("I8: Read aloud speaks a reply's words without its markdown or code, and stops when pressed again", async () => {
const p = await page(i8())
await fakeMicrophone(p)
await p.click('.chatrow[data-id="chat1"]')
await p.waitForSelector('.msg--assistant[data-id="m6"] .msg__speak')
await p.click('.msg--assistant[data-id="m6"] .msg__speak')
await p.waitForFunction(() => window.__mock.calls.some((c) => c.cmd === "speak"))
assert.deepEqual((await p.evaluate(() => window.__mock.calls.find((c) => c.cmd === "speak").args)), { text: "Lembas is baked in leaves." })
await p.waitForSelector('.msg__speak[aria-pressed="true"]:not(.is-busy)')
await p.click('.msg--assistant[data-id="m6"] .msg__speak')
await p.waitForSelector('.msg__speak[aria-pressed="false"]')
assert.deepEqual(p.errors, [])
})
test("I8: Draw a picture shows only the server's own pictures, as Rust read them", async () => {
const p = await page(i8())
await p.waitForFunction(() => window.__mock.api("GET", "/me").length === 1)
await p.keyboard.press("Control+k")
await p.fill(".palette__input", "draw")
await p.click(".palette__item >> text=Draw a picture")
await p.fill(".draw__prompt", "A lembas loaf on a leaf")
await p.selectOption(".draw__size", "1344x768")
await p.click(".draw button[type=submit]")
await p.waitForSelector(".draw__pic img")
assert.deepEqual((await p.evaluate(() => window.__mock.calls.find((c) => c.cmd === "draw").args)), { prompt: "A lembas loaf on a leaf", size: "1344x768" })
const srcs = await p.$$eval(".draw__pic img", (l) => l.map((x) => x.getAttribute("src")))
assert.deepEqual(srcs, [PNG])
await shot(p, "i8-draw-1280")
assert.deepEqual(p.errors, [])
})
test("I8: at 390 px the composer with Dictate and the drawing dialog stay inside the window", async () => {
const p = await page(i8(), { width: 390, height: 844 })
await p.waitForSelector("#dictate:not([hidden])")
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
await shot(p, "i8-composer-390")
await p.keyboard.press("Control+k")
await p.fill(".palette__input", "draw")
await p.click(".palette__item >> text=Draw a picture")
await p.fill(".draw__prompt", "A lembas loaf")
await p.click(".draw button[type=submit]")
await p.waitForSelector(".draw__pic img")
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
await shot(p, "i8-draw-390")
assert.deepEqual(p.errors, [])
})
// --- Run ----------------------------------------------------------------------------------------
const only = process.argv[2] || ""
+19 -2
View File
@@ -1168,6 +1168,18 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
box-shadow: var(--shadow-lg);
}
.dialog::backdrop { background: var(--scrim); }
.dialog--wide { width: min(var(--w-prose), calc(100vw - 2 * var(--sp-4))); }
/* Draw a picture (draw.js): the prompt, a shape and two buttons on one line, the pictures below. */
.draw .dialog__title { display: flex; gap: var(--sp-2); align-items: center; }
.draw__prompt { width: 100%; }
.draw__bar { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: var(--sp-2); align-items: center; }
.draw__size { justify-self: start; max-width: 100%; }
.draw__out { display: grid; gap: var(--sp-3); max-height: 60vh; overflow-y: auto; }
.draw__out:empty { display: none; }
.draw__pic { display: grid; gap: var(--sp-1); margin: 0; }
.draw__pic img { display: block; width: 100%; height: auto; border-radius: var(--radius-lg); background: var(--surface); }
.draw__pic figcaption { font-size: var(--text-xs); color: var(--ink-muted); overflow-wrap: anywhere; }
.dialog__body { display: grid; gap: var(--sp-3); padding: var(--sp-4); }
.dialog__title { font: 600 var(--text-md) / var(--leading-tight) var(--font-ui); }
/* A line to copy: as it is, on one line that scrolls on its own rather than the dialog. */
@@ -1276,11 +1288,16 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
.composer__files { justify-content: start; }
.composer__bar {
display: grid;
grid-template-columns: var(--chip-h) minmax(0, 1fr) auto auto;
grid-template-columns: var(--chip-h) minmax(0, 1fr) auto auto auto;
gap: var(--sp-2);
align-items: center;
}
.composer[data-attach="false"] .composer__bar { grid-template-columns: minmax(0, 1fr) auto auto; }
.composer[data-attach="false"] .composer__bar { grid-template-columns: minmax(0, 1fr) auto auto auto; }
/* Dictate: a round tool beside Send; while it records it says so in the accent. */
.composer__mic { width: var(--round); height: var(--round); border-radius: var(--radius-full); }
.composer .composer__mic.is-recording { color: var(--accent-ink); background: var(--accent); }
.composer__mic.is-busy, .msg__speak.is-busy { opacity: 0.6; cursor: progress; }
.msg__speak[aria-pressed="true"] { color: var(--accent); }
.composer__attach { width: var(--chip-h); height: var(--chip-h); border: var(--border-w) solid var(--border); border-radius: var(--radius-full); }
/* The chips stay on one line; where they do not fit, the line scrolls by itself. */
.composer__chips { display: flex; flex-wrap: nowrap; gap: var(--sp-1); min-width: 0; align-items: center; overflow-x: auto; scrollbar-width: none; }
+120
View File
@@ -116,8 +116,12 @@ const LEMBAS_I18N = {
"Delete this chat?": "Smazat tuto konverzaci?",
"Delete this scheduled task?": "Odstranit tuto naplánovanou úlohu?",
"Deny": "Zamítnout",
"Dictate a message": "Nadiktovat zprávu",
"Disconnected. Close and reopen to reconnect.": "Odpojeno. Pro opětovné připojení zavřete a znovu otevřete.",
"Done": "Hotovo",
"Draw": "Nakreslit",
"Draw a picture": "Nakreslit obrázek",
"Drawing… a picture can take a minute or two.": "Kreslím… obrázek může trvat minutu nebo dvě.",
"Drop the files to attach them": "Pusťte soubory a přiloží se",
"Due %(when)s": "Naplánováno na %(when)s",
"Each run sees the ones before it.": "Každé spuštění vidí ta předchozí.",
@@ -248,6 +252,7 @@ const LEMBAS_I18N = {
"Queued": "Ve frontě",
"Queued: {{ahead}} ahead": "Ve frontě: před ním {{ahead}}",
"Read again": "Načíst znovu",
"Read this aloud": "Přečíst nahlas",
"Reading the prompt": "Čte se výzva",
"Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)": "Čte se výzva: {{done}} / {{total}} tokenů ({{percent}} %)",
"Reasoning effort": "Náročnost uvažování",
@@ -290,6 +295,7 @@ const LEMBAS_I18N = {
"Server": "Server",
"Set the time yourself": "Nastavit čas sami",
"Settings": "Nastavení",
"Shape": "Tvar",
"Show %(n)s more": "Zobrazit další (%(n)s)",
"Shown by the agent": "Ukázal agent",
"Sidebar": "Postranní panel",
@@ -303,11 +309,13 @@ const LEMBAS_I18N = {
"Skipped": "Vynecháno",
"Something to remember…": "Něco k zapamatování…",
"Source": "Zdroj",
"Square": "Čtverec",
"Start the agent": "Spustit agenta",
"Stop": "Zastavit",
"Stop %(name)s": "Zastavit %(name)s",
"Stop %(name)s and the members under it? They leave the team.": "Zastavit %(name)s a členy pod ním? Opustí tým.",
"Stop %(name)s and the subagents under it?": "Zastavit %(name)s a podagenty pod ním?",
"Stop dictating": "Zastavit diktování",
"Stop the agent": "Zastavit agenta",
"Stopped": "Zastaveno",
"Stopped.": "Zastaveno.",
@@ -319,6 +327,7 @@ const LEMBAS_I18N = {
"Switched off here only. What is on to begin with is your default for this model, in Settings → Models.": "Vypnuto jen tady. Co je na začátku zapnuté, je vaše výchozí nastavení pro tento model v Nastavení → Modely.",
"System prompt": "Systémový pokyn",
"Taken in while it ran:": "Převzato během běhu:",
"Tall": "Vysoký",
"Tasks: %(n)s": "Úlohy: %(n)s",
"Team member": "Člen týmu",
"Team: %(size)s of at most %(max)s subagents": "Tým: %(size)s z nejvýše %(max)s podagentů",
@@ -346,6 +355,7 @@ const LEMBAS_I18N = {
"The instruction each run is given, on its own: say everything it needs.": "Instrukce, kterou dostane každé spuštění samostatně: uveďte vše, co potřebuje.",
"The interface only. Models answer in the language you write to them in, whatever this says.": "Jen rozhraní. Modely odpovídají v jazyce, ve kterém jim píšete, bez ohledu na toto nastavení.",
"The last %(n)s runs failed: %(why)s": "Posledních %(n)s spuštění selhalo: %(why)s",
"The microphone could not be opened: %(why)s": "Mikrofon se nepodařilo otevřít: %(why)s",
"The model's own": "Podle modelu",
"The most an agent chat may do here": "Nejvíc, co tu agentní konverzace smí",
"The next command": "Další příkaz",
@@ -357,6 +367,8 @@ const LEMBAS_I18N = {
"The reply is ready.": "Odpověď je hotová.",
"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.",
"The server's answer was not audio.": "Odpověď serveru nebyla zvuk.",
"The terminal could not be loaded.": "Terminál se nepodařilo načíst.",
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Terminál potřebuje novější server LLeMbas. Požádejte jeho správce o aktualizaci.",
"Theme": "Vzhled",
@@ -399,10 +411,13 @@ const LEMBAS_I18N = {
"Waiting for {{model}}: {{reason}}": "Čeká se na {{model}}: {{reason}}",
"What it does": "Co dělá",
"What should happen, and when": "Co se má stát a kdy",
"What to draw…": "Co nakreslit…",
"What your models remember about you, across chats. Change or remove anything here.": "Co si o vás vaše modely pamatují napříč konverzacemi. Cokoli tu můžete změnit nebo odstranit.",
"When": "Kdy",
"When it matters": "Když na tom záleží",
"Wide": "Široký",
"Writing": "Píše",
"Writing down…": "Zapisuji…",
"Writing: {{rate}} tok/s": "Píše: {{rate}} tok/s",
"Writing…": "Píše se…",
"Written to %(path)s": "Zapsáno do %(path)s",
@@ -566,8 +581,12 @@ const LEMBAS_I18N = {
"Delete this chat?": "Diesen Chat löschen?",
"Delete this scheduled task?": "Diese geplante Aufgabe löschen?",
"Deny": "Ablehnen",
"Dictate a message": "Nachricht diktieren",
"Disconnected. Close and reopen to reconnect.": "Getrennt. Schließen und erneut öffnen, um neu zu verbinden.",
"Done": "Fertig",
"Draw": "Zeichnen",
"Draw a picture": "Ein Bild zeichnen",
"Drawing… a picture can take a minute or two.": "Wird gezeichnet… ein Bild kann ein bis zwei Minuten dauern.",
"Drop the files to attach them": "Dateien ablegen, um sie anzuhängen",
"Due %(when)s": "Fällig %(when)s",
"Each run sees the ones before it.": "Jeder Lauf sieht die vorherigen.",
@@ -698,6 +717,7 @@ const LEMBAS_I18N = {
"Queued": "In der Warteschlange",
"Queued: {{ahead}} ahead": "In der Warteschlange: {{ahead}} davor",
"Read again": "Neu lesen",
"Read this aloud": "Vorlesen",
"Reading the prompt": "Lese den Prompt",
"Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)": "Lese den Prompt: {{done}} / {{total}} Tokens ({{percent}} %)",
"Reasoning effort": "Denkaufwand",
@@ -740,6 +760,7 @@ const LEMBAS_I18N = {
"Server": "Server",
"Set the time yourself": "Zeit selbst festlegen",
"Settings": "Einstellungen",
"Shape": "Form",
"Show %(n)s more": "%(n)s weitere anzeigen",
"Shown by the agent": "Vom Agenten gezeigt",
"Sidebar": "Seitenleiste",
@@ -753,11 +774,13 @@ const LEMBAS_I18N = {
"Skipped": "Ausgelassen",
"Something to remember…": "Etwas, das behalten werden soll…",
"Source": "Quelltext",
"Square": "Quadratisch",
"Start the agent": "Agent starten",
"Stop": "Stoppen",
"Stop %(name)s": "%(name)s stoppen",
"Stop %(name)s and the members under it? They leave the team.": "%(name)s und die Mitglieder darunter stoppen? Sie verlassen das Team.",
"Stop %(name)s and the subagents under it?": "%(name)s und die Subagenten darunter stoppen?",
"Stop dictating": "Diktieren beenden",
"Stop the agent": "Agent anhalten",
"Stopped": "Angehalten",
"Stopped.": "Angehalten.",
@@ -769,6 +792,7 @@ const LEMBAS_I18N = {
"Switched off here only. What is on to begin with is your default for this model, in Settings → Models.": "Nur hier ausgeschaltet. Was anfangs an ist, ist Ihr Standard für dieses Modell, unter Einstellungen → Modelle.",
"System prompt": "Systemprompt",
"Taken in while it ran:": "Während des Laufs übernommen:",
"Tall": "Hoch",
"Tasks: %(n)s": "Aufgaben: %(n)s",
"Team member": "Teammitglied",
"Team: %(size)s of at most %(max)s subagents": "Team: %(size)s von höchstens %(max)s Subagenten",
@@ -796,6 +820,7 @@ const LEMBAS_I18N = {
"The instruction each run is given, on its own: say everything it needs.": "Die Anweisung, die jeder Lauf für sich erhält: Sagen Sie alles, was er braucht.",
"The interface only. Models answer in the language you write to them in, whatever this says.": "Nur die Oberfläche. Modelle antworten in der Sprache, in der Sie ihnen schreiben, egal was hier steht.",
"The last %(n)s runs failed: %(why)s": "Die letzten %(n)s Läufe sind fehlgeschlagen: %(why)s",
"The microphone could not be opened: %(why)s": "Das Mikrofon konnte nicht geöffnet werden: %(why)s",
"The model's own": "Die des Modells",
"The most an agent chat may do here": "Das Meiste, was ein Agent-Chat hier darf",
"The next command": "Nächster Befehl",
@@ -807,6 +832,8 @@ const LEMBAS_I18N = {
"The reply is ready.": "Die Antwort ist fertig.",
"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.",
"The server's answer was not audio.": "Die Antwort des Servers war kein Audio.",
"The terminal could not be loaded.": "Das Terminal konnte nicht geladen werden.",
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Das Terminal braucht einen neueren LLeMbas-Server. Bitten Sie seinen Administrator, ihn zu aktualisieren.",
"Theme": "Design",
@@ -849,10 +876,13 @@ const LEMBAS_I18N = {
"Waiting for {{model}}: {{reason}}": "Warte auf {{model}}: {{reason}}",
"What it does": "Was sie tut",
"What should happen, and when": "Was soll wann geschehen?",
"What to draw…": "Was gezeichnet werden soll…",
"What your models remember about you, across chats. Change or remove anything here.": "Was Ihre Modelle über Sie behalten, über alle Chats hinweg. Hier können Sie alles ändern oder entfernen.",
"When": "Wann",
"When it matters": "Wenn es zählt",
"Wide": "Breit",
"Writing": "Schreibe",
"Writing down…": "Wird aufgeschrieben…",
"Writing: {{rate}} tok/s": "Schreibe: {{rate}} Tok/s",
"Writing…": "Schreibt…",
"Written to %(path)s": "Geschrieben nach %(path)s",
@@ -1016,8 +1046,12 @@ const LEMBAS_I18N = {
"Delete this chat?": "¿Eliminar este chat?",
"Delete this scheduled task?": "¿Eliminar esta tarea programada?",
"Deny": "Denegar",
"Dictate a message": "Dictar un mensaje",
"Disconnected. Close and reopen to reconnect.": "Desconectado. Cierre y vuelva a abrir para reconectar.",
"Done": "Hecho",
"Draw": "Dibujar",
"Draw a picture": "Dibujar una imagen",
"Drawing… a picture can take a minute or two.": "Dibujando… una imagen puede tardar uno o dos minutos.",
"Drop the files to attach them": "Suelte los archivos para adjuntarlos",
"Due %(when)s": "Prevista %(when)s",
"Each run sees the ones before it.": "Cada ejecución ve las anteriores.",
@@ -1148,6 +1182,7 @@ const LEMBAS_I18N = {
"Queued": "En cola",
"Queued: {{ahead}} ahead": "En cola: {{ahead}} por delante",
"Read again": "Leer de nuevo",
"Read this aloud": "Leer en voz alta",
"Reading the prompt": "Leyendo el prompt",
"Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)": "Leyendo el prompt: {{done}} / {{total}} tokens ({{percent}} %)",
"Reasoning effort": "Esfuerzo de razonamiento",
@@ -1190,6 +1225,7 @@ const LEMBAS_I18N = {
"Server": "Servidor",
"Set the time yourself": "Fijar la hora usted mismo",
"Settings": "Ajustes",
"Shape": "Forma",
"Show %(n)s more": "Mostrar %(n)s más",
"Shown by the agent": "Mostrado por el agente",
"Sidebar": "Barra lateral",
@@ -1203,11 +1239,13 @@ const LEMBAS_I18N = {
"Skipped": "Omitida",
"Something to remember…": "Algo que recordar…",
"Source": "Código fuente",
"Square": "Cuadrada",
"Start the agent": "Iniciar el agente",
"Stop": "Detener",
"Stop %(name)s": "Detener %(name)s",
"Stop %(name)s and the members under it? They leave the team.": "¿Detener %(name)s y a los miembros que dependen de él? Dejan el equipo.",
"Stop %(name)s and the subagents under it?": "¿Detener a %(name)s y a los subagentes que tiene debajo?",
"Stop dictating": "Dejar de dictar",
"Stop the agent": "Detener el agente",
"Stopped": "Detenido",
"Stopped.": "Detenido.",
@@ -1219,6 +1257,7 @@ const LEMBAS_I18N = {
"Switched off here only. What is on to begin with is your default for this model, in Settings → Models.": "Desactivado solo aquí. Lo que está activado al principio es su valor predeterminado para este modelo, en Ajustes → Modelos.",
"System prompt": "Prompt de sistema",
"Taken in while it ran:": "Incorporado mientras se ejecutaba:",
"Tall": "Alta",
"Tasks: %(n)s": "Tareas: %(n)s",
"Team member": "Miembro del equipo",
"Team: %(size)s of at most %(max)s subagents": "Equipo: %(size)s de un máximo de %(max)s subagentes",
@@ -1246,6 +1285,7 @@ const LEMBAS_I18N = {
"The instruction each run is given, on its own: say everything it needs.": "La instrucción que recibe cada ejecución por sí sola: diga todo lo que necesita.",
"The interface only. Models answer in the language you write to them in, whatever this says.": "Solo la interfaz. Los modelos responden en el idioma en el que usted les escribe, diga lo que diga esto.",
"The last %(n)s runs failed: %(why)s": "Las últimas %(n)s ejecuciones fallaron: %(why)s",
"The microphone could not be opened: %(why)s": "No se pudo abrir el micrófono: %(why)s",
"The model's own": "La del modelo",
"The most an agent chat may do here": "Lo máximo que un chat de agente puede hacer aquí",
"The next command": "El comando siguiente",
@@ -1257,6 +1297,8 @@ const LEMBAS_I18N = {
"The reply is ready.": "La respuesta está lista.",
"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.",
"The server's answer was not audio.": "La respuesta del servidor no era audio.",
"The terminal could not be loaded.": "No se ha podido cargar el terminal.",
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "El terminal necesita un servidor LLeMbas más reciente. Pida a su administrador que lo actualice.",
"Theme": "Tema",
@@ -1299,10 +1341,13 @@ const LEMBAS_I18N = {
"Waiting for {{model}}: {{reason}}": "Esperando a {{model}}: {{reason}}",
"What it does": "Qué hace",
"What should happen, and when": "Qué debe ocurrir, y cuándo",
"What to draw…": "Qué dibujar…",
"What your models remember about you, across chats. Change or remove anything here.": "Lo que sus modelos recuerdan de usted entre chats. Aquí puede cambiar o quitar cualquier cosa.",
"When": "Cuándo",
"When it matters": "Cuando importa",
"Wide": "Ancha",
"Writing": "Escribiendo",
"Writing down…": "Transcribiendo…",
"Writing: {{rate}} tok/s": "Escribiendo: {{rate}} tok/s",
"Writing…": "Escribiendo…",
"Written to %(path)s": "Escrito en %(path)s",
@@ -1466,8 +1511,12 @@ const LEMBAS_I18N = {
"Delete this chat?": "Supprimer cette conversation ?",
"Delete this scheduled task?": "Supprimer cette tâche planifiée ?",
"Deny": "Refuser",
"Dictate a message": "Dicter un message",
"Disconnected. Close and reopen to reconnect.": "Déconnecté. Fermez et rouvrez pour vous reconnecter.",
"Done": "Terminé",
"Draw": "Dessiner",
"Draw a picture": "Dessiner une image",
"Drawing… a picture can take a minute or two.": "Dessin en cours… une image peut prendre une minute ou deux.",
"Drop the files to attach them": "Déposez les fichiers pour les joindre",
"Due %(when)s": "Prévue %(when)s",
"Each run sees the ones before it.": "Chaque exécution voit les précédentes.",
@@ -1598,6 +1647,7 @@ const LEMBAS_I18N = {
"Queued": "En file d’attente",
"Queued: {{ahead}} ahead": "En file d’attente : {{ahead}} avant",
"Read again": "Relire",
"Read this aloud": "Lire à voix haute",
"Reading the prompt": "Lecture du prompt",
"Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)": "Lecture du prompt : {{done}} / {{total}} jetons ({{percent}} %)",
"Reasoning effort": "Effort de raisonnement",
@@ -1640,6 +1690,7 @@ const LEMBAS_I18N = {
"Server": "Serveur",
"Set the time yourself": "Régler l'heure vous-même",
"Settings": "Paramètres",
"Shape": "Forme",
"Show %(n)s more": "Afficher %(n)s de plus",
"Shown by the agent": "Montré par l'agent",
"Sidebar": "Barre latérale",
@@ -1653,11 +1704,13 @@ const LEMBAS_I18N = {
"Skipped": "Ignorée",
"Something to remember…": "Quelque chose à retenir…",
"Source": "Source",
"Square": "Carrée",
"Start the agent": "Démarrer l'agent",
"Stop": "Arrêter",
"Stop %(name)s": "Arrêter %(name)s",
"Stop %(name)s and the members under it? They leave the team.": "Arrêter %(name)s et les membres qui en dépendent ? Ils quittent l’équipe.",
"Stop %(name)s and the subagents under it?": "Arrêter %(name)s et les sous-agents en dessous ?",
"Stop dictating": "Arrêter la dictée",
"Stop the agent": "Arrêter l'agent",
"Stopped": "Arrêté",
"Stopped.": "Arrêté.",
@@ -1669,6 +1722,7 @@ const LEMBAS_I18N = {
"Switched off here only. What is on to begin with is your default for this model, in Settings → Models.": "Désactivé ici seulement. Ce qui est activé au départ est votre réglage par défaut pour ce modèle, dans Paramètres → Modèles.",
"System prompt": "Prompt système",
"Taken in while it ran:": "Pris en compte pendant l'exécution :",
"Tall": "Haute",
"Tasks: %(n)s": "Tâches : %(n)s",
"Team member": "Membre de l’équipe",
"Team: %(size)s of at most %(max)s subagents": "Équipe : %(size)s sur %(max)s sous-agents au plus",
@@ -1696,6 +1750,7 @@ const LEMBAS_I18N = {
"The instruction each run is given, on its own: say everything it needs.": "L'instruction donnée à chaque exécution, seule : dites tout ce dont elle a besoin.",
"The interface only. Models answer in the language you write to them in, whatever this says.": "L'interface uniquement. Les modèles répondent dans la langue dans laquelle vous leur écrivez, quoi qu'indique ce réglage.",
"The last %(n)s runs failed: %(why)s": "Les %(n)s dernières exécutions ont échoué : %(why)s",
"The microphone could not be opened: %(why)s": "Le micro n’a pas pu être ouvert : %(why)s",
"The model's own": "Celle du modèle",
"The most an agent chat may do here": "Le maximum qu'une conversation d'agent peut faire ici",
"The next command": "La commande suivante",
@@ -1707,6 +1762,8 @@ const LEMBAS_I18N = {
"The reply is ready.": "La réponse est prête.",
"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.",
"The server's answer was not audio.": "La réponse du serveur n’était pas de l’audio.",
"The terminal could not be loaded.": "Le terminal n'a pas pu être chargé.",
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Le terminal nécessite un serveur LLeMbas plus récent. Demandez à son administrateur de le mettre à jour.",
"Theme": "Thème",
@@ -1749,10 +1806,13 @@ const LEMBAS_I18N = {
"Waiting for {{model}}: {{reason}}": "En attente de {{model}} : {{reason}}",
"What it does": "Ce qu’elle fait",
"What should happen, and when": "Ce qui doit se passer, et quand",
"What to draw…": "Que dessiner…",
"What your models remember about you, across chats. Change or remove anything here.": "Ce que vos modèles retiennent de vous, d’une conversation à l’autre. Modifiez ou supprimez ce que vous voulez ici.",
"When": "Quand",
"When it matters": "Quand c'est important",
"Wide": "Large",
"Writing": "Écriture",
"Writing down…": "Transcription…",
"Writing: {{rate}} tok/s": "Écriture : {{rate}} jetons/s",
"Writing…": "Rédaction…",
"Written to %(path)s": "Écrit dans %(path)s",
@@ -1916,8 +1976,12 @@ const LEMBAS_I18N = {
"Delete this chat?": "Törli ezt a csevegést?",
"Delete this scheduled task?": "Törli ezt az ütemezett feladatot?",
"Deny": "Elutasítás",
"Dictate a message": "Üzenet diktálása",
"Disconnected. Close and reopen to reconnect.": "Kapcsolat bontva. Az újrakapcsolódáshoz zárja be és nyissa meg újra.",
"Done": "Kész",
"Draw": "Rajzolás",
"Draw a picture": "Kép rajzolása",
"Drawing… a picture can take a minute or two.": "Rajzolás… egy kép egy-két percig is eltarthat.",
"Drop the files to attach them": "Engedje el a fájlokat a csatoláshoz",
"Due %(when)s": "Esedékes: %(when)s",
"Each run sees the ones before it.": "Minden futás látja az előzőeket.",
@@ -2048,6 +2112,7 @@ const LEMBAS_I18N = {
"Queued": "Sorban áll",
"Queued: {{ahead}} ahead": "Sorban áll: {{ahead}} van előtte",
"Read again": "Újraolvasás",
"Read this aloud": "Felolvasás",
"Reading the prompt": "A prompt olvasása",
"Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)": "A prompt olvasása: {{done}} / {{total}} token ({{percent}}%)",
"Reasoning effort": "Gondolkodási erőfeszítés",
@@ -2090,6 +2155,7 @@ const LEMBAS_I18N = {
"Server": "Kiszolgáló",
"Set the time yourself": "Az időpont megadása kézzel",
"Settings": "Beállítások",
"Shape": "Forma",
"Show %(n)s more": "Még %(n)s megjelenítése",
"Shown by the agent": "Az ügynök mutatta",
"Sidebar": "Oldalsáv",
@@ -2103,11 +2169,13 @@ const LEMBAS_I18N = {
"Skipped": "Kihagyva",
"Something to remember…": "Valami, amit meg kell jegyezni…",
"Source": "Forrás",
"Square": "Négyzet",
"Start the agent": "Ügynök indítása",
"Stop": "Leállítás",
"Stop %(name)s": "%(name)s leállítása",
"Stop %(name)s and the members under it? They leave the team.": "Leállítja %(name)s tagot és az alatta lévőket? Kilépnek a csapatból.",
"Stop %(name)s and the subagents under it?": "Leállítod: %(name)s, és az alatta lévő alügynököket?",
"Stop dictating": "Diktálás leállítása",
"Stop the agent": "Ügynök leállítása",
"Stopped": "Leállítva",
"Stopped.": "Leállítva.",
@@ -2119,6 +2187,7 @@ const LEMBAS_I18N = {
"Switched off here only. What is on to begin with is your default for this model, in Settings → Models.": "Csak itt kikapcsolva. Hogy kezdetben mi van bekapcsolva, az ennél a modellnél az Ön alapértelmezése, a Beállítások → Modellek alatt.",
"System prompt": "Rendszerprompt",
"Taken in while it ran:": "Futás közben átvéve:",
"Tall": "Magas",
"Tasks: %(n)s": "Feladatok: %(n)s",
"Team member": "Csapattag",
"Team: %(size)s of at most %(max)s subagents": "Csapat: %(size)s a legfeljebb %(max)s alügynökből",
@@ -2146,6 +2215,7 @@ const LEMBAS_I18N = {
"The instruction each run is given, on its own: say everything it needs.": "Az utasítás, amelyet minden futás önmagában kap: mondjon el mindent, amire szüksége van.",
"The interface only. Models answer in the language you write to them in, whatever this says.": "Csak a felület. A modellek azon a nyelven válaszolnak, amelyen ír nekik, függetlenül ettől a beállítástól.",
"The last %(n)s runs failed: %(why)s": "Az utolsó %(n)s futás sikertelen: %(why)s",
"The microphone could not be opened: %(why)s": "A mikrofont nem sikerült megnyitni: %(why)s",
"The model's own": "A modell sajátja",
"The most an agent chat may do here": "A legtöbb, amit egy ügynök-csevegés itt tehet",
"The next command": "Következő parancs",
@@ -2157,6 +2227,8 @@ const LEMBAS_I18N = {
"The reply is ready.": "A válasz kész.",
"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.",
"The server's answer was not audio.": "A szerver válasza nem hang volt.",
"The terminal could not be loaded.": "A terminált nem sikerült betölteni.",
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "A terminálhoz újabb LLeMbas-szerver kell. Kérje meg a rendszergazdáját, hogy frissítse.",
"Theme": "Téma",
@@ -2199,10 +2271,13 @@ const LEMBAS_I18N = {
"Waiting for {{model}}: {{reason}}": "Várakozás a(z) {{model}} modellre: {{reason}}",
"What it does": "Mit csinál",
"What should happen, and when": "Mi történjen, és mikor",
"What to draw…": "Mit rajzoljon…",
"What your models remember about you, across chats. Change or remove anything here.": "Amit a modelljei megjegyeznek Önről a beszélgetéseken át. Itt bármit módosíthat vagy eltávolíthat.",
"When": "Mikor",
"When it matters": "Ha számít",
"Wide": "Széles",
"Writing": "Írás",
"Writing down…": "Leírás…",
"Writing: {{rate}} tok/s": "Írás: {{rate}} token/s",
"Writing…": "Írás…",
"Written to %(path)s": "Kiírva ide: %(path)s",
@@ -2366,8 +2441,12 @@ const LEMBAS_I18N = {
"Delete this chat?": "Eliminare questa chat?",
"Delete this scheduled task?": "Eliminare questa attività pianificata?",
"Deny": "Nega",
"Dictate a message": "Detta un messaggio",
"Disconnected. Close and reopen to reconnect.": "Disconnesso. Chiudi e riapri per riconnetterti.",
"Done": "Fatto",
"Draw": "Disegna",
"Draw a picture": "Disegna un'immagine",
"Drawing… a picture can take a minute or two.": "Disegno in corso… un’immagine può richiedere un minuto o due.",
"Drop the files to attach them": "Rilasci i file per allegarli",
"Due %(when)s": "Prevista %(when)s",
"Each run sees the ones before it.": "Ogni esecuzione vede quelle precedenti.",
@@ -2498,6 +2577,7 @@ const LEMBAS_I18N = {
"Queued": "In coda",
"Queued: {{ahead}} ahead": "In coda: {{ahead}} davanti",
"Read again": "Rileggi",
"Read this aloud": "Leggi ad alta voce",
"Reading the prompt": "Lettura del prompt",
"Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)": "Lettura del prompt: {{done}} / {{total}} token ({{percent}}%)",
"Reasoning effort": "Sforzo di ragionamento",
@@ -2540,6 +2620,7 @@ const LEMBAS_I18N = {
"Server": "Server",
"Set the time yourself": "Imposta tu l’orario",
"Settings": "Impostazioni",
"Shape": "Forma",
"Show %(n)s more": "Mostra altri %(n)s",
"Shown by the agent": "Mostrato dall'agente",
"Sidebar": "Barra laterale",
@@ -2553,11 +2634,13 @@ const LEMBAS_I18N = {
"Skipped": "Saltata",
"Something to remember…": "Qualcosa da ricordare…",
"Source": "Sorgente",
"Square": "Quadrata",
"Start the agent": "Avvia l'agente",
"Stop": "Ferma",
"Stop %(name)s": "Ferma %(name)s",
"Stop %(name)s and the members under it? They leave the team.": "Fermare %(name)s e i membri sotto di lui? Lasciano la squadra.",
"Stop %(name)s and the subagents under it?": "Fermare %(name)s e i subagenti sotto di esso?",
"Stop dictating": "Interrompi la dettatura",
"Stop the agent": "Ferma l'agente",
"Stopped": "Fermato",
"Stopped.": "Interrotto.",
@@ -2569,6 +2652,7 @@ const LEMBAS_I18N = {
"Switched off here only. What is on to begin with is your default for this model, in Settings → Models.": "Disattivato solo qui. Ciò che è attivo all'inizio è il tuo predefinito per questo modello, in Impostazioni → Modelli.",
"System prompt": "Prompt di sistema",
"Taken in while it ran:": "Accolto durante l'esecuzione:",
"Tall": "Alta",
"Tasks: %(n)s": "Attività: %(n)s",
"Team member": "Membro della squadra",
"Team: %(size)s of at most %(max)s subagents": "Squadra: %(size)s su un massimo di %(max)s sottoagenti",
@@ -2596,6 +2680,7 @@ const LEMBAS_I18N = {
"The instruction each run is given, on its own: say everything it needs.": "L’istruzione che ogni esecuzione riceve da sola: di’ tutto ciò che le serve.",
"The interface only. Models answer in the language you write to them in, whatever this says.": "Solo l'interfaccia. I modelli rispondono nella lingua in cui scrivi loro, qualunque cosa dica questa impostazione.",
"The last %(n)s runs failed: %(why)s": "Le ultime %(n)s esecuzioni non sono riuscite: %(why)s",
"The microphone could not be opened: %(why)s": "Impossibile aprire il microfono: %(why)s",
"The model's own": "Quello del modello",
"The most an agent chat may do here": "Il massimo che una chat agente può fare qui",
"The next command": "Il comando successivo",
@@ -2607,6 +2692,8 @@ const LEMBAS_I18N = {
"The reply is ready.": "La risposta è pronta.",
"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.",
"The server's answer was not audio.": "La risposta del server non era audio.",
"The terminal could not be loaded.": "Impossibile caricare il terminale.",
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Il terminale richiede un server LLeMbas più recente. Chiedi al suo amministratore di aggiornarlo.",
"Theme": "Tema",
@@ -2649,10 +2736,13 @@ const LEMBAS_I18N = {
"Waiting for {{model}}: {{reason}}": "In attesa di {{model}}: {{reason}}",
"What it does": "Cosa fa",
"What should happen, and when": "Cosa deve succedere, e quando",
"What to draw…": "Cosa disegnare…",
"What your models remember about you, across chats. Change or remove anything here.": "Ciò che i tuoi modelli ricordano di te tra una chat e l’altra. Qui puoi cambiare o rimuovere qualsiasi cosa.",
"When": "Quando",
"When it matters": "Quando conta",
"Wide": "Larga",
"Writing": "Scrittura",
"Writing down…": "Trascrizione…",
"Writing: {{rate}} tok/s": "Scrittura: {{rate}} tok/s",
"Writing…": "Sta scrivendo…",
"Written to %(path)s": "Scritto in %(path)s",
@@ -2816,8 +2906,12 @@ const LEMBAS_I18N = {
"Delete this chat?": "Usunąć ten czat?",
"Delete this scheduled task?": "Usunąć to zaplanowane zadanie?",
"Deny": "Odrzuć",
"Dictate a message": "Podyktuj wiadomość",
"Disconnected. Close and reopen to reconnect.": "Rozłączono. Aby połączyć ponownie, zamknij i otwórz ponownie.",
"Done": "Gotowe",
"Draw": "Narysuj",
"Draw a picture": "Narysuj obraz",
"Drawing… a picture can take a minute or two.": "Rysowanie… obraz może zająć minutę lub dwie.",
"Drop the files to attach them": "Upuść pliki, aby je dołączyć",
"Due %(when)s": "Zaplanowane na %(when)s",
"Each run sees the ones before it.": "Każde uruchomienie widzi poprzednie.",
@@ -2948,6 +3042,7 @@ const LEMBAS_I18N = {
"Queued": "W kolejce",
"Queued: {{ahead}} ahead": "W kolejce: przed nim {{ahead}}",
"Read again": "Wczytaj ponownie",
"Read this aloud": "Przeczytaj na głos",
"Reading the prompt": "Czytanie promptu",
"Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)": "Czytanie promptu: {{done}} / {{total}} tokenów ({{percent}}%)",
"Reasoning effort": "Poziom rozumowania",
@@ -2990,6 +3085,7 @@ const LEMBAS_I18N = {
"Server": "Serwer",
"Set the time yourself": "Ustaw czas samodzielnie",
"Settings": "Ustawienia",
"Shape": "Kształt",
"Show %(n)s more": "Pokaż jeszcze %(n)s",
"Shown by the agent": "Pokazane przez agenta",
"Sidebar": "Pasek boczny",
@@ -3003,11 +3099,13 @@ const LEMBAS_I18N = {
"Skipped": "Pominięty",
"Something to remember…": "Coś do zapamiętania…",
"Source": "Źródło",
"Square": "Kwadrat",
"Start the agent": "Uruchom agenta",
"Stop": "Zatrzymać",
"Stop %(name)s": "Zatrzymaj %(name)s",
"Stop %(name)s and the members under it? They leave the team.": "Zatrzymać %(name)s i członków pod nim? Opuszczą zespół.",
"Stop %(name)s and the subagents under it?": "Zatrzymać %(name)s i podagentów pod nim?",
"Stop dictating": "Zatrzymaj dyktowanie",
"Stop the agent": "Zatrzymaj agenta",
"Stopped": "Zatrzymano",
"Stopped.": "Zatrzymano.",
@@ -3019,6 +3117,7 @@ const LEMBAS_I18N = {
"Switched off here only. What is on to begin with is your default for this model, in Settings → Models.": "Wyłączone tylko tutaj. To, co jest początkowo włączone, to ustawienie domyślne dla tego modelu w Ustawienia → Modele.",
"System prompt": "Prompt systemowy",
"Taken in while it ran:": "Przejęte w trakcie działania:",
"Tall": "Wysoki",
"Tasks: %(n)s": "Zadania: %(n)s",
"Team member": "Członek zespołu",
"Team: %(size)s of at most %(max)s subagents": "Zespół: %(size)s z najwyżej %(max)s podagentów",
@@ -3046,6 +3145,7 @@ const LEMBAS_I18N = {
"The instruction each run is given, on its own: say everything it needs.": "Instrukcja, którą każde uruchomienie dostaje samodzielnie: podaj wszystko, czego potrzebuje.",
"The interface only. Models answer in the language you write to them in, whatever this says.": "Tylko interfejs. Modele odpowiadają w języku, w którym się do nich pisze, bez względu na to ustawienie.",
"The last %(n)s runs failed: %(why)s": "Ostatnie uruchomienia nie powiodły się (%(n)s): %(why)s",
"The microphone could not be opened: %(why)s": "Nie udało się otworzyć mikrofonu: %(why)s",
"The model's own": "Domyślne modelu",
"The most an agent chat may do here": "Najwięcej, co czat agenta może tu zrobić",
"The next command": "Następne polecenie",
@@ -3057,6 +3157,8 @@ const LEMBAS_I18N = {
"The reply is ready.": "Odpowiedź jest gotowa.",
"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.",
"The server's answer was not audio.": "Odpowiedź serwera nie była dźwiękiem.",
"The terminal could not be loaded.": "Nie udało się wczytać terminala.",
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Terminal wymaga nowszego serwera LLeMbas. Poproś jego administratora o aktualizację.",
"Theme": "Motyw",
@@ -3099,10 +3201,13 @@ const LEMBAS_I18N = {
"Waiting for {{model}}: {{reason}}": "Czekam na {{model}}: {{reason}}",
"What it does": "Co robi",
"What should happen, and when": "Co ma się stać i kiedy",
"What to draw…": "Co narysować…",
"What your models remember about you, across chats. Change or remove anything here.": "Co Twoje modele pamiętają o Tobie we wszystkich czatach. Tutaj możesz wszystko zmienić lub usunąć.",
"When": "Kiedy",
"When it matters": "Gdy to ważne",
"Wide": "Szeroki",
"Writing": "Pisanie",
"Writing down…": "Zapisywanie…",
"Writing: {{rate}} tok/s": "Pisanie: {{rate}} tok/s",
"Writing…": "Pisze…",
"Written to %(path)s": "Zapisano do %(path)s",
@@ -3266,8 +3371,12 @@ const LEMBAS_I18N = {
"Delete this chat?": "Zmazať túto konverzáciu?",
"Delete this scheduled task?": "Odstrániť túto naplánovanú úlohu?",
"Deny": "Odmietnuť",
"Dictate a message": "Nadiktovať správu",
"Disconnected. Close and reopen to reconnect.": "Odpojené. Zavrite a znova otvorte, aby sa pripojilo.",
"Done": "Hotovo",
"Draw": "Nakresliť",
"Draw a picture": "Nakresliť obrázok",
"Drawing… a picture can take a minute or two.": "Kreslím… obrázok môže trvať minútu alebo dve.",
"Drop the files to attach them": "Pustite súbory a priložia sa",
"Due %(when)s": "Naplánované na %(when)s",
"Each run sees the ones before it.": "Každé spustenie vidí tie predchádzajúce.",
@@ -3398,6 +3507,7 @@ const LEMBAS_I18N = {
"Queued": "Vo fronte",
"Queued: {{ahead}} ahead": "Vo fronte: pred ním {{ahead}}",
"Read again": "Načítať znova",
"Read this aloud": "Prečítať nahlas",
"Reading the prompt": "Číta sa výzva",
"Reading the prompt: {{done}} / {{total}} tokens ({{percent}}%)": "Číta sa výzva: {{done}} / {{total}} tokenov ({{percent}} %)",
"Reasoning effort": "Náročnosť uvažovania",
@@ -3440,6 +3550,7 @@ const LEMBAS_I18N = {
"Server": "Server",
"Set the time yourself": "Nastaviť čas sami",
"Settings": "Nastavenia",
"Shape": "Tvar",
"Show %(n)s more": "Zobraziť ďalšie (%(n)s)",
"Shown by the agent": "Ukázal agent",
"Sidebar": "Bočný panel",
@@ -3453,11 +3564,13 @@ const LEMBAS_I18N = {
"Skipped": "Vynechané",
"Something to remember…": "Niečo na zapamätanie…",
"Source": "Zdroj",
"Square": "Štvorec",
"Start the agent": "Spustiť agenta",
"Stop": "Zastaviť",
"Stop %(name)s": "Zastaviť %(name)s",
"Stop %(name)s and the members under it? They leave the team.": "Zastaviť %(name)s a členov pod ním? Opustia tím.",
"Stop %(name)s and the subagents under it?": "Zastaviť %(name)s a podagentov pod ním?",
"Stop dictating": "Zastaviť diktovanie",
"Stop the agent": "Zastaviť agenta",
"Stopped": "Zastavené",
"Stopped.": "Zastavené.",
@@ -3469,6 +3582,7 @@ const LEMBAS_I18N = {
"Switched off here only. What is on to begin with is your default for this model, in Settings → Models.": "Vypnuté len tu. Čo je na začiatku zapnuté, je vaša predvoľba pre tento model v Nastavenia → Modely.",
"System prompt": "Systémový pokyn",
"Taken in while it ran:": "Prevzaté počas behu:",
"Tall": "Vysoký",
"Tasks: %(n)s": "Úlohy: %(n)s",
"Team member": "Člen tímu",
"Team: %(size)s of at most %(max)s subagents": "Tím: %(size)s z najviac %(max)s podagentov",
@@ -3496,6 +3610,7 @@ const LEMBAS_I18N = {
"The instruction each run is given, on its own: say everything it needs.": "Inštrukcia, ktorú dostane každé spustenie samostatne: uveďte všetko, čo potrebuje.",
"The interface only. Models answer in the language you write to them in, whatever this says.": "Len rozhranie. Modely odpovedajú v jazyku, v ktorom im píšete, bez ohľadu na toto nastavenie.",
"The last %(n)s runs failed: %(why)s": "Posledné spustenia zlyhali (%(n)s): %(why)s",
"The microphone could not be opened: %(why)s": "Mikrofón sa nepodarilo otvoriť: %(why)s",
"The model's own": "Podľa modelu",
"The most an agent chat may do here": "Najviac, čo tu agentská konverzácia smie",
"The next command": "Nasledujúci príkaz",
@@ -3507,6 +3622,8 @@ const LEMBAS_I18N = {
"The reply is ready.": "Odpoveď je hotová.",
"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.",
"The server's answer was not audio.": "Odpoveď servera nebola zvuk.",
"The terminal could not be loaded.": "Terminál sa nepodarilo načítať.",
"The terminal needs a newer LLeMbas server. Ask its administrator to update it.": "Terminál potrebuje novší server LLeMbas. Požiadajte jeho správcu o aktualizáciu.",
"Theme": "Vzhľad",
@@ -3549,10 +3666,13 @@ const LEMBAS_I18N = {
"Waiting for {{model}}: {{reason}}": "Čaká sa na {{model}}: {{reason}}",
"What it does": "Čo robí",
"What should happen, and when": "Čo sa má stať a kedy",
"What to draw…": "Čo nakresliť…",
"What your models remember about you, across chats. Change or remove anything here.": "Čo si o vás vaše modely pamätajú naprieč konverzáciami. Čokoľvek tu môžete zmeniť alebo odstrániť.",
"When": "Kedy",
"When it matters": "Keď na tom záleží",
"Wide": "Široký",
"Writing": "Píše",
"Writing down…": "Zapisujem…",
"Writing: {{rate}} tok/s": "Píše: {{rate}} tok/s",
"Writing…": "Píše sa…",
"Written to %(path)s": "Zapísané do %(path)s",
+1
View File
@@ -84,6 +84,7 @@
<div class="composer__bar">
<button type="button" class="tool composer__attach" id="attach" aria-label="Attach files" title="Attach files" data-i18n-aria-label data-i18n-title></button>
<div class="composer__chips" id="composer-chips"></div>
<button type="button" class="tool composer__mic" id="dictate" hidden aria-pressed="false"></button>
<button type="button" class="round round--stop" id="stop" hidden aria-label="Stop" title="Stop" data-i18n-aria-label data-i18n-title></button>
<button type="submit" class="round" id="send" aria-label="Send" title="Send" data-i18n-aria-label data-i18n-title></button>
</div>
+2
View File
@@ -22,6 +22,7 @@ import { unarchive } from "./archived.js"
import { isProgress, progressLine, progressTicks } from "./progress.js"
import { aimTerminal, terminalButton, terminalOpen, toggleTerminal } from "./terminal.js"
import { scopePopover } from "./scope.js"
import { readAloudButton } from "./voice.js"
const MODES = ["manual", "edit", "auto", "plan"]
@@ -292,6 +293,7 @@ function assistantMessage(m, chat) {
"div",
{ class: "msg__actions" },
action(ICONS.copy, t("Copy"), () => copyText(replyText(m))),
readAloudButton(m.id, () => replyText(m)),
// Only the last reply: CSS shows it there and nowhere else (app.css, .msg__regen).
action(ICONS.refresh, t("Regenerate reply"), () => regenerate(m), "msg__regen"),
),
+3
View File
@@ -101,6 +101,9 @@ export const ICONS = {
bell: ["M8 1.5a4.5 4.5 0 014.5 4.5v3l1.5 2.5H2L3.5 9V6A4.5 4.5 0 018 1.5zM8 3a3 3 0 00-3 3v3.42l-.35.58h6.7L11 9.42V6a3 3 0 00-3-3z", "M6.25 12.5h3.5a1.75 1.75 0 01-3.5 0z"],
archive: ["M1.5 2.5h13v3.5h-13zM3 4v.5h10V4z", "M2.5 6.5H4V12h8V6.5h1.5v7h-11z", "M6 7.5h4V9H6z"],
contrast: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M8 3a5 5 0 010 10z"],
mic: ["M8 1.5a2.5 2.5 0 012.5 2.5v4a2.5 2.5 0 01-5 0V4A2.5 2.5 0 018 1.5zM8 3a1 1 0 00-1 1v4a1 1 0 002 0V4a1 1 0 00-1-1z", "M3.5 7.5H5a3 3 0 006 0h1.5a4.5 4.5 0 01-3.75 4.44V14h-1.5v-2.06A4.5 4.5 0 013.5 7.5z"],
speaker: ["M2 6h2.5L8 3v10L4.5 10H2z", "M10.2 5.2a4 4 0 010 5.6l-1.06-1.06a2.5 2.5 0 000-3.48z", "M12.3 3.1a7 7 0 010 9.8l-1.06-1.06a5.5 5.5 0 000-7.68z"],
picture: ["M1.5 2.5h13v11h-13zM3 4v6.4l2.75-2.75 2.5 2.5 1.75-1.75L13 11.4V4z", "M10.5 5a1.25 1.25 0 110 2.5 1.25 1.25 0 010-2.5z"],
info: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M7.25 7h1.5v4.5h-1.5zM7.25 4.5h1.5V6h-1.5z"],
}
+86
View File
@@ -0,0 +1,86 @@
// Draw a picture: the instance's own image generation (`POST /v1/images/generations`, its
// workflow and default checkpoint), from Ctrl+K. Offered only where `GET /me` says the account may
// (`services.images`) and the token holds `models`.
//
// The pictures are shown only as Rust read them: each one of the person's own files on this
// server (`/v1/files/<id>/content`, fetched by id from the app's own server, never from an
// address the answer named), handed over as a `data:` URL of an image type -- checked again here
// before it becomes an <img>.
import { h, icon, ICONS } from "./dom.js"
import { invoke, why } from "./bridge.js"
const PICTURE = /^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/
/** Whether pictures can be drawn here, for the account `me` (`GET /me`). */
export function canDraw(me) {
const models = !Array.isArray(me?.scopes) || me.scopes.includes("models")
return !!me?.services?.images && models && me?.permissions?.["tools.image"] !== false
}
/** The dialog: a prompt, a shape, Draw, and the pictures under it. */
export function openDraw() {
const prompt = h("textarea", { class: "textarea draw__prompt", rows: "3", placeholder: t("What to draw…"), "aria-label": t("What to draw…") })
const size = h(
"select",
{ class: "draw__size", "aria-label": t("Shape") },
h("option", { value: "1024x1024", text: t("Square") }),
h("option", { value: "1344x768", text: t("Wide") }),
h("option", { value: "768x1344", text: t("Tall") }),
)
const status = h("p", { class: "status", role: "status", hidden: true })
const out = h("div", { class: "draw__out" })
const go = h("button", { type: "submit", class: "btn btn--primary", text: t("Draw") })
const form = h(
"form",
{ class: "dialog__body draw", method: "dialog" },
h("h2", { class: "dialog__title" }, icon(ICONS.picture), h("span", { text: t("Draw a picture") })),
prompt,
h("div", { class: "draw__bar" }, size, h("button", { type: "button", class: "btn", text: t("Close"), onclick: () => close() }), go),
status,
out,
)
const dlg = h("dialog", { class: "dialog dialog--wide" }, form)
let asked = 0
const close = () => {
asked++
dlg.close()
dlg.remove()
}
const said = (text, error = false) => {
status.hidden = !text
status.textContent = text
status.className = `status${error ? " is-error" : ""}`
status.setAttribute("role", error ? "alert" : "status")
}
form.addEventListener("submit", async (e) => {
e.preventDefault()
const words = prompt.value.trim()
if (!words) return prompt.focus()
const mine = ++asked
go.disabled = true
said(t("Drawing… a picture can take a minute or two."))
try {
const urls = await invoke("draw", { prompt: words, size: size.value })
if (mine !== asked) return
const pictures = (urls || []).filter((u) => PICTURE.test(u))
if (!pictures.length) throw { detail: t("The server's answer had no picture in it.") }
out.prepend(...pictures.map((src) => h("figure", { class: "draw__pic" }, h("img", { src, alt: words }), h("figcaption", { text: words }))))
said("")
} catch (err) {
if (mine === asked) said(why(err), true)
} finally {
if (mine === asked) go.disabled = false
}
})
prompt.addEventListener("keydown", (e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) form.requestSubmit()
})
dlg.addEventListener("cancel", (e) => {
e.preventDefault()
close()
})
document.body.append(dlg)
dlg.showModal()
prompt.focus()
}
+5
View File
@@ -22,6 +22,8 @@ import { initAgent, link } from "./agent.js"
import { initScheduled, showScheduled, scheduledCommands } from "./scheduled.js"
import { initArchived, showArchived } from "./archived.js"
import { showMemory } from "./memory.js"
import { initVoice, voiceFor } from "./voice.js"
import { canDraw, openDraw } from "./draw.js"
import { closePopover } from "./popup.js"
import { noteSubagentsCap } from "./models.js"
@@ -94,6 +96,7 @@ async function startApp() {
try {
me = await get("/me")
noteMe(me)
voiceFor(me)
} catch (err) {
$("thread-note").textContent = why(err)
$("thread-note").hidden = false
@@ -233,6 +236,7 @@ function commands() {
{ label: t("Memory"), run: () => showMemoryPage() },
{ label: t("Settings"), run: () => showSettings(true) },
]
if (canDraw(me)) out.push({ label: t("Draw a picture"), run: openDraw })
if (openChatId()) out.push({ label: panelOpen() ? t("Close the activity panel") : t("Open the activity panel"), run: togglePanel })
if (terminalAvailable()) out.push({ label: terminalOpen() ? t("Close terminal") : t("Terminal"), run: toggleTerminal })
if (status?.linked) out.push({ label: status.agent_wanted ? t("Stop the agent") : t("Start the agent"), run: () => invoke("set_agent", { on: !status.agent_wanted }) })
@@ -280,6 +284,7 @@ async function boot() {
$("thread-note").hidden = !text
},
})
initVoice()
initChat({
status: () => status,
thisDevices,
+180
View File
@@ -0,0 +1,180 @@
// Voice: a message dictated in the composer, and a reply read aloud. Both are the instance's own
// speech services on the OpenAI-compatible routes, which the app's token reaches through its
// `models` scope (src-tauri/src/server.rs):
//
// POST /v1/audio/transcriptions the recording -> { text } (Rust: `transcribe`)
// POST /v1/audio/speech { input } -> the audio (Rust: `speak`, a data: URL)
//
// Each is offered only where `GET /me` says the instance has it for this account (`services.stt`,
// `services.tts`) and the token holds `models` (`scopes`), so no button is there that can only fail.
// The microphone is asked for only when Dictate is pressed: Rust allows it for that moment alone.
import { $, h, icon, ICONS } from "./dom.js"
import { invoke, why } from "./bridge.js"
let me = null
const hasModels = () => !Array.isArray(me?.scopes) || me.scopes.includes("models")
/** Whether a message can be dictated here. */
export const canDictate = () => !!me?.services?.stt && hasModels() && me?.permissions?.["audio.transcribe"] !== false
/** Whether a reply can be read aloud here. */
export const canSpeak = () => !!me?.services?.tts && hasModels() && me?.permissions?.["audio.listen"] !== false
function say(text) {
const box = $("thread-note")
if (!box) return
box.textContent = text
box.hidden = !text
}
// --- Dictate --------------------------------------------------------------------------------------
let recorder = null // the recording in progress
let busy = false // a recording being turned into words
function drawMic() {
const b = $("dictate")
if (!b) return
b.hidden = !canDictate()
const on = !!recorder
b.classList.toggle("is-recording", on)
b.classList.toggle("is-busy", busy)
b.disabled = busy
b.setAttribute("aria-pressed", String(on))
const label = busy ? t("Writing down…") : on ? t("Stop dictating") : t("Dictate a message")
b.setAttribute("aria-label", label)
b.title = label
}
async function start() {
say("")
let stream
try {
await invoke("voice_arm")
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
} catch (err) {
say(t("The microphone could not be opened: %(why)s", { why: err?.message || why(err) }))
return
}
const type = ["audio/webm;codecs=opus", "audio/ogg;codecs=opus", "audio/webm"].find((x) => window.MediaRecorder?.isTypeSupported?.(x))
let rec
try {
rec = new MediaRecorder(stream, type ? { mimeType: type } : undefined)
} catch (err) {
stream.getTracks().forEach((x) => x.stop())
say(t("The microphone could not be opened: %(why)s", { why: err?.message || why(err) }))
return
}
const chunks = []
rec.addEventListener("dataavailable", (e) => e.data.size && chunks.push(e.data))
rec.addEventListener("stop", async () => {
stream.getTracks().forEach((x) => x.stop())
recorder = null
const blob = new Blob(chunks, { type: rec.mimeType || type || "audio/webm" })
if (!blob.size) return drawMic()
busy = true
drawMic()
try {
const text = await invoke("transcribe", new Uint8Array(await blob.arrayBuffer()), { headers: { "x-type": blob.type } })
intoComposer(text)
} catch (err) {
say(why(err))
}
busy = false
drawMic()
})
recorder = rec
rec.start()
drawMic()
}
/** The words heard, where the cursor is in the message box, a space either side where needed. */
function intoComposer(text) {
text = String(text || "").trim()
if (!text) return
const box = $("composer-text")
const at = box.selectionStart ?? box.value.length
const end = box.selectionEnd ?? at
const before = box.value.slice(0, at)
const after = box.value.slice(end)
const words = `${before && !/\s$/.test(before) ? " " : ""}${text}${after && !/^\s/.test(after) ? " " : ""}`
box.value = before + words + after
box.selectionStart = box.selectionEnd = before.length + words.length
box.dispatchEvent(new Event("input"))
box.focus()
}
/** Stop a recording (a chat closed, the window hidden): what was said is still written down. */
export function stopDictating() {
if (recorder?.state === "recording") recorder.stop()
}
// --- Read aloud -----------------------------------------------------------------------------------
let playing = null // { id, audio, button }
/** A reply's words without the markdown around them, and without its code. */
export function plain(markdown) {
return String(markdown || "")
.replace(/```[\s\S]*?```/g, " ")
.replace(/!?\[([^\]]*)]\([^)]*\)/g, "$1")
.replace(/[*_`#>|~]/g, "")
.replace(/\s+/g, " ")
.trim()
}
function stopReading() {
if (!playing) return
playing.audio?.pause()
playing.button.setAttribute("aria-pressed", "false")
playing.button.classList.remove("is-busy")
playing = null
}
/** The Read aloud button of a reply (`id`), reading `text()` when pressed; null where it cannot. */
export function readAloudButton(id, text) {
if (!canSpeak()) return null
const label = t("Read this aloud")
const b = h("button", { type: "button", class: "msg__act msg__speak", "aria-label": label, title: label, "aria-pressed": "false" }, icon(ICONS.speaker))
b.addEventListener("click", async () => {
if (playing?.id === id) return stopReading()
stopReading()
const words = plain(text())
if (!words) return
const mine = { id, audio: null, button: b }
playing = mine
b.setAttribute("aria-pressed", "true")
b.classList.add("is-busy")
try {
const url = await invoke("speak", { text: words })
if (playing !== mine) return
// Only audio the app's own server sent, as Rust read it: a data: URL of an audio type.
if (!/^data:audio\/[a-z0-9.+-]+;base64,/i.test(url)) throw new Error(t("The server's answer was not audio."))
mine.audio = new Audio(url)
b.classList.remove("is-busy")
mine.audio.addEventListener("ended", () => playing === mine && stopReading())
await mine.audio.play()
} catch (err) {
if (playing === mine) stopReading()
say(why(err?.message ? { detail: err.message } : err))
}
})
return b
}
/** What the account may use, from `GET /me`: the composer's Dictate button is drawn by it. */
export function voiceFor(account) {
me = account
drawMic()
}
export function initVoice() {
const b = $("dictate")
b.replaceChildren(icon(ICONS.mic))
b.addEventListener("click", () => {
if (busy) return
if (recorder) recorder.stop()
else start()
})
drawMic()
}