The app's own interface: agent chats first, two explorers, a viewer

Plain HTML, CSS and ES modules in ui/, no build step. The sidebar opens on agent
chats (this computer first), with chats on the server as the second tab; the
transcript draws the /api/v2 steps, follows a reply as it is written, answers
cards, and starts agent chats in one of the agent's folders. Two explorers kept
apart (this computer's, the server's) and a viewer: Markdown drawn by a safe
renderer that never parses HTML, code in read-only CodeMirror with the server's
token colours, HTML in the sealed frame, show calls opened in it. Settings for
the sign-in, the agent and its folders, notifications and updates; no admin
pages. Layout on fixed and shared tracks; the sidebar is a drawer below 48rem,
the viewer goes over the chat below 64rem. All 124 sentences in the server's
nine languages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-28 04:55:03 +00:00
1 parent 96a2abe7e0
commit 4b4e63f622
20 files changed
+4272 -755

No files matched your search

+732 -270
View File
File diff suppressed because it is too large. Load diff
+614 -99
View File
@@ -1,24 +1,197 @@
/* The first-run page. Every colour, size and space is a token from tokens.css (the server's, /* LLeMbas Desktop's page. Every colour, size, space and font is a token from tokens.css (the
fetched by scripts/fetch-tokens.sh); the one value of this page's own is the card's width. */ server's, copied by scripts/fetch-tokens.sh and pinned); the only values of this page's own are
the title bar's height and the setup card's width, named below. Media queries cannot read a
custom property, so the two breakpoints are written out and named after the tokens they are:
48rem is --bp-narrow (the sidebar becomes a drawer), 64rem is --bp-wide (the viewer goes over
the chat instead of beside it).
Rows line up by construction: every row of controls sits on a grid with known tracks, a field
is always three parts (label, control, hint) on shared rows, and nothing is aligned by
`baseline` across text of unknown length. */
* { box-sizing: border-box; } * { box-sizing: border-box; }
:root { :root {
/* The card's widest: a layout fact of this page, not a token the server has. */ /* The title bar's height: a fact of this page, not a token the server has. */
--titlebar-h: 36px;
/* The setup card's widest. */
--card-max: 40rem; --card-max: 40rem;
/* The sidebar toggle's track in the title bar: none until the sidebar is a drawer. */
--side-btn-w: 0px;
} }
html, body { margin: 0; min-height: 100%; } html, body { margin: 0; height: 100%; overflow: hidden; }
body { body {
display: grid;
place-items: center;
min-height: 100vh;
padding: var(--sp-4);
background: var(--bg); background: var(--bg);
color: var(--ink); color: var(--ink);
font: var(--text-base) / var(--leading-normal) var(--font-body); font: var(--text-base) / var(--leading-normal) var(--font-body);
} }
[hidden] { display: none !important; }
.app { display: grid; grid-template-rows: var(--titlebar-h) minmax(0, 1fr); height: 100vh; }
h1, h2, h3, p { margin: 0; }
.muted { color: var(--ink-muted); font-size: var(--text-sm); }
.is-error { color: var(--warning); }
bdi { unicode-bidi: isolate; }
.icon { width: 16px; height: 16px; fill: currentColor; flex: none; }
/* --- Controls ------------------------------------------------------------------------------ */
.btn {
height: var(--control-h);
padding: 0 var(--sp-4);
display: inline-grid;
grid-auto-flow: column;
gap: var(--sp-2);
align-items: center;
justify-content: center;
font: 600 var(--text-sm) var(--font-ui);
color: var(--ink);
background: var(--surface);
border: var(--border-w) solid var(--border-strong);
border-radius: var(--radius);
box-shadow: var(--shadow-block);
cursor: pointer;
white-space: nowrap;
}
.btn:hover { background: var(--surface-hover); }
.btn:active { transform: translate(var(--press), var(--press)); box-shadow: none; }
.btn:disabled { opacity: var(--opacity-dim); cursor: default; }
.btn--primary { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); }
.btn--primary:hover { background: var(--accent-hover); }
.btn--sm { height: var(--control-h-sm); padding: 0 var(--sp-3); box-shadow: none; }
input[type="text"], select, textarea {
min-width: 0;
font: var(--text-sm) var(--font-ui);
color: var(--ink);
background: var(--bg);
border: var(--border-w) solid var(--border-strong);
border-radius: var(--radius);
}
input[type="text"], select { height: var(--control-h); padding: 0 var(--control-px-sm); }
textarea { padding: var(--sp-2) var(--control-px-sm); resize: none; line-height: var(--leading-normal); }
:focus-visible { outline: var(--outline-w) solid var(--accent); outline-offset: var(--outline-offset); }
label, .label { font: var(--text-sm) / var(--leading-tight) var(--font-ui); color: var(--ink-muted); }
.hint { font-size: var(--text-sm); color: var(--ink-muted); min-height: calc(var(--text-sm) * var(--leading-normal)); }
.status { font: var(--text-sm) / var(--leading-normal) var(--font-ui); }
.status.is-ok { color: var(--leaf); }
.check { display: grid; grid-template-columns: var(--check-size) minmax(0, 1fr); gap: var(--sp-2); align-items: center; color: var(--ink); }
.check input { width: var(--check-size); height: var(--check-size); margin: 0; accent-color: var(--accent); }
.chip {
display: inline-block;
padding: 0 var(--sp-2);
font: var(--text-xs) / 1.6 var(--font-ui);
color: var(--ink-muted);
background: var(--surface);
border: var(--border-w) solid var(--border);
border-radius: var(--radius);
white-space: nowrap;
}
.chip--leaf { color: var(--leaf); border-color: var(--leaf); }
.dot { width: var(--dot-size); height: var(--dot-size); background: var(--ink-faint); flex: none; }
.dot.is-ok { background: var(--leaf); }
.dot.is-busy { background: var(--accent); }
.dot.is-new { background: var(--leaf); }
.dot.is-error { background: var(--warning); }
/* A field is three stacked parts on fixed rows: label, control, hint -- always all three. */
.field { display: grid; grid-template-rows: auto var(--control-h) auto; gap: var(--sp-2); align-content: start; }
.fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
grid-auto-rows: auto var(--control-h) auto;
column-gap: var(--sp-4);
row-gap: var(--sp-2);
}
/* Each field spans three shared rows, so side-by-side controls sit on one line whatever their
labels and hints do. */
.fields .field { grid-row: span 3; grid-template-rows: subgrid; }
.row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--sp-2); }
.row--buttons { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr)); gap: var(--sp-2); }
/* A section's heading in brackets, as on the server's screens: `[ SERVER ]`. */
.card h2, .set h2 {
margin: 0 0 var(--sp-3);
font: 600 var(--text-sm) / var(--leading-tight) var(--font-ui);
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--accent);
}
.card h2::before, .set h2::before { content: "[ "; }
.card h2::after, .set h2::after { content: " ]"; }
.code {
font: 700 var(--text-xl) / var(--leading-tight) var(--font-mono);
letter-spacing: 0.15em;
color: var(--leaf);
margin: var(--sp-2) 0 var(--sp-3);
}
/* --- The title bar ------------------------------------------------------------------------- */
.bar {
display: grid;
/* side · leaf · name · host (the rest, and the drag area) · update · agent · menu · sep · 3 controls */
grid-template-columns: var(--side-btn-w) var(--titlebar-h) auto minmax(0, 1fr) auto auto var(--titlebar-h) var(--border-w) repeat(3, 2.875rem);
align-items: center;
height: var(--titlebar-h);
border-bottom: var(--border-w) solid var(--border);
background: var(--bg);
font: 600 var(--text-sm) / 1 var(--font-ui);
user-select: none;
-webkit-user-select: none;
cursor: default;
}
.bar button {
height: var(--titlebar-h);
margin: 0;
padding: 0;
font: inherit;
color: var(--ink-muted);
background: transparent;
border: 0;
border-radius: 0;
cursor: pointer;
}
.bar button:hover { color: var(--ink); background: var(--surface-hover); }
.bar button:focus-visible { outline-offset: calc(-1 * var(--outline-w)); }
.bar__leaf { justify-self: center; width: 18px; height: 18px; }
.bar__name b { color: var(--leaf); font-weight: 700; }
.bar__host {
padding: 0 var(--sp-3);
color: var(--ink-muted);
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
align-self: stretch;
display: flex;
align-items: center;
}
.bar__btn { display: grid; place-items: center; }
.bar__side { overflow: hidden; visibility: hidden; }
.bar__agent { display: grid; grid-template-columns: var(--dot-size) auto; gap: var(--sp-2); align-items: center; padding: 0 var(--sp-3) !important; font-weight: 400 !important; }
.bar__agent[data-state="running"] .dot, .bar__agent[data-state="elsewhere"] .dot { background: var(--leaf); }
.bar__agent[data-state="starting"] .dot { background: var(--accent); }
.bar__agent[data-state="failed"] .dot, .bar__agent[data-state="not_linked"] .dot, .bar__agent[data-state="no_folders"] .dot { background: var(--warning); }
.bar__update { padding: 0 var(--sp-3) !important; color: var(--accent-ink) !important; background: var(--accent) !important; }
.bar__update:hover { background: var(--accent-hover) !important; }
/* Hidden keeps its grid track (auto, so zero wide): the slots after it never move. */
.bar__update[hidden] { display: block !important; width: 0; padding: 0 !important; overflow: hidden; visibility: hidden; }
.bar__sep { width: var(--border-w); height: var(--sp-5); background: var(--border); }
.bar__btn--close:hover { color: var(--ink-inverse) !important; background: var(--danger) !important; }
/* --- Setup --------------------------------------------------------------------------------- */
.setup { overflow: auto; display: grid; place-items: center; padding: var(--sp-4); }
.card { .card {
width: min(100%, var(--card-max)); width: min(100%, var(--card-max));
display: grid; display: grid;
@@ -29,77 +202,370 @@ body {
border-radius: var(--radius); border-radius: var(--radius);
box-shadow: var(--shadow-block-lg); box-shadow: var(--shadow-block-lg);
} }
.setup__body { display: grid; gap: var(--sp-6); }
.brand { display: grid; grid-template-columns: var(--sp-10) 1fr; gap: var(--sp-3); align-items: center; } .setup__body section { display: grid; gap: var(--sp-3); }
.brand { display: grid; grid-template-columns: var(--sp-10) minmax(0, 1fr); gap: var(--sp-3); align-items: center; }
.brand img { width: var(--sp-10); height: var(--sp-10); } .brand img { width: var(--sp-10); height: var(--sp-10); }
h1 { margin: 0; font: 600 var(--text-xl) / var(--leading-tight) var(--font-ui); } .brand h1 { font: 600 var(--text-xl) / var(--leading-tight) var(--font-ui); }
h1 b { color: var(--leaf); font-weight: 700; } .brand h1 b { color: var(--leaf); font-weight: 700; }
/* A section's heading, in brackets as on the server's screens: `[ SERVER ]`. The brackets are
drawn here so the words alone are what gets translated. */ /* --- The shell ----------------------------------------------------------------------------- */
h2 {
margin: 0 0 var(--sp-3); .shell {
font: 600 var(--text-sm) / var(--leading-tight) var(--font-ui); position: relative;
letter-spacing: 0.08em; min-height: 0;
text-transform: uppercase; display: grid;
color: var(--accent); grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
grid-template-areas: "side main";
}
.shell[data-viewer="true"] {
grid-template-columns: var(--sidebar-width) minmax(0, 1fr) minmax(var(--inspector-width-min), 42%);
grid-template-areas: "side main viewer";
} }
h2::before { content: "[ "; }
h2::after { content: " ]"; }
p { margin: 0; }
.muted { color: var(--ink-muted); font-size: var(--text-sm); }
/* A field is three stacked parts on fixed rows: label, control row, hint. */ /* The sidebar: the switch, the new-chat button, the chats, then the two explorers (each a row of
.field { display: grid; grid-template-rows: auto var(--control-h) auto; gap: var(--sp-2); } its own that takes room only while open), then settings. */
label, .label { font: var(--text-sm) / 1.4 var(--font-ui); color: var(--ink-muted); } .side {
.hint { font-size: var(--text-sm); color: var(--ink-muted); min-height: 1.4em; } grid-area: side;
.hint.is-error, .status.is-error { color: var(--warning); } min-height: 0;
display: grid;
--local-row: auto;
--server-row: auto;
grid-template-rows: auto auto minmax(calc(3 * var(--row-h)), 2fr) var(--local-row) var(--server-row) auto;
border-right: var(--border-w) solid var(--border);
background: var(--bg-sunken);
}
.side[data-local="true"] { --local-row: minmax(calc(4 * var(--row-h)), 1.5fr); }
.side[data-server="true"] { --server-row: minmax(calc(4 * var(--row-h)), 1.5fr); }
.row { display: grid; grid-template-columns: 1fr auto; gap: var(--sp-2); } .switch { display: grid; grid-template-columns: 1fr 1fr; border-bottom: var(--border-w) solid var(--border); }
.row--buttons { grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr)); margin-top: var(--sp-3); } .switch button {
input {
height: var(--control-h); height: var(--control-h);
min-width: 0; font: 600 var(--text-sm) var(--font-ui);
padding: 0 var(--control-px); color: var(--ink-muted);
font: var(--text-base) var(--font-ui); background: transparent;
border: 0;
border-bottom: var(--border-w-accent) solid transparent;
border-radius: var(--radius-none);
cursor: pointer;
}
.switch button[aria-selected="true"] { color: var(--ink); border-bottom-color: var(--accent); background: var(--surface); }
.side__new { margin: var(--sp-3); }
.list { list-style: none; margin: 0; padding: 0 0 var(--sp-2); overflow: auto; min-height: 0; }
.list__head { padding: var(--sp-2) var(--sp-3) var(--sp-1); font: 600 var(--text-xs) var(--font-ui); text-transform: uppercase; letter-spacing: 0.08em; color: var(--ink-faint); }
.list__note { padding: var(--sp-3); font-size: var(--text-sm); color: var(--ink-muted); }
.list__more { margin: var(--sp-2) var(--sp-3); }
/* A chat's row: dot · title · when, and under them its folder -- fixed tracks, so rows with long
titles or no folder keep their dots and times on the same lines. */
.chatrow {
width: 100%;
display: grid;
grid-template-columns: var(--dot-size) minmax(0, 1fr) auto;
grid-template-rows: auto auto;
column-gap: var(--sp-2);
align-items: center;
min-height: var(--row-h);
padding: var(--sp-2) var(--sp-3);
text-align: left;
font: var(--text-sm) / var(--leading-tight) var(--font-ui);
color: var(--ink);
background: transparent;
border: 0;
border-left: var(--border-w-accent) solid transparent;
cursor: pointer;
}
.chatrow:hover { background: var(--surface-hover); }
.chatrow[aria-current="true"] { background: var(--surface-active); border-left-color: var(--accent); }
.chatrow.is-unread .row__title { font-weight: 700; }
.row__title, .row__sub { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.row__when { font-size: var(--text-xs); color: var(--ink-faint); }
.row__sub { grid-column: 2 / 4; font-size: var(--text-xs); color: var(--ink-muted); direction: rtl; text-align: left; }
/* The explorers. */
.pane { min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); border-top: var(--border-w) solid var(--border); }
.pane[data-open="false"] .pane__body { display: none; }
.pane__head { display: grid; grid-template-columns: minmax(0, 1fr) var(--control-h-sm); align-items: center; }
.pane__toggle {
height: var(--control-h);
display: grid;
grid-template-columns: 1rem minmax(0, 1fr);
gap: var(--sp-1);
align-items: center;
padding: 0 var(--sp-3);
text-align: left;
font: 600 var(--text-xs) var(--font-ui);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-muted);
background: transparent;
border: 0;
cursor: pointer;
}
.pane__toggle[aria-expanded="true"] .tree__chevron .icon { transform: rotate(90deg); }
.pane__btn {
width: var(--control-h-sm);
height: var(--control-h-sm);
display: grid;
place-items: center;
padding: 0;
color: var(--ink-muted);
background: transparent;
border: 0;
border-radius: var(--radius);
cursor: pointer;
}
.pane__btn:hover { color: var(--ink); background: var(--surface-hover); }
.pane__body { min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr); }
.pane__where { padding: 0 var(--sp-3) var(--sp-2); }
.pane__where:empty { display: none; }
.pane__pick { width: 100%; height: var(--control-h-sm); }
.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); }
.pane__tree { overflow: auto; min-height: 0; padding-bottom: var(--sp-2); }
.pane__note { padding: var(--sp-2) var(--sp-3); font-size: var(--text-sm); color: var(--ink-muted); }
.pane__tree > .btn { margin: 0 var(--sp-3); }
.tree, .tree ul { list-style: none; margin: 0; padding: 0; }
.tree__row {
width: 100%;
height: var(--row-h);
display: grid;
grid-template-columns: calc(var(--depth, 0) * var(--sp-3)) 1rem 1rem minmax(0, 1fr) auto;
gap: var(--sp-1);
align-items: center;
padding: 0 var(--sp-3);
text-align: left;
font: var(--text-xs) var(--font-ui);
color: var(--ink);
background: transparent;
border: 0;
cursor: pointer;
}
/* The indent's track needs an item of its own, or the chevron would sit in it. */
.tree__row::before { content: ""; }
.tree__row:hover { background: var(--surface-hover); }
.tree__row .icon { color: var(--ink-muted); }
.tree__chevron { display: grid; place-items: center; width: 1rem; }
.tree__chevron .icon { width: 12px; height: 12px; transition: transform var(--dur-1) var(--ease-out); }
.tree__row[aria-expanded="true"] .tree__chevron .icon { transform: rotate(90deg); }
.tree__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.tree__size { color: var(--ink-faint); }
.tree__note { padding: var(--sp-1) var(--sp-3); font-size: var(--text-xs); color: var(--ink-muted); }
.side__settings {
display: grid;
grid-template-columns: 16px minmax(0, 1fr);
gap: var(--sp-2);
align-items: center;
height: var(--control-h);
padding: 0 var(--sp-3);
text-align: left;
font: var(--text-sm) var(--font-ui);
color: var(--ink-muted);
background: transparent;
border: 0;
border-top: var(--border-w) solid var(--border);
cursor: pointer;
}
.side__settings:hover { color: var(--ink); background: var(--surface-hover); }
.scrim { display: none; }
/* --- The chat ------------------------------------------------------------------------------ */
.main, .settings { grid-area: main; min-width: 0; min-height: 0; display: grid; grid-template-rows: auto minmax(0, 1fr) auto auto; }
.settings { grid-template-rows: auto minmax(0, 1fr); }
.head {
display: grid;
grid-template-columns: minmax(0, auto) minmax(0, 1fr) auto;
gap: var(--sp-3);
align-items: center;
min-height: var(--header-height);
padding: 0 var(--sp-4);
border-bottom: var(--border-w) solid var(--border);
}
.head__title { font: 600 var(--text-md) / var(--leading-tight) var(--font-ui); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.head__place { display: flex; gap: var(--sp-2); align-items: center; min-width: 0; }
.head__dir { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; direction: rtl; text-align: left; font: var(--text-xs) var(--font-ui); color: var(--ink-muted); min-width: 0; }
.head__tools { display: flex; gap: var(--sp-2); align-items: center; }
.head__mode { height: var(--control-h-sm); }
.thread { overflow: auto; min-height: 0; padding: var(--sp-4); scroll-behavior: auto; }
.thread > * { max-width: var(--thread-max-width); margin-left: auto; margin-right: auto; }
.thread__note { padding: var(--sp-2) 0; font-size: var(--text-sm); }
.thread__loading { padding: var(--sp-4) 0; }
.thread__earlier { display: block; margin: 0 auto var(--sp-4); }
.messages { display: grid; gap: var(--sp-5); }
.new { display: grid; gap: var(--sp-4); padding: var(--sp-4) 0; }
.new__warn { color: var(--warning); font-size: var(--text-sm); }
.new select { width: 100%; }
.msg { display: grid; gap: var(--sp-3); min-width: 0; }
.msg--user {
justify-self: end;
max-width: min(100%, var(--w-prose));
padding: var(--sp-3) var(--sp-4);
background: var(--bubble-user);
border: var(--border-w) solid var(--border);
border-radius: var(--radius-lg);
}
.msg--user.is-queued { opacity: var(--opacity-dim); }
.msg--user.is-machine { justify-self: stretch; max-width: none; font-size: var(--text-sm); color: var(--ink-muted); background: transparent; }
.msg__text { white-space: pre-wrap; overflow-wrap: anywhere; }
.msg__files { margin: 0; padding: 0; list-style: none; display: flex; flex-wrap: wrap; gap: var(--sp-1); font-size: var(--text-xs); }
.msg__note { font-size: var(--text-sm); color: var(--ink-muted); }
.msg__foot { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center; }
.msg__metrics { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
.msg__steps { display: grid; gap: var(--sp-3); min-width: 0; }
.msg__status, .msg__thinking { font: var(--text-xs) var(--font-ui); }
.msg__status.is-error { color: var(--warning); }
.msg__jobs { margin: 0; padding: 0; list-style: none; display: grid; gap: var(--sp-1); font-size: var(--text-sm); }
.job { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--sp-2); align-items: center; }
.step--thinking summary, .call__head { cursor: pointer; font: var(--text-sm) var(--font-ui); color: var(--ink-muted); }
.step__pre { margin-top: var(--sp-2); padding-left: var(--sp-3); border-left: var(--border-w-thick) solid var(--border); white-space: pre-wrap; font-size: var(--text-sm); color: var(--ink-muted); }
.step--tools { display: grid; gap: var(--sp-1); }
.step--steer { padding-left: var(--sp-3); border-left: var(--border-w-thick) solid var(--accent-soft); }
.steer__text { white-space: pre-wrap; font-size: var(--text-sm); }
.call { border: var(--border-w) solid var(--border); border-radius: var(--radius); background: var(--surface); min-width: 0; }
.call.is-failed { border-color: var(--warning); }
/* The call's line: dot · title · chips, on fixed tracks. */
.call__head {
list-style: none;
display: grid;
grid-template-columns: var(--dot-size) minmax(0, 1fr) auto;
gap: var(--sp-2);
align-items: center;
min-height: var(--row-h);
padding: 0 var(--sp-3);
}
.call__head::-webkit-details-marker { display: none; }
.call__title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; color: var(--ink); font-family: var(--font-mono); }
.call__chips { display: flex; gap: var(--sp-1); }
.call__body { display: grid; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3) var(--sp-3); border-top: var(--border-w) solid var(--border); }
.call__pre, .md-code pre {
margin: 0;
padding: var(--sp-2) var(--sp-3);
max-height: var(--h-list);
overflow: auto;
font: var(--text-xs) / var(--leading-normal) var(--font-mono);
background: var(--code-bg);
border: var(--border-w) solid var(--code-border);
border-radius: var(--radius);
white-space: pre;
}
.call__open { justify-self: start; margin: 0 var(--sp-3) var(--sp-3); }
/* Markdown, drawn by markdown.js from model output (never as HTML). */
.md { overflow-wrap: anywhere; min-width: 0; }
.md > * + * { margin-top: var(--sp-3); }
.md h1, .md h2, .md h3, .md h4, .md h5, .md h6 { font-family: var(--font-ui); line-height: var(--leading-tight); }
.md h1 { font-size: var(--text-xl); }
.md h2 { font-size: var(--text-lg); }
.md h3 { font-size: var(--text-md); }
.md h4, .md h5, .md h6 { font-size: var(--text-base); }
.md ul, .md ol { margin: 0; padding-left: var(--sp-6); }
.md li + li { margin-top: var(--sp-1); }
.md-task { list-style: none; margin-left: calc(-1 * var(--sp-5)); }
.md-task input { margin: 0 var(--sp-2) 0 0; }
.md blockquote { margin: 0; padding-left: var(--sp-3); border-left: var(--border-w-thick) solid var(--border-strong); color: var(--ink-muted); }
.md hr { border: 0; border-top: var(--border-w) solid var(--border); }
.md code { font: 0.9em var(--font-mono); padding: 0 var(--sp-1); background: var(--code-bg); border-radius: var(--radius-sm); }
.md pre code { padding: 0; background: none; }
.md a { color: var(--accent); }
.md a.md-unsafe { color: var(--ink-muted); text-decoration: line-through dotted; }
.md-code { position: relative; display: grid; }
.md-lang { position: absolute; top: var(--sp-1); left: var(--sp-3); font: var(--text-xs) var(--font-ui); color: var(--ink-faint); }
.md-code:has(.md-lang) pre { padding-top: var(--sp-6); }
.md-copy {
position: absolute;
top: var(--sp-1);
right: var(--sp-1);
height: var(--control-h-sm);
padding: 0 var(--sp-2);
font: var(--text-xs) var(--font-ui);
color: var(--ink-muted);
background: var(--surface);
border: var(--border-w) solid var(--border);
border-radius: var(--radius);
cursor: pointer;
}
/* A wide table scrolls by itself; the page does not. */
.md-table { overflow-x: auto; }
.md table { border-collapse: collapse; font-size: var(--text-sm); }
.md th, .md td { padding: var(--sp-1) var(--sp-3); border: var(--border-w) solid var(--border); text-align: left; vertical-align: top; }
.md [data-align="center"] { text-align: center; }
.md [data-align="right"] { text-align: right; }
.md th { background: var(--surface); }
/* Cards: what waits for an answer, above the composer. */
.cards { overflow: auto; max-height: 50vh; padding: var(--sp-3) var(--sp-4) 0; display: grid; gap: var(--sp-3); }
.ask {
width: 100%;
max-width: var(--thread-max-width);
margin: 0 auto;
display: grid;
gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
background: var(--surface-raised);
border: var(--border-w-accent) solid var(--accent);
border-radius: var(--radius);
box-shadow: var(--shadow-block-accent);
}
.ask__item { display: grid; gap: var(--sp-2); }
.ask__header { font: 600 var(--text-xs) var(--font-ui); text-transform: uppercase; letter-spacing: 0.08em; color: var(--accent); }
.ask__title { font-weight: 600; }
.ask__edit { width: 100%; font-family: var(--font-mono); }
.ask__text { width: 100%; }
.ask__options { display: grid; gap: var(--sp-2); }
.opt {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-rows: auto auto;
column-gap: var(--sp-2);
align-items: center;
min-height: var(--control-h);
padding: var(--sp-2) var(--sp-3);
text-align: left;
font: var(--text-sm) var(--font-body);
color: var(--ink); color: var(--ink);
background: var(--bg); background: var(--bg);
border: var(--border-w) solid var(--border-strong); border: var(--border-w) solid var(--border-strong);
border-radius: var(--radius); border-radius: var(--radius);
}
input:focus-visible, .btn:focus-visible {
outline: var(--outline-w) solid var(--accent);
outline-offset: var(--outline-w);
}
.btn {
height: var(--control-h);
padding: 0 var(--sp-4);
font: 600 var(--text-sm) var(--font-ui);
color: var(--ink);
background: var(--surface);
border: var(--border-w) solid var(--border-strong);
border-radius: var(--radius);
box-shadow: var(--shadow-block);
cursor: pointer; cursor: pointer;
} }
.btn:hover { background: var(--surface-hover); } .opt[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
.btn:active { transform: translate(var(--press), var(--press)); box-shadow: none; } .opt__desc { grid-column: 1; font-size: var(--text-xs); color: var(--ink-muted); }
.btn:disabled { opacity: 0.55; cursor: default; } .opt .chip { grid-column: 2; grid-row: 1; }
.btn--primary { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); } .ask__actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 8rem), 1fr)); gap: var(--sp-2); }
.btn--primary:hover { background: var(--accent-hover); } .ask__said { font-size: var(--text-sm); color: var(--warning); }
.ask__said:empty { display: none; }
.plan { margin: 0; padding-left: var(--sp-5); display: grid; gap: var(--sp-2); font-size: var(--text-sm); }
.plan ul { margin: var(--sp-1) 0 0; padding-left: var(--sp-4); }
.plan__phase { font-weight: 600; }
.plan__task.is-done { text-decoration: line-through; color: var(--ink-muted); }
.status { font: var(--text-sm) / 1.4 var(--font-ui); margin-top: var(--sp-3); } /* The composer: the text and its buttons, on two tracks; the buttons hold their own row. */
.status.is-ok { color: var(--leaf); } .composer {
display: grid;
@media (max-width: 30rem) { grid-template-columns: minmax(0, 1fr) auto;
.card { padding: var(--sp-4); gap: var(--sp-4); } gap: var(--sp-2);
.row { grid-template-columns: 1fr; grid-template-rows: var(--control-h) var(--control-h); } align-items: end;
.field { grid-template-rows: auto auto auto; } padding: var(--sp-3) var(--sp-4) var(--sp-4);
} }
.composer > * { min-width: 0; }
.composer textarea { width: 100%; min-height: var(--control-h); max-height: 40vh; font: var(--text-base) / var(--leading-normal) var(--font-body); }
.composer__actions { display: grid; grid-auto-flow: column; gap: var(--sp-2); }
/* The folder list: every row the same two tracks, the path cut from the left (its end is the /* --- Settings ------------------------------------------------------------------------------ */
part that tells folders apart), the full path in the title. */
.settings__body { overflow: auto; padding: var(--sp-4); display: grid; gap: var(--sp-6); align-content: start; }
.settings__body > .set { width: 100%; max-width: var(--w-prose); margin: 0 auto; display: grid; gap: var(--sp-3); }
/* The folder list: every row the same two tracks, the path cut from the left (its end tells
folders apart), the full path in the title. */
.folders { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-2); } .folders { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--sp-2); }
.folders li { .folders li {
display: grid; display: grid;
@@ -112,42 +578,91 @@ input:focus-visible, .btn:focus-visible {
border: var(--border-w) solid var(--border); border: var(--border-w) solid var(--border);
border-radius: var(--radius); border-radius: var(--radius);
} }
.folders .path { .folders .path { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; direction: rtl; text-align: left; font: var(--text-sm) var(--font-ui); }
overflow: hidden; .folders .btn { height: calc(var(--control-h) - 2 * var(--border-w)); box-shadow: none; border-width: 0 0 0 var(--border-w); }
white-space: nowrap;
text-overflow: ellipsis;
direction: rtl;
text-align: left;
font: var(--text-sm) var(--font-ui);
}
.folders .path bdi { direction: ltr; unicode-bidi: isolate; }
.folders .btn {
height: calc(var(--control-h) - 2 * var(--border-w));
box-shadow: none;
border-width: 0 0 0 var(--border-w);
}
.folders .empty { padding: var(--control-px); color: var(--warning); font-size: var(--text-sm); } .folders .empty { padding: var(--control-px); color: var(--warning); font-size: var(--text-sm); }
.settings-grid { /* --- The viewer ---------------------------------------------------------------------------- */
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr)); .viewer {
grid-auto-rows: auto var(--control-h) auto; grid-area: viewer;
column-gap: var(--sp-4);
row-gap: var(--sp-2);
margin-top: var(--sp-4);
}
/* Each field spans three shared rows (label, control, hint), so the controls of side-by-side
fields sit on one line whatever their labels and hints do. */
.settings-grid .field { grid-row: span 3; grid-template-rows: subgrid; }
select {
height: var(--control-h);
min-width: 0; min-width: 0;
padding: 0 var(--control-px-sm); min-height: 0;
font: var(--text-sm) var(--font-ui); display: grid;
color: var(--ink); grid-template-rows: auto minmax(0, 1fr);
border-left: var(--border-w) solid var(--border);
background: var(--bg); background: var(--bg);
border: var(--border-w) solid var(--border-strong);
border-radius: var(--radius);
} }
.check { display: flex; align-items: center; gap: var(--sp-2); color: var(--ink); } .viewer__head {
.check input { width: var(--check-size); height: var(--check-size); margin: 0; accent-color: var(--accent); } display: grid;
grid-template-columns: minmax(0, 1fr) auto var(--control-h-sm);
gap: var(--sp-2);
align-items: center;
min-height: var(--header-height);
padding: 0 var(--sp-3);
border-bottom: var(--border-w) solid var(--border);
}
.viewer__id { min-width: 0; }
.viewer__name { font: 600 var(--text-sm) / var(--leading-tight) var(--font-ui); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.viewer__where { font: var(--text-xs) var(--font-ui); color: var(--ink-muted); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.viewer__modes { display: grid; grid-auto-flow: column; }
.seg {
height: var(--control-h-sm);
padding: 0 var(--sp-3);
font: var(--text-xs) var(--font-ui);
color: var(--ink-muted);
background: var(--surface);
border: var(--border-w) solid var(--border);
cursor: pointer;
}
.seg + .seg { border-left: 0; }
.seg[aria-pressed="true"] { color: var(--ink); background: var(--surface-active); }
.viewer__body { min-height: 0; display: grid; }
.viewer__code { min-height: 0; overflow: hidden; }
.viewer__code .cm-editor { height: 100%; }
.viewer__md { overflow: auto; padding: var(--sp-4); }
.viewer__frame { width: 100%; height: 100%; border: 0; background: var(--surface); }
.viewer__note { padding: var(--sp-4); font-size: var(--text-sm); color: var(--ink-muted); }
.viewer__note.is-error { color: var(--warning); }
/* --- Narrower windows ---------------------------------------------------------------------- */
/* 64rem (--bp-wide): the viewer goes over the chat instead of beside it. */
@media (max-width: 64rem) {
.shell[data-viewer="true"] { grid-template-columns: var(--sidebar-width) minmax(0, 1fr); grid-template-areas: "side main"; }
.viewer { grid-area: main; z-index: var(--z-panel); border-left: 0; }
}
/* 48rem (--bp-narrow): the sidebar is a drawer, opened from the title bar. */
@media (max-width: 48rem) {
:root { --side-btn-w: var(--titlebar-h); }
.bar__side { visibility: visible; }
.bar__host { display: none; }
.shell, .shell[data-viewer="true"] { grid-template-columns: minmax(0, 1fr); grid-template-areas: "main"; }
.side {
position: absolute;
inset: 0 auto 0 0;
width: min(85vw, var(--sidebar-width));
z-index: var(--z-panel);
transform: translateX(-100%);
transition: transform var(--dur-2) var(--ease-out);
box-shadow: var(--shadow-lg);
}
.shell[data-side="true"] .side { transform: none; }
.shell[data-side="true"] .scrim { display: block; position: absolute; inset: 0; z-index: calc(var(--z-panel) - 1); background: var(--scrim); }
.viewer { z-index: var(--z-panel); }
.head { padding: 0 var(--sp-3); grid-template-columns: minmax(0, 1fr) auto; }
.head__place { grid-column: 1 / -1; grid-row: 2; padding-bottom: var(--sp-2); }
.thread { padding: var(--sp-3); }
.composer { padding: var(--sp-2) var(--sp-3) var(--sp-3); }
.cards { padding: var(--sp-2) var(--sp-3) 0; }
.card { padding: var(--sp-4); gap: var(--sp-4); }
.msg--user { max-width: 100%; }
}
/* Very narrow: the agent's words go, its dot stays; the row's button goes under its field. */
@media (max-width: 30rem) {
#agent-text { display: none; }
.setup .row { grid-template-columns: minmax(0, 1fr); }
.setup .field { grid-template-rows: auto auto auto; }
}
-125
View File
@@ -1,125 +0,0 @@
// The first-run page. It talks to the app over Tauri's IPC — the commands in src-tauri/src/lib.rs.
// The server's own pages never get this: they are remote content with no capability.
// Every word shown goes through `t` (i18n.js, loaded before this), in the system's language.
"use strict"
const { invoke } = window.__TAURI__.core
const { listen } = window.__TAURI__.event
const $ = (id) => document.getElementById(id)
const AGENT = {
off: () => [t("The agent is off."), ""],
not_linked: () => [t("This computer is not linked to the server yet."), ""],
starting: () => [t("The agent is starting…"), ""],
running: () => [t("The agent is running: agent chats can use this computer."), "is-ok"],
elsewhere: (pid) => [t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }), "is-ok"],
failed: (why) => [t("The agent stopped: %(why)s. Trying again shortly.", { why }), "is-error"],
}
function render(s) {
window.lembasTheme.apply(s.theme)
$("version").textContent = s.version
if (s.server && !$("address").value) $("address").value = s.server
$("agent-section").hidden = !s.server
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
const [text, cls] =
s.agent.state === "running" && noFolders
? [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
: (AGENT[s.agent.state] ?? AGENT.off)(s.agent.detail)
const status = $("agent-status")
status.textContent = text
status.className = `status ${cls}`
$("link").textContent = s.linked ? t("Link again") : t("Link this computer")
$("toggle").textContent = s.agent_wanted ? t("Stop the agent") : t("Start the agent")
$("toggle").disabled = !s.linked && !s.agent_wanted
renderFolders(s)
}
function renderFolders(s) {
const r = s.remote
$("folders-section").hidden = !s.server || !s.linked || !r
if (!r) return
const list = $("folders")
list.replaceChildren()
if (!r.enabled || !r.roots.length) {
const li = document.createElement("li")
li.className = "empty"
li.textContent = t("No folder yet: the agent can open nothing. Add the folder of a project.")
list.append(li)
}
for (const dir of r.enabled ? r.roots : []) {
const li = document.createElement("li")
const path = document.createElement("span")
path.className = "path"
path.title = dir
const inner = document.createElement("bdi")
inner.textContent = dir
path.append(inner)
const remove = document.createElement("button")
remove.type = "button"
remove.className = "btn"
remove.textContent = t("Remove")
remove.setAttribute("aria-label", t("Remove %(dir)s", { dir }))
remove.addEventListener("click", () => invoke("remote_remove", { dir }))
li.append(path, remove)
list.append(li)
}
if (r.max_mode) $("max-mode").value = r.max_mode
$("terminal").checked = !!r.terminal
$("folders-status").className = "status"
$("folders-status").textContent = ""
}
async function refresh() {
render(await invoke("get_status"))
}
$("server-form").addEventListener("submit", async (e) => {
e.preventDefault()
const hint = $("server-hint")
const button = e.target.querySelector("button")
button.disabled = true
hint.className = "hint"
hint.textContent = t("Looking for a LLeMbas server there…")
try {
await invoke("set_server", { address: $("address").value })
// The window now shows the server.
} catch (err) {
hint.className = "hint is-error"
hint.textContent = String(err)
} finally {
button.disabled = false
}
})
$("link").addEventListener("click", async () => {
try {
await invoke("link_computer")
} catch (err) {
$("agent-status").className = "status is-error"
$("agent-status").textContent = String(err)
}
})
$("toggle").addEventListener("click", async () => {
const s = await invoke("get_status")
await invoke("set_agent", { on: !s.agent_wanted })
})
$("open").addEventListener("click", async () => {
const s = await invoke("get_status")
if (s.server) await invoke("set_server", { address: s.server })
})
$("add-folder").addEventListener("click", () => invoke("remote_add"))
$("max-mode").addEventListener("change", (e) => invoke("remote_mode", { mode: e.target.value }))
$("terminal").addEventListener("change", (e) => invoke("remote_terminal", { on: e.target.checked }))
listen("remote-error", (e) => {
$("folders-status").className = "status is-error"
$("folders-status").textContent = String(e.payload)
})
listen("status", (e) => render(e.payload))
refresh()
invoke("remote_refresh")
+608 -24
View File
@@ -11,48 +11,121 @@
const LEMBAS_I18N = { const LEMBAS_I18N = {
"cs": { "cs": {
"%(n)s tokens": "tokeny: %(n)s",
"%(name)s, LLeMbas %(version)s.": "%(name)s, LLeMbas %(version)s.",
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Služba LLeMbas CLI už tu běží (pid %(pid)s); je agentem tohoto počítače.", "A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Služba LLeMbas CLI už tu běží (pid %(pid)s); je agentem tohoto počítače.",
"A chat asking for more is lowered to this.": "Konverzace, která chce víc, je snížena na tohle.", "A chat asking for more is lowered to this.": "Konverzace, která chce víc, je snížena na tohle.",
"A chat asking for more than the folder allows is lowered to it.": "Konverzace, která chce víc, než složka dovoluje, je snížena na to.",
"A shell as you, in one of these folders.": "Shell pod vaším účtem, v jedné z těchto složek.", "A shell as you, in one of these folders.": "Shell pod vaším účtem, v jedné z těchto složek.",
"About": "O aplikaci",
"Add folder…": "Přidat složku…", "Add folder…": "Přidat složku…",
"Address of your LLeMbas server": "Adresa vašeho serveru LLeMbas", "Address of your LLeMbas server": "Adresa vašeho serveru LLeMbas",
"Agent": "Agent", "Agent": "Agent",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.": "Agentní konverzace z vašeho serveru mohou pracovat v těchto složkách a ve všem uvnitř nich — nikde jinde na tomto počítači. Spusťte ji na serveru na kartě Agenti, na zařízení „Místní“.", "Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Agentní konverzace mohou pracovat v těchto složkách a ve všem uvnitř nich — nikde jinde na tomto počítači.",
"Agent stopped: %(why)s": "Agent se zastavil: %(why)s", "Agent stopped: %(why)s": "Agent se zastavil: %(why)s",
"Allow": "Povolit",
"Always allow": "Vždy povolit",
"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í",
"Answer": "Odpovědět",
"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:",
"Auto — asks nothing": "Auto — na nic se neptá", "Auto — asks nothing": "Auto — na nic se neptá",
"Cancel": "Zrušit",
"Carry it out": "Provést to",
"Chats": "Konverzace",
"Check for updates…": "Zkontrolovat aktualizace…",
"Close": "Zavřít", "Close": "Zavřít",
"Close (LLeMbas stays in the tray)": "Zavřít (LLeMbas zůstane v oznamovací oblasti)", "Close (LLeMbas stays in the tray)": "Zavřít (LLeMbas zůstane v oznamovací oblasti)",
"Command": "Příkaz",
"Connect": "Připojit", "Connect": "Připojit",
"Copy": "Kopírovat",
"Cut short here; the whole of it is in the web interface.": "Zde zkráceno; celé je to ve webovém rozhraní.",
"Deny": "Zamítnout",
"Earlier messages": "Starší zprávy",
"Edit — asks before commands": "Edit — ptá se před příkazy", "Edit — asks before commands": "Edit — ptá se před příkazy",
"Empty folder": "Prázdná složka",
"Folder": "Složka",
"Install LLeMbas %(version)s…": "Nainstalovat LLeMbas %(version)s…",
"LLeMbas %(version)s is available (this is %(current)s)": "Je k dispozici LLeMbas %(version)s (tohle je %(current)s)", "LLeMbas %(version)s is available (this is %(current)s)": "Je k dispozici LLeMbas %(version)s (tohle je %(current)s)",
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop potřebuje server LLeMbas 2.0 nebo novější.",
"Let the server open a terminal here": "Dovolit serveru otevřít zde terminál", "Let the server open a terminal here": "Dovolit serveru otevřít zde terminál",
"Link again": "Propojit znovu", "Link again": "Propojit znovu",
"Link this computer": "Propojit tento počítač", "Link this computer": "Propojit tento počítač",
"Loading…": "Načítá se…",
"Looking for a LLeMbas server there…": "Hledám tam server LLeMbas…", "Looking for a LLeMbas server there…": "Hledám tam server LLeMbas…",
"Manual — asks first": "Manual — nejdřív se zeptá", "Manual — asks first": "Manual — nejdřív se zeptá",
"Maximise": "Maximalizovat", "Maximise": "Maximalizovat",
"Menu": "Nabídka", "Menu": "Nabídka",
"Minimise": "Minimalizovat", "Minimise": "Minimalizovat",
"Mode": "Režim",
"Model": "Model",
"More": "Více",
"New agent chat": "Nová agentní konverzace",
"New chat": "Nová konverzace",
"No agent chat yet. Start one: it works in a folder on this computer.": "Zatím žádná agentní konverzace. Začněte ji: pracuje ve složce na tomto počítači.",
"No chat yet.": "Zatím žádná konverzace.",
"No folder yet: add one under Settings, Project folders.": "Zatím žádná složka: přidejte ji v Nastavení, Složky projektů.",
"No folder yet: the agent can open nothing. Add the folder of a project.": "Zatím žádná složka: agent nemůže nic otevřít. Přidejte složku projektu.", "No folder yet: the agent can open nothing. Add the folder of a project.": "Zatím žádná složka: agent nemůže nic otevřít. Přidejte složku projektu.",
"Open the server": "Otevřít server", "No model is available to this account.": "Tomuto účtu není dostupný žádný model.",
"No project folder yet: the agent can open nothing on this computer.": "Zatím žádná složka projektu: agent na tomto počítači nemůže nic otevřít.",
"Not yet": "Ještě ne",
"Notifications": "Upozornění",
"Only the agent's folders: Settings, Project folders.": "Jen složky agenta: Nastavení, Složky projektů.",
"Open in the viewer": "Otevřít v prohlížeči souborů",
"Open the page again": "Otevřít stránku znovu",
"Or type an answer": "Nebo napište odpověď",
"Other devices": "Jiná zařízení",
"Page": "Stránka",
"Plan — changes nothing": "Plan — nic nemění", "Plan — changes nothing": "Plan — nic nemění",
"Preview": "Náhled",
"Project folders": "Složky projektů", "Project folders": "Složky projektů",
"Read again": "Načíst znovu",
"Recommended": "Doporučeno",
"Remove": "Odebrat", "Remove": "Odebrat",
"Remove %(dir)s": "Odebrat %(dir)s", "Remove %(dir)s": "Odebrat %(dir)s",
"Restore": "Obnovit", "Restore": "Obnovit",
"Send": "Odeslat",
"Server": "Server", "Server": "Server",
"Sign in there as you would in a browser.": "Přihlaste se tam jako v prohlížeči.", "Settings": "Nastavení",
"Shown by the agent": "Ukázal agent",
"Sign in": "Přihlásit se",
"Sign out": "Odhlásit se",
"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.",
"Source": "Zdroj",
"Start the agent": "Spustit agenta", "Start the agent": "Spustit agenta",
"Stop the agent": "Zastavit agenta", "Stop the agent": "Zastavit agenta",
"Stopped.": "Zastaveno.",
"Taken in while it ran:": "Převzato během běhu:",
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Upozornit mě, když je odpověď nebo práce agenta hotová, zatímco jsem jinde",
"Terminal": "Terminál", "Terminal": "Terminál",
"The agent asks to run %(tool)s:": "Agent žádá o spuštění %(tool)s:",
"The agent asks:": "Agent se ptá:",
"The agent has finished.": "Agent dokončil práci.",
"The agent is connected, but may open no folder yet: add one below.": "Agent je připojený, ale zatím nesmí otevřít žádnou složku: přidejte ji níže.", "The agent is connected, but may open no folder yet: add one below.": "Agent je připojený, ale zatím nesmí otevřít žádnou složku: přidejte ji níže.",
"The agent is not connected to the server yet: start it, or link this computer, under Settings.": "Agent ještě není připojený k serveru: spusťte ho nebo propojte tento počítač v Nastavení.",
"The agent is off.": "Agent je vypnutý.", "The agent is off.": "Agent je vypnutý.",
"The agent is running: agent chats can use this computer.": "Agent běží: agentní konverzace mohou používat tento počítač.", "The agent is running: agent chats can use this computer.": "Agent běží: agentní konverzace mohou používat tento počítač.",
"The agent is starting…": "Agent se spouští…", "The agent is starting…": "Agent se spouští…",
"The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.": "Agent umožňuje agentním konverzacím na vašem serveru pracovat v projektech na tomto počítači. Je to vestavěné LLeMbas CLI, které běží na pozadí, dokud běží aplikace.", "The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.": "Agent umožňuje agentním konverzacím na vašem serveru pracovat v projektech na tomto počítači. Je to vestavěné LLeMbas CLI, které běží na pozadí, dokud běží aplikace.",
"The agent stopped: %(why)s. Trying again shortly.": "Agent se zastavil: %(why)s. Za chvíli to zkusí znovu.", "The agent stopped: %(why)s. Trying again shortly.": "Agent se zastavil: %(why)s. Za chvíli to zkusí znovu.",
"The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.": "Aplikace se k vašemu serveru přihlásí jednou, ve vašem prohlížeči, jako každé zařízení. Pak může číst a psát vaše konverzace, jako webové rozhraní.",
"The connection to the reply was lost: %(why)s": "Spojení s odpovědí se přerušilo: %(why)s",
"The most an agent chat may do here": "Nejvíc, co tu agentní konverzace smí", "The most an agent chat may do here": "Nejvíc, co tu agentní konverzace smí",
"The reply is ready.": "Odpověď je hotová.",
"Thinking": "Přemýšlí",
"This computer": "Tento počítač",
"This computer is not linked to the server yet.": "Tento počítač ještě není propojený se serverem.", "This computer is not linked to the server yet.": "Tento počítač ještě není propojený se serverem.",
"This file is too big to show here (%(size)s).": "Tento soubor je příliš velký na zobrazení (%(size)s).",
"This is not a text file; it cannot be shown here.": "Toto není textový soubor; nelze ho zde zobrazit.",
"This server does not offer its files to the app yet.": "Tento server zatím nenabízí své soubory aplikaci.",
"Thought": "Přemýšlel",
"Try again": "Zkusit znovu",
"Waiting for the browser…": "Čeká se na prohlížeč…",
"Waiting for the reply before it": "Čeká na odpověď před ní",
"Waiting for your answer": "Čeká na vaši odpověď",
"Writing…": "Píše se…",
"Your answer": "Vaše odpověď",
"agent off": "agent vypnutý", "agent off": "agent vypnutý",
"agent running": "agent běží", "agent running": "agent běží",
"agent running (service)": "agent běží (služba)", "agent running (service)": "agent běží (služba)",
@@ -64,48 +137,121 @@ const LEMBAS_I18N = {
"update %(version)s": "aktualizace %(version)s" "update %(version)s": "aktualizace %(version)s"
}, },
"de": { "de": {
"%(n)s tokens": "%(n)s Tokens",
"%(name)s, LLeMbas %(version)s.": "%(name)s, LLeMbas %(version)s.",
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Ein LLeMbas-CLI-Dienst läuft hier bereits (PID %(pid)s); er ist der Agent dieses Computers.", "A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Ein LLeMbas-CLI-Dienst läuft hier bereits (PID %(pid)s); er ist der Agent dieses Computers.",
"A chat asking for more is lowered to this.": "Ein Chat, der mehr verlangt, wird hierauf begrenzt.", "A chat asking for more is lowered to this.": "Ein Chat, der mehr verlangt, wird hierauf begrenzt.",
"A chat asking for more than the folder allows is lowered to it.": "Ein Chat, der mehr will, als der Ordner erlaubt, wird darauf herabgesetzt.",
"A shell as you, in one of these folders.": "Eine Shell als Sie, in einem dieser Ordner.", "A shell as you, in one of these folders.": "Eine Shell als Sie, in einem dieser Ordner.",
"About": "Über",
"Add folder…": "Ordner hinzufügen…", "Add folder…": "Ordner hinzufügen…",
"Address of your LLeMbas server": "Adresse Ihres LLeMbas-Servers", "Address of your LLeMbas server": "Adresse Ihres LLeMbas-Servers",
"Agent": "Agent", "Agent": "Agent",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.": "Agent-Chats von Ihrem Server dürfen in diesen Ordnern und allem darin arbeiten — nirgends sonst auf diesem Computer. Starten Sie einen im Reiter Agenten des Servers, auf „Lokal“.", "Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Agenten-Chats können in diesen Ordnern und allem darin arbeiten — nirgendwo sonst auf diesem Computer.",
"Agent stopped: %(why)s": "Agent angehalten: %(why)s", "Agent stopped: %(why)s": "Agent angehalten: %(why)s",
"Allow": "Erlauben",
"Always allow": "Immer erlauben",
"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",
"Answer": "Antworten",
"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:",
"Auto — asks nothing": "Auto — fragt nichts", "Auto — asks nothing": "Auto — fragt nichts",
"Cancel": "Abbrechen",
"Carry it out": "Ausführen",
"Chats": "Chats",
"Check for updates…": "Nach Updates suchen…",
"Close": "Schließen", "Close": "Schließen",
"Close (LLeMbas stays in the tray)": "Schließen (LLeMbas bleibt im Benachrichtigungsbereich)", "Close (LLeMbas stays in the tray)": "Schließen (LLeMbas bleibt im Benachrichtigungsbereich)",
"Command": "Befehl",
"Connect": "Verbinden", "Connect": "Verbinden",
"Copy": "Kopieren",
"Cut short here; the whole of it is in the web interface.": "Hier gekürzt; vollständig steht es in der Weboberfläche.",
"Deny": "Ablehnen",
"Earlier messages": "Frühere Nachrichten",
"Edit — asks before commands": "Edit — fragt vor Befehlen", "Edit — asks before commands": "Edit — fragt vor Befehlen",
"Empty folder": "Leerer Ordner",
"Folder": "Ordner",
"Install LLeMbas %(version)s…": "LLeMbas %(version)s installieren…",
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s ist verfügbar (dies ist %(current)s)", "LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s ist verfügbar (dies ist %(current)s)",
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop braucht einen LLeMbas-Server 2.0 oder neuer.",
"Let the server open a terminal here": "Dem Server erlauben, hier ein Terminal zu öffnen", "Let the server open a terminal here": "Dem Server erlauben, hier ein Terminal zu öffnen",
"Link again": "Erneut verbinden", "Link again": "Erneut verbinden",
"Link this computer": "Diesen Computer verbinden", "Link this computer": "Diesen Computer verbinden",
"Loading…": "Wird geladen…",
"Looking for a LLeMbas server there…": "Suche dort nach einem LLeMbas-Server…", "Looking for a LLeMbas server there…": "Suche dort nach einem LLeMbas-Server…",
"Manual — asks first": "Manual — fragt zuerst", "Manual — asks first": "Manual — fragt zuerst",
"Maximise": "Maximieren", "Maximise": "Maximieren",
"Menu": "Menü", "Menu": "Menü",
"Minimise": "Minimieren", "Minimise": "Minimieren",
"Mode": "Modus",
"Model": "Modell",
"More": "Mehr",
"New agent chat": "Neuer Agenten-Chat",
"New chat": "Neuer Chat",
"No agent chat yet. Start one: it works in a folder on this computer.": "Noch kein Agenten-Chat. Starten Sie einen: Er arbeitet in einem Ordner auf diesem Computer.",
"No chat yet.": "Noch kein Chat.",
"No folder yet: add one under Settings, Project folders.": "Noch kein Ordner: Fügen Sie einen unter Einstellungen, Projektordner hinzu.",
"No folder yet: the agent can open nothing. Add the folder of a project.": "Noch kein Ordner: Der Agent kann nichts öffnen. Fügen Sie den Ordner eines Projekts hinzu.", "No folder yet: the agent can open nothing. Add the folder of a project.": "Noch kein Ordner: Der Agent kann nichts öffnen. Fügen Sie den Ordner eines Projekts hinzu.",
"Open the server": "Server öffnen", "No model is available to this account.": "Diesem Konto steht kein Modell zur Verfügung.",
"No project folder yet: the agent can open nothing on this computer.": "Noch kein Projektordner: Der Agent kann auf diesem Computer nichts öffnen.",
"Not yet": "Noch nicht",
"Notifications": "Benachrichtigungen",
"Only the agent's folders: Settings, Project folders.": "Nur die Ordner des Agenten: Einstellungen, Projektordner.",
"Open in the viewer": "Im Betrachter öffnen",
"Open the page again": "Seite erneut öffnen",
"Or type an answer": "Oder eine Antwort eingeben",
"Other devices": "Andere Geräte",
"Page": "Seite",
"Plan — changes nothing": "Plan — ändert nichts", "Plan — changes nothing": "Plan — ändert nichts",
"Preview": "Vorschau",
"Project folders": "Projektordner", "Project folders": "Projektordner",
"Read again": "Neu lesen",
"Recommended": "Empfohlen",
"Remove": "Entfernen", "Remove": "Entfernen",
"Remove %(dir)s": "%(dir)s entfernen", "Remove %(dir)s": "%(dir)s entfernen",
"Restore": "Wiederherstellen", "Restore": "Wiederherstellen",
"Send": "Senden",
"Server": "Server", "Server": "Server",
"Sign in there as you would in a browser.": "Melden Sie sich dort wie im Browser an.", "Settings": "Einstellungen",
"Shown by the agent": "Vom Agenten gezeigt",
"Sign in": "Anmelden",
"Sign out": "Abmelden",
"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.",
"Source": "Quelltext",
"Start the agent": "Agent starten", "Start the agent": "Agent starten",
"Stop the agent": "Agent anhalten", "Stop the agent": "Agent anhalten",
"Stopped.": "Angehalten.",
"Taken in while it ran:": "Während des Laufs übernommen:",
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Benachrichtigen, wenn eine Antwort oder die Arbeit des Agenten fertig ist, während ich woanders bin",
"Terminal": "Terminal", "Terminal": "Terminal",
"The agent asks to run %(tool)s:": "Der Agent möchte %(tool)s ausführen:",
"The agent asks:": "Der Agent fragt:",
"The agent has finished.": "Der Agent ist fertig.",
"The agent is connected, but may open no folder yet: add one below.": "Der Agent ist verbunden, darf aber noch keinen Ordner öffnen: Fügen Sie unten einen hinzu.", "The agent is connected, but may open no folder yet: add one below.": "Der Agent ist verbunden, darf aber noch keinen Ordner öffnen: Fügen Sie unten einen hinzu.",
"The agent is not connected to the server yet: start it, or link this computer, under Settings.": "Der Agent ist noch nicht mit dem Server verbunden: Starten Sie ihn oder verbinden Sie diesen Computer unter Einstellungen.",
"The agent is off.": "Der Agent ist aus.", "The agent is off.": "Der Agent ist aus.",
"The agent is running: agent chats can use this computer.": "Der Agent läuft: Agent-Chats können diesen Computer nutzen.", "The agent is running: agent chats can use this computer.": "Der Agent läuft: Agent-Chats können diesen Computer nutzen.",
"The agent is starting…": "Der Agent startet…", "The agent is starting…": "Der Agent startet…",
"The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.": "Der Agent lässt Agent-Chats auf Ihrem Server in den Projekten dieses Computers arbeiten. Er ist das eingebaute LLeMbas CLI und läuft im Hintergrund, solange die App läuft.", "The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.": "Der Agent lässt Agent-Chats auf Ihrem Server in den Projekten dieses Computers arbeiten. Er ist das eingebaute LLeMbas CLI und läuft im Hintergrund, solange die App läuft.",
"The agent stopped: %(why)s. Trying again shortly.": "Der Agent hat angehalten: %(why)s. Gleich wird es erneut versucht.", "The agent stopped: %(why)s. Trying again shortly.": "Der Agent hat angehalten: %(why)s. Gleich wird es erneut versucht.",
"The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.": "Die App meldet sich einmal an Ihrem Server an, in Ihrem Browser, wie jedes Gerät. Danach kann sie Ihre Chats lesen und schreiben, wie die Weboberfläche.",
"The connection to the reply was lost: %(why)s": "Die Verbindung zur Antwort ist abgebrochen: %(why)s",
"The most an agent chat may do here": "Das Meiste, was ein Agent-Chat hier darf", "The most an agent chat may do here": "Das Meiste, was ein Agent-Chat hier darf",
"The reply is ready.": "Die Antwort ist fertig.",
"Thinking": "Denkt nach",
"This computer": "Dieser Computer",
"This computer is not linked to the server yet.": "Dieser Computer ist noch nicht mit dem Server verbunden.", "This computer is not linked to the server yet.": "Dieser Computer ist noch nicht mit dem Server verbunden.",
"This file is too big to show here (%(size)s).": "Diese Datei ist zu groß für die Anzeige (%(size)s).",
"This is not a text file; it cannot be shown here.": "Das ist keine Textdatei; sie kann hier nicht angezeigt werden.",
"This server does not offer its files to the app yet.": "Dieser Server bietet der App seine Dateien noch nicht an.",
"Thought": "Nachgedacht",
"Try again": "Erneut versuchen",
"Waiting for the browser…": "Warte auf den Browser…",
"Waiting for the reply before it": "Wartet auf die Antwort davor",
"Waiting for your answer": "Wartet auf Ihre Antwort",
"Writing…": "Schreibt…",
"Your answer": "Ihre Antwort",
"agent off": "Agent aus", "agent off": "Agent aus",
"agent running": "Agent läuft", "agent running": "Agent läuft",
"agent running (service)": "Agent läuft (Dienst)", "agent running (service)": "Agent läuft (Dienst)",
@@ -117,48 +263,121 @@ const LEMBAS_I18N = {
"update %(version)s": "Update %(version)s" "update %(version)s": "Update %(version)s"
}, },
"es": { "es": {
"%(n)s tokens": "tokens: %(n)s",
"%(name)s, LLeMbas %(version)s.": "%(name)s, LLeMbas %(version)s.",
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Ya se está ejecutando aquí un servicio de LLeMbas CLI (pid %(pid)s); es el agente de este ordenador.", "A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Ya se está ejecutando aquí un servicio de LLeMbas CLI (pid %(pid)s); es el agente de este ordenador.",
"A chat asking for more is lowered to this.": "Un chat que pida más se limita a esto.", "A chat asking for more is lowered to this.": "Un chat que pida más se limita a esto.",
"A chat asking for more than the folder allows is lowered to it.": "Un chat que pide más de lo que la carpeta permite se rebaja a ello.",
"A shell as you, in one of these folders.": "Un shell con su usuario, en una de estas carpetas.", "A shell as you, in one of these folders.": "Un shell con su usuario, en una de estas carpetas.",
"About": "Acerca de",
"Add folder…": "Añadir carpeta…", "Add folder…": "Añadir carpeta…",
"Address of your LLeMbas server": "Dirección de su servidor LLeMbas", "Address of your LLeMbas server": "Dirección de su servidor LLeMbas",
"Agent": "Agente", "Agent": "Agente",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.": "Los chats de agente de su servidor pueden trabajar en estas carpetas y en todo lo que contienen — en ningún otro lugar de este ordenador. Inicie uno en la pestaña Agentes del servidor, en «Local».", "Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Los chats de agente pueden trabajar en estas carpetas y en todo lo que contienen — en ningún otro lugar de este equipo.",
"Agent stopped: %(why)s": "El agente se detuvo: %(why)s", "Agent stopped: %(why)s": "El agente se detuvo: %(why)s",
"Allow": "Permitir",
"Always allow": "Permitir siempre",
"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",
"Answer": "Responder",
"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:",
"Auto — asks nothing": "Auto — no pregunta nada", "Auto — asks nothing": "Auto — no pregunta nada",
"Cancel": "Cancelar",
"Carry it out": "Llevarlo a cabo",
"Chats": "Chats",
"Check for updates…": "Buscar actualizaciones…",
"Close": "Cerrar", "Close": "Cerrar",
"Close (LLeMbas stays in the tray)": "Cerrar (LLeMbas sigue en la bandeja del sistema)", "Close (LLeMbas stays in the tray)": "Cerrar (LLeMbas sigue en la bandeja del sistema)",
"Command": "Comando",
"Connect": "Conectar", "Connect": "Conectar",
"Copy": "Copiar",
"Cut short here; the whole of it is in the web interface.": "Recortado aquí; completo está en la interfaz web.",
"Deny": "Denegar",
"Earlier messages": "Mensajes anteriores",
"Edit — asks before commands": "Edit — pregunta antes de los comandos", "Edit — asks before commands": "Edit — pregunta antes de los comandos",
"Empty folder": "Carpeta vacía",
"Folder": "Carpeta",
"Install LLeMbas %(version)s…": "Instalar LLeMbas %(version)s…",
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s está disponible (esta es la %(current)s)", "LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s está disponible (esta es la %(current)s)",
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop necesita un servidor LLeMbas 2.0 o posterior.",
"Let the server open a terminal here": "Permitir que el servidor abra un terminal aquí", "Let the server open a terminal here": "Permitir que el servidor abra un terminal aquí",
"Link again": "Vincular de nuevo", "Link again": "Vincular de nuevo",
"Link this computer": "Vincular este ordenador", "Link this computer": "Vincular este ordenador",
"Loading…": "Cargando…",
"Looking for a LLeMbas server there…": "Buscando un servidor LLeMbas allí…", "Looking for a LLeMbas server there…": "Buscando un servidor LLeMbas allí…",
"Manual — asks first": "Manual — pregunta primero", "Manual — asks first": "Manual — pregunta primero",
"Maximise": "Maximizar", "Maximise": "Maximizar",
"Menu": "Menú", "Menu": "Menú",
"Minimise": "Minimizar", "Minimise": "Minimizar",
"Mode": "Modo",
"Model": "Modelo",
"More": "Más",
"New agent chat": "Nuevo chat de agente",
"New chat": "Nuevo chat",
"No agent chat yet. Start one: it works in a folder on this computer.": "Aún no hay chats de agente. Inicie uno: trabaja en una carpeta de este equipo.",
"No chat yet.": "Aún no hay chats.",
"No folder yet: add one under Settings, Project folders.": "Aún no hay carpeta: añada una en Ajustes, Carpetas de proyecto.",
"No folder yet: the agent can open nothing. Add the folder of a project.": "Aún no hay ninguna carpeta: el agente no puede abrir nada. Añada la carpeta de un proyecto.", "No folder yet: the agent can open nothing. Add the folder of a project.": "Aún no hay ninguna carpeta: el agente no puede abrir nada. Añada la carpeta de un proyecto.",
"Open the server": "Abrir el servidor", "No model is available to this account.": "Ningún modelo está disponible para esta cuenta.",
"No project folder yet: the agent can open nothing on this computer.": "Aún no hay carpeta de proyecto: el agente no puede abrir nada en este equipo.",
"Not yet": "Todavía no",
"Notifications": "Notificaciones",
"Only the agent's folders: Settings, Project folders.": "Solo las carpetas del agente: Ajustes, Carpetas de proyecto.",
"Open in the viewer": "Abrir en el visor",
"Open the page again": "Abrir la página de nuevo",
"Or type an answer": "O escriba una respuesta",
"Other devices": "Otros dispositivos",
"Page": "Página",
"Plan — changes nothing": "Plan — no cambia nada", "Plan — changes nothing": "Plan — no cambia nada",
"Preview": "Vista previa",
"Project folders": "Carpetas de proyectos", "Project folders": "Carpetas de proyectos",
"Read again": "Leer de nuevo",
"Recommended": "Recomendado",
"Remove": "Quitar", "Remove": "Quitar",
"Remove %(dir)s": "Quitar %(dir)s", "Remove %(dir)s": "Quitar %(dir)s",
"Restore": "Restaurar", "Restore": "Restaurar",
"Send": "Enviar",
"Server": "Servidor", "Server": "Servidor",
"Sign in there as you would in a browser.": "Inicie sesión allí como lo haría en un navegador.", "Settings": "Ajustes",
"Shown by the agent": "Mostrado por el agente",
"Sign in": "Iniciar sesión",
"Sign out": "Cerrar sesión",
"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.",
"Source": "Código fuente",
"Start the agent": "Iniciar el agente", "Start the agent": "Iniciar el agente",
"Stop the agent": "Detener el agente", "Stop the agent": "Detener el agente",
"Stopped.": "Detenido.",
"Taken in while it ran:": "Incorporado mientras se ejecutaba:",
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Avisarme cuando una respuesta o el trabajo de un agente termine mientras estoy en otra parte",
"Terminal": "Terminal", "Terminal": "Terminal",
"The agent asks to run %(tool)s:": "El agente pide ejecutar %(tool)s:",
"The agent asks:": "El agente pregunta:",
"The agent has finished.": "El agente ha terminado.",
"The agent is connected, but may open no folder yet: add one below.": "El agente está conectado, pero aún no puede abrir ninguna carpeta: añada una abajo.", "The agent is connected, but may open no folder yet: add one below.": "El agente está conectado, pero aún no puede abrir ninguna carpeta: añada una abajo.",
"The agent is not connected to the server yet: start it, or link this computer, under Settings.": "El agente aún no está conectado al servidor: inícielo o vincule este equipo en Ajustes.",
"The agent is off.": "El agente está apagado.", "The agent is off.": "El agente está apagado.",
"The agent is running: agent chats can use this computer.": "El agente está en marcha: los chats de agente pueden usar este ordenador.", "The agent is running: agent chats can use this computer.": "El agente está en marcha: los chats de agente pueden usar este ordenador.",
"The agent is starting…": "El agente se está iniciando…", "The agent is starting…": "El agente se está iniciando…",
"The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.": "El agente permite que los chats de agente de su servidor trabajen en los proyectos de este ordenador. Es el LLeMbas CLI, integrado, que se ejecuta en segundo plano mientras la aplicación está abierta.", "The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.": "El agente permite que los chats de agente de su servidor trabajen en los proyectos de este ordenador. Es el LLeMbas CLI, integrado, que se ejecuta en segundo plano mientras la aplicación está abierta.",
"The agent stopped: %(why)s. Trying again shortly.": "El agente se detuvo: %(why)s. Se volverá a intentar en breve.", "The agent stopped: %(why)s. Trying again shortly.": "El agente se detuvo: %(why)s. Se volverá a intentar en breve.",
"The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.": "La aplicación inicia sesión en su servidor una vez, en su navegador, como cualquier dispositivo. Después puede leer y escribir sus chats, como la interfaz web.",
"The connection to the reply was lost: %(why)s": "Se perdió la conexión con la respuesta: %(why)s",
"The most an agent chat may do here": "Lo máximo que un chat de agente puede hacer aquí", "The most an agent chat may do here": "Lo máximo que un chat de agente puede hacer aquí",
"The reply is ready.": "La respuesta está lista.",
"Thinking": "Pensando",
"This computer": "Este equipo",
"This computer is not linked to the server yet.": "Este ordenador aún no está vinculado al servidor.", "This computer is not linked to the server yet.": "Este ordenador aún no está vinculado al servidor.",
"This file is too big to show here (%(size)s).": "Este archivo es demasiado grande para mostrarlo (%(size)s).",
"This is not a text file; it cannot be shown here.": "No es un archivo de texto; no se puede mostrar aquí.",
"This server does not offer its files to the app yet.": "Este servidor aún no ofrece sus archivos a la aplicación.",
"Thought": "Pensamiento",
"Try again": "Reintentar",
"Waiting for the browser…": "Esperando al navegador…",
"Waiting for the reply before it": "Esperando la respuesta anterior",
"Waiting for your answer": "Esperando su respuesta",
"Writing…": "Escribiendo…",
"Your answer": "Su respuesta",
"agent off": "agente apagado", "agent off": "agente apagado",
"agent running": "agente en marcha", "agent running": "agente en marcha",
"agent running (service)": "agente en marcha (servicio)", "agent running (service)": "agente en marcha (servicio)",
@@ -170,48 +389,121 @@ const LEMBAS_I18N = {
"update %(version)s": "actualizar a %(version)s" "update %(version)s": "actualizar a %(version)s"
}, },
"fr": { "fr": {
"%(n)s tokens": "%(n)s jetons",
"%(name)s, LLeMbas %(version)s.": "%(name)s, LLeMbas %(version)s.",
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Un service LLeMbas CLI tourne déjà ici (pid %(pid)s) ; c'est l'agent de cet ordinateur.", "A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Un service LLeMbas CLI tourne déjà ici (pid %(pid)s) ; c'est l'agent de cet ordinateur.",
"A chat asking for more is lowered to this.": "Une conversation qui en demande plus est ramenée à ceci.", "A chat asking for more is lowered to this.": "Une conversation qui en demande plus est ramenée à ceci.",
"A chat asking for more than the folder allows is lowered to it.": "Une discussion qui demande plus que le dossier ne permet y est ramenée.",
"A shell as you, in one of these folders.": "Un shell en votre nom, dans l'un de ces dossiers.", "A shell as you, in one of these folders.": "Un shell en votre nom, dans l'un de ces dossiers.",
"About": "À propos",
"Add folder…": "Ajouter un dossier…", "Add folder…": "Ajouter un dossier…",
"Address of your LLeMbas server": "Adresse de votre serveur LLeMbas", "Address of your LLeMbas server": "Adresse de votre serveur LLeMbas",
"Agent": "Agent", "Agent": "Agent",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.": "Les conversations d'agent de votre serveur peuvent travailler dans ces dossiers et tout ce qu'ils contiennent — nulle part ailleurs sur cet ordinateur. Démarrez-en une dans l'onglet Agents du serveur, sur « Local ».", "Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Les discussions d'agent peuvent travailler dans ces dossiers et tout ce qu'ils contiennent — nulle part ailleurs sur cet ordinateur.",
"Agent stopped: %(why)s": "L'agent s'est arrêté : %(why)s", "Agent stopped: %(why)s": "L'agent s'est arrêté : %(why)s",
"Allow": "Autoriser",
"Always allow": "Toujours autoriser",
"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",
"Answer": "Répondre",
"Approve this code in the page that opened in your browser:": "Approuvez ce code sur la page qui s'est ouverte dans votre navigateur :",
"Auto — asks nothing": "Auto — ne demande rien", "Auto — asks nothing": "Auto — ne demande rien",
"Cancel": "Annuler",
"Carry it out": "Exécuter",
"Chats": "Conversations",
"Check for updates…": "Rechercher des mises à jour…",
"Close": "Fermer", "Close": "Fermer",
"Close (LLeMbas stays in the tray)": "Fermer (LLeMbas reste dans la zone de notification)", "Close (LLeMbas stays in the tray)": "Fermer (LLeMbas reste dans la zone de notification)",
"Command": "Commande",
"Connect": "Se connecter", "Connect": "Se connecter",
"Copy": "Copier",
"Cut short here; the whole of it is in the web interface.": "Tronqué ici ; le tout est dans l'interface web.",
"Deny": "Refuser",
"Earlier messages": "Messages précédents",
"Edit — asks before commands": "Edit — demande avant les commandes", "Edit — asks before commands": "Edit — demande avant les commandes",
"Empty folder": "Dossier vide",
"Folder": "Dossier",
"Install LLeMbas %(version)s…": "Installer LLeMbas %(version)s…",
"LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s est disponible (ceci est la %(current)s)", "LLeMbas %(version)s is available (this is %(current)s)": "LLeMbas %(version)s est disponible (ceci est la %(current)s)",
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop nécessite un serveur LLeMbas 2.0 ou plus récent.",
"Let the server open a terminal here": "Laisser le serveur ouvrir un terminal ici", "Let the server open a terminal here": "Laisser le serveur ouvrir un terminal ici",
"Link again": "Relier à nouveau", "Link again": "Relier à nouveau",
"Link this computer": "Relier cet ordinateur", "Link this computer": "Relier cet ordinateur",
"Loading…": "Chargement…",
"Looking for a LLeMbas server there…": "Recherche d'un serveur LLeMbas à cette adresse…", "Looking for a LLeMbas server there…": "Recherche d'un serveur LLeMbas à cette adresse…",
"Manual — asks first": "Manual — demande d'abord", "Manual — asks first": "Manual — demande d'abord",
"Maximise": "Agrandir", "Maximise": "Agrandir",
"Menu": "Menu", "Menu": "Menu",
"Minimise": "Réduire", "Minimise": "Réduire",
"Mode": "Mode",
"Model": "Modèle",
"More": "Plus",
"New agent chat": "Nouvelle discussion d'agent",
"New chat": "Nouvelle conversation",
"No agent chat yet. Start one: it works in a folder on this computer.": "Aucune discussion d'agent pour l'instant. Commencez-en une : elle travaille dans un dossier de cet ordinateur.",
"No chat yet.": "Aucune discussion pour l'instant.",
"No folder yet: add one under Settings, Project folders.": "Aucun dossier pour l'instant : ajoutez-en un dans Paramètres, Dossiers de projet.",
"No folder yet: the agent can open nothing. Add the folder of a project.": "Aucun dossier pour l'instant : l'agent ne peut rien ouvrir. Ajoutez le dossier d'un projet.", "No folder yet: the agent can open nothing. Add the folder of a project.": "Aucun dossier pour l'instant : l'agent ne peut rien ouvrir. Ajoutez le dossier d'un projet.",
"Open the server": "Ouvrir le serveur", "No model is available to this account.": "Aucun modèle n'est disponible pour ce compte.",
"No project folder yet: the agent can open nothing on this computer.": "Aucun dossier de projet pour l'instant : l'agent ne peut rien ouvrir sur cet ordinateur.",
"Not yet": "Pas encore",
"Notifications": "Notifications",
"Only the agent's folders: Settings, Project folders.": "Uniquement les dossiers de l'agent : Paramètres, Dossiers de projet.",
"Open in the viewer": "Ouvrir dans la visionneuse",
"Open the page again": "Rouvrir la page",
"Or type an answer": "Ou saisissez une réponse",
"Other devices": "Autres appareils",
"Page": "Page",
"Plan — changes nothing": "Plan — ne change rien", "Plan — changes nothing": "Plan — ne change rien",
"Preview": "Aperçu",
"Project folders": "Dossiers de projets", "Project folders": "Dossiers de projets",
"Read again": "Relire",
"Recommended": "Recommandé",
"Remove": "Retirer", "Remove": "Retirer",
"Remove %(dir)s": "Retirer %(dir)s", "Remove %(dir)s": "Retirer %(dir)s",
"Restore": "Restaurer", "Restore": "Restaurer",
"Send": "Envoyer",
"Server": "Serveur", "Server": "Serveur",
"Sign in there as you would in a browser.": "Connectez-vous là-bas comme dans un navigateur.", "Settings": "Paramètres",
"Shown by the agent": "Montré par l'agent",
"Sign in": "Se connecter",
"Sign out": "Se déconnecter",
"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.",
"Source": "Source",
"Start the agent": "Démarrer l'agent", "Start the agent": "Démarrer l'agent",
"Stop the agent": "Arrêter l'agent", "Stop the agent": "Arrêter l'agent",
"Stopped.": "Arrêté.",
"Taken in while it ran:": "Pris en compte pendant l'exécution :",
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Me prévenir quand une réponse ou le travail d'un agent est terminé pendant que je suis ailleurs",
"Terminal": "Terminal", "Terminal": "Terminal",
"The agent asks to run %(tool)s:": "L'agent demande à exécuter %(tool)s :",
"The agent asks:": "L'agent demande :",
"The agent has finished.": "L'agent a terminé.",
"The agent is connected, but may open no folder yet: add one below.": "L'agent est connecté, mais ne peut encore ouvrir aucun dossier : ajoutez-en un ci-dessous.", "The agent is connected, but may open no folder yet: add one below.": "L'agent est connecté, mais ne peut encore ouvrir aucun dossier : ajoutez-en un ci-dessous.",
"The agent is not connected to the server yet: start it, or link this computer, under Settings.": "L'agent n'est pas encore connecté au serveur : démarrez-le ou reliez cet ordinateur dans les Paramètres.",
"The agent is off.": "L'agent est arrêté.", "The agent is off.": "L'agent est arrêté.",
"The agent is running: agent chats can use this computer.": "L'agent tourne : les conversations d'agent peuvent utiliser cet ordinateur.", "The agent is running: agent chats can use this computer.": "L'agent tourne : les conversations d'agent peuvent utiliser cet ordinateur.",
"The agent is starting…": "L'agent démarre…", "The agent is starting…": "L'agent démarre…",
"The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.": "L'agent permet aux conversations d'agent de votre serveur de travailler dans les projets de cet ordinateur. C'est le LLeMbas CLI, intégré, qui tourne en arrière-plan tant que l'application est ouverte.", "The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.": "L'agent permet aux conversations d'agent de votre serveur de travailler dans les projets de cet ordinateur. C'est le LLeMbas CLI, intégré, qui tourne en arrière-plan tant que l'application est ouverte.",
"The agent stopped: %(why)s. Trying again shortly.": "L'agent s'est arrêté : %(why)s. Nouvel essai sous peu.", "The agent stopped: %(why)s. Trying again shortly.": "L'agent s'est arrêté : %(why)s. Nouvel essai sous peu.",
"The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.": "L'application se connecte une fois à votre serveur, dans votre navigateur, comme tout appareil. Elle peut ensuite lire et écrire vos discussions, comme l'interface web.",
"The connection to the reply was lost: %(why)s": "La connexion à la réponse a été perdue : %(why)s",
"The most an agent chat may do here": "Le maximum qu'une conversation d'agent peut faire ici", "The most an agent chat may do here": "Le maximum qu'une conversation d'agent peut faire ici",
"The reply is ready.": "La réponse est prête.",
"Thinking": "Réflexion",
"This computer": "Cet ordinateur",
"This computer is not linked to the server yet.": "Cet ordinateur n'est pas encore relié au serveur.", "This computer is not linked to the server yet.": "Cet ordinateur n'est pas encore relié au serveur.",
"This file is too big to show here (%(size)s).": "Ce fichier est trop volumineux pour être affiché (%(size)s).",
"This is not a text file; it cannot be shown here.": "Ce n'est pas un fichier texte ; il ne peut pas être affiché ici.",
"This server does not offer its files to the app yet.": "Ce serveur n'offre pas encore ses fichiers à l'application.",
"Thought": "Réflexion",
"Try again": "Réessayer",
"Waiting for the browser…": "En attente du navigateur…",
"Waiting for the reply before it": "En attente de la réponse précédente",
"Waiting for your answer": "En attente de votre réponse",
"Writing…": "Rédaction…",
"Your answer": "Votre réponse",
"agent off": "agent arrêté", "agent off": "agent arrêté",
"agent running": "agent actif", "agent running": "agent actif",
"agent running (service)": "agent actif (service)", "agent running (service)": "agent actif (service)",
@@ -223,48 +515,121 @@ const LEMBAS_I18N = {
"update %(version)s": "mise à jour %(version)s" "update %(version)s": "mise à jour %(version)s"
}, },
"hu": { "hu": {
"%(n)s tokens": "%(n)s token",
"%(name)s, LLeMbas %(version)s.": "%(name)s, LLeMbas %(version)s.",
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Itt már fut egy LLeMbas CLI szolgáltatás (pid %(pid)s); ez a számítógép ügynöke.", "A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Itt már fut egy LLeMbas CLI szolgáltatás (pid %(pid)s); ez a számítógép ügynöke.",
"A chat asking for more is lowered to this.": "Az ennél többet kérő csevegés erre korlátozódik.", "A chat asking for more is lowered to this.": "Az ennél többet kérő csevegés erre korlátozódik.",
"A chat asking for more than the folder allows is lowered to it.": "A csevegés, amely többet kér, mint amit a mappa enged, erre lesz csökkentve.",
"A shell as you, in one of these folders.": "Egy shell az Ön nevében, ezek egyik mappájában.", "A shell as you, in one of these folders.": "Egy shell az Ön nevében, ezek egyik mappájában.",
"About": "Névjegy",
"Add folder…": "Mappa hozzáadása…", "Add folder…": "Mappa hozzáadása…",
"Address of your LLeMbas server": "A LLeMbas-kiszolgáló címe", "Address of your LLeMbas server": "A LLeMbas-kiszolgáló címe",
"Agent": "Ügynök", "Agent": "Ügynök",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.": "A kiszolgáló ügynök-csevegései ezekben a mappákban és a bennük lévőkben dolgozhatnak — a számítógépen máshol nem. Indítson egyet a kiszolgáló Ügynökök lapján, a „Helyi” eszközön.", "Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Az ügynökcsevegések ezekben a mappákban és mindenben, ami bennük van, dolgozhatnak — sehol máshol ezen a számítógépen.",
"Agent stopped: %(why)s": "Az ügynök leállt: %(why)s", "Agent stopped: %(why)s": "Az ügynök leállt: %(why)s",
"Allow": "Engedélyezés",
"Always allow": "Mindig engedélyez",
"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",
"Answer": "Válasz",
"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:",
"Auto — asks nothing": "Auto — semmit nem kérdez", "Auto — asks nothing": "Auto — semmit nem kérdez",
"Cancel": "Mégse",
"Carry it out": "Végrehajtás",
"Chats": "Csevegések",
"Check for updates…": "Frissítések keresése…",
"Close": "Bezárás", "Close": "Bezárás",
"Close (LLeMbas stays in the tray)": "Bezárás (a LLeMbas a tálcán marad)", "Close (LLeMbas stays in the tray)": "Bezárás (a LLeMbas a tálcán marad)",
"Command": "Parancs",
"Connect": "Csatlakozás", "Connect": "Csatlakozás",
"Copy": "Másolás",
"Cut short here; the whole of it is in the web interface.": "Itt rövidítve; a teljes a webes felületen van.",
"Deny": "Elutasítás",
"Earlier messages": "Korábbi üzenetek",
"Edit — asks before commands": "Edit — parancsok előtt kérdez", "Edit — asks before commands": "Edit — parancsok előtt kérdez",
"Empty folder": "Üres mappa",
"Folder": "Mappa",
"Install LLeMbas %(version)s…": "LLeMbas %(version)s telepítése…",
"LLeMbas %(version)s is available (this is %(current)s)": "Elérhető a LLeMbas %(version)s (ez a %(current)s)", "LLeMbas %(version)s is available (this is %(current)s)": "Elérhető a LLeMbas %(version)s (ez a %(current)s)",
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "A LLeMbas Desktophoz LLeMbas 2.0 vagy újabb szerver kell.",
"Let the server open a terminal here": "A kiszolgáló nyithasson itt terminált", "Let the server open a terminal here": "A kiszolgáló nyithasson itt terminált",
"Link again": "Újra összekapcsolás", "Link again": "Újra összekapcsolás",
"Link this computer": "A számítógép összekapcsolása", "Link this computer": "A számítógép összekapcsolása",
"Loading…": "Betöltés…",
"Looking for a LLeMbas server there…": "LLeMbas-kiszolgáló keresése ott…", "Looking for a LLeMbas server there…": "LLeMbas-kiszolgáló keresése ott…",
"Manual — asks first": "Manual — előbb kérdez", "Manual — asks first": "Manual — előbb kérdez",
"Maximise": "Teljes méret", "Maximise": "Teljes méret",
"Menu": "Menü", "Menu": "Menü",
"Minimise": "Kis méret", "Minimise": "Kis méret",
"Mode": "Mód",
"Model": "Modell",
"More": "Több",
"New agent chat": "Új ügynökcsevegés",
"New chat": "Új csevegés",
"No agent chat yet. Start one: it works in a folder on this computer.": "Még nincs ügynökcsevegés. Indítson egyet: egy mappában dolgozik ezen a számítógépen.",
"No chat yet.": "Még nincs csevegés.",
"No folder yet: add one under Settings, Project folders.": "Még nincs mappa: adjon hozzá egyet a Beállítások, Projektmappák alatt.",
"No folder yet: the agent can open nothing. Add the folder of a project.": "Még nincs mappa: az ügynök semmit nem nyithat meg. Adja hozzá egy projekt mappáját.", "No folder yet: the agent can open nothing. Add the folder of a project.": "Még nincs mappa: az ügynök semmit nem nyithat meg. Adja hozzá egy projekt mappáját.",
"Open the server": "Kiszolgáló megnyitása", "No model is available to this account.": "Ennek a fióknak nem érhető el modell.",
"No project folder yet: the agent can open nothing on this computer.": "Még nincs projektmappa: az ügynök semmit sem nyithat meg ezen a számítógépen.",
"Not yet": "Még nem",
"Notifications": "Értesítések",
"Only the agent's folders: Settings, Project folders.": "Csak az ügynök mappái: Beállítások, Projektmappák.",
"Open in the viewer": "Megnyitás a megjelenítőben",
"Open the page again": "Oldal újbóli megnyitása",
"Or type an answer": "Vagy írjon választ",
"Other devices": "Más eszközök",
"Page": "Oldal",
"Plan — changes nothing": "Plan — semmit nem változtat", "Plan — changes nothing": "Plan — semmit nem változtat",
"Preview": "Előnézet",
"Project folders": "Projektmappák", "Project folders": "Projektmappák",
"Read again": "Újraolvasás",
"Recommended": "Ajánlott",
"Remove": "Eltávolítás", "Remove": "Eltávolítás",
"Remove %(dir)s": "%(dir)s eltávolítása", "Remove %(dir)s": "%(dir)s eltávolítása",
"Restore": "Visszaállítás", "Restore": "Visszaállítás",
"Send": "Küldés",
"Server": "Kiszolgáló", "Server": "Kiszolgáló",
"Sign in there as you would in a browser.": "Jelentkezzen be ott úgy, mint egy böngészőben.", "Settings": "Beállítások",
"Shown by the agent": "Az ügynök mutatta",
"Sign in": "Bejelentkezés",
"Sign out": "Kijelentkezés",
"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.",
"Source": "Forrás",
"Start the agent": "Ügynök indítása", "Start the agent": "Ügynök indítása",
"Stop the agent": "Ügynök leállítása", "Stop the agent": "Ügynök leállítása",
"Stopped.": "Leállítva.",
"Taken in while it ran:": "Futás közben átvéve:",
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Szóljon, ha egy válasz vagy az ügynök munkája elkészül, amíg máshol vagyok",
"Terminal": "Terminál", "Terminal": "Terminál",
"The agent asks to run %(tool)s:": "Az ügynök a(z) %(tool)s futtatását kéri:",
"The agent asks:": "Az ügynök kérdezi:",
"The agent has finished.": "Az ügynök befejezte.",
"The agent is connected, but may open no folder yet: add one below.": "Az ügynök csatlakozott, de még egy mappát sem nyithat meg: adjon hozzá egyet lent.", "The agent is connected, but may open no folder yet: add one below.": "Az ügynök csatlakozott, de még egy mappát sem nyithat meg: adjon hozzá egyet lent.",
"The agent is not connected to the server yet: start it, or link this computer, under Settings.": "Az ügynök még nem kapcsolódik a szerverhez: indítsa el, vagy kapcsolja össze ezt a számítógépet a Beállításokban.",
"The agent is off.": "Az ügynök ki van kapcsolva.", "The agent is off.": "Az ügynök ki van kapcsolva.",
"The agent is running: agent chats can use this computer.": "Az ügynök fut: az ügynök-csevegések használhatják ezt a számítógépet.", "The agent is running: agent chats can use this computer.": "Az ügynök fut: az ügynök-csevegések használhatják ezt a számítógépet.",
"The agent is starting…": "Az ügynök indul…", "The agent is starting…": "Az ügynök indul…",
"The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.": "Az ügynök lehetővé teszi, hogy a kiszolgálón futó ügynök-csevegések ennek a számítógépnek a projektjeiben dolgozzanak. Ez a beépített LLeMbas CLI, amely a háttérben fut, amíg az alkalmazás fut.", "The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.": "Az ügynök lehetővé teszi, hogy a kiszolgálón futó ügynök-csevegések ennek a számítógépnek a projektjeiben dolgozzanak. Ez a beépített LLeMbas CLI, amely a háttérben fut, amíg az alkalmazás fut.",
"The agent stopped: %(why)s. Trying again shortly.": "Az ügynök leállt: %(why)s. Hamarosan újra próbálja.", "The agent stopped: %(why)s. Trying again shortly.": "Az ügynök leállt: %(why)s. Hamarosan újra próbálja.",
"The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.": "Az alkalmazás egyszer jelentkezik be a szerverére, a böngészőjében, mint bármely eszköz. Ezután olvashatja és írhatja a csevegéseit, mint a webes felület.",
"The connection to the reply was lost: %(why)s": "A kapcsolat a válasszal megszakadt: %(why)s",
"The most an agent chat may do here": "A legtöbb, amit egy ügynök-csevegés itt tehet", "The most an agent chat may do here": "A legtöbb, amit egy ügynök-csevegés itt tehet",
"The reply is ready.": "A válasz kész.",
"Thinking": "Gondolkodás",
"This computer": "Ez a számítógép",
"This computer is not linked to the server yet.": "Ez a számítógép még nincs összekapcsolva a kiszolgálóval.", "This computer is not linked to the server yet.": "Ez a számítógép még nincs összekapcsolva a kiszolgálóval.",
"This file is too big to show here (%(size)s).": "Ez a fájl túl nagy a megjelenítéshez (%(size)s).",
"This is not a text file; it cannot be shown here.": "Ez nem szövegfájl; itt nem jeleníthető meg.",
"This server does not offer its files to the app yet.": "Ez a szerver még nem kínálja fel a fájljait az alkalmazásnak.",
"Thought": "Gondolkodott",
"Try again": "Újrapróbálás",
"Waiting for the browser…": "Várakozás a böngészőre…",
"Waiting for the reply before it": "Az előző válaszra vár",
"Waiting for your answer": "Az Ön válaszára vár",
"Writing…": "Írás…",
"Your answer": "Az Ön válasza",
"agent off": "ügynök kikapcsolva", "agent off": "ügynök kikapcsolva",
"agent running": "ügynök fut", "agent running": "ügynök fut",
"agent running (service)": "ügynök fut (szolgáltatás)", "agent running (service)": "ügynök fut (szolgáltatás)",
@@ -276,48 +641,121 @@ const LEMBAS_I18N = {
"update %(version)s": "frissítés: %(version)s" "update %(version)s": "frissítés: %(version)s"
}, },
"it": { "it": {
"%(n)s tokens": "token: %(n)s",
"%(name)s, LLeMbas %(version)s.": "%(name)s, LLeMbas %(version)s.",
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Un servizio LLeMbas CLI è già in esecuzione qui (pid %(pid)s); è l'agente di questo computer.", "A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Un servizio LLeMbas CLI è già in esecuzione qui (pid %(pid)s); è l'agente di questo computer.",
"A chat asking for more is lowered to this.": "Una chat che chiede di più viene limitata a questo.", "A chat asking for more is lowered to this.": "Una chat che chiede di più viene limitata a questo.",
"A chat asking for more than the folder allows is lowered to it.": "Una chat che chiede più di quanto la cartella consente viene ridotta a quello.",
"A shell as you, in one of these folders.": "Una shell con il tuo utente, in una di queste cartelle.", "A shell as you, in one of these folders.": "Una shell con il tuo utente, in una di queste cartelle.",
"About": "Informazioni",
"Add folder…": "Aggiungi cartella…", "Add folder…": "Aggiungi cartella…",
"Address of your LLeMbas server": "Indirizzo del tuo server LLeMbas", "Address of your LLeMbas server": "Indirizzo del tuo server LLeMbas",
"Agent": "Agente", "Agent": "Agente",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.": "Le chat agente del tuo server possono lavorare in queste cartelle e in tutto ciò che contengono — in nessun altro posto di questo computer. Avviane una nella scheda Agenti del server, su “Locale”.", "Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Le chat con agente possono lavorare in queste cartelle e in tutto ciò che contengono — in nessun altro punto di questo computer.",
"Agent stopped: %(why)s": "L'agente si è fermato: %(why)s", "Agent stopped: %(why)s": "L'agente si è fermato: %(why)s",
"Allow": "Consenti",
"Always allow": "Consenti sempre",
"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",
"Answer": "Rispondi",
"Approve this code in the page that opened in your browser:": "Approvi questo codice nella pagina che si è aperta nel suo browser:",
"Auto — asks nothing": "Auto — non chiede nulla", "Auto — asks nothing": "Auto — non chiede nulla",
"Cancel": "Annulla",
"Carry it out": "Eseguilo",
"Chats": "Chat",
"Check for updates…": "Cerca aggiornamenti…",
"Close": "Chiudi", "Close": "Chiudi",
"Close (LLeMbas stays in the tray)": "Chiudi (LLeMbas resta nell'area di notifica)", "Close (LLeMbas stays in the tray)": "Chiudi (LLeMbas resta nell'area di notifica)",
"Command": "Comando",
"Connect": "Connetti", "Connect": "Connetti",
"Copy": "Copia",
"Cut short here; the whole of it is in the web interface.": "Troncato qui; il resto è nell'interfaccia web.",
"Deny": "Nega",
"Earlier messages": "Messaggi precedenti",
"Edit — asks before commands": "Edit — chiede prima dei comandi", "Edit — asks before commands": "Edit — chiede prima dei comandi",
"Empty folder": "Cartella vuota",
"Folder": "Cartella",
"Install LLeMbas %(version)s…": "Installa LLeMbas %(version)s…",
"LLeMbas %(version)s is available (this is %(current)s)": "È disponibile LLeMbas %(version)s (questa è la %(current)s)", "LLeMbas %(version)s is available (this is %(current)s)": "È disponibile LLeMbas %(version)s (questa è la %(current)s)",
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop richiede un server LLeMbas 2.0 o successivo.",
"Let the server open a terminal here": "Lascia che il server apra un terminale qui", "Let the server open a terminal here": "Lascia che il server apra un terminale qui",
"Link again": "Collega di nuovo", "Link again": "Collega di nuovo",
"Link this computer": "Collega questo computer", "Link this computer": "Collega questo computer",
"Loading…": "Caricamento…",
"Looking for a LLeMbas server there…": "Cerco un server LLeMbas lì…", "Looking for a LLeMbas server there…": "Cerco un server LLeMbas lì…",
"Manual — asks first": "Manual — chiede prima", "Manual — asks first": "Manual — chiede prima",
"Maximise": "Ingrandisci", "Maximise": "Ingrandisci",
"Menu": "Menu", "Menu": "Menu",
"Minimise": "Riduci a icona", "Minimise": "Riduci a icona",
"Mode": "Modalità",
"Model": "Modello",
"More": "Altro",
"New agent chat": "Nuova chat con agente",
"New chat": "Nuova chat",
"No agent chat yet. Start one: it works in a folder on this computer.": "Nessuna chat con agente ancora. Ne inizi una: lavora in una cartella di questo computer.",
"No chat yet.": "Nessuna chat ancora.",
"No folder yet: add one under Settings, Project folders.": "Nessuna cartella ancora: ne aggiunga una in Impostazioni, Cartelle di progetto.",
"No folder yet: the agent can open nothing. Add the folder of a project.": "Ancora nessuna cartella: l'agente non può aprire nulla. Aggiungi la cartella di un progetto.", "No folder yet: the agent can open nothing. Add the folder of a project.": "Ancora nessuna cartella: l'agente non può aprire nulla. Aggiungi la cartella di un progetto.",
"Open the server": "Apri il server", "No model is available to this account.": "Nessun modello è disponibile per questo account.",
"No project folder yet: the agent can open nothing on this computer.": "Nessuna cartella di progetto ancora: l'agente non può aprire nulla su questo computer.",
"Not yet": "Non ancora",
"Notifications": "Notifiche",
"Only the agent's folders: Settings, Project folders.": "Solo le cartelle dell'agente: Impostazioni, Cartelle di progetto.",
"Open in the viewer": "Apri nel visualizzatore",
"Open the page again": "Riapri la pagina",
"Or type an answer": "Oppure scriva una risposta",
"Other devices": "Altri dispositivi",
"Page": "Pagina",
"Plan — changes nothing": "Plan — non cambia nulla", "Plan — changes nothing": "Plan — non cambia nulla",
"Preview": "Anteprima",
"Project folders": "Cartelle dei progetti", "Project folders": "Cartelle dei progetti",
"Read again": "Rileggi",
"Recommended": "Consigliato",
"Remove": "Rimuovi", "Remove": "Rimuovi",
"Remove %(dir)s": "Rimuovi %(dir)s", "Remove %(dir)s": "Rimuovi %(dir)s",
"Restore": "Ripristina", "Restore": "Ripristina",
"Send": "Invia",
"Server": "Server", "Server": "Server",
"Sign in there as you would in a browser.": "Accedi lì come faresti in un browser.", "Settings": "Impostazioni",
"Shown by the agent": "Mostrato dall'agente",
"Sign in": "Accedi",
"Sign out": "Esci",
"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.",
"Source": "Sorgente",
"Start the agent": "Avvia l'agente", "Start the agent": "Avvia l'agente",
"Stop the agent": "Ferma l'agente", "Stop the agent": "Ferma l'agente",
"Stopped.": "Interrotto.",
"Taken in while it ran:": "Accolto durante l'esecuzione:",
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Avvisami quando una risposta o il lavoro di un agente è finito mentre sono altrove",
"Terminal": "Terminale", "Terminal": "Terminale",
"The agent asks to run %(tool)s:": "L'agente chiede di eseguire %(tool)s:",
"The agent asks:": "L'agente chiede:",
"The agent has finished.": "L'agente ha finito.",
"The agent is connected, but may open no folder yet: add one below.": "L'agente è connesso, ma non può ancora aprire nessuna cartella: aggiungine una qui sotto.", "The agent is connected, but may open no folder yet: add one below.": "L'agente è connesso, ma non può ancora aprire nessuna cartella: aggiungine una qui sotto.",
"The agent is not connected to the server yet: start it, or link this computer, under Settings.": "L'agente non è ancora connesso al server: lo avvii o colleghi questo computer nelle Impostazioni.",
"The agent is off.": "L'agente è spento.", "The agent is off.": "L'agente è spento.",
"The agent is running: agent chats can use this computer.": "L'agente è in esecuzione: le chat agente possono usare questo computer.", "The agent is running: agent chats can use this computer.": "L'agente è in esecuzione: le chat agente possono usare questo computer.",
"The agent is starting…": "L'agente si sta avviando…", "The agent is starting…": "L'agente si sta avviando…",
"The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.": "L'agente permette alle chat agente del tuo server di lavorare nei progetti di questo computer. È il LLeMbas CLI, integrato, in esecuzione in background finché l'app è aperta.", "The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.": "L'agente permette alle chat agente del tuo server di lavorare nei progetti di questo computer. È il LLeMbas CLI, integrato, in esecuzione in background finché l'app è aperta.",
"The agent stopped: %(why)s. Trying again shortly.": "L'agente si è fermato: %(why)s. Tra poco riprova.", "The agent stopped: %(why)s. Trying again shortly.": "L'agente si è fermato: %(why)s. Tra poco riprova.",
"The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.": "L'app accede al suo server una volta, nel suo browser, come ogni dispositivo. Poi può leggere e scrivere le sue chat, come l'interfaccia web.",
"The connection to the reply was lost: %(why)s": "La connessione alla risposta si è interrotta: %(why)s",
"The most an agent chat may do here": "Il massimo che una chat agente può fare qui", "The most an agent chat may do here": "Il massimo che una chat agente può fare qui",
"The reply is ready.": "La risposta è pronta.",
"Thinking": "Ragionamento",
"This computer": "Questo computer",
"This computer is not linked to the server yet.": "Questo computer non è ancora collegato al server.", "This computer is not linked to the server yet.": "Questo computer non è ancora collegato al server.",
"This file is too big to show here (%(size)s).": "Questo file è troppo grande per essere mostrato (%(size)s).",
"This is not a text file; it cannot be shown here.": "Questo non è un file di testo; non può essere mostrato qui.",
"This server does not offer its files to the app yet.": "Questo server non offre ancora i suoi file all'app.",
"Thought": "Ha ragionato",
"Try again": "Riprova",
"Waiting for the browser…": "In attesa del browser…",
"Waiting for the reply before it": "In attesa della risposta precedente",
"Waiting for your answer": "In attesa della sua risposta",
"Writing…": "Sta scrivendo…",
"Your answer": "La sua risposta",
"agent off": "agente spento", "agent off": "agente spento",
"agent running": "agente attivo", "agent running": "agente attivo",
"agent running (service)": "agente attivo (servizio)", "agent running (service)": "agente attivo (servizio)",
@@ -329,48 +767,121 @@ const LEMBAS_I18N = {
"update %(version)s": "aggiorna a %(version)s" "update %(version)s": "aggiorna a %(version)s"
}, },
"pl": { "pl": {
"%(n)s tokens": "Tokeny: %(n)s",
"%(name)s, LLeMbas %(version)s.": "%(name)s, LLeMbas %(version)s.",
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Usługa LLeMbas CLI już tu działa (pid %(pid)s); jest agentem tego komputera.", "A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Usługa LLeMbas CLI już tu działa (pid %(pid)s); jest agentem tego komputera.",
"A chat asking for more is lowered to this.": "Czat, który prosi o więcej, zostaje ograniczony do tego.", "A chat asking for more is lowered to this.": "Czat, który prosi o więcej, zostaje ograniczony do tego.",
"A chat asking for more than the folder allows is lowered to it.": "Czat, który chce więcej, niż folder pozwala, zostaje do tego obniżony.",
"A shell as you, in one of these folders.": "Powłoka na Twoim koncie, w jednym z tych folderów.", "A shell as you, in one of these folders.": "Powłoka na Twoim koncie, w jednym z tych folderów.",
"About": "O aplikacji",
"Add folder…": "Dodaj folder…", "Add folder…": "Dodaj folder…",
"Address of your LLeMbas server": "Adres Twojego serwera LLeMbas", "Address of your LLeMbas server": "Adres Twojego serwera LLeMbas",
"Agent": "Agent", "Agent": "Agent",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.": "Czaty agenta z Twojego serwera mogą pracować w tych folderach i we wszystkim, co zawierają — nigdzie indziej na tym komputerze. Uruchom czat na karcie Agenci na serwerze, na urządzeniu „Lokalne”.", "Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Czaty z agentem mogą pracować w tych folderach i we wszystkim, co zawierają — nigdzie indziej na tym komputerze.",
"Agent stopped: %(why)s": "Agent się zatrzymał: %(why)s", "Agent stopped: %(why)s": "Agent się zatrzymał: %(why)s",
"Allow": "Pozwól",
"Always allow": "Zawsze zezwalaj",
"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",
"Answer": "Odpowiedź",
"Approve this code in the page that opened in your browser:": "Zatwierdź ten kod na stronie, która otworzyła się w przeglądarce:",
"Auto — asks nothing": "Auto — o nic nie pyta", "Auto — asks nothing": "Auto — o nic nie pyta",
"Cancel": "Anuluj",
"Carry it out": "Wykonaj",
"Chats": "Czaty",
"Check for updates…": "Sprawdź aktualizacje…",
"Close": "Zamknij", "Close": "Zamknij",
"Close (LLeMbas stays in the tray)": "Zamknij (LLeMbas zostaje w zasobniku systemowym)", "Close (LLeMbas stays in the tray)": "Zamknij (LLeMbas zostaje w zasobniku systemowym)",
"Command": "Polecenie",
"Connect": "Połącz", "Connect": "Połącz",
"Copy": "Kopiuj",
"Cut short here; the whole of it is in the web interface.": "Tu skrócone; całość jest w interfejsie WWW.",
"Deny": "Odrzuć",
"Earlier messages": "Wcześniejsze wiadomości",
"Edit — asks before commands": "Edit — pyta przed poleceniami", "Edit — asks before commands": "Edit — pyta przed poleceniami",
"Empty folder": "Pusty folder",
"Folder": "Folder",
"Install LLeMbas %(version)s…": "Zainstaluj LLeMbas %(version)s…",
"LLeMbas %(version)s is available (this is %(current)s)": "Dostępny jest LLeMbas %(version)s (to jest %(current)s)", "LLeMbas %(version)s is available (this is %(current)s)": "Dostępny jest LLeMbas %(version)s (to jest %(current)s)",
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop wymaga serwera LLeMbas 2.0 lub nowszego.",
"Let the server open a terminal here": "Pozwól serwerowi otworzyć tu terminal", "Let the server open a terminal here": "Pozwól serwerowi otworzyć tu terminal",
"Link again": "Połącz ponownie", "Link again": "Połącz ponownie",
"Link this computer": "Połącz ten komputer", "Link this computer": "Połącz ten komputer",
"Loading…": "Wczytywanie…",
"Looking for a LLeMbas server there…": "Szukam tam serwera LLeMbas…", "Looking for a LLeMbas server there…": "Szukam tam serwera LLeMbas…",
"Manual — asks first": "Manual — najpierw pyta", "Manual — asks first": "Manual — najpierw pyta",
"Maximise": "Maksymalizuj", "Maximise": "Maksymalizuj",
"Menu": "Menu", "Menu": "Menu",
"Minimise": "Minimalizuj", "Minimise": "Minimalizuj",
"Mode": "Tryb",
"Model": "Model",
"More": "Więcej",
"New agent chat": "Nowy czat z agentem",
"New chat": "Nowy czat",
"No agent chat yet. Start one: it works in a folder on this computer.": "Jeszcze nie ma czatu z agentem. Zacznij go: pracuje w folderze na tym komputerze.",
"No chat yet.": "Jeszcze nie ma czatu.",
"No folder yet: add one under Settings, Project folders.": "Jeszcze nie ma folderu: dodaj go w Ustawieniach, Foldery projektów.",
"No folder yet: the agent can open nothing. Add the folder of a project.": "Nie ma jeszcze folderu: agent nie może nic otworzyć. Dodaj folder projektu.", "No folder yet: the agent can open nothing. Add the folder of a project.": "Nie ma jeszcze folderu: agent nie może nic otworzyć. Dodaj folder projektu.",
"Open the server": "Otwórz serwer", "No model is available to this account.": "Dla tego konta nie jest dostępny żaden model.",
"No project folder yet: the agent can open nothing on this computer.": "Jeszcze nie ma folderu projektu: agent nie może niczego otworzyć na tym komputerze.",
"Not yet": "Jeszcze nie",
"Notifications": "Powiadomienia",
"Only the agent's folders: Settings, Project folders.": "Tylko foldery agenta: Ustawienia, Foldery projektów.",
"Open in the viewer": "Otwórz w podglądzie",
"Open the page again": "Otwórz stronę ponownie",
"Or type an answer": "Albo wpisz odpowiedź",
"Other devices": "Inne urządzenia",
"Page": "Strona",
"Plan — changes nothing": "Plan — niczego nie zmienia", "Plan — changes nothing": "Plan — niczego nie zmienia",
"Preview": "Podgląd",
"Project folders": "Foldery projektów", "Project folders": "Foldery projektów",
"Read again": "Wczytaj ponownie",
"Recommended": "Zalecane",
"Remove": "Usuń", "Remove": "Usuń",
"Remove %(dir)s": "Usuń %(dir)s", "Remove %(dir)s": "Usuń %(dir)s",
"Restore": "Przywróć", "Restore": "Przywróć",
"Send": "Wyślij",
"Server": "Serwer", "Server": "Serwer",
"Sign in there as you would in a browser.": "Zaloguj się tam tak jak w przeglądarce.", "Settings": "Ustawienia",
"Shown by the agent": "Pokazane przez agenta",
"Sign in": "Zaloguj się",
"Sign out": "Wyloguj się",
"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.",
"Source": "Źródło",
"Start the agent": "Uruchom agenta", "Start the agent": "Uruchom agenta",
"Stop the agent": "Zatrzymaj agenta", "Stop the agent": "Zatrzymaj agenta",
"Stopped.": "Zatrzymano.",
"Taken in while it ran:": "Przejęte w trakcie działania:",
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Powiadom mnie, gdy odpowiedź lub praca agenta jest gotowa, a ja jestem gdzie indziej",
"Terminal": "Terminal", "Terminal": "Terminal",
"The agent asks to run %(tool)s:": "Agent prosi o uruchomienie %(tool)s:",
"The agent asks:": "Agent pyta:",
"The agent has finished.": "Agent skończył.",
"The agent is connected, but may open no folder yet: add one below.": "Agent jest połączony, ale nie może jeszcze otworzyć żadnego folderu: dodaj go poniżej.", "The agent is connected, but may open no folder yet: add one below.": "Agent jest połączony, ale nie może jeszcze otworzyć żadnego folderu: dodaj go poniżej.",
"The agent is not connected to the server yet: start it, or link this computer, under Settings.": "Agent nie jest jeszcze połączony z serwerem: uruchom go lub połącz ten komputer w Ustawieniach.",
"The agent is off.": "Agent jest wyłączony.", "The agent is off.": "Agent jest wyłączony.",
"The agent is running: agent chats can use this computer.": "Agent działa: czaty agenta mogą używać tego komputera.", "The agent is running: agent chats can use this computer.": "Agent działa: czaty agenta mogą używać tego komputera.",
"The agent is starting…": "Agent się uruchamia…", "The agent is starting…": "Agent się uruchamia…",
"The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.": "Agent pozwala czatom agenta z Twojego serwera pracować w projektach na tym komputerze. To wbudowane LLeMbas CLI, działające w tle, dopóki działa aplikacja.", "The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.": "Agent pozwala czatom agenta z Twojego serwera pracować w projektach na tym komputerze. To wbudowane LLeMbas CLI, działające w tle, dopóki działa aplikacja.",
"The agent stopped: %(why)s. Trying again shortly.": "Agent się zatrzymał: %(why)s. Za chwilę spróbuje ponownie.", "The agent stopped: %(why)s. Trying again shortly.": "Agent się zatrzymał: %(why)s. Za chwilę spróbuje ponownie.",
"The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.": "Aplikacja loguje się do Twojego serwera raz, w przeglądarce, jak każde urządzenie. Potem może czytać i pisać Twoje czaty, jak interfejs WWW.",
"The connection to the reply was lost: %(why)s": "Połączenie z odpowiedzią zostało zerwane: %(why)s",
"The most an agent chat may do here": "Najwięcej, co czat agenta może tu zrobić", "The most an agent chat may do here": "Najwięcej, co czat agenta może tu zrobić",
"The reply is ready.": "Odpowiedź jest gotowa.",
"Thinking": "Rozumowanie",
"This computer": "Ten komputer",
"This computer is not linked to the server yet.": "Ten komputer nie jest jeszcze połączony z serwerem.", "This computer is not linked to the server yet.": "Ten komputer nie jest jeszcze połączony z serwerem.",
"This file is too big to show here (%(size)s).": "Ten plik jest za duży, by go tu pokazać (%(size)s).",
"This is not a text file; it cannot be shown here.": "To nie jest plik tekstowy; nie można go tu pokazać.",
"This server does not offer its files to the app yet.": "Ten serwer nie udostępnia jeszcze swoich plików aplikacji.",
"Thought": "Rozumowanie",
"Try again": "Spróbuj ponownie",
"Waiting for the browser…": "Czekam na przeglądarkę…",
"Waiting for the reply before it": "Czeka na odpowiedź przed nią",
"Waiting for your answer": "Czeka na Twoją odpowiedź",
"Writing…": "Pisze…",
"Your answer": "Twoja odpowiedź",
"agent off": "agent wyłączony", "agent off": "agent wyłączony",
"agent running": "agent działa", "agent running": "agent działa",
"agent running (service)": "agent działa (usługa)", "agent running (service)": "agent działa (usługa)",
@@ -382,48 +893,121 @@ const LEMBAS_I18N = {
"update %(version)s": "aktualizacja %(version)s" "update %(version)s": "aktualizacja %(version)s"
}, },
"sk": { "sk": {
"%(n)s tokens": "tokeny: %(n)s",
"%(name)s, LLeMbas %(version)s.": "%(name)s, LLeMbas %(version)s.",
"A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Služba LLeMbas CLI tu už beží (pid %(pid)s); je agentom tohto počítača.", "A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.": "Služba LLeMbas CLI tu už beží (pid %(pid)s); je agentom tohto počítača.",
"A chat asking for more is lowered to this.": "Konverzácia, ktorá chce viac, je znížená na toto.", "A chat asking for more is lowered to this.": "Konverzácia, ktorá chce viac, je znížená na toto.",
"A chat asking for more than the folder allows is lowered to it.": "Konverzácia, ktorá chce viac, než priečinok dovoľuje, je znížená na to.",
"A shell as you, in one of these folders.": "Shell pod vaším účtom, v jednom z týchto priečinkov.", "A shell as you, in one of these folders.": "Shell pod vaším účtom, v jednom z týchto priečinkov.",
"About": "O aplikácii",
"Add folder…": "Pridať priečinok…", "Add folder…": "Pridať priečinok…",
"Address of your LLeMbas server": "Adresa vášho servera LLeMbas", "Address of your LLeMbas server": "Adresa vášho servera LLeMbas",
"Agent": "Agent", "Agent": "Agent",
"Agent chats from your server can work in these folders and anything inside them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.": "Agentské konverzácie z vášho servera môžu pracovať v týchto priečinkoch a vo všetkom, čo obsahujú — nikde inde na tomto počítači. Spustite ju na serveri na karte Agenti, na zariadení „Lokálny“.", "Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Agentské konverzácie môžu pracovať v týchto priečinkoch a vo všetkom, čo obsahujú — nikde inde na tomto počítači.",
"Agent stopped: %(why)s": "Agent sa zastavil: %(why)s", "Agent stopped: %(why)s": "Agent sa zastavil: %(why)s",
"Allow": "Povoliť",
"Always allow": "Vždy povoliť",
"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",
"Answer": "Odpovedať",
"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:",
"Auto — asks nothing": "Auto — na nič sa nepýta", "Auto — asks nothing": "Auto — na nič sa nepýta",
"Cancel": "Zrušiť",
"Carry it out": "Vykonať to",
"Chats": "Konverzácie",
"Check for updates…": "Skontrolovať aktualizácie…",
"Close": "Zavrieť", "Close": "Zavrieť",
"Close (LLeMbas stays in the tray)": "Zavrieť (LLeMbas zostane v oznamovacej oblasti)", "Close (LLeMbas stays in the tray)": "Zavrieť (LLeMbas zostane v oznamovacej oblasti)",
"Command": "Príkaz",
"Connect": "Pripojiť", "Connect": "Pripojiť",
"Copy": "Kopírovať",
"Cut short here; the whole of it is in the web interface.": "Tu skrátené; celé je to vo webovom rozhraní.",
"Deny": "Odmietnuť",
"Earlier messages": "Staršie správy",
"Edit — asks before commands": "Edit — pýta sa pred príkazmi", "Edit — asks before commands": "Edit — pýta sa pred príkazmi",
"Empty folder": "Prázdny priečinok",
"Folder": "Priečinok",
"Install LLeMbas %(version)s…": "Nainštalovať LLeMbas %(version)s…",
"LLeMbas %(version)s is available (this is %(current)s)": "Je k dispozícii LLeMbas %(version)s (toto je %(current)s)", "LLeMbas %(version)s is available (this is %(current)s)": "Je k dispozícii LLeMbas %(version)s (toto je %(current)s)",
"LLeMbas Desktop needs a LLeMbas 2.0 server or later.": "LLeMbas Desktop potrebuje server LLeMbas 2.0 alebo novší.",
"Let the server open a terminal here": "Dovoliť serveru otvoriť tu terminál", "Let the server open a terminal here": "Dovoliť serveru otvoriť tu terminál",
"Link again": "Prepojiť znova", "Link again": "Prepojiť znova",
"Link this computer": "Prepojiť tento počítač", "Link this computer": "Prepojiť tento počítač",
"Loading…": "Načítava sa…",
"Looking for a LLeMbas server there…": "Hľadám tam server LLeMbas…", "Looking for a LLeMbas server there…": "Hľadám tam server LLeMbas…",
"Manual — asks first": "Manual — najprv sa opýta", "Manual — asks first": "Manual — najprv sa opýta",
"Maximise": "Maximalizovať", "Maximise": "Maximalizovať",
"Menu": "Ponuka", "Menu": "Ponuka",
"Minimise": "Minimalizovať", "Minimise": "Minimalizovať",
"Mode": "Režim",
"Model": "Model",
"More": "Viac",
"New agent chat": "Nová agentská konverzácia",
"New chat": "Nová konverzácia",
"No agent chat yet. Start one: it works in a folder on this computer.": "Zatiaľ žiadna agentská konverzácia. Začnite ju: pracuje v priečinku na tomto počítači.",
"No chat yet.": "Zatiaľ žiadna konverzácia.",
"No folder yet: add one under Settings, Project folders.": "Zatiaľ žiadny priečinok: pridajte ho v Nastaveniach, Priečinky projektov.",
"No folder yet: the agent can open nothing. Add the folder of a project.": "Zatiaľ žiadny priečinok: agent nemôže nič otvoriť. Pridajte priečinok projektu.", "No folder yet: the agent can open nothing. Add the folder of a project.": "Zatiaľ žiadny priečinok: agent nemôže nič otvoriť. Pridajte priečinok projektu.",
"Open the server": "Otvoriť server", "No model is available to this account.": "Tomuto účtu nie je dostupný žiadny model.",
"No project folder yet: the agent can open nothing on this computer.": "Zatiaľ žiadny priečinok projektu: agent na tomto počítači nemôže nič otvoriť.",
"Not yet": "Ešte nie",
"Notifications": "Upozornenia",
"Only the agent's folders: Settings, Project folders.": "Len priečinky agenta: Nastavenia, Priečinky projektov.",
"Open in the viewer": "Otvoriť v prehliadači súborov",
"Open the page again": "Otvoriť stránku znova",
"Or type an answer": "Alebo napíšte odpoveď",
"Other devices": "Iné zariadenia",
"Page": "Stránka",
"Plan — changes nothing": "Plan — nič nemení", "Plan — changes nothing": "Plan — nič nemení",
"Preview": "Náhľad",
"Project folders": "Priečinky projektov", "Project folders": "Priečinky projektov",
"Read again": "Načítať znova",
"Recommended": "Odporúčané",
"Remove": "Odstrániť", "Remove": "Odstrániť",
"Remove %(dir)s": "Odstrániť %(dir)s", "Remove %(dir)s": "Odstrániť %(dir)s",
"Restore": "Obnoviť", "Restore": "Obnoviť",
"Send": "Poslať",
"Server": "Server", "Server": "Server",
"Sign in there as you would in a browser.": "Prihláste sa tam ako v prehliadači.", "Settings": "Nastavenia",
"Shown by the agent": "Ukázal agent",
"Sign in": "Prihlásiť sa",
"Sign out": "Odhlásiť sa",
"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.",
"Source": "Zdroj",
"Start the agent": "Spustiť agenta", "Start the agent": "Spustiť agenta",
"Stop the agent": "Zastaviť agenta", "Stop the agent": "Zastaviť agenta",
"Stopped.": "Zastavené.",
"Taken in while it ran:": "Prevzaté počas behu:",
"Tell me when a reply or an agent's work is finished while I am elsewhere": "Upozorniť ma, keď je odpoveď alebo práca agenta hotová, kým som inde",
"Terminal": "Terminál", "Terminal": "Terminál",
"The agent asks to run %(tool)s:": "Agent žiada spustiť %(tool)s:",
"The agent asks:": "Agent sa pýta:",
"The agent has finished.": "Agent dokončil prácu.",
"The agent is connected, but may open no folder yet: add one below.": "Agent je pripojený, ale zatiaľ nesmie otvoriť žiadny priečinok: pridajte ho nižšie.", "The agent is connected, but may open no folder yet: add one below.": "Agent je pripojený, ale zatiaľ nesmie otvoriť žiadny priečinok: pridajte ho nižšie.",
"The agent is not connected to the server yet: start it, or link this computer, under Settings.": "Agent ešte nie je pripojený k serveru: spustite ho alebo prepojte tento počítač v Nastaveniach.",
"The agent is off.": "Agent je vypnutý.", "The agent is off.": "Agent je vypnutý.",
"The agent is running: agent chats can use this computer.": "Agent beží: agentské konverzácie môžu používať tento počítač.", "The agent is running: agent chats can use this computer.": "Agent beží: agentské konverzácie môžu používať tento počítač.",
"The agent is starting…": "Agent sa spúšťa…", "The agent is starting…": "Agent sa spúšťa…",
"The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.": "Agent umožňuje agentským konverzáciám na vašom serveri pracovať v projektoch na tomto počítači. Je to vstavané LLeMbas CLI, ktoré beží na pozadí, kým beží aplikácia.", "The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.": "Agent umožňuje agentským konverzáciám na vašom serveri pracovať v projektoch na tomto počítači. Je to vstavané LLeMbas CLI, ktoré beží na pozadí, kým beží aplikácia.",
"The agent stopped: %(why)s. Trying again shortly.": "Agent sa zastavil: %(why)s. O chvíľu to skúsi znova.", "The agent stopped: %(why)s. Trying again shortly.": "Agent sa zastavil: %(why)s. O chvíľu to skúsi znova.",
"The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.": "Aplikácia sa na váš server prihlási raz, vo vašom prehliadači, ako každé zariadenie. Potom môže čítať a písať vaše konverzácie, ako webové rozhranie.",
"The connection to the reply was lost: %(why)s": "Spojenie s odpoveďou sa prerušilo: %(why)s",
"The most an agent chat may do here": "Najviac, čo tu agentská konverzácia smie", "The most an agent chat may do here": "Najviac, čo tu agentská konverzácia smie",
"The reply is ready.": "Odpoveď je hotová.",
"Thinking": "Premýšľa",
"This computer": "Tento počítač",
"This computer is not linked to the server yet.": "Tento počítač ešte nie je prepojený so serverom.", "This computer is not linked to the server yet.": "Tento počítač ešte nie je prepojený so serverom.",
"This file is too big to show here (%(size)s).": "Tento súbor je príliš veľký na zobrazenie (%(size)s).",
"This is not a text file; it cannot be shown here.": "Toto nie je textový súbor; nedá sa tu zobraziť.",
"This server does not offer its files to the app yet.": "Tento server zatiaľ neponúka svoje súbory aplikácii.",
"Thought": "Premýšľal",
"Try again": "Skúsiť znova",
"Waiting for the browser…": "Čaká sa na prehliadač…",
"Waiting for the reply before it": "Čaká na odpoveď pred ňou",
"Waiting for your answer": "Čaká na vašu odpoveď",
"Writing…": "Píše sa…",
"Your answer": "Vaša odpoveď",
"agent off": "agent vypnutý", "agent off": "agent vypnutý",
"agent running": "agent beží", "agent running": "agent beží",
"agent running (service)": "agent beží (služba)", "agent running (service)": "agent beží (služba)",
+104 -60
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" data-theme="moria" data-base="moria">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -7,72 +7,116 @@
<title>LLeMbas</title> <title>LLeMbas</title>
<link rel="stylesheet" href="tokens.css"> <link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="app.css"> <link rel="stylesheet" href="app.css">
<script src="theme.js"></script> <script src="i18n.js"></script>
<script src="i18n.js" defer></script> <script type="module" src="js/main.js"></script>
<script src="app.js" defer></script>
</head> </head>
<body> <body>
<main class="card" aria-labelledby="title"> <div class="app">
<header class="bar" data-tauri-drag-region>
<button class="bar__btn bar__side" id="side-toggle" type="button" aria-label="Chats" title="Chats" aria-expanded="false" aria-controls="side" data-i18n-aria-label data-i18n-title></button>
<img class="bar__leaf" src="logo-mark.svg" alt="" width="18" height="18" data-tauri-drag-region>
<span class="bar__name" data-tauri-drag-region><b>LL</b>e<b>M</b>bas</span>
<span class="bar__host" id="host" data-tauri-drag-region></span>
<button class="bar__update" id="update" type="button" hidden>update</button>
<button class="bar__agent" id="agent" type="button" title="Agent">
<span class="dot" aria-hidden="true"></span><span id="agent-text">agent off</span>
</button>
<button class="bar__btn" id="menu" type="button" aria-label="Menu" title="Menu" data-i18n-aria-label data-i18n-title></button>
<span class="bar__sep" aria-hidden="true"></span>
<button class="bar__btn" id="minimize" type="button" aria-label="Minimise" title="Minimise" data-i18n-aria-label data-i18n-title></button>
<button class="bar__btn" id="maximize" type="button" aria-label="Maximise" title="Maximise" data-i18n-aria-label data-i18n-title></button>
<button class="bar__btn bar__btn--close" id="close" type="button" aria-label="Close" title="Close (LLeMbas stays in the tray)" data-i18n-aria-label data-i18n-title></button>
</header>
<div class="setup" id="setup" hidden>
<main class="card" aria-labelledby="setup-title">
<header class="brand"> <header class="brand">
<img src="logo-mark.svg" alt="" width="40" height="40"> <img src="logo-mark.svg" alt="" width="40" height="40">
<div> <div>
<h1 id="title"><b>LL</b>e<b>M</b>bas</h1> <h1 id="setup-title"><b>LL</b>e<b>M</b>bas</h1>
<p class="muted">Desktop <span id="version"></span></p> <p class="muted">Desktop <span id="setup-version"></span></p>
</div> </div>
</header> </header>
<div class="setup__body" id="setup-body"></div>
<section aria-labelledby="server-h">
<h2 id="server-h" data-i18n>Server</h2>
<form id="server-form" class="field" novalidate>
<label for="address" data-i18n>Address of your LLeMbas server</label>
<div class="row">
<input id="address" name="address" type="text" inputmode="url" autocomplete="url"
placeholder="https://ai.example" spellcheck="false" required>
<button type="submit" class="btn btn--primary" data-i18n>Connect</button>
</div>
<p class="hint" id="server-hint" aria-live="polite" data-i18n>Sign in there as you would in a browser.</p>
</form>
</section>
<section aria-labelledby="agent-h" id="agent-section" hidden>
<h2 id="agent-h" data-i18n>Agent</h2>
<p class="muted" data-i18n>The agent lets agent chats on your server work in this computer's projects.
It is the LLeMbas CLI, built in, running in the background while the app runs.</p>
<p class="status" id="agent-status" aria-live="polite"></p>
<div class="row row--buttons">
<button type="button" class="btn" id="link" data-i18n>Link this computer</button>
<button type="button" class="btn" id="toggle" data-i18n>Start the agent</button>
<button type="button" class="btn btn--primary" id="open" data-i18n>Open the server</button>
</div>
</section>
<section aria-labelledby="folders-h" id="folders-section" hidden>
<h2 id="folders-h" data-i18n>Project folders</h2>
<p class="muted" data-i18n>Agent chats from your server can work in these folders and anything inside
them — nowhere else on this computer. Start one in the server's Agents tab, on “Local”.</p>
<ul class="folders" id="folders"></ul>
<p class="status" id="folders-status" aria-live="polite"></p>
<div class="row row--buttons">
<button type="button" class="btn btn--primary" id="add-folder" data-i18n>Add folder…</button>
</div>
<div class="settings-grid">
<div class="field">
<label for="max-mode" data-i18n>The most an agent chat may do here</label>
<select id="max-mode">
<option value="manual" data-i18n>Manual — asks first</option>
<option value="edit" data-i18n>Edit — asks before commands</option>
<option value="auto" data-i18n>Auto — asks nothing</option>
<option value="plan" data-i18n>Plan — changes nothing</option>
</select>
<p class="hint" data-i18n>A chat asking for more is lowered to this.</p>
</div>
<div class="field">
<span class="label" data-i18n>Terminal</span>
<label class="check"><input type="checkbox" id="terminal"> <span data-i18n>Let the server open a terminal here</span></label>
<p class="hint" data-i18n>A shell as you, in one of these folders.</p>
</div>
</div>
</section>
</main> </main>
</div>
<div class="shell" id="shell" hidden data-side="false" data-viewer="false">
<nav class="side" id="side" aria-label="Chats" data-i18n-aria-label data-local="true" data-server="false">
<div class="switch" role="tablist">
<button type="button" role="tab" data-kind="agent" aria-selected="true" data-i18n>Agent</button>
<button type="button" role="tab" data-kind="chat" aria-selected="false" data-i18n>Chats</button>
</div>
<button type="button" class="btn btn--primary side__new" id="new-chat-btn"><span data-i18n>New agent chat</span></button>
<ul class="list" id="chat-list"></ul>
<section class="pane" id="pane-local" data-open="true" aria-labelledby="pane-local-h">
<div class="pane__head">
<button type="button" class="pane__toggle" aria-expanded="true" id="pane-local-h"><span data-i18n>This computer</span></button>
<button type="button" class="pane__btn" id="local-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
</div>
<div class="pane__body">
<div class="pane__where" id="local-where"></div>
<div class="pane__tree" id="local-tree"></div>
</div>
</section>
<section class="pane" id="pane-server" data-open="false" aria-labelledby="pane-server-h">
<div class="pane__head">
<button type="button" class="pane__toggle" aria-expanded="false" id="pane-server-h"><span data-i18n>Server files</span></button>
<button type="button" class="pane__btn" id="server-refresh" aria-label="Read again" title="Read again" data-i18n-aria-label data-i18n-title></button>
</div>
<div class="pane__body">
<div class="pane__tree" id="server-tree"></div>
</div>
</section>
<button type="button" class="side__settings" id="settings-btn"></button>
</nav>
<div class="scrim" id="scrim" aria-hidden="true"></div>
<main class="main" id="main" aria-labelledby="chat-title">
<header class="head">
<h1 class="head__title" id="chat-title"></h1>
<div class="head__place" id="chat-place"></div>
<div class="head__tools" id="chat-tools"></div>
</header>
<div class="thread" id="thread">
<p class="thread__note is-error" id="thread-note" hidden></p>
<div class="new" id="new-chat" hidden></div>
<div class="messages" id="messages"></div>
</div>
<div class="cards" id="cards" hidden></div>
<form class="composer" id="composer" hidden data-running="false">
<textarea id="composer-text" rows="1" placeholder="Message" aria-label="Message" data-i18n-placeholder data-i18n-aria-label></textarea>
<div class="composer__actions">
<button type="button" class="btn" id="stop" hidden data-i18n>Stop</button>
<button type="submit" class="btn btn--primary" id="send" data-i18n>Send</button>
</div>
</form>
</main>
<section class="settings" id="settings" hidden aria-labelledby="settings-title">
<header class="head">
<h1 class="head__title" id="settings-title" data-i18n>Settings</h1>
<div class="head__place"></div>
<div class="head__tools"><button type="button" class="pane__btn" id="settings-close" aria-label="Close" title="Close" data-i18n-aria-label data-i18n-title></button></div>
</header>
<div class="settings__body" id="settings-body"></div>
</section>
<aside class="viewer" id="viewer" hidden aria-labelledby="viewer-name">
<header class="viewer__head">
<div class="viewer__id">
<h2 class="viewer__name" id="viewer-name"></h2>
<p class="viewer__where" id="viewer-where"></p>
</div>
<div class="viewer__modes" id="viewer-modes"></div>
<button type="button" class="pane__btn" id="viewer-close" aria-label="Close" title="Close" data-i18n-aria-label data-i18n-title></button>
</header>
<div class="viewer__body" id="viewer-body"></div>
</aside>
</div>
</div>
</body> </body>
</html> </html>
+70
View File
@@ -0,0 +1,70 @@
// The page's one way out: Tauri's commands (src-tauri/src/lib.rs). The page holds no token and
// makes no request of its own -- the content security policy allows it none but the bridge. The
// server is reached through `api` and `follow`, which Rust sends with the app's sign-in.
const tauri = window.__TAURI__
export const invoke = tauri.core.invoke
export const listen = tauri.event.listen
export const win = tauri.window.getCurrentWindow()
/** A request to the server's /api/v2: `path` from there on. Rejects with {status, detail}. */
export function api(method, path, body) {
return invoke("api", { method, path, body: body === undefined ? null : body })
}
export const get = (path) => api("GET", path)
export const post = (path, body) => api("POST", path, body ?? {})
export const patch = (path, body) => api("PATCH", path, body)
export const del = (path) => api("DELETE", path)
/** An error's words, whatever shape it came in. */
export function why(err) {
if (err && typeof err === "object" && "detail" in err) return String(err.detail)
return String(err)
}
// Frames of every followed reply arrive as one event; they are handed on by stream id. A frame
// can arrive before `stream_open` has answered with its id, so frames for an unknown id wait.
const followers = new Map()
const early = new Map()
listen("stream", (e) => {
const { id, event, data } = e.payload
const f = followers.get(id)
if (f) deliver(f, id, event, data)
else {
if (!early.has(id)) early.set(id, [])
early.get(id).push([event, data])
}
})
function deliver(f, id, event, data) {
if (event === "end") {
followers.delete(id)
f.onEnd(data)
} else f.onFrame(event, data)
}
/**
* Follow a reply: `onFrame(event, data)` for each frame, `onEnd(error|null)` once. The handle's
* `close()` stops it (onEnd still comes).
*/
export async function follow(path, { onFrame, onEnd }) {
const id = await invoke("stream_open", { path })
const f = { onFrame, onEnd }
followers.set(id, f)
for (const [event, data] of early.get(id) || []) {
if (!followers.has(id)) break
deliver(f, id, event, data)
}
early.delete(id)
return {
id,
close() {
invoke("stream_close", { id })
},
}
}
/** A web link, in the system's browser. */
export function openLink(url) {
return invoke("open_external", { url }).catch(() => {})
}
+659
View File
@@ -0,0 +1,659 @@
// One chat: its transcript, the reply being written (followed as the server streams it), the
// cards that wait for an answer, and the composer. And the form a new chat starts from -- an
// agent chat on this computer, in one of the agent's folders, or a chat on the server.
//
// The transcript is the server's /api/v2 serialisation (tests/fixtures/api_v2 in the server's
// repository is the contract): a person's message is their text as typed; a reply is its steps --
// thinking, text (markdown, model output), tool calls, messages taken in while it ran.
import { $, h, clear, append } from "./dom.js"
import { api, get, post, patch, follow, why, invoke } from "./bridge.js"
import { markdown } from "./markdown.js"
import { openViewer, showKind } from "./viewer.js"
import { readLocal, readServer } from "./explorer.js"
const MODES = ["manual", "edit", "auto", "plan"]
let ctx = null // set by initChat: { status(), thisDevices(), models(), me(), changed(chat) }
let open = null // { chat, live, more, first }
export function openChatId() {
return open?.chat?.id || null
}
// --- Reading -----------------------------------------------------------------------------------
function onThisComputer(chat) {
return !!chat?.device && ctx.thisDevices().includes(chat.device.token_id)
}
function nearBottom() {
const th = $("thread")
return th.scrollHeight - th.scrollTop - th.clientHeight < 80
}
function toBottom(force) {
const th = $("thread")
if (force || nearBottom()) th.scrollTop = th.scrollHeight
}
function userMessage(m) {
return h(
"article",
{ class: `msg msg--user${m.queued ? " is-queued" : ""}${m.machine ? " is-machine" : ""}`, dataset: { id: m.id } },
h("div", { class: "msg__text", text: m.text || "" }),
m.attachments?.length ? h("ul", { class: "msg__files" }, m.attachments.map((a) => h("li", { text: a.name }))) : null,
m.queued ? h("p", { class: "msg__note", text: t("Waiting for the reply before it") }) : null,
)
}
function metricsLine(x) {
if (!x) return null
const parts = []
if (x.total_tokens) parts.push(t("%(n)s tokens", { n: x.total_tokens.toLocaleString() }))
if (x.tok_s) parts.push(`${x.tok_s} tok/s`)
if (x.elapsed_ms) parts.push(`${(x.elapsed_ms / 1000).toFixed(1)} s`)
if (x.context_limit && x.percent !== undefined) parts.push(`${x.percent} % ctx`)
return parts.length ? h("p", { class: "msg__metrics", text: parts.join(" · ") }) : null
}
function assistantMessage(m, chat) {
const el = h("article", { class: "msg msg--assistant", dataset: { id: m.id } })
const steps = h("div", { class: "msg__steps" })
renderSteps(steps, m.steps || [], chat)
el.append(steps)
if (m.error) el.append(h("p", { class: "msg__note is-error", text: m.error }))
if (m.stopped) el.append(h("p", { class: "msg__note", text: t("Stopped.") }))
const meta = metricsLine(m.metrics)
if (meta || m.model) {
el.append(h("footer", { class: "msg__foot" }, m.model ? h("span", { class: "chip", text: m.model }) : null, meta))
}
return el
}
function renderSteps(box, steps, chat) {
clear(box)
for (const s of steps) box.append(step(s, chat))
}
function step(s, chat) {
switch (s.kind) {
case "thinking":
return h(
"details",
{ class: "step step--thinking", open: s.open || null },
h("summary", null, t("Thought"), s.label ? h("span", { class: "muted", text: ` ${s.label}` }) : null),
h("div", { class: "step__pre", text: s.text || "" }),
)
case "text":
return h("div", { class: "step step--text md" }, markdown(s.markdown || ""))
case "tools":
return h("div", { class: "step step--tools" }, (s.calls || []).map((c) => toolCall(c, chat)))
case "steer":
return h(
"div",
{ class: "step step--steer" },
h("p", { class: "muted", text: t("Taken in while it ran:") }),
(s.messages || []).map((m) => h("p", { class: "steer__text", text: m.text })),
)
default:
return h("div", { class: "step" })
}
}
function shownPath(call) {
return call.show?.path || call.args?.path || call.path || ""
}
function toolCall(c, chat) {
const block = c.block || {}
const failed = c.status === "error" || !!c.error
const body = h("div", { class: "call__body" })
if (c.purpose) body.append(h("p", { class: "muted", text: c.purpose }))
if (c.args?.command) body.append(h("pre", { class: "call__pre", text: c.args.command }))
if (c.diff) body.append(h("pre", { class: "call__pre call__diff", text: typeof c.diff === "string" ? c.diff : JSON.stringify(c.diff, null, 2) }))
if (c.text) body.append(h("pre", { class: "call__pre", text: c.text }))
if (c.error) body.append(h("p", { class: "msg__note is-error", text: typeof c.error === "string" ? c.error : JSON.stringify(c.error) }))
if (c.truncated) body.append(h("p", { class: "muted", text: t("Cut short here; the whole of it is in the web interface.") }))
const isShow = c.name === "show" && !failed && shownPath(c)
const summary = h(
"summary",
{ class: "call__head" },
h("span", { class: `dot${failed ? " is-error" : c.status === "running" ? " is-busy" : " is-ok"}`, "aria-hidden": "true" }),
h("span", { class: "call__title", title: block.title || c.name, text: block.title || c.name || "" }),
h("span", { class: "call__chips" }, (block.chips || []).map((x) => h("span", { class: "chip", text: x }))),
)
const el = h("details", { class: `call${failed ? " is-failed" : ""}` }, summary, body)
if (isShow) {
el.append(h("button", { type: "button", class: "btn btn--sm call__open", text: t("Open in the viewer"), onclick: () => openShown(c, chat) }))
}
return el
}
/** A file the agent showed: from this computer when the chat runs here, else from the server. */
async function openShown(call, chat) {
const path = shownPath(call)
const kind = call.show?.kind
const { language } = showKind(path)
const line = call.show?.line || call.args?.line || 0
const base = { name: path.split("/").pop(), path, kind, language: call.show?.language || language, line }
try {
if (onThisComputer(chat) && chat.device.directory) {
const dir = chat.device.directory.replace(/\/+$/, "")
const rel = path.startsWith(`${dir}/`) ? path.slice(dir.length + 1) : path
const f = await readLocal(dir, rel)
openViewer({ ...base, where: `${t("Shown by the agent")} · ${t("This computer")}`, text: f.text, withheld: f.withheld, size: f.size })
} else if (call.show?.shown_id) {
// The copy a device sent (server: `GET /api/chats/{chat}/shown/{id}` is the web UI's; the
// app's door does not have it yet -- see SERVER_FILES in explorer.js).
const f = await get(`/chats/${encodeURIComponent(chat.id)}/shown/${encodeURIComponent(call.show.shown_id)}`)
openViewer({ ...base, where: t("Shown by the agent"), text: f.text })
} else {
const f = await readServer(path)
openViewer({ ...base, where: `${t("Shown by the agent")} · ${t("Server")}`, text: f.text, withheld: f.withheld, size: f.size })
}
} catch (err) {
openViewer({ ...base, where: t("Shown by the agent"), error: why(err) })
}
}
// --- The reply being written ---------------------------------------------------------------------
class Live {
constructor(chat, messageId) {
this.chat = chat
this.id = messageId
this.steps = h("div", { class: "msg__steps" })
this.tail = h("div", { class: "step step--text md msg__tail" })
this.thinking = h("p", { class: "msg__thinking muted" })
this.status = h("p", { class: "msg__status muted", text: t("Writing…") })
this.jobs = h("ul", { class: "msg__jobs", hidden: true })
this.el = h("article", { class: "msg msg--assistant is-live", dataset: { id: messageId } }, this.steps, this.thinking, this.tail, this.jobs, this.status)
this.done = false
this.handle = null
}
async start() {
try {
this.handle = await follow(`/chats/${encodeURIComponent(this.chat.id)}/messages/${encodeURIComponent(this.id)}/stream`, {
onFrame: (event, data) => this.frame(event, data),
onEnd: (err) => this.end(err),
})
} catch (err) {
this.end(err)
}
}
stop() {
this.done = true
this.handle?.close()
}
frame(event, data) {
if (this.done) return
switch (event) {
case "steps":
renderSteps(this.steps, data.steps || [], this.chat)
this.tailed(data.tail)
break
case "tail":
this.tailed(data)
break
case "status":
this.status.textContent = data.text || t("Writing…")
break
case "ask":
renderCards(this.chat, data.cards || [])
break
case "jobs":
this.jobs.hidden = !(data.jobs || []).length
append(
clear(this.jobs),
(data.jobs || []).map((j) =>
h("li", { class: `job job--${j.state}` }, h("span", { class: "chip", text: j.state }), h("span", { text: j.description || j.command || j.agent || "" })),
),
)
break
case "steered":
for (const id of data.ids || []) document.querySelector(`.msg--user[data-id="${CSS.escape(id)}"]`)?.classList.remove("is-queued")
break
case "done":
this.done = true
this.el.replaceWith(assistantMessage(data.message, this.chat))
renderCards(this.chat, [])
if (data.chat?.title) setTitle(data.chat.title)
ctx.changed(this.chat)
if (open?.live === this) open.live = null
setRunning(false)
// A message queued behind this reply has one of its own now: read the chat again.
if (data.next) reload()
break
default:
break
}
toBottom(false)
}
tailed(tail) {
if (!tail) return
this.thinking.textContent = tail.thinking ? `${t("Thinking")} ${tail.label || ""}…` : ""
this.thinking.hidden = !tail.thinking
clear(this.tail).append(markdown(tail.text || ""))
}
end(err) {
if (this.done) return
this.done = true
if (open?.live === this) open.live = null
setRunning(false)
if (err) {
this.status.textContent = t("The connection to the reply was lost: %(why)s", { why: why(err) })
this.status.classList.add("is-error")
this.el.append(h("button", { type: "button", class: "btn btn--sm", text: t("Read again"), onclick: reload }))
} else reload()
}
}
function setRunning(on) {
$("send").hidden = on && !$("composer-text").value.trim()
$("stop").hidden = !on
$("composer").dataset.running = String(on)
}
function follower(chat, messageId) {
if (open?.live) open.live.stop()
const live = new Live(chat, messageId)
open.live = live
$("messages").append(live.el)
setRunning(true)
live.start()
toBottom(true)
}
// --- Cards ----------------------------------------------------------------------------------------
function renderCards(chat, cards) {
const box = clear($("cards"))
box.hidden = !cards.length
for (const card of cards) box.append(cardOf(chat, card))
}
function cardOf(chat, card) {
const answers = { choices: {}, texts: {} }
const decides = card.items.some((i) => i.kind === "approval" || i.kind === "plan")
const el = h("section", { class: `ask ask--${card.kind}`, "aria-label": t("Waiting for your answer") })
if (card.why) el.append(h("p", { class: "muted", text: card.why }))
for (const item of card.items) {
const part = h("div", { class: "ask__item" })
if (item.header) part.append(h("p", { class: "ask__header", text: item.header }))
if (item.kind === "approval") {
part.append(h("p", { class: "ask__title", text: item.tool ? t("The agent asks to run %(tool)s:", { tool: item.tool }) : t("The agent asks:") }))
if (item.editable) {
const edit = h("textarea", { class: "ask__edit", rows: "2", "aria-label": t("Command"), value: item.title })
edit.addEventListener("input", () => {
if (edit.value !== item.title) answers.texts[item.key] = edit.value
else delete answers.texts[item.key]
})
part.append(edit)
} else part.append(h("pre", { class: "call__pre", text: item.title }))
} else if (item.kind === "plan" && item.plan) {
part.append(h("p", { class: "ask__title", text: item.plan.title || item.title }), planList(item.plan))
} else {
part.append(h("p", { class: "ask__title", text: item.title }))
}
if (item.detail) part.append(h("p", { class: "muted", text: item.detail }))
if (item.purpose) part.append(h("p", { class: "muted", text: item.purpose }))
if (item.options?.length) {
const picked = new Set()
const opts = h("div", { class: "ask__options", role: item.multiple ? "group" : "radiogroup" })
for (const o of item.options) {
const b = h(
"button",
{ type: "button", class: "opt", "aria-pressed": "false" },
h("span", { class: "opt__label", text: o.label }),
o.description ? h("span", { class: "opt__desc", text: o.description }) : null,
o.recommended ? h("span", { class: "chip", text: t("Recommended") }) : null,
)
b.addEventListener("click", () => {
if (!item.multiple) {
picked.clear()
for (const x of opts.children) x.setAttribute("aria-pressed", "false")
}
if (picked.has(o.label)) picked.delete(o.label)
else picked.add(o.label)
b.setAttribute("aria-pressed", String(picked.has(o.label)))
answers.choices[item.key] = [...picked]
})
opts.append(b)
}
part.append(opts)
}
if (item.free_text && item.kind === "question") {
const typed = h("input", { type: "text", class: "ask__text", placeholder: t("Or type an answer"), "aria-label": t("Your answer") })
typed.addEventListener("input", () => {
answers.texts[item.key] = typed.value
})
part.append(typed)
}
el.append(part)
}
const said = h("p", { class: "ask__said", "aria-live": "polite" })
const send = async (verdict) => {
for (const b of el.querySelectorAll(".ask__actions button")) b.disabled = true
try {
await post(`/chats/${encodeURIComponent(chat.id)}/interactions/${encodeURIComponent(card.id)}`, {
verdict,
choices: answers.choices,
texts: answers.texts,
})
el.remove()
if (!$("cards").children.length) $("cards").hidden = true
} catch (err) {
if (err?.status === 409) {
el.remove()
reload()
return
}
said.textContent = why(err)
for (const b of el.querySelectorAll(".ask__actions button")) b.disabled = false
}
}
const actions = h("div", { class: "ask__actions" })
if (decides) {
const isPlan = card.items.some((i) => i.kind === "plan")
append(
actions,
h("button", { type: "button", class: "btn btn--primary", text: isPlan ? t("Carry it out") : t("Allow"), onclick: () => send("allow") }),
isPlan ? null : h("button", { type: "button", class: "btn", text: t("Always allow"), onclick: () => send("allow_always") }),
h("button", { type: "button", class: "btn", text: isPlan ? t("Not yet") : t("Deny"), onclick: () => send("deny") }),
)
} else {
actions.append(h("button", { type: "button", class: "btn btn--primary", text: t("Answer"), onclick: () => send("") }))
}
el.append(actions, said)
return el
}
function planList(plan) {
return h(
"ol",
{ class: "plan" },
(plan.phases || []).map((p) =>
h("li", null, h("span", { class: "plan__phase", text: p.title }), h("ul", null, (p.tasks || []).map((task) => h("li", { class: `plan__task is-${task.status}`, text: task.text })))),
),
)
}
// --- Opening a chat --------------------------------------------------------------------------------
function setTitle(title) {
$("chat-title").textContent = title || t("New chat")
$("chat-title").title = title || ""
}
function renderHead(chat) {
setTitle(chat.title)
const place = clear($("chat-place"))
const tools = clear($("chat-tools"))
if (chat.kind === "agent" && chat.device) {
const here = onThisComputer(chat)
place.append(
h("span", { class: `chip${here ? " chip--leaf" : ""}`, text: here ? t("This computer") : t("Another device") }),
h("span", { class: "head__dir", title: chat.device.directory }, h("bdi", { text: chat.device.directory })),
)
const mode = h(
"select",
{ class: "head__mode", "aria-label": t("Mode"), onchange: (e) => patch(`/chats/${encodeURIComponent(chat.id)}`, { mode: e.target.value }).catch((err) => alertIn(why(err))) },
MODES.map((m) => h("option", { value: m, text: modeName(m), selected: chat.mode === m })),
)
tools.append(mode)
} else if (chat.model) {
place.append(h("span", { class: "chip", text: chat.model }))
}
}
function modeName(m) {
return { manual: "Manual", edit: "Edit", auto: "Auto", plan: "Plan" }[m] || m
}
function alertIn(text) {
const box = $("thread-note")
box.textContent = text
box.hidden = !text
}
/** Open a chat by id: its newest page, and the reply being written, if one is. */
export async function openChat(id) {
if (open?.live) open.live.stop()
open = { chat: { id }, live: null, more: false }
invoke("set_open_chat", { chat: id })
showComposer(true)
$("new-chat").hidden = true
const list = clear($("messages"))
renderCards({ id }, [])
alertIn("")
list.append(h("p", { class: "muted thread__loading", text: t("Loading…") }))
let r
try {
r = await get(`/chats/${encodeURIComponent(id)}`)
} catch (err) {
clear(list)
alertIn(why(err))
return
}
if (open?.chat?.id !== id) return
open.chat = r.chat
open.more = r.more
renderHead(r.chat)
clear(list)
if (r.more) list.append(earlier())
for (const m of r.messages) {
if (m.id === r.live) continue
list.append(m.role === "user" ? userMessage(m) : assistantMessage(m, r.chat))
}
ctx.opened(r.chat)
if (r.live) follower(r.chat, r.live)
else setRunning(false)
toBottom(true)
$("composer-text").focus()
}
function earlier() {
return h("button", {
type: "button",
class: "btn btn--sm thread__earlier",
text: t("Earlier messages"),
onclick: async (e) => {
const first = $("messages").querySelector(".msg")?.dataset.id
if (!first || !open) return
e.target.disabled = true
try {
const r = await get(`/chats/${encodeURIComponent(open.chat.id)}/messages?before=${encodeURIComponent(first)}`)
const th = $("thread")
const from = th.scrollHeight
const frag = document.createDocumentFragment()
if (r.more) frag.append(earlier())
for (const m of r.messages) frag.append(m.role === "user" ? userMessage(m) : assistantMessage(m, open.chat))
e.target.replaceWith(frag)
th.scrollTop += th.scrollHeight - from
} catch (err) {
e.target.disabled = false
alertIn(why(err))
}
},
})
}
function reload() {
if (open?.chat?.id) openChat(open.chat.id)
}
export function closeChat() {
if (open?.live) open.live.stop()
open = null
invoke("set_open_chat", { chat: null })
}
// --- A new chat ------------------------------------------------------------------------------------
let draft = null // { kind }
/** The form a new chat starts from: `agent` (on this computer) or `chat` (on the server). */
export function newChat(kind) {
closeChat()
draft = { kind }
clear($("messages"))
renderCards({ id: "" }, [])
alertIn("")
setTitle(kind === "agent" ? t("New agent chat") : t("New chat"))
clear($("chat-place"))
clear($("chat-tools"))
const form = clear($("new-chat"))
form.hidden = false
const s = ctx.status()
const models = ctx.models()
const modelField = field(
t("Model"),
h(
"select",
{ id: "new-model" },
models.map((m) => h("option", { value: m.id, text: m.name, selected: m.default })),
),
models.length ? "" : t("No model is available to this account."),
)
if (kind === "agent") {
const roots = s.remote?.enabled ? s.remote.roots : []
const devices = ctx.thisDevices()
form.append(
h("p", { class: "muted", text: t("An agent chat works in a folder on this computer, through the agent built into this app.") }),
)
if (!devices.length) {
form.append(
h("p", { class: "new__warn", text: t("The agent is not connected to the server yet: start it, or link this computer, under Settings.") }),
)
}
form.append(
h(
"div",
{ class: "fields" },
field(
t("Folder"),
h("select", { id: "new-dir" }, roots.map((r) => h("option", { value: r, text: r }))),
roots.length ? t("Only the agent's folders: Settings, Project folders.") : t("No folder yet: add one under Settings, Project folders."),
),
field(
t("Mode"),
h(
"select",
{ id: "new-mode" },
MODES.map((m) => h("option", { value: m, text: modeName(m), selected: m === "edit" })),
),
t("A chat asking for more than the folder allows is lowered to it."),
),
modelField,
),
)
} else {
form.append(h("div", { class: "fields" }, modelField))
}
showComposer(true)
setRunning(false)
invoke("set_open_chat", { chat: null })
$("composer-text").focus()
}
/** A field: label, control, hint -- always all three, so a row of them lines up. */
function field(label, control, hint) {
const id = control.id || `f${Math.random().toString(36).slice(2)}`
control.id = id
return h("div", { class: "field" }, h("label", { for: id, text: label }), control, h("p", { class: "hint", text: hint || "" }))
}
async function createChat(text) {
const body = { text }
const model = $("new-model")?.value
if (model) body.model = model
if (draft.kind === "agent") {
const device = ctx.thisDevices()[0]
const dir = $("new-dir")?.value
if (!device) throw { detail: t("The agent is not connected to the server yet: start it, or link this computer, under Settings.") }
if (!dir) throw { detail: t("No folder yet: add one under Settings, Project folders.") }
Object.assign(body, { kind: "agent", device, directory: dir, mode: $("new-mode")?.value || "edit" })
}
const r = await post("/chats", body)
draft = null
$("new-chat").hidden = true
open = { chat: r.chat, live: null, more: false }
invoke("set_open_chat", { chat: r.chat.id })
renderHead(r.chat)
clear($("messages"))
if (r.message) $("messages").append(userMessage(r.message))
ctx.changed(r.chat)
ctx.opened(r.chat)
if (r.reply) follower(r.chat, r.reply.id)
}
// --- The composer ------------------------------------------------------------------------------------
function showComposer(on) {
$("composer").hidden = !on
}
async function submit() {
const box = $("composer-text")
const text = box.value.trim()
if (!text) return
$("send").disabled = true
alertIn("")
try {
if (draft) await createChat(text)
else if (open?.chat?.id) {
const r = await post(`/chats/${encodeURIComponent(open.chat.id)}/messages`, { text })
if (r.message) $("messages").append(userMessage(r.message))
if (r.reply) follower(open.chat, r.reply.id)
}
box.value = ""
grow()
toBottom(true)
} catch (err) {
alertIn(why(err))
} finally {
$("send").disabled = false
setRunning(!!open?.live)
}
}
function grow() {
const box = $("composer-text")
box.style.height = "auto"
box.style.height = `${Math.min(box.scrollHeight, window.innerHeight * 0.4)}px`
}
export function initChat(context) {
ctx = context
$("composer").addEventListener("submit", (e) => {
e.preventDefault()
submit()
})
$("composer-text").addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
e.preventDefault()
submit()
}
})
$("composer-text").addEventListener("input", () => {
grow()
if (open?.live) $("send").hidden = !$("composer-text").value.trim()
})
$("stop").addEventListener("click", async () => {
const live = open?.live
if (!live) return
try {
await api("POST", `/chats/${encodeURIComponent(live.chat.id)}/messages/${encodeURIComponent(live.id)}/stop`)
} catch (err) {
alertIn(why(err))
}
})
showComposer(false)
}
+104
View File
@@ -0,0 +1,104 @@
// Source in the viewer: CodeMirror, read-only, from the server's own vendored build (ui/vendor,
// copied and pinned by scripts/fetch-vendor.sh). The look is the server's editor's: colours from
// the theme's tokens, never CodeMirror's own. Loaded the first time a file is shown.
let cm = null
async function load() {
if (cm) return cm
const [state, view, language, search, commands, highlight] = await Promise.all([
import("../vendor/cm-state.mjs"),
import("../vendor/cm-view.mjs"),
import("../vendor/cm-language.mjs"),
import("../vendor/cm-search.mjs"),
import("../vendor/cm-commands.mjs"),
import("../vendor/lezer-highlight.mjs"),
])
const { EditorView } = view
const { HighlightStyle, syntaxHighlighting } = language
const { tags } = highlight
// As the server's static/js/editor.js has them.
const look = EditorView.theme({
"&": { color: "var(--ink)", backgroundColor: "var(--code-bg)", fontSize: "var(--text-sm)", height: "100%" },
".cm-scroller": { fontFamily: "var(--font-mono)", lineHeight: "var(--leading-normal)" },
".cm-content": { caretColor: "var(--accent)" },
".cm-gutters": { backgroundColor: "var(--code-bg)", color: "var(--ink-faint)", border: "none" },
".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "var(--surface-hover)" },
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection": { backgroundColor: "var(--selection)" },
"&.cm-focused": { outline: "none" },
".cm-panels": { backgroundColor: "var(--surface)", color: "var(--ink)" },
".cm-searchMatch": { backgroundColor: "var(--accent-soft)" },
})
const colours = HighlightStyle.define([
{ tag: [tags.keyword, tags.operatorKeyword, tags.modifier], color: "var(--accent)" },
{ tag: [tags.string, tags.special(tags.string)], color: "var(--leaf)" },
{ tag: [tags.comment], color: "var(--ink-faint)", fontStyle: "italic" },
{ tag: [tags.number, tags.bool, tags.null, tags.atom], color: "var(--warning)" },
{ tag: [tags.function(tags.variableName), tags.definition(tags.variableName)], color: "var(--ink)", fontWeight: "600" },
{ tag: [tags.heading], color: "var(--accent)", fontWeight: "700" },
{ tag: [tags.link, tags.url], color: "var(--accent)", textDecoration: "underline" },
{ tag: [tags.tagName, tags.typeName, tags.className], color: "var(--accent-hover)" },
{ tag: [tags.invalid], color: "var(--danger)" },
])
cm = {
state,
view,
base: [
view.lineNumbers(),
view.highlightActiveLine(),
view.highlightActiveLineGutter(),
view.drawSelection(),
view.keymap.of([...search.searchKeymap, ...commands.defaultKeymap]),
search.highlightSelectionMatches(),
state.EditorState.readOnly.of(true),
view.EditorView.editable.of(false),
view.EditorView.lineWrapping,
look,
syntaxHighlighting(colours),
],
}
return cm
}
/** The language support for a name as `show` gives it; nothing for one without support. */
async function language(name) {
try {
switch (name) {
case "markdown":
return (await import("../vendor/cm-lang-markdown.mjs")).markdown()
case "python":
return (await import("../vendor/cm-lang-python.mjs")).python()
case "javascript":
case "jsx":
return (await import("../vendor/cm-lang-javascript.mjs")).javascript({ jsx: name === "jsx" })
case "typescript":
case "tsx":
return (await import("../vendor/cm-lang-javascript.mjs")).javascript({ typescript: true, jsx: name === "tsx" })
case "html":
return (await import("../vendor/cm-lang-html.mjs")).html()
case "css":
case "scss":
return (await import("../vendor/cm-lang-css.mjs")).css()
case "json":
return (await import("../vendor/cm-lang-json.mjs")).json()
default:
return []
}
} catch {
return []
}
}
/** Show `text` in `parent`, read-only, at `line` when given. Returns something to `destroy()`. */
export async function codeView(parent, text, { lang = "", line = 0 } = {}) {
const { state, view, base } = await load()
const ed = new view.EditorView({
parent,
state: state.EditorState.create({ doc: text, extensions: [base, await language(lang)] }),
})
if (line > 0 && line <= ed.state.doc.lines) {
const at = ed.state.doc.line(line).from
ed.dispatch({ selection: { anchor: at }, effects: view.EditorView.scrollIntoView(at, { y: "center" }) })
}
return ed
}
+114
View File
@@ -0,0 +1,114 @@
// Building the page: elements made in code, text always as text. Nothing here ever parses a
// string as HTML -- what the server sends (a reply, a file, a title) is model output or the
// person's own words, and is set with textContent, never innerHTML.
export const $ = (id) => document.getElementById(id)
/**
* h("button", { class: "btn", onclick: fn, "aria-label": "…" }, "text", child, [more])
* Attributes: `class`, `text`, `hidden`, `on<event>` (a function), `dataset` (an object), any
* other as an attribute (false/null/undefined leave it out, true sets it empty).
*/
export function h(tag, attrs, ...children) {
const el = document.createElement(tag)
for (const [key, value] of Object.entries(attrs || {})) {
if (value === null || value === undefined || value === false) continue
if (key === "class") el.className = value
else if (key === "text") el.textContent = value
else if (key === "dataset") Object.assign(el.dataset, value)
else if (key.startsWith("on") && typeof value === "function") el.addEventListener(key.slice(2), value)
else if (key === "hidden") el.hidden = !!value
else if (key === "value") el.value = value
else if (key === "checked") el.checked = !!value
else if (key === "disabled") el.disabled = !!value
else el.setAttribute(key, value === true ? "" : String(value))
}
append(el, children)
return el
}
export function append(el, children) {
for (const c of children.flat(Infinity)) {
if (c === null || c === undefined || c === false) continue
el.append(c instanceof Node ? c : document.createTextNode(String(c)))
}
return el
}
export function clear(el) {
el.replaceChildren()
return el
}
/** An inline SVG icon from a path list, drawn in currentColor. */
export function icon(d, label) {
const ns = "http://www.w3.org/2000/svg"
const svg = document.createElementNS(ns, "svg")
svg.setAttribute("viewBox", "0 0 16 16")
svg.setAttribute("aria-hidden", "true")
svg.classList.add("icon")
for (const one of [].concat(d)) {
const p = document.createElementNS(ns, "path")
p.setAttribute("d", one)
p.setAttribute("fill-rule", "evenodd")
svg.append(p)
}
if (label) {
svg.removeAttribute("aria-hidden")
svg.setAttribute("role", "img")
svg.setAttribute("aria-label", label)
}
return svg
}
export const ICONS = {
plus: "M7.25 3h1.5v4.25H13v1.5H8.75V13h-1.5V8.75H3v-1.5h4.25z",
close: "M4.5 3.4L8 6.9l3.5-3.5 1.1 1.1L9.1 8l3.5 3.5-1.1 1.1L8 9.1l-3.5 3.5-1.1-1.1L6.9 8 3.4 4.5z",
folder: "M1.5 3h5l1.5 1.5h6.5v8.5h-13zM3 6v5.5h10V6z",
file: "M3 1.5h6.5L13 5v9.5H3zM4.5 3v10h7V6H8.5V3z",
chevron: "M6 3.5L10.5 8 6 12.5 4.9 11.4 8.3 8 4.9 4.6z",
menu: "M3 4h10v1.5H3zM3 7.25h10v1.5H3zM3 10.5h10V12H3z",
side: "M2 3h12v10H2zM3.5 4.5v7H6v-7z",
gear: "M7 1.5h2l.4 1.8 1.1.5 1.6-1 1.4 1.4-1 1.6.5 1.1 1.8.4v2l-1.8.4-.5 1.1 1 1.6-1.4 1.4-1.6-1-1.1.5L9 14.5H7l-.4-1.8-1.1-.5-1.6 1-1.4-1.4 1-1.6-.5-1.1-1.8-.4v-2l1.8-.4.5-1.1-1-1.6 1.4-1.4 1.6 1 1.1-.5zM8 5.75a2.25 2.25 0 100 4.5 2.25 2.25 0 000-4.5z",
refresh: "M8 2.5a5.5 5.5 0 015.2 3.7H11.5v1.5H15V4.2h-1.5v1A7 7 0 001 8h1.5A5.5 5.5 0 018 2.5zM2.5 11.8v-1h1.7A5.5 5.5 0 0013.5 8H15A7 7 0 012.5 11.8z",
min: "M4 10h8v1.5H4z",
max: "M4 4h8v8H4zM5.5 5.5v5h5v-5z",
restore: "M5.5 3h7.5v7.5h-2.5V13H3V5.5h2.5zM7 4.5v1h3.5V9h1V4.5zM4.5 7v4.5H9V7z",
}
/** "3 min ago"-style, in the system's language, for a stamp the server sent. */
export function when(stamp) {
if (!stamp) return ""
const then = new Date(stamp)
if (Number.isNaN(then.getTime())) return ""
const seconds = Math.round((then.getTime() - Date.now()) / 1000)
const rtf = new Intl.RelativeTimeFormat(document.documentElement.lang || undefined, { numeric: "auto" })
const steps = [
[60, "second"],
[60, "minute"],
[24, "hour"],
[7, "day"],
[4.35, "week"],
[12, "month"],
[Infinity, "year"],
]
let value = seconds
for (const [size, unit] of steps) {
if (Math.abs(value) < size) return rtf.format(Math.round(value), unit)
value /= size
}
return ""
}
/** A size in bytes, as a person reads it. */
export function bytes(n) {
if (!Number.isFinite(n)) return ""
const units = ["B", "KB", "MB", "GB"]
let i = 0
let v = n
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i += 1
}
return `${i === 0 ? v : v.toFixed(v < 10 ? 1 : 0)} ${units[i]}`
}
+168
View File
@@ -0,0 +1,168 @@
// The two explorers, kept apart: this computer's -- the agent's working directory, read through
// a command that reaches only the agent's folders (src-tauri/src/files.rs) -- and the 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 { openViewer } from "./viewer.js"
// The server's tree as the app's door gives it. `/api/v2` has no file routes yet (the web UI's
// /files pages take the session cookie and answer HTML); these are the routes this pane is written
// against, and a 404 says "not on this server yet" instead of failing:
// GET /files?path=/a -> { path, entries: [{ name, path, kind: "dir"|"file", size, mime }] }
// GET /files/content?path= -> { path, name, size, text | null, withheld?: "binary"|"too_big" }
export const SERVER_FILES = {
list: (path) => `/files?path=${encodeURIComponent(path || "/")}`,
read: (path) => `/files/content?path=${encodeURIComponent(path)}`,
}
/** A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file. */
function tree(box, { list, open, rootPath }) {
clear(box)
const ul = h("ul", { class: "tree", role: "tree" })
box.append(ul)
const level = async (parent, path, depth) => {
let entries
try {
entries = await list(path)
} catch (err) {
parent.append(h("li", { class: "tree__note is-error", text: why(err) }))
return
}
if (!entries.length) parent.append(h("li", { class: "tree__note", text: t("Empty folder") }))
for (const e of entries) {
const row = h(
"button",
{ type: "button", class: "tree__row", title: e.path, "aria-expanded": e.kind === "dir" ? "false" : null },
h("span", { class: "tree__chevron" }, e.kind === "dir" ? icon(ICONS.chevron) : null),
icon(e.kind === "dir" ? ICONS.folder : ICONS.file),
h("span", { class: "tree__name", text: e.name }),
h("span", { class: "tree__size", text: e.kind === "dir" ? "" : bytes(e.size) }),
)
row.style.setProperty("--depth", String(depth))
const li = h("li", { role: "treeitem" }, row)
if (e.kind === "dir") {
let kids = null
row.addEventListener("click", async () => {
const opening = row.getAttribute("aria-expanded") !== "true"
row.setAttribute("aria-expanded", String(opening))
if (!opening) {
kids?.remove()
kids = null
return
}
kids = h("ul", { role: "group" })
li.append(kids)
await level(kids, e.path, depth + 1)
})
} else row.addEventListener("click", () => open(e))
parent.append(li)
}
}
return level(ul, rootPath, 0)
}
// --- This computer ------------------------------------------------------------------------------
let local = { root: "", roots: [], fixed: false }
/**
* Point this computer's pane at a folder: `fixed` (an agent chat's working directory, which is
* what the pane shows while that chat is open) or one of the agent's folders, chosen in the pane.
*/
export function showLocal({ roots, root, fixed }) {
const changed = root !== local.root || fixed !== local.fixed || roots.join("\n") !== local.roots.join("\n")
local = { roots, root: root || (roots.includes(local.root) ? local.root : roots[0] || ""), fixed: !!fixed }
if (changed) renderLocal()
}
function renderLocal() {
const head = clear($("local-where"))
const box = $("local-tree")
if (!local.roots.length) {
clear(box).append(
h("p", { class: "pane__note", text: t("No project folder yet: the agent can open nothing on this computer.") }),
h("button", { type: "button", class: "btn btn--sm", text: t("Add folder…"), onclick: () => invoke("remote_add") }),
)
return
}
if (local.fixed) {
head.append(h("span", { class: "pane__path", title: local.root }, h("bdi", { text: local.root })))
} else {
const pick = h(
"select",
{ class: "pane__pick", "aria-label": t("Folder"), onchange: (e) => showLocal({ roots: local.roots, root: e.target.value, fixed: false }) },
local.roots.map((r) => h("option", { value: r, text: r, selected: r === local.root })),
)
head.append(pick)
}
const root = local.root
tree(box, {
rootPath: "",
list: async (path) => (await invoke("local_list", { root, path })).entries,
open: async (e) => {
const where = t("This computer")
try {
const f = await invoke("local_read", { root, path: e.path })
openViewer({ name: f.name, path: `${f.root}/${f.path}`, where, text: f.text, withheld: f.withheld, size: f.size })
} catch (err) {
openViewer({ name: e.name, path: e.path, where, error: why(err) })
}
},
})
}
/** Read a file the agent showed in a chat on this computer: `path` from the chat's folder. */
export async function readLocal(root, path) {
return invoke("local_read", { root, path })
}
// --- The server ---------------------------------------------------------------------------------
export async function readServer(path) {
return get(SERVER_FILES.read(path))
}
export function renderServer() {
const box = $("server-tree")
tree(box, {
rootPath: "/",
list: async (path) => {
try {
const r = await get(SERVER_FILES.list(path))
return r.entries || []
} catch (err) {
if (err && (err.status === 404 || err.status === 405)) {
throw { detail: t("This server does not offer its files to the app yet.") }
}
throw err
}
},
open: async (e) => {
const where = t("Server")
try {
const f = await readServer(e.path)
openViewer({ name: f.name || e.name, path: f.path || e.path, where, text: f.text, withheld: f.withheld, size: f.size })
} catch (err) {
openViewer({ name: e.name, path: e.path, where, error: why(err) })
}
},
})
}
export function initExplorer() {
for (const pane of document.querySelectorAll(".pane")) {
const toggle = pane.querySelector(".pane__toggle")
toggle.prepend(h("span", { class: "tree__chevron" }, icon(ICONS.chevron)))
toggle.addEventListener("click", () => {
const open = pane.dataset.open !== "true"
pane.dataset.open = String(open)
toggle.setAttribute("aria-expanded", String(open))
$("side").dataset[pane.id === "pane-local" ? "local" : "server"] = String(open)
})
}
$("server-refresh").replaceChildren(icon(ICONS.refresh))
$("server-refresh").addEventListener("click", renderServer)
$("local-refresh").replaceChildren(icon(ICONS.refresh))
$("local-refresh").addEventListener("click", renderLocal)
}
+81
View File
@@ -0,0 +1,81 @@
// The sidebar's chats: agent chats (the default -- the agent on this computer is what the app is
// for) or chats on the server. Agent chats on this computer come first; those on another device
// of the account follow under their own heading.
import { $, h, clear, when, append } from "./dom.js"
import { get, why } from "./bridge.js"
let ctx = null // { kind(), thisDevices(), open(id), current() }
let rows = []
let more = false
/** The list again, from the server. */
export async function refreshList() {
const kind = ctx.kind()
try {
const r = await get(`/chats?kind=${kind}&limit=50`)
if (ctx.kind() !== kind) return
rows = r.chats || []
more = !!r.more
render()
} catch (err) {
clear($("chat-list")).append(h("li", { class: "list__note is-error", text: why(err) }))
}
}
async function loadMore() {
const last = rows[rows.length - 1]
if (!last) return
const kind = ctx.kind()
const r = await get(`/chats?kind=${kind}&limit=50&before=${encodeURIComponent(last.id)}`)
if (ctx.kind() !== kind) return
rows = rows.concat(r.chats || [])
more = !!r.more
render()
}
export function markCurrent() {
const id = ctx.current()
for (const b of $("chat-list").querySelectorAll(".chatrow")) b.setAttribute("aria-current", String(b.dataset.id === id))
}
function row(c) {
const b = h(
"button",
{ type: "button", class: `chatrow${c.unread ? " is-unread" : ""}`, dataset: { id: c.id }, title: c.title || "" },
h("span", { class: `dot${c.running ? " is-busy" : c.unread ? " is-new" : ""}`, "aria-hidden": "true" }),
h("span", { class: "row__title", text: c.title || t("New chat") }),
h("span", { class: "row__when", text: when(c.updated_at) }),
c.device?.directory ? h("span", { class: "row__sub", title: c.device.directory }, h("bdi", { text: c.device.directory })) : null,
)
b.addEventListener("click", () => ctx.open(c.id))
return h("li", null, b)
}
function render() {
const list = clear($("chat-list"))
const kind = ctx.kind()
const visible = rows.filter((r) => !r.archived && !r.temporary)
if (!visible.length) {
list.append(
h("li", {
class: "list__note",
text: kind === "agent" ? t("No agent chat yet. Start one: it works in a folder on this computer.") : t("No chat yet."),
}),
)
return
}
if (kind === "agent") {
const mine = ctx.thisDevices()
const here = visible.filter((r) => r.device && mine.includes(r.device.token_id))
const there = visible.filter((r) => !(r.device && mine.includes(r.device.token_id)))
if (here.length) append(list, [h("li", { class: "list__head", text: t("This computer") }), here.map(row)])
if (there.length) append(list, [h("li", { class: "list__head", text: t("Other devices") }), there.map(row)])
} else append(list, visible.map(row))
if (more) list.append(h("li", null, h("button", { type: "button", class: "btn btn--sm list__more", text: t("More"), onclick: loadMore })))
markCurrent()
}
export function initList(context) {
ctx = context
}
+242
View File
@@ -0,0 +1,242 @@
// The app's page: which view (the setup card, or the app), and the app itself -- the sidebar
// (agent chats by default, chats on the server, and the two explorers), the chat, the viewer and
// the settings. Everything it knows comes from Rust: `status` (the app, the agent, the server) and
// `api` (the server's /api/v2, as the signed-in account).
import { $, h, clear, icon, ICONS } from "./dom.js"
import { invoke, listen, get, why } from "./bridge.js"
import { renderBar, initBar } from "./titlebar.js"
import { renderSetup, initSetup } from "./setup.js"
import { renderSettings, initSettings } from "./settings.js"
import { initViewer, onViewer, closeViewer } from "./viewer.js"
import { initExplorer, showLocal, renderServer } from "./explorer.js"
import { initList, refreshList, markCurrent } from "./list.js"
import { initChat, openChat, newChat, closeChat, openChatId } from "./chat.js"
let status = null
let view = "" // "setup" | "app"
let me = null
let models = []
let devices = [] // this account's linked devices
let kind = "agent"
let started = false
let currentChat = null
const remembered = (() => {
try {
return JSON.parse(localStorage.getItem("lembas.thisDevices") || "[]")
} catch {
return []
}
})()
/**
* The device ids that are this computer: a linked device whose host is this computer's name (the
* CLI sends it at login). Remembered, so agent chats here are still known as here while the
* agent is off and the device is not linked right now.
*/
function thisDevices() {
let changed = false
for (const d of devices) {
if (status?.host && d.host === status.host && !remembered.includes(d.id)) {
remembered.push(d.id)
changed = true
}
}
if (changed) {
remembered.splice(0, Math.max(0, remembered.length - 8))
try {
localStorage.setItem("lembas.thisDevices", JSON.stringify(remembered))
} catch {}
}
return remembered.slice()
}
function applyTheme(name) {
if (name !== "moria" && name !== "shire") name = "moria"
const root = document.documentElement
root.dataset.theme = name
root.dataset.base = name
}
// --- Views ------------------------------------------------------------------------------------
function route() {
const ready = status.checked && status.discovery && !status.refused && status.account
const want = ready ? "app" : "setup"
if (want !== view) {
view = want
$("setup").hidden = want !== "setup"
$("shell").hidden = want !== "app"
if (want === "app") startApp()
else stopApp()
}
if (view === "setup") renderSetup(status)
if (view === "app") {
if (!$("settings").hidden) renderSettings(status)
localPane()
}
}
async function startApp() {
started = true
try {
me = await get("/me")
applyTheme(me.theme)
invoke("set_theme", { name: me.theme === "shire" ? "shire" : "moria" })
} catch (err) {
$("thread-note").textContent = why(err)
$("thread-note").hidden = false
}
await Promise.all([loadModels(), loadDevices()])
$("new-chat-btn").hidden = me?.permissions && me.permissions["chat.create"] === false
setKind(kind)
renderServer()
invoke("remote_refresh")
}
function stopApp() {
started = false
closeChat()
closeViewer()
}
async function loadModels() {
try {
models = (await get("/models")).models || []
} catch {
models = []
}
}
async function loadDevices() {
try {
devices = (await get("/devices")).devices || []
} catch {
devices = []
}
}
// --- The sidebar --------------------------------------------------------------------------------
function setKind(k) {
kind = k
for (const b of document.querySelectorAll(".switch button")) b.setAttribute("aria-selected", String(b.dataset.kind === k))
$("new-chat-btn").lastChild.textContent = k === "agent" ? t("New agent chat") : t("New chat")
clear($("chat-list"))
refreshList()
// With no chat open, the main view is where a chat of this kind starts.
if (!openChatId() && $("settings").hidden) {
currentChat = null
newChat(k)
localPane()
}
}
/** This computer's pane: the open agent chat's folder when it runs here, else a folder chosen. */
function localPane(chat) {
const roots = status?.remote?.enabled ? status.remote.roots : []
const c = chat || currentChat
const here = c?.device && thisDevices().includes(c.device.token_id)
if (here && c.device.directory) showLocal({ roots, root: c.device.directory, fixed: true })
else showLocal({ roots, root: "", fixed: false })
}
function narrow() {
return window.matchMedia("(max-width: 48rem)").matches
}
function sideOpen(on) {
$("shell").dataset.side = String(on)
$("side-toggle").setAttribute("aria-expanded", String(on))
}
// --- Settings -----------------------------------------------------------------------------------
function showSettings(on) {
$("settings").hidden = !on
$("main").hidden = on
if (on) {
renderSettings(status)
invoke("remote_refresh")
$("settings-close").focus()
}
}
// --- Start --------------------------------------------------------------------------------------
async function boot() {
initBar()
initViewer()
initExplorer()
initSetup(() => status)
initSettings(() => status)
initList({
kind: () => kind,
thisDevices,
current: () => openChatId(),
open: (id) => {
showSettings(false)
if (narrow()) sideOpen(false)
openChat(id).then(markCurrent)
},
})
initChat({
status: () => status,
thisDevices,
models: () => models,
me: () => me,
changed: () => refreshList(),
opened: (chat) => {
currentChat = chat
localPane(chat)
markCurrent()
},
})
onViewer((on) => {
$("shell").dataset.viewer = String(on)
})
for (const b of document.querySelectorAll(".switch button")) b.addEventListener("click", () => setKind(b.dataset.kind))
$("new-chat-btn").prepend(icon(ICONS.plus))
$("new-chat-btn").addEventListener("click", () => {
showSettings(false)
currentChat = null
if (narrow()) sideOpen(false)
newChat(kind)
markCurrent()
localPane()
})
$("settings-btn").replaceChildren(icon(ICONS.gear), h("span", { text: t("Settings") }))
$("settings-btn").addEventListener("click", () => showSettings($("settings").hidden))
$("settings-close").replaceChildren(icon(ICONS.close))
$("settings-close").addEventListener("click", () => showSettings(false))
$("side-toggle").addEventListener("click", () => sideOpen($("shell").dataset.side !== "true"))
$("scrim").addEventListener("click", () => sideOpen(false))
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && $("shell").dataset.side === "true") sideOpen(false)
})
invoke("set_words", { words: { reply: t("The reply is ready."), agent: t("The agent has finished.") } })
listen("status", (e) => {
const before = status
status = e.payload
renderBar(status)
route()
// The agent came up or went: which devices are linked changes with it.
if (started && before && before.agent.state !== status.agent.state) loadDevices().then(refreshList)
})
listen("open-settings", () => {
if (view === "app") showSettings(true)
})
status = await invoke("get_status")
applyTheme(status.theme)
renderBar(status)
route()
// The list keeps up with replies finished elsewhere (the web interface, the phone).
setInterval(() => {
if (view === "app" && document.visibilityState === "visible") refreshList()
}, 30000)
}
boot()
+347
View File
@@ -0,0 +1,347 @@
// Markdown, as a model writes it, to elements -- never to an HTML string. A reply is model
// output and untrusted: no raw HTML passes (it shows as the text it is), no image is fetched
// (an image is its link), and a link opens in the system's browser only when it is http, https
// or mail. What it does draw: headings, paragraphs, emphasis, strike-through, inline code, fenced
// code, block quotes, lists (nested), tables, rules, links and line breaks. A reply still being
// written renders the same way; an unclosed `*` is simply a star.
import { h } from "./dom.js"
import { openLink } from "./bridge.js"
const FENCE = /^ {0,3}(`{3,}|~{3,})\s*([^`\s]*)/
const HEADING = /^ {0,3}(#{1,6})(?:\s+(.*?))?\s*#*\s*$/
const RULE = /^ {0,3}([-*_])(?:\s*\1){2,}\s*$/
const QUOTE = /^ {0,3}> ?/
const ITEM = /^(\s*)([-*+]|\d{1,9}[.)])\s+(.*)$/
const TABLE_RULE = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/
const SAFE_LINK = /^(https?:|mailto:)/i
/** The rendered fragment of `source`. */
export function markdown(source) {
const out = document.createDocumentFragment()
blocks(String(source ?? "").replace(/\r\n?/g, "\n").split("\n"), out)
return out
}
function indentOf(line) {
const m = /^\s*/.exec(line)
return m ? m[0].replace(/\t/g, " ").length : 0
}
function startsBlock(line, next) {
return (
FENCE.test(line) ||
HEADING.test(line) ||
RULE.test(line) ||
QUOTE.test(line) ||
ITEM.test(line) ||
(line.includes("|") && next !== undefined && TABLE_RULE.test(next) && next.includes("-"))
)
}
function blocks(lines, parent) {
let i = 0
while (i < lines.length) {
const line = lines[i]
if (!line.trim()) {
i += 1
continue
}
const fence = FENCE.exec(line)
if (fence) {
const mark = fence[1]
const body = []
i += 1
while (i < lines.length && !new RegExp(`^ {0,3}${mark[0]}{${mark.length},}\\s*$`).test(lines[i])) {
body.push(lines[i])
i += 1
}
i += 1 // the closing fence (or the end)
parent.append(codeBlock(body.join("\n"), fence[2]))
continue
}
const heading = HEADING.exec(line)
if (heading) {
parent.append(h(`h${heading[1].length}`, null, inline(heading[2] || "")))
i += 1
continue
}
if (RULE.test(line)) {
parent.append(h("hr"))
i += 1
continue
}
if (QUOTE.test(line)) {
const inner = []
while (i < lines.length && lines[i].trim() && (QUOTE.test(lines[i]) || !startsBlock(lines[i], lines[i + 1]))) {
inner.push(lines[i].replace(QUOTE, ""))
i += 1
}
const quote = h("blockquote")
blocks(inner, quote)
parent.append(quote)
continue
}
if (ITEM.test(line)) {
i = list(lines, i, parent)
continue
}
if (line.includes("|") && TABLE_RULE.test(lines[i + 1] || "") && (lines[i + 1] || "").includes("-")) {
i = table(lines, i, parent)
continue
}
const para = [line]
i += 1
while (i < lines.length && lines[i].trim() && !startsBlock(lines[i], lines[i + 1])) {
para.push(lines[i])
i += 1
}
parent.append(h("p", null, inline(para.join("\n"))))
}
}
function codeBlock(text, lang) {
const code = h("code", { text })
const copy = h("button", {
type: "button",
class: "md-copy",
text: t("Copy"),
onclick: () => navigator.clipboard?.writeText(text).catch(() => {}),
})
return h("div", { class: "md-code" }, lang ? h("span", { class: "md-lang", text: lang }) : null, copy, h("pre", null, code))
}
function list(lines, start, parent) {
const first = ITEM.exec(lines[start])
const base = indentOf(first[1])
const ordered = /\d/.test(first[2])
const el = h(ordered ? "ol" : "ul")
if (ordered) {
const n = parseInt(first[2], 10)
if (n !== 1) el.setAttribute("start", String(n))
}
let i = start
while (i < lines.length) {
const m = ITEM.exec(lines[i])
if (!m || indentOf(m[1]) !== base || /\d/.test(m[2]) !== ordered) break
const content = [m[3]]
const width = indentOf(m[1]) + m[2].length + 1
i += 1
while (i < lines.length) {
const l = lines[i]
if (!l.trim()) {
// A blank line inside an item, when the item goes on indented after it.
if (i + 1 < lines.length && indentOf(lines[i + 1]) > base && lines[i + 1].trim()) {
content.push("")
i += 1
continue
}
break
}
const sub = ITEM.exec(l)
if (sub && indentOf(sub[1]) <= base) break
if (indentOf(l) <= base && startsBlock(l, lines[i + 1])) break
content.push(l.slice(Math.min(width, indentOf(l))))
i += 1
}
const li = h("li")
const task = /^\[([ xX])\]\s+/.exec(content[0])
if (task) {
content[0] = content[0].slice(task[0].length)
li.classList.add("md-task")
li.append(h("input", { type: "checkbox", disabled: true, checked: task[1] !== " " }))
}
// A tight item is its text; one with blocks in it is blocks.
if (content.length === 1 || !content.slice(1).some((c) => startsBlock(c) || !c.trim())) {
li.append(...inline(content.join("\n")))
} else blocks(content, li)
el.append(li)
while (i < lines.length && !lines[i].trim()) {
const next = ITEM.exec(lines[i + 1] || "")
if (next && indentOf(next[1]) === base) i += 1
else break
}
}
parent.append(el)
return i
}
function cells(line) {
let s = line.trim()
if (s.startsWith("|")) s = s.slice(1)
if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1)
return s.split(/(?<!\\)\|/).map((c) => c.trim().replace(/\\\|/g, "|"))
}
function table(lines, start, parent) {
const head = cells(lines[start])
const aligns = cells(lines[start + 1]).map((c) =>
c.startsWith(":") && c.endsWith(":") ? "center" : c.endsWith(":") ? "right" : c.startsWith(":") ? "left" : "",
)
let i = start + 2
const rows = []
while (i < lines.length && lines[i].includes("|") && lines[i].trim()) {
rows.push(cells(lines[i]))
i += 1
}
const cell = (tag, text, n) => {
const c = h(tag, null, inline(text ?? ""))
if (aligns[n]) c.dataset.align = aligns[n]
return c
}
const tbl = h(
"table",
null,
h("thead", null, h("tr", null, head.map((c, n) => cell("th", c, n)))),
h("tbody", null, rows.map((r) => h("tr", null, head.map((_, n) => cell("td", r[n], n))))),
)
// A wide table scrolls by itself; the page does not.
parent.append(h("div", { class: "md-table" }, tbl))
return i
}
function link(label, url) {
const safe = SAFE_LINK.test(url)
const a = h("a", { href: safe ? url : null, title: url, class: safe ? null : "md-unsafe" }, label)
if (safe) {
a.addEventListener("click", (e) => {
e.preventDefault()
openLink(url)
})
}
return a
}
/** The closing `mark` for an opening at `from`, or -1. */
function closing(text, mark, from) {
let at = text.indexOf(mark, from)
while (at !== -1) {
const before = text[at - 1]
if (at > from && before !== " " && before !== "\\") {
if (mark !== "_" || !/\w/.test(text[at + 1] || "")) return at
}
at = text.indexOf(mark, at + 1)
}
return -1
}
/** Inline markdown to nodes. */
export function inline(text) {
const out = []
let plain = ""
const flush = () => {
if (plain) out.push(document.createTextNode(plain))
plain = ""
}
let i = 0
while (i < text.length) {
const c = text[i]
const rest = text.slice(i)
if (c === "\\" && i + 1 < text.length) {
if (text[i + 1] === "\n") {
flush()
out.push(h("br"))
i += 2
continue
}
if (/[\\`*_{}[\]()#+\-.!|~<>]/.test(text[i + 1])) {
plain += text[i + 1]
i += 2
continue
}
}
if (c === "`") {
const run = /^`+/.exec(rest)[0]
const end = text.indexOf(run, i + run.length)
if (end !== -1) {
flush()
out.push(h("code", { text: text.slice(i + run.length, end).replace(/^ (.+) $/, "$1") }))
i = end + run.length
continue
}
plain += run
i += run.length
continue
}
const img = /^!\[([^\]]*)\]\(\s*<?([^\s)>]+)>?(?:\s+"[^"]*")?\s*\)/.exec(rest)
if (img) {
flush()
out.push(link(img[1] || img[2], img[2]))
i += img[0].length
continue
}
if (c === "[") {
const m = /^\[((?:[^[\]\\]|\\.|\[[^\]]*\])*)\]\(\s*<?([^\s)>]+)>?(?:\s+"[^"]*")?\s*\)/.exec(rest)
if (m) {
flush()
out.push(link(inline(m[1]), m[2]))
i += m[0].length
continue
}
}
if (c === "<") {
const m = /^<((?:https?:\/\/|mailto:)[^\s>]+)>/i.exec(rest)
if (m) {
flush()
out.push(link(m[1], m[1]))
i += m[0].length
continue
}
}
if ((c === "h" || c === "H") && !/\w/.test(text[i - 1] || "")) {
const m = /^https?:\/\/[^\s<]+/i.exec(rest)
if (m) {
let url = m[0]
// Trailing punctuation belongs to the sentence, and an unmatched ")" to a parenthesis.
while (/[.,;:!?'"]$/.test(url) || (url.endsWith(")") && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length)) {
url = url.slice(0, -1)
}
flush()
out.push(link(url, url))
i += url.length
continue
}
}
const strong = rest.startsWith("**") ? "**" : rest.startsWith("__") && !/\w/.test(text[i - 1] || "") ? "__" : ""
if (strong && text[i + 2] && text[i + 2] !== " ") {
const end = closing(text, strong, i + 2)
if (end !== -1) {
flush()
out.push(h("strong", null, inline(text.slice(i + 2, end))))
i = end + 2
continue
}
}
if (rest.startsWith("~~") && text[i + 2] && text[i + 2] !== " ") {
const end = closing(text, "~~", i + 2)
if (end !== -1) {
flush()
out.push(h("del", null, inline(text.slice(i + 2, end))))
i = end + 2
continue
}
}
if ((c === "*" || (c === "_" && !/\w/.test(text[i - 1] || ""))) && text[i + 1] && !/\s/.test(text[i + 1]) && text[i + 1] !== c) {
const end = closing(text, c, i + 1)
if (end !== -1) {
flush()
out.push(h("em", null, inline(text.slice(i + 1, end))))
i = end + 1
continue
}
}
if (c === "\n") {
if (plain.endsWith(" ")) {
plain = plain.replace(/ +$/, "")
flush()
out.push(h("br"))
} else plain += " "
i += 1
continue
}
plain += c
i += 1
}
flush()
return out
}
+178
View File
@@ -0,0 +1,178 @@
// Settings: the app's, not the server's (no admin page here, ever -- an admin sees what everyone
// sees). The server and the app's sign-in, the agent and the folders it may work in, the
// notifications, and this release.
import { $, h, clear } from "./dom.js"
import { invoke, listen, why } from "./bridge.js"
const AGENT = {
off: () => [t("The agent is off."), ""],
not_linked: () => [t("This computer is not linked to the server yet."), ""],
starting: () => [t("The agent is starting…"), ""],
running: () => [t("The agent is running: agent chats can use this computer."), "is-ok"],
elsewhere: (pid) => [t("A LLeMbas CLI service already runs here (pid %(pid)s); it is this computer's agent.", { pid }), "is-ok"],
failed: (why) => [t("The agent stopped: %(why)s. Trying again shortly.", { why }), "is-error"],
}
let folderError = ""
let linking = null // { user_code, uri } while the computer is being linked
let linkError = ""
export function agentWords(s) {
const noFolders = s.remote && !(s.remote.enabled && s.remote.roots.length)
if (s.agent.state === "running" && noFolders) return [t("The agent is connected, but may open no folder yet: add one below."), "is-error"]
return (AGENT[s.agent.state] ?? AGENT.off)(s.agent.detail)
}
function section(id, title, ...body) {
return h("section", { class: "set", "aria-labelledby": id }, h("h2", { id, text: title }), body)
}
export function renderSettings(s) {
const box = clear($("settings-body"))
// The server and the account.
const who = s.account ? [s.account.name, s.account.email].filter(Boolean).join(" · ") : ""
box.append(
section(
"set-server",
t("Server"),
h("p", null, h("bdi", { text: s.server || "" }), s.discovery ? h("span", { class: "muted", text: ` · LLeMbas ${s.discovery.version}` }) : null),
who ? h("p", { class: "muted", text: t("Signed in as %(who)s.", { who }) }) : null,
h("p", { class: "muted", text: t("Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.") }),
h("div", { class: "row--buttons" }, h("button", { type: "button", class: "btn", text: t("Sign out"), onclick: () => invoke("sign_out") })),
),
)
// The agent.
const [text, cls] = agentWords(s)
const agent = section(
"set-agent",
t("Agent"),
h("p", { class: "muted", text: t("The agent lets agent chats on your server work in this computer's projects. It is the LLeMbas CLI, built in, running in the background while the app runs.") }),
h("p", { class: `status ${cls}`, "aria-live": "polite", text }),
)
if (linking) {
agent.append(h("p", null, t("Approve this code in the page that opened in your browser:")), h("p", { class: "code", text: linking.user_code }))
}
if (linkError) agent.append(h("p", { class: "status is-error", text: linkError }))
agent.append(
h(
"div",
{ class: "row--buttons" },
h("button", {
type: "button",
class: "btn",
text: s.linked ? t("Link again") : t("Link this computer"),
onclick: () => {
linkError = ""
invoke("link_computer").catch((err) => {
linkError = why(err)
renderSettings(s)
})
},
}),
h("button", {
type: "button",
class: "btn",
text: s.agent_wanted ? t("Stop the agent") : t("Start the agent"),
disabled: !s.linked && !s.agent_wanted,
onclick: () => invoke("set_agent", { on: !s.agent_wanted }),
}),
),
)
box.append(agent)
// The folders.
const r = s.remote
if (s.linked && r) {
const list = h("ul", { class: "folders" })
if (!r.enabled || !r.roots.length) list.append(h("li", { class: "empty", text: t("No folder yet: the agent can open nothing. Add the folder of a project.") }))
for (const dir of r.enabled ? r.roots : []) {
list.append(
h(
"li",
null,
h("span", { class: "path", title: dir }, h("bdi", { text: dir })),
h("button", { type: "button", class: "btn", text: t("Remove"), "aria-label": t("Remove %(dir)s", { dir }), onclick: () => invoke("remote_remove", { dir }) }),
),
)
}
const mode = h(
"select",
{ id: "max-mode", onchange: (e) => invoke("remote_mode", { mode: e.target.value }) },
[
["manual", t("Manual — asks first")],
["edit", t("Edit — asks before commands")],
["auto", t("Auto — asks nothing")],
["plan", t("Plan — changes nothing")],
].map(([v, label]) => h("option", { value: v, text: label, selected: r.max_mode === v })),
)
const terminal = h("input", { type: "checkbox", id: "terminal", checked: r.terminal, onchange: (e) => invoke("remote_terminal", { on: e.target.checked }) })
box.append(
section(
"set-folders",
t("Project folders"),
h("p", { class: "muted", text: t("Agent chats can work in these folders and anything inside them — nowhere else on this computer.") }),
list,
folderError ? h("p", { class: "status is-error", text: folderError }) : null,
h("div", { class: "row--buttons" }, h("button", { type: "button", class: "btn btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") })),
h(
"div",
{ class: "fields" },
h("div", { class: "field" }, h("label", { for: "max-mode", text: t("The most an agent chat may do here") }), mode, h("p", { class: "hint", text: t("A chat asking for more is lowered to this.") })),
h(
"div",
{ class: "field" },
h("span", { class: "label", text: t("Terminal") }),
h("label", { class: "check" }, terminal, h("span", { text: t("Let the server open a terminal here") })),
h("p", { class: "hint", text: t("A shell as you, in one of these folders.") }),
),
),
),
)
}
// Notifications.
box.append(
section(
"set-notify",
t("Notifications"),
h(
"label",
{ class: "check" },
h("input", { type: "checkbox", checked: s.notify, onchange: (e) => invoke("set_notify", { on: e.target.checked }) }),
h("span", { text: t("Tell me when a reply or an agent's work is finished while I am elsewhere") }),
),
),
)
// This release.
box.append(
section(
"set-about",
t("About"),
h("p", null, `LLeMbas Desktop ${s.version}`),
h(
"div",
{ class: "row--buttons" },
h("button", { type: "button", class: "btn", text: s.update ? t("Install LLeMbas %(version)s…", { version: s.update }) : t("Check for updates…"), onclick: () => invoke("update_app") }),
),
),
)
}
export function initSettings(current) {
listen("remote-error", (e) => {
folderError = String(e.payload)
renderSettings(current())
})
listen("status", () => {
folderError = ""
})
listen("linking", (e) => {
const p = e.payload
linking = p.state === "code" ? { user_code: p.user_code, uri: p.uri } : null
linkError = p.state === "failed" ? p.message : ""
renderSettings(current())
})
}
+112
View File
@@ -0,0 +1,112 @@
// Before the app: which server, and the app's sign-in to it. A server older than LLeMbas 2.0 is
// refused here, with the reason Rust gave (src-tauri/src/server.rs, `check`). Signing in is the
// server's device login: the approval page opens in the system's browser; this page shows the
// code to compare and waits.
import { $, h, clear } from "./dom.js"
import { invoke, listen, why } from "./bridge.js"
let code = null // the code being approved, while signing in
let failed = ""
export function renderSetup(s) {
const box = clear($("setup-body"))
$("setup-version").textContent = s.version
const server = h("section", { "aria-labelledby": "setup-server-h" }, h("h2", { id: "setup-server-h", text: t("Server") }))
const address = h("input", {
id: "address",
name: "address",
type: "text",
inputmode: "url",
autocomplete: "url",
placeholder: "https://ai.example",
spellcheck: "false",
value: s.server || "",
})
const hint = h("p", { class: "hint", id: "server-hint", "aria-live": "polite" })
if (!s.checked) hint.textContent = t("Looking for a LLeMbas server there…")
else if (s.refused) {
hint.textContent = s.refused
hint.classList.add("is-error")
} else if (s.discovery) hint.textContent = t("%(name)s, LLeMbas %(version)s.", { name: s.discovery.name, version: s.discovery.version })
else hint.textContent = t("LLeMbas Desktop needs a LLeMbas 2.0 server or later.")
const connect = h("button", { type: "submit", class: "btn btn--primary", text: t("Connect") })
const form = h(
"form",
{ class: "field", novalidate: true },
h("label", { for: "address", text: t("Address of your LLeMbas server") }),
h("div", { class: "row" }, address, connect),
hint,
)
form.addEventListener("submit", async (e) => {
e.preventDefault()
connect.disabled = true
hint.className = "hint"
hint.textContent = t("Looking for a LLeMbas server there…")
try {
await invoke("set_server", { address: address.value })
} catch (err) {
hint.className = "hint is-error"
hint.textContent = why(err)
} finally {
connect.disabled = false
}
})
server.append(form)
if (s.refused && s.server) {
server.append(h("div", { class: "row--buttons" }, h("button", { type: "button", class: "btn", text: t("Try again"), onclick: () => invoke("recheck_server") })))
}
box.append(server)
if (s.discovery && !s.account) {
const sign = h("section", { "aria-labelledby": "setup-sign-h" }, h("h2", { id: "setup-sign-h", text: t("Sign in") }))
sign.append(h("p", { class: "muted", text: t("The app signs in to your server once, in your browser, as any device does. It may then read and write your chats, as the web interface does.") }))
if (s.signing_in && code) {
sign.append(
h("p", null, t("Approve this code in the page that opened in your browser:")),
h("p", { class: "code", text: code.user_code }),
h(
"div",
{ class: "row--buttons" },
h("button", { type: "button", class: "btn", text: t("Open the page again"), onclick: () => invoke("open_external", { url: code.verification_uri_complete }) }),
h("button", { type: "button", class: "btn", text: t("Cancel"), onclick: () => invoke("sign_in_cancel") }),
),
)
} else {
sign.append(
h(
"div",
{ class: "row--buttons" },
h("button", {
type: "button",
class: "btn btn--primary",
text: s.signing_in ? t("Waiting for the browser…") : t("Sign in"),
disabled: s.signing_in,
onclick: () => {
failed = ""
invoke("sign_in").catch((err) => {
failed = why(err)
renderSetup(s)
})
},
}),
),
)
}
if (failed) sign.append(h("p", { class: "status is-error", text: failed }))
box.append(sign)
}
}
export function initSetup(current) {
listen("signin", (e) => {
const p = e.payload
if (p.state === "code") code = p.code
if (p.state === "done") code = null
if (p.state === "failed") {
code = null
failed = p.message
}
renderSetup(current())
})
}
+20 -24
View File
@@ -1,14 +1,9 @@
// The title bar. Moving and double-click maximising come from `data-tauri-drag-region`; the // The title bar: the app's own, in the page (the window has no system decoration). Moving and
// buttons call the window directly; the menu and the agent pill open the app's native menu (the // double-click maximising come from `data-tauri-drag-region`; the edges resize the window (Tauri
// tray's items), which can reach past this 36-pixel-high page. The bar is painted in the theme // does that for an undecorated one); the menu and the agent pill open the app's native menu.
// the app reports in `status` (the server page's); every word goes through `t` (i18n.js).
"use strict"
const { invoke } = window.__TAURI__.core import { $, icon, ICONS } from "./dom.js"
const { listen } = window.__TAURI__.event import { invoke, listen, win } from "./bridge.js"
const win = window.__TAURI__.window.getCurrentWindow()
const $ = (id) => document.getElementById(id)
const WORDS = { const WORDS = {
off: () => t("agent off"), off: () => t("agent off"),
@@ -20,8 +15,7 @@ const WORDS = {
no_folders: () => t("agent: no folders"), no_folders: () => t("agent: no folders"),
} }
function render(s) { export function renderBar(s) {
window.lembasTheme.apply(s.theme)
let host = "" let host = ""
try { try {
host = s.server ? new URL(s.server).host : "" host = s.server ? new URL(s.server).host : ""
@@ -36,31 +30,33 @@ function render(s) {
$("agent").title = state === "failed" ? t("Agent stopped: %(why)s", { why: s.agent.detail }) : t("Agent") $("agent").title = state === "failed" ? t("Agent stopped: %(why)s", { why: s.agent.detail }) : t("Agent")
$("update").hidden = !s.update $("update").hidden = !s.update
$("update").textContent = s.update ? t("update %(version)s", { version: s.update }) : t("update") $("update").textContent = s.update ? t("update %(version)s", { version: s.update }) : t("update")
$("update").title = s.update $("update").title = s.update ? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version }) : ""
? t("LLeMbas %(version)s is available (this is %(current)s)", { version: s.update, current: s.version })
: ""
} }
function maximized(on) { function maximized(on) {
const b = $("maximize") const b = $("maximize")
b.toggleAttribute("data-maximized", on) b.replaceChildren(icon(on ? ICONS.restore : ICONS.max))
b.setAttribute("aria-label", on ? t("Restore") : t("Maximise")) b.setAttribute("aria-label", on ? t("Restore") : t("Maximise"))
b.title = on ? t("Restore") : t("Maximise") b.title = on ? t("Restore") : t("Maximise")
} }
$("minimize").addEventListener("click", () => win.minimize())
$("maximize").addEventListener("click", () => win.toggleMaximize())
$("close").addEventListener("click", () => win.close())
$("update").addEventListener("click", () => invoke("update_app"))
// The menu opens under the button that was clicked: its bottom-left corner, in page pixels. // The menu opens under the button that was clicked: its bottom-left corner, in page pixels.
function menuAt(button) { function menuAt(button) {
const r = button.getBoundingClientRect() const r = button.getBoundingClientRect()
return invoke("titlebar_menu", { x: r.left, y: r.bottom }) return invoke("titlebar_menu", { x: r.left, y: r.bottom })
} }
export function initBar() {
$("minimize").replaceChildren(icon(ICONS.min))
$("close").replaceChildren(icon(ICONS.close))
$("menu").replaceChildren(icon(ICONS.menu))
$("side-toggle").replaceChildren(icon(ICONS.side))
$("minimize").addEventListener("click", () => win.minimize())
$("maximize").addEventListener("click", () => win.toggleMaximize())
$("close").addEventListener("click", () => win.close())
$("update").addEventListener("click", () => invoke("update_app"))
$("menu").addEventListener("click", (e) => menuAt(e.currentTarget)) $("menu").addEventListener("click", (e) => menuAt(e.currentTarget))
$("agent").addEventListener("click", (e) => menuAt(e.currentTarget)) $("agent").addEventListener("click", (e) => menuAt(e.currentTarget))
listen("status", (e) => render(e.payload))
listen("maximized", (e) => maximized(!!e.payload)) listen("maximized", (e) => maximized(!!e.payload))
invoke("get_status").then(render) win.isMaximized().then(maximized).catch(() => maximized(false))
win.isMaximized().then(maximized) }
+119
View File
@@ -0,0 +1,119 @@
// The viewer beside the chat: a file from this computer, from the server, or one the agent showed
// (`show`). Markdown is drawn (`markdown.js`), code and text are CodeMirror, read-only, and HTML
// is a sealed frame (src-tauri/src/sealed.rs): its own scheme, no bridge, no network, no way
// out. What a file says is never part of the app's page.
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
import { invoke } from "./bridge.js"
import { markdown } from "./markdown.js"
import { codeView } from "./code.js"
// The viewer's kind by a file's name: the CLI's and the server's `showKind`, the same table.
const LANGUAGES = {
ts: "typescript", tsx: "tsx", js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx", json: "json", py: "python", rs: "rust", go: "go",
rb: "ruby", php: "php", java: "java", kt: "kotlin", swift: "swift", c: "c", h: "c", cc: "cpp", cpp: "cpp", hpp: "cpp", cs: "csharp",
sh: "bash", bash: "bash", zsh: "bash", fish: "fish", css: "css", scss: "scss", sql: "sql", yaml: "yaml", yml: "yaml", toml: "toml",
xml: "xml", svg: "xml", lua: "lua", zig: "zig", dart: "dart", vue: "vue", svelte: "svelte",
}
export function showKind(path) {
const name = String(path).split("/").pop() || ""
const ext = name.includes(".") ? name.split(".").pop().toLowerCase() : ""
if (ext === "md" || ext === "markdown") return { kind: "markdown", language: "markdown" }
if (ext === "html" || ext === "htm") return { kind: "html", language: "html" }
const language = LANGUAGES[ext] || (name === "Dockerfile" ? "dockerfile" : name === "Makefile" ? "makefile" : "")
return language ? { kind: "code", language } : { kind: "text", language: "" }
}
let current = null // { editor, sealed }
let onChange = () => {}
/** Called when the viewer opens or closes (the layout makes room for it). */
export function onViewer(fn) {
onChange = fn
}
function teardown() {
if (!current) return
current.editor?.destroy()
if (current.sealed) invoke("sealed_close", { url: current.sealed })
current = null
}
export function closeViewer() {
teardown()
$("viewer").hidden = true
clear($("viewer-body"))
onChange(false)
}
/**
* Show a file. `file`: { name, path, where (words for its place), text, kind?, language?, line?,
* withheld?: "binary" | "too_big", size?, error? }.
*/
export async function openViewer(file) {
teardown()
const kind = file.kind || showKind(file.path || file.name).kind
const lang = file.language ?? showKind(file.path || file.name).language
$("viewer").hidden = false
onChange(true)
$("viewer-name").textContent = file.name || file.path || ""
$("viewer-name").title = file.path || ""
$("viewer-where").textContent = file.where || ""
const body = clear($("viewer-body"))
const modes = clear($("viewer-modes"))
current = {}
if (file.error) {
body.append(h("p", { class: "viewer__note is-error", text: file.error }))
return
}
if (file.withheld) {
body.append(
h("p", {
class: "viewer__note",
text:
file.withheld === "too_big"
? t("This file is too big to show here (%(size)s).", { size: bytes(file.size) })
: t("This is not a text file; it cannot be shown here."),
}),
)
return
}
const text = file.text ?? ""
// Markdown and HTML can be seen drawn or as their source.
const drawn = kind === "markdown" || kind === "html"
const show = async (asSource) => {
teardown()
current = {}
clear(body)
for (const b of modes.querySelectorAll("button")) b.setAttribute("aria-pressed", String(b.dataset.source === String(asSource)))
if (asSource || !drawn) {
const holder = h("div", { class: "viewer__code" })
body.append(holder)
current.editor = await codeView(holder, text, { lang, line: file.line || 0 })
} else if (kind === "markdown") {
body.append(h("div", { class: "md viewer__md" }, markdown(text)))
} else {
const url = await invoke("sealed_open", { html: text })
current.sealed = url
// No allow-same-origin, no allow-top-navigation: an opaque page that can run its own
// scripts and nothing else. The address's own policy says the same again.
body.append(h("iframe", { class: "viewer__frame", src: url, sandbox: "allow-scripts", referrerpolicy: "no-referrer", title: file.name || "" }))
}
}
if (drawn) {
modes.append(
h("button", { type: "button", class: "seg", dataset: { source: "false" }, text: kind === "html" ? t("Page") : t("Preview"), onclick: () => show(false) }),
h("button", { type: "button", class: "seg", dataset: { source: "true" }, text: t("Source"), onclick: () => show(true) }),
)
}
await show(false)
}
export function initViewer() {
$("viewer-close").replaceChildren(icon(ICONS.close))
$("viewer-close").addEventListener("click", closeViewer)
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !$("viewer").hidden && !e.defaultPrevented) closeViewer()
})
}
-15
View File
@@ -1,15 +0,0 @@
// The app's own pages are painted in the server page's theme, which the app reports in `status`
// (see src-tauri/src/lib.rs: the content view says, the app remembers). tokens.css selects a
// theme by `data-theme` and `data-base` on <html>, as the server's pages do; with nothing set
// it is Moria, the server's own default, so the page needs no script to be right at first.
"use strict"
window.lembasTheme = {
apply(name) {
if (name !== "moria" && name !== "shire") return
const root = document.documentElement
if (root.dataset.theme === name) return
root.dataset.theme = name
root.dataset.base = name
},
}
-99
View File
@@ -1,99 +0,0 @@
/* The title bar: one row, fixed height, every slot a fixed or shared track so nothing in it
moves when the host name or the agent's state changes length. Colours, type and space are
tokens from tokens.css (the server's); the bar's height is this page's own. */
* { box-sizing: border-box; }
:root {
/* `TITLE_H` in src-tauri/src/lib.rs is this number: the app lays the bar out at it. */
--titlebar-h: 36px;
}
html, body {
margin: 0;
height: var(--titlebar-h);
overflow: hidden;
background: var(--bg);
color: var(--ink);
user-select: none;
-webkit-user-select: none;
cursor: default;
}
.bar {
display: grid;
/* leaf · name · host (takes the rest, and is the drag area) · update · agent · menu · sep · 3 controls */
grid-template-columns: var(--titlebar-h) auto minmax(0, 1fr) auto auto var(--titlebar-h) var(--border-w) repeat(3, 2.875rem);
align-items: center;
height: var(--titlebar-h);
border-bottom: var(--border-w) solid var(--border);
font: 600 var(--text-sm) / 1 var(--font-ui);
}
.bar__leaf { justify-self: center; width: 18px; height: 18px; image-rendering: pixelated; }
.bar__name b { color: var(--leaf); font-weight: 700; }
.bar__host {
padding: 0 var(--sp-3);
color: var(--ink-muted);
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
align-self: stretch;
display: flex;
align-items: center;
}
button {
height: var(--titlebar-h);
margin: 0;
padding: 0;
font: inherit;
color: var(--ink-muted);
background: transparent;
border: 0;
border-radius: 0;
cursor: pointer;
}
button:hover { color: var(--ink); background: var(--surface-hover); }
button:focus-visible { outline: var(--outline-w) solid var(--accent); outline-offset: calc(-1 * var(--outline-w)); }
.bar__agent {
display: grid;
grid-template-columns: var(--sp-2) auto;
gap: var(--sp-2);
align-items: center;
padding: 0 var(--sp-3);
font-weight: 400;
}
.dot { width: var(--sp-2); height: var(--sp-2); background: var(--ink-muted); }
.bar__agent[data-state="running"] .dot,
.bar__agent[data-state="elsewhere"] .dot { background: var(--leaf); }
.bar__agent[data-state="starting"] .dot { background: var(--accent); }
.bar__agent[data-state="failed"] .dot,
.bar__agent[data-state="not_linked"] .dot,
.bar__agent[data-state="no_folders"] .dot { background: var(--warning); }
.bar__update {
padding: 0 var(--sp-3);
color: var(--accent-ink);
background: var(--accent);
}
.bar__update:hover { color: var(--accent-ink); background: var(--accent-hover); }
/* Hidden keeps its grid track (auto, so zero wide): the slots after it never move. */
.bar__update[hidden] { display: block; width: 0; padding: 0; overflow: hidden; }
.bar__sep { width: var(--border-w); height: var(--sp-5); background: var(--border); }
.bar__btn { display: grid; place-items: center; }
.bar__btn svg { width: 16px; height: 16px; }
.bar__btn--close:hover { color: var(--ink-inverse); background: var(--danger); }
#maximize .i-restore { display: none; }
#maximize[data-maximized] .i-max { display: none; }
#maximize[data-maximized] .i-restore { display: block; }
/* Narrow windows: the host goes first, then the agent's words (its dot stays). */
@media (max-width: 34rem) {
.bar__host { visibility: hidden; }
#agent-text { display: none; }
}
-39
View File
@@ -1,39 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="dark light">
<title>LLeMbas</title>
<link rel="stylesheet" href="tokens.css">
<link rel="stylesheet" href="titlebar.css">
<script src="theme.js"></script>
<script src="i18n.js" defer></script>
<script src="titlebar.js" defer></script>
</head>
<body>
<header class="bar" data-tauri-drag-region>
<img class="bar__leaf" src="logo-mark.svg" alt="" width="18" height="18" data-tauri-drag-region>
<span class="bar__name" data-tauri-drag-region><b>LL</b>e<b>M</b>bas</span>
<span class="bar__host" id="host" data-tauri-drag-region></span>
<button class="bar__update" id="update" type="button" hidden data-i18n>update</button>
<button class="bar__agent" id="agent" type="button" title="Agent" data-i18n-title>
<span class="dot" aria-hidden="true"></span><span id="agent-text" data-i18n>agent off</span>
</button>
<button class="bar__btn" id="menu" type="button" aria-label="Menu" title="Menu" data-i18n-aria-label data-i18n-title>
<svg viewBox="0 0 16 16" aria-hidden="true"><g fill="currentColor"><rect x="3" y="4" width="10" height="1.5"/><rect x="3" y="7.25" width="10" height="1.5"/><rect x="3" y="10.5" width="10" height="1.5"/></g></svg>
</button>
<span class="bar__sep" aria-hidden="true"></span>
<button class="bar__btn" id="minimize" type="button" aria-label="Minimise" title="Minimise" data-i18n-aria-label data-i18n-title>
<svg viewBox="0 0 16 16" aria-hidden="true"><rect x="4" y="10" width="8" height="1.5" fill="currentColor"/></svg>
</button>
<button class="bar__btn" id="maximize" type="button" aria-label="Maximise" title="Maximise" data-i18n-aria-label data-i18n-title>
<svg class="i-max" viewBox="0 0 16 16" aria-hidden="true"><path d="M4.75 4.75h6.5v6.5h-6.5z" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
<svg class="i-restore" viewBox="0 0 16 16" aria-hidden="true"><path d="M3.75 6.25h6v6h-6z M6.25 6.25v-2.5h6v6h-2.5" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
<button class="bar__btn bar__btn--close" id="close" type="button" aria-label="Close" title="Close (LLeMbas stays in the tray)" data-i18n-aria-label data-i18n-title>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
</header>
</body>
</html>