Library upload, new folder, rename and delete; a chat's tools and skills (/scope)
Files -> Server writes through /api/v2/files/{upload,mkdir,rename} and
DELETE /files (the trash); upload is a Rust command (library_upload) that
picks files in the system dialog and sends them multipart in the field
`files`. A chat's menu opens Tools and skills..., the web's chat-settings
switches over GET/POST /chats/{id}/scope and /allow/clear. A popover can
open at a context menu's point.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
13 files changed
+582
-27
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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úť.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Value>,
|
||||
sent: usize,
|
||||
failed: Vec<Uploaded>,
|
||||
}
|
||||
|
||||
/// 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<LibraryUpload, ApiError> {
|
||||
let h = handle.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let picked = h.dialog().file().set_title(title).blocking_pick_files();
|
||||
let paths: Vec<PathBuf> = picked
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter_map(|p| p.into_path().ok())
|
||||
.collect();
|
||||
let Some((app_url, token)) = h.state::<App>().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,
|
||||
|
||||
+41
-11
@@ -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<u8> {
|
||||
/// 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> {
|
||||
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<Value, ApiError> {
|
||||
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<Value, ApiError> {
|
||||
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]
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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] || ""
|
||||
|
||||
+20
@@ -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); }
|
||||
|
||||
+80
@@ -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}}",
|
||||
|
||||
@@ -109,8 +109,11 @@
|
||||
<section class="files__pane" id="pane-server" hidden aria-label="Server" data-i18n-aria-label>
|
||||
<div class="files__head">
|
||||
<div class="files__where muted" id="server-where"></div>
|
||||
<button type="button" class="tool" id="server-upload" aria-label="Upload" title="Upload" data-i18n-aria-label data-i18n-title></button>
|
||||
<button type="button" class="tool" id="server-mkdir" aria-label="New folder" title="New folder" data-i18n-aria-label data-i18n-title></button>
|
||||
<button type="button" class="tool" id="server-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
|
||||
</div>
|
||||
<p class="status" id="server-status" hidden></p>
|
||||
<div class="files__tree" id="server-tree"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -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) })
|
||||
|
||||
+126
-11
@@ -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)
|
||||
}
|
||||
+4
-3
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user