diff --git a/CHANGELOG.md b/CHANGELOG.md index ee46906..73828cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,13 @@ All notable changes to LLeMbas Desktop. The format follows server's search, and lists below the titles what it found inside chats, in the file library and in memory, each with the words around the match, the match marked. A chat opens, a file opens in the viewer, a memory opens the Memory page at it. A server without search finds titles only. +- **The server's files can be changed.** On Files → Server, Upload (files chosen in the system's + dialog) and New folder work in the home folder, and each folder's menu does the same inside it; + a file's or folder's menu renames it or moves it to the trash. What the server refuses is said + in the pane, in its words. +- **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. ### Fixed diff --git a/scripts/gen-i18n.py b/scripts/gen-i18n.py index 57babcf..b8e32f7 100755 --- a/scripts/gen-i18n.py +++ b/scripts/gen-i18n.py @@ -493,6 +493,17 @@ KEYS = ( "What your models remember about you, across chats. Change or remove anything here.", "Something to remember…", "Nothing remembered yet.", + # explorer.js, scope.js: the library's writes, and a chat's tools and skills + "Upload", + "Moved to the trash.", + "Move to the trash", + "Move “%(name)s” to the trash? It can be restored from there.", + "Tools and skills…", + "Skills", + "Switched off here only. What is on to begin with is your default for this model, in Settings → Models.", + "Always allowed here", + "Ask me about these again", + "Nothing to switch in this chat.", ) # The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the @@ -748,6 +759,8 @@ OWN: dict[str, dict[str, str]] = { "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.", "Something to remember…": "Něco k zapamatování…", "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.", }, "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.", @@ -998,6 +1011,8 @@ OWN: dict[str, dict[str, str]] = { "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.", "Something to remember…": "Etwas, das behalten werden soll…", "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.", }, "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.", @@ -1248,6 +1263,8 @@ OWN: dict[str, dict[str, str]] = { "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.", "Something to remember…": "Algo que recordar…", "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.", }, "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.", @@ -1498,6 +1515,8 @@ OWN: dict[str, dict[str, str]] = { "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.", "Something to remember…": "Quelque chose à retenir…", "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.", }, "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.", @@ -1748,6 +1767,8 @@ OWN: dict[str, dict[str, str]] = { "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.", "Something to remember…": "Valami, amit meg kell jegyezni…", "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.", }, "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.", @@ -1998,6 +2019,8 @@ OWN: dict[str, dict[str, str]] = { "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.", "Something to remember…": "Qualcosa da ricordare…", "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.", }, "pl": { "Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.", @@ -2248,6 +2271,8 @@ OWN: dict[str, dict[str, str]] = { "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ąć.", "Something to remember…": "Coś do zapamiętania…", "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ć.", }, "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.", @@ -2498,6 +2523,8 @@ OWN: dict[str, dict[str, str]] = { "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ť.", "Something to remember…": "Niečo na zapamätanie…", "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úť.", }, } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3c9c7ef..44c6a3e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -690,6 +690,75 @@ async fn attach_dropped( .map_err(|e| ApiError::new(0, e.to_string()))? } +/// What an upload into the library came to: the directory's listing after the last file the +/// server took, and the files it did not take, each with the reason. +#[derive(Clone, Serialize)] +struct LibraryUpload { + listing: Option, + sent: usize, + failed: Vec, +} + +/// Choose files with the system's dialog and send them into the person's library, into the +/// directory `dir`. Nothing is sent when the dialog is cancelled. +#[tauri::command] +async fn library_upload( + handle: AppHandle, + dir: String, + title: String, +) -> Result { + let h = handle.clone(); + tauri::async_runtime::spawn_blocking(move || { + let picked = h.dialog().file().set_title(title).blocking_pick_files(); + let paths: Vec = picked + .unwrap_or_default() + .into_iter() + .filter_map(|p| p.into_path().ok()) + .collect(); + let Some((app_url, token)) = h.state::().door() else { + return Err(ApiError::new(401, "Not signed in.")); + }; + let mut out = LibraryUpload { + listing: None, + sent: 0, + failed: Vec::new(), + }; + for path in paths { + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "file".into()); + let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0); + let sent = match read_for_upload(&path) { + Ok(bytes) => server::upload_to_library(&app_url, &token, &dir, &name, &bytes) + .map_err(|e| { + if e.status == 401 { + token_refused(&h); + } + (None, e.detail) + }), + Err((reason, message)) => Err((Some(reason), message)), + }; + match sent { + Ok(listing) => { + out.listing = Some(listing); + out.sent += 1; + } + Err((reason, message)) => out.failed.push(Uploaded { + name, + size, + attachment: None, + error: Some(message), + reason, + }), + } + } + Ok(out) + }) + .await + .map_err(|e| ApiError::new(0, e.to_string()))? +} + /// `%XX` escapes undone (the page sends a file's name as `encodeURIComponent` gives it). fn percent_decode(text: &str) -> String { let b = text.as_bytes(); @@ -1640,6 +1709,7 @@ pub fn run() { sealed_open, sealed_close, attach_pick, + library_upload, attach_dropped, attach_bytes, set_theme, diff --git a/src-tauri/src/server.rs b/src-tauri/src/server.rs index 8d0632c..77c8d4b 100644 --- a/src-tauri/src/server.rs +++ b/src-tauri/src/server.rs @@ -650,12 +650,12 @@ fn header_name(name: &str) -> String { } } -/// The body of a `multipart/form-data` request with one file in the field `file`. -fn multipart(boundary: &str, name: &str, bytes: &[u8]) -> Vec { +/// 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 { let mut body = Vec::with_capacity(bytes.len() + 256); body.extend_from_slice( format!( - "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n", + "--{boundary}\r\nContent-Disposition: form-data; name=\"{field}\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n", header_name(name) ) .as_bytes(), @@ -685,6 +685,38 @@ pub fn upload( } else { format!("/attachments?chat={chat}") }; + let v = send_file(app, token, &path, "file", name, bytes)?; + v.get("attachment") + .cloned() + .ok_or_else(|| ApiError::new(0, "The server's answer was not understood.".to_string())) +} + +/// Send one file into the person's library, into the directory `dir` (`/files/upload`, the +/// explorer's upload): the server's listing of that directory afterwards, as JSON. +pub fn upload_to_library( + app: &str, + token: &str, + dir: &str, + name: &str, + bytes: &[u8], +) -> Result { + if !dir.starts_with('/') || dir.chars().any(|c| c.is_control()) { + return Err(ApiError::new(400, format!("Not a folder: {dir}"))); + } + let path = format!("/files/upload?path={}", crate::terminal::encode(dir)); + send_file(app, token, &path, "files", name, bytes) +} + +/// One `multipart/form-data` POST of one file to `path` on the app's door; the JSON answer. +fn send_file( + app: &str, + token: &str, + path: &str, + field: &str, + name: &str, + bytes: &[u8], +) -> Result { + let path = checked_path(path)?; let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) @@ -698,13 +730,9 @@ pub fn upload( "content-type", &format!("multipart/form-data; boundary={boundary}"), ) - .send_bytes(&multipart(&boundary, name, bytes))?; - let v: Value = res - .into_json() - .map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string()))?; - v.get("attachment") - .cloned() - .ok_or_else(|| ApiError::new(0, "The server's answer was not understood.".to_string())) + .send_bytes(&multipart(&boundary, field, name, bytes))?; + res.into_json() + .map_err(|_| ApiError::new(0, "The server's answer was not JSON.".to_string())) } /// Server-sent events, a line at a time: what `stream` hands on, one `(event, data)` per frame. @@ -959,7 +987,7 @@ mod tests { #[test] fn a_file_goes_as_one_multipart_field() { - let body = multipart("XYZ", "a\"b\r\n.txt", b"hello"); + let body = multipart("XYZ", "file", "a\"b\r\n.txt", b"hello"); let text = String::from_utf8(body).unwrap(); assert!(text.starts_with( "--XYZ\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a_b__.txt\"\r\n" @@ -967,6 +995,8 @@ mod tests { assert!(text.contains("\r\n\r\nhello\r\n--XYZ--\r\n")); assert_eq!(header_name(" "), "file"); assert_eq!(header_name("Schöne Grüße.pdf"), "Schöne Grüße.pdf"); + let library = String::from_utf8(multipart("XYZ", "files", "b.md", b"x")).unwrap(); + assert!(library.contains("name=\"files\"; filename=\"b.md\"")); } #[test] diff --git a/tests/ui/mock-tauri.js b/tests/ui/mock-tauri.js index a1afee2..cf47222 100644 --- a/tests/ui/mock-tauri.js +++ b/tests/ui/mock-tauri.js @@ -100,8 +100,12 @@ return "" case "terminal_word": return "lembas" - default: - return null + default: { + // A command the test answers itself: `commands: { name: value }` (an `__error` rejects). + const own = (config.commands || {})[cmd] + if (own && own.__error) return Promise.reject(own.__error) + return own === undefined ? null : JSON.parse(JSON.stringify(own)) + } } } diff --git a/tests/ui/run.mjs b/tests/ui/run.mjs index e67164b..69f8705 100644 --- a/tests/ui/run.mjs +++ b/tests/ui/run.mjs @@ -1293,6 +1293,111 @@ test("G8: at 390 px the memory page and the palette's hits stay inside the windo assert.deepEqual(p.errors, []) }) +// H8: the library's writes (files_listing.json) and a chat's tools and skills (scope.json). + +function h8(over = {}, commands = {}) { + const listing = fixture("files_listing") + return { + status: status(), + routes: baseRoutes({ + "GET /files": listing, + "GET /files?path=%2Fhome%2Ffrodo%2Frecipes": { path: "/home/frodo/recipes", parent: "/home/frodo", entries: [] }, + "POST /files/mkdir": listing, + "POST /files/rename": listing, + "DELETE /files": null, + "GET /chats/chat1/scope": fixture("scope"), + "POST /chats/chat1/scope": { scope: { ...fixture("scope").scope, families: [{ gate: "notes", label: "Notes", on: false }, { gate: "web", label: "Web search", on: false }] } }, + "POST /chats/chat1/allow/clear": { scope: { ...fixture("scope").scope, allow: [] } }, + ...over, + }), + commands: { library_upload: { listing, sent: 1, failed: [] }, ...commands }, + streams: {}, + } +} + +async function serverPane(p) { + await p.click("#nav-files") + await p.click('#files-tabs [data-tab="server"]') + await p.waitForSelector('#server-tree li[data-path="/home/frodo/lembas.md"]') +} + +test("H8: the server's files: upload, a new folder, rename and delete, each read again from the server", async () => { + const p = await page(h8()) + await serverPane(p) + // Upload into the home the server answered with, through Rust's dialog. + await p.click("#server-upload") + await p.waitForFunction(() => window.__mock.calls.some((c) => c.cmd === "library_upload")) + assert.equal((await p.evaluate(() => window.__mock.calls.find((c) => c.cmd === "library_upload").args.dir)), "/home/frodo") + await p.waitForFunction(() => window.__mock.api("GET", "/files?path=%2F").length === 2) + // A new folder inside a folder, from its row's menu. + await p.click('#server-tree li[data-path="/home/frodo/recipes"] > .tree__more') + await p.click(".menu .menu__item >> text=New folder") + await p.fill("dialog[open] .dialog__input", "Breads") + await p.click("dialog[open] .btn--primary") + await p.waitForFunction(() => window.__mock.api("POST", "/files/mkdir").length === 1) + assert.deepEqual((await p.evaluate(() => window.__mock.api("POST", "/files/mkdir")))[0].args.body, { path: "/home/frodo/recipes", name: "Breads" }) + // Rename a file. + await p.click('#server-tree li[data-path="/home/frodo/lembas.md"] > .tree__more') + await p.click(".menu .menu__item >> text=Rename") + await p.fill("dialog[open] .dialog__input", "waybread.md") + await p.click("dialog[open] .btn--primary") + await p.waitForFunction(() => window.__mock.api("POST", "/files/rename").length === 1) + assert.deepEqual((await p.evaluate(() => window.__mock.api("POST", "/files/rename")))[0].args.body, { path: "/home/frodo/lembas.md", name: "waybread.md" }) + // Delete: to the trash, after asking. + await p.click('#server-tree li[data-path="/home/frodo/lembas.md"] > .tree__more') + await p.click(".menu .menu__item >> text=Delete") + await p.click("dialog[open] .btn--danger") + await p.waitForFunction(() => window.__mock.api("DELETE", "/files?path=%2Fhome%2Ffrodo%2Flembas.md").length === 1) + await p.waitForSelector("#server-status:not([hidden])") + assert.equal(await p.textContent("#server-status"), "Moved to the trash.") + await shot(p, "h8-files-1280") + assert.deepEqual(p.errors, []) +}) + +test("H8: a refusal or a file the server did not take is said in the pane", async () => { + const p = await page(h8({ "POST /files/rename": { __error: { status: 400, detail: "That did not work: read-only" } } }, { library_upload: { listing: null, sent: 0, failed: [{ name: "big.iso", error: "This file is too large to send." }] } })) + await serverPane(p) + await p.click("#server-upload") + await p.waitForSelector("#server-status.is-error") + assert.equal(await p.textContent("#server-status"), "big.iso: This file is too large to send.") + await p.click('#server-tree li[data-path="/home/frodo/lembas.md"] > .tree__more') + await p.click(".menu .menu__item >> text=Rename") + await p.fill("dialog[open] .dialog__input", "x.md") + await p.click("dialog[open] .btn--primary") + await p.waitForFunction(() => document.getElementById("server-status").textContent.includes("read-only")) + assert.deepEqual(p.errors, []) +}) + +test("H8: a chat's tools and skills switch here only; the allowed commands can be forgotten", async () => { + const p = await page(h8()) + await p.waitForSelector('.chatrow[data-id="chat1"]') + await p.click('.chatrow[data-id="chat1"]', { button: "right" }) + await p.click(".menu .menu__item >> text=Tools and skills…") + await p.waitForSelector('.scope [data-key="family:notes"]') + const rows = await p.$$eval(".scope .scope__row", (l) => l.map((x) => [x.textContent, x.querySelector("input").checked])) + assert.deepEqual(rows, [["Notes", true], ["Web search", false], ["baking", true]]) + assert.equal(await p.getAttribute('.scope .scope__row:has([data-key="skill:baking"])', "title"), "Bake waybread.") + await p.click('.scope [data-key="family:notes"]') + await p.waitForFunction(() => window.__mock.api("POST", "/chats/chat1/scope").length === 1) + assert.deepEqual((await p.evaluate(() => window.__mock.api("POST", "/chats/chat1/scope")))[0].args.body, { kind: "family", name: "notes", on: false }) + await p.waitForFunction(() => document.querySelector('.scope [data-key="family:notes"]') && !document.querySelector('.scope [data-key="family:notes"]').checked && !document.querySelector('.scope [data-key="family:notes"]').disabled) + assert.match(await p.textContent(".scope__allow"), /bash:ls \*/) + await shot(p, "h8-scope-1280") + await p.click(".scope .btn >> text=Ask me about these again") + await p.waitForFunction(() => !document.querySelector(".scope__allow")) + assert.deepEqual(p.errors, []) +}) + +test("H8: at 390 px the server's pane and the switches stay inside the window", async () => { + const p = await page(h8(), { width: 390, height: 844 }) + await p.evaluate(() => document.getElementById("nav-files").click()) + await p.click('#files-tabs [data-tab="server"]') + await p.waitForSelector('#server-tree li[data-path="/home/frodo/lembas.md"]') + assert.ok(await noSideways(p, 390), "nothing scrolls sideways") + await shot(p, "h8-files-390") + assert.deepEqual(p.errors, []) +}) + // --- Run ---------------------------------------------------------------------------------------- const only = process.argv[2] || "" diff --git a/ui/app.css b/ui/app.css index 32e786c..ce2eb41 100644 --- a/ui/app.css +++ b/ui/app.css @@ -1401,6 +1401,9 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); } .files__pane { display: grid; gap: var(--sp-2); background: var(--surface); border: var(--border-w) solid var(--border); border-radius: var(--radius-soft); padding: var(--sp-3); min-width: 0; } .files__head { display: grid; grid-template-columns: minmax(0, 1fr) var(--control-h-sm); gap: var(--sp-2); align-items: center; } .files__where { min-width: 0; } +/* The server's pane: Upload and New folder before Read again. */ +#pane-server .files__head { grid-template-columns: minmax(0, 1fr) repeat(3, var(--control-h-sm)); } +#server-status { white-space: pre-line; } .files__where select { width: 100%; height: var(--control-h-sm); } .files__tree { min-height: 0; } .pane__path { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; direction: rtl; text-align: left; font: var(--text-xs) var(--font-ui); color: var(--ink-muted); } @@ -1434,6 +1437,12 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); } .tree__row[aria-expanded="true"] .tree__chevron .icon { transform: rotate(90deg); } .tree__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .tree__size { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); } +/* A row with a more button beside it: the button on the row's own line, its folder below both. */ +.tree__item { display: grid; grid-template-columns: minmax(0, 1fr) var(--control-h-sm); align-items: center; } +.tree__item > ul { grid-column: 1 / -1; } +.tree__more { opacity: 0; } +.tree__item:hover > .tree__more, .tree__more:focus-visible, .tree__more[aria-expanded="true"] { opacity: 1; } +@media (hover: none) { .tree__more { opacity: 1; } } .tree__note { padding: var(--sp-1) var(--sp-3); font-size: var(--text-xs); color: var(--ink-muted); } /* An empty page: an icon, a heading, a sentence, centred. */ @@ -1696,6 +1705,17 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); } .arch__page { display: grid; gap: var(--sp-3); } .head__unarchive { white-space: nowrap; } +/* --- A chat's tools and skills (scope.js): switches in a popover, the allowed commands below. */ + +.scope { max-height: min(70vh, 32rem); overflow-y: auto; } +.scope .pop__actions { justify-content: flex-start; } +.scope .pop__actions .btn { max-width: 100%; white-space: normal; height: auto; min-height: var(--control-h-sm); } +.scope__list { display: grid; gap: var(--sp-2); } +.scope__row span { overflow-wrap: anywhere; } +.scope__hint { margin: 0; font-size: var(--text-xs); } +.scope__allow { display: grid; gap: var(--sp-1); margin: 0; padding: 0; list-style: none; } +.scope__allow code { font: var(--text-xs) var(--font-mono); overflow-wrap: anywhere; } + /* --- Memory: a new one on top, then each record over its group and date · Edit · more. */ .mnew { display: grid; gap: var(--sp-2); } diff --git a/ui/i18n.js b/ui/i18n.js index 82bc589..7589c28 100644 --- a/ui/i18n.js +++ b/ui/i18n.js @@ -53,6 +53,7 @@ const LEMBAS_I18N = { "Allow": "Povolit", "Always": "Vždy", "Always allow": "Vždy povolit", + "Always allowed here": "Tady vždy povoleno", "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Agentní konverzace pracuje ve složce na zařízení %(device)s přes tamního agenta LLeMbas.", "An agent chat works in a folder on this computer, through the agent built into this app.": "Agentní konverzace pracuje ve složce na tomto počítači přes agenta vestavěného v této aplikaci.", "Another device": "Jiné zařízení", @@ -61,6 +62,7 @@ const LEMBAS_I18N = { "Approve this code in the page that opened in your browser:": "Potvrďte tento kód na stránce, která se otevřela ve vašem prohlížeči:", "Archive chat": "Archivovat konverzaci", "Archived": "Archivované", + "Ask me about these again": "Znovu se mě na to ptát", "Attach files": "Přikládat soubory", "Auto — asks nothing": "Auto — na nic se neptá", "Automatic compaction is off.": "Automatické shrnutí je vypnuté.", @@ -183,6 +185,9 @@ const LEMBAS_I18N = { "Models for scheduled tasks": "Modely pro naplánované úlohy", "More": "Více", "Move to folder…": "Přesunout do složky…", + "Move to the trash": "Přesunout do koše", + "Move “%(name)s” to the trash? It can be restored from there.": "Přesunout „%(name)s“ do koše? Odtamtud ho lze obnovit.", + "Moved to the trash.": "Přesunuto do koše.", "Never": "Nikdy", "New agent chat": "Nová agentní konverzace", "New chat": "Nová konverzace", @@ -214,6 +219,7 @@ const LEMBAS_I18N = { "Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "Na pozadí nic neběží. Podagenti, členové týmu a příkazy na pozadí se tu zobrazí, dokud pracují.", "Nothing to report": "Nic k hlášení", "Nothing to send: select some output first.": "Není co odeslat: nejdřív označte část výstupu.", + "Nothing to switch in this chat.": "V této konverzaci není co přepnout.", "Nothing yet.": "Zatím nic.", "Notifications": "Upozornění", "One chat that grows": "Jedna konverzace, která roste", @@ -293,6 +299,7 @@ const LEMBAS_I18N = { "Signed in as %(who)s.": "Přihlášen jako %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Odhlášením se přihlášení aplikace na serveru zruší; propojení agenta zůstane. Chcete-li použít jiný server, odhlaste se.", "Since the last reply": "Od poslední odpovědi", + "Skills": "Dovednosti", "Skipped": "Vynecháno", "Something to remember…": "Něco k zapamatování…", "Source": "Zdroj", @@ -309,6 +316,7 @@ const LEMBAS_I18N = { "Subagent %(name)s (%(id)s), %(position)s, asks:": "Podagent %(name)s (%(id)s), %(position)s, se ptá:", "Subagents": "Podagenti", "Summarise the earlier turns now and send the summary in their place.": "Shrnout dřívější kola hned a posílat místo nich shrnutí.", + "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:", "Tasks: %(n)s": "Úlohy: %(n)s", @@ -374,11 +382,13 @@ const LEMBAS_I18N = { "Title": "Název", "Today": "Dnes", "Tools": "Nástroje", + "Tools and skills…": "Nástroje a dovednosti…", "Try again": "Zkusit znovu", "Type a command or a chat's title": "Napište příkaz nebo název konverzace", "Type a folder…": "Zadat složku…", "Unpin chat": "Odepnout konverzaci", "Up to %(n)s at once": "Nejvýše %(n)s současně", + "Upload": "Nahrát", "Wait until the files are sent.": "Počkejte, až se soubory odešlou.", "Waiting for a free model": "Čeká na volný model", "Waiting for subagents: {{running}} running": "Čeká na podagenty: pracuje {{running}}", @@ -493,6 +503,7 @@ const LEMBAS_I18N = { "Allow": "Erlauben", "Always": "Immer", "Always allow": "Immer erlauben", + "Always allowed here": "Hier immer erlaubt", "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Ein Agenten-Chat arbeitet in einem Ordner auf %(device)s, über den LLeMbas-Agenten dort.", "An agent chat works in a folder on this computer, through the agent built into this app.": "Ein Agenten-Chat arbeitet in einem Ordner auf diesem Computer, über den in diese App eingebauten Agenten.", "Another device": "Ein anderes Gerät", @@ -501,6 +512,7 @@ const LEMBAS_I18N = { "Approve this code in the page that opened in your browser:": "Bestätigen Sie diesen Code auf der Seite, die sich in Ihrem Browser geöffnet hat:", "Archive chat": "Chat archivieren", "Archived": "Archiviert", + "Ask me about these again": "Mich dazu wieder fragen", "Attach files": "Dateien anhängen", "Auto — asks nothing": "Auto — fragt nichts", "Automatic compaction is off.": "Die automatische Zusammenfassung ist aus.", @@ -623,6 +635,9 @@ const LEMBAS_I18N = { "Models for scheduled tasks": "Modelle für geplante Aufgaben", "More": "Mehr", "Move to folder…": "In Ordner verschieben…", + "Move to the trash": "In den Papierkorb", + "Move “%(name)s” to the trash? It can be restored from there.": "„%(name)s“ in den Papierkorb verschieben? Von dort lässt es sich wiederherstellen.", + "Moved to the trash.": "In den Papierkorb verschoben.", "Never": "Nie", "New agent chat": "Neuer Agenten-Chat", "New chat": "Neuer Chat", @@ -654,6 +669,7 @@ const LEMBAS_I18N = { "Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "Im Hintergrund läuft nichts. Subagenten, die Mitglieder eines Teams und Hintergrundbefehle erscheinen hier, solange sie arbeiten.", "Nothing to report": "Nichts zu melden", "Nothing to send: select some output first.": "Nichts zu senden: Markieren Sie zuerst etwas Ausgabe.", + "Nothing to switch in this chat.": "In diesem Chat gibt es nichts umzuschalten.", "Nothing yet.": "Noch nichts.", "Notifications": "Benachrichtigungen", "One chat that grows": "Ein Chat, der wächst", @@ -733,6 +749,7 @@ const LEMBAS_I18N = { "Signed in as %(who)s.": "Angemeldet als %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Beim Abmelden gibt die App ihre Anmeldung am Server auf; die Verbindung des Agenten bleibt. Melden Sie sich ab, um einen anderen Server zu verwenden.", "Since the last reply": "Seit der letzten Antwort", + "Skills": "Fertigkeiten", "Skipped": "Ausgelassen", "Something to remember…": "Etwas, das behalten werden soll…", "Source": "Quelltext", @@ -749,6 +766,7 @@ const LEMBAS_I18N = { "Subagent %(name)s (%(id)s), %(position)s, asks:": "Subagent %(name)s (%(id)s), %(position)s, fragt:", "Subagents": "Subagenten", "Summarise the earlier turns now and send the summary in their place.": "Die früheren Runden jetzt zusammenfassen und stattdessen die Zusammenfassung senden.", + "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:", "Tasks: %(n)s": "Aufgaben: %(n)s", @@ -814,11 +832,13 @@ const LEMBAS_I18N = { "Title": "Titel", "Today": "Heute", "Tools": "Werkzeuge", + "Tools and skills…": "Werkzeuge und Fähigkeiten…", "Try again": "Erneut versuchen", "Type a command or a chat's title": "Befehl oder Chat-Titel eingeben", "Type a folder…": "Ordner eingeben…", "Unpin chat": "Chat loslösen", "Up to %(n)s at once": "Bis zu %(n)s gleichzeitig", + "Upload": "Hochladen", "Wait until the files are sent.": "Warten Sie, bis die Dateien gesendet sind.", "Waiting for a free model": "Wartet auf ein freies Modell", "Waiting for subagents: {{running}} running": "Wartet auf Subagenten: {{running}} laufen", @@ -933,6 +953,7 @@ const LEMBAS_I18N = { "Allow": "Permitir", "Always": "Siempre", "Always allow": "Permitir siempre", + "Always allowed here": "Siempre permitido aquí", "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Un chat de agente trabaja en una carpeta de %(device)s, a través del agente LLeMbas de allí.", "An agent chat works in a folder on this computer, through the agent built into this app.": "Un chat de agente trabaja en una carpeta de este equipo, a través del agente integrado en esta aplicación.", "Another device": "Otro dispositivo", @@ -941,6 +962,7 @@ const LEMBAS_I18N = { "Approve this code in the page that opened in your browser:": "Apruebe este código en la página que se abrió en su navegador:", "Archive chat": "Archivar chat", "Archived": "Archivados", + "Ask me about these again": "Volver a preguntarme por esto", "Attach files": "Adjuntar archivos", "Auto — asks nothing": "Auto — no pregunta nada", "Automatic compaction is off.": "La compactación automática está desactivada.", @@ -1063,6 +1085,9 @@ const LEMBAS_I18N = { "Models for scheduled tasks": "Modelos para tareas programadas", "More": "Más", "Move to folder…": "Mover a una carpeta…", + "Move to the trash": "Mover a la papelera", + "Move “%(name)s” to the trash? It can be restored from there.": "¿Mover «%(name)s» a la papelera? Se puede restaurar desde allí.", + "Moved to the trash.": "Movido a la papelera.", "Never": "Nunca", "New agent chat": "Nuevo chat de agente", "New chat": "Nuevo chat", @@ -1094,6 +1119,7 @@ const LEMBAS_I18N = { "Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "No hay nada en segundo plano. Los subagentes, los miembros de un equipo y los comandos en segundo plano aparecen aquí mientras trabajan.", "Nothing to report": "Nada que informar", "Nothing to send: select some output first.": "Nada que enviar: seleccione primero algo de la salida.", + "Nothing to switch in this chat.": "No hay nada que activar o desactivar en este chat.", "Nothing yet.": "Aún nada.", "Notifications": "Notificaciones", "One chat that grows": "Un chat que va creciendo", @@ -1173,6 +1199,7 @@ const LEMBAS_I18N = { "Signed in as %(who)s.": "Sesión iniciada como %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Al cerrar sesión, la aplicación renuncia a su acceso en el servidor; la vinculación del agente se mantiene. Cierre sesión para usar otro servidor.", "Since the last reply": "Desde la última respuesta", + "Skills": "Habilidades", "Skipped": "Omitida", "Something to remember…": "Algo que recordar…", "Source": "Código fuente", @@ -1189,6 +1216,7 @@ const LEMBAS_I18N = { "Subagent %(name)s (%(id)s), %(position)s, asks:": "El subagente %(name)s (%(id)s), %(position)s, pregunta:", "Subagents": "Subagentes", "Summarise the earlier turns now and send the summary in their place.": "Resumir ahora los turnos anteriores y enviar el resumen en su lugar.", + "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:", "Tasks: %(n)s": "Tareas: %(n)s", @@ -1254,11 +1282,13 @@ const LEMBAS_I18N = { "Title": "Título", "Today": "Hoy", "Tools": "Herramientas", + "Tools and skills…": "Herramientas y habilidades…", "Try again": "Reintentar", "Type a command or a chat's title": "Escriba un comando o el título de un chat", "Type a folder…": "Escribir una carpeta…", "Unpin chat": "Dejar de fijar el chat", "Up to %(n)s at once": "Hasta %(n)s a la vez", + "Upload": "Subir", "Wait until the files are sent.": "Espere a que se envíen los archivos.", "Waiting for a free model": "Esperando un modelo libre", "Waiting for subagents: {{running}} running": "Esperando a los subagentes: {{running}} en marcha", @@ -1373,6 +1403,7 @@ const LEMBAS_I18N = { "Allow": "Autoriser", "Always": "Toujours", "Always allow": "Toujours autoriser", + "Always allowed here": "Toujours autorisé ici", "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Une discussion d'agent travaille dans un dossier de %(device)s, via l'agent LLeMbas qui s'y trouve.", "An agent chat works in a folder on this computer, through the agent built into this app.": "Une discussion d'agent travaille dans un dossier de cet ordinateur, via l'agent intégré à cette application.", "Another device": "Un autre appareil", @@ -1381,6 +1412,7 @@ const LEMBAS_I18N = { "Approve this code in the page that opened in your browser:": "Approuvez ce code sur la page qui s'est ouverte dans votre navigateur :", "Archive chat": "Archiver la conversation", "Archived": "Archivées", + "Ask me about these again": "Me redemander pour ceux-ci", "Attach files": "Joindre des fichiers", "Auto — asks nothing": "Auto — ne demande rien", "Automatic compaction is off.": "Le compactage automatique est désactivé.", @@ -1503,6 +1535,9 @@ const LEMBAS_I18N = { "Models for scheduled tasks": "Modèles pour les tâches planifiées", "More": "Plus", "Move to folder…": "Déplacer dans un dossier…", + "Move to the trash": "Placer dans la corbeille", + "Move “%(name)s” to the trash? It can be restored from there.": "Placer « %(name)s » dans la corbeille ? On peut l'y restaurer.", + "Moved to the trash.": "Placé dans la corbeille.", "Never": "Jamais", "New agent chat": "Nouvelle discussion d'agent", "New chat": "Nouvelle conversation", @@ -1534,6 +1569,7 @@ const LEMBAS_I18N = { "Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "Rien ne tourne en arrière-plan. Les sous-agents, les membres d’une équipe et les commandes en arrière-plan s’affichent ici pendant qu’ils travaillent.", "Nothing to report": "Rien à signaler", "Nothing to send: select some output first.": "Rien à envoyer : sélectionnez d'abord une partie de la sortie.", + "Nothing to switch in this chat.": "Rien à activer ou désactiver dans cette conversation.", "Nothing yet.": "Rien pour l'instant.", "Notifications": "Notifications", "One chat that grows": "Une conversation qui grandit", @@ -1613,6 +1649,7 @@ const LEMBAS_I18N = { "Signed in as %(who)s.": "Connecté en tant que %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "La déconnexion abandonne la connexion de l'application au serveur ; la liaison de l'agent reste. Déconnectez-vous pour utiliser un autre serveur.", "Since the last reply": "Depuis la dernière réponse", + "Skills": "Compétences", "Skipped": "Ignorée", "Something to remember…": "Quelque chose à retenir…", "Source": "Source", @@ -1629,6 +1666,7 @@ const LEMBAS_I18N = { "Subagent %(name)s (%(id)s), %(position)s, asks:": "Le sous-agent %(name)s (%(id)s), %(position)s, demande :", "Subagents": "Sous-agents", "Summarise the earlier turns now and send the summary in their place.": "Résumer maintenant les tours précédents et envoyer le résumé à leur place.", + "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 :", "Tasks: %(n)s": "Tâches : %(n)s", @@ -1694,11 +1732,13 @@ const LEMBAS_I18N = { "Title": "Titre", "Today": "Aujourd’hui", "Tools": "Outils", + "Tools and skills…": "Outils et compétences…", "Try again": "Réessayer", "Type a command or a chat's title": "Tapez une commande ou le titre d'une conversation", "Type a folder…": "Saisir un dossier…", "Unpin chat": "Désépingler la discussion", "Up to %(n)s at once": "Jusqu’à %(n)s à la fois", + "Upload": "Envoyer", "Wait until the files are sent.": "Attendez que les fichiers soient envoyés.", "Waiting for a free model": "En attente d’un modèle libre", "Waiting for subagents: {{running}} running": "En attente des sous-agents : {{running}} en cours", @@ -1813,6 +1853,7 @@ const LEMBAS_I18N = { "Allow": "Engedélyezés", "Always": "Mindig", "Always allow": "Mindig engedélyez", + "Always allowed here": "Itt mindig engedélyezett", "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Az ügynökcsevegés egy mappában dolgozik ezen: %(device)s, az ottani LLeMbas-ügynökön keresztül.", "An agent chat works in a folder on this computer, through the agent built into this app.": "Az ügynökcsevegés egy mappában dolgozik ezen a számítógépen, az alkalmazásba épített ügynökön keresztül.", "Another device": "Másik eszköz", @@ -1821,6 +1862,7 @@ const LEMBAS_I18N = { "Approve this code in the page that opened in your browser:": "Hagyja jóvá ezt a kódot a böngészőjében megnyílt oldalon:", "Archive chat": "Csevegés archiválása", "Archived": "Archivált", + "Ask me about these again": "Ezekről kérdezzen meg újra", "Attach files": "Fájlok csatolása", "Auto — asks nothing": "Auto — semmit nem kérdez", "Automatic compaction is off.": "Az automatikus tömörítés ki van kapcsolva.", @@ -1943,6 +1985,9 @@ const LEMBAS_I18N = { "Models for scheduled tasks": "Modellek az ütemezett feladatokhoz", "More": "Több", "Move to folder…": "Áthelyezés mappába…", + "Move to the trash": "Áthelyezés a lomtárba", + "Move “%(name)s” to the trash? It can be restored from there.": "Áthelyezi a lomtárba: „%(name)s”? Onnan visszaállítható.", + "Moved to the trash.": "A lomtárba került.", "Never": "Soha", "New agent chat": "Új ügynökcsevegés", "New chat": "Új csevegés", @@ -1974,6 +2019,7 @@ const LEMBAS_I18N = { "Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "A háttérben semmi sem fut. Az alügynökök, egy csapat tagjai és a háttérparancsok itt jelennek meg, amíg dolgoznak.", "Nothing to report": "Nincs jelentenivaló", "Nothing to send: select some output first.": "Nincs mit küldeni: előbb jelöljön ki valamennyi kimenetet.", + "Nothing to switch in this chat.": "Ebben a beszélgetésben nincs mit átkapcsolni.", "Nothing yet.": "Még semmi.", "Notifications": "Értesítések", "One chat that grows": "Egy beszélgetés, amely bővül", @@ -2053,6 +2099,7 @@ const LEMBAS_I18N = { "Signed in as %(who)s.": "Bejelentkezve: %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Kijelentkezéskor az alkalmazás lemond a szerveren lévő bejelentkezéséről; az ügynök kapcsolata megmarad. Más szerver használatához jelentkezzen ki.", "Since the last reply": "Az utolsó válasz óta", + "Skills": "Készségek", "Skipped": "Kihagyva", "Something to remember…": "Valami, amit meg kell jegyezni…", "Source": "Forrás", @@ -2069,6 +2116,7 @@ const LEMBAS_I18N = { "Subagent %(name)s (%(id)s), %(position)s, asks:": "A(z) %(name)s alügynök (%(id)s), %(position)s, kérdezi:", "Subagents": "Alügynökök", "Summarise the earlier turns now and send the summary in their place.": "A korábbi köröket most összefoglalja, és helyettük az összefoglalót küldi.", + "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:", "Tasks: %(n)s": "Feladatok: %(n)s", @@ -2134,11 +2182,13 @@ const LEMBAS_I18N = { "Title": "Cím", "Today": "Ma", "Tools": "Eszközök", + "Tools and skills…": "Eszközök és készségek…", "Try again": "Újrapróbálás", "Type a command or a chat's title": "Írjon be egy parancsot vagy egy csevegés címét", "Type a folder…": "Mappa megadása…", "Unpin chat": "Kitűzés megszüntetése", "Up to %(n)s at once": "Legfeljebb %(n)s egyszerre", + "Upload": "Feltöltés", "Wait until the files are sent.": "Várjon, amíg a fájlok elküldődnek.", "Waiting for a free model": "Szabad modellre vár", "Waiting for subagents: {{running}} running": "Az alügynökökre vár: {{running}} dolgozik", @@ -2253,6 +2303,7 @@ const LEMBAS_I18N = { "Allow": "Consenti", "Always": "Sempre", "Always allow": "Consenti sempre", + "Always allowed here": "Sempre consentito qui", "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Una chat con agente lavora in una cartella su %(device)s, tramite l’agente LLeMbas che vi si trova.", "An agent chat works in a folder on this computer, through the agent built into this app.": "Una chat con agente lavora in una cartella di questo computer, tramite l'agente integrato in questa app.", "Another device": "Un altro dispositivo", @@ -2261,6 +2312,7 @@ const LEMBAS_I18N = { "Approve this code in the page that opened in your browser:": "Approvi questo codice nella pagina che si è aperta nel suo browser:", "Archive chat": "Archivia chat", "Archived": "Archiviate", + "Ask me about these again": "Chiedimi di nuovo per questi", "Attach files": "Allegare file", "Auto — asks nothing": "Auto — non chiede nulla", "Automatic compaction is off.": "La compattazione automatica è disattivata.", @@ -2383,6 +2435,9 @@ const LEMBAS_I18N = { "Models for scheduled tasks": "Modelli per le attività pianificate", "More": "Altro", "Move to folder…": "Sposta in una cartella…", + "Move to the trash": "Sposta nel cestino", + "Move “%(name)s” to the trash? It can be restored from there.": "Spostare «%(name)s» nel cestino? Si può ripristinare da lì.", + "Moved to the trash.": "Spostato nel cestino.", "Never": "Mai", "New agent chat": "Nuova chat con agente", "New chat": "Nuova chat", @@ -2414,6 +2469,7 @@ const LEMBAS_I18N = { "Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "Nulla è in esecuzione in background. Sottoagenti, membri di una squadra e comandi in background compaiono qui mentre lavorano.", "Nothing to report": "Niente da segnalare", "Nothing to send: select some output first.": "Niente da inviare: seleziona prima un po' di output.", + "Nothing to switch in this chat.": "Non c’è niente da attivare o disattivare in questa chat.", "Nothing yet.": "Ancora niente.", "Notifications": "Notifiche", "One chat that grows": "Una chat che cresce", @@ -2493,6 +2549,7 @@ const LEMBAS_I18N = { "Signed in as %(who)s.": "Accesso effettuato come %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Uscendo, l'app rinuncia al suo accesso sul server; il collegamento dell'agente resta. Esca per usare un altro server.", "Since the last reply": "Dall’ultima risposta", + "Skills": "Abilità", "Skipped": "Saltata", "Something to remember…": "Qualcosa da ricordare…", "Source": "Sorgente", @@ -2509,6 +2566,7 @@ const LEMBAS_I18N = { "Subagent %(name)s (%(id)s), %(position)s, asks:": "Il subagente %(name)s (%(id)s), %(position)s, chiede:", "Subagents": "Sottoagenti", "Summarise the earlier turns now and send the summary in their place.": "Riassumi ora i turni precedenti e invia il riassunto al loro posto.", + "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:", "Tasks: %(n)s": "Attività: %(n)s", @@ -2574,11 +2632,13 @@ const LEMBAS_I18N = { "Title": "Titolo", "Today": "Oggi", "Tools": "Strumenti", + "Tools and skills…": "Strumenti e abilità…", "Try again": "Riprova", "Type a command or a chat's title": "Digiti un comando o il titolo di una chat", "Type a folder…": "Digita una cartella…", "Unpin chat": "Togli la chat dalle fissate", "Up to %(n)s at once": "Fino a %(n)s alla volta", + "Upload": "Carica", "Wait until the files are sent.": "Attenda che i file siano inviati.", "Waiting for a free model": "In attesa di un modello libero", "Waiting for subagents: {{running}} running": "In attesa dei subagenti: {{running}} al lavoro", @@ -2693,6 +2753,7 @@ const LEMBAS_I18N = { "Allow": "Pozwól", "Always": "Zawsze", "Always allow": "Zawsze zezwalaj", + "Always allowed here": "Tutaj zawsze dozwolone", "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Czat z agentem pracuje w folderze na %(device)s, przez działającego tam agenta LLeMbas.", "An agent chat works in a folder on this computer, through the agent built into this app.": "Czat z agentem pracuje w folderze na tym komputerze, przez agenta wbudowanego w tę aplikację.", "Another device": "Inne urządzenie", @@ -2701,6 +2762,7 @@ const LEMBAS_I18N = { "Approve this code in the page that opened in your browser:": "Zatwierdź ten kod na stronie, która otworzyła się w przeglądarce:", "Archive chat": "Archiwizuj czat", "Archived": "Zarchiwizowane", + "Ask me about these again": "Pytaj o to ponownie", "Attach files": "Załączaj pliki", "Auto — asks nothing": "Auto — o nic nie pyta", "Automatic compaction is off.": "Automatyczne streszczanie jest wyłączone.", @@ -2823,6 +2885,9 @@ const LEMBAS_I18N = { "Models for scheduled tasks": "Modele dla zaplanowanych zadań", "More": "Więcej", "Move to folder…": "Przenieś do folderu…", + "Move to the trash": "Przenieś do kosza", + "Move “%(name)s” to the trash? It can be restored from there.": "Przenieść „%(name)s” do kosza? Można go stamtąd przywrócić.", + "Moved to the trash.": "Przeniesiono do kosza.", "Never": "Nigdy", "New agent chat": "Nowy czat z agentem", "New chat": "Nowy czat", @@ -2854,6 +2919,7 @@ const LEMBAS_I18N = { "Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "W tle nic nie działa. Podagenci, członkowie zespołu i polecenia w tle pojawiają się tu, gdy pracują.", "Nothing to report": "Nic do zgłoszenia", "Nothing to send: select some output first.": "Nie ma czego wysłać: najpierw należy zaznaczyć jakiś wynik.", + "Nothing to switch in this chat.": "W tym czacie nie ma czego przełączać.", "Nothing yet.": "Jeszcze nic.", "Notifications": "Powiadomienia", "One chat that grows": "Jeden czat, który rośnie", @@ -2933,6 +2999,7 @@ const LEMBAS_I18N = { "Signed in as %(who)s.": "Zalogowano jako %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Wylogowanie zrzeka się logowania aplikacji na serwerze; połączenie agenta zostaje. Wyloguj się, aby użyć innego serwera.", "Since the last reply": "Od ostatniej odpowiedzi", + "Skills": "Umiejętności", "Skipped": "Pominięty", "Something to remember…": "Coś do zapamiętania…", "Source": "Źródło", @@ -2949,6 +3016,7 @@ const LEMBAS_I18N = { "Subagent %(name)s (%(id)s), %(position)s, asks:": "Podagent %(name)s (%(id)s), %(position)s, pyta:", "Subagents": "Podagenci", "Summarise the earlier turns now and send the summary in their place.": "Streścić teraz wcześniejsze tury i wysyłać streszczenie zamiast nich.", + "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:", "Tasks: %(n)s": "Zadania: %(n)s", @@ -3014,11 +3082,13 @@ const LEMBAS_I18N = { "Title": "Tytuł", "Today": "Dzisiaj", "Tools": "Narzędzia", + "Tools and skills…": "Narzędzia i umiejętności…", "Try again": "Spróbuj ponownie", "Type a command or a chat's title": "Wpisz polecenie lub tytuł czatu", "Type a folder…": "Wpisz folder…", "Unpin chat": "Odepnij czat", "Up to %(n)s at once": "Do %(n)s naraz", + "Upload": "Prześlij", "Wait until the files are sent.": "Poczekaj, aż pliki zostaną wysłane.", "Waiting for a free model": "Czeka na wolny model", "Waiting for subagents: {{running}} running": "Czeka na podagentów: pracuje {{running}}", @@ -3133,6 +3203,7 @@ const LEMBAS_I18N = { "Allow": "Povoliť", "Always": "Vždy", "Always allow": "Vždy povoliť", + "Always allowed here": "Tu vždy povolené", "An agent chat works in a folder on %(device)s, through the LLeMbas agent there.": "Agentská konverzácia pracuje v priečinku na zariadení %(device)s cez tamojšieho agenta LLeMbas.", "An agent chat works in a folder on this computer, through the agent built into this app.": "Agentská konverzácia pracuje v priečinku na tomto počítači cez agenta vstavaného v tejto aplikácii.", "Another device": "Iné zariadenie", @@ -3141,6 +3212,7 @@ const LEMBAS_I18N = { "Approve this code in the page that opened in your browser:": "Potvrďte tento kód na stránke, ktorá sa otvorila vo vašom prehliadači:", "Archive chat": "Archivovať konverzáciu", "Archived": "Archivované", + "Ask me about these again": "Znova sa ma na to spýtať", "Attach files": "Prikladať súbory", "Auto — asks nothing": "Auto — na nič sa nepýta", "Automatic compaction is off.": "Automatické zhrnutie je vypnuté.", @@ -3263,6 +3335,9 @@ const LEMBAS_I18N = { "Models for scheduled tasks": "Modely pre naplánované úlohy", "More": "Viac", "Move to folder…": "Presunúť do priečinka…", + "Move to the trash": "Presunúť do koša", + "Move “%(name)s” to the trash? It can be restored from there.": "Presunúť „%(name)s“ do koša? Odtiaľ sa dá obnoviť.", + "Moved to the trash.": "Presunuté do koša.", "Never": "Nikdy", "New agent chat": "Nová agentská konverzácia", "New chat": "Nová konverzácia", @@ -3294,6 +3369,7 @@ const LEMBAS_I18N = { "Nothing runs in the background. Subagents, a team's members and background commands show here while they work.": "Na pozadí nič nebeží. Podagenti, členovia tímu a príkazy na pozadí sa tu zobrazia, kým pracujú.", "Nothing to report": "Nič na hlásenie", "Nothing to send: select some output first.": "Nie je čo odoslať: najprv označte časť výstupu.", + "Nothing to switch in this chat.": "V tejto konverzácii nie je čo prepnúť.", "Nothing yet.": "Zatiaľ nič.", "Notifications": "Upozornenia", "One chat that grows": "Jedna konverzácia, ktorá rastie", @@ -3373,6 +3449,7 @@ const LEMBAS_I18N = { "Signed in as %(who)s.": "Prihlásený ako %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Odhlásením sa prihlásenie aplikácie na serveri zruší; prepojenie agenta zostane. Ak chcete použiť iný server, odhláste sa.", "Since the last reply": "Od poslednej odpovede", + "Skills": "Schopnosti", "Skipped": "Vynechané", "Something to remember…": "Niečo na zapamätanie…", "Source": "Zdroj", @@ -3389,6 +3466,7 @@ const LEMBAS_I18N = { "Subagent %(name)s (%(id)s), %(position)s, asks:": "Podagent %(name)s (%(id)s), %(position)s, sa pýta:", "Subagents": "Podagenti", "Summarise the earlier turns now and send the summary in their place.": "Zhrnúť skoršie kolá hneď a posielať namiesto nich zhrnutie.", + "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:", "Tasks: %(n)s": "Úlohy: %(n)s", @@ -3454,11 +3532,13 @@ const LEMBAS_I18N = { "Title": "Názov", "Today": "Dnes", "Tools": "Nástroje", + "Tools and skills…": "Nástroje a schopnosti…", "Try again": "Skúsiť znova", "Type a command or a chat's title": "Napíšte príkaz alebo názov konverzácie", "Type a folder…": "Zadať priečinok…", "Unpin chat": "Odopnúť konverzáciu", "Up to %(n)s at once": "Najviac %(n)s naraz", + "Upload": "Nahrať", "Wait until the files are sent.": "Počkajte, kým sa súbory odošlú.", "Waiting for a free model": "Čaká na voľný model", "Waiting for subagents: {{running}} running": "Čaká na podagentov: pracujú {{running}}", diff --git a/ui/index.html b/ui/index.html index bcf81f2..1fe9b78 100644 --- a/ui/index.html +++ b/ui/index.html @@ -109,8 +109,11 @@ diff --git a/ui/js/chat.js b/ui/js/chat.js index 8f3b138..73a412d 100644 --- a/ui/js/chat.js +++ b/ui/js/chat.js @@ -21,6 +21,7 @@ import { folderName, listed, folderTree, newFolder } from "./list.js" 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" const MODES = ["manual", "edit", "auto", "plan"] @@ -1037,6 +1038,7 @@ export function chatMenu(anchor, chat) { items.push({ label: chat.pinned ? t("Unpin chat") : t("Pin chat"), run: () => setChat(chat, { pinned: !chat.pinned }) }) items.push({ label: t("Move to folder…"), run: () => moveMenu(anchor, chat) }) } + if (!chat.archived) items.push({ label: t("Tools and skills…"), run: () => scopePopover(anchor, chat) }) if (chat.kind === "agent" && chat.device_session_id) items.push({ label: t("Continue in a terminal…"), run: () => continueInTerminal(chat) }) if (base) items.push({ label: t("Open in the web interface"), run: () => openLink(`${base}/chat/${encodeURIComponent(chat.id)}`) }) items.push(null, chat.archived ? { label: t("Restore chat"), run: () => unarchiveChat(chat) } : { label: t("Archive chat"), run: () => archiveChat(chat) }) diff --git a/ui/js/explorer.js b/ui/js/explorer.js index fa80636..b9248ec 100644 --- a/ui/js/explorer.js +++ b/ui/js/explorer.js @@ -3,7 +3,8 @@ // server's tree, for the chats, read through /api/v2. A file opens in the viewer either way. import { $, h, clear, icon, ICONS, bytes } from "./dom.js" -import { invoke, get, why } from "./bridge.js" +import { invoke, get, post, del, why } from "./bridge.js" +import { menu, ask } from "./popup.js" import { openViewer } from "./viewer.js" // The server's tree as the app's door gives it, read as the person (their own access, no chat): @@ -18,12 +19,19 @@ export const SERVER_FILES = { /** A directory in either tree: the server's mounts open like its folders. */ const isDir = (e) => e.kind === "dir" || e.kind === "mount" -/** A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file. */ -function tree(box, { list, open, rootPath }) { +/** + * A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file; `actions(entry)` + * (optional) -> menu items for an entry's more button. Returns `reload(path)`, which reads a + * directory that is open again (the root by `rootPath`), the folders open inside it kept open. + */ +function tree(box, { list, open, rootPath, actions }) { clear(box) const ul = h("ul", { class: "tree", role: "tree" }) box.append(ul) + const levels = new Map() // path -> { ul, depth }, each directory drawn now + const opened = new Set() // the directories open now const level = async (parent, path, depth) => { + levels.set(path, { ul: parent, depth }) let entries try { entries = await list(path) @@ -42,26 +50,45 @@ function tree(box, { list, open, rootPath }) { h("span", { class: "tree__size", text: isDir(e) ? "" : bytes(e.size) }), ) row.style.setProperty("--depth", String(depth)) - const li = h("li", { role: "treeitem" }, row) + const items = actions?.(e) || [] + const li = h( + "li", + { role: "treeitem", class: items.length ? "tree__item" : null, dataset: { path: e.path } }, + row, + items.length + ? h("button", { type: "button", class: "tool tree__more", "aria-label": t("More"), title: t("More"), onclick: (ev) => menu(ev.currentTarget, items) }, icon(ICONS.more)) + : null, + ) if (isDir(e)) { let kids = null - row.addEventListener("click", async () => { - const opening = row.getAttribute("aria-expanded") !== "true" + const expand = async (opening) => { row.setAttribute("aria-expanded", String(opening)) if (!opening) { + opened.delete(e.path) + levels.delete(e.path) kids?.remove() kids = null return } + opened.add(e.path) kids = h("ul", { role: "group" }) li.append(kids) await level(kids, e.path, depth + 1) - }) + } + row.addEventListener("click", () => expand(row.getAttribute("aria-expanded") !== "true")) + if (opened.has(e.path)) expand(true) } else row.addEventListener("click", () => open(e)) parent.append(li) } } - return level(ul, rootPath, 0) + const done = level(ul, rootPath, 0) + const reload = (path) => { + const at = levels.get(path) || levels.get(rootPath) + if (!at) return Promise.resolve() + clear(at.ul) + return level(at.ul, levels.has(path) ? path : rootPath, at.depth) + } + return { done, reload } } // --- This computer ------------------------------------------------------------------------------ @@ -99,7 +126,7 @@ function renderLocal() { head.append(pick) } const root = local.root - tree(box, { + return tree(box, { rootPath: "", list: async (path) => (await invoke("local_list", { root, path })).entries, open: async (e) => { @@ -125,12 +152,96 @@ export async function readServer(path) { return get(SERVER_FILES.read(path)) } +// The library's writes, as the person (each answers with the directory's listing, or a refusal +// in the server's words): +// POST /files/upload?path= multipart `files` (sent by Rust: `library_upload`, from the dialog) +// POST /files/mkdir { path, name } +// POST /files/rename { path, name } +// DELETE /files?path= to the trash, as on the web + +let server = null // the tree drawn now: { reload(path) } +let serverRoot = "/" // the directory the server answered `/` with: the person's home + +const parentOf = (path) => path.replace(/\/[^/]*$/, "") || "/" +const reloadServer = (dir) => server?.reload(dir === serverRoot ? "/" : dir) + +function serverSaid(text, error = false) { + const line = $("server-status") + line.hidden = !text + line.textContent = text || "" + line.className = `status${error ? " is-error" : ""}` + line.setAttribute("role", error ? "alert" : "status") +} + +/** Into `dir`: files chosen in the system's dialog. */ +export async function uploadInto(dir) { + serverSaid("") + try { + const r = await invoke("library_upload", { dir, title: t("Upload") }) + if (r.failed?.length) serverSaid(r.failed.map((f) => `${f.name}: ${f.error}`).join("\n"), true) + if (r.sent) await reloadServer(dir) + } catch (err) { + serverSaid(why(err), true) + } +} + +async function makeFolder(dir) { + const name = await ask({ title: t("New folder"), value: "", ok: t("Create"), cancel: t("Cancel") }) + if (!name) return + try { + await post("/files/mkdir", { path: dir, name }) + serverSaid("") + await reloadServer(dir) + } catch (err) { + serverSaid(why(err), true) + } +} + +async function renameEntry(e) { + const name = await ask({ title: t("Rename"), value: e.name, ok: t("Rename"), cancel: t("Cancel") }) + if (!name || name === e.name) return + try { + await post("/files/rename", { path: e.path, name }) + serverSaid("") + await reloadServer(parentOf(e.path)) + } catch (err) { + serverSaid(why(err), true) + } +} + +async function deleteEntry(e) { + const yes = await ask({ title: t("Move to the trash"), text: t("Move “%(name)s” to the trash? It can be restored from there.", { name: e.name }), ok: t("Move to the trash"), cancel: t("Cancel"), danger: true }) + if (!yes) return + try { + await del(`/files?path=${encodeURIComponent(e.path)}`) + serverSaid(t("Moved to the trash.")) + await reloadServer(parentOf(e.path)) + } catch (err) { + serverSaid(why(err), true) + } +} + +/** What can be done to an entry of the server's tree: a mount (another store) is filled, never + * renamed or deleted from here. A read-only place is refused by the server, in its words. */ +function serverActions(e) { + const items = [] + if (isDir(e)) items.push({ label: t("Upload"), run: () => uploadInto(e.path) }, { label: t("New folder"), run: () => makeFolder(e.path) }) + if (e.kind !== "mount") items.push(...(items.length ? [null] : []), { label: t("Rename"), run: () => renameEntry(e) }, { label: t("Delete"), danger: true, run: () => deleteEntry(e) }) + return items +} + export function renderServer() { const box = $("server-tree") - tree(box, { + serverSaid("") + server = tree(box, { rootPath: "/", - list: async (path) => (await get(SERVER_FILES.list(path))).entries || [], + list: async (path) => { + const r = await get(SERVER_FILES.list(path)) + if (path === "/" && r.path) serverRoot = r.path + return r.entries || [] + }, open: (e) => openServerFile(e.path, e.name), + actions: serverActions, }) } @@ -177,6 +288,10 @@ export function initExplorer() { $("server-where").textContent = t("Your files on the server, as the chats see them.") $("server-refresh").replaceChildren(icon(ICONS.refresh)) $("server-refresh").addEventListener("click", renderServer) + $("server-upload").replaceChildren(icon(ICONS.send)) + $("server-upload").addEventListener("click", () => uploadInto(serverRoot)) + $("server-mkdir").replaceChildren(icon(ICONS.folder)) + $("server-mkdir").addEventListener("click", () => makeFolder(serverRoot)) $("local-refresh").replaceChildren(icon(ICONS.refresh)) $("local-refresh").addEventListener("click", renderLocal) } diff --git a/ui/js/popup.js b/ui/js/popup.js index 17234cd..4f9ef57 100644 --- a/ui/js/popup.js +++ b/ui/js/popup.js @@ -199,7 +199,8 @@ function escPop(e) { } /** - * A small panel under `anchor` (or above it, `up`), holding `content` (an element, or a function + * A small panel under `anchor` (an element, or `{ x, y }` in page pixels as a context menu's + * point; or above it, `up`), holding `content` (an element, or a function * that draws it and is called again by `redraw`). Escape and a click outside close it. Returns * { redraw, close }. `align`: "start" (the anchor's left edge) or "end" (its right edge). */ @@ -211,7 +212,7 @@ export function popover(anchor, content, { up = false, align = "start", label = draw() document.body.append(el) const place = () => { - const at = anchor.getBoundingClientRect() + const at = anchor instanceof Element ? anchor.getBoundingClientRect() : { left: anchor.x, right: anchor.x, top: anchor.y, bottom: anchor.y } const r = el.getBoundingClientRect() const left = align === "end" ? at.right - r.width : at.left el.style.left = `${Math.max(8, Math.min(left, window.innerWidth - r.width - 8))}px` @@ -219,7 +220,7 @@ export function popover(anchor, content, { up = false, align = "start", label = el.style.top = `${Math.max(8, Math.min(top, window.innerHeight - r.height - 8))}px` } place() - openPop = { el, back: anchor, onClose } + openPop = { el, back: anchor instanceof Element ? anchor : document.activeElement, onClose } document.addEventListener("pointerdown", outsidePop, true) document.addEventListener("keydown", escPop, true) el.querySelector("button, input, select")?.focus() diff --git a/ui/js/scope.js b/ui/js/scope.js new file mode 100644 index 0000000..4d923f1 --- /dev/null +++ b/ui/js/scope.js @@ -0,0 +1,91 @@ +// What a chat may use: the web's chat-settings switches, from a chat's menu. The tool families +// the server offers this chat now (web search among them) and the skills, each on or off for this +// chat only, and the commands it was told to stop asking about. +// +// GET /chats/{chat}/scope -> { scope: { families: [{ gate, label, on }], skills: [{ name, description, on }], allow: [entry] } } +// POST /chats/{chat}/scope { kind: "family"|"skill", name, on } -> { scope } +// POST /chats/{chat}/allow/clear -> { scope } +// +// Narrowing only, on the server: switching on what the model, the permissions or the instance +// left out reaches nothing. Labels, skill names and descriptions are set as text. + +import { h } from "./dom.js" +import { get, post, why } from "./bridge.js" +import { popover } from "./popup.js" + +/** The switches of `chat`, in a popover under `anchor`. */ +export async function scopePopover(anchor, chat) { + const path = `/chats/${encodeURIComponent(chat.id)}` + let scope = null + let error = "" + let busy = false + const pop = popover(anchor, () => draw(), { align: "end", label: t("Tools") }) + + // Drawn again after each answer: the switch that was used keeps the focus. + const redraw = (key) => { + pop.redraw() + if (key) document.querySelector(`.scope [data-key="${CSS.escape(key)}"]`)?.focus() + } + const send = async (url, body) => { + const key = document.activeElement?.dataset?.key || "" + busy = true + redraw(key) + try { + scope = (await post(url, body))?.scope || scope + error = "" + } catch (err) { + error = why(err) + } + busy = false + redraw(key) + } + + const toggle = (kind, name, label, on) => + h( + "label", + { class: "check scope__row", title: label.title || null }, + h("input", { type: "checkbox", checked: on, disabled: busy, dataset: { key: `${kind}:${name}` }, onchange: (e) => send(`${path}/scope`, { kind, name, on: e.target.checked }) }), + h("span", { text: label.text }), + ) + + function draw() { + const body = h("div", { class: "pop__body scope" }) + if (!scope) { + body.append(error ? h("p", { class: "status is-error", role: "alert", text: error }) : h("p", { class: "muted", text: t("Loading…") })) + return body + } + const families = scope.families || [] + const skills = scope.skills || [] + const allow = scope.allow || [] + if (families.length || skills.length) body.append(h("p", { class: "muted scope__hint", text: t("Switched off here only. What is on to begin with is your default for this model, in Settings → Models.") })) + if (families.length) { + body.append( + h("p", { class: "pop__title", text: t("Tools") }), + h("div", { class: "scope__list" }, families.map((f) => toggle("family", f.gate, { text: f.label || f.gate }, !!f.on))), + ) + } + if (skills.length) { + body.append( + h("p", { class: "pop__title", text: t("Skills") }), + h("div", { class: "scope__list" }, skills.map((s) => toggle("skill", s.name, { text: s.name, title: s.description || "" }, !!s.on))), + ) + } + if (allow.length) { + body.append( + h("p", { class: "pop__title", text: t("Always allowed here") }), + h("ul", { class: "scope__allow" }, allow.map((a) => h("li", {}, h("code", { text: a })))), + h("div", { class: "pop__actions" }, h("button", { type: "button", class: "btn btn--sm", disabled: busy, text: t("Ask me about these again"), onclick: () => send(`${path}/allow/clear`, {}) })), + ) + } + if (!families.length && !skills.length && !allow.length) body.append(h("p", { class: "muted", text: t("Nothing to switch in this chat.") })) + if (error) body.append(h("p", { class: "status is-error", role: "alert", text: error })) + return body + } + + try { + scope = (await get(`${path}/scope`))?.scope || { families: [], skills: [], allow: [] } + } catch (err) { + error = why(err) + } + pop.redraw() +}