From 4899d28e8aa0f673747b4c985dec9400591ea849 Mon Sep 17 00:00:00 2001 From: mudler-agent Date: Sun, 1 Nov 2026 12:08:23 +0100 Subject: [PATCH] feat(chats): server-side chats with steering, sub-agents and compaction (#12918) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Find the Sandbox guest image in the executable, not its build info The Sandbox smoke checked that the packaged executable carries the guest image by looking for -X main.guestImage in go version -m. The package is built with -trimpath, and with -trimpath Go leaves -ldflags out of the build info, so the check failed on every gvisor run, although the image was linked in. Look for the reference in the executable instead: the value of SANDBOX_GUEST_IMAGE when it is set, any digest reference otherwise. package.sh had the same check for an executable built elsewhere; it now looks for its own reference the same way. Assisted-by: Claude:claude-opus-5-5 * Answer a network card the limit refuses with one precise reason A card that waited while the administrator's limit went down was refused on the first click with 400, and every later click was a bare 409, so a stale card looked like a conflict loop. A refused card now answers 400 egress_above_ceiling every time, the same answer sent twice is one answer (204), and another answer to an answered card is 409 already_answered. The chat view marks a card the limit cannot admit with above_ceiling. When the limit is off, or below the chat's preset, the note of the turn and the block line of the proxy now say that the server's setting blocks the network and no approval changes it, so the model reports it instead of waiting or retrying. Assisted-by: Claude:claude-sonnet-5-5 * Explain a blocked Sandbox network instead of offering dead buttons A card the limit cannot admit shows why network access is off or limited, and who can change it, with a link to Settings > Tools > Sandbox network for administrators. The buttons of a card disable while an answer is sent, and a failed answer reads the card again. The Network row says why its choices are capped. Settings opens a group from ?group=. Strings are translated in all nine locales and the chat and add-on docs describe the new behaviour. Assisted-by: Claude:claude-sonnet-5-5 * Add an always-allowed hosts list to the Sandbox network The extra registries setting only widens the package registries, so a one-off host needs the approval card in every chat. The new admin setting chats_egress_allowed_hosts lists exact hosts (optionally host:port to pin a port) that a chat reaches whenever its effective preset is registries or all. The list sits in the same decision as the presets: the ceiling still caps it, the ports setting still applies, and a private, loopback, link-local or metadata address still needs the private switch and an administrator's rule or answer. Each use is audited with the reason allowed_host. Hosts are validated like the extra registries (lower case, punycode, no scheme, path or pattern, at most 64) and an IP literal is refused unless the private switch is on. get_chat_egress and the chat egress view return the list; no MCP tool writes it, as for the extra registries. Assisted-by: Claude:claude-sonnet-5-5 * Let users delete from the Trash for good A delete only set a tombstone, so the Trash grew without end and nothing could free the space of a deleted file. The workspace stores gain a Purger: it erases the deleted nodes of some change sets, or of a whole folder, with their journal rows, the change sets that nothing is left in, and the blobs that no row refers to. Both stores implement it and pass the same contract specs. A blob is removed after a grace period, and only if it is still unused then, so an upload of the same bytes in flight keeps them. DELETE /api/chats/files/trash empties the user's Trash and DELETE /api/chats/files/trash/{rev} deletes one entry. Both are scoped to the caller's home. The Trash listing now gives files, folders and bytes per entry and for the whole Trash. The file tools of a chat cannot reach any of this. Assisted-by: Claude:claude-sonnet-5-5 * Add Empty Trash and Delete permanently to the Files page The Trash tab shows what it holds, with an Empty Trash button that is off while the Trash is empty and a Delete permanently button on each item. Both ask in the page first and say how many files and folders and how much space they remove. Texts are translated in all nine locales. Assisted-by: Claude:claude-sonnet-5-5 * Fall back to Brave Search and Bing when DuckDuckGo asks for a captcha DuckDuckGo answers some servers with HTTP 202 and a bot check (the "anomaly" page) instead of results. The parser read that page as "no results it could read", so web_search failed for every query. The keyless search is now a chain: DuckDuckGo, then the result page of Brave Search, then the result page of Bing. Each page has its own tolerant parser (title, URL, snippet; Bing's /ck/a links and DDG's uddg links are unwrapped) with saved fixtures. A bot check is a typed error (ErrChallenged), and a 403 or 429 is ErrBlocked. Both wrap ErrProvider. A Fallback with a cool-down skips a challenged or blocked provider for a few minutes, so each search does not wait for it first; when every provider is cooling down, all are asked again. Fallback.SearchAttempts and TestEach report what each provider did (ok, no_results, challenged, blocked, timeout, error, skipped). The error a model reads now lists what was tried and what each provider did, then where an administrator fixes it. safehttp keeps the status code of an answer (Response.StatusCode) and of a refusal (StatusError, still ErrStatus for errors.Is). Assisted-by: Claude:claude-opus-5-5 * Use the keyless search chain in the chats and test each provider The built-in web_search now searches with DuckDuckGo, Brave Search and Bing in turn when no SearXNG instance is set. A new runtime setting, chats_web_search_providers, sets the order (searxng, duckduckgo, brave_html, bing_html) and leaves a provider out. Empty keeps the old choice: SearXNG alone when an instance is set, so its queries do not reach the public engines unless the administrator lists them. POST /api/chats/admin/web/test asks every provider of the order and returns what each did in "providers" (ok, no_results, challenged, blocked, timeout or error, with the reason), next to the first answer. Settings > Tools has a Test search on the web_search row that shows it, and the provider list is checked on the page and by the server. The cool-down of a challenged provider is kept per frontend, like the cache and the limits; web.md says why. Docs: web.md (providers, chain, bot checks, privacy, how to fix it with SearXNG or the Web add-on) and runtime-settings.md. Strings in the nine locales. Swagger regenerated. Assisted-by: Claude:claude-opus-5-5 * Try the mirror library alone in the reinstall recovery spec The spec adds a second library with the same URL and expects an install from it to keep the retained data apart. Since two enabled libraries that list one add-on are a conflict, the reinstall from the original library then failed with library_conflict, and the install from the mirror failed for that reason instead of the trusted identity. Switch the original library off while the mirror is tried, check that the mirror is refused for its identity and not for a conflict, then switch the mirror off and the original on for the reinstall. Assisted-by: Claude:claude-opus-5-5 * Pause a chat turn at the step limit and offer Continue A chat turn ran at most 10 rounds of tool calls, the default of the harness, because nothing set a limit. A multi-step task (read some files, run commands, write a result) stopped half way, and the person saw an answer that looked final. The limit is now the runtime setting chats_max_iterations (1 to 500, default 40, Settings > Tools); a model's agent.max_iterations still wins. When the tool loop reaches it, the harness sends a paused event with the limit, and the turn ends with last_turn "paused". A paused turn counts as finished: waiting messages start the next turn and a sub-agent reports back. The status line says "Paused, after 40 steps" and has a Continue button, which sends a message that asks the model to go on. A claim that found no waiting message released the chat as "done", which hid a pause. It now keeps the status the chat had. Assisted-by: Claude:claude-opus-5-5 * Edit the always-allowed Sandbox hosts in Settings > Tools Add a list editor for chats_egress_allowed_hosts next to the extra package registries: the hosts are tags with a remove button, an input adds one on Enter, comma or Add (a pasted list adds them all), and the reason an entry is refused shows under the input, with the count out of 64. The same checks stop Apply on a bad entry, and the server still has the last word. The chat's Network panel lists the hosts read only and the row says how many an administrator always allows while the chat's network is on. The help text of both settings now says how they differ. Strings are translated in all nine locales. Assisted-by: Claude:claude-sonnet-5-5 * Document the always-allowed Sandbox hosts Describe chats_egress_allowed_hosts in the runtime settings page, the network section of the add-ons page (including the allowed_host audit reason and how the list differs from extra registries) and the chats page. Assisted-by: Claude:claude-sonnet-5-5 * Let a chat edit, pause and delete the person's triggers A chat could propose a trigger and list them, but not change one: the Triggers page and the API could. list_triggers now gives each trigger a stable ref (not its id) and shows the prompt of one trigger only when asked. update_trigger and delete_trigger store a change record in the trigger table: an edit or a delete waits for Apply or Delete on a card, a pause or a resume applies at once with a receipt. An applied change can be undone for a day; a delete restores the trigger with its id. Change records are rows in the database, so every frontend shows and answers the same card. The new routes under /api/chats/triggers/changes serve the card and are gated by the triggers feature. Assisted-by: Claude:claude-opus-5-5 * Show the trigger changes a chat asks for as cards An edit lists each changed field before and after with Apply and Dismiss, a delete shows the trigger and its last run with Delete and Keep, and an applied change is a receipt with Undo. The cards sit below the message text like the proposal card, and read their state from the server so a reload shows the same answer. Assisted-by: Claude:claude-opus-5-5 * Add a Test search button to the web tools settings POST /api/chats/admin/web/test existed, but no page called it and the docs showed only curl. The web_search row of Settings > Tools now has a Test search button. It runs one search with the applied settings and shows the provider with its first results, or the error a chat would read. Assisted-by: Claude:claude-opus-5-5 * Describe undo per tool call in the chats docs The docs said the checkpoint line of a turn carries the change set of the turn. A turn checkpoint is taken only by a chat with a VM sandbox, which the service never sets up, so that rev is always 0. What ships is one change set per file tool call, with a files_change card on its tool line. The docs now say so and how to undo a whole turn. Grouping a turn's changes into one change set in the file store is left for later: it touches the file store's revisions and the undo of shared folders. Assisted-by: Claude:claude-opus-5-5 * Mount the skill folders read-only for the built-in bash The built-in pdf skill ships scripts, but the commands saw only the home and the shared folders, so the model could read the scripts and not run them. The built-in bash now mounts the three skill layers read-only, at the names the file tools already use: shared-skills, shared-builtin-skills and shared-addon-skills. A shared folder named skills moves to "shared-skills 2", so the two never collide. A read-only mount with subfolders made every command report its folders as changes the mount refused, because the host backend lists every folder of its work tree. A folder alone is no change: both stores now refuse only files, whiteouts and opaque folders there. The Sandbox add-on mounts only the home; the docs say to copy a script there first. Assisted-by: Claude:claude-opus-5-5 * Find the add-on that runs commands by what it shadows The chats knew the add-on that runs commands from a per-process cache, filled with the Sandbox's own tool name the first time a chat listed its tools. Another add-on that replaces bash did not count, so skills that need commands showed as unavailable. The admin network view read the same cache: after a restart it said no Sandbox serves until someone chatted, each frontend of a cluster could answer differently, and a removed add-on stayed listed. The tool that runs commands is now the one that shadows bash, as the spec asks. sandboxRunTool and the cache are gone. The stand-in of an add-on that cannot work carries the built-in tools it replaces, and the admin view asks the add-on host which add-on serves bash now: the pinned one or the oldest running install, and none when the pinned one is stopped. Assisted-by: Claude:claude-opus-5-5 * Show international host names in punycode in the network checks The card-spoofing defence of the sandbox network asks for host names in their ASCII form, but CheckHost passed Unicode names through. A look-alike name reached the consent card as text that reads like the real site. CheckHost now converts an international name with the IDNA lookup profile and refuses one with no valid ASCII form. The conversion runs before the address checks, so full-width digits that spell a loopback address are refused as loopback. Assisted-by: Claude:claude-opus-5-5 * Keep the seeded Studio and Talk pins until the add-ons are installed The upgrade pins Studio and Talk for existing accounts, but both are add-ons that an administrator installs later. The sweep marked a pin whose add-on is not installed as missing and deleted it after 30 days, so an administrator who installed Studio late found the seeded pins gone. The sweep now records the first time a seeded add-on is present, in the table of seed runs or the pins file. A seeded pin keeps no clock until then, and a mark that an older sweep set is cleared. After the first install the pin follows the retention rule as before. Assisted-by: Claude:claude-opus-5-5 * Fix two small labels of the Skills and Backends pages On the Skills page, the skill of an add-on that is not chosen said "Replaced by ", the label of a built-in skill that an add-on serves. It now says "Not active: provides it", as the add-on skills spec asks. On the Backends page, Clear filters on an empty Installed search left the library filter set, so the list stayed empty. It now clears the library filter too. Assisted-by: Claude:claude-opus-5-5 * Tell the model when no network card can appear Each turn told the model that a blocked host asks the user. No card appears when the ceiling is off or no wider than the chat's preset, in an unattended run or in a sub-agent, so the model waited for an answer that could not come. The line now says that a blocked host is refused and nothing asks the user in those cases, so the model names the host it needs in its answer. Assisted-by: Claude:claude-opus-5-5 * Translate six Backends and preview labels in every locale check, reinstallBackend, deleteBackend and filterLabel of the Backends page and the label and failed text of the file preview showed in English in the eight other locales. They now have translations, and the i18n baseline goes down to match. Also fix a comment of the home composer that still named the removed Canvas chip. Assisted-by: Claude:claude-opus-5-5 * List the admin routes that have no assistant tool, with the reason The admin oversight routes of saved agents, triggers and connectors had no MCP tool and no recorded reason, and the other deliberate skips lived only in code comments. coverage_test.go now keeps them in adminRoutesWithoutTool with a reason each, and a spec checks that no route there is also wrapped by a tool. The contributor checklist names the map. The tool catalog of the assistant docs said it matched the real one but missed most tools added since. It now lists every tool, read-only and mutating, and the actions that stay in the web interface. Assisted-by: Claude:claude-opus-5-5 * Close the doc and registry gaps of the chats and add-ons - /api/agent/* answers 404 with the chats_off reason when the chats are off, as /api/chats/* and /api/workspace/* do. - The add-ons docs get an upgrade section: Studio and Talk are add-ons, nothing is preinstalled, and users need the addons feature, which the upgrade does not grant. It says how an administrator installs them (also from a private library) and grants the feature, and carries a paragraph for the release notes. - The migration page no longer lists fork, edit, the chat settings and the token counters as removed. - Runtime settings document chats_vision_model, chats_transcription_model and the skill keys of chats_tool_providers. - The docs show POST /api/chats/files/folders, GET /api/chats/files/history and the allow route of a connector sender. - The in-tree specs get amendments where the shipped design differs. - A route test of the tagged suite still set the removed Runs field. Assisted-by: Claude:claude-opus-5-5 * Look for the kit empty state in the backends regression spec The Backends page now draws the shared EmptyState of the kit (ui-empty), not the old dk-empty block, so the regression spec of the gallery suite found no empty state after a search with no match. Check the kit's no-results form and its title. Assisted-by: Claude:claude-opus-5-5 * Ignore a features request that outlives its page in the add-on stubs stubAddonRoutes answers /api/features by fetching the real flags and turning add-ons on. A spec that ends while the page polls, like the one that checks that a viewer without the feature never reads the list, left that fetch in flight, and Playwright failed the spec with "route.fetch: Target page, context or browser has been closed" after its checks had passed. Abort the request when the fetch fails because the page is gone. Assisted-by: Claude:claude-opus-5-5 * Bring the gallery-ui case inventory up to date The suite lists the node unit cases of addons.test.mjs and the Playwright specs of addons-gallery.spec.js. The inventory missed the unit cases added since it was written, and one spec was renamed when the legacy switch came in ("hub: the Add-ons tab appears only for an account that holds the feature" is now "hub: under the legacy switch the Add-ons tab appears ..."). The list comes from a local run of both parts against the UI test server. Assisted-by: Claude:claude-opus-5-5 * Read spreadsheets, documents and slides in read_document read_document said "file.xlsx has no text layer; it may be a scan" for workbooks that some Python libraries write. Their workbook gives the sheet as an absolute part name (/xl/worksheets/sheet1.xml) and the cells as inline strings. The text extractor of the harness joined "xl/" with that name, found no sheet, and returned no text; the tool then took any empty result for a scanned PDF. The new package core/services/studio/extract has one entry point, Extract(ctx, name, data), that returns the text and the structure it read, for read_document now and for a file indexer later. Office files go through the files preview reader, which resolves both kinds of part names and reads inline strings: - a workbook comes sheet by sheet, rows as tab-separated lines, up to 2000 rows and 50 columns a sheet, with a note for a cut sheet; - a Word document gives headings, list items, paragraphs and tables; - a presentation gives each slide with its title, text and notes. PDF and EPUB keep the harness extractor. Only a PDF with no text is reported as a probable scan; an empty Office file is reported as empty. The preview reader now keeps a Word table as one block with its rows, and the preview pane draws it as a table. Assisted-by: Claude:claude-opus-5-5 * Add the missing explorer, library and tools translations Eight locales had no explorer.json, library.json or tools.json. The browser asks the server for every namespace of the language it shows, so an Italian page that used the library answered 404 for /locales/it/library.json on every load and fell back to English. The 24 files now exist, translated, with every key of English. The locale gate counted a missing file as missing keys, which the baseline allowed. It now fails on a missing namespace file whatever the baseline says, and the baseline is 24 entries shorter. Assisted-by: Claude:claude-opus-5-5 * Back off while the add-on service is unavailable A page that followed an add-on upgrade read the operation every two seconds, up to 300 times. While the add-on service restarted, each read waited for its timeout and answered 503, so the log of one session had 48 failed dispatches in a row, and the page only showed the upgrade as running. The Sandbox state of a chat's Tools menu read once and, on a 503, said nothing more. The reads of an operation now back off while the service does not answer: 2, 4, 8, then up to 30 seconds. The upgrade entry says that it waits for the add-on service and when it checks again, in Activity and in the details, which also get a Check now button. The Tools menu says that the Sandbox is starting and reads its state again with the same back-off, kept for the page, so opening another chat does not ask again before the wait is over. Assisted-by: Claude:claude-opus-5-5 * Start a chat with its switches and list them before it exists The composer of a new chat could not show the Tools and Skills menus, because the lists and the switches belong to a chat. A chat started from Home therefore always began with every tool and skill on. - POST /api/chats takes switches: the add-ons, MCP servers and connectors, built-in groups and skills that are off, and the opt-in groups that are on. They are checked as the PUT endpoints check them and stored with the new record, so the chat never exists without them; a bad entry refuses the request and creates nothing. The SQL store now writes these lists on create too. - GET /api/chats/draft/tools and /api/chats/draft/addons list what a chat on a model, with a saved agent, could use, every switch in its default state. They refuse what create refuses: an unknown or forbidden model, an agent without the agents feature, another user's agent. Document both in the chats page and regenerate the swagger. Assisted-by: Claude:claude-opus-5-5 * Give Home the composer of the chat page Home drew a smaller message box than the chat page: no files, no agent, no Tools or Skills menus, no paste to attach, no rich model list, and its text was lost on a reload. A chat started there could not be set up before its first message. Move the composer wiring out of the chat page into one place that both pages use: - useChatComposer holds the draft (text and files), the model and the saved agent of a new chat, the chat models, the switches and what attaching and pasting do. - ChatComposer draws the model list (fit, vision, memory bar), the agent picker, the Tools and Skills menus, the attach buttons, dictation, the add-on slot and the vision notice over HomeComposer. - useChatSwitches has a draft mode: before the chat exists it lists from the draft endpoints, a switch changes only the screen, and the choices go with the create request. They outlive a change of model or agent. Home now creates the chat with the model, the agent and the drafted switches, sends the first message with its files and opens the chat. When the message cannot go, the new chat is removed and the text and files stay on Home. Its text is kept in the browser across a reload. Home also gets the shortcuts row, the notices of the chat page and the line that says whether the model is loaded. Its own slash actions, widgets, examples and first-run steps stay. The empty chat page gets the same draft menus. On a phone the chips of both wrap to a second row, and the Tools and agent menus open as a sheet from the bottom, as the skills picker does. Assisted-by: Claude:claude-opus-5-5 * Move add-on settings to the add-on's own page The settings.sections slot drew every add-on's settings at the end of the global Settings page, and the Sandbox and Web sections were unstyled: the add-on UIs used class names the host does not define (btn, btn-primary) and bare inputs, the SDK offered no form primitives, its muted token named a property that does not exist, and the slot root was an inline-flex span that laid a form out in a row. An add-on's settings sections now render on the Settings tab of its details on the Add-ons page (/app/addons?detail=&view=settings), narrowed to that add-on, for administrators only. An add-on page gets a host Settings link to that tab. The Settings page no longer draws the slot: an Add-ons group, shown when an add-on has settings, lists those add-ons with a link each. The old #ad-section-- anchor redirects to the tab once the apps list is read. Server settings of an add-on's feature stay on the Settings page; the tab shows them under "Server settings for this add-on", with a read-only summary of the Sandbox network and links to ?group=tools#settings-egress and #settings-builtin-tools. The SDK gets a settings kit in sdk.ui (Section, Field, Select, TextInput, Switch, Choice, StatusChip, Callout, List/ListRow, Code, Actions, Table, ExternalLink), drawn with host classes and tokens, and more theme tokens. The Sandbox and Web sections are rebuilt with it: backend rows with a status chip and the raw reason collapsed in a code block, a backend choice with one line per option, and the AppArmor and user-namespace hint (with a docs link) only for the gVisor "exit status 128 ... cannot read client sync file" failure. The Settings page showed "Changed, default 30, Reset" next to a 0 for the audit retention, and the same for the Sandbox network limit, preset and ports: the server stores 0 or an empty value for a setting never set. Those fields now read that value as their default, and the retention input has its unit and bounds. Assisted-by: Claude:claude-opus-5-5 * Adapt the add-on runtime specs to the new navigation Three specs of the esm-ui suite still assumed the old shell: - the Add-ons tab that lists the pages add-ons declare is part of the legacy layout, so the navigation spec found no hub subnav; it now sets the legacy switch on the server's flags for that page; - the sidebar has no "Chat" link any more, so the unsaved-edits spec could not leave the page; it leaves through Home in the primary navigation; - the shell keeps a notification stream open, so the network never goes idle and the spec for a member without the feature waited out its timeout; it waits for the server's switches, the account and the shell instead. Assisted-by: Claude:claude-opus-5-5 * Name the legacy Add-ons tab unit case as the esm unit tests do The esm-ui inventory listed "unit: registry: the Add-ons tab lists page links only when the registry has usable add-ons", but the unit test is named "the legacy Add-ons tab lists ...", so the suite could never match its inventory. Use the name the test prints. Assisted-by: Claude:claude-opus-5-5 * Say on the Collections page when the embedding model is missing At startup the server opened a collection and its first embedding call got 404 from POST /embeddings. The collections embed through this server's own API with collections_embedding_model, the default granite-embedding-107m-multilingual, and that model was not installed. Nothing told the user: uploads and searches just failed. GET /api/collections now also returns embedding_model and embedding_model_installed (a loaded configuration of that name, or a model file of that name). When it is missing, the Collections page says "No embedding model installed", names the model, and gives an administrator a button that opens the Models page with a search for it. Assisted-by: Claude:claude-opus-5-5 * Report modified times and the upload limit in folder listings The Files page could not sort by date or show when a file changed: a folder listing had no time. It also learned the upload limit only after it had sent the whole file. The default server body limit is 15 MB, lower than the 256 MiB a workspace file may have, so a large upload failed late and without a clear reason. Each listed entry now has "modified", the time of the change set of its revision. Both workspace stores fill it in List: the JSON store from its in-memory change sets, the database store with one query per batch of revisions. The listing also has "max_file_size", the smaller of the workspace limit and the server body limit, so a client can refuse a larger file before it sends any byte. The store contract suite checks the new time against the history, and the route tests check both fields. Assisted-by: Claude:claude-opus-5-5 * Add a sortable file list with thumbnails for the Files page The building blocks of the new file browser, used by the page in a later change: - fileBrowser.js: the kind of an entry from its extension, the order (folders first, natural and case-insensitive names, then size, modified time or type), the filter of a folder, dates in words with the full date for a tooltip, the Shift range, the arrow moves of a list and of a grid, type-to-find, names that do not clash, why a name cannot be used, the name of a pasted screenshot and the view settings read back from storage. - FileList: a grid with sortable column headers or a grid of tiles with image thumbnails, a check box per row, a row menu, inline rename with the reason a name is refused, rows that can be dragged onto folders, and the keyboard: arrows, Shift, Space, Enter, F2, Delete, Ctrl+A, Escape, Backspace and typing a name. - MoveDialog: a folder picker that walks the tree and refuses a move into the moving folder itself. - ActionMenu can open at a point with no trigger (a right click), and its trigger can take another icon and class (a split button). - fileSize also writes gigabytes. Assisted-by: Claude:claude-opus-5-5 * Add an upload queue with progress, cancel and retry An upload of the Files page was one fetch per file in a loop: nothing showed while it ran, one failure stopped the rest, and leaving the page lost it. - uploadQueue.js keeps every upload outside React: queued, sending with the bytes sent and a smoothed rate, done, failed with a reason and whether Try again can help, or canceled. Two files go at once. It lives for the whole app, so opening another folder or page does not stop an upload. - filesApi.uploadWithProgress sends the same PUT with XMLHttpRequest, which reports upload progress and can be aborted. The server already streams the body into the store; nothing changes there. - useUploads reads the queue and asks before the tab closes while a file is on its way. - UploadTray shows the queue at the bottom of the page: the total and the time left, each file with its progress, speed and time left, Cancel, Try again with the reason, and a done state that fades out. A live region tells a screen reader when uploads start, fail and end. - ConflictDialog asks what to do when an upload has the name of a file in the folder: replace it, keep both, or skip it. - dropFiles.js reads dropped folders with their paths. Assisted-by: Claude:claude-opus-5-5 * Rebuild the Files page as a file browser The Files page was a list of links: the store's byte order, the same icon for every file, three icon buttons on every row, no progress on an upload, no selection, and on a phone a preview that opened below the whole list. Browsing: a breadcrumb that folds a deep path, sortable columns (name, modified, type, size) with folders first, a list or a grid of thumbnails, both kept per user in the browser, a filter of the open folder with a search of every file on Enter, type icons, human sizes, dates in words with the full date on hover, and Back and Forward between folders. Uploads go through the upload queue and its tray: the Upload button or its folder menu, drops of files and folders anywhere on the page or onto a folder row or crumb, and paste. A clash asks to replace, keep both or skip; a file over the server limit is refused before it is sent. Selection and actions: click, Ctrl and Shift, check boxes, a selection bar (Download, Move to, Move to Trash; at the bottom of the screen on a phone), a row menu and a right click, inline rename and new folder or file, drag to move, the keyboard, and a toast with Undo for a delete, a move and a rename. States: a skeleton on the first load, an error with Try again, empty states that say how files get here, and a no-match state. The preview uses the side pane: docked on a wide screen, a sheet on a phone, with the same actions as the row menu. Trash, History and the Managed by LocalAI group stay as they were. Every new string is in the nine languages. The page uses design tokens only (seven raw font sizes fewer). The specs cover sorting, the filter, the views, selection and bulk actions, the keyboard, the menu, rename checks, drag to move, drops, paste, clashes, the size limit, upload progress, cancel and retry against the real server, and the error states. Assisted-by: Claude:claude-opus-5-5 * Fix two specs that fail on the integration branch The Describe spec of the egress package checked that the no-card line does not contain "asks the user", but that line says "nothing asks the user". It now checks for the sentence that promises a card. The context meter spec gave the chat a 4k context window. The tool schemas of the chat now take 3k of it, so the turn stopped before the model request. The spec uses a 16k window. Assisted-by: Claude:claude-opus-5-5 * Translate the always-allowed hosts note of the chat network row The three egress keys of the always-allowed hosts were only in English, so the locale check failed for every chats namespace. The workspace namespaces now miss two keys fewer, so their baseline goes down too. Assisted-by: Claude:claude-opus-5-5 * Bring the contracts and host-slots case inventories up to date The contracts suite has eight new specs for the check of a tool that shadows a built-in. The host-slots suite runs the unit case of the file preview slot, which the inventory did not list. Assisted-by: Claude:claude-opus-5-5 * Add the design of the file text index The search of the chats' files reads only small UTF-8 text files, so a PDF or an Office file is found by name only. The design extracts the text when a file is written, keeps it in LocalAI's own store keyed by node and hash, checks access at query time and runs one indexer per cluster under an advisory lock. Assisted-by: Claude:claude-opus-5-5 * Give extracted documents their pages, sheets and slides Extract now says where each page, sheet and slide starts in the text, so a search hit can name its page or sheet. A PDF page starts with a "## Page N" line, as sheets and slides already start with a heading. A PDF also stops at a page limit (200 by default) and at the text limit, which it ignored before, and a panic of the reader is an error. The test fixtures move to extracttest, with a real text PDF and a workbook with shared strings, so other packages can build the same documents. Assisted-by: Claude:claude-opus-5-5 * Add a store for the text of workspace documents textindex keeps one entry per workspace node: the text read from a PDF or Office file, the hash it was read from, its pages or sheets and its status (pending, ready, failed, skipped), plus the cursor of the indexer in the workspace journal. The GORM store keeps a lower-cased copy of the text, so SQL and the Go matcher agree on every script, and searches it with FTS5 and the trigram tokenizer on SQLite or a pg_trgm index on PostgreSQL. Without them it scans the column. The JSON store keeps the metadata in memory and each text in its own file, and scans the texts. One contract suite runs on JSON, SQLite and PostgreSQL. Assisted-by: Claude:claude-opus-5-5 * Index the text of new and changed workspace documents The indexer tails the workspace journal from a cursor kept in the text store. A write of a PDF, Word, Excel or PowerPoint file marks it pending (the same hash only follows the path), a delete removes the entry and a move rewrites the paths below a folder. Then it reads the pending files two at a time, with a timeout, a size cap, a page cap and a text cap, and stores ready, failed or skipped. A panic of the reader fails the file, not the pass. The same content is read once. The first pass starts at revision 0, which indexes the files that were there before, in bounded steps that resume from the cursor. Enqueue marks an upload pending at once and Forget drops the entries of nodes a purge erased. In a cluster each pass runs under a new advisory lock, so one frontend reads. Assisted-by: Claude:claude-opus-5-5 * Search and read the text of documents in the chats' files search_files and GET /api/chats/files/search now also search the text index: after the name and text-file hits come lines of PDF, Word, Excel and PowerPoint files, as path (page 3, line 40): text, with the sheet or slide where a document has them. Each entry is shown only when the caller may read its file now and the file still has the content the text came from. Documents keep up to a third of the hits. When files below the path are still pending, the answer says so instead of "Nothing found.", and the REST result has indexing. read_file returns the text of a PDF or Office file with the usual offset and limit, from the index when it matches the file and read on the spot otherwise. An upload and a write of a chat tool queue the file at once, and emptying the Trash drops the entries of the purged files. Assisted-by: Claude:claude-opus-5-5 * Start the file text index with the files and add its settings With files on, the server opens the text store next to the workspace index (the auth database, or a folder under the data path) and starts the indexer. A chat worker searches the index but runs no indexer. chats_index_files (default on) switches the reading off and chats_index_max_text_bytes (2 MiB by default, 64 KiB to 16 MiB) caps the text kept for one document. Both apply at the next pass, and both are rows of Settings > Tools. Assisted-by: Claude:claude-opus-5-5 * Show the page, sheet or slide of a document hit on the Files page Search all files now lists hits inside PDF and Office files with their page, sheet or slide instead of a line number of the extracted text, and says how many documents are still being indexed. The strings are in all nine locales. Assisted-by: Claude:claude-opus-5-5 * Test finding an uploaded PDF by its words through the routes The PDF has a generated name and is uploaded with the REST upload. The search route first reports it as being indexed, then finds it on its page after a pass of the indexer, and so does the search_files tool of a chat. Another user finds nothing. Assisted-by: Claude:claude-opus-5-5 * Document the search inside documents The chats page says what the index reads, where the text is kept, who finds it, which search engine each database uses, the two settings, the backfill and the single-runner mode in a cluster, and how search_files and read_file answer for documents. The API instructions and the PDF skill say the same in short. Assisted-by: Claude:claude-opus-5-5 * Test the indexer on the JSON workspace store A server without a database keeps the workspace and the text in JSON files. The indexer follows the uploads, a folder move and a delete in that journal as it does in the database one. Assisted-by: Claude:claude-opus-5-5 * Read all of a long document with read_file The index keeps the first chats_index_max_text_bytes of a document, so read_file used to stop where the index stopped. It now uses the index only when it holds the whole text, and reads the document again (up to 16 MiB of text) when the index cut it. Assisted-by: Claude:claude-opus-5-5 * Start bash in the user's home The Sandbox's bash and the built-in bash started in /workspace, the folder that holds the mounts, so a model that ran a script by a relative path failed and a file it saved there was lost. A call now starts in /workspace/home, the folder of the user's files: a relative path is the same file the file tools see. The tool descriptions and the commands block of every turn say where a call starts and that only what is saved there is kept. The built-in bash says that the /workspace paths do not exist on the host and that a shared folder is next to the home. Assisted-by: Claude:claude-opus-5-5 * Name files in tool results by the path the model uses write_file, edit_file and search_files named a file of the home by its path in the home ("/scripts/build.py") while the model had written /workspace/home/scripts/build.py, and a model took the two spellings for two files. The results now give the path the model uses, and move_file says where it moved the file to. The cards and the change notes keep the path in the home. Assisted-by: Claude:claude-opus-5-5 * Name the paths a command's commit did not save The result of a bash call ended with "N file(s) changed in your files meanwhile; the sandbox's version was kept as a copy" for every path the commit refused, also for a file written outside the user's files, where no copy is kept. It now has one line a path, with the path as the guest sees it, the reason, and the path of the copy when the store kept one, so the model and the user can act on it. The host returns the paths and the copy to the Sandbox add-on relative to the user's files, as its mounts are. Assisted-by: Claude:claude-opus-5-5 * Show the files a command saved under its step A document that a script wrote with bash reached the user's files, but the chat showed nothing: only the file tools put a file on their step, and the Sandbox committed for no chat, so the chat's file list, the chips under the answer and the preview pane never saw the file. The host now commits a Sandbox call for the chat that ran it (the action resource of the request names the chat). After each bash call, of the Sandbox or of this machine, the chat reads the change sets of the chat that the call made from the shared store and puts the files on the step's card, next to the card that says who served the call (session.SetToolFiles; files with path, change set and op on the card's new files field). The step lists them as chips that open the preview pane, the chat's clients are told that its files changed, and the files reach the file list, so the answer gets its chips and the pane opens on the newest one, as for write_file. Assisted-by: Claude:claude-opus-5-5 * Keep JSON out of the answer of a trigger run The workspace skill told the model to put the widget data shape and a source marker in a trigger prompt. Some proposals then asked the run to print that JSON, and every run ended its chat with a stray line of it. The skill now says source and note live inside the data file and are never the answer, and that a prompt must not ask for JSON. The runner appends one closing instruction to the first message of every trigger run: finish with one short plain sentence. The propose_trigger and update_trigger descriptions say the prompt must stand alone and must not ask for JSON. Connector chats are talks with people and do not get the line. Assisted-by: Claude:claude-sonnet-5-5 * Read the images of a page as base64 for its frame The bridge of a page reads UTF-8 text only, so the UI could not put a png or a jpg of the page's folder into the page. A read of the page file routes with encoding=base64 now returns an image as base64 with its media type. Only the extensions of an image are read this way (png, jpg, gif, webp, avif, bmp, ico, svg); any other file is 415, so this is no way to read an arbitrary binary file. The path checks, the 1 MiB limit, the viewer's rights and, for a shared page, the consent check are the same as for a text read. Assisted-by: Claude:claude-opus-5-5 * Let a page use the files next to it A page runs from a srcdoc with no network, so fetch('news.json'), ' }, { name: 'gvisor', usable: true }, { name: 'host', usable: true }] })) + const html = renderToString(React.createElement(View, { model: m, draft: 'host', phrase: '', busy: false, error: '', onDraft() {}, onPhrase() {}, onSave() {} })) + assert.ok(!html.includes('' } })) + const html = renderToString(React.createElement(View, { model: m, draft: draftOf(m), busy: false, error: '', saved: false, tests: [], onDraft() {}, onSave() {}, onTest() {} })) + assert.ok(!html.includes('` - - BeforeEach(func() { - store := NewCanvasPreviewStore() - e = echo.New() - e.Use(middleware.SecurityHeaders()) - e.POST("/api/canvas/preview", CreateCanvasPreviewEndpoint(store)) - e.GET("/api/canvas/preview/:id", GetCanvasPreviewEndpoint(store)) - e.GET("/other", func(c echo.Context) error { return c.String(http.StatusOK, "ok") }) - }) - - create := func(body string) *httptest.ResponseRecorder { - req := httptest.NewRequest(http.MethodPost, "/api/canvas/preview", strings.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - rec := httptest.NewRecorder() - e.ServeHTTP(rec, req) - return rec - } - - It("serves the stored HTML with a CDN-friendly CSP", func() { - rec := create(`{"code":""}`) - Expect(rec.Code).To(Equal(http.StatusOK)) - id := strings.Trim(strings.Split(rec.Body.String(), `"id":"`)[1], "\"}\n") - - req := httptest.NewRequest(http.MethodGet, "/api/canvas/preview/"+id, nil) - out := httptest.NewRecorder() - e.ServeHTTP(out, req) - Expect(out.Code).To(Equal(http.StatusOK)) - Expect(out.Body.String()).To(Equal(page)) - csp := out.Header().Get("Content-Security-Policy") - for _, host := range []string{"https://unpkg.com", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://cdn.tailwindcss.com", "https://code.jquery.com"} { - Expect(csp).To(ContainSubstring(host)) - } - Expect(csp).To(ContainSubstring("default-src 'none'")) - Expect(csp).To(ContainSubstring("frame-ancestors 'self'")) - Expect(out.Header().Values("Content-Security-Policy")).To(HaveLen(1)) - }) - - It("returns 404 for an unknown id", func() { - req := httptest.NewRequest(http.MethodGet, "/api/canvas/preview/nope", nil) - out := httptest.NewRecorder() - e.ServeHTTP(out, req) - Expect(out.Code).To(Equal(http.StatusNotFound)) - }) - - It("keeps the global CSP on other routes", func() { - req := httptest.NewRequest(http.MethodGet, "/other", nil) - out := httptest.NewRecorder() - e.ServeHTTP(out, req) - csp := out.Header().Get("Content-Security-Policy") - Expect(csp).To(ContainSubstring("script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:;")) - Expect(csp).ToNot(ContainSubstring("unpkg.com")) - }) -}) diff --git a/core/http/endpoints/localai/chat_history.go b/core/http/endpoints/localai/chat_history.go deleted file mode 100644 index f407f5e2d..000000000 --- a/core/http/endpoints/localai/chat_history.go +++ /dev/null @@ -1,189 +0,0 @@ -package localai - -import ( - "errors" - "net/http" - - "github.com/labstack/echo/v4" - "github.com/mudler/LocalAI/core/schema" - "github.com/mudler/LocalAI/core/services/chathistory" -) - -// chatHistoryError maps store errors onto HTTP status codes. A deleted -// conversation is 410 Gone, so a client holding a stale copy can tell it -// apart from a transient failure and drop the copy instead of retrying. -func chatHistoryError(c echo.Context, err error) error { - switch { - case errors.Is(err, chathistory.ErrInvalidID), errors.Is(err, chathistory.ErrInvalidUserID): - return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) - case errors.Is(err, chathistory.ErrNotFound): - return c.JSON(http.StatusNotFound, map[string]string{"error": err.Error()}) - case errors.Is(err, chathistory.ErrDeleted): - return c.JSON(http.StatusGone, map[string]string{"error": err.Error()}) - case errors.Is(err, chathistory.ErrConflict): - return c.JSON(http.StatusConflict, map[string]string{"error": err.Error()}) - default: - return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) - } -} - -// ListConversationsEndpoint lists the current user's conversations, plus the -// ids of conversations the user deleted (tombstones). -// -// @Summary List chat conversations -// @Description Returns the live conversations, newest first, and in `deleted` the conversations the user deleted. A client must drop any local copy whose id appears in `deleted` instead of uploading it again. -// @Tags chat-history -// @Produce json -// @Success 200 {object} schema.ConversationList -// @Router /api/conversations [get] -func ListConversationsEndpoint(store *chathistory.Store) echo.HandlerFunc { - return func(c echo.Context) error { - userID := getUserID(c) - convs, err := store.List(userID) - if err != nil { - return chatHistoryError(c, err) - } - deleted, err := store.Tombstones(userID) - if err != nil { - return chatHistoryError(c, err) - } - return c.JSON(http.StatusOK, schema.ConversationList{Conversations: convs, Deleted: deleted}) - } -} - -// GetConversationEndpoint returns a single conversation by ID. -// -// @Summary Get a chat conversation -// @Tags chat-history -// @Produce json -// @Param id path string true "Conversation ID" -// @Success 200 {object} schema.Conversation -// @Failure 404 {object} map[string]string "Never existed" -// @Failure 410 {object} map[string]string "Deleted" -// @Router /api/conversations/{id} [get] -func GetConversationEndpoint(store *chathistory.Store) echo.HandlerFunc { - return func(c echo.Context) error { - conv, err := store.Get(getUserID(c), c.Param("id")) - if err != nil { - return chatHistoryError(c, err) - } - return c.JSON(http.StatusOK, conv) - } -} - -// SaveConversationEndpoint upserts a conversation. The body's id field is the -// canonical identifier; a path id is also accepted and overrides the body -// when both are present (so PUT /api/conversations/ works as expected). -// -// @Summary Save a chat conversation (upsert) -// @Description Stores the conversation and returns it with its new server-assigned `version`. `createdAt`/`updatedAt` are kept as sent (Unix ms). Writing a conversation the user deleted returns 410 and does not bring it back. -// @Tags chat-history -// @Accept json -// @Produce json -// @Param body body schema.Conversation true "Conversation payload" -// @Success 200 {object} schema.Conversation -// @Failure 400 {object} map[string]string "Invalid id or body" -// @Failure 410 {object} map[string]string "Deleted" -// @Router /api/conversations [post] -func SaveConversationEndpoint(store *chathistory.Store) echo.HandlerFunc { - return func(c echo.Context) error { - var conv schema.Conversation - if err := c.Bind(&conv); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) - } - if pathID := c.Param("id"); pathID != "" { - conv.ID = pathID - } - saved, err := store.Save(getUserID(c), conv) - if err != nil { - return chatHistoryError(c, err) - } - return c.JSON(http.StatusOK, saved) - } -} - -// SaveConversationByIDEndpoint is SaveConversationEndpoint under its own -// name so the PUT route gets its own swagger entry. -// -// @Summary Save a chat conversation by id (upsert) -// @Tags chat-history -// @Accept json -// @Produce json -// @Param id path string true "Conversation ID (overrides the body id)" -// @Param body body schema.Conversation true "Conversation payload" -// @Success 200 {object} schema.Conversation -// @Failure 400 {object} map[string]string "Invalid id or body" -// @Failure 410 {object} map[string]string "Deleted" -// @Router /api/conversations/{id} [put] -func SaveConversationByIDEndpoint(store *chathistory.Store) echo.HandlerFunc { - return SaveConversationEndpoint(store) -} - -// BulkReplaceConversationsEndpoint replaces the user's live conversation set -// in one transaction. -// -// @Summary Replace all chat conversations -// @Description Upserts every conversation in the payload and deletes the user's other live conversations. Ids the user deleted earlier are not written and are listed in `skipped`. -// @Tags chat-history -// @Accept json -// @Produce json -// @Param body body schema.ConversationBulkRequest true "Conversations" -// @Success 200 {object} schema.ConversationBulkResponse -// @Failure 400 {object} map[string]string "Invalid id or body" -// @Router /api/conversations/bulk [put] -func BulkReplaceConversationsEndpoint(store *chathistory.Store) echo.HandlerFunc { - return func(c echo.Context) error { - var payload schema.ConversationBulkRequest - if err := c.Bind(&payload); err != nil { - return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) - } - skipped, err := store.ReplaceAll(getUserID(c), payload.Conversations) - if err != nil { - return chatHistoryError(c, err) - } - if skipped == nil { - skipped = []string{} - } - return c.JSON(http.StatusOK, schema.ConversationBulkResponse{ - Status: "ok", - Count: len(payload.Conversations) - len(skipped), - Skipped: skipped, - }) - } -} - -// DeleteConversationEndpoint deletes a single conversation, leaving a -// tombstone. Idempotent. -// -// @Summary Delete a chat conversation -// @Description Deletes the conversation and keeps a tombstone so no client can write it back. Deleting an unknown or already deleted id succeeds. -// @Tags chat-history -// @Produce json -// @Param id path string true "Conversation ID" -// @Success 200 {object} map[string]string -// @Failure 400 {object} map[string]string "Invalid id" -// @Router /api/conversations/{id} [delete] -func DeleteConversationEndpoint(store *chathistory.Store) echo.HandlerFunc { - return func(c echo.Context) error { - if err := store.Delete(getUserID(c), c.Param("id")); err != nil { - return chatHistoryError(c, err) - } - return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) - } -} - -// DeleteAllConversationsEndpoint deletes the user's entire chat history. -// -// @Summary Delete all chat conversations for the current user -// @Tags chat-history -// @Produce json -// @Success 200 {object} map[string]string -// @Router /api/conversations [delete] -func DeleteAllConversationsEndpoint(store *chathistory.Store) echo.HandlerFunc { - return func(c echo.Context) error { - if err := store.DeleteAll(getUserID(c)); err != nil { - return chatHistoryError(c, err) - } - return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) - } -} diff --git a/core/http/endpoints/localai/collections_embedding_internal_test.go b/core/http/endpoints/localai/collections_embedding_internal_test.go new file mode 100644 index 000000000..e227cf1da --- /dev/null +++ b/core/http/endpoints/localai/collections_embedding_internal_test.go @@ -0,0 +1,50 @@ +package localai + +import ( + "context" + "os" + "path/filepath" + + "github.com/mudler/LocalAI/core/application" + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/pkg/system" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("the embedding model of the collections", func() { + var ( + app *application.Application + models string + ) + + BeforeEach(func() { + tmp := GinkgoT().TempDir() + models = filepath.Join(tmp, "models") + Expect(os.MkdirAll(models, 0o755)).To(Succeed()) + st, err := system.GetSystemState(system.WithModelPath(models), system.WithBackendPath(filepath.Join(tmp, "backends"))) + Expect(err).ToNot(HaveOccurred()) + ctx, cancel := context.WithCancel(context.Background()) + DeferCleanup(cancel) + app, err = application.New(config.WithContext(ctx), config.WithSystemState(st), config.WithDisableLocalAIAssistant(true), config.WithDisableStats(true)) + Expect(err).ToNot(HaveOccurred()) + }) + + It("is missing when the server has neither its configuration nor its file", func() { + Expect(embeddingModelInstalled(app, "granite-embedding-107m-multilingual")).To(BeFalse()) + Expect(embeddingModelInstalled(app, "")).To(BeFalse()) + Expect(embeddingModelInstalled(app, "../outside")).To(BeFalse()) + }) + + It("is installed when its configuration is loaded", func() { + Expect(os.WriteFile(filepath.Join(models, "emb.yaml"), []byte("name: emb\nbackend: llama-cpp\nembeddings: true\nparameters:\n model: emb.gguf\n"), 0o644)).To(Succeed()) + Expect(app.ModelConfigLoader().LoadModelConfigsFromPath(models)).To(Succeed()) + Expect(embeddingModelInstalled(app, "emb")).To(BeTrue()) + }) + + It("is installed when a model file of that name is in the models folder", func() { + Expect(os.WriteFile(filepath.Join(models, "plain.gguf"), []byte("x"), 0o644)).To(Succeed()) + Expect(embeddingModelInstalled(app, "plain.gguf")).To(BeTrue()) + }) +}) diff --git a/core/http/endpoints/localai/engine_start_test.go b/core/http/endpoints/localai/engine_start_test.go new file mode 100644 index 000000000..52d4129ac --- /dev/null +++ b/core/http/endpoints/localai/engine_start_test.go @@ -0,0 +1,32 @@ +package localai + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/pkg/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("a job start whose engine is not installed", func() { + reply := func(err error) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + c := echo.New().NewContext(httptest.NewRequest(http.MethodPost, "/", nil), rec) + Expect(jobStartFailure(c, "quantization", err)).To(Succeed()) + return rec + } + It("is a conflict that names the engine, not a server error", func() { + rec := reply(fmt.Errorf("failed to load backend llama-cpp-quantization: %w", fmt.Errorf("%w: llama-cpp-quantization", model.ErrBackendNotFound))) + Expect(rec.Code).To(Equal(http.StatusConflict)) + Expect(rec.Body.String()).To(ContainSubstring("quantization engine llama-cpp-quantization is not installed")) + }) + It("keeps any other failure a server error with its text", func() { + rec := reply(errors.New("disk full")) + Expect(rec.Code).To(Equal(http.StatusInternalServerError)) + Expect(rec.Body.String()).To(ContainSubstring("disk full")) + }) +}) diff --git a/core/http/endpoints/localai/finetune.go b/core/http/endpoints/localai/finetune.go index 8405ad0c4..59c30339c 100644 --- a/core/http/endpoints/localai/finetune.go +++ b/core/http/endpoints/localai/finetune.go @@ -42,9 +42,7 @@ func StartFineTuneJobEndpoint(ftService *finetune.FineTuneService) echo.HandlerF resp, err := ftService.StartJob(c.Request().Context(), userID, req) if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{ - "error": err.Error(), - }) + return jobStartFailure(c, "fine-tuning", err) } return c.JSON(http.StatusCreated, resp) diff --git a/core/http/endpoints/localai/library_sources_test.go b/core/http/endpoints/localai/library_sources_test.go index 2716cc74f..81f115371 100644 --- a/core/http/endpoints/localai/library_sources_test.go +++ b/core/http/endpoints/localai/library_sources_test.go @@ -216,9 +216,10 @@ var _ = Describe("Library source routes", func() { return srv.URL } for _, id := range []string{"first", "second"} { - rec := call(auth.RoleAdmin, http.MethodPost, "/api/library/sources", `{"kind":"addon","id":"`+id+`","url":"`+serve("tables")+`"}`) - Expect(rec.Code).To(Equal(http.StatusCreated), rec.Body.String()) - _, err := store.Refresh(GinkgoT().Context(), id) + // The route refuses an add-on library over plain http on this machine, so add it to the store. + _, err := library.AddWithMirrors(store, catalog.KindAddon, serve("tables"), id, "", "", nil) + Expect(err).NotTo(HaveOccurred()) + _, err = store.Refresh(GinkgoT().Context(), id) Expect(err).NotTo(HaveOccurred()) } for _, id := range []string{"first", "second"} { diff --git a/core/http/endpoints/localai/nodes.go b/core/http/endpoints/localai/nodes.go index b38cb5a60..e48f831f2 100644 --- a/core/http/endpoints/localai/nodes.go +++ b/core/http/endpoints/localai/nodes.go @@ -119,7 +119,7 @@ type RegisterNodeRequest struct { // RegisterNodeEndpoint registers a new backend node. // expectedToken is the registration token configured on the frontend (may be empty to disable auth). // autoApprove controls whether new nodes go directly to "healthy" or require admin approval. -func RegisterNodeEndpoint(registry *nodes.NodeRegistry, expectedToken string, autoApprove bool, authDB *gorm.DB, hmacSecret string, natsCfg natsauth.Config) echo.HandlerFunc { +func RegisterNodeEndpoint(registry *nodes.NodeRegistry, expectedToken string, autoApprove bool, authDB *gorm.DB, _ string, natsCfg natsauth.Config) echo.HandlerFunc { return func(c echo.Context) error { var req RegisterNodeRequest if err := c.Bind(&req); err != nil { @@ -232,29 +232,11 @@ func RegisterNodeEndpoint(registry *nodes.NodeRegistry, expectedToken string, au "created_at": node.CreatedAt, } - // Provision API key for agent workers that are approved (not pending). - // On re-registration of a previously approved node, revoke old + provision new. - if nodeType == nodes.NodeTypeAgent && authDB != nil && node.Status != nodes.StatusPending { - // Use a transaction so that if provisioning fails after revoking old creds, - // the old credentials are not lost. - txErr := authDB.Transaction(func(tx *gorm.DB) error { - if node.AuthUserID != "" { - if err := tx.Exec("DELETE FROM users WHERE id = ?", node.AuthUserID).Error; err != nil { - return fmt.Errorf("revoking old credentials: %w", err) - } - node.AuthUserID = "" - node.APIKeyID = "" - } - plaintext, err := provisionAgentWorkerKey(ctx, tx, registry, node, hmacSecret) - if err != nil { - return err - } - response["api_token"] = plaintext - return nil - }) - if txErr != nil { - xlog.Warn("Failed to auto-provision API key for agent worker", "node", node.Name, "error", txErr) - } + // An earlier release gave every agent worker a user and an API key with + // the collections grant. The worker no longer calls the HTTP API, so + // remove what is left of that account the next time the node registers. + if nodeType == nodes.NodeTypeAgent { + revokeLegacyAgentWorkerAccount(ctx, authDB, registry, node) } attachNatsJWT(response, node, natsCfg) @@ -264,8 +246,7 @@ func RegisterNodeEndpoint(registry *nodes.NodeRegistry, expectedToken string, au } // ApproveNodeEndpoint approves a pending node, setting its status to healthy. -// For agent workers, it also provisions an API key so they can call the inference API. -func ApproveNodeEndpoint(registry *nodes.NodeRegistry, authDB *gorm.DB, hmacSecret string, natsCfg natsauth.Config) echo.HandlerFunc { +func ApproveNodeEndpoint(registry *nodes.NodeRegistry, _ *gorm.DB, _ string, natsCfg natsauth.Config) echo.HandlerFunc { return func(c echo.Context) error { ctx := c.Request().Context() id := c.Param("id") @@ -286,15 +267,6 @@ func ApproveNodeEndpoint(registry *nodes.NodeRegistry, authDB *gorm.DB, hmacSecr "message": "node approved", } - // Provision API key for newly approved agent workers - if node.NodeType == nodes.NodeTypeAgent && authDB != nil && node.AuthUserID == "" { - if plaintext, err := provisionAgentWorkerKey(ctx, authDB, registry, node, hmacSecret); err != nil { - xlog.Warn("Failed to provision API key on approval", "node", node.Name, "error", err) - } else { - response["api_token"] = plaintext - } - } - attachNatsJWT(response, node, natsCfg) return c.JSON(http.StatusOK, response) @@ -315,45 +287,35 @@ func attachNatsJWT(response map[string]any, node *nodes.BackendNode, natsCfg nat response["nats_user_seed"] = seed } -// provisionAgentWorkerKey creates a dedicated user and API key for an agent worker node. -// Returns the plaintext API key on success. -func provisionAgentWorkerKey(ctx context.Context, authDB *gorm.DB, registry *nodes.NodeRegistry, node *nodes.BackendNode, hmacSecret string) (string, error) { - workerUser := &auth.User{ - ID: uuid.New().String(), - Name: "agent-worker:" + node.Name, - Provider: auth.ProviderAgentWorker, - Subject: node.ID, - Role: "user", - Status: "active", - CreatedAt: time.Now(), +// revokeLegacyAgentWorkerAccount deletes the user and API key an earlier release +// provisioned for an agent worker, and clears the references on the node. It +// does nothing when the node has no such account. Failures are logged: the +// registration itself must not fail because of cleanup. +func revokeLegacyAgentWorkerAccount(ctx context.Context, authDB *gorm.DB, registry *nodes.NodeRegistry, node *nodes.BackendNode) { + if authDB == nil || node.AuthUserID == "" { + return } - if err := authDB.Create(workerUser).Error; err != nil { - return "", fmt.Errorf("creating agent worker user: %w", err) + // Delete only the account that was provisioned for a worker, never a + // user the reference might point to by mistake. + var n int64 + if err := authDB.Model(&auth.User{}).Where("id = ? AND provider = ?", node.AuthUserID, auth.ProviderAgentWorker).Count(&n).Error; err != nil { + xlog.Warn("Failed to look up legacy agent worker account", "node", node.Name, "user", node.AuthUserID, "error", err) + return } - - plaintext, apiKey, err := auth.CreateAPIKey(authDB, workerUser.ID, "agent-worker:"+node.Name, "user", hmacSecret, nil) - if err != nil { - return "", fmt.Errorf("creating API key: %w", err) + if n > 0 { + // The cascade removes the keys and sessions with the user. + if err := auth.DeleteUserCascade(authDB, node.AuthUserID); err != nil { + xlog.Warn("Failed to remove legacy agent worker account", "node", node.Name, "user", node.AuthUserID, "error", err) + return + } } - - node.AuthUserID = workerUser.ID - node.APIKeyID = apiKey.ID - if err := registry.UpdateAuthRefs(ctx, node.ID, workerUser.ID, apiKey.ID); err != nil { - xlog.Warn("Failed to update auth refs on node", "node", node.Name, "error", err) + if err := registry.UpdateAuthRefs(ctx, node.ID, "", ""); err != nil { + xlog.Warn("Failed to clear legacy agent worker references", "node", node.Name, "error", err) + return } - - // Grant collections feature so the worker can store/retrieve KB data on behalf of users. - perm := &auth.UserPermission{ - ID: uuid.New().String(), - UserID: workerUser.ID, - Permissions: auth.PermissionMap{auth.FeatureCollections: true}, - } - if err := authDB.Create(perm).Error; err != nil { - xlog.Warn("Failed to grant collections permission to agent worker", "node", node.Name, "error", err) - } - - xlog.Info("Provisioned API key for agent worker", "node", node.Name, "user", workerUser.ID) - return plaintext, nil + node.AuthUserID = "" + node.APIKeyID = "" + xlog.Info("Removed legacy agent worker account", "node", node.Name) } // loadCancelService builds the cancel path unload, drain and deregister share diff --git a/core/http/endpoints/localai/nodes_agent_worker_test.go b/core/http/endpoints/localai/nodes_agent_worker_test.go new file mode 100644 index 000000000..c2942b45a --- /dev/null +++ b/core/http/endpoints/localai/nodes_agent_worker_test.go @@ -0,0 +1,103 @@ +package localai + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/http/auth" + "github.com/mudler/LocalAI/core/services/nodes" + "github.com/mudler/LocalAI/pkg/natsauth" + "gorm.io/gorm" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The agent worker no longer calls the HTTP API, so registering or approving +// one must not mint a user or an API key. A key with the collections grant +// would let a worker host act on any user's collections for no reason. +var _ = Describe("Agent worker registration", func() { + var ( + db *gorm.DB + registry *nodes.NodeRegistry + ) + + BeforeEach(func() { + var err error + db, err = auth.InitDB(":memory:") + Expect(err).ToNot(HaveOccurred()) + registry, err = nodes.NewNodeRegistry(db) + Expect(err).ToNot(HaveOccurred()) + }) + + register := func(autoApprove bool) map[string]any { + e := echo.New() + body := `{"name":"agent-1","node_type":"agent"}` + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + rec := httptest.NewRecorder() + Expect(RegisterNodeEndpoint(registry, "", autoApprove, db, "secret", natsauth.Config{})(e.NewContext(req, rec))).To(Succeed()) + Expect(rec.Code).To(Equal(http.StatusCreated)) + var resp map[string]any + Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed()) + return resp + } + + countUsers := func() int64 { + var n int64 + Expect(db.Model(&auth.User{}).Where("provider = ?", auth.ProviderAgentWorker).Count(&n).Error).To(Succeed()) + return n + } + + It("returns no api_token and creates no user on registration", func() { + resp := register(true) + Expect(resp).ToNot(HaveKey("api_token")) + Expect(resp).To(HaveKeyWithValue("node_type", "agent")) + Expect(countUsers()).To(BeZero()) + }) + + It("returns no api_token and creates no user on approval", func() { + register(false) + node, err := registry.GetByName(context.Background(), "agent-1") + Expect(err).ToNot(HaveOccurred()) + + e := echo.New() + rec := httptest.NewRecorder() + c := e.NewContext(httptest.NewRequest(http.MethodPost, "/", nil), rec) + c.SetParamNames("id") + c.SetParamValues(node.ID) + Expect(ApproveNodeEndpoint(registry, db, "secret", natsauth.Config{})(c)).To(Succeed()) + Expect(rec.Code).To(Equal(http.StatusOK)) + var resp map[string]any + Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed()) + Expect(resp).ToNot(HaveKey("api_token")) + Expect(countUsers()).To(BeZero()) + }) + + It("removes the worker user an earlier release provisioned when the node registers again", func() { + register(true) + node, err := registry.GetByName(context.Background(), "agent-1") + Expect(err).ToNot(HaveOccurred()) + legacy := &auth.User{ID: "legacy-worker", Name: "agent-worker:agent-1", Provider: auth.ProviderAgentWorker, + Subject: node.ID, Role: "user", Status: "active", CreatedAt: time.Now()} + Expect(db.Create(legacy).Error).To(Succeed()) + Expect(db.Create(&auth.UserAPIKey{ID: "legacy-key", UserID: legacy.ID, Name: "worker", KeyHash: "legacy-hash", KeyPrefix: "lak-1234", Role: "user"}).Error).To(Succeed()) + Expect(registry.UpdateAuthRefs(context.Background(), node.ID, legacy.ID, "legacy-key")).To(Succeed()) + + register(true) + + Expect(countUsers()).To(BeZero()) + var keys int64 + Expect(db.Model(&auth.UserAPIKey{}).Where("user_id = ?", legacy.ID).Count(&keys).Error).To(Succeed()) + Expect(keys).To(BeZero(), "the key of the removed worker account must go with it") + again, err := registry.GetByName(context.Background(), "agent-1") + Expect(err).ToNot(HaveOccurred()) + Expect(again.AuthUserID).To(BeEmpty()) + Expect(again.APIKeyID).To(BeEmpty()) + }) +}) diff --git a/core/http/endpoints/localai/quantization.go b/core/http/endpoints/localai/quantization.go index 7ea319322..693adb264 100644 --- a/core/http/endpoints/localai/quantization.go +++ b/core/http/endpoints/localai/quantization.go @@ -2,6 +2,7 @@ package localai import ( "encoding/json" + "errors" "fmt" "net/http" "strings" @@ -10,6 +11,7 @@ import ( "github.com/mudler/LocalAI/core/config" "github.com/mudler/LocalAI/core/schema" "github.com/mudler/LocalAI/core/services/quantization" + "github.com/mudler/LocalAI/pkg/model" ) // StartQuantizationJobEndpoint starts a new quantization job. @@ -32,9 +34,7 @@ func StartQuantizationJobEndpoint(qService *quantization.QuantizationService) ec resp, err := qService.StartJob(c.Request().Context(), userID, req) if err != nil { - return c.JSON(http.StatusInternalServerError, map[string]string{ - "error": err.Error(), - }) + return jobStartFailure(c, "quantization", err) } return c.JSON(http.StatusCreated, resp) @@ -203,3 +203,17 @@ func ListQuantizationBackendsEndpoint(appConfig *config.ApplicationConfig, clust return c.JSON(http.StatusOK, result) } } + +// jobStartFailure answers a failed job start. A missing engine is a conflict +// with the state of this instance, not a server fault, and the answer names +// the engine so the person knows what to install. +func jobStartFailure(c echo.Context, kind string, err error) error { + if errors.Is(err, model.ErrBackendNotFound) { + // The loader names the backend after its sentinel. + _, name, _ := strings.Cut(err.Error(), model.ErrBackendNotFound.Error()+": ") + return c.JSON(http.StatusConflict, map[string]string{ + "error": fmt.Sprintf("%s engine %s is not installed; install it from the backends page", kind, name), + }) + } + return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) +} diff --git a/core/http/endpoints/localai/settings.go b/core/http/endpoints/localai/settings.go index d79bf239b..bcff19b10 100644 --- a/core/http/endpoints/localai/settings.go +++ b/core/http/endpoints/localai/settings.go @@ -2,9 +2,12 @@ package localai import ( "encoding/json" + "fmt" "io" "net/http" "path/filepath" + "regexp" + "strings" "time" "github.com/labstack/echo/v4" @@ -14,6 +17,8 @@ import ( "github.com/mudler/LocalAI/core/http/endpoints/openresponses" "github.com/mudler/LocalAI/core/p2p" "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/studio/hostexec" + "github.com/mudler/LocalAI/pkg/addons/contract" "github.com/mudler/LocalAI/pkg/vram" "github.com/mudler/LocalAI/pkg/vrambudget" "github.com/mudler/xlog" @@ -54,6 +59,38 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc { }) } + // The ports and the SearXNG address of the chats' web tools. + if settings.ChatsWebPorts != nil { + if _, err := config.ParsePorts(*settings.ChatsWebPorts); err != nil { + return c.JSON(http.StatusBadRequest, schema.SettingsResponse{ + Success: false, + Error: "Invalid chats_web_ports: " + err.Error(), + }) + } + } + if settings.ChatsWebSearXNGURL != nil { + if err := config.ValidateSearXNGURL(*settings.ChatsWebSearXNGURL); err != nil { + return c.JSON(http.StatusBadRequest, schema.SettingsResponse{ + Success: false, + Error: "Invalid chats_web_searxng_url: " + err.Error(), + }) + } + } + + if settings.ChatsWebSearchProviders != nil { + if _, err := config.ParseSearchProviders(*settings.ChatsWebSearchProviders, true); err != nil { + return c.JSON(http.StatusBadRequest, schema.SettingsResponse{ + Success: false, + Error: "Invalid chats_web_search_providers: " + err.Error(), + }) + } + } + + // The network of the Sandbox commands. + if msg := validateEgressSettings(settings, body, appConfig.ChatsEgressAllowPrivate); msg != "" { + return c.JSON(http.StatusBadRequest, schema.SettingsResponse{Success: false, Error: msg}) + } + // Validate timeouts if provided if settings.WatchdogIdleTimeout != nil { if _, err := time.ParseDuration(*settings.WatchdogIdleTimeout); err != nil { @@ -110,6 +147,25 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc { } } + // The built-in chat tools: a mode the chats understand, and pins only for + // tools and skills an add-on may replace. + if settings.ChatsHostExec != nil && !hostexec.ValidMode(*settings.ChatsHostExec) { + return c.JSON(http.StatusBadRequest, schema.SettingsResponse{ + Success: false, + Error: "Invalid chats_host_exec: use off, ask or on", + }) + } + if settings.ChatsToolProviders != nil { + for tool, addon := range *settings.ChatsToolProviders { + if !(contract.Replaceable(tool) || contract.ReplaceableSkill(tool)) || !validAddonID(addon) { + return c.JSON(http.StatusBadRequest, schema.SettingsResponse{ + Success: false, + Error: "Invalid chats_tool_providers: map a replaceable tool (bash, web_search, web_fetch) or skill (xlsx, docx, pptx, pdf) to an add-on ID", + }) + } + } + } + // The owner's value is the deployment's value. A frontend reads it from // the owner at every refresh, so a change made here would be undone and // would not reach the other frontends. @@ -271,9 +327,6 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc { xlog.Info("Updated Open Responses store TTL", "ttl", ttl) } - // Check if agent job retention changed - agentJobChanged := settings.AgentJobRetentionDays != nil - // Restart watchdog if settings changed. // // The live start/stop decision derives from the post-apply config @@ -304,17 +357,6 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc { } } - // Restart agent job service if retention days changed - if agentJobChanged { - if err := app.RestartAgentJobService(); err != nil { - xlog.Error("Failed to restart agent job service", "error", err) - return c.JSON(http.StatusInternalServerError, schema.SettingsResponse{ - Success: false, - Error: "Settings saved but failed to restart agent job service: " + err.Error(), - }) - } - } - // Rebuild the MITM listener when its address OR the instance-wide // default detectors change. The per-host detector map is resolved once // at listener start (startMITMLocked → ResolvePIIPolicy), so a @@ -361,3 +403,85 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc { }) } } + +var addonIDShape = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,63}$`) + +func validAddonID(id string) bool { return addonIDShape.MatchString(id) } + +// validateEgressSettings checks the chats_egress_* settings of a request and +// returns the error to show, or "". Turning chats_egress_allow_private on +// needs chats_egress_private_confirm with the typed phrase in the same body; +// the phrase is a person's act, it is not stored. +func validateEgressSettings(s config.RuntimeSettings, body []byte, privateOn bool) string { + for name, v := range map[string]*string{"chats_egress_ceiling": s.ChatsEgressCeiling, "chats_egress_default": s.ChatsEgressDefault} { + if v != nil && !config.ValidEgressPreset(*v) { + return "Invalid " + name + ": use off, registries or all" + } + } + if s.ChatsEgressPorts != nil { + if _, err := config.ParseEgressPorts(*s.ChatsEgressPorts); err != nil { + return "Invalid chats_egress_ports: " + err.Error() + } + } + if s.ChatsEgressExtraRegistries != nil { + if _, err := config.ParseEgressHosts(*s.ChatsEgressExtraRegistries); err != nil { + return "Invalid chats_egress_extra_registries: " + err.Error() + } + } + if s.ChatsEgressAllowedHosts != nil { + // An IP literal is judged with the private switch as this request + // leaves it. + priv := privateOn + if s.ChatsEgressAllowPrivate != nil { + priv = *s.ChatsEgressAllowPrivate + } + if _, err := config.ParseEgressAllowedHosts(*s.ChatsEgressAllowedHosts, priv); err != nil { + return "Invalid chats_egress_allowed_hosts: " + err.Error() + } + } + if s.ChatsEgressAuditDays != nil && (*s.ChatsEgressAuditDays < 1 || *s.ChatsEgressAuditDays > 365) { + return "Invalid chats_egress_audit_days: use 1 to 365" + } + if s.ChatsIndexMaxTextBytes != nil && (*s.ChatsIndexMaxTextBytes < config.MinChatsIndexMaxTextBytes || *s.ChatsIndexMaxTextBytes > config.MaxChatsIndexMaxTextBytes) { + return fmt.Sprintf("Invalid chats_index_max_text_bytes: use %d to %d", config.MinChatsIndexMaxTextBytes, config.MaxChatsIndexMaxTextBytes) + } + if s.ChatsMaxIterations != nil && (*s.ChatsMaxIterations < 1 || *s.ChatsMaxIterations > config.MaxChatsMaxIterations) { + return fmt.Sprintf("Invalid chats_max_iterations: use 1 to %d", config.MaxChatsMaxIterations) + } + if s.ChatsGoalMaxTurns != nil && (*s.ChatsGoalMaxTurns < 1 || *s.ChatsGoalMaxTurns > config.MaxChatsGoalMaxTurns) { + return fmt.Sprintf("Invalid chats_goal_max_turns: use 1 to %d", config.MaxChatsGoalMaxTurns) + } + if s.ChatsGoalMaxSteps != nil && (*s.ChatsGoalMaxSteps < 1 || *s.ChatsGoalMaxSteps > config.MaxChatsGoalMaxSteps) { + return fmt.Sprintf("Invalid chats_goal_max_steps: use 1 to %d", config.MaxChatsGoalMaxSteps) + } + if s.ChatsLoopMinInterval != nil { + d, err := time.ParseDuration(strings.TrimSpace(*s.ChatsLoopMinInterval)) + if err != nil || d < config.MinChatsLoopInterval || d > config.MaxChatsLoopInterval { + return "Invalid chats_loop_min_interval: use a duration from 30s to 24h, such as 1m" + } + } + for _, c := range []struct { + name string + v *int + hi int + }{ + {"chats_loop_max_runs", s.ChatsLoopMaxRuns, config.MaxChatsLoopMaxRuns}, + {"chats_loop_max_per_chat", s.ChatsLoopMaxPerChat, config.MaxChatsLoopMaxPerChat}, + {"chats_loop_max_per_user", s.ChatsLoopMaxPerUser, config.MaxChatsLoopMaxPerUser}, + {"chats_loop_daily_runs", s.ChatsLoopDailyRuns, config.MaxChatsLoopDailyRuns}, + } { + if c.v != nil && (*c.v < 1 || *c.v > c.hi) { + return fmt.Sprintf("Invalid %s: use 1 to %d", c.name, c.hi) + } + } + if s.ChatsEgressAllowPrivate != nil && *s.ChatsEgressAllowPrivate && !privateOn { + var confirm struct { + Phrase string `json:"chats_egress_private_confirm"` + } + _ = json.Unmarshal(body, &confirm) + if strings.TrimSpace(confirm.Phrase) != config.EgressPrivatePhrase { + return "Turning chats_egress_allow_private on needs chats_egress_private_confirm: \"" + config.EgressPrivatePhrase + "\"" + } + } + return "" +} diff --git a/core/http/endpoints/localai/settings_test.go b/core/http/endpoints/localai/settings_test.go index f5db0912d..cabce5768 100644 --- a/core/http/endpoints/localai/settings_test.go +++ b/core/http/endpoints/localai/settings_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "time" "github.com/labstack/echo/v4" "github.com/mudler/LocalAI/core/application" @@ -99,6 +100,162 @@ var _ = Describe("Settings endpoints", func() { Expect(rec.Body.String()).To(ContainSubstring("Invalid watchdog_idle_timeout")) }) + It("rejects bad ports and a bad SearXNG address for the chats' web tools", func() { + rec := post(`{"chats_web_ports":"80,http"}`) + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(rec.Body.String()).To(ContainSubstring("Invalid chats_web_ports")) + rec = post(`{"chats_web_searxng_url":"file:///etc/passwd"}`) + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(rec.Body.String()).To(ContainSubstring("Invalid chats_web_searxng_url")) + rec = post(`{"chats_web_searxng_url":"https://u:p@searx.example.org"}`) + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + rec = post(`{"chats_web_search_providers":"duckduckgo,altavista"}`) + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(rec.Body.String()).To(ContainSubstring("Invalid chats_web_search_providers")) + }) + + It("switches the chats' web tools and keeps them on by default", func() { + cfg := app.ApplicationConfig() + Expect(cfg.DisableChatsWebSearch || cfg.DisableChatsWebFetch).To(BeFalse()) + rec := post(`{"chats_web_search":false,"chats_web_ports":"80, 443, 8080","chats_web_searxng_url":"https://searx.example.org","chats_web_search_providers":"searxng,brave_html"}`) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + Expect(cfg.DisableChatsWebSearch).To(BeTrue()) + Expect(cfg.DisableChatsWebFetch).To(BeFalse()) + Expect(cfg.ChatsWebPorts).To(Equal("80, 443, 8080")) + Expect(cfg.ChatsWebSearXNGURL).To(Equal("https://searx.example.org")) + Expect(cfg.ChatsWebSearchProviders).To(Equal("searxng,brave_html")) + }) + + It("rejects a host-commands mode and pins the chats do not understand", func() { + rec := post(`{"chats_host_exec":"always"}`) + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(rec.Body.String()).To(ContainSubstring("Invalid chats_host_exec")) + rec = post(`{"chats_tool_providers":{"write_file":"files"}}`) + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(rec.Body.String()).To(ContainSubstring("Invalid chats_tool_providers")) + rec = post(`{"chats_tool_providers":{"bash":"Not An ID"}}`) + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + rec = post(`{"chats_tool_providers":{"writing-skills":"office"}}`) + Expect(rec.Code).To(Equal(http.StatusBadRequest), "a built-in skill an add-on may not replace") + }) + + It("saves a pin for a built-in skill in the same map as the tools", func() { + rec := post(`{"chats_tool_providers":{"bash":"sandbox","xlsx":"office"}}`) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + Expect(app.ApplicationConfig().ChatsToolProviders).To(Equal(map[string]string{"bash": "sandbox", "xlsx": "office"})) + }) + + It("saves the host-commands mode and a pin", func() { + rec := post(`{"chats_host_exec":"ask","chats_tool_providers":{"bash":"sandbox"}}`) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + Expect(app.ApplicationConfig().ChatsHostExec).To(Equal("ask")) + Expect(app.ApplicationConfig().ChatsToolProviders).To(Equal(map[string]string{"bash": "sandbox"})) + }) + + It("checks the Sandbox network settings and asks for the phrase to reach private addresses", func() { + for body, want := range map[string]string{ + `{"chats_egress_ceiling":"wide"}`: "Invalid chats_egress_ceiling", + `{"chats_egress_default":"everything"}`: "Invalid chats_egress_default", + `{"chats_egress_ports":"443,22"}`: "Invalid chats_egress_ports", + `{"chats_egress_ports":"443,25"}`: "Invalid chats_egress_ports", + `{"chats_egress_extra_registries":"*.example.com"}`: "Invalid chats_egress_extra_registries", + `{"chats_egress_extra_registries":"0x7f.1"}`: "Invalid chats_egress_extra_registries", + `{"chats_egress_allowed_hosts":"*.example.com"}`: "Invalid chats_egress_allowed_hosts", + `{"chats_egress_allowed_hosts":"https://example.com"}`: "Invalid chats_egress_allowed_hosts", + `{"chats_egress_allowed_hosts":"example.com:22"}`: "Invalid chats_egress_allowed_hosts", + `{"chats_egress_allowed_hosts":"10.0.0.5"}`: "Invalid chats_egress_allowed_hosts", + `{"chats_egress_audit_days":0}`: "Invalid chats_egress_audit_days", + `{"chats_egress_audit_days":366}`: "Invalid chats_egress_audit_days", + `{"chats_index_max_text_bytes":1024}`: "Invalid chats_index_max_text_bytes", + `{"chats_index_max_text_bytes":33554432}`: "Invalid chats_index_max_text_bytes", + `{"chats_egress_allow_private":true}`: "chats_egress_private_confirm", + `{"chats_egress_allow_private":true,"chats_egress_private_confirm":"yes"}`: "chats_egress_private_confirm", + } { + rec := post(body) + Expect(rec.Code).To(Equal(http.StatusBadRequest), body) + Expect(rec.Body.String()).To(ContainSubstring(want), body) + } + cfg := app.ApplicationConfig() + Expect(cfg.ChatsEgressAllowPrivate).To(BeFalse()) + rec := post(`{"chats_egress_ceiling":"registries","chats_egress_default":"registries","chats_egress_ports":"443","chats_egress_extra_registries":"Mirror.Example.org, pypi.internal.example","chats_egress_allowed_hosts":"API.example.com, git.example.org:8443, 10.0.0.5","chats_egress_audit_days":7,` + + `"chats_egress_allow_private":true,"chats_egress_private_confirm":"` + config.EgressPrivatePhrase + `"}`) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + Expect(cfg.ChatsEgressCeiling).To(Equal("registries")) + Expect(cfg.ChatsEgressDefault).To(Equal("registries")) + Expect(cfg.ChatsEgressPorts).To(Equal("443")) + Expect(cfg.ChatsEgressAllowedHosts).To(Equal("API.example.com, git.example.org:8443, 10.0.0.5"), "an IP literal is accepted in the same request that turns the private switch on") + Expect(cfg.ChatsEgressAuditDays).To(Equal(7)) + Expect(cfg.ChatsEgressAllowPrivate).To(BeTrue()) + raw, err := os.ReadFile(filepath.Join(tmp, "runtime_settings.json")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(raw)).NotTo(ContainSubstring(config.EgressPrivatePhrase), "the phrase is not stored") + // Already on: saving it again needs no phrase. + rec = post(`{"chats_egress_allow_private":true}`) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + }) + + It("checks and saves the step limit of a chat turn", func() { + Expect(app.ApplicationConfig().EffectiveChatsMaxIterations()).To(Equal(config.DefaultChatsMaxIterations), "unset: the default") + for _, body := range []string{`{"chats_max_iterations":0}`, `{"chats_max_iterations":501}`} { + rec := post(body) + Expect(rec.Code).To(Equal(http.StatusBadRequest), body) + Expect(rec.Body.String()).To(ContainSubstring("Invalid chats_max_iterations"), body) + } + rec := post(`{"chats_max_iterations":80}`) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + Expect(app.ApplicationConfig().ChatsMaxIterations).To(Equal(80)) + Expect(app.ApplicationConfig().EffectiveChatsMaxIterations()).To(Equal(80)) + raw, err := os.ReadFile(filepath.Join(tmp, "runtime_settings.json")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(raw)).To(ContainSubstring(`"chats_max_iterations": 80`)) + }) + + It("checks and saves the limits of a chat's goal", func() { + cfg := app.ApplicationConfig() + Expect(cfg.EffectiveChatsGoalMaxTurns()).To(Equal(config.DefaultChatsGoalMaxTurns), "unset: the default") + Expect(cfg.EffectiveChatsGoalMaxSteps()).To(Equal(config.DefaultChatsGoalMaxSteps), "unset: the default") + for body, msg := range map[string]string{ + `{"chats_goal_max_turns":0}`: "Invalid chats_goal_max_turns", + `{"chats_goal_max_turns":201}`: "Invalid chats_goal_max_turns", + `{"chats_goal_max_steps":0}`: "Invalid chats_goal_max_steps", + `{"chats_goal_max_steps":10001}`: "Invalid chats_goal_max_steps", + } { + rec := post(body) + Expect(rec.Code).To(Equal(http.StatusBadRequest), body) + Expect(rec.Body.String()).To(ContainSubstring(msg), body) + } + rec := post(`{"chats_goal_max_turns":5,"chats_goal_max_steps":90}`) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + Expect(cfg.EffectiveChatsGoalMaxTurns()).To(Equal(5)) + Expect(cfg.EffectiveChatsGoalMaxSteps()).To(Equal(90)) + raw, err := os.ReadFile(filepath.Join(tmp, "runtime_settings.json")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(raw)).To(ContainSubstring(`"chats_goal_max_turns": 5`)) + Expect(string(raw)).To(ContainSubstring(`"chats_goal_max_steps": 90`)) + }) + + It("checks and saves the limits of the loops of the chats", func() { + cfg := app.ApplicationConfig() + Expect(cfg.EffectiveChatsLoopMinInterval()).To(Equal(config.DefaultChatsLoopMinInterval), "unset: the default") + Expect(cfg.EffectiveChatsLoopMaxPerChat()).To(Equal(config.DefaultChatsLoopMaxPerChat)) + for _, body := range []string{`{"chats_loop_min_interval":"10s"}`, `{"chats_loop_min_interval":"soon"}`, + `{"chats_loop_max_runs":0}`, `{"chats_loop_max_per_chat":21}`, `{"chats_loop_max_per_user":0}`, `{"chats_loop_daily_runs":100001}`} { + rec := post(body) + Expect(rec.Code).To(Equal(http.StatusBadRequest), body) + Expect(rec.Body.String()).To(ContainSubstring("Invalid chats_loop_"), body) + } + rec := post(`{"chats_loop_min_interval":"2m","chats_loop_max_runs":50,"chats_loop_max_per_chat":2,"chats_loop_max_per_user":5,"chats_loop_daily_runs":40}`) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + Expect(cfg.EffectiveChatsLoopMinInterval()).To(Equal(2 * time.Minute)) + Expect(cfg.EffectiveChatsLoopMaxRuns()).To(Equal(50)) + Expect(cfg.EffectiveChatsLoopMaxPerChat()).To(Equal(2)) + Expect(cfg.EffectiveChatsLoopMaxPerUser()).To(Equal(5)) + Expect(cfg.EffectiveChatsLoopDailyRuns()).To(Equal(40)) + raw, err := os.ReadFile(filepath.Join(tmp, "runtime_settings.json")) + Expect(err).NotTo(HaveOccurred()) + Expect(string(raw)).To(ContainSubstring(`"chats_loop_min_interval": "2m`)) + }) + It("errors when DynamicConfigsDir is unset", func() { app.ApplicationConfig().DynamicConfigsDir = "" rec := post(`{}`) diff --git a/core/http/endpoints/localai/user_scope.go b/core/http/endpoints/localai/user_scope.go new file mode 100644 index 000000000..0b37f44d9 --- /dev/null +++ b/core/http/endpoints/localai/user_scope.go @@ -0,0 +1,71 @@ +package localai + +import ( + "net/url" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/http/auth" + "github.com/mudler/xlog" +) + +// getUserID extracts the scoped user ID from the request context. +// Returns empty string when auth is not active (backward compat). +func getUserID(c echo.Context) string { + user := auth.GetUser(c) + if user == nil { + return "" + } + return user.ID +} + +// decodedParam returns the named path parameter, URL-decoding it. +// +// Echo routes a request via URL.RawPath whenever the path contains +// percent-encoded characters (e.g. %3A for ':'), and in that case stores the +// matched path-param value raw/escaped. Agent and collection names carry a +// "legacy-api-key:" prefix, so the ':' arrives as %3A and the raw param no +// longer matches the stored name. Callers must unescape before lookups. +// Falls back to the raw value if it isn't valid percent-encoding. +func decodedParam(c echo.Context, name string) string { + raw := c.Param(name) + if decoded, err := url.PathUnescape(raw); err == nil { + return decoded + } + return raw +} + +// isAdminUser returns true if the authenticated user has admin role. +func isAdminUser(c echo.Context) bool { + user := auth.GetUser(c) + return user != nil && user.Role == auth.RoleAdmin +} + +// wantsAllUsers returns true if the request has ?all_users=true and the user is admin. +func wantsAllUsers(c echo.Context) bool { + return c.QueryParam("all_users") == "true" && isAdminUser(c) +} + +// effectiveUserID returns the user ID to scope operations to. +// SECURITY: Only admins may supply ?user_id= to operate on another user's +// resources. Regular callers always get their own ID regardless of query params. +func effectiveUserID(c echo.Context) string { + if targetUID := c.QueryParam("user_id"); targetUID != "" && canImpersonateUser(c) { + if callerID := getUserID(c); callerID != targetUID { + xlog.Info("User impersonation", "caller", callerID, "target", targetUID, "path", c.Path()) + } + return targetUID + } + return getUserID(c) +} + +// canImpersonateUser returns true if the caller is allowed to use ?user_id= to +// scope operations to another user. Admins only: the agent worker no longer +// calls the HTTP API, so the agent-worker provider carries no extra power (rows +// an earlier release created are treated as ordinary users). +func canImpersonateUser(c echo.Context) bool { + user := auth.GetUser(c) + if user == nil { + return false + } + return user.Role == auth.RoleAdmin +} diff --git a/core/http/endpoints/localai/agents_isolation_test.go b/core/http/endpoints/localai/user_scope_test.go similarity index 76% rename from core/http/endpoints/localai/agents_isolation_test.go rename to core/http/endpoints/localai/user_scope_test.go index 024dcd08d..921c8ed1d 100644 --- a/core/http/endpoints/localai/agents_isolation_test.go +++ b/core/http/endpoints/localai/user_scope_test.go @@ -10,14 +10,14 @@ import ( . "github.com/onsi/gomega" ) -// Per-user isolation contract for agent endpoints: a regular user is scoped -// to their own data, admins and agent-worker service accounts can override +// Per-user isolation contract for the collections endpoints: a regular user is scoped +// to their own data, only admins can override // scope via ?user_id=, and ?all_users=true is admin-only. -var _ = Describe("Agent endpoint per-user isolation", func() { +var _ = Describe("Collections endpoint per-user isolation", func() { var e *echo.Echo makeContext := func(query string) echo.Context { - req := httptest.NewRequest(http.MethodGet, "/api/agents"+query, nil) + req := httptest.NewRequest(http.MethodGet, "/api/collections"+query, nil) rec := httptest.NewRecorder() return e.NewContext(req, rec) } @@ -76,32 +76,16 @@ var _ = Describe("Agent endpoint per-user isolation", func() { Expect(canImpersonateUser(c)).To(BeTrue()) }) - It("is true for agent-worker service accounts", func() { + It("is false for a user with the agent-worker provider", func() { + // The agent worker no longer calls the HTTP API, so nothing + // legitimate holds this provider. Rows an earlier release + // created must not keep a cross-user power. c := makeContext("") c.Set("auth_user", &auth.User{ ID: "worker-1", Role: auth.RoleUser, Provider: auth.ProviderAgentWorker, }) - Expect(canImpersonateUser(c)).To(BeTrue()) - }) - - It("ignores user-supplied 'provider' since the field is set server-side at registration", func() { - // Defense-in-depth: even if a user could somehow inject a - // Provider field into their session row (they can't via any - // supported flow), the role check is independent and a - // non-admin RoleUser still fails the impersonation gate. - c := makeContext("") - c.Set("auth_user", &auth.User{ - ID: "alice", Role: auth.RoleUser, - // User claims to be an agent worker. - Provider: auth.ProviderAgentWorker, - }) - // Per the current contract canImpersonate accepts this — but - // the path that mints ProviderAgentWorker is server-only - // (see core/services/nodes/registration.go). Pin the - // expectation so a future refactor that changes the rule - // here also has to change the lock below. - Expect(canImpersonateUser(c)).To(BeTrue()) + Expect(canImpersonateUser(c)).To(BeFalse()) }) }) @@ -131,13 +115,13 @@ var _ = Describe("Agent endpoint per-user isolation", func() { Expect(effectiveUserID(c)).To(Equal("alice")) }) - It("honors ?user_id= for agent-worker service accounts", func() { + It("ignores ?user_id= for a user with the agent-worker provider", func() { c := makeContext("?user_id=alice") c.Set("auth_user", &auth.User{ ID: "worker-1", Role: auth.RoleUser, Provider: auth.ProviderAgentWorker, }) - Expect(effectiveUserID(c)).To(Equal("alice")) + Expect(effectiveUserID(c)).To(Equal("worker-1")) }) It("falls back to caller's own ID when impersonation is allowed but query is empty", func() { diff --git a/core/http/endpoints/mcp/addon_tools.go b/core/http/endpoints/mcp/addon_tools.go index fa3c3e5e9..94e1272bf 100644 --- a/core/http/endpoints/mcp/addon_tools.go +++ b/core/http/endpoints/mcp/addon_tools.go @@ -132,6 +132,22 @@ func AddonToolsHTTPError(err error) error { type AddonToolProvider struct { adapter *tools.ToolAdapter mode string + // hidden names the add-ons whose tools an actor is not offered, such as + // those of a permission the actor lacks. Nil hides none. + hidden func(contract.Actor) map[string]bool +} + +// WithHidden returns the provider with a rule that hides the tools of some +// add-ons from some actors. The rule decides nothing about authority: a call +// to a hidden tool is refused as an unknown tool, and the owner still checks +// every call. +func (p *AddonToolProvider) WithHidden(hidden func(contract.Actor) map[string]bool) *AddonToolProvider { + if p == nil { + return nil + } + out := *p + out.hidden = hidden + return &out } // NewAddonToolProvider returns a provider over the broker adapter. mode is @@ -168,7 +184,11 @@ func (p *AddonToolProvider) ExecutorForAddons(c echo.Context, addonIDs []string) if err != nil { return nil, err } - return e.Only(addonIDs), nil + e = e.Only(addonIDs) + if p.hidden != nil { + e = e.Without(p.hidden(actor)) + } + return e, nil } // AddonToolExecutor is a ToolExecutor for add-on tools with one immutable @@ -182,6 +202,26 @@ type AddonToolExecutor struct { defs []functions.Function // only limits the executor to some add-ons; nil means all of them. only map[string]bool + // hidden are add-ons that are neither offered nor callable. + hidden map[string]bool +} + +// Without returns the executor without the tools of the named add-ons: they +// are neither offered nor callable, as if the add-ons were not installed. +func (e *AddonToolExecutor) Without(addonIDs map[string]bool) *AddonToolExecutor { + if len(addonIDs) == 0 { + return e + } + out := *e + out.hidden = addonIDs + out.defs = nil + for _, d := range e.defs { + if id, _, ok := tools.ParseToolName(d.Name); ok && addonIDs[id] { + continue + } + out.defs = append(out.defs, d) + } + return &out } // Only returns the executor limited to the named add-ons: it offers and runs @@ -247,6 +287,9 @@ func (e *AddonToolExecutor) ExecuteTool(ctx context.Context, toolName, arguments return "", tools.ErrUnknownTool } } + if id, _, ok := tools.ParseToolName(toolName); ok && e.hidden[id] { + return "", tools.ErrUnknownTool + } res, err := e.adapter.ExecuteIn(ctx, e.actor, e.conversation, toolName, json.RawMessage(arguments)) if err != nil { return "", err diff --git a/core/http/endpoints/mcp/apps.go b/core/http/endpoints/mcp/apps.go new file mode 100644 index 000000000..dc5b5e901 --- /dev/null +++ b/core/http/endpoints/mcp/apps.go @@ -0,0 +1,74 @@ +package mcp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/mudler/LocalAI/core/config" + "github.com/mudler/LocalAI/pkg/mcpapps" +) + +// AppTool is a tool of a server with what it says about its view. +type AppTool struct { + Server, Name string + UI mcpapps.UI +} + +// AppExecutor is a ToolExecutor that can also serve MCP Apps: it says which +// tools have a view, reads the page of a view from the server that owns it, and +// runs a tool for an app. An executor that routes to workers (distributed mode) +// does not implement it. +type AppExecutor interface { + ToolExecutor + // AppTools lists every tool the executor offers, with its ui metadata. + AppTools() []AppTool + // ReadAppResource reads a ui:// resource from the named server. + ReadAppResource(ctx context.Context, server, uri string) (mcpapps.Resource, error) + // ExecuteAppTool runs a tool of the named server and returns its text and + // its full result. + ExecuteAppTool(ctx context.Context, server, tool string, arguments json.RawMessage) (mcpapps.CallResult, error) +} + +var _ AppExecutor = (*LocalToolExecutor)(nil) + +func (e *LocalToolExecutor) AppTools() []AppTool { + out := make([]AppTool, 0, len(e.tools)) + for _, t := range e.tools { + out = append(out, AppTool{Server: t.ServerName, Name: t.ToolName, UI: mcpapps.ParseUI(t.Meta)}) + } + return out +} + +func (e *LocalToolExecutor) ReadAppResource(ctx context.Context, server, uri string) (mcpapps.Resource, error) { + if err := mcpapps.CheckURI(uri); err != nil { + return mcpapps.Resource{}, err + } + for _, t := range e.tools { + if t.ServerName != server { + continue + } + res, err := t.Session.ReadResource(ctx, &mcp.ReadResourceParams{URI: uri}) + if err != nil { + return mcpapps.Resource{}, fmt.Errorf("the server could not read the resource: %w", err) + } + return mcpapps.FromReadResult(res, uri) + } + return mcpapps.Resource{}, errors.New("no such MCP server") +} + +func (e *LocalToolExecutor) ExecuteAppTool(ctx context.Context, server, tool string, arguments json.RawMessage) (mcpapps.CallResult, error) { + if server == "" { + return mcpapps.CallResult{}, errors.New("a server is required") + } + ctx, cancel := context.WithTimeout(ctx, config.DefaultMCPToolTimeout) + defer cancel() + res, err := callMCPTool(ctx, e.tools, server, tool, string(arguments)) + if err != nil { + return mcpapps.CallResult{}, err + } + return mcpapps.CallResult{Text: resultText(res), Result: mcpapps.FromCallResult(res)}, nil +} diff --git a/core/http/endpoints/mcp/apps_test.go b/core/http/endpoints/mcp/apps_test.go new file mode 100644 index 000000000..97587fd00 --- /dev/null +++ b/core/http/endpoints/mcp/apps_test.go @@ -0,0 +1,96 @@ +package mcp + +import ( + "context" + "encoding/json" + + "github.com/modelcontextprotocol/go-sdk/mcp" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type timeIn struct { + Zone string `json:"zone"` +} + +// appSession is a connected in-memory MCP server that offers one tool with a +// view, one tool only the app may call, and one plain tool. +func appSession(name string) NamedSession { + srv := mcp.NewServer(&mcp.Implementation{Name: name, Version: "1"}, nil) + mcp.AddTool(srv, &mcp.Tool{Name: "get-time", Description: "time", Meta: mcp.Meta{"ui": map[string]any{"resourceUri": "ui://clock/app.html"}}}, + func(_ context.Context, _ *mcp.CallToolRequest, in timeIn) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "12:00 " + in.Zone}}, StructuredContent: map[string]any{"time": "12:00"}}, nil, nil + }) + mcp.AddTool(srv, &mcp.Tool{Name: "refresh", Meta: mcp.Meta{"ui": map[string]any{"visibility": []any{"app"}}}}, + func(_ context.Context, _ *mcp.CallToolRequest, _ timeIn) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "ok"}}}, nil, nil + }) + mcp.AddTool(srv, &mcp.Tool{Name: "plain"}, + func(_ context.Context, _ *mcp.CallToolRequest, _ timeIn) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "plain"}}}, nil, nil + }) + srv.AddResource(&mcp.Resource{URI: "ui://clock/app.html", Name: "clock", MIMEType: "text/html;profile=mcp-app"}, + func(_ context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return &mcp.ReadResourceResult{Contents: []*mcp.ResourceContents{{URI: req.Params.URI, MIMEType: "text/html;profile=mcp-app", Text: "

clock

"}}}, nil + }) + ct, st := mcp.NewInMemoryTransports() + _, err := srv.Connect(context.Background(), st, nil) + Expect(err).NotTo(HaveOccurred()) + cs, err := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1"}, nil).Connect(context.Background(), ct, nil) + Expect(err).NotTo(HaveOccurred()) + return NamedSession{Name: name, Type: "remote", Session: cs} +} + +var _ = Describe("MCP Apps on the local executor", func() { + var exec *LocalToolExecutor + BeforeEach(func() { + exec = NewLocalToolExecutor(context.Background(), []NamedSession{appSession("clock")}) + }) + + It("lists the view and the visibility of each tool with its server", func() { + apps := exec.AppTools() + byName := map[string]AppTool{} + for _, a := range apps { + byName[a.Name] = a + } + Expect(byName).To(HaveKey("get-time")) + Expect(byName["get-time"].Server).To(Equal("clock")) + Expect(byName["get-time"].UI.ResourceURI).To(Equal("ui://clock/app.html")) + Expect(byName["get-time"].UI.ForApp()).To(BeTrue()) + Expect(byName["refresh"].UI.ForModel()).To(BeFalse()) + Expect(byName["plain"].UI.ResourceURI).To(BeEmpty()) + }) + + It("reads a resource of a named server", func() { + res, err := exec.ReadAppResource(context.Background(), "clock", "ui://clock/app.html") + Expect(err).NotTo(HaveOccurred()) + Expect(res.HTML).To(Equal("

clock

")) + }) + + It("refuses another server, a URI that is not ui:// and a missing resource", func() { + _, err := exec.ReadAppResource(context.Background(), "other", "ui://clock/app.html") + Expect(err).To(HaveOccurred()) + _, err = exec.ReadAppResource(context.Background(), "clock", "file:///etc/passwd") + Expect(err).To(HaveOccurred()) + _, err = exec.ReadAppResource(context.Background(), "clock", "ui://nope") + Expect(err).To(HaveOccurred()) + }) + + It("runs a tool for an app and returns the text and the structured result", func() { + out, err := exec.ExecuteAppTool(context.Background(), "clock", "get-time", json.RawMessage(`{"zone":"UTC"}`)) + Expect(err).NotTo(HaveOccurred()) + Expect(out.Text).To(Equal("12:00 UTC")) + var got map[string]any + Expect(json.Unmarshal(out.Result, &got)).To(Succeed()) + Expect(got["structuredContent"]).To(HaveKeyWithValue("time", "12:00")) + }) + + It("refuses an add-on tool name, an unknown tool and another server", func() { + _, err := exec.ExecuteAppTool(context.Background(), "clock", "addon__x__y", nil) + Expect(err).To(HaveOccurred()) + _, err = exec.ExecuteAppTool(context.Background(), "clock", "nope", nil) + Expect(err).To(HaveOccurred()) + _, err = exec.ExecuteAppTool(context.Background(), "other", "get-time", nil) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/core/http/endpoints/mcp/localai_assistant_test.go b/core/http/endpoints/mcp/localai_assistant_test.go index 45835bf53..75350d8ee 100644 --- a/core/http/endpoints/mcp/localai_assistant_test.go +++ b/core/http/endpoints/mcp/localai_assistant_test.go @@ -233,3 +233,55 @@ func (stubClient) ListFailoverChains(_ context.Context) ([]localaitools.Failover func (stubClient) PinFailoverTarget(_ context.Context, _, _ string) error { return nil } func (stubClient) UnpinFailoverTarget(_ context.Context, _ string) error { return nil } + +func (stubClient) ListSharedFolders(_ context.Context) ([]localaitools.SharedFolder, error) { + return nil, nil +} + +func (stubClient) CreateSharedFolder(_ context.Context, _ string) (*localaitools.SharedFolder, error) { + return &localaitools.SharedFolder{}, nil +} + +func (stubClient) RenameSharedFolder(_ context.Context, _ int64, _ string) (*localaitools.SharedFolder, error) { + return &localaitools.SharedFolder{}, nil +} + +func (stubClient) DeleteSharedFolder(_ context.Context, _ int64) (*localaitools.SharedFolderRemoved, error) { + return &localaitools.SharedFolderRemoved{}, nil +} + +func (stubClient) RestoreSharedFolder(_ context.Context, _ int64) (*localaitools.SharedFolder, error) { + return &localaitools.SharedFolder{}, nil +} + +func (stubClient) SetSharedFolderMember(_ context.Context, _ int64, _, _ string) (*localaitools.SharedFolder, error) { + return &localaitools.SharedFolder{}, nil +} + +func (stubClient) RemoveSharedFolderMember(_ context.Context, _ int64, _ string) (*localaitools.SharedFolder, error) { + return &localaitools.SharedFolder{}, nil +} + +func (stubClient) ListSharedFolderEvents(_ context.Context, _ int) ([]localaitools.SharedFolderEvent, error) { + return nil, nil +} + +func (stubClient) ListChatTools(_ context.Context) (localaitools.ChatBuiltinToolList, error) { + return localaitools.ChatBuiltinToolList{}, nil +} + +func (stubClient) GetChatEgress(_ context.Context) (*localaitools.ChatEgress, error) { + return &localaitools.ChatEgress{}, nil +} + +func (stubClient) ListEgressEvents(_ context.Context, _ localaitools.EgressEventsQuery) ([]localaitools.EgressEvent, error) { + return nil, nil +} + +func (stubClient) SetChatEgressCeiling(_ context.Context, _ string) (*localaitools.ChatEgress, error) { + return &localaitools.ChatEgress{}, nil +} + +func (stubClient) ListHostExecEvents(_ context.Context, _ int) ([]localaitools.HostExecEvent, error) { + return nil, nil +} diff --git a/core/http/endpoints/mcp/tools.go b/core/http/endpoints/mcp/tools.go index 076b88feb..d4fd5c94d 100644 --- a/core/http/endpoints/mcp/tools.go +++ b/core/http/endpoints/mcp/tools.go @@ -41,6 +41,8 @@ type MCPToolInfo struct { ToolName string Function functions.Function Session *mcp.ClientSession + // Meta is the _meta of the tool, where MCP Apps declare their view. + Meta map[string]any } // MCPServerInfo describes an MCP server and its available tools, prompts, and resources. @@ -425,6 +427,11 @@ func NamedSessionsFromMCPConfig( return filtered, nil } +// IsAddonToolName reports whether a tool name is in the add-on namespace, which +// belongs to the broker. A consumer of shared MCP sessions uses it to drop such +// a tool, so only the adapter's own importers name the namespace. +func IsAddonToolName(name string) bool { return addontools.IsToolName(name) } + // DiscoverMCPTools queries each session for its tools and converts them to functions.Function. // Deduplicates by tool name (first server wins). func DiscoverMCPTools(ctx context.Context, sessions []NamedSession) ([]MCPToolInfo, error) { @@ -482,32 +489,34 @@ func DiscoverMCPTools(ctx context.Context, sessions []NamedSession) ([]MCPToolIn ToolName: tool.Name, Function: f, Session: ns.Session, + Meta: tool.Meta, }) } } return result, nil } -// ExecuteMCPToolCall finds the matching tool and executes it. -func ExecuteMCPToolCall(ctx context.Context, tools []MCPToolInfo, toolName string, arguments string) (string, error) { +// callMCPTool finds the tool of a server (any server when server is empty) and +// calls it. The add-on namespace is refused here, so no caller can reach it. +func callMCPTool(ctx context.Context, tools []MCPToolInfo, server, toolName string, arguments string) (*mcp.CallToolResult, error) { if addontools.IsToolName(toolName) { - return "", ErrAddonToolsUnsupported + return nil, ErrAddonToolsUnsupported } var toolInfo *MCPToolInfo for i := range tools { - if tools[i].ToolName == toolName { + if tools[i].ToolName == toolName && (server == "" || tools[i].ServerName == server) { toolInfo = &tools[i] break } } if toolInfo == nil { - return "", fmt.Errorf("MCP tool %q not found", toolName) + return nil, fmt.Errorf("MCP tool %q not found", toolName) } var args map[string]any if arguments != "" { if err := json.Unmarshal([]byte(arguments), &args); err != nil { - return "", fmt.Errorf("failed to parse arguments for tool %q: %w", toolName, err) + return nil, fmt.Errorf("failed to parse arguments for tool %q: %w", toolName, err) } } @@ -516,10 +525,13 @@ func ExecuteMCPToolCall(ctx context.Context, tools []MCPToolInfo, toolName strin Arguments: args, }) if err != nil { - return "", fmt.Errorf("MCP tool %q call failed: %w", toolName, err) + return nil, fmt.Errorf("MCP tool %q call failed: %w", toolName, err) } + return result, nil +} - // Extract text content from result +// resultText is the text a model sees for a tool result. +func resultText(result *mcp.CallToolResult) string { var texts []string for _, content := range result.Content { if tc, ok := content.(*mcp.TextContent); ok { @@ -529,13 +541,22 @@ func ExecuteMCPToolCall(ctx context.Context, tools []MCPToolInfo, toolName strin if len(texts) == 0 { // Fallback: marshal the whole result data, _ := json.Marshal(result.Content) - return string(data), nil + return string(data) } if len(texts) == 1 { - return texts[0], nil + return texts[0] } combined, _ := json.Marshal(texts) - return string(combined), nil + return string(combined) +} + +// ExecuteMCPToolCall finds the matching tool and executes it. +func ExecuteMCPToolCall(ctx context.Context, tools []MCPToolInfo, toolName string, arguments string) (string, error) { + result, err := callMCPTool(ctx, tools, "", toolName, arguments) + if err != nil { + return "", err + } + return resultText(result), nil } // ExecuteMCPToolCallRemote routes an MCP tool execution request to an agent worker. diff --git a/core/http/endpoints/studio/addons.go b/core/http/endpoints/studio/addons.go new file mode 100644 index 000000000..172b6cc40 --- /dev/null +++ b/core/http/endpoints/studio/addons.go @@ -0,0 +1,63 @@ +package studio + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/schema" + studiosvc "github.com/mudler/LocalAI/core/services/studio" +) + +func addonList(c echo.Context, h *Handlers) error { + list, err := h.svc.Addons(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failFor(c, err) + } + cmds, err := h.svc.CommandRunner(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failFor(c, err) + } + return writeAddonList(c, list, cmds) +} + +func writeAddonList(c echo.Context, list []studiosvc.AddonInfo, cmds studiosvc.Commands) error { + out := schema.ChatsAddonList{Addons: make([]schema.ChatsAddon, 0, len(list)), Commands: string(cmds.State), CommandsReason: cmds.Reason, + CommandsIsolation: cmds.Isolation, CommandsProvider: cmds.Provider, CommandsConsent: cmds.Consent, + CommandsFallback: cmds.Fallback, CommandsFallbackReason: cmds.FallbackReason} + for _, a := range list { + out.Addons = append(out.Addons, schema.ChatsAddon{ID: a.ID, Tools: a.Tools, Off: a.Off}) + } + return c.JSON(http.StatusOK, out) +} + +// ListChatAddons lists the add-ons a chat can use. +// @Summary List the add-ons a chat can use. +// @Description The add-ons whose tools the signed-in user may use now, each with the chat's switch, and whether the chat can run commands at its next turn (commands: available, off, unavailable, none, or switched_off when the chat switched the built-in group shell off). When commands is available, commands_provider says who serves bash (builtin: directly on this machine with no isolation and with its network, or the add-on's ID) and commands_isolation says isolated or host; commands_consent is ask when each command waits for a yes in the chat, and commands_fallback names an add-on that would serve but does not (commands_fallback_reason: off, or why it cannot run). Without the add-on host the list is empty. +// @Tags chats +// @Param id path string true "conversation id" +// @Success 200 {object} schema.ChatsAddonList "The add-ons and their switches" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/{id}/addons [get] +func (h *Handlers) ListChatAddons(c echo.Context) error { return addonList(c, h) } + +// SetChatAddons sets the add-ons a chat has switched off. +// @Summary Switch the tools of add-ons on and off for a chat. +// @Description The list replaces the earlier one. The next turn of the chat gets the tools of the add-ons that are on. +// @Tags chats +// @Param id path string true "conversation id" +// @Param request body schema.ChatsAddonSwitches true "the IDs of the add-ons that are off" +// @Success 200 {object} schema.ChatsAddonList "The add-ons and their switches now in force" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/{id}/addons [put] +func (h *Handlers) SetChatAddons(c echo.Context) error { + req, ok := bind[schema.ChatsAddonSwitches](c, "off is required") + if !ok { + return nil + } + if err := h.svc.SetAddonsOff(c.Request().Context(), userID(c), c.Param("id"), req.Off); err != nil { + return failFor(c, err) + } + return addonList(c, h) +} diff --git a/core/http/endpoints/studio/admin.go b/core/http/endpoints/studio/admin.go new file mode 100644 index 000000000..1af12ea1d --- /dev/null +++ b/core/http/endpoints/studio/admin.go @@ -0,0 +1,197 @@ +package studio + +import ( + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" +) + +// The routes below are for administrators. They list what every user has set up +// (saved agents, triggers with their runs, connector bindings), each with its +// owner, and let the administrator switch a trigger or a connector off or delete +// any of the three. They never return a secret: a binding has no token, and an +// agent has no instructions. The route group carries the administrator gate. + +// AdminListAgents lists the saved agents of every user. +// @Summary List the saved agents of every user (administrators). +// @Description Read-only view with the owner of each agent. The instructions are not returned. +// @Tags chats +// @Success 200 {object} schema.ChatsAdminAgentList "Every user's agents, by owner and name" +// @Failure 403 {object} schema.ErrorResponse "Not an administrator" +// @Router /api/chats/admin/agents [get] +func (h *Handlers) AdminListAgents(c echo.Context) error { + list, err := h.svc.AdminAgents(c.Request().Context()) + if err != nil { + return failFor(c, err) + } + out := schema.ChatsAdminAgentList{Agents: make([]schema.ChatsAdminAgent, 0, len(list))} + for _, a := range list { + v := agentOut(a) + out.Agents = append(out.Agents, schema.ChatsAdminAgent{ID: v.ID, Owner: a.Principal, Name: v.Name, Description: v.Description, + Model: v.Model, MCPServers: v.MCPServers, Addons: v.Addons, Collections: v.Collections, Skills: v.Skills, + Created: v.Created, Updated: v.Updated}) + } + return c.JSON(http.StatusOK, out) +} + +// AdminDeleteAgent deletes the saved agent of any user. +// @Summary Delete a saved agent of any user (administrators). +// @Tags chats +// @Param id path string true "Agent ID" +// @Success 204 "Deleted" +// @Failure 404 {object} schema.ErrorResponse "No such agent" +// @Router /api/chats/admin/agents/{id} [delete] +func (h *Handlers) AdminDeleteAgent(c echo.Context) error { + if err := h.svc.AdminDeleteAgent(c.Request().Context(), c.Param("id")); err != nil { + return failFor(c, err) + } + return c.NoContent(http.StatusNoContent) +} + +// AdminListTriggers lists the triggers of every user. +// @Summary List the triggers of every user (administrators). +// @Description Read-only view with the owner of each trigger and its last run. +// @Tags chats +// @Success 200 {object} schema.ChatsAdminTriggerList "Every user's triggers, by owner and name" +// @Failure 403 {object} schema.ErrorResponse "Not an administrator" +// @Router /api/chats/admin/triggers [get] +func (h *Handlers) AdminListTriggers(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + ctx := c.Request().Context() + list, err := host.AdminTriggers(ctx) + if err != nil { + return failTrigger(c, err) + } + out := schema.ChatsAdminTriggerList{Triggers: make([]schema.ChatsAdminTrigger, 0, len(list))} + for _, t := range list { + out.Triggers = append(out.Triggers, schema.ChatsAdminTrigger{Owner: t.Owner, ChatsTrigger: triggerOut(ctx, host, t, true)}) + } + return c.JSON(http.StatusOK, out) +} + +// AdminTriggerRuns lists the runs of a trigger of any user. +// @Summary List the runs of a trigger of any user (administrators). +// @Tags chats +// @Param id path string true "Trigger ID" +// @Param limit query int false "How many runs, newest first (default 20, at most 200)" +// @Success 200 {object} schema.ChatsTriggerRunList "The runs, newest first" +// @Failure 404 {object} schema.ErrorResponse "No such trigger" +// @Router /api/chats/admin/triggers/{id}/runs [get] +func (h *Handlers) AdminTriggerRuns(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + t, runs, err := host.AdminTriggerRuns(c.Request().Context(), c.Param("id"), intParam(c, "limit", 20, 200)) + if err != nil { + return failTrigger(c, err) + } + out := schema.ChatsTriggerRunList{Runs: make([]schema.ChatsTriggerRun, 0, len(runs))} + for _, r := range runs { + out.Runs = append(out.Runs, runOut(host, t.Owner, r)) + } + return c.JSON(http.StatusOK, out) +} + +// AdminDisableTrigger switches a trigger of any user off. +// @Summary Switch off a trigger of any user (administrators). +// @Tags chats +// @Param id path string true "Trigger ID" +// @Success 200 {object} schema.ChatsAdminTrigger "The trigger, now off" +// @Failure 404 {object} schema.ErrorResponse "No such trigger" +// @Router /api/chats/admin/triggers/{id}/disable [post] +func (h *Handlers) AdminDisableTrigger(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + ctx := c.Request().Context() + t, err := host.AdminDisableTrigger(ctx, c.Param("id")) + if err != nil { + return failTrigger(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsAdminTrigger{Owner: t.Owner, ChatsTrigger: triggerOut(ctx, host, t, true)}) +} + +// AdminDeleteTrigger deletes a trigger of any user. +// @Summary Delete a trigger of any user (administrators). +// @Tags chats +// @Param id path string true "Trigger ID" +// @Success 204 "Deleted" +// @Failure 404 {object} schema.ErrorResponse "No such trigger" +// @Router /api/chats/admin/triggers/{id} [delete] +func (h *Handlers) AdminDeleteTrigger(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + if err := host.AdminDeleteTrigger(c.Request().Context(), c.Param("id")); err != nil { + return failTrigger(c, err) + } + return c.NoContent(http.StatusNoContent) +} + +// AdminListConnectors lists the connector bindings of every user. +// @Summary List the connector bindings of every user (administrators). +// @Description Read-only view with the owner of each binding. The bot token is never returned. +// @Tags chats +// @Success 200 {object} schema.ChatsAdminConnectorList "Every user's bindings, by name" +// @Failure 403 {object} schema.ErrorResponse "Not an administrator" +// @Router /api/chats/admin/connectors [get] +func (h *Handlers) AdminListConnectors(c echo.Context) error { + host, ok := h.connectorHost(c) + if !ok { + return nil + } + list, err := host.AdminConnectors(c.Request().Context()) + if err != nil { + return failConnector(c, err) + } + out := schema.ChatsAdminConnectorList{Connectors: make([]schema.ChatsAdminConnector, 0, len(list))} + for _, b := range list { + out.Connectors = append(out.Connectors, schema.ChatsAdminConnector{Owner: b.Owner, ChatsConnector: connectorOut(b.ConnectorInfo)}) + } + return c.JSON(http.StatusOK, out) +} + +// AdminDisableConnector switches a connector binding of any user off. +// @Summary Switch off a connector binding of any user (administrators). +// @Tags chats +// @Param id path string true "Binding ID" +// @Success 200 {object} schema.ChatsConnector "The binding, now off" +// @Failure 404 {object} schema.ErrorResponse "No such binding" +// @Router /api/chats/admin/connectors/{id}/disable [post] +func (h *Handlers) AdminDisableConnector(c echo.Context) error { + host, ok := h.connectorHost(c) + if !ok { + return nil + } + b, err := host.AdminDisableConnector(c.Request().Context(), c.Param("id")) + if err != nil { + return failConnector(c, err) + } + return c.JSON(http.StatusOK, connectorOut(b)) +} + +// AdminDeleteConnector deletes a connector binding of any user. +// @Summary Delete a connector binding of any user (administrators). +// @Tags chats +// @Param id path string true "Binding ID" +// @Success 204 "Deleted" +// @Failure 404 {object} schema.ErrorResponse "No such binding" +// @Router /api/chats/admin/connectors/{id} [delete] +func (h *Handlers) AdminDeleteConnector(c echo.Context) error { + host, ok := h.connectorHost(c) + if !ok { + return nil + } + if err := host.AdminDeleteConnector(c.Request().Context(), c.Param("id")); err != nil { + return failConnector(c, err) + } + return c.NoContent(http.StatusNoContent) +} diff --git a/core/http/endpoints/studio/agents.go b/core/http/endpoints/studio/agents.go new file mode 100644 index 000000000..3a8d556b4 --- /dev/null +++ b/core/http/endpoints/studio/agents.go @@ -0,0 +1,144 @@ +package studio + +import ( + "errors" + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/http/auth" + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/studio/agents" +) + +func agentOut(a agents.Agent) schema.ChatsAgent { + nz := func(l []string) []string { + if l == nil { + return []string{} + } + return l + } + return schema.ChatsAgent{ID: a.ID, Name: a.Name, Description: a.Description, Instructions: a.Instructions, Model: a.Model, + MCPServers: nz(a.MCPServers), Addons: nz(a.Addons), Collections: nz(a.Collections), Skills: nz(a.Skills), + Network: networkOf(a.Network), Created: a.Created, Updated: a.Updated} +} + +// agentIn reads the body. A model the user may not use is refused here, so a +// saved agent never names one the service would refuse at the first turn. +// When ok is false the refusal is already written and err is the write result, +// so the caller returns err without writing a second body. +func (h *Handlers) agentIn(c echo.Context) (a agents.Agent, ok bool, err error) { + var req schema.ChatsAgentWrite + if err := c.Bind(&req); err != nil { + return a, false, fail(c, http.StatusBadRequest, "invalid_request_error", "the body is not valid JSON") + } + if req.Model != "" && h.models != nil { + switch err := h.models.Check(c.Request().Context(), auth.GetUser(c), req.Model); { + case errors.Is(err, ErrUnknownModel): + return a, false, fail(c, http.StatusBadRequest, "invalid_request_error", "unknown model") + case errors.Is(err, ErrModelNotAllowed): + return a, false, fail(c, http.StatusForbidden, "authorization_error", "you may not use this model") + case err != nil: + return a, false, failFor(c, err) + } + } + return agents.Agent{Name: req.Name, Description: req.Description, Instructions: req.Instructions, Model: req.Model, + MCPServers: req.MCPServers, Addons: req.Addons, Collections: req.Collections, Skills: req.Skills, Network: req.Network}, true, nil +} + +// networkOf is the network preset of an agent as the API shows it: off when +// none was set. +func networkOf(n string) string { + if n == "" { + return "off" + } + return n +} + +// ListAgents lists the saved agents. +// @Summary List your saved agents. +// @Tags chats +// @Success 200 {object} schema.ChatsAgentList "Your agents, sorted by name" +// @Router /api/chats/agents [get] +func (h *Handlers) ListAgents(c echo.Context) error { + list, err := h.svc.Agents(c.Request().Context(), userID(c)) + if err != nil { + return failFor(c, err) + } + out := schema.ChatsAgentList{Agents: make([]schema.ChatsAgent, 0, len(list))} + for _, a := range list { + out.Agents = append(out.Agents, agentOut(a)) + } + return c.JSON(http.StatusOK, out) +} + +// CreateAgent saves a new agent. +// @Summary Save an agent. +// @Description An agent is a definition: instructions and exact lists of the MCP servers, add-ons, collections and skills a chat with it may use. A chat gets what the lists name and you may use at that moment, never more. Start a chat with it by passing agent_id to POST /api/chats. +// @Tags chats +// @Param request body schema.ChatsAgentWrite true "the agent" +// @Success 201 {object} schema.ChatsAgent "The saved agent" +// @Router /api/chats/agents [post] +func (h *Handlers) CreateAgent(c echo.Context) error { + a, ok, err := h.agentIn(c) + if !ok { + return err + } + saved, err := h.svc.SaveAgent(c.Request().Context(), userID(c), a) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusCreated, agentOut(saved)) +} + +// GetAgent reads a saved agent. +// @Summary Read a saved agent. +// @Tags chats +// @Param id path string true "the agent's id" +// @Success 200 {object} schema.ChatsAgent "The agent" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/agents/{id} [get] +func (h *Handlers) GetAgent(c echo.Context) error { + a, err := h.svc.Agent(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, agentOut(a)) +} + +// UpdateAgent replaces a saved agent. +// @Summary Replace a saved agent. +// @Description The body replaces every field. Chats that already run with the agent use the new definition from their next turn. +// @Tags chats +// @Param id path string true "the agent's id" +// @Param request body schema.ChatsAgentWrite true "the agent" +// @Success 200 {object} schema.ChatsAgent "The saved agent" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/agents/{id} [put] +func (h *Handlers) UpdateAgent(c echo.Context) error { + a, ok, err := h.agentIn(c) + if !ok { + return err + } + a.ID = c.Param("id") + saved, err := h.svc.SaveAgent(c.Request().Context(), userID(c), a) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, agentOut(saved)) +} + +// DeleteAgent deletes a saved agent. +// @Summary Delete a saved agent. +// @Description Chats that ran with it keep their history; their next turn runs without the agent's tools. +// @Tags chats +// @Param id path string true "the agent's id" +// @Success 204 "Deleted" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/agents/{id} [delete] +func (h *Handlers) DeleteAgent(c echo.Context) error { + if err := h.svc.DeleteAgent(c.Request().Context(), userID(c), c.Param("id")); err != nil { + return failFor(c, err) + } + return c.NoContent(http.StatusNoContent) +} diff --git a/core/http/endpoints/studio/agentsfile.go b/core/http/endpoints/studio/agentsfile.go new file mode 100644 index 000000000..f566a7ef4 --- /dev/null +++ b/core/http/endpoints/studio/agentsfile.go @@ -0,0 +1,195 @@ +package studio + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/http/auth" + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/studio/agents" +) + +const ( + agentExportKind = "localai-agent" + maxAgentImportBody = 1 << 20 +) + +// ExportAgent returns a saved agent as a file. +// @Summary Export a saved agent. +// @Description Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export. +// @Tags chats +// @Param id path string true "the agent's id" +// @Success 200 {object} schema.ChatsAgentExport "The agent file" +// @Router /api/chats/agents/{id}/export [get] +func (h *Handlers) ExportAgent(c echo.Context) error { + a, err := h.svc.Agent(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failFor(c, err) + } + w := schema.ChatsAgentWrite{Name: a.Name, Description: a.Description, Instructions: a.Instructions, Model: a.Model, + MCPServers: a.MCPServers, Addons: a.Addons, Collections: a.Collections, Skills: a.Skills, Network: a.Network} + c.Response().Header().Set(echo.HeaderContentDisposition, fmt.Sprintf("attachment; filename=%q", fileSafe(a.Name)+".json")) + return c.JSON(http.StatusOK, schema.ChatsAgentExport{Version: 1, Kind: agentExportKind, ExportedAt: time.Now().UTC(), Agent: w}) +} + +// fileSafe keeps a name usable as a file name in a header. +func fileSafe(name string) string { + var b strings.Builder + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + b.WriteRune(r) + default: + b.WriteByte('_') + } + } + if b.Len() == 0 { + return "agent" + } + return b.String() +} + +// ImportAgent saves an agent from a file. +// @Summary Import a saved agent. +// @Description Send the export file as the JSON body, or as the file field of a multipart form. A bare agent definition (the body of POST /api/chats/agents) is accepted too. The agent is always new: a name you already use gets a number, and a model this server does not have, or you may not use, is dropped with a warning. +// @Tags chats +// @Accept json,mpfd +// @Param request body schema.ChatsAgentExport true "the agent file" +// @Param file formData file false "the agent file (multipart)" +// @Success 201 {object} schema.ChatsAgentImportResult "The new agent and what the import changed" +// @Router /api/chats/agents/import [post] +func (h *Handlers) ImportAgent(c echo.Context) error { + var raw []byte + if strings.HasPrefix(c.Request().Header.Get("Content-Type"), "multipart/form-data") { + fh, err := c.FormFile("file") + if err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "send the agent file as the file field") + } + f, err := fh.Open() + if err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "the file could not be read") + } + defer func() { _ = f.Close() }() + if raw, err = io.ReadAll(io.LimitReader(f, maxAgentImportBody+1)); err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "the file could not be read") + } + } else { + var err error + if raw, err = io.ReadAll(io.LimitReader(c.Request().Body, maxAgentImportBody+1)); err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "the body could not be read") + } + } + if len(raw) > maxAgentImportBody { + return fail(c, http.StatusRequestEntityTooLarge, "invalid_request_error", "the agent file is larger than 1 MiB") + } + def, err := readAgentFile(raw) + if err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + } + var warnings []string + if def.Model != "" && h.models != nil { + switch merr := h.models.Check(c.Request().Context(), auth.GetUser(c), def.Model); { + case errors.Is(merr, ErrUnknownModel), errors.Is(merr, ErrModelNotAllowed): + warnings = append(warnings, fmt.Sprintf("the model %q is not available to you here, so the agent has no model", def.Model)) + def.Model = "" + case merr != nil: + return failFor(c, merr) + } + } + a := agents.Agent{Name: def.Name, Description: def.Description, Instructions: def.Instructions, Model: def.Model, + MCPServers: def.MCPServers, Addons: def.Addons, Collections: def.Collections, Skills: def.Skills, Network: def.Network} + saved, err := h.svc.ImportAgent(c.Request().Context(), userID(c), a) + if err != nil { + return failFor(c, err) + } + if saved.Name != strings.TrimSpace(def.Name) { + warnings = append(warnings, fmt.Sprintf("you already have an agent named %q, so the new one is named %q", strings.TrimSpace(def.Name), saved.Name)) + } + return c.JSON(http.StatusCreated, schema.ChatsAgentImportResult{Agent: agentOut(saved), Warnings: warnings}) +} + +// readAgentFile reads an export file, or a bare agent definition. +func readAgentFile(raw []byte) (schema.ChatsAgentWrite, error) { + var doc struct { + Version int `json:"version"` + Kind string `json:"kind"` + Agent *schema.ChatsAgentWrite `json:"agent"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + return schema.ChatsAgentWrite{}, errors.New("the body is not a valid agent file") + } + if doc.Agent != nil { + if doc.Version != 1 || (doc.Kind != "" && doc.Kind != agentExportKind) { + return schema.ChatsAgentWrite{}, errors.New("this agent file has a version or kind this server does not know") + } + return *doc.Agent, nil + } + var bare schema.ChatsAgentWrite + if err := json.Unmarshal(raw, &bare); err != nil || strings.TrimSpace(bare.Name) == "" { + return schema.ChatsAgentWrite{}, errors.New("the body is not a valid agent file") + } + return bare, nil +} + +// AgentTemplates lists the starter agents. +// @Summary List the starter agents. +// @Description Ready-made definitions to start from. They grant nothing; save one, then grant it tools. POST /api/chats/agents/templates/{id}/create saves one for you. +// @Tags chats +// @Success 200 {object} schema.ChatsAgentTemplateList "The starter agents" +// @Router /api/chats/agents/templates [get] +func (h *Handlers) AgentTemplates(c echo.Context) error { + out := schema.ChatsAgentTemplateList{Templates: []schema.ChatsAgentTemplate{}} + for _, t := range agents.Templates() { + out.Templates = append(out.Templates, schema.ChatsAgentTemplate{ID: t.ID, Name: t.Name, Description: t.Description, Instructions: t.Instructions}) + } + return c.JSON(http.StatusOK, out) +} + +// CreateAgentFromTemplate saves a starter agent. +// @Summary Save a starter agent as your own. +// @Tags chats +// @Param id path string true "the template's id" +// @Param request body schema.ChatsAgentTemplateUse false "a name to use instead of the template's" +// @Success 201 {object} schema.ChatsAgent "The saved agent" +// @Router /api/chats/agents/templates/{id}/create [post] +func (h *Handlers) CreateAgentFromTemplate(c echo.Context) error { + var req schema.ChatsAgentTemplateUse + if c.Request().ContentLength != 0 { + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "the body is not valid JSON") + } + } + a, err := h.svc.CreateAgentFromTemplate(c.Request().Context(), userID(c), c.Param("id"), req.Name) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusCreated, agentOut(a)) +} + +// AgentRuns returns the run history of a saved agent. +// @Summary Show what an agent has run. +// @Description Lists your chats that ran with the agent, newest first: the ones you had, the ones its triggers started and the ones its connectors started, with how each ended. Open one with GET /api/chats/{chat_id}. +// @Tags chats +// @Param id path string true "the agent's id" +// @Param limit query int false "most runs to list (default 50, at most 200)" +// @Success 200 {object} schema.ChatsAgentHistory "The runs and the counts" +// @Router /api/chats/agents/{id}/runs [get] +func (h *Handlers) AgentRuns(c echo.Context) error { + hist, err := h.svc.AgentRuns(c.Request().Context(), userID(c), c.Param("id"), intParam(c, "limit", defaultListLimit, maxListLimit)) + if err != nil { + return failFor(c, err) + } + out := schema.ChatsAgentHistory{Runs: make([]schema.ChatsAgentRun, 0, len(hist.Runs)), Total: hist.Total, Failed: hist.Failed, Running: hist.Running} + for _, r := range hist.Runs { + out.Runs = append(out.Runs, schema.ChatsAgentRun{ChatID: r.Chat.ID, Title: r.Chat.Title, Model: r.Chat.Model, Origin: r.Chat.Origin, + OriginID: r.Chat.OriginID, Created: r.Chat.Created, Updated: r.Chat.Updated, Running: r.Turn.Running, LastTurn: string(r.Turn.Last)}) + } + return c.JSON(http.StatusOK, out) +} diff --git a/core/http/endpoints/studio/attachments_test.go b/core/http/endpoints/studio/attachments_test.go new file mode 100644 index 000000000..327cd46e3 --- /dev/null +++ b/core/http/endpoints/studio/attachments_test.go @@ -0,0 +1,57 @@ +package studio + +import ( + "encoding/json" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/studio/session" + "github.com/mudler/LocalAI/core/services/studio/store" +) + +var _ = Describe("attachments in the chats API", func() { + It("a user line gives the words and the files apart", func() { + list := messageList("alice", []session.Entry{{ + Seq: 1, Role: session.RoleUser, Text: "whats in this photo", + Attachments: []session.Attachment{{Path: "/Chats/c/photo.jpg", Name: "photo.jpg", Size: 8, Mime: "image/jpeg", Version: 2, Missing: true}}, + }}) + Expect(list.Messages).To(HaveLen(1)) + m := list.Messages[0] + Expect(m.Text).To(Equal("whats in this photo")) + Expect(m.Attachments).To(Equal([]schema.ChatsAttachment{{Path: "/Chats/c/photo.jpg", Name: "photo.jpg", Size: 8, Mime: "image/jpeg", Version: 2, Missing: true}})) + raw, err := json.Marshal(m) + Expect(err).NotTo(HaveOccurred()) + Expect(string(raw)).To(ContainSubstring(`"attachments":[{"path":"/Chats/c/photo.jpg","name":"photo.jpg","size":8,"mime":"image/jpeg","version":2,"missing":true}]`)) + }) + + It("an old user line with the list in its text gives the words and the files apart", func() { + list := messageList("alice", []session.Entry{{ + Seq: 1, Role: session.RoleUser, + Text: "whats in this photo\n\nThe user attached these files:\n- /workspace/home/Chats/2026-10-30 ci sei?/photo_5985526360434217157_y.jpg", + }}) + m := list.Messages[0] + Expect(m.Text).To(Equal("whats in this photo")) + Expect(m.Attachments).To(HaveLen(1)) + Expect(m.Attachments[0].Path).To(Equal("/Chats/2026-10-30 ci sei?/photo_5985526360434217157_y.jpg")) + Expect(m.Attachments[0].Name).To(Equal("photo_5985526360434217157_y.jpg")) + Expect(m.Attachments[0].Mime).To(Equal("image/jpeg")) + }) + + It("a line without files has no attachments in its JSON", func() { + list := messageList("alice", []session.Entry{{Seq: 1, Role: session.RoleAssistant, Text: "hi"}}) + raw, err := json.Marshal(list.Messages[0]) + Expect(err).NotTo(HaveOccurred()) + Expect(string(raw)).NotTo(ContainSubstring("attachments")) + }) + + It("a waiting message gives the words and the files apart", func() { + p := pendingOf(store.Pending{ID: "p1", Seq: 3, Text: "and this\n\nThe user attached these files:\n- /workspace/home/Chats/c/x.txt"}) + Expect(p.ID).To(Equal("p1")) + Expect(p.Seq).To(Equal(int64(3))) + Expect(p.Text).To(Equal("and this")) + Expect(p.Attachments).To(HaveLen(1)) + Expect(p.Attachments[0].Path).To(Equal("/Chats/c/x.txt")) + }) +}) diff --git a/core/http/endpoints/studio/chatcommands.go b/core/http/endpoints/studio/chatcommands.go new file mode 100644 index 000000000..5576a7f74 --- /dev/null +++ b/core/http/endpoints/studio/chatcommands.go @@ -0,0 +1,44 @@ +package studio + +import ( + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" +) + +// Compact shrinks what the model of a chat sees. +// @Summary Compact a chat's context now. +// @Description The older part of the conversation becomes a summary for the model, the way the chat does by itself when its context fills up; the last messages stay as they are. The transcript keeps every message and gets a compacted line with the sizes, and the next turn starts from the compacted context. It takes a while: the model writes the summary. 409 while a turn runs; 400 when the conversation is too short. +// @Tags chats +// @Param id path string true "conversation id" +// @Success 200 {object} schema.ChatsContextResult "The size of the context, in tokens, before and after" +// @Failure 400 {object} schema.ErrorResponse "The conversation is too short" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Failure 409 {object} schema.ErrorResponse "A turn is running" +// @Router /api/chats/{id}/compact [post] +func (h *Handlers) Compact(c echo.Context) error { + before, after, err := h.svc.Compact(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsContextResult{Before: before, After: after}) +} + +// Clear gives a chat a fresh context. +// @Summary Clear a chat's context. +// @Description The transcript gets a cleared line, and from the next turn on the model sees nothing before it. The messages stay readable, and the chat keeps its files, title and switches. A branch made from a message above the line keeps what the model saw there. 409 while a turn runs; 400 when there is nothing to clear. +// @Tags chats +// @Param id path string true "conversation id" +// @Success 200 {object} map[string]bool "cleared" +// @Failure 400 {object} schema.ErrorResponse "Nothing to clear" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Failure 409 {object} schema.ErrorResponse "A turn is running" +// @Router /api/chats/{id}/clear [post] +func (h *Handlers) Clear(c echo.Context) error { + if err := h.svc.Clear(c.Request().Context(), userID(c), c.Param("id")); err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, map[string]bool{"cleared": true}) +} diff --git a/core/http/endpoints/studio/chatcommands_test.go b/core/http/endpoints/studio/chatcommands_test.go new file mode 100644 index 000000000..31f8407a4 --- /dev/null +++ b/core/http/endpoints/studio/chatcommands_test.go @@ -0,0 +1,156 @@ +package studio + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/labstack/echo/v4" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mudler/LocalAI/core/http/auth" + "github.com/mudler/LocalAI/core/schema" + studiosvc "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/provider" + "github.com/mudler/LocalAI/core/services/studio/session" + "github.com/mudler/LocalAI/core/services/studio/store" + "github.com/mudler/LocalAI/core/services/studio/studiotest" + "github.com/mudler/LocalAI/core/services/studio/workspace" +) + +// commandRig is a chats API over a real service, with a scripted harness that +// compacts on request, and an echo router with the command routes. +type commandRig struct { + svc *studiosvc.Service + e *echo.Echo +} + +func newCommandRig() *commandRig { + h := &studiotest.ToolHarness{Script: func(workspace.Actor, string) []studiotest.ToolStep { + return []studiotest.ToolStep{{Say: "ok"}} + }} + h.Compact = func(string) (int, int, error) { return 800, 200, nil } + st, err := store.NewJSON("") + Expect(err).NotTo(HaveOccurred()) + svc, err := studiosvc.New(studiosvc.Deps{Store: st, Provider: provider.New(), StateDir: GinkgoT().TempDir(), Harness: h}) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = svc.Close(context.Background()) }) + return &commandRig{svc: svc, e: commandRouter(svc)} +} + +func commandRouter(svc *studiosvc.Service) *echo.Echo { + hs := New(svc, nil) + e := echo.New() + e.POST("/api/chats/:id/compact", hs.Compact) + e.POST("/api/chats/:id/clear", hs.Clear) + e.POST("/api/chats/:id/messages", hs.Send) + e.GET("/api/chats/:id/messages", hs.Messages) + return e +} + +// call runs one request and returns the status and the body. +func (r *commandRig) call(method, path, body string) (int, string) { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + if body != "" { + req.Header.Set("Content-Type", "application/json") + } + rec := httptest.NewRecorder() + r.e.ServeHTTP(rec, req.WithContext(context.Background())) + return rec.Code, rec.Body.String() +} + +// asUser makes every request of the router run as user. +func (r *commandRig) asUser(user string) { + r.e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + c.Set("auth_user", &auth.User{ID: user, Role: auth.RoleUser}) + return next(c) + } + }) +} + +func (r *commandRig) chatWithTurn(user string) string { + rec, err := r.svc.Create(context.Background(), user, studiosvc.CreateInput{Model: "m"}) + Expect(err).NotTo(HaveOccurred()) + _, err = r.svc.Send(context.Background(), user, rec.ID, "hello") + Expect(err).NotTo(HaveOccurred()) + Eventually(func() bool { + info, err := r.svc.Turn(context.Background(), user, rec.ID) + return err == nil && !info.Running && info.Last != "" + }, 20*time.Second, 5*time.Millisecond).Should(BeTrue()) + return rec.ID +} + +var _ = Describe("chat command routes", func() { + It("compact answers the sizes and the transcript has the line", func() { + r := newCommandRig() + r.asUser("alice") + id := r.chatWithTurn("alice") + code, body := r.call(http.MethodPost, "/api/chats/"+id+"/compact", "") + Expect(code).To(Equal(http.StatusOK), body) + var res schema.ChatsContextResult + Expect(json.Unmarshal([]byte(body), &res)).To(Succeed()) + Expect(res).To(Equal(schema.ChatsContextResult{Before: 800, After: 200})) + + code, body = r.call(http.MethodGet, "/api/chats/"+id+"/messages", "") + Expect(code).To(Equal(http.StatusOK)) + var list schema.ChatsMessageList + Expect(json.Unmarshal([]byte(body), &list)).To(Succeed()) + last := list.Messages[len(list.Messages)-1] + Expect(last.Role).To(Equal(session.RoleCompacted)) + Expect([]int{last.Before, last.After}).To(Equal([]int{800, 200})) + }) + + It("clear answers 200 then 400 with nothing left, and another user gets 404", func() { + r := newCommandRig() + r.asUser("alice") + id := r.chatWithTurn("alice") + code, body := r.call(http.MethodPost, "/api/chats/"+id+"/clear", "") + Expect(code).To(Equal(http.StatusOK), body) + code, body = r.call(http.MethodPost, "/api/chats/"+id+"/clear", "") + Expect(code).To(Equal(http.StatusBadRequest), body) + Expect(body).To(ContainSubstring("nothing to clear")) + code, _ = r.call(http.MethodPost, "/api/chats/"+id+"/compact", "") + Expect(code).To(Equal(http.StatusBadRequest), "nothing to compact after a clear") + + bob := &commandRig{svc: r.svc, e: commandRouter(r.svc)} + bob.asUser("bob") + for _, p := range []string{"/compact", "/clear"} { + code, _ = bob.call(http.MethodPost, "/api/chats/"+id+p, "") + Expect(code).To(Equal(http.StatusNotFound), "bob %s", p) + } + }) + + It("a message with an unknown skill or agent is a 400 with a reason", func() { + r := newCommandRig() + r.asUser("alice") + id := r.chatWithTurn("alice") + code, body := r.call(http.MethodPost, "/api/chats/"+id+"/messages", `{"text":"x","skill":"nope"}`) + Expect(code).To(Equal(http.StatusBadRequest), body) + Expect(body).To(ContainSubstring(`no skill \"nope\"`)) + code, body = r.call(http.MethodPost, "/api/chats/"+id+"/messages", `{"text":"x","agent":"nobody"}`) + Expect(code).To(Equal(http.StatusBadRequest), body) + Expect(body).To(ContainSubstring("saved agents")) + // A skill message may come without words; the check still runs. + code, _ = r.call(http.MethodPost, "/api/chats/"+id+"/messages", `{"skill":"nope"}`) + Expect(code).To(Equal(http.StatusBadRequest)) + }) + + It("lists the command of a user line and the sizes of a compacted line", func() { + list := messageList("alice", []session.Entry{ + {Seq: 1, Role: session.RoleUser, Text: "check", Invoked: &session.Invocation{Kind: session.InvokeSkill, Name: "review"}}, + {Seq: 2, Role: session.RoleCompacted, Text: session.CompactedText(9000, 3000)}, + {Seq: 3, Role: session.RoleCleared}, + }) + Expect(list.Messages[0].Invoked).To(Equal(&schema.ChatsInvocation{Kind: "skill", Name: "review"})) + Expect([]int{list.Messages[1].Before, list.Messages[1].After}).To(Equal([]int{9000, 3000})) + raw, err := json.Marshal(list.Messages[2]) + Expect(err).NotTo(HaveOccurred()) + Expect(string(raw)).To(ContainSubstring(`"role":"cleared"`)) + Expect(string(raw)).NotTo(ContainSubstring("invoked")) + }) +}) diff --git a/core/http/endpoints/studio/chatfeatures.go b/core/http/endpoints/studio/chatfeatures.go new file mode 100644 index 000000000..683393ddf --- /dev/null +++ b/core/http/endpoints/studio/chatfeatures.go @@ -0,0 +1,269 @@ +package studio + +import ( + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/http/auth" + "github.com/mudler/LocalAI/core/schema" + studiosvc "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/session" +) + +// maxImportBody caps the JSON body of a chat import. +const maxImportBody = 64 << 20 + +func settingsJSON(s session.ChatSettings) *schema.ChatsSettings { + if s == (session.ChatSettings{}) { + return nil + } + return &schema.ChatsSettings{SystemPrompt: s.SystemPrompt, Temperature: s.Temperature, TopP: s.TopP, TopK: s.TopK, + ReasoningEffort: s.ReasoningEffort, ContextSize: s.ContextSize} +} + +func settingsFrom(s schema.ChatsSettings) session.ChatSettings { + return session.ChatSettings{SystemPrompt: s.SystemPrompt, Temperature: s.Temperature, TopP: s.TopP, TopK: s.TopK, + ReasoningEffort: s.ReasoningEffort, ContextSize: s.ContextSize} +} + +// checkModel answers a request that names a model the user may not use. +func (h *Handlers) checkModel(c echo.Context, model string) error { + if h.models == nil { + return nil + } + switch err := h.models.Check(c.Request().Context(), auth.GetUser(c), model); { + case errors.Is(err, ErrUnknownModel): + return fail(c, http.StatusBadRequest, "invalid_request_error", "unknown model") + case errors.Is(err, ErrModelNotAllowed): + return fail(c, http.StatusForbidden, "authorization_error", "you may not use this model") + default: + return err + } +} + +// Update changes a chat. +// @Summary Rename a chat, move it to another model or change its settings. +// @Description The settings (system prompt, temperature, top_p, top_k, reasoning effort, context size) are kept with the chat and apply from its next turn. A new title or model needs a chat with no turn running (409 otherwise). +// @Tags chats +// @Param id path string true "conversation id" +// @Param request body schema.ChatsUpdateRequest true "the fields to change" +// @Success 200 {object} schema.ChatsConversation "The chat after the change" +// @Router /api/chats/{id} [patch] +func (h *Handlers) Update(c echo.Context) error { + req, ok := bind[schema.ChatsUpdateRequest](c, msgNotJSON) + if !ok { + return nil + } + if req.Title == nil && req.Model == nil && req.Settings == nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "name at least one of title, model and settings") + } + if req.Model != nil { + if err := h.checkModel(c, strings.TrimSpace(*req.Model)); err != nil || c.Response().Committed { + return err + } + } + in := studiosvc.UpdateInput{Title: req.Title, Model: req.Model} + if req.Settings != nil { + set := settingsFrom(*req.Settings) + in.Settings = &set + } + user := userID(c) + rec, err := h.svc.Update(c.Request().Context(), user, c.Param("id"), in) + if err != nil { + return failFor(c, err) + } + out, err := h.describe(c, user, rec) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, out) +} + +// Regenerate answers the last message again. +// @Summary Regenerate the last answer. +// @Description Removes everything the model said after your last message (all your messages of that turn stay), then starts a new turn. Files that the removed turn changed stay changed. 409 while a turn runs. +// @Tags chats +// @Param id path string true "conversation id" +// @Success 200 {object} schema.ChatsSendResponse "The new turn started" +// @Router /api/chats/{id}/regenerate [post] +func (h *Handlers) Regenerate(c echo.Context) error { + res, err := h.svc.Regenerate(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsSendResponse{Started: res.Started, PendingID: res.PendingID}) +} + +// EditMessage edits a sent message and runs it again. +// @Summary Edit a message you sent and run the chat again from it. +// @Description Replaces the text of your message with the given sequence number, removes everything after it, and starts a new turn. 409 while a turn runs; 400 when the line is not one of your messages. +// @Tags chats +// @Param id path string true "conversation id" +// @Param seq path int true "sequence number of the message in the transcript" +// @Param request body schema.ChatsEditRequest true "the new text" +// @Success 200 {object} schema.ChatsSendResponse "The new turn started" +// @Router /api/chats/{id}/messages/{seq}/edit [post] +func (h *Handlers) EditMessage(c echo.Context) error { + seq, err := strconv.ParseInt(c.Param("seq"), 10, 64) + if err != nil || seq <= 0 { + return fail(c, http.StatusBadRequest, "invalid_request_error", "seq must be a positive number") + } + var req schema.ChatsEditRequest + if err := c.Bind(&req); err != nil || strings.TrimSpace(req.Text) == "" { + return fail(c, http.StatusBadRequest, "invalid_request_error", "text is required") + } + res, err := h.svc.EditAndRerun(c.Request().Context(), userID(c), c.Param("id"), seq, req.Text) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsSendResponse{Started: res.Started, PendingID: res.PendingID}) +} + +// Fork copies a chat. +// @Summary Fork a chat from a message. +// @Description Copies the transcript up to and including the given line into a new chat with the same model, settings and tool switches. The original is not changed. +// @Tags chats +// @Param id path string true "conversation id" +// @Param request body schema.ChatsForkRequest false "where to cut and the new title" +// @Success 201 {object} schema.ChatsConversation "The new chat" +// @Router /api/chats/{id}/fork [post] +func (h *Handlers) Fork(c echo.Context) error { + var req schema.ChatsForkRequest + if c.Request().ContentLength != 0 { + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "the body is not valid JSON") + } + } + if req.FromSeq < 0 { + return fail(c, http.StatusBadRequest, "invalid_request_error", "from_seq must not be negative") + } + user := userID(c) + rec, err := h.svc.Fork(c.Request().Context(), user, c.Param("id"), req.FromSeq, req.Title) + if err != nil { + return failFor(c, err) + } + out, err := h.describe(c, user, rec) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusCreated, out) +} + +// Search finds chats. +// @Summary Search your chats. +// @Description Finds chats whose title or messages contain the text, ignoring case. The newest chats come first. It reads the newest 500 chats. +// @Tags chats +// @Param q query string true "text to find" +// @Param limit query int false "most chats to return (default 50, at most 200)" +// @Success 200 {object} schema.ChatsChatSearchResponse "The matching chats" +// @Router /api/chats/search [get] +func (h *Handlers) Search(c echo.Context) error { + q := strings.TrimSpace(c.QueryParam("q")) + if q == "" { + return fail(c, http.StatusBadRequest, "invalid_request_error", "q is required") + } + user := userID(c) + hits, err := h.svc.Search(c.Request().Context(), user, q, intParam(c, "limit", defaultListLimit, maxListLimit)) + if err != nil { + return failFor(c, err) + } + out := schema.ChatsChatSearchResponse{Hits: make([]schema.ChatsChatHit, 0, len(hits))} + for _, hit := range hits { + conv, err := h.describe(c, user, hit.Chat) + if err != nil { + return failFor(c, err) + } + out.Hits = append(out.Hits, schema.ChatsChatHit{Chat: conv, InTitle: hit.InTitle, Seq: hit.Seq, Snippet: hit.Snippet}) + } + return c.JSON(http.StatusOK, out) +} + +func exportJSON(e studiosvc.ChatExport) schema.ChatsExport { + out := schema.ChatsExport{Version: e.Version, ExportedAt: e.ExportedAt, Title: e.Title, Model: e.Model, Created: e.Created, + Updated: e.Updated, Messages: make([]schema.ChatsExportMessage, 0, len(e.Messages))} + if s := settingsJSON(e.Settings); s != nil { + out.Settings = *s + } + for _, m := range e.Messages { + out.Messages = append(out.Messages, schema.ChatsExportMessage{Role: m.Role, Text: m.Text, At: m.At}) + } + return out +} + +func exportFrom(e schema.ChatsExport) studiosvc.ChatExport { + out := studiosvc.ChatExport{Version: e.Version, ExportedAt: e.ExportedAt, Title: e.Title, Model: e.Model, Settings: settingsFrom(e.Settings), + Created: e.Created, Updated: e.Updated, Messages: make([]studiosvc.ChatExportMessage, 0, len(e.Messages))} + for _, m := range e.Messages { + out.Messages = append(out.Messages, studiosvc.ChatExportMessage{Role: m.Role, Text: m.Text, At: m.At}) + } + return out +} + +// Export returns a chat as a file. +// @Summary Export a chat. +// @Description Returns the chat as Markdown (format=markdown, the default) or as JSON (format=json), which POST /api/chats/import reads. Files, tool state and checkpoints are not exported. +// @Tags chats +// @Param id path string true "conversation id" +// @Param format query string false "markdown (default) or json" +// @Produce text/markdown,json +// @Success 200 {object} schema.ChatsExport "The chat (JSON format)" +// @Router /api/chats/{id}/export [get] +func (h *Handlers) Export(c echo.Context) error { + format := strings.ToLower(c.QueryParam("format")) + if format != "" && format != "markdown" && format != "json" { + return fail(c, http.StatusBadRequest, "invalid_request_error", "format must be markdown or json") + } + exp, err := h.svc.Export(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failFor(c, err) + } + stamp := time.Now().UTC().Format("2006-01-02") + if format == "json" { + c.Response().Header().Set(echo.HeaderContentDisposition, `attachment; filename="chat-`+stamp+`.json"`) + return c.JSON(http.StatusOK, exportJSON(exp)) + } + c.Response().Header().Set(echo.HeaderContentDisposition, `attachment; filename="chat-`+stamp+`.md"`) + return c.Blob(http.StatusOK, "text/markdown; charset=utf-8", []byte(exp.Markdown())) +} + +// Import stores an exported chat. +// @Summary Import an exported chat. +// @Description Creates a new chat from a JSON export. The model of the file is used unless the request names another; it must be one you may use. The chat has no files and no tool state; the next message starts it. +// @Tags chats +// @Param request body schema.ChatsImportRequest true "the export and an optional model" +// @Success 201 {object} schema.ChatsConversation "The new chat" +// @Failure 400 {object} schema.ErrorResponse "The body is not a valid export" +// @Failure 413 {object} schema.ErrorResponse "The export is too large" +// @Router /api/chats/import [post] +func (h *Handlers) Import(c echo.Context) error { + c.Request().Body = http.MaxBytesReader(c.Response(), c.Request().Body, maxImportBody) + req, ok := bind[schema.ChatsImportRequest](c, "the body is not a valid chat export") + if !ok { + return nil + } + model := strings.TrimSpace(req.Model) + if model == "" { + model = strings.TrimSpace(req.Chat.Model) + } + if model == "" { + return failFor(c, studiosvc.ErrNoModel) + } + if err := h.checkModel(c, model); err != nil || c.Response().Committed { + return err + } + user := userID(c) + rec, err := h.svc.ImportExport(c.Request().Context(), user, exportFrom(req.Chat), model) + if err != nil { + return failFor(c, err) + } + out, err := h.describe(c, user, rec) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusCreated, out) +} diff --git a/core/http/endpoints/studio/chatfiles.go b/core/http/endpoints/studio/chatfiles.go new file mode 100644 index 000000000..1049ea783 --- /dev/null +++ b/core/http/endpoints/studio/chatfiles.go @@ -0,0 +1,46 @@ +package studio + +import ( + "net/http" + "strings" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/studio/session" +) + +// ChatFiles lists the files of a chat. +// @Summary List the files of a chat. +// @Description The files that belong to one conversation: the files in its folder under Chats (attached by the user, or written there), and the files its turns created or changed elsewhere in the user's home folder. Paths are the current ones, so they follow a rename of the chat. Each file has a kind: deliverable, or working for a script, a log or a draft the chat made on the way (in its work folder Chats//work, or a helper file elsewhere). The model gets the same list in its prompt at every turn. A files event on the chat's event stream says that the list may have changed. +// @Tags chats +// @Param id path string true "conversation id" +// @Success 200 {object} schema.ChatsChatFileList "The files of the chat" +// @Router /api/chats/{id}/files [get] +func (h *Handlers) ChatFiles(c echo.Context) error { + cf, err := h.svc.ChatFiles(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failFor(c, err) + } + out := schema.ChatsChatFileList{Files: make([]schema.ChatsChatFile, 0, len(cf.Files)), Folder: cf.Folder, Stale: cf.Stale} + if cf.Folder != "" { + out.FolderPath = homePath(strings.TrimSuffix(cf.Folder, "/")) + } + for _, f := range cf.Files { + item := schema.ChatsChatFile{ + Name: f.Name, Path: homePath(f.GuestPath), GuestPath: f.GuestPath, Size: f.Size, Mime: f.Mime, + Origin: string(f.Origin), Changed: f.Changed, Turn: f.Turn, Media: f.Media, Kind: f.Kind, + } + if !f.At.IsZero() { + at := f.At.UTC() + item.At = &at + } + out.Files = append(out.Files, item) + } + return c.JSON(http.StatusOK, out) +} + +// homePath turns a guest path in the home mount into a path of the files API. +func homePath(guest string) string { + return strings.TrimPrefix(guest, session.GuestRoot+"/"+session.GuestHome) +} diff --git a/core/http/endpoints/studio/connectors.go b/core/http/endpoints/studio/connectors.go new file mode 100644 index 000000000..6a48b95cc --- /dev/null +++ b/core/http/endpoints/studio/connectors.go @@ -0,0 +1,293 @@ +package studio + +import ( + "errors" + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" + addonruntime "github.com/mudler/LocalAI/core/services/addons/runtime" + studiosvc "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +func (h *Handlers) connectorHost(c echo.Context) (*studiosvc.Connectors, bool) { + return feature(c, h.svc.Connectors(), func(c echo.Context) error { return failFor(c, studiosvc.ErrConnectorsOff) }) +} + +func connectorOut(i studiosvc.ConnectorInfo) schema.ChatsConnector { + allow := i.Allow + if allow == nil { + allow = []string{} + } + return schema.ChatsConnector{ID: i.ID, Name: i.Name, Kind: i.Kind, Addon: i.AddonID, Active: i.Active, Reason: i.Reason, AgentID: i.AgentID, Enabled: i.Enabled, AcceptAny: i.AcceptAny, + Allow: allow, NotifyChat: i.NotifyChat, HasToken: i.HasToken, Created: i.Created, + ReplyMode: i.ReplyMode(), Conversation: i.ConversationMode(), WindowMinutes: i.WindowMinutes, SendFiles: i.SendsFiles()} +} + +// acceptAnyConfirmation is what a request must say to turn on accept_any. +const acceptAnyConfirmation = "accept any message" + +func failAcceptAny(c echo.Context) error { + return fail(c, http.StatusBadRequest, "invalid_request_error", + `accept_any lets anyone who can message the bot use the agent as you; to turn it on, set confirm_accept_any to "`+acceptAnyConfirmation+`"`) +} + +func connectorIn(req schema.ChatsConnectorWrite) studiosvc.ConnectorInput { + return studiosvc.ConnectorInput{Kind: req.Kind, Name: req.Name, Token: req.Token, AgentID: req.AgentID, Enabled: req.Enabled, + AcceptAny: req.AcceptAny, Allow: req.Allow, NotifyChat: req.NotifyChat, + Reply: req.ReplyMode, Conversation: req.Conversation, WindowMinutes: req.WindowMinutes, SendFiles: req.SendFiles} +} + +var connectorRules = []errRule{ + fixed(http.StatusNotFound, typeNotFound, "connector not found", connectors.ErrNotFound), + verbatim(http.StatusBadRequest, typeInvalid, connectors.ErrInvalid.Error()+": ", connectors.ErrInvalid), + verbatim(http.StatusConflict, typeConnectorOff, "", studiosvc.ErrConnectorsOff), +} + +func failConnector(c echo.Context, err error) error { + return mapErr(c, err, connectorRules, func(c echo.Context, err error) error { + var ce *contract.ConnectorError + if errors.As(err, &ce) && ce.Code == contract.ConnectorUnauthorized { + return fail(c, http.StatusBadGateway, typeUpstream, "the platform refused the bot token") + } + if errors.As(err, &ce) || errors.Is(err, studiosvc.ErrConnectorAddon) || errors.Is(err, addonruntime.ErrRuntime) || errors.Is(err, addonruntime.ErrNotSent) { + return fail(c, http.StatusBadGateway, typeUpstream, "the platform could not be reached or refused the request") + } + return failFor(c, err) + }) +} + +// ListConnectors lists the bindings. +// @Summary List your connector bindings. +// @Description A binding connects a bot of a messaging platform (served by a connector add-on, such as Telegram) to one of your saved agents. A person who messages the bot talks to that agent, in a conversation of their own, as you: your quota, your model allowlist and only the agent's grants apply. +// @Tags chats +// @Success 200 {object} schema.ChatsConnectorList "Your bindings, sorted by name" +// @Router /api/chats/connectors [get] +func (h *Handlers) ListConnectors(c echo.Context) error { + host, ok := h.connectorHost(c) + if !ok { + return nil + } + list, err := host.ListConnectors(c.Request().Context(), userID(c)) + if err != nil { + return failConnector(c, err) + } + out := schema.ChatsConnectorList{Connectors: make([]schema.ChatsConnector, 0, len(list))} + for _, i := range list { + out.Connectors = append(out.Connectors, connectorOut(i)) + } + return c.JSON(http.StatusOK, out) +} + +// ConnectorKinds lists the kinds of binding that installed add-ons serve. +// @Summary List the connector kinds you can bind. +// @Description The messaging platforms that a running connector add-on serves, with the add-on page that adds and edits their bindings. Empty when no connector add-on is installed. +// @Tags chats +// @Success 200 {object} schema.ChatsConnectorKindList "The kinds" +// @Router /api/chats/connectors/kinds [get] +func (h *Handlers) ConnectorKinds(c echo.Context) error { + host, ok := h.connectorHost(c) + if !ok { + return nil + } + kinds, err := host.ConnectorKinds(c.Request().Context()) + if err != nil { + return failConnector(c, err) + } + out := schema.ChatsConnectorKindList{Kinds: make([]schema.ChatsConnectorKind, 0, len(kinds))} + for _, k := range kinds { + out.Kinds = append(out.Kinds, schema.ChatsConnectorKind{Addon: k.AddonID, Kind: k.ID, Title: k.Title, Description: k.Description, SenderHint: k.SenderHint, Page: k.Page}) + } + return c.JSON(http.StatusOK, out) +} + +// CreateConnector creates a binding. +// @Summary Create a connector binding. +// @Description Needs the kind (the platform), the bot token (kept sealed, never returned) and an agent of yours that names a model. The token is checked with the platform when its add-on runs: a token that the platform refuses is a 400. reply_mode (off, progress, stream; default progress), conversation (sender, chat, window with window_minutes, thread; default sender) and send_files (default true) say how the bot answers. The allowlist is empty at first, so nobody is accepted: add chat IDs, user IDs or @usernames, or set accept_any (anyone who can message the bot then uses the agent's tools; the request that turns it on must also carry confirm_accept_any set to "accept any message", or it is a 400). At most 10 bindings. +// @Tags chats +// @Param request body schema.ChatsConnectorWrite true "the binding" +// @Success 201 {object} schema.ChatsConnector "The binding" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Router /api/chats/connectors [post] +func (h *Handlers) CreateConnector(c echo.Context) error { + host, ok := h.connectorHost(c) + if !ok { + return nil + } + req, ok := bind[schema.ChatsConnectorWrite](c, msgNotJSON) + if !ok { + return nil + } + if req.AcceptAny && req.ConfirmAcceptAny != acceptAnyConfirmation { + return failAcceptAny(c) + } + i, err := host.CreateConnector(c.Request().Context(), userID(c), connectorIn(req)) + if err != nil { + return failConnector(c, err) + } + return c.JSON(http.StatusCreated, connectorOut(i)) +} + +// GetConnector reads a binding. +// @Summary Read a connector binding. +// @Tags chats +// @Param id path string true "the binding's id" +// @Success 200 {object} schema.ChatsConnector "The binding" +// @Router /api/chats/connectors/{id} [get] +func (h *Handlers) GetConnector(c echo.Context) error { + host, ok := h.connectorHost(c) + if !ok { + return nil + } + i, err := host.GetConnector(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failConnector(c, err) + } + return c.JSON(http.StatusOK, connectorOut(i)) +} + +// UpdateConnector replaces a binding. +// @Summary Replace a connector binding. +// @Description The body replaces every field except the token, which is kept unless the request has a new one, and send_files, which is kept when it is absent. A new token is checked with the platform: a token that the platform refuses is a 400 and nothing is saved. +// @Tags chats +// @Param id path string true "the binding's id" +// @Param request body schema.ChatsConnectorWrite true "the binding" +// @Success 200 {object} schema.ChatsConnector "The binding" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Router /api/chats/connectors/{id} [put] +func (h *Handlers) UpdateConnector(c echo.Context) error { + host, ok := h.connectorHost(c) + if !ok { + return nil + } + req, ok := bind[schema.ChatsConnectorWrite](c, msgNotJSON) + if !ok { + return nil + } + if req.AcceptAny && req.ConfirmAcceptAny != acceptAnyConfirmation { + // Only turning it on is a new decision; a binding that has it keeps it. + cur, err := host.GetConnector(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failConnector(c, err) + } + if !cur.AcceptAny { + return failAcceptAny(c) + } + } + i, err := host.UpdateConnector(c.Request().Context(), userID(c), c.Param("id"), connectorIn(req)) + if err != nil { + return failConnector(c, err) + } + return c.JSON(http.StatusOK, connectorOut(i)) +} + +// DeleteConnector deletes a binding. +// @Summary Delete a connector binding. +// @Description Its offset, sender log and sender-to-chat map go; the chats it made stay. +// @Tags chats +// @Param id path string true "the binding's id" +// @Success 204 "Deleted" +// @Router /api/chats/connectors/{id} [delete] +func (h *Handlers) DeleteConnector(c echo.Context) error { + host, ok := h.connectorHost(c) + if !ok { + return nil + } + if err := host.DeleteConnector(c.Request().Context(), userID(c), c.Param("id")); err != nil { + return failConnector(c, err) + } + return c.NoContent(http.StatusNoContent) +} + +func (h *Handlers) setConnectorEnabled(c echo.Context, on bool) error { + host, ok := h.connectorHost(c) + if !ok { + return nil + } + i, err := host.SetConnectorEnabled(c.Request().Context(), userID(c), c.Param("id"), on) + if err != nil { + return failConnector(c, err) + } + return c.JSON(http.StatusOK, connectorOut(i)) +} + +// EnableConnector switches a binding on. +// @Summary Switch a connector binding on. +// @Tags chats +// @Param id path string true "the binding's id" +// @Success 200 {object} schema.ChatsConnector "The binding" +// @Router /api/chats/connectors/{id}/enable [post] +func (h *Handlers) EnableConnector(c echo.Context) error { return h.setConnectorEnabled(c, true) } + +// DisableConnector switches a binding off. +// @Summary Switch a connector binding off. +// @Tags chats +// @Param id path string true "the binding's id" +// @Success 200 {object} schema.ChatsConnector "The binding" +// @Router /api/chats/connectors/{id}/disable [post] +func (h *Handlers) DisableConnector(c echo.Context) error { return h.setConnectorEnabled(c, false) } + +// TestConnector asks the platform who the bot is. +// @Summary Test a connector binding. +// @Description Asks the platform, through its add-on, for the bot of the stored token. 502 when the platform refuses the token, the add-on does not run or the platform cannot be reached. +// @Tags chats +// @Param id path string true "the binding's id" +// @Success 200 {object} schema.ChatsConnectorTest "The bot" +// @Router /api/chats/connectors/{id}/test [post] +func (h *Handlers) TestConnector(c echo.Context) error { + host, ok := h.connectorHost(c) + if !ok { + return nil + } + bot, err := host.TestConnector(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failConnector(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsConnectorTest{BotID: bot.ID, BotUsername: bot.Username}) +} + +// ConnectorSenders lists who messaged a bot. +// @Summary List the senders of a connector binding. +// @Description The log of people who wrote to the bot, newest first, with whether they were accepted. A message from a sender who is not accepted is ignored, never answered. +// @Tags chats +// @Param id path string true "the binding's id" +// @Success 200 {object} schema.ChatsConnectorSenderList "The senders" +// @Router /api/chats/connectors/{id}/senders [get] +func (h *Handlers) ConnectorSenders(c echo.Context) error { + host, ok := h.connectorHost(c) + if !ok { + return nil + } + list, err := host.ConnectorSenders(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failConnector(c, err) + } + out := schema.ChatsConnectorSenderList{Senders: make([]schema.ChatsConnectorSender, 0, len(list))} + for _, s := range list { + out.Senders = append(out.Senders, schema.ChatsConnectorSender{ID: s.ID, Name: s.Name, Count: s.Count, First: s.First, Last: s.Last, Allowed: s.Allowed}) + } + return c.JSON(http.StatusOK, out) +} + +// AllowConnectorSender adds a sender of the log to the allowlist. +// @Summary Allow a sender of a connector binding. +// @Description Adds a sender who already wrote to the bot to the allowlist. +// @Tags chats +// @Param id path string true "the binding's id" +// @Param sender path string true "the sender's id from the sender log" +// @Success 200 {object} schema.ChatsConnector "The binding" +// @Router /api/chats/connectors/{id}/senders/{sender}/allow [post] +func (h *Handlers) AllowConnectorSender(c echo.Context) error { + host, ok := h.connectorHost(c) + if !ok { + return nil + } + i, err := host.AllowSender(c.Request().Context(), userID(c), c.Param("id"), c.Param("sender")) + if err != nil { + return failConnector(c, err) + } + return c.JSON(http.StatusOK, connectorOut(i)) +} diff --git a/core/http/endpoints/studio/conversion.go b/core/http/endpoints/studio/conversion.go new file mode 100644 index 000000000..994a8c61d --- /dev/null +++ b/core/http/endpoints/studio/conversion.go @@ -0,0 +1,48 @@ +package studio + +import ( + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/studio/convert" +) + +func totalsOf(r convert.Report) schema.ChatsConversionTotals { + c := r.Counts() + return schema.ChatsConversionTotals{Converted: c[convert.StatusConverted], Skipped: c[convert.StatusSkipped], + Failed: c[convert.StatusFailed], Dropped: c[convert.StatusDropped]} +} + +// Conversion returns what the conversion of the old data did for the caller. +// @Summary Report the conversion of your old agents, skills and chats. +// @Description At start the server converts the old chat page, agents, tasks, skills and Telegram connectors into chats, saved agents, triggers, skills and connector bindings. Triggers and connectors are made switched off. This lists the items that belong to you, with what was dropped and why; an administrator also gets the totals of all users. It never holds a token or the text of a prompt. +// @Tags chats +// @Success 200 {object} schema.ChatsConversion "The report" +// @Router /api/chats/conversion [get] +func (h *Handlers) Conversion(c echo.Context) error { + rep, ok, err := h.svc.Conversion(c.Request().Context()) + if err != nil { + return failFor(c, err) + } + out := schema.ChatsConversion{Done: ok, Items: []schema.ChatsConversionItem{}} + if ok { + own := rep.ForUser(userID(c)) + if isAdmin(c) && userID(c) != "" { + // What had no owner when accounts were switched on belongs to nobody + // who could read it: the administrator gets it. + own.Items = append(own.Items, rep.ForUser("").Items...) + } + out.At = &rep.At + out.Counts = totalsOf(own) + for _, i := range own.Items { + out.Items = append(out.Items, schema.ChatsConversionItem{Kind: i.Kind, Name: i.Name, Status: i.Status, Reason: i.Reason}) + } + if isAdmin(c) { + t := totalsOf(rep) + out.Totals = &t + } + } + return c.JSON(http.StatusOK, out) +} diff --git a/core/http/endpoints/studio/draft.go b/core/http/endpoints/studio/draft.go new file mode 100644 index 000000000..be63180c5 --- /dev/null +++ b/core/http/endpoints/studio/draft.go @@ -0,0 +1,80 @@ +package studio + +import ( + "errors" + "net/http" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/http/auth" +) + +// draftQuery reads the model and the agent of a chat that does not exist yet, +// and refuses what Create would refuse: an agent without the agents feature, +// a model the user may not use. +func (h *Handlers) draftQuery(c echo.Context) (model, agentID string, ok bool) { + model, agentID = c.QueryParam("model"), c.QueryParam("agent_id") + if agentID != "" && !auth.FeatureAllowed(c, auth.FeatureAgents) { + _ = fail(c, http.StatusForbidden, "authorization_error", "feature not enabled for your account: "+auth.FeatureAgents) + return "", "", false + } + if model != "" && h.models != nil { + switch err := h.models.Check(c.Request().Context(), auth.GetUser(c), model); { + case errors.Is(err, ErrUnknownModel): + _ = fail(c, http.StatusBadRequest, "invalid_request_error", "unknown model") + return "", "", false + case errors.Is(err, ErrModelNotAllowed): + _ = fail(c, http.StatusForbidden, "authorization_error", "you may not use this model") + return "", "", false + case err != nil: + _ = failFor(c, err) + return "", "", false + } + } + return model, agentID, true +} + +// DraftTools lists the tools a new chat could switch. +// @Summary List the tools a new chat could switch on and off. +// @Description What GET /api/chats/{id}/tools lists, for a chat that does not exist yet: a chat on model, with the saved agent agent_id when it is set (the agent's model, when it has one, replaces model). Every switch is in its default state. The composer of a new chat shows it and sends the person's choices in the switches of POST /api/chats. +// @Tags chats +// @Param model query string false "the model of the new chat" +// @Param agent_id query string false "a saved agent of yours" +// @Success 200 {object} schema.ChatsToolsPanel "The tools and their default switches" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Failure 403 {object} schema.ErrorResponse "Refused" +// @Failure 404 {object} schema.ErrorResponse "No such agent" +// @Router /api/chats/draft/tools [get] +func (h *Handlers) DraftTools(c echo.Context) error { + model, agentID, ok := h.draftQuery(c) + if !ok { + return nil + } + p, err := h.svc.DraftTools(c.Request().Context(), userID(c), model, agentID) + if err != nil { + return failFor(c, err) + } + return writeToolsPanel(c, p) +} + +// DraftAddons lists the add-ons a new chat could use. +// @Summary List the add-ons a new chat could use. +// @Description What GET /api/chats/{id}/addons lists, for a chat that does not exist yet, as GET /api/chats/draft/tools takes it: the add-ons with their default switch and whether the chat could run commands at its first turn. +// @Tags chats +// @Param model query string false "the model of the new chat" +// @Param agent_id query string false "a saved agent of yours" +// @Success 200 {object} schema.ChatsAddonList "The add-ons and their default switches" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Failure 403 {object} schema.ErrorResponse "Refused" +// @Failure 404 {object} schema.ErrorResponse "No such agent" +// @Router /api/chats/draft/addons [get] +func (h *Handlers) DraftAddons(c echo.Context) error { + model, agentID, ok := h.draftQuery(c) + if !ok { + return nil + } + list, cmds, err := h.svc.DraftAddons(c.Request().Context(), userID(c), model, agentID) + if err != nil { + return failFor(c, err) + } + return writeAddonList(c, list, cmds) +} diff --git a/core/http/endpoints/studio/egress.go b/core/http/endpoints/studio/egress.go new file mode 100644 index 000000000..d40d3587c --- /dev/null +++ b/core/http/endpoints/studio/egress.go @@ -0,0 +1,133 @@ +package studio + +import ( + "net/http" + "strconv" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/studio/egress" + "github.com/mudler/LocalAI/core/services/studio/egress/audit" +) + +// GetEgress returns the network of a chat's Sandbox commands. +// @Summary Read the network of a chat's Sandbox commands. +// @Description The preset of the chat (off, registries, all), the administrator's ceiling, the preset in force (the lower of the two), whether it applies (false when commands run on this machine, applies_reason host, or no Sandbox serves bash, none), addon (the add-on that runs the commands and draws the network controls of the chat, from its network-policy host control; absent when none does), the package registries the preset registries admits, the chat's allow and deny rules (above_limit when the ceiling no longer admits one) and the cards that asked. A sub-agent chat shows its parent's. Another user's chat is 404. +// @Tags chats +// @Param id path string true "conversation id" +// @Success 200 {object} schema.ChatsEgress "The network of the chat" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/{id}/egress [get] +func (h *Handlers) GetEgress(c echo.Context) error { + v, err := h.svc.Egress(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, v.JSON()) +} + +// PutEgress sets the preset of a chat. +// @Summary Set the network preset of a chat's Sandbox commands. +// @Description off reaches nothing; registries the HTTPS hosts of the package registries; all every public host. A preset above the administrator's ceiling (chats_egress_ceiling) is 400 egress_above_ceiling. A sub-agent follows its parent: 409. It applies at the next connection of a command. +// @Tags chats +// @Param id path string true "conversation id" +// @Param request body schema.ChatsEgressPreset true "the preset" +// @Success 200 {object} schema.ChatsEgress "The network of the chat" +// @Failure 400 {object} schema.ErrorResponse "Not a preset, or above the ceiling" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/{id}/egress [put] +func (h *Handlers) PutEgress(c echo.Context) error { + var in schema.ChatsEgressPreset + if err := c.Bind(&in); err != nil { + return fail(c, http.StatusBadRequest, typeInvalid, "the body must be {preset}") + } + ctx := c.Request().Context() + if err := h.svc.SetEgressPreset(ctx, userID(c), c.Param("id"), egress.Preset(in.Preset)); err != nil { + return failFor(c, err) + } + v, err := h.svc.Egress(ctx, userID(c), c.Param("id")) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, v.JSON()) +} + +// DeleteEgressRule removes an allow or deny rule of a chat. +// @Summary Remove a network rule of a chat. +// @Description Removes the rule that allows or denies one host and port for the chat's Sandbox commands. A host that was denied asks again; one that was allowed follows the preset again. +// @Tags chats +// @Param id path string true "conversation id" +// @Param host path string true "host name" +// @Param port path int true "port" +// @Success 204 "Removed" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or no such rule" +// @Router /api/chats/{id}/egress/rules/{host}/{port} [delete] +func (h *Handlers) DeleteEgressRule(c echo.Context) error { + port, err := strconv.Atoi(c.Param("port")) + if err != nil || port < 1 || port > 65535 { + return fail(c, http.StatusBadRequest, typeInvalid, "the port must be 1 to 65535") + } + if err := h.svc.DeleteEgressRule(c.Request().Context(), userID(c), c.Param("id"), c.Param("host"), port); err != nil { + return failFor(c, err) + } + return c.NoContent(http.StatusNoContent) +} + +// AdminEgress returns the administrator's view of the Sandbox network. +// @Summary Read the settings of the Sandbox network (administrators). +// @Description The chats_egress_* settings in force (ceiling, default, allow_private, ports, extra_registries, allowed_hosts, audit_days), the package registries with the extra hosts, the add-on that runs commands and how (isolated or host; the settings do not apply to commands on this machine), and the allowed and blocked requests of the last 24 hours. Change the settings with POST /api/settings. +// @Tags chats +// @Success 200 {object} schema.ChatsEgressAdmin "The settings in force" +// @Router /api/chats/admin/egress [get] +func (h *Handlers) AdminEgress(c echo.Context) error { + v, err := h.svc.AdminEgress(c.Request().Context(), userID(c)) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, v.JSON()) +} + +// AdminEgressEvents returns the audit of the Sandbox network. +// @Summary Read the audit of the Sandbox network requests (administrators). +// @Description Every request a command made through the Sandbox proxy, newest first: who, which chat, host, port, kind (connect or http), verdict (allow, block, or done when an allowed connection ended, with its address, bytes and duration) and reason. +// @Tags chats +// @Param limit query int false "How many lines (default 100, at most 500)" +// @Param user query string false "Only this user" +// @Param chat query string false "Only this chat" +// @Param verdict query string false "allow, block or done" +// @Param host query string false "Only this host" +// @Success 200 {object} schema.ChatsEgressEventList "The newest lines first" +// @Router /api/chats/admin/egress/events [get] +func (h *Handlers) AdminEgressEvents(c echo.Context) error { + ctx := c.Request().Context() + evs, err := h.svc.EgressEvents(ctx, audit.Query{Limit: intParam(c, "limit", 100, audit.MaxPage), User: c.QueryParam("user"), + Chat: c.QueryParam("chat"), Verdict: c.QueryParam("verdict"), Host: c.QueryParam("host")}) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, h.svc.EgressEventsJSON(ctx, evs)) +} + +// EgressConsent answers a card that asks whether a command may reach a host. +// @Summary Answer a network card of a chat's Sandbox commands. +// @Description A command asked for a host the chat does not allow but the administrator's ceiling would: an egress_consent event carried the card id and host:port, and the card is in GET /api/chats/{id}/egress. once allows the host for the rest of that command (or the next call of the chat within ten minutes when the answer comes late), chat stores an allow rule for the chat, deny stores a deny rule so the host does not ask again. A host inside your network (private) needs an administrator to allow it; anyone who owns the chat may deny. A card that expired at the end of its turn may still be answered. The waiting connection goes on at once; without an answer it gets a 403 after 60 seconds. +// @Tags chats +// @Param id path string true "conversation id" +// @Param request body schema.ChatsEgressConsent true "the card id and the answer" +// @Success 204 "The answer was stored and sent to the waiting command" +// @Failure 400 {object} schema.ErrorResponse "Not an answer, or the ceiling does not admit the host (egress_above_ceiling; the card is refused, and every later answer gets the same reason)" +// @Failure 403 {object} schema.ErrorResponse "Only an administrator can allow a host inside your network" +// @Failure 404 {object} schema.ErrorResponse "No such conversation or card" +// @Failure 409 {object} schema.ErrorResponse "The card was answered already with another answer (already_answered); the same answer again is 204" +// @Router /api/chats/{id}/egress/consent [post] +func (h *Handlers) EgressConsent(c echo.Context) error { + var in schema.ChatsEgressConsent + if err := c.Bind(&in); err != nil { + return fail(c, http.StatusBadRequest, typeInvalid, "the body must be {id, decision}") + } + if err := h.svc.EgressDecide(c.Request().Context(), userID(c), c.Param("id"), in.ID, in.Decision); err != nil { + return failFor(c, err) + } + return c.NoContent(http.StatusNoContent) +} diff --git a/core/http/endpoints/studio/errmap.go b/core/http/endpoints/studio/errmap.go new file mode 100644 index 000000000..b6951a8bb --- /dev/null +++ b/core/http/endpoints/studio/errmap.go @@ -0,0 +1,111 @@ +package studio + +import ( + "errors" + "net/http" + "strings" + + "github.com/labstack/echo/v4" +) + +// msgNotJSON is the answer to a body that does not parse. +const msgNotJSON = "the body is not valid JSON" + +// Error types of the chats API. The UI and clients match on them. +const ( + typeInvalid = "invalid_request_error" + typeNotFound = "not_found" + typeConflict = "conflict" + typeServerError = "server_error" + typeUpstream = "upstream_error" + typeAuthz = "authorization_error" + typeFilesOff = "files_off" + typeAgentsOff = "agents_off" + typeNotifyOff = "notifications_off" + typeTriggersOff = "triggers_off" + typeConnectorOff = "connectors_off" + // typePageNotAllowed: a shared page called its bridge without your consent + // to the version it runs (or after you revoked it). + typePageNotAllowed = "page_not_allowed" + // typeSharedPagesOff: an administrator switched the shared pages off. + typeSharedPagesOff = "shared_pages_off" +) + +// errRule is one row of an error table: the errors that match (errors.Is), the +// status and type of the answer, and its text. The text is msg when it is set; +// else the text of the error, less the prefix trim, or less the sentinel strip +// and a following ": " when strip is set. +type errRule struct { + is []error + status int + typ string + msg string + trim string + strip error +} + +// fixed answers with msg whatever the text of the error is, so that the answer +// never holds a detail of the failure. +func fixed(status int, typ, msg string, is ...error) errRule { + return errRule{is: is, status: status, typ: typ, msg: msg} +} + +// verbatim answers with the text of the error, less the prefix trim. +func verbatim(status int, typ, trim string, is ...error) errRule { + return errRule{is: is, status: status, typ: typ, trim: trim} +} + +// sentinelText answers with the text of the error after its sentinel. The +// text is written for the person who reads it. +func sentinelText(status int, typ string, sentinel error) errRule { + return errRule{is: []error{sentinel}, status: status, typ: typ, strip: sentinel} +} + +func (r errRule) text(err error) string { + switch { + case r.msg != "": + return r.msg + case r.strip != nil: + return strings.TrimPrefix(strings.TrimPrefix(err.Error(), r.strip.Error()), ": ") + } + return strings.TrimPrefix(err.Error(), r.trim) +} + +func (r errRule) matches(err error) bool { + for _, target := range r.is { + if errors.Is(err, target) { + return true + } + } + return false +} + +// mapErr answers with the first rule that matches err. When none does it calls +// next, the mapper of the layer below. +func mapErr(c echo.Context, err error, rules []errRule, next func(echo.Context, error) error) error { + for _, r := range rules { + if r.matches(err) { + return fail(c, r.status, r.typ, r.text(err)) + } + } + return next(c, err) +} + +// bind reads the JSON body of the request into a T. When it cannot, it writes +// the answer and returns false: 413 for a body over the limit that the handler +// set with http.MaxBytesReader, else 400 with msg. +func bind[T any](c echo.Context, msg string) (T, bool) { + var req T + err := c.Bind(&req) + if err == nil { + return req, true + } + var tooBig *http.MaxBytesError + var httpErr *echo.HTTPError + if errors.As(err, &tooBig) || (errors.As(err, &httpErr) && httpErr.Code == http.StatusRequestEntityTooLarge) { + _ = fail(c, http.StatusRequestEntityTooLarge, typeInvalid, "the body is too large") + } else { + _ = fail(c, http.StatusBadRequest, typeInvalid, msg) + } + return req, false +} diff --git a/core/http/endpoints/studio/errmap_test.go b/core/http/endpoints/studio/errmap_test.go new file mode 100644 index 000000000..f081cf162 --- /dev/null +++ b/core/http/endpoints/studio/errmap_test.go @@ -0,0 +1,128 @@ +package studio + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + + "github.com/labstack/echo/v4" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mudler/LocalAI/core/schema" + studiosvc "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/session" + "github.com/mudler/LocalAI/core/services/studio/triggers" + "github.com/mudler/LocalAI/core/services/studio/workspace" +) + +// answer runs a mapper on err and decodes the one JSON document it writes. +func answer(mapper func(echo.Context, error) error, err error) (int, schema.APIError) { + rec := httptest.NewRecorder() + c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/api/chats/x", nil), rec) + Expect(mapper(c, err)).To(Succeed()) + var body schema.ErrorResponse + Expect(json.NewDecoder(rec.Body).Decode(&body)).To(Succeed()) + Expect(body.Error).NotTo(BeNil()) + return rec.Code, *body.Error +} + +var _ = Describe("the studio endpoint helpers", func() { + It("gives the status, type and text of each row of the error tables", func() { + tests := []struct { + name string + mapper func(echo.Context, error) error + err error + status int + typ string + msg string + }{ + {"a missing conversation", failFor, session.ErrNotFound, 404, "not_found", "conversation not found"}, + {"a wrapped sentinel still matches", failFor, fmt.Errorf("opening: %w", session.ErrNotFound), 404, "not_found", "conversation not found"}, + {"a busy chat", failFor, studiosvc.ErrChatBusy, 409, "conflict", "a turn is running; stop it first"}, + {"a refusal keeps its text less the package prefix", failFor, fmt.Errorf("%w", studiosvc.ErrNothingToRegenerate), 400, "invalid_request_error", "the chat has no message to answer again"}, + {"an attachment over the limit", failFor, workspace.ErrTooLarge, 413, "invalid_request_error", "an attachment is too large"}, + {"an unknown error hides its text", failFor, errors.New("secret detail"), 500, "server_error", "the request failed"}, + {"a missing file is a missing path", failFile, workspace.ErrReadOnly, 404, "not_found", "not found"}, + {"a file over the limit", failFile, workspace.ErrTooLarge, 413, "invalid_request_error", "the file is too large"}, + {"the body limit of the server", failFile, echo.NewHTTPError(http.StatusRequestEntityTooLarge), 413, "invalid_request_error", "the file is too large"}, + {"an unknown file error hides its text", failFile, errors.New("disk path /x"), 500, "server_error", "the request failed"}, + {"a missing trigger", failTrigger, triggers.ErrNotFound, 404, "not_found", "trigger not found"}, + {"a bad trigger says why", failTrigger, fmt.Errorf("%w: the schedule is empty", studiosvc.ErrBadTrigger), 400, "invalid_request_error", "the schedule is empty"}, + {"triggers off", failTrigger, studiosvc.ErrTriggersOff, 409, "triggers_off", studiosvc.ErrTriggersOff.Error()}, + {"a trigger error falls to the chat table", failTrigger, session.ErrNotFound, 404, "not_found", "conversation not found"}, + {"an app refusal keeps its reason", failApp, fmt.Errorf("%w: not declared", studiosvc.ErrMCPAppRefused), 403, "mcp_app_refused", "not declared"}, + {"an app refusal with no reason", failApp, studiosvc.ErrMCPAppRefused, 403, "mcp_app_refused", ""}, + {"an MCP content failure", failMCPContent, fmt.Errorf("%w: server said no", studiosvc.ErrMCPContent), 502, "mcp_error", "server said no"}, + } + for _, tt := range tests { + status, apiErr := answer(tt.mapper, tt.err) + Expect(status).To(Equal(tt.status), tt.name) + Expect(apiErr.Type).To(Equal(tt.typ), tt.name) + Expect(apiErr.Code).To(BeEquivalentTo(tt.status), tt.name) + Expect(apiErr.Message).To(ContainSubstring(tt.msg), tt.name) + } + }) + + It("bind answers 400 for bad JSON and 413 for a body over the limit", func() { + type body struct { + Name string `json:"name"` + } + run := func(raw string, limit int64) (body, bool, int, schema.APIError) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/x", strings.NewReader(raw)) + req.Header.Set("Content-Type", "application/json") + c := echo.New().NewContext(req, rec) + if limit > 0 { + c.Request().Body = http.MaxBytesReader(rec, c.Request().Body, limit) + } + got, ok := bind[body](c, "the body is not valid JSON") + var out schema.ErrorResponse + if !ok { + _ = json.NewDecoder(rec.Body).Decode(&out) + return got, ok, rec.Code, *out.Error + } + return got, ok, rec.Code, schema.APIError{} + } + got, ok, _, _ := run(`{"name":"a"}`, 0) + Expect(ok).To(BeTrue()) + Expect(got.Name).To(Equal("a")) + _, ok, status, apiErr := run(`{"name":`, 0) + Expect(ok).To(BeFalse()) + Expect(status).To(Equal(http.StatusBadRequest)) + Expect(apiErr.Message).To(Equal("the body is not valid JSON")) + _, ok, status, apiErr = run(`{"name":"`+strings.Repeat("x", 100)+`"}`, 16) + Expect(ok).To(BeFalse()) + Expect(status).To(Equal(http.StatusRequestEntityTooLarge)) + Expect(apiErr.Type).To(Equal("invalid_request_error")) + }) +}) + +var _ = Describe("the feature guard", func() { + off := func(c echo.Context) error { return fail(c, http.StatusConflict, "thing_off", "the thing is off") } + + It("returns the host of a feature that is on and writes nothing", func() { + rec := httptest.NewRecorder() + c := echo.New().NewContext(httptest.NewRequest(http.MethodGet, "/api/chats/x", nil), rec) + host := &struct{ name string }{"on"} + got, ok := feature(c, host, off) + Expect(ok).To(BeTrue()) + Expect(got).To(BeIdenticalTo(host)) + Expect(rec.Body.Len()).To(BeZero()) + }) + + It("writes the answer of a feature that is off", func() { + code, body := answer(func(c echo.Context, _ error) error { + got, ok := feature[struct{}](c, nil, off) + Expect(ok).To(BeFalse()) + Expect(got).To(BeNil()) + return nil + }, nil) + Expect(code).To(Equal(http.StatusConflict)) + Expect(body.Type).To(Equal("thing_off")) + Expect(body.Message).To(Equal("the thing is off")) + }) +}) diff --git a/core/http/endpoints/studio/events_test.go b/core/http/endpoints/studio/events_test.go new file mode 100644 index 000000000..3ed8a34f5 --- /dev/null +++ b/core/http/endpoints/studio/events_test.go @@ -0,0 +1,36 @@ +package studio + +import ( + "encoding/json" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mudler/LocalAI/core/services/studio/session" +) + +var _ = Describe("eventJSON", func() { + It("carries the model, the node and the prompt time of a usage event", func() { + raw, err := json.Marshal(eventJSON(session.Event{Kind: session.KindUsage, Usage: &session.UsageEvent{ + Used: 1200, Limit: 8192, Completion: 40, TokensPerSecond: 32.5, + Model: "qwen3-8b", Node: "worker-2", PromptMillis: 180, + }})) + Expect(err).ToNot(HaveOccurred()) + var got struct { + Usage map[string]any `json:"usage"` + } + Expect(json.Unmarshal(raw, &got)).To(Succeed()) + Expect(got.Usage).To(HaveKeyWithValue("model", "qwen3-8b")) + Expect(got.Usage).To(HaveKeyWithValue("node", "worker-2")) + Expect(got.Usage).To(HaveKeyWithValue("prompt_ms", BeNumerically("==", 180))) + Expect(got.Usage).To(HaveKeyWithValue("tokens_per_second", BeNumerically("==", 32.5))) + }) + + It("leaves the new fields out when they are not known", func() { + raw, err := json.Marshal(eventJSON(session.Event{Kind: session.KindUsage, Usage: &session.UsageEvent{Used: 10, Completion: 2}})) + Expect(err).ToNot(HaveOccurred()) + Expect(string(raw)).ToNot(ContainSubstring(`"node"`)) + Expect(string(raw)).ToNot(ContainSubstring(`"prompt_ms"`)) + Expect(string(raw)).ToNot(ContainSubstring(`"model"`)) + }) +}) diff --git a/core/http/endpoints/studio/filepreview.go b/core/http/endpoints/studio/filepreview.go new file mode 100644 index 000000000..5a5088cd2 --- /dev/null +++ b/core/http/endpoints/studio/filepreview.go @@ -0,0 +1,165 @@ +package studio + +import ( + "bytes" + "container/list" + "context" + "errors" + "io" + "net/http" + "path" + "strings" + "sync" + "time" + + "github.com/labstack/echo/v4" + "github.com/mudler/xlog" + + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/studio/files/preview" + "github.com/mudler/LocalAI/core/services/studio/workspace" +) + +const ( + // maxPreviewBytes is the largest file the server reads for a preview. A + // larger one is a download only. + maxPreviewBytes = 20 << 20 + // previewTimeout bounds the work on one file. + previewTimeout = 10 * time.Second + // previewSlots is how many previews are read at once. Each holds the file + // in memory, so this bounds the memory as well as the CPU. + previewSlots = 4 + // previewCacheSize is how many previews the cache keeps. + previewCacheSize = 64 +) + +// previewCache keeps recent previews by the content they were read from (the +// blob's hash and the type), so a file read again after a check of the +// caller's access costs nothing, and a new version of a file is a new key. +// Every frontend of a cluster keeps its own: an entry never goes stale, so +// there is nothing to share (see docs/content/features/chats.md). +type previewCache struct { + mu sync.Mutex + order *list.List + items map[string]*list.Element + slots chan struct{} +} + +type previewEntry struct { + key string + val schema.ChatsFilePreview +} + +func newPreviewCache() *previewCache { + return &previewCache{order: list.New(), items: map[string]*list.Element{}, slots: make(chan struct{}, previewSlots)} +} + +func (pc *previewCache) get(key string) (schema.ChatsFilePreview, bool) { + pc.mu.Lock() + defer pc.mu.Unlock() + if el, ok := pc.items[key]; ok { + pc.order.MoveToFront(el) + return el.Value.(previewEntry).val, true + } + return schema.ChatsFilePreview{}, false +} + +func (pc *previewCache) put(key string, val schema.ChatsFilePreview) { + pc.mu.Lock() + defer pc.mu.Unlock() + if el, ok := pc.items[key]; ok { + pc.order.MoveToFront(el) + return + } + pc.items[key] = pc.order.PushFront(previewEntry{key: key, val: val}) + for pc.order.Len() > previewCacheSize { + last := pc.order.Back() + pc.order.Remove(last) + delete(pc.items, last.Value.(previewEntry).key) + } +} + +// PreviewFile reads what a spreadsheet, a CSV or TSV file, a Word document or +// a PowerPoint presentation holds, for the preview pane of a chat. +// @Summary Preview a spreadsheet, document or presentation. +// @Description Reads the values of a .xlsx, .xlsm, .csv or .tsv file (kind "table": every sheet, at most the first 200 rows and 30 columns, with the size of the whole sheet), the headings and paragraphs of a .docx (kind "document") or the slide titles, text and speaker notes of a .pptx (kind "slides"). The formatting is not kept. The same path rules as the download apply. Files over 20 MiB are not read. +// @Tags chats +// @Param path query string true "file inside your home" +// @Produce json +// @Success 200 {object} schema.ChatsFilePreview "The content of the file" +// @Failure 400 {object} schema.ErrorResponse "The path is not valid or is a folder" +// @Failure 404 {object} schema.ErrorResponse "No such file" +// @Failure 413 {object} schema.ErrorResponse "The file is over 20 MiB" +// @Failure 415 {object} schema.ErrorResponse "This type of file has no preview" +// @Failure 422 {object} schema.ErrorResponse "The file could not be read" +// @Router /api/chats/files/preview [get] +func (h *Handlers) PreviewFile(c echo.Context) error { + fc, ok := h.files(c) + if !ok { + return nil + } + abs, err := fc.abs(c.QueryParam("path"), false) + if err != nil { + return failFile(c, err) + } + ctx, cancel := context.WithTimeout(c.Request().Context(), previewTimeout) + defer cancel() + r, n, err := fc.f.Store().Open(ctx, fc.a, abs) + if err != nil { + return failFile(c, err) + } + defer func() { _ = r.Close() }() + if n.Kind != workspace.KindFile { + return failFile(c, workspace.ErrInvalid) + } + if !preview.Supported(n.Name) { + return fail(c, http.StatusUnsupportedMediaType, "unsupported_preview", "this type of file has no preview; download it instead") + } + if n.Size > maxPreviewBytes { + return fail(c, http.StatusRequestEntityTooLarge, "too_large", "the file is over 20 MiB; download it instead") + } + c.Response().Header().Set("Cache-Control", "private, no-store") + key := n.Blob + "\x00" + strings.ToLower(path.Ext(n.Name)) + if n.Blob != "" { + if out, ok := h.previews.get(key); ok { + return c.JSON(http.StatusOK, out) + } + } + select { + case h.previews.slots <- struct{}{}: + defer func() { <-h.previews.slots }() + case <-ctx.Done(): + return previewFailed(c, ctx.Err()) + } + data, err := io.ReadAll(io.LimitReader(r, maxPreviewBytes+1)) + if err != nil { + return previewFailed(c, err) + } + if len(data) > maxPreviewBytes { + return fail(c, http.StatusRequestEntityTooLarge, "too_large", "the file is over 20 MiB; download it instead") + } + out, err := preview.Build(ctx, n.Name, bytes.NewReader(data), int64(len(data))) + if err != nil { + return previewFailed(c, err) + } + if n.Blob != "" { + h.previews.put(key, out) + } + return c.JSON(http.StatusOK, out) +} + +func previewFailed(c echo.Context, err error) error { + switch { + case errors.Is(err, preview.ErrTooLarge): + return fail(c, http.StatusRequestEntityTooLarge, "too_large", "the file unpacks to too much data to preview; download it instead") + case errors.Is(err, preview.ErrInvalid): + return fail(c, http.StatusUnprocessableEntity, "invalid_file", "the file could not be read; it may be damaged or not the type its name says") + case errors.Is(err, context.DeadlineExceeded): + return fail(c, http.StatusUnprocessableEntity, "too_complex", "the file took too long to read; download it instead") + case errors.Is(err, context.Canceled): + // The client went away; nobody reads the answer. + return nil + } + xlog.Error("studio: file preview failed", "error", err) + return fail(c, http.StatusInternalServerError, typeServerError, "the request failed") +} diff --git a/core/http/endpoints/studio/files.go b/core/http/endpoints/studio/files.go new file mode 100644 index 000000000..1c0c2feff --- /dev/null +++ b/core/http/endpoints/studio/files.go @@ -0,0 +1,510 @@ +package studio + +import ( + "errors" + "io" + "mime" + "net/http" + "path" + "strconv" + "strings" + "time" + + "github.com/labstack/echo/v4" + "github.com/mudler/xlog" + + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/studio/extract" + "github.com/mudler/LocalAI/core/services/studio/files" + "github.com/mudler/LocalAI/core/services/studio/workspace" +) + +// The files endpoints work on the user's own workspace. Every path is inside +// the user's home folder, which is "/": a path cannot name anything else, and +// another user's files cannot be named at all. + +// fileCall holds what a files endpoint needs after the checks. +type fileCall struct { + f *files.Service + p string // the user's owner ID + a workspace.Actor +} + +// files checks that the workspace is on and the user has a home. On failure it +// has written the response and returns false. +func (h *Handlers) files(c echo.Context) (fileCall, bool) { + f := h.svc.Files() + if !f.Available() { + _ = fail(c, http.StatusConflict, "files_off", f.Unavailable()) + return fileCall{}, false + } + p := h.svc.Principal(userID(c)) + if err := f.EnsureHome(c.Request().Context(), p); err != nil { + _ = failFile(c, err) + return fileCall{}, false + } + return fileCall{f: f, p: p, a: f.Actor(p)}, true +} + +// fileRules maps the errors of the workspace. A path the user cannot reach is +// the same answer as one that does not exist. +var fileRules = []errRule{ + fixed(http.StatusNotFound, typeNotFound, "not found", workspace.ErrNotFound, workspace.ErrReadOnly, workspace.ErrNotAdmin), + fixed(http.StatusForbidden, "forbidden", "only an administrator may change the shared skills", files.ErrNotAdmin), + verbatim(http.StatusBadRequest, typeInvalid, "", files.ErrBadSkill), + fixed(http.StatusBadRequest, typeInvalid, "the path has a name that is not allowed", workspace.ErrBadName), + fixed(http.StatusConflict, typeConflict, "something with that name exists already", workspace.ErrExists), + fixed(http.StatusRequestEntityTooLarge, typeInvalid, "the file is too large", workspace.ErrTooLarge), + fixed(http.StatusBadRequest, typeInvalid, "that cannot be done to this path", workspace.ErrInvalid), + fixed(http.StatusConflict, typeConflict, "later changes conflict with this undo", workspace.ErrConflict), + verbatim(http.StatusConflict, typeFilesOff, "", files.ErrUnavailable), + verbatim(http.StatusConflict, typeFilesOff, "", files.ErrNoPurge), +} + +// failFile maps a workspace error to a response. +func failFile(c echo.Context, err error) error { + // The server's body limit fails the read of an upload with a 413 of its own. + var httpErr *echo.HTTPError + if errors.As(err, &httpErr) && httpErr.Code == http.StatusRequestEntityTooLarge { + err = workspace.ErrTooLarge + } + return mapErr(c, err, fileRules, func(c echo.Context, err error) error { + xlog.Error("studio: files request failed", "path", c.Request().URL.Path, "error", err) + return fail(c, http.StatusInternalServerError, typeServerError, "the request failed") + }) +} + +func (fc fileCall) node(n workspace.Node) schema.ChatsFile { + rel := fc.f.Rel(fc.p, n.Path) + return schema.ChatsFile{ + Path: rel, Name: n.Name, Kind: string(n.Kind), Size: n.Size, Version: n.Version, Rev: n.Rev, + ConflictOf: fc.f.Rel(fc.p, n.ConflictOf), Managed: files.Managed(rel), + } +} + +// abs resolves a path of the request. "/" is the home, which no call may delete or move. +func (fc fileCall) abs(rel string, allowHome bool) (string, error) { + if rel == "" { + rel = "/" + } + abs, err := fc.f.Abs(fc.p, rel) + if err != nil { + return "", err + } + if !allowHome && fc.f.Rel(fc.p, abs) == "/" { + return "", workspace.ErrInvalid + } + return abs, nil +} + +// ListFiles lists a folder. +// @Summary List a folder of your files. +// @Tags chats +// @Param path query string false "folder inside your home; default /" +// @Success 200 {object} schema.ChatsFileList "The folder's entries, sorted by name, each with when it last changed; managed marks the folders LocalAI manages; max_file_size is the largest upload the server takes" +// @Failure 409 {object} schema.ErrorResponse "Files are off on this server" +// @Router /api/chats/files [get] +func (h *Handlers) ListFiles(c echo.Context) error { + fc, ok := h.files(c) + if !ok { + return nil + } + abs, err := fc.abs(c.QueryParam("path"), true) + if err != nil { + return failFile(c, err) + } + if fc.f.Rel(fc.p, abs) == "/" { + // The settings files that are still at the top of the home move + // into LocalAI/ before the user sees the folder. + h.svc.WellKnown().AdoptTopLevelFiles(c.Request().Context(), fc.p) + } + kids, err := fc.f.Store().List(c.Request().Context(), fc.a, abs) + if err != nil { + return failFile(c, err) + } + out := schema.ChatsFileList{Path: fc.f.Rel(fc.p, abs), Entries: make([]schema.ChatsFile, 0, len(kids)), MaxFileSize: fc.f.MaxFileSize()} + for _, k := range kids { + e := fc.node(k) + if !k.Modified.IsZero() { + m := k.Modified + e.Modified = &m + } + out.Entries = append(out.Entries, e) + } + return c.JSON(http.StatusOK, out) +} + +// inlineTypes may be shown by the browser. Everything else is a download, and +// is served as opaque bytes, so a file a user writes cannot run as a page. +var inlineTypes = map[string]bool{ + "image/png": true, "image/jpeg": true, "image/gif": true, "image/webp": true, + "application/pdf": true, "text/plain": true, + "audio/mpeg": true, "audio/wav": true, "audio/ogg": true, "video/mp4": true, "video/webm": true, +} + +// GetFileContent downloads a file. +// @Summary Download a file. +// @Description Files are served as downloads. With inline=1 an image, PDF, plain text, audio or video is shown by the browser instead. Ranges are supported. +// @Tags chats +// @Param path query string true "file inside your home" +// @Param inline query int false "1 to show a safe type in the browser" +// @Produce application/octet-stream +// @Success 200 {file} binary "The file" +// @Router /api/chats/files/content [get] +func (h *Handlers) GetFileContent(c echo.Context) error { + fc, ok := h.files(c) + if !ok { + return nil + } + abs, err := fc.abs(c.QueryParam("path"), false) + if err != nil { + return failFile(c, err) + } + r, n, err := fc.f.Store().Open(c.Request().Context(), fc.a, abs) + if err != nil { + return failFile(c, err) + } + defer func() { _ = r.Close() }() + ct := mime.TypeByExtension(path.Ext(n.Name)) + if i := strings.IndexByte(ct, ';'); i >= 0 { + ct = ct[:i] + } + if ct == "" { + var err error + if ct, err = sniffType(r); err != nil { + return fail(c, http.StatusInternalServerError, "server_error", "the file could not be read") + } + } + disposition := "attachment" + if inlineTypes[ct] { + if c.QueryParam("inline") == "1" { + disposition = "inline" + } + } else { + ct = "application/octet-stream" + } + res := c.Response() + res.Header().Set("Content-Type", ct) + res.Header().Set("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{"filename": n.Name})) + res.Header().Set("X-Content-Type-Options", "nosniff") + res.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'") + http.ServeContent(res, c.Request(), n.Name, time.Time{}, r) + return nil +} + +// sniffType detects the content type from the start of r and rewinds r. A +// reader that cannot rewind is an error: ServeContent would send the file from +// the wrong offset with status 200. +func sniffType(r io.ReadSeeker) (string, error) { + head := make([]byte, 512) + k, err := io.ReadFull(r, head) + if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { + return "", err + } + if _, err := r.Seek(0, io.SeekStart); err != nil { + return "", err + } + ct := http.DetectContentType(head[:k]) + if i := strings.IndexByte(ct, ';'); i >= 0 { + ct = ct[:i] + } + return ct, nil +} + +// PutFileContent uploads a file. +// @Summary Upload a file. +// @Description The request body is the file. A file that exists is replaced only when the upload says which version it started from (base); otherwise both are kept and the new one gets a conflict name, reported in conflict_of. overwrite=1 replaces without the check. +// @Tags chats +// @Param path query string true "file inside your home; missing folders are created" +// @Param base query int false "version of the existing file the upload started from" +// @Param overwrite query int false "1 to replace without the version check" +// @Accept application/octet-stream +// @Success 200 {object} schema.ChatsFile "The stored file" +// @Failure 413 {object} schema.ErrorResponse "The file is too large" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Router /api/chats/files/content [put] +func (h *Handlers) PutFileContent(c echo.Context) error { + fc, ok := h.files(c) + if !ok { + return nil + } + abs, err := fc.abs(c.QueryParam("path"), false) + if err != nil { + return failFile(c, err) + } + opts := workspace.PutOptions{Overwrite: c.QueryParam("overwrite") == "1"} + if b := c.QueryParam("base"); b != "" { + v, perr := strconv.ParseInt(b, 10, 64) + if perr != nil || v < 0 { + return fail(c, http.StatusBadRequest, "invalid_request_error", "base must be a version number") + } + opts.BaseVersion = v + } + n, err := fc.f.Store().Put(c.Request().Context(), fc.a, abs, c.Request().Body, opts) + if err != nil && !errors.Is(err, workspace.ErrCommitted) { + return failFile(c, err) + } + fc.f.Written(c.Request().Context(), n, fc.p) + return c.JSON(http.StatusOK, fc.node(n)) +} + +// CreateFolder creates a folder. +// @Summary Create a folder. +// @Tags chats +// @Param request body schema.ChatsFolderRequest true "the folder" +// @Success 200 {object} schema.ChatsFile "The folder" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Router /api/chats/files/folders [post] +func (h *Handlers) CreateFolder(c echo.Context) error { + fc, ok := h.files(c) + if !ok { + return nil + } + var req schema.ChatsFolderRequest + if err := c.Bind(&req); err != nil || req.Path == "" { + return fail(c, http.StatusBadRequest, "invalid_request_error", "path is required") + } + abs, err := fc.abs(req.Path, false) + if err != nil { + return failFile(c, err) + } + n, err := fc.f.Store().Mkdir(c.Request().Context(), fc.a, abs) + if err != nil && !errors.Is(err, workspace.ErrCommitted) { + return failFile(c, err) + } + return c.JSON(http.StatusOK, fc.node(n)) +} + +// MoveFile moves or renames a file or folder. +// @Summary Move or rename a file or folder. +// @Tags chats +// @Param request body schema.ChatsMoveRequest true "from and to" +// @Success 200 {object} schema.ChatsFile "The moved entry" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Router /api/chats/files/move [post] +func (h *Handlers) MoveFile(c echo.Context) error { + fc, ok := h.files(c) + if !ok { + return nil + } + var req schema.ChatsMoveRequest + if err := c.Bind(&req); err != nil || req.From == "" || req.To == "" { + return fail(c, http.StatusBadRequest, "invalid_request_error", "from and to are required") + } + from, err := fc.abs(req.From, false) + if err != nil { + return failFile(c, err) + } + to, err := fc.abs(req.To, false) + if err != nil { + return failFile(c, err) + } + n, err := fc.f.Store().Move(c.Request().Context(), fc.a, from, to) + if err != nil && !errors.Is(err, workspace.ErrCommitted) { + return failFile(c, err) + } + return c.JSON(http.StatusOK, fc.node(n)) +} + +// DeleteFile moves a file or folder to the Trash. +// @Summary Delete a file or folder. +// @Description Nothing is erased: the deletion is a change that the Trash lists and undo restores. +// @Tags chats +// @Param path query string true "file or folder inside your home" +// @Success 200 {object} map[string]bool "deleted" +// @Router /api/chats/files [delete] +func (h *Handlers) DeleteFile(c echo.Context) error { + fc, ok := h.files(c) + if !ok { + return nil + } + abs, err := fc.abs(c.QueryParam("path"), false) + if err != nil { + return failFile(c, err) + } + if err := fc.f.Store().Delete(c.Request().Context(), fc.a, abs); err != nil { + return failFile(c, err) + } + return c.JSON(http.StatusOK, map[string]bool{"deleted": true}) +} + +// Trash lists deletions that can be undone. +// @Summary List the Trash. +// @Tags chats +// @Success 200 {object} schema.ChatsTrash "Deletions that nobody has undone, newest first" +// @Router /api/chats/files/trash [get] +func (h *Handlers) Trash(c echo.Context) error { + fc, ok := h.files(c) + if !ok { + return nil + } + entries, err := fc.f.Trash(c.Request().Context(), fc.p, intParam(c, "limit", 100, 500)) + if err != nil { + return failFile(c, err) + } + out := schema.ChatsTrash{Entries: make([]schema.ChatsTrashEntry, 0, len(entries))} + for _, e := range entries { + out.Entries = append(out.Entries, schema.ChatsTrashEntry{Rev: e.Rev, Title: e.Title, At: e.At, Paths: e.Paths, Files: e.Files, Folders: e.Dirs, Bytes: e.Bytes}) + } + // A store that cannot count leaves the totals at zero; the list stays. + if t, err := fc.f.EmptyTrash(c.Request().Context(), fc.p, true); err == nil { + out.Totals = trashTotals(t) + } + return c.JSON(http.StatusOK, out) +} + +func trashTotals(t workspace.PurgeCount) schema.ChatsTrashTotals { + return schema.ChatsTrashTotals{Files: t.Files, Folders: t.Dirs, Bytes: t.Bytes} +} + +// EmptyTrash deletes everything in the Trash for good. +// @Summary Empty the Trash. +// @Description Permanently deletes every file and folder in your Trash, with their older versions, and frees the space. It cannot be undone. It touches only your own Trash. The agent tools cannot do this; it is an action of the person. +// @Tags chats +// @Success 200 {object} schema.ChatsTrashTotals "What was removed" +// @Router /api/chats/files/trash [delete] +func (h *Handlers) EmptyTrash(c echo.Context) error { + fc, ok := h.files(c) + if !ok { + return nil + } + t, err := fc.f.EmptyTrash(c.Request().Context(), fc.p, false) + if err != nil { + return failFile(c, err) + } + return c.JSON(http.StatusOK, trashTotals(t)) +} + +// PurgeTrashEntry deletes one item of the Trash for good. +// @Summary Delete one item of the Trash permanently. +// @Description Permanently deletes the files and folders that one change set deleted, by the revision that the Trash lists. It cannot be undone. A revision that is not in your Trash is 404. +// @Tags chats +// @Param rev path int true "revision of the Trash entry" +// @Success 200 {object} schema.ChatsTrashTotals "What was removed" +// @Failure 400 {object} schema.ErrorResponse "The revision is not a number" +// @Failure 404 {object} schema.ErrorResponse "Not in your Trash" +// @Router /api/chats/files/trash/{rev} [delete] +func (h *Handlers) PurgeTrashEntry(c echo.Context) error { + fc, ok := h.files(c) + if !ok { + return nil + } + rev, err := strconv.ParseInt(c.Param("rev"), 10, 64) + if err != nil || rev <= 0 { + return fail(c, http.StatusBadRequest, typeInvalid, "rev must be a positive number") + } + t, err := fc.f.PurgeTrashEntry(c.Request().Context(), fc.p, rev) + if err != nil { + return failFile(c, err) + } + return c.JSON(http.StatusOK, trashTotals(t)) +} + +// UndoFiles undoes a change set. +// @Summary Undo a change. +// @Description Restores what a change set changed, as a new change. When later changes conflict, nothing changes and the answer is 409, unless skip_conflicts is set: then the rest is restored and the conflicts are listed. +// @Tags chats +// @Param request body schema.ChatsUndoRequest true "the revision" +// @Success 200 {object} schema.ChatsUndoResponse "The new revision and any conflicts that were skipped" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Failure 409 {object} schema.ErrorResponse "The state of the server does not allow it (a feature that is off, or a conflict)" +// @Router /api/chats/files/undo [post] +func (h *Handlers) UndoFiles(c echo.Context) error { + fc, ok := h.files(c) + if !ok { + return nil + } + var req schema.ChatsUndoRequest + if err := c.Bind(&req); err != nil || req.Rev <= 0 { + return fail(c, http.StatusBadRequest, "invalid_request_error", "rev is required") + } + cs, conflicts, err := fc.f.Store().Undo(c.Request().Context(), fc.a, req.Rev, workspace.UndoOptions{SkipConflicts: req.SkipConflicts}) + out := schema.ChatsUndoResponse{Rev: cs.Rev} + for _, k := range conflicts { + out.Conflicts = append(out.Conflicts, schema.ChatsFileConflict{Path: fc.f.Rel(fc.p, k.Path), Reason: k.Reason}) + } + if err != nil { + if errors.Is(err, workspace.ErrConflict) { + return c.JSON(http.StatusConflict, out) + } + return failFile(c, err) + } + return c.JSON(http.StatusOK, out) +} + +// FileHistory lists change sets. +// @Summary List the changes to your files. +// @Tags chats +// @Param path query string false "folder or file inside your home; default /" +// @Param limit query int false "most entries to return (default 50, at most 200)" +// @Success 200 {object} schema.ChatsHistory "Change sets, newest first" +// @Router /api/chats/files/history [get] +func (h *Handlers) FileHistory(c echo.Context) error { + fc, ok := h.files(c) + if !ok { + return nil + } + abs, err := fc.abs(c.QueryParam("path"), true) + if err != nil { + return failFile(c, err) + } + hist, err := fc.f.Store().History(c.Request().Context(), fc.a, workspace.HistoryQuery{Path: abs, Limit: intParam(c, "limit", 50, 200)}) + if err != nil { + return failFile(c, err) + } + out := schema.ChatsHistory{Entries: make([]schema.ChatsHistoryEntry, 0, len(hist))} + for _, cs := range hist { + e := schema.ChatsHistoryEntry{Rev: cs.Rev, Title: cs.Title, Kind: string(cs.Kind), Chat: cs.ChatID, At: cs.CreatedAt} + for _, op := range cs.Ops { + o := schema.ChatsFileOp{Kind: string(op.Kind), Path: fc.f.Rel(fc.p, op.NewPath)} + if op.Kind == workspace.OpDelete { + o.Path = fc.f.Rel(fc.p, op.OldPath) + } + if op.Kind == workspace.OpMove { + o.From = fc.f.Rel(fc.p, op.OldPath) + } + e.Ops = append(e.Ops, o) + } + out.Entries = append(out.Entries, e) + } + return c.JSON(http.StatusOK, out) +} + +// SearchFiles searches your files. +// @Summary Search your files. +// @Description Finds files by name, lines inside text files and lines of the text of PDF, Word, Excel and PowerPoint files that contain the query, ignoring case. Only what you can read is searched. A document hit has document true and its page, sheet or slide; indexing counts the documents whose text is still being read. Text files over 1 MiB and other binary files are found by name only. +// @Tags chats +// @Param q query string true "text to find" +// @Param path query string false "search only below this folder; default /" +// @Param limit query int false "most hits to return (default 50, at most 200)" +// @Success 200 {object} schema.ChatsSearchResult "Name matches first, then lines" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Router /api/chats/files/search [get] +func (h *Handlers) SearchFiles(c echo.Context) error { + fc, ok := h.files(c) + if !ok { + return nil + } + q := strings.TrimSpace(c.QueryParam("q")) + if q == "" { + return fail(c, http.StatusBadRequest, "invalid_request_error", "q is required") + } + res, err := fc.f.Search(c.Request().Context(), fc.p, q, c.QueryParam("path"), intParam(c, "limit", 50, 200)) + if err != nil { + return failFile(c, err) + } + out := schema.ChatsSearchResult{Hits: make([]schema.ChatsSearchHit, 0, len(res.Hits)), Indexing: res.Indexing, Limited: res.Limited} + for _, hit := range res.Hits { + h := schema.ChatsSearchHit{Path: hit.Path, Kind: string(hit.Kind), Line: hit.Line, Snippet: hit.Snippet, Document: hit.Document} + switch hit.Section.Kind { + case extract.SectionPage: + h.Page, _ = strconv.Atoi(hit.Section.Name) + case extract.SectionSlide: + h.Slide, _ = strconv.Atoi(hit.Section.Name) + case extract.SectionSheet: + h.Sheet = hit.Section.Name + } + out.Hits = append(out.Hits, h) + } + return c.JSON(http.StatusOK, out) +} diff --git a/core/http/endpoints/studio/files_sniff_test.go b/core/http/endpoints/studio/files_sniff_test.go new file mode 100644 index 000000000..8042a1c9a --- /dev/null +++ b/core/http/endpoints/studio/files_sniff_test.go @@ -0,0 +1,45 @@ +//go:build auth + +package studio + +import ( + "bytes" + "errors" + "io" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type seekFails struct{ *bytes.Reader } + +func (seekFails) Seek(int64, int) (int64, error) { return 0, errors.New("seek failed") } + +type readFails struct{} + +func (readFails) Read([]byte) (int, error) { return 0, errors.New("disk error") } +func (readFails) Seek(int64, int) (int64, error) { return 0, nil } + +var _ = Describe("sniffType", func() { + It("reads a short file and rewinds", func() { + r := bytes.NewReader([]byte("hello")) + ct, err := sniffType(r) + Expect(err).ToNot(HaveOccurred()) + Expect(ct).To(Equal("text/plain")) + rest, _ := io.ReadAll(r) + Expect(string(rest)).To(Equal("hello"), "the reader was not rewound") + }) + + It("fails when the reader cannot rewind", func() { + // A reader that stays at the end would be served from the wrong offset + // with status 200, so the caller must see the failure. + _, err := sniffType(seekFails{bytes.NewReader([]byte(strings.Repeat("x", 600)))}) + Expect(err).To(HaveOccurred(), "a failed rewind was ignored") + }) + + It("fails when the file cannot be read", func() { + _, err := sniffType(readFails{}) + Expect(err).To(HaveOccurred(), "a failed read was ignored") + }) +}) diff --git a/core/http/endpoints/studio/goal.go b/core/http/endpoints/studio/goal.go new file mode 100644 index 000000000..4c0c218ad --- /dev/null +++ b/core/http/endpoints/studio/goal.go @@ -0,0 +1,91 @@ +package studio + +import ( + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/studio/session" +) + +// goalJSON is a goal as the API gives it, with the limits that apply now. +func (h *Handlers) goalJSON(g *session.Goal) *schema.ChatsGoal { + if g == nil { + return nil + } + turns, steps := h.svc.GoalLimits() + return &schema.ChatsGoal{Text: g.Text, State: string(g.State), PausedBy: g.PausedBy, Note: g.Note, + Turns: g.Turns, MaxTurns: turns, Steps: g.Steps, MaxSteps: steps, Set: g.Set, Updated: g.Updated} +} + +// GetGoal returns the goal of a chat. +// @Summary Get a chat's goal. +// @Tags chats +// @Param id path string true "conversation id" +// @Success 200 {object} schema.ChatsGoalResponse "The goal, or null" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/{id}/goal [get] +func (h *Handlers) GetGoal(c echo.Context) error { + g, err := h.svc.Goal(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsGoalResponse{Goal: h.goalJSON(g)}) +} + +// PutGoal sets the goal of a chat. +// @Summary Set a chat's goal. +// @Description Sets the goal, replacing the one the chat had (replaced). With no turn running a turn starts on it at once. While the goal is active the server starts the next turn by itself when one ends, until the model calls goal_done or goal_blocked, you stop or clear it, or the limits chats_goal_max_turns and chats_goal_max_steps pause it. A chat that a trigger, a connector, a job or a page started, and a sub-agent run, cannot have a goal (409). +// @Tags chats +// @Param id path string true "conversation id" +// @Param request body schema.ChatsGoalRequest true "the goal" +// @Success 200 {object} schema.ChatsGoalResponse "The new goal and the one it replaced" +// @Failure 400 {object} schema.ErrorResponse "The text is empty or longer than 2000 characters" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Failure 409 {object} schema.ErrorResponse "This chat cannot have a goal" +// @Router /api/chats/{id}/goal [put] +func (h *Handlers) PutGoal(c echo.Context) error { + var req schema.ChatsGoalRequest + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, typeInvalid, "the body must be JSON {text}") + } + prev, cur, err := h.svc.SetGoal(c.Request().Context(), userID(c), c.Param("id"), req.Text) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsGoalResponse{Goal: h.goalJSON(cur), Replaced: h.goalJSON(prev)}) +} + +// DeleteGoal clears the goal of a chat. +// @Summary Clear a chat's goal. +// @Description A running turn goes on; nothing follows it by itself. Clearing a chat with no goal is not an error. +// @Tags chats +// @Param id path string true "conversation id" +// @Success 200 {object} schema.ChatsGoalResponse "goal null, replaced the goal that was removed" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/{id}/goal [delete] +func (h *Handlers) DeleteGoal(c echo.Context) error { + prev, err := h.svc.ClearGoal(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsGoalResponse{Replaced: h.goalJSON(prev)}) +} + +// ResumeGoal resumes a paused or blocked goal. +// @Summary Resume a chat's goal. +// @Description Makes a paused or blocked goal active again with new turn and step counts, and starts a turn on it when none runs. A reached goal does not resume (409): set a new one. +// @Tags chats +// @Param id path string true "conversation id" +// @Success 200 {object} schema.ChatsGoalResponse "The goal" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or the chat has no goal" +// @Failure 409 {object} schema.ErrorResponse "The goal is reached, or this chat cannot have a goal" +// @Router /api/chats/{id}/goal/resume [post] +func (h *Handlers) ResumeGoal(c echo.Context) error { + g, err := h.svc.ResumeGoal(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsGoalResponse{Goal: h.goalJSON(g)}) +} diff --git a/core/http/endpoints/studio/hostexec.go b/core/http/endpoints/studio/hostexec.go new file mode 100644 index 000000000..8c63d4505 --- /dev/null +++ b/core/http/endpoints/studio/hostexec.go @@ -0,0 +1,86 @@ +package studio + +import ( + "net/http" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/schema" +) + +// HostExecConsent answers the card that asks to run one command on this +// machine. +// @Summary Allow or refuse one command of the built-in bash on this machine. +// @Description When chats_host_exec is ask, each command of the chat's built-in bash waits for an answer: a host_exec_consent event carries the id and the command. once runs it (administrators only); deny refuses it (anyone who owns the chat). The command runs with no isolation, as the user LocalAI runs as, with the network of the server. Without an answer the call ends after 5 minutes. A card waits only while the turn that asked runs; an answer to one whose turn has ended is 410 consent_expired. +// @Tags chats +// @Param id path string true "conversation id" +// @Param request body schema.ChatsHostExecConsent true "the id of the event and the answer" +// @Success 204 "The answer was stored with the card and sent to the running turn" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Failure 403 {object} schema.ErrorResponse "Only an administrator can allow a command" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Failure 409 {object} schema.ErrorResponse "The card was answered already with the other answer (already_answered); the same answer again is 204" +// @Failure 410 {object} schema.ErrorResponse "The card expired (consent_expired): the turn that asked has ended, its frontend stopped, or a newer card replaced it; the command did not run" +// @Router /api/chats/{id}/host-exec/consent [post] +func (h *Handlers) HostExecConsent(c echo.Context) error { + var in schema.ChatsHostExecConsent + if err := c.Bind(&in); err != nil { + return fail(c, http.StatusBadRequest, typeInvalid, "the body must be {id, decision}") + } + if err := h.svc.HostExecDecide(c.Request().Context(), userID(c), c.Param("id"), in.ID, in.Decision); err != nil { + return failFor(c, err) + } + return c.NoContent(http.StatusNoContent) +} + +// AdminHostExecEvents returns the audit of the commands the built-in bash ran. +// @Summary Read the audit of the commands run on this machine (administrators). +// @Description Every command the chats' built-in bash ran directly on this machine: who, in which chat, the command (cut at 4 KiB), the exit code (-1 when it did not exit by itself), how long it took and whether it timed out. The commands had the network of the server, so this is also the record of what they could reach. +// @Tags chats +// @Param limit query int false "How many lines, newest first (default 100, at most 500)" +// @Success 200 {object} schema.ChatsHostExecEventList "The newest commands first" +// @Router /api/chats/admin/host-exec/events [get] +func (h *Handlers) AdminHostExecEvents(c echo.Context) error { + ctx := c.Request().Context() + evs, err := h.svc.HostExecEvents(ctx, intParam(c, "limit", 100, 500)) + if err != nil { + return failFor(c, err) + } + out := schema.ChatsHostExecEventList{Events: make([]schema.ChatsHostExecEvent, 0, len(evs))} + for _, e := range evs { + out.Events = append(out.Events, schema.ChatsHostExecEvent{User: e.User, UserName: h.svc.UserName(ctx, e.User), Chat: e.Chat, Command: e.Command, + Exit: e.Exit, DurationMS: e.DurationMS, TimedOut: e.TimedOut, At: e.At}) + } + return c.JSON(http.StatusOK, out) +} + +// AdminBuiltinTools lists the built-in chat tools for Settings > Tools. +// @Summary List the built-in chat tools (administrators). +// @Description The built-in tools of the chats (bash, web_search, web_fetch, as this server registers them): label, the per-chat switch group, the runtime setting that switches each off and the ones that configure it, whether it is on, whether it only reads and whether an add-on may replace it, who serves it now (builtin, an add-on's ID, or empty), the add-ons that replace it (oldest install first, with their install times in installed_at), the default one that serves with no pin (default: the oldest running install) and the one pinned with chats_tool_providers, the titles of the candidates, the candidates that are installed but stopped (stopped), a note "not running" when an installed add-on replaces the tool but none serves, and for bash the mode of chats_host_exec and why this server cannot run it, if so. skills lists the built-in skills (xlsx, docx, pptx, pdf) that an installed add-on replaces: who serves each now, the add-ons that replace it (the same order, default and installed_at) and the one pinned in the same chats_tool_providers map. Change them with POST /api/settings. +// @Tags chats +// @Success 200 {object} schema.ChatsBuiltinToolList "The built-in tools, by ID" +// @Router /api/chats/admin/tools [get] +func (h *Handlers) AdminBuiltinTools(c echo.Context) error { + list, err := h.svc.AdminBuiltinTools(c.Request().Context(), userID(c)) + if err != nil { + return failFor(c, err) + } + out := schema.ChatsBuiltinToolList{Tools: make([]schema.ChatsBuiltinTool, 0, len(list))} + for _, t := range list { + cands := t.Candidates + if cands == nil { + cands = []string{} + } + opts := t.Options + if opts == nil { + opts = []string{} + } + out.Tools = append(out.Tools, schema.ChatsBuiltinTool{ID: t.ID, Label: t.Label, Description: t.Description, Group: t.Group, Setting: t.Setting, + Options: opts, ReadOnly: t.ReadOnly, Replaceable: t.Replaceable, Enabled: t.Enabled, Provider: t.Provider, Candidates: cands, Default: t.Default, Pinned: t.Pinned, InstalledAt: t.InstalledAt, + Titles: t.Titles, Stopped: t.Stopped, Note: t.Note, Mode: t.Mode, Unavailable: t.Unavailable}) + } + out.Skills = []schema.ChatsBuiltinSkill{} + for _, sk := range h.svc.AdminBuiltinSkills(c.Request().Context(), userID(c)) { + out.Skills = append(out.Skills, schema.ChatsBuiltinSkill{ID: sk.ID, Provider: sk.Provider, Candidates: sk.Candidates, Default: sk.Default, Titles: sk.Titles, Pinned: sk.Pinned, InstalledAt: sk.InstalledAt, Note: sk.Note}) + } + return c.JSON(http.StatusOK, out) +} diff --git a/core/http/endpoints/studio/import.go b/core/http/endpoints/studio/import.go new file mode 100644 index 000000000..61d79aea8 --- /dev/null +++ b/core/http/endpoints/studio/import.go @@ -0,0 +1,41 @@ +package studio + +import ( + "errors" + "io" + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/studio/convert" +) + +// maxBrowserImportBytes caps the upload of browser chats: the browser keeps them +// in localStorage, which holds a few megabytes. +const maxBrowserImportBytes = 64 << 20 + +// ImportBrowserChats stores the chats the old chat page kept in the browser. +// @Summary Import the chats that the old chat page kept in your browser. +// @Description Send the value of the browser's localStorage key "localai_chats_data" as it is: {"chats": [...]}. Each chat with a creation date and a message becomes one of your chats, with its transcript. A chat that you have already, with the same title and creation time, is skipped, so the upload can be repeated. The import changes nothing in the browser; the page clears its copy when the answer says nothing failed. +// @Tags chats +// @Accept json +// @Produce json +// @Param request body object true "the stored value, {chats: [...]}" +// @Success 200 {object} schema.ChatsBrowserImport "What was imported" +// @Failure 400 {object} schema.ErrorResponse "The body is not the stored form" +// @Router /api/chats/import/browser [post] +func (h *Handlers) ImportBrowserChats(c echo.Context) error { + body, err := io.ReadAll(http.MaxBytesReader(c.Response(), c.Request().Body, maxBrowserImportBytes)) + if err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "the body could not be read") + } + res, err := h.svc.ImportBrowserChats(c.Request().Context(), userID(c), body) + if errors.Is(err, convert.ErrBrowserForm) { + return fail(c, http.StatusBadRequest, "invalid_request_error", "the body is not the chat store of the old page") + } + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsBrowserImport{Imported: res.Imported, Skipped: res.Skipped, Failed: res.Failed}) +} diff --git a/core/http/endpoints/studio/jobs.go b/core/http/endpoints/studio/jobs.go new file mode 100644 index 000000000..62b9c7310 --- /dev/null +++ b/core/http/endpoints/studio/jobs.go @@ -0,0 +1,525 @@ +package studio + +import ( + "errors" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/labstack/echo/v4" + "github.com/mudler/xlog" + + "github.com/mudler/LocalAI/core/http/auth" + "github.com/mudler/LocalAI/core/schema" + studiosvc "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/agents" + "github.com/mudler/LocalAI/core/services/studio/files" + "github.com/mudler/LocalAI/core/services/studio/jobs" +) + +// The agent tasks and jobs API keeps the paths and the JSON of the one the old +// agent pool had, so a script that called it keeps working. A task is a prompt +// template with the model or saved agent that runs it; a job is a chat that runs +// in the background, as the task's owner, with the same loop as every chat. +// Errors use the old {"error": "..."} body. + +func jobFail(c echo.Context, status int, msg string) error { + return c.JSON(status, map[string]string{"error": msg}) +} + +func (h *Handlers) jobHost(c echo.Context) (*studiosvc.Jobs, bool) { + return feature(c, h.svc.Jobs(), func(c echo.Context) error { return jobFail(c, http.StatusConflict, studiosvc.ErrJobsOff.Error()) }) +} + +// failJob maps the errors of the job host. +func failJob(c echo.Context, err error) error { + switch { + case errors.Is(err, jobs.ErrNotFound): + return jobFail(c, http.StatusNotFound, "not found") + case errors.Is(err, agents.ErrNotFound): + return jobFail(c, http.StatusBadRequest, "agent not found") + case errors.Is(err, jobs.ErrInvalid): + return jobFail(c, http.StatusBadRequest, strings.TrimPrefix(err.Error(), jobs.ErrInvalid.Error()+": ")) + case errors.Is(err, studiosvc.ErrBadJobInput): + return jobFail(c, http.StatusBadRequest, strings.TrimPrefix(err.Error(), studiosvc.ErrBadJobInput.Error()+": ")) + case errors.Is(err, studiosvc.ErrTaskDisabled): + return jobFail(c, http.StatusBadRequest, "the task is switched off") + case errors.Is(err, jobs.ErrNameTaken), errors.Is(err, studiosvc.ErrTooManyTasks), errors.Is(err, studiosvc.ErrJobEnded): + return jobFail(c, http.StatusConflict, strings.TrimPrefix(err.Error(), "studio: ")) + case errors.Is(err, studiosvc.ErrTooManyJobs): + return jobFail(c, http.StatusTooManyRequests, strings.TrimPrefix(err.Error(), "studio: ")) + case errors.Is(err, files.ErrUnavailable): + return jobFail(c, http.StatusConflict, err.Error()) + case errors.Is(err, studiosvc.ErrNoModel): + return jobFail(c, http.StatusBadRequest, err.Error()) + } + xlog.Error("studio: agent job request failed", "path", c.Request().URL.Path, "error", err) + return jobFail(c, http.StatusInternalServerError, "the request failed") +} + +func taskOut(t jobs.Task) schema.Task { + enabled := t.Enabled + out := schema.Task{ID: t.ID, Name: t.Name, Description: t.Description, Model: t.Model, AgentID: t.AgentID, Prompt: t.Prompt, + CreatedAt: t.Created, UpdatedAt: t.Updated, Enabled: &enabled} + for _, w := range t.Webhooks { + hidden := make(map[string]string, len(w.Headers)) + for k := range w.Headers { + hidden[k] = studiosvc.HiddenHeader + } + if len(hidden) == 0 { + hidden = nil + } + out.Webhooks = append(out.Webhooks, schema.WebhookConfig{URL: w.URL, Method: w.Method, Headers: hidden, PayloadTemplate: w.PayloadTemplate}) + } + return out +} + +func taskIn(t schema.Task) (studiosvc.TaskInput, error) { + if t.Cron != "" || len(t.CronParameters) > 0 || len(t.MultimediaSources) > 0 { + return studiosvc.TaskInput{}, fmt.Errorf("%w: cron, cron_parameters and multimedia_sources are not supported: schedule a saved agent with a trigger (/api/chats/triggers)", jobs.ErrInvalid) + } + in := studiosvc.TaskInput{Name: t.Name, Description: t.Description, Model: t.Model, AgentID: t.AgentID, Prompt: t.Prompt, Enabled: t.Enabled} + for _, w := range t.Webhooks { + in.Webhooks = append(in.Webhooks, jobs.Webhook{URL: w.URL, Method: strings.ToUpper(w.Method), Headers: w.Headers, PayloadTemplate: w.PayloadTemplate}) + } + return in, nil +} + +func jobOut(i studiosvc.JobInfo) schema.Job { + out := schema.Job{ID: i.ID, TaskID: i.TaskID, Status: schema.JobStatus(i.Status), Parameters: i.Parameters, Result: i.Result, Error: i.Error, + CreatedAt: i.Created.UTC(), TriggeredBy: i.TriggeredBy, WebhookSent: i.WebhookSent, WebhookError: i.WebhookError, Attachments: i.Attachments} + if !i.StartedAt.IsZero() { + t := i.StartedAt.UTC() + out.StartedAt = &t + } + if !i.CompletedAt.IsZero() { + t := i.CompletedAt.UTC() + out.CompletedAt = &t + } + if !i.WebhookSentAt.IsZero() { + t := i.WebhookSentAt.UTC() + out.WebhookSentAt = &t + } + if out.Parameters == nil { + out.Parameters = map[string]string{} + } + return out +} + +// refuseTaskModel answers the request and returns true when the model does not +// exist or the user may not use it. +func (h *Handlers) refuseTaskModel(c echo.Context, model string) bool { + if h.models == nil || model == "" { + return false + } + switch err := h.models.Check(c.Request().Context(), auth.GetUser(c), model); { + case errors.Is(err, ErrUnknownModel): + _ = jobFail(c, http.StatusBadRequest, "unknown model") + case errors.Is(err, ErrModelNotAllowed): + _ = jobFail(c, http.StatusForbidden, "you may not use this model") + case err != nil: + _ = failJob(c, err) + default: + return false + } + return true +} + +// CreateTask creates an agent task. +// @Summary Create an agent task. +// @Description A task is a prompt template (Go template syntax, each parameter written as .name between double curly braces) run by a model, or by a saved agent (agent_id), as a background chat. webhooks are called when a job of the task ends; header values are never returned. cron, cron_parameters and multimedia_sources are refused: schedule a saved agent with a trigger. Needs the mcp_jobs feature. +// @Tags agent-jobs +// @Accept json +// @Produce json +// @Param request body schema.Task true "Task definition" +// @Success 201 {object} map[string]string "id" +// @Failure 400 {object} map[string]string "error" +// @Router /api/agent/tasks [post] +func (h *Handlers) CreateTask(c echo.Context) error { + host, ok := h.jobHost(c) + if !ok { + return nil + } + var task schema.Task + if err := c.Bind(&task); err != nil { + return jobFail(c, http.StatusBadRequest, "Invalid request body: "+err.Error()) + } + in, err := taskIn(task) + if err != nil { + return failJob(c, err) + } + if h.refuseTaskModel(c, in.Model) { + return nil + } + t, err := host.CreateTask(c.Request().Context(), userID(c), in) + if err != nil { + return failJob(c, err) + } + return c.JSON(http.StatusCreated, map[string]string{"id": t.ID}) +} + +// UpdateTask replaces an agent task. +// @Summary Update an agent task. +// @Description Replaces every field. A webhook header sent back as "[hidden]" keeps its stored value. +// @Tags agent-jobs +// @Accept json +// @Produce json +// @Param id path string true "Task ID" +// @Param request body schema.Task true "Updated task definition" +// @Success 200 {object} map[string]string "message" +// @Failure 400 {object} map[string]string "error" +// @Failure 404 {object} map[string]string "error" +// @Router /api/agent/tasks/{id} [put] +func (h *Handlers) UpdateTask(c echo.Context) error { + host, ok := h.jobHost(c) + if !ok { + return nil + } + var task schema.Task + if err := c.Bind(&task); err != nil { + return jobFail(c, http.StatusBadRequest, "Invalid request body: "+err.Error()) + } + in, err := taskIn(task) + if err != nil { + return failJob(c, err) + } + if h.refuseTaskModel(c, in.Model) { + return nil + } + if _, err := host.UpdateTask(c.Request().Context(), userID(c), c.Param("id"), in); err != nil { + return failJob(c, err) + } + return c.JSON(http.StatusOK, map[string]string{"message": "Task updated"}) +} + +// DeleteTask deletes an agent task. +// @Summary Delete an agent task. +// @Description The jobs of the task stay until their retention passes. +// @Tags agent-jobs +// @Produce json +// @Param id path string true "Task ID" +// @Success 200 {object} map[string]string "message" +// @Failure 404 {object} map[string]string "error" +// @Router /api/agent/tasks/{id} [delete] +func (h *Handlers) DeleteTask(c echo.Context) error { + host, ok := h.jobHost(c) + if !ok { + return nil + } + if err := host.DeleteTask(c.Request().Context(), userID(c), c.Param("id")); err != nil { + return failJob(c, err) + } + return c.JSON(http.StatusOK, map[string]string{"message": "Task deleted"}) +} + +// wantsAllUsers is true when an administrator asks for every user's items. +func wantsAllUsers(c echo.Context) bool { + u := auth.GetUser(c) + return c.QueryParam("all_users") == "true" && (u == nil || u.Role == auth.RoleAdmin) +} + +// ListTasks lists agent tasks. +// @Summary List agent tasks. +// @Description With all_users=true an administrator also gets the tasks of the other users, read-only, in user_groups keyed by owner. +// @Tags agent-jobs +// @Produce json +// @Param all_users query string false "Set to 'true' for the administrator's cross-user listing" +// @Success 200 {object} []schema.Task "tasks" +// @Router /api/agent/tasks [get] +func (h *Handlers) ListTasks(c echo.Context) error { + host, ok := h.jobHost(c) + if !ok { + return nil + } + ctx := c.Request().Context() + me := userID(c) + mine, err := host.ListTasks(ctx, me) + if err != nil { + return failJob(c, err) + } + tasks := make([]schema.Task, 0, len(mine)) + for _, t := range mine { + tasks = append(tasks, taskOut(t)) + } + if !wantsAllUsers(c) { + return c.JSON(http.StatusOK, tasks) + } + all, err := host.AdminTasks(ctx) + if err != nil { + return failJob(c, err) + } + groups := map[string]any{} + byOwner := map[string][]schema.Task{} + for _, t := range all { + if t.Owner != h.svc.Principal(me) { + byOwner[t.Owner] = append(byOwner[t.Owner], taskOut(t)) + } + } + for owner, list := range byOwner { + groups[owner] = map[string]any{"tasks": list} + } + if len(groups) == 0 { + return c.JSON(http.StatusOK, tasks) + } + return c.JSON(http.StatusOK, map[string]any{"tasks": tasks, "user_groups": groups}) +} + +// GetTask returns one agent task. +// @Summary Get an agent task. +// @Tags agent-jobs +// @Produce json +// @Param id path string true "Task ID" +// @Success 200 {object} schema.Task "task" +// @Failure 404 {object} map[string]string "error" +// @Router /api/agent/tasks/{id} [get] +func (h *Handlers) GetTask(c echo.Context) error { + host, ok := h.jobHost(c) + if !ok { + return nil + } + t, err := host.GetTask(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failJob(c, err) + } + return c.JSON(http.StatusOK, taskOut(t)) +} + +func attachments(req schema.JobExecutionRequest) []studiosvc.Attachment { + var out []studiosvc.Attachment + for kind, list := range map[string][]string{"image": req.Images, "video": req.Videos, "audio": req.Audios, "file": req.Files} { + for _, d := range list { + out = append(out, studiosvc.Attachment{Kind: kind, Data: d}) + } + } + return out +} + +func jobResponse(c echo.Context, i studiosvc.JobInfo) error { + return c.JSON(http.StatusCreated, schema.JobExecutionResponse{ + JobID: i.ID, Status: "pending", URL: c.Scheme() + "://" + c.Request().Host + "/api/agent/jobs/" + i.ID, + }) +} + +// ExecuteJob starts a job for a task. +// @Summary Execute an agent job. +// @Description Starts a job for the task as a background chat of the caller and answers at once; poll the job for its status and result. parameters fill the prompt template. images, videos, audios and files are base64 data URIs (an address is not fetched). +// @Tags agent-jobs +// @Accept json +// @Produce json +// @Param request body schema.JobExecutionRequest true "Job execution request" +// @Success 201 {object} schema.JobExecutionResponse "job" +// @Failure 400 {object} map[string]string "error" +// @Router /api/agent/jobs/execute [post] +func (h *Handlers) ExecuteJob(c echo.Context) error { + host, ok := h.jobHost(c) + if !ok { + return nil + } + var req schema.JobExecutionRequest + if err := c.Bind(&req); err != nil { + return jobFail(c, http.StatusBadRequest, "Invalid request body: "+err.Error()) + } + info, err := host.Execute(c.Request().Context(), userID(c), req.TaskID, + studiosvc.RunRequest{Params: req.Parameters, Attachments: attachments(req), TriggeredBy: "api"}) + if err != nil { + return failJob(c, err) + } + return jobResponse(c, info) +} + +// ExecuteTaskByName starts a job for the task with this name. +// @Summary Execute an agent task by name. +// @Description The body is an object of parameters for the prompt template. +// @Tags agent-jobs +// @Accept json +// @Produce json +// @Param name path string true "Task name" +// @Param parameters body object false "Optional template parameters" +// @Success 201 {object} schema.JobExecutionResponse "job" +// @Failure 404 {object} map[string]string "error" +// @Router /api/agent/tasks/{name}/execute [post] +func (h *Handlers) ExecuteTaskByName(c echo.Context) error { + host, ok := h.jobHost(c) + if !ok { + return nil + } + ctx := c.Request().Context() + params := map[string]string{} + if c.Request().ContentLength != 0 { + body := map[string]any{} + if err := c.Bind(&body); err != nil { + return jobFail(c, http.StatusBadRequest, "Invalid request body: "+err.Error()) + } + for k, v := range body { + if s, isStr := v.(string); isStr { + params[k] = s + } else { + params[k] = fmt.Sprintf("%v", v) + } + } + } + task, err := host.FindTask(ctx, userID(c), c.Param("name")) + if err != nil { + if errors.Is(err, jobs.ErrNotFound) { + return jobFail(c, http.StatusNotFound, "Task not found: "+c.Param("name")) + } + return failJob(c, err) + } + info, err := host.Execute(ctx, userID(c), task.ID, studiosvc.RunRequest{Params: params, TriggeredBy: "api"}) + if err != nil { + return failJob(c, err) + } + return jobResponse(c, info) +} + +// GetJob returns one job. +// @Summary Get an agent job. +// @Description status is pending, running, completed, failed or cancelled; result is the last answer of the chat; traces are the reasoning and tool results. An administrator can read any user's job. +// @Tags agent-jobs +// @Produce json +// @Param id path string true "Job ID" +// @Success 200 {object} schema.Job "job" +// @Failure 404 {object} map[string]string "error" +// @Router /api/agent/jobs/{id} [get] +func (h *Handlers) GetJob(c echo.Context) error { + host, ok := h.jobHost(c) + if !ok { + return nil + } + ctx := c.Request().Context() + info, err := host.GetJob(ctx, userID(c), c.Param("id")) + if errors.Is(err, jobs.ErrNotFound) && wantsAdminRead(c) { + info, err = host.AdminJob(ctx, c.Param("id")) + } + if err != nil { + return failJob(c, err) + } + out := jobOut(info) + traces, err := host.Traces(ctx, info.Owner, info.ID) + if err != nil { + return failJob(c, err) + } + for _, t := range traces { + out.Traces = append(out.Traces, schema.JobTrace{Type: t.Type, Content: t.Content, Timestamp: t.At}) + } + return c.JSON(http.StatusOK, out) +} + +// wantsAdminRead is true for an administrator, who may read any user's job. +func wantsAdminRead(c echo.Context) bool { + u := auth.GetUser(c) + return u != nil && u.Role == auth.RoleAdmin +} + +// ListJobs lists agent jobs. +// @Summary List agent jobs. +// @Description Newest first. With all_users=true an administrator also gets the jobs of the other users, read-only, in user_groups keyed by owner. +// @Tags agent-jobs +// @Produce json +// @Param task_id query string false "Filter by task ID" +// @Param status query string false "Filter by status (pending, running, completed, failed, cancelled)" +// @Param limit query integer false "Max number of jobs to return" +// @Param all_users query string false "Set to 'true' for the administrator's cross-user listing" +// @Success 200 {object} []schema.Job "jobs" +// @Router /api/agent/jobs [get] +func (h *Handlers) ListJobs(c echo.Context) error { + host, ok := h.jobHost(c) + if !ok { + return nil + } + ctx := c.Request().Context() + limit, _ := strconv.Atoi(c.QueryParam("limit")) + q := studiosvc.JobQuery{TaskID: c.QueryParam("task_id"), Status: studiosvc.JobStatus(c.QueryParam("status")), Limit: limit} + me := userID(c) + mine, err := host.ListJobs(ctx, me, q) + if err != nil { + return failJob(c, err) + } + list := make([]schema.Job, 0, len(mine)) + for _, j := range mine { + list = append(list, jobOut(j)) + } + if !wantsAllUsers(c) { + return c.JSON(http.StatusOK, list) + } + all, err := host.AdminJobs(ctx, q) + if err != nil { + return failJob(c, err) + } + byOwner := map[string][]schema.Job{} + for _, j := range all { + if j.Owner != h.svc.Principal(me) { + byOwner[j.Owner] = append(byOwner[j.Owner], jobOut(j)) + } + } + if len(byOwner) == 0 { + return c.JSON(http.StatusOK, list) + } + groups := map[string]any{} + for owner, l := range byOwner { + groups[owner] = map[string]any{"jobs": l} + } + return c.JSON(http.StatusOK, map[string]any{"jobs": list, "user_groups": groups}) +} + +// CancelJob stops a running job. +// @Summary Cancel an agent job. +// @Tags agent-jobs +// @Produce json +// @Param id path string true "Job ID" +// @Success 200 {object} map[string]string "message" +// @Failure 404 {object} map[string]string "error" +// @Failure 409 {object} map[string]string "error" +// @Router /api/agent/jobs/{id}/cancel [post] +func (h *Handlers) CancelJob(c echo.Context) error { + host, ok := h.jobHost(c) + if !ok { + return nil + } + if err := host.CancelJob(c.Request().Context(), userID(c), c.Param("id")); err != nil { + return failJob(c, err) + } + return c.JSON(http.StatusOK, map[string]string{"message": "Job cancelled"}) +} + +// RerunJob starts a new job with the task and parameters of an earlier one. +// @Summary Run an agent job again. +// @Description Starts a new job with the same task and parameters. Attachments are not kept, so they are not sent again. +// @Tags agent-jobs +// @Produce json +// @Param id path string true "Job ID" +// @Success 201 {object} schema.JobExecutionResponse "job" +// @Failure 404 {object} map[string]string "error" +// @Router /api/agent/jobs/{id}/rerun [post] +func (h *Handlers) RerunJob(c echo.Context) error { + host, ok := h.jobHost(c) + if !ok { + return nil + } + info, err := host.Rerun(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failJob(c, err) + } + return jobResponse(c, info) +} + +// DeleteJob deletes a job and its chat. +// @Summary Delete an agent job. +// @Description A running job is stopped first. +// @Tags agent-jobs +// @Produce json +// @Param id path string true "Job ID" +// @Success 200 {object} map[string]string "message" +// @Failure 404 {object} map[string]string "error" +// @Router /api/agent/jobs/{id} [delete] +func (h *Handlers) DeleteJob(c echo.Context) error { + host, ok := h.jobHost(c) + if !ok { + return nil + } + if err := host.DeleteJob(c.Request().Context(), userID(c), c.Param("id")); err != nil { + return failJob(c, err) + } + return c.JSON(http.StatusOK, map[string]string{"message": "Job deleted"}) +} diff --git a/core/http/endpoints/studio/loops.go b/core/http/endpoints/studio/loops.go new file mode 100644 index 000000000..c5803e5d7 --- /dev/null +++ b/core/http/endpoints/studio/loops.go @@ -0,0 +1,162 @@ +package studio + +import ( + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" + studiosvc "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/loops" +) + +const typeLoopsOff = "loops_off" + +// loopRules maps the errors of the loop host; the rest go to chatRules. +var loopRules = []errRule{ + fixed(http.StatusNotFound, typeNotFound, "loop not found", loops.ErrNotFound), + fixed(http.StatusConflict, typeConflict, "this chat has as many loops as it may; stop one first", loops.ErrTooManyInChat), + fixed(http.StatusConflict, typeConflict, "you have as many loops as you may; stop one first", loops.ErrTooManyForOwner), + sentinelText(http.StatusBadRequest, typeInvalid, loops.ErrInvalid), + verbatim(http.StatusBadRequest, typeInvalid, "", studiosvc.ErrLoopCommand, studiosvc.ErrLoopChat, studiosvc.ErrLoopState), + fixed(http.StatusNotFound, typeLoopsOff, "loops are turned off on this server", studiosvc.ErrLoopsOff), + fixed(http.StatusServiceUnavailable, typeServerError, "the server is shutting down", studiosvc.ErrLoopsClosed), +} + +func failLoop(c echo.Context, err error) error { return mapErr(c, err, loopRules, failFor) } + +func (h *Handlers) loopHost(c echo.Context) (*studiosvc.Loops, bool) { + return feature(c, h.svc.Loops(), func(c echo.Context) error { return failLoop(c, studiosvc.ErrLoopsOff) }) +} + +// loopJSON is a loop as the API gives it. +func loopJSON(l loops.Loop) schema.ChatsLoop { + out := schema.ChatsLoop{ID: l.ID, Prompt: l.Prompt, IntervalSeconds: int64(l.Interval.Seconds()), State: string(l.State), + Reason: l.Reason, Runs: l.Runs, Failures: l.Failures, Next: utcPtr(l.Next), Running: l.Running, Wait: l.Wait, + Created: l.Created.UTC(), LastRun: utcPtr(l.LastRun)} + if l.Interval > 0 { + out.Interval = loops.ShortDuration(l.Interval) + } + return out +} + +func loopsJSON(list []loops.Loop) []schema.ChatsLoop { + out := make([]schema.ChatsLoop, 0, len(list)) + for _, l := range list { + out = append(out, loopJSON(l)) + } + return out +} + +// ListLoops lists the loops of a chat. +// @Summary List the loops of a chat. +// @Description A loop sends a prompt to its chat again and again, as a new message, at an interval or, when it has none, whenever the model asks for the next run. Ended loops stay listed with the reason until you remove them. +// @Tags chats +// @Param id path string true "conversation id" +// @Success 200 {object} schema.ChatsLoopList "The loops of the chat, oldest first" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or loops are off" +// @Router /api/chats/{id}/loops [get] +func (h *Handlers) ListLoops(c echo.Context) error { + host, ok := h.loopHost(c) + if !ok { + return nil + } + list, err := host.List(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failLoop(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsLoopList{Loops: loopsJSON(list)}) +} + +// StartLoop starts a loop in a chat. +// @Summary Start a loop in a chat. +// @Description With an interval (30s, 5m, 2h; at least chats_loop_min_interval, at most 24h) the prompt is sent every interval. Without one the loop is self-paced: after each run the model calls schedule_next to say when to run again, or loop_done to stop, and a run that does neither ends the loop. The prompt is plain text or a skill (/skill-name and its words). The first loop of a chat is pending until you confirm it with PUT .../loops/{loopId} {"state":"active"}; later ones run at once. A run starts only when the chat is idle. +// @Tags chats +// @Param id path string true "conversation id" +// @Param request body schema.ChatsLoopRequest true "the interval and the prompt" +// @Success 201 {object} schema.ChatsLoop "The new loop" +// @Failure 400 {object} schema.ErrorResponse "The interval or the prompt is not valid" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or loops are off" +// @Failure 409 {object} schema.ErrorResponse "The chat or you have as many loops as allowed" +// @Router /api/chats/{id}/loops [post] +func (h *Handlers) StartLoop(c echo.Context) error { + host, ok := h.loopHost(c) + if !ok { + return nil + } + var req schema.ChatsLoopRequest + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, typeInvalid, msgNotJSON) + } + l, err := host.Start(c.Request().Context(), userID(c), c.Param("id"), studiosvc.LoopInput{Interval: req.Interval, Prompt: req.Prompt}) + if err != nil { + return failLoop(c, err) + } + return c.JSON(http.StatusCreated, loopJSON(l)) +} + +// SetLoopState pauses a loop or makes it active. +// @Summary Pause, resume or confirm a loop. +// @Description state paused stops the runs until you make it active again; a run that goes on finishes. state active resumes a paused loop (it runs at once) or confirms a pending one. An ended loop cannot go on. +// @Tags chats +// @Param id path string true "conversation id" +// @Param loopId path string true "loop id" +// @Param request body schema.ChatsLoopStateRequest true "the new state" +// @Success 200 {object} schema.ChatsLoop "The loop" +// @Failure 400 {object} schema.ErrorResponse "The state is not paused or active, or the loop has ended" +// @Failure 404 {object} schema.ErrorResponse "No such conversation or loop" +// @Router /api/chats/{id}/loops/{loopId} [put] +func (h *Handlers) SetLoopState(c echo.Context) error { + host, ok := h.loopHost(c) + if !ok { + return nil + } + var req schema.ChatsLoopStateRequest + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, typeInvalid, msgNotJSON) + } + l, err := host.SetState(c.Request().Context(), userID(c), c.Param("id"), c.Param("loopId"), loops.State(req.State)) + if err != nil { + return failLoop(c, err) + } + return c.JSON(http.StatusOK, loopJSON(l)) +} + +// StopLoop stops one loop of a chat for good. +// @Summary Stop a loop. +// @Description The loop is removed and runs no more. A run that goes on is not stopped: stop the chat's turn for that. +// @Tags chats +// @Param id path string true "conversation id" +// @Param loopId path string true "loop id" +// @Success 200 {object} map[string]bool "stopped" +// @Failure 404 {object} schema.ErrorResponse "No such conversation or loop" +// @Router /api/chats/{id}/loops/{loopId} [delete] +func (h *Handlers) StopLoop(c echo.Context) error { + host, ok := h.loopHost(c) + if !ok { + return nil + } + if err := host.Remove(c.Request().Context(), userID(c), c.Param("id"), c.Param("loopId")); err != nil { + return failLoop(c, err) + } + return c.JSON(http.StatusOK, map[string]bool{"stopped": true}) +} + +// StopLoops stops every loop of a chat. +// @Summary Stop every loop of a chat. +// @Tags chats +// @Param id path string true "conversation id" +// @Success 200 {object} map[string]int "the number of loops stopped" +// @Failure 404 {object} schema.ErrorResponse "No such conversation" +// @Router /api/chats/{id}/loops [delete] +func (h *Handlers) StopLoops(c echo.Context) error { + host, ok := h.loopHost(c) + if !ok { + return nil + } + n, err := host.RemoveAll(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failLoop(c, err) + } + return c.JSON(http.StatusOK, map[string]int{"stopped": n}) +} diff --git a/core/http/endpoints/studio/mcpapps.go b/core/http/endpoints/studio/mcpapps.go new file mode 100644 index 000000000..c49cef136 --- /dev/null +++ b/core/http/endpoints/studio/mcpapps.go @@ -0,0 +1,97 @@ +package studio + +import ( + "encoding/json" + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" + studiosvc "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/session" +) + +// maxAppCallBody bounds the body of a tool call from an app: the arguments are +// capped at 16 KiB, the rest is the names. +const maxAppCallBody = 32 << 10 + +// appRules map the error of an app request. A refusal and a failure of the +// server carry text that holds no secret. +var appRules = []errRule{ + sentinelText(http.StatusForbidden, "mcp_app_refused", studiosvc.ErrMCPAppRefused), + fixed(http.StatusConflict, "mcp_app_unsupported", "MCP apps are not available for this server", studiosvc.ErrMCPAppUnsupported), + sentinelText(http.StatusBadRequest, typeInvalid, studiosvc.ErrBadMCPAppRequest), + sentinelText(http.StatusBadGateway, "mcp_app_failed", studiosvc.ErrMCPAppFailed), +} + +func failApp(c echo.Context, err error) error { return mapErr(c, err, appRules, failFor) } + +// GetMCPAppResource reads the page of an MCP App. +// @Summary Read the page of an MCP App. +// @Description An MCP server can declare an interactive page for a tool (_meta.ui.resourceUri). The tool_end event and the transcript line of the call name the server and the resource. This route reads that page from the server, for the interface to show in a sandboxed frame. server is connector: for one of your connectors, or the name of an MCP server of the chat's model. It answers only for a server the chat may use now (not switched off for the chat, granted by the chat's agent if it has one, a connector that is yours and enabled), only for a ui:// resource that a tool of that server declares, and only for text/html up to 4 MiB; anything else is 403. Needs the MCP feature. The answer is not cached and never holds a header or token of the connector. +// @Tags chats +// @Param id path string true "conversation id" +// @Param server query string true "connector: or the name of an MCP server of the model" +// @Param uri query string true "the ui:// resource" +// @Success 200 {object} schema.ChatsMCPAppResource "The page and its metadata" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Router /api/chats/{id}/mcp-apps/resource [get] +func (h *Handlers) GetMCPAppResource(c echo.Context) error { + server, uri := c.QueryParam("server"), c.QueryParam("uri") + if server == "" || uri == "" { + return fail(c, http.StatusBadRequest, "invalid_request_error", "server and uri are required") + } + res, err := h.svc.MCPAppResource(c.Request().Context(), userID(c), c.Param("id"), server, uri) + if err != nil { + return failApp(c, err) + } + raw, _ := json.Marshal(res.Meta) + meta := map[string]any{} + _ = json.Unmarshal(raw, &meta) + c.Response().Header().Set("Cache-Control", "no-store") + return c.JSON(http.StatusOK, schema.ChatsMCPAppResource{URI: res.URI, MimeType: res.MIMEType, HTML: res.HTML, Meta: meta}) +} + +// CallMCPAppTool runs a tool of an MCP server for its app. +// @Summary Call a tool for an MCP App. +// @Description The page of an MCP App can call tools of its own server. The interface passes the call here. The tool must belong to the server named, the chat must be able to use that server now (the same checks as the resource route), and the tool's _meta.ui.visibility must include "app" (the default includes it). A tool in the add-on namespace or with a built-in's name is refused. The call has the timeout of a model's call and its result is cut at 256 KiB. Needs the MCP feature. +// @Tags chats +// @Param id path string true "conversation id" +// @Param request body schema.ChatsMCPAppCall true "the call" +// @Success 200 {object} schema.ChatsMCPAppCallResult "The MCP result" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Failure 413 {object} schema.ErrorResponse "The body is over 32 KiB" +// @Router /api/chats/{id}/mcp-apps/call [post] +func (h *Handlers) CallMCPAppTool(c echo.Context) error { + c.Request().Body = http.MaxBytesReader(c.Response(), c.Request().Body, maxAppCallBody) + req, ok := bind[schema.ChatsMCPAppCall](c, "the body is not valid JSON") + if !ok { + return nil + } + if req.Server == "" || req.Tool == "" { + return fail(c, http.StatusBadRequest, "invalid_request_error", "server and tool are required") + } + out, err := h.svc.MCPAppCall(c.Request().Context(), userID(c), c.Param("id"), req.Server, req.Tool, req.Arguments) + if err != nil { + return failApp(c, err) + } + c.Response().Header().Set("Cache-Control", "no-store") + return c.JSON(http.StatusOK, schema.ChatsMCPAppCallResult{Result: out}) +} + +// appJSON converts the app of a card. With payload it keeps the input and the +// result of the call; a live event has the reference only. +func appJSON(a *session.ToolApp, payload bool) *schema.ChatsToolApp { + if a == nil { + return nil + } + vis := a.Visibility + if vis == nil { + vis = []string{} + } + out := &schema.ChatsToolApp{Server: a.Server, Tool: a.Tool, ResourceURI: a.ResourceURI, Visibility: vis, Cut: a.Cut} + if payload { + out.Input, out.Result = a.Input, a.Result + } + return out +} diff --git a/core/http/endpoints/studio/mcpconnectors.go b/core/http/endpoints/studio/mcpconnectors.go new file mode 100644 index 000000000..b0865f8b0 --- /dev/null +++ b/core/http/endpoints/studio/mcpconnectors.go @@ -0,0 +1,291 @@ +package studio + +import ( + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" + studiosvc "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/mcpconns" +) + +func (h *Handlers) mcpHost(c echo.Context) (*mcpconns.Service, bool) { + return feature(c, h.svc.MCPConnectors(), func(c echo.Context) error { + return fail(c, http.StatusConflict, "mcp_connectors_off", "MCP connectors are not available") + }) +} + +var mcpRules = []errRule{ + fixed(http.StatusNotFound, typeNotFound, "connector not found", mcpconns.ErrNotFound), + verbatim(http.StatusForbidden, typeAuthz, mcpconns.ErrForbidden.Error()+": ", mcpconns.ErrForbidden), + verbatim(http.StatusBadRequest, typeInvalid, mcpconns.ErrInvalid.Error()+": ", mcpconns.ErrInvalid), +} + +func failMCP(c echo.Context, err error) error { return mapErr(c, err, mcpRules, failFor) } + +func mcpOut(m mcpconns.Connector) schema.ChatsMCPConnector { + args := m.Args + if args == nil { + args = []string{} + } + return schema.ChatsMCPConnector{ID: m.ID, Name: m.Name, Transport: m.Transport, URL: m.URL, Command: m.Command, Args: args, + HasToken: m.HasToken, HasHeaders: m.HasHeaders, HasEnv: m.HasEnv, Enabled: m.Enabled, Created: m.Created, Updated: m.Updated} +} + +func mcpIn(r schema.ChatsMCPConnectorWrite) mcpconns.Input { + return mcpconns.Input{Name: r.Name, Transport: r.Transport, URL: r.URL, Command: r.Command, Args: r.Args, + Token: r.Token, Headers: r.Headers, Env: r.Env, Enabled: r.Enabled} +} + +// ListMCPConnectors lists your MCP connectors. +// @Summary List your MCP connectors. +// @Description An MCP connector is an MCP server that you defined. Your chats use the enabled ones next to the MCP servers of the model, and a saved agent can name one as connector:. Tokens, headers and the environment of a local command are never returned. Needs the MCP feature. +// @Tags chats +// @Success 200 {object} schema.ChatsMCPConnectorList "Your connectors, sorted by name" +// @Router /api/chats/mcp-connectors [get] +func (h *Handlers) ListMCPConnectors(c echo.Context) error { + host, ok := h.mcpHost(c) + if !ok { + return nil + } + list, err := host.List(c.Request().Context(), userID(c)) + if err != nil { + return failMCP(c, err) + } + out := schema.ChatsMCPConnectorList{Connectors: make([]schema.ChatsMCPConnector, 0, len(list))} + for _, m := range list { + out.Connectors = append(out.Connectors, mcpOut(m)) + } + return c.JSON(http.StatusOK, out) +} + +// CreateMCPConnector creates an MCP connector. +// @Summary Create an MCP connector. +// @Description transport is http (a remote server, streamable HTTP with a fall back to SSE) or stdio (a command run on the host, administrators only and refused in distributed mode). The address of an http connector must be http or https; an address on a private network is refused unless an administrator allows it (the mcp_connectors_allow_private setting), and the check is made again when the connection is opened. token, headers and env are sealed at rest and never returned. At most 20 connectors; names are unique per user. OAuth for remote servers is not supported. +// @Tags chats +// @Param request body schema.ChatsMCPConnectorWrite true "the connector" +// @Success 201 {object} schema.ChatsMCPConnector "The connector" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Router /api/chats/mcp-connectors [post] +func (h *Handlers) CreateMCPConnector(c echo.Context) error { + host, ok := h.mcpHost(c) + if !ok { + return nil + } + var req schema.ChatsMCPConnectorWrite + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "the body is not valid JSON") + } + m, err := host.Create(c.Request().Context(), userID(c), mcpIn(req)) + if err != nil { + return failMCP(c, err) + } + return c.JSON(http.StatusCreated, mcpOut(m)) +} + +// GetMCPConnector reads an MCP connector. +// @Summary Read an MCP connector. +// @Tags chats +// @Param id path string true "the connector's id" +// @Success 200 {object} schema.ChatsMCPConnector "The connector" +// @Router /api/chats/mcp-connectors/{id} [get] +func (h *Handlers) GetMCPConnector(c echo.Context) error { + host, ok := h.mcpHost(c) + if !ok { + return nil + } + m, err := host.Get(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failMCP(c, err) + } + return c.JSON(http.StatusOK, mcpOut(m)) +} + +// UpdateMCPConnector replaces an MCP connector. +// @Summary Replace an MCP connector. +// @Description The body replaces every field. token, headers and env keep the stored value when left out, and are cleared by an empty one. The open session of the connector is closed. +// @Tags chats +// @Param id path string true "the connector's id" +// @Param request body schema.ChatsMCPConnectorWrite true "the connector" +// @Success 200 {object} schema.ChatsMCPConnector "The connector" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Router /api/chats/mcp-connectors/{id} [put] +func (h *Handlers) UpdateMCPConnector(c echo.Context) error { + host, ok := h.mcpHost(c) + if !ok { + return nil + } + var req schema.ChatsMCPConnectorWrite + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "the body is not valid JSON") + } + m, err := host.Update(c.Request().Context(), userID(c), c.Param("id"), mcpIn(req)) + if err != nil { + return failMCP(c, err) + } + return c.JSON(http.StatusOK, mcpOut(m)) +} + +// DeleteMCPConnector deletes an MCP connector. +// @Summary Delete an MCP connector. +// @Description Its open session is closed. A saved agent that names it gets a note that it could not be used. +// @Tags chats +// @Param id path string true "the connector's id" +// @Success 204 "Deleted" +// @Router /api/chats/mcp-connectors/{id} [delete] +func (h *Handlers) DeleteMCPConnector(c echo.Context) error { + host, ok := h.mcpHost(c) + if !ok { + return nil + } + if err := host.Delete(c.Request().Context(), userID(c), c.Param("id")); err != nil { + return failMCP(c, err) + } + return c.NoContent(http.StatusNoContent) +} + +func (h *Handlers) setMCPEnabled(c echo.Context, on bool) error { + host, ok := h.mcpHost(c) + if !ok { + return nil + } + m, err := host.SetEnabled(c.Request().Context(), userID(c), c.Param("id"), on) + if err != nil { + return failMCP(c, err) + } + return c.JSON(http.StatusOK, mcpOut(m)) +} + +// EnableMCPConnector switches an MCP connector on. +// @Summary Switch an MCP connector on. +// @Description The address is checked again; one on a private network is refused unless an administrator allows it. +// @Tags chats +// @Param id path string true "the connector's id" +// @Success 200 {object} schema.ChatsMCPConnector "The connector" +// @Router /api/chats/mcp-connectors/{id}/enable [post] +func (h *Handlers) EnableMCPConnector(c echo.Context) error { return h.setMCPEnabled(c, true) } + +// DisableMCPConnector switches an MCP connector off. +// @Summary Switch an MCP connector off. +// @Tags chats +// @Param id path string true "the connector's id" +// @Success 200 {object} schema.ChatsMCPConnector "The connector" +// @Router /api/chats/mcp-connectors/{id}/disable [post] +func (h *Handlers) DisableMCPConnector(c echo.Context) error { return h.setMCPEnabled(c, false) } + +// TestMCPConnector connects to an MCP connector and lists its tools. +// @Summary Test an MCP connector. +// @Description Connects with the stored definition, with the same address and credential checks as a chat, and lists the tools, with the description each one has in tool_details. It works for a connector that is switched off. A server that cannot be used is a 200 with ok false and the reason, which never holds a header value. +// @Tags chats +// @Param id path string true "the connector's id" +// @Success 200 {object} schema.ChatsMCPConnectorTest "The tools, or the reason" +// @Router /api/chats/mcp-connectors/{id}/test [post] +func (h *Handlers) TestMCPConnector(c echo.Context) error { + host, ok := h.mcpHost(c) + if !ok { + return nil + } + p, err := host.Test(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failMCP(c, err) + } + tools := p.Tools + if tools == nil { + tools = []string{} + } + details := make([]schema.ChatsMCPConnectorTool, 0, len(p.Details)) + for _, d := range p.Details { + details = append(details, schema.ChatsMCPConnectorTool{Name: d.Name, Description: d.Description}) + } + return c.JSON(http.StatusOK, schema.ChatsMCPConnectorTest{OK: p.Error == "", Tools: tools, ToolDetails: details, Error: p.Error}) +} + +func toolsPanel(c echo.Context, h *Handlers) error { + p, err := h.svc.Tools(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failFor(c, err) + } + return writeToolsPanel(c, p) +} + +func writeToolsPanel(c echo.Context, p studiosvc.ToolsPanel) error { + out := schema.ChatsToolsPanel{Builtins: make([]schema.ChatsBuiltinGroup, 0, len(p.Builtins)), Addons: make([]schema.ChatsAddon, 0, len(p.Addons)), Servers: make([]schema.ChatsMCPServerSwitch, 0, len(p.Servers)), + Connectors: make([]schema.ChatsMCPConnectorSwitch, 0, len(p.Connectors)), Off: p.Off} + for _, g := range p.Builtins { + out.Builtins = append(out.Builtins, schema.ChatsBuiltinGroup{Group: g.Group, Tools: g.Tools, Off: g.Off, OptIn: g.OptIn, + Usable: g.Usable, Reason: g.Reason, Provider: g.Provider, Providers: g.Providers}) + } + for _, a := range p.Addons { + out.Addons = append(out.Addons, schema.ChatsAddon{ID: a.ID, Tools: a.Tools, Off: a.Off}) + } + for _, f := range p.Fallbacks { + out.Fallbacks = append(out.Fallbacks, schema.ChatsToolFallback{Tool: f.Tool, Addon: f.AddonID, AddonTitle: f.Title, Builtin: f.Builtin}) + } + for _, s := range p.Servers { + out.Servers = append(out.Servers, schema.ChatsMCPServerSwitch{Name: s.Name, Off: s.Off, Usable: s.Usable, Reason: s.Reason}) + } + for _, s := range p.Connectors { + out.Connectors = append(out.Connectors, schema.ChatsMCPConnectorSwitch{ID: s.ID, Name: s.Name, Off: s.Off, Usable: s.Usable, Reason: s.Reason}) + } + return c.JSON(http.StatusOK, out) +} + +// ListChatTools lists what a chat can use besides its built-in tools. +// @Summary List the tools a chat can switch on and off. +// @Description The groups of built-in tools (shell, web, files, media, collections, subagents, triggers and saved_agents; a group the server does not offer is absent, and shell is listed even when nothing can run commands, not usable with the reason), add-ons, the MCP servers of the chat's model and your MCP connectors, each with the chat's switch and whether it can be used now (with the reason when not). A chat with a saved agent lists only what the agent grants. fallbacks lists the built-in tools (bash, web_search, web_fetch) that an installed add-on replaces but that no add-on serves in this chat now, with the add-on and whether the built-in serves instead. +// @Tags chats +// @Param id path string true "conversation id" +// @Success 200 {object} schema.ChatsToolsPanel "The tools and their switches" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/{id}/tools [get] +func (h *Handlers) ListChatTools(c echo.Context) error { return toolsPanel(c, h) } + +// SetChatBuiltins sets the groups of built-in tools a chat has switched off, +// and the opt-in groups it has switched on. +// @Summary Switch groups of built-in tools on and off for a chat. +// @Description off replaces the earlier list of groups that are off. The groups are shell (bash, whether the Sandbox add-on or the built-in bash on this machine serves it), web (web_search and web_fetch), files (list_files, read_file, write_file, edit_file, move_file, delete_file, search_files and read_document), media (describe_image and transcribe_audio), collections (list_collections and search_collection), subagents (spawn_agent), triggers (propose_trigger, list_triggers, update_trigger and delete_trigger) and saved_agents. A sub-agent follows the switches of its parent chat. An opt-in group (opt_in in the list, such as saved_agents: list_agents and the agent argument of spawn_agent) is off until on names it; on replaces the earlier list of opt-in groups that are on, and an opt-in group in off is ignored. Omit on to keep it. A switch never adds what the administrator has off or the user may not use. The next turn of the chat gets the tools that are on. +// @Tags chats +// @Param id path string true "conversation id" +// @Param request body schema.ChatsBuiltinSwitches true "the groups that are off" +// @Success 200 {object} schema.ChatsToolsPanel "The tools and their switches now in force" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/{id}/builtins [put] +func (h *Handlers) SetChatBuiltins(c echo.Context) error { + var req schema.ChatsBuiltinSwitches + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "off is required") + } + if err := h.svc.SetBuiltinsOff(c.Request().Context(), userID(c), c.Param("id"), req.Off); err != nil { + return failFor(c, err) + } + if req.On != nil { + if err := h.svc.SetBuiltinsOn(c.Request().Context(), userID(c), c.Param("id"), *req.On); err != nil { + return failFor(c, err) + } + } + return toolsPanel(c, h) +} + +// SetChatMCP sets the MCP servers and connectors a chat has switched off. +// @Summary Switch MCP servers and connectors on and off for a chat. +// @Description The list replaces the earlier one: the name of a server of the model, or connector:. A switch only takes away; a chat with a saved agent never gets more than the agent grants. The next turn of the chat gets the tools that are on. +// @Tags chats +// @Param id path string true "conversation id" +// @Param request body schema.ChatsMCPSwitches true "what is off" +// @Success 200 {object} schema.ChatsToolsPanel "The tools and their switches now in force" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/{id}/mcp [put] +func (h *Handlers) SetChatMCP(c echo.Context) error { + var req schema.ChatsMCPSwitches + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "off is required") + } + if err := h.svc.SetMcpOff(c.Request().Context(), userID(c), c.Param("id"), req.Off); err != nil { + return failFor(c, err) + } + return toolsPanel(c, h) +} diff --git a/core/http/endpoints/studio/mcpcontent.go b/core/http/endpoints/studio/mcpcontent.go new file mode 100644 index 000000000..a97539fe4 --- /dev/null +++ b/core/http/endpoints/studio/mcpcontent.go @@ -0,0 +1,121 @@ +package studio + +import ( + "encoding/json" + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" + studiosvc "github.com/mudler/LocalAI/core/services/studio" +) + +var mcpContentRules = []errRule{ + fixed(http.StatusForbidden, "mcp_refused", "the chat cannot use this MCP server", studiosvc.ErrMCPServerNotUsable), + verbatim(http.StatusBadGateway, "mcp_error", "", studiosvc.ErrMCPContent), +} + +// failMCPContent maps the errors of MCP prompts and resources. +func failMCPContent(c echo.Context, err error) error { return mapErr(c, err, mcpContentRules, failMCP) } + +func mcpRefs(in []schema.ChatsMCPRef) []studiosvc.MCPRef { + out := make([]studiosvc.MCPRef, len(in)) + for i, r := range in { + out[i] = studiosvc.MCPRef{Server: r.Server, Name: r.Name, Arguments: r.Arguments, URI: r.URI} + } + return out +} + +// parseContext reads the context form field of a multipart message. +func parseContext(raw string) ([]schema.ChatsMCPRef, error) { + if raw == "" { + return nil, nil + } + var refs []schema.ChatsMCPRef + if err := json.Unmarshal([]byte(raw), &refs); err != nil { + return nil, err + } + return refs, nil +} + +// MCPPrompts lists the MCP prompts a chat can use. +// @Summary List the MCP prompts of a chat. +// @Description Lists the prompts of the MCP servers of the chat's model and of your connectors that this chat can use now: not switched off for the chat, and granted by its saved agent when it has one. Add one to a message with the context field of POST /api/chats/{id}/messages. +// @Tags chats +// @Param id path string true "conversation id" +// @Success 200 {object} schema.ChatsMCPPromptList "The prompts" +// @Router /api/chats/{id}/mcp/prompts [get] +func (h *Handlers) MCPPrompts(c echo.Context) error { + ps, err := h.svc.MCPPrompts(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failMCPContent(c, err) + } + out := schema.ChatsMCPPromptList{Prompts: make([]schema.ChatsMCPPrompt, 0, len(ps))} + for _, p := range ps { + mp := schema.ChatsMCPPrompt{Server: p.Server, Name: p.Name, Title: p.Title, Description: p.Description} + for _, a := range p.Arguments { + mp.Arguments = append(mp.Arguments, schema.ChatsMCPPromptArg{Name: a.Name, Description: a.Description, Required: a.Required}) + } + out.Prompts = append(out.Prompts, mp) + } + return c.JSON(http.StatusOK, out) +} + +// MCPExpandPrompt expands an MCP prompt. +// @Summary Expand an MCP prompt. +// @Description Returns the text of the prompt with the given arguments. 403 mcp_refused when the chat cannot use the server. +// @Tags chats +// @Param id path string true "conversation id" +// @Param request body schema.ChatsMCPPromptRequest true "the prompt" +// @Success 200 {object} schema.ChatsMCPContent "The expanded prompt" +// @Router /api/chats/{id}/mcp/prompts [post] +func (h *Handlers) MCPExpandPrompt(c echo.Context) error { + var req schema.ChatsMCPPromptRequest + if err := c.Bind(&req); err != nil || req.Server == "" || req.Name == "" { + return fail(c, http.StatusBadRequest, "invalid_request_error", "server and name are required") + } + text, err := h.svc.MCPPrompt(c.Request().Context(), userID(c), c.Param("id"), req.Server, req.Name, req.Arguments) + if err != nil { + return failMCPContent(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsMCPContent{Text: text}) +} + +// MCPResources lists the MCP resources a chat can use. +// @Summary List the MCP resources of a chat. +// @Description Lists the resources of the MCP servers of the chat's model and of your connectors that this chat can use now. Add one to a message with the context field of POST /api/chats/{id}/messages. +// @Tags chats +// @Param id path string true "conversation id" +// @Success 200 {object} schema.ChatsMCPResourceList "The resources" +// @Router /api/chats/{id}/mcp/resources [get] +func (h *Handlers) MCPResources(c echo.Context) error { + rs, err := h.svc.MCPResources(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failMCPContent(c, err) + } + out := schema.ChatsMCPResourceList{Resources: make([]schema.ChatsMCPResource, 0, len(rs))} + for _, r := range rs { + out.Resources = append(out.Resources, schema.ChatsMCPResource{Server: r.Server, URI: r.URI, Name: r.Name, Description: r.Description, MimeType: r.MIMEType}) + } + return c.JSON(http.StatusOK, out) +} + +// MCPReadResource reads an MCP resource. +// @Summary Read an MCP resource. +// @Description Returns the text of the resource (binary parts are left out, the text is cut at 256 KiB). 403 mcp_refused when the chat cannot use the server. +// @Tags chats +// @Param id path string true "conversation id" +// @Param request body schema.ChatsMCPReadRequest true "the resource" +// @Success 200 {object} schema.ChatsMCPContent "The resource text" +// @Router /api/chats/{id}/mcp/resources/read [post] +func (h *Handlers) MCPReadResource(c echo.Context) error { + var req schema.ChatsMCPReadRequest + if err := c.Bind(&req); err != nil || req.Server == "" || req.URI == "" { + return fail(c, http.StatusBadRequest, "invalid_request_error", "server and uri are required") + } + text, err := h.svc.MCPResource(c.Request().Context(), userID(c), c.Param("id"), req.Server, req.URI) + if err != nil { + return failMCPContent(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsMCPContent{Text: text}) +} diff --git a/core/http/endpoints/studio/notifications.go b/core/http/endpoints/studio/notifications.go new file mode 100644 index 000000000..0806bfe32 --- /dev/null +++ b/core/http/endpoints/studio/notifications.go @@ -0,0 +1,136 @@ +package studio + +import ( + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/studio/notify" +) + +const ( + defaultNotifications = 50 + maxNotifications = notify.MaxPerUser + maxNotificationIDs = 500 +) + +func notificationOut(n notify.Notification) schema.ChatsNotification { + return schema.ChatsNotification{ID: n.ID, Kind: n.Kind, Status: n.Status, Source: n.Source, Title: n.Title, Body: n.Body, Link: n.Link, Read: n.Read, Created: n.Created} +} + +// ListNotifications lists the inbox. +// @Summary List your notifications. +// @Description The inbox: one item for each run of a trigger that finished, failed or needs you. Newest first. It also returns the number unread and the total kept. +// @Tags chats +// @Param unread query bool false "only the unread ones" +// @Param limit query int false "most items to return (default 50, at most 500)" +// @Success 200 {object} schema.ChatsNotificationList "The notifications" +// @Router /api/chats/notifications [get] +func (h *Handlers) ListNotifications(c echo.Context) error { + user := userID(c) + list, err := h.svc.Notifications(c.Request().Context(), user, c.QueryParam("unread") == "1" || c.QueryParam("unread") == "true", + intParam(c, "limit", defaultNotifications, maxNotifications)) + if err != nil { + return failFor(c, err) + } + unread, err := h.svc.UnreadNotifications(c.Request().Context(), user) + if err != nil { + return failFor(c, err) + } + total, err := h.svc.TotalNotifications(c.Request().Context(), user) + if err != nil { + return failFor(c, err) + } + out := schema.ChatsNotificationList{Notifications: make([]schema.ChatsNotification, 0, len(list)), Unread: unread, Total: total} + for _, n := range list { + out.Notifications = append(out.Notifications, notificationOut(n)) + } + return c.JSON(http.StatusOK, out) +} + +// CountNotifications returns the unread count. +// @Summary Count unread notifications. +// @Tags chats +// @Success 200 {object} schema.ChatsNotificationCount "The number unread" +// @Router /api/chats/notifications/count [get] +func (h *Handlers) CountNotifications(c echo.Context) error { + n, err := h.svc.UnreadNotifications(c.Request().Context(), userID(c)) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsNotificationCount{Unread: n}) +} + +// ReadNotifications marks notifications read. +// @Summary Mark notifications read. +// @Description Name the ids, or pass all. Opening a chat marks its notifications read by itself. +// @Tags chats +// @Param request body schema.ChatsNotificationsRead true "ids or all" +// @Success 200 {object} schema.ChatsNotificationCount "The number still unread" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Router /api/chats/notifications/read [post] +func (h *Handlers) ReadNotifications(c echo.Context) error { + var req schema.ChatsNotificationsRead + if err := c.Bind(&req); err != nil || (!req.All && len(req.IDs) == 0) || len(req.IDs) > maxNotificationIDs { + return fail(c, http.StatusBadRequest, "invalid_request_error", "name up to 500 ids, or pass all") + } + user := userID(c) + if err := h.svc.MarkNotificationsRead(c.Request().Context(), user, req.IDs, req.All); err != nil { + return failFor(c, err) + } + return h.CountNotifications(c) +} + +// DeleteNotifications removes notifications. +// @Summary Delete notifications. +// @Description Name the ids, or pass all to empty your inbox; with all, read_only keeps the unread ones. Only your own notifications are touched. +// @Tags chats +// @Param request body schema.ChatsNotificationsDelete true "ids, or all" +// @Success 200 {object} schema.ChatsNotificationCount "The number still unread and how many were deleted" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Router /api/chats/notifications [delete] +func (h *Handlers) DeleteNotifications(c echo.Context) error { + var req schema.ChatsNotificationsDelete + if err := c.Bind(&req); err != nil || len(req.IDs) > maxNotificationIDs || (req.All == (len(req.IDs) > 0)) || (req.ReadOnly && !req.All) { + return fail(c, http.StatusBadRequest, "invalid_request_error", "name 1 to 500 ids, or pass all") + } + ctx, user := c.Request().Context(), userID(c) + deleted := len(req.IDs) + if req.All { + n, err := h.svc.ClearNotifications(ctx, user, req.ReadOnly) + if err != nil { + return failFor(c, err) + } + deleted = n + } else if err := h.svc.DeleteNotifications(ctx, user, req.IDs); err != nil { + return failFor(c, err) + } + unread, err := h.svc.UnreadNotifications(ctx, user) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsNotificationCount{Unread: unread, Deleted: deleted}) +} + +// NotificationEvents streams new notifications. +// @Summary Stream your notifications. +// @Description Server-sent events: one notification event for each new inbox item, with the same fields as the list, and a notifications_changed event when notifications were read or deleted, so a client reads the list again. Live only; list the inbox for what came before. +// @Tags chats +// @Produce text/event-stream +// @Success 200 {object} schema.ChatsNotification "An event stream" +// @Router /api/chats/notifications/events [get] +func (h *Handlers) NotificationEvents(c echo.Context) error { + ch, cancel, err := h.svc.SubscribeNotifications(userID(c)) + if err != nil { + return failFor(c, err) + } + defer cancel() + + return streamSSE(c, ch, func(n notify.Notification) (string, any) { + if n.Op == notify.OpChanged { + return "notifications_changed", struct{}{} + } + return "notification", notificationOut(n) + }) +} diff --git a/core/http/endpoints/studio/pages.go b/core/http/endpoints/studio/pages.go new file mode 100644 index 000000000..13b222725 --- /dev/null +++ b/core/http/endpoints/studio/pages.go @@ -0,0 +1,365 @@ +package studio + +import ( + "encoding/base64" + "errors" + "net/http" + "strings" + "unicode/utf8" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/http/auth" + "github.com/mudler/LocalAI/core/schema" + studiosvc "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/pages" + "github.com/mudler/LocalAI/core/services/studio/session" +) + +// The pages endpoints serve the pages of the caller's own home and the bridge +// that the UI gives a page in its sandboxed frame. The UI calls them for the +// page; the page itself never reaches the server. + +const ( + // maxPageMessage bounds a message that a page sends to a chat, in runes. + maxPageMessage = 4000 + // maxPageWriteBody bounds the JSON body of a write: 1 MiB of content can + // grow when JSON escapes it. + maxPageWriteBody = 8 << 20 + // pageChatScan is how many recent chats are searched for a page's chat. + pageChatScan = 200 +) + +var pageRules = []errRule{ + sentinelText(http.StatusBadRequest, typeInvalid, pages.ErrBadName), + sentinelText(http.StatusBadRequest, typeInvalid, pages.ErrBadPath), + fixed(http.StatusNotFound, typeNotFound, "page or file not found", pages.ErrNotFound), + sentinelText(http.StatusRequestEntityTooLarge, typeInvalid, pages.ErrTooLarge), + sentinelText(http.StatusUnsupportedMediaType, typeInvalid, pages.ErrNotText), + sentinelText(http.StatusUnsupportedMediaType, typeInvalid, pages.ErrNotImage), + verbatim(http.StatusForbidden, typeAuthz, "pages: ", pages.ErrReadOnly), + sentinelText(http.StatusTooManyRequests, "rate_limit_error", pages.ErrRateLimited), + verbatim(http.StatusForbidden, typeAuthz, "pages: ", pages.ErrFolderReadOnly), + verbatim(http.StatusForbidden, typePageNotAllowed, "pages: ", pages.ErrNoConsent), + verbatim(http.StatusConflict, typeConflict, "pages: ", pages.ErrChanged), + verbatim(http.StatusConflict, typeConflict, "pages: ", pages.ErrCodeChanged), +} + +func failPage(c echo.Context, err error) error { + return mapErr(c, err, pageRules, failFile) +} + +// pagesOn checks the pages feature and the files, as files does. On failure it +// has written the response and returns false. +func (h *Handlers) pagesOn(c echo.Context) (fileCall, bool) { + if !auth.FeatureAllowed(c, auth.FeaturePages) { + _ = fail(c, http.StatusForbidden, typeAuthz, "feature not enabled for your account: "+auth.FeaturePages) + return fileCall{}, false + } + return h.files(c) +} + +func pageJSON(p pages.Page) schema.PagesPage { + return schema.PagesPage{ + Name: p.Name, Path: p.Path, Title: p.Title, Icon: p.Icon, Description: p.Description, + Source: p.Source, Author: p.Author, UpdatedAt: p.UpdatedAt, UpdatedBy: p.UpdatedBy, Rev: p.Rev, Size: p.Size, + Widget: widgetJSON(p.Widget), + } +} + +func widgetJSON(w *pages.Widget) *schema.PagesWidget { + if w == nil { + return nil + } + return &schema.PagesWidget{Size: w.Size, Title: w.Title} +} + +func pageFileJSON(f pages.File) schema.PagesFile { + return schema.PagesFile{Path: f.Path, Kind: f.Kind, Size: f.Size, Version: f.Version, ConflictOf: f.ConflictOf} +} + +// ListPages lists the caller's pages. +// @Summary List your pages. +// @Description A page is a folder pages// of your home with an index.html. Folders without one are left out. +// @Tags pages +// @Success 200 {object} schema.PagesList "Your pages, sorted by name" +// @Failure 403 {object} schema.ErrorResponse "The pages feature is off for your account" +// @Failure 409 {object} schema.ErrorResponse "Files are off on this server" +// @Router /api/chats/pages [get] +func (h *Handlers) ListPages(c echo.Context) error { + fc, ok := h.pagesOn(c) + if !ok { + return nil + } + list, err := h.pages.List(c.Request().Context(), fc.p) + if err != nil { + return failPage(c, err) + } + out := schema.PagesList{Pages: make([]schema.PagesPage, 0, len(list))} + for _, p := range list { + out.Pages = append(out.Pages, pageJSON(p)) + } + return c.JSON(http.StatusOK, out) +} + +// GetPage returns a page with its index.html. +// @Summary Read a page and its index.html. +// @Description The UI shows the HTML in a sandboxed frame. An index.html over 1 MiB is refused. +// @Tags pages +// @Param name path string true "page name" +// @Success 200 {object} schema.PagesSource "The page and its HTML" +// @Failure 404 {object} schema.ErrorResponse "No such page" +// @Failure 413 {object} schema.ErrorResponse "index.html is larger than 1 MiB" +// @Router /api/chats/pages/{name} [get] +func (h *Handlers) GetPage(c echo.Context) error { + fc, ok := h.pagesOn(c) + if !ok { + return nil + } + p, html, err := h.pages.Source(c.Request().Context(), fc.p, c.Param("name")) + if err != nil { + return failPage(c, err) + } + c.Response().Header().Set("Cache-Control", "no-store") + return c.JSON(http.StatusOK, schema.PagesSource{Page: pageJSON(p), HTML: html}) +} + +// ListPageFiles lists a folder of a page. +// @Summary List a folder of a page (bridge files.list). +// @Tags pages +// @Param name path string true "page name" +// @Param dir query string false "folder inside the page's folder; default the page's folder" +// @Success 200 {object} schema.PagesFileList "The entries, with paths relative to the page's folder" +// @Failure 400 {object} schema.ErrorResponse "The path leaves the page's folder" +// @Router /api/chats/pages/{name}/files [get] +func (h *Handlers) ListPageFiles(c echo.Context) error { + fc, ok := h.pagesOn(c) + if !ok { + return nil + } + list, err := h.pages.ListFiles(c.Request().Context(), fc.p, c.Param("name"), c.QueryParam("dir")) + if err != nil { + return failPage(c, err) + } + out := schema.PagesFileList{Entries: make([]schema.PagesFile, 0, len(list))} + for _, f := range list { + out.Entries = append(out.Entries, pageFileJSON(f)) + } + return c.JSON(http.StatusOK, out) +} + +// ReadPageFile reads a text file of a page, or an image with encoding=base64. +// @Summary Read a text file of a page (bridge files.read). +// @Description With encoding=base64 the file must be an image (png, jpg, gif, webp, avif, bmp, ico or svg, by extension) and content is its bytes in base64. The UI uses it to put the images next to a page into the page as data: URLs; the bridge of a page reads text only. +// @Tags pages +// @Param name path string true "page name" +// @Param path query string true "file inside the page's folder, for example data.json" +// @Param encoding query string false "base64 to read an image" +// @Success 200 {object} schema.PagesFileContent "The file and its text" +// @Failure 400 {object} schema.ErrorResponse "The path leaves the page's folder" +// @Failure 413 {object} schema.ErrorResponse "The file is larger than 1 MiB" +// @Failure 415 {object} schema.ErrorResponse "The file is not UTF-8 text, or not an image with encoding=base64" +// @Router /api/chats/pages/{name}/file [get] +func (h *Handlers) ReadPageFile(c echo.Context) error { + fc, ok := h.pagesOn(c) + if !ok { + return nil + } + ctx, name, rel := c.Request().Context(), c.Param("name"), c.QueryParam("path") + if image, ok := imageRead(c); !ok { + return nil + } else if image { + img, err := h.pages.ReadImage(ctx, fc.p, name, rel) + return sendPageImage(c, img, err) + } + got, err := h.pages.Read(ctx, fc.p, name, rel) + if err != nil { + return failPage(c, err) + } + c.Response().Header().Set("Cache-Control", "no-store") + return c.JSON(http.StatusOK, schema.PagesFileContent{PagesFile: pageFileJSON(got.File), Content: got.Content}) +} + +// imageRead says whether a read asks for an image (encoding=base64). Any other +// value of encoding is refused, so a typo does not silently read text; then the +// response is written and ok is false. +func imageRead(c echo.Context) (image, ok bool) { + if !c.QueryParams().Has("encoding") { + return false, true + } + if c.QueryParam("encoding") != "base64" { + _ = fail(c, http.StatusBadRequest, typeInvalid, "invalid params: encoding is base64 or absent") + return false, false + } + return true, true +} + +func sendPageImage(c echo.Context, img pages.Image, err error) error { + if err != nil { + return failPage(c, err) + } + c.Response().Header().Set("Cache-Control", "no-store") + return c.JSON(http.StatusOK, schema.PagesFileContent{ + PagesFile: pageFileJSON(img.File), Content: base64.StdEncoding.EncodeToString(img.Data), + Encoding: "base64", MediaType: img.MediaType, + }) +} + +// WritePageFile writes a text file of a page. +// @Summary Write a text file of a page (bridge files.write). +// @Description Missing folders are created. A page writes data files only: code and document files (.js .mjs .cjs .css .html .htm .svg .xml .xhtml .wasm, index, page.json) are refused with 403; the Files page and file tools are not affected. With base, a file that changed since is kept and the content goes to a conflict copy (conflict_of). Writes are rate limited per page. +// @Tags pages +// @Param name path string true "page name" +// @Param request body schema.PagesWriteRequest true "the file" +// @Success 200 {object} schema.PagesFile "The stored file" +// @Failure 400 {object} schema.ErrorResponse "The path leaves the page's folder" +// @Failure 403 {object} schema.ErrorResponse "The page tried to change its own code" +// @Failure 413 {object} schema.ErrorResponse "The file is larger than 1 MiB" +// @Failure 429 {object} schema.ErrorResponse "Too many writes" +// @Router /api/chats/pages/{name}/file [put] +func (h *Handlers) WritePageFile(c echo.Context) error { + fc, ok := h.pagesOn(c) + if !ok { + return nil + } + c.Request().Body = http.MaxBytesReader(c.Response(), c.Request().Body, maxPageWriteBody) + var req schema.PagesWriteRequest + if err := c.Bind(&req); err != nil { + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) { + return failPage(c, pages.ErrTooLarge) + } + return fail(c, http.StatusBadRequest, typeInvalid, msgNotJSON) + } + if req.Base < 0 { + return fail(c, http.StatusBadRequest, typeInvalid, "base must be a version number") + } + f, err := h.pages.Write(c.Request().Context(), fc.p, c.Param("name"), req.Path, []byte(req.Content), req.Base) + if err != nil { + return failPage(c, err) + } + return c.JSON(http.StatusOK, pageFileJSON(f)) +} + +// SendFromPage sends a message from a page to a chat. +// @Summary Send a message from a page to a chat (bridge chat.send). +// @Description The UI calls this after the person saw the message and chose Send. The message is added to the chat as a user message that says which page it comes from, so it shows in the chat before the assistant answers. Without chat_id it goes to the page's own chat, created on first use with model (or the model of your most recent chat). Rate limited per page. +// @Tags pages +// @Param name path string true "page name" +// @Param request body schema.PagesChatRequest true "the message" +// @Success 200 {object} schema.PagesChatResponse "Where the message went" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Failure 404 {object} schema.ErrorResponse "No such page or chat" +// @Failure 429 {object} schema.ErrorResponse "Too many messages" +// @Router /api/chats/pages/{name}/chat [post] +func (h *Handlers) SendFromPage(c echo.Context) error { + fc, ok := h.pagesOn(c) + if !ok { + return nil + } + var req schema.PagesChatRequest + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, typeInvalid, msgNotJSON) + } + text := strings.TrimSpace(req.Text) + if text == "" || utf8.RuneCountInString(text) > maxPageMessage { + return fail(c, http.StatusBadRequest, typeInvalid, "text is required, at most 4000 characters") + } + ctx, name, user := c.Request().Context(), c.Param("name"), userID(c) + page, err := h.pages.Get(ctx, fc.p, name) + if err != nil { + return failPage(c, err) + } + if !h.pages.AllowSend(fc.p, name) { + return failPage(c, pages.ErrRateLimited) + } + var rec session.ChatRecord + if req.ChatID != "" { + rec, err = h.svc.Get(ctx, user, req.ChatID) + } else { + rec, err = h.pageChat(c, user, page.Name, page.Title, req.Model) + } + if err != nil { + if errors.Is(err, errNoPageModel) { + return fail(c, http.StatusBadRequest, typeInvalid, err.Error()) + } + return failFor(c, err) + } + res, err := h.svc.Send(ctx, user, rec.ID, "From the page \""+page.Title+"\" (pages/"+page.Name+"):\n\n"+text) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, schema.PagesChatResponse{ChatID: rec.ID, Title: rec.Title, Started: res.Started, PendingID: res.PendingID}) +} + +var errNoPageModel = errors.New("choose a model: there is no chat to take one from") + +// pageChat finds the page's own chat among the recent chats, or creates it. +// originID names the page: its name for an own page. +func (h *Handlers) pageChat(c echo.Context, user, originID, title, model string) (session.ChatRecord, error) { + ctx := c.Request().Context() + recent, err := h.svc.ListChats(ctx, user, pageChatScan) + if err != nil { + return session.ChatRecord{}, err + } + for _, r := range recent { + if r.Origin == session.OriginPage && r.OriginID == originID { + return r, nil + } + } + if model == "" { + for _, r := range recent { + if r.Model != "" { + model = r.Model + break + } + } + } + if model == "" { + return session.ChatRecord{}, errNoPageModel + } + if h.models != nil { + if err := h.models.Check(ctx, auth.GetUser(c), model); err != nil { + return session.ChatRecord{}, errNoPageModel + } + } + return h.svc.CreateAs(ctx, user, studiosvc.CreateInput{Title: "Page: " + title, Model: model}, + studiosvc.Origin{Kind: session.OriginPage, ID: originID}) +} + +// ListPageStarters lists the starter pages. +// @Summary List the starter pages that ship with the server. +// @Tags pages +// @Success 200 {object} schema.PagesStarterList "The starters" +// @Router /api/chats/page-starters [get] +func (h *Handlers) ListPageStarters(c echo.Context) error { + if _, ok := h.pagesOn(c); !ok { + return nil + } + list := h.pages.Starters() + out := schema.PagesStarterList{Starters: make([]schema.PagesStarter, 0, len(list))} + for _, s := range list { + out.Starters = append(out.Starters, schema.PagesStarter{ID: s.ID, Title: s.Title, Icon: s.Icon, Description: s.Description}) + } + return c.JSON(http.StatusOK, out) +} + +// CopyPageStarter copies a starter page into your pages. +// @Summary Copy a starter page into pages/. +// @Description The copy is named after the starter, or the starter's name with -2, -3 and so on when that is taken. It is yours to change. +// @Tags pages +// @Param id path string true "starter id" +// @Success 201 {object} schema.PagesPage "The new page" +// @Failure 404 {object} schema.ErrorResponse "No such starter" +// @Router /api/chats/page-starters/{id}/copy [post] +func (h *Handlers) CopyPageStarter(c echo.Context) error { + fc, ok := h.pagesOn(c) + if !ok { + return nil + } + p, err := h.pages.CopyStarter(c.Request().Context(), fc.p, c.Param("id")) + if err != nil { + return failPage(c, err) + } + return c.JSON(http.StatusCreated, pageJSON(p)) +} diff --git a/core/http/endpoints/studio/push.go b/core/http/endpoints/studio/push.go new file mode 100644 index 000000000..8e56ff690 --- /dev/null +++ b/core/http/endpoints/studio/push.go @@ -0,0 +1,84 @@ +package studio + +import ( + "errors" + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" + studiosvc "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/notify" +) + +func (h *Handlers) pusher(c echo.Context) (*notify.Pusher, bool) { + return feature(c, h.svc.Pusher(), func(c echo.Context) error { + return fail(c, http.StatusConflict, "push_off", studiosvc.ErrPushOff.Error()+": the server needs a database or a data path to keep subscriptions") + }) +} + +// PushKey returns the public key for browser push. +// @Summary Get the browser push key. +// @Description The VAPID public key a browser needs to subscribe to push for your notifications. 409 when the server cannot keep subscriptions (no database and no data path). +// @Tags chats +// @Success 200 {object} schema.ChatsPushKey "The key" +// @Router /api/chats/push/key [get] +func (h *Handlers) PushKey(c echo.Context) error { + p, ok := h.pusher(c) + if !ok { + return nil + } + key, err := p.PublicKey(c.Request().Context()) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsPushKey{PublicKey: key}) +} + +// PushSubscribe stores a browser's push subscription. +// @Summary Subscribe a browser to push. +// @Description Pass the endpoint and keys of a browser PushSubscription. A notification is then pushed to that browser with its title and link only. The endpoint must be an https address on the public internet. At most 10 browsers per user; the oldest go first. +// @Tags chats +// @Param request body schema.ChatsPushSubscription true "the browser's subscription" +// @Success 204 "Subscribed" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Router /api/chats/push/subscribe [post] +func (h *Handlers) PushSubscribe(c echo.Context) error { + p, ok := h.pusher(c) + if !ok { + return nil + } + req, ok := bind[schema.ChatsPushSubscription](c, msgNotJSON) + if !ok { + return nil + } + if err := p.Subscribe(c.Request().Context(), h.svc.Principal(userID(c)), req.Endpoint, req.Keys.P256dh, req.Keys.Auth); err != nil { + if errors.Is(err, notify.ErrPushEndpoint) { + return fail(c, http.StatusBadRequest, "invalid_request_error", err.Error()) + } + return failFor(c, err) + } + return c.NoContent(http.StatusNoContent) +} + +// PushUnsubscribe removes a browser's push subscription. +// @Summary Unsubscribe a browser from push. +// @Tags chats +// @Param request body schema.ChatsPushUnsubscribe true "the endpoint" +// @Success 204 "Unsubscribed" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Router /api/chats/push/subscribe [delete] +func (h *Handlers) PushUnsubscribe(c echo.Context) error { + p, ok := h.pusher(c) + if !ok { + return nil + } + var req schema.ChatsPushUnsubscribe + if err := c.Bind(&req); err != nil || req.Endpoint == "" { + return fail(c, http.StatusBadRequest, "invalid_request_error", "endpoint is required") + } + if err := p.Unsubscribe(c.Request().Context(), h.svc.Principal(userID(c)), req.Endpoint); err != nil { + return failFor(c, err) + } + return c.NoContent(http.StatusNoContent) +} diff --git a/core/http/endpoints/studio/sharedfolders.go b/core/http/endpoints/studio/sharedfolders.go new file mode 100644 index 000000000..168161540 --- /dev/null +++ b/core/http/endpoints/studio/sharedfolders.go @@ -0,0 +1,271 @@ +package studio + +import ( + "net/http" + "net/url" + "strconv" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/studio/sharedfolders" +) + +// The shared folders endpoints. An administrator creates the folders +// /shared/, chooses who may read or edit each one (a user or everyone), +// renames them and removes them with an undo. A user lists the folders shared +// with them; a folder without a grant is not found, like a missing one. + +var folderRules = []errRule{ + fixed(http.StatusNotFound, typeNotFound, "shared folder not found", sharedfolders.ErrNotFound), + verbatim(http.StatusBadRequest, typeInvalid, "shared folders: ", sharedfolders.ErrBadName, sharedfolders.ErrBadMode, + sharedfolders.ErrUnknownUser, sharedfolders.ErrBadRestore), + verbatim(http.StatusConflict, typeConflict, "shared folders: ", sharedfolders.ErrExists, sharedfolders.ErrConflict, sharedfolders.ErrLimit), + verbatim(http.StatusConflict, typeFilesOff, "shared folders: ", sharedfolders.ErrUnavailable), +} + +func failFolder(c echo.Context, err error) error { + return mapErr(c, err, folderRules, failFile) +} + +func folderJSON(f sharedfolders.Folder, admin bool) schema.SharedFolder { + return sharedfolders.ToSchema(f, admin) +} + +func folderID(c echo.Context) int64 { + id, err := strconv.ParseInt(c.Param("id"), 10, 64) + if err != nil || id <= 0 { + return 0 + } + return id +} + +// memberParam reads the member of the route. Echo leaves a path parameter +// escaped, and a client escapes the "@" of @everyone or an email. +func memberParam(c echo.Context) string { + p := c.Param("principal") + if u, err := url.PathUnescape(p); err == nil { + return u + } + return p +} + +// by is the audit actor of an administrator's change. +func (h *Handlers) by(c echo.Context) string { return h.svc.Principal(userID(c)) } + +// ListMySharedFolders lists the folders shared with the caller. +// @Summary List the folders shared with you. +// @Description A shared folder is a folder outside your home that an administrator gave you, or every user, access to. mode is read or write. pages counts its pages (pages//index.html); open them from /api/chats/shared-pages. Your chats see each folder at /workspace/shared-. +// @Tags chats +// @Success 200 {object} schema.SharedFolderList "Your shared folders, by path" +// @Failure 409 {object} schema.ErrorResponse "Files are off on this server" +// @Router /api/chats/shared-folders [get] +func (h *Handlers) ListMySharedFolders(c echo.Context) error { + fc, ok := h.files(c) + if !ok { + return nil + } + list, err := h.svc.SharedFolders().ForUser(c.Request().Context(), fc.p) + if err != nil { + return failFolder(c, err) + } + out := schema.SharedFolderList{Folders: make([]schema.SharedFolder, 0, len(list))} + for _, f := range list { + out.Folders = append(out.Folders, folderJSON(f, false)) + } + return c.JSON(http.StatusOK, out) +} + +// GetMySharedFolder returns one folder shared with the caller. +// @Summary Read a folder shared with you. +// @Tags chats +// @Param id path integer true "Shared folder ID" +// @Success 200 {object} schema.SharedFolder "The folder and your mode" +// @Failure 404 {object} schema.ErrorResponse "No such folder, or it is not shared with you" +// @Router /api/chats/shared-folders/{id} [get] +func (h *Handlers) GetMySharedFolder(c echo.Context) error { + fc, ok := h.files(c) + if !ok { + return nil + } + f, err := h.svc.SharedFolders().GetForUser(c.Request().Context(), fc.p, folderID(c)) + if err != nil { + return failFolder(c, err) + } + return c.JSON(http.StatusOK, folderJSON(f, false)) +} + +// AdminListSharedFolders lists every shared folder with its members. +// @Summary List the shared folders and their members (administrators). +// @Description A member is a user, or @everyone for every user, with the mode read or write. +// @Tags chats +// @Success 200 {object} schema.SharedFolderList "The shared folders, by name" +// @Failure 403 {object} schema.ErrorResponse "Not an administrator" +// @Failure 409 {object} schema.ErrorResponse "Files are off on this server" +// @Router /api/chats/admin/shared-folders [get] +func (h *Handlers) AdminListSharedFolders(c echo.Context) error { + list, err := h.svc.SharedFolders().List(c.Request().Context()) + if err != nil { + return failFolder(c, err) + } + out := schema.SharedFolderList{Folders: make([]schema.SharedFolder, 0, len(list)), + MaxFolders: sharedfolders.MaxFolders, MaxMembers: sharedfolders.MaxMembers} + for _, f := range list { + out.Folders = append(out.Folders, folderJSON(f, true)) + } + return c.JSON(http.StatusOK, out) +} + +// AdminCreateSharedFolder creates a shared folder. +// @Summary Create a shared folder (administrators). +// @Description Creates /shared/ with an empty pages folder. You become a member who can edit it, so you can fill it; remove yourself when you do not need it. +// @Tags chats +// @Param request body schema.SharedFolderNameRequest true "The folder's name" +// @Success 201 {object} schema.SharedFolder "The new folder" +// @Failure 400 {object} schema.ErrorResponse "A name that is not allowed" +// @Failure 409 {object} schema.ErrorResponse "The name is taken, or the limit is reached" +// @Router /api/chats/admin/shared-folders [post] +func (h *Handlers) AdminCreateSharedFolder(c echo.Context) error { + req, ok := bind[schema.SharedFolderNameRequest](c, msgNotJSON) + if !ok { + return nil + } + f, err := h.svc.SharedFolders().Create(c.Request().Context(), h.by(c), req.Name) + if err != nil { + return failFolder(c, err) + } + return c.JSON(http.StatusCreated, folderJSON(f, true)) +} + +// AdminGetSharedFolder returns one shared folder with its members. +// @Summary Read a shared folder and its members (administrators). +// @Tags chats +// @Param id path integer true "Shared folder ID" +// @Success 200 {object} schema.SharedFolder "The folder" +// @Failure 404 {object} schema.ErrorResponse "No such folder" +// @Router /api/chats/admin/shared-folders/{id} [get] +func (h *Handlers) AdminGetSharedFolder(c echo.Context) error { + f, err := h.svc.SharedFolders().Get(c.Request().Context(), folderID(c)) + if err != nil { + return failFolder(c, err) + } + return c.JSON(http.StatusOK, folderJSON(f, true)) +} + +// AdminRenameSharedFolder renames a shared folder. +// @Summary Rename a shared folder (administrators). +// @Description The members, the pages and the consents to them stay. A chat that is open sees the new name the next time it opens. +// @Tags chats +// @Param id path integer true "Shared folder ID" +// @Param request body schema.SharedFolderNameRequest true "The new name" +// @Success 200 {object} schema.SharedFolder "The renamed folder" +// @Failure 404 {object} schema.ErrorResponse "No such folder" +// @Failure 409 {object} schema.ErrorResponse "The name is taken" +// @Router /api/chats/admin/shared-folders/{id} [patch] +func (h *Handlers) AdminRenameSharedFolder(c echo.Context) error { + req, ok := bind[schema.SharedFolderNameRequest](c, msgNotJSON) + if !ok { + return nil + } + f, err := h.svc.SharedFolders().Rename(c.Request().Context(), h.by(c), folderID(c), req.Name) + if err != nil { + return failFolder(c, err) + } + return c.JSON(http.StatusOK, folderJSON(f, true)) +} + +// AdminDeleteSharedFolder removes a shared folder. +// @Summary Remove a shared folder (administrators). +// @Description Nothing is erased: the folder and its files go to the trash as one change, and undo_rev restores them with their members. Every user's consent to the folder's pages is dropped, so a restored page asks again. +// @Tags chats +// @Param id path integer true "Shared folder ID" +// @Success 200 {object} schema.SharedFolderRemoved "The removed folder and the change to undo" +// @Failure 404 {object} schema.ErrorResponse "No such folder" +// @Router /api/chats/admin/shared-folders/{id} [delete] +func (h *Handlers) AdminDeleteSharedFolder(c echo.Context) error { + f, rev, err := h.svc.SharedFolders().Delete(c.Request().Context(), h.by(c), folderID(c)) + if err != nil { + return failFolder(c, err) + } + return c.JSON(http.StatusOK, schema.SharedFolderRemoved{Folder: folderJSON(f, true), UndoRev: rev}) +} + +// AdminRestoreSharedFolder undoes the removal of a shared folder. +// @Summary Restore a removed shared folder (administrators). +// @Tags chats +// @Param request body schema.SharedFolderRestoreRequest true "The undo_rev of the removal" +// @Success 200 {object} schema.SharedFolder "The restored folder" +// @Failure 400 {object} schema.ErrorResponse "That change is not the removal of a shared folder" +// @Failure 409 {object} schema.ErrorResponse "A folder with its name exists now" +// @Router /api/chats/admin/shared-folders/restore [post] +func (h *Handlers) AdminRestoreSharedFolder(c echo.Context) error { + req, ok := bind[schema.SharedFolderRestoreRequest](c, msgNotJSON) + if !ok { + return nil + } + f, err := h.svc.SharedFolders().Restore(c.Request().Context(), h.by(c), req.Rev) + if err != nil { + return failFolder(c, err) + } + return c.JSON(http.StatusOK, folderJSON(f, true)) +} + +// AdminSetSharedFolderMember gives a member access to a shared folder. +// @Summary Add a member to a shared folder or change their mode (administrators). +// @Description principal is a user's ID, email or name, or @everyone for every user, the ones who sign up later too. mode is read or write. +// @Tags chats +// @Param id path integer true "Shared folder ID" +// @Param principal path string true "User ID, email or name, or @everyone" +// @Param request body schema.SharedFolderMemberRequest true "The mode" +// @Success 200 {object} schema.SharedFolder "The folder and its members" +// @Failure 400 {object} schema.ErrorResponse "No such user, or a mode that is not read or write" +// @Failure 404 {object} schema.ErrorResponse "No such folder" +// @Router /api/chats/admin/shared-folders/{id}/members/{principal} [put] +func (h *Handlers) AdminSetSharedFolderMember(c echo.Context) error { + req, ok := bind[schema.SharedFolderMemberRequest](c, msgNotJSON) + if !ok { + return nil + } + f, err := h.svc.SharedFolders().SetMember(c.Request().Context(), h.by(c), folderID(c), memberParam(c), sharedfolders.ParseMode(req.Mode)) + if err != nil { + return failFolder(c, err) + } + return c.JSON(http.StatusOK, folderJSON(f, true)) +} + +// AdminRemoveSharedFolderMember takes a member's access to a shared folder away. +// @Summary Remove a member from a shared folder (administrators). +// @Description The member's pages list and chats stop showing the folder. A member who is not there is not an error. +// @Tags chats +// @Param id path integer true "Shared folder ID" +// @Param principal path string true "User ID, email or name, or @everyone" +// @Success 200 {object} schema.SharedFolder "The folder and its members" +// @Failure 404 {object} schema.ErrorResponse "No such folder" +// @Router /api/chats/admin/shared-folders/{id}/members/{principal} [delete] +func (h *Handlers) AdminRemoveSharedFolderMember(c echo.Context) error { + f, err := h.svc.SharedFolders().RemoveMember(c.Request().Context(), h.by(c), folderID(c), memberParam(c)) + if err != nil { + return failFolder(c, err) + } + return c.JSON(http.StatusOK, folderJSON(f, true)) +} + +// AdminSharedFolderEvents returns the audit of the shared folders. +// @Summary Read the audit of the shared folders (administrators). +// @Tags chats +// @Param limit query int false "How many lines, newest first (default 100, at most 500)" +// @Success 200 {object} schema.SharedFolderEventList "The newest changes first" +// @Router /api/chats/admin/shared-folders/events [get] +func (h *Handlers) AdminSharedFolderEvents(c echo.Context) error { + ctx := c.Request().Context() + evs, err := h.svc.SharedFolders().Events(ctx, intParam(c, "limit", 100, 500)) + if err != nil { + return failFolder(c, err) + } + out := schema.SharedFolderEventList{Events: make([]schema.SharedFolderEvent, 0, len(evs))} + name := func(id string) string { return h.svc.UserName(ctx, id) } + for _, e := range evs { + out.Events = append(out.Events, sharedfolders.EventToSchema(e, name)) + } + return c.JSON(http.StatusOK, out) +} diff --git a/core/http/endpoints/studio/sharedpages.go b/core/http/endpoints/studio/sharedpages.go new file mode 100644 index 000000000..dff4e700d --- /dev/null +++ b/core/http/endpoints/studio/sharedpages.go @@ -0,0 +1,371 @@ +package studio + +import ( + "errors" + "net/http" + "strconv" + "strings" + "unicode/utf8" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/studio/pages" + "github.com/mudler/LocalAI/core/services/studio/session" +) + +// The shared pages endpoints serve the pages of the folders an administrator +// shared with the caller. A shared page runs as the caller, so the UI runs it +// only after the caller allowed that version, and every call of its bridge +// says which version runs (X-LocalAI-Page-Hash) and, for a visit allowed once, +// the visit (X-LocalAI-Page-Visit). The server checks that consent on every +// call, with the caller's own grant on the folder. + +const ( + headerPageHash = "X-LocalAI-Page-Hash" + headerPageVisit = "X-LocalAI-Page-Visit" + // authorShared is the author of a shared page that another account wrote. + authorShared = "other" +) + +// sharedOn checks the pages feature, the files and the administrator's switch. +func (h *Handlers) sharedOn(c echo.Context) (fileCall, bool) { + fc, ok := h.pagesOn(c) + if !ok { + return fc, false + } + if !h.svc.SharedPagesOn() { + _ = fail(c, http.StatusForbidden, typeSharedPagesOff, "shared pages are off on this server; an administrator turned them off") + return fileCall{}, false + } + return fc, true +} + +// sharedRef reads the folder id and page name of the route. A folder id that +// is not a number is a page that does not exist. +func sharedRef(c echo.Context) (int64, string, error) { + id, err := strconv.ParseInt(c.Param("folder"), 10, 64) + if err != nil || id <= 0 { + return 0, "", pages.ErrNotFound + } + return id, c.Param("name"), nil +} + +func (h *Handlers) sharedJSON(c echo.Context, p pages.Page, consent string) schema.PagesPage { + out := pageJSON(p) + // Another account wrote it: "you" and "assistant" describe own pages only. + if out.Author, out.UpdatedBy = authorShared, authorShared; p.AuthorID == userID(c) { + out.Author, out.UpdatedBy = pages.AuthorYou, pages.AuthorYou + } + out.AuthorID, out.AuthorName = p.AuthorID, h.svc.UserName(c.Request().Context(), p.AuthorID) + out.Folder, out.FolderID, out.Writable, out.Hash, out.MetaSize, out.Consent = p.Folder, p.FolderID, p.Writable, p.Hash, p.MetaSize, consent + out.CodeFiles = append([]string{}, p.CodeFiles...) + return out +} + +// bridgeCheck checks the consent of the frame that calls: on failure it has +// written the response and returns false. +func (h *Handlers) bridgeCheck(c echo.Context, fc fileCall, folder int64, name string) bool { + err := h.trust.Check(c.Request().Context(), fc.p, folder, name, c.Request().Header.Get(headerPageHash), c.Request().Header.Get(headerPageVisit)) + if err != nil { + _ = failPage(c, err) + return false + } + return true +} + +// ListSharedPages lists the pages of the caller's shared folders. +// @Summary List the pages of your shared folders. +// @Description A shared folder is a folder outside your home that an administrator gave you access to. Its pages are the folders pages// with an index.html. Each page has its folder, its author (the account that last wrote index.html), the hash of its code and your consent: none, allowed (this version) or changed (you allowed an earlier one). enabled is false when an administrator turned shared pages off. +// @Tags pages +// @Success 200 {object} schema.PagesSharedList "The pages, by folder and name" +// @Failure 403 {object} schema.ErrorResponse "The pages feature is off for your account" +// @Router /api/chats/shared-pages [get] +func (h *Handlers) ListSharedPages(c echo.Context) error { + fc, ok := h.pagesOn(c) + if !ok { + return nil + } + ctx := c.Request().Context() + list, err := h.pages.ListShared(ctx, fc.p) + if err != nil { + return failPage(c, err) + } + out := schema.PagesSharedList{Enabled: h.svc.SharedPagesOn(), Pages: make([]schema.PagesPage, 0, len(list))} + for _, p := range list { + rev, err := h.trust.State(ctx, fc.p, p) + if err != nil { + return failPage(c, err) + } + out.Pages = append(out.Pages, h.sharedJSON(c, p, rev.State)) + } + return c.JSON(http.StatusOK, out) +} + +// GetSharedPage returns a shared page, its index.html and your consent. +// @Summary Read a shared page, its index.html and your consent. +// @Description The UI shows the trust sheet unless the consent is allowed, and runs the HTML only after you allow its hash. consent has the size and lines of the version you allowed, so a changed page can say what changed. +// @Tags pages +// @Param folder path integer true "shared folder id (folder_id of the list)" +// @Param name path string true "page name" +// @Success 200 {object} schema.PagesSharedSource "The page, its HTML and your consent" +// @Failure 403 {object} schema.ErrorResponse "Shared pages are off on this server" +// @Failure 404 {object} schema.ErrorResponse "No such page, or no access to its folder" +// @Router /api/chats/shared-pages/{folder}/{name} [get] +func (h *Handlers) GetSharedPage(c echo.Context) error { + fc, ok := h.sharedOn(c) + if !ok { + return nil + } + folder, name, err := sharedRef(c) + if err != nil { + return failPage(c, err) + } + ctx := c.Request().Context() + p, html, err := h.pages.SharedSource(ctx, fc.p, folder, name) + if err != nil { + return failPage(c, err) + } + rev, err := h.trust.State(ctx, fc.p, p) + if err != nil { + return failPage(c, err) + } + out := schema.PagesSharedSource{Page: h.sharedJSON(c, p, rev.State), HTML: html, Lines: strings.Count(html, "\n") + 1, + Consent: schema.PagesConsent{State: rev.State}} + if a := rev.Allowed; a != nil { + out.Consent.AllowedAt, out.Consent.Size, out.Consent.MetaSize, out.Consent.Lines = utcPtr(a.At), a.Size, a.MetaSize, a.Lines + } + c.Response().Header().Set("Cache-Control", "no-store") + return c.JSON(http.StatusOK, out) +} + +// AllowSharedPage records your consent to a version of a shared page. +// @Summary Allow a version of a shared page to run. +// @Description hash is the hash you reviewed; a page that changed since is refused with 409. once allows one visit: the answer has a token that the bridge calls of that visit send. Each consent and revoke is audited. +// @Tags pages +// @Param folder path integer true "shared folder id" +// @Param name path string true "page name" +// @Param request body schema.PagesConsentRequest true "the version" +// @Success 200 {object} schema.PagesConsentResponse "The consent" +// @Failure 409 {object} schema.ErrorResponse "The page changed while you were reviewing it" +// @Router /api/chats/shared-pages/{folder}/{name}/consent [put] +func (h *Handlers) AllowSharedPage(c echo.Context) error { + fc, ok := h.sharedOn(c) + if !ok { + return nil + } + folder, name, err := sharedRef(c) + if err != nil { + return failPage(c, err) + } + var req schema.PagesConsentRequest + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, typeInvalid, msgNotJSON) + } + got, err := h.trust.Allow(c.Request().Context(), fc.p, folder, name, req.Hash, req.Once) + if err != nil { + return failPage(c, err) + } + out := schema.PagesConsentResponse{State: pages.StateAllowed, Kind: got.Kind, Hash: got.Hash, Token: got.Token} + if got.Kind == pages.ConsentOnce { + out.State, out.Expires = pages.StateNone, utcPtr(got.Expires) + } + return c.JSON(http.StatusOK, out) +} + +// RevokeSharedPage removes your consents to a shared page. +// @Summary Stop allowing a shared page. +// @Description Its open frames stop at their next bridge call; the next open shows the trust sheet again. +// @Tags pages +// @Param folder path integer true "shared folder id" +// @Param name path string true "page name" +// @Success 204 "Revoked" +// @Router /api/chats/shared-pages/{folder}/{name}/consent [delete] +func (h *Handlers) RevokeSharedPage(c echo.Context) error { + fc, ok := h.pagesOn(c) // a revoke works with shared pages off too + if !ok { + return nil + } + folder, name, err := sharedRef(c) + if err != nil { + return failPage(c, err) + } + if err := h.trust.Revoke(c.Request().Context(), fc.p, folder, name); err != nil { + return failPage(c, err) + } + return c.NoContent(http.StatusNoContent) +} + +// ListSharedPageFiles lists a folder of a shared page. +// @Summary List a folder of a shared page (bridge files.list). +// @Description Needs the headers X-LocalAI-Page-Hash (the version that runs) and, for a visit allowed once, X-LocalAI-Page-Visit. +// @Tags pages +// @Param folder path integer true "shared folder id" +// @Param name path string true "page name" +// @Param dir query string false "folder inside the page's folder" +// @Success 200 {object} schema.PagesFileList "The entries" +// @Failure 403 {object} schema.ErrorResponse "You have not allowed this version (page_not_allowed)" +// @Router /api/chats/shared-pages/{folder}/{name}/files [get] +func (h *Handlers) ListSharedPageFiles(c echo.Context) error { + fc, ok := h.sharedOn(c) + if !ok { + return nil + } + folder, name, err := sharedRef(c) + if err != nil { + return failPage(c, err) + } + if !h.bridgeCheck(c, fc, folder, name) { + return nil + } + list, err := h.pages.ListSharedFiles(c.Request().Context(), fc.p, folder, name, c.QueryParam("dir")) + if err != nil { + return failPage(c, err) + } + out := schema.PagesFileList{Entries: make([]schema.PagesFile, 0, len(list))} + for _, f := range list { + out.Entries = append(out.Entries, pageFileJSON(f)) + } + return c.JSON(http.StatusOK, out) +} + +// ReadSharedPageFile reads a text file of a shared page, or an image with +// encoding=base64. +// @Summary Read a text file of a shared page (bridge files.read). +// @Description With encoding=base64 the file must be an image, as for your own pages. A file of the page's code (index.html, page.json and the code_files of the page) is read only by a frame of the current version: an older frame gets 409, so a script changed after the consent never runs under it. +// @Tags pages +// @Param folder path integer true "shared folder id" +// @Param name path string true "page name" +// @Param path query string true "file inside the page's folder" +// @Param encoding query string false "base64 to read an image" +// @Success 200 {object} schema.PagesFileContent "The file" +// @Failure 403 {object} schema.ErrorResponse "You have not allowed this version (page_not_allowed)" +// @Failure 409 {object} schema.ErrorResponse "A file of code changed since the version that runs" +// @Router /api/chats/shared-pages/{folder}/{name}/file [get] +func (h *Handlers) ReadSharedPageFile(c echo.Context) error { + fc, ok := h.sharedOn(c) + if !ok { + return nil + } + folder, name, err := sharedRef(c) + if err != nil { + return failPage(c, err) + } + // A file of the page's code is read only by a frame of the current + // version, so a script changed after the consent never runs under it. + if err := h.trust.CheckRead(c.Request().Context(), fc.p, folder, name, c.Request().Header.Get(headerPageHash), c.Request().Header.Get(headerPageVisit), c.QueryParam("path")); err != nil { + return failPage(c, err) + } + if image, ok := imageRead(c); !ok { + return nil + } else if image { + img, err := h.pages.ReadSharedImage(c.Request().Context(), fc.p, folder, name, c.QueryParam("path")) + return sendPageImage(c, img, err) + } + got, err := h.pages.ReadShared(c.Request().Context(), fc.p, folder, name, c.QueryParam("path")) + if err != nil { + return failPage(c, err) + } + c.Response().Header().Set("Cache-Control", "no-store") + return c.JSON(http.StatusOK, schema.PagesFileContent{PagesFile: pageFileJSON(got.File), Content: got.Content}) +} + +// WriteSharedPageFile writes a text file of a shared page. +// @Summary Write a text file of a shared page (bridge files.write). +// @Description With your own grant on the folder: a read grant is refused with 403. A page writes data files only: code and document files (.js .mjs .cjs .css .html .htm .svg .xml .xhtml .wasm, index, page.json) are refused with 403. +// @Tags pages +// @Param folder path integer true "shared folder id" +// @Param name path string true "page name" +// @Param request body schema.PagesWriteRequest true "the file" +// @Success 200 {object} schema.PagesFile "The stored file" +// @Failure 403 {object} schema.ErrorResponse "Not allowed, or you can only read the folder" +// @Router /api/chats/shared-pages/{folder}/{name}/file [put] +func (h *Handlers) WriteSharedPageFile(c echo.Context) error { + fc, ok := h.sharedOn(c) + if !ok { + return nil + } + folder, name, err := sharedRef(c) + if err != nil { + return failPage(c, err) + } + if !h.bridgeCheck(c, fc, folder, name) { + return nil + } + c.Request().Body = http.MaxBytesReader(c.Response(), c.Request().Body, maxPageWriteBody) + var req schema.PagesWriteRequest + if err := c.Bind(&req); err != nil { + var tooBig *http.MaxBytesError + if errors.As(err, &tooBig) { + return failPage(c, pages.ErrTooLarge) + } + return fail(c, http.StatusBadRequest, typeInvalid, msgNotJSON) + } + if req.Base < 0 { + return fail(c, http.StatusBadRequest, typeInvalid, "base must be a version number") + } + f, err := h.pages.WriteShared(c.Request().Context(), fc.p, folder, name, req.Path, []byte(req.Content), req.Base) + if err != nil { + return failPage(c, err) + } + return c.JSON(http.StatusOK, pageFileJSON(f)) +} + +// SendFromSharedPage sends a message from a shared page to one of your chats. +// @Summary Send a message from a shared page to a chat (bridge chat.send). +// @Description As for an own page: the UI calls it after you saw the message and chose Send, and the message says which shared page it comes from. +// @Tags pages +// @Param folder path integer true "shared folder id" +// @Param name path string true "page name" +// @Param request body schema.PagesChatRequest true "the message" +// @Success 200 {object} schema.PagesChatResponse "Where the message went" +// @Failure 403 {object} schema.ErrorResponse "You have not allowed this version (page_not_allowed)" +// @Failure 429 {object} schema.ErrorResponse "Too many messages" +// @Router /api/chats/shared-pages/{folder}/{name}/chat [post] +func (h *Handlers) SendFromSharedPage(c echo.Context) error { + fc, ok := h.sharedOn(c) + if !ok { + return nil + } + folder, name, err := sharedRef(c) + if err != nil { + return failPage(c, err) + } + if !h.bridgeCheck(c, fc, folder, name) { + return nil + } + var req schema.PagesChatRequest + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, typeInvalid, msgNotJSON) + } + text := strings.TrimSpace(req.Text) + if text == "" || utf8.RuneCountInString(text) > maxPageMessage { + return fail(c, http.StatusBadRequest, typeInvalid, "text is required, at most 4000 characters") + } + ctx, user := c.Request().Context(), userID(c) + page, err := h.pages.GetShared(ctx, fc.p, folder, name) + if err != nil { + return failPage(c, err) + } + if !h.pages.AllowSharedSend(fc.p, folder, name) { + return failPage(c, pages.ErrRateLimited) + } + var rec session.ChatRecord + if req.ChatID != "" { + rec, err = h.svc.Get(ctx, user, req.ChatID) + } else { + rec, err = h.pageChat(c, user, "shared/"+strconv.FormatInt(folder, 10)+"/"+page.Name, page.Title, req.Model) + } + if err != nil { + if errors.Is(err, errNoPageModel) { + return fail(c, http.StatusBadRequest, typeInvalid, err.Error()) + } + return failFor(c, err) + } + from := strings.TrimPrefix(page.Folder, "/") + "/pages/" + page.Name + res, err := h.svc.Send(ctx, user, rec.ID, "From the shared page \""+page.Title+"\" ("+from+"):\n\n"+text) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, schema.PagesChatResponse{ChatID: rec.ID, Title: rec.Title, Started: res.Started, PendingID: res.PendingID}) +} diff --git a/core/http/endpoints/studio/skills.go b/core/http/endpoints/studio/skills.go new file mode 100644 index 000000000..419ce39d2 --- /dev/null +++ b/core/http/endpoints/studio/skills.go @@ -0,0 +1,215 @@ +package studio + +import ( + "net/http" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/http/auth" + "github.com/mudler/LocalAI/core/schema" + studiosvc "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/files" + "github.com/mudler/LocalAI/core/services/studio/skills" +) + +// isAdmin reports whether the caller may change the shared skills. With +// authentication off there is one user, who is the administrator. +func isAdmin(c echo.Context) bool { + u := auth.GetUser(c) + return u == nil || u.Role == auth.RoleAdmin +} + +// skillActor is who a skill call acts as: the user, or for the shared layer the +// administrator's identity in the workspace, when the caller is one. +func skillActor(c echo.Context, fc fileCall, layer skills.Layer) string { + if layer == skills.LayerShared && isAdmin(c) { + return files.AdminPrincipal + } + return fc.p +} + +func parseLayer(s string) (skills.Layer, bool) { + switch skills.Layer(s) { + case skills.LayerPersonal, skills.LayerShared, skills.LayerAddon, skills.LayerBuiltin: + return skills.Layer(s), true + } + return "", false +} + +func editable(c echo.Context, l skills.Layer) bool { + return l == skills.LayerPersonal || (l == skills.LayerShared && isAdmin(c)) +} + +// ListSkills lists the skills. +// @Summary List your skills. +// @Description Skills from every layer: .skills folders of your files, your personal skills, the shared ones, the ones of the add-ons you may use now (addon, with the add-on in addon and addon_title) and the built-in ones. The nearest wins; a skill that another overrides says so in shadowed. With chat set, the chat's switches are applied. +// @Tags chats +// @Param chat query string false "a chat id, to apply its switches" +// @Success 200 {object} schema.ChatsSkillList "The skills" +// @Router /api/chats/skills [get] +func (h *Handlers) ListSkills(c echo.Context) error { + if _, ok := h.files(c); !ok { + return nil + } + list, notes, err := h.svc.Skills(c.Request().Context(), userID(c), c.QueryParam("chat")) + if err != nil { + return failFor(c, err) + } + out := schema.ChatsSkillList{Skills: make([]schema.ChatsSkill, 0, len(list)), Notes: notes} + for _, sk := range list { + out.Skills = append(out.Skills, schema.ChatsSkill{ + ID: studiosvc.SkillID(sk), Name: sk.Name, Description: sk.Description, Layer: string(sk.Layer), Path: sk.GuestPath, + Addon: sk.Addon, AddonTitle: sk.AddonTitle, ShadowedBy: sk.ShadowedBy, Fallback: sk.Fallback, + Enabled: !sk.Off, Shadowed: string(sk.Shadowed), Invalid: sk.Invalid, Editable: editable(c, sk.Layer), + }) + } + return c.JSON(http.StatusOK, out) +} + +// CreateSkill creates a skill. +// @Summary Create a skill. +// @Description A skill is a folder with a SKILL.md: a name, a one-line description and the procedure the model reads when a task matches. The personal layer is yours; the shared layer needs an administrator. +// @Tags chats +// @Param request body schema.ChatsSkillWrite true "the skill" +// @Success 200 {object} schema.ChatsSkillDetail "The skill" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Router /api/chats/skills [post] +func (h *Handlers) CreateSkill(c echo.Context) error { + fc, ok := h.files(c) + if !ok { + return nil + } + var req schema.ChatsSkillWrite + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "layer, name, description and body are required") + } + layer, ok := parseLayer(req.Layer) + if !ok || !writable(layer) { + return fail(c, http.StatusBadRequest, "invalid_request_error", "layer must be personal or shared") + } + if err := fc.f.WriteSkill(c.Request().Context(), skillActor(c, fc, layer), layer, req.Name, req.Description, req.Body); err != nil { + return failFile(c, err) + } + return h.skillDetail(c, fc, layer, req.Name) +} + +// GetSkill reads a skill. +// @Summary Read a skill. +// @Tags chats +// @Param layer path string true "personal, shared, addon or builtin" +// @Param name path string true "the skill's name" +// @Param addon query string false "the add-on's ID, for the addon layer" +// @Success 200 {object} schema.ChatsSkillDetail "The skill with its text" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Router /api/chats/skills/{layer}/{name} [get] +func (h *Handlers) GetSkill(c echo.Context) error { + fc, ok := h.files(c) + if !ok { + return nil + } + layer, ok := parseLayer(c.Param("layer")) + if !ok { + return fail(c, http.StatusBadRequest, "invalid_request_error", "layer must be personal, shared, addon or builtin") + } + if layer == skills.LayerAddon { + name := c.Param("name") + desc, body, err := fc.f.ReadAddonSkill(c.Request().Context(), fc.p, c.QueryParam("addon"), name) + if err != nil { + return failFile(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsSkillDetail{Name: name, Layer: string(layer), Description: desc, Body: body}) + } + return h.skillDetail(c, fc, layer, c.Param("name")) +} + +// writable reports a layer whose skills are changed through this API. +func writable(l skills.Layer) bool { return l == skills.LayerPersonal || l == skills.LayerShared } + +func (h *Handlers) skillDetail(c echo.Context, fc fileCall, layer skills.Layer, name string) error { + desc, body, err := fc.f.ReadSkill(c.Request().Context(), fc.p, layer, name) + if err != nil { + return failFile(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsSkillDetail{Name: name, Layer: string(layer), Description: desc, Body: body, Editable: editable(c, layer)}) +} + +// UpdateSkill replaces a skill's description and text. +// @Summary Change a skill. +// @Tags chats +// @Param layer path string true "personal or shared" +// @Param name path string true "the skill's name" +// @Param request body schema.ChatsSkillUpdate true "the new description and body" +// @Success 200 {object} schema.ChatsSkillDetail "The skill" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Router /api/chats/skills/{layer}/{name} [put] +func (h *Handlers) UpdateSkill(c echo.Context) error { + fc, ok := h.files(c) + if !ok { + return nil + } + layer, ok := parseLayer(c.Param("layer")) + if !ok || !writable(layer) { + return fail(c, http.StatusBadRequest, "invalid_request_error", "layer must be personal or shared") + } + var req schema.ChatsSkillUpdate + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "description and body are required") + } + if err := fc.f.WriteSkill(c.Request().Context(), skillActor(c, fc, layer), layer, c.Param("name"), req.Description, req.Body); err != nil { + return failFile(c, err) + } + return h.skillDetail(c, fc, layer, c.Param("name")) +} + +// DeleteSkill moves a skill to the Trash. +// @Summary Delete a skill. +// @Description The skill's folder goes to the Trash of your files, where it can be restored. Shared skills need an administrator. +// @Tags chats +// @Param layer path string true "personal or shared" +// @Param name path string true "the skill's name" +// @Success 200 {object} map[string]bool "deleted" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Router /api/chats/skills/{layer}/{name} [delete] +func (h *Handlers) DeleteSkill(c echo.Context) error { + fc, ok := h.files(c) + if !ok { + return nil + } + layer, ok := parseLayer(c.Param("layer")) + if !ok || !writable(layer) { + return fail(c, http.StatusBadRequest, "invalid_request_error", "layer must be personal or shared") + } + if err := fc.f.DeleteSkill(c.Request().Context(), skillActor(c, fc, layer), layer, c.Param("name")); err != nil { + return failFile(c, err) + } + return c.JSON(http.StatusOK, map[string]bool{"deleted": true}) +} + +// SetChatSkills sets the skills a chat has switched off. +// @Summary Switch skills on and off for a chat. +// @Description The list replaces the earlier one. An entry is a skill name, which turns off the skill that wins so that one of the same name further away shows through, or the id "layer:name" from the skill list, which turns off the skill of that layer. The next turn of the chat gets the index without the skills that are off. +// @Tags chats +// @Param id path string true "conversation id" +// @Param request body schema.ChatsSkillSwitches true "the names or ids of the skills that are off" +// @Success 200 {object} schema.ChatsSkillSwitches "The switches now in force" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/{id}/skills [put] +func (h *Handlers) SetChatSkills(c echo.Context) error { + var req schema.ChatsSkillSwitches + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "off is required") + } + if err := h.svc.SetSkillsOff(c.Request().Context(), userID(c), c.Param("id"), req.Off); err != nil { + return failFor(c, err) + } + rec, err := h.svc.Get(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failFor(c, err) + } + off := rec.SkillsOff + if off == nil { + off = []string{} + } + return c.JSON(http.StatusOK, schema.ChatsSkillSwitches{Off: off}) +} diff --git a/core/http/endpoints/studio/sse.go b/core/http/endpoints/studio/sse.go new file mode 100644 index 000000000..f7beae19b --- /dev/null +++ b/core/http/endpoints/studio/sse.go @@ -0,0 +1,65 @@ +package studio + +import ( + "encoding/json" + "net/http" + "time" + + "github.com/labstack/echo/v4" +) + +// streamSSE writes the values of events as server-sent events until the client +// goes away or the channel closes. A closed channel means the subscriber fell +// behind; the client reconnects and reads what it missed. frame gives the event +// name and the value to encode. A value that cannot be encoded is skipped. A +// heartbeat comment keeps idle proxies from closing the stream. +func streamSSE[T any](c echo.Context, events <-chan T, frame func(T) (name string, data any)) error { + return streamSSEWithIDs(c, events, heartbeat, func(v T) (string, string, any) { + name, data := frame(v) + return "", name, data + }) +} + +// streamSSEWithIDs is streamSSE with an event id on each event (none when id +// is empty), which a browser sends back as Last-Event-ID when it reconnects, +// and a heartbeat interval of the caller's choice. +func streamSSEWithIDs[T any](c echo.Context, events <-chan T, every time.Duration, frame func(T) (id, name string, data any)) error { + w := c.Response() + w.Header().Set(echo.HeaderContentType, "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + w.Flush() + + tick := time.NewTicker(every) + defer tick.Stop() + for { + select { + case <-c.Request().Context().Done(): + return nil + case <-tick.C: + if _, err := w.Write([]byte(": ping\n\n")); err != nil { + return nil + } + w.Flush() + case v, ok := <-events: + if !ok { + return nil + } + id, name, data := frame(v) + raw, err := json.Marshal(data) + if err != nil { + continue + } + head := "" + if id != "" { + head = "id: " + id + "\n" + } + if _, err := w.Write([]byte(head + "event: " + name + "\ndata: " + string(raw) + "\n\n")); err != nil { + return nil + } + w.Flush() + } + } +} diff --git a/core/http/endpoints/studio/sse_test.go b/core/http/endpoints/studio/sse_test.go new file mode 100644 index 000000000..975c6679b --- /dev/null +++ b/core/http/endpoints/studio/sse_test.go @@ -0,0 +1,59 @@ +package studio + +import ( + "context" + "net/http" + "net/http/httptest" + "time" + + "github.com/labstack/echo/v4" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func sseContext(ctx context.Context) (echo.Context, *httptest.ResponseRecorder) { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/events", nil).WithContext(ctx) + return echo.New().NewContext(req, rec), rec +} + +var _ = Describe("streamSSE", func() { + It("writes each value as an event and stops when the channel closes", func() { + c, rec := sseContext(context.Background()) + events := make(chan int, 3) + events <- 1 + events <- 2 + close(events) + Expect(streamSSE(c, events, func(n int) (string, any) { return "number", map[string]int{"n": n} })).To(Succeed()) + Expect(rec.Header().Get("Content-Type")).To(Equal("text/event-stream")) + Expect(rec.Body.String()).To(Equal("event: number\ndata: {\"n\":1}\n\nevent: number\ndata: {\"n\":2}\n\n")) + }) + + It("returns when the client goes away", func() { + ctx, cancel := context.WithCancel(context.Background()) + DeferCleanup(cancel) + c, _ := sseContext(ctx) + events := make(chan int) // never written and never closed + done := make(chan error, 1) + go func() { done <- streamSSE(c, events, func(n int) (string, any) { return "x", n }) }() + Consistently(done, 50*time.Millisecond).ShouldNot(Receive()) + cancel() + Eventually(done, time.Second).Should(Receive(BeNil()), "the stream kept running after the client left") + }) + + It("skips a value that cannot be encoded", func() { + c, rec := sseContext(context.Background()) + events := make(chan int, 2) + events <- 1 + events <- 2 + close(events) + err := streamSSE(c, events, func(n int) (string, any) { + if n == 1 { + return "bad", make(chan int) // json cannot encode a channel + } + return "ok", n + }) + Expect(err).To(Succeed()) + Expect(rec.Body.String()).To(Equal("event: ok\ndata: 2\n\n")) + }) +}) diff --git a/core/http/endpoints/studio/studio.go b/core/http/endpoints/studio/studio.go new file mode 100644 index 000000000..31306f819 --- /dev/null +++ b/core/http/endpoints/studio/studio.go @@ -0,0 +1,611 @@ +// Package studio has the HTTP handlers of the chats API: conversations the +// server keeps, with steering, sub-agents and live events. +package studio + +import ( + "context" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/labstack/echo/v4" + "github.com/mudler/xlog" + + "github.com/mudler/LocalAI/core/http/auth" + "github.com/mudler/LocalAI/core/schema" + studiosvc "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/agents" + "github.com/mudler/LocalAI/core/services/studio/files" + "github.com/mudler/LocalAI/core/services/studio/pages" + "github.com/mudler/LocalAI/core/services/studio/session" + "github.com/mudler/LocalAI/core/services/studio/store" + "github.com/mudler/LocalAI/core/services/studio/workspace" +) + +// Errors a ModelChecker returns. +var ( + // ErrUnknownModel means no model has this name. + ErrUnknownModel = errors.New("unknown model") + // ErrModelNotAllowed means the user may not use the model. + ErrModelNotAllowed = errors.New("model not allowed") +) + +// ModelChecker says whether a user may start a conversation with a model. +type ModelChecker interface { + Check(ctx context.Context, user *auth.User, model string) error +} + +// ModelCheckerFunc adapts a function to ModelChecker. +type ModelCheckerFunc func(ctx context.Context, user *auth.User, model string) error + +// Check implements ModelChecker. +func (f ModelCheckerFunc) Check(ctx context.Context, user *auth.User, model string) error { + return f(ctx, user, model) +} + +// Handlers serves the conversation API. +type Handlers struct { + svc *studiosvc.Service + models ModelChecker + pages *pages.Service + trust *pages.Trust + // previews keeps the recent file previews of this frontend. + previews *previewCache +} + +// New returns the handlers. A nil models skips the model check. +func New(svc *studiosvc.Service, models ModelChecker) *Handlers { + h := &Handlers{svc: svc, models: models, previews: newPreviewCache()} + if svc != nil { + h.pages = pages.New(svc.Files()) + store := svc.PageConsents() + if store == nil { + store, _ = pages.NewJSONConsents("") + } + h.trust = pages.NewTrust(h.pages, store) + } + return h +} + +// maxAttachBytes caps one message with its attachments. It applies when the +// operator set no --upload-limit; with one, the general body limit is lower +// and wins (see bodyLimitSkipper in core/http). Each file is also held to the +// workspace file size. +const maxAttachBytes = 1 << 30 + +const ( + defaultListLimit = 50 + maxListLimit = 200 + defaultMessages = 200 + maxMessages = 1000 + heartbeat = 15 * time.Second +) + +func userID(c echo.Context) string { + if u := auth.GetUser(c); u != nil { + return u.ID + } + return "" +} + +// feature is the guard of the endpoints of a feature that can be off: it +// returns host when the feature is on. When host is nil the feature is off: it +// writes the answer of off and returns false, and the handler returns nil, as +// after a failed bind. +func feature[T any](c echo.Context, host *T, off func(echo.Context) error) (*T, bool) { + if host == nil { + _ = off(c) + return nil, false + } + return host, true +} + +func fail(c echo.Context, status int, typ, msg string) error { + return c.JSON(status, schema.ErrorResponse{Error: &schema.APIError{Message: msg, Code: status, Type: typ}}) +} + +// chatRules maps the errors of the chat service. Another user's conversation +// is the same answer as a missing one. +var chatRules = []errRule{ + fixed(http.StatusNotFound, typeNotFound, "conversation not found", session.ErrNotFound), + fixed(http.StatusConflict, typeConflict, "the message was already delivered to the model", store.ErrDelivered), + fixed(http.StatusNotFound, typeNotFound, "agent template not found", studiosvc.ErrNoTemplate), + fixed(http.StatusConflict, typeConflict, "a turn is running; stop it first", studiosvc.ErrChatBusy), + verbatim(http.StatusBadRequest, typeInvalid, "studio: ", studiosvc.ErrNothingToRegenerate, studiosvc.ErrNotUserMessage, + studiosvc.ErrInvalidSettings, studiosvc.ErrBadImport, session.ErrInvalid), + verbatim(http.StatusBadRequest, typeInvalid, "", studiosvc.ErrBadSkillName, studiosvc.ErrBadAddonID, studiosvc.ErrBadMCPSwitch, studiosvc.ErrBadBuiltinGroup), + verbatim(http.StatusConflict, typeFilesOff, "", files.ErrUnavailable), + fixed(http.StatusRequestEntityTooLarge, typeInvalid, "an attachment is too large", workspace.ErrTooLarge), + fixed(http.StatusConflict, typeConflict, "a sub-agent conversation takes no messages from you", studiosvc.ErrSubAgentReadOnly), + verbatim(http.StatusBadRequest, typeInvalid, "", studiosvc.ErrNoModel), + sentinelText(http.StatusBadRequest, typeInvalid, studiosvc.ErrBadCommand), + verbatim(http.StatusConflict, typeConflict, "studio: ", studiosvc.ErrCommandBusy), + verbatim(http.StatusBadRequest, typeInvalid, "studio: ", studiosvc.ErrNothingToCompact, studiosvc.ErrNothingToClear, studiosvc.ErrCannotCompact), + fixed(http.StatusNotFound, typeNotFound, "agent not found", agents.ErrNotFound), + fixed(http.StatusConflict, typeConflict, "you already have an agent with this name", agents.ErrNameTaken), + verbatim(http.StatusBadRequest, typeInvalid, agents.ErrInvalid.Error()+": ", agents.ErrInvalid), + verbatim(http.StatusConflict, typeNotifyOff, "", studiosvc.ErrNotificationsOff), + fixed(http.StatusForbidden, typeAuthz, "the LocalAI assistant needs an administrator", studiosvc.ErrAssistantForbidden), + fixed(http.StatusServiceUnavailable, "assistant_off", "the LocalAI assistant is switched off on this server", studiosvc.ErrAssistantOff), + fixed(http.StatusBadRequest, typeInvalid, "an assistant chat cannot name a saved agent", studiosvc.ErrAssistantAgent), + verbatim(http.StatusConflict, typeAgentsOff, "", studiosvc.ErrAgentsOff), + fixed(http.StatusForbidden, typeAuthz, "only an administrator can allow commands on this machine; anyone can refuse them", studiosvc.ErrHostExecDecision), + fixed(http.StatusBadRequest, typeInvalid, "the body must be {id, decision} with decision once or deny", studiosvc.ErrHostExecBadDecision), + fixed(http.StatusGone, "consent_expired", "the card expired: the turn that asked has ended, so the command did not run", studiosvc.ErrHostExecExpired), + fixed(http.StatusConflict, "already_answered", "the card was answered already with the other answer", studiosvc.ErrHostExecAnswered), + fixed(http.StatusBadRequest, "egress_above_ceiling", "the preset is above the limit an administrator set", studiosvc.ErrEgressAboveCeiling), + fixed(http.StatusBadRequest, typeInvalid, "the preset must be off, registries or all", studiosvc.ErrEgressPreset), + fixed(http.StatusNotFound, typeNotFound, "no such network rule", studiosvc.ErrEgressNoRule), + fixed(http.StatusNotFound, typeNotFound, "no such network card", studiosvc.ErrEgressNoCard), + fixed(http.StatusBadRequest, typeInvalid, "the answer must be once, chat or deny", studiosvc.ErrEgressBadDecision), + fixed(http.StatusConflict, "already_answered", "the network card was answered already with another answer", studiosvc.ErrEgressAnswered), + fixed(http.StatusBadRequest, "egress_above_ceiling", "the limit an administrator set for the Sandbox network does not admit this host; an administrator can raise it on the Settings tab of the add-on that runs the commands", studiosvc.ErrEgressHostAboveCeiling), + fixed(http.StatusForbidden, typeAuthz, "only an administrator can allow a host inside your network; anyone can deny it", studiosvc.ErrEgressDecision), + verbatim(http.StatusConflict, typeConflict, "", studiosvc.ErrGoalNotAllowed, studiosvc.ErrGoalReached), + verbatim(http.StatusNotFound, typeNotFound, "", studiosvc.ErrNoGoal), + verbatim(http.StatusBadRequest, typeInvalid, "", studiosvc.ErrGoalText), +} + +// failFor maps a service error to a response. +func failFor(c echo.Context, err error) error { + return mapErr(c, err, chatRules, func(c echo.Context, err error) error { + xlog.Error("studio: request failed", "path", c.Request().URL.Path, "error", err) + return fail(c, http.StatusInternalServerError, typeServerError, "the request failed") + }) +} + +func conversation(rec session.ChatRecord, info store.TurnInfo) schema.ChatsConversation { + return schema.ChatsConversation{ + ID: rec.ID, Title: rec.Title, Model: rec.Model, Kind: rec.Kind, ParentID: rec.ParentID, + AgentID: rec.AgentID, Origin: rec.Origin, OriginID: rec.OriginID, Assistant: rec.Assistant, + Created: rec.Created, Updated: rec.Updated, Running: info.Running, LastTurn: string(info.Last), + Settings: settingsJSON(rec.Settings), + } +} + +func (h *Handlers) describe(c echo.Context, user string, rec session.ChatRecord) (schema.ChatsConversation, error) { + info, err := h.svc.Turn(c.Request().Context(), user, rec.ID) + if err != nil { + return schema.ChatsConversation{}, err + } + conv := conversation(rec, info) + conv.Goal = h.goalJSON(rec.Goal) + return conv, nil +} + +// Create starts a conversation. +// @Summary Start a chat. +// @Tags chats +// @Description With switches, the chat starts with those tools, add-ons and skills switched off (and those opt-in tools on), as GET /api/chats/draft/tools and GET /api/chats/skills list them for a chat that does not exist yet. A bad switch refuses the request and no chat is created. +// @Param request body schema.ChatsCreateRequest true "model and optional title, agent and switches" +// @Success 201 {object} schema.ChatsConversation "The new conversation" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Failure 403 {object} schema.ErrorResponse "Refused" +// @Router /api/chats [post] +func (h *Handlers) Create(c echo.Context) error { + var req schema.ChatsCreateRequest + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "the body is not valid JSON") + } + if req.Assistant { + // The service checks the role again from its user lookup; this answer + // comes first so a refusal never depends on that lookup being set. + if u := auth.GetUser(c); u != nil && u.Role != auth.RoleAdmin { + return failFor(c, studiosvc.ErrAssistantForbidden) + } + } + if req.AgentID != "" && !req.Assistant && !auth.FeatureAllowed(c, auth.FeatureAgents) { + return fail(c, http.StatusForbidden, "authorization_error", "feature not enabled for your account: "+auth.FeatureAgents) + } + model := req.Model + if req.AgentID != "" && !req.Assistant { + ag, err := h.svc.Agent(c.Request().Context(), userID(c), req.AgentID) + if err != nil { + return failFor(c, err) + } + if ag.Model != "" { + model = ag.Model + } + } + if model == "" { + return fail(c, http.StatusBadRequest, "invalid_request_error", "model is required") + } + if h.models != nil { + switch err := h.models.Check(c.Request().Context(), auth.GetUser(c), model); { + case errors.Is(err, ErrUnknownModel): + return fail(c, http.StatusBadRequest, "invalid_request_error", "unknown model") + case errors.Is(err, ErrModelNotAllowed): + return fail(c, http.StatusForbidden, "authorization_error", "you may not use this model") + case err != nil: + return failFor(c, err) + } + } + user := userID(c) + in := studiosvc.CreateInput{Title: req.Title, Model: req.Model, AgentID: req.AgentID, Assistant: req.Assistant} + if sw := req.Switches; sw != nil { + in.Switches = studiosvc.Switches{AddonsOff: sw.AddonsOff, McpOff: sw.McpOff, BuiltinsOff: sw.BuiltinsOff, BuiltinsOn: sw.BuiltinsOn, SkillsOff: sw.SkillsOff} + } + rec, err := h.svc.Create(c.Request().Context(), user, in) + if err != nil { + return failFor(c, err) + } + out, err := h.describe(c, user, rec) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusCreated, out) +} + +// List returns the user's conversations, newest first. +// @Summary List chats. +// @Tags chats +// @Param limit query int false "most conversations to return (default 50, at most 200)" +// @Param kind query string false "with chat, sub-agent runs are left out and the limit counts chats" +// @Success 200 {object} schema.ChatsConversationList "Conversations, including sub-agent runs unless kind=chat" +// @Router /api/chats [get] +func (h *Handlers) List(c echo.Context) error { + limit := intParam(c, "limit", defaultListLimit, maxListLimit) + user := userID(c) + list := h.svc.List + if c.QueryParam("kind") == "chat" { + list = h.svc.ListChats + } + recs, err := list(c.Request().Context(), user, limit) + if err != nil { + return failFor(c, err) + } + out := schema.ChatsConversationList{Conversations: make([]schema.ChatsConversation, 0, len(recs)), Persistent: h.svc.Persistent(), Files: h.svc.Files().Available(), FilesReason: h.svc.Files().Unavailable(), ImageModel: h.svc.ImageModel()} + for _, rec := range recs { + conv, err := h.describe(c, user, rec) + if err != nil { + return failFor(c, err) + } + out.Conversations = append(out.Conversations, conv) + } + return c.JSON(http.StatusOK, out) +} + +// Get returns one conversation with its waiting messages. +// @Summary Get a chat. +// @Tags chats +// @Param id path string true "conversation id" +// @Success 200 {object} schema.ChatsConversationDetail "The conversation and its pending messages" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/{id} [get] +func (h *Handlers) Get(c echo.Context) error { + user, id := userID(c), c.Param("id") + rec, err := h.svc.Get(c.Request().Context(), user, id) + if err != nil { + return failFor(c, err) + } + info, err := h.svc.Turn(c.Request().Context(), user, rec.ID) + if err != nil { + return failFor(c, err) + } + conv := conversation(rec, info) + conv.Goal = h.goalJSON(rec.Goal) + pend, err := h.svc.Pending(c.Request().Context(), user, id) + if err != nil { + return failFor(c, err) + } + // Opening a chat is how a run's notifications are read. + h.svc.MarkChatNotificationsRead(c.Request().Context(), user, id) + out := schema.ChatsConversationDetail{ChatsConversation: conv, Pending: make([]schema.ChatsPending, 0, len(pend))} + for _, p := range pend { + out.Pending = append(out.Pending, pendingOf(p)) + } + // The card waits only while the turn that asked holds the chat; a card a + // dead frontend left, also under a later turn, is not shown. + if hc := h.svc.HostConsent(c.Request().Context(), user, rec, info); hc != nil { + out.HostConsent = &schema.ChatsHostConsent{ID: hc.ID, Command: hc.Command} + } + out.Loops = []schema.ChatsLoop{} + if host := h.svc.Loops(); host != nil { + list, err := host.List(c.Request().Context(), user, id) + if err != nil { + return failFor(c, err) + } + out.Loops = loopsJSON(list) + } + return c.JSON(http.StatusOK, out) +} + +// Delete removes a conversation. +// @Summary Delete a chat. +// @Tags chats +// @Param id path string true "conversation id" +// @Success 200 {object} map[string]bool "deleted" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/{id} [delete] +func (h *Handlers) Delete(c echo.Context) error { + if err := h.svc.Delete(c.Request().Context(), userID(c), c.Param("id")); err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, map[string]bool{"deleted": true}) +} + +// Messages returns the transcript. +// @Summary Read a chat's transcript. +// @Tags chats +// @Param id path string true "conversation id" +// @Param after query int false "only lines after this sequence number" +// @Param limit query int false "most lines to return (default 200, at most 1000)" +// @Success 200 {object} schema.ChatsMessageList "Transcript lines in order" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/{id}/messages [get] +func (h *Handlers) Messages(c echo.Context) error { + after, _ := strconv.ParseInt(c.QueryParam("after"), 10, 64) + entries, err := h.svc.Messages(c.Request().Context(), userID(c), c.Param("id"), after, intParam(c, "limit", defaultMessages, maxMessages)) + if err != nil { + return failFor(c, err) + } + entries = h.svc.ResolveAttachments(c.Request().Context(), userID(c), c.Param("id"), entries) + return c.JSON(http.StatusOK, messageList(h.svc.Principal(userID(c)), entries)) +} + +// attachmentsOf maps the files of a message to the API's shape. +func attachmentsOf(atts []session.Attachment) []schema.ChatsAttachment { + if len(atts) == 0 { + return nil + } + out := make([]schema.ChatsAttachment, len(atts)) + for i, a := range atts { + out[i] = schema.ChatsAttachment{Path: a.Path, Name: a.Name, Size: a.Size, Mime: a.Mime, Version: a.Version, Missing: a.Missing} + } + return out +} + +// pendingOf is a waiting message as the person reads it. It holds the text +// the model will get, so the files are split out of it. +func pendingOf(p store.Pending) schema.ChatsPending { + text, _, atts := session.SplitMessage(p.Text) + return schema.ChatsPending{ID: p.ID, Text: text, Attachments: attachmentsOf(atts), Seq: p.Seq} +} + +// homePaths turns the workspace paths of a checkpoint line into paths inside +// the user's home, one a line, and drops the others. +func homePaths(principal, text string) string { + home := session.HomePath(principal) + var out []string + for p := range strings.SplitSeq(text, "\n") { + if rel, ok := strings.CutPrefix(p, home+"/"); ok && rel != "" { + out = append(out, "/"+rel) + } + } + return strings.Join(out, "\n") +} + +func messageList(principal string, entries []session.Entry) schema.ChatsMessageList { + out := schema.ChatsMessageList{Messages: make([]schema.ChatsMessage, 0, len(entries))} + for _, e := range entries { + // A user line shows the words; the files go apart, also for a line + // stored with the list of files in its text. + e = session.DisplayEntry(e) + m := schema.ChatsMessage{ + Seq: e.Seq, Turn: e.Turn, Role: e.Role, Text: e.Text, Attachments: attachmentsOf(e.Attachments), + DurMs: e.Dur.Milliseconds(), At: e.At, + } + if e.Loop != nil { + m.Loop = &schema.ChatsLoopMark{ID: e.Loop.ID, Run: e.Loop.Run} + } + if e.Card != nil { + m.App = appJSON(e.Card.App, true) + m.Card = toolCardJSON(e.Card) + } + if e.Role == session.RoleCheckpoint { + m.Rev, m.Text = e.Rev, homePaths(principal, e.Text) + } + if e.Invoked != nil { + m.Invoked = &schema.ChatsInvocation{Kind: e.Invoked.Kind, Name: e.Invoked.Name, ID: e.Invoked.ID} + } + if e.Role == session.RoleCompacted { + m.Before, m.After, _ = session.ParseCompacted(e.Text) + } + if e.Role == session.RoleGoal { + m.GoalEvent, m.GoalTurn = e.Note, e.Rev + } + out.Messages = append(out.Messages, m) + } + return out +} + +// Send gives the conversation a message. +// @Summary Send a message to a chat. +// @Description With no turn running the message starts one. With a turn running it waits and reaches the model, with any others, at its next request. +// @Tags chats +// @Param id path string true "conversation id" +// @Description Send JSON {"text"}, or multipart/form-data with a text field and files to attach. Files are saved in the chat's folder of your workspace. With a turn running the files are saved at once and the message waits like any other, with their paths in its text. With skill (a skill of the chat) or agent (a saved agent, when the chat has saved agents on) the message runs with that command; such a message does not wait for a running turn (409). An unknown skill or agent is a 400 that lists what the chat has. +// @Accept json,mpfd +// @Param request body schema.ChatsSendRequest true "the message" +// @Param files formData file false "files to attach (multipart)" +// @Success 200 {object} schema.ChatsSendResponse "Whether a turn started, or the id of the waiting message" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/{id}/messages [post] +func (h *Handlers) Send(c echo.Context) error { + var ( + text string + up []session.Upload + refs []schema.ChatsMCPRef + cmd studiosvc.Command + ) + if strings.HasPrefix(c.Request().Header.Get("Content-Type"), "multipart/form-data") { + c.Request().Body = http.MaxBytesReader(c.Response(), c.Request().Body, maxAttachBytes) + form, err := c.MultipartForm() + if err != nil { + if tooLarge := (*http.MaxBytesError)(nil); errors.As(err, &tooLarge) { + return fail(c, http.StatusRequestEntityTooLarge, "invalid_request_error", "a message with attachments is at most 1 GiB") + } + return fail(c, http.StatusBadRequest, "invalid_request_error", "the attachments could not be read") + } + defer func() { _ = form.RemoveAll() }() + if v := form.Value["text"]; len(v) > 0 { + text = v[0] + } + if v := form.Value["skill"]; len(v) > 0 { + cmd.Skill = v[0] + } + if v := form.Value["agent"]; len(v) > 0 { + cmd.Agent = v[0] + } + if v := form.Value["context"]; len(v) > 0 { + var err error + if refs, err = parseContext(v[0]); err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "context must be a JSON list of prompts and resources") + } + } + for _, fh := range form.File["files"] { + f, err := fh.Open() + if err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "an attachment could not be read") + } + defer func() { _ = f.Close() }() + up = append(up, session.Upload{Name: fh.Filename, Body: f}) + } + } else { + var req schema.ChatsSendRequest + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "text is required") + } + text, refs = req.Text, req.Context + cmd = studiosvc.Command{Skill: req.Skill, Agent: req.Agent} + } + cmd.Skill, cmd.Agent = strings.TrimSpace(cmd.Skill), strings.TrimSpace(cmd.Agent) + if text == "" && len(up) == 0 && len(refs) == 0 && cmd.Skill == "" { + return fail(c, http.StatusBadRequest, "invalid_request_error", "text is required") + } + var err error + if text, err = h.svc.WithMCPContext(c.Request().Context(), userID(c), c.Param("id"), text, mcpRefs(refs)); err != nil { + return failMCPContent(c, err) + } + res, err := h.svc.SendCommand(c.Request().Context(), userID(c), c.Param("id"), text, up, cmd) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsSendResponse{Started: res.Started, PendingID: res.PendingID}) +} + +// EditPending changes a waiting message. +// @Summary Edit a message that waits for the model. +// @Tags chats +// @Param id path string true "conversation id" +// @Param pid path string true "pending message id" +// @Param request body schema.ChatsPendingEdit true "the new text" +// @Success 200 {object} map[string]bool "updated" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/{id}/pending/{pid} [patch] +func (h *Handlers) EditPending(c echo.Context) error { + var req schema.ChatsPendingEdit + if err := c.Bind(&req); err != nil || req.Text == "" { + return fail(c, http.StatusBadRequest, "invalid_request_error", "text is required") + } + if err := h.svc.EditPending(c.Request().Context(), userID(c), c.Param("id"), c.Param("pid"), req.Text); err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, map[string]bool{"updated": true}) +} + +// CancelPending removes a waiting message. +// @Summary Cancel a message that waits for the model. +// @Tags chats +// @Param id path string true "conversation id" +// @Param pid path string true "pending message id" +// @Success 200 {object} map[string]bool "cancelled" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/{id}/pending/{pid} [delete] +func (h *Handlers) CancelPending(c echo.Context) error { + if err := h.svc.CancelPending(c.Request().Context(), userID(c), c.Param("id"), c.Param("pid")); err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, map[string]bool{"cancelled": true}) +} + +// SendNow starts a turn from the waiting messages. +// @Summary Send the waiting messages now. +// @Description After a stop or a failed turn the waiting messages stay put. This starts a turn from them. +// @Tags chats +// @Param id path string true "conversation id" +// @Success 200 {object} map[string]bool "sent" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/{id}/send-now [post] +func (h *Handlers) SendNow(c echo.Context) error { + if err := h.svc.SendNow(c.Request().Context(), userID(c), c.Param("id")); err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, map[string]bool{"sent": true}) +} + +// Stop ends the running turn. +// @Summary Stop the running turn. +// @Tags chats +// @Param id path string true "conversation id" +// @Success 200 {object} map[string]bool "stopped" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/{id}/stop [post] +func (h *Handlers) Stop(c echo.Context) error { + if err := h.svc.Stop(c.Request().Context(), userID(c), c.Param("id")); err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, map[string]bool{"stopped": true}) +} + +// Events streams the conversation's live events. +// @Summary Stream a chat's events. +// @Description Server-sent events: text, reasoning, tool_start, tool_end, checkpoint, turn_start, turn_end, error, pending_delivered, sub_agent_done, compacted (the context was compacted, by the chat or by POST compact; before and after are token counts), cleared (the context was cleared; read the transcript again), paused (the tool loop reached its step limit, chats_max_iterations; steps is the limit), goal (the chat's goal changed; text is its state, empty when cleared: read the chat again), files and loops (the loops of the chat changed; read GET /api/chats/{id}/loops). Events are live only; read the transcript for what came before. +// @Tags chats +// @Param id path string true "conversation id" +// @Produce text/event-stream +// @Success 200 {object} schema.ChatsEvent "An event stream" +// @Failure 404 {object} schema.ErrorResponse "No such conversation, or it is another user's" +// @Router /api/chats/{id}/events [get] +func (h *Handlers) Events(c echo.Context) error { + events, cancel, err := h.svc.Events(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failFor(c, err) + } + defer cancel() + + return streamSSE(c, events, func(ev session.Event) (string, any) { return string(ev.Kind), eventJSON(ev) }) +} + +func eventJSON(ev session.Event) schema.ChatsEvent { + out := schema.ChatsEvent{Kind: string(ev.Kind), Turn: ev.Turn, Text: ev.Text, IDs: ev.IDs, Before: ev.Before, After: ev.After, Steps: ev.Steps, + Code: ev.Code, Model: ev.Model, Retry: ev.Retry, Detail: ev.Detail} + if u := ev.Usage; u != nil { + out.Usage = &schema.ChatsUsage{ + Used: u.Used, Limit: u.Limit, Completion: u.Completion, TokensPerSecond: u.TokensPerSecond, + Model: u.Model, Node: u.Node, PromptMs: u.PromptMillis, + } + } + if ev.Tool != nil { + out.Tool = &schema.ChatsToolEvent{Name: ev.Tool.Name, Summary: ev.Tool.Summary, OK: ev.Tool.OK, Output: ev.Tool.Output, Seq: ev.Tool.Seq} + if ev.Tool.Card != nil { + out.Tool.App = appJSON(ev.Tool.Card.App, false) + out.Tool.Card = toolCardJSON(ev.Tool.Card) + } + } + return out +} + +func intParam(c echo.Context, name string, def, max int) int { + n, err := strconv.Atoi(c.QueryParam(name)) + if err != nil || n <= 0 { + return def + } + if n > max { + return max + } + return n +} + +// toolCardJSON is the card of a tool line as the API gives it. +func toolCardJSON(c *session.ToolCard) *schema.ChatsToolCard { + out := &schema.ChatsToolCard{Kind: c.Kind, ID: c.ID, FilesMore: c.FilesMore, FileKind: c.FileKind} + for _, f := range c.Files { + out.Files = append(out.Files, schema.ChatsToolFile{Path: f.Path, Rev: f.Rev, Op: f.Op, Kind: f.Kind}) + } + return out +} diff --git a/core/http/endpoints/studio/studio_suite_test.go b/core/http/endpoints/studio/studio_suite_test.go new file mode 100644 index 000000000..2f9f2d804 --- /dev/null +++ b/core/http/endpoints/studio/studio_suite_test.go @@ -0,0 +1,13 @@ +package studio + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestStudioEndpoints(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Studio endpoints") +} diff --git a/core/http/endpoints/studio/subagents.go b/core/http/endpoints/studio/subagents.go new file mode 100644 index 000000000..d10cc0982 --- /dev/null +++ b/core/http/endpoints/studio/subagents.go @@ -0,0 +1,66 @@ +package studio + +import ( + "net/http" + "strconv" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" +) + +// SubAgents lists the sub-agent runs of a chat. +// @Summary List the sub-agents of a chat. +// @Description Each sub-agent run is its own conversation. This lists the runs of one chat with their state, from the shared store, so runs started on any frontend show. A chat runs at most background_limit in the background at once. Live starts and ends arrive as sub_agent_started and sub_agent_done events. +// @Tags chats +// @Param id path string true "conversation id" +// @Success 200 {object} schema.ChatsSubAgentList "The sub-agent runs" +// @Router /api/chats/{id}/agents [get] +func (h *Handlers) SubAgents(c echo.Context) error { + runs, err := h.svc.SubAgents(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failFor(c, err) + } + out := schema.ChatsSubAgentList{SubAgents: make([]schema.ChatsSubAgent, 0, len(runs)), BackgroundLimit: h.svc.SubAgentLimit()} + for _, r := range runs { + if r.Turn.Running { + out.Running++ + } + out.SubAgents = append(out.SubAgents, schema.ChatsSubAgent{ID: r.Chat.ID, Title: r.Chat.Title, Created: r.Chat.Created, + Updated: r.Chat.Updated, Running: r.Turn.Running, LastTurn: string(r.Turn.Last)}) + } + return c.JSON(http.StatusOK, out) +} + +// SubAgentMessages reads the transcript of a sub-agent run. +// @Summary Read a sub-agent's transcript. +// @Tags chats +// @Param id path string true "conversation id of the parent" +// @Param agent_id path string true "sub-agent run id" +// @Param after query int false "only lines after this sequence number" +// @Param limit query int false "most lines to return (default 200, at most 1000)" +// @Success 200 {object} schema.ChatsMessageList "Transcript lines in order" +// @Router /api/chats/{id}/agents/{agent_id}/messages [get] +func (h *Handlers) SubAgentMessages(c echo.Context) error { + after, _ := strconv.ParseInt(c.QueryParam("after"), 10, 64) + entries, err := h.svc.SubAgentMessages(c.Request().Context(), userID(c), c.Param("id"), c.Param("agent_id"), after, intParam(c, "limit", defaultMessages, maxMessages)) + if err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, messageList(h.svc.Principal(userID(c)), entries)) +} + +// StopSubAgent stops a sub-agent run. +// @Summary Stop a sub-agent. +// @Description Ends the run's turn, wherever it runs. The parent goes on and sees a failed sub-agent. +// @Tags chats +// @Param id path string true "conversation id of the parent" +// @Param agent_id path string true "sub-agent run id" +// @Success 200 {object} map[string]bool "stopped" +// @Router /api/chats/{id}/agents/{agent_id}/stop [post] +func (h *Handlers) StopSubAgent(c echo.Context) error { + if err := h.svc.StopSubAgent(c.Request().Context(), userID(c), c.Param("id"), c.Param("agent_id")); err != nil { + return failFor(c, err) + } + return c.JSON(http.StatusOK, map[string]bool{"stopped": true}) +} diff --git a/core/http/endpoints/studio/triggers.go b/core/http/endpoints/studio/triggers.go new file mode 100644 index 000000000..6d3db8e54 --- /dev/null +++ b/core/http/endpoints/studio/triggers.go @@ -0,0 +1,542 @@ +package studio + +import ( + "context" + "net/http" + "time" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" + studiosvc "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/triggers" +) + +func (h *Handlers) triggerHost(c echo.Context) (*studiosvc.Triggers, bool) { + return feature(c, h.svc.Triggers(), func(c echo.Context) error { return failTrigger(c, studiosvc.ErrTriggersOff) }) +} + +func utcPtr(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + u := t.UTC() + return &u +} + +func runOut(host *studiosvc.Triggers, user string, r triggers.Run) schema.ChatsTriggerRun { + file := r.File + if file != "" { + file = host.RelPath(user, file) + } + return schema.ChatsTriggerRun{ID: r.ID, Status: string(r.Status), Reason: r.Reason, ChatID: r.ChatID, File: file, + Due: r.Due.UTC(), Started: utcPtr(r.Started), Ended: utcPtr(r.Ended), Tokens: r.Tokens} +} + +// costRuns is how many recent runs the average cost of a trigger reads. +const costRuns = 10 + +func triggerOut(ctx context.Context, host *studiosvc.Triggers, t triggers.Trigger, withLast bool) schema.ChatsTrigger { + out := schema.ChatsTrigger{ID: t.ID, Name: t.Name, Prompt: t.Prompt, Kind: string(t.Kind), Schedule: t.Schedule, TimeZone: t.TZ, + Folder: host.RelFolder(t), QuietSeconds: int(t.Quiet / time.Second), DailyCap: t.CapOrDefault(), Enabled: t.Enabled, + AgentID: t.AgentID, Model: t.Model, SourceChatID: t.SourceChatID, Summary: host.Summary(t), + Plan: schema.ChatsTriggerPlan{Reads: t.Reads, Writes: t.Writes, Widget: t.Widget}, + Cost: schema.ChatsTriggerCost{EstimatedTokens: t.EstTokens, MaxRunsPerDay: host.MaxRunsPerDay(t), Remote: host.RemoteModel(t.Model)}} + if t.Kind == triggers.KindFile { + out.MinGapSeconds = int(t.GapOrDefault() / time.Second) + } + if next, ok := host.NextRun(t); ok { + out.NextRun = utcPtr(next) + } + if withLast { + if runs, err := host.TriggerRuns(ctx, t.Owner, t.ID, costRuns); err == nil && len(runs) > 0 { + r := runOut(host, t.Owner, runs[0]) + out.LastRun = &r + sum := 0 + for _, run := range runs { + if run.Status == triggers.StatusDone && out.LastDone == nil { + out.LastDone = utcPtr(run.Ended) + } + if run.Status.Final() && run.Tokens > 0 { + sum += run.Tokens + out.Cost.Runs++ + } + } + if out.Cost.Runs > 0 { + out.Cost.AverageTokens = sum / out.Cost.Runs + } + } + } + return out +} + +func triggerIn(req schema.ChatsTriggerWrite) studiosvc.TriggerInput { + in := studiosvc.TriggerInput{Name: req.Name, Prompt: req.Prompt, Kind: triggers.Kind(req.Kind), Schedule: req.Schedule, TZ: req.TimeZone, + Folder: req.Folder, Quiet: time.Duration(req.QuietSeconds) * time.Second, DailyCap: req.DailyCap, + AgentID: req.AgentID, Model: req.Model, SourceChatID: req.SourceChatID, Enabled: req.Enabled} + in.MinGap, in.EstTokens = time.Duration(req.MinGapSeconds)*time.Second, req.EstimatedTokens + if req.Plan != nil { + in.Reads, in.Writes, in.Widget = req.Plan.Reads, req.Plan.Writes, req.Plan.Widget + } + return in +} + +var triggerRules = []errRule{ + fixed(http.StatusNotFound, typeNotFound, "trigger not found", triggers.ErrNotFound), + verbatim(http.StatusBadRequest, typeInvalid, studiosvc.ErrBadTrigger.Error()+": ", studiosvc.ErrBadTrigger), + verbatim(http.StatusConflict, typeConflict, "", studiosvc.ErrTooManyTriggers, studiosvc.ErrTooManyProposals), + verbatim(http.StatusConflict, typeConflict, "studio: ", studiosvc.ErrTooManyChanges, studiosvc.ErrChangeStale), + sentinelText(http.StatusConflict, typeConflict, studiosvc.ErrCannotUndo), + verbatim(http.StatusConflict, typeConflict, "studio: ", studiosvc.ErrProposalAnswered), + verbatim(http.StatusConflict, typeTriggersOff, "", studiosvc.ErrTriggersOff), + fixed(http.StatusServiceUnavailable, typeServerError, "the server is shutting down", studiosvc.ErrTriggersClosed), +} + +// failTrigger maps the errors of the trigger service. +func failTrigger(c echo.Context, err error) error { return mapErr(c, err, triggerRules, failFor) } + +// ListTriggers lists the triggers. +// @Summary List your triggers. +// @Description A trigger starts a chat as you, by itself, on a schedule or when a file arrives in a folder of your files. Each run is an ordinary chat, marked with its trigger, that you can open and undo. +// @Tags chats +// @Success 200 {object} schema.ChatsTriggerList "Your triggers, sorted by name" +// @Router /api/chats/triggers [get] +func (h *Handlers) ListTriggers(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + list, err := host.ListTriggers(c.Request().Context(), userID(c)) + if err != nil { + return failTrigger(c, err) + } + out := schema.ChatsTriggerList{Triggers: make([]schema.ChatsTrigger, 0, len(list))} + for _, t := range list { + out.Triggers = append(out.Triggers, triggerOut(c.Request().Context(), host, t, true)) + } + return c.JSON(http.StatusOK, out) +} + +// CreateTrigger creates a trigger. +// @Summary Create a trigger. +// @Description kind is schedule (schedule: "daily 08:00", "weekdays 09:30", "weekly mon,thu 09:30", "monthly 15 08:00", "once 2026-12-24 18:00" or five cron fields; time_zone: an IANA name) or file (folder: a path inside your files). It runs nothing for the time before it was saved. At most 50 triggers. +// @Tags chats +// @Param request body schema.ChatsTriggerWrite true "the trigger" +// @Success 201 {object} schema.ChatsTrigger "The trigger" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Router /api/chats/triggers [post] +func (h *Handlers) CreateTrigger(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + var req schema.ChatsTriggerWrite + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "the body is not valid JSON") + } + t, err := host.CreateTrigger(c.Request().Context(), userID(c), triggerIn(req)) + if err != nil { + return failTrigger(c, err) + } + return c.JSON(http.StatusCreated, triggerOut(c.Request().Context(), host, t, false)) +} + +// GetTrigger reads a trigger. +// @Summary Read a trigger. +// @Tags chats +// @Param id path string true "the trigger's id" +// @Success 200 {object} schema.ChatsTrigger "The trigger" +// @Router /api/chats/triggers/{id} [get] +func (h *Handlers) GetTrigger(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + t, err := host.GetTrigger(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failTrigger(c, err) + } + return c.JSON(http.StatusOK, triggerOut(c.Request().Context(), host, t, true)) +} + +// UpdateTrigger replaces a trigger. +// @Summary Replace a trigger. +// @Description The body replaces every field. A change of when it runs, or switching it on, means it runs nothing for the time before the change. +// @Tags chats +// @Param id path string true "the trigger's id" +// @Param request body schema.ChatsTriggerWrite true "the trigger" +// @Success 200 {object} schema.ChatsTrigger "The trigger" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Router /api/chats/triggers/{id} [put] +func (h *Handlers) UpdateTrigger(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + var req schema.ChatsTriggerWrite + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "the body is not valid JSON") + } + t, err := host.UpdateTrigger(c.Request().Context(), userID(c), c.Param("id"), triggerIn(req)) + if err != nil { + return failTrigger(c, err) + } + return c.JSON(http.StatusOK, triggerOut(c.Request().Context(), host, t, true)) +} + +// DeleteTrigger deletes a trigger. +// @Summary Delete a trigger. +// @Description Its queued runs are dropped and a running run is stopped. The chats it made stay. +// @Tags chats +// @Param id path string true "the trigger's id" +// @Success 204 "Deleted" +// @Router /api/chats/triggers/{id} [delete] +func (h *Handlers) DeleteTrigger(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + if err := host.DeleteTrigger(c.Request().Context(), userID(c), c.Param("id")); err != nil { + return failTrigger(c, err) + } + return c.NoContent(http.StatusNoContent) +} + +func (h *Handlers) setEnabled(c echo.Context, on bool) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + t, err := host.SetTriggerEnabled(c.Request().Context(), userID(c), c.Param("id"), on) + if err != nil { + return failTrigger(c, err) + } + return c.JSON(http.StatusOK, triggerOut(c.Request().Context(), host, t, true)) +} + +// EnableTrigger switches a trigger on. +// @Summary Switch a trigger on. +// @Tags chats +// @Param id path string true "the trigger's id" +// @Success 200 {object} schema.ChatsTrigger "The trigger" +// @Router /api/chats/triggers/{id}/enable [post] +func (h *Handlers) EnableTrigger(c echo.Context) error { return h.setEnabled(c, true) } + +// DisableTrigger switches a trigger off. +// @Summary Switch a trigger off. +// @Tags chats +// @Param id path string true "the trigger's id" +// @Success 200 {object} schema.ChatsTrigger "The trigger" +// @Router /api/chats/triggers/{id}/disable [post] +func (h *Handlers) DisableTrigger(c echo.Context) error { return h.setEnabled(c, false) } + +// RunTrigger starts a run now. +// @Summary Run a trigger now. +// @Description Starts a run at once, whether the trigger is on or not. The run is skipped, with its reason, when the trigger has a run going. On a cluster the run may start a few seconds later. +// @Tags chats +// @Param id path string true "the trigger's id" +// @Success 202 {object} schema.ChatsTriggerRun "The run" +// @Router /api/chats/triggers/{id}/run [post] +func (h *Handlers) RunTrigger(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + run, err := host.RunTrigger(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failTrigger(c, err) + } + return c.JSON(http.StatusAccepted, runOut(host, userID(c), run)) +} + +// TriggerRuns lists the runs of a trigger. +// @Summary List the runs of a trigger. +// @Tags chats +// @Param id path string true "the trigger's id" +// @Param limit query int false "most runs to return (default 20, at most 200)" +// @Success 200 {object} schema.ChatsTriggerRunList "Newest first" +// @Router /api/chats/triggers/{id}/runs [get] +func (h *Handlers) TriggerRuns(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + runs, err := host.TriggerRuns(c.Request().Context(), userID(c), c.Param("id"), intParam(c, "limit", 20, 200)) + if err != nil { + return failTrigger(c, err) + } + out := schema.ChatsTriggerRunList{Runs: make([]schema.ChatsTriggerRun, 0, len(runs))} + for _, r := range runs { + out.Runs = append(out.Runs, runOut(host, userID(c), r)) + } + return c.JSON(http.StatusOK, out) +} + +// PreviewTrigger shows the next times of a schedule. +// @Summary Preview a schedule. +// @Description Returns the next times a schedule text runs in a time zone, and the schedule in plain words. A bad text is a 400 with the reason. +// @Tags chats +// @Param when query string true "the schedule text" +// @Param tz query string false "an IANA time zone name (default UTC)" +// @Param n query int false "how many times (default 3, at most 10)" +// @Success 200 {object} schema.ChatsTriggerPreview "The next times" +// @Router /api/chats/triggers/preview [get] +func (h *Handlers) PreviewTrigger(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + times, err := host.PreviewTrigger(c.QueryParam("when"), c.QueryParam("tz"), intParam(c, "n", 3, 10)) + if err != nil { + return failTrigger(c, err) + } + sch, _ := triggers.ParseSchedule(c.QueryParam("when")) + out := schema.ChatsTriggerPreview{Times: make([]time.Time, len(times)), Summary: sch.String()} + for i, t := range times { + out.Times[i] = t.UTC() + } + return c.JSON(http.StatusOK, out) +} + +// ListTriggerProposals lists the proposals that wait for an answer. +// @Summary List trigger proposals. +// @Description A model in a chat can propose a trigger. A proposal is not active and runs nothing; confirm it to turn it into a trigger that is on. The chat shows it as a tool line with a card of kind trigger_proposal whose id is the proposal's id, and a trigger_proposed event announces it. +// @Tags chats +// @Param chat query string false "only the proposals made in this chat" +// @Success 200 {object} schema.ChatsTriggerList "The proposals" +// @Router /api/chats/triggers/proposals [get] +func (h *Handlers) ListTriggerProposals(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + list, err := host.Proposals(c.Request().Context(), userID(c)) + if err != nil { + return failTrigger(c, err) + } + out := schema.ChatsTriggerList{Triggers: make([]schema.ChatsTrigger, 0, len(list))} + chat := c.QueryParam("chat") + for _, t := range list { + if chat != "" && t.SourceChatID != chat { + continue + } + out.Triggers = append(out.Triggers, triggerOut(c.Request().Context(), host, t, false)) + } + return c.JSON(http.StatusOK, out) +} + +// ConfirmTriggerProposal confirms a proposal. +// @Summary Confirm a trigger proposal. +// @Description Turns the proposal into a trigger that is on. time_zone is used when the proposal names none. +// @Tags chats +// @Param id path string true "the proposal's id" +// @Param request body schema.ChatsTriggerConfirm false "the time zone of the person confirming" +// @Success 200 {object} schema.ChatsTrigger "The trigger" +// @Router /api/chats/triggers/proposals/{id}/confirm [post] +func (h *Handlers) ConfirmTriggerProposal(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + var req schema.ChatsTriggerConfirm + _ = c.Bind(&req) + t, err := host.ConfirmProposal(c.Request().Context(), userID(c), c.Param("id"), req.TimeZone) + if err != nil { + return failTrigger(c, err) + } + return c.JSON(http.StatusOK, triggerOut(c.Request().Context(), host, t, true)) +} + +// EditTriggerProposal changes a proposal. +// @Summary Edit a trigger proposal. +// @Description The body replaces every field of the proposal, as for a trigger. The proposal stays a proposal: it is not active until you confirm it. +// @Tags chats +// @Param id path string true "the proposal's id" +// @Param request body schema.ChatsTriggerWrite true "the new fields" +// @Success 200 {object} schema.ChatsTrigger "The proposal" +// @Router /api/chats/triggers/proposals/{id} [put] +func (h *Handlers) EditTriggerProposal(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + var req schema.ChatsTriggerWrite + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, "invalid_request_error", "the body is not valid JSON") + } + t, err := host.EditProposal(c.Request().Context(), userID(c), c.Param("id"), triggerIn(req)) + if err != nil { + return failTrigger(c, err) + } + return c.JSON(http.StatusOK, triggerOut(c.Request().Context(), host, t, true)) +} + +// DismissTriggerProposal declines a proposal. +// @Summary Dismiss a trigger proposal. +// @Description The proposal stays as a declined record (GET .../proposals/{id}) that .../reopen restores. +// @Tags chats +// @Param id path string true "the proposal's id" +// @Success 204 "Dismissed" +// @Router /api/chats/triggers/proposals/{id} [delete] +func (h *Handlers) DismissTriggerProposal(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + if err := host.DismissProposal(c.Request().Context(), userID(c), c.Param("id")); err != nil { + return failTrigger(c, err) + } + return c.NoContent(http.StatusNoContent) +} + +func proposalOut(ctx context.Context, host *studiosvc.Triggers, v studiosvc.ProposalView) schema.ChatsTriggerProposal { + return schema.ChatsTriggerProposal{State: string(v.State), Trigger: triggerOut(ctx, host, v.Trigger, false), + AnsweredAt: utcPtr(v.At), CanUndo: v.CanUndo, UndoBlocked: v.UndoBlocked} +} + +// GetTriggerProposal reads a proposal and what became of it. +// @Summary Read a trigger proposal and its answer. +// @Description Answers the state of a proposal the chat made: pending, accepted (the trigger is the one that accepting made, with the same id), declined or undone. The answer is kept on the server, so the chat shows the same receipt after a reload and on every frontend. 404 once the trigger was removed on the Triggers page. +// @Tags chats +// @Param id path string true "the proposal's id" +// @Success 200 {object} schema.ChatsTriggerProposal "The proposal" +// @Router /api/chats/triggers/proposals/{id} [get] +func (h *Handlers) GetTriggerProposal(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + v, err := host.ProposalStatus(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failTrigger(c, err) + } + return c.JSON(http.StatusOK, proposalOut(c.Request().Context(), host, v)) +} + +// UndoTriggerProposal takes back an accepted proposal. +// @Summary Undo an accepted trigger proposal. +// @Description Switches the trigger that accepting made off and removes it from the Triggers page; the proposal stays as an undone record that can be reopened. 409 when the trigger already ran or was edited or switched since (the message says so), or when the proposal is not accepted. +// @Tags chats +// @Param id path string true "the proposal's id" +// @Success 200 {object} schema.ChatsTriggerProposal "The proposal, now undone" +// @Router /api/chats/triggers/proposals/{id}/undo [post] +func (h *Handlers) UndoTriggerProposal(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + if _, err := host.UndoProposal(c.Request().Context(), userID(c), c.Param("id")); err != nil { + return failTrigger(c, err) + } + return h.GetTriggerProposal(c) +} + +// ReopenTriggerProposal makes a declined or undone proposal wait again. +// @Summary Restore a declined or undone trigger proposal. +// @Tags chats +// @Param id path string true "the proposal's id" +// @Success 200 {object} schema.ChatsTriggerProposal "The proposal, waiting again" +// @Router /api/chats/triggers/proposals/{id}/reopen [post] +func (h *Handlers) ReopenTriggerProposal(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + if _, err := host.ReopenProposal(c.Request().Context(), userID(c), c.Param("id")); err != nil { + return failTrigger(c, err) + } + return h.GetTriggerProposal(c) +} + +func changeOut(ctx context.Context, host *studiosvc.Triggers, v studiosvc.ChangeView) schema.ChatsTriggerChange { + out := schema.ChatsTriggerChange{ID: v.ID, Op: v.Op, State: string(v.State), TriggerID: v.Target, + Before: triggerOut(ctx, host, v.Before, false), Fields: v.Fields, AnsweredAt: utcPtr(v.At), + Stale: v.Stale, CanUndo: v.CanUndo, UndoBlocked: v.UndoBlocked} + if out.Fields == nil { + out.Fields = []string{} + } + if v.HasAfter { + a := triggerOut(ctx, host, v.After, false) + out.After = &a + } + if v.LastRun != nil { + r := runOut(host, v.Before.Owner, *v.LastRun) + out.LastRun = &r + } + return out +} + +// GetTriggerChange reads a change of a trigger and what became of it. +// @Summary Read a trigger change and its answer. +// @Description A model in a chat can ask to edit, pause, resume or delete one of your triggers (update_trigger, delete_trigger). An edit or a delete waits for you; a pause or a resume applies at once. The chat carries the change as a tool card of kind trigger_change whose id is the change's id. The answer is kept on the server, so the card says the same after a reload and on every frontend. 404 once the change is no longer kept (a week after its answer). +// @Tags chats +// @Param id path string true "the change's id" +// @Success 200 {object} schema.ChatsTriggerChange "The change" +// @Router /api/chats/triggers/changes/{id} [get] +func (h *Handlers) GetTriggerChange(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + v, err := host.ChangeStatus(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failTrigger(c, err) + } + return c.JSON(http.StatusOK, changeOut(c.Request().Context(), host, v)) +} + +// ApplyTriggerChange applies a pending change. +// @Summary Apply a trigger change. +// @Description Makes the edit or deletes the trigger. 409 when the trigger was changed or removed since the change was asked for, or when the change was answered already. +// @Tags chats +// @Param id path string true "the change's id" +// @Success 200 {object} schema.ChatsTriggerChange "The change, now applied" +// @Failure 409 {object} schema.ErrorResponse "The trigger changed since, or the change was answered" +// @Router /api/chats/triggers/changes/{id}/apply [post] +func (h *Handlers) ApplyTriggerChange(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + if _, err := host.ApplyChange(c.Request().Context(), userID(c), c.Param("id")); err != nil { + return failTrigger(c, err) + } + return h.GetTriggerChange(c) +} + +// DismissTriggerChange declines a pending change. +// @Summary Dismiss a trigger change. +// @Description The trigger stays as it is. The answer is kept, so the card shows it. +// @Tags chats +// @Param id path string true "the change's id" +// @Success 204 "Dismissed" +// @Router /api/chats/triggers/changes/{id} [delete] +func (h *Handlers) DismissTriggerChange(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + if err := host.DismissChange(c.Request().Context(), userID(c), c.Param("id")); err != nil { + return failTrigger(c, err) + } + return c.NoContent(http.StatusNoContent) +} + +// UndoTriggerChange takes back an applied change. +// @Summary Undo a trigger change. +// @Description Puts the trigger back as it was before the change, for a day after it applied: an edit, a pause or a resume is reverted, and a deleted trigger is restored with its id (its run history is not). 409 when the time is over, the trigger was changed since (the message says so), or you have as many triggers as you may. +// @Tags chats +// @Param id path string true "the change's id" +// @Success 200 {object} schema.ChatsTriggerChange "The change, now undone" +// @Failure 409 {object} schema.ErrorResponse "The change cannot be undone" +// @Router /api/chats/triggers/changes/{id}/undo [post] +func (h *Handlers) UndoTriggerChange(c echo.Context) error { + host, ok := h.triggerHost(c) + if !ok { + return nil + } + if _, err := host.UndoChange(c.Request().Context(), userID(c), c.Param("id")); err != nil { + return failTrigger(c, err) + } + return h.GetTriggerChange(c) +} diff --git a/core/http/endpoints/studio/web.go b/core/http/endpoints/studio/web.go new file mode 100644 index 000000000..c40e25019 --- /dev/null +++ b/core/http/endpoints/studio/web.go @@ -0,0 +1,48 @@ +package studio + +import ( + "errors" + "net/http" + "strings" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" + studiosvc "github.com/mudler/LocalAI/core/services/studio" +) + +// AdminTestWebSearch runs one web search with the saved settings. +// @Summary Run a test web search with the saved settings (administrators). +// @Description Runs the query with each search provider of the built-in web_search tool, in the order of chats_web_search_providers (by default DuckDuckGo, Brave Search and Bing without a key, or the SearXNG instance of chats_web_searxng_url), and returns what each did (ok, no_results, challenged, blocked, timeout or error), the first three results of the first answer, or the error a user would read. Save the settings first. The test counts as one search against the rate limits. +// @Tags chats +// @Param request body schema.ChatsWebTestRequest true "the query (default LocalAI)" +// @Success 200 {object} schema.ChatsWebTestResponse "The provider and its results, or the error" +// @Failure 400 {object} schema.ErrorResponse "The request is not valid" +// @Failure 403 {object} schema.ErrorResponse "Not an administrator" +// @Failure 503 {object} schema.ErrorResponse "The built-in web tools are not available on this instance" +// @Router /api/chats/admin/web/test [post] +func (h *Handlers) AdminTestWebSearch(c echo.Context) error { + var req schema.ChatsWebTestRequest + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, typeInvalid, "the body must be a JSON object") + } + q := strings.TrimSpace(req.Query) + if q == "" { + q = "LocalAI" + } + provider, host, results, attempts, err := h.svc.TestWebSearch(c.Request().Context(), userID(c), q) + if errors.Is(err, studiosvc.ErrWebOff) { + return fail(c, http.StatusServiceUnavailable, "unavailable", "the built-in web tools are not available on this instance") + } + out := schema.ChatsWebTestResponse{OK: err == nil, Provider: provider, Host: host, Results: make([]schema.ChatsWebResult, 0, len(results)), Providers: make([]schema.ChatsWebProviderTest, 0, len(attempts))} + for _, a := range attempts { + out.Providers = append(out.Providers, schema.ChatsWebProviderTest{Name: a.Provider, Host: a.Host, Outcome: string(a.Outcome), Results: a.Results, Reason: a.Reason}) + } + if err != nil { + out.Error = err.Error() + } + for _, r := range results { + out.Results = append(out.Results, schema.ChatsWebResult{Title: r.Title, URL: r.URL, Snippet: r.Snippet}) + } + return c.JSON(http.StatusOK, out) +} diff --git a/core/http/endpoints/studio/wellknown.go b/core/http/endpoints/studio/wellknown.go new file mode 100644 index 000000000..00bd2710b --- /dev/null +++ b/core/http/endpoints/studio/wellknown.go @@ -0,0 +1,262 @@ +package studio + +import ( + "errors" + "maps" + "net/http" + "strconv" + "strings" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/http/auth" + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/studio/files" + "github.com/mudler/LocalAI/core/services/studio/wellknown" +) + +// The well-known files endpoints read and write the files of the user's home +// that change how LocalAI looks and behaves for that user. Every file has a +// JSON Schema; a read validates, a write is refused when the result breaks it. + +// wellKnownRules maps the errors of the well-known files service. The other +// errors are the workspace's (fileRules). +var wellKnownRules = []errRule{ + fixed(http.StatusNotFound, typeNotFound, "there is no such well-known file", wellknown.ErrUnknown), + fixed(http.StatusBadRequest, typeInvalid, "path must name a .json file in your home", wellknown.ErrBadPath), + fixed(http.StatusRequestEntityTooLarge, typeInvalid, "the file would be larger than 64 KiB", wellknown.ErrTooLarge), + fixed(http.StatusConflict, typeConflict, "the history has no earlier valid version", wellknown.ErrNoPrevious), +} + +func failWellKnown(c echo.Context, err error) error { + var inv *wellknown.InvalidError + if errors.As(err, &inv) { + return c.JSON(http.StatusBadRequest, schema.ChatsWellKnownError{ + Error: &schema.APIError{Message: "the file does not match its schema: " + strings.Join(inv.Errors, "; "), Code: http.StatusBadRequest, Type: typeInvalid}, + Errors: inv.Errors, + }) + } + var cf *wellknown.ConflictError + if errors.As(err, &cf) { + out := schema.ChatsWellKnownError{Error: &schema.APIError{Message: "the file changed since you read it", Code: http.StatusConflict, Type: typeConflict}} + if cf.Current.Name != "" { + cur := docOut(c, cf.Current) + out.Current = &cur + } + return c.JSON(http.StatusConflict, out) + } + return mapErr(c, err, wellKnownRules, failFile) +} + +// docOut is the answer for a document. user.json gets the account's name when +// it has none, marked as prefilled, so the settings page shows where it came from. +func docOut(c echo.Context, d wellknown.Doc) schema.ChatsWellKnownFile { + out := schema.ChatsWellKnownFile{ + Name: string(d.Name), Path: d.Path, Exists: d.Exists, Version: d.Version, Rev: d.Rev, Data: d.Data, + Valid: d.Valid, Errors: d.Errors, Fallback: d.Fallback, FallbackRev: d.FallbackRev, + } + if out.Data == nil { + out.Data = map[string]any{} + } + if u := auth.GetUser(c); d.Name == wellknown.User && u != nil && strings.TrimSpace(u.Name) != "" { + if name, _ := out.Data["name"].(string); name == "" { + data := make(map[string]any, len(out.Data)+1) + maps.Copy(data, out.Data) + data["name"] = u.Name + out.Data, out.Prefilled = data, map[string]string{"name": "account"} + } + } + return out +} + +// wellKnown resolves the name of the route. On failure it has answered. +func (h *Handlers) wellKnown(c echo.Context) (fileCall, wellknown.Name, bool) { + fc, ok := h.files(c) + if !ok { + return fileCall{}, "", false + } + n, err := wellknown.Parse(c.Param("name")) + if err != nil { + _ = failWellKnown(c, err) + return fileCall{}, "", false + } + return fc, n, true +} + +// ifMatch reads the version the client read from If-Match ("12", W/"12" or 12). +// It is nil when the header is absent. +func ifMatch(c echo.Context) (*int64, bool) { + v := strings.TrimSpace(c.Request().Header.Get("If-Match")) + if v == "" { + return nil, true + } + v = strings.Trim(strings.TrimPrefix(v, "W/"), `"`) + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n < 0 { + return nil, false + } + return &n, true +} + +func (h *Handlers) answerDoc(c echo.Context, d wellknown.Doc) error { + c.Response().Header().Set("ETag", strconv.Quote(strconv.FormatInt(d.Version, 10))) + c.Response().Header().Set("Cache-Control", "no-store") + return c.JSON(http.StatusOK, docOut(c, d)) +} + +// GetWellKnown reads a well-known file. +// @Summary Read a well-known file of your home. +// @Description name is persona, user, shortcuts, home, ui or widget-data. The file is validated against its JSON Schema. When it is not valid, data holds its last valid version from the history and errors says what is wrong. A missing file is an empty object with exists false. widget-data needs path, the data file of a Home widget. +// @Tags chats +// @Param name path string true "persona, user, shortcuts, home, ui or widget-data" +// @Param path query string false "the data file, for widget-data" +// @Success 200 {object} schema.ChatsWellKnownFile "The file; the ETag header holds its version" +// @Failure 404 {object} schema.ErrorResponse "Not a well-known file" +// @Failure 409 {object} schema.ErrorResponse "Files are off on this server" +// @Router /api/chats/wellknown/{name} [get] +func (h *Handlers) GetWellKnown(c echo.Context) error { + fc, n, ok := h.wellKnown(c) + if !ok { + return nil + } + d, err := h.svc.WellKnown().Read(c.Request().Context(), fc.p, n, c.QueryParam("path")) + if err != nil { + return failWellKnown(c, err) + } + return h.answerDoc(c, d) +} + +// PutWellKnown changes a well-known file. +// @Summary Change a well-known file of your home. +// @Description The keys of values replace the keys of the file and a null removes one; keys the request does not name stay as they are. With replace, values is the whole file. The result must match the JSON Schema of the file and be at most 64 KiB. Send the version you read in If-Match (0 when the file did not exist): when the file changed since, the answer is 409 with what it holds now. Without If-Match the write is not checked. +// @Tags chats +// @Param name path string true "persona, user, shortcuts, home, ui or widget-data" +// @Param path query string false "the data file, for widget-data" +// @Param If-Match header string false "the version you read" +// @Param request body schema.ChatsWellKnownWrite true "the keys to change" +// @Success 200 {object} schema.ChatsWellKnownFile "The file as written" +// @Failure 400 {object} schema.ChatsWellKnownError "The result does not match the schema; errors lists why" +// @Failure 409 {object} schema.ChatsWellKnownError "The file changed since you read it; current holds it" +// @Failure 413 {object} schema.ErrorResponse "The file would be larger than 64 KiB" +// @Router /api/chats/wellknown/{name} [put] +func (h *Handlers) PutWellKnown(c echo.Context) error { + fc, n, ok := h.wellKnown(c) + if !ok { + return nil + } + base, ok := ifMatch(c) + if !ok { + return fail(c, http.StatusBadRequest, typeInvalid, "If-Match must be a version number") + } + var req schema.ChatsWellKnownWrite + if err := c.Bind(&req); err != nil { + return fail(c, http.StatusBadRequest, typeInvalid, msgNotJSON) + } + if req.Values == nil { + return fail(c, http.StatusBadRequest, typeInvalid, "values is required") + } + d, err := h.svc.WellKnown().Write(c.Request().Context(), fc.p, wellknown.WriteRequest{ + Name: n, Path: c.QueryParam("path"), Values: req.Values, Replace: req.Replace, Base: base, + }) + if err != nil { + return failWellKnown(c, err) + } + return h.answerDoc(c, d) +} + +// RevertWellKnown goes back to an earlier version of a well-known file. +// @Summary Revert a well-known file to an earlier valid version. +// @Description Without a body, writes the newest earlier version that differs from the file and matches its schema. With {"rev"}, writes that version from GET .../versions. Either way it is a new change: the revert itself can be undone, and the newer version stays in the list. +// @Tags chats +// @Param name path string true "persona, user, shortcuts, home, ui or widget-data" +// @Param path query string false "the data file, for widget-data" +// @Param request body schema.ChatsWellKnownRevert false "the version to write back" +// @Success 200 {object} schema.ChatsWellKnownFile "The file as written" +// @Failure 409 {object} schema.ErrorResponse "There is no such valid version" +// @Router /api/chats/wellknown/{name}/revert [post] +func (h *Handlers) RevertWellKnown(c echo.Context) error { + fc, n, ok := h.wellKnown(c) + if !ok { + return nil + } + var req schema.ChatsWellKnownRevert + if c.Request().ContentLength > 0 { + if err := c.Bind(&req); err != nil || req.Rev < 0 { + return fail(c, http.StatusBadRequest, typeInvalid, msgNotJSON) + } + } + var ( + d wellknown.Doc + err error + ) + if req.Rev > 0 { + d, err = h.svc.WellKnown().RevertTo(c.Request().Context(), fc.p, n, c.QueryParam("path"), req.Rev) + } else { + d, err = h.svc.WellKnown().Revert(c.Request().Context(), fc.p, n, c.QueryParam("path")) + } + if err != nil { + return failWellKnown(c, err) + } + return h.answerDoc(c, d) +} + +// WellKnownVersions lists the versions of a well-known file. +// @Summary List the versions of a well-known file. +// @Description Newest first, from the history of your files: when, who (kind user or agent, and the chat that wrote it), and the content when it matches the schema. POST .../revert {rev} writes one back. +// @Tags chats +// @Param name path string true "persona, user, shortcuts, home, ui or widget-data" +// @Param path query string false "the data file, for widget-data" +// @Param limit query int false "most versions to return (default 10, at most 50)" +// @Success 200 {object} schema.ChatsWellKnownVersions "The versions, newest first" +// @Failure 404 {object} schema.ErrorResponse "Not a well-known file" +// @Router /api/chats/wellknown/{name}/versions [get] +func (h *Handlers) WellKnownVersions(c echo.Context) error { + fc, n, ok := h.wellKnown(c) + if !ok { + return nil + } + ctx := c.Request().Context() + vs, err := h.svc.WellKnown().Versions(ctx, fc.p, n, c.QueryParam("path"), intParam(c, "limit", 10, 50)) + if err != nil && !errors.Is(err, files.ErrNoVersions) { + return failWellKnown(c, err) + } + out := schema.ChatsWellKnownVersions{Versions: make([]schema.ChatsWellKnownVersion, 0, len(vs))} + titles := map[string]string{} + for _, v := range vs { + o := schema.ChatsWellKnownVersion{Rev: v.Rev, At: v.At, Kind: v.Kind, Chat: v.ChatID, Data: v.Data, Valid: v.Valid, Errors: v.Errors} + if v.ChatID != "" { + if _, seen := titles[v.ChatID]; !seen { + titles[v.ChatID] = "" + if rec, gerr := h.svc.Get(ctx, userID(c), v.ChatID); gerr == nil { + titles[v.ChatID] = rec.Title + } + } + o.ChatTitle = titles[v.ChatID] + } + out.Versions = append(out.Versions, o) + } + c.Response().Header().Set("Cache-Control", "no-store") + return c.JSON(http.StatusOK, out) +} + +// WellKnownSchema returns the JSON Schema of a well-known file. +// @Summary Get the JSON Schema of a well-known file. +// @Description The same schema is in the built-in skill localai-workspace, which the assistant reads. Properties may carry x-ui hints for the settings pages (widget, hidden). +// @Tags chats +// @Param name path string true "persona, user, shortcuts, home, ui or widget-data" +// @Produce application/schema+json +// @Success 200 {object} map[string]any "A JSON Schema, draft 2020-12" +// @Failure 404 {object} schema.ErrorResponse "Not a well-known file" +// @Router /api/chats/wellknown/{name}/schema [get] +func (h *Handlers) WellKnownSchema(c echo.Context) error { + n, err := wellknown.Parse(c.Param("name")) + if err != nil { + return failWellKnown(c, err) + } + data, err := wellknown.Schema(n) + if err != nil { + return failWellKnown(c, err) + } + c.Response().Header().Set("Cache-Control", "no-cache") + return c.Blob(http.StatusOK, "application/schema+json", data) +} diff --git a/core/http/endpoints/studio/workspacefeed.go b/core/http/endpoints/studio/workspacefeed.go new file mode 100644 index 000000000..fdd01cd37 --- /dev/null +++ b/core/http/endpoints/studio/workspacefeed.go @@ -0,0 +1,71 @@ +package studio + +import ( + "errors" + "net/http" + "strconv" + "time" + + "github.com/labstack/echo/v4" + + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/core/services/studio/wsfeed" +) + +// feedHeartbeat keeps idle proxies from closing the change feed. +const feedHeartbeat = 25 * time.Second + +// WorkspaceEvents streams the changes to the user's files. +// @Summary Stream the changes to your files. +// @Description Server-sent events: a "change" event when files in your home folder change, whoever changed them (you in another tab, a chat, a trigger, a connector, an API client, on any frontend), with the paths inside your home and the revision as the event id. Changes that arrive close together come as one event. A "resync" event means changes were left out (the client fell behind, or the resume point is too far back): read again what you show. Send the last id back as the Last-Event-ID header or the after query parameter to resume; without it the stream starts from now. A comment line is sent every 25 seconds. At most 8 streams per user. +// @Tags workspace +// @Produce text/event-stream +// @Param after query int false "resume after this revision" +// @Param Last-Event-ID header int false "resume after this revision (sent by a browser when it reconnects)" +// @Success 200 {object} schema.WorkspaceChange "An event stream" +// @Failure 400 {object} schema.ErrorResponse "The resume point is not a number" +// @Failure 409 {object} schema.ErrorResponse "Files are off on this server" +// @Failure 429 {object} schema.ErrorResponse "Too many streams for this user" +// @Router /api/workspace/events [get] +func (h *Handlers) WorkspaceEvents(c echo.Context) error { + if _, ok := h.files(c); !ok { + return nil + } + after, resume, err := resumePoint(c) + if err != nil { + return fail(c, http.StatusBadRequest, typeInvalid, "the resume point must be a revision number") + } + ch, cancel, err := h.svc.SubscribeWorkspace(c.Request().Context(), userID(c), after, resume) + if errors.Is(err, wsfeed.ErrTooManySubscribers) { + return fail(c, http.StatusTooManyRequests, "rate_limit_error", "too many open change streams; close one first") + } + if err != nil { + return failFile(c, err) + } + defer cancel() + return streamSSEWithIDs(c, ch, feedHeartbeat, func(ev wsfeed.Event) (string, string, any) { + id := strconv.FormatInt(ev.Revision, 10) + if ev.Resync { + return id, "resync", schema.WorkspaceChange{Revision: ev.Revision} + } + return id, "change", schema.WorkspaceChange{Revision: ev.Revision, Paths: ev.Paths, Kinds: ev.Kinds} + }) +} + +// resumePoint reads the revision to resume after: the Last-Event-ID header a +// browser sends when it reconnects by itself, else the after parameter, which +// a client sets when it opens a new stream. +func resumePoint(c echo.Context) (int64, bool, error) { + v := c.Request().Header.Get("Last-Event-ID") + if v == "" { + v = c.QueryParam("after") + } + if v == "" { + return 0, false, nil + } + n, err := strconv.ParseInt(v, 10, 64) + if err != nil || n < 0 { + return 0, false, errors.New("bad revision") + } + return n, true, nil +} diff --git a/core/http/middleware/compress.go b/core/http/middleware/compress.go index 7fdd59643..f27d16d66 100644 --- a/core/http/middleware/compress.go +++ b/core/http/middleware/compress.go @@ -39,14 +39,13 @@ var streamingPathPrefixes = []string{ "/audio/transcriptions", "/api/chat", "/api/generate", - "/api/agent/jobs", "/api/backend-logs", "/api/node-backend-logs", "/ws/", } // streamingPathSubstrings catches SSE bridges that carry a variable -// segment before the streaming suffix, e.g. /api/agents/:name/sse. +// segment before the streaming suffix, e.g. /api/chats/:id/events. var streamingPathSubstrings = []string{ "/sse", "/progress", diff --git a/core/http/model_load_cooldown_test.go b/core/http/model_load_cooldown_test.go new file mode 100644 index 000000000..d81a2fb22 --- /dev/null +++ b/core/http/model_load_cooldown_test.go @@ -0,0 +1,37 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "time" + + "github.com/labstack/echo/v4" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mudler/LocalAI/pkg/model" +) + +// A model whose last load failed is answered 503 with Retry-After, and a header +// that tells in-process callers the failure is not a passing overload. +var _ = Describe("A model in load cooldown", func() { + It("is marked as a failed load", func() { + rec := httptest.NewRecorder() + c := echo.New().NewContext(httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil), rec) + code := applyModelLoadCooldown(&model.ModelLoadCooldownError{ModelID: "m", RetryAfter: 20 * time.Second}, http.StatusInternalServerError, c) + Expect(code).To(Equal(http.StatusServiceUnavailable)) + Expect(rec.Header().Get(model.LoadFailedHeader)).To(Equal("true")) + }) + It("names a missing backend as the cause", func() { + rec := httptest.NewRecorder() + c := echo.New().NewContext(httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil), rec) + applyModelLoadCooldown(&model.ModelLoadCooldownError{ModelID: "m", RetryAfter: 20 * time.Second, BackendMissing: true}, http.StatusInternalServerError, c) + Expect(rec.Header().Get(model.LoadCauseHeader)).To(Equal(model.LoadCauseBackendMissing)) + }) + It("leaves other errors unmarked", func() { + rec := httptest.NewRecorder() + c := echo.New().NewContext(httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil), rec) + Expect(applyModelLoadCooldown(http.ErrAbortHandler, http.StatusInternalServerError, c)).To(Equal(http.StatusInternalServerError)) + Expect(rec.Header().Get(model.LoadFailedHeader)).To(BeEmpty()) + }) +}) diff --git a/core/http/react-ui/css-literal-baseline.json b/core/http/react-ui/css-literal-baseline.json new file mode 100644 index 000000000..9ab23c6fe --- /dev/null +++ b/core/http/react-ui/css-literal-baseline.json @@ -0,0 +1,5 @@ +{ + "font-size": 296, + "border-radius": 50, + "@keyframes": 16 +} diff --git a/core/http/react-ui/e2e/account-page.spec.js b/core/http/react-ui/e2e/account-page.spec.js index de86b62b8..44badcd7a 100644 --- a/core/http/react-ui/e2e/account-page.spec.js +++ b/core/http/react-ui/e2e/account-page.spec.js @@ -9,9 +9,9 @@ test.describe('Account', () => { await expect(page.getByTestId('account-profile')).toBeVisible() }) - test('has four tabs, opens on the profile and keeps the tab in the address', async ({ page }) => { + test('has its tabs, opens on the profile and keeps the tab in the address', async ({ page }) => { const tabs = await page.getByRole('tab').allTextContents() - expect(tabs).toEqual(['Profile', 'Security', 'API keys', 'Usage']) + expect(tabs).toEqual(['Profile', 'Security', 'API keys', 'Usage', 'About you and the assistant', 'Appearance']) await expect(page.getByRole('tab', { name: 'Profile' })).toHaveAttribute('aria-selected', 'true') await page.getByRole('tab', { name: 'Usage' }).click() await expect(page).toHaveURL(/tab=usage/) @@ -94,20 +94,24 @@ test.describe('Account: other people and states', () => { await page.goto('/app/account?tab=usage') await expect(page.getByTestId('account-usage')).toBeVisible() await expect(page.getByTestId('account-page')).toContainText('bob · user') - await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' })).toHaveCount(0) + await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Administration' })).toHaveCount(0) }) - test('without authentication there is no account', async ({ page }) => { + test('without authentication there is no account, only the settings of the one user', async ({ page }) => { await page.route('**/api/auth/status', route => route.fulfill({ json: { authEnabled: false, staticApiKeyRequired: false, providers: [] } })) await page.goto('/app/account') - await expect(page.getByText('Account unavailable')).toBeVisible() + await expect(page.getByTestId('account-about')).toBeVisible() + expect(await page.getByRole('tab').allTextContents()).toEqual(['About you and the assistant', 'Appearance']) + await expect(page.getByTestId('sidebar-your-settings')).toBeVisible() }) test('the usage tab says so when the call fails', async ({ page }) => { await mockAccess(page) await page.route('**/api/auth/usage?*', route => route.fulfill({ status: 500, json: { error: 'boom' } })) await page.goto('/app/account?tab=usage') - await expect(page.getByRole('alert')).toContainText('Failed to load your usage') + // The load error primitive: what failed, then the server's words. + await expect(page.getByRole('alert')).toContainText('Could not load your usage') + await expect(page.getByRole('alert')).toContainText('boom') }) test('a person with no limits and no requests sees calm empty states', async ({ page }) => { diff --git a/core/http/react-ui/e2e/addon-navigation.spec.js b/core/http/react-ui/e2e/addon-navigation.spec.js index 65df355c6..d73a3380b 100644 --- a/core/http/react-ui/e2e/addon-navigation.spec.js +++ b/core/http/react-ui/e2e/addon-navigation.spec.js @@ -31,7 +31,7 @@ test.describe('Add-on navigation', () => { await setup(page) await page.goto('/app') const nav = page.getByRole('navigation') - await expect(nav.getByRole('link', { name: 'Chat' })).toBeVisible() + await expect(nav.getByRole('link', { name: 'Home' })).toBeVisible() // Apps, then the pinned apps in pin order; Apps is the only add-on entry. await expect(nav.getByRole('link', { name: 'Add-ons', exact: true })).toHaveCount(1) const pinned = nav.getByTestId('apps-pinned-link') @@ -160,7 +160,8 @@ test.describe('Add-on navigation', () => { await expect(pane).toBeVisible() await expect(page).toHaveURL(/detail=indexer/) await expect(pane.getByRole('heading', { name: 'Indexer' })).toBeVisible() - await expect(pane.getByRole('img', { name: 'State: Off' })).toBeVisible() + // The header chip; the operator facts below repeat it under Health. + await expect(pane.getByRole('img', { name: 'State: Off' }).first()).toBeVisible() await expect(pane).toContainText('Indexes your files.') await expect(pane.getByTestId('app-tools')).toContainText('2 chat tools') await expect(pane.getByTestId('app-tools')).toContainText('query, export') @@ -181,6 +182,70 @@ test.describe('Add-on navigation', () => { await expect(page.getByTestId('addon-detail').getByTestId('addon-shots').getByRole('listitem')).toHaveCount(1) }) + // An add-on that declares a settings.sections contribution: its settings + // live on the Settings tab of its details, and the Settings page links there. + const withSettings = (over = {}) => app({ id: 'sandbox', name: 'sandbox', title: 'Sandbox', description: 'Run commands.', mainPage: '/app/addons/sandbox/sandbox', ui: { slots: [{ id: 'settings', slot: 'settings.sections', component: 'SandboxSettings' }] }, ...over }) + + test('an administrator gets a Settings tab in the details of an add-on with settings, with the host controls it has', async ({ page }) => { + const controlled = withSettings({ shadows: ['bash'], ui: { entry: `/api/addons/sandbox/assets/${'a'.repeat(64)}/ui.js`, sdk: { min: 2, max: 2 }, controls: ['network-policy'] } }) + await setup(page, { pins: [], studio: false, user: ADMIN, apps: [controlled, app({ id: 'notes', title: 'Notes' })], installed: [installedAddon({ id: 'sandbox', title: 'Sandbox' }), installedAddon()] }) + await page.route('**/api/settings', route => route.fulfill({ json: { chats_egress_ceiling: 'registries', chats_egress_audit_days: 14 } })) + await page.route('**/api/chats/admin/egress', route => route.fulfill({ json: { ceiling: 'registries', default: 'off', ports: [80, 443], audit_days: 14, registries: [], allowed_24h: 0, blocked_24h: 0 } })) + await page.goto('/app/addons?detail=sandbox') + const pane = page.getByTestId('addon-detail') + await expect(pane.getByTestId('addon-detail-tab-overview')).toHaveAttribute('aria-selected', 'true') + await pane.getByTestId('addon-detail-tab-settings').click() + await expect(page).toHaveURL(/detail=sandbox&view=settings$/) + await expect(pane.getByTestId('addon-detail-tab-settings')).toHaveAttribute('aria-selected', 'true') + const block = pane.getByTestId('host-control-network-policy') + await expect(block).toBeVisible() + await expect(block.locator('[data-field="chats_egress_ceiling"] select')).toHaveValue('registries') + await expect(block.locator('[data-field="chats_egress_audit_days"] input')).toHaveValue('14') + await expect(pane.getByTestId('addon-server-settings')).toHaveCount(0) + // Arrow keys move between the two tabs. + await pane.getByTestId('addon-detail-tab-settings').press('ArrowLeft') + await expect(pane.getByTestId('addon-detail-tab-overview')).toHaveAttribute('aria-selected', 'true') + // An add-on without settings has no tab. + await page.goto('/app/addons?detail=notes&view=settings') + await expect(page.getByTestId('addon-detail')).toBeVisible() + await expect(page.getByTestId('addon-detail-tab-settings')).toHaveCount(0) + await expect(page.getByTestId('preview-facts')).toBeVisible() + }) + + test('a member gets no Settings tab, even from its address', async ({ page }) => { + await setup(page, { pins: [], studio: false, apps: [withSettings()] }) + await page.goto('/app/addons?detail=sandbox&view=settings') + await expect(page.getByTestId('addon-detail')).toBeVisible() + await expect(page.getByTestId('addon-detail-tab-settings')).toHaveCount(0) + await expect(page.getByTestId('addon-settings')).toHaveCount(0) + }) + + test('the Settings page lists the add-ons with settings in an Add-ons group, and only when there is one', async ({ page }) => { + await setup(page, { pins: [], studio: false, user: ADMIN, apps: [withSettings(), app({ id: 'notes', title: 'Notes' })] }) + await page.goto('/app/settings') + const group = page.locator('.st-group[data-group="addons"]') + await expect(group).toBeVisible() + await expect(group).toContainText('1') + await group.click() + const rows = page.getByTestId('settings-addon') + await expect(rows).toHaveCount(1) + await expect(rows.first()).toHaveAttribute('data-app', 'sandbox') + await expect(rows.first().getByTestId('settings-addon-link')).toHaveAttribute('href', /\/app\/addons\?detail=sandbox&view=settings$/) + // The old anchor of the section opens the Settings tab. + await page.goto('/app/settings#ad-section-sandbox-settings') + await expect(page).toHaveURL(/\/app\/addons\?detail=sandbox&view=settings$/) + }) + + test('the Settings page has no Add-ons group when no add-on has settings', async ({ page }) => { + await setup(page, { pins: [], studio: false, user: ADMIN, apps: [app({ id: 'notes', title: 'Notes' })] }) + const listed = page.waitForResponse(r => /\/api\/addons\/apps$/.test(new URL(r.url()).pathname)) + await page.goto('/app/settings') + await listed + await expect(page.getByTestId('settings-page')).toBeVisible() + await expect(page.locator('.st-group').first()).toBeVisible() + await expect(page.locator('.st-group[data-group="addons"]')).toHaveCount(0) + }) + test('a member sees the pane without Remove and without the source', async ({ page }) => { await setup(page, { pins: [], studio: false, apps: [app({ id: 'notes', title: 'Notes' })] }) await page.goto('/app/addons?detail=notes') @@ -208,12 +273,12 @@ test.describe('Add-on navigation', () => { await page.goto('/app/addons') const card = page.getByTestId('addons-first-run') await expect(card).toBeVisible() - await expect(card.getByRole('heading', { name: 'Add your first add-on' })).toBeVisible() + await expect(card.getByRole('heading', { name: 'No add-ons yet' })).toBeVisible() const featured = card.getByTestId('featured').getByRole('listitem') await expect(featured).toHaveCount(3) await expect(featured.first()).toContainText('does a thing.') await expect(featured.first()).toContainText('Adds a page') - await expect(card.getByRole('button', { name: 'Install and turn on' })).toHaveCount(3) + await expect(card.getByRole('button', { name: /^Install / })).toHaveCount(3) // One way into Find add-ons while the card shows: the bar's button waits. await expect(page.getByRole('link', { name: 'Find add-ons' })).toHaveCount(1) await expect(page.getByTestId('addons-add')).toHaveCount(0) @@ -226,7 +291,7 @@ test.describe('Add-on navigation', () => { test('Install and turn on asks once, installs, then turns the add-on on', async ({ page }) => { const state = await setup(page, { user: ADMIN, studio: false, pins: [], releases: [release('notes', { name: 'Notes' })] }) await page.goto('/app/addons') - await page.getByRole('button', { name: 'Install and turn on' }).click() + await page.getByRole('button', { name: 'Install Notes' }).click() const dialog = page.getByRole('alertdialog').or(page.getByRole('dialog')) await expect(dialog).toContainText('Install and turn on Notes?') await dialog.getByRole('button', { name: 'Install and turn on' }).click() @@ -298,7 +363,7 @@ test.describe('Add-on navigation', () => { expect(appsCalled).toBe(false) }) - // The icon of a built-in tile sat in the top-left corner of its 40px box, at the + // The icon of a built-in tile sat in the top-left corner of its 36px box, at the // size of the page text, because only the empty-icon box was laid out as a grid. test('a built-in tile draws its icon centred in the icon box', async ({ page }) => { await setup(page, { builtin: [{ id: 'guide', kind: 'page', titleKey: 'items.chat', icon: 'palette', mainPath: '/app/chat' }], studio: false }) @@ -313,7 +378,7 @@ test.describe('Add-on navigation', () => { expect(Math.abs(offsets.dx)).toBeLessThan(1) expect(Math.abs(offsets.dy)).toBeLessThan(1) expect(offsets.width).toBeGreaterThan(16) - expect(offsets.box).toBe(40) + expect(offsets.box).toBe(36) }) test('member without the add-ons feature and with no built-in tile goes home, and Studio and Talk are not built in', async ({ page }) => { @@ -329,7 +394,7 @@ test.describe('Add-on navigation', () => { await page.goto('/app') await expect(page.getByTestId('apps-show-all')).toHaveCount(0) // Home, Models and Chat head the old sidebar; Studio and Talk are add-ons and are not in it. - await expect(page.getByRole('navigation').getByRole('link', { name: 'Chat' })).toBeVisible() + await expect(page.getByRole('navigation').getByRole('link', { name: 'Home' })).toBeVisible() await expect(page.getByRole('navigation').getByRole('link', { name: 'Studio' })).toHaveCount(0) await expect(page.locator('.sidebar-section-title', { hasText: 'Create' })).toHaveCount(0) }) @@ -402,11 +467,9 @@ test.describe('Add-on navigation', () => { }) }) -test.describe('Add-on navigation: the Add-ons hub tab and the route in both layouts', () => { +test.describe('Add-on navigation: the legacy Add-ons hub tab and the route in both layouts', () => { test('the new layout has no Add-ons hub tab and serves the drawer at /app/addons', async ({ page }) => { await setup(page) - await page.goto('/app/build') - await expect(page.locator('.dk-hubtabs [data-hub-tab="addons"]')).toHaveCount(0) await page.goto('/app/addons') await expect(page.getByTestId('addon-tile')).toHaveCount(2) await expect(page.locator('.dk-hubtabs')).toHaveCount(0) @@ -415,7 +478,9 @@ test.describe('Add-on navigation: the Add-ons hub tab and the route in both layo test('the legacy flag brings the Add-ons hub tab back and /app/addons opens under it', async ({ page }) => { await setup(page, { apps: [app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' })] }) await page.route('**/api/features', route => route.fulfill({ json: { addon_navigation: false } })) - await page.goto('/app/build') + await page.goto('/app') + // The legacy sidebar lists the Add-ons hub, which opens the drawer. + await page.locator('.sidebar-nav a.nav-item[href="/app/addons"]').click() const hubTab = page.locator('.dk-hubtabs [data-hub-tab="addons"]') await expect(hubTab).toHaveAttribute('href', '/app/addons') await hubTab.click() @@ -680,13 +745,12 @@ test.describe('Add-on navigation: the Voice recognition page', () => { } }) - test('the Build hub has no Voices tab', async ({ page }) => { + test('there is no Build hub to hold a Voices tab: its address opens the models', async ({ page }) => { await setup(page, { apps: [] }) await asAdmin(page) await page.goto('/app/build') - await expect(page.locator('.dk-hubtabs')).toBeVisible() - await expect(page.locator('.dk-hubtabs [data-hub-tab="voices"]')).toHaveCount(0) - await expect(page.locator('.dk-hubtabs [data-hub-tab="faces"]')).toHaveCount(0) + await expect(page).toHaveURL(/\/app\/models/) + await expect(page.locator('.dk-hubtabs [data-hub-tab="voices"], .dk-hubtabs [data-hub-tab="faces"]')).toHaveCount(0) }) test('the add-on page identifies, enrols from a side panel and holds a removal behind Undo', async ({ page }) => { @@ -899,21 +963,21 @@ test.describe('Add-on navigation: the Face recognition page', () => { await expect(page).toHaveURL(/\/app\/addons\/quantization\/jobs$/) }) - test('the Build hub has no fine-tuning or quantization tab', async ({ page }) => { + test('there is no Build hub with a fine-tuning or quantization tab', async ({ page }) => { await setup(page) await asFaceAdmin(page) await page.goto('/app/build') - await expect(page.locator('.dk-hubtabs')).toBeVisible() + await expect(page).toHaveURL(/\/app\/models/) await expect(page.getByRole('tab', { name: /Fine-tun/ })).toHaveCount(0) await expect(page.getByRole('tab', { name: /Quantiz/ })).toHaveCount(0) await expect(page.locator('.dk-hubtabs [data-hub-tab="fine-tune"], .dk-hubtabs [data-hub-tab="quantize"]')).toHaveCount(0) }) - test('the Build hub shows no Faces tab', async ({ page }) => { + test('there is no Build hub to hold a Faces tab', async ({ page }) => { await setup(page, { apps: [] }) await asFaceAdmin(page) await page.goto('/app/build') - await expect(page.locator('.dk-hubtabs')).toBeVisible() + await expect(page).toHaveURL(/\/app\/models/) await expect(page.locator('.dk-hubtabs [data-hub-tab="faces"]')).toHaveCount(0) }) @@ -988,7 +1052,7 @@ test.describe('Add-on navigation: the addons feature gate', () => { await setup(page, { features: OFF, user: ADMIN }) await page.goto('/app') const nav = page.getByRole('navigation') - await expect(nav.getByRole('link', { name: 'Chat' })).toBeVisible() + await expect(nav.getByRole('link', { name: 'Home' })).toBeVisible() await expect(nav.getByRole('link', { name: 'Add-ons', exact: true })).toHaveCount(0) await expect(nav.getByTestId('apps-pinned-link')).toHaveCount(0) await expect(nav.getByRole('link', { name: /Fine-?tun/i })).toHaveCount(0) @@ -996,11 +1060,12 @@ test.describe('Add-on navigation: the addons feature gate', () => { expect(calls).toEqual([]) }) - test('with add-ons off the Build hub has no Add-ons, fine-tuning or quantization tab, in either layout', async ({ page }) => { + test('with add-ons off there is no Add-ons, fine-tuning or quantization entry, in either layout', async ({ page }) => { for (const addon_navigation of [true, false]) { await setup(page, { features: { ...OFF, addon_navigation }, user: ADMIN }) - await page.goto('/app/build') - await expect(page.getByRole('tab', { name: 'Agents' }).or(page.getByRole('link', { name: 'Agents' })).first()).toBeVisible() + await page.goto('/app') + await expect(page.locator('.sidebar-nav a.nav-item[href="/app/agents"]')).toBeVisible() + await expect(page.locator('.sidebar-nav a.nav-item[href="/app/addons"]')).toHaveCount(0) for (const name of [/^Add-ons$/, /Fine-?tun/i, /Quantiz/i]) { await expect(page.getByRole('tab', { name })).toHaveCount(0) await expect(page.locator('.hub-tabs, [role="tablist"]').getByRole('link', { name })).toHaveCount(0) @@ -1015,7 +1080,7 @@ test.describe('Add-on navigation: the addons feature gate', () => { await setup(page, { features: OFF, user: ADMIN }) await page.goto(address) await expect(page).toHaveURL(/\/app\/?$/) - await expect(page.getByRole('navigation').getByRole('link', { name: 'Chat' })).toBeVisible() + await expect(page.getByRole('navigation').getByRole('link', { name: 'Home' })).toBeVisible() expect(calls).toEqual([]) }) } diff --git a/core/http/react-ui/e2e/addons-fixtures.js b/core/http/react-ui/e2e/addons-fixtures.js index 87cc580fb..7d1c1fc46 100644 --- a/core/http/react-ui/e2e/addons-fixtures.js +++ b/core/http/react-ui/e2e/addons-fixtures.js @@ -53,7 +53,14 @@ const fail = (status, type, extra = {}) => ({ status, contentType: 'application/ export async function stubAddonRoutes(page) { // The real flags, with add-ons on: the other switches keep what the server says. await page.context().route('**/api/features', async route => { - const real = await (await route.fetch()).json() + let real + try { + real = await (await route.fetch()).json() + } catch { + // The page closed while the request was in flight (a spec that ends + // during a poll): nothing waits for the answer any more. + return route.abort().catch(() => {}) + } return route.fulfill({ json: { ...real, addons: true } }) }) await page.context().route('**/api/addons/builtin', route => route.fulfill({ json: { builtin: [] } })) diff --git a/core/http/react-ui/e2e/addons-foundation.spec.js b/core/http/react-ui/e2e/addons-foundation.spec.js index c712f4086..34ace9854 100644 --- a/core/http/react-ui/e2e/addons-foundation.spec.js +++ b/core/http/react-ui/e2e/addons-foundation.spec.js @@ -36,6 +36,9 @@ const isAddonAsset = (url) => /\/api\/addons\/[^/]+\/assets\//.test(url) // the card of the detail sheet (/app/addons?detail=ID). const card = (page) => page.locator(`[data-testid="addon-tile"][data-app="${ID}"]`) const sheetCard = (page) => page.getByTestId('addon-detail').locator(`[data-app="${ID}"]`) +// The state as the sheet's header shows it. The Details area repeats the +// observed state under Health, so a bare role query would match both. +const sheetState = (page, name) => sheetCard(page).locator('.ad-pane__meta').getByRole('img', { name: `State: ${name}` }) test.describe.configure({ mode: 'serial' }) @@ -65,7 +68,7 @@ test.describe('Add-ons foundation through real servers', () => { const { context, page } = await browserAs(browser, node, 'member') await page.goto('/app/addons') await expect(card(page)).toBeVisible({ timeout: 30_000 }) - await expect(card(page).locator('.ad-tile__state')).toHaveText('On') + await expect(card(page).locator('.ad-tile__state')).toHaveText('Running') await context.close() } }) @@ -117,14 +120,14 @@ test.describe('Add-ons foundation through real servers', () => { await expect(page.getByRole('tablist')).toBeVisible() await page.goto(`/app/addons?detail=${ID}`) await sheetCard(page).getByRole('button', { name: 'Turn off' }).click() - await expect(sheetCard(page).getByRole('img', { name: 'State: Off' })).toBeVisible({ timeout: 30_000 }) + await expect(sheetState(page, 'Off')).toBeVisible({ timeout: 30_000 }) // The other frontend and the owner see the same stopped add-on: the // administrator in the list with its state, a member not at all, because // the owner lists a disabled add-on to administrators only. for (const node of ['front-a', 'owner']) { const other = await browserAs(browser, node, 'admin') await other.page.goto(`/app/addons?detail=${ID}`) - await expect(sheetCard(other.page).getByRole('img', { name: 'State: Off' })).toBeVisible({ timeout: 30_000 }) + await expect(sheetState(other.page, 'Off')).toBeVisible({ timeout: 30_000 }) await other.context.close() const member = await browserAs(browser, node, 'member') await member.page.goto('/app/addons') @@ -133,7 +136,7 @@ test.describe('Add-ons foundation through real servers', () => { await member.context.close() } await sheetCard(page).getByRole('button', { name: 'Turn on' }).click() - await expect(sheetCard(page).getByRole('img', { name: 'State: On' })).toBeVisible({ timeout: 30_000 }) + await expect(sheetState(page, 'Running')).toBeVisible({ timeout: 30_000 }) await context.close() }) diff --git a/core/http/react-ui/e2e/addons-gallery.spec.js b/core/http/react-ui/e2e/addons-gallery.spec.js index 7743aae66..c34c36cb4 100644 --- a/core/http/react-ui/e2e/addons-gallery.spec.js +++ b/core/http/react-ui/e2e/addons-gallery.spec.js @@ -33,10 +33,10 @@ test.describe('Add-ons: apps', () => { await page.goto('/app/addons') const notes = page.locator('[data-app="notes"]') await expect(notes.locator('.ad-tile__title')).toHaveText('Notes') - await expect(notes.locator('.ad-tile__state')).toHaveText('On') + await expect(notes.locator('.ad-tile__state')).toHaveText('Running') await expect(notes.getByRole('link')).toHaveAttribute('href', '/app/addons/notes/home') await expect(notes.locator('img.ad-icon')).toHaveAttribute('src', '/api/addons/apps/notes/icon?generation=3') - await expect(page.locator('[data-app="broken"] .ad-tile__state')).toHaveText('Off, failed') + await expect(page.locator('[data-app="broken"] .ad-tile__state')).toHaveText('Failed') await expect(page.locator('[data-app="broken"]').getByRole('link')).toHaveCount(0) await expect(page.locator('[data-app="indexer"]').getByRole('link')).toHaveCount(0) // Every image is served by the host itself, never from another origin. @@ -79,7 +79,7 @@ test.describe('Add-ons: apps', () => { const card = detail(page, 'indexer') await expect(card.getByRole('img', { name: 'State: Off' })).toBeVisible() await card.getByRole('button', { name: 'Turn on' }).click() - await expect(card.getByRole('img', { name: 'State: On' })).toBeVisible() + await expect(card.getByRole('img', { name: 'State: Running' })).toBeVisible() expect(state.log.start).toHaveLength(1) expect(state.log.start[0].generation).toBe(4) expect(state.log.start[0].operationId).toMatch(UUID) @@ -173,7 +173,7 @@ test.describe('Add-ons: apps', () => { await card.getByRole('button', { name: 'Turn on' }).click() await expect.poll(() => state.log.start.length).toBe(2) expect(state.log.start[1].operationId).not.toBe(firstId) - await expect(card.getByRole('img', { name: 'State: On' })).toBeVisible() + await expect(card.getByRole('img', { name: 'State: Running' })).toBeVisible() await expect(card.getByRole('alert')).toHaveCount(0) expect(state.apps[0].enabled).toBe(true) }) @@ -252,7 +252,7 @@ test.describe('Add-ons: apps', () => { await expect.poll(() => state.log.appsList).toBe(4) await expect(page.getByText('may be out of date')).toHaveCount(0) // Leaving the page stops the requests. - await page.evaluate(() => { window.history.pushState({}, '', '/app/chat'); window.dispatchEvent(new PopStateEvent('popstate')) }) + await page.evaluate(() => { window.history.pushState({}, '', '/app/chats'); window.dispatchEvent(new PopStateEvent('popstate')) }) await expect(page.locator('[data-app="notes"]')).toHaveCount(0) const seen = state.log.appsList await page.clock.runFor(180_000) @@ -260,41 +260,105 @@ test.describe('Add-ons: apps', () => { }) }) +// The stand-in counts a read when the request arrives, before the page has its +// answer. The page sets its next timer only once it has read the answer, so a +// spec that moves the clock as soon as a request arrives can move it past a +// timer that does not exist yet. This counts the answers the page has read. +async function countAppsAnswers(page) { + await page.addInitScript(() => { + window.__appsAnswers = 0 + const original = window.fetch + window.fetch = async (...args) => { + const response = await original(...args) + const target = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '' + if (!/\/api\/addons\/apps$/.test(new URL(target, window.location.href).pathname)) return response + const json = response.json.bind(response) + response.json = async () => { + try { return await json() } finally { window.__appsAnswers += 1 } + } + return response + } + }) +} + +// Lets the page finish the work it has queued (the render of a new list and +// its effects). A message channel is not under the page clock. +const settle = (page) => page.evaluate(async () => { + for (let i = 0; i < 10; i++) { + await new Promise(resolve => { + const channel = new MessageChannel() + channel.port1.onmessage = () => resolve() + channel.port2.postMessage(null) + }) + } +}) + +// Pauses the page clock a moment ahead of its own time. On a slow machine that +// moment can pass before the pause arrives, so a pause in the past is retried. +async function pauseClock(page) { + for (let attempt = 0; ; attempt++) { + const now = await page.evaluate(() => Date.now()) + try { + await page.clock.pauseAt(now + 500) + return + } catch (error) { + if (attempt >= 4 || !String(error).includes('to the past')) throw error + } + } +} + +// Waits until read `n` has arrived and the page has read its answer. +async function appsAnswered(page, state, n) { + await expect.poll(() => state.log.appsList).toBe(n) + await expect.poll(() => page.evaluate(() => window.__appsAnswers)).toBe(n) + await settle(page) +} + test.describe('Add-ons: registry polling', () => { - const leave = (page) => page.evaluate(() => { window.history.pushState({}, '', '/app/chat'); window.dispatchEvent(new PopStateEvent('popstate')) }) + const leave = (page) => page.evaluate(() => { window.history.pushState({}, '', '/app/chats'); window.dispatchEvent(new PopStateEvent('popstate')) }) const contributing = (over = {}) => app({ ui: { entry: '/api/addons/notes/assets/' + 'c'.repeat(64) + '/ui.js', css: [], sdk: { min: 1, max: 2 }, pages: ['home'], navigation: ['home'] }, ...over }) test('apps: an add-on that contributes UI keeps the list current off the page and its disable stops the reads', async ({ page }) => { await signIn(page, MEMBER, { addons: true }) + await countAppsAnswers(page) await page.clock.install() const state = await mockAddons(page, { apps: [contributing()] }) await page.goto('/app/addons') await expect(page.locator('[data-app="notes"]')).toBeVisible() + // The clock is paused before the registry takes over, so from here only + // runFor moves it and a slow machine cannot make a read come early. + await pauseClock(page) + await page.waitForTimeout(150) + await settle(page) + const onPage = state.log.appsList + await expect.poll(() => page.evaluate(() => window.__appsAnswers)).toBe(onPage) await leave(page) await expect(page.locator('[data-app="notes"]')).toHaveCount(0) - await page.clock.pauseAt((await page.evaluate(() => Date.now())) + 50) - await expect.poll(() => state.log.appsList).toBeGreaterThanOrEqual(1) - // Reads continue, one per interval and no faster. The clock only moves - // by runFor, and each step waits for its answer before the next one. + // Off the page the registry reads the list at once. Each step waits until + // the page has read the answer, and so has set its next timer, before the + // clock moves again. + await appsAnswered(page, state, onPage + 1) const before = state.log.appsList + // Reads continue, one per interval and no faster. await page.clock.runFor(4_000) await page.waitForTimeout(150) + await settle(page) expect(state.log.appsList).toBe(before) await page.clock.runFor(1_100) - await expect.poll(() => state.log.appsList).toBe(before + 1) + await appsAnswered(page, state, before + 1) for (let n = 2; n <= 4; n++) { await page.clock.runFor(5_100) - await expect.poll(() => state.log.appsList).toBe(before + n) + await appsAnswered(page, state, before + n) } // The owner disables it: the next list withdraws the contribution, and // with nothing left to keep current the reads stop. state.apps[0].enabled = false state.apps[0].state = 'stopped' await page.clock.runFor(5_100) - await expect.poll(() => state.log.appsList).toBe(before + 5) - await page.waitForTimeout(150) + await appsAnswered(page, state, before + 5) await page.clock.runFor(180_000) await page.waitForTimeout(300) + await settle(page) expect(state.log.appsList).toBe(before + 5) }) @@ -302,7 +366,7 @@ test.describe('Add-ons: registry polling', () => { await signIn(page, MEMBER, {}) await page.clock.install() const state = await mockAddons(page, { apps: [contributing()] }) - await page.goto('/app/chat') + await page.goto('/app/chats') await page.clock.runFor(120_000) await page.goto('/app/addons') await page.clock.runFor(120_000) @@ -349,7 +413,7 @@ test.describe('Add-ons: browse', () => { await expect(row.getByTestId('entry-adds')).toHaveText('Adds a page') // One button on the entry, and Details is folded into it. await expect(row.getByRole('button')).toHaveCount(1) - await row.getByRole('button', { name: 'Install and turn on' }).click() + await row.getByRole('button', { name: /^Install / }).click() const dialog = page.getByRole('alertdialog') await expect(dialog).toContainText('Install and turn on Tables?') await expect(dialog).toContainText('sha256:aaaaaaaaaaaa') @@ -388,7 +452,7 @@ test.describe('Add-ons: browse', () => { }) await page.goto('/app/addons?tab=browse') const row = page.locator('.cat-item', { hasText: 'Tables' }) - await row.getByRole('button', { name: 'Install and turn on' }).click() + await row.getByRole('button', { name: /^Install / }).click() const dialog = page.getByRole('alertdialog') await dialog.getByRole('button', { name: 'Install and turn on' }).click() await expect(dialog.getByTestId('install-dialog-failed')).toContainText('Tables is installed but did not turn on.') @@ -413,7 +477,7 @@ test.describe('Add-ons: browse', () => { }) }) -// What the Installed tab used to show is the Details of an add-on in My add-ons: +// What the old Installed tab showed is the Details of an add-on in Installed: // the list read for an administrator and its installed record go together. const details = (id, over = {}, installedOver = {}) => ({ apps: [app({ id, title: installedOver.title || id, admin: { canStart: false, canStop: true, managementUrl: `/app/addons?manage=${id}` }, ...over })], @@ -423,7 +487,7 @@ const details = (id, over = {}, installedOver = {}) => ({ test.describe('Add-ons: installed', () => { test('installed: health is separate from enabled and the details show trust, compatibility and storage', async ({ page }) => { await signIn(page, ADMIN) - await mockAddons(page, details('notes', { state: 'failed' }, { state: 'failed', restarts: 3, verification: 'sha256+sigstore', title: 'Notes' })) + await mockAddons(page, details('notes', { state: 'failed' }, { state: 'failed', restarts: 3, verification: 'sha256+sigstore', title: 'Notes', installedAt: '2026-10-01T10:00:00Z' })) await page.goto('/app/addons?manage=notes') const pane = page.getByTestId('addon-detail') await expect(pane.getByRole('heading', { name: 'Notes' })).toBeVisible() @@ -438,6 +502,7 @@ test.describe('Add-ons: installed', () => { await expect(sheet.getByTestId('manage-updates').getByText('sha256:0123456789ab')).toBeVisible() await expect(sheet.locator('dt', { hasText: 'Host API' }).locator('xpath=following-sibling::dd[1]')).toHaveText('1-2') await expect(sheet.getByText('12 KiB')).toBeVisible() + await expect(sheet.getByTestId('manage-installed-at')).toContainText('2026') await expect(sheet.getByTestId('update-state')).toHaveAttribute('data-state', 'unknown') await expect(sheet.getByText('not collected in this release')).toBeVisible() await shoot(page, 'manage') @@ -455,17 +520,17 @@ test.describe('Add-ons: installed', () => { ], }) await page.goto('/app/addons') - await expect(page.locator('[data-testid="addon-tile"][data-app="notes"]').getByTestId('update-chip')).toHaveText('Update pending') + await expect(page.locator('[data-testid="addon-tile"][data-app="notes"]').getByTestId('update-chip')).toHaveText('Update 1.3.0') await expect(page.locator('[data-testid="addon-tile"][data-app="plain"]').getByTestId('update-chip')).toHaveCount(0) await page.goto('/app/addons?manage=notes') await page.getByTestId('addon-manage').getByRole('button', { name: 'Upgrade' }).click() - // The review sheet names the version and digest the owner resolved and - // nothing is sent until it is confirmed there. - const dialog = page.getByTestId('update-review') + // The confirmation names the version and digest the owner resolved and + // nothing is sent until it is confirmed. + const dialog = page.getByTestId('upgrade-confirm') await expect(dialog).toContainText('1.3.0') - await expect(dialog.getByTestId('update-review-detail')).toContainText('sha256:bbbbbbbbbbbb') + await expect(dialog).toContainText('sha256:bbbbbbbbbbbb') expect(state.log.upgrade).toHaveLength(0) - await dialog.getByTestId('update-review-selected').click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Upgrade' }).click() await expect.poll(() => state.log.upgrade.length).toBe(1) const sent = state.log.upgrade[0] expect(sent.id).toBe('notes') @@ -526,7 +591,7 @@ test.describe('Add-ons: installed', () => { const sheet = page.getByTestId('addon-manage') await expect(sheet.getByText('Headless, no pages')).toBeVisible() await expect(sheet.getByText('2: query, export')).toBeVisible() - await expect(sheet.getByRole('img', { name: 'State: On' })).toBeVisible() + await expect(sheet.getByRole('img', { name: 'State: Running' })).toBeVisible() await expect(sheet.getByRole('link', { name: 'Open' })).toHaveCount(0) }) @@ -536,18 +601,18 @@ test.describe('Add-ons: installed', () => { await page.goto('/app/addons?detail=notes') await expect(page.getByTestId('addon-detail').getByRole('heading', { name: 'Notes' })).toBeVisible() await expect(page.getByTestId('preview-source')).toHaveText('Not known') - await expect(page.getByTestId('addon-more')).toHaveCount(0) + await expect(page.getByTestId('addon-manage')).toHaveCount(0) }) - test('installed: an old Installed address lands on My add-ons and the management link opens the details', async ({ page }) => { + test('installed: an old Installed address lands on Installed and the management link opens the details', async ({ page }) => { await signIn(page, ADMIN) await mockAddons(page, details('notes', {}, { title: 'Notes' })) await page.goto('/app/addons?tab=installed') - await expect(tab(page, 'My add-ons')).toHaveAttribute('aria-selected', 'true') - await expect(page.getByRole('tab', { name: 'Installed' })).toHaveCount(0) + await expect(tab(page, 'Installed')).toHaveAttribute('aria-selected', 'true') + await expect(page.getByRole('tab', { name: /^Installed/ })).toHaveCount(1) await expect(page.getByTestId('addon-tile')).toHaveCount(1) await page.goto('/app/addons?manage=notes') - await expect(page.getByTestId('addon-more')).toHaveAttribute('open', '') + await expect(page.getByTestId('addon-detail').getByTestId('addon-manage')).toBeVisible() }) }) @@ -589,7 +654,7 @@ test.describe('Add-ons: libraries', () => { // A removal goes through the library route, the same one the other kinds use. const removal = state.log.library.find(c => c.method === 'DELETE') expect(removal.id).toBe('main') - await tab(page, 'My add-ons').click() + await tab(page, 'Installed').click() await expect(page.locator('[data-testid="addon-tile"][data-app="notes"]')).toBeVisible() expect(state.installed).toHaveLength(1) }) @@ -617,7 +682,7 @@ test.describe('Add-ons: access, keyboard, layout and language', () => { await signIn(page, ADMIN) const state = await mockAddons(page, { apps: [app({ admin })] }) await page.goto('/app/addons') - await tab(page, 'My add-ons').focus() + await tab(page, 'Installed').focus() await page.keyboard.press('ArrowRight') await expect(tab(page, 'Find add-ons')).toBeFocused() await expect(tab(page, 'Find add-ons')).toHaveAttribute('aria-selected', 'true') @@ -625,7 +690,7 @@ test.describe('Add-ons: access, keyboard, layout and language', () => { await page.keyboard.press('End') await expect(tab(page, 'Sources')).toHaveAttribute('aria-selected', 'true') await page.keyboard.press('Home') - await expect(tab(page, 'My add-ons')).toHaveAttribute('aria-selected', 'true') + await expect(tab(page, 'Installed')).toHaveAttribute('aria-selected', 'true') // The selected tab is the only one in the tab order. await expect(tab(page, 'Find add-ons')).toHaveAttribute('tabindex', '-1') // Stop is reachable and operable with the keyboard alone, in the detail sheet. @@ -663,7 +728,7 @@ test.describe('Add-ons: access, keyboard, layout and language', () => { await page.goto('/app/addons?detail=notes') const read = () => page.locator('.ad-pane').first().evaluate(el => { const css = getComputedStyle(el) - const chip = getComputedStyle(el.querySelector('.ad-chip')) + const chip = getComputedStyle(el.querySelector('.ad-state .ui-chip')) return { card: css.backgroundColor, chip: chip.color, chipBg: chip.backgroundColor } }) const light = await read() @@ -684,18 +749,18 @@ test.describe('Add-ons: access, keyboard, layout and language', () => { await expect(page.getByRole('tab', { name: 'Trova componenti' })).toBeVisible() await expect(page.getByRole('tab', { name: 'Fonti' })).toBeVisible() await page.goto('/app/addons?detail=notes') - await expect(detail(page, 'notes').getByRole('img', { name: 'Stato: Attivo' })).toBeVisible() + await expect(detail(page, 'notes').getByRole('img', { name: 'Stato: In esecuzione' }).first()).toBeVisible() }) test('hub: under the legacy switch the Add-ons tab appears only for an account that holds the feature', async ({ page }) => { await page.route('**/api/features', route => route.fulfill({ json: { addon_navigation: false } })) await signIn(page, MEMBER, { chat: true }) await mockAddons(page) - await page.goto('/app/build') + await page.goto('/app/addons') await expect(page.locator('.dk-hubtabs [data-hub-tab="addons"]')).toHaveCount(0) await page.unroute('**/api/auth/status') await signIn(page, MEMBER, { chat: true, addons: true }) - await page.goto('/app/build') + await page.goto('/app/addons') await expect(page.locator('.dk-hubtabs [data-hub-tab="addons"]')).toHaveAttribute('href', '/app/addons') await page.locator('.dk-hubtabs [data-hub-tab="addons"]').click() await expect(page.locator('.dk-hubtabs [data-hub-tab="addons"]')).toHaveAttribute('aria-current', 'page') @@ -719,7 +784,8 @@ test.describe('Add-ons: shared catalog components keep the backends gallery', () await expect(page.locator('[data-entity="backend-60"]')).toBeVisible() await expect(page.locator('.bk-search.dk-input-icon input')).toBeVisible() await page.getByPlaceholder(/search backends/i).fill('nothing-here') - await expect(page.locator('.dk-empty')).toBeVisible() - await expect(page.locator('.dk-empty .dk-empty-title')).toBeVisible() + // The page draws the shared empty state of the kit, in its no-results form. + await expect(page.locator('.ui-empty.ui-empty--no-results')).toBeVisible() + await expect(page.locator('.ui-empty .ui-empty__title')).toHaveText('No backends found') }) }) diff --git a/core/http/react-ui/e2e/addons-proposals.spec.js b/core/http/react-ui/e2e/addons-proposals.spec.js deleted file mode 100644 index 43584b852..000000000 --- a/core/http/react-ui/e2e/addons-proposals.spec.js +++ /dev/null @@ -1,332 +0,0 @@ -import { test, expect, request as playwrightRequest } from '@playwright/test' -import fs from 'node:fs' -import { randomUUID } from 'node:crypto' -import { chatOf, mockChat } from './chat-fixtures.js' -import { seedChats, CHATS_KEY } from './home-fixtures.js' - -// Add-on proposals from chat. The UI test server runs with real -// authentication, a real add-on owner and the real Tables executable. The -// scripted model (tests/e2e/mock-backend) answers its first turn with the tool -// call the spec encodes in the message, as a model that chose the tool would; -// the call then goes through the real chat handler, the real broker and the -// real owner. Only the chat model list and system probes are stubbed. -// -// The specs change the owner's state and run in order on one worker. - -const creds = JSON.parse(fs.readFileSync(process.env.ADDONS_FIXTURE, 'utf8')) -const BASE = process.env.ADDONS_UI_URL -const shots = process.env.ADDONS_SHOTS -const SECRET = 'ROW-SECRET-4411' - -async function shoot(locator, name, page) { - if (!shots) return - await page.emulateMedia({ reducedMotion: 'reduce' }) - await page.waitForTimeout(300) - await locator.screenshot({ path: `${shots}/${name}.png` }) -} - -async function browserAs(browser, who, extra = {}) { - const context = await browser.newContext({ baseURL: BASE, ...extra }) - await context.addCookies([{ name: 'session', value: creds.users[who].session, url: BASE }]) - const page = await context.newPage() - return { context, page } -} - -async function apiAs(who) { - const user = creds.users[who] - return playwrightRequest.newContext({ baseURL: BASE, extraHTTPHeaders: { Authorization: `Bearer ${user.token}` } }) -} - -// A fetch made by the page itself: a real same-origin request that carries the -// session cookie and the browser's own Sec-Fetch metadata, which is what the -// host confirmation surface requires. -async function pageFetch(page, path, { method = 'GET', body, headers = {} } = {}) { - return page.evaluate(async ({ path, method, body, headers }) => { - const r = await fetch(path, { - method, credentials: 'same-origin', - headers: { Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), ...headers }, - body: body === undefined ? undefined : JSON.stringify(body), - }) - let json = null - try { json = await r.json() } catch { json = null } - return { status: r.status, json } - }, { path, method, body, headers }) -} - -const b64url = (s) => Buffer.from(s).toString('base64url') -const toolMessage = (tool, args) => `Please run this ADDON_TOOL_CALL:addon__tables__${tool}:${b64url(JSON.stringify(args))}` - -async function openChat(page, { id = 'c1' } = {}) { - await mockChat(page, { models: [{ id: 'mock-model', capabilities: ['FLAG_CHAT'] }], loaded: ['mock-model'] }) - await seedChats(page, [chatOf(id, 'Tables', 'mock-model', [], { addonTools: true })], id) - await page.goto('/app/chat') - await page.getByTestId('chat-composer').waitFor() -} - -async function say(page, text) { - await page.getByTestId('chat-input').fill(text) - await page.getByTestId('chat-send').click() -} - -const cards = (page) => page.getByTestId('proposal-card') - -async function tool(page, name, args) { - const before = await cards(page).count() - await say(page, toolMessage(name, args)) - await expect(cards(page)).toHaveCount(before + 1, { timeout: 40_000 }) - return cards(page).nth(before) -} - -async function pendingOf(api) { - const r = await api.get('/api/addons/proposals?state=pending') - expect(r.status()).toBe(200) - return (await r.json()).proposals -} - -// Every confirmation the specs make by hand is the person's own tab. -async function confirmIn(page, p) { - return pageFetch(page, `/api/addons/proposals/${encodeURIComponent(p.id)}/confirm`, { - method: 'POST', body: { digest: p.digest }, headers: { 'X-LocalAI-Confirm': p.id }, - }) -} - -// What the chat stores for a tool result of an add-on is the reference of the -// proposal: no rows, no preview, no digest. (The model's own words are the -// conversation and are not a host result.) -function expectReferenceOnly(stored) { - const chats = JSON.parse(stored).chats - const results = chats.flatMap(c => c.history).filter(m => m.role === 'tool_result') - expect(results.length).toBeGreaterThan(0) - for (const m of results) { - const doc = JSON.parse(m.content) - expect(Object.keys(doc).sort()).toEqual(['name', 'result', 'type']) - const held = JSON.parse(doc.result) - expect(held).toEqual({ kind: 'proposal', proposal: { id: expect.any(String) } }) - } -} - -test.describe.configure({ mode: 'serial' }) - -let admin -let member -let state = {} -test.beforeAll(async () => { - admin = await apiAs('admin') - member = await apiAs('member') -}) -test.afterAll(async () => { - await admin.dispose() - await member.dispose() -}) - -async function rowCount(resource) { - const app = (await (await member.get('/api/addons/apps')).json()).apps.find(a => a.id === 'tables') - const r = await member.post('/api/addons/dispatch', { - data: { operationId: `op-${randomUUID()}`, addonId: 'tables', resourceId: resource, generation: app.generation, action: 'read', arguments: { tool: 'rows-query', table: 'Entries' } }, - }) - expect(r.status()).toBe(200) - return (await r.json()).payload.rows.length -} - -test.describe('Add-on proposals in chat', () => { - test('proposals: a tool call in chat stores a pending proposal and the card shows the host record, not the model text', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'member') - await openChat(page) - const card = await tool(page, 'databases-create', { name: 'Ledger' }) - await expect(card.getByTestId('proposal-addon')).toHaveText('tables') - await expect(card.getByTestId('proposal-database')).toHaveText('Ledger') - await expect(card.getByTestId('proposal-operation')).toHaveText('databases-create') - await expect(card.getByTestId('proposal-count')).toHaveText('1') - await expect(card.getByTestId('proposal-state')).toHaveText('Waiting for your decision') - await expect(card.getByTestId('proposal-confirm')).toBeEnabled() - await expect(card.getByTestId('proposal-cancel')).toBeEnabled() - await shoot(card, 'proposal-pending-create', page) - // The host holds exactly this proposal, pending, for the proposer only. - const list = await pendingOf(member) - expect(list).toHaveLength(1) - expect(list[0].action).toBe('create') - expect(list[0].state).toBe('pending') - expect(await pendingOf(admin)).toHaveLength(0) - await expect(card).toHaveAttribute('data-proposal', list[0].id) - state.create = list[0] - // Nothing exists for anybody before the person decides. - const apps = await (await member.get('/api/addons/apps')).json() - expect(apps.apps.find(a => a.id === 'tables').toolDefinitions.length).toBeGreaterThan(0) - await context.close() - }) - - test('proposals: confirming in the card applies the change exactly once and a repeat returns the stored receipt', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'member') - await openChat(page) - const card = await tool(page, 'databases-create', { name: 'Books' }) - await expect(card.getByTestId('proposal-database')).toHaveText('Books') - const p = (await pendingOf(member)).find(x => x.effects.sample && x.effects.sample.name === 'Books') - await card.getByTestId('proposal-confirm').click() - await expect(card.getByTestId('proposal-state')).toHaveText('Applied', { timeout: 20_000 }) - await expect(card.getByTestId('proposal-confirm')).toBeDisabled() - await expect(card.getByTestId('proposal-cancel')).toBeDisabled() - await shoot(card, 'proposal-applied', page) - state.db = p.resourceId - // A second confirmation of the same proposal returns the stored receipt. - const again = await confirmIn(page, p) - expect(again.status).toBe(200) - expect(again.json.state).toBe('applied') - // The database exists now, for its owner only. - const inspect = await member.post('/api/addons/dispatch', { - data: { operationId: `op-${randomUUID()}`, addonId: 'tables', resourceId: state.db, generation: p.generation, action: 'read', arguments: { tool: 'databases-inspect' } }, - }) - expect(inspect.status(), await inspect.text()).toBe(200) - expect((await inspect.json()).payload.name).toBe('Books') - await context.close() - }) - - test('proposals: row changes show the exact count and a bounded sample, and nothing changes until the person confirms', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'member') - await openChat(page) - const app = (await (await member.get('/api/addons/apps')).json()).apps.find(a => a.id === 'tables') - const schema = async (args) => { - const op = `op-${randomUUID()}` - const r = await member.post('/api/addons/proposals', { data: { operationId: op, addonId: 'tables', resourceId: state.db, generation: app.generation, action: 'schema', arguments: args } }) - expect(r.status()).toBe(200) - const p = await r.json() - const done = await confirmIn(page, p) - expect(done.json.state).toBe('applied') - } - await schema({ tool: 'tables-create', table: 'Entries' }) - await schema({ tool: 'columns-add', table: 'Entries', column: 'Memo', type: 'text' }) - const card = await tool(page, 'rows-insert', { - resourceId: state.db, table: 'Entries', rows: [{ Memo: SECRET }, { Memo: 'two' }, { Memo: 'three' }], - }) - await expect(card.getByTestId('proposal-operation')).toHaveText('rows-insert') - await expect(card.getByTestId('proposal-count')).toHaveText('3') - await expect(card.getByTestId('proposal-database')).toHaveText(state.db) - await expect(card.getByTestId('proposal-sample')).toContainText(SECRET) - await shoot(card, 'proposal-rows-pending', page) - expect(await rowCount(state.db)).toBe(0) - await card.getByTestId('proposal-confirm').click() - await expect(card.getByTestId('proposal-state')).toHaveText('Applied', { timeout: 20_000 }) - expect(await rowCount(state.db)).toBe(3) - // The stored conversation keeps the reference and no data. - const readStored = () => page.evaluate((key) => localStorage.getItem(key) || '', CHATS_KEY) - // The chat writes its storage after a short delay. - await expect.poll(readStored, { timeout: 15_000 }).toContain('tool_result') - const stored = await readStored() - expect(stored).toContain('addon__tables__rows-insert') - expect(stored).not.toContain('digest') - expectReferenceOnly(stored) - // Reloading the page draws the card again from the host. - await page.reload() - const again = page.getByTestId('proposal-card').last() - await expect(again.getByTestId('proposal-state')).toHaveText('Applied', { timeout: 20_000 }) - await expect(again.getByTestId('proposal-confirm')).toBeDisabled() - expect(await rowCount(state.db)).toBe(3) - await context.close() - }) - - test('proposals: cancel withdraws the proposal and nothing is applied', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'member') - await openChat(page) - const card = await tool(page, 'rows-delete', { resourceId: state.db, table: 'Entries', filter: { column: 'Memo', op: 'eq', value: 'two' } }) - await expect(card.getByTestId('proposal-count')).toHaveText('1') - await card.getByTestId('proposal-cancel').click() - await expect(card.getByTestId('proposal-state')).toHaveText('Cancelled', { timeout: 20_000 }) - await expect(card.getByTestId('proposal-confirm')).toBeDisabled() - await shoot(card, 'proposal-cancelled', page) - expect(await rowCount(state.db)).toBe(3) - await context.close() - }) - - test('proposals: an expired proposal is disabled in the card and the host refuses a late confirmation', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'member') - await page.clock.install() - await openChat(page) - const card = await tool(page, 'rows-delete', { resourceId: state.db, table: 'Entries', filter: { column: 'Memo', op: 'eq', value: 'three' } }) - await expect(card.getByTestId('proposal-confirm')).toBeEnabled() - const pending = (await pendingOf(member)).find(p => p.action === 'write') - // Six minutes pass in the page; the proposal lives five. - await page.clock.fastForward(6 * 60 * 1000) - await expect(card.getByTestId('proposal-state')).toHaveText('Expired', { timeout: 20_000 }) - await expect(card.getByTestId('proposal-confirm')).toBeDisabled() - await expect(card.getByTestId('proposal-cancel')).toBeDisabled() - await expect(card.getByTestId('proposal-notice')).toBeVisible() - await shoot(card, 'proposal-expired', page) - await context.close() - // The owner's own clock decides for real: nothing was applied by the card. - expect(await rowCount(state.db)).toBe(3) - expect(pending.id).toBeTruthy() - }) - - test('proposals: forged fields and approval names in a tool call create no proposal', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'member') - await openChat(page) - const before = (await pendingOf(member)).length - const base = { resourceId: state.db, table: 'Entries', rows: [{ Memo: 'x' }] } - const calls = [ - ['rows-insert', { ...base, approved: true }], - ['rows-insert', { ...base, userId: 'fixture-admin' }], - ['rows-insert', { ...base, role: 'owner' }], - ['rows-insert', { ...base, proposalId: 'x' }], - ['confirm', { proposalId: 'x' }], - ['approve', {}], - ['databases-delete', { resourceId: state.db }], - ] - // Each call ends in the scripted final answer, which the model gives - // only after the host put a tool result into the prompt. - for (const [i, [name, args]] of calls.entries()) { - await say(page, toolMessage(name, args)) - await expect(page.getByText('The change is waiting for your confirmation.')).toHaveCount(i + 1, { timeout: 40_000 }) - } - expect(await pendingOf(member)).toHaveLength(before) - expect(await rowCount(state.db)).toBe(3) - await expect(cards(page)).toHaveCount(0) - await context.close() - }) - - test('proposals: a user whose add-ons feature is withdrawn sees no data and the host answers no', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'member') - await openChat(page) - const card = await tool(page, 'rows-insert', { resourceId: state.db, table: 'Entries', rows: [{ Memo: 'late' }] }) - await expect(card.getByTestId('proposal-database')).toHaveText(state.db) - const p = (await pendingOf(member)).find(x => x.action === 'write') - // An administrator withdraws the feature. - const revoke = await admin.put(`/api/auth/admin/users/${creds.users.member.id}/permissions`, { data: { addons: false } }) - expect(revoke.status()).toBe(200) - await page.evaluate(() => window.dispatchEvent(new Event('focus'))) - await expect(card.getByTestId('proposal-withdrawn')).toBeVisible({ timeout: 20_000 }) - await expect(card.getByTestId('proposal-database')).toHaveCount(0) - await expect(card.getByTestId('proposal-sample')).toHaveCount(0) - await expect(card.getByTestId('proposal-confirm')).toHaveCount(0) - await expect(card).not.toContainText(state.db) - await shoot(card, 'proposal-withdrawn', page) - const denied = await confirmIn(page, p) - expect(denied.status).toBe(403) - expect((await member.get('/api/addons/proposals?state=pending')).status()).toBe(403) - const read = await member.post('/api/addons/dispatch', { - data: { operationId: `op-${randomUUID()}`, addonId: 'tables', resourceId: state.db, generation: 1, action: 'read', arguments: { tool: 'rows-query', table: 'Entries' } }, - }) - expect(read.status()).toBe(403) - await context.close() - // Restore the feature for the next spec. - expect((await admin.put(`/api/auth/admin/users/${creds.users.member.id}/permissions`, { data: { addons: true } })).status()).toBe(200) - }) - - test('proposals: logging out clears the card and leaves no data in the stored conversation', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'member') - await openChat(page) - const card = await tool(page, 'rows-insert', { resourceId: state.db, table: 'Entries', rows: [{ Memo: SECRET }] }) - await expect(card.getByTestId('proposal-sample')).toContainText(SECRET) - const out = await pageFetch(page, '/api/auth/logout', { method: 'POST' }) - expect([200, 204]).toContain(out.status) - await page.evaluate(() => window.dispatchEvent(new Event('focus'))) - await expect(card.getByTestId('proposal-withdrawn')).toBeVisible({ timeout: 20_000 }) - await expect(card.getByTestId('proposal-sample')).toHaveCount(0) - await expect(card).not.toContainText(SECRET) - const readStored = () => page.evaluate((key) => localStorage.getItem(key) || '', CHATS_KEY) - await expect.poll(readStored, { timeout: 15_000 }).toContain('tool_result') - const stored = await readStored() - expect(stored).not.toContain('digest') - expectReferenceOnly(stored) - await shoot(card, 'proposal-logged-out', page) - await context.close() - }) -}) diff --git a/core/http/react-ui/e2e/addons-runtime.spec.js b/core/http/react-ui/e2e/addons-runtime.spec.js index e30d8cd7f..d01f2046d 100644 --- a/core/http/react-ui/e2e/addons-runtime.spec.js +++ b/core/http/react-ui/e2e/addons-runtime.spec.js @@ -293,7 +293,13 @@ test.describe('Add-on UI runtime', () => { await page.getByRole('button', { name: 'Go to dialog' }).click() await expect(page).toHaveURL(/\/app\/addons\/uifixture\/dialog\/opened\?from=home$/) await expect(page.getByTestId('fx-where')).toHaveText('page=dialog path=opened') - // The Add-ons tab lists the pages that usable add-ons declared in navigation. + // The Add-ons tab lists the pages that usable add-ons declared in + // navigation. That tab belongs to the legacy layout, so the page gets the + // server's flags with the legacy switch on. + await page.route('**/api/features', async route => { + const real = await (await route.fetch()).json() + return route.fulfill({ json: { ...real, addon_navigation: false } }) + }) await page.goto('/app/addons') const subnav = page.locator('.hub-subnav') await expect(subnav.getByRole('link', { name: 'UI fixture: home' })).toHaveAttribute('href', '/app/addons/uifixture/home') @@ -343,7 +349,9 @@ test.describe('Add-on UI runtime', () => { await page.goto('/app/addons/uifixture/home') await expect(surface(page)).toBeVisible({ timeout: 20_000 }) await page.getByTestId('fx-draft').fill('half a thought') - await page.getByRole('link', { name: 'Chat', exact: true }).first().click() + // Any host link leaves the page; the sidebar starts with Home. + const leave = page.getByRole('complementary', { name: 'Primary navigation' }).getByRole('link', { name: 'Home', exact: true }) + await leave.click() const guard = page.getByRole('alertdialog') await expect(guard).toContainText('Leave this page?') await guard.getByRole('button', { name: 'Stay' }).click() @@ -351,8 +359,8 @@ test.describe('Add-on UI runtime', () => { await expect(page.getByTestId('fx-draft')).toHaveValue('half a thought') // Saving clears the guard: leaving is then free. await page.getByRole('button', { name: 'Save draft' }).click() - await page.getByRole('link', { name: 'Chat', exact: true }).first().click() - await expect(page).toHaveURL(/\/app\/chat/) + await leave.click() + await expect(page).toHaveURL(/\/app$/) await context.close() }) @@ -375,8 +383,14 @@ test.describe('Add-on UI runtime', () => { const { context, page } = await browserAs(browser, 'outsider') const assets = [] page.on('request', r => { if (isAddonAsset(r.url())) assets.push(r.url()) }) + // The shell keeps a notification stream open, so the network is never + // idle. The page has decided once it has the server's switches and the + // account and has drawn the shell. + const features = page.waitForResponse(r => new URL(r.url()).pathname === '/api/features') + const account = page.waitForResponse(r => new URL(r.url()).pathname === '/api/auth/status') await page.goto('/app/addons/uifixture/home') - await page.waitForLoadState('networkidle') + await Promise.all([features, account]) + await expect(page.getByRole('complementary', { name: 'Primary navigation' })).toBeVisible() await expect(page.locator('[data-testid="addon-surface"]')).toHaveCount(0) expect(assets).toEqual([]) expect(await events(page)).toEqual([]) diff --git a/core/http/react-ui/e2e/addons-slots.spec.js b/core/http/react-ui/e2e/addons-slots.spec.js index 9fca9a028..d8f931b79 100644 --- a/core/http/react-ui/e2e/addons-slots.spec.js +++ b/core/http/react-ui/e2e/addons-slots.spec.js @@ -1,8 +1,6 @@ import { test, expect, request as playwrightRequest } from '@playwright/test' import fs from 'node:fs' -import { randomUUID } from 'node:crypto' -import { chatOf, pair, mockChat } from './chat-fixtures.js' -import { seedChats, setTheme } from './home-fixtures.js' +import { mockChat } from './chat-fixtures.js' // Host UI slots. The UI test server runs with real authentication and a real // add-on owner; the "slots" fixture package (tests/addons/fixture/ui/slots) is @@ -18,7 +16,6 @@ import { seedChats, setTheme } from './home-fixtures.js' const creds = JSON.parse(fs.readFileSync(process.env.ADDONS_FIXTURE, 'utf8')) const BASE = process.env.ADDONS_UI_URL const shots = process.env.ADDONS_SHOTS -const SECRET = 'SECRET-MESSAGE-TEXT-7731' async function shoot(page, name) { if (!shots) return @@ -39,30 +36,6 @@ async function apiAs(who) { return playwrightRequest.newContext({ baseURL: BASE, extraHTTPHeaders: { Authorization: `Bearer ${user.token}` } }) } -async function appOf(api, id) { - const response = await api.get('/api/addons/apps') - expect(response.status()).toBe(200) - return (await response.json()).apps.find(a => a.id === id) -} - -async function lifecycle(api, id, verb) { - const app = await appOf(api, id) - const response = await api.post(`/api/addons/apps/${id}/${verb}`, { data: { operationId: randomUUID(), generation: app.generation } }) - expect(response.status(), `${verb} ${id}`).toBe(200) -} - -const history = pair(SECRET, 'An answer that must stay in the page.') -async function openChat(page) { - await mockChat(page) - await seedChats(page, [chatOf('c1', 'Slots', 'qwen3-8b', history)], 'c1') - await page.goto('/app/chat') - await page.getByTestId('chat-composer').waitFor() -} - -const menuButton = (page) => page.getByTestId('addon-composer-menu') -const actionOf = (page, id) => page.locator(`[data-addon-action="${id}"]`) -const noOverflow = (page) => page.evaluate(() => document.scrollingElement.scrollWidth <= window.innerWidth) - test.describe.configure({ mode: 'serial' }) let admin @@ -70,182 +43,87 @@ test.beforeAll(async () => { admin = await apiAs('admin') }) test.afterAll(async () => { await admin.dispose() }) test.describe('Host UI slots', () => { - test('slots: the composer overflow menu holds the declared composer actions and components only', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'member') - await openChat(page) - const button = menuButton(page) - await expect(button).toBeVisible({ timeout: 25_000 }) - await expect(button).toHaveAttribute('aria-haspopup', 'menu') - await expect(button).toHaveAttribute('aria-expanded', 'false') - // Closed, no contribution of the add-on is mounted. - await expect(page.getByTestId('fx-composer-chip')).toHaveCount(0) - await button.click() - await expect(button).toHaveAttribute('aria-expanded', 'true') - const menu = page.getByRole('menu') - await expect(menu.getByRole('menuitem')).toHaveText(['Summarize chat', 'Review a request']) - // The component contribution is drawn in its own root, with the slot props only. - const chip = menu.getByTestId('fx-composer-chip') - await expect(chip).toBeVisible() - expect(JSON.parse(await chip.getAttribute('data-props'))).toEqual({ conversationId: 'c1', disabled: false }) - // Message-placement actions and components never appear in the composer menu. - await expect(menu.locator('[data-addon-action="inspect"], [data-testid="fx-message-chip"]')).toHaveCount(0) - // The throwing contribution is a marker; the page and the composer keep working. - await expect(menu.getByTestId('addon-slot-unavailable')).toHaveText('Unavailable') - await expect(page.getByTestId('chat-input')).toBeEditable() - await page.getByTestId('chat-input').fill('still typing') - await expect(page.getByTestId('chat-input')).toHaveValue('still typing') - // A module export the manifest does not declare is never drawn. - await expect(page.getByTestId('fx-rogue')).toHaveCount(0) - await shoot(page, 'slots-composer-menu-dark') - await context.close() - }) - - test('slots: a tool action calls only its declared own tool and draws the declared result renderer', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'member') - const calls = [] - page.on('request', (r) => { - if (r.url().endsWith('/api/addons/dispatch') && r.method() === 'POST') calls.push(JSON.parse(r.postData())) - }) - await openChat(page) - // The fixture owner has no resource integration, so the real gateway - // denies every read: the host says so in a dialog and draws no result. - await menuButton(page).click() - await actionOf(page, 'summarize').click() - await expect(page.getByTestId('addon-action-failed')).toHaveText('Summarize chat could not be completed.', { timeout: 25_000 }) - await page.keyboard.press('Escape') - await expect(page.getByRole('dialog')).toHaveCount(0) - // The success path: only the answer of the dispatch route is stubbed. The - // request is the real one the host built. - await page.route('**/api/addons/dispatch', async (route) => { - const body = route.request().postDataJSON() - await route.fulfill({ json: { payload: { actor: 'member', arguments: body.arguments } } }) - }) - calls.length = 0 - await menuButton(page).click() - await actionOf(page, 'summarize').click() - const card = page.getByTestId('fx-result-card') - await expect(card).toBeVisible({ timeout: 25_000 }) - await expect(card).toContainText('Summary of c1') - await expect(card).toContainText('tool summarize') - const reads = calls.filter(c => c.action === 'read') - expect(reads).toHaveLength(1) - expect(reads[0].addonId).toBe('slots') - expect(reads[0].arguments).toEqual({ tool: 'summarize', context: { conversationId: 'c1' } }) - // No conversation text left the page with the call. - expect(JSON.stringify(calls)).not.toContain(SECRET) - await shoot(page, 'slots-tool-result') - await page.keyboard.press('Escape') - await expect(card).toHaveCount(0) - await context.close() - }) - - test('slots: message actions sit in the message action row and carry no message text', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'member') - await openChat(page) - const assistant = page.locator('[data-testid="chat-message"][data-role="assistant"]') - const row = assistant.getByTestId('message-actions') - await expect(actionOf(page, 'inspect')).toHaveCount(2, { timeout: 25_000 }) - await expect(row.locator('[data-addon-action="inspect"]')).toHaveAttribute('aria-label', 'Inspect message') - await expect(row.locator('[data-addon-action="explode"]')).toHaveCount(1) - // Composer-placement actions are not drawn on messages. - await expect(page.locator('[data-testid="chat-message"] [data-addon-action="summarize"]')).toHaveCount(0) - const props = await row.getByTestId('fx-message-chip').getAttribute('data-props') - expect(JSON.parse(props)).toEqual({ conversationId: 'c1', messageId: '1', role: 'assistant' }) - // Nothing the contributions were given or drew holds the text of any message. - const attributes = await page.evaluate(() => Array.from(document.querySelectorAll('[data-props]')).map(n => n.getAttribute('data-props'))) - expect(attributes.join(' ')).not.toContain(SECRET) - expect(attributes.join(' ')).not.toContain('An answer that must stay') - await shoot(page, 'slots-message-actions') - await context.close() - }) - - test('slots: a component action opens the add-on component in a host dialog and a throwing one is contained', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'member') - await openChat(page) - const assistant = page.locator('[data-testid="chat-message"][data-role="assistant"]') - await assistant.locator('[data-addon-action="inspect"]').click() - const dialog = page.getByRole('dialog') - await expect(dialog).toBeVisible() - await expect(dialog).toContainText('Inspect message') - expect(JSON.parse(await dialog.getByTestId('fx-inspect').getAttribute('data-props'))).toEqual({ conversationId: 'c1', messageId: '1', role: 'assistant' }) - await page.keyboard.press('Escape') - await expect(dialog).toHaveCount(0) - // A component that throws leaves the contained error card and the chat page. - await assistant.locator('[data-addon-action="explode"]').click() - await expect(page.getByRole('dialog').getByTestId('addon-error')).toBeVisible() - await page.keyboard.press('Escape') - await expect(page.getByRole('dialog')).toHaveCount(0) - await expect(page.getByTestId('chat-composer')).toBeVisible() - await expect(page.locator('[data-testid="chat-message"]')).toHaveCount(2) - await context.close() - }) - - test('slots: the host approval frame owns Confirm and Cancel and the card only draws details', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'member') - const posts = [] - const proposal = { - id: 'op-fixture-1', operationId: 'op-fixture-1', addonId: 'slots', resourceId: 'r1', generation: 1, action: 'write', - arguments: { tool: 'ping', rows: 2 }, revision: { schema: 1, data: 1 }, digest: 'sha256:' + 'c'.repeat(64), - effects: { count: 2, sample: [{ row: 1 }, { row: 2 }] }, state: 'pending', - expiresAt: new Date(Date.now() + 5 * 60_000).toISOString(), createdAt: new Date().toISOString(), - } - await page.route('**/api/addons/proposals/op-fixture-1', route => route.fulfill({ json: proposal })) - await page.route('**/api/addons/proposals/op-fixture-1/confirm', (route) => { - posts.push({ headers: route.request().headers(), body: route.request().postDataJSON() }) - route.fulfill({ json: { ...proposal, state: 'applied' } }) - }) - await openChat(page) - await menuButton(page).click() - await actionOf(page, 'approve').click() - await page.getByRole('button', { name: 'Show the request' }).click() - const frame = page.getByTestId('approval-frame') - await expect(frame).toBeVisible() - await expect(frame.getByTestId('approval-count')).toHaveText('2') - const card = frame.getByTestId('fx-card') - await expect(card).toContainText('Card for ping: 2 changes') - // The card's own button does nothing to the proposal. - await card.getByRole('button', { name: 'Card button' }).click() - expect(posts).toHaveLength(0) - // Confirm and Cancel are the host's, outside the card's root. - const confirm = frame.getByTestId('approval-confirm') - expect(await confirm.evaluate(n => !n.closest('[data-addon]'))).toBe(true) - expect(await frame.getByTestId('approval-cancel').evaluate(n => !n.closest('[data-addon]'))).toBe(true) - await shoot(page, 'slots-approval-frame') - await confirm.click() - await expect(frame.getByTestId('approval-state')).toHaveText('Applied') - expect(posts).toHaveLength(1) - expect(posts[0].headers['x-localai-confirm']).toBe('op-fixture-1') - expect(posts[0].body).toEqual({ digest: proposal.digest }) - await expect(confirm).toBeDisabled() - await context.close() - }) - - test('slots: the settings page ends with a host-owned section for each contributing add-on', async ({ browser }) => { + test('slots: an add-on\'s settings sections are drawn on the Settings tab of its own details and nowhere else', async ({ browser }) => { const { context, page } = await browserAs(browser, 'admin') await mockChat(page) - await page.goto('/app/settings') - await expect(page.getByTestId('settings-page')).toBeVisible() + await page.goto('/app/addons?detail=slots&view=settings') + await expect(page.getByTestId('addon-detail-tab-settings')).toHaveAttribute('aria-selected', 'true', { timeout: 25_000 }) const section = page.getByTestId('addon-settings-section') // Two contributions: a working card and one that throws. await expect(section).toHaveCount(2, { timeout: 25_000 }) - await expect(page.locator('.ad-section__title')).toHaveText(['Slots fixture', 'Slots fixture']) + // Only this add-on's sections, and no heading that repeats its name. + await expect(page.locator('[data-testid="addon-settings-section"]:not([data-addon-section="slots"])')).toHaveCount(0) + await expect(page.locator('[data-testid="addon-detail"] .ad-section__head')).toHaveCount(0) await expect(page.getByTestId('fx-settings')).toContainText('Settings contributed by the slots fixture.') - // The failing section is a marker under its host heading; the settings page stays. - await expect(page.getByTestId('addon-slot-unavailable')).toHaveCount(1) + // A section is a block root, so its form keeps its own layout. + expect(await page.locator('[data-testid="addon-settings-section"] > .ad-slot').first().evaluate(n => getComputedStyle(n).display)).toBe('block') + // The failing section is a marker in its own section; the pane stays. await expect(section.nth(1).getByTestId('addon-slot-unavailable')).toBeVisible() - // The section is last in the page, after the host's own content. - const order = await page.evaluate(() => { - const page = document.querySelector('[data-testid="settings-page"]') - const host = page.querySelector('.st-bar') - const sections = page.querySelector('[data-testid="addon-settings-sections"]') - return Boolean(host.compareDocumentPosition(sections) & Node.DOCUMENT_POSITION_FOLLOWING) - }) - expect(order).toBe(true) - await section.first().scrollIntoViewIfNeeded() - await shoot(page, 'slots-settings-section') + await expect(page.getByTestId('addon-detail')).toBeVisible() + // The Overview tab is the details as before. + await page.getByTestId('addon-detail-tab-overview').click() + await expect(page).not.toHaveURL(/view=settings/) + await expect(page.getByTestId('preview-facts')).toBeVisible() + await expect(page.getByTestId('addon-settings-section')).toHaveCount(0) + // The Settings page draws no add-on section any more. + await page.goto('/app/settings') + await expect(page.getByTestId('settings-page')).toBeVisible() + await expect(page.locator('[data-group="addons"]')).toBeVisible({ timeout: 25_000 }) + await expect(page.getByTestId('addon-settings-sections')).toHaveCount(0) + await shoot(page, 'slots-settings-tab') await context.close() }) + test('slots: the Settings page lists the add-ons that have settings, each a link to its Settings tab', async ({ browser }) => { + const { context, page } = await browserAs(browser, 'admin') + await mockChat(page) + await page.goto('/app/settings') + const group = page.locator('.st-group[data-group="addons"]') + await expect(group).toBeVisible({ timeout: 25_000 }) + await group.click() + await expect(group).toHaveAttribute('aria-current', 'true') + const row = page.locator('[data-testid="settings-addon"][data-app="slots"]') + await expect(row).toBeVisible() + await expect(row).toContainText('Slots fixture') + await row.getByTestId('settings-addon-link').click() + await expect(page).toHaveURL(/\/app\/addons\?detail=slots&view=settings$/) + await expect(page.getByTestId('addon-settings-section')).toHaveCount(2, { timeout: 25_000 }) + // ?group= opens a group directly. + await page.goto('/app/settings?group=addons') + await expect(page.getByTestId('settings-addons')).toBeVisible({ timeout: 25_000 }) + await context.close() + }) + + test('slots: the old anchor of a section on the Settings page opens the add-on\'s Settings tab', async ({ browser }) => { + const { context, page } = await browserAs(browser, 'admin') + await mockChat(page) + await page.goto('/app/settings#ad-section-slots-settings-card') + await expect(page).toHaveURL(/\/app\/addons\?detail=slots&view=settings$/, { timeout: 25_000 }) + await expect(page.getByTestId('addon-settings-section')).toHaveCount(2, { timeout: 25_000 }) + await context.close() + }) + + test('slots: the add-on page and its details offer the settings to an administrator only', async ({ browser }) => { + const admin = await browserAs(browser, 'admin') + await mockChat(admin.page) + await admin.page.goto('/app/addons/slots/home') + const link = admin.page.getByTestId('addon-page-settings') + await expect(link).toBeVisible({ timeout: 25_000 }) + await link.click() + await expect(admin.page).toHaveURL(/\/app\/addons\?detail=slots&view=settings$/) + await admin.context.close() + const member = await browserAs(browser, 'member') + await mockChat(member.page) + await member.page.goto('/app/addons/slots/home') + await expect(member.page.getByTestId('addon-surface')).toBeVisible({ timeout: 25_000 }) + await expect(member.page.getByTestId('addon-page-settings')).toHaveCount(0) + await member.page.goto('/app/addons?detail=slots&view=settings') + await expect(member.page.getByTestId('addon-detail')).toBeVisible({ timeout: 25_000 }) + await expect(member.page.getByTestId('addon-detail-tab-settings')).toHaveCount(0) + await expect(member.page.getByTestId('addon-settings-section')).toHaveCount(0) + await member.context.close() + }) + test('slots: the navigation slot adds its entry below the tile grid of the Add-ons drawer', async ({ browser }) => { const { context, page } = await browserAs(browser, 'member') await mockChat(page) @@ -269,141 +147,4 @@ test.describe('Host UI slots', () => { await context.close() }) - test('slots: add-on style sheets that collide with host class names change no host node', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'member') - await openChat(page) - await expect(menuButton(page)).toBeVisible({ timeout: 25_000 }) - const probe = await page.evaluate(() => { - const s = (el) => getComputedStyle(el) - const acts = document.querySelector('[data-testid="message-actions"]') - const attach = document.querySelector('.home-attach-btn') - return { - acts: s(acts).display, - attachWidth: attach.getBoundingClientRect().width, - attachBackground: s(attach).backgroundColor, - body: s(document.body).backgroundColor, - sheet: document.querySelector('style[data-addon-style="slots"]')?.textContent || '', - } - }) - expect(probe.sheet).toContain('[data-addon="slots"]') - expect(probe.acts).not.toBe('none') - expect(probe.attachWidth).toBeGreaterThan(8) - expect(probe.attachBackground).not.toBe('rgb(255, 0, 0)') - expect(probe.body).not.toBe('rgb(255, 0, 0)') - await context.close() - }) - - test('slots: keyboard users open the menu, move through it, close it with Escape and run an action', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'member') - await openChat(page) - const button = menuButton(page) - await expect(button).toBeVisible({ timeout: 25_000 }) - await button.focus() - await page.keyboard.press('Enter') - const items = page.getByRole('menuitem') - await expect(items.first()).toBeFocused() - await page.keyboard.press('ArrowDown') - await expect(items.nth(1)).toBeFocused() - await page.keyboard.press('ArrowDown') - await expect(items.first()).toBeFocused() - await page.keyboard.press('Escape') - await expect(page.getByRole('menu')).toHaveCount(0) - await expect(button).toBeFocused() - await page.keyboard.press('Enter') - await page.keyboard.press('ArrowDown') - await page.keyboard.press('Enter') - await expect(page.getByRole('dialog')).toContainText('Review a request') - await page.keyboard.press('Escape') - await expect(page.getByRole('dialog')).toHaveCount(0) - await context.close() - }) - - test('slots: the layout keeps inside a 400 px screen in the light theme', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'admin', { viewport: { width: 400, height: 800 } }) - await setTheme(page, 'light') - await openChat(page) - await expect(menuButton(page)).toBeVisible({ timeout: 25_000 }) - expect(await noOverflow(page)).toBe(true) - await menuButton(page).click() - const box = await page.getByRole('menu').boundingBox() - expect(box.x).toBeGreaterThanOrEqual(0) - expect(box.x + box.width).toBeLessThanOrEqual(400) - await shoot(page, 'slots-narrow-menu-light') - await page.keyboard.press('Escape') - await expect(actionOf(page, 'inspect').last()).toBeVisible() - expect(await noOverflow(page)).toBe(true) - await page.goto('/app/settings') - await expect(page.getByTestId('addon-settings-section').first()).toBeVisible({ timeout: 25_000 }) - expect(await noOverflow(page)).toBe(true) - await page.getByTestId('addon-settings-section').first().scrollIntoViewIfNeeded() - await shoot(page, 'slots-narrow-settings-light') - await context.close() - }) - - test('slots: a caller without the add-ons feature sees no contribution and the server denies the tool call', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'outsider') - const apps = [] - page.on('response', (r) => { if (/\/api\/addons\/apps$/.test(r.url())) apps.push(r.status()) }) - await openChat(page) - await expect(page.getByTestId('chat-composer')).toBeVisible() - await expect(page.locator('[data-testid="chat-message"]')).toHaveCount(2) - await expect(menuButton(page)).toHaveCount(0) - await expect(page.locator('[data-addon-action]')).toHaveCount(0) - await expect(page.getByTestId('addon-composer')).toHaveCount(0) - await page.goto('/app/settings') - await expect(page.getByTestId('addon-settings-section')).toHaveCount(0) - // Hiding is never the control: the same call through the real gateway is denied. - const app = await appOf(admin, 'slots') - for (const who of ['outsider']) { - const api = await apiAs(who) - const response = await api.post('/api/addons/dispatch', { - data: { operationId: randomUUID(), addonId: 'slots', generation: app.generation, action: 'read', arguments: { tool: 'summarize', context: { conversationId: 'c1' } } }, - }) - expect(response.status()).toBe(403) - expect((await api.get('/api/addons/apps')).status()).toBe(403) - await api.dispose() - } - // The member holds the feature and sees the contributions; the owner has - // no resource integration for this fixture, so a read is denied for them too. - const member = await apiAs('member') - expect((await member.get('/api/addons/apps')).status()).toBe(200) - const denied = await member.post('/api/addons/dispatch', { - data: { operationId: randomUUID(), addonId: 'slots', generation: app.generation, action: 'read', arguments: { tool: 'summarize', context: { conversationId: 'c1' } } }, - }) - expect(denied.status()).toBe(403) - await member.dispose() - expect(apps.every(s => s === 403 || s === 401)).toBe(true) - await context.close() - }) - - test('slots: disabling the add-on through the real owner removes every contribution and enabling brings them back', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'member') - await openChat(page) - await expect(menuButton(page)).toBeVisible({ timeout: 25_000 }) - await expect(actionOf(page, 'inspect')).toHaveCount(2) - await menuButton(page).click() - await expect(page.getByTestId('fx-composer-chip')).toBeVisible() - await lifecycle(admin, 'slots', 'stop') - // The owner hides a stopped app from readers; the next list unmounts it all. - await expect(menuButton(page)).toHaveCount(0, { timeout: 30_000 }) - await expect(page.getByTestId('fx-composer-chip')).toHaveCount(0) - await expect(actionOf(page, 'inspect')).toHaveCount(0) - await expect(page.locator('[data-addon="slots"]')).toHaveCount(0) - await expect(page.locator('style[data-addon-style="slots"]')).toHaveCount(0) - const log = await page.evaluate(() => window.__slotsFixture.events) - expect(log).toContain('abort:1') - expect(log).toContain('dispose:1') - // The chat itself is untouched. - await expect(page.getByTestId('chat-composer')).toBeVisible() - await expect(page.locator('[data-testid="chat-message"]')).toHaveCount(2) - await shoot(page, 'slots-after-disable') - await lifecycle(admin, 'slots', 'start') - await expect.poll(async () => (await appOf(admin, 'slots')).state, { timeout: 30_000 }).toBe('running') - // A list that holds no contribution is not polled again, so the add-on - // returns with the next page load. - await page.reload() - await expect(menuButton(page)).toBeVisible({ timeout: 30_000 }) - await expect(actionOf(page, 'inspect')).toHaveCount(2) - await context.close() - }) }) diff --git a/core/http/react-ui/e2e/addons-upgrade-check.spec.js b/core/http/react-ui/e2e/addons-upgrade-check.spec.js new file mode 100644 index 000000000..f348df3f5 --- /dev/null +++ b/core/http/react-ui/e2e/addons-upgrade-check.spec.js @@ -0,0 +1,110 @@ +import { test, expect } from '@playwright/test' +import { ADMIN, app, installedAddon, sessionFor, mockAddons } from './addons-fixtures.js' + +// "Check again" on the card of an add-on whose update notice cannot be acted +// on yet. The check route answers the owner's status, whose candidate fields +// are candidateVersion and candidateDigest; the installed records carry the +// update object with version and digest. The routes are stand-ins +// (addons-fixtures.js); the owner's own decisions are covered by the Go specs +// of the update checker. + +const CANDIDATE = 'sha256:' + 'b'.repeat(64) +const adminOf = (id) => ({ canStart: false, canStop: true, managementUrl: `/app/addons?manage=${id}` }) +const pending = (id, title) => app({ id, name: id, title, update: 'update-pending', admin: adminOf(id), icon: undefined, mainPage: undefined }) +const rec = (id, title, update) => installedAddon({ id, title, icon: undefined, mainPage: undefined, version: '0.0.0-dev.2cadf319a0e1', channel: 'development', digest: 'sha256:' + 'a'.repeat(64), update }) +const tile = (page, id) => page.locator(`[data-testid="addon-tile"][data-app="${id}"]`) +// A development build the owner reported as an update with no build to pin. +const unpinned = () => ({ available: true, state: 'update-available', version: '0.0.0-development', checkedAt: '2026-10-30T16:00:00Z' }) +const status = (over) => ({ status: 200, contentType: 'application/json', body: JSON.stringify({ addonId: 'talk', generation: 1, installedVersion: '0.0.0-dev.2cadf319a0e1', channel: 'development', selection: '0.0.0-development', checkedAt: new Date().toISOString(), ...over }) }) + +async function signIn(page) { + await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN) })) +} + +test.describe('Add-ons: Check again on the card', () => { + test('a check that still names no build to pin says so, and when it checked', async ({ page }) => { + await signIn(page) + const state = await mockAddons(page, { + apps: [pending('talk', 'Talk')], + installed: [rec('talk', 'Talk', unpinned())], + check: () => status({ state: 'update-available', available: true, candidateVersion: '0.0.0-development' }), + }) + await page.goto('/app/addons') + const card = tile(page, 'talk') + await expect(card.getByTestId('upgrade-reason')).toContainText('An update is reported, but its details are not confirmed yet.') + await expect(card.getByTestId('upgrade-check-result')).toHaveCount(0) + await card.getByTestId('upgrade-reason-check').click() + await expect.poll(() => state.log.check.length).toBe(1) + const result = card.getByTestId('upgrade-check-result') + await expect(result).toHaveText('Checked just now.') + // The same answer again is explained, not sent back to another check. + await expect(card.getByTestId('upgrade-reason')).toContainText('The library reports an update but names no build to install, so it cannot be upgraded from here.') + await expect(card.getByTestId('upgrade-reason')).not.toContainText('not confirmed yet') + await expect(card.getByTestId('update-upgrade-card')).toHaveCount(0) + await expect(card.getByTestId('upgrade-reason-check')).toBeEnabled() + }) + + test('a check that finds the tag moved offers Upgrade pinned to the build it names', async ({ page }) => { + await signIn(page) + let talk = null + const state = await mockAddons(page, { + apps: [pending('talk', 'Talk')], + installed: [rec('talk', 'Talk', unpinned())], + check: () => { + talk.update = { available: true, state: 'update-available', version: '0.0.0-development.bbbbbbbbbbbb', digest: CANDIDATE, checkedAt: new Date().toISOString() } + return status({ state: 'update-available', available: true, candidateVersion: '0.0.0-development.bbbbbbbbbbbb', candidateDigest: CANDIDATE }) + }, + }) + talk = state.installed.find(a => a.id === 'talk') + await page.goto('/app/addons') + const card = tile(page, 'talk') + await card.getByTestId('upgrade-reason-check').click() + await expect(card.getByTestId('update-upgrade-card')).toBeVisible({ timeout: 15_000 }) + await card.getByTestId('update-upgrade-card').click() + await expect(page.getByTestId('upgrade-confirm')).toContainText('0.0.0-development.bbbbbbbbbbbb') + await page.getByRole('alertdialog').getByRole('button', { name: 'Upgrade' }).click() + await expect.poll(() => state.log.upgrade.length).toBe(1) + expect(state.log.upgrade[0]).toMatchObject({ id: 'talk', candidateDigest: CANDIDATE, expectedDigest: 'sha256:' + 'a'.repeat(64) }) + }) + + test('a check that finds the add-on current says so after the notice is gone', async ({ page }) => { + await signIn(page) + let talk = null + let listed = null + const state = await mockAddons(page, { + apps: [pending('talk', 'Talk')], + installed: [rec('talk', 'Talk', unpinned())], + check: () => { + talk.update = { available: false, state: 'up-to-date', checkedAt: new Date().toISOString() } + delete listed.update + return status({ state: 'up-to-date', available: false, candidateVersion: '0.0.0-development.aaaaaaaaaaaa', candidateDigest: 'sha256:' + 'a'.repeat(64) }) + }, + }) + talk = state.installed.find(a => a.id === 'talk') + listed = state.apps.find(a => a.id === 'talk') + await page.goto('/app/addons') + const card = tile(page, 'talk') + await card.getByTestId('upgrade-reason-check').click() + await expect(card.getByTestId('upgrade-check-result')).toHaveText('Checked just now. This add-on is up to date.') + await expect(card.getByTestId('update-chip')).toHaveCount(0) + await expect(card.getByTestId('update-upgrade-card')).toHaveCount(0) + }) + + test('a failed check shows why, and the check can be run again', async ({ page }) => { + await signIn(page) + const state = await mockAddons(page, { + apps: [pending('talk', 'Talk')], + installed: [rec('talk', 'Talk', unpinned())], + check: () => ({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: { type: 'check_failed', message: 'check_failed', code: 503 } }) }), + }) + await page.goto('/app/addons') + const card = tile(page, 'talk') + await card.getByTestId('upgrade-reason-check').click() + const result = card.getByTestId('upgrade-check-result') + await expect(result).toHaveText('The check did not complete. The library could not be read, so the upgrade was not started. Nothing was changed.') + await expect(result).toHaveAttribute('data-tone', 'error') + await expect(card.getByTestId('upgrade-reason-check')).toBeEnabled() + await card.getByTestId('upgrade-reason-check').click() + await expect.poll(() => state.log.check.length).toBe(2) + }) +}) diff --git a/core/http/react-ui/e2e/addons-upgrade-list.spec.js b/core/http/react-ui/e2e/addons-upgrade-list.spec.js new file mode 100644 index 000000000..0a08df6b8 --- /dev/null +++ b/core/http/react-ui/e2e/addons-upgrade-list.spec.js @@ -0,0 +1,145 @@ +import { test, expect } from '@playwright/test' +import { ADMIN, MEMBER, app, installedAddon, sessionFor, mockAddons, fail } from './addons-fixtures.js' + +// The upgrade action where the "update available" notice is seen: the list. +// The routes are stand-ins (addons-fixtures.js); the owner's own decisions are +// covered by addons-upgrade.spec.js against the real owner. + +const shots = process.env.UPGRADE_SHOTS +const theme = process.env.UPGRADE_THEME +async function shoot(page, name) { + if (!shots) return + await page.emulateMedia({ reducedMotion: 'reduce' }) + await page.waitForTimeout(400) + await page.screenshot({ path: `${shots}/${name}${process.env.UPGRADE_SUFFIX || ''}.png`, fullPage: false }) +} + +const CANDIDATE = 'sha256:' + 'b'.repeat(64) +const adminOf = (id) => ({ canStart: false, canStop: true, managementUrl: `/app/addons?manage=${id}` }) +const pending = (id, title) => app({ id, name: id, title, update: 'update-pending', admin: adminOf(id), icon: undefined, mainPage: undefined }) +const avail = (over = {}) => ({ available: true, state: 'update-available', version: '1.3.0', digest: CANDIDATE, checkedAt: '2026-10-01T00:00:00Z', ...over }) +const rec = (id, title, update) => installedAddon({ id, title, icon: undefined, mainPage: undefined, digest: 'sha256:' + id.padEnd(32, '0'), update }) +const tile = (page, id) => page.locator(`[data-testid="addon-tile"][data-app="${id}"]`) + +async function signIn(page, user, permissions = {}) { + if (theme) await page.addInitScript((value) => { try { localStorage.setItem('localai-theme', value) } catch { /* private window */ } }, theme) + await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(user, permissions) })) +} + +test.describe('Add-ons: upgrade from the list', () => { + test('the Upgrade action is on the card of an add-on with an update, and opens the confirmation', async ({ page }) => { + await signIn(page, ADMIN) + const state = await mockAddons(page, { + apps: [pending('notes', 'Notes'), app({ id: 'plain', title: 'Plain', admin: adminOf('plain'), icon: undefined, mainPage: undefined })], + installed: [rec('notes', 'Notes', avail()), rec('plain', 'Plain', { state: 'up-to-date', available: false })], + }) + await page.goto('/app/addons') + await expect(tile(page, 'notes').getByTestId('update-chip')).toBeVisible() + await expect(tile(page, 'plain').getByTestId('update-upgrade-card')).toHaveCount(0) + await expect(tile(page, 'notes').getByTestId('update-upgrade-card')).toBeVisible() + await shoot(page, 'list-action') + await tile(page, 'notes').getByTestId('update-upgrade-card').click() + await expect(page.getByTestId('upgrade-confirm')).toContainText('1.3.0') + expect(state.log.upgrade).toHaveLength(0) + await page.getByRole('alertdialog').getByRole('button', { name: 'Upgrade' }).click() + await expect.poll(() => state.log.upgrade.length).toBe(1) + expect(state.log.upgrade[0]).toMatchObject({ id: 'notes', candidateDigest: CANDIDATE }) + }) + + test('when the upgrade cannot start the card says why, and Check again can make it startable', async ({ page }) => { + await signIn(page, ADMIN) + let installedRef = null + const state = await mockAddons(page, { + check: ({ id }) => { if (id === 'tables') installedRef.update = { ...installedRef.update, digest: CANDIDATE }; return null }, + apps: [pending('tables', 'Tables'), pending('docs', 'Docs'), pending('slack', 'Slack')], + installed: [ + rec('tables', 'Tables', { available: true, state: 'update-available', version: '2.0.0', checkedAt: '2026-10-01T00:00:00Z' }), + rec('docs', 'Docs', { available: false, state: 'incompatible', version: '3.0.0', reasons: ['host-version'] }), + rec('slack', 'Slack', { state: 'check-failed', available: false, error: 'refresh-failed' }), + ], + }) + installedRef = state.installed.find(a => a.id === 'tables') + await page.goto('/app/addons') + await expect(tile(page, 'tables').getByTestId('upgrade-reason')).toContainText('not confirmed yet') + await expect(tile(page, 'tables').getByTestId('update-upgrade-card')).toHaveCount(0) + await expect(tile(page, 'docs').getByTestId('upgrade-reason')).toContainText('cannot run here') + await expect(tile(page, 'docs').getByTestId('upgrade-reason')).not.toContainText('Check again') + await expect(tile(page, 'slack').getByTestId('upgrade-reason')).toContainText('last check failed') + await shoot(page, 'list-reasons') + // The check resolves the candidate: the owner now has a digest. + await tile(page, 'tables').getByTestId('upgrade-reason-check').click() + await expect.poll(() => state.log.check.length).toBe(1) + expect(state.log.check[0].id).toBe('tables') + await expect(tile(page, 'tables').getByTestId('update-upgrade-card')).toBeVisible({ timeout: 15_000 }) + await expect(tile(page, 'tables').getByTestId('upgrade-reason')).toHaveCount(0) + }) + + test('a user who is not an administrator sees the notice with the reason and no action', async ({ page }) => { + await signIn(page, MEMBER, { addons: true }) + await mockAddons(page, { apps: [app({ id: 'notes', title: 'Notes', update: 'update-pending', icon: undefined, mainPage: undefined })], installed: [] }) + await page.goto('/app/addons') + await expect(tile(page, 'notes').getByTestId('update-chip')).toBeVisible() + await expect(tile(page, 'notes').getByTestId('upgrade-reason')).toContainText('Only an administrator') + await expect(tile(page, 'notes').getByTestId('update-upgrade-card')).toHaveCount(0) + await expect(page.getByTestId('bulk-upgrade-open')).toHaveCount(0) + }) + + test('the Browse entry of an installed add-on with an update carries the same action', async ({ page }) => { + await signIn(page, ADMIN) + const state = await mockAddons(page, { apps: [pending('notes', 'Notes')], installed: [rec('notes', 'Notes', avail())] }) + await page.goto('/app/addons?tab=browse') + await expect(page.getByTestId('entry-upgrade').getByTestId('update-upgrade-card')).toBeVisible() + await page.getByTestId('entry-upgrade').getByTestId('update-upgrade-card').click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Upgrade' }).click() + await expect.poll(() => state.log.upgrade.length).toBe(1) + }) +}) + +test.describe('Add-ons: upgrade all', () => { + const ids = ['alpha', 'bravo', 'charlie', 'delta'] + const setup = async (page, upgrade) => { + await signIn(page, ADMIN) + return mockAddons(page, { + apps: [...ids.map(id => pending(id, id[0].toUpperCase() + id.slice(1))), pending('echo', 'Echo')], + installed: [...ids.map(id => rec(id, id[0].toUpperCase() + id.slice(1), avail())), rec('echo', 'Echo', { available: true, state: 'update-available', version: '9.0.0' })], + upgrade, + }) + } + + test('runs the selected add-ons one after another, keeps going after a failure and retries the failed one', async ({ page }) => { + let bravo = 0 + const state = await setup(page, ({ id }) => { + if (id !== 'bravo') return null + bravo += 1 + return bravo === 1 ? fail(409, 'candidate_changed') : null + }) + await page.goto('/app/addons') + await expect(page.getByTestId('bulk-upgrade-open')).toContainText('Upgrade all (4)') + await page.getByTestId('bulk-upgrade-open').click() + const dialog = page.getByTestId('bulk-upgrade-dialog') + await expect(dialog.getByTestId('bulk-upgrade-item')).toHaveCount(5) + // The add-on that cannot start is listed with its reason and cannot be picked. + const echo = dialog.locator('[data-item="echo"]') + await expect(echo.getByRole('checkbox')).toBeDisabled() + await expect(echo.getByTestId('bulk-upgrade-why')).toContainText('not confirmed') + await dialog.getByTestId('bulk-upgrade-none').click() + await expect(dialog.getByTestId('bulk-upgrade-start')).toBeDisabled() + await dialog.getByTestId('bulk-upgrade-all').click() + await dialog.locator('[data-item="delta"]').getByRole('checkbox').uncheck() + await shoot(page, 'bulk-select') + await dialog.getByTestId('bulk-upgrade-start').click() + await expect(dialog.locator('[data-item="bravo"]')).toHaveAttribute('data-phase', 'failed') + await expect(dialog.locator('[data-item="charlie"]')).toHaveAttribute('data-phase', 'done') + await expect(dialog.getByTestId('bulk-upgrade-progress')).toContainText('2 upgraded, 1 failed, 0 skipped') + expect(state.log.upgrade.map(u => u.id)).toEqual(['alpha', 'bravo', 'charlie']) + expect(new Set(state.log.upgrade.map(u => u.operationId)).size).toBe(3) + await expect(dialog.getByTestId('bulk-upgrade-error')).toHaveCount(1) + await shoot(page, 'bulk-result') + await dialog.getByTestId('bulk-upgrade-retry').click() + await expect(dialog.getByTestId('bulk-upgrade-progress')).toContainText('1 upgraded, 0 failed') + expect(state.log.upgrade.map(u => u.id)).toEqual(['alpha', 'bravo', 'charlie', 'bravo']) + expect(state.log.upgrade[3].operationId).not.toBe(state.log.upgrade[1].operationId) + await dialog.getByTestId('bulk-upgrade-close').click() + await expect(page.getByTestId('bulk-upgrade-summary')).toContainText('1 upgraded') + }) +}) diff --git a/core/http/react-ui/e2e/addons-upgrade.spec.js b/core/http/react-ui/e2e/addons-upgrade.spec.js index 254f4aeb0..e9d30be8c 100644 --- a/core/http/react-ui/e2e/addons-upgrade.spec.js +++ b/core/http/react-ui/e2e/addons-upgrade.spec.js @@ -138,7 +138,7 @@ test.describe('Add-on updates and the explicit upgrade through the real owner', await state.memberPage.goto('/app/addons') const card = state.memberPage.locator('[data-app="tables"]') await expect(card.getByTestId('update-chip')).toBeVisible({ timeout: 25_000 }) - await expect(card.locator('.ad-tile__state')).toHaveText('On') + await expect(card.locator('.ad-tile__state')).toHaveText('Running') await shoot(state.memberPage, 'updates-pending-chip-1280', 'update-chip') const read = await pageFetch(state.memberPage, '/api/addons/tables/update') expect(read.status).toBe(200) @@ -173,31 +173,30 @@ test.describe('Add-on updates and the explicit upgrade through the real owner', await outsider.context.close() }) - test('updates: the sheet names the library, versions and digests, a cancel sends nothing, and the upgrade backs up and keeps the data', async ({ browser }) => { + test('updates: the dialog names the library, versions and digests, a cancel sends nothing, and the upgrade backs up and keeps the data', async ({ browser }) => { const admin = await browserAs(browser, 'admin') const api = await apiAs('admin') await openManage(admin.page) await expect(admin.page.getByTestId('update-state')).toHaveAttribute('data-state', 'update-available') state.candidate = (await installed(api)).update.digest await admin.page.getByTestId('update-upgrade').click() - const dialog = admin.page.getByTestId('update-review') + const dialog = admin.page.getByTestId('upgrade-confirm') await expect(dialog).toBeVisible() - await expect(dialog.getByTestId('update-review-detail')).toContainText('development') - await expect(dialog.getByTestId('update-review-detail')).toContainText(state.firstDigest.slice(0, 19)) - await expect(dialog.getByTestId('update-review-detail')).toContainText(state.candidate.slice(0, 19)) - await expect(dialog).toContainText('backs up the data') - await shoot(admin.page, 'updates-dialog-1280', 'update-review') + await expect(dialog).toContainText('development') + await expect(dialog).toContainText(state.firstDigest.slice(0, 19)) + await expect(dialog).toContainText(state.candidate.slice(0, 19)) + await expect(dialog).toContainText('backed up before anything changes') + await shoot(admin.page, 'updates-dialog-1280', 'upgrade-confirm') // Escape closes it and sends nothing. await admin.page.keyboard.press('Escape') await expect(dialog).toBeHidden() expect((await installed(api)).digest).toBe(state.firstDigest) await admin.page.getByTestId('update-upgrade').click() - await admin.page.getByTestId('update-review-selected').click() + await admin.page.getByRole('alertdialog').getByRole('button', { name: 'Upgrade' }).click() await expect(admin.page.getByTestId('upgrade-progress')).toBeVisible() await shoot(admin.page, 'updates-progress-1280', null, { settle: 0 }) await expect(admin.page.getByTestId('upgrade-progress')).toBeHidden({ timeout: 90_000 }) - await admin.page.getByTestId('update-review-close').click() await expect(admin.page.getByTestId('update-state')).toHaveAttribute('data-state', 'up-to-date', { timeout: 30_000 }) const now = await installed(api) expect(now.digest).toBe(state.candidate) diff --git a/core/http/react-ui/e2e/agent-jobs-run.spec.js b/core/http/react-ui/e2e/agent-jobs-run.spec.js deleted file mode 100644 index 86483f640..000000000 --- a/core/http/react-ui/e2e/agent-jobs-run.spec.js +++ /dev/null @@ -1,101 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { mockJobs } from './jobs-fixtures.js' - -test.describe('A job run', () => { - test('a finished job reads as a document: task, outcome, delivery and steps', async ({ page }) => { - await mockJobs(page) - await page.goto('/app/agent-jobs/jobs/job-done-0002') - const doc = page.getByTestId('job-page') - await expect(doc.getByRole('heading', { level: 1 })).toContainText('repo-triage, today') - await expect(doc).toContainText('Done') - await expect(doc).toContainText('Started by the schedule') - await expect(doc).toContainText('Took 52 s') - await expect(doc).toContainText('job-done-0002') - await expect(doc.getByRole('heading', { name: 'Task' })).toBeVisible() - await expect(doc.locator('.aj-prompt')).toContainText('Read the new issues in example/app and label each one.') - await expect(doc.getByRole('heading', { name: 'Outcome' })).toBeVisible() - await expect(doc).toContainText('Labelled 12 issues and routed 3 of them to the person on call.') - await expect(doc.locator('.cx-prose table')).toBeVisible() - await expect(doc.getByTestId('job-webhook')).toContainText('Webhook delivered') - await expect(doc.getByRole('button', { name: 'Run again' })).toBeEnabled() - await expect(doc.getByRole('link', { name: 'Open task' })).toHaveAttribute('href', '/app/agent-jobs/tasks/t2') - }) - - test('the steps the server recorded are rows that open into what they hold', async ({ page }) => { - await mockJobs(page) - await page.goto('/app/agent-jobs/jobs/job-done-0002') - const rows = page.getByTestId('job-traces').locator('li') - await expect(rows).toHaveCount(2) - await expect(rows.nth(1)).toContainText('list_issues') - await expect(rows.nth(1)).toContainText('Tool call') - await expect(rows.nth(1).locator('.aj-trace__body')).toHaveCount(0) - await rows.nth(1).getByRole('button').click() - await expect(rows.nth(1).locator('.aj-trace__body')).toContainText('example/app') - await expect(rows.nth(1).locator('.aj-trace__body')).toContainText('Listing issues') - }) - - test('a job with no recorded steps says so', async ({ page }) => { - await mockJobs(page) - await page.goto('/app/agent-jobs/jobs/job-done-0004') - await expect(page.getByText('The server recorded no steps for this job.')).toBeVisible() - await expect(page.getByTestId('job-traces')).toHaveCount(0) - }) - - test('a failed job says what happened in plain words and offers one next action', async ({ page }) => { - const { seen } = await mockJobs(page) - await page.goto('/app/agent-jobs/jobs/job-fail-0001') - const fail = page.getByTestId('job-failed') - await expect(fail).toContainText('This job failed.') - await expect(fail).toContainText('read_page: timeout after 20 s (3 of 3 attempts)') - await expect(page.locator('.aj-prompt .aj-gap[data-filled]')).toHaveCount(3) - await fail.getByRole('button', { name: 'Run again now' }).click() - expect(seen.jobExecutes).toEqual([{ task_id: 't1', parameters: { topic: 'local inference', format: 'bullet points', items: '8' } }]) - await expect(page).toHaveURL(/\/app\/agent-jobs\/jobs\/job-new-0006$/) - await expect(page.getByTestId('job-working')).toContainText('waiting to start') - }) - - test('a running job says it is working and Cancel stops it', async ({ page }) => { - const { seen } = await mockJobs(page) - await page.goto('/app/agent-jobs/jobs/job-run-0003') - await expect(page.getByTestId('job-working')).toContainText('still running') - await expect(page.getByRole('button', { name: 'Run again' })).toHaveCount(0) - await page.getByRole('button', { name: 'Cancel' }).click() - await expect(page.getByText('Job cancelled')).toBeVisible() - expect(seen.cancels).toEqual(['job-run-0003']) - await expect(page.getByText('This job was cancelled before it finished.')).toBeVisible({ timeout: 6000 }) - }) - - test('does not invent a duration when the job has no end time', async ({ page }) => { - await mockJobs(page) - await page.goto('/app/agent-jobs/jobs/job-stop-0005') - await expect(page.getByTestId('job-page')).not.toContainText('Took') - await expect(page.getByTestId('job-page')).toContainText('Cancelled') - await expect(page.getByTestId('job-page')).toContainText('Started by the API') - }) - - test('says so when the job does not exist', async ({ page }) => { - await mockJobs(page) - await page.goto('/app/agent-jobs/jobs/nope') - await expect(page.getByTestId('job-missing')).toContainText('Job not found') - }) - - test('copies its address', async ({ page, context }) => { - await context.grantPermissions(['clipboard-read', 'clipboard-write']) - await mockJobs(page) - await page.goto('/app/agent-jobs/jobs/job-done-0002') - await page.getByRole('button', { name: 'Copy link' }).click() - await expect(page.getByText('Link copied')).toBeVisible() - }) -}) - -test.describe('A job run on a phone', () => { - test.use({ viewport: { width: 390, height: 844 } }) - - test('fits the width', async ({ page }) => { - await mockJobs(page) - await page.goto('/app/agent-jobs/jobs/job-fail-0001') - await expect(page.getByTestId('job-failed')).toBeVisible() - const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth) - expect(overflow).toBeLessThanOrEqual(0) - }) -}) diff --git a/core/http/react-ui/e2e/agent-jobs-task.spec.js b/core/http/react-ui/e2e/agent-jobs-task.spec.js deleted file mode 100644 index dba88c189..000000000 --- a/core/http/react-ui/e2e/agent-jobs-task.spec.js +++ /dev/null @@ -1,229 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { mockJobs } from './jobs-fixtures.js' - -test.describe('A task', () => { - test('shows what it is: model, schedule in words, runs, prompt with its gaps', async ({ page }) => { - await mockJobs(page) - await page.goto('/app/agent-jobs/tasks/t1') - const facts = page.getByTestId('task-facts') - await expect(page.getByRole('heading', { name: 'daily-digest' })).toBeVisible() - await expect(facts).toContainText('qwen3-8b-instruct') - await expect(facts).toContainText('Every day at 07:00') - await expect(facts).toContainText('0 7 * * *') - await expect(facts).toContainText('1 webhook') - await expect(facts.getByRole('switch')).toHaveAttribute('aria-checked', 'true') - const runs = page.getByTestId('task-runs') - await expect(runs.locator('a')).toHaveCount(2) - await expect(runs.locator('a').first()).toContainText('read_page: timeout after 20 s') - await expect(runs.locator('a').first()).toContainText('Started by the schedule, took 1 min 06 s') - const gaps = page.locator('.aj-prompt .aj-gap') - await expect(gaps).toHaveCount(3) - await expect(gaps.first()).toHaveText('local inference') - await expect(page.getByText('Audience: engineers who run models on their own hardware.')).toBeVisible() - await expect(page.getByText('Times follow the clock of the machine that runs LocalAI.')).toBeVisible() - await expect(page.locator('.dk-hubtabs [data-hub-tab="jobs"]')).toHaveAttribute('aria-current', 'page') - }) - - test('a task with no schedule says it runs when started; the API examples stay one click away', async ({ page }) => { - await mockJobs(page) - await page.goto('/app/agent-jobs/tasks/t4') - await expect(page.getByText('This task has no schedule.')).toBeVisible() - await expect(page.getByTestId('task-facts').getByRole('switch')).toHaveAttribute('aria-checked', 'false') - await expect(page.getByText('This task has not run yet.')).toBeVisible() - await page.getByText('Use it from the API').click() - await expect(page.getByText(/api\/agent\/tasks\/ad-hoc-research\/execute/).first()).toBeVisible() - }) - - test('the switch saves the task; Run now opens the dialog; a run row opens the job', async ({ page }) => { - const { seen } = await mockJobs(page) - await page.goto('/app/agent-jobs/tasks/t1') - await page.getByTestId('task-facts').getByRole('switch').click() - await expect(page.getByText('daily-digest is off')).toBeVisible() - expect(seen.puts[0].body.enabled).toBe(false) - await page.getByRole('button', { name: 'Run now' }).click() - await expect(page.getByTestId('run-task-dialog')).toContainText('Run daily-digest now') - await page.keyboard.press('Escape') - await page.getByTestId('task-runs').locator('a').first().click() - await expect(page).toHaveURL(/\/app\/agent-jobs\/jobs\/job-fail-0001$/) - }) - - test('a task that does not exist says so', async ({ page }) => { - await mockJobs(page) - await page.goto('/app/agent-jobs/tasks/nope') - await expect(page.getByTestId('task-missing')).toContainText('Task not found') - }) -}) - -test.describe('Edit a task', () => { - async function edit(page, options) { - const mocked = await mockJobs(page, options) - await page.goto('/app/agent-jobs/tasks/t1/edit') - await expect(page.getByTestId('task-edit')).toBeVisible() - return mocked - } - - test('sections fold, with a ready mark and a one-line summary', async ({ page }) => { - await edit(page) - const basics = page.locator('[data-section="basics"]') - await expect(basics.locator('.ag-fold__sum')).toHaveText('daily-digest, qwen3-8b-instruct') - await expect(basics.locator('.ag-ready')).toHaveAttribute('data-state', 'ready') - await expect(basics.locator('.ag-fold__button')).toHaveAttribute('aria-expanded', 'false') - await expect(page.locator('[data-section="schedule"] .ag-fold__sum')).toHaveText('Every day at 07:00, 0 7 * * *') - await expect(page.locator('[data-section="webhooks"] .ag-fold__sum')).toHaveText('1 webhook') - await expect(page.locator('[data-section="media"] .ag-fold__sum')).toHaveText('None') - await basics.locator('.ag-fold__button').click() - await expect(page.getByLabel('Task name')).toHaveValue('daily-digest') - }) - - test('the prompt lists the parameters it uses', async ({ page }) => { - await edit(page) - await expect(page.getByTestId('prompt-params')).toContainText('{{.topic}}') - await expect(page.getByTestId('prompt-params').locator('.aj-gap')).toHaveCount(3) - await page.getByLabel('Prompt template').fill('Plain prompt') - await expect(page.getByTestId('prompt-params')).toContainText('No parameters in the prompt.') - }) - - test('the schedule is said in words, with presets that write the cron expression', async ({ page }) => { - await edit(page) - const words = page.getByTestId('schedule-words') - await expect(words).toContainText('Every day at 07:00.') - await expect(page.locator('[data-preset="daily"]')).toHaveAttribute('aria-checked', 'true') - await expect(page.getByLabel('Time')).toHaveValue('07:00') - await page.getByLabel('Time').fill('18:30') - await expect(words).toContainText('Every day at 18:30.') - await page.locator('[data-preset="weekdays"]').click() - await expect(words).toContainText('Weekdays at 18:30.') - await page.locator('[data-preset="hourly"]').click() - await expect(words).toContainText('Every hour.') - await expect(page.getByLabel('Time')).toHaveCount(0) - await page.locator('[data-preset="none"]').click() - await expect(words).toHaveCount(0) - await expect(page.getByLabel('Parameters, one key=value per line')).toHaveCount(0) - await page.locator('[data-preset="custom"]').click() - await page.getByLabel('Cron expression').fill('*/15 * * * *') - await expect(words).toContainText('Every 15 minutes.') - await page.getByLabel('Cron expression').fill('0,30 9-17 * * 1-5') - await expect(words).toContainText('Custom schedule.') - }) - - test('a cron expression that is not valid says why and blocks the save', async ({ page }) => { - const { seen } = await edit(page) - await page.locator('[data-preset="custom"]').click() - await page.getByLabel('Cron expression').fill('* * * *') - await expect(page.getByRole('alert').filter({ hasText: 'Use five fields' })).toContainText('You wrote 4.') - await expect(page.getByLabel('Cron expression')).toHaveAttribute('aria-invalid', 'true') - await expect(page.locator('[data-section="schedule"] .ag-ready')).toHaveAttribute('data-state', 'needs') - await page.getByLabel('Cron expression').fill('61 * * * *') - await expect(page.getByRole('alert').filter({ hasText: 'minute field' })).toBeVisible() - await page.getByRole('button', { name: 'Save task' }).first().click() - await expect(page.getByText('Fix the schedule first')).toBeVisible() - expect(seen.puts).toEqual([]) - }) - - test('warns when a scheduled prompt has a gap the schedule does not fill', async ({ page }) => { - await edit(page) - await expect(page.getByTestId('schedule-gaps')).toHaveCount(0) - await page.getByLabel('Parameters, one key=value per line').fill('topic=news') - await expect(page.getByTestId('schedule-gaps')).toContainText('{{.format}}, {{.items}}') - }) - - test('saves the task as the API takes it: parameters as a map, headers as objects', async ({ page }) => { - const { seen } = await edit(page) - await page.getByLabel('Time').fill('08:15') - await page.getByLabel('Parameters, one key=value per line').fill('topic=chips\nformat=prose\nitems=5') - await page.locator('[data-section="webhooks"] .ag-fold__button').click() - await expect(page.getByLabel('Headers (JSON)')).toHaveValue('{"X-Token":"abc"}') - await page.getByRole('button', { name: 'Save task' }).first().click() - await expect(page.getByText('Task updated')).toBeVisible() - await expect(page).toHaveURL(/\/app\/agent-jobs$/) - expect(seen.puts).toHaveLength(1) - const { id, body } = seen.puts[0] - expect(id).toBe('t1') - expect(body.cron).toBe('15 8 * * *') - expect(body.cron_parameters).toEqual({ topic: 'chips', format: 'prose', items: '5' }) - expect(body.webhooks[0].headers).toEqual({ 'X-Token': 'abc' }) - expect(body.webhooks[0].url).toBe('https://hooks.example.org/digest') - expect(body.enabled).toBe(true) - }) - - test('a webhook header that is not JSON stops the save and names the webhook', async ({ page }) => { - const { seen } = await edit(page) - await page.locator('[data-section="webhooks"] .ag-fold__button').click() - await page.getByLabel('Headers (JSON)').fill('{oops') - await page.getByRole('button', { name: 'Save task' }).first().click() - await expect(page.getByText('Headers must be valid JSON: Webhooks 1')).toBeVisible() - expect(seen.puts).toEqual([]) - }) - - test('the preview shows the prompt as a scheduled run sends it, and what changed', async ({ page }) => { - await edit(page) - await page.getByLabel('Time').fill('08:00') - await page.getByTestId('task-preview-open').click() - const sheet = page.getByTestId('task-preview') - await expect(sheet.getByTestId('task-preview-prompt')).toContainText('Summarise overnight news about local inference in bullet points format. Keep it to 8 items.') - // Switching tabs re-renders the page; focus stays on the tab. - await sheet.getByRole('tab', { name: /Changes/ }).click() - await expect(sheet.getByRole('tab', { name: /Changes/ })).toBeFocused() - const changes = sheet.getByTestId('task-preview-changes') - await expect(changes.locator('[data-key="cron"]')).toContainText('0 7 * * *') - await expect(changes.locator('[data-key="cron"]')).toContainText('0 8 * * *') - await page.keyboard.press('Escape') - await expect(sheet).toHaveCount(0) - }) - - test('leaving with unsaved changes asks first', async ({ page }) => { - await edit(page) - await page.getByLabel('Time').fill('09:00') - await page.getByRole('button', { name: 'Back', exact: true }).click() - await expect(page.getByRole('alertdialog')).toBeVisible() - }) - - test('media sources and webhooks add and remove', async ({ page }) => { - await edit(page, { tasks: undefined }) - await page.locator('[data-section="media"] .ag-fold__button').click() - await page.getByRole('button', { name: 'Add source' }).click() - await expect(page.getByTestId('media-source')).toHaveCount(1) - await page.getByLabel('URL').fill('https://example.org/clip.mp4') - await page.getByLabel('Type').selectOption('video') - await expect(page.locator('[data-section="media"] .ag-fold__sum')).toHaveText('1 source') - await page.getByRole('button', { name: 'Remove source' }).click() - await expect(page.getByTestId('media-source')).toHaveCount(0) - }) -}) - -test.describe('Create a task', () => { - test('starts with sections that need a name, and creates the task', async ({ page }) => { - const { seen } = await mockJobs(page) - await page.goto('/app/agent-jobs/tasks/new') - await expect(page.getByRole('heading', { name: 'New task' })).toBeVisible() - await expect(page.locator('[data-section="basics"] .ag-ready')).toHaveAttribute('data-state', 'needs') - await expect(page.locator('[data-section="basics"] .ag-fold__sum')).toHaveText('Needs a name') - await page.getByRole('button', { name: 'Create task' }).first().click() - await expect(page.getByText('Task name is required')).toBeVisible() - expect(seen.posts).toEqual([]) - await page.getByLabel('Task name').fill('nightly') - await page.getByLabel('Prompt template').fill('Check {{.thing}}.') - await page.locator('[data-preset="weekdays"]').click() - await expect(page.getByTestId('schedule-words')).toContainText('Weekdays at 09:00.') - await page.getByRole('button', { name: 'Create task' }).first().click() - await expect(page.getByText('Task created')).toBeVisible() - expect(seen.posts).toHaveLength(1) - expect(seen.posts[0].name).toBe('nightly') - expect(seen.posts[0].cron).toBe('0 9 * * 1-5') - expect(seen.posts[0].enabled).toBe(true) - expect(seen.posts[0].model).toBe('qwen3-8b-instruct') - }) -}) - -test.describe('The task form on a phone', () => { - test.use({ viewport: { width: 390, height: 844 } }) - - test('fits the width', async ({ page }) => { - await mockJobs(page) - await page.goto('/app/agent-jobs/tasks/t1/edit') - await expect(page.getByTestId('task-edit')).toBeVisible() - await expect(page.locator('[data-preset="daily"]')).toBeVisible() - const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth) - expect(overflow).toBeLessThanOrEqual(0) - }) -}) diff --git a/core/http/react-ui/e2e/agent-jobs.spec.js b/core/http/react-ui/e2e/agent-jobs.spec.js index ad8804645..e8d0bfcf0 100644 --- a/core/http/react-ui/e2e/agent-jobs.spec.js +++ b/core/http/react-ui/e2e/agent-jobs.spec.js @@ -1,291 +1,214 @@ import { test, expect } from './coverage-fixtures.js' -import { mockJobs, fixtureData } from './jobs-fixtures.js' +import { installAgents } from './agents-fixtures.js' -async function open(page, options) { - const mocked = await mockJobs(page, options) - await page.goto('/app/agent-jobs') - await expect(page.getByTestId('jobs-page')).toBeVisible() - return mocked -} +// The triggers pages: the list with its history, a trigger, and the form. -test.describe('Jobs page: the week and the tasks', () => { - test('one sentence says what the week held, from the jobs the API returned', async ({ page }) => { - await open(page) - const sum = page.getByTestId('jobs-summary') - await expect(sum).toContainText('5 runs in the last 7 days.') - await expect(sum).toContainText('2 finished, 1 failed, 1 cancelled, 1 still going.') - await expect(sum).toContainText('The last run of daily-digest failed.') +const row = (page, name) => page.locator(`[data-task="${name}"]`) + +test.describe('Triggers list', () => { + test('lists the triggers with their schedule, agent and last runs, and the history of every run', async ({ page }) => { + await installAgents(page) + await page.goto('/app/triggers') + await expect(page.getByTestId('jobs-page')).toBeVisible() + await expect(page.getByRole('heading', { name: 'Triggers', level: 1 })).toBeVisible() + + await expect(row(page, 'Morning digest')).toContainText('Every weekday at 09:00 (Europe/Rome)') + await expect(row(page, 'Morning digest')).toContainText('research-assistant') + await expect(row(page, 'Morning digest')).toContainText('Last run failed') + await expect(row(page, 'New contracts')).toContainText('When a file arrives in /Contracts') + await expect(row(page, 'New contracts').getByRole('switch')).toHaveAttribute('aria-checked', 'false') + + const history = page.getByTestId('jobs-history') + await expect(history.locator('[data-job]')).toHaveCount(4) + await expect(history.locator('[data-job="r1"]')).toContainText('the agent was deleted') + await expect(history.locator('[data-job="rs"]')).toContainText('daily cap reached') + await expect(page.getByTestId('jobs-summary')).toContainText('runs in the last 7 days') }) - test('a week with no runs says so, and a page with no jobs at all says nothing ran', async ({ page }) => { - const old = fixtureData().jobs.filter(j => j.id === 'job-stop-0005').map(j => ({ ...j, created_at: new Date(Date.now() - 10 * 86400000).toISOString(), started_at: undefined })) - await open(page, { jobs: old }) - await expect(page.getByTestId('jobs-summary')).toHaveText('No runs in the last 7 days.') - const none = await page.context().newPage() - await mockJobs(none, { jobs: [] }) - await none.goto('/app/agent-jobs') - await expect(none.getByTestId('jobs-summary')).toHaveText('Nothing has run yet.') - await expect(none.getByText('No runs yet')).toBeVisible() - }) - - test('each task shows its model, its schedule in words, its last runs and its last outcome', async ({ page }) => { - await open(page) - const digest = page.locator('[data-task="daily-digest"]') - await expect(digest).toContainText('qwen3-8b-instruct') - await expect(digest).toContainText('Every day at 07:00') - await expect(digest.locator('.aj-sched__cron')).toHaveText('0 7 * * *') - await expect(digest).toContainText('last run failed') - await expect(digest.locator('.ag-pip[data-state="failed"]')).toHaveCount(1) - await expect(digest.locator('.ag-pip[data-state="done"]')).toHaveCount(1) - await expect(page.locator('[data-task="repo-triage"]')).toContainText('Every 30 minutes') - await expect(page.locator('[data-task="weekly-report"]')).toContainText('Every Monday at 09:00') - const manual = page.locator('[data-task="ad-hoc-research"]') - await expect(manual).toContainText('Runs when you start it') - await expect(manual).toContainText('Not run yet') - await expect(manual.getByRole('switch')).toHaveAttribute('aria-checked', 'false') - }) - - test('a task name opens the task, and the model opens its editor', async ({ page }) => { - await open(page) - await expect(page.locator('[data-task="daily-digest"] .aj-model')).toHaveAttribute('href', '/app/model-editor/qwen3-8b-instruct') - await page.locator('[data-task="daily-digest"]').getByRole('link', { name: 'daily-digest' }).click() - await expect(page).toHaveURL(/\/app\/agent-jobs\/tasks\/t1$/) - }) - - test('the task list folds away and comes back', async ({ page }) => { - await open(page) - await page.getByRole('button', { name: 'Hide' }).click() - await expect(page.getByTestId('jobs-tasks')).toHaveCount(0) - await page.getByRole('button', { name: 'Show' }).click() - await expect(page.getByTestId('jobs-tasks')).toBeVisible() - }) - - test('the enabled switch saves the task and says so', async ({ page }) => { - const { seen } = await open(page) - await page.locator('[data-task="daily-digest"]').getByRole('switch').click() - await expect(page.getByText('daily-digest is off')).toBeVisible() - expect(seen.puts).toHaveLength(1) - expect(seen.puts[0].id).toBe('t1') - expect(seen.puts[0].body.enabled).toBe(false) - expect(seen.puts[0].body.cron).toBe('0 7 * * *') - expect(seen.puts[0].body.cron_parameters).toEqual({ topic: 'local inference', format: 'bullet points', items: '8' }) - }) - - test('the switch goes back when the save fails', async ({ page }) => { - await open(page, { updateStatus: 500 }) - const toggle = page.locator('[data-task="daily-digest"]').getByRole('switch') - await toggle.click() - await expect(page.getByText(/Could not change the task/)).toBeVisible() - await expect(toggle).toHaveAttribute('aria-checked', 'true') - }) - - test('with no tasks the page teaches what a task is and how to make one', async ({ page }) => { - await open(page, { tasks: [], jobs: [] }) - const empty = page.getByTestId('jobs-empty') - await expect(empty).toContainText('A task is a prompt a model runs for you') - await expect(empty.locator('li')).toHaveCount(3) - await expect(empty).toContainText('{{.name}}') - await empty.getByRole('button', { name: 'Create task' }).click() - await expect(page).toHaveURL(/\/app\/agent-jobs\/tasks\/new$/) - }) - - test('without a model it says Jobs need one, and without MCP it shows how to add it', async ({ page }) => { - await mockJobs(page, { models: [] }) - await page.goto('/app/agent-jobs') - await expect(page.getByTestId('jobs-no-models')).toContainText('No models installed') - const other = await page.context().newPage() - await mockJobs(other, { tasks: [], jobs: [], mcp: false }) - await other.goto('/app/agent-jobs') - await expect(other.getByTestId('jobs-no-mcp')).toContainText('MCP is not set up') - await expect(other.getByTestId('jobs-no-mcp')).toContainText('command: /path/to/tool') - }) -}) - -test.describe('Jobs page: run history', () => { - test('is grouped by day, newest first, one outcome sentence per run', async ({ page }) => { - await open(page) - const days = page.locator('.aj-day') - await expect(days.nth(0)).toHaveText('Today') - await expect(days.nth(1)).toHaveText('Yesterday') - await expect(days).toHaveCount(3) - const rows = page.locator('[data-testid="jobs-history"] tr[data-job]') - await expect(rows).toHaveCount(5) - await expect(rows.nth(0)).toHaveAttribute('data-job', 'job-run-0003') - await expect(rows.nth(1)).toHaveAttribute('data-job', 'job-fail-0001') - await expect(rows.nth(2)).toHaveAttribute('data-job', 'job-done-0002') - await expect(rows.nth(3)).toHaveAttribute('data-job', 'job-done-0004') - await expect(rows.nth(4)).toHaveAttribute('data-job', 'job-stop-0005') - await expect(page.locator('[data-job="job-fail-0001"]')).toContainText('read_page: timeout after 20 s') - await expect(page.locator('[data-job="job-fail-0001"]')).toContainText('1 min 06 s') - await expect(page.locator('[data-job="job-done-0002"]')).toContainText('Labelled 12 issues and routed 3 of them') - await expect(page.locator('[data-job="job-run-0003"]')).toContainText('Working since') - await expect(page.locator('[data-job="job-stop-0005"]')).toContainText('Cancelled before it finished') - }) - - test('shows no duration for a run the server has no end time for', async ({ page }) => { - await open(page) - const cells = page.locator('[data-job="job-run-0003"] td.dk-num') - await expect(cells.nth(1)).toHaveText('') - }) - - test('status chips filter, with the counts, and a second press clears', async ({ page }) => { - await open(page) - await expect(page.locator('[data-filter="all"]')).toContainText('5') - await expect(page.locator('[data-filter="failed"]')).toContainText('1') - await expect(page.locator('[data-filter="completed"]')).toContainText('2') - await expect(page.locator('[data-filter="pending"]')).toHaveCount(0) - await page.locator('[data-filter="completed"]').click() - await expect(page.locator('[data-testid="jobs-history"] tr[data-job]')).toHaveCount(2) - await page.locator('[data-filter="completed"]').click() - await expect(page.locator('[data-testid="jobs-history"] tr[data-job]')).toHaveCount(5) - }) - - test('the task filter narrows the history and the counts follow it', async ({ page }) => { - await open(page) - await page.getByLabel('Task', { exact: true }).selectOption({ label: 'repo-triage' }) - await expect(page.locator('[data-testid="jobs-history"] tr[data-job]')).toHaveCount(2) - await expect(page.locator('[data-filter="all"]')).toContainText('2') - await expect(page.locator('[data-filter="failed"]')).toHaveCount(0) - }) - - test('a filter with no match says so and clears', async ({ page }) => { - await open(page) + test('filters the history by status and by trigger', async ({ page }) => { + await installAgents(page) + await page.goto('/app/triggers') + const history = page.getByTestId('jobs-history') await page.locator('[data-filter="failed"]').click() - await page.getByLabel('Task', { exact: true }).selectOption({ label: 'weekly-report' }) - await expect(page.getByTestId('jobs-history-empty')).toContainText('No runs match these filters.') - await page.getByRole('button', { name: 'Clear filters' }).click() - await expect(page.locator('[data-testid="jobs-history"] tr[data-job]')).toHaveCount(5) + await expect(history.locator('[data-job]')).toHaveCount(1) + await page.locator('[data-filter="all"]').click() + await page.getByLabel('Trigger', { exact: true }).selectOption('t2') + await expect(history.locator('[data-job]')).toHaveCount(1) + await expect(history.locator('[data-job="rf"]')).toBeVisible() }) - test('a row opens into its error and one next action', async ({ page }) => { - const { seen } = await open(page) - const toggle = page.getByRole('button', { name: 'Details for the run of daily-digest' }).first() - await expect(toggle).toHaveAttribute('aria-expanded', 'false') - await toggle.click() - await expect(toggle).toHaveAttribute('aria-expanded', 'true') - const detail = page.getByTestId('job-detail') - await expect(detail.getByTestId('job-excerpt')).toContainText('read_page: timeout after 20 s (3 of 3 attempts)') - await detail.getByRole('button', { name: 'Run again' }).click() - await expect(page.getByText('Started a new run')).toBeVisible() - expect(seen.jobExecutes).toEqual([{ task_id: 't1', parameters: { topic: 'local inference', format: 'bullet points', items: '8' } }]) - await expect(detail.getByRole('link', { name: 'Open the run' })).toHaveAttribute('href', '/app/agent-jobs/jobs/job-fail-0001') + test('a history row opens to the reason and a link to the chat it started', async ({ page }) => { + await installAgents(page) + await page.goto('/app/triggers') + await page.getByRole('button', { name: /Details for the run of Morning digest/ }).first().click() + const detail = page.getByTestId('job-detail').first() + await expect(detail).toContainText('the agent was deleted') + await detail.getByRole('link', { name: 'Open the chat' }).click() + await expect(page).toHaveURL(/\/app\/chats\/c2$/) }) - test('a run in flight offers Cancel, and Cancel calls the API', async ({ page }) => { - const { seen } = await open(page) - await page.locator('[data-job="job-run-0003"]').getByRole('button', { name: /Details for/ }).click() - await page.getByTestId('job-detail').getByRole('button', { name: 'Cancel' }).click() - await expect.poll(() => seen.cancels).toEqual(['job-run-0003']) + test('turns a trigger on and off, and runs it now', async ({ page }) => { + const s = await installAgents(page) + await page.goto('/app/triggers') + await row(page, 'New contracts').getByRole('switch').click() + await expect.poll(() => s.calls.some(c => c.url === '/api/chats/triggers/t2/enable')).toBe(true) + await row(page, 'Morning digest').getByRole('button', { name: 'Run now' }).click() + await expect.poll(() => s.calls.some(c => c.url === '/api/chats/triggers/t1/run')).toBe(true) + await row(page, 'Morning digest').getByRole('switch').click() + await expect.poll(() => s.calls.some(c => c.url === '/api/chats/triggers/t1/disable')).toBe(true) }) - test('the page menu stops every running job after a confirm', async ({ page }) => { - const { seen } = await open(page) - await page.getByRole('button', { name: 'More job actions' }).click() - await page.getByRole('menuitem', { name: 'Stop running jobs' }).click() - await expect(page.getByRole('alertdialog')).toContainText('Finished jobs stay in the history') - await page.getByRole('alertdialog').getByRole('button', { name: 'Stop jobs' }).click() - await expect.poll(() => seen.cancels).toEqual(['job-run-0003']) + test('deletes a trigger after asking', async ({ page }) => { + const s = await installAgents(page) + await page.goto('/app/triggers') + await row(page, 'New contracts').getByRole('button', { name: /More actions/ }).click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + const dialog = page.getByRole('alertdialog') + await expect(dialog).toContainText('Delete New contracts?') + await dialog.getByRole('button', { name: 'Delete' }).click() + await expect(row(page, 'New contracts')).toHaveCount(0) + expect(s.calls.some(c => c.method === 'DELETE' && c.url === '/api/chats/triggers/t2')).toBe(true) }) - test('the Jobs tab stays lit around the page', async ({ page }) => { - await open(page) - await expect(page.locator('.dk-hubtabs [data-hub-tab="jobs"]')).toHaveAttribute('aria-current', 'page') + test('confirms and dismisses a trigger a chat proposed', async ({ page }) => { + const s = await installAgents(page, { proposals: [{ id: 'p1', name: 'Weekly digest', prompt: 'Summarise the week.', kind: 'schedule', summary: 'Every Friday at 17:00' }] }) + await page.goto('/app/triggers') + const proposal = page.getByTestId('proposal-row') + await expect(proposal).toContainText('Weekly digest') + await expect(proposal).toContainText('Every Friday at 17:00') + await proposal.getByRole('button', { name: 'Confirm' }).click() + await expect.poll(() => s.calls.some(c => c.url === '/api/chats/triggers/proposals/p1/confirm')).toBe(true) + await expect(page.getByTestId('proposals')).toHaveCount(0) + }) + + test('dismisses a proposal without creating a trigger', async ({ page }) => { + const s = await installAgents(page, { proposals: [{ id: 'p1', name: 'Weekly digest', prompt: 'Go.', kind: 'schedule', summary: 'Every Friday at 17:00' }] }) + await page.goto('/app/triggers') + await page.getByTestId('proposal-row').getByRole('button', { name: 'Dismiss' }).click() + await expect.poll(() => s.calls.some(c => c.method === 'DELETE' && c.url === '/api/chats/triggers/proposals/p1')).toBe(true) + }) + + test('with no triggers it explains what one is', async ({ page }) => { + await installAgents(page, { triggers: [] }) + await page.goto('/app/triggers') + await expect(page.getByTestId('jobs-empty')).toBeVisible() + await page.getByRole('button', { name: 'Create trigger' }).click() + await expect(page).toHaveURL(/\/app\/triggers\/new$/) + }) + + test('says why the page is unavailable when triggers are off', async ({ page }) => { + await installAgents(page) + await page.route(/\/api\/chats\/triggers(\?.*)?$/, route => route.fulfill({ status: 409, json: { error: { message: 'Triggers are off on this server.', type: 'triggers_off', code: 409 } } })) + await page.goto('/app/triggers') + await expect(page.getByRole('note')).toContainText('Triggers are off on this server.') + }) + + test('the old addresses redirect', async ({ page }) => { + await installAgents(page) + await page.goto('/app/chats/triggers') + await expect(page).toHaveURL(/\/app\/triggers$/) + await page.goto('/app/agent-jobs/tasks/t1') + await expect(page).toHaveURL(/\/app\/triggers$/) }) }) -test.describe('Jobs page: run a task now', () => { - test('the dialog asks for the values the prompt uses, filled from the schedule', async ({ page }) => { - const { seen } = await open(page) - await page.locator('[data-task="daily-digest"]').getByRole('button', { name: 'Run now' }).click() - const dialog = page.getByTestId('run-task-dialog') - await expect(dialog).toContainText('Run daily-digest now') - await expect(dialog.getByLabel('topic')).toHaveValue('local inference') - await expect(dialog.getByLabel('format')).toHaveValue('bullet points') - await dialog.getByLabel('topic').fill('open models') - await dialog.getByLabel('More parameters, one key=value per line').fill('tone=dry') - await dialog.getByRole('button', { name: 'Start run' }).click() - await expect(page.getByText('Task "daily-digest" started')).toBeVisible() - expect(seen.executes).toEqual([{ name: 'daily-digest', body: { topic: 'open models', format: 'bullet points', items: '8', tone: 'dry' } }]) - await expect(dialog).toHaveCount(0) +test.describe('A trigger', () => { + test('shows its facts, prompt and recent runs; a run with a chat opens it', async ({ page }) => { + await installAgents(page) + await page.goto('/app/triggers/t1') + await expect(page.getByRole('heading', { name: 'Morning digest', level: 1 })).toBeVisible() + const facts = page.getByTestId('task-facts') + await expect(facts).toContainText('research-assistant') + await expect(facts).toContainText('Every weekday at 09:00') + await expect(page.getByTestId('task-prompt')).toContainText('Summarise yesterday.') + const runs = page.getByTestId('task-runs') + await expect(runs.locator('[data-job]')).toHaveCount(3) + await runs.locator('[data-job="r0"]').click() + await expect(page).toHaveURL(/\/app\/chats\/c7$/) }) - test('a task with no gaps says so, and Escape closes the dialog', async ({ page }) => { - await open(page) - await page.locator('[data-task="weekly-report"]').getByRole('button', { name: 'Run now' }).click() - await expect(page.getByTestId('run-task-dialog')).toContainText('This prompt has no gaps to fill.') - await page.keyboard.press('Escape') - await expect(page.getByTestId('run-task-dialog')).toHaveCount(0) + test('a file trigger names the folder it watches', async ({ page }) => { + await installAgents(page) + await page.goto('/app/triggers/t2') + await expect(page.getByTestId('task-facts')).toContainText('Watches the folder /Contracts') + await expect(page.getByTestId('task-runs')).toContainText('/Contracts/a.pdf') }) - test('attached media goes with the run through the job call', async ({ page }) => { - const { seen } = await open(page) - await page.locator('[data-task="weekly-report"]').getByRole('button', { name: 'Run now' }).click() - const dialog = page.getByTestId('run-task-dialog') - await dialog.getByText('Media', { exact: true }).click() - const chooser = page.waitForEvent('filechooser') - await dialog.getByRole('button', { name: 'Add' }).first().click() - await (await chooser).setFiles({ name: 'board.png', mimeType: 'image/png', buffer: Buffer.from('png') }) - await expect(dialog.getByText('board.png')).toBeVisible() - await dialog.getByRole('button', { name: 'Start run' }).click() - await expect.poll(() => seen.jobExecutes.length).toBe(1) - expect(seen.jobExecutes[0].task_id).toBe('t3') - expect(seen.jobExecutes[0].images).toHaveLength(1) - expect(seen.jobExecutes[0].images[0]).toMatch(/^data:image\/png;base64,/) - expect(seen.executes).toEqual([]) - }) -}) - -test.describe('Jobs page: delete a task', () => { - test('hides the task, offers undo and sends nothing until the time ends; undo brings it back', async ({ page }) => { - const { seen } = await open(page) - await page.locator('[data-task="repo-triage"]').getByRole('button', { name: /More actions for/ }).click() + test('runs now, turns off and deletes', async ({ page }) => { + const s = await installAgents(page) + await page.goto('/app/triggers/t1') + await page.getByTestId('task-run').click() + await expect.poll(() => s.calls.some(c => c.url === '/api/chats/triggers/t1/run')).toBe(true) + await page.getByTestId('task-facts').getByRole('switch').click() + await expect.poll(() => s.calls.some(c => c.url === '/api/chats/triggers/t1/disable')).toBe(true) + await page.getByRole('button', { name: /More actions/ }).click() await page.getByRole('menuitem', { name: 'Delete' }).click() await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click() - await expect(page.locator('[data-task="repo-triage"]')).toHaveCount(0) - const toast = page.getByTestId('task-undo-toast') - await expect(toast).toContainText('Task "repo-triage" deleted') - expect(seen.deletes).toEqual([]) - await toast.getByRole('button', { name: 'Undo' }).click() - await expect(page.locator('[data-task="repo-triage"]')).toBeVisible() - await expect(toast).toHaveCount(0) - expect(seen.deletes).toEqual([]) + await expect(page).toHaveURL(/\/app\/triggers$/) }) - test('without undo the delete is sent when the time ends', async ({ page }) => { - await page.clock.install() - const { seen } = await open(page) - await page.locator('[data-task="repo-triage"]').getByRole('button', { name: /More actions for/ }).click() - await page.getByRole('menuitem', { name: 'Delete' }).click() - await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click() - await expect(page.getByTestId('task-undo-toast')).toBeVisible() - expect(seen.deletes).toEqual([]) - await page.clock.runFor(30500) - await expect.poll(() => seen.deletes).toEqual(['t2']) - await expect(page.getByText('Task deleted')).toBeVisible() - }) - - test('leaving the page during the wait deletes nothing', async ({ page }) => { - const { seen } = await open(page) - await page.locator('[data-task="repo-triage"]').getByRole('button', { name: /More actions for/ }).click() - await page.getByRole('menuitem', { name: 'Delete' }).click() - await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click() - await expect(page.getByTestId('task-undo-toast')).toBeVisible() - await page.getByRole('link', { name: 'Skills' }).first().click() - await expect(page.getByText('Nothing was deleted, because the page was closed first.')).toBeVisible() - expect(seen.deletes).toEqual([]) + test('a trigger that does not exist says so', async ({ page }) => { + await installAgents(page) + await page.goto('/app/triggers/nope') + await expect(page.getByTestId('task-missing')).toBeVisible() }) }) -test.describe('Jobs page on a phone', () => { - test.use({ viewport: { width: 390, height: 844 } }) +test.describe('Trigger form', () => { + test('creates a schedule trigger with a preview of its next times', async ({ page }) => { + const s = await installAgents(page) + await page.goto('/app/triggers/new') + await page.getByLabel('Name', { exact: true }).fill('Evening recap') + await page.getByLabel('Agent', { exact: true }).selectOption('a1') + await page.getByLabel('What to do', { exact: true }).fill('Recap the day.') + await expect(page.getByTestId('trigger-preview')).toContainText('Every day at 08:00') + await expect(page.getByTestId('trigger-preview')).toContainText('Next:') + await page.getByRole('button', { name: 'Create trigger' }).first().click() + await expect(page).toHaveURL(/\/app\/triggers$/) + expect(s.calls.find(c => c.method === 'POST').body).toMatchObject({ + name: 'Evening recap', prompt: 'Recap the day.', kind: 'schedule', schedule: 'daily 08:00', agent_id: 'a1', enabled: true, + }) + }) - test('fits the width, keeps the essentials and drops the wide columns', async ({ page }) => { - await open(page) - await expect(page.locator('[data-task="daily-digest"]')).toBeVisible() - await expect(page.locator('[data-task="daily-digest"] .aj-sched')).toBeHidden() - await expect(page.locator('[data-task="daily-digest"]').getByRole('button', { name: 'Run now' })).toBeVisible() - await expect(page.locator('[data-job="job-fail-0001"]')).toBeVisible() - const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth) - expect(overflow).toBeLessThanOrEqual(0) + test('a file trigger asks for a folder', async ({ page }) => { + const s = await installAgents(page) + await page.goto('/app/triggers/new') + await page.getByLabel('Name', { exact: true }).fill('Inbox') + await page.getByLabel('When', { exact: true }).selectOption('file') + await page.getByLabel('Folder in your files').fill('/Contracts') + await page.getByLabel('Quiet period (seconds)').fill('45') + await page.getByLabel('What to do', { exact: true }).fill('Read it.') + await page.getByRole('button', { name: 'Create trigger' }).first().click() + await expect.poll(() => s.calls.find(c => c.method === 'POST')?.body).toMatchObject({ kind: 'file', folder: '/Contracts', quiet_seconds: 45, prompt: 'Read it.' }) + }) + + test('shows the reason of a schedule the server cannot read', async ({ page }) => { + await installAgents(page) + await page.goto('/app/triggers/new') + await page.getByLabel('When', { exact: true }).selectOption('cron') + await page.getByLabel('Cron (five fields)').fill('25:00') + await expect(page.getByRole('alert')).toContainText('hour must be 0 to 23') + }) + + test('edits a trigger, shows what changed in the preview, and saves it with PUT', async ({ page }) => { + const s = await installAgents(page) + await page.goto('/app/triggers/t1/edit') + await page.locator('[data-section="basics"] .ag-fold__button').click() + await expect(page.getByLabel('Name', { exact: true })).toHaveValue('Morning digest') + await page.getByLabel('Name', { exact: true }).fill('Morning digest 2') + await page.getByTestId('task-preview-open').click() + await page.getByTestId('task-preview').getByRole('tab', { name: /Changes/ }).click() + await expect(page.getByTestId('task-preview-changes')).toContainText('Morning digest 2') + await page.getByTestId('task-preview').getByRole('button', { name: 'Save trigger' }).click() + await expect(page).toHaveURL(/\/app\/triggers$/) + expect(s.calls.find(c => c.method === 'PUT')).toMatchObject({ url: '/api/chats/triggers/t1', body: { name: 'Morning digest 2', schedule: 'weekdays 09:00', agent_id: 'a1' } }) + }) + + test('needs a name and something to do', async ({ page }) => { + const s = await installAgents(page) + await page.goto('/app/triggers/new') + await page.getByRole('button', { name: 'Create trigger' }).first().click() + expect(s.calls.some(c => c.method === 'POST')).toBe(false) + await expect(page.getByText('A trigger needs a name')).toBeVisible() }) }) diff --git a/core/http/react-ui/e2e/agent-tasks-fixtures.js b/core/http/react-ui/e2e/agent-tasks-fixtures.js new file mode 100644 index 000000000..759669569 --- /dev/null +++ b/core/http/react-ui/e2e/agent-tasks-fixtures.js @@ -0,0 +1,53 @@ +// Stubbed API of the agent tasks and their jobs (/api/agent/tasks and +// /api/agent/jobs), for the specs of the agent tasks page. Every write is +// recorded in `s.calls` so a spec can assert what the page sent. + +const iso = (m) => new Date(Date.now() - m * 60_000).toISOString() + +export const TASKS = [ + { id: 'k1', name: 'Summarise a support ticket', description: 'Reads a ticket and writes a three-line summary for the on-call channel.', model: 'qwen3-8b', prompt: 'Summarise ticket {{.ticket}} in three lines. Say who should act next.', enabled: true, created_at: iso(9000), updated_at: iso(600), webhooks: [{ url: 'https://hooks.example/oncall', method: 'POST' }] }, + { id: 'k2', name: 'Translate release notes', description: '', model: '', agent_id: 'a4', prompt: 'Translate the release notes of version {{.version}} into {{.language}}. Keep the headings and the links as they are.', enabled: true, created_at: iso(9000), updated_at: iso(600) }, + { id: 'k3', name: 'Nightly test report', description: 'Turns the CI log into a short list of failing tests and the likely cause of each.', model: 'phi-4-mini', prompt: 'Read the CI log and list the failing tests.', enabled: false, created_at: iso(9000), updated_at: iso(600) }, + { id: 'k4', name: 'Draft a reply', description: 'Writes a polite first reply to a customer email.', model: 'gemma-3-12b', prompt: 'Draft a reply to: {{.email}}', enabled: true, created_at: iso(9000), updated_at: iso(600) }, +] +const job = (id, task, status, mins, extra = {}) => ({ + id, task_id: task, status, parameters: {}, created_at: iso(mins), triggered_by: 'manual', + ...(status !== 'pending' ? { started_at: iso(mins) } : {}), + ...(['completed', 'failed', 'cancelled'].includes(status) ? { completed_at: iso(mins - 0.6) } : {}), + ...extra, +}) +export const JOBS = [ + job('j1', 'k1', 'running', 0.5, { parameters: { ticket: 'SUP-4821' }, triggered_by: 'api' }), + job('j2', 'k4', 'pending', 0.2, { parameters: { email: 'Hi, my order has not arrived yet.' } }), + job('j3', 'k1', 'completed', 42, { parameters: { ticket: 'SUP-4817' }, result: 'Login fails for SSO users after the 2.4 upgrade.\nWorkaround: clear the session cookie.\nNext: the identity team should check the callback URL.', webhook_sent: true, traces: [{ type: 'reasoning', content: 'The ticket names the 2.4 upgrade.' }, { type: 'tool_call', tool_name: 'search_tickets', content: 'query=SSO login' }] }), + job('j4', 'k2', 'failed', 130, { parameters: { version: '4.2.0', language: 'Italian' }, error: 'model "phi-4-mini" is not loaded and could not be started: out of memory (needs 6.1 GB, 4.0 GB free)', triggered_by: 'api' }), + job('j5', 'k1', 'cancelled', 300, { parameters: { ticket: 'SUP-4790' } }), + job('j6', 'k4', 'completed', 1500, { parameters: { email: 'Can I change the delivery address?' }, result: 'Hello, and thank you for writing. Yes, you can change the address until the parcel ships. Reply with the new address and we update it today.', webhook_error: 'POST https://hooks.example/crm answered 502' }), +] +for (let i = 0; i < 14; i++) JOBS.push(job(`jh${i}`, i % 2 ? 'k1' : 'k4', i % 5 === 2 ? 'failed' : 'completed', 2000 + i * 600, { parameters: { ticket: `SUP-47${i}` }, result: 'Done.', error: i % 5 === 2 ? 'context window exceeded (8192 tokens)' : undefined })) + +export async function installTasks(page, { tasks = TASKS, jobs = JOBS, fail = false } = {}) { + const s = { tasks: structuredClone(tasks), jobs: structuredClone(jobs), calls: [] } + await page.route(/\/api\/agent\/tasks(\?.*)?$/, route => { + const req = route.request() + if (fail) return route.fulfill({ status: 500, json: { error: { message: 'database is locked' } } }) + if (req.method() === 'POST') { const b = req.postDataJSON(); const t = { id: `k${s.tasks.length + 9}`, ...b }; s.tasks.push(t); s.calls.push({ m: 'POST', b }); return route.fulfill({ status: 201, json: { id: t.id } }) } + return route.fulfill({ json: s.tasks }) + }) + await page.route(/\/api\/agent\/tasks\/[^/?]+$/, route => { + const req = route.request(); const id = new URL(req.url()).pathname.split('/').pop() + if (req.method() === 'DELETE') { s.tasks = s.tasks.filter(t => t.id !== id); return route.fulfill({ json: { message: 'ok' } }) } + if (req.method() === 'PUT') { const b = req.postDataJSON(); Object.assign(s.tasks.find(t => t.id === id), b); s.calls.push({ m: 'PUT', id, b }); return route.fulfill({ json: { message: 'ok' } }) } + return route.fulfill({ json: s.tasks.find(t => t.id === id) }) + }) + await page.route(/\/api\/agent\/jobs\/[^/?]+\/(cancel|rerun)$/, route => { const parts = new URL(route.request().url()).pathname.split('/'); s.calls.push({ m: parts.pop(), id: parts.pop() }); return route.fulfill({ json: { message: 'ok' } }) }) + await page.route(/\/api\/agent\/jobs\/[^/?]+$/, route => { const id = new URL(route.request().url()).pathname.split('/').pop(); if (route.request().method() === 'DELETE') { s.jobs = s.jobs.filter(j => j.id !== id); return route.fulfill({ json: { message: 'ok' } }) } return route.fulfill({ json: s.jobs.find(j => j.id === id) }) }) + await page.route(/\/api\/agent\/jobs(\?.*)?$/, route => { + if (fail) return route.fulfill({ status: 500, json: { error: { message: 'database is locked' } } }) + const u = new URL(route.request().url()); const tid = u.searchParams.get('task_id') + return route.fulfill({ json: tid ? s.jobs.filter(j => j.task_id === tid) : s.jobs }) + }) + // Registered last: Playwright tries the newest route first, and the job route above also matches /execute. + await page.route(/\/api\/agent\/jobs\/execute$/, route => { const b = route.request().postDataJSON(); s.calls.push({ m: 'RUN', b }); const j = job(`jn${s.jobs.length}`, b.task_id, 'pending', 0, { parameters: b.parameters }); s.jobs.unshift(j); return route.fulfill({ status: 201, json: { job_id: j.id, status: 'pending' } }) }) + return s +} diff --git a/core/http/react-ui/e2e/agent-tasks.spec.js b/core/http/react-ui/e2e/agent-tasks.spec.js new file mode 100644 index 000000000..555e1299f --- /dev/null +++ b/core/http/react-ui/e2e/agent-tasks.spec.js @@ -0,0 +1,140 @@ +import { test, expect } from './coverage-fixtures.js' +import { installAgents, AGENTS } from './agents-fixtures.js' +import { installTasks } from './agent-tasks-fixtures.js' + +// The agent tasks page against stubbed routes: the task list with its filters, +// the create and run sheets, the details pane and the run history. + +async function setup(page, opts = {}) { + await installAgents(page, { agents: [...AGENTS, { id: 'a4', name: 'release-notes-writer', description: '', model: 'phi-4-mini', instructions: 'Write notes.', mcp_servers: [], addons: [], collections: [], skills: [] }] }) + await page.route('**/api/features', route => route.fulfill({ json: { agents: true, triggers: true, mcp_jobs: true, agent_jobs: true, mcp: true } })) + const k = await installTasks(page, opts) + await page.goto('/app/agent-tasks') + return k +} +const taskRow = (page, name) => page.locator(`[data-testid="agent-task-row"][data-task="${name}"]`) + +test.describe('Agent tasks', () => { + test('lists the tasks with who runs them, what they ask for and how the last run went', async ({ page }) => { + await setup(page) + await expect(page.getByRole('heading', { name: 'Agent tasks', level: 1 })).toBeVisible() + const first = taskRow(page, 'Summarise a support ticket') + await expect(first).toContainText('qwen3-8b') + await expect(first).toContainText('Asks for ticket') + await expect(first).toContainText('Running') + await expect(first).toContainText('1 webhook') + await expect(taskRow(page, 'Translate release notes')).toContainText('release-notes-writer') + await expect(taskRow(page, 'Translate release notes')).toContainText('Failed') + await expect(taskRow(page, 'Nightly test report').getByRole('switch')).toHaveAttribute('aria-checked', 'false') + }) + + test('filters the tasks by on and off and by a search', async ({ page }) => { + await setup(page) + await page.getByRole('group', { name: 'Show tasks' }).getByRole('button', { name: /^Off/ }).click() + await expect(page.getByTestId('agent-task-row')).toHaveCount(1) + await page.getByRole('group', { name: 'Show tasks' }).getByRole('button', { name: /^All/ }).click() + await page.getByRole('searchbox', { name: 'Search tasks' }).fill('customer') + await expect(page.getByTestId('agent-task-row')).toHaveCount(1) + await expect(taskRow(page, 'Draft a reply')).toBeVisible() + await page.getByRole('searchbox', { name: 'Search tasks' }).fill('nothing like this') + await expect(page.getByText('No tasks match.')).toBeVisible() + await page.getByRole('button', { name: 'Clear filters' }).click() + await expect(page.getByTestId('agent-task-row')).toHaveCount(4) + }) + + test('creates a task in the sheet and says what is missing before it saves', async ({ page }) => { + const k = await setup(page) + await page.getByRole('button', { name: 'New task' }).click() + const form = page.getByTestId('agent-task-form') + await page.getByRole('button', { name: 'Save task' }).click() + await expect(form.getByText('Give the task a name.')).toBeVisible() + await expect(form.getByText('Choose a model.')).toBeVisible() + await expect(form.getByLabel('Name')).toHaveAttribute('aria-invalid', 'true') + await form.getByLabel('Name').fill('weekly-summary') + await form.getByLabel('Model').selectOption('qwen3-8b') + await form.getByLabel('Prompt').fill('Summarise the week of {{.team}}') + await expect(page.getByTestId('agent-task-inputs')).toContainText('team') + await page.getByRole('button', { name: 'Save task' }).click() + await expect(form).toHaveCount(0) + const post = k.calls.find(c => c.m === 'POST') + expect(post.b).toMatchObject({ name: 'weekly-summary', model: 'qwen3-8b', prompt: 'Summarise the week of {{.team}}', enabled: true, agent_id: '' }) + await expect(taskRow(page, 'weekly-summary')).toBeVisible() + }) + + test('runs a task with the values its prompt asks for, and previews the prompt', async ({ page }) => { + const k = await setup(page) + await taskRow(page, 'Translate release notes').getByRole('button', { name: 'Run Translate release notes' }).click() + const sheet = page.getByTestId('agent-task-run') + await sheet.getByLabel('version').fill('4.2.0') + await sheet.getByLabel('language').fill('German') + await expect(page.getByTestId('agent-task-preview')).toContainText('version 4.2.0 into German') + await page.getByRole('button', { name: 'Run now' }).click() + await expect.poll(() => k.calls.find(c => c.m === 'RUN')?.b).toEqual({ task_id: 'k2', parameters: { version: '4.2.0', language: 'German' } }) + }) + + test('a task that asks for nothing starts at once; a switch turns a task off', async ({ page }) => { + const k = await setup(page, { tasks: [{ id: 'k9', name: 'Plain', prompt: 'Say hi.', model: 'qwen3-8b', enabled: true }], jobs: [] }) + await taskRow(page, 'Plain').getByRole('button', { name: 'Run Plain' }).click() + await expect.poll(() => k.calls.find(c => c.m === 'RUN')?.b).toEqual({ task_id: 'k9', parameters: {} }) + await taskRow(page, 'Plain').getByRole('switch').click() + await expect.poll(() => k.calls.find(c => c.m === 'PUT')?.b?.enabled).toBe(false) + await expect(taskRow(page, 'Plain').getByRole('switch')).toHaveAttribute('aria-checked', 'false') + }) + + test('the details pane shows the prompt and the API call, the runs, and deletes after asking', async ({ page }) => { + const k = await setup(page) + await page.getByRole('button', { name: 'Open Summarise a support ticket' }).click() + const pane = page.getByTestId('agent-task-pane') + await expect(pane).toContainText('Summarise ticket {{.ticket}}') + await expect(pane).toContainText('/api/agent/tasks/Summarise%20a%20support%20ticket/execute') + await pane.getByRole('tab', { name: /Runs/ }).click() + await expect(pane.getByTestId('agent-job-row')).toHaveCount(k.jobs.filter(j => j.task_id === 'k1').length) + await pane.getByRole('tab', { name: 'Settings' }).click() + await pane.getByRole('button', { name: 'Delete' }).click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click() + await expect(taskRow(page, 'Summarise a support ticket')).toHaveCount(0) + await expect(pane).toHaveCount(0) + }) + + test('the run history filters by state and task, opens a run to its values and answer, and runs it again', async ({ page }) => { + const k = await setup(page) + const history = page.getByTestId('agent-jobs') + await page.getByRole('group', { name: 'Show runs' }).getByRole('button', { name: /^Failed/ }).click() + await expect(history.getByTestId('agent-job-row')).toHaveCount(k.jobs.filter(j => j.status === 'failed').length) + await page.getByRole('group', { name: 'Show runs' }).getByRole('button', { name: /^All/ }).click() + await page.getByLabel('Task', { exact: true }).selectOption('k2') + await expect(history.getByTestId('agent-job-row')).toHaveCount(1) + await page.getByLabel('Task', { exact: true }).selectOption('all') + + const done = history.locator('[data-job="j3"]') + await done.getByRole('button', { name: /Details of the run/ }).click() + const detail = done.getByTestId('agent-job-detail') + await expect(detail).toContainText('SUP-4817') + await expect(detail).toContainText('Workaround: clear the session cookie.') + await expect(detail.getByRole('button', { name: 'Copy' })).toBeVisible() + await expect(detail.getByRole('link', { name: 'Open the chat' })).toHaveAttribute('href', /\/app\/chats\/j3$/) + await done.getByRole('button', { name: /again/ }).click() + await expect.poll(() => k.calls.some(c => c.m === 'rerun' && c.id === 'j3')).toBe(true) + + await history.locator('[data-job="j1"]').getByRole('button', { name: 'Cancel' }).click() + await expect.poll(() => k.calls.some(c => c.m === 'cancel' && c.id === 'j1')).toBe(true) + + await history.locator('[data-job="j4"]').getByRole('button', { name: /More actions for this run/ }).click() + await page.getByRole('menuitem', { name: 'Delete run' }).click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click() + await expect(history.locator('[data-job="j4"]')).toHaveCount(0) + }) + + test('with no tasks it explains what one is and offers to create one', async ({ page }) => { + await setup(page, { tasks: [], jobs: [] }) + await expect(page.getByTestId('agent-tasks-empty')).toContainText('No tasks yet') + await page.getByTestId('agent-tasks-empty').getByRole('button', { name: 'New task' }).click() + await expect(page.getByTestId('agent-task-form')).toBeVisible() + }) + + test('a failed load says so with a way to try again', async ({ page }) => { + await setup(page, { fail: true }) + await expect(page.getByRole('alert')).toContainText('Could not load the tasks.') + await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible() + }) +}) diff --git a/core/http/react-ui/e2e/agents-edit.spec.js b/core/http/react-ui/e2e/agents-edit.spec.js index 47c1058d7..78983b967 100644 --- a/core/http/react-ui/e2e/agents-edit.spec.js +++ b/core/http/react-ui/e2e/agents-edit.spec.js @@ -1,128 +1,179 @@ import { test, expect } from './coverage-fixtures.js' -import { installFakeStream, mockAgents, RESEARCH_CONFIG } from './agents-fixtures.js' +import { installAgents } from './agents-fixtures.js' +import { writeFile, mkdtemp } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' -test.describe('Create and edit an agent', () => { - test('sections fold, show ready marks and a one-line summary', async ({ page }) => { - await installFakeStream(page) - await mockAgents(page) - await page.goto('/app/agents/research-assistant/edit') - await expect(page.getByRole('heading', { name: 'Edit Agent: research-assistant' })).toBeVisible() - const basics = page.locator('[data-section="BasicInfo"]') - await expect(basics.locator('.ag-fold__button')).toHaveAttribute('aria-expanded', 'true') - const model = page.locator('[data-section="ModelSettings"]') - await expect(model.locator('.ag-fold__button')).toHaveAttribute('aria-expanded', 'false') - await expect(model.locator('.ag-fold__sum')).toHaveText('qwen3-14b-instruct') - await expect(model.locator('.ag-ready')).toHaveAttribute('data-state', 'ready') - await expect(model.locator('.ag-fold__body')).toHaveCount(0) - await model.locator('.ag-fold__button').click() - await expect(model.locator('.ag-fold__body')).toBeVisible() - await model.locator('.ag-fold__button').click() - await expect(model.locator('.ag-fold__body')).toHaveCount(0) - }) +// The agent form: folds, preview, import and export, with the saved-agents API stubbed. - test('a new agent starts with sections that need a name and a model', async ({ page }) => { - await installFakeStream(page) - await mockAgents(page) +const fold = (page, id) => page.locator(`[data-section="${id}"]`) + +test.describe('Agent form', () => { + test('creates an agent from the folds and sends the definition', async ({ page }) => { + const s = await installAgents(page) await page.goto('/app/agents/new') - await expect(page.locator('[data-section="BasicInfo"] .ag-ready')).toHaveAttribute('data-state', 'needs') - await expect(page.locator('[data-section="ModelSettings"] .ag-fold__sum')).toHaveText('Needs a model') - await expect(page.locator('[data-section="MemorySettings"] .ag-ready')).toHaveAttribute('data-state', 'empty') + await expect(page.getByTestId('agent-edit')).toBeVisible() + + await page.getByLabel('Name', { exact: false }).first().fill('Reviewer') + await page.getByLabel('Description').fill('Checks a change') + + await fold(page, 'PromptsGoals').locator('.ag-fold__button').click() + await page.getByLabel('Instructions').fill('Answer in French.') + + await fold(page, 'MemorySettings').locator('.ag-fold__button').click() + await page.getByRole('combobox', { name: 'Collections +' }).fill('handbook') + await page.getByRole('combobox', { name: 'Collections +' }).press('Enter') + await expect(page.getByTestId('agent-collections').getByRole('listitem')).toHaveText(['handbook']) + + await fold(page, 'Skills').locator('.ag-fold__button').click() + await page.getByTestId('agent-skills').getByRole('checkbox', { name: /citations/ }).check() + + await fold(page, 'MCP').locator('.ag-fold__button').click() + await page.getByTestId('agent-connectors').getByRole('checkbox', { name: /Docs/ }).check() + + await page.getByRole('button', { name: 'Create Agent' }).first().click() + await expect(page).toHaveURL(/\/app\/agents$/) + expect(s.calls.find(c => c.method === 'POST').body).toEqual({ + name: 'Reviewer', description: 'Checks a change', instructions: 'Answer in French.', model: '', + mcp_servers: ['connector:m1'], addons: [], collections: ['handbook'], skills: ['citations'], network: 'off', + }) }) - test('a template fills the form and saves nothing', async ({ page }) => { - await installFakeStream(page) - const seen = await mockAgents(page) + test('sets the network of the commands of unattended runs', async ({ page }) => { + const s = await installAgents(page) await page.goto('/app/agents/new') - await page.getByRole('button', { name: 'Code reviewer' }).click() - await expect(page.locator('[data-section="BasicInfo"] .ag-fold__sum')).toContainText('code-reviewer') - expect(seen.saves).toEqual([]) + await page.getByLabel('Name', { exact: false }).first().fill('Builder') + await fold(page, 'Addons').locator('.ag-fold__button').click() + await expect(page.getByTestId('agent-network')).toHaveValue('off') + await page.getByTestId('agent-network').selectOption('registries') + await page.getByRole('button', { name: 'Create Agent' }).first().click() + await expect(page).toHaveURL(/\/app\/agents$/) + expect(s.calls.find(c => c.method === 'POST').body.network).toBe('registries') }) - test('a template link from the launcher opens the form already filled', async ({ page }) => { - await installFakeStream(page) - await mockAgents(page) - await page.goto('/app/agents/new?template=digest') - await expect(page.locator('[data-section="BasicInfo"] .ag-fold__sum')).toContainText('daily-digest') + test('needs a name, and says so on the fold', async ({ page }) => { + const s = await installAgents(page) + await page.goto('/app/agents/new') + await expect(fold(page, 'BasicInfo')).toContainText('Needs a name') + await page.getByRole('button', { name: 'Create Agent' }).first().click() + expect(s.calls.some(c => c.method === 'POST')).toBe(false) }) - test('the preview sheet shows the config and, when editing, what changed', async ({ page }) => { - await installFakeStream(page) - const seen = await mockAgents(page, { agents: { 'research-assistant': { active: true, config: { ...RESEARCH_CONFIG, api_key: 'sk-secret-value', mcp_servers: [{ url: 'https://x.example.org', token: 'tok-123' }] } } } }) - await page.goto('/app/agents/research-assistant/edit') - await page.locator('[data-section="PromptsGoals"] .ag-fold__button').click() - await page.locator('[data-section="PromptsGoals"] textarea').fill('A new instruction.') - await expect(page.locator('[data-section="PromptsGoals"] .ag-badge-changed')).toHaveText('1 changed') + test('a starting point fills the name, the description and the instructions', async ({ page }) => { + await installAgents(page) + await page.goto('/app/agents/new') + await page.locator('[data-template="reviewer"]').click() + await expect(page.getByLabel('Name', { exact: false }).first()).toHaveValue('code-reviewer') + await expect(page.getByLabel('Instructions')).toContainText('You review code changes') + }) + + test('the preview shows the definition and what changed from the saved agent', async ({ page }) => { + const s = await installAgents(page) + await page.goto('/app/agents/a1/edit') + await expect(page.getByLabel('Name', { exact: false }).first()).toHaveValue('research-assistant') + await page.getByLabel('Description').fill('Searches and cites.') + await expect(fold(page, 'BasicInfo')).toContainText('1 changed') await page.getByTestId('agent-preview-open').click() const sheet = page.getByTestId('agent-preview') - await expect(sheet.getByTestId('agent-preview-config')).toContainText('"system_prompt": "A new instruction."') - const cfg = await sheet.getByTestId('agent-preview-config').textContent() - expect(cfg).not.toContain('sk-secret-value') - expect(cfg).not.toContain('tok-123') - - // Switching tabs re-renders the page; focus stays on the tab. + await expect(sheet.getByTestId('agent-preview-config')).toContainText('"description": "Searches and cites."') await sheet.getByRole('tab', { name: /Changes/ }).click() - await expect(sheet.getByRole('tab', { name: /Changes/ })).toBeFocused() const changes = sheet.getByTestId('agent-preview-changes') - await expect(changes.locator('[data-key]')).toHaveCount(1) - await expect(changes.locator('[data-key="system_prompt"]')).toContainText('You are a careful research assistant') - await expect(changes.locator('[data-key="system_prompt"]')).toContainText('A new instruction.') + await expect(changes).toContainText('description') + await expect(changes.locator('[data-side="before"]')).toContainText('Searches the web and writes short briefs.') + await expect(changes.locator('[data-side="after"]')).toContainText('Searches and cites.') - await page.keyboard.press('Escape') - await expect(sheet).toHaveCount(0) - - await page.getByTestId('agent-preview-open').click() - await page.getByTestId('agent-preview').getByRole('button', { name: 'Save Changes' }).click() - await expect.poll(() => seen.saves.length).toBe(1) - expect(seen.saves[0]).toMatchObject({ method: 'PUT', name: 'research-assistant' }) - expect(seen.saves[0].body.system_prompt).toBe('A new instruction.') + await sheet.getByRole('button', { name: 'Save Changes' }).click() + await expect(page).toHaveURL(/\/app\/agents$/) + expect(s.calls.find(c => c.method === 'PUT')).toMatchObject({ url: '/api/chats/agents/a1', body: { name: 'research-assistant', description: 'Searches and cites.', mcp_servers: ['crm', 'connector:m1'] } }) }) - test('with no edits the Changes tab says the form matches the saved agent', async ({ page }) => { - await installFakeStream(page) - await mockAgents(page) - await page.goto('/app/agents/research-assistant/edit') - await page.getByTestId('agent-preview-open').click() - await page.getByRole('tab', { name: /Changes/ }).click() - await expect(page.getByTestId('agent-preview-changes')).toContainText('No changes') + test('the connector picker names connectors, keeps one that was deleted, and the servers are free text', async ({ page }) => { + const s = await installAgents(page) + await page.goto('/app/agents/a1/edit') + await fold(page, 'MCP').locator('.ag-fold__button').click() + const picker = page.getByTestId('agent-connectors') + await expect(picker.getByRole('checkbox', { name: /Docs/ })).toBeChecked() + await expect(picker.getByRole('checkbox', { name: /Parked/ })).not.toBeChecked() + await expect(picker).toContainText('off') + await expect(page.getByTestId('agent-mcp').getByRole('listitem')).toHaveText(['crm']) + await expect(page.locator('datalist option[value="mail"]')).toHaveCount(1) + await picker.getByRole('checkbox', { name: /Parked/ }).check() + await page.getByRole('combobox', { name: 'MCP servers +' }).fill('mail') + await page.getByRole('combobox', { name: 'MCP servers +' }).press('Enter') + await page.getByRole('button', { name: 'Save Changes' }).first().click() + await expect.poll(() => s.calls.find(c => c.method === 'PUT')?.body.mcp_servers).toEqual(['crm', 'mail', 'connector:m1', 'connector:m2']) }) - test('creating an agent sends the form and offers no Dry run it cannot run', async ({ page }) => { - await installFakeStream(page) - const seen = await mockAgents(page) + test('shows the reason when the server refuses the agent', async ({ page }) => { + await installAgents(page) + await page.route(/\/api\/chats\/agents(\?.*)?$/, route => (route.request().method() === 'POST' + ? route.fulfill({ status: 409, json: { error: { message: 'you already have an agent with this name', type: 'conflict', code: 409 } } }) + : route.fulfill({ json: { agents: [] } }))) await page.goto('/app/agents/new') - await page.locator('[data-section="BasicInfo"] input').first().fill('my-agent') - await page.locator('[data-section="ModelSettings"] .ag-fold__button').click() - await page.locator('[data-section="ModelSettings"] input').first().fill('qwen3-8b-instruct') - await page.getByTestId('agent-preview-open').click() - await expect(page.getByRole('tab', { name: /Dry run/ })).toHaveCount(0) - await page.getByRole('tab', { name: /Changes/ }).click() - await expect(page.getByTestId('agent-preview-changes')).toContainText('qwen3-8b-instruct') - await page.keyboard.press('Escape') + await page.getByLabel('Name', { exact: false }).first().fill('Dup') await page.getByRole('button', { name: 'Create Agent' }).first().click() - await expect.poll(() => seen.saves.length).toBe(1) - expect(seen.saves[0].body).toMatchObject({ name: 'my-agent', model: 'qwen3-8b-instruct' }) + await expect(page.getByText('you already have an agent with this name')).toBeVisible() + await expect(page).toHaveURL(/\/app\/agents\/new$/) }) - test('draft from a sentence needs a model first and is labelled optional', async ({ page }) => { - await installFakeStream(page) - await mockAgents(page) - await page.goto('/app/agents/new') - await expect(page.getByTestId('agent-start')).toContainText('Optional') - await page.getByLabel('Describe the agent in one sentence').fill('reads release notes') - await page.getByRole('button', { name: 'Draft' }).click() - await expect(page.getByText(/Pick a model in the Model section first/)).toBeVisible() + test('imports an exported agent and the export of an older agent through the server, and names what was dropped', async ({ page }) => { + const s = await installAgents(page) + const dir = await mkdtemp(join(tmpdir(), 'agent-import-')) + const exported = join(dir, 'new.json') + const file = { version: 1, kind: 'localai-agent', agent: { name: 'Imported', description: 'd', instructions: 'Be brief.', model: '', mcp_servers: [], addons: [], collections: ['handbook'], skills: ['citations'] } } + await writeFile(exported, JSON.stringify(file)) + await page.goto('/app/agents') + await page.locator('input[type=file]').first().setInputFiles(exported) + // The server makes the agent, and the page opens it. + await expect(page).toHaveURL(/\/app\/agents\/a3$/) + await expect(page.getByRole('heading', { name: 'Imported', level: 1 })).toBeVisible() + await expect(page.getByText('Agent "Imported" imported')).toBeVisible() + expect(s.calls.find(c => c.method === 'POST' && c.url === '/api/chats/agents/import').body).toEqual(file) + + // An export of the old agents is turned into a definition first, and what + // a saved agent has no place for is named. + const legacy = join(dir, 'old.json') + await writeFile(legacy, JSON.stringify({ name: 'helper', model: 'qwen3-8b', system_prompt: 'Be kind.', selected_skills: ['citations'], actions: [{ name: 'web_search' }], enable_kb: true })) + await page.goto('/app/agents') + await page.locator('input[type=file]').first().setInputFiles(legacy) + await expect(page).toHaveURL(/\/app\/agents\/a4$/) + await expect(page.getByText('Not imported: actions, enable_kb.')).toBeVisible() + const sent = s.calls.filter(c => c.method === 'POST' && c.url === '/api/chats/agents/import')[1].body + expect(sent).toMatchObject({ name: 'helper', model: 'qwen3-8b', instructions: 'Be kind.', skills: ['citations'] }) + expect(sent).not.toHaveProperty('system_prompt') }) - test('draft from a sentence fills name, description and instructions from the chosen model', async ({ page }) => { - await installFakeStream(page) - await mockAgents(page) - await page.route('**/v1/chat/completions', route => route.fulfill({ json: { choices: [{ message: { content: '```json\n{"name":"release-reader","description":"Reads release notes.","system_prompt":"List breaking changes."}\n```' } }] } })) - await page.goto('/app/agents/new') - await page.locator('[data-section="ModelSettings"] .ag-fold__button').click() - await page.locator('[data-section="ModelSettings"] input').first().fill('qwen3-8b-instruct') - await page.getByLabel('Describe the agent in one sentence').fill('reads release notes') - await page.getByRole('button', { name: 'Draft' }).click() - await expect(page.locator('[data-section="BasicInfo"] .ag-fold__sum')).toContainText('release-reader') + test('says what the server changed in an imported agent', async ({ page }) => { + await installAgents(page, { importWarnings: ['the model "model-from-elsewhere" is not available to you here, so the agent has no model'] }) + const dir = await mkdtemp(join(tmpdir(), 'agent-import-')) + const path = join(dir, 'shared.json') + await writeFile(path, JSON.stringify({ version: 1, kind: 'localai-agent', agent: { name: 'Shared', instructions: 'Hi', model: 'model-from-elsewhere' } })) + await page.goto('/app/agents') + await page.locator('input[type=file]').first().setInputFiles(path) + await expect(page).toHaveURL(/\/app\/agents\/a3$/) + await expect(page.getByText(/is not available to you here/)).toBeVisible() + }) + + test('a file that is not an agent is refused with a message', async ({ page }) => { + const s = await installAgents(page) + const dir = await mkdtemp(join(tmpdir(), 'agent-import-')) + const bad = join(dir, 'bad.json') + await writeFile(bad, JSON.stringify({ hello: 'world' })) + await page.goto('/app/agents') + await page.locator('input[type=file]').first().setInputFiles(bad) + // The server reads the file and refuses it; nothing is made. + await expect(page.getByText(/the body is not a valid agent file/)).toBeVisible() + await expect(page).toHaveURL(/\/app\/agents$/) + expect(s.agents).toHaveLength(2) + }) + + test('the Export fold downloads the definition of a saved agent', async ({ page }) => { + await installAgents(page) + await page.goto('/app/agents/a1/edit') + await fold(page, 'export').locator('.ag-fold__button').click() + const download = page.waitForEvent('download') + await page.getByRole('button', { name: 'Export agent' }).click() + expect((await download).suggestedFilename()).toBe('research-assistant.json') }) }) diff --git a/core/http/react-ui/e2e/agents-fixtures.js b/core/http/react-ui/e2e/agents-fixtures.js index 427e38134..cea1dfa8b 100644 --- a/core/http/react-ui/e2e/agents-fixtures.js +++ b/core/http/react-ui/e2e/agents-fixtures.js @@ -1,139 +1,218 @@ -// Shared fixtures for the Agents specs: stubbed agent API routes, a fake event -// stream the test drives by hand, and run records written to the browser -// storage the pages read. +// Stubbed API of the saved agents, their chats, the triggers and the skills, for +// the specs of the agents, skills and triggers pages. Every call is recorded in +// `s.calls` so a spec can assert what the page sent. +import { mockChat } from './chat-fixtures.js' -export const RUNS_KEY = (agent, userId) => `localai_agent_runs_${userId ? `${userId}:` : ''}${agent}` - -export const RESEARCH_CONFIG = { - name: 'research-assistant', - description: 'Searches the web, reads sources and writes short briefs.', - model: 'qwen3-14b-instruct', - system_prompt: 'You are a careful research assistant. Search first, read at least two sources, then summarise in plain language.', - actions: [{ name: 'web_search', config: '{}' }, { name: 'read_page', config: '{}' }, { name: 'write_file', config: '{}' }], - mcp_stdio_servers: JSON.stringify({ mcpServers: { filesystem: { command: 'npx', args: [], env: {} } } }), - enable_kb: true, - enable_skills: true, - selected_skills: ['citations'], -} - -export const AGENTS = { - 'research-assistant': { active: true, config: RESEARCH_CONFIG }, - 'code-reviewer': { - active: true, - config: { name: 'code-reviewer', description: 'Reviews a change and lists what to fix first.', model: 'qwen3-14b-instruct', system_prompt: 'You review code changes.' }, +export const AGENTS = [ + { + id: 'a1', name: 'research-assistant', description: 'Searches the web and writes short briefs.', model: 'qwen3-8b', + instructions: 'You are a careful research assistant. Cite every claim.', mcp_servers: ['crm', 'connector:m1'], + addons: ['office'], collections: ['handbook'], skills: ['citations'], + created: '2026-10-01T10:00:00Z', updated: '2026-10-02T10:00:00Z', }, - 'daily-digest': { - active: false, - config: { name: 'daily-digest', description: 'Collects what is new and summarises it.', model: 'qwen3-8b-instruct', system_prompt: 'You collect news.' }, + { + id: 'a2', name: 'code-reviewer', description: 'Reviews a change and lists what to fix first.', model: '', + instructions: 'You review code changes.', mcp_servers: [], addons: [], collections: [], skills: [], network: 'off', + created: '2026-10-01T10:00:00Z', updated: '2026-10-02T10:00:00Z', }, -} +] -const META = { - Fields: [ - { name: 'name', label: 'Name', type: 'text', required: true, tags: { section: 'BasicInfo' } }, - { name: 'description', label: 'Description', type: 'textarea', tags: { section: 'BasicInfo' } }, - { name: 'model', label: 'Model', type: 'text', required: true, tags: { section: 'ModelSettings' } }, - { name: 'enable_kb', label: 'Knowledge base', type: 'checkbox', tags: { section: 'MemorySettings' } }, - { name: 'system_prompt', label: 'System prompt', type: 'textarea', tags: { section: 'PromptsGoals' } }, - { name: 'enable_skills', label: 'Skills', type: 'checkbox', tags: { section: 'AdvancedSettings' } }, +const iso = (minutesAgo) => new Date(Date.now() - minutesAgo * 60_000).toISOString() + +export const CHATS = [ + { id: 'c1', title: 'Find sources on speculative decoding', model: 'qwen3-8b', kind: 'chat', agent_id: 'a1', running: false, last_turn: 'done', created: iso(300), updated: iso(298) }, + { id: 'c2', title: 'Digest of the week', model: 'qwen3-8b', kind: 'chat', agent_id: 'a1', origin: 'trigger', origin_id: 't1', running: false, last_turn: 'failed', created: iso(120), updated: iso(118) }, + { id: 'c3', title: 'Unrelated chat', model: 'qwen3-8b', kind: 'chat', running: false, last_turn: 'done', created: iso(60), updated: iso(59) }, +] + +export const TRIGGERS = [ + { + id: 't1', name: 'Morning digest', prompt: 'Summarise yesterday.', kind: 'schedule', schedule: 'weekdays 09:00', time_zone: 'Europe/Rome', + daily_cap: 24, enabled: true, agent_id: 'a1', model: '', summary: 'Every weekday at 09:00 (Europe/Rome)', next_run: '2026-10-28T08:00:00Z', + last_run: { id: 'r1', status: 'failed', reason: 'the agent was deleted', due: '2026-10-27T08:00:00Z' }, + }, + { + id: 't2', name: 'New contracts', prompt: 'Read the new file and list its parties.', kind: 'file', folder: '/Contracts', quiet_seconds: 30, + daily_cap: 10, enabled: false, agent_id: '', model: 'qwen3-8b', summary: 'When a file arrives in /Contracts', + }, +] + +export const RUNS = { + t1: [ + { id: 'r1', status: 'failed', reason: 'the agent was deleted', due: iso(120), started: iso(120), ended: iso(119), chat_id: 'c2' }, + { id: 'r0', status: 'done', due: iso(1560), started: iso(1560), ended: iso(1558), chat_id: 'c7' }, + { id: 'rs', status: 'skipped', reason: 'daily cap reached', due: iso(3000) }, ], - Connectors: [], Actions: [], Filters: [], DynamicPrompts: [], + t2: [{ id: 'rf', status: 'done', due: iso(200), started: iso(200), ended: iso(199), chat_id: 'c8', file: '/Contracts/a.pdf' }], } -// Replace EventSource with one the test drives: window.__emit(type, data). -export async function installFakeStream(page) { - await page.addInitScript(() => { - const sources = [] - class FakeES { - constructor(url) { - this.url = url - this.listeners = {} - this.readyState = 1 - sources.push(this) - setTimeout(() => this.onopen && this.onopen({}), 0) - } - addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn) } - removeEventListener(type, fn) { this.listeners[type] = (this.listeners[type] || []).filter(f => f !== fn) } - close() { this.readyState = 2; const i = sources.indexOf(this); if (i >= 0) sources.splice(i, 1) } - } - window.EventSource = FakeES - window.__emit = (type, data) => { - sources.forEach(s => (s.listeners[type] || []).forEach(fn => fn({ data: typeof data === 'string' ? data : JSON.stringify(data) }))) - } - window.__streams = () => sources.length - }) -} +export const SKILLS = [ + { name: 'citations', description: 'Cite every claim with a numbered source.', layer: 'personal', path: '/skills/citations/SKILL.md', enabled: true, editable: true }, + { name: 'house-style', description: 'The shared writing rules.', layer: 'shared', path: '/shared/house-style/SKILL.md', enabled: true, editable: false }, + { name: 'pdf', description: 'Read and fill PDF forms.', layer: 'builtin', path: '/builtin/pdf/SKILL.md', enabled: true, editable: false }, + { name: 'broken', description: '', layer: 'folder', path: '/Notes/.skills/broken/SKILL.md', enabled: true, editable: false, invalid: 'the description is empty' }, +] -export function emit(page, type, data) { - return page.evaluate(([t, d]) => window.__emit(t, d), [type, data]) -} +const BODIES = { citations: 'Cite every claim.\n\n1. Number the sources.\n2. List them at the end.\n' } -// Stub every agent endpoint the area reads. Returns the requests the page -// sent, so a spec can assert on them. -export async function mockAgents(page, { - agents = AGENTS, - observables = {}, - chatStatus = 202, - chatError = 'agent unavailable', - features = { distributed: false, localai_assistant: true, agents: true, mcp: true }, - skills = [{ name: 'citations', description: 'Cite sources' }], +export async function installAgents(page, { + agents = AGENTS, chats = CHATS, triggers = TRIGGERS, runs = RUNS, skills = SKILLS, proposals = [], importWarnings = [], + collections = ['handbook', 'notes'], connectors = [{ id: 'm1', name: 'Docs', enabled: true }, { id: 'm2', name: 'Parked', enabled: false }], } = {}) { - const seen = { chats: [], saves: [], deletes: [], pauses: [], resumes: [], clears: [] } - await page.route('**/api/features', route => route.fulfill({ json: features })) - await page.route('**/api/agents/skills', route => route.fulfill({ json: { skills } })) - await page.route('**/api/agents/config/metadata', route => route.fulfill({ json: META })) - await page.route('**/api/agents', async route => { + await mockChat(page) + const s = { + agents: structuredClone(agents), triggers: structuredClone(triggers), skills: structuredClone(skills), proposals: structuredClone(proposals), + calls: [], createdChats: [], + } + const rec = (req) => { const u = new URL(req.url()); s.calls.push({ method: req.method(), url: u.pathname, search: u.search, body: req.postData() ? req.postDataJSON() : null }) } + const err = (route, status, message) => route.fulfill({ status, json: { error: { message, type: 'invalid_request_error', code: status } } }) + + await page.route('**/api/collections*', route => route.fulfill({ json: { collections } })) + await page.route('**/v1/mcp/servers/*', route => route.fulfill({ json: { model: 'qwen3-8b', servers: [{ name: 'crm' }, { name: 'mail' }] } })) + await page.route('**/api/chats/mcp-connectors*', route => route.fulfill({ json: { connectors } })) + await page.route('**/api/chats/files*', route => route.fulfill({ json: { path: '/', entries: [{ path: '/Contracts', name: 'Contracts', kind: 'dir' }] } })) + await page.route('**/api/chats/notifications/events', route => route.abort()) + await page.route(/\/api\/chats\/notifications(\?.*)?$/, route => route.fulfill({ json: { notifications: [], unread: 0 } })) + await page.route('**/api/chats/push/**', route => err(route, 409, 'push off')) + + // Agents. + await page.route(/\/api\/chats\/agents\/[^/?]+$/, route => { + const req = route.request() + rec(req) + const id = new URL(req.url()).pathname.split('/').pop() + const found = s.agents.find(a => a.id === id) + if (!found) return err(route, 404, 'no such agent') + if (req.method() === 'DELETE') { s.agents = s.agents.filter(a => a.id !== id); return route.fulfill({ status: 204, body: '' }) } + if (req.method() === 'PUT') { Object.assign(found, req.postDataJSON()); return route.fulfill({ json: found }) } + return route.fulfill({ json: found }) + }) + await page.route(/\/api\/chats\/agents(\?.*)?$/, route => { const req = route.request() if (req.method() === 'POST') { - seen.saves.push({ method: 'POST', body: req.postDataJSON() }) - return route.fulfill({ json: { status: 'ok' } }) + rec(req) + const b = req.postDataJSON() + const a = { id: `a${s.agents.length + 1}`, mcp_servers: [], addons: [], collections: [], skills: [], network: 'off', ...b } + s.agents.push(a) + return route.fulfill({ status: 201, json: a }) } - const statuses = Object.fromEntries(Object.entries(agents).map(([n, a]) => [n, a.active])) - return route.fulfill({ json: { agents: Object.keys(agents), statuses } }) + return route.fulfill({ json: { agents: s.agents } }) }) - await page.route(/\/api\/agents\/[^/?]+(\/[a-z]+)?(\?.*)?$/, async route => { + + // Agent files and run history, as the server answers them: an export is the + // definition without id or owner, an import always makes a new agent, and + // the runs are the chats that ran with the agent. + await page.route(/\/api\/chats\/agents\/import$/, route => { const req = route.request() - const url = new URL(req.url()) - const m = url.pathname.match(/\/api\/agents\/([^/]+)(?:\/([a-z]+))?$/) - if (!m) return route.fallback() - const name = decodeURIComponent(m[1]) - const tail = m[2] - if (['skills', 'collections', 'actions', 'config', 'git-repos', 'import'].includes(name)) return route.fallback() - const agent = agents[name] - if (!agent) return route.fulfill({ status: 404, json: { error: 'Agent not found' } }) - if (tail === 'chat' && req.method() === 'POST') { - seen.chats.push({ name, ...req.postDataJSON() }) - if (chatStatus !== 202) return route.fulfill({ status: chatStatus, json: { error: chatError } }) - return route.fulfill({ status: 202, json: { status: 'message_received', message_id: `m${seen.chats.length}` } }) - } - if (tail === 'config') return route.fulfill({ json: agent.config }) - if (tail === 'observables') { - if (req.method() === 'DELETE') { seen.clears.push(name); return route.fulfill({ json: { cleared: true } }) } - return route.fulfill({ json: { Name: name, History: observables[name] || [] } }) - } - if (tail === 'status') return route.fulfill({ json: { Name: name, History: [] } }) - if (tail === 'pause') { seen.pauses.push(name); return route.fulfill({ json: { status: 'ok' } }) } - if (tail === 'resume') { seen.resumes.push(name); return route.fulfill({ json: { status: 'ok' } }) } - if (tail === 'export') return route.fulfill({ json: agent.config }) - if (req.method() === 'PUT') { seen.saves.push({ method: 'PUT', name, body: req.postDataJSON() }); return route.fulfill({ json: { status: 'ok' } }) } - if (req.method() === 'DELETE') { seen.deletes.push(name); return route.fulfill({ json: { status: 'ok' } }) } - return route.fulfill({ json: { active: agent.active } }) + rec(req) + const b = req.postDataJSON() + const def = b && b.kind === 'localai-agent' ? b.agent : b + if (!def || typeof def !== 'object' || !def.name) return err(route, 400, 'the body is not a valid agent file') + const a = { id: `a${s.agents.length + 1}`, mcp_servers: [], addons: [], collections: [], skills: [], ...def } + s.agents.push(a) + return route.fulfill({ status: 201, json: { agent: a, ...(importWarnings.length ? { warnings: importWarnings } : {}) } }) + }) + await page.route(/\/api\/chats\/agents\/[^/?]+\/export$/, route => { + rec(route.request()) + const id = new URL(route.request().url()).pathname.split('/')[4] + const found = s.agents.find(a => a.id === id) + if (!found) return err(route, 404, 'no such agent') + const { name, description, instructions, model, mcp_servers, addons, collections, skills } = found + return route.fulfill({ json: { version: 1, kind: 'localai-agent', agent: { name, description, instructions, model, mcp_servers, addons, collections, skills } } }) + }) + await page.route(/\/api\/chats\/agents\/[^/?]+\/runs(\?.*)?$/, route => { + const id = new URL(route.request().url()).pathname.split('/')[4] + const mine = chats.filter(c => c.agent_id === id) + return route.fulfill({ + json: { + runs: mine.map(c => ({ chat_id: c.id, title: c.title, model: c.model, origin: c.origin, origin_id: c.origin_id, created: c.created, updated: c.updated, running: c.running, last_turn: c.last_turn })), + total: mine.length, failed: mine.filter(c => c.last_turn === 'failed').length, running: mine.filter(c => c.running).length, + }, + }) }) - return seen -} -// Seed run records. Each run: { id, task, status, outcome, ... } is expanded -// into the stored shape. -export function storedRun(agent, { id, task, status = 'done', outcome = '', error = '', steps = [], startedAt, seconds = 30, metadata = null, turns = null }) { - const first = { - task, startedAt, endedAt: status === 'running' ? null : startedAt + seconds * 1000, status, outcome, error, steps, - live: { reasoning: '', content: '' }, metadata, - } - return { id, agent, startedAt, updatedAt: startedAt + seconds * 1000, turns: turns || [first] } -} + // Chats. + await page.route(/\/api\/chats(\?.*)?$/, route => { + const req = route.request() + if (req.method() === 'POST') { + const b = req.postDataJSON() + rec(req) + s.createdChats.push(b) + return route.fulfill({ status: 201, json: { id: 'cnew', title: 'New chat', model: b.model || 'qwen3-8b', kind: 'chat', agent_id: b.agent_id, running: false, created: iso(0), updated: iso(0) } }) + } + return route.fulfill({ json: { conversations: chats, persistent: true, files: true } }) + }) -export async function seedRuns(page, agent, runs, userId) { - await page.addInitScript(([key, data]) => { - try { localStorage.setItem(key, JSON.stringify(data)) } catch { /* ignore */ } - }, [RUNS_KEY(agent, userId), { version: 1, runs }]) + // Triggers. + await page.route('**/api/chats/triggers/preview*', route => { + const when = new URL(route.request().url()).searchParams.get('when') || '' + if (when.includes('25:00')) return err(route, 400, 'hour must be 0 to 23') + return route.fulfill({ json: { summary: 'Every day at 08:00', times: ['2026-10-28T08:00:00Z', '2026-10-29T08:00:00Z', '2026-10-30T08:00:00Z'] } }) + }) + await page.route('**/api/chats/triggers/proposals/*/confirm', route => { + rec(route.request()) + s.proposals = [] + return route.fulfill({ json: { ...TRIGGERS[0], id: 't9', name: 'Proposed digest' } }) + }) + await page.route(/\/api\/chats\/triggers\/proposals\/[^/?]+$/, route => { + rec(route.request()) + s.proposals = [] + return route.fulfill({ status: 204, body: '' }) + }) + await page.route(/\/api\/chats\/triggers\/proposals(\?.*)?$/, route => route.fulfill({ json: { triggers: s.proposals } })) + await page.route(/\/api\/chats\/triggers\/[^/?]+\/runs$/, route => { + const id = new URL(route.request().url()).pathname.split('/')[4] + return route.fulfill({ json: { runs: runs[id] || [] } }) + }) + await page.route(/\/api\/chats\/triggers\/[^/?]+\/(run|enable|disable)$/, route => { + const req = route.request() + rec(req) + const parts = new URL(req.url()).pathname.split('/') + const verb = parts.pop() + const id = parts.pop() + const found = s.triggers.find(x => x.id === id) + if (verb !== 'run' && found) found.enabled = verb === 'enable' + return route.fulfill({ status: verb === 'run' ? 202 : 200, json: verb === 'run' ? { id: 'rnew', status: 'queued', due: iso(0) } : found }) + }) + await page.route(/\/api\/chats\/triggers\/(?!proposals|preview)[^/?]+$/, route => { + const req = route.request() + rec(req) + const id = new URL(req.url()).pathname.split('/').pop() + const found = s.triggers.find(x => x.id === id) + if (req.method() === 'DELETE') { s.triggers = s.triggers.filter(x => x.id !== id); return route.fulfill({ status: 204, body: '' }) } + Object.assign(found, req.postDataJSON()) + return route.fulfill({ json: found }) + }) + await page.route(/\/api\/chats\/triggers(\?.*)?$/, route => { + const req = route.request() + if (req.method() === 'POST') { + rec(req) + const t = { id: `t${s.triggers.length + 5}`, summary: 'Every day at 08:00', ...req.postDataJSON() } + s.triggers.push(t) + return route.fulfill({ status: 201, json: t }) + } + return route.fulfill({ json: { triggers: s.triggers } }) + }) + + // Skills. + await page.route(/\/api\/chats\/skills\/[^/?]+\/[^/?]+$/, route => { + const req = route.request() + rec(req) + const [, , , , layer, name] = new URL(req.url()).pathname.split('/') + const found = s.skills.find(x => x.layer === layer && x.name === name) + if (!found) return err(route, 404, 'no such skill') + if (req.method() === 'DELETE') { s.skills = s.skills.filter(x => x !== found); return route.fulfill({ status: 204, body: '' }) } + if (req.method() === 'PUT') { Object.assign(found, { description: req.postDataJSON().description }); return route.fulfill({ json: found }) } + return route.fulfill({ json: { name, layer, description: found.description, body: BODIES[name] || `Instructions of ${name}.\n`, editable: found.editable } }) + }) + await page.route(/\/api\/chats\/skills(\?.*)?$/, route => { + const req = route.request() + if (req.method() === 'POST') { + rec(req) + const b = req.postDataJSON() + s.skills.push({ name: b.name, description: b.description, layer: b.layer, path: `/skills/${b.name}/SKILL.md`, enabled: true, editable: true }) + return route.fulfill({ status: 201, json: b }) + } + return route.fulfill({ json: { skills: s.skills, notes: [] } }) + }) + return s } diff --git a/core/http/react-ui/e2e/agents-runs.spec.js b/core/http/react-ui/e2e/agents-runs.spec.js deleted file mode 100644 index 9d992476a..000000000 --- a/core/http/react-ui/e2e/agents-runs.spec.js +++ /dev/null @@ -1,316 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { installFakeStream, mockAgents, seedRuns, storedRun, emit, AGENTS } from './agents-fixtures.js' - -const TASK = 'Find two sources on speculative decoding and summarise them.' -const OUTCOME = 'I read two sources.\n\n| A | B | C | D |\n| - | - | - | - |\n| 1 | 2 | 3 | 4 |' - -async function startRun(page, seen) { - await page.goto('/app/agents/research-assistant') - await expect(page.getByTestId('agent-page')).toBeVisible() - await page.locator('#ag-task-input').fill(TASK) - await page.getByRole('button', { name: 'Start run' }).click() - await expect(page).toHaveURL(/\/app\/agents\/research-assistant\/runs\/r_[a-z0-9]{6}$/) - await expect(page.getByTestId('run-live')).toBeVisible() - await expect.poll(() => seen.chats.length).toBe(1) -} - -test.describe('Agent page', () => { - test('shows what the agent is: model, tools, memory, skills, instructions', async ({ page }) => { - await installFakeStream(page) - await mockAgents(page) - await page.goto('/app/agents/research-assistant') - const facts = page.getByTestId('agent-facts') - await expect(facts).toContainText('qwen3-14b-instruct') - await expect(facts).toContainText('web_search') - await expect(facts).toContainText('mcp: filesystem') - await expect(facts.getByRole('link', { name: 'Knowledge base' })).toHaveAttribute('href', '/app/collections') - await expect(facts.getByRole('link', { name: 'citations' })).toHaveAttribute('href', '/app/skills') - await expect(facts).toContainText('You are a careful research assistant') - await expect(page.getByText('No runs yet')).toBeVisible() - }) - - test('an agent with no memory or skills shows None, not a guess', async ({ page }) => { - await installFakeStream(page) - await mockAgents(page) - await page.goto('/app/agents/code-reviewer') - const facts = page.getByTestId('agent-facts') - await expect(facts.getByText('None')).toHaveCount(3) - await expect(facts.getByRole('link')).toHaveCount(0) - }) - - test('lists runs newest first and opens one', async ({ page }) => { - await installFakeStream(page) - await mockAgents(page) - const t = Date.now() - await seedRuns(page, 'research-assistant', [ - storedRun('research-assistant', { id: 'r_new', task: 'Newest task', startedAt: t - 1000, outcome: 'Wrote the digest.' }), - storedRun('research-assistant', { id: 'r_old', task: 'Older task', startedAt: t - 90000, status: 'failed', error: 'The file is not a valid PDF.' }), - ]) - await page.goto('/app/agents/research-assistant') - const rows = page.getByTestId('agent-runs').locator('a') - await expect(rows).toHaveCount(2) - await expect(rows.first()).toContainText('Newest task') - await expect(rows.last()).toContainText('The file is not a valid PDF.') - await rows.last().click() - await expect(page).toHaveURL(/\/runs\/r_old$/) - await expect(page.getByTestId('run-report')).toBeVisible() - }) - - test('a paused agent cannot start a run and offers Resume', async ({ page }) => { - await installFakeStream(page) - const seen = await mockAgents(page) - await page.goto('/app/agents/daily-digest') - await expect(page.locator('#ag-task-input')).toBeDisabled() - await page.getByTestId('agent-page').getByRole('button', { name: 'Resume', exact: true }).first().click() - await expect.poll(() => seen.resumes).toEqual(['daily-digest']) - }) - - test('an unknown agent says so', async ({ page }) => { - await installFakeStream(page) - await mockAgents(page) - await page.goto('/app/agents/nobody') - await expect(page.getByTestId('agent-missing')).toContainText('No agent named "nobody"') - }) - - test('Clear run record removes this browser\'s runs after a confirmation', async ({ page }) => { - await installFakeStream(page) - await mockAgents(page) - await seedRuns(page, 'research-assistant', [storedRun('research-assistant', { id: 'r_x', task: 'One', startedAt: Date.now() - 1000, outcome: 'ok' })]) - await page.goto('/app/agents/research-assistant') - await expect(page.getByTestId('agent-runs')).toBeVisible() - await page.getByTestId('agent-page').getByRole('button', { name: /More actions/ }).click() - await page.getByRole('menuitem', { name: 'Clear run record' }).click() - await page.getByRole('button', { name: 'Clear', exact: true }).click() - await expect(page.getByText('No runs yet')).toBeVisible() - }) -}) - -test.describe('A run', () => { - test('starts from the task box, shows the thread live and settles into the report', async ({ page }) => { - await installFakeStream(page) - const seen = await mockAgents(page) - await startRun(page, seen) - expect(seen.chats[0]).toMatchObject({ name: 'research-assistant', message: TASK, history: [] }) - - await expect(page.getByTestId('run-task')).toContainText(TASK) - await emit(page, 'stream_event', { type: 'reasoning', content: 'Search first.' }) - await emit(page, 'stream_event', { type: 'done' }) - await emit(page, 'stream_event', { type: 'tool_call', tool_name: 'web_search', tool_args: '{"q":"x"}' }) - await expect(page.getByTestId('run-working')).toContainText('Using web_search') - await emit(page, 'stream_event', { type: 'tool_result', tool_name: 'web_search', tool_result: '8 results' }) - await expect(page.getByTestId('run-fold')).toContainText('1 steps so far') - - await emit(page, 'json_message', { sender: 'agent', content: OUTCOME }) - await expect(page.getByTestId('agent-run')).toHaveAttribute('data-status', 'done') - await expect(page.getByTestId('run-settling')).toContainText('Report ready') - await expect(page.getByTestId('run-report')).toBeVisible({ timeout: 5000 }) - await expect(page.getByRole('heading', { name: 'Outcome' })).toBeVisible() - await expect(page.getByTestId('run-evidence')).toContainText('8 results') - await expect(page.getByRole('heading', { name: 'Steps' })).toBeVisible() - // Wide content opens wider on demand. - const wideBtn = page.locator('.ag-wide__btn').first() - await expect(wideBtn).toHaveText('Open wider') - await wideBtn.click() - await expect(page.locator('.ag-wide[data-wide]')).toHaveCount(1) - await expect(wideBtn).toHaveText('Back to column') - }) - - test('choosing a view stops the page from moving by itself', async ({ page }) => { - await installFakeStream(page) - const seen = await mockAgents(page) - await startRun(page, seen) - await page.getByRole('button', { name: 'Live' }).click() - await emit(page, 'json_message', { sender: 'agent', content: 'Done.' }) - await page.waitForTimeout(2200) - await expect(page.getByTestId('run-live')).toBeVisible() - await expect(page.getByTestId('run-report')).toHaveCount(0) - }) - - test('has its own address that survives a reload', async ({ page }) => { - await installFakeStream(page) - const seen = await mockAgents(page) - await startRun(page, seen) - await emit(page, 'json_message', { sender: 'agent', content: 'The answer.' }) - await expect(page.getByTestId('run-report')).toBeVisible({ timeout: 5000 }) - const url = page.url() - await page.reload() - await expect(page).toHaveURL(url) - await expect(page.getByTestId('run-report')).toContainText('The answer.') - // The reload did not send the task again. - expect(seen.chats.length).toBe(1) - }) - - test('a run from another browser opens a notice, not an error', async ({ page }) => { - await installFakeStream(page) - await mockAgents(page) - await page.goto('/app/agents/research-assistant/runs/r_nope12') - await expect(page.getByTestId('run-missing')).toContainText('not in this browser') - await expect(page.getByRole('link', { name: 'Open research-assistant' }).last()).toBeVisible() - }) - - test('a follow-up sits under the outcome and carries the earlier turns', async ({ page }) => { - await installFakeStream(page) - const seen = await mockAgents(page) - await startRun(page, seen) - await emit(page, 'json_message', { sender: 'agent', content: 'First answer.' }) - await expect(page.getByTestId('run-report')).toBeVisible({ timeout: 5000 }) - await page.getByTestId('run-followup').locator('textarea').fill('Add one more source') - await page.getByRole('button', { name: 'Send' }).click() - await expect.poll(() => seen.chats.length).toBe(2) - expect(seen.chats[1].history).toEqual([ - { role: 'user', content: TASK }, - { role: 'assistant', content: 'First answer.' }, - ]) - await emit(page, 'json_message', { sender: 'agent', content: 'Second answer.' }) - await expect(page.getByTestId('run-report')).toBeVisible({ timeout: 5000 }) - await expect(page.getByTestId('run-followups')).toContainText('Add one more source') - await expect(page.getByTestId('run-followups')).toContainText('Second answer.') - }) - - test('the agent name switches to another agent', async ({ page }) => { - await installFakeStream(page) - await mockAgents(page) - await seedRuns(page, 'research-assistant', [storedRun('research-assistant', { id: 'r_s', task: 'T', startedAt: Date.now() - 1000, outcome: 'ok' })]) - await page.goto('/app/agents/research-assistant/runs/r_s') - await page.getByTestId('agent-switcher').click() - await page.getByRole('menuitem', { name: 'code-reviewer' }).click() - await expect(page).toHaveURL(/\/app\/agents\/code-reviewer$/) - }) - - test('there is no Stop, approval or steer control the API cannot honour', async ({ page }) => { - await installFakeStream(page) - const seen = await mockAgents(page) - await startRun(page, seen) - await expect(page.getByRole('button', { name: /^Stop$/ })).toHaveCount(0) - await expect(page.getByRole('button', { name: /Approve|Allow/ })).toHaveCount(0) - await expect(page.getByTestId('run-dock-note')).toContainText('Follow-ups open when it answers') - }) -}) - -test.describe('A run that fails', () => { - test('a task the server refuses says so in plain words with one next action', async ({ page }) => { - await installFakeStream(page) - const seen = await mockAgents(page, { chatStatus: 500, chatError: 'model is not loaded' }) - await page.goto('/app/agents/research-assistant') - await page.locator('#ag-task-input').fill(TASK) - await page.keyboard.press('Enter') - const failed = page.getByTestId('run-failed') - await expect(failed).toContainText('The task did not reach the agent') - await expect(failed).toContainText('model is not loaded') - await expect(failed.getByRole('button')).toHaveCount(1) - await expect(page.getByTestId('agent-run')).toHaveAttribute('data-status', 'failed') - await failed.getByRole('button', { name: 'Run again' }).click() - await expect.poll(() => seen.chats.length).toBe(2) - await expect(page).toHaveURL(/\/runs\/r_[a-z0-9]{6}$/) - }) - - test('an error the agent reports stays in the live view and is the outcome in the report', async ({ page }) => { - await installFakeStream(page) - const seen = await mockAgents(page) - await startRun(page, seen) - await emit(page, 'json_error', { error: 'A page did not answer' }) - await expect(page.getByTestId('run-failed')).toContainText('The run stopped') - await expect(page.getByTestId('run-live')).toBeVisible() - await page.getByRole('button', { name: 'Report' }).click() - await expect(page.getByTestId('run-report').getByTestId('run-failed')).toContainText('A page did not answer') - }) - - test('a run cut off before it answered reads as stopped', async ({ page }) => { - await installFakeStream(page) - await mockAgents(page) - const started = Date.now() - 20 * 60_000 - await seedRuns(page, 'research-assistant', [storedRun('research-assistant', { id: 'r_cut', task: 'Cut off', startedAt: started, status: 'running' })]) - await page.goto('/app/agents/research-assistant/runs/r_cut') - await expect(page.getByTestId('agent-run')).toHaveAttribute('data-status', 'stopped') - await expect(page.getByTestId('run-report')).toContainText('No answer was recorded') - }) -}) - -test.describe('Status page', () => { - test('is a quiet panel with the raw record and a way back', async ({ page }) => { - await installFakeStream(page) - await mockAgents(page, { observables: { 'research-assistant': [ - { id: 1, name: 'web_search', creation: { function_definition: { name: 'web_search' } }, completion: { action_result: '8 results' } }, - { id: 2, name: 'read_page', completion: { error: 'page did not answer' } }, - ] } }) - await page.goto('/app/agents/research-assistant/status') - const panel = page.getByTestId('agent-status') - await expect(panel.getByRole('heading', { name: 'Status' })).toBeVisible() - await expect(panel.getByTestId('status-observable')).toHaveCount(2) - await panel.getByRole('button', { name: 'Failed', exact: true }).click() - await expect(panel.getByTestId('status-observable')).toHaveCount(1) - await panel.getByRole('link', { name: 'research-assistant' }).click() - await expect(page).toHaveURL(/\/app\/agents\/research-assistant$/) - }) - - test('Clear removes the records through the API', async ({ page }) => { - await installFakeStream(page) - const seen = await mockAgents(page, { observables: { 'research-assistant': [{ id: 1, name: 'x', completion: {} }] } }) - await page.goto('/app/agents/research-assistant/status') - await page.getByRole('button', { name: 'Clear' }).click() - await expect.poll(() => seen.clears).toEqual(['research-assistant']) - }) -}) - -test.describe('Layouts', () => { - async function seeded(page) { - await installFakeStream(page) - await mockAgents(page) - await seedRuns(page, 'research-assistant', [storedRun('research-assistant', { id: 'r_l', task: TASK, startedAt: Date.now() - 4000, outcome: OUTCOME, steps: [{ kind: 'tool', name: 'web_search', args: '{}', result: 'r', ts: Date.now() - 3000 }] })]) - } - const noOverflow = page => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1) - - test('phone: no sideways scroll on the launcher, agent page, report and editor', async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }) - await seeded(page) - for (const path of ['/app/agents', '/app/agents/research-assistant', '/app/agents/research-assistant/runs/r_l', '/app/agents/research-assistant/edit', '/app/agents/research-assistant/status']) { - await page.goto(path) - await page.waitForTimeout(400) - expect(await noOverflow(page), path).toBe(true) - } - }) - - test('phone: the preview opens as a sheet that fits the screen', async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }) - await seeded(page) - await page.goto('/app/agents/research-assistant/edit') - await page.getByTestId('agent-preview-open').click() - await page.waitForTimeout(600) // the sheet rises into place - const box = await page.getByTestId('agent-preview').boundingBox() - expect(box.width).toBeLessThanOrEqual(390) - expect(box.y + box.height).toBeLessThanOrEqual(844 + 1) - }) - - test('2560 wide: the report keeps a reading column and opens a table wider on demand', async ({ page }) => { - await page.setViewportSize({ width: 2560, height: 1200 }) - await seeded(page) - await page.goto('/app/agents/research-assistant/runs/r_l') - const col = page.locator('.ag-doc__in') - const colBox = await col.boundingBox() - expect(colBox.width).toBeLessThanOrEqual(830) - // Centred in the area beside the sidebar. - expect(colBox.x).toBeGreaterThan(500) - await page.locator('.ag-wide__btn').first().click() - const wide = await page.locator('.ag-wide[data-wide]').boundingBox() - expect(wide.width).toBeGreaterThan(colBox.width) - expect(wide.x + wide.width).toBeLessThanOrEqual(2560) - }) - - test('1440: the run page uses the page height and docks the follow-up box', async ({ page }) => { - await page.setViewportSize({ width: 1440, height: 900 }) - await seeded(page) - await page.goto('/app/agents/research-assistant/runs/r_l') - const dock = await page.getByTestId('run-followup').boundingBox() - expect(dock.y + dock.height).toBeGreaterThan(780) - expect(await noOverflow(page)).toBe(true) - }) - - test('reduced motion: the working dot does not animate', async ({ page }) => { - await page.emulateMedia({ reducedMotion: 'reduce' }) - await installFakeStream(page) - await mockAgents(page, { observables: { 'research-assistant': [{ id: 1, name: 'x' }] } }) - await page.goto('/app/agents/research-assistant') - const anim = await page.locator('.ag-state[data-state="running"] .dk-dot').first().evaluate(el => getComputedStyle(el).animationName) - expect(anim).toBe('none') - }) -}) diff --git a/core/http/react-ui/e2e/agents-skills-real.spec.js b/core/http/react-ui/e2e/agents-skills-real.spec.js new file mode 100644 index 000000000..dc1fec773 --- /dev/null +++ b/core/http/react-ui/e2e/agents-skills-real.spec.js @@ -0,0 +1,27 @@ +import { test, expect } from './coverage-fixtures.js' + +// Skills against the real test server: write one on the page, find it in the +// list, and delete it. + +test('a skill written on the page shows in the list and can be deleted', async ({ page }) => { + const name = `checklist-${Date.now()}` + await page.goto('/app/skills') + test.skip(await page.getByTestId('files-off').isVisible(), 'files are off on this server build') + + await page.goto('/app/skills/new') + await page.getByLabel(/^Name/).fill(name) + await page.getByLabel(/^Description/).fill('Go through the release checklist') + await page.locator('.skilledit-sidebar-item', { hasText: 'Content' }).click() + await page.getByLabel('Skill content (markdown)').fill('Tick every box.') + await page.getByRole('button', { name: 'Create skill' }).click() + await expect(page).toHaveURL(/\/app\/skills$/) + + await page.getByTestId(`skill-row-${name}`).click() + await expect(page.getByTestId('skill-title')).toHaveText(name) + await page.getByRole('tab', { name: 'Instructions' }).click() + await expect(page.getByTestId('skill-content')).toContainText('Tick every box.') + + await page.getByRole('button', { name: 'Delete' }).click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click() + await expect(page.getByTestId(`skill-row-${name}`)).toHaveCount(0) +}) diff --git a/core/http/react-ui/e2e/agents-tasks-admin-real.spec.js b/core/http/react-ui/e2e/agents-tasks-admin-real.spec.js new file mode 100644 index 000000000..127982324 --- /dev/null +++ b/core/http/react-ui/e2e/agents-tasks-admin-real.spec.js @@ -0,0 +1,111 @@ +import { test, expect } from './coverage-fixtures.js' +import { readFileSync } from 'node:fs' +import { offerMockModel } from './journey-fixtures.js' + +// The agent files, the starter agents, the agent tasks with their jobs and the +// administrator's view, against the real test server. After each step the +// spec reads the API. + +test.describe.configure({ mode: 'serial', timeout: 90_000 }) + +const api = (request, path) => request.get(path).then(r => r.json()) +const uniq = (p) => `${p}-${Date.now().toString(36)}` + +test('an agent exports to the server file and imports back as a new agent', async ({ page }) => { + const name = uniq('exp') + const made = await (await page.request.post('/api/chats/agents', { data: { name, instructions: 'Be kind', model: 'mock-model' } })).json() + await page.goto('/app/agents') + const row = page.locator('.ag-row').filter({ hasText: name }).first() + await expect(row).toBeVisible() + await row.getByRole('button', { name: /more|actions/i }).first().click() + const [download] = await Promise.all([page.waitForEvent('download'), page.getByRole('menuitem', { name: 'Export' }).click()]) + const file = JSON.parse(readFileSync(await download.path(), 'utf8')) + expect(file.kind).toBe('localai-agent') + expect(file.agent.name).toBe(name) + + await page.locator('input[type=file][accept=".json"]').setInputFiles({ name: 'a.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(file)) }) + await page.waitForURL(/\/app\/agents\/[^/]+$/) + const list = (await api(page.request, '/api/chats/agents')).agents.filter(a => a.instructions === 'Be kind' && a.name.startsWith(name)) + expect(list.length).toBe(2) + const copy = list.find(a => a.id !== made.id) + expect(page.url()).toContain(copy.id) + for (const a of list) await page.request.delete(`/api/chats/agents/${a.id}`) +}) + +test('a starter agent of the server is saved and opened for editing', async ({ page }) => { + const before = (await api(page.request, '/api/chats/agents')).agents + for (const a of before) await page.request.delete(`/api/chats/agents/${a.id}`) + const templates = (await api(page.request, '/api/chats/agents/templates')).templates + expect(templates.length).toBeGreaterThan(0) + await page.goto('/app/agents') + const tpl = page.getByTestId('agent-template').first() + await expect(tpl).toBeVisible() + await expect(tpl).toContainText(templates[0].name) + await tpl.getByRole('button').click() + await page.waitForURL(/\/app\/agents\/[^/]+\/edit$/) + const after = (await api(page.request, '/api/chats/agents')).agents + expect(after.map(a => a.name)).toContain(templates[0].name) + for (const a of after) await page.request.delete(`/api/chats/agents/${a.id}`) +}) + +test('an agent task is created, run with a parameter, and its job is listed with its result', async ({ page }) => { + await offerMockModel(page) + const name = uniq('task') + await page.goto('/app/agents') + await page.getByRole('link', { name: 'Tasks' }).click() + await page.waitForURL(/\/app\/agent-tasks$/) + await page.getByRole('button', { name: 'New task' }).click() + const form = page.getByTestId('agent-task-form') + await form.getByLabel('Name').fill(name) + await form.getByLabel('Model').selectOption('mock-model') + await form.getByLabel('Prompt').fill('Say hello to {{.who}}') + await page.getByRole('button', { name: 'Save task' }).click() + await expect(form).toHaveCount(0) + const task = (await api(page.request, '/api/agent/tasks')).find(t => t.name === name) + expect(task.model).toBe('mock-model') + expect(task.prompt).toBe('Say hello to {{.who}}') + + const row = page.getByTestId('agent-task-row').filter({ hasText: name }) + await row.getByRole('button', { name: `Run ${name}` }).click() + await page.getByTestId('agent-task-run').getByLabel('who').fill('Ada') + await page.getByRole('button', { name: 'Run now' }).click() + await expect.poll(async () => (await api(page.request, `/api/agent/jobs?task_id=${task.id}`)).length).toBe(1) + const job = (await api(page.request, `/api/agent/jobs?task_id=${task.id}`))[0] + expect(job.parameters).toEqual({ who: 'Ada' }) + const jobRow = page.getByTestId('agent-job-row').first() + await expect(jobRow).toBeVisible() + await expect.poll(async () => (await api(page.request, `/api/agent/jobs/${job.id}`)).status, { timeout: 60_000 }).toMatch(/completed|failed/) + await expect(jobRow).toHaveAttribute('data-status', /completed|failed/, { timeout: 10_000 }) + await jobRow.getByRole('button', { name: 'Details' }).click() + await expect(page.getByTestId('agent-job-detail')).toContainText('who') + await expect(page.getByTestId('agent-job-detail')).toContainText('Ada') + await page.screenshot({ path: 'test-results/wiring-tasks.png', fullPage: true }) + + await jobRow.getByRole('button', { name: /again/ }).click() + await expect.poll(async () => (await api(page.request, `/api/agent/jobs?task_id=${task.id}`)).length).toBe(2) + + await row.getByRole('button', { name: /More actions/ }).click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click() + await expect.poll(async () => (await api(page.request, '/api/agent/tasks')).some(t => t.id === task.id)).toBe(false) +}) + +test('the administrator sees every user\'s agents and triggers, switches a trigger off and deletes it', async ({ page }) => { + const agent = await (await page.request.post('/api/chats/agents', { data: { name: uniq('adm'), model: 'mock-model' } })).json() + const trig = await (await page.request.post('/api/chats/triggers', { data: { name: uniq('admtrig'), prompt: 'x', kind: 'schedule', schedule: 'daily 08:00', enabled: true, model: 'mock-model' } })).json() + expect(trig.id).toBeTruthy() + await page.goto('/app/settings') + await page.getByRole('link', { name: 'Chats of all users' }).first().click() + await page.waitForURL(/\/app\/chats-admin$/) + await expect(page.getByTestId('admin-agent-row').filter({ hasText: agent.name })).toBeVisible() + const row = page.getByTestId('admin-trigger-row').filter({ hasText: trig.name }) + await expect(row).toBeVisible() + await page.screenshot({ path: 'test-results/wiring-admin.png', fullPage: true }) + await row.getByRole('button', { name: 'Switch off' }).click() + await expect.poll(async () => (await api(page.request, '/api/chats/admin/triggers')).triggers.find(t => t.id === trig.id).enabled).toBe(false) + await row.getByRole('button', { name: 'Runs' }).click() + await row.getByRole('button', { name: 'Delete' }).click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click() + await expect.poll(async () => (await api(page.request, '/api/chats/admin/triggers')).triggers.some(t => t.id === trig.id)).toBe(false) + await page.request.delete(`/api/chats/agents/${agent.id}`) +}) diff --git a/core/http/react-ui/e2e/agents.spec.js b/core/http/react-ui/e2e/agents.spec.js index e58ea01f6..4f388c4ed 100644 --- a/core/http/react-ui/e2e/agents.spec.js +++ b/core/http/react-ui/e2e/agents.spec.js @@ -1,146 +1,172 @@ import { test, expect } from './coverage-fixtures.js' -import { installFakeStream, mockAgents, seedRuns, storedRun, emit, AGENTS } from './agents-fixtures.js' +import { installAgents, AGENTS } from './agents-fixtures.js' +import { readFile } from 'node:fs/promises' -// Agents launcher (src/pages/Agents.jsx) and the routes around it. -test.describe('Agents page', () => { - test('renders the agents list and empty state', async ({ page }) => { +// The agents list and the page of one agent, with the saved-agents API stubbed. + +test.describe('Agents list', () => { + test('lists the agents with their model, their record and what ran with them', async ({ page }) => { + await installAgents(page) await page.goto('/app/agents') - await expect(page).toHaveURL(/\/app\/agents$/) - await expect(page.getByRole('heading', { name: 'Agents', exact: true })).toBeVisible() - await expect(page.getByText(/No agents configured/i)).toBeVisible() - await expect(page.getByRole('button', { name: 'Create Agent' }).first()).toBeVisible() + + await expect(page.getByRole('heading', { name: 'Agents', level: 1 })).toBeVisible() + const rows = page.getByTestId('agents-list').locator('.ag-row') + await expect(rows).toHaveCount(2) + const research = page.locator('[data-agent="research-assistant"]') + await expect(research).toContainText('Searches the web and writes short briefs.') + await expect(research).toContainText('qwen3-8b') + await expect(research).toContainText('handbook') + await expect(research).toContainText('citations') + // Two chats ran with it: one finished, one failed. + await expect(research.getByTestId('agent-run-strip')).toBeVisible() + await expect(research).toContainText('50% of 2 runs finished') + await expect(page.locator('[data-agent="code-reviewer"]')).toContainText('No runs yet') + // The failed run of the last day is in Now. + await expect(page.getByTestId('agents-now')).toContainText('research-assistant') }) - test('the empty state teaches with starting points', async ({ page }) => { + test('searches by name, description and model', async ({ page }) => { + await installAgents(page) await page.goto('/app/agents') - const empty = page.getByTestId('agents-empty') - await expect(empty.getByRole('link', { name: 'Research assistant' })).toHaveAttribute('href', '/app/agents/new?template=researcher') - await expect(empty.getByRole('link', { name: 'Code reviewer' })).toBeVisible() - }) - - test('keeps Import Agent visible when agents exist', async ({ page }) => { - await page.route('**/api/agents', route => route.fulfill({ - json: { agents: ['existing-agent'], statuses: { 'existing-agent': true } }, - })) - await page.route('**/api/agents/existing-agent/observables', route => route.fulfill({ - json: { History: [] }, - })) - await page.goto('/app/agents') - await expect(page.locator('.header-actions label', { hasText: 'Import' })).toBeVisible() - }) - - test('Create Agent navigates to the agent creation form', async ({ page }) => { - await page.goto('/app/agents') - const create = page.getByRole('button', { name: 'Create Agent' }).last() - await create.scrollIntoViewIfNeeded() - await Promise.all([ - page.waitForURL(/\/app\/agents\/new$/), - create.click(), - ]) - // Wait for AgentCreate.jsx to actually render, not just for the URL to - // change, so the coverage teardown does not race the component mount. - await expect(page.getByRole('heading', { name: 'Create Agent' })).toBeVisible() - }) - - test('opens agent status without leaving an active run', async ({ page }) => { - await mockAgents(page) - await installFakeStream(page) - await seedRuns(page, 'research-assistant', [storedRun('research-assistant', { id: 'r_a1', task: 'Do it', startedAt: Date.now() - 5000, outcome: 'Done.' })], 'test-user') - await page.goto('/app/agents/research-assistant/runs/r_a1?user_id=test-user') - const status = page.getByRole('link', { name: 'Status' }) - await expect(status).toHaveAttribute('href', '/app/agents/research-assistant/status?user_id=test-user') - await expect(status).toHaveAttribute('target', '_blank') - await expect(page).toHaveURL(/\/runs\/r_a1\?user_id=test-user$/) - }) - - test('an old chat link opens the agent page and keeps the user', async ({ page }) => { - await mockAgents(page) - await page.goto('/app/agents/research-assistant/chat?user_id=u7') - await expect(page).toHaveURL(/\/app\/agents\/research-assistant\?user_id=u7$/) - await expect(page.getByTestId('agent-page')).toBeVisible() - }) -}) - -test.describe('Launcher with agents', () => { - const now = Date.now() - const hours = h => now - h * 3600_000 - - async function open(page, opts = {}) { - await installFakeStream(page) - const seen = await mockAgents(page, { - observables: { 'research-assistant': [{ id: 7, name: 'read_page', creation: { function_definition: { name: 'read_page' } } }] }, - ...opts, - }) - const runs = [] - for (let i = 0; i < 16; i++) { - runs.push(storedRun('code-reviewer', { - id: `r_c${i}`, task: `Review ${i}`, startedAt: hours(60 - i), status: i === 3 ? 'failed' : i === 5 ? 'stopped' : 'done', outcome: 'ok', error: i === 3 ? 'boom' : '', - })) - } - await seedRuns(page, 'code-reviewer', runs.reverse()) - await seedRuns(page, 'daily-digest', [storedRun('daily-digest', { id: 'r_d1', task: 'Collect news', startedAt: hours(2), status: 'failed', error: 'A page did not answer.' })]) - await page.goto('/app/agents') - await expect(page.getByTestId('agents-list')).toBeVisible() - return seen - } - - test('shows each agent with its model, attached chips and run strip', async ({ page }) => { - await open(page) - const row = page.locator('[data-agent="research-assistant"]') - await expect(row.getByText('qwen3-14b-instruct')).toBeVisible() - await expect(row.getByRole('link', { name: 'Knowledge base' })).toHaveAttribute('href', '/app/collections') - await expect(row.getByRole('link', { name: 'citations' })).toHaveAttribute('href', '/app/skills') - const strip = page.locator('[data-agent="code-reviewer"] [data-testid="agent-run-strip"]') - await expect(strip.locator('.ag-pip')).toHaveCount(14) - await expect(strip.locator('.ag-pip[data-state="failed"]')).toHaveCount(1) - await expect(strip.locator('.ag-pip[data-state="stopped"]')).toHaveCount(1) - await expect(page.locator('[data-agent="code-reviewer"]')).toContainText('of 16 runs finished') - }) - - test('an agent with no runs says so instead of showing a record', async ({ page }) => { - await open(page) - await expect(page.locator('[data-agent="research-assistant"]')).toContainText('No runs recorded here yet') - }) - - test('the Now strip lists work in flight and a run that failed in the last day', async ({ page }) => { - await open(page) - const now = page.getByTestId('agents-now') - await expect(now).toContainText('research-assistant') - await expect(now).toContainText('Working') - await expect(now).toContainText('daily-digest') - await expect(now).toContainText('Failed') - await expect(now).toContainText('A page did not answer.') - await now.getByRole('link', { name: 'Open' }).last().click() - await expect(page).toHaveURL(/\/app\/agents\/daily-digest\/runs\/r_d1$/) - }) - - test('Run again from the Now strip puts the task in the agent page box', async ({ page }) => { - await open(page) - await page.getByTestId('agents-now').getByRole('link', { name: 'Run again' }).click() - await expect(page).toHaveURL(/\/app\/agents\/daily-digest$/) - await expect(page.locator('#ag-task-input')).toHaveValue('Collect news') - }) - - test('search narrows the list and says when nothing matches', async ({ page }) => { - await open(page) - await page.getByLabel('Search agents...').fill('review') - await expect(page.locator('[data-agent]')).toHaveCount(1) - await page.getByLabel('Search agents...').fill('zzz') + await page.getByLabel('Search agents').fill('reviews a change') + await expect(page.getByTestId('agents-list').locator('.ag-row')).toHaveCount(1) + await expect(page.locator('[data-agent="code-reviewer"]')).toBeVisible() + await page.getByLabel('Search agents').fill('nothing like it') await expect(page.getByText('No matching agents')).toBeVisible() }) - test('the row menu pauses an agent through the API', async ({ page }) => { - const seen = await open(page) - await page.locator('[data-agent="code-reviewer"]').getByRole('button', { name: /More actions/ }).click() - await page.getByRole('menuitem', { name: 'Pause' }).click() - await expect.poll(() => seen.pauses).toEqual(['code-reviewer']) + test('with no agents it offers the starting points and a link to create one', async ({ page }) => { + await installAgents(page, { agents: [] }) + await page.goto('/app/agents') + await expect(page.getByTestId('agents-empty')).toBeVisible() + await page.getByRole('link', { name: 'Research assistant' }).click() + await expect(page).toHaveURL(/\/app\/agents\/new\?template=researcher$/) + await expect(page.getByLabel('Name', { exact: false }).first()).toHaveValue('research-assistant') }) - test('no Now strip when nothing needs a look', async ({ page }) => { - await installFakeStream(page) - await mockAgents(page, { agents: { 'code-reviewer': AGENTS['code-reviewer'] } }) + test('Chat opens the chat page with this agent chosen, and the first message uses it', async ({ page }) => { + const s = await installAgents(page) await page.goto('/app/agents') - await expect(page.getByTestId('agents-list')).toBeVisible() - await expect(page.getByTestId('agents-now')).toHaveCount(0) + await page.locator('[data-agent="code-reviewer"]').getByRole('button', { name: 'Chat' }).click() + await expect(page).toHaveURL(/\/app\/chats$/) + await expect(page.getByTestId('chat-agent-picker')).toContainText('code-reviewer') + await page.getByTestId('chat-input').fill('hello') + await page.getByTestId('chat-send').click() + await expect.poll(() => s.createdChats.length).toBe(1) + expect(s.createdChats[0]).toMatchObject({ agent_id: 'a2' }) + }) + + test('deletes an agent at once with an Undo; Undo brings it back and sends nothing', async ({ page }) => { + // Delete is reversible for the Undo window (8 s), so it asks nothing and + // the server hears about it only when the window closes. + await page.clock.install() + const s = await installAgents(page) + await page.goto('/app/agents') + const del = async () => { + await page.locator('[data-agent="code-reviewer"]').getByRole('button', { name: /More actions/ }).click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + } + await del() + await expect(page.getByRole('alertdialog')).toHaveCount(0) + await expect(page.locator('[data-agent="code-reviewer"]')).toHaveCount(0) + const toast = page.getByTestId('agent-undo-toast') + await expect(toast).toContainText('Deleted “code-reviewer”.') + await toast.getByRole('button', { name: 'Undo' }).click() + await expect(page.locator('[data-agent="code-reviewer"]')).toBeVisible() + await page.clock.runFor(9000) + expect(s.calls.some(c => c.method === 'DELETE')).toBe(false) + + await del() + await expect(page.locator('[data-agent="code-reviewer"]')).toHaveCount(0) + await page.clock.runFor(9000) + await expect.poll(() => s.calls.filter(c => c.method === 'DELETE').length).toBe(1) + await expect(page.locator('[data-agent="code-reviewer"]')).toHaveCount(0) + }) + + test('exports an agent as the file the server writes, without id, dates or secrets', async ({ page }) => { + const s = await installAgents(page) + await page.goto('/app/agents') + await page.locator('[data-agent="research-assistant"]').getByRole('button', { name: /More actions/ }).click() + const download = page.waitForEvent('download') + await page.getByRole('menuitem', { name: 'Export' }).click() + const file = await download + expect(file.suggestedFilename()).toBe('research-assistant.json') + expect(s.calls.some(c => c.method === 'GET' && c.url === '/api/chats/agents/a1/export')).toBe(true) + const body = JSON.parse(await readFile(await file.path(), 'utf8')) + expect(body).toMatchObject({ version: 1, kind: 'localai-agent' }) + expect(body.agent).toMatchObject({ name: 'research-assistant', skills: ['citations'], collections: ['handbook'], addons: ['office'] }) + expect(body.agent.id).toBeUndefined() + expect(body.agent.created).toBeUndefined() + }) + + test('says why the list is unavailable when agents are off', async ({ page }) => { + await installAgents(page) + await page.route(/\/api\/chats\/agents(\?.*)?$/, route => route.fulfill({ status: 409, json: { error: { message: 'Agents are off on this server.', type: 'agents_off', code: 409 } } })) + await page.goto('/app/agents') + await expect(page.getByRole('note')).toContainText('Agents are off on this server.') }) }) + +test.describe('Agent page', () => { + test('shows what the agent is, its triggers and the chats that ran with it', async ({ page }) => { + await installAgents(page) + await page.goto('/app/agents/a1') + + await expect(page.getByRole('heading', { name: 'research-assistant', level: 1 })).toBeVisible() + const facts = page.getByTestId('agent-facts') + await expect(facts).toContainText('qwen3-8b') + await expect(facts).toContainText('office') + await expect(facts).toContainText('mcp: crm') + await expect(facts).toContainText('handbook') + await expect(facts).toContainText('citations') + await expect(page.getByTestId('agent-triggers')).toContainText('Morning digest') + const runs = page.getByTestId('agent-runs') + await expect(runs.locator('.ag-run-row')).toHaveCount(2) + await expect(runs).toContainText('Find sources on speculative decoding') + await expect(runs).toContainText('Started by a trigger') + // A run opens its chat. + await runs.locator('[data-run="c1"]').click() + await expect(page).toHaveURL(/\/app\/chats\/c1$/) + }) + + test('the old address by name finds the agent, and the old status page redirects', async ({ page }) => { + await installAgents(page) + await page.goto('/app/agents/code-reviewer') + await expect(page.getByRole('heading', { name: 'code-reviewer', level: 1 })).toBeVisible() + await page.goto('/app/agents/a2/status') + await expect(page).toHaveURL(/\/app\/agents\/a2$/) + await page.goto('/app/agents/nobody') + await expect(page.getByTestId('agent-missing')).toBeVisible() + }) + + test('Chat opens the chat page with the agent chosen; Edit and Delete work', async ({ page }) => { + const s = await installAgents(page) + await page.goto('/app/agents/a2') + await page.getByTestId('agent-chat').click() + await expect(page).toHaveURL(/\/app\/chats$/) + await expect(page.getByTestId('chat-agent-picker')).toContainText('code-reviewer') + + await page.goto('/app/agents/a2') + await page.getByRole('link', { name: 'Edit' }).click() + await expect(page).toHaveURL(/\/app\/agents\/a2\/edit$/) + + // Delete goes back to the list, where the agent is gone at once and the + // server is asked when the Undo window closes. + await page.clock.install() + await page.goto('/app/agents/a2') + await page.getByRole('button', { name: /More actions/ }).click() + await page.getByRole('menuitem', { name: 'Delete' }).click() + await expect(page).toHaveURL(/\/app\/agents$/) + await expect(page.getByTestId('agent-undo-toast')).toBeVisible() + await expect(page.locator('[data-agent="code-reviewer"]')).toHaveCount(0) + await page.clock.runFor(9000) + await expect.poll(() => s.calls.some(c => c.method === 'DELETE' && c.url === '/api/chats/agents/a2')).toBe(true) + }) +}) + +test('the old addresses of the agents page redirect', async ({ page }) => { + await installAgents(page) + await page.goto('/app/chats/agents') + await expect(page).toHaveURL(/\/app\/agents$/) + expect(AGENTS.length).toBe(2) +}) diff --git a/core/http/react-ui/e2e/all-chats.spec.js b/core/http/react-ui/e2e/all-chats.spec.js new file mode 100644 index 000000000..115a6e421 --- /dev/null +++ b/core/http/react-ui/e2e/all-chats.spec.js @@ -0,0 +1,115 @@ +import { test, expect } from './coverage-fixtures.js' + +// Home is the front door: the sidebar has no Chats entry, it lists the recent +// chats with New chat and All chats, and All chats holds the history. Against +// the real test server; the chats are made through the API, with a title that +// no other spec uses. + +async function makeChat(request, title) { + const r = await request.post('/api/chats', { data: { model: 'mock-model' } }) + expect(r.ok()).toBeTruthy() + const { id } = await r.json() + expect((await request.patch(`/api/chats/${id}`, { data: { title } })).ok()).toBeTruthy() + return id +} + +const unique = (word) => `${word} ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` + +test.describe('Recent chats in the sidebar', () => { + test('a new chat is listed, a row opens its transcript and New chat opens Home', async ({ page, request }) => { + const title = unique('Sidebar recent') + const id = await makeChat(request, title) + await page.goto('/app') + const recent = page.getByTestId('sidebar-chats') + const row = recent.getByTestId('sidebar-chat').filter({ hasText: title }) + await expect(row).toBeVisible() + await row.click() + await expect(page).toHaveURL(new RegExp(`/app/chats/${id}$`)) + await expect(row).toHaveAttribute('aria-current', 'page') + await recent.getByRole('button', { name: 'New chat' }).click() + await expect(page).toHaveURL(/\/app$/) + await expect(page.locator('.home-page')).toBeVisible() + }) + + test('the collapsed rail hides the rows and keeps the All chats icon', async ({ page, request }) => { + await makeChat(request, unique('Rail')) + await page.addInitScript(() => localStorage.setItem('localai_sidebar_collapsed', 'true')) + await page.goto('/app') + const all = page.getByTestId('sidebar-all-chats') + await expect(all).toBeVisible() + await expect(all).toHaveAttribute('title', 'All chats') + await expect(page.getByTestId('sidebar-chat').first()).toBeHidden() + await all.click() + await expect(page).toHaveURL(/\/app\/chats\/all$/) + }) + + test('the phone drawer lists the recent chats and closes on a click', async ({ page, request }) => { + const title = unique('Drawer') + await makeChat(request, title) + await page.setViewportSize({ width: 390, height: 844 }) + await page.goto('/app') + await page.getByRole('button', { name: 'Open menu' }).click() + const row = page.getByTestId('sidebar-chat').filter({ hasText: title }) + await expect(row).toBeVisible() + await row.click() + await expect(page).toHaveURL(/\/app\/chats\/[^/]+$/) + await expect(page.locator('.sidebar.open')).toHaveCount(0) + }) +}) + +test.describe('All chats', () => { + test('Home links to it, search finds a chat by name and a row opens it', async ({ page, request }) => { + const title = unique('Findable') + const id = await makeChat(request, title) + await page.goto('/app') + await page.getByTestId('home-all-chats').first().click() + await expect(page).toHaveURL(/\/app\/chats\/all$/) + await expect(page.getByRole('heading', { name: 'All chats', level: 1 })).toBeVisible() + const list = page.getByTestId('all-chats-list') + await list.getByRole('combobox', { name: 'Search conversations...' }).fill(title) + await expect(list.getByTestId('chats-row')).toHaveCount(1) + await list.getByTestId('chats-row').click() + await expect(page).toHaveURL(new RegExp(`/app/chats/${id}$`)) + }) + + test('a delete hides the row and offers Undo; the server deletes it when the offer ends', async ({ page, request }) => { + const title = unique('Delete me') + const id = await makeChat(request, title) + await page.goto('/app/chats/all') + const row = page.getByTestId('all-chats-list').getByTestId('chats-row').filter({ hasText: title }) + await row.hover() + await row.getByRole('button', { name: 'Delete chat' }).click() + await expect(row).toHaveCount(0) + // The sidebar leaves it out too while the offer runs. + await expect(page.getByTestId('sidebar-chat').filter({ hasText: title })).toHaveCount(0) + const toast = page.getByTestId('chat-undo-toast') + await expect(toast).toContainText(title) + await toast.getByRole('button', { name: 'Undo' }).click() + await expect(row).toBeVisible() + expect((await request.get(`/api/chats/${id}`)).status()).toBe(200) + + await row.hover() + await row.getByRole('button', { name: 'Delete chat' }).click() + await page.getByTestId('chat-undo-toast').getByRole('button', { name: 'Dismiss' }).click() + await expect.poll(async () => (await request.get(`/api/chats/${id}`)).status()).toBe(404) + }) + + test('rename from the list changes the title on the server', async ({ page, request }) => { + const title = unique('Old name') + const id = await makeChat(request, title) + await page.goto('/app/chats/all') + const row = page.getByTestId('all-chats-list').getByTestId('chats-row').filter({ hasText: title }) + await row.hover() + await row.getByRole('button', { name: 'Rename' }).click() + const box = page.getByTestId('all-chats-list').getByRole('textbox', { name: 'Rename' }) + await box.fill(`${title} renamed`) + await box.press('Enter') + await expect.poll(async () => (await (await request.get(`/api/chats/${id}`)).json()).title).toBe(`${title} renamed`) + }) +}) + +test('/app/chats as an address opens Home', async ({ page }) => { + await page.goto('/app/chats') + await expect(page).toHaveURL(/\/app$/) + await expect(page.locator('.home-page')).toBeVisible() +}) diff --git a/core/http/react-ui/e2e/backends-installed-node-removal.spec.js b/core/http/react-ui/e2e/backends-installed-node-removal.spec.js index bb04dcd84..dbcedacf6 100644 --- a/core/http/react-ui/e2e/backends-installed-node-removal.spec.js +++ b/core/http/react-ui/e2e/backends-installed-node-removal.spec.js @@ -185,10 +185,12 @@ test('catalog target uses the same confirmed exact-node flow', async ({ page }) test('cold installed failure is not empty success, and retry recovers', async ({ page }) => { const s = await setup(page, { failRefresh: true }) await page.goto('/app/backends?view=installed&show_all=1&development=1') - await expect(page.getByRole('button', { name: 'Retry backend refresh' })).toBeVisible() + // A list that never loaded is the page's load error, the same as the catalog's. + const loadError = page.getByTestId('load-error') + await expect(loadError).toContainText('Could not load the installed backends') await expect(page.getByText('No backends installed', { exact: true })).toHaveCount(0) s.failRefresh = false - await page.getByRole('button', { name: 'Retry backend refresh' }).click() + await loadError.getByRole('button', { name: 'Try again' }).click() await expect(page.locator(`[data-entity="${BUILD}"]`)).toBeVisible() }) @@ -227,7 +229,11 @@ for (const initial of [true, false]) test(`unsuccessful ${initial ? 'initial' : s.failRefresh = true await page.route('**/api/operations', route => route.fulfill({ json: { operations: [{ name: SIBLING, jobID: 'synthetic-job', isQueued: true }] } })) } - const retry = page.getByRole('button', { name: 'Retry backend refresh' }) + // A list that never loaded shows the load error; a list on screen keeps its + // rows and offers the refresh. + const retry = initial + ? page.getByTestId('load-error').getByRole('button', { name: 'Try again' }) + : page.getByRole('button', { name: 'Retry backend refresh' }) await expect(retry).toBeVisible({ timeout: 15000 }) const before = s.installedCalls await retry.click() @@ -322,7 +328,10 @@ const clearTarget = page => page.evaluate(() => { history.pushState({}, '', url) dispatchEvent(new PopStateEvent('popstate')) }) -const genericRemove = page => page.locator('.bk-remove') +// Generic catalog removal is "Delete backend" in the row menu, so the menu is +// opened before the item is checked or clicked. +const genericRemove = page => page.getByRole('menuitem', { name: 'Delete backend' }) +const openRowMenu = page => page.locator(`[data-entity="${BUILD}"]`).getByRole('button', { name: `Actions for ${BUILD}` }).click() // A synthetic click must not bypass the disabled control. const dispatchClick = locator => locator.evaluate(el => el.dispatchEvent(new MouseEvent('click', { bubbles: true }))) @@ -339,6 +348,7 @@ test('generic removal stays blocked during scoped postdelete refresh after targe await expect(page.getByRole('alertdialog')).toBeHidden() await expect.poll(() => s.installedCalls).toBeGreaterThan(1) await clearTarget(page) + await openRowMenu(page) await expect(genericRemove(page)).toBeDisabled() await dispatchClick(genericRemove(page)) await expect(page.getByRole('alertdialog')).toBeHidden() @@ -360,6 +370,7 @@ test('generic removal rechecks scoped conflict after dependency lookup', async ( }) await catalogTarget(page) await clearTarget(page) + await openRowMenu(page) await genericRemove(page).click() await expect.poll(() => capabilitiesStarted).toBe(true) // The generic lookup is pending, so a scoped confirmation can start first. @@ -385,7 +396,7 @@ test('generic confirmation rechecks current row conflict', async ({ page }) => { globalDeletes.push(route.request().url()) return route.fulfill({ json: { message: 'removed' } }) }) - await catalogTarget(page); await clearTarget(page); await genericRemove(page).click() + await catalogTarget(page); await clearTarget(page); await openRowMenu(page); await genericRemove(page).click() await expect(page.getByRole('alertdialog')).toBeVisible() await page.route('**/api/operations', route => route.fulfill({ json: { operations: [{ name: BUILD, jobID: 'synthetic-job', isQueued: true }] } })) await expect(page.locator(`[data-entity="${BUILD}"]`)).toContainText('Queued', { timeout: 15000 }) @@ -405,7 +416,7 @@ for (const beforeConfirm of [false, true]) test(`generic deletion blocks targete }) await catalogTarget(page) if (beforeConfirm) { - await clearTarget(page); await genericRemove(page).click() + await clearTarget(page); await openRowMenu(page); await genericRemove(page).click() await expect(page.getByRole('alertdialog')).toBeVisible() // Restore the target while the existing generic confirmation remains open. await page.evaluate(id => { @@ -425,6 +436,7 @@ for (const beforeConfirm of [false, true]) test(`generic deletion blocks targete await expect(page.getByRole('alertdialog')).toBeHidden() } else { await clearTarget(page) + await openRowMenu(page) await expect(genericRemove(page)).toBeDisabled() await dispatchClick(genericRemove(page)) await expect(page.getByRole('alertdialog')).toBeHidden() diff --git a/core/http/react-ui/e2e/backends-lifecycle.spec.js b/core/http/react-ui/e2e/backends-lifecycle.spec.js index 7bdb5b720..f7d8b2733 100644 --- a/core/http/react-ui/e2e/backends-lifecycle.spec.js +++ b/core/http/react-ui/e2e/backends-lifecycle.spec.js @@ -107,7 +107,9 @@ test.describe('Backends lifecycle page', () => { await page.getByRole('textbox', { name: /search installed backends/i }).fill('llama') await expect(page).toHaveURL(/[?&]q=llama(?:&|$)/) - await page.getByRole('tab', { name: /user/i }).click() + // The state filter is in the Filters menu. + await page.getByTestId('backends-filters').click() + await page.getByRole('radio', { name: /user/i }).check() await expect(page).toHaveURL(/[?&]state=user(?:&|$)/) }) @@ -116,7 +118,11 @@ test.describe('Backends lifecycle page', () => { await expect(page.getByRole('link', { name: 'Installed', exact: true })).toHaveAttribute('aria-current', 'page') await expect(page.getByRole('textbox', { name: /search installed backends/i })).toHaveValue('llama') - await expect(page.getByRole('tab', { name: /updates/i })).toHaveAttribute('aria-selected', 'true') + // The state from the URL shows as an active filter and is the checked option. + await expect(page.getByRole('button', { name: 'Remove filter: Updates' })).toBeVisible() + await page.getByTestId('backends-filters').click() + await expect(page.getByRole('radio', { name: /updates/i })).toBeChecked() + await page.keyboard.press('Escape') // The open row is the one in the URL. await expect(backendRow(page, 'llama-cpp')).toHaveAttribute('data-selected', 'true') await expect(page.locator('[data-testid="backends-installed-pane"]')).toContainText('v1.1.0') @@ -143,7 +149,8 @@ test.describe('Backends lifecycle page', () => { }) await page.goto('/app/backends?view=installed&backend=llama-cpp') - await expect(page.getByRole('button', { name: /update to v1\.1\.0/i })).toBeVisible() + // Update is the row's action (the open row no longer repeats it). + await expect(backendRow(page, 'llama-cpp').getByRole('button', { name: 'Update llama-cpp' })).toBeVisible() await page.getByRole('button', { name: 'Actions for llama-cpp', exact: true }).click() const reinstall = page.getByRole('menuitem', { name: 'Reinstall backend' }) await expect(reinstall).toBeVisible() @@ -154,8 +161,12 @@ test.describe('Backends lifecycle page', () => { test('keeps Reinstall beside Upgrade for an installed backend in Catalog', async ({ page }) => { await page.goto('/app/backends?view=catalog&backend=llama-cpp') - await expect(page.locator('button[title^="Update to"]')).toBeVisible() - await expect(page.locator('button[title="Reinstall"]')).toBeVisible() + // The same row actions as in Installed: Update in the row, Reinstall and + // Delete in its menu. + await expect(backendRow(page, 'llama-cpp').getByRole('button', { name: 'Update llama-cpp' })).toBeVisible() + await page.getByRole('button', { name: 'Actions for llama-cpp', exact: true }).click() + await expect(page.getByRole('menuitem', { name: 'Reinstall backend' })).toBeVisible() + await expect(page.getByRole('menuitem', { name: 'Delete backend' })).toBeVisible() }) test('shows a backend action failure inline with the selected backend', async ({ page }) => { @@ -172,17 +183,17 @@ test.describe('Backends lifecycle page', () => { await expect(detail.getByRole('alert')).toContainText('registry unavailable') }) - test('shows an Upgrade All failure on its row in the review sheet', async ({ page }) => { + test('shows Upgrade All failures in the rendered global inline error', async ({ page }) => { await page.route('**/api/backends/upgrade/llama-cpp', route => route.fulfill({ status: 500, json: { error: 'upgrade registry unavailable' }, })) await page.goto('/app/backends?view=installed') - await page.getByRole('button', { name: /^updates \(1\)/i }).click() - await page.getByTestId('update-review-all').click() + await page.getByTestId('backends-updates').getByRole('button', { name: 'Review' }).click() + await page.getByTestId('bulk-upgrade-start').click() - await expect(page.getByTestId('update-review').getByRole('alert')).toContainText('upgrade registry unavailable') + await expect(page.getByRole('alert')).toContainText('upgrade registry unavailable') }) test('continues Upgrade All after an earlier backend fails', async ({ page }) => { @@ -207,10 +218,10 @@ test.describe('Backends lifecycle page', () => { }) await page.goto('/app/backends?view=installed') - await page.getByRole('button', { name: /^updates \(2\)/i }).click() - await page.getByTestId('update-review-all').click() + await page.getByTestId('backends-updates').getByRole('button', { name: 'Review' }).click() + await page.getByTestId('bulk-upgrade-start').click() - await expect(page.getByTestId('update-review').getByRole('alert')).toContainText('first registry unavailable') + await expect(page.getByRole('alert')).toContainText('first registry unavailable') await expect.poll(() => laterUpgradeRequests).toBe(1) }) @@ -264,8 +275,11 @@ test.describe('Backends lifecycle page', () => { await expect(backendRow(page, 'llama-cpp-cuda12')).toHaveCount(0) await expect(backendRow(page, 'llama-cpp-development')).toHaveCount(0) - await page.getByText(/variants \(1\)/i).click() - await page.getByText(/development \(1\)/i).click() + // Both are in the Filters menu, each with the number it hides. + await page.getByTestId('backends-filters').click() + await expect(page.getByRole('checkbox', { name: /variant builds 1/i })).not.toBeChecked() + await page.getByRole('checkbox', { name: /variant builds/i }).check() + await page.getByRole('checkbox', { name: /development builds 1/i }).check() await expect(backendRow(page, 'llama-cpp-cuda12')).toBeVisible() await expect(backendRow(page, 'llama-cpp-development')).toBeVisible() }) diff --git a/core/http/react-ui/e2e/backends-management.spec.js b/core/http/react-ui/e2e/backends-management.spec.js index 16c9362da..88bd4fb80 100644 --- a/core/http/react-ui/e2e/backends-management.spec.js +++ b/core/http/react-ui/e2e/backends-management.spec.js @@ -10,12 +10,17 @@ test.describe('Backends management page', () => { await page.goto('/app/backends') }) - test('renders the management header and gallery tabs', async ({ page }) => { + test('renders the management header, the More menu and the type filters', async ({ page }) => { await expect(page).toHaveURL(/\/app\/backends$/) await expect(page.getByRole('heading', { name: 'Backend Management' })).toBeVisible() - await expect(page.getByRole('button', { name: 'From URL' })).toBeVisible() - await expect(page.getByRole('button').filter({ hasText: /^All$/ })).toBeVisible() - await expect(page.getByRole('button').filter({ hasText: /^Image$/ })).toBeVisible() + // Install from a URL is a rare action: it sits in the More menu. + await page.getByRole('button', { name: 'More backend actions' }).click() + await expect(page.getByRole('menuitem', { name: 'Install from URL…' })).toBeVisible() + await page.keyboard.press('Escape') + // The type filters are in the Filters menu. + await page.getByTestId('backends-filters').click() + await expect(page.getByRole('radio', { name: 'All', exact: true })).toBeVisible() + await expect(page.getByRole('radio', { name: 'Image', exact: true })).toBeVisible() }) test('search field accepts input', async ({ page }) => { @@ -25,8 +30,9 @@ test.describe('Backends management page', () => { await expect(search).toHaveValue('whisper') }) - test('From URL reveals the OCI install form', async ({ page }) => { - await page.getByRole('button', { name: 'From URL' }).click() + test('Install from URL opens the OCI install form', async ({ page }) => { + await page.getByRole('button', { name: 'More backend actions' }).click() + await page.getByRole('menuitem', { name: 'Install from URL…' }).click() await expect(page.getByPlaceholder('oci://quay.io/example/backend:latest')).toBeVisible() }) }) @@ -138,9 +144,11 @@ test.describe('Backends gallery - list', () => { await expect(page.getByTestId('backend-detail')).toContainText('transcript') }) - test('a filter chip narrows the list', async ({ page }) => { - await page.getByRole('button', { name: 'Image', exact: true }).click() + test('a type filter narrows the list and shows as a removable chip', async ({ page }) => { + await page.getByTestId('backends-filters').click() + await page.getByRole('radio', { name: 'Image', exact: true }).check() await expect(page).toHaveURL(/[?&]state=image/) + await expect(page.getByRole('button', { name: 'Remove filter: Image' })).toBeVisible() await expect(row(page, 'diffusers')).toBeVisible() await expect(row(page, 'llama-cpp')).toHaveCount(0) }) @@ -151,3 +159,51 @@ test.describe('Backends gallery - list', () => { await expect(row(page, 'diffusers')).toContainText('Not installed') }) }) + +// A filtered list that shows nothing offers Clear filters in both views, and +// the button brings the full list back. +test.describe('Backends - Clear filters', () => { + test.beforeEach(async ({ page }) => { + await page.route('**/api/backends*', (route) => { + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + backends: [ + { name: 'llama-cpp', description: 'GGUF inference', installed: true, version: '1.52.0', tags: ['chat'] }, + { name: 'whisper', description: 'Speech to text', installed: true, version: '1.8.2', tags: ['transcript'] }, + ], + }), + }) + }) + // The installed list is GET /backends; the page itself is /app/backends. + await page.route((url) => url.pathname === '/backends', (route) => { + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify([ + { Name: 'llama-cpp', Metadata: { version: '1.52.0' } }, + { Name: 'whisper', Metadata: { version: '1.8.2' } }, + ]), + }) + }) + }) + + test('the Catalog with a filter that matches nothing clears it', async ({ page }) => { + await page.goto('/app/backends?state=video') + const clear = page.getByTestId('backends-clear-filters') + await expect(clear).toBeVisible({ timeout: 10_000 }) + await expect(row(page, 'llama-cpp')).toHaveCount(0) + await clear.click() + await expect(row(page, 'llama-cpp')).toBeVisible() + await expect(row(page, 'whisper')).toBeVisible() + await expect(page).not.toHaveURL(/state=/) + }) + + test('the Installed view with a search that matches nothing clears it', async ({ page }) => { + await page.goto('/app/backends?view=installed&q=no-such-backend') + const clear = page.getByTestId('backends-clear-filters') + await expect(clear).toBeVisible({ timeout: 10_000 }) + await clear.click() + await expect(row(page, 'llama-cpp')).toBeVisible() + await expect(page).not.toHaveURL(/q=/) + }) +}) diff --git a/core/http/react-ui/e2e/backends-notice.spec.js b/core/http/react-ui/e2e/backends-notice.spec.js index d9567c383..24b5e5521 100644 --- a/core/http/react-ui/e2e/backends-notice.spec.js +++ b/core/http/react-ui/e2e/backends-notice.spec.js @@ -1,18 +1,20 @@ import { test, expect } from './coverage-fixtures.js' import { UPGRADE_LLAMA, mockOperate } from './operate-fixtures.js' -// An update is said where it can be acted on: in the row's state, in the one -// primary button above the list and on the tab. It is not a banner. A filled +// An update is said where it can be acted on: in the row's state, in the +// update line above the list and on the tab. It is not a banner. A filled // panel makes every notice shout at the weight of an error, and a coloured // rail on its edge is decoration that repeats on every item. -test('an update is a row state and a header button, not a banner or a rail', async ({ page }) => { +test('an update is a row state and a line above the list, not a banner or a rail', async ({ page }) => { await mockOperate(page, { upgrades: UPGRADE_LLAMA }) await page.goto('/app/backends?view=installed') const row = page.locator('[data-entity="llama-cpp"]') await expect(row).toContainText('Update 0.9.7') - await expect(page.getByRole('button', { name: 'Updates (1)' })).toBeVisible() + const updates = page.getByTestId('backends-updates') + await expect(updates).toContainText('1 update available') + await expect(updates.getByRole('button', { name: 'Review' })).toBeVisible() await expect(page.locator('.dk-hubtabs [data-hub-tab="runtime"] .dk-hubtab-attn')).toContainText('1') // Nothing here is a filled card with an edge. diff --git a/core/http/react-ui/e2e/backends-runtime.spec.js b/core/http/react-ui/e2e/backends-runtime.spec.js index 296bc1177..7140ad46c 100644 --- a/core/http/react-ui/e2e/backends-runtime.spec.js +++ b/core/http/react-ui/e2e/backends-runtime.spec.js @@ -8,6 +8,8 @@ import { // into the rest. const row = (page, name) => page.locator(`[data-entity="${name}"]`) +// Rare actions (check for updates, install from a URL) are in the More menu. +const openMore = page => page.getByRole('button', { name: 'More backend actions' }).click() const jsonOk = route => route.fulfill({ json: { status: 'ok' } }) const VLLM_INSTALLING = { ...RUNNING_OP, name: 'vllm', id: 'vllm', fullName: 'localai@vllm', jobID: 'job-vllm', progress: 6 } @@ -109,19 +111,7 @@ test.describe('Backend install progress', () => { }) test.describe('Backend updates', () => { - test('Update on the row starts that update only', async ({ page }) => { - await mockOperate(page, { upgrades: UPGRADE_LLAMA }) - const calls = [] - await page.route('**/api/backends/upgrade/llama-cpp', route => { calls.push('llama-cpp'); return jsonOk(route) }) - await page.goto('/app/backends?view=installed') - await row(page, 'llama-cpp').getByRole('button', { name: 'Update llama-cpp' }).click() - // The row button opens the review sheet; nothing starts until it is confirmed. - expect(calls).toEqual([]) - await page.getByTestId('update-review-selected').click() - await expect.poll(() => calls).toEqual(['llama-cpp']) - }) - - test('Updates reviews every update and starts them on confirm', async ({ page }) => { + test('Update on the row opens the dialog with that update only, and starts it on confirm', async ({ page }) => { await mockOperate(page, { upgrades: { ...UPGRADE_LLAMA, @@ -131,9 +121,29 @@ test.describe('Backend updates', () => { const calls = [] await page.route('**/api/backends/upgrade/*', route => { calls.push(new URL(route.request().url()).pathname.split('/').pop()); return jsonOk(route) }) await page.goto('/app/backends?view=installed') - await page.getByRole('button', { name: 'Updates (2)' }).click() + await row(page, 'llama-cpp').getByRole('button', { name: 'Update llama-cpp' }).click() + // The row button opens the dialog; nothing starts until it is confirmed. + const dialog = page.getByTestId('bulk-upgrade-dialog') + await expect(dialog.locator('[data-item="llama-cpp"]').getByRole('checkbox')).toBeChecked() + await expect(dialog.locator('[data-item="whisper"]').getByRole('checkbox')).not.toBeChecked() expect(calls).toEqual([]) - await page.getByTestId('update-review-all').click() + await dialog.getByTestId('bulk-upgrade-start').click() + await expect.poll(() => calls).toEqual(['llama-cpp']) + }) + + test('Update all starts every update and counts them', async ({ page }) => { + await mockOperate(page, { + upgrades: { + ...UPGRADE_LLAMA, + whisper: { backend_name: 'whisper', installed_version: '1.7.2', available_version: '1.8.0' }, + }, + }) + const calls = [] + await page.route('**/api/backends/upgrade/*', route => { calls.push(new URL(route.request().url()).pathname.split('/').pop()); return jsonOk(route) }) + await page.goto('/app/backends?view=installed') + await expect(page.getByTestId('backends-updates')).toContainText('2 updates available') + await page.getByTestId('backends-updates').getByRole('button', { name: 'Review' }).click() + await page.getByTestId('bulk-upgrade-start').click() await expect.poll(() => calls.sort()).toEqual(['llama-cpp', 'whisper']) }) @@ -142,16 +152,19 @@ test.describe('Backend updates', () => { let checks = 0 await page.route('**/api/backends/upgrades/check', route => { checks += 1; return route.fulfill({ json: UPGRADE_LLAMA }) }) await page.goto('/app/backends?view=installed') - await page.getByRole('button', { name: 'Check for updates' }).click() + await openMore(page) + await page.getByRole('menuitem', { name: 'Check for updates' }).click() await expect.poll(() => checks).toBe(1) - await expect(page.getByRole('button', { name: 'Updates (1)' })).toBeVisible() + await expect(page.getByTestId('backends-updates')).toContainText('1 update available') + await expect(page.getByTestId('backends-updates').getByRole('button', { name: 'Review' })).toBeVisible() }) test('says when every backend is current', async ({ page }) => { await mockOperate(page) await page.route('**/api/backends/upgrades/check', route => route.fulfill({ json: {} })) await page.goto('/app/backends?view=installed') - await page.getByRole('button', { name: 'Check for updates' }).click() + await openMore(page) + await page.getByRole('menuitem', { name: 'Check for updates' }).click() await expect(page.getByText('Every installed backend is current')).toBeVisible() }) }) @@ -217,7 +230,8 @@ test.describe('Install from URL and the catalog', () => { const bodies = [] await page.route('**/api/backends/install-external', route => { bodies.push(route.request().postDataJSON()); return jsonOk(route) }) await page.goto('/app/backends') - await page.getByRole('button', { name: 'From URL' }).click() + await openMore(page) + await page.getByRole('menuitem', { name: 'Install from URL…' }).click() await page.getByLabel('OCI image, URL or path').fill('oci://quay.io/example/backend:latest') await page.getByLabel('Name (required for an OCI image)').fill('my-backend') await page.getByLabel('Alias (optional)').fill('mine') @@ -228,7 +242,8 @@ test.describe('Install from URL and the catalog', () => { test('asks for an address before posting anything', async ({ page }) => { await mockOperate(page) await page.goto('/app/backends') - await page.getByRole('button', { name: 'From URL' }).click() + await openMore(page) + await page.getByRole('menuitem', { name: 'Install from URL…' }).click() await page.getByRole('button', { name: 'Install', exact: true }).click() await expect(page.getByRole('alert')).toContainText('Enter an image, URL or path') }) diff --git a/core/http/react-ui/e2e/build-landing.spec.js b/core/http/react-ui/e2e/build-landing.spec.js deleted file mode 100644 index a138d4797..000000000 --- a/core/http/react-ui/e2e/build-landing.spec.js +++ /dev/null @@ -1,162 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { CPU_HOST, GPU_HOST, ftJob, mockFineTune, mockMachine, mockQuantize, qzJob } from './tools-fixtures.js' - -// The Build landing says what each tool is for and what it needs from this -// machine. The needs come from the machine's own figures and the installed -// backends, so each spec stubs those and reads the card. - -const tool = (page, id) => page.locator(`[data-testid="build-landing"] [data-tool="${id}"]`) - -async function setup(page, { host = GPU_HOST, ft = {}, qz = {}, features } = {}) { - await mockMachine(page, { host, ...(features ? { features } : {}) }) - await mockFineTune(page, { jobs: [], ...ft }) - await mockQuantize(page, { jobs: [], ...qz }) -} - -test.describe('Build landing', () => { - test('lists the tools in three groups with a sentence each', async ({ page }) => { - await setup(page) - await page.goto('/app/build') - const landing = page.getByTestId('build-landing') - await expect(landing.getByRole('heading', { name: 'Make a model' })).toBeVisible() - // The Recognise group went with the Faces and Voices tabs: those are add-ons now. - await expect(landing.getByRole('heading', { name: 'Recognise' })).toHaveCount(0) - await expect(landing.getByRole('heading', { name: 'Automate' })).toBeVisible() - await expect(tool(page, 'import')).toContainText('Bring in a model from Hugging Face') - await expect(tool(page, 'quantize')).toContainText('Make a model smaller') - await expect(tool(page, 'fine-tune')).toContainText('Teach a model your own data') - await expect(tool(page, 'agents')).toContainText('Create and run agents') - // The flow strip names the make-a-model steps in order. - await expect(page.getByRole('list', { name: /path from a model/i }).locator('li')).toHaveText(['Import', 'Quantize', 'Fine-Tune', 'Chat']) - }) - - test('every tool opens its page', async ({ page }) => { - await setup(page) - await page.goto('/app/build') - await expect(tool(page, 'fine-tune').getByRole('link', { name: 'Open Fine-Tune' })).toHaveAttribute('href', '/app/fine-tune') - await expect(tool(page, 'quantize').getByRole('link', { name: /Open Quantize/ })).toHaveAttribute('href', '/app/quantize') - await expect(tool(page, 'import').getByRole('link', { name: /Open Import/ })).toHaveAttribute('href', '/app/import-model') - }) - - test('a machine with a GPU and both backends shows ready tools and the figures', async ({ page }) => { - await setup(page) - await page.goto('/app/build') - const machine = page.getByTestId('build-machine') - await expect(machine).toContainText('RTX 4070') - await expect(machine).toContainText('9.8 GB of 12 GB free') - await expect(machine).toContainText('118 GB') - await expect(machine).toContainText('llama-cpp-quantization') - await expect(machine).toContainText('trl') - for (const id of ['import', 'quantize', 'fine-tune']) { - await expect(tool(page, id)).toHaveAttribute('data-state', 'ready') - await expect(tool(page, id).locator('.dk-badge--ok')).toHaveText('Ready') - } - await expect(tool(page, 'fine-tune')).toContainText('GPU memory: RTX 4070, 9.8 GB of 12 GB free') - await expect(tool(page, 'quantize')).toContainText('Runs on the CPU') - await expect(tool(page, 'import')).toContainText('118 GB free on the models disk') - }) - - test('no backend installed: the tool says why and an admin gets an Install link', async ({ page }) => { - await setup(page, { host: CPU_HOST, ft: { backends: [] }, qz: { backends: [] } }) - await page.goto('/app/build') - for (const id of ['quantize', 'fine-tune']) { - await expect(tool(page, id)).toHaveAttribute('data-state', 'needs-backend') - await expect(tool(page, id)).toContainText('Backend needed') - await expect(tool(page, id).getByRole('link', { name: 'Install' })).toHaveAttribute('href', '/app/backends') - } - await expect(tool(page, 'quantize')).toContainText('The quantization backend is not installed') - await expect(tool(page, 'fine-tune')).toContainText('No fine-tuning backend is installed') - await expect(tool(page, 'import')).toHaveAttribute('data-state', 'ready') - await expect(page.getByTestId('build-machine')).toContainText('none installed for these tools') - }) - - test('no GPU is a warning on Fine-tune, not a block', async ({ page }) => { - await setup(page, { host: CPU_HOST }) - await page.goto('/app/build') - await expect(page.getByTestId('build-machine')).toContainText('No GPU found') - await expect(tool(page, 'fine-tune')).toHaveAttribute('data-state', 'ready') - await expect(tool(page, 'fine-tune').locator('[data-tone="warn"]')).toContainText('No GPU found. Training runs on the CPU') - }) - - test('a running job is surfaced above the list and opens its page', async ({ page }) => { - await setup(page, { qz: { jobs: [qzJob('qz1', 'converting', { message: 'converting tensor 84 of 201' })] } }) - await page.goto('/app/build') - const now = page.getByTestId('build-now') - await expect(now).toHaveAttribute('data-kind', 'running') - await expect(now).toContainText('Quantizing meta-llama/Llama-3.2-1B to q4_k_m') - await expect(now).toContainText('converting tensor 84 of 201') - await expect(now.getByRole('link', { name: 'Open' })).toHaveAttribute('href', '/app/quantize') - }) - - test('a failed newest job is surfaced, an old failure is not', async ({ page }) => { - await setup(page, { ft: { jobs: [ftJob('j2', 'failed', { message: 'CUDA out of memory' })] } }) - await page.goto('/app/build') - await expect(page.getByTestId('build-now')).toHaveAttribute('data-kind', 'failed') - await expect(page.getByTestId('build-now')).toContainText('Fine-tuning TinyLlama/TinyLlama-1.1B-Chat-v1.0 failed') - }) - - test('no job, no line', async ({ page }) => { - await setup(page, { ft: { jobs: [ftJob('j1', 'completed')] } }) - await page.goto('/app/build') - await expect(page.getByTestId('build-landing')).toBeVisible() - await expect(page.getByTestId('build-now')).toHaveCount(0) - }) - - test('a tool switched off by a feature flag is left out', async ({ page }) => { - await setup(page, { features: { agents: false, mcp: false, fine_tuning: false, quantization: true, distributed: false } }) - await page.goto('/app/build') - await expect(tool(page, 'fine-tune')).toHaveCount(0) - await expect(tool(page, 'quantize')).toBeVisible() - await expect(tool(page, 'agents')).toHaveCount(0) - await expect(page.getByRole('list', { name: 'Build tools' }).locator('a[href="/app/import-model"]')).toBeVisible() - }) - - test('a member without admin sees no machine strip, no Install link, and the tools they may use', async ({ page }) => { - await mockMachine(page, { host: null }) - await mockFineTune(page, { jobs: [], backends: [] }) - await mockQuantize(page, { jobs: [] }) - await page.route('**/api/auth/status', route => route.fulfill({ - json: { authEnabled: true, staticApiKeyRequired: false, providers: ['local'], user: { id: 'u1', name: 'Sam', role: 'user', permissions: { fine_tuning: true } } }, - })) - await page.goto('/app/build') - await expect(tool(page, 'fine-tune')).toHaveAttribute('data-state', 'needs-backend') - await expect(tool(page, 'fine-tune')).toContainText('Ask an administrator to install the backend') - await expect(tool(page, 'fine-tune').getByRole('link', { name: 'Install' })).toHaveCount(0) - await expect(page.getByTestId('build-machine')).toHaveCount(0) - await expect(tool(page, 'import')).toHaveCount(0) - await expect(tool(page, 'quantize')).toHaveCount(0) - }) - - test('a user with no Build tool sees the empty line', async ({ page }) => { - await mockMachine(page, { host: null }) - await page.route('**/api/auth/status', route => route.fulfill({ - json: { authEnabled: true, staticApiKeyRequired: false, providers: ['local'], user: { id: 'u1', name: 'Sam', role: 'user', permissions: { chat: true } } }, - })) - await page.goto('/app/build') - await expect(page.getByTestId('build-empty')).toContainText('No build tools are available') - }) -}) - -test.describe('Build landing: phone', () => { - test.use({ viewport: { width: 390, height: 844 } }) - - test('fits the screen and keeps every Open link tappable', async ({ page }) => { - await setup(page, { qz: { jobs: [qzJob('qz1', 'converting')] } }) - await page.goto('/app/build') - await expect(page.getByTestId('build-landing')).toBeVisible() - expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)).toBe(false) - const heights = await page.locator('[data-testid="build-landing"] .bt-tool__end a').evaluateAll(els => els.map(el => el.getBoundingClientRect().height)) - expect(heights.length).toBeGreaterThan(5) - expect(Math.min(...heights)).toBeGreaterThanOrEqual(30) - }) -}) - -test.describe('Build landing: reduced motion', () => { - test('page content does not animate', async ({ page }) => { - await page.emulateMedia({ reducedMotion: 'reduce' }) - await setup(page) - await page.goto('/app/build') - const duration = await page.getByTestId('build-landing').locator('.bt-tool').first().evaluate(el => getComputedStyle(el).animationDuration) - expect(parseFloat(duration)).toBeLessThan(0.01) - }) -}) diff --git a/core/http/react-ui/e2e/canonical-resource-navigation.spec.js b/core/http/react-ui/e2e/canonical-resource-navigation.spec.js index 0342fa03e..3fce71c07 100644 --- a/core/http/react-ui/e2e/canonical-resource-navigation.spec.js +++ b/core/http/react-ui/e2e/canonical-resource-navigation.spec.js @@ -69,7 +69,7 @@ test.describe('Canonical resource navigation', () => { await expect(sidebar.getByRole('link', { name: 'Models', exact: true })).toBeVisible() await expect(sidebar.getByRole('link', { name: 'Discover', exact: true })).toHaveCount(0) - await sidebar.getByRole('link', { name: 'Operate', exact: true }).click() + await sidebar.getByRole('link', { name: 'Administration', exact: true }).click() const operateBar = page.locator('.dk-hubtabs') await expect(operateBar.getByRole('link', { name: /Runtime/ })).toBeVisible() await expect(operateBar.getByRole('link', { name: /Host/ })).toHaveCount(0) diff --git a/core/http/react-ui/e2e/chat-addons.spec.js b/core/http/react-ui/e2e/chat-addons.spec.js deleted file mode 100644 index 71970356b..000000000 --- a/core/http/react-ui/e2e/chat-addons.spec.js +++ /dev/null @@ -1,186 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { chatOf, pair, mockChat, answerWith, CHATS_KEY } from './chat-fixtures.js' -import { MEMBER, app, sessionFor, mockAddons } from './addons-fixtures.js' - -// The add-ons control in the chat composer, next to the MCP control, lists the add-ons that ship tools, -// one switch each, and shows how many are on. The /addons command of the -// composer flips all of them and stays in step with it. - -const TOOL = { name: 'run', kind: 'read', description: 'x', inputSchema: { type: 'object' } } -const NOTES = app({ id: 'notes', title: 'Notes', toolDefinitions: [TOOL] }) -const INDEXER = app({ id: 'indexer', title: 'Indexer', icon: undefined, toolDefinitions: [TOOL] }) -// Running, but its manifest declares no tool: never listed. -const VIEWER = app({ id: 'viewer', title: 'Viewer', toolDefinitions: [] }) -// Has tools but is stopped: not offered. -const STOPPED = app({ id: 'idle', title: 'Idle', enabled: false, state: 'stopped', toolDefinitions: [TOOL] }) - -const button = (page) => page.getByTestId('chat-addons-button') -const items = (page) => page.getByTestId('chat-addon-item') -const input = (page) => page.getByTestId('chat-input') - -// The signed-in person's chats live under a key of their own. -const KEY = `${CHATS_KEY}:${MEMBER.id}` - -async function openChat(page, chats) { - await page.addInitScript(([key, data]) => { - if (!localStorage.getItem(key)) localStorage.setItem(key, JSON.stringify(data)) - }, [KEY, { chats, activeChatId: chats[0].id, lastSaved: Date.now() }]) - await page.goto('/app/chat') - await page.getByTestId('chat-composer').waitFor() -} - -const storedChats = (page) => page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), KEY) - -async function setup(page, apps, user = MEMBER) { - await mockChat(page) - await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(user, { addons: true }) })) - await mockAddons(page, { apps }) -} - -test.describe('Chat add-ons control', () => { - test('lists only the add-ons that ship tools, with name and icon, and nothing else', async ({ page }) => { - await setup(page, [NOTES, INDEXER, VIEWER, STOPPED]) - await openChat(page, [chatOf('c1', 'Add-ons', 'qwen3-8b', pair('hi', 'hello'))]) - await expect(button(page)).toBeVisible() - await expect(page.getByTestId('chat-addons-count')).toHaveCount(0) - await button(page).click() - await expect(page.getByTestId('chat-addons-menu')).toBeVisible() - await expect(items(page)).toHaveCount(2) - await expect(items(page).nth(0)).toContainText('Notes') - await expect(items(page).nth(1)).toContainText('Indexer') - await expect(items(page).nth(0).locator('img.ad-icon')).toBeVisible() - await expect(items(page).nth(1).locator('.ad-icon--empty')).toBeVisible() - await expect(page.getByTestId('chat-addons-menu')).not.toContainText('Viewer') - await expect(page.getByTestId('chat-addons-menu')).not.toContainText('Idle') - // The menu closes on Escape. - await page.keyboard.press('Escape') - await expect(page.getByTestId('chat-addons-menu')).toHaveCount(0) - }) - - test('sits right after the MCP control in the composer, not in the chat header', async ({ page }) => { - await setup(page, [NOTES]) - await openChat(page, [chatOf('c1', 'Place', 'qwen3-8b', pair('hi', 'hello'))]) - await expect(button(page)).toBeVisible() - await expect(page.getByTestId('chat-header').getByTestId('chat-addons')).toHaveCount(0) - const beside = await page.getByTestId('chat-addons').evaluate(el => { - const before = el.previousElementSibling - return Boolean(before && before.classList.contains('chat-mcp-dropdown') && before.textContent.includes('MCP') && el.parentElement === before.parentElement) - }) - expect(beside).toBe(true) - }) - - test('says in one line that an add-on that is off does nothing in this chat', async ({ page }) => { - await setup(page, [NOTES, INDEXER, VIEWER]) - await openChat(page, [chatOf('c1', 'Line', 'qwen3-8b', pair('hi', 'hello'))]) - await button(page).click() - const menu = page.getByTestId('chat-addons-menu') - const note = menu.getByTestId('chat-addons-note') - await expect(note).toHaveText('An add-on that is off here does nothing in this chat.') - // One line: a single text node paragraph, tied to the control by its description. - await expect(button(page)).toHaveAttribute('aria-describedby', await note.getAttribute('id')) - // Only the add-ons that ship tools are listed under it. - await expect(items(page)).toHaveCount(2) - await expect(menu).not.toContainText('Viewer') - }) - - test('shows nothing when no add-on ships tools', async ({ page }) => { - await setup(page, [VIEWER, STOPPED]) - await openChat(page, [chatOf('c1', 'None', 'qwen3-8b', pair('hi', 'hello'))]) - await expect(page.getByTestId('chat-header')).toBeVisible() - await expect(page.getByTestId('chat-addons')).toHaveCount(0) - await input(page).fill('/') - await expect(page.getByRole('option', { name: /\/addons/ })).toHaveCount(0) - }) - - test('shows nothing without the add-ons feature', async ({ page }) => { - await mockChat(page) - await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: false }) })) - await mockAddons(page, { apps: [NOTES] }) - await openChat(page, [chatOf('c1', 'None', 'qwen3-8b', pair('hi', 'hello'))]) - await expect(page.getByTestId('chat-addons')).toHaveCount(0) - }) - - test('a switch turns one add-on on for this chat, counts it, and stores the list on the chat', async ({ page }) => { - await setup(page, [NOTES, INDEXER]) - await openChat(page, [chatOf('c1', 'One', 'qwen3-8b', pair('hi', 'hello')), chatOf('c2', 'Two', 'qwen3-8b', pair('a', 'b'), {}, Date.now() - 1000)]) - await button(page).click() - await items(page).nth(0).getByRole('checkbox').check() - await expect(page.getByTestId('chat-addons-count')).toHaveText('1') - await expect(items(page).nth(0).getByRole('checkbox')).toBeChecked() - await expect(items(page).nth(1).getByRole('checkbox')).not.toBeChecked() - await items(page).nth(1).getByRole('checkbox').check() - await expect(page.getByTestId('chat-addons-count')).toHaveText('2') - await items(page).nth(0).getByRole('checkbox').uncheck() - await expect(page.getByTestId('chat-addons-count')).toHaveText('1') - await expect.poll(async () => (await storedChats(page)).chats?.find(c => c.id === 'c1')?.addonToolIds).toEqual(['indexer']) - // The other chat keeps its own selection. - const stored = await storedChats(page) - expect(stored.chats.find(c => c.id === 'c2').addonToolIds).toBeUndefined() - }) - - test('the select-all link flips every add-on', async ({ page }) => { - await setup(page, [NOTES, INDEXER]) - await openChat(page, [chatOf('c1', 'All', 'qwen3-8b', pair('hi', 'hello'))]) - await button(page).click() - await page.getByTestId('chat-addons-all').click() - await expect(page.getByTestId('chat-addons-count')).toHaveText('2') - await expect(page.getByTestId('chat-addons-all')).toHaveText('Deselect all') - await page.getByTestId('chat-addons-all').click() - await expect(page.getByTestId('chat-addons-count')).toHaveCount(0) - }) - - test('/addons flips all add-ons and the composer control shows it, in step with the switches', async ({ page }) => { - await setup(page, [NOTES, INDEXER]) - await openChat(page, [chatOf('c1', 'Slash', 'qwen3-8b', pair('hi', 'hello'))]) - await input(page).fill('/addons') - await input(page).press('Enter') - await expect(page.getByText('Add-on tools on for this chat')).toBeVisible() - await expect(page.getByTestId('chat-addons-count')).toHaveText('2') - await button(page).click() - await items(page).nth(0).getByRole('checkbox').uncheck() - await expect(page.getByTestId('chat-addons-count')).toHaveText('1') - // With some on, /addons turns them all off. - await input(page).fill('/addons') - await input(page).press('Enter') - await expect(page.getByText('Add-on tools off for this chat')).toBeVisible() - await expect(page.getByTestId('chat-addons-count')).toHaveCount(0) - await expect(items(page).nth(1).getByRole('checkbox')).not.toBeChecked() - }) - - test('the request sends the boolean when every add-on is on, the list when some are, nothing when none', async ({ page }) => { - await setup(page, [NOTES, INDEXER]) - const sent = await answerWith(page, 'ok') - await openChat(page, [chatOf('c1', 'Send', 'qwen3-8b', pair('hi', 'hello'))]) - const send = async (text) => { - await input(page).fill(text) - await input(page).press('Enter') - await expect(page.getByText('ok').last()).toBeVisible() - } - - await send('none on') - expect(sent.request().metadata?.addon_tools).toBeUndefined() - expect(sent.request().metadata?.addon_tool_ids).toBeUndefined() - - await button(page).click() - await items(page).nth(1).getByRole('checkbox').check() - await send('some on') - expect(sent.request().metadata).toMatchObject({ addon_tools: 'true', addon_tool_ids: 'indexer' }) - - await items(page).nth(0).getByRole('checkbox').check() - await send('all on') - expect(sent.request().metadata.addon_tools).toBe('true') - expect(sent.request().metadata.addon_tool_ids).toBeUndefined() - }) - - test('a chat saved with the older boolean means every add-on', async ({ page }) => { - await setup(page, [NOTES, INDEXER]) - const sent = await answerWith(page, 'ok') - await openChat(page, [chatOf('c1', 'Old', 'qwen3-8b', pair('hi', 'hello'), { addonTools: true })]) - await expect(page.getByTestId('chat-addons-count')).toHaveText('2') - await input(page).fill('hello') - await input(page).press('Enter') - await expect(page.getByText('ok').last()).toBeVisible() - expect(sent.request().metadata).toMatchObject({ addon_tools: 'true' }) - expect(sent.request().metadata.addon_tool_ids).toBeUndefined() - }) -}) diff --git a/core/http/react-ui/e2e/chat-canvas-find.spec.js b/core/http/react-ui/e2e/chat-canvas-find.spec.js deleted file mode 100644 index 5d3316ac0..000000000 --- a/core/http/react-ui/e2e/chat-canvas-find.spec.js +++ /dev/null @@ -1,184 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { chatOf, pair, mockChat, openChat, GO_BLOCK } from './chat-fixtures.js' - -// The canvas opens on demand beside the thread, find marks matches in the -// messages that are loaded, and a long thread has a way back to the end. - -const CODE_THREAD = chatOf('c1', 'Code', 'qwen3-8b', [ - { role: 'user', content: 'write the handler and a page' }, - { - role: 'assistant', - content: 'First the handler:\n\n```go\n' + GO_BLOCK + '\n```\n\nThen the page:\n\n```html\n

Hello canvas

\n```\n\nThat is all.', - }, -]) - -const canvas = (page) => page.getByTestId('canvas-panel') - -test.describe('Chat canvas', () => { - test.beforeEach(async ({ page }) => { - await mockChat(page) - }) - - test('the Canvas button on a code block opens that block in the panel', async ({ page }) => { - await openChat(page, [CODE_THREAD]) - await expect(canvas(page)).toHaveCount(0) - await page.locator('.code-block').nth(1).getByRole('button', { name: 'Canvas' }).click() - await expect(canvas(page)).toBeVisible() - // Canvas mode is on now, and the block that was clicked is the one shown. - await expect(page.getByTestId('chat-canvas-chip')).toHaveAttribute('aria-pressed', 'true') - await expect(canvas(page).getByRole('tab', { selected: true })).toContainText('.html') - await expect(canvas(page).getByRole('button', { name: 'Preview' })).toHaveAttribute('aria-pressed', 'true') - await expect(canvas(page).frameLocator('iframe').getByRole('heading', { name: 'Hello canvas' })).toBeVisible() - }) - - test('with Canvas on, code blocks are cards and a card opens the panel with tabs', async ({ page }) => { - await openChat(page, [CODE_THREAD]) - await page.getByTestId('chat-canvas-chip').click() - // Turning it on opens the panel on the newest block; the blocks are cards now. - await expect(canvas(page)).toBeVisible() - await expect(page.locator('.artifact-card')).toHaveCount(2) - await expect(canvas(page).getByRole('tab')).toHaveCount(2) - - await canvas(page).getByRole('tab', { name: /main.go/ }).click() - await expect(canvas(page)).toContainText('context.WithCancel') - // Code and Preview switch for html. - await canvas(page).getByRole('tab', { name: /\.html/ }).click() - await canvas(page).getByRole('button', { name: 'Code' }).click() - await expect(canvas(page)).toContainText('

Hello canvas

') - await expect(canvas(page).getByRole('button', { name: 'Copy' })).toBeVisible() - await expect(canvas(page).getByRole('button', { name: 'Download' })).toBeVisible() - - await canvas(page).getByRole('button', { name: 'Close canvas' }).click() - await expect(canvas(page)).toHaveCount(0) - // The chip carries the count and opens it again. - await page.getByRole('button', { name: 'Open canvas panel' }).click() - await expect(canvas(page)).toBeVisible() - }) - - test('Esc closes the canvas', async ({ page }) => { - await openChat(page, [CODE_THREAD]) - await page.getByTestId('chat-canvas-chip').click() - await expect(canvas(page)).toBeVisible() - await page.locator('.cx-body').click({ position: { x: 5, y: 5 } }) - await page.keyboard.press('Escape') - await expect(canvas(page)).toHaveCount(0) - }) - - test('the panel is wide beside the thread on a desktop window', async ({ page }) => { - await openChat(page, [CODE_THREAD]) - await page.getByTestId('chat-canvas-chip').click() - const panel = await canvas(page).boundingBox() - const view = page.viewportSize() - expect(panel.width).toBeGreaterThan(view.width * 0.3) - // The thread keeps its own room beside it. - const thread = await page.getByTestId('chat-thread').boundingBox() - expect(thread.x + thread.width).toBeLessThanOrEqual(panel.x + 1) - }) - - test('on a phone the canvas takes the whole page', async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }) - await openChat(page, [CODE_THREAD]) - await page.getByTestId('chat-canvas-chip').click() - const box = await canvas(page).boundingBox() - expect(box.width).toBeGreaterThanOrEqual(389) - expect(box.height).toBeGreaterThan(500) - await canvas(page).getByRole('button', { name: 'Close canvas' }).click() - await expect(canvas(page)).toHaveCount(0) - }) -}) - -test.describe('Chat find', () => { - test.beforeEach(async ({ page }) => { - await mockChat(page) - }) - - const THREAD = chatOf('c1', 'Find', 'qwen3-8b', [ - ...pair('Why do goroutines leak?', 'Because nothing watches the context. Each goroutine waits forever, and the goroutine count grows.'), - ...pair('And the fix?', 'Select on ctx.Done() in every goroutine.'), - ]) - - test('Ctrl+Shift+F searches the loaded messages, shows n of m and steps with Enter', async ({ page }) => { - let completions = 0 - await page.route('**/v1/chat/completions', (route) => { completions++; route.abort() }) - await openChat(page, [THREAD]) - await page.keyboard.press('Control+Shift+F') - const input = page.getByTestId('chat-find-input') - await expect(input).toBeFocused() - await input.fill('goroutine') - const count = page.getByTestId('chat-find-count') - await expect(count).toHaveText('1 of 4') - await expect(page.locator('mark.cx-hit')).toHaveCount(4) - await expect(page.locator('mark.cx-hit[data-cur]')).toHaveCount(1) - await expect(page.locator('mark.cx-hit').first()).toHaveAttribute('data-cur', '') - - await input.press('Enter') - await expect(count).toHaveText('2 of 4') - await expect(page.locator('mark.cx-hit').nth(1)).toHaveAttribute('data-cur', '') - await input.press('Shift+Enter') - await input.press('Shift+Enter') - await expect(count).toHaveText('4 of 4') - - // Case does not matter, and a miss says so. - await input.fill('CTX.DONE') - await expect(count).toHaveText('1 of 1') - await input.fill('nothing like this') - await expect(count).toHaveText('No matches') - await expect(page.locator('mark.cx-hit')).toHaveCount(0) - - // The search never leaves the page. - expect(completions).toBe(0) - }) - - test('Esc closes the search, clears the marks and puts the cursor back in the box', async ({ page }) => { - await openChat(page, [THREAD]) - await page.getByTestId('chat-find-button').click() - await page.getByTestId('chat-find-input').fill('context') - await expect(page.locator('mark.cx-hit')).not.toHaveCount(0) - await page.keyboard.press('Escape') - await expect(page.getByTestId('chat-find')).toHaveCount(0) - await expect(page.locator('mark.cx-hit')).toHaveCount(0) - await expect(page.getByTestId('chat-input')).toBeFocused() - // The text of the messages is whole again. - await expect(page.locator('[data-role="assistant"]').first()).toContainText('Because nothing watches the context.') - }) - - test('it finds text inside code blocks and in your own messages', async ({ page }) => { - await openChat(page, [CODE_THREAD]) - await page.keyboard.press('Control+Shift+F') - await page.getByTestId('chat-find-input').fill('write the') - await expect(page.locator('[data-role="user"] mark.cx-hit')).toHaveCount(1) - await page.getByTestId('chat-find-input').fill('WithCancel') - await expect(page.locator('.code-block mark.cx-hit')).toHaveCount(1) - }) -}) - -test.describe('Chat long threads', () => { - test('Jump to latest appears when you leave the end and takes you back', async ({ page }) => { - await mockChat(page) - const history = [] - for (let i = 1; i <= 40; i++) history.push(...pair(`Question ${i} about context sizes`, `Answer ${i}. The same advice applies each time, so keep going.`)) - await openChat(page, [chatOf('c1', 'Long', 'qwen3-8b', history)]) - const body = page.locator('.cx-body') - await expect(page.getByTestId('chat-message')).toHaveCount(80) - await expect(page.getByTestId('chat-jump-latest')).toHaveCount(0) - - await body.evaluate(el => { el.scrollTop = 0 }) - const jump = page.getByTestId('chat-jump-latest') - await expect(jump).toBeVisible() - await jump.click() - await expect(jump).toHaveCount(0) - await expect.poll(() => body.evaluate(el => el.scrollHeight - el.scrollTop - el.clientHeight)).toBeLessThan(80) - }) - - test('a long thread does not push the page: the thread scrolls inside the window', async ({ page }) => { - await mockChat(page) - const history = [] - for (let i = 1; i <= 40; i++) history.push(...pair(`Question ${i}`, `Answer ${i}`)) - await openChat(page, [chatOf('c1', 'Long', 'qwen3-8b', history)]) - await expect(page.getByTestId('chat-message')).toHaveCount(80) - const overflow = await page.evaluate(() => document.documentElement.scrollHeight - window.innerHeight) - expect(overflow).toBeLessThanOrEqual(1) - // The composer stays in view. - await expect(page.getByTestId('chat-input')).toBeInViewport() - }) -}) diff --git a/core/http/react-ui/e2e/chat-composer.spec.js b/core/http/react-ui/e2e/chat-composer.spec.js deleted file mode 100644 index fedd45a46..000000000 --- a/core/http/react-ui/e2e/chat-composer.spec.js +++ /dev/null @@ -1,226 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { - chatOf, pair, mockChat, openChat, storedChats, answerWith, PNG, -} from './chat-fixtures.js' - -// The Chat composer is the Home command bar: the model chip, the MCP chip, a -// Canvas chip, attach buttons, a solid Send and the slash menu. These specs -// check that it is the same object and that what it adds for a conversation -// (Stop, Up to edit, the token line) works. - -const input = (page) => page.getByTestId('chat-input') - -test.describe('Chat composer', () => { - test.beforeEach(async ({ page }) => { - await mockChat(page) - }) - - test('is the Home command bar, with the Canvas chip and the hint line', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Bar', 'qwen3-8b', pair('hi', 'hello'))]) - const bar = page.getByTestId('chat-composer') - await expect(bar).toHaveClass(/home-cmd/) - await expect(bar.getByTestId('home-model-chip')).toContainText('qwen3-8b') - await expect(bar.locator('.chat-mcp-dropdown')).toBeVisible() - await expect(bar.getByTestId('chat-canvas-chip')).toHaveAttribute('aria-pressed', 'false') - await expect(bar.getByRole('button', { name: 'Attach image' })).toBeVisible() - await expect(bar.getByRole('button', { name: 'Attach audio' })).toBeVisible() - await expect(bar.getByRole('button', { name: 'Attach file' })).toBeVisible() - await expect(bar).toContainText('Enter to send, Shift+Enter for a new line') - await expect(bar).toContainText('for actions') - }) - - test('Send is quiet and disabled when empty, active with text', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Send', 'qwen3-8b', [])]) - const send = page.getByTestId('chat-send') - await expect(send).toBeDisabled() - await expect(send).toHaveAttribute('data-empty', 'true') - await input(page).fill('hello') - await expect(send).toBeEnabled() - await expect(send).not.toHaveAttribute('data-empty', 'true') - }) - - test('Enter sends, Shift+Enter adds a line, and Ctrl, Cmd and Alt Enter do not send', async ({ page }) => { - const answer = await answerWith(page, 'ok') - await openChat(page, [chatOf('c1', 'Keys', 'qwen3-8b', [])]) - await input(page).fill('one') - await input(page).press('Shift+Enter') - await expect(input(page)).toHaveValue('one\n') - for (const combo of ['Control+Enter', 'Meta+Enter', 'Alt+Enter']) await input(page).press(combo) - await expect(page.locator('[data-role="user"]')).toHaveCount(0) - expect(answer.request()).toBeNull() - - await input(page).press('Enter') - await expect(page.locator('[data-role="user"]')).toContainText('one') - expect(answer.request().messages.at(-1).content).toBe('one') - }) - - test('the settings the chat holds go out with the message', async ({ page }) => { - const answer = await answerWith(page, 'ok') - await openChat(page, [chatOf('c1', 'Params', 'qwen3-8b', [], { temperature: 0.3, topP: 0.8, topK: 20, systemPrompt: 'Be brief.' })]) - await input(page).fill('go') - await page.keyboard.press('Enter') - await expect(page.locator('[data-role="assistant"]')).toContainText('ok') - const body = answer.request() - expect(body).toMatchObject({ model: 'qwen3-8b', temperature: 0.3, top_p: 0.8, top_k: 20, stream: true }) - expect(body.messages[0]).toEqual({ role: 'system', content: 'Be brief.' }) - }) - - test('the slash menu lists the chat actions in two groups and filters as you type', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Slash', 'qwen3-8b', pair('hi', 'hello'))]) - await input(page).fill('/') - const list = page.getByRole('listbox', { name: 'Actions' }) - await expect(list).toBeVisible() - await expect(list.locator('.dk-cmd-label')).toHaveText(['Chat', 'View']) - const names = await list.locator('.dk-cmd-name').allTextContents() - expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/effort', '/canvas', '/find', '/settings', '/export', '/clear']) - - await input(page).fill('/set') - await expect(list.locator('.dk-cmd-name')).toHaveText(['/settings']) - await input(page).fill('/zzz') - await expect(list).toHaveCount(0) - - // A message that merely starts with a slash is a message. - await input(page).fill('/etc/hosts how do I read it') - await expect(list).toHaveCount(0) - }) - - test('arrow keys, Enter, Tab and Esc work in the slash menu', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Slash', 'qwen3-8b', pair('hi', 'hello'))]) - await input(page).fill('/') - await input(page).press('ArrowDown') - await expect(page.getByRole('option', { selected: true })).toContainText('/new') - await input(page).press('ArrowUp') - await expect(page.getByRole('option', { selected: true })).toContainText('/model') - await input(page).press('Escape') - await expect(page.getByRole('listbox', { name: 'Actions' })).toHaveCount(0) - // Escape closes the menu and leaves the text. - await expect(input(page)).toHaveValue('/') - }) - - test('/new starts an empty chat, /canvas toggles Canvas, /settings opens the sheet', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Slash', 'qwen3-8b', pair('hi', 'hello'))]) - await input(page).fill('/canvas') - await input(page).press('Enter') - await expect(page.getByTestId('chat-canvas-chip')).toHaveAttribute('aria-pressed', 'true') - await expect(input(page)).toHaveValue('') - - await input(page).fill('/settings') - await input(page).press('Enter') - await expect(page.getByTestId('chat-settings')).toBeVisible() - await page.keyboard.press('Escape') - await expect(page.getByTestId('chat-settings')).toHaveCount(0) - - await input(page).fill('/new') - await input(page).press('Enter') - await expect(page.getByTestId('chat-empty')).toBeVisible() - await expect.poll(async () => (await storedChats(page)).chats.length).toBe(2) - }) - - test('/chats opens the conversations, /find opens the search, /clear asks first', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Slash', 'qwen3-8b', pair('hi', 'hello'))]) - await input(page).fill('/chats') - await input(page).press('Enter') - await expect(page.getByTestId('chats-menu')).toBeVisible() - await page.keyboard.press('Escape') - - await input(page).fill('/find') - await input(page).press('Enter') - await expect(page.getByTestId('chat-find')).toBeVisible() - await page.getByTestId('chat-find-input').press('Escape') - - await input(page).fill('/clear') - await input(page).press('Enter') - const dialog = page.getByRole('alertdialog') - await expect(dialog).toContainText('Clear this conversation') - await dialog.getByRole('button', { name: 'Clear conversation' }).click() - await expect(page.getByTestId('chat-empty')).toBeVisible() - await expect.poll(async () => (await storedChats(page)).chats[0].history.length).toBe(0) - }) - - test('/export downloads the chat as Markdown', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Slash export', 'qwen3-8b', pair('hi', 'hello'))]) - const download = page.waitForEvent('download') - await input(page).fill('/export') - await input(page).press('Enter') - expect((await download).suggestedFilename()).toBe('Slash_export.md') - }) - - test('the actions that need a thread are hidden in an empty chat', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Empty', 'qwen3-8b', [])]) - await input(page).fill('/') - const names = await page.getByRole('listbox', { name: 'Actions' }).locator('.dk-cmd-name').allTextContents() - expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/effort', '/canvas', '/settings']) - }) - - test('"/" from outside a text field starts a command', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Slash', 'qwen3-8b', pair('hi', 'hello'))]) - await page.locator('.cx-body').click({ position: { x: 5, y: 5 } }) - await page.keyboard.press('/') - await expect(input(page)).toBeFocused() - await expect(input(page)).toHaveValue('/') - await expect(page.getByRole('listbox', { name: 'Actions' })).toBeVisible() - }) - - test('an attached image shows as a thumbnail tag and can be removed', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Attach', 'gemma-3-12b', [])]) - await page.locator('input[type=file][accept="image/*"]').setInputFiles({ - name: 'pixel.png', mimeType: 'image/png', buffer: Buffer.from(PNG.split(',')[1], 'base64'), - }) - const tag = page.locator('.home-file-tag', { hasText: 'pixel.png' }) - await expect(tag).toBeVisible() - await expect(tag.locator('img')).toBeVisible() - await expect(page.getByTestId('chat-send')).toBeEnabled() - await tag.getByRole('button', { name: /Remove pixel.png/ }).click() - await expect(tag).toHaveCount(0) - }) - - test('a pasted image attaches as a file', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Paste', 'gemma-3-12b', [])]) - await input(page).focus() - await input(page).evaluate((el, b64) => { - const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0)) - const dt = new DataTransfer() - dt.items.add(new File([bytes], 'image.png', { type: 'image/png' })) - el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true })) - }, PNG.split(',')[1]) - await expect(page.locator('.home-file-tag', { hasText: 'pasted-image-1.png' })).toBeVisible() - }) - - test('the line under the box shows the token count against the context size', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Tokens', 'qwen3-8b', pair('hi', 'hello'), { - contextSize: 8192, tokenUsage: { prompt: 2600, completion: 540, total: 3140 }, - })]) - await expect(page.getByTestId('chat-foot')).toContainText('3140 / 8192 tokens') - await expect(page.getByTestId('chat-context')).toContainText('38%') - }) - - test('the foot warns when the context is nearly full', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Full', 'qwen3-8b', pair('hi', 'hello'), { - contextSize: 8192, tokenUsage: { prompt: 7000, completion: 400, total: 7400 }, - })]) - await expect(page.getByTestId('chat-foot')).toContainText('The context is nearly full') - await expect(page.getByTestId('chat-context')).toHaveAttribute('data-warn', 'true') - }) - - test('Up in an empty box edits your last message', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Up', 'qwen3-8b', [...pair('first question', 'first answer'), ...pair('second question', 'second answer')])]) - await input(page).focus() - await input(page).press('ArrowUp') - const user = page.locator('[data-role="user"]').last() - await expect(user.getByRole('textbox')).toHaveValue('second question') - await user.getByRole('textbox').press('Escape') - - // With text in the box the key does what it always did. - await input(page).fill('draft') - await input(page).press('ArrowUp') - await expect(page.getByRole('textbox', { name: 'Edit message' })).toHaveCount(0) - }) - - test('the model chip changes the model of this chat', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Model', 'qwen3-8b', [])]) - await page.getByTestId('home-model-chip').click() - await page.getByRole('option', { name: /phi-4-mini/ }).click() - await expect(page.getByTestId('home-model-chip')).toContainText('phi-4-mini') - await expect.poll(async () => (await storedChats(page)).chats[0].model).toBe('phi-4-mini') - }) -}) diff --git a/core/http/react-ui/e2e/chat-dictation.spec.js b/core/http/react-ui/e2e/chat-dictation.spec.js deleted file mode 100644 index cab8d53cd..000000000 --- a/core/http/react-ui/e2e/chat-dictation.spec.js +++ /dev/null @@ -1,74 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { chatOf, mockChat, openChat, MODELS } from './chat-fixtures.js' - -// Dictation in the composer: record from the microphone, send the recording to -// /v1/audio/transcriptions with the installed transcription model, put the text -// into the message box. The browser has no microphone here, so getUserMedia and -// MediaRecorder are replaced with stand-ins that hand back a few bytes. - -const WHISPER = { id: 'whisper-1', capabilities: ['FLAG_TRANSCRIPT'] } - -async function fakeMicrophone(page) { - await page.addInitScript(() => { - navigator.mediaDevices.getUserMedia = async () => ({ getTracks: () => [{ stop() { window.__micStopped = true } }] }) - window.MediaRecorder = class { - constructor() { this.state = 'inactive'; this.mimeType = 'audio/webm' } - start() { this.state = 'recording' } - stop() { - this.state = 'inactive' - this.ondataavailable?.({ data: new Blob(['voice'], { type: 'audio/webm' }) }) - this.onstop?.() - } - } - }) -} - -const input = (page) => page.getByTestId('chat-input') - -test.describe('Chat dictation', () => { - test('records, transcribes with the transcription model and appends the text', async ({ page }) => { - await fakeMicrophone(page) - await mockChat(page, { models: [...MODELS, WHISPER] }) - let form = '' - await page.route('**/v1/audio/transcriptions', (route) => { - form = route.request().postDataBuffer()?.toString('latin1') || '' - route.fulfill({ json: { text: ' Wie spät ist es? ' } }) - }) - await openChat(page, [chatOf('c1', 'Dictate', 'qwen3-8b', [])]) - - await input(page).fill('Frage:') - const btn = page.getByTestId('chat-dictate') - await btn.click() - await expect(btn).toHaveAttribute('data-state', 'recording') - await expect(btn).toHaveAttribute('aria-pressed', 'true') - await btn.click() - - await expect(input(page)).toHaveValue('Frage: Wie spät ist es?') - await expect(btn).toHaveAttribute('data-state', 'idle') - expect(form).toContain('name="model"\r\n\r\nwhisper-1') - expect(form).toContain('filename="dictation.webm"') - expect(await page.evaluate(() => window.__micStopped)).toBe(true) - }) - - test('shows an error and keeps the message when transcription fails', async ({ page }) => { - await fakeMicrophone(page) - await mockChat(page, { models: [...MODELS, WHISPER] }) - await page.route('**/v1/audio/transcriptions', (route) => - route.fulfill({ status: 500, json: { error: { message: 'backend down' } } })) - await openChat(page, [chatOf('c1', 'Dictate', 'qwen3-8b', [])]) - - await input(page).fill('bleibt') - await page.getByTestId('chat-dictate').click() - await page.getByTestId('chat-dictate').click() - await expect(page.getByText(/Transcription failed/)).toBeVisible() - await expect(input(page)).toHaveValue('bleibt') - }) - - test('is not offered without a transcription model', async ({ page }) => { - await fakeMicrophone(page) - await mockChat(page) - await openChat(page, [chatOf('c1', 'Dictate', 'qwen3-8b', [])]) - await expect(page.getByRole('button', { name: 'Attach audio' })).toBeVisible() - await expect(page.getByTestId('chat-dictate')).toHaveCount(0) - }) -}) diff --git a/core/http/react-ui/e2e/chat-errors.spec.js b/core/http/react-ui/e2e/chat-errors.spec.js deleted file mode 100644 index 2562001d3..000000000 --- a/core/http/react-ui/e2e/chat-errors.spec.js +++ /dev/null @@ -1,106 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' - -async function setupChatPage(page) { - // Mock capabilities endpoint so ModelSelector auto-selects a model - await page.route('**/api/models/capabilities', (route) => { - route.fulfill({ - contentType: 'application/json', - body: JSON.stringify({ - data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }], - }), - }) - }) -} - -test.describe('Chat - Error Handling', () => { - test('shows backend error message on HTTP error', async ({ page }) => { - await setupChatPage(page) - - await page.route('**/v1/chat/completions', (route) => { - route.fulfill({ - status: 500, - contentType: 'application/json', - body: JSON.stringify({ - error: { message: 'Model failed to load', type: 'server_error', code: 500 }, - }), - }) - }) - - await page.goto('/app/chat') - // Wait for the model to be auto-selected (ModelSelector shows model name in button) - await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - - await page.getByTestId('chat-input').fill('Hello') - await page.getByTestId('chat-send').click() - - await expect(page.getByRole('paragraph').filter({ hasText: 'Model failed to load' })).toBeVisible({ timeout: 10_000 }) - }) - - test('shows error with trace link on HTTP error', async ({ page }) => { - await setupChatPage(page) - - await page.route('**/v1/chat/completions', (route) => { - route.fulfill({ - status: 500, - contentType: 'application/json', - body: JSON.stringify({ - error: { message: 'Backend crashed unexpectedly', type: 'server_error', code: 500 }, - }), - }) - }) - - await page.goto('/app/chat') - await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - - await page.getByTestId('chat-input').fill('Hello') - await page.getByTestId('chat-send').click() - - await expect(page.getByRole('paragraph').filter({ hasText: 'Backend crashed unexpectedly' })).toBeVisible({ timeout: 10_000 }) - await expect(page.locator('.chat-error-trace-link')).toBeVisible() - }) - - test('shows error from SSE error event during streaming', async ({ page }) => { - await setupChatPage(page) - - await page.route('**/v1/chat/completions', (route) => { - const body = [ - 'data: {"choices":[{"delta":{"content":"Hello"},"index":0}]}\n\n', - 'data: {"error":{"message":"Backend crashed mid-stream","type":"server_error"}}\n\n', - 'data: [DONE]\n\n', - ].join('') - route.fulfill({ - status: 200, - headers: { 'Content-Type': 'text/event-stream' }, - body, - }) - }) - - await page.goto('/app/chat') - await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - - await page.getByTestId('chat-input').fill('Hello') - await page.getByTestId('chat-send').click() - - await expect(page.getByRole('paragraph').filter({ hasText: 'Backend crashed mid-stream' })).toBeVisible({ timeout: 10_000 }) - }) - - test('shows generic HTTP error when no error body', async ({ page }) => { - await setupChatPage(page) - - await page.route('**/v1/chat/completions', (route) => { - route.fulfill({ - status: 502, - contentType: 'text/plain', - body: 'Bad Gateway', - }) - }) - - await page.goto('/app/chat') - await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - - await page.getByTestId('chat-input').fill('Hello') - await page.getByTestId('chat-send').click() - - await expect(page.getByRole('paragraph').filter({ hasText: 'HTTP 502' })).toBeVisible({ timeout: 10_000 }) - }) -}) diff --git a/core/http/react-ui/e2e/chat-fixtures.js b/core/http/react-ui/e2e/chat-fixtures.js index 764eadff1..e6ee0d2c6 100644 --- a/core/http/react-ui/e2e/chat-fixtures.js +++ b/core/http/react-ui/e2e/chat-fixtures.js @@ -1,12 +1,8 @@ -// Shared fixtures for the Chat and Talk specs: stubbed API routes, conversations -// written to the browser storage the Chat page reads, and a completion stream -// the spec drives by hand. -import { mockHome, seedChats, CHATS_KEY } from './home-fixtures.js' +// Shared fixtures for the Chats and Talk specs: stubbed API routes, a completion +// answer and a faked realtime connection. +import { mockHome } from './home-fixtures.js' import { installStudioAddons } from './studio-addon.js' -export { CHATS_KEY, seedChats } - -const GB = 1024 * 1024 * 1024 export const MODELS = [ { id: 'qwen3-8b', capabilities: ['FLAG_CHAT'] }, @@ -14,22 +10,7 @@ export const MODELS = [ { id: 'phi-4-mini', capabilities: ['FLAG_CHAT'] }, ] -// A bare conversation in the shape useChat stores. -export function chatOf(id, name, model, history, extra = {}, updatedAt = Date.now()) { - return { - id, name, model, history, updatedAt, createdAt: updatedAt - 60_000, - systemPrompt: '', mcpMode: false, mcpServers: [], clientMCPServers: [], - temperature: null, topP: null, topK: null, - tokenUsage: { prompt: 0, completion: 0, total: 0 }, contextSize: null, - ...extra, - } -} - -export const pair = (q, a) => [{ role: 'user', content: q }, { role: 'assistant', content: a }] - -export const GO_BLOCK = 'func main() {\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n}' - -// Stub what Chat reads. Models are the chat models the list offers; `loaded` +// Stub what the chat pages read. Models are the chat models the list offers; `loaded` // the ones the server holds in memory. export async function mockChat(page, { models = MODELS, loaded = ['qwen3-8b', 'phi-4-mini'], resources, features } = {}) { await mockHome(page, { @@ -43,13 +24,6 @@ export async function mockChat(page, { models = MODELS, loaded = ['qwen3-8b', 'p await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: models } })) } -export const RESOURCES_24GB = { - type: 'gpu', - gpus: [{ name: 'RTX 4090', vendor: 'nvidia', total_vram: 24 * GB, used_vram: 7.6 * GB }], - ram: { total: 64 * GB, used: 20 * GB, available: 40 * GB }, - aggregate: { total_memory: 24 * GB, used_memory: 7.6 * GB, gpu_count: 1, usage_percent: 31 }, -} - // One answer in the OpenAI stream format. export function sse(text, usage = { prompt_tokens: 3, completion_tokens: 5, total_tokens: 8 }) { return `data: ${JSON.stringify({ choices: [{ delta: { content: text } }] })}\n\n` @@ -66,44 +40,6 @@ export async function answerWith(page, text) { return { request: () => body } } -// Replace fetch for the completion endpoint with a stream the spec drives: -// await page.evaluate(() => window.__sse.push({ choices: [{ delta: { content: 'Hi' } }] })) -// await page.evaluate(() => window.__sse.end()) -// Abort is honoured, as the real fetch does. -export async function controlStream(page) { - await page.addInitScript(() => { - const orig = window.fetch.bind(window) - const enc = new TextEncoder() - window.__sse = { ctrl: null, calls: 0 } - window.__sse.push = (obj) => window.__sse.ctrl?.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`)) - window.__sse.end = () => { window.__sse.ctrl?.enqueue(enc.encode('data: [DONE]\n\n')); window.__sse.ctrl?.close() } - window.fetch = (url, init) => { - if (!String(url).includes('/v1/chat/completions')) return orig(url, init) - window.__sse.calls++ - const stream = new ReadableStream({ start(c) { window.__sse.ctrl = c } }) - init?.signal?.addEventListener('abort', () => { - try { window.__sse.ctrl.error(new DOMException('aborted', 'AbortError')) } catch { /* already closed */ } - }) - return Promise.resolve(new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } })) - } - }) -} - -export async function storedChats(page) { - return page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), CHATS_KEY) -} - -// Open Chat with these conversations stored. -export async function openChat(page, chats, active = chats[0]?.id) { - await seedChats(page, chats, active) - await page.goto('/app/chat') - // Keys and clicks only work once the page has mounted its handlers. - await page.getByTestId('chat-composer').waitFor() -} - -// A tiny valid PNG, for a message with an image in it. -export const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==' - // The Talk page's WebRTC, faked: the page connects as it would, and the spec // plays the server's events with window.__talk.emit(event). export async function fakeRealtime(page, { pipelines = true, micError = null } = {}) { diff --git a/core/http/react-ui/e2e/chat-history-sync.spec.js b/core/http/react-ui/e2e/chat-history-sync.spec.js deleted file mode 100644 index f2525f3ba..000000000 --- a/core/http/react-ui/e2e/chat-history-sync.spec.js +++ /dev/null @@ -1,509 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { mockChat, chatOf, pair, answerWith, CHATS_KEY } from './chat-fixtures.js' - -// Server-side chat history (see docs/content/features/chat-history.md). The -// server keeps a tombstone for every deleted conversation and refuses to -// write it again; these specs pin the client half: a stale browser must drop -// what the server deleted, and must not lose what it typed while the list -// was loading. - -const T0 = new Date('2026-10-09T12:00:00Z').getTime() - -// A stand-in for /api/conversations with the server's contract: tombstones -// in the list, 410 for a write of a deleted id, versions on every write. -// `hiddenDeleted` are tombstones the list does not report yet (deleted on -// another device after this tab loaded the list). `holdList` keeps the list -// request pending until release() is called. -async function fakeHistoryServer(page, { conversations = [], deleted = [], hiddenDeleted = [], holdList = false } = {}) { - const live = new Map(conversations.map(c => [c.id, c])) - const tombs = new Map(deleted.map(id => [id, { id, version: 9, deletedAt: T0 }])) - const hidden = new Set(hiddenDeleted) - const requests = [] - let release = () => {} - const listGate = holdList ? new Promise(resolve => { release = resolve }) : Promise.resolve() - - let down = false - - await page.route(/\/api\/conversations(\/[^?]*)?(\?.*)?$/, async (route) => { - if (down) return route.abort('internetdisconnected') - const req = route.request() - const path = new URL(req.url()).pathname - const rest = path.slice(path.indexOf('/api/conversations') + '/api/conversations'.length) - const id = rest.startsWith('/') ? decodeURIComponent(rest.slice(1)) : null - const method = req.method() - const body = method === 'PUT' || method === 'POST' ? req.postDataJSON() : null - requests.push({ method, id, body }) - - if (method === 'GET' && !id) { - await listGate - return route.fulfill({ json: { conversations: [...live.values()], deleted: [...tombs.values()] } }) - } - if (method === 'PUT' && id && id !== 'bulk') { - if (tombs.has(id) || hidden.has(id)) { - return route.fulfill({ status: 410, json: { error: 'conversation was deleted' } }) - } - const saved = { ...body, id, version: (live.get(id)?.version || 0) + 1 } - live.set(id, saved) - return route.fulfill({ json: saved }) - } - if (method === 'DELETE' && id) { - live.delete(id) - tombs.set(id, { id, version: 9, deletedAt: T0 }) - return route.fulfill({ json: { status: 'ok' } }) - } - if (method === 'DELETE' && !id) { - for (const liveId of live.keys()) tombs.set(liveId, { id: liveId, version: 9, deletedAt: T0 }) - live.clear() - return route.fulfill({ json: { status: 'ok' } }) - } - return route.fulfill({ status: 404, json: { error: 'not found' } }) - }) - - return { - release: () => release(), - setDown: (value) => { down = value }, - liveIds: () => [...live.keys()].sort(), - saves: (id) => requests.filter(r => r.method === 'PUT' && (id === undefined || r.id === id)), - deletes: (id) => requests.filter(r => r.method === 'DELETE' && r.id === id), - deleteAlls: () => requests.filter(r => r.method === 'DELETE' && !r.id), - bulk: () => requests.filter(r => r.id === 'bulk'), - } -} - -// Signs the browser in as `who()`, read on every request so a spec can sign -// one account out and another in on the same browser. -async function signedInAs(page, who) { - await page.route('**/api/auth/status', route => route.fulfill({ - json: { authEnabled: true, staticApiKeyRequired: false, providers: ['local'], hasUsers: true, user: who() }, - })) -} - -const ALICE = { id: 'u-alice', name: 'Alice', email: 'alice@example.com', role: 'admin', permissions: {} } -const BOB = { id: 'u-bob', name: 'Bob', email: 'bob@example.com', role: 'admin', permissions: {} } - -// Each account has its own browser cache (src/utils/chatStore.js). -const keyOf = (user) => `${CHATS_KEY}:${user.id}` - -const storedChats = (page, user = ALICE) => - page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), keyOf(user)) - -// Open Chat signed in as Alice with these conversations in her cache. -async function openChat(page, chats, active = chats[0]?.id) { - await page.addInitScript(([key, data]) => { - if (!localStorage.getItem(key)) localStorage.setItem(key, JSON.stringify(data)) - }, [keyOf(ALICE), { chats, activeChatId: active, lastSaved: Date.now() }]) - await page.goto('/app/chat') - await page.getByTestId('chat-composer').waitFor() -} - -// The server half per account: the session decides whose conversations a -// request reads and writes. Saves for an account in `failSaves` answer 500, -// as a first save that does not get through. -async function fakeAccountServer(page, who, { failSaves = [] } = {}) { - const accounts = new Map() - const failing = new Set(failSaves) - const requests = [] - const of = (userId) => { - if (!accounts.has(userId)) accounts.set(userId, new Map()) - return accounts.get(userId) - } - await page.route(/\/api\/conversations(\/[^?]*)?(\?.*)?$/, async (route) => { - const req = route.request() - const user = who().id - const live = of(user) - const path = new URL(req.url()).pathname - const rest = path.slice(path.indexOf('/api/conversations') + '/api/conversations'.length) - const id = rest.startsWith('/') ? decodeURIComponent(rest.slice(1)) : null - const method = req.method() - const body = method === 'PUT' ? req.postDataJSON() : null - requests.push({ user, method, id, body }) - if (method === 'GET' && !id) return route.fulfill({ json: { conversations: [...live.values()], deleted: [] } }) - if (method === 'PUT' && id) { - if (failing.has(user)) return route.fulfill({ status: 500, json: { error: 'database is locked' } }) - const saved = { ...body, id, version: (live.get(id)?.version || 0) + 1 } - live.set(id, saved) - return route.fulfill({ json: saved }) - } - return route.fulfill({ status: 404, json: { error: 'not found' } }) - }) - return { - recover: (userId) => failing.delete(userId), - saves: (userId) => requests.filter(r => r.user === userId && r.method === 'PUT'), - liveTexts: (userId) => [...of(userId).values()].map(c => JSON.stringify(c.history)), - } -} - -const storedIds = async (page) => (await storedChats(page)).chats.map(c => c.id) - -async function openMenu(page) { - await page.keyboard.press('Control+k') - await expect(page.getByTestId('chats-menu')).toBeVisible() -} - -test.describe('Chat history sync with the server', () => { - test.beforeEach(async ({ page }) => { - await mockChat(page) - await signedInAs(page, () => ALICE) - }) - - test('a stale client drops a chat deleted on another device instead of uploading it again', async ({ page }) => { - const server = await fakeHistoryServer(page, { - conversations: [chatOf('kept', 'Kept on the server', 'qwen3-8b', pair('q', 'a'), { version: 1 }, T0)], - deleted: ['gone'], - }) - await openChat(page, [ - chatOf('gone', 'Deleted on the laptop', 'qwen3-8b', pair('old q', 'old a'), { version: 2 }, T0 + 5_000), - chatOf('kept', 'Kept on the server', 'qwen3-8b', pair('q', 'a'), { version: 1 }, T0), - chatOf('draft', 'Never synced draft', 'qwen3-8b', pair('draft q', 'draft a'), {}, T0 - 5_000), - ], 'gone') - - // The draft is uploaded: that is the save pass the deleted chat must not be in. - await expect.poll(() => server.saves('draft').length).toBe(1) - expect(server.saves('gone')).toHaveLength(0) - await expect.poll(() => storedIds(page)).toEqual(['kept', 'draft']) - - await openMenu(page) - await expect(page.getByTestId('chats-row').filter({ hasText: 'Deleted on the laptop' })).toHaveCount(0) - await expect(page.getByTestId('chats-row').filter({ hasText: 'Never synced draft' })).toHaveCount(1) - }) - - test('a synced chat the server no longer has is dropped, not resurrected', async ({ page }) => { - // Another account signed in on this browser, or an admin purge: the chat - // has a version, so it was on the server, and the server does not list it. - const server = await fakeHistoryServer(page, { conversations: [] }) - await openChat(page, [ - chatOf('synced', 'Synced earlier', 'qwen3-8b', pair('q', 'a'), { version: 3 }, T0), - chatOf('draft', 'Fresh draft', 'qwen3-8b', pair('q', 'a'), {}, T0 - 1_000), - ]) - await expect.poll(() => server.saves('draft').length).toBe(1) - expect(server.saves('synced')).toHaveLength(0) - // An empty server list is no reason to replace the server's set with - // this browser's cache. - expect(server.bulk()).toHaveLength(0) - await expect.poll(() => storedIds(page)).toEqual(['draft']) - }) - - test('a 410 on save drops the chat locally', async ({ page }) => { - // Deleted on another device after this tab loaded the list. - const server = await fakeHistoryServer(page, { hiddenDeleted: ['late'] }) - await openChat(page, [ - chatOf('late', 'Deleted meanwhile', 'qwen3-8b', pair('q', 'a'), {}, T0), - chatOf('other', 'Still here', 'qwen3-8b', pair('q', 'a'), {}, T0 - 1_000), - ]) - await expect.poll(() => server.saves('late').length).toBe(1) - await expect.poll(() => storedIds(page)).toEqual(['other']) - // Dropped, not retried. - await page.waitForTimeout(1_000) - expect(server.saves('late')).toHaveLength(1) - }) - - test('a message sent while the list is loading survives the older server copy', async ({ page }) => { - const server = await fakeHistoryServer(page, { - holdList: true, - conversations: [chatOf('x', 'Release notes', 'qwen3-8b', pair('first question', 'first answer'), { version: 4 }, T0)], - }) - await answerWith(page, 'Answer written during the probe') - await openChat(page, [chatOf('x', 'Release notes', 'qwen3-8b', pair('first question', 'first answer'), { version: 4 }, T0)]) - - await page.getByTestId('chat-input').fill('Question typed while loading') - await page.getByTestId('chat-input').press('Enter') - await expect(page.getByText('Answer written during the probe')).toBeVisible() - - server.release() - // The newer local copy wins and is uploaded with the new turn in it. - await expect.poll(() => server.saves('x').map(r => JSON.stringify(r.body.history)).join('\n')) - .toContain('Question typed while loading') - await expect(page.getByText('Question typed while loading')).toBeVisible() - await expect(page.getByText('Answer written during the probe')).toBeVisible() - }) - - test('a newer copy from the server replaces the older local one', async ({ page }) => { - const remote = chatOf('x', 'Release notes', 'qwen3-8b', - [...pair('first question', 'first answer'), ...pair('asked on the phone', 'answered on the phone')], { version: 5 }, T0 + 60_000) - const server = await fakeHistoryServer(page, { conversations: [remote] }) - await openChat(page, [chatOf('x', 'Release notes', 'qwen3-8b', pair('first question', 'first answer'), { version: 4 }, T0)]) - - await expect(page.getByText('answered on the phone')).toBeVisible() - // Adopting the server copy is not a change to send back. - await page.waitForTimeout(1_000) - expect(server.saves('x')).toHaveLength(0) - }) - - test('a delete made while the list is loading reaches the server and wins', async ({ page }) => { - const server = await fakeHistoryServer(page, { - holdList: true, - conversations: [ - chatOf('a', 'Keep me', 'qwen3-8b', pair('q', 'a'), { version: 1 }, T0), - chatOf('b', 'Delete me', 'qwen3-8b', pair('q', 'b'), { version: 1 }, T0 - 1_000), - ], - }) - await openChat(page, [ - chatOf('a', 'Keep me', 'qwen3-8b', pair('q', 'a'), { version: 1 }, T0), - chatOf('b', 'Delete me', 'qwen3-8b', pair('q', 'b'), { version: 1 }, T0 - 1_000), - ]) - - await openMenu(page) - const row = page.getByTestId('chats-row').filter({ hasText: 'Delete me' }) - await row.hover() - await row.getByTitle('Delete chat').click() - const toast = page.getByTestId('chat-undo-toast') - await toast.getByRole('button', { name: 'Dismiss' }).click() - await expect(toast).toHaveCount(0) - - server.release() - await expect.poll(() => server.deletes('b').length).toBe(1) - // The server still listed it when the answer left; the local delete wins. - await expect.poll(() => storedIds(page)).toEqual(['a']) - await expect(page.getByTestId('chats-row').filter({ hasText: 'Delete me' })).toHaveCount(0) - expect(server.saves('b')).toHaveLength(0) - }) - - test('the LocalAI Assistant toggle survives the trip to the server and back', async ({ page }) => { - const server = await fakeHistoryServer(page, { - conversations: [chatOf('remote', 'Assistant on the phone', 'qwen3-8b', pair('q', 'a'), { version: 1, localaiAssistant: true }, T0)], - }) - await openChat(page, [chatOf('local', 'Assistant draft', 'qwen3-8b', pair('q', 'a'), { localaiAssistant: true }, T0 + 1_000)]) - - await expect.poll(() => server.saves('local').length).toBe(1) - expect(server.saves('local')[0].body.localaiAssistant).toBe(true) - await expect.poll(async () => (await storedChats(page)).chats.map(c => [c.id, !!c.localaiAssistant])) - .toEqual([['local', true], ['remote', true]]) - }) -}) - -test.describe('Chat history belongs to the signed-in account', () => { - test.beforeEach(async ({ page }) => { - await mockChat(page) - }) - - test('a draft one account could not save is not uploaded into the next account on this browser', async ({ page }) => { - let who = ALICE - await signedInAs(page, () => who) - const server = await fakeAccountServer(page, () => who, { failSaves: [ALICE.id] }) - await answerWith(page, 'Answer meant for Alice') - - await page.goto('/app/chat') - await page.getByTestId('chat-composer').waitFor() - await page.getByTestId('chat-input').fill('Alice private question') - await page.getByTestId('chat-input').press('Enter') - await expect(page.getByText('Answer meant for Alice')).toBeVisible() - // The first save of Alice's chat does not get through. - await expect.poll(() => server.saves(ALICE.id).some(r => JSON.stringify(r.body).includes('Alice private question'))).toBe(true) - - // Alice signs out, Bob signs in on the same browser. His list is empty. - who = BOB - await page.goto('/app/chat') - await page.getByTestId('chat-composer').waitFor() - - // Bob's first save pass is the one Alice's draft must not be part of. - await expect.poll(() => server.saves(BOB.id).length).toBeGreaterThan(0) - await page.waitForTimeout(1_000) - expect(server.saves(BOB.id).filter(r => JSON.stringify(r.body).includes('Alice private'))).toHaveLength(0) - expect(server.liveTexts(BOB.id).join('\n')).not.toContain('Alice private') - await openMenu(page) - await expect(page.getByTestId('chats-row').filter({ hasText: 'Alice private question' })).toHaveCount(0) - }) - - test('the same account still gets its unsaved draft back and uploads it', async ({ page }) => { - let who = ALICE - await signedInAs(page, () => who) - const server = await fakeAccountServer(page, () => who, { failSaves: [ALICE.id] }) - await answerWith(page, 'Answer meant for Alice') - - await page.goto('/app/chat') - await page.getByTestId('chat-composer').waitFor() - await page.getByTestId('chat-input').fill('Alice private question') - await page.getByTestId('chat-input').press('Enter') - await expect(page.getByText('Answer meant for Alice')).toBeVisible() - await expect.poll(() => server.saves(ALICE.id).length).toBeGreaterThan(0) - - // Bob uses the browser in between; then Alice signs in again and the - // server takes writes again. - who = BOB - await page.goto('/app/chat') - await page.getByTestId('chat-composer').waitFor() - await expect.poll(() => server.saves(BOB.id).length).toBeGreaterThan(0) - - who = ALICE - server.recover(ALICE.id) - await page.goto('/app/chat') - await page.getByTestId('chat-composer').waitFor() - await expect.poll(() => server.liveTexts(ALICE.id).join('\n')).toContain('Alice private question') - await openMenu(page) - await expect(page.getByTestId('chats-row').filter({ hasText: 'Alice private question' })).toHaveCount(1) - }) -}) - -test.describe('Deletes stay pending until the server confirms them', () => { - const remote = () => [ - chatOf('a', 'Keep me', 'qwen3-8b', pair('q', 'a'), { version: 1 }, T0), - chatOf('b', 'Delete me', 'qwen3-8b', pair('q', 'b'), { version: 1 }, T0 - 1_000), - ] - - test.beforeEach(async ({ page }) => { - await mockChat(page) - await signedInAs(page, () => ALICE) - }) - - // Opens Chat with nothing in the browser and waits until the list has been - // reconciled: the empty chat Chat starts with is uploaded only after that. - async function openSynced(page, server) { - await page.goto('/app/chat') - await page.getByTestId('chat-composer').waitFor() - await expect.poll(() => server.saves().length).toBeGreaterThan(0) - await openMenu(page) - await expect(page.getByTestId('chats-row').filter({ hasText: 'Delete me' })).toHaveCount(1) - } - - async function deleteRow(page, name) { - const row = page.getByTestId('chats-row').filter({ hasText: name }) - await row.hover() - await row.getByTitle('Delete chat').click() - const toast = page.getByTestId('chat-undo-toast') - await toast.getByRole('button', { name: 'Dismiss' }).click() - await expect(toast).toHaveCount(0) - } - - async function reopen(page) { - await page.goto('/app/chat') - await page.getByTestId('chat-composer').waitFor() - await openMenu(page) - } - - test('a delete made while the server is unreachable is sent later and the chat does not come back', async ({ page, context }) => { - const server = await fakeHistoryServer(page, { conversations: remote() }) - await openSynced(page, server) - - server.setDown(true) - await context.setOffline(true) - await deleteRow(page, 'Delete me') - await expect(page.getByTestId('chats-row').filter({ hasText: 'Delete me' })).toHaveCount(0) - await page.waitForTimeout(500) - - server.setDown(false) - await context.setOffline(false) - await reopen(page) - await expect.poll(() => server.liveIds().filter(id => id === 'a' || id === 'b')).toEqual(['a']) - await expect(page.getByTestId('chats-row').filter({ hasText: 'Keep me' })).toHaveCount(1) - await expect(page.getByTestId('chats-row').filter({ hasText: 'Delete me' })).toHaveCount(0) - }) - - test('"clear all" made while the server is unreachable is sent later and nothing comes back', async ({ page, context }) => { - const server = await fakeHistoryServer(page, { conversations: remote() }) - await openSynced(page, server) - - server.setDown(true) - await context.setOffline(true) - await page.getByRole('button', { name: 'Clear all' }).click() - await page.getByRole('button', { name: 'Delete all' }).click() - await page.waitForTimeout(500) - - server.setDown(false) - await context.setOffline(false) - await reopen(page) - await expect.poll(() => server.liveIds().filter(id => id === 'a' || id === 'b')).toEqual([]) - await expect(page.getByTestId('chats-row').filter({ hasText: 'Keep me' })).toHaveCount(0) - await expect(page.getByTestId('chats-row').filter({ hasText: 'Delete me' })).toHaveCount(0) - }) - - test('clear all persists before navigation can cancel the debounced save', async ({ page }) => { - const server = await fakeHistoryServer(page) - server.setDown(true) - // Two chats: with a single chat the menu offers no Clear all. - await openChat(page, [ - chatOf('draft', 'Unsynced draft', 'qwen3-8b', pair('private draft', 'answer'), {}, T0), - chatOf('other', 'Other draft', 'qwen3-8b', pair('second draft', 'answer'), {}, T0 - 1000), - ]) - await openMenu(page) - // Freeze the debounce: assert the cache produced by the delete itself. - // The fake clock runs from install on, so a pause at "now" is already in - // the past on a slow runner. Pause a little ahead; timers due before the - // delete only save the chats that are open, which is harmless. - const t0 = Date.now() - await page.clock.install({ time: t0 }) - await page.clock.pauseAt(t0 + 5_000) - await page.getByRole('button', { name: 'Clear all' }).click() - await page.getByRole('button', { name: 'Delete all' }).click() - const stored = await storedChats(page) - expect(stored.chats.map(c => c.id)).not.toContain('draft') - expect(stored.chats.map(c => c.id)).not.toContain('other') - expect(stored.pendingDeletes).toEqual(['*']) - - await page.clock.resume() - server.setDown(false) - await reopen(page) - await expect.poll(() => server.deleteAlls().length).toBe(1) - await page.waitForTimeout(1_000) - expect(server.saves('draft')).toHaveLength(0) - expect(server.saves('other')).toHaveLength(0) - await expect(page.getByTestId('chats-row').filter({ hasText: 'Unsynced draft' })).toHaveCount(0) - }) - - test('a delete the server confirmed is not sent again and does not come back', async ({ page }) => { - const server = await fakeHistoryServer(page, { conversations: remote() }) - await openSynced(page, server) - - await deleteRow(page, 'Delete me') - await expect.poll(() => server.deletes('b').length).toBe(1) - expect(server.liveIds()).not.toContain('b') - // The server has the request; wait until the page has its answer too. - // Reopening earlier leaves the delete unconfirmed, and it is rightly sent again. - await expect.poll(async () => (await storedChats(page)).pendingDeletes).toEqual([]) - - await reopen(page) - await expect(page.getByTestId('chats-row').filter({ hasText: 'Keep me' })).toHaveCount(1) - await expect(page.getByTestId('chats-row').filter({ hasText: 'Delete me' })).toHaveCount(0) - await page.waitForTimeout(1_000) - expect(server.deletes('b')).toHaveLength(1) - }) -}) - -test.describe('Chat history from Home and without an account', () => { - test('a delete on the Home page reaches the server through Chat', async ({ page }) => { - await mockChat(page) - await signedInAs(page, () => ALICE) - const server = await fakeHistoryServer(page, { - conversations: [ - chatOf('a', 'Keep me', 'qwen3-8b', pair('q', 'kept answer'), { version: 1 }, T0), - chatOf('b', 'Delete me', 'qwen3-8b', pair('q', 'deleted answer'), { version: 1 }, T0 - 1_000), - ], - }) - await page.addInitScript(([key, data]) => { - if (!localStorage.getItem(key)) localStorage.setItem(key, JSON.stringify(data)) - }, [keyOf(ALICE), { - chats: [ - chatOf('a', 'Keep me', 'qwen3-8b', pair('q', 'kept answer'), { version: 1 }, Date.now()), - chatOf('b', 'Delete me', 'qwen3-8b', pair('q', 'deleted answer'), { version: 1 }, Date.now() - 1_000), - ], - activeChatId: 'a', - }]) - - await page.goto('/app') - const row = page.getByTestId('home-conversation').filter({ hasText: 'Delete me' }) - await row.hover() - await row.getByRole('button', { name: /delete conversation/i }).click() - await expect(page.getByTestId('home-undo-toast')).toBeVisible() - // Resuming another chat makes the delete final and opens Chat. - await page.getByTestId('home-conversation').filter({ hasText: 'Keep me' }).locator('.home-row__open').click() - await expect(page).toHaveURL(/\/app\/chat/) - - await expect.poll(() => server.deletes('b').length).toBe(1) - await openMenu(page) - await expect(page.getByTestId('chats-row').filter({ hasText: 'Keep me' })).toHaveCount(1) - await expect(page.getByTestId('chats-row').filter({ hasText: 'Delete me' })).toHaveCount(0) - }) - - test('nobody signed in: chats stay in the browser and nothing is sent', async ({ page }) => { - await mockChat(page) - const server = await fakeHistoryServer(page, {}) - await page.addInitScript(([key, data]) => { - if (!localStorage.getItem(key)) localStorage.setItem(key, JSON.stringify(data)) - }, [CHATS_KEY, { chats: [chatOf('local', 'Browser only', 'qwen3-8b', pair('q', 'a'), {}, T0)], activeChatId: 'local' }]) - await page.goto('/app/chat') - await page.getByTestId('chat-composer').waitFor() - await openMenu(page) - await expect(page.getByTestId('chats-row').filter({ hasText: 'Browser only' })).toHaveCount(1) - await page.waitForTimeout(1_000) - expect(server.saves()).toHaveLength(0) - }) -}) diff --git a/core/http/react-ui/e2e/chat-history.spec.js b/core/http/react-ui/e2e/chat-history.spec.js deleted file mode 100644 index 568cd177c..000000000 --- a/core/http/react-ui/e2e/chat-history.spec.js +++ /dev/null @@ -1,217 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { mockChat, openChat, storedChats, chatOf, pair } from './chat-fixtures.js' -import { sampleChats } from './home-fixtures.js' - -// The conversations list opens on Ctrl K. It replaces the permanent history -// pane: groups by day like the Home list, search, resume, rename, delete with -// undo, and the same duplicate, copy and export actions as before. - -const menu = (page) => page.getByTestId('chats-menu') -const rows = (page) => menu(page).getByTestId('chats-row') - -async function openMenu(page) { - await expect(page.getByTestId('chats-trigger')).toBeVisible() - await page.keyboard.press('Control+k') - await expect(menu(page)).toBeVisible() - // The search box takes focus a moment after the menu opens; keys need it. - await expect(menu(page).getByRole('combobox')).toBeFocused() -} - -test.describe('Chat conversations menu', () => { - const now = new Date('2026-10-09T12:00:00Z').getTime() - test.beforeEach(async ({ page }) => { - await page.clock.setFixedTime(now) - await mockChat(page, { models: [ - { id: 'qwen3-8b-instruct', capabilities: ['FLAG_CHAT'] }, - { id: 'gemma-4-e4b-it-qat-q4_0', capabilities: ['FLAG_CHAT'] }, - { id: 'qwen2.5-vl-7b-instruct', capabilities: ['FLAG_CHAT'] }, - ], loaded: ['qwen3-8b-instruct'] }) - await openChat(page, sampleChats(now)) - await expect(page.getByTestId('chat-message').first()).toBeVisible() - }) - - test('opens on Ctrl K, closes on Esc and gives focus back', async ({ page }) => { - const composer = page.getByTestId('chat-input') - await composer.focus() - await openMenu(page) - await expect(menu(page).getByRole('combobox')).toBeFocused() - await page.keyboard.press('Escape') - await expect(menu(page)).toHaveCount(0) - await expect(composer).toBeFocused() - // The button opens it too, and Ctrl K closes it again. - await page.getByTestId('chats-trigger').click() - await expect(menu(page)).toBeVisible() - await page.keyboard.press('Control+k') - await expect(menu(page)).toHaveCount(0) - }) - - test('groups the chats by day, newest first, with the model and the time', async ({ page }) => { - await openMenu(page) - await expect(page.getByTestId('chats-day-today').getByRole('heading')).toHaveText('Today') - await expect(page.getByTestId('chats-day-yesterday').getByRole('heading')).toHaveText('Yesterday') - await expect(page.getByTestId('chats-day-week').getByRole('heading')).toHaveText('Earlier this week') - await expect(page.getByTestId('chats-day-today').getByTestId('chats-row')).toHaveCount(2) - await expect(rows(page).first()).toContainText('Rewrite the 3.4 release notes for clarity') - await expect(rows(page).first()).toContainText('qwen3-8b-instruct') - // The line under the name is the last thing said. - await expect(rows(page).first()).toContainText('Anytime.') - // The open chat is marked. - await expect(rows(page).first()).toHaveAttribute('data-current', 'true') - }) - - test('search narrows by name and by what was said, and says when nothing matches', async ({ page }) => { - await openMenu(page) - const search = menu(page).getByRole('combobox') - await search.fill('regex') - await expect(rows(page)).toHaveCount(1) - await expect(rows(page).first()).toContainText('Regex for semver') - // A word that only appears inside a message. - await search.fill('accounting') - await expect(rows(page)).toHaveCount(1) - await expect(rows(page).first()).toContainText('Draft a polite reply about the invoice') - await expect(rows(page).first()).toContainText('accounting') - await search.fill('zzzz') - await expect(rows(page)).toHaveCount(0) - await expect(menu(page)).toContainText('No conversations match your search') - }) - - test('arrow keys move and Enter resumes that conversation', async ({ page }) => { - await openMenu(page) - await page.keyboard.press('ArrowDown') - await expect(rows(page).nth(1)).toHaveAttribute('aria-selected', 'true') - await page.keyboard.press('Enter') - await expect(menu(page)).toHaveCount(0) - await expect(page.getByTestId('chat-title')).toHaveText('Why does llama-cpp stall at a 4096 context') - await expect.poll(async () => (await storedChats(page)).activeChatId).toBe('c-today-b') - }) - - test('clicking a row resumes it', async ({ page }) => { - await openMenu(page) - await rows(page).filter({ hasText: 'Regex for semver' }).click() - await expect(page.getByTestId('chat-title')).toHaveText('Regex for semver with prerelease tags') - }) - - test('F2 renames in place and Enter saves', async ({ page }) => { - await openMenu(page) - await page.keyboard.press('F2') - const box = menu(page).getByRole('textbox', { name: 'Rename' }) - await expect(box).toBeFocused() - await box.fill('Release notes 3.4') - await box.press('Enter') - await expect(rows(page).first()).toContainText('Release notes 3.4') - await expect.poll(async () => (await storedChats(page)).chats.find(c => c.id === 'c-today-a').name).toBe('Release notes 3.4') - // The header shows it too. - await page.keyboard.press('Escape') - await expect(page.getByTestId('chat-title')).toHaveText('Release notes 3.4') - }) - - test('Escape in the rename box cancels and keeps the menu open', async ({ page }) => { - await openMenu(page) - await page.keyboard.press('F2') - await menu(page).getByRole('textbox', { name: 'Rename' }).fill('Nope') - await page.keyboard.press('Escape') - await expect(menu(page)).toBeVisible() - await expect(rows(page).first()).toContainText('Rewrite the 3.4 release notes for clarity') - }) - - test('the title in the header renames the chat with a click', async ({ page }) => { - await page.getByTestId('chat-title').click() - const box = page.getByTestId('chat-title-input') - await expect(box).toBeFocused() - await box.fill('Clearer notes') - await box.press('Enter') - await expect(page.getByTestId('chat-title')).toHaveText('Clearer notes') - await expect.poll(async () => (await storedChats(page)).chats.find(c => c.id === 'c-today-a').name).toBe('Clearer notes') - }) - - test('Delete hides the row and offers an undo; Undo brings it back', async ({ page }) => { - await openMenu(page) - const target = rows(page).filter({ hasText: 'Regex for semver' }) - await target.hover() - await page.keyboard.press('Delete') - await expect(rows(page).filter({ hasText: 'Regex for semver' })).toHaveCount(0) - const toast = page.getByTestId('chat-undo-toast') - await expect(toast).toContainText('Deleted "Regex for semver with prerelease tags"') - // Until the undo time ends the chat is still stored. - expect((await storedChats(page)).chats.map(c => c.id)).toContain('c-week-a') - - await toast.getByRole('button', { name: 'Undo' }).click() - await expect(toast).toHaveCount(0) - await expect(rows(page).filter({ hasText: 'Regex for semver' })).toHaveCount(1) - expect((await storedChats(page)).chats.map(c => c.id)).toContain('c-week-a') - }) - - test('the chat is deleted for good when the undo time ends', async ({ page }) => { - await openMenu(page) - const row = rows(page).filter({ hasText: 'Regex for semver' }) - await row.hover() - await row.getByTitle('Delete chat').click() - const toast = page.getByTestId('chat-undo-toast') - await expect(toast).toBeVisible() - // Dismissing the toast ends the undo time at once. - await toast.getByRole('button', { name: 'Dismiss' }).click() - await expect(toast).toHaveCount(0) - await expect.poll(async () => (await storedChats(page)).chats.map(c => c.id)).not.toContain('c-week-a') - }) - - test('the undo time runs out by itself after six seconds', async ({ page }) => { - await page.clock.install({ time: now }) - await page.reload() - await page.getByTestId('chat-composer').waitFor() - await openMenu(page) - const row = rows(page).filter({ hasText: 'Regex for semver' }) - await row.hover() - await row.getByTitle('Delete chat').click() - await expect(page.getByTestId('chat-undo-toast')).toBeVisible() - await page.clock.runFor(6500) - await expect(page.getByTestId('chat-undo-toast')).toHaveCount(0) - await expect.poll(async () => (await storedChats(page)).chats.map(c => c.id)).not.toContain('c-week-a') - }) - - test('deleting the open chat opens another one, and Undo returns to it', async ({ page }) => { - await openMenu(page) - await page.keyboard.press('Delete') - await expect(page.getByTestId('chat-undo-toast')).toBeVisible() - await expect(page.getByTestId('chat-title')).not.toHaveText('Rewrite the 3.4 release notes for clarity') - await page.getByTestId('chat-undo-toast').getByRole('button', { name: 'Undo' }).click() - await expect(page.getByTestId('chat-title')).toHaveText('Rewrite the 3.4 release notes for clarity') - }) - - test('Duplicate, Copy chat and Export are on every row', async ({ page }) => { - await openMenu(page) - const row = rows(page).first() - await expect(row.getByTitle('Duplicate chat')).toBeVisible() - await expect(row.getByTitle('Copy chat')).toBeVisible() - await expect(row.getByTitle('Export as Markdown')).toBeVisible() - await expect(row.getByTitle('Rename')).toBeVisible() - await expect(row.getByTitle('Delete chat')).toBeVisible() - }) - - test('New chat from the menu opens an empty chat', async ({ page }) => { - await openMenu(page) - await page.getByTestId('chats-new').click() - await expect(menu(page)).toHaveCount(0) - await expect(page.getByTestId('chat-empty')).toBeVisible() - }) - - test('Clear all asks before it removes anything', async ({ page }) => { - await openMenu(page) - await menu(page).getByRole('button', { name: 'Clear all' }).click() - const dialog = page.getByRole('alertdialog') - await expect(dialog).toContainText('Delete all chats?') - await dialog.getByRole('button', { name: 'Delete all' }).click() - await expect.poll(async () => (await storedChats(page)).chats.length).toBe(1) - await expect(page.getByTestId('chat-empty')).toBeVisible() - }) -}) - -test.describe('Chat conversations menu with one chat', () => { - test('has no delete button and no Clear all', async ({ page }) => { - await mockChat(page) - await openChat(page, [chatOf('only', 'The only chat', 'qwen3-8b', pair('hi', 'hello'))]) - await openMenu(page) - await expect(rows(page)).toHaveCount(1) - await expect(rows(page).first().getByTitle('Delete chat')).toHaveCount(0) - await expect(menu(page).getByRole('button', { name: 'Clear all' })).toHaveCount(0) - }) -}) diff --git a/core/http/react-ui/e2e/chat-model-loading.spec.js b/core/http/react-ui/e2e/chat-model-loading.spec.js deleted file mode 100644 index 9a48a0cf8..000000000 --- a/core/http/react-ui/e2e/chat-model-loading.spec.js +++ /dev/null @@ -1,88 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' - -// A model that is cold-loading onto a worker answers 503 with a `loading` -// object rather than an error. The chat must show that as progress and retry -// itself once the load finishes — the whole point of the change: an operator -// watching a 35 GB model stage normally used to see only repeated failures. -test.describe('Chat - model loading', () => { - test('renders staging progress on 503 and retries when the model is ready', async ({ page }) => { - await page.route('**/api/models/capabilities', (route) => { - route.fulfill({ - contentType: 'application/json', - body: JSON.stringify({ data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }] }), - }) - }) - - let completions = 0 - await page.route('**/v1/chat/completions', (route) => { - completions++ - if (completions === 1) { - route.fulfill({ - status: 503, - contentType: 'application/json', - headers: { 'Retry-After': '5' }, - body: JSON.stringify({ - error: { - message: 'model test-model is staging on node nvidia-thor (41%)', - type: 'model_loading', - code: 'model_loading', - }, - loading: { - model: 'test-model', - state: 'staging', - node: 'nvidia-thor', - progress: 41.2, - bytes_sent: 14730000000, - total_bytes: 35776484480, - file_index: 1, - total_files: 2, - eta_seconds: 660, - }, - }), - }) - return - } - route.fulfill({ - status: 200, - contentType: 'text/event-stream', - body: - 'data: {"choices":[{"delta":{"content":"loaded at last"}}]}\n\n' + - 'data: [DONE]\n\n', - }) - }) - - // First poll still staging, then the job is gone: the model is ready. - let polls = 0 - await page.route('**/api/models/*/load-status', (route) => { - polls++ - if (polls === 1) { - route.fulfill({ - contentType: 'application/json', - body: JSON.stringify({ - model: 'test-model', state: 'staging', node: 'nvidia-thor', - progress: 62.5, bytes_sent: 22000000000, total_bytes: 35776484480, - file_index: 2, total_files: 2, eta_seconds: 300, - }), - }) - return - } - route.fulfill({ status: 404, contentType: 'application/json', body: '{}' }) - }) - - await page.goto('/app/chat') - await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - - await page.getByTestId('chat-input').fill('Hello') - await page.getByTestId('chat-send').click() - - // The 503 is shown as progress, not as an error. - await expect(page.getByTestId('chat-load')).toBeVisible({ timeout: 10_000 }) - await expect(page.getByTestId('chat-load-phase')).toContainText('nvidia-thor') - await expect(page.getByTestId('chat-load-pct')).toContainText('41%') - await expect(page.getByTestId('chat-load')).toContainText('13.7 of 33.3 GB') - - // ...and the request retries itself once the load reports ready. - await expect(page.getByText('loaded at last')).toBeVisible({ timeout: 25_000 }) - expect(completions).toBeGreaterThan(1) - }) -}) diff --git a/core/http/react-ui/e2e/chat-model-switcher.spec.js b/core/http/react-ui/e2e/chat-model-switcher.spec.js deleted file mode 100644 index 2360448fc..000000000 --- a/core/http/react-ui/e2e/chat-model-switcher.spec.js +++ /dev/null @@ -1,145 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { chatOf, pair, mockChat, openChat, storedChats, RESOURCES_24GB } from './chat-fixtures.js' - -// The model chip lists the chat models: loaded ones first, a dot for warm or -// not loaded, an eye for vision, and, where the server can estimate, whether -// the model fits this machine. Nothing is shown that the API does not report. - -const GB = 1024 * 1024 * 1024 -const NEED_GB = { 'qwen3-8b': 5.1, 'gemma-3-12b': 7.6, 'phi-4-mini': 2.5, 'llama-70b': 42.5, 'deepseek-32b': 19.5 } - -const MODELS = [ - { id: 'qwen3-8b', capabilities: ['FLAG_CHAT'] }, - { id: 'gemma-3-12b', capabilities: ['FLAG_CHAT', 'FLAG_VISION'] }, - { id: 'phi-4-mini', capabilities: ['FLAG_CHAT'] }, - { id: 'deepseek-32b', capabilities: ['FLAG_CHAT'] }, - { id: 'llama-70b', capabilities: ['FLAG_CHAT'] }, - { id: 'mystery-model', capabilities: ['FLAG_CHAT'] }, -] - -async function stubEstimates(page) { - const asked = [] - await page.route('**/api/models/vram-estimate', (route) => { - const body = route.request().postDataJSON() - asked.push(body) - const need = NEED_GB[body.model] - // A model the server cannot read answers 200 with no figure. - if (!need) return route.fulfill({ json: { message: 'no estimate for this format' } }) - return route.fulfill({ json: { vram_bytes: need * GB, size_bytes: need * 0.9 * GB } }) - }) - return asked -} - -const chip = (page) => page.getByTestId('home-model-chip') -const row = (page, name) => page.getByRole('option').filter({ has: page.locator('code', { hasText: new RegExp(`^${name}$`) }) }) - -test.describe('Chat model switcher', () => { - test.beforeEach(async ({ page }) => { - await mockChat(page, { models: MODELS, loaded: ['qwen3-8b', 'phi-4-mini'], resources: RESOURCES_24GB }) - }) - - test('groups loaded and installed models and says warm or not loaded', async ({ page }) => { - await stubEstimates(page) - await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', pair('hi', 'hello'), { contextSize: 8192 })]) - await chip(page).click() - const heads = page.locator('.home-menu__head') - await expect(heads).toHaveText(['Loaded now', 'Installed']) - await expect(row(page, 'qwen3-8b')).toContainText('warm') - await expect(row(page, 'phi-4-mini')).toContainText('warm') - await expect(row(page, 'gemma-3-12b')).toContainText('not loaded') - // Loaded models come first. - const names = await page.getByRole('option').locator('code').allTextContents() - expect(names.slice(0, 2).sort()).toEqual(['phi-4-mini', 'qwen3-8b']) - // The chip's dot follows the loaded state. - await expect(chip(page).locator('.home-dot')).not.toHaveClass(/home-dot--cold/) - }) - - test('marks the models that understand images', async ({ page }) => { - await stubEstimates(page) - await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', [])]) - await chip(page).click() - await expect(row(page, 'gemma-3-12b').getByRole('img', { name: 'Understands images' })).toBeVisible() - await expect(row(page, 'phi-4-mini').getByRole('img', { name: 'Understands images' })).toHaveCount(0) - }) - - test('reads memory and estimates once the list opens, then shows what fits', async ({ page }) => { - const asked = await stubEstimates(page) - await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', [], { contextSize: 16384 })]) - // Nothing is asked until the list opens. - await page.waitForTimeout(300) - expect(asked).toHaveLength(0) - - await chip(page).click() - // Warm models say they are ready; a cold one that fits says how much room is left. - await expect(row(page, 'qwen3-8b')).toContainText('ready now') - await expect(row(page, 'gemma-3-12b')).toContainText('GB free') - await expect(row(page, 'gemma-3-12b')).toContainText('7.6 GB') - // 19.5 GB on a 24 GB card with a 22.8 GB limit fits with 3.3 left. - await expect(row(page, 'deepseek-32b')).toContainText('3.3 GB free') - // 42.5 GB does not fit the card; the rest would run on the CPU (RAM is 40 GB). - await expect(row(page, 'llama-70b')).toContainText('runs on the CPU') - // The estimates use this chat's context size. - expect(asked.every(b => b.context_size === 16384)).toBe(true) - // The memory bar closes the list. - await expect(page.getByTestId('chat-model-memory')).toContainText('7.6 of 24 GB used') - }) - - test('says how far over the machine a model is when it cannot run at all', async ({ page }) => { - await page.route('**/api/resources', route => route.fulfill({ json: { - ...RESOURCES_24GB, ram: { total: 16 * GB, used: 10 * GB, available: 6 * GB }, - } })) - await stubEstimates(page) - await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', [])]) - await chip(page).click() - await expect(row(page, 'llama-70b')).toContainText('more than this machine has') - }) - - test('a model with no estimate shows no fit text and no invented numbers', async ({ page }) => { - await stubEstimates(page) - await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', [])]) - await chip(page).click() - await expect(row(page, 'deepseek-32b')).toContainText('GB free') - const mystery = row(page, 'mystery-model') - await expect(mystery).toContainText('not loaded') - await expect(mystery).not.toContainText('GB') - await expect(mystery).not.toContainText('free') - }) - - test('picking a model changes this chat and remembers it', async ({ page }) => { - await stubEstimates(page) - await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', [])]) - await chip(page).click() - await row(page, 'gemma-3-12b').click() - await expect(chip(page)).toContainText('gemma-3-12b') - await expect(page.getByRole('option')).toHaveCount(0) - await expect.poll(async () => (await storedChats(page)).chats[0].model).toBe('gemma-3-12b') - // Cold now: the dot is hollow. - await expect(chip(page).locator('.home-dot')).toHaveClass(/home-dot--cold/) - }) - - test('/model opens the list and Esc closes it', async ({ page }) => { - await stubEstimates(page) - await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', pair('hi', 'hello'))]) - await page.getByTestId('chat-input').fill('/model') - await page.getByTestId('chat-input').press('Enter') - await expect(page.getByRole('listbox', { name: 'Model' })).toBeVisible() - await page.keyboard.press('ArrowDown') - await page.keyboard.press('Escape') - await expect(page.getByRole('listbox', { name: 'Model' })).toHaveCount(0) - await expect(chip(page)).toBeFocused() - }) - - test('on a phone the list is a sheet from the bottom', async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }) - await stubEstimates(page) - await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', pair('hi', 'hello'))]) - await chip(page).click() - const menu = page.locator('.home-menu--models') - await expect(menu).toBeVisible() - const box = await menu.boundingBox() - expect(box.x).toBeGreaterThanOrEqual(0) - expect(box.x + box.width).toBeLessThanOrEqual(390) - expect(box.y + box.height).toBeLessThanOrEqual(844) - expect(await menu.evaluate(el => getComputedStyle(el).position)).toBe('fixed') - }) -}) diff --git a/core/http/react-ui/e2e/chat-pdf-attachment.spec.js b/core/http/react-ui/e2e/chat-pdf-attachment.spec.js deleted file mode 100644 index d42696222..000000000 --- a/core/http/react-ui/e2e/chat-pdf-attachment.spec.js +++ /dev/null @@ -1,134 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' - -// Single-page PDF with a real text layer, built byte by byte so the xref -// offsets are valid and the spec needs no binary fixture. -function buildPdf(text, { stream: rawStream } = {}) { - const stream = rawStream ?? `BT /F1 18 Tf 20 100 Td (${text}) Tj ET` - const objs = [ - '<< /Type /Catalog /Pages 2 0 R >>', - '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', - '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 200] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>', - `<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`, - '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', - ] - let out = '%PDF-1.4\n' - const offsets = [] - objs.forEach((body, i) => { - offsets.push(out.length) - out += `${i + 1} 0 obj\n${body}\nendobj\n` - }) - const xref = out.length - out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n` - for (const o of offsets) out += `${String(o).padStart(10, '0')} 00000 n \n` - out += `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n` - return Buffer.from(out, 'latin1') -} - -async function openChat(page) { - await page.route('**/api/models/capabilities', (route) => { - route.fulfill({ - contentType: 'application/json', - body: JSON.stringify({ data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }] }), - }) - }) - await page.goto('/app/chat') - await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) -} - -test.describe('Chat - PDF attachments', () => { - test('sends the extracted text layer, not the raw PDF bytes', async ({ page }) => { - let requestBody = '' - await page.route('**/v1/chat/completions', (route) => { - requestBody = route.request().postData() || '' - route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: { message: 'stop' } }) }) - }) - await openChat(page) - - await page.locator('input[type=file][accept*="pdf"]').setInputFiles({ - name: 'report.pdf', - mimeType: 'application/pdf', - buffer: buildPdf('Quarterly revenue grew 42 percent'), - }) - // pdf.js and its worker load on first use, so the tag can take a while. - await expect(page.locator('.home-file-tag', { hasText: 'report.pdf' })).toBeVisible({ timeout: 10_000 }) - - await page.getByTestId('chat-input').fill('Summarize') - await page.getByTestId('chat-send').click() - - await expect.poll(() => requestBody).toContain('Quarterly revenue grew 42 percent') - expect(requestBody).toContain('File: report.pdf') - expect(requestBody).not.toContain('%PDF') - }) - - test('rejects a PDF that cannot be parsed instead of attaching garbage', async ({ page }) => { - await openChat(page) - - await page.locator('input[type=file][accept*="pdf"]').setInputFiles({ - name: 'broken.pdf', - mimeType: 'application/pdf', - buffer: Buffer.from('%PDF-1.4 this is not a real document'), - }) - - await expect(page.getByText('Could not read text from broken.pdf')).toBeVisible({ timeout: 10_000 }) - await expect(page.locator('.home-file-tag', { hasText: 'broken.pdf' })).toHaveCount(0) - }) -}) - -// A page that only paints a filled rectangle: what a scan looks like to pdf.js -// (an image, no text layer). -const scannedPdf = () => buildPdf('', { stream: '0 0 1 rg 20 20 200 120 re f' }) - -test.describe('Chat - scanned PDF attachments', () => { - test('sends the pages of a PDF without text layer as images', async ({ page }) => { - let requestBody = '' - await page.route('**/v1/chat/completions', (route) => { - requestBody = route.request().postData() || '' - route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: { message: 'stop' } }) }) - }) - await openChat(page) - - await page.locator('input[type=file][accept*="pdf"]').setInputFiles({ - name: 'scan.pdf', mimeType: 'application/pdf', buffer: scannedPdf(), - }) - await expect(page.getByText('scan.pdf has no text layer')).toBeVisible({ timeout: 10_000 }) - await expect(page.locator('.home-file-tag', { hasText: 'scan-page-1.png' })).toBeVisible() - - await page.getByTestId('chat-input').fill('What does this say?') - await page.getByTestId('chat-send').click() - - await expect.poll(() => requestBody).toContain('data:image/png;base64,') - expect(requestBody).not.toContain('%PDF') - }) - - test('still rejects a PDF that cannot be parsed at all', async ({ page }) => { - await openChat(page) - await page.locator('input[type=file][accept*="pdf"]').setInputFiles({ - name: 'broken.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4 this is not a real document'), - }) - await expect(page.getByText('Could not read text from broken.pdf')).toBeVisible({ timeout: 10_000 }) - await expect(page.locator('.home-file-tag', { hasText: 'broken' })).toHaveCount(0) - }) -}) - -test.describe('Home - PDF attachments', () => { - test('attaches a PDF that has a text layer', async ({ page }) => { - await page.goto('/app') - await page.locator('input[type=file][accept*="pdf"]').setInputFiles({ - name: 'notes.pdf', - mimeType: 'application/pdf', - buffer: buildPdf('Meeting notes for Tuesday'), - }) - await expect(page.locator('.home-file-tag', { hasText: 'notes.pdf' })).toBeVisible({ timeout: 10_000 }) - }) - - test('rejects a PDF that cannot be parsed', async ({ page }) => { - await page.goto('/app') - await page.locator('input[type=file][accept*="pdf"]').setInputFiles({ - name: 'broken.pdf', - mimeType: 'application/pdf', - buffer: Buffer.from('%PDF-1.4 this is not a real document'), - }) - await expect(page.getByText('Could not read text from broken.pdf')).toBeVisible({ timeout: 10_000 }) - await expect(page.locator('.home-file-tag')).toHaveCount(0) - }) -}) diff --git a/core/http/react-ui/e2e/chat-polling-selection.spec.js b/core/http/react-ui/e2e/chat-polling-selection.spec.js deleted file mode 100644 index ae40c74f7..000000000 --- a/core/http/react-ui/e2e/chat-polling-selection.spec.js +++ /dev/null @@ -1,143 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' - -// Regression coverage for issue #9904: -// - /api/operations was polled every 1s and *always* re-rendered the Chat -// page, even when the response was unchanged. The reconciliation would -// collapse any text selection inside an assistant message. -// - The copy button next to each assistant message used navigator.clipboard -// without any fallback, which is undefined when the page is served over -// plain http (non-secure context) from a remote host. - -async function setupChatPage(page) { - await page.route('**/api/models/capabilities', (route) => { - route.fulfill({ - contentType: 'application/json', - body: JSON.stringify({ - data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }], - }), - }) - }) - - // Poll-tracking mock: assert the hook is hammering /api/operations every - // ~1s, and always return an empty list so its contents never change. - let operationsHits = 0 - await page.route('**/api/operations', (route) => { - operationsHits++ - route.fulfill({ - contentType: 'application/json', - body: JSON.stringify({ operations: [] }), - }) - }) - - await page.route('**/v1/chat/completions', (route) => { - // One short SSE stream so the chat finishes streaming quickly and we - // can interact with a stable assistant message. - const body = [ - 'data: {"choices":[{"delta":{"content":"Hello world this is a long assistant reply that we can try to select."},"index":0}]}\n\n', - 'data: {"choices":[{"delta":{},"index":0,"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}\n\n', - 'data: [DONE]\n\n', - ].join('') - route.fulfill({ - status: 200, - headers: { 'Content-Type': 'text/event-stream' }, - body, - }) - }) - - return { getOperationsHits: () => operationsHits } -} - -test.describe('Chat - /api/operations polling (#9904)', () => { - test('text selection inside an assistant message survives polling', async ({ page }) => { - const { getOperationsHits } = await setupChatPage(page) - - await page.goto('/app/chat') - await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - - await page.getByTestId('chat-input').fill('Hi') - await page.getByTestId('chat-send').click() - - const assistantContent = page.locator('[data-role="assistant"] .cx-prose').first() - await expect(assistantContent).toContainText('Hello world', { timeout: 10_000 }) - - // Sanity check: the polling we're regressing against is actually firing. - await page.waitForTimeout(2_500) - expect(getOperationsHits()).toBeGreaterThan(1) - - // Sanity check that the bug we're guarding against is structurally - // possible: count how many times the assistant content node gets - // *touched* by React (childList / characterData mutations) over a - // 3-second window. Before the fix, every poll re-rendered Chat and - // re-set dangerouslySetInnerHTML, triggering a mutation cascade that - // collapsed the user's text selection. After the fix, polling with - // identical contents must not mutate the DOM at all. - const mutationCount = await assistantContent.evaluate((el) => new Promise((resolve) => { - let count = 0 - const obs = new MutationObserver((records) => { count += records.length }) - obs.observe(el, { childList: true, subtree: true, characterData: true }) - setTimeout(() => { obs.disconnect(); resolve(count) }, 3_000) - })) - expect(mutationCount).toBe(0) - - // Same sanity check translated to a user-observable property: a - // programmatically created selection survives the polling window. - await assistantContent.evaluate((el) => { - const range = document.createRange() - range.selectNodeContents(el) - const sel = window.getSelection() - sel.removeAllRanges() - sel.addRange(range) - }) - - const initialSelection = await page.evaluate(() => window.getSelection().toString()) - expect(initialSelection).toContain('Hello world') - - await page.waitForTimeout(2_500) - - const selectionAfterPolling = await page.evaluate(() => window.getSelection().toString()) - expect(selectionAfterPolling).toBe(initialSelection) - }) -}) - -test.describe('Chat - copy button (#9904)', () => { - test('copy button works when navigator.clipboard is unavailable (plain http)', async ({ page }) => { - await setupChatPage(page) - - // Simulate a non-secure context: hide navigator.clipboard before any of - // our app code touches it. This mirrors what browsers do over plain - // http from a remote host. - await page.addInitScript(() => { - Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true }) - try { - Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true }) - } catch { /* some browsers refuse — the secure-context flag is enough */ } - }) - - await page.goto('/app/chat') - await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - - await page.getByTestId('chat-input').fill('Hi') - await page.getByTestId('chat-send').click() - - const assistantBubble = page.locator('[data-role="assistant"]').first() - await expect(assistantBubble).toContainText('Hello world', { timeout: 10_000 }) - - // Spy on document.execCommand so we can confirm the fallback path ran. - await page.evaluate(() => { - window.__execCommandCalls = [] - const original = document.execCommand?.bind(document) - document.execCommand = (cmd, ...rest) => { - window.__execCommandCalls.push(cmd) - // execCommand('copy') in a headless browser may return false because - // there is no real clipboard, but the fact that we tried is what we - // care about for this regression. - return original ? original(cmd, ...rest) : false - } - }) - - await assistantBubble.getByTestId('message-actions').locator('button').first().click() - - const execCommandCalls = await page.evaluate(() => window.__execCommandCalls) - expect(execCommandCalls).toContain('copy') - }) -}) diff --git a/core/http/react-ui/e2e/chat-reasoning.spec.js b/core/http/react-ui/e2e/chat-reasoning.spec.js deleted file mode 100644 index d184f499f..000000000 --- a/core/http/react-ui/e2e/chat-reasoning.spec.js +++ /dev/null @@ -1,78 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { chatOf, pair, mockChat, openChat, storedChats } from './chat-fixtures.js' - -// The composer has one reasoning control, stored per chat: the effort select. -// "None" asks for no reasoning and hides what the model still sends. It is -// also the /effort slash action. - -const sendWith = async (page, answer) => { - let body = null - await page.route('**/v1/chat/completions', (route) => { - body = route.request().postDataJSON() - route.fulfill({ status: 200, contentType: 'text/event-stream', body: answer }) - }) - await page.getByTestId('chat-input').fill('hello') - await page.getByTestId('chat-send').click() - await expect.poll(() => body).not.toBeNull() - return body -} - -const stream = (...deltas) => deltas.map(d => `data: ${JSON.stringify({ choices: [{ delta: d }] })}\n\n`).join('') - + `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}\n\ndata: [DONE]\n\n` - -test.describe('Chat reasoning controls', () => { - test.beforeEach(async ({ page }) => { - await mockChat(page) - }) - - test('sends no reasoning_effort until a level is picked, then sends it', async ({ page }) => { - await openChat(page, [chatOf('c1', 'R', 'qwen3-8b', pair('hi', 'hello'))]) - const chip = page.getByTestId('chat-effort-chip') - await expect(chip).toContainText('Model default') - let body = await sendWith(page, stream({ content: 'ok' })) - expect(body.reasoning_effort).toBeUndefined() - - await chip.click() - await page.getByTestId('chat-effort-high').click() - await expect(chip).toContainText('High') - await expect.poll(async () => (await storedChats(page)).chats[0].reasoningEffort).toBe('high') - await page.unroute('**/v1/chat/completions') - body = await sendWith(page, stream({ content: 'ok' })) - expect(body.reasoning_effort).toBe('high') - }) - - test('"None" asks for "none" and hides reasoning the model still sends', async ({ page }) => { - await openChat(page, [chatOf('c1', 'R', 'qwen3-8b', pair('hi', 'hello'))]) - const chip = page.getByTestId('chat-effort-chip') - await chip.click() - await page.getByTestId('chat-effort-none').click() - await expect(chip).toContainText('None') - await expect(chip).toBeEnabled() - const body = await sendWith(page, stream({ reasoning: 'secret plan' }, { content: 'The answer.' })) - expect(body.reasoning_effort).toBe('none') - await expect(page.getByText('The answer.')).toBeVisible() - await expect(page.getByText('secret plan')).toHaveCount(0) - const history = (await storedChats(page)).chats[0].history - expect(history.some(m => m.role === 'thinking')).toBe(false) - }) - - test('a chat saved with the old reasoningOff flag still hides reasoning', async ({ page }) => { - const chat = { ...chatOf('c1', 'R', 'qwen3-8b', pair('hi', 'hello')), reasoningOff: true } - await openChat(page, [chat]) - await expect(page.getByTestId('chat-effort-chip')).toContainText('None') - const body = await sendWith(page, stream({ reasoning: 'secret plan' }, { content: 'The answer.' })) - expect(body.reasoning_effort).toBe('none') - await expect(page.getByText('secret plan')).toHaveCount(0) - }) - - test('the /effort slash action opens the effort select', async ({ page }) => { - await openChat(page, [chatOf('c1', 'R', 'qwen3-8b', pair('hi', 'hello'))]) - await page.getByTestId('chat-input').fill('/effort') - await page.getByTestId('chat-input').press('Enter') - await expect(page.getByTestId('chat-effort-menu')).toBeVisible() - await expect(page.getByTestId('chat-effort-default')).toBeFocused() - await page.keyboard.press('Escape') - await expect(page.getByTestId('chat-effort-menu')).toHaveCount(0) - await expect(page.getByTestId('chat-effort-chip')).toBeFocused() - }) -}) diff --git a/core/http/react-ui/e2e/chat-settings.spec.js b/core/http/react-ui/e2e/chat-settings.spec.js deleted file mode 100644 index 900c0f583..000000000 --- a/core/http/react-ui/e2e/chat-settings.spec.js +++ /dev/null @@ -1,200 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { chatOf, pair, mockChat, openChat, storedChats, answerWith } from './chat-fixtures.js' - -// Chat settings are a sheet: the system prompt, sampling, the context size, -// the behaviour switches and, for admins, the model's facts. They apply from -// the next message. - -const sheet = (page) => page.getByTestId('chat-settings') - -async function open(page) { - await page.getByTestId('chat-settings-button').click() - await expect(sheet(page)).toBeVisible() -} - -test.describe('Chat settings sheet', () => { - test.beforeEach(async ({ page }) => { - await mockChat(page) - await page.route('**/api/models/config-json/qwen3-8b', route => route.fulfill({ json: { - name: 'qwen3-8b', backend: 'llama-cpp', context_size: 8192, threads: 8, gpu_layers: 99, - parameters: { model: 'qwen3-8b-q4_k_m.gguf' }, template: { chat_message: 'x' }, - } })) - }) - - test('opens as a kit sheet, says when it applies, and closes on Esc, the veil and the button', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'))]) - await open(page) - await expect(sheet(page)).toHaveClass(/dk-sheet/) - await expect(sheet(page)).toContainText('Applies from the next message.') - await expect(sheet(page).getByRole('button', { name: 'Close' })).toBeFocused() - - await page.keyboard.press('Escape') - await expect(sheet(page)).toHaveCount(0) - // Focus returns to the button that opened it. - await expect(page.getByTestId('chat-settings-button')).toBeFocused() - - await open(page) - await page.locator('.cx-sheet-veil').click({ position: { x: 20, y: 300 } }) - await expect(sheet(page)).toHaveCount(0) - - await open(page) - await sheet(page).getByRole('button', { name: 'Close' }).click() - await expect(sheet(page)).toHaveCount(0) - }) - - test('each sampling value says "model default" until changed, and Reset puts it back', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'))]) - await open(page) - const temp = sheet(page).locator('.cx-field', { hasText: 'Temperature' }) - await expect(temp.locator('.cx-field__value')).toHaveText('model default') - await expect(temp.getByRole('button', { name: 'Reset' })).toHaveCount(0) - - await temp.getByRole('slider').fill('0.4') - await expect(temp.locator('.cx-field__value')).toHaveText('0.4') - await expect.poll(async () => (await storedChats(page)).chats[0].temperature).toBe(0.4) - - await temp.getByRole('button', { name: 'Reset' }).click() - await expect(temp.locator('.cx-field__value')).toHaveText('model default') - await expect.poll(async () => (await storedChats(page)).chats[0].temperature).toBeNull() - - // Top P and Top K are there too and change their own fields. - await sheet(page).locator('.cx-field', { hasText: 'Top P' }).getByRole('slider').fill('0.5') - await sheet(page).locator('.cx-field', { hasText: 'Top K' }).getByRole('slider').fill('20') - await expect.poll(async () => { - const c = (await storedChats(page)).chats[0] - return [c.topP, c.topK] - }).toEqual([0.5, 20]) - }) - - test('the system prompt is stored and goes out before the next message', async ({ page }) => { - const answer = await answerWith(page, 'ok') - await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', [])]) - await open(page) - await sheet(page).getByTestId('chat-system-prompt').fill('Answer in one line.') - await expect.poll(async () => (await storedChats(page)).chats[0].systemPrompt).toBe('Answer in one line.') - await page.keyboard.press('Escape') - - await page.getByTestId('chat-input').fill('hello') - await page.keyboard.press('Enter') - await expect(page.locator('[data-role="assistant"]')).toContainText('ok') - expect(answer.request().messages[0]).toEqual({ role: 'system', content: 'Answer in one line.' }) - }) - - // Every edit in the sheet re-renders the chat page, which hands the sheet a - // new onClose. Focus must stay where the user put it: before the fix each - // re-render sent focus to the opener and then to the Close button, so a - // second keystroke went nowhere and an open native dropdown closed itself. - test('keeps focus on the control being edited while the page re-renders', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'))]) - await open(page) - - const prompt = sheet(page).getByTestId('chat-system-prompt') - await prompt.click() - await page.keyboard.type('Be brief.', { delay: 20 }) - await expect(prompt).toBeFocused() - await expect(prompt).toHaveValue('Be brief.') - await expect.poll(async () => (await storedChats(page)).chats[0].systemPrompt).toBe('Be brief.') - - const topK = sheet(page).locator('.cx-field', { hasText: 'Top K' }).getByRole('slider') - await topK.focus() - await page.keyboard.press('ArrowRight') - await page.keyboard.press('ArrowRight') - await expect(topK).toBeFocused() - await expect.poll(async () => (await storedChats(page)).chats[0].topK).toBe(42) - - // Escape still reaches the current onClose, and focus goes back to the - // button that opened the sheet. - await expect(sheet(page)).toBeVisible() - await page.keyboard.press('Escape') - await expect(sheet(page)).toHaveCount(0) - await expect(page.getByTestId('chat-settings-button')).toBeFocused() - }) - - test('the context size has quick sizes, drives the header meter and is not sent', async ({ page }) => { - const answer = await answerWith(page, 'ok') - await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'), { tokenUsage: { prompt: 1000, completion: 96, total: 1096 } })]) - // The admin's model configuration fills the size in: 8192. - await expect.poll(async () => (await storedChats(page)).chats[0].contextSize).toBe(8192) - await open(page) - const size = sheet(page).getByRole('group', { name: 'Context Size' }) - await expect(size.getByRole('button', { name: '8k' })).toHaveAttribute('aria-pressed', 'true') - await expect(sheet(page)).toContainText('Using 1096 of 8192 tokens (13%)') - await size.getByRole('button', { name: '4k' }).click() - await expect(sheet(page)).toContainText('(27%)') - await page.keyboard.press('Escape') - await expect(page.getByTestId('chat-context')).toContainText('27%') - - await page.getByTestId('chat-input').fill('go') - await page.keyboard.press('Enter') - await expect(page.locator('[data-role="assistant"]').last()).toContainText('ok') - expect(JSON.stringify(answer.request())).not.toContain('4096') - }) - - test('Manage mode turns on from the sheet, marks the header and is sent with the message', async ({ page }) => { - const answer = await answerWith(page, 'ok') - await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', [])]) - await open(page) - const manage = sheet(page).getByRole('switch', { name: 'Manage mode' }) - await expect(manage).toHaveAttribute('aria-checked', 'false') - await manage.click() - await expect(manage).toHaveAttribute('aria-checked', 'true') - await page.keyboard.press('Escape') - await expect(page.getByTestId('chat-manage-badge')).toBeVisible() - await page.getByTestId('chat-input').fill('what is installed') - await page.keyboard.press('Enter') - await expect(page.locator('[data-role="assistant"]')).toContainText('ok') - expect(answer.request().metadata).toMatchObject({ localai_assistant: 'true' }) - }) - - test('Focus mode is a switch that is remembered', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'))]) - await open(page) - const focus = sheet(page).getByRole('switch', { name: 'Focus mode' }) - await expect(focus).toHaveAttribute('aria-checked', 'true') - await focus.click() - await expect(focus).toHaveAttribute('aria-checked', 'false') - expect(await page.evaluate(() => localStorage.getItem('localai_chat_focus_mode'))).toBe('false') - }) - - test('admins see the model facts and can open its config', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'))]) - await open(page) - const info = page.getByTestId('chat-model-info') - await expect(info).toContainText('llama-cpp') - await expect(info).toContainText('qwen3-8b-q4_k_m.gguf') - await expect(info).toContainText('99') - await info.getByRole('button', { name: 'Edit config' }).click() - await expect(page).toHaveURL(/\/app\/model-editor\/qwen3-8b/) - }) - - test('Clear conversation asks for a confirmation, then empties the chat but keeps its settings', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'), { temperature: 0.4 })]) - await open(page) - await sheet(page).getByRole('button', { name: 'Clear chat history' }).click() - await expect(sheet(page)).toHaveCount(0) - const dialog = page.getByRole('alertdialog') - await expect(dialog).toContainText('Clear this conversation') - await dialog.getByRole('button', { name: 'Cancel' }).click() - await expect(page.getByTestId('chat-message')).toHaveCount(2) - - await open(page) - await sheet(page).getByRole('button', { name: 'Clear chat history' }).click() - await page.getByRole('alertdialog').getByRole('button', { name: 'Clear conversation' }).click() - await expect(page.getByTestId('chat-empty')).toBeVisible() - await expect.poll(async () => { - const c = (await storedChats(page)).chats[0] - return [c.history.length, c.temperature] - }).toEqual([0, 0.4]) - }) - - test('on a phone the sheet rises from the bottom', async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }) - await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'))]) - await page.getByTestId('chat-settings-button').click() - const box = await sheet(page).boundingBox() - expect(box.width).toBeLessThanOrEqual(390) - expect(box.y + box.height).toBeGreaterThanOrEqual(843) - expect(box.y).toBeGreaterThan(20) - await expect(sheet(page).locator('.dk-sheet-grip')).toBeVisible() - }) -}) diff --git a/core/http/react-ui/e2e/chat-states.spec.js b/core/http/react-ui/e2e/chat-states.spec.js deleted file mode 100644 index e628315d0..000000000 --- a/core/http/react-ui/e2e/chat-states.spec.js +++ /dev/null @@ -1,200 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { - chatOf, pair, mockChat, openChat, storedChats, controlStream, answerWith, -} from './chat-fixtures.js' -import { sampleChats } from './home-fixtures.js' - -// The states around the thread: an empty chat, no model, a reply waiting for a -// model, the phone layout and reduced motion. - -test.describe('Empty chat', () => { - test.beforeEach(async ({ page }) => { - await mockChat(page) - }) - - test('says what the page is for, offers starters and says whether the model is ready', async ({ page }) => { - await openChat(page, [chatOf('n1', 'New Chat', 'qwen3-8b', [])]) - await expect(page.getByTestId('chat-empty').getByRole('heading')).toHaveText('What are we working on?') - await expect(page.getByTestId('chat-starters').getByRole('button')).toHaveCount(4) - await expect(page.getByTestId('chat-ready')).toContainText('qwen3-8b is loaded and ready.') - // The composer sits under the line, not at the bottom of the page. - const head = await page.getByTestId('chat-empty').boundingBox() - const bar = await page.getByTestId('chat-composer').boundingBox() - expect(bar.y).toBeGreaterThan(head.y) - expect(bar.y).toBeLessThan(head.y + 200) - }) - - test('a model that is not loaded says the first reply loads it', async ({ page }) => { - await openChat(page, [chatOf('n1', 'New Chat', 'gemma-3-12b', [])]) - await expect(page.getByTestId('chat-ready')).toContainText('gemma-3-12b is not loaded yet. The first reply loads it.') - }) - - test('a starter fills the box and focuses it', async ({ page }) => { - await openChat(page, [chatOf('n1', 'New Chat', 'qwen3-8b', [])]) - await page.getByTestId('chat-starters').getByRole('button', { name: 'Help me write code' }).click() - await expect(page.getByTestId('chat-input')).toHaveValue('Help me write code') - await expect(page.getByTestId('chat-input')).toBeFocused() - }) - - test('lists the conversations like Home does, and a row resumes one', async ({ page }) => { - const chats = sampleChats() - await openChat(page, [chatOf('n1', 'New Chat', 'qwen3-8b', []), ...chats], 'n1') - const list = page.getByTestId('chat-under') - await expect(list.getByTestId('home-day-today')).toBeVisible() - await expect(list.getByTestId('home-day-yesterday')).toBeVisible() - await expect(list.getByTestId('home-conversation')).toHaveCount(5) - await list.getByTestId('home-conversation').filter({ hasText: 'Regex for semver' }).getByRole('button').first().click() - await expect(page.getByTestId('chat-title')).toHaveText('Regex for semver with prerelease tags') - await expect(page.getByTestId('chat-empty')).toHaveCount(0) - }) - - test('deleting from the list offers an undo', async ({ page }) => { - await openChat(page, [chatOf('n1', 'New Chat', 'qwen3-8b', []), ...sampleChats()], 'n1') - const row = page.getByTestId('chat-under').getByTestId('home-conversation').filter({ hasText: 'Draft a polite reply' }) - await row.hover() - await row.getByRole('button', { name: /Delete conversation/ }).click() - await expect(page.getByTestId('chat-undo-toast')).toContainText('Deleted "Draft a polite reply about the invoice"') - await page.getByTestId('chat-undo-toast').getByRole('button', { name: 'Undo' }).click() - await expect(page.getByTestId('chat-under').getByTestId('home-conversation')).toHaveCount(5) - }) - - test('the first message moves the composer to the bottom without losing the box', async ({ page }) => { - await answerWith(page, 'hello there') - await openChat(page, [chatOf('n1', 'New Chat', 'qwen3-8b', [])]) - await page.getByTestId('chat-input').fill('hi') - await page.keyboard.press('Enter') - await expect(page.locator('[data-role="assistant"]')).toContainText('hello there') - await expect(page.getByTestId('chat-empty')).toHaveCount(0) - const bar = await page.getByTestId('chat-composer').boundingBox() - expect(bar.y + bar.height).toBeGreaterThan(page.viewportSize().height - 80) - }) -}) - -test.describe('No chat model', () => { - test('shows an install card, keeps the composer and what was typed, and cannot send', async ({ page }) => { - await mockChat(page, { models: [], loaded: [] }) - await openChat(page, [chatOf('n1', 'New Chat', '', [])]) - const card = page.getByTestId('chat-no-model') - await expect(card).toBeVisible() - await expect(card.getByRole('heading')).toHaveText('Chat needs a language model') - await expect(card.getByRole('button', { name: 'Browse the gallery' })).toBeVisible() - await expect(card.getByRole('button', { name: 'Import a model' })).toBeVisible() - await expect(page.getByTestId('chat-input')).toHaveAttribute('placeholder', 'Install a model to start chatting') - await expect(page.getByTestId('home-model-chip')).toContainText('No chat model') - await page.getByTestId('chat-input').fill('what I wanted to ask') - await expect(page.getByTestId('chat-send')).toBeDisabled() - await expect(page.getByTestId('chat-input')).toHaveValue('what I wanted to ask') - }) - - test('the gallery button opens the Models page', async ({ page }) => { - await mockChat(page, { models: [], loaded: [] }) - await openChat(page, [chatOf('n1', 'New Chat', '', [])]) - await page.getByTestId('chat-no-model').getByRole('button', { name: 'Browse the gallery' }).click() - await expect(page).toHaveURL(/\/app\/models/) - }) -}) - -test.describe('A reply that waits for a model', () => { - test('a model that is not loaded gets a plain note, with no invented phases', async ({ page }) => { - await mockChat(page) - await controlStream(page) - await openChat(page, [chatOf('c1', 'Cold', 'gemma-3-12b', pair('hi', 'hello'))]) - await page.getByTestId('chat-input').fill('go') - await page.keyboard.press('Enter') - const card = page.getByTestId('chat-load') - await expect(card).toBeVisible() - await expect(card).toContainText('Loading') - await expect(card).toContainText('gemma-3-12b') - await expect(card).toContainText('This model is not loaded yet, so the first reply takes longer.') - await expect(card.getByTestId('chat-load-phase')).toHaveCount(0) - await expect(card.getByRole('progressbar')).toHaveCount(0) - // The first token replaces the card. - await page.evaluate(() => window.__sse.push({ choices: [{ delta: { content: 'Hello' } }] })) - await expect(page.getByTestId('chat-load')).toHaveCount(0) - await expect(page.getByTestId('chat-streaming')).toContainText('Hello') - }) - - test('a loaded model waits behind quiet dots, not a card', async ({ page }) => { - await mockChat(page) - await controlStream(page) - await openChat(page, [chatOf('c1', 'Warm', 'qwen3-8b', pair('hi', 'hello'))]) - await page.getByTestId('chat-input').fill('go') - await page.keyboard.press('Enter') - await expect(page.getByTestId('chat-streaming').locator('.cx-dots')).toBeVisible() - await expect(page.getByTestId('chat-load')).toHaveCount(0) - }) - - test('a staging operation shows its progress when no load job names the phase', async ({ page }) => { - await mockChat(page) - await page.route('**/api/operations', route => route.fulfill({ json: { operations: [{ - id: 'op1', name: 'gemma-3-12b', taskType: 'staging', nodeName: 'node-2', progress: 55, message: 'file 1 of 2', - }] } })) - await controlStream(page) - await openChat(page, [chatOf('c1', 'Stage', 'gemma-3-12b', [])]) - await page.getByTestId('chat-input').fill('go') - await page.keyboard.press('Enter') - const card = page.getByTestId('chat-load') - await expect(card.getByTestId('chat-load-phase')).toContainText('node-2') - await expect(card.getByTestId('chat-load-pct')).toHaveText('55%') - await expect(card.getByTestId('chat-load-detail')).toHaveText('file 1 of 2') - }) -}) - -test.describe('Chat on a phone', () => { - test.use({ viewport: { width: 390, height: 844 } }) - - test('fits the width, keeps the composer in view and keeps message actions visible', async ({ page }) => { - await mockChat(page) - await openChat(page, [chatOf('c1', 'Phone', 'qwen3-8b', [...pair('one', 'first'), ...pair('two', 'second')], { contextSize: 8192, tokenUsage: { prompt: 10, completion: 5, total: 15 } })]) - await expect(page.getByTestId('chat-message')).toHaveCount(4) - const scroll = await page.evaluate(() => ({ w: document.documentElement.scrollWidth, v: window.innerWidth })) - expect(scroll.w).toBeLessThanOrEqual(scroll.v) - await expect(page.getByTestId('chat-input')).toBeInViewport() - // The header drops its words and keeps the buttons. - await expect(page.locator('.cx-hist__label')).toBeHidden() - await expect(page.getByTestId('chat-settings-button')).toBeVisible() - // Actions need no hover on a touch screen. - const opacity = await page.getByTestId('message-actions').first().evaluate(el => getComputedStyle(el).opacity) - expect(opacity).toBe('1') - // On a turn that is not the last one, only the first two buttons stay. - const visible = await page.getByTestId('message-actions').nth(2).locator('button').evaluateAll(els => els.filter(e => e.offsetParent !== null).length) - expect(visible).toBe(2) - }) - - test('the conversations menu fits the screen', async ({ page }) => { - await mockChat(page) - await openChat(page, sampleChats()) - await page.getByTestId('chats-trigger').click() - const box = await page.getByTestId('chats-menu').boundingBox() - expect(box.x).toBeGreaterThanOrEqual(0) - expect(box.x + box.width).toBeLessThanOrEqual(390) - expect(box.y + box.height).toBeLessThanOrEqual(844) - }) - - test('the empty chat fits too', async ({ page }) => { - await mockChat(page) - await openChat(page, [chatOf('n1', 'New Chat', 'qwen3-8b', [])]) - await expect(page.getByTestId('chat-empty')).toBeVisible() - const scroll = await page.evaluate(() => ({ w: document.documentElement.scrollWidth, v: window.innerWidth })) - expect(scroll.w).toBeLessThanOrEqual(scroll.v) - await expect(page.getByTestId('chat-composer')).toBeInViewport() - }) -}) - -test.describe('Chat with reduced motion', () => { - test('nothing in the thread animates', async ({ page }) => { - // The fixture option test.use({ reducedMotion }) does not reach our extended page. - await page.emulateMedia({ reducedMotion: 'reduce' }) - await mockChat(page) - await controlStream(page) - await openChat(page, [chatOf('c1', 'Calm', 'qwen3-8b', pair('hi', 'hello'))]) - const msgAnim = await page.getByTestId('chat-message').first().evaluate(el => getComputedStyle(el).animationName) - expect(msgAnim).toBe('none') - await page.getByTestId('chat-input').fill('go') - await page.keyboard.press('Enter') - await page.evaluate(() => window.__sse.push({ choices: [{ delta: { reasoning: 'thinking it over' } }] })) - const shimmer = await page.getByTestId('chat-activity').locator('.cx-shimmer, .cx-fold__head span').first().evaluate(el => getComputedStyle(el).animationName) - expect(shimmer).toBe('none') - expect((await storedChats(page)).chats).toHaveLength(1) - }) -}) diff --git a/core/http/react-ui/e2e/chat-thread.spec.js b/core/http/react-ui/e2e/chat-thread.spec.js deleted file mode 100644 index 1361d6133..000000000 --- a/core/http/react-ui/e2e/chat-thread.spec.js +++ /dev/null @@ -1,237 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { - chatOf, pair, mockChat, openChat, storedChats, controlStream, answerWith, sse, PNG, GO_BLOCK, -} from './chat-fixtures.js' - -// The thread's parts: the one-line activity fold, code blocks, attachments, -// per-message actions and their keys, a failed reply and a streaming reply. - -const msg = (page, role) => page.locator(`[data-testid="chat-message"][data-role="${role}"]`) - -const ACTIVITY = [ - { role: 'user', content: 'Which file has the leak?' }, - { role: 'thinking', content: 'The handler starts one reader per request.' }, - { role: 'tool_call', content: JSON.stringify({ type: 'tool_call', name: 'read_file', arguments: { path: 'stream_handler.go' } }) }, - { role: 'tool_result', content: JSON.stringify({ type: 'tool_result', name: 'read_file', result: '148 lines' }) }, - { role: 'assistant', content: 'It is stream_handler.go.' }, -] - -test.describe('Chat thread', () => { - test.beforeEach(async ({ page }) => { - await mockChat(page) - }) - - test('reasoning and tool calls fold into one quiet line that opens inline', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Activity', 'qwen3-8b', ACTIVITY)]) - const fold = page.getByTestId('chat-activity') - const head = fold.getByRole('button') - await expect(head).toHaveText('Thought · read_file') - await expect(head).toHaveAttribute('aria-expanded', 'false') - // Closed: the steps are not in the page at all. - await expect(page.locator('.cx-steps')).toHaveCount(0) - - await head.click() - await expect(head).toHaveAttribute('aria-expanded', 'true') - const steps = page.locator('.cx-steps .cx-step') - await expect(steps).toHaveCount(3) - await expect(steps.nth(0)).toContainText('The handler starts one reader per request.') - await expect(steps.nth(1)).toContainText('Tool call') - await expect(steps.nth(1)).toContainText('stream_handler.go') - await expect(steps.nth(2)).toContainText('148 lines') - - await head.click() - await expect(page.locator('.cx-steps')).toHaveCount(0) - }) - - test('the activity line sits inside the answer it belongs to', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Activity', 'qwen3-8b', ACTIVITY)]) - // One assistant turn, and the fold is part of it, between the name and the text. - await expect(msg(page, 'assistant')).toHaveCount(1) - await expect(msg(page, 'assistant').getByTestId('chat-activity')).toBeVisible() - await expect(msg(page, 'assistant').locator('.cx-prose')).toContainText('It is stream_handler.go.') - }) - - test('a code block has Copy and Canvas buttons', async ({ page, context }) => { - await context.grantPermissions(['clipboard-read', 'clipboard-write']) - await openChat(page, [chatOf('c1', 'Code', 'qwen3-8b', pair('Show me', 'Here:\n\n```go\n' + GO_BLOCK + '\n```\n\nDone.'))]) - const block = page.locator('.code-block') - await expect(block.locator('.code-block__lang')).toHaveText('go') - const copy = block.getByRole('button', { name: 'Copy code' }) - await expect(copy).toContainText('Copy') - await expect(block.getByRole('button', { name: 'Canvas' })).toBeVisible() - await copy.click() - await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('context.WithCancel') - }) - - test('an image is a thumbnail that opens in the viewer, and a file is a chip', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Attachments', 'gemma-3-12b', [ - { - role: 'user', - content: [{ type: 'text', text: 'What is in this?' }, { type: 'image_url', image_url: { url: PNG } }], - files: [{ name: 'shot.png', type: 'image' }, { name: 'notes.txt', type: 'file', content: 'x' }], - }, - { role: 'assistant', content: 'A single pixel.' }, - ])]) - const user = msg(page, 'user') - await expect(user.locator('.cx-thumb img')).toBeVisible() - // The image is shown once: as a thumbnail, not also as a chip. - await expect(user.locator('.cx-chip')).toHaveCount(1) - await expect(user.locator('.cx-chip')).toContainText('notes.txt') - - await user.locator('.cx-thumb').click() - await expect(page.locator('.lightbox')).toBeVisible() - await page.keyboard.press('Escape') - await expect(page.locator('.lightbox')).toHaveCount(0) - }) - - test('message actions show on hover, on focus and on the last turn', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Actions', 'qwen3-8b', [...pair('one', 'first'), ...pair('two', 'second')])]) - const opacity = (loc) => loc.getByTestId('message-actions').evaluate(el => getComputedStyle(el).opacity) - const first = msg(page, 'user').first() - // Away from the pointer the first turn's actions are hidden; the last turn keeps them. - await page.mouse.move(5, 5) - await expect.poll(() => opacity(first)).toBe('0') - await expect.poll(() => opacity(msg(page, 'assistant').last())).toBe('1') - await first.hover() - await expect.poll(() => opacity(first)).toBe('1') - await page.mouse.move(5, 5) - await first.focus() - await expect.poll(() => opacity(first)).toBe('1') - }) - - test('the buttons are Copy, Edit, Regenerate and Branch on an answer, and only Copy and Edit on yours', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Actions', 'qwen3-8b', pair('one', 'first'))]) - await expect(msg(page, 'user').getByTestId('message-actions').locator('button')).toHaveCount(2) - await expect(msg(page, 'assistant').getByTestId('message-actions').getByTitle('Regenerate')).toBeVisible() - await expect(msg(page, 'assistant').getByTestId('message-actions').getByTitle('Branch from here')).toBeVisible() - }) - - test('arrow keys move between messages and C, E and B act on the focused one', async ({ page, context }) => { - await context.grantPermissions(['clipboard-read', 'clipboard-write']) - await openChat(page, [chatOf('c1', 'Keys', 'qwen3-8b', [...pair('first question', 'first answer'), ...pair('second question', 'second answer')])]) - const all = page.getByTestId('chat-message') - await all.first().focus() - await page.keyboard.press('ArrowDown') - await expect(all.nth(1)).toBeFocused() - await page.keyboard.press('ArrowUp') - await expect(all.first()).toBeFocused() - - // C copies the focused turn. - await page.keyboard.press('c') - await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('first question') - - // E opens the editor on your own turn; Esc leaves it as it was. - await page.keyboard.press('e') - await expect(all.first().getByRole('textbox')).toHaveValue('first question') - await page.keyboard.press('Escape') - await expect(all.first().getByRole('textbox')).toHaveCount(0) - - // B branches from an answer into a new chat. - await all.nth(1).focus() - await page.keyboard.press('b') - await expect(page.getByTestId('chat-title')).toHaveText('Keys (fork)') - await expect(msg(page, 'assistant')).toHaveCount(1) - }) - - test('R regenerates the focused answer', async ({ page }) => { - const answer = await answerWith(page, 'a fresh answer') - await openChat(page, [chatOf('c1', 'Regen', 'qwen3-8b', pair('the question', 'the old answer'))]) - await msg(page, 'assistant').focus() - await page.keyboard.press('r') - await expect(msg(page, 'assistant')).toContainText('a fresh answer') - expect(JSON.stringify(answer.request().messages)).toContain('the question') - }) - - test('Edit and Regenerate are not offered while a reply is streaming', async ({ page }) => { - await controlStream(page) - await openChat(page, [chatOf('c1', 'Busy', 'qwen3-8b', pair('one', 'first'))]) - await page.getByTestId('chat-input').fill('two') - await page.keyboard.press('Enter') - await expect(page.getByTestId('chat-streaming')).toBeVisible() - await expect(msg(page, 'assistant').first().getByTitle('Regenerate')).toHaveCount(0) - await expect(msg(page, 'assistant').first().getByTitle('Edit')).toHaveCount(0) - }) - - test('a failed reply keeps the text written so far, says why, and Retry asks again', async ({ page }) => { - let calls = 0 - await page.route('**/v1/chat/completions', (route) => { - calls++ - const body = calls === 1 - ? `data: ${JSON.stringify({ choices: [{ delta: { content: 'Half an answer' } }] })}\n\n` - + `data: ${JSON.stringify({ error: { message: 'out of memory after 214 tokens' } })}\n\n` - + 'data: [DONE]\n\n' - : sse('A whole answer') - route.fulfill({ status: 200, contentType: 'text/event-stream', body }) - }) - await openChat(page, [chatOf('c1', 'Fail', 'qwen3-8b', [])]) - await page.getByTestId('chat-input').fill('Tell me') - await page.keyboard.press('Enter') - - const card = page.getByTestId('chat-error') - await expect(card).toBeVisible() - await expect(card.getByRole('heading')).toHaveText('The reply failed') - await expect(card).toContainText('out of memory after 214 tokens') - await expect(card).toContainText('What was written so far is kept above.') - await expect(msg(page, 'assistant')).toContainText('Half an answer') - // One action to take, and the way to the traces. - await expect(card.getByRole('button')).toHaveCount(1) - await expect(card.getByRole('link', { name: /traces/i })).toBeVisible() - await card.locator('details summary').click() - await expect(card.locator('pre')).toContainText('out of memory') - - await card.getByRole('button', { name: 'Retry' }).click() - await expect(msg(page, 'assistant')).toContainText('A whole answer') - await expect(page.getByTestId('chat-error')).toHaveCount(0) - expect(calls).toBe(2) - }) - - test('a reply that quotes "Error:" in a sentence is not an error', async ({ page }) => { - await openChat(page, [chatOf('c1', 'Quote', 'qwen3-8b', pair('what does it print', 'It prints Error: file not found when the path is wrong.'))]) - await expect(msg(page, 'assistant')).toContainText('Error: file not found') - await expect(page.getByTestId('chat-error')).toHaveCount(0) - }) - - test('while a reply streams, Stop ends it and Esc does the same', async ({ page }) => { - await controlStream(page) - await openChat(page, [chatOf('c1', 'Stop', 'qwen3-8b', [])]) - const input = page.getByTestId('chat-input') - - await input.fill('first') - await page.keyboard.press('Enter') - await expect(page.getByTestId('chat-stop')).toBeVisible() - await page.evaluate(() => window.__sse.push({ choices: [{ delta: { content: 'Partial' } }] })) - await expect(page.getByTestId('chat-streaming')).toContainText('Partial') - await page.getByTestId('chat-stop').click() - await expect(page.getByTestId('chat-stop')).toHaveCount(0) - await expect(page.getByTestId('chat-send')).toBeVisible() - - await input.fill('second') - await page.keyboard.press('Enter') - await expect(page.getByTestId('chat-stop')).toBeVisible() - await page.keyboard.press('Escape') - await expect(page.getByTestId('chat-stop')).toHaveCount(0) - await expect.poll(() => page.evaluate(() => window.__sse.calls)).toBe(2) - }) - - test('the reply streams under the model name, with its reasoning open until the answer starts', async ({ page }) => { - await controlStream(page) - await openChat(page, [chatOf('c1', 'Stream', 'qwen3-8b', [])]) - await page.getByTestId('chat-input').fill('go') - await page.keyboard.press('Enter') - const turn = page.getByTestId('chat-streaming') - await expect(turn.locator('.cx-who b')).toHaveText('qwen3-8b') - - await page.evaluate(() => window.__sse.push({ choices: [{ delta: { reasoning: 'Weighing the options.' } }] })) - await expect(turn.getByRole('button', { name: /Thinking/ })).toHaveAttribute('aria-expanded', 'true') - await expect(turn).toContainText('Weighing the options.') - - await page.evaluate(() => window.__sse.push({ choices: [{ delta: { content: 'The answer.' } }] })) - await expect(turn.locator('.cx-prose')).toContainText('The answer.') - // Once the answer is coming, the reasoning folds to one line. - await expect(turn.getByRole('button', { name: /Thought/ })).toHaveAttribute('aria-expanded', 'false') - - await page.evaluate(() => window.__sse.end()) - await expect(page.getByTestId('chat-streaming')).toHaveCount(0) - await expect.poll(async () => (await storedChats(page)).chats[0].history.map(m => m.role)).toEqual(['user', 'thinking', 'assistant']) - }) -}) diff --git a/core/http/react-ui/e2e/chat-throughput.spec.js b/core/http/react-ui/e2e/chat-throughput.spec.js deleted file mode 100644 index 4e1d995e2..000000000 --- a/core/http/react-ui/e2e/chat-throughput.spec.js +++ /dev/null @@ -1,48 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { chatOf, mockChat, openChat, controlStream } from './chat-fixtures.js' - -async function start(page) { - await mockChat(page) - await controlStream(page) - await openChat(page, [chatOf('c1', 'Throughput', 'qwen3-8b', [])]) - // Only Date is fixed: browser timers and React continue running normally. - await page.clock.setFixedTime(new Date('2026-10-26T12:00:00Z')) - await page.getByTestId('chat-input').fill('hello') - await page.getByTestId('chat-send').click() - await expect.poll(() => page.evaluate(() => window.__sse.calls)).toBe(1) -} - -async function atTwoSeconds(page, usage) { - await page.clock.setFixedTime(new Date('2026-10-26T12:00:02Z')) - await page.evaluate((usage) => { - window.__sse.push({ choices: [{ delta: { reasoning: 'thinking about the answer' } }] }) - window.__sse.push({ choices: [{ delta: { content: 'The answer has several tokens.' } }] }) - if (usage) window.__sse.push({ choices: [], usage }) - window.__sse.end() - }, usage) -} - -test('chat throughput uses completion usage, including reasoning, over request duration', async ({ page }) => { - await start(page) - await atTwoSeconds(page, { prompt_tokens: 10, completion_tokens: 100, total_tokens: 110, - completion_tokens_details: { reasoning_tokens: 80 } }) - await expect(page.getByTestId('chat-foot')).toContainText('50 tok/s · end-to-end') - await expect(page.getByTestId('chat-foot')).not.toContainText('estimated') - await expect(page.getByTestId('chat-foot')).not.toContainText('Peak') -}) - -test('chat throughput labels live and missing-usage rates as estimates', async ({ page }) => { - await start(page) - await page.clock.setFixedTime(new Date('2026-10-26T12:00:01Z')) - await page.evaluate(() => window.__sse.push({ choices: [{ delta: { content: '12345678' } }] })) - await expect(page.getByTestId('chat-foot')).toContainText('2 tok/s · estimated') - await page.clock.setFixedTime(new Date('2026-10-26T12:00:02Z')) - await page.evaluate(() => window.__sse.end()) - await expect(page.getByTestId('chat-foot')).toContainText('1 tok/s · estimated') -}) - -test('chat throughput accepts a reported zero completion count', async ({ page }) => { - await start(page) - await atTwoSeconds(page, { prompt_tokens: 10, completion_tokens: 0, total_tokens: 10 }) - await expect(page.getByTestId('chat-foot')).toContainText('0 tok/s · end-to-end') -}) diff --git a/core/http/react-ui/e2e/chat-transcript.spec.js b/core/http/react-ui/e2e/chat-transcript.spec.js deleted file mode 100644 index b095c24f7..000000000 --- a/core/http/react-ui/e2e/chat-transcript.spec.js +++ /dev/null @@ -1,56 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { chatOf, pair, mockChat, openChat } from './chat-fixtures.js' - -// The thread: your turns are raised blocks on the right, the model's turns are -// plain prose under its name, both at one 760 px measure. Both turns still say -// who is speaking in words, so the thread reads aloud and prints well. - -const THREAD = chatOf('c1', 'Transcript', 'qwen3-8b', pair('Which backends do I have?', 'Seven are installed.')) - -test.describe('Chat thread layout', () => { - test.beforeEach(async ({ page }) => { - await mockChat(page) - await openChat(page, [THREAD]) - await expect(page.getByTestId('chat-message').first()).toBeVisible() - }) - - test('your turn is a raised block, the reply is plain prose', async ({ page }) => { - const user = page.locator('[data-role="user"] .cx-bubble') - const cs = await user.evaluate(el => { - const s = getComputedStyle(el) - return { radius: parseFloat(s.borderTopLeftRadius), bg: s.backgroundColor, shadow: s.boxShadow } - }) - expect(cs.radius).toBeGreaterThan(8) - expect(cs.bg).not.toBe('rgba(0, 0, 0, 0)') - expect(cs.shadow).not.toBe('none') - - const prose = await page.locator('[data-role="assistant"] .cx-prose').evaluate(el => { - const s = getComputedStyle(el) - return { bg: s.backgroundColor, border: s.borderLeftWidth, shadow: s.boxShadow } - }) - // No fill, no rail, no shadow: the reply is text on the page. - expect(prose.bg).toBe('rgba(0, 0, 0, 0)') - expect(prose.border).toBe('0px') - expect(prose.shadow).toBe('none') - }) - - test('your turn sits on the right and the reply on the left of one 760 px column', async ({ page }) => { - const thread = await page.getByTestId('chat-thread').boundingBox() - expect(thread.width).toBeLessThanOrEqual(760) - const user = await page.locator('[data-role="user"] .cx-bubble').boundingBox() - const reply = await page.locator('[data-role="assistant"] .cx-prose').boundingBox() - expect(Math.abs(user.x + user.width - (thread.x + thread.width))).toBeLessThan(2) - expect(Math.abs(reply.x - thread.x)).toBeLessThan(2) - }) - - test('every turn says who is speaking', async ({ page }) => { - await expect(page.locator('[data-role="assistant"] .cx-who b')).toHaveText('qwen3-8b') - // Your turn carries the word for assistive technology and for reading aloud. - await expect(page.locator('[data-role="user"]')).toHaveAttribute('aria-label', 'You') - }) - - test('the reply names whether its model is loaded', async ({ page }) => { - // qwen3-8b is in the stubbed loaded list, so its dot is filled. - await expect(page.locator('[data-role="assistant"] .cx-who .home-dot')).not.toHaveClass(/home-dot--cold/) - }) -}) diff --git a/core/http/react-ui/e2e/chat-vision.spec.js b/core/http/react-ui/e2e/chat-vision.spec.js new file mode 100644 index 000000000..f9323615c --- /dev/null +++ b/core/http/react-ui/e2e/chat-vision.spec.js @@ -0,0 +1,128 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockChat } from './chat-fixtures.js' + +// A cloud-proxy model sees images only when its config says so: LocalAI cannot +// ask the upstream. The model editor offers a switch for it, and the chat says +// at attach time when the chosen model cannot look at an image. The API is +// stubbed. + +const PNG = Buffer.from('89504e470d0a1a0a0000000d4948445200000001000000010806000000', 'hex') + +const PROXY_METADATA = { + sections: [ + { id: 'general', label: 'General', icon: 'settings', order: 0 }, + { id: 'proxy', label: 'Cloud Proxy', icon: 'cloud', order: 50 }, + ], + fields: [ + { path: 'name', yaml_key: 'name', go_type: 'string', ui_type: 'string', section: 'general', label: 'Model Name', component: 'input', order: 0 }, + { path: 'backend', yaml_key: 'backend', go_type: 'string', ui_type: 'string', section: 'general', label: 'Backend', component: 'input', order: 1 }, + { path: 'known_usecases', yaml_key: 'known_usecases', go_type: '[]string', ui_type: '[]string', section: 'general', label: 'Known Usecases', component: 'string-list', order: 2 }, + { path: 'proxy.upstream_url', yaml_key: 'upstream_url', go_type: 'string', ui_type: 'string', section: 'proxy', label: 'Proxy Upstream URL', component: 'input', order: 210 }, + ], +} + +async function mockEditor(page, yaml) { + await page.route('**/api/auth/status', route => route.fulfill({ json: { authEnabled: false, staticApiKeyRequired: false, providers: [] } })) + await page.route('**/api/models/config-metadata*', route => route.fulfill({ json: PROXY_METADATA })) + await page.route('**/api/models/config-metadata/autocomplete/**', route => route.fulfill({ json: { values: [] } })) + await page.route('**/api/models/edit/**', route => route.fulfill({ json: { config: yaml, name: 'cloud' } })) + const patches = [] + await page.route('**/api/models/config-json/**', route => { + if (route.request().method() === 'PATCH') patches.push(route.request().postDataJSON()) + return route.fulfill({ json: {} }) + }) + return patches +} + +test.describe('Cloud-proxy model: can look at images', () => { + test('the switch writes vision into known_usecases', async ({ page }) => { + const patches = await mockEditor(page, 'name: cloud\nbackend: cloud-proxy\nknown_usecases: [chat]\nproxy:\n upstream_url: https://upstream.example/v1/chat/completions\n') + await page.goto('/app/model-editor/cloud') + const sw = page.getByTestId('proxy-vision-switch') + await expect(sw).toContainText('This model can look at images') + await expect(sw).toContainText('LocalAI cannot check it') + const box = sw.locator('input[type=checkbox]') + await expect(box).not.toBeChecked() + + await sw.locator('label.toggle').click() + await expect(box).toBeChecked() + await page.getByRole('button', { name: 'Save Changes' }).click() + await expect.poll(() => patches.length).toBe(1) + expect(patches[0].known_usecases).toEqual(['chat', 'vision']) + }) + + test('the switch shows a declared vision and turns it off', async ({ page }) => { + const patches = await mockEditor(page, 'name: cloud\nbackend: cloud-proxy\nknown_usecases: [chat, vision]\nproxy:\n upstream_url: https://upstream.example/v1/chat/completions\n') + await page.goto('/app/model-editor/cloud') + const sw = page.getByTestId('proxy-vision-switch') + await expect(sw.locator('input[type=checkbox]')).toBeChecked() + await sw.locator('label.toggle').click() + await page.getByRole('button', { name: 'Save Changes' }).click() + await expect.poll(() => patches.length).toBe(1) + expect(patches[0].known_usecases).toEqual(['chat']) + }) + + test('a cloud model in translate mode has no switch: that mode sends text only', async ({ page }) => { + await mockEditor(page, 'name: cloud\nbackend: cloud-proxy\nknown_usecases: [chat]\nproxy:\n mode: translate\n upstream_url: https://upstream.example/v1/messages\n') + await page.goto('/app/model-editor/cloud') + await expect(page.getByText('Proxy Upstream URL')).toBeVisible({ timeout: 10_000 }) + await expect(page.getByTestId('proxy-vision-switch')).toHaveCount(0) + }) + + test('a model that is not a cloud proxy has no switch', async ({ page }) => { + await mockEditor(page, 'name: local\nbackend: llama-cpp\nknown_usecases: [chat]\nproxy:\n upstream_url: https://upstream.example/v1/chat/completions\n') + await page.goto('/app/model-editor/local') + await expect(page.getByText('Proxy Upstream URL')).toBeVisible({ timeout: 10_000 }) + await expect(page.getByTestId('proxy-vision-switch')).toHaveCount(0) + }) +}) + +const MODELS = [ + { id: 'cloud', capabilities: ['chat'] }, + { id: 'cloud-vl', capabilities: ['chat', 'vision'] }, +] + +async function stubChat(page, model, imageModel) { + const detail = { + id: 'c1', title: 'Photo', model, kind: 'chat', created: '2026-10-25T10:00:00Z', updated: '2026-10-25T10:00:00Z', + running: false, last_turn: 'done', pending: [], + } + await page.route('**/api/chats/c1/events', route => route.abort()) + await page.route(/\/api\/chats\/c1\/messages(\?.*)?$/, route => route.fulfill({ json: { messages: [] } })) + await page.route('**/api/chats/c1', route => route.fulfill({ json: detail })) + await page.route(/\/api\/chats(\?.*)?$/, route => route.fulfill({ + json: { conversations: [{ ...detail, pending: undefined }], persistent: true, files: true, image_model: imageModel }, + })) +} + +const attachPhoto = page => page.getByTestId('chat-file-input').setInputFiles({ name: 'photo.png', mimeType: 'image/png', buffer: PNG }) + +test.describe('Chat: an image for a model that cannot look at it', () => { + test.beforeEach(async ({ page }) => { await mockChat(page, { models: MODELS, loaded: [] }) }) + + test('names the model that will describe the image', async ({ page }) => { + await stubChat(page, 'cloud', 'helper-vl') + await page.goto('/app/chats/c1') + await expect(page.getByTestId('chat-input')).toBeVisible() + await expect(page.getByTestId('chat-vision-notice')).toHaveCount(0) + await attachPhoto(page) + await expect(page.getByTestId('chat-vision-notice')) + .toHaveText("This model can't look at images. Pick another model, or the image will be described by helper-vl.") + }) + + test('says when no model can describe the image', async ({ page }) => { + await stubChat(page, 'cloud', undefined) + await page.goto('/app/chats/c1') + await attachPhoto(page) + await expect(page.getByTestId('chat-vision-notice')) + .toHaveText("This model can't look at images, and no model here can describe them. Pick a model that can look at images.") + }) + + test('says nothing for a model that can look at images', async ({ page }) => { + await stubChat(page, 'cloud-vl', '') + await page.goto('/app/chats/c1') + await attachPhoto(page) + await expect(page.getByTestId('attachment')).toHaveCount(1) + await expect(page.getByTestId('chat-vision-notice')).toHaveCount(0) + }) +}) diff --git a/core/http/react-ui/e2e/chats-apps.spec.js b/core/http/react-ui/e2e/chats-apps.spec.js new file mode 100644 index 000000000..2b8956ca5 --- /dev/null +++ b/core/http/react-ui/e2e/chats-apps.spec.js @@ -0,0 +1,350 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockChat } from './chat-fixtures.js' + +// MCP Apps in a chat, with the chat API stubbed: a tool line of the transcript +// that carries an app shows the page in a sandboxed frame, the frame talks to the +// host over postMessage, and the host answers through the chat's two mcp-apps +// routes. The app below is a small page that drives every message the host +// supports, and probes the sandbox from the inside. + +const chat = { id: 'c1', title: 'Clock', model: 'qwen3-8b', kind: 'chat', created: '2026-10-25T10:00:00Z', updated: '2026-10-25T10:00:00Z', running: false } + +const APP_HTML = ` +

waiting

+ + +` + +const toolLine = { + seq: 2, turn: 1, role: 'tool', text: 'get-time: {"zone":"UTC"}\n12:00 UTC', at: '2026-10-25T10:00:01Z', + app: { + server: 'connector:abc', tool: 'get-time', resource_uri: 'ui://clock/app.html', visibility: ['model', 'app'], + input: { zone: 'UTC' }, result: { content: [{ type: 'text', text: '12:00 UTC' }], structuredContent: { time: '12:00' } }, + }, +} + +async function stub(page, { resource, calls = [], sent = [] } = {}) { + await mockChat(page) + await page.route(/\/api\/chats(\?.*)?$/, route => route.fulfill({ json: { conversations: [chat], persistent: true, files: true } })) + await page.route('**/api/chats/notifications/events', route => route.abort()) + await page.route('**/api/chats/c1/events', route => route.abort()) + await page.route(/\/api\/chats\/c1\/messages(\?.*)?$/, (route) => { + if (route.request().method() === 'POST') { + sent.push(route.request().postDataJSON()) + return route.fulfill({ json: { started: true } }) + } + return route.fulfill({ json: { messages: [ + { seq: 1, turn: 1, role: 'user', text: 'what time is it', at: '2026-10-25T10:00:00Z' }, + toolLine, + { seq: 3, turn: 1, role: 'assistant', text: 'It is noon.', at: '2026-10-25T10:00:02Z' }, + ] } }) + }) + await page.route('**/api/chats/c1', route => route.fulfill({ json: { ...chat, pending: [] } })) + await page.route(/\/api\/chats\/c1\/mcp-apps\/resource/, (route) => { + if (resource) return resource(route) + return route.fulfill({ json: { uri: 'ui://clock/app.html', mime_type: 'text/html;profile=mcp-app', html: APP_HTML, meta: { csp: {} } } }) + }) + await page.route(/\/api\/chats\/c1\/mcp-apps\/call/, (route) => { + calls.push(route.request().postDataJSON()) + return route.fulfill({ json: { result: { content: [{ type: 'text', text: 'refreshed' }] } } }) + }) +} + +const frame = (page) => page.frameLocator('[data-testid="chat-app-frame"]') + +test.describe('Chat MCP apps', () => { + test('shows the app under its tool line in a frame that has scripts and nothing else', async ({ page }) => { + await stub(page) + await page.goto('/app/chats/c1') + const el = page.getByTestId('chat-app-frame') + await expect(el).toBeVisible() + await expect(el).toHaveAttribute('sandbox', 'allow-scripts') + await expect(el).toHaveAttribute('referrerpolicy', 'no-referrer') + const srcdoc = await el.getAttribute('srcdoc') + expect(srcdoc.toLowerCase().startsWith(' { + const calls = [] + await stub(page, { calls }) + await page.goto('/app/chats/c1') + await expect(frame(page).locator('#out')).toHaveText('12:00 UTC') + await expect(frame(page).locator('#input')).toHaveText('{"zone":"UTC"}') + + await frame(page).locator('#call').click() + await expect(frame(page).locator('#reply')).toContainText('refreshed') + expect(calls).toEqual([{ server: 'connector:abc', tool: 'refresh', arguments: { zone: 'EU' } }]) + + await frame(page).locator('#size').click() + await expect.poll(async () => page.getByTestId('chat-app-frame').evaluate(el => Math.round(el.getBoundingClientRect().height))).toBe(333) + + await frame(page).locator('#unknown').click() + await expect(frame(page).locator('#reply')).toContainText('-32601') + }) + + test('the frame cannot read the page, its storage or the network', async ({ page }) => { + await stub(page) + await page.goto('/app/chats/c1') + await expect(frame(page).locator('#probe')).toHaveText('parent-blocked,storage-blocked,net-blocked') + }) + + test('a message the app suggests waits for a click, and a link opens only after one', async ({ page, context }) => { + const sent = [] + await stub(page, { sent }) + await page.goto('/app/chats/c1') + await expect(frame(page).locator('#out')).toHaveText('12:00 UTC') + + await frame(page).locator('#suggest').click() + const ask = page.getByTestId('chat-app-suggestion') + await expect(ask).toContainText('Show me March') + expect(sent).toEqual([]) + await ask.getByRole('button', { name: 'Send' }).click() + await expect.poll(() => sent).toEqual([{ text: 'Show me March' }]) + await expect(ask).toBeHidden() + + await frame(page).locator('#badlink').click() + await expect(frame(page).locator('#reply')).toContainText('"isError":true') + await expect(page.getByTestId('chat-app-link')).toHaveCount(0) + + await frame(page).locator('#link').click() + const link = page.getByTestId('chat-app-link').getByRole('link', { name: 'Open' }) + await expect(link).toHaveAttribute('href', 'https://example.com/docs') + await expect(link).toHaveAttribute('rel', 'noopener noreferrer') + await expect(link).toHaveAttribute('target', '_blank') + expect(context.pages()).toHaveLength(1) + }) + + test('a message from any other window is ignored', async ({ page }) => { + const calls = [] + await stub(page, { calls }) + await page.goto('/app/chats/c1') + await expect(frame(page).locator('#out')).toHaveText('12:00 UTC') + await page.evaluate(() => { + const call = { jsonrpc: '2.0', id: 99, method: 'tools/call', params: { name: 'refresh', arguments: {} } } + // From the page itself, and from a second sandboxed frame. + window.postMessage(call, '*') + const other = document.createElement('iframe') + other.setAttribute('sandbox', 'allow-scripts') + other.srcdoc = ` +` +const NEWS_HTML = `${STYLE} +

News

+
From news.json · updated this morning
+
    +` +const BOARD_HTML = `${STYLE} +

    Board

    +
    4 to do · 2 doing
    +
    • Write the release notes
    • Review the gallery PRs
    +` + +const widgets = (list) => ({ widgets: list }) +const tasks = { id: 'tasks', type: 'list', title: 'Open tasks', source: `data/t-${stamp}.json` } +const tiles = [ + { id: 'clock', type: 'page', page: CLOCK }, + tasks, + { id: 'board', type: 'page', page: BOARD }, + { id: 'news', type: 'page', page: NEWS }, + { id: 'chats', type: 'chats', title: 'Last chats' }, +] + +const tile = (page, id) => page.getByTestId(`home-widget-${id}`) +const tileFrame = (page, id) => tile(page, id).frameLocator('[data-testid="page-frame"]') + +async function home(page, theme = 'light') { + await page.route('**/api/chats/triggers*', (r) => r.fulfill({ json: { triggers: [] } })) + await page.addInitScript((t) => { try { localStorage.setItem('localai-theme', t) } catch { /* private mode */ } }, theme) + await page.goto('/app') +} + +test.beforeAll(async ({ request }) => { + const files = { + [`/pages/${CLOCK}/index.html`]: CLOCK_HTML, + [`/pages/${CLOCK}/page.json`]: { title: 'Clock', icon: 'clock', widget: { size: 'small', title: 'Now' } }, + [`/pages/${NEWS}/index.html`]: NEWS_HTML, + [`/pages/${NEWS}/page.json`]: { title: 'News ticker', icon: 'file-text', widget: { size: 'large' } }, + [`/pages/${NEWS}/news.json`]: { items: ['Local models get faster on small boards', 'A new release of the gallery', 'Three tips for long contexts', 'What changed in the scheduler'] }, + [`/pages/${BOARD}/index.html`]: BOARD_HTML, + [`/pages/${BOARD}/page.json`]: { title: 'Task board', icon: 'checklist', widget: { size: 'medium', title: 'Board' } }, + [`/pages/${PLAIN}/index.html`]: '

    no widget

    ', + [`/pages/${PLAIN}/page.json`]: { title: 'Plain page' }, + [`/data/t-${stamp}.json`]: { items: [{ label: 'Call the dentist', meta: 'Fri' }, { label: 'Send the invoice', done: true }] }, + } + for (const [p, body] of Object.entries(files)) expect((await put(request, p, body)).ok(), p).toBeTruthy() + expect((await put(request, '/LocalAI/home.json', widgets(tiles))).ok()).toBeTruthy() +}) +test.afterAll(async ({ request }) => { + for (const p of ['/LocalAI/home.json', `/data/t-${stamp}.json`, `/pages/${CLOCK}`, `/pages/${NEWS}`, `/pages/${BOARD}`, `/pages/${PLAIN}`]) await del(request, p) +}) +test.afterAll(() => unlockHomeFiles()) + +test('a page tile runs the page in its compact mode, at its size', async ({ page }) => { + await home(page) + await expect(tile(page, 'clock')).toHaveAttribute('data-size', 'small') + await expect(tile(page, 'news')).toHaveAttribute('data-size', 'large') + await expect(tile(page, 'clock').locator('.wk-ptile__title')).toHaveText('Now') + await expect(tile(page, 'news').locator('.wk-ptile__title')).toHaveText('News ticker') + const clock = tileFrame(page, 'clock') + await expect(clock.locator('#mode')).toHaveText('widget small') + await expect(clock.locator('html')).toHaveAttribute('data-mode', 'widget') + await expect(clock.locator('.compact')).toBeVisible() + await expect(clock.locator('.full')).toBeHidden() + // The news tile reads its data with fetch, once it is in view. + await tile(page, 'news').scrollIntoViewIfNeeded() + await expect(tileFrame(page, 'news').locator('li')).toHaveCount(4) + // Heights by size: small < medium < large, and a large tile spans two columns. + const h = async (id) => (await tile(page, id).locator('.wk-ptile__body').boundingBox()).height + expect(await h('clock')).toBeLessThan(await h('board')) + expect(await h('board')).toBeLessThan(await h('news')) + const open = tile(page, 'clock').getByTestId('page-tile-open') + await expect(open).toHaveAttribute('href', `/app/pages/${CLOCK}`) + await expect(tile(page, 'clock')).toHaveAttribute('data-frame', 'live') + if (shots) { + await tile(page, 'clock').scrollIntoViewIfNeeded() + await page.waitForTimeout(300) + await page.screenshot({ path: `${shots}/home-tiles-light.png`, fullPage: true }) + } + await open.click() + await expect(page).toHaveURL(new RegExp(`/app/pages/${CLOCK}$`)) + // The same page in its own view runs its full layout. + const full = page.getByTestId('page-view').frameLocator('[data-testid="page-frame"]') + await expect(full.locator('#mode')).toHaveText('page ') + await expect(full.locator('html')).toHaveAttribute('data-mode', 'page') + await expect(full.locator('.full')).toBeVisible() + await expect(full.locator('.compact')).toBeHidden() +}) + +test('the refresh button builds the frame again and the tile stays in place', async ({ page }) => { + await home(page) + const clock = tileFrame(page, 'clock') + await expect(clock.locator('#mode')).toHaveText('widget small') + const before = await tile(page, 'clock').boundingBox() + await tile(page, 'clock').getByTestId('page-tile-refresh').click() + await expect(clock.locator('#mode')).toHaveText('widget small') + const after = await tile(page, 'clock').boundingBox() + expect(after.height).toBe(before.height) +}) + +test('a link inside a tile asks first, in the tile', async ({ page, context }) => { + await context.route('https://news.example.com/**', (r) => r.fulfill({ contentType: 'text/html', body: 't

    ok

    ' })) + await home(page) + await tileFrame(page, 'clock').locator('#ext').click() + const ask = tile(page, 'clock').getByTestId('page-link-ask') + await expect(ask).toBeVisible() + await expect(ask.getByTestId('page-link-host')).toHaveText('news.example.com') + if (shots) await tile(page, 'clock').screenshot({ path: `${shots}/home-tile-link-ask.png` }) + let opened = 0 + context.on('page', () => { opened++ }) + await ask.getByTestId('page-link-cancel').click() + await expect(ask).toHaveCount(0) + await page.waitForTimeout(200) + expect(opened).toBe(0) + await tileFrame(page, 'clock').locator('#ext').click() + const [tab] = await Promise.all([context.waitForEvent('page'), tile(page, 'clock').getByTestId('page-link-open').click()]) + await tab.waitForLoadState() + expect(tab.url()).toBe('https://news.example.com/time') + expect(await tab.evaluate(() => window.opener)).toBeNull() + await tab.close() +}) + +test('the tiles in the dark theme and on a phone', async ({ page }) => { + await home(page, 'dark') + await tile(page, 'news').scrollIntoViewIfNeeded() + await expect(tileFrame(page, 'news').locator('li')).toHaveCount(4) + await expect(tileFrame(page, 'clock').locator('html')).toHaveAttribute('data-theme', 'dark') + if (shots) { + await tile(page, 'clock').scrollIntoViewIfNeeded() + await page.waitForTimeout(300) + await page.screenshot({ path: `${shots}/home-tiles-dark.png`, fullPage: true }) + } + await page.setViewportSize({ width: 390, height: 844 }) + await tile(page, 'clock').scrollIntoViewIfNeeded() + await expect(tileFrame(page, 'clock').locator('#mode')).toHaveText('widget small') + // One column: no tile is wider than the screen. + for (const id of ['clock', 'board', 'news']) { + const box = await tile(page, id).boundingBox() + expect(box.x + box.width).toBeLessThanOrEqual(390) + } + if (shots) { + // A tall phone screen, so every tile is in view and has its frame. + await page.setViewportSize({ width: 390, height: 2400 }) + await expect(tileFrame(page, 'news').locator('li')).toHaveCount(4) + await expect(tileFrame(page, 'clock').locator('#mode')).toHaveText('widget small') + await page.waitForTimeout(300) + await page.screenshot({ path: `${shots}/home-tiles-phone-dark.png`, fullPage: true }) + } +}) + +test('the tiles on a phone in the light theme', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await home(page) + await tile(page, 'news').scrollIntoViewIfNeeded() + await expect(tileFrame(page, 'news').locator('li')).toHaveCount(4) + // Every tile was in view once, so each has its frame for the shot. + await tile(page, 'clock').scrollIntoViewIfNeeded() + await expect(tileFrame(page, 'clock').locator('#mode')).toHaveText('widget small') + if (shots) { + // A tall phone screen, so every tile is in view and has its frame. + await page.setViewportSize({ width: 390, height: 2400 }) + await expect(tileFrame(page, 'news').locator('li')).toHaveCount(4) + await expect(tileFrame(page, 'clock').locator('#mode')).toHaveText('widget small') + await page.waitForTimeout(300) + await page.screenshot({ path: `${shots}/home-tiles-phone-light.png`, fullPage: true }) + } +}) + +test('Customise Home lists the pages that can be a tile, with their size', async ({ page }) => { + await home(page) + await page.getByTestId('home-edit-layout').click() + const editor = page.getByTestId('home-layout-editor') + await expect(editor).toBeVisible() + const picks = editor.getByTestId('home-layout-page') + await expect(picks).toHaveCount(3) + const options = await picks.first().locator('option').allTextContents() + expect(options).toEqual(expect.arrayContaining(['Clock', 'News ticker', 'Task board'])) + expect(options).not.toContain('Plain page') + await expect(editor.getByTestId('home-layout-page-size').first()).toHaveAttribute('data-size', 'small') + // Three page widgets: the limit says so and no fourth can be added. + await expect(editor.getByTestId('home-layout-page-limit')).toBeVisible() + await expect(editor.getByTestId('home-layout-add-page')).toBeDisabled() + if (shots) await editor.screenshot({ path: `${shots}/home-tiles-editor.png` }) + // Swap the board for the clock... and back: a save keeps the layout valid. + await picks.nth(1).selectOption({ label: 'News ticker' }) + await expect(editor.getByTestId('home-layout-page-size').nth(1)).toHaveAttribute('data-size', 'large') + await picks.nth(1).selectOption({ label: 'Task board' }) + await editor.getByTestId('home-layout-save').click() + await expect(editor).toHaveCount(0) + await expect(tile(page, 'board')).toHaveAttribute('data-size', 'medium') +}) + +test('the server takes at most three page widgets and says why', async ({ request }) => { + const four = [1, 2, 3, 4].map((n) => ({ id: `p${n}`, type: 'page', page: CLOCK })) + const res = await request.put('/api/chats/wellknown/home', { data: { values: { widgets: four }, replace: true } }) + expect(res.ok()).toBeFalsy() + expect(JSON.stringify(await res.json())).toContain('Home shows at most 3 page widgets, because each one runs its page; this layout has 4') + const bad = await request.put('/api/chats/wellknown/home', { data: { values: { widgets: [{ id: 'p', type: 'page', page: '../x' }] }, replace: true } }) + expect(JSON.stringify(await bad.json())).toContain('is not a page name') + const schema = await (await request.get('/api/chats/wellknown/home/schema')).json() + expect(JSON.stringify(schema)).toContain('"maxContains":3') +}) + +test('a tile whose page is gone, or has no widget block, says so and links to Customise Home', async ({ page, request }) => { + expect((await put(request, '/LocalAI/home.json', widgets([ + { id: 'gone', type: 'page', page: GONE }, + { id: 'plain', type: 'page', page: PLAIN }, + tasks, + ]))).ok()).toBeTruthy() + await home(page) + const gone = tile(page, 'gone') + await expect(gone.getByTestId('page-tile-gone')).toContainText('This page is no longer available') + await expect(gone.locator('iframe')).toHaveCount(0) + await expect(gone.getByTestId('page-tile-open')).toHaveCount(0) + const plain = tile(page, 'plain') + await expect(plain.getByTestId('page-tile-noWidget')).toContainText('This page has no widget view') + await expect(plain.locator('iframe')).toHaveCount(0) + await expect(plain.getByTestId('page-tile-open')).toHaveAttribute('href', `/app/pages/${PLAIN}`) + if (shots) { + await gone.scrollIntoViewIfNeeded() + await page.screenshot({ path: `${shots}/home-tiles-calm-light.png`, fullPage: true }) + } + await gone.getByTestId('page-tile-fix').click() + const editor = page.getByTestId('home-layout-editor') + await expect(editor).toBeVisible() + // The gone page stays in the row, marked, so a save does not drop it unseen. + await expect(editor.getByTestId('home-layout-page').first()).toContainText(`${GONE} (not available)`) +}) + +test('the calm states in the dark theme', async ({ page }) => { + await home(page, 'dark') + await expect(tile(page, 'gone').getByTestId('page-tile-gone')).toBeVisible() + if (shots) { + await tile(page, 'gone').scrollIntoViewIfNeeded() + await page.screenshot({ path: `${shots}/home-tiles-calm-dark.png`, fullPage: true }) + } +}) + +test('a tile builds its frame only once it scrolls into view', async ({ page, request }) => { + const lists = Array.from({ length: 11 }, (_, i) => ({ ...tasks, id: `l${i}`, title: `List ${i + 1}` })) + expect((await put(request, '/LocalAI/home.json', widgets([...lists, { id: 'clock', type: 'page', page: CLOCK }]))).ok()).toBeTruthy() + await page.setViewportSize({ width: 1280, height: 480 }) + await home(page) + const clock = tile(page, 'clock') + await expect(clock).toHaveAttribute('data-state', 'ready') + await expect(clock).not.toBeInViewport() + await expect(clock).toHaveAttribute('data-frame', 'none') + await expect(clock.locator('iframe')).toHaveCount(0) + const reserved = (await clock.locator('.wk-ptile__body').boundingBox()).height + await clock.scrollIntoViewIfNeeded() + await expect(clock.locator('iframe')).toHaveCount(1) + await expect(tileFrame(page, 'clock').locator('#mode')).toHaveText('widget small') + expect((await clock.locator('.wk-ptile__body').boundingBox()).height).toBe(reserved) + // Far away again: the frame goes; back in view: it comes back. + await page.evaluate(() => window.scrollTo(0, 0)) + await page.setViewportSize({ width: 1280, height: 200 }) + await page.evaluate(() => window.scrollTo(0, 0)) + await expect(clock).toHaveAttribute('data-frame', /none|paused/) + await clock.scrollIntoViewIfNeeded() + await expect(clock).toHaveAttribute('data-frame', 'live') +}) diff --git a/core/http/react-ui/e2e/home-transition.spec.js b/core/http/react-ui/e2e/home-transition.spec.js new file mode 100644 index 000000000..dc86341ce --- /dev/null +++ b/core/http/react-ui/e2e/home-transition.spec.js @@ -0,0 +1,221 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockHome } from './home-fixtures.js' + +// Send on Home opens the new chat as one move: the composer travels to the +// dock, the message is in the thread from the first frame of the chat page, +// and Home is gone by then (hooks/useComposerTransition.js). A probe in the +// page records every animation frame, so the specs can say what was never on +// screen: the empty chat, the route's loading placeholder, Home and the +// message together, the message twice. + +const CONV = { id: 'n1', title: 'New chat', model: 'qwen3-8b-instruct', kind: 'chat', created: '2026-10-30T10:00:00Z', updated: '2026-10-30T10:00:00Z', running: false } +const sleep = (ms) => new Promise(r => setTimeout(r, ms)) + +// The chat API, a little slow, as a model host is: the create, the send and +// the reads of the chat each take a moment. +async function server(page, { failSend = false, text = 'What changed in the 3.4 release?', attachments = [] } = {}) { + const seen = { created: 0, sent: 0, deleted: 0, reads: 0 } + let sent = false + await page.route(/\/api\/chats(\?.*)?$/, async route => { + if (route.request().method() === 'POST') { + seen.created++ + await sleep(150) + return route.fulfill({ status: 201, json: CONV }) + } + return route.fulfill({ json: { conversations: [], persistent: true, files: true } }) + }) + await page.route(/\/api\/chats\/n1\/messages(\?.*)?$/, async route => { + if (route.request().method() === 'POST') { + seen.sent++ + await sleep(150) + if (failSend) return route.fulfill({ status: 500, json: { error: { message: 'the model is not available' } } }) + sent = true + return route.fulfill({ json: { started: true } }) + } + seen.reads++ + await sleep(300) + return route.fulfill({ json: { messages: sent ? [{ seq: 1, turn: 1, role: 'user', text, attachments, at: '2026-10-30T10:00:01Z' }] : [] } }) + }) + await page.route('**/api/chats/n1/events', route => route.abort()) + await page.route('**/api/chats/n1', async route => { + if (route.request().method() === 'DELETE') { seen.deleted++; return route.fulfill({ json: {} }) } + await sleep(300) + return route.fulfill({ json: { ...CONV, running: sent, pending: [] } }) + }) + await page.route(/\/api\/chats\/draft\/tools(\?.*)?$/, route => route.fulfill({ json: { builtins: [], addons: [], servers: [], connectors: [], off: [] } })) + await page.route(/\/api\/chats\/draft\/addons(\?.*)?$/, route => route.fulfill({ json: { addons: [], commands: 'none' } })) + await page.route(/\/api\/chats\/skills(\?.*)?$/, route => route.fulfill({ json: { skills: [] } })) + await page.route('**/api/chats/agents', route => route.fulfill({ json: { agents: [] } })) + return seen +} + +// One sample per animation frame from Send on. +function probe() { + window.__frames = [] + const shown = (el) => { + if (!el) return false + const r = el.getBoundingClientRect() + if (r.width === 0 || r.height === 0) return false + for (let e = el; e; e = e.parentElement) if (Number(getComputedStyle(e).opacity) === 0) return false + return true + } + const tick = () => { + if (window.__probeOn) { + window.__frames.push({ + path: location.pathname, + home: shown(document.querySelector('.home-greeting')), + empty: shown(document.querySelector('[data-testid="chat-empty"]')), + placeholder: !!document.querySelector('.route-fallback'), + chat: !!document.querySelector('[data-testid="chats-page"]'), + users: document.querySelectorAll('[data-testid="chat-message"][data-role="user"]').length, + handoff: document.documentElement.dataset.handoff || '', + }) + } + requestAnimationFrame(tick) + } + requestAnimationFrame(tick) +} + +async function ready(page, { noViewTransitions = false } = {}) { + await page.addInitScript(probe) + if (noViewTransitions) await page.addInitScript(() => { delete Document.prototype.startViewTransition }) + await page.goto('/app') + await expect(page.getByTestId('home-model-chip')).toContainText('qwen3-8b-instruct', { timeout: 15_000 }) +} + +async function send(page, text) { + await page.getByTestId('home-input').fill(text) + await page.evaluate(() => { window.__probeOn = true }) + await page.getByTestId('home-input').press('Enter') +} + +// What every frame of a hand-off must hold, whatever the motion mode. +async function expectOneMove(page, text) { + await expect(page).toHaveURL(/\/app\/chats\/n1$/) + const bubble = page.getByTestId('chat-message').filter({ hasText: text }) + await expect(bubble).toHaveCount(1) + // The row drawn from the hand-off is the one that stays: mark it, wait for + // the server's transcript, and it is still the same element. + await bubble.evaluate(el => { el.__first = true }) + await expect(page.locator('html')).not.toHaveAttribute('data-handoff', /.+/) + await page.waitForTimeout(900) + await expect(bubble).toHaveCount(1) + expect(await bubble.evaluate(el => el.__first === true)).toBe(true) + // Docked and focused, ready for the next message. + await expect(page.getByTestId('chat-input')).toBeFocused() + await expect(page.getByTestId('chat-input')).toHaveValue('') + await expect(page.getByTestId('chat-composer')).toBeInViewport() + await expect(page.locator('.home-page')).toHaveCount(0) + + const frames = await page.evaluate(() => window.__frames) + const onChat = frames.filter(f => f.path.startsWith('/app/chats/')) + expect(onChat.length).toBeGreaterThan(0) + expect(frames.filter(f => f.empty), 'the empty chat was never on screen').toEqual([]) + expect(frames.filter(f => f.placeholder), 'the loading placeholder was never on screen').toEqual([]) + expect(frames.filter(f => f.home && f.users > 0), 'Home and the message were never on screen together').toEqual([]) + expect(frames.filter(f => f.users > 1), 'the message was never drawn twice').toEqual([]) + // Once the chat page is there, the message is in it in every frame. + expect(frames.filter(f => f.chat && f.users !== 1)).toEqual([]) + return frames +} + +test.describe('Home to chat', () => { + test.beforeEach(async ({ page }) => { + await mockHome(page) + }) + + test('the composer glides into the chat and the message is there from the first frame', async ({ page }) => { + await server(page) + await ready(page) + const text = 'What changed in the 3.4 release?' + await send(page, text) + const frames = await expectOneMove(page, text) + expect(frames.some(f => f.handoff === 'glide')).toBe(true) + // The model is working: its indicator follows the message. + await expect(page.getByTestId('chat-streaming')).toBeVisible() + }) + + test('without view transitions the composer and the message slide in from Home', async ({ page }) => { + const text = 'Draft the release post' + await server(page, { text }) + await ready(page, { noViewTransitions: true }) + await page.getByTestId('home-input').fill(text) + // Catch the slide on the dock's composer as it starts. + await page.evaluate(() => { + window.__slid = false + const check = () => { + const card = document.querySelector('.cx-dock form.home-cmd') + if (card && card.getAnimations().length > 0) { window.__slid = true; return } + requestAnimationFrame(check) + } + requestAnimationFrame(check) + window.__probeOn = true + }) + await page.getByTestId('home-input').press('Enter') + const frames = await expectOneMove(page, text) + expect(frames.some(f => f.handoff === 'flip')).toBe(true) + expect(await page.evaluate(() => window.__slid)).toBe(true) + }) + + test('the files travel with the message and show once', async ({ page }) => { + await server(page, { text: 'What is in this file?', attachments: [{ path: '/Chats/n1/notes.txt', name: 'notes.txt', size: 17, mime: 'text/plain' }] }) + await ready(page) + await page.getByTestId('chat-file-input').setInputFiles({ name: 'notes.txt', mimeType: 'text/plain', buffer: Buffer.from('remember the milk') }) + await send(page, 'What is in this file?') + await expectOneMove(page, 'What is in this file?') + const message = page.getByTestId('chat-message').filter({ hasText: 'What is in this file?' }) + await expect(message.getByTestId('attachment')).toHaveCount(1) + await expect(message.getByTestId('attachment')).toContainText('notes.txt') + // The server's copy has a path: the chip can be downloaded now. + await expect(message.getByTestId('attachment').locator('a')).toHaveCount(1) + }) + + test('a message that cannot go stays on Home, with Home back and no stuck overlay', async ({ page }) => { + const seen = await server(page, { failSend: true }) + await ready(page) + await send(page, 'This will not go') + await expect(page.getByText('the model is not available')).toBeVisible() + await expect(page).toHaveURL(/\/app$/) + await expect(page.getByTestId('home-input')).toHaveValue('This will not go') + await expect(page.getByTestId('home-input')).toBeFocused() + await expect(page.locator('.home-greeting')).toBeVisible() + await expect(page.locator('.home-page')).not.toHaveAttribute('data-depart', /.+/) + await expect(page.locator('html')).not.toHaveAttribute('data-handoff', /.+/) + await expect.poll(() => seen.deleted).toBe(1) + const frames = await page.evaluate(() => window.__frames) + expect(frames.some(f => f.path !== '/app')).toBe(false) + // It can be sent again. + await expect(page.getByTestId('home-send')).toBeEnabled() + }) + + test('a second Enter while the first goes sends nothing more', async ({ page }) => { + const seen = await server(page) + await ready(page) + await page.getByTestId('home-input').fill('Only once') + await page.getByTestId('home-input').press('Enter') + await page.getByTestId('home-input').press('Enter').catch(() => {}) + await expect(page).toHaveURL(/\/app\/chats\/n1$/) + await page.keyboard.press('Enter') + await page.waitForTimeout(500) + expect(seen.created).toBe(1) + expect(seen.sent).toBe(1) + }) +}) + +test.describe('Home to chat, less motion', () => { + test('it is a cross-fade, with the same guarantees', async ({ page }) => { + // test.use({ reducedMotion }) does not reach the extended page fixture. + await page.emulateMedia({ reducedMotion: 'reduce' }) + await mockHome(page) + const text = 'Short answers, please' + await server(page, { text }) + await ready(page) + await send(page, text) + const frames = await expectOneMove(page, text) + expect(frames.some(f => f.handoff === 'glide')).toBe(false) + // Home does not fade away while the message goes. + const leaving = frames.filter(f => f.path === '/app') + expect(leaving.length).toBeGreaterThan(0) + expect(leaving.every(f => f.home)).toBe(true) + }) +}) diff --git a/core/http/react-ui/e2e/hub-tabs.spec.js b/core/http/react-ui/e2e/hub-tabs.spec.js index 8ac5f85b6..d65645274 100644 --- a/core/http/react-ui/e2e/hub-tabs.spec.js +++ b/core/http/react-ui/e2e/hub-tabs.spec.js @@ -1,7 +1,8 @@ import { test, expect } from './coverage-fixtures.js' -// The Build and Operate hubs replace the second navigation rail with a tab bar -// (the kit's dk-hubtabs). Routes stay flat; the tab that owns the current +// The Operate hub replaces the second navigation rail with a tab bar (the +// kit's dk-hubtabs). The Build hub is gone: its pages are sidebar items or +// part of adding a model. Routes stay flat; the tab that owns the current // path carries aria-current="page", and a tab nobody can use is not drawn. const BAR = '.dk-hubtabs' @@ -24,19 +25,12 @@ async function quietOperate(page) { } test.describe('Hub tabs: the right tab for each route', () => { - const build = [ - ['/app/build', 'overview'], - ['/app/agents', 'agents'], - ['/app/skills', 'skills'], - ['/app/collections', 'memory'], - ['/app/agent-jobs', 'jobs'], - ['/app/import-model', 'import'], - ] - for (const [path, id] of build) { - test(`Build: ${path} highlights ${id}`, async ({ page }) => { + // The pages the Build hub had are pages of their own: no tab bar. + for (const path of ['/app/collections', '/app/import-model']) { + test(`${path} has no hub tab bar`, async ({ page }) => { await page.goto(path) - await expect(tab(page, id)).toHaveAttribute('aria-current', 'page') - await expect(page.locator(`${BAR} [aria-current="page"]`)).toHaveCount(1) + await expect(page.locator('h1').first()).toBeVisible() + await expect(page.locator(BAR)).toHaveCount(0) }) } @@ -115,36 +109,26 @@ test.describe('Hub tabs: gating', () => { await expect(tab(page, 'machine')).toHaveCount(0) }) - test('feature flags hide the tabs they gate', async ({ page }) => { - await mockFeatures(page, { agents: false, mcp: false, fine_tuning: false, quantization: false, distributed: false }) - await page.goto('/app/build') - await expect(tab(page, 'overview')).toBeVisible() - for (const id of ['agents', 'skills', 'memory', 'jobs']) { - await expect(tab(page, id)).toHaveCount(0) - } - // The overview lists only what the bar lists. - await expect(page.getByRole('list', { name: 'Build tools' }).locator('a[href="/app/agents"]')).toHaveCount(0) - await expect(page.getByRole('list', { name: 'Build tools' }).locator('a[href="/app/import-model"]')).toBeVisible() + test('feature flags hide the sidebar items they gate', async ({ page }) => { + await mockFeatures(page, { collections: false, mcp: false, fine_tuning: false, quantization: false, distributed: false }) + await page.goto('/app') + await expect(page.locator('.sidebar-nav a.nav-item[href="/app/collections"]')).toHaveCount(0) + await expect(page.locator('.sidebar-nav a.nav-item[href="/app/agents"]')).toBeVisible() }) - test('a user without admin or permissions sees only the tabs they can use', async ({ page }) => { - await mockFeatures(page, { agents: true, mcp: true, fine_tuning: true, quantization: true }) - await mockAuth(page, { id: 'u1', name: 'Sam', role: 'user', permissions: { agents: true } }) - await page.goto('/app/agents') - await expect(tab(page, 'agents')).toHaveAttribute('aria-current', 'page') - for (const id of ['skills', 'memory', 'jobs', 'import', 'voices', 'faces']) { - await expect(tab(page, id)).toHaveCount(0) - } - // The Build hub no longer lists fine-tuning or quantization at all. - await expect(tab(page, 'fine-tune')).toHaveCount(0) - await expect(tab(page, 'quantize')).toHaveCount(0) + test('a user without admin sees Memory with the collections permission and no Import', async ({ page }) => { + await mockFeatures(page, { collections: true, mcp: true, fine_tuning: true, quantization: true }) + await mockAuth(page, { id: 'u1', name: 'Sam', role: 'user', permissions: { collections: true } }) + await page.goto('/app') + await expect(page.locator('.sidebar-nav a.nav-item[href="/app/collections"]')).toBeVisible() + await expect(page.locator('.sidebar-nav a.nav-item[href="/app/import-model"]')).toHaveCount(0) }) test('a non-admin user gets no Operate entry in the sidebar', async ({ page }) => { - await mockAuth(page, { id: 'u1', name: 'Sam', role: 'user', permissions: { agents: true } }) + await mockAuth(page, { id: 'u1', name: 'Sam', role: 'user', permissions: { collections: true } }) await page.goto('/app') - await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' })).toHaveCount(0) - await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Build' })).toBeVisible() + await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Administration' })).toHaveCount(0) + await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Build' })).toHaveCount(0) }) test('Users appears in the Settings row only with auth on', async ({ page }) => { @@ -171,39 +155,25 @@ test.describe('Hub tabs: badges', () => { await page.goto('/app/operate') await expect(tab(page, 'status').locator('.dk-hubtab-attn')).toContainText('1') }) - - test('the Build bar carries no badges', async ({ page }) => { - await page.goto('/app/agents') - await expect(tab(page, 'agents')).toBeVisible() - await expect(page.locator(`${BAR} .dk-hubtab-attn, ${BAR} .dk-hubtab-count`)).toHaveCount(0) - }) }) test.describe('Hub tabs: phone', () => { test.use({ viewport: { width: 390, height: 844 } }) - test('the Build bar scrolls inside its own box and the page does not', async ({ page }) => { - await page.goto('/app/build') - const bar = page.locator(BAR) - await expect(bar).toBeVisible() - expect((await bar.boundingBox()).width).toBeLessThanOrEqual(390) - expect(await bar.evaluate(el => el.scrollWidth > el.clientWidth)).toBe(true) - expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)).toBe(false) - await expect(bar).toHaveCSS('overflow-x', 'auto') - }) - test('scrolling reaches the last tab and keeps it tappable', async ({ page }) => { - await page.goto('/app/build') - const last = tab(page, 'import') + await quietOperate(page) + await page.goto('/app/operate') + const last = tab(page, 'settings') await last.scrollIntoViewIfNeeded() await expect(last).toBeInViewport() await last.click() - await expect(page).toHaveURL(/\/app\/import-model$/) - await expect(tab(page, 'import')).toHaveAttribute('aria-current', 'page') + await expect(page).toHaveURL(/\/app\/settings$/) + await expect(tab(page, 'settings')).toHaveAttribute('aria-current', 'page') }) test('every tab is at least 36px tall', async ({ page }) => { - await page.goto('/app/build') + await quietOperate(page) + await page.goto('/app/operate') const heights = await page.locator(`${BAR} .dk-hubtab`).evaluateAll(els => els.map(el => el.getBoundingClientRect().height)) expect(Math.min(...heights)).toBeGreaterThanOrEqual(36) }) diff --git a/core/http/react-ui/e2e/import-flow.spec.js b/core/http/react-ui/e2e/import-flow.spec.js index 23a2b9136..6f221986a 100644 --- a/core/http/react-ui/e2e/import-flow.spec.js +++ b/core/http/react-ui/e2e/import-flow.spec.js @@ -223,7 +223,7 @@ test.describe('Import: running and done', () => { const done = page.getByTestId('import-done') await expect(done).toContainText('qwen2.5-7b-instruct is imported') await expectStep(page, 'done', 'current') - await expect(done.getByRole('link', { name: 'Chat with qwen2.5-7b-instruct' })).toHaveAttribute('href', '/app/chat/qwen2.5-7b-instruct') + await expect(done.getByRole('link', { name: 'Chat with qwen2.5-7b-instruct' })).toHaveAttribute('href', '/app/chats?model=qwen2.5-7b-instruct') await expect(done.getByTestId('import-open-models')).toHaveAttribute('href', '/app/models?view=installed') await done.getByRole('button', { name: 'Import another' }).click() await expect(source(page)).toHaveValue('') diff --git a/core/http/react-ui/e2e/inbox-fixtures.js b/core/http/react-ui/e2e/inbox-fixtures.js new file mode 100644 index 000000000..7459e4887 --- /dev/null +++ b/core/http/react-ui/e2e/inbox-fixtures.js @@ -0,0 +1,78 @@ +import { mockChat } from './chat-fixtures.js' + +// The chat page with a fake inbox. The server is a small stateful inbox kept +// in `inbox.rows`; the notification stream is a fake EventSource the spec +// drives with window.__inboxEmit(name). `inbox.fail` makes the list read +// answer 500 while it is true. `inbox.calls` records the deletes, and +// `inbox.reads` the mark-read bodies. + +export const detail = { id: 'c1', title: 'Quotes', model: 'qwen3-8b', kind: 'chat', created: '2026-10-25T10:00:00Z', updated: '2026-10-25T10:00:00Z', running: false, pending: [] } + +export function makeInbox(count, readIds = []) { + const rows = Array.from({ length: count }, (_, i) => ({ + id: i + 1, kind: 'run_ended', status: 'ok', source: 'trigger', title: `Run ${i + 1}`, body: '', link: `/app/chats/r${i + 1}`, + read: readIds.includes(i + 1), created: new Date(Date.now() - (count - i) * 60_000).toISOString(), + })) + // Newest first, as the server lists them. + rows.reverse() + return { rows, calls: [], reads: [], lists: [], fail: false } +} + +export async function stubInbox(page, inbox) { + await mockChat(page) + await page.addInitScript(() => { + const sources = [] + window.EventSource = class { + constructor() { this.listeners = {}; sources.push(this); setTimeout(() => this.onopen && this.onopen(), 0) } + addEventListener(name, fn) { (this.listeners[name] ||= []).push(fn) } + close() {} + } + window.__inboxEmit = (name) => sources.forEach(s => (s.listeners[name] || []).forEach(fn => fn({}))) + }) + await page.route(/\/api\/chats(\?.*)?$/, route => route.fulfill({ json: { conversations: [detail], persistent: true, files: true } })) + await page.route('**/api/chats/c1/messages*', route => route.fulfill({ json: { messages: [] } })) + await page.route('**/api/chats/c1', route => route.fulfill({ json: detail })) + await page.route('**/api/chats/agents*', route => route.fulfill({ json: { agents: [] } })) + await page.route('**/api/chats/push/**', route => route.fulfill({ status: 409, json: { error: { message: 'off' } } })) + await page.route(/\/api\/chats\/notifications(\?.*)?$/, async (route) => { + const req = route.request() + const unread = () => inbox.rows.filter(r => !r.read).length + if (req.method() === 'DELETE') { + const body = req.postDataJSON() + inbox.calls.push(body) + const before = inbox.rows.length + if (body.all) inbox.rows = inbox.rows.filter(r => body.read_only && !r.read) + else inbox.rows = inbox.rows.filter(r => !body.ids.includes(r.id)) + return route.fulfill({ json: { unread: unread(), deleted: before - inbox.rows.length } }) + } + const url = new URL(req.url()) + inbox.lists.push(url.search) + if (inbox.fail) return route.fulfill({ status: 500, json: { error: { message: 'the database is busy' } } }) + const limit = Number(url.searchParams.get('limit') || 50) + const only = url.searchParams.get('unread') === '1' + const rows = only ? inbox.rows.filter(r => !r.read) : inbox.rows + return route.fulfill({ json: { notifications: rows.slice(0, limit), unread: unread(), total: inbox.rows.length } }) + }) + await page.route('**/api/chats/notifications/read', async (route) => { + const body = route.request().postDataJSON() + inbox.reads.push(body) + inbox.rows.forEach((r) => { if (body.all || (body.ids || []).includes(r.id)) r.read = true }) + return route.fulfill({ json: { unread: inbox.rows.filter(r => !r.read).length } }) + }) +} + +export function inboxButton(page) { + return page.getByTestId('chat-inbox').getByRole('button', { name: /^Notifications/ }) +} + +export async function openInbox(page) { + await page.goto('/app/chats/c1') + await inboxButton(page).click() + await page.getByRole('dialog', { name: 'Notifications' }).waitFor() +} + +// Opens the panel's menu and picks one of its items. +export async function menuPick(page, name) { + await page.getByTestId('inbox-menu').getByRole('button').click() + await page.getByRole('menuitem', { name }).click() +} diff --git a/core/http/react-ui/e2e/jobs-fixtures.js b/core/http/react-ui/e2e/jobs-fixtures.js deleted file mode 100644 index f68cbc4d3..000000000 --- a/core/http/react-ui/e2e/jobs-fixtures.js +++ /dev/null @@ -1,159 +0,0 @@ -// Shared fixtures for the Jobs specs: tasks and jobs the API would return, the -// stubbed agent jobs endpoints, and the requests the page sent. - -const MIN = 60 * 1000 -const HOUR = 60 * MIN - -// Times are built from the start of today, so "today" and "yesterday" mean the -// same thing whenever the suite runs. -function clockTimes() { - const now = Date.now() - const d = new Date(now) - const midnight = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() - const sinceMidnight = now - midnight - const todayAt = (ms) => new Date(Math.max(midnight + 1000, now - Math.min(ms, sinceMidnight - 1000))).toISOString() - const dayAt = (daysAgo, hour) => new Date(midnight - (daysAgo - 1) * 24 * HOUR - (24 - hour) * HOUR).toISOString() - return { todayAt, dayAt, now } -} - -export function fixtureData() { - const { todayAt, dayAt } = clockTimes() - const tasks = [ - { - id: 't1', name: 'daily-digest', description: 'Summarise overnight news.', model: 'qwen3-8b-instruct', - prompt: 'Summarise overnight news about {{.topic}} in {{.format}} format. Keep it to {{.items}} items.', - context: 'Audience: engineers who run models on their own hardware.', - enabled: true, cron: '0 7 * * *', cron_parameters: { topic: 'local inference', format: 'bullet points', items: '8' }, - webhooks: [{ url: 'https://hooks.example.org/digest', method: 'POST', headers: { 'X-Token': 'abc' }, payload_template: '{"text": "{{.Status}}"}' }], - multimedia_sources: [], - }, - { - id: 't2', name: 'repo-triage', description: 'Label and route new issues.', model: 'qwen3-coder-30b', - prompt: 'Read the new issues in example/app and label each one.', context: '', - enabled: true, cron: '*/30 * * * *', webhooks: [], multimedia_sources: [{ type: 'image', url: 'https://example.org/board.png', headers: {} }], - }, - { - id: 't3', name: 'weekly-report', description: 'Write the Monday status report.', model: 'qwen3-14b-instruct', - prompt: 'Write the status report for the week.', context: '', enabled: true, cron: '0 9 * * 1', webhooks: [], multimedia_sources: [], - }, - { - id: 't4', name: 'ad-hoc-research', description: '', model: 'qwen3-14b-instruct', - prompt: 'Research {{.subject}}.', context: '', enabled: false, cron: '', webhooks: [], multimedia_sources: [], - }, - ] - const jobs = [ - { - id: 'job-fail-0001', task_id: 't1', status: 'failed', triggered_by: 'cron', - parameters: { topic: 'local inference', format: 'bullet points', items: '8' }, - error: 'read_page: timeout after 20 s (3 of 3 attempts)', - created_at: todayAt(10 * MIN), started_at: todayAt(10 * MIN), completed_at: new Date(Date.parse(todayAt(10 * MIN)) + 66 * 1000).toISOString(), - traces: [ - { type: 'status', content: 'Started by the schedule', timestamp: todayAt(10 * MIN) }, - { type: 'tool_call', tool_name: 'web_search', content: 'Searching for local inference news', arguments: { query: 'local inference' }, timestamp: todayAt(10 * MIN) }, - { type: 'tool_result', tool_name: 'read_page', content: 'timeout after 20 s', timestamp: todayAt(10 * MIN) }, - ], - }, - { - id: 'job-done-0002', task_id: 't2', status: 'completed', triggered_by: 'cron', parameters: {}, - result: 'Labelled 12 issues and routed 3 of them to the person on call.\n\n| Issue | Label |\n| - | - |\n| #1204 | bug |\n| #1203 | question |', - created_at: todayAt(40 * MIN), started_at: todayAt(40 * MIN), completed_at: new Date(Date.parse(todayAt(40 * MIN)) + 52 * 1000).toISOString(), - webhook_sent: true, webhook_sent_at: todayAt(39 * MIN), - traces: [ - { type: 'reasoning', content: 'I need to read the new issues first.', timestamp: todayAt(40 * MIN) }, - { type: 'tool_call', tool_name: 'list_issues', content: 'Listing issues', arguments: { repo: 'example/app' }, timestamp: todayAt(40 * MIN) }, - ], - }, - { - id: 'job-run-0003', task_id: 't2', status: 'running', triggered_by: 'manual', parameters: {}, - created_at: todayAt(2 * MIN), started_at: todayAt(2 * MIN), - }, - { - id: 'job-done-0004', task_id: 't1', status: 'completed', triggered_by: 'cron', - parameters: { topic: 'local inference', format: 'bullet points', items: '8' }, - result: 'Wrote the digest, 7 items, saved.', - created_at: dayAt(1, 7), started_at: dayAt(1, 7), completed_at: new Date(Date.parse(dayAt(1, 7)) + 49 * 1000).toISOString(), - }, - { - id: 'job-stop-0005', task_id: 't3', status: 'cancelled', triggered_by: 'api', parameters: {}, - created_at: dayAt(3, 9), started_at: dayAt(3, 9), - }, - ] - return { tasks, jobs } -} - -// Stub the models and the agent jobs endpoints. Returns { seen, state }: seen -// lists what the page sent, state is the tasks and jobs the stub serves. -export async function mockJobs(page, { - tasks, jobs, mcp = true, models = ['qwen3-8b-instruct', 'qwen3-coder-30b', 'qwen3-14b-instruct'], - updateStatus = 200, newJobId = 'job-new-0006', -} = {}) { - const data = fixtureData() - const state = { tasks: structuredClone(tasks ?? data.tasks), jobs: structuredClone(jobs ?? data.jobs) } - const seen = { puts: [], posts: [], deletes: [], executes: [], jobExecutes: [], cancels: [] } - - await page.route('**/api/features', route => route.fulfill({ - json: { distributed: false, localai_assistant: true, agents: true, mcp: true, mcp_jobs: true }, - })) - await page.route('**/api/models/capabilities', route => route.fulfill({ - json: { data: models.map(id => ({ id, capabilities: ['chat'] })) }, - })) - await page.route('**/api/models/config-json/**', route => route.fulfill({ - json: mcp ? { mcp: { stdio: { mcpServers: {} } } } : {}, - })) - await page.route('**/api/agent/**', async route => { - const req = route.request() - const url = new URL(req.url()) - const parts = url.pathname.split('/').filter(Boolean) // api agent tasks|jobs ... - const kind = parts[2] - const method = req.method() - if (kind === 'tasks') { - const rest = parts.slice(3) - if (rest.length === 0) { - if (method === 'POST') { seen.posts.push(req.postDataJSON()); return route.fulfill({ status: 201, json: { id: 't-new' } }) } - return route.fulfill({ json: state.tasks }) - } - if (rest[1] === 'execute' && method === 'POST') { - let body = {} - try { body = req.postDataJSON() || {} } catch { body = {} } - seen.executes.push({ name: decodeURIComponent(rest[0]), body }) - return route.fulfill({ status: 201, json: { job_id: newJobId, status: 'pending', url: '' } }) - } - const id = decodeURIComponent(rest[0]) - const task = state.tasks.find(t => t.id === id || t.name === id) - if (method === 'GET') return task ? route.fulfill({ json: task }) : route.fulfill({ status: 404, json: { error: 'task not found' } }) - if (method === 'PUT') { - const body = req.postDataJSON() - seen.puts.push({ id, body }) - if (updateStatus !== 200) return route.fulfill({ status: updateStatus, json: { error: 'could not update' } }) - state.tasks = state.tasks.map(t => (t.id === id ? { ...t, ...body, id } : t)) - return route.fulfill({ json: { message: 'Task updated' } }) - } - if (method === 'DELETE') { - seen.deletes.push(id) - state.tasks = state.tasks.filter(t => t.id !== id) - return route.fulfill({ json: { message: 'Task deleted' } }) - } - } - if (kind === 'jobs') { - const rest = parts.slice(3) - if (rest.length === 0) return route.fulfill({ json: state.jobs }) - if (rest[0] === 'execute' && method === 'POST') { - seen.jobExecutes.push(req.postDataJSON()) - return route.fulfill({ status: 201, json: { job_id: newJobId, status: 'pending', url: '' } }) - } - const id = decodeURIComponent(rest[0]) - if (rest[1] === 'cancel' && method === 'POST') { - seen.cancels.push(id) - state.jobs = state.jobs.map(j => (j.id === id ? { ...j, status: 'cancelled' } : j)) - return route.fulfill({ json: { message: 'Job cancelled' } }) - } - const job = state.jobs.find(j => j.id === id) - if (id === newJobId && !job) { - return route.fulfill({ json: { id: newJobId, task_id: 't1', status: 'pending', triggered_by: 'api', parameters: {}, created_at: new Date().toISOString() } }) - } - return job ? route.fulfill({ json: job }) : route.fulfill({ status: 404, json: { error: 'job not found' } }) - } - return route.fallback() - }) - return { seen, state } -} diff --git a/core/http/react-ui/e2e/journey-fixtures.js b/core/http/react-ui/e2e/journey-fixtures.js new file mode 100644 index 000000000..081f6f426 --- /dev/null +++ b/core/http/react-ui/e2e/journey-fixtures.js @@ -0,0 +1,74 @@ +import { expect } from '@playwright/test' + +// Helpers of the journey specs that run against the real test server: the +// mock model is offered to the model picker, and every console error, page +// error and failed request of a journey is collected so a spec can assert the +// journey was quiet. + +export async function offerMockModel(page) { + await page.route('**/api/models/capabilities', route => + route.fulfill({ json: { data: [{ id: 'mock-model', capabilities: ['FLAG_CHAT'] }] } })) +} + +// Requests the browser cancels on its own when the page navigates away. +const BENIGN = /net::ERR_ABORTED|net::ERR_NETWORK_CHANGED/ + +// Watches a page. problems() lists what a quiet journey would not have. +export function watchProblems(page, { ignore = [] } = {}) { + const found = [] + const skip = (text) => ignore.some(re => re.test(text)) + page.on('console', (m) => { + if (m.type() === 'error' && !skip(m.text())) found.push(`console: ${m.text()}`) + }) + page.on('pageerror', (e) => found.push(`pageerror: ${e.message}`)) + page.on('requestfailed', (r) => { + const why = r.failure()?.errorText || '' + if (!BENIGN.test(why) && !skip(r.url())) found.push(`request failed: ${r.method()} ${new URL(r.url()).pathname} ${why}`) + }) + page.on('response', (r) => { + const path = new URL(r.url()).pathname + if (r.status() >= 400 && !skip(path) && !skip(`${r.status()} ${path}`)) found.push(`http ${r.status()}: ${r.request().method()} ${path}`) + }) + return { problems: () => [...found] } +} + +export const composer = (page) => page.getByTestId('chat-input') +export const transcript = (page) => page.getByRole('region', { name: 'Conversation' }) + +// Home is where a chat starts: its message box sends the first message, and +// the chat opens at /app/chats/. +export const homeBox = (page) => page.locator('.home-textarea') + +export async function sendFromHome(page, text) { + await page.goto('/app') + await homeBox(page).fill(text) + // Send waits for the model picker to choose a model. + await expect(page.locator('.home-send-btn')).toBeEnabled({ timeout: 15_000 }) + await page.locator('.home-send-btn').click() +} + +// The empty chat, before its first message: /new on Home. /app/chats as an +// address opens Home, so this is how a spec reaches the empty chat's message +// box (to attach a file first, or to read its notices and shortcuts). +export async function openNewChat(page) { + await page.goto('/app') + await expect(homeBox(page)).toBeVisible({ timeout: 15_000 }) + await homeBox(page).fill('/new') + await homeBox(page).press('Enter') + await page.waitForURL(/\/app\/chats(\?|$)/) + await expect(page.getByTestId('chats-page')).toBeVisible() +} + +export async function startChat(page, text) { + await offerMockModel(page) + await sendFromHome(page, text) + await page.waitForURL(/\/app\/chats\/[0-9a-f]+$/) + // The address changes as the hand-off starts; the chat page draws a frame + // later, and until then a key or a reload reaches Home, not the chat. + await expect(page.getByTestId('chats-page')).toBeVisible() + return page.url().split('/').pop() +} + +export function b64(obj) { + return Buffer.from(JSON.stringify(obj)).toString('base64url') +} diff --git a/core/http/react-ui/e2e/ledger-fixtures.js b/core/http/react-ui/e2e/ledger-fixtures.js index 30a87d523..6d59529f3 100644 --- a/core/http/react-ui/e2e/ledger-fixtures.js +++ b/core/http/react-ui/e2e/ledger-fixtures.js @@ -388,14 +388,10 @@ export async function mockLedger(page, options = {}) { await page.route('**/api/operations', route => route.fulfill({ json: { operations: state.operations } })) await page.route('**/api/failover', route => route.fulfill({ json: { chains } })) await page.route('**/api/failover/events', route => route.abort()) - await page.route('**/api/agents*', route => (state.agentsStatus !== 200 - ? route.fulfill({ status: state.agentsStatus, json: { error: 'agent pool unavailable' } }) - : route.fulfill({ json: { agents: Object.keys(state.agents), agentCount: Object.keys(state.agents).length } }))) - await page.route('**/api/agents/*/config', route => { - const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/').slice(-2)[0]) - return route.fulfill({ json: state.agents[name] || {} }) - }) - await page.route('**/api/agent/tasks*', route => route.fulfill({ json: state.tasks })) + await page.route('**/api/chats/agents', route => (state.agentsStatus !== 200 + ? route.fulfill({ status: state.agentsStatus, json: { error: 'agents unavailable' } }) + : route.fulfill({ json: { agents: Object.entries(state.agents).map(([name, cfg]) => ({ id: `id-${name}`, name, ...cfg })) } }))) + await page.route('**/api/chats/triggers', route => route.fulfill({ json: { triggers: state.tasks } })) await page.route('**/api/operations/*/dismiss', route => { state.dismissed.push(new URL(route.request().url()).pathname.split('/').slice(-2)[0]) state.operations = state.operations.filter(op => op.jobID !== state.dismissed[state.dismissed.length - 1]) diff --git a/core/http/react-ui/e2e/library-agent-form.spec.js b/core/http/react-ui/e2e/library-agent-form.spec.js deleted file mode 100644 index feb4adf25..000000000 --- a/core/http/react-ui/e2e/library-agent-form.spec.js +++ /dev/null @@ -1,24 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' -import { mockLibrary } from './library-fixtures.js' - -// The agent form's skill and memory pickers say what an addition costs and -// who else uses it, from the same facts the Library pages read. - -test.describe('Agent form pickers', () => { - test('each skill says who else uses it and what it costs, with a total', async ({ page }) => { - await mockLibrary(page) - await page.goto('/app/agents/handbook/edit') - await page.locator('[data-section="AdvancedSettings"] .ag-fold__button').click() - await expect(page.getByTestId('agent-skill-hint-summarise-pdf')).toHaveText('Used by research-assistant · +114 tokens per message (estimate)') - await expect(page.getByTestId('agent-skill-hint-translate-document')).toHaveText('Not used yet · +90 tokens per message (estimate)') - await expect(page.getByTestId('agent-skills-budget')).toContainText('about 114 tokens') - }) - - test('the memory section names the collection the agent reads', async ({ page }) => { - await mockLibrary(page) - await page.goto('/app/agents/handbook/edit') - await page.locator('[data-section="MemorySettings"] .ag-fold__button').click() - await expect(page.getByTestId('agent-kb-note')).toContainText('collection named handbook') - await expect(page.getByTestId('agent-kb-note')).toContainText('fill and search it in Memory') - }) -}) diff --git a/core/http/react-ui/e2e/library-fixtures.js b/core/http/react-ui/e2e/library-fixtures.js index b2764e43e..0a3695071 100644 --- a/core/http/react-ui/e2e/library-fixtures.js +++ b/core/http/react-ui/e2e/library-fixtures.js @@ -1,7 +1,6 @@ -// Shared fixtures for the Skills and Memory specs: stubbed API routes for -// skills, git repositories, collections and the agents that use them. The -// stubs keep the agent configs in memory, so a save changes what the next -// read returns, as the real server does. +// Shared fixtures for the Memory specs: stubbed API routes for the skills, the +// collections and the saved agents that use them. The stubs keep the agents in +// memory, so a save changes what the next read returns, as the real server does. export const SKILLS = [ { name: 'summarise-pdf', description: 'Summarise a PDF into a short brief with page references.', content: 'Read the file the user attached. Write five lines, each with a page number.\n'.repeat(6), license: 'MIT', readOnly: false }, @@ -11,23 +10,13 @@ export const SKILLS = [ { name: 'weekly-digest', description: 'Collect the week into a digest.', content: 'Collect what changed.\n'.repeat(2), readOnly: true }, ] -export const AGENT_CONFIGS = { - 'research-assistant': { - name: 'research-assistant', model: 'qwen3-14b-instruct', system_prompt: 'You are a careful research assistant. Search first, read two sources, then summarise.', - enable_skills: true, selected_skills: ['summarise-pdf', 'query-database'], enable_kb: false, skills_mode: 'prompt', - }, - handbook: { - name: 'handbook', model: 'qwen3-8b-instruct', system_prompt: 'You answer questions about staff policies.', - enable_skills: true, selected_skills: ['summarise-pdf'], enable_kb: true, kb_mode: 'auto_search', kb_results: 3, - }, - 'support-desk': { - name: 'support-desk', model: 'qwen3-8b-instruct', system_prompt: 'You help customers.', - enable_skills: true, selected_skills: ['triage-issue'], enable_kb: false, skills_mode: 'tools', - }, - 'idle-agent': { - name: 'idle-agent', model: 'qwen3-8b-instruct', system_prompt: 'Idle.', enable_skills: false, enable_kb: false, - }, -} +// Saved agents of the chats: exact lists of the skills and collections each may use. +export const AGENTS = [ + { id: 'a1', name: 'research-assistant', description: '', instructions: 'You are a careful research assistant. Search first, read two sources, then summarise.', model: 'qwen3-14b-instruct', mcp_servers: [], addons: [], collections: [], skills: ['summarise-pdf', 'query-database'] }, + { id: 'a2', name: 'handbook', description: '', instructions: 'You answer questions about staff policies.', model: 'qwen3-8b-instruct', mcp_servers: [], addons: [], collections: ['handbook'], skills: ['summarise-pdf'] }, + { id: 'a3', name: 'support-desk', description: '', instructions: 'You help customers.', model: 'qwen3-8b-instruct', mcp_servers: [], addons: [], collections: [], skills: ['triage-issue'] }, + { id: 'a4', name: 'idle-agent', description: '', instructions: 'Idle.', model: 'qwen3-8b-instruct', mcp_servers: [], addons: [], collections: [], skills: [] }, +] export const COLLECTIONS = ['handbook', 'release-notes', 'meeting-notes'] @@ -37,88 +26,48 @@ export const PASSAGES = [ { content: 'If the VPN drops every few minutes, switch the protocol to TCP in the client settings.', similarity: 0.51, metadata: { filename: 'vpn-setup.md' } }, ] -const META = { - Fields: [ - { name: 'name', label: 'Name', type: 'text', required: true, tags: { section: 'BasicInfo' } }, - { name: 'model', label: 'Model', type: 'text', required: true, tags: { section: 'ModelSettings' } }, - { name: 'enable_kb', label: 'Knowledge base', type: 'checkbox', tags: { section: 'MemorySettings' } }, - { name: 'system_prompt', label: 'System prompt', type: 'textarea', tags: { section: 'PromptsGoals' } }, - { name: 'enable_skills', label: 'Skills', type: 'checkbox', tags: { section: 'AdvancedSettings' } }, - { name: 'skills_mode', label: 'Skills mode', type: 'text', tags: { section: 'AdvancedSettings' } }, - ], - Connectors: [], Actions: [], Filters: [], DynamicPrompts: [], -} - const clone = value => JSON.parse(JSON.stringify(value)) // Stub every endpoint the two pages read or write. Returns what the page sent // and the live state, so a spec can assert on both. export async function mockLibrary(page, { skills = SKILLS, - agents = AGENT_CONFIGS, + agents = AGENTS, collections = COLLECTIONS, entries = { handbook: ['employee-handbook.pdf', 'vpn-setup.md'] }, sources = { handbook: [{ url: 'https://docs.example.org/guide', update_interval: 60, last_update: '2026-10-05T09:30:00Z' }] }, passages = PASSAGES, uploadError = null, - skillsStatus = 200, agentsStatus = 200, - repos = [], - resources = { scripts: [{ path: 'scripts/extract.py', size: 1200 }], references: [{ path: 'references/style.md', size: 300 }], assets: [] }, - features = { distributed: false, localai_assistant: true, agents: true, skills: true, collections: true, mcp: true }, + features = { distributed: false, localai_assistant: true, collections: true, mcp: true }, } = {}) { - const state = { agents: clone(agents), collections: [...collections], entries: clone(entries), sources: clone(sources), repos: clone(repos) } - const seen = { saves: [], searches: [], uploads: [], sourcePosts: [], creates: [], resets: [], imports: 0 } + const state = { agents: clone(agents), collections: [...collections], entries: clone(entries), sources: clone(sources) } + const seen = { saves: [], searches: [], uploads: [], sourcePosts: [], creates: [], resets: [] } const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }) await page.route('**/api/features', route => json(route, features)) - await page.route('**/api/agents/config/metadata', route => json(route, META)) - // Agents: list, config and save. - await page.route(/\/api\/agents(\?.*)?$/, route => { + // Saved agents of the chats: list and save. + await page.route('**/api/chats/agents', route => { if (agentsStatus !== 200) return json(route, { error: 'boom' }, agentsStatus) - return json(route, { agents: Object.keys(state.agents), statuses: Object.fromEntries(Object.keys(state.agents).map(n => [n, true])) }) + return json(route, { agents: state.agents }) }) - await page.route(/\/api\/agents\/[^/?]+\/config(\?.*)?$/, route => { - const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/')[3]) - return state.agents[name] ? json(route, state.agents[name]) : json(route, { error: 'Agent not found' }, 404) - }) - await page.route(/\/api\/agents\/[^/?]+(\?.*)?$/, async route => { - const url = new URL(route.request().url()) - const name = decodeURIComponent(url.pathname.split('/')[3]) - if (['skills', 'collections', 'git-repos', 'config', 'import', 'actions'].includes(name)) return route.fallback() - if (route.request().method() === 'PUT') { - const body = route.request().postDataJSON() - seen.saves.push({ name, body }) - state.agents[name] = body - return json(route, { status: 'ok' }) - } - return state.agents[name] ? json(route, { active: true }) : json(route, { error: 'Agent not found' }, 404) + await page.route(/\/api\/chats\/agents\/[^/?]+$/, route => { + const id = decodeURIComponent(new URL(route.request().url()).pathname.split('/')[4]) + if (route.request().method() !== 'PUT') return route.fallback() + const body = route.request().postDataJSON() + seen.saves.push({ name: body.name, body }) + state.agents = state.agents.map(a => (a.id === id ? { ...a, ...body, id } : a)) + return json(route, state.agents.find(a => a.id === id)) }) - // Skills. - await page.route(/\/api\/agents\/skills(\?.*)?$/, route => { - if (skillsStatus !== 200) return json(route, { error: 'skills unavailable' }, skillsStatus) - return json(route, { skills }) - }) - await page.route('**/api/agents/skills/search?**', route => { - const q = (new URL(route.request().url()).searchParams.get('q') || '').toLowerCase() - return json(route, skills.filter(s => s.name.includes(q) || (s.description || '').toLowerCase().includes(q))) - }) - await page.route(/\/api\/agents\/skills\/[^/]+\/resources(\?.*)?$/, route => json(route, { ...resources, readOnly: false })) - await page.route('**/api/agents/skills/import', route => { seen.imports += 1; return json(route, { status: 'ok' }) }) - await page.route(/\/api\/agents\/git-repos(\/.*)?(\?.*)?$/, route => { - const req = route.request() - if (req.method() === 'POST' && !req.url().includes('/sync') && !req.url().includes('/toggle')) { - const body = req.postDataJSON() - state.repos.push({ id: `r${state.repos.length + 1}`, url: body.url, name: body.url.split('/').pop(), enabled: true }) - return json(route, { status: 'ok' }) - } - return json(route, req.method() === 'GET' ? state.repos : { status: 'ok' }) - }) + // Skills of the chats. + await page.route(/\/api\/chats\/skills(\?.*)?$/, route => json(route, { + skills: skills.map(sk => ({ name: sk.name, description: sk.description, layer: 'personal', path: `/skills/${sk.name}`, enabled: true, shadowed: '', invalid: '' })), + })) // Collections. - await page.route(/\/api\/agents\/collections(\?.*)?$/, route => { + await page.route(/\/api\/collections(\?.*)?$/, route => { const req = route.request() if (req.method() === 'POST') { const body = req.postDataJSON() @@ -128,14 +77,14 @@ export async function mockLibrary(page, { } return json(route, { collections: state.collections, count: state.collections.length }) }) - await page.route(/\/api\/agents\/collections\/[^/]+\/entries(\?.*)?$/, route => { - const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/')[4]) + await page.route(/\/api\/collections\/[^/]+\/entries(\?.*)?$/, route => { + const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/')[3]) return json(route, { entries: state.entries[name] || [], count: (state.entries[name] || []).length }) }) - await page.route(/\/api\/agents\/collections\/[^/]+\/entries\/.+/, route => json(route, { content: 'Full text of the entry.', chunk_count: 12 })) - await page.route(/\/api\/agents\/collections\/[^/]+\/sources(\?.*)?$/, route => { + await page.route(/\/api\/collections\/[^/]+\/entries\/.+/, route => json(route, { content: 'Full text of the entry.', chunk_count: 12 })) + await page.route(/\/api\/collections\/[^/]+\/sources(\?.*)?$/, route => { const req = route.request() - const name = decodeURIComponent(new URL(req.url()).pathname.split('/')[4]) + const name = decodeURIComponent(new URL(req.url()).pathname.split('/')[3]) if (req.method() === 'POST') { const body = req.postDataJSON() seen.sourcePosts.push({ name, ...body }) @@ -149,21 +98,21 @@ export async function mockLibrary(page, { } return json(route, { sources: state.sources[name] || [], count: (state.sources[name] || []).length }) }) - await page.route(/\/api\/agents\/collections\/[^/]+\/search(\?.*)?$/, route => { - const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/')[4]) + await page.route(/\/api\/collections\/[^/]+\/search(\?.*)?$/, route => { + const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/')[3]) const body = route.request().postDataJSON() seen.searches.push({ name, ...body }) return json(route, { results: passages.slice(0, body.max_results || passages.length), count: passages.length }) }) - await page.route(/\/api\/agents\/collections\/[^/]+\/upload(\?.*)?$/, route => { - const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/')[4]) + await page.route(/\/api\/collections\/[^/]+\/upload(\?.*)?$/, route => { + const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/')[3]) seen.uploads.push(name) if (uploadError) return json(route, { error: uploadError }, 409) state.entries[name] = [...(state.entries[name] || []), 'uploaded.txt'] return json(route, { status: 'ok', filename: 'uploaded.txt', key: 'uploaded.txt' }) }) - await page.route(/\/api\/agents\/collections\/[^/]+\/reset(\?.*)?$/, route => { - const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/')[4]) + await page.route(/\/api\/collections\/[^/]+\/reset(\?.*)?$/, route => { + const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/')[3]) seen.resets.push(name) state.entries[name] = [] return json(route, { status: 'ok' }) diff --git a/core/http/react-ui/e2e/library-simulate.spec.js b/core/http/react-ui/e2e/library-simulate.spec.js index ee654802c..e0dbda644 100644 --- a/core/http/react-ui/e2e/library-simulate.spec.js +++ b/core/http/react-ui/e2e/library-simulate.spec.js @@ -24,50 +24,33 @@ test.describe('Simulate a message', () => { await expect(sheet.getByTestId('simulate-total')).toHaveCount(0) }) - test('an agent shows its skills, its estimated budget and the passages of its own collection', async ({ page }) => { + test('an agent shows its skills, an estimated budget and the passages of the collections on its list', async ({ page }) => { const { seen } = await mockLibrary(page) - await page.goto('/app/skills') + await page.goto('/app/collections') await page.getByTestId('open-simulate').click() const sheet = page.getByTestId('simulate-sheet') await sheet.getByLabel('Context').selectOption('handbook') await sheet.getByLabel('Message').fill('When can I work remotely?') await sheet.getByTestId('simulate-run').click() - await expect(sheet.getByTestId('simulate-skill-summarise-pdf')).toContainText('114 tokens') - // The agent asks for 3 passages, so the search asks for 3. - expect(seen.searches[0]).toEqual({ name: 'handbook', query: 'When can I work remotely?', max_results: 3 }) + // A skill adds nothing up front: the model reads it when a task matches. + await expect(sheet.getByTestId('simulate-skill-summarise-pdf')).toContainText('0 up front') + await expect(sheet).toContainText('Read only when the model asks for one') + expect(seen.searches[0]).toEqual({ name: 'handbook', query: 'When can I work remotely?', max_results: 5 }) await expect(sheet.getByTestId('simulate-passages-handbook').locator('.lib-passage')).toHaveCount(3) await expect(sheet.getByTestId('simulate-total')).toContainText('About') await expect(sheet).toContainText('Estimates: characters divided by four') }) - test('an agent in tools mode loads skills only when the model asks', async ({ page }) => { - await mockLibrary(page) - await page.goto('/app/skills') + test('an agent whose list has no collection searches nothing', async ({ page }) => { + const { seen } = await mockLibrary(page) + await page.goto('/app/collections') await page.getByTestId('open-simulate').click() const sheet = page.getByTestId('simulate-sheet') await sheet.getByLabel('Context').selectOption('support-desk') await sheet.getByLabel('Message').fill('Where is my order?') await sheet.getByTestId('simulate-run').click() await expect(sheet.getByTestId('simulate-skill-triage-issue')).toContainText('0 up front') - await expect(sheet).toContainText('Read only when the model asks for one') - await expect(sheet).toContainText('no knowledge base') - }) - - test('an unused skill is tried only for this test and never saved', async ({ page }) => { - const { seen } = await mockLibrary(page) - await page.goto('/app/skills?skill=translate-document') - await page.getByTestId('skill-try').click() - const sheet = page.getByTestId('simulate-sheet') - await expect(sheet.getByTestId('simulate-extras')).toContainText('translate-document') - await sheet.getByLabel('Context').selectOption('idle-agent') - await sheet.getByLabel('Message').fill('Translate this contract') - await sheet.getByTestId('simulate-run').click() - await expect(sheet.getByTestId('simulate-skill-translate-document')).toContainText('Only for this test') - await expect(sheet.getByTestId('simulate-skill-translate-document')).toContainText('90 tokens') - expect(seen.saves).toEqual([]) - await page.keyboard.press('Escape') - await expect(sheet).toHaveCount(0) - await expect(page.getByTestId('skill-used-translate-document')).toHaveText('Not used yet') + expect(seen.searches).toEqual([]) }) test('an unused collection can be searched for this test inside an agent context', async ({ page }) => { @@ -85,7 +68,7 @@ test.describe('Simulate a message', () => { test('running without a message asks for one, and a failed search says why', async ({ page }) => { await mockLibrary(page) - await page.route('**/api/agents/collections/handbook/search', route => route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'index offline' }) })) + await page.route('**/api/collections/handbook/search', route => route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'index offline' }) })) await page.goto('/app/collections') await page.getByTestId('open-simulate').click() const sheet = page.getByTestId('simulate-sheet') diff --git a/core/http/react-ui/e2e/library-sources.spec.js b/core/http/react-ui/e2e/library-sources.spec.js index 582cac081..6b41cfa63 100644 --- a/core/http/react-ui/e2e/library-sources.spec.js +++ b/core/http/react-ui/e2e/library-sources.spec.js @@ -24,7 +24,11 @@ const PAGES = { addon: '/app/addons?tab=libraries', model: '/app/models', backen async function openLibraries(page, kind = 'addon') { await page.goto(PAGES[kind]) - if (kind !== 'addon') await page.getByRole('button', { name: 'Manage libraries' }).click() + // The Backends page keeps its rare actions in the More menu. + if (kind === 'backend') { + await page.getByRole('button', { name: 'More backend actions' }).click() + await page.getByRole('menuitem', { name: 'Manage libraries…' }).click() + } else if (kind !== 'addon') await page.getByRole('button', { name: 'Manage libraries' }).click() await expect(page.getByRole('list', { name: 'Libraries' })).toBeVisible() } @@ -46,11 +50,11 @@ test.describe('Library sources: all three kinds', () => { await expect(row).toHaveAttribute('data-kind', kind) await expect(row.getByTestId('source-default')).toHaveText('Default') } - await expect(ROW(page, OFFICIAL[kind]).getByTestId('source-state')).toHaveText('Enabled') + await expect(ROW(page, OFFICIAL[kind]).getByTestId('source-state')).toHaveText('On') await expect(page.getByRole('group', { name: 'Filter by kind' })).toHaveCount(0) } await openLibraries(page, 'model') - await expect(ROW(page, 'localai').getByTestId('source-state')).toHaveText('Disabled') + await expect(ROW(page, 'localai').getByTestId('source-state')).toHaveText('Off') }) test('adds a model source of your own, which has no Default badge and stays after a reload', async ({ page }) => { @@ -66,7 +70,7 @@ test.describe('Library sources: all three kinds', () => { await expect(row).toBeVisible() await expect(row).toHaveAttribute('data-kind', 'model') await expect(row.getByTestId('source-default')).toHaveCount(0) - await expect(row.getByTestId('source-state')).toHaveText('Enabled') + await expect(row.getByTestId('source-state')).toHaveText('On') const stored = (await sources(page)).sources.find(s => s.id === 'team-models') expect(stored).toMatchObject({ kind: 'model', format: 'gallery', default: false, enabled: true, url: 'https://models.example.org/index.yaml' }) @@ -87,7 +91,7 @@ test.describe('Library sources: all three kinds', () => { await openLibraries(page, 'model') const row = ROW(page, 'official-models') await row.locator('[data-action="disable"]').click() - await expect(row.getByTestId('source-state')).toHaveText('Disabled') + await expect(row.getByTestId('source-state')).toHaveText('Off') await expect(row).toHaveAttribute('data-state', 'disabled') expect((await sources(page)).sources.find(s => s.id === 'official-models')).toMatchObject({ enabled: false, removed: false, default: true }) diff --git a/core/http/react-ui/e2e/mcp-fixture.js b/core/http/react-ui/e2e/mcp-fixture.js new file mode 100644 index 000000000..df8cf854f --- /dev/null +++ b/core/http/react-ui/e2e/mcp-fixture.js @@ -0,0 +1,45 @@ +// A small MCP server over HTTP for the specs that need a real connector: an +// echo tool, and a clock tool that declares an MCP App page. It listens on a +// free loopback port, one stateless transport per request. +import http from 'node:http' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import { z } from 'zod' + +export const APP_URI = 'ui://clock/app.html' +export const APP_HTML = '

    clock app ready

    ' + +function build() { + const server = new McpServer({ name: 'spec-mcp', version: '1.0.0' }) + server.registerTool('echo', { description: 'Echo the text back', inputSchema: { text: z.string() } }, + async ({ text }) => ({ content: [{ type: 'text', text: `echo: ${text}` }] })) + server.registerTool('get-time', { + description: 'The time, with a clock page', + inputSchema: {}, + _meta: { ui: { resourceUri: APP_URI } }, + }, async () => ({ content: [{ type: 'text', text: 'It is 12:00.' }], structuredContent: { time: '12:00' } })) + server.registerResource('clock', APP_URI, { mimeType: 'text/html;profile=mcp-app' }, + async () => ({ contents: [{ uri: APP_URI, mimeType: 'text/html;profile=mcp-app', text: APP_HTML }] })) + return server +} + +export async function startMcpFixture() { + const srv = http.createServer(async (req, res) => { + if (req.method !== 'POST') { res.writeHead(405).end(); return } + const chunks = [] + for await (const c of req) chunks.push(c) + let body + try { body = JSON.parse(Buffer.concat(chunks).toString() || '{}') } catch { res.writeHead(400).end(); return } + const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }) + const server = build() + res.on('close', () => { transport.close(); server.close() }) + await server.connect(transport) + await transport.handleRequest(req, res, body) + }) + await new Promise(r => srv.listen(0, '127.0.0.1', r)) + const url = `http://127.0.0.1:${srv.address().port}/mcp` + // Keep-alive sockets of the server under test must not hold the close. + return { url, close: () => new Promise(r => { srv.close(r); srv.closeAllConnections?.() }) } +} diff --git a/core/http/react-ui/e2e/mcp-server-status.spec.js b/core/http/react-ui/e2e/mcp-server-status.spec.js deleted file mode 100644 index 20f34706a..000000000 --- a/core/http/react-ui/e2e/mcp-server-status.spec.js +++ /dev/null @@ -1,147 +0,0 @@ -import { test, expect } from './coverage-fixtures.js' - -async function seedModelChat(page) { - await page.addInitScript(() => { - const now = Date.now() - localStorage.setItem('localai_chats_data', JSON.stringify({ - chats: [{ - id: 'mcp-status-chat', - name: 'MCP status', - model: 'test-model', - history: [], - systemPrompt: '', - mcpMode: false, - mcpServers: [], - clientMCPServers: [], - temperature: null, - topP: null, - topK: null, - tokenUsage: { prompt: 0, completion: 0, total: 0 }, - contextSize: null, - createdAt: now, - updatedAt: now, - }], - activeChatId: 'mcp-status-chat', - lastSaved: now, - })) - }) -} - -async function mockMCPModel(page) { - await page.route('**/api/models/capabilities', route => route.fulfill({ - json: { data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }] }, - })) - await page.route('**/api/models/config-json/test-model', route => route.fulfill({ - json: { - name: 'test-model', - mcp: { remote: 'mcpServers:\n ordino:\n url: http://ordino:8080/mcp' }, - }, - })) -} - -test('configured MCP server remains visible with its discovery error', async ({ page }) => { - await seedModelChat(page) - await mockMCPModel(page) - - let discoveryRequests = 0 - await page.route('**/v1/mcp/servers/test-model', route => { - discoveryRequests++ - return route.fulfill({ - json: { - model: 'test-model', - servers: [{ - name: 'ordino', - type: 'remote', - tools: [], - error: 'connection failed: lookup ordino.internal: no such host', - }], - }, - }) - }) - - await page.goto('/app/chat') - await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) - - await page.locator('.chat-mcp-dropdown > button').click() - await page.getByRole('button', { name: 'Servers', exact: true }).click() - - const server = page.locator('.chat-mcp-server-item', { hasText: 'ordino' }) - await expect(server).toBeVisible() - await expect(server).toContainText('lookup ordino.internal: no such host') - await expect(server.locator('.chat-mcp-server-status--error')).toBeVisible() - await expect(server.getByRole('checkbox')).toBeDisabled() - - await page.getByRole('button', { name: 'Client', exact: true }).click() - await page.getByRole('button', { name: 'Servers', exact: true }).click() - await expect.poll(() => discoveryRequests).toBeGreaterThanOrEqual(2) -}) - -test('server-list request errors are shown in the MCP menu', async ({ page }) => { - await seedModelChat(page) - await mockMCPModel(page) - await page.route('**/v1/mcp/servers/test-model', route => route.fulfill({ - status: 500, - json: { message: 'invalid MCP configuration: missing mcpServers map' }, - })) - - await page.goto('/app/chat') - await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) - - await page.locator('.chat-mcp-dropdown > button').click() - await page.getByRole('button', { name: 'Servers', exact: true }).click() - await expect(page.getByRole('alert')).toContainText('invalid MCP configuration: missing mcpServers map') -}) - -// Non-admin users cannot read /api/models/config-json (admin-only). Their MCP -// availability comes from the `mcp` flag of /api/models/capabilities plus the -// user's `mcp` feature permission. -async function mockNonAdmin(page, permissions) { - await page.route('**/api/auth/status', route => route.fulfill({ - json: { - authEnabled: true, - staticApiKeyRequired: false, - providers: ['local'], - hasUsers: true, - user: { id: 'u-member', name: 'Member', email: 'member@example.com', role: 'user', permissions }, - }, - })) - let configJsonRequests = 0 - await page.route('**/api/models/config-json/**', route => { - configJsonRequests++ - return route.fulfill({ status: 403, json: { error: 'admin access required' } }) - }) - await page.route('**/api/models/capabilities', route => route.fulfill({ - json: { data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'], mcp: true }] }, - })) - await page.route('**/v1/mcp/servers/test-model', route => route.fulfill({ - json: { model: 'test-model', servers: [{ name: 'weather', type: 'remote', tools: ['get_weather'] }] }, - })) - return () => configJsonRequests -} - -test('non-admin user with the mcp feature can select model MCP servers', async ({ page }) => { - await seedModelChat(page) - const configJsonRequests = await mockNonAdmin(page, { chat: true, mcp: true }) - - await page.goto('/app/chat') - await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) - - await page.locator('.chat-mcp-dropdown > button').click() - await page.getByRole('button', { name: 'Servers', exact: true }).click() - const server = page.locator('.chat-mcp-server-item', { hasText: 'weather' }) - await expect(server).toBeVisible() - await expect(server.getByRole('checkbox')).toBeEnabled() - expect(configJsonRequests()).toBe(0) -}) - -test('non-admin user without the mcp feature gets no model MCP servers', async ({ page }) => { - await seedModelChat(page) - await mockNonAdmin(page, { chat: true, mcp: false }) - - await page.goto('/app/chat') - await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) - - await page.locator('.chat-mcp-dropdown > button').click() - await expect(page.getByRole('button', { name: 'Client', exact: true })).toBeVisible() - await expect(page.getByRole('button', { name: 'Servers', exact: true })).toHaveCount(0) -}) diff --git a/core/http/react-ui/e2e/memory.spec.js b/core/http/react-ui/e2e/memory.spec.js index 4edb8dfdf..aec7a93e1 100644 --- a/core/http/react-ui/e2e/memory.spec.js +++ b/core/http/react-ui/e2e/memory.spec.js @@ -5,7 +5,7 @@ import { mockLibrary } from './library-fixtures.js' // on the collection alone, and what the API reports about sources and files. test.describe('Memory library', () => { - test('a collection is used by the agent that carries its name, and otherwise by nobody', async ({ page }) => { + test('a collection is used by the agents that list it, and otherwise by nobody', async ({ page }) => { await mockLibrary(page) await page.goto('/app/collections') await expect(page.getByTestId('collection-used-handbook')).toHaveText('Used by handbook') @@ -16,7 +16,27 @@ test.describe('Memory library', () => { } }) - test('an agent with the knowledge base off does not use its collection', async ({ page }) => { + test('says when the embedding model is not installed and leads to the models page', async ({ page }) => { + await mockLibrary(page) + await page.route(/\/api\/collections(\?.*)?$/, route => route.request().method() === 'GET' + ? route.fulfill({ json: { collections: ['handbook'], count: 1, embedding_model: 'granite-embedding-107m-multilingual', embedding_model_installed: false } }) + : route.fallback()) + await page.goto('/app/collections') + const note = page.getByTestId('memory-no-embedding') + await expect(note).toContainText('No embedding model installed.') + await expect(note).toContainText('Collections use granite-embedding-107m-multilingual to index files') + await page.getByTestId('install-embedding').click() + await expect(page).toHaveURL(/\/app\/models\?q=granite-embedding-107m-multilingual$/) + }) + + test('says nothing about the embedding model when it is installed', async ({ page }) => { + await mockLibrary(page) + await page.goto('/app/collections') + await expect(page.getByTestId('collection-used-handbook')).toBeVisible() + await expect(page.getByTestId('memory-no-embedding')).toHaveCount(0) + }) + + test('an agent that does not list a collection does not use it', async ({ page }) => { await mockLibrary(page, { collections: ['idle-agent'] }) await page.goto('/app/collections') await expect(page.getByTestId('collection-used-idle-agent')).toHaveText('Not used yet') @@ -138,37 +158,38 @@ test.describe('Memory library', () => { await expect(dialog).toHaveCount(0) }) - test('Add to... offers only the agent that carries the collection name', async ({ page }) => { - const { seen } = await mockLibrary(page, { collections: ['idle-agent', 'release-notes'] }) - await page.goto('/app/collections/idle-agent') - await page.getByTestId('add-to-button').click() - const menu = page.getByTestId('add-to-menu') - await expect(menu).toContainText('An agent reads the collection that has its own name') - await expect(menu.getByTestId('add-to-idle-agent')).toContainText('Turns its knowledge base on') - await expect(menu.locator('[data-testid^="add-to-"][data-testid$="research-assistant"]')).toHaveCount(0) - await menu.getByRole('button', { name: 'Add to idle-agent' }).click() - await expect.poll(() => seen.saves.length).toBe(1) - expect(seen.saves[0].body).toMatchObject({ name: 'idle-agent', enable_kb: true }) - await expect(page.getByTestId('collection-used-idle-agent')).toHaveText('Used by idle-agent') - }) - - test('with no agent of that name Add to... says how a collection gets used', async ({ page }) => { - await mockLibrary(page) + test('Add to... offers every saved agent and adds the collection to its list', async ({ page }) => { + const { seen } = await mockLibrary(page) await page.goto('/app/collections/release-notes') await page.getByTestId('add-to-button').click() - await expect(page.getByTestId('add-to-menu')).toContainText('No agent is named release-notes') + const menu = page.getByTestId('add-to-menu') + await expect(menu).toContainText('An agent can search the collections on its list') + for (const name of ['research-assistant', 'handbook', 'support-desk', 'idle-agent']) { + await expect(menu.getByTestId(`add-to-${name}`)).toBeVisible() + } + await menu.getByRole('button', { name: 'Add to idle-agent' }).click() + await expect.poll(() => seen.saves.length).toBe(1) + expect(seen.saves[0].body).toMatchObject({ name: 'idle-agent', collections: ['release-notes'] }) + await expect(page.getByTestId('collection-used-release-notes')).toHaveText('Used by idle-agent') }) - test('removing a collection from an agent turns its knowledge base off, and Undo restores it', async ({ page }) => { + test('with no saved agent Add to... says how a collection gets used', async ({ page }) => { + await mockLibrary(page, { agents: [] }) + await page.goto('/app/collections/release-notes') + await page.getByTestId('add-to-button').click() + await expect(page.getByTestId('add-to-menu')).toContainText('No agents yet') + }) + + test('removing a collection from an agent takes it off the list, and Undo restores it', async ({ page }) => { const { seen } = await mockLibrary(page) await page.goto('/app/collections/handbook') await page.getByRole('button', { name: 'Remove from handbook' }).click() await expect.poll(() => seen.saves.length).toBe(1) - expect(seen.saves[0].body).toMatchObject({ name: 'handbook', enable_kb: false }) + expect(seen.saves[0].body).toMatchObject({ name: 'handbook', collections: [] }) await expect(page.getByTestId('collection-used-handbook')).toHaveText('Not used yet') await page.getByTestId('library-undo-toast').getByRole('button', { name: 'Undo' }).click() await expect.poll(() => seen.saves.length).toBe(2) - expect(seen.saves[1].body).toMatchObject({ name: 'handbook', enable_kb: true }) + expect(seen.saves[1].body).toMatchObject({ name: 'handbook', collections: ['handbook'] }) }) test('the disclosure names the endpoints and says where files stay', async ({ page }) => { @@ -176,8 +197,8 @@ test.describe('Memory library', () => { await page.goto('/app/collections/handbook') const api = page.getByTestId('collection-api') await api.getByText('Index, API and privacy').click() - await expect(api).toContainText('/api/agents/collections/handbook/search') - await expect(api).toContainText('/api/agents/collections/handbook/upload') + await expect(api).toContainText('/api/collections/handbook/search') + await expect(api).toContainText('/api/collections/handbook/upload') await expect(api).toContainText('stay on this server') }) diff --git a/core/http/react-ui/e2e/model-page.spec.js b/core/http/react-ui/e2e/model-page.spec.js index 5ee36ecb5..c8a57c38c 100644 --- a/core/http/react-ui/e2e/model-page.spec.js +++ b/core/http/react-ui/e2e/model-page.spec.js @@ -476,7 +476,7 @@ test.describe('Model page - an installed model', () => { await expect(used).toContainText('Agent') await expect(used).toContainText('default-chat') await expect(used).toContainText('Alias') - await expect(used.getByRole('link', { name: 'research-helper' })).toHaveAttribute('href', '/app/agents/research-helper/edit') + await expect(used.getByRole('link', { name: 'research-helper' })).toHaveAttribute('href', '/app/agents') }) test('a model nothing uses says so', async ({ page }) => { diff --git a/core/http/react-ui/e2e/models-cleanup.spec.js b/core/http/react-ui/e2e/models-cleanup.spec.js index 11c4ee8cc..f62bfeed9 100644 --- a/core/http/react-ui/e2e/models-cleanup.spec.js +++ b/core/http/react-ui/e2e/models-cleanup.spec.js @@ -149,7 +149,7 @@ test.describe('Cleanup sheet - what it knows and what it does not', () => { test('the first look shows skeleton rows while the facts are read', async ({ page }) => { await mockLedger(page) - await page.route('**/api/agents*', async (route) => { + await page.route('**/api/chats/agents', async (route) => { await new Promise((r) => setTimeout(r, 1500)) return route.fulfill({ json: { agents: [] } }) }) @@ -315,8 +315,8 @@ test.describe('Cleanup sheet - removal with an undo window', () => { test('leaving the page during the window deletes nothing and says so', async ({ page }) => { const state = await removeSafe(page) await page.keyboard.press('Escape') - await page.getByRole('link', { name: 'Chat', exact: true }).first().click() - await expect(page).toHaveURL(/\/app\/chat/) + await page.getByRole('link', { name: 'All chats', exact: true }).first().click() + await expect(page).toHaveURL(/\/app\/chats\/all$/) await expect(page.getByText('You left the page, so 1 model was not deleted.')).toBeVisible() await page.clock.fastForward(120_000) expect(state.deletes).toHaveLength(0) diff --git a/core/http/react-ui/e2e/models-ledger.spec.js b/core/http/react-ui/e2e/models-ledger.spec.js index f0e421e91..106287137 100644 --- a/core/http/react-ui/e2e/models-ledger.spec.js +++ b/core/http/react-ui/e2e/models-ledger.spec.js @@ -436,7 +436,9 @@ test.describe('Models ledger - states', () => { }) test.describe('Models ledger - phone', () => { - test.use({ viewport: { width: 390, height: 844 } }) + // A phone has a touch screen: controls size for a finger on a coarse + // pointer, not for the width of the window. + test.use({ viewport: { width: 390, height: 844 }, hasTouch: true }) test('rows collapse to name, fit and action, and the page does not scroll sideways', async ({ page }) => { await open(page) diff --git a/core/http/react-ui/e2e/navigation.spec.js b/core/http/react-ui/e2e/navigation.spec.js index 9ac7befba..d26bc248c 100644 --- a/core/http/react-ui/e2e/navigation.spec.js +++ b/core/http/react-ui/e2e/navigation.spec.js @@ -21,16 +21,20 @@ test.describe('Navigation', () => { await expect(models.locator('.nav-label')).toHaveText('Models') }) - test('the sidebar has Chat, Models and Add-ons, no Settings and no Create tier', async ({ page }) => { + test('the sidebar has Files, Agents, MCP connectors, Memory, Skills, Models and Add-ons, then the recent chats; no Chats entry, no Settings and no Create tier', async ({ page }) => { await stubAddonRoutes(page) await page.goto('/app') await expect(page.locator('.sidebar-section-title', { hasText: 'Create' })).toHaveCount(0) - for (const href of ['/app/chat', '/app/models', '/app/addons']) { + // Home is where a chat starts: Chats is not an entry of its own. + await expect(page.locator('.sidebar-nav a.nav-item[href="/app/chats"]')).toHaveCount(0) + await expect(page.getByTestId('sidebar-chats')).toBeVisible() + await expect(page.locator('.sidebar-nav a.nav-item[href="/app/chats/all"]')).toBeVisible() + for (const href of ['/app/files', '/app/agents', '/app/mcp-connectors', '/app/collections', '/app/skills', '/app/models', '/app/addons']) { await expect(page.locator(`.sidebar-nav a.nav-item[href="${href}"]`).first()).toBeVisible() } // Settings lives under the Operate hub, not in the sidebar. await expect(page.locator('.sidebar-nav a.nav-item[href="/app/settings"]')).toHaveCount(0) - await page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' }).click() + await page.locator('.sidebar-nav a.nav-item', { hasText: 'Administration' }).click() await expect(page).toHaveURL(/\/app\/operate$/) const settingsTab = page.locator('.dk-hubtabs [data-hub-tab="settings"]') await expect(settingsTab).toBeVisible() @@ -43,57 +47,69 @@ test.describe('Navigation', () => { } }) - test('Build and Operate sit under a Workspace label', async ({ page }) => { + test('Administration is pinned at the bottom, outside any section, and there is no Build entry', async ({ page }) => { await page.goto('/app') - await expect(page.locator('.sidebar-section-title', { hasText: 'Workspace' })).toBeVisible() - await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Build' })).toBeVisible() - await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' })).toBeVisible() + // It is no longer an item of the Workspace section. + await expect(page.locator('.sidebar-section-title', { hasText: 'Workspace' })).toHaveCount(0) + const admin = page.locator('.sidebar-nav--pinned a.nav-item', { hasText: 'Administration' }) + await expect(admin).toBeVisible() + await expect(page.locator('.sidebar-section a.nav-item', { hasText: 'Administration' })).toHaveCount(0) + // It sits below the scrolling nav and above the footer controls. + const [adminBox, homeBox, footerBox] = await Promise.all([ + admin.boundingBox(), + page.locator('.sidebar-nav a.nav-item', { hasText: 'Home' }).boundingBox(), + page.locator('.sidebar-footer').boundingBox(), + ]) + expect(adminBox.y).toBeGreaterThan(homeBox.y) + expect(adminBox.y + adminBox.height).toBeLessThanOrEqual(footerBox.y) + await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Build' })).toHaveCount(0) }) - test('Build is a single entry that opens the Build hub', async ({ page }) => { + test('the old Build address opens the models, and Import starts from adding a model', async ({ page }) => { + await page.goto('/app/build') + await expect(page).toHaveURL(/\/app\/models/) + await page.goto('/app/model-editor') + await page.getByTestId('add-model-import').click() + await expect(page).toHaveURL(/\/app\/import-model$/) + }) + + test('Agents, MCP connectors and Memory are sidebar items, and their pages light them up', async ({ page }) => { await page.goto('/app') - const build = page.locator('.sidebar-nav a.nav-item', { hasText: 'Build' }) - await expect(build).toBeVisible() - await build.click() - await expect(page).toHaveURL(/\/app\/build$/) - const bar = page.locator('.dk-hubtabs') - await expect(bar.locator('[data-hub-tab="overview"]')).toHaveAttribute('aria-current', 'page') - await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Build' })).toHaveClass(/active/) + const item = (href) => page.locator(`.sidebar-nav a.nav-item[href="${href}"]`) + await item('/app/agents').click() + await expect(page).toHaveURL(/\/app\/agents$/) + await expect(item('/app/agents')).toHaveClass(/active/) + // Triggers and the messaging connectors belong to the agents. + for (const path of ['/app/triggers', '/app/chats/connectors']) { + await page.goto(path) + await expect(item('/app/agents')).toHaveClass(/active/) + await expect(item('/app/chats/all')).not.toHaveClass(/active/) + } + await item('/app/mcp-connectors').click() + await expect(page.getByTestId('mcp-connectors-page')).toBeVisible() + await expect(item('/app/mcp-connectors')).toHaveClass(/active/) + await item('/app/collections').click() + await expect(page).toHaveURL(/\/app\/collections$/) + await expect(item('/app/collections')).toHaveClass(/active/) + // Memory is a page of its own now, not a tab of a hub. + await expect(page.locator('.dk-hubtabs')).toHaveCount(0) + }) + + test('the old MCP tab of the connectors opens the MCP connectors page', async ({ page }) => { + await page.goto('/app/chats/connectors?kind=mcp') + await expect(page).toHaveURL(/\/app\/mcp-connectors$/) + await expect(page.getByTestId('mcp-connectors-page')).toBeVisible() + await page.goto('/app/chats/connectors') + await expect(page.getByRole('tab', { name: 'MCP server' })).toHaveCount(0) }) test('Operate is a single entry that opens the Operate hub', async ({ page }) => { await page.goto('/app') - const operate = page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' }) + const operate = page.locator('.sidebar-nav a.nav-item', { hasText: 'Administration' }) await expect(operate).toBeVisible() await operate.click() await expect(page).toHaveURL(/\/app\/operate$/) await expect(page.locator('.dk-hubtabs [data-hub-tab="status"]')).toHaveAttribute('aria-current', 'page') }) - test('Build hub lists its tools as tabs', async ({ page }) => { - await page.goto('/app/agents') - const bar = page.locator('.dk-hubtabs') - await expect(bar).toBeVisible() - for (const id of ['overview', 'agents', 'skills', 'memory', 'jobs', 'import']) { - await expect(bar.locator(`[data-hub-tab="${id}"]`)).toBeVisible() - } - await expect(bar.locator('[data-hub-tab="fine-tune"], [data-hub-tab="quantize"]')).toHaveCount(0) - await expect(bar.locator('[data-hub-tab="faces"]')).toHaveCount(0) - await expect(bar.locator('[data-hub-tab="agents"]')).toHaveAttribute('aria-current', 'page') - }) - - test('the Build overview lists the tools with a line each', async ({ page }) => { - // The Fine-tuning tile is shown when the server has the switch on. - await page.route('**/api/features', async route => { - const real = await (await route.fetch()).json() - return route.fulfill({ json: { ...real, fine_tuning: true } }) - }) - await page.goto('/app/build') - const list = page.getByRole('list', { name: 'Build tools' }) - // Each tool is a row with its sentence and an Open link. - const row = (path) => list.locator('li.bt-tool', { has: page.locator(`a[href="${path}"]`) }) - await expect(row('/app/agents')).toContainText('Create and run agents') - await expect(list.locator('a[href="/app/fine-tune"]')).toBeVisible() - }) - }) diff --git a/core/http/react-ui/e2e/no-edge-rails.spec.js b/core/http/react-ui/e2e/no-edge-rails.spec.js index 9a8e51154..3c54e74c0 100644 --- a/core/http/react-ui/e2e/no-edge-rails.spec.js +++ b/core/http/react-ui/e2e/no-edge-rails.spec.js @@ -9,9 +9,9 @@ import { installStudioAddons } from './studio-addon.js' // 4. a narrow, tall child pinned to the left edge of its parent. // Selection and status use a surface step, a check or a dot instead. const ROUTES = [ - '/app', '/app/chat', '/app/models', '/app/models?view=installed', '/app/studio', - '/app/talk', '/app/agents', '/app/skills', '/app/collections', '/app/agent-jobs', - '/app/backends', '/app/activity', '/app/operate', '/app/build', '/app/settings', + '/app', '/app/chats/all', '/app/models', '/app/models?view=installed', '/app/studio', + '/app/talk', '/app/agents', '/app/skills', '/app/collections', '/app/triggers', + '/app/backends', '/app/activity', '/app/operate', '/app/mcp-connectors', '/app/settings', '/app/traces', '/app/usage', '/app/nodes', '/app/p2p', '/app/account', ] diff --git a/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js b/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js index 495f97906..27b5225ea 100644 --- a/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js +++ b/core/http/react-ui/e2e/nodes-fleet-dashboard.spec.js @@ -47,7 +47,7 @@ test.describe('Swarm nodes: navigation', () => { await mockNodes(page, [baseNodes[0]]) await page.goto('/app/nodes') - const primaryOperate = page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' }) + const primaryOperate = page.locator('.sidebar-nav a.nav-item', { hasText: 'Administration' }) await expect(primaryOperate).toBeVisible({ timeout: 15_000 }) await expect(primaryOperate).toHaveClass(/active/) @@ -80,7 +80,7 @@ test.describe('Swarm nodes: navigation', () => { await page.goto('/app/nodes') await page.getByRole('button', { name: 'Open menu' }).click() - await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' })).toBeVisible() + await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Administration' })).toBeVisible() await page.getByRole('button', { name: 'Close menu' }).click() const bar = page.locator('.hub-layout > .hub-bar .dk-hubtabs') diff --git a/core/http/react-ui/e2e/not-found.spec.js b/core/http/react-ui/e2e/not-found.spec.js index 59ae829b6..77652e849 100644 --- a/core/http/react-ui/e2e/not-found.spec.js +++ b/core/http/react-ui/e2e/not-found.spec.js @@ -11,9 +11,11 @@ test.describe('404', () => { await expect(nf).toContainText('/app/no-such-page') await expect(nf.getByRole('link', { name: 'Go home' })).toHaveAttribute('href', '/app') const places = nf.getByRole('navigation', { name: 'Where to go' }) - for (const name of ['Home', 'Models', 'Chat', 'Add-ons', 'Build', 'Operate']) { + for (const name of ['Home', 'Models', 'Chat', 'Agents', 'MCP connectors', 'Memory', 'Add-ons', 'Administration']) { await expect(places.getByRole('link', { name })).toBeVisible() } + // The Build hub is gone. + await expect(places.getByRole('link', { name: 'Build' })).toHaveCount(0) // Settings is a tab of the Operate hub, so the places do not list it. await expect(places.getByRole('link', { name: 'Settings' })).toHaveCount(0) }) @@ -26,8 +28,8 @@ test.describe('404', () => { test('the links go where they say', async ({ page }) => { await page.goto('/app/no-such-page') - await page.getByTestId('not-found').getByRole('link', { name: 'Chat' }).click() - await expect(page).toHaveURL(/\/app\/chat$/) + await page.getByTestId('not-found').getByRole('link', { name: 'All chats' }).click() + await expect(page).toHaveURL(/\/app\/chats\/all$/) await page.goto('/app/no-such-page') await page.getByTestId('not-found').getByRole('link', { name: 'Go home' }).click() await expect(page).toHaveURL(/\/app\/?$/) @@ -37,9 +39,9 @@ test.describe('404', () => { await mockAccess(page, { status: 'member' }) await page.goto('/app/no-such-page') const places = page.getByTestId('not-found').getByRole('navigation', { name: 'Where to go' }) - await expect(places.getByRole('link', { name: 'Chat' })).toBeVisible() + await expect(places.getByRole('link', { name: 'Home' })).toBeVisible() await expect(places.getByRole('link', { name: 'Models' })).toHaveCount(0) - await expect(places.getByRole('link', { name: 'Operate' })).toHaveCount(0) + await expect(places.getByRole('link', { name: 'Administration' })).toHaveCount(0) }) test('an address outside the app gets the same page', async ({ page }) => { diff --git a/core/http/react-ui/e2e/operate-overview.spec.js b/core/http/react-ui/e2e/operate-overview.spec.js index d0a2807fc..a7b2d32a1 100644 --- a/core/http/react-ui/e2e/operate-overview.spec.js +++ b/core/http/react-ui/e2e/operate-overview.spec.js @@ -30,11 +30,11 @@ async function mockQuiet(page, { upgrades = NO_UPGRADES, operations = [] } = {}) route.fulfill({ json: [{ id: 'node-a', status: 'healthy', healthy: true }] })) } -test.describe('Operate overview', () => { - test('Operate opens the overview, not whichever page happens to be first', async ({ page }) => { +test.describe('Administration overview', () => { + test('Administration opens the overview, not whichever page happens to be first', async ({ page }) => { await mockQuiet(page) await page.goto('/app') - await page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' }).click() + await page.locator('.sidebar-nav a.nav-item', { hasText: 'Administration' }).click() // Today this lands on /app/backends purely because Backends is the first // entry in operateConsole.groups — an ordering accident, not a decision. await expect(page).toHaveURL(/\/app\/operate$/) @@ -132,7 +132,7 @@ test.describe('Operate overview', () => { route.fulfill({ json: NO_UPGRADES }) }) await page.route('**/api/operations', route => route.fulfill({ json: [] })) - await page.goto('/app/chat') + await page.goto('/app/chats') await expect(page.locator('.sidebar')).toBeVisible() await page.waitForTimeout(1500) // Nobody asked for this data outside Operate; a dashboard-shaped poll on diff --git a/core/http/react-ui/e2e/operate-status.spec.js b/core/http/react-ui/e2e/operate-status.spec.js index 3e6962658..a2b516a5b 100644 --- a/core/http/react-ui/e2e/operate-status.spec.js +++ b/core/http/react-ui/e2e/operate-status.spec.js @@ -178,7 +178,7 @@ test.describe('Status capacity chart', () => { await page.goto('/app/operate') const capacity = page.getByTestId('operate-capacity') - await expect(capacity).toContainText('Since you opened Operate. LocalAI keeps no memory history.') + await expect(capacity).toContainText('Since you opened Administration. LocalAI keeps no memory history.') // One reading is a bar, not a history. await expect(page.getByTestId('operate-capacity-wait')).toBeVisible() await expect(capacity.locator('.dk-chart-plot')).toHaveCount(0) diff --git a/core/http/react-ui/e2e/page-header-no-crumb.spec.js b/core/http/react-ui/e2e/page-header-no-crumb.spec.js index 95ddb8d0c..28d34f2f0 100644 --- a/core/http/react-ui/e2e/page-header-no-crumb.spec.js +++ b/core/http/react-ui/e2e/page-header-no-crumb.spec.js @@ -4,7 +4,7 @@ import { test, expect } from './coverage-fixtures.js' // not repeated above the title as a small crumb: the sidebar and the hub tab // bar already say where the page lives. const PAGES = [ - ['/app/agents', 'Agents'], + ['/app/collections', 'Memory'], ['/app/activity', 'Activity'], ] diff --git a/core/http/react-ui/e2e/page-render-smoke.spec.js b/core/http/react-ui/e2e/page-render-smoke.spec.js index efb2864bc..57a217e24 100644 --- a/core/http/react-ui/e2e/page-render-smoke.spec.js +++ b/core/http/react-ui/e2e/page-render-smoke.spec.js @@ -39,8 +39,9 @@ test.describe('Page render smoke', () => { test(`renders ${label} (${path})`, async ({ page }) => { await page.goto(path) // .page-title for the normal header; .empty-state-title for pages that - // render a gated/empty state (e.g. Account when auth is disabled). - await expect(page.locator('.page-title, .view-bar__title, .empty-state-title, .dk-empty-title, .op-status__title, .op-runtime h1, .sw-page h1, .tf-title, .st-title').first()).toBeVisible({ timeout: 15_000 }) + // render a gated/empty state; .ac-title for Account, which shows its + // settings tabs with or without accounts. + await expect(page.locator('.page-title, .view-bar__title, .empty-state-title, .dk-empty-title, .op-status__title, .op-runtime h1, .sw-page h1, .tf-title, .st-title, .ac-title').first()).toBeVisible({ timeout: 15_000 }) await expect(page).toHaveURL(new RegExp(path.replace(/\//g, '\\/') + '$')) }) } diff --git a/core/http/react-ui/e2e/pages-files-real.spec.js b/core/http/react-ui/e2e/pages-files-real.spec.js new file mode 100644 index 000000000..e9d04accd --- /dev/null +++ b/core/http/react-ui/e2e/pages-files-real.spec.js @@ -0,0 +1,245 @@ +import { deflateSync } from 'node:zlib' +import { test, expect } from './coverage-fixtures.js' + +// A page split into several files, against the real test server: the viewer +// puts its script, style sheet, image and modules into the frame, and fetch() +// and XMLHttpRequest of the page read the files next to it. The network and +// the files outside the folder stay out of reach. PAGES_SHOTS names a folder +// for screenshots of the page in both themes. + +test.describe.configure({ mode: 'serial', timeout: 60_000 }) + +const stamp = Date.now().toString(36) +const NAME = `news-${stamp}` +const SECRET = `secret-${stamp}.json` +const shots = process.env.PAGES_SHOTS || '' + +const put = (request, path, body) => request.put(`/api/chats/files/content?path=${encodeURIComponent(path)}&overwrite=1`, { + data: body, headers: { 'Content-Type': 'application/octet-stream' }, +}) + +// A small PNG: a ring in the accent colour, so the screenshot shows it loaded. +function ringPNG(size = 40) { + const crcTable = Array.from({ length: 256 }, (_, n) => { + let c = n + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 + return c >>> 0 + }) + const crc = (buf) => { + let c = 0xffffffff + for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8) + return (c ^ 0xffffffff) >>> 0 + } + const chunk = (type, data) => { + const len = Buffer.alloc(4) + len.writeUInt32BE(data.length) + const body = Buffer.concat([Buffer.from(type), data]) + const sum = Buffer.alloc(4) + sum.writeUInt32BE(crc(body)) + return Buffer.concat([len, body, sum]) + } + const rows = [] + for (let y = 0; y < size; y++) { + const row = [0] + for (let x = 0; x < size; x++) { + const d = Math.hypot(x - size / 2 + 0.5, y - size / 2 + 0.5) + const on = d < size / 2 - 1 && d > size / 2 - 9 + row.push(...(on ? [31, 111, 235, 255] : [0, 0, 0, 0])) + } + rows.push(Buffer.from(row)) + } + const ihdr = Buffer.alloc(13) + ihdr.writeUInt32BE(size, 0) + ihdr.writeUInt32BE(size, 4) + ihdr[8] = 8 + ihdr[9] = 6 + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), chunk('IDAT', deflateSync(Buffer.concat(rows))), chunk('IEND', Buffer.alloc(0)), + ]) +} + +const INDEX = ` + + + +News + + + + +

    Morning news

    +
      +
      +
      script src
      +
      deferred script
      +
      module import
      +
      dynamic import
      +
      missing file
      +
      remote fetch
      +
      outside the folder
      +
      XMLHttpRequest
      +
      + + + +` + +const STYLE = `@import "theme.css"; +* { box-sizing: border-box; } +body { margin: 0; padding: 20px; background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, sans-serif; } +.top { display: flex; align-items: center; gap: 12px; padding-bottom: 12px; border-bottom: 1px solid var(--line); background: url(img/stripe.svg) right center / 120px 40px no-repeat; } +.top h1 { margin: 0; font-size: 20px; flex: 1; } +.pill { padding: 2px 10px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); font-size: 12px; } +.items { list-style: none; padding: 0; margin: 16px 0; display: grid; gap: 8px; } +.items li { padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); } +.items li b { display: block; } +.items li span { color: var(--muted); font-size: 12px; } +.checks { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; margin: 0; color: var(--muted); font-size: 12px; } +.checks dd { margin: 0; color: var(--text); font-family: ui-monospace, monospace; overflow-wrap: anywhere; } +#styled { color: var(--accent); } +` + +const THEME = `:root { --bg: #f6f7f9; --card: #fff; --line: #d9dde3; --text: #121417; --muted: #5d6673; --accent: #1f6feb; } +:root[data-theme="dark"] { --bg: #0b0d10; --card: #171c22; --line: #29313b; --text: #e6e9ee; --muted: #8b95a3; --accent: #58a6ff; } +` + +const STRIPE = '' + +const APP = `var out = function (id, v) { document.getElementById(id).textContent = v } +out('classic', 'ran before the module: ' + (typeof window.fromModule === 'undefined')) +out('mode', document.documentElement.getAttribute('data-theme')) +fetch('news.json?fresh=1').then(function (r) { return r.json() }).then(function (data) { + var list = document.getElementById('items') + data.items.forEach(function (it) { + var li = document.createElement('li') + var b = document.createElement('b'); b.textContent = it.title + var s = document.createElement('span'); s.textContent = it.source + li.appendChild(b); li.appendChild(s); list.appendChild(li) + }) +}) +fetch('missing.json').then(function (r) { out('missing', r.status + ' ' + r.ok) }) +fetch('https://example.com/news.json').then(function () { out('remote', 'LEAK') }, function (e) { out('remote', e.name + ': ' + e.message) }) +fetch('../../${SECRET}').then(function (r) { return r.text() }).then(function (t) { out('outside', 'LEAK ' + t) }, function (e) { out('outside', 'refused: ' + e.message) }) +var x = new XMLHttpRequest() +x.open('GET', 'news.json') +x.responseType = 'json' +x.onload = function () { out('xhr', x.status + ' ' + x.response.items.length + ' items') } +x.send() +` + +const LATE = "document.getElementById('late').textContent = 'after parsing: ' + !!document.getElementById('xhr')\n" + +const MAIN = `import { label } from './lib/format.js' +window.fromModule = true +document.getElementById('module').textContent = label('ok') +const mod = await import('./lib/dynamic.js') +document.getElementById('dynamic').textContent = mod.default +` +const FORMAT = "import { suffix } from './util.js'\nexport const label = (s) => s + suffix()\n" +const UTIL = "import { label } from './format.js'\nexport const suffix = () => ' (cycle ' + typeof label + ')'\n" +const DYNAMIC = "export default 'loaded on demand'\n" +const NEWS = JSON.stringify({ items: [ + { title: 'Local models get faster on small boards', source: 'news.json' }, + { title: 'A page can now read the files next to it', source: 'news.json' }, + { title: 'Split code into app.js and style.css', source: 'news.json' }, +] }) + +const frame = (page) => page.frameLocator('[data-testid="page-frame"]') + +test.beforeAll(async ({ request }) => { + const files = { + 'index.html': INDEX, 'style.css': STYLE, 'theme.css': THEME, 'img/stripe.svg': STRIPE, 'img/logo.png': ringPNG(), + 'app.js': APP, 'lib/late.js': LATE, 'main.js': MAIN, 'lib/format.js': FORMAT, 'lib/util.js': UTIL, + 'lib/dynamic.js': DYNAMIC, 'news.json': NEWS, 'page.json': '{"title":"Morning news","icon":"news"}', + } + for (const [p, body] of Object.entries(files)) { + expect((await put(request, `/pages/${NAME}/${p}`, body)).ok(), p).toBeTruthy() + } + expect((await put(request, `/${SECRET}`, '{"secret":true}')).ok()).toBeTruthy() +}) + +test('a page split into files runs: script src, css link, image, modules, fetch and XHR', async ({ page }) => { + const errors = [] + page.on('console', (m) => { if (m.type() === 'error' && /LocalAI page/.test(m.text())) errors.push(m.text()) }) + await page.addInitScript(() => { try { localStorage.setItem('localai-theme', 'light') } catch { /* private mode */ } }) + await page.goto(`/app/pages/${NAME}`) + const f = frame(page) + await expect(f.locator('#items li')).toHaveCount(3, { timeout: 15_000 }) + await expect(f.locator('#items li').first()).toContainText('Local models get faster') + await expect(f.locator('#classic')).toHaveText('ran before the module: true') + await expect(f.locator('#late')).toHaveText('after parsing: true') + await expect(f.locator('#module')).toHaveText('ok (cycle function)') + await expect(f.locator('#dynamic')).toHaveText('loaded on demand') + await expect(f.locator('#missing')).toHaveText('404 false') + await expect(f.locator('#remote')).toHaveText(/^TypeError: network is off in pages; put the data in a file next to the page/) + await expect(f.locator('#outside')).toHaveText(/^refused: .*outside the page's folder/) + await expect(f.locator('#outside')).not.toContainText('LEAK') + await expect(f.locator('#xhr')).toHaveText('200 3 items') + // The style sheet and its @import went in: the theme colours apply. + const bg = await f.locator('body').evaluate((el) => getComputedStyle(el).backgroundColor) + expect(bg).toBe('rgb(246, 247, 249)') + const logo = await f.locator('#logo').evaluate((img) => ({ w: img.naturalWidth, src: img.src.slice(0, 22) })) + expect(logo).toEqual({ w: 40, src: 'data:image/png;base64,' }) + const stripe = await f.locator('.top').evaluate((el) => getComputedStyle(el).backgroundImage.slice(0, 30)) + expect(stripe).toBe('url("data:image/svg+xml;base64') + await expect(f.locator('localai-notice')).toHaveCount(0) + expect(errors).toEqual([]) + if (shots) { + await page.waitForTimeout(300) + await page.screenshot({ path: `${shots}/pages-files-light.png` }) + } +}) + +test('the page in the dark theme', async ({ page }) => { + await page.addInitScript(() => { try { localStorage.setItem('localai-theme', 'dark') } catch { /* private mode */ } }) + await page.goto(`/app/pages/${NAME}`) + const f = frame(page) + await expect(f.locator('#items li')).toHaveCount(3, { timeout: 15_000 }) + await expect(f.locator('#mode')).toHaveText('dark') + expect(await f.locator('body').evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(11, 13, 16)') + if (shots) { + await page.waitForTimeout(300) + await page.screenshot({ path: `${shots}/pages-files-dark.png` }) + } +}) + +test('a changed style sheet reloads the page with it; a data file does not reload', async ({ page, request }) => { + await page.addInitScript(() => { try { localStorage.setItem('localai-theme', 'light') } catch { /* private mode */ } }) + await page.goto(`/app/pages/${NAME}`) + const f = frame(page) + await expect(f.locator('#items li')).toHaveCount(3, { timeout: 15_000 }) + await f.locator('body').evaluate(() => { window.__mark = 1 }) + expect((await put(request, `/pages/${NAME}/news.json`, JSON.stringify({ items: [{ title: 'one', source: 'x' }] }))).ok()).toBeTruthy() + await page.waitForTimeout(1500) + expect(await f.locator('body').evaluate(() => window.__mark)).toBe(1) + expect((await put(request, `/pages/${NAME}/theme.css`, THEME.replace('#f6f7f9', '#fdf0e0'))).ok()).toBeTruthy() + await expect.poll(() => f.locator('body').evaluate((el) => getComputedStyle(el).backgroundColor), { timeout: 15_000 }).toBe('rgb(253, 240, 224)') + expect(await f.locator('body').evaluate(() => window.__mark)).toBeUndefined() + await expect(f.locator('#items li')).toHaveCount(1) + // Put the files back for the other tests. + expect((await put(request, `/pages/${NAME}/theme.css`, THEME)).ok()).toBeTruthy() + expect((await put(request, `/pages/${NAME}/news.json`, NEWS)).ok()).toBeTruthy() +}) + +test('a script that is missing or outside the folder shows a banner in the page and stays out', async ({ page, request }) => { + const BROKEN = `broken-${stamp}` + expect((await put(request, `/pages/${BROKEN}/index.html`, `

      still here

      + +`)).ok()).toBeTruthy() + const reads = [] + page.on('request', (r) => { if (r.url().includes('/file?')) reads.push(decodeURIComponent(r.url())) }) + await page.goto(`/app/pages/${BROKEN}`) + const f = frame(page) + await expect(f.locator('#p')).toHaveText('still here') + const notice = f.locator('localai-notice') + await expect(notice).toHaveCount(1) + const text = await notice.evaluate((el) => el.shadowRoot === null ? 'closed' : 'open') + expect(text).toBe('closed') + expect(reads.every((u) => !u.includes(`pages/${BROKEN}/file?path=../`) && !u.includes(`${NAME}/app.js`)), reads.join('\n')).toBeTruthy() + expect(reads.some((u) => u.includes('path=gone.js'))).toBeTruthy() + if (shots) { + await page.waitForTimeout(300) + await page.screenshot({ path: `${shots}/pages-files-banner.png` }) + } +}) diff --git a/core/http/react-ui/e2e/pages-links-real.spec.js b/core/http/react-ui/e2e/pages-links-real.spec.js new file mode 100644 index 000000000..d4075a228 --- /dev/null +++ b/core/http/react-ui/e2e/pages-links-real.spec.js @@ -0,0 +1,89 @@ +import { test, expect } from './coverage-fixtures.js' + +// Links in a page against the real test server: the sandbox opens nothing by +// itself, so a click asks LocalAI, which shows the site and opens it in a new +// tab only after Open. PAGES_SHOTS names a folder for screenshots. + +test.describe.configure({ mode: 'serial', timeout: 60_000 }) + +const stamp = Date.now().toString(36) +const NAME = `links-${stamp}` +const shots = process.env.PAGES_SHOTS || '' +const put = (request, path, body) => request.put(`/api/chats/files/content?path=${encodeURIComponent(path)}&overwrite=1`, { + data: body, headers: { 'Content-Type': 'application/octet-stream' }, +}) + +const PAGE = ` +

      Read the article

      +

      javascript link

      +

      another file

      +

      look-alike

      +

      +

      to the end

      +

      end

      + +` + +const frame = (page) => page.frameLocator('[data-testid="page-frame"]') + +test.beforeAll(async ({ request }) => { + expect((await put(request, `/pages/${NAME}/index.html`, PAGE)).ok()).toBeTruthy() +}) + +test('a link asks first; Open opens a new tab without an opener, Cancel does nothing', async ({ page, context }) => { + await context.route('https://news.example.com/**', (r) => r.fulfill({ contentType: 'text/html', body: 'article

      ok

      ' })) + await page.addInitScript(() => { try { localStorage.setItem('localai-theme', 'light') } catch { /* private mode */ } }) + const logs = [] + page.on('console', (m) => logs.push(m.text())) + await page.goto(`/app/pages/${NAME}`) + const f = frame(page) + await f.locator('#ext').click() + const ask = page.getByTestId('page-link-ask') + await expect(ask).toBeVisible() + await expect(page.getByTestId('page-link-host')).toHaveText('news.example.com') + await expect(ask.locator('.pg-link__site')).toHaveText('example.com') + await expect(ask).toContainText('https://news.example.com/article?id=7') + await expect(page.getByTestId('page-link-lookalike')).toHaveCount(0) + if (shots) await page.screenshot({ path: `${shots}/page-link-ask-light.png` }) + + let opened = 0 + context.on('page', () => { opened++ }) + await page.getByTestId('page-link-cancel').click() + await expect(ask).toHaveCount(0) + await page.waitForTimeout(300) + expect(opened).toBe(0) + + await f.locator('#ext').click() + const [tab] = await Promise.all([context.waitForEvent('page'), page.getByTestId('page-link-open').click()]) + await tab.waitForLoadState() + expect(tab.url()).toBe('https://news.example.com/article?id=7') + expect(await tab.evaluate(() => window.opener)).toBeNull() + await tab.close() + await expect(ask).toHaveCount(0) + + // Refused links never ask and say why in the console. + await f.locator('#js').click() + await f.locator('#file').click() + await expect.poll(() => logs.join('\n')).toMatch(/the link was not opened: only http and https links open from a page/) + await expect.poll(() => logs.join('\n')).toMatch(/other\.html opens nothing/) + await expect(ask).toHaveCount(0) + + // An anchor scrolls the page and asks nothing. + await f.locator('#anchor').click() + await expect(ask).toHaveCount(0) + expect(await f.locator('body').evaluate(() => window.scrollY)).toBeGreaterThan(500) +}) + +test('window.open asks too, and a look-alike host says how it reads', async ({ page }) => { + await page.goto(`/app/pages/${NAME}`) + const f = frame(page) + await f.locator('#win').click() + await expect(page.getByTestId('page-link-host')).toHaveText('example.org') + expect(await f.locator('body').evaluate(() => window.__ret)).toBeNull() + await f.locator('#look').click() + await expect(page.getByTestId('page-link-host')).toHaveText('xn--pple-43d.com') + await expect(page.getByTestId('page-link-lookalike')).toContainText('аpple.com') + await expect(page.getByTestId('page-link-ask')).toHaveCount(1) + if (shots) await page.screenshot({ path: `${shots}/page-link-lookalike-dark.png` }) + await page.getByTestId('page-link-cancel').click() +}) diff --git a/core/http/react-ui/e2e/pages-real.spec.js b/core/http/react-ui/e2e/pages-real.spec.js new file mode 100644 index 000000000..282d77d5e --- /dev/null +++ b/core/http/react-ui/e2e/pages-real.spec.js @@ -0,0 +1,186 @@ +import { test, expect } from './coverage-fixtures.js' + +// Pages of the workspace against the real test server: the files are written +// with the files API, the viewer loads the page into its sandbox and the bridge +// answers through the real pages routes, so what the frame can and cannot reach +// is what a user's page can and cannot reach. + +test.describe.configure({ mode: 'serial', timeout: 60_000 }) + +const stamp = Date.now().toString(36) +const NAME = `probe-${stamp}` +const SECRET = `secret-${stamp}.txt` + +const put = (request, path, body) => request.put(`/api/chats/files/content?path=${encodeURIComponent(path)}&overwrite=1`, { + data: body, headers: { 'Content-Type': 'application/octet-stream' }, +}) + +// A page that probes its sandbox from the inside and drives every bridge call. +const PROBE = ` +

      running

      + +` + +const frame = (page) => page.frameLocator('[data-testid="page-frame"]') + +test.beforeAll(async ({ request }) => { + expect((await put(request, `/pages/${NAME}/index.html`, PROBE)).ok()).toBeTruthy() + expect((await put(request, `/pages/${NAME}/data.json`, '{"n":1}')).ok()).toBeTruthy() + expect((await put(request, `/pages/${NAME}/page.json`, '{"title":"Probe"}')).ok()).toBeTruthy() + expect((await put(request, `/${SECRET}`, 'top secret')).ok()).toBeTruthy() +}) + +test('a page runs in a sandbox: no parent DOM, no storage, no network, nothing outside its folder', async ({ page }) => { + await page.goto(`/app/pages/${NAME}`) + const el = page.getByTestId('page-frame') + await expect(el).toHaveAttribute('sandbox', 'allow-scripts') + const f = frame(page) + await expect(f.locator('#probe')).toHaveText(/net-/, { timeout: 15_000 }) + const probe = (await f.locator('#probe').textContent()).split(',') + expect(probe).toEqual(['parent-blocked', 'top-blocked', 'storage-blocked', 'cookie-blocked', 'bridge', 'api-blocked', 'net-blocked', 'img-blocked']) + await expect(f.locator('#outside')).toHaveText(/^refused: /) + await expect(f.locator('#outside')).not.toContainText('top secret') + await expect(f.locator('#frozen')).toHaveText('function') + // The title from page.json is text in the strip, never markup. + await expect(page.locator('.pg-strip__title')).toHaveText('Probe') + expect(await page.evaluate(() => window.__pwned)).toBeUndefined() + await expect(page.getByTestId('page-runs-as-you')).toBeVisible() +}) + +test('the bridge reads, lists and writes the own folder and tells the page what changed', async ({ page, request }) => { + await page.goto(`/app/pages/${NAME}`) + const f = frame(page) + await expect(f.locator('#read')).toHaveText('{"n":1}') + await expect(f.locator('#list')).toHaveText('data.json,index.html,page.json') + await f.locator('#w').click() + await expect(f.locator('#write')).toHaveText(/^v\d+$/) + await expect(f.locator('#changed')).toHaveText('data.json') + const res = await request.get(`/api/chats/pages/${NAME}/file?path=data.json`) + expect((await res.json()).content).toBe('{"n":2}') + // A page cannot change its own code through the bridge. + const own = await request.put(`/api/chats/pages/${NAME}/file`, { data: { path: 'index.html', content: 'x' } }) + expect(own.status()).toBe(403) +}) + +// A page may save data files; the code of a page is changed by the person or +// the assistant, so a page cannot make its next load run new code. +const WRITER = `

      +` + +test('a page cannot write its own code, only data files', async ({ page, request }) => { + const writer = `writer-${stamp}` + expect((await put(request, `/pages/${writer}/index.html`, WRITER)).ok()).toBeTruthy() + await page.goto(`/app/pages/${writer}`) + const f = frame(page) + await expect(f.locator('#code')).toHaveText(/^forbidden: a page can write data files only/, { timeout: 15_000 }) + await expect(f.locator('#data')).toHaveText(/^v\d+$/) + expect((await request.get(`/api/chats/pages/${writer}/file?path=app.js`)).status()).toBe(404) + const res = await request.get(`/api/chats/pages/${writer}/file?path=data.json`) + expect((await res.json()).content).toBe('{"ok":true}') + // The server refuses it too, for a caller that skips the host's check. + const direct = await request.put(`/api/chats/pages/${writer}/file`, { data: { path: 'style.css', content: 'p{}' } }) + expect(direct.status()).toBe(403) + // The person's own Files route still writes code in the page folder. + expect((await put(request, `/pages/${writer}/app.js`, 'console.log(1)')).ok()).toBeTruthy() +}) + +test('chat.send asks first, then puts the message in the page chat', async ({ page, request }) => { + await page.addInitScript(() => { try { localStorage.setItem('localai_last_model:FLAG_CHAT', 'mock-model') } catch { /* private mode */ } }) + await page.goto(`/app/pages/${NAME}`) + const f = frame(page) + await f.locator('#s').click() + const ask = page.getByTestId('page-send-ask') + await expect(ask).toContainText('hello from the page') + await expect(f.locator('#send')).toHaveText('') + await ask.getByRole('button', { name: 'Send' }).click() + await expect(page.getByTestId('page-send-done')).toBeVisible({ timeout: 15_000 }) + await expect(f.locator('#send')).toHaveText(/"sent":true/) + const chatId = JSON.parse(await f.locator('#send').textContent()).chat_id + await expect.poll(async () => { + const r = await request.get(`/api/chats/${chatId}/messages`) + const pend = await request.get(`/api/chats/${chatId}`) + return JSON.stringify(await r.json()) + JSON.stringify(await pend.json()) + }, { timeout: 15_000 }).toContain('hello from the page') + const chat = await (await request.get(`/api/chats/${chatId}`)).json() + expect(chat.origin).toBe('page') +}) + +test('a page that navigates its frame away loses its bridge until Reload', async ({ page }) => { + await page.goto(`/app/pages/${NAME}`) + const f = frame(page) + await expect(f.locator('#read')).toHaveText(/n/) + await f.locator('#nav').click() + await expect(page.getByTestId('page-navigated')).toBeVisible() + await page.getByTestId('page-reload').click() + await expect(frame(page).locator('#read')).toHaveText(/n/) +}) + +test('the starter task board is copied, opened and saves its tasks', async ({ page, request }) => { + const before = (await (await request.get('/api/chats/pages')).json()).pages.map((p) => p.name) + const res = await request.post('/api/chats/page-starters/task-board/copy', { data: {} }) + expect(res.status()).toBe(201) + const { name } = await res.json() + expect(before).not.toContain(name) + await page.goto(`/app/pages/${name}`) + const f = frame(page) + await expect(f.locator('.col')).toHaveCount(3) + await f.getByLabel('New task').fill(`Buy milk ${stamp}`) + await f.getByRole('button', { name: 'Add', exact: true }).click() + await expect(f.locator('.status')).toHaveText('Saved') + const saved = await (await request.get(`/api/chats/pages/${name}/file?path=tasks.json`)).json() + expect(saved.content).toContain(`Buy milk ${stamp}`) +}) + +test('the task board keeps a task added while its first read is still on the way', async ({ page, request }) => { + const { name } = await (await request.post('/api/chats/page-starters/task-board/copy', { data: {} })).json() + // The first read of tasks.json answers late, with the file as it was before + // the task was added. + let first = true + let landed + const late = new Promise((resolve) => { landed = resolve }) + await page.route(new RegExp(`/api/chats/pages/${name}/file\\?path=tasks\\.json`), async (route) => { + if (route.request().method() !== 'GET' || !first) return route.continue() + first = false + const before = await route.fetch() + await new Promise((resolve) => { setTimeout(resolve, 1500) }) + await route.fulfill({ response: before }) + landed() + }) + await page.goto(`/app/pages/${name}`) + const f = frame(page) + await expect(f.locator('.col')).toHaveCount(3) + await f.getByLabel('New task').fill(`Call the bank ${stamp}`) + await f.getByRole('button', { name: 'Add', exact: true }).click() + await expect(f.locator('.status')).toHaveText('Saved') + await late + await page.waitForTimeout(500) // the page handles the late answer + await expect(f.getByText(`Call the bank ${stamp}`)).toBeVisible() + await expect(f.locator('.status')).toHaveText('Saved') + await page.unrouteAll({ behavior: 'ignoreErrors' }) +}) diff --git a/core/http/react-ui/e2e/pages.spec.js b/core/http/react-ui/e2e/pages.spec.js new file mode 100644 index 000000000..c89ddcf12 --- /dev/null +++ b/core/http/react-ui/e2e/pages.spec.js @@ -0,0 +1,230 @@ +import { test, expect } from './coverage-fixtures.js' + +// The Pages screens with the pages API stubbed: the sidebar entry, the list, +// pins in ui.json, the empty state, the viewer strip, chat.send and the reload +// rules. pages-real.spec.js runs the sandbox and the bridge against the server. + +// The time is read when the page is served, so it stays "just now" however +// long the worker ran before this spec. +const TASKS = { + name: 'tasks', path: '/pages/tasks', title: 'Tasks board', icon: 'checklist', description: 'What I do this week', + source: 'own', author: 'assistant', updated_by: 'assistant', rev: 7, size: 100, + get updated_at() { return new Date().toISOString() }, +} + +const PAGE_HTML = (label) => `

      ${label}

      +` + +// Stubs the pages routes and ui.json. state.pages and state.html can change +// during a test; state.ui is the parsed ui.json; puts records the writes of +// ui.json with the version each one started from. +async function stub(page, state) { + state.puts = [] + state.sends = [] + await page.route(/\/api\/chats\/pages(\?.*)?$/, (route) => route.fulfill({ json: { pages: state.pages } })) + await page.route(/\/api\/chats\/pages\/tasks(\?.*)?$/, (route) => route.fulfill({ json: { page: state.pages[0] || TASKS, html: state.html } })) + await page.route(/\/api\/chats\/pages\/tasks\/chat$/, (route) => { + state.sends.push(route.request().postDataJSON()) + return route.fulfill({ json: { chat_id: 'pc1', title: 'Page: Tasks board', started: true } }) + }) + await page.route(/\/api\/chats\/page-starters\/task-board\/copy$/, (route) => { + state.pages = [{ ...TASKS, name: 'tasks', title: 'Task board', author: 'you', updated_by: 'you' }] + return route.fulfill({ status: 201, json: state.pages[0] }) + }) + // ui.json through the well-known files service: a write names the version it + // read in If-Match, keeps the keys it does not send, and an older version is + // a 409 that carries what the file holds now. state.racer, when set, changes + // the file once just before the next write lands, as another tab would. + const uiDoc = () => ({ + name: 'ui', path: '/LocalAI/ui.json', exists: !!state.ui, version: state.ui ? (state.uiVersion || 1) : 0, + data: state.ui || {}, valid: true, errors: [], + }) + await page.route(/\/api\/chats\/wellknown\/ui$/, (route) => { + const req = route.request() + if (req.method() !== 'PUT') return route.fulfill({ json: uiDoc() }) + if (state.racer) { + state.ui = state.racer(state.ui || {}) + state.uiVersion = uiDoc().version + 1 + state.racer = null + } + const body = req.postDataJSON() + const base = Number(String(req.headers()['if-match'] || '').replace(/"/g, '')) + state.puts.push({ base, body }) + if (base !== uiDoc().version) { + return route.fulfill({ status: 409, json: { error: { message: 'the file changed since you read it', code: 409, type: 'conflict' }, current: uiDoc() } }) + } + const next = body.replace ? {} : { ...(state.ui || {}) } + for (const [k, v] of Object.entries(body.values)) { + if (v === null) delete next[k] + else next[k] = v + } + state.ui = next + state.uiVersion = uiDoc().version + 1 + return route.fulfill({ json: uiDoc() }) + }) +} + +const nav = (page) => page.locator('#app-sidebar') + +test('the Pages entry is hidden with no page and shows with one, with pinned pages under it', async ({ page }) => { + const state = { pages: [], html: PAGE_HTML('v1') } + await stub(page, state) + const listed = page.waitForResponse(/\/api\/chats\/pages$/) + await page.goto('/app/files') + await listed + await expect(nav(page).getByRole('link', { name: 'Files', exact: true })).toBeVisible() + await expect(nav(page).getByRole('link', { name: /^Pages( New)?$/ })).toHaveCount(0) + + state.pages = [TASKS] + state.ui = { theme: 'dark', pinnedPages: ['tasks', 'gone'] } + await page.goto('/app/files') + await expect(nav(page).getByRole('link', { name: /^Pages( New)?$/ })).toBeVisible() + const pinned = nav(page).getByTestId('pages-pinned-link') + await expect(pinned).toHaveCount(1) + await expect(pinned).toHaveText('Tasks board') + await pinned.click() + await expect(page).toHaveURL(/\/app\/pages\/tasks$/) +}) + +test('the list shows each page with who changed it, and Pin keeps the other keys of ui.json', async ({ page }) => { + const state = { pages: [TASKS], html: PAGE_HTML('v1'), ui: { theme: 'light', accent: '#112233', later: { x: 1 } } } + await stub(page, state) + await page.goto('/app/pages') + const card = page.getByTestId('page-card') + await expect(card).toHaveCount(1) + await expect(card).toContainText('Tasks board') + await expect(card).toContainText('pages/tasks') + await expect(card.getByTestId('page-author')).toHaveText('Changed just now by the assistant') + + await card.getByTestId('page-pin').click() + await expect(card.getByTestId('page-pin')).toHaveAttribute('aria-pressed', 'true') + await expect(card.getByTestId('page-pin')).toHaveAccessibleName('Unpin from sidebar') + expect(state.puts).toHaveLength(1) + expect(state.puts[0].base).toBe(1) + expect(state.puts[0].body).toEqual({ values: { pinnedPages: ['tasks'] }, replace: false }) + expect(state.ui).toEqual({ theme: 'light', accent: '#112233', later: { x: 1 }, pinnedPages: ['tasks'] }) + await expect(nav(page).getByTestId('pages-pinned-link')).toHaveText('Tasks board') + + // Someone else changes ui.json between the read and the write: the unpin + // gets a 409 with the newer file and is written again on top of it. + state.racer = (ui) => ({ ...ui, density: 'compact' }) + await card.getByTestId('page-pin').click() + await expect(card.getByTestId('page-pin')).toHaveAttribute('aria-pressed', 'false') + expect(state.puts.map((p) => p.base)).toEqual([1, 2, 3]) + expect(state.puts[2].body.values).toEqual({ pinnedPages: [] }) + expect(state.ui).toEqual({ theme: 'light', accent: '#112233', later: { x: 1 }, density: 'compact', pinnedPages: [] }) + await expect(nav(page).getByTestId('pages-pinned-link')).toHaveCount(0) +}) + +test('the empty state copies the task board and opens it', async ({ page }) => { + const state = { pages: [], html: PAGE_HTML('board') } + await stub(page, state) + await page.goto('/app/pages') + await expect(page.getByTestId('pages-empty')).toBeVisible() + await page.getByTestId('pages-starter').click() + await expect(page).toHaveURL(/\/app\/pages\/tasks$/) + await expect(page.frameLocator('[data-testid="page-frame"]').locator('#v')).toHaveText('board') +}) + +test('Ask the assistant opens a chat with the page path in the box, not sent', async ({ page }) => { + const state = { pages: [TASKS], html: PAGE_HTML('v1') } + await stub(page, state) + await page.goto('/app/pages/tasks') + await expect(page.getByTestId('page-updated')).toHaveText(/updated just now by the assistant/) + await page.getByTestId('page-ask').click() + await expect(page).toHaveURL(/\/app\/chats$/) + await expect(page.locator('textarea').first()).toHaveValue('Change the page in pages/tasks/ so that ') +}) + +test('chat.send waits for the person: Dismiss answers sent false, Send posts to the chosen chat', async ({ page }) => { + const state = { pages: [TASKS], html: PAGE_HTML('v1') } + await stub(page, state) + await page.goto('/app/pages/tasks') + const f = page.frameLocator('[data-testid="page-frame"]') + await f.locator('#s').click() + await expect(page.getByTestId('page-send-ask')).toContainText('summarise my tasks') + await page.getByTestId('page-send-ask').getByRole('button', { name: 'Dismiss' }).click() + await expect(f.locator('#sent')).toHaveText('{"sent":false}') + expect(state.sends).toHaveLength(0) + + await f.locator('#s').click() + await page.getByTestId('page-send-ask').getByRole('button', { name: 'Send' }).click() + await expect(page.getByTestId('page-send-done')).toContainText('Sent to Page: Tasks board') + await expect(f.locator('#sent')).toHaveText('{"sent":true,"chat_id":"pc1"}') + expect(state.sends).toEqual([expect.objectContaining({ text: 'summarise my tasks' })]) + expect(state.sends[0].chat_id).toBeUndefined() +}) + +// Stubs the change feed. Each stream stays open until the test sends a change, +// carries it and ends, as a dropped connection does; the client reconnects +// with the revision it saw and waits for the next one. +async function stubFeed(page) { + const queue = [] + let wake = null + let revision = 10 + await page.route('**/api/workspace/events*', async (route) => { + while (queue.length === 0) await new Promise((resolve) => { wake = resolve }) + const body = queue.splice(0).join('') + await route.fulfill({ status: 200, headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }, body }).catch(() => {}) + }) + return (paths) => { + revision++ + queue.push(`id: ${revision}\nevent: change\ndata: ${JSON.stringify({ revision, paths })}\n\n`) + wake?.() + } +} + +test('a change of index.html reloads the page; anything else reaches it as files.changed', async ({ page }) => { + const state = { pages: [TASKS], html: PAGE_HTML('v1') } + await stub(page, state) + const change = await stubFeed(page) + await page.goto('/app/pages/tasks') + const f = page.frameLocator('[data-testid="page-frame"]') + await expect(f.locator('#v')).toHaveText('v1') + const mark = await f.locator('#v').getAttribute('data-mark') + + // The window gets the focus back: the feed may have missed changes, so the + // page hears a resync and stays. + await page.evaluate(() => window.dispatchEvent(new Event('focus'))) + await expect(f.locator('#ev')).toHaveText('{"paths":[],"resync":true}') + expect(await f.locator('#v').getAttribute('data-mark')).toBe(mark) + + // A data file of the page changes: the page hears its path, relative to its + // folder, and stays. Other pages and other files do not reach it. + change(['pages/tasks/tasks.json', 'pages/other/tasks.json', 'notes.txt']) + await expect(f.locator('#ev')).toHaveText('{"paths":["tasks.json"]}') + expect(await f.locator('#v').getAttribute('data-mark')).toBe(mark) + + // index.html changes: the frame loads the new page. + state.html = PAGE_HTML('v2') + change(['pages/tasks/index.html']) + await expect(f.locator('#v')).toHaveText('v2') + await page.unrouteAll({ behavior: 'ignoreErrors' }) +}) + +test('a page that does not exist says so, with a way back', async ({ page }) => { + await page.route(/\/api\/chats\/pages\/nope(\?.*)?$/, (route) => route.fulfill({ status: 404, json: { error: { message: 'page or file not found', code: 404, type: 'not_found' } } })) + await page.goto('/app/pages/nope') + await expect(page.getByTestId('page-not-found')).toContainText('There is no page called nope.') + await page.getByRole('link', { name: 'All pages' }).click() + await expect(page).toHaveURL(/\/app\/pages$/) +}) + +test('the viewer fits a phone screen without horizontal scroll', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + const state = { pages: [TASKS], html: PAGE_HTML('v1') } + await stub(page, state) + await page.goto('/app/pages/tasks') + await expect(page.getByTestId('page-frame')).toBeVisible() + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + expect(overflow).toBeLessThanOrEqual(0) + await page.goto('/app/pages') + await expect(page.getByTestId('page-card')).toBeVisible() + const listOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + expect(listOverflow).toBeLessThanOrEqual(0) +}) diff --git a/core/http/react-ui/e2e/production-bundle.spec.js b/core/http/react-ui/e2e/production-bundle.spec.js new file mode 100644 index 000000000..63c0d5aaa --- /dev/null +++ b/core/http/react-ui/e2e/production-bundle.spec.js @@ -0,0 +1,82 @@ +import { test, expect } from './coverage-fixtures.js' + +// The bundle the binary embeds is code-split and its CSS is emitted per chunk, +// in an order the V8 coverage bundle (one inlined chunk) does not have. A rule +// that wins in one order and loses in the other passes the coverage run and +// breaks in the shipped app. This walk loads the main pages on the production +// bundle, against the real test server with nothing stubbed, and fails on +// anything the browser reports: a page error, a console error, a request that +// the server refused or a stylesheet that did not apply. +// +// Tagged @production-chunks so `make test-ui-stale-chunk` (production build) +// runs it, and the coverage run (inlined build) skips it. + +const PAGES = [ + { name: 'Home', path: '/app', ready: page => page.locator('.home-page') }, + { name: 'All chats', path: '/app/chats/all', ready: page => page.getByTestId('all-chats-page') }, + { name: 'Agents', path: '/app/agents', ready: page => page.getByRole('heading', { name: 'Agents', level: 1 }) }, + { name: 'Administration', path: '/app/operate', ready: page => page.getByTestId('operate-overview') }, + { name: 'Models', path: '/app/models', ready: page => page.locator('.sidebar-nav a.nav-item[href="/app/models"][aria-current="page"], .sidebar-nav a.nav-item.active[href="/app/models"]').first() }, +] + +function watch(page) { + const problems = [] + page.on('pageerror', err => problems.push(`page error: ${err.message}`)) + // The browser reports a failed request as a console error too; the response + // handler below names the request, so that duplicate is dropped. + page.on('console', msg => { + if (msg.type() === 'error' && !msg.text().startsWith('Failed to load resource')) problems.push(`console error: ${msg.text()}`) + }) + page.on('response', res => { + const status = res.status() + const url = new URL(res.url()) + // Static assets must not fail. The test server runs without add-ons, and + // their routes answer 503 "unavailable" by design. Any other server error + // is a bug. A 404 on an API list with nothing to show yet is the API's + // own answer and does not count. + const addonRoute = /^\/api\/(addons|fine-tuning|quantization)(\/|$)/.test(url.pathname) + if (url.pathname.startsWith('/assets/') && status >= 400) problems.push(`asset ${url.pathname}: ${status}`) + else if (status >= 500 && !(addonRoute && status === 503)) problems.push(`${url.pathname}: ${status}`) + }) + return problems +} + +test.describe('@production-chunks Production bundle', () => { + for (const { name, path, ready } of PAGES) { + test(`${name} loads on the shipped bundle`, async ({ page }) => { + const problems = watch(page) + await page.goto(path) + await expect(page.locator('.sidebar')).toBeVisible() + await expect(ready(page)).toBeVisible() + await expect(page.getByText('Unexpected Application Error')).toHaveCount(0) + + // Every stylesheet the page links has been parsed. + const unapplied = await page.evaluate(() => + [...document.querySelectorAll('link[rel="stylesheet"]')].filter(l => !l.sheet).map(l => l.href)) + expect(unapplied).toEqual([]) + // And the design system reached the sidebar: an unstyled app has a + // static, transparent one. + const sidebar = await page.locator('.sidebar').evaluate(el => { + const s = getComputedStyle(el) + return { position: s.position, background: s.backgroundColor } + }) + expect(sidebar.background).not.toBe('rgba(0, 0, 0, 0)') + + expect(problems).toEqual([]) + }) + } + + test('the main pages are code-split chunks, not one inlined bundle', async ({ page }) => { + const scripts = new Set() + page.on('response', res => { + const { pathname } = new URL(res.url()) + if (pathname.startsWith('/assets/') && pathname.endsWith('.js')) scripts.add(pathname) + }) + await page.goto('/app') + await expect(page.locator('.home-page')).toBeVisible() + await page.goto('/app/agents') + await expect(page.getByRole('heading', { name: 'Agents', level: 1 })).toBeVisible() + // The coverage bundle has a single script; the shipped one has a chunk per page. + expect(scripts.size).toBeGreaterThan(2) + }) +}) diff --git a/core/http/react-ui/e2e/real-auth.spec.js b/core/http/react-ui/e2e/real-auth.spec.js new file mode 100644 index 000000000..3d5310bdb --- /dev/null +++ b/core/http/react-ui/e2e/real-auth.spec.js @@ -0,0 +1,161 @@ +import { test, expect, request as pwRequest } from '@playwright/test' +import { haveBinary, startRealServer } from './real-server.js' + +// Authentication against a real local-ai binary with LOCALAI_AUTH on: the +// first account becomes the administrator, a wrong password is refused, API +// keys are created and revoked on the account page, and the per-user features +// (chats, collections, add-ons) take the matching pages and routes away. +// +// LOCALAI_BIN= MOCK_BACKEND_BIN= \ +// PLAYWRIGHT_EXTERNAL_SERVER=1 npx playwright test e2e/real-auth.spec.js +// +// Skipped when the two binaries are not given, so the default run is unchanged. +test.skip(!haveBinary, 'needs LOCALAI_BIN and MOCK_BACKEND_BIN') +test.describe.configure({ mode: 'serial' }) + +const ADMIN = { email: 'admin@example.test', password: 'Correct-horse-9-battery', name: 'Admin' } +const USER = { email: 'carol@example.test', password: 'Another-long-pass-7', name: 'Carol' } + +let server + +// The server limits sign-ins, so the specs sign in once through the API and +// reuse the session; only the sign-in specs go through the form. +let adminKey +let carolState +let adminState +let carolId + +async function apiLogin(u) { + const ctx = await pwRequest.newContext({ baseURL: server.url }) + const r = await ctx.post('/api/auth/login', { data: { email: u.email, password: u.password } }) + expect(r.ok(), await r.text()).toBe(true) + return ctx +} + +test.beforeAll(async () => { + server = await startRealServer({ auth: true }) + const anon = await pwRequest.newContext({ baseURL: server.url }) + for (const u of [ADMIN, USER]) { + const r = await anon.post('/api/auth/register', { data: u }) + expect(r.ok(), await r.text()).toBe(true) + } + const admin = await apiLogin(ADMIN) + adminState = await admin.storageState() + const key = await admin.post('/api/auth/api-keys', { data: { name: 'setup' } }) + adminKey = (await key.json()).key + const carol = await apiLogin(USER) + carolState = await carol.storageState() + const users = await (await admin.get('/api/auth/admin/users')).json() + carolId = (users.users || users).find(u => u.email === USER.email).id +}) +test.afterAll(async () => { await server?.close() }) + +async function signIn(page, u) { + await page.goto(`${server.url}/login`) + await page.getByLabel('Email').fill(u.email) + await page.getByRole('button', { name: /continue/i }).click() + await page.getByLabel('Password').fill(u.password) + await page.getByRole('button', { name: /sign in/i }).click() + await expect(page).toHaveURL(/\/app/) +} + +test('an anonymous visitor is sent to the login page and the API refuses them', async ({ page, request }) => { + await page.goto(`${server.url}/app/chats`) + await expect(page).toHaveURL(/\/login/) + const r = await request.get(`${server.url}/api/chats`) + expect(r.status()).toBe(401) +}) + +test('the first account is the administrator and signs in through the form', async ({ page }) => { + await signIn(page, ADMIN) + await expect(page).toHaveURL(/\/app/) + await expect(page.getByRole('link', { name: 'Administration' })).toBeVisible() + await page.goto(`${server.url}/app/chats/all`) + await expect(page.getByTestId('all-chats-page')).toBeVisible() +}) + +test('a wrong password is refused with a message and stays on the login page', async ({ page }) => { + await page.goto(`${server.url}/login`) + await page.getByLabel('Email').fill(ADMIN.email) + await page.getByRole('button', { name: /continue/i }).click() + await page.getByLabel('Password').fill('not-the-password-1') + await page.getByRole('button', { name: /sign in/i }).click() + await expect(page.getByRole('alert')).toBeVisible() + await expect(page).toHaveURL(/\/login/) +}) + +test('an API key made on the account page works for the API and stops working when revoked', async ({ browser, request }) => { + const context = await browser.newContext({ storageState: adminState }) + const page = await context.newPage() + await page.goto(`${server.url}/app/account?tab=keys`) + const panel = page.getByTestId('api-keys-panel') + await expect(panel).toBeVisible() + await panel.getByPlaceholder('Key name (e.g. my-app)').fill('spec key') + await panel.getByRole('button', { name: 'Create', exact: true }).click() + const secret = (await panel.locator('code').first().innerText()).trim() + const auth = { Authorization: `Bearer ${secret}` } + const ok = await request.get(`${server.url}/api/chats`, { headers: auth }) + expect(ok.status()).toBe(200) + + await panel.getByRole('button', { name: 'I copied it' }).click() + await panel.getByRole('button', { name: /Revoke/ }).first().click() + const dialog = page.getByRole('alertdialog') + if (await dialog.isVisible().catch(() => false)) await dialog.getByRole('button', { name: /Revoke/ }).click() + // Revoking waits a few seconds so it can be undone; then the key stops working. + await expect.poll(async () => (await request.get(`${server.url}/api/chats`, { headers: auth })).status(), { timeout: 30_000 }).toBe(401) + await context.close() +}) + +const setPerms = async (request, perms) => { + const r = await request.put(`${server.url}/api/auth/admin/users/${carolId}/permissions`, { data: perms, headers: { Authorization: `Bearer ${adminKey}` } }) + expect(r.ok(), await r.text()).toBe(true) +} + +async function carolPage(browser) { + const context = await browser.newContext({ storageState: carolState }) + return { context, page: await context.newPage() } +} + +test('a user whose chats feature is off loses All chats, the recent chats, the Files page and the chats API', async ({ browser, request }) => { + await setPerms(request, { chats: false }) + const { context, page } = await carolPage(browser) + await page.goto(`${server.url}/app`) + const nav = page.getByRole('complementary', { name: 'Primary navigation' }) + await expect(nav.getByRole('link', { name: 'Home' })).toBeVisible() + await expect(nav.getByRole('link', { name: 'All chats' })).toHaveCount(0) + await expect(page.getByTestId('sidebar-chats')).toHaveCount(0) + await expect(nav.getByRole('link', { name: 'Files' })).toHaveCount(0) + expect((await page.request.get(`${server.url}/api/chats`)).status()).toBe(403) + await page.goto(`${server.url}/app/chats/all`) + await expect(page.getByTestId('all-chats-page')).toHaveCount(0) + + await setPerms(request, { chats: true }) + await page.goto(`${server.url}/app/chats/all`) + await expect(page.getByTestId('all-chats-page')).toBeVisible() + await context.close() +}) + +test('a user whose collections feature is off cannot open Memory or call the collections API', async ({ browser, request }) => { + await setPerms(request, { collections: false }) + const { context, page } = await carolPage(browser) + expect([403, 404]).toContain((await page.request.get(`${server.url}/api/collections`)).status()) + await page.goto(`${server.url}/app/collections`) + await expect(page.getByRole('heading', { name: 'Memory' })).toHaveCount(0) + // The same page opens once the feature is back, so the absence above is the permission. + await setPerms(request, { collections: true }) + await page.goto(`${server.url}/app/collections`) + await expect(page.getByRole('heading', { name: 'Memory' }).first()).toBeVisible() + await context.close() +}) + +test('a user without the add-ons feature gets no Add-ons entry and the add-ons API refuses them', async ({ browser, request }) => { + await setPerms(request, { addons: false }) + const { context, page } = await carolPage(browser) + await page.goto(`${server.url}/app`) + await expect(page.getByRole('complementary', { name: 'Primary navigation' }).getByRole('link', { name: 'Add-ons' })).toHaveCount(0) + expect([403, 404, 503]).toContain((await page.request.get(`${server.url}/api/addons/apps`)).status()) + await setPerms(request, { addons: true }) + await page.goto(`${server.url}/app`) + await expect(page.getByRole('complementary', { name: 'Primary navigation' }).getByRole('link', { name: 'Add-ons' })).toBeVisible() + await context.close() +}) diff --git a/core/http/react-ui/e2e/real-home.spec.js b/core/http/react-ui/e2e/real-home.spec.js new file mode 100644 index 000000000..83583fae5 --- /dev/null +++ b/core/http/react-ui/e2e/real-home.spec.js @@ -0,0 +1,79 @@ +import { test, expect, request as pwRequest } from '@playwright/test' +import { haveBinary, startRealServer } from './real-server.js' + +// The Home console and the first run against real local-ai binaries, with no +// stubbed route: one server with no model installed, one with the mock model. +// +// LOCALAI_BIN= MOCK_BACKEND_BIN= \ +// npx playwright test -c e2e/real-home.spec.js +test.skip(!haveBinary, 'needs LOCALAI_BIN and MOCK_BACKEND_BIN') +test.describe.configure({ mode: 'serial' }) + +let empty +let ready + +test.beforeAll(async () => { + ;[empty, ready] = await Promise.all([startRealServer({ withModel: false }), startRealServer()]) +}) +test.afterAll(async () => { + await Promise.all([empty?.close(), ready?.close()]) +}) + +const bar = (page) => page.getByRole('combobox', { name: 'Message' }) + +test('first run: no model installed shows the three steps, and slash actions still work', async ({ page }) => { + await page.goto(`${empty.url}/app`) + await expect(page.getByTestId('home-first-run')).toBeVisible({ timeout: 20_000 }) + await expect(page.getByTestId('home-first-run').getByRole('listitem')).toHaveCount(3) + await bar(page).focus() + await page.keyboard.type('/') + await expect(page.getByRole('listbox', { name: 'Actions' })).toBeVisible() + await page.getByTestId('home-slash-settings').click() + await expect(page).toHaveURL(/\/app\/settings/) +}) + +test('first run: the old chats address opens Home, which offers no way to send without a model', async ({ page }) => { + await page.goto(`${empty.url}/app/chats`) + await expect(page).toHaveURL(/\/app$/) + await expect(page.getByTestId('home-first-run')).toBeVisible({ timeout: 20_000 }) + await bar(page).fill('hello') + await expect(page.locator('.home-send-btn')).toBeDisabled() +}) + +test('a message typed on Home starts a real chat that answers, and Home lists it to resume', async ({ page }) => { + await page.goto(`${ready.url}/app`) + await expect(bar(page)).toBeVisible({ timeout: 20_000 }) + await bar(page).fill('hello from home') + await bar(page).press('Enter') + await expect(page).toHaveURL(/\/app\/chats\/[0-9a-f]+$/) + await expect(page.getByRole('region', { name: 'Conversation' })).toContainText('mocked', { timeout: 30_000 }) + await page.goto(`${ready.url}/app`) + const row = page.getByTestId('home-conversation').first() + await expect(row).toBeVisible() + await row.click() + await expect(page).toHaveURL(/\/app\/chats\/[0-9a-f]+$/) + await expect(page.getByRole('region', { name: 'Conversation' })).toContainText('hello from home') +}) + +test('the LocalAI Assistant entry and /assistant follow the setting, and start an assistant chat', async ({ page }) => { + const api = await pwRequest.newContext({ baseURL: ready.url }) + await api.post('/api/settings', { data: { localai_assistant_enabled: true } }) + await page.goto(`${ready.url}/app`) + await expect(page.getByTestId('home-assistant')).toBeVisible({ timeout: 20_000 }) + await bar(page).fill('/') + await page.getByTestId('home-slash-assistant').click() + await expect(page).toHaveURL(/\/app\/chats\/[0-9a-f]+$/) + const id = page.url().split('/').pop() + const chat = await (await api.get(`/api/chats/${id}`)).json() + expect(chat.title).toBe('LocalAI Assistant') + + await api.post('/api/settings', { data: { localai_assistant_enabled: false } }) + await page.goto(`${ready.url}/app`) + await expect(bar(page)).toBeVisible({ timeout: 20_000 }) + await expect(page.getByTestId('home-assistant')).toHaveCount(0) + await bar(page).fill('/ass') + await expect(page.getByTestId('home-slash-assistant')).toHaveCount(0) + const refused = await api.post('/api/chats', { data: { model: 'mock-model', assistant: true } }) + expect(refused.ok()).toBe(false) + await api.post('/api/settings', { data: { localai_assistant_enabled: true } }) +}) diff --git a/core/http/react-ui/e2e/real-server.js b/core/http/react-ui/e2e/real-server.js new file mode 100644 index 000000000..deb7e56e4 --- /dev/null +++ b/core/http/react-ui/e2e/real-server.js @@ -0,0 +1,53 @@ +// Starts a real local-ai binary for the specs that need what the shared test +// server cannot give: authentication, or a first run with no model. The +// binary and the mock backend come from the environment (LOCALAI_BIN built +// with -tags auth, MOCK_BACKEND_BIN); the data lives in a temporary folder. +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import net from 'node:net' +import os from 'node:os' +import path from 'node:path' + +export const haveBinary = Boolean(process.env.LOCALAI_BIN && process.env.MOCK_BACKEND_BIN) + +function freePort() { + return new Promise((resolve, reject) => { + const s = net.createServer() + s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)) }) + s.on('error', reject) + }) +} + +export async function startRealServer({ auth = false, withModel = true, env = {} } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'localai-real-')) + const models = path.join(dir, 'models') + fs.mkdirSync(models, { recursive: true }) + if (withModel) { + fs.writeFileSync(path.join(models, 'mock-model.yaml'), 'name: mock-model\nbackend: mock-backend\nknown_usecases:\n - chat\nparameters:\n model: mock-model.bin\n') + } + const port = await freePort() + const args = ['run', '--address', `127.0.0.1:${port}`, '--models-path', models, '--data-path', path.join(dir, 'data'), + '--localai-config-dir', path.join(dir, 'config'), '--backends-path', path.join(dir, 'backends'), + '--external-grpc-backends', `mock-backend:${process.env.MOCK_BACKEND_BIN}`] + const childEnv = { ...process.env, LOCALAI_AUTOLOAD_GALLERIES: 'false', LOCALAI_AUTOLOAD_BACKEND_GALLERIES: 'false', ...env } + delete childEnv.API_KEY + delete childEnv.LOCALAI_API_KEY + if (auth) childEnv.LOCALAI_AUTH = 'true' + const child = spawn(process.env.LOCALAI_BIN, args, { env: childEnv, stdio: ['ignore', fs.openSync(path.join(dir, 'server.log'), 'a'), fs.openSync(path.join(dir, 'server.log'), 'a')], cwd: dir }) + const url = `http://127.0.0.1:${port}` + const deadline = Date.now() + 150_000 + for (;;) { + try { if ((await fetch(`${url}/readyz`)).ok) break } catch { /* not up yet */ } + if (child.exitCode !== null) throw new Error(`local-ai exited with ${child.exitCode}: ${fs.readFileSync(path.join(dir, 'server.log'), 'utf8').slice(-600)}`) + if (Date.now() > deadline) { child.kill(); throw new Error('local-ai did not start in time') } + await new Promise(r => setTimeout(r, 500)) + } + return { + url, + async close() { + child.kill('SIGTERM') + await new Promise(r => { child.on('exit', r); setTimeout(r, 5000) }) + fs.rmSync(dir, { recursive: true, force: true }) + }, + } +} diff --git a/core/http/react-ui/e2e/route-param-decoding.spec.js b/core/http/react-ui/e2e/route-param-decoding.spec.js index 5a1c41134..1ebd31031 100644 --- a/core/http/react-ui/e2e/route-param-decoding.spec.js +++ b/core/http/react-ui/e2e/route-param-decoding.spec.js @@ -1,9 +1,7 @@ import { test, expect } from './coverage-fixtures.js' const MODEL_NAME = 'rate%model' -const SKILL_NAME = 'cache%skill' const encodedModel = encodeURIComponent(MODEL_NAME) -const encodedSkill = encodeURIComponent(SKILL_NAME) test.describe('Encoded route parameters', () => { test('model editor preserves a literal percent in the model name', async ({ page }) => { @@ -50,18 +48,4 @@ test.describe('Encoded route parameters', () => { await expect(page.locator('.page-title')).toContainText(MODEL_NAME) }) - test('skill editor does not decode an already decoded route parameter', async ({ page }) => { - await page.route(`**/api/agents/skills/${encodedSkill}`, (route) => route.fulfill({ - contentType: 'application/json', - body: JSON.stringify({ - name: SKILL_NAME, - description: 'A route parameter regression fixture', - content: '# Fixture', - }), - })) - - await page.goto(`/app/skills/edit/${encodedSkill}`) - - await expect(page.locator('.page-title')).toContainText(`Edit: ${SKILL_NAME}`) - }) }) diff --git a/core/http/react-ui/e2e/settings-chats-real.spec.js b/core/http/react-ui/e2e/settings-chats-real.spec.js new file mode 100644 index 000000000..218f926bb --- /dev/null +++ b/core/http/react-ui/e2e/settings-chats-real.spec.js @@ -0,0 +1,71 @@ +import { test, expect } from './coverage-fixtures.js' + +// The runtime settings the chats added, against the real test server: they are +// listed where an administrator looks for them, an edit waits for Apply, and +// what the page applies is what the server stores (and what the rest of the UI +// then follows). The spec puts every value back. + +const row = (page, key) => page.locator(`[data-field="${key}"]`) +const sw = (page, key) => row(page, key).getByRole('switch') +const stored = async (page) => (await page.request.get('/api/settings')).json() + +test.describe('Chat settings against the real server', () => { + test('the chats settings are listed with their keys', async ({ page }) => { + await page.goto('/app/settings') + await page.getByRole('button', { name: /Chats and collections/ }).click() + for (const key of ['chats_vision_model', 'chats_transcription_model', 'localai_assistant_enabled']) { + await expect(row(page, key)).toBeVisible() + } + await page.getByRole('button', { name: /Access and security/ }).click() + await expect(row(page, 'mcp_connectors_allow_private')).toBeVisible() + }) + + test('switching the assistant off waits for Apply, is stored, and Home follows', async ({ page }) => { + const before = (await stored(page)).localai_assistant_enabled + try { + await page.goto('/app/settings') + await page.getByRole('button', { name: /Chats and collections/ }).click() + const toggle = sw(page, 'localai_assistant_enabled') + const wasOn = (await toggle.getAttribute('aria-checked')) === 'true' + await toggle.click() + await expect(page.getByTestId('settings-pending')).toContainText('1 pending') + // Nothing is stored until Apply. + expect((await stored(page)).localai_assistant_enabled).toBe(before) + await page.getByRole('button', { name: 'Apply' }).click() + await expect(page.getByTestId('settings-undo-toast')).toBeVisible() + await expect.poll(async () => (await stored(page)).localai_assistant_enabled).toBe(!wasOn) + const features = await (await page.request.get('/api/features')).json() + expect(features.localai_assistant).toBe(!wasOn) + } finally { + await page.request.post('/api/settings', { data: { localai_assistant_enabled: before } }) + } + }) + + test('the private address switch for MCP connectors is stored on Apply and Discard drops an edit', async ({ page }) => { + const before = (await stored(page)).mcp_connectors_allow_private + try { + await page.goto('/app/settings') + await page.getByRole('button', { name: /Access and security/ }).click() + const toggle = sw(page, 'mcp_connectors_allow_private') + const wasOn = (await toggle.getAttribute('aria-checked')) === 'true' + await toggle.click() + await page.getByRole('button', { name: 'Discard' }).click() + await expect(toggle).toHaveAttribute('aria-checked', String(wasOn)) + await toggle.click() + await page.getByRole('button', { name: 'Apply' }).click() + await expect.poll(async () => (await stored(page)).mcp_connectors_allow_private).toBe(!wasOn) + } finally { + await page.request.post('/api/settings', { data: { mcp_connectors_allow_private: Boolean(before) } }) + } + }) + + test('the group captions of the chats settings do not name the removed agent pool', async ({ page }) => { + await page.goto('/app/settings') + await page.getByRole('button', { name: /Chats and collections/ }).click() + await expect(page.locator('main')).not.toContainText(/AGENT POOL|AGENT JOBS/i) + // Each caption heads one run of fields: the collections, then the chats. + const heads = await page.locator('main .st-h3').allTextContents() + expect(heads.slice(0, 2)).toEqual(['Collections', 'Chats']) + expect(new Set(heads).size).toBe(heads.length) + }) +}) diff --git a/core/http/react-ui/e2e/settings-fixtures.js b/core/http/react-ui/e2e/settings-fixtures.js index b66f09d9a..f4681e096 100644 --- a/core/http/react-ui/e2e/settings-fixtures.js +++ b/core/http/react-ui/e2e/settings-fixtures.js @@ -19,12 +19,12 @@ export function settingsFixture(overrides = {}) { galleries: [{ url: 'https://index.localai.io/models', name: 'localai' }], backend_galleries: [{ url: 'https://index.localai.io/backends', name: 'localai' }], autoload_galleries: true, autoload_backend_galleries: true, vram_persistent_cache: true, - api_keys: ['sk-shared-1'], agent_job_retention_days: 30, - open_responses_store_ttl: '0', agent_pool_enabled: true, agent_pool_default_model: 'qwen3-8b-instruct', - agent_pool_embedding_model: 'bge-m3', agent_pool_max_chunking_size: 400, agent_pool_chunk_overlap: 0, - agent_pool_enable_logs: false, agent_pool_collection_db_path: '', agent_pool_vector_engine: 'chromem', - agent_pool_database_url: '', agent_pool_agent_hub_url: '', - distributed_disk_headroom_check: true, localai_assistant_enabled: true, + api_keys: ['sk-shared-1'], + open_responses_store_ttl: '0', collections_enabled: true, collections_default_model: 'qwen3-8b-instruct', + collections_embedding_model: 'bge-m3', collections_max_chunking_size: 400, collections_chunk_overlap: 0, + collections_db_path: '', collections_vector_engine: 'chromem', + collections_database_url: '', chats_vision_model: '', chats_transcription_model: '', + distributed_disk_headroom_check: true, localai_assistant_enabled: true, shared_pages_enabled: true, mcp_connectors_allow_private: false, instance_name: 'Lab AI', instance_tagline: '', ...overrides, } diff --git a/core/http/react-ui/e2e/settings-page.spec.js b/core/http/react-ui/e2e/settings-page.spec.js index 61120179a..f250c537e 100644 --- a/core/http/react-ui/e2e/settings-page.spec.js +++ b/core/http/react-ui/e2e/settings-page.spec.js @@ -1,6 +1,7 @@ import { test, expect } from './coverage-fixtures.js' import { mockSettings } from './settings-fixtures.js' import { FIELDS } from '../src/utils/settingsSchema.js' +import { ownedByControl } from '../src/addons/hostControls.js' const row = (page, key) => page.locator(`[data-field="${key}"]`) const group = (page, name) => page.getByRole('button', { name: new RegExp(`^${name}`) }) @@ -14,11 +15,11 @@ test.describe('Settings: groups and search', () => { await expect(page.getByTestId('settings-page')).toBeVisible() }) - test('shows eight groups by intent and opens on memory and models', async ({ page }) => { + test('shows nine groups by intent and opens on memory and models', async ({ page }) => { const names = await page.locator('.st-group .st-group__label').allTextContents() expect(names).toEqual([ 'Memory and models', 'Speed and defaults', 'Backends and galleries', 'Access and security', - 'Debugging and traces', 'Agents and responses', 'Swarm and sharing', 'Look and feel', + 'Debugging and traces', 'Chats and collections', 'Tools', 'Swarm and sharing', 'Look and feel', ]) await expect(page.locator('.st-group[aria-current="true"]')).toHaveText(/Memory and models/) await expect(page.getByRole('heading', { level: 2, name: /Memory and models/ })).toBeVisible() @@ -38,11 +39,15 @@ test.describe('Settings: groups and search', () => { // The schema is the list of settings the page owns (the three branding // images included). Compare by name so a failure says which one is // missing from every group or shown twice, not just a count. - const expected = FIELDS.map(f => f.key) + // A field a tab draws with its own controls (Settings > Tools) is found by + // search, not as a row of its group. A field a host control draws is on + // the page of the add-on that has it, not here. + const page_ = FIELDS.filter(f => !ownedByControl(f.key)) + const expected = page_.filter(f => !f.custom).map(f => f.key) expect([...seen].filter(k => !expected.includes(k)), 'shown but not in the schema').toEqual([]) expect(expected.filter(k => !seen.has(k)), 'in the schema but in no group').toEqual([]) expect(seen.size).toBe(expected.length) - await expect(page.getByLabel(new RegExp(`Search all ${expected.length} settings`))).toBeVisible() + await expect(page.getByLabel(new RegExp(`Search all ${page_.length} settings`))).toBeVisible() }) test('a group lists its settings, and the memory group shows memory use now', async ({ page }) => { @@ -120,20 +125,20 @@ test.describe('Settings: defaults and hints', () => { }) test('counts the settings that differ from their default', async ({ page }) => { - const count = Number(await page.getByTestId('settings-changed-count').locator('.dk-mono').textContent()) + const count = Number(await page.getByTestId('settings-changed-count').locator('.st-bar__n').textContent()) expect(count).toBeGreaterThan(0) await row(page, 'lru_eviction_max_retries').getByRole('button', { name: 'Reset' }).click() await expect(row(page, 'lru_eviction_max_retries').getByRole('spinbutton')).toHaveValue('30') - await expect(page.getByTestId('settings-changed-count').locator('.dk-mono')).toHaveText(String(count - 1)) + await expect(page.getByTestId('settings-changed-count').locator('.st-bar__n')).toHaveText(String(count - 1)) await expect(page.getByTestId('settings-pending')).toContainText('1 pending') }) test('says when a setting applies only where the server says so', async ({ page }) => { await expect(row(page, 'watchdog_idle_timeout')).toContainText('Applies now') await expect(row(page, 'max_active_backends')).toContainText('Applies now') - await group(page, 'Agents and responses').click() - await expect(row(page, 'agent_pool_enable_logs')).toContainText('Needs restart') - await expect(row(page, 'agent_job_retention_days')).toContainText('Applies now') + await group(page, 'Chats and collections').click() + await expect(row(page, 'collections_enabled')).toContainText('Needs restart') + await expect(row(page, 'chats_vision_model')).toContainText('Applies now') await group(page, 'Speed and defaults').click() await expect(row(page, 'threads')).not.toContainText('Applies now') await expect(row(page, 'threads')).not.toContainText('Needs restart') @@ -197,12 +202,12 @@ test.describe('Settings: pending, diff, apply, discard and undo', () => { }) test('a restart-only change is counted and warned about', async ({ page }) => { - await group(page, 'Agents and responses').click() - await sw(page, 'agent_pool_enable_logs').click() + await group(page, 'Chats and collections').click() + await sw(page, 'collections_enabled').click() await expect(page.getByTestId('settings-pending')).toContainText('1 pending') await expect(page.getByTestId('settings-pending')).toContainText('1 needs a restart') await page.getByRole('button', { name: 'Show diff' }).click() - await expect(page.getByLabel('Checks')).toContainText('Restart LocalAI for Agent logs') + await expect(page.getByLabel('Checks')).toContainText('Restart LocalAI for Collections') }) test('apply sends only the changed keys, then offers undo', async ({ page }) => { diff --git a/core/http/react-ui/e2e/settings-tools.spec.js b/core/http/react-ui/e2e/settings-tools.spec.js new file mode 100644 index 000000000..02834e402 --- /dev/null +++ b/core/http/react-ui/e2e/settings-tools.spec.js @@ -0,0 +1,196 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockSettings } from './settings-fixtures.js' +import { ADMIN, installedAddon, release, sessionFor, mockAddons } from './addons-fixtures.js' + +// Settings > Tools lists the built-in chat tools an add-on may replace, and the +// install dialog warns when an add-on replaces a tool another one already +// replaces. The API is stubbed. + +const SHOTS = process.env.HOST_EXEC_SHOT_DIR +const shot = async (page, name) => { if (SHOTS) { await page.waitForTimeout(300); await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: true }) } } + +const bash = (over = {}) => ({ + id: 'bash', label: 'Commands (bash)', description: 'x', setting: 'chats_host_exec', enabled: true, + provider: 'builtin', candidates: [], mode: 'ask', ...over, +}) + +async function openTools(page, tools) { + const state = await mockSettings(page, { chats_host_exec: '' }) + await page.route('**/api/chats/admin/tools', route => route.fulfill({ json: { tools } })) + await page.goto('/app/settings') + await page.getByRole('button', { name: /^Tools/ }).click() + await expect(page.getByTestId('settings-tools')).toBeVisible() + return state +} + +test.describe('Settings > Tools', () => { + test('bash says who serves it and what it can reach, and its mode is a draft applied with the others', async ({ page }) => { + const state = await openTools(page, [bash()]) + const row = page.getByTestId('settings-tools-row-bash') + await expect(row).toContainText('Commands (bash)') + await expect(page.getByTestId('settings-tools-bash-provider')).toHaveText('Built-in') + await expect(page.getByTestId('settings-tools-bash-warning')).toContainText('reach the internet, your network and the LocalAI API') + await expect(row).toContainText('Not set: the default applies (ask each time).') + await expect(page.getByTestId('settings-tools-install-sandbox')).toHaveAttribute('href', '/app/addons?tab=browse&q=sandbox') + await shot(page, 'settings-tools') + await row.getByRole('radio', { name: /Allow for everyone/ }).check() + await page.getByRole('button', { name: 'Apply' }).click() + await expect.poll(() => state.posts).toEqual([{ chats_host_exec: 'on' }]) + }) + + test('the web_search row tests each search provider and says what each did', async ({ page }) => { + const webSearch = { + id: 'web_search', label: 'Web search', description: 'x', setting: 'chats_web_search', enabled: true, + provider: 'builtin', candidates: [], options: ['chats_web_searxng_url', 'chats_web_search_providers'], + } + await openTools(page, [webSearch]) + const queries = [] + let answer = { + ok: true, provider: 'brave_html', host: 'search.brave.com', results: [{ title: 'LocalAI', url: 'https://localai.io/', snippet: '' }], + providers: [ + { name: 'duckduckgo', host: 'duckduckgo.com', outcome: 'challenged', results: 0, reason: 'challenged by a bot check' }, + { name: 'brave_html', host: 'search.brave.com', outcome: 'ok', results: 20 }, + { name: 'bing_html', host: 'www.bing.com', outcome: 'blocked', results: 0, reason: 'blocked this server (429 Too Many Requests)' }, + ], + } + await page.route('**/api/chats/admin/web/test', route => { queries.push(route.request().postDataJSON()); return route.fulfill({ json: answer }) }) + await page.getByTestId('settings-tools-web-test-query').fill('ultime news oggi') + await page.getByTestId('settings-tools-web-test-run').click() + await expect(page.getByTestId('settings-tools-web-test-summary')).toHaveText('Chats get the results of search.brave.com.') + await expect(page.getByTestId('settings-tools-web-test-duckduckgo')).toContainText('Bot check') + await expect(page.getByTestId('settings-tools-web-test-duckduckgo')).toContainText('challenged by a bot check') + await expect(page.getByTestId('settings-tools-web-test-brave_html')).toContainText('20 results') + await expect(page.getByTestId('settings-tools-web-test-bing_html')).toHaveAttribute('data-outcome', 'blocked') + expect(queries).toEqual([{ query: 'ultime news oggi' }]) + await shot(page, 'settings-tools-web-test') + + answer = { ok: false, provider: '', host: '', results: [], error: 'no search provider works. Tried duckduckgo: challenged by a bot check.', providers: [answer.providers[0]] } + await page.getByTestId('settings-tools-web-test-run').click() + await expect(page.getByTestId('settings-tools-web-test-summary')).toContainText('No provider answered. no search provider works. Tried duckduckgo') + }) + + test('switching bash off saves off', async ({ page }) => { + const state = await openTools(page, [bash()]) + await page.getByTestId('settings-tools-bash-switch').click() + await expect(page.getByTestId('settings-tools-bash-mode')).toHaveCount(0) + await page.getByRole('button', { name: 'Apply' }).click() + await expect.poll(() => state.posts).toEqual([{ chats_host_exec: 'off' }]) + }) + + test('with two add-ons that replace bash and no choice, it asks to choose one and saves the pin', async ({ page }) => { + const state = await openTools(page, [bash({ provider: 'other', candidates: ['other', 'sandbox'], default: 'other', installed_at: { other: '2026-09-01T10:00:00Z', sandbox: '2026-10-01T10:00:00Z' } })]) + await expect(page.getByTestId('settings-tools-bash-unpinned')).toHaveText('2 add-ons replace bash: choose which one to use.') + await expect(page.getByTestId('settings-tools-bash-provider')).toHaveText('Add-on: other') + await expect(page.getByTestId('settings-tools-bash-other-default')).toHaveText('Default: installed first') + await expect(page.getByTestId('settings-tools-bash-sandbox-default')).toHaveCount(0) + await expect(page.getByTestId('settings-tools-bash-sandbox-installed')).toContainText('Installed') + await page.getByTestId('settings-tools-bash-providers').getByRole('radio', { name: 'sandbox' }).check() + await page.getByRole('button', { name: 'Apply' }).click() + await expect.poll(() => state.posts).toEqual([{ chats_tool_providers: { bash: 'sandbox' } }]) + }) + + test('a chosen add-on that is stopped is marked, and the row says the built-in serves', async ({ page }) => { + await openTools(page, [bash({ + provider: 'builtin', candidates: ['sandbox', 'other'], default: 'other', pinned: 'sandbox', stopped: ['sandbox'], + titles: { sandbox: 'Sandbox', other: 'Other runner' }, note: 'not running', + })]) + await expect(page.getByTestId('settings-tools-bash-provider')).toHaveText('Built-in') + await expect(page.getByTestId('settings-tools-bash-note')).toHaveText('The add-on that replaces this tool is not running: the built-in tool serves.') + await expect(page.getByTestId('settings-tools-bash-sandbox-stopped')).toHaveText('(stopped)') + await expect(page.getByTestId('settings-tools-bash-other-stopped')).toHaveCount(0) + await expect(page.getByTestId('settings-tools-bash-providers').getByRole('radio', { name: /Other runner/ })).toBeVisible() + }) +}) + +test.describe('Install of an add-on that replaces a built-in tool', () => { + test('warns when another installed add-on already replaces it, and still installs', async ({ page }) => { + await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN) })) + await mockAddons(page, { + installed: [installedAddon({ id: 'sandbox', title: 'Sandbox', shadows: ['bash'] })], + releases: [release('runner', { name: 'Runner', toolCount: 1, tools: ['bash'], shadows: ['bash'] })], + }) + await page.goto('/app/addons?tab=browse') + await page.getByRole('button', { name: 'Install Runner' }).click() + const warning = page.getByTestId('install-shadow-warning') + await expect(warning).toHaveText('This add-on replaces the built-in bash. Another add-on (Sandbox) already does. Only one can be active; you choose which in Settings > Tools. Until you choose, the one installed first serves.') + await expect(page.getByTestId('install-dialog').getByRole('button', { name: 'Install and turn on' })).toBeEnabled() + await shot(page, 'install-shadow-warning') + }) +}) + +test.describe('Settings > Tools: skills that add-ons replace', () => { + async function openWithSkills(page, skills) { + const state = await mockSettings(page, { chats_host_exec: '' }) + await page.route('**/api/chats/admin/tools', route => route.fulfill({ json: { tools: [bash()], skills } })) + await page.goto('/app/settings') + await page.getByRole('button', { name: /^Tools/ }).click() + await expect(page.getByTestId('settings-tools')).toBeVisible() + return state + } + + test('lists who serves a replaced skill and saves the choice in the same pin map as the tools', async ({ page }) => { + const state = await openWithSkills(page, [ + { id: 'xlsx', provider: 'office', candidates: ['office', 'sheets'], default: 'office', titles: { office: 'Office', sheets: 'Sheets' } }, + { id: 'docx', provider: 'builtin', candidates: ['office'], titles: { office: 'Office' }, pinned: 'office', note: 'not running' }, + ]) + await expect(page.getByTestId('settings-tools-skills')).toContainText('Skills that add-ons replace') + await expect(page.getByTestId('settings-tools-skill-xlsx-provider')).toHaveText('Add-on: Office') + await expect(page.getByTestId('settings-tools-xlsx-unpinned')).toHaveText('2 add-ons replace xlsx: choose which one to use.') + await expect(page.getByTestId('settings-tools-xlsx-office-default')).toBeVisible() + await expect(page.getByTestId('settings-tools-skill-docx-provider')).toHaveText('Built-in') + await expect(page.getByTestId('settings-tools-skill-docx-note')).toHaveText('The chosen add-on is not running: the built-in skill serves.') + await expect(page.getByTestId('settings-tools-docx-providers')).toHaveCount(0) + await shot(page, 'settings-tools-skills') + await page.getByTestId('settings-tools-xlsx-providers').getByRole('radio', { name: 'Sheets' }).check() + await page.getByRole('button', { name: 'Apply' }).click() + await expect.poll(() => state.posts).toEqual([{ chats_tool_providers: { xlsx: 'sheets' } }]) + }) +}) + +test.describe('Install of an add-on that replaces a built-in skill', () => { + test('says which built-in skills it replaces, warns when another add-on already does, and still installs', async ({ page }) => { + await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN) })) + await mockAddons(page, { + installed: [installedAddon({ id: 'sheets', title: 'Sheets', skillShadows: ['xlsx'] })], + releases: [release('office', { name: 'Office', skillShadows: ['xlsx', 'docx'] })], + }) + await page.goto('/app/addons?tab=browse') + await page.getByRole('button', { name: 'Install Office' }).click() + const warnings = page.getByTestId('install-skill-warning') + await expect(warnings).toHaveCount(2) + await expect(warnings.nth(0)).toHaveText('This add-on replaces the built-in skill xlsx. Another add-on (Sheets) already does. Only one can be active; you choose which in Settings > Tools. Until you choose, the one installed first serves.') + await expect(warnings.nth(1)).toHaveText('This add-on replaces the built-in skill docx.') + await expect(page.getByTestId('install-dialog').getByRole('button', { name: 'Install and turn on' })).toBeEnabled() + await shot(page, 'install-skill-warning') + }) +}) + +test.describe('The skills of an add-on in its details', () => { + test('lists its skills, and lets an administrator choose it for a skill another add-on also replaces', async ({ page }) => { + await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN) })) + const office = { id: 'office', name: 'office', title: 'Office', skills: [{ name: 'xlsx', shadows: 'xlsx' }, { name: 'docx', shadows: 'docx' }, { name: 'office-tips' }] } + await mockAddons(page, { + apps: [{ ...installedAddon({ id: 'office', title: 'Office' }), ...office, admin: { canStart: false, canStop: true, managementUrl: '/app/addons?manage=office' } }], + installed: [installedAddon({ id: 'office', title: 'Office', skillShadows: ['xlsx', 'docx'] })], + }) + const settings = await mockSettings(page, { chats_tool_providers: { bash: 'sandbox' } }) + let rows = [ + { id: 'xlsx', provider: 'sheets', candidates: ['office', 'sheets'], titles: { office: 'Office', sheets: 'Sheets' } }, + { id: 'docx', provider: 'office', candidates: ['office'], titles: { office: 'Office' } }, + ] + await page.route('**/api/chats/admin/tools', route => route.fulfill({ json: { tools: [], skills: rows } })) + await page.goto('/app/addons?detail=office') + const skills = page.getByTestId('app-skills') + await expect(skills).toContainText('3 chat skills') + await expect(page.getByTestId('app-skill-office-tips')).not.toContainText('replaces') + await expect(page.getByTestId('app-skill-docx')).toContainText('replaces the built-in skill · serves in the chats') + await expect(page.getByTestId('app-skill-docx-switch')).toHaveCount(0) + const sw = page.getByTestId('app-skill-xlsx-switch') + await expect(sw).not.toBeChecked() + await shot(page, 'addon-skills') + rows = [{ ...rows[0], provider: 'office', pinned: 'office' }, rows[1]] + await sw.click() + await expect.poll(() => settings.posts).toEqual([{ chats_tool_providers: { bash: 'sandbox', xlsx: 'office' } }]) + await expect(sw).toBeChecked() + }) +}) diff --git a/core/http/react-ui/e2e/shared-folders-real.spec.js b/core/http/react-ui/e2e/shared-folders-real.spec.js new file mode 100644 index 000000000..d018a2879 --- /dev/null +++ b/core/http/react-ui/e2e/shared-folders-real.spec.js @@ -0,0 +1,177 @@ +import { test, expect } from './coverage-fixtures.js' + +// The Shared tab of Files against the real test server started with +// --shared-pages-fixture. Auth is off, so the person in the browser is the +// administrator and also the viewer of the fixture's folders: /shared/team +// (read, pages standup and releases) and /shared/board (write, pages board and digest). +// The spec takes the viewer's grant on team away and gives it back through the +// UI, and checks that the pages follow: gone, then back with the trust sheet. +// A new folder is created, shared with everyone, renamed, removed and brought +// back with Undo. The specs for several users are in the Go route tests. + +test.describe.configure({ mode: 'serial', timeout: 90_000 }) + +const VIEWER = 'anonymous' +const SHOTS = process.env.SHARED_FOLDERS_SHOTS || 'test-results/shared-folders' +const settle = (page) => page.evaluate(() => Promise.all(document.getAnimations().filter(a => a.effect?.getTiming().iterations !== Infinity).map(a => a.finished.catch(() => {})))) +const shot = async (page, name) => { await settle(page); await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage: false }) } +const api = (request, path) => request.get(path).then(r => r.json()) +const row = (page, name) => page.locator(`[data-testid="sf-row"][data-folder="${name}"]`) +const openShared = (page) => page.goto('/app/files?tab=shared') + +test.beforeAll(async ({ request }) => { + // A run before this one may have left a folder or a missing grant behind. + const { folders } = await api(request, '/api/chats/admin/shared-folders') + for (const f of folders) { + if (f.name === 'team') { + await request.put(`/api/chats/admin/shared-folders/${f.id}/members/${VIEWER}`, { data: { mode: 'read' } }) + await request.delete(`/api/chats/admin/shared-folders/${f.id}/members/%40everyone`) + } + if (f.name !== 'team' && f.name !== 'board') await request.delete(`/api/chats/admin/shared-folders/${f.id}`) + } + for (const p of (await api(request, '/api/chats/shared-pages')).pages.filter(x => x.name === 'standup')) { + await request.delete(`/api/chats/shared-pages/${p.folder_id}/${p.name}/consent`) + } +}) + +test('the admin sees every shared folder with its pages and members', async ({ page }) => { + await openShared(page) + const admin = page.getByTestId('sf-admin') + await expect(admin.getByRole('heading', { name: /Shared folders/ })).toBeVisible() + await expect(row(page, 'team')).toContainText('shared/team') + await expect(row(page, 'team').getByTestId('sf-pages')).toHaveText('2 pages') + await expect(row(page, 'team').getByTestId('sf-summary')).toHaveText('2 people') + await expect(row(page, 'board').getByTestId('sf-pages')).toHaveText('2 pages') + await expect(page.getByRole('tab', { name: 'Shared' })).toHaveAttribute('aria-selected', 'true') +}) + +test('taking the viewer off a folder hides its pages, and giving read back brings the trust sheet', async ({ page }) => { + await openShared(page) + await row(page, 'team').getByTestId('sf-members').click() + const pane = page.getByTestId('sf-pane') + await expect(pane).toBeVisible() + const me = pane.locator(`[data-testid="sf-member"][data-principal="${VIEWER}"]`) + await expect(me.getByTestId('sf-member-mode')).toHaveValue('read') + await me.getByTestId('sf-member-remove').click() + await expect(me).toHaveCount(0) + await expect(page.getByText(`${VIEWER} can no longer use “team”.`)).toBeVisible() + + await page.goto('/app/pages') + await expect(page.locator('[data-testid="shared-card"][data-page="standup"]')).toHaveCount(0) + await expect(page.locator('[data-testid="shared-card"][data-page="board"]')).toHaveCount(1) + + // Without a user database the picker offers Everyone: the viewer gets the + // folder back through the everyone group. + await openShared(page) + await row(page, 'team').getByTestId('sf-members').click() + await pane.getByTestId('sf-add-who').selectOption('@everyone') + await pane.getByTestId('sf-add-mode').selectOption('read') + await pane.getByTestId('sf-add-submit').click() + await expect(pane.locator('[data-testid="sf-member"][data-principal="@everyone"]')).toBeVisible() + await expect(row(page, 'team').getByTestId('sf-summary')).toHaveText('Everyone can read · 1 person') + + await page.goto('/app/pages') + const standup = page.locator('[data-testid="shared-card"][data-page="standup"]') + await expect(standup).toBeVisible() + await standup.getByTestId('shared-open').click() + const sheet = page.getByTestId('trust-sheet') + await expect(sheet).toBeVisible() + await expect(sheet).toContainText('You can only read this shared folder') + await page.getByTestId('trust-allow').click() + await expect(page.getByTestId('page-frame')).toBeVisible() + await expect(page.getByTestId('shared-pill')).toHaveText(/Allowed/) +}) + +test('a new folder: create, share with everyone, rename, remove and undo', async ({ page }) => { + await openShared(page) + await page.getByTestId('sf-new').click() + const form = page.getByTestId('sf-create') + await form.getByRole('textbox').fill('bad/name') + await expect(form.getByRole('button', { name: 'Create' })).toBeDisabled() + await form.getByRole('textbox').fill('Design reviews') + await form.getByRole('button', { name: 'Create' }).click() + + const pane = page.getByTestId('sf-pane') + await expect(pane).toBeVisible() + await expect(pane).toContainText('shared/Design reviews') + await expect(pane.getByTestId('sf-member')).toHaveCount(1) + await pane.getByTestId('sf-add-who').selectOption('@everyone') + await pane.getByTestId('sf-add-mode').selectOption('write') + await pane.getByTestId('sf-add-submit').click() + await expect(pane.locator('[data-testid="sf-member"][data-principal="@everyone"]')).toContainText('Everyone') + await expect(row(page, 'Design reviews').getByTestId('sf-summary')).toHaveText('Everyone can edit · 1 person') + await page.keyboard.press('Escape') + await expect(pane).toHaveCount(0) + + await row(page, 'Design reviews').getByRole('button', { name: 'More actions for Design reviews' }).click() + await page.getByRole('menuitem', { name: 'Rename' }).click() + const rename = row(page, 'Design reviews').getByRole('textbox') + await rename.fill('Reviews') + await rename.press('Enter') + await expect(row(page, 'Reviews')).toBeVisible() + + await row(page, 'Reviews').getByRole('button', { name: 'More actions for Reviews' }).click() + await page.getByRole('menuitem', { name: 'Remove' }).click() + await expect(row(page, 'Reviews')).toHaveCount(0) + const toast = page.getByTestId('sf-removed-toast') + await expect(toast).toContainText('Removed “Reviews”.') + await toast.getByRole('button', { name: 'Undo' }).click() + await expect(row(page, 'Reviews')).toBeVisible() + await expect(row(page, 'Reviews').getByTestId('sf-summary')).toHaveText('Everyone can edit · 1 person') + + const { events } = await api(page.request, '/api/chats/admin/shared-folders/events') + expect(events.slice(0, 3).map(e => e.action)).toEqual(['restore', 'delete', 'rename']) +}) + +// A member who is not an administrator: the same tab lists the folders shared +// with them and their mode. The auth status is answered by the browser, so the +// UI takes the person for a user; the list is the server's. +const asMember = (page) => page.route('**/api/auth/status', route => route.fulfill({ + json: { authEnabled: true, user: { id: VIEWER, name: 'Marta', role: 'user', permissions: { chats: true, pages: true } } }, +})) + +test('a member sees the folders shared with them and what they may do', async ({ page }) => { + await asMember(page) + await openShared(page) + const mine = page.getByTestId('sf-mine') + await expect(mine.getByRole('heading', { name: /Shared with you/ })).toBeVisible() + await expect(row(page, 'team').getByTestId('sf-mode')).toHaveText('Can read') + await expect(row(page, 'board').getByTestId('sf-mode')).toHaveText('Can edit') + await expect(row(page, 'team').getByTestId('sf-pages')).toHaveAttribute('href', /\/app\/pages#pg-shared$/) + await expect(page.getByTestId('sf-members')).toHaveCount(0) +}) + +for (const v of [{ name: 'desktop', width: 1440, height: 900 }, { name: 'phone', width: 390, height: 844 }]) { + for (const theme of ['dark', 'light']) { + test(`screenshots, ${v.name} ${theme}`, async ({ page }) => { + await page.setViewportSize({ width: v.width, height: v.height }) + await page.addInitScript((m) => localStorage.setItem('localai-theme', m), theme) + await openShared(page) + await expect(row(page, 'team')).toBeVisible() + await shot(page, `sf-admin-list-${v.name}-${theme}`) + await row(page, 'team').getByTestId('sf-members').click() + // The pane slides in: draw it once it rests. + await expect(page.getByTestId('sf-pane')).toHaveAttribute('data-state', 'open') + await shot(page, `sf-admin-members-${v.name}-${theme}`) + + const member = await page.context().newPage() + await member.setViewportSize({ width: v.width, height: v.height }) + await member.addInitScript((m) => localStorage.setItem('localai-theme', m), theme) + await asMember(member) + await openShared(member) + await expect(row(member, 'team')).toBeVisible() + await shot(member, `sf-member-list-${v.name}-${theme}`) + await member.close() + }) + } +} + +test('screenshot of the empty state', async ({ page }) => { + await page.route('**/api/chats/admin/shared-folders', route => (route.request().method() === 'GET' + ? route.fulfill({ json: { folders: [], max_folders: 100, max_members: 200 } }) + : route.continue())) + await page.setViewportSize({ width: 1440, height: 900 }) + await openShared(page) + await expect(page.getByTestId('sf-empty')).toContainText('No shared folders yet') + await shot(page, 'sf-admin-empty-desktop-dark') +}) diff --git a/core/http/react-ui/e2e/shared-folders.spec.js b/core/http/react-ui/e2e/shared-folders.spec.js new file mode 100644 index 000000000..ebf78323a --- /dev/null +++ b/core/http/react-ui/e2e/shared-folders.spec.js @@ -0,0 +1,166 @@ +import { test, expect } from './coverage-fixtures.js' + +// The Shared tab of Files with the shared folders API mocked in the browser, so +// it runs in the default suite. The real server is covered by +// shared-folders-real.spec.js and the Go route tests. + +const row = (page, name) => page.locator(`[data-testid="sf-row"][data-folder="${name}"]`) + +// A small in-memory server: folders by id, with members. +function mockFolders(page, initial) { + const state = { folders: structuredClone(initial), next: 100, rev: 1, removed: {}, fail: false } + const json = (route, body, status = 200) => route.fulfill({ status, json: body }) + const find = (id) => state.folders.find(f => f.id === Number(id)) + page.route(/\/api\/chats\/admin\/shared-folders(\/.*)?(\?.*)?$/, async (route) => { + const req = route.request() + const path = new URL(req.url()).pathname.replace('/api/chats/admin/shared-folders', '') + const body = req.postDataJSON?.() || {} + const method = req.method() + if (state.fail && method === 'GET') return json(route, { error: { message: 'database is locked' } }, 500) + if (path === '' && method === 'GET') return json(route, { folders: state.folders, max_folders: 100, max_members: 200 }) + if (path === '' && method === 'POST') { + if (state.folders.some(f => f.name.toLowerCase() === body.name.toLowerCase())) { + return json(route, { error: { message: 'a shared folder with this name exists' } }, 409) + } + const f = { id: state.next++, name: body.name, path: `/shared/${body.name}`, pages: 0, members: [{ principal: 'root', name: 'Root', mode: 'write' }] } + state.folders.push(f) + return json(route, f, 201) + } + if (path === '/restore') { + const f = state.removed[body.rev] + state.folders.push(f) + return json(route, f) + } + const m = path.match(/^\/(\d+)(?:\/members\/(.+))?$/) + const f = m && find(m[1]) + if (!f) return json(route, { error: { message: 'shared folder not found' } }, 404) + if (m[2]) { + const who = decodeURIComponent(m[2]) + f.members = f.members.filter(x => x.principal !== who) + if (method === 'PUT') { + const user = { '@everyone': 'Everyone', 'u-marta': 'Marta', 'u-sara': 'Sara' }[who] || who + f.members.push({ principal: who, name: who === '@everyone' ? '@everyone' : user, everyone: who === '@everyone', mode: body.mode }) + f.members.sort((a, b) => (a.everyone ? -1 : b.everyone ? 1 : a.name.localeCompare(b.name))) + } + return json(route, f) + } + if (method === 'PATCH') { f.name = body.name; f.path = `/shared/${body.name}`; return json(route, f) } + if (method === 'DELETE') { + state.folders = state.folders.filter(x => x !== f) + const rev = ++state.rev + state.removed[rev] = f + return json(route, { folder: f, undo_rev: rev }) + } + return json(route, f) + }) + return state +} + +const TEAM = { id: 7, name: 'Team', path: '/shared/Team', pages: 2, members: [ + { principal: '@everyone', name: '@everyone', everyone: true, mode: 'read' }, + { principal: 'u-marta', name: 'Marta', mode: 'write' }, +] } +const BOARD = { id: 9, name: 'Board', path: '/shared/Board', pages: 0, members: [] } + +// An administrator signed in, so the members pane lists the users. +test.beforeEach(async ({ page }) => { + await page.route('**/api/auth/status', route => route.fulfill({ + json: { authEnabled: true, user: { id: 'root', name: 'Root', role: 'admin', permissions: {} } }, + })) + await page.route('**/api/auth/admin/users', route => route.fulfill({ json: { users: [ + { id: 'u-marta', name: 'Marta', email: 'marta@example.test' }, + { id: 'u-sara', name: 'Sara', email: 'sara@example.test' }, + ] } })) +}) + +test('lists the shared folders with their pages and members', async ({ page }) => { + mockFolders(page, [TEAM, BOARD]) + await page.goto('/app/files?tab=shared') + await expect(row(page, 'Team').getByTestId('sf-summary')).toHaveText('Everyone can read · 1 person') + await expect(row(page, 'Team').getByTestId('sf-pages')).toHaveText('2 pages') + await expect(row(page, 'Board')).toContainText('No pages') + await expect(row(page, 'Board').getByTestId('sf-summary')).toHaveText('No members') +}) + +test('the members pane adds, changes and removes a member, with Undo', async ({ page }) => { + const state = mockFolders(page, [TEAM, BOARD]) + await page.goto('/app/files?tab=shared') + await row(page, 'Board').getByTestId('sf-members').click() + const pane = page.getByTestId('sf-pane') + await expect(pane.getByTestId('sf-no-members')).toBeVisible() + await pane.getByTestId('sf-add-who').selectOption('u-sara') + await pane.getByTestId('sf-add-mode').selectOption('write') + await pane.getByTestId('sf-add-submit').click() + const sara = pane.locator('[data-testid="sf-member"][data-principal="u-sara"]') + await expect(sara.getByTestId('sf-member-mode')).toHaveValue('write') + await expect(page.getByText('Added Sara: Can edit.')).toBeVisible() + + await sara.getByTestId('sf-member-mode').selectOption('read') + await expect.poll(() => state.folders.find(f => f.id === 9).members[0].mode).toBe('read') + + await sara.getByTestId('sf-member-remove').click() + await expect(sara).toHaveCount(0) + await page.getByRole('button', { name: 'Undo' }).click() + await expect(sara).toBeVisible() + await page.keyboard.press('Escape') + await expect(pane).toHaveCount(0) +}) + +test('creates, renames and removes a folder, and Undo brings it back', async ({ page }) => { + mockFolders(page, [TEAM]) + await page.goto('/app/files?tab=shared') + await page.getByTestId('sf-new').click() + const input = page.getByTestId('sf-create').getByRole('textbox') + await input.fill('.hidden') + await expect(page.getByTestId('sf-create').getByRole('button', { name: 'Create' })).toBeDisabled() + await input.fill('team') + await page.getByTestId('sf-create').getByRole('button', { name: 'Create' }).click() + await expect(page.getByText('Not done: a shared folder with this name exists')).toBeVisible() + await input.fill('Reports') + await page.getByTestId('sf-create').getByRole('button', { name: 'Create' }).click() + await expect(page.getByTestId('sf-pane')).toContainText('shared/Reports') + await page.keyboard.press('Escape') + + await row(page, 'Reports').getByRole('button', { name: 'More actions for Reports' }).click() + await page.getByRole('menuitem', { name: 'Rename' }).click() + const rename = row(page, 'Reports').getByRole('textbox') + await rename.fill('Q4 reports') + await rename.press('Enter') + await expect(row(page, 'Q4 reports')).toBeVisible() + + await row(page, 'Q4 reports').getByRole('button', { name: 'More actions for Q4 reports' }).click() + await page.getByRole('menuitem', { name: 'Remove' }).click() + await expect(row(page, 'Q4 reports')).toHaveCount(0) + await page.getByTestId('sf-removed-toast').getByRole('button', { name: 'Undo' }).click() + await expect(row(page, 'Q4 reports')).toBeVisible() + await expect(page.getByText('“Q4 reports” is back, with its members.')).toBeVisible() +}) + +test('the empty state starts a folder, and a failed load says so', async ({ page }) => { + const state = mockFolders(page, []) + state.fail = true + await page.goto('/app/files?tab=shared') + await expect(page.getByTestId('load-error')).toContainText('Could not load the shared folders.') + state.fail = false + await page.getByRole('button', { name: 'Try again' }).click() + await expect(page.getByTestId('sf-empty')).toContainText('No shared folders yet') + await page.getByTestId('sf-new').click() + await expect(page.getByTestId('sf-create')).toBeVisible() + await page.getByTestId('sf-create').getByRole('button', { name: 'Cancel' }).click() + await expect(page.getByTestId('sf-empty')).toBeVisible() +}) + +test('a member sees the folders shared with them, or an empty state', async ({ page }) => { + await page.route('**/api/auth/status', route => route.fulfill({ + json: { authEnabled: true, user: { id: 'u-marta', name: 'Marta', role: 'user', permissions: { chats: true, pages: true } } }, + })) + let folders = [{ id: 7, name: 'Team', path: '/shared/Team', pages: 1, mode: 'write' }] + await page.route('**/api/chats/shared-folders', route => route.fulfill({ json: { folders } })) + await page.goto('/app/files?tab=shared') + await expect(page.getByTestId('sf-mine').getByRole('heading', { name: /Shared with you/ })).toBeVisible() + await expect(row(page, 'Team').getByTestId('sf-mode')).toHaveText('Can edit') + await expect(row(page, 'Team').getByTestId('sf-pages')).toHaveText('1 page') + folders = [] + await page.reload() + await expect(page.getByTestId('sf-empty')).toContainText('Nothing is shared with you yet') +}) diff --git a/core/http/react-ui/e2e/shared-pages-real.spec.js b/core/http/react-ui/e2e/shared-pages-real.spec.js new file mode 100644 index 000000000..64ed7e994 --- /dev/null +++ b/core/http/react-ui/e2e/shared-pages-real.spec.js @@ -0,0 +1,194 @@ +import { test, expect } from './coverage-fixtures.js' + +// Pages of shared folders against the real test server started with +// --shared-pages-fixture: /shared/team (read-only for the viewer, written by +// "luca") has the pages standup and releases (allowed in an earlier version), +// /shared/board (writable) has the pages board and digest (split into +// files). Nothing of a shared page runs before the trust sheet; the bridge +// works only with the consent, with the viewer's own grant; a revoke and the +// administrator's switch stop it. + +test.describe.configure({ mode: 'serial', timeout: 90_000 }) + +const api = (request, path) => request.get(path).then(r => r.json()) +// Screenshots wait for the entry animations, so a sheet is drawn as it stays. +const settle = (page) => page.evaluate(() => Promise.all(document.getAnimations().filter(a => a.effect?.getTiming().iterations !== Infinity).map(a => a.finished.catch(() => {})))) +const shot = async (page, name) => { await settle(page); await page.screenshot({ path: `test-results/ws-share/${name}.png` }) } +const frame = (page) => page.frameLocator('[data-testid="page-frame"]') +let pages = [] +const pathOf = (name) => { + const p = pages.find(x => x.name === name) + return `/app/pages/shared/${p.folder_id}/${p.name}` +} + +test.beforeAll(async ({ request }) => { + pages = (await api(request, '/api/chats/shared-pages')).pages + expect(pages.map(p => p.name).sort()).toEqual(['board', 'digest', 'releases', 'standup']) + // A run before this one may have allowed them; releases keeps the consent + // the fixture gave it. + for (const p of pages.filter(x => x.name !== 'releases')) await request.delete(`/api/chats/shared-pages/${p.folder_id}/${p.name}/consent`) +}) + +test('the Pages screen lists the shared pages with their author, folder and state', async ({ page }) => { + await page.goto('/app/pages') + const group = page.getByTestId('shared-group') + await expect(group).toBeVisible() + await expect(group.getByRole('heading', { name: 'Shared with you' })).toBeVisible() + const standup = group.locator('[data-testid="shared-card"][data-page="standup"]') + await expect(standup.getByTestId('shared-badge')).toHaveText('Not allowed yet') + await expect(standup).toContainText('From luca') + await expect(standup).toContainText('shared/team/pages/standup') + await expect(standup.getByTestId('shared-open')).toHaveText('Review') + await expect(group.locator('[data-testid="shared-card"][data-page="releases"]').getByTestId('shared-badge')).toHaveText('Changed since you allowed it') + await expect(group).toContainText('A shared page runs only after you allow it.') + await expect(page.getByRole('link', { name: /^Pages/ }).first()).toBeVisible() + await shot(page, 's4-pages-list-desktop-dark') +}) + +test('the first open shows the trust sheet with Cancel focused, and nothing runs', async ({ page }) => { + await page.goto(pathOf('standup')) + const sheet = page.getByTestId('trust-sheet') + await expect(sheet).toBeVisible() + await expect(sheet).toHaveAttribute('data-variant', 'first') + await expect(sheet.getByRole('heading', { name: 'Open "Team standup"?' })).toBeVisible() + await expect(sheet).toContainText('Written by luca') + await expect(sheet).toContainText('shared/team') + await expect(sheet).toContainText('Read and write files in its own folder') + await expect(sheet).toContainText('You can only read this shared folder') + await expect(sheet).toContainText('Reach the network or other sites') + await expect(sheet).toContainText('it can load another address in its own frame. That sends one request out, and then the page stops.') + await expect(sheet).toContainText('It acts as you inside its folder. If luca changes it, you are asked again.') + await expect(sheet.getByTestId('trust-cancel')).toBeFocused() + await expect(page.getByTestId('page-frame')).toHaveCount(0) + await expect(page.getByTestId('shared-pill')).toHaveText(/Not allowed yet/) + await shot(page, 's4-trust-first-desktop-dark') + + // View source opens beside the sheet, as text. + await sheet.getByTestId('trust-source').click() + const src = page.getByTestId('trust-source-sheet') + await expect(src).toContainText("localai.files.read('data.json')") + await page.keyboard.press('Escape') + await expect(src).toHaveCount(0) + await expect(sheet).toBeVisible() + + // Escape is Cancel: the frame says the page has not run. + await page.keyboard.press('Escape') + await expect(sheet).toHaveCount(0) + await expect(page.getByTestId('shared-blocked')).toContainText('This page has not run') + await expect(page.getByTestId('shared-blocked')).toContainText("It comes from luca's shared folder shared/team.") + await expect(page.getByTestId('shared-pill')).toHaveText(/Not allowed/) + await expect(page.getByTestId('page-frame')).toHaveCount(0) + await shot(page, 's4-blocked-desktop-dark') +}) + +test('allow once runs the page for this visit only; a read-only folder refuses its write', async ({ page }) => { + await page.goto(pathOf('standup')) + await page.getByTestId('trust-allow-once').click() + await expect(page.getByTestId('shared-pill')).toHaveText(/Allowed for this visit/) + await expect(frame(page).locator('#read')).toHaveText('{"n":1}', { timeout: 15_000 }) + await expect(frame(page).locator('#list')).toContainText('data.json') + await frame(page).locator('#w').click() + await expect(frame(page).locator('#write')).toContainText('ERR you can only read this shared folder') + expect((await api(page.request, '/api/chats/shared-pages')).pages.find(p => p.name === 'standup').consent).toBe('none') + await page.reload() + await expect(page.getByTestId('trust-sheet')).toBeVisible() +}) + +test('allow runs it on every open; stop allowing closes it, and a revoke elsewhere stops it at its next call', async ({ page }) => { + await page.goto(pathOf('board')) + await page.getByTestId('trust-allow').click() + await expect(frame(page).locator('#read')).toHaveText(/^\{"n":\d+\}$/, { timeout: 15_000 }) + await frame(page).locator('#w').click() + await expect(frame(page).locator('#write')).toHaveText(/^v\d+$/) + await expect(page.getByTestId('shared-pill')).toHaveText(/Allowed/) + await shot(page, 's4-running-desktop-dark') + await page.reload() + await expect(page.getByTestId('trust-sheet')).toHaveCount(0) + await expect(frame(page).locator('#read')).toHaveText('{"n":2}', { timeout: 15_000 }) + + // Revoked from another tab: the next bridge call is refused and the frame goes. + const p = pages.find(x => x.name === 'board') + expect((await page.request.delete(`/api/chats/shared-pages/${p.folder_id}/board/consent`)).status()).toBe(204) + await frame(page).locator('#again').click() + await expect(page.getByTestId('page-frame')).toHaveCount(0) + await expect(page.getByTestId('shared-blocked')).toContainText('no longer allowed') + + // Stop allowing from the page menu. + await page.getByTestId('shared-review').click() + await page.getByTestId('trust-allow').click() + await expect(frame(page).locator('#read')).toBeVisible({ timeout: 15_000 }) + await page.getByTestId('shared-menu').click() + await page.getByTestId('shared-revoke').click() + await expect(page.getByTestId('page-frame')).toHaveCount(0) + await expect(page.getByTestId('shared-blocked')).toContainText('You stopped allowing this page') + expect((await api(page.request, '/api/chats/shared-pages')).pages.find(x => x.name === 'board').consent).toBe('none') +}) + +test('a page changed since it was allowed asks again and says what changed', async ({ page }) => { + await page.goto(pathOf('releases')) + const sheet = page.getByTestId('trust-sheet') + await expect(sheet).toHaveAttribute('data-variant', 'changed') + await expect(sheet.getByRole('heading', { name: '"Release calendar" has changed' })).toBeVisible() + await expect(sheet).toContainText('Changed by luca') + await expect(sheet).toContainText('You allowed the version of') + await expect(sheet.getByTestId('trust-changes')).toContainText('index.html') + await expect(sheet.getByTestId('trust-changes')).toContainText('3 → 4 lines') + await expect(sheet).toContainText('If you allow it, this version runs each time you open it, until luca changes it again.') + await expect(sheet.getByTestId('trust-cancel')).toBeFocused() + await expect(page.getByTestId('shared-pill')).toHaveText(/Changed since you allowed it/) + await shot(page, 's4-trust-changed-desktop-dark') +}) + +for (const v of [{ name: 'desktop', width: 1440, height: 900 }, { name: 'phone', width: 390, height: 844 }]) { + for (const theme of ['dark', 'light']) { + test(`the trust sheet and the shared group, ${v.name} ${theme}`, async ({ page }) => { + await page.setViewportSize({ width: v.width, height: v.height }) + await page.addInitScript((m) => localStorage.setItem('localai-theme', m), theme) + await page.goto('/app/pages') + await expect(page.getByTestId('shared-group')).toBeVisible() + await shot(page, `s4-pages-list-${v.name}-${theme}`) + await page.goto(pathOf('standup')) + await expect(page.getByTestId('trust-sheet')).toBeVisible() + await shot(page, `s4-trust-first-${v.name}-${theme}`) + await page.goto(pathOf('releases')) + await expect(page.getByTestId('trust-sheet')).toHaveAttribute('data-variant', 'changed') + await shot(page, `s4-trust-changed-${v.name}-${theme}`) + await page.getByTestId('trust-cancel').click() + await expect(page.getByTestId('shared-blocked')).toBeVisible() + await shot(page, `s4-blocked-${v.name}-${theme}`) + }) + } +} + +test('an administrator switches shared pages off: the list says so and a page does not open', async ({ page }) => { + expect((await page.request.post('/api/settings', { data: { shared_pages_enabled: false } })).ok()).toBe(true) + try { + await page.goto('/app/pages') + await expect(page.getByTestId('shared-off')).toContainText('Shared pages are off on this server. An administrator turned them off.') + await expect(page.getByTestId('shared-open')).toHaveCount(0) + await shot(page, 's4-shared-off-desktop-dark') + await page.goto(pathOf('board')) + await expect(page.getByTestId('shared-off')).toContainText('Shared pages are off on this server.') + } finally { + await page.request.post('/api/settings', { data: { shared_pages_enabled: true } }) + } +}) + +test('a shared page split into files runs with the consent and rights of the viewer, inside its folder only', async ({ page }) => { + const digest = pages.find((p) => p.name === 'digest') + const reads = [] + page.on('request', (r) => { if (r.url().includes('/digest/file?')) reads.push({ url: decodeURIComponent(r.url()), hash: r.headers()['x-localai-page-hash'] || '' }) }) + await page.goto(pathOf('digest')) + await expect(page.getByTestId('trust-sheet')).toBeVisible() + expect(reads, 'nothing of the page is read before the consent').toEqual([]) + await page.getByTestId('trust-allow').click() + const f = frame(page) + await expect(f.locator('#data')).toHaveText('7', { timeout: 15_000 }) + await expect(f.locator('#out')).toHaveText(/^refused: .*outside the page's folder/) + expect(await f.locator('#h').evaluate((el) => getComputedStyle(el).color)).toBe('rgb(1, 2, 3)') + expect(await f.locator('#dot').evaluate((img) => img.naturalWidth)).toBe(8) + expect(reads.length).toBeGreaterThan(0) + expect(reads.every((r) => r.hash === digest.hash), JSON.stringify(reads)).toBeTruthy() + expect(reads.some((r) => r.url.includes('path=dot.svg') && r.url.includes('encoding=base64'))).toBeTruthy() + expect(reads.some((r) => r.url.includes('board'))).toBeFalsy() +}) diff --git a/core/http/react-ui/e2e/skills-nav.spec.js b/core/http/react-ui/e2e/skills-nav.spec.js new file mode 100644 index 000000000..8be1722b2 --- /dev/null +++ b/core/http/react-ui/e2e/skills-nav.spec.js @@ -0,0 +1,59 @@ +import { test, expect } from './coverage-fixtures.js' + +// The Skills page has an item of its own in the sidebar, right after Memory. +// Skills work in every chat, so Agents does not light up on the page. + +const item = (page, href) => page.locator(`.sidebar-nav a.nav-item[href="${href}"]`) + +test.describe('Skills in the sidebar', () => { + test('the item follows Memory, opens the page and is the only one lit up', async ({ page }) => { + await page.goto('/app') + await expect(item(page, '/app/skills')).toBeVisible() + const hrefs = await page.locator('.sidebar-nav a.nav-item').evaluateAll(els => els.map(e => e.getAttribute('href'))) + expect(hrefs[hrefs.indexOf('/app/collections') + 1]).toBe('/app/skills') + await expect(item(page, '/app/skills').locator('.nav-label')).toHaveText('Skills') + await item(page, '/app/skills').click() + await expect(page).toHaveURL(/\/app\/skills$/) + await expect(page.getByTestId('skills-page')).toBeVisible() + await expect(item(page, '/app/skills')).toHaveClass(/active/) + await expect(item(page, '/app/agents')).not.toHaveClass(/active/) + await expect(item(page, '/app/collections')).not.toHaveClass(/active/) + }) + + test('Agents is not lit up on any skills page, and Skills is', async ({ page }) => { + for (const path of ['/app/skills', '/app/skills/new']) { + await page.goto(path) + await expect(item(page, '/app/skills')).toHaveClass(/active/) + await expect(item(page, '/app/agents')).not.toHaveClass(/active/) + } + }) + + test('the page says what skills are and that every chat can use them', async ({ page }) => { + await page.goto('/app/skills') + await expect(page.locator('.page-header')).toContainText('all your chats') + }) + + test('the page loads without console errors', async ({ page }) => { + const errors = [] + page.on('pageerror', e => errors.push(e.message)) + page.on('console', m => { if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) errors.push(m.text()) }) + await page.goto('/app/skills') + await expect(page.getByTestId('skills-page')).toBeVisible() + expect(errors).toEqual([]) + }) +}) + +test.describe('Skills in the sidebar on a phone', () => { + test.use({ viewport: { width: 390, height: 844 } }) + + test('the menu lists Skills after Memory and opens the page', async ({ page }) => { + await page.goto('/app') + await page.getByRole('button', { name: 'Open menu' }).click() + await expect(item(page, '/app/skills')).toBeVisible() + const hrefs = await page.locator('.sidebar-nav a.nav-item').evaluateAll(els => els.map(e => e.getAttribute('href'))) + expect(hrefs[hrefs.indexOf('/app/collections') + 1]).toBe('/app/skills') + await item(page, '/app/skills').click() + await expect(page).toHaveURL(/\/app\/skills$/) + expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true) + }) +}) diff --git a/core/http/react-ui/e2e/skills.spec.js b/core/http/react-ui/e2e/skills.spec.js index 7dd9cd784..77ef85e70 100644 --- a/core/http/react-ui/e2e/skills.spec.js +++ b/core/http/react-ui/e2e/skills.spec.js @@ -1,187 +1,157 @@ import { test, expect } from './coverage-fixtures.js' -import { mockLibrary, SKILLS } from './library-fixtures.js' +import { installAgents } from './agents-fixtures.js' -// Skills page (src/pages/Skills.jsx): the library of instructions, who uses -// each one, and how to add one to an agent. +// The skills page and the skill editor, with the skills API stubbed. -test.describe('Skills page', () => { - test('renders the skills list with create affordances', async ({ page }) => { +test.describe('Skills', () => { + test('lists the skills with their layer, who uses them, and opens one', async ({ page }) => { + await installAgents(page) await page.goto('/app/skills') - await expect(page).toHaveURL(/\/app\/skills$/) - await expect(page.getByRole('heading', { name: 'Skills', exact: true })).toBeVisible() - await expect(page.getByRole('button', { name: 'New skill' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Git repositories' })).toBeVisible() + await expect(page.getByTestId('skills-page')).toBeVisible() + await expect(page.getByTestId('skill-row-citations')).toContainText('Personal') + await expect(page.getByTestId('skill-used-citations')).toHaveText('Used by research-assistant') + await expect(page.getByTestId('skill-row-pdf')).toContainText('Built-in') + await expect(page.getByTestId('skill-row-house-style')).toContainText('Shared') + await expect(page.getByTestId('skill-row-broken')).toContainText('Workspace') + await expect(page.getByTestId('skill-used-broken')).toContainText('not a valid skill') + + await page.getByTestId('skill-row-citations').click() + await expect(page.getByTestId('skill-title')).toHaveText('citations') + await expect(page.getByTestId('skill-preview')).toContainText('Cite every claim.') + await page.getByRole('tab', { name: 'Instructions' }).click() + await expect(page.getByTestId('skill-content')).toContainText('List them at the end.') }) - test('New skill navigates to the skill editor', async ({ page }) => { - await page.goto('/app/skills') - await page.getByRole('button', { name: 'New skill' }).click() - await expect(page).toHaveURL(/\/app\/skills\/new$/) - }) -}) - -test.describe('Skills library', () => { - test('each skill says who uses it, from the agents configs, and never claims Chat', async ({ page }) => { - await mockLibrary(page) - await page.goto('/app/skills') - await expect(page.getByTestId('skill-used-summarise-pdf')).toHaveText('Used by research-assistant +1') - await expect(page.getByTestId('skill-used-query-database')).toHaveText('Used by research-assistant') - await expect(page.getByTestId('skill-used-triage-issue')).toHaveText('Used by support-desk') - await expect(page.getByTestId('skill-used-translate-document')).toHaveText('Not used yet') - await expect(page.getByTestId('skill-used-weekly-digest')).toHaveText('Not used yet') - for (const skill of SKILLS) { - await expect(page.getByTestId(`skill-used-${skill.name}`)).not.toContainText(/chat/i) - } - }) - - test('an agent with skills on and no selection uses every skill', async ({ page }) => { - await mockLibrary(page, { - agents: { everything: { name: 'everything', model: 'm', enable_skills: true, selected_skills: [] } }, + test('an add-on skill that another, chosen add-on serves says which one provides it', async ({ page }) => { + await installAgents(page, { + skills: [ + { name: 'xlsx', description: 'Office sheets.', layer: 'addon', addon: 'office', addon_title: 'Office', path: '/addon-skills/office/xlsx/SKILL.md', enabled: true, editable: false }, + { name: 'xlsx', description: 'Sheets sheets.', layer: 'addon', addon: 'sheets', addon_title: 'Sheets', path: '/addon-skills/sheets/xlsx/SKILL.md', enabled: true, editable: false, shadowed: 'addon', shadowed_by: 'office' }, + { name: 'xlsx', description: 'Built-in sheets.', layer: 'builtin', path: '/builtin-skills/xlsx/SKILL.md', enabled: true, editable: false, shadowed: 'addon', shadowed_by: 'office' }, + ], }) - await page.goto('/app/skills?skill=weekly-digest') - await expect(page.getByTestId('skill-used-weekly-digest')).toHaveText('Used by everything') - const chip = page.getByTestId('used-by-everything') - await expect(chip).toContainText('all skills') + await page.goto('/app/skills') + await expect(page.getByTestId('skills-page')).toContainText('Not active: Office provides it') + await expect(page.getByTestId('skills-page')).toContainText('Replaced by Office') }) - test('the Used and Not used yet filters split the list', async ({ page }) => { - await mockLibrary(page) + test('only your own skills can be edited or deleted', async ({ page }) => { + await installAgents(page) await page.goto('/app/skills') - await page.getByTestId('skills-filter-used').click() - await expect(page.locator('[data-testid^="skill-row-"]')).toHaveCount(3) - await expect(page.getByTestId('skill-row-translate-document')).toHaveCount(0) + await page.getByTestId('skill-row-pdf').click() + await expect(page.getByTestId('skill-title')).toHaveText('pdf') + await expect(page.getByTestId('skill-layer')).toHaveText('Built-in') + await expect(page.getByRole('button', { name: 'Edit' })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Delete' })).toHaveCount(0) + await page.getByTestId('skill-row-citations').click() + await expect(page.getByRole('button', { name: 'Edit' })).toBeVisible() + }) + + test('an invalid SKILL.md says why it was skipped', async ({ page }) => { + await installAgents(page) + await page.goto('/app/skills') + await page.getByTestId('skill-row-broken').click() + await expect(page.getByTestId('skill-invalid')).toContainText('the description is empty') + }) + + test('filters by search, by layer and by use', async ({ page }) => { + await installAgents(page) + await page.goto('/app/skills') + await page.getByTestId('skills-search').fill('pdf') + await expect(page.getByTestId('skill-row-pdf')).toBeVisible() + await expect(page.getByTestId('skill-row-citations')).toHaveCount(0) + await page.getByTestId('skills-search').fill('') + await page.getByTestId('skills-layer-shared').click() + await expect(page.getByTestId('skill-row-house-style')).toBeVisible() + await expect(page.getByTestId('skill-row-pdf')).toHaveCount(0) + await page.getByTestId('skills-layer-all').click() await page.getByTestId('skills-filter-unused').click() - await expect(page.locator('[data-testid^="skill-row-"]')).toHaveCount(2) - await expect(page.getByTestId('skill-row-translate-document')).toBeVisible() - await expect(page.getByTestId('skill-row-weekly-digest')).toBeVisible() - await page.getByTestId('skills-filter-all').click() - await expect(page.locator('[data-testid^="skill-row-"]')).toHaveCount(5) - }) - - test('search narrows the list to the matching skills', async ({ page }) => { - await mockLibrary(page) - await page.goto('/app/skills') - await page.getByTestId('skills-search').fill('triage') - await expect(page.locator('[data-testid^="skill-row-"]')).toHaveCount(1) - await expect(page.getByTestId('skill-row-triage-issue')).toBeVisible() - await page.getByTestId('skills-search').fill('zzzz') + await expect(page.getByTestId('skill-row-citations')).toHaveCount(0) + await expect(page.getByTestId('skill-row-pdf')).toBeVisible() + await page.getByTestId('skills-search').fill('zzz') await expect(page.getByTestId('skills-none')).toBeVisible() }) - test('the pane shows instructions, an estimate, files and who uses the skill', async ({ page }) => { - await mockLibrary(page) + test('adds a skill to an agent and takes it away, with an undo', async ({ page }) => { + const s = await installAgents(page) await page.goto('/app/skills') - await page.getByTestId('skill-row-summarise-pdf').click() - await expect(page.getByTestId('skill-title')).toHaveText('summarise-pdf') - await expect(page.getByTestId('used-by-research-assistant').getByRole('link')).toHaveAttribute('href', '/app/agents/research-assistant') - await expect(page.getByTestId('used-by-handbook')).toBeVisible() - // 456 characters, 114 tokens by the stated rule - await expect(page.getByTestId('skill-tokens')).toContainText('About 114 tokens') - await page.getByRole('tab', { name: 'Instructions' }).click() - await expect(page.getByTestId('skill-content')).toContainText('Read the file the user attached') - await page.getByRole('tab', { name: 'Files' }).click() - await expect(page.getByText('scripts/extract.py')).toBeVisible() - await expect(page.getByText('references/style.md')).toBeVisible() - }) - - test('Add to... lists agents with the cost, and adding saves the agent', async ({ page }) => { - const { seen } = await mockLibrary(page) - await page.goto('/app/skills?skill=translate-document') - await expect(page.getByTestId('skill-not-used')).toBeVisible() + await page.getByTestId('skill-row-pdf').click() await page.getByTestId('add-to-button').click() - const menu = page.getByTestId('add-to-menu') - await expect(menu.getByTestId('add-to-research-assistant')).toContainText('tokens per message (estimate)') - await expect(menu.getByTestId('add-to-support-desk')).toContainText('Loaded when the model asks for it') - await menu.getByRole('button', { name: 'Add to handbook' }).click() - await expect.poll(() => seen.saves.length).toBe(1) - expect(seen.saves[0].name).toBe('handbook') - expect(seen.saves[0].body.selected_skills).toEqual(['summarise-pdf', 'translate-document']) - expect(seen.saves[0].body.enable_skills).toBe(true) - await expect(page.getByTestId('skill-used-translate-document')).toHaveText('Used by handbook') - await expect(page.getByTestId('skill-not-used')).toHaveCount(0) + await page.getByTestId('add-to-code-reviewer').getByRole('button', { name: 'Add to code-reviewer' }).click() + await expect.poll(() => s.calls.find(c => c.method === 'PUT' && c.url === '/api/chats/agents/a2')?.body.skills).toEqual(['pdf']) + await expect(page.getByTestId('used-by-code-reviewer')).toBeVisible() + await page.getByRole('button', { name: 'Remove from code-reviewer' }).click() + await expect(page.getByTestId('library-undo-toast')).toBeVisible() + await expect.poll(() => s.calls.filter(c => c.method === 'PUT' && c.url === '/api/chats/agents/a2').at(-1)?.body.skills).toEqual([]) }) - test('adding to an agent with skills off turns them on with only that skill', async ({ page }) => { - const { seen } = await mockLibrary(page) - await page.goto('/app/skills?skill=translate-document') - await page.getByTestId('add-to-button').click() - await page.getByRole('button', { name: 'Add to idle-agent' }).click() - await expect.poll(() => seen.saves.length).toBe(1) - expect(seen.saves[0].body).toMatchObject({ name: 'idle-agent', enable_skills: true, selected_skills: ['translate-document'] }) - }) - - test('removing a skill from an agent saves the rest, and Undo puts it back', async ({ page }) => { - const { seen } = await mockLibrary(page) - await page.goto('/app/skills?skill=query-database') - await page.getByRole('button', { name: 'Remove from research-assistant' }).click() - await expect.poll(() => seen.saves.length).toBe(1) - expect(seen.saves[0].body.selected_skills).toEqual(['summarise-pdf']) - await expect(page.getByTestId('skill-used-query-database')).toHaveText('Not used yet') - await page.getByTestId('library-undo-toast').getByRole('button', { name: 'Undo' }).click() - await expect.poll(() => seen.saves.length).toBe(2) - expect(seen.saves[1].body.selected_skills).toEqual(['summarise-pdf', 'query-database']) - await expect(page.getByTestId('skill-used-query-database')).toHaveText('Used by research-assistant') - }) - - test('removing the only skill switches skills off instead of selecting every skill', async ({ page }) => { - const { seen } = await mockLibrary(page) - await page.goto('/app/skills?skill=triage-issue') - await page.getByRole('button', { name: 'Remove from support-desk' }).click() - await expect.poll(() => seen.saves.length).toBe(1) - expect(seen.saves[0].body).toMatchObject({ name: 'support-desk', enable_skills: false, selected_skills: [] }) - await expect(page.getByTestId('library-undo-toast')).toContainText('Skills are now off') - }) - - test('when the agents cannot be read the page says nothing about use', async ({ page }) => { - await mockLibrary(page, { agentsStatus: 500 }) + test('deletes a skill after asking', async ({ page }) => { + const s = await installAgents(page) await page.goto('/app/skills') - await expect(page.getByTestId('skill-row-summarise-pdf')).toBeVisible() - await expect(page.getByTestId('skill-used-summarise-pdf')).toHaveText('Use unknown') - await expect(page.getByTestId('skills-filter-used')).toBeDisabled() - await expect(page.locator('[data-testid^="skill-used-"]').filter({ hasText: 'Not used yet' })).toHaveCount(0) - await expect(page.getByTestId('used-by')).toContainText('could not be read') + await page.getByTestId('skill-row-citations').click() + await page.getByRole('button', { name: 'Delete' }).click() + const dialog = page.getByRole('alertdialog') + await expect(dialog).toContainText('Delete citations?') + await dialog.getByRole('button', { name: 'Delete' }).click() + await expect(page.getByTestId('skill-row-citations')).toHaveCount(0) + expect(s.calls.some(c => c.method === 'DELETE' && c.url === '/api/chats/skills/personal/citations')).toBe(true) }) - test('an empty library teaches what a skill is', async ({ page }) => { - await mockLibrary(page, { skills: [] }) + test('says why the page is unavailable when the files are off', async ({ page }) => { + await installAgents(page) + await page.route(/\/api\/chats\/skills(\?.*)?$/, route => route.fulfill({ status: 409, json: { error: { message: 'Files are off on this server.', type: 'files_off', code: 409 } } })) await page.goto('/app/skills') - const empty = page.getByTestId('skills-empty') - await expect(empty.getByRole('heading', { name: 'Skills are instructions an agent can follow' })).toBeVisible() - await expect(empty.getByRole('button', { name: 'Create skill' })).toBeVisible() - await expect(empty.getByText('Import', { exact: true })).toBeVisible() + await expect(page.getByTestId('files-off')).toContainText('Files are off on this server.') }) - test('Git repositories open in the pane and a repository can be added', async ({ page }) => { - await mockLibrary(page) + test('the empty page walks through writing the first skill', async ({ page }) => { + await installAgents(page, { skills: [] }) await page.goto('/app/skills') - await page.getByTestId('skills-git-toggle').click() - const git = page.getByTestId('skills-git') - await expect(git.getByText('No Git repos configured')).toBeVisible() - await git.getByLabel('Repository URL').fill('https://github.com/example/skills') - await git.getByRole('button', { name: 'Add repo' }).click() - await expect(git.getByText('https://github.com/example/skills')).toBeVisible() + await expect(page.getByTestId('skills-empty')).toBeVisible() + await page.getByRole('button', { name: 'Create skill' }).click() + await expect(page).toHaveURL(/\/app\/skills\/new$/) }) - test('on a phone the list and the skill take turns', async ({ page }) => { - await page.setViewportSize({ width: 390, height: 844 }) - await mockLibrary(page) - await page.goto('/app/skills') - await expect(page.getByTestId('skill-row-summarise-pdf')).toBeVisible() - await expect(page.getByTestId('skill-pane')).toBeHidden() - await page.getByTestId('skill-row-summarise-pdf').click() - await expect(page.getByTestId('skill-title')).toBeVisible() - await expect(page.getByTestId('skill-row-summarise-pdf')).toBeHidden() - await page.getByRole('button', { name: 'All skills' }).click() - await expect(page.getByTestId('skill-row-summarise-pdf')).toBeVisible() - const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) - expect(overflow).toBeLessThanOrEqual(0) - }) - - test('wide, the first skill is open until another is picked', async ({ page }) => { - await mockLibrary(page) - await page.goto('/app/skills') - await expect(page.getByTestId('skill-title')).toHaveText('summarise-pdf') - await page.getByTestId('skill-row-triage-issue').click() - await expect(page.getByTestId('skill-title')).toHaveText('triage-issue') + test('the old address of the skills page redirects', async ({ page }) => { + await installAgents(page) + await page.goto('/app/chats/skills') + await expect(page).toHaveURL(/\/app\/skills$/) + }) +}) + +test.describe('Skill editor', () => { + test('writes a new skill in your own layer', async ({ page }) => { + const s = await installAgents(page) + await page.goto('/app/skills/new') + await page.getByLabel(/^Name/).fill('release-notes') + await page.getByLabel(/^Description/).fill('Write release notes from a changelog') + await page.locator('.skilledit-sidebar-item', { hasText: 'Content' }).click() + await page.getByLabel('Skill content (markdown)').fill('Group by feature.') + await page.getByRole('button', { name: 'Create skill' }).click() + await expect(page).toHaveURL(/\/app\/skills$/) + expect(s.calls.find(c => c.method === 'POST').body).toEqual({ + layer: 'personal', name: 'release-notes', description: 'Write release notes from a changelog', body: 'Group by feature.', + }) + }) + + test('edits a skill: the name and the layer stay, the description and the body change', async ({ page }) => { + const s = await installAgents(page) + await page.goto('/app/skills/edit/personal/citations') + await expect(page.getByLabel(/^Name/)).toBeDisabled() + await expect(page.getByLabel(/^Name/)).toHaveValue('citations') + await expect(page.getByTestId('skill-edit-layer')).toHaveText('Personal') + await page.getByLabel(/^Description/).fill('Cite with numbers.') + await page.locator('.skilledit-sidebar-item', { hasText: 'Content' }).click() + await expect(page.getByLabel('Skill content (markdown)')).toHaveValue(/Number the sources/) + await page.getByRole('button', { name: 'Save changes' }).click() + await expect(page).toHaveURL(/\/app\/skills$/) + expect(s.calls.find(c => c.method === 'PUT' && c.url.includes('/skills/')).body).toMatchObject({ description: 'Cite with numbers.' }) + }) + + test('a skill you cannot write opens read-only', async ({ page }) => { + await installAgents(page) + await page.goto('/app/skills/edit/builtin/pdf') + await expect(page.getByTestId('skill-readonly')).toBeVisible() + await expect(page.getByRole('button', { name: 'Save changes' })).toHaveCount(0) }) }) diff --git a/core/http/react-ui/e2e/slack-addon.spec.js b/core/http/react-ui/e2e/slack-addon.spec.js new file mode 100644 index 000000000..609fe3431 --- /dev/null +++ b/core/http/react-ui/e2e/slack-addon.spec.js @@ -0,0 +1,128 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockChat } from './chat-fixtures.js' +import { installStudioAddons } from './studio-addon.js' +import { connectorApi } from './connector-fixtures.js' + +// The page of the Slack add-on (the real built bundle, inside the add-on +// shell) with the core API stubbed: both tokens go in as one secret and are +// checked on save, people and channels are two lists of one allowlist, and +// the setup shows the app manifest. + +const PAGE = '/app/addons/slack/apps' +const NEWCOMER = { id: 'U042', name: 'Newcomer', count: 2, allowed: false, first: '2026-10-27T08:00:00Z', last: '2026-10-27T08:05:00Z' } +const BINDING = { id: 'b1', name: 'Team app', kind: 'slack', agent_id: 'a1', enabled: true, accept_any: false, allow: ['U001', 'C001'], has_token: true } +const api = (page, initial = []) => connectorApi(page, initial, 'slack', [NEWCOMER]) + +test.describe('Slack add-on', () => { + test.beforeEach(async ({ page }) => { + await mockChat(page) + await installStudioAddons(page, { ids: ['slack'] }) + }) + + test('creates a binding with both tokens, people and channels, and checks the tokens', async ({ page }) => { + const a = api(page) + await a.install() + await page.route(/\/api\/chats\/connectors\/[^/?]+\/test$/, r => r.fulfill({ json: { bot_id: 'UBOT', bot_username: 'localai (Acme)' } })) + await page.goto(PAGE) + + await expect(page.getByText('No Slack apps yet.')).toBeVisible() + await page.getByRole('button', { name: 'New Slack app' }).click() + await page.getByLabel('Name', { exact: true }).fill('Team app') + await page.getByLabel('Bot token').fill('xoxb-secret-bot') + await expect(page.getByTestId('token-problem')).toContainText('Type both tokens') + await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() + await page.getByLabel('App-level token').fill('xapp-secret-app') + await expect(page.getByTestId('token-problem')).toHaveCount(0) + await page.getByLabel('Agent', { exact: true }).selectOption('a1') + await page.getByRole('textbox', { name: 'Allowed people +' }).fill('U001') + await page.getByRole('textbox', { name: 'Allowed people +' }).press('Enter') + await page.getByRole('textbox', { name: 'Allowed channels +' }).fill('C001') + await page.getByRole('textbox', { name: 'Allowed channels +' }).press('Enter') + await expect(page.getByTestId('connector-allow')).toContainText('U001') + await expect(page.getByTestId('connector-allow')).not.toContainText('C001') + await expect(page.getByTestId('connector-allow-channels')).toContainText('C001') + await page.getByRole('button', { name: 'Save' }).click() + + await expect(page.getByTestId('connector-row')).toContainText('Team app') + await expect(page.getByTestId('connector-row')).toContainText('Accepts: 2 listed entries') + await expect(page.getByText('Connected to localai (Acme).')).toBeVisible() + const body = a.s.calls.find(c => c.method === 'POST' && c.url === '/api/chats/connectors').body + expect(body).toMatchObject({ kind: 'slack', name: 'Team app', token: 'xoxb-secret-bot xapp-secret-app', agent_id: 'a1', allow: ['U001', 'C001'] }) + expect(body).not.toHaveProperty('bot_token') + await expect(page.locator('body')).not.toContainText('secret-bot') + }) + + test('a token the platform refuses is saved and the reason is shown', async ({ page }) => { + await api(page).install() + await page.route(/\/api\/chats\/connectors\/[^/?]+\/test$/, r => r.fulfill({ status: 502, json: { error: { message: 'the platform refused the bot token' } } })) + await page.goto(PAGE) + await page.getByRole('button', { name: 'New Slack app' }).click() + await page.getByLabel('Name', { exact: true }).fill('Team app') + await page.getByLabel('Bot token').fill('xoxb-1') + await page.getByLabel('App-level token').fill('xapp-2') + await page.getByLabel('Agent', { exact: true }).selectOption('a1') + await page.getByRole('button', { name: 'Save' }).click() + await expect(page.getByRole('alert')).toContainText('Saved. The token check failed: the platform refused the bot token') + await expect(page.getByTestId('connector-row')).toHaveCount(1) + }) + + test('an edit keeps the stored tokens unless both are typed, and keeps both lists', async ({ page }) => { + const a = api(page, [BINDING]) + await a.install() + await page.goto(PAGE) + await page.getByRole('button', { name: 'Edit' }).click() + await expect(page.getByLabel('Bot token')).toHaveValue('') + await expect(page.getByLabel('App-level token')).toHaveAttribute('placeholder', /Saved/) + await expect(page.getByTestId('connector-allow-channels')).toContainText('C001') + await page.getByRole('button', { name: 'C001 ×' }).click() + await page.getByRole('button', { name: 'Save' }).click() + const put = a.s.calls.find(c => c.method === 'PUT') + expect(put.url).toBe('/api/chats/connectors/b1') + expect('token' in put.body).toBe(false) + expect(put.body.allow).toEqual(['U001']) + }) + + test('shows how to make the Slack app, with its manifest', async ({ page }) => { + await api(page).install() + await page.goto(PAGE) + const setup = page.getByTestId('slack-setup') + await setup.getByText('How to make the Slack app').click() + await expect(setup).toContainText('connections:write') + await expect(setup.getByLabel('Slack app manifest')).toContainText('socket_mode_enabled: true') + }) + + test('tests the tokens, shows the sender log and allows a sender', async ({ page }) => { + const a = api(page, [BINDING]) + await a.install() + await page.route(/\/api\/chats\/connectors\/[^/?]+\/test$/, r => r.fulfill({ json: { bot_id: 'UBOT', bot_username: 'localai (Acme)' } })) + await page.goto(PAGE) + await page.getByRole('button', { name: 'Test' }).click() + await expect(page.getByText('Connected to localai (Acme).')).toBeVisible() + await page.getByRole('button', { name: 'Senders' }).click() + await expect(page.getByTestId('sender-log')).toContainText('Newcomer · 2 messages · U042') + await page.getByRole('button', { name: 'Allow' }).click() + await expect(page.getByTestId('sender-log')).toContainText('Accepted') + expect(a.s.calls.some(c => c.url === '/api/chats/connectors/b1/senders/U042/allow')).toBe(true) + }) + + test('a token the server refuses on save keeps the form open with the reason', async ({ page }) => { + const a = api(page) + await a.install() + await page.route(/\/api\/chats\/connectors(\?.*)?$/, r => { + if (r.request().method() !== 'POST') return r.fallback() + return r.fulfill({ status: 400, json: { error: { message: 'the platform refused the bot token' } } }) + }) + await page.goto(PAGE) + await page.getByRole('button', { name: 'New Slack app' }).click() + await page.getByLabel('Name', { exact: true }).fill('Team app') + await page.getByLabel('Bot token').fill('xoxb-1') + await page.getByLabel('App-level token').fill('xapp-2') + await page.getByLabel('Agent', { exact: true }).selectOption('a1') + await page.getByTestId('connector-reply').selectOption('progress') + await page.getByRole('button', { name: 'Save' }).click() + await expect(page.getByRole('alert')).toContainText('the platform refused the bot token') + await expect(page.getByRole('form')).toBeVisible() + await expect(page.getByTestId('connector-row')).toHaveCount(0) + expect(a.s.items).toHaveLength(0) + }) +}) diff --git a/core/http/react-ui/e2e/slash-fixtures.js b/core/http/react-ui/e2e/slash-fixtures.js new file mode 100644 index 000000000..77bda9c6c --- /dev/null +++ b/core/http/react-ui/e2e/slash-fixtures.js @@ -0,0 +1,84 @@ +import { expect } from '@playwright/test' +import { mockChat } from './chat-fixtures.js' + +// A chat with skills, saved agents and the command routes stubbed, for the +// slash commands spec (chats-slash-commands.spec.js). The event stream is +// faked: window.__emit(kind, payload) plays a server event. + +const AT = '2026-10-25T10:00:00Z' +export const HISTORY = [ + { seq: 1, turn: 1, role: 'user', text: 'Compare the quotes.', at: AT }, + { seq: 2, turn: 1, role: 'assistant', text: 'The second one is cheapest.', at: AT }, +] +export const SKILLS = [ + { id: 'personal:review', name: 'review', description: 'Check a change before it ships', layer: 'personal', path: '/s/review', enabled: true }, + { id: 'builtin:pdf', name: 'pdf', description: 'Read and write PDF documents', layer: 'builtin', path: '/s/pdf', enabled: true }, + { id: 'addon:office:slides', name: 'slides', description: 'Make slide decks', layer: 'addon', addon: 'office', addon_title: 'Office', path: '/s/slides', enabled: true }, + { id: 'shared:new', name: 'new', description: 'A shared skill named like a command', layer: 'shared', path: '/s/new', enabled: true }, +] +export const AGENTS = [ + { id: 'rev1', name: 'Reviewer', description: 'Checks drafts for tone' }, + { id: 'rev2', name: 'Researcher', description: 'Finds sources on the web' }, +] + +export const emit = (page, kind, payload) => page.evaluate(([k, p]) => window.__emit(k, p), [kind, payload]) +export const box = (page) => page.getByTestId('chat-input') +export const menu = (page) => page.locator('#home-slash') + +export async function openSlashChat(page, { agentsOn = true, messages = HISTORY } = {}) { + await page.addInitScript(() => { + window.__sources = [] + window.EventSource = class { + constructor(url) { this.url = url; this.l = {}; window.__sources.push(this); setTimeout(() => this.onopen && this.onopen(), 0) } + addEventListener(kind, fn) { (this.l[kind] = this.l[kind] || []).push(fn) } + close() { this.closed = true } + } + window.__emit = (kind, payload) => { + const src = window.__sources.filter((x) => x.url.includes('/api/chats/c1/events') && !x.closed).pop() + for (const fn of (src && src.l[kind]) || []) fn({ data: JSON.stringify(payload) }) + } + }) + await mockChat(page) + const api = { messages: [...messages], sent: [], builtins: [], compactRelease: null, agentsOn } + const detail = { id: 'c1', title: 'Quotes', model: 'qwen3-8b', kind: 'chat', created: AT, updated: AT, running: false, last_turn: 'done', pending: [] } + await page.route(/\/api\/chats\/c1\/messages(\?.*)?$/, async (route) => { + if (route.request().method() === 'POST') { + const body = route.request().postDataJSON() + api.sent.push(body) + const seq = api.messages.length + 1 + api.messages.push({ seq, turn: 2, role: 'user', text: body.text, at: AT, + ...(body.skill ? { invoked: { kind: 'skill', name: body.skill } } : {}), + ...(body.agent ? { invoked: { kind: 'agent', name: AGENTS.find(a => a.id === body.agent)?.name || body.agent, id: body.agent } } : {}) }) + return route.fulfill({ json: { started: false, pending_id: '' } }) + } + return route.fulfill({ json: { messages: api.messages } }) + }) + await page.route(/\/api\/chats\/skills(\?.*)?$/, route => route.fulfill({ json: { skills: SKILLS } })) + await page.route('**/api/chats/c1/tools', route => route.fulfill({ json: { + builtins: [{ group: 'saved_agents', opt_in: true, off: !api.agentsOn, usable: true, tools: ['list_agents'] }], servers: [], connectors: [], + } })) + await page.route('**/api/chats/c1/builtins', route => { + const body = route.request().postDataJSON() + api.builtins.push(body) + api.agentsOn = (body.on || []).includes('saved_agents') + return route.fulfill({ json: { builtins: [{ group: 'saved_agents', opt_in: true, off: !api.agentsOn, usable: true, tools: ['list_agents'] }] } }) + }) + await page.route('**/api/chats/c1/addons', route => route.fulfill({ json: { addons: [], commands: 'none' } })) + await page.route('**/api/chats/agents', route => route.fulfill({ json: { agents: AGENTS } })) + await page.route('**/api/chats/c1/compact', async route => { + await new Promise((resolve) => { api.compactRelease = resolve }) + api.messages.push({ seq: api.messages.length + 1, turn: 1, role: 'compacted', text: 'Compacted: 9000 tokens to 3000', before: 9000, after: 3000, at: AT }) + return route.fulfill({ json: { before: 9000, after: 3000 } }) + }) + await page.route('**/api/chats/c1/clear', route => { + api.messages.push({ seq: api.messages.length + 1, turn: 1, role: 'cleared', text: '', at: AT }) + return route.fulfill({ json: { cleared: true } }) + }) + await page.route('**/api/chats/c1', route => route.fulfill({ json: detail })) + await page.route(/\/api\/chats(\?.*)?$/, route => route.fulfill({ json: { conversations: [{ ...detail, pending: undefined }], persistent: true, files: true } })) + await page.goto('/app/chats/c1') + await expect(page.getByTestId('chat-foot')).toBeVisible() + await expect(page.getByTestId('chat-message')).toHaveCount(messages.filter(m => m.role === 'user' || m.role === 'assistant').length) + return api +} + diff --git a/core/http/react-ui/e2e/studio-addon.js b/core/http/react-ui/e2e/studio-addon.js index 4ce7e098f..d50e093f8 100644 --- a/core/http/react-ui/e2e/studio-addon.js +++ b/core/http/react-ui/e2e/studio-addon.js @@ -26,7 +26,7 @@ export async function buildAddonBundles(ids) { // The add-ons the specs serve from their build output: Studio, Talk and Voice // through installStudioAddons, Face and Fine-tuning through their own routes. -export async function buildStudioAddons(ids = ['studio', 'talk', 'voice', 'face', 'fine-tuning']) { +export async function buildStudioAddons(ids = ['studio', 'talk', 'voice', 'face', 'fine-tuning', 'telegram', 'slack']) { await buildAddonBundles(ids) process.env[BUILT] = '1' } @@ -56,7 +56,7 @@ export function packagedApp(id, over = {}) { export async function installStudioAddons(page, { ids = ['studio', 'talk'], ...options } = {}) { const dirs = await Promise.all(ids.map(build)) await stubAddonRoutes(page) - await page.route(/\/api\/addons\/(studio|talk|voice)\/assets\//, route => { + await page.route(/\/api\/addons\/(studio|talk|voice|telegram|slack)\/assets\//, route => { const parts = new URL(route.request().url()).pathname.split('/') const id = parts[3] const file = parts.slice(6).join('/') diff --git a/core/http/react-ui/e2e/surfaces.spec.js b/core/http/react-ui/e2e/surfaces.spec.js index 0cf03dee9..c853c14ef 100644 --- a/core/http/react-ui/e2e/surfaces.spec.js +++ b/core/http/react-ui/e2e/surfaces.spec.js @@ -26,7 +26,8 @@ for (const theme of ['light', 'dark']) { canvas: getComputedStyle(document.body).backgroundColor, } }) - expect(style.radius).toBe('20px') + // Cards use the 12px card radius of the token scale (--r-lg). + expect(style.radius).toBe('12px') expect(style.shadow).not.toBe('none') // The edge differs from the card, and the card differs from the canvas. expect(distance(rgb(style.border), rgb(style.bg))).toBeGreaterThan(8) @@ -76,28 +77,6 @@ for (const theme of ['light', 'dark']) { expect(distance(rgb(colours.active), rgb(colours.quiet))).toBeGreaterThan(40) }) - test('the chat send button is quiet when empty and active with text', async ({ page }) => { - await page.goto('/app/chat') - const btn = page.locator('#chat-submit-btn') - await expect(btn).toBeVisible({ timeout: 15_000 }) - await expect(btn).toBeDisabled() - await expect(btn).toHaveAttribute('data-empty', 'true') - await page.getByTestId('chat-input').fill('hello') - await expect(btn).not.toHaveAttribute('data-empty', 'true') - // The test server lists no chat model, so the button stays disabled. Compare - // the two looks directly: the active look must differ from the quiet one - // whatever the disabled state. - const colours = await btn.evaluate((el) => { - el.disabled = false - el.style.transition = 'none' - const active = getComputedStyle(el).backgroundColor - el.setAttribute('data-empty', 'true') - const quiet = getComputedStyle(el).backgroundColor - return { active, quiet } - }) - expect(distance(rgb(colours.active), rgb(colours.quiet))).toBeGreaterThan(40) - }) - test('a popover sits on the float surface, not on the card', async ({ page }) => { await page.goto('/app') await expect(page.locator('.home-greeting')).toBeVisible({ timeout: 15_000 }) diff --git a/core/http/react-ui/e2e/tables-acceptance.spec.js b/core/http/react-ui/e2e/tables-acceptance.spec.js index ea08821cb..de5f83628 100644 --- a/core/http/react-ui/e2e/tables-acceptance.spec.js +++ b/core/http/react-ui/e2e/tables-acceptance.spec.js @@ -1,8 +1,6 @@ import { test, expect, request as playwrightRequest } from '@playwright/test' import fs from 'node:fs' import { randomUUID } from 'node:crypto' -import { chatOf, mockChat } from './chat-fixtures.js' -import { seedChats, CHATS_KEY } from './home-fixtures.js' // Tables acceptance in the browser. The runner (scripts/addons/tables_acceptance.sh) // starts the owner and two frontends as separate server processes, one shared @@ -50,8 +48,6 @@ const control = async (path) => { const isAsset = (url) => /\/api\/addons\/tables\/assets\//.test(url) const noOverflow = (page) => page.evaluate(() => document.scrollingElement.scrollWidth <= window.innerWidth) -const b64url = (s) => Buffer.from(s).toString('base64url') -const toolMessage = (tool, args) => `Please run this ADDON_TOOL_CALL:addon__tables__${tool}:${b64url(JSON.stringify(args))}` async function generation(api) { const r = await api.get('/api/addons/apps') @@ -120,37 +116,6 @@ test.describe('Tables acceptance through real servers', () => { } }) - test('tables-acceptance: an approval card from chat on one frontend applies once and the row shows on the other frontend', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'front-a', 'member') - await mockChat(page, { models: [{ id: 'mock-model', capabilities: ['FLAG_CHAT'] }], loaded: ['mock-model'] }) - await seedChats(page, [chatOf('acc', 'Tables', 'mock-model', [], { addonTools: true })], 'acc') - await page.goto('/app/chat') - await page.getByTestId('chat-composer').waitFor() - await page.getByTestId('chat-input').fill(toolMessage('rows-insert', { resourceId: state.db.id, table: 'Entries', rows: [{ Item: 'from-chat' }] })) - await page.getByTestId('chat-send').click() - const card = page.getByTestId('proposal-card') - await expect(card).toHaveCount(1, { timeout: 40_000 }) - await expect(card.getByTestId('proposal-operation')).toHaveText('rows-insert') - await expect(card.getByTestId('proposal-count')).toHaveText('1') - await expect(card.getByTestId('proposal-state')).toHaveText('Waiting for your decision') - // Nothing is applied before the person decides: the other frontend still - // shows the two rows. - const other = await browserAs(browser, 'front-b', 'member') - await openDb(other.page, state.db.id) - await expect(other.page.getByTestId('tbl-row')).toHaveCount(2) - await card.getByTestId('proposal-confirm').click() - await expect(card.getByTestId('proposal-state')).toHaveText('Applied', { timeout: 30_000 }) - await expect(card.getByTestId('proposal-confirm')).toBeDisabled() - await other.page.reload() - await expect(other.page.getByTestId('tbl-row')).toHaveCount(3, { timeout: 30_000 }) - await expect(other.page.getByTestId('tbl-grid')).toContainText('from-chat') - // A reload of the chat draws the same applied card from the host. - await page.reload() - await expect(page.getByTestId('proposal-card').last().getByTestId('proposal-state')).toHaveText('Applied', { timeout: 30_000 }) - await other.context.close() - await context.close() - }) - test('tables-acceptance: deep links keep the proxy path prefix on a non-owner frontend and survive a reload', async ({ browser }) => { const { context, page } = await browserAs(browser, 'front-b', 'member', { extraHTTPHeaders: { 'X-Forwarded-Prefix': '/llm' } }) const assets = [] @@ -220,26 +185,4 @@ test.describe('Tables acceptance through real servers', () => { await context.close() }) - test('tables-acceptance: after logout nothing of the database is left in the page or the stored chat', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'front-a', 'member') - await mockChat(page, { models: [{ id: 'mock-model', capabilities: ['FLAG_CHAT'] }], loaded: ['mock-model'] }) - await seedChats(page, [chatOf('acc2', 'Tables', 'mock-model', [], { addonTools: true })], 'acc2') - await openDb(page, state.db.id) - await expect(page.getByTestId('tbl-grid')).toContainText(SECRET) - const out = await pageFetch(page, '/api/auth/logout', { method: 'POST' }) - expect([200, 204]).toContain(out.status) - // The same session is refused by the other frontend too. - const other = await browserAs(browser, 'front-b', 'member') - await other.page.goto('/app') - const apps = await pageFetch(other.page, '/api/addons/apps') - expect(apps.status).toBe(401) - await other.context.close() - await page.reload() - await expect(page.getByTestId('tbl-grid')).toHaveCount(0) - await expect(page.locator('body')).not.toContainText(SECRET) - const stored = await page.evaluate((key) => localStorage.getItem(key) || '', CHATS_KEY) - expect(stored).not.toContain(SECRET) - expect(stored).not.toContain('digest') - await context.close() - }) }) diff --git a/core/http/react-ui/e2e/tables-artifacts.spec.js b/core/http/react-ui/e2e/tables-artifacts.spec.js index c9a79fe16..2130068bf 100644 --- a/core/http/react-ui/e2e/tables-artifacts.spec.js +++ b/core/http/react-ui/e2e/tables-artifacts.spec.js @@ -1,8 +1,6 @@ import { test, expect, request as playwrightRequest } from '@playwright/test' import fs from 'node:fs' import { randomUUID } from 'node:crypto' -import { chatOf, mockChat } from './chat-fixtures.js' -import { seedChats, CHATS_KEY } from './home-fixtures.js' // Private CSV exports of the Tables add-on. Two UI test servers run the real // add-on owner, the real Tables executable and the real built UI package, one @@ -131,8 +129,6 @@ async function exportFromPage(page, { rowId = false } = {}) { return { dialog, card } } -const b64url = (s) => Buffer.from(s).toString('base64url') -const toolMessage = (tool, args) => `Please run this ADDON_TOOL_CALL:addon__tables__${tool}:${b64url(JSON.stringify(args))}` test.describe.configure({ mode: 'serial' }) @@ -300,39 +296,6 @@ test.describe('Tables CSV exports', () => { expect(bad.status()).toBeGreaterThanOrEqual(400) }) - test('tables-artifacts: the chat draws the host card for an export and stores only its identifier', async ({ browser }) => { - const { context, page } = await browserAs(browser, 'member') - await mockChat(page, { models: [{ id: 'mock-model', capabilities: ['FLAG_CHAT'] }], loaded: ['mock-model'] }) - await seedChats(page, [chatOf('c1', 'Tables', 'mock-model', [], { addonTools: true })], 'c1') - await page.goto('/app/chat') - await page.getByTestId('chat-composer').waitFor() - await page.getByTestId('chat-input').fill(toolMessage('rows-export', { resourceId: state.db.id, table: 'Typed', columns: ['Name'] })) - await page.getByTestId('chat-send').click() - const card = page.getByTestId('artifact-card') - await expect(card).toHaveCount(1, { timeout: 40_000 }) - await expect(card).toHaveAttribute('data-state', 'complete', { timeout: 30_000 }) - await expect(card.getByTestId('artifact-name')).toHaveText('Typed.csv') - await expect(card.getByTestId('artifact-expires')).toBeVisible() - const href = await card.getByTestId('artifact-download').getAttribute('href') - expect(href).toMatch(/^\/api\/addons\/artifacts\/a[0-9a-f]{32}\/download$/) - const res = await member.get(href) - expect(res.status()).toBe(200) - expect((await res.text()).startsWith('Name\r\n')).toBe(true) - await shoot(page, 'tables-artifacts-chat') - // The conversation keeps the identifier and nothing of the file. - const readStored = () => page.evaluate((key) => localStorage.getItem(key) || '', CHATS_KEY) - await expect.poll(readStored, { timeout: 15_000 }).toContain('tool_result') - const chats = JSON.parse(await readStored()).chats - const results = chats.flatMap(c => c.history).filter(m => m.role === 'tool_result') - expect(results.length).toBeGreaterThan(0) - for (const m of results) { - const held = JSON.parse(JSON.parse(m.content).result) - expect(held).toEqual({ kind: 'artifact', artifacts: [{ id: expect.stringMatching(/^a[0-9a-f]{32}$/) }] }) - } - expect(await readStored()).not.toContain('=HYPERLINK') - await context.close() - }) - test('tables-artifacts: a person at the file quota is told so and no file is made', async ({ browser }) => { const viewerGrant = await browserAs(browser, 'member') await viewerGrant.page.goto(ROUTE) diff --git a/core/http/react-ui/e2e/tables-ui.spec.js b/core/http/react-ui/e2e/tables-ui.spec.js index 15d7c537d..4d097dafb 100644 --- a/core/http/react-ui/e2e/tables-ui.spec.js +++ b/core/http/react-ui/e2e/tables-ui.spec.js @@ -1,8 +1,6 @@ import { test, expect, request as playwrightRequest } from '@playwright/test' import fs from 'node:fs' import { randomUUID } from 'node:crypto' -import { chatOf, mockChat } from './chat-fixtures.js' -import { seedChats } from './home-fixtures.js' // The native Tables UI. Two UI test servers run the real add-on owner, the // real Tables executable and the real built UI package (addons/tables/ui), @@ -623,151 +621,6 @@ test.describe('Tables UI', () => { await own.context.close() }) - test('tables-ui: Select database keeps one database for each person and conversation and a tool call uses it', async ({ browser }) => { - // Two databases the editor may read and change. - const own = await browserAs(browser, 'member') - const gen = await generation(admin) - await own.page.goto(ROUTE) - await expect(own.page.getByTestId('tbl-list-page')).toBeVisible({ timeout: 25_000 }) - const mk = async (name, item) => { - const db = await tab.create(own.page, name, gen) - let rev = db.revision - for (const [tool, args] of [['tables-create', { table: 'Notes' }], ['columns-add', { table: 'Notes', column: 'Text', type: 'text' }], ['rows-insert', { table: 'Notes', rows: [{ Text: item }] }]]) { - const r = await tab.apply(own.page, db.id, tool, args, rev, gen) - expect(r.status, JSON.stringify(r.json)).toBe(200) - rev = r.json.revision - } - const g = await pageFetch(own.page, `/api/addons/resources/${db.id}/grants/${creds.users.editor.id}`, { method: 'PUT', body: { role: 'editor' }, headers: { 'X-LocalAI-Confirm': db.id } }) - expect(g.status).toBe(200) - return db.id - } - const alpha = await mk('Alpha notes', 'ALPHA-ROW') - const beta = await mk('Beta notes', 'BETA-ROW') - - const chat = async (who, convs) => { - const s = await browserAs(browser, who) - await mockChat(s.page, { models: [{ id: 'mock-model', capabilities: ['FLAG_CHAT'] }], loaded: ['mock-model'] }) - await seedChats(s.page, convs.map(c => chatOf(c, `Chat ${c}`, 'mock-model', [], { addonTools: true })), convs[0]) - await s.page.goto('/app/chat') - await s.page.getByTestId('chat-composer').waitFor() - return s - } - const openPicker = async (page) => { - await page.getByTestId('addon-composer-menu').click() - await page.locator('[data-addon-action="select-database"]').click() - const dlg = page.getByTestId('tbl-select-dialog') - await expect(dlg).toBeVisible({ timeout: 25_000 }) - return dlg - } - const pick = async (page, name) => { - const dlg = await openPicker(page) - await dlg.getByTestId('tbl-pick').filter({ hasText: name }).getByRole('button', { name: /Use in this chat/ }).click() - await expect(dlg.getByTestId('tbl-selected')).toContainText(name, { timeout: 25_000 }) - return dlg - } - const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url') - // One scripted call of the model that names no database. The host runs it - // as the person, with the conversation's selection; a row insert becomes - // a proposal card whose database is the one the selection resolved to. - const say = async (page, text) => { - const before = await page.getByTestId('proposal-card').count() - await page.getByTestId('chat-input').fill(`Please run this ADDON_TOOL_CALL:addon__tables__rows-insert:${b64({ table: 'Notes', rows: [{ Text: text }] })}`) - await page.getByTestId('chat-send').click() - return before - } - const rowsIn = async (id) => { - const r = await read(member, id, gen, { tool: 'rows-query', table: 'Notes' }) - expect(r.status).toBe(200) - return r.json.payload.rows.map(x => x.values[0]) - } - - // The owner: c1 works on Alpha. - const o = await chat('member', ['c1', 'c2']) - const dlg = await pick(o.page, 'Alpha notes') - await shoot(o.page, 'tables-ui-select') - await expect(dlg.getByTestId('tbl-in-use')).toHaveCount(1) - await dlg.getByRole('button', { name: 'Close' }).click() - let before = await say(o.page, 'OWNER-C1') - const ownerCard = o.page.getByTestId('proposal-card').nth(before) - await expect(ownerCard).toBeVisible({ timeout: 40_000 }) - await expect(ownerCard.getByTestId('proposal-database')).toHaveText(alpha) - await ownerCard.getByTestId('proposal-confirm').click() - await expect(ownerCard.getByTestId('proposal-state')).toHaveText('Applied', { timeout: 25_000 }) - expect(await rowsIn(alpha)).toContain('OWNER-C1') - expect(await rowsIn(beta)).not.toContain('OWNER-C1') - - // The editor chose nothing in c1: the owner's choice is not theirs. The - // picker lists what this person may use, with their own role. - const e = await chat('editor', ['c1', 'c2']) - const edlg = await openPicker(e.page) - await expect(edlg.getByTestId('tbl-none-selected')).toBeVisible({ timeout: 25_000 }) - // Household budget, Alpha and Beta: all three were shared as editor. - await expect(edlg.getByTestId('tbl-pick')).toHaveCount(3) - await expect(edlg.locator('[data-testid="tbl-pick"]:not([data-role="editor"])')).toHaveCount(0) - await expect(edlg.getByTestId('tbl-pick').filter({ hasText: 'Typed values' })).toHaveCount(0) - await edlg.getByRole('button', { name: 'Close' }).click() - // The same conversation identifier, another person, another database. - const dlg2 = await pick(e.page, 'Beta notes') - await dlg2.getByRole('button', { name: 'Close' }).click() - before = await say(e.page, 'EDITOR-C1') - const editorCard = e.page.getByTestId('proposal-card').nth(before) - await expect(editorCard).toBeVisible({ timeout: 40_000 }) - await expect(editorCard.getByTestId('proposal-database')).toHaveText(beta) - await editorCard.getByTestId('proposal-confirm').click() - await expect(editorCard.getByTestId('proposal-state')).toHaveText('Applied', { timeout: 25_000 }) - expect(await rowsIn(beta)).toContain('EDITOR-C1') - expect(await rowsIn(alpha)).not.toContain('EDITOR-C1') - - // Each person and conversation has its own choice. - const sel = (api, conv) => api.get(`/api/addons/conversations/${conv}/selection/tables`) - const editorApi = await apiAs('editor') - expect((await (await sel(member, 'c1')).json()).resourceId).toBe(alpha) - expect((await sel(member, 'c2')).status()).toBe(404) - expect((await (await sel(editorApi, 'c1')).json()).resourceId).toBe(beta) - // The owner's second conversation picks Beta; c1 is unchanged. - const o2 = await chat('member', ['c2', 'c1']) - const dlg3 = await pick(o2.page, 'Beta notes') - await dlg3.getByRole('button', { name: 'Close' }).click() - expect((await (await sel(member, 'c2')).json()).resourceId).toBe(beta) - expect((await (await sel(member, 'c1')).json()).resourceId).toBe(alpha) - before = await say(o2.page, 'OWNER-C2') - const c2Card = o2.page.getByTestId('proposal-card').nth(before) - await expect(c2Card.getByTestId('proposal-database')).toHaveText(beta, { timeout: 40_000 }) - await c2Card.getByTestId('proposal-confirm').click() - await expect(c2Card.getByTestId('proposal-state')).toHaveText('Applied', { timeout: 25_000 }) - expect(await rowsIn(beta)).toContain('OWNER-C2') - expect(await rowsIn(alpha)).not.toContain('OWNER-C2') - - // Clearing is per person and conversation too. - const again = await openPicker(o.page) - await expect(again.getByTestId('tbl-selected')).toContainText('Alpha notes', { timeout: 25_000 }) - await again.getByRole('button', { name: 'Clear selection' }).click() - await expect(again.getByTestId('tbl-none-selected')).toBeVisible({ timeout: 25_000 }) - expect((await sel(member, 'c1')).status()).toBe(404) - expect((await (await sel(member, 'c2')).json()).resourceId).toBe(beta) - expect((await (await sel(editorApi, 'c1')).json()).resourceId).toBe(beta) - // With nothing selected a call runs nothing: no card, no proposal. - await again.getByRole('button', { name: 'Close' }).click() - const cardsBefore = await o.page.getByTestId('proposal-card').count() - const pendingBefore = (await (await member.get('/api/addons/proposals?state=pending')).json()).proposals.length - await say(o.page, 'NOWHERE') - await expect(o.page.getByText('The change is waiting for your confirmation.')).toHaveCount(2, { timeout: 40_000 }) - expect(await o.page.getByTestId('proposal-card').count()).toBe(cardsBefore) - expect((await (await member.get('/api/addons/proposals?state=pending')).json()).proposals.length).toBe(pendingBefore) - expect(await rowsIn(alpha)).not.toContain('NOWHERE') - expect(await rowsIn(beta)).not.toContain('NOWHERE') - // A database that is no longer shared is no longer selected: the stored - // choice is refused like an unknown one. - const revoke = await pageFetch(own.page, `/api/addons/resources/${beta}/grants/${creds.users.editor.id}`, { method: 'DELETE', headers: { 'X-LocalAI-Confirm': beta } }) - expect(revoke.status).toBe(200) - expect((await sel(editorApi, 'c1')).status()).toBe(404) - await editorApi.dispose() - await o.context.close() - await o2.context.close() - await e.context.close() - await own.context.close() - }) - test('tables-ui: the pages work by keyboard, at 400 pixels and in the light and dark themes', async ({ browser }) => { const own = await browserAs(browser, 'member', { viewport: { width: 400, height: 800 } }) const p = own.page diff --git a/core/http/react-ui/e2e/taste-polish.spec.js b/core/http/react-ui/e2e/taste-polish.spec.js index 5ad94052b..31e95bd59 100644 --- a/core/http/react-ui/e2e/taste-polish.spec.js +++ b/core/http/react-ui/e2e/taste-polish.spec.js @@ -1,6 +1,5 @@ import { test, expect } from './coverage-fixtures.js' import { mockLedger, mockPlacement } from './ledger-fixtures.js' -import { mockLibrary } from './library-fixtures.js' // Selected rows, status strips and empty states keep to the taste guide: // no coloured left rail on a selectable or card-like element, and an empty @@ -53,14 +52,6 @@ test('an install in progress is a plain strip with no status rail', async ({ pag expect(await leftBorder(strip)).toBe(0) }) -test('the skill editor marks its current section without a left rail', async ({ page }) => { - await mockLibrary(page) - await page.goto('/app/skills/new') - const current = page.locator('.skilledit-sidebar-item.active').first() - await expect(current).toBeVisible({ timeout: 15_000 }) - expect(await leftBorder(current)).toBe(0) -}) - test.describe('Add Model', () => { test('the templates are a list of rows, and choosing one moves on to its fields', async ({ page }) => { await mockLedger(page) @@ -80,8 +71,7 @@ test.describe('Add Model', () => { test.describe('Hub pages', () => { for (const [name, path, setup] of [ - ['Build', '/app/build', async page => { const { mockMachine } = await import('./tools-fixtures.js'); await mockMachine(page) }], - ['Agents', '/app/agents', async page => { const { mockAgents } = await import('./agents-fixtures.js'); await mockAgents(page) }], + ['Administration', '/app/settings', async () => {}], ]) { test(`${name}: the page title starts at the same edge as the first tab`, async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }) @@ -112,16 +102,3 @@ test('the Studio voice field shows its whole placeholder and stays inside a phon if (width === 1440) expect(box.width).toBeGreaterThan(200) } }) - -test('on a phone the job schedule line wraps instead of ending in an ellipsis', async ({ page }) => { - const { mockJobs } = await import('./jobs-fixtures.js') - await page.setViewportSize({ width: 390, height: 844 }) - await mockJobs(page) - await page.goto('/app/agent-jobs') - const line = page.locator('.aj-phone-line').first() - await expect(line).toBeVisible({ timeout: 15_000 }) - const style = await line.evaluate(el => getComputedStyle(el)) - expect(style.textOverflow).not.toBe('ellipsis') - expect(style.whiteSpace).not.toBe('nowrap') - expect(await line.evaluate(el => el.scrollWidth <= el.clientWidth + 1)).toBe(true) -}) diff --git a/core/http/react-ui/e2e/telegram-addon.spec.js b/core/http/react-ui/e2e/telegram-addon.spec.js new file mode 100644 index 000000000..866ff91a9 --- /dev/null +++ b/core/http/react-ui/e2e/telegram-addon.spec.js @@ -0,0 +1,169 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockChat } from './chat-fixtures.js' +import { installStudioAddons } from './studio-addon.js' +import { connectorApi as api } from './connector-fixtures.js' + +// The page of the Telegram add-on (the real built bundle, inside the add-on +// shell) with the core API stubbed: the token only goes in, "anyone" +// needs a confirmation, and a sender in the log can be allowed. + +const PAGE = '/app/addons/telegram/bots' + +test.describe('Telegram add-on', () => { + test.beforeEach(async ({ page }) => { + await mockChat(page) + await installStudioAddons(page, { ids: ['telegram'] }) + }) + + test('creates a binding with a token and an agent, and lists who it accepts', async ({ page }) => { + const a = api(page) + await a.install() + await page.goto(PAGE) + + await expect(page.getByText('No Telegram bots yet.')).toBeVisible() + await page.getByRole('button', { name: 'New Telegram bot' }).click() + await expect(page.getByRole('option', { name: 'No model (needs a model)' })).toHaveAttribute('disabled', '') + await page.getByLabel('Name', { exact: true }).fill('Support bot') + await page.getByLabel('Bot token').fill('555:secret-token') + await page.getByLabel('Agent', { exact: true }).selectOption('a1') + await page.getByRole('textbox', { name: 'Allowed senders +' }).fill('42') + await page.getByRole('textbox', { name: 'Allowed senders +' }).press('Enter') + await page.getByRole('button', { name: 'Save' }).click() + + await expect(page.getByTestId('connector-row')).toContainText('Support bot') + await expect(page.getByTestId('connector-row')).toContainText('Agent: Support') + const body = a.s.calls.find(c => c.method === 'POST' && c.url === '/api/chats/connectors').body + expect(body).toMatchObject({ name: 'Support bot', token: '555:secret-token', agent_id: 'a1', allow: ['42'], accept_any: false }) + await expect(page.locator('body')).not.toContainText('secret-token') + // A new token is checked as soon as it is saved. + await expect(page.getByText('Connected to @support_bot.')).toBeVisible() + }) + + test('the token field is empty on edit, and an update without a new token sends none', async ({ page }) => { + const a = api(page, [{ id: 'b1', name: 'Support bot', kind: 'telegram', agent_id: 'a1', enabled: true, accept_any: false, allow: ['42'], has_token: true }]) + await a.install() + await page.goto(PAGE) + await page.getByRole('button', { name: 'Edit' }).click() + await expect(page.getByLabel('Bot token')).toHaveValue('') + await expect(page.getByLabel('Bot token')).toHaveAttribute('placeholder', /Saved/) + await page.getByLabel('Name', { exact: true }).fill('Renamed') + await page.getByRole('button', { name: 'Save' }).click() + const put = a.s.calls.find(c => c.method === 'PUT') + expect(put.url).toBe('/api/chats/connectors/b1') + expect('token' in put.body).toBe(false) + }) + + test('anyone needs the warning confirmed before it can be saved', async ({ page }) => { + await api(page).install() + await page.goto(PAGE) + await page.getByRole('button', { name: 'New Telegram bot' }).click() + await page.getByLabel('Name', { exact: true }).fill('Public bot') + await page.getByLabel('Bot token').fill('1:x') + await page.getByLabel('Agent', { exact: true }).selectOption('a1') + await page.getByRole('radio', { name: 'Anyone who can message the bot' }).check() + await expect(page.getByTestId('anyone-warning')).toContainText("can use this agent's tools") + await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() + await page.getByRole('checkbox', { name: 'I understand' }).check() + await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled() + }) + + test('tests the token, shows the sender log and allows a sender', async ({ page }) => { + const a = api(page, [{ id: 'b1', name: 'Support bot', kind: 'telegram', agent_id: 'a1', enabled: true, accept_any: false, allow: [], has_token: true }]) + await a.install() + await page.goto(PAGE) + await page.getByRole('button', { name: 'Test' }).click() + await expect(page.getByText('Connected to @support_bot.')).toBeVisible() + await page.getByRole('button', { name: 'Senders' }).click() + await expect(page.getByTestId('sender-log')).toContainText('Newcomer · 2 messages · 42') + await expect(page.getByTestId('sender-log')).toContainText('Ignored') + await page.getByRole('button', { name: 'Allow' }).click() + await expect(page.getByTestId('sender-log')).toContainText('Accepted') + expect(a.s.calls.some(c => c.url === '/api/chats/connectors/b1/senders/42/allow')).toBe(true) + }) + + test('switches a binding off and deletes it', async ({ page }) => { + const a = api(page, [{ id: 'b1', name: 'Support bot', kind: 'telegram', agent_id: 'a1', enabled: true, accept_any: false, allow: [], has_token: true }]) + await a.install() + await page.goto(PAGE) + await page.getByRole('switch', { name: 'Support bot on' }).uncheck() + await expect.poll(() => a.s.calls.some(c => c.url.endsWith('/disable'))).toBe(true) + // Deleting asks first, and says the bot token goes with it. + await page.getByTestId('connector-row').getByRole('button', { name: 'Delete' }).click() + const dialog = page.getByRole('alertdialog') + await expect(dialog).toContainText('Delete Support bot?') + await expect(dialog).toContainText('bot token') + await dialog.getByRole('button', { name: 'Cancel' }).click() + await expect(page.getByTestId('connector-row')).toHaveCount(1) + expect(a.s.calls.some(c => c.method === 'DELETE')).toBe(false) + + await page.getByTestId('connector-row').getByRole('button', { name: 'Delete' }).click() + await dialog.getByRole('button', { name: 'Delete' }).click() + await expect(page.getByTestId('connector-row')).toHaveCount(0) + }) + + test('a delete the server refuses shows its reason and keeps the binding', async ({ page }) => { + const a = api(page, [{ id: 'b1', name: 'Support bot', kind: 'telegram', agent_id: 'a1', enabled: true, accept_any: false, allow: [], has_token: true }]) + await a.install() + await page.route(/\/api\/chats\/connectors\/b1$/, r => r.fulfill({ status: 500, json: { error: { message: 'the store is read-only' } } })) + await page.goto(PAGE) + await page.getByTestId('connector-row').getByRole('button', { name: 'Delete' }).click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click() + await expect(page.getByRole('alert')).toContainText('the store is read-only') + await expect(page.getByTestId('connector-row')).toHaveCount(1) + }) + + test('sets how the bot answers, and the API keeps what was set at each step', async ({ page }) => { + const a = api(page) + await a.install() + await page.goto(PAGE) + await page.getByRole('button', { name: 'New Telegram bot' }).click() + // A new binding shows progress, one chat per person, and sends files. + await expect(page.getByTestId('connector-reply')).toHaveValue('progress') + await expect(page.getByTestId('connector-conversation')).toHaveValue('sender') + await expect(page.getByTestId('connector-files')).toBeChecked() + await expect(page.getByTestId('connector-window')).toHaveCount(0) + await page.getByLabel('Name', { exact: true }).fill('Live bot') + await page.getByLabel('Bot token').fill('555:secret-token') + await page.getByLabel('Agent', { exact: true }).selectOption('a1') + await page.getByTestId('connector-reply').selectOption('stream') + await page.getByTestId('connector-conversation').selectOption('window') + await page.getByTestId('connector-window').fill('45') + await page.getByTestId('connector-files').uncheck() + await page.getByRole('button', { name: 'Save' }).click() + + await expect(page.getByTestId('connector-answer-summary')).toHaveText('Answers: live answer · new chat after 45 min') + const post = a.s.calls.find(c => c.method === 'POST' && c.url === '/api/chats/connectors').body + expect(post).toMatchObject({ reply_mode: 'stream', conversation: 'window', window_minutes: 45, send_files: false }) + expect(a.s.items[0]).toMatchObject({ reply_mode: 'stream', conversation: 'window', window_minutes: 45, send_files: false }) + + // Edit: the form shows what the API holds; the thread mode drops the window. + await page.getByRole('button', { name: 'Edit' }).click() + await expect(page.getByTestId('connector-reply')).toHaveValue('stream') + await expect(page.getByTestId('connector-conversation')).toHaveValue('window') + await expect(page.getByTestId('connector-window')).toHaveValue('45') + await expect(page.getByTestId('connector-files')).not.toBeChecked() + await page.getByTestId('connector-reply').selectOption('off') + await page.getByTestId('connector-conversation').selectOption('thread') + await page.getByTestId('connector-files').check() + await page.getByRole('button', { name: 'Save' }).click() + await expect(page.getByTestId('connector-answer-summary')).toHaveText('Answers: typing · a chat per message') + const put = a.s.calls.filter(c => c.method === 'PUT').at(-1).body + expect(put).toMatchObject({ reply_mode: 'off', conversation: 'thread', send_files: true }) + expect(put).not.toHaveProperty('window_minutes') + expect(a.s.items[0]).toMatchObject({ reply_mode: 'off', conversation: 'thread', send_files: true }) + }) + + test('a window out of range cannot be saved', async ({ page }) => { + await api(page).install() + await page.goto(PAGE) + await page.getByRole('button', { name: 'New Telegram bot' }).click() + await page.getByLabel('Name', { exact: true }).fill('Window bot') + await page.getByLabel('Bot token').fill('1:x') + await page.getByLabel('Agent', { exact: true }).selectOption('a1') + await page.getByTestId('connector-conversation').selectOption('window') + await page.getByTestId('connector-window').fill('0') + await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled() + await page.getByTestId('connector-window').fill('30') + await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled() + }) +}) diff --git a/core/http/react-ui/e2e/threed-gen.spec.js b/core/http/react-ui/e2e/threed-gen.spec.js index 9fdcbc92f..c5e4408f3 100644 --- a/core/http/react-ui/e2e/threed-gen.spec.js +++ b/core/http/react-ui/e2e/threed-gen.spec.js @@ -1,3 +1,4 @@ +import { truncateSync, writeFileSync } from 'node:fs' import { test, expect } from './coverage-fixtures.js' import { installStudioAddons } from './studio-addon.js' @@ -333,17 +334,20 @@ test.describe('3D generation', () => { await expect(page.locator('.media-result')).toContainText(/missing files|error/i, { timeout: 15_000 }) }) - test('rejects oversized conditioning images before making an API request', async ({ page }) => { + test('rejects oversized conditioning images before making an API request', async ({ page }, testInfo) => { let requested = false await mockGeneration(page, () => { requested = true }) await page.goto('/app/3d') await expect(page.getByRole('button', { name: 'trellis-test-model' })).toBeVisible({ timeout: 10_000 }) - await page.locator('#threed-image-file').setInputFiles({ - name: 'oversized.png', - mimeType: 'image/png', - buffer: Buffer.alloc(32 * 1024 * 1024 + 1), - }) + // A file on disk, not an in-memory buffer: Playwright sends a buffer to + // the browser over the protocol, and 32 MiB of it took most of the 30 s + // test timeout on a loaded CI runner. A path is handed to the local browser + // as is, and the sparse file costs no disk. + const oversized = testInfo.outputPath('oversized.png') + writeFileSync(oversized, '') + truncateSync(oversized, 32 * 1024 * 1024 + 1) + await page.locator('#threed-image-file').setInputFiles(oversized) await expect(page.locator('.toast')).toContainText('32 MiB limit') expect(requested).toBe(false) diff --git a/core/http/react-ui/e2e/ui-primitives.spec.js b/core/http/react-ui/e2e/ui-primitives.spec.js new file mode 100644 index 000000000..a7bc76bed --- /dev/null +++ b/core/http/react-ui/e2e/ui-primitives.spec.js @@ -0,0 +1,316 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockChat } from './chat-fixtures.js' + +// Visual contracts of the UI primitives, read from computed styles on the +// catalog page (/app/ui-catalog), which draws every primitive in every state. +// Set UI_SHOTS_DIR to also save screenshots of the catalog in both themes at +// desktop and phone sizes. + +const CATALOG = '/app/ui-catalog' +const SHOTS = process.env.UI_SHOTS_DIR + +async function openCatalog(page, { theme = 'light' } = {}) { + await page.addInitScript((t) => localStorage.setItem('localai-theme', t), theme) + await page.goto(CATALOG) + await expect(page.getByTestId('cat-section-buttons')).toBeVisible({ timeout: 15_000 }) + await page.evaluate(() => document.fonts.ready) +} + +// WCAG contrast of two computed colours (rgb() or color(srgb …)). +function contrastOf(fg, bg) { + const parse = (c) => { + const n = c.match(/[\d.]+/g).map(Number) + return c.startsWith('color(') ? n.slice(0, 3).map(v => v * 255) : n.slice(0, 3) + } + const lum = (rgb) => { + const [r, g, b] = rgb.map(v => { const s = v / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4 }) + return 0.2126 * r + 0.7152 * g + 0.0722 * b + } + const [a, b] = [lum(parse(fg)), lum(parse(bg))] + return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) +} + +test.describe('UI primitives', () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + }) + + test('buttons: one size scale for the component and the legacy classes', async ({ page }) => { + await openCatalog(page) + const box = async (id) => (await page.getByTestId(id).boundingBox()).height + expect(await box('btn-primary')).toBe(36) + expect(await box('btn-primary-sm')).toBe(28) + expect(await box('legacy-primary')).toBe(36) + expect(await box('legacy-sm')).toBe(28) + const style = (id) => page.getByTestId(id).evaluate(el => { + const cs = getComputedStyle(el) + return { font: cs.fontFamily, size: cs.fontSize, weight: cs.fontWeight, bg: cs.backgroundColor, shadow: cs.boxShadow, radius: cs.borderTopLeftRadius } + }) + const kit = await style('btn-primary') + const legacy = await style('legacy-primary') + expect(kit.font).toMatch(/^"Geist Variable"/) + expect(kit).toEqual(legacy) + expect(kit.size).toBe('13px') + expect(kit.weight).toBe('500') + expect(kit.radius).toBe('8px') + // Secondary: card fill and a 1px edge; the legacy class matches it. + const sec = await style('btn-secondary') + expect(sec.shadow).toMatch(/0px 0px 0px 1px inset/) + expect(await style('legacy-secondary')).toEqual(sec) + expect(await style('legacy-plain')).toEqual(sec) + expect((await style('btn-secondary-sm')).size).toBe('12px') + }) + + test('buttons: busy keeps the width and says so; focus shows the ring', async ({ page }) => { + await openCatalog(page) + const busy = page.getByTestId('btn-secondary-busy') + await expect(busy).toHaveAttribute('aria-busy', 'true') + const widths = await busy.evaluate(el => { + const probe = document.createElement('button') + probe.className = 'dk-btn dk-btn--secondary' + probe.innerHTML = 'Saving' + el.parentElement.appendChild(probe) + const w = [el.getBoundingClientRect().width, probe.getBoundingClientRect().width] + probe.remove() + return w + }) + expect(widths[0]).toBeCloseTo(widths[1], 0) + + const primary = page.getByTestId('btn-primary') + await primary.focus() + await page.keyboard.press('Shift+Tab') + await page.keyboard.press('Tab') + await expect(primary).toBeFocused() + const ring = await primary.evaluate(el => { + const cs = getComputedStyle(el) + return { style: cs.outlineStyle, width: cs.outlineWidth, offset: cs.outlineOffset, color: cs.outlineColor } + }) + expect(ring).toMatchObject({ style: 'solid', width: '2px', offset: '2px' }) + expect(ring.color).not.toMatch(/rgba\(0, 0, 0, 0\)|transparent/) + }) + + test('switch: a named role=switch that Space toggles and a disabled one that does not', async ({ page }) => { + await openCatalog(page) + const first = page.getByRole('switch', { name: 'Sample switch' }) + await expect(first).toHaveAttribute('aria-checked', 'true') + await first.focus() + await page.keyboard.press('Space') + await expect(first).toHaveAttribute('aria-checked', 'false') + await expect(page.getByRole('switch', { name: 'Opposite switch' })).toHaveAttribute('aria-checked', 'true') + await expect(page.getByRole('switch', { name: 'Disabled off' })).toBeDisabled() + expect((await first.boundingBox()).height).toBe(26) + }) + + for (const theme of ['light', 'dark']) { + test(`status chips: words, an icon and 4.5:1 text in ${theme}`, async ({ page }) => { + await openCatalog(page, { theme }) + const chips = page.getByTestId('chips').locator('.ui-chip') + expect(await chips.count()).toBe(7) + const read = await chips.evaluateAll(els => els.map(el => { + const cs = getComputedStyle(el) + return { text: el.textContent, icon: !!el.querySelector('.ui-chip__icon'), fg: cs.color, bg: cs.backgroundColor, h: el.getBoundingClientRect().height } + })) + expect(read.map(c => c.text)).toEqual(['Running', 'Ready', 'Starting', 'Stopped', 'Failed', 'Update available', 'No runs yet']) + for (const c of read) { + expect(c.icon).toBe(true) + expect(c.h).toBe(22) + expect(contrastOf(c.fg, c.bg), `${c.text} ${c.fg} on ${c.bg}`).toBeGreaterThanOrEqual(4.5) + } + }) + } + + test('the old StatusPill is a chip and keeps its classes and words', async ({ page }) => { + await openCatalog(page) + const pill = page.getByTestId('legacy-pill').locator('.status-pill') + await expect(pill).toHaveText('degraded') + await expect(pill).toHaveClass(/status-pill--error/) + await expect(pill).toHaveClass(/ui-chip--error/) + const alias = page.locator('.ui-chip[data-status="healthy"]') + await expect(alias).toHaveText('healthy (alias)') + await expect(alias).toHaveClass(/ui-chip--ok/) + }) + + test('load error: inline, with the server message, and Try again works', async ({ page }) => { + await openCatalog(page) + const error = page.getByTestId('cat-load-error') + await expect(error).toHaveAttribute('role', 'alert') + await expect(error).toContainText('Could not load your MCP servers.') + await expect(error).toContainText('“database is locked”') + await expect(error).toContainText('Nothing was changed.') + await expect(page.locator('.toast')).toHaveCount(0) + const retry = error.getByRole('button', { name: 'Try again' }) + await retry.click() + await expect(retry).toHaveAttribute('aria-busy', 'true') + await expect(page.getByTestId('cat-load-ready')).toContainText('After 1 retry.') + await expect(error).toHaveCount(0) + }) + + test('empty state: a left-aligned dashed outline with the action on the right', async ({ page }) => { + await openCatalog(page) + const empty = page.getByTestId('cat-empty-first-run') + const cs = await empty.evaluate(el => ({ border: getComputedStyle(el).borderTopStyle, align: getComputedStyle(el).textAlign, title: getComputedStyle(el.querySelector('.ui-empty__title')).fontSize })) + expect(cs).toEqual({ border: 'dashed', align: 'left', title: '17px' }) + const text = await empty.locator('.ui-empty__text').boundingBox() + const action = await empty.getByRole('button', { name: 'Add MCP server' }).boundingBox() + expect(action.x).toBeGreaterThan(text.x + text.width - 1) + }) + + test('toast: an inverse surface, Undo runs its action and closes the toast', async ({ page }) => { + await openCatalog(page) + await page.getByTestId('toast-undo').click() + const toast = page.getByTestId('cat-undo-toast') + await expect(toast).toBeVisible() + await expect(toast).toHaveAttribute('data-state', 'open') + const bg = await toast.evaluate(el => getComputedStyle(el).backgroundColor) + const inverse = await page.evaluate(() => { + const p = document.createElement('i'); p.style.color = 'var(--dk-inverse)'; document.body.appendChild(p) + const c = getComputedStyle(p).color; p.remove(); return c + }) + expect(bg).toBe(inverse) + await expect(toast.locator('.toast__bar')).toHaveCount(1) + // Bottom centre, 24 px from the edge, once the enter transition ends. + await expect.poll(async () => { const b = await toast.boundingBox(); return Math.round(900 - (b.y + b.height)) }).toBe(24) + const box = await toast.boundingBox() + expect(box.x + box.width / 2).toBeCloseTo(720, 0) + await toast.getByRole('button', { name: 'Undo' }).click() + await expect(page.getByTestId('undo-count')).toHaveText('Undo clicked: 1') + await expect(toast).toHaveCount(0) + }) + + test('toast: at most three at once', async ({ page }) => { + await openCatalog(page) + for (let i = 0; i < 5; i++) await page.getByRole('button', { name: 'Error' }).click() + await expect(page.locator('.toast:not([data-state="exit"])')).toHaveCount(3) + await expect(page.locator('.toast')).toHaveCount(3) + }) + + test('toast: the stack rises above a bar fixed at the bottom', async ({ page }) => { + await openCatalog(page) + await page.getByTestId('toast-bar').click() + const bar = await page.getByTestId('cat-bottom-bar').boundingBox() + await page.getByTestId('toast-info').click() + const toast = page.locator('.toast').last() + await expect(toast).toHaveAttribute('data-state', 'open') + await expect.poll(async () => { + const b = await toast.boundingBox() + return Math.round(bar.y - (b.y + b.height)) + }).toBe(24) + }) + + test('toast: in an open chat the stack is lifted above the composer', async ({ page }) => { + await mockChat(page) + const detail = { id: 'c1', title: 'Quotes', model: 'qwen3-8b', kind: 'chat', created: '2026-10-25T10:00:00Z', updated: '2026-10-25T10:00:00Z', running: false, last_turn: '', pending: [] } + await page.route('**/api/chats/c1/events', route => route.abort()) + await page.route(/\/api\/chats\/c1\/messages(\?.*)?$/, route => route.fulfill({ json: { messages: [ + { seq: 1, turn: 1, role: 'user', text: 'hello', at: '2026-10-25T10:00:00Z' }, + { seq: 2, turn: 1, role: 'assistant', text: 'Hi. What do you need?', at: '2026-10-25T10:00:01Z' }, + ] } })) + await page.route('**/api/chats/c1', route => route.fulfill({ json: detail })) + await page.route(/\/api\/chats(\?.*)?$/, route => route.fulfill({ json: { conversations: [detail], persistent: true, files: true, files_reason: '' } })) + await page.goto('/app/chats/c1') + await expect(page.getByText('Hi. What do you need?')).toBeVisible({ timeout: 15_000 }) + // The chat marks what the stack stays above with data-toast-anchor: the + // dock that holds the notices, the shortcuts and the composer. + const composer = page.locator('[data-toast-anchor]').first() + await expect(composer).toBeVisible() + // The stack clears the whole dock, the shortcut row over the composer too. + const dock = page.locator('.cx-dock__in') + const read = () => dock.evaluate(el => ({ + lift: parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--toast-lift')) || 0, + top: el.getBoundingClientRect().top, + vh: window.innerHeight, + stack: parseFloat(getComputedStyle(document.querySelector('.toast-container')).bottom), + })) + // The composer sits at the bottom of an open chat, so the stack clears it. + await expect.poll(async () => { const r = await read(); return r.lift > 0 && Math.abs(r.lift - (r.vh - r.top)) < 2 }).toBe(true) + const r = await read() + expect(r.vh - r.stack).toBeLessThanOrEqual(r.top - 20) + }) + + test('side pane: docked on a wide screen, Escape closes it and returns focus', async ({ page }) => { + await openCatalog(page) + const trigger = page.getByTestId('open-pane-Tables') + await trigger.click() + const pane = page.getByTestId('cat-pane') + await expect(pane).toHaveAttribute('data-state', 'open') + await expect(pane).toHaveAttribute('role', 'complementary') + await expect(page.locator('.ui-pane-scrim')).toHaveCount(0) + expect((await pane.boundingBox()).width).toBeCloseTo(360, 0) + await expect(pane.getByRole('heading', { name: 'Tables' })).toBeVisible() + await page.keyboard.press('Escape') + await expect(pane).toHaveCount(0) + await expect(trigger).toBeFocused() + }) + + test('side pane: an overlay with a scrim below 1280 px', async ({ page }) => { + await page.setViewportSize({ width: 1024, height: 800 }) + await openCatalog(page) + await page.getByTestId('open-pane-Tables').click() + const pane = page.getByTestId('cat-pane') + await expect(pane).toHaveAttribute('role', 'dialog') + await expect(page.locator('.ui-pane-scrim')).toBeVisible() + // Flush with the right edge once it has slid in. + await expect.poll(async () => { const b = await pane.boundingBox(); return Math.round(b.x + b.width) }).toBe(1024) + await page.locator('.ui-pane-scrim').click({ position: { x: 20, y: 400 } }) + await expect(pane).toHaveCount(0) + }) + + test('cold start draws the app shell, not a blank screen', async ({ page }) => { + let release + const held = new Promise(r => { release = r }) + await page.route('**/locales/**', async route => { await held; await route.continue() }) + await page.goto('/app', { waitUntil: 'commit' }) + const shell = page.getByTestId('shell-fallback') + await expect(shell).toBeVisible({ timeout: 15_000 }) + const side = await shell.locator('.app-shell-fallback__side').boundingBox() + expect(side.width).toBe(240) + release() + await expect(shell).toHaveCount(0, { timeout: 15_000 }) + }) + + test('reduced motion: skeletons stand still, toasts and panes switch at once', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + await openCatalog(page) + const sweep = await page.locator('.skeleton').first().evaluate(el => getComputedStyle(el, '::after').animationName) + expect(sweep).toBe('none') + await page.getByTestId('toast-undo').click() + const toast = page.getByTestId('cat-undo-toast') + const t = await toast.evaluate(el => ({ + duration: Math.max(...getComputedStyle(el).transitionDuration.split(',').map(parseFloat)), + bar: getComputedStyle(el.querySelector('.toast__bar')).animationName, + })) + expect(t.duration).toBeLessThan(0.001) + // The Undo window line stays full instead of running. + expect(t.bar).toBe('none') + }) + + test.describe('screenshots', () => { + test.skip(!SHOTS, 'set UI_SHOTS_DIR to save the catalog screenshots') + for (const theme of ['light', 'dark']) { + for (const [name, size] of [['desk', { width: 1440, height: 900 }], ['mob', { width: 390, height: 844 }]]) { + test(`catalog ${name} ${theme}`, async ({ page }) => { + await page.setViewportSize(size) + await openCatalog(page, { theme }) + await page.waitForTimeout(400) + await page.screenshot({ path: `${SHOTS}/catalog-${name}-${theme}.png`, fullPage: true }) + for (const id of ['buttons', 'header', 'chips', 'callouts', 'load-error', 'empty', 'loading']) { + await page.getByTestId(`cat-section-${id}`).screenshot({ path: `${SHOTS}/section-${id}-${name}-${theme}.png` }) + } + // The states that need a click: busy retry, toasts, the open pane. + await page.getByTestId('toast-undo').click() + await page.getByRole('button', { name: 'Error' }).click() + await page.getByTestId('cat-section-toasts').scrollIntoViewIfNeeded() + await page.waitForTimeout(400) + await page.screenshot({ path: `${SHOTS}/toasts-${name}-${theme}.png` }) + await page.getByTestId('open-pane-Tables').click() + await page.waitForTimeout(500) + await page.screenshot({ path: `${SHOTS}/pane-${name}-${theme}.png` }) + await page.keyboard.press('Escape') + await page.getByTestId('cat-load-error').getByRole('button', { name: 'Try again' }).click() + await page.getByTestId('cat-load-error').scrollIntoViewIfNeeded() + await page.screenshot({ path: `${SHOTS}/load-error-busy-${name}-${theme}.png` }) + }) + } + } + }) +}) diff --git a/core/http/react-ui/e2e/ui-quality.spec.js b/core/http/react-ui/e2e/ui-quality.spec.js new file mode 100644 index 000000000..bb2977cc9 --- /dev/null +++ b/core/http/react-ui/e2e/ui-quality.spec.js @@ -0,0 +1,177 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockHome } from './home-fixtures.js' +import { offerMockModel, watchProblems, composer, openNewChat } from './journey-fixtures.js' + +// Cross-cutting quality of the pages on the real test server: a quiet console, +// no sideways scroll on a phone, the redirects of the removed pages, the theme, +// the language and the keyboard. Selectors are roles and visible text. + +// Pages of the shell. The test server runs no add-on host and is not +// distributed, so these two answers are expected and do not count. +const EXPECTED = [ + /Failed to load resource/, // the browser line has no address; the response check below names it + /\/api\/addons\//, /\/api\/nodes/, /\/api\/collections/, /\/api\/(fine-tuning|quantization)\//, + /notifications\/events/, /\/backends\/known/, + // The sidebar notes the first day it labels Pages as new in the seen record + // of user.json, from the version it read. Every spec of the run shares the + // test server's one user, so another tab can write first: the save answers + // 409 and useSeen reads the file again and retries. A conflict it resolves + // is not a problem of the page. + /^409 \/api\/chats\/wellknown\/user$/, +] + +const ROUTES = [ + '/app', '/app/chats/all', '/app/chats/skills', '/app/chats/agents', '/app/chats/triggers', '/app/chats/connectors', + '/app/chats/conversion', '/app/files', '/app/collections', '/app/build', '/app/operate', '/app/models', + '/app/settings', '/app/usage', '/app/account', '/app/activity', '/app/traces', '/app/backends', +] + +const PHONE = { width: 390, height: 844 } + +test.describe('Every page of the shell', () => { + for (const route of ROUTES) { + test(`${route} loads quietly and fits a phone`, async ({ page }) => { + await page.setViewportSize(PHONE) + await offerMockModel(page) + const watch = watchProblems(page, { ignore: EXPECTED }) + await page.goto(route) + await expect(page.getByRole('heading', { level: 1 }).or(page.getByRole('heading', { level: 2 })).first()).toBeVisible({ timeout: 20_000 }) + // Let the lazy chunks and first reads settle before judging. + await page.evaluate(() => new Promise(r => setTimeout(r, 750))) + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + expect(overflow).toBeLessThanOrEqual(1) + expect(watch.problems()).toEqual([]) + // No translation key shows as text. + const body = await page.locator('body').innerText() + expect(body).not.toMatch(/\b(?:errors|composer|list|attach|pending|skillsPage|agentsPage|triggersPage|connectorsPage|mcpPage|confirmDelete)\.[a-zA-Z]+\b/) + }) + } +}) + +test.describe('Removed pages', () => { + const MAP = [ + ['/app/chat', /\/app$/], + ['/app/chat/some-model', /\/app\?model=some-model$/], + ['/app/chats', /\/app$/], + ['/app/chats?model=some-model', /\/app\?model=some-model$/], + ['/app/chats/agents', /\/app\/agents$/], + ['/app/chats/skills', /\/app\/skills$/], + ['/app/chats/triggers', /\/app\/triggers$/], + ['/app/agent-jobs', /\/app\/triggers$/], + ] + for (const [from, to] of MAP) { + test(`${from} opens its current page`, async ({ page }) => { + await page.goto(from) + await expect(page).toHaveURL(to) + }) + } + test('an address that never existed shows the not-found page, not a blank one', async ({ page }) => { + await page.goto('/app/no-such-page') + await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + }) +}) + +test.describe('Theme and language', () => { + test('the theme choice sticks across a reload', async ({ page }) => { + await page.goto('/app') + await page.getByRole('button', { name: /Switch to (dark|light) mode/ }).click() + const after = await page.evaluate(() => document.documentElement.getAttribute('data-theme') || document.documentElement.className) + await page.reload() + const again = await page.evaluate(() => document.documentElement.getAttribute('data-theme') || document.documentElement.className) + expect(again).toBe(after) + }) + + test('switching the language to Italian changes the sidebar and leaves no raw key behind', async ({ page }) => { + await page.goto('/app') + await page.getByRole('button', { name: 'Change language' }).click() + await page.getByRole('option', { name: /Italiano/ }).click() + await expect(page.getByRole('link', { name: 'File', exact: true })).toBeVisible() + for (const route of ['/app/chats/all', '/app/agents', '/app/files', '/app/skills']) { + await page.goto(route) + await expect(page.getByRole('heading').first()).toBeVisible() + const body = await page.locator('body').innerText() + expect(body, route).not.toMatch(/\b(?:errors|composer|list|attach|agentsPage|skillsPage|triggersPage)\.[a-zA-Z]+\b/) + } + }) + + test('a language the chats pages are not translated into shows English, not keys', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('i18nextLng', 'de')) + await offerMockModel(page) + await openNewChat(page) + await expect(composer(page)).toBeVisible() + await expect(page.getByTestId('chat-send')).toBeVisible() + }) +}) + +test.describe('Keyboard', () => { + test('Tab reaches the sidebar links and Enter follows one', async ({ page }) => { + await page.goto('/app') + const nav = page.getByRole('complementary', { name: 'Primary navigation' }) + await nav.getByRole('link', { name: 'All chats' }).focus() + await expect(nav.getByRole('link', { name: 'All chats' })).toBeFocused() + await page.keyboard.press('Enter') + await expect(page).toHaveURL(/\/app\/chats\/all$/) + }) + + test('on the chats page the composer takes keyboard input, Enter sends and Shift+Enter adds a line', async ({ page }) => { + await offerMockModel(page) + await openNewChat(page) + await composer(page).focus() + await page.keyboard.type('line one') + await page.keyboard.press('Shift+Enter') + await page.keyboard.type('line two') + await expect(composer(page)).toHaveValue('line one\nline two') + await page.keyboard.press('Enter') + await expect(page).toHaveURL(/\/app\/chats\/[0-9a-f]+$/) + }) + + test('the Home slash menu opens with / and closes with Escape', async ({ page }) => { + await mockHome(page) + await page.goto('/app') + const box = page.getByRole('combobox', { name: 'Message' }) // the Home command bar + await box.focus() + await page.keyboard.type('/') + await expect(page.getByRole('listbox', { name: 'Actions' })).toBeVisible() + await page.keyboard.press('Escape') + await expect(page.getByRole('listbox', { name: 'Actions' })).toBeHidden() + }) + + test('Home stays on screen and keeps the typing while its widgets load their strings', async ({ page }) => { + // A widget reads the pages strings, which load only when one first shows. + // Their load is held until the menu is open, so the composer is in use + // while it ends. + await mockHome(page) + const doc = (name, data) => ({ name, path: `/${name}.json`, exists: true, version: 1, data, valid: true, errors: [] }) + await page.route('**/api/chats/wellknown/home', route => route.fulfill({ json: doc('home', { widgets: [{ id: 'notes', type: 'list', title: 'Notes', source: 'data/w-notes.json' }] }) })) + await page.route('**/api/chats/wellknown/widget-data*', route => route.fulfill({ json: doc('widget-data', { items: [{ label: 'Buy stamps' }] }) })) + let release + const held = new Promise(resolve => { release = resolve }) + let asked = false + await page.route('**/locales/en/pages.json', async route => { asked = true; await held; await route.continue() }) + + await page.goto('/app') + await expect.poll(() => asked).toBe(true) + const box = page.getByRole('combobox', { name: 'Message' }) + await box.focus() + await page.keyboard.type('/') + await expect(page.getByRole('listbox', { name: 'Actions' })).toBeVisible() + release() + await expect(page.getByText('Buy stamps')).toBeVisible() + await expect(box).toBeFocused() + await expect(box).toHaveValue('/') + }) + + test('the delete dialog holds the focus and Escape cancels it', async ({ page }) => { + await page.goto('/app/skills') + const r = await page.request.post('/api/chats/skills', { data: { layer: 'personal', name: `kb-${Date.now().toString(36)}`, description: 'x', body: 'y' } }) + test.skip(!r.ok(), 'files are off on this server build') + await page.reload() + await page.getByRole('button', { name: 'Delete' }).first().click() + const dialog = page.getByRole('alertdialog') + await expect(dialog).toBeVisible() + await page.keyboard.press('Tab') + await expect(dialog.locator(':focus')).toHaveCount(1) + await page.keyboard.press('Escape') + await expect(dialog).toBeHidden() + }) +}) diff --git a/core/http/react-ui/e2e/update-review.spec.js b/core/http/react-ui/e2e/update-review.spec.js index a0e62b799..6aefd0a1b 100644 --- a/core/http/react-ui/e2e/update-review.spec.js +++ b/core/http/react-ui/e2e/update-review.spec.js @@ -1,9 +1,11 @@ import { test, expect } from './coverage-fixtures.js' import { ADMIN, app, installedAddon, sessionFor, mockAddons, ok, fail } from './addons-fixtures.js' -// The review step for add-on and backend upgrades: nothing runs until the -// administrator confirms in the sheet, one failure does not stop the others, -// and an add-on upgrade shows in Activity. +// What follows an add-on upgrade after it is confirmed: Check for updates +// reads every installed add-on, and an upgrade runs in the upgrade store, so it +// goes on when the page is left and shows in Activity. The confirmation and the +// "Upgrade all" dialog are covered by addons-upgrade-list.spec.js, and the +// backend dialog by backends-runtime.spec.js. const CANDIDATE = `sha256:${'b'.repeat(64)}` const admin = { canStart: false, canStop: true } @@ -31,75 +33,7 @@ function twoPending(over = {}) { } } -test.describe('Add-on updates: review sheet', () => { - test('the badge opens a list of pending updates; an incompatible one cannot be selected and nothing is sent until confirmed', async ({ page }) => { - await signIn(page) - const state = await mockAddons(page, twoPending()) - await page.goto('/app/addons') - - const badge = page.getByTestId('addons-updates') - await expect(badge).toContainText('Updates (3)') - await badge.click() - - const sheet = page.getByTestId('update-review') - const rows = sheet.getByTestId('update-review-row') - await expect(rows).toHaveCount(3) - const notes = rows.filter({ hasText: 'Notes' }) - await expect(notes.getByTestId('update-review-versions')).toContainText('1.2.0') - await expect(notes.getByTestId('update-review-versions')).toContainText('1.3.0') - const cal = rows.filter({ hasText: 'Calendar' }) - await expect(cal.getByRole('checkbox')).toBeDisabled() - await expect(cal.getByTestId('update-review-blocked')).toContainText('The host version does not fit') - - // Nothing is selected: "Upgrade selected" is off, and "Upgrade all" leaves the blocked one out. - await expect(sheet.getByTestId('update-review-selected')).toBeDisabled() - await expect(sheet.getByTestId('update-review-all')).toContainText('Upgrade all (2)') - expect(state.log.upgrade).toHaveLength(0) - - await notes.getByRole('checkbox').check() - await expect(sheet.getByTestId('update-review-selected')).toContainText('Upgrade selected (1)') - await sheet.getByTestId('update-review-selected').click() - await expect.poll(() => state.log.upgrade.length).toBe(1) - expect(state.log.upgrade[0].id).toBe('notes') - expect(state.log.upgrade[0].confirm).toBe(state.log.upgrade[0].operationId) - await expect(notes).toHaveAttribute('data-state', 'done') - await expect(sheet.getByTestId('update-review-summary')).toContainText('1 upgraded, 0 failed') - }) - - test('Upgrade all runs every upgradable add-on and continues past one that fails, then reports each', async ({ page }) => { - await signIn(page) - const state = await mockAddons(page, twoPending({ - upgrade: ({ id }) => (id === 'notes' ? fail(409, 'upgrade_failed') : null), - })) - await page.goto('/app/addons') - await page.getByTestId('addons-updates').click() - const sheet = page.getByTestId('update-review') - await sheet.getByTestId('update-review-all').click() - - await expect(sheet.getByTestId('update-review-summary')).toContainText('1 upgraded, 1 failed') - expect(state.log.upgrade.map(u => u.id)).toEqual(['notes', 'wiki']) - const rows = sheet.getByTestId('update-review-row') - await expect(rows.filter({ hasText: 'Notes' })).toHaveAttribute('data-state', 'failed') - await expect(rows.filter({ hasText: 'Notes' }).getByRole('alert')).toContainText('installed version is kept') - await expect(rows.filter({ hasText: 'Wiki' })).toHaveAttribute('data-state', 'done') - await expect(rows.filter({ hasText: 'Calendar' })).toHaveAttribute('data-state', 'idle') - // One toast per item. - await expect(page.getByText('Wiki upgraded to 1.0.0.')).toBeVisible() - await expect(page.getByText(/Notes was not upgraded/)).toBeVisible() - }) - - test('a card Upgrade action opens the sheet with that add-on checked', async ({ page }) => { - await signIn(page) - const state = await mockAddons(page, twoPending()) - await page.goto('/app/addons') - await page.locator('[data-testid="addon-tile"][data-app="wiki"]').getByTestId('tile-upgrade').click() - const sheet = page.getByTestId('update-review') - await expect(sheet.getByTestId('update-review-row').filter({ hasText: 'Wiki' }).getByRole('checkbox')).toBeChecked() - await expect(sheet.getByTestId('update-review-row').filter({ hasText: 'Notes' }).getByRole('checkbox')).not.toBeChecked() - expect(state.log.upgrade).toHaveLength(0) - await page.locator('[data-testid="addon-tile"][data-app="plain"]').getByTestId('tile-upgrade').waitFor({ state: 'detached' }) - }) - +test.describe('Add-on updates: after the confirmation', () => { test('Check for updates reads every installed add-on and keeps going when one cannot be checked', async ({ page }) => { await signIn(page) const state = await mockAddons(page, twoPending({ @@ -129,9 +63,8 @@ test.describe('Add-on updates: review sheet', () => { return route.fulfill(ok({ OperationID: body.operationId, AddonID: 'wiki', Phase: 'completed', Generation: a.generation })) }) await page.goto('/app/addons') - await page.locator('[data-testid="addon-tile"][data-app="wiki"]').getByTestId('tile-upgrade').click() - await page.getByTestId('update-review-selected').click() - await page.getByTestId('update-review-close').click() + await page.locator('[data-testid="addon-tile"][data-app="wiki"]').getByTestId('update-upgrade-card').click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Upgrade' }).click() // Leave the page without a reload: the upgrade goes on. await page.evaluate(() => { window.history.pushState({}, '', '/app/activity'); window.dispatchEvent(new PopStateEvent('popstate')) }) @@ -165,10 +98,9 @@ test.describe('Add-on updates: review sheet', () => { upgrade: () => { attempts += 1; return attempts === 1 ? fail(409, 'upgrade_failed') : null }, })) await page.goto('/app/addons') - await page.locator('[data-testid="addon-tile"][data-app="wiki"]').getByTestId('tile-upgrade').click() - await page.getByTestId('update-review-selected').click() - await expect(page.getByTestId('update-review-summary')).toContainText('0 upgraded, 1 failed') - await page.getByTestId('update-review-close').click() + await page.locator('[data-testid="addon-tile"][data-app="wiki"]').getByTestId('update-upgrade-card').click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Upgrade' }).click() + await expect.poll(() => state.log.upgrade.length).toBe(1) await page.evaluate(() => { window.history.pushState({}, '', '/app/activity'); window.dispatchEvent(new PopStateEvent('popstate')) }) const card = page.locator('.operation-card').filter({ hasText: 'Wiki' }) @@ -181,53 +113,3 @@ test.describe('Add-on updates: review sheet', () => { await expect(page.locator('.activity-row').filter({ hasText: 'Wiki' })).toContainText('Upgraded') }) }) - -test.describe('Backend updates: review sheet', () => { - const installedBackends = ['llama-cpp', 'whisper'].map(Name => ({ Name, IsSystem: false, Version: '1.0.0', Metadata: { version: '1.0.0' } })) - const catalog = installedBackends.map(b => ({ id: b.Name, name: b.Name, description: b.Name, installed: true, version: '1.0.0', isMeta: true, isAlias: false, isDevelopment: false, tags: [] })) - const upgrades = Object.fromEntries(installedBackends.map(b => [b.Name, { backend_name: b.Name, installed_version: '1.0.0', available_version: '1.1.0' }])) - - async function mock(page) { - await page.route('**/api/backends/upgrades', route => route.fulfill({ json: upgrades })) - await page.route('**/api/backends?*', route => route.fulfill({ json: { backends: catalog } })) - await page.route('**/backends', route => (new URL(route.request().url()).pathname === '/backends' ? route.fulfill({ json: installedBackends }) : route.continue())) - await page.route('**/api/nodes', route => route.fulfill({ json: [] })) - } - - test('Updates opens the sheet; nothing is upgraded until confirmed, and one failure does not stop the next', async ({ page }) => { - await mock(page) - const sent = [] - await page.route('**/api/backends/upgrade/*', route => { - const name = new URL(route.request().url()).pathname.split('/').at(-1) - sent.push(name) - return name === 'llama-cpp' - ? route.fulfill({ status: 500, json: { error: 'registry unavailable' } }) - : route.fulfill({ json: { jobID: 'j1', uuid: 'j1', message: 'started' } }) - }) - await page.goto('/app/backends?view=installed') - await page.getByTestId('backends-updates').click() - const sheet = page.getByTestId('update-review') - await expect(sheet.getByTestId('update-review-row')).toHaveCount(2) - await expect(sheet.getByTestId('update-review-row').first().getByTestId('update-review-versions')).toContainText('v1.0.0') - await expect(sheet.getByTestId('update-review-row').first().getByTestId('update-review-versions')).toContainText('v1.1.0') - expect(sent).toEqual([]) - await sheet.getByTestId('update-review-all').click() - await expect(sheet.getByTestId('update-review-summary')).toContainText('1 started, 1 failed') - expect(sent).toEqual(['llama-cpp', 'whisper']) - await expect(sheet.getByTestId('update-review-row').filter({ hasText: 'llama-cpp' }).getByRole('alert')).toContainText('registry unavailable') - }) - - test('a row Upgrade button opens the sheet with that backend checked instead of upgrading at once', async ({ page }) => { - await mock(page) - let calls = 0 - await page.route('**/api/backends/upgrade/*', route => { calls += 1; return route.fulfill({ json: { jobID: 'j1' } }) }) - await page.goto('/app/backends?view=installed&backend=whisper') - await page.getByRole('button', { name: /update to v1\.1\.0/i }).first().click() - const sheet = page.getByTestId('update-review') - await expect(sheet.getByTestId('update-review-row').filter({ hasText: 'whisper' }).getByRole('checkbox')).toBeChecked() - await expect(sheet.getByTestId('update-review-row').filter({ hasText: 'llama-cpp' }).getByRole('checkbox')).not.toBeChecked() - expect(calls).toBe(0) - await sheet.getByTestId('update-review-selected').click() - await expect.poll(() => calls).toBe(1) - }) -}) diff --git a/core/http/react-ui/e2e/users-page.spec.js b/core/http/react-ui/e2e/users-page.spec.js index a6cb66440..fe375cbd5 100644 --- a/core/http/react-ui/e2e/users-page.spec.js +++ b/core/http/react-ui/e2e/users-page.spec.js @@ -362,8 +362,11 @@ test.describe('Users and keys: phone', () => { await expect(page.getByRole('columnheader', { name: 'Sign-in' })).toBeHidden() await expect(page.getByRole('columnheader', { name: 'Access' })).toBeHidden() // The role moves under the name, and the row's actions stay on screen. + // The Administration header sits above the page, so the row may start + // below the fold: bring it up, then check nothing is cut at the side. await expect(page.getByRole('columnheader', { name: 'Role' })).toBeHidden() await expect(row(page, 'bob@lab.example').locator('.us-role-phone')).toBeVisible() + await row(page, 'dave@lab.example').scrollIntoViewIfNeeded() await expect(row(page, 'dave@lab.example').getByRole('button', { name: 'Approve' })).toBeInViewport() await expect(page.getByLabel('Actions for dave')).toBeInViewport() expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)).toBe(false) diff --git a/core/http/react-ui/e2e/wellknown.spec.js b/core/http/react-ui/e2e/wellknown.spec.js new file mode 100644 index 000000000..e6c2e8dba --- /dev/null +++ b/core/http/react-ui/e2e/wellknown.spec.js @@ -0,0 +1,237 @@ +import { test, expect } from './coverage-fixtures.js' +import { holdHomeFiles, unlockHomeFiles } from './home-files-lock.js' +import { offerMockModel, composer, openNewChat } from './journey-fixtures.js' + +// The well-known files of the home (persona.json, user.json, shortcuts.json, +// home.json, ui.json) and their editors: the Account tabs, the shortcuts row of +// the chat and the Home widgets. Against the real test server: every step is +// checked against the file the server keeps. + +test.describe.configure({ mode: 'serial', timeout: 60_000 }) + +// Other spec files write the same Home files: hold them while this file runs. +holdHomeFiles(test) + +const FILES = ['/LocalAI/persona.json', '/LocalAI/user.json', '/LocalAI/shortcuts.json', '/LocalAI/home.json', '/LocalAI/ui.json', '/data/tasks.json', '/data/tokens.json', '/data/broken.json'] + +async function clean(request) { + for (const p of FILES) await request.delete(`/api/chats/files?path=${encodeURIComponent(p)}`) +} +const read = (request, name) => request.get(`/api/chats/wellknown/${name}`).then(r => r.json()) +const write = (request, name, values) => request.put(`/api/chats/wellknown/${name}`, { data: { values, replace: true } }) +const upload = (request, path, body) => request.put(`/api/chats/files/content?path=${encodeURIComponent(path)}&overwrite=1`, { + data: typeof body === 'string' ? body : JSON.stringify(body), headers: { 'Content-Type': 'application/octet-stream' }, +}) + +test.beforeEach(async ({ request }) => { await clean(request) }) +test.afterAll(async ({ request }) => { await clean(request) }) +test.afterAll(() => unlockHomeFiles()) + +test('about you: the assistant and the user cards save their files, fill in from the browser and keep unknown keys', async ({ page, request }) => { + await upload(request, '/LocalAI/persona.json', { name: 'Ada', x_mood: 'calm' }) + await page.goto('/app/account?tab=about') + const persona = page.getByTestId('wk-card-persona') + await expect(persona.getByTestId('wk-field-persona-name')).toHaveValue('Ada') + // The card speaks of the setting, not of the file behind it. + await expect(persona).not.toContainText('LocalAI/persona.json') + + await persona.getByTestId('wk-field-persona-system_prompt').fill('Keep answers short.') + await persona.getByTestId('wk-save-persona').click() + await expect(persona.getByRole('status').filter({ hasText: 'Saved at' })).toBeVisible() + const saved = await read(request, 'persona') + expect(saved.data).toEqual({ name: 'Ada', system_prompt: 'Keep answers short.', x_mood: 'calm' }) + + // The language and the time zone come from the browser until saved. A + // user.json that holds only LocalAI's seen record (the pages and the server + // write it on their own) is not saved yet either. + const seen = { home_examples: { hidden: '2026-10-29' } } + await upload(request, '/LocalAI/user.json', { seen }) + await page.reload() + const user = page.getByTestId('wk-card-user') + await expect(user.getByText('From your browser').first()).toBeVisible() + await expect(user.getByText('Not saved yet.', { exact: false })).toBeVisible() + await user.getByTestId('wk-field-user-name').fill('Marta') + await user.getByTestId('wk-field-user-bio').fill('Product designer in Milan.') + await user.getByTestId('wk-save-user').click() + await expect.poll(async () => (await read(request, 'user')).data.name).toBe('Marta') + const u = (await read(request, 'user')).data + expect(u.bio).toBe('Product designer in Milan.') + expect(u.timezone).toBeTruthy() + // The sidebar adds its own date under seen.new when the user has pages, so + // the saved record holds the uploaded one and may hold more. + expect(u.seen).toMatchObject(seen) +}) + +test('about you: the source view is validated, a broken file shows its last good version, and revert restores it', async ({ page, request }) => { + await write(request, 'persona', { name: 'Ada' }) + await write(request, 'persona', { name: 'Bea' }) + await page.goto('/app/account?tab=about') + const persona = page.getByTestId('wk-card-persona') + await expect(persona.getByTestId('wk-field-persona-name')).toHaveValue('Bea') + + await persona.getByTestId('wk-source-persona').click() + await persona.getByTestId('wk-source-text-persona').fill('{"name": 42}') + await persona.getByTestId('wk-save-persona').click() + const problem = persona.getByTestId('wk-problem-persona') + await expect(problem).toContainText('Some values are not valid.') + // The server's words stay folded under Details until asked for. + await expect(problem.locator('details')).not.toHaveAttribute('open', '') + await problem.getByText('Details').click() + await expect(problem.locator('details li')).toContainText('name') + expect((await read(request, 'persona')).data.name).toBe('Bea') + + // The assistant writes a file that breaks the schema. + await upload(request, '/LocalAI/persona.json', '{"name": ["broken"]}') + await page.reload() + await expect(persona.getByTestId('wk-invalid-persona')).toContainText('last valid version') + await expect(persona.getByTestId('wk-field-persona-name')).toHaveValue('Bea') + await persona.getByTestId('wk-revert-persona.json').click() + await expect(persona.getByTestId('wk-invalid-persona')).toHaveCount(0) + const back = await read(request, 'persona') + expect(back.valid).toBe(true) + expect(back.data.name).toBe('Bea') +}) + +test('about you: a change by someone else while editing is a conflict that shows the newer values', async ({ page, request }) => { + await write(request, 'persona', { name: 'Ada' }) + await page.goto('/app/account?tab=about') + const persona = page.getByTestId('wk-card-persona') + await expect(persona.getByTestId('wk-field-persona-name')).toHaveValue('Ada') + await persona.getByTestId('wk-field-persona-name').fill('Mine') + // The change feed brings the other write to this tab while the form holds + // the edit: the save must still start from the version the edit started on. + const reread = page.waitForResponse(r => /\/api\/chats\/wellknown\/persona$/.test(r.url()) && r.request().method() === 'GET') + await write(request, 'persona', { name: 'Theirs' }) + await reread + await expect(persona.getByTestId('wk-field-persona-name')).toHaveValue('Mine') + await persona.getByTestId('wk-save-persona').click() + const conflict = persona.getByTestId('wk-conflict-persona') + await expect(conflict).toContainText('Theirs') + await conflict.getByRole('button', { name: 'Keep my changes' }).click() + await expect.poll(async () => (await read(request, 'persona')).data.name).toBe('Mine') +}) + +test('appearance: the theme applies at once and is kept in ui.json', async ({ page, request }) => { + await page.goto('/app/account?tab=appearance') + const card = page.getByTestId('wk-card-ui') + await card.getByTestId('wk-field-ui-theme').selectOption('light') + await expect(page.locator('html')).toHaveAttribute('data-theme', 'light') + await card.getByTestId('wk-field-ui-density').selectOption('compact') + await expect(page.locator('html')).toHaveAttribute('data-density', 'compact') + await card.getByTestId('wk-save-ui').click() + await expect.poll(async () => (await read(request, 'ui')).data).toEqual({ theme: 'light', density: 'compact' }) + await write(request, 'ui', { theme: 'dark' }) + await page.goto('/app/chats') + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark') + await expect(page.locator('html')).not.toHaveAttribute('data-density', 'compact') +}) + +test('appearance: a pin made elsewhere while the form holds an edit is kept by the save', async ({ page, request }) => { + await write(request, 'ui', { theme: 'dark' }) + await page.goto('/app/account?tab=appearance') + const card = page.getByTestId('wk-card-ui') + await expect(card.getByTestId('wk-field-ui-theme')).toHaveValue('dark') + await card.getByTestId('wk-field-ui-density').selectOption('compact') + // Another tab pins a page: only pinnedPages changes. + const reread = page.waitForResponse(r => /\/api\/chats\/wellknown\/ui$/.test(r.url()) && r.request().method() === 'GET') + expect((await request.put('/api/chats/wellknown/ui', { data: { values: { pinnedPages: ['tasks'] } } })).ok()).toBeTruthy() + await reread + await card.getByTestId('wk-save-ui').click() + await expect.poll(async () => (await read(request, 'ui')).data).toEqual({ theme: 'dark', density: 'compact', pinnedPages: ['tasks'] }) + await expect(card.getByTestId('wk-conflict-ui')).toHaveCount(0) +}) + +test('shortcuts: the starters are the default shortcuts, a click fills the composer, and the editor writes shortcuts.json', async ({ page, request }) => { + await offerMockModel(page) + // The shortcuts row is over the message box of a chat; /new on Home opens an empty one. + await openNewChat(page) + const row = page.getByTestId('chat-shortcuts') + await expect(row.getByTestId('chat-shortcut')).toHaveCount(4) + await expect(row).toContainText('Default shortcuts') + await row.getByTestId('chat-shortcut').first().click() + await expect(composer(page)).toHaveValue('Explain how this works') + + await row.getByTestId('chat-shortcuts-edit').click() + const editor = page.getByTestId('wk-shortcuts-editor') + await expect(editor.getByTestId('wk-shortcut-row')).toHaveCount(4) + await editor.getByRole('button', { name: 'Remove' }).nth(3).click() + await editor.getByTestId('wk-shortcut-add').click() + const last = editor.getByTestId('wk-shortcut-row').last() + await last.getByLabel('Label').fill('Plan my day') + await last.getByLabel('Prompt').fill('Look at my open tasks and plan today.') + await editor.getByRole('button', { name: 'Move up' }).last().click() + await editor.getByTestId('wk-shortcut-save').click() + await expect(editor).toHaveCount(0) + + const saved = (await read(request, 'shortcuts')).data.shortcuts + expect(saved.map(s => s.label)).toEqual(['Explain how this works', 'Help me write code', 'Plan my day', 'Summarize a document']) + // Own shortcuts carry no note; only the defaults are labelled as such. + await expect(row).not.toContainText('Default shortcuts') + await expect(row).not.toContainText('.json') + await expect(row.getByTestId('chat-shortcut').nth(2)).toHaveText('Plan my day') + + // A file the assistant writes shows at the next read. + await write(request, 'shortcuts', { shortcuts: [{ label: 'Summarise my inbox', prompt: 'Summarise my inbox', icon: 'mail' }] }) + await openNewChat(page) + await expect(row.getByTestId('chat-shortcut')).toHaveText(['Summarise my inbox']) + + await row.getByTestId('chat-shortcuts-assistant').click() + await expect(composer(page)).toHaveValue('Change my shortcuts: ') +}) + +test('home widgets: list, metric and chats widgets read their data, a broken file is marked stale, and the layout editor writes home.json', async ({ page, request }) => { + await offerMockModel(page) + await upload(request, '/data/tasks.json', { title: 'Open tasks', items: [{ label: 'Send the print invoice', meta: 'Wed' }, { label: 'Call the dentist', meta: 'Fri', done: true }] }) + await upload(request, '/data/tokens.json', { value: 184000, note: '12% less than last week', series: [3, 4, 3, 5, 4, 6, 5] }) + await upload(request, '/data/broken.json', { items: [{ label: 'Kept row' }] }) + await upload(request, '/data/broken.json', '{"items": "nope"}') + await write(request, 'home', { + widgets: [ + { id: 'tasks', type: 'list', title: 'Open tasks', source: 'data/tasks.json' }, + { id: 'tokens', type: 'metric', title: 'Tokens this week', source: 'data/tokens.json' }, + { id: 'old', type: 'list', title: 'Broken', source: 'data/broken.json' }, + { id: 'missing', type: 'list', title: 'Missing', source: 'data/none.json' }, + ], + }) + await page.goto('/app') + await expect(page.getByTestId('home-widget-tasks')).toContainText('Send the print invoice') + await expect(page.getByTestId('home-widget-tasks')).toContainText('1 left') + await expect(page.getByTestId('home-widget-tokens')).toContainText('184k') + await expect(page.getByTestId('home-widget-old')).toHaveAttribute('data-state', 'stale') + await expect(page.getByTestId('home-widget-old')).toContainText('Kept row') + await expect(page.getByTestId('home-widget-missing')).toHaveAttribute('data-state', 'empty') + + await page.getByTestId('home-edit-layout').click() + const editor = page.getByTestId('home-layout-editor') + await expect(editor.getByTestId('home-layout-row')).toHaveCount(4) + await editor.getByRole('button', { name: 'Remove' }).nth(3).click() + await editor.getByRole('button', { name: 'Remove' }).nth(2).click() + await editor.getByTestId('home-layout-add').click() + const added = editor.getByTestId('home-layout-row').last() + await added.getByLabel('Type').selectOption('chats') + await added.getByLabel('Title').fill('Last chats') + await editor.getByTestId('home-layout-save').click() + await expect(editor).toHaveCount(0) + const home = (await read(request, 'home')).data.widgets + expect(home.map(w => w.type)).toEqual(['list', 'metric', 'chats']) + await expect(page.getByTestId('home-widget-last-chats')).toBeVisible() + + await page.getByTestId('home-ask-layout').click() + await page.waitForURL(/\/app\/chats/) + await expect(composer(page)).toHaveValue('Change my Home page: ') +}) + +test('the editors fit a phone screen without a sideways scroll', async ({ page, request }) => { + await offerMockModel(page) + await write(request, 'home', { widgets: [{ id: 'tasks', type: 'list', title: 'Open tasks with a very long title that has to be cut', source: 'data/tasks.json' }] }) + await upload(request, '/data/tasks.json', { items: [{ label: 'A task with a long, long label that does not fit a narrow card at all', meta: 'Wed' }] }) + await page.setViewportSize({ width: 390, height: 844 }) + for (const [path, ready] of [['/app/account?tab=about', 'wk-card-user'], ['/app/account?tab=appearance', 'wk-card-ui'], ['/app', 'home-widget-tasks'], ['/app/chats', 'chat-shortcuts']]) { + // /app/chats as an address opens Home; the empty chat is /new on Home. + if (path === '/app/chats') await openNewChat(page) + else await page.goto(path) + await expect(page.getByTestId(ready)).toBeVisible() + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + expect(overflow, path).toBeLessThanOrEqual(1) + } +}) diff --git a/core/http/react-ui/e2e/workspace-browser.spec.js b/core/http/react-ui/e2e/workspace-browser.spec.js new file mode 100644 index 000000000..da598df0e --- /dev/null +++ b/core/http/react-ui/e2e/workspace-browser.spec.js @@ -0,0 +1,397 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockChat } from './chat-fixtures.js' + +// The Files page as a file browser, with the API stubbed by a small in-memory +// folder: sorting, the filter, the views, selection and bulk actions, the +// keyboard, the context menu, drag and drop, paste and the name clash of an +// upload. The real-server flows are in workspace.spec.js. + +const T0 = Date.parse('2026-10-30T10:00:00Z') +const at = (mins) => new Date(T0 - mins * 60_000).toISOString() + +function seed() { + return [ + { path: '/Projects', name: 'Projects', kind: 'dir', size: 0, version: 1, rev: 1, modified: at(600) }, + { path: '/archive', name: 'archive', kind: 'dir', size: 0, version: 1, rev: 2, modified: at(30) }, + { path: '/b-report.pdf', name: 'b-report.pdf', kind: 'file', size: 300_000, version: 1, rev: 3, modified: at(5) }, + { path: '/a-notes.md', name: 'a-notes.md', kind: 'file', size: 10, version: 1, rev: 4, modified: at(60 * 48) }, + { path: '/c-budget.xlsx', name: 'c-budget.xlsx', kind: 'file', size: 5_000, version: 1, rev: 5, modified: at(1) }, + { path: '/Note 10.txt', name: 'Note 10.txt', kind: 'file', size: 20, version: 1, rev: 6, modified: at(2) }, + { path: '/Note 2.txt', name: 'Note 2.txt', kind: 'file', size: 30, version: 1, rev: 7, modified: at(3) }, + ] +} + +// stubFs answers the files API from an array that the moves, deletes, renames +// and uploads change, and records every call. +async function stubFs(page, { entries = seed(), maxFileSize = 0 } = {}) { + const fs = { entries, calls: [], trash: [], rev: 100 } + const parent = (p) => p.slice(0, p.lastIndexOf('/')) || '/' + const name = (p) => p.slice(p.lastIndexOf('/') + 1) + await page.route(/\/api\/chats\/files\/trash(\?.*)?$/, route => route.fulfill({ json: { entries: fs.trash } })) + await page.route(/\/api\/chats\/files\/undo(\?.*)?$/, route => { + const { rev } = route.request().postDataJSON() + fs.calls.push(['undo', rev]) + const t = fs.trash.find(x => x.rev === rev) + if (t) { fs.entries.push(t.entry); fs.trash = fs.trash.filter(x => x !== t) } + const m = fs.moves?.[rev] + if (m) { const e = fs.entries.find(x => x.path === m.to); if (e) { e.path = m.from; e.name = name(m.from) } } + return route.fulfill({ json: { rev: ++fs.rev } }) + }) + await page.route(/\/api\/chats\/files\/move(\?.*)?$/, route => { + const { from, to } = route.request().postDataJSON() + fs.calls.push(['move', from, to]) + const e = fs.entries.find(x => x.path === from) + const rev = ++fs.rev + fs.moves = { ...(fs.moves || {}), [rev]: { from, to } } + if (e) { e.path = to; e.name = name(to); e.rev = rev } + return route.fulfill({ json: { ...(e || {}), path: to, name: name(to), rev } }) + }) + await page.route(/\/api\/chats\/files\/folders(\?.*)?$/, route => { + const { path } = route.request().postDataJSON() + fs.calls.push(['mkdir', path]) + const e = { path, name: name(path), kind: 'dir', size: 0, version: 1, rev: ++fs.rev, modified: new Date().toISOString() } + fs.entries.push(e) + return route.fulfill({ json: e }) + }) + await page.route('**/api/chats/files/content*', route => { + const req = route.request() + const url = new URL(req.url()) + const path = url.searchParams.get('path') + if (req.method() !== 'PUT') return route.fulfill({ body: 'hello', contentType: 'text/plain' }) + const replace = url.searchParams.get('overwrite') === '1' + fs.calls.push(['put', path, replace ? 'replace' : 'keep']) + const size = req.postDataBuffer()?.length || 0 + const existing = fs.entries.find(x => x.path === path) + let out = path + if (existing && !replace) out = path.replace(/(\.[^.]+)?$/, ' (conflict)$1') + const e = { path: out, name: name(out), kind: 'file', size, version: 1, rev: ++fs.rev, modified: new Date().toISOString(), ...(out !== path ? { conflict_of: path } : {}) } + if (existing && replace) Object.assign(existing, e) + else if (parent(out) === '/') fs.entries.push(e) + return route.fulfill({ json: e }) + }) + await page.route(/\/api\/chats\/files(\?.*)?$/, route => { + const req = route.request() + const url = new URL(req.url()) + const path = url.searchParams.get('path') || '/' + if (req.method() === 'DELETE') { + fs.calls.push(['delete', path]) + const e = fs.entries.find(x => x.path === path) + fs.entries = fs.entries.filter(x => x !== e) + fs.trash.unshift({ rev: ++fs.rev, title: `Delete ${path}`, at: new Date().toISOString(), paths: [path], entry: e }) + return route.fulfill({ json: { deleted: true } }) + } + const list = path === '/' ? fs.entries.filter(e => parent(e.path) === '/') : [] + return route.fulfill({ json: { path, entries: [...list].sort((a, b) => (a.name < b.name ? -1 : 1)), ...(maxFileSize ? { max_file_size: maxFileSize } : {}) } }) + }) + return fs +} + +const names = (page) => page.getByTestId('ws-row').evaluateAll(els => els.map(e => e.getAttribute('data-path').slice(1))) +const row = (page, name) => page.getByTestId('ws-row').filter({ has: page.getByRole('button', { name, exact: true }) }) + +test.describe('Files: browsing, selection and actions', () => { + test.beforeEach(async ({ page }) => { + await page.clock.setFixedTime(T0) + await mockChat(page) + }) + + test('sorts folders first by name, naturally, and by any column both ways, and remembers it', async ({ page }) => { + await stubFs(page) + await page.goto('/app/files') + await expect(page.getByTestId('ws-row')).toHaveCount(7) + expect(await names(page)).toEqual(['archive', 'Projects', 'a-notes.md', 'b-report.pdf', 'c-budget.xlsx', 'Note 2.txt', 'Note 10.txt']) + await expect(page.getByRole('columnheader', { name: 'Name' })).toHaveAttribute('aria-sort', 'ascending') + + await page.getByTestId('ws-sort-size').click() + await expect(page.getByRole('columnheader', { name: 'Size' })).toHaveAttribute('aria-sort', 'descending') + expect(await names(page)).toEqual(['archive', 'Projects', 'b-report.pdf', 'c-budget.xlsx', 'Note 2.txt', 'Note 10.txt', 'a-notes.md']) + await page.getByTestId('ws-sort-size').click() + await expect(page.getByRole('columnheader', { name: 'Size' })).toHaveAttribute('aria-sort', 'ascending') + + await page.getByTestId('ws-sort-modified').click() + expect(await names(page)).toEqual(['archive', 'Projects', 'c-budget.xlsx', 'Note 10.txt', 'Note 2.txt', 'b-report.pdf', 'a-notes.md']) + const modified = row(page, 'b-report.pdf').locator('.ws-cell--modified') + await expect(modified).toHaveText('5 minutes ago') + await expect(modified).toHaveAttribute('title', /2026/) + + await page.reload() + await expect(page.getByRole('columnheader', { name: 'Modified' })).toHaveAttribute('aria-sort', 'descending') + }) + + test('filters the folder as you type, offers a search of every file, and says when nothing matches', async ({ page }) => { + await stubFs(page) + let searched = '' + await page.route('**/api/chats/files/search*', route => { + searched = new URL(route.request().url()).searchParams.get('q') + return route.fulfill({ json: { hits: [] } }) + }) + await page.goto('/app/files') + const box = page.getByRole('searchbox', { name: 'Search your files' }) + await box.fill('note') + expect(await names(page)).toEqual(['a-notes.md', 'Note 2.txt', 'Note 10.txt']) + await expect(page.getByTestId('ws-filtering')).toContainText('3 items in this folder match “note”.') + await box.fill('zebra') + await expect(page.getByTestId('ws-no-match')).toContainText('Nothing in this folder matches “zebra”.') + await page.getByTestId('ws-no-match').getByRole('button', { name: 'Search all files' }).click() + await expect(page.getByText('Files that contain “zebra”')).toBeVisible() + expect(searched).toBe('zebra') + await page.getByRole('button', { name: 'Back to the folder' }).click() + await box.press('Escape') + await expect(page.getByTestId('ws-row')).toHaveCount(7) + }) + + test('names the page, sheet or slide of a hit inside a document and says when documents are still being indexed', async ({ page }) => { + await stubFs(page) + await page.route('**/api/chats/files/search*', route => route.fulfill({ json: { + indexing: 2, + hits: [ + { path: '/notes.md', kind: 'file', line: 4, snippet: 'the lease ends in May' }, + { path: '/inbox/7c9e6679.pdf', kind: 'file', line: 12, snippet: 'The lease deposit is returned', document: true, page: 2 }, + { path: '/book.xlsx', kind: 'file', line: 3, snippet: 'lease\t900', document: true, sheet: 'Costs' }, + { path: '/plan.docx', kind: 'file', line: 7, snippet: 'sign the lease', document: true }, + ], + } })) + await page.goto('/app/files') + const box = page.getByRole('searchbox', { name: 'Search your files' }) + await box.fill('lease') + await box.press('Enter') + await expect(page.getByTestId('ws-hits-indexing')).toHaveText('2 documents are still being indexed: search again in a moment to include them.') + const hits = page.getByTestId('ws-hit') + await expect(hits).toHaveCount(4) + await expect(hits.nth(0)).toContainText('/notes.md:4') + await expect(hits.nth(1).getByTestId('ws-hit-place')).toHaveText(' · page 2') + await expect(hits.nth(2).getByTestId('ws-hit-place')).toHaveText(' · sheet “Costs”') + // A document with no pages shows neither a page nor a line of its text. + await expect(hits.nth(3)).not.toContainText(':7') + await expect(hits.nth(3).getByTestId('ws-hit-place')).toHaveCount(0) + }) + + test('switches to thumbnails and keeps the choice for the next visit', async ({ page }) => { + await stubFs(page) + await page.goto('/app/files') + await page.getByTestId('ws-view-grid').click() + await expect(page.getByTestId('ws-list')).toHaveClass(/ws-table--grid/) + await expect(page.getByTestId('ws-view-grid')).toHaveAttribute('aria-checked', 'true') + await page.reload() + await expect(page.getByTestId('ws-list')).toHaveClass(/ws-table--grid/) + await page.getByTestId('ws-view-list').click() + await expect(page.getByTestId('ws-list')).toHaveClass(/ws-table--list/) + }) + + test('selects with click, Ctrl and Shift, and moves the selection to the Trash with one Undo', async ({ page }) => { + const fs = await stubFs(page) + await page.goto('/app/files') + await row(page, 'a-notes.md').locator('.ws-cell--size').click() + await row(page, 'c-budget.xlsx').locator('.ws-cell--size').click({ modifiers: ['Shift'] }) + await row(page, 'Note 10.txt').locator('.ws-cell--size').click({ modifiers: ['Control'] }) + const bar = page.getByTestId('ws-selection-bar') + await expect(bar).toContainText('4 selected') + await expect(page.getByTestId('ws-row').and(page.locator('[aria-selected="true"]'))).toHaveCount(4) + + await bar.getByRole('button', { name: 'Move to Trash' }).click() + await expect(page.getByTestId('ws-row')).toHaveCount(3) + expect(fs.calls.filter(c => c[0] === 'delete').map(c => c[1])).toEqual(['/a-notes.md', '/b-report.pdf', '/c-budget.xlsx', '/Note 10.txt']) + await page.getByTestId('ws-toast').filter({ hasText: '4 items moved to the Trash.' }).getByRole('button', { name: 'Undo' }).click() + await expect(page.getByTestId('ws-row')).toHaveCount(7) + expect(fs.calls.filter(c => c[0] === 'undo')).toHaveLength(4) + }) + + test('the checkbox and the select-all box select, Escape clears', async ({ page }) => { + await stubFs(page) + await page.goto('/app/files') + await row(page, 'b-report.pdf').getByRole('checkbox', { name: 'Select b-report.pdf' }).check() + await expect(page.getByTestId('ws-selection-bar')).toContainText('1 selected') + await page.getByRole('checkbox', { name: 'Select all' }).check() + await expect(page.getByTestId('ws-selection-bar')).toContainText('7 selected') + await row(page, 'b-report.pdf').focus() + await page.keyboard.press('Escape') + await expect(page.getByTestId('ws-selection-bar')).toHaveCount(0) + }) + + test('works from the keyboard: arrows, Shift, Space, Enter, F2, Delete, Ctrl+A and type to find', async ({ page }) => { + const fs = await stubFs(page) + await page.goto('/app/files') + await row(page, 'archive').focus() + await page.keyboard.press('ArrowDown') + await expect(row(page, 'Projects')).toBeFocused() + await page.keyboard.press('Shift+ArrowDown') + await expect(page.getByTestId('ws-selection-bar')).toContainText('2 selected') + await page.keyboard.press('Escape') + await page.keyboard.type('c-b') + await expect(row(page, 'c-budget.xlsx')).toBeFocused() + await page.keyboard.press('Space') + await expect(row(page, 'c-budget.xlsx')).toHaveAttribute('aria-selected', 'true') + await page.keyboard.press('Control+a') + await expect(page.getByTestId('ws-selection-bar')).toContainText('7 selected') + await page.keyboard.press('Escape') + + await row(page, 'Note 2.txt').focus() + await page.keyboard.press('F2') + const box = page.getByRole('textbox', { name: 'Rename' }) + await expect(box).toBeFocused() + // The name without its extension is selected. + expect(await box.evaluate(el => el.value.slice(el.selectionStart, el.selectionEnd))).toBe('Note 2') + await page.keyboard.type('Note 3') + await page.keyboard.press('Enter') + await expect.poll(() => fs.calls.find(c => c[0] === 'move')).toEqual(['move', '/Note 2.txt', '/Note 3.txt']) + + await row(page, 'a-notes.md').focus() + await page.keyboard.press('Delete') + await expect.poll(() => fs.calls.find(c => c[0] === 'delete')).toEqual(['delete', '/a-notes.md']) + + await row(page, 'b-report.pdf').focus() + await page.keyboard.press('Enter') + await expect(page.getByTestId('file-preview')).toContainText('b-report.pdf') + }) + + test('a rename that cannot be used says why and keeps the field open', async ({ page }) => { + const fs = await stubFs(page) + await page.goto('/app/files') + await row(page, 'a-notes.md').focus() + await page.keyboard.press('F2') + const box = page.getByRole('textbox', { name: 'Rename' }) + await box.fill('b-report.pdf') + await box.press('Enter') + await expect(page.getByRole('alert').filter({ hasText: 'Something in this folder already has that name.' })).toBeVisible() + await box.fill('x/y') + await box.press('Enter') + await expect(page.getByRole('alert').filter({ hasText: 'A name cannot contain / or \\.' })).toBeVisible() + await box.press('Escape') + await expect(box).toHaveCount(0) + expect(fs.calls).toEqual([]) + }) + + test('a right click opens the menu of the row and of the selection', async ({ page }) => { + await stubFs(page) + await page.goto('/app/files') + await row(page, 'b-report.pdf').locator('.ws-cell--type').click({ button: 'right' }) + const menu = page.getByRole('menu') + await expect(menu.getByRole('menuitem')).toHaveText(['Preview', 'Download', /Rename/, 'Move to…', /Move to Trash/]) + await page.keyboard.press('Escape') + await expect(menu).toHaveCount(0) + + await row(page, 'a-notes.md').locator('.ws-cell--size').click({ modifiers: ['Control'] }) + await row(page, 'b-report.pdf').locator('.ws-cell--type').click({ button: 'right' }) + await expect(page.getByRole('menu').getByRole('menuitem')).toHaveText(['Download 2 files', 'Move 2 items to…', /Move 2 items to Trash/]) + }) + + test('creates a new file, named so it does not clash, and opens its preview', async ({ page }) => { + const fs = await stubFs(page) + fs.entries.push({ path: '/Untitled.md', name: 'Untitled.md', kind: 'file', size: 0, version: 1, rev: 9, modified: at(1) }) + await page.goto('/app/files') + await page.getByRole('button', { name: 'New file' }).click() + const box = page.getByRole('textbox', { name: 'File name' }) + await expect(box).toHaveValue('Untitled 2.md') + await box.press('Enter') + await expect.poll(() => fs.calls.find(c => c[0] === 'put')).toEqual(['put', '/Untitled 2.md', 'keep']) + await expect(page.getByTestId('file-preview')).toContainText('Untitled 2.md') + }) + + test('dragging rows onto a folder moves them, and Undo moves them back', async ({ page }) => { + const fs = await stubFs(page) + await page.goto('/app/files') + await row(page, 'a-notes.md').locator('.ws-cell--size').click() + await row(page, 'b-report.pdf').locator('.ws-cell--size').click({ modifiers: ['Control'] }) + await row(page, 'a-notes.md').dragTo(row(page, 'archive')) + await expect.poll(() => fs.calls.filter(c => c[0] === 'move')).toEqual([ + ['move', '/a-notes.md', '/archive/a-notes.md'], + ['move', '/b-report.pdf', '/archive/b-report.pdf'], + ]) + const toast = page.getByTestId('ws-toast').filter({ hasText: '2 items moved to archive.' }) + await toast.getByRole('button', { name: 'Undo' }).click() + await expect.poll(() => fs.calls.filter(c => c[0] === 'undo').length).toBe(2) + }) + + test('files dropped from outside show the drop target and upload to the open folder', async ({ page }) => { + const fs = await stubFs(page) + await page.goto('/app/files') + await expect(page.getByTestId('ws-row')).toHaveCount(7) + const dt = await page.evaluateHandle(() => { + const d = new DataTransfer() + d.items.add(new File(['one'], 'dropped-1.txt', { type: 'text/plain' })) + d.items.add(new File(['two!'], 'dropped-2.txt', { type: 'text/plain' })) + return d + }) + const main = page.locator('.ws-main') + await main.dispatchEvent('dragenter', { dataTransfer: dt }) + await main.dispatchEvent('dragover', { dataTransfer: dt }) + await expect(page.getByTestId('ws-drop-overlay')).toContainText('Drop to upload to Home') + await main.dispatchEvent('drop', { dataTransfer: dt }) + await expect(page.getByTestId('ws-drop-overlay')).toHaveCount(0) + await expect.poll(() => fs.calls.filter(c => c[0] === 'put').map(c => c[1]).sort()).toEqual(['/dropped-1.txt', '/dropped-2.txt']) + await expect(page.getByTestId('ws-upload-tray')).toContainText('2 files uploaded') + }) + + test('a pasted screenshot uploads with a name of its own', async ({ page }) => { + const fs = await stubFs(page) + await page.goto('/app/files') + await expect(page.getByTestId('ws-row')).toHaveCount(7) + await page.evaluate(() => { + const d = new DataTransfer() + d.items.add(new File([new Uint8Array([137, 80, 78, 71])], 'image.png', { type: 'image/png' })) + document.body.dispatchEvent(new ClipboardEvent('paste', { clipboardData: d, bubbles: true })) + }) + await expect.poll(() => fs.calls.find(c => c[0] === 'put')?.[1]).toMatch(/^\/Pasted image 2026-10-30 \d\d\.\d\d\.\d\d\.png$/) + }) + + test('asks before an upload replaces a file: Replace, Keep both or Skip', async ({ page }) => { + const fs = await stubFs(page) + await page.goto('/app/files') + const input = page.getByTestId('ws-file-input') + const files = [{ name: 'a-notes.md', mimeType: 'text/plain', buffer: Buffer.from('new') }, { name: 'fresh.txt', mimeType: 'text/plain', buffer: Buffer.from('x') }] + + await input.setInputFiles(files) + const dialog = page.getByTestId('ws-conflict') + await expect(dialog).toContainText('A file with this name already exists') + await expect(dialog).toContainText('a-notes.md') + await dialog.getByRole('button', { name: 'Skip it' }).click() + await expect.poll(() => fs.calls.filter(c => c[0] === 'put')).toEqual([['put', '/fresh.txt', 'keep']]) + + await input.setInputFiles(files.slice(0, 1)) + await page.getByTestId('ws-conflict').getByRole('button', { name: 'Keep both' }).click() + await expect(page.getByTestId('ws-upload-row').filter({ hasText: 'a-notes.md' }).first()).toContainText('Saved as a-notes (conflict).md') + + await input.setInputFiles(files.slice(0, 1)) + await page.getByTestId('ws-conflict').getByRole('button', { name: 'Replace' }).click() + await expect.poll(() => fs.calls.filter(c => c[0] === 'put').at(-1)).toEqual(['put', '/a-notes.md', 'replace']) + }) + + test('refuses a file over the server limit before sending it, and says the limit', async ({ page }) => { + const fs = await stubFs(page, { maxFileSize: 1024 }) + await page.goto('/app/files') + await page.getByTestId('ws-file-input').setInputFiles({ name: 'huge.bin', mimeType: 'application/octet-stream', buffer: Buffer.alloc(4096) }) + const up = page.getByTestId('ws-upload-row').filter({ hasText: 'huge.bin' }) + await expect(up).toContainText('Larger than the 1.0 KB limit of this server.') + await expect(up.getByRole('button', { name: 'Try huge.bin again' })).toHaveCount(0) + expect(fs.calls).toEqual([]) + }) + + test('an upload keeps its folder when the user opens another one, and Cancel stops a slow one', async ({ page }) => { + await stubFs(page) + const puts = [] + let release + const held = new Promise(r => { release = r }) + await page.route('**/api/chats/files/content*', async route => { + puts.push(new URL(route.request().url()).searchParams.get('path')) + await held + return route.fulfill({ json: { path: '/slow.bin', name: 'slow.bin', kind: 'file', size: 1, version: 1, rev: 1 } }).catch(() => {}) + }) + await page.goto('/app/files') + await page.getByTestId('ws-file-input').setInputFiles([ + { name: 'slow.bin', mimeType: 'application/octet-stream', buffer: Buffer.from('a') }, + { name: 'second.bin', mimeType: 'application/octet-stream', buffer: Buffer.from('b') }, + { name: 'third.bin', mimeType: 'application/octet-stream', buffer: Buffer.from('c') }, + ]) + const tray = page.getByTestId('ws-upload-tray') + await expect(tray).toContainText('Uploading 3 files') + await expect(page.getByTestId('ws-upload-row').filter({ hasText: 'third.bin' })).toContainText('Waiting') + await page.getByTestId('ws-upload-row').filter({ hasText: 'third.bin' }).getByRole('button', { name: 'Cancel the upload of third.bin' }).click() + await expect(page.getByTestId('ws-upload-row').filter({ hasText: 'third.bin' })).toContainText('Canceled') + + await row(page, 'archive').getByRole('button', { name: 'archive', exact: true }).click() + await expect(page).toHaveURL(/path=%2Farchive/) + release() + await expect(tray).toContainText('2 files uploaded') + expect(puts.sort()).toEqual(['/second.bin', '/slow.bin']) + }) +}) diff --git a/core/http/react-ui/e2e/workspace-feed.spec.js b/core/http/react-ui/e2e/workspace-feed.spec.js new file mode 100644 index 000000000..733da75d1 --- /dev/null +++ b/core/http/react-ui/e2e/workspace-feed.spec.js @@ -0,0 +1,49 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockChat } from './chat-fixtures.js' + +// The change feed of the files with the stream stubbed: a change that someone +// else made (a chat, another tab, another frontend) shows on the open Files +// page without a reload, through one shared stream that resumes after the last +// revision it saw. + +test.describe('Files page and the change feed', () => { + test('a change from elsewhere refreshes the open folder, and the stream resumes', async ({ page }) => { + await mockChat(page) + let lists = 0 + const feedUrls = [] + // The app shell (the appearance sync) opens the shared stream at start, + // before the Files page has loaded. A change sent at once would reach a tab + // with nobody watching the folder, and the page's own first read would + // already show it on a real server. So the change happens once the page + // has read the folder: by then its subscription exists, since the read and + // the subscription start in the same commit. + let listed + const firstList = new Promise(resolve => { listed = resolve }) + await page.route(/\/api\/chats\/files\/trash(\?.*)?$/, route => route.fulfill({ json: { entries: [] } })) + await page.route(/\/api\/chats\/files(\?.*)?$/, route => { + lists++ + listed() + const entries = [{ path: '/plan.txt', name: 'plan.txt', kind: 'file', size: 4, version: 1, rev: 3 }] + if (lists > 1) entries.push({ path: '/from-chat.txt', name: 'from-chat.txt', kind: 'file', size: 9, version: 1, rev: 5 }) + return route.fulfill({ json: { path: '/', entries } }) + }) + await page.route('**/api/workspace/events*', async route => { + feedUrls.push(new URL(route.request().url()).search) + if (feedUrls.length === 1) await firstList + // The first stream carries one change and ends, as a dropped connection + // does; the client reconnects with the revision it saw. + const body = feedUrls.length === 1 + ? 'id: 5\nevent: change\ndata: {"revision":5,"paths":["from-chat.txt"],"kinds":["agent"]}\n\n' + : ': ping\n\n' + return route.fulfill({ status: 200, headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }, body }) + }) + + await page.goto('/app/files') + const rows = page.getByTestId('ws-row') + await expect(rows.filter({ hasText: 'plan.txt' })).toBeVisible() + await expect(rows.filter({ hasText: 'from-chat.txt' })).toBeVisible() + expect(feedUrls[0]).toBe('') + await expect.poll(() => feedUrls.length, { timeout: 10_000 }).toBeGreaterThan(1) + expect(feedUrls[1]).toBe('?after=5') + }) +}) diff --git a/core/http/react-ui/e2e/workspace-managed.spec.js b/core/http/react-ui/e2e/workspace-managed.spec.js new file mode 100644 index 000000000..f44ce7dcf --- /dev/null +++ b/core/http/react-ui/e2e/workspace-managed.spec.js @@ -0,0 +1,112 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockChat } from './chat-fixtures.js' + +// The folders LocalAI manages in the Files page, with the API stubbed: the +// marker, the group under the user's own files, the line at the top of +// LocalAI/, and the extra confirmation before a delete or a rename. + +const HOME = [ + { path: '/Chats', name: 'Chats', kind: 'dir', size: 0, version: 1, managed: 'chats' }, + { path: '/LocalAI', name: 'LocalAI', kind: 'dir', size: 0, version: 1, managed: 'localai' }, + { path: '/Notes', name: 'Notes', kind: 'dir', size: 0, version: 1 }, + { path: '/report.txt', name: 'report.txt', kind: 'file', size: 12, version: 1 }, +] +const SETTINGS = [ + { path: '/LocalAI/persona.json', name: 'persona.json', kind: 'file', size: 40, version: 2, managed: 'localai' }, + { path: '/LocalAI/ui.json', name: 'ui.json', kind: 'file', size: 30, version: 1, managed: 'localai' }, +] + +async function stubFiles(page) { + const calls = [] + await page.route(/\/api\/chats\/files\/trash(\?.*)?$/, route => route.fulfill({ json: { entries: [] } })) + await page.route(/\/api\/chats\/files\/move(\?.*)?$/, route => { + calls.push(['move', route.request().postDataJSON()]) + return route.fulfill({ json: { path: '/x', name: 'x', kind: 'dir', size: 0, version: 1 } }) + }) + await page.route(/\/api\/chats\/files(\?.*)?$/, route => { + const url = new URL(route.request().url()) + const path = url.searchParams.get('path') || '/' + if (route.request().method() === 'DELETE') { + calls.push(['delete', path]) + return route.fulfill({ json: {} }) + } + return route.fulfill({ json: { path, entries: path === '/LocalAI' ? SETTINGS : HOME } }) + }) + return calls +} + +test.describe('Files: the folders LocalAI manages', () => { + test.beforeEach(async ({ page }) => { + await mockChat(page) + }) + + test('lists the user\'s own files first and the managed folders last, marked and explained', async ({ page }) => { + await stubFiles(page) + await page.goto('/app/files') + const rows = page.getByTestId('ws-row') + await expect(rows).toHaveCount(4) + await expect(rows.nth(0)).toContainText('Notes') + await expect(rows.nth(1)).toContainText('report.txt') + const group = page.getByTestId('ws-managed') + await expect(group.getByRole('heading', { name: 'Managed by LocalAI' })).toBeVisible() + const managed = group.getByTestId('ws-row') + await expect(managed.nth(0)).toContainText('LocalAI') + await expect(managed.nth(0)).toContainText('managed by LocalAI') + await expect(managed.nth(0)).toContainText('Settings LocalAI reads: persona, profile, shortcuts, Home, theme.') + await expect(managed.nth(1)).toContainText('Chats') + await expect(page.getByTestId('ws-managed-tag')).toHaveCount(2) + await page.screenshot({ path: process.env.LAYOUT_SHOTS ? `${process.env.LAYOUT_SHOTS}/files-home-after.png` : undefined, fullPage: true }) + + await managed.nth(0).getByRole('button', { name: 'LocalAI', exact: true }).click() + await expect(page.getByTestId('ws-folder-about')).toContainText('Settings LocalAI reads') + await expect(page.getByTestId('ws-managed')).toHaveCount(0) + await expect(page.getByTestId('ws-row')).toHaveCount(2) + await page.screenshot({ path: process.env.LAYOUT_SHOTS ? `${process.env.LAYOUT_SHOTS}/files-localai-after.png` : undefined, fullPage: true }) + }) + + test('asks before LocalAI/ goes to the Trash and says what stops working; Cancel changes nothing', async ({ page }) => { + const calls = await stubFiles(page) + await page.goto('/app/files') + const row = page.getByTestId('ws-managed').getByTestId('ws-row').first() + const trash = async () => { + await row.getByRole('button', { name: 'More actions for LocalAI' }).click() + await page.getByRole('menuitem', { name: 'Move to Trash' }).click() + } + await trash() + const dialog = page.getByRole('alertdialog') + await expect(dialog).toContainText('Move LocalAI to the Trash?') + await expect(dialog).toContainText('LocalAI forgets your persona, your profile, your shortcuts, your Home layout and your theme.') + await expect(dialog).toContainText('You can restore it from the Trash.') + await page.screenshot({ path: process.env.LAYOUT_SHOTS ? `${process.env.LAYOUT_SHOTS}/files-confirm-after.png` : undefined }) + await dialog.getByRole('button', { name: 'Cancel' }).click() + await expect(dialog).toHaveCount(0) + expect(calls).toEqual([]) + + await trash() + await page.getByRole('alertdialog').getByRole('button', { name: 'Move to Trash' }).click() + await expect.poll(() => calls).toEqual([['delete', '/LocalAI']]) + }) + + test('asks before a settings file is renamed, and not before the user\'s own file goes', async ({ page }) => { + const calls = await stubFiles(page) + await page.goto('/app/files?path=/LocalAI') + const persona = page.getByTestId('ws-row').filter({ hasText: 'persona.json' }) + await persona.getByRole('button', { name: 'More actions for persona.json' }).click() + await page.getByRole('menuitem', { name: 'Rename' }).click() + const box = page.getByRole('textbox', { name: 'Rename' }) + await box.fill('old-persona.json') + await box.press('Enter') + const dialog = page.getByRole('alertdialog') + await expect(dialog).toContainText('Rename LocalAI/persona.json?') + await expect(dialog).toContainText('The assistant loses its name and its instructions.') + await dialog.getByRole('button', { name: 'Rename' }).click() + await expect.poll(() => calls.length).toBe(1) + expect(calls[0][0]).toBe('move') + + await page.goto('/app/files') + await page.getByTestId('ws-row').filter({ hasText: 'report.txt' }).getByRole('button', { name: 'More actions for report.txt' }).click() + await page.getByRole('menuitem', { name: 'Move to Trash' }).click() + await expect.poll(() => calls.at(-1)).toEqual(['delete', '/report.txt']) + await expect(page.getByRole('alertdialog')).toHaveCount(0) + }) +}) diff --git a/core/http/react-ui/e2e/workspace-trash.spec.js b/core/http/react-ui/e2e/workspace-trash.spec.js new file mode 100644 index 000000000..01b524779 --- /dev/null +++ b/core/http/react-ui/e2e/workspace-trash.spec.js @@ -0,0 +1,117 @@ +import { test, expect } from './coverage-fixtures.js' + +// Deleting for good from the Trash tab of the Files page, against the real +// server: the browser, the files API, the workspace store and its blob store. +// The confirmation is in the page (a dialog element), never a browser dialog. + +const page_ = (page) => page.getByTestId('workspace-page') +const trashRows = (page) => page.getByTestId('ws-trash-row') + +async function upload(page, name, content) { + await page.getByTestId('ws-file-input').setInputFiles({ name, mimeType: 'text/plain', buffer: Buffer.from(content) }) + await expect(page.getByTestId('ws-row').filter({ hasText: name })).toBeVisible() +} + +async function trash(page, name) { + await page.getByTestId('ws-row').filter({ hasText: name }).getByRole('button', { name: `More actions for ${name}` }).click() + await page.getByRole('menuitem', { name: 'Move to Trash' }).click() + await expect(page.getByTestId('ws-row').filter({ hasText: name })).toHaveCount(0) +} + +const openTrash = (page) => page.getByRole('tab', { name: /Trash/ }).click() + +test.describe('Deleting from the Trash for good', () => { + test.beforeEach(async ({ page }) => { + // A native dialog would hang the test: fail it loudly instead. + page.on('dialog', async (d) => { await d.dismiss(); throw new Error(`browser dialog: ${d.message()}`) }) + await page.goto('/app/files') + await expect(page_(page)).toBeVisible() + await expect(page.getByTestId('files-off')).toHaveCount(0) + }) + + test('deletes one item permanently after a confirmation that says what goes', async ({ page }) => { + const gone = `purge-${Date.now()}.txt` + const kept = `keep-${Date.now()}.txt` + await upload(page, gone, '0123456789') + await upload(page, kept, 'abc') + await trash(page, gone) + await trash(page, kept) + await openTrash(page) + + const row = trashRows(page).filter({ hasText: gone }) + await expect(row).toBeVisible() + await expect(row.getByTestId('ws-trash-size')).toHaveText('1 file, 10 B') + + await row.getByTestId('ws-purge').click() + const dialog = page.getByRole('alertdialog') + await expect(dialog).toContainText('Delete permanently?') + await expect(dialog).toContainText('1 file, 10 B') + await expect(dialog).toContainText('It cannot be undone') + // Cancel keeps it. + await dialog.getByRole('button', { name: 'Cancel' }).click() + await expect(row).toBeVisible() + + await row.getByTestId('ws-purge').click() + await page.getByRole('alertdialog').getByRole('button', { name: 'Delete permanently' }).click() + await expect(row).toHaveCount(0) + await expect(page.getByTestId('ws-trash-done')).toContainText('Deleted permanently: 1 file, 10 B.') + // The other item is still there and still restores. + const other = trashRows(page).filter({ hasText: kept }) + await other.getByRole('button', { name: 'Restore' }).click() + await expect(other).toHaveCount(0) + await page.getByRole('tab', { name: 'Files' }).click() + await expect(page.getByTestId('ws-row').filter({ hasText: kept })).toBeVisible() + }) + + test('empties the whole Trash, counts it first, and then disables the button', async ({ page }) => { + const stamp = Date.now() + for (const [i, body] of ['aa', 'bbbb'].entries()) { + const name = `bulk-${stamp}-${i}.txt` + await upload(page, name, body) + await trash(page, name) + } + await openTrash(page) + const empty = page.getByTestId('ws-empty-trash') + await expect(empty).toBeEnabled() + await expect(page.getByTestId('ws-trash-summary')).toContainText('In the Trash:') + + await empty.click() + const dialog = page.getByRole('alertdialog') + await expect(dialog).toContainText('Empty the Trash?') + await expect(dialog).toContainText(/\d+ files?/) + await dialog.getByRole('button', { name: 'Empty Trash' }).click() + + await expect(trashRows(page)).toHaveCount(0) + await expect(page.getByTestId('ws-trash-summary')).toHaveText('The Trash is empty.') + await expect(empty).toBeDisabled() + await expect(page.getByTestId('ws-trash-done')).toContainText('Deleted permanently:') + // Nothing comes back after a reload: not in the Trash, not in Files. The + // Trash itself is the one of the test server's single user, which the + // specs of the other workers fill meanwhile, so it is not asked to be + // empty here. + const trashText = await (await page.request.get('/api/chats/files/trash')).text() + expect(trashText).not.toContain(`bulk-${stamp}-`) + await page.reload() + await openTrash(page) + await expect(trashRows(page).filter({ hasText: `bulk-${stamp}-` })).toHaveCount(0) + await page.getByRole('tab', { name: 'Files' }).click() + // The home always holds the LocalAI settings folder: the list has loaded. + await expect(page.getByTestId('ws-row').filter({ hasText: 'LocalAI' }).first()).toBeVisible() + await expect(page.getByTestId('ws-row').filter({ hasText: `bulk-${stamp}-` })).toHaveCount(0) + }) + + test('a deleted folder counts its files and folders', async ({ page }) => { + const folder = `Dir-${Date.now()}` + await page.getByRole('button', { name: 'New folder' }).click() + await page.getByRole('textbox', { name: 'Folder name' }).fill(folder) + await page.getByRole('textbox', { name: 'Folder name' }).press('Enter') + await page.getByTestId('ws-row').filter({ hasText: folder }).getByRole('button', { name: folder, exact: true }).click() + // Upload only once the new folder is open, so the file lands inside it. + await expect(page.getByTestId('ws-empty')).toContainText('This folder is empty') + await upload(page, 'inside.txt', '12345') + await page.getByTestId('ws-crumbs').getByRole('button', { name: 'Home' }).click() + await trash(page, folder) + await openTrash(page) + await expect(trashRows(page).filter({ hasText: folder }).getByTestId('ws-trash-size')).toHaveText('1 file, 1 folder, 5 B') + }) +}) diff --git a/core/http/react-ui/e2e/workspace-ui.spec.js b/core/http/react-ui/e2e/workspace-ui.spec.js new file mode 100644 index 000000000..f064f5f66 --- /dev/null +++ b/core/http/react-ui/e2e/workspace-ui.spec.js @@ -0,0 +1,86 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockChat } from './chat-fixtures.js' + +// The files page with the API stubbed, for the states a real server does not +// produce on demand: files off, an empty folder, a failed call. + +test.describe('Files page with a stubbed API', () => { + test.beforeEach(async ({ page }) => { + await mockChat(page) + }) + + test('says why files are off, and offers nothing to do', async ({ page }) => { + await page.route(/\/api\/chats\/files(\/trash)?(\?.*)?$/, route => route.fulfill({ + status: 409, json: { error: { type: 'files_off', message: 'There is no data path, so files are off.', code: 409 } }, + })) + await page.goto('/app/files') + await expect(page.getByTestId('files-off')).toContainText('There is no data path') + await expect(page.getByRole('button', { name: 'Upload' })).toHaveCount(0) + }) + + test('an upload that fails says why in the tray, and Try again sends it again', async ({ page }) => { + await page.route(/\/api\/chats\/files\/trash(\?.*)?$/, route => route.fulfill({ json: { entries: [] } })) + await page.route(/\/api\/chats\/files(\?.*)?$/, route => route.fulfill({ json: { path: '/', entries: [] } })) + let puts = 0 + await page.route('**/api/chats/files/content*', route => { + puts++ + if (puts === 1) return route.fulfill({ status: 500, json: { error: { message: 'disk full', code: 500 } } }) + return route.fulfill({ json: { path: '/a.txt', name: 'a.txt', kind: 'file', size: 1, version: 1, rev: 2 } }) + }) + await page.goto('/app/files') + await page.getByTestId('ws-file-input').setInputFiles({ name: 'a.txt', mimeType: 'text/plain', buffer: Buffer.from('x') }) + const row = page.getByTestId('ws-upload-row').filter({ hasText: 'a.txt' }) + await expect(row).toContainText('The server could not save it. The server said: disk full.') + await expect(page.getByTestId('ws-upload-tray')).toContainText('1 upload failed') + await expect(page.getByTestId('ws-upload-live')).toContainText('Could not upload a.txt.') + await row.getByRole('button', { name: 'Try a.txt again' }).click() + await expect(row).toContainText('Uploaded') + expect(puts).toBe(2) + }) + + test('a failed action shows its error until it is dismissed', async ({ page }) => { + await page.route(/\/api\/chats\/files\/trash(\?.*)?$/, route => route.fulfill({ json: { entries: [] } })) + await page.route(/\/api\/chats\/files(\?.*)?$/, route => { + if (route.request().method() === 'GET') return route.fulfill({ json: { path: '/', entries: [{ path: '/a.txt', name: 'a.txt', kind: 'file', size: 1, version: 1 }] } }) + return route.fulfill({ status: 500, json: { error: { message: 'boom', code: 500 } } }) + }) + await page.goto('/app/files') + await page.getByTestId('ws-row').filter({ hasText: 'a.txt' }).getByRole('button', { name: 'More actions for a.txt' }).click() + await page.getByRole('menuitem', { name: 'Move to Trash' }).click() + await expect(page.getByRole('alert')).toContainText('boom') + await page.getByRole('button', { name: 'Dismiss' }).click() + await expect(page.getByRole('alert')).toHaveCount(0) + }) + + test('a folder that cannot be read says so and offers Try again', async ({ page }) => { + await page.route(/\/api\/chats\/files\/trash(\?.*)?$/, route => route.fulfill({ json: { entries: [] } })) + let lists = 0 + await page.route(/\/api\/chats\/files(\?.*)?$/, route => { + lists++ + if (lists === 1) return route.fulfill({ status: 500, json: { error: { message: 'database locked', code: 500 } } }) + return route.fulfill({ json: { path: '/', entries: [{ path: '/a.txt', name: 'a.txt', kind: 'file', size: 1, version: 1 }] } }) + }) + await page.goto('/app/files') + const error = page.getByTestId('ws-load-error') + await expect(error).toContainText('Could not open Home.') + await expect(error).toContainText('database locked') + await error.getByRole('button', { name: 'Try again' }).click() + await expect(page.getByTestId('ws-row').filter({ hasText: 'a.txt' })).toBeVisible() + }) + + test('shows the host preview of a file no add-on previews, and no add-on frame', async ({ page }) => { + await page.route(/\/api\/chats\/files\/trash(\?.*)?$/, route => route.fulfill({ json: { entries: [] } })) + await page.route(/\/api\/chats\/files(\?.*)?$/, route => route.fulfill({ json: { path: '/', entries: [ + { path: '/plan.docx', name: 'plan.docx', kind: 'file', size: 2048, version: 1 }, + ] } })) + await page.goto('/app/files') + await page.getByTestId('ws-row').filter({ hasText: 'plan.docx' }).getByRole('button', { name: 'plan.docx', exact: true }).click() + const preview = page.getByTestId('file-preview') + await expect(preview).toContainText('plan.docx') + await expect(preview.getByTestId('ws-preview-meta')).toContainText('Document · 2.0 KB') + await expect(page.getByTestId('addon-preview')).toHaveCount(0) + await expect(preview.getByRole('link', { name: 'Download' })).toBeVisible() + await preview.getByRole('button', { name: 'Close' }).click() + await expect(preview).toHaveCount(0) + }) +}) diff --git a/core/http/react-ui/e2e/workspace.spec.js b/core/http/react-ui/e2e/workspace.spec.js new file mode 100644 index 000000000..614b69d50 --- /dev/null +++ b/core/http/react-ui/e2e/workspace.spec.js @@ -0,0 +1,149 @@ +import { test, expect } from './coverage-fixtures.js' + +// The files page against the real test server: the browser, the files API, the +// workspace store and its blob store. Nothing is stubbed, so a pass means a +// user can really keep, find, delete and restore files. + +const page_ = (page) => page.getByTestId('workspace-page') +const rows = (page) => page.getByTestId('ws-row') +const rowOf = (page, name) => rows(page).filter({ hasText: name }) + +async function upload(page, name, content) { + await page.getByTestId('ws-file-input').setInputFiles({ name, mimeType: 'text/plain', buffer: Buffer.from(content) }) +} + +async function rowAction(page, name, action) { + await rowOf(page, name).getByRole('button', { name: `More actions for ${name}` }).click() + await page.getByRole('menuitem', { name: action }).click() +} + +test.describe('Files against the real server', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/app/files') + await expect(page_(page)).toBeVisible() + // Every build has a workspace: a database, or a JSON index when there is none. + await expect(page.getByTestId('files-off')).toHaveCount(0) + }) + + test('uploads a file, previews it, renames it, and finds it by its text', async ({ page }) => { + const name = `notes-${Date.now()}.txt` + await upload(page, name, 'the quarterly budget is approved') + const row = rowOf(page, name) + await expect(row).toBeVisible() + await expect(page.getByTestId('ws-upload-row').filter({ hasText: name })).toContainText('Uploaded') + + await row.getByRole('button', { name, exact: true }).click() + await expect(page.getByTestId('file-preview')).toContainText('the quarterly budget is approved') + await page.keyboard.press('Escape') + await expect(page.getByTestId('file-preview')).toHaveCount(0) + + const renamed = `plan-${Date.now()}.txt` + await rowAction(page, name, 'Rename') + await page.getByRole('textbox', { name: 'Rename' }).fill(renamed) + await page.getByRole('textbox', { name: 'Rename' }).press('Enter') + await expect(rowOf(page, renamed)).toBeVisible() + await expect(rowOf(page, name)).toHaveCount(0) + await expect(page.getByTestId('ws-toast').filter({ hasText: `Renamed to ${renamed}.` })).toBeVisible() + + await page.getByRole('searchbox', { name: 'Search your files' }).fill('quarterly budget') + await page.getByRole('searchbox', { name: 'Search your files' }).press('Enter') + await expect(page.getByTestId('ws-hit').filter({ hasText: renamed }).first()).toBeVisible() + }) + + test('creates a folder, opens it, uploads into it and goes back up', async ({ page }) => { + const folder = `Reports-${Date.now()}` + await page.getByRole('button', { name: 'New folder' }).click() + const box = page.getByRole('textbox', { name: 'Folder name' }) + await expect(box).toHaveValue(/^New folder/) + await box.fill(folder) + await box.press('Enter') + const row = rowOf(page, folder) + await expect(row).toBeVisible() + await row.getByRole('button', { name: folder, exact: true }).click() + await expect(page.getByTestId('ws-empty')).toContainText('This folder is empty') + await expect(page).toHaveURL(new RegExp(`path=%2F${folder}`)) + await upload(page, 'inside.txt', 'x') + await expect(rowOf(page, 'inside.txt')).toBeVisible() + await page.getByTestId('ws-crumbs').getByRole('button', { name: 'Home' }).click() + await expect(rowOf(page, folder)).toBeVisible() + // Back returns to the folder. + await page.goBack() + await expect(rowOf(page, 'inside.txt')).toBeVisible() + }) + + test('a deleted file goes to the Trash, Undo brings it back, and so does Restore', async ({ page }) => { + const name = `gone-${Date.now()}.txt` + await upload(page, name, 'bye') + await expect(rowOf(page, name)).toBeVisible() + + await rowAction(page, name, 'Move to Trash') + await expect(rowOf(page, name)).toHaveCount(0) + const toast = page.getByTestId('ws-toast').filter({ hasText: `${name} moved to the Trash.` }) + await toast.getByRole('button', { name: 'Undo' }).click() + await expect(rowOf(page, name)).toBeVisible() + + await rowAction(page, name, 'Move to Trash') + await expect(rowOf(page, name)).toHaveCount(0) + await page.getByRole('tab', { name: /Trash/ }).click() + const trashed = page.getByTestId('ws-trash-row').filter({ hasText: name }) + await expect(trashed).toBeVisible() + await trashed.getByRole('button', { name: 'Restore' }).click() + await expect(trashed).toHaveCount(0) + + await page.getByRole('tab', { name: 'Files' }).click() + await expect(rowOf(page, name)).toBeVisible() + }) + + test('moves a file into a folder with the Move dialog, and Undo moves it back', async ({ page }) => { + const stamp = Date.now() + const folder = `Inbox-${stamp}` + const name = `move-me-${stamp}.txt` + await page.getByRole('button', { name: 'New folder' }).click() + await page.getByRole('textbox', { name: 'Folder name' }).fill(folder) + await page.getByRole('textbox', { name: 'Folder name' }).press('Enter') + await upload(page, name, 'move') + await expect(rowOf(page, name)).toBeVisible() + + await rowAction(page, name, 'Move to…') + const dialog = page.getByTestId('ws-move') + await dialog.getByRole('button', { name: folder }).click() + await dialog.getByRole('button', { name: `Move to ${folder}` }).click() + await expect(rowOf(page, name)).toHaveCount(0) + await page.getByTestId('ws-toast').filter({ hasText: `${name} moved to ${folder}.` }).getByRole('button', { name: 'Undo' }).click() + await expect(rowOf(page, name)).toBeVisible() + }) + + test('shows the progress of a slow upload, cancels it, and sends it again', async ({ page }) => { + const cdp = await page.context().newCDPSession(page) + await cdp.send('Network.enable') + await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: 256 * 1024 }) + const name = `big-${Date.now()}.bin` + await page.getByTestId('ws-file-input').setInputFiles({ name, mimeType: 'application/octet-stream', buffer: Buffer.alloc(3 * 1024 * 1024, 1) }) + const up = page.getByTestId('ws-upload-row').filter({ hasText: name }) + await expect(page.getByTestId('ws-upload-tray')).toContainText('Uploading 1 file') + await expect(up.getByTestId('ws-upload-line')).toContainText(/[0-9.]+ (KB|MB) of 3\.0 MB/) + await expect.poll(async () => Number(await up.getByRole('progressbar').getAttribute('value')), { timeout: 10_000 }).toBeGreaterThan(0) + + await up.getByRole('button', { name: `Cancel the upload of ${name}` }).click() + await expect(up).toContainText('Canceled') + await expect(rowOf(page, name)).toHaveCount(0) + + await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }) + await up.getByRole('button', { name: `Try ${name} again` }).click() + await expect(up).toContainText('Uploaded', { timeout: 15_000 }) + await expect(rowOf(page, name)).toBeVisible() + await expect(page.getByTestId('ws-upload-live')).toContainText('Upload done: 1 file.') + }) + + test('shows what changed in a folder and lets the user undo it', async ({ page }) => { + const name = `undo-${Date.now()}.txt` + await upload(page, name, 'first') + await expect(rowOf(page, name)).toBeVisible() + await page.getByRole('tab', { name: 'History' }).click() + const entry = page.getByTestId('ws-history-row').filter({ hasText: `Upload ${name}` }) + await expect(entry).toBeVisible() + await entry.getByRole('button', { name: 'Undo' }).click() + await page.getByRole('tab', { name: 'Files' }).click() + await expect(rowOf(page, name)).toHaveCount(0) + }) +}) diff --git a/core/http/react-ui/e2e/ws-recurring-real.spec.js b/core/http/react-ui/e2e/ws-recurring-real.spec.js new file mode 100644 index 000000000..21429aba3 --- /dev/null +++ b/core/http/react-ui/e2e/ws-recurring-real.spec.js @@ -0,0 +1,141 @@ +import { test, expect } from './coverage-fixtures.js' +import { holdHomeFiles, unlockHomeFiles } from './home-files-lock.js' +import { startChat, b64 } from './journey-fixtures.js' + +// Recurring updates against the real test server and the mock model: the +// model proposes a trigger that rewrites the data file of a Home widget, the +// card says what a run reads, writes and costs, the widget waits for it, and +// once created the widget's footer runs, pauses and resumes the real trigger. +// The run states the server cannot be made to show on demand (a failed run, a +// broken file next to a good one) are drawn by ws-widgets.spec.js. + +test.describe.configure({ mode: 'serial', timeout: 120_000 }) + +// Other spec files write the same Home files: hold them while this file runs. +holdHomeFiles(test) + +const api = (request, path) => request.get(path).then(r => r.json()) +const upload = (request, path, body) => request.put(`/api/chats/files/content?path=${encodeURIComponent(path)}&overwrite=1`, { + data: typeof body === 'string' ? body : JSON.stringify(body), headers: { 'Content-Type': 'application/octet-stream' }, +}) +// Screenshots wait for the entry animations, so a sheet is drawn as it stays. +const settle = (page) => page.evaluate(() => Promise.all(document.getAnimations().filter(a => a.effect?.getTiming().iterations !== Infinity).map(a => a.finished.catch(() => {})))) +const shot = async (page, name) => { await settle(page); await page.screenshot({ path: `test-results/ws-share/${name}.png` }) } +const VIEWS = [ + { name: 'desktop', width: 1440, height: 900 }, + { name: 'phone', width: 390, height: 844 }, +] + +async function clean(request) { + for (const p of ['/LocalAI/home.json', '/data/inbox.json', '/LocalAI/ui.json']) await request.delete(`/api/chats/files?path=${encodeURIComponent(p)}`) + const all = await api(request, '/api/chats/triggers') + for (const t of all.triggers || []) if (t.name.startsWith('Morning inbox')) await request.delete(`/api/chats/triggers/${t.id}`) + const props = await api(request, '/api/chats/triggers/proposals') + for (const t of props.triggers || []) await request.delete(`/api/chats/triggers/proposals/${t.id}`) +} + +test.beforeAll(async ({ request }) => { await clean(request) }) +test.afterAll(async ({ request }) => { await clean(request) }) +test.afterAll(() => unlockHomeFiles()) + +let proposalId = '' + +test('a chat proposes a trigger for a widget: the card lists when, as whom, what it reads and writes, its cost and how to stop it', async ({ page }) => { + await upload(page.request, '/LocalAI/home.json', { widgets: [{ id: 'inbox', type: 'list', title: 'Inbox this morning', source: 'data/inbox.json', note: 'kept' }] }) + await upload(page.request, '/data/inbox.json', { + title: 'Inbox', updatedAt: new Date().toISOString(), source: { kind: 'assistant' }, + items: [{ label: '3 need a reply: the printer quote, Sara, the dentist' }, { label: '9 newsletters and 2 receipts' }], + }) + const args = { + name: 'Morning inbox summary', prompt: 'Rewrite data/inbox.json with the mail of the last day.', when: 'daily 08:00', time_zone: 'Europe/Rome', + reads: 'Mail from the last 24 hours', writes: 'data/inbox.json', widget: 'inbox', estimated_tokens: 1500, + } + const id = await startChat(page, `ADDON_TOOL_CALL:propose_trigger:${b64(args)}`) + const card = page.getByTestId('trigger-proposal').first() + await expect(card).toHaveAttribute('data-state', 'proposed', { timeout: 30_000 }) + const plan = card.getByTestId('trigger-plan') + await expect(plan.locator('[data-row="when"]')).toContainText('Europe/Rome') + await expect(plan.locator('[data-row="when"]')).toContainText('First run') + await expect(plan.locator('[data-row="runsAs"]')).toContainText('You, with mock-model') + await expect(plan.locator('[data-row="reads"]')).toContainText('Mail from the last 24 hours') + await expect(plan.locator('[data-row="writes"]')).toContainText('data/inbox.json, shown in the "Inbox this morning" widget on Home') + await expect(plan.locator('[data-row="cost"]')).toContainText('About 1,500 tokens a run on a local model. At most 1 run a day.') + await expect(plan.locator('[data-row="stop"]')).toContainText('Pause it from the widget, or delete it in Triggers') + await card.scrollIntoViewIfNeeded() + await shot(page, 's3-proposal-card-desktop-dark') + const props = await api(page.request, '/api/chats/triggers/proposals') + proposalId = props.triggers.find(t => t.name === args.name).id + expect(id).toBeTruthy() + + // Home: the widget says it waits for the trigger and links to the chat. + await page.goto('/app') + const foot = page.getByTestId('home-widget-inbox').getByTestId('widget-footer') + await expect(foot).toHaveAttribute('data-state', 'waiting') + await expect(foot).toContainText('Waiting for you to create its trigger in the chat.') + await expect(foot.getByTestId('widget-open-chat')).toHaveAttribute('href', `/app/chats/${id}`) +}) + +test('a schedule more often than hourly from a chat is refused and nothing is stored', async ({ page }) => { + const before = (await api(page.request, '/api/chats/triggers/proposals')).triggers.length + await startChat(page, `ADDON_TOOL_CALL:propose_trigger:${b64({ name: 'Morning inbox fast', prompt: 'p', when: '*/15 * * * *' })}`) + await expect.poll(async () => { + const chats = await api(page.request, '/api/chats?limit=5') + return chats.conversations?.[0]?.running + }, { timeout: 60_000 }).toBe(false) + expect((await api(page.request, '/api/chats/triggers/proposals')).triggers.length).toBe(before) +}) + +test('once created, the widget names its trigger and its footer runs, pauses and resumes it', async ({ page }) => { + expect(proposalId).toBeTruthy() + const ok = await page.request.post(`/api/chats/triggers/proposals/${proposalId}/confirm`, { data: { time_zone: 'Europe/Rome' } }) + expect(ok.ok()).toBe(true) + const home = await api(page.request, '/api/chats/wellknown/home') + expect(home.data.widgets[0]).toMatchObject({ id: 'inbox', trigger: proposalId, note: 'kept' }) + + await page.goto('/app') + const widget = page.getByTestId('home-widget-inbox') + const foot = widget.getByTestId('widget-footer') + await expect(foot).toHaveAttribute('data-state', 'firstRun') + await expect(foot).toContainText('First run') + await expect(widget).toContainText('Daily') + await expect(page.getByTestId('home-schedule-note')).toContainText('1 widget updates on a schedule.') + await expect(widget).toHaveAttribute('aria-describedby', 'wk-foot-inbox') + + await foot.getByTestId('widget-run-now').click() + await expect.poll(async () => (await api(page.request, `/api/chats/triggers/${proposalId}`)).last_run?.status, { timeout: 60_000 }).toMatch(/done|failed/) + await page.reload() + await expect(foot).toHaveAttribute('data-state', /fresh|stale/, { timeout: 15_000 }) + if (await foot.getAttribute('data-state') === 'fresh') { + await expect(foot).toContainText('updated') + await foot.getByTestId('widget-pause').click() + } else { + await page.request.post(`/api/chats/triggers/${proposalId}/disable`) + await page.reload() + } + await expect(foot).toHaveAttribute('data-state', 'paused') + await expect(widget).toContainText('Paused') + expect((await api(page.request, `/api/chats/triggers/${proposalId}`)).enabled).toBe(false) + await foot.getByTestId('widget-resume').click() + await expect.poll(async () => (await api(page.request, `/api/chats/triggers/${proposalId}`)).enabled).toBe(true) + await expect(foot).not.toHaveAttribute('data-state', 'paused') + await expect(foot.getByTestId('widget-details').or(foot.getByTestId('widget-pause'))).toBeVisible() +}) + +for (const v of VIEWS) { + for (const theme of ['dark', 'light']) { + test(`the trigger proposal card and the widget, ${v.name} ${theme}`, async ({ page }) => { + await page.setViewportSize({ width: v.width, height: v.height }) + await page.addInitScript((m) => localStorage.setItem('localai-theme', m), theme) + await page.goto('/app') + await expect(page.getByTestId('home-widget-inbox').getByTestId('widget-footer')).toBeVisible() + await page.getByTestId('home-widget-inbox').scrollIntoViewIfNeeded() + await shot(page, `s3-home-widget-${v.name}-${theme}`) + const args = { name: 'Morning inbox shot', prompt: 'p', when: 'daily 08:00', time_zone: 'Europe/Rome', reads: 'Mail from the last 24 hours', writes: 'data/inbox.json', widget: 'inbox', estimated_tokens: 1500 } + await startChat(page, `ADDON_TOOL_CALL:propose_trigger:${b64(args)}`) + const card = page.getByTestId('trigger-proposal').first() + await expect(card.getByTestId('trigger-plan')).toBeVisible({ timeout: 30_000 }) + await card.scrollIntoViewIfNeeded() + await shot(page, `s3-proposal-card-${v.name}-${theme}`) + }) + } +} diff --git a/core/http/react-ui/e2e/ws-widget-links.spec.js b/core/http/react-ui/e2e/ws-widget-links.spec.js new file mode 100644 index 000000000..46a1a76a5 --- /dev/null +++ b/core/http/react-ui/e2e/ws-widget-links.spec.js @@ -0,0 +1,89 @@ +import { test, expect } from './coverage-fixtures.js' +import { holdHomeFiles, unlockHomeFiles } from './home-files-lock.js' + +// A list widget whose items link: an http(s) address asks first, with the +// confirmation bar of page links, then opens a new tab without an opener; a +// path of the app navigates inside LocalAI directly; and a file with a bad +// link is refused with an error that names the item. PAGES_SHOTS names a +// folder for screenshots. + +test.describe.configure({ mode: 'serial', timeout: 60_000 }) +holdHomeFiles(test) + +const shots = process.env.PAGES_SHOTS || '' +const upload = (request, path, body) => request.put(`/api/chats/files/content?path=${encodeURIComponent(path)}&overwrite=1`, { + data: typeof body === 'string' ? body : JSON.stringify(body), headers: { 'Content-Type': 'application/octet-stream' }, +}) +const widgets = [{ id: 'news', type: 'list', title: 'Morning news', source: 'data/w-news.json' }] +const news = { + updatedAt: new Date().toISOString(), source: { kind: 'trigger', note: 'Three sites, last 24 hours' }, + items: [ + { label: 'Local models get faster on small boards', meta: 'example.com', url: 'https://news.example.com/small-boards' }, + { label: 'The release notes of this week, with every change', meta: 'pages', url: '/app/pages' }, + { label: 'A row without a link', meta: 'note' }, + ], +} + +test.beforeAll(async ({ request }) => { + await upload(request, '/LocalAI/home.json', { widgets }) + await upload(request, '/data/w-news.json', news) +}) +test.afterAll(async ({ request }) => { + for (const p of ['/LocalAI/home.json', '/data/w-news.json']) await request.delete(`/api/chats/files?path=${encodeURIComponent(p)}`) +}) +test.afterAll(() => unlockHomeFiles()) + +test('widget items ask before they open another site, and link inside the app directly', async ({ page, context }) => { + await page.route('**/api/chats/triggers*', (r) => r.fulfill({ json: { triggers: [] } })) + await context.route('https://news.example.com/**', (r) => r.fulfill({ contentType: 'text/html', body: 'news

      article

      ' })) + await page.addInitScript(() => { try { localStorage.setItem('localai-theme', 'light') } catch { /* private mode */ } }) + await page.goto('/app') + const w = page.getByTestId('home-widget-news') + const links = w.getByTestId('widget-item-link') + await expect(links).toHaveCount(2) + const ext = links.first() + await expect(ext).toHaveAttribute('data-href', 'https://news.example.com/small-boards') + await expect(ext).not.toHaveAttribute('href', /.*/) + await expect(ext).toHaveAttribute('title', 'news.example.com: opens in a new tab') + await expect(w.locator('li').nth(2).getByTestId('widget-item-link')).toHaveCount(0) + // Keyboard: the link takes the focus and shows its ring. + await ext.focus() + await expect(ext).toBeFocused() + expect(await ext.evaluate((el) => getComputedStyle(el).outlineStyle)).toBe('solid') + // A click asks first; Cancel opens nothing. + await ext.click() + const ask = w.getByTestId('page-link-ask') + await expect(ask).toBeVisible() + await expect(ask).toContainText('This widget wants to open a link') + await expect(ask.getByTestId('page-link-host')).toHaveText('news.example.com') + if (shots) await page.screenshot({ path: `${shots}/widget-links-light.png` }) + await ask.getByTestId('page-link-cancel').click() + await expect(ask).toHaveCount(0) + expect(context.pages()).toHaveLength(1) + await ext.click() + const [tab] = await Promise.all([context.waitForEvent('page'), w.getByTestId('page-link-open').click()]) + await tab.waitForLoadState() + expect(tab.url()).toBe('https://news.example.com/small-boards') + expect(await tab.evaluate(() => window.opener)).toBeNull() + await tab.close() + await links.nth(1).click() + await expect(page).toHaveURL(/\/app\/pages$/) +}) + +test('the widget in the dark theme', async ({ page }) => { + await page.route('**/api/chats/triggers*', (r) => r.fulfill({ json: { triggers: [] } })) + await page.addInitScript(() => { try { localStorage.setItem('localai-theme', 'dark') } catch { /* private mode */ } }) + await page.goto('/app') + await expect(page.getByTestId('home-widget-news').getByTestId('widget-item-link')).toHaveCount(2) + await page.getByTestId('home-widget-news').getByTestId('widget-item-link').first().hover() + if (shots) await page.screenshot({ path: `${shots}/widget-links-dark.png` }) +}) + +test('a bad link is refused with an error that names the item', async ({ request }) => { + const bad = { ...news, items: [...news.items, { label: 'x', url: 'javascript:alert(1)' }] } + const res = await request.put(`/api/chats/wellknown/widget-data?path=${encodeURIComponent('data/w-news.json')}`, { data: { values: bad, replace: true } }) + expect(res.ok()).toBeFalsy() + expect(JSON.stringify(await res.json())).toContain('items[3].url: a link is an http or https address') + const schema = await (await request.get('/api/chats/wellknown/widget-data/schema')).json() + expect(JSON.stringify(schema)).toContain('"url"') +}) diff --git a/core/http/react-ui/e2e/ws-widgets.spec.js b/core/http/react-ui/e2e/ws-widgets.spec.js new file mode 100644 index 000000000..46ee38db9 --- /dev/null +++ b/core/http/react-ui/e2e/ws-widgets.spec.js @@ -0,0 +1,133 @@ +import { test, expect } from './coverage-fixtures.js' +import { holdHomeFiles, unlockHomeFiles } from './home-files-lock.js' + +// The footer of every state of a Home widget fed by a trigger (fresh, from a +// page, stale after a failed run, paused, running, first run, failed with no +// data, broken file, missing file, waiting for confirmation). The files are +// real, on the test server; the trigger list is answered by the spec, because +// the server cannot be made to fail a run or hold one running on demand. + +test.describe.configure({ mode: 'serial', timeout: 60_000 }) + +// Other spec files write the same Home files: hold them while this file runs. +holdHomeFiles(test) + +const upload = (request, path, body) => request.put(`/api/chats/files/content?path=${encodeURIComponent(path)}&overwrite=1`, { + data: typeof body === 'string' ? body : JSON.stringify(body), headers: { 'Content-Type': 'application/octet-stream' }, +}) +// Screenshots wait for the entry animations, so a sheet is drawn as it stays. +const settle = (page) => page.evaluate(() => Promise.all(document.getAnimations().filter(a => a.effect?.getTiming().iterations !== Infinity).map(a => a.finished.catch(() => {})))) +const shot = async (page, name) => { await settle(page); await page.screenshot({ path: `test-results/ws-share/${name}.png`, fullPage: true }) } +const now = new Date() +const iso = (ms) => new Date(now.getTime() + ms).toISOString() +const H = 3600_000 + +const widgets = [ + { id: 'inbox', type: 'list', title: 'Inbox this morning', source: 'data/w-inbox.json', trigger: 'tr-fresh' }, + { id: 'tasks', type: 'list', title: 'Open tasks', source: 'data/w-tasks.json' }, + { id: 'week', type: 'metric', title: 'Week in review', source: 'data/w-week.json', trigger: 'tr-stale' }, + { id: 'reading', type: 'list', title: 'Reading digest', source: 'data/w-reading.json', trigger: 'tr-paused' }, + { id: 'releases', type: 'list', title: 'Release calendar', source: 'data/w-releases.json', trigger: 'tr-running' }, + { id: 'standup', type: 'list', title: 'Standup notes', source: 'data/w-standup.json', trigger: 'tr-first' }, + { id: 'empty', type: 'list', title: 'Build health', source: 'data/w-none.json', trigger: 'tr-failed' }, + { id: 'broken', type: 'list', title: 'Invoices', source: 'data/w-broken.json' }, + { id: 'later', type: 'list', title: 'Mentions', source: 'data/w-later.json' }, +] +const data = { + 'data/w-inbox.json': { updatedAt: iso(-2 * H), source: { kind: 'trigger' }, items: [{ label: '3 need a reply: the printer quote, Sara, the dentist' }, { label: '9 newsletters and 2 receipts, nothing urgent' }] }, + 'data/w-tasks.json': { updatedAt: iso(-H), source: { kind: 'page', page: 'tasks' }, items: [{ label: 'Send the print invoice', meta: 'Wed' }, { label: 'Review the release checklist', meta: 'Thu' }, { label: 'Call the dentist', meta: 'Fri' }] }, + 'data/w-week.json': { updatedAt: iso(-4 * 24 * H), value: 12, note: 'chats this week, 4 with files' }, + 'data/w-reading.json': { updatedAt: iso(-3 * 24 * H), items: [{ label: 'Local models on small GPUs', meta: '12 min' }, { label: 'Notes on writing short docs', meta: '6 min' }] }, + 'data/w-releases.json': { updatedAt: iso(-H), items: [{ label: 'v3.9 code freeze', meta: 'Mon 2' }, { label: 'v3.9 release', meta: 'Thu 5' }] }, + 'data/w-standup.json': { items: [] }, + 'data/w-later.json': { items: [{ label: '2 mentions in the release thread' }] }, +} +const trig = (id, over) => ({ + id, name: id, kind: 'schedule', schedule: 'daily 08:00', time_zone: '', enabled: true, summary: 'Every day at 08:00', daily_cap: 24, + next_run: iso(20 * H), plan: {}, cost: { estimated_tokens: 1500, max_runs_per_day: 1, remote: false }, ...over, +}) +const TRIGGERS = [ + trig('tr-fresh', { last_run: { id: 'r1', status: 'done', due: iso(-2 * H) }, last_done: iso(-2 * H), cost: { average_tokens: 1420, runs: 5, max_runs_per_day: 1 } }), + trig('tr-stale', { schedule: 'weekly fri 08:00', summary: 'Every Friday at 08:00', last_run: { id: 'r2', status: 'failed', reason: 'the model is not loaded', due: iso(-H) } }), + trig('tr-paused', { enabled: false, next_run: undefined, last_run: { id: 'r3', status: 'done', due: iso(-3 * 24 * H) } }), + trig('tr-running', { schedule: '0 * * * *', summary: 'Every hour at :00', last_run: { id: 'r4', status: 'running', chat_id: 'c-run', due: iso(-60_000) } }), + trig('tr-first', { schedule: 'weekdays 09:00', summary: 'Every weekday at 09:00' }), + trig('tr-failed', { last_run: { id: 'r5', status: 'failed', due: iso(-H) } }), +] +const PROPOSALS = [trig('pr-later', { plan: { widget: 'later' }, source_chat_id: 'c-prop', enabled: false })] + +async function mockTriggers(page) { + const calls = [] + await page.route('**/api/chats/triggers', (r) => r.fulfill({ json: { triggers: TRIGGERS } })) + await page.route('**/api/chats/triggers/proposals*', (r) => r.fulfill({ json: { triggers: PROPOSALS } })) + await page.route('**/api/chats/triggers/*/{enable,disable,run}', (r) => { calls.push(new URL(r.request().url()).pathname); r.fulfill({ status: 200, json: {} }) }) + return calls +} + +test.beforeAll(async ({ request }) => { + await upload(request, '/LocalAI/home.json', { widgets }) + for (const [p, body] of Object.entries(data)) await upload(request, `/${p}`, body) + await upload(request, '/data/w-broken.json', { items: [{ label: 'Paid: the studio rent' }] }) + await upload(request, '/data/w-broken.json', '{"items": "not a list"}') + await request.delete(`/api/chats/files?path=${encodeURIComponent('/data/w-none.json')}`) +}) +test.afterAll(async ({ request }) => { + for (const p of ['/LocalAI/home.json', ...Object.keys(data).map(p => `/${p}`), '/data/w-broken.json']) await request.delete(`/api/chats/files?path=${encodeURIComponent(p)}`) +}) +test.afterAll(() => unlockHomeFiles()) + +test('every footer state says what happens and offers the action that fits', async ({ page }) => { + const calls = await mockTriggers(page) + await page.goto('/app') + const foot = (id) => page.getByTestId(`home-widget-${id}`).getByTestId('widget-footer') + await expect(foot('inbox')).toHaveAttribute('data-state', 'fresh') + await expect(foot('inbox')).toContainText('Every day at 08:00 · updated') + await expect(foot('inbox')).toContainText('about 1,420 tokens a run') + await expect(foot('tasks')).toHaveAttribute('data-state', 'page') + await expect(foot('tasks')).toContainText('From the page tasks') + await expect(foot('week')).toHaveAttribute('data-state', 'stale') + await expect(foot('week')).toContainText(/Last update failed\. Showing \w+'s data\./) + await expect(page.getByTestId('home-widget-week')).toHaveClass(/wk-widget--dim/) + await expect(page.getByTestId('home-widget-week')).toContainText('Weekly') + await expect(foot('reading')).toHaveAttribute('data-state', 'paused') + await expect(page.getByTestId('home-widget-reading')).toContainText('Paused') + await expect(foot('releases')).toHaveAttribute('data-state', 'running') + await expect(foot('releases').getByTestId('widget-open-run')).toHaveAttribute('href', '/app/chats/c-run') + await expect(page.getByTestId('home-widget-releases')).toContainText('Hourly') + await expect(foot('standup')).toHaveAttribute('data-state', 'firstRun') + await expect(foot('empty')).toHaveAttribute('data-state', 'failedEmpty') + await expect(foot('empty')).toContainText('The update failed and there is no data yet.') + await expect(foot('broken')).toHaveAttribute('data-state', 'invalid') + await expect(foot('broken')).toContainText('The data has an error. Showing the last good version.') + await expect(foot('later')).toHaveAttribute('data-state', 'waiting') + await expect(foot('later').getByTestId('widget-open-chat')).toHaveAttribute('href', '/app/chats/c-prop') + await expect(page.getByTestId('home-schedule-note')).toContainText('5 widgets update on a schedule.') + + await foot('inbox').getByTestId('widget-pause').click() + await foot('reading').getByTestId('widget-resume').click() + await foot('week').getByTestId('widget-retry').click() + await foot('standup').getByTestId('widget-run-now').click() + await expect.poll(() => calls.sort()).toEqual([ + '/api/chats/triggers/tr-fresh/disable', '/api/chats/triggers/tr-first/run', '/api/chats/triggers/tr-paused/enable', '/api/chats/triggers/tr-stale/run', + ].sort()) + await expect(foot('week').getByTestId('widget-details')).toHaveAttribute('href', '/app/triggers/tr-stale') + + // Revert writes the last good version of the broken file. + await foot('broken').getByTestId('widget-revert').click() + await expect(foot('broken')).toHaveAttribute('data-state', 'file') +}) + +for (const v of [{ name: 'desktop', width: 1440, height: 900 }, { name: 'phone', width: 390, height: 844 }]) { + for (const theme of ['dark', 'light']) { + test(`widget states, ${v.name} ${theme}`, async ({ page, request }) => { + await upload(request, '/data/w-broken.json', '{"items": "not a list"}') + await mockTriggers(page) + await page.setViewportSize({ width: v.width, height: v.height }) + await page.addInitScript((m) => localStorage.setItem('localai-theme', m), theme) + await page.goto('/app') + await expect(page.getByTestId('home-widget-later').getByTestId('widget-footer')).toBeVisible() + await page.getByTestId('home-widgets').scrollIntoViewIfNeeded() + await shot(page, `s3-widget-states-${v.name}-${theme}`) + }) + } +} diff --git a/core/http/react-ui/eslint.config.js b/core/http/react-ui/eslint.config.js index a70e3952b..7f2d10f4d 100644 --- a/core/http/react-ui/eslint.config.js +++ b/core/http/react-ui/eslint.config.js @@ -29,4 +29,9 @@ export default [ 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^(Icon|FaIcon)$' }], }, }, + { + // Playwright fixtures call their `use` callback; it is not a React hook. + files: ['e2e/**/*.js'], + rules: { 'react-hooks/rules-of-hooks': 'off' }, + }, ] diff --git a/core/http/react-ui/i18n-baseline.json b/core/http/react-ui/i18n-baseline.json new file mode 100644 index 000000000..ce10f8d8e --- /dev/null +++ b/core/http/react-ui/i18n-baseline.json @@ -0,0 +1,141 @@ +{ + "de/admin": 25, + "de/agents": 217, + "de/auth": 65, + "de/chat": 126, + "de/chats": 272, + "de/collections": 73, + "de/common": 1, + "de/home": 118, + "de/importModel": 64, + "de/media": 490, + "de/modelEditor": 22, + "de/models": 418, + "de/operate": 413, + "de/pages": 39, + "de/skills": 17, + "de/swarm": 491, + "de/traffic": 479, + "de/workspace": 19, + "es/admin": 25, + "es/agents": 217, + "es/auth": 65, + "es/chat": 126, + "es/chats": 272, + "es/collections": 73, + "es/common": 1, + "es/home": 118, + "es/importModel": 64, + "es/media": 490, + "es/modelEditor": 22, + "es/models": 418, + "es/operate": 413, + "es/pages": 39, + "es/skills": 17, + "es/swarm": 491, + "es/traffic": 479, + "es/workspace": 19, + "fr/admin": 4, + "fr/agents": 217, + "fr/auth": 75, + "fr/chat": 121, + "fr/chats": 272, + "fr/collections": 73, + "fr/home": 113, + "fr/importModel": 62, + "fr/media": 263, + "fr/modelEditor": 22, + "fr/models": 402, + "fr/operate": 413, + "fr/pages": 39, + "fr/skills": 17, + "fr/swarm": 491, + "fr/traffic": 479, + "fr/workspace": 19, + "id/admin": 4, + "id/agents": 217, + "id/auth": 65, + "id/chat": 126, + "id/chats": 272, + "id/collections": 73, + "id/home": 118, + "id/importModel": 64, + "id/media": 453, + "id/models": 407, + "id/operate": 413, + "id/pages": 39, + "id/skills": 17, + "id/swarm": 491, + "id/traffic": 479, + "id/workspace": 19, + "it/admin": 25, + "it/agents": 217, + "it/auth": 65, + "it/chat": 126, + "it/chats": 272, + "it/collections": 73, + "it/common": 1, + "it/home": 118, + "it/importModel": 64, + "it/media": 490, + "it/modelEditor": 22, + "it/models": 418, + "it/operate": 413, + "it/pages": 39, + "it/skills": 17, + "it/swarm": 491, + "it/traffic": 479, + "it/workspace": 19, + "ko/admin": 4, + "ko/agents": 217, + "ko/auth": 65, + "ko/chat": 126, + "ko/chats": 272, + "ko/collections": 73, + "ko/common": 1, + "ko/home": 118, + "ko/importModel": 64, + "ko/media": 490, + "ko/modelEditor": 22, + "ko/models": 429, + "ko/operate": 413, + "ko/pages": 39, + "ko/skills": 17, + "ko/swarm": 491, + "ko/traffic": 479, + "ko/workspace": 19, + "pt-BR/admin": 4, + "pt-BR/agents": 217, + "pt-BR/auth": 65, + "pt-BR/chat": 123, + "pt-BR/chats": 272, + "pt-BR/collections": 73, + "pt-BR/home": 107, + "pt-BR/importModel": 64, + "pt-BR/media": 300, + "pt-BR/models": 403, + "pt-BR/operate": 413, + "pt-BR/pages": 39, + "pt-BR/skills": 17, + "pt-BR/swarm": 491, + "pt-BR/traffic": 479, + "pt-BR/workspace": 19, + "zh-CN/admin": 25, + "zh-CN/agents": 217, + "zh-CN/auth": 65, + "zh-CN/chat": 126, + "zh-CN/chats": 272, + "zh-CN/collections": 73, + "zh-CN/common": 1, + "zh-CN/home": 118, + "zh-CN/importModel": 64, + "zh-CN/media": 490, + "zh-CN/modelEditor": 22, + "zh-CN/models": 418, + "zh-CN/operate": 413, + "zh-CN/pages": 39, + "zh-CN/skills": 17, + "zh-CN/swarm": 491, + "zh-CN/traffic": 479, + "zh-CN/workspace": 19 +} diff --git a/core/http/react-ui/inline-style-baseline.txt b/core/http/react-ui/inline-style-baseline.txt index c5356ba1e..415196e47 100644 --- a/core/http/react-ui/inline-style-baseline.txt +++ b/core/http/react-ui/inline-style-baseline.txt @@ -1 +1 @@ -174 +118 diff --git a/core/http/react-ui/playwright.config.js b/core/http/react-ui/playwright.config.js index e831ebec3..3a329ecd4 100644 --- a/core/http/react-ui/playwright.config.js +++ b/core/http/react-ui/playwright.config.js @@ -8,11 +8,16 @@ export default defineConfig({ // load them. Every one has its own config (playwright..config.js) and // is started by scripts/addons/*.sh. The library sources spec needs a server // started with --library-sources and runs from playwright.library.config.js. + // The shared pages spec needs a server started with --shared-pages-fixture + // and runs from playwright.shared-pages.config.js, and the shared folders + // spec, which changes the fixture's grants, from + // playwright.shared-folders.config.js. testIgnore: [ - '**/addons-foundation.spec.js', '**/addons-gallery.spec.js', '**/addons-proposals.spec.js', + '**/addons-foundation.spec.js', '**/addons-gallery.spec.js', '**/addons-recovery.spec.js', '**/addons-runtime.spec.js', '**/addons-slots.spec.js', '**/addons-upgrade.spec.js', '**/tables-acceptance.spec.js', '**/tables-artifacts.spec.js', - '**/tables-ui.spec.js', '**/library-sources.spec.js', + '**/tables-ui.spec.js', '**/library-sources.spec.js', '**/shared-pages-real.spec.js', + '**/shared-folders-real.spec.js', ], globalSetup: './e2e/global-setup.js', timeout: 30_000, diff --git a/core/http/react-ui/playwright.library.config.js b/core/http/react-ui/playwright.library.config.js index cd5c78f50..67086e46d 100644 --- a/core/http/react-ui/playwright.library.config.js +++ b/core/http/react-ui/playwright.library.config.js @@ -10,6 +10,16 @@ const port = Number(process.env.LIBRARY_UI_PORT || 8097) export default defineConfig({ ...base, testIgnore: [], + // The Models and Backends pages are still fetching their catalogs (a few + // requests per host at a time) when the Libraries panel opens, so a list can + // wait behind them for longer than the default five seconds. + expect: { timeout: 20_000 }, + timeout: 90_000, + // A retry that passes is a flake, and the runner (scripts/addons/library_ui.sh) + // rejects it; it reads this report. + retries: 0, + workers: 1, + reporter: process.env.ADDONS_UI_REPORT ? [['json', { outputFile: process.env.ADDONS_UI_REPORT }]] : base.reporter, testMatch: ['**/library-sources.spec.js'], globalSetup: undefined, use: { ...base.use, baseURL: `http://127.0.0.1:${port}` }, diff --git a/core/http/react-ui/playwright.shared-folders.config.js b/core/http/react-ui/playwright.shared-folders.config.js new file mode 100644 index 000000000..412915cf4 --- /dev/null +++ b/core/http/react-ui/playwright.shared-folders.config.js @@ -0,0 +1,23 @@ +import { defineConfig } from '@playwright/test' +import base from './playwright.config.js' + +// The shared folders spec runs against a real server started with +// --shared-pages-fixture, on its own port, because it changes the grants of +// the fixture's folders. SHARED_FOLDERS_UI_PORT and SHARED_FOLDERS_UI_LOG +// change the port and the server log; SHARED_FOLDERS_SHOTS the folder of the +// screenshots. +const port = Number(process.env.SHARED_FOLDERS_UI_PORT || 8099) + +export default defineConfig({ + ...base, + testIgnore: [], + testMatch: ['**/shared-folders-real.spec.js'], + workers: 1, + use: { ...base.use, baseURL: `http://127.0.0.1:${port}` }, + webServer: process.env.PLAYWRIGHT_EXTERNAL_SERVER ? undefined : { + command: `../../../tests/e2e-ui/ui-test-server --mock-backend=../../../tests/e2e/mock-backend/mock-backend --port=${port} --shared-pages-fixture > ${process.env.SHARED_FOLDERS_UI_LOG || '/dev/null'} 2>&1`, + port, + timeout: 120_000, + reuseExistingServer: false, + }, +}) diff --git a/core/http/react-ui/playwright.shared-pages.config.js b/core/http/react-ui/playwright.shared-pages.config.js new file mode 100644 index 000000000..b7d80d4ad --- /dev/null +++ b/core/http/react-ui/playwright.shared-pages.config.js @@ -0,0 +1,22 @@ +import { defineConfig } from '@playwright/test' +import base from './playwright.config.js' + +// The shared pages spec runs against a real server started with +// --shared-pages-fixture, which seeds shared folders and their pages, on its +// own port. SHARED_PAGES_UI_PORT and SHARED_PAGES_UI_LOG change the port and the +// server log. +const port = Number(process.env.SHARED_PAGES_UI_PORT || 8098) + +export default defineConfig({ + ...base, + testIgnore: [], + testMatch: ['**/shared-pages-real.spec.js'], + workers: 1, + use: { ...base.use, baseURL: `http://127.0.0.1:${port}` }, + webServer: process.env.PLAYWRIGHT_EXTERNAL_SERVER ? undefined : { + command: `../../../tests/e2e-ui/ui-test-server --mock-backend=../../../tests/e2e/mock-backend/mock-backend --port=${port} --shared-pages-fixture > ${process.env.SHARED_PAGES_UI_LOG || '/dev/null'} 2>&1`, + port, + timeout: 120_000, + reuseExistingServer: false, + }, +}) diff --git a/core/http/react-ui/playwright.tools.config.js b/core/http/react-ui/playwright.tools.config.js deleted file mode 100644 index 4d057c684..000000000 --- a/core/http/react-ui/playwright.tools.config.js +++ /dev/null @@ -1,31 +0,0 @@ -import { defineConfig } from '@playwright/test' - -// Configuration of the add-on tools suite (scripts/addons/tables_tools.sh). -// The runner starts the UI test server with a real add-on owner, the real -// Tables executable and the scripted mock model on an isolated port, and -// passes its address in ADDONS_UI_URL and the seeded accounts in -// ADDONS_FIXTURE. The specs change the owner's state, so they run in order on -// one worker. -export default defineConfig({ - testDir: './e2e', - testMatch: ['addons-proposals.spec.js'], - timeout: 120_000, - retries: 0, - workers: 1, - fullyParallel: false, - reporter: [['json', { outputFile: process.env.ADDONS_UI_REPORT || 'addons-proposals-report.json' }]], - use: { - baseURL: process.env.ADDONS_UI_URL, - }, - projects: [ - { - name: 'chromium', - use: { - browserName: 'chromium', - ...(process.env.PLAYWRIGHT_CHROMIUM_PATH - ? { launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH } } - : {}), - }, - }, - ], -}) diff --git a/core/http/react-ui/public/chats-sw.js b/core/http/react-ui/public/chats-sw.js new file mode 100644 index 000000000..59ede362d --- /dev/null +++ b/core/http/react-ui/public/chats-sw.js @@ -0,0 +1,27 @@ +// Service worker of browser push for chats. It shows the title of a +// notification and opens its link; the payload carries nothing else. +self.addEventListener('push', (event) => { + let data = {} + try { + data = event.data ? event.data.json() : {} + } catch (_) { + data = {} + } + const title = typeof data.title === 'string' && data.title ? data.title : 'LocalAI' + event.waitUntil(self.registration.showNotification(title, { data: { link: typeof data.link === 'string' ? data.link : '' }, tag: data.link || undefined })) +}) + +self.addEventListener('notificationclick', (event) => { + event.notification.close() + const link = (event.notification.data && event.notification.data.link) || '' + // The scope is the root of the server, which may sit under a proxy prefix. + const target = new URL(link.replace(/^\//, '') || 'app/', self.registration.scope).href + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((wins) => { + for (const w of wins) { + if (w.url === target && 'focus' in w) return w.focus() + } + return self.clients.openWindow(target) + }), + ) +}) diff --git a/core/http/react-ui/public/locales/de/addons.json b/core/http/react-ui/public/locales/de/addons.json index 20f9f442b..1627e9d78 100644 --- a/core/http/react-ui/public/locales/de/addons.json +++ b/core/http/react-ui/public/locales/de/addons.json @@ -3,9 +3,9 @@ "blocked": "Eine frühere Aktion für dieses Add-on ist noch nicht bestätigt. Prüfe zuerst ihren Status oder verwirf sie.", "checkStatus": "Status prüfen", "dismiss": "Verwerfen", - "failed": "Der Betreiber hat die Aktion nicht abgeschlossen. Es wurde nichts geändert. Versuche es erneut, um einen neuen Versuch zu starten.", - "notApplied": "Der Betreiber meldete die Aktion als erledigt, aber das Add-on hat sich nicht geändert. Versuche es erneut.", - "unknown": "Der Betreiber hat die Aktion noch nicht bestätigt. Sie kann noch angewendet werden. Prüfe den Status oder verwirf sie, bevor du eine weitere Aktion startest." + "failed": "Die Aktion wurde nicht abgeschlossen. Es wurde nichts geändert. Versuche es erneut, um einen neuen Versuch zu starten.", + "notApplied": "Die Aktion wurde als erledigt gemeldet, aber das Add-on hat sich nicht geändert. Versuche es erneut, um einen neuen Versuch zu starten.", + "unknown": "Die Aktion ist noch nicht bestätigt. Sie kann noch angewendet werden. Prüfe den Status oder verwirf sie, bevor du eine weitere Aktion startest." }, "approval": { "title": "Freigabe erforderlich", @@ -37,14 +37,16 @@ "emptyTitle": "Noch keine Apps", "firstRun": { "browse": "Browse add-ons", - "title": "Welcome to Apps", - "lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.", - "featuredLabel": "Featured add-ons", + "title": "Noch keine Add-ons", + "lead": "Add-ons geben LocalAI neue Seiten und Chat-Werkzeuge. Sie laufen für sich und verlangen nur, was sie angeben.", + "featuredLabel": "Beginne mit einem davon", "featuredFailed": "The list of add-ons could not be read.", - "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons." + "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons.", + "factChecked": "Vor dem Start gegen deine Quellen geprüft", + "factRemove": "Jederzeit ausschalten oder entfernen" }, "headless": "Läuft im Hintergrund und hat keine eigene Seite.", - "listLabel": "Apps", + "listLabel": "Installierte Add-ons", "loading": "Apps werden geladen", "manage": "Verwalten", "open": "Öffnen", @@ -56,10 +58,14 @@ "tools_one": "{{count}} Werkzeug", "tools_other": "{{count}} Werkzeuge", "version": "Version {{version}}", - "working": "Warte auf die Bestätigung des Betreibers.", + "working": "Warte auf die Bestätigung.", "noMatchTitle": "No add-on matches", - "noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.", - "clearSearch": "Clear search" + "noMatchBody": "Nichts Installiertes passt zu „{{query}}“. Versuche andere Wörter.", + "clearSearch": "Clear search", + "loadError": { + "title": "Add-ons sind gerade nicht verfügbar.", + "paused": "Add-on-Seiten und -Werkzeuge pausieren, bis er wieder da ist." + } }, "browse": { "confirmTitle": "{{name}} installieren?", @@ -69,7 +75,7 @@ "fromLibrary": "aus {{library}}", "install": "Installieren", "installedDetail": "Version {{version}}, Generation {{generation}}", - "installedOther": "Installiert {{version}} (andere Version)", + "installedOther": "Du hast {{version}}", "installedSame": "Installiert {{version}}", "installedState": "Installiert", "installing": "Wird installiert", @@ -85,7 +91,8 @@ "backendFailed": "The backend was not installed.", "alias": "Alias", "kindLabel": "Kind", - "installedOn": "{{name}} is installed and on." + "installedOn": "{{name}} is installed and on.", + "installedChip": "Installiert" }, "catalog": { "allLibraries": "Alle Bibliotheken", @@ -115,8 +122,8 @@ "pin": "Pin {{name}}", "pinLabel": "Pin", "pinned": "Pinned", - "running": "An", - "search": "Search add-ons", + "running": "Läuft", + "search": "Installierte Add-ons durchsuchen", "status": { "failed": "failed", "starting": "starting" @@ -129,7 +136,7 @@ "denied": "Der Server hat dies abgelehnt. Möglicherweise fehlt dir die Berechtigung oder das Add-on hat sich geändert. Die Liste wurde aktualisiert.", "invalid": "Der Server konnte diese Anfrage nicht annehmen.", "network": "Der Server ist nicht erreichbar. Prüfe deine Verbindung.", - "unavailable": "Der Betreiber des Add-ons ist gerade nicht verfügbar. Versuche es gleich noch einmal.", + "unavailable": "Add-ons sind gerade nicht verfügbar. Versuche es gleich noch einmal.", "unknownOutcome": "Das Ergebnis konnte nicht bestätigt werden. Prüfe den Status, bevor du es erneut versuchst." }, "installed": { @@ -174,14 +181,14 @@ }, "hints": { "id": "Kleinbuchstaben, Ziffern und Bindestriche. Kann später nicht geändert werden.", - "reference": "Der Name einer Zugangsdaten-Konfiguration des Betreibers. Füge hier nie ein Token oder Passwort ein.", + "reference": "Der Name von Zugangsdaten, die ein Administrator auf diesem Server eingerichtet hat. Füge hier nie ein Token oder Passwort ein.", "url": "Eine https-Adresse ohne Zugangsdaten und ohne Query-String.", "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.", "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.", "mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched." }, "lastRefresh": "Aktualisiert {{when}}, {{count}} Einträge", - "lead": "Eine Bibliothek ist ein Katalogdokument, das Add-ons auflistet. Zugangsdaten werden hier nie eingetippt: Eine private Bibliothek nennt eine Zugangsdaten-Referenz, die der Betreiber eingerichtet hat.", + "lead": "Eine Bibliothek ist ein Verzeichnis, das Add-ons, Backends oder Modelle auflistet. Zugangsdaten werden hier nie eingetippt: Eine private Add-on-Bibliothek nennt Zugangsdaten, die ein Administrator auf diesem Server eingerichtet hat.", "listLabel": "Bibliotheken", "loading": "Bibliotheken werden geladen", "notRefreshed": "Noch nicht aktualisiert", @@ -251,10 +258,11 @@ "hasUI": "Hat Seiten", "headless": "Ohne Oberfläche, keine Seiten", "health": "Zustand", + "installedAt": "Installiert", "host": "Host-API", "interface": "Oberfläche", "logs": "Protokolle", - "logsNone": "Die Ausgabe von Add-on-Prozessen wird in dieser Version nicht erfasst. Zustand und Neustartversuche oben sind das, was der Betreiber melden kann.", + "logsNone": "Die Ausgabe von Add-on-Prozessen wird in dieser Version nicht erfasst. Zustand und Neustartversuche oben sind alles, was LocalAI melden kann.", "observed": "Beobachteter Zustand", "protocol": "Protokoll", "restarts": "Neustartversuche", @@ -338,14 +346,15 @@ "state": { "failed": "Fehlgeschlagen", "label": "Zustand: {{state}}", - "running": "An", + "running": "Läuft", "starting": "Startet", "stopped": "Aus", - "update-pending": "Update ausstehend" + "update-pending": "Update ausstehend", + "updateTo": "Update {{version}}" }, - "supporting": "Apps, die dein Betreiber für dich ausführt, und die Bibliotheken, aus denen sie stammen.", + "supporting": "Software, die du installierst, um LocalAI neue Seiten und Chat-Werkzeuge zu geben. Jede läuft für sich und verlangt nur, was sie angibt.", "tabs": { - "apps": "Meine Add-ons", + "apps": "Installiert", "browse": "Add-ons finden", "label": "Bereiche der Add-ons", "libraries": "Quellen" @@ -516,8 +525,8 @@ "unknownOutcome": "Das Ergebnis ist nicht bekannt. Lesen Sie den Status erneut: Dieselbe Aktion wird nicht zweimal gesendet.", "auth": "Melden Sie sich erneut an.", "denied": "Nur Administratoren können dies, aus ihrer eigenen Browsersitzung.", - "network": "Der Eigentümer war nicht erreichbar.", - "unavailable": "Der Eigentümer ist nicht verfügbar." + "network": "Der Add-on-Dienst war nicht erreichbar.", + "unavailable": "Der Add-on-Dienst ist nicht verfügbar." } }, "updates": { @@ -591,18 +600,30 @@ "auth": "Bitte erneut anmelden.", "denied": "Das kann nur ein Administrator in der eigenen Browsersitzung tun.", "network": "Das Netzwerk ist nicht erreichbar.", - "unavailable": "Der Add-on-Eigentümer ist nicht verfügbar. Bitte erneut versuchen." + "unavailable": "Der Add-on-Dienst ist nicht verfügbar. Versuche es erneut." + }, + "upgradeNamed": "{{name}} aktualisieren", + "why": { + "role": "Ein Update ist verfügbar. Nur ein Administrator kann es installieren.", + "needs-check": "Ein Update wird gemeldet, aber die Details sind noch nicht bestätigt. Prüfen Sie erneut, um es zu bestätigen.", + "check-failed": "Die letzte Prüfung ist fehlgeschlagen, das Update kann nicht bestätigt werden. Erneut prüfen.", + "incompatible": "Ein Update existiert, läuft hier aber nicht:", + "integrity": "Die Bibliothek listet diese Version mit anderem Inhalt. Sie wird nicht als Update angeboten.", + "recovery": "Die Daten dieses Add-ons müssen zuerst wiederhergestellt werden. Stellen Sie in den Details ein Backup wieder her und aktualisieren Sie dann.", + "current": "Die letzte Prüfung hat kein Update gefunden. Der Hinweis ist möglicherweise veraltet. Erneut prüfen.", + "unpinned": "Die Bibliothek meldet ein Update, nennt aber keinen Build zum Installieren. Daher kann es hier nicht aktualisiert werden. Prüfen Sie später erneut." }, - "reviewButton": "Updates ({{count}})", - "reviewTitle": "Add-on-Updates prüfen", - "reviewDescription": "Nichts wird aktualisiert, bevor Sie hier auswählen. Jedes Update sichert zuerst die Daten des Add-ons.", "checkAll": "Nach Updates suchen", "checkingAll": "Prüfe...", "checkAllDone": "{{count}} Add-ons geprüft.", "checkAllFailed": "{{count}} konnten nicht geprüft werden.", - "upgradeNamed": "{{name}} aktualisieren", - "toastDone": "{{name}} auf {{version}} aktualisiert.", - "toastFailed": "{{name}} wurde nicht aktualisiert: {{message}}" + "checkResult": { + "done": "Geprüft.", + "failed": "Die Prüfung wurde nicht abgeschlossen.", + "justNow": "Gerade eben geprüft." + }, + "waitingService": "Der Add-on-Dienst startet. LocalAI fragt in {{seconds}} s erneut nach dem Upgrade.", + "checkAgain": "Jetzt prüfen" }, "install": { "stepsLabel": "Install steps", @@ -639,17 +660,19 @@ "addsTools_other": "Adds {{count}} tools", "openNamed": "Open {{name}}", "removeTitle": "Remove {{name}}?", - "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted." + "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted.", + "removeNamed": "{{name}} entfernen…" }, "preview": { "screenshots": "Screenshots", - "adds": "What it adds", + "adds": "Was es kann", "page": "A page of its own in LocalAI", "addsNothing": "Nothing you can see. It works in the background.", "version": "Version", "id": "Identifier", "sourceUnknown": "Not known", - "moreDetails": "Health, trust and updates" + "moreDetails": "Health, trust and updates", + "updateTitle": "Update auf {{version}}" }, "flow": { "installAndTurnOn": "Installieren und einschalten", @@ -663,6 +686,66 @@ "install": "Download, verify and install", "turn-on": "Turn on" }, - "seeInList": "See it in My add-ons" + "seeInList": "In Installiert ansehen", + "install": "Installieren", + "installNamed": "{{name}} installieren", + "shadowWarning": "Dieses Add-on ersetzt das eingebaute {{tool}}. Ein anderes Add-on ({{name}}) tut das bereits. Nur eines kann aktiv sein; welches, wählen Sie unter Einstellungen > Werkzeuge. Solange Sie nichts wählen, stellt das zuerst installierte es bereit.", + "skillShadow": "Dieses Add-on ersetzt den integrierten Skill {{skill}}.", + "skillShadowWarning": "Dieses Add-on ersetzt den integrierten Skill {{skill}}. Ein anderes Add-on ({{name}}) tut das bereits. Nur eines kann aktiv sein; welches, wählen Sie unter Einstellungen > Werkzeuge. Solange Sie nichts wählen, stellt das zuerst installierte es bereit." + }, + "family": { + "pages": "Du suchst Seiten, die du oder ein Agent erstellt hast? Sie sind unter Seiten." + }, + "filter": { + "label": "Anzeigen", + "all": "Alle", + "running": "Läuft", + "off": "Aus", + "showAll": "Alle anzeigen", + "none": { + "all": "Keine Add-ons anzuzeigen.", + "running": "Kein Add-on läuft.", + "off": "Alle Add-ons laufen." + } + }, + "attention": { + "label": "Braucht Aufmerksamkeit", + "failedTitle": "{{name}} wurde angehalten.", + "failedRestarts_one": "Der Start ist {{count}}-mal in Folge fehlgeschlagen.", + "failedRestarts_other": "Der Start ist {{count}}-mal in Folge fehlgeschlagen.", + "failedOnce": "Es ist nicht gestartet.", + "toolsOff_one": "Sein {{count}} Chat-Werkzeug ist aus, bis es wieder läuft.", + "toolsOff_other": "Seine {{count}} Chat-Werkzeuge sind aus, bis es wieder läuft.", + "pageOff": "Seine Seite ist nicht verfügbar, bis es wieder läuft.", + "updateTitle": "{{name}} {{version}} ist verfügbar.", + "updateBody": "Du hast {{current}}. Du siehst, was sich ändert, und bestätigst, bevor etwas ersetzt wird." + }, + "meta": { + "builtin": "Integriert", + "builtinLong": "Wird mit LocalAI geliefert", + "signed": "Signiert · {{library}}", + "checked": "Geprüft · {{library}}", + "devBuild": "Entwicklungs-Build", + "library": "Bibliothek {{library}}" + }, + "skills": { + "title_one": "{{count}} Chat-Skill", + "title_other": "{{count}} Chat-Skills", + "replaces": "ersetzt den integrierten Skill", + "serves": "wird in den Chats verwendet", + "notServing": "derzeit nicht verwendet", + "serveIn": "Für {{skill}} in den Chats verwenden", + "saveFailed": "Die Auswahl konnte nicht gespeichert werden." + }, + "settingsTab": { + "overview": "Übersicht", + "settings": "Einstellungen", + "tabsLabel": "Details zu {{name}}", + "off": "Schalte {{name}} ein, um seine Einstellungen zu ändern.", + "pageLink": "Einstellungen", + "controls": { + "loadFailed": "Die Einstellungen konnten nicht gelesen werden", + "saveFailed": "Die Einstellungen konnten nicht gespeichert werden: {{message}}" + } } } diff --git a/core/http/react-ui/public/locales/de/admin.json b/core/http/react-ui/public/locales/de/admin.json index 05dabef5d..80ef4b6d3 100644 --- a/core/http/react-ui/public/locales/de/admin.json +++ b/core/http/react-ui/public/locales/de/admin.json @@ -107,6 +107,72 @@ "assistant": "LocalAI Assistant", "distributed": "Verteilt", "responses": "Antworten" + }, + "loadFailedTitle": "Die Einstellungen konnten nicht geladen werden.", + "egress": { + "title": "Netzwerk der Befehle", + "intro": "Befehle in der Sandbox erreichen das Netzwerk nur über einen Proxy, der LocalAI bei jeder Verbindung fragt. Legen Sie unten das Limit, die Voreinstellung neuer Chats, die Ports, zusätzliche Registries und die Aufbewahrung des Protokolls fest; jede Person wählt die Voreinstellung eines Chats in dessen Tools-Menü bis zum Limit und erlaubt einen blockierten Host über eine Karte.", + "serves": "Befehle laufen isoliert im Add-on {{name}}", + "servesHost": "Befehle laufen über {{name}} auf diesem Rechner", + "servesNone": "Noch führt keine Sandbox Befehle aus", + "hostNote": "Das Host-Backend führt Befehle auf diesem Rechner mit dessen Netzwerk aus: Diese Einstellungen gelten dafür nicht.", + "counts": "Letzte 24 Stunden: {{allowed}} erlaubt, {{blocked}} blockiert.", + "private": "Regeln eines Administrators dürfen private Netzwerke erreichen", + "privateWarning": "Ein Host, den ein Administrator erlaubt, kann dann Adressen in Ihrem Netzwerk erreichen. Eine Voreinstellung allein tut das nie; Cloud-Metadaten- und Serveradressen bleiben gesperrt.", + "typePhrase": "Zum Einschalten eingeben: {{phrase}}", + "registries": "Paket-Registries ({{count}})", + "showAudit": "Protokoll zeigen", + "hideAudit": "Protokoll ausblenden", + "loadFailed": "Die Einstellungen des Sandbox-Netzwerks konnten nicht gelesen werden", + "audit": { + "verdict": "Ergebnis", + "host": "Host", + "user": "Benutzer", + "at": "Zeitpunkt", + "reason": "Grund", + "bytes": "Bytes hoch / runter", + "refresh": "Aktualisieren", + "empty": "Keine passenden Anfragen.", + "loadFailed": "Das Protokoll konnte nicht gelesen werden", + "verdicts": { + "any": "Alle Ergebnisse", + "allow": "Erlaubt", + "block": "Blockiert", + "done": "Beendet" + } + } + }, + "addons": { + "title": "Add-ons", + "hint": "Einstellungen, die Add-ons mitbringen", + "intro": "Jedes Add-on hat seine Einstellungen auf seiner eigenen Seite, im Tab Einstellungen seiner Details. Öffne eines, um sie zu ändern.", + "off": "Ausgeschaltet. Schalte es ein, um seine Einstellungen zu ändern.", + "open": "Einstellungen öffnen", + "openNamed": "Einstellungen von {{name}} öffnen", + "count_one": "{{count}} Add-on mit Einstellungen", + "count_other": "{{count}} Add-ons mit Einstellungen" + }, + "hosts": { + "add": "Hinzufügen", + "listLabel": "Immer erlaubte Hosts", + "remove": "{{host}} entfernen", + "empty": "Noch kein Host. Geben Sie einen Hostnamen ein, oder Host:Port, um den Port festzulegen.", + "hint": "Nur genaue Namen. Mit :Port erlauben Sie nur einen Port; sonst gelten die Netzwerk-Ports der Sandbox.", + "count": "{{n}}/{{max}}", + "errors": { + "empty": "Geben Sie einen Hostnamen ein.", + "wildcard": "\"{{value}}\": Geben Sie einen genauen Hostnamen ein, kein Muster.", + "shape": "\"{{value}}\" ist kein Hostname. Geben Sie nur den Host ein, ohne Schema und Pfad.", + "port": "\"{{value}}\": Der Port muss zwischen 1 und 65535 liegen; 22 und 25 werden immer abgelehnt.", + "private": "\"{{value}}\" ist eine private oder lokale Adresse. Schalten Sie zuerst den Schalter für das private Netzwerk ein.", + "numeric": "\"{{value}}\": Schreiben Sie eine IP-Adresse als vier Dezimalzahlen.", + "duplicate": "\"{{value}}\" steht schon in der Liste.", + "full": "Die Liste enthält höchstens {{max}} Hosts." + } + }, + "network": { + "title": "Netzwerk der Befehle", + "where": "{{name}} verwaltet die Netzwerkeinstellungen der Befehle, die es ausführt, auf seiner eigenen Seite." } }, "backends": { @@ -253,5 +319,75 @@ "subtitle": "Auf dieser LocalAI-Instanz geladene Modelle und die Host-Kapazität, die sie nutzen.", "scaleOut": "Rechner hinzufügen", "hideScaleOut": "Einrichtung ausblenden" + }, + "settingsTools": { + "intro": "Die Chats haben einige eingebaute Werkzeuge, die ein Add-on ersetzen kann. Ist das Add-on installiert, läuft es und ist es für einen Chat an, nutzt der Chat das Werkzeug des Add-ons unter demselben Namen.", + "empty": "Auf diesem Server lässt sich kein eingebautes Werkzeug verwalten.", + "loadFailed": "Die eingebauten Werkzeuge konnten nicht gelesen werden", + "switch": "An", + "byBuiltin": "Eingebaut", + "byAddon": "Add-on: {{name}}", + "byNone": "Aus", + "unavailable": "Dieser Server kann es nicht ausführen: {{reason}}.", + "installSandbox": "Installieren Sie die Sandbox, um Befehle isoliert auszuführen", + "replaceExplain": "Diese Add-ons ersetzen {{tool}}: Das gewählte stellt es in jedem Chat bereit, in dem es an ist und läuft, und das eingebaute wird ausgeblendet. Nur eines kann aktiv sein. Solange Sie nichts wählen, stellt das zuerst installierte es bereit.", + "defaultInstalledFirst": "Standard: zuerst installiert", + "installedOn": "Installiert {{when}}", + "choose_one": "{{count}} Add-on ersetzt {{tool}}: Wählen Sie, welches verwendet wird.", + "choose_other": "{{count}} Add-ons ersetzen {{tool}}: Wählen Sie, welches verwendet wird.", + "labels": { + "bash": "Befehle (bash)", + "web_search": "Websuche", + "web_fetch": "Webseiten lesen", + "list_agents": "Gespeicherte Agenten" + }, + "descriptions": { + "bash": "Führt Befehle und Code für die Chats direkt auf diesem Rechner aus, wenn keine Sandbox bash bereitstellt. Das Sandbox-Add-on ersetzt es durch isolierte Befehle.", + "web_search": "Durchsucht das Web für die Chats ohne Schlüssel (DuckDuckGo, dann Brave Search, dann Bing) oder mit Ihrer SearXNG-Instanz, wenn eine eingerichtet ist.", + "web_fetch": "Liest für die Chats eine öffentliche Webseite oder Datei über ihre Adresse. Adressen in Ihrem Netzwerk werden abgelehnt, außer Sie erlauben sie.", + "list_agents": "Erlaubt einem Chat, die gespeicherten Agenten des Benutzers aufzulisten und einen als Sub-Agenten zu starten. Jeder Chat schaltet es in seinem Werkzeugmenü ein." + }, + "bash": { + "switch": "Chats Befehle auf diesem Rechner ausführen lassen", + "modeLegend": "Wer Befehle ausführen kann", + "ask": "Jedes Mal fragen.", + "askHint": "Nur die Chats von Administratoren erhalten es, und jeder Befehl wartet auf deren Ja im Chat.", + "on": "Für alle erlauben.", + "onHint": "Die Chats aller Benutzer führen hier ohne Nachfrage Befehle aus, auch geplante Läufe und Sub-Agenten.", + "default": "Nicht gesetzt: Es gilt der Standard ({{mode}}).", + "warning": "Befehle laufen als der Benutzer, unter dem LocalAI läuft, ohne Isolierung und mit dem Netzwerk dieses Rechners: Sie können lesen, was LocalAI lesen kann, und das Internet, Ihr Netzwerk und die LocalAI-API erreichen. Jeder Befehl wird protokolliert. Installieren Sie die Sandbox, um sie zu isolieren." + }, + "modes": { + "off": "aus", + "ask": "jedes Mal fragen", + "on": "für alle erlaubt" + }, + "skillsTitle": "Skills, die Add-ons ersetzen", + "skillsIntro": "Ein Add-on kann einen integrierten Skill durch einen eigenen ersetzen, der zu seinen Werkzeugen passt. Ersetzen mehrere Add-ons denselben, wählen Sie, welches verwendet wird.", + "skillLabel": "Skill {{skill}}", + "skillNotRunning": "Das gewählte Add-on läuft nicht: der integrierte Skill wird verwendet.", + "toolNotRunning": "Das Add-on, das dieses Werkzeug ersetzt, läuft nicht: das eingebaute Werkzeug wird verwendet.", + "stopped": "gestoppt", + "webTest": { + "intro": "Führt eine Suche mit jedem Suchanbieter der gespeicherten Einstellungen aus und zeigt, was jeder getan hat. Übernehmen Sie zuerst Ihre Änderungen.", + "queryLabel": "Suchbegriff", + "run": "Suche testen", + "running": "Test läuft…", + "answered": "Die Chats erhalten die Ergebnisse von {{host}}.", + "noneAnswered": "Kein Anbieter hat geantwortet.", + "failed": "Der Test konnte nicht laufen: {{error}}", + "results_one": "{{count}} Ergebnis", + "results_other": "{{count}} Ergebnisse", + "outcomes": { + "ok": "Funktioniert", + "no_results": "Keine Ergebnisse", + "challenged": "Bot-Prüfung", + "blocked": "Gesperrt", + "timeout": "Zu langsam", + "error": "Fehler", + "skipped": "Übersprungen" + } + }, + "addonSettings": "Einstellungen von {{name}}" } } diff --git a/core/http/react-ui/public/locales/de/agents.json b/core/http/react-ui/public/locales/de/agents.json index b97077c5e..02bd5ce0d 100644 --- a/core/http/react-ui/public/locales/de/agents.json +++ b/core/http/react-ui/public/locales/de/agents.json @@ -2,15 +2,13 @@ "title": "Agenten", "subtitle": "Verwalten Sie autonome KI-Agenten", "actions": { - "agentHub": "Agent Hub", "import": "Importieren", "createAgent": "Agent erstellen", "edit": "Bearbeiten", "chat": "Chat", "export": "Exportieren", "delete": "Löschen", - "pause": "Pausieren", - "resume": "Fortsetzen" + "newAgent": "Neuer Agent" }, "table": { "name": "Name", @@ -27,13 +25,9 @@ "empty": { "noConfigured": "Keine Agenten konfiguriert", "noConfiguredText": "Erstellen Sie einen Agenten, um mit autonomen KI-Workflows zu beginnen.", - "browseHub": "Sie wissen nicht, wo Sie anfangen sollen? Durchsuchen Sie den <1>Agent Hub, um vorgefertigte Agentenkonfigurationen zum Importieren zu finden.", "noMatching": "Keine passenden Agenten", - "noMatchingText": "Kein Agent entspricht \"{{query}}\"" - }, - "sections": { - "yourAgents": "Ihre Agenten", - "otherUsersAgents": "Agenten anderer Benutzer" + "noMatchingText": "Kein Agent entspricht \"{{query}}\"", + "clearSearch": "Suche leeren" }, "deleteDialog": { "title": "Agent löschen", @@ -44,12 +38,411 @@ "loadFailed": "Laden der Agenten fehlgeschlagen: {{message}}", "deleted": "Agent \"{{name}}\" gelöscht", "deleteFailed": "Löschen des Agenten fehlgeschlagen: {{message}}", - "paused": "Agent \"{{name}}\" pausiert", - "resumed": "Agent \"{{name}}\" fortgesetzt", - "pauseFailed": "Pausieren des Agenten fehlgeschlagen: {{message}}", - "resumeFailed": "Fortsetzen des Agenten fehlgeschlagen: {{message}}", "exported": "Agent \"{{name}}\" exportiert", - "exportFailed": "Export des Agenten fehlgeschlagen: {{message}}", - "parseFailed": "Parsen der Agentendatei fehlgeschlagen: {{message}}" + "parseFailed": "Parsen der Agentendatei fehlgeschlagen: {{message}}", + "notAnAgent": "The file is not an exported agent", + "deletedUndo": "„{{name}}“ gelöscht.", + "undoLate": "„{{name}}“ wurde bereits gelöscht.", + "restored": "„{{name}}“ wiederhergestellt." + }, + "facts": { + "addons": "Add-ons", + "connector": "MCP connector" + }, + "agent": { + "triggers": "Triggers", + "newTrigger": "New trigger", + "noTriggers": "No trigger starts this agent.", + "untitled": "Untitled chat", + "triggerOn": "On", + "triggerOff": "Off", + "origin": { + "trigger": "Started by a trigger", + "connector": "Started by a connector" + }, + "noRunsText": "Press Chat to start a conversation with this agent. Chats that ran with it, and the ones its triggers start, are listed here.", + "missingText": "It may have been deleted, or it belongs to another user.", + "backToList": "Zurück zu den Agenten" + }, + "record": { + "where": "A run is a chat that ran with the agent, including the ones its triggers and connectors started.", + "none": "No runs yet" + }, + "create": { + "sections": { + "BasicInfo": "Basic info", + "ModelSettings": "Model", + "PromptsGoals": "Instructions", + "MemorySettings": "Memory", + "Skills": "Skills", + "Addons": "Add-ons", + "MCP": "MCP servers", + "export": "Export" + }, + "name": "Name", + "description": "Description", + "descriptionHelp": "One line that says what the agent is for. It shows in the list.", + "model": "Model", + "modelPlaceholder": "Any model (the chat picks one)", + "modelHelp": "Leave it empty and a chat with this agent uses the model you pick in the chat. Set one to fix it.", + "anyModel": "Any model", + "instructions": "Instructions", + "instructionsHelp": "What the agent always follows. Every chat with it starts from this text.", + "collections": "Collections", + "collectionsHint": "Collection name", + "collectionsHelp": "The memory collections a chat with this agent may search. The list is exact: an empty list grants none.", + "skillsHelp": "The skills a chat with this agent may read. The list is exact: an empty list grants none.", + "noSkills": "No skills yet.", + "manageSkills": "Write or manage skills", + "addonsHelp": "The add-ons whose tools a chat with this agent may use.", + "noAddons": "No add-on offers tools to a model.", + "mcpHelp": "The MCP servers of the fixed model and the MCP connectors a chat with this agent may use.", + "mcpServers": "MCP servers", + "mcpHint": "Server name", + "mcpConnectors": "MCP connectors", + "noConnectors": "No MCP connectors yet.", + "manageConnectors": "Manage MCP connectors", + "connectorOff": "off", + "gone": "not found", + "countCollections_one": "{{count}} collection", + "countCollections_other": "{{count}} collections", + "countAddons_one": "{{count}} add-on", + "countAddons_other": "{{count}} add-ons", + "needsName": "Needs a name", + "loadFailed": "Failed to load the agent: {{message}}", + "saveFailed": "Save failed: {{message}}", + "created": "Agent \"{{name}}\" created", + "updated": "Agent \"{{name}}\" updated", + "exportSummary": "Download as JSON", + "exportHelp": "Download the definition of this agent as a JSON file. It holds no id, no dates and no secrets. Import it on another instance from the agents list.", + "exportButton": "Export agent", + "importNew": "Imported from an exported agent. Read the form, then save.", + "importLegacy": "Imported from the export of an older agent. Name, description, model, instructions and skills were read; the rest has no counterpart here.", + "importDropped": "Not imported: {{keys}}.", + "subEdit": "A chat that is running keeps the version it started with. Its next turn uses the new one.", + "configNote": "The definition this form will save.", + "previewNew": "The agent this form will save.", + "previewEdit": "The agent this form will save, and how it differs from the saved one.", + "default": "Default", + "network": "Netzwerk der Befehle in unbeaufsichtigten Läufen", + "networkHelp": "Wenn ein Trigger oder ein Connector diesen Agenten ausführt, erreichen seine Sandbox-Befehle so viel vom Netzwerk, bis zum Limit eines Administrators. Ein Lauf fragt nie: Ein Host außerhalb wird blockiert. Ein Chat, den Sie mit diesem Agenten starten, beginnt ebenfalls damit.", + "networkPresets": { + "off": "Aus", + "registries": "Paket-Registries", + "all": "Alle öffentlichen Seiten" + } + }, + "run": { + "workingLine": "Working" + }, + "jobs": { + "title": "Triggers", + "newTask": "New trigger", + "createTask": "Create trigger", + "loading": "Loading triggers", + "loadFailed": "Failed to load: {{message}}", + "actionFailed": "The action failed: {{message}}", + "summary": { + "stopped": "{{count}} skipped", + "lastFailed_one": "The last run of {{names}} failed.", + "lastFailed_other": "The last run failed for {{names}}." + }, + "tasks": { + "title": "Triggers, {{count}}", + "caption": "Triggers", + "task": "Trigger", + "noSchedule": "No schedule", + "noAgent": "No agent", + "fileKind": "When a file arrives", + "open": "Open", + "started": "Trigger \"{{name}}\" started", + "toggleLabel": "Run {{name}} by itself", + "toggleFailed": "Could not change the trigger: {{message}}", + "deleted": "Trigger deleted", + "noneYet": "You have no triggers yet.", + "noModel": "No model", + "next": "Nächster Lauf" + }, + "history": { + "filterTask": "Trigger", + "allTasks": "All triggers", + "task": "Trigger", + "unknownTask": "Unknown trigger", + "cancelledLine": "Skipped", + "doneLine": "Finished. Open the chat for the answer.", + "doneFile": "Finished for {{file}}. Open the chat for the answer.", + "pendingLine": "Waiting to start", + "emptyText": "A run starts when you press Run now, when a schedule fires, or when a file arrives in a watched folder.", + "openRun": "Open the chat", + "reason": "Reason", + "file": "Started by the file {{file}}." + }, + "status": { + "done": "Done", + "failed": "Failed", + "running": "Working", + "queued": "Waiting", + "skipped": "Skipped" + }, + "empty": { + "title": "No triggers yet", + "text": "A trigger starts a chat for you by itself: at a time, or when a file arrives in a folder. Each run is an ordinary chat that you can open.", + "step1": "Write what the chat should do.", + "step2": "Pick an agent, or a model, to run it.", + "step3": "Choose when it runs, or press Run now." + }, + "task": { + "back": "Triggers", + "missingTitle": "Trigger not found", + "agent": "Agent", + "model": "Model", + "schedule": "Schedule", + "cap": "Most runs a day", + "watches": "Watches the folder {{folder}}", + "noRuns": "This trigger has not run yet.", + "runMeta": "took {{time}}", + "prompt": "What to do" + }, + "edit": { + "titleNew": "New trigger", + "subNew": "Name it, write what to do and choose when it runs.", + "subEdit": "Changes apply from the next run.", + "save": "Save trigger", + "create": "Create trigger", + "nameRequired": "A trigger needs a name", + "promptRequired": "Write what the trigger should do", + "created": "Trigger created", + "updated": "Trigger updated", + "sections": { + "basics": "Basics", + "prompt": "What to do", + "schedule": "When", + "limits": "Limits" + }, + "summary": { + "basics": "{{name}}, {{model}}", + "basicsNoModel": "{{name}}, no agent or model yet", + "promptEmpty": "Nothing to do yet", + "folderEmpty": "Needs a folder", + "cap": "Up to {{count}} runs a day", + "capDefault": "Default limit" + }, + "name": "Name", + "namePlaceholder": "daily-digest", + "noAgent": "No agent", + "model": "Model", + "enabled": "On", + "enabledHint": "A trigger that is off keeps its schedule but does not run.", + "prompt": "What to do", + "promptPlaceholder": "Summarise what is new in my Reports folder.", + "promptHint": "This text starts the chat each time the trigger fires.", + "capHint": "The most chats this trigger may start in one day. Empty uses the default.", + "previewEdit": "What the next run would send, and what differs from the saved trigger.", + "previewNew": "What the first run would send.", + "promptAsSent": "The text a run starts with", + "noChanges": "Nothing differs from the saved trigger.", + "loadingTask": "Loading trigger", + "changed_one": "{{count}} change", + "changed_other": "{{count}} changes" + }, + "subtitle": "Starten Sie einen Agenten nach Zeitplan oder wenn eine Datei ankommt. Jeder Lauf ist ein Chat, den Sie öffnen können." + }, + "views": { + "label": "Agentenbereich", + "agents": "Agenten" + }, + "now": { + "failedTitle": "{{name}} ist fehlgeschlagen.", + "workingTitle": "{{name}} arbeitet.", + "openRun": "Lauf öffnen" + }, + "load": { + "agents": "Ihre Agenten konnten nicht geladen werden.", + "agentsStale": "Die Liste konnte nicht aktualisiert werden. Sie zeigt den vorherigen Stand.", + "agent": "Dieser Agent konnte nicht geladen werden.", + "agentStale": "Diese Seite konnte nicht aktualisiert werden. Sie zeigt den vorherigen Stand.", + "agentForm": "Dieser Agent konnte nicht zum Bearbeiten geladen werden.", + "triggers": "Ihre Auslöser konnten nicht geladen werden.", + "triggersStale": "Die Auslöser konnten nicht aktualisiert werden. Sie zeigen den vorherigen Stand.", + "trigger": "Dieser Auslöser konnte nicht geladen werden.", + "triggerStale": "Dieser Auslöser konnte nicht aktualisiert werden. Er zeigt den vorherigen Stand.", + "tasks": "Die Aufgaben konnten nicht geladen werden.", + "tasksStale": "Die Aufgaben konnten nicht aktualisiert werden. Sie zeigen den vorherigen Stand." + }, + "meta": { + "anyModel": "Beliebiges Modell", + "addons_one": "{{count}} Add-on", + "addons_other": "{{count}} Add-ons" + }, + "chips": { + "lastFailed": "Letzter Lauf fehlgeschlagen" + }, + "tasks": { + "title": "Agenten-Aufgaben", + "allUsers": "Alle Nutzer", + "new": "Neue Aufgabe", + "newTitle": "Neue Aufgabe", + "editTitle": "Aufgabe bearbeiten", + "name": "Name", + "description": "Beschreibung", + "runner": "Läuft mit", + "byModel": "Einem Modell", + "byAgent": "Einem deiner Agenten", + "model": "Modell", + "agent": "Agent", + "pick": "Auswählen …", + "prompt": "Prompt", + "promptHint": "Schreib einen Parameter als {{example}}; ein Lauf fragt nach seinem Wert.", + "webhooksKept_one": "Diese Aufgabe hat {{count}} Webhook. Er bleibt, wie er ist; Webhooks änderst du über die API.", + "webhooksKept_other": "Diese Aufgabe hat {{count}} Webhooks. Sie bleiben, wie sie sind; Webhooks änderst du über die API.", + "save": "Aufgabe speichern", + "cancel": "Abbrechen", + "saved": "Aufgabe „{{name}}“ gespeichert", + "runTitle": "{{name}} ausführen", + "runNow": "Jetzt ausführen", + "started": "Aufgabe „{{name}}“ gestartet", + "tasks": "Aufgaben", + "off": "Aus", + "run": "Ausführen", + "edit": "Bearbeiten", + "delete": "Löschen", + "deleted": "Aufgabe „{{name}}“ gelöscht", + "task": "Aufgabe", + "cancelJob": "Abbrechen", + "rerun": "Erneut ausführen", + "rerunStarted": "Erneut gestartet", + "stillRunning": "Läuft noch.", + "noResult": "Kein Ergebnis.", + "traces_one": "{{count}} Schritt", + "traces_other": "{{count}} Schritte", + "webhookSent": "Der Webhook wurde aufgerufen.", + "webhookFailed": "Der Webhook ist fehlgeschlagen: {{message}}", + "actionFailed": "Das hat nicht geklappt: {{message}}", + "state": { + "pending": "Wartet", + "running": "Läuft", + "completed": "Fertig", + "failed": "Fehlgeschlagen", + "cancelled": "Abgebrochen" + }, + "lede": "Prompts, die ein Modell oder einer deiner Agenten im Hintergrund ausführt. Starte sie hier oder aus einem Skript über die API.", + "searchLabel": "Aufgaben suchen", + "filter": { + "label": "Aufgaben anzeigen", + "all": "Alle", + "on": "An", + "off": "Aus" + }, + "noMatch": "Keine Aufgabe passt.", + "on": "An", + "offLine": "Aus. Sie läuft erst, wenn du sie einschaltest.", + "onDemand": "Läuft, wenn du sie startest", + "asksFor": "Fragt nach {{list}}", + "webhooks_one": "{{count}} Webhook", + "webhooks_other": "{{count}} Webhooks", + "runsCount_one": "{{count}} Lauf", + "runsCount_other": "{{count}} Läufe", + "runsOf_one": "{{count}} Lauf von {{name}}", + "runsOf_other": "{{count}} Läufe von {{name}}", + "toggleLabel": "{{name}} darf laufen", + "turnedOn": "„{{name}}“ ist an", + "turnedOff": "„{{name}}“ ist aus", + "moreFor": "Weitere Aktionen für {{name}}", + "open": "Öffnen", + "openTask": "{{name}} öffnen", + "runFor": "{{name}} ausführen", + "rerunFor": "{{name}} erneut ausführen", + "jobMoreFor": "Weitere Aktionen für diesen Lauf von {{name}}", + "detailsFor": "Details zum Lauf von {{name}}", + "openChat": "Chat öffnen", + "deleteJob": "Lauf löschen", + "jobDeleted": "Lauf gelöscht", + "cancelled": "Lauf gestoppt", + "confirmJob": { + "title": "Diesen Lauf löschen?", + "message": "Die Antwort und die Schritte werden gelöscht. Das lässt sich nicht rückgängig machen." + }, + "line": { + "pending": "Wartet auf den Start", + "running": "Arbeitet", + "cancelled": "Vor dem Ende gestoppt" + }, + "startedBy": { + "manual": "Hier gestartet", + "api": "Über die API gestartet", + "cron": "Nach Zeitplan gestartet" + }, + "output": { + "answer": "Antwort", + "error": "Fehler", + "values": "Werte" + }, + "traceKind": { + "reasoning": "Denken", + "tool_call": "Werkzeug", + "tool_result": "Werkzeugergebnis", + "status": "Status" + }, + "empty": { + "title": "Noch keine Aufgaben", + "body": "Eine Aufgabe ist ein Prompt, den du einmal schreibst und oft ausführst. Schreib {{example}} dort, wo sich ein Wert ändert, und jeder Lauf fragt danach.", + "fact1": "Ein Modell oder einer deiner Agenten führt sie aus", + "fact2": "Starte sie hier oder aus einem Skript über die API", + "fact3": "Jeder Lauf behält seine Antwort und seine Schritte" + }, + "sheet": { + "optional": "(optional)", + "newDesc": "Schreib den Prompt einmal und führe ihn aus, wann immer du ihn brauchst.", + "editDesc": "Änderungen gelten ab dem nächsten Lauf.", + "about": "Allgemein", + "nameHelp": "Skripte nutzen diesen Namen, um die Aufgabe über die API auszuführen.", + "descHelp": "Eine Zeile, die sagt, wofür die Aufgabe da ist.", + "who": "Wer sie ausführt", + "modelHelp": "Das Modell beantwortet den Prompt ohne Werkzeuge.", + "agentHelp": "Der Agent antwortet mit seinen eigenen Anweisungen und Werkzeugen.", + "what": "Was sie tut", + "promptPlaceholder": "Zum Beispiel: Fasse Ticket {{example}} in drei Zeilen zusammen.", + "inputs": "Jeder Lauf fragt nach:", + "noInputs": "Dieser Prompt fragt nach nichts. Jeder Lauf sendet ihn so, wie er ist.", + "on": "An", + "onHelp": "Eine ausgeschaltete Aufgabe behält ihre Läufe, kann aber nicht starten.", + "runDesc": "Gib die Werte für diesen Lauf ein.", + "runNone": "Diese Aufgabe fragt nach nichts. Sie führt den Prompt so aus, wie er ist.", + "preview": "Was die Aufgabe erhält" + }, + "errors": { + "name": "Gib der Aufgabe einen Namen.", + "prompt": "Schreib, was die Aufgabe tun soll.", + "model": "Wähle ein Modell.", + "agent": "Wähle einen Agenten." + }, + "pane": { + "label": "Details der Aufgabe", + "overview": "Übersicht", + "runs": "Läufe", + "settings": "Einstellungen", + "inputs": "Fragt nach", + "noInputs": "Nichts", + "changed": "Geändert", + "api": "Über die API ausführen", + "apiHint": "Sende die Werte als JSON-Objekt. Die Antwort enthält die ID des Laufs, um das Ergebnis abzufragen.", + "noRuns": "Noch keine Läufe. Führe die Aufgabe aus, dann erscheint ihre Antwort hier.", + "webhooks": "Webhooks", + "noWebhooks": "Keine. Füge Webhooks über die API hinzu, um zu erfahren, wann ein Lauf endet.", + "deleteTitle": "Diese Aufgabe löschen", + "deleteHelp": "Ihre Läufe bleiben, bis ihre Aufbewahrungszeit abläuft." + }, + "history": { + "title": "Verlauf", + "filterLabel": "Läufe anzeigen", + "all": "Alle", + "active": "Laufend", + "completed": "Fertig", + "failed": "Fehlgeschlagen", + "cancelled": "Abgebrochen", + "allTasks": "Alle Aufgaben", + "emptyBody": "Führe eine Aufgabe aus, dann erscheint ihre Antwort hier.", + "more": "{{count}} weitere anzeigen" + } } } diff --git a/core/http/react-ui/public/locales/de/auth.json b/core/http/react-ui/public/locales/de/auth.json index 286420c52..f0232dda0 100644 --- a/core/http/react-ui/public/locales/de/auth.json +++ b/core/http/react-ui/public/locales/de/auth.json @@ -47,12 +47,15 @@ "account": { "title": "Konto", "subtitle": "Profil, Zugangsdaten und API-Schlüssel", + "subtitleLocal": "Wer du bist und wie sich der Assistent und LocalAI für dich verhalten", "unavailable": "Konto nicht verfügbar", "unavailableText": "Authentifizierung muss aktiviert sein, um Ihr Konto zu verwalten.", "tabs": { "profile": "Profil", "security": "Sicherheit", - "apiKeys": "API-Schlüssel" + "apiKeys": "API-Schlüssel", + "about": "Über dich und den Assistenten", + "appearance": "Darstellung" }, "profile": { "displayName": "Anzeigename", @@ -112,6 +115,9 @@ "pausedToast": "API key paused", "resumedToast": "API key resumed", "pauseFailed": "Failed to update API key: {{message}}" + }, + "usage": { + "loadFailedTitle": "Ihre Nutzung konnte nicht geladen werden." } }, "notFound": { diff --git a/core/http/react-ui/public/locales/de/chat.json b/core/http/react-ui/public/locales/de/chat.json index ebc248451..739ca399a 100644 --- a/core/http/react-ui/public/locales/de/chat.json +++ b/core/http/react-ui/public/locales/de/chat.json @@ -25,22 +25,15 @@ "yes": "Ja", "gpuLayers": "GPU-Schichten" }, - "context": { - "label": "Kontext: {{percent}}%", - "labelWithTokens": "Kontext: {{percent}}% ({{tokens}} Tokens)" - }, "settings": { "title": "Chat-Einstellungen", "manageMode": "Verwaltungsmodus", - "manageModeDesc": "Erlauben Sie diesem Chat, Modelle zu installieren, Backends zu wechseln und Konfigurationen zu bearbeiten — durch Gespräche mit LocalAI.", - "systemPrompt": "System-Prompt", - "systemPromptPlaceholder": "Sie sind ein hilfreicher Assistent...", - "temperature": "Temperatur", - "topP": "Top P", - "topK": "Top K", - "contextSize": "Kontextgröße", - "contextSizePlaceholder": "2048", - "clearHistory": "Chat-Verlauf löschen" + "serverKept": "Der Server speichert diesen Chat. Modell und Agent werden beim Start festgelegt.", + "manageModeOn": "Dieser Chat hat die Verwaltungswerkzeuge von LocalAI. Mit /assistant startest du einen weiteren.", + "agent": "Agent", + "agentHelp": "Dieser Chat läuft mit dem Agenten {{name}}: mit seinen Anweisungen und nur den Werkzeugen, die er erlaubt.", + "editAgent": "Agent bearbeiten", + "deleteHelp": "Diesen Chat und seine Nachrichten endgültig löschen." }, "empty": { "manageTitle": "LocalAI per Chat verwalten", @@ -61,7 +54,6 @@ "Brainstorme Ideen" ], "recent": "Zuletzt", - "noMessages": "Noch keine Nachrichten", "hintEnter": "Enter zum Senden", "hintShiftEnter": "Shift+Enter für neue Zeile", "hintAttach": "Dateien anhängen" @@ -71,29 +63,17 @@ }, "actions": { "copy": "Kopieren", - "edit": "Bearbeiten", - "editMessage": "Nachricht bearbeiten", - "save": "Speichern", - "cancel": "Abbrechen", "regenerate": "Neu generieren", - "jumpToLatest": "Jump to latest" + "jumpToLatest": "Jump to latest", + "reuse": "Bearbeiten und erneut senden" }, "streaming": { "transferring": "Modell wird übertragen...", "transferringTo": "Modell wird zu {{node}} übertragen..." }, - "tokens": { - "perSec": "{{count}} tok/s", - "peak": "Spitze: {{count}} tok/s", - "usage": "{{prompt}}p + {{completion}}c = {{total}}" - }, "input": { - "placeholder": "Nachricht...", - "attachFile": "Datei anhängen", "stopGenerating": "Generierung stoppen", - "canvasTitle": "Canvas — Codeblöcke und Medien in ein Seitenpanel zur Vorschau, zum Kopieren und Herunterladen extrahieren", - "canvasLabel": "Canvas", - "openCanvas": "Canvas-Panel öffnen" + "placeholderQueue": "Das Modell arbeitet. Eine jetzt gesendete Nachricht wartet auf seinen nächsten Schritt." }, "deleteAllDialog": { "title": "Alle Chats löschen", @@ -104,7 +84,7 @@ "selectModel": "Bitte wählen Sie ein Modell", "copied": "In die Zwischenablage kopiert", "copyFailed": "Kopieren in die Zwischenablage fehlgeschlagen", - "pdfScannedAsImages": "{{name}} hat keine Textebene (Scan). {{count}} von {{total}} Seiten als Bilder für ein Bildmodell angehängt." + "newChatForModel": "Ein Chat behält sein Modell. Neuer Chat mit {{model}} gestartet." }, "menu": { "trigger": "Chats", @@ -118,17 +98,492 @@ "deleteChat": "Chat löschen", "newChat": "Neuer Chat", "clearAll": "Alle löschen", - "deleteAllTitle": "Alle Unterhaltungen löschen" + "deleteAllTitle": "Alle Unterhaltungen löschen", + "untitled": "Neuer Chat", + "origin": { + "trigger": "Von einem Auslöser gestartet", + "connector": "Über einen Connector" + } }, "message": { - "you": "You" + "you": "You", + "loopRun": "Schleife, Lauf {{run}}" }, - "addons": { - "note": "An add-on that is off here does nothing in this chat.", - "label": "Add-ons", - "title": "Add-on tools for this chat", - "heading": "Add-on tools", - "selectAll": "Select all", - "deselectAll": "Deselect all" + "tools": { + "label": "Werkzeuge", + "title": "Werkzeuge, die dieser Chat nutzen kann", + "heading": "Werkzeuge für diesen Chat", + "draftNote": "Ein neuer Chat startet mit diesen Schaltern. Ein ausgeschaltetes Werkzeug wird dem Modell nicht angeboten.", + "note": "Ein hier ausgeschaltetes Werkzeug wird dem Modell in diesem Chat nicht angeboten.", + "count": "{{on}} von {{total}} an", + "selectAll": "Alle einschalten", + "disableAll": "Alle ausschalten", + "groupOn": "Alle an", + "groupOff": "Alle aus", + "groupOnLabel": "Alle von {{group}} einschalten", + "groupOffLabel": "Alle von {{group}} ausschalten", + "groupCount": "{{on}} von {{total}}", + "optIn": "Aus, bis Sie es einschalten. „Alle einschalten“ lässt es aus.", + "empty": "In diesem Chat gibt es nichts zu schalten.", + "failed": "Nicht gespeichert. Der Schalter steht wieder wie vorher.", + "unavailable": "In diesem Chat nicht verfügbar", + "toolCount_one": "{{count}} Werkzeug", + "toolCount_other": "{{count}} Werkzeuge" + }, + "skills": { + "label": "Skills", + "manage": "Skills verwalten", + "title": "Skills, die dieser Chat nutzen kann", + "heading": "Skills für diesen Chat", + "count": "{{on}} von {{total}} an", + "selectAll": "Alle auswählen", + "disableAll": "Alle ausschalten", + "selectShown": "Angezeigte auswählen", + "disableShown": "Angezeigte ausschalten", + "search": "Skills suchen", + "noMatch": "Kein Skill passt zu „{{query}}“.", + "later": "Skills, die du später hinzufügst, sind zunächst an.", + "failed": "Speichern fehlgeschlagen. Nichts wurde geändert.", + "source": { + "builtin": "Integriert", + "personal": "Persönlich", + "shared": "Geteilt", + "folder": "Arbeitsbereich", + "addon": "{{name}}" + }, + "replacedBy": "Ersetzt durch {{name}}", + "fallback_one": "Integriertes {{skills}} aktiv: der eigene Skill des Add-ons {{name}} ist hier aus oder läuft nicht.", + "fallback_other": "Integrierte {{skills}} aktiv: die eigenen Skills des Add-ons {{name}} sind hier aus oder laufen nicht." + }, + "agent": { + "label": "Agent", + "title": "Gespeicherter Agent für diesen Chat", + "heading": "Mit einem Agenten starten", + "note": "Der Agent gibt dem Chat seine Anweisungen und genau die Werkzeuge, die er erlaubt.", + "none": "Kein Agent", + "fixed": "Der Agent dieses Chats" + }, + "assistant": { + "chatTitle": "LocalAI-Assistent" + }, + "slash": { + "delete": { + "label": "Chat löschen", + "desc": "Diese Unterhaltung löschen" + }, + "assistant": { + "desc": "Einen Chat starten, der LocalAI verwalten kann" + }, + "settings": { + "desc": "Fokusmodus, Agent und Modellinfo" + }, + "group": { + "context": "Kontext", + "skills": "Skills und Agenten", + "help": "Hilfe" + }, + "models": { + "label": "Modelle", + "desc": "Die Modellliste öffnen" + }, + "skill": { + "label": "Skill ausführen", + "desc": "Einen der Skills dieses Chats wählen" + }, + "skills": { + "label": "Skills verwalten", + "desc": "Die Skills-Seite öffnen, um Skills zu schreiben und zu ändern" + }, + "agent": { + "label": "Gespeicherten Agenten fragen", + "desc": "Eine Aufgabe an einen deiner Agenten geben", + "enable": "Gespeicherte Agenten für diesen Chat einschalten", + "enableDesc": "Dann den Agenten wählen" + }, + "compact": { + "label": "Kontext verdichten", + "desc": "Ältere Nachrichten zusammenfassen, um dem Modell Platz zu schaffen" + }, + "clear": { + "label": "Kontext leeren", + "desc": "In diesem Chat neu anfangen; die Nachrichten bleiben" + }, + "attach": { + "label": "Dateien anhängen", + "desc": "Dateien zum Senden wählen", + "clearDesc": "Die wartenden Dateien entfernen" + }, + "help": { + "label": "Alle Befehle", + "desc": "Jeden Befehl mit seiner Wirkung auflisten" + }, + "about": { + "label": "Über diesen Chat", + "desc": "Modell, Werkzeuge, Skills und Kontextgröße" + }, + "args": { + "skill": "Skills", + "agent": "Gespeicherte Agenten", + "model": "Modelle", + "attach": "Dateien", + "loop": "Schleifen", + "goal": "Ziel" + }, + "badge": { + "builtin": "eingebaut", + "addon": "Add-on", + "shared": "geteilt", + "yours": "deins" + }, + "collides": "nur mit /skill", + "errors": { + "skill": "Dieser Chat hat keinen Skill namens „{{name}}“.", + "agent": "Du hast keinen gespeicherten Agenten namens „{{name}}“.", + "agentTask": "Schreib die Aufgabe für {{name}} hinter den Namen.", + "agentsOff": "Gespeicherte Agenten sind für diesen Chat aus. Schalte sie im Menü Werkzeuge ein." + }, + "loop": { + "label": "Prompt wiederholen", + "desc": "Einen Prompt in diesem Chat immer wieder ausführen: /loop 5m prüfe den Build", + "listDesc": "Die Schleifen dieses Chats zeigen", + "stopDesc": "Eine Schleife oder alle stoppen", + "stopAll": "Alle Schleifen", + "stopAllDesc": "Alle Schleifen dieses Chats stoppen" + }, + "goal": { + "label": "Ziel", + "desc": "Ein Ziel setzen, an dem der Chat selbstständig weiterarbeitet", + "clearDesc": "Das Ziel entfernen", + "resumeDesc": "Mit dem Ziel weitermachen" + } + }, + "shortcuts": { + "onMessage": "Auf einer Nachricht: kopieren, bearbeiten, neu erzeugen", + "editLast": "Deine letzte Nachricht bearbeiten" + }, + "files": { + "title": "Dateien in diesem Chat", + "chip": "Dateien", + "chipTitle": "Dateien in diesem Chat: was du angehängt hast und was der Assistent erstellt oder geändert hat", + "close": "Dateien schließen", + "emptyTitle": "Noch keine Dateien in diesem Chat", + "emptyBody": "Zieh Dateien hierher, um sie an deine nächste Nachricht anzuhängen. Dateien, die der Assistent erstellt oder ändert, erscheinen auch hier.", + "dropHint": "Zieh Dateien hierher, um sie an deine nächste Nachricht anzuhängen.", + "groups": { + "uploaded": "Von dir", + "created": "Erstellt und geändert", + "working": "Arbeitsdateien" + }, + "turn": "Runde {{n}}", + "media": "Im Kontext", + "mediaTitle": "Das Modell hat diese Datei mit deiner Nachricht als Bild oder Video erhalten", + "changedTag": "Bearbeitet", + "changedTitle": "Der Assistent hat diese Datei geändert", + "download": "{{name}} herunterladen", + "error": "Die Dateien dieses Chats konnten nicht gelesen werden.", + "workingNote": "Skripte, Daten und Protokolle, die der Assistent unterwegs erstellt hat. Sie liegen im Arbeitsordner dieses Chats." + }, + "steps": { + "verb": { + "read": "Gelesen", + "list": "Aufgelistet", + "write": "Gespeichert", + "create": "Erstellt", + "change": "Geändert", + "move": "Verschoben", + "delete": "Gelöscht", + "search": "Gesucht", + "run": "Ausgeführt", + "fetch": "Abgerufen", + "websearch": "Im Web gesucht nach", + "webfetch": "Gelesen", + "delegate": "Einen Agenten gefragt", + "used": "Verwendet" + }, + "worked": "{{duration}} gearbeitet", + "thoughtFor": "{{duration}} nachgedacht", + "working": "Arbeitet", + "thinking": "Denkt nach", + "noMatches": "keine Treffer", + "noResults": "keine Ergebnisse", + "emptyFolder": "leerer Ordner", + "size": "{{size}}", + "seconds": "{{value}} s", + "minutes": "{{min}} min {{sec}} s", + "tool": "Werkzeug", + "input": "Eingabe", + "showLess": "Weniger zeigen", + "running": "Läuft", + "failed": "Fehlgeschlagen", + "count_one": "{{count}} Schritt", + "count_other": "{{count}} Schritte", + "files_one": "{{count}} Datei", + "files_other": "{{count}} Dateien", + "items_one": "{{count}} Eintrag", + "items_other": "{{count}} Einträge", + "matches_one": "{{count}} Treffer", + "matches_other": "{{count}} Treffer", + "results_one": "{{count}} Ergebnis", + "results_other": "{{count}} Ergebnisse", + "lines_one": "{{count}} Zeile", + "lines_other": "{{count}} Zeilen", + "showAll_one": "Alle {{count}} Zeilen zeigen", + "showAll_other": "Alle {{count}} Zeilen zeigen", + "details": "Details anzeigen", + "openFile": "{{path}} in Dateien öffnen", + "savedFiles": "In deinen Dateien gespeichert:", + "fileOp": { + "changed": "geändert", + "deleted": "gelöscht" + }, + "moreFiles": "und {{count}} weitere", + "alsoSavedWorking_one": "Außerdem {{count}} Arbeitsdatei gespeichert", + "alsoSavedWorking_other": "Außerdem {{count}} Arbeitsdateien gespeichert", + "savedWorking_one": "{{count}} Arbeitsdatei gespeichert", + "savedWorking_other": "{{count}} Arbeitsdateien gespeichert", + "workingTitle": "Arbeitsdatei (ein Skript, ein Protokoll oder ein Entwurf): {{path}} öffnen" + }, + "error": { + "title": "Die Antwort ist fehlgeschlagen", + "kept": "Was bisher geschrieben wurde, bleibt oben erhalten.", + "retry": "Wiederholen", + "detail": "Technische Details", + "reason": { + "unknown": "Der Agent wurde wegen eines Fehlers angehalten. Wiederhole den Zug, um es erneut zu versuchen.", + "model_backend_missing": "Das Modell \"{{model}}\" konnte nicht gestartet werden: Das Backend ist nicht installiert.", + "model_backend_missing_anon": "Das Modell konnte nicht gestartet werden: Das Backend ist nicht installiert.", + "model_cooldown": "Das Modell \"{{model}}\" ist vor einem Moment beim Start fehlgeschlagen und pausiert.", + "model_cooldown_anon": "Das Modell ist vor einem Moment beim Start fehlgeschlagen und pausiert.", + "model_load_failed": "Das Modell \"{{model}}\" konnte nicht gestartet werden.", + "model_load_failed_anon": "Das Modell konnte nicht gestartet werden.", + "model_not_found": "Das Modell \"{{model}}\" wurde nicht gefunden.", + "model_not_found_anon": "Das Modell wurde nicht gefunden.", + "upstream_key": "Der Upstream hat den Schlüssel abgelehnt.", + "upstream_path": "Der Upstream nimmt diese Anfrage unter dieser Adresse nicht an.", + "upstream_unreachable": "Die Upstream-Adresse ist nicht erreichbar.", + "upstream": "Der Anbieter hinter dem Modell hat einen Fehler zurückgegeben.", + "timeout": "Das Modell hat zu lange für die Antwort gebraucht.", + "cancelled": "Der Zug wurde abgebrochen, bevor das Modell geantwortet hat." + }, + "hint": { + "model_backend_missing": "Wähle ein anderes Modell oder bitte eine Administratorin bzw. einen Administrator, das Backend zu installieren.", + "model_backend_missing_admin": "Wähle ein anderes Modell oder installiere das Backend auf der Seite Backends.", + "model_cooldown": "Versuche es in {{seconds}} Sekunden erneut.", + "model_load_failed": "Prüfe die Konfiguration des Modells und sein Backend und versuche es dann erneut.", + "model_not_found": "Wähle ein anderes Modell oder installiere dieses auf der Seite Modelle.", + "upstream_key": "Prüfe den API-Schlüssel in den Einstellungen des Modells.", + "upstream_path": "Die Upstream-Adresse muss der vollständige Endpunkt sein, zum Beispiel .../v1/chat/completions.", + "upstream_unreachable": "Prüfe die Adresse in den Einstellungen des Modells.", + "upstream": "Versuche es gleich noch einmal. Wenn es weiter fehlschlägt, prüfe Adresse und Schlüssel des Anbieters.", + "timeout": "Versuche es erneut oder nutze ein kleineres oder schnelleres Modell.", + "cancelled": "Versuche es erneut, wenn du bereit bist." + }, + "openBackends": "Backends öffnen" + }, + "allChats": { + "title": "Alle Chats", + "supporting": "Alle gespeicherten Chats. Suchen, umbenennen, exportieren oder löschen.", + "loadFailed": "Deine Chats konnten nicht geladen werden.", + "emptyTitle": "Noch keine Chats", + "emptyBody": "Ein Chat beginnt auf der Startseite: Nachricht eingeben und senden. Danach stehen deine Chats hier.", + "start": "Chat beginnen" + }, + "foot": { + "state": { + "ready": "Bereit", + "nomodel": "Kein Modell", + "sending": "Wird gesendet", + "loading": "Modell wird geladen", + "thinking": "Denkt nach", + "tool": "{{tool}} läuft", + "answering": "Antwortet", + "waiting": "Wartet auf dich", + "stopped": "Gestoppt", + "failed": "Fehlgeschlagen", + "interrupted": "Unterbrochen", + "paused": "Pausiert", + "compacting": "Kontext wird verdichtet", + "clearing": "Kontext wird geleert" + }, + "tool": { + "search": "Sucht", + "list": "Listet auf", + "read": "Liest", + "create": "Erstellt", + "write": "Schreibt", + "change": "Ändert", + "move": "Verschiebt", + "delete": "Löscht", + "run": "Führt einen Befehl aus", + "fetch": "Lädt ab", + "delegate": "Fragt einen Sub-Agenten" + }, + "hint": "Enter zum Senden · Umschalt+Enter für eine neue Zeile", + "meter": { + "label": "Kontext zu {{pct}} % voll. Kontext und Tempo anzeigen", + "labelNoLimit": "Kontext {{used}} Tokens. Kontext und Tempo anzeigen" + }, + "details": { + "title": "Kontext und Tempo", + "context": "Kontext", + "contextOf": "{{used}} von {{limit}} Tokens ({{pct}} %)", + "contextUsed": "{{used}} Tokens", + "contextLeft": "{{left}} Tokens frei", + "contextHelp": "Der Kontext ist das, was das Modell bei jeder Anfrage liest: die Anweisungen, diesen Chat bis jetzt und die Ergebnisse der Tools.", + "nearFull": "Dieser Chat ist nahe am Limit. Der Server fasst den älteren Teil zusammen, damit das Modell weitermachen kann. Das Protokoll behält jede Nachricht.", + "limitUnknown": "Der Server hat nicht gesagt, wie groß das Kontextfenster ist.", + "lastRequest": "Letzte Anfrage", + "model": "Modell", + "backend": "Backend", + "node": "Knoten", + "tokensIn": "Tokens rein", + "tokensOut": "Tokens raus", + "firstToken": "Zeit bis zum ersten Token", + "promptTime": "Prompt-Verarbeitung", + "speed": "Tempo", + "speedLive": "Tempo, geschätzt während des Streamens", + "average": "Durchschnitt der letzten {{count}} Anfragen", + "note": "Diese Werte stammen aus den Anfragen, die diese Seite gesehen hat. Der Server speichert sie nicht, ein Neuladen löscht sie." + }, + "paused": { + "after": "nach {{count}} Schritten", + "atLimit": "am Schrittlimit", + "continue": "Fortsetzen", + "continueTitle": "Das Modell hat alle Schritte eines Durchgangs verbraucht. Lass es dort weitermachen, wo es aufgehört hat.", + "message": "Mach dort weiter, wo du aufgehört hast." + } + }, + "attachments": { + "label": "Angehängte Dateien", + "open": "{{name}} öffnen", + "showMore": "{{count}} weitere anzeigen", + "missing": "Datei nicht mehr verfügbar" + }, + "preview": { + "open": "Vorschau öffnen", + "created": "Vom Assistenten erstellt", + "changed": "Vom Assistenten geändert", + "yours": "Von dir", + "fromReply": "Aus der Antwort", + "download": "Herunterladen", + "downloadCode": "Code herunterladen", + "copy": "Kopieren", + "copied": "Kopiert", + "openInFiles": "In Dateien öffnen", + "actions": "Dateiaktionen", + "opened": "Vorschau geöffnet: {{name}}", + "chip": { + "created": "Erstellt", + "changed": "Geändert", + "label": "{{verb}}: {{name}}, Vorschau öffnen", + "row": "Dateien aus dieser Antwort" + } + }, + "meter": { + "usedOf": "{{used}} / {{limit}} Tokens ({{pct}} %)", + "used": "{{used}} Tokens", + "speed": "{{speed}} Tok./s", + "title": "Kontext, den die letzte Modellanfrage belegt hat", + "ofLimit": "{{used}} / {{limit}} Tokens", + "left": "{{left}} frei" + }, + "goal": { + "label": "Ziel", + "states": { + "active": "In Arbeit", + "paused": "Pausiert", + "done": "Erreicht", + "blocked": "Braucht dich" + }, + "pausedBy": { + "user": "Du hast es pausiert.", + "turn_limit": "Alle automatischen Runden sind aufgebraucht. Setze fort, um weitere zu geben.", + "step_limit": "Alle Schritte sind aufgebraucht. Setze fort, um weitere zu geben.", + "error": "Eine Runde ist fehlgeschlagen. Setze fort, um es erneut zu versuchen.", + "interrupted": "Der Server wurde während einer Runde beendet. Setze fort, um weiterzumachen." + }, + "blockedNote": "Braucht von dir: {{note}}", + "doneNote": "{{note}}", + "replaced": "Ersetzt: {{text}}", + "pause": "Pausieren", + "pauseTitle": "Die laufende Runde stoppen und das Ziel pausieren", + "resume": "Fortsetzen", + "resumeTitle": "Mit dem Ziel weitermachen", + "clear": "Entfernen", + "clearTitle": "Das Ziel entfernen", + "progress": "Ziel · Runde {{turn}} von {{max}}", + "progressStart": "Ziel · startet", + "markers": { + "set": "Ziel gesetzt", + "replaced": "Ziel ersetzt", + "continue": "Arbeitet weiter am Ziel · Runde {{turn}}", + "continueStart": "Arbeitet weiter am Ziel", + "paused": "Ziel pausiert", + "resumed": "Ziel fortgesetzt", + "done": "Ziel erreicht", + "blocked": "Ziel blockiert", + "cleared": "Ziel entfernt" + }, + "announce": { + "set": "Ziel gesetzt: {{text}}", + "paused": "Ziel pausiert. {{why}}", + "resumed": "Ziel fortgesetzt", + "done": "Ziel erreicht: {{note}}", + "blocked": "Das Ziel braucht dich: {{note}}", + "cleared": "Ziel entfernt" + }, + "card": { + "title": "Ziel", + "none": "Dieser Chat hat kein Ziel. Gib /goal und das ein, was erledigt werden soll, und der Chat arbeitet selbstständig daran weiter.", + "turns": "Runde {{turn}} von {{max}}", + "steps": "{{steps}} von {{max}} Schritten", + "clear": "/goal clear entfernt es.", + "resume": "/goal resume macht damit weiter.", + "turnsLabel": "Automatische Runden", + "stepsLabel": "Schritte", + "setLabel": "Gesetzt", + "stateLabel": "Stand", + "noteLabel": "Hinweis" + }, + "errors": { + "empty": "Schreibe nach /goal, was das Ziel ist.", + "notAllowed": "Nur ein Chat, den du selbst gestartet hast, kann ein Ziel haben." + } + }, + "invoked": { + "skill": "Skill: {{name}}", + "agent": "Agent: {{name}}" + }, + "marker": { + "compacted": "Kontext verdichtet: {{before}} auf {{after}} Token", + "cleared": "Kontext geleert", + "clearedHint": "Das Modell sieht die Nachrichten über dieser Linie nicht." + }, + "cmdcard": { + "close": "Schließen", + "helpTitle": "Befehle", + "helpSkills_one": "Skill: {{names}}. Tippe / und den Namen.", + "helpSkills_other": "{{count}} Skills: {{names}}. Tippe / und den Namen.", + "helpKeys": "Tippe / ins Feld, um die Liste zu öffnen. Tab ergänzt, Enter führt aus, Esc schließt.", + "aboutTitle": "Dieser Chat", + "model": "Modell", + "state": "Status", + "loaded": "Geladen", + "notLoaded": "Nicht geladen", + "backend": "Backend", + "tools": "Werkzeuge", + "toolsOn_one": "{{count}} an", + "toolsOn_other": "{{count}} an", + "skills": "Skills", + "skillsOn_one": "{{count}} Skill", + "skillsOn_other": "{{count}} Skills", + "commands": "Befehle", + "isolated": "Laufen in einer isolierten Sandbox", + "host": "Laufen auf diesem Rechner", + "noCommands": "Nicht möglich", + "context": "Kontext", + "contextOf": "{{used}} von {{limit}} Token", + "contextUsed": "{{used}} Token", + "unknown": "Noch nicht bekannt" } } diff --git a/core/http/react-ui/public/locales/de/chats.json b/core/http/react-ui/public/locales/de/chats.json new file mode 100644 index 000000000..3ea576722 --- /dev/null +++ b/core/http/react-ui/public/locales/de/chats.json @@ -0,0 +1,581 @@ +{ + "proposal": { + "loading": "Der vorgeschlagene Trigger wird gelesen ...", + "unread": "Der vorgeschlagene Trigger konnte nicht gelesen werden: {{message}}", + "removed": "Dieser Trigger wurde entfernt.", + "retry": "Erneut versuchen", + "inactive": "Vorgeschlagener Trigger. Er führt nichts aus, bis du ihn erstellst.", + "name": "Name", + "schedule": "Wann (zum Beispiel daily 08:00)", + "folder": "Zu überwachender Ordner", + "saveEdit": "Speichern", + "cancelEdit": "Abbrechen", + "create": "Trigger erstellen", + "edit": "Bearbeiten", + "dismiss": "Ablehnen", + "receipt": { + "scheduled": "Geplant:", + "watching": "Überwacht:", + "next": "Nächster Lauf {{when}}", + "view": "Trigger ansehen", + "undo": "Rückgängig", + "undone": "Rückgängig gemacht:", + "again": "Erneut planen", + "declined": "Abgelehnt:", + "restore": "Wiederherstellen", + "blocked": { + "ran": "Er ist schon einmal gelaufen; entferne ihn auf der Seite Trigger.", + "edited": "Du hast ihn seitdem bearbeitet; ändere oder entferne ihn auf der Seite Trigger." + } + }, + "announce": { + "accepted": "{{name}} geplant.", + "undone": "{{name}} rückgängig gemacht. Er ist nicht geplant.", + "declined": "{{name}} abgelehnt.", + "reopened": "{{name}} wartet wieder auf deine Antwort." + }, + "plan": { + "whenLabel": "Wann", + "runsAsLabel": "Läuft als", + "readsLabel": "Liest", + "writesLabel": "Schreibt", + "costLabel": "Kosten", + "stopLabel": "Zum Beenden", + "when": "{{schedule}}, {{zone}}. Erster Lauf {{when}}.", + "whenNoFirst": "{{schedule}}, {{zone}}.", + "whenFile": "Wenn eine Datei in {{folder}} ankommt.", + "runsAs": "Du", + "runsAsModel": "Du, mit {{model}}", + "writesWidget": ", angezeigt im Widget „{{widget}}“ auf der Startseite", + "costLocal_one": "Etwa {{tokens}} Tokens pro Lauf auf einem lokalen Modell. Höchstens {{count}} Lauf pro Tag.", + "costLocal_other": "Etwa {{tokens}} Tokens pro Lauf auf einem lokalen Modell. Höchstens {{count}} Läufe pro Tag.", + "costRemote_one": "Etwa {{tokens}} Tokens pro Lauf auf {{model}}, einem entfernten Dienst. Höchstens {{count}} Lauf pro Tag.", + "costRemote_other": "Etwa {{tokens}} Tokens pro Lauf auf {{model}}, einem entfernten Dienst. Höchstens {{count}} Läufe pro Tag.", + "costUnknown_one": "Die Tokens eines Laufs sind nach dem ersten Lauf bekannt. Höchstens {{count}} Lauf pro Tag.", + "costUnknown_other": "Die Tokens eines Laufs sind nach dem ersten Lauf bekannt. Höchstens {{count}} Läufe pro Tag.", + "remoteNote": "{{model}} ist ein entfernter Dienst: Was ein Lauf liest, verlässt diesen Server.", + "stopWidget": "Pausiere ihn im Widget oder lösche ihn unter Trigger", + "stop": "Schalte ihn aus oder lösche ihn unter Trigger" + } + }, + "mcpPage": { + "title": "MCP-Konnektoren", + "supporting": "Verbinden Sie MCP-Server, um Ihren Chats und Agenten mehr Werkzeuge zu geben, etwa Suche oder Ihre eigenen APIs.", + "family": "Bots für Telegram oder Slack sind Messaging-Konnektoren.", + "related": "Verwandt", + "chats": "Chats", + "add": "Konnektor hinzufügen", + "learn": "So funktioniert MCP", + "loading": "Ihre Konnektoren werden geladen", + "loadFailed": "Ihre Konnektoren konnten nicht geladen werden.", + "forbidden": "MCP ist für Ihr Konto nicht verfügbar.", + "empty": { + "title": "Noch keine Konnektoren", + "body": "Ein Konnektor verbindet einen MCP-Server mit LocalAI. Ihre Chats und Agenten können dann seine Werkzeuge nutzen, zum Beispiel um Ihre Dokumentation zu durchsuchen oder einen Kalender zu lesen.", + "fact1": "Jeder MCP-Server über http oder https", + "fact2": "Tokens und Header verschlüsselt gespeichert", + "fact3": "Testen, bevor Chats ihn nutzen" + }, + "toolbar": { + "search": "Konnektoren suchen", + "testAll": "Alle testen", + "testingAll": "Wird getestet", + "testedAll": "Die eingeschalteten Konnektoren wurden getestet." + }, + "filter": { + "label": "Anzeigen", + "all": "Alle", + "on": "Ein", + "attention": "Prüfen", + "off": "Aus", + "showAll": "Alle anzeigen", + "none": { + "all": "Keine Konnektoren.", + "on": "Kein Konnektor ist eingeschaltet.", + "attention": "Nichts braucht Ihre Aufmerksamkeit.", + "off": "Alle Konnektoren sind eingeschaltet." + } + }, + "noMatch": { + "title": "Kein Konnektor passt zu „{{query}}“", + "body": "Suchen Sie nach Name oder Adresse.", + "clear": "Suche löschen" + }, + "state": { + "label": "Status: {{state}}", + "ok": "Verbunden", + "auth": "Anmeldung nötig", + "error": "Keine Verbindung", + "unchecked": "Nicht getestet", + "off": "Aus" + }, + "row": { + "open": "{{name}}, {{state}}. Details anzeigen", + "toggle": "{{name}} in Chats verwenden", + "menu": "Aktionen für {{name}}", + "offLine": "Aus. Chats und Agenten verwenden ihn nicht.", + "notChecked": "Noch nicht getestet. Testen Sie ihn, um seine Werkzeuge zu sehen.", + "noTools": "Verbunden, aber der Server bietet keine Werkzeuge.", + "tools_one": "{{count}} Werkzeug", + "tools_other": "{{count}} Werkzeuge", + "checked": "Getestet {{when}}", + "tokenSaved": "Token gespeichert", + "headersSaved": "Header gespeichert", + "envSaved": "Variablen gespeichert" + }, + "actions": { + "details": "Details", + "test": "Verbindung testen", + "testNamed": "Verbindung zu {{name}} testen", + "testing": "Wird getestet", + "edit": "Bearbeiten", + "reauth": "Token ersetzen", + "remove": "Entfernen" + }, + "details": { + "use": "In Chats verwenden", + "useHelp": "Chats ohne Agent verwenden jeden eingeschalteten Konnektor. Ein gespeicherter Agent verwendet nur die, die Sie für ihn auswählen.", + "lastCheck": "Letzter Test", + "never": "In diesem Browser noch nicht getestet.", + "okAt": "Verbunden {{when}}.", + "testedAt": "Getestet {{when}}.", + "errorTitle": "Der letzte Test konnte keine Verbindung herstellen.", + "authTitle": "Der Server hat die Anmeldung abgelehnt.", + "tools": "Werkzeuge", + "toolsUnknown": "Testen Sie die Verbindung, um die Werkzeuge zu sehen.", + "toolsNone": "Der Server bietet keine Werkzeuge.", + "toolsSearch": "Werkzeuge suchen", + "noToolMatch": "Kein Werkzeug passt zu „{{query}}“.", + "noDescription": "Keine Beschreibung.", + "connection": "Verbindung", + "kind": "Art", + "address": "Adresse", + "command": "Befehl", + "added": "Hinzugefügt", + "queryHidden": "Der Teil der Adresse nach ? ist ausgeblendet, weil er einen Schlüssel enthalten kann.", + "signIn": "Anmeldung", + "token": "Bearer-Token", + "headers": "Header", + "env": "Variablen", + "saved": "Gespeichert", + "none": "Keine", + "encrypted": "Verschlüsselt auf dem Server gespeichert. Wird nie wieder angezeigt." + }, + "sheet": { + "addTitle": "Konnektor hinzufügen", + "editTitle": "{{name}} bearbeiten", + "addDesc": "Verbinden Sie einen MCP-Server. Sie testen ihn, bevor Chats ihn verwenden.", + "editDesc": "Gespeicherte Geheimnisse bleiben, außer Sie ersetzen oder entfernen sie.", + "testDesc": "LocalAI verbindet sich mit dem Gespeicherten und listet die Werkzeuge auf.", + "close": "Schließen", + "kind": "Art", + "remote": "Entfernter Server", + "remoteHelp": "Ein MCP-Server, den dieser Rechner über http oder https erreicht.", + "local": "Lokaler Befehl", + "localHelp": "Ein Programm, das LocalAI auf diesem Rechner startet. Nur für Administratoren.", + "basics": "Verbindung", + "name": "Name", + "nameHelp": "Erscheint im Werkzeuge-Menü eines Chats.", + "url": "Adresse", + "urlHelp": "Server in einem privaten Netz werden abgelehnt, außer ein Administrator erlaubt sie.", + "command": "Befehl", + "commandHelp": "Das zu startende Programm, ohne Shell.", + "args": "Argumente", + "argsHelp": "Ein Argument pro Zeile.", + "signIn": "Anmeldung", + "signInHelp": "Nur wenn der Server einen Schlüssel verlangt.", + "envIntro": "Variablen, die der Befehl erhält, zum Beispiel ein API-Schlüssel.", + "token": "Bearer-Token", + "tokenHelp": "Wird als Authorization-Header gesendet.", + "tokenSaved": "Ein Token ist gespeichert. Geben Sie ein neues ein, um es zu ersetzen.", + "tokenSavedShort": "Gespeichert", + "clearToken": "Gespeichertes Token entfernen", + "headers": "Header", + "headersHelp": "Weitere Header, die der Server braucht, etwa ein Header mit API-Schlüssel.", + "headersSaved": "Header sind gespeichert. Neue Zeilen ersetzen alle.", + "clearHeaders": "Gespeicherte Header entfernen", + "headerName": "Header-Name", + "headerValue": "Wert", + "addHeader": "Header hinzufügen", + "env": "Umgebungsvariablen", + "envSaved": "Variablen sind gespeichert. Neue Zeilen ersetzen alle.", + "clearEnv": "Gespeicherte Variablen entfernen", + "envName": "Variable", + "addEnv": "Variable hinzufügen", + "removeRow": "Zeile {{n}} entfernen", + "showValue": "Wert anzeigen", + "hideValue": "Wert ausblenden", + "encrypted": "Tokens, Header und Variablen werden verschlüsselt auf dem Server gespeichert und nie wieder angezeigt.", + "cancel": "Abbrechen", + "saving": "Wird gespeichert", + "saveAndTest": "Speichern und testen", + "savedOn": "Gespeichert. Er ist eingeschaltet.", + "savedOff": "Gespeichert. Er bleibt aus, bis Sie ihn einschalten.", + "testing": "Verbindung zu {{name}} wird hergestellt…", + "okTitle": "Verbunden.", + "okBody_one": "{{name}} bietet {{count}} Werkzeug.", + "okBody_other": "{{name}} bietet {{count}} Werkzeuge.", + "okNoTools": "Der Server bietet keine Werkzeuge.", + "failTitle": "Keine Verbindung möglich.", + "failHint": "Prüfen Sie die Adresse und testen Sie erneut.", + "failAuth": "Prüfen Sie das Token oder die Header.", + "back": "Angaben bearbeiten", + "retry": "Erneut testen", + "done": "Fertig", + "keepOff": "Ausgeschaltet lassen", + "turnOn": "Einschalten", + "discardTitle": "Änderungen verwerfen?", + "discardBody": "Was Sie in dieses Formular eingegeben haben, geht verloren.", + "discard": "Verwerfen", + "keepEditing": "Weiter bearbeiten" + }, + "errors": { + "nameRequired": "Geben Sie einen Namen ein.", + "nameTaken": "Ein anderer Konnektor hat diesen Namen.", + "urlRequired": "Geben Sie die Adresse des Servers ein.", + "urlInvalid": "Das ist keine gültige Adresse.", + "urlScheme": "Die Adresse muss mit http:// oder https:// beginnen.", + "urlCredentials": "Zugangsdaten gehören in das Token oder einen Header, nicht in die Adresse.", + "commandRequired": "Geben Sie den zu startenden Befehl ein." + }, + "remove": { + "title": "{{name}} entfernen?", + "body": "Der Konnektor wird mit seinem gespeicherten Token, seinen Headern und Variablen gelöscht. Ein gespeicherter Agent, der ihn verwendet, kann seine Werkzeuge nicht mehr aufrufen.", + "confirm": "Entfernen", + "pending": "Wird entfernt" + }, + "toasts": { + "removed": "„{{name}}“ entfernt.", + "on": "„{{name}}“ ist eingeschaltet.", + "off": "„{{name}}“ ist ausgeschaltet." + }, + "import": { + "title": "In diesem Browser gespeicherte Server", + "text": "Die alte Chat-Seite hat diese MCP-Server in diesem Browser gespeichert. Importieren Sie sie, um sie auf dem Server zu behalten, wo jeder Browser und jede Sitzung sie nutzen kann. Sie werden ausgeschaltet angelegt. Ihre Tokens und Header gehen an den Server und werden dann aus diesem Browser entfernt.", + "button": "{{count}} Server aus diesem Browser importieren", + "buttonOne": "1 Server aus diesem Browser importieren", + "busy": "Wird importiert", + "headersCount": "{{count}} Header", + "done": "{{count}} importiert. Sie sind ausgeschaltet: Testen und schalten Sie sie unten ein.", + "failed": "{{name}} wurde nicht importiert: {{error}}", + "left": "Die nicht importierten Server bleiben in diesem Browser." + } + }, + "connectorsPage": { + "loadFailed": "Ihre Konnektoren konnten nicht geladen werden.", + "waitingTitle": "Warten auf ihr Add-on", + "waitingText": "Diese Verknüpfungen bleiben erhalten, laufen aber erst, wenn ihr Add-on installiert und eingeschaltet ist." + }, + "tools": { + "commands": { + "title": "Befehle können hier nicht laufen", + "none": "Es ist kein Add-on installiert, das Code ausführt. Der Assistent kann Dateien schreiben, aber keine Skripte oder Befehle ausführen.", + "off": "Das Sandbox-Add-on ist für diesen Chat aus. Schalte es im Menü „Werkzeuge“ ein, damit der Assistent Code ausführen kann.", + "unavailable": "Das Sandbox-Add-on ist installiert, kann auf diesem Server aber keine Befehle ausführen.", + "unavailableWhy": "Das Sandbox-Add-on kann auf diesem Server keine Befehle ausführen: {{reason}}", + "install": "Sandbox installieren", + "openSandbox": "Sandbox-Add-on öffnen", + "askInstall": "Bitte eine Administratorin oder einen Administrator, das Sandbox-Add-on zu installieren.", + "askFix": "Bitte eine Administratorin oder einen Administrator, das zu beheben.", + "hide": "Ausblenden", + "hideLabel": "Diesen Hinweis ausblenden", + "chipTitle": "Dieser Chat kann keinen Code und keine Befehle ausführen. Für Details auswählen.", + "hostTitle": "Befehle laufen auf diesem Rechner", + "host": "Befehle laufen auf diesem Rechner, mit seinen Dateien und seinem Netzwerk, ohne Isolierung. Installieren Sie die Sandbox für Isolierung.", + "hostOff": "Die Sandbox ist für diesen Chat aus, daher laufen Befehle auf diesem Rechner, mit seinen Dateien und seinem Netzwerk, ohne Isolierung. Schalten Sie die Sandbox in diesem Menü ein, um sie zu isolieren.", + "hostUnavailable": "Die Sandbox kann hier nicht laufen, daher laufen Befehle auf diesem Rechner, mit seinen Dateien und seinem Netzwerk, ohne Isolierung.", + "hostUnavailableWhy": "Die Sandbox kann hier nicht laufen ({{reason}}), daher laufen Befehle auf diesem Rechner, mit seinen Dateien und seinem Netzwerk, ohne Isolierung.", + "hostChipTitle": "Befehle in diesem Chat laufen auf diesem Rechner, ohne Isolierung. Für Details auswählen." + }, + "consent": { + "title": "Diesen Befehl auf diesem Rechner ausführen?", + "body": "Befehle laufen auf diesem Rechner ohne Isolierung. Sie können Dateien lesen, die LocalAI lesen kann, und das Internet und Ihr Netzwerk erreichen. Installieren Sie die Sandbox für Isolierung.", + "once": "Einmal ausführen", + "always": "Immer auf dieser Instanz", + "deny": "Nicht ausführen", + "alwaysHint": "„Immer auf dieser Instanz“ lässt die Chats aller Benutzer hier ohne Nachfrage Befehle ausführen (Einstellungen > Werkzeuge).", + "askAdmin": "Nur ein Administrator kann Befehle auf diesem Rechner erlauben. Fragen Sie einen, oder führen Sie den Befehl selbst aus.", + "expired": "Nicht beantwortet: Die Runde, die gefragt hat, ist beendet, daher wurde der Befehl nicht ausgeführt." + }, + "provider": { + "sandbox": "Sandbox", + "addon": "Add-on {{name}}", + "host": "Dieser Rechner, nicht isoliert", + "hostOnce": "Dieser Rechner, einmal erlaubt", + "hostDenied": "Nicht ausgeführt: abgelehnt", + "hostTimeout": "Nicht ausgeführt: keine Antwort", + "hostAdmin": "Nicht ausgeführt: braucht einen Administrator" + }, + "builtins": "Eingebaute Werkzeuge", + "builtin": { + "web": "Web", + "saved_agents": "Gespeicherte Agenten", + "triggers": "Trigger", + "shell": "Befehle ausführen", + "files": "Deine Dateien", + "media": "Bilder und Audio", + "collections": "Sammlungen", + "subagents": "Sub-Agenten" + }, + "builtinBy": "{{tools}} · eingebaut", + "builtinByAddon": "{{tools}} · bedient von {{provider}}", + "servedBy": "Bedient vom Add-on {{provider}}", + "servedByTool": "{{tool}}: {{provider}}", + "servedByBuiltin": "eingebaut", + "sandbox": { + "starting": "Die Sandbox startet. LocalAI prüft in {{seconds}} s erneut." + }, + "fallbackBuiltin": "{{addon}} ersetzt {{tool}}, kann diesen Chat aber gerade nicht bedienen (gestoppt, hier ausgeschaltet oder nicht freigegeben): das eingebaute {{tool}} wird verwendet.", + "fallbackNone": "{{addon}} ersetzt {{tool}}, kann diesen Chat aber gerade nicht bedienen, und das eingebaute {{tool}} ist aus: dieser Chat hat kein {{tool}}.", + "shell": { + "machine": "Dieser Rechner", + "isolated": "Befehle laufen isoliert" + } + }, + "vision": { + "describedBy": "Dieses Modell kann keine Bilder ansehen. Wähle ein anderes Modell, sonst beschreibt {{model}} das Bild.", + "none": "Dieses Modell kann keine Bilder ansehen, und kein Modell hier kann sie beschreiben. Wähle ein Modell, das Bilder ansehen kann." + }, + "egress": { + "label": "Netzwerk", + "preset": { + "off": "Aus", + "registries": "Paket-Registries", + "all": "Alle öffentlichen Seiten" + }, + "presetHint": { + "off": "Befehle in der Sandbox erreichen kein Netzwerk.", + "registries": "Befehle erreichen nur die Paket-Registries (PyPI, npm, Go, crates.io, RubyGems, Maven Central, GitHub-Releases).", + "all": "Befehle erreichen jede öffentliche Seite." + }, + "limited": "Von Ihrem Administrator begrenzt", + "limitedBy": "Ihr Administrator erlaubt höchstens: {{limit}}.", + "hostNetwork": "Befehle laufen auf diesem Rechner und nutzen dessen Netzwerk. Netzwerkgrenzen erfordern das Sandbox-Add-on.", + "panel": "Regeln und Anfragen ({{count}})", + "rules": "Regeln dieses Chats", + "cards": "Anfragen dieses Chats", + "ruleAllow": "Für diesen Chat erlaubt", + "ruleDeny": "Für diesen Chat abgelehnt", + "aboveLimit": "über dem Limit, gilt nicht", + "remove": "Entfernen", + "removeLabel": "Regel für {{host}} entfernen", + "card": { + "pending": "Wartet auf eine Antwort", + "allowedOnce": "Einmal erlaubt", + "allowedChat": "Für diesen Chat erlaubt", + "denied": "Abgelehnt", + "expired": "Nicht beantwortet", + "refused": "Abgelehnt: Das Netzwerklimit des Servers erlaubt es nicht" + }, + "consent": { + "title": "Netzwerkzugriff", + "body": "Ein Befehl in diesem Chat möchte erreichen:", + "once": "Einmal erlauben", + "chat": "Für diesen Chat erlauben", + "deny": "Ablehnen", + "preset": "Dieser Chat: {{preset}}. Limit Ihres Administrators: {{limit}}.", + "adminOnly": "Dieser Host liegt in Ihrem Netzwerk: Nur ein Administrator kann ihn erlauben.", + "answered": "{{state}} von {{who}}, {{time}}", + "answeredNoWho": "{{state}}, {{time}}", + "you": "Ihnen" + }, + "blocked": { + "titleOff": "Netzwerkzugriff ist aus", + "titleCapped": "Netzwerkzugriff ist begrenzt", + "bodyOff": "Der Netzwerkzugriff der Sandbox ist auf diesem Server ausgeschaltet. Ein Befehl wollte erreichen:", + "bodyCapped": "Dieser Server erlaubt für das Sandbox-Netzwerk nur: {{limit}}. Ein Befehl wollte erreichen:", + "adminOff": "Ein Administrator kann ihn in den Einstellungen von {{name}} einschalten.", + "adminCapped": "Ein Administrator kann das Limit in den Einstellungen von {{name}} anheben.", + "adminLink": "Einstellungen von {{name}} öffnen", + "askAdmin": "Bitten Sie einen Administrator dieses Servers, das zu ändern.", + "rowOff": "Aus: Der Administrator hat keinen Netzwerkzugriff erlaubt.", + "runner": "dem Add-on, das die Befehle ausführt" + }, + "allowedHosts": "Von Ihrem Administrator immer erlaubt:", + "allowedHostsOff": "Sie gelten, wenn das Netzwerk eingeschaltet ist.", + "allowedHostsNote": "{{count}} Hosts sind von Ihrem Administrator immer erlaubt.", + "limitNote": "Netzwerk dieses Chats. Der Administrator legt fest, was ein Chat höchstens erreichen darf.", + "asksAbove": "Dieser Chat verlangt {{choice}}; das Limit des Administrators ist {{limit}}." + }, + "inbox": { + "unread": "{{count}} ungelesen", + "markAll": "Alle als gelesen markieren", + "delete": "Löschen", + "pushOn": "Browser-Benachrichtigungen aktivieren", + "pushOff": "Browser-Benachrichtigungen deaktivieren", + "pushDenied": "Browser-Benachrichtigungen sind für diese Website blockiert.", + "pushUnavailable": "Dieser Server kann keine Push-Abonnements speichern.", + "clearAll": "Alle löschen", + "clearRead": "Gelesene löschen", + "cancel": "Abbrechen", + "markRead": "Als gelesen markieren", + "cleared_one": "{{count}} Benachrichtigung entfernt.", + "cleared_other": "{{count}} Benachrichtigungen entfernt.", + "confirmAll_one": "{{count}} Benachrichtigung entfernen? Das lässt sich nicht rückgängig machen.", + "confirmAll_other": "Alle {{count}} Benachrichtigungen entfernen? Das lässt sich nicht rückgängig machen.", + "confirmRead_one": "{{count}} gelesene Benachrichtigung entfernen? Ungelesene bleiben erhalten.", + "confirmRead_other": "{{count}} gelesene Benachrichtigungen entfernen? Ungelesene bleiben erhalten.", + "onlyRead": "Nur die {{count}} gelesenen", + "showing": "{{shown}} von {{total}} angezeigt", + "showMore": "Mehr anzeigen", + "title": "Benachrichtigungen", + "buttonUnread_one": "Benachrichtigungen, {{count}} ungelesen", + "buttonUnread_other": "Benachrichtigungen, {{count}} ungelesen", + "filterLabel": "Benachrichtigungen anzeigen", + "filterAll": "Alle", + "filterUnread": "Ungelesen", + "filterFailed": "Fehlgeschlagen", + "more": "Weitere Aktionen", + "close": "Schließen", + "day": { + "today": "Heute", + "yesterday": "Gestern", + "earlier": "Früher" + }, + "emptyTitle": "Noch keine Benachrichtigungen", + "emptyBody": "Hier erscheinen die Ergebnisse deiner Trigger und Connectors, auch für Arbeit, die fertig wurde, während du weg warst.", + "caughtUp": "Du bist auf dem neuesten Stand.", + "caughtUpTitle": "Du bist auf dem neuesten Stand", + "caughtUpBody": "Es gibt keine ungelesenen Benachrichtigungen.", + "noFailedTitle": "Nichts ist fehlgeschlagen", + "noFailedBody": "Läufe und Connectors, die fehlschlagen, erscheinen hier.", + "loadError": "Deine Benachrichtigungen konnten nicht geladen werden.", + "source": { + "trigger": "Trigger", + "connector": "Connector", + "system": "LocalAI" + }, + "itemUnread": "{{title}}, ungelesen", + "arrived": "Neue Benachrichtigung: {{title}}", + "arrivedMany_one": "{{count}} neue Benachrichtigung", + "arrivedMany_other": "{{count}} neue Benachrichtigungen" + }, + "change": { + "loading": "Die Trigger-Änderung wird gelesen...", + "unread": "Die Trigger-Änderung konnte nicht gelesen werden: {{message}}", + "removed": "Diese Änderung wird nicht mehr aufbewahrt.", + "none": "keine", + "on": "An", + "off": "Aus (pausiert)", + "becomes": "wird zu", + "neverRan": "Noch nicht ausgeführt", + "lastRun": "Letzte Ausführung", + "lastRunValue": "{{status}}, {{when}}", + "runStatus": { + "queued": "Wartet", + "running": "Läuft", + "done": "Fertig", + "failed": "Fehlgeschlagen", + "skipped": "Übersprungen" + }, + "updateTitle": "{{name}} ändern", + "deleteTitle": "{{name}} löschen?", + "updateHelp": "Vorgeschlagene Änderung. Nichts ändert sich, bis du sie übernimmst.", + "deleteHelp": "Nichts wird gelöscht, bis du auf Löschen drückst. Du kannst es einen Tag lang rückgängig machen.", + "stale": "Der Trigger wurde nach dem Vorschlag geändert oder entfernt, daher kann die Änderung nicht übernommen werden. Bitte erneut um die Änderung.", + "apply": "Übernehmen", + "delete": "Löschen", + "keep": "Behalten", + "field": { + "name": "Name", + "prompt": "Prompt", + "when": "Wann", + "time_zone": "Zeitzone", + "daily_cap": "Tageslimit", + "enabled": "Status", + "reads": "Liest", + "writes": "Schreibt", + "widget": "Home-Widget", + "estimated_tokens": "Tokens pro Lauf", + "agent": "Agent", + "model": "Modell" + }, + "receipt": { + "update": "Geändert:", + "pause": "Pausiert:", + "resume": "Fortgesetzt:", + "delete": "Gelöscht:", + "fields": "Was sich geändert hat: {{fields}}", + "dismissed": "Änderung verworfen:", + "kept": "Behalten:", + "undone": "Rückgängig gemacht:", + "restored": "Wiederhergestellt:" + }, + "blocked": { + "expired": "Die Zeit zum Rückgängigmachen ist vorbei; ändere den Trigger auf der Seite Trigger.", + "edited": "Der Trigger wurde seitdem geändert; ändere ihn auf der Seite Trigger.", + "removed": "Der Trigger wurde seitdem entfernt.", + "full": "Du hast so viele Trigger wie erlaubt; entferne zuerst einen auf der Seite Trigger." + }, + "announce": { + "applied": "{{name}} geändert.", + "deleted": "{{name}} gelöscht.", + "dismissed": "Änderung an {{name}} verworfen.", + "kept": "{{name}} behalten.", + "undone": "Änderung an {{name}} rückgängig gemacht.", + "restored": "{{name}} wiederhergestellt." + } + }, + "loops": { + "region": "Schleifen dieses Chats", + "unit": { + "seconds_one": "{{count}} Sekunde", + "seconds_other": "{{count}} Sekunden", + "minutes_one": "{{count}} Minute", + "minutes_other": "{{count}} Minuten", + "hours_one": "{{count}} Stunde", + "hours_other": "{{count}} Stunden" + }, + "unitShort": { + "seconds_one": "{{count}} s", + "seconds_other": "{{count}} s", + "minutes_one": "{{count}} Min.", + "minutes_other": "{{count}} Min.", + "hours_one": "{{count}} Std.", + "hours_other": "{{count}} Std." + }, + "confirm": { + "every": "Dies alle {{every}} ausführen, bis du es stoppst?", + "selfPaced": "Dies jedes Mal erneut ausführen, wenn das Modell darum bittet, bis du es stoppst?", + "start": "Starten", + "cancel": "Abbrechen" + }, + "chip": { + "name": "Schleife", + "every": "alle {{every}}", + "selfPaced": "vom Modell getaktet", + "run_one": "Lauf {{count}}", + "run_other": "Lauf {{count}}", + "paused": "pausiert", + "running": "läuft gerade", + "next": "nächster um {{time}}", + "ended": "beendet", + "wait": "Wartet auf: {{wait}}" + }, + "actions": { + "pause": "Pausieren", + "resume": "Fortsetzen", + "stop": "Stoppen", + "dismiss": "Ausblenden" + }, + "state": { + "pending": "wartet auf deine Zustimmung", + "active": "aktiv", + "paused": "pausiert", + "ended": "beendet" + }, + "list": { + "title": "Schleifen dieses Chats", + "close": "Schließen", + "empty": "Dieser Chat hat keine Schleifen.", + "hint": "Zum Stoppen gib /loop stop und den Code am Anfang ihrer Zeile ein." + }, + "slash": { + "usage": "Gib /loop ein, bei Bedarf ein Intervall, und was wiederholt werden soll. Zum Beispiel: /loop 5m prüfe den Build.", + "notFound": "Keine Schleife dieses Chats passt zu {{id}}.", + "noChat": "Sende zuerst eine Nachricht und starte dann eine Schleife in diesem Chat.", + "stopped_one": "{{count}} Schleife gestoppt.", + "stopped_other": "{{count}} Schleifen gestoppt." + } + } +} diff --git a/core/http/react-ui/public/locales/de/collections.json b/core/http/react-ui/public/locales/de/collections.json index 6a3bb9239..138b28df6 100644 --- a/core/http/react-ui/public/locales/de/collections.json +++ b/core/http/react-ui/public/locales/de/collections.json @@ -4,13 +4,13 @@ "newPlaceholder": "Name der neuen Sammlung...", "actions": { "create": "Erstellen", - "creating": "Wird erstellt...", "details": "Details", "reset": "Zurücksetzen", "delete": "Löschen", "viewDetails": "Details anzeigen", "resetCollection": "Sammlung zurücksetzen", - "deleteCollection": "Sammlung löschen" + "deleteCollection": "Sammlung löschen", + "cancel": "Abbrechen" }, "sections": { "yourCollections": "Ihre Sammlungen", @@ -19,7 +19,8 @@ "empty": { "title": "Noch keine Sammlungen", "text": "Mit Sammlungen organisieren Sie Dokumente in Wissensdatenbanken, die Agenten mit RAG (Retrieval-Augmented Generation) durchsuchen können. Erstellen Sie oben eine Sammlung, um zu beginnen.", - "noPersonal": "Sie haben noch keine Sammlungen." + "noPersonal": "Sie haben noch keine Sammlungen.", + "learn": "So funktioniert der Speicher" }, "deleteDialog": { "title": "Sammlung löschen", @@ -32,12 +33,29 @@ "confirm": "Zurücksetzen" }, "toasts": { - "loadFailed": "Laden der Sammlungen fehlgeschlagen: {{message}}", "created": "Sammlung \"{{name}}\" erstellt", "createFailed": "Erstellen der Sammlung fehlgeschlagen: {{message}}", "deleted": "Sammlung \"{{name}}\" gelöscht", "deleteFailed": "Löschen der Sammlung fehlgeschlagen: {{message}}", "reset": "Sammlung \"{{name}}\" zurückgesetzt", "resetFailed": "Zurücksetzen der Sammlung fehlgeschlagen: {{message}}" + }, + "loadFailed": "Ihre Sammlungen konnten nicht geladen werden.", + "sources": { + "loadFailed": "Die Webquellen konnten nicht geladen werden." + }, + "entries": { + "loadFailed": "Die Dateien konnten nicht geladen werden." + }, + "starting": { + "title": "Der Speicher startet.", + "text": "Der Server bereitet den Speicherdienst noch vor. Diese Seite fragt von selbst erneut." + }, + "embedding": { + "missingTitle": "Kein Embedding-Modell installiert.", + "missingText": "Sammlungen nutzen {{model}}, um Dateien zu indexieren und zu durchsuchen. Bis es installiert ist, schlagen das Hinzufügen von Dateien und die Suche fehl.", + "noneSet": "Für Sammlungen ist kein Embedding-Modell festgelegt (collections_embedding_model), daher schlagen das Hinzufügen von Dateien und die Suche fehl.", + "install": "In Modelle suchen", + "askAdmin": "Bitte eine Administratorin oder einen Administrator, es zu installieren." } } diff --git a/core/http/react-ui/public/locales/de/common.json b/core/http/react-ui/public/locales/de/common.json index f77dea65b..18c2bd3bc 100644 --- a/core/http/react-ui/public/locales/de/common.json +++ b/core/http/react-ui/public/locales/de/common.json @@ -111,26 +111,22 @@ "gigabytes": "GB", "terabytes": "TB" }, - "updateReview": { - "title": "Updates prüfen", - "description": "Nichts wird aktualisiert, bevor Sie hier auswählen.", - "close": "Schließen", - "done": "Fertig", - "selectAll": "Alle auswählen", - "select": "{{name}} auswählen", - "unknownVersion": "unbekannt", - "latest": "neueste", - "blocked": "Hier nicht aktualisierbar:", - "upgradeSelected": "Ausgewählte aktualisieren ({{count}})", - "upgradeAll": "Alle aktualisieren ({{count}})", - "running": "Wird aktualisiert...", - "queued": "Wartet", - "statusRunning": "Wird aktualisiert", - "statusDone": "Aktualisiert", - "statusFailed": "Fehlgeschlagen", - "summary": "{{succeeded}} aktualisiert, {{failed}} fehlgeschlagen.", - "empty": "Nichts zu aktualisieren.", - "statusStarted": "Gestartet", - "summaryStarted": "{{succeeded}} gestartet, {{failed}} fehlgeschlagen." + "ui": { + "tryAgain": "Erneut versuchen", + "undo": "Rückgängig", + "loadError": { + "title": "Konnte nicht geladen werden.", + "serverSaid": "Der Server antwortete „{{message}}“.", + "nothingChanged": "Es wurde nichts geändert." + } + }, + "chip": { + "running": "Läuft", + "starting": "Startet", + "stopped": "Gestoppt", + "failed": "Fehlgeschlagen", + "updateAvailable": "Update verfügbar", + "ready": "Bereit", + "noRunsYet": "Noch keine Läufe" } } diff --git a/core/http/react-ui/public/locales/de/explorer.json b/core/http/react-ui/public/locales/de/explorer.json new file mode 100644 index 000000000..eb29adce7 --- /dev/null +++ b/core/http/react-ui/public/locales/de/explorer.json @@ -0,0 +1,57 @@ +{ + "brand": "{{name}} Explorer", + "openApp": "LocalAI öffnen", + "close": "Schließen", + "copy": "Kopieren", + "copied": "Kopiert", + "loading": "Swarms werden geladen", + "title": "Öffentliche Swarms", + "lede": "Ein Swarm ist eine Gruppe von Rechnern, die Modellinferenz über ein Peer-to-Peer-Netzwerk teilen. Diese Liste zeigt die Swarms, deren Besitzer sie öffentlich gemacht haben. Wer das Token eines Swarms hat, kann ihm beitreten.", + "count_one": "{{count}} Swarm online", + "count_other": "{{count}} Swarms online", + "sorted": "Zuerst die mit den meisten Workern", + "workers_one": "Worker", + "workers_other": "Worker", + "tokenTitle": "Anfang und Ende des Tokens. Kopieren Sie es unter So treten Sie bei.", + "warning": "Der Explorer ist eine Community-Liste von Swarm-Tokens. Jeder kann ein Token verwenden, um Arbeit an einen Swarm zu senden oder Ressourcen mit ihm zu teilen. Nutzung auf eigene Gefahr.", + "listed": "Der Swarm ist gelistet.", + "empty": { + "title": "Kein Swarm hat Worker online", + "text": "Nur Swarms mit mindestens einem Worker online werden aufgelistet. Listen Sie Ihren Swarm, um ihn öffentlich zu machen." + }, + "off": { + "title": "Dieser Server ist kein Explorer", + "text": "Die Explorer-Seite braucht einen LocalAI-Server im Explorer-Modus. Dieser Server hat keine Swarm-Liste zum Anzeigen." + }, + "error": { + "title": "Die Swarms konnten nicht geladen werden", + "text": "Der Explorer-Server hat nicht geantwortet. Prüfen Sie, ob er läuft, und versuchen Sie es dann erneut.", + "retry": "Erneut versuchen" + }, + "join": { + "open": "So treten Sie bei", + "title": "{{name}} beitreten", + "text": "Starten Sie einen LocalAI-Worker mit dem Token dieses Swarms.", + "token": "Token", + "federated": "Föderierter Cluster {{id}}. Starten Sie mit einem der beiden Befehle einen Knoten darin.", + "docker": "Mit Docker", + "cli": "Mit der Kommandozeile", + "noCommands": "Die Worker dieses Swarms teilen ihre Rechenleistung über sein Token. Das Token oben braucht ein Knoten, um beizutreten.", + "docs": "Lesen Sie, wie Swarms funktionieren" + }, + "list": { + "open": "Swarm listen", + "title": "Swarm listen", + "text": "Fügen Sie einen Swarm der öffentlichen Liste hinzu. Er erscheint, sobald er einen Worker online hat.", + "name": "Name", + "description": "Beschreibung", + "token": "Token", + "tokenHint": "Das Netzwerk-Token des Swarms, so wie der Server es ausgegeben hat.", + "public": "Das Listen veröffentlicht das Token. Jeder, der die Liste sieht, kann die Worker Ihres Swarms nutzen.", + "understood": "Mir ist klar, dass das Token öffentlich wird.", + "cancel": "Abbrechen", + "add": "Diesen Swarm listen", + "adding": "Wird gelistet...", + "failed": "Der Swarm konnte nicht gelistet werden." + } +} diff --git a/core/http/react-ui/public/locales/de/home.json b/core/http/react-ui/public/locales/de/home.json index 5ee65e007..ffd6957f2 100644 --- a/core/http/react-ui/public/locales/de/home.json +++ b/core/http/react-ui/public/locales/de/home.json @@ -25,7 +25,10 @@ "title": "LocalAI per Chat verwalten", "description": "Modelle installieren, Backends wechseln, Konfigurationen bearbeiten und Status prüfen — durch Gespräche mit LocalAI.", "open": "Assistent öffnen", - "tooltip": "LocalAI per Chat verwalten" + "tooltip": "LocalAI per Chat verwalten", + "noModel": "Wähle zuerst ein Chat-Modell.", + "startFailed": "Der Assistenten-Chat konnte nicht gestartet werden: {{message}}", + "chatTitle": "LocalAI-Assistent" }, "input": { "placeholder": "Nachricht...", @@ -79,13 +82,17 @@ "step2Title": "Modell installieren", "step2Body": "Klicken Sie auf Installieren, um es automatisch herunterzuladen und zu konfigurieren.", "step3Title": "Chat starten", - "step3Body": "Chatten Sie mit Ihrem Modell direkt im Browser oder nutzen Sie die API." + "step3Body": "Chatten Sie mit Ihrem Modell direkt im Browser oder nutzen Sie die API.", + "sandboxTitle": "Code sicher ausführen (optional)", + "sandboxBody": "Installieren Sie das Sandbox-Add-on, damit das Modell Code und Befehle isoliert ausführt. Ohne es laufen Befehle direkt auf diesem Rechner, mit vollem Netzwerkzugriff und ohne Isolation." }, "browseGallery": "Modellgalerie durchsuchen", "importModel": "Modell importieren", "docs": "Dokumentation", "noModelsTitle": "Keine Modelle verfügbar", - "noModelsBody": "Es sind noch keine Modelle installiert. Bitten Sie Ihren Administrator, Modelle einzurichten, damit Sie mit dem Chatten beginnen können." + "noModelsBody": "Es sind noch keine Modelle installiert. Bitten Sie Ihren Administrator, Modelle einzurichten, damit Sie mit dem Chatten beginnen können.", + "sandboxInstall": "Sandbox installieren", + "sandboxSkip": "Überspringen" }, "connect": { "title": "Ein Endpunkt, jede API", @@ -105,7 +112,23 @@ "modelsSummary": "Explore, install, and manage models", "create": "Create", "createSummary": "Open a chat, image or voice session", - "operate": "Operate", - "operateSummary": "{{models}} models configured · nodes, activity and traces" + "operate": "Verwaltung", + "operateSummary": "{{models}} models configured · nodes, activity and traces", + "allChats": "Alle Chats" + }, + "slash": { + "agents": { + "label": "Agenten", + "desc": "Deine gespeicherten Agenten, Auslöser und Skills" + }, + "mcp": { + "label": "MCP-Connectors", + "desc": "MCP-Server, die deine Chats nutzen können" + }, + "memory": { + "label": "Gedächtnis", + "desc": "Sammlungen, in denen deine Chats suchen können" + }, + "complete": "ergänzen" } } diff --git a/core/http/react-ui/public/locales/de/library.json b/core/http/react-ui/public/locales/de/library.json new file mode 100644 index 000000000..961534f47 --- /dev/null +++ b/core/http/react-ui/public/locales/de/library.json @@ -0,0 +1,114 @@ +{ + "usedBy": { + "label": "Verwendet von", + "none": "Noch nicht verwendet", + "line": "Verwendet von {{name}}", + "lineMore": "Verwendet von {{name}} +{{count}}", + "all": "alle Skills", + "allTitle": "Dieser Agent lädt jeden Skill", + "remove": "Von {{agent}} entfernen", + "open": "{{agent}} öffnen", + "unread": "Die Verwendung kann gerade nicht gelesen werden.", + "unreadShort": "Verwendung unbekannt" + }, + "notUsed": { + "lead": "Noch nicht verwendet.", + "skill": "Er bleibt in der Bibliothek, bis Sie ihn irgendwo hinzufügen. Probieren Sie ihn zuerst aus oder fügen Sie ihn einem Agenten hinzu.", + "collection": "Die Sammlung bleibt in der Bibliothek, bis Sie sie irgendwo hinzufügen. Durchsuchen Sie sie hier oder fügen Sie sie einem Agenten hinzu.", + "skillShort": "Er bleibt in der Liste, bis Sie ihn einem Agenten hinzufügen." + }, + "tryInContext": "In einem Kontext testen", + "add": { + "button": "Hinzufügen zu...", + "title": "{{name}} hinzufügen zu", + "agents": "Agenten", + "noAgents": "Noch keine Agenten. Erstellen Sie einen Agenten und fügen Sie ihn dann hier hinzu.", + "createAgent": "Agenten erstellen", + "added": "Hinzugefügt", + "action": "Hinzufügen", + "addToAgent": "Zu {{agent}} hinzufügen", + "working": "Wird hinzugefügt...", + "estimate": "Schätzungen rechnen mit Zeichen geteilt durch vier.", + "toolsMode": "Wird geladen, wenn das Modell es anfordert", + "tokens": "+{{count}} Token pro Nachricht (Schätzung)", + "collectionNote": "Ein Agent kann die Sammlungen auf seiner Liste durchsuchen.", + "kbOn": "Schaltet seine Wissensbasis ein", + "noAgentNamed": "Es gibt keinen Agenten namens {{name}}. Erstellen Sie einen Agenten mit diesem Namen, um diese Sammlung zu nutzen.", + "close": "Schließen", + "skillNote": "Ein Agent kann die Skills auf seiner Liste lesen." + }, + "toasts": { + "skillAdded": "{{name}} zu {{agent}} hinzugefügt", + "skillRemoved": "{{name}} von {{agent}} entfernt", + "skillsOff": "{{name}} von {{agent}} entfernt. Skills sind dafür jetzt ausgeschaltet.", + "collectionAdded": "{{name}} zu {{agent}} hinzugefügt", + "collectionRemoved": "{{name}} von {{agent}} entfernt", + "undone": "Änderung rückgängig gemacht", + "updateFailed": "{{agent}} konnte nicht aktualisiert werden: {{message}}", + "undo": "Rückgängig", + "dismiss": "Schließen" + }, + "simulate": { + "button": "Eine Nachricht simulieren", + "title": "Eine Nachricht simulieren", + "desc": "Wählen Sie einen Kontext und sehen Sie, was er laden würde. Nichts wird an ein Modell gesendet.", + "close": "Schließen", + "context": "Kontext", + "contextCollection": "Eine Sammlung allein", + "contextAgent": "Agent: {{name}}", + "collectionOn": "Sammlung", + "noCollections": "Noch keine Sammlungen.", + "contextHintCollection": "Durchsucht die Sammlung mit Ihrer Nachricht und zeigt die gefundenen Passagen mit ihren Werten. Kein Agent und kein Modell ist beteiligt.", + "contextHintAgent": "Zeigt, was dieser Agent in seinem Prompt behält, plus die Passagen, die seine Wissensbasis für diese Nachricht hinzufügen würde.", + "message": "Nachricht", + "messagePlaceholder": "Schreiben Sie, was eine Person fragen könnte", + "passages": "Passagen", + "run": "Ausführen", + "running": "Läuft...", + "onlyForTest": "Nur für diesen Test", + "removeExtra": "{{name}} aus diesem Test entfernen", + "tryAlso": "Etwas testen, das noch nicht hinzugefügt ist", + "tryAlsoPick": "Wählen Sie einen Skill oder eine Sammlung", + "extraSkill": "Skill {{name}}", + "extraCollection": "Sammlung {{name}}", + "budgetTitle": "Was dieser Kontext hinzufügt", + "budgetTotal": "Etwa {{count}} Token um Ihre Nachricht herum", + "budgetMessage": "Ihre Nachricht: {{count}} Token", + "budgetNote": "Schätzungen: Zeichen geteilt durch vier. Das Kontextfenster des Modells ist hier nicht bekannt, daher gibt es keinen Anteil an einem Limit.", + "instructions": "Anweisungen", + "skills": "Skills", + "memory": "Speicher", + "skillsTitle": "Skills, die dieser Kontext lädt", + "skillsNone": "Für diesen Agenten sind keine Skills aktiv.", + "skillsPrompt": "Im Prompt jeder Nachricht.", + "skillsTools": "Wird nur gelesen, wenn das Modell einen anfordert.", + "skillsBoth": "Im Prompt, und wird erneut gelesen, wenn das Modell einen anfordert.", + "skillsAll": "Für diesen Agenten sind alle Skills aktiv.", + "skillMissing": "{{name}} steht auf der Liste, aber kein Skill hat diesen Namen.", + "skillTokens": "{{count}} Token", + "skillTokensTools": "0 vorab", + "memoryTitle": "Speicher, der gesendet würde", + "memoryNone": "Auf der Liste dieses Agenten steht keine Sammlung. Fügen Sie eine hinzu, um sie zu nutzen.", + "memoryNoCollection": "Die Wissensbasis dieses Agenten ist aktiv, aber es gibt noch keine Sammlung namens {{name}}.", + "memoryTools": "Dieser Agent sucht nur, wenn das Modell es anfordert. Vorab wird nichts zur Nachricht hinzugefügt.", + "memoryTesting": "Sammlung {{name}}, nur für diesen Test", + "memoryFrom": "Von {{name}}", + "passagesCount": "{{count}} Passagen, nach Ähnlichkeit sortiert", + "noPassages": "Für diese Nachricht wurde nichts gefunden.", + "pickMessage": "Schreiben Sie zuerst eine Nachricht.", + "searchFailed": "Die Suche ist fehlgeschlagen: {{message}}", + "score": "Wert {{score}}", + "tokens": "{{count}} Token", + "noAgents": "Noch keine Agenten. Sie können eine Sammlung trotzdem allein durchsuchen.", + "backendNote": "Ein Probelauf des Modells ist hier nicht verfügbar, daher wird keine Antwort simuliert." + }, + "search": { + "failed": "Suche fehlgeschlagen: {{message}}" + }, + "agentBudget": "Die ausgewählten Skills fügen jeder Nachricht etwa {{count}} Token hinzu (Schätzung: Zeichen geteilt durch vier).", + "agentKb": { + "reads": "Dieser Agent liest die Sammlung namens {{name}}.", + "exists": "Dieser Agent liest die Sammlung namens {{name}}. Sie können sie im Speicher befüllen und durchsuchen.", + "created": "Dieser Agent liest die Sammlung namens {{name}}. Sie wird beim Speichern erstellt." + } +} diff --git a/core/http/react-ui/public/locales/de/modelEditor.json b/core/http/react-ui/public/locales/de/modelEditor.json new file mode 100644 index 000000000..565ac9a97 --- /dev/null +++ b/core/http/react-ui/public/locales/de/modelEditor.json @@ -0,0 +1,6 @@ +{ + "proxyVision": { + "label": "Dieses Modell kann Bilder ansehen", + "hint": "Einschalten, wenn das Upstream-Modell Bilder annimmt. LocalAI kann das nicht prüfen. Angehängte Bilder gehen dann mit der Nachricht an das Modell." + } +} diff --git a/core/http/react-ui/public/locales/de/models.json b/core/http/react-ui/public/locales/de/models.json index af487c86e..de6934e80 100644 --- a/core/http/react-ui/public/locales/de/models.json +++ b/core/http/react-ui/public/locales/de/models.json @@ -1,5 +1,6 @@ { "lifecycle": { + "supporting": "Finde ein Modell, das auf diesen Rechner passt, installiere es und pflege die vorhandenen.", "title": "Models", "navLabel": "Model lifecycle", "views": { "explore": "Explore", "installed": "Installed" }, @@ -81,6 +82,10 @@ "delete": "Löschen" }, "filters": { + "sheetDone": "Modelle anzeigen", + "sheetTitle": "Filter", + "sheetButtonCount": "Filter ({{count}})", + "sheetButton": "Filter", "all": "Alle", "llm": "LLM", "image": "Bild", diff --git a/core/http/react-ui/public/locales/de/nav.json b/core/http/react-ui/public/locales/de/nav.json index fcf1a84a7..168461d98 100644 --- a/core/http/react-ui/public/locales/de/nav.json +++ b/core/http/react-ui/public/locales/de/nav.json @@ -10,12 +10,13 @@ "changeLanguage": "Sprache ändern", "logout": "Abmelden", "accountSettings": "Kontoeinstellungen", + "yourSettings": "Deine Einstellungen", "account": "Konto", "accountFor": "Konto: {{name}}", "sections": { "recognition": "Erkennung", "build": "Entwickeln", - "operate": "Betrieb", + "operate": "Verwaltung", "workspace": "Arbeitsbereich" }, "operate": { @@ -66,7 +67,14 @@ "logs": "Logs", "trafficModels": "Models", "gpuHost": "GPU and host", - "prometheus": "Prometheus" + "prometheus": "Prometheus", + "chats": "Chats", + "files": "Dateien", + "triggers": "Auslöser", + "mcpConnectors": "MCP-Connectors", + "chatsAdmin": "Chats aller Benutzer", + "pages": "Seiten", + "allChats": "Alle Chats" }, "footer": { "github": "GitHub", @@ -99,7 +107,15 @@ "import": "Füge ein Modell aus einer Datei, einer URL oder einem Hugging-Face-Repository hinzu.", "voices": "Erkenne Sprecher und verwalte eine Bibliothek von Stimmprofilen.", "faces": "Gesichter erkennen, vergleichen und zuordnen.", - "addons": "Add-ons installieren und die Apps öffnen, die sie bereitstellen." - } - } + "addons": "Add-ons installieren und die Apps öffnen, die sie bereitstellen.", + "triggers": "Starte einen Chat von selbst, nach Zeitplan oder wenn eine Datei eintrifft." + }, + "operateSupporting": "Was auf diesem Server läuft, wie er genutzt wird und wie er eingerichtet ist." + }, + "pinnedPages": "Angeheftete Seiten", + "new": "Neu", + "recentChats": "Letzte Chats", + "newChat": "Neuer Chat", + "untitledChat": "Chat ohne Titel", + "chatRunning": "Läuft" } diff --git a/core/http/react-ui/public/locales/de/operate.json b/core/http/react-ui/public/locales/de/operate.json new file mode 100644 index 000000000..43c260d8d --- /dev/null +++ b/core/http/react-ui/public/locales/de/operate.json @@ -0,0 +1,57 @@ +{ + "backends": { + "filterLabel": "Backends filtern", + "check": "Nach Updates suchen", + "reinstallBackend": "Backend neu installieren", + "deleteBackend": "Backend löschen", + "updateAllTitle": "Alle Backends aktualisieren", + "updateAllIntro": "Wählen Sie, welche Backends aktualisiert werden. Jedes wird einzeln nacheinander aktualisiert. Ein Fehler stoppt die anderen nicht.", + "updateSelected": "Auswahl aktualisieren", + "loadFailedTitle": "Der Backend-Katalog konnte nicht geladen werden.", + "filtersButton": "Filter", + "filtersDone": "Fertig", + "filterGroups": { + "state": "Status", + "type": "Typ", + "show": "Anzeigen", + "library": "Bibliothek" + }, + "showVariants": "Varianten", + "showDevelopment": "Entwicklungs-Builds", + "allLibraries": "Alle Bibliotheken", + "moreActions": "Weitere Backend-Aktionen", + "installFromUrl": "Von URL installieren…", + "manageLibraries": "Bibliotheken verwalten…", + "count_one": "{{count}} Backend", + "count_other": "{{count}} Backends", + "countOf": "{{shown}} von {{total}} Backends", + "activeFilters": "Aktive Filter", + "removeFilter": "Filter entfernen: {{name}}", + "checking": "Suche nach Updates…", + "updatesAvailable_one": "{{count}} Update verfügbar", + "updatesAvailable_other": "{{count}} Updates verfügbar", + "reviewUpdates": "Prüfen", + "close": "Schließen", + "installedLoadFailedTitle": "Die installierten Backends konnten nicht geladen werden." + }, + "capacity": { + "diskChip": "Datenträger fast voll" + }, + "activity": { + "kind": { + "addon": "Add-on" + }, + "verb": { + "upgrading": "{{kind}} wird aktualisiert", + "failedUpgrade": "{{kind}} konnte nicht aktualisiert werden" + }, + "filter": { + "addons": "Add-ons" + }, + "upgradeVersions": "{{from}} auf {{to}}", + "rowUpgraded": "Aktualisiert in {{duration}}", + "rowUpgradedPlain": "Aktualisiert", + "viewInAddons": "In Add-ons anzeigen", + "upgradeWaiting": "Wartet auf den Start des Add-on-Dienstes. Nächste Prüfung in {{seconds}} s." + } +} diff --git a/core/http/react-ui/public/locales/de/pages.json b/core/http/react-ui/public/locales/de/pages.json new file mode 100644 index 000000000..187bf2bec --- /dev/null +++ b/core/http/react-ui/public/locales/de/pages.json @@ -0,0 +1,138 @@ +{ + "subtitle": "Kleine Werkzeuge, die der Assistent für dich gebaut hat. Sie öffnen sich hier in einer Sandbox und speichern ihre Daten in deinen Dateien.", + "card": { + "pin": "An Seitenleiste anheften", + "unpin": "Von Seitenleiste lösen", + "changedBy": { + "you": "Geändert {{when}} von dir", + "assistant": "Geändert {{when}} vom Assistenten" + }, + "menu": "Mehr für {{title}}", + "files": "Dateien anzeigen", + "rename": "Umbenennen", + "renameLabel": "Neuer Ordnername", + "renameHint": "Kleinbuchstaben, Ziffern und Bindestriche.", + "renameSave": "Speichern", + "renameCancel": "Abbrechen", + "renameFailed": "Die Seite wurde nicht umbenannt: {{message}}", + "delete": "Seite löschen", + "deleteTitle": "„{{title}}“ löschen?", + "deleteMessage": "Der Ordner {{folder}} kommt in den Papierkorb. Du kannst ihn in Dateien wiederherstellen.", + "deleted": "{{title}} ist im Papierkorb.", + "deleteFailed": "Die Seite wurde nicht gelöscht: {{message}}" + }, + "empty": { + "text": "Eine Seite ist ein kleines Werkzeug, etwa ein Aufgabenboard oder eine Leseliste. Bitte den Assistenten im Chat um eine oder starte mit einer fertigen Seite.", + "starting": "Seite wird kopiert", + "starterFailed": "Die Seite wurde nicht kopiert: {{message}}", + "ask": "Seite anfragen", + "askPrompt": "Erstelle mir eine Seite, die ", + "starterReading": "Mit einer Leseliste starten", + "note": "Dieser Eintrag verschwindet aus der Seitenleiste, wenn du keine Seiten hast. Mit der nächsten kommt er zurück.", + "learn": "So funktionieren Seiten" + }, + "askNew": "Neue Seite anfragen", + "yours": "Deine", + "filter": "Seiten filtern", + "shared": { + "yours": "Deine", + "group": "Mit dir geteilt", + "note": "Eine geteilte Seite läuft erst, wenn du sie erlaubst. Ändert ihr Autor sie, wirst du erneut gefragt.", + "offNotice": "Geteilte Seiten sind auf diesem Server ausgeschaltet. Ein Administrator hat sie ausgeschaltet.", + "off": "Geteilte Seiten sind auf diesem Server ausgeschaltet. Ein Administrator hat sie ausgeschaltet.", + "gone": "Diese Seite ist nicht mehr im geteilten Ordner, oder du hast keinen Zugriff mehr darauf.", + "review": "Prüfen", + "from": "Von {{author}}", + "menu": "Seitenmenü", + "badge": { + "notAllowed": "Noch nicht erlaubt", + "changed": "Geändert, seit du sie erlaubt hast", + "allowed": "Erlaubt" + } + }, + "viewer": { + "stopAllowing": "Diese Seite nicht mehr erlauben", + "authorChanged": "{{author}} hat diese Seite geändert. Die erlaubte Version läuft weiter, bis du die Änderung prüfst.", + "sandboxShared": "Abgeschottet. Läuft als du, nur in {{folder}}, und erreicht das Netzwerk nicht." + }, + "trust": { + "title": "„{{title}}“ öffnen?", + "desc": "Diese Seite stammt aus einem geteilten Ordner. Seiten laufen als du, prüfe sie also, bevor sie läuft.", + "who": "Geschrieben von {{author}}", + "whereLabel": "Geteilter Ordner", + "whereWhen": ", geändert {{when}}", + "someone": "jemandem", + "close": "Schließen", + "canTitle": "Sie kann", + "canStillTitle": "Sie kann weiterhin", + "cannotTitle": "Sie kann nicht", + "items": { + "files": "Dateien in ihrem eigenen Ordner lesen und schreiben", + "readOnly": "Du kannst diesen geteilten Ordner nur lesen, daher kann die Seite ihre Änderungen nicht speichern.", + "chat": "Eine Nachricht in einen Chat schreiben", + "chatNote": "Du siehst die Nachricht im Chat, bevor der Assistent antwortet.", + "noFiles": "Deine anderen Dateien oder Chats lesen", + "noNet": "Das Netzwerk oder andere Websites erreichen", + "noNetNote": "Eine Grenze: Sie kann in ihrem eigenen Rahmen eine andere Adresse laden. Das sendet eine Anfrage nach außen, danach hält die Seite an.", + "noTools": "Werkzeuge nutzen oder deine Einstellungen ändern" + }, + "warn": "Sie handelt in ihrem Ordner als du. Ändert {{author}} sie, wirst du erneut gefragt.", + "viewSource": "Quelltext ansehen", + "cancel": "Abbrechen", + "allowOnce": "Einmal erlauben", + "allow": "Erlauben", + "changed": { + "title": "„{{title}}“ wurde geändert", + "desc": "Du hast eine frühere Version erlaubt. Prüfe die Änderungen, bevor sie wieder läuft.", + "who": "Geändert von {{author}} {{when}}", + "allowed": "Du hast die Version vom {{date}} erlaubt", + "what": "Was sich geändert hat", + "same": "Was sie tun kann, ist wie vorher.", + "warn": "Wenn du sie erlaubst, läuft diese Version bei jedem Öffnen, bis {{author}} sie wieder ändert.", + "view": "Änderungen ansehen", + "lines": "{{before}} → {{after}} Zeilen", + "code": "Ein Skript, Stylesheet oder Modul der Seite hat sich geändert: {{files}}" + }, + "pill": { + "notYet": "Noch nicht erlaubt", + "not": "Nicht erlaubt", + "once": "Für diesen Besuch erlaubt", + "allowed": "Erlaubt", + "changed": "Geändert, seit du sie erlaubt hast" + }, + "from": "Von {{author}}", + "frame": { + "chrome": "Geteilte Seite aus {{folder}}. Sie ist nicht gelaufen." + }, + "blocked": { + "title": "Diese Seite ist nicht gelaufen", + "body": "Du hast entschieden, sie nicht auszuführen. Sie stammt aus dem geteilten Ordner {{folder}} von {{author}}. Nichts darin ist gelaufen und nichts wurde gespeichert.", + "review": "Prüfen und erlauben" + }, + "waiting": { + "title": "Wartet auf dich", + "body": "Nichts auf dieser Seite läuft, bevor du sie erlaubst." + }, + "stopped": { + "revoked": "Du erlaubst diese Seite nicht mehr. Sie wurde angehalten, und nichts darin läuft, bis du sie wieder erlaubst.", + "refused": "Diese Seite ist nicht mehr erlaubt und wurde angehalten. Prüfe sie, um sie wieder zu erlauben.", + "off": "Geteilte Seiten wurden auf diesem Server ausgeschaltet, daher wurde diese Seite angehalten." + }, + "changedMeanwhile": "Die Seite hat sich geändert, während du sie geprüft hast. Das ist die neue Version.", + "sourceTitle": "Quelltext von „{{title}}“", + "sourceLines_one": "{{count}} Zeile", + "sourceLines_other": "{{count}} Zeilen", + "sourceNote": "Der Quelltext wird als Text gezeigt. Nichts davon läuft hier." + }, + "frame": { + "filesFailed": "Dateien neben dieser Seite wurden nicht geladen: {{files}}. Die Browserkonsole nennt den Grund.", + "tooLarge": "Diese Seite bindet mehr als {{limit}} an Dateien aus ihrem Ordner ein, daher fehlen einige. Laden Sie Daten stattdessen mit fetch().", + "dismiss": "Schließen" + }, + "link": { + "label": "Die Seite möchte einen Link öffnen", + "open": "{{site}} öffnen", + "cancel": "Abbrechen", + "lookalike": "Diese Adresse nutzt Buchstaben anderer Alphabete und liest sich als {{display}}. Sie kann eine andere Website nachahmen: Prüfen Sie sie vor dem Öffnen." + } +} diff --git a/core/http/react-ui/public/locales/de/skills.json b/core/http/react-ui/public/locales/de/skills.json index e648d7195..2f53f7e53 100644 --- a/core/http/react-ui/public/locales/de/skills.json +++ b/core/http/react-ui/public/locales/de/skills.json @@ -1,79 +1,83 @@ { "title": "Fähigkeiten", - "subtitle": "Verwalten Sie Agentenfähigkeiten (wiederverwendbare Anweisungen und Ressourcen)", + "subtitle": "Anweisungen, die alle deine Chats nutzen können, wenn eine Aufgabe passt. Agenten nutzen die, die du ihnen hinzufügst.", "unavailable": { - "subtitle": "Der Fähigkeiten-Dienst ist nicht verfügbar oder der Index wird neu erstellt. Versuchen Sie es in einem Moment erneut.", - "retry": "Erneut versuchen" + "subtitle": "Fähigkeiten brauchen Dateien, und Dateien sind auf diesem Server ausgeschaltet." }, "actions": { "newSkill": "Neue Fähigkeit", "createSkill": "Fähigkeit erstellen", - "import": "Importieren", - "importing": "Importieren...", - "gitRepos": "Git-Repos", "edit": "Bearbeiten", - "delete": "Löschen", - "export": "Exportieren", - "sync": "Synchronisieren", - "addRepo": "Repo hinzufügen", - "adding": "Hinzufügen...", - "remove": "Entfernen", - "enable": "Aktivieren", - "disable": "Deaktivieren" + "delete": "Löschen" }, "search": { "placeholder": "Fähigkeiten suchen..." }, - "git": { - "title": "Git-Repositories", - "description": "Fügen Sie Git-Repositories hinzu, aus denen Fähigkeiten geladen werden. Fähigkeiten erscheinen nach der Synchronisierung in der Liste.", - "urlPlaceholder": "https://github.com/user/repo oder git@github.com:user/repo.git", - "noRepos": "Keine Git-Repos konfiguriert. Fügen Sie oben eines hinzu.", - "disabled": "Deaktiviert", - "removeRepo": "Repo entfernen" - }, "card": { "noDescription": "Keine Beschreibung", "readOnly": "Nur lesen", "editTitle": "Fähigkeit bearbeiten", "deleteTitle": "Fähigkeit löschen", - "exportTitle": "Als .tar.gz exportieren" + "invalid": "Skipped: not a valid skill", + "invalidWhy": "Diese Fähigkeit wurde übersprungen: {{reason}}", + "shadowed": "Hidden by a {{layer}} skill", + "shadowedLong": "Hidden by a {{layer}} skill of the same name", + "providedBy": "Bereitgestellt von {{name}}", + "replacedBy": "Ersetzt durch {{name}}", + "notActive": "Nicht aktiv: der integrierte Skill wird verwendet", + "notActiveBy": "Nicht aktiv: {{name}} stellt ihn bereit", + "fallback": "Aktiv, weil der eigene Skill des Add-ons {{name}} aus ist oder nicht läuft" }, "empty": { "title": "Keine Fähigkeiten gefunden", "text": "Erstellen oder importieren Sie eine Fähigkeit, um loszulegen.", - "noPersonal": "Sie haben noch keine Fähigkeiten." - }, - "sections": { - "yourSkills": "Ihre Fähigkeiten", - "otherUsersSkills": "Fähigkeiten anderer Benutzer" - }, - "deleteDialog": { - "title": "Fähigkeit löschen", - "message": "Fähigkeit \"{{name}}\" löschen? Diese Aktion kann nicht rückgängig gemacht werden.", - "confirm": "Löschen" - }, - "removeRepoDialog": { - "title": "Git-Repository entfernen", - "message": "Dieses Git-Repository entfernen? Fähigkeiten daraus sind nicht mehr verfügbar.", - "confirm": "Entfernen" + "stepWrite": "Write a skill: a name, a line that says when to use it, and the instructions.", + "stepTry": "Check it in the list: it shows who uses it and what it says.", + "stepAdd": "Add it to an agent when you want it used." }, "toasts": { "loadFailed": "Laden der Fähigkeiten fehlgeschlagen", "deleted": "Fähigkeit \"{{name}}\" gelöscht", - "deleteFailed": "Löschen der Fähigkeit fehlgeschlagen", - "exported": "Fähigkeit \"{{name}}\" exportiert", - "exportFailed": "Export fehlgeschlagen", - "imported": "Fähigkeit aus \"{{file}}\" importiert", - "importFailed": "Import fehlgeschlagen", - "loadReposFailed": "Laden der Git-Repos fehlgeschlagen", - "repoAdded": "Git-Repo hinzugefügt und synchronisiert", - "addRepoFailed": "Hinzufügen des Repos fehlgeschlagen", - "synced": "Repo synchronisiert", - "syncFailed": "Synchronisierung fehlgeschlagen", - "toggled": "Repo umgeschaltet", - "toggleFailed": "Umschalten fehlgeschlagen", - "removed": "Repo entfernt", - "removeFailed": "Entfernen fehlgeschlagen" + "deleteFailed": "Löschen der Fähigkeit fehlgeschlagen" + }, + "layers": { + "label": "Layer", + "personal": "Personal", + "shared": "Shared", + "builtin": "Built-in", + "folder": "Workspace", + "addon": "Add-on" + }, + "pane": { + "loading": "Loading...", + "loadFailed": "The skill could not be read.", + "tokensExplain": "A chat reads a skill only when a task matches its description, so it adds nothing until then. About {{count}} tokens when read (estimate)." + }, + "edit": { + "back": "Back to skills", + "titleNew": "New skill", + "titleEdit": "Edit: {{name}}", + "basic": "Basic information", + "content": "Content", + "layer": "Layer", + "layerHelp": "Personal is yours alone. Shared is read by every user.", + "name": "Name (lowercase, hyphens only)", + "nameLocked": "Name cannot be changed after creation.", + "description": "Description (when to use it)", + "descriptionHelp": "A chat reads this line to decide whether the skill fits the task.", + "body": "Skill content (markdown)", + "cancel": "Cancel", + "save": "Save changes", + "create": "Create skill", + "saving": "Saving...", + "created": "Skill created", + "updated": "Skill updated", + "required": "A name and a description are required", + "saveFailed": "Save failed", + "loadFailed": "Failed to load the skill", + "readOnly": "Diese Fähigkeit ist hier schreibgeschützt. Eine eingebaute Fähigkeit gehört zum Server, und eine Fähigkeit aus deinen Dateien änderst du unter Dateien." + }, + "load": { + "title": "Ihre Skills konnten nicht geladen werden." } } diff --git a/core/http/react-ui/public/locales/de/swarm.json b/core/http/react-ui/public/locales/de/swarm.json new file mode 100644 index 000000000..1054290eb --- /dev/null +++ b/core/http/react-ui/public/locales/de/swarm.json @@ -0,0 +1,5 @@ +{ + "nodes": { + "retry": "Erneut versuchen" + } +} diff --git a/core/http/react-ui/public/locales/de/tools.json b/core/http/react-ui/public/locales/de/tools.json new file mode 100644 index 000000000..d1f7b410e --- /dev/null +++ b/core/http/react-ui/public/locales/de/tools.json @@ -0,0 +1,468 @@ +{ + "steps": { + "label": "Fortschritt", + "setup": "Einrichten", + "check": "Prüfen", + "run": "Ausführen", + "result": "Ergebnis" + }, + "run": { + "stop": "Stoppen", + "connecting": "Verbindung zum Job wird hergestellt. Warten auf das erste Fortschrittsereignis.", + "progress": "Fortschritt", + "stages": "Phasen", + "step": "Schritt", + "of": "von", + "epoch": "Epoche", + "tokens": "Token/s", + "eta": "noch etwa {{time}}", + "etaSeconds": "noch etwa {{seconds}} s" + }, + "landing": { + "lede": "Was Sie auf diesem Rechner erstellen können und was jedes Werkzeug zuerst braucht.", + "machine": "Dieser Rechner", + "gpu": "GPU", + "gpuFree": "{{free}} von {{total}} frei", + "noGpu": "Keine GPU gefunden", + "cluster": "{{total}} auf dem besten Knoten ({{node}})", + "clusterNode": "ein Worker", + "ram": "Speicher", + "ramFree": "RAM, {{free}} frei", + "disk": "Modell-Speicher", + "diskFree": "frei", + "backends": "Backends", + "noBackends": "für diese Werkzeuge keines installiert", + "open": "Öffnen", + "openTool": "{{tool}} öffnen", + "install": "Installieren", + "ready": "Bereit", + "needsBackend": "Backend nötig", + "experimental": "Experimentell", + "needs": "Was es braucht", + "flow": "Der Weg von einem gefundenen Modell zu einem, mit dem Sie chatten können", + "flowNote": "Jeder Schritt endet mit einem Modell, das Sie laden können.", + "now": { + "running": { + "fineTune": "{{model}} wird feinabgestimmt", + "quantize": "{{model}} wird auf {{type}} quantisiert" + }, + "failed": { + "fineTune": "Feinabstimmung von {{model}} fehlgeschlagen", + "quantize": "Quantisierung von {{model}} auf {{type}} fehlgeschlagen" + } + }, + "groups": { + "make": { + "title": "Ein Modell erstellen", + "text": "Beginnen Sie mit einem gefundenen Modell und erhalten Sie eines, das passt und Ihre Daten kennt." + }, + "recognise": { + "title": "Erkennen", + "text": "Wer spricht, und wessen Gesicht auf einem Foto zu sehen ist." + }, + "automate": { + "title": "Automatisieren", + "text": "Assistenten, ihre Anweisungen und ihr Gedächtnis." + } + }, + "sentence": { + "import": "Importieren Sie ein Modell von Hugging Face, per URL, als Ollama- oder OCI-Referenz oder als Datei auf diesem Host. LocalAI wählt das Backend und schreibt die Konfiguration.", + "quantize": "Machen Sie ein Modell kleiner, damit es in weniger Speicher passt. Wandelt ein Hugging-Face-Modell in GGUF mit der Genauigkeit um, die Sie wählen.", + "fine-tune": "Bringen Sie einem Modell mit einem Adapter Ihre eigenen Daten bei und exportieren Sie es als Modell, mit dem Sie chatten können." + }, + "line": { + "import": { + "noSetup": "Keine Einrichtung nötig. Das Backend wird bei Bedarf heruntergeladen.", + "disk": "{{free}} frei auf dem Modell-Speicher" + }, + "quantize": { + "backend": "Backend {{name}} installiert", + "backendMissing": "Das Quantisierungs-Backend ist nicht installiert", + "backendUnknown": "Der Backend-Status konnte nicht gelesen werden", + "cpu": "Läuft auf der CPU, keine GPU nötig" + }, + "fineTune": { + "backend": "Backend {{name}} installiert", + "backendMissing": "Kein Backend für Feinabstimmung ist installiert", + "backendUnknown": "Der Backend-Status konnte nicht gelesen werden", + "gpu": "GPU-Speicher: {{name}}, {{free}} von {{total}} frei", + "noGpu": "Keine GPU gefunden. Das Training läuft auf der CPU und ist langsam.", + "cluster": "Speicher: {{total}} auf dem besten Knoten" + } + }, + "enable": { + "admin": "Installieren Sie das Backend unter Backends, dann ist dieses Werkzeug bereit.", + "user": "Bitten Sie einen Administrator, das Backend zu installieren." + } + }, + "checks": { + "heading": "Vor dem Start prüfen", + "live": "Wird aktualisiert, während Sie die Einrichtung ändern.", + "meterLabel": "{{used}} Prozent belegt, {{free}} von {{total}} frei", + "openBackends": "Backends öffnen", + "barOk": "Nichts blockiert den Start.", + "barWarn_one": "{{count}} Warnung. Sie können trotzdem starten.", + "barWarn_other": "{{count}} Warnungen. Sie können trotzdem starten.", + "title": { + "model": "Modell", + "data": "Datensatz", + "backend": "Backend", + "memory": "Speicher", + "disk": "Festplatte", + "reward": "Belohnungsfunktionen", + "resume": "Fortsetzen", + "access": "Modellzugriff", + "type": "Quantisierungstyp", + "source": "Quelle", + "name": "Name" + }, + "fineTune": { + "modelOk": "Training von {{model}}.", + "modelMissing": "Geben Sie das Modell ein, das trainiert werden soll.", + "dataOk": "Die Daten kommen von {{source}}.", + "dataMissing": "Fügen Sie einen Datensatz hinzu: eine Hugging-Face-ID oder eine hochgeladene Datei.", + "backendOk": "{{name}} ist installiert.", + "backendMissing": "Kein Backend für Feinabstimmung ist installiert, der Job kann daher nicht starten.", + "backendOther": "{{name}} gehört nicht zu den installierten Backends für Feinabstimmung ({{list}}).", + "backendUnknown": "Die installierten Backends konnten nicht gelesen werden.", + "memoryGpu": "{{name}}: {{free}} von {{total}} frei. Batch-Größe {{batch}}, Sequenzlänge {{seq}}, Gradientenakkumulation {{accum}}. Wie viel der Job braucht, hängt vom Modell und diesen Einstellungen ab. LocalAI schätzt es vor dem Start des Jobs nicht.", + "memoryCluster": "{{total}} auf dem besten Knoten ({{node}}). Batch-Größe {{batch}}, Sequenzlänge {{seq}}. Wie viel der Job braucht, hängt vom Modell und diesen Einstellungen ab.", + "memoryCpu": "Keine GPU gefunden. Das Training läuft auf der CPU, und {{free}} von {{total}} RAM sind frei. Rechnen Sie mit Langsamkeit. Batch-Größe {{batch}}, Sequenzlänge {{seq}}.", + "memoryUnknown": "Dieses Konto kann den Speicher des Rechners nicht lesen. Batch-Größe {{batch}}, Sequenzlänge {{seq}}. Wie viel der Job braucht, hängt vom Modell und diesen Einstellungen ab.", + "disk": "{{free}} frei auf dem Modell-Speicher. Downloads und Checkpoints brauchen Platz.", + "diskLow": "Nur {{free}} frei auf dem Modell-Speicher. Downloads und Checkpoints brauchen Platz.", + "rewardMissing": "GRPO braucht mindestens eine Belohnungsfunktion. Fügen Sie unter Weitere Optionen eine hinzu.", + "resume": "Das Training wird ab {{path}} fortgesetzt.", + "accessNone": "Kein Token gesetzt. Ein geschütztes Modell braucht ein Hugging-Face-Token unter Weitere Optionen.", + "accessSet": "Ein Hugging-Face-Token ist gesetzt." + }, + "quantize": { + "modelOk": "Quantisierung von {{model}}.", + "modelMissing": "Geben Sie das Modell ein, das quantisiert werden soll.", + "typeMissing": "Geben Sie den eigenen Quantisierungstyp ein.", + "backendOk": "{{name}} ist installiert.", + "backendMissing": "Kein Quantisierungs-Backend ist installiert, der Job kann daher nicht starten.", + "backendOther": "{{name}} gehört nicht zu den installierten Quantisierungs-Backends ({{list}}).", + "backendUnknown": "Die installierten Backends konnten nicht gelesen werden.", + "memory": "{{free}} von {{total}} RAM sind frei. Die Umwandlung läuft auf der CPU.", + "disk": "{{free}} frei auf dem Modell-Speicher. Download, umgewandelte Datei und Ergebnis werden auf die Festplatte geschrieben.", + "diskLow": "Nur {{free}} frei auf dem Modell-Speicher. Download, umgewandelte Datei und Ergebnis werden auf die Festplatte geschrieben.", + "accessNone": "Kein Token gesetzt. Ein geschütztes Modell braucht ein Hugging-Face-Token unter Weitere Optionen.", + "accessSet": "Ein Hugging-Face-Token ist gesetzt." + }, + "import": { + "sourceOk_huggingface": "Erkannt als Hugging-Face-Repository. LocalAI prüft seine Dateien beim Import.", + "sourceOk_url": "URL-Format erkannt. Der Inhalt wurde nicht geprüft.", + "sourceOk_config": "Erkannt als Konfigurationsdatei eines Modells. Sie wird so gelesen und verwendet, wie sie ist.", + "sourceOk_oci": "Erkannt als OCI-Image-Referenz.", + "sourceOk_ollama": "Erkannt als Ollama-Modellreferenz.", + "sourceOk_local": "Erkannt als Pfad auf dem Rechner, auf dem LocalAI läuft.", + "sourceOk": "Die Quelle ist erkannt.", + "sourceUnknown": "Das sieht nach keinem Format aus, das LocalAI kennt. Es wird trotzdem versucht und kann fehlschlagen.", + "backendDownload": "{{name}} ist noch nicht installiert. LocalAI lädt es zuerst herunter.", + "backendChosen": "{{name}} ist ausgewählt und wird installiert.", + "backendAuto": "LocalAI wählt das Backend anhand der Dateien der Quelle, sobald der Import beginnt.", + "nameTaken": "Ein Modell namens {{name}} ist bereits installiert.", + "nameFree": "{{name}} ist nicht in Verwendung.", + "nameAuto": "Das Modell erhält den Namen der Quelle.", + "disk": "{{free}} frei auf dem Modell-Speicher. Die Download-Größe ist bekannt, sobald der Import beginnt.", + "memory_gpu": "{{free}} GPU-Speicher sind jetzt frei. Was dieses Modell braucht, ist bekannt, sobald der Import beginnt.", + "memory_ram": "{{free}} RAM sind jetzt frei. Was dieses Modell braucht, ist bekannt, sobald der Import beginnt.", + "memory_cluster": "{{free}} sind auf dem besten Knoten verfügbar. Was dieses Modell braucht, ist bekannt, sobald der Import beginnt.", + "memory": "{{free}} Speicher sind jetzt frei. Was dieses Modell braucht, ist bekannt, sobald der Import beginnt." + } + }, + "form": { + "key": "Schlüssel", + "value": "Wert", + "addOption": "Option hinzufügen", + "copy": "In die Zwischenablage kopieren", + "more": "Weitere Optionen", + "moreHint": "Methode, Backend, Adapter, Optimierer, Auswertung, Belohnungsfunktionen", + "baseModel": "Basismodell", + "model": "Modell", + "modelHint": "Eine Hugging-Face-ID oder ein Pfad auf diesem Host.", + "data": "Ihre Daten", + "dataset": "Quelle des Datensatzes", + "datasetHint": "Eine Hugging-Face-Datensatz-ID, oder laden Sie eine Datei hoch.", + "split": "Aufteilung", + "upload": "Stattdessen eine Datei hochladen", + "uploadHint": ".json, .jsonl oder .csv", + "removeFile": "Datei entfernen", + "train": "Was trainiert werden soll", + "kind": "Art des Trainings", + "kinds": { + "adapter": { + "title": "Adapter", + "text": "Trainiert einen kleinen Adapter und lässt die Basisgewichte unverändert. Die übliche Wahl, und sie braucht wenig Speicher." + }, + "full": { + "title": "Vollständiges Modell", + "text": "Ändert jedes Gewicht. Es braucht ein Vielfaches des Speichers eines Adapters." + } + }, + "epochs": "Epochen", + "batch": "Batch-Größe", + "lr": "Lernrate", + "backend": "Backend", + "method": "Trainingsmethode", + "adapterKind": "Adaptertyp", + "rank": "Rang", + "alpha": "Alpha", + "dropout": "Dropout", + "gradAccum": "Schritte der Gradientenakkumulation", + "warmup": "Warmup-Schritte", + "seqLen": "Maximale Sequenzlänge", + "optimizer": "Optimierer", + "maxSteps": "Maximale Schritte", + "maxStepsHint": "0 für automatisch.", + "saveSteps": "Speicherschritte", + "saveLimit": "Gesamtlimit für Speicherungen", + "saveLimitHint": "0 behält jeden Checkpoint.", + "weightDecay": "Gewichtsabnahme", + "seed": "Seed", + "seedHint": "0 wählt einen zufälligen Seed.", + "precision": "Gemischte Genauigkeit", + "auto": "Automatisch", + "targetModules": "Zielmodule", + "targetModulesHint": "Durch Kommas getrennt. Leer lassen für den Standard des Backends.", + "gradCheckpointing": "Gradient Checkpointing (langsamer, braucht weniger Speicher)", + "token": "Hugging-Face-Token", + "tokenHint": "Nur für geschützte Modelle nötig.", + "evalEnable": "Während des Trainings auswerten", + "evalStrategy": "Auswertungsstrategie", + "evalSteps_": "Schritte", + "evalEpoch": "Epoche", + "evalSteps": "Auswertungsschritte", + "evalStepsHint": "0 entspricht den Speicherschritten.", + "evalSplit": "Aufteilung für die Auswertung", + "evalDataset": "Datensatz für die Auswertung", + "evalDatasetPlaceholder": "Ein eigener Datensatz", + "evalRatio": "Verhältnis der automatischen Aufteilung", + "rewards": "Belohnungsfunktionen (GRPO)", + "rewardsHint": "GRPO braucht mindestens eine Belohnungsfunktion. Wählen Sie eingebaute aus oder fügen Sie eigene hinzu.", + "resume": "Vom Checkpoint fortsetzen", + "clearCheckpoint": "Checkpoint löschen", + "extra": "Zusätzliche Optionen", + "extraHint": "Schlüssel-Wert-Paare, die vom Backend abhängen.", + "summaryFull": "vollständiges Modell", + "summary_lora": "LoRA-Adapter", + "summary_loha": "LoHa-Adapter", + "summary_lokr": "LoKr-Adapter", + "recipe": "Trainiert einen {{kind}} von {{model}} mit {{method}}, {{epochs}} Epochen über {{data}}.", + "recipeEmpty": "Wählen Sie ein Modell und einen Datensatz, um zu sehen, was trainiert wird." + }, + "rewards": { + "format_reward": "Prüft das Format ... gefolgt von der Antwort.", + "reasoning_accuracy_reward": "Vergleicht den Inhalt von mit der Antwortspalte des Datensatzes.", + "length_reward": "Bewertet, wie nah die Antwort an einer Ziellänge liegt.", + "xml_tag_reward": "Bewertet korrekt geöffnete und geschlossene XML-Tags.", + "no_repetition_reward": "Bestraft wiederholte n-Gramme.", + "code_execution_reward": "Prüft, ob Python-Codeblöcke gültige Syntax haben.", + "param_target_length": "Ziellänge", + "fnName": "Funktionsname", + "fnBody": "Funktionskörper", + "fnHint": "Erhält completions und kwargs und muss eine Liste von Gleitkommazahlen zurückgeben. re, math, json und string sind verfügbar.", + "add": "Hinzufügen", + "cancel": "Abbrechen", + "custom": "Eigene Belohnungsfunktion hinzufügen" + }, + "fineTune": { + "title": "Feinabstimmung", + "lede": "Trainieren Sie einen Adapter mit Ihren eigenen Daten und exportieren Sie ihn als Modell, mit dem Sie chatten können.", + "importConfig": "Konfiguration importieren", + "exportConfig": "Konfiguration exportieren", + "newJob": "Neuer Job", + "badConfig": "Diese Datei konnte nicht gelesen werden. Verwenden Sie eine JSON-Konfiguration, die auf dieser Seite exportiert wurde.", + "start": "Feinabstimmung starten", + "starting": "Wird gestartet...", + "resume": "Training fortsetzen", + "resuming": "Wird vom Checkpoint fortgesetzt:", + "clear": "Leeren", + "barNote": "Nichts wird geändert, bis Sie starten.", + "jobMeta": "{{kind}} {{method}} auf {{backend}}, gestartet {{started}}", + "failed": "Training fehlgeschlagen", + "failedAt": "Training bei Schritt {{step}} von {{total}} gestoppt", + "noMessage": "Das Backend hat keine Meldung ausgegeben.", + "finished": "Training abgeschlossen.", + "finishedLoss": "Training abgeschlossen. Der letzte Loss war {{loss}}.", + "stopped": "Der Job wurde gestoppt. Ein Checkpoint bleibt erhalten, wenn Sie das gewählt haben.", + "tryTitle": "Was als Nächstes zu tun ist", + "tryText": "Der Server meldet, dass der Speicher nicht mehr reicht. Jede Änderung unten wird auf eine Kopie der Einrichtung angewendet, und nichts startet, bis Sie Start drücken.", + "tryPlain": "Öffnen Sie die Einrichtung erneut, ändern Sie, worauf die Meldung hinweist, und starten Sie einen neuen Job.", + "fixBatch": "Batch-Größe auf 1 senken", + "fixCheckpointing": "Gradient Checkpointing einschalten (langsamer, braucht weniger Speicher)", + "retryChanges": "Einrichtung mit diesen Änderungen öffnen", + "reuse": "Einrichtung erneut öffnen", + "resumeFrom": "Ab Schritt {{step}} fortsetzen", + "status": { + "queued": "In der Warteschlange", + "loading_model": "Modell wird geladen", + "loading_dataset": "Datensatz wird geladen", + "training": "Training", + "saving": "Speichern", + "completed": "Abgeschlossen", + "failed": "Fehlgeschlagen", + "stopped": "Gestoppt" + } + }, + "stop": { + "title": "Training stoppen?", + "text": "Ein jetzt gespeicherter Checkpoint erlaubt es, das bisher Gelernte fortzusetzen oder zu exportieren.", + "keep": "Stoppen und einen Checkpoint behalten", + "discard": "Ohne Checkpoint stoppen", + "cancel": "Training fortsetzen", + "cancelDelete": "Abbrechen" + }, + "chart": { + "loss": "Loss", + "learning_rate": "Lernrate", + "grad_norm": "Gradientennorm", + "metric": "Metrik", + "step": "Schritt", + "eval": "Auswertungs-Loss", + "waiting": "Warten auf weitere Schritte.", + "window": "Schritte {{from}} bis {{to}} von {{total}}", + "windowEval": "Schritte {{from}} bis {{to}} von {{total}}. Die hohlen Punkte sind der Auswertungs-Loss.", + "aria": "{{metric}} über die Trainingsschritte. Der letzte Wert ist {{last}} bei Schritt {{step}}.", + "readout": "Schritt {{step}}: {{value}}", + "dataTitle": "Die Zahlen anzeigen", + "emptyText": "Das Diagramm wird gezeichnet, sobald der Job zwei Schritte gemeldet hat." + }, + "log": { + "title": "Protokoll", + "note": "Was der Job seit dem Öffnen dieser Seite gemeldet hat.", + "only": "Nur Warnungen und Fehler", + "copy": "Kopieren", + "copied": "Kopiert", + "download": "Herunterladen", + "empty": "Noch nichts gemeldet.", + "noneFiltered": "Keine Warnungen oder Fehler." + }, + "checkpoints": { + "title": "Checkpoints", + "step": "Schritt", + "epoch": "Epoche", + "loss": "Loss", + "path": "Pfad", + "actions": "Aktionen", + "resume": "Fortsetzen", + "resumeTitle": "Training ab diesem Checkpoint fortsetzen", + "export": "Exportieren", + "exportTitle": "Diesen Checkpoint exportieren" + }, + "export": { + "title": "Als Modell exportieren", + "lede": "Beim Exportieren wird das Ergebnis in ein Modell umgewandelt, das LocalAI laden kann.", + "checkpoint": "Checkpoint", + "final": "Endmodell (Ausgabeverzeichnis)", + "checkpointOption": "Schritt {{step}} (Loss {{loss}})", + "format": "Exportformat", + "lora": "LoRA-Adapter", + "merged16": "Zusammengeführt (16 Bit)", + "merged4": "Zusammengeführt (4 Bit)", + "quant": "Quantisierung", + "name": "Modellname", + "nameHint": "Leer lassen, um einen Namen aus dem Job zu erzeugen.", + "go": "Exportieren", + "exporting": "Wird exportiert...", + "working": "Export läuft...", + "done": "Modell exportiert und als \"{{name}}\" registriert", + "failed": "Export fehlgeschlagen: {{message}}", + "unknown": "unbekannter Fehler", + "nextTitle": "{{name}} ist jetzt ein Modell", + "nextText": "Es ist bei LocalAI registriert. Öffnen Sie einen Chat damit oder finden Sie es unter Modelle.", + "chat": "Mit {{name}} chatten", + "models": "Unter Modelle öffnen", + "archive": "Archiv herunterladen" + }, + "jobs": { + "title": "Frühere Jobs", + "count_one": "{{count}} auf diesem Server gespeichert", + "count_other": "{{count}} auf diesem Server gespeichert", + "loading": "Jobs werden geladen", + "emptyTitle": "Noch keine Feinabstimmungs-Jobs", + "emptyText": "Richten Sie oben einen ein. Er erscheint hier mit seinem Status, und Sie können seine Einrichtung später wiederverwenden.", + "model": "Modell", + "kind": "Art", + "status": "Status", + "started": "Gestartet", + "actions": "Aktionen", + "open": "Diesen Job öffnen", + "reuse": "Wiederverwenden", + "reuseTitle": "Die Einrichtung dieses Jobs für einen neuen Job verwenden", + "delete": "Löschen", + "deleteTitle": "Diesen Job und seine Daten löschen", + "deleteDialogTitle": "Diesen Job löschen?", + "deleteDialogText": "Dies löscht den Job mit seinen Checkpoints und einem exportierten Modell. Das kann nicht rückgängig gemacht werden." + }, + "quantize": { + "title": "Quantisieren", + "lede": "Machen Sie ein Modell kleiner, damit es in weniger Speicher passt. Das Ergebnis ist eine GGUF-Datei, die Sie importieren können.", + "newJob": "Neuer Job", + "sectionModel": "Modell", + "model": "Modell", + "modelHint": "Eine Hugging-Face-ID oder ein Pfad auf diesem Host.", + "sectionPrecision": "Genauigkeit", + "type": "Quantisierungstyp", + "typeHint": "Eine kleinere Zahl bedeutet eine kleinere Datei und etwas weniger Qualität.", + "custom": "Eigener...", + "customLabel": "Eigener Typ", + "customPlaceholder": "zum Beispiel iq4_nl", + "moreHint": "Backend, Hugging-Face-Token", + "tokenHint": "Nur für geschützte Modelle nötig.", + "recipe": "{{model}} in GGUF mit {{type}} umwandeln.", + "recipeEmpty": "Geben Sie ein Modell ein, um zu sehen, was erstellt wird.", + "start": "Quantisieren ({{type}})", + "barNote": "Nichts wird geändert, bis Sie starten.", + "jobMeta": "{{type}} auf {{backend}}, gestartet {{started}}", + "failed": "Quantisierung fehlgeschlagen", + "finished": "Quantisierung abgeschlossen: {{type}}.", + "stopped": "Der Job wurde gestoppt.", + "tryText": "Öffnen Sie die Einrichtung erneut, ändern Sie, worauf die Meldung hinweist, und starten Sie einen neuen Job. Ein neueres Backend unterstützt vielleicht eine Architektur, die ein älteres nicht unterstützt.", + "outputTitle": "Das Ergebnis", + "outputText": "Eine GGUF-Datei mit {{type}}. Importieren Sie sie, um sie in LocalAI zu nutzen, oder laden Sie sie herunter.", + "file": "Datei", + "importName": "Modellname", + "importNamePlaceholder": "Wird erzeugt, wenn leer", + "import": "In LocalAI importieren", + "importing": "Wird importiert...", + "importFailed": "Import fehlgeschlagen", + "importFailedWith": "Import fehlgeschlagen: {{message}}", + "download": "GGUF herunterladen", + "nextTitle": "{{name}} ist jetzt ein Modell", + "nextText": "Es ist bei LocalAI registriert. Öffnen Sie einen Chat damit oder finden Sie es unter Modelle.", + "chat": "Mit {{name}} chatten", + "models": "Unter Modelle öffnen", + "imported": "importiert als {{name}}", + "emptyTitle": "Noch keine Quantisierungs-Jobs", + "emptyText": "Richten Sie oben einen ein. Er erscheint hier mit seinem Status.", + "deleteDialogText": "Dies löscht den Job mit seinen umgewandelten Dateien. Das kann nicht rückgängig gemacht werden.", + "startFailed": "Job konnte nicht gestartet werden", + "stopFailed": "Job konnte nicht gestoppt werden", + "deleteFailed": "Job konnte nicht gelöscht werden", + "status": { + "queued": "In der Warteschlange", + "downloading": "Wird heruntergeladen", + "converting": "Wird umgewandelt", + "quantizing": "Wird quantisiert", + "completed": "Abgeschlossen", + "failed": "Fehlgeschlagen", + "stopped": "Gestoppt" + } + }, + "off": { + "badge": "Für Sie nicht verfügbar", + "fineTune": { + "title": "Ihr Konto kann keine Feinabstimmung", + "text": "Feinabstimmung ist eine eigene Berechtigung, und dieses Konto hat sie nicht." + }, + "quantize": { + "title": "Ihr Konto kann nicht quantisieren", + "text": "Quantisierung ist eine eigene Berechtigung, und dieses Konto hat sie nicht." + }, + "admin": "Ein Administrator kann sie unter Benutzer und Schlüssel aktivieren.", + "askAdmin": "Bitten Sie einen Administrator, sie für Ihr Konto zu aktivieren.", + "back": "Zur Startseite" + } +} diff --git a/core/http/react-ui/public/locales/de/traffic.json b/core/http/react-ui/public/locales/de/traffic.json new file mode 100644 index 000000000..41dbb9691 --- /dev/null +++ b/core/http/react-ui/public/locales/de/traffic.json @@ -0,0 +1,12 @@ +{ + "overview": { + "usageErrorTitle": "Die Nutzung konnte nicht gelesen werden." + }, + "host": { + "errorTitle": "Der Host konnte nicht gelesen werden." + }, + "models": { + "noMatch": "Kein Modell passt zu „{{query}}“." + }, + "clearSearch": "Suche löschen" +} diff --git a/core/http/react-ui/public/locales/de/upgrade.json b/core/http/react-ui/public/locales/de/upgrade.json new file mode 100644 index 000000000..afe0c8c47 --- /dev/null +++ b/core/http/react-ui/public/locales/de/upgrade.json @@ -0,0 +1,46 @@ +{ + "badge": { + "available": "Update verfügbar", + "availableVersion": "Update verfügbar: {{version}}" + }, + "trigger": { + "all": "Alle aktualisieren ({{count}})", + "running": "Aktualisiere {{current}} von {{total}}", + "summary": "{{done}} aktualisiert, {{failed}} fehlgeschlagen, {{skipped}} übersprungen", + "summaryOk": "{{done}} aktualisiert", + "review": "Ansehen" + }, + "dialog": { + "title": "Alle aktualisieren", + "intro": "Wählen Sie aus, was aktualisiert werden soll. Jedes Element wird einzeln nacheinander aktualisiert. Ein Fehler stoppt die anderen nicht.", + "selection": "Auswahl", + "selectAll": "Alle auswählen", + "selectNone": "Keine auswählen", + "selectedCount_one": "{{count}} ausgewählt", + "selectedCount_other": "{{count}} ausgewählt", + "item": "{{name}} aktualisieren", + "versions": "{{from}} auf {{to}}", + "blocked": "Kann nicht starten", + "start": "Auswahl aktualisieren", + "starting": "Aktualisiere...", + "retry": "Fehlgeschlagene wiederholen ({{count}})", + "again": "Neue Auswahl", + "close": "Schließen", + "closeRunning": "Schließen (läuft weiter)", + "runningNote": "Das Schließen dieses Fensters stoppt die Updates nicht.", + "progress": "Aktualisiere {{current}} von {{total}}", + "summary": "{{done}} aktualisiert, {{failed}} fehlgeschlagen, {{skipped}} übersprungen", + "empty": "Nichts zu aktualisieren." + }, + "item": { + "queued": "Wartend", + "upgrading": "Wird aktualisiert", + "done": "Fertig", + "failed": "Fehlgeschlagen", + "skipped": "Übersprungen" + }, + "reason": { + "checkAgain": "Erneut prüfen", + "checking": "Prüfe..." + } +} diff --git a/core/http/react-ui/public/locales/de/workspace.json b/core/http/react-ui/public/locales/de/workspace.json new file mode 100644 index 000000000..6c16d018a --- /dev/null +++ b/core/http/react-ui/public/locales/de/workspace.json @@ -0,0 +1,671 @@ +{ + "wellKnown": { + "revert": "Zur vorherigen Version zurückkehren", + "view": "Ansicht", + "form": "Formular", + "save": "Speichern", + "cancel": "Abbrechen", + "notSet": "Nicht gesetzt", + "moveUp": "Nach oben", + "moveDown": "Nach unten", + "remove": "Entfernen", + "savedAt": "Gespeichert um {{time}}", + "notSaved": "Noch nicht gespeichert.", + "notSavedPrefilled": "Noch nicht gespeichert. Einige Werte sind schon ausgefüllt; Speichern behält sie.", + "loadFailed": "Diese Einstellungen konnten nicht geladen werden. Lade die Seite neu, um es noch einmal zu versuchen.", + "invalid": "Diese Einstellungen enthalten Fehler, und es gibt keine frühere gültige Version.", + "invalidFallback": "Diese Einstellungen enthalten Fehler. Hier siehst du ihre letzte gültige Version.", + "conflict": "Diese Einstellungen wurden geändert, während du sie bearbeitet hast, wahrscheinlich vom Assistenten oder in einem anderen Tab. Die neueren Werte sind:", + "useTheirs": "Neuere Werte verwenden", + "keepMine": "Meine Änderungen behalten", + "editWithAssistant": "Mit dem Assistenten bearbeiten", + "from": { + "account": "Aus deinem Konto", + "browser": "Aus deinem Browser" + }, + "check": { + "required": "Pflichtfeld.", + "tooLong": "Höchstens {{max}} Zeichen.", + "format": "Dieser Wert hat das falsche Format." + }, + "source": { + "label": "Quelle", + "hint": "Die Einstellungen als Text. Beim Speichern werden sie genauso geprüft.", + "notJson": "Der Text hat einen Syntaxfehler. Prüfe Anführungszeichen, Kommas und Klammern.", + "notObject": "Der Text muss mit { beginnen und mit } enden." + }, + "persona": { + "title": "Assistent", + "lede": "Name und Anweisungen, die der Assistent in jedem Chat verwendet." + }, + "user": { + "title": "Du", + "lede": "Der Assistent liest dies, um zu wissen, mit wem er spricht. Es gelangt in jedem Chat zum Modell." + }, + "ui": { + "title": "Darstellung", + "lede": "So sieht LocalAI für dich aus. Änderungen werden sofort angezeigt; Speichern behält sie." + }, + "shortcuts": { + "title": "Kurzbefehle", + "lede": "Die Vorschläge über dem Eingabefeld jedes Chats.", + "edit": "Kurzbefehle bearbeiten", + "defaults": "Standard-Kurzbefehle", + "none": "Keine Kurzbefehle. Füge einen hinzu oder speichere eine leere Liste, um sie auszublenden.", + "label": "Bezeichnung", + "prompt": "Prompt", + "send": "Sofort senden", + "add": "Kurzbefehl hinzufügen", + "invalid": "Einige Kurzbefehle enthalten Fehler.", + "assistantPrompt": "Ändere meine Kurzbefehle: " + }, + "home": { + "editLayout": "Layout bearbeiten", + "ask": "Den Assistenten fragen", + "askPrompt": "Ändere meine Home-Seite: ", + "add": "Widget hinzufügen", + "newTitle": "Neues Widget", + "noWidgets": "Noch keine Widgets.", + "type": "Typ", + "title": "Titel", + "source": "Datendatei", + "noSourceNeeded": "Keine Datendatei nötig", + "types": { + "list": "Liste", + "metric": "Kennzahl", + "chats": "Letzte Chats", + "page": "Seite" + }, + "left": "{{count}} übrig", + "fromChats": "aus Chats", + "noChats": "Noch keine Chats.", + "noData": "Noch keine Daten. Der Assistent kann schreiben", + "nothing": "Hier ist nichts.", + "noSource": "Dieses Widget hat keine Datendatei. Wähle eine unter Layout bearbeiten.", + "broken": "Die Daten dieses Widgets enthalten Fehler, und es gibt keine frühere gültige Version.", + "stale": "Die letzten gültigen Daten werden angezeigt. Die neuesten Daten enthalten Fehler.", + "updated": "Aktualisiert {{when}}", + "footer": { + "noData": "Noch keine Daten.", + "invalid": "Die Daten enthalten einen Fehler. Die letzte gute Version wird angezeigt.", + "revert": "Zurücksetzen", + "missing": "Die Daten dieses Widgets fehlen.", + "fix": "Den Assistenten um eine Korrektur bitten", + "fixPrompt": "Das Widget der Startseite liest {{file}}, die fehlt oder fehlerhaft ist. Korrigiere sie: ", + "waiting": "Wartet darauf, dass du seinen Trigger im Chat erstellst.", + "openChat": "Chat öffnen", + "running": "Wird gerade aktualisiert…", + "openRun": "Lauf öffnen", + "paused": "Pausiert · war {{schedule}}", + "resume": "Fortsetzen", + "failedEmpty": "Die Aktualisierung ist fehlgeschlagen und es gibt noch keine Daten.", + "details": "Details", + "retry": "Erneut versuchen", + "stale": "Letzte Aktualisierung fehlgeschlagen. Daten von {{when}} werden angezeigt.", + "staleToday": "Letzte Aktualisierung fehlgeschlagen. Frühere Daten von heute werden angezeigt.", + "earlier": "früher", + "firstRun": "Erster Lauf {{when}}", + "firstRunFile": "Läuft, wenn eine Datei ankommt", + "runNow": "Jetzt ausführen", + "fresh": "{{schedule}} · aktualisiert {{time}}", + "pause": "Pausieren", + "fromPage": "Von der Seite {{title}}", + "fromFile": "Aus deinen Dateien", + "next": "Nächster Lauf {{when}}", + "cost": "etwa {{tokens}} Tokens pro Lauf", + "costRemote": "etwa {{tokens}} Tokens pro Lauf auf einem entfernten Modell", + "cadence": { + "paused": "Pausiert", + "hourly": "Stündlich", + "daily": "Täglich", + "weekdays": "Werktags", + "weekly": "Wöchentlich", + "monthly": "Monatlich", + "file": "Bei neuen Dateien" + }, + "scheduleNote_one": "{{count}} Widget wird nach Zeitplan aktualisiert. Seine Läufe nutzen deine Modelle und zählen zu deiner Nutzung.", + "scheduleNote_other": "{{count}} Widgets werden nach Zeitplan aktualisiert. Ihre Läufe nutzen deine Modelle und zählen zu deiner Nutzung." + }, + "opensSite": "{{host}}: öffnet in einem neuen Tab", + "linkAsk": "Dieses Widget möchte einen Link öffnen", + "invalid": "Dein Home-Layout enthält Fehler, und es gibt keine frühere gültige Version.", + "invalidFallback": "Dein Home-Layout enthält Fehler. Home zeigt seine letzte gültige Version.", + "page": { + "gone": "Diese Seite ist nicht mehr verfügbar", + "goneHint": "Die Seite \"{{name}}\" wurde verschoben oder gelöscht, oder du kannst sie nicht mehr öffnen.", + "noWidget": "Diese Seite hat keine Widget-Ansicht", + "noWidgetHint": "Bitte den Assistenten, der Seite „{{name}}“ eine kompakte Ansicht für die Startseite zu geben.", + "off": "Geteilte Seiten sind ausgeschaltet", + "offHint": "Ein Administrator hat geteilte Seiten auf diesem Server ausgeschaltet.", + "fix": "Startseite anpassen", + "failed": "Die Seite wurde nicht geladen", + "retry": "Erneut versuchen", + "changed": "Diese Seite hat sich geändert, seit du sie erlaubt hast", + "notAllowed": "Du hast diese Seite noch nicht erlaubt", + "allowHint": "Sie stammt aus einem geteilten Ordner. Prüfe sie, bevor sie in deinem Namen läuft.", + "allow": "Diese Seite erlauben", + "frameTitle": "{{title}} (Seite auf der Startseite)", + "refresh": "Aktualisieren", + "open": "Seite öffnen", + "add": "Seite hinzufügen", + "limit": "Die Startseite zeigt höchstens {{max}} Seiten; entferne eine, um eine weitere hinzuzufügen.", + "noneEligible": "Noch keine Seite hat eine kompakte Ansicht für die Startseite. Bitte den Assistenten, einer Seite eine hinzuzufügen.", + "pick": "Seite", + "unavailable": "{{name}} (nicht verfügbar)", + "sharedLabel": "geteilt", + "sizes": { + "small": "Klein", + "medium": "Mittel", + "large": "Groß" + } + } + }, + "fields": { + "persona": { + "name": { + "label": "Name", + "help": "Wie der Assistent sich selbst nennt." + }, + "system_prompt": { + "label": "Systemprompt", + "help": "Wird jedem Chat hinzugefügt, nach dem Prompt des Chats und den Anweisungen des Agenten." + } + }, + "user": { + "name": { + "label": "Name", + "help": "Wie der Assistent dich anspricht." + }, + "language": { + "label": "Sprache", + "help": "Die Sprache, in der der Assistent antwortet." + }, + "bio": { + "label": "Über dich", + "help": "Ein paar Zeilen, die der Assistent in jedem Chat liest." + }, + "timezone": { + "label": "Zeitzone", + "help": "Für Datums- und Zeitangaben in Antworten." + } + }, + "ui": { + "theme": { + "label": "Design", + "help": "System folgt der Einstellung deines Geräts.", + "options": { + "dark": "Dunkel", + "light": "Hell", + "system": "System" + } + }, + "accent": { + "label": "Akzentfarbe", + "help": "Für Schaltflächen und Links. Leer lässt den Standard." + }, + "density": { + "label": "Dichte", + "help": "Kompakt zeigt auf einem Bildschirm mehr an.", + "options": { + "comfortable": "Komfortabel", + "compact": "Kompakt" + } + }, + "defaultModel": { + "label": "Standardmodell", + "help": "Das Modell, mit dem ein neuer Chat startet." + } + } + }, + "saveInvalid": "Einige Werte sind ungültig. Prüfe sie und speichere erneut.", + "saveTooLarge": "Diese Einstellungen sind zu groß zum Speichern. Entferne einige Einträge und versuche es erneut.", + "saveFailed": "Speichern fehlgeschlagen. Versuche es erneut.", + "details": "Details" + }, + "discovery": { + "ex": { + "title": "Mach LocalAI zu deinem", + "lede": "Sag es im Chat. Der Assistent speichert es, und du kannst es ändern oder rückgängig machen.", + "hide": "Ausblenden", + "hiddenToast": "Beispiele ausgeblendet.", + "saveFailed": "Nicht gespeichert: {{message}}", + "card1": { + "kicker": "Du und der Assistent", + "prompt": "Nenn mich {{firstName}} und antworte kurz", + "promptNoName": "Merk dir meinen Namen und antworte kurz", + "what": "Speichert deinen Namen und wie der Assistent antwortet" + }, + "card2": { + "kicker": "Eine Seite", + "prompt": "Erstelle mir ein Aufgabenboard", + "what": "Fügt eine Seite hinzu, die du in der Seitenleiste öffnest" + }, + "card3": { + "kicker": "Jeden Morgen", + "prompt": "Fasse hier jeden Morgen meinen Posteingang zusammen", + "promptNoMail": "Liste hier jeden Morgen die Chats von gestern auf", + "what": "Fügt ein Home-Widget und einen Trigger hinzu, den du bestätigst" + }, + "quote": "„{{text}}“", + "donePrefix": "Fertig.", + "doneLink": "In Account ändern", + "ready": "Oder starte mit einer fertigen Seite:", + "readyTasks": "Aufgabenboard", + "readyReading": "Leseliste", + "readyFailed": "Die Seite wurde nicht kopiert: {{message}}", + "learn": "So funktioniert das", + "inComposer": "Bearbeite den Text nach Wunsch und drücke dann Enter. Nichts wird gespeichert, bis du sendest.", + "live": "Prompt zum Nachrichtenfeld hinzugefügt." + }, + "home": { + "customise": "Home anpassen" + }, + "action": { + "undo": "Rückgängig", + "openAccount": "In Account öffnen", + "openPage": "Seite öffnen", + "openHome": "Home öffnen", + "turnBackOn": "In Account wieder einschalten" + }, + "change": { + "profilePersona": "Dein Name und deine Anweisungen für den Assistenten sind gespeichert. Jeder Chat nutzt sie ab deiner nächsten Nachricht.", + "profile": "In deinem Profil gespeichert.", + "persona": "Die Anweisungen des Assistenten sind gespeichert. Jeder Chat nutzt sie ab deiner nächsten Nachricht.", + "shortcuts": "Deine Kurzbefehle sind gespeichert.", + "home": "Deine Home-Seite ist gespeichert. Home zeigt die Änderung.", + "ui": "Deine Darstellungseinstellungen sind gespeichert.", + "pageCreated": "Die Seite {{title}} wurde erstellt.", + "pageChanged": "Die Seite {{title}} wurde geändert.", + "offersOff": "Vorschläge des Assistenten sind aus.", + "undoing": "Wird rückgängig gemacht…", + "undone": "Rückgängig gemacht.", + "undoFailed": "Nicht rückgängig gemacht: {{message}}" + }, + "toast": { + "shortcutAdded": "Zu deinem Eingabefeld hinzugefügt: {{label}}", + "pageCreated": "{{title}} ist unter Pages in der Seitenleiste", + "open": "Öffnen", + "homeChanged": "Home aktualisiert.", + "themeChanged": "Design geändert." + }, + "files": { + "learn": "Diese Dateien ändern, wie LocalAI für dich arbeitet.", + "tag": { + "profile": "Dein Profil", + "persona": "Assistent", + "shortcuts": "Kurzbefehle", + "home": "Home-Layout", + "ui": "Aussehen", + "page": "Seite" + } + }, + "privacy": "Der Assistent erhält zu Beginn jedes Chats deinen Namen, diesen Text, deine Sprache und Zeitzone. Lass ein Feld leer, um es draußen zu lassen.", + "versions": { + "label": "Versionen", + "title": "Frühere Versionen", + "loading": "Verlauf wird gelesen…", + "none": "Noch keine frühere Version.", + "current": "aktuell", + "invalid": "hat einen Fehler", + "byAssistant": "Vom Assistenten", + "byAssistantIn": "Vom Assistenten in „{{chat}}“", + "byYou": "Von dir", + "undo": "Ein Rückgängig", + "upload": "Hochgeladen", + "revert": "Zurücksetzen", + "note": "Beim Zurücksetzen bleibt die neuere Version in dieser Liste.", + "invalidBadge": "Ungültig", + "revertGood": "Auf die letzte gute Version zurücksetzen" + }, + "help": { + "title": "Hilfe und Vorschläge", + "sub": "So zeigt dir LocalAI, was es kann. Schalte aus, was du nicht brauchst.", + "offers": "Angebote des Assistenten", + "offersHint": "Höchstens eines pro Tag, nie während einer Aufgabe: zum Beispiel, um sich etwas zu merken, das du gesagt hast.", + "new": "Hinweise auf Neues", + "newHint": "Ein kurzes „Neu“-Label in der Seitenleiste, wenn etwas zum ersten Mal erscheint.", + "examples": "Beispiele auf Home", + "examplesShown": "Die Karten „Mach LocalAI zu deinem“ erscheinen auf Home, solange du noch etwas ausprobieren kannst.", + "examplesHidden": "Du hast die Karten „Mach LocalAI zu deinem“ ausgeblendet.", + "showExamples": "Beispiele wieder anzeigen", + "examplesBack": "Die Beispiele sind wieder auf Home.", + "dismissed": "Was der Assistent gelernt hat", + "dismissedHint_one": "Du hast {{count}} Angebot abgelehnt. Es bleibt ausgeblendet, bis du es zurücksetzt.", + "dismissedHint_other": "Du hast {{count}} Angebote abgelehnt. Sie bleiben ausgeblendet, bis du sie zurücksetzt.", + "forget": "Vergessen, was der Assistent über meine Hinweise gelernt hat", + "forgotten": "Fertig. Der Assistent darf wieder etwas anbieten.", + "learn": "Lerne, was du ändern kannst:", + "dismissedNone": "Noch nichts. Wenn du auf ein Angebot antwortest, merkt sich der Assistent das hier." + } + }, + "tabs": { + "shared": "Geteilt", + "trashCount_one": "{{count}} Element im Papierkorb", + "trashCount_other": "{{count}} Elemente im Papierkorb" + }, + "trash": { + "empty": "Der Papierkorb ist leer.", + "restore": "Wiederherstellen", + "emptyAll": "Papierkorb leeren", + "purge": "Endgültig löschen", + "purgeNamed": "{{name}} endgültig löschen", + "files_one": "{{count}} Datei", + "files_other": "{{count}} Dateien", + "folders_one": "{{count}} Ordner", + "folders_other": "{{count}} Ordner", + "listJoin": ", ", + "summary": "Im Papierkorb: {{what}}.", + "removed": "Endgültig gelöscht: {{what}}.", + "confirmAll": { + "title": "Papierkorb leeren?", + "message": "Dadurch werden {{what}} samt früherer Versionen endgültig gelöscht. Das lässt sich nicht rückgängig machen." + }, + "confirmOne": { + "title": "Endgültig löschen?", + "message": "{{path}}: Dadurch werden {{what}} samt früherer Versionen endgültig gelöscht. Das lässt sich nicht rückgängig machen." + } + }, + "shared": { + "adminTitle": "Geteilte Ordner", + "adminLede": "Ordner, die mehrere Personen nutzen. Mitglieder öffnen ihre Seiten, und ihre Chats können die Dateien lesen oder bearbeiten.", + "userTitle": "Mit dir geteilt", + "userLede": "Ordner, die ein Administrator mit dir geteilt hat. Ihre Seiten sind unter Seiten, und deine Chats können die Dateien nutzen.", + "new": "Neuer geteilter Ordner", + "nameLabel": "Ordnername", + "nameHint": "Bis zu 64 Buchstaben, Ziffern, Leerzeichen, Punkte, Bindestriche oder Unterstriche. Beginne mit einem Buchstaben oder einer Ziffer.", + "create": "Erstellen", + "cancel": "Abbrechen", + "rename": "Umbenennen", + "renameSave": "Speichern", + "remove": "Entfernen", + "manage": "Mitglieder", + "menu": "Weitere Aktionen für {{name}}", + "pages_one": "{{count}} Seite", + "pages_other": "{{count}} Seiten", + "pagesNone": "Keine Seiten", + "people_one": "{{count}} Person", + "people_other": "{{count}} Personen", + "membersNone": "Keine Mitglieder", + "everyone": "Alle", + "everyoneOption": "Alle (alle Benutzer)", + "everyoneHint": "Alle Benutzer, auch die, die später dazukommen", + "everyoneCan": { + "read": "Alle können lesen", + "write": "Alle können bearbeiten" + }, + "mode": { + "read": "Kann lesen", + "write": "Kann bearbeiten" + }, + "addTitle": "Mitglied hinzufügen", + "person": "Person", + "access": "Zugriff", + "accessFor": "Zugriff für {{name}}", + "add": "Hinzufügen", + "allAdded": "Alle sind bereits Mitglied.", + "usersUnavailable": "Die Benutzerliste konnte nicht geladen werden. Du kannst trotzdem Alle hinzufügen.", + "membersTitle": "Mitglieder", + "noMembers": "Noch niemand kann diesen Ordner nutzen. Füge eine Person oder Alle hinzu.", + "removeMember": "{{name}} entfernen", + "paneNote": "Eine Änderung gilt sofort. Ein offener Chat sieht sie, wenn er das nächste Mal geöffnet wird.", + "created": "„{{name}}“ erstellt. Füge die Personen hinzu, die ihn nutzen dürfen.", + "removed": "„{{name}}“ entfernt. Seine Seiten fragen erneut, bevor sie laufen.", + "restored": "„{{name}}“ ist wieder da, mit seinen Mitgliedern.", + "memberAdded": "{{name}}: {{mode}}.", + "memberRemoved": "{{name}} kann „{{folder}}“ nicht mehr nutzen.", + "failed": "Nicht erledigt: {{message}}", + "loadFailed": "Die geteilten Ordner konnten nicht geladen werden.", + "emptyAdminTitle": "Noch keine geteilten Ordner", + "emptyAdminBody": "Ein geteilter Ordner enthält Dateien und Seiten, die andere lesen oder bearbeiten können. Erstelle einen und füge dann Mitglieder hinzu.", + "factMembers": "Gib einer Person oder allen Lese- oder Bearbeitungszugriff", + "factChats": "Die Chats der Mitglieder sehen die Dateien des Ordners", + "factUndo": "Das Entfernen eines Ordners lässt sich rückgängig machen", + "emptyUserTitle": "Noch nichts mit dir geteilt", + "emptyUserBody": "Wenn ein Administrator einen Ordner mit dir teilt, erscheint er hier und seine Seiten unter Seiten." + }, + "preview": { + "label": "Vorschau", + "failed": "Diese Datei konnte nicht angezeigt werden.", + "loading": "Vorschau von {{name}} wird geladen", + "failedTitle": "{{name}} konnte nicht angezeigt werden.", + "tooLarge": "Diese Datei ist zu groß für die Vorschau. Lade sie herunter, um sie zu öffnen.", + "none": "Für diesen Dateityp gibt es keine Vorschau.", + "typeFile": "{{ext}}-Datei", + "typeUnknown": "Datei", + "sheets": "Tabellenblätter", + "sheetLabel": "Tabellenblatt {{name}}", + "sheetEmpty": "Dieses Tabellenblatt ist leer.", + "row": "Zeile", + "showingRows": "{{rows}} von {{total}} Zeilen", + "showingCols": "und {{cols}} von {{total}} Spalten.", + "downloadRest": "Lade die Datei für den Rest herunter.", + "textOnly": "Textvorschau, ohne Formatierung.", + "truncated": "Der Text ist gekürzt.", + "noText": "Diese Datei enthält keinen Text.", + "slide": "Folie {{n}}", + "notes": "Sprechernotizen", + "pdfTitle": "PDF {{name}}", + "pdfFallback": "Wenn das PDF hier nicht erscheint, lade es herunter.", + "htmlTitle": "Vorschau von {{name}}", + "imageFailed": "Das Bild konnte nicht angezeigt werden.", + "zoomIn": "In voller Größe zeigen", + "zoomOut": "An den Bereich anpassen", + "items_one": "{{count}} Eintrag", + "items_other": "{{count}} Einträge", + "textCut": "Die ersten {{size}} werden gezeigt.", + "actions": "Dateiaktionen" + }, + "managed": { + "group": "Von LocalAI verwaltet", + "marker": "von LocalAI verwaltet", + "about": { + "localai": "Einstellungen, die LocalAI liest: Persona, Profil, Kurzbefehle, Home, Design.", + "chats": "Ein Ordner pro Chat, mit den Dateien des Chats." + }, + "confirm": { + "deleteTitle": "{{name}} in den Papierkorb verschieben?", + "renameTitle": "{{name}} umbenennen?", + "delete": "In den Papierkorb", + "rename": "Umbenennen", + "restore": "Du kannst es aus dem Papierkorb wiederherstellen.", + "renameNote": "LocalAI liest es nur unter diesem Namen; nach dem Umbenennen ist es, als wäre es weg." + }, + "loses": { + "settings": "LocalAI vergisst deine Persona, dein Profil, deine Kurzbefehle, dein Home-Layout und dein Design.", + "chats": "Jeder Chat verliert seine Dateien: was du angehängt hast und was der Assistent dort geschrieben hat.", + "persona": "Der Assistent verliert seinen Namen und seine Anweisungen.", + "user": "Der Assistent vergisst, was du ihm über dich gesagt hast, und deine Antworten auf seine Angebote.", + "shortcuts": "Das Nachrichtenfeld zeigt wieder die Standard-Kurzbefehle.", + "home": "Home verliert seine Widgets.", + "ui": "Design, Akzentfarbe, Dichte, Standardmodell und angeheftete Seiten kehren zu den Standardwerten zurück." + } + }, + "newFile": "Neue Datei", + "newFolderName": "Neuer Ordner", + "newFileName": "Unbenannt.md", + "fileName": "Dateiname", + "downloadOne": "{{name}} herunterladen", + "moveTo": "Verschieben nach…", + "uploadMenu": "Weitere Upload-Optionen", + "uploadFiles": "Dateien hochladen", + "uploadFolder": "Ordner hochladen", + "loading": "Ordner wird geladen", + "loadFailed": "{{folder}} konnte nicht geöffnet werden.", + "types": { + "folder": "Ordner", + "image": "Bild", + "pdf": "PDF", + "sheet": "Tabelle", + "doc": "Dokument", + "slides": "Präsentation", + "archive": "Archiv", + "audio": "Audio", + "video": "Video", + "code": "Code", + "text": "Text", + "file": "Datei" + }, + "columns": { + "name": "Name", + "modified": "Geändert", + "type": "Typ", + "size": "Größe", + "actions": "Aktionen" + }, + "view": { + "label": "Ansicht", + "list": "Liste", + "grid": "Vorschaubilder" + }, + "select": { + "one": "{{name}} auswählen", + "all": "Alle auswählen", + "bar": "Ausgewählte Elemente", + "clear": "Auswahl aufheben", + "count_one": "{{count}} ausgewählt", + "count_other": "{{count}} ausgewählt", + "start": "Auswählen" + }, + "menu": { + "label": "Aktionen für {{name}}", + "more": "Weitere Aktionen für {{name}}", + "openFolder": "Öffnen", + "preview": "Vorschau", + "downloadMany_one": "{{count}} Datei herunterladen", + "downloadMany_other": "{{count}} Dateien herunterladen", + "moveMany_one": "{{count}} Element verschieben nach…", + "moveMany_other": "{{count}} Elemente verschieben nach…", + "deleteMany_one": "{{count}} Element in den Papierkorb legen", + "deleteMany_other": "{{count}} Elemente in den Papierkorb legen" + }, + "keys": { + "delete": "Entf" + }, + "crumbs": { + "hidden": "Übergeordnete Ordner anzeigen" + }, + "naming": { + "empty": "Gib einen Namen ein.", + "slash": "Ein Name darf weder / noch \\ enthalten.", + "taken": "In diesem Ordner gibt es bereits etwas mit diesem Namen." + }, + "toast": { + "undo": "Rückgängig", + "deletedOne": "{{name}} wurde in den Papierkorb gelegt.", + "deleted_one": "{{count}} Element wurde in den Papierkorb gelegt.", + "deleted_other": "{{count}} Elemente wurden in den Papierkorb gelegt.", + "restored": "Wiederhergestellt.", + "renamed": "Umbenannt in {{name}}.", + "renameUndone": "Name zurückgesetzt.", + "movedOne": "{{name}} wurde nach {{folder}} verschoben.", + "moved_one": "{{count}} Element wurde nach {{folder}} verschoben.", + "moved_other": "{{count}} Elemente wurden nach {{folder}} verschoben.", + "moveUndone": "Zurückverschoben." + }, + "search": { + "placeholder": "Diesen Ordner filtern", + "resultsFor": "Dateien mit „{{query}}“", + "back": "Zurück zum Ordner", + "noneBody": "Kein Name und kein Text in deinen Dateien passt. Versuche es mit anderen Wörtern.", + "page": "Seite {{n}}", + "sheet": "Tabellenblatt „{{name}}“", + "slide": "Folie {{n}}", + "indexing_one": "{{count}} Dokument wird noch indexiert: Suche gleich noch einmal, um es einzuschließen.", + "indexing_other": "{{count}} Dokumente werden noch indexiert: Suche gleich noch einmal, um sie einzuschließen.", + "limited": "Ergebnisse begrenzt: Die Suche hat an ihren Grenzen angehalten, bevor sie jede Datei durchsucht hat. Suche in einem Ordner oder mit anderen Wörtern, um den Rest zu sehen.", + "filtering_one": "{{count}} Element in diesem Ordner passt zu „{{query}}“.", + "filtering_other": "{{count}} Elemente in diesem Ordner passen zu „{{query}}“.", + "everywhere": "Alle Dateien nach „{{query}}“ durchsuchen", + "everywhereShort": "Alle Dateien durchsuchen", + "noMatch": "Nichts in diesem Ordner passt zu „{{query}}“.", + "clear": "Filter löschen" + }, + "emptyHome": { + "title": "Hier liegen deine Dateien" + }, + "emptyFolder": { + "title": "Dieser Ordner ist leer", + "body": "Ziehe Dateien oder Ordner an eine beliebige Stelle dieser Seite, füge sie mit Strg+V ein oder lade sie von deinem Computer hoch. Der Assistent kann auch aus einem Chat Dateien hier speichern.", + "bodyTouch": "Lade Dateien von deinem Gerät hoch. Der Assistent kann auch aus einem Chat Dateien hier speichern.", + "ask": "Den Assistenten fragen" + }, + "drop": { + "title": "Zum Hochladen nach {{folder}} ablegen", + "body": "Dateien und ganze Ordner sind willkommen.", + "limit": "Dateien und ganze Ordner, jeweils bis zu {{size}}." + }, + "conflict": { + "title_one": "Eine Datei mit diesem Namen existiert bereits", + "title_other": "{{count}} Dateien mit diesen Namen existieren bereits", + "body_one": "In {{folder}} gibt es bereits eine Datei mit diesem Namen.", + "body_other": "In {{folder}} gibt es bereits Dateien mit diesen Namen.", + "skip_one": "Überspringen", + "skip_other": "Alle überspringen", + "keep": "Beide behalten", + "replace_one": "Ersetzen", + "replace_other": "Alle ersetzen", + "more_one": "und {{count}} weitere", + "more_other": "und {{count}} weitere", + "note": "Bei „Beide behalten“ erhält die neue Datei einen neuen Namen. Eine ersetzte Datei lässt sich über den Verlauf wiederherstellen.", + "cancel": "Upload abbrechen" + }, + "move": { + "title_one": "{{count}} Element verschieben", + "title_other": "{{count}} Elemente verschieben", + "items_one": "{{count}} Element", + "items_other": "{{count}} Elemente", + "cancel": "Abbrechen", + "here": "Nach {{folder}} verschieben", + "failed": "Dieser Ordner konnte nicht gelesen werden.", + "noFolders": "Keine Ordner vorhanden. Hierher verschieben oder eine Ebene nach oben gehen." + }, + "uploads": { + "waiting": "Wartet", + "progress": "{{loaded}} von {{total}}", + "rate": "{{rate}}/s", + "left": "noch {{time}}", + "renamed": "Gespeichert als {{name}}", + "done": "Hochgeladen", + "canceled": "Abgebrochen", + "cancel": "Abbrechen", + "cancelOne": "Upload von {{name}} abbrechen", + "retry": "Erneut versuchen", + "retryOne": "{{name}} erneut versuchen", + "dismiss": "Ausblenden", + "dismissOne": "{{name}} ausblenden", + "show": "Anzeigen", + "showOne": "{{name}} anzeigen", + "meterOne": "{{name}}: {{percent}} %", + "meterAll": "Alle Uploads: {{percent}} %", + "cancelAll": "Alle abbrechen", + "retryAll": "Alle erneut versuchen", + "fold": "Liste ausblenden", + "unfold": "Liste anzeigen", + "close": "Schließen", + "head": { + "sending_one": "{{count}} Datei wird hochgeladen", + "sending_other": "{{count}} Dateien werden hochgeladen", + "failed_one": "{{count}} Upload fehlgeschlagen", + "failed_other": "{{count}} Uploads fehlgeschlagen", + "done_one": "{{count}} Datei hochgeladen", + "done_other": "{{count}} Dateien hochgeladen" + }, + "say": { + "start_one": "{{count}} Datei wird hochgeladen.", + "start_other": "{{count}} Dateien werden hochgeladen.", + "done_one": "Upload abgeschlossen: {{count}} Datei.", + "done_other": "Uploads abgeschlossen: {{count}} Dateien.", + "doneFailed": "Uploads abgeschlossen: {{count}} hochgeladen, {{failed}} fehlgeschlagen.", + "failed": "{{name}} konnte nicht hochgeladen werden." + }, + "errors": { + "tooBig": "Größer als das Limit von {{limit}} dieses Servers.", + "tooLarge": "Der Server hat die Datei als zu groß abgelehnt.", + "off": "Dateien sind auf diesem Server deaktiviert.", + "invalid": "Der Server hat diese Datei abgelehnt.", + "network": "Die Verbindung wurde unterbrochen.", + "server": "Der Server konnte die Datei nicht speichern." + }, + "said": "Der Server meldet: {{reason}}." + } +} diff --git a/core/http/react-ui/public/locales/en/addons.json b/core/http/react-ui/public/locales/en/addons.json index f7ac5740c..e854799d2 100644 --- a/core/http/react-ui/public/locales/en/addons.json +++ b/core/http/react-ui/public/locales/en/addons.json @@ -3,9 +3,9 @@ "blocked": "An earlier action on this add-on is not confirmed yet. Check its status or dismiss it first.", "checkStatus": "Check status", "dismiss": "Dismiss", - "failed": "The owner did not complete the action. Nothing was changed. Try again to start a new attempt.", - "notApplied": "The owner reported the action as done, but the add-on did not change. Try again to start a new attempt.", - "unknown": "The owner did not confirm the action yet. It may still be applied. Check its status, or dismiss it before you start another action." + "failed": "The action did not finish. Nothing was changed. Try again to start a new attempt.", + "notApplied": "The action was reported as done, but the add-on did not change. Try again to start a new attempt.", + "unknown": "The action is not confirmed yet. It may still be applied. Check its status, or dismiss it before you start another action." }, "approval": { "title": "Approval needed", @@ -37,14 +37,16 @@ "emptyTitle": "No add-ons yet", "firstRun": { "browse": "Find add-ons", - "title": "Add your first add-on", - "lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.", - "featuredLabel": "Featured add-ons", + "title": "No add-ons yet", + "lead": "Add-ons give LocalAI new pages and chat tools. They run on their own and ask only for what they list.", + "featuredLabel": "Start with one of these", "featuredFailed": "The list of add-ons could not be read.", - "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons." + "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons.", + "factChecked": "Checked against your sources before they run", + "factRemove": "Turn off or remove any time" }, "headless": "Runs in the background and has no page of its own.", - "listLabel": "My add-ons", + "listLabel": "Installed add-ons", "loading": "Loading add-ons", "manage": "Manage", "open": "Open", @@ -56,10 +58,14 @@ "tools_one": "{{count}} tool", "tools_other": "{{count}} tools", "version": "Version {{version}}", - "working": "Waiting for the owner to confirm.", + "working": "Waiting for confirmation.", "noMatchTitle": "No add-on matches", - "noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.", - "clearSearch": "Clear search" + "noMatchBody": "Nothing installed matches \"{{query}}\". Try other words.", + "clearSearch": "Clear search", + "loadError": { + "title": "Add-ons are not available right now.", + "paused": "Add-on pages and tools are paused until it is back." + } }, "browse": { "confirmTitle": "Install {{name}}?", @@ -69,7 +75,7 @@ "fromLibrary": "from {{library}}", "install": "Install", "installedDetail": "Version {{version}}, generation {{generation}}", - "installedOther": "Installed {{version}} (another version)", + "installedOther": "You have {{version}}", "installedSame": "Installed {{version}}", "installedState": "Installed", "installing": "Installing", @@ -85,7 +91,8 @@ "backendFailed": "The backend was not installed.", "alias": "Alias", "kindLabel": "Kind", - "installedOn": "{{name}} is installed and on." + "installedOn": "{{name}} is installed and on.", + "installedChip": "Installed" }, "catalog": { "allLibraries": "All libraries", @@ -115,8 +122,8 @@ "pin": "Pin {{name}}", "pinLabel": "Pin", "pinned": "Pinned", - "running": "On", - "search": "Search add-ons", + "running": "Running", + "search": "Search installed add-ons", "status": { "failed": "failed", "starting": "starting" @@ -129,7 +136,7 @@ "denied": "The server refused this. You may not have permission, or the add-on changed. The list was refreshed.", "invalid": "The server could not accept this request.", "network": "The server could not be reached. Check your connection.", - "unavailable": "The add-on owner is not available right now. Try again in a moment.", + "unavailable": "Add-ons are not available right now. Try again in a moment.", "unknownOutcome": "The result could not be confirmed. Check the status before you try again." }, "installed": { @@ -182,7 +189,7 @@ "gallery": "Gallery index", "hints": { "id": "Lowercase letters, digits and hyphens. It cannot change later.", - "reference": "The name of a credential the owner configured. Never paste a token or password here.", + "reference": "The name of a credential an administrator configured on this server. Never paste a token or password here.", "url": "An https address without credentials or a query string.", "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.", "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.", @@ -201,7 +208,7 @@ "model": "Models" }, "lastRefresh": "Refreshed {{when}}, {{count}} entries", - "lead": "A library is an index that lists add-ons, backends or models. Credentials are never typed here: a private add-on library names a credential the owner configured.", + "lead": "A library is an index that lists add-ons, backends or models. Credentials are never typed here: a private add-on library names a credential an administrator configured on this server.", "listLabel": "Libraries", "loading": "Loading libraries", "notRefreshed": "Not refreshed yet", @@ -251,10 +258,11 @@ "hasUI": "Has pages", "headless": "Headless, no pages", "health": "Health", + "installedAt": "Installed", "host": "Host API", "interface": "Interface", "logs": "Logs", - "logsNone": "The output of add-on processes is not collected in this release. Health and restart attempts above are what the owner can report.", + "logsNone": "The output of add-on processes is not collected in this release. Health and restart attempts above are all that LocalAI can report.", "observed": "Observed state", "protocol": "Protocol", "restarts": "Restart attempts", @@ -338,14 +346,15 @@ "state": { "failed": "Failed", "label": "State: {{state}}", - "running": "On", + "running": "Running", "starting": "Starting", "stopped": "Off", - "update-pending": "Update pending" + "update-pending": "Update pending", + "updateTo": "Update {{version}}" }, - "supporting": "Add-ons extend LocalAI with pages and chat tools. Find one, turn it on, and use it.", + "supporting": "Software you install to give LocalAI new pages and chat tools. Each one runs on its own and asks only for what it lists.", "tabs": { - "apps": "My add-ons", + "apps": "Installed", "browse": "Find add-ons", "label": "Add-ons sections", "libraries": "Sources" @@ -516,8 +525,8 @@ "unknownOutcome": "The outcome is not known. Read the state again: the same action is not sent twice.", "auth": "Sign in again.", "denied": "Only administrators can do this, from their own browser session.", - "network": "The owner could not be reached.", - "unavailable": "The owner is not available." + "network": "The add-on service could not be reached.", + "unavailable": "The add-on service is not available." } }, "updates": { @@ -591,18 +600,30 @@ "auth": "Sign in again to continue.", "denied": "Only an administrator can do this from their own browser session.", "network": "The network is not reachable.", - "unavailable": "The add-on owner is not available. Try again." + "unavailable": "The add-on service is not available. Try again." + }, + "upgradeNamed": "Upgrade {{name}}", + "why": { + "role": "An update is available. Only an administrator can upgrade it.", + "needs-check": "An update is reported, but its details are not confirmed yet. Check again to confirm it.", + "check-failed": "The last check failed, so the update cannot be confirmed. Check again.", + "incompatible": "An update exists but cannot run here:", + "integrity": "The library lists this version with different contents. It is not offered as an update.", + "recovery": "The data of this add-on needs recovery first. Restore a backup in Details, then upgrade.", + "current": "The latest check found no update. The notice may be out of date. Check again.", + "unpinned": "The library reports an update but names no build to install, so it cannot be upgraded from here. Check again later." }, - "reviewButton": "Updates ({{count}})", - "reviewTitle": "Review add-on updates", - "reviewDescription": "Nothing is upgraded until you choose here. Each upgrade backs up the data of the add-on first.", "checkAll": "Check for updates", "checkingAll": "Checking...", "checkAllDone": "Checked {{count}} add-ons.", "checkAllFailed": "{{count}} could not be checked.", - "upgradeNamed": "Upgrade {{name}}", - "toastDone": "{{name}} upgraded to {{version}}.", - "toastFailed": "{{name}} was not upgraded: {{message}}" + "checkResult": { + "done": "Checked.", + "failed": "The check did not complete.", + "justNow": "Checked just now." + }, + "waitingService": "The add-on service is starting. LocalAI asks about the upgrade again in {{seconds}} s.", + "checkAgain": "Check now" }, "install": { "stepsLabel": "Install steps", @@ -639,17 +660,19 @@ "addsTools_other": "Adds {{count}} tools", "openNamed": "Open {{name}}", "removeTitle": "Remove {{name}}?", - "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted." + "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted.", + "removeNamed": "Remove {{name}}…" }, "preview": { "screenshots": "Screenshots", - "adds": "What it adds", + "adds": "What it can do", "page": "A page of its own in LocalAI", "addsNothing": "Nothing you can see. It works in the background.", "version": "Version", "id": "Identifier", "sourceUnknown": "Not known", - "moreDetails": "Health, trust and updates" + "moreDetails": "Health, trust and updates", + "updateTitle": "Update to {{version}}" }, "flow": { "installAndTurnOn": "Install and turn on", @@ -663,6 +686,66 @@ "install": "Download, verify and install", "turn-on": "Turn on" }, - "seeInList": "See it in My add-ons" + "seeInList": "See it in Installed", + "install": "Install", + "installNamed": "Install {{name}}", + "shadowWarning": "This add-on replaces the built-in {{tool}}. Another add-on ({{name}}) already does. Only one can be active; you choose which in Settings > Tools. Until you choose, the one installed first serves.", + "skillShadow": "This add-on replaces the built-in skill {{skill}}.", + "skillShadowWarning": "This add-on replaces the built-in skill {{skill}}. Another add-on ({{name}}) already does. Only one can be active; you choose which in Settings > Tools. Until you choose, the one installed first serves." + }, + "family": { + "pages": "Looking for pages you or an agent made? They are in Pages." + }, + "filter": { + "label": "Show", + "all": "All", + "running": "Running", + "off": "Off", + "showAll": "Show all", + "none": { + "all": "No add-ons to show.", + "running": "No add-on is running.", + "off": "Every add-on is running." + } + }, + "attention": { + "label": "Needs attention", + "failedTitle": "{{name}} stopped.", + "failedRestarts_one": "It failed to start {{count}} time in a row.", + "failedRestarts_other": "It failed to start {{count}} times in a row.", + "failedOnce": "It did not start.", + "toolsOff_one": "Its {{count}} chat tool is off until it runs again.", + "toolsOff_other": "Its {{count}} chat tools are off until it runs again.", + "pageOff": "Its page is not available until it runs again.", + "updateTitle": "{{name}} {{version}} is available.", + "updateBody": "You have {{current}}. You see what changes and confirm before anything is replaced." + }, + "meta": { + "builtin": "Built in", + "builtinLong": "Ships with LocalAI", + "signed": "Signed · {{library}}", + "checked": "Checked · {{library}}", + "devBuild": "Development build", + "library": "{{library}} library" + }, + "skills": { + "title_one": "{{count}} chat skill", + "title_other": "{{count}} chat skills", + "replaces": "replaces the built-in skill", + "serves": "serves in the chats", + "notServing": "not serving now", + "serveIn": "Use for {{skill}} in the chats", + "saveFailed": "Could not save the choice." + }, + "settingsTab": { + "overview": "Overview", + "settings": "Settings", + "tabsLabel": "Details of {{name}}", + "off": "Turn on {{name}} to change its settings.", + "pageLink": "Settings", + "controls": { + "loadFailed": "The settings could not be read", + "saveFailed": "The settings could not be saved: {{message}}" + } } } diff --git a/core/http/react-ui/public/locales/en/admin.json b/core/http/react-ui/public/locales/en/admin.json index eb51ce6ab..8d5577389 100644 --- a/core/http/react-ui/public/locales/en/admin.json +++ b/core/http/react-ui/public/locales/en/admin.json @@ -58,6 +58,72 @@ "assistant": "LocalAI Assistant", "distributed": "Distributed", "responses": "Responses" + }, + "loadFailedTitle": "Could not load the settings.", + "egress": { + "title": "Network of commands", + "intro": "Commands in the Sandbox reach the network only through a proxy that asks LocalAI about each connection. Set the limit, the preset of a new chat, the ports, the extra registries and the audit retention below; a person chooses the preset of each chat in its Tools menu, up to the limit, and allows a blocked host on a card.", + "serves": "Commands run isolated in the {{name}} add-on", + "servesHost": "Commands run on this machine through {{name}}", + "servesNone": "No Sandbox runs commands yet", + "hostNote": "The host backend runs commands on this machine with its network: these settings do not apply to them.", + "counts": "Last 24 hours: {{allowed}} allowed, {{blocked}} blocked.", + "private": "Let an administrator’s rule reach private networks", + "privateWarning": "A host that an administrator allows can then reach addresses inside your network. A preset alone never does, and the cloud metadata and server addresses stay refused.", + "typePhrase": "To turn it on, type: {{phrase}}", + "registries": "Package registries ({{count}})", + "showAudit": "Show the audit", + "hideAudit": "Hide the audit", + "loadFailed": "The Sandbox network settings could not be read", + "audit": { + "verdict": "Verdict", + "host": "Host", + "user": "User", + "at": "When", + "reason": "Reason", + "bytes": "Bytes up / down", + "refresh": "Refresh", + "empty": "No requests match.", + "loadFailed": "The audit could not be read", + "verdicts": { + "any": "Any verdict", + "allow": "Allowed", + "block": "Blocked", + "done": "Finished" + } + } + }, + "addons": { + "title": "Add-ons", + "hint": "Settings that add-ons bring", + "intro": "Each add-on keeps its settings on its own page, on the Settings tab of its details. Open one to change them.", + "off": "Turned off. Turn it on to change its settings.", + "open": "Open settings", + "openNamed": "Open the settings of {{name}}", + "count_one": "{{count}} add-on with settings", + "count_other": "{{count}} add-ons with settings" + }, + "hosts": { + "add": "Add", + "listLabel": "Always allowed hosts", + "remove": "Remove {{host}}", + "empty": "No host yet. Write a host name, or host:port to pin the port.", + "hint": "Exact names only. Add :port to allow one port; otherwise the Sandbox network ports apply.", + "count": "{{n}}/{{max}}", + "errors": { + "empty": "Write a host name.", + "wildcard": "\"{{value}}\": write an exact host name, not a pattern.", + "shape": "\"{{value}}\" is not a host name. Write the host only, with no scheme or path.", + "port": "\"{{value}}\": the port must be 1 to 65535, and 22 and 25 are always refused.", + "private": "\"{{value}}\" is a private or local address. Turn on the private network switch first.", + "numeric": "\"{{value}}\": write an IP address as four decimal numbers.", + "duplicate": "\"{{value}}\" is already on the list.", + "full": "The list holds at most {{max}} hosts." + } + }, + "network": { + "title": "Network of commands", + "where": "{{name}} keeps the network settings of the commands it runs on its own page." } }, "traces": { @@ -103,5 +169,75 @@ "users": "Users", "invites": "Invites", "keys": "API keys" + }, + "settingsTools": { + "intro": "The chats have a few built-in tools that an add-on can replace. When the add-on is installed, running and on for a chat, the chat uses the add-on's tool instead, under the same name.", + "empty": "No built-in tool can be managed on this server.", + "loadFailed": "The built-in tools could not be read", + "switch": "On", + "byBuiltin": "Built-in", + "byAddon": "Add-on: {{name}}", + "byNone": "Off", + "unavailable": "This server cannot run it: {{reason}}.", + "installSandbox": "Install the Sandbox to run commands isolated", + "replaceExplain": "These add-ons replace {{tool}}: the one chosen serves it in every chat where it is on and running, and the built-in is hidden. Only one can be active. Until you choose, the one installed first serves.", + "defaultInstalledFirst": "Default: installed first", + "installedOn": "Installed {{when}}", + "choose_one": "{{count}} add-on replaces {{tool}}: choose which one to use.", + "choose_other": "{{count}} add-ons replace {{tool}}: choose which one to use.", + "labels": { + "bash": "Commands (bash)", + "web_search": "Web search", + "web_fetch": "Read web pages", + "list_agents": "Saved agents" + }, + "descriptions": { + "bash": "Runs commands and code for the chats directly on this machine when no Sandbox serves bash. The Sandbox add-on replaces it with isolated commands.", + "web_search": "Searches the web for the chats without a key (DuckDuckGo, then Brave Search, then Bing), or with your SearXNG instance when one is set.", + "web_fetch": "Reads a public web page or file by its address for the chats. Addresses inside your network are refused unless you allow them.", + "list_agents": "Lets a chat list the user's saved agents and start one as a sub-agent. Each chat switches it on in its Tools menu." + }, + "bash": { + "switch": "Let chats run commands on this machine", + "modeLegend": "Who can run commands", + "ask": "Ask each time.", + "askHint": "Only administrators' chats get it, and each command waits for their yes in the chat.", + "on": "Allow for everyone.", + "onHint": "Every user's chats run commands here without asking, also scheduled runs and sub-agents.", + "default": "Not set: the default applies ({{mode}}).", + "warning": "Commands run as the user LocalAI runs as, with no isolation and with this machine's network: they can read what LocalAI can read and reach the internet, your network and the LocalAI API. Every command is logged. Install the Sandbox to isolate them." + }, + "modes": { + "off": "off", + "ask": "ask each time", + "on": "allowed for everyone" + }, + "skillsTitle": "Skills that add-ons replace", + "skillsIntro": "An add-on can replace a built-in skill with its own, written for its tools. When several add-ons replace one, choose the one that serves.", + "skillLabel": "Skill {{skill}}", + "skillNotRunning": "The chosen add-on is not running: the built-in skill serves.", + "toolNotRunning": "The add-on that replaces this tool is not running: the built-in tool serves.", + "stopped": "stopped", + "webTest": { + "intro": "Runs a query with each search provider of the saved settings and shows what each one did. Apply your changes first.", + "queryLabel": "Query", + "run": "Test search", + "running": "Testing…", + "answered": "Chats get the results of {{host}}.", + "noneAnswered": "No provider answered.", + "failed": "The test could not run: {{error}}", + "results_one": "{{count}} result", + "results_other": "{{count}} results", + "outcomes": { + "ok": "Works", + "no_results": "No results", + "challenged": "Bot check", + "blocked": "Blocked", + "timeout": "Too slow", + "error": "Error", + "skipped": "Skipped" + } + }, + "addonSettings": "Settings of {{name}}" } } diff --git a/core/http/react-ui/public/locales/en/agents.json b/core/http/react-ui/public/locales/en/agents.json index 46b33a230..7d29293eb 100644 --- a/core/http/react-ui/public/locales/en/agents.json +++ b/core/http/react-ui/public/locales/en/agents.json @@ -1,20 +1,18 @@ { "title": "Agents", - "subtitle": "Give an agent a task and read what it did", + "subtitle": "Saved agents: instructions, a model and what a chat with them may use.", "actions": { - "agentHub": "Agent Hub", "import": "Import", - "createAgent": "Create Agent", + "createAgent": "Create agent", "edit": "Edit", "chat": "Chat", "export": "Export", "delete": "Delete", - "pause": "Pause", - "resume": "Resume", "open": "Open", "moreFor": "More actions for {{name}}", - "statusCount_one": "Status, {{count}} event", - "statusCount_other": "Status, {{count}} events" + "triggers": "Triggers", + "tasks": "Tasks", + "newAgent": "New agent" }, "table": { "name": "Name", @@ -24,22 +22,20 @@ "eventsTooltip": "{{count}} events - Click to view" }, "search": { - "placeholder": "Search agents...", + "placeholder": "Search agents", "summary_one": "{{shown}} of {{total}} agent", "summary_other": "{{shown}} of {{total}} agents" }, "empty": { - "noConfigured": "No agents configured", - "noConfiguredText": "Create an agent to get started with autonomous AI workflows.", - "browseHub": "Don't know where to start? Browse the <1>Agent Hub to find ready-made agent configurations you can import.", + "noConfigured": "No agents yet", + "noConfiguredText": "An agent is saved instructions, a model, and what a chat with it may use. Write one, or start from one of these.", "noMatching": "No matching agents", "noMatchingText": "No agents match \"{{query}}\"", "startWith": "Start from one of these", - "useTemplate": "Use this" + "useTemplate": "Use this", + "clearSearch": "Clear search" }, "sections": { - "yourAgents": "Your Agents", - "otherUsersAgents": "Other Users' Agents", "agents": "Your agents" }, "deleteDialog": { @@ -51,13 +47,18 @@ "loadFailed": "Failed to load agents: {{message}}", "deleted": "Agent \"{{name}}\" deleted", "deleteFailed": "Failed to delete agent: {{message}}", - "paused": "Agent \"{{name}}\" paused", - "resumed": "Agent \"{{name}}\" resumed", - "pauseFailed": "Failed to pause agent: {{message}}", - "resumeFailed": "Failed to resume agent: {{message}}", "exported": "Agent \"{{name}}\" exported", - "exportFailed": "Failed to export agent: {{message}}", - "parseFailed": "Failed to parse agent file: {{message}}" + "parseFailed": "Failed to parse agent file: {{message}}", + "notAnAgent": "The file is not an exported agent", + "exportFailed": "The agent could not be exported: {{message}}", + "imported": "Agent \"{{name}}\" imported", + "importedWith": "Agent \"{{name}}\" imported. {{notes}}", + "importDropped": "Not imported: {{fields}}.", + "fromTemplate": "Agent \"{{name}}\" saved. Grant it the tools it needs.", + "templateFailed": "The starter agent could not be saved: {{message}}", + "deletedUndo": "Deleted “{{name}}”.", + "undoLate": "“{{name}}” was already deleted.", + "restored": "Restored “{{name}}”." }, "state": { "ready": "Ready", @@ -74,15 +75,18 @@ "record": { "line_one": "{{pct}}% of {{count}} run finished", "line_other": "{{pct}}% of {{count}} runs finished", - "none": "No runs recorded here yet", - "where": "Runs are recorded in this browser while you watch them. The server keeps no run history, so another browser starts with an empty record." + "none": "No runs yet", + "where": "A run is a chat that ran with the agent, including the ones its triggers and connectors started." }, "now": { "title": "Now", "working": "Working", "failed": "Failed", "open": "Open", - "runAgain": "Run again" + "runAgain": "Run again", + "failedTitle": "{{name}} failed.", + "workingTitle": "{{name}} is working.", + "openRun": "Open the run" }, "facts": { "model": "Model", @@ -91,8 +95,9 @@ "memory": "Memory", "skills": "Skills", "instructions": "Instructions", - "schedule": "Schedule", - "record": "Record" + "record": "Record", + "addons": "Add-ons", + "connector": "MCP connector" }, "chips": { "none": "None", @@ -100,7 +105,8 @@ "memory": { "knowledge": "Knowledge base", "longTerm": "Long-term memory" - } + }, + "lastFailed": "Last run failed" }, "templates": { "researcher": { @@ -118,132 +124,116 @@ }, "agent": { "back": "Agents", - "status": "Status", - "taskLabel": "Task for the agent", - "taskPlaceholder": "Describe the task. For example: find two good sources on speculative decoding and summarise them.", - "taskHint": "Enter starts the run, Shift+Enter adds a line", - "start": "Start run", - "pausedNote": "This agent is paused, so it cannot start a run.", "runs": "Runs", "runsCount_one": "{{count}} run", "runsCount_other": "{{count}} runs", "noRunsTitle": "No runs yet", - "noRunsText": "Describe a task above and press Start run. The run keeps its own address, and its report stays here.", + "noRunsText": "Press Chat to start a conversation with this agent. Chats that ran with it, and the ones its triggers start, are listed here.", "showOlder": "Show {{count}} older", "showFewer": "Show fewer", - "clearRecord": "Clear run record", - "clearTitle": "Clear the run record", - "clearMessage": "Remove the runs of \"{{name}}\" that this browser keeps, including chats the earlier agent chat page stored here? The agent and its server-side activity are not touched.", - "clearConfirm": "Clear", - "cleared": "Run record cleared", "missingTitle": "No agent named \"{{name}}\"", - "missingText": "It may have been deleted or renamed, or it belongs to another user." - }, - "run": { - "task": "Task", - "followUp": "Follow-up", - "followUps": "Follow-ups", - "followUpN": "Follow-up {{n}}", - "outcome": "Outcome", - "evidence": "Evidence", - "steps": "Steps", - "live": "Live", - "report": "Report", - "view": "View", - "openAgent": "Open {{name}}", - "switchAgent": "Switch agent", - "noOtherAgents": "No other agents", - "statusTitle": "Opens the agent's status in a new tab, so this run stays open", - "more": "More actions for this run", - "copyLink": "Copy link", - "linkCopied": "Link copied", - "linkNotCopied": "Could not copy the link", - "reuseTask": "Reuse this task", - "started": "Started {{when}}", - "addressNote": "This address opens in the browser that recorded the run. Another browser shows a notice, because the server keeps no run record.", - "workingFor": "Working {{time}}, {{count}} steps so far", - "workedFor": "Worked {{time}}, {{count}} steps", - "stoppedAfter": "Stopped after {{time}}, {{count}} steps", - "noSteps": "Nothing recorded yet", - "noStepsReport": "The agent answered without calling a tool.", - "thought": "Thought", - "statusLine": "Status", - "thinking": "Thinking", - "working": "Working", - "using": "Using {{name}}", - "reportReady": "Report ready", - "reportWorking": "The agent is still working on this.", - "watchLive": "Watch live", - "cutOff": "This run was cut off before the agent answered. The page that watched it was closed, or the connection dropped.", - "noAnswer": "No answer was recorded for this run. The page was closed before the agent answered, or the answer did not arrive.", - "stoppedLine": "Stopped before an answer was recorded", - "workingLine": "Working", - "dockWorking": "The agent is working. Follow-ups open when it answers.", - "reconnecting": "Connection to the agent lost. Reconnecting.", - "followUpLabel": "Follow-up", - "followUpPlaceholder": "Ask a follow-up, or add to this result", - "followUpPlaceholderStopped": "Give it a new task, or ask about the last one", - "followUpHint": "Enter sends, Shift+Enter adds a line", - "send": "Send", - "failTitle": "The run stopped", - "failText": "The agent reported an error and did not finish the task. Running it again starts a new run with the same task.", - "failSendTitle": "The task did not reach the agent", - "failSendText": "The server did not accept the task, so the agent never started. Running it again sends it once more.", - "runAgain": "Run again", - "seeStatus": "See the agent's status", - "evidenceNote": "What the tools returned to the agent during this run, as the agent received it.", - "showMore": "Show all", - "showLess": "Show less", - "openWider": "Open wider", - "backToColumn": "Back to column", - "missingTitle": "This run is not in this browser", - "missingText": "Run {{id}} is not in this browser's record. Runs are kept where they were started, and the server keeps no run history, so a link from another browser or a cleared record cannot be opened." + "missingText": "It may have been deleted, or it belongs to another user.", + "triggers": "Triggers", + "newTrigger": "New trigger", + "noTriggers": "No trigger starts this agent.", + "untitled": "Untitled chat", + "triggerOn": "On", + "triggerOff": "Off", + "origin": { + "trigger": "Started by a trigger", + "connector": "Started by a connector" + }, + "backToList": "Back to agents" }, "create": { - "titleCreate": "Create Agent", - "titleEdit": "Edit Agent: {{name}}", - "titleImport": "Import Agent", - "save": "Save Changes", + "titleCreate": "Create agent", + "titleEdit": "Edit {{name}}", + "titleImport": "Import agent", + "save": "Save changes", "saving": "Saving...", "discard": "Discard", "subCreate": "Fill in what you need. Sections you leave alone keep their defaults.", - "subEdit": "Runs in progress keep working on the version they started with.", + "subEdit": "A chat that is running keeps the version it started with. Its next turn uses the new one.", "preview": "Preview", "previewTitle": "Preview", - "previewNew": "The configuration this form will save.", - "previewEdit": "The configuration this form will save, and how it differs from the saved agent.", + "previewNew": "The agent this form will save.", + "previewEdit": "The agent this form will save, and how it differs from the saved one.", "close": "Close", "tabConfig": "Config", "tabChanges": "Changes", - "configNote": "Values that look like keys, tokens or passwords are hidden here. They are saved as typed.", + "configNote": "The definition this form will save.", "noChanges": "No changes. The form matches the saved agent.", "nothingSet": "Nothing is set yet.", "allNew": "A new agent: every value you set is new.", "saved": "Saved", - "default": "Default", "new": "New", "empty": "empty", "startFrom": "Start from", - "draftLabel": "Describe the agent in one sentence", - "draftPlaceholder": "For example: an agent that reads my release notes and lists breaking changes", - "draft": "Draft", - "draftNote": "Optional. Asks the model you chose to write a name, a description and instructions. Nothing is saved until you save, and the draft may be wrong, so read it first.", - "draftNeedsModel": "Pick a model in the Model section first. The draft is written by that model.", - "draftBad": "The model did not return a configuration", - "draftFailed": "Could not draft: {{message}}", - "drafted": "Draft added. Read it before you save.", "needsName": "Needs a name", - "needsModel": "Needs a model", "noneYet": "None yet", "exportSummary": "Download as JSON", - "countConfigured_one": "{{count}} configured", - "countConfigured_other": "{{count}} configured", "countServers_one": "{{count}} server", "countServers_other": "{{count}} servers", "countSkills_one": "{{count}} skill", "countSkills_other": "{{count}} skills", "changed_one": "{{count}} changed", - "changed_other": "{{count}} changed" + "changed_other": "{{count}} changed", + "sections": { + "BasicInfo": "Basic info", + "ModelSettings": "Model", + "PromptsGoals": "Instructions", + "MemorySettings": "Memory", + "Skills": "Skills", + "Addons": "Add-ons", + "MCP": "MCP servers", + "export": "Export" + }, + "name": "Name", + "description": "Description", + "descriptionHelp": "One line that says what the agent is for. It shows in the list.", + "model": "Model", + "modelPlaceholder": "Any model (the chat picks one)", + "modelHelp": "Leave it empty and a chat with this agent uses the model you pick in the chat. Set one to fix it.", + "anyModel": "Any model", + "instructions": "Instructions", + "instructionsHelp": "What the agent always follows. Every chat with it starts from this text.", + "collections": "Collections", + "collectionsHint": "Collection name", + "collectionsHelp": "The memory collections a chat with this agent may search. The list is exact: an empty list grants none.", + "skillsHelp": "The skills a chat with this agent may read. The list is exact: an empty list grants none.", + "noSkills": "No skills yet.", + "manageSkills": "Write or manage skills", + "addonsHelp": "The add-ons whose tools a chat with this agent may use.", + "noAddons": "No add-on offers tools to a model.", + "mcpHelp": "The MCP servers of the fixed model and the MCP connectors a chat with this agent may use.", + "mcpServers": "MCP servers", + "mcpHint": "Server name", + "mcpConnectors": "MCP connectors", + "noConnectors": "No MCP connectors yet.", + "manageConnectors": "Manage MCP connectors", + "connectorOff": "off", + "gone": "not found", + "countCollections_one": "{{count}} collection", + "countCollections_other": "{{count}} collections", + "countAddons_one": "{{count}} add-on", + "countAddons_other": "{{count}} add-ons", + "loadFailed": "Failed to load the agent: {{message}}", + "saveFailed": "Save failed: {{message}}", + "created": "Agent \"{{name}}\" created", + "updated": "Agent \"{{name}}\" updated", + "exportHelp": "Download the definition of this agent as a JSON file. It holds no id, no dates and no secrets. Import it on another instance from the agents list.", + "exportButton": "Export agent", + "importNew": "Imported from an exported agent. Read the form, then save.", + "importLegacy": "Imported from the export of an older agent. Name, description, model, instructions and skills were read; the rest has no counterpart here.", + "importDropped": "Not imported: {{keys}}.", + "default": "Default", + "network": "Network of commands in unattended runs", + "networkHelp": "When a trigger or a connector runs this agent, its Sandbox commands reach this much of the network, up to the limit an administrator set. A run never asks: a host outside it is blocked. A chat you start with this agent starts with it too.", + "networkPresets": { + "off": "Off", + "registries": "Package registries", + "all": "All public sites" + } }, "status": { "title": "Status", @@ -285,18 +275,10 @@ "failedBy": "Failed by {{name}}" }, "jobs": { - "title": "Jobs", - "newTask": "New task", - "createTask": "Create task", - "loading": "Loading jobs", - "moreJobs": "More job actions", - "stopRunning": "Stop running jobs", - "stopTitle": "Stop running jobs", - "stopMessage": "Stop every job that is running or waiting? Finished jobs stay in the history.", - "stopConfirm": "Stop jobs", - "stopped": "Running jobs stopped", - "stopFailed": "Could not stop the jobs: {{message}}", - "noRunningToStop": "No job is running", + "title": "Triggers", + "newTask": "New trigger", + "createTask": "Create trigger", + "loading": "Loading triggers", "loadFailed": "Failed to load: {{message}}", "summary": { "none": "Nothing has run yet.", @@ -305,26 +287,24 @@ "lead_other": "{{count}} runs in the last 7 days.", "finished": "{{count}} finished", "failed": "{{count}} failed", - "stopped": "{{count}} cancelled", + "stopped": "{{count}} skipped", "running": "{{count}} still going", "lastFailed_one": "The last run of {{names}} failed.", "lastFailed_other": "The last run failed for {{names}}.", "andMore": "{{names}} and {{count}} more" }, "tasks": { - "title": "Tasks, {{count}}", + "title": "Triggers, {{count}}", "hide": "Hide", "show": "Show", - "caption": "Tasks", - "task": "Task", - "model": "Model", + "caption": "Triggers", + "task": "Trigger", "schedule": "Schedule", "lastRuns": "Last runs", "enabled": "Enabled", "actions": "Actions", - "noSchedule": "Runs when you start it", + "noSchedule": "No schedule", "noModel": "No model", - "off": "Off", "neverRun": "Not run yet", "lastRun": "Last run {{when}}", "lastRunFailed": "last run failed", @@ -332,33 +312,28 @@ "edit": "Edit", "delete": "Delete", "moreFor": "More actions for {{name}}", - "toggleLabel": "Run {{name}} on its schedule", + "toggleLabel": "Run {{name}} by itself", "turnedOn": "{{name}} is on", "turnedOff": "{{name}} is off", - "toggleFailed": "Could not change the task: {{message}}", - "deleteTitle": "Delete task", - "deleteMessage": "Delete the task \"{{name}}\"? Its jobs stay in the history.", - "deleteConfirm": "Delete", - "undoMessage": "Task \"{{name}}\" deleted. Nothing is removed until this closes.", - "undoLabel": "Undo", - "deleting": "Deleting task \"{{name}}\"", - "deleted": "Task deleted", - "deleteFailed": "Failed to delete: {{message}}", - "deleteAbandoned": "Nothing was deleted, because the page was closed first.", - "others": "Other users' tasks", - "yours": "Your tasks", - "noneYet": "You have no tasks yet.", - "lastRunFailedSentence": "Last run failed" + "toggleFailed": "Could not change the trigger: {{message}}", + "deleted": "Trigger deleted", + "noneYet": "You have no triggers yet.", + "lastRunFailedSentence": "Last run failed", + "noAgent": "No agent", + "fileKind": "When a file arrives", + "open": "Open", + "started": "Trigger \"{{name}}\" started", + "next": "Next" }, "history": { "title": "Run history", "caption": "Run history", "filterStatus": "Show runs", - "filterTask": "Task", - "allTasks": "All tasks", + "filterTask": "Trigger", + "allTasks": "All triggers", "all": "All", "outcome": "Outcome", - "task": "Task", + "task": "Trigger", "what": "What happened", "started": "Started", "took": "Took", @@ -367,14 +342,14 @@ "yesterday": "Yesterday", "noOutput": "Finished with no output", "noError": "Failed without an error message", - "cancelledLine": "Cancelled before it finished", + "cancelledLine": "Skipped", "runningLine": "Working since {{when}}", "pendingLine": "Waiting to start", "emptyFiltered": "No runs match these filters.", "clearFilters": "Clear filters", "emptyTitle": "No runs yet", - "emptyText": "A run starts when you press Run now on a task, when a schedule fires, or when the API starts one.", - "openRun": "Open the run", + "emptyText": "A run starts when you press Run now, when a schedule fires, or when a file arrives in a watched folder.", + "openRun": "Open the chat", "runAgain": "Run again", "cancel": "Cancel", "result": "Result", @@ -383,105 +358,38 @@ "startedBy": "Started by {{who}}", "others": "Other users' jobs", "yours": "Your jobs", - "unknownTask": "Unknown task", + "unknownTask": "Unknown trigger", "cancelled": "Job cancelled", - "cancelFailed": "Failed to cancel: {{message}}" + "cancelFailed": "Failed to cancel: {{message}}", + "doneLine": "Finished. Open the chat for the answer.", + "doneFile": "Finished for {{file}}. Open the chat for the answer.", + "reason": "Reason", + "file": "Started by the file {{file}}." }, "status": { "completed": "Done", "failed": "Failed", "running": "Working", "pending": "Waiting", - "cancelled": "Cancelled" - }, - "trigger": { - "manual": "hand", - "cron": "the schedule", - "api": "the API" + "cancelled": "Cancelled", + "done": "Done", + "queued": "Waiting", + "skipped": "Skipped" }, "empty": { - "title": "No tasks yet", - "text": "A task is a prompt a model runs for you, on a schedule or whenever you start it.", - "step1": "Write the prompt. Use {{gap}} where a value changes from run to run.", - "step2": "Pick a model that has MCP tools, so the task can do more than answer.", - "step3": "Give it a schedule, or press Run now.", - "docs": "Read the documentation" - }, - "wizard": { - "noModelsTitle": "No models installed", - "noModelsText": "Jobs need at least one model with MCP (Model Context Protocol) support. Install a model first, then add MCP in the model settings.", - "browseModels": "Browse models", - "noMcpTitle": "MCP is not set up", - "noMcpText": "You have models, but none has MCP turned on. Jobs use MCP to reach tools and outside services. Edit a model to add MCP servers.", - "mcpExample": "Example MCP configuration (YAML):", - "manageModels": "Manage models", - "docs": "Documentation" - }, - "run": { - "title": "Run {{name}} now", - "desc": "Give the prompt the values it uses. A schedule uses the parameters saved with the task.", - "noGaps": "This prompt has no gaps to fill.", - "gapsLabel": "Values for the prompt", - "more": "More parameters, one key=value per line", - "moreHint": "Available in the prompt as {{gap}}.", - "media": "Media", - "mediaHint": "Attach files to send with this run.", - "add": "Add", - "remove": "Remove {{name}}", - "images": "Images", - "videos": "Videos", - "audios": "Audio", - "files": "Files", - "cancel": "Cancel", - "start": "Start run", - "starting": "Starting", - "close": "Close", - "started": "Task \"{{name}}\" started", - "failed": "Failed to execute: {{message}}" - }, - "cron": { - "everyMinute": "Every minute", - "everyMinutes": "Every {{n}} minutes", - "hourly": "Every hour", - "hourlyAt": "Every hour at :{{minute}}", - "everyHours": "Every {{n}} hours", - "everyHoursAt": "Every {{n}} hours at :{{minute}}", - "daily": "Every day at {{time}}", - "weekdays": "Weekdays at {{time}}", - "weekly": "Every {{days}} at {{time}}", - "monthly": "On day {{day}} of every month at {{time}}", - "each_second_one": "Every second", - "each_second_other": "Every {{count}} seconds", - "each_minute_one": "Every minute", - "each_minute_other": "Every {{count}} minutes", - "each_hour_one": "Every hour", - "each_hour_other": "Every {{count}} hours", - "custom": "Custom schedule", - "zone": "Times follow the clock of the machine that runs LocalAI.", - "errors": { - "fields": "Use five fields: minute, hour, day, month, weekday. You wrote {{count}}.", - "field": "The {{field}} field is not valid.", - "descriptor": "Use @hourly, @daily, @weekly, @monthly or @every followed by a time such as 5m." - }, - "fieldNames": { - "minute": "minute", - "hour": "hour", - "day": "day", - "month": "month", - "weekday": "weekday" - } + "title": "No triggers yet", + "text": "A trigger starts a chat for you by itself: at a time, or when a file arrives in a folder. Each run is an ordinary chat that you can open.", + "step1": "Write what the chat should do.", + "step2": "Pick an agent, or a model, to run it.", + "step3": "Choose when it runs, or press Run now." }, "task": { - "back": "Jobs", - "missingTitle": "Task not found", + "back": "Triggers", + "missingTitle": "Trigger not found", "missingText": "It may have been deleted.", "loadFailed": "Failed to load task: {{message}}", "model": "Model", "schedule": "Schedule", - "scheduleParams": "Schedule parameters", - "webhooks": "Webhooks", - "media": "Media sources", - "none": "None", "enabled": "On", "disabled": "Off", "enabledSwitch": "Enabled", @@ -489,180 +397,291 @@ "edit": "Edit", "recent": "Recent runs", "recentNote": "Last {{count}} runs, oldest first", - "noRuns": "This task has not run yet.", - "allRuns": "All runs of {{name}}", - "prompt": "Prompt", - "promptNote": "Values in colour are filled in when the task runs: from the schedule, or from the person who starts it.", - "context": "Context", - "scheduleSection": "Schedule", - "noScheduleText": "This task has no schedule. It runs when you start it, or when the API does.", - "apiTitle": "Use it from the API", - "apiExecute": "Run by name", - "apiMedia": "Run with media", - "apiStatus": "Check a job", - "copyLink": "Copy link", - "runMeta": "{{who}}, took {{time}}" + "noRuns": "This trigger has not run yet.", + "prompt": "What to do", + "runMeta": "took {{time}}", + "agent": "Agent", + "cap": "Most runs a day", + "watches": "Watches the folder {{folder}}" }, "edit": { - "titleNew": "New task", + "titleNew": "New trigger", "titleEdit": "Edit {{name}}", - "subNew": "Name it, write the prompt and choose when it runs.", + "subNew": "Name it, write what to do and choose when it runs.", "subEdit": "Changes apply from the next run.", "back": "Back", "preview": "Preview", "discard": "Discard", - "save": "Save task", - "create": "Create task", + "save": "Save trigger", + "create": "Create trigger", "saving": "Saving", "changed_one": "{{count}} change", "changed_other": "{{count}} changes", - "nameRequired": "Task name is required", - "cronFix": "Fix the schedule first", - "headerJson": "Headers must be valid JSON: {{where}}", - "created": "Task created", - "updated": "Task updated", + "nameRequired": "A trigger needs a name", + "created": "Trigger created", + "updated": "Trigger updated", "saveFailed": "Save failed: {{message}}", "sections": { "basics": "Basics", - "prompt": "Prompt", - "schedule": "Schedule", + "prompt": "What to do", + "schedule": "When", "media": "Media sources", - "webhooks": "Webhooks" + "webhooks": "Webhooks", + "limits": "Limits" }, "summary": { "basics": "{{name}}, {{model}}", - "basicsNoModel": "{{name}}, no model yet", + "basicsNoModel": "{{name}}, no agent or model yet", "basicsEmpty": "Needs a name", - "promptEmpty": "No prompt yet", - "scheduleNone": "Not scheduled", - "mediaNone": "None", - "media_one": "{{count}} source", - "media_other": "{{count}} sources", - "webhooksNone": "None", - "webhooks_one": "{{count}} webhook", - "webhooks_other": "{{count}} webhooks" + "promptEmpty": "Nothing to do yet", + "folderEmpty": "Needs a folder", + "cap": "Up to {{count}} runs a day", + "capDefault": "Default limit" }, - "name": "Task name", + "name": "Name", "namePlaceholder": "daily-digest", "model": "Model", - "description": "Description", - "descriptionPlaceholder": "One line on what this task does", - "enabled": "Enabled", - "enabledHint": "A task that is off keeps its schedule but does not run on it.", - "prompt": "Prompt template", - "promptPlaceholder": "Write a summary about {{topic}} in {{format}} format.", - "promptHint": "Use {{gap}} where a value changes from run to run. The values come from the schedule, or from the person who starts the task.", - "found": "Parameters found:", - "foundNone": "No parameters in the prompt.", - "context": "Context, optional", - "contextPlaceholder": "Extra context for the agent", - "when": "When should it run?", - "presetNone": "Only when I start it", - "presetHourly": "Hourly", - "presetDaily": "Daily", - "presetWeekdays": "Weekdays", - "presetCustom": "Custom", - "time": "Time", - "cron": "Cron expression", - "cronHint": "Minute, hour, day, month, weekday. For example 0 */6 * * * runs every six hours.", - "scheduleParams": "Parameters, one key=value per line", - "scheduleParamsHint": "These values fill the prompt when the schedule starts the task.", - "gapNoValue": "The prompt uses {{names}}, but the schedule does not set a value for it. A scheduled run would send the text unfilled.", - "mediaNote": "A scheduled run fetches these before it starts.", - "mediaEmpty": "No media sources.", - "addSource": "Add source", - "sourceType": "Type", - "image": "Image", - "video": "Video", - "audio": "Audio", - "file": "File", - "url": "URL", - "headers": "Headers (JSON)", - "removeSource": "Remove source", - "webhooksNote": "Called when a job ends, whether it worked or not.", - "webhooksEmpty": "No webhooks.", - "addWebhook": "Add webhook", - "method": "Method", - "removeWebhook": "Remove webhook", - "payload": "Payload template (Go template syntax)", - "payloadHint": "Available: {{vars}}", + "enabled": "On", + "enabledHint": "A trigger that is off keeps its schedule but does not run.", + "prompt": "What to do", + "promptPlaceholder": "Summarise what is new in my Reports folder.", + "promptHint": "This text starts the chat each time the trigger fires.", "previewTitle": "Preview", - "previewEdit": "What the next run would send, and what differs from the saved task.", + "previewEdit": "What the next run would send, and what differs from the saved trigger.", "previewNew": "What the first run would send.", "tabPrompt": "Prompt", "tabChanges": "Changes", - "promptAsSent": "The prompt as a scheduled run would send it", + "promptAsSent": "The text a run starts with", "emptyPrompt": "The prompt is empty.", - "noChanges": "Nothing differs from the saved task.", + "noChanges": "Nothing differs from the saved trigger.", "nothingSet": "Nothing is set yet.", "saved": "Saved", "new": "New", "empty": "empty", "close": "Close", - "loadingTask": "Loading task" + "loadingTask": "Loading trigger", + "promptRequired": "Write what the trigger should do", + "noAgent": "No agent", + "capHint": "The most chats this trigger may start in one day. Empty uses the default." }, - "job": { - "back": "Jobs", - "title": "{{task}}, {{when}}", - "titleUnknown": "Job {{id}}", - "notFoundTitle": "Job not found", - "notFoundText": "It may have been removed from the history.", - "loadFailed": "Failed to load job: {{message}}", - "startedBy": "Started by {{who}}", - "tookFor": "Took {{time}}", - "copyLink": "Copy link", - "linkCopied": "Link copied", - "linkNotCopied": "Could not copy the link", - "runAgain": "Run again", - "runAgainNow": "Run again now", - "again": "Started a new run", - "againFailed": "Could not start a new run: {{message}}", - "openTask": "Open task", - "cancel": "Cancel", - "cancelled": "Job cancelled", - "cancelFailed": "Cancel failed: {{message}}", - "task": "Task", - "taskNote": "The prompt as this run sent it.", - "taskNoteParams_one": "The prompt as this run sent it. {{count}} parameter.", - "taskNoteParams_other": "The prompt as this run sent it. {{count}} parameters.", - "taskUnknown": "The task is gone, so the prompt is not shown. The parameters this run used are listed below.", - "params": "Parameters", - "outcome": "Outcome", - "outcomeWorking": "This job is still running. The page checks again every two seconds.", - "outcomeWaiting": "This job is waiting to start. The page checks again every two seconds.", - "outcomeCancelled": "This job was cancelled before it finished.", - "outcomeNone": "The job finished and returned no output.", - "failTitle": "This job failed.", - "failText": "The task did not finish, so nothing was produced. Run it again to try once more, or open the task to change what it does.", - "failNoMessage": "The job gave no error message.", - "media": "Attached media", - "mediaNote_one": "{{count}} item went with this run.", - "mediaNote_other": "{{count}} items went with this run.", - "delivery": "Delivery", - "deliverySent": "Webhook delivered", - "deliverySentAt": "Webhook delivered {{when}}", - "deliveryFailed": "Webhook failed: {{message}}", - "trace": "Steps", - "traceNote_one": "{{count}} entry. Open one for what it holds.", - "traceNote_other": "{{count}} entries. Open one for what each holds.", - "noTrace": "The server recorded no steps for this job.", - "arguments": "Arguments", - "showMore": "Show more", - "showLess": "Show less", - "traceType": { - "reasoning": "Thinking", - "tool_call": "Tool call", - "tool_result": "Tool result", - "status": "Status", - "stream_reasoning": "Thinking", - "stream_content": "Writing", - "stream_tool_call": "Tool call" - }, - "when": { - "created": "Created", - "started": "Started", - "completed": "Finished" - } + "actionFailed": "The action failed: {{message}}", + "subtitle": "Start an agent on a schedule or when a file arrives. Every run is a chat you can open." + }, + "run": { + "workingLine": "Working" + }, + "tasks": { + "title": "Agent tasks", + "lede": "Prompts that a model or one of your agents runs in the background. Start them here or from a script over the API.", + "allUsers": "All users", + "new": "New task", + "newTitle": "New task", + "editTitle": "Edit task", + "name": "Name", + "description": "Description", + "runner": "Runs with", + "byModel": "A model", + "byAgent": "One of your agents", + "model": "Model", + "agent": "Agent", + "pick": "Choose...", + "prompt": "Prompt", + "promptHint": "Write a parameter as {{example}}; a run asks for its value.", + "webhooksKept_one": "This task has {{count}} webhook. It is kept as it is; change webhooks over the API.", + "webhooksKept_other": "This task has {{count}} webhooks. They are kept as they are; change webhooks over the API.", + "save": "Save task", + "cancel": "Cancel", + "saved": "Task \"{{name}}\" saved", + "runTitle": "Run {{name}}", + "runNow": "Run now", + "started": "Task \"{{name}}\" started", + "tasks": "Tasks", + "off": "Off", + "run": "Run", + "edit": "Edit", + "delete": "Delete", + "deleted": "Task \"{{name}}\" deleted", + "task": "Task", + "cancelJob": "Cancel", + "rerun": "Run again", + "rerunStarted": "Started again", + "stillRunning": "Still running.", + "noResult": "No result.", + "traces_one": "{{count}} step", + "traces_other": "{{count}} steps", + "webhookSent": "The webhook was called.", + "webhookFailed": "The webhook failed: {{message}}", + "actionFailed": "That did not work: {{message}}", + "state": { + "pending": "Waiting", + "running": "Running", + "completed": "Done", + "failed": "Failed", + "cancelled": "Cancelled" + }, + "searchLabel": "Search tasks", + "filter": { + "label": "Show tasks", + "all": "All", + "on": "On", + "off": "Off" + }, + "noMatch": "No tasks match.", + "on": "On", + "offLine": "Off. It does not run until you turn it on.", + "onDemand": "Runs when you start it", + "asksFor": "Asks for {{list}}", + "webhooks_one": "{{count}} webhook", + "webhooks_other": "{{count}} webhooks", + "runsCount_one": "{{count}} run", + "runsCount_other": "{{count}} runs", + "runsOf_one": "{{count}} run of {{name}}", + "runsOf_other": "{{count}} runs of {{name}}", + "toggleLabel": "{{name}} can run", + "turnedOn": "\"{{name}}\" is on", + "turnedOff": "\"{{name}}\" is off", + "moreFor": "More actions for {{name}}", + "open": "Open", + "openTask": "Open {{name}}", + "runFor": "Run {{name}}", + "rerunFor": "Run {{name}} again", + "jobMoreFor": "More actions for this run of {{name}}", + "detailsFor": "Details of the run of {{name}}", + "openChat": "Open the chat", + "deleteJob": "Delete run", + "jobDeleted": "Run deleted", + "cancelled": "Run stopped", + "confirmJob": { + "title": "Delete this run?", + "message": "Its answer and its steps are deleted. This cannot be undone." + }, + "line": { + "pending": "Waiting to start", + "running": "Working", + "cancelled": "Stopped before it finished" + }, + "startedBy": { + "manual": "Started here", + "api": "Started over the API", + "cron": "Started on a schedule" + }, + "output": { + "answer": "Answer", + "error": "Error", + "values": "Values" + }, + "traceKind": { + "reasoning": "Thinking", + "tool_call": "Tool", + "tool_result": "Tool result", + "status": "Status" + }, + "empty": { + "title": "No tasks yet", + "body": "A task is a prompt you write once and run many times. Put {{example}} where a value changes, and each run asks for it.", + "fact1": "A model or one of your agents runs it", + "fact2": "Start it here, or from a script over the API", + "fact3": "Every run keeps its answer and its steps" + }, + "sheet": { + "optional": "(optional)", + "newDesc": "Write the prompt once, then run it whenever you need it.", + "editDesc": "Changes apply from the next run.", + "about": "About", + "nameHelp": "Scripts use this name to run the task over the API.", + "descHelp": "One line that says what the task is for.", + "who": "Who runs it", + "modelHelp": "The model answers the prompt with no tools.", + "agentHelp": "The agent answers with its own instructions and tools.", + "what": "What it does", + "promptPlaceholder": "For example: Summarise ticket {{example}} in three lines.", + "inputs": "Each run asks for:", + "noInputs": "This prompt asks for nothing. Every run sends it as it is.", + "on": "On", + "onHelp": "A task that is off keeps its runs but cannot start.", + "runDesc": "Fill in the values for this run.", + "runNone": "This task asks for nothing. It runs the prompt as it is.", + "preview": "What the task receives" + }, + "errors": { + "name": "Give the task a name.", + "prompt": "Write what the task should do.", + "model": "Choose a model.", + "agent": "Choose an agent." + }, + "pane": { + "label": "Task details", + "overview": "Overview", + "runs": "Runs", + "settings": "Settings", + "inputs": "Asks for", + "noInputs": "Nothing", + "changed": "Changed", + "api": "Run it over the API", + "apiHint": "Send the values as a JSON object. The answer has the id of the run, to poll for its result.", + "noRuns": "No runs yet. Run the task and its answer shows here.", + "webhooks": "Webhooks", + "noWebhooks": "None. Add webhooks over the API to hear when a run ends.", + "deleteTitle": "Delete this task", + "deleteHelp": "Its runs stay until their retention passes." + }, + "history": { + "title": "Run history", + "filterLabel": "Show runs", + "all": "All", + "active": "In progress", + "completed": "Done", + "failed": "Failed", + "cancelled": "Cancelled", + "allTasks": "All tasks", + "emptyBody": "Run a task and its answer shows here.", + "more": "Show {{count}} more" } + }, + "admin": { + "title": "Chats of all users", + "lede": "Every user's saved agents, triggers and messaging connectors. You can switch a trigger or a connector off, or delete an item. Instructions, prompts and tokens stay with their owner.", + "agents": "Agents ({{count}})", + "triggers": "Triggers ({{count}})", + "connectors": "Messaging connectors ({{count}})", + "noneAgents": "No user has a saved agent.", + "noneTriggers": "No user has a trigger.", + "noneConnectors": "No user has a messaging connector.", + "owner": "Owner", + "name": "Name", + "model": "Model", + "state": "State", + "on": "On", + "off": "Off", + "runs": "Runs", + "noRuns": "No runs yet.", + "disable": "Switch off", + "disabled": "\"{{name}}\" is off", + "delete": "Delete", + "deleted": "\"{{name}}\" deleted", + "loadFailed": "Could not read the list: {{message}}", + "actionFailed": "That did not work: {{message}}" + }, + "views": { + "label": "Agents area", + "agents": "Agents" + }, + "load": { + "agents": "Could not load your agents.", + "agentsStale": "The list could not be refreshed. It shows what was there before.", + "agent": "Could not load this agent.", + "agentStale": "This page could not be refreshed. It shows what was there before.", + "agentForm": "Could not load this agent for editing.", + "triggers": "Could not load your triggers.", + "triggersStale": "The triggers could not be refreshed. They show what was there before.", + "trigger": "Could not load this trigger.", + "triggerStale": "This trigger could not be refreshed. It shows what was there before.", + "tasks": "Could not load the tasks.", + "tasksStale": "The tasks could not be refreshed. They show what was there before." + }, + "meta": { + "anyModel": "Any model", + "addons_one": "{{count}} add-on", + "addons_other": "{{count}} add-ons" } } diff --git a/core/http/react-ui/public/locales/en/auth.json b/core/http/react-ui/public/locales/en/auth.json index d0bf19b8f..68594b46f 100644 --- a/core/http/react-ui/public/locales/en/auth.json +++ b/core/http/react-ui/public/locales/en/auth.json @@ -71,13 +71,16 @@ "account": { "title": "Account", "subtitle": "Profile, password, personal API keys and your usage", + "subtitleLocal": "Who you are, and how the assistant and LocalAI behave for you", "unavailable": "Account unavailable", "unavailableText": "Authentication must be enabled to manage your account.", "tabs": { "profile": "Profile", "security": "Security", "apiKeys": "API keys", - "usage": "Usage" + "usage": "Usage", + "about": "About you and the assistant", + "appearance": "Appearance" }, "profile": { "displayName": "Display name", @@ -167,7 +170,8 @@ "allModels": "All models", "tokens": "tokens", "resets": "resets {{when}}", - "loadFailed": "Failed to load your usage: {{message}}" + "loadFailed": "Failed to load your usage: {{message}}", + "loadFailedTitle": "Could not load your usage." } }, "notFound": { diff --git a/core/http/react-ui/public/locales/en/chat.json b/core/http/react-ui/public/locales/en/chat.json index 19f053378..982398c21 100644 --- a/core/http/react-ui/public/locales/en/chat.json +++ b/core/http/react-ui/public/locales/en/chat.json @@ -31,38 +31,48 @@ "yes": "Yes", "gpuLayers": "GPU layers" }, - "context": { - "label": "Context: {{percent}}%", - "labelWithTokens": "Context: {{percent}}% ({{tokens}} tokens)", - "title": "Context", - "ofSize": "{{percent}}% of {{size}} tokens", - "nearlyFull": "The context is nearly full. Start a new chat or raise the size in settings." - }, "settings": { "title": "Chat Settings", "manageMode": "Manage mode", - "manageModeDesc": "Let this chat install models, switch backends, and edit configs by talking to LocalAI.", "focusMode": "Focus mode", "focusModeDesc": "Collapse the sidebar and slim the header while a conversation is active. Esc restores them temporarily; turn this off to keep the full layout.", - "systemPrompt": "System Prompt", - "systemPromptPlaceholder": "You are a helpful assistant...", + "behaviour": "Behaviour", + "conversation": "This conversation", + "serverKept": "The server keeps this chat and its settings. A new model or title needs the chat to be idle.", + "manageModeOn": "This chat has the LocalAI management tools. Use /assistant to start another one.", + "agent": "Agent", + "agentHelp": "This chat runs with the agent {{name}}: its instructions and only the tools it grants.", + "editAgent": "Edit agent", + "deleteHelp": "Delete this chat and its messages for good.", + "answers": "Answers", + "answersHelp": "These apply from the next turn. Empty fields use the model's own values.", + "systemPrompt": "System prompt", + "systemPromptPlaceholder": "Added to the instructions of this chat", "temperature": "Temperature", "topP": "Top P", "topK": "Top K", - "contextSize": "Context Size", - "contextSizePlaceholder": "2048", - "clearHistory": "Clear chat history", - "appliesNext": "Applies from the next message.", - "systemPromptHelp": "Sent before every message in this chat. Leave it empty to use the model's own default.", - "sampling": "Sampling", - "modelDefault": "model default", - "reset": "Reset", - "contextWindow": "Context window", - "contextUsing": "Using {{used}} of {{size}} tokens ({{percent}}%). The size only drives the meter in the header; it is not sent to the model.", - "contextHelp": "Used to show how full the context is. It is not sent to the model.", - "behaviour": "Behaviour", - "conversation": "This conversation", - "clearHelp": "Remove every message. The name, model and settings stay." + "contextSize": "Context size (tokens)", + "reasoningEffort": "Reasoning effort", + "modelDefault": "Model default", + "effort": { + "default": "Default", + "none": "None", + "minimal": "Minimal", + "low": "Low", + "medium": "Medium", + "high": "High" + }, + "invalid": { + "temperature": "Use a number from 0 to 2.", + "topP": "Use a number above 0 and at most 1.", + "topK": "Use a whole number, 0 or more.", + "contextSize": "Use a whole number from 512 to 4194304.", + "effort": "Choose one of the listed values." + }, + "save": "Save settings", + "saving": "Saving...", + "saved": "Saved. The next turn uses these settings.", + "saveFailed": "The settings could not be saved." }, "empty": { "manageTitle": "Manage LocalAI by chatting", @@ -79,7 +89,6 @@ "Summarize a document", "Brainstorm ideas" ], - "noMessages": "No messages yet", "title": "What are we working on?", "try": "Try", "warmReady": "{{model}} is loaded and ready.", @@ -91,59 +100,28 @@ }, "actions": { "copy": "Copy", - "edit": "Edit", - "editMessage": "Edit message", - "save": "Save", - "cancel": "Cancel", "regenerate": "Regenerate", - "branch": "Branch from here", - "jumpToLatest": "Jump to latest" + "jumpToLatest": "Jump to latest", + "reuse": "Edit and send again", + "editMessage": "Edit message", + "editHelp": "Sending the edit removes everything after this message and runs the chat again.", + "editSend": "Send edit", + "editCancel": "Cancel", + "branch": "Branch from here" }, "streaming": { "transferring": "Transferring model...", "transferringTo": "Transferring model to {{node}}...", - "onNode": "on {{node}}", - "eta": "~{{value}} left", - "modelState": { - "pending": "Preparing to load the model...", - "installing": "Installing the backend...", - "staging": "Staging model files...", - "loading": "Loading the model..." - }, "waiting": "Waiting for the reply" }, - "tokens": { - "perSec": "{{count}} tok/s", - "estimatedPerSec": "{{count}} tok/s · estimated", - "averagePerSec": "{{count}} tok/s · end-to-end", - "usage": "{{prompt}}p + {{completion}}c = {{total}}", - "generating": "generating", - "ofContext": "{{used}} / {{size}} tokens" - }, "input": { - "placeholder": "Message...", - "attachFile": "Attach file", "send": "Send message", "stopGenerating": "Stop generating", - "canvasTitle": "Canvas — extract code blocks and media into a side panel for preview, copy, and download", - "canvasLabel": "Canvas", - "openCanvas": "Open canvas panel", "placeholderModel": "Message {{model}}...", "placeholderNoModel": "Install a model to start chatting", "selectModelFirst": "Select a model first", - "placeholderPick": "Pick a model to start" - }, - "reasoning": { - "effortLabel": "Effort", - "effortTitle": "Reasoning effort for this chat", - "default": "Model default", - "level": { - "none": "None", - "minimal": "Minimal", - "low": "Low", - "medium": "Medium", - "high": "High" - } + "placeholderPick": "Pick a model to start", + "placeholderQueue": "The model is working. A message sent now waits for its next step." }, "deleteAllDialog": { "title": "Delete All Chats", @@ -155,11 +133,12 @@ "copied": "Copied to clipboard", "copyFailed": "Could not copy to clipboard", "chatCopied": "Chat copied to clipboard", - "forked": "Created a new chat", - "pdfReadFailed": "Could not read text from {{name}}. It may be scanned, encrypted or damaged.", - "pdfScannedAsImages": "{{name}} has no text layer (scan). Attached {{count}} of {{total}} pages as images for a vision model.", - "addonToolsOn": "Add-on tools on for this chat", - "addonToolsOff": "Add-on tools off for this chat" + "newChatForModel": "A chat keeps its model. Started a new chat with {{model}}.", + "branched": "Branched into a new chat.", + "notAChat": "This file is not a chat export.", + "imported": "Imported \"{{title}}\".", + "importFailed": "The chat could not be imported: {{message}}", + "modelChanged": "The next turn uses {{model}}." }, "menu": { "trigger": "Chats", @@ -169,7 +148,6 @@ "noMatch": "No conversations match your search", "noConversations": "No conversations yet", "rename": "Rename", - "duplicate": "Duplicate chat", "copyChat": "Copy chat", "exportMarkdown": "Export as Markdown", "deleteChat": "Delete chat", @@ -192,30 +170,64 @@ }, "deleted": "Deleted \"{{title}}\"", "undo": "Undo", - "dismiss": "Dismiss" + "dismiss": "Dismiss", + "untitled": "New chat", + "origin": { + "trigger": "Started by a trigger", + "connector": "From a connector" + }, + "exportJson": "Export as JSON", + "importChat": "Import a chat" }, "message": { "you": "You", "assistant": "Assistant", - "system": "System", - "attachedImage": "Attached image {{n}}", - "openImage": "Open image {{n}}" + "loopRun": "Loop run {{run}}" }, "error": { "title": "The reply failed", "kept": "What was written so far is kept above.", "retry": "Retry", - "detail": "Technical detail" - }, - "clearDialog": { - "title": "Clear this conversation", - "message": "Remove every message in this chat? Its name, model and settings stay.", - "confirm": "Clear conversation" + "detail": "Technical detail", + "reason": { + "unknown": "The agent stopped because of an error. Retry to run the turn again.", + "model_backend_missing": "The model \"{{model}}\" could not be started: its backend is not installed.", + "model_backend_missing_anon": "The model could not be started: its backend is not installed.", + "model_cooldown": "The model \"{{model}}\" failed to start a moment ago and is resting.", + "model_cooldown_anon": "The model failed to start a moment ago and is resting.", + "model_load_failed": "The model \"{{model}}\" could not be started.", + "model_load_failed_anon": "The model could not be started.", + "model_not_found": "The model \"{{model}}\" was not found.", + "model_not_found_anon": "The model was not found.", + "upstream_key": "The upstream refused the key.", + "upstream_path": "The upstream does not accept this request at that address.", + "upstream_unreachable": "The upstream address could not be reached.", + "upstream": "The provider behind the model returned an error.", + "timeout": "The model took too long to answer.", + "cancelled": "The turn was cancelled before the model answered." + }, + "hint": { + "model_backend_missing": "Pick another model, or ask an administrator to install the backend.", + "model_backend_missing_admin": "Pick another model, or install the backend from the Backends page.", + "model_cooldown": "Try again in {{seconds}} seconds.", + "model_load_failed": "Check the model's configuration and its backend, then retry.", + "model_not_found": "Pick another model, or install this one from the Models page.", + "upstream_key": "Check the API key in the model's settings.", + "upstream_path": "The upstream address must be the full endpoint, for example .../v1/chat/completions.", + "upstream_unreachable": "Check the address in the model's settings.", + "upstream": "Retry in a moment. If it keeps failing, check the provider's address and key.", + "timeout": "Retry, or use a smaller or faster model.", + "cancelled": "Retry when you are ready." + }, + "openBackends": "Open Backends" }, "slash": { "group": { "chat": "Chat", - "view": "View" + "view": "View", + "context": "Context", + "skills": "Skills and agents", + "help": "Help" }, "model": { "label": "Switch model", @@ -229,17 +241,9 @@ "label": "Conversations", "desc": "Open the list of your chats" }, - "effort": { - "label": "Reasoning effort", - "desc": "Choose how hard the model thinks" - }, "assistant": { "label": "Manage mode", - "desc": "Let this chat manage LocalAI" - }, - "canvas": { - "label": "Canvas", - "desc": "Show code blocks and media in a side panel" + "desc": "Start a chat that can manage LocalAI" }, "find": { "label": "Find in chat", @@ -247,19 +251,89 @@ }, "settings": { "label": "Chat settings", - "desc": "System prompt, sampling and context" + "desc": "Focus mode, agent and model info" }, "export": { "label": "Export as Markdown", "desc": "Download this conversation" }, - "clear": { - "label": "Clear conversation", - "desc": "Remove every message in this chat" + "delete": { + "label": "Delete chat", + "desc": "Delete this conversation" }, - "addon-tools": { - "label": "Add-on tools", - "desc": "Let this chat use the tools of your add-ons" + "models": { + "label": "Models", + "desc": "Open the model list" + }, + "skill": { + "label": "Run a skill", + "desc": "Pick one of the skills of this chat" + }, + "skills": { + "label": "Manage skills", + "desc": "Open the Skills page to write and change skills" + }, + "agent": { + "label": "Ask a saved agent", + "desc": "Hand a task to one of your agents", + "enable": "Turn on saved agents for this chat", + "enableDesc": "Then pick the agent" + }, + "compact": { + "label": "Compact the context", + "desc": "Summarize older messages to free room for the model" + }, + "clear": { + "label": "Clear the context", + "desc": "Start fresh in this chat; the messages stay" + }, + "attach": { + "label": "Attach files", + "desc": "Choose files to send", + "clearDesc": "Remove the files waiting to be sent" + }, + "help": { + "label": "All commands", + "desc": "List every command with what it does" + }, + "about": { + "label": "About this chat", + "desc": "Model, tools, skills and context size" + }, + "args": { + "skill": "Skills", + "agent": "Saved agents", + "model": "Models", + "attach": "Files", + "loop": "Loops", + "goal": "Goal" + }, + "badge": { + "builtin": "built-in", + "addon": "add-on", + "shared": "shared", + "yours": "yours" + }, + "collides": "only with /skill", + "errors": { + "skill": "This chat has no skill named \"{{name}}\".", + "agent": "You have no saved agent named \"{{name}}\".", + "agentTask": "Write the task for {{name}} after the name.", + "agentsOff": "Saved agents are off for this chat. Turn them on in the Tools menu." + }, + "loop": { + "label": "Repeat a prompt", + "desc": "Run a prompt again and again in this chat: /loop 5m check the build", + "listDesc": "Show the loops of this chat", + "stopDesc": "Stop one loop, or all of them", + "stopAll": "All loops", + "stopAllDesc": "Stop every loop of this chat" + }, + "goal": { + "label": "Goal", + "desc": "Set a goal the chat keeps working on by itself", + "clearDesc": "Remove the goal", + "resumeDesc": "Go on with the goal" } }, "shortcuts": { @@ -273,7 +347,7 @@ "inEmptyBox": "in an empty box", "stop": "Stop the reply", "moveMessages": "Move between messages", - "onMessage": "On a message: copy, edit, regenerate, branch", + "onMessage": "On a message: copy, edit, branch, regenerate", "close": "Close" }, "picker": { @@ -293,21 +367,6 @@ "next": "Next match", "close": "Close search" }, - "canvas": { - "title": "Canvas", - "artifact": "Artifact", - "artifacts": "Artifacts", - "close": "Close canvas", - "fullscreen": "Fullscreen", - "exitFullscreen": "Exit fullscreen", - "resize": "Resize canvas (double-click to reset)", - "resizeHint": "Drag to resize, double-click to reset", - "view": "View", - "code": "Code", - "preview": "Preview", - "download": "Download", - "htmlPreview": "HTML preview" - }, "load": { "title": "Loading", "bytes": "{{sent}} of {{total}}", @@ -321,12 +380,357 @@ "gallery": "Browse the gallery", "import": "Import a model" }, - "addons": { - "label": "Add-ons", - "title": "Add-on tools for this chat", - "heading": "Add-on tools", + "tools": { + "label": "Tools", + "title": "Tools this chat can use", + "heading": "Tools for this chat", + "draftNote": "A new chat starts with these switches. A tool that is off is not offered to the model.", + "note": "A tool that is off here is not offered to the model in this chat.", + "count": "{{on}} of {{total}} on", "selectAll": "Select all", - "deselectAll": "Deselect all", - "note": "An add-on that is off here does nothing in this chat." + "disableAll": "Disable all", + "groupOn": "All on", + "groupOff": "All off", + "groupOnLabel": "Switch on all of {{group}}", + "groupOffLabel": "Switch off all of {{group}}", + "groupCount": "{{on}} of {{total}}", + "optIn": "Off until you switch it on. Select all leaves it off.", + "empty": "Nothing to switch in this chat.", + "failed": "Could not save. The switch is back as it was.", + "unavailable": "Not available in this chat", + "toolCount_one": "{{count}} tool", + "toolCount_other": "{{count}} tools" + }, + "skills": { + "label": "Skills", + "manage": "Manage skills", + "title": "Skills this chat can use", + "heading": "Skills for this chat", + "count": "{{on}} of {{total}} on", + "selectAll": "Select all", + "disableAll": "Disable all", + "selectShown": "Select shown", + "disableShown": "Disable shown", + "search": "Search skills", + "noMatch": "No skill matches “{{query}}”.", + "later": "Skills you add later start on.", + "failed": "Could not save. Nothing changed.", + "source": { + "builtin": "Built-in", + "personal": "Personal", + "shared": "Shared", + "folder": "Workspace", + "addon": "{{name}}" + }, + "replacedBy": "Replaced by {{name}}", + "fallback_one": "Built-in {{skills}} in use: the {{name}} add-on's own skill is off or not running here.", + "fallback_other": "Built-in {{skills}} in use: the {{name}} add-on's own skills are off or not running here." + }, + "agent": { + "label": "Agent", + "title": "Saved agent for this chat", + "heading": "Start with an agent", + "note": "The agent gives the chat its instructions and exactly the tools it grants.", + "none": "No agent", + "fixed": "The agent of this chat" + }, + "assistant": { + "chatTitle": "LocalAI assistant" + }, + "browserImport": { + "found_one": "This browser still has {{count}} chat from the old chat page.", + "found_other": "This browser still has {{count}} chats from the old chat page.", + "import": "Import them", + "dismiss": "Not now", + "done": "Imported {{imported}}, skipped {{skipped}}, failed {{failed}}.", + "failed": "The old chats could not be imported: {{message}}" + }, + "meter": { + "usedOf": "{{used}} / {{limit}} tokens ({{pct}}%)", + "used": "{{used}} tokens", + "speed": "{{speed}} tok/s", + "title": "Context the last model request used", + "ofLimit": "{{used}} / {{limit}} tokens", + "left": "{{left}} left" + }, + "files": { + "title": "Files in this chat", + "chip": "Files", + "chipTitle": "Files in this chat: what you attached and what the assistant created or changed", + "close": "Close files", + "emptyTitle": "No files in this chat yet", + "emptyBody": "Drop files here to attach them to your next message. Files the assistant creates or changes show up here too.", + "dropHint": "Drop files here to attach them to your next message.", + "groups": { + "uploaded": "You gave it", + "created": "Created and changed", + "working": "Working files" + }, + "turn": "turn {{n}}", + "media": "In context", + "mediaTitle": "The model received this file as an image or video with your message", + "changedTag": "Edited", + "changedTitle": "The assistant changed this file", + "download": "Download {{name}}", + "error": "The files of this chat could not be read.", + "workingNote": "Scripts, data and logs the assistant made on the way. They are kept in this chat's work folder." + }, + "steps": { + "verb": { + "read": "Read", + "list": "Listed", + "write": "Saved", + "create": "Created", + "change": "Changed", + "move": "Moved", + "delete": "Deleted", + "search": "Searched", + "run": "Ran", + "fetch": "Fetched", + "websearch": "Searched the web for", + "webfetch": "Read", + "delegate": "Asked an agent", + "used": "Used" + }, + "worked": "Worked for {{duration}}", + "thoughtFor": "Thought for {{duration}}", + "working": "Working", + "thinking": "Thinking", + "noMatches": "no matches", + "noResults": "no results", + "emptyFolder": "empty folder", + "size": "{{size}}", + "seconds": "{{value}} s", + "minutes": "{{min}} min {{sec}} s", + "tool": "Tool", + "input": "Input", + "showLess": "Show less", + "running": "Running", + "failed": "Failed", + "count_one": "{{count}} step", + "count_other": "{{count}} steps", + "files_one": "{{count}} file", + "files_other": "{{count}} files", + "items_one": "{{count}} item", + "items_other": "{{count}} items", + "matches_one": "{{count}} match", + "matches_other": "{{count}} matches", + "results_one": "{{count}} result", + "results_other": "{{count}} results", + "lines_one": "{{count}} line", + "lines_other": "{{count}} lines", + "showAll_one": "Show all {{count}} lines", + "showAll_other": "Show all {{count}} lines", + "details": "Show details", + "openFile": "Open {{path}} in Files", + "savedFiles": "Saved to your files:", + "fileOp": { + "changed": "changed", + "deleted": "deleted" + }, + "moreFiles": "and {{count}} more", + "alsoSavedWorking_one": "Also saved {{count}} working file", + "alsoSavedWorking_other": "Also saved {{count}} working files", + "savedWorking_one": "Saved {{count}} working file", + "savedWorking_other": "Saved {{count}} working files", + "workingTitle": "Working file (a script, a log or a draft): open {{path}}" + }, + "allChats": { + "title": "All chats", + "supporting": "Every chat you kept. Search, rename, export or delete them.", + "loadFailed": "Could not load your chats.", + "emptyTitle": "No chats yet", + "emptyBody": "A chat starts on Home: type a message and send it. Your chats are listed here after that.", + "start": "Start a chat" + }, + "foot": { + "state": { + "ready": "Ready", + "nomodel": "No model", + "sending": "Sending", + "loading": "Loading model", + "thinking": "Thinking", + "tool": "Running {{tool}}", + "answering": "Answering", + "waiting": "Waiting for you", + "stopped": "Stopped", + "failed": "Failed", + "interrupted": "Interrupted", + "paused": "Paused", + "compacting": "Compacting the context", + "clearing": "Clearing the context" + }, + "tool": { + "search": "Searching", + "list": "Listing", + "read": "Reading", + "create": "Creating", + "write": "Writing", + "change": "Changing", + "move": "Moving", + "delete": "Deleting", + "run": "Running a command", + "fetch": "Fetching", + "delegate": "Asking a sub-agent" + }, + "hint": "Enter to send · Shift+Enter for a new line", + "meter": { + "label": "Context {{pct}}% full. Show context and speed", + "labelNoLimit": "Context {{used}} tokens. Show context and speed" + }, + "details": { + "title": "Context and speed", + "context": "Context", + "contextOf": "{{used}} of {{limit}} tokens ({{pct}}%)", + "contextUsed": "{{used}} tokens", + "contextLeft": "{{left}} tokens left", + "contextHelp": "The context is what the model reads for each request: the instructions, this chat so far and the tool results.", + "nearFull": "This chat is close to the limit. The server compacts the older part so the model can go on. The transcript keeps every message.", + "limitUnknown": "The server did not say how large the context window is.", + "lastRequest": "Last request", + "model": "Model", + "backend": "Backend", + "node": "Node", + "tokensIn": "Tokens in", + "tokensOut": "Tokens out", + "firstToken": "Time to first token", + "promptTime": "Prompt processing", + "speed": "Speed", + "speedLive": "Speed, estimated while streaming", + "average": "Average of the last {{count}} requests", + "note": "These figures come from the requests this page saw. The server does not keep them, so a reload clears them." + }, + "paused": { + "after": "after {{count}} steps", + "atLimit": "at the step limit", + "continue": "Continue", + "continueTitle": "The model used all the steps of one turn. Let it go on from where it stopped.", + "message": "Continue from where you stopped." + } + }, + "attachments": { + "label": "Attached files", + "open": "Open {{name}}", + "showMore": "Show {{count}} more", + "missing": "File no longer available" + }, + "preview": { + "open": "Open preview", + "created": "Created by the assistant", + "changed": "Changed by the assistant", + "yours": "You gave it", + "fromReply": "From the answer", + "download": "Download", + "downloadCode": "Download code", + "copy": "Copy", + "copied": "Copied", + "openInFiles": "Open in Files", + "actions": "File actions", + "opened": "Preview opened: {{name}}", + "chip": { + "created": "Created", + "changed": "Changed", + "label": "{{verb}} {{name}}, open preview", + "row": "Files from this answer" + } + }, + "goal": { + "label": "Goal", + "states": { + "active": "Working on it", + "paused": "Paused", + "done": "Reached", + "blocked": "Needs you" + }, + "pausedBy": { + "user": "You paused it.", + "turn_limit": "It used all its automatic turns. Resume to give it more.", + "step_limit": "It used all its steps. Resume to give it more.", + "error": "A turn failed. Resume to try again.", + "interrupted": "The server stopped during a turn. Resume to go on." + }, + "blockedNote": "Needs from you: {{note}}", + "doneNote": "{{note}}", + "replaced": "Replaced: {{text}}", + "pause": "Pause", + "pauseTitle": "Stop the running turn and pause the goal", + "resume": "Resume", + "resumeTitle": "Go on with the goal", + "clear": "Clear", + "clearTitle": "Remove the goal", + "progress": "Goal · turn {{turn}} of {{max}}", + "progressStart": "Goal · starting", + "markers": { + "set": "Goal set", + "replaced": "Goal replaced", + "continue": "Continuing toward the goal · turn {{turn}}", + "continueStart": "Continuing toward the goal", + "paused": "Goal paused", + "resumed": "Goal resumed", + "done": "Goal reached", + "blocked": "Goal blocked", + "cleared": "Goal cleared" + }, + "announce": { + "set": "Goal set: {{text}}", + "paused": "Goal paused. {{why}}", + "resumed": "Goal resumed", + "done": "Goal reached: {{note}}", + "blocked": "The goal needs you: {{note}}", + "cleared": "Goal cleared" + }, + "card": { + "title": "Goal", + "none": "This chat has no goal. Type /goal and what you want done, and the chat keeps working on it by itself.", + "turns": "Turn {{turn}} of {{max}}", + "steps": "{{steps}} of {{max}} steps", + "clear": "/goal clear removes it.", + "resume": "/goal resume goes on with it.", + "turnsLabel": "Automatic turns", + "stepsLabel": "Steps", + "setLabel": "Set", + "stateLabel": "State", + "noteLabel": "Note" + }, + "errors": { + "empty": "Write what the goal is after /goal.", + "notAllowed": "Only a chat that you started can have a goal." + } + }, + "invoked": { + "skill": "Skill: {{name}}", + "agent": "Agent: {{name}}" + }, + "marker": { + "compacted": "Context compacted: {{before}} to {{after}} tokens", + "cleared": "Context cleared", + "clearedHint": "The model does not see the messages above this line." + }, + "cmdcard": { + "close": "Close", + "helpTitle": "Commands", + "helpSkills_one": "Skill: {{names}}. Type / and its name.", + "helpSkills_other": "{{count}} skills: {{names}}. Type / and the name.", + "helpKeys": "Type / in the box to open the list. Tab completes, Enter runs, Esc closes.", + "aboutTitle": "This chat", + "model": "Model", + "state": "State", + "loaded": "Loaded", + "notLoaded": "Not loaded", + "backend": "Backend", + "tools": "Tools", + "toolsOn_one": "{{count}} on", + "toolsOn_other": "{{count}} on", + "skills": "Skills", + "skillsOn_one": "{{count}} skill", + "skillsOn_other": "{{count}} skills", + "commands": "Commands", + "isolated": "Run in an isolated sandbox", + "host": "Run on this machine", + "noCommands": "Cannot run", + "context": "Context", + "contextOf": "{{used}} of {{limit}} tokens", + "contextUsed": "{{used}} tokens", + "unknown": "Not known yet" } } diff --git a/core/http/react-ui/public/locales/en/chats.json b/core/http/react-ui/public/locales/en/chats.json new file mode 100644 index 000000000..aa8b6d46a --- /dev/null +++ b/core/http/react-ui/public/locales/en/chats.json @@ -0,0 +1,907 @@ +{ + "title": "Chats", + "list": { + "new": "New chat", + "label": "Your chats", + "empty": "No chats yet.", + "untitled": "New chat", + "delete": "Delete chat", + "deleteNamed": "Delete chat {{title}}", + "toggle": "Chats", + "working": "Working", + "agentPicker": "Agent", + "noAgent": "No agent", + "agents": "Agents", + "triggers": "Triggers", + "library": "Library", + "automation": "Automation", + "files": "Files", + "inbox": "Inbox", + "automatic": "Started by a trigger", + "unread": "Unread", + "connectors": "Connectors" + }, + "empty": { + "title": "Start a chat", + "body": "Pick a model and ask anything. You can keep typing while it works." + }, + "model": "Model", + "transcript": "Conversation", + "composer": { + "label": "Message", + "placeholder": "Message. Enter sends, Shift+Enter adds a line.", + "send": "Send", + "stop": "Stop", + "sendNow": "Send now" + }, + "pending": { + "header_one": "1 message waiting", + "header_other": "{{count}} messages waiting", + "note": "They reach the model together, in order, at its next step.", + "afterStop": "You stopped the turn. These messages are waiting for you.", + "afterFailure": "The turn ended early. These messages are waiting for you.", + "edit": "Edit", + "cancel": "Cancel", + "save": "Save", + "discard": "Discard" + }, + "status": { + "working": "Working…", + "stopped": "Stopped", + "failed": "The last turn failed.", + "interrupted": "The last turn was interrupted.", + "done": "Done" + }, + "tool": { + "running": "Running", + "failed": "Failed", + "applied": "Changed {{addon}}: {{count}} applied.", + "appliedNone": "Changed {{addon}}.", + "notUndoable": "This cannot be undone.", + "notUndoableWhy": "This cannot be undone: {{reason}}.", + "undo": "Undo", + "undoing": "Undoing.", + "undoDone": "Undone.", + "undoAlready": "Already undone.", + "undoStale": "Not undone. The data changed after this change, so undoing it would overwrite later work. {{detail}}", + "undoNotFound": "Not undone. The record of this change is gone.", + "undoExpired": "Not undone. This change is too old to undo.", + "undoCannot": "Not undone. {{detail}}", + "undoDenied": "Not undone. Your role on this data does not allow it.", + "undoUnknown": "Not sure the undo went through. Press Undo to check; it will not run twice.", + "undoUnavailable": "Not undone. The add-on could not be reached.", + "waiting": "{{addon}} left this change waiting; it was not applied.", + "refused": { + "anAddon": "the add-on", + "not_permitted": "You do not have the permission {{addon}} needs for this. Nothing was changed. An administrator can grant it.", + "resource_unreachable": "{{addon}} could not find that resource, or you have no access to it. Nothing was changed.", + "not_running": "{{addon}} is starting or stopped, so nothing was sent. Try again in a moment.", + "invalid_request": "{{addon}} refused the request:", + "said": "It said: {{message}}" + } + }, + "app": { + "title": "Interactive view of {{tool}}", + "loading": "Loading the view", + "unavailable": "The view could not be loaded ({{reason}}). Here is the result as text.", + "notRead": "the result could not be read now; the page is tried again when the turn ends", + "suggests": "The app suggests this message", + "send": "Send", + "dismiss": "Dismiss", + "wantsLink": "The app wants to open {{host}}", + "open": "Open" + }, + "reasoning": "Thought", + "notes": { + "compacted": "The conversation was compacted to make room ({{before}} to {{after}} tokens).", + "subAgentDone": "Sub-agent finished: {{title}}" + }, + "errors": { + "send": "The message could not be sent.", + "delete": "The chat could not be deleted.", + "dismiss": "Dismiss" + }, + "confirmDelete": { + "title": "Delete {{name}}?", + "chat": "The chat and its messages are deleted for good.", + "agent": "Chats that use this agent are kept. Triggers that use it stop working.", + "trigger": "The trigger stops and its history of runs is deleted.", + "skill": "The skill is deleted for good.", + "confirm": "Delete", + "pending": "Deleting", + "mcp": "The connector is deleted with its saved token, headers and environment. A saved agent that names it can no longer use it.", + "task": "The task is deleted. Its jobs stay until their retention passes.", + "connector": "The connector stops and its settings are deleted." + }, + "memoryNotice": "Chats are kept in memory only. They are lost when the server restarts.", + "attach": { + "button": "Attach a file", + "busy": "Files go with a message that starts a turn. Wait for this one to end or stop it.", + "list": "Attached files", + "remove": "Remove {{name}}", + "off": "Files are off on this server.", + "queued": "A file sent now waits with its message until the model takes it at its next step." + }, + "skills": { + "summary": "Skills ({{on}} of {{total}} on)", + "none": "No skills yet.", + "manage": "Manage skills", + "off": "off for this chat" + }, + "addons": { + "summary": "Add-ons ({{on}} of {{total}} on)", + "tools": "{{count}} tools" + }, + "skillsPage": { + "title": "Skills", + "intro": "A skill tells the model how to do one kind of task. Chats list the skills they can use, and the model reads one when a task matches. When two skills have the same name, the nearest one wins.", + "new": "New skill", + "edit": "Edit skill", + "editButton": "Edit", + "delete": "Delete", + "save": "Save", + "cancel": "Cancel", + "layer": "Where", + "name": "Name", + "description": "Description", + "body": "Instructions", + "groups": { + "personal": "Personal", + "folder": "In your folders", + "shared": "Shared", + "builtin": "Built in" + }, + "shadowed": "overridden by {{layer}}", + "invalid": "Skipped: {{reason}}", + "empty": "You have no skills yet.", + "back": "Back to chats" + }, + "agentsPage": { + "title": "Agents", + "new": "New agent", + "edit": "Edit agent", + "intro": "An agent is a saved set of instructions and tools. A chat with an agent gets only what its lists name, and only what you may use at that moment.", + "name": "Name", + "description": "Description", + "instructions": "Instructions", + "model": "Model", + "grants": "What a chat with this agent may use. An empty list gives nothing.", + "addons": "Add-ons", + "addonsHint": "Add-on ID", + "mcp": "MCP servers of the model", + "mcpHint": "Server name from the model's MCP configuration", + "collections": "Collections", + "collectionsHint": "Collection name", + "skills": "Skills", + "skillsHint": "Skill name", + "save": "Save", + "cancel": "Cancel", + "editButton": "Edit", + "delete": "Delete", + "chat": "Chat", + "empty": "No agents yet.", + "noGrants": "Gives no tools", + "fixModel": "Always use one model (otherwise you choose the model for each chat)", + "mcpConnectors": "Your MCP connectors", + "connectorOff": "switched off", + "connectorGone": "{{id}} (deleted)", + "noConnectors": "You have no MCP connectors yet.", + "manageConnectors": "Manage connectors" + }, + "title_agent": "Agent: {{name}}", + "triggersPage": { + "title": "Triggers", + "new": "New trigger", + "edit": "Edit trigger", + "intro": "A trigger starts a chat as you, by itself: at a time, or when a file arrives in a folder. Each run is an ordinary chat that you can open and undo.", + "name": "Name", + "when": "When", + "time": "Time", + "dayOfMonth": "Day of the month", + "date": "Date", + "minute": "Minute past the hour", + "everyHours": "Every (hours)", + "everyMinutes": "Every (minutes)", + "cron": "Cron (five fields)", + "zone": "Time zone", + "days": "Days", + "folder": "Folder in your files", + "quiet": "Quiet period (seconds)", + "prompt": "What to do", + "agent": "Agent", + "dailyCap": "Most runs a day", + "on": "On", + "onFor": "{{name}} on", + "runNow": "Run now", + "runs": "Runs", + "noRuns": "No runs yet.", + "neverRan": "Never ran", + "openChat": "Open the chat", + "next": "next {{time}}", + "nextRuns": "Next: {{times}}", + "empty": "No triggers yet.", + "noAgentNote": "With no agent, a run uses the model and switches of the chat that made it, and no tool that acts at once. Choose an agent to give it more.", + "proposals": "Proposed by a chat", + "proposalsNote": "A proposal does nothing until you confirm it.", + "confirm": "Confirm", + "dismiss": "Dismiss", + "status": { + "queued": "Waiting", + "running": "Running", + "done": "Done", + "failed": "Failed", + "skipped": "Skipped" + }, + "presets": { + "daily": "Every day", + "weekdays": "Every weekday", + "weekly": "Certain days of the week", + "monthly": "Once a month", + "hourly": "Every hour", + "everyHours": "Every few hours", + "everyMinutes": "Every few minutes", + "once": "Once", + "cron": "Advanced (cron)", + "file": "When a file arrives" + }, + "day": { + "mon": "Mon", + "tue": "Tue", + "wed": "Wed", + "thu": "Thu", + "fri": "Fri", + "sat": "Sat", + "sun": "Sun" + } + }, + "inbox": { + "unread": "{{count}} unread", + "markAll": "Mark all read", + "delete": "Delete", + "pushOn": "Turn on browser notifications", + "pushOff": "Turn off browser notifications", + "pushDenied": "Browser notifications are blocked for this site.", + "pushUnavailable": "This server cannot keep push subscriptions.", + "clearAll": "Clear all", + "clearRead": "Clear read", + "cancel": "Cancel", + "markRead": "Mark read", + "cleared_one": "Removed {{count}} notification.", + "cleared_other": "Removed {{count}} notifications.", + "confirmAll_one": "Remove {{count}} notification? This cannot be undone.", + "confirmAll_other": "Remove all {{count}} notifications? This cannot be undone.", + "confirmRead_one": "Remove {{count}} read notification? Unread ones stay.", + "confirmRead_other": "Remove {{count}} read notifications? Unread ones stay.", + "onlyRead": "Only the {{count}} read", + "showing": "Showing {{shown}} of {{total}}", + "showMore": "Show more", + "title": "Notifications", + "buttonUnread_one": "Notifications, {{count}} unread", + "buttonUnread_other": "Notifications, {{count}} unread", + "filterLabel": "Show notifications", + "filterAll": "All", + "filterUnread": "Unread", + "filterFailed": "Failed", + "more": "More actions", + "close": "Close", + "day": { + "today": "Today", + "yesterday": "Yesterday", + "earlier": "Earlier" + }, + "emptyTitle": "No notifications yet", + "emptyBody": "Results of your triggers and connectors appear here, also for work that finished while you were away.", + "caughtUp": "You're all caught up.", + "caughtUpTitle": "You're all caught up", + "caughtUpBody": "There are no unread notifications.", + "noFailedTitle": "Nothing failed", + "noFailedBody": "Runs and connectors that fail show up here.", + "loadError": "Could not load your notifications.", + "source": { + "trigger": "Trigger", + "connector": "Connector", + "system": "LocalAI" + }, + "itemUnread": "{{title}}, unread", + "arrived": "New notification: {{title}}", + "arrivedMany_one": "{{count}} new notification", + "arrivedMany_other": "{{count}} new notifications" + }, + "connectorsPage": { + "title": "Connectors", + "empty": "No connectors yet.", + "agentOf": "Agent: {{name}}", + "accepts": "Accepts: {{who}}", + "modes": { + "anyone": "anyone", + "nobody": "nobody yet", + "listed_one": "1 listed sender", + "listed_other": "{{count}} listed senders" + }, + "on": "On", + "onFor": "{{name}} on", + "intro": "A connector links a messaging platform to your chats, so people can talk to one of your agents from there. Each platform comes from a connector add-on.", + "manage": "Add or change bindings", + "noAddon": "No connector add-on is installed.", + "noAddonHint": "A connector add-on carries the messages of a platform such as Telegram. Install one from the add-on gallery to add a binding.", + "gallery": "Open the add-on gallery", + "inactive": "Not running: {{reason}}.", + "bindingsOf": "Bindings of {{name}}", + "loadFailed": "Could not load your connectors.", + "waitingTitle": "Waiting for their add-on", + "waitingText": "These bindings are kept, but they do not run until their add-on is installed and turned on." + }, + "conversionPage": { + "title": "Converted from the old chat", + "intro": "The old chat, agents, tasks, skills and Telegram connectors were converted when this server started. Triggers and connectors are switched off until you turn them on. A chat keeps what was said, not what the model remembered.", + "notRun": "No conversion has run on this server.", + "nothing": "Nothing of yours needed converting.", + "allUsers": "All users:", + "count": { + "converted": "{{count}} converted", + "skipped": "{{count}} skipped", + "failed": "{{count}} failed", + "dropped": "{{count}} dropped" + }, + "kind": { + "agent": "Agent", + "trigger": "Trigger", + "skill": "Skill", + "chat": "Chat", + "connector": "Connector" + }, + "status": { + "converted": "Converted", + "skipped": "Skipped", + "failed": "Failed", + "dropped": "Dropped" + }, + "openTriggers": "Triggers", + "openConnectors": "Connectors" + }, + "tools": { + "summary": "Tools ({{on}} of {{total}} on)", + "builtins": "Built-in tools", + "builtin": { + "web": "Web", + "saved_agents": "Saved agents", + "triggers": "Triggers", + "shell": "Run commands", + "files": "Your files", + "media": "Images and audio", + "collections": "Collections", + "subagents": "Sub-agents" + }, + "builtinBy": "{{tools}} · built-in", + "builtinByAddon": "{{tools}} · served by {{provider}}", + "servedBy": "Served by add-on {{provider}}", + "servedByTool": "{{tool}}: {{provider}}", + "servedByBuiltin": "built-in", + "fallbackBuiltin": "{{addon}} replaces {{tool}} but cannot serve this chat now (stopped, switched off here or not granted): the built-in {{tool}} serves.", + "fallbackNone": "{{addon}} replaces {{tool}} but cannot serve this chat now, and the built-in {{tool}} is off: this chat has no {{tool}}.", + "addons": "Add-ons", + "servers": "MCP servers of the model", + "connectors": "Your connectors", + "manage": "Manage connectors", + "tools": "{{count}} tools", + "sandbox": { + "isolated": "Runs commands isolated ({{backend}})", + "host": "Commands run directly on this machine, with no isolation", + "cannot": "Sandbox unavailable: this host cannot run commands", + "cannotWhy": "Sandbox unavailable: {{reason}}", + "starting": "The Sandbox is starting. LocalAI checks again in {{seconds}} s." + }, + "commands": { + "title": "Commands can't run here", + "none": "No add-on that runs code is installed, so the assistant can write files but cannot run scripts or commands.", + "off": "The Sandbox add-on is off for this chat. Switch it on in the Tools menu to let the assistant run code.", + "unavailable": "The Sandbox add-on is installed but cannot run commands on this server.", + "unavailableWhy": "The Sandbox add-on cannot run commands on this server: {{reason}}", + "install": "Install the Sandbox", + "openSandbox": "Open the Sandbox add-on", + "askInstall": "Ask an administrator to install the Sandbox add-on.", + "askFix": "Ask an administrator to fix it.", + "hide": "Hide", + "hideLabel": "Hide this notice", + "chipTitle": "This chat cannot run code or commands. Select for details.", + "hostTitle": "Commands run on this machine", + "host": "Commands run on this machine, with its files and network, not isolated. Install the Sandbox for isolation.", + "hostOff": "The Sandbox is off for this chat, so commands run on this machine, with its files and network, not isolated. Switch the Sandbox on in this menu to isolate them.", + "hostUnavailable": "The Sandbox cannot run here, so commands run on this machine, with its files and network, not isolated.", + "hostUnavailableWhy": "The Sandbox cannot run here ({{reason}}), so commands run on this machine, with its files and network, not isolated.", + "hostChipTitle": "Commands in this chat run on this machine, not isolated. Select for details." + }, + "consent": { + "title": "Run this command on this machine?", + "body": "Commands run on this machine with no isolation. They can read files that LocalAI can read and reach the internet and your network. Install the Sandbox for isolation.", + "once": "Run once", + "always": "Always on this instance", + "deny": "Don't run", + "alwaysHint": "Always on this instance lets every user's chats run commands here without asking (Settings > Tools).", + "askAdmin": "Only an administrator can allow commands on this machine. Ask one, or run the command yourself.", + "expired": "Not answered: the turn that asked has ended, so the command did not run." + }, + "provider": { + "sandbox": "Sandbox", + "addon": "Add-on {{name}}", + "host": "This machine, not isolated", + "hostOnce": "This machine, allowed once", + "hostDenied": "Not run: refused", + "hostTimeout": "Not run: no answer", + "hostAdmin": "Not run: needs an administrator" + }, + "shell": { + "machine": "This machine", + "isolated": "Commands run isolated" + } + }, + "mcpPage": { + "title": "MCP connectors", + "supporting": "Connect MCP servers to give your chats and agents more tools, such as search or your own APIs.", + "family": "Bots for Telegram or Slack are messaging connectors.", + "related": "Related", + "chats": "Chats", + "add": "Add connector", + "learn": "How MCP works", + "loading": "Loading your connectors", + "loadFailed": "Could not load your connectors.", + "forbidden": "MCP is not available to your account.", + "empty": { + "title": "No connectors yet", + "body": "A connector links an MCP server to LocalAI. Your chats and agents can then use its tools, for example to search your docs or read a calendar.", + "fact1": "Any MCP server over http or https", + "fact2": "Tokens and headers stored encrypted", + "fact3": "Test it before chats use it" + }, + "toolbar": { + "search": "Search connectors", + "testAll": "Test all", + "testingAll": "Testing", + "testedAll": "Tested the connectors that are on." + }, + "filter": { + "label": "Show", + "all": "All", + "on": "On", + "attention": "Needs attention", + "off": "Off", + "showAll": "Show all", + "none": { + "all": "No connectors.", + "on": "No connector is on.", + "attention": "Nothing needs your attention.", + "off": "Every connector is on." + } + }, + "noMatch": { + "title": "No connector matches “{{query}}”", + "body": "Search by name or by address.", + "clear": "Clear search" + }, + "state": { + "label": "Status: {{state}}", + "ok": "Connected", + "auth": "Needs sign-in", + "error": "Can't connect", + "unchecked": "Not tested", + "off": "Off" + }, + "row": { + "open": "{{name}}, {{state}}. Show details", + "toggle": "Use {{name}} in chats", + "menu": "Actions for {{name}}", + "offLine": "Off. Chats and agents do not use it.", + "notChecked": "Not tested yet. Test it to see its tools.", + "noTools": "Connected, but the server offers no tools.", + "tools_one": "{{count}} tool", + "tools_other": "{{count}} tools", + "checked": "Tested {{when}}", + "tokenSaved": "Token saved", + "headersSaved": "Headers saved", + "envSaved": "Variables saved" + }, + "actions": { + "details": "Details", + "test": "Test connection", + "testNamed": "Test the connection to {{name}}", + "testing": "Testing", + "edit": "Edit", + "reauth": "Replace token", + "remove": "Remove" + }, + "details": { + "use": "Use in chats", + "useHelp": "Chats without an agent use every connector that is on. A saved agent uses only the ones you pick for it.", + "lastCheck": "Last test", + "never": "Not tested in this browser yet.", + "okAt": "Connected {{when}}.", + "testedAt": "Tested {{when}}.", + "errorTitle": "The last test could not connect.", + "authTitle": "The server refused the sign-in.", + "tools": "Tools", + "toolsUnknown": "Test the connection to see the tools.", + "toolsNone": "The server offers no tools.", + "toolsSearch": "Search tools", + "noToolMatch": "No tool matches “{{query}}”.", + "noDescription": "No description.", + "connection": "Connection", + "kind": "Kind", + "address": "Address", + "command": "Command", + "added": "Added", + "queryHidden": "The part of the address after ? is hidden, because it can hold a key.", + "signIn": "Sign-in", + "token": "Bearer token", + "headers": "Headers", + "env": "Variables", + "saved": "Saved", + "none": "None", + "encrypted": "Stored encrypted on the server. Never shown again." + }, + "sheet": { + "addTitle": "Add connector", + "editTitle": "Edit {{name}}", + "addDesc": "Connect an MCP server. You test it before chats use it.", + "editDesc": "Saved secrets stay unless you replace or remove them.", + "testDesc": "LocalAI connects with what you saved and lists the tools.", + "close": "Close", + "kind": "Kind", + "remote": "Remote server", + "remoteHelp": "An MCP server this machine reaches over http or https.", + "local": "Local command", + "localHelp": "A program that LocalAI starts on this machine. Administrators only.", + "basics": "Connection", + "name": "Name", + "nameHelp": "Shown in the Tools menu of a chat.", + "url": "Address", + "urlHelp": "Servers on a private network are refused unless an administrator allows them.", + "command": "Command", + "commandHelp": "The program to start, without a shell.", + "args": "Arguments", + "argsHelp": "One argument per line.", + "signIn": "Sign-in", + "signInHelp": "Only if the server asks for a key.", + "envIntro": "Variables the command gets, for example an API key.", + "token": "Bearer token", + "tokenHelp": "Sent as an Authorization header.", + "tokenSaved": "A token is saved. Type a new one to replace it.", + "tokenSavedShort": "Saved", + "clearToken": "Remove the saved token", + "headers": "Headers", + "headersHelp": "Other headers the server needs, such as an API key header.", + "headersSaved": "Headers are saved. New rows replace all of them.", + "clearHeaders": "Remove the saved headers", + "headerName": "Header name", + "headerValue": "Value", + "addHeader": "Add a header", + "env": "Environment variables", + "envSaved": "Variables are saved. New rows replace all of them.", + "clearEnv": "Remove the saved variables", + "envName": "Variable", + "addEnv": "Add a variable", + "removeRow": "Remove row {{n}}", + "showValue": "Show the value", + "hideValue": "Hide the value", + "encrypted": "Tokens, headers and variables are stored encrypted on the server and are never shown again.", + "cancel": "Cancel", + "saving": "Saving", + "saveAndTest": "Save and test", + "savedOn": "Saved. It is on.", + "savedOff": "Saved. It stays off until you turn it on.", + "testing": "Connecting to {{name}}…", + "okTitle": "Connected.", + "okBody_one": "{{name}} offers {{count}} tool.", + "okBody_other": "{{name}} offers {{count}} tools.", + "okNoTools": "The server offers no tools.", + "failTitle": "Could not connect.", + "failHint": "Check the address, then test again.", + "failAuth": "Check the token or the headers.", + "back": "Edit details", + "retry": "Test again", + "done": "Done", + "keepOff": "Keep it off", + "turnOn": "Turn on", + "discardTitle": "Discard your changes?", + "discardBody": "What you typed in this form is lost.", + "discard": "Discard", + "keepEditing": "Keep editing" + }, + "errors": { + "nameRequired": "Give it a name.", + "nameTaken": "Another connector has this name.", + "urlRequired": "Enter the address of the server.", + "urlInvalid": "This is not a valid address.", + "urlScheme": "The address must start with http:// or https://.", + "urlCredentials": "Put credentials in the token or a header, not in the address.", + "commandRequired": "Enter the command to start." + }, + "remove": { + "title": "Remove {{name}}?", + "body": "The connector is deleted with its saved token, headers and variables. A saved agent that uses it can no longer call its tools.", + "confirm": "Remove", + "pending": "Removing" + }, + "toasts": { + "removed": "Removed “{{name}}”.", + "on": "“{{name}}” is on.", + "off": "“{{name}}” is off." + }, + "import": { + "title": "Servers kept in this browser", + "text": "The old chat page kept these MCP servers in this browser. Import them to keep them on the server, where every browser and session can use them. They are made switched off. Their tokens and headers go to the server and are then removed from this browser.", + "button": "Import {{count}} servers from this browser", + "buttonOne": "Import 1 server from this browser", + "busy": "Importing", + "headersCount": "{{count}} headers", + "done": "Imported {{count}}. They are switched off: test and turn them on below.", + "failed": "{{name}} was not imported: {{error}}", + "left": "The servers that were not imported stay in this browser." + } + }, + "proposal": { + "loading": "Reading the proposed trigger...", + "unread": "The proposed trigger could not be read: {{message}}", + "removed": "This trigger was removed.", + "retry": "Try again", + "inactive": "Proposed trigger. It runs nothing until you create it.", + "name": "Name", + "schedule": "When (for example daily 08:00)", + "folder": "Folder to watch", + "saveEdit": "Save", + "cancelEdit": "Cancel", + "create": "Create trigger", + "edit": "Edit", + "dismiss": "Dismiss", + "receipt": { + "scheduled": "Scheduled:", + "watching": "Watching:", + "next": "Next run {{when}}", + "view": "View trigger", + "undo": "Undo", + "undone": "Undone:", + "again": "Schedule it again", + "declined": "Declined:", + "restore": "Restore", + "blocked": { + "ran": "It already ran once; remove it from the Triggers page.", + "edited": "You edited it since; change or remove it on the Triggers page." + } + }, + "announce": { + "accepted": "Scheduled {{name}}.", + "undone": "Undid {{name}}. It is not scheduled.", + "declined": "Declined {{name}}.", + "reopened": "{{name}} waits for your answer again." + }, + "plan": { + "whenLabel": "When", + "runsAsLabel": "Runs as", + "readsLabel": "Reads", + "writesLabel": "Writes", + "costLabel": "Cost", + "stopLabel": "To stop it", + "when": "{{schedule}}, {{zone}}. First run {{when}}.", + "whenNoFirst": "{{schedule}}, {{zone}}.", + "whenFile": "When a file arrives in {{folder}}.", + "runsAs": "You", + "runsAsModel": "You, with {{model}}", + "writesWidget": ", shown in the \"{{widget}}\" widget on Home", + "costLocal_one": "About {{tokens}} tokens a run on a local model. At most {{count}} run a day.", + "costLocal_other": "About {{tokens}} tokens a run on a local model. At most {{count}} runs a day.", + "costRemote_one": "About {{tokens}} tokens a run on {{model}}, a remote service. At most {{count}} run a day.", + "costRemote_other": "About {{tokens}} tokens a run on {{model}}, a remote service. At most {{count}} runs a day.", + "costUnknown_one": "The tokens of a run are known after the first run. At most {{count}} run a day.", + "costUnknown_other": "The tokens of a run are known after the first run. At most {{count}} runs a day.", + "remoteNote": "{{model}} is a remote service: what a run reads leaves this server.", + "stopWidget": "Pause it from the widget, or delete it in Triggers", + "stop": "Switch it off or delete it in Triggers" + } + }, + "subAgents": { + "label": "Sub-agents", + "title": "The sub-agents this chat started", + "heading": "{{running}} running, at most {{limit}} in the background", + "running": "Running", + "untitled": "Sub-agent", + "show": "Show", + "hide": "Hide", + "stop": "Stop", + "loading": "Reading...", + "empty": "Nothing yet.", + "task": "Task", + "answer": "Answer" + }, + "vision": { + "describedBy": "This model can't look at images. Pick another model, or the image will be described by {{model}}.", + "none": "This model can't look at images, and no model here can describe them. Pick a model that can look at images." + }, + "egress": { + "label": "Network", + "preset": { + "off": "Off", + "registries": "Package registries", + "all": "All public sites" + }, + "presetHint": { + "off": "Commands in the Sandbox reach no network.", + "registries": "Commands reach the package registries only (PyPI, npm, Go, crates.io, RubyGems, Maven Central, GitHub releases).", + "all": "Commands reach every public site." + }, + "limited": "Limited by your administrator", + "limitedBy": "Your administrator allows at most: {{limit}}.", + "hostNetwork": "Commands run on this machine and use its network. Network limits need the Sandbox add-on.", + "panel": "Rules and requests ({{count}})", + "rules": "Rules of this chat", + "cards": "Requests of this chat", + "ruleAllow": "Allowed for this chat", + "ruleDeny": "Denied for this chat", + "aboveLimit": "above the limit, does not apply", + "remove": "Remove", + "removeLabel": "Remove the rule of {{host}}", + "card": { + "pending": "Waiting for an answer", + "allowedOnce": "Allowed once", + "allowedChat": "Allowed for this chat", + "denied": "Denied", + "expired": "Not answered", + "refused": "Refused: the server's network limit does not allow it" + }, + "consent": { + "title": "Network access", + "body": "A command in this chat wants to reach", + "once": "Allow once", + "chat": "Allow for this chat", + "deny": "Deny", + "preset": "This chat: {{preset}}. Limit set by your administrator: {{limit}}.", + "adminOnly": "This host is inside your network: only an administrator can allow it.", + "answered": "{{state}} by {{who}}, {{time}}", + "answeredNoWho": "{{state}}, {{time}}", + "you": "you" + }, + "blocked": { + "titleOff": "Network access is off", + "titleCapped": "Network access is limited", + "bodyOff": "Network access is off for the Sandbox on this server. A command asked for", + "bodyCapped": "This server only allows {{limit}} for the Sandbox network. A command asked for", + "adminOff": "An administrator can turn it on in the settings of {{name}}.", + "adminCapped": "An administrator can raise the limit in the settings of {{name}}.", + "adminLink": "Open the settings of {{name}}", + "askAdmin": "Ask an administrator of this server to change it.", + "rowOff": "Off: the administrator has not allowed network access.", + "runner": "the add-on that runs commands" + }, + "allowedHosts": "Always allowed by your administrator:", + "allowedHostsOff": "They apply when the network is on.", + "allowedHostsNote": "{{count}} hosts are always allowed by your administrator.", + "limitNote": "This chat’s network. The administrator sets the most a chat can reach.", + "asksAbove": "This chat asks for {{choice}}; the administrator limit is {{limit}}." + }, + "change": { + "loading": "Reading the trigger change...", + "unread": "The trigger change could not be read: {{message}}", + "removed": "This change is no longer kept.", + "none": "none", + "on": "On", + "off": "Off (paused)", + "becomes": "becomes", + "neverRan": "It has not run yet", + "lastRun": "Last run", + "lastRunValue": "{{status}}, {{when}}", + "runStatus": { + "queued": "Queued", + "running": "Running", + "done": "Done", + "failed": "Failed", + "skipped": "Skipped" + }, + "updateTitle": "Change {{name}}", + "deleteTitle": "Delete {{name}}?", + "updateHelp": "Proposed change. Nothing changes until you apply it.", + "deleteHelp": "Nothing is deleted until you press Delete. You can undo it for a day.", + "stale": "The trigger was changed or removed after this was proposed, so it cannot apply. Ask for the change again.", + "apply": "Apply", + "delete": "Delete", + "keep": "Keep", + "field": { + "name": "Name", + "prompt": "Prompt", + "when": "When", + "time_zone": "Time zone", + "daily_cap": "Daily limit", + "enabled": "State", + "reads": "Reads", + "writes": "Writes", + "widget": "Home widget", + "estimated_tokens": "Tokens a run", + "agent": "Agent", + "model": "Model" + }, + "receipt": { + "update": "Changed:", + "pause": "Paused:", + "resume": "Resumed:", + "delete": "Deleted:", + "fields": "What changed: {{fields}}", + "dismissed": "Change dismissed:", + "kept": "Kept:", + "undone": "Undone:", + "restored": "Restored:" + }, + "blocked": { + "expired": "The time to undo it is over; change the trigger on the Triggers page.", + "edited": "The trigger was changed since; change it on the Triggers page.", + "removed": "The trigger was removed since.", + "full": "You have as many triggers as you may; remove one on the Triggers page first." + }, + "announce": { + "applied": "Changed {{name}}.", + "deleted": "Deleted {{name}}.", + "dismissed": "Dismissed the change to {{name}}.", + "kept": "Kept {{name}}.", + "undone": "Undid the change to {{name}}.", + "restored": "Restored {{name}}." + } + }, + "loops": { + "region": "Loops of this chat", + "unit": { + "seconds_one": "{{count}} second", + "seconds_other": "{{count}} seconds", + "minutes_one": "{{count}} minute", + "minutes_other": "{{count}} minutes", + "hours_one": "{{count}} hour", + "hours_other": "{{count}} hours" + }, + "unitShort": { + "seconds_one": "{{count}} s", + "seconds_other": "{{count}} s", + "minutes_one": "{{count}} min", + "minutes_other": "{{count}} min", + "hours_one": "{{count}} h", + "hours_other": "{{count}} h" + }, + "confirm": { + "every": "Run this every {{every}} until you stop it?", + "selfPaced": "Run this again each time the model asks for it, until you stop it?", + "start": "Start", + "cancel": "Cancel" + }, + "chip": { + "name": "Loop", + "every": "every {{every}}", + "selfPaced": "self-paced", + "run_one": "run {{count}}", + "run_other": "run {{count}}", + "paused": "paused", + "running": "running now", + "next": "next at {{time}}", + "ended": "ended", + "wait": "Waiting for: {{wait}}" + }, + "actions": { + "pause": "Pause", + "resume": "Resume", + "stop": "Stop", + "dismiss": "Dismiss" + }, + "state": { + "pending": "waits for your yes", + "active": "active", + "paused": "paused", + "ended": "ended" + }, + "list": { + "title": "Loops of this chat", + "close": "Close", + "empty": "This chat has no loops.", + "hint": "To stop one, type /loop stop and the code at the start of its line." + }, + "slash": { + "usage": "Type /loop, an interval if you want one, and what to repeat. For example: /loop 5m check the build.", + "notFound": "No loop of this chat matches {{id}}.", + "noChat": "Send a first message, then start a loop in this chat.", + "stopped_one": "Stopped {{count}} loop.", + "stopped_other": "Stopped {{count}} loops." + } + } +} diff --git a/core/http/react-ui/public/locales/en/collections.json b/core/http/react-ui/public/locales/en/collections.json index 1442584ec..9a6a4ac20 100644 --- a/core/http/react-ui/public/locales/en/collections.json +++ b/core/http/react-ui/public/locales/en/collections.json @@ -4,14 +4,14 @@ "newPlaceholder": "New collection name...", "actions": { "create": "Create", - "creating": "Creating...", "details": "Details", "reset": "Reset", "delete": "Delete", "viewDetails": "View details", "resetCollection": "Remove every entry, keep the collection", "deleteCollection": "Delete collection", - "newCollection": "New collection" + "newCollection": "New collection", + "cancel": "Cancel" }, "sections": { "yourCollections": "Your collections", @@ -23,7 +23,8 @@ "stepCreate": "Create a collection.", "stepFill": "Add files or a web page to it.", "stepAsk": "Ask it a question to see what comes back, then give it to an agent.", - "noPersonal": "You have no collections yet." + "noPersonal": "You have no collections yet.", + "learn": "How memory works" }, "deleteDialog": { "title": "Delete Collection", @@ -36,15 +37,12 @@ "confirm": "Remove entries" }, "toasts": { - "loadFailed": "Failed to load collections: {{message}}", "created": "Collection \"{{name}}\" created", "createFailed": "Failed to create collection: {{message}}", "deleted": "Collection \"{{name}}\" deleted", "deleteFailed": "Failed to delete collection: {{message}}", "reset": "Collection \"{{name}}\" reset", "resetFailed": "Failed to reset collection: {{message}}", - "entriesFailed": "Failed to load entries: {{message}}", - "sourcesFailed": "Failed to load sources: {{message}}", "uploaded": "{{file}} added", "sourceAdded": "Source added", "sourceFailed": "Failed to add source: {{message}}", @@ -80,15 +78,13 @@ "url": "A web page or feed", "interval": "Refresh every", "intervalPlaceholder": "minutes", - "addUrl": "Add URL", - "adding": "Adding..." + "addUrl": "Add URL" }, "ask": { "title": "Try a question", "label": "Ask this collection a question", "placeholder": "Ask a question", "search": "Search", - "searching": "Searching...", "alone": "Runs on this collection alone. No chat, model or agent is involved.", "tune": "Tune the search", "max": "Passages to return", @@ -103,12 +99,12 @@ "updated": "last updated {{when}}", "never": "not fetched yet", "remove": "Remove {{url}}", - "removeTitle": "Remove source" + "removeTitle": "Remove source", + "loadFailed": "Could not load the web sources." }, "entries": { "title": "Files", "none": "No files yet. Add a file or a web page to fill this collection.", - "failed": "Part of this collection could not be read.", "indexing": "Indexing...", "uploadFailed": "Upload failed", "retry": "Retry", @@ -121,7 +117,8 @@ "close": "Close", "empty": "(empty)", "noContent": "No content available.", - "adding": "Adding files" + "adding": "Adding files", + "loadFailed": "Could not load the files." }, "removeSource": { "title": "Remove Source", @@ -138,5 +135,16 @@ "index": "Files are split into passages and indexed when they are added. A question is matched to passages by similarity, and each passage comes back with a score.", "privacy": "Files you upload stay on this server. A web source is fetched from the address you give, on the interval you set." }, - "loading": "Loading..." + "loadFailed": "Could not load your collections.", + "starting": { + "title": "Memory is starting.", + "text": "The server is still getting the memory service ready. This page asks again by itself." + }, + "embedding": { + "missingTitle": "No embedding model installed.", + "missingText": "Collections use {{model}} to index files and to search them. Until it is installed, adding files and searching fail.", + "noneSet": "No embedding model is set for collections (collections_embedding_model), so adding files and searching fail.", + "install": "Find it in Models", + "askAdmin": "Ask an administrator to install it." + } } diff --git a/core/http/react-ui/public/locales/en/common.json b/core/http/react-ui/public/locales/en/common.json index d3b62341b..2a2770ce3 100644 --- a/core/http/react-ui/public/locales/en/common.json +++ b/core/http/react-ui/public/locales/en/common.json @@ -112,26 +112,22 @@ "gigabytes": "GB", "terabytes": "TB" }, - "updateReview": { - "title": "Review updates", - "description": "Nothing is upgraded until you choose here.", - "close": "Close", - "done": "Done", - "selectAll": "Select all", - "select": "Select {{name}}", - "unknownVersion": "unknown", - "latest": "latest", - "blocked": "Cannot be upgraded here:", - "upgradeSelected": "Upgrade selected ({{count}})", - "upgradeAll": "Upgrade all ({{count}})", - "running": "Upgrading...", - "queued": "Waiting", - "statusRunning": "Upgrading", - "statusDone": "Upgraded", - "statusFailed": "Failed", - "summary": "{{succeeded}} upgraded, {{failed}} failed.", - "empty": "Nothing to upgrade.", - "statusStarted": "Started", - "summaryStarted": "{{succeeded}} started, {{failed}} failed." + "ui": { + "tryAgain": "Try again", + "undo": "Undo", + "loadError": { + "title": "Could not load this.", + "serverSaid": "The server answered “{{message}}”.", + "nothingChanged": "Nothing was changed." + } + }, + "chip": { + "running": "Running", + "starting": "Starting", + "stopped": "Stopped", + "failed": "Failed", + "updateAvailable": "Update available", + "ready": "Ready", + "noRunsYet": "No runs yet" } } diff --git a/core/http/react-ui/public/locales/en/home.json b/core/http/react-ui/public/locales/en/home.json index 75a949802..f64f93983 100644 --- a/core/http/react-ui/public/locales/en/home.json +++ b/core/http/react-ui/public/locales/en/home.json @@ -26,7 +26,10 @@ "description": "Install models, switch backends, edit configs and check status by talking to LocalAI.", "open": "Open assistant", "tooltip": "Manage LocalAI by chatting", - "dismiss": "Dismiss" + "dismiss": "Dismiss", + "noModel": "Pick a chat model first.", + "startFailed": "Could not start the assistant chat: {{message}}", + "chatTitle": "LocalAI Assistant" }, "input": { "placeholder": "Message...", @@ -88,14 +91,18 @@ "step2Title": "Install a Model", "step2Body": "Click install to download and configure it automatically.", "step3Title": "Start Chatting", - "step3Body": "Chat with your model right from the browser or use the API." + "step3Body": "Chat with your model right from the browser or use the API.", + "sandboxTitle": "Run code safely (optional)", + "sandboxBody": "Install the Sandbox add-on so the model runs code and commands in isolation. Without it, commands run directly on this machine, with full network access and no isolation." }, "browseGallery": "Browse Model Gallery", "importModel": "Import Model", "docs": "Docs", "noModelsTitle": "No Models Available", "noModelsBody": "There are no models installed yet. Ask your administrator to set up models so you can start chatting.", - "guideLabel": "Getting started" + "guideLabel": "Getting started", + "sandboxInstall": "Install Sandbox", + "sandboxSkip": "Skip" }, "starters": { "title": "Recommended for your hardware", @@ -148,7 +155,8 @@ "emptyBody": "They are grouped by day, with the model that answered. Move with j and k, resume with Enter.", "hasImage": "Includes an image", "noModel": "No model", - "examples": "Slash actions" + "examples": "Slash actions", + "allChats": "All chats" }, "picker": { "loading": "Loading models", @@ -183,8 +191,8 @@ "desc": "Start an empty conversation" }, "chat": { - "label": "Open Chat", - "desc": "Go to the chat page" + "label": "All chats", + "desc": "Find, rename and delete your chats" }, "assistant": { "label": "Manage by chat", @@ -217,7 +225,24 @@ "docs": { "label": "Documentation", "desc": "Open localai.io in a new tab" - } + }, + "agents": { + "label": "Agents", + "desc": "Your saved agents, triggers and skills" + }, + "mcp": { + "label": "MCP connectors", + "desc": "MCP servers your chats can use" + }, + "memory": { + "label": "Memory", + "desc": "Collections your chats can search" + }, + "studioInstall": { + "label": "Install Studio", + "desc": "Studio makes images, audio and video. It is an add-on" + }, + "complete": "complete" }, "strip": { "label": "Memory", @@ -247,7 +272,13 @@ "stagingFailedBody": "The model could not be sent to a node. Nothing else was changed.", "stagingFailedBodyNode": "The model could not be sent to {{node}}. Nothing else was changed.", "openActivity": "Open Activity", - "details": "Details" + "details": "Details", + "addonsOffTitle": "Add-ons are off on this server", + "addonsOffAdmin": "{{name}} is an add-on. Start LocalAI with add-ons on, then install it from Add-ons.", + "addonsOffReader": "{{name}} is an add-on. Ask an administrator to turn add-ons on.", + "thisPage": "That page", + "addonsDocs": "How to turn add-ons on", + "dismiss": "Dismiss" }, "library": { "label": "Library" diff --git a/core/http/react-ui/public/locales/en/library.json b/core/http/react-ui/public/locales/en/library.json index 2a520c151..9bfd3cdb1 100644 --- a/core/http/react-ui/public/locales/en/library.json +++ b/core/http/react-ui/public/locales/en/library.json @@ -14,7 +14,8 @@ "notUsed": { "lead": "Not used yet.", "skill": "It stays in the library until you add it somewhere. Try it first, or add it to an agent.", - "collection": "It stays in the library until you add it somewhere. Search it here, or add it to an agent." + "collection": "It stays in the library until you add it somewhere. Search it here, or add it to an agent.", + "skillShort": "It stays in the list until you add it to an agent." }, "tryInContext": "Try it in a context", "add": { @@ -30,10 +31,11 @@ "estimate": "Estimates use characters divided by four.", "toolsMode": "Loaded when the model asks for it", "tokens": "+{{count}} tokens per message (estimate)", - "collectionNote": "An agent reads the collection that has its own name.", + "collectionNote": "An agent can search the collections on its list.", "kbOn": "Turns its knowledge base on", "noAgentNamed": "No agent is named {{name}}. Create an agent with that name to use this collection.", - "close": "Close" + "close": "Close", + "skillNote": "An agent can read the skills on its list." }, "toasts": { "skillAdded": "{{name}} added to {{agent}}", @@ -86,7 +88,7 @@ "skillTokens": "{{count}} tokens", "skillTokensTools": "0 up front", "memoryTitle": "Memory that would be sent", - "memoryNone": "This agent has no knowledge base. Turn it on in the agent to use a collection.", + "memoryNone": "No collection is on this agent's list. Add one to the agent to use it.", "memoryNoCollection": "This agent's knowledge base is on, but no collection named {{name}} exists yet.", "memoryTools": "This agent searches only when the model asks. Nothing is added to the message up front.", "memoryTesting": "Collection {{name}}, only for this test", diff --git a/core/http/react-ui/public/locales/en/media.json b/core/http/react-ui/public/locales/en/media.json index e2426fc02..2ea22ee34 100644 --- a/core/http/react-ui/public/locales/en/media.json +++ b/core/http/react-ui/public/locales/en/media.json @@ -605,7 +605,7 @@ "api": { "title": "API usage and compatible models", "summary": "Use this voice outside the browser or check model support", - "body": "Send the profile’s stable voice URI with each speech request. LocalAI resolves the private reference audio and transcript at generation time; model YAML is not modified.", + "body": "Send the profile’s stable voice URI with each speech request. LocalAI resolves the private reference audio and transcript at generation time; the settings of your models are not changed.", "compatibleModels": "Compatible installed models", "noInstalledModels": "None yet. Install one of the server-verified models above before sending this request.", "curlExample": "cURL example", @@ -624,7 +624,7 @@ }, "deleteDialog": { "title": "Delete this voice?", - "message": "“{{name}}” and its reference audio will be permanently removed. Existing model YAML is not changed.", + "message": "“{{name}}” and its reference audio will be permanently removed. Models set up with this voice are not changed.", "deleting": "Deleting…" }, "toasts": { diff --git a/core/http/react-ui/public/locales/en/modelEditor.json b/core/http/react-ui/public/locales/en/modelEditor.json index 38d4c4801..f7569a3ba 100644 --- a/core/http/react-ui/public/locales/en/modelEditor.json +++ b/core/http/react-ui/public/locales/en/modelEditor.json @@ -34,5 +34,9 @@ "title": "No fields configured", "text": "Use the search bar above to find and add configuration fields." } + }, + "proxyVision": { + "label": "This model can look at images", + "hint": "Turn this on when the upstream model accepts images. LocalAI cannot check it. Attached images then go to the model with the message." } } diff --git a/core/http/react-ui/public/locales/en/models.json b/core/http/react-ui/public/locales/en/models.json index df2db5439..b6c14dd58 100644 --- a/core/http/react-ui/public/locales/en/models.json +++ b/core/http/react-ui/public/locales/en/models.json @@ -1,5 +1,6 @@ { "lifecycle": { + "supporting": "Find a model that fits this machine, install it, and look after the ones you have.", "title": "Models", "navLabel": "Model lifecycle", "views": { "explore": "Explore", "installed": "Installed" }, @@ -92,13 +93,17 @@ "installed": "Installed" }, "actions": { - "addModel": "Add Model", - "importModel": "Import Model", + "addModel": "Add model", + "importModel": "Import model", "install": "Install", "reinstall": "Reinstall", "delete": "Delete" }, "filters": { + "sheetDone": "Show models", + "sheetTitle": "Filters", + "sheetButtonCount": "Filters ({{count}})", + "sheetButton": "Filters", "motion": "Motion Capture", "forecast": "Forecasting", "all": "All", diff --git a/core/http/react-ui/public/locales/en/nav.json b/core/http/react-ui/public/locales/en/nav.json index 0e74e971b..e15869fed 100644 --- a/core/http/react-ui/public/locales/en/nav.json +++ b/core/http/react-ui/public/locales/en/nav.json @@ -10,12 +10,13 @@ "changeLanguage": "Change language", "logout": "Logout", "accountSettings": "Account settings", + "yourSettings": "Your settings", "account": "Account", "accountFor": "Account: {{name}}", "sections": { "recognition": "Recognition", "build": "Build", - "operate": "Operate", + "operate": "Administration", "workspace": "Workspace" }, "operate": { @@ -66,7 +67,14 @@ "trafficModels": "Models", "gpuHost": "GPU and host", "prometheus": "Prometheus", - "addons": "Add-ons" + "addons": "Add-ons", + "chats": "Chats", + "files": "Files", + "triggers": "Triggers", + "mcpConnectors": "MCP connectors", + "chatsAdmin": "Chats of all users", + "pages": "Pages", + "allChats": "All chats" }, "footer": { "github": "GitHub", @@ -90,16 +98,23 @@ "overviewEmpty": "No build tools are available to your account.", "toolsLabel": "Build tools", "descriptions": { - "agents": "Create and run agents that use your models and tools.", - "skills": "Reusable instructions that agents can load on demand.", + "agents": "Saved agents: instructions and the tools, add-ons, collections and skills a chat may use.", + "skills": "Reusable instructions that a chat can load when a task matches.", "memory": "Document collections that agents can search.", - "jobs": "Scheduled and one-off tasks that agents run for you.", "fineTune": "Train a model on your own data.", "quantize": "Shrink a model to run on smaller hardware.", "import": "Add a model from a file, a URL or a Hugging Face repository.", "voices": "Identify speakers and keep a library of voice profiles.", "faces": "Detect, compare and match faces.", - "addons": "Install, turn on and open add-ons." - } - } + "addons": "Install, turn on and open add-ons.", + "triggers": "Start a chat by itself, on a schedule or when a file arrives." + }, + "operateSupporting": "What runs on this server, how it is used, and how it is set up." + }, + "pinnedPages": "Pinned pages", + "new": "New", + "recentChats": "Recent chats", + "newChat": "New chat", + "untitledChat": "Untitled chat", + "chatRunning": "Working" } diff --git a/core/http/react-ui/public/locales/en/operate.json b/core/http/react-ui/public/locales/en/operate.json index 54333bb0a..01715e855 100644 --- a/core/http/react-ui/public/locales/en/operate.json +++ b/core/http/react-ui/public/locales/en/operate.json @@ -16,7 +16,7 @@ "looking_other": "Running, with {{count}} things to look at.", "firstRun": "Nothing is running yet." }, - "firstRunBody": "Operate shows health once a model is loaded. Start with a backend: it is the program that runs models.", + "firstRunBody": "Administration shows health once a model is loaded. Start with a backend: it is the program that runs models.", "firstRunBackend": "Install a backend", "firstRunModels": "Browse models", "rows": { @@ -62,7 +62,8 @@ "nodeMeter": "{{name}}: {{used}} of {{total}} in use", "openMachine": "Open This machine", "openNodes": "Open the swarm", - "reclaimer": "The memory reclaimer is on." + "reclaimer": "The memory reclaimer is on.", + "diskChip": "Disk almost full" }, "running": { "none": "Nothing is running.", @@ -95,7 +96,7 @@ "titleRam": "System memory in use", "titleClusterGpu": "GPU memory in use, all nodes", "titleClusterRam": "System memory in use, all nodes", - "scope": "Since you opened Operate. LocalAI keeps no memory history.", + "scope": "Since you opened Administration. LocalAI keeps no memory history.", "meterLabel": "{{used}} of {{total}} in use, {{percent}} percent", "of": "of {{total}}", "hint": "Readings over {{span}}. Hover the chart or use the arrow keys.", @@ -105,7 +106,7 @@ "capacity": "capacity {{value}}", "gbTick": "{{value}} GB", "now": "{{value}} now", - "waiting": "The chart starts with the second reading, about 15 seconds after you open this page. It shows only readings taken while Operate is open.", + "waiting": "The chart starts with the second reading, about 15 seconds after you open this page. It shows only readings taken while Administration is open.", "unavailable": "No memory reading is available, so no chart is drawn.", "dataTable": "Data table", "time": "Time", @@ -278,7 +279,8 @@ "upgradeVersions": "{{from}} to {{to}}", "rowUpgraded": "Upgraded in {{duration}}", "rowUpgradedPlain": "Upgraded", - "viewInAddons": "View in Add-ons" + "viewInAddons": "View in Add-ons", + "upgradeWaiting": "Waiting for the add-on service to start. Next check in {{seconds}} s." }, "backends": { "title": "Backend Management", @@ -304,17 +306,9 @@ "updates": "Updates", "offline": "Offline nodes" }, - "variants": "Variants", - "variantsCount": "Variants ({{count}})", - "development": "Development", - "developmentCount": "Development ({{count}})", "update": "Update", "updating": "Updating...", - "updateAll": "Update all ({{count}})", "updateLabel": "Update {{name}}", - "updateTo": "Update to v{{version}}", - "updateToTitle": "Update to {{version}}", - "latest": "latest", "updateStarted": "Updating {{name}}...", "updateFailed": "Failed to update: {{message}}", "updateAllFailed": "Update failed for {{name}}: {{message}}", @@ -325,7 +319,6 @@ "checkFound_other": "{{count}} backends have updates", "checkNone": "Every installed backend is current", "checkFailed": "Could not check for updates: {{message}}", - "fromUrl": "From URL", "manualTitle": "Install a backend from a URL", "manualUri": "OCI image, URL or path", "manualName": "Name (required for an OCI image)", @@ -397,7 +390,6 @@ "installedLoadFailed": "Failed to load installed backends: {{message}}", "installedAria": "Installed backends", "catalogAria": "Backend catalog", - "shown": "{{shown}} of {{total}}", "prevPage": "Previous page", "nextPage": "Next page", "showDetails": "Show details for {{name}}", @@ -458,11 +450,35 @@ "updated": "{{name}} updated from its library.", "updateFailed": "The update failed. {{message}}" }, - "reviewButton": "Updates ({{count}})", - "reviewTitle": "Review backend updates", - "reviewDescription": "Nothing is upgraded until you choose here. Models that use an upgraded backend restart.", - "reviewDone": "Update started for {{name}}.", - "reviewFailed": "Update failed for {{name}}: {{message}}" + "updateAllTitle": "Update all backends", + "updateAllIntro": "Choose which backends to update. Each one is updated on its own, one after another. A failure does not stop the others.", + "updateSelected": "Update selected", + "loadFailedTitle": "Could not load the backend catalog.", + "filtersButton": "Filters", + "filtersDone": "Done", + "filterGroups": { + "state": "State", + "type": "Type", + "show": "Show", + "library": "Library" + }, + "showVariants": "Variant builds", + "showDevelopment": "Development builds", + "allLibraries": "All libraries", + "moreActions": "More backend actions", + "installFromUrl": "Install from URL…", + "manageLibraries": "Manage libraries…", + "count_one": "{{count}} backend", + "count_other": "{{count}} backends", + "countOf": "{{shown}} of {{total}} backends", + "activeFilters": "Active filters", + "removeFilter": "Remove filter: {{name}}", + "checking": "Checking for updates…", + "updatesAvailable_one": "{{count}} update available", + "updatesAvailable_other": "{{count}} updates available", + "reviewUpdates": "Review", + "close": "Close", + "installedLoadFailedTitle": "Could not load the installed backends." }, "logs": { "title": "Backend logs", diff --git a/core/http/react-ui/public/locales/en/pages.json b/core/http/react-ui/public/locales/en/pages.json new file mode 100644 index 000000000..b9840f164 --- /dev/null +++ b/core/http/react-ui/public/locales/en/pages.json @@ -0,0 +1,185 @@ +{ + "title": "Pages", + "subtitle": "Small tools the assistant made for you. They open here, in a sandbox, and keep their data in your files.", + "loading": "Loading your pages", + "error": "The pages could not be read: {{message}}", + "retry": "Try again", + "off": "Pages are not available: {{reason}}", + "justNow": "just now", + "card": { + "changed": "Changed {{when}}", + "author": { + "you": "by you", + "assistant": "by the assistant" + }, + "open": "Open", + "pin": "Pin to sidebar", + "unpin": "Unpin from sidebar", + "pinFailed": "The pin was not saved: {{message}}", + "ask": "Ask the assistant to change this", + "list": "Your pages", + "changedBy": { + "you": "Changed {{when}} by you", + "assistant": "Changed {{when}} by the assistant" + }, + "menu": "More for {{title}}", + "files": "Show its files", + "rename": "Rename", + "renameLabel": "New folder name", + "renameHint": "Lowercase letters, digits and dashes.", + "renameSave": "Save", + "renameCancel": "Cancel", + "renameFailed": "The page was not renamed: {{message}}", + "delete": "Delete page", + "deleteTitle": "Delete \"{{title}}\"?", + "deleteMessage": "The folder {{folder}} goes to the Trash. You can restore it from Files.", + "deleted": "{{title}} is in the Trash.", + "deleteFailed": "The page was not deleted: {{message}}" + }, + "empty": { + "title": "No pages yet", + "text": "A page is a small tool, like a task board or a reading list. Ask the assistant for one in a chat, or start from a ready page.", + "starter": "Start from a task board", + "starting": "Copying the page", + "starterFailed": "The page was not copied: {{message}}", + "ask": "Ask for a page", + "askPrompt": "Make me a page that ", + "starterReading": "Start from a reading list", + "note": "This entry leaves the sidebar when you have no pages. It comes back with your next one.", + "learn": "How pages work" + }, + "view": { + "runsAsYou": "Runs as you", + "runsAsYouHint": "The page reads and writes the files of its folder with your rights. It cannot reach the network or your other files.", + "updated": { + "you": "updated {{when}} by you", + "assistant": "updated {{when}} by the assistant" + }, + "reload": "Reload", + "ask": "Ask the assistant to change this", + "askPrompt": "Change the page in pages/{{name}}/ so that ", + "sandbox": "Sandboxed frame, own folder only", + "frameTitle": "Page: {{title}}", + "loading": "Loading the page", + "notFound": "There is no page called {{name}}.", + "failed": "The page could not be loaded: {{message}}", + "back": "All pages", + "navigated": "The page tried to open another address and was stopped. Reload to start it again.", + "untrusted": "This page comes from a shared folder. It does not run until you allow it." + }, + "send": { + "label": "This page wants to send a message to the assistant", + "to": "Send to", + "pageChat": "This page's chat", + "send": "Send", + "dismiss": "Dismiss", + "sending": "Sending", + "sent": "Sent to {{title}}", + "openChat": "Open chat", + "failed": "Not sent: {{message}}" + }, + "askNew": "Ask for a new page", + "yours": "Yours", + "filter": "Filter pages", + "shared": { + "yours": "Yours", + "group": "Shared with you", + "note": "A shared page runs only after you allow it. If its author changes it, you are asked again.", + "offNotice": "Shared pages are off on this server. An administrator turned them off.", + "off": "Shared pages are off on this server. An administrator turned them off.", + "gone": "This page is no longer in the shared folder, or you no longer have access to it.", + "review": "Review", + "from": "From {{author}}", + "menu": "Page menu", + "badge": { + "notAllowed": "Not allowed yet", + "changed": "Changed since you allowed it", + "allowed": "Allowed" + } + }, + "viewer": { + "stopAllowing": "Stop allowing this page", + "authorChanged": "{{author}} changed this page. The version you allowed keeps running until you review the change.", + "sandboxShared": "Sandboxed. Runs as you, in {{folder}} only, and cannot reach the network." + }, + "trust": { + "title": "Open \"{{title}}\"?", + "desc": "This page comes from a shared folder. Pages run as you, so check it before it runs.", + "who": "Written by {{author}}", + "whereLabel": "Shared folder", + "whereWhen": ", changed {{when}}", + "someone": "someone", + "close": "Close", + "canTitle": "It can", + "canStillTitle": "It can still", + "cannotTitle": "It cannot", + "items": { + "files": "Read and write files in its own folder", + "readOnly": "You can only read this shared folder, so the page cannot save its changes.", + "chat": "Write a message into a chat", + "chatNote": "You see the message in the chat before the assistant answers it.", + "noFiles": "Read your other files or chats", + "noNet": "Reach the network or other sites", + "noNetNote": "One limit: it can load another address in its own frame. That sends one request out, and then the page stops.", + "noTools": "Use tools or change your settings" + }, + "warn": "It acts as you inside its folder. If {{author}} changes it, you are asked again.", + "viewSource": "View source", + "cancel": "Cancel", + "allowOnce": "Allow once", + "allow": "Allow", + "changed": { + "title": "\"{{title}}\" has changed", + "desc": "You allowed an earlier version. Check what changed before it runs again.", + "who": "Changed by {{author}} {{when}}", + "allowed": "You allowed the version of {{date}}", + "what": "What changed", + "same": "What it can do is the same as before.", + "warn": "If you allow it, this version runs each time you open it, until {{author}} changes it again.", + "view": "View changes", + "lines": "{{before}} → {{after}} lines", + "code": "A script, style sheet or module of the page changed: {{files}}" + }, + "pill": { + "notYet": "Not allowed yet", + "not": "Not allowed", + "once": "Allowed for this visit", + "allowed": "Allowed", + "changed": "Changed since you allowed it" + }, + "from": "From {{author}}", + "frame": { + "chrome": "Shared page from {{folder}}. It has not run." + }, + "blocked": { + "title": "This page has not run", + "body": "You chose not to run it. It comes from {{author}}'s shared folder {{folder}}. Nothing in it has run, and nothing was saved.", + "review": "Review and allow" + }, + "waiting": { + "title": "Waiting for you", + "body": "Nothing in this page runs until you allow it." + }, + "stopped": { + "revoked": "You stopped allowing this page. It stopped running and nothing else in it runs until you allow it again.", + "refused": "This page is no longer allowed, so it was stopped. Review it to allow it again.", + "off": "Shared pages were turned off on this server, so this page was stopped." + }, + "changedMeanwhile": "The page changed while you were reviewing it. This is the new version.", + "sourceTitle": "Source of \"{{title}}\"", + "sourceLines_one": "{{count}} line", + "sourceLines_other": "{{count}} lines", + "sourceNote": "The source is shown as text. Nothing in it runs here." + }, + "frame": { + "filesFailed": "Files next to this page did not load: {{files}}. The browser console says why.", + "tooLarge": "This page puts more than {{limit}} of files from its folder into itself, so some were left out. Load data with fetch() instead.", + "dismiss": "Dismiss" + }, + "link": { + "label": "The page wants to open a link", + "open": "Open {{site}}", + "cancel": "Cancel", + "lookalike": "This address uses letters from other alphabets and reads as {{display}}. It may imitate another site: check it before you open it." + } +} diff --git a/core/http/react-ui/public/locales/en/skills.json b/core/http/react-ui/public/locales/en/skills.json index c70b34666..df6824543 100644 --- a/core/http/react-ui/public/locales/en/skills.json +++ b/core/http/react-ui/public/locales/en/skills.json @@ -1,85 +1,45 @@ { "title": "Skills", - "subtitle": "Your library of instructions. Anything here can be tried, edited and added to an agent when you want it used.", + "subtitle": "Instructions all your chats can use when a task matches. Agents use the ones you add to them.", "unavailable": { - "subtitle": "Skills service is not available or the index is rebuilding. Try again in a moment.", - "retry": "Retry" + "subtitle": "Skills need Files, and Files are off on this server." }, "actions": { "newSkill": "New skill", "createSkill": "Create skill", - "import": "Import", - "importing": "Importing...", - "gitRepos": "Git repositories", "edit": "Edit", - "delete": "Delete", - "export": "Export", - "sync": "Sync", - "addRepo": "Add repo", - "adding": "Adding...", - "remove": "Remove", - "enable": "Enable", - "disable": "Disable" + "delete": "Delete" }, "search": { "placeholder": "Search skills", "label": "Search skills" }, - "git": { - "title": "Git repositories", - "description": "Add Git repositories to pull skills from. Skills will appear in the list after sync.", - "urlPlaceholder": "https://github.com/user/repo or git@github.com:user/repo.git", - "noRepos": "No Git repos configured. Add one above.", - "disabled": "Disabled", - "removeRepo": "Remove repo", - "urlLabel": "Repository URL" - }, "card": { "noDescription": "No description", "readOnly": "Read-only", "editTitle": "Edit skill", "deleteTitle": "Delete skill", - "exportTitle": "Export as .tar.gz" + "invalid": "Skipped: not a valid skill", + "invalidWhy": "This skill was skipped: {{reason}}", + "shadowed": "Hidden by a {{layer}} skill", + "shadowedLong": "Hidden by a {{layer}} skill of the same name", + "providedBy": "Provided by {{name}}", + "replacedBy": "Replaced by {{name}}", + "notActive": "Not active: the built-in skill serves", + "notActiveBy": "Not active: {{name}} provides it", + "fallback": "In use because the {{name}} add-on's own skill is off or not running" }, "empty": { "title": "Skills are instructions an agent can follow", "text": "Write one once, then add it to the agents that should follow it. A skill that nobody uses stays in the library.", - "stepWrite": "Write a skill, or import one from a .tar.gz file or a Git repository.", - "stepTry": "Try it: pick a context and see what it would add to a prompt.", - "stepAdd": "Add it to an agent when you want it used.", - "noPersonal": "You have no skills yet." - }, - "sections": { - "yourSkills": "Your Skills", - "otherUsersSkills": "Other users' skills" - }, - "deleteDialog": { - "title": "Delete Skill", - "message": "Delete skill \"{{name}}\"? This action cannot be undone.", - "confirm": "Delete" - }, - "removeRepoDialog": { - "title": "Remove Git Repository", - "message": "Remove this Git repository? Skills from it will no longer be available.", - "confirm": "Remove" + "stepWrite": "Write a skill: a name, a line that says when to use it, and the instructions.", + "stepTry": "Check it in the list: it shows who uses it and what it says.", + "stepAdd": "Add it to an agent when you want it used." }, "toasts": { "loadFailed": "Failed to load skills", "deleted": "Skill \"{{name}}\" deleted", - "deleteFailed": "Failed to delete skill", - "exported": "Skill \"{{name}}\" exported", - "exportFailed": "Export failed", - "imported": "Skill imported from \"{{file}}\"", - "importFailed": "Import failed", - "loadReposFailed": "Failed to load Git repos", - "repoAdded": "Git repo added and syncing", - "addRepoFailed": "Failed to add repo", - "synced": "Repo synced", - "syncFailed": "Sync failed", - "toggled": "Repo toggled", - "toggleFailed": "Toggle failed", - "removed": "Repo removed", - "removeFailed": "Remove failed" + "deleteFailed": "Failed to delete skill" }, "filters": { "label": "Show", @@ -97,27 +57,50 @@ "tabs": "Skill sections", "tab": { "overview": "Overview", - "instructions": "Instructions", - "files": "Files" + "instructions": "Instructions" }, - "description": "Description", - "allowedTools": "Allowed tools", - "compatibility": "Compatibility", - "license": "License", - "licenseFact": "License {{license}}", "tokens": "About {{count}} tokens", "tokensTitle": "Estimate: characters divided by four", - "tokensExplain": "In prompt mode an agent that uses this skill carries about {{count}} tokens in every message. This is an estimate: characters divided by four.", + "tokensExplain": "A chat reads a skill only when a task matches its description, so it adds nothing until then. About {{count}} tokens when read (estimate).", "noContent": "This skill has no instructions.", - "filesLoading": "Loading files...", - "filesFailed": "The files could not be listed.", - "filesNone": "This skill has no scripts, references or assets.", - "files": { - "scripts": "Scripts", - "references": "References", - "assets": "Assets" - }, "startsWith": "Starts with", - "showAll": "Show all instructions" + "showAll": "Show all instructions", + "loading": "Loading...", + "loadFailed": "The skill could not be read." + }, + "layers": { + "label": "Layer", + "personal": "Personal", + "shared": "Shared", + "builtin": "Built-in", + "folder": "Workspace", + "addon": "Add-on" + }, + "edit": { + "back": "Back to skills", + "titleNew": "New skill", + "titleEdit": "Edit: {{name}}", + "basic": "Basic information", + "content": "Content", + "layer": "Layer", + "layerHelp": "Personal is yours alone. Shared is read by every user.", + "name": "Name (lowercase, hyphens only)", + "nameLocked": "Name cannot be changed after creation.", + "description": "Description (when to use it)", + "descriptionHelp": "A chat reads this line to decide whether the skill fits the task.", + "body": "Skill content (markdown)", + "cancel": "Cancel", + "save": "Save changes", + "create": "Create skill", + "saving": "Saving...", + "created": "Skill created", + "updated": "Skill updated", + "required": "A name and a description are required", + "saveFailed": "Save failed", + "loadFailed": "Failed to load the skill", + "readOnly": "This skill is read-only here. A built-in skill comes with the server, and a skill from your files is changed in Files." + }, + "load": { + "title": "Could not load your skills." } } diff --git a/core/http/react-ui/public/locales/en/swarm.json b/core/http/react-ui/public/locales/en/swarm.json index ed3fc549c..82cdf52eb 100644 --- a/core/http/react-ui/public/locales/en/swarm.json +++ b/core/http/react-ui/public/locales/en/swarm.json @@ -71,7 +71,8 @@ "noGroup": "No grouping", "groupType": "Group by type", "groupLabel": "Label: {{key}}", - "stale": "The node list could not be refreshed. What you see is the last reading." + "stale": "The node list could not be refreshed. What you see is the last reading.", + "retry": "Try again" }, "first": { "title": "No workers registered yet", diff --git a/core/http/react-ui/public/locales/en/traffic.json b/core/http/react-ui/public/locales/en/traffic.json index b9618a1fc..5e2260067 100644 --- a/core/http/react-ui/public/locales/en/traffic.json +++ b/core/http/react-ui/public/locales/en/traffic.json @@ -48,7 +48,8 @@ "emptyChat": "Open Chat", "emptyApi": "Scrape the metrics", "scrape": "Scrape the same numbers into your own Prometheus or Grafana from /metrics.", - "scrapeHow": "How" + "scrapeHow": "How", + "usageErrorTitle": "Usage could not be read." }, "figures": { "requests": "Requests", @@ -166,7 +167,8 @@ "traces": "Backend operations", "logs": "Backend logs", "page": "Model page", - "bufferNote": "Operations and errors count the {{count}} operations the buffer still holds, not all time." + "bufferNote": "Operations and errors count the {{count}} operations the buffer still holds, not all time.", + "noMatch": "No model matches “{{query}}”." }, "host": { "title": "GPU and host", @@ -205,7 +207,8 @@ "registering": "Registering", "unknown": "Unknown" }, - "lostNodes": "A node that stops answering keeps its last reading and is marked in the State column. A node that reports no memory shows no reading." + "lostNodes": "A node that stops answering keeps its last reading and is marked in the State column. A node that reports no memory shows no reading.", + "errorTitle": "The host could not be read." }, "traces": { "title": "Traces", @@ -535,5 +538,6 @@ "config": "Scrape config", "example": "Example query", "exampleNote": "The 95th percentile of request duration over 5 minutes, across all routes." - } + }, + "clearSearch": "Clear search" } diff --git a/core/http/react-ui/public/locales/en/upgrade.json b/core/http/react-ui/public/locales/en/upgrade.json new file mode 100644 index 000000000..5b493fb80 --- /dev/null +++ b/core/http/react-ui/public/locales/en/upgrade.json @@ -0,0 +1,46 @@ +{ + "badge": { + "available": "Update available", + "availableVersion": "Update available: {{version}}" + }, + "trigger": { + "all": "Upgrade all ({{count}})", + "running": "Upgrading {{current}} of {{total}}", + "summary": "{{done}} upgraded, {{failed}} failed, {{skipped}} skipped", + "summaryOk": "{{done}} upgraded", + "review": "Review" + }, + "dialog": { + "title": "Upgrade all", + "intro": "Choose what to upgrade. Each one is upgraded on its own, one after another. A failure does not stop the others.", + "selection": "Select what to upgrade", + "selectAll": "Select all", + "selectNone": "Select none", + "item": "Upgrade {{name}}", + "versions": "{{from}} to {{to}}", + "blocked": "Cannot start", + "start": "Upgrade selected", + "starting": "Upgrading...", + "retry": "Retry failed ({{count}})", + "again": "New selection", + "close": "Close", + "closeRunning": "Close (keeps running)", + "runningNote": "Closing this window does not stop the upgrades.", + "progress": "Upgrading {{current}} of {{total}}", + "summary": "{{done}} upgraded, {{failed}} failed, {{skipped}} skipped", + "empty": "Nothing to upgrade.", + "selectedCount_one": "{{count}} selected", + "selectedCount_other": "{{count}} selected" + }, + "item": { + "queued": "Queued", + "upgrading": "Upgrading", + "done": "Done", + "failed": "Failed", + "skipped": "Skipped" + }, + "reason": { + "checkAgain": "Check again", + "checking": "Checking..." + } +} diff --git a/core/http/react-ui/public/locales/en/workspace.json b/core/http/react-ui/public/locales/en/workspace.json new file mode 100644 index 000000000..b09c398d9 --- /dev/null +++ b/core/http/react-ui/public/locales/en/workspace.json @@ -0,0 +1,692 @@ +{ + "title": "Files", + "home": "Home", + "breadcrumb": "Folder path", + "upload": "Upload", + "newFolder": "New folder", + "folderName": "Folder name", + "download": "Download", + "rename": "Rename", + "delete": "Move to Trash", + "dismiss": "Dismiss", + "tabs": { + "label": "Views", + "files": "Files", + "trash": "Trash", + "history": "History", + "shared": "Shared", + "trashCount_one": "{{count}} item in the Trash", + "trashCount_other": "{{count}} items in the Trash" + }, + "trash": { + "empty": "The Trash is empty.", + "restore": "Restore", + "emptyAll": "Empty Trash", + "purge": "Delete permanently", + "purgeNamed": "Delete {{name}} permanently", + "files_one": "{{count}} file", + "files_other": "{{count}} files", + "folders_one": "{{count}} folder", + "folders_other": "{{count}} folders", + "listJoin": ", ", + "summary": "In the Trash: {{what}}.", + "removed": "Deleted permanently: {{what}}.", + "confirmAll": { + "title": "Empty the Trash?", + "message": "This permanently deletes {{what}}, with their earlier versions. It cannot be undone." + }, + "confirmOne": { + "title": "Delete permanently?", + "message": "{{path}}: this permanently deletes {{what}}, with earlier versions. It cannot be undone." + } + }, + "history": { + "empty": "No changes yet.", + "undo": "Undo", + "agent": "chat" + }, + "search": { + "label": "Search your files", + "placeholder": "Filter this folder", + "none": "Nothing found.", + "resultsFor": "Files that contain “{{query}}”", + "back": "Back to the folder", + "noneBody": "No name or text in your files matches. Try other words.", + "page": "page {{n}}", + "sheet": "sheet “{{name}}”", + "slide": "slide {{n}}", + "indexing_one": "{{count}} document is still being indexed: search again in a moment to include it.", + "indexing_other": "{{count}} documents are still being indexed: search again in a moment to include them.", + "limited": "Results limited: the search stopped at its limits before it looked at every file. Search in a folder or use other words to see the rest.", + "filtering_one": "{{count}} item in this folder matches “{{query}}”.", + "filtering_other": "{{count}} items in this folder match “{{query}}”.", + "everywhere": "Search all files for “{{query}}”", + "everywhereShort": "Search all files", + "noMatch": "Nothing in this folder matches “{{query}}”.", + "clear": "Clear the filter" + }, + "preview": { + "label": "Preview", + "failed": "This file could not be shown.", + "loading": "Loading the preview of {{name}}", + "failedTitle": "Could not show {{name}}.", + "tooLarge": "This file is too large to preview here. Download it to open it.", + "none": "There is no preview for this type of file.", + "typeFile": "{{ext}} file", + "typeUnknown": "File", + "sheets": "Sheets", + "sheetLabel": "Sheet {{name}}", + "sheetEmpty": "This sheet is empty.", + "row": "Row", + "showingRows": "Showing {{rows}} of {{total}} rows", + "showingCols": "and {{cols}} of {{total}} columns.", + "downloadRest": "Download the file for the rest.", + "textOnly": "Text preview, formatting not shown.", + "truncated": "The text is cut short.", + "noText": "This file has no text to show.", + "slide": "Slide {{n}}", + "notes": "Speaker notes", + "pdfTitle": "PDF {{name}}", + "pdfFallback": "If the PDF does not show here, download it.", + "htmlTitle": "Preview of {{name}}", + "imageFailed": "The image could not be shown.", + "zoomIn": "Show at full size", + "zoomOut": "Fit to the pane", + "items_one": "{{count}} item", + "items_other": "{{count}} items", + "textCut": "Showing the first {{size}}.", + "actions": "File actions" + }, + "wellKnown": { + "revert": "Revert to previous version", + "view": "View", + "form": "Form", + "save": "Save", + "cancel": "Cancel", + "notSet": "Not set", + "moveUp": "Move up", + "moveDown": "Move down", + "remove": "Remove", + "savedAt": "Saved at {{time}}", + "notSaved": "Not saved yet.", + "notSavedPrefilled": "Not saved yet. Some values are filled in for you; Save keeps them.", + "loadFailed": "Could not load these settings. Reload the page to try again.", + "invalid": "These settings have errors, and there is no earlier valid version.", + "invalidFallback": "These settings have errors. This shows their last valid version.", + "conflict": "These settings changed while you were editing them, probably by the assistant or in another tab. The newer values are:", + "useTheirs": "Use the newer values", + "keepMine": "Keep my changes", + "editWithAssistant": "Edit with the assistant", + "from": { + "account": "From your account", + "browser": "From your browser" + }, + "check": { + "required": "Required.", + "tooLong": "At most {{max}} characters.", + "format": "This value has the wrong format." + }, + "source": { + "label": "Source", + "hint": "The settings as text. They are checked in the same way when you save.", + "notJson": "The text has a syntax error. Check the quotes, commas and braces.", + "notObject": "The text must start with { and end with }." + }, + "persona": { + "title": "Assistant", + "lede": "Name and instructions the assistant uses in every chat." + }, + "user": { + "title": "You", + "lede": "The assistant reads this to know who it talks to. It reaches the model in every chat." + }, + "ui": { + "title": "Appearance", + "lede": "How LocalAI looks for you. Changes show at once; Save keeps them." + }, + "shortcuts": { + "title": "Shortcuts", + "lede": "The prompts above the composer of every chat.", + "edit": "Edit shortcuts", + "defaults": "Default shortcuts", + "none": "No shortcuts. Add one, or save an empty list to hide them.", + "label": "Label", + "prompt": "Prompt", + "send": "Send at once", + "add": "Add shortcut", + "invalid": "Some shortcuts have errors.", + "assistantPrompt": "Change my shortcuts: " + }, + "home": { + "editLayout": "Edit layout", + "ask": "Ask the assistant", + "askPrompt": "Change my Home page: ", + "add": "Add widget", + "newTitle": "New widget", + "noWidgets": "No widgets yet.", + "type": "Type", + "title": "Title", + "source": "Data file", + "noSourceNeeded": "No data file needed", + "types": { + "list": "List", + "metric": "Metric", + "chats": "Last chats", + "page": "Page" + }, + "left": "{{count}} left", + "fromChats": "from chats", + "noChats": "No chats yet.", + "noData": "No data yet. The assistant can write", + "nothing": "Nothing here.", + "noSource": "This widget has no data file. Pick one in Edit layout.", + "broken": "The data of this widget has errors, and there is no earlier valid version.", + "stale": "Showing the last valid data. The newest data has errors.", + "updated": "Updated {{when}}", + "footer": { + "noData": "No data yet.", + "invalid": "The data has an error. Showing the last good version.", + "revert": "Revert", + "missing": "The data of this widget is missing.", + "fix": "Ask the assistant to fix it", + "fixPrompt": "The Home widget reads {{file}}, which is missing or broken. Fix it: ", + "waiting": "Waiting for you to create its trigger in the chat.", + "openChat": "Open the chat", + "running": "Updating now…", + "openRun": "Open the run", + "paused": "Paused · was {{schedule}}", + "resume": "Resume", + "failedEmpty": "The update failed and there is no data yet.", + "details": "Details", + "retry": "Retry", + "stale": "Last update failed. Showing {{when}}'s data.", + "staleToday": "Last update failed. Showing earlier data from today.", + "earlier": "earlier", + "firstRun": "First run {{when}}", + "firstRunFile": "Runs when a file arrives", + "runNow": "Run now", + "fresh": "{{schedule}} · updated {{time}}", + "pause": "Pause", + "fromPage": "From the page {{title}}", + "fromFile": "From your files", + "next": "Next run {{when}}", + "cost": "about {{tokens}} tokens a run", + "costRemote": "about {{tokens}} tokens a run on a remote model", + "cadence": { + "paused": "Paused", + "hourly": "Hourly", + "daily": "Daily", + "weekdays": "Weekdays", + "weekly": "Weekly", + "monthly": "Monthly", + "file": "On new files" + }, + "scheduleNote_one": "{{count}} widget updates on a schedule. Its runs use your models and count toward your usage.", + "scheduleNote_other": "{{count}} widgets update on a schedule. Their runs use your models and count toward your usage." + }, + "opensSite": "{{host}}: opens in a new tab", + "linkAsk": "This widget wants to open a link", + "invalid": "Your Home layout has errors, and there is no earlier valid version.", + "invalidFallback": "Your Home layout has errors. Home shows its last valid version.", + "page": { + "gone": "This page is no longer available", + "goneHint": "The page \"{{name}}\" was moved or deleted, or you can no longer open it.", + "noWidget": "This page has no widget view", + "noWidgetHint": "Ask the assistant to give the page \"{{name}}\" a compact view for Home.", + "off": "Shared pages are off", + "offHint": "An administrator turned shared pages off on this server.", + "fix": "Customise Home", + "failed": "The page did not load", + "retry": "Retry", + "changed": "This page changed since you allowed it", + "notAllowed": "You have not allowed this page yet", + "allowHint": "It comes from a shared folder. Review it before it runs as you.", + "allow": "Allow this page", + "frameTitle": "{{title}} (page on Home)", + "refresh": "Refresh", + "open": "Open page", + "add": "Add a page", + "limit": "Home shows at most {{max}} pages; remove one to add another.", + "noneEligible": "No page has a compact view for Home yet. Ask the assistant to add one to a page.", + "pick": "Page", + "unavailable": "{{name}} (not available)", + "sharedLabel": "shared", + "sizes": { + "small": "Small", + "medium": "Medium", + "large": "Large" + } + } + }, + "fields": { + "persona": { + "name": { + "label": "Name", + "help": "What the assistant calls itself." + }, + "system_prompt": { + "label": "System prompt", + "help": "Added to every chat, after the chat's own prompt and the agent's instructions." + } + }, + "user": { + "name": { + "label": "Name", + "help": "How the assistant addresses you." + }, + "language": { + "label": "Language", + "help": "The language the assistant answers in." + }, + "bio": { + "label": "About you", + "help": "A few lines the assistant reads in every chat." + }, + "timezone": { + "label": "Time zone", + "help": "For dates and times in answers." + } + }, + "ui": { + "theme": { + "label": "Theme", + "help": "System follows the setting of your device.", + "options": { + "dark": "Dark", + "light": "Light", + "system": "System" + } + }, + "accent": { + "label": "Accent colour", + "help": "For buttons and links. Empty keeps the default." + }, + "density": { + "label": "Density", + "help": "Compact shows more on one screen.", + "options": { + "comfortable": "Comfortable", + "compact": "Compact" + } + }, + "defaultModel": { + "label": "Default model", + "help": "The model a new chat starts with." + } + } + }, + "saveInvalid": "Some values are not valid. Check them and save again.", + "saveTooLarge": "These settings are too large to save. Remove some entries and try again.", + "saveFailed": "Could not save. Try again.", + "details": "Details" + }, + "discovery": { + "ex": { + "title": "Make LocalAI yours", + "lede": "Say it in a chat. The assistant saves it, and you can change or undo it.", + "hide": "Hide these", + "hiddenToast": "Examples hidden.", + "saveFailed": "Not saved: {{message}}", + "card1": { + "kicker": "You and the assistant", + "prompt": "Call me {{firstName}} and keep answers short", + "promptNoName": "Remember my name and keep answers short", + "what": "Saves your name and how the assistant answers" + }, + "card2": { + "kicker": "A page", + "prompt": "Make me a task board", + "what": "Adds a page you open from the sidebar" + }, + "card3": { + "kicker": "Every morning", + "prompt": "Every morning, summarise my inbox here", + "promptNoMail": "Every morning, list yesterday's chats here", + "what": "Adds a Home widget and a trigger you confirm" + }, + "quote": "“{{text}}”", + "donePrefix": "Done.", + "doneLink": "Change it in Account", + "ready": "Or start from a ready page:", + "readyTasks": "Task board", + "readyReading": "Reading list", + "readyFailed": "The page was not copied: {{message}}", + "learn": "How this works", + "inComposer": "Edit it if you like, then press Enter. Nothing is saved until you send it.", + "live": "Prompt added to the message box." + }, + "home": { + "customise": "Customise Home" + }, + "action": { + "undo": "Undo", + "openAccount": "Open in Account", + "openPage": "Open page", + "openHome": "Open Home", + "turnBackOn": "Turn them back on in Account" + }, + "change": { + "profilePersona": "Saved your name and your instructions for the assistant. Every chat uses them from your next message.", + "profile": "Saved to your profile.", + "persona": "Saved the assistant’s instructions. Every chat uses them from your next message.", + "shortcuts": "Saved your shortcuts.", + "home": "Saved your Home page. Home shows the change.", + "ui": "Saved your appearance settings.", + "pageCreated": "Created the page {{title}}.", + "pageChanged": "Changed the page {{title}}.", + "offersOff": "Suggestions from the assistant are off.", + "undoing": "Undoing…", + "undone": "Undone.", + "undoFailed": "Not undone: {{message}}" + }, + "toast": { + "shortcutAdded": "Added to your composer: {{label}}", + "pageCreated": "{{title}} is in Pages, in the sidebar", + "open": "Open", + "homeChanged": "Home updated.", + "themeChanged": "Theme changed." + }, + "files": { + "learn": "These files change how LocalAI works for you.", + "tag": { + "profile": "Your profile", + "persona": "Assistant", + "shortcuts": "Shortcuts", + "home": "Home layout", + "ui": "Look", + "page": "Page" + } + }, + "privacy": "The assistant receives your name, this text, your language and time zone at the start of every chat. Leave a field empty to keep it out.", + "versions": { + "label": "Versions", + "title": "Earlier versions", + "loading": "Reading the history…", + "none": "No earlier version yet.", + "current": "current", + "invalid": "has an error", + "byAssistant": "By the assistant", + "byAssistantIn": "By the assistant in \"{{chat}}\"", + "byYou": "By you", + "undo": "An undo", + "upload": "Uploaded", + "revert": "Revert", + "note": "Revert keeps the newer version in this list.", + "invalidBadge": "Invalid", + "revertGood": "Revert to the last good version" + }, + "help": { + "title": "Help and suggestions", + "sub": "Ways LocalAI shows you what it can do. Turn off what you do not need.", + "offers": "Offers from the assistant", + "offersHint": "At most one a day, never during a task: for example, to remember something you told it.", + "new": "Notes about new things", + "newHint": "A short \"New\" label in the sidebar the first time something appears.", + "examples": "Examples on Home", + "examplesShown": "The \"Make LocalAI yours\" cards show on Home while you have something left to try.", + "examplesHidden": "You hid the \"Make LocalAI yours\" cards.", + "showExamples": "Show the examples again", + "examplesBack": "The examples are back on Home.", + "dismissed": "What the assistant learned", + "dismissedHint_one": "You turned down {{count}} offer. It stays hidden until you reset it.", + "dismissedHint_other": "You turned down {{count}} offers. They stay hidden until you reset them.", + "forget": "Forget what the assistant learned about my hints", + "forgotten": "Done. The assistant may offer again.", + "learn": "Learn what you can change:", + "dismissedNone": "Nothing yet. When you answer an offer, the assistant remembers it here." + } + }, + "shared": { + "adminTitle": "Shared folders", + "adminLede": "Folders that several people use. Members open their pages, and their chats can read or edit the files.", + "userTitle": "Shared with you", + "userLede": "Folders an administrator shared with you. Their pages are in Pages, and your chats can use the files.", + "new": "New shared folder", + "nameLabel": "Folder name", + "nameHint": "Up to 64 letters, digits, spaces, dots, dashes or underscores. Start with a letter or a digit.", + "create": "Create", + "cancel": "Cancel", + "rename": "Rename", + "renameSave": "Save", + "remove": "Remove", + "manage": "Members", + "menu": "More actions for {{name}}", + "pages_one": "{{count}} page", + "pages_other": "{{count}} pages", + "pagesNone": "No pages", + "people_one": "{{count}} person", + "people_other": "{{count}} people", + "membersNone": "No members", + "everyone": "Everyone", + "everyoneOption": "Everyone (all users)", + "everyoneHint": "All users, also the ones who join later", + "everyoneCan": { + "read": "Everyone can read", + "write": "Everyone can edit" + }, + "mode": { + "read": "Can read", + "write": "Can edit" + }, + "addTitle": "Add a member", + "person": "Person", + "access": "Access", + "accessFor": "Access for {{name}}", + "add": "Add", + "allAdded": "Everyone is already a member.", + "usersUnavailable": "The user list could not be loaded. You can still add Everyone.", + "membersTitle": "Members", + "noMembers": "Nobody can use this folder yet. Add a person or Everyone.", + "removeMember": "Remove {{name}}", + "paneNote": "A change applies at once. A chat that is open sees it the next time it opens.", + "created": "Created “{{name}}”. Add the people who can use it.", + "removed": "Removed “{{name}}”. Its pages ask again before they run.", + "restored": "“{{name}}” is back, with its members.", + "memberAdded": "Added {{name}}: {{mode}}.", + "memberRemoved": "{{name}} can no longer use “{{folder}}”.", + "failed": "Not done: {{message}}", + "loadFailed": "Could not load the shared folders.", + "emptyAdminTitle": "No shared folders yet", + "emptyAdminBody": "A shared folder holds files and pages that other people can read or edit. Create one, then add members.", + "factMembers": "Give a person or everyone read or edit access", + "factChats": "Members' chats see the folder's files", + "factUndo": "Removing a folder can be undone", + "emptyUserTitle": "Nothing is shared with you yet", + "emptyUserBody": "When an administrator shares a folder with you, it shows here and its pages show in Pages." + }, + "managed": { + "group": "Managed by LocalAI", + "marker": "managed by LocalAI", + "about": { + "localai": "Settings LocalAI reads: persona, profile, shortcuts, Home, theme.", + "chats": "One folder per chat, with the files of the chat." + }, + "confirm": { + "deleteTitle": "Move {{name}} to the Trash?", + "renameTitle": "Rename {{name}}?", + "delete": "Move to Trash", + "rename": "Rename", + "restore": "You can restore it from the Trash.", + "renameNote": "LocalAI reads it only under this name, so after a rename it is as if it were gone." + }, + "loses": { + "settings": "LocalAI forgets your persona, your profile, your shortcuts, your Home layout and your theme.", + "chats": "Every chat loses its files: what you attached and what the assistant wrote there.", + "persona": "The assistant loses its name and its instructions.", + "user": "The assistant forgets what you told it about you, and your answers to its offers.", + "shortcuts": "The message box shows the default shortcuts again.", + "home": "Home loses its widgets.", + "ui": "Your theme, accent, density, default model and pinned pages go back to the defaults." + } + }, + "newFile": "New file", + "newFolderName": "New folder", + "newFileName": "Untitled.md", + "fileName": "File name", + "downloadOne": "Download {{name}}", + "moveTo": "Move to…", + "uploadMenu": "More upload options", + "uploadFiles": "Upload files", + "uploadFolder": "Upload a folder", + "loading": "Loading the folder", + "loadFailed": "Could not open {{folder}}.", + "types": { + "folder": "Folder", + "image": "Image", + "pdf": "PDF", + "sheet": "Spreadsheet", + "doc": "Document", + "slides": "Presentation", + "archive": "Archive", + "audio": "Audio", + "video": "Video", + "code": "Code", + "text": "Text", + "file": "File" + }, + "columns": { + "name": "Name", + "modified": "Modified", + "type": "Type", + "size": "Size", + "actions": "Actions" + }, + "view": { + "label": "View", + "list": "List", + "grid": "Thumbnails" + }, + "select": { + "one": "Select {{name}}", + "all": "Select all", + "bar": "Selected items", + "clear": "Clear the selection", + "count_one": "{{count}} selected", + "count_other": "{{count}} selected", + "start": "Select" + }, + "menu": { + "label": "Actions for {{name}}", + "more": "More actions for {{name}}", + "openFolder": "Open", + "preview": "Preview", + "downloadMany_one": "Download {{count}} file", + "downloadMany_other": "Download {{count}} files", + "moveMany_one": "Move {{count}} item to…", + "moveMany_other": "Move {{count}} items to…", + "deleteMany_one": "Move {{count}} item to Trash", + "deleteMany_other": "Move {{count}} items to Trash" + }, + "keys": { + "delete": "Del" + }, + "crumbs": { + "hidden": "Show the folders above" + }, + "naming": { + "empty": "Type a name.", + "slash": "A name cannot contain / or \\.", + "taken": "Something in this folder already has that name." + }, + "toast": { + "undo": "Undo", + "deletedOne": "{{name}} moved to the Trash.", + "deleted_one": "{{count}} item moved to the Trash.", + "deleted_other": "{{count}} items moved to the Trash.", + "restored": "Restored.", + "renamed": "Renamed to {{name}}.", + "renameUndone": "Name changed back.", + "movedOne": "{{name}} moved to {{folder}}.", + "moved_one": "{{count}} item moved to {{folder}}.", + "moved_other": "{{count}} items moved to {{folder}}.", + "moveUndone": "Moved back." + }, + "emptyHome": { + "title": "Your files live here" + }, + "emptyFolder": { + "title": "This folder is empty", + "body": "Drop files or folders anywhere on this page, paste them with Ctrl+V, or upload them from your computer. The assistant can also save files here from a chat.", + "bodyTouch": "Upload files from your device. The assistant can also save files here from a chat.", + "ask": "Ask the assistant" + }, + "drop": { + "title": "Drop to upload to {{folder}}", + "body": "Files and whole folders are welcome.", + "limit": "Files and whole folders, up to {{size}} each." + }, + "conflict": { + "title_one": "A file with this name already exists", + "title_other": "{{count}} files with these names already exist", + "body_one": "{{folder}} already has a file with this name.", + "body_other": "{{folder}} already has files with these names.", + "skip_one": "Skip it", + "skip_other": "Skip them", + "keep": "Keep both", + "replace_one": "Replace", + "replace_other": "Replace all", + "more_one": "and {{count}} more", + "more_other": "and {{count}} more", + "note": "Keep both gives the new file a new name. A replaced file can be brought back from History.", + "cancel": "Cancel the upload" + }, + "move": { + "title_one": "Move {{count}} item", + "title_other": "Move {{count}} items", + "items_one": "{{count}} item", + "items_other": "{{count}} items", + "cancel": "Cancel", + "here": "Move to {{folder}}", + "failed": "Could not read this folder.", + "noFolders": "No folders here. Move here, or go back up." + }, + "uploads": { + "waiting": "Waiting", + "progress": "{{loaded}} of {{total}}", + "rate": "{{rate}}/s", + "left": "{{time}} left", + "renamed": "Saved as {{name}}", + "done": "Uploaded", + "canceled": "Canceled", + "cancel": "Cancel", + "cancelOne": "Cancel the upload of {{name}}", + "retry": "Try again", + "retryOne": "Try {{name}} again", + "dismiss": "Dismiss", + "dismissOne": "Dismiss {{name}}", + "show": "Show", + "showOne": "Show {{name}}", + "meterOne": "{{name}}: {{percent}}%", + "meterAll": "All uploads: {{percent}}%", + "cancelAll": "Cancel all", + "retryAll": "Try all again", + "fold": "Hide the list", + "unfold": "Show the list", + "close": "Close", + "head": { + "sending_one": "Uploading {{count}} file", + "sending_other": "Uploading {{count}} files", + "failed_one": "{{count}} upload failed", + "failed_other": "{{count}} uploads failed", + "done_one": "{{count}} file uploaded", + "done_other": "{{count}} files uploaded" + }, + "say": { + "start_one": "Uploading {{count}} file.", + "start_other": "Uploading {{count}} files.", + "done_one": "Upload done: {{count}} file.", + "done_other": "Uploads done: {{count}} files.", + "doneFailed": "Uploads done: {{count}} uploaded, {{failed}} failed.", + "failed": "Could not upload {{name}}." + }, + "errors": { + "tooBig": "Larger than the {{limit}} limit of this server.", + "tooLarge": "The server refused it as too large.", + "off": "Files are off on this server.", + "invalid": "The server refused this file.", + "network": "The connection dropped.", + "server": "The server could not save it." + }, + "said": "The server said: {{reason}}." + } +} diff --git a/core/http/react-ui/public/locales/es/addons.json b/core/http/react-ui/public/locales/es/addons.json index ea6911969..9f7c964fd 100644 --- a/core/http/react-ui/public/locales/es/addons.json +++ b/core/http/react-ui/public/locales/es/addons.json @@ -3,9 +3,9 @@ "blocked": "Una acción anterior sobre este complemento aún no está confirmada. Comprueba su estado o descártala primero.", "checkStatus": "Comprobar estado", "dismiss": "Descartar", - "failed": "El propietario no completó la acción. No se cambió nada. Vuelve a intentarlo para iniciar un intento nuevo.", - "notApplied": "El propietario informó de la acción como completada, pero el complemento no cambió. Vuelve a intentarlo.", - "unknown": "El propietario aún no confirmó la acción. Aún puede aplicarse. Comprueba su estado o descártala antes de iniciar otra acción." + "failed": "La acción no terminó. No se cambió nada. Vuelve a intentarlo para iniciar un intento nuevo.", + "notApplied": "La acción se informó como hecha, pero el complemento no cambió. Vuelve a intentarlo para iniciar un intento nuevo.", + "unknown": "La acción aún no está confirmada. Aún puede aplicarse. Comprueba su estado o descártala antes de iniciar otra acción." }, "approval": { "title": "Se necesita aprobación", @@ -37,14 +37,16 @@ "emptyTitle": "Aún no hay apps", "firstRun": { "browse": "Browse add-ons", - "title": "Welcome to Apps", - "lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.", - "featuredLabel": "Featured add-ons", + "title": "Aún no hay complementos", + "lead": "Los complementos dan a LocalAI nuevas páginas y herramientas de chat. Se ejecutan por separado y solo piden lo que declaran.", + "featuredLabel": "Empieza con uno de estos", "featuredFailed": "The list of add-ons could not be read.", - "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons." + "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons.", + "factChecked": "Se comprueban con tus fuentes antes de ejecutarse", + "factRemove": "Apágalos o quítalos cuando quieras" }, "headless": "Se ejecuta en segundo plano y no tiene página propia.", - "listLabel": "Apps", + "listLabel": "Complementos instalados", "loading": "Cargando apps", "manage": "Gestionar", "open": "Abrir", @@ -56,10 +58,14 @@ "tools_one": "{{count}} herramienta", "tools_other": "{{count}} herramientas", "version": "Versión {{version}}", - "working": "Esperando la confirmación del propietario.", + "working": "Esperando la confirmación.", "noMatchTitle": "No add-on matches", - "noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.", - "clearSearch": "Clear search" + "noMatchBody": "Nada instalado coincide con «{{query}}». Prueba con otras palabras.", + "clearSearch": "Clear search", + "loadError": { + "title": "Los complementos no están disponibles ahora.", + "paused": "Las páginas y herramientas de los complementos quedan en pausa hasta que vuelva." + } }, "browse": { "confirmTitle": "¿Instalar {{name}}?", @@ -69,7 +75,7 @@ "fromLibrary": "de {{library}}", "install": "Instalar", "installedDetail": "Versión {{version}}, generación {{generation}}", - "installedOther": "Instalado {{version}} (otra versión)", + "installedOther": "Tienes {{version}}", "installedSame": "Instalado {{version}}", "installedState": "Instalado", "installing": "Instalando", @@ -85,7 +91,8 @@ "backendFailed": "The backend was not installed.", "alias": "Alias", "kindLabel": "Kind", - "installedOn": "{{name}} is installed and on." + "installedOn": "{{name}} is installed and on.", + "installedChip": "Instalado" }, "catalog": { "allLibraries": "Todas las bibliotecas", @@ -115,8 +122,8 @@ "pin": "Pin {{name}}", "pinLabel": "Pin", "pinned": "Pinned", - "running": "Activado", - "search": "Search add-ons", + "running": "En ejecución", + "search": "Buscar complementos instalados", "status": { "failed": "failed", "starting": "starting" @@ -129,7 +136,7 @@ "denied": "El servidor rechazó la solicitud. Puede que no tengas permiso o que el complemento haya cambiado. Se actualizó la lista.", "invalid": "El servidor no pudo aceptar la solicitud.", "network": "No se pudo contactar con el servidor. Revisa tu conexión.", - "unavailable": "El propietario del complemento no está disponible ahora. Inténtalo de nuevo en un momento.", + "unavailable": "Los complementos no están disponibles ahora. Inténtalo de nuevo en un momento.", "unknownOutcome": "No se pudo confirmar el resultado. Comprueba el estado antes de volver a intentarlo." }, "installed": { @@ -174,14 +181,14 @@ }, "hints": { "id": "Letras minúsculas, dígitos y guiones. No se puede cambiar después.", - "reference": "El nombre de una credencial que configuró el propietario. Nunca pegues aquí un token ni una contraseña.", + "reference": "El nombre de una credencial que un administrador configuró en este servidor. Nunca pegues aquí un token ni una contraseña.", "url": "Una dirección https sin credenciales ni cadena de consulta.", "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.", "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.", "mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched." }, "lastRefresh": "Actualizada {{when}}, {{count}} entradas", - "lead": "Una biblioteca es un documento de catálogo que enumera complementos. Las credenciales nunca se escriben aquí: una biblioteca privada nombra una credencial que configuró el propietario.", + "lead": "Una biblioteca es un índice que enumera complementos, backends o modelos. Las credenciales nunca se escriben aquí: una biblioteca privada de complementos nombra una credencial que un administrador configuró en este servidor.", "listLabel": "Bibliotecas", "loading": "Cargando bibliotecas", "notRefreshed": "Aún sin actualizar", @@ -251,10 +258,11 @@ "hasUI": "Tiene páginas", "headless": "Sin interfaz, sin páginas", "health": "Salud", + "installedAt": "Instalado", "host": "API del host", "interface": "Interfaz", "logs": "Registros", - "logsNone": "La salida de los procesos de los complementos no se recopila en esta versión. La salud y los intentos de reinicio de arriba es lo que el propietario puede informar.", + "logsNone": "La salida de los procesos de los complementos no se recopila en esta versión. La salud y los intentos de reinicio de arriba es todo lo que LocalAI puede informar.", "observed": "Estado observado", "protocol": "Protocolo", "restarts": "Intentos de reinicio", @@ -338,14 +346,15 @@ "state": { "failed": "Con error", "label": "Estado: {{state}}", - "running": "Activado", + "running": "En ejecución", "starting": "Iniciando", "stopped": "Desactivado", - "update-pending": "Actualización pendiente" + "update-pending": "Actualización pendiente", + "updateTo": "Actualización {{version}}" }, - "supporting": "Las apps que tu propietario ejecuta por ti y las bibliotecas de las que provienen.", + "supporting": "Software que instalas para dar a LocalAI nuevas páginas y herramientas de chat. Cada una se ejecuta por separado y solo pide lo que declara.", "tabs": { - "apps": "Mis complementos", + "apps": "Instalados", "browse": "Buscar complementos", "label": "Secciones de complementos", "libraries": "Fuentes" @@ -516,8 +525,8 @@ "unknownOutcome": "Se desconoce el resultado. Vuelve a leer el estado: la misma acción no se envía dos veces.", "auth": "Inicia sesión de nuevo.", "denied": "Solo los administradores pueden hacerlo, desde su propia sesión del navegador.", - "network": "No se pudo contactar con el propietario.", - "unavailable": "El propietario no está disponible." + "network": "No se pudo contactar con el servicio de complementos.", + "unavailable": "El servicio de complementos no está disponible." } }, "updates": { @@ -591,18 +600,30 @@ "auth": "Inicia sesión de nuevo para continuar.", "denied": "Solo un administrador puede hacerlo desde su propia sesión del navegador.", "network": "No se puede acceder a la red.", - "unavailable": "El propietario del complemento no está disponible. Inténtalo de nuevo." + "unavailable": "El servicio de complementos no está disponible. Inténtalo de nuevo." + }, + "upgradeNamed": "Actualizar {{name}}", + "why": { + "role": "Hay una actualización disponible. Solo un administrador puede instalarla.", + "needs-check": "Se informa de una actualización, pero sus detalles aún no están confirmados. Compruebe de nuevo para confirmarla.", + "check-failed": "La última comprobación falló, así que no se puede confirmar la actualización. Compruebe de nuevo.", + "incompatible": "Existe una actualización, pero no puede ejecutarse aquí:", + "integrity": "La biblioteca lista esta versión con contenido distinto. No se ofrece como actualización.", + "recovery": "Los datos de este complemento necesitan recuperación primero. Restaure una copia en Detalles y luego actualice.", + "current": "La última comprobación no encontró actualizaciones. El aviso puede estar desfasado. Compruebe de nuevo.", + "unpinned": "La biblioteca indica una actualización pero no nombra ninguna compilación para instalar, así que no se puede actualizar desde aquí. Vuelve a comprobarlo más tarde." }, - "reviewButton": "Actualizaciones ({{count}})", - "reviewTitle": "Revisar actualizaciones de complementos", - "reviewDescription": "No se actualiza nada hasta que lo elijas aquí. Cada actualización hace antes una copia de los datos del complemento.", "checkAll": "Buscar actualizaciones", "checkingAll": "Comprobando...", "checkAllDone": "{{count}} complementos comprobados.", "checkAllFailed": "No se pudieron comprobar {{count}}.", - "upgradeNamed": "Actualizar {{name}}", - "toastDone": "{{name}} actualizado a {{version}}.", - "toastFailed": "{{name}} no se actualizó: {{message}}" + "checkResult": { + "done": "Comprobado.", + "failed": "La comprobación no se completó.", + "justNow": "Comprobado ahora mismo." + }, + "waitingService": "El servicio de complementos se está iniciando. LocalAI vuelve a preguntar por la actualización en {{seconds}} s.", + "checkAgain": "Comprobar ahora" }, "install": { "stepsLabel": "Install steps", @@ -639,17 +660,19 @@ "addsTools_other": "Adds {{count}} tools", "openNamed": "Open {{name}}", "removeTitle": "Remove {{name}}?", - "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted." + "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted.", + "removeNamed": "Quitar {{name}}…" }, "preview": { "screenshots": "Screenshots", - "adds": "What it adds", + "adds": "Qué puede hacer", "page": "A page of its own in LocalAI", "addsNothing": "Nothing you can see. It works in the background.", "version": "Version", "id": "Identifier", "sourceUnknown": "Not known", - "moreDetails": "Health, trust and updates" + "moreDetails": "Health, trust and updates", + "updateTitle": "Actualizar a {{version}}" }, "flow": { "installAndTurnOn": "Instalar y activar", @@ -663,6 +686,66 @@ "install": "Download, verify and install", "turn-on": "Turn on" }, - "seeInList": "See it in My add-ons" + "seeInList": "Verlo en Instalados", + "install": "Instalar", + "installNamed": "Instalar {{name}}", + "shadowWarning": "Este complemento reemplaza el {{tool}} integrado. Otro complemento ({{name}}) ya lo hace. Solo uno puede estar activo; eliges cuál en Ajustes > Herramientas. Hasta que elijas, lo sirve el que se instaló primero.", + "skillShadow": "Este complemento reemplaza la habilidad integrada {{skill}}.", + "skillShadowWarning": "Este complemento reemplaza la habilidad integrada {{skill}}. Otro complemento ({{name}}) ya lo hace. Solo uno puede estar activo; eliges cuál en Ajustes > Herramientas. Hasta que elijas, lo sirve el que se instaló primero." + }, + "family": { + "pages": "¿Buscas páginas que creaste tú o un agente? Están en Páginas." + }, + "filter": { + "label": "Mostrar", + "all": "Todos", + "running": "En ejecución", + "off": "Apagados", + "showAll": "Mostrar todos", + "none": { + "all": "No hay complementos que mostrar.", + "running": "Ningún complemento está en ejecución.", + "off": "Todos los complementos están en ejecución." + } + }, + "attention": { + "label": "Requiere atención", + "failedTitle": "{{name}} se detuvo.", + "failedRestarts_one": "No pudo iniciarse {{count}} vez seguida.", + "failedRestarts_other": "No pudo iniciarse {{count}} veces seguidas.", + "failedOnce": "No se inició.", + "toolsOff_one": "Su {{count}} herramienta de chat está apagada hasta que vuelva a ejecutarse.", + "toolsOff_other": "Sus {{count}} herramientas de chat están apagadas hasta que vuelva a ejecutarse.", + "pageOff": "Su página no está disponible hasta que vuelva a ejecutarse.", + "updateTitle": "{{name}} {{version}} está disponible.", + "updateBody": "Tienes {{current}}. Verás qué cambia y confirmarás antes de reemplazar nada." + }, + "meta": { + "builtin": "Integrado", + "builtinLong": "Viene con LocalAI", + "signed": "Firmado · {{library}}", + "checked": "Comprobado · {{library}}", + "devBuild": "Compilación de desarrollo", + "library": "biblioteca {{library}}" + }, + "skills": { + "title_one": "{{count}} habilidad de chat", + "title_other": "{{count}} habilidades de chat", + "replaces": "reemplaza la habilidad integrada", + "serves": "se usa en los chats", + "notServing": "no se usa ahora", + "serveIn": "Usar para {{skill}} en los chats", + "saveFailed": "No se pudo guardar la elección." + }, + "settingsTab": { + "overview": "Resumen", + "settings": "Ajustes", + "tabsLabel": "Detalles de {{name}}", + "off": "Activa {{name}} para cambiar sus ajustes.", + "pageLink": "Ajustes", + "controls": { + "loadFailed": "No se pudieron leer los ajustes", + "saveFailed": "No se pudieron guardar los ajustes: {{message}}" + } } } diff --git a/core/http/react-ui/public/locales/es/admin.json b/core/http/react-ui/public/locales/es/admin.json index 9e39c7dcf..6ad09cabc 100644 --- a/core/http/react-ui/public/locales/es/admin.json +++ b/core/http/react-ui/public/locales/es/admin.json @@ -107,6 +107,72 @@ "assistant": "LocalAI Assistant", "distributed": "Distribuido", "responses": "Respuestas" + }, + "loadFailedTitle": "No se pudieron cargar los ajustes.", + "egress": { + "title": "Red de los comandos", + "intro": "Los comandos del Sandbox llegan a la red solo a través de un proxy que consulta a LocalAI cada conexión. Abajo se fijan el límite, el valor de un chat nuevo, los puertos, los registros adicionales y la retención del registro; cada persona elige el valor de cada chat en su menú Herramientas, hasta el límite, y permite un host bloqueado en una tarjeta.", + "serves": "Los comandos se ejecutan aislados en el complemento {{name}}", + "servesHost": "Los comandos se ejecutan en esta máquina con {{name}}", + "servesNone": "Ningún Sandbox ejecuta comandos todavía", + "hostNote": "El backend del host ejecuta comandos en esta máquina con su red: estos ajustes no se les aplican.", + "counts": "Últimas 24 horas: {{allowed}} permitidas, {{blocked}} bloqueadas.", + "private": "Permitir que la regla de un administrador llegue a redes privadas", + "privateWarning": "Un host que permite un administrador podrá llegar a direcciones dentro de tu red. Un valor por sí solo nunca lo hace; las direcciones de metadatos de la nube y del servidor siguen rechazadas.", + "typePhrase": "Para activarlo, escribe: {{phrase}}", + "registries": "Registros de paquetes ({{count}})", + "showAudit": "Ver el registro", + "hideAudit": "Ocultar el registro", + "loadFailed": "No se pudieron leer los ajustes de red del Sandbox", + "audit": { + "verdict": "Resultado", + "host": "Host", + "user": "Usuario", + "at": "Cuándo", + "reason": "Motivo", + "bytes": "Bytes subida / bajada", + "refresh": "Actualizar", + "empty": "No hay solicitudes que coincidan.", + "loadFailed": "No se pudo leer el registro", + "verdicts": { + "any": "Cualquier resultado", + "allow": "Permitida", + "block": "Bloqueada", + "done": "Terminada" + } + } + }, + "addons": { + "title": "Complementos", + "hint": "Ajustes que traen los complementos", + "intro": "Cada complemento guarda sus ajustes en su propia página, en la pestaña Ajustes de sus detalles. Abre uno para cambiarlos.", + "off": "Desactivado. Actívalo para cambiar sus ajustes.", + "open": "Abrir ajustes", + "openNamed": "Abrir los ajustes de {{name}}", + "count_one": "{{count}} complemento con ajustes", + "count_other": "{{count}} complementos con ajustes" + }, + "hosts": { + "add": "Añadir", + "listLabel": "Hosts siempre permitidos", + "remove": "Quitar {{host}}", + "empty": "Aún no hay hosts. Escribe un nombre de host, o host:puerto para fijar el puerto.", + "hint": "Solo nombres exactos. Añade :puerto para permitir un único puerto; si no, se aplican los puertos de red del Sandbox.", + "count": "{{n}}/{{max}}", + "errors": { + "empty": "Escribe un nombre de host.", + "wildcard": "\"{{value}}\": escribe un nombre de host exacto, no un patrón.", + "shape": "\"{{value}}\" no es un nombre de host. Escribe solo el host, sin esquema ni ruta.", + "port": "\"{{value}}\": el puerto debe estar entre 1 y 65535, y el 22 y el 25 se rechazan siempre.", + "private": "\"{{value}}\" es una dirección privada o local. Activa antes el interruptor de red privada.", + "numeric": "\"{{value}}\": escribe una dirección IP como cuatro números decimales.", + "duplicate": "\"{{value}}\" ya está en la lista.", + "full": "La lista admite como máximo {{max}} hosts." + } + }, + "network": { + "title": "Red de los comandos", + "where": "{{name}} guarda en su propia página los ajustes de red de los comandos que ejecuta." } }, "backends": { @@ -253,5 +319,75 @@ "subtitle": "Modelos cargados en esta instancia de LocalAI y la capacidad del host que utilizan.", "scaleOut": "Añadir máquinas", "hideScaleOut": "Ocultar configuración" + }, + "settingsTools": { + "intro": "Los chats tienen algunas herramientas integradas que un complemento puede reemplazar. Cuando el complemento está instalado, en marcha y activado en un chat, el chat usa la herramienta del complemento con el mismo nombre.", + "empty": "En este servidor no hay herramientas integradas que gestionar.", + "loadFailed": "No se pudieron leer las herramientas integradas", + "switch": "Activado", + "byBuiltin": "Integrada", + "byAddon": "Complemento: {{name}}", + "byNone": "Desactivada", + "unavailable": "Este servidor no puede ejecutarla: {{reason}}.", + "installSandbox": "Instala el Sandbox para ejecutar comandos aislados", + "replaceExplain": "Estos complementos reemplazan {{tool}}: el elegido lo sirve en cada chat donde está activado y en marcha, y el integrado se oculta. Solo uno puede estar activo. Hasta que elijas, lo sirve el que se instaló primero.", + "defaultInstalledFirst": "Predeterminado: instalado primero", + "installedOn": "Instalado {{when}}", + "choose_one": "{{count}} complemento reemplaza {{tool}}: elige cuál usar.", + "choose_other": "{{count}} complementos reemplazan {{tool}}: elige cuál usar.", + "labels": { + "bash": "Comandos (bash)", + "web_search": "Búsqueda web", + "web_fetch": "Leer páginas web", + "list_agents": "Agentes guardados" + }, + "descriptions": { + "bash": "Ejecuta comandos y código para los chats directamente en esta máquina cuando ningún Sandbox sirve bash. El complemento Sandbox lo reemplaza con comandos aislados.", + "web_search": "Busca en la web para los chats sin clave (DuckDuckGo, luego Brave Search, luego Bing), o con tu instancia de SearXNG si hay una configurada.", + "web_fetch": "Lee para los chats una página o un archivo web público por su dirección. Las direcciones de tu red se rechazan salvo que las permitas.", + "list_agents": "Permite a un chat listar los agentes guardados del usuario e iniciar uno como subagente. Cada chat lo activa en su menú Herramientas." + }, + "bash": { + "switch": "Permitir que los chats ejecuten comandos en esta máquina", + "modeLegend": "Quién puede ejecutar comandos", + "ask": "Preguntar cada vez.", + "askHint": "Solo lo reciben los chats de administradores, y cada comando espera su sí en el chat.", + "on": "Permitir para todos.", + "onHint": "Los chats de todos los usuarios ejecutan comandos aquí sin preguntar, también las ejecuciones programadas y los subagentes.", + "default": "Sin definir: se aplica el valor por defecto ({{mode}}).", + "warning": "Los comandos se ejecutan como el usuario de LocalAI, sin aislamiento y con la red de esta máquina: pueden leer lo que LocalAI puede leer y llegar a internet, a tu red y a la API de LocalAI. Cada comando queda registrado. Instala el Sandbox para aislarlos." + }, + "modes": { + "off": "desactivado", + "ask": "preguntar cada vez", + "on": "permitido para todos" + }, + "skillsTitle": "Habilidades que reemplazan los complementos", + "skillsIntro": "Un complemento puede reemplazar una habilidad integrada por la suya, escrita para sus herramientas. Si varios reemplazan la misma, elige cuál se usa.", + "skillLabel": "Habilidad {{skill}}", + "skillNotRunning": "El complemento elegido no se ejecuta: se usa la habilidad integrada.", + "toolNotRunning": "El complemento que reemplaza esta herramienta no se ejecuta: se usa la herramienta integrada.", + "stopped": "detenido", + "webTest": { + "intro": "Ejecuta una búsqueda con cada proveedor de la configuración guardada y muestra qué hizo cada uno. Aplica antes tus cambios.", + "queryLabel": "Consulta", + "run": "Probar búsqueda", + "running": "Probando…", + "answered": "Los chats reciben los resultados de {{host}}.", + "noneAnswered": "Ningún proveedor respondió.", + "failed": "No se pudo ejecutar la prueba: {{error}}", + "results_one": "{{count}} resultado", + "results_other": "{{count}} resultados", + "outcomes": { + "ok": "Funciona", + "no_results": "Sin resultados", + "challenged": "Control antibots", + "blocked": "Bloqueado", + "timeout": "Demasiado lento", + "error": "Error", + "skipped": "Omitido" + } + }, + "addonSettings": "Ajustes de {{name}}" } } diff --git a/core/http/react-ui/public/locales/es/agents.json b/core/http/react-ui/public/locales/es/agents.json index 5b28596a5..e16cb5386 100644 --- a/core/http/react-ui/public/locales/es/agents.json +++ b/core/http/react-ui/public/locales/es/agents.json @@ -2,15 +2,13 @@ "title": "Agentes", "subtitle": "Administra agentes de IA autónomos", "actions": { - "agentHub": "Agent Hub", "import": "Importar", "createAgent": "Crear agente", "edit": "Editar", "chat": "Chat", "export": "Exportar", "delete": "Eliminar", - "pause": "Pausar", - "resume": "Reanudar" + "newAgent": "Nuevo agente" }, "table": { "name": "Nombre", @@ -27,13 +25,9 @@ "empty": { "noConfigured": "No hay agentes configurados", "noConfiguredText": "Crea un agente para empezar con flujos de trabajo de IA autónomos.", - "browseHub": "¿No sabes por dónde empezar? Explora el <1>Agent Hub para encontrar configuraciones de agentes listas para importar.", "noMatching": "No hay agentes coincidentes", - "noMatchingText": "Ningún agente coincide con \"{{query}}\"" - }, - "sections": { - "yourAgents": "Tus agentes", - "otherUsersAgents": "Agentes de otros usuarios" + "noMatchingText": "Ningún agente coincide con \"{{query}}\"", + "clearSearch": "Borrar búsqueda" }, "deleteDialog": { "title": "Eliminar agente", @@ -44,12 +38,411 @@ "loadFailed": "Error al cargar agentes: {{message}}", "deleted": "Agente \"{{name}}\" eliminado", "deleteFailed": "Error al eliminar agente: {{message}}", - "paused": "Agente \"{{name}}\" pausado", - "resumed": "Agente \"{{name}}\" reanudado", - "pauseFailed": "Error al pausar agente: {{message}}", - "resumeFailed": "Error al reanudar agente: {{message}}", "exported": "Agente \"{{name}}\" exportado", - "exportFailed": "Error al exportar agente: {{message}}", - "parseFailed": "Error al analizar archivo de agente: {{message}}" + "parseFailed": "Error al analizar archivo de agente: {{message}}", + "notAnAgent": "The file is not an exported agent", + "deletedUndo": "Se eliminó «{{name}}».", + "undoLate": "«{{name}}» ya se eliminó.", + "restored": "Se restauró «{{name}}»." + }, + "facts": { + "addons": "Add-ons", + "connector": "MCP connector" + }, + "agent": { + "triggers": "Triggers", + "newTrigger": "New trigger", + "noTriggers": "No trigger starts this agent.", + "untitled": "Untitled chat", + "triggerOn": "On", + "triggerOff": "Off", + "origin": { + "trigger": "Started by a trigger", + "connector": "Started by a connector" + }, + "noRunsText": "Press Chat to start a conversation with this agent. Chats that ran with it, and the ones its triggers start, are listed here.", + "missingText": "It may have been deleted, or it belongs to another user.", + "backToList": "Volver a los agentes" + }, + "record": { + "where": "A run is a chat that ran with the agent, including the ones its triggers and connectors started.", + "none": "No runs yet" + }, + "create": { + "sections": { + "BasicInfo": "Basic info", + "ModelSettings": "Model", + "PromptsGoals": "Instructions", + "MemorySettings": "Memory", + "Skills": "Skills", + "Addons": "Add-ons", + "MCP": "MCP servers", + "export": "Export" + }, + "name": "Name", + "description": "Description", + "descriptionHelp": "One line that says what the agent is for. It shows in the list.", + "model": "Model", + "modelPlaceholder": "Any model (the chat picks one)", + "modelHelp": "Leave it empty and a chat with this agent uses the model you pick in the chat. Set one to fix it.", + "anyModel": "Any model", + "instructions": "Instructions", + "instructionsHelp": "What the agent always follows. Every chat with it starts from this text.", + "collections": "Collections", + "collectionsHint": "Collection name", + "collectionsHelp": "The memory collections a chat with this agent may search. The list is exact: an empty list grants none.", + "skillsHelp": "The skills a chat with this agent may read. The list is exact: an empty list grants none.", + "noSkills": "No skills yet.", + "manageSkills": "Write or manage skills", + "addonsHelp": "The add-ons whose tools a chat with this agent may use.", + "noAddons": "No add-on offers tools to a model.", + "mcpHelp": "The MCP servers of the fixed model and the MCP connectors a chat with this agent may use.", + "mcpServers": "MCP servers", + "mcpHint": "Server name", + "mcpConnectors": "MCP connectors", + "noConnectors": "No MCP connectors yet.", + "manageConnectors": "Manage MCP connectors", + "connectorOff": "off", + "gone": "not found", + "countCollections_one": "{{count}} collection", + "countCollections_other": "{{count}} collections", + "countAddons_one": "{{count}} add-on", + "countAddons_other": "{{count}} add-ons", + "needsName": "Needs a name", + "loadFailed": "Failed to load the agent: {{message}}", + "saveFailed": "Save failed: {{message}}", + "created": "Agent \"{{name}}\" created", + "updated": "Agent \"{{name}}\" updated", + "exportSummary": "Download as JSON", + "exportHelp": "Download the definition of this agent as a JSON file. It holds no id, no dates and no secrets. Import it on another instance from the agents list.", + "exportButton": "Export agent", + "importNew": "Imported from an exported agent. Read the form, then save.", + "importLegacy": "Imported from the export of an older agent. Name, description, model, instructions and skills were read; the rest has no counterpart here.", + "importDropped": "Not imported: {{keys}}.", + "subEdit": "A chat that is running keeps the version it started with. Its next turn uses the new one.", + "configNote": "The definition this form will save.", + "previewNew": "The agent this form will save.", + "previewEdit": "The agent this form will save, and how it differs from the saved one.", + "default": "Default", + "network": "Red de los comandos en ejecuciones sin supervisión", + "networkHelp": "Cuando un disparador o un conector ejecuta este agente, sus comandos del Sandbox llegan a esta parte de la red, hasta el límite que fijó un administrador. Una ejecución nunca pregunta: un host fuera de ella se bloquea. Un chat que inicias con este agente también empieza así.", + "networkPresets": { + "off": "Desactivada", + "registries": "Registros de paquetes", + "all": "Todos los sitios públicos" + } + }, + "run": { + "workingLine": "Working" + }, + "jobs": { + "title": "Triggers", + "newTask": "New trigger", + "createTask": "Create trigger", + "loading": "Loading triggers", + "loadFailed": "Failed to load: {{message}}", + "actionFailed": "The action failed: {{message}}", + "summary": { + "stopped": "{{count}} skipped", + "lastFailed_one": "The last run of {{names}} failed.", + "lastFailed_other": "The last run failed for {{names}}." + }, + "tasks": { + "title": "Triggers, {{count}}", + "caption": "Triggers", + "task": "Trigger", + "noSchedule": "No schedule", + "noAgent": "No agent", + "fileKind": "When a file arrives", + "open": "Open", + "started": "Trigger \"{{name}}\" started", + "toggleLabel": "Run {{name}} by itself", + "toggleFailed": "Could not change the trigger: {{message}}", + "deleted": "Trigger deleted", + "noneYet": "You have no triggers yet.", + "noModel": "No model", + "next": "Próxima" + }, + "history": { + "filterTask": "Trigger", + "allTasks": "All triggers", + "task": "Trigger", + "unknownTask": "Unknown trigger", + "cancelledLine": "Skipped", + "doneLine": "Finished. Open the chat for the answer.", + "doneFile": "Finished for {{file}}. Open the chat for the answer.", + "pendingLine": "Waiting to start", + "emptyText": "A run starts when you press Run now, when a schedule fires, or when a file arrives in a watched folder.", + "openRun": "Open the chat", + "reason": "Reason", + "file": "Started by the file {{file}}." + }, + "status": { + "done": "Done", + "failed": "Failed", + "running": "Working", + "queued": "Waiting", + "skipped": "Skipped" + }, + "empty": { + "title": "No triggers yet", + "text": "A trigger starts a chat for you by itself: at a time, or when a file arrives in a folder. Each run is an ordinary chat that you can open.", + "step1": "Write what the chat should do.", + "step2": "Pick an agent, or a model, to run it.", + "step3": "Choose when it runs, or press Run now." + }, + "task": { + "back": "Triggers", + "missingTitle": "Trigger not found", + "agent": "Agent", + "model": "Model", + "schedule": "Schedule", + "cap": "Most runs a day", + "watches": "Watches the folder {{folder}}", + "noRuns": "This trigger has not run yet.", + "runMeta": "took {{time}}", + "prompt": "What to do" + }, + "edit": { + "titleNew": "New trigger", + "subNew": "Name it, write what to do and choose when it runs.", + "subEdit": "Changes apply from the next run.", + "save": "Save trigger", + "create": "Create trigger", + "nameRequired": "A trigger needs a name", + "promptRequired": "Write what the trigger should do", + "created": "Trigger created", + "updated": "Trigger updated", + "sections": { + "basics": "Basics", + "prompt": "What to do", + "schedule": "When", + "limits": "Limits" + }, + "summary": { + "basics": "{{name}}, {{model}}", + "basicsNoModel": "{{name}}, no agent or model yet", + "promptEmpty": "Nothing to do yet", + "folderEmpty": "Needs a folder", + "cap": "Up to {{count}} runs a day", + "capDefault": "Default limit" + }, + "name": "Name", + "namePlaceholder": "daily-digest", + "noAgent": "No agent", + "model": "Model", + "enabled": "On", + "enabledHint": "A trigger that is off keeps its schedule but does not run.", + "prompt": "What to do", + "promptPlaceholder": "Summarise what is new in my Reports folder.", + "promptHint": "This text starts the chat each time the trigger fires.", + "capHint": "The most chats this trigger may start in one day. Empty uses the default.", + "previewEdit": "What the next run would send, and what differs from the saved trigger.", + "previewNew": "What the first run would send.", + "promptAsSent": "The text a run starts with", + "noChanges": "Nothing differs from the saved trigger.", + "loadingTask": "Loading trigger", + "changed_one": "{{count}} change", + "changed_other": "{{count}} changes" + }, + "subtitle": "Inicia un agente según un horario o cuando llega un archivo. Cada ejecución es un chat que puedes abrir." + }, + "views": { + "label": "Área de agentes", + "agents": "Agentes" + }, + "now": { + "failedTitle": "{{name}} falló.", + "workingTitle": "{{name}} está trabajando.", + "openRun": "Abrir la ejecución" + }, + "load": { + "agents": "No se pudieron cargar tus agentes.", + "agentsStale": "No se pudo actualizar la lista. Muestra lo que había antes.", + "agent": "No se pudo cargar este agente.", + "agentStale": "No se pudo actualizar esta página. Muestra lo que había antes.", + "agentForm": "No se pudo cargar este agente para editarlo.", + "triggers": "No se pudieron cargar tus disparadores.", + "triggersStale": "No se pudieron actualizar los disparadores. Muestran lo que había antes.", + "trigger": "No se pudo cargar este disparador.", + "triggerStale": "No se pudo actualizar este disparador. Muestra lo que había antes.", + "tasks": "No se pudieron cargar las tareas.", + "tasksStale": "No se pudieron actualizar las tareas. Muestran lo que había antes." + }, + "meta": { + "anyModel": "Cualquier modelo", + "addons_one": "{{count}} complemento", + "addons_other": "{{count}} complementos" + }, + "chips": { + "lastFailed": "La última ejecución falló" + }, + "tasks": { + "title": "Tareas de agentes", + "allUsers": "Todos los usuarios", + "new": "Nueva tarea", + "newTitle": "Nueva tarea", + "editTitle": "Editar tarea", + "name": "Nombre", + "description": "Descripción", + "runner": "Se ejecuta con", + "byModel": "Un modelo", + "byAgent": "Uno de tus agentes", + "model": "Modelo", + "agent": "Agente", + "pick": "Elige…", + "prompt": "Prompt", + "promptHint": "Escribe un parámetro como {{example}}; cada ejecución pide su valor.", + "webhooksKept_one": "Esta tarea tiene {{count}} webhook. Se mantiene tal cual; cambia los webhooks con la API.", + "webhooksKept_other": "Esta tarea tiene {{count}} webhooks. Se mantienen tal cual; cambia los webhooks con la API.", + "save": "Guardar tarea", + "cancel": "Cancelar", + "saved": "Tarea «{{name}}» guardada", + "runTitle": "Ejecutar {{name}}", + "runNow": "Ejecutar ahora", + "started": "Tarea «{{name}}» iniciada", + "tasks": "Tareas", + "off": "Desactivada", + "run": "Ejecutar", + "edit": "Editar", + "delete": "Eliminar", + "deleted": "Tarea «{{name}}» eliminada", + "task": "Tarea", + "cancelJob": "Cancelar", + "rerun": "Ejecutar de nuevo", + "rerunStarted": "Iniciada de nuevo", + "stillRunning": "Todavía en curso.", + "noResult": "Sin resultado.", + "traces_one": "{{count}} paso", + "traces_other": "{{count}} pasos", + "webhookSent": "Se llamó al webhook.", + "webhookFailed": "El webhook falló: {{message}}", + "actionFailed": "No funcionó: {{message}}", + "state": { + "pending": "En espera", + "running": "En curso", + "completed": "Hecha", + "failed": "Fallida", + "cancelled": "Cancelada" + }, + "lede": "Prompts que un modelo o uno de tus agentes ejecuta en segundo plano. Inícialos aquí o desde un script con la API.", + "searchLabel": "Buscar tareas", + "filter": { + "label": "Mostrar tareas", + "all": "Todas", + "on": "Activas", + "off": "Desactivadas" + }, + "noMatch": "Ninguna tarea coincide.", + "on": "Activa", + "offLine": "Desactivada. No se ejecuta hasta que la actives.", + "onDemand": "Se ejecuta cuando la inicias", + "asksFor": "Pide {{list}}", + "webhooks_one": "{{count}} webhook", + "webhooks_other": "{{count}} webhooks", + "runsCount_one": "{{count}} ejecución", + "runsCount_other": "{{count}} ejecuciones", + "runsOf_one": "{{count}} ejecución de {{name}}", + "runsOf_other": "{{count}} ejecuciones de {{name}}", + "toggleLabel": "{{name}} puede ejecutarse", + "turnedOn": "«{{name}}» está activa", + "turnedOff": "«{{name}}» está desactivada", + "moreFor": "Más acciones para {{name}}", + "open": "Abrir", + "openTask": "Abrir {{name}}", + "runFor": "Ejecutar {{name}}", + "rerunFor": "Ejecutar {{name}} de nuevo", + "jobMoreFor": "Más acciones para esta ejecución de {{name}}", + "detailsFor": "Detalles de la ejecución de {{name}}", + "openChat": "Abrir el chat", + "deleteJob": "Eliminar ejecución", + "jobDeleted": "Ejecución eliminada", + "cancelled": "Ejecución detenida", + "confirmJob": { + "title": "¿Eliminar esta ejecución?", + "message": "Se eliminan su respuesta y sus pasos. No se puede deshacer." + }, + "line": { + "pending": "Esperando para empezar", + "running": "Trabajando", + "cancelled": "Detenida antes de terminar" + }, + "startedBy": { + "manual": "Iniciada aquí", + "api": "Iniciada con la API", + "cron": "Iniciada por un horario" + }, + "output": { + "answer": "Respuesta", + "error": "Error", + "values": "Valores" + }, + "traceKind": { + "reasoning": "Razonamiento", + "tool_call": "Herramienta", + "tool_result": "Resultado de herramienta", + "status": "Estado" + }, + "empty": { + "title": "Todavía no hay tareas", + "body": "Una tarea es un prompt que escribes una vez y ejecutas muchas. Pon {{example}} donde cambia un valor y cada ejecución lo pide.", + "fact1": "La ejecuta un modelo o uno de tus agentes", + "fact2": "Iníciala aquí o desde un script con la API", + "fact3": "Cada ejecución guarda su respuesta y sus pasos" + }, + "sheet": { + "optional": "(opcional)", + "newDesc": "Escribe el prompt una vez y ejecútalo cuando lo necesites.", + "editDesc": "Los cambios se aplican desde la próxima ejecución.", + "about": "Datos", + "nameHelp": "Los scripts usan este nombre para ejecutar la tarea con la API.", + "descHelp": "Una línea que diga para qué sirve la tarea.", + "who": "Quién la ejecuta", + "modelHelp": "El modelo responde al prompt sin herramientas.", + "agentHelp": "El agente responde con sus propias instrucciones y herramientas.", + "what": "Qué hace", + "promptPlaceholder": "Por ejemplo: Resume el ticket {{example}} en tres líneas.", + "inputs": "Cada ejecución pide:", + "noInputs": "Este prompt no pide nada. Cada ejecución lo envía tal cual.", + "on": "Activa", + "onHelp": "Una tarea desactivada conserva sus ejecuciones pero no puede iniciarse.", + "runDesc": "Rellena los valores de esta ejecución.", + "runNone": "Esta tarea no pide nada. Ejecuta el prompt tal cual.", + "preview": "Lo que recibe la tarea" + }, + "errors": { + "name": "Ponle un nombre a la tarea.", + "prompt": "Escribe lo que debe hacer la tarea.", + "model": "Elige un modelo.", + "agent": "Elige un agente." + }, + "pane": { + "label": "Detalles de la tarea", + "overview": "Resumen", + "runs": "Ejecuciones", + "settings": "Ajustes", + "inputs": "Pide", + "noInputs": "Nada", + "changed": "Modificada", + "api": "Ejecutarla con la API", + "apiHint": "Envía los valores como un objeto JSON. La respuesta trae el id de la ejecución para consultar su resultado.", + "noRuns": "Todavía no hay ejecuciones. Ejecuta la tarea y su respuesta aparecerá aquí.", + "webhooks": "Webhooks", + "noWebhooks": "Ninguno. Añade webhooks con la API para saber cuándo termina una ejecución.", + "deleteTitle": "Eliminar esta tarea", + "deleteHelp": "Sus ejecuciones se quedan hasta que pase su tiempo de retención." + }, + "history": { + "title": "Historial", + "filterLabel": "Mostrar ejecuciones", + "all": "Todas", + "active": "En curso", + "completed": "Hechas", + "failed": "Fallidas", + "cancelled": "Canceladas", + "allTasks": "Todas las tareas", + "emptyBody": "Ejecuta una tarea y su respuesta aparecerá aquí.", + "more": "Mostrar {{count}} más" + } } } diff --git a/core/http/react-ui/public/locales/es/auth.json b/core/http/react-ui/public/locales/es/auth.json index 8e54ff809..0c799e938 100644 --- a/core/http/react-ui/public/locales/es/auth.json +++ b/core/http/react-ui/public/locales/es/auth.json @@ -47,12 +47,15 @@ "account": { "title": "Cuenta", "subtitle": "Perfil, credenciales y claves API", + "subtitleLocal": "Quién eres y cómo se comportan el asistente y LocalAI para ti", "unavailable": "Cuenta no disponible", "unavailableText": "La autenticación debe estar habilitada para administrar tu cuenta.", "tabs": { "profile": "Perfil", "security": "Seguridad", - "apiKeys": "Claves API" + "apiKeys": "Claves API", + "about": "Sobre ti y el asistente", + "appearance": "Apariencia" }, "profile": { "displayName": "Nombre para mostrar", @@ -112,6 +115,9 @@ "pausedToast": "API key paused", "resumedToast": "API key resumed", "pauseFailed": "Failed to update API key: {{message}}" + }, + "usage": { + "loadFailedTitle": "No se pudo cargar tu uso." } }, "notFound": { diff --git a/core/http/react-ui/public/locales/es/chat.json b/core/http/react-ui/public/locales/es/chat.json index 8567dfa1e..8561304e3 100644 --- a/core/http/react-ui/public/locales/es/chat.json +++ b/core/http/react-ui/public/locales/es/chat.json @@ -25,22 +25,15 @@ "yes": "Sí", "gpuLayers": "Capas GPU" }, - "context": { - "label": "Contexto: {{percent}}%", - "labelWithTokens": "Contexto: {{percent}}% ({{tokens}} tokens)" - }, "settings": { "title": "Ajustes del chat", "manageMode": "Modo administración", - "manageModeDesc": "Permite que este chat instale modelos, cambie backends y edite configuraciones hablando con LocalAI.", - "systemPrompt": "Prompt del sistema", - "systemPromptPlaceholder": "Eres un asistente útil...", - "temperature": "Temperatura", - "topP": "Top P", - "topK": "Top K", - "contextSize": "Tamaño del contexto", - "contextSizePlaceholder": "2048", - "clearHistory": "Borrar historial del chat" + "serverKept": "El servidor guarda este chat. Su modelo y su agente se fijan al empezar.", + "manageModeOn": "Este chat tiene las herramientas de gestión de LocalAI. Usa /assistant para iniciar otro.", + "agent": "Agente", + "agentHelp": "Este chat usa el agente {{name}}: sus instrucciones y solo las herramientas que concede.", + "editAgent": "Editar agente", + "deleteHelp": "Elimina este chat y sus mensajes para siempre." }, "empty": { "manageTitle": "Administra LocalAI chateando", @@ -61,7 +54,6 @@ "Lluvia de ideas" ], "recent": "Recientes", - "noMessages": "Aún no hay mensajes", "hintEnter": "Enter para enviar", "hintShiftEnter": "Shift+Enter para nueva línea", "hintAttach": "Adjuntar archivos" @@ -71,29 +63,17 @@ }, "actions": { "copy": "Copiar", - "edit": "Editar", - "editMessage": "Editar mensaje", - "save": "Guardar", - "cancel": "Cancelar", "regenerate": "Regenerar", - "jumpToLatest": "Jump to latest" + "jumpToLatest": "Jump to latest", + "reuse": "Editar y enviar de nuevo" }, "streaming": { "transferring": "Transfiriendo modelo...", "transferringTo": "Transfiriendo modelo a {{node}}..." }, - "tokens": { - "perSec": "{{count}} tok/s", - "peak": "Pico: {{count}} tok/s", - "usage": "{{prompt}}p + {{completion}}c = {{total}}" - }, "input": { - "placeholder": "Mensaje...", - "attachFile": "Adjuntar archivo", "stopGenerating": "Detener generación", - "canvasTitle": "Canvas — extrae bloques de código y multimedia en un panel lateral para vista previa, copia y descarga", - "canvasLabel": "Canvas", - "openCanvas": "Abrir panel canvas" + "placeholderQueue": "El modelo está trabajando. Un mensaje enviado ahora espera a su siguiente paso." }, "deleteAllDialog": { "title": "Eliminar todos los chats", @@ -104,7 +84,7 @@ "selectModel": "Por favor selecciona un modelo", "copied": "Copiado al portapapeles", "copyFailed": "No se pudo copiar al portapapeles", - "pdfScannedAsImages": "{{name}} no tiene capa de texto (escaneo). Se adjuntaron {{count}} de {{total}} páginas como imágenes para un modelo de visión." + "newChatForModel": "Un chat conserva su modelo. Se inició un chat nuevo con {{model}}." }, "menu": { "trigger": "Chats", @@ -118,17 +98,492 @@ "deleteChat": "Eliminar chat", "newChat": "Nuevo chat", "clearAll": "Borrar todo", - "deleteAllTitle": "Eliminar todas las conversaciones" + "deleteAllTitle": "Eliminar todas las conversaciones", + "untitled": "Nuevo chat", + "origin": { + "trigger": "Iniciado por un disparador", + "connector": "Desde un conector" + } }, "message": { - "you": "You" + "you": "You", + "loopRun": "Bucle, ejecución {{run}}" }, - "addons": { - "note": "An add-on that is off here does nothing in this chat.", - "label": "Add-ons", - "title": "Add-on tools for this chat", - "heading": "Add-on tools", - "selectAll": "Select all", - "deselectAll": "Deselect all" + "tools": { + "label": "Herramientas", + "title": "Herramientas que puede usar este chat", + "heading": "Herramientas de este chat", + "draftNote": "Un chat nuevo empieza con estos interruptores. Una herramienta desactivada no se ofrece al modelo.", + "note": "Una herramienta apagada aquí no se ofrece al modelo en este chat.", + "count": "{{on}} de {{total}} activadas", + "selectAll": "Activar todas", + "disableAll": "Desactivar todas", + "groupOn": "Todas sí", + "groupOff": "Todas no", + "groupOnLabel": "Activar todo en {{group}}", + "groupOffLabel": "Desactivar todo en {{group}}", + "groupCount": "{{on}} de {{total}}", + "optIn": "Apagada hasta que la actives. Activar todas no la cambia.", + "empty": "No hay nada que activar en este chat.", + "failed": "No se pudo guardar. El interruptor vuelve a como estaba.", + "unavailable": "No disponible en este chat", + "toolCount_one": "{{count}} herramienta", + "toolCount_other": "{{count}} herramientas" + }, + "skills": { + "label": "Habilidades", + "manage": "Gestionar habilidades", + "title": "Habilidades que puede usar este chat", + "heading": "Habilidades de este chat", + "count": "{{on}} de {{total}} activadas", + "selectAll": "Seleccionar todas", + "disableAll": "Desactivar todas", + "selectShown": "Seleccionar las mostradas", + "disableShown": "Desactivar las mostradas", + "search": "Buscar habilidades", + "noMatch": "Ninguna habilidad coincide con «{{query}}».", + "later": "Las habilidades que añadas después empiezan activadas.", + "failed": "No se pudo guardar. No cambió nada.", + "source": { + "builtin": "Integrada", + "personal": "Personal", + "shared": "Compartida", + "folder": "Espacio de trabajo", + "addon": "{{name}}" + }, + "replacedBy": "Reemplazada por {{name}}", + "fallback_one": "{{skills}} integrada en uso: la habilidad propia del complemento {{name}} está desactivada o no se ejecuta aquí.", + "fallback_other": "{{skills}} integradas en uso: las habilidades propias del complemento {{name}} están desactivadas o no se ejecutan aquí." + }, + "agent": { + "label": "Agente", + "title": "Agente guardado para este chat", + "heading": "Empezar con un agente", + "note": "El agente da al chat sus instrucciones y exactamente las herramientas que concede.", + "none": "Sin agente", + "fixed": "El agente de este chat" + }, + "assistant": { + "chatTitle": "Asistente de LocalAI" + }, + "slash": { + "delete": { + "label": "Eliminar chat", + "desc": "Eliminar esta conversación" + }, + "assistant": { + "desc": "Iniciar un chat que puede gestionar LocalAI" + }, + "settings": { + "desc": "Modo enfoque, agente e información del modelo" + }, + "group": { + "context": "Contexto", + "skills": "Skills y agentes", + "help": "Ayuda" + }, + "models": { + "label": "Modelos", + "desc": "Abrir la lista de modelos" + }, + "skill": { + "label": "Usar una skill", + "desc": "Elegir una de las skills de este chat" + }, + "skills": { + "label": "Gestionar habilidades", + "desc": "Abrir la página de habilidades para escribirlas y cambiarlas" + }, + "agent": { + "label": "Preguntar a un agente guardado", + "desc": "Encargar una tarea a uno de tus agentes", + "enable": "Activar los agentes guardados en este chat", + "enableDesc": "Luego elige el agente" + }, + "compact": { + "label": "Compactar el contexto", + "desc": "Resumir los mensajes antiguos para dejar sitio al modelo" + }, + "clear": { + "label": "Vaciar el contexto", + "desc": "Empezar de cero en este chat; los mensajes se quedan" + }, + "attach": { + "label": "Adjuntar archivos", + "desc": "Elegir archivos para enviar", + "clearDesc": "Quitar los archivos pendientes de enviar" + }, + "help": { + "label": "Todos los comandos", + "desc": "Listar cada comando y lo que hace" + }, + "about": { + "label": "Acerca de este chat", + "desc": "Modelo, herramientas, skills y tamaño del contexto" + }, + "args": { + "skill": "Skills", + "agent": "Agentes guardados", + "model": "Modelos", + "attach": "Archivos", + "loop": "Bucles", + "goal": "Objetivo" + }, + "badge": { + "builtin": "integrada", + "addon": "complemento", + "shared": "compartida", + "yours": "tuya" + }, + "collides": "solo con /skill", + "errors": { + "skill": "Este chat no tiene ninguna skill llamada \"{{name}}\".", + "agent": "No tienes ningún agente guardado llamado \"{{name}}\".", + "agentTask": "Escribe la tarea para {{name}} después del nombre.", + "agentsOff": "Los agentes guardados están desactivados en este chat. Actívalos en el menú Herramientas." + }, + "loop": { + "label": "Repetir un prompt", + "desc": "Ejecuta un prompt una y otra vez en este chat: /loop 5m revisa la compilación", + "listDesc": "Muestra los bucles de este chat", + "stopDesc": "Detén un bucle, o todos", + "stopAll": "Todos los bucles", + "stopAllDesc": "Detén todos los bucles de este chat" + }, + "goal": { + "label": "Objetivo", + "desc": "Fija un objetivo en el que el chat sigue trabajando por su cuenta", + "clearDesc": "Quitar el objetivo", + "resumeDesc": "Continuar con el objetivo" + } + }, + "shortcuts": { + "onMessage": "En un mensaje: copiar, editar, regenerar", + "editLast": "Editar tu último mensaje" + }, + "files": { + "title": "Archivos de este chat", + "chip": "Archivos", + "chipTitle": "Archivos de este chat: lo que adjuntaste y lo que el asistente creó o cambió", + "close": "Cerrar archivos", + "emptyTitle": "Todavía no hay archivos en este chat", + "emptyBody": "Suelta archivos aquí para adjuntarlos a tu próximo mensaje. Los archivos que el asistente cree o cambie también aparecen aquí.", + "dropHint": "Suelta archivos aquí para adjuntarlos a tu próximo mensaje.", + "groups": { + "uploaded": "Los diste tú", + "created": "Creados y modificados", + "working": "Archivos de trabajo" + }, + "turn": "turno {{n}}", + "media": "En contexto", + "mediaTitle": "El modelo recibió este archivo como imagen o vídeo con tu mensaje", + "changedTag": "Editado", + "changedTitle": "El asistente cambió este archivo", + "download": "Descargar {{name}}", + "error": "No se pudieron leer los archivos de este chat.", + "workingNote": "Scripts, datos y registros que el asistente creó por el camino. Se guardan en la carpeta de trabajo de este chat." + }, + "steps": { + "verb": { + "read": "Leído", + "list": "Listado", + "write": "Guardado", + "create": "Creado", + "change": "Modificado", + "move": "Movido", + "delete": "Eliminado", + "search": "Buscado", + "run": "Ejecutado", + "fetch": "Descargado", + "websearch": "Buscado en la web:", + "webfetch": "Leído", + "delegate": "Consultó a un agente", + "used": "Usó" + }, + "worked": "Trabajó {{duration}}", + "thoughtFor": "Pensó {{duration}}", + "working": "Trabajando", + "thinking": "Pensando", + "noMatches": "sin coincidencias", + "noResults": "sin resultados", + "emptyFolder": "carpeta vacía", + "size": "{{size}}", + "seconds": "{{value}} s", + "minutes": "{{min}} min {{sec}} s", + "tool": "Herramienta", + "input": "Entrada", + "showLess": "Mostrar menos", + "running": "En curso", + "failed": "Falló", + "count_one": "{{count}} paso", + "count_other": "{{count}} pasos", + "files_one": "{{count}} archivo", + "files_other": "{{count}} archivos", + "items_one": "{{count}} elemento", + "items_other": "{{count}} elementos", + "matches_one": "{{count}} coincidencia", + "matches_other": "{{count}} coincidencias", + "results_one": "{{count}} resultado", + "results_other": "{{count}} resultados", + "lines_one": "{{count}} línea", + "lines_other": "{{count}} líneas", + "showAll_one": "Mostrar las {{count}} líneas", + "showAll_other": "Mostrar las {{count}} líneas", + "details": "Mostrar detalles", + "openFile": "Abrir {{path}} en Archivos", + "savedFiles": "Guardado en tus archivos:", + "fileOp": { + "changed": "cambiado", + "deleted": "eliminado" + }, + "moreFiles": "y {{count}} más", + "alsoSavedWorking_one": "También guardó {{count}} archivo de trabajo", + "alsoSavedWorking_other": "También guardó {{count}} archivos de trabajo", + "savedWorking_one": "Guardó {{count}} archivo de trabajo", + "savedWorking_other": "Guardó {{count}} archivos de trabajo", + "workingTitle": "Archivo de trabajo (un script, un registro o un borrador): abrir {{path}}" + }, + "error": { + "title": "La respuesta falló", + "kept": "Lo escrito hasta ahora se conserva arriba.", + "retry": "Reintentar", + "detail": "Detalle técnico", + "reason": { + "unknown": "El agente se detuvo por un error. Reintenta para ejecutar el turno de nuevo.", + "model_backend_missing": "No se pudo iniciar el modelo \"{{model}}\": su backend no está instalado.", + "model_backend_missing_anon": "No se pudo iniciar el modelo: su backend no está instalado.", + "model_cooldown": "El modelo \"{{model}}\" falló al iniciarse hace un momento y está en pausa.", + "model_cooldown_anon": "El modelo falló al iniciarse hace un momento y está en pausa.", + "model_load_failed": "No se pudo iniciar el modelo \"{{model}}\".", + "model_load_failed_anon": "No se pudo iniciar el modelo.", + "model_not_found": "No se encontró el modelo \"{{model}}\".", + "model_not_found_anon": "No se encontró el modelo.", + "upstream_key": "El upstream rechazó la clave.", + "upstream_path": "El upstream no acepta esta petición en esa dirección.", + "upstream_unreachable": "No se pudo alcanzar la dirección del upstream.", + "upstream": "El proveedor detrás del modelo devolvió un error.", + "timeout": "El modelo tardó demasiado en responder.", + "cancelled": "El turno se canceló antes de que el modelo respondiera." + }, + "hint": { + "model_backend_missing": "Elige otro modelo o pide a un administrador que instale el backend.", + "model_backend_missing_admin": "Elige otro modelo o instala el backend desde la página Backends.", + "model_cooldown": "Inténtalo de nuevo en {{seconds}} segundos.", + "model_load_failed": "Revisa la configuración del modelo y su backend, y reintenta.", + "model_not_found": "Elige otro modelo o instala este desde la página Modelos.", + "upstream_key": "Revisa la clave de API en los ajustes del modelo.", + "upstream_path": "La dirección del upstream debe ser el endpoint completo, por ejemplo .../v1/chat/completions.", + "upstream_unreachable": "Revisa la dirección en los ajustes del modelo.", + "upstream": "Reintenta en un momento. Si sigue fallando, revisa la dirección y la clave del proveedor.", + "timeout": "Reintenta o usa un modelo más pequeño o más rápido.", + "cancelled": "Reintenta cuando quieras." + }, + "openBackends": "Abrir Backends" + }, + "allChats": { + "title": "Todos los chats", + "supporting": "Todos los chats guardados. Busca, renombra, exporta o elimina.", + "loadFailed": "No se pudieron cargar tus chats.", + "emptyTitle": "Aún no hay chats", + "emptyBody": "Un chat empieza en Inicio: escribe un mensaje y envíalo. Después, tus chats aparecen aquí.", + "start": "Empezar un chat" + }, + "foot": { + "state": { + "ready": "Listo", + "nomodel": "Sin modelo", + "sending": "Enviando", + "loading": "Cargando el modelo", + "thinking": "Pensando", + "tool": "Ejecutando {{tool}}", + "answering": "Respondiendo", + "waiting": "Esperándote", + "stopped": "Detenido", + "failed": "Falló", + "interrupted": "Interrumpido", + "paused": "En pausa", + "compacting": "Compactando el contexto", + "clearing": "Vaciando el contexto" + }, + "tool": { + "search": "Buscando", + "list": "Listando", + "read": "Leyendo", + "create": "Creando", + "write": "Escribiendo", + "change": "Cambiando", + "move": "Moviendo", + "delete": "Eliminando", + "run": "Ejecutando un comando", + "fetch": "Descargando", + "delegate": "Consultando a un subagente" + }, + "hint": "Enter para enviar · Mayús+Enter para una nueva línea", + "meter": { + "label": "Contexto al {{pct}} %. Ver contexto y velocidad", + "labelNoLimit": "Contexto de {{used}} tokens. Ver contexto y velocidad" + }, + "details": { + "title": "Contexto y velocidad", + "context": "Contexto", + "contextOf": "{{used}} de {{limit}} tokens ({{pct}} %)", + "contextUsed": "{{used}} tokens", + "contextLeft": "Quedan {{left}} tokens", + "contextHelp": "El contexto es lo que el modelo lee en cada solicitud: las instrucciones, este chat hasta ahora y los resultados de las herramientas.", + "nearFull": "Este chat está cerca del límite. El servidor compacta la parte más antigua para que el modelo pueda seguir. La transcripción conserva todos los mensajes.", + "limitUnknown": "El servidor no indicó el tamaño de la ventana de contexto.", + "lastRequest": "Última solicitud", + "model": "Modelo", + "backend": "Backend", + "node": "Nodo", + "tokensIn": "Tokens de entrada", + "tokensOut": "Tokens de salida", + "firstToken": "Tiempo hasta el primer token", + "promptTime": "Procesamiento del prompt", + "speed": "Velocidad", + "speedLive": "Velocidad estimada durante la transmisión", + "average": "Media de las últimas {{count}} solicitudes", + "note": "Estas cifras vienen de las solicitudes que vio esta página. El servidor no las guarda, así que al recargar se borran." + }, + "paused": { + "after": "tras {{count}} pasos", + "atLimit": "en el límite de pasos", + "continue": "Continuar", + "continueTitle": "El modelo usó todos los pasos de un turno. Deja que siga desde donde se detuvo.", + "message": "Continúa desde donde te detuviste." + } + }, + "attachments": { + "label": "Archivos adjuntos", + "open": "Abrir {{name}}", + "showMore": "Mostrar {{count}} más", + "missing": "El archivo ya no está disponible" + }, + "preview": { + "open": "Abrir vista previa", + "created": "Creado por el asistente", + "changed": "Modificado por el asistente", + "yours": "Lo diste tú", + "fromReply": "De la respuesta", + "download": "Descargar", + "downloadCode": "Descargar código", + "copy": "Copiar", + "copied": "Copiado", + "openInFiles": "Abrir en Archivos", + "actions": "Acciones del archivo", + "opened": "Vista previa abierta: {{name}}", + "chip": { + "created": "Creado", + "changed": "Modificado", + "label": "{{verb}} {{name}}, abrir vista previa", + "row": "Archivos de esta respuesta" + } + }, + "meter": { + "usedOf": "{{used}} / {{limit}} tokens ({{pct}} %)", + "used": "{{used}} tokens", + "speed": "{{speed}} tok/s", + "title": "Contexto que usó la última solicitud al modelo", + "ofLimit": "{{used}} / {{limit}} tokens", + "left": "quedan {{left}}" + }, + "goal": { + "label": "Objetivo", + "states": { + "active": "En marcha", + "paused": "En pausa", + "done": "Logrado", + "blocked": "Te necesita" + }, + "pausedBy": { + "user": "Lo pausaste tú.", + "turn_limit": "Usó todos sus turnos automáticos. Reanúdalo para darle más.", + "step_limit": "Usó todos sus pasos. Reanúdalo para darle más.", + "error": "Un turno falló. Reanúdalo para intentarlo de nuevo.", + "interrupted": "El servidor se detuvo durante un turno. Reanúdalo para continuar." + }, + "blockedNote": "Necesita de ti: {{note}}", + "doneNote": "{{note}}", + "replaced": "Reemplazado: {{text}}", + "pause": "Pausar", + "pauseTitle": "Detener el turno en curso y pausar el objetivo", + "resume": "Reanudar", + "resumeTitle": "Continuar con el objetivo", + "clear": "Quitar", + "clearTitle": "Quitar el objetivo", + "progress": "Objetivo · turno {{turn}} de {{max}}", + "progressStart": "Objetivo · empezando", + "markers": { + "set": "Objetivo fijado", + "replaced": "Objetivo reemplazado", + "continue": "Sigue hacia el objetivo · turno {{turn}}", + "continueStart": "Sigue hacia el objetivo", + "paused": "Objetivo en pausa", + "resumed": "Objetivo reanudado", + "done": "Objetivo logrado", + "blocked": "Objetivo bloqueado", + "cleared": "Objetivo quitado" + }, + "announce": { + "set": "Objetivo fijado: {{text}}", + "paused": "Objetivo en pausa. {{why}}", + "resumed": "Objetivo reanudado", + "done": "Objetivo logrado: {{note}}", + "blocked": "El objetivo te necesita: {{note}}", + "cleared": "Objetivo quitado" + }, + "card": { + "title": "Objetivo", + "none": "Este chat no tiene objetivo. Escribe /goal y lo que quieres conseguir, y el chat seguirá trabajando en ello por su cuenta.", + "turns": "Turno {{turn}} de {{max}}", + "steps": "{{steps}} de {{max}} pasos", + "clear": "/goal clear lo quita.", + "resume": "/goal resume continúa con él.", + "turnsLabel": "Turnos automáticos", + "stepsLabel": "Pasos", + "setLabel": "Fijado", + "stateLabel": "Estado", + "noteLabel": "Nota" + }, + "errors": { + "empty": "Escribe cuál es el objetivo después de /goal.", + "notAllowed": "Solo un chat que iniciaste tú puede tener un objetivo." + } + }, + "invoked": { + "skill": "Skill: {{name}}", + "agent": "Agente: {{name}}" + }, + "marker": { + "compacted": "Contexto compactado: de {{before}} a {{after}} tokens", + "cleared": "Contexto vaciado", + "clearedHint": "El modelo no ve los mensajes por encima de esta línea." + }, + "cmdcard": { + "close": "Cerrar", + "helpTitle": "Comandos", + "helpSkills_one": "Skill: {{names}}. Escribe / y su nombre.", + "helpSkills_other": "{{count}} skills: {{names}}. Escribe / y el nombre.", + "helpKeys": "Escribe / en el cuadro para abrir la lista. Tab completa, Intro ejecuta, Esc cierra.", + "aboutTitle": "Este chat", + "model": "Modelo", + "state": "Estado", + "loaded": "Cargado", + "notLoaded": "Sin cargar", + "backend": "Backend", + "tools": "Herramientas", + "toolsOn_one": "{{count}} activa", + "toolsOn_other": "{{count}} activas", + "skills": "Skills", + "skillsOn_one": "{{count}} skill", + "skillsOn_other": "{{count}} skills", + "commands": "Comandos", + "isolated": "Se ejecutan en un sandbox aislado", + "host": "Se ejecutan en esta máquina", + "noCommands": "No se pueden ejecutar", + "context": "Contexto", + "contextOf": "{{used}} de {{limit}} tokens", + "contextUsed": "{{used}} tokens", + "unknown": "Aún no se sabe" } } diff --git a/core/http/react-ui/public/locales/es/chats.json b/core/http/react-ui/public/locales/es/chats.json new file mode 100644 index 000000000..013a149e7 --- /dev/null +++ b/core/http/react-ui/public/locales/es/chats.json @@ -0,0 +1,581 @@ +{ + "proposal": { + "loading": "Leyendo el disparador propuesto...", + "unread": "No se pudo leer el disparador propuesto: {{message}}", + "removed": "Este disparador se eliminó.", + "retry": "Reintentar", + "inactive": "Disparador propuesto. No ejecuta nada hasta que lo crees.", + "name": "Nombre", + "schedule": "Cuándo (por ejemplo daily 08:00)", + "folder": "Carpeta a vigilar", + "saveEdit": "Guardar", + "cancelEdit": "Cancelar", + "create": "Crear disparador", + "edit": "Editar", + "dismiss": "Rechazar", + "receipt": { + "scheduled": "Programado:", + "watching": "Vigilando:", + "next": "Próxima ejecución {{when}}", + "view": "Ver disparador", + "undo": "Deshacer", + "undone": "Deshecho:", + "again": "Programarlo de nuevo", + "declined": "Rechazado:", + "restore": "Restaurar", + "blocked": { + "ran": "Ya se ejecutó una vez; elimínalo desde la página Disparadores.", + "edited": "Lo editaste después; cámbialo o elimínalo desde la página Disparadores." + } + }, + "announce": { + "accepted": "{{name}} programado.", + "undone": "Se deshizo {{name}}. No está programado.", + "declined": "{{name}} rechazado.", + "reopened": "{{name}} espera de nuevo tu respuesta." + }, + "plan": { + "whenLabel": "Cuándo", + "runsAsLabel": "Se ejecuta como", + "readsLabel": "Lee", + "writesLabel": "Escribe", + "costLabel": "Coste", + "stopLabel": "Para detenerlo", + "when": "{{schedule}}, {{zone}}. Primera ejecución {{when}}.", + "whenNoFirst": "{{schedule}}, {{zone}}.", + "whenFile": "Cuando llega un archivo a {{folder}}.", + "runsAs": "Tú", + "runsAsModel": "Tú, con {{model}}", + "writesWidget": ", que se muestra en el widget «{{widget}}» de Inicio", + "costLocal_one": "Unos {{tokens}} tokens por ejecución en un modelo local. Como máximo {{count}} ejecución al día.", + "costLocal_other": "Unos {{tokens}} tokens por ejecución en un modelo local. Como máximo {{count}} ejecuciones al día.", + "costRemote_one": "Unos {{tokens}} tokens por ejecución en {{model}}, un servicio remoto. Como máximo {{count}} ejecución al día.", + "costRemote_other": "Unos {{tokens}} tokens por ejecución en {{model}}, un servicio remoto. Como máximo {{count}} ejecuciones al día.", + "costUnknown_one": "Los tokens de una ejecución se conocen tras la primera. Como máximo {{count}} ejecución al día.", + "costUnknown_other": "Los tokens de una ejecución se conocen tras la primera. Como máximo {{count}} ejecuciones al día.", + "remoteNote": "{{model}} es un servicio remoto: lo que lee una ejecución sale de este servidor.", + "stopWidget": "Páusalo desde el widget o elimínalo en Disparadores", + "stop": "Apágalo o elimínalo en Disparadores" + } + }, + "mcpPage": { + "title": "Conectores MCP", + "supporting": "Conecta servidores MCP para dar a tus chats y agentes más herramientas, como búsqueda o tus propias API.", + "family": "Los bots de Telegram o Slack son conectores de mensajería.", + "related": "Relacionado", + "chats": "Chats", + "add": "Añadir conector", + "learn": "Cómo funciona MCP", + "loading": "Cargando tus conectores", + "loadFailed": "No se pudieron cargar tus conectores.", + "forbidden": "MCP no está disponible para tu cuenta.", + "empty": { + "title": "Todavía no hay conectores", + "body": "Un conector une un servidor MCP a LocalAI. Tus chats y agentes pueden usar entonces sus herramientas, por ejemplo para buscar en tu documentación o leer un calendario.", + "fact1": "Cualquier servidor MCP por http o https", + "fact2": "Tokens y cabeceras guardados cifrados", + "fact3": "Pruébalo antes de que lo usen los chats" + }, + "toolbar": { + "search": "Buscar conectores", + "testAll": "Probar todos", + "testingAll": "Probando", + "testedAll": "Se probaron los conectores activados." + }, + "filter": { + "label": "Mostrar", + "all": "Todos", + "on": "Activados", + "attention": "Requieren atención", + "off": "Desactivados", + "showAll": "Mostrar todos", + "none": { + "all": "No hay conectores.", + "on": "Ningún conector está activado.", + "attention": "Nada requiere tu atención.", + "off": "Todos los conectores están activados." + } + }, + "noMatch": { + "title": "Ningún conector coincide con «{{query}}»", + "body": "Busca por nombre o por dirección.", + "clear": "Borrar la búsqueda" + }, + "state": { + "label": "Estado: {{state}}", + "ok": "Conectado", + "auth": "Requiere acceso", + "error": "No conecta", + "unchecked": "Sin probar", + "off": "Desactivado" + }, + "row": { + "open": "{{name}}, {{state}}. Ver detalles", + "toggle": "Usar {{name}} en los chats", + "menu": "Acciones de {{name}}", + "offLine": "Desactivado. Los chats y agentes no lo usan.", + "notChecked": "Aún sin probar. Pruébalo para ver sus herramientas.", + "noTools": "Conectado, pero el servidor no ofrece herramientas.", + "tools_one": "{{count}} herramienta", + "tools_other": "{{count}} herramientas", + "checked": "Probado {{when}}", + "tokenSaved": "Token guardado", + "headersSaved": "Cabeceras guardadas", + "envSaved": "Variables guardadas" + }, + "actions": { + "details": "Detalles", + "test": "Probar la conexión", + "testNamed": "Probar la conexión con {{name}}", + "testing": "Probando", + "edit": "Editar", + "reauth": "Reemplazar el token", + "remove": "Quitar" + }, + "details": { + "use": "Usar en los chats", + "useHelp": "Los chats sin agente usan todos los conectores activados. Un agente guardado usa solo los que elijas para él.", + "lastCheck": "Última prueba", + "never": "Aún no se ha probado en este navegador.", + "okAt": "Conectado {{when}}.", + "testedAt": "Probado {{when}}.", + "errorTitle": "La última prueba no pudo conectar.", + "authTitle": "El servidor rechazó el acceso.", + "tools": "Herramientas", + "toolsUnknown": "Prueba la conexión para ver las herramientas.", + "toolsNone": "El servidor no ofrece herramientas.", + "toolsSearch": "Buscar herramientas", + "noToolMatch": "Ninguna herramienta coincide con «{{query}}».", + "noDescription": "Sin descripción.", + "connection": "Conexión", + "kind": "Tipo", + "address": "Dirección", + "command": "Comando", + "added": "Añadido", + "queryHidden": "La parte de la dirección después de ? está oculta porque puede contener una clave.", + "signIn": "Acceso", + "token": "Token bearer", + "headers": "Cabeceras", + "env": "Variables", + "saved": "Guardado", + "none": "Ninguno", + "encrypted": "Guardado cifrado en el servidor. No se vuelve a mostrar." + }, + "sheet": { + "addTitle": "Añadir conector", + "editTitle": "Editar {{name}}", + "addDesc": "Conecta un servidor MCP. Lo pruebas antes de que lo usen los chats.", + "editDesc": "Los secretos guardados se mantienen salvo que los reemplaces o los quites.", + "testDesc": "LocalAI se conecta con lo que guardaste y enumera las herramientas.", + "close": "Cerrar", + "kind": "Tipo", + "remote": "Servidor remoto", + "remoteHelp": "Un servidor MCP al que esta máquina llega por http o https.", + "local": "Comando local", + "localHelp": "Un programa que LocalAI inicia en esta máquina. Solo administradores.", + "basics": "Conexión", + "name": "Nombre", + "nameHelp": "Aparece en el menú Herramientas de un chat.", + "url": "Dirección", + "urlHelp": "Los servidores de una red privada se rechazan salvo que un administrador los permita.", + "command": "Comando", + "commandHelp": "El programa que se inicia, sin shell.", + "args": "Argumentos", + "argsHelp": "Un argumento por línea.", + "signIn": "Acceso", + "signInHelp": "Solo si el servidor pide una clave.", + "envIntro": "Variables que recibe el comando, por ejemplo una clave de API.", + "token": "Token bearer", + "tokenHelp": "Se envía como cabecera Authorization.", + "tokenSaved": "Hay un token guardado. Escribe uno nuevo para reemplazarlo.", + "tokenSavedShort": "Guardado", + "clearToken": "Quitar el token guardado", + "headers": "Cabeceras", + "headersHelp": "Otras cabeceras que necesita el servidor, como una cabecera con la clave de API.", + "headersSaved": "Las cabeceras están guardadas. Las filas nuevas las reemplazan todas.", + "clearHeaders": "Quitar las cabeceras guardadas", + "headerName": "Nombre de la cabecera", + "headerValue": "Valor", + "addHeader": "Añadir una cabecera", + "env": "Variables de entorno", + "envSaved": "Las variables están guardadas. Las filas nuevas las reemplazan todas.", + "clearEnv": "Quitar las variables guardadas", + "envName": "Variable", + "addEnv": "Añadir una variable", + "removeRow": "Quitar la fila {{n}}", + "showValue": "Mostrar el valor", + "hideValue": "Ocultar el valor", + "encrypted": "Los tokens, las cabeceras y las variables se guardan cifrados en el servidor y no se vuelven a mostrar.", + "cancel": "Cancelar", + "saving": "Guardando", + "saveAndTest": "Guardar y probar", + "savedOn": "Guardado. Está activado.", + "savedOff": "Guardado. Sigue desactivado hasta que lo actives.", + "testing": "Conectando con {{name}}…", + "okTitle": "Conectado.", + "okBody_one": "{{name}} ofrece {{count}} herramienta.", + "okBody_other": "{{name}} ofrece {{count}} herramientas.", + "okNoTools": "El servidor no ofrece herramientas.", + "failTitle": "No se pudo conectar.", + "failHint": "Revisa la dirección y vuelve a probar.", + "failAuth": "Revisa el token o las cabeceras.", + "back": "Editar los datos", + "retry": "Probar de nuevo", + "done": "Listo", + "keepOff": "Dejarlo desactivado", + "turnOn": "Activar", + "discardTitle": "¿Descartar los cambios?", + "discardBody": "Se pierde lo que escribiste en este formulario.", + "discard": "Descartar", + "keepEditing": "Seguir editando" + }, + "errors": { + "nameRequired": "Ponle un nombre.", + "nameTaken": "Otro conector tiene este nombre.", + "urlRequired": "Escribe la dirección del servidor.", + "urlInvalid": "Esta dirección no es válida.", + "urlScheme": "La dirección debe empezar por http:// o https://.", + "urlCredentials": "Pon las credenciales en el token o en una cabecera, no en la dirección.", + "commandRequired": "Escribe el comando que se inicia." + }, + "remove": { + "title": "¿Quitar {{name}}?", + "body": "El conector se elimina con su token, sus cabeceras y sus variables guardados. Un agente guardado que lo use ya no puede llamar a sus herramientas.", + "confirm": "Quitar", + "pending": "Quitando" + }, + "toasts": { + "removed": "«{{name}}» quitado.", + "on": "«{{name}}» está activado.", + "off": "«{{name}}» está desactivado." + }, + "import": { + "title": "Servidores guardados en este navegador", + "text": "La antigua página de chats guardaba estos servidores MCP en este navegador. Impórtalos para guardarlos en el servidor, donde cualquier navegador y sesión puede usarlos. Se crean desactivados. Sus tokens y cabeceras van al servidor y luego se eliminan de este navegador.", + "button": "Importar {{count}} servidores de este navegador", + "buttonOne": "Importar 1 servidor de este navegador", + "busy": "Importando", + "headersCount": "{{count}} cabeceras", + "done": "Se importaron {{count}}. Están desactivados: pruébalos y actívalos abajo.", + "failed": "{{name}} no se importó: {{error}}", + "left": "Los servidores que no se importaron siguen en este navegador." + } + }, + "connectorsPage": { + "loadFailed": "No se pudieron cargar tus conectores.", + "waitingTitle": "Esperando su complemento", + "waitingText": "Estos enlaces se conservan, pero no funcionan hasta que su complemento esté instalado y activado." + }, + "tools": { + "commands": { + "title": "Aquí no se pueden ejecutar comandos", + "none": "No hay ningún complemento instalado que ejecute código: el asistente puede escribir archivos, pero no ejecutar scripts ni comandos.", + "off": "El complemento Sandbox está desactivado en este chat. Actívalo en el menú Herramientas para que el asistente pueda ejecutar código.", + "unavailable": "El complemento Sandbox está instalado, pero no puede ejecutar comandos en este servidor.", + "unavailableWhy": "El complemento Sandbox no puede ejecutar comandos en este servidor: {{reason}}", + "install": "Instalar Sandbox", + "openSandbox": "Abrir el complemento Sandbox", + "askInstall": "Pide a un administrador que instale el complemento Sandbox.", + "askFix": "Pide a un administrador que lo solucione.", + "hide": "Ocultar", + "hideLabel": "Ocultar este aviso", + "chipTitle": "Este chat no puede ejecutar código ni comandos. Selecciona para ver detalles.", + "hostTitle": "Los comandos se ejecutan en esta máquina", + "host": "Los comandos se ejecutan en esta máquina, con sus archivos y su red, sin aislamiento. Instala el Sandbox para aislarlos.", + "hostOff": "El Sandbox está desactivado en este chat, así que los comandos se ejecutan en esta máquina, con sus archivos y su red, sin aislamiento. Activa el Sandbox en este menú para aislarlos.", + "hostUnavailable": "El Sandbox no puede ejecutarse aquí, así que los comandos se ejecutan en esta máquina, con sus archivos y su red, sin aislamiento.", + "hostUnavailableWhy": "El Sandbox no puede ejecutarse aquí ({{reason}}), así que los comandos se ejecutan en esta máquina, con sus archivos y su red, sin aislamiento.", + "hostChipTitle": "Los comandos de este chat se ejecutan en esta máquina, sin aislamiento. Selecciona para ver detalles." + }, + "consent": { + "title": "¿Ejecutar este comando en esta máquina?", + "body": "Los comandos se ejecutan en esta máquina sin aislamiento. Pueden leer los archivos que LocalAI puede leer y llegar a internet y a tu red. Instala el Sandbox para aislarlos.", + "once": "Ejecutar una vez", + "always": "Siempre en esta instancia", + "deny": "No ejecutar", + "alwaysHint": "«Siempre en esta instancia» deja que los chats de todos los usuarios ejecuten comandos aquí sin preguntar (Ajustes > Herramientas).", + "askAdmin": "Solo un administrador puede permitir comandos en esta máquina. Pídeselo a uno o ejecuta tú el comando.", + "expired": "Sin respuesta: el turno que lo pidió ha terminado, así que el comando no se ejecutó." + }, + "provider": { + "sandbox": "Sandbox", + "addon": "Complemento {{name}}", + "host": "Esta máquina, sin aislar", + "hostOnce": "Esta máquina, permitido una vez", + "hostDenied": "No ejecutado: rechazado", + "hostTimeout": "No ejecutado: sin respuesta", + "hostAdmin": "No ejecutado: requiere un administrador" + }, + "builtins": "Herramientas integradas", + "builtin": { + "web": "Web", + "saved_agents": "Agentes guardados", + "triggers": "Disparadores", + "shell": "Ejecutar comandos", + "files": "Tus archivos", + "media": "Imágenes y audio", + "collections": "Colecciones", + "subagents": "Subagentes" + }, + "builtinBy": "{{tools}} · integradas", + "builtinByAddon": "{{tools}} · servidas por {{provider}}", + "servedBy": "Servido por el complemento {{provider}}", + "servedByTool": "{{tool}}: {{provider}}", + "servedByBuiltin": "integrada", + "sandbox": { + "starting": "El Sandbox se está iniciando. LocalAI vuelve a comprobarlo en {{seconds}} s." + }, + "fallbackBuiltin": "{{addon}} reemplaza {{tool}} pero ahora no puede atender este chat (detenido, desactivado aquí o no concedido): se usa {{tool}} integrada.", + "fallbackNone": "{{addon}} reemplaza {{tool}} pero ahora no puede atender este chat, y {{tool}} integrada está desactivada: este chat no tiene {{tool}}.", + "shell": { + "machine": "Esta máquina", + "isolated": "Los comandos se ejecutan aislados" + } + }, + "vision": { + "describedBy": "Este modelo no puede ver imágenes. Elige otro modelo o la imagen la describirá {{model}}.", + "none": "Este modelo no puede ver imágenes y ningún modelo aquí puede describirlas. Elige un modelo que pueda ver imágenes." + }, + "egress": { + "label": "Red", + "preset": { + "off": "Desactivada", + "registries": "Registros de paquetes", + "all": "Todos los sitios públicos" + }, + "presetHint": { + "off": "Los comandos del Sandbox no llegan a ninguna red.", + "registries": "Los comandos solo llegan a los registros de paquetes (PyPI, npm, Go, crates.io, RubyGems, Maven Central, versiones de GitHub).", + "all": "Los comandos llegan a cualquier sitio público." + }, + "limited": "Limitado por tu administrador", + "limitedBy": "Tu administrador permite como máximo: {{limit}}.", + "hostNetwork": "Los comandos se ejecutan en esta máquina y usan su red. Los límites de red requieren el complemento Sandbox.", + "panel": "Reglas y solicitudes ({{count}})", + "rules": "Reglas de este chat", + "cards": "Solicitudes de este chat", + "ruleAllow": "Permitido en este chat", + "ruleDeny": "Denegado en este chat", + "aboveLimit": "por encima del límite, no se aplica", + "remove": "Quitar", + "removeLabel": "Quitar la regla de {{host}}", + "card": { + "pending": "Esperando respuesta", + "allowedOnce": "Permitido una vez", + "allowedChat": "Permitido en este chat", + "denied": "Denegado", + "expired": "Sin respuesta", + "refused": "Rechazado: el límite de red del servidor no lo permite" + }, + "consent": { + "title": "Acceso a la red", + "body": "Un comando de este chat quiere llegar a", + "once": "Permitir una vez", + "chat": "Permitir en este chat", + "deny": "Denegar", + "preset": "Este chat: {{preset}}. Límite de tu administrador: {{limit}}.", + "adminOnly": "Este host está dentro de tu red: solo un administrador puede permitirlo.", + "answered": "{{state}} por {{who}}, {{time}}", + "answeredNoWho": "{{state}}, {{time}}", + "you": "ti" + }, + "blocked": { + "titleOff": "El acceso a la red está desactivado", + "titleCapped": "El acceso a la red está limitado", + "bodyOff": "El acceso a la red del Sandbox está desactivado en este servidor. Un comando pidió llegar a", + "bodyCapped": "Este servidor solo permite {{limit}} para la red del Sandbox. Un comando pidió llegar a", + "adminOff": "Un administrador puede activarlo en los ajustes de {{name}}.", + "adminCapped": "Un administrador puede subir el límite en los ajustes de {{name}}.", + "adminLink": "Abrir los ajustes de {{name}}", + "askAdmin": "Pide a un administrador de este servidor que lo cambie.", + "rowOff": "Desactivada: el administrador no ha permitido el acceso a la red.", + "runner": "el complemento que ejecuta los comandos" + }, + "allowedHosts": "Siempre permitidos por tu administrador:", + "allowedHostsOff": "Se aplican cuando la red está activada.", + "allowedHostsNote": "Tu administrador permite siempre {{count}} hosts.", + "limitNote": "Red de este chat. El administrador fija lo máximo que un chat puede alcanzar.", + "asksAbove": "Este chat pide {{choice}}; el límite del administrador es {{limit}}." + }, + "inbox": { + "unread": "{{count}} sin leer", + "markAll": "Marcar todo como leído", + "delete": "Eliminar", + "pushOn": "Activar las notificaciones del navegador", + "pushOff": "Desactivar las notificaciones del navegador", + "pushDenied": "Las notificaciones del navegador están bloqueadas para este sitio.", + "pushUnavailable": "Este servidor no puede guardar suscripciones push.", + "clearAll": "Borrar todo", + "clearRead": "Borrar leídas", + "cancel": "Cancelar", + "markRead": "Marcar como leída", + "cleared_one": "Se eliminó {{count}} notificación.", + "cleared_other": "Se eliminaron {{count}} notificaciones.", + "confirmAll_one": "¿Eliminar {{count}} notificación? No se puede deshacer.", + "confirmAll_other": "¿Eliminar las {{count}} notificaciones? No se puede deshacer.", + "confirmRead_one": "¿Eliminar {{count}} notificación leída? Las no leídas se conservan.", + "confirmRead_other": "¿Eliminar {{count}} notificaciones leídas? Las no leídas se conservan.", + "onlyRead": "Solo las {{count}} leídas", + "showing": "Mostrando {{shown}} de {{total}}", + "showMore": "Mostrar más", + "title": "Notificaciones", + "buttonUnread_one": "Notificaciones, {{count}} sin leer", + "buttonUnread_other": "Notificaciones, {{count}} sin leer", + "filterLabel": "Mostrar notificaciones", + "filterAll": "Todas", + "filterUnread": "Sin leer", + "filterFailed": "Con error", + "more": "Más acciones", + "close": "Cerrar", + "day": { + "today": "Hoy", + "yesterday": "Ayer", + "earlier": "Antes" + }, + "emptyTitle": "Aún no hay notificaciones", + "emptyBody": "Aquí aparecen los resultados de tus disparadores y conectores, también del trabajo que terminó mientras no estabas.", + "caughtUp": "Estás al día.", + "caughtUpTitle": "Estás al día", + "caughtUpBody": "No hay notificaciones sin leer.", + "noFailedTitle": "Nada ha fallado", + "noFailedBody": "Aquí aparecen las ejecuciones y los conectores que fallan.", + "loadError": "No se pudieron cargar tus notificaciones.", + "source": { + "trigger": "Disparador", + "connector": "Conector", + "system": "LocalAI" + }, + "itemUnread": "{{title}}, sin leer", + "arrived": "Nueva notificación: {{title}}", + "arrivedMany_one": "{{count}} notificación nueva", + "arrivedMany_other": "{{count}} notificaciones nuevas" + }, + "change": { + "loading": "Leyendo el cambio del disparador...", + "unread": "No se pudo leer el cambio del disparador: {{message}}", + "removed": "Este cambio ya no se conserva.", + "none": "ninguno", + "on": "Activado", + "off": "Desactivado (en pausa)", + "becomes": "pasa a", + "neverRan": "Aún no se ha ejecutado", + "lastRun": "Última ejecución", + "lastRunValue": "{{status}}, {{when}}", + "runStatus": { + "queued": "En cola", + "running": "En ejecución", + "done": "Terminada", + "failed": "Fallida", + "skipped": "Omitida" + }, + "updateTitle": "Cambiar {{name}}", + "deleteTitle": "¿Eliminar {{name}}?", + "updateHelp": "Cambio propuesto. No cambia nada hasta que lo apliques.", + "deleteHelp": "No se elimina nada hasta que pulses Eliminar. Puedes deshacerlo durante un día.", + "stale": "El disparador se cambió o se eliminó después de la propuesta, así que el cambio no se puede aplicar. Pide el cambio de nuevo.", + "apply": "Aplicar", + "delete": "Eliminar", + "keep": "Conservar", + "field": { + "name": "Nombre", + "prompt": "Prompt", + "when": "Cuándo", + "time_zone": "Zona horaria", + "daily_cap": "Límite diario", + "enabled": "Estado", + "reads": "Lee", + "writes": "Escribe", + "widget": "Widget de Inicio", + "estimated_tokens": "Tokens por ejecución", + "agent": "Agente", + "model": "Modelo" + }, + "receipt": { + "update": "Cambiado:", + "pause": "En pausa:", + "resume": "Reanudado:", + "delete": "Eliminado:", + "fields": "Qué cambió: {{fields}}", + "dismissed": "Cambio descartado:", + "kept": "Conservado:", + "undone": "Deshecho:", + "restored": "Restaurado:" + }, + "blocked": { + "expired": "Se acabó el tiempo para deshacerlo; cambia el disparador en la página Disparadores.", + "edited": "El disparador se cambió desde entonces; cámbialo en la página Disparadores.", + "removed": "El disparador se eliminó desde entonces.", + "full": "Tienes el máximo de disparadores permitido; elimina uno en la página Disparadores primero." + }, + "announce": { + "applied": "{{name}} cambiado.", + "deleted": "{{name}} eliminado.", + "dismissed": "Cambio de {{name}} descartado.", + "kept": "{{name}} conservado.", + "undone": "Cambio de {{name}} deshecho.", + "restored": "{{name}} restaurado." + } + }, + "loops": { + "region": "Bucles de este chat", + "unit": { + "seconds_one": "{{count}} segundo", + "seconds_other": "{{count}} segundos", + "minutes_one": "{{count}} minuto", + "minutes_other": "{{count}} minutos", + "hours_one": "{{count}} hora", + "hours_other": "{{count}} horas" + }, + "unitShort": { + "seconds_one": "{{count}} s", + "seconds_other": "{{count}} s", + "minutes_one": "{{count}} min", + "minutes_other": "{{count}} min", + "hours_one": "{{count}} h", + "hours_other": "{{count}} h" + }, + "confirm": { + "every": "¿Ejecutar esto cada {{every}} hasta que lo detengas?", + "selfPaced": "¿Ejecutar esto de nuevo cada vez que el modelo lo pida, hasta que lo detengas?", + "start": "Iniciar", + "cancel": "Cancelar" + }, + "chip": { + "name": "Bucle", + "every": "cada {{every}}", + "selfPaced": "al ritmo del modelo", + "run_one": "ejecución {{count}}", + "run_other": "ejecución {{count}}", + "paused": "en pausa", + "running": "en curso", + "next": "próxima a las {{time}}", + "ended": "terminado", + "wait": "Esperando: {{wait}}" + }, + "actions": { + "pause": "Pausar", + "resume": "Reanudar", + "stop": "Detener", + "dismiss": "Descartar" + }, + "state": { + "pending": "espera tu confirmación", + "active": "activo", + "paused": "en pausa", + "ended": "terminado" + }, + "list": { + "title": "Bucles de este chat", + "close": "Cerrar", + "empty": "Este chat no tiene bucles.", + "hint": "Para detener uno, escribe /loop stop y el código del inicio de su línea." + }, + "slash": { + "usage": "Escribe /loop, un intervalo si quieres uno y lo que se debe repetir. Por ejemplo: /loop 5m revisa la compilación.", + "notFound": "Ningún bucle de este chat coincide con {{id}}.", + "noChat": "Envía un primer mensaje y luego inicia un bucle en este chat.", + "stopped_one": "Se detuvo {{count}} bucle.", + "stopped_other": "Se detuvieron {{count}} bucles." + } + } +} diff --git a/core/http/react-ui/public/locales/es/collections.json b/core/http/react-ui/public/locales/es/collections.json index 6422067b5..49f505c69 100644 --- a/core/http/react-ui/public/locales/es/collections.json +++ b/core/http/react-ui/public/locales/es/collections.json @@ -4,13 +4,13 @@ "newPlaceholder": "Nombre de la nueva colección...", "actions": { "create": "Crear", - "creating": "Creando...", "details": "Detalles", "reset": "Restablecer", "delete": "Eliminar", "viewDetails": "Ver detalles", "resetCollection": "Restablecer colección", - "deleteCollection": "Eliminar colección" + "deleteCollection": "Eliminar colección", + "cancel": "Cancelar" }, "sections": { "yourCollections": "Tus colecciones", @@ -19,7 +19,8 @@ "empty": { "title": "Aún no hay colecciones", "text": "Las colecciones te permiten organizar documentos en bases de conocimiento que los agentes pueden buscar usando RAG (Retrieval-Augmented Generation). Crea una colección arriba para empezar.", - "noPersonal": "Aún no tienes colecciones." + "noPersonal": "Aún no tienes colecciones.", + "learn": "Cómo funciona la memoria" }, "deleteDialog": { "title": "Eliminar colección", @@ -32,12 +33,29 @@ "confirm": "Restablecer" }, "toasts": { - "loadFailed": "Error al cargar colecciones: {{message}}", "created": "Colección \"{{name}}\" creada", "createFailed": "Error al crear colección: {{message}}", "deleted": "Colección \"{{name}}\" eliminada", "deleteFailed": "Error al eliminar colección: {{message}}", "reset": "Colección \"{{name}}\" restablecida", "resetFailed": "Error al restablecer colección: {{message}}" + }, + "loadFailed": "No se pudieron cargar tus colecciones.", + "sources": { + "loadFailed": "No se pudieron cargar las fuentes web." + }, + "entries": { + "loadFailed": "No se pudieron cargar los archivos." + }, + "starting": { + "title": "La memoria se está iniciando.", + "text": "El servidor aún está preparando el servicio de memoria. Esta página vuelve a preguntar sola." + }, + "embedding": { + "missingTitle": "No hay ningún modelo de embeddings instalado.", + "missingText": "Las colecciones usan {{model}} para indexar los archivos y buscar en ellos. Hasta que se instale, añadir archivos y buscar fallan.", + "noneSet": "No hay ningún modelo de embeddings configurado para las colecciones (collections_embedding_model), así que añadir archivos y buscar fallan.", + "install": "Buscarlo en Modelos", + "askAdmin": "Pide a un administrador que lo instale." } } diff --git a/core/http/react-ui/public/locales/es/common.json b/core/http/react-ui/public/locales/es/common.json index 3923ad092..47aad7311 100644 --- a/core/http/react-ui/public/locales/es/common.json +++ b/core/http/react-ui/public/locales/es/common.json @@ -111,26 +111,22 @@ "gigabytes": "GB", "terabytes": "TB" }, - "updateReview": { - "title": "Revisar actualizaciones", - "description": "No se actualiza nada hasta que lo elijas aquí.", - "close": "Cerrar", - "done": "Hecho", - "selectAll": "Seleccionar todo", - "select": "Seleccionar {{name}}", - "unknownVersion": "desconocida", - "latest": "última", - "blocked": "No se puede actualizar aquí:", - "upgradeSelected": "Actualizar seleccionados ({{count}})", - "upgradeAll": "Actualizar todos ({{count}})", - "running": "Actualizando...", - "queued": "En espera", - "statusRunning": "Actualizando", - "statusDone": "Actualizado", - "statusFailed": "Falló", - "summary": "{{succeeded}} actualizados, {{failed}} fallidos.", - "empty": "Nada que actualizar.", - "statusStarted": "Iniciado", - "summaryStarted": "{{succeeded}} iniciados, {{failed}} fallidos." + "ui": { + "tryAgain": "Reintentar", + "undo": "Deshacer", + "loadError": { + "title": "No se pudo cargar.", + "serverSaid": "El servidor respondió «{{message}}».", + "nothingChanged": "No se cambió nada." + } + }, + "chip": { + "running": "En ejecución", + "starting": "Iniciando", + "stopped": "Detenido", + "failed": "Falló", + "updateAvailable": "Actualización disponible", + "ready": "Listo", + "noRunsYet": "Sin ejecuciones" } } diff --git a/core/http/react-ui/public/locales/es/explorer.json b/core/http/react-ui/public/locales/es/explorer.json new file mode 100644 index 000000000..800a9aff0 --- /dev/null +++ b/core/http/react-ui/public/locales/es/explorer.json @@ -0,0 +1,57 @@ +{ + "brand": "{{name}} Explorer", + "openApp": "Abrir LocalAI", + "close": "Cerrar", + "copy": "Copiar", + "copied": "Copiado", + "loading": "Cargando swarms", + "title": "Swarms públicos", + "lede": "Un swarm es un grupo de máquinas que comparten la inferencia de modelos a través de una red peer-to-peer. Esta lista muestra los swarms cuyos dueños han decidido hacerlos públicos. Cualquiera que tenga el token de un swarm puede unirse.", + "count_one": "{{count}} swarm en línea", + "count_other": "{{count}} swarms en línea", + "sorted": "Primero los que tienen más workers", + "workers_one": "worker", + "workers_other": "workers", + "tokenTitle": "El inicio y el final del token. Cópialo desde Cómo unirse.", + "warning": "Explorer es una lista comunitaria de tokens de swarms. Cualquiera puede usar un token para enviar trabajo a un swarm o compartir recursos con él. Úsalo bajo tu propio riesgo.", + "listed": "El swarm está en la lista.", + "empty": { + "title": "Ningún swarm tiene workers en línea", + "text": "Solo se muestran los swarms con al menos un worker en línea. Añade el tuyo a la lista para hacerlo público." + }, + "off": { + "title": "Este servidor no es un explorer", + "text": "La página Explorer necesita un servidor LocalAI iniciado en modo explorer. Este no tiene una lista de swarms que mostrar." + }, + "error": { + "title": "No se pudieron cargar los swarms", + "text": "El servidor explorer no respondió. Comprueba que esté en marcha y vuelve a intentarlo.", + "retry": "Reintentar" + }, + "join": { + "open": "Cómo unirse", + "title": "Unirse a {{name}}", + "text": "Inicia un worker de LocalAI con el token de este swarm.", + "token": "Token", + "federated": "Clúster federado {{id}}. Inicia un nodo en él con cualquiera de los dos comandos.", + "docker": "Con Docker", + "cli": "Con la línea de comandos", + "noCommands": "Los workers de este swarm comparten cómputo a través de su token. El token de arriba es lo que necesita un nodo para unirse.", + "docs": "Lee cómo funcionan los swarms" + }, + "list": { + "open": "Listar un swarm", + "title": "Listar un swarm", + "text": "Añade un swarm a la lista pública. Aparece cuando tiene un worker en línea.", + "name": "Nombre", + "description": "Descripción", + "token": "Token", + "tokenHint": "El token de red del swarm, tal como lo mostró el servidor.", + "public": "Listar publica el token. Cualquiera que vea la lista puede usar los workers de tu swarm.", + "understood": "Entiendo que el token se hará público.", + "cancel": "Cancelar", + "add": "Listar este swarm", + "adding": "Listando...", + "failed": "No se pudo listar el swarm." + } +} diff --git a/core/http/react-ui/public/locales/es/home.json b/core/http/react-ui/public/locales/es/home.json index 8b9a9c807..5583a4677 100644 --- a/core/http/react-ui/public/locales/es/home.json +++ b/core/http/react-ui/public/locales/es/home.json @@ -25,7 +25,10 @@ "title": "Administra LocalAI chateando", "description": "Instala modelos, cambia backends, edita configuraciones y consulta el estado hablando con LocalAI.", "open": "Abrir asistente", - "tooltip": "Administra LocalAI chateando" + "tooltip": "Administra LocalAI chateando", + "noModel": "Elige primero un modelo de chat.", + "startFailed": "No se pudo iniciar el chat del asistente: {{message}}", + "chatTitle": "Asistente de LocalAI" }, "input": { "placeholder": "Mensaje...", @@ -79,13 +82,17 @@ "step2Title": "Instala un modelo", "step2Body": "Haz clic en instalar para descargarlo y configurarlo automáticamente.", "step3Title": "Empieza a chatear", - "step3Body": "Chatea con tu modelo desde el navegador o usa la API." + "step3Body": "Chatea con tu modelo desde el navegador o usa la API.", + "sandboxTitle": "Ejecuta código de forma segura (opcional)", + "sandboxBody": "Instala el complemento Sandbox para que el modelo ejecute código y comandos aislados. Sin él, los comandos se ejecutan directamente en esta máquina, con acceso completo a la red y sin aislamiento." }, "browseGallery": "Explorar galería de modelos", "importModel": "Importar modelo", "docs": "Documentación", "noModelsTitle": "No hay modelos disponibles", - "noModelsBody": "No hay modelos instalados aún. Pídele a tu administrador que configure modelos para que puedas empezar a chatear." + "noModelsBody": "No hay modelos instalados aún. Pídele a tu administrador que configure modelos para que puedas empezar a chatear.", + "sandboxInstall": "Instalar Sandbox", + "sandboxSkip": "Omitir" }, "connect": { "title": "Un endpoint, todas las APIs", @@ -105,7 +112,23 @@ "modelsSummary": "Explore, install, and manage models", "create": "Create", "createSummary": "Open a chat, image or voice session", - "operate": "Operate", - "operateSummary": "{{models}} models configured · nodes, activity and traces" + "operate": "Administración", + "operateSummary": "{{models}} models configured · nodes, activity and traces", + "allChats": "Todos los chats" + }, + "slash": { + "agents": { + "label": "Agentes", + "desc": "Tus agentes guardados, disparadores y habilidades" + }, + "mcp": { + "label": "Conectores MCP", + "desc": "Servidores MCP que pueden usar tus chats" + }, + "memory": { + "label": "Memoria", + "desc": "Colecciones en las que pueden buscar tus chats" + }, + "complete": "completar" } } diff --git a/core/http/react-ui/public/locales/es/library.json b/core/http/react-ui/public/locales/es/library.json new file mode 100644 index 000000000..99ec7eb2f --- /dev/null +++ b/core/http/react-ui/public/locales/es/library.json @@ -0,0 +1,114 @@ +{ + "usedBy": { + "label": "Usado por", + "none": "Aún no se usa", + "line": "Usado por {{name}}", + "lineMore": "Usado por {{name}} +{{count}}", + "all": "todas las habilidades", + "allTitle": "Este agente carga todas las habilidades", + "remove": "Quitar de {{agent}}", + "open": "Abrir {{agent}}", + "unread": "No se puede leer el uso en este momento.", + "unreadShort": "Uso desconocido" + }, + "notUsed": { + "lead": "Aún no se usa.", + "skill": "Se queda en la biblioteca hasta que la añadas a algún sitio. Pruébala primero o añádela a un agente.", + "collection": "Se queda en la biblioteca hasta que la añadas a algún sitio. Búscala aquí o añádela a un agente.", + "skillShort": "Se queda en la lista hasta que la añadas a un agente." + }, + "tryInContext": "Pruébalo en un contexto", + "add": { + "button": "Añadir a...", + "title": "Añadir {{name}} a", + "agents": "Agentes", + "noAgents": "Aún no hay agentes. Crea un agente y luego añádelo aquí.", + "createAgent": "Crear un agente", + "added": "Añadido", + "action": "Añadir", + "addToAgent": "Añadir a {{agent}}", + "working": "Añadiendo...", + "estimate": "Las estimaciones usan caracteres divididos entre cuatro.", + "toolsMode": "Se carga cuando el modelo la pide", + "tokens": "+{{count}} tokens por mensaje (estimación)", + "collectionNote": "Un agente puede buscar en las colecciones de su lista.", + "kbOn": "Activa su base de conocimiento", + "noAgentNamed": "No hay ningún agente llamado {{name}}. Crea un agente con ese nombre para usar esta colección.", + "close": "Cerrar", + "skillNote": "Un agente puede leer las habilidades de su lista." + }, + "toasts": { + "skillAdded": "{{name}} añadida a {{agent}}", + "skillRemoved": "{{name}} quitada de {{agent}}", + "skillsOff": "{{name}} quitada de {{agent}}. Las habilidades ahora están desactivadas para este agente.", + "collectionAdded": "{{name}} añadida a {{agent}}", + "collectionRemoved": "{{name}} quitada de {{agent}}", + "undone": "Cambio deshecho", + "updateFailed": "No se pudo actualizar {{agent}}: {{message}}", + "undo": "Deshacer", + "dismiss": "Descartar" + }, + "simulate": { + "button": "Simular un mensaje", + "title": "Simular un mensaje", + "desc": "Elige un contexto y mira qué cargaría. No se envía nada a un modelo.", + "close": "Cerrar", + "context": "Contexto", + "contextCollection": "Una colección por sí sola", + "contextAgent": "Agente: {{name}}", + "collectionOn": "Colección", + "noCollections": "Aún no hay colecciones.", + "contextHintCollection": "Busca en la colección con tu mensaje y muestra los pasajes que encuentra, con sus puntuaciones. No intervienen agentes ni modelos.", + "contextHintAgent": "Muestra lo que este agente guarda en su prompt, además de los pasajes que su base de conocimiento añadiría para este mensaje.", + "message": "Mensaje", + "messagePlaceholder": "Escribe lo que podría preguntar una persona", + "passages": "Pasajes", + "run": "Ejecutar", + "running": "Ejecutando...", + "onlyForTest": "Solo para esta prueba", + "removeExtra": "Quitar {{name}} de esta prueba", + "tryAlso": "Probar algo que aún no está añadido", + "tryAlsoPick": "Elige una habilidad o una colección", + "extraSkill": "Habilidad {{name}}", + "extraCollection": "Colección {{name}}", + "budgetTitle": "Lo que añade este contexto", + "budgetTotal": "Unos {{count}} tokens alrededor de tu mensaje", + "budgetMessage": "Tu mensaje: {{count}} tokens", + "budgetNote": "Estimaciones: caracteres divididos entre cuatro. La ventana del modelo no se conoce aquí, así que no hay una parte de ningún límite.", + "instructions": "Instrucciones", + "skills": "Habilidades", + "memory": "Memoria", + "skillsTitle": "Habilidades que carga este contexto", + "skillsNone": "No hay habilidades activas para este agente.", + "skillsPrompt": "En el prompt de cada mensaje.", + "skillsTools": "Se lee solo cuando el modelo la pide.", + "skillsBoth": "En el prompt, y se vuelve a leer cuando el modelo la pide.", + "skillsAll": "Todas las habilidades están activas para este agente.", + "skillMissing": "{{name}} está en la lista, pero ninguna habilidad tiene ese nombre.", + "skillTokens": "{{count}} tokens", + "skillTokensTools": "0 al inicio", + "memoryTitle": "Memoria que se enviaría", + "memoryNone": "No hay ninguna colección en la lista de este agente. Añade una al agente para usarla.", + "memoryNoCollection": "La base de conocimiento de este agente está activa, pero aún no existe una colección llamada {{name}}.", + "memoryTools": "Este agente busca solo cuando el modelo lo pide. No se añade nada al mensaje al inicio.", + "memoryTesting": "Colección {{name}}, solo para esta prueba", + "memoryFrom": "De {{name}}", + "passagesCount": "{{count}} pasajes, ordenados por similitud", + "noPassages": "No se encontró nada para este mensaje.", + "pickMessage": "Escribe primero un mensaje.", + "searchFailed": "La búsqueda falló: {{message}}", + "score": "Puntuación {{score}}", + "tokens": "{{count}} tokens", + "noAgents": "Aún no hay agentes. Aun así puedes buscar en una colección por sí sola.", + "backendNote": "Aquí no está disponible una simulación del modelo, así que no se simula ninguna respuesta." + }, + "search": { + "failed": "La búsqueda falló: {{message}}" + }, + "agentBudget": "Las habilidades seleccionadas añaden unos {{count}} tokens a cada mensaje (estimación: caracteres divididos entre cuatro).", + "agentKb": { + "reads": "Este agente lee la colección llamada {{name}}.", + "exists": "Este agente lee la colección llamada {{name}}. Puedes llenarla y buscar en ella desde Memoria.", + "created": "Este agente lee la colección llamada {{name}}. Se crea al guardar." + } +} diff --git a/core/http/react-ui/public/locales/es/modelEditor.json b/core/http/react-ui/public/locales/es/modelEditor.json new file mode 100644 index 000000000..fab8227c8 --- /dev/null +++ b/core/http/react-ui/public/locales/es/modelEditor.json @@ -0,0 +1,6 @@ +{ + "proxyVision": { + "label": "Este modelo puede ver imágenes", + "hint": "Actívalo si el modelo remoto acepta imágenes. LocalAI no puede comprobarlo. Las imágenes adjuntas se envían al modelo con el mensaje." + } +} diff --git a/core/http/react-ui/public/locales/es/models.json b/core/http/react-ui/public/locales/es/models.json index eddf6a0b4..8b152a36d 100644 --- a/core/http/react-ui/public/locales/es/models.json +++ b/core/http/react-ui/public/locales/es/models.json @@ -1,5 +1,6 @@ { "lifecycle": { + "supporting": "Encuentra un modelo que quepa en esta máquina, instálalo y cuida los que ya tienes.", "title": "Models", "navLabel": "Model lifecycle", "views": { "explore": "Explore", "installed": "Installed" }, @@ -81,6 +82,10 @@ "delete": "Eliminar" }, "filters": { + "sheetDone": "Ver modelos", + "sheetTitle": "Filtros", + "sheetButtonCount": "Filtros ({{count}})", + "sheetButton": "Filtros", "all": "Todos", "llm": "LLM", "image": "Imagen", diff --git a/core/http/react-ui/public/locales/es/nav.json b/core/http/react-ui/public/locales/es/nav.json index 048418465..4fd657224 100644 --- a/core/http/react-ui/public/locales/es/nav.json +++ b/core/http/react-ui/public/locales/es/nav.json @@ -10,12 +10,13 @@ "changeLanguage": "Cambiar idioma", "logout": "Cerrar sesión", "accountSettings": "Configuración de la cuenta", + "yourSettings": "Tu configuración", "account": "Cuenta", "accountFor": "Cuenta: {{name}}", "sections": { "recognition": "Reconocimiento", "build": "Construir", - "operate": "Operar", + "operate": "Administración", "workspace": "Espacio de trabajo" }, "operate": { @@ -66,7 +67,14 @@ "logs": "Logs", "trafficModels": "Models", "gpuHost": "GPU and host", - "prometheus": "Prometheus" + "prometheus": "Prometheus", + "chats": "Chats", + "files": "Archivos", + "triggers": "Disparadores", + "mcpConnectors": "Conectores MCP", + "chatsAdmin": "Chats de todos los usuarios", + "pages": "Páginas", + "allChats": "Todos los chats" }, "footer": { "github": "GitHub", @@ -99,7 +107,15 @@ "import": "Añade un modelo desde un archivo, una URL o un repositorio de Hugging Face.", "voices": "Identifica hablantes y mantén una biblioteca de perfiles de voz.", "faces": "Detecta, compara y empareja rostros.", - "addons": "Instala complementos y abre las apps que ofrecen." - } - } + "addons": "Instala complementos y abre las apps que ofrecen.", + "triggers": "Inicia un chat por sí solo, con una programación o cuando llega un archivo." + }, + "operateSupporting": "Qué se ejecuta en este servidor, cómo se usa y cómo está configurado." + }, + "pinnedPages": "Páginas fijadas", + "new": "Nuevo", + "recentChats": "Chats recientes", + "newChat": "Nuevo chat", + "untitledChat": "Chat sin título", + "chatRunning": "En curso" } diff --git a/core/http/react-ui/public/locales/es/operate.json b/core/http/react-ui/public/locales/es/operate.json new file mode 100644 index 000000000..06a9de206 --- /dev/null +++ b/core/http/react-ui/public/locales/es/operate.json @@ -0,0 +1,57 @@ +{ + "backends": { + "filterLabel": "Filtrar backends", + "check": "Buscar actualizaciones", + "reinstallBackend": "Reinstalar backend", + "deleteBackend": "Eliminar backend", + "updateAllTitle": "Actualizar todos los backends", + "updateAllIntro": "Elija qué backends actualizar. Cada uno se actualiza por separado, uno tras otro. Un error no detiene a los demás.", + "updateSelected": "Actualizar selección", + "loadFailedTitle": "No se pudo cargar el catálogo de backends.", + "filtersButton": "Filtros", + "filtersDone": "Listo", + "filterGroups": { + "state": "Estado", + "type": "Tipo", + "show": "Mostrar", + "library": "Biblioteca" + }, + "showVariants": "Variantes", + "showDevelopment": "Compilaciones de desarrollo", + "allLibraries": "Todas las bibliotecas", + "moreActions": "Más acciones de backends", + "installFromUrl": "Instalar desde URL…", + "manageLibraries": "Gestionar bibliotecas…", + "count_one": "{{count}} backend", + "count_other": "{{count}} backends", + "countOf": "{{shown}} de {{total}} backends", + "activeFilters": "Filtros activos", + "removeFilter": "Quitar filtro: {{name}}", + "checking": "Buscando actualizaciones…", + "updatesAvailable_one": "{{count}} actualización disponible", + "updatesAvailable_other": "{{count}} actualizaciones disponibles", + "reviewUpdates": "Revisar", + "close": "Cerrar", + "installedLoadFailedTitle": "No se pudieron cargar los backends instalados." + }, + "capacity": { + "diskChip": "Disco casi lleno" + }, + "activity": { + "kind": { + "addon": "complemento" + }, + "verb": { + "upgrading": "Actualizando {{kind}}", + "failedUpgrade": "No se pudo actualizar {{kind}}" + }, + "filter": { + "addons": "Complementos" + }, + "upgradeVersions": "{{from}} a {{to}}", + "rowUpgraded": "Actualizado en {{duration}}", + "rowUpgradedPlain": "Actualizado", + "viewInAddons": "Ver en Complementos", + "upgradeWaiting": "Esperando a que se inicie el servicio de complementos. Próxima comprobación en {{seconds}} s." + } +} diff --git a/core/http/react-ui/public/locales/es/pages.json b/core/http/react-ui/public/locales/es/pages.json new file mode 100644 index 000000000..d4250302e --- /dev/null +++ b/core/http/react-ui/public/locales/es/pages.json @@ -0,0 +1,138 @@ +{ + "subtitle": "Pequeñas herramientas que el asistente hizo para ti. Se abren aquí, en un sandbox, y guardan sus datos en tus archivos.", + "card": { + "pin": "Fijar en la barra lateral", + "unpin": "Quitar de la barra lateral", + "changedBy": { + "you": "Cambiada {{when}} por ti", + "assistant": "Cambiada {{when}} por el asistente" + }, + "menu": "Más para {{title}}", + "files": "Mostrar sus archivos", + "rename": "Renombrar", + "renameLabel": "Nuevo nombre de carpeta", + "renameHint": "Minúsculas, dígitos y guiones.", + "renameSave": "Guardar", + "renameCancel": "Cancelar", + "renameFailed": "La página no se renombró: {{message}}", + "delete": "Eliminar página", + "deleteTitle": "¿Eliminar «{{title}}»?", + "deleteMessage": "La carpeta {{folder}} va a la papelera. Puedes restaurarla desde Archivos.", + "deleted": "{{title}} está en la papelera.", + "deleteFailed": "La página no se eliminó: {{message}}" + }, + "empty": { + "text": "Una página es una herramienta pequeña, como un tablero de tareas o una lista de lectura. Pídele una al asistente en un chat o empieza con una página lista.", + "starting": "Copiando la página", + "starterFailed": "La página no se copió: {{message}}", + "ask": "Pedir una página", + "askPrompt": "Hazme una página que ", + "starterReading": "Empezar con una lista de lectura", + "note": "Esta entrada sale de la barra lateral cuando no tienes páginas. Vuelve con la siguiente.", + "learn": "Cómo funcionan las páginas" + }, + "askNew": "Pedir una página nueva", + "yours": "Tuyas", + "filter": "Filtrar páginas", + "shared": { + "yours": "Tuyas", + "group": "Compartidas contigo", + "note": "Una página compartida solo se ejecuta después de que la permitas. Si su autor la cambia, se te vuelve a preguntar.", + "offNotice": "Las páginas compartidas están desactivadas en este servidor. Un administrador las desactivó.", + "off": "Las páginas compartidas están desactivadas en este servidor. Un administrador las desactivó.", + "gone": "Esta página ya no está en la carpeta compartida o ya no tienes acceso a ella.", + "review": "Revisar", + "from": "De {{author}}", + "menu": "Menú de la página", + "badge": { + "notAllowed": "Aún no permitida", + "changed": "Cambió desde que la permitiste", + "allowed": "Permitida" + } + }, + "viewer": { + "stopAllowing": "Dejar de permitir esta página", + "authorChanged": "{{author}} cambió esta página. La versión que permitiste sigue en marcha hasta que revises el cambio.", + "sandboxShared": "Aislada. Se ejecuta como tú, solo en {{folder}}, y no puede acceder a la red." + }, + "trust": { + "title": "¿Abrir «{{title}}»?", + "desc": "Esta página viene de una carpeta compartida. Las páginas se ejecutan como tú, así que revísala antes de que se ejecute.", + "who": "Escrita por {{author}}", + "whereLabel": "Carpeta compartida", + "whereWhen": ", cambiada {{when}}", + "someone": "alguien", + "close": "Cerrar", + "canTitle": "Puede", + "canStillTitle": "Todavía puede", + "cannotTitle": "No puede", + "items": { + "files": "Leer y escribir archivos en su propia carpeta", + "readOnly": "Solo puedes leer esta carpeta compartida, así que la página no puede guardar sus cambios.", + "chat": "Escribir un mensaje en un chat", + "chatNote": "Ves el mensaje en el chat antes de que el asistente lo responda.", + "noFiles": "Leer tus otros archivos o chats", + "noNet": "Acceder a la red u otros sitios", + "noNetNote": "Un límite: puede cargar otra dirección en su propio marco. Eso envía una solicitud hacia fuera y luego la página se detiene.", + "noTools": "Usar herramientas o cambiar tus ajustes" + }, + "warn": "Actúa como tú dentro de su carpeta. Si {{author}} la cambia, se te vuelve a preguntar.", + "viewSource": "Ver el código", + "cancel": "Cancelar", + "allowOnce": "Permitir una vez", + "allow": "Permitir", + "changed": { + "title": "«{{title}}» ha cambiado", + "desc": "Permitiste una versión anterior. Revisa qué cambió antes de que se vuelva a ejecutar.", + "who": "Cambiada por {{author}} {{when}}", + "allowed": "Permitiste la versión del {{date}}", + "what": "Qué cambió", + "same": "Lo que puede hacer es lo mismo que antes.", + "warn": "Si la permites, esta versión se ejecuta cada vez que la abras, hasta que {{author}} la vuelva a cambiar.", + "view": "Ver los cambios", + "lines": "{{before}} → {{after}} líneas", + "code": "Cambió un script, una hoja de estilo o un módulo de la página: {{files}}" + }, + "pill": { + "notYet": "Aún no permitida", + "not": "No permitida", + "once": "Permitida para esta visita", + "allowed": "Permitida", + "changed": "Cambió desde que la permitiste" + }, + "from": "De {{author}}", + "frame": { + "chrome": "Página compartida de {{folder}}. No se ha ejecutado." + }, + "blocked": { + "title": "Esta página no se ha ejecutado", + "body": "Elegiste no ejecutarla. Viene de la carpeta compartida {{folder}} de {{author}}. No se ha ejecutado nada de ella y no se ha guardado nada.", + "review": "Revisar y permitir" + }, + "waiting": { + "title": "Esperándote", + "body": "Nada de esta página se ejecuta hasta que la permitas." + }, + "stopped": { + "revoked": "Dejaste de permitir esta página. Se detuvo y nada de ella se ejecuta hasta que la vuelvas a permitir.", + "refused": "Esta página ya no está permitida, así que se detuvo. Revísala para volver a permitirla.", + "off": "Las páginas compartidas se desactivaron en este servidor, así que esta página se detuvo." + }, + "changedMeanwhile": "La página cambió mientras la revisabas. Esta es la versión nueva.", + "sourceTitle": "Código de «{{title}}»", + "sourceLines_one": "{{count}} línea", + "sourceLines_other": "{{count}} líneas", + "sourceNote": "El código se muestra como texto. Nada de él se ejecuta aquí." + }, + "frame": { + "filesFailed": "No se cargaron archivos junto a esta página: {{files}}. La consola del navegador indica el motivo.", + "tooLarge": "Esta página incluye más de {{limit}} de archivos de su carpeta, así que algunos se omitieron. Cargue los datos con fetch().", + "dismiss": "Cerrar" + }, + "link": { + "label": "La página quiere abrir un enlace", + "open": "Abrir {{site}}", + "cancel": "Cancelar", + "lookalike": "Esta dirección usa letras de otros alfabetos y se lee como {{display}}. Puede imitar otro sitio: revísela antes de abrirla." + } +} diff --git a/core/http/react-ui/public/locales/es/skills.json b/core/http/react-ui/public/locales/es/skills.json index 07e0873ae..f3857c189 100644 --- a/core/http/react-ui/public/locales/es/skills.json +++ b/core/http/react-ui/public/locales/es/skills.json @@ -1,79 +1,83 @@ { "title": "Habilidades", - "subtitle": "Administra las habilidades de los agentes (instrucciones y recursos reutilizables)", + "subtitle": "Instrucciones que todos tus chats pueden usar cuando una tarea coincide. Los agentes usan las que les añades.", "unavailable": { - "subtitle": "El servicio de habilidades no está disponible o el índice se está reconstruyendo. Inténtalo de nuevo en un momento.", - "retry": "Reintentar" + "subtitle": "Las habilidades necesitan Archivos, y Archivos está desactivado en este servidor." }, "actions": { "newSkill": "Nueva habilidad", "createSkill": "Crear habilidad", - "import": "Importar", - "importing": "Importando...", - "gitRepos": "Repos Git", "edit": "Editar", - "delete": "Eliminar", - "export": "Exportar", - "sync": "Sincronizar", - "addRepo": "Añadir repo", - "adding": "Añadiendo...", - "remove": "Quitar", - "enable": "Habilitar", - "disable": "Deshabilitar" + "delete": "Eliminar" }, "search": { "placeholder": "Buscar habilidades..." }, - "git": { - "title": "Repositorios Git", - "description": "Añade repositorios Git de los que obtener habilidades. Las habilidades aparecerán en la lista después de sincronizar.", - "urlPlaceholder": "https://github.com/user/repo o git@github.com:user/repo.git", - "noRepos": "No hay repos Git configurados. Añade uno arriba.", - "disabled": "Deshabilitado", - "removeRepo": "Quitar repo" - }, "card": { "noDescription": "Sin descripción", "readOnly": "Solo lectura", "editTitle": "Editar habilidad", "deleteTitle": "Eliminar habilidad", - "exportTitle": "Exportar como .tar.gz" + "invalid": "Skipped: not a valid skill", + "invalidWhy": "Esta habilidad se omitió: {{reason}}", + "shadowed": "Hidden by a {{layer}} skill", + "shadowedLong": "Hidden by a {{layer}} skill of the same name", + "providedBy": "Proporcionada por {{name}}", + "replacedBy": "Reemplazada por {{name}}", + "notActive": "No activa: se usa la habilidad integrada", + "notActiveBy": "No activa: la proporciona {{name}}", + "fallback": "En uso porque la habilidad del complemento {{name}} está desactivada o no se ejecuta" }, "empty": { "title": "No se encontraron habilidades", "text": "Crea una habilidad o importa una para empezar.", - "noPersonal": "Aún no tienes habilidades." - }, - "sections": { - "yourSkills": "Tus habilidades", - "otherUsersSkills": "Habilidades de otros usuarios" - }, - "deleteDialog": { - "title": "Eliminar habilidad", - "message": "¿Eliminar la habilidad \"{{name}}\"? Esta acción no se puede deshacer.", - "confirm": "Eliminar" - }, - "removeRepoDialog": { - "title": "Quitar repositorio Git", - "message": "¿Quitar este repositorio Git? Las habilidades de él ya no estarán disponibles.", - "confirm": "Quitar" + "stepWrite": "Write a skill: a name, a line that says when to use it, and the instructions.", + "stepTry": "Check it in the list: it shows who uses it and what it says.", + "stepAdd": "Add it to an agent when you want it used." }, "toasts": { "loadFailed": "Error al cargar habilidades", "deleted": "Habilidad \"{{name}}\" eliminada", - "deleteFailed": "Error al eliminar habilidad", - "exported": "Habilidad \"{{name}}\" exportada", - "exportFailed": "Error al exportar", - "imported": "Habilidad importada desde \"{{file}}\"", - "importFailed": "Error al importar", - "loadReposFailed": "Error al cargar repos Git", - "repoAdded": "Repo Git añadido y sincronizando", - "addRepoFailed": "Error al añadir repo", - "synced": "Repo sincronizado", - "syncFailed": "Error al sincronizar", - "toggled": "Repo actualizado", - "toggleFailed": "Error al actualizar", - "removed": "Repo eliminado", - "removeFailed": "Error al eliminar" + "deleteFailed": "Error al eliminar habilidad" + }, + "layers": { + "label": "Layer", + "personal": "Personal", + "shared": "Shared", + "builtin": "Built-in", + "folder": "Workspace", + "addon": "Complemento" + }, + "pane": { + "loading": "Loading...", + "loadFailed": "The skill could not be read.", + "tokensExplain": "A chat reads a skill only when a task matches its description, so it adds nothing until then. About {{count}} tokens when read (estimate)." + }, + "edit": { + "back": "Back to skills", + "titleNew": "New skill", + "titleEdit": "Edit: {{name}}", + "basic": "Basic information", + "content": "Content", + "layer": "Layer", + "layerHelp": "Personal is yours alone. Shared is read by every user.", + "name": "Name (lowercase, hyphens only)", + "nameLocked": "Name cannot be changed after creation.", + "description": "Description (when to use it)", + "descriptionHelp": "A chat reads this line to decide whether the skill fits the task.", + "body": "Skill content (markdown)", + "cancel": "Cancel", + "save": "Save changes", + "create": "Create skill", + "saving": "Saving...", + "created": "Skill created", + "updated": "Skill updated", + "required": "A name and a description are required", + "saveFailed": "Save failed", + "loadFailed": "Failed to load the skill", + "readOnly": "Esta habilidad es de solo lectura aquí. Una habilidad integrada viene con el servidor, y una de tus archivos se cambia en Archivos." + }, + "load": { + "title": "No se pudieron cargar tus habilidades." } } diff --git a/core/http/react-ui/public/locales/es/swarm.json b/core/http/react-ui/public/locales/es/swarm.json new file mode 100644 index 000000000..5833dead7 --- /dev/null +++ b/core/http/react-ui/public/locales/es/swarm.json @@ -0,0 +1,5 @@ +{ + "nodes": { + "retry": "Reintentar" + } +} diff --git a/core/http/react-ui/public/locales/es/tools.json b/core/http/react-ui/public/locales/es/tools.json new file mode 100644 index 000000000..70670fe48 --- /dev/null +++ b/core/http/react-ui/public/locales/es/tools.json @@ -0,0 +1,468 @@ +{ + "steps": { + "label": "Progreso", + "setup": "Configurar", + "check": "Comprobar", + "run": "Ejecutar", + "result": "Resultado" + }, + "run": { + "stop": "Detener", + "connecting": "Conectando con el job. Esperando el primer evento de progreso.", + "progress": "Progreso", + "stages": "Etapas", + "step": "paso", + "of": "de", + "epoch": "época", + "tokens": "tokens/s", + "eta": "faltan unos {{time}}", + "etaSeconds": "faltan unos {{seconds}} s" + }, + "landing": { + "lede": "Lo que puedes crear en esta máquina y lo que cada herramienta necesita primero.", + "machine": "Esta máquina", + "gpu": "GPU", + "gpuFree": "{{free}} libres de {{total}}", + "noGpu": "No se encontró ninguna GPU", + "cluster": "{{total}} en el mejor nodo ({{node}})", + "clusterNode": "un worker", + "ram": "Memoria", + "ramFree": "RAM, {{free}} libres", + "disk": "Disco de modelos", + "diskFree": "libres", + "backends": "Backends", + "noBackends": "ninguno instalado para estas herramientas", + "open": "Abrir", + "openTool": "Abrir {{tool}}", + "install": "Instalar", + "ready": "Listo", + "needsBackend": "Hace falta un backend", + "experimental": "Experimental", + "needs": "Lo que necesita", + "flow": "El camino desde un modelo encontrado hasta uno con el que puedes chatear", + "flowNote": "Cada paso termina con un modelo que puedes cargar.", + "now": { + "running": { + "fineTune": "Ajustando {{model}}", + "quantize": "Cuantizando {{model}} a {{type}}" + }, + "failed": { + "fineTune": "Falló el ajuste de {{model}}", + "quantize": "Falló la cuantización de {{model}} a {{type}}" + } + }, + "groups": { + "make": { + "title": "Crear un modelo", + "text": "Parte de un modelo que encontraste y termina con uno que encaje y conozca tus datos." + }, + "recognise": { + "title": "Reconocer", + "text": "Quién habla y de quién es la cara que aparece en una foto." + }, + "automate": { + "title": "Automatizar", + "text": "Asistentes, sus instrucciones y su memoria." + } + }, + "sentence": { + "import": "Trae un modelo desde Hugging Face, una URL, una referencia de Ollama u OCI, o un archivo de este host. LocalAI elige el backend y escribe la configuración.", + "quantize": "Haz un modelo más pequeño para que quepa en menos memoria. Convierte un modelo de Hugging Face a GGUF con la precisión que elijas.", + "fine-tune": "Enseña a un modelo tus propios datos con un adaptador y luego expórtalo como un modelo con el que puedas chatear." + }, + "line": { + "import": { + "noSetup": "Sin configuración. El backend se descarga cuando hace falta.", + "disk": "{{free}} libres en el disco de modelos" + }, + "quantize": { + "backend": "Backend {{name}} instalado", + "backendMissing": "El backend de cuantización no está instalado", + "backendUnknown": "No se pudo leer el estado del backend", + "cpu": "Funciona en la CPU, no hace falta GPU" + }, + "fineTune": { + "backend": "Backend {{name}} instalado", + "backendMissing": "No hay ningún backend de ajuste instalado", + "backendUnknown": "No se pudo leer el estado del backend", + "gpu": "Memoria de GPU: {{name}}, {{free}} libres de {{total}}", + "noGpu": "No se encontró ninguna GPU. El entrenamiento se ejecuta en la CPU y es lento.", + "cluster": "Memoria: {{total}} en el mejor nodo" + } + }, + "enable": { + "admin": "Instala el backend desde Backends y esta herramienta estará lista.", + "user": "Pide a un administrador que instale el backend." + } + }, + "checks": { + "heading": "Comprueba antes de empezar", + "live": "Se actualiza mientras cambias la configuración.", + "meterLabel": "{{used}} por ciento en uso, {{free}} libres de {{total}}", + "openBackends": "Abrir Backends", + "barOk": "Nada bloquea el inicio.", + "barWarn_one": "{{count}} aviso. Aun así puedes empezar.", + "barWarn_other": "{{count}} avisos. Aun así puedes empezar.", + "title": { + "model": "Modelo", + "data": "Dataset", + "backend": "Backend", + "memory": "Memoria", + "disk": "Disco", + "reward": "Funciones de recompensa", + "resume": "Reanudar", + "access": "Acceso al modelo", + "type": "Tipo de cuantización", + "source": "Origen", + "name": "Nombre" + }, + "fineTune": { + "modelOk": "Entrenando {{model}}.", + "modelMissing": "Escribe el modelo que quieres entrenar.", + "dataOk": "Los datos vienen de {{source}}.", + "dataMissing": "Añade un dataset: un id de Hugging Face o un archivo subido.", + "backendOk": "{{name}} está instalado.", + "backendMissing": "No hay ningún backend de ajuste instalado, así que el job podría no arrancar.", + "backendOther": "{{name}} no está entre los backends de ajuste instalados ({{list}}).", + "backendUnknown": "No se pudieron leer los backends instalados.", + "memoryGpu": "{{name}}: {{free}} libres de {{total}}. Tamaño de lote {{batch}}, longitud de secuencia {{seq}}, acumulación de gradiente {{accum}}. Cuánta memoria necesita el job depende del modelo y de estos ajustes, y LocalAI no lo estima antes de que el job arranque.", + "memoryCluster": "{{total}} en el mejor nodo ({{node}}). Tamaño de lote {{batch}}, longitud de secuencia {{seq}}. Cuánta memoria necesita el job depende del modelo y de estos ajustes.", + "memoryCpu": "No se encontró ninguna GPU. El entrenamiento usa la CPU y {{free}} de {{total}} de RAM están libres. Espera que sea lento. Tamaño de lote {{batch}}, longitud de secuencia {{seq}}.", + "memoryUnknown": "Esta cuenta no puede leer la memoria de la máquina. Tamaño de lote {{batch}}, longitud de secuencia {{seq}}. Cuánta memoria necesita el job depende del modelo y de estos ajustes.", + "disk": "{{free}} libres en el disco de modelos. Las descargas y los checkpoints necesitan espacio.", + "diskLow": "Solo {{free}} libres en el disco de modelos. Las descargas y los checkpoints necesitan espacio.", + "rewardMissing": "GRPO necesita al menos una función de recompensa. Añade una en Más opciones.", + "resume": "El entrenamiento continúa desde {{path}}.", + "accessNone": "No hay token definido. Un modelo restringido necesita un token de Hugging Face en Más opciones.", + "accessSet": "Hay un token de Hugging Face definido." + }, + "quantize": { + "modelOk": "Cuantizando {{model}}.", + "modelMissing": "Escribe el modelo que quieres cuantizar.", + "typeMissing": "Escribe el tipo de cuantización personalizado.", + "backendOk": "{{name}} está instalado.", + "backendMissing": "No hay ningún backend de cuantización instalado, así que el job podría no arrancar.", + "backendOther": "{{name}} no está entre los backends de cuantización instalados ({{list}}).", + "backendUnknown": "No se pudieron leer los backends instalados.", + "memory": "{{free}} libres de {{total}} de RAM. La conversión se ejecuta en la CPU.", + "disk": "{{free}} libres en el disco de modelos. La descarga, el archivo convertido y el resultado se escriben en disco.", + "diskLow": "Solo {{free}} libres en el disco de modelos. La descarga, el archivo convertido y el resultado se escriben en disco.", + "accessNone": "No hay token definido. Un modelo restringido necesita un token de Hugging Face en Más opciones.", + "accessSet": "Hay un token de Hugging Face definido." + }, + "import": { + "sourceOk_huggingface": "Se reconoce como repositorio de Hugging Face. LocalAI revisa sus archivos al importar.", + "sourceOk_url": "Formato de URL reconocido. Su contenido no se ha comprobado.", + "sourceOk_config": "Se reconoce como archivo de configuración de un modelo. Se lee y se usa tal como está.", + "sourceOk_oci": "Se reconoce como referencia de imagen OCI.", + "sourceOk_ollama": "Se reconoce como referencia de modelo de Ollama.", + "sourceOk_local": "Se reconoce como ruta en la máquina que ejecuta LocalAI.", + "sourceOk": "El origen se reconoce.", + "sourceUnknown": "Esto no parece ninguna forma que LocalAI conozca. Lo intentará igualmente y podría fallar.", + "backendDownload": "{{name}} aún no está instalado. LocalAI lo descarga primero.", + "backendChosen": "{{name}} está elegido e instalado.", + "backendAuto": "LocalAI elige el backend según los archivos del origen cuando empieza la importación.", + "nameTaken": "Ya hay instalado un modelo llamado {{name}}.", + "nameFree": "{{name}} no está en uso.", + "nameAuto": "El modelo toma el nombre del origen.", + "disk": "{{free}} libres en el disco de modelos. El tamaño de la descarga se conoce cuando empieza la importación.", + "memory_gpu": "Ahora hay {{free}} libres de memoria de GPU. Lo que necesita este modelo se conoce cuando empieza la importación.", + "memory_ram": "Ahora hay {{free}} libres de RAM. Lo que necesita este modelo se conoce cuando empieza la importación.", + "memory_cluster": "Hay {{free}} disponibles en el mejor nodo. Lo que necesita este modelo se conoce cuando empieza la importación.", + "memory": "Ahora hay {{free}} libres de memoria. Lo que necesita este modelo se conoce cuando empieza la importación." + } + }, + "form": { + "key": "Clave", + "value": "Valor", + "addOption": "Añadir opción", + "copy": "Copiar al portapapeles", + "more": "Más opciones", + "moreHint": "método, backend, adaptador, optimizador, evaluación, funciones de recompensa", + "baseModel": "Modelo base", + "model": "Modelo", + "modelHint": "Un id de Hugging Face, o una ruta en este host.", + "data": "Tus datos", + "dataset": "Origen del dataset", + "datasetHint": "Un id de dataset de Hugging Face, o sube un archivo.", + "split": "Partición", + "upload": "Subir un archivo en su lugar", + "uploadHint": ".json, .jsonl o .csv", + "removeFile": "Quitar el archivo", + "train": "Qué entrenar", + "kind": "Tipo de entrenamiento", + "kinds": { + "adapter": { + "title": "Adaptador", + "text": "Entrena un adaptador pequeño y deja intactos los pesos base. Es la opción habitual, y necesita poca memoria." + }, + "full": { + "title": "Modelo completo", + "text": "Cambia cada peso. Necesita varias veces la memoria de un adaptador." + } + }, + "epochs": "Épocas", + "batch": "Tamaño de lote", + "lr": "Tasa de aprendizaje", + "backend": "Backend", + "method": "Método de entrenamiento", + "adapterKind": "Tipo de adaptador", + "rank": "Rango", + "alpha": "Alpha", + "dropout": "Dropout", + "gradAccum": "Pasos de acumulación de gradiente", + "warmup": "Pasos de calentamiento", + "seqLen": "Longitud máxima de secuencia", + "optimizer": "Optimizador", + "maxSteps": "Pasos máximos", + "maxStepsHint": "0 para automático.", + "saveSteps": "Pasos de guardado", + "saveLimit": "Límite total de guardados", + "saveLimitHint": "0 conserva cada checkpoint.", + "weightDecay": "Decaimiento de pesos", + "seed": "Semilla", + "seedHint": "0 elige una semilla aleatoria.", + "precision": "Precisión mixta", + "auto": "Automático", + "targetModules": "Módulos objetivo", + "targetModulesHint": "Separados por comas. Déjalo en blanco para el valor por defecto del backend.", + "gradCheckpointing": "Gradient checkpointing (más lento, usa menos memoria)", + "token": "Token de Hugging Face", + "tokenHint": "Solo hace falta para los modelos restringidos.", + "evalEnable": "Evaluar durante el entrenamiento", + "evalStrategy": "Estrategia de evaluación", + "evalSteps_": "Pasos", + "evalEpoch": "Época", + "evalSteps": "Pasos de evaluación", + "evalStepsHint": "0 coincide con los pasos de guardado.", + "evalSplit": "Partición de evaluación", + "evalDataset": "Dataset de evaluación", + "evalDatasetPlaceholder": "Un dataset separado", + "evalRatio": "Proporción de partición automática", + "rewards": "Funciones de recompensa (GRPO)", + "rewardsHint": "GRPO necesita al menos una función de recompensa. Elige las integradas o añade las tuyas.", + "resume": "Reanudar desde un checkpoint", + "clearCheckpoint": "Borrar el checkpoint", + "extra": "Opciones extra", + "extraHint": "Pares clave y valor propios del backend.", + "summaryFull": "modelo completo", + "summary_lora": "adaptador LoRA", + "summary_loha": "adaptador LoHa", + "summary_lokr": "adaptador LoKr", + "recipe": "Entrenar un {{kind}} de {{model}} con {{method}}, {{epochs}} épocas sobre {{data}}.", + "recipeEmpty": "Elige un modelo y un dataset para ver qué se entrenará." + }, + "rewards": { + "format_reward": "Comprueba el formato ... seguido de la respuesta.", + "reasoning_accuracy_reward": "Compara el contenido de con la columna de respuestas del dataset.", + "length_reward": "Puntúa lo cerca que está la respuesta de una longitud objetivo.", + "xml_tag_reward": "Puntúa las etiquetas XML abiertas y cerradas correctamente.", + "no_repetition_reward": "Penaliza los n-gramas repetidos.", + "code_execution_reward": "Comprueba que los bloques de código Python tengan una sintaxis válida.", + "param_target_length": "Longitud objetivo", + "fnName": "Nombre de la función", + "fnBody": "Cuerpo de la función", + "fnHint": "Recibe completions y kwargs y debe devolver una lista de floats. Están disponibles re, math, json y string.", + "add": "Añadir", + "cancel": "Cancelar", + "custom": "Añadir una función de recompensa propia" + }, + "fineTune": { + "title": "Ajuste fino", + "lede": "Entrena un adaptador con tus propios datos y luego expórtalo como un modelo con el que puedas chatear.", + "importConfig": "Importar configuración", + "exportConfig": "Exportar configuración", + "newJob": "Nuevo job", + "badConfig": "No se pudo leer ese archivo. Usa una configuración JSON exportada desde esta página.", + "start": "Iniciar el ajuste fino", + "starting": "Iniciando...", + "resume": "Reanudar el entrenamiento", + "resuming": "Reanudando desde el checkpoint:", + "clear": "Limpiar", + "barNote": "No se cambia nada hasta que inicies.", + "jobMeta": "{{kind}} {{method}} en {{backend}}, iniciado {{started}}", + "failed": "El entrenamiento falló", + "failedAt": "El entrenamiento se detuvo en el paso {{step}} de {{total}}", + "noMessage": "El backend no dio ningún mensaje.", + "finished": "El entrenamiento terminó.", + "finishedLoss": "El entrenamiento terminó. La última pérdida fue {{loss}}.", + "stopped": "El job se detuvo. Se conserva un checkpoint si lo elegiste.", + "tryTitle": "Qué hacer después", + "tryText": "El servidor indicó que se acabó la memoria. Cada cambio de abajo se aplica a una copia de la configuración, y nada arranca hasta que pulses Iniciar.", + "tryPlain": "Vuelve a abrir la configuración, cambia lo que indica el mensaje e inicia un job nuevo.", + "fixBatch": "Bajar el tamaño de lote a 1", + "fixCheckpointing": "Activar gradient checkpointing (más lento, usa menos memoria)", + "retryChanges": "Abrir la configuración con estos cambios", + "reuse": "Volver a abrir la configuración", + "resumeFrom": "Reanudar desde el paso {{step}}", + "status": { + "queued": "En cola", + "loading_model": "Cargando el modelo", + "loading_dataset": "Cargando el dataset", + "training": "Entrenando", + "saving": "Guardando", + "completed": "Completado", + "failed": "Fallido", + "stopped": "Detenido" + } + }, + "stop": { + "title": "¿Detener el entrenamiento?", + "text": "Un checkpoint guardado ahora te permite reanudar o exportar lo aprendido hasta ahora.", + "keep": "Detener y guardar un checkpoint", + "discard": "Detener sin checkpoint", + "cancel": "Seguir entrenando", + "cancelDelete": "Cancelar" + }, + "chart": { + "loss": "Pérdida", + "learning_rate": "Tasa de aprendizaje", + "grad_norm": "Norma del gradiente", + "metric": "Métrica", + "step": "paso", + "eval": "Pérdida de evaluación", + "waiting": "Esperando más pasos.", + "window": "Pasos {{from}} a {{to}} de {{total}}", + "windowEval": "Pasos {{from}} a {{to}} de {{total}}. Los puntos vacíos son la pérdida de evaluación.", + "aria": "{{metric}} a lo largo de los pasos de entrenamiento. El último valor es {{last}} en el paso {{step}}.", + "readout": "paso {{step}}: {{value}}", + "dataTitle": "Mostrar los números", + "emptyText": "El gráfico se dibuja cuando el job ha informado de dos pasos." + }, + "log": { + "title": "Registro", + "note": "Lo que el job ha informado desde que se abrió esta página.", + "only": "Solo avisos y errores", + "copy": "Copiar", + "copied": "Copiado", + "download": "Descargar", + "empty": "Aún no se ha informado de nada.", + "noneFiltered": "No hay avisos ni errores." + }, + "checkpoints": { + "title": "Checkpoints", + "step": "Paso", + "epoch": "Época", + "loss": "Pérdida", + "path": "Ruta", + "actions": "Acciones", + "resume": "Reanudar", + "resumeTitle": "Reanudar el entrenamiento desde este checkpoint", + "export": "Exportar", + "exportTitle": "Exportar este checkpoint" + }, + "export": { + "title": "Exportar como modelo", + "lede": "Exportar convierte el resultado en un modelo que LocalAI puede cargar.", + "checkpoint": "Checkpoint", + "final": "Modelo final (directorio de salida)", + "checkpointOption": "Paso {{step}} (pérdida {{loss}})", + "format": "Formato de exportación", + "lora": "Adaptador LoRA", + "merged16": "Fusionado (16 bits)", + "merged4": "Fusionado (4 bits)", + "quant": "Cuantización", + "name": "Nombre del modelo", + "nameHint": "Déjalo en blanco para generar uno a partir del job.", + "go": "Exportar", + "exporting": "Exportando...", + "working": "Exportación en curso...", + "done": "Modelo exportado y registrado como \"{{name}}\"", + "failed": "La exportación falló: {{message}}", + "unknown": "error desconocido", + "nextTitle": "{{name}} ya es un modelo", + "nextText": "Está registrado en LocalAI. Abre un chat con él o búscalo en Modelos.", + "chat": "Chatear con {{name}}", + "models": "Abrir en Modelos", + "archive": "Descargar el archivo" + }, + "jobs": { + "title": "Jobs anteriores", + "count_one": "{{count}} guardado en este servidor", + "count_other": "{{count}} guardados en este servidor", + "loading": "Cargando jobs", + "emptyTitle": "Aún no hay jobs de ajuste fino", + "emptyText": "Configura uno arriba. Aparece aquí con su estado, y puedes reutilizar su configuración más adelante.", + "model": "Modelo", + "kind": "Tipo", + "status": "Estado", + "started": "Iniciado", + "actions": "Acciones", + "open": "Abrir este job", + "reuse": "Reutilizar", + "reuseTitle": "Usar la configuración de este job para uno nuevo", + "delete": "Eliminar", + "deleteTitle": "Eliminar este job y sus datos", + "deleteDialogTitle": "¿Eliminar este job?", + "deleteDialogText": "Esto elimina el job con sus checkpoints y cualquier modelo exportado. No se puede deshacer." + }, + "quantize": { + "title": "Cuantizar", + "lede": "Haz un modelo más pequeño para que quepa en menos memoria. El resultado es un archivo GGUF que puedes importar.", + "newJob": "Nuevo job", + "sectionModel": "Modelo", + "model": "Modelo", + "modelHint": "Un id de Hugging Face, o una ruta en este host.", + "sectionPrecision": "Precisión", + "type": "Tipo de cuantización", + "typeHint": "Un número más bajo significa un archivo más pequeño y algo menos de calidad.", + "custom": "Personalizado...", + "customLabel": "Tipo personalizado", + "customPlaceholder": "por ejemplo iq4_nl", + "moreHint": "backend, token de Hugging Face", + "tokenHint": "Solo hace falta para los modelos restringidos.", + "recipe": "Convertir {{model}} a GGUF en {{type}}.", + "recipeEmpty": "Escribe un modelo para ver qué se creará.", + "start": "Cuantizar ({{type}})", + "barNote": "No se cambia nada hasta que inicies.", + "jobMeta": "{{type}} en {{backend}}, iniciado {{started}}", + "failed": "La cuantización falló", + "finished": "Cuantización terminada: {{type}}.", + "stopped": "El job se detuvo.", + "tryText": "Vuelve a abrir la configuración, cambia lo que indica el mensaje e inicia un job nuevo. Un backend más reciente puede soportar una arquitectura que uno anterior no soporta.", + "outputTitle": "El resultado", + "outputText": "Un archivo GGUF en {{type}}. Impórtalo para usarlo en LocalAI, o descárgalo.", + "file": "Archivo", + "importName": "Nombre del modelo", + "importNamePlaceholder": "Se genera si está vacío", + "import": "Importar a LocalAI", + "importing": "Importando...", + "importFailed": "La importación falló", + "importFailedWith": "La importación falló: {{message}}", + "download": "Descargar GGUF", + "nextTitle": "{{name}} ya es un modelo", + "nextText": "Está registrado en LocalAI. Abre un chat con él o búscalo en Modelos.", + "chat": "Chatear con {{name}}", + "models": "Abrir en Modelos", + "imported": "importado como {{name}}", + "emptyTitle": "Aún no hay jobs de cuantización", + "emptyText": "Configura uno arriba. Aparece aquí con su estado.", + "deleteDialogText": "Esto elimina el job con sus archivos convertidos. No se puede deshacer.", + "startFailed": "No se pudo iniciar el job", + "stopFailed": "No se pudo detener el job", + "deleteFailed": "No se pudo eliminar el job", + "status": { + "queued": "En cola", + "downloading": "Descargando", + "converting": "Convirtiendo", + "quantizing": "Cuantizando", + "completed": "Completado", + "failed": "Fallido", + "stopped": "Detenido" + } + }, + "off": { + "badge": "No disponible para ti", + "fineTune": { + "title": "Tu cuenta no puede hacer ajuste fino", + "text": "El ajuste fino es un permiso aparte, y esta cuenta no lo tiene." + }, + "quantize": { + "title": "Tu cuenta no puede cuantizar", + "text": "La cuantización es un permiso aparte, y esta cuenta no lo tiene." + }, + "admin": "Un administrador puede activarlo en Usuarios y claves.", + "askAdmin": "Pide a un administrador que lo active para tu cuenta.", + "back": "Ir al inicio" + } +} diff --git a/core/http/react-ui/public/locales/es/traffic.json b/core/http/react-ui/public/locales/es/traffic.json new file mode 100644 index 000000000..1509011f9 --- /dev/null +++ b/core/http/react-ui/public/locales/es/traffic.json @@ -0,0 +1,12 @@ +{ + "overview": { + "usageErrorTitle": "No se pudo leer el uso." + }, + "host": { + "errorTitle": "No se pudo leer el host." + }, + "models": { + "noMatch": "Ningún modelo coincide con «{{query}}»." + }, + "clearSearch": "Borrar búsqueda" +} diff --git a/core/http/react-ui/public/locales/es/upgrade.json b/core/http/react-ui/public/locales/es/upgrade.json new file mode 100644 index 000000000..c29ea8b1d --- /dev/null +++ b/core/http/react-ui/public/locales/es/upgrade.json @@ -0,0 +1,46 @@ +{ + "badge": { + "available": "Actualización disponible", + "availableVersion": "Actualización disponible: {{version}}" + }, + "trigger": { + "all": "Actualizar todo ({{count}})", + "running": "Actualizando {{current}} de {{total}}", + "summary": "{{done}} actualizados, {{failed}} con error, {{skipped}} omitidos", + "summaryOk": "{{done}} actualizados", + "review": "Revisar" + }, + "dialog": { + "title": "Actualizar todo", + "intro": "Elija qué actualizar. Cada uno se actualiza por separado, uno tras otro. Un error no detiene a los demás.", + "selection": "Selección", + "selectAll": "Seleccionar todo", + "selectNone": "No seleccionar nada", + "selectedCount_one": "{{count}} seleccionado", + "selectedCount_other": "{{count}} seleccionados", + "item": "Actualizar {{name}}", + "versions": "{{from}} a {{to}}", + "blocked": "No puede iniciarse", + "start": "Actualizar selección", + "starting": "Actualizando...", + "retry": "Reintentar los fallidos ({{count}})", + "again": "Nueva selección", + "close": "Cerrar", + "closeRunning": "Cerrar (sigue en curso)", + "runningNote": "Cerrar esta ventana no detiene las actualizaciones.", + "progress": "Actualizando {{current}} de {{total}}", + "summary": "{{done}} actualizados, {{failed}} con error, {{skipped}} omitidos", + "empty": "Nada que actualizar." + }, + "item": { + "queued": "En cola", + "upgrading": "Actualizando", + "done": "Hecho", + "failed": "Error", + "skipped": "Omitido" + }, + "reason": { + "checkAgain": "Comprobar de nuevo", + "checking": "Comprobando..." + } +} diff --git a/core/http/react-ui/public/locales/es/workspace.json b/core/http/react-ui/public/locales/es/workspace.json new file mode 100644 index 000000000..c60616859 --- /dev/null +++ b/core/http/react-ui/public/locales/es/workspace.json @@ -0,0 +1,671 @@ +{ + "wellKnown": { + "revert": "Volver a la versión anterior", + "view": "Vista", + "form": "Formulario", + "save": "Guardar", + "cancel": "Cancelar", + "notSet": "No definido", + "moveUp": "Subir", + "moveDown": "Bajar", + "remove": "Quitar", + "savedAt": "Guardado a las {{time}}", + "notSaved": "Aún no guardado.", + "notSavedPrefilled": "Aún no guardado. Algunos valores ya están rellenados; Guardar los conserva.", + "loadFailed": "No se pudieron cargar estos ajustes. Recarga la página para intentarlo de nuevo.", + "invalid": "Estos ajustes tienen errores y no hay una versión válida anterior.", + "invalidFallback": "Estos ajustes tienen errores. Aquí se muestra su última versión válida.", + "conflict": "Estos ajustes cambiaron mientras los editabas, probablemente por el asistente o en otra pestaña. Los valores más recientes son:", + "useTheirs": "Usar los valores más recientes", + "keepMine": "Mantener mis cambios", + "editWithAssistant": "Editar con el asistente", + "from": { + "account": "Desde tu cuenta", + "browser": "Desde tu navegador" + }, + "check": { + "required": "Obligatorio.", + "tooLong": "Como máximo {{max}} caracteres.", + "format": "Este valor tiene un formato incorrecto." + }, + "source": { + "label": "Fuente", + "hint": "Los ajustes como texto. Se comprueban de la misma forma al guardar.", + "notJson": "El texto tiene un error de sintaxis. Revisa las comillas, las comas y las llaves.", + "notObject": "El texto debe empezar con { y terminar con }." + }, + "persona": { + "title": "Asistente", + "lede": "Nombre e instrucciones que el asistente usa en cada chat." + }, + "user": { + "title": "Tú", + "lede": "El asistente lee esto para saber con quién habla. Llega al modelo en cada chat." + }, + "ui": { + "title": "Apariencia", + "lede": "Cómo se ve LocalAI para ti. Los cambios se muestran al momento; Guardar los conserva." + }, + "shortcuts": { + "title": "Atajos", + "lede": "Las sugerencias encima del cuadro de texto de cada chat.", + "edit": "Editar atajos", + "defaults": "Atajos predeterminados", + "none": "No hay atajos. Añade uno o guarda una lista vacía para ocultarlos.", + "label": "Etiqueta", + "prompt": "Prompt", + "send": "Enviar al momento", + "add": "Añadir atajo", + "invalid": "Algunos atajos tienen errores.", + "assistantPrompt": "Cambia mis atajos: " + }, + "home": { + "editLayout": "Editar diseño", + "ask": "Preguntar al asistente", + "askPrompt": "Cambia mi página Home: ", + "add": "Añadir widget", + "newTitle": "Nuevo widget", + "noWidgets": "Aún no hay widgets.", + "type": "Tipo", + "title": "Título", + "source": "Archivo de datos", + "noSourceNeeded": "No hace falta archivo de datos", + "types": { + "list": "Lista", + "metric": "Métrica", + "chats": "Últimos chats", + "page": "Página" + }, + "left": "{{count}} restantes", + "fromChats": "de los chats", + "noChats": "Aún no hay chats.", + "noData": "Aún no hay datos. El asistente puede escribir", + "nothing": "No hay nada aquí.", + "noSource": "Este widget no tiene archivo de datos. Elige uno en Editar diseño.", + "broken": "Los datos de este widget tienen errores y no hay una versión válida anterior.", + "stale": "Se muestran los últimos datos válidos. Los datos más recientes tienen errores.", + "updated": "Actualizado {{when}}", + "footer": { + "noData": "Aún no hay datos.", + "invalid": "Los datos tienen un error. Se muestra la última versión correcta.", + "revert": "Revertir", + "missing": "Faltan los datos de este widget.", + "fix": "Pedir al asistente que lo arregle", + "fixPrompt": "El widget de Inicio lee {{file}}, que falta o está dañado. Arréglalo: ", + "waiting": "Esperando a que crees su disparador en el chat.", + "openChat": "Abrir el chat", + "running": "Actualizando…", + "openRun": "Abrir la ejecución", + "paused": "En pausa · era {{schedule}}", + "resume": "Reanudar", + "failedEmpty": "La actualización falló y aún no hay datos.", + "details": "Detalles", + "retry": "Reintentar", + "stale": "La última actualización falló. Se muestran los datos de {{when}}.", + "staleToday": "La última actualización falló. Se muestran datos anteriores de hoy.", + "earlier": "antes", + "firstRun": "Primera ejecución {{when}}", + "firstRunFile": "Se ejecuta cuando llega un archivo", + "runNow": "Ejecutar ahora", + "fresh": "{{schedule}} · actualizado {{time}}", + "pause": "Pausar", + "fromPage": "De la página {{title}}", + "fromFile": "De tus archivos", + "next": "Próxima ejecución {{when}}", + "cost": "unos {{tokens}} tokens por ejecución", + "costRemote": "unos {{tokens}} tokens por ejecución en un modelo remoto", + "cadence": { + "paused": "En pausa", + "hourly": "Cada hora", + "daily": "Diario", + "weekdays": "Laborables", + "weekly": "Semanal", + "monthly": "Mensual", + "file": "Con archivos nuevos" + }, + "scheduleNote_one": "{{count}} widget se actualiza según un horario. Sus ejecuciones usan tus modelos y cuentan para tu uso.", + "scheduleNote_other": "{{count}} widgets se actualizan según un horario. Sus ejecuciones usan tus modelos y cuentan para tu uso." + }, + "opensSite": "{{host}}: se abre en una pestaña nueva", + "linkAsk": "Este widget quiere abrir un enlace", + "invalid": "El diseño de tu Home tiene errores y no hay una versión válida anterior.", + "invalidFallback": "El diseño de tu Home tiene errores. Home muestra su última versión válida.", + "page": { + "gone": "Esta página ya no está disponible", + "goneHint": "La página \"{{name}}\" se movió o se eliminó, o ya no puedes abrirla.", + "noWidget": "Esta página no tiene vista de widget", + "noWidgetHint": "Pide al asistente que dé a la página \"{{name}}\" una vista compacta para Inicio.", + "off": "Las páginas compartidas están desactivadas", + "offHint": "Un administrador desactivó las páginas compartidas en este servidor.", + "fix": "Personalizar Inicio", + "failed": "La página no se cargó", + "retry": "Reintentar", + "changed": "Esta página cambió desde que la permitiste", + "notAllowed": "Aún no has permitido esta página", + "allowHint": "Viene de una carpeta compartida. Revísala antes de que se ejecute en tu nombre.", + "allow": "Permitir esta página", + "frameTitle": "{{title}} (página en Inicio)", + "refresh": "Actualizar", + "open": "Abrir página", + "add": "Añadir una página", + "limit": "Inicio muestra como máximo {{max}} páginas; quita una para añadir otra.", + "noneEligible": "Ninguna página tiene todavía una vista compacta para Inicio. Pide al asistente que añada una a una página.", + "pick": "Página", + "unavailable": "{{name}} (no disponible)", + "sharedLabel": "compartida", + "sizes": { + "small": "Pequeño", + "medium": "Mediano", + "large": "Grande" + } + } + }, + "fields": { + "persona": { + "name": { + "label": "Nombre", + "help": "Cómo se llama el asistente a sí mismo." + }, + "system_prompt": { + "label": "Prompt del sistema", + "help": "Se añade a cada chat, después del prompt del chat y de las instrucciones del agente." + } + }, + "user": { + "name": { + "label": "Nombre", + "help": "Cómo te llama el asistente." + }, + "language": { + "label": "Idioma", + "help": "El idioma en el que responde el asistente." + }, + "bio": { + "label": "Sobre ti", + "help": "Algunas líneas que el asistente lee en cada chat." + }, + "timezone": { + "label": "Zona horaria", + "help": "Para fechas y horas en las respuestas." + } + }, + "ui": { + "theme": { + "label": "Tema", + "help": "Sistema sigue la configuración de tu dispositivo.", + "options": { + "dark": "Oscuro", + "light": "Claro", + "system": "Sistema" + } + }, + "accent": { + "label": "Color de acento", + "help": "Para botones y enlaces. Vacío mantiene el predeterminado." + }, + "density": { + "label": "Densidad", + "help": "Compacta muestra más en una pantalla.", + "options": { + "comfortable": "Cómoda", + "compact": "Compacta" + } + }, + "defaultModel": { + "label": "Modelo predeterminado", + "help": "El modelo con el que empieza un chat nuevo." + } + } + }, + "saveInvalid": "Algunos valores no son válidos. Revísalos y guarda de nuevo.", + "saveTooLarge": "Estos ajustes son demasiado grandes para guardarlos. Quita algunas entradas e inténtalo de nuevo.", + "saveFailed": "No se pudo guardar. Inténtalo de nuevo.", + "details": "Detalles" + }, + "discovery": { + "ex": { + "title": "Haz LocalAI tuyo", + "lede": "Dilo en un chat. El asistente lo guarda, y puedes cambiarlo o deshacerlo.", + "hide": "Ocultar", + "hiddenToast": "Ejemplos ocultos.", + "saveFailed": "No guardado: {{message}}", + "card1": { + "kicker": "Tú y el asistente", + "prompt": "Llámame {{firstName}} y responde con brevedad", + "promptNoName": "Recuerda mi nombre y responde con brevedad", + "what": "Guarda tu nombre y cómo responde el asistente" + }, + "card2": { + "kicker": "Una página", + "prompt": "Crea un tablero de tareas para mí", + "what": "Añade una página que abres desde la barra lateral" + }, + "card3": { + "kicker": "Cada mañana", + "prompt": "Cada mañana, resume aquí mi bandeja de entrada", + "promptNoMail": "Cada mañana, enumera aquí los chats de ayer", + "what": "Añade un widget en Home y un disparador que confirmas" + }, + "quote": "«{{text}}»", + "donePrefix": "Hecho.", + "doneLink": "Cámbialo en Account", + "ready": "O empieza con una página lista:", + "readyTasks": "Tablero de tareas", + "readyReading": "Lista de lectura", + "readyFailed": "La página no se copió: {{message}}", + "learn": "Cómo funciona", + "inComposer": "Edítalo si quieres y pulsa Enter. No se guarda nada hasta que lo envíes.", + "live": "Texto añadido al cuadro de mensaje." + }, + "home": { + "customise": "Personalizar Home" + }, + "action": { + "undo": "Deshacer", + "openAccount": "Abrir en Account", + "openPage": "Abrir página", + "openHome": "Abrir Home", + "turnBackOn": "Vuelve a activarlos en Account" + }, + "change": { + "profilePersona": "Se guardaron tu nombre y tus instrucciones para el asistente. Cada chat los usa desde tu próximo mensaje.", + "profile": "Se guardó en tu perfil.", + "persona": "Se guardaron las instrucciones del asistente. Cada chat las usa desde tu próximo mensaje.", + "shortcuts": "Se guardaron tus atajos.", + "home": "Se guardó tu página Home. Home muestra el cambio.", + "ui": "Se guardaron tus ajustes de apariencia.", + "pageCreated": "Se creó la página {{title}}.", + "pageChanged": "Se cambió la página {{title}}.", + "offersOff": "Las sugerencias del asistente están desactivadas.", + "undoing": "Deshaciendo…", + "undone": "Deshecho.", + "undoFailed": "No se deshizo: {{message}}" + }, + "toast": { + "shortcutAdded": "Añadido a tu cuadro de mensaje: {{label}}", + "pageCreated": "{{title}} está en Pages, en la barra lateral", + "open": "Abrir", + "homeChanged": "Home actualizado.", + "themeChanged": "Tema cambiado." + }, + "files": { + "learn": "Estos archivos cambian cómo funciona LocalAI para ti.", + "tag": { + "profile": "Tu perfil", + "persona": "Asistente", + "shortcuts": "Atajos", + "home": "Diseño de Home", + "ui": "Aspecto", + "page": "Página" + } + }, + "privacy": "El asistente recibe tu nombre, este texto, tu idioma y tu zona horaria al inicio de cada chat. Deja un campo vacío para no enviarlo.", + "versions": { + "label": "Versiones", + "title": "Versiones anteriores", + "loading": "Leyendo el historial…", + "none": "Aún no hay versiones anteriores.", + "current": "actual", + "invalid": "tiene un error", + "byAssistant": "Del asistente", + "byAssistantIn": "Del asistente en «{{chat}}»", + "byYou": "Tuya", + "undo": "Un deshacer", + "upload": "Subida", + "revert": "Revertir", + "note": "Revertir conserva la versión más reciente en esta lista.", + "invalidBadge": "No válida", + "revertGood": "Revertir a la última versión correcta" + }, + "help": { + "title": "Ayuda y sugerencias", + "sub": "Formas en que LocalAI te muestra lo que puede hacer. Desactiva lo que no necesites.", + "offers": "Propuestas del asistente", + "offersHint": "Como mucho una al día, nunca durante una tarea: por ejemplo, para recordar algo que le dijiste.", + "new": "Avisos de novedades", + "newHint": "Una breve etiqueta «Nuevo» en la barra lateral la primera vez que aparece algo.", + "examples": "Ejemplos en Home", + "examplesShown": "Las tarjetas «Haz LocalAI tuyo» se muestran en Home mientras te quede algo por probar.", + "examplesHidden": "Ocultaste las tarjetas «Haz LocalAI tuyo».", + "showExamples": "Mostrar los ejemplos de nuevo", + "examplesBack": "Los ejemplos han vuelto a Home.", + "dismissed": "Lo que el asistente aprendió", + "dismissedHint_one": "Rechazaste {{count}} propuesta. Sigue oculta hasta que la restablezcas.", + "dismissedHint_other": "Rechazaste {{count}} propuestas. Siguen ocultas hasta que las restablezcas.", + "forget": "Olvidar lo que el asistente aprendió sobre mis sugerencias", + "forgotten": "Hecho. El asistente puede volver a proponer.", + "learn": "Descubre qué puedes cambiar:", + "dismissedNone": "Nada todavía. Cuando respondas a una propuesta, el asistente lo recordará aquí." + } + }, + "tabs": { + "shared": "Compartido", + "trashCount_one": "{{count}} elemento en la papelera", + "trashCount_other": "{{count}} elementos en la papelera" + }, + "trash": { + "empty": "La papelera está vacía.", + "restore": "Restaurar", + "emptyAll": "Vaciar la papelera", + "purge": "Eliminar definitivamente", + "purgeNamed": "Eliminar {{name}} definitivamente", + "files_one": "{{count}} archivo", + "files_other": "{{count}} archivos", + "folders_one": "{{count}} carpeta", + "folders_other": "{{count}} carpetas", + "listJoin": ", ", + "summary": "En la papelera: {{what}}.", + "removed": "Eliminado definitivamente: {{what}}.", + "confirmAll": { + "title": "¿Vaciar la papelera?", + "message": "Se eliminarán definitivamente {{what}}, con sus versiones anteriores. No se puede deshacer." + }, + "confirmOne": { + "title": "¿Eliminar definitivamente?", + "message": "{{path}}: se eliminarán definitivamente {{what}}, con sus versiones anteriores. No se puede deshacer." + } + }, + "shared": { + "adminTitle": "Carpetas compartidas", + "adminLede": "Carpetas que usan varias personas. Los miembros abren sus páginas y sus chats pueden leer o editar los archivos.", + "userTitle": "Compartido contigo", + "userLede": "Carpetas que un administrador compartió contigo. Sus páginas están en Páginas y tus chats pueden usar los archivos.", + "new": "Nueva carpeta compartida", + "nameLabel": "Nombre de la carpeta", + "nameHint": "Hasta 64 letras, dígitos, espacios, puntos, guiones o guiones bajos. Empieza con una letra o un dígito.", + "create": "Crear", + "cancel": "Cancelar", + "rename": "Cambiar nombre", + "renameSave": "Guardar", + "remove": "Quitar", + "manage": "Miembros", + "menu": "Más acciones para {{name}}", + "pages_one": "{{count}} página", + "pages_other": "{{count}} páginas", + "pagesNone": "Sin páginas", + "people_one": "{{count}} persona", + "people_other": "{{count}} personas", + "membersNone": "Sin miembros", + "everyone": "Todos", + "everyoneOption": "Todos (todos los usuarios)", + "everyoneHint": "Todos los usuarios, también los que lleguen después", + "everyoneCan": { + "read": "Todos pueden leer", + "write": "Todos pueden editar" + }, + "mode": { + "read": "Puede leer", + "write": "Puede editar" + }, + "addTitle": "Añadir un miembro", + "person": "Persona", + "access": "Acceso", + "accessFor": "Acceso de {{name}}", + "add": "Añadir", + "allAdded": "Todos ya son miembros.", + "usersUnavailable": "No se pudo cargar la lista de usuarios. Aun así puedes añadir a Todos.", + "membersTitle": "Miembros", + "noMembers": "Nadie puede usar esta carpeta todavía. Añade una persona o a Todos.", + "removeMember": "Quitar a {{name}}", + "paneNote": "El cambio se aplica al instante. Un chat abierto lo verá la próxima vez que se abra.", + "created": "Se creó «{{name}}». Añade a las personas que pueden usarla.", + "removed": "Se quitó «{{name}}». Sus páginas vuelven a pedir permiso antes de ejecutarse.", + "restored": "«{{name}}» ha vuelto, con sus miembros.", + "memberAdded": "{{name}}: {{mode}}.", + "memberRemoved": "{{name}} ya no puede usar «{{folder}}».", + "failed": "No se hizo: {{message}}", + "loadFailed": "No se pudieron cargar las carpetas compartidas.", + "emptyAdminTitle": "Aún no hay carpetas compartidas", + "emptyAdminBody": "Una carpeta compartida guarda archivos y páginas que otras personas pueden leer o editar. Crea una y luego añade miembros.", + "factMembers": "Da acceso de lectura o edición a una persona o a todos", + "factChats": "Los chats de los miembros ven los archivos de la carpeta", + "factUndo": "Quitar una carpeta se puede deshacer", + "emptyUserTitle": "Todavía no se ha compartido nada contigo", + "emptyUserBody": "Cuando un administrador comparta una carpeta contigo, aparecerá aquí y sus páginas en Páginas." + }, + "preview": { + "label": "Vista previa", + "failed": "No se pudo mostrar este archivo.", + "loading": "Cargando la vista previa de {{name}}", + "failedTitle": "No se pudo mostrar {{name}}.", + "tooLarge": "Este archivo es demasiado grande para la vista previa. Descárgalo para abrirlo.", + "none": "No hay vista previa para este tipo de archivo.", + "typeFile": "Archivo {{ext}}", + "typeUnknown": "Archivo", + "sheets": "Hojas", + "sheetLabel": "Hoja {{name}}", + "sheetEmpty": "Esta hoja está vacía.", + "row": "Fila", + "showingRows": "Se muestran {{rows}} de {{total}} filas", + "showingCols": "y {{cols}} de {{total}} columnas.", + "downloadRest": "Descarga el archivo para ver el resto.", + "textOnly": "Vista previa del texto, sin formato.", + "truncated": "El texto está recortado.", + "noText": "Este archivo no tiene texto para mostrar.", + "slide": "Diapositiva {{n}}", + "notes": "Notas del orador", + "pdfTitle": "PDF {{name}}", + "pdfFallback": "Si el PDF no se muestra aquí, descárgalo.", + "htmlTitle": "Vista previa de {{name}}", + "imageFailed": "No se pudo mostrar la imagen.", + "zoomIn": "Ver a tamaño completo", + "zoomOut": "Ajustar al panel", + "items_one": "{{count}} elemento", + "items_other": "{{count}} elementos", + "textCut": "Se muestran los primeros {{size}}.", + "actions": "Acciones del archivo" + }, + "managed": { + "group": "Gestionado por LocalAI", + "marker": "gestionado por LocalAI", + "about": { + "localai": "Ajustes que LocalAI lee: persona, perfil, atajos, Inicio, tema.", + "chats": "Una carpeta por chat, con los archivos del chat." + }, + "confirm": { + "deleteTitle": "¿Mover {{name}} a la Papelera?", + "renameTitle": "¿Cambiar el nombre de {{name}}?", + "delete": "Mover a la Papelera", + "rename": "Cambiar nombre", + "restore": "Puedes restaurarlo desde la Papelera.", + "renameNote": "LocalAI solo lo lee con este nombre; después de cambiarlo, es como si no existiera." + }, + "loses": { + "settings": "LocalAI olvida tu persona, tu perfil, tus atajos, el diseño de tu Inicio y tu tema.", + "chats": "Cada chat pierde sus archivos: lo que adjuntaste y lo que el asistente escribió allí.", + "persona": "El asistente pierde su nombre y sus instrucciones.", + "user": "El asistente olvida lo que le contaste sobre ti y tus respuestas a sus ofertas.", + "shortcuts": "El cuadro de mensaje vuelve a mostrar los atajos predeterminados.", + "home": "Inicio pierde sus widgets.", + "ui": "Tu tema, color de acento, densidad, modelo predeterminado y páginas fijadas vuelven a los valores predeterminados." + } + }, + "newFile": "Archivo nuevo", + "newFolderName": "Carpeta nueva", + "newFileName": "Sin título.md", + "fileName": "Nombre del archivo", + "downloadOne": "Descargar {{name}}", + "moveTo": "Mover a…", + "uploadMenu": "Más opciones de subida", + "uploadFiles": "Subir archivos", + "uploadFolder": "Subir una carpeta", + "loading": "Cargando la carpeta", + "loadFailed": "No se pudo abrir {{folder}}.", + "types": { + "folder": "Carpeta", + "image": "Imagen", + "pdf": "PDF", + "sheet": "Hoja de cálculo", + "doc": "Documento", + "slides": "Presentación", + "archive": "Archivo comprimido", + "audio": "Audio", + "video": "Vídeo", + "code": "Código", + "text": "Texto", + "file": "Archivo" + }, + "columns": { + "name": "Nombre", + "modified": "Modificado", + "type": "Tipo", + "size": "Tamaño", + "actions": "Acciones" + }, + "view": { + "label": "Vista", + "list": "Lista", + "grid": "Miniaturas" + }, + "select": { + "one": "Seleccionar {{name}}", + "all": "Seleccionar todo", + "bar": "Elementos seleccionados", + "clear": "Borrar la selección", + "count_one": "{{count}} seleccionado", + "count_other": "{{count}} seleccionados", + "start": "Seleccionar" + }, + "menu": { + "label": "Acciones para {{name}}", + "more": "Más acciones para {{name}}", + "openFolder": "Abrir", + "preview": "Vista previa", + "downloadMany_one": "Descargar {{count}} archivo", + "downloadMany_other": "Descargar {{count}} archivos", + "moveMany_one": "Mover {{count}} elemento a…", + "moveMany_other": "Mover {{count}} elementos a…", + "deleteMany_one": "Mover {{count}} elemento a la papelera", + "deleteMany_other": "Mover {{count}} elementos a la papelera" + }, + "keys": { + "delete": "Supr" + }, + "crumbs": { + "hidden": "Mostrar las carpetas superiores" + }, + "naming": { + "empty": "Escribe un nombre.", + "slash": "Un nombre no puede contener / ni \\.", + "taken": "Ya hay algo con ese nombre en esta carpeta." + }, + "toast": { + "undo": "Deshacer", + "deletedOne": "{{name}} se movió a la papelera.", + "deleted_one": "{{count}} elemento se movió a la papelera.", + "deleted_other": "{{count}} elementos se movieron a la papelera.", + "restored": "Restaurado.", + "renamed": "Renombrado a {{name}}.", + "renameUndone": "Se restauró el nombre anterior.", + "movedOne": "{{name}} se movió a {{folder}}.", + "moved_one": "{{count}} elemento se movió a {{folder}}.", + "moved_other": "{{count}} elementos se movieron a {{folder}}.", + "moveUndone": "Devuelto a su sitio." + }, + "search": { + "placeholder": "Filtrar esta carpeta", + "resultsFor": "Archivos que contienen “{{query}}”", + "back": "Volver a la carpeta", + "noneBody": "Ningún nombre ni texto de tus archivos coincide. Prueba con otras palabras.", + "page": "página {{n}}", + "sheet": "hoja «{{name}}»", + "slide": "diapositiva {{n}}", + "indexing_one": "{{count}} documento aún se está indexando: vuelve a buscar en un momento para incluirlo.", + "indexing_other": "{{count}} documentos aún se están indexando: vuelve a buscar en un momento para incluirlos.", + "limited": "Resultados limitados: la búsqueda se detuvo en sus límites antes de revisar todos los archivos. Busca en una carpeta o usa otras palabras para ver el resto.", + "filtering_one": "{{count}} elemento de esta carpeta coincide con “{{query}}”.", + "filtering_other": "{{count}} elementos de esta carpeta coinciden con “{{query}}”.", + "everywhere": "Buscar “{{query}}” en todos los archivos", + "everywhereShort": "Buscar en todos los archivos", + "noMatch": "Nada en esta carpeta coincide con “{{query}}”.", + "clear": "Borrar el filtro" + }, + "emptyHome": { + "title": "Tus archivos están aquí" + }, + "emptyFolder": { + "title": "Esta carpeta está vacía", + "body": "Suelta archivos o carpetas en cualquier parte de esta página, pégalos con Ctrl+V o súbelos desde tu ordenador. El asistente también puede guardar archivos aquí desde un chat.", + "bodyTouch": "Sube archivos desde tu dispositivo. El asistente también puede guardar archivos aquí desde un chat.", + "ask": "Pedírselo al asistente" + }, + "drop": { + "title": "Suelta para subir a {{folder}}", + "body": "Se admiten archivos y carpetas completas.", + "limit": "Archivos y carpetas completas, de hasta {{size}} cada uno." + }, + "conflict": { + "title_one": "Ya existe un archivo con este nombre", + "title_other": "Ya existen {{count}} archivos con estos nombres", + "body_one": "{{folder}} ya tiene un archivo con este nombre.", + "body_other": "{{folder}} ya tiene archivos con estos nombres.", + "skip_one": "Omitir", + "skip_other": "Omitir todos", + "keep": "Conservar ambos", + "replace_one": "Reemplazar", + "replace_other": "Reemplazar todos", + "more_one": "y {{count}} más", + "more_other": "y {{count}} más", + "note": "Conservar ambos le da un nombre nuevo al archivo nuevo. Un archivo reemplazado se puede recuperar desde el historial.", + "cancel": "Cancelar la subida" + }, + "move": { + "title_one": "Mover {{count}} elemento", + "title_other": "Mover {{count}} elementos", + "items_one": "{{count}} elemento", + "items_other": "{{count}} elementos", + "cancel": "Cancelar", + "here": "Mover a {{folder}}", + "failed": "No se pudo leer esta carpeta.", + "noFolders": "No hay carpetas aquí. Mueve aquí o sube un nivel." + }, + "uploads": { + "waiting": "En espera", + "progress": "{{loaded}} de {{total}}", + "rate": "{{rate}}/s", + "left": "quedan {{time}}", + "renamed": "Guardado como {{name}}", + "done": "Subido", + "canceled": "Cancelado", + "cancel": "Cancelar", + "cancelOne": "Cancelar la subida de {{name}}", + "retry": "Reintentar", + "retryOne": "Reintentar {{name}}", + "dismiss": "Descartar", + "dismissOne": "Descartar {{name}}", + "show": "Mostrar", + "showOne": "Mostrar {{name}}", + "meterOne": "{{name}}: {{percent}} %", + "meterAll": "Todas las subidas: {{percent}} %", + "cancelAll": "Cancelar todo", + "retryAll": "Reintentar todo", + "fold": "Ocultar la lista", + "unfold": "Mostrar la lista", + "close": "Cerrar", + "head": { + "sending_one": "Subiendo {{count}} archivo", + "sending_other": "Subiendo {{count}} archivos", + "failed_one": "Falló {{count}} subida", + "failed_other": "Fallaron {{count}} subidas", + "done_one": "{{count}} archivo subido", + "done_other": "{{count}} archivos subidos" + }, + "say": { + "start_one": "Subiendo {{count}} archivo.", + "start_other": "Subiendo {{count}} archivos.", + "done_one": "Subida completada: {{count}} archivo.", + "done_other": "Subidas completadas: {{count}} archivos.", + "doneFailed": "Subidas completadas: {{count}} subidos, {{failed}} con error.", + "failed": "No se pudo subir {{name}}." + }, + "errors": { + "tooBig": "Supera el límite de {{limit}} de este servidor.", + "tooLarge": "El servidor lo rechazó por ser demasiado grande.", + "off": "Los archivos están desactivados en este servidor.", + "invalid": "El servidor rechazó este archivo.", + "network": "Se perdió la conexión.", + "server": "El servidor no pudo guardarlo." + }, + "said": "El servidor respondió: {{reason}}." + } +} diff --git a/core/http/react-ui/public/locales/fr/addons.json b/core/http/react-ui/public/locales/fr/addons.json index da1242065..a0f46531b 100644 --- a/core/http/react-ui/public/locales/fr/addons.json +++ b/core/http/react-ui/public/locales/fr/addons.json @@ -3,9 +3,9 @@ "blocked": "Une action précédente sur ce module n'est pas encore confirmée. Vérifiez son état ou ignorez-la d'abord.", "checkStatus": "Vérifier l'état", "dismiss": "Ignorer", - "failed": "Le propriétaire n'a pas terminé l'action. Rien n'a été modifié. Réessayez pour lancer une nouvelle tentative.", - "notApplied": "Le propriétaire a signalé l'action comme terminée, mais le module n'a pas changé. Réessayez pour lancer une nouvelle tentative.", - "unknown": "Le propriétaire n'a pas encore confirmé l'action. Elle est peut-être déjà appliquée. Vérifiez son état, ou ignorez-la avant de lancer une autre action." + "failed": "L’action ne s’est pas terminée. Rien n’a été modifié. Réessayez pour lancer une nouvelle tentative.", + "notApplied": "L’action a été signalée comme terminée, mais le module n’a pas changé. Réessayez pour lancer une nouvelle tentative.", + "unknown": "L’action n’est pas encore confirmée. Elle est peut-être déjà appliquée. Vérifiez son état, ou ignorez-la avant de lancer une autre action." }, "approval": { "title": "Approbation requise", @@ -37,14 +37,16 @@ "emptyTitle": "Aucune application pour le moment", "firstRun": { "browse": "Parcourir les modules", - "title": "Bienvenue dans Applications", - "lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.", - "featuredLabel": "Featured add-ons", + "title": "Aucun add-on pour l’instant", + "lead": "Les add-ons donnent à LocalAI de nouvelles pages et des outils de chat. Ils s’exécutent seuls et ne demandent que ce qu’ils déclarent.", + "featuredLabel": "Commencez par l’un de ceux-ci", "featuredFailed": "The list of add-ons could not be read.", - "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons." + "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons.", + "factChecked": "Vérifiés auprès de vos sources avant de s’exécuter", + "factRemove": "Désactivez-les ou retirez-les à tout moment" }, "headless": "S'exécute en arrière-plan et n'a pas de page propre.", - "listLabel": "Applications", + "listLabel": "Add-ons installés", "loading": "Chargement des applications", "manage": "Gérer", "open": "Ouvrir", @@ -58,10 +60,14 @@ "tools_many": "{{count}} d'outils", "tools_other": "{{count}} outils", "version": "Version {{version}}", - "working": "En attente de la confirmation du propriétaire.", + "working": "En attente de confirmation.", "noMatchTitle": "No add-on matches", - "noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.", - "clearSearch": "Clear search" + "noMatchBody": "Aucun add-on installé ne correspond à « {{query}} ». Essayez d’autres mots.", + "clearSearch": "Clear search", + "loadError": { + "title": "Les add-ons ne sont pas disponibles pour le moment.", + "paused": "Les pages et outils des add-ons sont en pause jusqu’à son retour." + } }, "browse": { "confirmTitle": "Installer {{name}} ?", @@ -71,7 +77,7 @@ "fromLibrary": "depuis {{library}}", "install": "Installer", "installedDetail": "Version {{version}}, génération {{generation}}", - "installedOther": "{{version}} installée (autre version)", + "installedOther": "Vous avez {{version}}", "installedSame": "{{version}} installée", "installedState": "Installé", "installing": "Installation en cours", @@ -87,7 +93,8 @@ "backendFailed": "The backend was not installed.", "alias": "Alias", "kindLabel": "Kind", - "installedOn": "{{name}} is installed and on." + "installedOn": "{{name}} is installed and on.", + "installedChip": "Installé" }, "catalog": { "allLibraries": "Toutes les bibliothèques", @@ -118,8 +125,8 @@ "pin": "Épingler {{name}}", "pinLabel": "Épingler", "pinned": "Épinglé", - "running": "Activé", - "search": "Rechercher des modules", + "running": "En cours", + "search": "Rechercher dans les add-ons installés", "status": { "failed": "en échec", "starting": "démarrage" @@ -132,7 +139,7 @@ "denied": "Le serveur a refusé cette demande. Vous n'avez peut-être pas la permission, ou le module a changé. La liste a été actualisée.", "invalid": "Le serveur n'a pas pu accepter cette demande.", "network": "Le serveur est injoignable. Vérifiez votre connexion.", - "unavailable": "Le propriétaire du module n'est pas disponible pour le moment. Réessayez dans un instant.", + "unavailable": "Les modules ne sont pas disponibles pour le moment. Réessayez dans un instant.", "unknownOutcome": "Le résultat n'a pas pu être confirmé. Vérifiez l'état avant de réessayer." }, "installed": { @@ -177,14 +184,14 @@ }, "hints": { "id": "Minuscules, chiffres et traits d'union. Il ne peut plus changer ensuite.", - "reference": "Le nom d'un identifiant configuré par le propriétaire. Ne collez jamais un jeton ou un mot de passe ici.", + "reference": "Le nom d’un identifiant qu’un administrateur a configuré sur ce serveur. Ne collez jamais de jeton ni de mot de passe ici.", "url": "Une adresse https sans identifiants ni chaîne de requête.", "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.", "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.", "mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched." }, "lastRefresh": "Actualisée {{when}}, {{count}} entrées", - "lead": "Une bibliothèque est un document de catalogue qui liste des modules. Les identifiants ne se saisissent jamais ici : une bibliothèque privée nomme un identifiant configuré par le propriétaire.", + "lead": "Une bibliothèque est un index qui liste des modules, des backends ou des modèles. Les identifiants ne se saisissent jamais ici : une bibliothèque privée de modules nomme un identifiant qu’un administrateur a configuré sur ce serveur.", "listLabel": "Bibliothèques", "loading": "Chargement des bibliothèques", "notRefreshed": "Pas encore actualisée", @@ -255,10 +262,11 @@ "hasUI": "A des pages", "headless": "Sans interface, aucune page", "health": "Santé", + "installedAt": "Installé", "host": "API de l'hôte", "interface": "Interface", "logs": "Journaux", - "logsNone": "La sortie des processus des modules n'est pas collectée dans cette version. La santé et les tentatives de redémarrage ci-dessus sont ce que le propriétaire peut signaler.", + "logsNone": "La sortie des processus des modules n’est pas collectée dans cette version. La santé et les tentatives de redémarrage ci-dessus sont tout ce que LocalAI peut signaler.", "observed": "État observé", "protocol": "Protocole", "restarts": "Tentatives de redémarrage", @@ -342,14 +350,15 @@ "state": { "failed": "En échec", "label": "État : {{state}}", - "running": "Activé", + "running": "En cours", "starting": "Démarrage", "stopped": "Désactivé", - "update-pending": "Mise à jour en attente" + "update-pending": "Mise à jour en attente", + "updateTo": "Mise à jour {{version}}" }, - "supporting": "Les applications que votre propriétaire exécute pour vous, et les bibliothèques dont elles proviennent.", + "supporting": "Des logiciels que vous installez pour donner à LocalAI de nouvelles pages et des outils de chat. Chacun s’exécute seul et ne demande que ce qu’il déclare.", "tabs": { - "apps": "Mes add-ons", + "apps": "Installés", "browse": "Trouver des add-ons", "label": "Sections des modules", "libraries": "Sources" @@ -520,8 +529,8 @@ "unknownOutcome": "Le résultat n'est pas connu. Relisez l'état : la même action n'est pas envoyée deux fois.", "auth": "Reconnectez-vous.", "denied": "Seuls les administrateurs peuvent le faire, depuis leur propre session de navigateur.", - "network": "Le propriétaire est injoignable.", - "unavailable": "Le propriétaire n'est pas disponible." + "network": "Le service des modules est injoignable.", + "unavailable": "Le service des modules n’est pas disponible." } }, "updates": { @@ -595,18 +604,30 @@ "auth": "Reconnectez-vous pour continuer.", "denied": "Seul un administrateur peut le faire depuis sa propre session de navigateur.", "network": "Le réseau est injoignable.", - "unavailable": "Le propriétaire du module n'est pas disponible. Réessayez." + "unavailable": "Le service des modules n’est pas disponible. Réessayez." + }, + "upgradeNamed": "Mettre à jour {{name}}", + "why": { + "role": "Une mise à jour est disponible. Seul un administrateur peut l'installer.", + "needs-check": "Une mise à jour est signalée, mais ses détails ne sont pas encore confirmés. Vérifiez à nouveau pour la confirmer.", + "check-failed": "La dernière vérification a échoué, la mise à jour ne peut pas être confirmée. Vérifiez à nouveau.", + "incompatible": "Une mise à jour existe, mais elle ne peut pas fonctionner ici :", + "integrity": "La bibliothèque liste cette version avec un contenu différent. Elle n'est pas proposée comme mise à jour.", + "recovery": "Les données de cet module doivent d'abord être récupérées. Restaurez une sauvegarde dans Détails, puis mettez à jour.", + "current": "La dernière vérification n'a trouvé aucune mise à jour. L'avis est peut-être périmé. Vérifiez à nouveau.", + "unpinned": "La bibliothèque signale une mise à jour mais n'indique aucun build à installer, elle ne peut donc pas être appliquée d'ici. Vérifiez à nouveau plus tard." }, - "reviewButton": "Mises à jour ({{count}})", - "reviewTitle": "Vérifier les mises à jour des modules", - "reviewDescription": "Rien n'est mis à jour tant que vous ne choisissez pas ici. Chaque mise à jour sauvegarde d'abord les données du module.", "checkAll": "Rechercher des mises à jour", "checkingAll": "Vérification...", "checkAllDone": "{{count}} modules vérifiés.", "checkAllFailed": "{{count}} n’ont pas pu être vérifiés.", - "upgradeNamed": "Mettre à jour {{name}}", - "toastDone": "{{name}} mis à jour vers {{version}}.", - "toastFailed": "{{name}} n’a pas été mis à jour : {{message}}" + "checkResult": { + "done": "Vérifié.", + "failed": "La vérification n'a pas abouti.", + "justNow": "Vérifié à l'instant." + }, + "waitingService": "Le service des modules démarre. LocalAI redemande l'état de la mise à niveau dans {{seconds}} s.", + "checkAgain": "Vérifier maintenant" }, "install": { "stepsLabel": "Install steps", @@ -643,17 +664,19 @@ "addsTools_other": "Adds {{count}} tools", "openNamed": "Open {{name}}", "removeTitle": "Remove {{name}}?", - "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted." + "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted.", + "removeNamed": "Retirer {{name}}…" }, "preview": { "screenshots": "Screenshots", - "adds": "What it adds", + "adds": "Ce qu’il peut faire", "page": "A page of its own in LocalAI", "addsNothing": "Nothing you can see. It works in the background.", "version": "Version", "id": "Identifier", "sourceUnknown": "Not known", - "moreDetails": "Health, trust and updates" + "moreDetails": "Health, trust and updates", + "updateTitle": "Mise à jour vers {{version}}" }, "flow": { "installAndTurnOn": "Installer et activer", @@ -667,6 +690,66 @@ "install": "Download, verify and install", "turn-on": "Turn on" }, - "seeInList": "See it in My add-ons" + "seeInList": "Le voir dans Installés", + "install": "Installer", + "installNamed": "Installer {{name}}", + "shadowWarning": "Ce module remplace l'outil intégré {{tool}}. Un autre module ({{name}}) le fait déjà. Un seul peut être actif ; vous choisissez lequel dans Paramètres > Outils. Tant que vous ne choisissez pas, c'est le premier installé qui le fournit.", + "skillShadow": "Ce module remplace la compétence intégrée {{skill}}.", + "skillShadowWarning": "Ce module remplace la compétence intégrée {{skill}}. Un autre module ({{name}}) le fait déjà. Un seul peut être actif ; vous choisissez lequel dans Paramètres > Outils. Tant que vous ne choisissez pas, c'est le premier installé qui le fournit." + }, + "family": { + "pages": "Vous cherchez des pages créées par vous ou par un agent ? Elles sont dans Pages." + }, + "filter": { + "label": "Afficher", + "all": "Tous", + "running": "En cours", + "off": "Désactivés", + "showAll": "Tout afficher", + "none": { + "all": "Aucun add-on à afficher.", + "running": "Aucun add-on n’est en cours.", + "off": "Tous les add-ons sont en cours." + } + }, + "attention": { + "label": "À traiter", + "failedTitle": "{{name}} s’est arrêté.", + "failedRestarts_one": "Son démarrage a échoué {{count}} fois de suite.", + "failedRestarts_other": "Son démarrage a échoué {{count}} fois de suite.", + "failedOnce": "Il n’a pas démarré.", + "toolsOff_one": "Son outil de chat ({{count}}) est désactivé jusqu’à ce qu’il redémarre.", + "toolsOff_other": "Ses {{count}} outils de chat sont désactivés jusqu’à ce qu’il redémarre.", + "pageOff": "Sa page n’est pas disponible jusqu’à ce qu’il redémarre.", + "updateTitle": "{{name}} {{version}} est disponible.", + "updateBody": "Vous avez {{current}}. Vous voyez ce qui change et confirmez avant tout remplacement." + }, + "meta": { + "builtin": "Intégré", + "builtinLong": "Fourni avec LocalAI", + "signed": "Signé · {{library}}", + "checked": "Vérifié · {{library}}", + "devBuild": "Version de développement", + "library": "bibliothèque {{library}}" + }, + "skills": { + "title_one": "{{count}} compétence de chat", + "title_other": "{{count}} compétences de chat", + "replaces": "remplace la compétence intégrée", + "serves": "utilisée dans les discussions", + "notServing": "non utilisée pour l'instant", + "serveIn": "Utiliser pour {{skill}} dans les discussions", + "saveFailed": "Impossible d'enregistrer le choix." + }, + "settingsTab": { + "overview": "Aperçu", + "settings": "Paramètres", + "tabsLabel": "Détails de {{name}}", + "off": "Activez {{name}} pour modifier ses paramètres.", + "pageLink": "Paramètres", + "controls": { + "loadFailed": "Les paramètres n’ont pas pu être lus", + "saveFailed": "Les paramètres n’ont pas pu être enregistrés : {{message}}" + } } } diff --git a/core/http/react-ui/public/locales/fr/admin.json b/core/http/react-ui/public/locales/fr/admin.json index efa33e231..ccb13421b 100644 --- a/core/http/react-ui/public/locales/fr/admin.json +++ b/core/http/react-ui/public/locales/fr/admin.json @@ -57,6 +57,72 @@ "assistant": "Assistant LocalAI", "distributed": "Distribué", "responses": "Réponses" + }, + "loadFailedTitle": "Impossible de charger les paramètres.", + "egress": { + "title": "Réseau des commandes", + "intro": "Les commandes du Sandbox n’atteignent le réseau que par un proxy qui consulte LocalAI pour chaque connexion. Réglez ci-dessous la limite, le préréglage d’un nouveau chat, les ports, les registres supplémentaires et la durée du journal ; chaque personne choisit le préréglage de chaque chat dans son menu Outils, dans la limite, et autorise un hôte bloqué sur une carte.", + "serves": "Les commandes s’exécutent isolées dans le module {{name}}", + "servesHost": "Les commandes s’exécutent sur cette machine via {{name}}", + "servesNone": "Aucun Sandbox n’exécute encore de commandes", + "hostNote": "Le backend hôte exécute les commandes sur cette machine avec son réseau : ces réglages ne s’y appliquent pas.", + "counts": "Dernières 24 heures : {{allowed}} autorisées, {{blocked}} bloquées.", + "private": "Laisser la règle d’un administrateur atteindre les réseaux privés", + "privateWarning": "Un hôte autorisé par un administrateur pourra alors atteindre des adresses de votre réseau. Un préréglage seul ne le fait jamais ; les adresses de métadonnées du cloud et du serveur restent refusées.", + "typePhrase": "Pour l’activer, tapez : {{phrase}}", + "registries": "Registres de paquets ({{count}})", + "showAudit": "Afficher le journal", + "hideAudit": "Masquer le journal", + "loadFailed": "Les réglages réseau du Sandbox n’ont pas pu être lus", + "audit": { + "verdict": "Verdict", + "host": "Hôte", + "user": "Utilisateur", + "at": "Quand", + "reason": "Raison", + "bytes": "Octets envoyés / reçus", + "refresh": "Actualiser", + "empty": "Aucune demande ne correspond.", + "loadFailed": "Le journal n’a pas pu être lu", + "verdicts": { + "any": "Tous les verdicts", + "allow": "Autorisée", + "block": "Bloquée", + "done": "Terminée" + } + } + }, + "addons": { + "title": "Modules", + "hint": "Les paramètres apportés par les modules", + "intro": "Chaque module garde ses paramètres sur sa propre page, dans l'onglet Paramètres de ses détails. Ouvrez-en un pour les modifier.", + "off": "Désactivé. Activez-le pour modifier ses paramètres.", + "open": "Ouvrir les paramètres", + "openNamed": "Ouvrir les paramètres de {{name}}", + "count_one": "{{count}} module avec des paramètres", + "count_other": "{{count}} modules avec des paramètres" + }, + "hosts": { + "add": "Ajouter", + "listLabel": "Hôtes toujours autorisés", + "remove": "Retirer {{host}}", + "empty": "Aucun hôte pour l'instant. Saisissez un nom d'hôte, ou hôte:port pour fixer le port.", + "hint": "Noms exacts uniquement. Ajoutez :port pour n'autoriser qu'un port ; sinon, les ports réseau du Sandbox s'appliquent.", + "count": "{{n}}/{{max}}", + "errors": { + "empty": "Saisissez un nom d'hôte.", + "wildcard": "« {{value}} » : saisissez un nom d'hôte exact, pas un motif.", + "shape": "« {{value}} » n'est pas un nom d'hôte. Saisissez seulement l'hôte, sans schéma ni chemin.", + "port": "« {{value}} » : le port doit être compris entre 1 et 65535, et 22 et 25 sont toujours refusés.", + "private": "« {{value}} » est une adresse privée ou locale. Activez d'abord l'interrupteur du réseau privé.", + "numeric": "« {{value}} » : écrivez une adresse IP sous la forme de quatre nombres décimaux.", + "duplicate": "« {{value}} » est déjà dans la liste.", + "full": "La liste contient au plus {{max}} hôtes." + } + }, + "network": { + "title": "Réseau des commandes", + "where": "{{name}} garde sur sa propre page les paramètres réseau des commandes qu’il exécute." } }, "traces": { @@ -97,5 +163,75 @@ "explorer": { "title": "Explorateur", "subtitle": "Parcourir les fichiers et la configuration" + }, + "settingsTools": { + "intro": "Les chats ont quelques outils intégrés qu'un module peut remplacer. Quand le module est installé, en marche et activé pour un chat, le chat utilise l'outil du module, sous le même nom.", + "empty": "Aucun outil intégré n'est gérable sur ce serveur.", + "loadFailed": "Impossible de lire les outils intégrés", + "switch": "Activé", + "byBuiltin": "Intégré", + "byAddon": "Module : {{name}}", + "byNone": "Désactivé", + "unavailable": "Ce serveur ne peut pas l'exécuter : {{reason}}.", + "installSandbox": "Installez le Sandbox pour exécuter les commandes isolées", + "replaceExplain": "Ces modules remplacent {{tool}} : celui choisi le fournit dans chaque chat où il est activé et en marche, et l'outil intégré est masqué. Un seul peut être actif. Tant que vous ne choisissez pas, c'est le premier installé qui le fournit.", + "defaultInstalledFirst": "Par défaut : installé en premier", + "installedOn": "Installé {{when}}", + "choose_one": "{{count}} module remplace {{tool}} : choisissez lequel utiliser.", + "choose_other": "{{count}} modules remplacent {{tool}} : choisissez lequel utiliser.", + "labels": { + "bash": "Commandes (bash)", + "web_search": "Recherche web", + "web_fetch": "Lire des pages web", + "list_agents": "Agents enregistrés" + }, + "descriptions": { + "bash": "Exécute des commandes et du code pour les chats directement sur cette machine quand aucun Sandbox ne fournit bash. Le module Sandbox le remplace par des commandes isolées.", + "web_search": "Recherche sur le web pour les chats sans clé (DuckDuckGo, puis Brave Search, puis Bing), ou avec votre instance SearXNG si elle est définie.", + "web_fetch": "Lit pour les chats une page ou un fichier web public par son adresse. Les adresses de votre réseau sont refusées sauf si vous les autorisez.", + "list_agents": "Permet à un chat de lister les agents enregistrés de l'utilisateur et d'en lancer un comme sous-agent. Chaque chat l'active dans son menu Outils." + }, + "bash": { + "switch": "Laisser les chats exécuter des commandes sur cette machine", + "modeLegend": "Qui peut exécuter des commandes", + "ask": "Demander à chaque fois.", + "askHint": "Seuls les chats des administrateurs l'obtiennent, et chaque commande attend leur accord dans le chat.", + "on": "Autoriser pour tous.", + "onHint": "Les chats de tous les utilisateurs exécutent des commandes ici sans demander, y compris les exécutions planifiées et les sous-agents.", + "default": "Non défini : la valeur par défaut s'applique ({{mode}}).", + "warning": "Les commandes s'exécutent sous l'utilisateur de LocalAI, sans isolation et avec le réseau de cette machine : elles peuvent lire ce que LocalAI peut lire et joindre Internet, votre réseau et l'API LocalAI. Chaque commande est journalisée. Installez le Sandbox pour les isoler." + }, + "modes": { + "off": "désactivé", + "ask": "demander à chaque fois", + "on": "autorisé pour tous" + }, + "skillsTitle": "Compétences remplacées par des modules", + "skillsIntro": "Un module peut remplacer une compétence intégrée par la sienne, écrite pour ses outils. Si plusieurs modules remplacent la même, choisissez celui qui est utilisé.", + "skillLabel": "Compétence {{skill}}", + "skillNotRunning": "Le module choisi ne tourne pas : la compétence intégrée est utilisée.", + "toolNotRunning": "Le module qui remplace cet outil ne tourne pas : l'outil intégré est utilisé.", + "stopped": "arrêté", + "webTest": { + "intro": "Lance une recherche avec chaque fournisseur des paramètres enregistrés et montre ce que chacun a fait. Appliquez d’abord vos modifications.", + "queryLabel": "Requête", + "run": "Tester la recherche", + "running": "Test en cours…", + "answered": "Les chats reçoivent les résultats de {{host}}.", + "noneAnswered": "Aucun fournisseur n’a répondu.", + "failed": "Le test n’a pas pu s’exécuter : {{error}}", + "results_one": "{{count}} résultat", + "results_other": "{{count}} résultats", + "outcomes": { + "ok": "Fonctionne", + "no_results": "Aucun résultat", + "challenged": "Contrôle anti-robot", + "blocked": "Bloqué", + "timeout": "Trop lent", + "error": "Erreur", + "skipped": "Ignoré" + } + }, + "addonSettings": "Paramètres de {{name}}" } } diff --git a/core/http/react-ui/public/locales/fr/agents.json b/core/http/react-ui/public/locales/fr/agents.json index 7f3d5ec2f..58b2e7964 100644 --- a/core/http/react-ui/public/locales/fr/agents.json +++ b/core/http/react-ui/public/locales/fr/agents.json @@ -2,15 +2,13 @@ "title": "Agents", "subtitle": "Gérez des agents IA autonomes", "actions": { - "agentHub": "Hub d'agents", "import": "Importer", "createAgent": "Créer un agent", "edit": "Modifier", "chat": "Discuter", "export": "Exporter", "delete": "Supprimer", - "pause": "Mettre en pause", - "resume": "Reprendre" + "newAgent": "Nouvel agent" }, "table": { "name": "Nom", @@ -27,13 +25,9 @@ "empty": { "noConfigured": "Aucun agent configuré", "noConfiguredText": "Créez un agent pour démarrer des workflows IA autonomes.", - "browseHub": "Vous ne savez pas par où commencer ? Parcourez le <1>Hub d'agents pour trouver des configurations prêtes à importer.", "noMatching": "Aucun agent correspondant", - "noMatchingText": "Aucun agent ne correspond à « {{query}} »" - }, - "sections": { - "yourAgents": "Vos agents", - "otherUsersAgents": "Agents des autres utilisateurs" + "noMatchingText": "Aucun agent ne correspond à « {{query}} »", + "clearSearch": "Effacer la recherche" }, "deleteDialog": { "title": "Supprimer l'agent", @@ -44,12 +38,411 @@ "loadFailed": "Échec du chargement des agents : {{message}}", "deleted": "Agent « {{name}} » supprimé", "deleteFailed": "Échec de la suppression de l'agent : {{message}}", - "paused": "Agent « {{name}} » mis en pause", - "resumed": "Agent « {{name}} » repris", - "pauseFailed": "Échec de la mise en pause de l'agent : {{message}}", - "resumeFailed": "Échec de la reprise de l'agent : {{message}}", "exported": "Agent « {{name}} » exporté", - "exportFailed": "Échec de l'export de l'agent : {{message}}", - "parseFailed": "Échec de l'analyse du fichier d'agent : {{message}}" + "parseFailed": "Échec de l'analyse du fichier d'agent : {{message}}", + "notAnAgent": "The file is not an exported agent", + "deletedUndo": "« {{name}} » supprimé.", + "undoLate": "« {{name}} » était déjà supprimé.", + "restored": "« {{name}} » restauré." + }, + "facts": { + "addons": "Add-ons", + "connector": "MCP connector" + }, + "agent": { + "triggers": "Triggers", + "newTrigger": "New trigger", + "noTriggers": "No trigger starts this agent.", + "untitled": "Untitled chat", + "triggerOn": "On", + "triggerOff": "Off", + "origin": { + "trigger": "Started by a trigger", + "connector": "Started by a connector" + }, + "noRunsText": "Press Chat to start a conversation with this agent. Chats that ran with it, and the ones its triggers start, are listed here.", + "missingText": "It may have been deleted, or it belongs to another user.", + "backToList": "Retour aux agents" + }, + "record": { + "where": "A run is a chat that ran with the agent, including the ones its triggers and connectors started.", + "none": "No runs yet" + }, + "create": { + "sections": { + "BasicInfo": "Basic info", + "ModelSettings": "Model", + "PromptsGoals": "Instructions", + "MemorySettings": "Memory", + "Skills": "Skills", + "Addons": "Add-ons", + "MCP": "MCP servers", + "export": "Export" + }, + "name": "Name", + "description": "Description", + "descriptionHelp": "One line that says what the agent is for. It shows in the list.", + "model": "Model", + "modelPlaceholder": "Any model (the chat picks one)", + "modelHelp": "Leave it empty and a chat with this agent uses the model you pick in the chat. Set one to fix it.", + "anyModel": "Any model", + "instructions": "Instructions", + "instructionsHelp": "What the agent always follows. Every chat with it starts from this text.", + "collections": "Collections", + "collectionsHint": "Collection name", + "collectionsHelp": "The memory collections a chat with this agent may search. The list is exact: an empty list grants none.", + "skillsHelp": "The skills a chat with this agent may read. The list is exact: an empty list grants none.", + "noSkills": "No skills yet.", + "manageSkills": "Write or manage skills", + "addonsHelp": "The add-ons whose tools a chat with this agent may use.", + "noAddons": "No add-on offers tools to a model.", + "mcpHelp": "The MCP servers of the fixed model and the MCP connectors a chat with this agent may use.", + "mcpServers": "MCP servers", + "mcpHint": "Server name", + "mcpConnectors": "MCP connectors", + "noConnectors": "No MCP connectors yet.", + "manageConnectors": "Manage MCP connectors", + "connectorOff": "off", + "gone": "not found", + "countCollections_one": "{{count}} collection", + "countCollections_other": "{{count}} collections", + "countAddons_one": "{{count}} add-on", + "countAddons_other": "{{count}} add-ons", + "needsName": "Needs a name", + "loadFailed": "Failed to load the agent: {{message}}", + "saveFailed": "Save failed: {{message}}", + "created": "Agent \"{{name}}\" created", + "updated": "Agent \"{{name}}\" updated", + "exportSummary": "Download as JSON", + "exportHelp": "Download the definition of this agent as a JSON file. It holds no id, no dates and no secrets. Import it on another instance from the agents list.", + "exportButton": "Export agent", + "importNew": "Imported from an exported agent. Read the form, then save.", + "importLegacy": "Imported from the export of an older agent. Name, description, model, instructions and skills were read; the rest has no counterpart here.", + "importDropped": "Not imported: {{keys}}.", + "subEdit": "A chat that is running keeps the version it started with. Its next turn uses the new one.", + "configNote": "The definition this form will save.", + "previewNew": "The agent this form will save.", + "previewEdit": "The agent this form will save, and how it differs from the saved one.", + "default": "Default", + "network": "Réseau des commandes dans les exécutions sans surveillance", + "networkHelp": "Quand un déclencheur ou un connecteur exécute cet agent, ses commandes du Sandbox atteignent cette part du réseau, dans la limite fixée par un administrateur. Une exécution ne demande jamais : un hôte hors de cette part est bloqué. Un chat que vous démarrez avec cet agent commence aussi ainsi.", + "networkPresets": { + "off": "Désactivé", + "registries": "Registres de paquets", + "all": "Tous les sites publics" + } + }, + "run": { + "workingLine": "Working" + }, + "jobs": { + "title": "Triggers", + "newTask": "New trigger", + "createTask": "Create trigger", + "loading": "Loading triggers", + "loadFailed": "Failed to load: {{message}}", + "actionFailed": "The action failed: {{message}}", + "summary": { + "stopped": "{{count}} skipped", + "lastFailed_one": "The last run of {{names}} failed.", + "lastFailed_other": "The last run failed for {{names}}." + }, + "tasks": { + "title": "Triggers, {{count}}", + "caption": "Triggers", + "task": "Trigger", + "noSchedule": "No schedule", + "noAgent": "No agent", + "fileKind": "When a file arrives", + "open": "Open", + "started": "Trigger \"{{name}}\" started", + "toggleLabel": "Run {{name}} by itself", + "toggleFailed": "Could not change the trigger: {{message}}", + "deleted": "Trigger deleted", + "noneYet": "You have no triggers yet.", + "noModel": "No model", + "next": "Prochaine" + }, + "history": { + "filterTask": "Trigger", + "allTasks": "All triggers", + "task": "Trigger", + "unknownTask": "Unknown trigger", + "cancelledLine": "Skipped", + "doneLine": "Finished. Open the chat for the answer.", + "doneFile": "Finished for {{file}}. Open the chat for the answer.", + "pendingLine": "Waiting to start", + "emptyText": "A run starts when you press Run now, when a schedule fires, or when a file arrives in a watched folder.", + "openRun": "Open the chat", + "reason": "Reason", + "file": "Started by the file {{file}}." + }, + "status": { + "done": "Done", + "failed": "Failed", + "running": "Working", + "queued": "Waiting", + "skipped": "Skipped" + }, + "empty": { + "title": "No triggers yet", + "text": "A trigger starts a chat for you by itself: at a time, or when a file arrives in a folder. Each run is an ordinary chat that you can open.", + "step1": "Write what the chat should do.", + "step2": "Pick an agent, or a model, to run it.", + "step3": "Choose when it runs, or press Run now." + }, + "task": { + "back": "Triggers", + "missingTitle": "Trigger not found", + "agent": "Agent", + "model": "Model", + "schedule": "Schedule", + "cap": "Most runs a day", + "watches": "Watches the folder {{folder}}", + "noRuns": "This trigger has not run yet.", + "runMeta": "took {{time}}", + "prompt": "What to do" + }, + "edit": { + "titleNew": "New trigger", + "subNew": "Name it, write what to do and choose when it runs.", + "subEdit": "Changes apply from the next run.", + "save": "Save trigger", + "create": "Create trigger", + "nameRequired": "A trigger needs a name", + "promptRequired": "Write what the trigger should do", + "created": "Trigger created", + "updated": "Trigger updated", + "sections": { + "basics": "Basics", + "prompt": "What to do", + "schedule": "When", + "limits": "Limits" + }, + "summary": { + "basics": "{{name}}, {{model}}", + "basicsNoModel": "{{name}}, no agent or model yet", + "promptEmpty": "Nothing to do yet", + "folderEmpty": "Needs a folder", + "cap": "Up to {{count}} runs a day", + "capDefault": "Default limit" + }, + "name": "Name", + "namePlaceholder": "daily-digest", + "noAgent": "No agent", + "model": "Model", + "enabled": "On", + "enabledHint": "A trigger that is off keeps its schedule but does not run.", + "prompt": "What to do", + "promptPlaceholder": "Summarise what is new in my Reports folder.", + "promptHint": "This text starts the chat each time the trigger fires.", + "capHint": "The most chats this trigger may start in one day. Empty uses the default.", + "previewEdit": "What the next run would send, and what differs from the saved trigger.", + "previewNew": "What the first run would send.", + "promptAsSent": "The text a run starts with", + "noChanges": "Nothing differs from the saved trigger.", + "loadingTask": "Loading trigger", + "changed_one": "{{count}} change", + "changed_other": "{{count}} changes" + }, + "subtitle": "Lancez un agent selon un horaire ou à l'arrivée d'un fichier. Chaque exécution est un chat que vous pouvez ouvrir." + }, + "views": { + "label": "Espace des agents", + "agents": "Agents" + }, + "now": { + "failedTitle": "{{name}} a échoué.", + "workingTitle": "{{name}} travaille.", + "openRun": "Ouvrir l'exécution" + }, + "load": { + "agents": "Impossible de charger vos agents.", + "agentsStale": "La liste n'a pas pu être actualisée. Elle montre l'état précédent.", + "agent": "Impossible de charger cet agent.", + "agentStale": "Cette page n'a pas pu être actualisée. Elle montre l'état précédent.", + "agentForm": "Impossible de charger cet agent pour le modifier.", + "triggers": "Impossible de charger vos déclencheurs.", + "triggersStale": "Les déclencheurs n'ont pas pu être actualisés. Ils montrent l'état précédent.", + "trigger": "Impossible de charger ce déclencheur.", + "triggerStale": "Ce déclencheur n'a pas pu être actualisé. Il montre l'état précédent.", + "tasks": "Impossible de charger les tâches.", + "tasksStale": "Les tâches n'ont pas pu être actualisées. Elles montrent l'état précédent." + }, + "meta": { + "anyModel": "N'importe quel modèle", + "addons_one": "{{count}} module", + "addons_other": "{{count}} modules" + }, + "chips": { + "lastFailed": "Dernière exécution échouée" + }, + "tasks": { + "title": "Tâches d'agents", + "allUsers": "Tous les utilisateurs", + "new": "Nouvelle tâche", + "newTitle": "Nouvelle tâche", + "editTitle": "Modifier la tâche", + "name": "Nom", + "description": "Description", + "runner": "Exécutée par", + "byModel": "Un modèle", + "byAgent": "Un de vos agents", + "model": "Modèle", + "agent": "Agent", + "pick": "Choisir…", + "prompt": "Prompt", + "promptHint": "Écrivez un paramètre sous la forme {{example}} ; chaque exécution demande sa valeur.", + "webhooksKept_one": "Cette tâche a {{count}} webhook. Il est conservé tel quel ; modifiez les webhooks via l'API.", + "webhooksKept_other": "Cette tâche a {{count}} webhooks. Ils sont conservés tels quels ; modifiez les webhooks via l'API.", + "save": "Enregistrer la tâche", + "cancel": "Annuler", + "saved": "Tâche « {{name}} » enregistrée", + "runTitle": "Exécuter {{name}}", + "runNow": "Exécuter maintenant", + "started": "Tâche « {{name}} » lancée", + "tasks": "Tâches", + "off": "Désactivée", + "run": "Exécuter", + "edit": "Modifier", + "delete": "Supprimer", + "deleted": "Tâche « {{name}} » supprimée", + "task": "Tâche", + "cancelJob": "Annuler", + "rerun": "Exécuter à nouveau", + "rerunStarted": "Relancée", + "stillRunning": "Toujours en cours.", + "noResult": "Aucun résultat.", + "traces_one": "{{count}} étape", + "traces_other": "{{count}} étapes", + "webhookSent": "Le webhook a été appelé.", + "webhookFailed": "Le webhook a échoué : {{message}}", + "actionFailed": "Cela n'a pas fonctionné : {{message}}", + "state": { + "pending": "En attente", + "running": "En cours", + "completed": "Terminée", + "failed": "Échouée", + "cancelled": "Annulée" + }, + "lede": "Des prompts qu'un modèle ou l'un de vos agents exécute en arrière-plan. Lancez-les ici ou depuis un script via l'API.", + "searchLabel": "Rechercher des tâches", + "filter": { + "label": "Afficher les tâches", + "all": "Toutes", + "on": "Activées", + "off": "Désactivées" + }, + "noMatch": "Aucune tâche ne correspond.", + "on": "Activée", + "offLine": "Désactivée. Elle ne s'exécute pas tant que vous ne l'activez pas.", + "onDemand": "S'exécute quand vous la lancez", + "asksFor": "Demande {{list}}", + "webhooks_one": "{{count}} webhook", + "webhooks_other": "{{count}} webhooks", + "runsCount_one": "{{count}} exécution", + "runsCount_other": "{{count}} exécutions", + "runsOf_one": "{{count}} exécution de {{name}}", + "runsOf_other": "{{count}} exécutions de {{name}}", + "toggleLabel": "{{name}} peut s'exécuter", + "turnedOn": "« {{name}} » est activée", + "turnedOff": "« {{name}} » est désactivée", + "moreFor": "Plus d'actions pour {{name}}", + "open": "Ouvrir", + "openTask": "Ouvrir {{name}}", + "runFor": "Exécuter {{name}}", + "rerunFor": "Exécuter {{name}} à nouveau", + "jobMoreFor": "Plus d'actions pour cette exécution de {{name}}", + "detailsFor": "Détails de l'exécution de {{name}}", + "openChat": "Ouvrir le chat", + "deleteJob": "Supprimer l'exécution", + "jobDeleted": "Exécution supprimée", + "cancelled": "Exécution arrêtée", + "confirmJob": { + "title": "Supprimer cette exécution ?", + "message": "Sa réponse et ses étapes sont supprimées. Cette action est définitive." + }, + "line": { + "pending": "En attente de démarrage", + "running": "En cours", + "cancelled": "Arrêtée avant la fin" + }, + "startedBy": { + "manual": "Lancée ici", + "api": "Lancée via l'API", + "cron": "Lancée par une planification" + }, + "output": { + "answer": "Réponse", + "error": "Erreur", + "values": "Valeurs" + }, + "traceKind": { + "reasoning": "Réflexion", + "tool_call": "Outil", + "tool_result": "Résultat de l'outil", + "status": "État" + }, + "empty": { + "title": "Aucune tâche pour l'instant", + "body": "Une tâche est un prompt que vous écrivez une fois et exécutez souvent. Mettez {{example}} là où une valeur change, et chaque exécution la demande.", + "fact1": "Un modèle ou l'un de vos agents l'exécute", + "fact2": "Lancez-la ici ou depuis un script via l'API", + "fact3": "Chaque exécution garde sa réponse et ses étapes" + }, + "sheet": { + "optional": "(facultatif)", + "newDesc": "Écrivez le prompt une fois, puis exécutez-le quand vous en avez besoin.", + "editDesc": "Les modifications s'appliquent à partir de la prochaine exécution.", + "about": "Informations", + "nameHelp": "Les scripts utilisent ce nom pour exécuter la tâche via l'API.", + "descHelp": "Une ligne qui dit à quoi sert la tâche.", + "who": "Qui l'exécute", + "modelHelp": "Le modèle répond au prompt sans outils.", + "agentHelp": "L'agent répond avec ses propres instructions et outils.", + "what": "Ce qu'elle fait", + "promptPlaceholder": "Par exemple : Résume le ticket {{example}} en trois lignes.", + "inputs": "Chaque exécution demande :", + "noInputs": "Ce prompt ne demande rien. Chaque exécution l'envoie tel quel.", + "on": "Activée", + "onHelp": "Une tâche désactivée garde ses exécutions mais ne peut pas démarrer.", + "runDesc": "Indiquez les valeurs de cette exécution.", + "runNone": "Cette tâche ne demande rien. Elle exécute le prompt tel quel.", + "preview": "Ce que reçoit la tâche" + }, + "errors": { + "name": "Donnez un nom à la tâche.", + "prompt": "Écrivez ce que la tâche doit faire.", + "model": "Choisissez un modèle.", + "agent": "Choisissez un agent." + }, + "pane": { + "label": "Détails de la tâche", + "overview": "Aperçu", + "runs": "Exécutions", + "settings": "Réglages", + "inputs": "Demande", + "noInputs": "Rien", + "changed": "Modifiée", + "api": "L'exécuter via l'API", + "apiHint": "Envoyez les valeurs dans un objet JSON. La réponse contient l'identifiant de l'exécution, pour interroger son résultat.", + "noRuns": "Aucune exécution pour l'instant. Exécutez la tâche et sa réponse apparaîtra ici.", + "webhooks": "Webhooks", + "noWebhooks": "Aucun. Ajoutez des webhooks via l'API pour savoir quand une exécution se termine.", + "deleteTitle": "Supprimer cette tâche", + "deleteHelp": "Ses exécutions restent jusqu'à la fin de leur durée de conservation." + }, + "history": { + "title": "Historique", + "filterLabel": "Afficher les exécutions", + "all": "Toutes", + "active": "En cours", + "completed": "Terminées", + "failed": "Échouées", + "cancelled": "Annulées", + "allTasks": "Toutes les tâches", + "emptyBody": "Exécutez une tâche et sa réponse apparaîtra ici.", + "more": "Afficher {{count}} de plus" + } } } diff --git a/core/http/react-ui/public/locales/fr/auth.json b/core/http/react-ui/public/locales/fr/auth.json index 90423c3ae..596540ebf 100644 --- a/core/http/react-ui/public/locales/fr/auth.json +++ b/core/http/react-ui/public/locales/fr/auth.json @@ -47,12 +47,15 @@ "account": { "title": "Compte", "subtitle": "Profil, identifiants et clés d'API", + "subtitleLocal": "Qui vous êtes, et comment l'assistant et LocalAI se comportent pour vous", "unavailable": "Compte indisponible", "unavailableText": "L'authentification doit être activée pour gérer votre compte.", "tabs": { "profile": "Profil", "security": "Sécurité", - "apiKeys": "Clés d'API" + "apiKeys": "Clés d'API", + "about": "À propos de vous et de l'assistant", + "appearance": "Apparence" }, "profile": { "displayName": "Nom affiché", @@ -102,6 +105,9 @@ "copyFailed": "Échec de la copie", "empty": "Aucune clé d'API pour l'instant. Créez-en une ci-dessus pour un accès programmatique.", "lastUsed": "dernière utilisation {{date}}" + }, + "usage": { + "loadFailedTitle": "Impossible de charger votre utilisation." } }, "notFound": { diff --git a/core/http/react-ui/public/locales/fr/chat.json b/core/http/react-ui/public/locales/fr/chat.json index 0aa5e4cc1..6ba752d6b 100644 --- a/core/http/react-ui/public/locales/fr/chat.json +++ b/core/http/react-ui/public/locales/fr/chat.json @@ -25,24 +25,17 @@ "yes": "Oui", "gpuLayers": "Couches GPU" }, - "context": { - "label": "Contexte : {{percent}} %", - "labelWithTokens": "Contexte : {{percent}} % ({{tokens}} tokens)" - }, "settings": { "title": "Paramètres du chat", "manageMode": "Mode gestion", - "manageModeDesc": "Autoriser ce chat à installer des modèles, changer de backend et modifier la configuration en dialoguant avec LocalAI.", "focusMode": "Mode concentration", "focusModeDesc": "Réduit la barre latérale et l'en-tête pendant une conversation. Échap les restaure temporairement ; désactivez cette option pour conserver la mise en page complète.", - "systemPrompt": "Prompt système", - "systemPromptPlaceholder": "Vous êtes un assistant utile...", - "temperature": "Température", - "topP": "Top P", - "topK": "Top K", - "contextSize": "Taille du contexte", - "contextSizePlaceholder": "2048", - "clearHistory": "Effacer l'historique du chat" + "serverKept": "Le serveur conserve ce chat. Son modèle et son agent sont fixés au début.", + "manageModeOn": "Ce chat a les outils de gestion de LocalAI. Utilisez /assistant pour en lancer un autre.", + "agent": "Agent", + "agentHelp": "Ce chat utilise l'agent {{name}} : ses instructions et seulement les outils qu'il accorde.", + "editAgent": "Modifier l'agent", + "deleteHelp": "Supprimer définitivement ce chat et ses messages." }, "empty": { "manageTitle": "Gérez LocalAI par la conversation", @@ -58,47 +51,25 @@ "Aide-moi à écrire du code", "Résume un document", "Trouve-moi des idées" - ], - "noMessages": "Aucun message pour l'instant" + ] }, "errors": { "viewTraces": "Consulter les traces pour plus de détails" }, "actions": { "copy": "Copier", - "edit": "Modifier", - "editMessage": "Modifier le message", - "save": "Enregistrer", - "cancel": "Annuler", "regenerate": "Régénérer", - "branch": "Créer une branche ici", - "jumpToLatest": "Aller au plus récent" + "jumpToLatest": "Aller au plus récent", + "reuse": "Modifier et renvoyer" }, "streaming": { "transferring": "Transfert du modèle...", - "transferringTo": "Transfert du modèle vers {{node}}...", - "onNode": "sur {{node}}", - "eta": "~{{value}} restant", - "modelState": { - "pending": "Préparation du chargement du modèle...", - "installing": "Installation du backend...", - "staging": "Préparation des fichiers du modèle...", - "loading": "Chargement du modèle..." - } - }, - "tokens": { - "perSec": "{{count}} tok/s", - "peak": "Pic : {{count}} tok/s", - "usage": "{{prompt}}p + {{completion}}c = {{total}}" + "transferringTo": "Transfert du modèle vers {{node}}..." }, "input": { - "placeholder": "Message...", - "attachFile": "Joindre un fichier", "send": "Envoyer le message", "stopGenerating": "Arrêter la génération", - "canvasTitle": "Canvas — extrait les blocs de code et les médias dans un panneau latéral pour les prévisualiser, copier et télécharger", - "canvasLabel": "Canvas", - "openCanvas": "Ouvrir le panneau Canvas" + "placeholderQueue": "Le modèle travaille. Un message envoyé maintenant attend son étape suivante." }, "deleteAllDialog": { "title": "Supprimer toutes les conversations", @@ -110,8 +81,7 @@ "copied": "Copié dans le presse-papiers", "copyFailed": "Impossible de copier dans le presse-papiers", "chatCopied": "Conversation copiée dans le presse-papiers", - "forked": "Nouvelle conversation créée", - "pdfReadFailed": "Impossible de lire le texte de {{name}}. Le fichier est peut-être scanné, chiffré ou endommagé." + "newChatForModel": "Un chat garde son modèle. Nouveau chat lancé avec {{model}}." }, "menu": { "trigger": "Conversations", @@ -121,23 +91,497 @@ "noMatch": "Aucune conversation ne correspond", "noConversations": "Aucune conversation pour l'instant", "rename": "Renommer", - "duplicate": "Dupliquer la conversation", "copyChat": "Copier la conversation", "exportMarkdown": "Exporter en Markdown", "deleteChat": "Supprimer la conversation", "newChat": "Nouvelle conversation", "clearAll": "Tout effacer", - "deleteAllTitle": "Supprimer toutes les conversations" + "deleteAllTitle": "Supprimer toutes les conversations", + "untitled": "Nouveau chat", + "origin": { + "trigger": "Lancé par un déclencheur", + "connector": "Depuis un connecteur" + } }, "message": { - "you": "Vous" + "you": "Vous", + "loopRun": "Boucle, exécution {{run}}" }, - "addons": { - "note": "An add-on that is off here does nothing in this chat.", - "label": "Add-ons", - "title": "Add-on tools for this chat", - "heading": "Add-on tools", - "selectAll": "Select all", - "deselectAll": "Deselect all" + "tools": { + "label": "Outils", + "title": "Outils que ce chat peut utiliser", + "heading": "Outils de ce chat", + "draftNote": "Une nouvelle discussion démarre avec ces réglages. Un outil désactivé n'est pas proposé au modèle.", + "note": "Un outil désactivé ici n'est pas proposé au modèle dans ce chat.", + "count": "{{on}} sur {{total}} activés", + "selectAll": "Tout activer", + "disableAll": "Tout désactiver", + "groupOn": "Tout activer", + "groupOff": "Tout désactiver", + "groupOnLabel": "Activer tout {{group}}", + "groupOffLabel": "Désactiver tout {{group}}", + "groupCount": "{{on}} sur {{total}}", + "optIn": "Désactivé tant que vous ne l'activez pas. Tout activer le laisse désactivé.", + "empty": "Rien à activer dans ce chat.", + "failed": "Enregistrement impossible. L'interrupteur revient à son état précédent.", + "unavailable": "Indisponible dans ce chat", + "toolCount_one": "{{count}} outil", + "toolCount_other": "{{count}} outils" + }, + "skills": { + "label": "Compétences", + "manage": "Gérer les compétences", + "title": "Compétences que ce chat peut utiliser", + "heading": "Compétences de ce chat", + "count": "{{on}} sur {{total}} activées", + "selectAll": "Tout sélectionner", + "disableAll": "Tout désactiver", + "selectShown": "Sélectionner celles affichées", + "disableShown": "Désactiver celles affichées", + "search": "Rechercher des compétences", + "noMatch": "Aucune compétence ne correspond à « {{query}} ».", + "later": "Les compétences ajoutées plus tard sont activées au départ.", + "failed": "Enregistrement impossible. Rien n’a changé.", + "source": { + "builtin": "Intégrée", + "personal": "Personnelle", + "shared": "Partagée", + "folder": "Espace de travail", + "addon": "{{name}}" + }, + "replacedBy": "Remplacée par {{name}}", + "fallback_one": "{{skills}} intégrée utilisée : la compétence du module {{name}} est désactivée ou ne tourne pas ici.", + "fallback_other": "{{skills}} intégrées utilisées : les compétences du module {{name}} sont désactivées ou ne tournent pas ici." + }, + "agent": { + "label": "Agent", + "title": "Agent enregistré pour ce chat", + "heading": "Commencer avec un agent", + "note": "L'agent donne au chat ses instructions et exactement les outils qu'il accorde.", + "none": "Aucun agent", + "fixed": "L'agent de ce chat" + }, + "assistant": { + "chatTitle": "Assistant LocalAI" + }, + "slash": { + "delete": { + "label": "Supprimer le chat", + "desc": "Supprimer cette conversation" + }, + "assistant": { + "desc": "Lancer un chat qui peut gérer LocalAI" + }, + "settings": { + "desc": "Mode focus, agent et infos du modèle" + }, + "group": { + "context": "Contexte", + "skills": "Skills et agents", + "help": "Aide" + }, + "models": { + "label": "Modèles", + "desc": "Ouvrir la liste des modèles" + }, + "skill": { + "label": "Lancer une skill", + "desc": "Choisir une des skills de cette conversation" + }, + "skills": { + "label": "Gérer les compétences", + "desc": "Ouvrir la page des compétences pour les écrire et les modifier" + }, + "agent": { + "label": "Demander à un agent enregistré", + "desc": "Confier une tâche à l'un de vos agents", + "enable": "Activer les agents enregistrés pour cette conversation", + "enableDesc": "Puis choisissez l'agent" + }, + "compact": { + "label": "Compacter le contexte", + "desc": "Résumer les anciens messages pour laisser de la place au modèle" + }, + "clear": { + "label": "Vider le contexte", + "desc": "Repartir de zéro dans cette conversation ; les messages restent" + }, + "attach": { + "label": "Joindre des fichiers", + "desc": "Choisir des fichiers à envoyer", + "clearDesc": "Retirer les fichiers en attente d'envoi" + }, + "help": { + "label": "Toutes les commandes", + "desc": "Lister chaque commande et ce qu'elle fait" + }, + "about": { + "label": "À propos de cette conversation", + "desc": "Modèle, outils, skills et taille du contexte" + }, + "args": { + "skill": "Skills", + "agent": "Agents enregistrés", + "model": "Modèles", + "attach": "Fichiers", + "loop": "Boucles", + "goal": "Objectif" + }, + "badge": { + "builtin": "intégrée", + "addon": "module", + "shared": "partagée", + "yours": "la vôtre" + }, + "collides": "seulement avec /skill", + "errors": { + "skill": "Cette conversation n'a pas de skill nommée « {{name}} ».", + "agent": "Vous n'avez pas d'agent enregistré nommé « {{name}} ».", + "agentTask": "Écrivez la tâche pour {{name}} après son nom.", + "agentsOff": "Les agents enregistrés sont désactivés pour cette conversation. Activez-les dans le menu Outils." + }, + "loop": { + "label": "Répéter un prompt", + "desc": "Exécuter un prompt encore et encore dans ce chat : /loop 5m vérifie le build", + "listDesc": "Afficher les boucles de ce chat", + "stopDesc": "Arrêter une boucle, ou toutes", + "stopAll": "Toutes les boucles", + "stopAllDesc": "Arrêter toutes les boucles de ce chat" + }, + "goal": { + "label": "Objectif", + "desc": "Fixer un objectif sur lequel le chat continue de travailler tout seul", + "clearDesc": "Retirer l'objectif", + "resumeDesc": "Continuer vers l'objectif" + } + }, + "shortcuts": { + "onMessage": "Sur un message : copier, modifier, régénérer", + "editLast": "Modifier votre dernier message" + }, + "files": { + "title": "Fichiers de cette conversation", + "chip": "Fichiers", + "chipTitle": "Fichiers de cette conversation : ce que vous avez joint et ce que l'assistant a créé ou modifié", + "close": "Fermer les fichiers", + "emptyTitle": "Aucun fichier dans cette conversation", + "emptyBody": "Déposez des fichiers ici pour les joindre à votre prochain message. Les fichiers que l'assistant crée ou modifie apparaissent aussi ici.", + "dropHint": "Déposez des fichiers ici pour les joindre à votre prochain message.", + "groups": { + "uploaded": "Fournis par vous", + "created": "Créés et modifiés", + "working": "Fichiers de travail" + }, + "turn": "tour {{n}}", + "media": "Dans le contexte", + "mediaTitle": "Le modèle a reçu ce fichier comme image ou vidéo avec votre message", + "changedTag": "Modifié", + "changedTitle": "L'assistant a modifié ce fichier", + "download": "Télécharger {{name}}", + "error": "Impossible de lire les fichiers de cette conversation.", + "workingNote": "Scripts, données et journaux créés en cours de route par l'assistant. Ils sont rangés dans le dossier de travail de cette discussion." + }, + "steps": { + "verb": { + "read": "Lu", + "list": "Listé", + "write": "Enregistré", + "create": "Créé", + "change": "Modifié", + "move": "Déplacé", + "delete": "Supprimé", + "search": "Recherché", + "run": "Exécuté", + "fetch": "Récupéré", + "websearch": "Recherché sur le web :", + "webfetch": "Lu", + "delegate": "A consulté un agent", + "used": "A utilisé" + }, + "worked": "A travaillé {{duration}}", + "thoughtFor": "A réfléchi {{duration}}", + "working": "En cours", + "thinking": "Réflexion", + "noMatches": "aucun résultat", + "noResults": "aucun résultat", + "emptyFolder": "dossier vide", + "size": "{{size}}", + "seconds": "{{value}} s", + "minutes": "{{min}} min {{sec}} s", + "tool": "Outil", + "input": "Entrée", + "showLess": "Afficher moins", + "running": "En cours", + "failed": "Échec", + "count_one": "{{count}} étape", + "count_other": "{{count}} étapes", + "files_one": "{{count}} fichier", + "files_other": "{{count}} fichiers", + "items_one": "{{count}} élément", + "items_other": "{{count}} éléments", + "matches_one": "{{count}} résultat", + "matches_other": "{{count}} résultats", + "results_one": "{{count}} résultat", + "results_other": "{{count}} résultats", + "lines_one": "{{count}} ligne", + "lines_other": "{{count}} lignes", + "showAll_one": "Afficher les {{count}} lignes", + "showAll_other": "Afficher les {{count}} lignes", + "details": "Afficher les détails", + "openFile": "Ouvrir {{path}} dans Fichiers", + "savedFiles": "Enregistré dans vos fichiers :", + "fileOp": { + "changed": "modifié", + "deleted": "supprimé" + }, + "moreFiles": "et {{count}} de plus", + "alsoSavedWorking_one": "A aussi enregistré {{count}} fichier de travail", + "alsoSavedWorking_other": "A aussi enregistré {{count}} fichiers de travail", + "savedWorking_one": "A enregistré {{count}} fichier de travail", + "savedWorking_other": "A enregistré {{count}} fichiers de travail", + "workingTitle": "Fichier de travail (un script, un journal ou un brouillon) : ouvrir {{path}}" + }, + "error": { + "title": "La réponse a échoué", + "kept": "Ce qui a été écrit jusqu'ici est conservé ci-dessus.", + "retry": "Réessayer", + "detail": "Détail technique", + "reason": { + "unknown": "L'agent s'est arrêté à cause d'une erreur. Réessayez pour relancer le tour.", + "model_backend_missing": "Le modèle « {{model}} » n'a pas pu démarrer : son backend n'est pas installé.", + "model_backend_missing_anon": "Le modèle n'a pas pu démarrer : son backend n'est pas installé.", + "model_cooldown": "Le modèle « {{model}} » a échoué au démarrage il y a un instant et se repose.", + "model_cooldown_anon": "Le modèle a échoué au démarrage il y a un instant et se repose.", + "model_load_failed": "Le modèle « {{model}} » n'a pas pu démarrer.", + "model_load_failed_anon": "Le modèle n'a pas pu démarrer.", + "model_not_found": "Le modèle « {{model}} » est introuvable.", + "model_not_found_anon": "Le modèle est introuvable.", + "upstream_key": "L'upstream a refusé la clé.", + "upstream_path": "L'upstream n'accepte pas cette requête à cette adresse.", + "upstream_unreachable": "L'adresse de l'upstream est injoignable.", + "upstream": "Le fournisseur derrière le modèle a renvoyé une erreur.", + "timeout": "Le modèle a mis trop de temps à répondre.", + "cancelled": "Le tour a été annulé avant la réponse du modèle." + }, + "hint": { + "model_backend_missing": "Choisissez un autre modèle ou demandez à un administrateur d'installer le backend.", + "model_backend_missing_admin": "Choisissez un autre modèle ou installez le backend depuis la page Backends.", + "model_cooldown": "Réessayez dans {{seconds}} secondes.", + "model_load_failed": "Vérifiez la configuration du modèle et son backend, puis réessayez.", + "model_not_found": "Choisissez un autre modèle ou installez celui-ci depuis la page Modèles.", + "upstream_key": "Vérifiez la clé d'API dans les réglages du modèle.", + "upstream_path": "L'adresse de l'upstream doit être le point d'accès complet, par exemple .../v1/chat/completions.", + "upstream_unreachable": "Vérifiez l'adresse dans les réglages du modèle.", + "upstream": "Réessayez dans un instant. Si l'échec persiste, vérifiez l'adresse et la clé du fournisseur.", + "timeout": "Réessayez, ou utilisez un modèle plus petit ou plus rapide.", + "cancelled": "Réessayez quand vous voulez." + }, + "openBackends": "Ouvrir Backends" + }, + "allChats": { + "title": "Toutes les conversations", + "supporting": "Toutes vos conversations. Recherchez, renommez, exportez ou supprimez-les.", + "loadFailed": "Impossible de charger vos conversations.", + "emptyTitle": "Aucune conversation", + "emptyBody": "Une conversation commence sur l'accueil : écrivez un message et envoyez-le. Vos conversations s'affichent ensuite ici.", + "start": "Commencer une conversation" + }, + "foot": { + "state": { + "ready": "Prêt", + "nomodel": "Aucun modèle", + "sending": "Envoi", + "loading": "Chargement du modèle", + "thinking": "Réflexion", + "tool": "Exécution de {{tool}}", + "answering": "Réponse en cours", + "waiting": "En attente de vous", + "stopped": "Arrêté", + "failed": "Échec", + "interrupted": "Interrompu", + "paused": "En pause", + "compacting": "Compactage du contexte", + "clearing": "Vidage du contexte" + }, + "tool": { + "search": "Recherche", + "list": "Liste", + "read": "Lecture", + "create": "Création", + "write": "Écriture", + "change": "Modification", + "move": "Déplacement", + "delete": "Suppression", + "run": "Exécution d’une commande", + "fetch": "Récupération", + "delegate": "Appel d’un sous-agent" + }, + "hint": "Entrée pour envoyer · Maj+Entrée pour une nouvelle ligne", + "meter": { + "label": "Contexte rempli à {{pct}} %. Afficher le contexte et la vitesse", + "labelNoLimit": "Contexte de {{used}} jetons. Afficher le contexte et la vitesse" + }, + "details": { + "title": "Contexte et vitesse", + "context": "Contexte", + "contextOf": "{{used}} sur {{limit}} jetons ({{pct}} %)", + "contextUsed": "{{used}} jetons", + "contextLeft": "{{left}} jetons restants", + "contextHelp": "Le contexte est ce que le modèle lit à chaque requête : les instructions, ce chat jusqu’ici et les résultats des outils.", + "nearFull": "Ce chat approche de la limite. Le serveur condense la partie la plus ancienne pour que le modèle puisse continuer. La transcription garde chaque message.", + "limitUnknown": "Le serveur n’a pas indiqué la taille de la fenêtre de contexte.", + "lastRequest": "Dernière requête", + "model": "Modèle", + "backend": "Backend", + "node": "Nœud", + "tokensIn": "Jetons en entrée", + "tokensOut": "Jetons en sortie", + "firstToken": "Délai du premier jeton", + "promptTime": "Traitement du prompt", + "speed": "Vitesse", + "speedLive": "Vitesse estimée pendant le flux", + "average": "Moyenne des {{count}} dernières requêtes", + "note": "Ces chiffres viennent des requêtes vues par cette page. Le serveur ne les garde pas : un rechargement les efface." + }, + "paused": { + "after": "après {{count}} étapes", + "atLimit": "à la limite d'étapes", + "continue": "Continuer", + "continueTitle": "Le modèle a utilisé toutes les étapes d'un tour. Laissez-le reprendre là où il s'est arrêté.", + "message": "Reprends là où tu t'es arrêté." + } + }, + "attachments": { + "label": "Fichiers joints", + "open": "Ouvrir {{name}}", + "showMore": "Afficher {{count}} de plus", + "missing": "Fichier plus disponible" + }, + "preview": { + "open": "Ouvrir l’aperçu", + "created": "Créé par l’assistant", + "changed": "Modifié par l’assistant", + "yours": "Fourni par vous", + "fromReply": "Tiré de la réponse", + "download": "Télécharger", + "downloadCode": "Télécharger le code", + "copy": "Copier", + "copied": "Copié", + "openInFiles": "Ouvrir dans Fichiers", + "actions": "Actions sur le fichier", + "opened": "Aperçu ouvert : {{name}}", + "chip": { + "created": "Créé", + "changed": "Modifié", + "label": "{{verb}} {{name}}, ouvrir l’aperçu", + "row": "Fichiers de cette réponse" + } + }, + "meter": { + "usedOf": "{{used}} / {{limit}} jetons ({{pct}} %)", + "used": "{{used}} jetons", + "speed": "{{speed}} jetons/s", + "title": "Contexte utilisé par la dernière requête au modèle", + "ofLimit": "{{used}} / {{limit}} jetons", + "left": "{{left}} restants" + }, + "goal": { + "label": "Objectif", + "states": { + "active": "En cours", + "paused": "En pause", + "done": "Atteint", + "blocked": "A besoin de vous" + }, + "pausedBy": { + "user": "Vous l'avez mis en pause.", + "turn_limit": "Il a utilisé tous ses tours automatiques. Reprenez pour lui en donner d'autres.", + "step_limit": "Il a utilisé toutes ses étapes. Reprenez pour lui en donner d'autres.", + "error": "Un tour a échoué. Reprenez pour réessayer.", + "interrupted": "Le serveur s'est arrêté pendant un tour. Reprenez pour continuer." + }, + "blockedNote": "Ce qu'il attend de vous : {{note}}", + "doneNote": "{{note}}", + "replaced": "Remplacé : {{text}}", + "pause": "Pause", + "pauseTitle": "Arrêter le tour en cours et mettre l'objectif en pause", + "resume": "Reprendre", + "resumeTitle": "Continuer vers l'objectif", + "clear": "Retirer", + "clearTitle": "Retirer l'objectif", + "progress": "Objectif · tour {{turn}} sur {{max}}", + "progressStart": "Objectif · démarrage", + "markers": { + "set": "Objectif fixé", + "replaced": "Objectif remplacé", + "continue": "Poursuite de l'objectif · tour {{turn}}", + "continueStart": "Poursuite de l'objectif", + "paused": "Objectif en pause", + "resumed": "Objectif repris", + "done": "Objectif atteint", + "blocked": "Objectif bloqué", + "cleared": "Objectif retiré" + }, + "announce": { + "set": "Objectif fixé : {{text}}", + "paused": "Objectif en pause. {{why}}", + "resumed": "Objectif repris", + "done": "Objectif atteint : {{note}}", + "blocked": "L'objectif a besoin de vous : {{note}}", + "cleared": "Objectif retiré" + }, + "card": { + "title": "Objectif", + "none": "Ce chat n'a pas d'objectif. Tapez /goal suivi de ce que vous voulez obtenir, et le chat continue d'y travailler tout seul.", + "turns": "Tour {{turn}} sur {{max}}", + "steps": "{{steps}} étapes sur {{max}}", + "clear": "/goal clear le retire.", + "resume": "/goal resume le reprend.", + "turnsLabel": "Tours automatiques", + "stepsLabel": "Étapes", + "setLabel": "Fixé", + "stateLabel": "État", + "noteLabel": "Note" + }, + "errors": { + "empty": "Écrivez l'objectif après /goal.", + "notAllowed": "Seul un chat que vous avez lancé peut avoir un objectif." + } + }, + "invoked": { + "skill": "Skill : {{name}}", + "agent": "Agent : {{name}}" + }, + "marker": { + "compacted": "Contexte compacté : de {{before}} à {{after}} tokens", + "cleared": "Contexte vidé", + "clearedHint": "Le modèle ne voit pas les messages au-dessus de cette ligne." + }, + "cmdcard": { + "close": "Fermer", + "helpTitle": "Commandes", + "helpSkills_one": "Skill : {{names}}. Tapez / et son nom.", + "helpSkills_other": "{{count}} skills : {{names}}. Tapez / et le nom.", + "helpKeys": "Tapez / dans la zone pour ouvrir la liste. Tab complète, Entrée lance, Échap ferme.", + "aboutTitle": "Cette conversation", + "model": "Modèle", + "state": "État", + "loaded": "Chargé", + "notLoaded": "Non chargé", + "backend": "Backend", + "tools": "Outils", + "toolsOn_one": "{{count}} actif", + "toolsOn_other": "{{count}} actifs", + "skills": "Skills", + "skillsOn_one": "{{count}} skill", + "skillsOn_other": "{{count}} skills", + "commands": "Commandes", + "isolated": "Exécutées dans un bac à sable isolé", + "host": "Exécutées sur cette machine", + "noCommands": "Impossible", + "context": "Contexte", + "contextOf": "{{used}} sur {{limit}} tokens", + "contextUsed": "{{used}} tokens", + "unknown": "Pas encore connu" } } diff --git a/core/http/react-ui/public/locales/fr/chats.json b/core/http/react-ui/public/locales/fr/chats.json new file mode 100644 index 000000000..c7950bf7a --- /dev/null +++ b/core/http/react-ui/public/locales/fr/chats.json @@ -0,0 +1,581 @@ +{ + "proposal": { + "loading": "Lecture du déclencheur proposé...", + "unread": "Impossible de lire le déclencheur proposé : {{message}}", + "removed": "Ce déclencheur a été supprimé.", + "retry": "Réessayer", + "inactive": "Déclencheur proposé. Il n'exécute rien tant que vous ne l'avez pas créé.", + "name": "Nom", + "schedule": "Quand (par exemple daily 08:00)", + "folder": "Dossier à surveiller", + "saveEdit": "Enregistrer", + "cancelEdit": "Annuler", + "create": "Créer le déclencheur", + "edit": "Modifier", + "dismiss": "Refuser", + "receipt": { + "scheduled": "Planifié :", + "watching": "Surveille :", + "next": "Prochaine exécution {{when}}", + "view": "Voir le déclencheur", + "undo": "Annuler", + "undone": "Annulé :", + "again": "Le planifier de nouveau", + "declined": "Refusé :", + "restore": "Restaurer", + "blocked": { + "ran": "Il s'est déjà exécuté une fois ; supprimez-le depuis la page Déclencheurs.", + "edited": "Vous l'avez modifié depuis ; modifiez-le ou supprimez-le depuis la page Déclencheurs." + } + }, + "announce": { + "accepted": "{{name}} planifié.", + "undone": "{{name}} annulé. Il n'est pas planifié.", + "declined": "{{name}} refusé.", + "reopened": "{{name}} attend de nouveau votre réponse." + }, + "plan": { + "whenLabel": "Quand", + "runsAsLabel": "S’exécute en tant que", + "readsLabel": "Lit", + "writesLabel": "Écrit", + "costLabel": "Coût", + "stopLabel": "Pour l’arrêter", + "when": "{{schedule}}, {{zone}}. Première exécution {{when}}.", + "whenNoFirst": "{{schedule}}, {{zone}}.", + "whenFile": "Quand un fichier arrive dans {{folder}}.", + "runsAs": "Vous", + "runsAsModel": "Vous, avec {{model}}", + "writesWidget": ", affiché dans le widget « {{widget}} » de l’accueil", + "costLocal_one": "Environ {{tokens}} jetons par exécution sur un modèle local. Au plus {{count}} exécution par jour.", + "costLocal_other": "Environ {{tokens}} jetons par exécution sur un modèle local. Au plus {{count}} exécutions par jour.", + "costRemote_one": "Environ {{tokens}} jetons par exécution sur {{model}}, un service distant. Au plus {{count}} exécution par jour.", + "costRemote_other": "Environ {{tokens}} jetons par exécution sur {{model}}, un service distant. Au plus {{count}} exécutions par jour.", + "costUnknown_one": "Les jetons d’une exécution sont connus après la première. Au plus {{count}} exécution par jour.", + "costUnknown_other": "Les jetons d’une exécution sont connus après la première. Au plus {{count}} exécutions par jour.", + "remoteNote": "{{model}} est un service distant : ce que lit une exécution quitte ce serveur.", + "stopWidget": "Mettez-le en pause depuis le widget, ou supprimez-le dans Déclencheurs", + "stop": "Désactivez-le ou supprimez-le dans Déclencheurs" + } + }, + "mcpPage": { + "title": "Connecteurs MCP", + "supporting": "Connectez des serveurs MCP pour donner plus d'outils à vos chats et à vos agents, comme la recherche ou vos propres API.", + "family": "Les bots pour Telegram ou Slack sont des connecteurs de messagerie.", + "related": "Voir aussi", + "chats": "Chats", + "add": "Ajouter un connecteur", + "learn": "Comment fonctionne MCP", + "loading": "Chargement de vos connecteurs", + "loadFailed": "Impossible de charger vos connecteurs.", + "forbidden": "MCP n'est pas disponible pour votre compte.", + "empty": { + "title": "Aucun connecteur pour l'instant", + "body": "Un connecteur relie un serveur MCP à LocalAI. Vos chats et vos agents peuvent alors utiliser ses outils, par exemple pour chercher dans votre documentation ou lire un agenda.", + "fact1": "Tout serveur MCP en http ou https", + "fact2": "Jetons et en-têtes enregistrés chiffrés", + "fact3": "Testez-le avant que les chats l'utilisent" + }, + "toolbar": { + "search": "Rechercher des connecteurs", + "testAll": "Tout tester", + "testingAll": "Test en cours", + "testedAll": "Les connecteurs activés ont été testés." + }, + "filter": { + "label": "Afficher", + "all": "Tous", + "on": "Activés", + "attention": "À vérifier", + "off": "Désactivés", + "showAll": "Tout afficher", + "none": { + "all": "Aucun connecteur.", + "on": "Aucun connecteur n'est activé.", + "attention": "Rien à vérifier.", + "off": "Tous les connecteurs sont activés." + } + }, + "noMatch": { + "title": "Aucun connecteur ne correspond à « {{query}} »", + "body": "Cherchez par nom ou par adresse.", + "clear": "Effacer la recherche" + }, + "state": { + "label": "État : {{state}}", + "ok": "Connecté", + "auth": "Connexion requise", + "error": "Injoignable", + "unchecked": "Non testé", + "off": "Désactivé" + }, + "row": { + "open": "{{name}}, {{state}}. Afficher les détails", + "toggle": "Utiliser {{name}} dans les chats", + "menu": "Actions pour {{name}}", + "offLine": "Désactivé. Les chats et les agents ne l'utilisent pas.", + "notChecked": "Pas encore testé. Testez-le pour voir ses outils.", + "noTools": "Connecté, mais le serveur ne propose aucun outil.", + "tools_one": "{{count}} outil", + "tools_other": "{{count}} outils", + "checked": "Testé {{when}}", + "tokenSaved": "Jeton enregistré", + "headersSaved": "En-têtes enregistrés", + "envSaved": "Variables enregistrées" + }, + "actions": { + "details": "Détails", + "test": "Tester la connexion", + "testNamed": "Tester la connexion à {{name}}", + "testing": "Test en cours", + "edit": "Modifier", + "reauth": "Remplacer le jeton", + "remove": "Supprimer" + }, + "details": { + "use": "Utiliser dans les chats", + "useHelp": "Les chats sans agent utilisent tous les connecteurs activés. Un agent enregistré n'utilise que ceux que vous choisissez pour lui.", + "lastCheck": "Dernier test", + "never": "Pas encore testé dans ce navigateur.", + "okAt": "Connecté {{when}}.", + "testedAt": "Testé {{when}}.", + "errorTitle": "Le dernier test n'a pas pu se connecter.", + "authTitle": "Le serveur a refusé la connexion.", + "tools": "Outils", + "toolsUnknown": "Testez la connexion pour voir les outils.", + "toolsNone": "Le serveur ne propose aucun outil.", + "toolsSearch": "Rechercher des outils", + "noToolMatch": "Aucun outil ne correspond à « {{query}} ».", + "noDescription": "Pas de description.", + "connection": "Connexion", + "kind": "Type", + "address": "Adresse", + "command": "Commande", + "added": "Ajouté", + "queryHidden": "La partie de l'adresse après ? est masquée, car elle peut contenir une clé.", + "signIn": "Connexion", + "token": "Jeton bearer", + "headers": "En-têtes", + "env": "Variables", + "saved": "Enregistré", + "none": "Aucun", + "encrypted": "Enregistré chiffré sur le serveur. Jamais réaffiché." + }, + "sheet": { + "addTitle": "Ajouter un connecteur", + "editTitle": "Modifier {{name}}", + "addDesc": "Connectez un serveur MCP. Vous le testez avant que les chats l'utilisent.", + "editDesc": "Les secrets enregistrés restent, sauf si vous les remplacez ou les supprimez.", + "testDesc": "LocalAI se connecte avec ce que vous avez enregistré et liste les outils.", + "close": "Fermer", + "kind": "Type", + "remote": "Serveur distant", + "remoteHelp": "Un serveur MCP que cette machine joint en http ou https.", + "local": "Commande locale", + "localHelp": "Un programme que LocalAI lance sur cette machine. Administrateurs uniquement.", + "basics": "Connexion", + "name": "Nom", + "nameHelp": "Affiché dans le menu Outils d'un chat.", + "url": "Adresse", + "urlHelp": "Les serveurs d'un réseau privé sont refusés, sauf si un administrateur les autorise.", + "command": "Commande", + "commandHelp": "Le programme à lancer, sans shell.", + "args": "Arguments", + "argsHelp": "Un argument par ligne.", + "signIn": "Connexion", + "signInHelp": "Seulement si le serveur demande une clé.", + "envIntro": "Les variables que reçoit la commande, par exemple une clé d'API.", + "token": "Jeton bearer", + "tokenHelp": "Envoyé dans un en-tête Authorization.", + "tokenSaved": "Un jeton est enregistré. Saisissez-en un nouveau pour le remplacer.", + "tokenSavedShort": "Enregistré", + "clearToken": "Supprimer le jeton enregistré", + "headers": "En-têtes", + "headersHelp": "D'autres en-têtes dont le serveur a besoin, comme un en-tête de clé d'API.", + "headersSaved": "Des en-têtes sont enregistrés. Les nouvelles lignes les remplacent tous.", + "clearHeaders": "Supprimer les en-têtes enregistrés", + "headerName": "Nom de l'en-tête", + "headerValue": "Valeur", + "addHeader": "Ajouter un en-tête", + "env": "Variables d'environnement", + "envSaved": "Des variables sont enregistrées. Les nouvelles lignes les remplacent toutes.", + "clearEnv": "Supprimer les variables enregistrées", + "envName": "Variable", + "addEnv": "Ajouter une variable", + "removeRow": "Supprimer la ligne {{n}}", + "showValue": "Afficher la valeur", + "hideValue": "Masquer la valeur", + "encrypted": "Les jetons, les en-têtes et les variables sont enregistrés chiffrés sur le serveur et ne sont jamais réaffichés.", + "cancel": "Annuler", + "saving": "Enregistrement", + "saveAndTest": "Enregistrer et tester", + "savedOn": "Enregistré. Il est activé.", + "savedOff": "Enregistré. Il reste désactivé jusqu'à ce que vous l'activiez.", + "testing": "Connexion à {{name}}…", + "okTitle": "Connecté.", + "okBody_one": "{{name}} propose {{count}} outil.", + "okBody_other": "{{name}} propose {{count}} outils.", + "okNoTools": "Le serveur ne propose aucun outil.", + "failTitle": "Connexion impossible.", + "failHint": "Vérifiez l'adresse, puis testez à nouveau.", + "failAuth": "Vérifiez le jeton ou les en-têtes.", + "back": "Modifier les informations", + "retry": "Tester à nouveau", + "done": "Terminé", + "keepOff": "Le laisser désactivé", + "turnOn": "Activer", + "discardTitle": "Abandonner vos modifications ?", + "discardBody": "Ce que vous avez saisi dans ce formulaire sera perdu.", + "discard": "Abandonner", + "keepEditing": "Continuer à modifier" + }, + "errors": { + "nameRequired": "Donnez-lui un nom.", + "nameTaken": "Un autre connecteur porte ce nom.", + "urlRequired": "Saisissez l'adresse du serveur.", + "urlInvalid": "Cette adresse n'est pas valide.", + "urlScheme": "L'adresse doit commencer par http:// ou https://.", + "urlCredentials": "Mettez les identifiants dans le jeton ou un en-tête, pas dans l'adresse.", + "commandRequired": "Saisissez la commande à lancer." + }, + "remove": { + "title": "Supprimer {{name}} ?", + "body": "Le connecteur est supprimé avec son jeton, ses en-têtes et ses variables enregistrés. Un agent enregistré qui l'utilise ne peut plus appeler ses outils.", + "confirm": "Supprimer", + "pending": "Suppression" + }, + "toasts": { + "removed": "« {{name}} » supprimé.", + "on": "« {{name}} » est activé.", + "off": "« {{name}} » est désactivé." + }, + "import": { + "title": "Serveurs enregistrés dans ce navigateur", + "text": "L'ancienne page des chats enregistrait ces serveurs MCP dans ce navigateur. Importez-les pour les garder sur le serveur, où chaque navigateur et chaque session peut les utiliser. Ils sont créés désactivés. Leurs jetons et en-têtes partent vers le serveur, puis sont supprimés de ce navigateur.", + "button": "Importer {{count}} serveurs de ce navigateur", + "buttonOne": "Importer 1 serveur de ce navigateur", + "busy": "Importation", + "headersCount": "{{count}} en-têtes", + "done": "{{count}} importés. Ils sont désactivés : testez-les et activez-les ci-dessous.", + "failed": "{{name}} n'a pas été importé : {{error}}", + "left": "Les serveurs non importés restent dans ce navigateur." + } + }, + "connectorsPage": { + "loadFailed": "Impossible de charger vos connecteurs.", + "waitingTitle": "En attente de leur module", + "waitingText": "Ces liaisons sont conservées, mais elles ne fonctionnent qu'une fois leur module installé et activé." + }, + "tools": { + "commands": { + "title": "Les commandes ne peuvent pas s'exécuter ici", + "none": "Aucun module qui exécute du code n'est installé : l'assistant peut écrire des fichiers, mais pas exécuter de scripts ni de commandes.", + "off": "Le module Sandbox est désactivé pour ce chat. Activez-le dans le menu Outils pour que l'assistant puisse exécuter du code.", + "unavailable": "Le module Sandbox est installé, mais il ne peut pas exécuter de commandes sur ce serveur.", + "unavailableWhy": "Le module Sandbox ne peut pas exécuter de commandes sur ce serveur : {{reason}}", + "install": "Installer Sandbox", + "openSandbox": "Ouvrir le module Sandbox", + "askInstall": "Demandez à un administrateur d'installer le module Sandbox.", + "askFix": "Demandez à un administrateur de corriger le problème.", + "hide": "Masquer", + "hideLabel": "Masquer cet avis", + "chipTitle": "Ce chat ne peut pas exécuter de code ni de commandes. Sélectionnez pour les détails.", + "hostTitle": "Les commandes s'exécutent sur cette machine", + "host": "Les commandes s'exécutent sur cette machine, avec ses fichiers et son réseau, sans isolation. Installez le Sandbox pour les isoler.", + "hostOff": "Le Sandbox est désactivé pour ce chat : les commandes s'exécutent donc sur cette machine, avec ses fichiers et son réseau, sans isolation. Activez le Sandbox dans ce menu pour les isoler.", + "hostUnavailable": "Le Sandbox ne peut pas s'exécuter ici : les commandes s'exécutent donc sur cette machine, avec ses fichiers et son réseau, sans isolation.", + "hostUnavailableWhy": "Le Sandbox ne peut pas s'exécuter ici ({{reason}}) : les commandes s'exécutent donc sur cette machine, avec ses fichiers et son réseau, sans isolation.", + "hostChipTitle": "Les commandes de ce chat s'exécutent sur cette machine, sans isolation. Sélectionnez pour les détails." + }, + "consent": { + "title": "Exécuter cette commande sur cette machine ?", + "body": "Les commandes s'exécutent sur cette machine sans isolation. Elles peuvent lire les fichiers que LocalAI peut lire et joindre Internet et votre réseau. Installez le Sandbox pour les isoler.", + "once": "Exécuter une fois", + "always": "Toujours sur cette instance", + "deny": "Ne pas exécuter", + "alwaysHint": "« Toujours sur cette instance » laisse les chats de tous les utilisateurs exécuter des commandes ici sans demander (Paramètres > Outils).", + "askAdmin": "Seul un administrateur peut autoriser des commandes sur cette machine. Demandez à l'un d'eux, ou exécutez la commande vous-même.", + "expired": "Sans réponse : le tour qui l'a demandé est terminé, la commande n'a donc pas été exécutée." + }, + "provider": { + "sandbox": "Sandbox", + "addon": "Module {{name}}", + "host": "Cette machine, non isolée", + "hostOnce": "Cette machine, autorisée une fois", + "hostDenied": "Non exécutée : refusée", + "hostTimeout": "Non exécutée : pas de réponse", + "hostAdmin": "Non exécutée : nécessite un administrateur" + }, + "builtins": "Outils intégrés", + "builtin": { + "web": "Web", + "saved_agents": "Agents enregistrés", + "triggers": "Déclencheurs", + "shell": "Exécuter des commandes", + "files": "Vos fichiers", + "media": "Images et audio", + "collections": "Collections", + "subagents": "Sous-agents" + }, + "builtinBy": "{{tools}} · intégrés", + "builtinByAddon": "{{tools}} · fournis par {{provider}}", + "servedBy": "Fourni par l'extension {{provider}}", + "servedByTool": "{{tool}}: {{provider}}", + "servedByBuiltin": "intégré", + "sandbox": { + "starting": "Le Sandbox démarre. LocalAI vérifie à nouveau dans {{seconds}} s." + }, + "fallbackBuiltin": "{{addon}} remplace {{tool}} mais ne peut pas servir cette discussion pour l'instant (arrêté, désactivé ici ou non accordé) : {{tool}} intégré est utilisé.", + "fallbackNone": "{{addon}} remplace {{tool}} mais ne peut pas servir cette discussion pour l'instant, et {{tool}} intégré est désactivé : cette discussion n'a pas de {{tool}}.", + "shell": { + "machine": "Cette machine", + "isolated": "Les commandes s’exécutent isolées" + } + }, + "vision": { + "describedBy": "Ce modèle ne peut pas regarder les images. Choisissez un autre modèle, sinon l'image sera décrite par {{model}}.", + "none": "Ce modèle ne peut pas regarder les images, et aucun modèle ici ne peut les décrire. Choisissez un modèle capable de regarder les images." + }, + "egress": { + "label": "Réseau", + "preset": { + "off": "Désactivé", + "registries": "Registres de paquets", + "all": "Tous les sites publics" + }, + "presetHint": { + "off": "Les commandes du Sandbox n’atteignent aucun réseau.", + "registries": "Les commandes n’atteignent que les registres de paquets (PyPI, npm, Go, crates.io, RubyGems, Maven Central, versions GitHub).", + "all": "Les commandes atteignent tous les sites publics." + }, + "limited": "Limité par votre administrateur", + "limitedBy": "Votre administrateur autorise au plus : {{limit}}.", + "hostNetwork": "Les commandes s’exécutent sur cette machine et utilisent son réseau. Les limites réseau nécessitent le module Sandbox.", + "panel": "Règles et demandes ({{count}})", + "rules": "Règles de ce chat", + "cards": "Demandes de ce chat", + "ruleAllow": "Autorisé pour ce chat", + "ruleDeny": "Refusé pour ce chat", + "aboveLimit": "au-dessus de la limite, ne s’applique pas", + "remove": "Retirer", + "removeLabel": "Retirer la règle de {{host}}", + "card": { + "pending": "En attente d’une réponse", + "allowedOnce": "Autorisé une fois", + "allowedChat": "Autorisé pour ce chat", + "denied": "Refusé", + "expired": "Sans réponse", + "refused": "Refusé : la limite réseau du serveur ne l’autorise pas" + }, + "consent": { + "title": "Accès au réseau", + "body": "Une commande de ce chat veut atteindre", + "once": "Autoriser une fois", + "chat": "Autoriser pour ce chat", + "deny": "Refuser", + "preset": "Ce chat : {{preset}}. Limite fixée par votre administrateur : {{limit}}.", + "adminOnly": "Cet hôte est dans votre réseau : seul un administrateur peut l’autoriser.", + "answered": "{{state}} par {{who}}, {{time}}", + "answeredNoWho": "{{state}}, {{time}}", + "you": "vous" + }, + "blocked": { + "titleOff": "L’accès au réseau est désactivé", + "titleCapped": "L’accès au réseau est limité", + "bodyOff": "L’accès au réseau du Sandbox est désactivé sur ce serveur. Une commande a demandé à joindre", + "bodyCapped": "Ce serveur n’autorise que {{limit}} pour le réseau du Sandbox. Une commande a demandé à joindre", + "adminOff": "Un administrateur peut l’activer dans les paramètres de {{name}}.", + "adminCapped": "Un administrateur peut relever la limite dans les paramètres de {{name}}.", + "adminLink": "Ouvrir les paramètres de {{name}}", + "askAdmin": "Demandez à un administrateur de ce serveur de le modifier.", + "rowOff": "Désactivé : l’administrateur n’a pas autorisé l’accès au réseau.", + "runner": "l’extension qui exécute les commandes" + }, + "allowedHosts": "Toujours autorisés par votre administrateur :", + "allowedHostsOff": "Ils s’appliquent quand le réseau est activé.", + "allowedHostsNote": "{{count}} hôtes sont toujours autorisés par votre administrateur.", + "limitNote": "Réseau de cette conversation. L’administrateur fixe le maximum qu’une conversation peut atteindre.", + "asksAbove": "Cette conversation demande {{choice}} ; la limite de l’administrateur est {{limit}}." + }, + "inbox": { + "unread": "{{count}} non lues", + "markAll": "Tout marquer comme lu", + "delete": "Supprimer", + "pushOn": "Activer les notifications du navigateur", + "pushOff": "Désactiver les notifications du navigateur", + "pushDenied": "Les notifications du navigateur sont bloquées pour ce site.", + "pushUnavailable": "Ce serveur ne peut pas conserver d'abonnements push.", + "clearAll": "Tout effacer", + "clearRead": "Effacer les lues", + "cancel": "Annuler", + "markRead": "Marquer comme lue", + "cleared_one": "{{count}} notification supprimée.", + "cleared_other": "{{count}} notifications supprimées.", + "confirmAll_one": "Supprimer {{count}} notification ? Cette action est irréversible.", + "confirmAll_other": "Supprimer les {{count}} notifications ? Cette action est irréversible.", + "confirmRead_one": "Supprimer {{count}} notification lue ? Les non lues restent.", + "confirmRead_other": "Supprimer {{count}} notifications lues ? Les non lues restent.", + "onlyRead": "Seulement les {{count}} lues", + "showing": "{{shown}} sur {{total}} affichées", + "showMore": "Afficher plus", + "title": "Notifications", + "buttonUnread_one": "Notifications, {{count}} non lue", + "buttonUnread_other": "Notifications, {{count}} non lues", + "filterLabel": "Afficher les notifications", + "filterAll": "Toutes", + "filterUnread": "Non lues", + "filterFailed": "En échec", + "more": "Plus d'actions", + "close": "Fermer", + "day": { + "today": "Aujourd'hui", + "yesterday": "Hier", + "earlier": "Plus tôt" + }, + "emptyTitle": "Aucune notification pour l'instant", + "emptyBody": "Les résultats de vos déclencheurs et connecteurs apparaissent ici, y compris le travail terminé pendant votre absence.", + "caughtUp": "Vous êtes à jour.", + "caughtUpTitle": "Vous êtes à jour", + "caughtUpBody": "Aucune notification non lue.", + "noFailedTitle": "Aucun échec", + "noFailedBody": "Les exécutions et les connecteurs en échec apparaissent ici.", + "loadError": "Impossible de charger vos notifications.", + "source": { + "trigger": "Déclencheur", + "connector": "Connecteur", + "system": "LocalAI" + }, + "itemUnread": "{{title}}, non lue", + "arrived": "Nouvelle notification : {{title}}", + "arrivedMany_one": "{{count}} nouvelle notification", + "arrivedMany_other": "{{count}} nouvelles notifications" + }, + "change": { + "loading": "Lecture de la modification du déclencheur...", + "unread": "Impossible de lire la modification du déclencheur : {{message}}", + "removed": "Cette modification n'est plus conservée.", + "none": "aucun", + "on": "Activé", + "off": "Désactivé (en pause)", + "becomes": "devient", + "neverRan": "Pas encore exécuté", + "lastRun": "Dernière exécution", + "lastRunValue": "{{status}}, {{when}}", + "runStatus": { + "queued": "En attente", + "running": "En cours", + "done": "Terminée", + "failed": "Échouée", + "skipped": "Ignorée" + }, + "updateTitle": "Modifier {{name}}", + "deleteTitle": "Supprimer {{name}} ?", + "updateHelp": "Modification proposée. Rien ne change tant que vous ne l'appliquez pas.", + "deleteHelp": "Rien n'est supprimé tant que vous n'appuyez pas sur Supprimer. Vous pouvez annuler pendant un jour.", + "stale": "Le déclencheur a été modifié ou supprimé après la proposition : la modification ne peut plus s'appliquer. Redemandez la modification.", + "apply": "Appliquer", + "delete": "Supprimer", + "keep": "Conserver", + "field": { + "name": "Nom", + "prompt": "Prompt", + "when": "Quand", + "time_zone": "Fuseau horaire", + "daily_cap": "Limite quotidienne", + "enabled": "État", + "reads": "Lit", + "writes": "Écrit", + "widget": "Widget d'accueil", + "estimated_tokens": "Jetons par exécution", + "agent": "Agent", + "model": "Modèle" + }, + "receipt": { + "update": "Modifié :", + "pause": "En pause :", + "resume": "Repris :", + "delete": "Supprimé :", + "fields": "Ce qui a changé : {{fields}}", + "dismissed": "Modification ignorée :", + "kept": "Conservé :", + "undone": "Annulé :", + "restored": "Restauré :" + }, + "blocked": { + "expired": "Le délai pour annuler est passé ; modifiez le déclencheur sur la page Déclencheurs.", + "edited": "Le déclencheur a été modifié depuis ; modifiez-le sur la page Déclencheurs.", + "removed": "Le déclencheur a été supprimé depuis.", + "full": "Vous avez le nombre maximal de déclencheurs ; supprimez-en un sur la page Déclencheurs d'abord." + }, + "announce": { + "applied": "{{name}} modifié.", + "deleted": "{{name}} supprimé.", + "dismissed": "Modification de {{name}} ignorée.", + "kept": "{{name}} conservé.", + "undone": "Modification de {{name}} annulée.", + "restored": "{{name}} restauré." + } + }, + "loops": { + "region": "Boucles de ce chat", + "unit": { + "seconds_one": "{{count}} seconde", + "seconds_other": "{{count}} secondes", + "minutes_one": "{{count}} minute", + "minutes_other": "{{count}} minutes", + "hours_one": "{{count}} heure", + "hours_other": "{{count}} heures" + }, + "unitShort": { + "seconds_one": "{{count}} s", + "seconds_other": "{{count}} s", + "minutes_one": "{{count}} min", + "minutes_other": "{{count}} min", + "hours_one": "{{count}} h", + "hours_other": "{{count}} h" + }, + "confirm": { + "every": "Exécuter ceci toutes les {{every}} jusqu'à ce que vous l'arrêtiez ?", + "selfPaced": "Exécuter ceci à nouveau chaque fois que le modèle le demande, jusqu'à ce que vous l'arrêtiez ?", + "start": "Démarrer", + "cancel": "Annuler" + }, + "chip": { + "name": "Boucle", + "every": "toutes les {{every}}", + "selfPaced": "au rythme du modèle", + "run_one": "exécution {{count}}", + "run_other": "exécution {{count}}", + "paused": "en pause", + "running": "en cours", + "next": "prochaine à {{time}}", + "ended": "terminée", + "wait": "En attente de : {{wait}}" + }, + "actions": { + "pause": "Pause", + "resume": "Reprendre", + "stop": "Arrêter", + "dismiss": "Masquer" + }, + "state": { + "pending": "attend votre accord", + "active": "active", + "paused": "en pause", + "ended": "terminée" + }, + "list": { + "title": "Boucles de ce chat", + "close": "Fermer", + "empty": "Ce chat n'a aucune boucle.", + "hint": "Pour en arrêter une, tapez /loop stop et le code au début de sa ligne." + }, + "slash": { + "usage": "Tapez /loop, un intervalle si vous en voulez un, et ce qu'il faut répéter. Par exemple : /loop 5m vérifie le build.", + "notFound": "Aucune boucle de ce chat ne correspond à {{id}}.", + "noChat": "Envoyez un premier message, puis démarrez une boucle dans ce chat.", + "stopped_one": "{{count}} boucle arrêtée.", + "stopped_other": "{{count}} boucles arrêtées." + } + } +} diff --git a/core/http/react-ui/public/locales/fr/collections.json b/core/http/react-ui/public/locales/fr/collections.json index e94dcf782..fcc1e4497 100644 --- a/core/http/react-ui/public/locales/fr/collections.json +++ b/core/http/react-ui/public/locales/fr/collections.json @@ -4,13 +4,13 @@ "newPlaceholder": "Nom de la nouvelle collection...", "actions": { "create": "Créer", - "creating": "Création...", "details": "Détails", "reset": "Réinitialiser", "delete": "Supprimer", "viewDetails": "Voir les détails", "resetCollection": "Réinitialiser la collection", - "deleteCollection": "Supprimer la collection" + "deleteCollection": "Supprimer la collection", + "cancel": "Annuler" }, "sections": { "yourCollections": "Vos collections", @@ -19,7 +19,8 @@ "empty": { "title": "Aucune collection pour l'instant", "text": "Les collections permettent d'organiser des documents en bases de connaissances que les agents peuvent interroger via RAG (génération augmentée par recherche). Créez une collection ci-dessus pour commencer.", - "noPersonal": "Vous n'avez encore aucune collection." + "noPersonal": "Vous n'avez encore aucune collection.", + "learn": "Fonctionnement de la mémoire" }, "deleteDialog": { "title": "Supprimer la collection", @@ -32,12 +33,29 @@ "confirm": "Réinitialiser" }, "toasts": { - "loadFailed": "Échec du chargement des collections : {{message}}", "created": "Collection « {{name}} » créée", "createFailed": "Échec de la création de la collection : {{message}}", "deleted": "Collection « {{name}} » supprimée", "deleteFailed": "Échec de la suppression de la collection : {{message}}", "reset": "Collection « {{name}} » réinitialisée", "resetFailed": "Échec de la réinitialisation de la collection : {{message}}" + }, + "loadFailed": "Impossible de charger vos collections.", + "sources": { + "loadFailed": "Impossible de charger les sources web." + }, + "entries": { + "loadFailed": "Impossible de charger les fichiers." + }, + "starting": { + "title": "La mémoire démarre.", + "text": "Le serveur prépare encore le service de mémoire. Cette page réessaie toute seule." + }, + "embedding": { + "missingTitle": "Aucun modèle d'embedding installé.", + "missingText": "Les collections utilisent {{model}} pour indexer les fichiers et y chercher. Tant qu'il n'est pas installé, l'ajout de fichiers et la recherche échouent.", + "noneSet": "Aucun modèle d'embedding n'est défini pour les collections (collections_embedding_model) : l'ajout de fichiers et la recherche échouent.", + "install": "Le trouver dans Modèles", + "askAdmin": "Demandez à un administrateur de l'installer." } } diff --git a/core/http/react-ui/public/locales/fr/common.json b/core/http/react-ui/public/locales/fr/common.json index 1074da5f8..9a8f26143 100644 --- a/core/http/react-ui/public/locales/fr/common.json +++ b/core/http/react-ui/public/locales/fr/common.json @@ -112,26 +112,22 @@ "gigabytes": "Go", "terabytes": "To" }, - "updateReview": { - "title": "Vérifier les mises à jour", - "description": "Rien n'est mis à jour tant que vous ne choisissez pas ici.", - "close": "Fermer", - "done": "Terminé", - "selectAll": "Tout sélectionner", - "select": "Sélectionner {{name}}", - "unknownVersion": "inconnue", - "latest": "dernière", - "blocked": "Mise à jour impossible ici :", - "upgradeSelected": "Mettre à jour la sélection ({{count}})", - "upgradeAll": "Tout mettre à jour ({{count}})", - "running": "Mise à jour...", - "queued": "En attente", - "statusRunning": "Mise à jour", - "statusDone": "Mis à jour", - "statusFailed": "Échec", - "summary": "{{succeeded}} mis à jour, {{failed}} en échec.", - "empty": "Rien à mettre à jour.", - "statusStarted": "Lancé", - "summaryStarted": "{{succeeded}} lancés, {{failed}} en échec." + "ui": { + "tryAgain": "Réessayer", + "undo": "Annuler", + "loadError": { + "title": "Le chargement a échoué.", + "serverSaid": "Le serveur a répondu « {{message}} ».", + "nothingChanged": "Rien n’a été modifié." + } + }, + "chip": { + "running": "En cours", + "starting": "Démarrage", + "stopped": "Arrêté", + "failed": "Échec", + "updateAvailable": "Mise à jour disponible", + "ready": "Prêt", + "noRunsYet": "Aucune exécution" } } diff --git a/core/http/react-ui/public/locales/fr/explorer.json b/core/http/react-ui/public/locales/fr/explorer.json new file mode 100644 index 000000000..a646ed096 --- /dev/null +++ b/core/http/react-ui/public/locales/fr/explorer.json @@ -0,0 +1,57 @@ +{ + "brand": "{{name}} Explorer", + "openApp": "Ouvrir LocalAI", + "close": "Fermer", + "copy": "Copier", + "copied": "Copié", + "loading": "Chargement des essaims", + "title": "Essaims publics", + "lede": "Un essaim est un groupe de machines qui partagent l'inférence de modèles sur un réseau pair à pair. Cette liste affiche les essaims dont les propriétaires ont choisi de les rendre publics. Toute personne possédant le jeton d'un essaim peut le rejoindre.", + "count_one": "{{count}} essaim en ligne", + "count_other": "{{count}} essaims en ligne", + "sorted": "Les plus fournis en workers d'abord", + "workers_one": "worker", + "workers_other": "workers", + "tokenTitle": "Le début et la fin du jeton. Copiez-le depuis Comment rejoindre.", + "warning": "L'Explorer est une liste communautaire de jetons d'essaims. N'importe qui peut utiliser un jeton pour envoyer du travail à un essaim ou partager des ressources avec lui. À utiliser à vos risques et périls.", + "listed": "L'essaim est répertorié.", + "empty": { + "title": "Aucun essaim n'a de workers en ligne", + "text": "Seuls les essaims avec au moins un worker en ligne sont répertoriés. Répertoriez le vôtre pour le rendre public." + }, + "off": { + "title": "Ce serveur n'est pas un explorer", + "text": "La page Explorer nécessite un serveur LocalAI démarré en mode explorer. Celui-ci n'a pas de liste d'essaims à afficher." + }, + "error": { + "title": "Impossible de charger les essaims", + "text": "Le serveur explorer n'a pas répondu. Vérifiez qu'il est en marche, puis réessayez.", + "retry": "Réessayer" + }, + "join": { + "open": "Comment rejoindre", + "title": "Rejoindre {{name}}", + "text": "Démarrez un worker LocalAI avec le jeton de cet essaim.", + "token": "Jeton", + "federated": "Cluster fédéré {{id}}. Démarrez un nœud dessus avec l'une des deux commandes.", + "docker": "Avec Docker", + "cli": "Avec la ligne de commande", + "noCommands": "Les workers de cet essaim partagent leur puissance de calcul via son jeton. Le jeton ci-dessus est ce dont un nœud a besoin pour rejoindre l'essaim.", + "docs": "Découvrir le fonctionnement des essaims" + }, + "list": { + "open": "Répertorier un essaim", + "title": "Répertorier un essaim", + "text": "Ajoutez un essaim à la liste publique. Il apparaît dès qu'il a un worker en ligne.", + "name": "Nom", + "description": "Description", + "token": "Jeton", + "tokenHint": "Le jeton réseau de l'essaim, tel que le serveur l'a affiché.", + "public": "La mise en liste rend le jeton public. Toute personne qui voit la liste peut utiliser les workers de votre essaim.", + "understood": "Je comprends que le jeton devient public.", + "cancel": "Annuler", + "add": "Répertorier cet essaim", + "adding": "Ajout en cours...", + "failed": "Impossible de répertorier l'essaim." + } +} diff --git a/core/http/react-ui/public/locales/fr/home.json b/core/http/react-ui/public/locales/fr/home.json index 5311ed126..0bd56ebc5 100644 --- a/core/http/react-ui/public/locales/fr/home.json +++ b/core/http/react-ui/public/locales/fr/home.json @@ -25,7 +25,10 @@ "title": "Gérez LocalAI par la conversation", "description": "Installez des modèles, changez de backend, modifiez la configuration et vérifiez l'état en discutant avec LocalAI.", "open": "Ouvrir l'assistant", - "tooltip": "Gérer LocalAI par la conversation" + "tooltip": "Gérer LocalAI par la conversation", + "noModel": "Choisissez d'abord un modèle de chat.", + "startFailed": "Impossible de démarrer la conversation de l'assistant : {{message}}", + "chatTitle": "Assistant LocalAI" }, "input": { "placeholder": "Message...", @@ -73,13 +76,17 @@ "step2Title": "Installer un modèle", "step2Body": "Cliquez sur installer pour le télécharger et le configurer automatiquement.", "step3Title": "Commencer à discuter", - "step3Body": "Discutez avec votre modèle depuis le navigateur ou via l'API." + "step3Body": "Discutez avec votre modèle depuis le navigateur ou via l'API.", + "sandboxTitle": "Exécuter du code en sécurité (facultatif)", + "sandboxBody": "Installez le module Sandbox pour que le modèle exécute code et commandes en isolation. Sans lui, les commandes s’exécutent directement sur cette machine, avec un accès réseau complet et aucune isolation." }, "browseGallery": "Parcourir la galerie", "importModel": "Importer un modèle", "docs": "Docs", "noModelsTitle": "Aucun modèle disponible", - "noModelsBody": "Aucun modèle n'est installé pour l'instant. Demandez à votre administrateur de configurer des modèles pour pouvoir discuter." + "noModelsBody": "Aucun modèle n'est installé pour l'instant. Demandez à votre administrateur de configurer des modèles pour pouvoir discuter.", + "sandboxInstall": "Installer Sandbox", + "sandboxSkip": "Ignorer" }, "starters": { "title": "Recommandé pour votre matériel", @@ -109,6 +116,22 @@ "dismiss": "Ignorer" }, "jump": { - "heading": "Reprendre où vous en étiez" + "heading": "Reprendre où vous en étiez", + "allChats": "Toutes les conversations" + }, + "slash": { + "agents": { + "label": "Agents", + "desc": "Vos agents enregistrés, déclencheurs et compétences" + }, + "mcp": { + "label": "Connecteurs MCP", + "desc": "Serveurs MCP que vos chats peuvent utiliser" + }, + "memory": { + "label": "Mémoire", + "desc": "Collections que vos chats peuvent consulter" + }, + "complete": "compléter" } } diff --git a/core/http/react-ui/public/locales/fr/library.json b/core/http/react-ui/public/locales/fr/library.json new file mode 100644 index 000000000..247968f06 --- /dev/null +++ b/core/http/react-ui/public/locales/fr/library.json @@ -0,0 +1,114 @@ +{ + "usedBy": { + "label": "Utilisé par", + "none": "Pas encore utilisé", + "line": "Utilisé par {{name}}", + "lineMore": "Utilisé par {{name}} +{{count}}", + "all": "toutes les compétences", + "allTitle": "Cet agent charge toutes les compétences", + "remove": "Retirer de {{agent}}", + "open": "Ouvrir {{agent}}", + "unread": "L'utilisation ne peut pas être lue pour le moment.", + "unreadShort": "Utilisation inconnue" + }, + "notUsed": { + "lead": "Pas encore utilisé.", + "skill": "Elle reste dans la bibliothèque tant que vous ne l'ajoutez pas quelque part. Essayez-la d'abord, ou ajoutez-la à un agent.", + "collection": "Elle reste dans la bibliothèque tant que vous ne l'ajoutez pas quelque part. Recherchez-y ici, ou ajoutez-la à un agent.", + "skillShort": "Elle reste dans la liste tant que vous ne l'ajoutez pas à un agent." + }, + "tryInContext": "Essayer dans un contexte", + "add": { + "button": "Ajouter à...", + "title": "Ajouter {{name}} à", + "agents": "Agents", + "noAgents": "Aucun agent pour le moment. Créez un agent, puis ajoutez-le ici.", + "createAgent": "Créer un agent", + "added": "Ajouté", + "action": "Ajouter", + "addToAgent": "Ajouter à {{agent}}", + "working": "Ajout en cours...", + "estimate": "Les estimations utilisent les caractères divisés par quatre.", + "toolsMode": "Chargée lorsque le modèle la demande", + "tokens": "+{{count}} jetons par message (estimation)", + "collectionNote": "Un agent peut rechercher dans les collections de sa liste.", + "kbOn": "Active sa base de connaissances", + "noAgentNamed": "Aucun agent ne se nomme {{name}}. Créez un agent portant ce nom pour utiliser cette collection.", + "close": "Fermer", + "skillNote": "Un agent peut lire les compétences de sa liste." + }, + "toasts": { + "skillAdded": "{{name}} ajoutée à {{agent}}", + "skillRemoved": "{{name}} retirée de {{agent}}", + "skillsOff": "{{name}} retirée de {{agent}}. Les compétences sont désormais désactivées pour lui.", + "collectionAdded": "{{name}} ajoutée à {{agent}}", + "collectionRemoved": "{{name}} retirée de {{agent}}", + "undone": "Modification annulée", + "updateFailed": "Impossible de mettre à jour {{agent}} : {{message}}", + "undo": "Annuler", + "dismiss": "Ignorer" + }, + "simulate": { + "button": "Simuler un message", + "title": "Simuler un message", + "desc": "Choisissez un contexte et voyez ce qu'il chargerait. Rien n'est envoyé à un modèle.", + "close": "Fermer", + "context": "Contexte", + "contextCollection": "Une collection seule", + "contextAgent": "Agent : {{name}}", + "collectionOn": "Collection", + "noCollections": "Aucune collection pour le moment.", + "contextHintCollection": "Recherche dans la collection avec votre message et affiche les passages trouvés, avec leurs scores. Aucun agent ni modèle n'intervient.", + "contextHintAgent": "Affiche ce que cet agent garde dans son prompt, ainsi que les passages que sa base de connaissances ajouterait pour ce message.", + "message": "Message", + "messagePlaceholder": "Saisissez ce qu'une personne pourrait demander", + "passages": "Passages", + "run": "Lancer", + "running": "En cours...", + "onlyForTest": "Uniquement pour ce test", + "removeExtra": "Retirer {{name}} de ce test", + "tryAlso": "Essayer quelque chose qui n'est pas encore ajouté", + "tryAlsoPick": "Choisissez une compétence ou une collection", + "extraSkill": "Compétence {{name}}", + "extraCollection": "Collection {{name}}", + "budgetTitle": "Ce que ce contexte ajoute", + "budgetTotal": "Environ {{count}} jetons autour de votre message", + "budgetMessage": "Votre message : {{count}} jetons", + "budgetNote": "Estimations : caractères divisés par quatre. La fenêtre du modèle n'est pas connue ici, il n'y a donc pas de part d'une limite.", + "instructions": "Instructions", + "skills": "Compétences", + "memory": "Mémoire", + "skillsTitle": "Compétences chargées par ce contexte", + "skillsNone": "Aucune compétence n'est active pour cet agent.", + "skillsPrompt": "Dans le prompt de chaque message.", + "skillsTools": "Lue uniquement lorsque le modèle en fait la demande.", + "skillsBoth": "Dans le prompt, et relue lorsque le modèle en fait la demande.", + "skillsAll": "Toutes les compétences sont actives pour cet agent.", + "skillMissing": "{{name}} figure dans la liste, mais aucune compétence ne porte ce nom.", + "skillTokens": "{{count}} jetons", + "skillTokensTools": "0 au départ", + "memoryTitle": "Mémoire qui serait envoyée", + "memoryNone": "Aucune collection n'est dans la liste de cet agent. Ajoutez-en une à l'agent pour l'utiliser.", + "memoryNoCollection": "La base de connaissances de cet agent est active, mais aucune collection nommée {{name}} n'existe encore.", + "memoryTools": "Cet agent ne recherche que lorsque le modèle en fait la demande. Rien n'est ajouté au message au départ.", + "memoryTesting": "Collection {{name}}, uniquement pour ce test", + "memoryFrom": "Depuis {{name}}", + "passagesCount": "{{count}} passages, classés par similarité", + "noPassages": "Rien n'a été trouvé pour ce message.", + "pickMessage": "Saisissez d'abord un message.", + "searchFailed": "La recherche a échoué : {{message}}", + "score": "Score {{score}}", + "tokens": "{{count}} jetons", + "noAgents": "Aucun agent pour le moment. Vous pouvez toujours rechercher dans une collection seule.", + "backendNote": "Une simulation à blanc du modèle n'est pas disponible ici, aucune réponse n'est donc simulée." + }, + "search": { + "failed": "Échec de la recherche : {{message}}" + }, + "agentBudget": "Les compétences sélectionnées ajoutent environ {{count}} jetons à chaque message (estimation : caractères divisés par quatre).", + "agentKb": { + "reads": "Cet agent lit la collection nommée {{name}}.", + "exists": "Cet agent lit la collection nommée {{name}}. Vous pouvez la remplir et la rechercher dans Mémoire.", + "created": "Cet agent lit la collection nommée {{name}}. Elle est créée à l'enregistrement." + } +} diff --git a/core/http/react-ui/public/locales/fr/media.json b/core/http/react-ui/public/locales/fr/media.json index 75a67ed5e..0ba93602e 100644 --- a/core/http/react-ui/public/locales/fr/media.json +++ b/core/http/react-ui/public/locales/fr/media.json @@ -243,7 +243,7 @@ "api": { "title": "Utilisation de l'API et modèles compatibles", "summary": "Utilisez cette voix hors du navigateur ou vérifiez la compatibilité des modèles", - "body": "Envoyez l'URI stable de la voix avec chaque requête de synthèse. LocalAI résout l'audio de référence privé et sa transcription au moment de la génération ; le YAML du modèle n'est pas modifié.", + "body": "Envoyez l'URI stable de la voix avec chaque requête de synthèse. LocalAI résout l'audio de référence privé et sa transcription au moment de la génération ; les réglages de vos modèles ne sont pas modifiés.", "compatibleModels": "Modèles installés compatibles", "noInstalledModels": "Aucun pour l'instant. Installez l'un des modèles vérifiés ci-dessus avant d'envoyer cette requête.", "curlExample": "Exemple cURL", @@ -262,7 +262,7 @@ }, "deleteDialog": { "title": "Supprimer cette voix ?", - "message": "« {{name}} » et son audio de référence seront définitivement supprimés. Le YAML des modèles existants n'est pas modifié.", + "message": "« {{name}} » et son audio de référence seront définitivement supprimés. Les modèles configurés avec cette voix ne sont pas modifiés.", "deleting": "Suppression…" }, "toasts": { diff --git a/core/http/react-ui/public/locales/fr/modelEditor.json b/core/http/react-ui/public/locales/fr/modelEditor.json new file mode 100644 index 000000000..9b52dddca --- /dev/null +++ b/core/http/react-ui/public/locales/fr/modelEditor.json @@ -0,0 +1,6 @@ +{ + "proxyVision": { + "label": "Ce modèle peut regarder les images", + "hint": "Activez-le si le modèle en amont accepte les images. LocalAI ne peut pas le vérifier. Les images jointes sont alors envoyées au modèle avec le message." + } +} diff --git a/core/http/react-ui/public/locales/fr/models.json b/core/http/react-ui/public/locales/fr/models.json index ae61fda42..c108c7de3 100644 --- a/core/http/react-ui/public/locales/fr/models.json +++ b/core/http/react-ui/public/locales/fr/models.json @@ -1,5 +1,6 @@ { "lifecycle": { + "supporting": "Trouvez un modèle adapté à cette machine, installez-le et gérez ceux que vous avez.", "title": "Modèles", "navLabel": "Cycle de vie des modèles", "views": { @@ -153,6 +154,10 @@ "delete": "Supprimer" }, "filters": { + "sheetDone": "Afficher les modèles", + "sheetTitle": "Filtres", + "sheetButtonCount": "Filtres ({{count}})", + "sheetButton": "Filtres", "all": "Tous", "llm": "Chat", "image": "Image", diff --git a/core/http/react-ui/public/locales/fr/nav.json b/core/http/react-ui/public/locales/fr/nav.json index 566c428ab..c60221ff1 100644 --- a/core/http/react-ui/public/locales/fr/nav.json +++ b/core/http/react-ui/public/locales/fr/nav.json @@ -10,6 +10,7 @@ "changeLanguage": "Changer de langue", "logout": "Déconnexion", "accountSettings": "Paramètres du compte", + "yourSettings": "Vos paramètres", "account": "Compte", "accountFor": "Compte : {{name}}", "sections": { @@ -38,8 +39,10 @@ "quantize": "Réduisez un modèle pour l'exécuter sur du matériel plus modeste.", "skills": "Instructions réutilisables que les agents chargent à la demande.", "voices": "Identifiez les locuteurs et conservez une bibliothèque de profils vocaux.", - "addons": "Install, turn on and open add-ons." - } + "addons": "Install, turn on and open add-ons.", + "triggers": "Démarrez un chat automatiquement, selon un calendrier ou à l'arrivée d'un fichier." + }, + "operateSupporting": "Ce qui tourne sur ce serveur, comment il est utilisé et comment il est configuré." }, "operate": { "inference": "Inférence", @@ -91,7 +94,14 @@ "p2p": "P2P", "prometheus": "Prometheus", "trafficModels": "Modèles", - "addons": "Add-ons" + "addons": "Add-ons", + "chats": "Chats", + "files": "Fichiers", + "triggers": "Déclencheurs", + "mcpConnectors": "Connecteurs MCP", + "chatsAdmin": "Chats de tous les utilisateurs", + "pages": "Pages", + "allChats": "Toutes les conversations" }, "footer": { "github": "GitHub", @@ -104,5 +114,11 @@ "training": "Entraînement", "expandNavigation": "Développer la navigation {{section}}", "collapseNavigation": "Réduire la navigation {{section}}" - } + }, + "pinnedPages": "Pages épinglées", + "new": "Nouveau", + "recentChats": "Conversations récentes", + "newChat": "Nouvelle conversation", + "untitledChat": "Conversation sans titre", + "chatRunning": "En cours" } diff --git a/core/http/react-ui/public/locales/fr/operate.json b/core/http/react-ui/public/locales/fr/operate.json new file mode 100644 index 000000000..4b4c3af77 --- /dev/null +++ b/core/http/react-ui/public/locales/fr/operate.json @@ -0,0 +1,57 @@ +{ + "backends": { + "filterLabel": "Filtrer les backends", + "check": "Rechercher des mises à jour", + "reinstallBackend": "Réinstaller le backend", + "deleteBackend": "Supprimer le backend", + "updateAllTitle": "Mettre à jour tous les backends", + "updateAllIntro": "Choisissez les backends à mettre à jour. Chacun est mis à jour séparément, l'un après l'autre. Un échec n'arrête pas les autres.", + "updateSelected": "Mettre à jour la sélection", + "loadFailedTitle": "Impossible de charger le catalogue des backends.", + "filtersButton": "Filtres", + "filtersDone": "Terminé", + "filterGroups": { + "state": "État", + "type": "Type", + "show": "Afficher", + "library": "Bibliothèque" + }, + "showVariants": "Variantes", + "showDevelopment": "Versions de développement", + "allLibraries": "Toutes les bibliothèques", + "moreActions": "Autres actions sur les backends", + "installFromUrl": "Installer depuis une URL…", + "manageLibraries": "Gérer les bibliothèques…", + "count_one": "{{count}} backend", + "count_other": "{{count}} backends", + "countOf": "{{shown}} sur {{total}} backends", + "activeFilters": "Filtres actifs", + "removeFilter": "Retirer le filtre : {{name}}", + "checking": "Recherche de mises à jour…", + "updatesAvailable_one": "{{count}} mise à jour disponible", + "updatesAvailable_other": "{{count}} mises à jour disponibles", + "reviewUpdates": "Examiner", + "close": "Fermer", + "installedLoadFailedTitle": "Impossible de charger les backends installés." + }, + "capacity": { + "diskChip": "Disque presque plein" + }, + "activity": { + "kind": { + "addon": "module" + }, + "verb": { + "upgrading": "Mise à niveau du {{kind}}", + "failedUpgrade": "Impossible de mettre à niveau le {{kind}}" + }, + "filter": { + "addons": "Modules" + }, + "upgradeVersions": "{{from}} vers {{to}}", + "rowUpgraded": "Mis à niveau en {{duration}}", + "rowUpgradedPlain": "Mis à niveau", + "viewInAddons": "Voir dans Modules", + "upgradeWaiting": "En attente du démarrage du service des modules. Prochaine vérification dans {{seconds}} s." + } +} diff --git a/core/http/react-ui/public/locales/fr/pages.json b/core/http/react-ui/public/locales/fr/pages.json new file mode 100644 index 000000000..d84e5e53a --- /dev/null +++ b/core/http/react-ui/public/locales/fr/pages.json @@ -0,0 +1,138 @@ +{ + "subtitle": "De petits outils créés par l’assistant pour vous. Ils s’ouvrent ici, dans un bac à sable, et gardent leurs données dans vos fichiers.", + "card": { + "pin": "Épingler à la barre latérale", + "unpin": "Détacher de la barre latérale", + "changedBy": { + "you": "Modifiée {{when}} par vous", + "assistant": "Modifiée {{when}} par l’assistant" + }, + "menu": "Plus pour {{title}}", + "files": "Afficher ses fichiers", + "rename": "Renommer", + "renameLabel": "Nouveau nom de dossier", + "renameHint": "Minuscules, chiffres et tirets.", + "renameSave": "Enregistrer", + "renameCancel": "Annuler", + "renameFailed": "La page n’a pas été renommée : {{message}}", + "delete": "Supprimer la page", + "deleteTitle": "Supprimer « {{title}} » ?", + "deleteMessage": "Le dossier {{folder}} va dans la corbeille. Vous pouvez le restaurer depuis Fichiers.", + "deleted": "{{title}} est dans la corbeille.", + "deleteFailed": "La page n’a pas été supprimée : {{message}}" + }, + "empty": { + "text": "Une page est un petit outil, comme un tableau de tâches ou une liste de lecture. Demandez-en une à l’assistant dans un chat, ou partez d’une page prête.", + "starting": "Copie de la page", + "starterFailed": "La page n’a pas été copiée : {{message}}", + "ask": "Demander une page", + "askPrompt": "Créez-moi une page qui ", + "starterReading": "Partir d’une liste de lecture", + "note": "Cette entrée quitte la barre latérale quand vous n’avez aucune page. Elle revient avec la suivante.", + "learn": "Comment fonctionnent les pages" + }, + "askNew": "Demander une nouvelle page", + "yours": "Les vôtres", + "filter": "Filtrer les pages", + "shared": { + "yours": "Les vôtres", + "group": "Partagées avec vous", + "note": "Une page partagée ne s’exécute qu’après votre autorisation. Si son auteur la modifie, la question vous est reposée.", + "offNotice": "Les pages partagées sont désactivées sur ce serveur. Un administrateur les a désactivées.", + "off": "Les pages partagées sont désactivées sur ce serveur. Un administrateur les a désactivées.", + "gone": "Cette page n’est plus dans le dossier partagé, ou vous n’y avez plus accès.", + "review": "Examiner", + "from": "De {{author}}", + "menu": "Menu de la page", + "badge": { + "notAllowed": "Pas encore autorisée", + "changed": "Modifiée depuis votre autorisation", + "allowed": "Autorisée" + } + }, + "viewer": { + "stopAllowing": "Ne plus autoriser cette page", + "authorChanged": "{{author}} a modifié cette page. La version autorisée continue jusqu’à ce que vous examiniez la modification.", + "sandboxShared": "Isolée. S’exécute en votre nom, uniquement dans {{folder}}, sans accès au réseau." + }, + "trust": { + "title": "Ouvrir « {{title}} » ?", + "desc": "Cette page vient d’un dossier partagé. Les pages s’exécutent en votre nom, vérifiez-la donc avant qu’elle ne s’exécute.", + "who": "Écrite par {{author}}", + "whereLabel": "Dossier partagé", + "whereWhen": ", modifiée {{when}}", + "someone": "quelqu’un", + "close": "Fermer", + "canTitle": "Elle peut", + "canStillTitle": "Elle peut toujours", + "cannotTitle": "Elle ne peut pas", + "items": { + "files": "Lire et écrire des fichiers dans son propre dossier", + "readOnly": "Vous ne pouvez que lire ce dossier partagé, la page ne peut donc pas enregistrer ses modifications.", + "chat": "Écrire un message dans un chat", + "chatNote": "Vous voyez le message dans le chat avant que l’assistant n’y réponde.", + "noFiles": "Lire vos autres fichiers ou chats", + "noNet": "Accéder au réseau ou à d’autres sites", + "noNetNote": "Une limite : elle peut charger une autre adresse dans son propre cadre. Cela envoie une requête vers l’extérieur, puis la page s’arrête.", + "noTools": "Utiliser des outils ou modifier vos réglages" + }, + "warn": "Elle agit en votre nom dans son dossier. Si {{author}} la modifie, la question vous est reposée.", + "viewSource": "Voir la source", + "cancel": "Annuler", + "allowOnce": "Autoriser une fois", + "allow": "Autoriser", + "changed": { + "title": "« {{title}} » a été modifiée", + "desc": "Vous avez autorisé une version précédente. Vérifiez ce qui a changé avant qu’elle ne s’exécute à nouveau.", + "who": "Modifiée par {{author}} {{when}}", + "allowed": "Vous avez autorisé la version du {{date}}", + "what": "Ce qui a changé", + "same": "Ce qu’elle peut faire est inchangé.", + "warn": "Si vous l’autorisez, cette version s’exécute à chaque ouverture, jusqu’à ce que {{author}} la modifie à nouveau.", + "view": "Voir les modifications", + "lines": "{{before}} → {{after}} lignes", + "code": "Un script, une feuille de style ou un module de la page a changé : {{files}}" + }, + "pill": { + "notYet": "Pas encore autorisée", + "not": "Non autorisée", + "once": "Autorisée pour cette visite", + "allowed": "Autorisée", + "changed": "Modifiée depuis votre autorisation" + }, + "from": "De {{author}}", + "frame": { + "chrome": "Page partagée de {{folder}}. Elle ne s’est pas exécutée." + }, + "blocked": { + "title": "Cette page ne s’est pas exécutée", + "body": "Vous avez choisi de ne pas l’exécuter. Elle vient du dossier partagé {{folder}} de {{author}}. Rien ne s’y est exécuté et rien n’a été enregistré.", + "review": "Examiner et autoriser" + }, + "waiting": { + "title": "En attente de vous", + "body": "Rien dans cette page ne s’exécute avant votre autorisation." + }, + "stopped": { + "revoked": "Vous n’autorisez plus cette page. Elle s’est arrêtée et rien ne s’y exécute jusqu’à une nouvelle autorisation.", + "refused": "Cette page n’est plus autorisée et a été arrêtée. Examinez-la pour l’autoriser à nouveau.", + "off": "Les pages partagées ont été désactivées sur ce serveur, cette page a donc été arrêtée." + }, + "changedMeanwhile": "La page a changé pendant votre examen. Voici la nouvelle version.", + "sourceTitle": "Source de « {{title}} »", + "sourceLines_one": "{{count}} ligne", + "sourceLines_other": "{{count}} lignes", + "sourceNote": "La source est affichée comme du texte. Rien ne s’y exécute ici." + }, + "frame": { + "filesFailed": "Des fichiers à côté de cette page ne se sont pas chargés : {{files}}. La console du navigateur indique pourquoi.", + "tooLarge": "Cette page intègre plus de {{limit}} de fichiers de son dossier, certains ont donc été omis. Chargez plutôt les données avec fetch().", + "dismiss": "Fermer" + }, + "link": { + "label": "La page veut ouvrir un lien", + "open": "Ouvrir {{site}}", + "cancel": "Annuler", + "lookalike": "Cette adresse utilise des lettres d'autres alphabets et se lit {{display}}. Elle peut imiter un autre site : vérifiez-la avant de l'ouvrir." + } +} diff --git a/core/http/react-ui/public/locales/fr/skills.json b/core/http/react-ui/public/locales/fr/skills.json index a267ba6d9..ee7508e2a 100644 --- a/core/http/react-ui/public/locales/fr/skills.json +++ b/core/http/react-ui/public/locales/fr/skills.json @@ -1,79 +1,83 @@ { "title": "Compétences", - "subtitle": "Gérez les compétences des agents (instructions et ressources réutilisables)", + "subtitle": "Des instructions que tous vos chats peuvent utiliser quand une tâche correspond. Les agents utilisent celles que vous leur ajoutez.", "unavailable": { - "subtitle": "Le service de compétences est indisponible ou l'index est en cours de reconstruction. Réessayez dans un instant.", - "retry": "Réessayer" + "subtitle": "Les compétences ont besoin de Fichiers, et Fichiers est désactivé sur ce serveur." }, "actions": { "newSkill": "Nouvelle compétence", "createSkill": "Créer une compétence", - "import": "Importer", - "importing": "Importation...", - "gitRepos": "Dépôts Git", "edit": "Modifier", - "delete": "Supprimer", - "export": "Exporter", - "sync": "Synchroniser", - "addRepo": "Ajouter un dépôt", - "adding": "Ajout...", - "remove": "Retirer", - "enable": "Activer", - "disable": "Désactiver" + "delete": "Supprimer" }, "search": { "placeholder": "Rechercher des compétences..." }, - "git": { - "title": "Dépôts Git", - "description": "Ajoutez des dépôts Git depuis lesquels récupérer des compétences. Elles apparaîtront dans la liste après synchronisation.", - "urlPlaceholder": "https://github.com/utilisateur/depot ou git@github.com:utilisateur/depot.git", - "noRepos": "Aucun dépôt Git configuré. Ajoutez-en un ci-dessus.", - "disabled": "Désactivé", - "removeRepo": "Retirer le dépôt" - }, "card": { "noDescription": "Aucune description", "readOnly": "Lecture seule", "editTitle": "Modifier la compétence", "deleteTitle": "Supprimer la compétence", - "exportTitle": "Exporter en .tar.gz" + "invalid": "Skipped: not a valid skill", + "invalidWhy": "Cette compétence a été ignorée : {{reason}}", + "shadowed": "Hidden by a {{layer}} skill", + "shadowedLong": "Hidden by a {{layer}} skill of the same name", + "providedBy": "Fournie par {{name}}", + "replacedBy": "Remplacée par {{name}}", + "notActive": "Inactive : la compétence intégrée est utilisée", + "notActiveBy": "Inactive : {{name}} la fournit", + "fallback": "Utilisée car la compétence du module {{name}} est désactivée ou ne tourne pas" }, "empty": { "title": "Aucune compétence trouvée", "text": "Créez une compétence ou importez-en une pour commencer.", - "noPersonal": "Vous n'avez encore aucune compétence." - }, - "sections": { - "yourSkills": "Vos compétences", - "otherUsersSkills": "Compétences des autres utilisateurs" - }, - "deleteDialog": { - "title": "Supprimer la compétence", - "message": "Supprimer la compétence « {{name}} » ? Cette action est irréversible.", - "confirm": "Supprimer" - }, - "removeRepoDialog": { - "title": "Retirer le dépôt Git", - "message": "Retirer ce dépôt Git ? Les compétences qui en proviennent ne seront plus disponibles.", - "confirm": "Retirer" + "stepWrite": "Write a skill: a name, a line that says when to use it, and the instructions.", + "stepTry": "Check it in the list: it shows who uses it and what it says.", + "stepAdd": "Add it to an agent when you want it used." }, "toasts": { "loadFailed": "Échec du chargement des compétences", "deleted": "Compétence « {{name}} » supprimée", - "deleteFailed": "Échec de la suppression de la compétence", - "exported": "Compétence « {{name}} » exportée", - "exportFailed": "Échec de l'export", - "imported": "Compétence importée depuis « {{file}} »", - "importFailed": "Échec de l'import", - "loadReposFailed": "Échec du chargement des dépôts Git", - "repoAdded": "Dépôt Git ajouté et en cours de synchronisation", - "addRepoFailed": "Échec de l'ajout du dépôt", - "synced": "Dépôt synchronisé", - "syncFailed": "Échec de la synchronisation", - "toggled": "Dépôt basculé", - "toggleFailed": "Échec du basculement", - "removed": "Dépôt retiré", - "removeFailed": "Échec du retrait" + "deleteFailed": "Échec de la suppression de la compétence" + }, + "layers": { + "label": "Layer", + "personal": "Personal", + "shared": "Shared", + "builtin": "Built-in", + "folder": "Workspace", + "addon": "Module" + }, + "pane": { + "loading": "Loading...", + "loadFailed": "The skill could not be read.", + "tokensExplain": "A chat reads a skill only when a task matches its description, so it adds nothing until then. About {{count}} tokens when read (estimate)." + }, + "edit": { + "back": "Back to skills", + "titleNew": "New skill", + "titleEdit": "Edit: {{name}}", + "basic": "Basic information", + "content": "Content", + "layer": "Layer", + "layerHelp": "Personal is yours alone. Shared is read by every user.", + "name": "Name (lowercase, hyphens only)", + "nameLocked": "Name cannot be changed after creation.", + "description": "Description (when to use it)", + "descriptionHelp": "A chat reads this line to decide whether the skill fits the task.", + "body": "Skill content (markdown)", + "cancel": "Cancel", + "save": "Save changes", + "create": "Create skill", + "saving": "Saving...", + "created": "Skill created", + "updated": "Skill updated", + "required": "A name and a description are required", + "saveFailed": "Save failed", + "loadFailed": "Failed to load the skill", + "readOnly": "Cette compétence est en lecture seule ici. Une compétence intégrée est fournie avec le serveur, et une compétence de vos fichiers se modifie dans Fichiers." + }, + "load": { + "title": "Impossible de charger vos compétences." } } diff --git a/core/http/react-ui/public/locales/fr/swarm.json b/core/http/react-ui/public/locales/fr/swarm.json new file mode 100644 index 000000000..f6aabbb83 --- /dev/null +++ b/core/http/react-ui/public/locales/fr/swarm.json @@ -0,0 +1,5 @@ +{ + "nodes": { + "retry": "Réessayer" + } +} diff --git a/core/http/react-ui/public/locales/fr/tools.json b/core/http/react-ui/public/locales/fr/tools.json new file mode 100644 index 000000000..1eca706c5 --- /dev/null +++ b/core/http/react-ui/public/locales/fr/tools.json @@ -0,0 +1,468 @@ +{ + "steps": { + "label": "Progression", + "setup": "Configuration", + "check": "Vérification", + "run": "Exécution", + "result": "Résultat" + }, + "run": { + "stop": "Arrêter", + "connecting": "Connexion au job. En attente du premier événement de progression.", + "progress": "Progression", + "stages": "Étapes", + "step": "étape", + "of": "sur", + "epoch": "époque", + "tokens": "jetons/s", + "eta": "environ {{time}} restantes", + "etaSeconds": "environ {{seconds}} s restantes" + }, + "landing": { + "lede": "Ce que vous pouvez créer sur cette machine, et ce dont chaque outil a besoin en premier.", + "machine": "Cette machine", + "gpu": "GPU", + "gpuFree": "{{free}} libres sur {{total}}", + "noGpu": "Aucun GPU trouvé", + "cluster": "{{total}} sur le meilleur nœud ({{node}})", + "clusterNode": "un worker", + "ram": "Mémoire", + "ramFree": "RAM, {{free}} libres", + "disk": "Disque des modèles", + "diskFree": "libres", + "backends": "Backends", + "noBackends": "aucun installé pour ces outils", + "open": "Ouvrir", + "openTool": "Ouvrir {{tool}}", + "install": "Installer", + "ready": "Prêt", + "needsBackend": "Backend requis", + "experimental": "Expérimental", + "needs": "Ce dont il a besoin", + "flow": "Le chemin d'un modèle trouvé à un modèle avec lequel vous pouvez discuter", + "flowNote": "Chaque étape se termine par un modèle que vous pouvez charger.", + "now": { + "running": { + "fineTune": "Fine-tuning de {{model}} en cours", + "quantize": "Quantification de {{model}} en {{type}} en cours" + }, + "failed": { + "fineTune": "Échec du fine-tuning de {{model}}", + "quantize": "Échec de la quantification de {{model}} en {{type}}" + } + }, + "groups": { + "make": { + "title": "Créer un modèle", + "text": "Partez d'un modèle trouvé, et obtenez-en un qui convient et connaît vos données." + }, + "recognise": { + "title": "Reconnaître", + "text": "Qui parle, et à qui appartient le visage sur une photo." + }, + "automate": { + "title": "Automatiser", + "text": "Des assistants, leurs instructions et leur mémoire." + } + }, + "sentence": { + "import": "Importez un modèle depuis Hugging Face, une URL, une référence Ollama ou OCI, ou un fichier de cette machine. LocalAI choisit le backend et écrit la configuration.", + "quantize": "Réduisez la taille d'un modèle pour qu'il tienne dans moins de mémoire. Convertit un modèle Hugging Face en GGUF avec la précision que vous choisissez.", + "fine-tune": "Apprenez à un modèle vos propres données avec un adaptateur, puis exportez-le comme modèle avec lequel vous pouvez discuter." + }, + "line": { + "import": { + "noSetup": "Aucune configuration. Le backend se télécharge lorsqu'il est nécessaire.", + "disk": "{{free}} libres sur le disque des modèles" + }, + "quantize": { + "backend": "Backend {{name}} installé", + "backendMissing": "Le backend de quantification n'est pas installé", + "backendUnknown": "Impossible de lire l'état du backend", + "cpu": "Fonctionne sur le CPU, pas besoin de GPU" + }, + "fineTune": { + "backend": "Backend {{name}} installé", + "backendMissing": "Aucun backend de fine-tuning n'est installé", + "backendUnknown": "Impossible de lire l'état du backend", + "gpu": "Mémoire GPU : {{name}}, {{free}} libres sur {{total}}", + "noGpu": "Aucun GPU trouvé. L'entraînement se fait sur le CPU et il est lent.", + "cluster": "Mémoire : {{total}} sur le meilleur nœud" + } + }, + "enable": { + "admin": "Installez le backend depuis Backends, et cet outil sera prêt.", + "user": "Demandez à un administrateur d'installer le backend." + } + }, + "checks": { + "heading": "Vérifier avant de commencer", + "live": "Se met à jour à mesure que vous modifiez la configuration.", + "meterLabel": "{{used}} pour cent utilisés, {{free}} libres sur {{total}}", + "openBackends": "Ouvrir Backends", + "barOk": "Rien ne bloque le démarrage.", + "barWarn_one": "{{count}} avertissement. Vous pouvez tout de même démarrer.", + "barWarn_other": "{{count}} avertissements. Vous pouvez tout de même démarrer.", + "title": { + "model": "Modèle", + "data": "Jeu de données", + "backend": "Backend", + "memory": "Mémoire", + "disk": "Disque", + "reward": "Fonctions de récompense", + "resume": "Reprise", + "access": "Accès au modèle", + "type": "Type de quantification", + "source": "Source", + "name": "Nom" + }, + "fineTune": { + "modelOk": "Entraînement de {{model}}.", + "modelMissing": "Saisissez le modèle à entraîner.", + "dataOk": "Les données viennent de {{source}}.", + "dataMissing": "Ajoutez un jeu de données : un identifiant Hugging Face ou un fichier envoyé.", + "backendOk": "{{name}} est installé.", + "backendMissing": "Aucun backend de fine-tuning n'est installé, la tâche risque donc de ne pas démarrer.", + "backendOther": "{{name}} ne fait pas partie des backends de fine-tuning installés ({{list}}).", + "backendUnknown": "Impossible de lire les backends installés.", + "memoryGpu": "{{name}} : {{free}} libres sur {{total}}. Taille de batch {{batch}}, longueur de séquence {{seq}}, accumulation de gradient {{accum}}. La mémoire nécessaire dépend du modèle et de ces réglages, et LocalAI ne l'estime pas avant le démarrage de la tâche.", + "memoryCluster": "{{total}} sur le meilleur nœud ({{node}}). Taille de batch {{batch}}, longueur de séquence {{seq}}. La mémoire nécessaire dépend du modèle et de ces réglages.", + "memoryCpu": "Aucun GPU trouvé. L'entraînement utilise le CPU, et {{free}} de RAM sur {{total}} sont libres. Attendez-vous à de la lenteur. Taille de batch {{batch}}, longueur de séquence {{seq}}.", + "memoryUnknown": "Ce compte ne peut pas lire la mémoire de la machine. Taille de batch {{batch}}, longueur de séquence {{seq}}. La mémoire nécessaire dépend du modèle et de ces réglages.", + "disk": "{{free}} libres sur le disque des modèles. Les téléchargements et les checkpoints ont besoin d'espace.", + "diskLow": "Seulement {{free}} libres sur le disque des modèles. Les téléchargements et les checkpoints ont besoin d'espace.", + "rewardMissing": "GRPO nécessite au moins une fonction de récompense. Ajoutez-en une dans Plus d'options.", + "resume": "L'entraînement reprend depuis {{path}}.", + "accessNone": "Aucun jeton défini. Un modèle protégé nécessite un jeton Hugging Face dans Plus d'options.", + "accessSet": "Un jeton Hugging Face est défini." + }, + "quantize": { + "modelOk": "Quantification de {{model}}.", + "modelMissing": "Saisissez le modèle à quantifier.", + "typeMissing": "Saisissez le type de quantification personnalisé.", + "backendOk": "{{name}} est installé.", + "backendMissing": "Aucun backend de quantification n'est installé, la tâche risque donc de ne pas démarrer.", + "backendOther": "{{name}} ne fait pas partie des backends de quantification installés ({{list}}).", + "backendUnknown": "Impossible de lire les backends installés.", + "memory": "{{free}} de RAM sur {{total}} sont libres. La conversion se fait sur le CPU.", + "disk": "{{free}} libres sur le disque des modèles. Le téléchargement, le fichier converti et le résultat sont écrits sur le disque.", + "diskLow": "Seulement {{free}} libres sur le disque des modèles. Le téléchargement, le fichier converti et le résultat sont écrits sur le disque.", + "accessNone": "Aucun jeton défini. Un modèle protégé nécessite un jeton Hugging Face dans Plus d'options.", + "accessSet": "Un jeton Hugging Face est défini." + }, + "import": { + "sourceOk_huggingface": "Reconnu comme dépôt Hugging Face. LocalAI examine ses fichiers lors de l'import.", + "sourceOk_url": "Format d'URL reconnu. Son contenu n'a pas été vérifié.", + "sourceOk_config": "Reconnu comme fichier de configuration de modèle. Il est lu et utilisé tel quel.", + "sourceOk_oci": "Reconnu comme référence d'image OCI.", + "sourceOk_ollama": "Reconnu comme référence de modèle Ollama.", + "sourceOk_local": "Reconnu comme chemin sur la machine qui exécute LocalAI.", + "sourceOk": "La source est reconnue.", + "sourceUnknown": "Ce format ne correspond à aucun format connu de LocalAI. L'import essaiera quand même, et peut échouer.", + "backendDownload": "{{name}} n'est pas encore installé. LocalAI le télécharge d'abord.", + "backendChosen": "{{name}} est choisi et installé.", + "backendAuto": "LocalAI choisit le backend d'après les fichiers de la source au démarrage de l'import.", + "nameTaken": "Un modèle nommé {{name}} est déjà installé.", + "nameFree": "{{name}} n'est pas utilisé.", + "nameAuto": "Le modèle prend le nom de la source.", + "disk": "{{free}} libres sur le disque des modèles. La taille du téléchargement est connue au démarrage de l'import.", + "memory_gpu": "{{free}} de mémoire GPU sont libres maintenant. Ce dont ce modèle a besoin est connu au démarrage de l'import.", + "memory_ram": "{{free}} de RAM sont libres maintenant. Ce dont ce modèle a besoin est connu au démarrage de l'import.", + "memory_cluster": "{{free}} sont disponibles sur le meilleur nœud. Ce dont ce modèle a besoin est connu au démarrage de l'import.", + "memory": "{{free}} de mémoire sont libres maintenant. Ce dont ce modèle a besoin est connu au démarrage de l'import." + } + }, + "form": { + "key": "Clé", + "value": "Valeur", + "addOption": "Ajouter une option", + "copy": "Copier dans le presse-papiers", + "more": "Plus d'options", + "moreHint": "méthode, backend, adaptateur, optimiseur, évaluation, fonctions de récompense", + "baseModel": "Modèle de base", + "model": "Modèle", + "modelHint": "Un identifiant Hugging Face, ou un chemin sur cette machine.", + "data": "Vos données", + "dataset": "Source du jeu de données", + "datasetHint": "Un identifiant de jeu de données Hugging Face, ou envoyez un fichier.", + "split": "Découpage", + "upload": "Envoyer un fichier à la place", + "uploadHint": ".json, .jsonl ou .csv", + "removeFile": "Retirer le fichier", + "train": "Ce qu'il faut entraîner", + "kind": "Type d'entraînement", + "kinds": { + "adapter": { + "title": "Adaptateur", + "text": "Entraîne un petit adaptateur et laisse les poids de base intacts. Le choix habituel, qui demande peu de mémoire." + }, + "full": { + "title": "Modèle complet", + "text": "Modifie chaque poids. Il demande plusieurs fois la mémoire d'un adaptateur." + } + }, + "epochs": "Époques", + "batch": "Taille de batch", + "lr": "Taux d'apprentissage", + "backend": "Backend", + "method": "Méthode d'entraînement", + "adapterKind": "Type d'adaptateur", + "rank": "Rang", + "alpha": "Alpha", + "dropout": "Dropout", + "gradAccum": "Pas d'accumulation de gradient", + "warmup": "Pas de préchauffage", + "seqLen": "Longueur maximale de séquence", + "optimizer": "Optimiseur", + "maxSteps": "Nombre maximal de pas", + "maxStepsHint": "0 pour automatique.", + "saveSteps": "Pas de sauvegarde", + "saveLimit": "Limite totale de sauvegardes", + "saveLimitHint": "0 conserve chaque checkpoint.", + "weightDecay": "Décroissance des poids", + "seed": "Graine", + "seedHint": "0 choisit une graine aléatoire.", + "precision": "Précision mixte", + "auto": "Automatique", + "targetModules": "Modules cibles", + "targetModulesHint": "Séparés par des virgules. Laissez vide pour la valeur par défaut du backend.", + "gradCheckpointing": "Gradient checkpointing (plus lent, utilise moins de mémoire)", + "token": "Jeton Hugging Face", + "tokenHint": "Nécessaire uniquement pour les modèles protégés.", + "evalEnable": "Évaluer pendant l'entraînement", + "evalStrategy": "Stratégie d'évaluation", + "evalSteps_": "Pas", + "evalEpoch": "Époque", + "evalSteps": "Pas d'évaluation", + "evalStepsHint": "0 correspond aux pas de sauvegarde.", + "evalSplit": "Découpage pour l'évaluation", + "evalDataset": "Jeu de données d'évaluation", + "evalDatasetPlaceholder": "Un jeu de données séparé", + "evalRatio": "Ratio de découpage automatique", + "rewards": "Fonctions de récompense (GRPO)", + "rewardsHint": "GRPO nécessite au moins une fonction de récompense. Choisissez les fonctions intégrées ou ajoutez les vôtres.", + "resume": "Reprendre depuis un checkpoint", + "clearCheckpoint": "Effacer le checkpoint", + "extra": "Options supplémentaires", + "extraHint": "Paires clé-valeur propres au backend.", + "summaryFull": "modèle complet", + "summary_lora": "adaptateur LoRA", + "summary_loha": "adaptateur LoHa", + "summary_lokr": "adaptateur LoKr", + "recipe": "Entraîner un {{kind}} de {{model}} avec {{method}}, {{epochs}} époques sur {{data}}.", + "recipeEmpty": "Choisissez un modèle et un jeu de données pour voir ce qui sera entraîné." + }, + "rewards": { + "format_reward": "Vérifie le format ... suivi de la réponse.", + "reasoning_accuracy_reward": "Compare le contenu de à la colonne de réponses du jeu de données.", + "length_reward": "Note la proximité de la réponse avec une longueur cible.", + "xml_tag_reward": "Note les balises XML correctement ouvertes et fermées.", + "no_repetition_reward": "Pénalise les n-grammes répétés.", + "code_execution_reward": "Vérifie que les blocs de code Python ont une syntaxe valide.", + "param_target_length": "Longueur cible", + "fnName": "Nom de la fonction", + "fnBody": "Corps de la fonction", + "fnHint": "Reçoit completions et kwargs et doit renvoyer une liste de flottants. re, math, json et string sont disponibles.", + "add": "Ajouter", + "cancel": "Annuler", + "custom": "Ajouter une fonction de récompense personnalisée" + }, + "fineTune": { + "title": "Fine-tuning", + "lede": "Entraînez un adaptateur sur vos propres données, puis exportez-le comme modèle avec lequel vous pouvez discuter.", + "importConfig": "Importer la configuration", + "exportConfig": "Exporter la configuration", + "newJob": "Nouvelle tâche", + "badConfig": "Impossible de lire ce fichier. Utilisez une configuration JSON exportée depuis cette page.", + "start": "Lancer le fine-tuning", + "starting": "Démarrage...", + "resume": "Reprendre l'entraînement", + "resuming": "Reprise depuis le checkpoint :", + "clear": "Effacer", + "barNote": "Rien n'est modifié tant que vous ne lancez pas.", + "jobMeta": "{{kind}} {{method}} sur {{backend}}, démarré {{started}}", + "failed": "Échec de l'entraînement", + "failedAt": "Entraînement arrêté à l'étape {{step}} sur {{total}}", + "noMessage": "Le backend n'a donné aucun message.", + "finished": "Entraînement terminé.", + "finishedLoss": "Entraînement terminé. La dernière perte était {{loss}}.", + "stopped": "La tâche a été arrêtée. Un checkpoint est conservé si vous l'avez choisi.", + "tryTitle": "Que faire ensuite", + "tryText": "Le serveur indique que la mémoire est épuisée. Chaque modification ci-dessous est appliquée à une copie de la configuration, et rien ne démarre tant que vous n'appuyez pas sur Lancer.", + "tryPlain": "Rouvrez la configuration, modifiez ce que le message indique et lancez une nouvelle tâche.", + "fixBatch": "Réduire la taille de batch à 1", + "fixCheckpointing": "Activer le gradient checkpointing (plus lent, utilise moins de mémoire)", + "retryChanges": "Ouvrir la configuration avec ces modifications", + "reuse": "Rouvrir la configuration", + "resumeFrom": "Reprendre à partir de l'étape {{step}}", + "status": { + "queued": "En file d'attente", + "loading_model": "Chargement du modèle", + "loading_dataset": "Chargement du jeu de données", + "training": "Entraînement", + "saving": "Sauvegarde", + "completed": "Terminée", + "failed": "Échouée", + "stopped": "Arrêtée" + } + }, + "stop": { + "title": "Arrêter l'entraînement ?", + "text": "Un checkpoint sauvegardé maintenant permet de reprendre ou d'exporter ce qui a été appris jusqu'ici.", + "keep": "Arrêter et garder un checkpoint", + "discard": "Arrêter sans checkpoint", + "cancel": "Continuer l'entraînement", + "cancelDelete": "Annuler" + }, + "chart": { + "loss": "Perte", + "learning_rate": "Taux d'apprentissage", + "grad_norm": "Norme du gradient", + "metric": "Métrique", + "step": "étape", + "eval": "Perte d'évaluation", + "waiting": "En attente de plus d'étapes.", + "window": "Étapes {{from}} à {{to}} sur {{total}}", + "windowEval": "Étapes {{from}} à {{to}} sur {{total}}. Les points vides sont la perte d'évaluation.", + "aria": "{{metric}} au fil des étapes d'entraînement. La dernière valeur est {{last}} à l'étape {{step}}.", + "readout": "étape {{step}} : {{value}}", + "dataTitle": "Afficher les valeurs", + "emptyText": "Le graphique se trace lorsque la tâche a signalé deux étapes." + }, + "log": { + "title": "Journal", + "note": "Ce que la tâche a signalé depuis l'ouverture de cette page.", + "only": "Avertissements et erreurs uniquement", + "copy": "Copier", + "copied": "Copié", + "download": "Télécharger", + "empty": "Rien n'a encore été signalé.", + "noneFiltered": "Aucun avertissement ni erreur." + }, + "checkpoints": { + "title": "Checkpoints", + "step": "Étape", + "epoch": "Époque", + "loss": "Perte", + "path": "Chemin", + "actions": "Actions", + "resume": "Reprendre", + "resumeTitle": "Reprendre l'entraînement depuis ce checkpoint", + "export": "Exporter", + "exportTitle": "Exporter ce checkpoint" + }, + "export": { + "title": "Exporter comme modèle", + "lede": "L'export transforme le résultat en modèle que LocalAI peut charger.", + "checkpoint": "Checkpoint", + "final": "Modèle final (dossier de sortie)", + "checkpointOption": "Étape {{step}} (perte {{loss}})", + "format": "Format d'export", + "lora": "Adaptateur LoRA", + "merged16": "Fusionné (16 bits)", + "merged4": "Fusionné (4 bits)", + "quant": "Quantification", + "name": "Nom du modèle", + "nameHint": "Laissez vide pour en générer un à partir de la tâche.", + "go": "Exporter", + "exporting": "Export en cours...", + "working": "Export en cours...", + "done": "Modèle exporté et enregistré sous « {{name}} »", + "failed": "Échec de l'export : {{message}}", + "unknown": "erreur inconnue", + "nextTitle": "{{name}} est maintenant un modèle", + "nextText": "Il est enregistré dans LocalAI. Ouvrez une discussion avec lui, ou retrouvez-le dans Modèles.", + "chat": "Discuter avec {{name}}", + "models": "Ouvrir dans Modèles", + "archive": "Télécharger l'archive" + }, + "jobs": { + "title": "Tâches précédentes", + "count_one": "{{count}} conservée sur ce serveur", + "count_other": "{{count}} conservées sur ce serveur", + "loading": "Chargement des tâches", + "emptyTitle": "Aucune tâche de fine-tuning pour le moment", + "emptyText": "Configurez-en une ci-dessus. Elle apparaît ici avec son état, et vous pourrez réutiliser sa configuration plus tard.", + "model": "Modèle", + "kind": "Type", + "status": "État", + "started": "Démarré", + "actions": "Actions", + "open": "Ouvrir cette tâche", + "reuse": "Réutiliser", + "reuseTitle": "Utiliser la configuration de cette tâche pour une nouvelle tâche", + "delete": "Supprimer", + "deleteTitle": "Supprimer cette tâche et ses données", + "deleteDialogTitle": "Supprimer cette tâche ?", + "deleteDialogText": "Cela supprime la tâche avec ses checkpoints et tout modèle exporté. Action irréversible." + }, + "quantize": { + "title": "Quantifier", + "lede": "Réduisez la taille d'un modèle pour qu'il tienne dans moins de mémoire. Le résultat est un fichier GGUF que vous pouvez importer.", + "newJob": "Nouvelle tâche", + "sectionModel": "Modèle", + "model": "Modèle", + "modelHint": "Un identifiant Hugging Face, ou un chemin sur cette machine.", + "sectionPrecision": "Précision", + "type": "Type de quantification", + "typeHint": "Un nombre plus petit signifie un fichier plus petit et une qualité un peu moindre.", + "custom": "Personnalisé...", + "customLabel": "Type personnalisé", + "customPlaceholder": "par exemple iq4_nl", + "moreHint": "backend, jeton Hugging Face", + "tokenHint": "Nécessaire uniquement pour les modèles protégés.", + "recipe": "Convertir {{model}} en GGUF en {{type}}.", + "recipeEmpty": "Saisissez un modèle pour voir ce qui sera créé.", + "start": "Quantifier ({{type}})", + "barNote": "Rien n'est modifié tant que vous ne lancez pas.", + "jobMeta": "{{type}} sur {{backend}}, démarré {{started}}", + "failed": "Échec de la quantification", + "finished": "Quantification terminée : {{type}}.", + "stopped": "La tâche a été arrêtée.", + "tryText": "Rouvrez la configuration, modifiez ce que le message indique et lancez une nouvelle tâche. Un backend plus récent peut prendre en charge une architecture que l'ancien ne gère pas.", + "outputTitle": "Le résultat", + "outputText": "Un fichier GGUF en {{type}}. Importez-le pour l'utiliser dans LocalAI, ou téléchargez-le.", + "file": "Fichier", + "importName": "Nom du modèle", + "importNamePlaceholder": "Généré si vide", + "import": "Importer dans LocalAI", + "importing": "Importation...", + "importFailed": "Échec de l'importation", + "importFailedWith": "Échec de l'importation : {{message}}", + "download": "Télécharger le GGUF", + "nextTitle": "{{name}} est maintenant un modèle", + "nextText": "Il est enregistré dans LocalAI. Ouvrez une discussion avec lui, ou retrouvez-le dans Modèles.", + "chat": "Discuter avec {{name}}", + "models": "Ouvrir dans Modèles", + "imported": "importé sous le nom {{name}}", + "emptyTitle": "Aucune tâche de quantification pour le moment", + "emptyText": "Configurez-en une ci-dessus. Elle apparaît ici avec son état.", + "deleteDialogText": "Cela supprime la tâche avec ses fichiers convertis. Action irréversible.", + "startFailed": "Échec du lancement de la tâche", + "stopFailed": "Échec de l'arrêt de la tâche", + "deleteFailed": "Échec de la suppression de la tâche", + "status": { + "queued": "En file d'attente", + "downloading": "Téléchargement", + "converting": "Conversion", + "quantizing": "Quantification", + "completed": "Terminée", + "failed": "Échouée", + "stopped": "Arrêtée" + } + }, + "off": { + "badge": "Non disponible pour vous", + "fineTune": { + "title": "Votre compte ne peut pas faire de fine-tuning", + "text": "Le fine-tuning est une autorisation distincte, que ce compte n'a pas." + }, + "quantize": { + "title": "Votre compte ne peut pas quantifier", + "text": "La quantification est une autorisation distincte, que ce compte n'a pas." + }, + "admin": "Un administrateur peut l'activer dans Utilisateurs et clés.", + "askAdmin": "Demandez à un administrateur de l'activer pour votre compte.", + "back": "Aller à l'accueil" + } +} diff --git a/core/http/react-ui/public/locales/fr/traffic.json b/core/http/react-ui/public/locales/fr/traffic.json new file mode 100644 index 000000000..e3b785c47 --- /dev/null +++ b/core/http/react-ui/public/locales/fr/traffic.json @@ -0,0 +1,12 @@ +{ + "overview": { + "usageErrorTitle": "L'utilisation n'a pas pu être lue." + }, + "host": { + "errorTitle": "L'hôte n'a pas pu être lu." + }, + "models": { + "noMatch": "Aucun modèle ne correspond à « {{query}} »." + }, + "clearSearch": "Effacer la recherche" +} diff --git a/core/http/react-ui/public/locales/fr/upgrade.json b/core/http/react-ui/public/locales/fr/upgrade.json new file mode 100644 index 000000000..ade0f6444 --- /dev/null +++ b/core/http/react-ui/public/locales/fr/upgrade.json @@ -0,0 +1,46 @@ +{ + "badge": { + "available": "Mise à jour disponible", + "availableVersion": "Mise à jour disponible : {{version}}" + }, + "trigger": { + "all": "Tout mettre à jour ({{count}})", + "running": "Mise à jour {{current}} sur {{total}}", + "summary": "{{done}} mis à jour, {{failed}} en échec, {{skipped}} ignorés", + "summaryOk": "{{done}} mis à jour", + "review": "Consulter" + }, + "dialog": { + "title": "Tout mettre à jour", + "intro": "Choisissez ce qu'il faut mettre à jour. Chaque élément est mis à jour séparément, l'un après l'autre. Un échec n'arrête pas les autres.", + "selection": "Sélection", + "selectAll": "Tout sélectionner", + "selectNone": "Tout désélectionner", + "selectedCount_one": "{{count}} sélectionné", + "selectedCount_other": "{{count}} sélectionnés", + "item": "Mettre à jour {{name}}", + "versions": "{{from}} vers {{to}}", + "blocked": "Ne peut pas démarrer", + "start": "Mettre à jour la sélection", + "starting": "Mise à jour...", + "retry": "Réessayer les échecs ({{count}})", + "again": "Nouvelle sélection", + "close": "Fermer", + "closeRunning": "Fermer (continue en arrière-plan)", + "runningNote": "Fermer cette fenêtre n'arrête pas les mises à jour.", + "progress": "Mise à jour {{current}} sur {{total}}", + "summary": "{{done}} mis à jour, {{failed}} en échec, {{skipped}} ignorés", + "empty": "Rien à mettre à jour." + }, + "item": { + "queued": "En attente", + "upgrading": "Mise à jour", + "done": "Terminé", + "failed": "Échec", + "skipped": "Ignoré" + }, + "reason": { + "checkAgain": "Vérifier à nouveau", + "checking": "Vérification..." + } +} diff --git a/core/http/react-ui/public/locales/fr/workspace.json b/core/http/react-ui/public/locales/fr/workspace.json new file mode 100644 index 000000000..db6330ddd --- /dev/null +++ b/core/http/react-ui/public/locales/fr/workspace.json @@ -0,0 +1,671 @@ +{ + "wellKnown": { + "revert": "Revenir à la version précédente", + "view": "Affichage", + "form": "Formulaire", + "save": "Enregistrer", + "cancel": "Annuler", + "notSet": "Non défini", + "moveUp": "Monter", + "moveDown": "Descendre", + "remove": "Retirer", + "savedAt": "Enregistré à {{time}}", + "notSaved": "Pas encore enregistré.", + "notSavedPrefilled": "Pas encore enregistré. Certaines valeurs sont déjà remplies ; Enregistrer les conserve.", + "loadFailed": "Impossible de charger ces réglages. Rechargez la page pour réessayer.", + "invalid": "Ces réglages contiennent des erreurs, et il n’existe pas de version valide antérieure.", + "invalidFallback": "Ces réglages contiennent des erreurs. Leur dernière version valide est affichée.", + "conflict": "Ces réglages ont changé pendant votre modification, probablement par l’assistant ou dans un autre onglet. Les valeurs les plus récentes sont :", + "useTheirs": "Utiliser les valeurs les plus récentes", + "keepMine": "Garder mes modifications", + "editWithAssistant": "Modifier avec l'assistant", + "from": { + "account": "Depuis votre compte", + "browser": "Depuis votre navigateur" + }, + "check": { + "required": "Obligatoire.", + "tooLong": "{{max}} caractères au maximum.", + "format": "Cette valeur a un format incorrect." + }, + "source": { + "label": "Source", + "hint": "Les réglages sous forme de texte. Ils sont vérifiés de la même façon à l’enregistrement.", + "notJson": "Le texte contient une erreur de syntaxe. Vérifiez les guillemets, les virgules et les accolades.", + "notObject": "Le texte doit commencer par { et se terminer par }." + }, + "persona": { + "title": "Assistant", + "lede": "Nom et instructions que l'assistant utilise dans chaque chat." + }, + "user": { + "title": "Vous", + "lede": "L'assistant lit ceci pour savoir à qui il parle. Cela parvient au modèle dans chaque chat." + }, + "ui": { + "title": "Apparence", + "lede": "L'apparence de LocalAI pour vous. Les changements s'affichent immédiatement ; Enregistrer les conserve." + }, + "shortcuts": { + "title": "Raccourcis", + "lede": "Les suggestions au-dessus de la zone de saisie de chaque chat.", + "edit": "Modifier les raccourcis", + "defaults": "Raccourcis par défaut", + "none": "Aucun raccourci. Ajoutez-en un, ou enregistrez une liste vide pour les masquer.", + "label": "Libellé", + "prompt": "Prompt", + "send": "Envoyer immédiatement", + "add": "Ajouter un raccourci", + "invalid": "Certains raccourcis contiennent des erreurs.", + "assistantPrompt": "Modifie mes raccourcis : " + }, + "home": { + "editLayout": "Modifier la mise en page", + "ask": "Demander à l'assistant", + "askPrompt": "Modifie ma page Home : ", + "add": "Ajouter un widget", + "newTitle": "Nouveau widget", + "noWidgets": "Aucun widget pour l'instant.", + "type": "Type", + "title": "Titre", + "source": "Fichier de données", + "noSourceNeeded": "Aucun fichier de données requis", + "types": { + "list": "Liste", + "metric": "Indicateur", + "chats": "Derniers chats", + "page": "Page" + }, + "left": "{{count}} restant(s)", + "fromChats": "des chats", + "noChats": "Aucun chat pour l'instant.", + "noData": "Pas encore de données. L'assistant peut écrire", + "nothing": "Rien ici.", + "noSource": "Ce widget n'a pas de fichier de données. Choisissez-en un dans Modifier la mise en page.", + "broken": "Les données de ce widget contiennent des erreurs, et il n’existe pas de version valide antérieure.", + "stale": "Les dernières données valides sont affichées. Les données les plus récentes contiennent des erreurs.", + "updated": "Mis à jour {{when}}", + "footer": { + "noData": "Pas encore de données.", + "invalid": "Les données contiennent une erreur. La dernière bonne version est affichée.", + "revert": "Rétablir", + "missing": "Les données de ce widget sont manquantes.", + "fix": "Demander à l’assistant de le corriger", + "fixPrompt": "Le widget de l’accueil lit {{file}}, qui est manquant ou cassé. Corrige-le : ", + "waiting": "En attente que vous créiez son déclencheur dans le chat.", + "openChat": "Ouvrir le chat", + "running": "Mise à jour en cours…", + "openRun": "Ouvrir l’exécution", + "paused": "En pause · était {{schedule}}", + "resume": "Reprendre", + "failedEmpty": "La mise à jour a échoué et il n’y a pas encore de données.", + "details": "Détails", + "retry": "Réessayer", + "stale": "La dernière mise à jour a échoué. Données de {{when}} affichées.", + "staleToday": "La dernière mise à jour a échoué. Données antérieures d’aujourd’hui affichées.", + "earlier": "avant", + "firstRun": "Première exécution {{when}}", + "firstRunFile": "S’exécute quand un fichier arrive", + "runNow": "Exécuter maintenant", + "fresh": "{{schedule}} · mis à jour {{time}}", + "pause": "Pause", + "fromPage": "De la page {{title}}", + "fromFile": "De vos fichiers", + "next": "Prochaine exécution {{when}}", + "cost": "environ {{tokens}} jetons par exécution", + "costRemote": "environ {{tokens}} jetons par exécution sur un modèle distant", + "cadence": { + "paused": "En pause", + "hourly": "Toutes les heures", + "daily": "Quotidien", + "weekdays": "Jours ouvrés", + "weekly": "Hebdomadaire", + "monthly": "Mensuel", + "file": "À chaque nouveau fichier" + }, + "scheduleNote_one": "{{count}} widget se met à jour selon un horaire. Ses exécutions utilisent vos modèles et comptent dans votre utilisation.", + "scheduleNote_other": "{{count}} widgets se mettent à jour selon un horaire. Leurs exécutions utilisent vos modèles et comptent dans votre utilisation." + }, + "opensSite": "{{host}} : s'ouvre dans un nouvel onglet", + "linkAsk": "Ce widget veut ouvrir un lien", + "invalid": "La disposition de votre Home contient des erreurs, et il n’existe pas de version valide antérieure.", + "invalidFallback": "La disposition de votre Home contient des erreurs. Home affiche sa dernière version valide.", + "page": { + "gone": "Cette page n'est plus disponible", + "goneHint": "La page \"{{name}}\" a été déplacée ou supprimée, ou vous ne pouvez plus l'ouvrir.", + "noWidget": "Cette page n'a pas de vue widget", + "noWidgetHint": "Demandez à l’assistant de donner à la page « {{name}} » une vue compacte pour l’accueil.", + "off": "Les pages partagées sont désactivées", + "offHint": "Un administrateur a désactivé les pages partagées sur ce serveur.", + "fix": "Personnaliser l'accueil", + "failed": "La page ne s'est pas chargée", + "retry": "Réessayer", + "changed": "Cette page a changé depuis que vous l'avez autorisée", + "notAllowed": "Vous n'avez pas encore autorisé cette page", + "allowHint": "Elle vient d'un dossier partagé. Vérifiez-la avant qu'elle s'exécute en votre nom.", + "allow": "Autoriser cette page", + "frameTitle": "{{title}} (page sur l'accueil)", + "refresh": "Actualiser", + "open": "Ouvrir la page", + "add": "Ajouter une page", + "limit": "L'accueil affiche au plus {{max}} pages ; retirez-en une pour en ajouter une autre.", + "noneEligible": "Aucune page n’a encore de vue compacte pour l’accueil. Demandez à l’assistant d’en ajouter une à une page.", + "pick": "Page", + "unavailable": "{{name}} (indisponible)", + "sharedLabel": "partagée", + "sizes": { + "small": "Petit", + "medium": "Moyen", + "large": "Grand" + } + } + }, + "fields": { + "persona": { + "name": { + "label": "Nom", + "help": "Le nom que l'assistant se donne." + }, + "system_prompt": { + "label": "Prompt système", + "help": "Ajouté à chaque chat, après le prompt du chat et les instructions de l'agent." + } + }, + "user": { + "name": { + "label": "Nom", + "help": "Le nom sous lequel l'assistant vous appelle." + }, + "language": { + "label": "Langue", + "help": "La langue dans laquelle l'assistant répond." + }, + "bio": { + "label": "À propos de vous", + "help": "Quelques lignes que l'assistant lit dans chaque chat." + }, + "timezone": { + "label": "Fuseau horaire", + "help": "Pour les dates et heures dans les réponses." + } + }, + "ui": { + "theme": { + "label": "Thème", + "help": "Système suit le réglage de votre appareil.", + "options": { + "dark": "Sombre", + "light": "Clair", + "system": "Système" + } + }, + "accent": { + "label": "Couleur d'accent", + "help": "Pour les boutons et liens. Vide garde la valeur par défaut." + }, + "density": { + "label": "Densité", + "help": "Compact affiche plus de contenu sur un écran.", + "options": { + "comfortable": "Confortable", + "compact": "Compact" + } + }, + "defaultModel": { + "label": "Modèle par défaut", + "help": "Le modèle avec lequel un nouveau chat démarre." + } + } + }, + "saveInvalid": "Certaines valeurs ne sont pas valides. Vérifiez-les et enregistrez à nouveau.", + "saveTooLarge": "Ces réglages sont trop volumineux pour être enregistrés. Supprimez quelques entrées et réessayez.", + "saveFailed": "Impossible d’enregistrer. Réessayez.", + "details": "Détails" + }, + "discovery": { + "ex": { + "title": "Faites de LocalAI le vôtre", + "lede": "Dites-le dans un chat. L’assistant l’enregistre, et vous pouvez le modifier ou l’annuler.", + "hide": "Masquer", + "hiddenToast": "Exemples masqués.", + "saveFailed": "Non enregistré : {{message}}", + "card1": { + "kicker": "Vous et l’assistant", + "prompt": "Appelez-moi {{firstName}} et gardez des réponses courtes", + "promptNoName": "Retenez mon prénom et gardez des réponses courtes", + "what": "Enregistre votre nom et la façon dont l’assistant répond" + }, + "card2": { + "kicker": "Une page", + "prompt": "Créez-moi un tableau de tâches", + "what": "Ajoute une page à ouvrir depuis la barre latérale" + }, + "card3": { + "kicker": "Chaque matin", + "prompt": "Chaque matin, résumez ici ma boîte de réception", + "promptNoMail": "Chaque matin, listez ici les chats d’hier", + "what": "Ajoute un widget Home et un déclencheur que vous confirmez" + }, + "quote": "« {{text}} »", + "donePrefix": "Terminé.", + "doneLink": "Modifier dans Account", + "ready": "Ou partez d’une page prête :", + "readyTasks": "Tableau de tâches", + "readyReading": "Liste de lecture", + "readyFailed": "La page n’a pas été copiée : {{message}}", + "learn": "Comment ça marche", + "inComposer": "Modifiez-le si vous voulez, puis appuyez sur Entrée. Rien n’est enregistré tant que vous n’envoyez pas.", + "live": "Texte ajouté à la zone de message." + }, + "home": { + "customise": "Personnaliser Home" + }, + "action": { + "undo": "Annuler", + "openAccount": "Ouvrir dans Account", + "openPage": "Ouvrir la page", + "openHome": "Ouvrir Home", + "turnBackOn": "Les réactiver dans Account" + }, + "change": { + "profilePersona": "Votre nom et vos instructions pour l’assistant sont enregistrés. Chaque chat les utilise dès votre prochain message.", + "profile": "Enregistré dans votre profil.", + "persona": "Les instructions de l’assistant sont enregistrées. Chaque chat les utilise dès votre prochain message.", + "shortcuts": "Vos raccourcis sont enregistrés.", + "home": "Votre page Home est enregistrée. Home affiche le changement.", + "ui": "Vos réglages d’apparence sont enregistrés.", + "pageCreated": "La page {{title}} a été créée.", + "pageChanged": "La page {{title}} a été modifiée.", + "offersOff": "Les suggestions de l’assistant sont désactivées.", + "undoing": "Annulation…", + "undone": "Annulé.", + "undoFailed": "Non annulé : {{message}}" + }, + "toast": { + "shortcutAdded": "Ajouté à votre zone de saisie : {{label}}", + "pageCreated": "{{title}} est dans Pages, dans la barre latérale", + "open": "Ouvrir", + "homeChanged": "Home mis à jour.", + "themeChanged": "Thème modifié." + }, + "files": { + "learn": "Ces fichiers changent le fonctionnement de LocalAI pour vous.", + "tag": { + "profile": "Votre profil", + "persona": "Assistant", + "shortcuts": "Raccourcis", + "home": "Disposition de Home", + "ui": "Apparence", + "page": "Page" + } + }, + "privacy": "L’assistant reçoit votre nom, ce texte, votre langue et votre fuseau horaire au début de chaque chat. Laissez un champ vide pour le garder privé.", + "versions": { + "label": "Versions", + "title": "Versions précédentes", + "loading": "Lecture de l’historique…", + "none": "Aucune version antérieure pour l’instant.", + "current": "actuelle", + "invalid": "contient une erreur", + "byAssistant": "Par l’assistant", + "byAssistantIn": "Par l’assistant dans « {{chat}} »", + "byYou": "Par vous", + "undo": "Une annulation", + "upload": "Importé", + "revert": "Restaurer", + "note": "Restaurer conserve la version plus récente dans cette liste.", + "invalidBadge": "Invalide", + "revertGood": "Restaurer la dernière bonne version" + }, + "help": { + "title": "Aide et suggestions", + "sub": "Les façons dont LocalAI vous montre ce qu’il sait faire. Désactivez ce dont vous n’avez pas besoin.", + "offers": "Propositions de l’assistant", + "offersHint": "Une par jour au plus, jamais pendant une tâche : par exemple, pour retenir ce que vous lui avez dit.", + "new": "Notes sur les nouveautés", + "newHint": "Une courte étiquette « Nouveau » dans la barre latérale la première fois que quelque chose apparaît.", + "examples": "Exemples sur Home", + "examplesShown": "Les cartes « Faites de LocalAI le vôtre » s’affichent sur Home tant qu’il vous reste quelque chose à essayer.", + "examplesHidden": "Vous avez masqué les cartes « Faites de LocalAI le vôtre ».", + "showExamples": "Afficher de nouveau les exemples", + "examplesBack": "Les exemples sont de retour sur Home.", + "dismissed": "Ce que l’assistant a appris", + "dismissedHint_one": "Vous avez refusé {{count}} proposition. Elle reste masquée jusqu’à la réinitialisation.", + "dismissedHint_other": "Vous avez refusé {{count}} propositions. Elles restent masquées jusqu’à la réinitialisation.", + "forget": "Oublier ce que l’assistant a appris de mes suggestions", + "forgotten": "Terminé. L’assistant pourra proposer de nouveau.", + "learn": "Découvrez ce que vous pouvez modifier :", + "dismissedNone": "Rien pour l’instant. Quand vous répondez à une proposition, l’assistant s’en souvient ici." + } + }, + "tabs": { + "shared": "Partagé", + "trashCount_one": "{{count}} élément dans la Corbeille", + "trashCount_other": "{{count}} éléments dans la Corbeille" + }, + "trash": { + "empty": "La corbeille est vide.", + "restore": "Restaurer", + "emptyAll": "Vider la corbeille", + "purge": "Supprimer définitivement", + "purgeNamed": "Supprimer {{name}} définitivement", + "files_one": "{{count}} fichier", + "files_other": "{{count}} fichiers", + "folders_one": "{{count}} dossier", + "folders_other": "{{count}} dossiers", + "listJoin": ", ", + "summary": "Dans la corbeille : {{what}}.", + "removed": "Supprimé définitivement : {{what}}.", + "confirmAll": { + "title": "Vider la corbeille ?", + "message": "Cela supprime définitivement {{what}}, avec leurs versions précédentes. Cette action est irréversible." + }, + "confirmOne": { + "title": "Supprimer définitivement ?", + "message": "{{path}} : cela supprime définitivement {{what}}, avec leurs versions précédentes. Cette action est irréversible." + } + }, + "shared": { + "adminTitle": "Dossiers partagés", + "adminLede": "Des dossiers que plusieurs personnes utilisent. Les membres ouvrent leurs pages, et leurs discussions peuvent lire ou modifier les fichiers.", + "userTitle": "Partagé avec vous", + "userLede": "Les dossiers qu’un administrateur a partagés avec vous. Leurs pages sont dans Pages, et vos discussions peuvent utiliser les fichiers.", + "new": "Nouveau dossier partagé", + "nameLabel": "Nom du dossier", + "nameHint": "Jusqu’à 64 lettres, chiffres, espaces, points, tirets ou soulignés. Commencez par une lettre ou un chiffre.", + "create": "Créer", + "cancel": "Annuler", + "rename": "Renommer", + "renameSave": "Enregistrer", + "remove": "Retirer", + "manage": "Membres", + "menu": "Autres actions pour {{name}}", + "pages_one": "{{count}} page", + "pages_other": "{{count}} pages", + "pagesNone": "Aucune page", + "people_one": "{{count}} personne", + "people_other": "{{count}} personnes", + "membersNone": "Aucun membre", + "everyone": "Tout le monde", + "everyoneOption": "Tout le monde (tous les utilisateurs)", + "everyoneHint": "Tous les utilisateurs, y compris ceux qui arrivent plus tard", + "everyoneCan": { + "read": "Tout le monde peut lire", + "write": "Tout le monde peut modifier" + }, + "mode": { + "read": "Peut lire", + "write": "Peut modifier" + }, + "addTitle": "Ajouter un membre", + "person": "Personne", + "access": "Accès", + "accessFor": "Accès de {{name}}", + "add": "Ajouter", + "allAdded": "Tout le monde est déjà membre.", + "usersUnavailable": "La liste des utilisateurs n’a pas pu être chargée. Vous pouvez quand même ajouter Tout le monde.", + "membersTitle": "Membres", + "noMembers": "Personne ne peut encore utiliser ce dossier. Ajoutez une personne ou Tout le monde.", + "removeMember": "Retirer {{name}}", + "paneNote": "Un changement s’applique tout de suite. Une discussion ouverte le voit à sa prochaine ouverture.", + "created": "« {{name}} » créé. Ajoutez les personnes qui peuvent l’utiliser.", + "removed": "« {{name}} » retiré. Ses pages redemandent l’accord avant de s’exécuter.", + "restored": "« {{name}} » est de retour, avec ses membres.", + "memberAdded": "{{name}} : {{mode}}.", + "memberRemoved": "{{name}} ne peut plus utiliser « {{folder}} ».", + "failed": "Non effectué : {{message}}", + "loadFailed": "Impossible de charger les dossiers partagés.", + "emptyAdminTitle": "Aucun dossier partagé pour l’instant", + "emptyAdminBody": "Un dossier partagé contient des fichiers et des pages que d’autres personnes peuvent lire ou modifier. Créez-en un, puis ajoutez des membres.", + "factMembers": "Donnez à une personne ou à tout le monde l’accès en lecture ou en modification", + "factChats": "Les discussions des membres voient les fichiers du dossier", + "factUndo": "Le retrait d’un dossier peut être annulé", + "emptyUserTitle": "Rien n’est encore partagé avec vous", + "emptyUserBody": "Quand un administrateur partage un dossier avec vous, il apparaît ici et ses pages dans Pages." + }, + "preview": { + "label": "Aperçu", + "failed": "Ce fichier n'a pas pu être affiché.", + "loading": "Chargement de l’aperçu de {{name}}", + "failedTitle": "Impossible d’afficher {{name}}.", + "tooLarge": "Ce fichier est trop volumineux pour l’aperçu. Téléchargez-le pour l’ouvrir.", + "none": "Aucun aperçu pour ce type de fichier.", + "typeFile": "Fichier {{ext}}", + "typeUnknown": "Fichier", + "sheets": "Feuilles", + "sheetLabel": "Feuille {{name}}", + "sheetEmpty": "Cette feuille est vide.", + "row": "Ligne", + "showingRows": "{{rows}} lignes affichées sur {{total}}", + "showingCols": "et {{cols}} colonnes sur {{total}}.", + "downloadRest": "Téléchargez le fichier pour le reste.", + "textOnly": "Aperçu du texte, mise en forme non affichée.", + "truncated": "Le texte est tronqué.", + "noText": "Ce fichier ne contient pas de texte à afficher.", + "slide": "Diapositive {{n}}", + "notes": "Notes de l’orateur", + "pdfTitle": "PDF {{name}}", + "pdfFallback": "Si le PDF ne s’affiche pas ici, téléchargez-le.", + "htmlTitle": "Aperçu de {{name}}", + "imageFailed": "L’image n’a pas pu être affichée.", + "zoomIn": "Afficher en taille réelle", + "zoomOut": "Ajuster au panneau", + "items_one": "{{count}} élément", + "items_other": "{{count}} éléments", + "textCut": "Les premiers {{size}} sont affichés.", + "actions": "Actions du fichier" + }, + "managed": { + "group": "Géré par LocalAI", + "marker": "géré par LocalAI", + "about": { + "localai": "Réglages que LocalAI lit : persona, profil, raccourcis, Accueil, thème.", + "chats": "Un dossier par discussion, avec les fichiers de la discussion." + }, + "confirm": { + "deleteTitle": "Mettre {{name}} à la corbeille ?", + "renameTitle": "Renommer {{name}} ?", + "delete": "Mettre à la corbeille", + "rename": "Renommer", + "restore": "Vous pouvez le restaurer depuis la corbeille.", + "renameNote": "LocalAI ne le lit que sous ce nom : après un renommage, c'est comme s'il avait disparu." + }, + "loses": { + "settings": "LocalAI oublie votre persona, votre profil, vos raccourcis, la disposition de votre Accueil et votre thème.", + "chats": "Chaque discussion perd ses fichiers : ce que vous avez joint et ce que l'assistant y a écrit.", + "persona": "L'assistant perd son nom et ses instructions.", + "user": "L'assistant oublie ce que vous lui avez dit sur vous et vos réponses à ses propositions.", + "shortcuts": "La zone de message affiche de nouveau les raccourcis par défaut.", + "home": "L'Accueil perd ses widgets.", + "ui": "Votre thème, couleur d'accent, densité, modèle par défaut et pages épinglées reviennent aux valeurs par défaut." + } + }, + "newFile": "Nouveau fichier", + "newFolderName": "Nouveau dossier", + "newFileName": "Sans titre.md", + "fileName": "Nom du fichier", + "downloadOne": "Télécharger {{name}}", + "moveTo": "Déplacer vers…", + "uploadMenu": "Autres options d’import", + "uploadFiles": "Importer des fichiers", + "uploadFolder": "Importer un dossier", + "loading": "Chargement du dossier", + "loadFailed": "Impossible d’ouvrir {{folder}}.", + "types": { + "folder": "Dossier", + "image": "Image", + "pdf": "PDF", + "sheet": "Feuille de calcul", + "doc": "Document", + "slides": "Présentation", + "archive": "Archive", + "audio": "Audio", + "video": "Vidéo", + "code": "Code", + "text": "Texte", + "file": "Fichier" + }, + "columns": { + "name": "Nom", + "modified": "Modifié", + "type": "Type", + "size": "Taille", + "actions": "Actions" + }, + "view": { + "label": "Affichage", + "list": "Liste", + "grid": "Miniatures" + }, + "select": { + "one": "Sélectionner {{name}}", + "all": "Tout sélectionner", + "bar": "Éléments sélectionnés", + "clear": "Effacer la sélection", + "count_one": "{{count}} sélectionné", + "count_other": "{{count}} sélectionnés", + "start": "Sélectionner" + }, + "menu": { + "label": "Actions pour {{name}}", + "more": "Plus d’actions pour {{name}}", + "openFolder": "Ouvrir", + "preview": "Aperçu", + "downloadMany_one": "Télécharger {{count}} fichier", + "downloadMany_other": "Télécharger {{count}} fichiers", + "moveMany_one": "Déplacer {{count}} élément vers…", + "moveMany_other": "Déplacer {{count}} éléments vers…", + "deleteMany_one": "Déplacer {{count}} élément vers la Corbeille", + "deleteMany_other": "Déplacer {{count}} éléments vers la Corbeille" + }, + "keys": { + "delete": "Suppr" + }, + "crumbs": { + "hidden": "Afficher les dossiers parents" + }, + "naming": { + "empty": "Saisissez un nom.", + "slash": "Un nom ne peut pas contenir / ni \\.", + "taken": "Un élément porte déjà ce nom dans ce dossier." + }, + "toast": { + "undo": "Annuler", + "deletedOne": "{{name}} a été déplacé vers la Corbeille.", + "deleted_one": "{{count}} élément a été déplacé vers la Corbeille.", + "deleted_other": "{{count}} éléments ont été déplacés vers la Corbeille.", + "restored": "Restauré.", + "renamed": "Renommé en {{name}}.", + "renameUndone": "Nom d’origine rétabli.", + "movedOne": "{{name}} a été déplacé vers {{folder}}.", + "moved_one": "{{count}} élément a été déplacé vers {{folder}}.", + "moved_other": "{{count}} éléments ont été déplacés vers {{folder}}.", + "moveUndone": "Remis à sa place." + }, + "search": { + "placeholder": "Filtrer ce dossier", + "resultsFor": "Fichiers contenant « {{query}} »", + "back": "Retour au dossier", + "noneBody": "Aucun nom ni texte de vos fichiers ne correspond. Essayez d’autres mots.", + "page": "page {{n}}", + "sheet": "feuille « {{name}} »", + "slide": "diapositive {{n}}", + "indexing_one": "{{count}} document est encore en cours d’indexation : relancez la recherche dans un instant pour l’inclure.", + "indexing_other": "{{count}} documents sont encore en cours d’indexation : relancez la recherche dans un instant pour les inclure.", + "limited": "Résultats limités : la recherche s’est arrêtée à ses limites avant de parcourir tous les fichiers. Cherchez dans un dossier ou avec d’autres mots pour voir le reste.", + "filtering_one": "{{count}} élément de ce dossier correspond à « {{query}} ».", + "filtering_other": "{{count}} éléments de ce dossier correspondent à « {{query}} ».", + "everywhere": "Rechercher « {{query}} » dans tous les fichiers", + "everywhereShort": "Rechercher dans tous les fichiers", + "noMatch": "Rien dans ce dossier ne correspond à « {{query}} ».", + "clear": "Effacer le filtre" + }, + "emptyHome": { + "title": "Vos fichiers sont ici" + }, + "emptyFolder": { + "title": "Ce dossier est vide", + "body": "Déposez des fichiers ou des dossiers n’importe où sur cette page, collez-les avec Ctrl+V ou importez-les depuis votre ordinateur. L’assistant peut aussi enregistrer des fichiers ici depuis une discussion.", + "bodyTouch": "Importez des fichiers depuis votre appareil. L’assistant peut aussi enregistrer des fichiers ici depuis une discussion.", + "ask": "Demander à l’assistant" + }, + "drop": { + "title": "Déposez pour importer dans {{folder}}", + "body": "Fichiers et dossiers entiers acceptés.", + "limit": "Fichiers et dossiers entiers, jusqu’à {{size}} chacun." + }, + "conflict": { + "title_one": "Un fichier portant ce nom existe déjà", + "title_other": "{{count}} fichiers portant ces noms existent déjà", + "body_one": "{{folder}} contient déjà un fichier portant ce nom.", + "body_other": "{{folder}} contient déjà des fichiers portant ces noms.", + "skip_one": "Ignorer", + "skip_other": "Tout ignorer", + "keep": "Conserver les deux", + "replace_one": "Remplacer", + "replace_other": "Tout remplacer", + "more_one": "et {{count}} autre", + "more_other": "et {{count}} autres", + "note": "« Conserver les deux » donne un nouveau nom au nouveau fichier. Un fichier remplacé peut être récupéré depuis l’historique.", + "cancel": "Annuler l’import" + }, + "move": { + "title_one": "Déplacer {{count}} élément", + "title_other": "Déplacer {{count}} éléments", + "items_one": "{{count}} élément", + "items_other": "{{count}} éléments", + "cancel": "Annuler", + "here": "Déplacer vers {{folder}}", + "failed": "Impossible de lire ce dossier.", + "noFolders": "Aucun dossier ici. Déplacez ici ou remontez d’un niveau." + }, + "uploads": { + "waiting": "En attente", + "progress": "{{loaded}} sur {{total}}", + "rate": "{{rate}}/s", + "left": "{{time}} restantes", + "renamed": "Enregistré sous {{name}}", + "done": "Importé", + "canceled": "Annulé", + "cancel": "Annuler", + "cancelOne": "Annuler l’import de {{name}}", + "retry": "Réessayer", + "retryOne": "Réessayer {{name}}", + "dismiss": "Fermer", + "dismissOne": "Fermer {{name}}", + "show": "Afficher", + "showOne": "Afficher {{name}}", + "meterOne": "{{name}} : {{percent}} %", + "meterAll": "Tous les imports : {{percent}} %", + "cancelAll": "Tout annuler", + "retryAll": "Tout réessayer", + "fold": "Masquer la liste", + "unfold": "Afficher la liste", + "close": "Fermer", + "head": { + "sending_one": "Import de {{count}} fichier", + "sending_other": "Import de {{count}} fichiers", + "failed_one": "{{count}} import a échoué", + "failed_other": "{{count}} imports ont échoué", + "done_one": "{{count}} fichier importé", + "done_other": "{{count}} fichiers importés" + }, + "say": { + "start_one": "Import de {{count}} fichier.", + "start_other": "Import de {{count}} fichiers.", + "done_one": "Import terminé : {{count}} fichier.", + "done_other": "Imports terminés : {{count}} fichiers.", + "doneFailed": "Imports terminés : {{count}} réussis, {{failed}} échoués.", + "failed": "Impossible d’importer {{name}}." + }, + "errors": { + "tooBig": "Dépasse la limite de {{limit}} de ce serveur.", + "tooLarge": "Le serveur l’a refusé car il est trop volumineux.", + "off": "Les fichiers sont désactivés sur ce serveur.", + "invalid": "Le serveur a refusé ce fichier.", + "network": "La connexion a été interrompue.", + "server": "Le serveur n’a pas pu l’enregistrer." + }, + "said": "Réponse du serveur : {{reason}}." + } +} diff --git a/core/http/react-ui/public/locales/id/addons.json b/core/http/react-ui/public/locales/id/addons.json index 897ec3e45..99a163891 100644 --- a/core/http/react-ui/public/locales/id/addons.json +++ b/core/http/react-ui/public/locales/id/addons.json @@ -3,9 +3,9 @@ "blocked": "Aksi sebelumnya pada add-on ini belum dikonfirmasi. Periksa statusnya atau abaikan terlebih dahulu.", "checkStatus": "Periksa status", "dismiss": "Abaikan", - "failed": "Pemilik tidak menyelesaikan tindakan. Tidak ada yang diubah. Coba lagi untuk memulai percobaan baru.", - "notApplied": "Pemilik melaporkan aksi selesai, tetapi add-on tidak berubah. Coba lagi.", - "unknown": "Pemilik belum mengonfirmasi aksi ini. Aksi mungkin masih diterapkan. Periksa statusnya atau abaikan sebelum memulai aksi lain." + "failed": "Tindakan tidak selesai. Tidak ada yang diubah. Coba lagi untuk memulai percobaan baru.", + "notApplied": "Tindakan dilaporkan selesai, tetapi add-on tidak berubah. Coba lagi untuk memulai percobaan baru.", + "unknown": "Tindakan belum dikonfirmasi. Tindakan mungkin masih diterapkan. Periksa statusnya atau abaikan sebelum memulai tindakan lain." }, "approval": { "title": "Perlu persetujuan", @@ -37,14 +37,16 @@ "emptyTitle": "Belum ada aplikasi", "firstRun": { "browse": "Browse add-ons", - "title": "Welcome to Apps", - "lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.", - "featuredLabel": "Featured add-ons", + "title": "Belum ada add-on", + "lead": "Add-on memberi LocalAI halaman dan alat chat baru. Add-on berjalan sendiri dan hanya meminta yang dicantumkannya.", + "featuredLabel": "Mulai dengan salah satu ini", "featuredFailed": "The list of add-ons could not be read.", - "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons." + "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons.", + "factChecked": "Diperiksa terhadap sumber Anda sebelum berjalan", + "factRemove": "Matikan atau hapus kapan saja" }, "headless": "Berjalan di latar belakang dan tidak punya halaman sendiri.", - "listLabel": "Aplikasi", + "listLabel": "Add-on terpasang", "loading": "Memuat aplikasi", "manage": "Kelola", "open": "Buka", @@ -56,10 +58,14 @@ "tools_one": "{{count}} alat", "tools_other": "{{count}} alat", "version": "Versi {{version}}", - "working": "Menunggu konfirmasi pemilik.", + "working": "Menunggu konfirmasi.", "noMatchTitle": "No add-on matches", - "noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.", - "clearSearch": "Clear search" + "noMatchBody": "Tidak ada yang terpasang cocok dengan \"{{query}}\". Coba kata lain.", + "clearSearch": "Clear search", + "loadError": { + "title": "Add-on tidak tersedia saat ini.", + "paused": "Halaman dan alat add-on dijeda sampai layanan kembali." + } }, "browse": { "confirmTitle": "Pasang {{name}}?", @@ -69,7 +75,7 @@ "fromLibrary": "dari {{library}}", "install": "Pasang", "installedDetail": "Versi {{version}}, generasi {{generation}}", - "installedOther": "Terpasang {{version}} (versi lain)", + "installedOther": "Anda memakai {{version}}", "installedSame": "Terpasang {{version}}", "installedState": "Terpasang", "installing": "Memasang", @@ -85,7 +91,8 @@ "backendFailed": "The backend was not installed.", "alias": "Alias", "kindLabel": "Kind", - "installedOn": "{{name}} is installed and on." + "installedOn": "{{name}} is installed and on.", + "installedChip": "Terpasang" }, "catalog": { "allLibraries": "Semua pustaka", @@ -115,8 +122,8 @@ "pin": "Pin {{name}}", "pinLabel": "Pin", "pinned": "Pinned", - "running": "Aktif", - "search": "Search add-ons", + "running": "Berjalan", + "search": "Cari add-on terpasang", "status": { "failed": "failed", "starting": "starting" @@ -129,7 +136,7 @@ "denied": "Server menolak ini. Anda mungkin tidak punya izin, atau add-on berubah. Daftar telah diperbarui.", "invalid": "Server tidak dapat menerima permintaan ini.", "network": "Server tidak dapat dijangkau. Periksa koneksi Anda.", - "unavailable": "Pemilik add-on tidak tersedia saat ini. Coba lagi sebentar lagi.", + "unavailable": "Add-on tidak tersedia saat ini. Coba lagi sebentar lagi.", "unknownOutcome": "Hasil tidak dapat dikonfirmasi. Periksa statusnya sebelum mencoba lagi." }, "installed": { @@ -174,14 +181,14 @@ }, "hints": { "id": "Huruf kecil, angka, dan tanda hubung. Tidak dapat diubah nanti.", - "reference": "Nama kredensial yang dikonfigurasi pemilik. Jangan pernah menempelkan token atau kata sandi di sini.", + "reference": "Nama kredensial yang dikonfigurasi administrator di server ini. Jangan pernah menempelkan token atau kata sandi di sini.", "url": "Alamat https tanpa kredensial dan tanpa query string.", "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.", "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.", "mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched." }, "lastRefresh": "Disegarkan {{when}}, {{count}} entri", - "lead": "Pustaka adalah dokumen katalog yang mendaftar add-on. Kredensial tidak pernah diketik di sini: pustaka privat menyebut kredensial yang dikonfigurasi pemilik.", + "lead": "Pustaka adalah indeks yang mendaftar add-on, backend, atau model. Kredensial tidak pernah diketik di sini: pustaka add-on privat menyebut kredensial yang dikonfigurasi administrator di server ini.", "listLabel": "Pustaka", "loading": "Memuat pustaka", "notRefreshed": "Belum disegarkan", @@ -251,10 +258,11 @@ "hasUI": "Punya halaman", "headless": "Tanpa antarmuka, tanpa halaman", "health": "Kesehatan", + "installedAt": "Dipasang", "host": "API host", "interface": "Antarmuka", "logs": "Log", - "logsNone": "Keluaran proses add-on tidak dikumpulkan di rilis ini. Kesehatan dan percobaan mulai ulang di atas adalah yang dapat dilaporkan pemilik.", + "logsNone": "Keluaran proses add-on tidak dikumpulkan di rilis ini. Kesehatan dan percobaan mulai ulang di atas adalah semua yang dapat dilaporkan LocalAI.", "observed": "Status teramati", "protocol": "Protokol", "restarts": "Percobaan mulai ulang", @@ -338,14 +346,15 @@ "state": { "failed": "Gagal", "label": "Status: {{state}}", - "running": "Aktif", + "running": "Berjalan", "starting": "Memulai", "stopped": "Nonaktif", - "update-pending": "Pembaruan tertunda" + "update-pending": "Pembaruan tertunda", + "updateTo": "Pembaruan {{version}}" }, - "supporting": "Aplikasi yang dijalankan pemilik untuk Anda, beserta pustaka asalnya.", + "supporting": "Perangkat lunak yang Anda pasang untuk memberi LocalAI halaman dan alat chat baru. Masing-masing berjalan sendiri dan hanya meminta yang dicantumkannya.", "tabs": { - "apps": "Add-on saya", + "apps": "Terpasang", "browse": "Cari add-on", "label": "Bagian add-on", "libraries": "Sumber" @@ -516,8 +525,8 @@ "unknownOutcome": "Hasilnya tidak diketahui. Baca status lagi: tindakan yang sama tidak dikirim dua kali.", "auth": "Masuk lagi.", "denied": "Hanya administrator yang bisa melakukannya, dari sesi peramban mereka sendiri.", - "network": "Pemilik tidak dapat dijangkau.", - "unavailable": "Pemilik tidak tersedia." + "network": "Layanan add-on tidak dapat dijangkau.", + "unavailable": "Layanan add-on tidak tersedia." } }, "updates": { @@ -591,18 +600,30 @@ "auth": "Masuk lagi untuk melanjutkan.", "denied": "Hanya administrator yang dapat melakukannya dari sesi browser sendiri.", "network": "Jaringan tidak dapat dijangkau.", - "unavailable": "Pemilik add-on tidak tersedia. Coba lagi." + "unavailable": "Layanan add-on tidak tersedia. Coba lagi." + }, + "upgradeNamed": "Perbarui {{name}}", + "why": { + "role": "Pembaruan tersedia. Hanya administrator yang dapat menjalankannya.", + "needs-check": "Pembaruan dilaporkan, tetapi rinciannya belum dikonfirmasi. Periksa lagi untuk memastikannya.", + "check-failed": "Pemeriksaan terakhir gagal, sehingga pembaruan tidak dapat dikonfirmasi. Periksa lagi.", + "incompatible": "Ada pembaruan, tetapi tidak dapat berjalan di sini:", + "integrity": "Pustaka mencantumkan versi ini dengan isi berbeda. Versi ini tidak ditawarkan sebagai pembaruan.", + "recovery": "Data add-on ini perlu dipulihkan dulu. Pulihkan cadangan di Detail, lalu perbarui.", + "current": "Pemeriksaan terakhir tidak menemukan pembaruan. Pemberitahuan mungkin sudah usang. Periksa lagi.", + "unpinned": "Pustaka melaporkan pembaruan tetapi tidak menyebutkan build yang akan dipasang, jadi tidak dapat ditingkatkan dari sini. Periksa lagi nanti." }, - "reviewButton": "Pembaruan ({{count}})", - "reviewTitle": "Tinjau pembaruan add-on", - "reviewDescription": "Tidak ada yang diperbarui sampai Anda memilih di sini. Setiap pembaruan mencadangkan data add-on terlebih dahulu.", "checkAll": "Periksa pembaruan", "checkingAll": "Memeriksa...", "checkAllDone": "{{count}} add-on diperiksa.", "checkAllFailed": "{{count}} tidak dapat diperiksa.", - "upgradeNamed": "Perbarui {{name}}", - "toastDone": "{{name}} diperbarui ke {{version}}.", - "toastFailed": "{{name}} tidak diperbarui: {{message}}" + "checkResult": { + "done": "Sudah diperiksa.", + "failed": "Pemeriksaan tidak selesai.", + "justNow": "Baru saja diperiksa." + }, + "waitingService": "Layanan add-on sedang dimulai. LocalAI menanyakan peningkatan lagi dalam {{seconds}} dtk.", + "checkAgain": "Periksa sekarang" }, "install": { "stepsLabel": "Install steps", @@ -639,17 +660,19 @@ "addsTools_other": "Adds {{count}} tools", "openNamed": "Open {{name}}", "removeTitle": "Remove {{name}}?", - "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted." + "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted.", + "removeNamed": "Hapus {{name}}…" }, "preview": { "screenshots": "Screenshots", - "adds": "What it adds", + "adds": "Yang dapat dilakukannya", "page": "A page of its own in LocalAI", "addsNothing": "Nothing you can see. It works in the background.", "version": "Version", "id": "Identifier", "sourceUnknown": "Not known", - "moreDetails": "Health, trust and updates" + "moreDetails": "Health, trust and updates", + "updateTitle": "Perbarui ke {{version}}" }, "flow": { "installAndTurnOn": "Pasang dan aktifkan", @@ -663,6 +686,66 @@ "install": "Download, verify and install", "turn-on": "Turn on" }, - "seeInList": "See it in My add-ons" + "seeInList": "Lihat di Terpasang", + "install": "Pasang", + "installNamed": "Pasang {{name}}", + "shadowWarning": "Add-on ini menggantikan {{tool}} bawaan. Add-on lain ({{name}}) sudah melakukannya. Hanya satu yang bisa aktif; Anda memilih yang mana di Pengaturan > Alat. Sebelum Anda memilih, yang dipasang lebih dulu yang melayaninya.", + "skillShadow": "Add-on ini menggantikan skill bawaan {{skill}}.", + "skillShadowWarning": "Add-on ini menggantikan skill bawaan {{skill}}. Add-on lain ({{name}}) sudah melakukannya. Hanya satu yang bisa aktif; Anda memilih yang mana di Pengaturan > Alat. Sebelum Anda memilih, yang dipasang lebih dulu yang melayaninya." + }, + "family": { + "pages": "Mencari halaman yang Anda atau agen buat? Ada di Halaman." + }, + "filter": { + "label": "Tampilkan", + "all": "Semua", + "running": "Berjalan", + "off": "Mati", + "showAll": "Tampilkan semua", + "none": { + "all": "Tidak ada add-on untuk ditampilkan.", + "running": "Tidak ada add-on yang berjalan.", + "off": "Semua add-on berjalan." + } + }, + "attention": { + "label": "Perlu perhatian", + "failedTitle": "{{name}} berhenti.", + "failedRestarts_one": "Gagal dimulai {{count}} kali berturut-turut.", + "failedRestarts_other": "Gagal dimulai {{count}} kali berturut-turut.", + "failedOnce": "Add-on tidak dimulai.", + "toolsOff_one": "{{count}} alat chat-nya mati sampai add-on berjalan lagi.", + "toolsOff_other": "{{count}} alat chat-nya mati sampai add-on berjalan lagi.", + "pageOff": "Halamannya tidak tersedia sampai add-on berjalan lagi.", + "updateTitle": "{{name}} {{version}} tersedia.", + "updateBody": "Anda memakai {{current}}. Anda melihat perubahannya dan mengonfirmasi sebelum ada yang diganti." + }, + "meta": { + "builtin": "Bawaan", + "builtinLong": "Disertakan dengan LocalAI", + "signed": "Bertanda tangan · {{library}}", + "checked": "Diperiksa · {{library}}", + "devBuild": "Build pengembangan", + "library": "pustaka {{library}}" + }, + "skills": { + "title_one": "{{count}} skill obrolan", + "title_other": "{{count}} skill obrolan", + "replaces": "menggantikan skill bawaan", + "serves": "dipakai di obrolan", + "notServing": "tidak dipakai sekarang", + "serveIn": "Pakai untuk {{skill}} di obrolan", + "saveFailed": "Pilihan tidak dapat disimpan." + }, + "settingsTab": { + "overview": "Ringkasan", + "settings": "Pengaturan", + "tabsLabel": "Detail {{name}}", + "off": "Aktifkan {{name}} untuk mengubah pengaturannya.", + "pageLink": "Pengaturan", + "controls": { + "loadFailed": "Pengaturan tidak dapat dibaca", + "saveFailed": "Pengaturan tidak dapat disimpan: {{message}}" + } } } diff --git a/core/http/react-ui/public/locales/id/admin.json b/core/http/react-ui/public/locales/id/admin.json index 15528662f..ea9d04332 100644 --- a/core/http/react-ui/public/locales/id/admin.json +++ b/core/http/react-ui/public/locales/id/admin.json @@ -107,6 +107,72 @@ "assistant": "Asisten LocalAI", "distributed": "Terdistribusi", "responses": "Respons" + }, + "loadFailedTitle": "Pengaturan tidak dapat dimuat.", + "egress": { + "title": "Jaringan perintah", + "intro": "Perintah di Sandbox menjangkau jaringan hanya melalui proxy yang menanyakan LocalAI untuk setiap koneksi. Atur batas, preset chat baru, port, registri tambahan, dan masa simpan audit di bawah; setiap orang memilih preset tiap chat di menu Alat-nya, sampai batas, dan mengizinkan host yang diblokir lewat kartu.", + "serves": "Perintah berjalan terisolasi di add-on {{name}}", + "servesHost": "Perintah berjalan di mesin ini melalui {{name}}", + "servesNone": "Belum ada Sandbox yang menjalankan perintah", + "hostNote": "Backend host menjalankan perintah di mesin ini dengan jaringannya: pengaturan ini tidak berlaku untuknya.", + "counts": "24 jam terakhir: {{allowed}} diizinkan, {{blocked}} diblokir.", + "private": "Izinkan aturan administrator menjangkau jaringan privat", + "privateWarning": "Host yang diizinkan administrator lalu dapat menjangkau alamat di dalam jaringan Anda. Preset saja tidak pernah; alamat metadata cloud dan server tetap ditolak.", + "typePhrase": "Untuk menyalakannya, ketik: {{phrase}}", + "registries": "Registri paket ({{count}})", + "showAudit": "Tampilkan audit", + "hideAudit": "Sembunyikan audit", + "loadFailed": "Pengaturan jaringan Sandbox tidak dapat dibaca", + "audit": { + "verdict": "Keputusan", + "host": "Host", + "user": "Pengguna", + "at": "Waktu", + "reason": "Alasan", + "bytes": "Byte naik / turun", + "refresh": "Muat ulang", + "empty": "Tidak ada permintaan yang cocok.", + "loadFailed": "Audit tidak dapat dibaca", + "verdicts": { + "any": "Semua keputusan", + "allow": "Diizinkan", + "block": "Diblokir", + "done": "Selesai" + } + } + }, + "addons": { + "title": "Add-on", + "hint": "Pengaturan yang dibawa add-on", + "intro": "Setiap add-on menyimpan pengaturannya di halamannya sendiri, di tab Pengaturan pada detailnya. Buka salah satu untuk mengubahnya.", + "off": "Nonaktif. Aktifkan untuk mengubah pengaturannya.", + "open": "Buka pengaturan", + "openNamed": "Buka pengaturan {{name}}", + "count_one": "{{count}} add-on dengan pengaturan", + "count_other": "{{count}} add-on dengan pengaturan" + }, + "hosts": { + "add": "Tambah", + "listLabel": "Host yang selalu diizinkan", + "remove": "Hapus {{host}}", + "empty": "Belum ada host. Tulis nama host, atau host:port untuk mengunci port.", + "hint": "Hanya nama yang tepat. Tambahkan :port untuk mengizinkan satu port; jika tidak, port jaringan Sandbox yang berlaku.", + "count": "{{n}}/{{max}}", + "errors": { + "empty": "Tulis nama host.", + "wildcard": "\"{{value}}\": tulis nama host yang tepat, bukan pola.", + "shape": "\"{{value}}\" bukan nama host. Tulis hanya hostnya, tanpa skema dan path.", + "port": "\"{{value}}\": port harus 1 sampai 65535, dan 22 serta 25 selalu ditolak.", + "private": "\"{{value}}\" adalah alamat privat atau lokal. Aktifkan dulu sakelar jaringan privat.", + "numeric": "\"{{value}}\": tulis alamat IP sebagai empat angka desimal.", + "duplicate": "\"{{value}}\" sudah ada di daftar.", + "full": "Daftar memuat paling banyak {{max}} host." + } + }, + "network": { + "title": "Jaringan perintah", + "where": "{{name}} menyimpan pengaturan jaringan untuk perintah yang dijalankannya di halamannya sendiri." } }, "backends": { @@ -276,5 +342,75 @@ "subtitle": "Model yang dimuat di instans LocalAI ini dan kapasitas host yang digunakannya.", "scaleOut": "Tambah mesin", "hideScaleOut": "Sembunyikan pengaturan" + }, + "settingsTools": { + "intro": "Chat memiliki beberapa alat bawaan yang dapat digantikan add-on. Saat add-on terpasang, berjalan, dan aktif untuk sebuah chat, chat memakai alat add-on itu dengan nama yang sama.", + "empty": "Tidak ada alat bawaan yang dapat dikelola di server ini.", + "loadFailed": "Alat bawaan tidak dapat dibaca", + "switch": "Aktif", + "byBuiltin": "Bawaan", + "byAddon": "Add-on: {{name}}", + "byNone": "Mati", + "unavailable": "Server ini tidak dapat menjalankannya: {{reason}}.", + "installSandbox": "Pasang Sandbox untuk menjalankan perintah secara terisolasi", + "replaceExplain": "Add-on ini menggantikan {{tool}}: yang dipilih melayaninya di setiap chat tempat ia aktif dan berjalan, dan yang bawaan disembunyikan. Hanya satu yang bisa aktif. Sebelum Anda memilih, yang dipasang lebih dulu yang melayaninya.", + "defaultInstalledFirst": "Bawaan: dipasang lebih dulu", + "installedOn": "Dipasang {{when}}", + "choose_one": "{{count}} add-on menggantikan {{tool}}: pilih yang akan dipakai.", + "choose_other": "{{count}} add-on menggantikan {{tool}}: pilih yang akan dipakai.", + "labels": { + "bash": "Perintah (bash)", + "web_search": "Pencarian web", + "web_fetch": "Baca halaman web", + "list_agents": "Agen tersimpan" + }, + "descriptions": { + "bash": "Menjalankan perintah dan kode untuk chat langsung di mesin ini saat tidak ada Sandbox yang melayani bash. Add-on Sandbox menggantinya dengan perintah terisolasi.", + "web_search": "Mencari di web untuk chat tanpa kunci (DuckDuckGo, lalu Brave Search, lalu Bing), atau dengan instans SearXNG Anda jika sudah diatur.", + "web_fetch": "Membaca halaman atau file web publik berdasarkan alamatnya untuk chat. Alamat di dalam jaringan Anda ditolak kecuali Anda mengizinkannya.", + "list_agents": "Memungkinkan chat menampilkan agen tersimpan milik pengguna dan memulai salah satunya sebagai sub-agen. Setiap chat mengaktifkannya di menu Alat." + }, + "bash": { + "switch": "Izinkan chat menjalankan perintah di mesin ini", + "modeLegend": "Siapa yang dapat menjalankan perintah", + "ask": "Tanya setiap kali.", + "askHint": "Hanya chat administrator yang mendapatkannya, dan setiap perintah menunggu persetujuan mereka di chat.", + "on": "Izinkan untuk semua.", + "onHint": "Chat semua pengguna menjalankan perintah di sini tanpa bertanya, termasuk jalankan terjadwal dan sub-agen.", + "default": "Belum diatur: bawaan berlaku ({{mode}}).", + "warning": "Perintah berjalan sebagai pengguna LocalAI, tanpa isolasi dan dengan jaringan mesin ini: dapat membaca apa yang bisa dibaca LocalAI dan menjangkau internet, jaringan Anda, dan API LocalAI. Setiap perintah dicatat. Pasang Sandbox untuk mengisolasinya." + }, + "modes": { + "off": "mati", + "ask": "tanya setiap kali", + "on": "diizinkan untuk semua" + }, + "skillsTitle": "Skill yang digantikan add-on", + "skillsIntro": "Add-on dapat menggantikan skill bawaan dengan miliknya sendiri, yang ditulis untuk alatnya. Jika beberapa add-on menggantikan skill yang sama, pilih yang dipakai.", + "skillLabel": "Skill {{skill}}", + "skillNotRunning": "Add-on yang dipilih tidak berjalan: skill bawaan yang dipakai.", + "toolNotRunning": "Add-on yang menggantikan alat ini tidak berjalan: alat bawaan yang dipakai.", + "stopped": "berhenti", + "webTest": { + "intro": "Menjalankan pencarian dengan setiap penyedia dari pengaturan yang tersimpan dan menampilkan hasil masing-masing. Terapkan perubahan Anda terlebih dahulu.", + "queryLabel": "Kueri", + "run": "Uji pencarian", + "running": "Menguji…", + "answered": "Chat mendapat hasil dari {{host}}.", + "noneAnswered": "Tidak ada penyedia yang menjawab.", + "failed": "Uji tidak dapat dijalankan: {{error}}", + "results_one": "{{count}} hasil", + "results_other": "{{count}} hasil", + "outcomes": { + "ok": "Berfungsi", + "no_results": "Tidak ada hasil", + "challenged": "Pemeriksaan bot", + "blocked": "Diblokir", + "timeout": "Terlalu lambat", + "error": "Galat", + "skipped": "Dilewati" + } + }, + "addonSettings": "Pengaturan {{name}}" } } diff --git a/core/http/react-ui/public/locales/id/agents.json b/core/http/react-ui/public/locales/id/agents.json index 7431bc417..b6554455f 100644 --- a/core/http/react-ui/public/locales/id/agents.json +++ b/core/http/react-ui/public/locales/id/agents.json @@ -2,15 +2,13 @@ "title": "Agen", "subtitle": "Kelola agen AI otonom", "actions": { - "agentHub": "Pusat Agen", "import": "Impor", "createAgent": "Buat Agen", "edit": "Edit", "chat": "Obrolan", "export": "Ekspor", "delete": "Hapus", - "pause": "Jeda", - "resume": "Lanjutkan" + "newAgent": "Agen baru" }, "table": { "name": "Nama", @@ -27,13 +25,9 @@ "empty": { "noConfigured": "Belum ada agen yang dikonfigurasi", "noConfiguredText": "Buat agen untuk memulai alur kerja AI otonom.", - "browseHub": "Tidak tahu harus mulai dari mana? Jelajahi <1>Pusat Agen untuk menemukan konfigurasi agen siap pakai yang bisa Anda impor.", "noMatching": "Tidak ada agen yang cocok", - "noMatchingText": "Tidak ada agen yang cocok dengan \"{{query}}\"" - }, - "sections": { - "yourAgents": "Agent Anda", - "otherUsersAgents": "Agent Pengguna Lain" + "noMatchingText": "Tidak ada agen yang cocok dengan \"{{query}}\"", + "clearSearch": "Hapus pencarian" }, "deleteDialog": { "title": "Hapus Agen", @@ -44,12 +38,411 @@ "loadFailed": "Gagal memuat agen: {{message}}", "deleted": "Agen \"{{name}}\" berhasil dihapus", "deleteFailed": "Gagal menghapus agen: {{message}}", - "paused": "Agen \"{{name}}\" dijeda", - "resumed": "Agen \"{{name}}\" dilanjutkan", - "pauseFailed": "Gagal menjeda agen: {{message}}", - "resumeFailed": "Gagal melanjutkan agen: {{message}}", "exported": "Agen \"{{name}}\" berhasil diekspor", - "exportFailed": "Gagal mengekspor agen: {{message}}", - "parseFailed": "Gagal melakukan parse file agen: {{message}}" + "parseFailed": "Gagal melakukan parse file agen: {{message}}", + "notAnAgent": "The file is not an exported agent", + "deletedUndo": "“{{name}}” dihapus.", + "undoLate": "“{{name}}” sudah dihapus.", + "restored": "“{{name}}” dipulihkan." + }, + "facts": { + "addons": "Add-ons", + "connector": "MCP connector" + }, + "agent": { + "triggers": "Triggers", + "newTrigger": "New trigger", + "noTriggers": "No trigger starts this agent.", + "untitled": "Untitled chat", + "triggerOn": "On", + "triggerOff": "Off", + "origin": { + "trigger": "Started by a trigger", + "connector": "Started by a connector" + }, + "noRunsText": "Press Chat to start a conversation with this agent. Chats that ran with it, and the ones its triggers start, are listed here.", + "missingText": "It may have been deleted, or it belongs to another user.", + "backToList": "Kembali ke agen" + }, + "record": { + "where": "A run is a chat that ran with the agent, including the ones its triggers and connectors started.", + "none": "No runs yet" + }, + "create": { + "sections": { + "BasicInfo": "Basic info", + "ModelSettings": "Model", + "PromptsGoals": "Instructions", + "MemorySettings": "Memory", + "Skills": "Skills", + "Addons": "Add-ons", + "MCP": "MCP servers", + "export": "Export" + }, + "name": "Name", + "description": "Description", + "descriptionHelp": "One line that says what the agent is for. It shows in the list.", + "model": "Model", + "modelPlaceholder": "Any model (the chat picks one)", + "modelHelp": "Leave it empty and a chat with this agent uses the model you pick in the chat. Set one to fix it.", + "anyModel": "Any model", + "instructions": "Instructions", + "instructionsHelp": "What the agent always follows. Every chat with it starts from this text.", + "collections": "Collections", + "collectionsHint": "Collection name", + "collectionsHelp": "The memory collections a chat with this agent may search. The list is exact: an empty list grants none.", + "skillsHelp": "The skills a chat with this agent may read. The list is exact: an empty list grants none.", + "noSkills": "No skills yet.", + "manageSkills": "Write or manage skills", + "addonsHelp": "The add-ons whose tools a chat with this agent may use.", + "noAddons": "No add-on offers tools to a model.", + "mcpHelp": "The MCP servers of the fixed model and the MCP connectors a chat with this agent may use.", + "mcpServers": "MCP servers", + "mcpHint": "Server name", + "mcpConnectors": "MCP connectors", + "noConnectors": "No MCP connectors yet.", + "manageConnectors": "Manage MCP connectors", + "connectorOff": "off", + "gone": "not found", + "countCollections_one": "{{count}} collection", + "countCollections_other": "{{count}} collections", + "countAddons_one": "{{count}} add-on", + "countAddons_other": "{{count}} add-ons", + "needsName": "Needs a name", + "loadFailed": "Failed to load the agent: {{message}}", + "saveFailed": "Save failed: {{message}}", + "created": "Agent \"{{name}}\" created", + "updated": "Agent \"{{name}}\" updated", + "exportSummary": "Download as JSON", + "exportHelp": "Download the definition of this agent as a JSON file. It holds no id, no dates and no secrets. Import it on another instance from the agents list.", + "exportButton": "Export agent", + "importNew": "Imported from an exported agent. Read the form, then save.", + "importLegacy": "Imported from the export of an older agent. Name, description, model, instructions and skills were read; the rest has no counterpart here.", + "importDropped": "Not imported: {{keys}}.", + "subEdit": "A chat that is running keeps the version it started with. Its next turn uses the new one.", + "configNote": "The definition this form will save.", + "previewNew": "The agent this form will save.", + "previewEdit": "The agent this form will save, and how it differs from the saved one.", + "default": "Default", + "network": "Jaringan perintah dalam proses tanpa pengawasan", + "networkHelp": "Saat pemicu atau konektor menjalankan agen ini, perintah Sandbox-nya menjangkau jaringan sejauh ini, sampai batas yang diatur administrator. Proses tidak pernah bertanya: host di luarnya diblokir. Chat yang Anda mulai dengan agen ini juga dimulai dengan ini.", + "networkPresets": { + "off": "Mati", + "registries": "Registri paket", + "all": "Semua situs publik" + } + }, + "run": { + "workingLine": "Working" + }, + "jobs": { + "title": "Triggers", + "newTask": "New trigger", + "createTask": "Create trigger", + "loading": "Loading triggers", + "loadFailed": "Failed to load: {{message}}", + "actionFailed": "The action failed: {{message}}", + "summary": { + "stopped": "{{count}} skipped", + "lastFailed_one": "The last run of {{names}} failed.", + "lastFailed_other": "The last run failed for {{names}}." + }, + "tasks": { + "title": "Triggers, {{count}}", + "caption": "Triggers", + "task": "Trigger", + "noSchedule": "No schedule", + "noAgent": "No agent", + "fileKind": "When a file arrives", + "open": "Open", + "started": "Trigger \"{{name}}\" started", + "toggleLabel": "Run {{name}} by itself", + "toggleFailed": "Could not change the trigger: {{message}}", + "deleted": "Trigger deleted", + "noneYet": "You have no triggers yet.", + "noModel": "No model", + "next": "Berikutnya" + }, + "history": { + "filterTask": "Trigger", + "allTasks": "All triggers", + "task": "Trigger", + "unknownTask": "Unknown trigger", + "cancelledLine": "Skipped", + "doneLine": "Finished. Open the chat for the answer.", + "doneFile": "Finished for {{file}}. Open the chat for the answer.", + "pendingLine": "Waiting to start", + "emptyText": "A run starts when you press Run now, when a schedule fires, or when a file arrives in a watched folder.", + "openRun": "Open the chat", + "reason": "Reason", + "file": "Started by the file {{file}}." + }, + "status": { + "done": "Done", + "failed": "Failed", + "running": "Working", + "queued": "Waiting", + "skipped": "Skipped" + }, + "empty": { + "title": "No triggers yet", + "text": "A trigger starts a chat for you by itself: at a time, or when a file arrives in a folder. Each run is an ordinary chat that you can open.", + "step1": "Write what the chat should do.", + "step2": "Pick an agent, or a model, to run it.", + "step3": "Choose when it runs, or press Run now." + }, + "task": { + "back": "Triggers", + "missingTitle": "Trigger not found", + "agent": "Agent", + "model": "Model", + "schedule": "Schedule", + "cap": "Most runs a day", + "watches": "Watches the folder {{folder}}", + "noRuns": "This trigger has not run yet.", + "runMeta": "took {{time}}", + "prompt": "What to do" + }, + "edit": { + "titleNew": "New trigger", + "subNew": "Name it, write what to do and choose when it runs.", + "subEdit": "Changes apply from the next run.", + "save": "Save trigger", + "create": "Create trigger", + "nameRequired": "A trigger needs a name", + "promptRequired": "Write what the trigger should do", + "created": "Trigger created", + "updated": "Trigger updated", + "sections": { + "basics": "Basics", + "prompt": "What to do", + "schedule": "When", + "limits": "Limits" + }, + "summary": { + "basics": "{{name}}, {{model}}", + "basicsNoModel": "{{name}}, no agent or model yet", + "promptEmpty": "Nothing to do yet", + "folderEmpty": "Needs a folder", + "cap": "Up to {{count}} runs a day", + "capDefault": "Default limit" + }, + "name": "Name", + "namePlaceholder": "daily-digest", + "noAgent": "No agent", + "model": "Model", + "enabled": "On", + "enabledHint": "A trigger that is off keeps its schedule but does not run.", + "prompt": "What to do", + "promptPlaceholder": "Summarise what is new in my Reports folder.", + "promptHint": "This text starts the chat each time the trigger fires.", + "capHint": "The most chats this trigger may start in one day. Empty uses the default.", + "previewEdit": "What the next run would send, and what differs from the saved trigger.", + "previewNew": "What the first run would send.", + "promptAsSent": "The text a run starts with", + "noChanges": "Nothing differs from the saved trigger.", + "loadingTask": "Loading trigger", + "changed_one": "{{count}} change", + "changed_other": "{{count}} changes" + }, + "subtitle": "Jalankan agen sesuai jadwal atau saat sebuah file tiba. Setiap jalan adalah chat yang bisa Anda buka." + }, + "views": { + "label": "Area agen", + "agents": "Agen" + }, + "now": { + "failedTitle": "{{name}} gagal.", + "workingTitle": "{{name}} sedang bekerja.", + "openRun": "Buka jalannya" + }, + "load": { + "agents": "Agen Anda tidak dapat dimuat.", + "agentsStale": "Daftar tidak dapat diperbarui. Daftar menampilkan isi sebelumnya.", + "agent": "Agen ini tidak dapat dimuat.", + "agentStale": "Halaman ini tidak dapat diperbarui. Halaman menampilkan isi sebelumnya.", + "agentForm": "Agen ini tidak dapat dimuat untuk diedit.", + "triggers": "Pemicu Anda tidak dapat dimuat.", + "triggersStale": "Pemicu tidak dapat diperbarui. Yang tampil adalah isi sebelumnya.", + "trigger": "Pemicu ini tidak dapat dimuat.", + "triggerStale": "Pemicu ini tidak dapat diperbarui. Yang tampil adalah isi sebelumnya.", + "tasks": "Tugas tidak dapat dimuat.", + "tasksStale": "Tugas tidak dapat diperbarui. Yang tampil adalah isi sebelumnya." + }, + "meta": { + "anyModel": "Model apa saja", + "addons_one": "{{count}} add-on", + "addons_other": "{{count}} add-on" + }, + "chips": { + "lastFailed": "Jalan terakhir gagal" + }, + "tasks": { + "title": "Tugas agen", + "allUsers": "Semua pengguna", + "new": "Tugas baru", + "newTitle": "Tugas baru", + "editTitle": "Ubah tugas", + "name": "Nama", + "description": "Deskripsi", + "runner": "Dijalankan oleh", + "byModel": "Sebuah model", + "byAgent": "Salah satu agen Anda", + "model": "Model", + "agent": "Agen", + "pick": "Pilih…", + "prompt": "Prompt", + "promptHint": "Tulis parameter sebagai {{example}}; setiap jalannya meminta nilainya.", + "webhooksKept_one": "Tugas ini punya {{count}} webhook. Webhook tetap seperti adanya; ubah webhook melalui API.", + "webhooksKept_other": "Tugas ini punya {{count}} webhook. Semuanya tetap seperti adanya; ubah webhook melalui API.", + "save": "Simpan tugas", + "cancel": "Batal", + "saved": "Tugas \"{{name}}\" disimpan", + "runTitle": "Jalankan {{name}}", + "runNow": "Jalankan sekarang", + "started": "Tugas \"{{name}}\" dimulai", + "tasks": "Tugas", + "off": "Mati", + "run": "Jalankan", + "edit": "Ubah", + "delete": "Hapus", + "deleted": "Tugas \"{{name}}\" dihapus", + "task": "Tugas", + "cancelJob": "Batalkan", + "rerun": "Jalankan lagi", + "rerunStarted": "Dimulai lagi", + "stillRunning": "Masih berjalan.", + "noResult": "Tidak ada hasil.", + "traces_one": "{{count}} langkah", + "traces_other": "{{count}} langkah", + "webhookSent": "Webhook sudah dipanggil.", + "webhookFailed": "Webhook gagal: {{message}}", + "actionFailed": "Tidak berhasil: {{message}}", + "state": { + "pending": "Menunggu", + "running": "Berjalan", + "completed": "Selesai", + "failed": "Gagal", + "cancelled": "Dibatalkan" + }, + "lede": "Prompt yang dijalankan sebuah model atau salah satu agen Anda di latar belakang. Mulai dari sini atau dari skrip melalui API.", + "searchLabel": "Cari tugas", + "filter": { + "label": "Tampilkan tugas", + "all": "Semua", + "on": "Nyala", + "off": "Mati" + }, + "noMatch": "Tidak ada tugas yang cocok.", + "on": "Nyala", + "offLine": "Mati. Tugas ini tidak berjalan sampai Anda menyalakannya.", + "onDemand": "Berjalan saat Anda memulainya", + "asksFor": "Meminta {{list}}", + "webhooks_one": "{{count}} webhook", + "webhooks_other": "{{count}} webhook", + "runsCount_one": "{{count}} kali jalan", + "runsCount_other": "{{count}} kali jalan", + "runsOf_one": "{{count}} kali jalan {{name}}", + "runsOf_other": "{{count}} kali jalan {{name}}", + "toggleLabel": "{{name}} boleh berjalan", + "turnedOn": "\"{{name}}\" menyala", + "turnedOff": "\"{{name}}\" mati", + "moreFor": "Tindakan lain untuk {{name}}", + "open": "Buka", + "openTask": "Buka {{name}}", + "runFor": "Jalankan {{name}}", + "rerunFor": "Jalankan {{name}} lagi", + "jobMoreFor": "Tindakan lain untuk jalannya {{name}} ini", + "detailsFor": "Detail jalannya {{name}}", + "openChat": "Buka obrolan", + "deleteJob": "Hapus jalannya", + "jobDeleted": "Jalannya dihapus", + "cancelled": "Jalannya dihentikan", + "confirmJob": { + "title": "Hapus jalannya ini?", + "message": "Jawaban dan langkah-langkahnya dihapus. Ini tidak bisa dibatalkan." + }, + "line": { + "pending": "Menunggu untuk mulai", + "running": "Sedang bekerja", + "cancelled": "Dihentikan sebelum selesai" + }, + "startedBy": { + "manual": "Dimulai di sini", + "api": "Dimulai melalui API", + "cron": "Dimulai oleh jadwal" + }, + "output": { + "answer": "Jawaban", + "error": "Galat", + "values": "Nilai" + }, + "traceKind": { + "reasoning": "Penalaran", + "tool_call": "Alat", + "tool_result": "Hasil alat", + "status": "Status" + }, + "empty": { + "title": "Belum ada tugas", + "body": "Tugas adalah prompt yang Anda tulis sekali dan jalankan berkali-kali. Tulis {{example}} di tempat nilai berubah, dan setiap jalannya akan memintanya.", + "fact1": "Sebuah model atau salah satu agen Anda menjalankannya", + "fact2": "Mulai dari sini, atau dari skrip melalui API", + "fact3": "Setiap jalannya menyimpan jawaban dan langkahnya" + }, + "sheet": { + "optional": "(opsional)", + "newDesc": "Tulis prompt sekali, lalu jalankan kapan pun Anda perlu.", + "editDesc": "Perubahan berlaku mulai jalannya berikutnya.", + "about": "Tentang", + "nameHelp": "Skrip memakai nama ini untuk menjalankan tugas melalui API.", + "descHelp": "Satu baris yang menjelaskan kegunaan tugas.", + "who": "Siapa yang menjalankan", + "modelHelp": "Model menjawab prompt tanpa alat.", + "agentHelp": "Agen menjawab dengan instruksi dan alatnya sendiri.", + "what": "Apa yang dilakukan", + "promptPlaceholder": "Contoh: Ringkas tiket {{example}} dalam tiga baris.", + "inputs": "Setiap jalannya meminta:", + "noInputs": "Prompt ini tidak meminta apa pun. Setiap jalannya mengirimnya apa adanya.", + "on": "Nyala", + "onHelp": "Tugas yang mati tetap menyimpan riwayatnya tetapi tidak bisa dimulai.", + "runDesc": "Isi nilai untuk jalannya ini.", + "runNone": "Tugas ini tidak meminta apa pun. Prompt dijalankan apa adanya.", + "preview": "Yang diterima tugas" + }, + "errors": { + "name": "Beri nama tugas.", + "prompt": "Tulis apa yang harus dilakukan tugas.", + "model": "Pilih sebuah model.", + "agent": "Pilih sebuah agen." + }, + "pane": { + "label": "Detail tugas", + "overview": "Ringkasan", + "runs": "Riwayat", + "settings": "Pengaturan", + "inputs": "Meminta", + "noInputs": "Tidak ada", + "changed": "Diubah", + "api": "Jalankan melalui API", + "apiHint": "Kirim nilai sebagai objek JSON. Jawabannya berisi id jalannya untuk memeriksa hasilnya.", + "noRuns": "Belum pernah dijalankan. Jalankan tugas dan jawabannya muncul di sini.", + "webhooks": "Webhook", + "noWebhooks": "Tidak ada. Tambahkan webhook melalui API agar tahu kapan jalannya selesai.", + "deleteTitle": "Hapus tugas ini", + "deleteHelp": "Riwayatnya tetap ada sampai masa simpannya habis." + }, + "history": { + "title": "Riwayat jalannya", + "filterLabel": "Tampilkan jalannya", + "all": "Semua", + "active": "Sedang berjalan", + "completed": "Selesai", + "failed": "Gagal", + "cancelled": "Dibatalkan", + "allTasks": "Semua tugas", + "emptyBody": "Jalankan sebuah tugas dan jawabannya muncul di sini.", + "more": "Tampilkan {{count}} lagi" + } } -} \ No newline at end of file +} diff --git a/core/http/react-ui/public/locales/id/auth.json b/core/http/react-ui/public/locales/id/auth.json index ec764254b..9d1c5752e 100644 --- a/core/http/react-ui/public/locales/id/auth.json +++ b/core/http/react-ui/public/locales/id/auth.json @@ -47,12 +47,15 @@ "account": { "title": "Akun", "subtitle": "Profil, kredensial, dan API key", + "subtitleLocal": "Siapa Anda, dan bagaimana asisten dan LocalAI bekerja untuk Anda", "unavailable": "Akun tidak tersedia", "unavailableText": "Autentikasi harus diaktifkan untuk mengelola akun Anda.", "tabs": { "profile": "Profil", "security": "Keamanan", - "apiKeys": "API Key" + "apiKeys": "API Key", + "about": "Tentang Anda dan asisten", + "appearance": "Tampilan" }, "profile": { "displayName": "Nama tampilan", @@ -112,6 +115,9 @@ "pausedToast": "API key paused", "resumedToast": "API key resumed", "pauseFailed": "Failed to update API key: {{message}}" + }, + "usage": { + "loadFailedTitle": "Penggunaan Anda tidak dapat dimuat." } }, "notFound": { diff --git a/core/http/react-ui/public/locales/id/chat.json b/core/http/react-ui/public/locales/id/chat.json index 39a6916e6..986aadc0f 100644 --- a/core/http/react-ui/public/locales/id/chat.json +++ b/core/http/react-ui/public/locales/id/chat.json @@ -25,22 +25,15 @@ "yes": "Ya", "gpuLayers": "Layer GPU" }, - "context": { - "label": "Konteks: {{percent}}%", - "labelWithTokens": "Konteks: {{percent}}% ({{tokens}} tokens)" - }, "settings": { "title": "Pengaturan Obrolan", "manageMode": "Mode Manajemen", - "manageModeDesc": "Izinkan obrolan ini menginstal model, mengganti backend, dan mengedit konfigurasi dengan berbicara melalui LocalAI.", - "systemPrompt": "System Prompt", - "systemPromptPlaceholder": "Anda adalah asisten yang membantu...", - "temperature": "Temperatur", - "topP": "Top P", - "topK": "Top K", - "contextSize": "Ukuran Konteks", - "contextSizePlaceholder": "2048", - "clearHistory": "Hapus riwayat obrolan" + "serverKept": "Server menyimpan obrolan ini. Model dan agennya ditetapkan saat dimulai.", + "manageModeOn": "Obrolan ini memiliki alat pengelolaan LocalAI. Gunakan /assistant untuk memulai yang lain.", + "agent": "Agen", + "agentHelp": "Obrolan ini memakai agen {{name}}: instruksinya dan hanya alat yang diizinkannya.", + "editAgent": "Ubah agen", + "deleteHelp": "Hapus obrolan ini dan pesannya secara permanen." }, "empty": { "manageTitle": "Kelola LocalAI dengan obrolan", @@ -61,7 +54,6 @@ "Gali ide" ], "recent": "Terbaru", - "noMessages": "Belum ada pesan", "hintEnter": "Enter untuk mengirim", "hintShiftEnter": "Shift+Enter untuk baris baru", "hintAttach": "Lampirkan file" @@ -71,29 +63,17 @@ }, "actions": { "copy": "Salin", - "edit": "Edit", - "editMessage": "Edit pesan", - "save": "Simpan", - "cancel": "Batal", "regenerate": "Hasilkan ulang", - "jumpToLatest": "Lompat ke terbaru" + "jumpToLatest": "Lompat ke terbaru", + "reuse": "Ubah dan kirim lagi" }, "streaming": { "transferring": "Mentransfer model...", "transferringTo": "Mentransfer model ke {{node}}..." }, - "tokens": { - "perSec": "{{count}} tok/s", - "peak": "Puncak: {{count}} tok/s", - "usage": "{{prompt}}p + {{completion}}c = {{total}}" - }, "input": { - "placeholder": "Pesan...", - "attachFile": "Lampirkan file", "stopGenerating": "Hentikan pembuatan", - "canvasTitle": "Canvas — ekstrak blok kode dan media ke panel samping untuk pratinjau, salin, dan unduh", - "canvasLabel": "Canvas", - "openCanvas": "Buka panel canvas" + "placeholderQueue": "Model sedang bekerja. Pesan yang dikirim sekarang menunggu langkah berikutnya." }, "deleteAllDialog": { "title": "Hapus Semua Obrolan", @@ -104,7 +84,7 @@ "selectModel": "Silahkan pilih model", "copied": "Berhasil disalin ke papan klip", "copyFailed": "Gagal menyalin ke papan klip", - "pdfScannedAsImages": "{{name}} tidak memiliki lapisan teks (pindaian). {{count}} dari {{total}} halaman dilampirkan sebagai gambar untuk model visi." + "newChatForModel": "Obrolan mempertahankan modelnya. Obrolan baru dimulai dengan {{model}}." }, "menu": { "trigger": "Obrolan", @@ -118,17 +98,492 @@ "deleteChat": "Hapus obrolan", "newChat": "Obrolan baru", "clearAll": "Hapus semua", - "deleteAllTitle": "Hapus semua percakapan" + "deleteAllTitle": "Hapus semua percakapan", + "untitled": "Obrolan baru", + "origin": { + "trigger": "Dimulai oleh pemicu", + "connector": "Dari konektor" + } }, "message": { - "you": "You" + "you": "You", + "loopRun": "Perulangan, jalan ke-{{run}}" }, - "addons": { - "note": "An add-on that is off here does nothing in this chat.", - "label": "Add-ons", - "title": "Add-on tools for this chat", - "heading": "Add-on tools", - "selectAll": "Select all", - "deselectAll": "Deselect all" + "tools": { + "label": "Alat", + "title": "Alat yang bisa dipakai obrolan ini", + "heading": "Alat untuk obrolan ini", + "draftNote": "Chat baru dimulai dengan sakelar ini. Alat yang dimatikan tidak ditawarkan ke model.", + "note": "Alat yang dimatikan di sini tidak ditawarkan ke model di obrolan ini.", + "count": "{{on}} dari {{total}} aktif", + "selectAll": "Aktifkan semua", + "disableAll": "Matikan semua", + "groupOn": "Semua aktif", + "groupOff": "Semua mati", + "groupOnLabel": "Aktifkan semua di {{group}}", + "groupOffLabel": "Matikan semua di {{group}}", + "groupCount": "{{on}} dari {{total}}", + "optIn": "Mati sampai Anda menyalakannya. Aktifkan semua tidak mengubahnya.", + "empty": "Tidak ada yang bisa diubah di obrolan ini.", + "failed": "Gagal menyimpan. Sakelar kembali seperti semula.", + "unavailable": "Tidak tersedia di obrolan ini", + "toolCount_one": "{{count}} alat", + "toolCount_other": "{{count}} alat" + }, + "skills": { + "label": "Keahlian", + "manage": "Kelola keahlian", + "title": "Keahlian yang bisa dipakai obrolan ini", + "heading": "Keahlian untuk obrolan ini", + "count": "{{on}} dari {{total}} aktif", + "selectAll": "Pilih semua", + "disableAll": "Nonaktifkan semua", + "selectShown": "Pilih yang ditampilkan", + "disableShown": "Nonaktifkan yang ditampilkan", + "search": "Cari keahlian", + "noMatch": "Tidak ada keahlian yang cocok dengan “{{query}}”.", + "later": "Keahlian yang Anda tambahkan nanti mulai dalam keadaan aktif.", + "failed": "Gagal menyimpan. Tidak ada yang berubah.", + "source": { + "builtin": "Bawaan", + "personal": "Pribadi", + "shared": "Bersama", + "folder": "Ruang kerja", + "addon": "{{name}}" + }, + "replacedBy": "Digantikan oleh {{name}}", + "fallback_one": "{{skills}} bawaan dipakai: skill milik add-on {{name}} mati atau tidak berjalan di sini.", + "fallback_other": "{{skills}} bawaan dipakai: skill milik add-on {{name}} mati atau tidak berjalan di sini." + }, + "agent": { + "label": "Agen", + "title": "Agen tersimpan untuk obrolan ini", + "heading": "Mulai dengan agen", + "note": "Agen memberi obrolan instruksinya dan tepat alat yang diizinkannya.", + "none": "Tanpa agen", + "fixed": "Agen obrolan ini" + }, + "assistant": { + "chatTitle": "Asisten LocalAI" + }, + "slash": { + "delete": { + "label": "Hapus obrolan", + "desc": "Hapus percakapan ini" + }, + "assistant": { + "desc": "Mulai obrolan yang bisa mengelola LocalAI" + }, + "settings": { + "desc": "Mode fokus, agen, dan info model" + }, + "group": { + "context": "Konteks", + "skills": "Skill dan agen", + "help": "Bantuan" + }, + "models": { + "label": "Model", + "desc": "Buka daftar model" + }, + "skill": { + "label": "Jalankan skill", + "desc": "Pilih salah satu skill di chat ini" + }, + "skills": { + "label": "Kelola keahlian", + "desc": "Buka halaman keahlian untuk menulis dan mengubahnya" + }, + "agent": { + "label": "Tanya agen tersimpan", + "desc": "Serahkan tugas ke salah satu agen Anda", + "enable": "Aktifkan agen tersimpan untuk chat ini", + "enableDesc": "Lalu pilih agennya" + }, + "compact": { + "label": "Padatkan konteks", + "desc": "Ringkas pesan lama agar model punya ruang" + }, + "clear": { + "label": "Kosongkan konteks", + "desc": "Mulai dari awal di chat ini; pesan tetap ada" + }, + "attach": { + "label": "Lampirkan file", + "desc": "Pilih file untuk dikirim", + "clearDesc": "Hapus file yang menunggu dikirim" + }, + "help": { + "label": "Semua perintah", + "desc": "Tampilkan setiap perintah dan fungsinya" + }, + "about": { + "label": "Tentang chat ini", + "desc": "Model, alat, skill, dan ukuran konteks" + }, + "args": { + "skill": "Skill", + "agent": "Agen tersimpan", + "model": "Model", + "attach": "File", + "loop": "Perulangan", + "goal": "Tujuan" + }, + "badge": { + "builtin": "bawaan", + "addon": "add-on", + "shared": "bersama", + "yours": "milik Anda" + }, + "collides": "hanya dengan /skill", + "errors": { + "skill": "Chat ini tidak punya skill bernama \"{{name}}\".", + "agent": "Anda tidak punya agen tersimpan bernama \"{{name}}\".", + "agentTask": "Tulis tugas untuk {{name}} setelah namanya.", + "agentsOff": "Agen tersimpan nonaktif untuk chat ini. Aktifkan di menu Alat." + }, + "loop": { + "label": "Ulangi prompt", + "desc": "Jalankan prompt berulang kali di obrolan ini: /loop 5m periksa build", + "listDesc": "Tampilkan perulangan obrolan ini", + "stopDesc": "Hentikan satu perulangan, atau semuanya", + "stopAll": "Semua perulangan", + "stopAllDesc": "Hentikan semua perulangan obrolan ini" + }, + "goal": { + "label": "Tujuan", + "desc": "Tetapkan tujuan yang terus dikerjakan chat sendiri", + "clearDesc": "Hapus tujuan", + "resumeDesc": "Teruskan tujuan" + } + }, + "shortcuts": { + "onMessage": "Pada pesan: salin, ubah, buat ulang", + "editLast": "Ubah pesan terakhir Anda" + }, + "files": { + "title": "File di obrolan ini", + "chip": "File", + "chipTitle": "File di obrolan ini: yang Anda lampirkan dan yang dibuat atau diubah asisten", + "close": "Tutup file", + "emptyTitle": "Belum ada file di obrolan ini", + "emptyBody": "Letakkan file di sini untuk melampirkannya ke pesan berikutnya. File yang dibuat atau diubah asisten juga muncul di sini.", + "dropHint": "Letakkan file di sini untuk melampirkannya ke pesan berikutnya.", + "groups": { + "uploaded": "Dari Anda", + "created": "Dibuat dan diubah", + "working": "File kerja" + }, + "turn": "giliran {{n}}", + "media": "Dalam konteks", + "mediaTitle": "Model menerima file ini sebagai gambar atau video bersama pesan Anda", + "changedTag": "Diedit", + "changedTitle": "Asisten mengubah file ini", + "download": "Unduh {{name}}", + "error": "File obrolan ini tidak dapat dibaca.", + "workingNote": "Skrip, data, dan log yang dibuat asisten selama bekerja. Semuanya disimpan di folder kerja chat ini." + }, + "steps": { + "verb": { + "read": "Membaca", + "list": "Mendaftar", + "write": "Menyimpan", + "create": "Membuat", + "change": "Mengubah", + "move": "Memindahkan", + "delete": "Menghapus", + "search": "Mencari", + "run": "Menjalankan", + "fetch": "Mengambil", + "websearch": "Mencari di web:", + "webfetch": "Membaca", + "delegate": "Bertanya ke agen", + "used": "Memakai" + }, + "worked": "Bekerja selama {{duration}}", + "thoughtFor": "Berpikir selama {{duration}}", + "working": "Bekerja", + "thinking": "Berpikir", + "noMatches": "tidak ada hasil", + "noResults": "tidak ada hasil", + "emptyFolder": "folder kosong", + "size": "{{size}}", + "seconds": "{{value}} dtk", + "minutes": "{{min}} mnt {{sec}} dtk", + "tool": "Alat", + "input": "Masukan", + "showLess": "Tampilkan lebih sedikit", + "running": "Berjalan", + "failed": "Gagal", + "count_one": "{{count}} langkah", + "count_other": "{{count}} langkah", + "files_one": "{{count}} file", + "files_other": "{{count}} file", + "items_one": "{{count}} item", + "items_other": "{{count}} item", + "matches_one": "{{count}} hasil", + "matches_other": "{{count}} hasil", + "results_one": "{{count}} hasil", + "results_other": "{{count}} hasil", + "lines_one": "{{count}} baris", + "lines_other": "{{count}} baris", + "showAll_one": "Tampilkan semua {{count}} baris", + "showAll_other": "Tampilkan semua {{count}} baris", + "details": "Tampilkan detail", + "openFile": "Buka {{path}} di File", + "savedFiles": "Disimpan di file Anda:", + "fileOp": { + "changed": "diubah", + "deleted": "dihapus" + }, + "moreFiles": "dan {{count}} lainnya", + "alsoSavedWorking_one": "Juga menyimpan {{count}} file kerja", + "alsoSavedWorking_other": "Juga menyimpan {{count}} file kerja", + "savedWorking_one": "Menyimpan {{count}} file kerja", + "savedWorking_other": "Menyimpan {{count}} file kerja", + "workingTitle": "File kerja (skrip, log, atau draf): buka {{path}}" + }, + "error": { + "title": "Balasan gagal", + "kept": "Yang sudah tertulis tetap ada di atas.", + "retry": "Coba lagi", + "detail": "Detail teknis", + "reason": { + "unknown": "Agen berhenti karena kesalahan. Coba lagi untuk menjalankan giliran ini kembali.", + "model_backend_missing": "Model \"{{model}}\" tidak dapat dimulai: backend-nya belum terpasang.", + "model_backend_missing_anon": "Model tidak dapat dimulai: backend-nya belum terpasang.", + "model_cooldown": "Model \"{{model}}\" gagal dimulai beberapa saat lalu dan sedang beristirahat.", + "model_cooldown_anon": "Model gagal dimulai beberapa saat lalu dan sedang beristirahat.", + "model_load_failed": "Model \"{{model}}\" tidak dapat dimulai.", + "model_load_failed_anon": "Model tidak dapat dimulai.", + "model_not_found": "Model \"{{model}}\" tidak ditemukan.", + "model_not_found_anon": "Model tidak ditemukan.", + "upstream_key": "Upstream menolak kunci.", + "upstream_path": "Upstream tidak menerima permintaan ini pada alamat tersebut.", + "upstream_unreachable": "Alamat upstream tidak dapat dijangkau.", + "upstream": "Penyedia di balik model mengembalikan kesalahan.", + "timeout": "Model terlalu lama menjawab.", + "cancelled": "Giliran dibatalkan sebelum model menjawab." + }, + "hint": { + "model_backend_missing": "Pilih model lain, atau minta administrator memasang backend.", + "model_backend_missing_admin": "Pilih model lain, atau pasang backend dari halaman Backend.", + "model_cooldown": "Coba lagi dalam {{seconds}} detik.", + "model_load_failed": "Periksa konfigurasi model dan backend-nya, lalu coba lagi.", + "model_not_found": "Pilih model lain, atau pasang model ini dari halaman Model.", + "upstream_key": "Periksa kunci API di pengaturan model.", + "upstream_path": "Alamat upstream harus berupa endpoint lengkap, misalnya .../v1/chat/completions.", + "upstream_unreachable": "Periksa alamat di pengaturan model.", + "upstream": "Coba lagi sebentar lagi. Jika terus gagal, periksa alamat dan kunci penyedia.", + "timeout": "Coba lagi, atau gunakan model yang lebih kecil atau lebih cepat.", + "cancelled": "Coba lagi saat Anda siap." + }, + "openBackends": "Buka Backend" + }, + "allChats": { + "title": "Semua obrolan", + "supporting": "Semua obrolan yang disimpan. Cari, ganti nama, ekspor, atau hapus.", + "loadFailed": "Tidak dapat memuat obrolan Anda.", + "emptyTitle": "Belum ada obrolan", + "emptyBody": "Obrolan dimulai di Beranda: ketik pesan lalu kirim. Setelah itu obrolan Anda tercantum di sini.", + "start": "Mulai obrolan" + }, + "foot": { + "state": { + "ready": "Siap", + "nomodel": "Tidak ada model", + "sending": "Mengirim", + "loading": "Memuat model", + "thinking": "Berpikir", + "tool": "Menjalankan {{tool}}", + "answering": "Menjawab", + "waiting": "Menunggu Anda", + "stopped": "Dihentikan", + "failed": "Gagal", + "interrupted": "Terputus", + "paused": "Dijeda", + "compacting": "Memadatkan konteks", + "clearing": "Mengosongkan konteks" + }, + "tool": { + "search": "Mencari", + "list": "Mendaftar", + "read": "Membaca", + "create": "Membuat", + "write": "Menulis", + "change": "Mengubah", + "move": "Memindahkan", + "delete": "Menghapus", + "run": "Menjalankan perintah", + "fetch": "Mengambil", + "delegate": "Bertanya ke sub-agen" + }, + "hint": "Enter untuk mengirim · Shift+Enter untuk baris baru", + "meter": { + "label": "Konteks terisi {{pct}}%. Tampilkan konteks dan kecepatan", + "labelNoLimit": "Konteks {{used}} token. Tampilkan konteks dan kecepatan" + }, + "details": { + "title": "Konteks dan kecepatan", + "context": "Konteks", + "contextOf": "{{used}} dari {{limit}} token ({{pct}}%)", + "contextUsed": "{{used}} token", + "contextLeft": "Sisa {{left}} token", + "contextHelp": "Konteks adalah apa yang dibaca model pada setiap permintaan: instruksi, obrolan ini sejauh ini, dan hasil alat.", + "nearFull": "Obrolan ini mendekati batas. Server memadatkan bagian yang lebih lama agar model bisa melanjutkan. Transkrip menyimpan setiap pesan.", + "limitUnknown": "Server tidak menyebutkan seberapa besar jendela konteksnya.", + "lastRequest": "Permintaan terakhir", + "model": "Model", + "backend": "Backend", + "node": "Node", + "tokensIn": "Token masuk", + "tokensOut": "Token keluar", + "firstToken": "Waktu hingga token pertama", + "promptTime": "Pemrosesan prompt", + "speed": "Kecepatan", + "speedLive": "Kecepatan, perkiraan saat streaming", + "average": "Rata-rata {{count}} permintaan terakhir", + "note": "Angka ini berasal dari permintaan yang dilihat halaman ini. Server tidak menyimpannya, jadi memuat ulang akan menghapusnya." + }, + "paused": { + "after": "setelah {{count}} langkah", + "atLimit": "di batas langkah", + "continue": "Lanjutkan", + "continueTitle": "Model telah memakai semua langkah dalam satu giliran. Biarkan ia melanjutkan dari tempat ia berhenti.", + "message": "Lanjutkan dari tempat kamu berhenti." + } + }, + "attachments": { + "label": "File terlampir", + "open": "Buka {{name}}", + "showMore": "Tampilkan {{count}} lagi", + "missing": "File tidak lagi tersedia" + }, + "preview": { + "open": "Buka pratinjau", + "created": "Dibuat oleh asisten", + "changed": "Diubah oleh asisten", + "yours": "Dari Anda", + "fromReply": "Dari jawaban", + "download": "Unduh", + "downloadCode": "Unduh kode", + "copy": "Salin", + "copied": "Disalin", + "openInFiles": "Buka di File", + "actions": "Tindakan file", + "opened": "Pratinjau dibuka: {{name}}", + "chip": { + "created": "Dibuat", + "changed": "Diubah", + "label": "{{verb}} {{name}}, buka pratinjau", + "row": "File dari jawaban ini" + } + }, + "meter": { + "usedOf": "{{used}} / {{limit}} token ({{pct}}%)", + "used": "{{used}} token", + "speed": "{{speed}} tok/dtk", + "title": "Konteks yang dipakai permintaan model terakhir", + "ofLimit": "{{used}} / {{limit}} token", + "left": "sisa {{left}}" + }, + "goal": { + "label": "Tujuan", + "states": { + "active": "Sedang dikerjakan", + "paused": "Dijeda", + "done": "Tercapai", + "blocked": "Butuh Anda" + }, + "pausedBy": { + "user": "Anda menjedanya.", + "turn_limit": "Semua giliran otomatisnya sudah terpakai. Lanjutkan untuk memberi lebih banyak.", + "step_limit": "Semua langkahnya sudah terpakai. Lanjutkan untuk memberi lebih banyak.", + "error": "Sebuah giliran gagal. Lanjutkan untuk mencoba lagi.", + "interrupted": "Server berhenti saat sebuah giliran berjalan. Lanjutkan untuk meneruskan." + }, + "blockedNote": "Butuh dari Anda: {{note}}", + "doneNote": "{{note}}", + "replaced": "Diganti: {{text}}", + "pause": "Jeda", + "pauseTitle": "Hentikan giliran yang berjalan dan jeda tujuan", + "resume": "Lanjutkan", + "resumeTitle": "Teruskan tujuan", + "clear": "Hapus", + "clearTitle": "Hapus tujuan", + "progress": "Tujuan · giliran {{turn}} dari {{max}}", + "progressStart": "Tujuan · memulai", + "markers": { + "set": "Tujuan ditetapkan", + "replaced": "Tujuan diganti", + "continue": "Melanjutkan menuju tujuan · giliran {{turn}}", + "continueStart": "Melanjutkan menuju tujuan", + "paused": "Tujuan dijeda", + "resumed": "Tujuan dilanjutkan", + "done": "Tujuan tercapai", + "blocked": "Tujuan terhambat", + "cleared": "Tujuan dihapus" + }, + "announce": { + "set": "Tujuan ditetapkan: {{text}}", + "paused": "Tujuan dijeda. {{why}}", + "resumed": "Tujuan dilanjutkan", + "done": "Tujuan tercapai: {{note}}", + "blocked": "Tujuan membutuhkan Anda: {{note}}", + "cleared": "Tujuan dihapus" + }, + "card": { + "title": "Tujuan", + "none": "Chat ini belum punya tujuan. Ketik /goal lalu apa yang ingin diselesaikan, dan chat akan terus mengerjakannya sendiri.", + "turns": "Giliran {{turn}} dari {{max}}", + "steps": "{{steps}} dari {{max}} langkah", + "clear": "/goal clear menghapusnya.", + "resume": "/goal resume melanjutkannya.", + "turnsLabel": "Giliran otomatis", + "stepsLabel": "Langkah", + "setLabel": "Ditetapkan", + "stateLabel": "Status", + "noteLabel": "Catatan" + }, + "errors": { + "empty": "Tulis tujuannya setelah /goal.", + "notAllowed": "Hanya chat yang Anda mulai sendiri yang bisa punya tujuan." + } + }, + "invoked": { + "skill": "Skill: {{name}}", + "agent": "Agen: {{name}}" + }, + "marker": { + "compacted": "Konteks dipadatkan: {{before}} menjadi {{after}} token", + "cleared": "Konteks dikosongkan", + "clearedHint": "Model tidak melihat pesan di atas garis ini." + }, + "cmdcard": { + "close": "Tutup", + "helpTitle": "Perintah", + "helpSkills_one": "Skill: {{names}}. Ketik / lalu namanya.", + "helpSkills_other": "{{count}} skill: {{names}}. Ketik / lalu namanya.", + "helpKeys": "Ketik / di kotak untuk membuka daftar. Tab melengkapi, Enter menjalankan, Esc menutup.", + "aboutTitle": "Chat ini", + "model": "Model", + "state": "Status", + "loaded": "Dimuat", + "notLoaded": "Belum dimuat", + "backend": "Backend", + "tools": "Alat", + "toolsOn_one": "{{count}} aktif", + "toolsOn_other": "{{count}} aktif", + "skills": "Skill", + "skillsOn_one": "{{count}} skill", + "skillsOn_other": "{{count}} skill", + "commands": "Perintah", + "isolated": "Berjalan di sandbox terisolasi", + "host": "Berjalan di mesin ini", + "noCommands": "Tidak bisa dijalankan", + "context": "Konteks", + "contextOf": "{{used}} dari {{limit}} token", + "contextUsed": "{{used}} token", + "unknown": "Belum diketahui" } } diff --git a/core/http/react-ui/public/locales/id/chats.json b/core/http/react-ui/public/locales/id/chats.json new file mode 100644 index 000000000..43fc4e396 --- /dev/null +++ b/core/http/react-ui/public/locales/id/chats.json @@ -0,0 +1,581 @@ +{ + "proposal": { + "loading": "Membaca pemicu yang diusulkan...", + "unread": "Pemicu yang diusulkan tidak dapat dibaca: {{message}}", + "removed": "Pemicu ini sudah dihapus.", + "retry": "Coba lagi", + "inactive": "Pemicu yang diusulkan. Tidak menjalankan apa pun sampai Anda membuatnya.", + "name": "Nama", + "schedule": "Kapan (misalnya daily 08:00)", + "folder": "Folder yang dipantau", + "saveEdit": "Simpan", + "cancelEdit": "Batal", + "create": "Buat pemicu", + "edit": "Ubah", + "dismiss": "Tolak", + "receipt": { + "scheduled": "Dijadwalkan:", + "watching": "Memantau:", + "next": "Eksekusi berikutnya {{when}}", + "view": "Lihat pemicu", + "undo": "Urungkan", + "undone": "Diurungkan:", + "again": "Jadwalkan lagi", + "declined": "Ditolak:", + "restore": "Pulihkan", + "blocked": { + "ran": "Pemicu ini sudah pernah berjalan; hapus dari halaman Pemicu.", + "edited": "Anda sudah mengubahnya; ubah atau hapus dari halaman Pemicu." + } + }, + "announce": { + "accepted": "{{name}} dijadwalkan.", + "undone": "{{name}} diurungkan. Tidak dijadwalkan.", + "declined": "{{name}} ditolak.", + "reopened": "{{name}} kembali menunggu jawaban Anda." + }, + "plan": { + "whenLabel": "Kapan", + "runsAsLabel": "Berjalan sebagai", + "readsLabel": "Membaca", + "writesLabel": "Menulis", + "costLabel": "Biaya", + "stopLabel": "Untuk menghentikannya", + "when": "{{schedule}}, {{zone}}. Jalan pertama {{when}}.", + "whenNoFirst": "{{schedule}}, {{zone}}.", + "whenFile": "Saat sebuah file masuk ke {{folder}}.", + "runsAs": "Anda", + "runsAsModel": "Anda, dengan {{model}}", + "writesWidget": ", ditampilkan di widget \"{{widget}}\" di Beranda", + "costLocal_other": "Sekitar {{tokens}} token per jalan pada model lokal. Paling banyak {{count}} jalan per hari.", + "costRemote_other": "Sekitar {{tokens}} token per jalan pada {{model}}, layanan jarak jauh. Paling banyak {{count}} jalan per hari.", + "costUnknown_other": "Token per jalan diketahui setelah jalan pertama. Paling banyak {{count}} jalan per hari.", + "costLocal_one": "Sekitar {{tokens}} token per jalan pada model lokal. Paling banyak {{count}} jalan per hari.", + "costRemote_one": "Sekitar {{tokens}} token per jalan pada {{model}}, layanan jarak jauh. Paling banyak {{count}} jalan per hari.", + "costUnknown_one": "Token per jalan diketahui setelah jalan pertama. Paling banyak {{count}} jalan per hari.", + "remoteNote": "{{model}} adalah layanan jarak jauh: apa yang dibaca sebuah jalan meninggalkan server ini.", + "stopWidget": "Jeda dari widget, atau hapus di Pemicu", + "stop": "Matikan atau hapus di Pemicu" + } + }, + "mcpPage": { + "title": "Konektor MCP", + "supporting": "Hubungkan server MCP untuk memberi chat dan agen Anda lebih banyak alat, seperti pencarian atau API Anda sendiri.", + "family": "Bot untuk Telegram atau Slack adalah konektor pesan.", + "related": "Terkait", + "chats": "Chat", + "add": "Tambah konektor", + "learn": "Cara kerja MCP", + "loading": "Memuat konektor Anda", + "loadFailed": "Tidak dapat memuat konektor Anda.", + "forbidden": "MCP tidak tersedia untuk akun Anda.", + "empty": { + "title": "Belum ada konektor", + "body": "Konektor menghubungkan server MCP ke LocalAI. Chat dan agen Anda lalu dapat memakai alatnya, misalnya untuk mencari di dokumentasi Anda atau membaca kalender.", + "fact1": "Server MCP apa pun lewat http atau https", + "fact2": "Token dan header disimpan terenkripsi", + "fact3": "Uji dulu sebelum chat memakainya" + }, + "toolbar": { + "search": "Cari konektor", + "testAll": "Uji semua", + "testingAll": "Menguji", + "testedAll": "Konektor yang aktif sudah diuji." + }, + "filter": { + "label": "Tampilkan", + "all": "Semua", + "on": "Aktif", + "attention": "Perlu perhatian", + "off": "Nonaktif", + "showAll": "Tampilkan semua", + "none": { + "all": "Tidak ada konektor.", + "on": "Tidak ada konektor yang aktif.", + "attention": "Tidak ada yang perlu perhatian Anda.", + "off": "Semua konektor aktif." + } + }, + "noMatch": { + "title": "Tidak ada konektor yang cocok dengan “{{query}}”", + "body": "Cari berdasarkan nama atau alamat.", + "clear": "Hapus pencarian" + }, + "state": { + "label": "Status: {{state}}", + "ok": "Terhubung", + "auth": "Perlu masuk", + "error": "Tidak terhubung", + "unchecked": "Belum diuji", + "off": "Nonaktif" + }, + "row": { + "open": "{{name}}, {{state}}. Lihat detail", + "toggle": "Pakai {{name}} di chat", + "menu": "Tindakan untuk {{name}}", + "offLine": "Nonaktif. Chat dan agen tidak memakainya.", + "notChecked": "Belum diuji. Uji untuk melihat alatnya.", + "noTools": "Terhubung, tetapi server tidak menawarkan alat.", + "tools_one": "{{count}} alat", + "tools_other": "{{count}} alat", + "checked": "Diuji {{when}}", + "tokenSaved": "Token tersimpan", + "headersSaved": "Header tersimpan", + "envSaved": "Variabel tersimpan" + }, + "actions": { + "details": "Detail", + "test": "Uji koneksi", + "testNamed": "Uji koneksi ke {{name}}", + "testing": "Menguji", + "edit": "Ubah", + "reauth": "Ganti token", + "remove": "Hapus" + }, + "details": { + "use": "Pakai di chat", + "useHelp": "Chat tanpa agen memakai semua konektor yang aktif. Agen tersimpan hanya memakai yang Anda pilih untuknya.", + "lastCheck": "Uji terakhir", + "never": "Belum diuji di browser ini.", + "okAt": "Terhubung {{when}}.", + "testedAt": "Diuji {{when}}.", + "errorTitle": "Uji terakhir tidak dapat terhubung.", + "authTitle": "Server menolak proses masuk.", + "tools": "Alat", + "toolsUnknown": "Uji koneksi untuk melihat alatnya.", + "toolsNone": "Server tidak menawarkan alat.", + "toolsSearch": "Cari alat", + "noToolMatch": "Tidak ada alat yang cocok dengan “{{query}}”.", + "noDescription": "Tidak ada deskripsi.", + "connection": "Koneksi", + "kind": "Jenis", + "address": "Alamat", + "command": "Perintah", + "added": "Ditambahkan", + "queryHidden": "Bagian alamat setelah ? disembunyikan karena dapat berisi kunci.", + "signIn": "Masuk", + "token": "Token bearer", + "headers": "Header", + "env": "Variabel", + "saved": "Tersimpan", + "none": "Tidak ada", + "encrypted": "Disimpan terenkripsi di server. Tidak pernah ditampilkan lagi." + }, + "sheet": { + "addTitle": "Tambah konektor", + "editTitle": "Ubah {{name}}", + "addDesc": "Hubungkan server MCP. Anda mengujinya sebelum chat memakainya.", + "editDesc": "Rahasia yang tersimpan tetap ada kecuali Anda mengganti atau menghapusnya.", + "testDesc": "LocalAI terhubung dengan yang Anda simpan dan menampilkan daftar alatnya.", + "close": "Tutup", + "kind": "Jenis", + "remote": "Server jarak jauh", + "remoteHelp": "Server MCP yang dijangkau mesin ini lewat http atau https.", + "local": "Perintah lokal", + "localHelp": "Program yang dijalankan LocalAI di mesin ini. Hanya administrator.", + "basics": "Koneksi", + "name": "Nama", + "nameHelp": "Muncul di menu Alat pada chat.", + "url": "Alamat", + "urlHelp": "Server di jaringan privat ditolak kecuali administrator mengizinkannya.", + "command": "Perintah", + "commandHelp": "Program yang dijalankan, tanpa shell.", + "args": "Argumen", + "argsHelp": "Satu argumen per baris.", + "signIn": "Masuk", + "signInHelp": "Hanya jika server meminta kunci.", + "envIntro": "Variabel yang diterima perintah, misalnya kunci API.", + "token": "Token bearer", + "tokenHelp": "Dikirim sebagai header Authorization.", + "tokenSaved": "Token sudah tersimpan. Ketik token baru untuk menggantinya.", + "tokenSavedShort": "Tersimpan", + "clearToken": "Hapus token yang tersimpan", + "headers": "Header", + "headersHelp": "Header lain yang dibutuhkan server, seperti header kunci API.", + "headersSaved": "Header sudah tersimpan. Baris baru menggantikan semuanya.", + "clearHeaders": "Hapus header yang tersimpan", + "headerName": "Nama header", + "headerValue": "Nilai", + "addHeader": "Tambah header", + "env": "Variabel lingkungan", + "envSaved": "Variabel sudah tersimpan. Baris baru menggantikan semuanya.", + "clearEnv": "Hapus variabel yang tersimpan", + "envName": "Variabel", + "addEnv": "Tambah variabel", + "removeRow": "Hapus baris {{n}}", + "showValue": "Tampilkan nilai", + "hideValue": "Sembunyikan nilai", + "encrypted": "Token, header, dan variabel disimpan terenkripsi di server dan tidak pernah ditampilkan lagi.", + "cancel": "Batal", + "saving": "Menyimpan", + "saveAndTest": "Simpan dan uji", + "savedOn": "Tersimpan. Konektor aktif.", + "savedOff": "Tersimpan. Tetap nonaktif sampai Anda mengaktifkannya.", + "testing": "Menghubungkan ke {{name}}…", + "okTitle": "Terhubung.", + "okBody_one": "{{name}} menawarkan {{count}} alat.", + "okBody_other": "{{name}} menawarkan {{count}} alat.", + "okNoTools": "Server tidak menawarkan alat.", + "failTitle": "Tidak dapat terhubung.", + "failHint": "Periksa alamatnya, lalu uji lagi.", + "failAuth": "Periksa token atau header.", + "back": "Ubah detail", + "retry": "Uji lagi", + "done": "Selesai", + "keepOff": "Biarkan nonaktif", + "turnOn": "Aktifkan", + "discardTitle": "Buang perubahan Anda?", + "discardBody": "Yang Anda ketik di formulir ini akan hilang.", + "discard": "Buang", + "keepEditing": "Lanjut mengubah" + }, + "errors": { + "nameRequired": "Beri nama.", + "nameTaken": "Konektor lain sudah memakai nama ini.", + "urlRequired": "Masukkan alamat server.", + "urlInvalid": "Ini bukan alamat yang valid.", + "urlScheme": "Alamat harus diawali http:// atau https://.", + "urlCredentials": "Letakkan kredensial di token atau header, bukan di alamat.", + "commandRequired": "Masukkan perintah yang dijalankan." + }, + "remove": { + "title": "Hapus {{name}}?", + "body": "Konektor dihapus bersama token, header, dan variabel yang tersimpan. Agen tersimpan yang memakainya tidak dapat lagi memanggil alatnya.", + "confirm": "Hapus", + "pending": "Menghapus" + }, + "toasts": { + "removed": "“{{name}}” dihapus.", + "on": "“{{name}}” aktif.", + "off": "“{{name}}” nonaktif." + }, + "import": { + "title": "Server yang tersimpan di browser ini", + "text": "Halaman chat lama menyimpan server MCP ini di browser ini. Impor agar tersimpan di server, sehingga setiap browser dan sesi dapat memakainya. Server dibuat dalam keadaan nonaktif. Token dan header-nya dikirim ke server lalu dihapus dari browser ini.", + "button": "Impor {{count}} server dari browser ini", + "buttonOne": "Impor 1 server dari browser ini", + "busy": "Mengimpor", + "headersCount": "{{count}} header", + "done": "{{count}} diimpor. Semuanya nonaktif: uji dan aktifkan di bawah.", + "failed": "{{name}} tidak diimpor: {{error}}", + "left": "Server yang tidak diimpor tetap ada di browser ini." + } + }, + "connectorsPage": { + "loadFailed": "Konektor Anda tidak dapat dimuat.", + "waitingTitle": "Menunggu add-on-nya", + "waitingText": "Tautan ini tetap disimpan, tetapi tidak berjalan sampai add-on-nya dipasang dan diaktifkan." + }, + "tools": { + "commands": { + "title": "Perintah tidak bisa dijalankan di sini", + "none": "Tidak ada add-on yang menjalankan kode, jadi asisten bisa menulis file tetapi tidak bisa menjalankan skrip atau perintah.", + "off": "Add-on Sandbox dimatikan untuk chat ini. Nyalakan di menu Alat agar asisten bisa menjalankan kode.", + "unavailable": "Add-on Sandbox terpasang, tetapi tidak bisa menjalankan perintah di server ini.", + "unavailableWhy": "Add-on Sandbox tidak bisa menjalankan perintah di server ini: {{reason}}", + "install": "Pasang Sandbox", + "openSandbox": "Buka add-on Sandbox", + "askInstall": "Minta administrator memasang add-on Sandbox.", + "askFix": "Minta administrator memperbaikinya.", + "hide": "Sembunyikan", + "hideLabel": "Sembunyikan pemberitahuan ini", + "chipTitle": "Chat ini tidak bisa menjalankan kode atau perintah. Pilih untuk detail.", + "hostTitle": "Perintah berjalan di mesin ini", + "host": "Perintah berjalan di mesin ini, dengan berkas dan jaringannya, tanpa isolasi. Pasang Sandbox untuk isolasi.", + "hostOff": "Sandbox mati untuk chat ini, jadi perintah berjalan di mesin ini, dengan berkas dan jaringannya, tanpa isolasi. Nyalakan Sandbox di menu ini untuk mengisolasinya.", + "hostUnavailable": "Sandbox tidak bisa berjalan di sini, jadi perintah berjalan di mesin ini, dengan berkas dan jaringannya, tanpa isolasi.", + "hostUnavailableWhy": "Sandbox tidak bisa berjalan di sini ({{reason}}), jadi perintah berjalan di mesin ini, dengan berkas dan jaringannya, tanpa isolasi.", + "hostChipTitle": "Perintah di chat ini berjalan di mesin ini, tanpa isolasi. Pilih untuk detail." + }, + "consent": { + "title": "Jalankan perintah ini di mesin ini?", + "body": "Perintah berjalan di mesin ini tanpa isolasi. Perintah dapat membaca berkas yang bisa dibaca LocalAI dan menjangkau internet serta jaringan Anda. Pasang Sandbox untuk isolasi.", + "once": "Jalankan sekali", + "always": "Selalu di instans ini", + "deny": "Jangan jalankan", + "alwaysHint": "\"Selalu di instans ini\" membuat chat semua pengguna menjalankan perintah di sini tanpa bertanya (Pengaturan > Alat).", + "askAdmin": "Hanya administrator yang dapat mengizinkan perintah di mesin ini. Minta kepada administrator, atau jalankan perintahnya sendiri.", + "expired": "Tidak dijawab: giliran yang meminta sudah selesai, jadi perintah tidak dijalankan." + }, + "provider": { + "sandbox": "Sandbox", + "addon": "Add-on {{name}}", + "host": "Mesin ini, tanpa isolasi", + "hostOnce": "Mesin ini, diizinkan sekali", + "hostDenied": "Tidak dijalankan: ditolak", + "hostTimeout": "Tidak dijalankan: tidak ada jawaban", + "hostAdmin": "Tidak dijalankan: perlu administrator" + }, + "builtins": "Alat bawaan", + "builtin": { + "web": "Web", + "saved_agents": "Agen tersimpan", + "triggers": "Pemicu", + "shell": "Jalankan perintah", + "files": "File Anda", + "media": "Gambar dan audio", + "collections": "Koleksi", + "subagents": "Sub-agen" + }, + "builtinBy": "{{tools}} · bawaan", + "builtinByAddon": "{{tools}} · dilayani oleh {{provider}}", + "servedBy": "Dilayani oleh add-on {{provider}}", + "servedByTool": "{{tool}}: {{provider}}", + "servedByBuiltin": "bawaan", + "sandbox": { + "starting": "Sandbox sedang dimulai. LocalAI memeriksa lagi dalam {{seconds}} dtk." + }, + "fallbackBuiltin": "{{addon}} menggantikan {{tool}} tetapi saat ini tidak bisa melayani chat ini (berhenti, dimatikan di sini, atau tidak diizinkan): {{tool}} bawaan yang dipakai.", + "fallbackNone": "{{addon}} menggantikan {{tool}} tetapi saat ini tidak bisa melayani chat ini, dan {{tool}} bawaan mati: chat ini tidak punya {{tool}}.", + "shell": { + "machine": "Mesin ini", + "isolated": "Perintah berjalan terisolasi" + } + }, + "vision": { + "describedBy": "Model ini tidak dapat melihat gambar. Pilih model lain, atau gambar akan dijelaskan oleh {{model}}.", + "none": "Model ini tidak dapat melihat gambar, dan tidak ada model di sini yang dapat menjelaskannya. Pilih model yang dapat melihat gambar." + }, + "egress": { + "label": "Jaringan", + "preset": { + "off": "Mati", + "registries": "Registri paket", + "all": "Semua situs publik" + }, + "presetHint": { + "off": "Perintah di Sandbox tidak menjangkau jaringan apa pun.", + "registries": "Perintah hanya menjangkau registri paket (PyPI, npm, Go, crates.io, RubyGems, Maven Central, rilis GitHub).", + "all": "Perintah menjangkau semua situs publik." + }, + "limited": "Dibatasi oleh administrator Anda", + "limitedBy": "Administrator Anda mengizinkan paling banyak: {{limit}}.", + "hostNetwork": "Perintah berjalan di mesin ini dan memakai jaringannya. Batas jaringan memerlukan add-on Sandbox.", + "panel": "Aturan dan permintaan ({{count}})", + "rules": "Aturan chat ini", + "cards": "Permintaan chat ini", + "ruleAllow": "Diizinkan untuk chat ini", + "ruleDeny": "Ditolak untuk chat ini", + "aboveLimit": "di atas batas, tidak berlaku", + "remove": "Hapus", + "removeLabel": "Hapus aturan {{host}}", + "card": { + "pending": "Menunggu jawaban", + "allowedOnce": "Diizinkan sekali", + "allowedChat": "Diizinkan untuk chat ini", + "denied": "Ditolak", + "expired": "Tidak dijawab", + "refused": "Ditolak: batas jaringan server tidak mengizinkannya" + }, + "consent": { + "title": "Akses jaringan", + "body": "Sebuah perintah di chat ini ingin menjangkau", + "once": "Izinkan sekali", + "chat": "Izinkan untuk chat ini", + "deny": "Tolak", + "preset": "Chat ini: {{preset}}. Batas dari administrator Anda: {{limit}}.", + "adminOnly": "Host ini ada di dalam jaringan Anda: hanya administrator yang dapat mengizinkannya.", + "answered": "{{state}} oleh {{who}}, {{time}}", + "answeredNoWho": "{{state}}, {{time}}", + "you": "Anda" + }, + "blocked": { + "titleOff": "Akses jaringan dimatikan", + "titleCapped": "Akses jaringan dibatasi", + "bodyOff": "Akses jaringan Sandbox dimatikan di server ini. Sebuah perintah meminta menjangkau", + "bodyCapped": "Server ini hanya mengizinkan {{limit}} untuk jaringan Sandbox. Sebuah perintah meminta menjangkau", + "adminOff": "Administrator dapat menyalakannya di pengaturan {{name}}.", + "adminCapped": "Administrator dapat menaikkan batasnya di pengaturan {{name}}.", + "adminLink": "Buka pengaturan {{name}}", + "askAdmin": "Minta administrator server ini untuk mengubahnya.", + "rowOff": "Mati: administrator belum mengizinkan akses jaringan.", + "runner": "add-on yang menjalankan perintah" + }, + "allowedHosts": "Selalu diizinkan oleh administrator Anda:", + "allowedHostsOff": "Berlaku saat jaringan aktif.", + "allowedHostsNote": "{{count}} host selalu diizinkan oleh administrator Anda.", + "limitNote": "Jaringan chat ini. Administrator menetapkan batas maksimum yang dapat dijangkau sebuah chat.", + "asksAbove": "Chat ini meminta {{choice}}; batas administrator adalah {{limit}}." + }, + "inbox": { + "unread": "{{count}} belum dibaca", + "markAll": "Tandai semua dibaca", + "delete": "Hapus", + "pushOn": "Aktifkan notifikasi browser", + "pushOff": "Matikan notifikasi browser", + "pushDenied": "Notifikasi browser diblokir untuk situs ini.", + "pushUnavailable": "Server ini tidak dapat menyimpan langganan push.", + "clearAll": "Hapus semua", + "clearRead": "Hapus yang dibaca", + "cancel": "Batal", + "markRead": "Tandai dibaca", + "cleared_other": "{{count}} notifikasi dihapus.", + "confirmAll_other": "Hapus semua {{count}} notifikasi? Tindakan ini tidak dapat dibatalkan.", + "confirmRead_other": "Hapus {{count}} notifikasi yang sudah dibaca? Yang belum dibaca tetap ada.", + "onlyRead": "Hanya {{count}} yang dibaca", + "showing": "Menampilkan {{shown}} dari {{total}}", + "showMore": "Tampilkan lebih banyak", + "cleared_one": "{{count}} notifikasi dihapus.", + "confirmAll_one": "Hapus semua {{count}} notifikasi? Tindakan ini tidak dapat dibatalkan.", + "confirmRead_one": "Hapus {{count}} notifikasi yang sudah dibaca? Yang belum dibaca tetap ada.", + "title": "Notifikasi", + "buttonUnread_one": "Notifikasi, {{count}} belum dibaca", + "buttonUnread_other": "Notifikasi, {{count}} belum dibaca", + "filterLabel": "Tampilkan notifikasi", + "filterAll": "Semua", + "filterUnread": "Belum dibaca", + "filterFailed": "Gagal", + "more": "Tindakan lain", + "close": "Tutup", + "day": { + "today": "Hari ini", + "yesterday": "Kemarin", + "earlier": "Sebelumnya" + }, + "emptyTitle": "Belum ada notifikasi", + "emptyBody": "Hasil pemicu dan konektor Anda muncul di sini, termasuk pekerjaan yang selesai saat Anda pergi.", + "caughtUp": "Semua sudah Anda baca.", + "caughtUpTitle": "Semua sudah Anda baca", + "caughtUpBody": "Tidak ada notifikasi yang belum dibaca.", + "noFailedTitle": "Tidak ada yang gagal", + "noFailedBody": "Proses dan konektor yang gagal muncul di sini.", + "loadError": "Notifikasi Anda tidak dapat dimuat.", + "source": { + "trigger": "Pemicu", + "connector": "Konektor", + "system": "LocalAI" + }, + "itemUnread": "{{title}}, belum dibaca", + "arrived": "Notifikasi baru: {{title}}", + "arrivedMany_one": "{{count}} notifikasi baru", + "arrivedMany_other": "{{count}} notifikasi baru" + }, + "change": { + "loading": "Membaca perubahan pemicu...", + "unread": "Perubahan pemicu tidak dapat dibaca: {{message}}", + "removed": "Perubahan ini tidak lagi disimpan.", + "none": "tidak ada", + "on": "Aktif", + "off": "Nonaktif (dijeda)", + "becomes": "menjadi", + "neverRan": "Belum pernah berjalan", + "lastRun": "Jalan terakhir", + "lastRunValue": "{{status}}, {{when}}", + "runStatus": { + "queued": "Mengantre", + "running": "Berjalan", + "done": "Selesai", + "failed": "Gagal", + "skipped": "Dilewati" + }, + "updateTitle": "Ubah {{name}}", + "deleteTitle": "Hapus {{name}}?", + "updateHelp": "Perubahan yang diusulkan. Tidak ada yang berubah sampai Anda menerapkannya.", + "deleteHelp": "Tidak ada yang dihapus sampai Anda menekan Hapus. Anda dapat membatalkannya selama satu hari.", + "stale": "Pemicu diubah atau dihapus setelah usulan ini, jadi perubahan tidak dapat diterapkan. Minta perubahannya lagi.", + "apply": "Terapkan", + "delete": "Hapus", + "keep": "Simpan", + "field": { + "name": "Nama", + "prompt": "Prompt", + "when": "Kapan", + "time_zone": "Zona waktu", + "daily_cap": "Batas harian", + "enabled": "Status", + "reads": "Membaca", + "writes": "Menulis", + "widget": "Widget Beranda", + "estimated_tokens": "Token per jalan", + "agent": "Agen", + "model": "Model" + }, + "receipt": { + "update": "Diubah:", + "pause": "Dijeda:", + "resume": "Dilanjutkan:", + "delete": "Dihapus:", + "fields": "Yang berubah: {{fields}}", + "dismissed": "Perubahan diabaikan:", + "kept": "Disimpan:", + "undone": "Dibatalkan:", + "restored": "Dipulihkan:" + }, + "blocked": { + "expired": "Waktu untuk membatalkan sudah habis; ubah pemicu di halaman Pemicu.", + "edited": "Pemicu sudah diubah sejak itu; ubah di halaman Pemicu.", + "removed": "Pemicu sudah dihapus sejak itu.", + "full": "Anda sudah memiliki jumlah pemicu maksimum; hapus satu di halaman Pemicu terlebih dahulu." + }, + "announce": { + "applied": "{{name}} diubah.", + "deleted": "{{name}} dihapus.", + "dismissed": "Perubahan pada {{name}} diabaikan.", + "kept": "{{name}} disimpan.", + "undone": "Perubahan pada {{name}} dibatalkan.", + "restored": "{{name}} dipulihkan." + } + }, + "loops": { + "region": "Perulangan obrolan ini", + "unit": { + "seconds_one": "{{count}} detik", + "seconds_other": "{{count}} detik", + "minutes_one": "{{count}} menit", + "minutes_other": "{{count}} menit", + "hours_one": "{{count}} jam", + "hours_other": "{{count}} jam" + }, + "unitShort": { + "seconds_one": "{{count}} dtk", + "seconds_other": "{{count}} dtk", + "minutes_one": "{{count}} mnt", + "minutes_other": "{{count}} mnt", + "hours_one": "{{count}} j", + "hours_other": "{{count}} j" + }, + "confirm": { + "every": "Jalankan ini setiap {{every}} sampai Anda menghentikannya?", + "selfPaced": "Jalankan ini lagi setiap kali model memintanya, sampai Anda menghentikannya?", + "start": "Mulai", + "cancel": "Batal" + }, + "chip": { + "name": "Perulangan", + "every": "setiap {{every}}", + "selfPaced": "sesuai model", + "run_one": "jalan ke-{{count}}", + "run_other": "jalan ke-{{count}}", + "paused": "dijeda", + "running": "sedang berjalan", + "next": "berikutnya pukul {{time}}", + "ended": "selesai", + "wait": "Menunggu: {{wait}}" + }, + "actions": { + "pause": "Jeda", + "resume": "Lanjutkan", + "stop": "Hentikan", + "dismiss": "Tutup" + }, + "state": { + "pending": "menunggu persetujuan Anda", + "active": "aktif", + "paused": "dijeda", + "ended": "selesai" + }, + "list": { + "title": "Perulangan obrolan ini", + "close": "Tutup", + "empty": "Obrolan ini tidak punya perulangan.", + "hint": "Untuk menghentikan satu, ketik /loop stop dan kode di awal barisnya." + }, + "slash": { + "usage": "Ketik /loop, interval jika Anda mau, dan apa yang diulang. Contoh: /loop 5m periksa build.", + "notFound": "Tidak ada perulangan di obrolan ini yang cocok dengan {{id}}.", + "noChat": "Kirim pesan pertama, lalu mulai perulangan di obrolan ini.", + "stopped_one": "{{count}} perulangan dihentikan.", + "stopped_other": "{{count}} perulangan dihentikan." + } + } +} diff --git a/core/http/react-ui/public/locales/id/collections.json b/core/http/react-ui/public/locales/id/collections.json index 36f70e570..45528ca92 100644 --- a/core/http/react-ui/public/locales/id/collections.json +++ b/core/http/react-ui/public/locales/id/collections.json @@ -4,13 +4,13 @@ "newPlaceholder": "Nama koleksi baru...", "actions": { "create": "Buat", - "creating": "Membuat...", "details": "Detail", "reset": "Reset", "delete": "Hapus", "viewDetails": "Lihat detail", "resetCollection": "Reset koleksi", - "deleteCollection": "Hapus koleksi" + "deleteCollection": "Hapus koleksi", + "cancel": "Batal" }, "sections": { "yourCollections": "Koleksi Anda", @@ -19,7 +19,8 @@ "empty": { "title": "Belum ada koleksi", "text": "Koleksi memungkinkan Anda mengatur dokumen ke dalam basis pengetahuan yang dapat dicari oleh agen menggunakan RAG (Retrieval-Augmented Generation). Buat koleksi di atas untuk memulai.", - "noPersonal": "Anda belum memiliki koleksi." + "noPersonal": "Anda belum memiliki koleksi.", + "learn": "Cara kerja memori" }, "deleteDialog": { "title": "Hapus koleksi", @@ -32,12 +33,29 @@ "confirm": "Reset" }, "toasts": { - "loadFailed": "Gagal memuat koleksi: {{message}}", "created": "Koleksi \"{{name}}\" berhasil dibuat", "createFailed": "Gagal membuat koleksi: {{message}}", "deleted": "Koleksi \"{{name}}\" berhasil dihapus", "deleteFailed": "Gagal menghapus koleksi: {{message}}", "reset": "Koleksi \"{{name}}\" berhasil direset", "resetFailed": "Gagal mereset koleksi: {{message}}" + }, + "loadFailed": "Koleksi Anda tidak dapat dimuat.", + "sources": { + "loadFailed": "Sumber web tidak dapat dimuat." + }, + "entries": { + "loadFailed": "File tidak dapat dimuat." + }, + "starting": { + "title": "Memori sedang dimulai.", + "text": "Server masih menyiapkan layanan memori. Halaman ini akan mencoba lagi dengan sendirinya." + }, + "embedding": { + "missingTitle": "Tidak ada model embedding yang terpasang.", + "missingText": "Koleksi memakai {{model}} untuk mengindeks file dan mencarinya. Sampai model itu terpasang, menambah file dan pencarian gagal.", + "noneSet": "Tidak ada model embedding yang diatur untuk koleksi (collections_embedding_model), jadi menambah file dan pencarian gagal.", + "install": "Cari di Model", + "askAdmin": "Minta administrator untuk memasangnya." } -} \ No newline at end of file +} diff --git a/core/http/react-ui/public/locales/id/common.json b/core/http/react-ui/public/locales/id/common.json index a7c135f18..651099f81 100644 --- a/core/http/react-ui/public/locales/id/common.json +++ b/core/http/react-ui/public/locales/id/common.json @@ -112,26 +112,22 @@ "gigabytes": "GB", "terabytes": "TB" }, - "updateReview": { - "title": "Tinjau pembaruan", - "description": "Tidak ada yang diperbarui sampai Anda memilih di sini.", - "close": "Tutup", - "done": "Selesai", - "selectAll": "Pilih semua", - "select": "Pilih {{name}}", - "unknownVersion": "tidak diketahui", - "latest": "terbaru", - "blocked": "Tidak dapat diperbarui di sini:", - "upgradeSelected": "Perbarui yang dipilih ({{count}})", - "upgradeAll": "Perbarui semua ({{count}})", - "running": "Memperbarui...", - "queued": "Menunggu", - "statusRunning": "Memperbarui", - "statusDone": "Diperbarui", - "statusFailed": "Gagal", - "summary": "{{succeeded}} diperbarui, {{failed}} gagal.", - "empty": "Tidak ada yang perlu diperbarui.", - "statusStarted": "Dimulai", - "summaryStarted": "{{succeeded}} dimulai, {{failed}} gagal." + "ui": { + "tryAgain": "Coba lagi", + "undo": "Urungkan", + "loadError": { + "title": "Tidak dapat memuat.", + "serverSaid": "Server menjawab “{{message}}”.", + "nothingChanged": "Tidak ada yang diubah." + } + }, + "chip": { + "running": "Berjalan", + "starting": "Memulai", + "stopped": "Berhenti", + "failed": "Gagal", + "updateAvailable": "Pembaruan tersedia", + "ready": "Siap", + "noRunsYet": "Belum ada eksekusi" } } diff --git a/core/http/react-ui/public/locales/id/explorer.json b/core/http/react-ui/public/locales/id/explorer.json new file mode 100644 index 000000000..c87d2526c --- /dev/null +++ b/core/http/react-ui/public/locales/id/explorer.json @@ -0,0 +1,57 @@ +{ + "brand": "Explorer {{name}}", + "openApp": "Buka LocalAI", + "close": "Tutup", + "copy": "Salin", + "copied": "Disalin", + "loading": "Memuat swarm", + "title": "Swarm publik", + "lede": "Swarm adalah sekelompok mesin yang berbagi inferensi model melalui jaringan peer-to-peer. Daftar ini menampilkan swarm yang pemiliknya memilih untuk membuatnya publik. Siapa pun yang memiliki token swarm dapat bergabung dengan swarm tersebut.", + "count_one": "{{count}} swarm online", + "count_other": "{{count}} swarm online", + "sorted": "Worker terbanyak lebih dulu", + "workers_one": "worker", + "workers_other": "worker", + "tokenTitle": "Awal dan akhir token. Salin dari Cara bergabung.", + "warning": "Explorer adalah daftar komunitas token swarm. Siapa pun dapat menggunakan token untuk mengirim pekerjaan ke swarm atau berbagi sumber daya dengannya. Gunakan atas risiko Anda sendiri.", + "listed": "Swarm ini ada dalam daftar.", + "empty": { + "title": "Belum ada swarm dengan worker online", + "text": "Hanya swarm dengan minimal satu worker online yang ditampilkan. Daftarkan swarm Anda agar menjadi publik." + }, + "off": { + "title": "Server ini bukan explorer", + "text": "Halaman Explorer memerlukan server LocalAI yang dijalankan dalam mode explorer. Server ini tidak memiliki daftar swarm untuk ditampilkan." + }, + "error": { + "title": "Tidak dapat memuat swarm", + "text": "Server explorer tidak merespons. Pastikan server berjalan, lalu coba lagi.", + "retry": "Coba lagi" + }, + "join": { + "open": "Cara bergabung", + "title": "Bergabung dengan {{name}}", + "text": "Jalankan worker LocalAI dengan token swarm ini.", + "token": "Token", + "federated": "Klaster federasi {{id}}. Jalankan node di dalamnya dengan salah satu perintah.", + "docker": "Dengan Docker", + "cli": "Dengan command line", + "noCommands": "Worker swarm ini berbagi komputasi melalui tokennya. Token di atas adalah yang dibutuhkan node untuk bergabung.", + "docs": "Baca cara kerja swarm" + }, + "list": { + "open": "Daftarkan swarm", + "title": "Daftarkan swarm", + "text": "Tambahkan swarm ke daftar publik. Swarm akan muncul setelah memiliki worker online.", + "name": "Nama", + "description": "Deskripsi", + "token": "Token", + "tokenHint": "Token jaringan swarm, seperti yang ditampilkan server.", + "public": "Pendaftaran akan mempublikasikan token. Siapa pun yang melihat daftar dapat menggunakan worker swarm Anda.", + "understood": "Saya memahami bahwa token akan menjadi publik.", + "cancel": "Batal", + "add": "Daftarkan swarm ini", + "adding": "Mendaftarkan...", + "failed": "Tidak dapat mendaftarkan swarm." + } +} diff --git a/core/http/react-ui/public/locales/id/home.json b/core/http/react-ui/public/locales/id/home.json index 63e3cc6b7..e31c62632 100644 --- a/core/http/react-ui/public/locales/id/home.json +++ b/core/http/react-ui/public/locales/id/home.json @@ -25,7 +25,10 @@ "title": "Kelola LocalAI melalui obrolan", "description": "Instal model, ganti backend, edit konfigurasi dan periksa status dengan berbicara pada LocalAI.", "open": "Buka asisten", - "tooltip": "Kelola LocalAI melalui obrolan" + "tooltip": "Kelola LocalAI melalui obrolan", + "noModel": "Pilih model obrolan terlebih dahulu.", + "startFailed": "Tidak dapat memulai obrolan asisten: {{message}}", + "chatTitle": "Asisten LocalAI" }, "input": { "placeholder": "Pesan...", @@ -79,13 +82,17 @@ "step2Title": "Instal Model", "step2Body": "Klik instal untuk mengunduh dan mengonfigurasinya secara otomatis.", "step3Title": "Mulai Mengobrol", - "step3Body": "Mengobrol dengan model Anda langsung dari peramban atau gunakan API." + "step3Body": "Mengobrol dengan model Anda langsung dari peramban atau gunakan API.", + "sandboxTitle": "Jalankan kode dengan aman (opsional)", + "sandboxBody": "Pasang add-on Sandbox agar model menjalankan kode dan perintah secara terisolasi. Tanpanya, perintah berjalan langsung di mesin ini, dengan akses jaringan penuh dan tanpa isolasi." }, "browseGallery": "Jelajahi Galeri Model", "importModel": "Impor Model", "docs": "Dokumentasi", "noModelsTitle": "Tidak Ada Model yang Tersedia", - "noModelsBody": "Belum ada model yang terinstal. Hubungi administrator Anda untuk menyiapkan model agar Anda dapat mulai mengobrol." + "noModelsBody": "Belum ada model yang terinstal. Hubungi administrator Anda untuk menyiapkan model agar Anda dapat mulai mengobrol.", + "sandboxInstall": "Pasang Sandbox", + "sandboxSkip": "Lewati" }, "connect": { "title": "Satu endpoint, semua API", @@ -105,7 +112,23 @@ "modelsSummary": "Explore, install, and manage models", "create": "Create", "createSummary": "Open a chat, image or voice session", - "operate": "Operate", - "operateSummary": "{{models}} models configured · nodes, activity and traces" + "operate": "Administrasi", + "operateSummary": "{{models}} models configured · nodes, activity and traces", + "allChats": "Semua obrolan" + }, + "slash": { + "agents": { + "label": "Agen", + "desc": "Agen tersimpan, pemicu, dan keahlian Anda" + }, + "mcp": { + "label": "Konektor MCP", + "desc": "Server MCP yang bisa dipakai obrolan Anda" + }, + "memory": { + "label": "Memori", + "desc": "Koleksi yang bisa dicari obrolan Anda" + }, + "complete": "lengkapi" } } diff --git a/core/http/react-ui/public/locales/id/library.json b/core/http/react-ui/public/locales/id/library.json new file mode 100644 index 000000000..1c6488dce --- /dev/null +++ b/core/http/react-ui/public/locales/id/library.json @@ -0,0 +1,114 @@ +{ + "usedBy": { + "label": "Digunakan oleh", + "none": "Belum digunakan", + "line": "Digunakan oleh {{name}}", + "lineMore": "Digunakan oleh {{name}} +{{count}}", + "all": "semua skill", + "allTitle": "Agen ini memuat setiap skill", + "remove": "Hapus dari {{agent}}", + "open": "Buka {{agent}}", + "unread": "Penggunaan tidak dapat dibaca saat ini.", + "unreadShort": "Penggunaan tidak diketahui" + }, + "notUsed": { + "lead": "Belum digunakan.", + "skill": "Skill tetap ada di pustaka sampai Anda menambahkannya ke suatu tempat. Coba dulu, atau tambahkan ke agen.", + "collection": "Koleksi tetap ada di pustaka sampai Anda menambahkannya ke suatu tempat. Cari di sini, atau tambahkan ke agen.", + "skillShort": "Tetap ada dalam daftar sampai Anda menambahkannya ke agen." + }, + "tryInContext": "Coba dalam sebuah konteks", + "add": { + "button": "Tambahkan ke...", + "title": "Tambahkan {{name}} ke", + "agents": "Agen", + "noAgents": "Belum ada agen. Buat agen, lalu tambahkan di sini.", + "createAgent": "Buat agen", + "added": "Ditambahkan", + "action": "Tambah", + "addToAgent": "Tambahkan ke {{agent}}", + "working": "Menambahkan...", + "estimate": "Perkiraan menggunakan jumlah karakter dibagi empat.", + "toolsMode": "Dimuat saat model memintanya", + "tokens": "+{{count}} token per pesan (perkiraan)", + "collectionNote": "Agen dapat mencari koleksi dalam daftarnya.", + "kbOn": "Mengaktifkan basis pengetahuannya", + "noAgentNamed": "Tidak ada agen bernama {{name}}. Buat agen dengan nama tersebut untuk menggunakan koleksi ini.", + "close": "Tutup", + "skillNote": "Agen dapat membaca skill dalam daftarnya." + }, + "toasts": { + "skillAdded": "{{name}} ditambahkan ke {{agent}}", + "skillRemoved": "{{name}} dihapus dari {{agent}}", + "skillsOff": "{{name}} dihapus dari {{agent}}. Skill sekarang dinonaktifkan untuknya.", + "collectionAdded": "{{name}} ditambahkan ke {{agent}}", + "collectionRemoved": "{{name}} dihapus dari {{agent}}", + "undone": "Perubahan dibatalkan", + "updateFailed": "Tidak dapat memperbarui {{agent}}: {{message}}", + "undo": "Batalkan", + "dismiss": "Tutup" + }, + "simulate": { + "button": "Simulasikan pesan", + "title": "Simulasikan pesan", + "desc": "Pilih konteks dan lihat apa yang akan dimuatnya. Tidak ada yang dikirim ke model.", + "close": "Tutup", + "context": "Konteks", + "contextCollection": "Koleksi saja", + "contextAgent": "Agen: {{name}}", + "collectionOn": "Koleksi", + "noCollections": "Belum ada koleksi.", + "contextHintCollection": "Mencari koleksi dengan pesan Anda dan menampilkan passage yang kembali, beserta skornya. Tidak ada agen atau model yang terlibat.", + "contextHintAgent": "Menampilkan apa yang disimpan agen ini di prompt-nya, ditambah passage yang akan ditambahkan basis pengetahuannya untuk pesan ini.", + "message": "Pesan", + "messagePlaceholder": "Ketik apa yang mungkin ditanyakan seseorang", + "passages": "Passage", + "run": "Jalankan", + "running": "Menjalankan...", + "onlyForTest": "Hanya untuk tes ini", + "removeExtra": "Keluarkan {{name}} dari tes ini", + "tryAlso": "Coba sesuatu yang belum ditambahkan", + "tryAlsoPick": "Pilih skill atau koleksi", + "extraSkill": "Skill {{name}}", + "extraCollection": "Koleksi {{name}}", + "budgetTitle": "Yang ditambahkan konteks ini", + "budgetTotal": "Sekitar {{count}} token di sekitar pesan Anda", + "budgetMessage": "Pesan Anda: {{count}} token", + "budgetNote": "Perkiraan: jumlah karakter dibagi empat. Jendela konteks model tidak diketahui di sini, jadi tidak ada persentase dari batas.", + "instructions": "Instruksi", + "skills": "Skill", + "memory": "Memori", + "skillsTitle": "Skill yang dimuat konteks ini", + "skillsNone": "Tidak ada skill yang aktif untuk agen ini.", + "skillsPrompt": "Di prompt setiap pesan.", + "skillsTools": "Dibaca hanya saat model memintanya.", + "skillsBoth": "Di prompt, dan dibaca lagi saat model memintanya.", + "skillsAll": "Semua skill aktif untuk agen ini.", + "skillMissing": "{{name}} ada dalam daftar, tetapi tidak ada skill dengan nama itu.", + "skillTokens": "{{count}} token", + "skillTokensTools": "0 di awal", + "memoryTitle": "Memori yang akan dikirim", + "memoryNone": "Tidak ada koleksi dalam daftar agen ini. Tambahkan satu ke agen untuk menggunakannya.", + "memoryNoCollection": "Basis pengetahuan agen ini aktif, tetapi belum ada koleksi bernama {{name}}.", + "memoryTools": "Agen ini hanya mencari saat model memintanya. Tidak ada yang ditambahkan ke pesan di awal.", + "memoryTesting": "Koleksi {{name}}, hanya untuk tes ini", + "memoryFrom": "Dari {{name}}", + "passagesCount": "{{count}} passage, diurutkan berdasarkan kemiripan", + "noPassages": "Tidak ada hasil untuk pesan ini.", + "pickMessage": "Ketik pesan terlebih dahulu.", + "searchFailed": "Pencarian gagal: {{message}}", + "score": "Skor {{score}}", + "tokens": "{{count}} token", + "noAgents": "Belum ada agen. Anda tetap dapat mencari koleksi sendiri.", + "backendNote": "Simulasi model tidak tersedia di sini, sehingga tidak ada jawaban yang disimulasikan." + }, + "search": { + "failed": "Pencarian gagal: {{message}}" + }, + "agentBudget": "Skill yang dipilih menambahkan sekitar {{count}} token ke setiap pesan (perkiraan: jumlah karakter dibagi empat).", + "agentKb": { + "reads": "Agen ini membaca koleksi bernama {{name}}.", + "exists": "Agen ini membaca koleksi bernama {{name}}. Anda dapat mengisi dan mencarinya di Memori.", + "created": "Agen ini membaca koleksi bernama {{name}}. Koleksi dibuat saat Anda menyimpan." + } +} diff --git a/core/http/react-ui/public/locales/id/modelEditor.json b/core/http/react-ui/public/locales/id/modelEditor.json index b32ec737c..2132c82af 100644 --- a/core/http/react-ui/public/locales/id/modelEditor.json +++ b/core/http/react-ui/public/locales/id/modelEditor.json @@ -34,5 +34,9 @@ "title": "Tidak ada field yang dikonfigurasi", "text": "Gunakan kolom pencarian di atas untuk menemukan dan menambahkan field konfigurasi." } + }, + "proxyVision": { + "label": "Model ini dapat melihat gambar", + "hint": "Aktifkan jika model upstream menerima gambar. LocalAI tidak dapat memeriksanya. Gambar terlampir lalu dikirim ke model bersama pesan." } } diff --git a/core/http/react-ui/public/locales/id/models.json b/core/http/react-ui/public/locales/id/models.json index 4ca0bcd2a..fd6d3ca92 100644 --- a/core/http/react-ui/public/locales/id/models.json +++ b/core/http/react-ui/public/locales/id/models.json @@ -1,5 +1,6 @@ { "lifecycle": { + "supporting": "Temukan model yang muat di mesin ini, pasang, dan kelola model yang sudah ada.", "title": "Models", "navLabel": "Model lifecycle", "views": { "explore": "Explore", "installed": "Installed" }, @@ -82,6 +83,10 @@ "delete": "Hapus" }, "filters": { + "sheetDone": "Tampilkan model", + "sheetTitle": "Filter", + "sheetButtonCount": "Filter ({{count}})", + "sheetButton": "Filter", "all": "Semua", "llm": "Obrolan", "image": "Gambar", diff --git a/core/http/react-ui/public/locales/id/nav.json b/core/http/react-ui/public/locales/id/nav.json index 69422ce44..ce37bd4d3 100644 --- a/core/http/react-ui/public/locales/id/nav.json +++ b/core/http/react-ui/public/locales/id/nav.json @@ -10,12 +10,13 @@ "changeLanguage": "Ubah bahasa", "logout": "Keluar", "accountSettings": "Pengaturan akun", + "yourSettings": "Pengaturan Anda", "account": "Akun", "accountFor": "Akun: {{name}}", "sections": { "recognition": "Pengenalan", "build": "Bangun", - "operate": "Operasikan", + "operate": "Administrasi", "workspace": "Ruang kerja" }, "operate": { @@ -66,7 +67,14 @@ "logs": "Logs", "trafficModels": "Models", "gpuHost": "GPU and host", - "prometheus": "Prometheus" + "prometheus": "Prometheus", + "chats": "Chats", + "files": "Berkas", + "triggers": "Pemicu", + "mcpConnectors": "Konektor MCP", + "chatsAdmin": "Obrolan semua pengguna", + "pages": "Halaman", + "allChats": "Semua obrolan" }, "footer": { "github": "GitHub", @@ -99,7 +107,15 @@ "import": "Tambahkan model dari berkas, URL, atau repositori Hugging Face.", "voices": "Kenali pembicara dan simpan pustaka profil suara.", "faces": "Deteksi, bandingkan, dan cocokkan wajah.", - "addons": "Pasang add-on dan buka aplikasi yang disediakannya." - } - } + "addons": "Pasang add-on dan buka aplikasi yang disediakannya.", + "triggers": "Mulai obrolan secara otomatis, sesuai jadwal atau saat ada berkas masuk." + }, + "operateSupporting": "Apa yang berjalan di server ini, bagaimana server ini dipakai, dan bagaimana pengaturannya." + }, + "pinnedPages": "Halaman yang disematkan", + "new": "Baru", + "recentChats": "Obrolan terbaru", + "newChat": "Obrolan baru", + "untitledChat": "Obrolan tanpa judul", + "chatRunning": "Berjalan" } diff --git a/core/http/react-ui/public/locales/id/operate.json b/core/http/react-ui/public/locales/id/operate.json new file mode 100644 index 000000000..5df8d85cc --- /dev/null +++ b/core/http/react-ui/public/locales/id/operate.json @@ -0,0 +1,57 @@ +{ + "backends": { + "filterLabel": "Saring backend", + "check": "Periksa pembaruan", + "reinstallBackend": "Pasang ulang backend", + "deleteBackend": "Hapus backend", + "updateAllTitle": "Perbarui semua backend", + "updateAllIntro": "Pilih backend yang akan diperbarui. Masing-masing diperbarui sendiri, satu per satu. Kegagalan tidak menghentikan yang lain.", + "updateSelected": "Perbarui yang dipilih", + "loadFailedTitle": "Katalog backend tidak dapat dimuat.", + "filtersButton": "Filter", + "filtersDone": "Selesai", + "filterGroups": { + "state": "Status", + "type": "Jenis", + "show": "Tampilkan", + "library": "Pustaka" + }, + "showVariants": "Varian build", + "showDevelopment": "Build pengembangan", + "allLibraries": "Semua pustaka", + "moreActions": "Tindakan backend lainnya", + "installFromUrl": "Pasang dari URL…", + "manageLibraries": "Kelola pustaka…", + "count_one": "{{count}} backend", + "count_other": "{{count}} backend", + "countOf": "{{shown}} dari {{total}} backend", + "activeFilters": "Filter aktif", + "removeFilter": "Hapus filter: {{name}}", + "checking": "Memeriksa pembaruan…", + "updatesAvailable_one": "{{count}} pembaruan tersedia", + "updatesAvailable_other": "{{count}} pembaruan tersedia", + "reviewUpdates": "Tinjau", + "close": "Tutup", + "installedLoadFailedTitle": "Backend terpasang tidak dapat dimuat." + }, + "capacity": { + "diskChip": "Disk hampir penuh" + }, + "activity": { + "kind": { + "addon": "add-on" + }, + "verb": { + "upgrading": "Memperbarui {{kind}}", + "failedUpgrade": "Tidak dapat memperbarui {{kind}}" + }, + "filter": { + "addons": "Add-on" + }, + "upgradeVersions": "{{from}} ke {{to}}", + "rowUpgraded": "Diperbarui dalam {{duration}}", + "rowUpgradedPlain": "Diperbarui", + "viewInAddons": "Lihat di Add-on", + "upgradeWaiting": "Menunggu layanan add-on dimulai. Pemeriksaan berikutnya dalam {{seconds}} dtk." + } +} diff --git a/core/http/react-ui/public/locales/id/pages.json b/core/http/react-ui/public/locales/id/pages.json new file mode 100644 index 000000000..0ece129c5 --- /dev/null +++ b/core/http/react-ui/public/locales/id/pages.json @@ -0,0 +1,138 @@ +{ + "subtitle": "Alat kecil yang dibuat asisten untukmu. Dibuka di sini, dalam sandbox, dan menyimpan datanya di file-mu.", + "card": { + "pin": "Sematkan ke bilah sisi", + "unpin": "Lepas dari bilah sisi", + "changedBy": { + "you": "Diubah {{when}} oleh kamu", + "assistant": "Diubah {{when}} oleh asisten" + }, + "menu": "Lainnya untuk {{title}}", + "files": "Tampilkan filenya", + "rename": "Ganti nama", + "renameLabel": "Nama folder baru", + "renameHint": "Huruf kecil, angka, dan tanda hubung.", + "renameSave": "Simpan", + "renameCancel": "Batal", + "renameFailed": "Halaman tidak diganti namanya: {{message}}", + "delete": "Hapus halaman", + "deleteTitle": "Hapus “{{title}}”?", + "deleteMessage": "Folder {{folder}} masuk ke Sampah. Kamu bisa memulihkannya dari File.", + "deleted": "{{title}} ada di Sampah.", + "deleteFailed": "Halaman tidak dihapus: {{message}}" + }, + "empty": { + "text": "Halaman adalah alat kecil, seperti papan tugas atau daftar bacaan. Minta satu ke asisten di chat, atau mulai dari halaman siap pakai.", + "starting": "Menyalin halaman", + "starterFailed": "Halaman tidak disalin: {{message}}", + "ask": "Minta halaman", + "askPrompt": "Buatkan aku halaman yang ", + "starterReading": "Mulai dari daftar bacaan", + "note": "Entri ini hilang dari bilah sisi saat kamu tidak punya halaman. Entri ini muncul lagi dengan halaman berikutnya.", + "learn": "Cara kerja halaman" + }, + "askNew": "Minta halaman baru", + "yours": "Milikmu", + "filter": "Saring halaman", + "shared": { + "yours": "Milik Anda", + "group": "Dibagikan dengan Anda", + "note": "Halaman bersama hanya berjalan setelah Anda mengizinkannya. Jika pembuatnya mengubahnya, Anda ditanya lagi.", + "offNotice": "Halaman bersama dimatikan di server ini. Seorang administrator mematikannya.", + "off": "Halaman bersama dimatikan di server ini. Seorang administrator mematikannya.", + "gone": "Halaman ini sudah tidak ada di folder bersama, atau Anda tidak lagi memiliki akses.", + "review": "Tinjau", + "from": "Dari {{author}}", + "menu": "Menu halaman", + "badge": { + "notAllowed": "Belum diizinkan", + "changed": "Berubah sejak Anda mengizinkannya", + "allowed": "Diizinkan" + } + }, + "viewer": { + "stopAllowing": "Berhenti mengizinkan halaman ini", + "authorChanged": "{{author}} mengubah halaman ini. Versi yang Anda izinkan tetap berjalan sampai Anda meninjau perubahannya.", + "sandboxShared": "Dalam sandbox. Berjalan sebagai Anda, hanya di {{folder}}, dan tidak dapat menjangkau jaringan." + }, + "trust": { + "title": "Buka \"{{title}}\"?", + "desc": "Halaman ini berasal dari folder bersama. Halaman berjalan sebagai Anda, jadi periksa dulu sebelum berjalan.", + "who": "Ditulis oleh {{author}}", + "whereLabel": "Folder bersama", + "whereWhen": ", diubah {{when}}", + "someone": "seseorang", + "close": "Tutup", + "canTitle": "Halaman ini dapat", + "canStillTitle": "Halaman ini masih dapat", + "cannotTitle": "Halaman ini tidak dapat", + "items": { + "files": "Membaca dan menulis file di foldernya sendiri", + "readOnly": "Anda hanya dapat membaca folder bersama ini, jadi halaman tidak dapat menyimpan perubahannya.", + "chat": "Menulis pesan ke obrolan", + "chatNote": "Anda melihat pesan di obrolan sebelum asisten menjawabnya.", + "noFiles": "Membaca file atau obrolan Anda yang lain", + "noNet": "Menjangkau jaringan atau situs lain", + "noNetNote": "Satu batasan: halaman dapat memuat alamat lain di bingkainya sendiri. Itu mengirim satu permintaan keluar, lalu halaman berhenti.", + "noTools": "Memakai alat atau mengubah pengaturan Anda" + }, + "warn": "Halaman ini bertindak sebagai Anda di dalam foldernya. Jika {{author}} mengubahnya, Anda ditanya lagi.", + "viewSource": "Lihat sumber", + "cancel": "Batal", + "allowOnce": "Izinkan sekali", + "allow": "Izinkan", + "changed": { + "title": "\"{{title}}\" telah berubah", + "desc": "Anda mengizinkan versi sebelumnya. Periksa apa yang berubah sebelum berjalan lagi.", + "who": "Diubah oleh {{author}} {{when}}", + "allowed": "Anda mengizinkan versi {{date}}", + "what": "Yang berubah", + "same": "Yang dapat dilakukannya sama seperti sebelumnya.", + "warn": "Jika Anda mengizinkannya, versi ini berjalan setiap kali Anda membukanya, sampai {{author}} mengubahnya lagi.", + "view": "Lihat perubahan", + "lines": "{{before}} → {{after}} baris", + "code": "Sebuah skrip, lembar gaya, atau modul halaman berubah: {{files}}" + }, + "pill": { + "notYet": "Belum diizinkan", + "not": "Tidak diizinkan", + "once": "Diizinkan untuk kunjungan ini", + "allowed": "Diizinkan", + "changed": "Berubah sejak Anda mengizinkannya" + }, + "from": "Dari {{author}}", + "frame": { + "chrome": "Halaman bersama dari {{folder}}. Belum berjalan." + }, + "blocked": { + "title": "Halaman ini belum berjalan", + "body": "Anda memilih untuk tidak menjalankannya. Halaman ini berasal dari folder bersama {{folder}} milik {{author}}. Tidak ada yang berjalan dan tidak ada yang disimpan.", + "review": "Tinjau dan izinkan" + }, + "waiting": { + "title": "Menunggu Anda", + "body": "Tidak ada yang berjalan di halaman ini sampai Anda mengizinkannya." + }, + "stopped": { + "revoked": "Anda berhenti mengizinkan halaman ini. Halaman berhenti dan tidak ada yang berjalan sampai Anda mengizinkannya lagi.", + "refused": "Halaman ini tidak lagi diizinkan, jadi dihentikan. Tinjau untuk mengizinkannya lagi.", + "off": "Halaman bersama dimatikan di server ini, jadi halaman ini dihentikan." + }, + "changedMeanwhile": "Halaman berubah saat Anda meninjaunya. Ini versi barunya.", + "sourceTitle": "Sumber \"{{title}}\"", + "sourceLines_one": "{{count}} baris", + "sourceLines_other": "{{count}} baris", + "sourceNote": "Sumber ditampilkan sebagai teks. Tidak ada yang berjalan di sini." + }, + "frame": { + "filesFailed": "Berkas di samping halaman ini tidak dimuat: {{files}}. Konsol browser menjelaskan alasannya.", + "tooLarge": "Halaman ini memuat lebih dari {{limit}} berkas dari foldernya, jadi sebagian dilewati. Muat data dengan fetch().", + "dismiss": "Tutup" + }, + "link": { + "label": "Halaman ingin membuka tautan", + "open": "Buka {{site}}", + "cancel": "Batal", + "lookalike": "Alamat ini memakai huruf dari alfabet lain dan terbaca sebagai {{display}}. Alamat ini bisa meniru situs lain: periksa sebelum membukanya." + } +} diff --git a/core/http/react-ui/public/locales/id/skills.json b/core/http/react-ui/public/locales/id/skills.json index 7ba932cd2..185b52f26 100644 --- a/core/http/react-ui/public/locales/id/skills.json +++ b/core/http/react-ui/public/locales/id/skills.json @@ -1,79 +1,83 @@ { "title": "Skill", - "subtitle": "Kelola skill agen (instruksi dan sumber daya yang dapat digunakan kembali)", + "subtitle": "Instruksi yang dapat dipakai semua obrolan Anda saat sebuah tugas cocok. Agen memakai yang Anda tambahkan ke mereka.", "unavailable": { - "subtitle": "Layanan Skill tidak tersedia atau indeks sedang dibangun ulang. Coba beberapa saat lagi.", - "retry": "Coba lagi" + "subtitle": "Skill memerlukan Berkas, dan Berkas dinonaktifkan di server ini." }, "actions": { "newSkill": "Skill baru", "createSkill": "Buat skill", - "import": "Impor", - "importing": "Mengimpor...", - "gitRepos": "Repo Git", "edit": "Edit", - "delete": "Hapus", - "export": "Ekspor", - "sync": "Sinkronisasi", - "addRepo": "Tambah repo", - "adding": "Menambahkan...", - "remove": "Hapus", - "enable": "Aktifkan", - "disable": "Nonaktifkan" + "delete": "Hapus" }, "search": { "placeholder": "Cari skill..." }, - "git": { - "title": "Repositori git", - "description": "Tambah repositori Git untuk mengambil skill. Skill akan muncul di daftar setelah sinkronisasi.", - "urlPlaceholder": "https://github.com/user/repo atau git@github.com:user/repo.git", - "noRepos": "Tidak ada repositori Git yang dikonfigurasi. Tambahkan satu di atas.", - "disabled": "Dinonaktifkan", - "removeRepo": "Hapus repo" - }, "card": { "noDescription": "Tidak ada deskripsi", "readOnly": "Hanya baca", "editTitle": "Edit skill", "deleteTitle": "Hapus skill", - "exportTitle": "Ekspor sebagai .tar.gz" + "invalid": "Skipped: not a valid skill", + "invalidWhy": "Skill ini dilewati: {{reason}}", + "shadowed": "Hidden by a {{layer}} skill", + "shadowedLong": "Hidden by a {{layer}} skill of the same name", + "providedBy": "Disediakan oleh {{name}}", + "replacedBy": "Digantikan oleh {{name}}", + "notActive": "Tidak aktif: skill bawaan yang dipakai", + "notActiveBy": "Tidak aktif: disediakan oleh {{name}}", + "fallback": "Dipakai karena skill add-on {{name}} mati atau tidak berjalan" }, "empty": { "title": "Tidak ada skill yang ditemukan", "text": "Buat atau impor skill untuk memulai.", - "noPersonal": "Anda belum memiliki skill." - }, - "sections": { - "yourSkills": "Skill Anda", - "otherUsersSkills": "Skill Pengguna Lain" - }, - "deleteDialog": { - "title": "Hapus Skill", - "message": "Hapus skill \"{{name}}\"? Tindakan ini tidak dapat dibatalkan.", - "confirm": "Hapus" - }, - "removeRepoDialog": { - "title": "Hapus Repositori Git", - "message": "Hapus repositori Git ini? Skill dari repo ini tidak akan tersedia lagi.", - "confirm": "Hapus" + "stepWrite": "Write a skill: a name, a line that says when to use it, and the instructions.", + "stepTry": "Check it in the list: it shows who uses it and what it says.", + "stepAdd": "Add it to an agent when you want it used." }, "toasts": { "loadFailed": "Gagal memuat skill", "deleted": "Skill \"{{name}}\" berhasil dihapus", - "deleteFailed": "Gagal menghapus skill", - "exported": "Skill \"{{name}}\" berhasil diekspor", - "exportFailed": "Gagal mengekspor", - "imported": "Skill berhasil diimpor dari \"{{file}}\" ", - "importFailed": "Gagal mengimpor", - "loadReposFailed": "Gagal memuat repositori Git", - "repoAdded": "Repo Git berhasil ditambahkan dan sedang sinkronisasi", - "addRepoFailed": "Gagal menambahkan repo", - "synced": "Repo berhasil disinkronisasi", - "syncFailed": "Gagal sinkronisasi", - "toggled": "Berhasil mengubah status repo", - "toggleFailed": "Gagal mengubah status repo", - "removed": "Repo berhasil dihapus", - "removeFailed": "Gagal menghapus repo" + "deleteFailed": "Gagal menghapus skill" + }, + "layers": { + "label": "Layer", + "personal": "Personal", + "shared": "Shared", + "builtin": "Built-in", + "folder": "Workspace", + "addon": "Add-on" + }, + "pane": { + "loading": "Loading...", + "loadFailed": "The skill could not be read.", + "tokensExplain": "A chat reads a skill only when a task matches its description, so it adds nothing until then. About {{count}} tokens when read (estimate)." + }, + "edit": { + "back": "Back to skills", + "titleNew": "New skill", + "titleEdit": "Edit: {{name}}", + "basic": "Basic information", + "content": "Content", + "layer": "Layer", + "layerHelp": "Personal is yours alone. Shared is read by every user.", + "name": "Name (lowercase, hyphens only)", + "nameLocked": "Name cannot be changed after creation.", + "description": "Description (when to use it)", + "descriptionHelp": "A chat reads this line to decide whether the skill fits the task.", + "body": "Skill content (markdown)", + "cancel": "Cancel", + "save": "Save changes", + "create": "Create skill", + "saving": "Saving...", + "created": "Skill created", + "updated": "Skill updated", + "required": "A name and a description are required", + "saveFailed": "Save failed", + "loadFailed": "Failed to load the skill", + "readOnly": "Skill ini hanya-baca di sini. Skill bawaan disertakan dengan server, dan skill dari berkas Anda diubah di Berkas." + }, + "load": { + "title": "Keterampilan Anda tidak dapat dimuat." } -} \ No newline at end of file +} diff --git a/core/http/react-ui/public/locales/id/swarm.json b/core/http/react-ui/public/locales/id/swarm.json new file mode 100644 index 000000000..9396a43e9 --- /dev/null +++ b/core/http/react-ui/public/locales/id/swarm.json @@ -0,0 +1,5 @@ +{ + "nodes": { + "retry": "Coba lagi" + } +} diff --git a/core/http/react-ui/public/locales/id/tools.json b/core/http/react-ui/public/locales/id/tools.json new file mode 100644 index 000000000..33a55582d --- /dev/null +++ b/core/http/react-ui/public/locales/id/tools.json @@ -0,0 +1,468 @@ +{ + "steps": { + "label": "Progres", + "setup": "Siapkan", + "check": "Periksa", + "run": "Jalankan", + "result": "Hasil" + }, + "run": { + "stop": "Hentikan", + "connecting": "Menghubungkan ke job. Menunggu event progres pertama.", + "progress": "Progres", + "stages": "Tahap", + "step": "langkah", + "of": "dari", + "epoch": "epoch", + "tokens": "token/s", + "eta": "sekitar {{time}} lagi", + "etaSeconds": "sekitar {{seconds}} d lagi" + }, + "landing": { + "lede": "Yang dapat Anda buat di mesin ini, dan apa yang dibutuhkan tiap alat lebih dulu.", + "machine": "Mesin ini", + "gpu": "GPU", + "gpuFree": "{{free}} dari {{total}} tersedia", + "noGpu": "GPU tidak ditemukan", + "cluster": "{{total}} di node terbaik ({{node}})", + "clusterNode": "sebuah worker", + "ram": "Memori", + "ramFree": "RAM, {{free}} tersedia", + "disk": "Disk model", + "diskFree": "tersedia", + "backends": "Backend", + "noBackends": "belum ada yang terpasang untuk alat ini", + "open": "Buka", + "openTool": "Buka {{tool}}", + "install": "Pasang", + "ready": "Siap", + "needsBackend": "Perlu backend", + "experimental": "Eksperimental", + "needs": "Yang dibutuhkan", + "flow": "Jalur dari model yang Anda temukan ke model yang bisa Anda ajak chat", + "flowNote": "Setiap langkah berakhir dengan model yang bisa Anda muat.", + "now": { + "running": { + "fineTune": "Melakukan fine-tuning {{model}}", + "quantize": "Melakukan kuantisasi {{model}} ke {{type}}" + }, + "failed": { + "fineTune": "Fine-tuning {{model}} gagal", + "quantize": "Kuantisasi {{model}} ke {{type}} gagal" + } + }, + "groups": { + "make": { + "title": "Buat model", + "text": "Mulai dari model yang Anda temukan, akhiri dengan model yang muat di mesin dan memahami data Anda." + }, + "recognise": { + "title": "Kenali", + "text": "Siapa yang sedang berbicara, dan wajah siapa yang ada di foto." + }, + "automate": { + "title": "Otomatiskan", + "text": "Asisten, instruksinya, dan memorinya." + } + }, + "sentence": { + "import": "Impor model dari Hugging Face, URL, referensi Ollama atau OCI, atau file di host ini. LocalAI memilih backend dan menulis konfigurasinya.", + "quantize": "Buat model lebih kecil agar muat dalam memori yang lebih sedikit. Mengonversi model Hugging Face ke GGUF dengan presisi yang Anda pilih.", + "fine-tune": "Ajarkan model dengan data Anda sendiri menggunakan adapter, lalu ekspor sebagai model yang bisa Anda ajak chat." + }, + "line": { + "import": { + "noSetup": "Tanpa pengaturan. Backend diunduh saat dibutuhkan.", + "disk": "{{free}} tersedia di disk model" + }, + "quantize": { + "backend": "Backend {{name}} terpasang", + "backendMissing": "Backend kuantisasi belum terpasang", + "backendUnknown": "Status backend tidak dapat dibaca", + "cpu": "Berjalan di CPU, tidak perlu GPU" + }, + "fineTune": { + "backend": "Backend {{name}} terpasang", + "backendMissing": "Belum ada backend fine-tuning yang terpasang", + "backendUnknown": "Status backend tidak dapat dibaca", + "gpu": "Memori GPU: {{name}}, {{free}} dari {{total}} tersedia", + "noGpu": "GPU tidak ditemukan. Pelatihan berjalan di CPU dan lambat.", + "cluster": "Memori: {{total}} di node terbaik" + } + }, + "enable": { + "admin": "Pasang backend dari Backend, dan alat ini siap digunakan.", + "user": "Minta administrator untuk memasang backend." + } + }, + "checks": { + "heading": "Periksa sebelum memulai", + "live": "Diperbarui saat Anda mengubah pengaturan.", + "meterLabel": "{{used}} persen terpakai, {{free}} tersedia dari {{total}}", + "openBackends": "Buka Backend", + "barOk": "Tidak ada yang menghalangi permulaan.", + "barWarn_one": "{{count}} peringatan. Anda tetap bisa memulai.", + "barWarn_other": "{{count}} peringatan. Anda tetap bisa memulai.", + "title": { + "model": "Model", + "data": "Dataset", + "backend": "Backend", + "memory": "Memori", + "disk": "Disk", + "reward": "Fungsi reward", + "resume": "Melanjutkan", + "access": "Akses model", + "type": "Tipe kuantisasi", + "source": "Sumber", + "name": "Nama" + }, + "fineTune": { + "modelOk": "Melatih {{model}}.", + "modelMissing": "Masukkan model yang akan dilatih.", + "dataOk": "Data berasal dari {{source}}.", + "dataMissing": "Tambahkan dataset: id Hugging Face atau file yang diunggah.", + "backendOk": "{{name}} sudah terpasang.", + "backendMissing": "Belum ada backend fine-tuning yang terpasang, jadi job mungkin gagal dimulai.", + "backendOther": "{{name}} tidak termasuk backend fine-tuning yang terpasang ({{list}}).", + "backendUnknown": "Backend yang terpasang tidak dapat dibaca.", + "memoryGpu": "{{name}}: {{free}} dari {{total}} tersedia. Ukuran batch {{batch}}, panjang sekuens {{seq}}, akumulasi gradien {{accum}}. Kebutuhan job bergantung pada model dan pengaturan ini, dan LocalAI tidak memperkirakannya sebelum job dimulai.", + "memoryCluster": "{{total}} di node terbaik ({{node}}). Ukuran batch {{batch}}, panjang sekuens {{seq}}. Kebutuhan job bergantung pada model dan pengaturan ini.", + "memoryCpu": "GPU tidak ditemukan. Pelatihan menggunakan CPU dan {{free}} dari {{total}} RAM tersedia. Diperkirakan lambat. Ukuran batch {{batch}}, panjang sekuens {{seq}}.", + "memoryUnknown": "Akun ini tidak dapat membaca memori mesin. Ukuran batch {{batch}}, panjang sekuens {{seq}}. Kebutuhan job bergantung pada model dan pengaturan ini.", + "disk": "{{free}} tersedia di disk model. Unduhan dan checkpoint membutuhkan ruang.", + "diskLow": "Hanya {{free}} tersedia di disk model. Unduhan dan checkpoint membutuhkan ruang.", + "rewardMissing": "GRPO membutuhkan minimal satu fungsi reward. Tambahkan satu di Opsi lainnya.", + "resume": "Pelatihan dilanjutkan dari {{path}}.", + "accessNone": "Token belum diatur. Model yang dibatasi membutuhkan token Hugging Face di Opsi lainnya.", + "accessSet": "Token Hugging Face sudah diatur." + }, + "quantize": { + "modelOk": "Mengkuantisasi {{model}}.", + "modelMissing": "Masukkan model yang akan dikuantisasi.", + "typeMissing": "Masukkan tipe kuantisasi kustom.", + "backendOk": "{{name}} sudah terpasang.", + "backendMissing": "Belum ada backend kuantisasi yang terpasang, jadi job mungkin gagal dimulai.", + "backendOther": "{{name}} tidak termasuk backend kuantisasi yang terpasang ({{list}}).", + "backendUnknown": "Backend yang terpasang tidak dapat dibaca.", + "memory": "{{free}} dari {{total}} RAM tersedia. Konversi berjalan di CPU.", + "disk": "{{free}} tersedia di disk model. Unduhan, file hasil konversi, dan hasilnya ditulis ke disk.", + "diskLow": "Hanya {{free}} tersedia di disk model. Unduhan, file hasil konversi, dan hasilnya ditulis ke disk.", + "accessNone": "Token belum diatur. Model yang dibatasi membutuhkan token Hugging Face di Opsi lainnya.", + "accessSet": "Token Hugging Face sudah diatur." + }, + "import": { + "sourceOk_huggingface": "Dikenali sebagai repositori Hugging Face. LocalAI memeriksa file-filenya saat Anda mengimpor.", + "sourceOk_url": "Format URL dikenali. Isinya belum diperiksa.", + "sourceOk_config": "Dikenali sebagai file konfigurasi model. File dibaca dan digunakan apa adanya.", + "sourceOk_oci": "Dikenali sebagai referensi image OCI.", + "sourceOk_ollama": "Dikenali sebagai referensi model Ollama.", + "sourceOk_local": "Dikenali sebagai path di mesin yang menjalankan LocalAI.", + "sourceOk": "Sumber dikenali.", + "sourceUnknown": "Ini tidak tampak seperti format yang diketahui LocalAI. Tetap akan dicoba, dan mungkin gagal.", + "backendDownload": "{{name}} belum terpasang. LocalAI mengunduhnya terlebih dahulu.", + "backendChosen": "{{name}} dipilih dan terpasang.", + "backendAuto": "LocalAI memilih backend dari file sumber saat impor dimulai.", + "nameTaken": "Model bernama {{name}} sudah terpasang.", + "nameFree": "{{name}} belum digunakan.", + "nameAuto": "Model dinamai sesuai sumbernya.", + "disk": "{{free}} tersedia di disk model. Ukuran unduhan diketahui saat impor dimulai.", + "memory_gpu": "{{free}} memori GPU tersedia saat ini. Kebutuhan model ini diketahui saat impor dimulai.", + "memory_ram": "{{free}} RAM tersedia saat ini. Kebutuhan model ini diketahui saat impor dimulai.", + "memory_cluster": "{{free}} tersedia di node terbaik. Kebutuhan model ini diketahui saat impor dimulai.", + "memory": "{{free}} memori tersedia saat ini. Kebutuhan model ini diketahui saat impor dimulai." + } + }, + "form": { + "key": "Kunci", + "value": "Nilai", + "addOption": "Tambah opsi", + "copy": "Salin ke clipboard", + "more": "Opsi lainnya", + "moreHint": "metode, backend, adapter, optimizer, evaluasi, fungsi reward", + "baseModel": "Model dasar", + "model": "Model", + "modelHint": "Id Hugging Face, atau path di host ini.", + "data": "Data Anda", + "dataset": "Sumber dataset", + "datasetHint": "Id dataset Hugging Face, atau unggah file.", + "split": "Pembagian", + "upload": "Unggah file sebagai gantinya", + "uploadHint": ".json, .jsonl atau .csv", + "removeFile": "Hapus file", + "train": "Yang akan dilatih", + "kind": "Jenis pelatihan", + "kinds": { + "adapter": { + "title": "Adapter", + "text": "Melatih adapter kecil dan membiarkan bobot dasar tidak berubah. Pilihan yang umum, dan membutuhkan sedikit memori." + }, + "full": { + "title": "Model penuh", + "text": "Mengubah setiap bobot. Membutuhkan memori beberapa kali lipat dari adapter." + } + }, + "epochs": "Epoch", + "batch": "Ukuran batch", + "lr": "Learning rate", + "backend": "Backend", + "method": "Metode pelatihan", + "adapterKind": "Jenis adapter", + "rank": "Rank", + "alpha": "Alpha", + "dropout": "Dropout", + "gradAccum": "Langkah akumulasi gradien", + "warmup": "Langkah warmup", + "seqLen": "Panjang sekuens maksimum", + "optimizer": "Optimizer", + "maxSteps": "Langkah maksimum", + "maxStepsHint": "0 untuk otomatis.", + "saveSteps": "Langkah simpan", + "saveLimit": "Batas total simpan", + "saveLimitHint": "0 menyimpan setiap checkpoint.", + "weightDecay": "Weight decay", + "seed": "Seed", + "seedHint": "0 memilih seed acak.", + "precision": "Presisi campuran", + "auto": "Otomatis", + "targetModules": "Modul target", + "targetModulesHint": "Dipisahkan koma. Kosongkan untuk default backend.", + "gradCheckpointing": "Gradient checkpointing (lebih lambat, memakai lebih sedikit memori)", + "token": "Token Hugging Face", + "tokenHint": "Hanya dibutuhkan untuk model yang dibatasi.", + "evalEnable": "Evaluasi selama pelatihan", + "evalStrategy": "Strategi evaluasi", + "evalSteps_": "Langkah", + "evalEpoch": "Epoch", + "evalSteps": "Langkah evaluasi", + "evalStepsHint": "0 mengikuti langkah simpan.", + "evalSplit": "Pembagian evaluasi", + "evalDataset": "Dataset evaluasi", + "evalDatasetPlaceholder": "Dataset terpisah", + "evalRatio": "Rasio pembagian otomatis", + "rewards": "Fungsi reward (GRPO)", + "rewardsHint": "GRPO membutuhkan minimal satu fungsi reward. Pilih yang bawaan atau tambahkan sendiri.", + "resume": "Lanjutkan dari checkpoint", + "clearCheckpoint": "Hapus checkpoint", + "extra": "Opsi tambahan", + "extraHint": "Pasangan kunci dan nilai khusus backend.", + "summaryFull": "model penuh", + "summary_lora": "adapter LoRA", + "summary_loha": "adapter LoHa", + "summary_lokr": "adapter LoKr", + "recipe": "Latih {{kind}} dari {{model}} dengan {{method}}, {{epochs}} epoch pada {{data}}.", + "recipeEmpty": "Pilih model dan dataset untuk melihat apa yang akan dilatih." + }, + "rewards": { + "format_reward": "Memeriksa format ... lalu jawaban.", + "reasoning_accuracy_reward": "Membandingkan isi dengan kolom jawaban dataset.", + "length_reward": "Menilai seberapa dekat jawaban dengan panjang target.", + "xml_tag_reward": "Menilai tag XML yang dibuka dan ditutup dengan benar.", + "no_repetition_reward": "Memberi penalti pada n-gram yang berulang.", + "code_execution_reward": "Memeriksa bahwa blok kode Python memiliki sintaks yang valid.", + "param_target_length": "Panjang target", + "fnName": "Nama fungsi", + "fnBody": "Isi fungsi", + "fnHint": "Menerima completions dan kwargs dan harus mengembalikan daftar float. re, math, json dan string tersedia.", + "add": "Tambah", + "cancel": "Batal", + "custom": "Tambahkan fungsi reward kustom" + }, + "fineTune": { + "title": "Fine-tune", + "lede": "Latih adapter dengan data Anda sendiri, lalu ekspor sebagai model yang bisa Anda ajak chat.", + "importConfig": "Impor konfigurasi", + "exportConfig": "Ekspor konfigurasi", + "newJob": "Job baru", + "badConfig": "File tidak dapat dibaca. Gunakan konfigurasi JSON yang diekspor dari halaman ini.", + "start": "Mulai fine-tuning", + "starting": "Memulai...", + "resume": "Lanjutkan pelatihan", + "resuming": "Melanjutkan dari checkpoint:", + "clear": "Bersihkan", + "barNote": "Tidak ada yang berubah sampai Anda memulai.", + "jobMeta": "{{kind}} {{method}} pada {{backend}}, dimulai {{started}}", + "failed": "Pelatihan gagal", + "failedAt": "Pelatihan berhenti pada langkah {{step}} dari {{total}}", + "noMessage": "Backend tidak memberikan pesan.", + "finished": "Pelatihan selesai.", + "finishedLoss": "Pelatihan selesai. Loss terakhir adalah {{loss}}.", + "stopped": "Job dihentikan. Checkpoint disimpan jika Anda memilih untuk menyimpannya.", + "tryTitle": "Langkah berikutnya", + "tryText": "Server melaporkan bahwa memori habis. Setiap perubahan di bawah diterapkan pada salinan pengaturan, dan tidak ada yang dimulai sampai Anda menekan Mulai.", + "tryPlain": "Buka pengaturan lagi, ubah apa yang ditunjuk pesan, lalu mulai job baru.", + "fixBatch": "Turunkan ukuran batch menjadi 1", + "fixCheckpointing": "Aktifkan gradient checkpointing (lebih lambat, memakai lebih sedikit memori)", + "retryChanges": "Buka pengaturan dengan perubahan ini", + "reuse": "Buka pengaturan lagi", + "resumeFrom": "Lanjutkan dari langkah {{step}}", + "status": { + "queued": "Antre", + "loading_model": "Memuat model", + "loading_dataset": "Memuat dataset", + "training": "Melatih", + "saving": "Menyimpan", + "completed": "Selesai", + "failed": "Gagal", + "stopped": "Dihentikan" + } + }, + "stop": { + "title": "Hentikan pelatihan?", + "text": "Checkpoint yang disimpan sekarang memungkinkan Anda melanjutkan atau mengekspor apa yang sudah dipelajari sejauh ini.", + "keep": "Hentikan dan simpan checkpoint", + "discard": "Hentikan tanpa checkpoint", + "cancel": "Lanjutkan pelatihan", + "cancelDelete": "Batal" + }, + "chart": { + "loss": "Loss", + "learning_rate": "Learning rate", + "grad_norm": "Norma gradien", + "metric": "Metrik", + "step": "langkah", + "eval": "Loss evaluasi", + "waiting": "Menunggu langkah lainnya.", + "window": "Langkah {{from}} sampai {{to}} dari {{total}}", + "windowEval": "Langkah {{from}} sampai {{to}} dari {{total}}. Titik kosong adalah loss evaluasi.", + "aria": "{{metric}} selama langkah pelatihan. Nilai terakhir adalah {{last}} pada langkah {{step}}.", + "readout": "langkah {{step}}: {{value}}", + "dataTitle": "Tampilkan angkanya", + "emptyText": "Grafik digambar setelah job melaporkan dua langkah." + }, + "log": { + "title": "Log", + "note": "Yang dilaporkan job sejak halaman ini dibuka.", + "only": "Hanya peringatan dan error", + "copy": "Salin", + "copied": "Disalin", + "download": "Unduh", + "empty": "Belum ada yang dilaporkan.", + "noneFiltered": "Tidak ada peringatan atau error." + }, + "checkpoints": { + "title": "Checkpoint", + "step": "Langkah", + "epoch": "Epoch", + "loss": "Loss", + "path": "Path", + "actions": "Aksi", + "resume": "Lanjutkan", + "resumeTitle": "Lanjutkan pelatihan dari checkpoint ini", + "export": "Ekspor", + "exportTitle": "Ekspor checkpoint ini" + }, + "export": { + "title": "Ekspor sebagai model", + "lede": "Ekspor mengubah hasilnya menjadi model yang dapat dimuat LocalAI.", + "checkpoint": "Checkpoint", + "final": "Model akhir (direktori output)", + "checkpointOption": "Langkah {{step}} (loss {{loss}})", + "format": "Format ekspor", + "lora": "Adapter LoRA", + "merged16": "Digabung (16-bit)", + "merged4": "Digabung (4-bit)", + "quant": "Kuantisasi", + "name": "Nama model", + "nameHint": "Kosongkan untuk membuat nama dari job.", + "go": "Ekspor", + "exporting": "Mengekspor...", + "working": "Ekspor sedang berjalan...", + "done": "Model diekspor dan didaftarkan sebagai \"{{name}}\"", + "failed": "Ekspor gagal: {{message}}", + "unknown": "error tidak diketahui", + "nextTitle": "{{name}} sekarang adalah sebuah model", + "nextText": "Model sudah terdaftar di LocalAI. Buka chat dengannya, atau temukan di Model.", + "chat": "Chat dengan {{name}}", + "models": "Buka di Model", + "archive": "Unduh arsip" + }, + "jobs": { + "title": "Job sebelumnya", + "count_one": "{{count}} disimpan di server ini", + "count_other": "{{count}} disimpan di server ini", + "loading": "Memuat job", + "emptyTitle": "Belum ada job fine-tuning", + "emptyText": "Siapkan satu di atas. Job akan muncul di sini dengan statusnya, dan Anda dapat menggunakan kembali pengaturannya nanti.", + "model": "Model", + "kind": "Jenis", + "status": "Status", + "started": "Dimulai", + "actions": "Aksi", + "open": "Buka job ini", + "reuse": "Gunakan ulang", + "reuseTitle": "Gunakan pengaturan job ini untuk job baru", + "delete": "Hapus", + "deleteTitle": "Hapus job ini beserta datanya", + "deleteDialogTitle": "Hapus job ini?", + "deleteDialogText": "Ini menghapus job beserta checkpoint dan model hasil ekspor. Tindakan ini tidak dapat dibatalkan." + }, + "quantize": { + "title": "Kuantisasi", + "lede": "Buat model lebih kecil agar muat dalam memori yang lebih sedikit. Hasilnya adalah file GGUF yang dapat Anda impor.", + "newJob": "Job baru", + "sectionModel": "Model", + "model": "Model", + "modelHint": "Id Hugging Face, atau path di host ini.", + "sectionPrecision": "Presisi", + "type": "Tipe kuantisasi", + "typeHint": "Angka yang lebih kecil berarti file lebih kecil dan kualitas sedikit lebih rendah.", + "custom": "Kustom...", + "customLabel": "Tipe kustom", + "customPlaceholder": "contoh iq4_nl", + "moreHint": "backend, token Hugging Face", + "tokenHint": "Hanya dibutuhkan untuk model yang dibatasi.", + "recipe": "Konversi {{model}} ke GGUF pada {{type}}.", + "recipeEmpty": "Masukkan model untuk melihat apa yang akan dibuat.", + "start": "Kuantisasi ({{type}})", + "barNote": "Tidak ada yang berubah sampai Anda memulai.", + "jobMeta": "{{type}} pada {{backend}}, dimulai {{started}}", + "failed": "Kuantisasi gagal", + "finished": "Kuantisasi selesai: {{type}}.", + "stopped": "Job dihentikan.", + "tryText": "Buka pengaturan lagi, ubah apa yang ditunjuk pesan, lalu mulai job baru. Backend yang lebih baru mungkin mendukung arsitektur yang tidak didukung backend lama.", + "outputTitle": "Hasil", + "outputText": "File GGUF pada {{type}}. Impor untuk digunakan di LocalAI, atau unduh.", + "file": "File", + "importName": "Nama model", + "importNamePlaceholder": "Dibuat otomatis jika kosong", + "import": "Impor ke LocalAI", + "importing": "Mengimpor...", + "importFailed": "Impor gagal", + "importFailedWith": "Impor gagal: {{message}}", + "download": "Unduh GGUF", + "nextTitle": "{{name}} sekarang adalah sebuah model", + "nextText": "Model sudah terdaftar di LocalAI. Buka chat dengannya, atau temukan di Model.", + "chat": "Chat dengan {{name}}", + "models": "Buka di Model", + "imported": "diimpor sebagai {{name}}", + "emptyTitle": "Belum ada job kuantisasi", + "emptyText": "Siapkan satu di atas. Job akan muncul di sini dengan statusnya.", + "deleteDialogText": "Ini menghapus job beserta file hasil konversinya. Tindakan ini tidak dapat dibatalkan.", + "startFailed": "Gagal memulai job", + "stopFailed": "Gagal menghentikan job", + "deleteFailed": "Gagal menghapus job", + "status": { + "queued": "Antre", + "downloading": "Mengunduh", + "converting": "Mengonversi", + "quantizing": "Mengkuantisasi", + "completed": "Selesai", + "failed": "Gagal", + "stopped": "Dihentikan" + } + }, + "off": { + "badge": "Tidak tersedia untuk Anda", + "fineTune": { + "title": "Akun Anda tidak dapat melakukan fine-tuning", + "text": "Fine-tuning adalah izin tersendiri, dan akun ini tidak memilikinya." + }, + "quantize": { + "title": "Akun Anda tidak dapat melakukan kuantisasi", + "text": "Kuantisasi adalah izin tersendiri, dan akun ini tidak memilikinya." + }, + "admin": "Administrator dapat mengaktifkannya di Pengguna dan kunci.", + "askAdmin": "Minta administrator untuk mengaktifkannya untuk akun Anda.", + "back": "Ke Beranda" + } +} diff --git a/core/http/react-ui/public/locales/id/traffic.json b/core/http/react-ui/public/locales/id/traffic.json new file mode 100644 index 000000000..3beb9955f --- /dev/null +++ b/core/http/react-ui/public/locales/id/traffic.json @@ -0,0 +1,12 @@ +{ + "overview": { + "usageErrorTitle": "Penggunaan tidak dapat dibaca." + }, + "host": { + "errorTitle": "Host tidak dapat dibaca." + }, + "models": { + "noMatch": "Tidak ada model yang cocok dengan “{{query}}”." + }, + "clearSearch": "Hapus pencarian" +} diff --git a/core/http/react-ui/public/locales/id/upgrade.json b/core/http/react-ui/public/locales/id/upgrade.json new file mode 100644 index 000000000..e62fea6a8 --- /dev/null +++ b/core/http/react-ui/public/locales/id/upgrade.json @@ -0,0 +1,46 @@ +{ + "badge": { + "available": "Pembaruan tersedia", + "availableVersion": "Pembaruan tersedia: {{version}}" + }, + "trigger": { + "all": "Perbarui semua ({{count}})", + "running": "Memperbarui {{current}} dari {{total}}", + "summary": "{{done}} diperbarui, {{failed}} gagal, {{skipped}} dilewati", + "summaryOk": "{{done}} diperbarui", + "review": "Tinjau" + }, + "dialog": { + "title": "Perbarui semua", + "intro": "Pilih apa yang akan diperbarui. Setiap item diperbarui sendiri-sendiri, satu per satu. Kegagalan tidak menghentikan yang lain.", + "selection": "Pilihan", + "selectAll": "Pilih semua", + "selectNone": "Hapus pilihan", + "selectedCount_one": "{{count}} dipilih", + "selectedCount_other": "{{count}} dipilih", + "item": "Perbarui {{name}}", + "versions": "{{from}} ke {{to}}", + "blocked": "Tidak dapat dimulai", + "start": "Perbarui yang dipilih", + "starting": "Memperbarui...", + "retry": "Ulangi yang gagal ({{count}})", + "again": "Pilihan baru", + "close": "Tutup", + "closeRunning": "Tutup (tetap berjalan)", + "runningNote": "Menutup jendela ini tidak menghentikan pembaruan.", + "progress": "Memperbarui {{current}} dari {{total}}", + "summary": "{{done}} diperbarui, {{failed}} gagal, {{skipped}} dilewati", + "empty": "Tidak ada yang perlu diperbarui." + }, + "item": { + "queued": "Antre", + "upgrading": "Memperbarui", + "done": "Selesai", + "failed": "Gagal", + "skipped": "Dilewati" + }, + "reason": { + "checkAgain": "Periksa lagi", + "checking": "Memeriksa..." + } +} diff --git a/core/http/react-ui/public/locales/id/workspace.json b/core/http/react-ui/public/locales/id/workspace.json new file mode 100644 index 000000000..402cbfe20 --- /dev/null +++ b/core/http/react-ui/public/locales/id/workspace.json @@ -0,0 +1,671 @@ +{ + "wellKnown": { + "revert": "Kembali ke versi sebelumnya", + "view": "Lihat", + "form": "Formulir", + "save": "Simpan", + "cancel": "Batal", + "notSet": "Belum diatur", + "moveUp": "Naik", + "moveDown": "Turun", + "remove": "Hapus", + "savedAt": "Disimpan pada {{time}}", + "notSaved": "Belum disimpan.", + "notSavedPrefilled": "Belum disimpan. Sebagian nilai sudah diisi untuk Anda; Simpan akan mempertahankannya.", + "loadFailed": "Pengaturan ini tidak dapat dimuat. Muat ulang halaman untuk mencoba lagi.", + "invalid": "Pengaturan ini memiliki kesalahan, dan tidak ada versi valid sebelumnya.", + "invalidFallback": "Pengaturan ini memiliki kesalahan. Yang ditampilkan adalah versi valid terakhirnya.", + "conflict": "Pengaturan ini berubah saat Anda mengeditnya, kemungkinan oleh asisten atau di tab lain. Nilai yang lebih baru adalah:", + "useTheirs": "Gunakan nilai yang lebih baru", + "keepMine": "Simpan perubahan saya", + "editWithAssistant": "Edit dengan asisten", + "from": { + "account": "Dari akun Anda", + "browser": "Dari browser Anda" + }, + "check": { + "required": "Wajib diisi.", + "tooLong": "Maksimal {{max}} karakter.", + "format": "Nilai ini memiliki format yang salah." + }, + "source": { + "label": "Sumber", + "hint": "Pengaturan dalam bentuk teks. Saat disimpan, pengaturan diperiksa dengan cara yang sama.", + "notJson": "Teks memiliki kesalahan sintaksis. Periksa tanda kutip, koma, dan kurung kurawal.", + "notObject": "Teks harus diawali dengan { dan diakhiri dengan }." + }, + "persona": { + "title": "Asisten", + "lede": "Nama dan instruksi yang digunakan asisten di setiap chat." + }, + "user": { + "title": "Anda", + "lede": "Asisten membaca ini untuk mengetahui siapa lawan bicaranya. Ini dikirim ke model di setiap chat." + }, + "ui": { + "title": "Tampilan", + "lede": "Tampilan LocalAI untuk Anda. Perubahan langsung terlihat; Simpan akan mempertahankannya." + }, + "shortcuts": { + "title": "Pintasan", + "lede": "Prompt di atas kolom tulis setiap chat.", + "edit": "Edit pintasan", + "defaults": "Pintasan bawaan", + "none": "Tidak ada pintasan. Tambahkan satu, atau simpan daftar kosong untuk menyembunyikannya.", + "label": "Label", + "prompt": "Prompt", + "send": "Kirim langsung", + "add": "Tambah pintasan", + "invalid": "Beberapa pintasan memiliki kesalahan.", + "assistantPrompt": "Ubah pintasan saya: " + }, + "home": { + "editLayout": "Edit tata letak", + "ask": "Tanya asisten", + "askPrompt": "Ubah halaman Home saya: ", + "add": "Tambah widget", + "newTitle": "Widget baru", + "noWidgets": "Belum ada widget.", + "type": "Jenis", + "title": "Judul", + "source": "File data", + "noSourceNeeded": "Tidak perlu file data", + "types": { + "list": "Daftar", + "metric": "Metrik", + "chats": "Chat terakhir", + "page": "Halaman" + }, + "left": "{{count}} tersisa", + "fromChats": "dari chat", + "noChats": "Belum ada chat.", + "noData": "Belum ada data. Asisten dapat menulis", + "nothing": "Tidak ada apa-apa di sini.", + "noSource": "Widget ini tidak memiliki file data. Pilih satu di Edit tata letak.", + "broken": "Data widget ini memiliki kesalahan, dan tidak ada versi valid sebelumnya.", + "stale": "Menampilkan data valid terakhir. Data terbaru memiliki kesalahan.", + "updated": "Diperbarui {{when}}", + "footer": { + "noData": "Belum ada data.", + "invalid": "Data memiliki kesalahan. Menampilkan versi baik terakhir.", + "revert": "Kembalikan", + "missing": "Data widget ini tidak ada.", + "fix": "Minta asisten memperbaikinya", + "fixPrompt": "Widget Beranda membaca {{file}}, yang hilang atau rusak. Perbaiki: ", + "waiting": "Menunggu Anda membuat pemicunya di obrolan.", + "openChat": "Buka obrolan", + "running": "Sedang diperbarui…", + "openRun": "Buka jalannya", + "paused": "Dijeda · sebelumnya {{schedule}}", + "resume": "Lanjutkan", + "failedEmpty": "Pembaruan gagal dan belum ada data.", + "details": "Detail", + "retry": "Coba lagi", + "stale": "Pembaruan terakhir gagal. Menampilkan data {{when}}.", + "staleToday": "Pembaruan terakhir gagal. Menampilkan data sebelumnya dari hari ini.", + "earlier": "sebelumnya", + "firstRun": "Jalan pertama {{when}}", + "firstRunFile": "Berjalan saat sebuah file masuk", + "runNow": "Jalankan sekarang", + "fresh": "{{schedule}} · diperbarui {{time}}", + "pause": "Jeda", + "fromPage": "Dari halaman {{title}}", + "fromFile": "Dari berkas Anda", + "next": "Jalan berikutnya {{when}}", + "cost": "sekitar {{tokens}} token per jalan", + "costRemote": "sekitar {{tokens}} token per jalan pada model jarak jauh", + "cadence": { + "paused": "Dijeda", + "hourly": "Tiap jam", + "daily": "Harian", + "weekdays": "Hari kerja", + "weekly": "Mingguan", + "monthly": "Bulanan", + "file": "Saat ada file baru" + }, + "scheduleNote_one": "{{count}} widget diperbarui sesuai jadwal. Jalannya memakai model Anda dan dihitung dalam penggunaan Anda.", + "scheduleNote_other": "{{count}} widget diperbarui sesuai jadwal. Jalannya memakai model Anda dan dihitung dalam penggunaan Anda." + }, + "opensSite": "{{host}}: terbuka di tab baru", + "linkAsk": "Widget ini ingin membuka tautan", + "invalid": "Tata letak Home Anda memiliki kesalahan, dan tidak ada versi valid sebelumnya.", + "invalidFallback": "Tata letak Home Anda memiliki kesalahan. Home menampilkan versi valid terakhirnya.", + "page": { + "gone": "Halaman ini tidak tersedia lagi", + "goneHint": "Halaman \"{{name}}\" dipindahkan atau dihapus, atau Anda tidak dapat membukanya lagi.", + "noWidget": "Halaman ini tidak punya tampilan widget", + "noWidgetHint": "Minta asisten memberi halaman \"{{name}}\" tampilan ringkas untuk Beranda.", + "off": "Halaman bersama dimatikan", + "offHint": "Administrator mematikan halaman bersama di server ini.", + "fix": "Sesuaikan Beranda", + "failed": "Halaman tidak dimuat", + "retry": "Coba lagi", + "changed": "Halaman ini berubah sejak Anda mengizinkannya", + "notAllowed": "Anda belum mengizinkan halaman ini", + "allowHint": "Halaman ini berasal dari folder bersama. Periksa sebelum berjalan atas nama Anda.", + "allow": "Izinkan halaman ini", + "frameTitle": "{{title}} (halaman di Beranda)", + "refresh": "Muat ulang", + "open": "Buka halaman", + "add": "Tambah halaman", + "limit": "Beranda menampilkan paling banyak {{max}} halaman; hapus satu untuk menambah yang lain.", + "noneEligible": "Belum ada halaman dengan tampilan ringkas untuk Beranda. Minta asisten menambahkannya ke sebuah halaman.", + "pick": "Halaman", + "unavailable": "{{name}} (tidak tersedia)", + "sharedLabel": "bersama", + "sizes": { + "small": "Kecil", + "medium": "Sedang", + "large": "Besar" + } + } + }, + "fields": { + "persona": { + "name": { + "label": "Nama", + "help": "Nama yang digunakan asisten untuk dirinya sendiri." + }, + "system_prompt": { + "label": "Prompt sistem", + "help": "Ditambahkan ke setiap chat, setelah prompt chat dan instruksi agen." + } + }, + "user": { + "name": { + "label": "Nama", + "help": "Cara asisten menyapa Anda." + }, + "language": { + "label": "Bahasa", + "help": "Bahasa yang digunakan asisten untuk menjawab." + }, + "bio": { + "label": "Tentang Anda", + "help": "Beberapa baris yang dibaca asisten di setiap chat." + }, + "timezone": { + "label": "Zona waktu", + "help": "Untuk tanggal dan waktu dalam jawaban." + } + }, + "ui": { + "theme": { + "label": "Tema", + "help": "Sistem mengikuti pengaturan perangkat Anda.", + "options": { + "dark": "Gelap", + "light": "Terang", + "system": "Sistem" + } + }, + "accent": { + "label": "Warna aksen", + "help": "Untuk tombol dan tautan. Kosong memakai bawaan." + }, + "density": { + "label": "Kepadatan", + "help": "Ringkas menampilkan lebih banyak dalam satu layar.", + "options": { + "comfortable": "Nyaman", + "compact": "Ringkas" + } + }, + "defaultModel": { + "label": "Model bawaan", + "help": "Model yang digunakan saat chat baru dimulai." + } + } + }, + "saveInvalid": "Beberapa nilai tidak valid. Periksa lalu simpan lagi.", + "saveTooLarge": "Pengaturan ini terlalu besar untuk disimpan. Hapus beberapa entri lalu coba lagi.", + "saveFailed": "Tidak dapat menyimpan. Coba lagi.", + "details": "Detail" + }, + "discovery": { + "ex": { + "title": "Buat LocalAI jadi milikmu", + "lede": "Katakan di chat. Asisten menyimpannya, dan Anda bisa mengubah atau membatalkannya.", + "hide": "Sembunyikan", + "hiddenToast": "Contoh disembunyikan.", + "saveFailed": "Tidak tersimpan: {{message}}", + "card1": { + "kicker": "Kamu dan asisten", + "prompt": "Panggil aku {{firstName}} dan jawab singkat saja", + "promptNoName": "Ingat namaku dan jawab singkat saja", + "what": "Menyimpan nama Anda dan cara asisten menjawab" + }, + "card2": { + "kicker": "Sebuah halaman", + "prompt": "Buatkan aku papan tugas", + "what": "Menambah halaman yang kamu buka dari bilah sisi" + }, + "card3": { + "kicker": "Setiap pagi", + "prompt": "Setiap pagi, ringkas kotak masukku di sini", + "promptNoMail": "Setiap pagi, daftarkan chat kemarin di sini", + "what": "Menambah widget Home dan pemicu yang kamu konfirmasi" + }, + "quote": "“{{text}}”", + "donePrefix": "Selesai.", + "doneLink": "Ubah di Account", + "ready": "Atau mulai dari halaman siap pakai:", + "readyTasks": "Papan tugas", + "readyReading": "Daftar bacaan", + "readyFailed": "Halaman tidak disalin: {{message}}", + "learn": "Cara kerjanya", + "inComposer": "Ubah jika perlu, lalu tekan Enter. Tidak ada yang disimpan sampai kamu mengirimnya.", + "live": "Prompt ditambahkan ke kotak pesan." + }, + "home": { + "customise": "Sesuaikan Home" + }, + "action": { + "undo": "Urungkan", + "openAccount": "Buka di Account", + "openPage": "Buka halaman", + "openHome": "Buka Home", + "turnBackOn": "Aktifkan lagi di Account" + }, + "change": { + "profilePersona": "Nama Anda dan instruksi Anda untuk asisten disimpan. Setiap chat memakainya mulai pesan berikutnya.", + "profile": "Disimpan ke profil Anda.", + "persona": "Instruksi asisten disimpan. Setiap chat memakainya mulai pesan berikutnya.", + "shortcuts": "Pintasan Anda disimpan.", + "home": "Halaman Home Anda disimpan. Home menampilkan perubahannya.", + "ui": "Pengaturan tampilan Anda disimpan.", + "pageCreated": "Halaman {{title}} dibuat.", + "pageChanged": "Halaman {{title}} diubah.", + "offersOff": "Saran dari asisten dimatikan.", + "undoing": "Mengurungkan…", + "undone": "Diurungkan.", + "undoFailed": "Tidak diurungkan: {{message}}" + }, + "toast": { + "shortcutAdded": "Ditambahkan ke kotak pesanmu: {{label}}", + "pageCreated": "{{title}} ada di Pages, di bilah sisi", + "open": "Buka", + "homeChanged": "Home diperbarui.", + "themeChanged": "Tema diubah." + }, + "files": { + "learn": "File ini mengubah cara LocalAI bekerja untukmu.", + "tag": { + "profile": "Profilmu", + "persona": "Asisten", + "shortcuts": "Pintasan", + "home": "Tata letak Home", + "ui": "Tampilan", + "page": "Halaman" + } + }, + "privacy": "Asisten menerima namamu, teks ini, bahasa dan zona waktumu di awal setiap chat. Kosongkan kolom agar tidak dikirim.", + "versions": { + "label": "Versi", + "title": "Versi sebelumnya", + "loading": "Membaca riwayat…", + "none": "Belum ada versi sebelumnya.", + "current": "saat ini", + "invalid": "ada kesalahan", + "byAssistant": "Oleh asisten", + "byAssistantIn": "Oleh asisten di “{{chat}}”", + "byYou": "Oleh kamu", + "undo": "Sebuah urungan", + "upload": "Diunggah", + "revert": "Pulihkan", + "note": "Memulihkan menyimpan versi yang lebih baru di daftar ini.", + "invalidBadge": "Tidak valid", + "revertGood": "Pulihkan ke versi bagus terakhir" + }, + "help": { + "title": "Bantuan dan saran", + "sub": "Cara LocalAI menunjukkan apa yang bisa dilakukannya. Matikan yang tidak kamu perlukan.", + "offers": "Tawaran dari asisten", + "offersHint": "Paling banyak satu per hari, tidak pernah saat tugas berjalan: misalnya, untuk mengingat sesuatu yang kamu katakan.", + "new": "Catatan tentang hal baru", + "newHint": "Label “Baru” singkat di bilah sisi saat sesuatu pertama kali muncul.", + "examples": "Contoh di Home", + "examplesShown": "Kartu “Buat LocalAI jadi milikmu” tampil di Home selama masih ada yang bisa dicoba.", + "examplesHidden": "Kamu menyembunyikan kartu “Buat LocalAI jadi milikmu”.", + "showExamples": "Tampilkan contoh lagi", + "examplesBack": "Contoh kembali di Home.", + "dismissed": "Yang dipelajari asisten", + "dismissedHint_one": "Kamu menolak {{count}} tawaran. Tawaran itu tetap tersembunyi sampai kamu mengatur ulang.", + "dismissedHint_other": "Kamu menolak {{count}} tawaran. Semuanya tetap tersembunyi sampai kamu mengatur ulang.", + "forget": "Lupakan yang dipelajari asisten dari petunjukku", + "forgotten": "Selesai. Asisten boleh menawarkan lagi.", + "learn": "Pelajari apa yang bisa kamu ubah:", + "dismissedNone": "Belum ada. Saat kamu menjawab tawaran, asisten mengingatnya di sini." + } + }, + "tabs": { + "shared": "Dibagikan", + "trashCount_one": "{{count}} item di Sampah", + "trashCount_other": "{{count}} item di Sampah" + }, + "trash": { + "empty": "Tempat sampah kosong.", + "restore": "Pulihkan", + "emptyAll": "Kosongkan tempat sampah", + "purge": "Hapus permanen", + "purgeNamed": "Hapus {{name}} secara permanen", + "files_one": "{{count}} berkas", + "files_other": "{{count}} berkas", + "folders_one": "{{count}} folder", + "folders_other": "{{count}} folder", + "listJoin": ", ", + "summary": "Di tempat sampah: {{what}}.", + "removed": "Dihapus permanen: {{what}}.", + "confirmAll": { + "title": "Kosongkan tempat sampah?", + "message": "Ini menghapus permanen {{what}} beserta versi sebelumnya. Tindakan ini tidak dapat dibatalkan." + }, + "confirmOne": { + "title": "Hapus permanen?", + "message": "{{path}}: ini menghapus permanen {{what}} beserta versi sebelumnya. Tindakan ini tidak dapat dibatalkan." + } + }, + "shared": { + "adminTitle": "Folder bersama", + "adminLede": "Folder yang dipakai beberapa orang. Anggota membuka halamannya, dan chat mereka bisa membaca atau mengubah filenya.", + "userTitle": "Dibagikan kepada Anda", + "userLede": "Folder yang dibagikan administrator kepada Anda. Halamannya ada di Halaman, dan chat Anda bisa memakai filenya.", + "new": "Folder bersama baru", + "nameLabel": "Nama folder", + "nameHint": "Hingga 64 huruf, angka, spasi, titik, tanda hubung, atau garis bawah. Mulai dengan huruf atau angka.", + "create": "Buat", + "cancel": "Batal", + "rename": "Ganti nama", + "renameSave": "Simpan", + "remove": "Hapus", + "manage": "Anggota", + "menu": "Tindakan lain untuk {{name}}", + "pages_one": "{{count}} halaman", + "pages_other": "{{count}} halaman", + "pagesNone": "Tidak ada halaman", + "people_one": "{{count}} orang", + "people_other": "{{count}} orang", + "membersNone": "Tidak ada anggota", + "everyone": "Semua orang", + "everyoneOption": "Semua orang (semua pengguna)", + "everyoneHint": "Semua pengguna, termasuk yang bergabung nanti", + "everyoneCan": { + "read": "Semua orang bisa membaca", + "write": "Semua orang bisa mengubah" + }, + "mode": { + "read": "Bisa membaca", + "write": "Bisa mengubah" + }, + "addTitle": "Tambah anggota", + "person": "Orang", + "access": "Akses", + "accessFor": "Akses untuk {{name}}", + "add": "Tambah", + "allAdded": "Semua orang sudah menjadi anggota.", + "usersUnavailable": "Daftar pengguna tidak dapat dimuat. Anda tetap bisa menambahkan Semua orang.", + "membersTitle": "Anggota", + "noMembers": "Belum ada yang bisa memakai folder ini. Tambahkan seseorang atau Semua orang.", + "removeMember": "Hapus {{name}}", + "paneNote": "Perubahan langsung berlaku. Chat yang terbuka melihatnya saat dibuka lagi.", + "created": "“{{name}}” dibuat. Tambahkan orang yang boleh memakainya.", + "removed": "“{{name}}” dihapus. Halamannya meminta izin lagi sebelum berjalan.", + "restored": "“{{name}}” kembali, bersama anggotanya.", + "memberAdded": "{{name}}: {{mode}}.", + "memberRemoved": "{{name}} tidak bisa lagi memakai “{{folder}}”.", + "failed": "Tidak berhasil: {{message}}", + "loadFailed": "Folder bersama tidak dapat dimuat.", + "emptyAdminTitle": "Belum ada folder bersama", + "emptyAdminBody": "Folder bersama menyimpan file dan halaman yang bisa dibaca atau diubah orang lain. Buat satu, lalu tambahkan anggota.", + "factMembers": "Beri seseorang atau semua orang akses baca atau ubah", + "factChats": "Chat anggota melihat file folder itu", + "factUndo": "Menghapus folder bisa dibatalkan", + "emptyUserTitle": "Belum ada yang dibagikan kepada Anda", + "emptyUserBody": "Saat administrator membagikan folder kepada Anda, folder itu muncul di sini dan halamannya di Halaman." + }, + "preview": { + "label": "Pratinjau", + "failed": "File ini tidak dapat ditampilkan.", + "loading": "Memuat pratinjau {{name}}", + "failedTitle": "Tidak dapat menampilkan {{name}}.", + "tooLarge": "File ini terlalu besar untuk dipratinjau. Unduh untuk membukanya.", + "none": "Tidak ada pratinjau untuk jenis file ini.", + "typeFile": "File {{ext}}", + "typeUnknown": "File", + "sheets": "Lembar", + "sheetLabel": "Lembar {{name}}", + "sheetEmpty": "Lembar ini kosong.", + "row": "Baris", + "showingRows": "Menampilkan {{rows}} dari {{total}} baris", + "showingCols": "dan {{cols}} dari {{total}} kolom.", + "downloadRest": "Unduh file untuk sisanya.", + "textOnly": "Pratinjau teks, format tidak ditampilkan.", + "truncated": "Teksnya dipotong.", + "noText": "File ini tidak berisi teks.", + "slide": "Slide {{n}}", + "notes": "Catatan pembicara", + "pdfTitle": "PDF {{name}}", + "pdfFallback": "Jika PDF tidak tampil di sini, unduh file-nya.", + "htmlTitle": "Pratinjau {{name}}", + "imageFailed": "Gambar tidak dapat ditampilkan.", + "zoomIn": "Tampilkan ukuran penuh", + "zoomOut": "Sesuaikan dengan panel", + "items_one": "{{count}} item", + "items_other": "{{count}} item", + "textCut": "Menampilkan {{size}} pertama.", + "actions": "Tindakan file" + }, + "managed": { + "group": "Dikelola oleh LocalAI", + "marker": "dikelola oleh LocalAI", + "about": { + "localai": "Pengaturan yang dibaca LocalAI: persona, profil, pintasan, Beranda, tema.", + "chats": "Satu folder per obrolan, berisi file obrolan itu." + }, + "confirm": { + "deleteTitle": "Pindahkan {{name}} ke Tempat Sampah?", + "renameTitle": "Ganti nama {{name}}?", + "delete": "Pindahkan ke Tempat Sampah", + "rename": "Ganti nama", + "restore": "Anda dapat memulihkannya dari Tempat Sampah.", + "renameNote": "LocalAI hanya membacanya dengan nama ini; setelah diganti namanya, seolah-olah hilang." + }, + "loses": { + "settings": "LocalAI melupakan persona, profil, pintasan, tata letak Beranda, dan tema Anda.", + "chats": "Setiap obrolan kehilangan filenya: yang Anda lampirkan dan yang ditulis asisten di sana.", + "persona": "Asisten kehilangan nama dan instruksinya.", + "user": "Asisten melupakan apa yang Anda ceritakan tentang diri Anda, dan jawaban Anda atas tawarannya.", + "shortcuts": "Kotak pesan kembali menampilkan pintasan bawaan.", + "home": "Beranda kehilangan widgetnya.", + "ui": "Tema, warna aksen, kepadatan, model bawaan, dan halaman yang disematkan kembali ke bawaan." + } + }, + "newFile": "File baru", + "newFolderName": "Folder baru", + "newFileName": "Tanpa judul.md", + "fileName": "Nama file", + "downloadOne": "Unduh {{name}}", + "moveTo": "Pindahkan ke…", + "uploadMenu": "Opsi unggah lainnya", + "uploadFiles": "Unggah file", + "uploadFolder": "Unggah folder", + "loading": "Memuat folder", + "loadFailed": "Tidak dapat membuka {{folder}}.", + "types": { + "folder": "Folder", + "image": "Gambar", + "pdf": "PDF", + "sheet": "Spreadsheet", + "doc": "Dokumen", + "slides": "Presentasi", + "archive": "Arsip", + "audio": "Audio", + "video": "Video", + "code": "Kode", + "text": "Teks", + "file": "File" + }, + "columns": { + "name": "Nama", + "modified": "Diubah", + "type": "Jenis", + "size": "Ukuran", + "actions": "Tindakan" + }, + "view": { + "label": "Tampilan", + "list": "Daftar", + "grid": "Gambar mini" + }, + "select": { + "one": "Pilih {{name}}", + "all": "Pilih semua", + "bar": "Item yang dipilih", + "clear": "Hapus pilihan", + "count_one": "{{count}} dipilih", + "count_other": "{{count}} dipilih", + "start": "Pilih" + }, + "menu": { + "label": "Tindakan untuk {{name}}", + "more": "Tindakan lainnya untuk {{name}}", + "openFolder": "Buka", + "preview": "Pratinjau", + "downloadMany_one": "Unduh {{count}} file", + "downloadMany_other": "Unduh {{count}} file", + "moveMany_one": "Pindahkan {{count}} item ke…", + "moveMany_other": "Pindahkan {{count}} item ke…", + "deleteMany_one": "Pindahkan {{count}} item ke Sampah", + "deleteMany_other": "Pindahkan {{count}} item ke Sampah" + }, + "keys": { + "delete": "Del" + }, + "crumbs": { + "hidden": "Tampilkan folder di atasnya" + }, + "naming": { + "empty": "Ketik nama.", + "slash": "Nama tidak boleh berisi / atau \\.", + "taken": "Sudah ada item dengan nama itu di folder ini." + }, + "toast": { + "undo": "Urungkan", + "deletedOne": "{{name}} dipindahkan ke Sampah.", + "deleted_one": "{{count}} item dipindahkan ke Sampah.", + "deleted_other": "{{count}} item dipindahkan ke Sampah.", + "restored": "Dipulihkan.", + "renamed": "Diganti namanya menjadi {{name}}.", + "renameUndone": "Nama dikembalikan.", + "movedOne": "{{name}} dipindahkan ke {{folder}}.", + "moved_one": "{{count}} item dipindahkan ke {{folder}}.", + "moved_other": "{{count}} item dipindahkan ke {{folder}}.", + "moveUndone": "Dikembalikan." + }, + "search": { + "placeholder": "Saring folder ini", + "resultsFor": "File yang berisi “{{query}}”", + "back": "Kembali ke folder", + "noneBody": "Tidak ada nama atau teks di file Anda yang cocok. Coba kata lain.", + "page": "halaman {{n}}", + "sheet": "lembar “{{name}}”", + "slide": "slide {{n}}", + "indexing_one": "{{count}} dokumen masih diindeks: cari lagi sebentar lagi untuk menyertakannya.", + "indexing_other": "{{count}} dokumen masih diindeks: cari lagi sebentar lagi untuk menyertakannya.", + "limited": "Hasil dibatasi: pencarian berhenti pada batasnya sebelum memeriksa setiap file. Cari di dalam folder atau gunakan kata lain untuk melihat sisanya.", + "filtering_one": "{{count}} item di folder ini cocok dengan “{{query}}”.", + "filtering_other": "{{count}} item di folder ini cocok dengan “{{query}}”.", + "everywhere": "Cari “{{query}}” di semua file", + "everywhereShort": "Cari di semua file", + "noMatch": "Tidak ada yang cocok dengan “{{query}}” di folder ini.", + "clear": "Hapus saringan" + }, + "emptyHome": { + "title": "File Anda ada di sini" + }, + "emptyFolder": { + "title": "Folder ini kosong", + "body": "Jatuhkan file atau folder di mana saja pada halaman ini, tempel dengan Ctrl+V, atau unggah dari komputer Anda. Asisten juga dapat menyimpan file di sini dari obrolan.", + "bodyTouch": "Unggah file dari perangkat Anda. Asisten juga dapat menyimpan file di sini dari obrolan.", + "ask": "Tanya asisten" + }, + "drop": { + "title": "Lepas untuk mengunggah ke {{folder}}", + "body": "File dan folder utuh diterima.", + "limit": "File dan folder utuh, hingga {{size}} per file." + }, + "conflict": { + "title_one": "File dengan nama ini sudah ada", + "title_other": "{{count}} file dengan nama ini sudah ada", + "body_one": "{{folder}} sudah memiliki file dengan nama ini.", + "body_other": "{{folder}} sudah memiliki file dengan nama-nama ini.", + "skip_one": "Lewati", + "skip_other": "Lewati semua", + "keep": "Simpan keduanya", + "replace_one": "Ganti", + "replace_other": "Ganti semua", + "more_one": "dan {{count}} lagi", + "more_other": "dan {{count}} lagi", + "note": "Simpan keduanya memberi nama baru pada file yang baru. File yang diganti dapat dikembalikan dari Riwayat.", + "cancel": "Batalkan unggahan" + }, + "move": { + "title_one": "Pindahkan {{count}} item", + "title_other": "Pindahkan {{count}} item", + "items_one": "{{count}} item", + "items_other": "{{count}} item", + "cancel": "Batal", + "here": "Pindahkan ke {{folder}}", + "failed": "Tidak dapat membaca folder ini.", + "noFolders": "Tidak ada folder di sini. Pindahkan ke sini, atau naik satu tingkat." + }, + "uploads": { + "waiting": "Menunggu", + "progress": "{{loaded}} dari {{total}}", + "rate": "{{rate}}/dtk", + "left": "{{time}} lagi", + "renamed": "Disimpan sebagai {{name}}", + "done": "Terunggah", + "canceled": "Dibatalkan", + "cancel": "Batal", + "cancelOne": "Batalkan unggahan {{name}}", + "retry": "Coba lagi", + "retryOne": "Coba {{name}} lagi", + "dismiss": "Tutup", + "dismissOne": "Tutup {{name}}", + "show": "Tampilkan", + "showOne": "Tampilkan {{name}}", + "meterOne": "{{name}}: {{percent}}%", + "meterAll": "Semua unggahan: {{percent}}%", + "cancelAll": "Batalkan semua", + "retryAll": "Coba semua lagi", + "fold": "Sembunyikan daftar", + "unfold": "Tampilkan daftar", + "close": "Tutup", + "head": { + "sending_one": "Mengunggah {{count}} file", + "sending_other": "Mengunggah {{count}} file", + "failed_one": "{{count}} unggahan gagal", + "failed_other": "{{count}} unggahan gagal", + "done_one": "{{count}} file terunggah", + "done_other": "{{count}} file terunggah" + }, + "say": { + "start_one": "Mengunggah {{count}} file.", + "start_other": "Mengunggah {{count}} file.", + "done_one": "Unggahan selesai: {{count}} file.", + "done_other": "Unggahan selesai: {{count}} file.", + "doneFailed": "Unggahan selesai: {{count}} berhasil, {{failed}} gagal.", + "failed": "Tidak dapat mengunggah {{name}}." + }, + "errors": { + "tooBig": "Melebihi batas {{limit}} di server ini.", + "tooLarge": "Server menolaknya karena terlalu besar.", + "off": "File dinonaktifkan di server ini.", + "invalid": "Server menolak file ini.", + "network": "Koneksi terputus.", + "server": "Server tidak dapat menyimpannya." + }, + "said": "Server menjawab: {{reason}}." + } +} diff --git a/core/http/react-ui/public/locales/it/addons.json b/core/http/react-ui/public/locales/it/addons.json index 1cbb8cb90..55d455a20 100644 --- a/core/http/react-ui/public/locales/it/addons.json +++ b/core/http/react-ui/public/locales/it/addons.json @@ -3,9 +3,9 @@ "blocked": "Un'azione precedente su questo componente non è ancora confermata. Controlla prima il suo stato o ignorala.", "checkStatus": "Controlla lo stato", "dismiss": "Ignora", - "failed": "Il proprietario non ha completato l'azione. Nulla è stato modificato. Riprova per iniziare un nuovo tentativo.", - "notApplied": "Il proprietario ha segnalato l'azione come completata, ma il componente non è cambiato. Riprova.", - "unknown": "Il proprietario non ha ancora confermato l'azione. Potrebbe ancora essere applicata. Controlla lo stato o ignorala prima di avviare un'altra azione." + "failed": "L’azione non è stata completata. Nulla è stato modificato. Riprova per iniziare un nuovo tentativo.", + "notApplied": "L’azione è stata segnalata come completata, ma il componente non è cambiato. Riprova per iniziare un nuovo tentativo.", + "unknown": "L’azione non è ancora confermata. Potrebbe ancora essere applicata. Controlla lo stato o ignorala prima di avviare un’altra azione." }, "approval": { "title": "Serve la tua approvazione", @@ -37,14 +37,16 @@ "emptyTitle": "Ancora nessuna app", "firstRun": { "browse": "Browse add-ons", - "title": "Welcome to Apps", - "lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.", - "featuredLabel": "Featured add-ons", + "title": "Ancora nessun componente", + "lead": "I componenti aggiuntivi danno a LocalAI nuove pagine e strumenti per le chat. Girano per conto loro e chiedono solo ciò che dichiarano.", + "featuredLabel": "Inizia da uno di questi", "featuredFailed": "The list of add-ons could not be read.", - "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons." + "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons.", + "factChecked": "Verificati sulle tue fonti prima di partire", + "factRemove": "Spegnili o rimuovili quando vuoi" }, "headless": "Gira in background e non ha una pagina propria.", - "listLabel": "App", + "listLabel": "Componenti installati", "loading": "Caricamento delle app", "manage": "Gestisci", "open": "Apri", @@ -56,10 +58,14 @@ "tools_one": "{{count}} strumento", "tools_other": "{{count}} strumenti", "version": "Versione {{version}}", - "working": "In attesa della conferma del proprietario.", + "working": "In attesa di conferma.", "noMatchTitle": "No add-on matches", - "noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.", - "clearSearch": "Clear search" + "noMatchBody": "Nessun componente installato corrisponde a \"{{query}}\". Prova altre parole.", + "clearSearch": "Clear search", + "loadError": { + "title": "I componenti aggiuntivi non sono disponibili ora.", + "paused": "Pagine e strumenti dei componenti sono in pausa finché non torna." + } }, "browse": { "confirmTitle": "Installare {{name}}?", @@ -69,7 +75,7 @@ "fromLibrary": "da {{library}}", "install": "Installa", "installedDetail": "Versione {{version}}, generazione {{generation}}", - "installedOther": "Installato {{version}} (altra versione)", + "installedOther": "Hai la {{version}}", "installedSame": "Installato {{version}}", "installedState": "Installato", "installing": "Installazione", @@ -85,7 +91,8 @@ "backendFailed": "The backend was not installed.", "alias": "Alias", "kindLabel": "Kind", - "installedOn": "{{name}} is installed and on." + "installedOn": "{{name}} is installed and on.", + "installedChip": "Installato" }, "catalog": { "allLibraries": "Tutte le librerie", @@ -115,8 +122,8 @@ "pin": "Pin {{name}}", "pinLabel": "Pin", "pinned": "Pinned", - "running": "Attivo", - "search": "Search add-ons", + "running": "In esecuzione", + "search": "Cerca tra i componenti installati", "status": { "failed": "failed", "starting": "starting" @@ -129,7 +136,7 @@ "denied": "Il server ha rifiutato la richiesta. Potresti non avere il permesso, oppure il componente è cambiato. L'elenco è stato aggiornato.", "invalid": "Il server non ha potuto accettare la richiesta.", "network": "Impossibile raggiungere il server. Controlla la connessione.", - "unavailable": "Il proprietario del componente non è disponibile al momento. Riprova tra poco.", + "unavailable": "I componenti non sono disponibili al momento. Riprova tra poco.", "unknownOutcome": "Non è stato possibile confermare il risultato. Controlla lo stato prima di riprovare." }, "installed": { @@ -174,14 +181,14 @@ }, "hints": { "id": "Lettere minuscole, cifre e trattini. Non può cambiare in seguito.", - "reference": "Il nome di una credenziale configurata dal proprietario. Non incollare mai un token o una password qui.", + "reference": "Il nome di una credenziale configurata da un amministratore su questo server. Non incollare mai qui un token o una password.", "url": "Un indirizzo https senza credenziali né stringa di query.", "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.", "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.", "mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched." }, "lastRefresh": "Aggiornata {{when}}, {{count}} voci", - "lead": "Una libreria è un documento di catalogo che elenca componenti. Le credenziali non si digitano mai qui: una libreria privata indica una credenziale configurata dal proprietario.", + "lead": "Una libreria è un indice che elenca componenti, backend o modelli. Le credenziali non si digitano mai qui: una libreria privata di componenti indica una credenziale configurata da un amministratore su questo server.", "listLabel": "Librerie", "loading": "Caricamento delle librerie", "notRefreshed": "Non ancora aggiornata", @@ -251,10 +258,11 @@ "hasUI": "Ha delle pagine", "headless": "Senza interfaccia, nessuna pagina", "health": "Stato di salute", + "installedAt": "Installato", "host": "API dell'host", "interface": "Interfaccia", "logs": "Log", - "logsNone": "L'output dei processi dei componenti non viene raccolto in questa versione. Stato di salute e tentativi di riavvio qui sopra sono ciò che il proprietario può riportare.", + "logsNone": "L’output dei processi dei componenti non viene raccolto in questa versione. Stato di salute e tentativi di riavvio qui sopra sono tutto ciò che LocalAI può riportare.", "observed": "Stato osservato", "protocol": "Protocollo", "restarts": "Tentativi di riavvio", @@ -338,14 +346,15 @@ "state": { "failed": "Errore", "label": "Stato: {{state}}", - "running": "Attivo", + "running": "In esecuzione", "starting": "In avvio", "stopped": "Disattivato", - "update-pending": "Aggiornamento in attesa" + "update-pending": "Aggiornamento in attesa", + "updateTo": "Aggiornamento {{version}}" }, - "supporting": "Le app che il proprietario esegue per te e le librerie da cui provengono.", + "supporting": "Software che installi per dare a LocalAI nuove pagine e strumenti per le chat. Ognuno gira per conto suo e chiede solo ciò che dichiara.", "tabs": { - "apps": "I miei componenti", + "apps": "Installati", "browse": "Trova componenti", "label": "Sezioni dei componenti aggiuntivi", "libraries": "Fonti" @@ -516,8 +525,8 @@ "unknownOutcome": "L'esito non è noto. Rileggi lo stato: la stessa azione non viene inviata due volte.", "auth": "Accedi di nuovo.", "denied": "Solo gli amministratori possono farlo, dalla propria sessione del browser.", - "network": "Il proprietario non è raggiungibile.", - "unavailable": "Il proprietario non è disponibile." + "network": "Il servizio dei componenti non è raggiungibile.", + "unavailable": "Il servizio dei componenti non è disponibile." } }, "updates": { @@ -591,18 +600,30 @@ "auth": "Accedi di nuovo per continuare.", "denied": "Solo un amministratore può farlo dalla propria sessione del browser.", "network": "La rete non è raggiungibile.", - "unavailable": "Il proprietario del componente non è disponibile. Riprova." + "unavailable": "Il servizio dei componenti non è disponibile. Riprova." + }, + "upgradeNamed": "Aggiorna {{name}}", + "why": { + "role": "Un aggiornamento è disponibile. Solo un amministratore può installarlo.", + "needs-check": "Viene segnalato un aggiornamento, ma i dettagli non sono ancora confermati. Controlla di nuovo per confermarlo.", + "check-failed": "L'ultimo controllo è fallito, quindi l'aggiornamento non può essere confermato. Controlla di nuovo.", + "incompatible": "Esiste un aggiornamento, ma non può funzionare qui:", + "integrity": "La libreria elenca questa versione con contenuto diverso. Non è proposta come aggiornamento.", + "recovery": "I dati di questo componente aggiuntivo richiedono prima il ripristino. Ripristina un backup nei Dettagli, poi aggiorna.", + "current": "L'ultimo controllo non ha trovato aggiornamenti. L'avviso potrebbe essere obsoleto. Controlla di nuovo.", + "unpinned": "La libreria segnala un aggiornamento ma non indica quale build installare, quindi non è possibile aggiornarlo da qui. Ricontrolla più tardi." }, - "reviewButton": "Aggiornamenti ({{count}})", - "reviewTitle": "Rivedi gli aggiornamenti dei componenti", - "reviewDescription": "Niente viene aggiornato finché non scegli qui. Ogni aggiornamento fa prima un backup dei dati del componente.", "checkAll": "Cerca aggiornamenti", "checkingAll": "Controllo...", "checkAllDone": "Controllati {{count}} componenti.", "checkAllFailed": "{{count}} non controllabili.", - "upgradeNamed": "Aggiorna {{name}}", - "toastDone": "{{name}} aggiornato alla {{version}}.", - "toastFailed": "{{name}} non aggiornato: {{message}}" + "checkResult": { + "done": "Controllato.", + "failed": "Il controllo non è stato completato.", + "justNow": "Controllato adesso." + }, + "waitingService": "Il servizio dei componenti aggiuntivi si sta avviando. LocalAI chiede di nuovo dell'aggiornamento tra {{seconds}} s.", + "checkAgain": "Controlla ora" }, "install": { "stepsLabel": "Install steps", @@ -639,17 +660,19 @@ "addsTools_other": "Adds {{count}} tools", "openNamed": "Open {{name}}", "removeTitle": "Remove {{name}}?", - "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted." + "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted.", + "removeNamed": "Rimuovi {{name}}…" }, "preview": { "screenshots": "Screenshots", - "adds": "What it adds", + "adds": "Cosa può fare", "page": "A page of its own in LocalAI", "addsNothing": "Nothing you can see. It works in the background.", "version": "Version", "id": "Identifier", "sourceUnknown": "Not known", - "moreDetails": "Health, trust and updates" + "moreDetails": "Health, trust and updates", + "updateTitle": "Aggiorna alla {{version}}" }, "flow": { "installAndTurnOn": "Installa e attiva", @@ -663,6 +686,66 @@ "install": "Download, verify and install", "turn-on": "Turn on" }, - "seeInList": "See it in My add-ons" + "seeInList": "Vedilo in Installati", + "install": "Installa", + "installNamed": "Installa {{name}}", + "shadowWarning": "Questo componente sostituisce lo strumento integrato {{tool}}. Un altro componente ({{name}}) lo fa già. Solo uno può essere attivo; scegli quale in Impostazioni > Strumenti. Finché non scegli, lo fornisce quello installato per primo.", + "skillShadow": "Questo componente sostituisce la skill integrata {{skill}}.", + "skillShadowWarning": "Questo componente sostituisce la skill integrata {{skill}}. Un altro componente ({{name}}) lo fa già. Solo uno può essere attivo; scegli quale in Impostazioni > Strumenti. Finché non scegli, lo fornisce quello installato per primo." + }, + "family": { + "pages": "Cerchi le pagine create da te o da un agente? Sono in Pagine." + }, + "filter": { + "label": "Mostra", + "all": "Tutti", + "running": "In esecuzione", + "off": "Spenti", + "showAll": "Mostra tutti", + "none": { + "all": "Nessun componente da mostrare.", + "running": "Nessun componente è in esecuzione.", + "off": "Tutti i componenti sono in esecuzione." + } + }, + "attention": { + "label": "Richiede attenzione", + "failedTitle": "{{name}} si è fermato.", + "failedRestarts_one": "Non è riuscito ad avviarsi {{count}} volta di seguito.", + "failedRestarts_other": "Non è riuscito ad avviarsi {{count}} volte di seguito.", + "failedOnce": "Non si è avviato.", + "toolsOff_one": "Il suo {{count}} strumento per le chat è spento finché non riparte.", + "toolsOff_other": "I suoi {{count}} strumenti per le chat sono spenti finché non riparte.", + "pageOff": "La sua pagina non è disponibile finché non riparte.", + "updateTitle": "{{name}} {{version}} è disponibile.", + "updateBody": "Hai la {{current}}. Vedi cosa cambia e confermi prima che venga sostituito qualcosa." + }, + "meta": { + "builtin": "Integrato", + "builtinLong": "Incluso in LocalAI", + "signed": "Firmato · {{library}}", + "checked": "Verificato · {{library}}", + "devBuild": "Build di sviluppo", + "library": "libreria {{library}}" + }, + "skills": { + "title_one": "{{count}} skill della chat", + "title_other": "{{count}} skill della chat", + "replaces": "sostituisce la skill integrata", + "serves": "usata nelle chat", + "notServing": "non usata ora", + "serveIn": "Usa per {{skill}} nelle chat", + "saveFailed": "Impossibile salvare la scelta." + }, + "settingsTab": { + "overview": "Panoramica", + "settings": "Impostazioni", + "tabsLabel": "Dettagli di {{name}}", + "off": "Attiva {{name}} per cambiarne le impostazioni.", + "pageLink": "Impostazioni", + "controls": { + "loadFailed": "Non è stato possibile leggere le impostazioni", + "saveFailed": "Non è stato possibile salvare le impostazioni: {{message}}" + } } } diff --git a/core/http/react-ui/public/locales/it/admin.json b/core/http/react-ui/public/locales/it/admin.json index 2fcb3dc39..92735a42c 100644 --- a/core/http/react-ui/public/locales/it/admin.json +++ b/core/http/react-ui/public/locales/it/admin.json @@ -107,6 +107,72 @@ "assistant": "LocalAI Assistant", "distributed": "Distribuito", "responses": "Risposte" + }, + "loadFailedTitle": "Impossibile caricare le impostazioni.", + "egress": { + "title": "Rete dei comandi", + "intro": "I comandi nel Sandbox raggiungono la rete solo tramite un proxy che chiede a LocalAI di ogni connessione. Qui sotto imposti il limite, il preset di una nuova chat, le porte, i registri aggiuntivi e la durata del registro; ogni persona sceglie il preset di ogni chat nel suo menu Strumenti, fino al limite, e consente un host bloccato da una scheda.", + "serves": "I comandi girano isolati nell’add-on {{name}}", + "servesHost": "I comandi girano su questa macchina tramite {{name}}", + "servesNone": "Nessun Sandbox esegue ancora comandi", + "hostNote": "Il backend host esegue i comandi su questa macchina con la sua rete: queste impostazioni non si applicano.", + "counts": "Ultime 24 ore: {{allowed}} consentite, {{blocked}} bloccate.", + "private": "Consenti alle regole di un amministratore di raggiungere reti private", + "privateWarning": "Un host consentito da un amministratore può allora raggiungere indirizzi della tua rete. Un preset da solo non lo fa mai; gli indirizzi dei metadati cloud e del server restano rifiutati.", + "typePhrase": "Per attivarlo, scrivi: {{phrase}}", + "registries": "Registri di pacchetti ({{count}})", + "showAudit": "Mostra il registro", + "hideAudit": "Nascondi il registro", + "loadFailed": "Impossibile leggere le impostazioni di rete del Sandbox", + "audit": { + "verdict": "Esito", + "host": "Host", + "user": "Utente", + "at": "Quando", + "reason": "Motivo", + "bytes": "Byte inviati / ricevuti", + "refresh": "Aggiorna", + "empty": "Nessuna richiesta corrisponde.", + "loadFailed": "Impossibile leggere il registro", + "verdicts": { + "any": "Qualsiasi esito", + "allow": "Consentita", + "block": "Bloccata", + "done": "Conclusa" + } + } + }, + "addons": { + "title": "Add-on", + "hint": "Le impostazioni portate dagli add-on", + "intro": "Ogni add-on tiene le sue impostazioni nella propria pagina, nella scheda Impostazioni dei suoi dettagli. Aprine uno per cambiarle.", + "off": "Disattivato. Attivalo per cambiarne le impostazioni.", + "open": "Apri le impostazioni", + "openNamed": "Apri le impostazioni di {{name}}", + "count_one": "{{count}} add-on con impostazioni", + "count_other": "{{count}} add-on con impostazioni" + }, + "hosts": { + "add": "Aggiungi", + "listLabel": "Host sempre consentiti", + "remove": "Rimuovi {{host}}", + "empty": "Nessun host. Scrivi un nome host, oppure host:porta per fissare la porta.", + "hint": "Solo nomi esatti. Aggiungi :porta per consentire una sola porta; altrimenti valgono le porte di rete della Sandbox.", + "count": "{{n}}/{{max}}", + "errors": { + "empty": "Scrivi un nome host.", + "wildcard": "\"{{value}}\": scrivi un nome host esatto, non un pattern.", + "shape": "\"{{value}}\" non è un nome host. Scrivi solo l'host, senza schema né percorso.", + "port": "\"{{value}}\": la porta deve essere da 1 a 65535, e 22 e 25 sono sempre rifiutate.", + "private": "\"{{value}}\" è un indirizzo privato o locale. Attiva prima l'interruttore della rete privata.", + "numeric": "\"{{value}}\": scrivi un indirizzo IP come quattro numeri decimali.", + "duplicate": "\"{{value}}\" è già nell'elenco.", + "full": "L'elenco contiene al massimo {{max}} host." + } + }, + "network": { + "title": "Rete dei comandi", + "where": "{{name}} tiene nella sua pagina le impostazioni di rete dei comandi che esegue." } }, "backends": { @@ -253,5 +319,75 @@ "subtitle": "Modelli caricati su questa istanza di LocalAI e la capacità dell'host che utilizzano.", "scaleOut": "Aggiungi macchine", "hideScaleOut": "Nascondi configurazione" + }, + "settingsTools": { + "intro": "Le chat hanno alcuni strumenti integrati che un componente può sostituire. Quando il componente è installato, in esecuzione e acceso per una chat, la chat usa lo strumento del componente, con lo stesso nome.", + "empty": "Su questo server non ci sono strumenti integrati da gestire.", + "loadFailed": "Impossibile leggere gli strumenti integrati", + "switch": "Attivo", + "byBuiltin": "Integrato", + "byAddon": "Componente: {{name}}", + "byNone": "Spento", + "unavailable": "Questo server non può eseguirlo: {{reason}}.", + "installSandbox": "Installa il Sandbox per eseguire i comandi isolati", + "replaceExplain": "Questi componenti sostituiscono {{tool}}: quello scelto lo fornisce in ogni chat in cui è acceso e in esecuzione, e quello integrato viene nascosto. Solo uno può essere attivo. Finché non scegli, lo fornisce quello installato per primo.", + "defaultInstalledFirst": "Predefinito: installato per primo", + "installedOn": "Installato {{when}}", + "choose_one": "{{count}} componente sostituisce {{tool}}: scegli quale usare.", + "choose_other": "{{count}} componenti sostituiscono {{tool}}: scegli quale usare.", + "labels": { + "bash": "Comandi (bash)", + "web_search": "Ricerca web", + "web_fetch": "Leggi pagine web", + "list_agents": "Agenti salvati" + }, + "descriptions": { + "bash": "Esegue comandi e codice per le chat direttamente su questa macchina quando nessun Sandbox fornisce bash. Il componente Sandbox lo sostituisce con comandi isolati.", + "web_search": "Cerca sul web per le chat senza chiave (DuckDuckGo, poi Brave Search, poi Bing), oppure con la tua istanza SearXNG se è impostata.", + "web_fetch": "Legge per le chat una pagina o un file web pubblico dal suo indirizzo. Gli indirizzi della tua rete sono rifiutati se non li consenti.", + "list_agents": "Permette a una chat di elencare gli agenti salvati dell'utente e di avviarne uno come sotto-agente. Ogni chat lo attiva dal suo menu Strumenti." + }, + "bash": { + "switch": "Consenti alle chat di eseguire comandi su questa macchina", + "modeLegend": "Chi può eseguire comandi", + "ask": "Chiedi ogni volta.", + "askHint": "Solo le chat degli amministratori lo ricevono, e ogni comando attende il loro sì nella chat.", + "on": "Consenti a tutti.", + "onHint": "Le chat di tutti gli utenti eseguono comandi qui senza chiedere, anche le esecuzioni pianificate e i sotto-agenti.", + "default": "Non impostato: vale il predefinito ({{mode}}).", + "warning": "I comandi vengono eseguiti come l'utente di LocalAI, senza isolamento e con la rete di questa macchina: possono leggere ciò che LocalAI può leggere e raggiungere internet, la tua rete e l'API di LocalAI. Ogni comando viene registrato. Installa il Sandbox per isolarli." + }, + "modes": { + "off": "spento", + "ask": "chiedi ogni volta", + "on": "consentito a tutti" + }, + "skillsTitle": "Skill sostituite dai componenti", + "skillsIntro": "Un componente può sostituire una skill integrata con la propria, scritta per i suoi strumenti. Se più componenti sostituiscono la stessa, scegli quale usare.", + "skillLabel": "Skill {{skill}}", + "skillNotRunning": "Il componente scelto non è in esecuzione: si usa la skill integrata.", + "toolNotRunning": "Il componente che sostituisce questo strumento non è in esecuzione: si usa lo strumento integrato.", + "stopped": "fermo", + "webTest": { + "intro": "Esegue una ricerca con ogni provider delle impostazioni salvate e mostra cosa ha fatto ciascuno. Applica prima le modifiche.", + "queryLabel": "Ricerca", + "run": "Prova la ricerca", + "running": "Prova in corso…", + "answered": "Le chat ricevono i risultati di {{host}}.", + "noneAnswered": "Nessun provider ha risposto.", + "failed": "Impossibile eseguire la prova: {{error}}", + "results_one": "{{count}} risultato", + "results_other": "{{count}} risultati", + "outcomes": { + "ok": "Funziona", + "no_results": "Nessun risultato", + "challenged": "Controllo anti-bot", + "blocked": "Bloccato", + "timeout": "Troppo lento", + "error": "Errore", + "skipped": "Saltato" + } + }, + "addonSettings": "Impostazioni di {{name}}" } } diff --git a/core/http/react-ui/public/locales/it/agents.json b/core/http/react-ui/public/locales/it/agents.json index e42120109..f66a83e41 100644 --- a/core/http/react-ui/public/locales/it/agents.json +++ b/core/http/react-ui/public/locales/it/agents.json @@ -2,15 +2,13 @@ "title": "Agenti", "subtitle": "Gestisci agenti AI autonomi", "actions": { - "agentHub": "Agent Hub", "import": "Importa", "createAgent": "Crea agente", "edit": "Modifica", "chat": "Chat", "export": "Esporta", "delete": "Elimina", - "pause": "Pausa", - "resume": "Riprendi" + "newAgent": "Nuovo agente" }, "table": { "name": "Nome", @@ -27,13 +25,9 @@ "empty": { "noConfigured": "Nessun agente configurato", "noConfiguredText": "Crea un agente per iniziare con i flussi di lavoro AI autonomi.", - "browseHub": "Non sai da dove iniziare? Sfoglia l'<1>Agent Hub per trovare configurazioni di agenti pronte da importare.", "noMatching": "Nessun agente corrispondente", - "noMatchingText": "Nessun agente corrisponde a \"{{query}}\"" - }, - "sections": { - "yourAgents": "I tuoi agenti", - "otherUsersAgents": "Agenti di altri utenti" + "noMatchingText": "Nessun agente corrisponde a \"{{query}}\"", + "clearSearch": "Cancella ricerca" }, "deleteDialog": { "title": "Elimina agente", @@ -44,12 +38,411 @@ "loadFailed": "Caricamento agenti fallito: {{message}}", "deleted": "Agente \"{{name}}\" eliminato", "deleteFailed": "Eliminazione agente fallita: {{message}}", - "paused": "Agente \"{{name}}\" in pausa", - "resumed": "Agente \"{{name}}\" ripreso", - "pauseFailed": "Pausa agente fallita: {{message}}", - "resumeFailed": "Ripresa agente fallita: {{message}}", "exported": "Agente \"{{name}}\" esportato", - "exportFailed": "Esportazione agente fallita: {{message}}", - "parseFailed": "Analisi del file agente fallita: {{message}}" + "parseFailed": "Analisi del file agente fallita: {{message}}", + "notAnAgent": "The file is not an exported agent", + "deletedUndo": "“{{name}}” eliminato.", + "undoLate": "“{{name}}” era già stato eliminato.", + "restored": "“{{name}}” ripristinato." + }, + "facts": { + "addons": "Add-ons", + "connector": "MCP connector" + }, + "agent": { + "triggers": "Triggers", + "newTrigger": "New trigger", + "noTriggers": "No trigger starts this agent.", + "untitled": "Untitled chat", + "triggerOn": "On", + "triggerOff": "Off", + "origin": { + "trigger": "Started by a trigger", + "connector": "Started by a connector" + }, + "noRunsText": "Press Chat to start a conversation with this agent. Chats that ran with it, and the ones its triggers start, are listed here.", + "missingText": "It may have been deleted, or it belongs to another user.", + "backToList": "Torna agli agenti" + }, + "record": { + "where": "A run is a chat that ran with the agent, including the ones its triggers and connectors started.", + "none": "No runs yet" + }, + "create": { + "sections": { + "BasicInfo": "Basic info", + "ModelSettings": "Model", + "PromptsGoals": "Instructions", + "MemorySettings": "Memory", + "Skills": "Skills", + "Addons": "Add-ons", + "MCP": "MCP servers", + "export": "Export" + }, + "name": "Name", + "description": "Description", + "descriptionHelp": "One line that says what the agent is for. It shows in the list.", + "model": "Model", + "modelPlaceholder": "Any model (the chat picks one)", + "modelHelp": "Leave it empty and a chat with this agent uses the model you pick in the chat. Set one to fix it.", + "anyModel": "Any model", + "instructions": "Instructions", + "instructionsHelp": "What the agent always follows. Every chat with it starts from this text.", + "collections": "Collections", + "collectionsHint": "Collection name", + "collectionsHelp": "The memory collections a chat with this agent may search. The list is exact: an empty list grants none.", + "skillsHelp": "The skills a chat with this agent may read. The list is exact: an empty list grants none.", + "noSkills": "No skills yet.", + "manageSkills": "Write or manage skills", + "addonsHelp": "The add-ons whose tools a chat with this agent may use.", + "noAddons": "No add-on offers tools to a model.", + "mcpHelp": "The MCP servers of the fixed model and the MCP connectors a chat with this agent may use.", + "mcpServers": "MCP servers", + "mcpHint": "Server name", + "mcpConnectors": "MCP connectors", + "noConnectors": "No MCP connectors yet.", + "manageConnectors": "Manage MCP connectors", + "connectorOff": "off", + "gone": "not found", + "countCollections_one": "{{count}} collection", + "countCollections_other": "{{count}} collections", + "countAddons_one": "{{count}} add-on", + "countAddons_other": "{{count}} add-ons", + "needsName": "Needs a name", + "loadFailed": "Failed to load the agent: {{message}}", + "saveFailed": "Save failed: {{message}}", + "created": "Agent \"{{name}}\" created", + "updated": "Agent \"{{name}}\" updated", + "exportSummary": "Download as JSON", + "exportHelp": "Download the definition of this agent as a JSON file. It holds no id, no dates and no secrets. Import it on another instance from the agents list.", + "exportButton": "Export agent", + "importNew": "Imported from an exported agent. Read the form, then save.", + "importLegacy": "Imported from the export of an older agent. Name, description, model, instructions and skills were read; the rest has no counterpart here.", + "importDropped": "Not imported: {{keys}}.", + "subEdit": "A chat that is running keeps the version it started with. Its next turn uses the new one.", + "configNote": "The definition this form will save.", + "previewNew": "The agent this form will save.", + "previewEdit": "The agent this form will save, and how it differs from the saved one.", + "default": "Default", + "network": "Rete dei comandi nelle esecuzioni non presidiate", + "networkHelp": "Quando un trigger o un connettore esegue questo agente, i suoi comandi nel Sandbox raggiungono questa parte della rete, fino al limite fissato da un amministratore. Un’esecuzione non chiede mai: un host al di fuori viene bloccato. Anche una chat che avvii con questo agente parte così.", + "networkPresets": { + "off": "Disattivata", + "registries": "Registri di pacchetti", + "all": "Tutti i siti pubblici" + } + }, + "run": { + "workingLine": "Working" + }, + "jobs": { + "title": "Triggers", + "newTask": "New trigger", + "createTask": "Create trigger", + "loading": "Loading triggers", + "loadFailed": "Failed to load: {{message}}", + "actionFailed": "The action failed: {{message}}", + "summary": { + "stopped": "{{count}} skipped", + "lastFailed_one": "The last run of {{names}} failed.", + "lastFailed_other": "The last run failed for {{names}}." + }, + "tasks": { + "title": "Triggers, {{count}}", + "caption": "Triggers", + "task": "Trigger", + "noSchedule": "No schedule", + "noAgent": "No agent", + "fileKind": "When a file arrives", + "open": "Open", + "started": "Trigger \"{{name}}\" started", + "toggleLabel": "Run {{name}} by itself", + "toggleFailed": "Could not change the trigger: {{message}}", + "deleted": "Trigger deleted", + "noneYet": "You have no triggers yet.", + "noModel": "No model", + "next": "Prossima" + }, + "history": { + "filterTask": "Trigger", + "allTasks": "All triggers", + "task": "Trigger", + "unknownTask": "Unknown trigger", + "cancelledLine": "Skipped", + "doneLine": "Finished. Open the chat for the answer.", + "doneFile": "Finished for {{file}}. Open the chat for the answer.", + "pendingLine": "Waiting to start", + "emptyText": "A run starts when you press Run now, when a schedule fires, or when a file arrives in a watched folder.", + "openRun": "Open the chat", + "reason": "Reason", + "file": "Started by the file {{file}}." + }, + "status": { + "done": "Done", + "failed": "Failed", + "running": "Working", + "queued": "Waiting", + "skipped": "Skipped" + }, + "empty": { + "title": "No triggers yet", + "text": "A trigger starts a chat for you by itself: at a time, or when a file arrives in a folder. Each run is an ordinary chat that you can open.", + "step1": "Write what the chat should do.", + "step2": "Pick an agent, or a model, to run it.", + "step3": "Choose when it runs, or press Run now." + }, + "task": { + "back": "Triggers", + "missingTitle": "Trigger not found", + "agent": "Agent", + "model": "Model", + "schedule": "Schedule", + "cap": "Most runs a day", + "watches": "Watches the folder {{folder}}", + "noRuns": "This trigger has not run yet.", + "runMeta": "took {{time}}", + "prompt": "What to do" + }, + "edit": { + "titleNew": "New trigger", + "subNew": "Name it, write what to do and choose when it runs.", + "subEdit": "Changes apply from the next run.", + "save": "Save trigger", + "create": "Create trigger", + "nameRequired": "A trigger needs a name", + "promptRequired": "Write what the trigger should do", + "created": "Trigger created", + "updated": "Trigger updated", + "sections": { + "basics": "Basics", + "prompt": "What to do", + "schedule": "When", + "limits": "Limits" + }, + "summary": { + "basics": "{{name}}, {{model}}", + "basicsNoModel": "{{name}}, no agent or model yet", + "promptEmpty": "Nothing to do yet", + "folderEmpty": "Needs a folder", + "cap": "Up to {{count}} runs a day", + "capDefault": "Default limit" + }, + "name": "Name", + "namePlaceholder": "daily-digest", + "noAgent": "No agent", + "model": "Model", + "enabled": "On", + "enabledHint": "A trigger that is off keeps its schedule but does not run.", + "prompt": "What to do", + "promptPlaceholder": "Summarise what is new in my Reports folder.", + "promptHint": "This text starts the chat each time the trigger fires.", + "capHint": "The most chats this trigger may start in one day. Empty uses the default.", + "previewEdit": "What the next run would send, and what differs from the saved trigger.", + "previewNew": "What the first run would send.", + "promptAsSent": "The text a run starts with", + "noChanges": "Nothing differs from the saved trigger.", + "loadingTask": "Loading trigger", + "changed_one": "{{count}} change", + "changed_other": "{{count}} changes" + }, + "subtitle": "Avvia un agente secondo un orario o quando arriva un file. Ogni esecuzione è una chat che puoi aprire." + }, + "views": { + "label": "Area agenti", + "agents": "Agenti" + }, + "now": { + "failedTitle": "{{name}} non è riuscito.", + "workingTitle": "{{name}} sta lavorando.", + "openRun": "Apri l'esecuzione" + }, + "load": { + "agents": "Impossibile caricare i tuoi agenti.", + "agentsStale": "Impossibile aggiornare l'elenco. Mostra lo stato precedente.", + "agent": "Impossibile caricare questo agente.", + "agentStale": "Impossibile aggiornare questa pagina. Mostra lo stato precedente.", + "agentForm": "Impossibile caricare questo agente per la modifica.", + "triggers": "Impossibile caricare i tuoi trigger.", + "triggersStale": "Impossibile aggiornare i trigger. Mostrano lo stato precedente.", + "trigger": "Impossibile caricare questo trigger.", + "triggerStale": "Impossibile aggiornare questo trigger. Mostra lo stato precedente.", + "tasks": "Impossibile caricare le attività.", + "tasksStale": "Impossibile aggiornare le attività. Mostrano lo stato precedente." + }, + "meta": { + "anyModel": "Qualsiasi modello", + "addons_one": "{{count}} add-on", + "addons_other": "{{count}} add-on" + }, + "chips": { + "lastFailed": "Ultima esecuzione non riuscita" + }, + "tasks": { + "title": "Attività degli agenti", + "allUsers": "Tutti gli utenti", + "new": "Nuova attività", + "newTitle": "Nuova attività", + "editTitle": "Modifica attività", + "name": "Nome", + "description": "Descrizione", + "runner": "Eseguita da", + "byModel": "Un modello", + "byAgent": "Uno dei tuoi agenti", + "model": "Modello", + "agent": "Agente", + "pick": "Scegli…", + "prompt": "Prompt", + "promptHint": "Scrivi un parametro come {{example}}; ogni esecuzione ne chiede il valore.", + "webhooksKept_one": "Questa attività ha {{count}} webhook. Resta com'è; i webhook si cambiano tramite API.", + "webhooksKept_other": "Questa attività ha {{count}} webhook. Restano come sono; i webhook si cambiano tramite API.", + "save": "Salva attività", + "cancel": "Annulla", + "saved": "Attività «{{name}}» salvata", + "runTitle": "Esegui {{name}}", + "runNow": "Esegui ora", + "started": "Attività «{{name}}» avviata", + "tasks": "Attività", + "off": "Spenta", + "run": "Esegui", + "edit": "Modifica", + "delete": "Elimina", + "deleted": "Attività «{{name}}» eliminata", + "task": "Attività", + "cancelJob": "Annulla", + "rerun": "Esegui di nuovo", + "rerunStarted": "Avviata di nuovo", + "stillRunning": "Ancora in corso.", + "noResult": "Nessun risultato.", + "traces_one": "{{count}} passaggio", + "traces_other": "{{count}} passaggi", + "webhookSent": "Il webhook è stato chiamato.", + "webhookFailed": "Il webhook non è riuscito: {{message}}", + "actionFailed": "Non ha funzionato: {{message}}", + "state": { + "pending": "In attesa", + "running": "In corso", + "completed": "Completata", + "failed": "Fallita", + "cancelled": "Annullata" + }, + "lede": "Prompt che un modello o uno dei tuoi agenti esegue in background. Avviali da qui o da uno script tramite l'API.", + "searchLabel": "Cerca attività", + "filter": { + "label": "Mostra attività", + "all": "Tutte", + "on": "Attive", + "off": "Spente" + }, + "noMatch": "Nessuna attività corrisponde.", + "on": "Attiva", + "offLine": "Spenta. Non si avvia finché non la attivi.", + "onDemand": "Si avvia quando la lanci", + "asksFor": "Chiede {{list}}", + "webhooks_one": "{{count}} webhook", + "webhooks_other": "{{count}} webhook", + "runsCount_one": "{{count}} esecuzione", + "runsCount_other": "{{count}} esecuzioni", + "runsOf_one": "{{count}} esecuzione di {{name}}", + "runsOf_other": "{{count}} esecuzioni di {{name}}", + "toggleLabel": "{{name}} può avviarsi", + "turnedOn": "«{{name}}» è attiva", + "turnedOff": "«{{name}}» è spenta", + "moreFor": "Altre azioni per {{name}}", + "open": "Apri", + "openTask": "Apri {{name}}", + "runFor": "Esegui {{name}}", + "rerunFor": "Esegui di nuovo {{name}}", + "jobMoreFor": "Altre azioni per questa esecuzione di {{name}}", + "detailsFor": "Dettagli dell'esecuzione di {{name}}", + "openChat": "Apri la chat", + "deleteJob": "Elimina esecuzione", + "jobDeleted": "Esecuzione eliminata", + "cancelled": "Esecuzione fermata", + "confirmJob": { + "title": "Eliminare questa esecuzione?", + "message": "La risposta e i passaggi vengono eliminati. Non si può annullare." + }, + "line": { + "pending": "In attesa di partire", + "running": "Al lavoro", + "cancelled": "Fermata prima della fine" + }, + "startedBy": { + "manual": "Avviata da qui", + "api": "Avviata tramite API", + "cron": "Avviata da una pianificazione" + }, + "output": { + "answer": "Risposta", + "error": "Errore", + "values": "Valori" + }, + "traceKind": { + "reasoning": "Ragionamento", + "tool_call": "Strumento", + "tool_result": "Risultato dello strumento", + "status": "Stato" + }, + "empty": { + "title": "Ancora nessuna attività", + "body": "Un'attività è un prompt che scrivi una volta ed esegui tante volte. Scrivi {{example}} dove un valore cambia, e ogni esecuzione te lo chiede.", + "fact1": "La esegue un modello o uno dei tuoi agenti", + "fact2": "Avviala da qui o da uno script tramite l'API", + "fact3": "Ogni esecuzione conserva la risposta e i passaggi" + }, + "sheet": { + "optional": "(facoltativo)", + "newDesc": "Scrivi il prompt una volta, poi eseguilo quando ti serve.", + "editDesc": "Le modifiche valgono dalla prossima esecuzione.", + "about": "Informazioni", + "nameHelp": "Gli script usano questo nome per eseguire l'attività tramite l'API.", + "descHelp": "Una riga che dice a cosa serve l'attività.", + "who": "Chi la esegue", + "modelHelp": "Il modello risponde al prompt senza strumenti.", + "agentHelp": "L'agente risponde con le sue istruzioni e i suoi strumenti.", + "what": "Cosa fa", + "promptPlaceholder": "Per esempio: Riassumi il ticket {{example}} in tre righe.", + "inputs": "Ogni esecuzione chiede:", + "noInputs": "Questo prompt non chiede nulla. Ogni esecuzione lo invia così com'è.", + "on": "Attiva", + "onHelp": "Un'attività spenta conserva le esecuzioni ma non può avviarsi.", + "runDesc": "Inserisci i valori per questa esecuzione.", + "runNone": "Questa attività non chiede nulla. Esegue il prompt così com'è.", + "preview": "Cosa riceve l'attività" + }, + "errors": { + "name": "Dai un nome all'attività.", + "prompt": "Scrivi cosa deve fare l'attività.", + "model": "Scegli un modello.", + "agent": "Scegli un agente." + }, + "pane": { + "label": "Dettagli dell'attività", + "overview": "Panoramica", + "runs": "Esecuzioni", + "settings": "Impostazioni", + "inputs": "Chiede", + "noInputs": "Niente", + "changed": "Modificata", + "api": "Eseguila tramite API", + "apiHint": "Invia i valori come oggetto JSON. La risposta contiene l'id dell'esecuzione, per leggerne il risultato.", + "noRuns": "Ancora nessuna esecuzione. Esegui l'attività e la risposta comparirà qui.", + "webhooks": "Webhook", + "noWebhooks": "Nessuno. Aggiungi webhook tramite API per sapere quando un'esecuzione finisce.", + "deleteTitle": "Elimina questa attività", + "deleteHelp": "Le sue esecuzioni restano fino alla fine del periodo di conservazione." + }, + "history": { + "title": "Cronologia", + "filterLabel": "Mostra esecuzioni", + "all": "Tutte", + "active": "In corso", + "completed": "Completate", + "failed": "Fallite", + "cancelled": "Annullate", + "allTasks": "Tutte le attività", + "emptyBody": "Esegui un'attività e la risposta comparirà qui.", + "more": "Mostra altre {{count}}" + } } } diff --git a/core/http/react-ui/public/locales/it/auth.json b/core/http/react-ui/public/locales/it/auth.json index 548553da3..fa2469615 100644 --- a/core/http/react-ui/public/locales/it/auth.json +++ b/core/http/react-ui/public/locales/it/auth.json @@ -47,12 +47,15 @@ "account": { "title": "Account", "subtitle": "Profilo, credenziali e chiavi API", + "subtitleLocal": "Chi sei, e come l'assistente e LocalAI si comportano per te", "unavailable": "Account non disponibile", "unavailableText": "L'autenticazione deve essere abilitata per gestire l'account.", "tabs": { "profile": "Profilo", "security": "Sicurezza", - "apiKeys": "Chiavi API" + "apiKeys": "Chiavi API", + "about": "Su di te e sull'assistente", + "appearance": "Aspetto" }, "profile": { "displayName": "Nome visualizzato", @@ -112,6 +115,9 @@ "pausedToast": "API key paused", "resumedToast": "API key resumed", "pauseFailed": "Failed to update API key: {{message}}" + }, + "usage": { + "loadFailedTitle": "Impossibile caricare il tuo utilizzo." } }, "notFound": { diff --git a/core/http/react-ui/public/locales/it/chat.json b/core/http/react-ui/public/locales/it/chat.json index a1e575af4..5e30f4e16 100644 --- a/core/http/react-ui/public/locales/it/chat.json +++ b/core/http/react-ui/public/locales/it/chat.json @@ -25,22 +25,15 @@ "yes": "Sì", "gpuLayers": "Layer GPU" }, - "context": { - "label": "Contesto: {{percent}}%", - "labelWithTokens": "Contesto: {{percent}}% ({{tokens}} token)" - }, "settings": { "title": "Impostazioni chat", "manageMode": "Modalità gestione", - "manageModeDesc": "Permetti a questa chat di installare modelli, cambiare backend e modificare configurazioni parlando con LocalAI.", - "systemPrompt": "Prompt di sistema", - "systemPromptPlaceholder": "Sei un assistente utile...", - "temperature": "Temperatura", - "topP": "Top P", - "topK": "Top K", - "contextSize": "Dimensione contesto", - "contextSizePlaceholder": "2048", - "clearHistory": "Cancella cronologia chat" + "serverKept": "Il server conserva questa chat. Modello e agente si scelgono all'inizio.", + "manageModeOn": "Questa chat ha gli strumenti di gestione di LocalAI. Usa /assistant per avviarne un'altra.", + "agent": "Agente", + "agentHelp": "Questa chat usa l'agente {{name}}: le sue istruzioni e solo gli strumenti che concede.", + "editAgent": "Modifica agente", + "deleteHelp": "Elimina questa chat e i suoi messaggi in modo definitivo." }, "empty": { "manageTitle": "Gestisci LocalAI chattando", @@ -61,7 +54,6 @@ "Brainstorming di idee" ], "recent": "Recenti", - "noMessages": "Nessun messaggio ancora", "hintEnter": "Invio per inviare", "hintShiftEnter": "Shift+Invio per andare a capo", "hintAttach": "Allega file" @@ -71,29 +63,17 @@ }, "actions": { "copy": "Copia", - "edit": "Modifica", - "editMessage": "Modifica messaggio", - "save": "Salva", - "cancel": "Annulla", "regenerate": "Rigenera", - "jumpToLatest": "Torna in fondo" + "jumpToLatest": "Torna in fondo", + "reuse": "Modifica e invia di nuovo" }, "streaming": { "transferring": "Trasferimento del modello...", "transferringTo": "Trasferimento del modello a {{node}}..." }, - "tokens": { - "perSec": "{{count}} tok/s", - "peak": "Picco: {{count}} tok/s", - "usage": "{{prompt}}p + {{completion}}c = {{total}}" - }, "input": { - "placeholder": "Messaggio...", - "attachFile": "Allega file", "stopGenerating": "Ferma generazione", - "canvasTitle": "Canvas — estrae blocchi di codice e media in un pannello laterale per anteprima, copia e download", - "canvasLabel": "Canvas", - "openCanvas": "Apri pannello canvas" + "placeholderQueue": "Il modello sta lavorando. Un messaggio inviato ora aspetta il suo prossimo passo." }, "deleteAllDialog": { "title": "Elimina tutte le chat", @@ -104,7 +84,7 @@ "selectModel": "Seleziona un modello", "copied": "Copiato negli appunti", "copyFailed": "Impossibile copiare negli appunti", - "pdfScannedAsImages": "{{name}} non ha un livello di testo (scansione). Allegate {{count}} di {{total}} pagine come immagini per un modello di visione." + "newChatForModel": "Una chat mantiene il suo modello. Avviata una nuova chat con {{model}}." }, "menu": { "trigger": "Chat", @@ -118,17 +98,492 @@ "deleteChat": "Elimina chat", "newChat": "Nuova chat", "clearAll": "Cancella tutto", - "deleteAllTitle": "Elimina tutte le conversazioni" + "deleteAllTitle": "Elimina tutte le conversazioni", + "untitled": "Nuova chat", + "origin": { + "trigger": "Avviata da un trigger", + "connector": "Da un connettore" + } }, "message": { - "you": "You" + "you": "You", + "loopRun": "Ciclo, esecuzione {{run}}" }, - "addons": { - "note": "An add-on that is off here does nothing in this chat.", - "label": "Add-ons", - "title": "Add-on tools for this chat", - "heading": "Add-on tools", - "selectAll": "Select all", - "deselectAll": "Deselect all" + "tools": { + "label": "Strumenti", + "title": "Strumenti che questa chat può usare", + "heading": "Strumenti di questa chat", + "draftNote": "Una nuova chat parte con queste impostazioni. Uno strumento spento non viene offerto al modello.", + "note": "Uno strumento spento qui non viene offerto al modello in questa chat.", + "count": "{{on}} di {{total}} attivi", + "selectAll": "Attiva tutti", + "disableAll": "Disattiva tutti", + "groupOn": "Tutti sì", + "groupOff": "Tutti no", + "groupOnLabel": "Attiva tutto in {{group}}", + "groupOffLabel": "Disattiva tutto in {{group}}", + "groupCount": "{{on}} di {{total}}", + "optIn": "Spento finché non lo accendi. Attiva tutti lo lascia spento.", + "empty": "In questa chat non c'è niente da attivare.", + "failed": "Salvataggio non riuscito. L'interruttore è tornato com'era.", + "unavailable": "Non disponibile in questa chat", + "toolCount_one": "{{count}} strumento", + "toolCount_other": "{{count}} strumenti" + }, + "skills": { + "label": "Skill", + "manage": "Gestisci le skill", + "title": "Skill che questa chat può usare", + "heading": "Skill di questa chat", + "count": "{{on}} di {{total}} attive", + "selectAll": "Seleziona tutte", + "disableAll": "Disattiva tutte", + "selectShown": "Seleziona quelle mostrate", + "disableShown": "Disattiva quelle mostrate", + "search": "Cerca skill", + "noMatch": "Nessuna skill corrisponde a “{{query}}”.", + "later": "Le skill che aggiungi dopo partono attive.", + "failed": "Salvataggio non riuscito. Nulla è cambiato.", + "source": { + "builtin": "Integrata", + "personal": "Personale", + "shared": "Condivisa", + "folder": "Area di lavoro", + "addon": "{{name}}" + }, + "replacedBy": "Sostituita da {{name}}", + "fallback_one": "{{skills}} integrata in uso: la skill del componente {{name}} è disattivata o non in esecuzione qui.", + "fallback_other": "{{skills}} integrate in uso: le skill del componente {{name}} sono disattivate o non in esecuzione qui." + }, + "agent": { + "label": "Agente", + "title": "Agente salvato per questa chat", + "heading": "Inizia con un agente", + "note": "L'agente dà alla chat le sue istruzioni ed esattamente gli strumenti che concede.", + "none": "Nessun agente", + "fixed": "L'agente di questa chat" + }, + "assistant": { + "chatTitle": "Assistente LocalAI" + }, + "slash": { + "delete": { + "label": "Elimina chat", + "desc": "Elimina questa conversazione" + }, + "assistant": { + "desc": "Avvia una chat che può gestire LocalAI" + }, + "settings": { + "desc": "Modalità focus, agente e info sul modello" + }, + "group": { + "context": "Contesto", + "skills": "Skill e agenti", + "help": "Aiuto" + }, + "models": { + "label": "Modelli", + "desc": "Apri l'elenco dei modelli" + }, + "skill": { + "label": "Usa una skill", + "desc": "Scegli una delle skill di questa chat" + }, + "skills": { + "label": "Gestisci le skill", + "desc": "Apri la pagina delle skill per scriverle e modificarle" + }, + "agent": { + "label": "Chiedi a un agente salvato", + "desc": "Affida un compito a uno dei tuoi agenti", + "enable": "Attiva gli agenti salvati per questa chat", + "enableDesc": "Poi scegli l'agente" + }, + "compact": { + "label": "Compatta il contesto", + "desc": "Riassumi i messaggi più vecchi per fare spazio al modello" + }, + "clear": { + "label": "Svuota il contesto", + "desc": "Riparti da zero in questa chat; i messaggi restano" + }, + "attach": { + "label": "Allega file", + "desc": "Scegli i file da inviare", + "clearDesc": "Togli i file in attesa di invio" + }, + "help": { + "label": "Tutti i comandi", + "desc": "Elenca ogni comando e cosa fa" + }, + "about": { + "label": "Info su questa chat", + "desc": "Modello, strumenti, skill e dimensione del contesto" + }, + "args": { + "skill": "Skill", + "agent": "Agenti salvati", + "model": "Modelli", + "attach": "File", + "loop": "Cicli", + "goal": "Obiettivo" + }, + "badge": { + "builtin": "integrata", + "addon": "add-on", + "shared": "condivisa", + "yours": "tua" + }, + "collides": "solo con /skill", + "errors": { + "skill": "Questa chat non ha una skill chiamata \"{{name}}\".", + "agent": "Non hai un agente salvato chiamato \"{{name}}\".", + "agentTask": "Scrivi il compito per {{name}} dopo il nome.", + "agentsOff": "Gli agenti salvati sono disattivati per questa chat. Attivali dal menu Strumenti." + }, + "loop": { + "label": "Ripeti un prompt", + "desc": "Esegui un prompt più volte in questa chat: /loop 5m controlla la build", + "listDesc": "Mostra i cicli di questa chat", + "stopDesc": "Ferma un ciclo, o tutti", + "stopAll": "Tutti i cicli", + "stopAllDesc": "Ferma tutti i cicli di questa chat" + }, + "goal": { + "label": "Obiettivo", + "desc": "Imposta un obiettivo su cui la chat continua a lavorare da sola", + "clearDesc": "Rimuovi l'obiettivo", + "resumeDesc": "Continua verso l'obiettivo" + } + }, + "shortcuts": { + "onMessage": "Su un messaggio: copia, modifica, rigenera", + "editLast": "Modifica il tuo ultimo messaggio" + }, + "files": { + "title": "File di questa chat", + "chip": "File", + "chipTitle": "File di questa chat: quelli che hai allegato e quelli che l'assistente ha creato o modificato", + "close": "Chiudi i file", + "emptyTitle": "Ancora nessun file in questa chat", + "emptyBody": "Trascina qui i file per allegarli al prossimo messaggio. Anche i file che l'assistente crea o modifica compaiono qui.", + "dropHint": "Trascina qui i file per allegarli al prossimo messaggio.", + "groups": { + "uploaded": "Forniti da te", + "created": "Creati e modificati", + "working": "File di lavoro" + }, + "turn": "turno {{n}}", + "media": "Nel contesto", + "mediaTitle": "Il modello ha ricevuto questo file come immagine o video insieme al tuo messaggio", + "changedTag": "Modificato", + "changedTitle": "L'assistente ha modificato questo file", + "download": "Scarica {{name}}", + "error": "Impossibile leggere i file di questa chat.", + "workingNote": "Script, dati e log che l'assistente ha creato lungo il percorso. Stanno nella cartella di lavoro di questa chat." + }, + "steps": { + "verb": { + "read": "Letto", + "list": "Elencato", + "write": "Salvato", + "create": "Creato", + "change": "Modificato", + "move": "Spostato", + "delete": "Eliminato", + "search": "Cercato", + "run": "Eseguito", + "fetch": "Scaricato", + "websearch": "Cercato sul web:", + "webfetch": "Letto", + "delegate": "Chiesto a un agente", + "used": "Usato" + }, + "worked": "Ha lavorato per {{duration}}", + "thoughtFor": "Ha pensato per {{duration}}", + "working": "Al lavoro", + "thinking": "Sta pensando", + "noMatches": "nessun risultato", + "noResults": "nessun risultato", + "emptyFolder": "cartella vuota", + "size": "{{size}}", + "seconds": "{{value}} s", + "minutes": "{{min}} min {{sec}} s", + "tool": "Strumento", + "input": "Input", + "showLess": "Mostra meno", + "running": "In corso", + "failed": "Non riuscito", + "count_one": "{{count}} passo", + "count_other": "{{count}} passi", + "files_one": "{{count}} file", + "files_other": "{{count}} file", + "items_one": "{{count}} elemento", + "items_other": "{{count}} elementi", + "matches_one": "{{count}} risultato", + "matches_other": "{{count}} risultati", + "results_one": "{{count}} risultato", + "results_other": "{{count}} risultati", + "lines_one": "{{count}} riga", + "lines_other": "{{count}} righe", + "showAll_one": "Mostra tutte le {{count}} righe", + "showAll_other": "Mostra tutte le {{count}} righe", + "details": "Mostra dettagli", + "openFile": "Apri {{path}} in File", + "savedFiles": "Salvati nei tuoi file:", + "fileOp": { + "changed": "modificato", + "deleted": "eliminato" + }, + "moreFiles": "e altri {{count}}", + "alsoSavedWorking_one": "Ha salvato anche {{count}} file di lavoro", + "alsoSavedWorking_other": "Ha salvato anche {{count}} file di lavoro", + "savedWorking_one": "Ha salvato {{count}} file di lavoro", + "savedWorking_other": "Ha salvato {{count}} file di lavoro", + "workingTitle": "File di lavoro (uno script, un log o una bozza): apri {{path}}" + }, + "error": { + "title": "La risposta non è riuscita", + "kept": "Quanto scritto finora resta qui sopra.", + "retry": "Riprova", + "detail": "Dettaglio tecnico", + "reason": { + "unknown": "L'agente si è fermato per un errore. Riprova per eseguire di nuovo il turno.", + "model_backend_missing": "Il modello \"{{model}}\" non si è potuto avviare: il suo backend non è installato.", + "model_backend_missing_anon": "Il modello non si è potuto avviare: il suo backend non è installato.", + "model_cooldown": "Il modello \"{{model}}\" non si è avviato poco fa ed è in pausa.", + "model_cooldown_anon": "Il modello non si è avviato poco fa ed è in pausa.", + "model_load_failed": "Il modello \"{{model}}\" non si è potuto avviare.", + "model_load_failed_anon": "Il modello non si è potuto avviare.", + "model_not_found": "Il modello \"{{model}}\" non è stato trovato.", + "model_not_found_anon": "Il modello non è stato trovato.", + "upstream_key": "L'upstream ha rifiutato la chiave.", + "upstream_path": "L'upstream non accetta questa richiesta a quell'indirizzo.", + "upstream_unreachable": "L'indirizzo dell'upstream non è raggiungibile.", + "upstream": "Il provider dietro il modello ha restituito un errore.", + "timeout": "Il modello ha impiegato troppo tempo a rispondere.", + "cancelled": "Il turno è stato annullato prima della risposta del modello." + }, + "hint": { + "model_backend_missing": "Scegli un altro modello oppure chiedi a un amministratore di installare il backend.", + "model_backend_missing_admin": "Scegli un altro modello oppure installa il backend dalla pagina Backend.", + "model_cooldown": "Riprova tra {{seconds}} secondi.", + "model_load_failed": "Controlla la configurazione del modello e il suo backend, poi riprova.", + "model_not_found": "Scegli un altro modello oppure installa questo dalla pagina Modelli.", + "upstream_key": "Controlla la chiave API nelle impostazioni del modello.", + "upstream_path": "L'indirizzo dell'upstream deve essere l'endpoint completo, per esempio .../v1/chat/completions.", + "upstream_unreachable": "Controlla l'indirizzo nelle impostazioni del modello.", + "upstream": "Riprova tra un attimo. Se continua a fallire, controlla indirizzo e chiave del provider.", + "timeout": "Riprova, oppure usa un modello più piccolo o più veloce.", + "cancelled": "Riprova quando vuoi." + }, + "openBackends": "Apri Backend" + }, + "allChats": { + "title": "Tutte le chat", + "supporting": "Tutte le chat salvate. Cerca, rinomina, esporta o elimina.", + "loadFailed": "Impossibile caricare le tue chat.", + "emptyTitle": "Ancora nessuna chat", + "emptyBody": "Una chat inizia dalla Home: scrivi un messaggio e invialo. Poi le tue chat sono elencate qui.", + "start": "Inizia una chat" + }, + "foot": { + "state": { + "ready": "Pronto", + "nomodel": "Nessun modello", + "sending": "Invio", + "loading": "Caricamento del modello", + "thinking": "Sta pensando", + "tool": "Esegue {{tool}}", + "answering": "Sta rispondendo", + "waiting": "In attesa di te", + "stopped": "Fermato", + "failed": "Non riuscito", + "interrupted": "Interrotto", + "paused": "In pausa", + "compacting": "Compattazione del contesto", + "clearing": "Svuotamento del contesto" + }, + "tool": { + "search": "Cerca", + "list": "Elenca", + "read": "Legge", + "create": "Crea", + "write": "Scrive", + "change": "Modifica", + "move": "Sposta", + "delete": "Elimina", + "run": "Esegue un comando", + "fetch": "Scarica", + "delegate": "Chiede a un sotto-agente" + }, + "hint": "Invio per inviare · Maiusc+Invio per una nuova riga", + "meter": { + "label": "Contesto pieno al {{pct}}%. Mostra contesto e velocità", + "labelNoLimit": "Contesto di {{used}} token. Mostra contesto e velocità" + }, + "details": { + "title": "Contesto e velocità", + "context": "Contesto", + "contextOf": "{{used}} di {{limit}} token ({{pct}}%)", + "contextUsed": "{{used}} token", + "contextLeft": "{{left}} token liberi", + "contextHelp": "Il contesto è ciò che il modello legge a ogni richiesta: le istruzioni, questa chat finora e i risultati degli strumenti.", + "nearFull": "Questa chat è vicina al limite. Il server compatta la parte più vecchia perché il modello possa continuare. La trascrizione conserva ogni messaggio.", + "limitUnknown": "Il server non ha indicato quanto è grande la finestra di contesto.", + "lastRequest": "Ultima richiesta", + "model": "Modello", + "backend": "Backend", + "node": "Nodo", + "tokensIn": "Token in ingresso", + "tokensOut": "Token in uscita", + "firstToken": "Tempo al primo token", + "promptTime": "Elaborazione del prompt", + "speed": "Velocità", + "speedLive": "Velocità stimata durante lo streaming", + "average": "Media delle ultime {{count}} richieste", + "note": "Questi valori vengono dalle richieste viste da questa pagina. Il server non li conserva, quindi un ricaricamento li cancella." + }, + "paused": { + "after": "dopo {{count}} passaggi", + "atLimit": "al limite di passaggi", + "continue": "Continua", + "continueTitle": "Il modello ha usato tutti i passaggi di un turno. Fallo continuare da dove si è fermato.", + "message": "Continua da dove ti sei fermato." + } + }, + "attachments": { + "label": "File allegati", + "open": "Apri {{name}}", + "showMore": "Mostra altri {{count}}", + "missing": "File non più disponibile" + }, + "preview": { + "open": "Apri anteprima", + "created": "Creato dall’assistente", + "changed": "Modificato dall’assistente", + "yours": "Fornito da te", + "fromReply": "Dalla risposta", + "download": "Scarica", + "downloadCode": "Scarica il codice", + "copy": "Copia", + "copied": "Copiato", + "openInFiles": "Apri in File", + "actions": "Azioni sul file", + "opened": "Anteprima aperta: {{name}}", + "chip": { + "created": "Creato", + "changed": "Modificato", + "label": "{{verb}} {{name}}, apri anteprima", + "row": "File di questa risposta" + } + }, + "meter": { + "usedOf": "{{used}} / {{limit}} token ({{pct}}%)", + "used": "{{used}} token", + "speed": "{{speed}} tok/s", + "title": "Contesto usato dall'ultima richiesta al modello", + "ofLimit": "{{used}} / {{limit}} token", + "left": "{{left}} liberi" + }, + "goal": { + "label": "Obiettivo", + "states": { + "active": "In corso", + "paused": "In pausa", + "done": "Raggiunto", + "blocked": "Serve il tuo aiuto" + }, + "pausedBy": { + "user": "L'hai messo in pausa tu.", + "turn_limit": "Ha usato tutti i turni automatici. Riprendi per dargliene altri.", + "step_limit": "Ha usato tutti i passi. Riprendi per dargliene altri.", + "error": "Un turno non è riuscito. Riprendi per riprovare.", + "interrupted": "Il server si è fermato durante un turno. Riprendi per continuare." + }, + "blockedNote": "Ha bisogno da te: {{note}}", + "doneNote": "{{note}}", + "replaced": "Sostituito: {{text}}", + "pause": "Pausa", + "pauseTitle": "Ferma il turno in corso e metti in pausa l'obiettivo", + "resume": "Riprendi", + "resumeTitle": "Continua verso l'obiettivo", + "clear": "Rimuovi", + "clearTitle": "Rimuovi l'obiettivo", + "progress": "Obiettivo · turno {{turn}} di {{max}}", + "progressStart": "Obiettivo · in avvio", + "markers": { + "set": "Obiettivo impostato", + "replaced": "Obiettivo sostituito", + "continue": "Prosegue verso l'obiettivo · turno {{turn}}", + "continueStart": "Prosegue verso l'obiettivo", + "paused": "Obiettivo in pausa", + "resumed": "Obiettivo ripreso", + "done": "Obiettivo raggiunto", + "blocked": "Obiettivo bloccato", + "cleared": "Obiettivo rimosso" + }, + "announce": { + "set": "Obiettivo impostato: {{text}}", + "paused": "Obiettivo in pausa. {{why}}", + "resumed": "Obiettivo ripreso", + "done": "Obiettivo raggiunto: {{note}}", + "blocked": "L'obiettivo ha bisogno di te: {{note}}", + "cleared": "Obiettivo rimosso" + }, + "card": { + "title": "Obiettivo", + "none": "Questa chat non ha un obiettivo. Scrivi /goal e cosa vuoi ottenere, e la chat ci lavora da sola.", + "turns": "Turno {{turn}} di {{max}}", + "steps": "{{steps}} passi su {{max}}", + "clear": "/goal clear lo rimuove.", + "resume": "/goal resume lo riprende.", + "turnsLabel": "Turni automatici", + "stepsLabel": "Passi", + "setLabel": "Impostato", + "stateLabel": "Stato", + "noteLabel": "Nota" + }, + "errors": { + "empty": "Scrivi l'obiettivo dopo /goal.", + "notAllowed": "Solo una chat che hai avviato tu può avere un obiettivo." + } + }, + "invoked": { + "skill": "Skill: {{name}}", + "agent": "Agente: {{name}}" + }, + "marker": { + "compacted": "Contesto compattato: da {{before}} a {{after}} token", + "cleared": "Contesto svuotato", + "clearedHint": "Il modello non vede i messaggi sopra questa linea." + }, + "cmdcard": { + "close": "Chiudi", + "helpTitle": "Comandi", + "helpSkills_one": "Skill: {{names}}. Scrivi / e il suo nome.", + "helpSkills_other": "{{count}} skill: {{names}}. Scrivi / e il nome.", + "helpKeys": "Scrivi / nella casella per aprire l'elenco. Tab completa, Invio esegue, Esc chiude.", + "aboutTitle": "Questa chat", + "model": "Modello", + "state": "Stato", + "loaded": "Caricato", + "notLoaded": "Non caricato", + "backend": "Backend", + "tools": "Strumenti", + "toolsOn_one": "{{count}} attivo", + "toolsOn_other": "{{count}} attivi", + "skills": "Skill", + "skillsOn_one": "{{count}} skill", + "skillsOn_other": "{{count}} skill", + "commands": "Comandi", + "isolated": "Eseguiti in una sandbox isolata", + "host": "Eseguiti su questa macchina", + "noCommands": "Non eseguibili", + "context": "Contesto", + "contextOf": "{{used}} di {{limit}} token", + "contextUsed": "{{used}} token", + "unknown": "Non ancora noto" } } diff --git a/core/http/react-ui/public/locales/it/chats.json b/core/http/react-ui/public/locales/it/chats.json new file mode 100644 index 000000000..0881da92f --- /dev/null +++ b/core/http/react-ui/public/locales/it/chats.json @@ -0,0 +1,581 @@ +{ + "proposal": { + "loading": "Lettura del trigger proposto...", + "unread": "Impossibile leggere il trigger proposto: {{message}}", + "removed": "Questo trigger è stato rimosso.", + "retry": "Riprova", + "inactive": "Trigger proposto. Non esegue nulla finché non lo crei.", + "name": "Nome", + "schedule": "Quando (per esempio daily 08:00)", + "folder": "Cartella da controllare", + "saveEdit": "Salva", + "cancelEdit": "Annulla", + "create": "Crea trigger", + "edit": "Modifica", + "dismiss": "Rifiuta", + "receipt": { + "scheduled": "Pianificato:", + "watching": "In osservazione:", + "next": "Prossima esecuzione {{when}}", + "view": "Vedi trigger", + "undo": "Annulla", + "undone": "Annullato:", + "again": "Pianificalo di nuovo", + "declined": "Rifiutato:", + "restore": "Ripristina", + "blocked": { + "ran": "È già stato eseguito una volta; rimuovilo dalla pagina Trigger.", + "edited": "Lo hai modificato dopo; cambialo o rimuovilo dalla pagina Trigger." + } + }, + "announce": { + "accepted": "{{name}} pianificato.", + "undone": "{{name}} annullato. Non è pianificato.", + "declined": "{{name}} rifiutato.", + "reopened": "{{name}} aspetta di nuovo la tua risposta." + }, + "plan": { + "whenLabel": "Quando", + "runsAsLabel": "Viene eseguito come", + "readsLabel": "Legge", + "writesLabel": "Scrive", + "costLabel": "Costo", + "stopLabel": "Per fermarlo", + "when": "{{schedule}}, {{zone}}. Prima esecuzione {{when}}.", + "whenNoFirst": "{{schedule}}, {{zone}}.", + "whenFile": "Quando arriva un file in {{folder}}.", + "runsAs": "Tu", + "runsAsModel": "Tu, con {{model}}", + "writesWidget": ", mostrato nel widget \"{{widget}}\" della Home", + "costLocal_one": "Circa {{tokens}} token per esecuzione su un modello locale. Al massimo {{count}} esecuzione al giorno.", + "costLocal_other": "Circa {{tokens}} token per esecuzione su un modello locale. Al massimo {{count}} esecuzioni al giorno.", + "costRemote_one": "Circa {{tokens}} token per esecuzione su {{model}}, un servizio remoto. Al massimo {{count}} esecuzione al giorno.", + "costRemote_other": "Circa {{tokens}} token per esecuzione su {{model}}, un servizio remoto. Al massimo {{count}} esecuzioni al giorno.", + "costUnknown_one": "I token di un'esecuzione si conoscono dopo la prima. Al massimo {{count}} esecuzione al giorno.", + "costUnknown_other": "I token di un'esecuzione si conoscono dopo la prima. Al massimo {{count}} esecuzioni al giorno.", + "remoteNote": "{{model}} è un servizio remoto: quello che un'esecuzione legge esce da questo server.", + "stopWidget": "Mettilo in pausa dal widget, o eliminalo in Trigger", + "stop": "Spegnilo o eliminalo in Trigger" + } + }, + "mcpPage": { + "title": "Connettori MCP", + "supporting": "Collega server MCP per dare alle tue chat e ai tuoi agenti più strumenti, come la ricerca o le tue API.", + "family": "I bot per Telegram o Slack sono connettori di messaggistica.", + "related": "Correlati", + "chats": "Chat", + "add": "Aggiungi connettore", + "learn": "Come funziona MCP", + "loading": "Caricamento dei tuoi connettori", + "loadFailed": "Impossibile caricare i tuoi connettori.", + "forbidden": "MCP non è disponibile per il tuo account.", + "empty": { + "title": "Nessun connettore", + "body": "Un connettore collega un server MCP a LocalAI. Le tue chat e i tuoi agenti possono poi usarne gli strumenti, per esempio per cercare nella documentazione o leggere un calendario.", + "fact1": "Qualsiasi server MCP via http o https", + "fact2": "Token e header salvati cifrati", + "fact3": "Provalo prima che le chat lo usino" + }, + "toolbar": { + "search": "Cerca connettori", + "testAll": "Prova tutti", + "testingAll": "Prova in corso", + "testedAll": "Provati i connettori attivi." + }, + "filter": { + "label": "Mostra", + "all": "Tutti", + "on": "Attivi", + "attention": "Da controllare", + "off": "Spenti", + "showAll": "Mostra tutti", + "none": { + "all": "Nessun connettore.", + "on": "Nessun connettore è attivo.", + "attention": "Niente da controllare.", + "off": "Tutti i connettori sono attivi." + } + }, + "noMatch": { + "title": "Nessun connettore corrisponde a “{{query}}”", + "body": "Cerca per nome o per indirizzo.", + "clear": "Cancella la ricerca" + }, + "state": { + "label": "Stato: {{state}}", + "ok": "Connesso", + "auth": "Accesso richiesto", + "error": "Non raggiungibile", + "unchecked": "Non provato", + "off": "Spento" + }, + "row": { + "open": "{{name}}, {{state}}. Mostra i dettagli", + "toggle": "Usa {{name}} nelle chat", + "menu": "Azioni per {{name}}", + "offLine": "Spento. Chat e agenti non lo usano.", + "notChecked": "Non ancora provato. Provalo per vedere i suoi strumenti.", + "noTools": "Connesso, ma il server non offre strumenti.", + "tools_one": "{{count}} strumento", + "tools_other": "{{count}} strumenti", + "checked": "Provato {{when}}", + "tokenSaved": "Token salvato", + "headersSaved": "Header salvati", + "envSaved": "Variabili salvate" + }, + "actions": { + "details": "Dettagli", + "test": "Prova la connessione", + "testNamed": "Prova la connessione a {{name}}", + "testing": "Prova in corso", + "edit": "Modifica", + "reauth": "Sostituisci il token", + "remove": "Rimuovi" + }, + "details": { + "use": "Usa nelle chat", + "useHelp": "Le chat senza agente usano tutti i connettori attivi. Un agente salvato usa solo quelli che scegli per lui.", + "lastCheck": "Ultima prova", + "never": "Non ancora provato in questo browser.", + "okAt": "Connesso {{when}}.", + "testedAt": "Provato {{when}}.", + "errorTitle": "L'ultima prova non si è connessa.", + "authTitle": "Il server ha rifiutato l'accesso.", + "tools": "Strumenti", + "toolsUnknown": "Prova la connessione per vedere gli strumenti.", + "toolsNone": "Il server non offre strumenti.", + "toolsSearch": "Cerca strumenti", + "noToolMatch": "Nessuno strumento corrisponde a “{{query}}”.", + "noDescription": "Nessuna descrizione.", + "connection": "Connessione", + "kind": "Tipo", + "address": "Indirizzo", + "command": "Comando", + "added": "Aggiunto", + "queryHidden": "La parte dell'indirizzo dopo ? è nascosta, perché può contenere una chiave.", + "signIn": "Accesso", + "token": "Token bearer", + "headers": "Header", + "env": "Variabili", + "saved": "Salvato", + "none": "Nessuno", + "encrypted": "Salvato cifrato sul server. Non viene più mostrato." + }, + "sheet": { + "addTitle": "Aggiungi connettore", + "editTitle": "Modifica {{name}}", + "addDesc": "Collega un server MCP. Lo provi prima che le chat lo usino.", + "editDesc": "I segreti salvati restano, a meno che tu non li sostituisca o li rimuova.", + "testDesc": "LocalAI si connette con quello che hai salvato ed elenca gli strumenti.", + "close": "Chiudi", + "kind": "Tipo", + "remote": "Server remoto", + "remoteHelp": "Un server MCP che questa macchina raggiunge via http o https.", + "local": "Comando locale", + "localHelp": "Un programma che LocalAI avvia su questa macchina. Solo amministratori.", + "basics": "Connessione", + "name": "Nome", + "nameHelp": "Appare nel menu Strumenti di una chat.", + "url": "Indirizzo", + "urlHelp": "I server su una rete privata sono rifiutati, a meno che un amministratore non li permetta.", + "command": "Comando", + "commandHelp": "Il programma da avviare, senza shell.", + "args": "Argomenti", + "argsHelp": "Un argomento per riga.", + "signIn": "Accesso", + "signInHelp": "Solo se il server chiede una chiave.", + "envIntro": "Variabili che il comando riceve, per esempio una chiave API.", + "token": "Token bearer", + "tokenHelp": "Inviato come header Authorization.", + "tokenSaved": "Un token è salvato. Scrivine uno nuovo per sostituirlo.", + "tokenSavedShort": "Salvato", + "clearToken": "Rimuovi il token salvato", + "headers": "Header", + "headersHelp": "Altri header di cui il server ha bisogno, come un header con la chiave API.", + "headersSaved": "Gli header sono salvati. Le nuove righe li sostituiscono tutti.", + "clearHeaders": "Rimuovi gli header salvati", + "headerName": "Nome dell'header", + "headerValue": "Valore", + "addHeader": "Aggiungi un header", + "env": "Variabili d'ambiente", + "envSaved": "Le variabili sono salvate. Le nuove righe le sostituiscono tutte.", + "clearEnv": "Rimuovi le variabili salvate", + "envName": "Variabile", + "addEnv": "Aggiungi una variabile", + "removeRow": "Rimuovi la riga {{n}}", + "showValue": "Mostra il valore", + "hideValue": "Nascondi il valore", + "encrypted": "Token, header e variabili sono salvati cifrati sul server e non vengono più mostrati.", + "cancel": "Annulla", + "saving": "Salvataggio", + "saveAndTest": "Salva e prova", + "savedOn": "Salvato. È attivo.", + "savedOff": "Salvato. Resta spento finché non lo attivi.", + "testing": "Connessione a {{name}}…", + "okTitle": "Connesso.", + "okBody_one": "{{name}} offre {{count}} strumento.", + "okBody_other": "{{name}} offre {{count}} strumenti.", + "okNoTools": "Il server non offre strumenti.", + "failTitle": "Connessione non riuscita.", + "failHint": "Controlla l'indirizzo, poi prova di nuovo.", + "failAuth": "Controlla il token o gli header.", + "back": "Modifica i dati", + "retry": "Prova di nuovo", + "done": "Fatto", + "keepOff": "Lascialo spento", + "turnOn": "Attiva", + "discardTitle": "Scartare le modifiche?", + "discardBody": "Quello che hai scritto in questo modulo va perso.", + "discard": "Scarta", + "keepEditing": "Continua a modificare" + }, + "errors": { + "nameRequired": "Dagli un nome.", + "nameTaken": "Un altro connettore ha questo nome.", + "urlRequired": "Inserisci l'indirizzo del server.", + "urlInvalid": "Questo non è un indirizzo valido.", + "urlScheme": "L'indirizzo deve iniziare con http:// o https://.", + "urlCredentials": "Metti le credenziali nel token o in un header, non nell'indirizzo.", + "commandRequired": "Inserisci il comando da avviare." + }, + "remove": { + "title": "Rimuovere {{name}}?", + "body": "Il connettore viene eliminato con token, header e variabili salvati. Un agente salvato che lo usa non può più chiamarne gli strumenti.", + "confirm": "Rimuovi", + "pending": "Rimozione" + }, + "toasts": { + "removed": "“{{name}}” rimosso.", + "on": "“{{name}}” è attivo.", + "off": "“{{name}}” è spento." + }, + "import": { + "title": "Server salvati in questo browser", + "text": "La vecchia pagina delle chat salvava questi server MCP in questo browser. Importali per tenerli sul server, dove ogni browser e sessione può usarli. Vengono creati spenti. I loro token e header vanno al server e poi vengono rimossi da questo browser.", + "button": "Importa {{count}} server da questo browser", + "buttonOne": "Importa 1 server da questo browser", + "busy": "Importazione", + "headersCount": "{{count}} header", + "done": "Importati {{count}}. Sono spenti: provali e attivali qui sotto.", + "failed": "{{name}} non è stato importato: {{error}}", + "left": "I server non importati restano in questo browser." + } + }, + "connectorsPage": { + "loadFailed": "Impossibile caricare i tuoi connettori.", + "waitingTitle": "In attesa del loro add-on", + "waitingText": "Questi collegamenti restano salvati, ma non funzionano finché il loro add-on non è installato e attivo." + }, + "tools": { + "commands": { + "title": "Qui non si possono eseguire comandi", + "none": "Non è installato nessun componente aggiuntivo che esegue codice: l'assistente può scrivere file, ma non eseguire script o comandi.", + "off": "Il componente aggiuntivo Sandbox è spento per questa chat. Attivalo nel menu Strumenti per far eseguire codice all'assistente.", + "unavailable": "Il componente aggiuntivo Sandbox è installato, ma non può eseguire comandi su questo server.", + "unavailableWhy": "Il componente aggiuntivo Sandbox non può eseguire comandi su questo server: {{reason}}", + "install": "Installa Sandbox", + "openSandbox": "Apri il componente Sandbox", + "askInstall": "Chiedi a un amministratore di installare il componente aggiuntivo Sandbox.", + "askFix": "Chiedi a un amministratore di risolvere il problema.", + "hide": "Nascondi", + "hideLabel": "Nascondi questo avviso", + "chipTitle": "Questa chat non può eseguire codice o comandi. Seleziona per i dettagli.", + "hostTitle": "I comandi vengono eseguiti su questa macchina", + "host": "I comandi vengono eseguiti su questa macchina, con i suoi file e la sua rete, senza isolamento. Installa il Sandbox per isolarli.", + "hostOff": "Il Sandbox è spento per questa chat, quindi i comandi vengono eseguiti su questa macchina, con i suoi file e la sua rete, senza isolamento. Accendi il Sandbox in questo menu per isolarli.", + "hostUnavailable": "Il Sandbox non può funzionare qui, quindi i comandi vengono eseguiti su questa macchina, con i suoi file e la sua rete, senza isolamento.", + "hostUnavailableWhy": "Il Sandbox non può funzionare qui ({{reason}}), quindi i comandi vengono eseguiti su questa macchina, con i suoi file e la sua rete, senza isolamento.", + "hostChipTitle": "I comandi di questa chat vengono eseguiti su questa macchina, senza isolamento. Seleziona per i dettagli." + }, + "consent": { + "title": "Eseguire questo comando su questa macchina?", + "body": "I comandi vengono eseguiti su questa macchina senza isolamento. Possono leggere i file che LocalAI può leggere e raggiungere internet e la tua rete. Installa il Sandbox per isolarli.", + "once": "Esegui una volta", + "always": "Sempre su questa istanza", + "deny": "Non eseguire", + "alwaysHint": "«Sempre su questa istanza» permette alle chat di tutti gli utenti di eseguire comandi qui senza chiedere (Impostazioni > Strumenti).", + "askAdmin": "Solo un amministratore può consentire comandi su questa macchina. Chiedi a un amministratore, oppure esegui tu il comando.", + "expired": "Nessuna risposta: il turno che l'ha chiesto è terminato, quindi il comando non è stato eseguito." + }, + "provider": { + "sandbox": "Sandbox", + "addon": "Componente {{name}}", + "host": "Questa macchina, non isolata", + "hostOnce": "Questa macchina, consentito una volta", + "hostDenied": "Non eseguito: rifiutato", + "hostTimeout": "Non eseguito: nessuna risposta", + "hostAdmin": "Non eseguito: serve un amministratore" + }, + "builtins": "Strumenti integrati", + "builtin": { + "web": "Web", + "saved_agents": "Agenti salvati", + "triggers": "Trigger", + "shell": "Esegui comandi", + "files": "I tuoi file", + "media": "Immagini e audio", + "collections": "Raccolte", + "subagents": "Sotto-agenti" + }, + "builtinBy": "{{tools}} · integrati", + "builtinByAddon": "{{tools}} · servito da {{provider}}", + "servedBy": "Servito dal componente aggiuntivo {{provider}}", + "servedByTool": "{{tool}}: {{provider}}", + "servedByBuiltin": "integrato", + "sandbox": { + "starting": "Il Sandbox si sta avviando. LocalAI ricontrolla tra {{seconds}} s." + }, + "fallbackBuiltin": "{{addon}} sostituisce {{tool}} ma ora non può servire questa chat (fermo, disattivato qui o non concesso): si usa {{tool}} integrato.", + "fallbackNone": "{{addon}} sostituisce {{tool}} ma ora non può servire questa chat, e {{tool}} integrato è disattivato: questa chat non ha {{tool}}.", + "shell": { + "machine": "Questa macchina", + "isolated": "I comandi girano isolati" + } + }, + "vision": { + "describedBy": "Questo modello non può guardare le immagini. Scegli un altro modello, oppure l'immagine sarà descritta da {{model}}.", + "none": "Questo modello non può guardare le immagini e nessun modello qui può descriverle. Scegli un modello che può guardare le immagini." + }, + "egress": { + "label": "Rete", + "preset": { + "off": "Disattivata", + "registries": "Registri di pacchetti", + "all": "Tutti i siti pubblici" + }, + "presetHint": { + "off": "I comandi nel Sandbox non raggiungono nessuna rete.", + "registries": "I comandi raggiungono solo i registri di pacchetti (PyPI, npm, Go, crates.io, RubyGems, Maven Central, release di GitHub).", + "all": "I comandi raggiungono ogni sito pubblico." + }, + "limited": "Limitato dal tuo amministratore", + "limitedBy": "Il tuo amministratore consente al massimo: {{limit}}.", + "hostNetwork": "I comandi vengono eseguiti su questa macchina e usano la sua rete. I limiti di rete richiedono il componente Sandbox.", + "panel": "Regole e richieste ({{count}})", + "rules": "Regole di questa chat", + "cards": "Richieste di questa chat", + "ruleAllow": "Consentito per questa chat", + "ruleDeny": "Negato per questa chat", + "aboveLimit": "oltre il limite, non si applica", + "remove": "Rimuovi", + "removeLabel": "Rimuovi la regola di {{host}}", + "card": { + "pending": "In attesa di risposta", + "allowedOnce": "Consentito una volta", + "allowedChat": "Consentito per questa chat", + "denied": "Negato", + "expired": "Senza risposta", + "refused": "Rifiutato: il limite di rete del server non lo consente" + }, + "consent": { + "title": "Accesso alla rete", + "body": "Un comando in questa chat vuole raggiungere", + "once": "Consenti una volta", + "chat": "Consenti per questa chat", + "deny": "Nega", + "preset": "Questa chat: {{preset}}. Limite del tuo amministratore: {{limit}}.", + "adminOnly": "Questo host è nella tua rete: solo un amministratore può consentirlo.", + "answered": "{{state}} da {{who}}, {{time}}", + "answeredNoWho": "{{state}}, {{time}}", + "you": "te" + }, + "blocked": { + "titleOff": "L’accesso alla rete è disattivato", + "titleCapped": "L’accesso alla rete è limitato", + "bodyOff": "L’accesso alla rete del Sandbox è disattivato su questo server. Un comando ha chiesto di raggiungere", + "bodyCapped": "Questo server consente solo {{limit}} per la rete del Sandbox. Un comando ha chiesto di raggiungere", + "adminOff": "Un amministratore può attivarlo nelle impostazioni di {{name}}.", + "adminCapped": "Un amministratore può alzare il limite nelle impostazioni di {{name}}.", + "adminLink": "Apri le impostazioni di {{name}}", + "askAdmin": "Chiedi a un amministratore di questo server di cambiarlo.", + "rowOff": "Disattivata: l’amministratore non ha consentito l’accesso alla rete.", + "runner": "l’add-on che esegue i comandi" + }, + "allowedHosts": "Sempre consentiti dal tuo amministratore:", + "allowedHostsOff": "Valgono quando la rete è attiva.", + "allowedHostsNote": "{{count}} host sono sempre consentiti dal tuo amministratore.", + "limitNote": "Rete di questa chat. L’amministratore imposta il massimo che una chat può raggiungere.", + "asksAbove": "Questa chat chiede {{choice}}; il limite dell’amministratore è {{limit}}." + }, + "inbox": { + "unread": "{{count}} non lette", + "markAll": "Segna tutto come letto", + "delete": "Elimina", + "pushOn": "Attiva le notifiche del browser", + "pushOff": "Disattiva le notifiche del browser", + "pushDenied": "Le notifiche del browser sono bloccate per questo sito.", + "pushUnavailable": "Questo server non può conservare le sottoscrizioni push.", + "clearAll": "Elimina tutte", + "clearRead": "Elimina le lette", + "cancel": "Annulla", + "markRead": "Segna come letta", + "cleared_one": "{{count}} notifica eliminata.", + "cleared_other": "{{count}} notifiche eliminate.", + "confirmAll_one": "Eliminare {{count}} notifica? Non si può annullare.", + "confirmAll_other": "Eliminare tutte le {{count}} notifiche? Non si può annullare.", + "confirmRead_one": "Eliminare {{count}} notifica letta? Quelle non lette restano.", + "confirmRead_other": "Eliminare {{count}} notifiche lette? Quelle non lette restano.", + "onlyRead": "Solo le {{count}} lette", + "showing": "{{shown}} di {{total}} mostrate", + "showMore": "Mostra altre", + "title": "Notifiche", + "buttonUnread_one": "Notifiche, {{count}} da leggere", + "buttonUnread_other": "Notifiche, {{count}} da leggere", + "filterLabel": "Mostra notifiche", + "filterAll": "Tutte", + "filterUnread": "Da leggere", + "filterFailed": "Non riuscite", + "more": "Altre azioni", + "close": "Chiudi", + "day": { + "today": "Oggi", + "yesterday": "Ieri", + "earlier": "Prima" + }, + "emptyTitle": "Ancora nessuna notifica", + "emptyBody": "Qui compaiono i risultati dei tuoi trigger e connettori, anche del lavoro finito mentre eri via.", + "caughtUp": "Sei in pari.", + "caughtUpTitle": "Sei in pari", + "caughtUpBody": "Non ci sono notifiche da leggere.", + "noFailedTitle": "Niente è andato storto", + "noFailedBody": "Qui compaiono le esecuzioni e i connettori che non riescono.", + "loadError": "Impossibile caricare le tue notifiche.", + "source": { + "trigger": "Trigger", + "connector": "Connettore", + "system": "LocalAI" + }, + "itemUnread": "{{title}}, da leggere", + "arrived": "Nuova notifica: {{title}}", + "arrivedMany_one": "{{count}} nuova notifica", + "arrivedMany_other": "{{count}} nuove notifiche" + }, + "change": { + "loading": "Lettura della modifica del trigger...", + "unread": "Impossibile leggere la modifica del trigger: {{message}}", + "removed": "Questa modifica non è più conservata.", + "none": "nessuno", + "on": "Attivo", + "off": "Disattivo (in pausa)", + "becomes": "diventa", + "neverRan": "Non è ancora stato eseguito", + "lastRun": "Ultima esecuzione", + "lastRunValue": "{{status}}, {{when}}", + "runStatus": { + "queued": "In coda", + "running": "In esecuzione", + "done": "Completata", + "failed": "Non riuscita", + "skipped": "Saltata" + }, + "updateTitle": "Modifica {{name}}", + "deleteTitle": "Eliminare {{name}}?", + "updateHelp": "Modifica proposta. Non cambia nulla finché non la applichi.", + "deleteHelp": "Non viene eliminato nulla finché non premi Elimina. Puoi annullare per un giorno.", + "stale": "Il trigger è stato modificato o rimosso dopo la proposta, quindi la modifica non si può applicare. Chiedi di nuovo la modifica.", + "apply": "Applica", + "delete": "Elimina", + "keep": "Mantieni", + "field": { + "name": "Nome", + "prompt": "Prompt", + "when": "Quando", + "time_zone": "Fuso orario", + "daily_cap": "Limite giornaliero", + "enabled": "Stato", + "reads": "Legge", + "writes": "Scrive", + "widget": "Widget della Home", + "estimated_tokens": "Token per esecuzione", + "agent": "Agente", + "model": "Modello" + }, + "receipt": { + "update": "Modificato:", + "pause": "In pausa:", + "resume": "Ripreso:", + "delete": "Eliminato:", + "fields": "Cosa è cambiato: {{fields}}", + "dismissed": "Modifica scartata:", + "kept": "Mantenuto:", + "undone": "Annullato:", + "restored": "Ripristinato:" + }, + "blocked": { + "expired": "Il tempo per annullare è scaduto; modifica il trigger nella pagina Trigger.", + "edited": "Il trigger è stato modificato nel frattempo; cambialo nella pagina Trigger.", + "removed": "Il trigger è stato rimosso nel frattempo.", + "full": "Hai già il numero massimo di trigger; rimuovine uno nella pagina Trigger." + }, + "announce": { + "applied": "{{name}} modificato.", + "deleted": "{{name}} eliminato.", + "dismissed": "Modifica a {{name}} scartata.", + "kept": "{{name}} mantenuto.", + "undone": "Modifica a {{name}} annullata.", + "restored": "{{name}} ripristinato." + } + }, + "loops": { + "region": "Cicli di questa chat", + "unit": { + "seconds_one": "{{count}} secondo", + "seconds_other": "{{count}} secondi", + "minutes_one": "{{count}} minuto", + "minutes_other": "{{count}} minuti", + "hours_one": "{{count}} ora", + "hours_other": "{{count}} ore" + }, + "unitShort": { + "seconds_one": "{{count}} s", + "seconds_other": "{{count}} s", + "minutes_one": "{{count}} min", + "minutes_other": "{{count}} min", + "hours_one": "{{count}} h", + "hours_other": "{{count}} h" + }, + "confirm": { + "every": "Eseguire questo ogni {{every}} finché non lo fermi?", + "selfPaced": "Eseguire questo di nuovo ogni volta che il modello lo chiede, finché non lo fermi?", + "start": "Avvia", + "cancel": "Annulla" + }, + "chip": { + "name": "Ciclo", + "every": "ogni {{every}}", + "selfPaced": "a ritmo del modello", + "run_one": "esecuzione {{count}}", + "run_other": "esecuzione {{count}}", + "paused": "in pausa", + "running": "in corso", + "next": "prossima alle {{time}}", + "ended": "terminato", + "wait": "In attesa di: {{wait}}" + }, + "actions": { + "pause": "Pausa", + "resume": "Riprendi", + "stop": "Ferma", + "dismiss": "Chiudi" + }, + "state": { + "pending": "attende il tuo sì", + "active": "attivo", + "paused": "in pausa", + "ended": "terminato" + }, + "list": { + "title": "Cicli di questa chat", + "close": "Chiudi", + "empty": "Questa chat non ha cicli.", + "hint": "Per fermarne uno, scrivi /loop stop e il codice all'inizio della sua riga." + }, + "slash": { + "usage": "Scrivi /loop, un intervallo se lo vuoi e cosa ripetere. Per esempio: /loop 5m controlla la build.", + "notFound": "Nessun ciclo di questa chat corrisponde a {{id}}.", + "noChat": "Invia un primo messaggio, poi avvia un ciclo in questa chat.", + "stopped_one": "Fermato {{count}} ciclo.", + "stopped_other": "Fermati {{count}} cicli." + } + } +} diff --git a/core/http/react-ui/public/locales/it/collections.json b/core/http/react-ui/public/locales/it/collections.json index e477c94cb..d434bc75d 100644 --- a/core/http/react-ui/public/locales/it/collections.json +++ b/core/http/react-ui/public/locales/it/collections.json @@ -4,13 +4,13 @@ "newPlaceholder": "Nome della nuova raccolta...", "actions": { "create": "Crea", - "creating": "Creazione...", "details": "Dettagli", "reset": "Reimposta", "delete": "Elimina", "viewDetails": "Visualizza dettagli", "resetCollection": "Reimposta raccolta", - "deleteCollection": "Elimina raccolta" + "deleteCollection": "Elimina raccolta", + "cancel": "Annulla" }, "sections": { "yourCollections": "Le tue raccolte", @@ -19,7 +19,8 @@ "empty": { "title": "Nessuna raccolta ancora", "text": "Le raccolte ti permettono di organizzare i documenti in basi di conoscenza che gli agenti possono consultare con RAG (Retrieval-Augmented Generation). Crea una raccolta sopra per iniziare.", - "noPersonal": "Non hai ancora raccolte." + "noPersonal": "Non hai ancora raccolte.", + "learn": "Come funziona la memoria" }, "deleteDialog": { "title": "Elimina raccolta", @@ -32,12 +33,29 @@ "confirm": "Reimposta" }, "toasts": { - "loadFailed": "Caricamento raccolte fallito: {{message}}", "created": "Raccolta \"{{name}}\" creata", "createFailed": "Creazione raccolta fallita: {{message}}", "deleted": "Raccolta \"{{name}}\" eliminata", "deleteFailed": "Eliminazione raccolta fallita: {{message}}", "reset": "Raccolta \"{{name}}\" reimpostata", "resetFailed": "Reimpostazione raccolta fallita: {{message}}" + }, + "loadFailed": "Impossibile caricare le tue raccolte.", + "sources": { + "loadFailed": "Impossibile caricare le fonti web." + }, + "entries": { + "loadFailed": "Impossibile caricare i file." + }, + "starting": { + "title": "La memoria si sta avviando.", + "text": "Il server sta ancora preparando il servizio di memoria. Questa pagina riprova da sola." + }, + "embedding": { + "missingTitle": "Nessun modello di embedding installato.", + "missingText": "Le raccolte usano {{model}} per indicizzare i file e per cercarli. Finché non è installato, l'aggiunta di file e la ricerca non funzionano.", + "noneSet": "Nessun modello di embedding è impostato per le raccolte (collections_embedding_model), quindi l'aggiunta di file e la ricerca non funzionano.", + "install": "Trovalo in Modelli", + "askAdmin": "Chiedi a un amministratore di installarlo." } } diff --git a/core/http/react-ui/public/locales/it/common.json b/core/http/react-ui/public/locales/it/common.json index 01b06243c..ad6473a4a 100644 --- a/core/http/react-ui/public/locales/it/common.json +++ b/core/http/react-ui/public/locales/it/common.json @@ -111,26 +111,22 @@ "gigabytes": "GB", "terabytes": "TB" }, - "updateReview": { - "title": "Rivedi gli aggiornamenti", - "description": "Niente viene aggiornato finché non scegli qui.", - "close": "Chiudi", - "done": "Fatto", - "selectAll": "Seleziona tutto", - "select": "Seleziona {{name}}", - "unknownVersion": "sconosciuta", - "latest": "ultima", - "blocked": "Non aggiornabile qui:", - "upgradeSelected": "Aggiorna selezionati ({{count}})", - "upgradeAll": "Aggiorna tutti ({{count}})", - "running": "Aggiornamento...", - "queued": "In attesa", - "statusRunning": "Aggiornamento", - "statusDone": "Aggiornato", - "statusFailed": "Non riuscito", - "summary": "{{succeeded}} aggiornati, {{failed}} non riusciti.", - "empty": "Niente da aggiornare.", - "statusStarted": "Avviato", - "summaryStarted": "{{succeeded}} avviati, {{failed}} non riusciti." + "ui": { + "tryAgain": "Riprova", + "undo": "Annulla", + "loadError": { + "title": "Impossibile caricare.", + "serverSaid": "Il server ha risposto «{{message}}».", + "nothingChanged": "Non è stato modificato nulla." + } + }, + "chip": { + "running": "In esecuzione", + "starting": "Avvio", + "stopped": "Fermo", + "failed": "Non riuscito", + "updateAvailable": "Aggiornamento disponibile", + "ready": "Pronto", + "noRunsYet": "Nessuna esecuzione" } } diff --git a/core/http/react-ui/public/locales/it/explorer.json b/core/http/react-ui/public/locales/it/explorer.json new file mode 100644 index 000000000..63965047b --- /dev/null +++ b/core/http/react-ui/public/locales/it/explorer.json @@ -0,0 +1,57 @@ +{ + "brand": "{{name}} Explorer", + "openApp": "Apri LocalAI", + "close": "Chiudi", + "copy": "Copia", + "copied": "Copiato", + "loading": "Caricamento degli swarm", + "title": "Swarm pubblici", + "lede": "Uno swarm è un gruppo di macchine che condividono l'inferenza dei modelli su una rete peer-to-peer. Questo elenco mostra gli swarm i cui proprietari hanno scelto di renderli pubblici. Chiunque abbia il token di uno swarm può unirsi.", + "count_one": "{{count}} swarm online", + "count_other": "{{count}} swarm online", + "sorted": "Prima quelli con più worker", + "workers_one": "worker", + "workers_other": "worker", + "tokenTitle": "L'inizio e la fine del token. Copialo da Come unirsi.", + "warning": "Explorer è un elenco della comunità di token di swarm. Chiunque può usare un token per inviare lavoro a uno swarm o per condividere risorse con esso. Usalo a tuo rischio.", + "listed": "Lo swarm è nell'elenco.", + "empty": { + "title": "Nessuno swarm ha worker online", + "text": "Vengono elencati solo gli swarm con almeno un worker online. Inserisci il tuo nell'elenco per renderlo pubblico." + }, + "off": { + "title": "Questo server non è un explorer", + "text": "La pagina Explorer richiede un server LocalAI avviato in modalità explorer. Questo server non ha un elenco di swarm da mostrare." + }, + "error": { + "title": "Impossibile caricare gli swarm", + "text": "Il server explorer non ha risposto. Controlla che sia in esecuzione, poi riprova.", + "retry": "Riprova" + }, + "join": { + "open": "Come unirsi", + "title": "Unisciti a {{name}}", + "text": "Avvia un worker LocalAI con il token di questo swarm.", + "token": "Token", + "federated": "Cluster federato {{id}}. Avvia un nodo su di esso con uno dei due comandi.", + "docker": "Con Docker", + "cli": "Con la riga di comando", + "noCommands": "I worker di questo swarm condividono la potenza di calcolo tramite il suo token. Il token qui sopra è ciò che un nodo deve avere per unirsi.", + "docs": "Leggi come funzionano gli swarm" + }, + "list": { + "open": "Elenca uno swarm", + "title": "Elenca uno swarm", + "text": "Aggiungi uno swarm all'elenco pubblico. Compare quando ha almeno un worker online.", + "name": "Nome", + "description": "Descrizione", + "token": "Token", + "tokenHint": "Il token di rete dello swarm, così come lo ha stampato il server.", + "public": "Elencare lo swarm rende pubblico il token. Chiunque veda l'elenco può usare i worker del tuo swarm.", + "understood": "Ho capito che il token diventa pubblico.", + "cancel": "Annulla", + "add": "Elenca questo swarm", + "adding": "Inserimento in corso...", + "failed": "Impossibile elencare lo swarm." + } +} diff --git a/core/http/react-ui/public/locales/it/home.json b/core/http/react-ui/public/locales/it/home.json index 5c21132f3..086409f40 100644 --- a/core/http/react-ui/public/locales/it/home.json +++ b/core/http/react-ui/public/locales/it/home.json @@ -25,7 +25,10 @@ "title": "Gestisci LocalAI chattando", "description": "Installa modelli, cambia backend, modifica configurazioni e controlla lo stato parlando con LocalAI.", "open": "Apri assistente", - "tooltip": "Gestisci LocalAI chattando" + "tooltip": "Gestisci LocalAI chattando", + "noModel": "Scegli prima un modello di chat.", + "startFailed": "Impossibile avviare la chat dell'assistente: {{message}}", + "chatTitle": "Assistente LocalAI" }, "input": { "placeholder": "Messaggio...", @@ -79,13 +82,17 @@ "step2Title": "Installa un modello", "step2Body": "Clicca installa per scaricarlo e configurarlo automaticamente.", "step3Title": "Inizia a chattare", - "step3Body": "Chatta con il tuo modello dal browser o usa l'API." + "step3Body": "Chatta con il tuo modello dal browser o usa l'API.", + "sandboxTitle": "Esegui il codice in sicurezza (facoltativo)", + "sandboxBody": "Installa il componente Sandbox perché il modello esegua codice e comandi in isolamento. Senza, i comandi vengono eseguiti direttamente su questa macchina, con accesso completo alla rete e nessun isolamento." }, "browseGallery": "Sfoglia galleria modelli", "importModel": "Importa modello", "docs": "Documentazione", "noModelsTitle": "Nessun modello disponibile", - "noModelsBody": "Non ci sono ancora modelli installati. Chiedi al tuo amministratore di configurare i modelli per iniziare a chattare." + "noModelsBody": "Non ci sono ancora modelli installati. Chiedi al tuo amministratore di configurare i modelli per iniziare a chattare.", + "sandboxInstall": "Installa Sandbox", + "sandboxSkip": "Salta" }, "connect": { "title": "Un endpoint, ogni API", @@ -105,7 +112,23 @@ "modelsSummary": "Explore, install, and manage models", "create": "Create", "createSummary": "Open a chat, image or voice session", - "operate": "Operate", - "operateSummary": "{{models}} models configured · nodes, activity and traces" + "operate": "Amministrazione", + "operateSummary": "{{models}} models configured · nodes, activity and traces", + "allChats": "Tutte le chat" + }, + "slash": { + "agents": { + "label": "Agenti", + "desc": "I tuoi agenti salvati, trigger e skill" + }, + "mcp": { + "label": "Connettori MCP", + "desc": "Server MCP che le tue chat possono usare" + }, + "memory": { + "label": "Memoria", + "desc": "Raccolte in cui le tue chat possono cercare" + }, + "complete": "completa" } } diff --git a/core/http/react-ui/public/locales/it/library.json b/core/http/react-ui/public/locales/it/library.json new file mode 100644 index 000000000..4061c6da0 --- /dev/null +++ b/core/http/react-ui/public/locales/it/library.json @@ -0,0 +1,114 @@ +{ + "usedBy": { + "label": "Usato da", + "none": "Non ancora usato", + "line": "Usato da {{name}}", + "lineMore": "Usato da {{name}} +{{count}}", + "all": "tutte le competenze", + "allTitle": "Questo agente carica ogni competenza", + "remove": "Rimuovi da {{agent}}", + "open": "Apri {{agent}}", + "unread": "Impossibile leggere l'uso in questo momento.", + "unreadShort": "Uso sconosciuto" + }, + "notUsed": { + "lead": "Non ancora usata.", + "skill": "Resta nella libreria finché non la aggiungi da qualche parte. Provala prima, o aggiungila a un agente.", + "collection": "Resta nella libreria finché non la aggiungi da qualche parte. Cercala qui, o aggiungila a un agente.", + "skillShort": "Resta nell'elenco finché non la aggiungi a un agente." + }, + "tryInContext": "Provala in un contesto", + "add": { + "button": "Aggiungi a...", + "title": "Aggiungi {{name}} a", + "agents": "Agenti", + "noAgents": "Nessun agente ancora. Crea un agente, poi aggiungilo qui.", + "createAgent": "Crea un agente", + "added": "Aggiunto", + "action": "Aggiungi", + "addToAgent": "Aggiungi a {{agent}}", + "working": "Aggiunta in corso...", + "estimate": "Le stime usano i caratteri divisi per quattro.", + "toolsMode": "Caricata quando il modello la richiede", + "tokens": "+{{count}} token per messaggio (stima)", + "collectionNote": "Un agente può cercare nelle collezioni del proprio elenco.", + "kbOn": "Attiva la sua base di conoscenza", + "noAgentNamed": "Nessun agente si chiama {{name}}. Crea un agente con quel nome per usare questa collezione.", + "close": "Chiudi", + "skillNote": "Un agente può leggere le competenze del proprio elenco." + }, + "toasts": { + "skillAdded": "{{name}} aggiunta a {{agent}}", + "skillRemoved": "{{name}} rimossa da {{agent}}", + "skillsOff": "{{name}} rimossa da {{agent}}. Le competenze ora sono disattivate per questo agente.", + "collectionAdded": "{{name}} aggiunta a {{agent}}", + "collectionRemoved": "{{name}} rimossa da {{agent}}", + "undone": "Modifica annullata", + "updateFailed": "Impossibile aggiornare {{agent}}: {{message}}", + "undo": "Annulla", + "dismiss": "Chiudi" + }, + "simulate": { + "button": "Simula un messaggio", + "title": "Simula un messaggio", + "desc": "Scegli un contesto e vedi cosa caricherebbe. Nulla viene inviato a un modello.", + "close": "Chiudi", + "context": "Contesto", + "contextCollection": "Una collezione da sola", + "contextAgent": "Agente: {{name}}", + "collectionOn": "Collezione", + "noCollections": "Ancora nessuna collezione.", + "contextHintCollection": "Cerca nella collezione con il tuo messaggio e mostra i passaggi trovati, con i loro punteggi. Non sono coinvolti agenti o modelli.", + "contextHintAgent": "Mostra cosa questo agente tiene nel suo prompt, più i passaggi che la sua base di conoscenza aggiungerebbe per questo messaggio.", + "message": "Messaggio", + "messagePlaceholder": "Scrivi quello che una persona potrebbe chiedere", + "passages": "Passaggi", + "run": "Esegui", + "running": "In esecuzione...", + "onlyForTest": "Solo per questo test", + "removeExtra": "Togli {{name}} da questo test", + "tryAlso": "Prova qualcosa non ancora aggiunto", + "tryAlsoPick": "Scegli una competenza o una collezione", + "extraSkill": "Competenza {{name}}", + "extraCollection": "Collezione {{name}}", + "budgetTitle": "Quanto aggiunge questo contesto", + "budgetTotal": "Circa {{count}} token attorno al tuo messaggio", + "budgetMessage": "Il tuo messaggio: {{count}} token", + "budgetNote": "Stime: caratteri divisi per quattro. La finestra di contesto del modello non è nota qui, quindi non c'è una quota di un limite.", + "instructions": "Istruzioni", + "skills": "Competenze", + "memory": "Memoria", + "skillsTitle": "Competenze caricate da questo contesto", + "skillsNone": "Nessuna competenza è attiva per questo agente.", + "skillsPrompt": "Nel prompt di ogni messaggio.", + "skillsTools": "Letta solo quando il modello la richiede.", + "skillsBoth": "Nel prompt, e riletta quando il modello la richiede.", + "skillsAll": "Ogni competenza è attiva per questo agente.", + "skillMissing": "{{name}} è nell'elenco, ma nessuna competenza ha quel nome.", + "skillTokens": "{{count}} token", + "skillTokensTools": "0 all'inizio", + "memoryTitle": "Memoria che verrebbe inviata", + "memoryNone": "Nessuna collezione è nell'elenco di questo agente. Aggiungine una per usarla.", + "memoryNoCollection": "La base di conoscenza di questo agente è attiva, ma non esiste ancora una collezione chiamata {{name}}.", + "memoryTools": "Questo agente cerca solo quando il modello lo richiede. Nulla viene aggiunto al messaggio all'inizio.", + "memoryTesting": "Collezione {{name}}, solo per questo test", + "memoryFrom": "Da {{name}}", + "passagesCount": "{{count}} passaggi, ordinati per somiglianza", + "noPassages": "Nulla è stato trovato per questo messaggio.", + "pickMessage": "Scrivi prima un messaggio.", + "searchFailed": "La ricerca non è riuscita: {{message}}", + "score": "Punteggio {{score}}", + "tokens": "{{count}} token", + "noAgents": "Nessun agente ancora. Puoi comunque cercare in una collezione da sola.", + "backendNote": "Qui non è disponibile una simulazione a secco del modello, quindi non viene simulata alcuna risposta." + }, + "search": { + "failed": "Ricerca non riuscita: {{message}}" + }, + "agentBudget": "Le competenze selezionate aggiungono circa {{count}} token a ogni messaggio (stima: caratteri divisi per quattro).", + "agentKb": { + "reads": "Questo agente legge la collezione chiamata {{name}}.", + "exists": "Questo agente legge la collezione chiamata {{name}}. Puoi riempirla e cercarla in Memoria.", + "created": "Questo agente legge la collezione chiamata {{name}}. Viene creata al salvataggio." + } +} diff --git a/core/http/react-ui/public/locales/it/modelEditor.json b/core/http/react-ui/public/locales/it/modelEditor.json new file mode 100644 index 000000000..fdae3b53c --- /dev/null +++ b/core/http/react-ui/public/locales/it/modelEditor.json @@ -0,0 +1,6 @@ +{ + "proxyVision": { + "label": "Questo modello può guardare le immagini", + "hint": "Attivalo se il modello remoto accetta immagini. LocalAI non può verificarlo. Le immagini allegate vanno al modello insieme al messaggio." + } +} diff --git a/core/http/react-ui/public/locales/it/models.json b/core/http/react-ui/public/locales/it/models.json index b67d9da75..33125efe5 100644 --- a/core/http/react-ui/public/locales/it/models.json +++ b/core/http/react-ui/public/locales/it/models.json @@ -1,5 +1,6 @@ { "lifecycle": { + "supporting": "Trova un modello adatto a questa macchina, installalo e gestisci quelli che hai.", "title": "Models", "navLabel": "Model lifecycle", "views": { "explore": "Explore", "installed": "Installed" }, @@ -81,6 +82,10 @@ "delete": "Elimina" }, "filters": { + "sheetDone": "Mostra modelli", + "sheetTitle": "Filtri", + "sheetButtonCount": "Filtri ({{count}})", + "sheetButton": "Filtri", "all": "Tutti", "llm": "LLM", "image": "Immagine", diff --git a/core/http/react-ui/public/locales/it/nav.json b/core/http/react-ui/public/locales/it/nav.json index 0c5dd9334..58160af80 100644 --- a/core/http/react-ui/public/locales/it/nav.json +++ b/core/http/react-ui/public/locales/it/nav.json @@ -10,12 +10,13 @@ "changeLanguage": "Cambia lingua", "logout": "Esci", "accountSettings": "Impostazioni account", + "yourSettings": "Le tue impostazioni", "account": "Account", "accountFor": "Account: {{name}}", "sections": { "recognition": "Riconoscimento", "build": "Costruisci", - "operate": "Gestione", + "operate": "Amministrazione", "workspace": "Area di lavoro" }, "operate": { @@ -66,7 +67,14 @@ "logs": "Logs", "trafficModels": "Models", "gpuHost": "GPU and host", - "prometheus": "Prometheus" + "prometheus": "Prometheus", + "chats": "Chats", + "files": "File", + "triggers": "Trigger", + "mcpConnectors": "Connettori MCP", + "chatsAdmin": "Chat di tutti gli utenti", + "pages": "Pagine", + "allChats": "Tutte le chat" }, "footer": { "github": "GitHub", @@ -99,7 +107,15 @@ "import": "Aggiungi un modello da un file, un URL o un repository Hugging Face.", "voices": "Identifica chi parla e gestisci una libreria di profili vocali.", "faces": "Rileva, confronta e abbina volti.", - "addons": "Installa componenti aggiuntivi e apri le app che offrono." - } - } + "addons": "Installa componenti aggiuntivi e apri le app che offrono.", + "triggers": "Avvia una chat da sola, a orari prefissati o quando arriva un file." + }, + "operateSupporting": "Cosa gira su questo server, come viene usato e come è configurato." + }, + "pinnedPages": "Pagine fissate", + "new": "Nuovo", + "recentChats": "Chat recenti", + "newChat": "Nuova chat", + "untitledChat": "Chat senza titolo", + "chatRunning": "In corso" } diff --git a/core/http/react-ui/public/locales/it/operate.json b/core/http/react-ui/public/locales/it/operate.json new file mode 100644 index 000000000..078b60821 --- /dev/null +++ b/core/http/react-ui/public/locales/it/operate.json @@ -0,0 +1,57 @@ +{ + "backends": { + "filterLabel": "Filtra i backend", + "check": "Cerca aggiornamenti", + "reinstallBackend": "Reinstalla il backend", + "deleteBackend": "Elimina il backend", + "updateAllTitle": "Aggiorna tutti i backend", + "updateAllIntro": "Scegli quali backend aggiornare. Ognuno viene aggiornato da solo, uno dopo l'altro. Un errore non ferma gli altri.", + "updateSelected": "Aggiorna selezione", + "loadFailedTitle": "Impossibile caricare il catalogo dei backend.", + "filtersButton": "Filtri", + "filtersDone": "Fatto", + "filterGroups": { + "state": "Stato", + "type": "Tipo", + "show": "Mostra", + "library": "Libreria" + }, + "showVariants": "Varianti", + "showDevelopment": "Build di sviluppo", + "allLibraries": "Tutte le librerie", + "moreActions": "Altre azioni sui backend", + "installFromUrl": "Installa da URL…", + "manageLibraries": "Gestisci librerie…", + "count_one": "{{count}} backend", + "count_other": "{{count}} backend", + "countOf": "{{shown}} di {{total}} backend", + "activeFilters": "Filtri attivi", + "removeFilter": "Rimuovi filtro: {{name}}", + "checking": "Ricerca aggiornamenti…", + "updatesAvailable_one": "{{count}} aggiornamento disponibile", + "updatesAvailable_other": "{{count}} aggiornamenti disponibili", + "reviewUpdates": "Rivedi", + "close": "Chiudi", + "installedLoadFailedTitle": "Impossibile caricare i backend installati." + }, + "capacity": { + "diskChip": "Disco quasi pieno" + }, + "activity": { + "kind": { + "addon": "add-on" + }, + "verb": { + "upgrading": "Aggiornamento {{kind}}", + "failedUpgrade": "Impossibile aggiornare {{kind}}" + }, + "filter": { + "addons": "Add-on" + }, + "upgradeVersions": "da {{from}} a {{to}}", + "rowUpgraded": "Aggiornato in {{duration}}", + "rowUpgradedPlain": "Aggiornato", + "viewInAddons": "Vedi in Add-on", + "upgradeWaiting": "In attesa dell'avvio del servizio dei componenti aggiuntivi. Prossimo controllo tra {{seconds}} s." + } +} diff --git a/core/http/react-ui/public/locales/it/pages.json b/core/http/react-ui/public/locales/it/pages.json new file mode 100644 index 000000000..eacda2a60 --- /dev/null +++ b/core/http/react-ui/public/locales/it/pages.json @@ -0,0 +1,138 @@ +{ + "subtitle": "Piccoli strumenti creati per te dall'assistente. Si aprono qui, in una sandbox, e tengono i loro dati nei tuoi file.", + "card": { + "pin": "Fissa nella barra laterale", + "unpin": "Rimuovi dalla barra laterale", + "changedBy": { + "you": "Modificata {{when}} da te", + "assistant": "Modificata {{when}} dall'assistente" + }, + "menu": "Altro per {{title}}", + "files": "Mostra i suoi file", + "rename": "Rinomina", + "renameLabel": "Nuovo nome della cartella", + "renameHint": "Lettere minuscole, cifre e trattini.", + "renameSave": "Salva", + "renameCancel": "Annulla", + "renameFailed": "La pagina non è stata rinominata: {{message}}", + "delete": "Elimina pagina", + "deleteTitle": "Eliminare «{{title}}»?", + "deleteMessage": "La cartella {{folder}} va nel Cestino. Puoi ripristinarla da File.", + "deleted": "{{title}} è nel Cestino.", + "deleteFailed": "La pagina non è stata eliminata: {{message}}" + }, + "empty": { + "text": "Una pagina è un piccolo strumento, come una bacheca delle attività o una lista di lettura. Chiedine una all'assistente in una chat, oppure parti da una pagina pronta.", + "starting": "Copia della pagina", + "starterFailed": "La pagina non è stata copiata: {{message}}", + "ask": "Chiedi una pagina", + "askPrompt": "Crea una pagina che ", + "starterReading": "Parti da una lista di lettura", + "note": "Questa voce esce dalla barra laterale quando non hai pagine. Torna con la prossima.", + "learn": "Come funzionano le pagine" + }, + "askNew": "Chiedi una nuova pagina", + "yours": "Tue", + "filter": "Filtra le pagine", + "shared": { + "yours": "Tue", + "group": "Condivise con te", + "note": "Una pagina condivisa si avvia solo dopo che l'hai consentita. Se l'autore la cambia, ti viene chiesto di nuovo.", + "offNotice": "Le pagine condivise sono spente su questo server. Un amministratore le ha spente.", + "off": "Le pagine condivise sono spente su questo server. Un amministratore le ha spente.", + "gone": "Questa pagina non è più nella cartella condivisa, oppure non hai più accesso.", + "review": "Esamina", + "from": "Da {{author}}", + "menu": "Menu della pagina", + "badge": { + "notAllowed": "Non ancora consentita", + "changed": "Cambiata da quando l'hai consentita", + "allowed": "Consentita" + } + }, + "viewer": { + "stopAllowing": "Non consentire più questa pagina", + "authorChanged": "{{author}} ha cambiato questa pagina. La versione che hai consentito continua finché non esamini la modifica.", + "sandboxShared": "Isolata. Viene eseguita come te, solo in {{folder}}, e non raggiunge la rete." + }, + "trust": { + "title": "Aprire \"{{title}}\"?", + "desc": "Questa pagina viene da una cartella condivisa. Le pagine vengono eseguite come te, quindi controllala prima che parta.", + "who": "Scritta da {{author}}", + "whereLabel": "Cartella condivisa", + "whereWhen": ", modificata {{when}}", + "someone": "qualcuno", + "close": "Chiudi", + "canTitle": "Può", + "canStillTitle": "Può ancora", + "cannotTitle": "Non può", + "items": { + "files": "Leggere e scrivere file nella sua cartella", + "readOnly": "Puoi solo leggere questa cartella condivisa, quindi la pagina non può salvare le modifiche.", + "chat": "Scrivere un messaggio in una chat", + "chatNote": "Vedi il messaggio nella chat prima che l'assistente risponda.", + "noFiles": "Leggere gli altri tuoi file o chat", + "noNet": "Raggiungere la rete o altri siti", + "noNetNote": "Un limite: può caricare un altro indirizzo nel proprio riquadro. Questo invia una richiesta all'esterno, poi la pagina si ferma.", + "noTools": "Usare strumenti o cambiare le tue impostazioni" + }, + "warn": "Agisce come te dentro la sua cartella. Se {{author}} la cambia, ti viene chiesto di nuovo.", + "viewSource": "Vedi il codice", + "cancel": "Annulla", + "allowOnce": "Consenti una volta", + "allow": "Consenti", + "changed": { + "title": "\"{{title}}\" è cambiata", + "desc": "Hai consentito una versione precedente. Controlla cosa è cambiato prima che riparta.", + "who": "Cambiata da {{author}} {{when}}", + "allowed": "Hai consentito la versione del {{date}}", + "what": "Cosa è cambiato", + "same": "Quello che può fare è come prima.", + "warn": "Se la consenti, questa versione parte ogni volta che la apri, finché {{author}} non la cambia di nuovo.", + "view": "Vedi le modifiche", + "lines": "{{before}} → {{after}} righe", + "code": "È cambiato uno script, un foglio di stile o un modulo della pagina: {{files}}" + }, + "pill": { + "notYet": "Non ancora consentita", + "not": "Non consentita", + "once": "Consentita per questa visita", + "allowed": "Consentita", + "changed": "Cambiata da quando l'hai consentita" + }, + "from": "Da {{author}}", + "frame": { + "chrome": "Pagina condivisa da {{folder}}. Non è stata eseguita." + }, + "blocked": { + "title": "Questa pagina non è stata eseguita", + "body": "Hai scelto di non eseguirla. Viene dalla cartella condivisa {{folder}} di {{author}}. Non è stato eseguito nulla e non è stato salvato nulla.", + "review": "Esamina e consenti" + }, + "waiting": { + "title": "In attesa di te", + "body": "Nulla in questa pagina viene eseguito finché non la consenti." + }, + "stopped": { + "revoked": "Non consenti più questa pagina. Si è fermata e nulla viene eseguito finché non la consenti di nuovo.", + "refused": "Questa pagina non è più consentita, quindi è stata fermata. Esaminala per consentirla di nuovo.", + "off": "Le pagine condivise sono state spente su questo server, quindi questa pagina è stata fermata." + }, + "changedMeanwhile": "La pagina è cambiata mentre la esaminavi. Questa è la nuova versione.", + "sourceTitle": "Codice di \"{{title}}\"", + "sourceLines_one": "{{count}} riga", + "sourceLines_other": "{{count}} righe", + "sourceNote": "Il codice è mostrato come testo. Qui non viene eseguito nulla." + }, + "frame": { + "filesFailed": "Alcuni file accanto a questa pagina non sono stati caricati: {{files}}. La console del browser spiega il motivo.", + "tooLarge": "Questa pagina include più di {{limit}} di file dalla sua cartella, quindi alcuni sono stati esclusi. Carica i dati con fetch().", + "dismiss": "Chiudi" + }, + "link": { + "label": "La pagina vuole aprire un link", + "open": "Apri {{site}}", + "cancel": "Annulla", + "lookalike": "Questo indirizzo usa lettere di altri alfabeti e si legge come {{display}}. Potrebbe imitare un altro sito: controllalo prima di aprirlo." + } +} diff --git a/core/http/react-ui/public/locales/it/skills.json b/core/http/react-ui/public/locales/it/skills.json index 5a0c564a3..4d1b85361 100644 --- a/core/http/react-ui/public/locales/it/skills.json +++ b/core/http/react-ui/public/locales/it/skills.json @@ -1,79 +1,83 @@ { "title": "Competenze", - "subtitle": "Gestisci le competenze degli agenti (istruzioni e risorse riutilizzabili)", + "subtitle": "Istruzioni che tutte le tue chat possono usare quando un compito corrisponde. Gli agenti usano quelle che aggiungi loro.", "unavailable": { - "subtitle": "Il servizio competenze non è disponibile o l'indice è in ricostruzione. Riprova tra un momento.", - "retry": "Riprova" + "subtitle": "Le competenze richiedono File, e File è disattivato su questo server." }, "actions": { "newSkill": "Nuova competenza", "createSkill": "Crea competenza", - "import": "Importa", - "importing": "Importazione...", - "gitRepos": "Repo Git", "edit": "Modifica", - "delete": "Elimina", - "export": "Esporta", - "sync": "Sincronizza", - "addRepo": "Aggiungi repo", - "adding": "Aggiunta...", - "remove": "Rimuovi", - "enable": "Abilita", - "disable": "Disabilita" + "delete": "Elimina" }, "search": { "placeholder": "Cerca competenze..." }, - "git": { - "title": "Repository Git", - "description": "Aggiungi repository Git da cui ottenere le competenze. Le competenze appariranno nella lista dopo la sincronizzazione.", - "urlPlaceholder": "https://github.com/user/repo o git@github.com:user/repo.git", - "noRepos": "Nessun repo Git configurato. Aggiungine uno sopra.", - "disabled": "Disabilitato", - "removeRepo": "Rimuovi repo" - }, "card": { "noDescription": "Nessuna descrizione", "readOnly": "Sola lettura", "editTitle": "Modifica competenza", "deleteTitle": "Elimina competenza", - "exportTitle": "Esporta come .tar.gz" + "invalid": "Skipped: not a valid skill", + "invalidWhy": "Questa competenza è stata saltata: {{reason}}", + "shadowed": "Hidden by a {{layer}} skill", + "shadowedLong": "Hidden by a {{layer}} skill of the same name", + "providedBy": "Fornita da {{name}}", + "replacedBy": "Sostituita da {{name}}", + "notActive": "Non attiva: si usa la skill integrata", + "notActiveBy": "Non attiva: la fornisce {{name}}", + "fallback": "In uso perché la skill del componente {{name}} è disattivata o non in esecuzione" }, "empty": { "title": "Nessuna competenza trovata", "text": "Crea una competenza o importane una per iniziare.", - "noPersonal": "Non hai ancora competenze." - }, - "sections": { - "yourSkills": "Le tue competenze", - "otherUsersSkills": "Competenze di altri utenti" - }, - "deleteDialog": { - "title": "Elimina competenza", - "message": "Eliminare la competenza \"{{name}}\"? Questa azione non può essere annullata.", - "confirm": "Elimina" - }, - "removeRepoDialog": { - "title": "Rimuovi repository Git", - "message": "Rimuovere questo repository Git? Le competenze al suo interno non saranno più disponibili.", - "confirm": "Rimuovi" + "stepWrite": "Write a skill: a name, a line that says when to use it, and the instructions.", + "stepTry": "Check it in the list: it shows who uses it and what it says.", + "stepAdd": "Add it to an agent when you want it used." }, "toasts": { "loadFailed": "Caricamento competenze fallito", "deleted": "Competenza \"{{name}}\" eliminata", - "deleteFailed": "Eliminazione competenza fallita", - "exported": "Competenza \"{{name}}\" esportata", - "exportFailed": "Esportazione fallita", - "imported": "Competenza importata da \"{{file}}\"", - "importFailed": "Importazione fallita", - "loadReposFailed": "Caricamento repo Git fallito", - "repoAdded": "Repo Git aggiunto e in sincronizzazione", - "addRepoFailed": "Aggiunta repo fallita", - "synced": "Repo sincronizzato", - "syncFailed": "Sincronizzazione fallita", - "toggled": "Repo modificato", - "toggleFailed": "Modifica fallita", - "removed": "Repo rimosso", - "removeFailed": "Rimozione fallita" + "deleteFailed": "Eliminazione competenza fallita" + }, + "layers": { + "label": "Layer", + "personal": "Personal", + "shared": "Shared", + "builtin": "Built-in", + "folder": "Workspace", + "addon": "Componente" + }, + "pane": { + "loading": "Loading...", + "loadFailed": "The skill could not be read.", + "tokensExplain": "A chat reads a skill only when a task matches its description, so it adds nothing until then. About {{count}} tokens when read (estimate)." + }, + "edit": { + "back": "Back to skills", + "titleNew": "New skill", + "titleEdit": "Edit: {{name}}", + "basic": "Basic information", + "content": "Content", + "layer": "Layer", + "layerHelp": "Personal is yours alone. Shared is read by every user.", + "name": "Name (lowercase, hyphens only)", + "nameLocked": "Name cannot be changed after creation.", + "description": "Description (when to use it)", + "descriptionHelp": "A chat reads this line to decide whether the skill fits the task.", + "body": "Skill content (markdown)", + "cancel": "Cancel", + "save": "Save changes", + "create": "Create skill", + "saving": "Saving...", + "created": "Skill created", + "updated": "Skill updated", + "required": "A name and a description are required", + "saveFailed": "Save failed", + "loadFailed": "Failed to load the skill", + "readOnly": "Questa competenza qui è di sola lettura. Una competenza integrata viene con il server, e una competenza dei tuoi file si modifica in File." + }, + "load": { + "title": "Impossibile caricare le tue skill." } } diff --git a/core/http/react-ui/public/locales/it/swarm.json b/core/http/react-ui/public/locales/it/swarm.json new file mode 100644 index 000000000..dcc2c3c47 --- /dev/null +++ b/core/http/react-ui/public/locales/it/swarm.json @@ -0,0 +1,5 @@ +{ + "nodes": { + "retry": "Riprova" + } +} diff --git a/core/http/react-ui/public/locales/it/tools.json b/core/http/react-ui/public/locales/it/tools.json new file mode 100644 index 000000000..f8a055baf --- /dev/null +++ b/core/http/react-ui/public/locales/it/tools.json @@ -0,0 +1,468 @@ +{ + "steps": { + "label": "Avanzamento", + "setup": "Configura", + "check": "Verifica", + "run": "Esecuzione", + "result": "Risultato" + }, + "run": { + "stop": "Ferma", + "connecting": "Connessione al job. In attesa del primo evento di avanzamento.", + "progress": "Avanzamento", + "stages": "Fasi", + "step": "passo", + "of": "di", + "epoch": "epoca", + "tokens": "token/s", + "eta": "circa {{time}} rimanenti", + "etaSeconds": "circa {{seconds}} s rimanenti" + }, + "landing": { + "lede": "Cosa puoi creare su questa macchina, e di cosa ha bisogno ogni strumento per primo.", + "machine": "Questa macchina", + "gpu": "GPU", + "gpuFree": "{{free}} liberi di {{total}}", + "noGpu": "Nessuna GPU trovata", + "cluster": "{{total}} sul nodo migliore ({{node}})", + "clusterNode": "un worker", + "ram": "Memoria", + "ramFree": "RAM, {{free}} liberi", + "disk": "Disco dei modelli", + "diskFree": "liberi", + "backends": "Backend", + "noBackends": "nessuno installato per questi strumenti", + "open": "Apri", + "openTool": "Apri {{tool}}", + "install": "Installa", + "ready": "Pronto", + "needsBackend": "Serve un backend", + "experimental": "Sperimentale", + "needs": "Di cosa ha bisogno", + "flow": "Il percorso da un modello trovato a uno con cui puoi chattare", + "flowNote": "Ogni passo termina con un modello che puoi caricare.", + "now": { + "running": { + "fineTune": "Fine-tuning di {{model}} in corso", + "quantize": "Quantizzazione di {{model}} in {{type}} in corso" + }, + "failed": { + "fineTune": "Fine-tuning di {{model}} non riuscito", + "quantize": "Quantizzazione di {{model}} in {{type}} non riuscita" + } + }, + "groups": { + "make": { + "title": "Crea un modello", + "text": "Parti da un modello trovato e arriva a uno adatto che conosce i tuoi dati." + }, + "recognise": { + "title": "Riconosci", + "text": "Chi sta parlando, e di chi è il volto in una foto." + }, + "automate": { + "title": "Automatizza", + "text": "Assistenti, le loro istruzioni e la loro memoria." + } + }, + "sentence": { + "import": "Importa un modello da Hugging Face, da un URL, da un riferimento Ollama o OCI, o da un file su questo host. LocalAI sceglie il backend e scrive la configurazione.", + "quantize": "Rendi un modello più piccolo, così occupa meno memoria. Converte un modello Hugging Face in GGUF con la precisione che scegli.", + "fine-tune": "Insegna a un modello i tuoi dati con un adattatore, poi esportalo come modello con cui puoi chattare." + }, + "line": { + "import": { + "noSetup": "Nessuna configurazione. Il backend si scarica quando serve.", + "disk": "{{free}} liberi sul disco dei modelli" + }, + "quantize": { + "backend": "Backend {{name}} installato", + "backendMissing": "Il backend di quantizzazione non è installato", + "backendUnknown": "Impossibile leggere lo stato del backend", + "cpu": "Funziona sulla CPU, non serve una GPU" + }, + "fineTune": { + "backend": "Backend {{name}} installato", + "backendMissing": "Nessun backend di fine-tuning è installato", + "backendUnknown": "Impossibile leggere lo stato del backend", + "gpu": "Memoria GPU: {{name}}, {{free}} liberi di {{total}}", + "noGpu": "Nessuna GPU trovata. L'addestramento gira sulla CPU ed è lento.", + "cluster": "Memoria: {{total}} sul nodo migliore" + } + }, + "enable": { + "admin": "Installa il backend da Backend, e questo strumento sarà pronto.", + "user": "Chiedi a un amministratore di installare il backend." + } + }, + "checks": { + "heading": "Verifica prima di iniziare", + "live": "Si aggiorna mentre cambi la configurazione.", + "meterLabel": "{{used}} per cento in uso, {{free}} liberi di {{total}}", + "openBackends": "Apri Backend", + "barOk": "Nulla blocca l'avvio.", + "barWarn_one": "{{count}} avviso. Puoi comunque avviare.", + "barWarn_other": "{{count}} avvisi. Puoi comunque avviare.", + "title": { + "model": "Modello", + "data": "Dataset", + "backend": "Backend", + "memory": "Memoria", + "disk": "Disco", + "reward": "Funzioni di ricompensa", + "resume": "Ripresa", + "access": "Accesso al modello", + "type": "Tipo di quantizzazione", + "source": "Sorgente", + "name": "Nome" + }, + "fineTune": { + "modelOk": "Addestramento di {{model}}.", + "modelMissing": "Inserisci il modello da addestrare.", + "dataOk": "I dati arrivano da {{source}}.", + "dataMissing": "Aggiungi un dataset: un id Hugging Face o un file caricato.", + "backendOk": "{{name}} è installato.", + "backendMissing": "Nessun backend di fine-tuning è installato, quindi il job potrebbe non partire.", + "backendOther": "{{name}} non è tra i backend di fine-tuning installati ({{list}}).", + "backendUnknown": "Impossibile leggere i backend installati.", + "memoryGpu": "{{name}}: {{free}} liberi di {{total}}. Batch size {{batch}}, lunghezza della sequenza {{seq}}, accumulo del gradiente {{accum}}. Quanta memoria serva al job dipende dal modello e da queste impostazioni, e LocalAI non la stima prima dell'avvio del job.", + "memoryCluster": "{{total}} sul nodo migliore ({{node}}). Batch size {{batch}}, lunghezza della sequenza {{seq}}. Quanta memoria serva al job dipende dal modello e da queste impostazioni.", + "memoryCpu": "Nessuna GPU trovata. L'addestramento usa la CPU e {{free}} di RAM su {{total}} sono liberi. Aspettati lentezza. Batch size {{batch}}, lunghezza della sequenza {{seq}}.", + "memoryUnknown": "Questo account non può leggere la memoria della macchina. Batch size {{batch}}, lunghezza della sequenza {{seq}}. Quanta memoria serva al job dipende dal modello e da queste impostazioni.", + "disk": "{{free}} liberi sul disco dei modelli. Download e checkpoint richiedono spazio.", + "diskLow": "Solo {{free}} liberi sul disco dei modelli. Download e checkpoint richiedono spazio.", + "rewardMissing": "GRPO richiede almeno una funzione di ricompensa. Aggiungine una in Altre opzioni.", + "resume": "L'addestramento continua da {{path}}.", + "accessNone": "Nessun token impostato. Un modello con accesso limitato richiede un token Hugging Face in Altre opzioni.", + "accessSet": "È impostato un token Hugging Face." + }, + "quantize": { + "modelOk": "Quantizzazione di {{model}}.", + "modelMissing": "Inserisci il modello da quantizzare.", + "typeMissing": "Inserisci il tipo di quantizzazione personalizzato.", + "backendOk": "{{name}} è installato.", + "backendMissing": "Nessun backend di quantizzazione è installato, quindi il job potrebbe non partire.", + "backendOther": "{{name}} non è tra i backend di quantizzazione installati ({{list}}).", + "backendUnknown": "Impossibile leggere i backend installati.", + "memory": "{{free}} di RAM su {{total}} sono liberi. La conversione gira sulla CPU.", + "disk": "{{free}} liberi sul disco dei modelli. Download, file convertito e risultato vengono scritti su disco.", + "diskLow": "Solo {{free}} liberi sul disco dei modelli. Download, file convertito e risultato vengono scritti su disco.", + "accessNone": "Nessun token impostato. Un modello con accesso limitato richiede un token Hugging Face in Altre opzioni.", + "accessSet": "È impostato un token Hugging Face." + }, + "import": { + "sourceOk_huggingface": "Riconosciuto come repository Hugging Face. LocalAI controlla i suoi file quando importi.", + "sourceOk_url": "Formato URL riconosciuto. Il contenuto non è stato verificato.", + "sourceOk_config": "Riconosciuto come file di configurazione del modello. Viene letto e usato così com'è.", + "sourceOk_oci": "Riconosciuto come riferimento a un'immagine OCI.", + "sourceOk_ollama": "Riconosciuto come riferimento a un modello Ollama.", + "sourceOk_local": "Riconosciuto come percorso sulla macchina che esegue LocalAI.", + "sourceOk": "La sorgente è riconosciuta.", + "sourceUnknown": "Non sembra un formato noto a LocalAI. Ci proverà comunque, e potrebbe non riuscire.", + "backendDownload": "{{name}} non è ancora installato. LocalAI lo scarica prima.", + "backendChosen": "{{name}} è scelto e installato.", + "backendAuto": "LocalAI sceglie il backend dai file della sorgente quando l'importazione inizia.", + "nameTaken": "Un modello chiamato {{name}} è già installato.", + "nameFree": "{{name}} non è in uso.", + "nameAuto": "Il modello prende il nome dalla sorgente.", + "disk": "{{free}} liberi sul disco dei modelli. La dimensione del download è nota quando l'importazione inizia.", + "memory_gpu": "{{free}} di memoria GPU sono liberi ora. Quanta memoria serva a questo modello è noto quando l'importazione inizia.", + "memory_ram": "{{free}} di RAM sono liberi ora. Quanta memoria serva a questo modello è noto quando l'importazione inizia.", + "memory_cluster": "{{free}} sono disponibili sul nodo migliore. Quanta memoria serva a questo modello è noto quando l'importazione inizia.", + "memory": "{{free}} di memoria sono liberi ora. Quanta memoria serva a questo modello è noto quando l'importazione inizia." + } + }, + "form": { + "key": "Chiave", + "value": "Valore", + "addOption": "Aggiungi opzione", + "copy": "Copia negli appunti", + "more": "Altre opzioni", + "moreHint": "metodo, backend, adattatore, ottimizzatore, valutazione, funzioni di ricompensa", + "baseModel": "Modello base", + "model": "Modello", + "modelHint": "Un id Hugging Face, o un percorso su questo host.", + "data": "I tuoi dati", + "dataset": "Sorgente del dataset", + "datasetHint": "Un id di dataset Hugging Face, oppure carica un file.", + "split": "Suddivisione", + "upload": "Carica invece un file", + "uploadHint": ".json, .jsonl o .csv", + "removeFile": "Rimuovi il file", + "train": "Cosa addestrare", + "kind": "Tipo di addestramento", + "kinds": { + "adapter": { + "title": "Adattatore", + "text": "Addestra un piccolo adattatore e lascia invariati i pesi base. È la scelta abituale, e richiede poca memoria." + }, + "full": { + "title": "Modello completo", + "text": "Cambia ogni peso. Richiede diverse volte la memoria di un adattatore." + } + }, + "epochs": "Epoche", + "batch": "Batch size", + "lr": "Tasso di apprendimento", + "backend": "Backend", + "method": "Metodo di addestramento", + "adapterKind": "Tipo di adattatore", + "rank": "Rank", + "alpha": "Alpha", + "dropout": "Dropout", + "gradAccum": "Passi di accumulo del gradiente", + "warmup": "Passi di warmup", + "seqLen": "Lunghezza massima della sequenza", + "optimizer": "Ottimizzatore", + "maxSteps": "Passi massimi", + "maxStepsHint": "0 per automatico.", + "saveSteps": "Passi di salvataggio", + "saveLimit": "Limite totale di salvataggi", + "saveLimitHint": "0 mantiene ogni checkpoint.", + "weightDecay": "Weight decay", + "seed": "Seed", + "seedHint": "0 sceglie un seed casuale.", + "precision": "Precisione mista", + "auto": "Automatico", + "targetModules": "Moduli target", + "targetModulesHint": "Separati da virgola. Lascia vuoto per il valore predefinito del backend.", + "gradCheckpointing": "Gradient checkpointing (più lento, usa meno memoria)", + "token": "Token Hugging Face", + "tokenHint": "Serve solo per i modelli con accesso limitato.", + "evalEnable": "Valuta durante l'addestramento", + "evalStrategy": "Strategia di valutazione", + "evalSteps_": "Passi", + "evalEpoch": "Epoca", + "evalSteps": "Passi di valutazione", + "evalStepsHint": "0 coincide con i passi di salvataggio.", + "evalSplit": "Suddivisione per la valutazione", + "evalDataset": "Dataset di valutazione", + "evalDatasetPlaceholder": "Un dataset separato", + "evalRatio": "Rapporto di suddivisione automatica", + "rewards": "Funzioni di ricompensa (GRPO)", + "rewardsHint": "GRPO richiede almeno una funzione di ricompensa. Scegline una integrata o aggiungi la tua.", + "resume": "Riprendi dal checkpoint", + "clearCheckpoint": "Cancella il checkpoint", + "extra": "Opzioni extra", + "extraHint": "Coppie chiave e valore specifiche del backend.", + "summaryFull": "modello completo", + "summary_lora": "adattatore LoRA", + "summary_loha": "adattatore LoHa", + "summary_lokr": "adattatore LoKr", + "recipe": "Addestra un {{kind}} di {{model}} con {{method}}, {{epochs}} epoche sui dati {{data}}.", + "recipeEmpty": "Scegli un modello e un dataset per vedere cosa verrà addestrato." + }, + "rewards": { + "format_reward": "Verifica il formato ... seguito dalla risposta.", + "reasoning_accuracy_reward": "Confronta il contenuto di con la colonna delle risposte del dataset.", + "length_reward": "Valuta quanto la risposta è vicina a una lunghezza target.", + "xml_tag_reward": "Valuta i tag XML aperti e chiusi correttamente.", + "no_repetition_reward": "Penalizza gli n-gram ripetuti.", + "code_execution_reward": "Verifica che i blocchi di codice Python abbiano una sintassi valida.", + "param_target_length": "Lunghezza target", + "fnName": "Nome della funzione", + "fnBody": "Corpo della funzione", + "fnHint": "Riceve completions e kwargs e deve restituire una lista di float. Sono disponibili re, math, json e string.", + "add": "Aggiungi", + "cancel": "Annulla", + "custom": "Aggiungi una funzione di ricompensa personalizzata" + }, + "fineTune": { + "title": "Fine-tuning", + "lede": "Addestra un adattatore sui tuoi dati, poi esportalo come modello con cui puoi chattare.", + "importConfig": "Importa configurazione", + "exportConfig": "Esporta configurazione", + "newJob": "Nuovo job", + "badConfig": "Impossibile leggere il file. Usa una configurazione JSON esportata da questa pagina.", + "start": "Avvia il fine-tuning", + "starting": "Avvio in corso...", + "resume": "Riprendi l'addestramento", + "resuming": "Ripresa dal checkpoint:", + "clear": "Cancella", + "barNote": "Non cambia nulla finché non avvii.", + "jobMeta": "{{kind}} {{method}} su {{backend}}, avviato {{started}}", + "failed": "Addestramento non riuscito", + "failedAt": "Addestramento fermato al passo {{step}} di {{total}}", + "noMessage": "Il backend non ha dato alcun messaggio.", + "finished": "Addestramento completato.", + "finishedLoss": "Addestramento completato. L'ultima loss è stata {{loss}}.", + "stopped": "Il job è stato fermato. Un checkpoint viene conservato se hai scelto di farlo.", + "tryTitle": "Cosa fare dopo", + "tryText": "Il server ha segnalato che la memoria è esaurita. Ogni modifica qui sotto viene applicata a una copia della configurazione, e nulla parte finché non premi Avvia.", + "tryPlain": "Apri di nuovo la configurazione, cambia ciò che indica il messaggio e avvia un nuovo job.", + "fixBatch": "Riduci il batch size a 1", + "fixCheckpointing": "Attiva il gradient checkpointing (più lento, usa meno memoria)", + "retryChanges": "Apri la configurazione con queste modifiche", + "reuse": "Apri di nuovo la configurazione", + "resumeFrom": "Riprendi dal passo {{step}}", + "status": { + "queued": "In coda", + "loading_model": "Caricamento del modello", + "loading_dataset": "Caricamento del dataset", + "training": "Addestramento", + "saving": "Salvataggio", + "completed": "Completato", + "failed": "Non riuscito", + "stopped": "Fermato" + } + }, + "stop": { + "title": "Fermare l'addestramento?", + "text": "Un checkpoint salvato ora ti permette di riprendere o esportare ciò che è stato appreso finora.", + "keep": "Ferma e conserva un checkpoint", + "discard": "Ferma senza checkpoint", + "cancel": "Continua l'addestramento", + "cancelDelete": "Annulla" + }, + "chart": { + "loss": "Loss", + "learning_rate": "Tasso di apprendimento", + "grad_norm": "Norma del gradiente", + "metric": "Metrica", + "step": "passo", + "eval": "Loss di valutazione", + "waiting": "In attesa di altri passi.", + "window": "Passi da {{from}} a {{to}} di {{total}}", + "windowEval": "Passi da {{from}} a {{to}} di {{total}}. I punti vuoti sono la loss di valutazione.", + "aria": "{{metric}} lungo i passi di addestramento. L'ultimo valore è {{last}} al passo {{step}}.", + "readout": "passo {{step}}: {{value}}", + "dataTitle": "Mostra i numeri", + "emptyText": "Il grafico si disegna quando il job ha riportato due passi." + }, + "log": { + "title": "Log", + "note": "Ciò che il job ha riportato da quando è aperta questa pagina.", + "only": "Solo avvisi ed errori", + "copy": "Copia", + "copied": "Copiato", + "download": "Scarica", + "empty": "Ancora nulla da riportare.", + "noneFiltered": "Nessun avviso o errore." + }, + "checkpoints": { + "title": "Checkpoint", + "step": "Passo", + "epoch": "Epoca", + "loss": "Loss", + "path": "Percorso", + "actions": "Azioni", + "resume": "Riprendi", + "resumeTitle": "Riprendi l'addestramento da questo checkpoint", + "export": "Esporta", + "exportTitle": "Esporta questo checkpoint" + }, + "export": { + "title": "Esporta come modello", + "lede": "L'esportazione trasforma il risultato in un modello che LocalAI può caricare.", + "checkpoint": "Checkpoint", + "final": "Modello finale (cartella di output)", + "checkpointOption": "Passo {{step}} (loss {{loss}})", + "format": "Formato di esportazione", + "lora": "Adattatore LoRA", + "merged16": "Unito (16 bit)", + "merged4": "Unito (4 bit)", + "quant": "Quantizzazione", + "name": "Nome del modello", + "nameHint": "Lascia vuoto per generarlo dal job.", + "go": "Esporta", + "exporting": "Esportazione...", + "working": "Esportazione in corso...", + "done": "Modello esportato e registrato come \"{{name}}\"", + "failed": "Esportazione non riuscita: {{message}}", + "unknown": "errore sconosciuto", + "nextTitle": "{{name}} è ora un modello", + "nextText": "È registrato in LocalAI. Apri una chat con esso, oppure trovalo in Modelli.", + "chat": "Chatta con {{name}}", + "models": "Apri in Modelli", + "archive": "Scarica l'archivio" + }, + "jobs": { + "title": "Job precedenti", + "count_one": "{{count}} conservato su questo server", + "count_other": "{{count}} conservati su questo server", + "loading": "Caricamento dei job", + "emptyTitle": "Ancora nessun job di fine-tuning", + "emptyText": "Configurane uno sopra. Compare qui con il suo stato, e in seguito puoi riusarne la configurazione.", + "model": "Modello", + "kind": "Tipo", + "status": "Stato", + "started": "Avviato", + "actions": "Azioni", + "open": "Apri questo job", + "reuse": "Riusa", + "reuseTitle": "Usa la configurazione di questo job per un nuovo job", + "delete": "Elimina", + "deleteTitle": "Elimina questo job e i suoi dati", + "deleteDialogTitle": "Eliminare questo job?", + "deleteDialogText": "Questo elimina il job con i suoi checkpoint e l'eventuale modello esportato. Non può essere annullato." + }, + "quantize": { + "title": "Quantizza", + "lede": "Rendi un modello più piccolo, così occupa meno memoria. Il risultato è un file GGUF che puoi importare.", + "newJob": "Nuovo job", + "sectionModel": "Modello", + "model": "Modello", + "modelHint": "Un id Hugging Face, o un percorso su questo host.", + "sectionPrecision": "Precisione", + "type": "Tipo di quantizzazione", + "typeHint": "Un numero più piccolo significa un file più piccolo e una qualità leggermente inferiore.", + "custom": "Personalizzato...", + "customLabel": "Tipo personalizzato", + "customPlaceholder": "per esempio iq4_nl", + "moreHint": "backend, token Hugging Face", + "tokenHint": "Serve solo per i modelli con accesso limitato.", + "recipe": "Converti {{model}} in GGUF a {{type}}.", + "recipeEmpty": "Inserisci un modello per vedere cosa verrà creato.", + "start": "Quantizza ({{type}})", + "barNote": "Non cambia nulla finché non avvii.", + "jobMeta": "{{type}} su {{backend}}, avviato {{started}}", + "failed": "Quantizzazione non riuscita", + "finished": "Quantizzazione completata: {{type}}.", + "stopped": "Il job è stato fermato.", + "tryText": "Apri di nuovo la configurazione, cambia ciò che indica il messaggio e avvia un nuovo job. Un backend più recente può supportare un'architettura che uno più vecchio non supporta.", + "outputTitle": "Il risultato", + "outputText": "Un file GGUF a {{type}}. Importalo per usarlo in LocalAI, oppure scaricalo.", + "file": "File", + "importName": "Nome del modello", + "importNamePlaceholder": "Generato se vuoto", + "import": "Importa in LocalAI", + "importing": "Importazione...", + "importFailed": "Importazione non riuscita", + "importFailedWith": "Importazione non riuscita: {{message}}", + "download": "Scarica GGUF", + "nextTitle": "{{name}} è ora un modello", + "nextText": "È registrato in LocalAI. Apri una chat con esso, oppure trovalo in Modelli.", + "chat": "Chatta con {{name}}", + "models": "Apri in Modelli", + "imported": "importato come {{name}}", + "emptyTitle": "Ancora nessun job di quantizzazione", + "emptyText": "Configurane uno sopra. Compare qui con il suo stato.", + "deleteDialogText": "Questo elimina il job con i file convertiti. Non può essere annullato.", + "startFailed": "Impossibile avviare il job", + "stopFailed": "Impossibile fermare il job", + "deleteFailed": "Impossibile eliminare il job", + "status": { + "queued": "In coda", + "downloading": "Download in corso", + "converting": "Conversione", + "quantizing": "Quantizzazione", + "completed": "Completato", + "failed": "Non riuscito", + "stopped": "Fermato" + } + }, + "off": { + "badge": "Non disponibile per te", + "fineTune": { + "title": "Il tuo account non può fare fine-tuning", + "text": "Il fine-tuning è un permesso separato, e questo account non ce l'ha." + }, + "quantize": { + "title": "Il tuo account non può quantizzare", + "text": "La quantizzazione è un permesso separato, e questo account non ce l'ha." + }, + "admin": "Un amministratore può attivarlo da Utenti e chiavi.", + "askAdmin": "Chiedi a un amministratore di attivarlo per il tuo account.", + "back": "Vai alla Home" + } +} diff --git a/core/http/react-ui/public/locales/it/traffic.json b/core/http/react-ui/public/locales/it/traffic.json new file mode 100644 index 000000000..ec0761d0c --- /dev/null +++ b/core/http/react-ui/public/locales/it/traffic.json @@ -0,0 +1,12 @@ +{ + "overview": { + "usageErrorTitle": "Impossibile leggere l'utilizzo." + }, + "host": { + "errorTitle": "Impossibile leggere l'host." + }, + "models": { + "noMatch": "Nessun modello corrisponde a “{{query}}”." + }, + "clearSearch": "Cancella ricerca" +} diff --git a/core/http/react-ui/public/locales/it/upgrade.json b/core/http/react-ui/public/locales/it/upgrade.json new file mode 100644 index 000000000..340e20db4 --- /dev/null +++ b/core/http/react-ui/public/locales/it/upgrade.json @@ -0,0 +1,46 @@ +{ + "badge": { + "available": "Aggiornamento disponibile", + "availableVersion": "Aggiornamento disponibile: {{version}}" + }, + "trigger": { + "all": "Aggiorna tutto ({{count}})", + "running": "Aggiornamento {{current}} di {{total}}", + "summary": "{{done}} aggiornati, {{failed}} falliti, {{skipped}} saltati", + "summaryOk": "{{done}} aggiornati", + "review": "Rivedi" + }, + "dialog": { + "title": "Aggiorna tutto", + "intro": "Scegli cosa aggiornare. Ogni elemento viene aggiornato da solo, uno dopo l'altro. Un errore non ferma gli altri.", + "selection": "Selezione", + "selectAll": "Seleziona tutto", + "selectNone": "Deseleziona tutto", + "selectedCount_one": "{{count}} selezionato", + "selectedCount_other": "{{count}} selezionati", + "item": "Aggiorna {{name}}", + "versions": "{{from}} a {{to}}", + "blocked": "Non può partire", + "start": "Aggiorna selezione", + "starting": "Aggiornamento...", + "retry": "Riprova i falliti ({{count}})", + "again": "Nuova selezione", + "close": "Chiudi", + "closeRunning": "Chiudi (continua)", + "runningNote": "Chiudere questa finestra non ferma gli aggiornamenti.", + "progress": "Aggiornamento {{current}} di {{total}}", + "summary": "{{done}} aggiornati, {{failed}} falliti, {{skipped}} saltati", + "empty": "Niente da aggiornare." + }, + "item": { + "queued": "In coda", + "upgrading": "In aggiornamento", + "done": "Fatto", + "failed": "Fallito", + "skipped": "Saltato" + }, + "reason": { + "checkAgain": "Controlla di nuovo", + "checking": "Controllo..." + } +} diff --git a/core/http/react-ui/public/locales/it/workspace.json b/core/http/react-ui/public/locales/it/workspace.json new file mode 100644 index 000000000..795b9eb2d --- /dev/null +++ b/core/http/react-ui/public/locales/it/workspace.json @@ -0,0 +1,671 @@ +{ + "wellKnown": { + "revert": "Ripristina la versione precedente", + "view": "Vista", + "form": "Modulo", + "save": "Salva", + "cancel": "Annulla", + "notSet": "Non impostato", + "moveUp": "Sposta su", + "moveDown": "Sposta giù", + "remove": "Rimuovi", + "savedAt": "Salvato alle {{time}}", + "notSaved": "Non ancora salvato.", + "notSavedPrefilled": "Non ancora salvato. Alcuni valori sono già compilati per te; Salva li mantiene.", + "loadFailed": "Impossibile caricare queste impostazioni. Ricarica la pagina per riprovare.", + "invalid": "Queste impostazioni contengono errori e non esiste una versione valida precedente.", + "invalidFallback": "Queste impostazioni contengono errori. Qui vedi la loro ultima versione valida.", + "conflict": "Queste impostazioni sono cambiate mentre le modificavi, probabilmente dall’assistente o in un’altra scheda. I valori più recenti sono:", + "useTheirs": "Usa i valori più recenti", + "keepMine": "Mantieni le mie modifiche", + "editWithAssistant": "Modifica con l'assistente", + "from": { + "account": "Dal tuo account", + "browser": "Dal tuo browser" + }, + "check": { + "required": "Obbligatorio.", + "tooLong": "Massimo {{max}} caratteri.", + "format": "Questo valore ha un formato errato." + }, + "source": { + "label": "Sorgente", + "hint": "Le impostazioni come testo. Al salvataggio vengono controllate allo stesso modo.", + "notJson": "Il testo ha un errore di sintassi. Controlla virgolette, virgole e parentesi graffe.", + "notObject": "Il testo deve iniziare con { e finire con }." + }, + "persona": { + "title": "Assistente", + "lede": "Nome e istruzioni che l'assistente usa in ogni chat." + }, + "user": { + "title": "Tu", + "lede": "L'assistente legge questo per sapere con chi parla. Arriva al modello in ogni chat." + }, + "ui": { + "title": "Aspetto", + "lede": "Come appare LocalAI per te. Le modifiche si vedono subito; Salva le mantiene." + }, + "shortcuts": { + "title": "Scorciatoie", + "lede": "I suggerimenti sopra il campo di scrittura di ogni chat.", + "edit": "Modifica scorciatoie", + "defaults": "Scorciatoie predefinite", + "none": "Nessuna scorciatoia. Aggiungine una, o salva un elenco vuoto per nasconderle.", + "label": "Etichetta", + "prompt": "Prompt", + "send": "Invia subito", + "add": "Aggiungi scorciatoia", + "invalid": "Alcune scorciatoie contengono errori.", + "assistantPrompt": "Modifica le mie scorciatoie: " + }, + "home": { + "editLayout": "Modifica layout", + "ask": "Chiedi all'assistente", + "askPrompt": "Modifica la mia pagina Home: ", + "add": "Aggiungi widget", + "newTitle": "Nuovo widget", + "noWidgets": "Nessun widget ancora.", + "type": "Tipo", + "title": "Titolo", + "source": "File dati", + "noSourceNeeded": "Nessun file dati necessario", + "types": { + "list": "Elenco", + "metric": "Metrica", + "chats": "Ultime chat", + "page": "Pagina" + }, + "left": "{{count}} rimanenti", + "fromChats": "dalle chat", + "noChats": "Nessuna chat ancora.", + "noData": "Nessun dato ancora. L'assistente può scrivere", + "nothing": "Niente qui.", + "noSource": "Questo widget non ha un file dati. Sceglierne uno in Modifica layout.", + "broken": "I dati di questo widget contengono errori e non esiste una versione valida precedente.", + "stale": "Sono mostrati gli ultimi dati validi. I dati più recenti contengono errori.", + "updated": "Aggiornato {{when}}", + "footer": { + "noData": "Ancora nessun dato.", + "invalid": "I dati contengono un errore. È mostrata l’ultima versione corretta.", + "revert": "Ripristina", + "missing": "Mancano i dati di questo widget.", + "fix": "Chiedi all'assistente di correggerlo", + "fixPrompt": "Il widget della Home legge {{file}}, che manca o è rotto. Correggilo: ", + "waiting": "In attesa che tu crei il suo trigger nella chat.", + "openChat": "Apri la chat", + "running": "Aggiornamento in corso…", + "openRun": "Apri l'esecuzione", + "paused": "In pausa · era {{schedule}}", + "resume": "Riprendi", + "failedEmpty": "L'aggiornamento non è riuscito e non ci sono ancora dati.", + "details": "Dettagli", + "retry": "Riprova", + "stale": "L'ultimo aggiornamento non è riuscito. Vengono mostrati i dati di {{when}}.", + "staleToday": "L'ultimo aggiornamento non è riuscito. Vengono mostrati dati precedenti di oggi.", + "earlier": "prima", + "firstRun": "Prima esecuzione {{when}}", + "firstRunFile": "Si avvia quando arriva un file", + "runNow": "Esegui ora", + "fresh": "{{schedule}} · aggiornato {{time}}", + "pause": "Pausa", + "fromPage": "Dalla pagina {{title}}", + "fromFile": "Dai tuoi file", + "next": "Prossima esecuzione {{when}}", + "cost": "circa {{tokens}} token per esecuzione", + "costRemote": "circa {{tokens}} token per esecuzione su un modello remoto", + "cadence": { + "paused": "In pausa", + "hourly": "Ogni ora", + "daily": "Ogni giorno", + "weekdays": "Feriali", + "weekly": "Ogni settimana", + "monthly": "Ogni mese", + "file": "A ogni nuovo file" + }, + "scheduleNote_one": "{{count}} widget si aggiorna secondo un orario. Le sue esecuzioni usano i tuoi modelli e contano nel tuo utilizzo.", + "scheduleNote_other": "{{count}} widget si aggiornano secondo un orario. Le loro esecuzioni usano i tuoi modelli e contano nel tuo utilizzo." + }, + "opensSite": "{{host}}: si apre in una nuova scheda", + "linkAsk": "Questo widget vuole aprire un link", + "invalid": "Il layout della tua Home contiene errori e non esiste una versione valida precedente.", + "invalidFallback": "Il layout della tua Home contiene errori. Home mostra la sua ultima versione valida.", + "page": { + "gone": "Questa pagina non è più disponibile", + "goneHint": "La pagina \"{{name}}\" è stata spostata o eliminata, oppure non puoi più aprirla.", + "noWidget": "Questa pagina non ha una vista widget", + "noWidgetHint": "Chiedi all’assistente di dare alla pagina \"{{name}}\" una vista compatta per la Home.", + "off": "Le pagine condivise sono disattivate", + "offHint": "Un amministratore ha disattivato le pagine condivise su questo server.", + "fix": "Personalizza la Home", + "failed": "La pagina non si è caricata", + "retry": "Riprova", + "changed": "Questa pagina è cambiata da quando l'hai consentita", + "notAllowed": "Non hai ancora consentito questa pagina", + "allowHint": "Viene da una cartella condivisa. Controllala prima che venga eseguita a tuo nome.", + "allow": "Consenti questa pagina", + "frameTitle": "{{title}} (pagina nella Home)", + "refresh": "Aggiorna", + "open": "Apri la pagina", + "add": "Aggiungi una pagina", + "limit": "La Home mostra al massimo {{max}} pagine; rimuovine una per aggiungerne un'altra.", + "noneEligible": "Nessuna pagina ha ancora una vista compatta per la Home. Chiedi all’assistente di aggiungerne una a una pagina.", + "pick": "Pagina", + "unavailable": "{{name}} (non disponibile)", + "sharedLabel": "condivisa", + "sizes": { + "small": "Piccolo", + "medium": "Medio", + "large": "Grande" + } + } + }, + "fields": { + "persona": { + "name": { + "label": "Nome", + "help": "Il nome con cui l'assistente si presenta." + }, + "system_prompt": { + "label": "Prompt di sistema", + "help": "Aggiunto a ogni chat, dopo il prompt della chat e le istruzioni dell'agente." + } + }, + "user": { + "name": { + "label": "Nome", + "help": "Come ti chiama l'assistente." + }, + "language": { + "label": "Lingua", + "help": "La lingua in cui risponde l'assistente." + }, + "bio": { + "label": "Su di te", + "help": "Alcune righe che l'assistente legge in ogni chat." + }, + "timezone": { + "label": "Fuso orario", + "help": "Per date e orari nelle risposte." + } + }, + "ui": { + "theme": { + "label": "Tema", + "help": "Sistema segue l'impostazione del tuo dispositivo.", + "options": { + "dark": "Scuro", + "light": "Chiaro", + "system": "Sistema" + } + }, + "accent": { + "label": "Colore di accento", + "help": "Per pulsanti e link. Vuoto mantiene il predefinito." + }, + "density": { + "label": "Densità", + "help": "Compatta mostra di più in una schermata.", + "options": { + "comfortable": "Confortevole", + "compact": "Compatta" + } + }, + "defaultModel": { + "label": "Modello predefinito", + "help": "Il modello con cui inizia una nuova chat." + } + } + }, + "saveInvalid": "Alcuni valori non sono validi. Controllali e salva di nuovo.", + "saveTooLarge": "Queste impostazioni sono troppo grandi per essere salvate. Rimuovi alcune voci e riprova.", + "saveFailed": "Impossibile salvare. Riprova.", + "details": "Dettagli" + }, + "discovery": { + "ex": { + "title": "Rendi LocalAI tuo", + "lede": "Dillo in una chat. L’assistente lo salva, e puoi modificarlo o annullarlo.", + "hide": "Nascondi", + "hiddenToast": "Esempi nascosti.", + "saveFailed": "Non salvato: {{message}}", + "card1": { + "kicker": "Tu e l'assistente", + "prompt": "Chiamami {{firstName}} e dammi risposte brevi", + "promptNoName": "Ricorda il mio nome e dammi risposte brevi", + "what": "Salva il tuo nome e come risponde l’assistente" + }, + "card2": { + "kicker": "Una pagina", + "prompt": "Crea una bacheca delle attività per me", + "what": "Aggiunge una pagina che apri dalla barra laterale" + }, + "card3": { + "kicker": "Ogni mattina", + "prompt": "Ogni mattina, riassumi qui la mia posta in arrivo", + "promptNoMail": "Ogni mattina, elenca qui le chat di ieri", + "what": "Aggiunge un widget in Home e un trigger che confermi" + }, + "quote": "«{{text}}»", + "donePrefix": "Fatto.", + "doneLink": "Modificalo in Account", + "ready": "Oppure parti da una pagina pronta:", + "readyTasks": "Bacheca attività", + "readyReading": "Lista di lettura", + "readyFailed": "La pagina non è stata copiata: {{message}}", + "learn": "Come funziona", + "inComposer": "Modificalo se vuoi, poi premi Invio. Non viene salvato nulla finché non lo invii.", + "live": "Testo aggiunto alla casella del messaggio." + }, + "home": { + "customise": "Personalizza Home" + }, + "action": { + "undo": "Annulla", + "openAccount": "Apri in Account", + "openPage": "Apri la pagina", + "openHome": "Apri Home", + "turnBackOn": "Riattivali in Account" + }, + "change": { + "profilePersona": "Il tuo nome e le tue istruzioni per l’assistente sono salvati. Ogni chat li usa dal tuo prossimo messaggio.", + "profile": "Salvato nel tuo profilo.", + "persona": "Le istruzioni dell’assistente sono salvate. Ogni chat le usa dal tuo prossimo messaggio.", + "shortcuts": "Le tue scorciatoie sono salvate.", + "home": "La tua pagina Home è salvata. Home mostra la modifica.", + "ui": "Le tue impostazioni di aspetto sono salvate.", + "pageCreated": "Creata la pagina {{title}}.", + "pageChanged": "Modificata la pagina {{title}}.", + "offersOff": "I suggerimenti dell'assistente sono disattivati.", + "undoing": "Annullamento…", + "undone": "Annullato.", + "undoFailed": "Non annullato: {{message}}" + }, + "toast": { + "shortcutAdded": "Aggiunto alla tua casella di scrittura: {{label}}", + "pageCreated": "{{title}} è in Pages, nella barra laterale", + "open": "Apri", + "homeChanged": "Home aggiornata.", + "themeChanged": "Tema cambiato." + }, + "files": { + "learn": "Questi file cambiano il modo in cui LocalAI lavora per te.", + "tag": { + "profile": "Il tuo profilo", + "persona": "Assistente", + "shortcuts": "Scorciatoie", + "home": "Layout di Home", + "ui": "Aspetto", + "page": "Pagina" + } + }, + "privacy": "L'assistente riceve il tuo nome, questo testo, la tua lingua e il tuo fuso orario all'inizio di ogni chat. Lascia un campo vuoto per tenerlo fuori.", + "versions": { + "label": "Versioni", + "title": "Versioni precedenti", + "loading": "Lettura della cronologia…", + "none": "Nessuna versione precedente.", + "current": "attuale", + "invalid": "contiene un errore", + "byAssistant": "Dall'assistente", + "byAssistantIn": "Dall'assistente in «{{chat}}»", + "byYou": "Da te", + "undo": "Un annullamento", + "upload": "Caricato", + "revert": "Ripristina", + "note": "Ripristinare mantiene la versione più recente in questo elenco.", + "invalidBadge": "Non valido", + "revertGood": "Ripristina l'ultima versione valida" + }, + "help": { + "title": "Aiuto e suggerimenti", + "sub": "I modi in cui LocalAI ti mostra cosa sa fare. Disattiva ciò che non ti serve.", + "offers": "Proposte dell'assistente", + "offersHint": "Al massimo una al giorno, mai durante un'attività: per esempio, per ricordare qualcosa che gli hai detto.", + "new": "Note sulle novità", + "newHint": "Una breve etichetta «Nuovo» nella barra laterale la prima volta che compare qualcosa.", + "examples": "Esempi in Home", + "examplesShown": "Le schede «Rendi LocalAI tuo» compaiono in Home finché hai ancora qualcosa da provare.", + "examplesHidden": "Hai nascosto le schede «Rendi LocalAI tuo».", + "showExamples": "Mostra di nuovo gli esempi", + "examplesBack": "Gli esempi sono tornati in Home.", + "dismissed": "Cosa ha imparato l'assistente", + "dismissedHint_one": "Hai rifiutato {{count}} proposta. Resta nascosta finché non la reimposti.", + "dismissedHint_other": "Hai rifiutato {{count}} proposte. Restano nascoste finché non le reimposti.", + "forget": "Dimentica ciò che l'assistente ha imparato sui miei suggerimenti", + "forgotten": "Fatto. L'assistente potrà riproporre.", + "learn": "Scopri cosa puoi cambiare:", + "dismissedNone": "Ancora niente. Quando rispondi a una proposta, l'assistente la ricorda qui." + } + }, + "tabs": { + "shared": "Condivisi", + "trashCount_one": "{{count}} elemento nel Cestino", + "trashCount_other": "{{count}} elementi nel Cestino" + }, + "trash": { + "empty": "Il cestino è vuoto.", + "restore": "Ripristina", + "emptyAll": "Svuota il cestino", + "purge": "Elimina definitivamente", + "purgeNamed": "Elimina {{name}} definitivamente", + "files_one": "{{count}} file", + "files_other": "{{count}} file", + "folders_one": "{{count}} cartella", + "folders_other": "{{count}} cartelle", + "listJoin": ", ", + "summary": "Nel cestino: {{what}}.", + "removed": "Eliminato definitivamente: {{what}}.", + "confirmAll": { + "title": "Svuotare il cestino?", + "message": "Verranno eliminati definitivamente {{what}}, con le versioni precedenti. L'operazione non può essere annullata." + }, + "confirmOne": { + "title": "Eliminare definitivamente?", + "message": "{{path}}: verranno eliminati definitivamente {{what}}, con le versioni precedenti. L'operazione non può essere annullata." + } + }, + "shared": { + "adminTitle": "Cartelle condivise", + "adminLede": "Cartelle usate da più persone. I membri aprono le loro pagine e le loro chat possono leggere o modificare i file.", + "userTitle": "Condivisi con te", + "userLede": "Le cartelle che un amministratore ha condiviso con te. Le loro pagine sono in Pagine e le tue chat possono usare i file.", + "new": "Nuova cartella condivisa", + "nameLabel": "Nome della cartella", + "nameHint": "Fino a 64 lettere, cifre, spazi, punti, trattini o trattini bassi. Inizia con una lettera o una cifra.", + "create": "Crea", + "cancel": "Annulla", + "rename": "Rinomina", + "renameSave": "Salva", + "remove": "Rimuovi", + "manage": "Membri", + "menu": "Altre azioni per {{name}}", + "pages_one": "{{count}} pagina", + "pages_other": "{{count}} pagine", + "pagesNone": "Nessuna pagina", + "people_one": "{{count}} persona", + "people_other": "{{count}} persone", + "membersNone": "Nessun membro", + "everyone": "Tutti", + "everyoneOption": "Tutti (tutti gli utenti)", + "everyoneHint": "Tutti gli utenti, anche quelli che arriveranno dopo", + "everyoneCan": { + "read": "Tutti possono leggere", + "write": "Tutti possono modificare" + }, + "mode": { + "read": "Può leggere", + "write": "Può modificare" + }, + "addTitle": "Aggiungi un membro", + "person": "Persona", + "access": "Accesso", + "accessFor": "Accesso di {{name}}", + "add": "Aggiungi", + "allAdded": "Sono già tutti membri.", + "usersUnavailable": "Non è stato possibile caricare l’elenco degli utenti. Puoi comunque aggiungere Tutti.", + "membersTitle": "Membri", + "noMembers": "Nessuno può ancora usare questa cartella. Aggiungi una persona o Tutti.", + "removeMember": "Rimuovi {{name}}", + "paneNote": "La modifica vale subito. Una chat aperta la vede la prossima volta che si apre.", + "created": "Creata «{{name}}». Aggiungi le persone che possono usarla.", + "removed": "Rimossa «{{name}}». Le sue pagine chiedono di nuovo il consenso prima di avviarsi.", + "restored": "«{{name}}» è tornata, con i suoi membri.", + "memberAdded": "{{name}}: {{mode}}.", + "memberRemoved": "{{name}} non può più usare «{{folder}}».", + "failed": "Non riuscito: {{message}}", + "loadFailed": "Impossibile caricare le cartelle condivise.", + "emptyAdminTitle": "Ancora nessuna cartella condivisa", + "emptyAdminBody": "Una cartella condivisa contiene file e pagine che altre persone possono leggere o modificare. Creane una, poi aggiungi i membri.", + "factMembers": "Dai a una persona o a tutti l’accesso in lettura o modifica", + "factChats": "Le chat dei membri vedono i file della cartella", + "factUndo": "La rimozione di una cartella si può annullare", + "emptyUserTitle": "Ancora nulla è condiviso con te", + "emptyUserBody": "Quando un amministratore condivide una cartella con te, compare qui e le sue pagine in Pagine." + }, + "preview": { + "label": "Anteprima", + "failed": "Non è stato possibile mostrare questo file.", + "loading": "Caricamento dell’anteprima di {{name}}", + "failedTitle": "Impossibile mostrare {{name}}.", + "tooLarge": "Questo file è troppo grande per l’anteprima. Scaricalo per aprirlo.", + "none": "Non c’è un’anteprima per questo tipo di file.", + "typeFile": "File {{ext}}", + "typeUnknown": "File", + "sheets": "Fogli", + "sheetLabel": "Foglio {{name}}", + "sheetEmpty": "Questo foglio è vuoto.", + "row": "Riga", + "showingRows": "Mostrate {{rows}} righe su {{total}}", + "showingCols": "e {{cols}} colonne su {{total}}.", + "downloadRest": "Scarica il file per il resto.", + "textOnly": "Anteprima del testo, formattazione non mostrata.", + "truncated": "Il testo è troncato.", + "noText": "Questo file non ha testo da mostrare.", + "slide": "Diapositiva {{n}}", + "notes": "Note del relatore", + "pdfTitle": "PDF {{name}}", + "pdfFallback": "Se il PDF non si vede qui, scaricalo.", + "htmlTitle": "Anteprima di {{name}}", + "imageFailed": "Impossibile mostrare l’immagine.", + "zoomIn": "Mostra a grandezza reale", + "zoomOut": "Adatta al pannello", + "items_one": "{{count}} elemento", + "items_other": "{{count}} elementi", + "textCut": "Sono mostrati i primi {{size}}.", + "actions": "Azioni del file" + }, + "managed": { + "group": "Gestite da LocalAI", + "marker": "gestita da LocalAI", + "about": { + "localai": "Impostazioni che LocalAI legge: persona, profilo, scorciatoie, Home, tema.", + "chats": "Una cartella per chat, con i file della chat." + }, + "confirm": { + "deleteTitle": "Spostare {{name}} nel Cestino?", + "renameTitle": "Rinominare {{name}}?", + "delete": "Sposta nel Cestino", + "rename": "Rinomina", + "restore": "Puoi ripristinarla dal Cestino.", + "renameNote": "LocalAI la legge solo con questo nome: dopo averla rinominata è come se non ci fosse più." + }, + "loses": { + "settings": "LocalAI dimentica la tua persona, il tuo profilo, le tue scorciatoie, il layout della Home e il tuo tema.", + "chats": "Ogni chat perde i suoi file: quello che hai allegato e quello che l'assistente ha scritto lì.", + "persona": "L'assistente perde il suo nome e le sue istruzioni.", + "user": "L'assistente dimentica quello che gli hai detto di te e le tue risposte alle sue proposte.", + "shortcuts": "Il campo di scrittura mostra di nuovo le scorciatoie predefinite.", + "home": "La Home perde i suoi widget.", + "ui": "Tema, colore, densità, modello predefinito e pagine fissate tornano ai valori predefiniti." + } + }, + "newFile": "Nuovo file", + "newFolderName": "Nuova cartella", + "newFileName": "Senza titolo.md", + "fileName": "Nome del file", + "downloadOne": "Scarica {{name}}", + "moveTo": "Sposta in…", + "uploadMenu": "Altre opzioni di caricamento", + "uploadFiles": "Carica file", + "uploadFolder": "Carica una cartella", + "loading": "Caricamento della cartella", + "loadFailed": "Impossibile aprire {{folder}}.", + "types": { + "folder": "Cartella", + "image": "Immagine", + "pdf": "PDF", + "sheet": "Foglio di calcolo", + "doc": "Documento", + "slides": "Presentazione", + "archive": "Archivio", + "audio": "Audio", + "video": "Video", + "code": "Codice", + "text": "Testo", + "file": "File" + }, + "columns": { + "name": "Nome", + "modified": "Modificato", + "type": "Tipo", + "size": "Dimensione", + "actions": "Azioni" + }, + "view": { + "label": "Visualizzazione", + "list": "Elenco", + "grid": "Miniature" + }, + "select": { + "one": "Seleziona {{name}}", + "all": "Seleziona tutto", + "bar": "Elementi selezionati", + "clear": "Annulla la selezione", + "count_one": "{{count}} selezionato", + "count_other": "{{count}} selezionati", + "start": "Seleziona" + }, + "menu": { + "label": "Azioni per {{name}}", + "more": "Altre azioni per {{name}}", + "openFolder": "Apri", + "preview": "Anteprima", + "downloadMany_one": "Scarica {{count}} file", + "downloadMany_other": "Scarica {{count}} file", + "moveMany_one": "Sposta {{count}} elemento in…", + "moveMany_other": "Sposta {{count}} elementi in…", + "deleteMany_one": "Sposta {{count}} elemento nel Cestino", + "deleteMany_other": "Sposta {{count}} elementi nel Cestino" + }, + "keys": { + "delete": "Canc" + }, + "crumbs": { + "hidden": "Mostra le cartelle superiori" + }, + "naming": { + "empty": "Inserisci un nome.", + "slash": "Un nome non può contenere / o \\.", + "taken": "In questa cartella esiste già qualcosa con questo nome." + }, + "toast": { + "undo": "Annulla", + "deletedOne": "{{name}} spostato nel Cestino.", + "deleted_one": "{{count}} elemento spostato nel Cestino.", + "deleted_other": "{{count}} elementi spostati nel Cestino.", + "restored": "Ripristinato.", + "renamed": "Rinominato in {{name}}.", + "renameUndone": "Nome ripristinato.", + "movedOne": "{{name}} spostato in {{folder}}.", + "moved_one": "{{count}} elemento spostato in {{folder}}.", + "moved_other": "{{count}} elementi spostati in {{folder}}.", + "moveUndone": "Rimesso al suo posto." + }, + "search": { + "placeholder": "Filtra questa cartella", + "resultsFor": "File che contengono “{{query}}”", + "back": "Torna alla cartella", + "noneBody": "Nessun nome o testo nei tuoi file corrisponde. Prova con altre parole.", + "page": "pagina {{n}}", + "sheet": "foglio “{{name}}”", + "slide": "diapositiva {{n}}", + "indexing_one": "{{count}} documento è ancora in indicizzazione: ripeti la ricerca tra poco per includerlo.", + "indexing_other": "{{count}} documenti sono ancora in indicizzazione: ripeti la ricerca tra poco per includerli.", + "limited": "Risultati limitati: la ricerca si è fermata ai suoi limiti prima di esaminare tutti i file. Cerca in una cartella o usa altre parole per vedere il resto.", + "filtering_one": "{{count}} elemento in questa cartella corrisponde a “{{query}}”.", + "filtering_other": "{{count}} elementi in questa cartella corrispondono a “{{query}}”.", + "everywhere": "Cerca “{{query}}” in tutti i file", + "everywhereShort": "Cerca in tutti i file", + "noMatch": "Niente in questa cartella corrisponde a “{{query}}”.", + "clear": "Rimuovi il filtro" + }, + "emptyHome": { + "title": "I tuoi file sono qui" + }, + "emptyFolder": { + "title": "Questa cartella è vuota", + "body": "Trascina file o cartelle in un punto qualsiasi di questa pagina, incollali con Ctrl+V oppure caricali dal tuo computer. L’assistente può anche salvare qui dei file da una chat.", + "bodyTouch": "Carica file dal tuo dispositivo. L’assistente può anche salvare qui dei file da una chat.", + "ask": "Chiedi all’assistente" + }, + "drop": { + "title": "Rilascia per caricare in {{folder}}", + "body": "Sono accettati file e cartelle intere.", + "limit": "File e cartelle intere, fino a {{size}} ciascuno." + }, + "conflict": { + "title_one": "Esiste già un file con questo nome", + "title_other": "Esistono già {{count}} file con questi nomi", + "body_one": "{{folder}} contiene già un file con questo nome.", + "body_other": "{{folder}} contiene già file con questi nomi.", + "skip_one": "Salta", + "skip_other": "Salta tutti", + "keep": "Mantieni entrambi", + "replace_one": "Sostituisci", + "replace_other": "Sostituisci tutti", + "more_one": "e altri {{count}}", + "more_other": "e altri {{count}}", + "note": "Con “Mantieni entrambi” il nuovo file riceve un nuovo nome. Un file sostituito si può recuperare dalla Cronologia.", + "cancel": "Annulla il caricamento" + }, + "move": { + "title_one": "Sposta {{count}} elemento", + "title_other": "Sposta {{count}} elementi", + "items_one": "{{count}} elemento", + "items_other": "{{count}} elementi", + "cancel": "Annulla", + "here": "Sposta in {{folder}}", + "failed": "Impossibile leggere questa cartella.", + "noFolders": "Nessuna cartella qui. Sposta qui oppure torna indietro di un livello." + }, + "uploads": { + "waiting": "In attesa", + "progress": "{{loaded}} di {{total}}", + "rate": "{{rate}}/s", + "left": "{{time}} rimanenti", + "renamed": "Salvato come {{name}}", + "done": "Caricato", + "canceled": "Annullato", + "cancel": "Annulla", + "cancelOne": "Annulla il caricamento di {{name}}", + "retry": "Riprova", + "retryOne": "Riprova {{name}}", + "dismiss": "Chiudi", + "dismissOne": "Chiudi {{name}}", + "show": "Mostra", + "showOne": "Mostra {{name}}", + "meterOne": "{{name}}: {{percent}}%", + "meterAll": "Tutti i caricamenti: {{percent}}%", + "cancelAll": "Annulla tutto", + "retryAll": "Riprova tutto", + "fold": "Nascondi l’elenco", + "unfold": "Mostra l’elenco", + "close": "Chiudi", + "head": { + "sending_one": "Caricamento di {{count}} file", + "sending_other": "Caricamento di {{count}} file", + "failed_one": "{{count}} caricamento non riuscito", + "failed_other": "{{count}} caricamenti non riusciti", + "done_one": "{{count}} file caricato", + "done_other": "{{count}} file caricati" + }, + "say": { + "start_one": "Caricamento di {{count}} file.", + "start_other": "Caricamento di {{count}} file.", + "done_one": "Caricamento completato: {{count}} file.", + "done_other": "Caricamenti completati: {{count}} file.", + "doneFailed": "Caricamenti completati: {{count}} riusciti, {{failed}} non riusciti.", + "failed": "Impossibile caricare {{name}}." + }, + "errors": { + "tooBig": "Supera il limite di {{limit}} di questo server.", + "tooLarge": "Il server l’ha rifiutato perché troppo grande.", + "off": "I file sono disattivati su questo server.", + "invalid": "Il server ha rifiutato questo file.", + "network": "La connessione si è interrotta.", + "server": "Il server non è riuscito a salvarlo." + }, + "said": "Il server ha risposto: {{reason}}." + } +} diff --git a/core/http/react-ui/public/locales/ko/addons.json b/core/http/react-ui/public/locales/ko/addons.json index e578da81f..1df3e192b 100644 --- a/core/http/react-ui/public/locales/ko/addons.json +++ b/core/http/react-ui/public/locales/ko/addons.json @@ -3,9 +3,9 @@ "blocked": "이 애드온에 대한 이전 작업이 아직 확인되지 않았습니다. 먼저 상태를 확인하거나 무시하세요.", "checkStatus": "상태 확인", "dismiss": "무시", - "failed": "소유자가 작업을 완료하지 못했습니다. 아무것도 변경되지 않았습니다. 다시 시도하면 새 시도를 시작합니다.", - "notApplied": "소유자가 작업을 완료했다고 알렸지만 애드온은 바뀌지 않았습니다. 다시 시도하세요.", - "unknown": "소유자가 아직 작업을 확인하지 않았습니다. 여전히 적용될 수 있습니다. 다른 작업을 시작하기 전에 상태를 확인하거나 무시하세요." + "failed": "작업이 완료되지 않았습니다. 아무것도 변경되지 않았습니다. 다시 시도하면 새 시도를 시작합니다.", + "notApplied": "작업이 완료되었다고 보고되었지만 애드온은 바뀌지 않았습니다. 다시 시도하면 새 시도를 시작합니다.", + "unknown": "작업이 아직 확인되지 않았습니다. 여전히 적용될 수 있습니다. 다른 작업을 시작하기 전에 상태를 확인하거나 무시하세요." }, "approval": { "title": "승인이 필요합니다", @@ -37,14 +37,16 @@ "emptyTitle": "아직 앱이 없습니다", "firstRun": { "browse": "Browse add-ons", - "title": "Welcome to Apps", - "lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.", - "featuredLabel": "Featured add-ons", + "title": "아직 애드온이 없습니다", + "lead": "애드온은 LocalAI에 새 페이지와 채팅 도구를 더합니다. 따로 실행되며 명시한 것만 요청합니다.", + "featuredLabel": "이 중 하나로 시작하세요", "featuredFailed": "The list of add-ons could not be read.", - "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons." + "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons.", + "factChecked": "실행 전에 소스와 대조해 확인됩니다", + "factRemove": "언제든 끄거나 제거할 수 있습니다" }, "headless": "백그라운드에서 실행되며 자체 페이지가 없습니다.", - "listLabel": "앱", + "listLabel": "설치된 애드온", "loading": "앱을 불러오는 중", "manage": "관리", "open": "열기", @@ -56,10 +58,14 @@ "tools_one": "{{count}} tool", "tools_other": "도구 {{count}}개", "version": "버전 {{version}}", - "working": "소유자의 확인을 기다리는 중입니다.", + "working": "확인을 기다리는 중입니다.", "noMatchTitle": "No add-on matches", - "noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.", - "clearSearch": "Clear search" + "noMatchBody": "\"{{query}}\"와 일치하는 설치된 항목이 없습니다. 다른 단어로 찾아 보세요.", + "clearSearch": "Clear search", + "loadError": { + "title": "지금은 애드온을 사용할 수 없습니다.", + "paused": "서비스가 돌아올 때까지 애드온 페이지와 도구는 일시 중지됩니다." + } }, "browse": { "confirmTitle": "{{name}}을(를) 설치할까요?", @@ -69,7 +75,7 @@ "fromLibrary": "{{library}}에서", "install": "설치", "installedDetail": "버전 {{version}}, 세대 {{generation}}", - "installedOther": "{{version}} 설치됨(다른 버전)", + "installedOther": "현재 {{version}}", "installedSame": "{{version}} 설치됨", "installedState": "설치됨", "installing": "설치 중", @@ -85,7 +91,8 @@ "backendFailed": "The backend was not installed.", "alias": "Alias", "kindLabel": "Kind", - "installedOn": "{{name}} is installed and on." + "installedOn": "{{name}} is installed and on.", + "installedChip": "설치됨" }, "catalog": { "allLibraries": "모든 라이브러리", @@ -115,8 +122,8 @@ "pin": "Pin {{name}}", "pinLabel": "Pin", "pinned": "Pinned", - "running": "켜짐", - "search": "Search add-ons", + "running": "실행 중", + "search": "설치된 애드온 검색", "status": { "failed": "failed", "starting": "starting" @@ -129,7 +136,7 @@ "denied": "서버가 거부했습니다. 권한이 없거나 애드온이 변경되었을 수 있습니다. 목록을 새로 고쳤습니다.", "invalid": "서버가 이 요청을 받아들일 수 없습니다.", "network": "서버에 연결할 수 없습니다. 연결을 확인하세요.", - "unavailable": "애드온 소유자를 지금은 사용할 수 없습니다. 잠시 후 다시 시도하세요.", + "unavailable": "지금은 애드온을 사용할 수 없습니다. 잠시 후 다시 시도하세요.", "unknownOutcome": "결과를 확인할 수 없습니다. 다시 시도하기 전에 상태를 확인하세요." }, "installed": { @@ -174,14 +181,14 @@ }, "hints": { "id": "소문자, 숫자, 하이픈. 나중에 변경할 수 없습니다.", - "reference": "소유자가 구성한 자격 증명의 이름. 토큰이나 비밀번호를 여기에 붙여넣지 마세요.", + "reference": "관리자가 이 서버에 구성한 자격 증명의 이름입니다. 여기에 토큰이나 비밀번호를 붙여 넣지 마세요.", "url": "자격 증명과 쿼리 문자열이 없는 https 주소.", "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.", "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.", "mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched." }, "lastRefresh": "{{when}}에 새로 고침, 항목 {{count}}개", - "lead": "라이브러리는 애드온을 나열하는 카탈로그 문서입니다. 자격 증명은 여기에 입력하지 않습니다. 비공개 라이브러리는 소유자가 구성한 자격 증명을 이름으로 가리킵니다.", + "lead": "라이브러리는 애드온, 백엔드 또는 모델을 나열하는 색인입니다. 자격 증명은 여기에 입력하지 않습니다. 비공개 애드온 라이브러리는 관리자가 이 서버에 구성한 자격 증명을 이름으로 가리킵니다.", "listLabel": "라이브러리", "loading": "라이브러리를 불러오는 중", "notRefreshed": "아직 새로 고치지 않음", @@ -251,10 +258,11 @@ "hasUI": "페이지 있음", "headless": "인터페이스 없음, 페이지 없음", "health": "상태 점검", + "installedAt": "설치됨", "host": "호스트 API", "interface": "인터페이스", "logs": "로그", - "logsNone": "이 릴리스에서는 애드온 프로세스의 출력을 수집하지 않습니다. 위의 상태와 재시작 시도가 소유자가 보고할 수 있는 정보입니다.", + "logsNone": "이 릴리스에서는 애드온 프로세스의 출력을 수집하지 않습니다. 위의 상태와 재시작 시도가 LocalAI가 보고할 수 있는 전부입니다.", "observed": "관찰된 상태", "protocol": "프로토콜", "restarts": "재시작 시도", @@ -338,14 +346,15 @@ "state": { "failed": "실패", "label": "상태: {{state}}", - "running": "켜짐", + "running": "실행 중", "starting": "시작 중", "stopped": "꺼짐", - "update-pending": "업데이트 대기 중" + "update-pending": "업데이트 대기 중", + "updateTo": "업데이트 {{version}}" }, - "supporting": "소유자가 대신 실행하는 앱과 그 출처 라이브러리입니다.", + "supporting": "LocalAI에 새 페이지와 채팅 도구를 더하려고 설치하는 소프트웨어입니다. 각각 따로 실행되며 명시한 것만 요청합니다.", "tabs": { - "apps": "내 애드온", + "apps": "설치됨", "browse": "애드온 찾기", "label": "애드온 섹션", "libraries": "소스" @@ -516,8 +525,8 @@ "unknownOutcome": "결과를 알 수 없습니다. 상태를 다시 읽으세요. 같은 작업은 두 번 전송되지 않습니다.", "auth": "다시 로그인하세요.", "denied": "관리자만 자신의 브라우저 세션에서 할 수 있습니다.", - "network": "소유자에 연결할 수 없습니다.", - "unavailable": "소유자를 사용할 수 없습니다." + "network": "애드온 서비스에 연결할 수 없습니다.", + "unavailable": "애드온 서비스를 사용할 수 없습니다." } }, "updates": { @@ -591,18 +600,30 @@ "auth": "계속하려면 다시 로그인하세요.", "denied": "관리자만 자신의 브라우저 세션에서 할 수 있습니다.", "network": "네트워크에 연결할 수 없습니다.", - "unavailable": "애드온 소유자를 사용할 수 없습니다. 다시 시도하세요." + "unavailable": "애드온 서비스를 사용할 수 없습니다. 다시 시도하세요." + }, + "upgradeNamed": "{{name}} 업그레이드", + "why": { + "role": "업데이트가 있습니다. 관리자만 업그레이드할 수 있습니다.", + "needs-check": "업데이트가 보고되었지만 세부 정보가 아직 확인되지 않았습니다. 다시 확인하세요.", + "check-failed": "마지막 확인이 실패하여 업데이트를 확정할 수 없습니다. 다시 확인하세요.", + "incompatible": "업데이트가 있지만 이 환경에서는 실행할 수 없습니다:", + "integrity": "라이브러리가 이 버전을 다른 내용으로 나열합니다. 업데이트로 제공되지 않습니다.", + "recovery": "이 애드온의 데이터를 먼저 복구해야 합니다. 세부 정보에서 백업을 복원한 뒤 업그레이드하세요.", + "current": "마지막 확인에서 업데이트를 찾지 못했습니다. 알림이 오래되었을 수 있습니다. 다시 확인하세요.", + "unpinned": "라이브러리가 업데이트를 알리지만 설치할 빌드를 지정하지 않아 여기서 업그레이드할 수 없습니다. 나중에 다시 확인하세요." }, - "reviewButton": "업데이트 ({{count}})", - "reviewTitle": "애드온 업데이트 검토", - "reviewDescription": "여기서 선택하기 전에는 아무것도 업데이트되지 않습니다. 각 업데이트는 먼저 애드온 데이터를 백업합니다.", "checkAll": "업데이트 확인", "checkingAll": "확인 중...", "checkAllDone": "애드온 {{count}}개를 확인했습니다.", "checkAllFailed": "{{count}}개는 확인할 수 없었습니다.", - "upgradeNamed": "{{name}} 업데이트", - "toastDone": "{{name}}을(를) {{version}}(으)로 업데이트했습니다.", - "toastFailed": "{{name}}을(를) 업데이트하지 못했습니다: {{message}}" + "checkResult": { + "done": "확인했습니다.", + "failed": "확인을 완료하지 못했습니다.", + "justNow": "방금 확인했습니다." + }, + "waitingService": "애드온 서비스가 시작되는 중입니다. LocalAI가 {{seconds}}초 후 업그레이드 상태를 다시 확인합니다.", + "checkAgain": "지금 확인" }, "install": { "stepsLabel": "Install steps", @@ -639,17 +660,19 @@ "addsTools_other": "Adds {{count}} tools", "openNamed": "Open {{name}}", "removeTitle": "Remove {{name}}?", - "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted." + "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted.", + "removeNamed": "{{name}} 제거…" }, "preview": { "screenshots": "Screenshots", - "adds": "What it adds", + "adds": "할 수 있는 일", "page": "A page of its own in LocalAI", "addsNothing": "Nothing you can see. It works in the background.", "version": "Version", "id": "Identifier", "sourceUnknown": "Not known", - "moreDetails": "Health, trust and updates" + "moreDetails": "Health, trust and updates", + "updateTitle": "{{version}}(으)로 업데이트" }, "flow": { "installAndTurnOn": "설치하고 켜기", @@ -663,6 +686,66 @@ "install": "Download, verify and install", "turn-on": "Turn on" }, - "seeInList": "See it in My add-ons" + "seeInList": "설치됨에서 보기", + "install": "설치", + "installNamed": "{{name}} 설치", + "shadowWarning": "이 애드온은 내장 {{tool}}을 대체합니다. 다른 애드온({{name}})이 이미 대체하고 있습니다. 하나만 활성화할 수 있으며, 설정 > 도구에서 선택합니다. 선택하기 전까지는 먼저 설치된 애드온이 제공합니다.", + "skillShadow": "이 애드온은 기본 제공 스킬 {{skill}}을(를) 대체합니다.", + "skillShadowWarning": "이 애드온은 기본 제공 스킬 {{skill}}을(를) 대체합니다. 다른 애드온({{name}})이 이미 대체하고 있습니다. 하나만 활성화할 수 있으며, 설정 > 도구에서 선택합니다. 선택하기 전까지는 먼저 설치된 애드온이 제공합니다." + }, + "family": { + "pages": "본인이나 에이전트가 만든 페이지를 찾으시나요? 페이지에 있습니다." + }, + "filter": { + "label": "표시", + "all": "전체", + "running": "실행 중", + "off": "꺼짐", + "showAll": "모두 표시", + "none": { + "all": "표시할 애드온이 없습니다.", + "running": "실행 중인 애드온이 없습니다.", + "off": "모든 애드온이 실행 중입니다." + } + }, + "attention": { + "label": "확인 필요", + "failedTitle": "{{name}}이(가) 중지되었습니다.", + "failedRestarts_one": "연속 {{count}}번 시작에 실패했습니다.", + "failedRestarts_other": "연속 {{count}}번 시작에 실패했습니다.", + "failedOnce": "시작되지 않았습니다.", + "toolsOff_one": "다시 실행될 때까지 채팅 도구 {{count}}개가 꺼져 있습니다.", + "toolsOff_other": "다시 실행될 때까지 채팅 도구 {{count}}개가 꺼져 있습니다.", + "pageOff": "다시 실행될 때까지 페이지를 사용할 수 없습니다.", + "updateTitle": "{{name}} {{version}}을(를) 사용할 수 있습니다.", + "updateBody": "현재 {{current}}입니다. 무엇이 바뀌는지 보고 확인한 뒤에만 교체됩니다." + }, + "meta": { + "builtin": "기본 제공", + "builtinLong": "LocalAI에 포함됨", + "signed": "서명됨 · {{library}}", + "checked": "확인됨 · {{library}}", + "devBuild": "개발 빌드", + "library": "{{library}} 라이브러리" + }, + "skills": { + "title_one": "채팅 스킬 {{count}}개", + "title_other": "채팅 스킬 {{count}}개", + "replaces": "기본 제공 스킬을 대체", + "serves": "채팅에서 사용 중", + "notServing": "현재 사용 안 함", + "serveIn": "채팅의 {{skill}}에 사용", + "saveFailed": "선택을 저장하지 못했습니다." + }, + "settingsTab": { + "overview": "개요", + "settings": "설정", + "tabsLabel": "{{name}} 세부 정보", + "off": "설정을 바꾸려면 {{name}}을(를) 켜세요.", + "pageLink": "설정", + "controls": { + "loadFailed": "설정을 읽을 수 없습니다", + "saveFailed": "설정을 저장할 수 없습니다: {{message}}" + } } } diff --git a/core/http/react-ui/public/locales/ko/admin.json b/core/http/react-ui/public/locales/ko/admin.json index 40ed10da4..ef073bc0c 100644 --- a/core/http/react-ui/public/locales/ko/admin.json +++ b/core/http/react-ui/public/locales/ko/admin.json @@ -107,6 +107,72 @@ "assistant": "LocalAI 어시스턴트", "distributed": "분산", "responses": "응답" + }, + "loadFailedTitle": "설정을 불러오지 못했습니다.", + "egress": { + "title": "명령의 네트워크", + "intro": "샌드박스의 명령은 연결마다 LocalAI에 묻는 프록시를 통해서만 네트워크에 연결됩니다. 아래에서 제한, 새 채팅의 기본값, 포트, 추가 레지스트리, 감사 보관 기간을 정하세요. 각 사용자는 채팅의 도구 메뉴에서 제한 안에서 기본값을 고르고, 차단된 호스트를 카드에서 허용합니다.", + "serves": "{{name}} 애드온에서 격리되어 명령이 실행됩니다", + "servesHost": "{{name}}을(를) 통해 이 컴퓨터에서 명령이 실행됩니다", + "servesNone": "아직 명령을 실행하는 샌드박스가 없습니다", + "hostNote": "호스트 백엔드는 이 컴퓨터의 네트워크로 명령을 실행합니다. 이 설정은 적용되지 않습니다.", + "counts": "최근 24시간: 허용 {{allowed}}, 차단 {{blocked}}.", + "private": "관리자 규칙이 사설 네트워크에 연결하도록 허용", + "privateWarning": "관리자가 허용한 호스트는 내부 네트워크 주소에 연결할 수 있게 됩니다. 기본값만으로는 절대 그렇지 않으며, 클라우드 메타데이터와 서버 주소는 계속 차단됩니다.", + "typePhrase": "켜려면 다음을 입력하세요: {{phrase}}", + "registries": "패키지 레지스트리 ({{count}})", + "showAudit": "감사 보기", + "hideAudit": "감사 숨기기", + "loadFailed": "샌드박스 네트워크 설정을 읽을 수 없습니다", + "audit": { + "verdict": "결과", + "host": "호스트", + "user": "사용자", + "at": "시간", + "reason": "이유", + "bytes": "보낸 / 받은 바이트", + "refresh": "새로 고침", + "empty": "일치하는 요청이 없습니다.", + "loadFailed": "감사를 읽을 수 없습니다", + "verdicts": { + "any": "모든 결과", + "allow": "허용됨", + "block": "차단됨", + "done": "완료됨" + } + } + }, + "addons": { + "title": "애드온", + "hint": "애드온이 가져오는 설정", + "intro": "각 애드온은 자체 페이지의 세부 정보 설정 탭에 설정을 둡니다. 설정을 바꾸려면 애드온을 여세요.", + "off": "꺼져 있습니다. 설정을 바꾸려면 켜세요.", + "open": "설정 열기", + "openNamed": "{{name}} 설정 열기", + "count_one": "설정이 있는 애드온 {{count}}개", + "count_other": "설정이 있는 애드온 {{count}}개" + }, + "hosts": { + "add": "추가", + "listLabel": "항상 허용되는 호스트", + "remove": "{{host}} 제거", + "empty": "아직 호스트가 없습니다. 호스트 이름을 입력하거나, 포트를 고정하려면 호스트:포트를 입력하세요.", + "hint": "정확한 이름만 가능합니다. 한 포트만 허용하려면 :포트를 붙이세요. 그렇지 않으면 샌드박스 네트워크 포트가 적용됩니다.", + "count": "{{n}}/{{max}}", + "errors": { + "empty": "호스트 이름을 입력하세요.", + "wildcard": "\"{{value}}\": 패턴이 아니라 정확한 호스트 이름을 입력하세요.", + "shape": "\"{{value}}\"은(는) 호스트 이름이 아닙니다. 스키마나 경로 없이 호스트만 입력하세요.", + "port": "\"{{value}}\": 포트는 1에서 65535 사이여야 하며, 22와 25는 항상 거부됩니다.", + "private": "\"{{value}}\"은(는) 사설 또는 로컬 주소입니다. 먼저 사설 네트워크 스위치를 켜세요.", + "numeric": "\"{{value}}\": IP 주소는 십진수 네 개로 입력하세요.", + "duplicate": "\"{{value}}\"은(는) 이미 목록에 있습니다.", + "full": "목록에는 호스트를 최대 {{max}}개까지 넣을 수 있습니다." + } + }, + "network": { + "title": "명령의 네트워크", + "where": "{{name}}은(는) 자신이 실행하는 명령의 네트워크 설정을 자체 페이지에서 관리합니다." } }, "backends": { @@ -276,5 +342,75 @@ "subtitle": "이 LocalAI 인스턴스에 로드된 모델과 모델이 사용하는 호스트 용량입니다.", "scaleOut": "머신 추가", "hideScaleOut": "설정 숨기기" + }, + "settingsTools": { + "intro": "채팅에는 애드온이 대체할 수 있는 내장 도구가 몇 가지 있습니다. 애드온이 설치되어 실행 중이고 채팅에서 켜져 있으면, 채팅은 같은 이름으로 애드온의 도구를 사용합니다.", + "empty": "이 서버에는 관리할 수 있는 내장 도구가 없습니다.", + "loadFailed": "내장 도구를 읽을 수 없습니다", + "switch": "켜짐", + "byBuiltin": "내장", + "byAddon": "애드온: {{name}}", + "byNone": "꺼짐", + "unavailable": "이 서버에서 실행할 수 없습니다: {{reason}}.", + "installSandbox": "격리된 명령 실행을 위해 Sandbox 설치", + "replaceExplain": "이 애드온들은 {{tool}}을 대체합니다. 선택한 애드온이 켜져 실행 중인 모든 채팅에서 이를 제공하며 내장 도구는 숨겨집니다. 하나만 활성화할 수 있습니다. 선택하기 전까지는 먼저 설치된 애드온이 제공합니다.", + "defaultInstalledFirst": "기본값: 먼저 설치됨", + "installedOn": "설치 {{when}}", + "choose_one": "애드온 {{count}}개가 {{tool}}을 대체합니다. 사용할 것을 선택하세요.", + "choose_other": "애드온 {{count}}개가 {{tool}}을 대체합니다. 사용할 것을 선택하세요.", + "labels": { + "bash": "명령(bash)", + "web_search": "웹 검색", + "web_fetch": "웹 페이지 읽기", + "list_agents": "저장된 에이전트" + }, + "descriptions": { + "bash": "Sandbox가 bash를 제공하지 않을 때 채팅의 명령과 코드를 이 컴퓨터에서 직접 실행합니다. Sandbox 애드온은 이를 격리된 명령으로 대체합니다.", + "web_search": "키 없이 채팅을 위해 웹을 검색합니다(DuckDuckGo, 그다음 Brave Search, 그다음 Bing). SearXNG 인스턴스가 설정되어 있으면 그 인스턴스로 검색합니다.", + "web_fetch": "채팅을 위해 공개 웹 페이지나 파일을 주소로 읽습니다. 허용하지 않으면 내부 네트워크 주소는 거부됩니다.", + "list_agents": "채팅이 사용자의 저장된 에이전트를 나열하고 그중 하나를 하위 에이전트로 시작할 수 있게 합니다. 각 채팅의 도구 메뉴에서 켭니다." + }, + "bash": { + "switch": "채팅이 이 컴퓨터에서 명령을 실행하도록 허용", + "modeLegend": "명령을 실행할 수 있는 사람", + "ask": "매번 묻기.", + "askHint": "관리자의 채팅만 사용할 수 있으며, 각 명령은 채팅에서 관리자의 승인을 기다립니다.", + "on": "모두에게 허용.", + "onHint": "모든 사용자의 채팅이 묻지 않고 여기서 명령을 실행하며, 예약 실행과 하위 에이전트도 마찬가지입니다.", + "default": "설정 안 됨: 기본값이 적용됩니다({{mode}}).", + "warning": "명령은 LocalAI가 실행되는 사용자로, 격리 없이 이 컴퓨터의 네트워크를 사용해 실행됩니다. LocalAI가 읽을 수 있는 것을 읽고 인터넷, 사용자 네트워크, LocalAI API에 접속할 수 있습니다. 모든 명령은 기록됩니다. 격리하려면 Sandbox를 설치하세요." + }, + "modes": { + "off": "꺼짐", + "ask": "매번 묻기", + "on": "모두에게 허용" + }, + "skillsTitle": "애드온이 대체하는 스킬", + "skillsIntro": "애드온은 기본 제공 스킬을 자체 도구에 맞게 작성한 스킬로 대체할 수 있습니다. 여러 애드온이 같은 스킬을 대체하면 사용할 것을 선택하세요.", + "skillLabel": "스킬 {{skill}}", + "skillNotRunning": "선택한 애드온이 실행 중이 아닙니다. 기본 제공 스킬이 사용됩니다.", + "toolNotRunning": "이 도구를 대체하는 애드온이 실행 중이 아닙니다. 기본 제공 도구가 사용됩니다.", + "stopped": "중지됨", + "webTest": { + "intro": "저장된 설정의 각 검색 제공자로 검색을 실행하고 각각의 결과를 보여 줍니다. 먼저 변경 사항을 적용하세요.", + "queryLabel": "검색어", + "run": "검색 테스트", + "running": "테스트 중…", + "answered": "채팅은 {{host}}의 결과를 받습니다.", + "noneAnswered": "응답한 제공자가 없습니다.", + "failed": "테스트를 실행할 수 없습니다: {{error}}", + "results_one": "결과 {{count}}개", + "results_other": "결과 {{count}}개", + "outcomes": { + "ok": "정상", + "no_results": "결과 없음", + "challenged": "봇 확인", + "blocked": "차단됨", + "timeout": "너무 느림", + "error": "오류", + "skipped": "건너뜀" + } + }, + "addonSettings": "{{name}} 설정" } } diff --git a/core/http/react-ui/public/locales/ko/agents.json b/core/http/react-ui/public/locales/ko/agents.json index 3fc42358e..e005fa62c 100644 --- a/core/http/react-ui/public/locales/ko/agents.json +++ b/core/http/react-ui/public/locales/ko/agents.json @@ -2,15 +2,13 @@ "title": "에이전트", "subtitle": "자율 AI 에이전트를 관리합니다", "actions": { - "agentHub": "에이전트 허브", "import": "가져오기", "createAgent": "에이전트 만들기", "edit": "편집", "chat": "채팅", "export": "내보내기", "delete": "삭제", - "pause": "일시중지", - "resume": "재개" + "newAgent": "새 에이전트" }, "table": { "name": "이름", @@ -27,13 +25,9 @@ "empty": { "noConfigured": "구성된 에이전트가 없습니다", "noConfiguredText": "에이전트를 만들어 자율 AI 워크플로를 시작하세요.", - "browseHub": "어디서 시작할지 모르겠나요? <1>에이전트 허브를 둘러보고 바로 가져올 수 있는 에이전트 구성을 찾아보세요.", "noMatching": "일치하는 에이전트가 없습니다", - "noMatchingText": "\"{{query}}\"와 일치하는 에이전트가 없습니다" - }, - "sections": { - "yourAgents": "내 에이전트", - "otherUsersAgents": "다른 사용자의 에이전트" + "noMatchingText": "\"{{query}}\"와 일치하는 에이전트가 없습니다", + "clearSearch": "검색 지우기" }, "deleteDialog": { "title": "에이전트 삭제", @@ -44,12 +38,411 @@ "loadFailed": "에이전트를 불러오지 못했습니다: {{message}}", "deleted": "에이전트 \"{{name}}\"이(가) 삭제되었습니다", "deleteFailed": "에이전트 삭제 실패: {{message}}", - "paused": "에이전트 \"{{name}}\"이(가) 일시중지되었습니다", - "resumed": "에이전트 \"{{name}}\"이(가) 재개되었습니다", - "pauseFailed": "에이전트 일시중지 실패: {{message}}", - "resumeFailed": "에이전트 재개 실패: {{message}}", "exported": "에이전트 \"{{name}}\"이(가) 내보내졌습니다", - "exportFailed": "에이전트 내보내기 실패: {{message}}", - "parseFailed": "에이전트 파일을 분석하지 못했습니다: {{message}}" + "parseFailed": "에이전트 파일을 분석하지 못했습니다: {{message}}", + "notAnAgent": "The file is not an exported agent", + "deletedUndo": "“{{name}}”을(를) 삭제했습니다.", + "undoLate": "“{{name}}”은(는) 이미 삭제되었습니다.", + "restored": "“{{name}}”을(를) 복원했습니다." + }, + "facts": { + "addons": "Add-ons", + "connector": "MCP connector" + }, + "agent": { + "triggers": "Triggers", + "newTrigger": "New trigger", + "noTriggers": "No trigger starts this agent.", + "untitled": "Untitled chat", + "triggerOn": "On", + "triggerOff": "Off", + "origin": { + "trigger": "Started by a trigger", + "connector": "Started by a connector" + }, + "noRunsText": "Press Chat to start a conversation with this agent. Chats that ran with it, and the ones its triggers start, are listed here.", + "missingText": "It may have been deleted, or it belongs to another user.", + "backToList": "에이전트로 돌아가기" + }, + "record": { + "where": "A run is a chat that ran with the agent, including the ones its triggers and connectors started.", + "none": "No runs yet" + }, + "create": { + "sections": { + "BasicInfo": "Basic info", + "ModelSettings": "Model", + "PromptsGoals": "Instructions", + "MemorySettings": "Memory", + "Skills": "Skills", + "Addons": "Add-ons", + "MCP": "MCP servers", + "export": "Export" + }, + "name": "Name", + "description": "Description", + "descriptionHelp": "One line that says what the agent is for. It shows in the list.", + "model": "Model", + "modelPlaceholder": "Any model (the chat picks one)", + "modelHelp": "Leave it empty and a chat with this agent uses the model you pick in the chat. Set one to fix it.", + "anyModel": "Any model", + "instructions": "Instructions", + "instructionsHelp": "What the agent always follows. Every chat with it starts from this text.", + "collections": "Collections", + "collectionsHint": "Collection name", + "collectionsHelp": "The memory collections a chat with this agent may search. The list is exact: an empty list grants none.", + "skillsHelp": "The skills a chat with this agent may read. The list is exact: an empty list grants none.", + "noSkills": "No skills yet.", + "manageSkills": "Write or manage skills", + "addonsHelp": "The add-ons whose tools a chat with this agent may use.", + "noAddons": "No add-on offers tools to a model.", + "mcpHelp": "The MCP servers of the fixed model and the MCP connectors a chat with this agent may use.", + "mcpServers": "MCP servers", + "mcpHint": "Server name", + "mcpConnectors": "MCP connectors", + "noConnectors": "No MCP connectors yet.", + "manageConnectors": "Manage MCP connectors", + "connectorOff": "off", + "gone": "not found", + "countCollections_one": "{{count}} collection", + "countCollections_other": "{{count}} collections", + "countAddons_one": "{{count}} add-on", + "countAddons_other": "{{count}} add-ons", + "needsName": "Needs a name", + "loadFailed": "Failed to load the agent: {{message}}", + "saveFailed": "Save failed: {{message}}", + "created": "Agent \"{{name}}\" created", + "updated": "Agent \"{{name}}\" updated", + "exportSummary": "Download as JSON", + "exportHelp": "Download the definition of this agent as a JSON file. It holds no id, no dates and no secrets. Import it on another instance from the agents list.", + "exportButton": "Export agent", + "importNew": "Imported from an exported agent. Read the form, then save.", + "importLegacy": "Imported from the export of an older agent. Name, description, model, instructions and skills were read; the rest has no counterpart here.", + "importDropped": "Not imported: {{keys}}.", + "subEdit": "A chat that is running keeps the version it started with. Its next turn uses the new one.", + "configNote": "The definition this form will save.", + "previewNew": "The agent this form will save.", + "previewEdit": "The agent this form will save, and how it differs from the saved one.", + "default": "Default", + "network": "무인 실행에서 명령의 네트워크", + "networkHelp": "트리거나 커넥터가 이 에이전트를 실행하면 샌드박스 명령은 관리자가 정한 제한 안에서 이만큼의 네트워크에 연결됩니다. 실행 중에는 묻지 않으며, 범위 밖의 호스트는 차단됩니다. 이 에이전트로 시작하는 채팅도 이 값으로 시작합니다.", + "networkPresets": { + "off": "끔", + "registries": "패키지 레지스트리", + "all": "모든 공개 사이트" + } + }, + "run": { + "workingLine": "Working" + }, + "jobs": { + "title": "Triggers", + "newTask": "New trigger", + "createTask": "Create trigger", + "loading": "Loading triggers", + "loadFailed": "Failed to load: {{message}}", + "actionFailed": "The action failed: {{message}}", + "summary": { + "stopped": "{{count}} skipped", + "lastFailed_one": "The last run of {{names}} failed.", + "lastFailed_other": "The last run failed for {{names}}." + }, + "tasks": { + "title": "Triggers, {{count}}", + "caption": "Triggers", + "task": "Trigger", + "noSchedule": "No schedule", + "noAgent": "No agent", + "fileKind": "When a file arrives", + "open": "Open", + "started": "Trigger \"{{name}}\" started", + "toggleLabel": "Run {{name}} by itself", + "toggleFailed": "Could not change the trigger: {{message}}", + "deleted": "Trigger deleted", + "noneYet": "You have no triggers yet.", + "noModel": "No model", + "next": "다음 실행" + }, + "history": { + "filterTask": "Trigger", + "allTasks": "All triggers", + "task": "Trigger", + "unknownTask": "Unknown trigger", + "cancelledLine": "Skipped", + "doneLine": "Finished. Open the chat for the answer.", + "doneFile": "Finished for {{file}}. Open the chat for the answer.", + "pendingLine": "Waiting to start", + "emptyText": "A run starts when you press Run now, when a schedule fires, or when a file arrives in a watched folder.", + "openRun": "Open the chat", + "reason": "Reason", + "file": "Started by the file {{file}}." + }, + "status": { + "done": "Done", + "failed": "Failed", + "running": "Working", + "queued": "Waiting", + "skipped": "Skipped" + }, + "empty": { + "title": "No triggers yet", + "text": "A trigger starts a chat for you by itself: at a time, or when a file arrives in a folder. Each run is an ordinary chat that you can open.", + "step1": "Write what the chat should do.", + "step2": "Pick an agent, or a model, to run it.", + "step3": "Choose when it runs, or press Run now." + }, + "task": { + "back": "Triggers", + "missingTitle": "Trigger not found", + "agent": "Agent", + "model": "Model", + "schedule": "Schedule", + "cap": "Most runs a day", + "watches": "Watches the folder {{folder}}", + "noRuns": "This trigger has not run yet.", + "runMeta": "took {{time}}", + "prompt": "What to do" + }, + "edit": { + "titleNew": "New trigger", + "subNew": "Name it, write what to do and choose when it runs.", + "subEdit": "Changes apply from the next run.", + "save": "Save trigger", + "create": "Create trigger", + "nameRequired": "A trigger needs a name", + "promptRequired": "Write what the trigger should do", + "created": "Trigger created", + "updated": "Trigger updated", + "sections": { + "basics": "Basics", + "prompt": "What to do", + "schedule": "When", + "limits": "Limits" + }, + "summary": { + "basics": "{{name}}, {{model}}", + "basicsNoModel": "{{name}}, no agent or model yet", + "promptEmpty": "Nothing to do yet", + "folderEmpty": "Needs a folder", + "cap": "Up to {{count}} runs a day", + "capDefault": "Default limit" + }, + "name": "Name", + "namePlaceholder": "daily-digest", + "noAgent": "No agent", + "model": "Model", + "enabled": "On", + "enabledHint": "A trigger that is off keeps its schedule but does not run.", + "prompt": "What to do", + "promptPlaceholder": "Summarise what is new in my Reports folder.", + "promptHint": "This text starts the chat each time the trigger fires.", + "capHint": "The most chats this trigger may start in one day. Empty uses the default.", + "previewEdit": "What the next run would send, and what differs from the saved trigger.", + "previewNew": "What the first run would send.", + "promptAsSent": "The text a run starts with", + "noChanges": "Nothing differs from the saved trigger.", + "loadingTask": "Loading trigger", + "changed_one": "{{count}} change", + "changed_other": "{{count}} changes" + }, + "subtitle": "일정에 따라 또는 파일이 도착하면 에이전트를 시작합니다. 각 실행은 열어 볼 수 있는 채팅입니다." + }, + "views": { + "label": "에이전트 영역", + "agents": "에이전트" + }, + "now": { + "failedTitle": "{{name}} 실행이 실패했습니다.", + "workingTitle": "{{name}}이(가) 작업 중입니다.", + "openRun": "실행 열기" + }, + "load": { + "agents": "에이전트를 불러올 수 없습니다.", + "agentsStale": "목록을 새로 고칠 수 없습니다. 이전 내용을 표시합니다.", + "agent": "이 에이전트를 불러올 수 없습니다.", + "agentStale": "이 페이지를 새로 고칠 수 없습니다. 이전 내용을 표시합니다.", + "agentForm": "편집할 에이전트를 불러올 수 없습니다.", + "triggers": "트리거를 불러올 수 없습니다.", + "triggersStale": "트리거를 새로 고칠 수 없습니다. 이전 내용을 표시합니다.", + "trigger": "이 트리거를 불러올 수 없습니다.", + "triggerStale": "이 트리거를 새로 고칠 수 없습니다. 이전 내용을 표시합니다.", + "tasks": "작업을 불러올 수 없습니다.", + "tasksStale": "작업을 새로 고칠 수 없습니다. 이전 내용을 표시합니다." + }, + "meta": { + "anyModel": "모든 모델", + "addons_one": "애드온 {{count}}개", + "addons_other": "애드온 {{count}}개" + }, + "chips": { + "lastFailed": "마지막 실행 실패" + }, + "tasks": { + "title": "에이전트 작업", + "allUsers": "모든 사용자", + "new": "새 작업", + "newTitle": "새 작업", + "editTitle": "작업 편집", + "name": "이름", + "description": "설명", + "runner": "실행 주체", + "byModel": "모델", + "byAgent": "내 에이전트", + "model": "모델", + "agent": "에이전트", + "pick": "선택…", + "prompt": "프롬프트", + "promptHint": "매개변수는 {{example}}처럼 쓰세요. 실행할 때마다 값을 묻습니다.", + "webhooksKept_one": "이 작업에는 웹훅이 {{count}}개 있습니다. 그대로 유지되며, 웹훅은 API로 변경합니다.", + "webhooksKept_other": "이 작업에는 웹훅이 {{count}}개 있습니다. 그대로 유지되며, 웹훅은 API로 변경합니다.", + "save": "작업 저장", + "cancel": "취소", + "saved": "작업 \"{{name}}\"을(를) 저장했습니다", + "runTitle": "{{name}} 실행", + "runNow": "지금 실행", + "started": "작업 \"{{name}}\"을(를) 시작했습니다", + "tasks": "작업", + "off": "꺼짐", + "run": "실행", + "edit": "편집", + "delete": "삭제", + "deleted": "작업 \"{{name}}\"을(를) 삭제했습니다", + "task": "작업", + "cancelJob": "취소", + "rerun": "다시 실행", + "rerunStarted": "다시 시작했습니다", + "stillRunning": "아직 실행 중입니다.", + "noResult": "결과가 없습니다.", + "traces_one": "{{count}}단계", + "traces_other": "{{count}}단계", + "webhookSent": "웹훅을 호출했습니다.", + "webhookFailed": "웹훅 실패: {{message}}", + "actionFailed": "실패했습니다: {{message}}", + "state": { + "pending": "대기 중", + "running": "실행 중", + "completed": "완료", + "failed": "실패", + "cancelled": "취소됨" + }, + "lede": "모델이나 내 에이전트가 백그라운드에서 실행하는 프롬프트입니다. 여기서 시작하거나 스크립트에서 API로 시작하세요.", + "searchLabel": "작업 검색", + "filter": { + "label": "작업 표시", + "all": "전체", + "on": "켜짐", + "off": "꺼짐" + }, + "noMatch": "일치하는 작업이 없습니다.", + "on": "켜짐", + "offLine": "꺼짐. 켤 때까지 실행되지 않습니다.", + "onDemand": "시작할 때 실행", + "asksFor": "{{list}} 입력 필요", + "webhooks_one": "웹훅 {{count}}개", + "webhooks_other": "웹훅 {{count}}개", + "runsCount_one": "{{count}}회 실행", + "runsCount_other": "{{count}}회 실행", + "runsOf_one": "{{name}} {{count}}회 실행", + "runsOf_other": "{{name}} {{count}}회 실행", + "toggleLabel": "{{name}} 실행 허용", + "turnedOn": "\"{{name}}\"을(를) 켰습니다", + "turnedOff": "\"{{name}}\"을(를) 껐습니다", + "moreFor": "{{name}}의 다른 작업", + "open": "열기", + "openTask": "{{name}} 열기", + "runFor": "{{name}} 실행", + "rerunFor": "{{name}} 다시 실행", + "jobMoreFor": "{{name}}의 이 실행에 대한 다른 작업", + "detailsFor": "{{name}} 실행 세부 정보", + "openChat": "채팅 열기", + "deleteJob": "실행 삭제", + "jobDeleted": "실행을 삭제했습니다", + "cancelled": "실행을 중지했습니다", + "confirmJob": { + "title": "이 실행을 삭제할까요?", + "message": "답변과 단계가 삭제됩니다. 되돌릴 수 없습니다." + }, + "line": { + "pending": "시작 대기 중", + "running": "작업 중", + "cancelled": "끝나기 전에 중지됨" + }, + "startedBy": { + "manual": "여기서 시작", + "api": "API로 시작", + "cron": "일정에 따라 시작" + }, + "output": { + "answer": "답변", + "error": "오류", + "values": "값" + }, + "traceKind": { + "reasoning": "추론", + "tool_call": "도구", + "tool_result": "도구 결과", + "status": "상태" + }, + "empty": { + "title": "아직 작업이 없습니다", + "body": "작업은 한 번 써 두고 여러 번 실행하는 프롬프트입니다. 값이 바뀌는 곳에 {{example}}을 넣으면 실행할 때마다 그 값을 묻습니다.", + "fact1": "모델이나 내 에이전트가 실행합니다", + "fact2": "여기서 시작하거나 스크립트에서 API로 시작합니다", + "fact3": "실행마다 답변과 단계가 남습니다" + }, + "sheet": { + "optional": "(선택)", + "newDesc": "프롬프트를 한 번 써 두고 필요할 때마다 실행하세요.", + "editDesc": "변경 사항은 다음 실행부터 적용됩니다.", + "about": "정보", + "nameHelp": "스크립트는 이 이름으로 API에서 작업을 실행합니다.", + "descHelp": "작업의 용도를 한 줄로 쓰세요.", + "who": "실행 주체", + "modelHelp": "모델이 도구 없이 프롬프트에 답합니다.", + "agentHelp": "에이전트가 자체 지침과 도구로 답합니다.", + "what": "하는 일", + "promptPlaceholder": "예: 티켓 {{example}}을 세 줄로 요약해 줘.", + "inputs": "실행할 때마다 묻는 값:", + "noInputs": "이 프롬프트는 아무것도 묻지 않습니다. 실행할 때마다 그대로 보냅니다.", + "on": "켜짐", + "onHelp": "꺼진 작업은 실행 기록을 유지하지만 시작할 수 없습니다.", + "runDesc": "이번 실행에 쓸 값을 입력하세요.", + "runNone": "이 작업은 아무것도 묻지 않습니다. 프롬프트를 그대로 실행합니다.", + "preview": "작업이 받는 내용" + }, + "errors": { + "name": "작업 이름을 입력하세요.", + "prompt": "작업이 할 일을 쓰세요.", + "model": "모델을 선택하세요.", + "agent": "에이전트를 선택하세요." + }, + "pane": { + "label": "작업 세부 정보", + "overview": "개요", + "runs": "실행", + "settings": "설정", + "inputs": "입력값", + "noInputs": "없음", + "changed": "변경됨", + "api": "API로 실행", + "apiHint": "값을 JSON 객체로 보내세요. 응답에 실행 ID가 있어 결과를 조회할 수 있습니다.", + "noRuns": "아직 실행 기록이 없습니다. 작업을 실행하면 답변이 여기에 표시됩니다.", + "webhooks": "웹훅", + "noWebhooks": "없음. 실행이 끝났을 때 알림을 받으려면 API로 웹훅을 추가하세요.", + "deleteTitle": "이 작업 삭제", + "deleteHelp": "실행 기록은 보관 기간이 끝날 때까지 남습니다." + }, + "history": { + "title": "실행 기록", + "filterLabel": "실행 표시", + "all": "전체", + "active": "진행 중", + "completed": "완료", + "failed": "실패", + "cancelled": "취소됨", + "allTasks": "모든 작업", + "emptyBody": "작업을 실행하면 답변이 여기에 표시됩니다.", + "more": "{{count}}개 더 보기" + } } } diff --git a/core/http/react-ui/public/locales/ko/auth.json b/core/http/react-ui/public/locales/ko/auth.json index 09e2e4694..37bac414f 100644 --- a/core/http/react-ui/public/locales/ko/auth.json +++ b/core/http/react-ui/public/locales/ko/auth.json @@ -47,12 +47,15 @@ "account": { "title": "계정", "subtitle": "프로필, 자격 증명, API 키", + "subtitleLocal": "나에 대한 정보와, 어시스턴트와 LocalAI가 나에게 동작하는 방식", "unavailable": "계정을 사용할 수 없습니다", "unavailableText": "계정을 관리하려면 인증이 활성화되어 있어야 합니다.", "tabs": { "profile": "프로필", "security": "보안", - "apiKeys": "API 키" + "apiKeys": "API 키", + "about": "나와 어시스턴트에 대하여", + "appearance": "모양" }, "profile": { "displayName": "표시 이름", @@ -112,6 +115,9 @@ "pausedToast": "API key paused", "resumedToast": "API key resumed", "pauseFailed": "Failed to update API key: {{message}}" + }, + "usage": { + "loadFailedTitle": "사용량을 불러올 수 없습니다." } }, "notFound": { diff --git a/core/http/react-ui/public/locales/ko/chat.json b/core/http/react-ui/public/locales/ko/chat.json index 7d8859fd5..0e752c371 100644 --- a/core/http/react-ui/public/locales/ko/chat.json +++ b/core/http/react-ui/public/locales/ko/chat.json @@ -25,22 +25,15 @@ "yes": "예", "gpuLayers": "GPU 레이어" }, - "context": { - "label": "컨텍스트: {{percent}}%", - "labelWithTokens": "컨텍스트: {{percent}}% ({{tokens}} 토큰)" - }, "settings": { "title": "채팅 설정", "manageMode": "관리 모드", - "manageModeDesc": "이 채팅이 LocalAI와 대화하여 모델을 설치하고, 백엔드를 전환하고, 구성을 편집할 수 있게 합니다.", - "systemPrompt": "시스템 프롬프트", - "systemPromptPlaceholder": "당신은 도움이 되는 어시스턴트입니다...", - "temperature": "Temperature", - "topP": "Top P", - "topK": "Top K", - "contextSize": "컨텍스트 크기", - "contextSizePlaceholder": "2048", - "clearHistory": "채팅 기록 지우기" + "serverKept": "서버가 이 채팅을 보관합니다. 모델과 에이전트는 시작할 때 정해집니다.", + "manageModeOn": "이 채팅에는 LocalAI 관리 도구가 있습니다. 다른 채팅은 /assistant로 시작하세요.", + "agent": "에이전트", + "agentHelp": "이 채팅은 에이전트 {{name}}의 지침과 허용된 도구만으로 실행됩니다.", + "editAgent": "에이전트 편집", + "deleteHelp": "이 채팅과 메시지를 영구히 삭제합니다." }, "empty": { "manageTitle": "채팅으로 LocalAI 관리", @@ -61,7 +54,6 @@ "아이디어를 브레인스토밍해 줘" ], "recent": "최근", - "noMessages": "아직 메시지가 없습니다", "hintEnter": "Enter로 전송", "hintShiftEnter": "Shift+Enter로 줄바꿈", "hintAttach": "파일 첨부" @@ -71,29 +63,17 @@ }, "actions": { "copy": "복사", - "edit": "편집", - "editMessage": "메시지 편집", - "save": "저장", - "cancel": "취소", "regenerate": "다시 생성", - "jumpToLatest": "Jump to latest" + "jumpToLatest": "Jump to latest", + "reuse": "편집해서 다시 보내기" }, "streaming": { "transferring": "모델 전송 중...", "transferringTo": "{{node}}(으)로 모델 전송 중..." }, - "tokens": { - "perSec": "{{count}} tok/s", - "peak": "최고: {{count}} tok/s", - "usage": "{{prompt}}p + {{completion}}c = {{total}}" - }, "input": { - "placeholder": "메시지...", - "attachFile": "파일 첨부", "stopGenerating": "생성 중지", - "canvasTitle": "캔버스 — 코드 블록과 미디어를 사이드 패널로 추출해 미리보기, 복사, 다운로드할 수 있습니다", - "canvasLabel": "캔버스", - "openCanvas": "캔버스 패널 열기" + "placeholderQueue": "모델이 작업 중입니다. 지금 보낸 메시지는 다음 단계까지 기다립니다." }, "deleteAllDialog": { "title": "모든 채팅 삭제", @@ -104,7 +84,7 @@ "selectModel": "모델을 선택해 주세요", "copied": "클립보드에 복사되었습니다", "copyFailed": "클립보드에 복사할 수 없습니다", - "pdfScannedAsImages": "{{name}}에 텍스트 레이어가 없습니다(스캔). 비전 모델용으로 {{total}}페이지 중 {{count}}페이지를 이미지로 첨부했습니다." + "newChatForModel": "채팅은 모델을 유지합니다. {{model}}로 새 채팅을 시작했습니다." }, "menu": { "trigger": "채팅", @@ -118,17 +98,492 @@ "deleteChat": "채팅 삭제", "newChat": "새 채팅", "clearAll": "모두 지우기", - "deleteAllTitle": "모든 대화 삭제" + "deleteAllTitle": "모든 대화 삭제", + "untitled": "새 채팅", + "origin": { + "trigger": "트리거로 시작됨", + "connector": "커넥터에서" + } }, "message": { - "you": "You" + "you": "You", + "loopRun": "반복 {{run}}번째 실행" }, - "addons": { - "note": "An add-on that is off here does nothing in this chat.", - "label": "Add-ons", - "title": "Add-on tools for this chat", - "heading": "Add-on tools", - "selectAll": "Select all", - "deselectAll": "Deselect all" + "tools": { + "label": "도구", + "title": "이 채팅이 쓸 수 있는 도구", + "heading": "이 채팅의 도구", + "draftNote": "새 채팅은 이 스위치 설정으로 시작합니다. 꺼진 도구는 모델에 제공되지 않습니다.", + "note": "여기서 끈 도구는 이 채팅에서 모델에 제공되지 않습니다.", + "count": "{{total}}개 중 {{on}}개 켜짐", + "selectAll": "모두 켜기", + "disableAll": "모두 끄기", + "groupOn": "모두 켜기", + "groupOff": "모두 끄기", + "groupOnLabel": "{{group}} 모두 켜기", + "groupOffLabel": "{{group}} 모두 끄기", + "groupCount": "{{total}}개 중 {{on}}개", + "optIn": "직접 켜기 전까지 꺼져 있습니다. 모두 켜기로는 켜지지 않습니다.", + "empty": "이 채팅에서 바꿀 항목이 없습니다.", + "failed": "저장하지 못했습니다. 스위치가 이전 상태로 돌아갔습니다.", + "unavailable": "이 채팅에서 사용할 수 없음", + "toolCount_one": "도구 {{count}}개", + "toolCount_other": "도구 {{count}}개" + }, + "skills": { + "label": "스킬", + "manage": "스킬 관리", + "title": "이 채팅이 쓸 수 있는 스킬", + "heading": "이 채팅의 스킬", + "count": "{{total}}개 중 {{on}}개 켜짐", + "selectAll": "모두 선택", + "disableAll": "모두 끄기", + "selectShown": "표시된 항목 선택", + "disableShown": "표시된 항목 끄기", + "search": "스킬 검색", + "noMatch": "“{{query}}”와 일치하는 스킬이 없습니다.", + "later": "나중에 추가하는 스킬은 켜진 상태로 시작합니다.", + "failed": "저장하지 못했습니다. 변경되지 않았습니다.", + "source": { + "builtin": "기본 제공", + "personal": "개인", + "shared": "공유", + "folder": "작업 공간", + "addon": "{{name}}" + }, + "replacedBy": "{{name}}(으)로 대체됨", + "fallback_one": "기본 제공 {{skills}} 사용 중: {{name}} 애드온의 자체 스킬이 여기서 꺼져 있거나 실행 중이 아닙니다.", + "fallback_other": "기본 제공 {{skills}} 사용 중: {{name}} 애드온의 자체 스킬이 여기서 꺼져 있거나 실행 중이 아닙니다." + }, + "agent": { + "label": "에이전트", + "title": "이 채팅의 저장된 에이전트", + "heading": "에이전트로 시작", + "note": "에이전트는 채팅에 지침과 허용한 도구만 제공합니다.", + "none": "에이전트 없음", + "fixed": "이 채팅의 에이전트" + }, + "assistant": { + "chatTitle": "LocalAI 어시스턴트" + }, + "slash": { + "delete": { + "label": "채팅 삭제", + "desc": "이 대화를 삭제합니다" + }, + "assistant": { + "desc": "LocalAI를 관리할 수 있는 채팅 시작" + }, + "settings": { + "desc": "집중 모드, 에이전트, 모델 정보" + }, + "group": { + "context": "컨텍스트", + "skills": "스킬과 에이전트", + "help": "도움말" + }, + "models": { + "label": "모델", + "desc": "모델 목록 열기" + }, + "skill": { + "label": "스킬 실행", + "desc": "이 채팅의 스킬 중 하나 선택" + }, + "skills": { + "label": "스킬 관리", + "desc": "스킬 페이지를 열어 스킬을 작성하고 수정합니다" + }, + "agent": { + "label": "저장된 에이전트에게 요청", + "desc": "내 에이전트 중 하나에게 작업 맡기기", + "enable": "이 채팅에서 저장된 에이전트 켜기", + "enableDesc": "그다음 에이전트 선택" + }, + "compact": { + "label": "컨텍스트 압축", + "desc": "오래된 메시지를 요약해 모델의 공간 확보" + }, + "clear": { + "label": "컨텍스트 비우기", + "desc": "이 채팅에서 새로 시작, 메시지는 남음" + }, + "attach": { + "label": "파일 첨부", + "desc": "보낼 파일 선택", + "clearDesc": "보내기를 기다리는 파일 제거" + }, + "help": { + "label": "모든 명령", + "desc": "각 명령과 그 기능 보기" + }, + "about": { + "label": "이 채팅 정보", + "desc": "모델, 도구, 스킬, 컨텍스트 크기" + }, + "args": { + "skill": "스킬", + "agent": "저장된 에이전트", + "model": "모델", + "attach": "파일", + "loop": "반복", + "goal": "목표" + }, + "badge": { + "builtin": "기본 제공", + "addon": "애드온", + "shared": "공유", + "yours": "내 것" + }, + "collides": "/skill로만", + "errors": { + "skill": "이 채팅에는 \"{{name}}\" 스킬이 없습니다.", + "agent": "\"{{name}}\"(이)라는 저장된 에이전트가 없습니다.", + "agentTask": "{{name}} 이름 뒤에 작업을 적어 주세요.", + "agentsOff": "이 채팅에서는 저장된 에이전트가 꺼져 있습니다. 도구 메뉴에서 켜세요." + }, + "loop": { + "label": "프롬프트 반복", + "desc": "이 채팅에서 프롬프트를 반복 실행: /loop 5m 빌드 확인", + "listDesc": "이 채팅의 반복 보기", + "stopDesc": "반복 하나 또는 모두 중지", + "stopAll": "모든 반복", + "stopAllDesc": "이 채팅의 모든 반복 중지" + }, + "goal": { + "label": "목표", + "desc": "채팅이 알아서 계속 작업할 목표 설정", + "clearDesc": "목표 지우기", + "resumeDesc": "목표를 계속 진행" + } + }, + "shortcuts": { + "onMessage": "메시지에서: 복사, 편집, 다시 생성", + "editLast": "마지막 메시지 편집" + }, + "files": { + "title": "이 채팅의 파일", + "chip": "파일", + "chipTitle": "이 채팅의 파일: 첨부한 파일과 어시스턴트가 만들거나 변경한 파일", + "close": "파일 닫기", + "emptyTitle": "아직 이 채팅에 파일이 없습니다", + "emptyBody": "파일을 여기에 놓으면 다음 메시지에 첨부됩니다. 어시스턴트가 만들거나 변경한 파일도 여기에 표시됩니다.", + "dropHint": "파일을 여기에 놓으면 다음 메시지에 첨부됩니다.", + "groups": { + "uploaded": "내가 준 파일", + "created": "만들고 바꾼 파일", + "working": "작업 파일" + }, + "turn": "{{n}}번째 턴", + "media": "컨텍스트에 포함", + "mediaTitle": "모델이 메시지와 함께 이 파일을 이미지나 동영상으로 받았습니다", + "changedTag": "편집됨", + "changedTitle": "어시스턴트가 이 파일을 변경했습니다", + "download": "{{name}} 다운로드", + "error": "이 채팅의 파일을 읽을 수 없습니다.", + "workingNote": "어시스턴트가 작업 중에 만든 스크립트, 데이터, 로그입니다. 이 채팅의 작업 폴더에 보관됩니다." + }, + "steps": { + "verb": { + "read": "읽음", + "list": "목록 확인", + "write": "저장함", + "create": "만듦", + "change": "변경함", + "move": "이동함", + "delete": "삭제함", + "search": "검색함", + "run": "실행함", + "fetch": "가져옴", + "websearch": "웹 검색함:", + "webfetch": "읽음", + "delegate": "에이전트에게 요청함", + "used": "사용함" + }, + "worked": "{{duration}} 동안 작업함", + "thoughtFor": "{{duration}} 동안 생각함", + "working": "작업 중", + "thinking": "생각 중", + "noMatches": "결과 없음", + "noResults": "결과 없음", + "emptyFolder": "빈 폴더", + "size": "{{size}}", + "seconds": "{{value}}초", + "minutes": "{{min}}분 {{sec}}초", + "tool": "도구", + "input": "입력", + "showLess": "간단히 보기", + "running": "실행 중", + "failed": "실패", + "count_one": "{{count}}단계", + "count_other": "{{count}}단계", + "files_one": "파일 {{count}}개", + "files_other": "파일 {{count}}개", + "items_one": "항목 {{count}}개", + "items_other": "항목 {{count}}개", + "matches_one": "결과 {{count}}개", + "matches_other": "결과 {{count}}개", + "results_one": "결과 {{count}}개", + "results_other": "결과 {{count}}개", + "lines_one": "{{count}}줄", + "lines_other": "{{count}}줄", + "showAll_one": "{{count}}줄 모두 보기", + "showAll_other": "{{count}}줄 모두 보기", + "details": "세부 정보 보기", + "openFile": "파일에서 {{path}} 열기", + "savedFiles": "내 파일에 저장됨:", + "fileOp": { + "changed": "변경됨", + "deleted": "삭제됨" + }, + "moreFiles": "외 {{count}}개", + "alsoSavedWorking_one": "작업 파일 {{count}}개도 저장함", + "alsoSavedWorking_other": "작업 파일 {{count}}개도 저장함", + "savedWorking_one": "작업 파일 {{count}}개 저장함", + "savedWorking_other": "작업 파일 {{count}}개 저장함", + "workingTitle": "작업 파일(스크립트, 로그 또는 초안): {{path}} 열기" + }, + "error": { + "title": "답변에 실패했습니다", + "kept": "지금까지 작성된 내용은 위에 그대로 있습니다.", + "retry": "다시 시도", + "detail": "기술 세부 정보", + "reason": { + "unknown": "오류로 에이전트가 멈췄습니다. 다시 시도하여 턴을 다시 실행하세요.", + "model_backend_missing": "모델 \"{{model}}\"을(를) 시작할 수 없습니다: 백엔드가 설치되어 있지 않습니다.", + "model_backend_missing_anon": "모델을 시작할 수 없습니다: 백엔드가 설치되어 있지 않습니다.", + "model_cooldown": "모델 \"{{model}}\"이(가) 방금 시작에 실패하여 잠시 쉬는 중입니다.", + "model_cooldown_anon": "모델이 방금 시작에 실패하여 잠시 쉬는 중입니다.", + "model_load_failed": "모델 \"{{model}}\"을(를) 시작할 수 없습니다.", + "model_load_failed_anon": "모델을 시작할 수 없습니다.", + "model_not_found": "모델 \"{{model}}\"을(를) 찾을 수 없습니다.", + "model_not_found_anon": "모델을 찾을 수 없습니다.", + "upstream_key": "업스트림이 키를 거부했습니다.", + "upstream_path": "업스트림이 해당 주소에서 이 요청을 받지 않습니다.", + "upstream_unreachable": "업스트림 주소에 연결할 수 없습니다.", + "upstream": "모델 뒤의 제공자가 오류를 반환했습니다.", + "timeout": "모델이 응답하는 데 너무 오래 걸렸습니다.", + "cancelled": "모델이 응답하기 전에 턴이 취소되었습니다." + }, + "hint": { + "model_backend_missing": "다른 모델을 고르거나 관리자에게 백엔드 설치를 요청하세요.", + "model_backend_missing_admin": "다른 모델을 고르거나 백엔드 페이지에서 백엔드를 설치하세요.", + "model_cooldown": "{{seconds}}초 후에 다시 시도하세요.", + "model_load_failed": "모델 설정과 백엔드를 확인한 뒤 다시 시도하세요.", + "model_not_found": "다른 모델을 고르거나 모델 페이지에서 이 모델을 설치하세요.", + "upstream_key": "모델 설정에서 API 키를 확인하세요.", + "upstream_path": "업스트림 주소는 전체 엔드포인트여야 합니다. 예: .../v1/chat/completions", + "upstream_unreachable": "모델 설정에서 주소를 확인하세요.", + "upstream": "잠시 후 다시 시도하세요. 계속 실패하면 제공자의 주소와 키를 확인하세요.", + "timeout": "다시 시도하거나 더 작거나 빠른 모델을 사용하세요.", + "cancelled": "준비되면 다시 시도하세요." + }, + "openBackends": "백엔드 열기" + }, + "allChats": { + "title": "모든 채팅", + "supporting": "저장된 모든 채팅입니다. 검색, 이름 변경, 내보내기, 삭제를 할 수 있습니다.", + "loadFailed": "채팅을 불러오지 못했습니다.", + "emptyTitle": "아직 채팅이 없습니다", + "emptyBody": "채팅은 홈에서 시작합니다. 메시지를 입력하고 보내세요. 그 후 채팅이 여기에 표시됩니다.", + "start": "채팅 시작" + }, + "foot": { + "state": { + "ready": "준비됨", + "nomodel": "모델 없음", + "sending": "보내는 중", + "loading": "모델 불러오는 중", + "thinking": "생각하는 중", + "tool": "{{tool}} 실행 중", + "answering": "답변하는 중", + "waiting": "입력을 기다리는 중", + "stopped": "중지됨", + "failed": "실패", + "interrupted": "중단됨", + "paused": "일시 중지됨", + "compacting": "컨텍스트 압축 중", + "clearing": "컨텍스트 비우는 중" + }, + "tool": { + "search": "검색 중", + "list": "목록 보는 중", + "read": "읽는 중", + "create": "만드는 중", + "write": "쓰는 중", + "change": "바꾸는 중", + "move": "옮기는 중", + "delete": "삭제하는 중", + "run": "명령 실행 중", + "fetch": "가져오는 중", + "delegate": "하위 에이전트에 묻는 중" + }, + "hint": "Enter로 보내기 · Shift+Enter로 줄바꿈", + "meter": { + "label": "컨텍스트 {{pct}}% 사용. 컨텍스트와 속도 보기", + "labelNoLimit": "컨텍스트 {{used}} 토큰. 컨텍스트와 속도 보기" + }, + "details": { + "title": "컨텍스트와 속도", + "context": "컨텍스트", + "contextOf": "{{limit}} 토큰 중 {{used}} ({{pct}}%)", + "contextUsed": "{{used}} 토큰", + "contextLeft": "{{left}} 토큰 남음", + "contextHelp": "컨텍스트는 모델이 요청마다 읽는 내용입니다: 지침, 지금까지의 이 채팅, 도구 결과.", + "nearFull": "이 채팅이 한도에 가깝습니다. 모델이 계속할 수 있도록 서버가 오래된 부분을 압축합니다. 대화 기록에는 모든 메시지가 남습니다.", + "limitUnknown": "서버가 컨텍스트 창의 크기를 알려 주지 않았습니다.", + "lastRequest": "마지막 요청", + "model": "모델", + "backend": "백엔드", + "node": "노드", + "tokensIn": "입력 토큰", + "tokensOut": "출력 토큰", + "firstToken": "첫 토큰까지 걸린 시간", + "promptTime": "프롬프트 처리", + "speed": "속도", + "speedLive": "스트리밍 중 추정 속도", + "average": "최근 {{count}}개 요청의 평균", + "note": "이 수치는 이 페이지가 본 요청에서 나옵니다. 서버는 이를 보관하지 않으므로 새로고침하면 사라집니다." + }, + "paused": { + "after": "{{count}}단계 후", + "atLimit": "단계 한도에 도달", + "continue": "계속", + "continueTitle": "모델이 한 턴의 단계를 모두 사용했습니다. 멈춘 곳부터 계속하게 하세요.", + "message": "멈춘 곳부터 계속해 주세요." + } + }, + "attachments": { + "label": "첨부 파일", + "open": "{{name}} 열기", + "showMore": "{{count}}개 더 보기", + "missing": "더 이상 없는 파일" + }, + "preview": { + "open": "미리보기 열기", + "created": "어시스턴트가 만든 파일", + "changed": "어시스턴트가 바꾼 파일", + "yours": "내가 준 파일", + "fromReply": "답변에서", + "download": "다운로드", + "downloadCode": "코드 다운로드", + "copy": "복사", + "copied": "복사됨", + "openInFiles": "파일에서 열기", + "actions": "파일 작업", + "opened": "미리보기 열림: {{name}}", + "chip": { + "created": "만듦", + "changed": "바꿈", + "label": "{{verb}} {{name}}, 미리보기 열기", + "row": "이 답변의 파일" + } + }, + "meter": { + "usedOf": "{{used}} / {{limit}} 토큰 ({{pct}}%)", + "used": "{{used}} 토큰", + "speed": "{{speed}} 토큰/초", + "title": "마지막 모델 요청이 사용한 컨텍스트", + "ofLimit": "{{used}} / {{limit}} 토큰", + "left": "{{left}} 남음" + }, + "goal": { + "label": "목표", + "states": { + "active": "진행 중", + "paused": "일시 정지됨", + "done": "달성", + "blocked": "도움 필요" + }, + "pausedBy": { + "user": "직접 일시 정지했습니다.", + "turn_limit": "자동 턴을 모두 사용했습니다. 계속하면 더 받을 수 있습니다.", + "step_limit": "단계를 모두 사용했습니다. 계속하면 더 받을 수 있습니다.", + "error": "턴 하나가 실패했습니다. 계속하면 다시 시도합니다.", + "interrupted": "턴 도중 서버가 멈췄습니다. 계속하면 이어서 진행합니다." + }, + "blockedNote": "필요한 것: {{note}}", + "doneNote": "{{note}}", + "replaced": "바뀐 목표: {{text}}", + "pause": "일시 정지", + "pauseTitle": "진행 중인 턴을 멈추고 목표를 일시 정지", + "resume": "계속", + "resumeTitle": "목표를 계속 진행", + "clear": "지우기", + "clearTitle": "목표 지우기", + "progress": "목표 · {{max}}턴 중 {{turn}}턴", + "progressStart": "목표 · 시작 중", + "markers": { + "set": "목표 설정됨", + "replaced": "목표 바뀜", + "continue": "목표를 향해 계속 진행 · {{turn}}턴", + "continueStart": "목표를 향해 계속 진행", + "paused": "목표 일시 정지됨", + "resumed": "목표 다시 진행", + "done": "목표 달성", + "blocked": "목표 막힘", + "cleared": "목표 지워짐" + }, + "announce": { + "set": "목표 설정됨: {{text}}", + "paused": "목표가 일시 정지되었습니다. {{why}}", + "resumed": "목표를 다시 진행합니다", + "done": "목표 달성: {{note}}", + "blocked": "목표에 도움이 필요합니다: {{note}}", + "cleared": "목표가 지워졌습니다" + }, + "card": { + "title": "목표", + "none": "이 채팅에는 목표가 없습니다. /goal 뒤에 원하는 일을 입력하면 채팅이 알아서 계속 작업합니다.", + "turns": "{{max}}턴 중 {{turn}}턴", + "steps": "{{max}}단계 중 {{steps}}단계", + "clear": "/goal clear로 지웁니다.", + "resume": "/goal resume으로 계속합니다.", + "turnsLabel": "자동 턴", + "stepsLabel": "단계", + "setLabel": "설정 시각", + "stateLabel": "상태", + "noteLabel": "메모" + }, + "errors": { + "empty": "/goal 뒤에 목표를 적어 주세요.", + "notAllowed": "직접 시작한 채팅만 목표를 가질 수 있습니다." + } + }, + "invoked": { + "skill": "스킬: {{name}}", + "agent": "에이전트: {{name}}" + }, + "marker": { + "compacted": "컨텍스트 압축됨: {{before}}에서 {{after}} 토큰으로", + "cleared": "컨텍스트 비워짐", + "clearedHint": "모델은 이 선 위의 메시지를 보지 않습니다." + }, + "cmdcard": { + "close": "닫기", + "helpTitle": "명령", + "helpSkills_one": "스킬: {{names}}. /와 이름을 입력하세요.", + "helpSkills_other": "스킬 {{count}}개: {{names}}. /와 이름을 입력하세요.", + "helpKeys": "입력란에 /를 입력하면 목록이 열립니다. Tab은 완성, Enter는 실행, Esc는 닫기.", + "aboutTitle": "이 채팅", + "model": "모델", + "state": "상태", + "loaded": "로드됨", + "notLoaded": "로드 안 됨", + "backend": "백엔드", + "tools": "도구", + "toolsOn_one": "{{count}}개 켜짐", + "toolsOn_other": "{{count}}개 켜짐", + "skills": "스킬", + "skillsOn_one": "스킬 {{count}}개", + "skillsOn_other": "스킬 {{count}}개", + "commands": "명령 실행", + "isolated": "격리된 샌드박스에서 실행", + "host": "이 컴퓨터에서 실행", + "noCommands": "실행 불가", + "context": "컨텍스트", + "contextOf": "{{limit}} 토큰 중 {{used}}", + "contextUsed": "{{used}} 토큰", + "unknown": "아직 알 수 없음" } } diff --git a/core/http/react-ui/public/locales/ko/chats.json b/core/http/react-ui/public/locales/ko/chats.json new file mode 100644 index 000000000..784a72591 --- /dev/null +++ b/core/http/react-ui/public/locales/ko/chats.json @@ -0,0 +1,581 @@ +{ + "proposal": { + "loading": "제안된 트리거를 읽는 중...", + "unread": "제안된 트리거를 읽을 수 없습니다: {{message}}", + "removed": "이 트리거는 삭제되었습니다.", + "retry": "다시 시도", + "inactive": "제안된 트리거입니다. 만들기 전에는 아무것도 실행하지 않습니다.", + "name": "이름", + "schedule": "실행 시점 (예: daily 08:00)", + "folder": "감시할 폴더", + "saveEdit": "저장", + "cancelEdit": "취소", + "create": "트리거 만들기", + "edit": "수정", + "dismiss": "거절", + "receipt": { + "scheduled": "예약됨:", + "watching": "감시 중:", + "next": "다음 실행 {{when}}", + "view": "트리거 보기", + "undo": "실행 취소", + "undone": "취소됨:", + "again": "다시 예약", + "declined": "거절됨:", + "restore": "복원", + "blocked": { + "ran": "이미 한 번 실행되었습니다. 트리거 페이지에서 삭제하세요.", + "edited": "이후에 직접 수정했습니다. 트리거 페이지에서 변경하거나 삭제하세요." + } + }, + "announce": { + "accepted": "{{name}}을(를) 예약했습니다.", + "undone": "{{name}}을(를) 취소했습니다. 예약되어 있지 않습니다.", + "declined": "{{name}}을(를) 거절했습니다.", + "reopened": "{{name}}이(가) 다시 답변을 기다립니다." + }, + "plan": { + "whenLabel": "언제", + "runsAsLabel": "실행 주체", + "readsLabel": "읽는 것", + "writesLabel": "쓰는 것", + "costLabel": "비용", + "stopLabel": "멈추려면", + "when": "{{schedule}}, {{zone}}. 첫 실행 {{when}}.", + "whenNoFirst": "{{schedule}}, {{zone}}.", + "whenFile": "{{folder}}에 파일이 도착하면.", + "runsAs": "나", + "runsAsModel": "나, {{model}} 사용", + "writesWidget": ", 홈의 \"{{widget}}\" 위젯에 표시", + "costLocal_other": "로컬 모델에서 실행당 약 {{tokens}} 토큰. 하루 최대 {{count}}회.", + "costRemote_other": "원격 서비스인 {{model}}에서 실행당 약 {{tokens}} 토큰. 하루 최대 {{count}}회.", + "costUnknown_other": "실행당 토큰은 첫 실행 후에 알 수 있습니다. 하루 최대 {{count}}회.", + "costLocal_one": "로컬 모델에서 실행당 약 {{tokens}} 토큰. 하루 최대 {{count}}회.", + "costRemote_one": "원격 서비스인 {{model}}에서 실행당 약 {{tokens}} 토큰. 하루 최대 {{count}}회.", + "costUnknown_one": "실행당 토큰은 첫 실행 후에 알 수 있습니다. 하루 최대 {{count}}회.", + "remoteNote": "{{model}}은(는) 원격 서비스입니다. 실행이 읽는 내용이 이 서버 밖으로 나갑니다.", + "stopWidget": "위젯에서 일시 중지하거나 트리거에서 삭제하세요", + "stop": "트리거에서 끄거나 삭제하세요" + } + }, + "mcpPage": { + "title": "MCP 커넥터", + "supporting": "MCP 서버를 연결해 채팅과 에이전트에 검색이나 직접 만든 API 같은 도구를 더하세요.", + "family": "Telegram이나 Slack 봇은 메시징 커넥터입니다.", + "related": "관련 항목", + "chats": "채팅", + "add": "커넥터 추가", + "learn": "MCP 작동 방식", + "loading": "커넥터를 불러오는 중", + "loadFailed": "커넥터를 불러올 수 없습니다.", + "forbidden": "이 계정에서는 MCP를 사용할 수 없습니다.", + "empty": { + "title": "아직 커넥터가 없습니다", + "body": "커넥터는 MCP 서버를 LocalAI에 연결합니다. 그러면 채팅과 에이전트가 그 도구를 사용할 수 있습니다. 예를 들어 문서를 검색하거나 캘린더를 읽을 수 있습니다.", + "fact1": "http 또는 https로 연결되는 모든 MCP 서버", + "fact2": "토큰과 헤더는 암호화해 저장", + "fact3": "채팅에서 쓰기 전에 테스트" + }, + "toolbar": { + "search": "커넥터 검색", + "testAll": "모두 테스트", + "testingAll": "테스트 중", + "testedAll": "켜져 있는 커넥터를 테스트했습니다." + }, + "filter": { + "label": "표시", + "all": "전체", + "on": "켜짐", + "attention": "확인 필요", + "off": "꺼짐", + "showAll": "모두 표시", + "none": { + "all": "커넥터가 없습니다.", + "on": "켜져 있는 커넥터가 없습니다.", + "attention": "확인할 항목이 없습니다.", + "off": "모든 커넥터가 켜져 있습니다." + } + }, + "noMatch": { + "title": "“{{query}}”와 일치하는 커넥터가 없습니다", + "body": "이름이나 주소로 검색하세요.", + "clear": "검색 지우기" + }, + "state": { + "label": "상태: {{state}}", + "ok": "연결됨", + "auth": "로그인 필요", + "error": "연결 불가", + "unchecked": "테스트 안 됨", + "off": "꺼짐" + }, + "row": { + "open": "{{name}}, {{state}}. 세부 정보 보기", + "toggle": "채팅에서 {{name}} 사용", + "menu": "{{name}} 작업", + "offLine": "꺼짐. 채팅과 에이전트가 사용하지 않습니다.", + "notChecked": "아직 테스트하지 않았습니다. 테스트하면 도구를 볼 수 있습니다.", + "noTools": "연결되었지만 서버가 도구를 제공하지 않습니다.", + "tools_one": "도구 {{count}}개", + "tools_other": "도구 {{count}}개", + "checked": "{{when}} 테스트함", + "tokenSaved": "토큰 저장됨", + "headersSaved": "헤더 저장됨", + "envSaved": "변수 저장됨" + }, + "actions": { + "details": "세부 정보", + "test": "연결 테스트", + "testNamed": "{{name}} 연결 테스트", + "testing": "테스트 중", + "edit": "편집", + "reauth": "토큰 교체", + "remove": "제거" + }, + "details": { + "use": "채팅에서 사용", + "useHelp": "에이전트가 없는 채팅은 켜져 있는 모든 커넥터를 사용합니다. 저장된 에이전트는 직접 고른 커넥터만 사용합니다.", + "lastCheck": "마지막 테스트", + "never": "이 브라우저에서 아직 테스트하지 않았습니다.", + "okAt": "{{when}} 연결됨.", + "testedAt": "{{when}} 테스트함.", + "errorTitle": "마지막 테스트에서 연결하지 못했습니다.", + "authTitle": "서버가 로그인을 거부했습니다.", + "tools": "도구", + "toolsUnknown": "연결을 테스트하면 도구를 볼 수 있습니다.", + "toolsNone": "서버가 도구를 제공하지 않습니다.", + "toolsSearch": "도구 검색", + "noToolMatch": "“{{query}}”와 일치하는 도구가 없습니다.", + "noDescription": "설명 없음.", + "connection": "연결", + "kind": "종류", + "address": "주소", + "command": "명령", + "added": "추가됨", + "queryHidden": "주소에서 ? 뒤의 부분은 키가 들어 있을 수 있어 숨겼습니다.", + "signIn": "로그인", + "token": "Bearer 토큰", + "headers": "헤더", + "env": "변수", + "saved": "저장됨", + "none": "없음", + "encrypted": "서버에 암호화해 저장되며 다시 표시되지 않습니다." + }, + "sheet": { + "addTitle": "커넥터 추가", + "editTitle": "{{name}} 편집", + "addDesc": "MCP 서버를 연결합니다. 채팅에서 쓰기 전에 테스트합니다.", + "editDesc": "저장된 비밀 값은 교체하거나 제거하지 않는 한 유지됩니다.", + "testDesc": "LocalAI가 저장한 내용으로 연결하고 도구 목록을 보여 줍니다.", + "close": "닫기", + "kind": "종류", + "remote": "원격 서버", + "remoteHelp": "이 컴퓨터가 http 또는 https로 접속하는 MCP 서버입니다.", + "local": "로컬 명령", + "localHelp": "LocalAI가 이 컴퓨터에서 실행하는 프로그램입니다. 관리자 전용입니다.", + "basics": "연결", + "name": "이름", + "nameHelp": "채팅의 도구 메뉴에 표시됩니다.", + "url": "주소", + "urlHelp": "사설 네트워크의 서버는 관리자가 허용하지 않으면 거부됩니다.", + "command": "명령", + "commandHelp": "셸 없이 실행할 프로그램입니다.", + "args": "인수", + "argsHelp": "한 줄에 인수 하나.", + "signIn": "로그인", + "signInHelp": "서버가 키를 요구할 때만 입력하세요.", + "envIntro": "명령이 받는 변수입니다. 예: API 키.", + "token": "Bearer 토큰", + "tokenHelp": "Authorization 헤더로 전송됩니다.", + "tokenSaved": "토큰이 저장되어 있습니다. 새 토큰을 입력하면 교체됩니다.", + "tokenSavedShort": "저장됨", + "clearToken": "저장된 토큰 제거", + "headers": "헤더", + "headersHelp": "서버에 필요한 다른 헤더입니다. 예: API 키 헤더.", + "headersSaved": "헤더가 저장되어 있습니다. 새 행을 추가하면 모두 교체됩니다.", + "clearHeaders": "저장된 헤더 제거", + "headerName": "헤더 이름", + "headerValue": "값", + "addHeader": "헤더 추가", + "env": "환경 변수", + "envSaved": "변수가 저장되어 있습니다. 새 행을 추가하면 모두 교체됩니다.", + "clearEnv": "저장된 변수 제거", + "envName": "변수", + "addEnv": "변수 추가", + "removeRow": "{{n}}번째 행 제거", + "showValue": "값 보기", + "hideValue": "값 숨기기", + "encrypted": "토큰, 헤더, 변수는 서버에 암호화해 저장되며 다시 표시되지 않습니다.", + "cancel": "취소", + "saving": "저장 중", + "saveAndTest": "저장하고 테스트", + "savedOn": "저장했습니다. 켜져 있습니다.", + "savedOff": "저장했습니다. 직접 켤 때까지 꺼진 상태로 유지됩니다.", + "testing": "{{name}}에 연결하는 중…", + "okTitle": "연결됨.", + "okBody_one": "{{name}}에서 도구 {{count}}개를 제공합니다.", + "okBody_other": "{{name}}에서 도구 {{count}}개를 제공합니다.", + "okNoTools": "서버가 도구를 제공하지 않습니다.", + "failTitle": "연결할 수 없습니다.", + "failHint": "주소를 확인한 뒤 다시 테스트하세요.", + "failAuth": "토큰이나 헤더를 확인하세요.", + "back": "정보 편집", + "retry": "다시 테스트", + "done": "완료", + "keepOff": "꺼 두기", + "turnOn": "켜기", + "discardTitle": "변경 사항을 버릴까요?", + "discardBody": "이 양식에 입력한 내용이 사라집니다.", + "discard": "버리기", + "keepEditing": "계속 편집" + }, + "errors": { + "nameRequired": "이름을 입력하세요.", + "nameTaken": "다른 커넥터가 이 이름을 사용합니다.", + "urlRequired": "서버 주소를 입력하세요.", + "urlInvalid": "올바른 주소가 아닙니다.", + "urlScheme": "주소는 http:// 또는 https://로 시작해야 합니다.", + "urlCredentials": "자격 증명은 주소가 아니라 토큰이나 헤더에 넣으세요.", + "commandRequired": "실행할 명령을 입력하세요." + }, + "remove": { + "title": "{{name}}을(를) 제거할까요?", + "body": "커넥터와 저장된 토큰, 헤더, 변수가 삭제됩니다. 이 커넥터를 쓰는 저장된 에이전트는 더 이상 그 도구를 호출할 수 없습니다.", + "confirm": "제거", + "pending": "제거 중" + }, + "toasts": { + "removed": "“{{name}}”을(를) 제거했습니다.", + "on": "“{{name}}”이(가) 켜졌습니다.", + "off": "“{{name}}”이(가) 꺼졌습니다." + }, + "import": { + "title": "이 브라우저에 저장된 서버", + "text": "이전 채팅 페이지가 이 MCP 서버들을 이 브라우저에 저장했습니다. 가져오면 서버에 저장되어 모든 브라우저와 세션에서 사용할 수 있습니다. 꺼진 상태로 만들어집니다. 토큰과 헤더는 서버로 보내진 뒤 이 브라우저에서 제거됩니다.", + "button": "이 브라우저에서 서버 {{count}}개 가져오기", + "buttonOne": "이 브라우저에서 서버 1개 가져오기", + "busy": "가져오는 중", + "headersCount": "헤더 {{count}}개", + "done": "{{count}}개를 가져왔습니다. 꺼져 있으니 아래에서 테스트하고 켜세요.", + "failed": "{{name}}을(를) 가져오지 못했습니다: {{error}}", + "left": "가져오지 못한 서버는 이 브라우저에 남아 있습니다." + } + }, + "connectorsPage": { + "loadFailed": "커넥터를 불러올 수 없습니다.", + "waitingTitle": "애드온 대기 중", + "waitingText": "이 연결은 유지되지만, 해당 애드온을 설치하고 켜야 실행됩니다." + }, + "tools": { + "commands": { + "title": "여기서는 명령을 실행할 수 없습니다", + "none": "코드를 실행하는 애드온이 설치되어 있지 않아, 어시스턴트는 파일을 쓸 수 있지만 스크립트나 명령은 실행할 수 없습니다.", + "off": "이 채팅에서 Sandbox 애드온이 꺼져 있습니다. 도구 메뉴에서 켜면 어시스턴트가 코드를 실행할 수 있습니다.", + "unavailable": "Sandbox 애드온이 설치되어 있지만 이 서버에서는 명령을 실행할 수 없습니다.", + "unavailableWhy": "Sandbox 애드온이 이 서버에서 명령을 실행할 수 없습니다: {{reason}}", + "install": "Sandbox 설치", + "openSandbox": "Sandbox 애드온 열기", + "askInstall": "관리자에게 Sandbox 애드온 설치를 요청하세요.", + "askFix": "관리자에게 해결을 요청하세요.", + "hide": "숨기기", + "hideLabel": "이 알림 숨기기", + "chipTitle": "이 채팅은 코드나 명령을 실행할 수 없습니다. 자세히 보려면 선택하세요.", + "hostTitle": "명령이 이 컴퓨터에서 실행됩니다", + "host": "명령은 이 컴퓨터의 파일과 네트워크를 그대로 쓰며 격리되지 않고 실행됩니다. 격리하려면 Sandbox를 설치하세요.", + "hostOff": "이 채팅에서 Sandbox가 꺼져 있어 명령이 이 컴퓨터의 파일과 네트워크를 쓰며 격리 없이 실행됩니다. 격리하려면 이 메뉴에서 Sandbox를 켜세요.", + "hostUnavailable": "여기서는 Sandbox를 실행할 수 없어 명령이 이 컴퓨터의 파일과 네트워크를 쓰며 격리 없이 실행됩니다.", + "hostUnavailableWhy": "여기서는 Sandbox를 실행할 수 없어({{reason}}) 명령이 이 컴퓨터의 파일과 네트워크를 쓰며 격리 없이 실행됩니다.", + "hostChipTitle": "이 채팅의 명령은 격리 없이 이 컴퓨터에서 실행됩니다. 자세히 보려면 선택하세요." + }, + "consent": { + "title": "이 명령을 이 컴퓨터에서 실행할까요?", + "body": "명령은 격리 없이 이 컴퓨터에서 실행됩니다. LocalAI가 읽을 수 있는 파일을 읽고 인터넷과 사용자 네트워크에 접속할 수 있습니다. 격리하려면 Sandbox를 설치하세요.", + "once": "한 번 실행", + "always": "이 인스턴스에서 항상", + "deny": "실행 안 함", + "alwaysHint": "'이 인스턴스에서 항상'을 고르면 모든 사용자의 채팅이 묻지 않고 여기서 명령을 실행합니다(설정 > 도구).", + "askAdmin": "관리자만 이 컴퓨터에서 명령을 허용할 수 있습니다. 관리자에게 요청하거나 직접 명령을 실행하세요.", + "expired": "응답 없음: 요청한 차례가 끝나서 명령이 실행되지 않았습니다." + }, + "provider": { + "sandbox": "Sandbox", + "addon": "애드온 {{name}}", + "host": "이 컴퓨터, 격리 없음", + "hostOnce": "이 컴퓨터, 한 번 허용", + "hostDenied": "실행 안 됨: 거부됨", + "hostTimeout": "실행 안 됨: 응답 없음", + "hostAdmin": "실행 안 됨: 관리자 필요" + }, + "builtins": "기본 제공 도구", + "builtin": { + "web": "웹", + "saved_agents": "저장된 에이전트", + "triggers": "트리거", + "shell": "명령 실행", + "files": "내 파일", + "media": "이미지와 오디오", + "collections": "컬렉션", + "subagents": "하위 에이전트" + }, + "builtinBy": "{{tools}} · 기본 제공", + "builtinByAddon": "{{tools}} · {{provider}}이(가) 제공", + "servedBy": "애드온 {{provider}}이(가) 제공", + "servedByTool": "{{tool}}: {{provider}}", + "servedByBuiltin": "기본 제공", + "sandbox": { + "starting": "Sandbox가 시작되는 중입니다. LocalAI가 {{seconds}}초 후 다시 확인합니다." + }, + "fallbackBuiltin": "{{addon}}이(가) {{tool}}을(를) 대체하지만 지금은 이 채팅에서 사용할 수 없습니다(중지됨, 여기서 꺼짐 또는 허용되지 않음). 기본 제공 {{tool}}이(가) 사용됩니다.", + "fallbackNone": "{{addon}}이(가) {{tool}}을(를) 대체하지만 지금은 이 채팅에서 사용할 수 없고, 기본 제공 {{tool}}도 꺼져 있습니다. 이 채팅에는 {{tool}}이(가) 없습니다.", + "shell": { + "machine": "이 컴퓨터", + "isolated": "명령이 격리되어 실행됩니다" + } + }, + "vision": { + "describedBy": "이 모델은 이미지를 볼 수 없습니다. 다른 모델을 선택하지 않으면 {{model}}이(가) 이미지를 설명합니다.", + "none": "이 모델은 이미지를 볼 수 없고, 여기에는 이미지를 설명할 수 있는 모델이 없습니다. 이미지를 볼 수 있는 모델을 선택하세요." + }, + "egress": { + "label": "네트워크", + "preset": { + "off": "끔", + "registries": "패키지 레지스트리", + "all": "모든 공개 사이트" + }, + "presetHint": { + "off": "샌드박스의 명령은 어떤 네트워크에도 연결되지 않습니다.", + "registries": "명령은 패키지 레지스트리(PyPI, npm, Go, crates.io, RubyGems, Maven Central, GitHub 릴리스)에만 연결됩니다.", + "all": "명령은 모든 공개 사이트에 연결됩니다." + }, + "limited": "관리자가 제한함", + "limitedBy": "관리자가 허용하는 최대 범위: {{limit}}.", + "hostNetwork": "명령은 이 머신에서 실행되며 이 머신의 네트워크를 사용합니다. 네트워크 제한에는 Sandbox 애드온이 필요합니다.", + "panel": "규칙과 요청 ({{count}})", + "rules": "이 채팅의 규칙", + "cards": "이 채팅의 요청", + "ruleAllow": "이 채팅에서 허용됨", + "ruleDeny": "이 채팅에서 거부됨", + "aboveLimit": "제한을 넘어 적용되지 않음", + "remove": "삭제", + "removeLabel": "{{host}} 규칙 삭제", + "card": { + "pending": "응답 대기 중", + "allowedOnce": "한 번 허용됨", + "allowedChat": "이 채팅에서 허용됨", + "denied": "거부됨", + "expired": "응답 없음", + "refused": "거부됨: 서버의 네트워크 제한이 허용하지 않습니다" + }, + "consent": { + "title": "네트워크 접근", + "body": "이 채팅의 명령이 다음에 연결하려고 합니다:", + "once": "한 번 허용", + "chat": "이 채팅에서 허용", + "deny": "거부", + "preset": "이 채팅: {{preset}}. 관리자가 정한 제한: {{limit}}.", + "adminOnly": "이 호스트는 내부 네트워크에 있습니다. 관리자만 허용할 수 있습니다.", + "answered": "{{who}}: {{state}}, {{time}}", + "answeredNoWho": "{{state}}, {{time}}", + "you": "나" + }, + "blocked": { + "titleOff": "네트워크 접근이 꺼져 있습니다", + "titleCapped": "네트워크 접근이 제한되어 있습니다", + "bodyOff": "이 서버에서는 샌드박스의 네트워크 접근이 꺼져 있습니다. 명령이 다음에 접속하려 했습니다:", + "bodyCapped": "이 서버는 샌드박스 네트워크에 {{limit}}만 허용합니다. 명령이 다음에 접속하려 했습니다:", + "adminOff": "관리자가 {{name}}의 설정에서 켤 수 있습니다.", + "adminCapped": "관리자가 {{name}}의 설정에서 한도를 높일 수 있습니다.", + "adminLink": "{{name}} 설정 열기", + "askAdmin": "이 서버의 관리자에게 변경을 요청하세요.", + "rowOff": "꺼짐: 관리자가 네트워크 접근을 허용하지 않았습니다.", + "runner": "명령을 실행하는 애드온" + }, + "allowedHosts": "관리자가 항상 허용함:", + "allowedHostsOff": "네트워크가 켜져 있을 때 적용됩니다.", + "allowedHostsNote": "관리자가 호스트 {{count}}개를 항상 허용합니다.", + "limitNote": "이 채팅의 네트워크입니다. 채팅이 접근할 수 있는 최대 범위는 관리자가 정합니다.", + "asksAbove": "이 채팅은 {{choice}}을(를) 요청합니다. 관리자 제한은 {{limit}}입니다." + }, + "inbox": { + "unread": "읽지 않음 {{count}}개", + "markAll": "모두 읽음으로 표시", + "delete": "삭제", + "pushOn": "브라우저 알림 켜기", + "pushOff": "브라우저 알림 끄기", + "pushDenied": "이 사이트의 브라우저 알림이 차단되어 있습니다.", + "pushUnavailable": "이 서버는 푸시 구독을 저장할 수 없습니다.", + "clearAll": "모두 지우기", + "clearRead": "읽은 항목 지우기", + "cancel": "취소", + "markRead": "읽음으로 표시", + "cleared_other": "알림 {{count}}개를 삭제했습니다.", + "confirmAll_other": "알림 {{count}}개를 모두 삭제할까요? 되돌릴 수 없습니다.", + "confirmRead_other": "읽은 알림 {{count}}개를 삭제할까요? 읽지 않은 알림은 유지됩니다.", + "onlyRead": "읽은 {{count}}개만", + "showing": "{{total}}개 중 {{shown}}개 표시", + "showMore": "더 보기", + "cleared_one": "알림 {{count}}개를 삭제했습니다.", + "confirmAll_one": "알림 {{count}}개를 모두 삭제할까요? 되돌릴 수 없습니다.", + "confirmRead_one": "읽은 알림 {{count}}개를 삭제할까요? 읽지 않은 알림은 유지됩니다.", + "title": "알림", + "buttonUnread_one": "알림, 읽지 않음 {{count}}개", + "buttonUnread_other": "알림, 읽지 않음 {{count}}개", + "filterLabel": "알림 보기", + "filterAll": "전체", + "filterUnread": "읽지 않음", + "filterFailed": "실패", + "more": "추가 작업", + "close": "닫기", + "day": { + "today": "오늘", + "yesterday": "어제", + "earlier": "이전" + }, + "emptyTitle": "아직 알림이 없습니다", + "emptyBody": "트리거와 커넥터의 결과가 여기에 표시됩니다. 자리를 비운 사이에 끝난 작업도 포함됩니다.", + "caughtUp": "모두 확인했습니다.", + "caughtUpTitle": "모두 확인했습니다", + "caughtUpBody": "읽지 않은 알림이 없습니다.", + "noFailedTitle": "실패한 항목이 없습니다", + "noFailedBody": "실패한 실행과 커넥터가 여기에 표시됩니다.", + "loadError": "알림을 불러오지 못했습니다.", + "source": { + "trigger": "트리거", + "connector": "커넥터", + "system": "LocalAI" + }, + "itemUnread": "{{title}}, 읽지 않음", + "arrived": "새 알림: {{title}}", + "arrivedMany_one": "새 알림 {{count}}개", + "arrivedMany_other": "새 알림 {{count}}개" + }, + "change": { + "loading": "트리거 변경 사항을 읽는 중...", + "unread": "트리거 변경 사항을 읽을 수 없습니다: {{message}}", + "removed": "이 변경 사항은 더 이상 보관되지 않습니다.", + "none": "없음", + "on": "켜짐", + "off": "꺼짐(일시 중지)", + "becomes": "변경 후", + "neverRan": "아직 실행되지 않았습니다", + "lastRun": "마지막 실행", + "lastRunValue": "{{status}}, {{when}}", + "runStatus": { + "queued": "대기 중", + "running": "실행 중", + "done": "완료", + "failed": "실패", + "skipped": "건너뜀" + }, + "updateTitle": "{{name}} 변경", + "deleteTitle": "{{name}}을(를) 삭제할까요?", + "updateHelp": "제안된 변경입니다. 적용하기 전에는 아무것도 바뀌지 않습니다.", + "deleteHelp": "삭제를 누르기 전에는 아무것도 삭제되지 않습니다. 하루 동안 실행 취소할 수 있습니다.", + "stale": "제안 후에 트리거가 변경되거나 삭제되어 적용할 수 없습니다. 변경을 다시 요청하세요.", + "apply": "적용", + "delete": "삭제", + "keep": "유지", + "field": { + "name": "이름", + "prompt": "프롬프트", + "when": "실행 시점", + "time_zone": "시간대", + "daily_cap": "일일 한도", + "enabled": "상태", + "reads": "읽는 내용", + "writes": "쓰는 파일", + "widget": "홈 위젯", + "estimated_tokens": "실행당 토큰", + "agent": "에이전트", + "model": "모델" + }, + "receipt": { + "update": "변경됨:", + "pause": "일시 중지됨:", + "resume": "다시 시작됨:", + "delete": "삭제됨:", + "fields": "변경된 항목: {{fields}}", + "dismissed": "변경 취소됨:", + "kept": "유지됨:", + "undone": "실행 취소됨:", + "restored": "복원됨:" + }, + "blocked": { + "expired": "실행 취소 가능 시간이 지났습니다. 트리거 페이지에서 변경하세요.", + "edited": "그 사이 트리거가 변경되었습니다. 트리거 페이지에서 변경하세요.", + "removed": "그 사이 트리거가 삭제되었습니다.", + "full": "허용된 최대 개수의 트리거가 있습니다. 먼저 트리거 페이지에서 하나를 삭제하세요." + }, + "announce": { + "applied": "{{name}}을(를) 변경했습니다.", + "deleted": "{{name}}을(를) 삭제했습니다.", + "dismissed": "{{name}} 변경을 취소했습니다.", + "kept": "{{name}}을(를) 유지했습니다.", + "undone": "{{name}} 변경을 실행 취소했습니다.", + "restored": "{{name}}을(를) 복원했습니다." + } + }, + "loops": { + "region": "이 채팅의 반복", + "unit": { + "seconds_one": "{{count}}초", + "seconds_other": "{{count}}초", + "minutes_one": "{{count}}분", + "minutes_other": "{{count}}분", + "hours_one": "{{count}}시간", + "hours_other": "{{count}}시간" + }, + "unitShort": { + "seconds_one": "{{count}}초", + "seconds_other": "{{count}}초", + "minutes_one": "{{count}}분", + "minutes_other": "{{count}}분", + "hours_one": "{{count}}시간", + "hours_other": "{{count}}시간" + }, + "confirm": { + "every": "멈출 때까지 {{every}}마다 이것을 실행할까요?", + "selfPaced": "멈출 때까지 모델이 요청할 때마다 이것을 다시 실행할까요?", + "start": "시작", + "cancel": "취소" + }, + "chip": { + "name": "반복", + "every": "{{every}}마다", + "selfPaced": "모델이 시점 결정", + "run_one": "{{count}}번째 실행", + "run_other": "{{count}}번째 실행", + "paused": "일시 중지됨", + "running": "지금 실행 중", + "next": "다음 실행 {{time}}", + "ended": "종료됨", + "wait": "기다리는 중: {{wait}}" + }, + "actions": { + "pause": "일시 중지", + "resume": "다시 시작", + "stop": "중지", + "dismiss": "닫기" + }, + "state": { + "pending": "승인을 기다리는 중", + "active": "활성", + "paused": "일시 중지됨", + "ended": "종료됨" + }, + "list": { + "title": "이 채팅의 반복", + "close": "닫기", + "empty": "이 채팅에는 반복이 없습니다.", + "hint": "하나를 멈추려면 /loop stop 과 그 줄 앞의 코드를 입력하세요." + }, + "slash": { + "usage": "/loop, 원하면 간격, 그리고 반복할 내용을 입력하세요. 예: /loop 5m 빌드 확인.", + "notFound": "이 채팅에 {{id}}와 맞는 반복이 없습니다.", + "noChat": "먼저 메시지를 보낸 다음 이 채팅에서 반복을 시작하세요.", + "stopped_one": "반복 {{count}}개를 멈췄습니다.", + "stopped_other": "반복 {{count}}개를 멈췄습니다." + } + } +} diff --git a/core/http/react-ui/public/locales/ko/collections.json b/core/http/react-ui/public/locales/ko/collections.json index dfebd8263..dc2051ea6 100644 --- a/core/http/react-ui/public/locales/ko/collections.json +++ b/core/http/react-ui/public/locales/ko/collections.json @@ -4,13 +4,13 @@ "newPlaceholder": "새 컬렉션 이름...", "actions": { "create": "만들기", - "creating": "생성 중...", "details": "세부 정보", "reset": "초기화", "delete": "삭제", "viewDetails": "세부 정보 보기", "resetCollection": "컬렉션 초기화", - "deleteCollection": "컬렉션 삭제" + "deleteCollection": "컬렉션 삭제", + "cancel": "취소" }, "sections": { "yourCollections": "내 컬렉션", @@ -19,7 +19,8 @@ "empty": { "title": "아직 컬렉션이 없습니다", "text": "컬렉션을 사용하면 문서를 지식 베이스로 정리하여 에이전트가 RAG(검색 증강 생성)로 검색할 수 있습니다. 위에서 컬렉션을 만들어 시작하세요.", - "noPersonal": "아직 컬렉션이 없습니다." + "noPersonal": "아직 컬렉션이 없습니다.", + "learn": "메모리 작동 방식" }, "deleteDialog": { "title": "컬렉션 삭제", @@ -32,12 +33,29 @@ "confirm": "초기화" }, "toasts": { - "loadFailed": "컬렉션을 불러오지 못했습니다: {{message}}", "created": "컬렉션 \"{{name}}\"이(가) 생성되었습니다", "createFailed": "컬렉션 생성 실패: {{message}}", "deleted": "컬렉션 \"{{name}}\"이(가) 삭제되었습니다", "deleteFailed": "컬렉션 삭제 실패: {{message}}", "reset": "컬렉션 \"{{name}}\"이(가) 초기화되었습니다", "resetFailed": "컬렉션 초기화 실패: {{message}}" + }, + "loadFailed": "컬렉션을 불러올 수 없습니다.", + "sources": { + "loadFailed": "웹 소스를 불러올 수 없습니다." + }, + "entries": { + "loadFailed": "파일을 불러올 수 없습니다." + }, + "starting": { + "title": "메모리를 시작하는 중입니다.", + "text": "서버가 아직 메모리 서비스를 준비하고 있습니다. 이 페이지가 자동으로 다시 확인합니다." + }, + "embedding": { + "missingTitle": "임베딩 모델이 설치되어 있지 않습니다.", + "missingText": "컬렉션은 {{model}}로 파일을 색인하고 검색합니다. 설치되기 전까지는 파일 추가와 검색이 실패합니다.", + "noneSet": "컬렉션에 임베딩 모델이 설정되어 있지 않아(collections_embedding_model) 파일 추가와 검색이 실패합니다.", + "install": "모델에서 찾기", + "askAdmin": "관리자에게 설치를 요청하세요." } } diff --git a/core/http/react-ui/public/locales/ko/common.json b/core/http/react-ui/public/locales/ko/common.json index c1e533589..c13407849 100644 --- a/core/http/react-ui/public/locales/ko/common.json +++ b/core/http/react-ui/public/locales/ko/common.json @@ -111,26 +111,22 @@ "gigabytes": "GB", "terabytes": "TB" }, - "updateReview": { - "title": "업데이트 검토", - "description": "여기서 선택하기 전에는 아무것도 업데이트되지 않습니다.", - "close": "닫기", - "done": "완료", - "selectAll": "모두 선택", - "select": "{{name}} 선택", - "unknownVersion": "알 수 없음", - "latest": "최신", - "blocked": "여기서는 업데이트할 수 없음:", - "upgradeSelected": "선택 항목 업데이트 ({{count}})", - "upgradeAll": "모두 업데이트 ({{count}})", - "running": "업데이트 중...", - "queued": "대기 중", - "statusRunning": "업데이트 중", - "statusDone": "업데이트됨", - "statusFailed": "실패", - "summary": "{{succeeded}}개 업데이트됨, {{failed}}개 실패.", - "empty": "업데이트할 항목이 없습니다.", - "statusStarted": "시작됨", - "summaryStarted": "{{succeeded}}개 시작됨, {{failed}}개 실패." + "ui": { + "tryAgain": "다시 시도", + "undo": "실행 취소", + "loadError": { + "title": "불러오지 못했습니다.", + "serverSaid": "서버 응답: “{{message}}”.", + "nothingChanged": "변경된 내용이 없습니다." + } + }, + "chip": { + "running": "실행 중", + "starting": "시작 중", + "stopped": "중지됨", + "failed": "실패", + "updateAvailable": "업데이트 있음", + "ready": "준비됨", + "noRunsYet": "실행 기록 없음" } } diff --git a/core/http/react-ui/public/locales/ko/explorer.json b/core/http/react-ui/public/locales/ko/explorer.json new file mode 100644 index 000000000..b9f695869 --- /dev/null +++ b/core/http/react-ui/public/locales/ko/explorer.json @@ -0,0 +1,57 @@ +{ + "brand": "{{name}} Explorer", + "openApp": "LocalAI 열기", + "close": "닫기", + "copy": "복사", + "copied": "복사됨", + "loading": "스웜을 불러오는 중", + "title": "공개 스웜", + "lede": "스웜은 P2P 네트워크를 통해 모델 추론을 공유하는 머신들의 그룹입니다. 이 목록은 소유자가 공개하기로 선택한 스웜을 보여 줍니다. 스웜의 토큰을 가진 사람은 누구나 참여할 수 있습니다.", + "count_one": "온라인 스웜 {{count}}개", + "count_other": "온라인 스웜 {{count}}개", + "sorted": "워커가 많은 순", + "workers_one": "워커", + "workers_other": "워커", + "tokenTitle": "토큰의 시작과 끝입니다. '참여 방법'에서 복사하세요.", + "warning": "Explorer는 스웜 토큰의 커뮤니티 목록입니다. 누구나 토큰을 사용해 스웜에 작업을 보내거나 리소스를 공유할 수 있습니다. 본인의 책임하에 사용하세요.", + "listed": "스웜이 목록에 등록되어 있습니다.", + "empty": { + "title": "온라인 워커가 있는 스웜이 없습니다", + "text": "온라인 워커가 한 명 이상 있는 스웜만 표시됩니다. 스웜을 공개하려면 목록에 등록하세요." + }, + "off": { + "title": "이 서버는 explorer가 아닙니다", + "text": "Explorer 페이지에는 explorer 모드로 시작된 LocalAI 서버가 필요합니다. 이 서버에는 표시할 스웜 목록이 없습니다." + }, + "error": { + "title": "스웜을 불러올 수 없습니다", + "text": "explorer 서버가 응답하지 않았습니다. 서버가 실행 중인지 확인한 뒤 다시 시도하세요.", + "retry": "다시 시도" + }, + "join": { + "open": "참여 방법", + "title": "{{name}} 참여", + "text": "이 스웜의 토큰으로 LocalAI 워커를 시작하세요.", + "token": "토큰", + "federated": "연합 클러스터 {{id}}. 두 명령 중 하나로 이 클러스터에 노드를 시작하세요.", + "docker": "Docker 사용", + "cli": "명령줄 사용", + "noCommands": "이 스웜의 워커는 토큰을 통해 연산을 공유합니다. 위의 토큰은 노드가 참여하는 데 필요한 값입니다.", + "docs": "스웜 작동 방식 읽기" + }, + "list": { + "open": "스웜 등록", + "title": "스웜 등록", + "text": "스웜을 공개 목록에 추가합니다. 온라인 워커가 생기면 표시됩니다.", + "name": "이름", + "description": "설명", + "token": "토큰", + "tokenHint": "서버가 출력한 대로의 스웜 네트워크 토큰입니다.", + "public": "등록하면 토큰이 공개됩니다. 목록을 보는 누구나 스웜의 워커를 사용할 수 있습니다.", + "understood": "토큰이 공개된다는 것을 이해했습니다.", + "cancel": "취소", + "add": "이 스웜 등록", + "adding": "등록 중...", + "failed": "스웜을 등록할 수 없습니다." + } +} diff --git a/core/http/react-ui/public/locales/ko/home.json b/core/http/react-ui/public/locales/ko/home.json index e240be633..0b51eade6 100644 --- a/core/http/react-ui/public/locales/ko/home.json +++ b/core/http/react-ui/public/locales/ko/home.json @@ -25,7 +25,10 @@ "title": "채팅으로 LocalAI 관리", "description": "LocalAI와 대화하여 모델을 설치하고, 백엔드를 전환하고, 구성을 편집하고, 상태를 확인하세요.", "open": "어시스턴트 열기", - "tooltip": "채팅으로 LocalAI 관리" + "tooltip": "채팅으로 LocalAI 관리", + "noModel": "먼저 채팅 모델을 선택하세요.", + "startFailed": "어시스턴트 채팅을 시작할 수 없습니다: {{message}}", + "chatTitle": "LocalAI 어시스턴트" }, "input": { "placeholder": "메시지...", @@ -79,13 +82,17 @@ "step2Title": "모델 설치", "step2Body": "설치를 클릭하면 자동으로 다운로드되고 구성됩니다.", "step3Title": "채팅 시작", - "step3Body": "브라우저에서 바로 모델과 채팅하거나 API를 사용하세요." + "step3Body": "브라우저에서 바로 모델과 채팅하거나 API를 사용하세요.", + "sandboxTitle": "코드를 안전하게 실행 (선택)", + "sandboxBody": "Sandbox 애드온을 설치하면 모델이 코드와 명령을 격리된 환경에서 실행합니다. 설치하지 않으면 명령이 이 컴퓨터에서 바로 실행되며, 네트워크에 완전히 접근하고 격리가 없습니다." }, "browseGallery": "모델 갤러리 둘러보기", "importModel": "모델 가져오기", "docs": "문서", "noModelsTitle": "사용 가능한 모델 없음", - "noModelsBody": "아직 설치된 모델이 없습니다. 채팅을 시작할 수 있도록 관리자에게 모델 설정을 요청하세요." + "noModelsBody": "아직 설치된 모델이 없습니다. 채팅을 시작할 수 있도록 관리자에게 모델 설정을 요청하세요.", + "sandboxInstall": "Sandbox 설치", + "sandboxSkip": "건너뛰기" }, "connect": { "title": "하나의 엔드포인트, 모든 API", @@ -105,7 +112,23 @@ "modelsSummary": "Explore, install, and manage models", "create": "Create", "createSummary": "Open a chat, image or voice session", - "operate": "Operate", - "operateSummary": "{{models}} models configured · nodes, activity and traces" + "operate": "관리", + "operateSummary": "{{models}} models configured · nodes, activity and traces", + "allChats": "모든 채팅" + }, + "slash": { + "agents": { + "label": "에이전트", + "desc": "저장된 에이전트, 트리거, 스킬" + }, + "mcp": { + "label": "MCP 커넥터", + "desc": "채팅이 쓸 수 있는 MCP 서버" + }, + "memory": { + "label": "메모리", + "desc": "채팅이 검색할 수 있는 컬렉션" + }, + "complete": "완성" } } diff --git a/core/http/react-ui/public/locales/ko/library.json b/core/http/react-ui/public/locales/ko/library.json new file mode 100644 index 000000000..f0a42aa04 --- /dev/null +++ b/core/http/react-ui/public/locales/ko/library.json @@ -0,0 +1,114 @@ +{ + "usedBy": { + "label": "사용처", + "none": "아직 사용되지 않음", + "line": "{{name}}에서 사용", + "lineMore": "{{name}} 외 +{{count}}", + "all": "모든 스킬", + "allTitle": "이 에이전트는 모든 스킬을 불러옵니다", + "remove": "{{agent}}에서 제거", + "open": "{{agent}} 열기", + "unread": "지금은 사용처를 읽을 수 없습니다.", + "unreadShort": "사용처 알 수 없음" + }, + "notUsed": { + "lead": "아직 사용되지 않았습니다.", + "skill": "어딘가에 추가하기 전까지 라이브러리에 남아 있습니다. 먼저 사용해 보거나 에이전트에 추가하세요.", + "collection": "어딘가에 추가하기 전까지 라이브러리에 남아 있습니다. 여기서 검색하거나 에이전트에 추가하세요.", + "skillShort": "에이전트에 추가하기 전까지 목록에 남아 있습니다." + }, + "tryInContext": "맥락에서 사용해 보기", + "add": { + "button": "추가...", + "title": "{{name}}을(를) 추가", + "agents": "에이전트", + "noAgents": "아직 에이전트가 없습니다. 에이전트를 만든 뒤 여기서 추가하세요.", + "createAgent": "에이전트 만들기", + "added": "추가됨", + "action": "추가", + "addToAgent": "{{agent}}에 추가", + "working": "추가 중...", + "estimate": "추정치는 글자 수를 4로 나눈 값을 사용합니다.", + "toolsMode": "모델이 요청할 때 불러옴", + "tokens": "메시지당 +{{count}} 토큰 (추정)", + "collectionNote": "에이전트는 목록에 있는 컬렉션을 검색할 수 있습니다.", + "kbOn": "지식 베이스를 켭니다", + "noAgentNamed": "{{name}}이라는 이름의 에이전트가 없습니다. 이 컬렉션을 사용하려면 그 이름으로 에이전트를 만드세요.", + "close": "닫기", + "skillNote": "에이전트는 목록에 있는 스킬을 읽을 수 있습니다." + }, + "toasts": { + "skillAdded": "{{name}}을(를) {{agent}}에 추가했습니다", + "skillRemoved": "{{agent}}에서 {{name}}을(를) 제거했습니다", + "skillsOff": "{{agent}}에서 {{name}}을(를) 제거했습니다. 이제 이 에이전트의 스킬이 꺼졌습니다.", + "collectionAdded": "{{name}}을(를) {{agent}}에 추가했습니다", + "collectionRemoved": "{{agent}}에서 {{name}}을(를) 제거했습니다", + "undone": "변경을 되돌렸습니다", + "updateFailed": "{{agent}}을(를) 업데이트할 수 없습니다: {{message}}", + "undo": "실행 취소", + "dismiss": "닫기" + }, + "simulate": { + "button": "메시지 시뮬레이션", + "title": "메시지 시뮬레이션", + "desc": "컨텍스트를 고르면 무엇을 불러올지 볼 수 있습니다. 아무것도 모델로 전송되지 않습니다.", + "close": "닫기", + "context": "컨텍스트", + "contextCollection": "컬렉션만", + "contextAgent": "에이전트: {{name}}", + "collectionOn": "컬렉션", + "noCollections": "아직 컬렉션이 없습니다.", + "contextHintCollection": "메시지로 컬렉션을 검색하고, 반환된 구절을 점수와 함께 보여 줍니다. 에이전트나 모델은 관여하지 않습니다.", + "contextHintAgent": "이 에이전트가 프롬프트에 유지하는 내용과, 이 메시지에 대해 지식 베이스가 추가할 구절을 보여 줍니다.", + "message": "메시지", + "messagePlaceholder": "사람이 물어볼 만한 내용을 입력하세요", + "passages": "구절", + "run": "실행", + "running": "실행 중...", + "onlyForTest": "이 테스트에서만", + "removeExtra": "이 테스트에서 {{name}} 제외", + "tryAlso": "아직 추가하지 않은 항목 시도", + "tryAlsoPick": "스킬 또는 컬렉션을 고르세요", + "extraSkill": "스킬 {{name}}", + "extraCollection": "컬렉션 {{name}}", + "budgetTitle": "이 컨텍스트가 추가하는 양", + "budgetTotal": "메시지 주변에 약 {{count}} 토큰", + "budgetMessage": "메시지: {{count}} 토큰", + "budgetNote": "추정치: 글자 수를 4로 나눈 값. 여기서는 모델의 컨텍스트 창을 알 수 없으므로 한도 대비 비율은 표시하지 않습니다.", + "instructions": "지침", + "skills": "스킬", + "memory": "메모리", + "skillsTitle": "이 컨텍스트가 불러오는 스킬", + "skillsNone": "이 에이전트에 켜진 스킬이 없습니다.", + "skillsPrompt": "모든 메시지의 프롬프트에 포함됩니다.", + "skillsTools": "모델이 요청할 때만 읽습니다.", + "skillsBoth": "프롬프트에 포함되며, 모델이 요청하면 다시 읽습니다.", + "skillsAll": "이 에이전트에 모든 스킬이 켜져 있습니다.", + "skillMissing": "{{name}}이(가) 목록에 있지만 그 이름의 스킬이 없습니다.", + "skillTokens": "{{count}} 토큰", + "skillTokensTools": "처음에는 0", + "memoryTitle": "전송될 메모리", + "memoryNone": "이 에이전트의 목록에 컬렉션이 없습니다. 사용하려면 에이전트에 컬렉션을 추가하세요.", + "memoryNoCollection": "이 에이전트의 지식 베이스는 켜져 있지만 {{name}}이라는 컬렉션이 아직 없습니다.", + "memoryTools": "이 에이전트는 모델이 요청할 때만 검색합니다. 처음에는 메시지에 아무것도 추가되지 않습니다.", + "memoryTesting": "컬렉션 {{name}}, 이 테스트에서만", + "memoryFrom": "{{name}}에서", + "passagesCount": "구절 {{count}}개, 유사도순", + "noPassages": "이 메시지에 대해 반환된 것이 없습니다.", + "pickMessage": "먼저 메시지를 입력하세요.", + "searchFailed": "검색 실패: {{message}}", + "score": "점수 {{score}}", + "tokens": "{{count}} 토큰", + "noAgents": "아직 에이전트가 없습니다. 그래도 컬렉션을 단독으로 검색할 수 있습니다.", + "backendNote": "여기서는 모델 드라이 런을 사용할 수 없으므로 답변을 시뮬레이션하지 않습니다." + }, + "search": { + "failed": "검색 실패: {{message}}" + }, + "agentBudget": "선택한 스킬은 모든 메시지에 약 {{count}} 토큰을 더합니다 (추정: 글자 수를 4로 나눈 값).", + "agentKb": { + "reads": "이 에이전트는 {{name}}이라는 컬렉션을 읽습니다.", + "exists": "이 에이전트는 {{name}}이라는 컬렉션을 읽습니다. 메모리에서 이 컬렉션을 채우고 검색할 수 있습니다.", + "created": "이 에이전트는 {{name}}이라는 컬렉션을 읽습니다. 저장할 때 만들어집니다." + } +} diff --git a/core/http/react-ui/public/locales/ko/modelEditor.json b/core/http/react-ui/public/locales/ko/modelEditor.json new file mode 100644 index 000000000..7ac73fa46 --- /dev/null +++ b/core/http/react-ui/public/locales/ko/modelEditor.json @@ -0,0 +1,6 @@ +{ + "proxyVision": { + "label": "이 모델은 이미지를 볼 수 있음", + "hint": "업스트림 모델이 이미지를 받으면 켜세요. LocalAI는 이를 확인할 수 없습니다. 첨부한 이미지는 메시지와 함께 모델로 전송됩니다." + } +} diff --git a/core/http/react-ui/public/locales/ko/models.json b/core/http/react-ui/public/locales/ko/models.json index e47a7da87..5c40f1e58 100644 --- a/core/http/react-ui/public/locales/ko/models.json +++ b/core/http/react-ui/public/locales/ko/models.json @@ -1,5 +1,6 @@ { "lifecycle": { + "supporting": "이 컴퓨터에 맞는 모델을 찾아 설치하고, 설치된 모델을 관리합니다.", "title": "Models", "navLabel": "Model lifecycle", "views": { "explore": "Explore", "installed": "Installed" }, @@ -81,6 +82,10 @@ "delete": "삭제" }, "filters": { + "sheetDone": "모델 보기", + "sheetTitle": "필터", + "sheetButtonCount": "필터 ({{count}})", + "sheetButton": "필터", "all": "전체", "llm": "채팅", "image": "이미지", diff --git a/core/http/react-ui/public/locales/ko/nav.json b/core/http/react-ui/public/locales/ko/nav.json index 2cf50a4b8..278f22b7d 100644 --- a/core/http/react-ui/public/locales/ko/nav.json +++ b/core/http/react-ui/public/locales/ko/nav.json @@ -10,12 +10,13 @@ "changeLanguage": "언어 변경", "logout": "로그아웃", "accountSettings": "계정 설정", + "yourSettings": "내 설정", "account": "계정", "accountFor": "계정: {{name}}", "sections": { "recognition": "인식", "build": "구축", - "operate": "운영", + "operate": "관리", "workspace": "워크스페이스" }, "operate": { @@ -66,7 +67,14 @@ "logs": "Logs", "trafficModels": "Models", "gpuHost": "GPU and host", - "prometheus": "Prometheus" + "prometheus": "Prometheus", + "chats": "채팅 세션", + "files": "파일", + "triggers": "트리거", + "mcpConnectors": "MCP 커넥터", + "chatsAdmin": "모든 사용자의 채팅", + "pages": "페이지", + "allChats": "모든 채팅" }, "footer": { "github": "GitHub", @@ -99,7 +107,15 @@ "import": "파일, URL 또는 Hugging Face 저장소에서 모델을 추가합니다.", "voices": "화자를 식별하고 음성 프로필 라이브러리를 관리합니다.", "faces": "얼굴을 감지, 비교, 매칭합니다.", - "addons": "애드온을 설치하고 그것이 제공하는 앱을 엽니다." - } - } + "addons": "애드온을 설치하고 그것이 제공하는 앱을 엽니다.", + "triggers": "일정에 따라 또는 파일이 도착하면 채팅을 자동으로 시작합니다." + }, + "operateSupporting": "이 서버에서 실행 중인 것, 사용 현황, 설정을 확인합니다." + }, + "pinnedPages": "고정된 페이지", + "new": "새 기능", + "recentChats": "최근 채팅", + "newChat": "새 채팅", + "untitledChat": "제목 없는 채팅", + "chatRunning": "진행 중" } diff --git a/core/http/react-ui/public/locales/ko/operate.json b/core/http/react-ui/public/locales/ko/operate.json new file mode 100644 index 000000000..abe36e529 --- /dev/null +++ b/core/http/react-ui/public/locales/ko/operate.json @@ -0,0 +1,57 @@ +{ + "backends": { + "filterLabel": "백엔드 필터", + "check": "업데이트 확인", + "reinstallBackend": "백엔드 다시 설치", + "deleteBackend": "백엔드 삭제", + "updateAllTitle": "모든 백엔드 업데이트", + "updateAllIntro": "업데이트할 백엔드를 선택하세요. 각각 하나씩 차례로 업데이트되며, 하나가 실패해도 나머지는 계속됩니다.", + "updateSelected": "선택 항목 업데이트", + "loadFailedTitle": "백엔드 카탈로그를 불러오지 못했습니다.", + "filtersButton": "필터", + "filtersDone": "완료", + "filterGroups": { + "state": "상태", + "type": "유형", + "show": "표시", + "library": "라이브러리" + }, + "showVariants": "변형 빌드", + "showDevelopment": "개발 빌드", + "allLibraries": "모든 라이브러리", + "moreActions": "백엔드 작업 더 보기", + "installFromUrl": "URL에서 설치…", + "manageLibraries": "라이브러리 관리…", + "count_one": "백엔드 {{count}}개", + "count_other": "백엔드 {{count}}개", + "countOf": "백엔드 {{total}}개 중 {{shown}}개", + "activeFilters": "적용된 필터", + "removeFilter": "필터 제거: {{name}}", + "checking": "업데이트 확인 중…", + "updatesAvailable_one": "업데이트 {{count}}개 있음", + "updatesAvailable_other": "업데이트 {{count}}개 있음", + "reviewUpdates": "검토", + "close": "닫기", + "installedLoadFailedTitle": "설치된 백엔드를 불러오지 못했습니다." + }, + "capacity": { + "diskChip": "디스크 거의 가득 참" + }, + "activity": { + "kind": { + "addon": "애드온" + }, + "verb": { + "upgrading": "{{kind}} 업그레이드 중", + "failedUpgrade": "{{kind}}을(를) 업그레이드할 수 없습니다" + }, + "filter": { + "addons": "애드온" + }, + "upgradeVersions": "{{from}} → {{to}}", + "rowUpgraded": "{{duration}} 만에 업그레이드됨", + "rowUpgradedPlain": "업그레이드됨", + "viewInAddons": "애드온에서 보기", + "upgradeWaiting": "애드온 서비스가 시작되기를 기다리는 중입니다. {{seconds}}초 후 다시 확인합니다." + } +} diff --git a/core/http/react-ui/public/locales/ko/pages.json b/core/http/react-ui/public/locales/ko/pages.json new file mode 100644 index 000000000..d43ee2fd9 --- /dev/null +++ b/core/http/react-ui/public/locales/ko/pages.json @@ -0,0 +1,138 @@ +{ + "subtitle": "어시스턴트가 만들어 준 작은 도구예요. 여기 샌드박스에서 열리고 데이터는 내 파일에 저장돼요.", + "card": { + "pin": "사이드바에 고정", + "unpin": "사이드바에서 고정 해제", + "changedBy": { + "you": "{{when}} 내가 변경", + "assistant": "{{when}} 어시스턴트가 변경" + }, + "menu": "{{title}} 더보기", + "files": "파일 보기", + "rename": "이름 바꾸기", + "renameLabel": "새 폴더 이름", + "renameHint": "소문자, 숫자, 하이픈만 쓸 수 있어요.", + "renameSave": "저장", + "renameCancel": "취소", + "renameFailed": "페이지 이름을 바꾸지 못했어요: {{message}}", + "delete": "페이지 삭제", + "deleteTitle": "“{{title}}”을(를) 삭제할까요?", + "deleteMessage": "{{folder}} 폴더가 휴지통으로 이동해요. 파일에서 복원할 수 있어요.", + "deleted": "{{title}}이(가) 휴지통에 있어요.", + "deleteFailed": "페이지를 삭제하지 못했어요: {{message}}" + }, + "empty": { + "text": "페이지는 작업 보드나 읽기 목록 같은 작은 도구예요. 채팅에서 어시스턴트에게 요청하거나 준비된 페이지로 시작하세요.", + "starting": "페이지를 복사하는 중", + "starterFailed": "페이지를 복사하지 못했어요: {{message}}", + "ask": "페이지 요청하기", + "askPrompt": "다음과 같은 페이지를 만들어 주세요: ", + "starterReading": "읽기 목록으로 시작", + "note": "페이지가 없으면 이 항목은 사이드바에서 사라져요. 다음 페이지를 만들면 다시 나타나요.", + "learn": "페이지 사용 방법" + }, + "askNew": "새 페이지 요청하기", + "yours": "내 페이지", + "filter": "페이지 필터", + "shared": { + "yours": "내 페이지", + "group": "나와 공유됨", + "note": "공유 페이지는 내가 허용한 뒤에만 실행됩니다. 작성자가 바꾸면 다시 묻습니다.", + "offNotice": "이 서버에서 공유 페이지가 꺼져 있습니다. 관리자가 껐습니다.", + "off": "이 서버에서 공유 페이지가 꺼져 있습니다. 관리자가 껐습니다.", + "gone": "이 페이지는 더 이상 공유 폴더에 없거나 접근 권한이 없습니다.", + "review": "검토", + "from": "{{author}} 작성", + "menu": "페이지 메뉴", + "badge": { + "notAllowed": "아직 허용 안 됨", + "changed": "허용한 뒤 변경됨", + "allowed": "허용됨" + } + }, + "viewer": { + "stopAllowing": "이 페이지 허용 중지", + "authorChanged": "{{author}}님이 이 페이지를 변경했습니다. 변경을 검토할 때까지 허용한 버전이 계속 실행됩니다.", + "sandboxShared": "샌드박스. 나로서 {{folder}} 안에서만 실행되며 네트워크에 접근할 수 없습니다." + }, + "trust": { + "title": "\"{{title}}\"을(를) 열까요?", + "desc": "이 페이지는 공유 폴더에서 왔습니다. 페이지는 나로서 실행되므로 실행 전에 확인하세요.", + "who": "{{author}} 작성", + "whereLabel": "공유 폴더", + "whereWhen": ", {{when}} 변경", + "someone": "누군가", + "close": "닫기", + "canTitle": "할 수 있는 것", + "canStillTitle": "계속 할 수 있는 것", + "cannotTitle": "할 수 없는 것", + "items": { + "files": "자기 폴더의 파일 읽기와 쓰기", + "readOnly": "이 공유 폴더는 읽기만 가능하므로 페이지가 변경 내용을 저장할 수 없습니다.", + "chat": "채팅에 메시지 쓰기", + "chatNote": "어시스턴트가 답하기 전에 채팅에서 메시지를 볼 수 있습니다.", + "noFiles": "내 다른 파일이나 채팅 읽기", + "noNet": "네트워크나 다른 사이트에 접근", + "noNetNote": "한 가지 한계: 자기 프레임에 다른 주소를 불러올 수 있습니다. 그러면 요청 하나가 밖으로 나가고 페이지가 멈춥니다.", + "noTools": "도구 사용이나 설정 변경" + }, + "warn": "자기 폴더 안에서 나로서 동작합니다. {{author}}님이 바꾸면 다시 묻습니다.", + "viewSource": "소스 보기", + "cancel": "취소", + "allowOnce": "한 번 허용", + "allow": "허용", + "changed": { + "title": "\"{{title}}\"이(가) 변경되었습니다", + "desc": "이전 버전을 허용했습니다. 다시 실행되기 전에 변경된 내용을 확인하세요.", + "who": "{{author}}님이 {{when}} 변경", + "allowed": "{{date}} 버전을 허용했습니다", + "what": "변경된 내용", + "same": "할 수 있는 일은 이전과 같습니다.", + "warn": "허용하면 {{author}}님이 다시 바꿀 때까지 열 때마다 이 버전이 실행됩니다.", + "view": "변경 내용 보기", + "lines": "{{before}} → {{after}}줄", + "code": "페이지의 스크립트, 스타일시트 또는 모듈이 바뀌었습니다: {{files}}" + }, + "pill": { + "notYet": "아직 허용 안 됨", + "not": "허용 안 됨", + "once": "이번 방문에만 허용", + "allowed": "허용됨", + "changed": "허용한 뒤 변경됨" + }, + "from": "{{author}} 작성", + "frame": { + "chrome": "{{folder}}의 공유 페이지. 실행되지 않았습니다." + }, + "blocked": { + "title": "이 페이지는 실행되지 않았습니다", + "body": "실행하지 않기로 했습니다. {{author}}님의 공유 폴더 {{folder}}에서 온 페이지입니다. 아무것도 실행되지 않았고 저장된 것도 없습니다.", + "review": "검토 후 허용" + }, + "waiting": { + "title": "확인 대기 중", + "body": "허용하기 전에는 이 페이지에서 아무것도 실행되지 않습니다." + }, + "stopped": { + "revoked": "이 페이지 허용을 중지했습니다. 페이지가 멈췄고 다시 허용할 때까지 아무것도 실행되지 않습니다.", + "refused": "이 페이지는 더 이상 허용되지 않아 중지되었습니다. 다시 허용하려면 검토하세요.", + "off": "이 서버에서 공유 페이지가 꺼져 이 페이지가 중지되었습니다." + }, + "changedMeanwhile": "검토하는 동안 페이지가 변경되었습니다. 이것이 새 버전입니다.", + "sourceTitle": "\"{{title}}\"의 소스", + "sourceLines_one": "{{count}}줄", + "sourceLines_other": "{{count}}줄", + "sourceNote": "소스는 텍스트로 표시됩니다. 여기서는 아무것도 실행되지 않습니다." + }, + "frame": { + "filesFailed": "이 페이지 옆의 파일을 불러오지 못했습니다: {{files}}. 브라우저 콘솔에 이유가 나옵니다.", + "tooLarge": "이 페이지는 폴더의 파일을 {{limit}} 넘게 포함하므로 일부를 제외했습니다. 데이터는 fetch()로 불러오세요.", + "dismiss": "닫기" + }, + "link": { + "label": "페이지가 링크를 열려고 합니다", + "open": "{{site}} 열기", + "cancel": "취소", + "lookalike": "이 주소는 다른 문자 체계의 글자를 사용하며 {{display}}(으)로 읽힙니다. 다른 사이트를 흉내 낼 수 있으니 열기 전에 확인하세요." + } +} diff --git a/core/http/react-ui/public/locales/ko/skills.json b/core/http/react-ui/public/locales/ko/skills.json index adbcab96b..ef9e1ab56 100644 --- a/core/http/react-ui/public/locales/ko/skills.json +++ b/core/http/react-ui/public/locales/ko/skills.json @@ -1,79 +1,83 @@ { "title": "스킬", - "subtitle": "에이전트 스킬(재사용 가능한 지침 및 리소스)을 관리합니다", + "subtitle": "작업에 맞을 때 모든 채팅이 쓸 수 있는 지침입니다. 에이전트는 추가한 스킬만 사용합니다.", "unavailable": { - "subtitle": "스킬 서비스를 사용할 수 없거나 인덱스를 다시 빌드하는 중입니다. 잠시 후 다시 시도하세요.", - "retry": "다시 시도" + "subtitle": "스킬에는 파일 기능이 필요한데, 이 서버에서는 파일 기능이 꺼져 있습니다." }, "actions": { "newSkill": "새 스킬", "createSkill": "스킬 만들기", - "import": "가져오기", - "importing": "가져오는 중...", - "gitRepos": "Git 저장소", "edit": "편집", - "delete": "삭제", - "export": "내보내기", - "sync": "동기화", - "addRepo": "저장소 추가", - "adding": "추가 중...", - "remove": "제거", - "enable": "사용", - "disable": "사용 안 함" + "delete": "삭제" }, "search": { "placeholder": "스킬 검색..." }, - "git": { - "title": "Git 저장소", - "description": "스킬을 가져올 Git 저장소를 추가합니다. 동기화 후 스킬이 목록에 표시됩니다.", - "urlPlaceholder": "https://github.com/user/repo 또는 git@github.com:user/repo.git", - "noRepos": "구성된 Git 저장소가 없습니다. 위에서 추가하세요.", - "disabled": "사용 안 함", - "removeRepo": "저장소 제거" - }, "card": { "noDescription": "설명 없음", "readOnly": "읽기 전용", "editTitle": "스킬 편집", "deleteTitle": "스킬 삭제", - "exportTitle": ".tar.gz로 내보내기" + "invalid": "Skipped: not a valid skill", + "invalidWhy": "이 스킬은 건너뛰었습니다: {{reason}}", + "shadowed": "Hidden by a {{layer}} skill", + "shadowedLong": "Hidden by a {{layer}} skill of the same name", + "providedBy": "{{name}} 제공", + "replacedBy": "{{name}}(으)로 대체됨", + "notActive": "비활성: 기본 제공 스킬이 사용됩니다", + "notActiveBy": "비활성: {{name}}에서 제공합니다", + "fallback": "{{name}} 애드온의 스킬이 꺼져 있거나 실행 중이 아니어서 사용 중" }, "empty": { "title": "스킬을 찾을 수 없습니다", "text": "스킬을 만들거나 가져와서 시작하세요.", - "noPersonal": "아직 스킬이 없습니다." - }, - "sections": { - "yourSkills": "내 스킬", - "otherUsersSkills": "다른 사용자의 스킬" - }, - "deleteDialog": { - "title": "스킬 삭제", - "message": "스킬 \"{{name}}\"을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", - "confirm": "삭제" - }, - "removeRepoDialog": { - "title": "Git 저장소 제거", - "message": "이 Git 저장소를 제거하시겠습니까? 해당 저장소의 스킬을 더 이상 사용할 수 없게 됩니다.", - "confirm": "제거" + "stepWrite": "Write a skill: a name, a line that says when to use it, and the instructions.", + "stepTry": "Check it in the list: it shows who uses it and what it says.", + "stepAdd": "Add it to an agent when you want it used." }, "toasts": { "loadFailed": "스킬을 불러오지 못했습니다", "deleted": "스킬 \"{{name}}\"이(가) 삭제되었습니다", - "deleteFailed": "스킬 삭제 실패", - "exported": "스킬 \"{{name}}\"이(가) 내보내졌습니다", - "exportFailed": "내보내기 실패", - "imported": "\"{{file}}\"에서 스킬을 가져왔습니다", - "importFailed": "가져오기 실패", - "loadReposFailed": "Git 저장소를 불러오지 못했습니다", - "repoAdded": "Git 저장소가 추가되고 동기화 중입니다", - "addRepoFailed": "저장소 추가 실패", - "synced": "저장소가 동기화되었습니다", - "syncFailed": "동기화 실패", - "toggled": "저장소가 전환되었습니다", - "toggleFailed": "전환 실패", - "removed": "저장소가 제거되었습니다", - "removeFailed": "제거 실패" + "deleteFailed": "스킬 삭제 실패" + }, + "layers": { + "label": "Layer", + "personal": "Personal", + "shared": "Shared", + "builtin": "Built-in", + "folder": "Workspace", + "addon": "애드온" + }, + "pane": { + "loading": "Loading...", + "loadFailed": "The skill could not be read.", + "tokensExplain": "A chat reads a skill only when a task matches its description, so it adds nothing until then. About {{count}} tokens when read (estimate)." + }, + "edit": { + "back": "Back to skills", + "titleNew": "New skill", + "titleEdit": "Edit: {{name}}", + "basic": "Basic information", + "content": "Content", + "layer": "Layer", + "layerHelp": "Personal is yours alone. Shared is read by every user.", + "name": "Name (lowercase, hyphens only)", + "nameLocked": "Name cannot be changed after creation.", + "description": "Description (when to use it)", + "descriptionHelp": "A chat reads this line to decide whether the skill fits the task.", + "body": "Skill content (markdown)", + "cancel": "Cancel", + "save": "Save changes", + "create": "Create skill", + "saving": "Saving...", + "created": "Skill created", + "updated": "Skill updated", + "required": "A name and a description are required", + "saveFailed": "Save failed", + "loadFailed": "Failed to load the skill", + "readOnly": "이 스킬은 여기서 읽기 전용입니다. 기본 제공 스킬은 서버에 포함되어 있고, 내 파일의 스킬은 파일에서 바꿉니다." + }, + "load": { + "title": "스킬을 불러올 수 없습니다." } } diff --git a/core/http/react-ui/public/locales/ko/swarm.json b/core/http/react-ui/public/locales/ko/swarm.json new file mode 100644 index 000000000..15c6eb2b2 --- /dev/null +++ b/core/http/react-ui/public/locales/ko/swarm.json @@ -0,0 +1,5 @@ +{ + "nodes": { + "retry": "다시 시도" + } +} diff --git a/core/http/react-ui/public/locales/ko/tools.json b/core/http/react-ui/public/locales/ko/tools.json new file mode 100644 index 000000000..a050ab31b --- /dev/null +++ b/core/http/react-ui/public/locales/ko/tools.json @@ -0,0 +1,468 @@ +{ + "steps": { + "label": "진행 상황", + "setup": "설정", + "check": "확인", + "run": "실행", + "result": "결과" + }, + "run": { + "stop": "중지", + "connecting": "작업에 연결하는 중입니다. 첫 진행 이벤트를 기다리고 있습니다.", + "progress": "진행률", + "stages": "단계", + "step": "스텝", + "of": "/", + "epoch": "에포크", + "tokens": "tokens/s", + "eta": "약 {{time}} 남음", + "etaSeconds": "약 {{seconds}}초 남음" + }, + "landing": { + "lede": "이 컴퓨터에서 만들 수 있는 것과 각 도구에 먼저 필요한 것입니다.", + "machine": "이 컴퓨터", + "gpu": "GPU", + "gpuFree": "{{total}} 중 {{free}} 사용 가능", + "noGpu": "GPU를 찾을 수 없음", + "cluster": "최적 노드({{node}})의 {{total}}", + "clusterNode": "워커", + "ram": "메모리", + "ramFree": "RAM, {{free}} 사용 가능", + "disk": "모델 디스크", + "diskFree": "사용 가능", + "backends": "백엔드", + "noBackends": "이 도구에 설치된 백엔드가 없습니다", + "open": "열기", + "openTool": "{{tool}} 열기", + "install": "설치", + "ready": "준비됨", + "needsBackend": "백엔드 필요", + "experimental": "실험적", + "needs": "필요한 것", + "flow": "찾은 모델에서 대화할 수 있는 모델까지의 과정", + "flowNote": "각 단계는 불러올 수 있는 모델로 끝납니다.", + "now": { + "running": { + "fineTune": "{{model}} 파인튜닝 중", + "quantize": "{{model}}을(를) {{type}}으로 양자화 중" + }, + "failed": { + "fineTune": "{{model}} 파인튜닝 실패", + "quantize": "{{model}}을(를) {{type}}으로 양자화하지 못함" + } + }, + "groups": { + "make": { + "title": "모델 만들기", + "text": "찾은 모델에서 시작해 내 컴퓨터에 맞고 내 데이터를 아는 모델로 마무리합니다." + }, + "recognise": { + "title": "인식", + "text": "누가 말하고 있는지, 사진 속 얼굴이 누구인지 알아봅니다." + }, + "automate": { + "title": "자동화", + "text": "어시스턴트, 지침, 그리고 메모리." + } + }, + "sentence": { + "import": "Hugging Face, URL, Ollama 또는 OCI 참조, 또는 이 호스트의 파일로 모델을 가져옵니다. LocalAI가 백엔드를 고르고 구성을 작성합니다.", + "quantize": "모델을 더 작게 만들어 더 적은 메모리에 맞춥니다. 선택한 정밀도로 Hugging Face 모델을 GGUF로 변환합니다.", + "fine-tune": "어댑터로 모델에 내 데이터를 가르친 뒤, 대화할 수 있는 모델로 내보냅니다." + }, + "line": { + "import": { + "noSetup": "별도 설정이 필요 없습니다. 필요할 때 백엔드를 다운로드합니다.", + "disk": "모델 디스크에 {{free}} 사용 가능" + }, + "quantize": { + "backend": "{{name}} 백엔드 설치됨", + "backendMissing": "양자화 백엔드가 설치되어 있지 않습니다", + "backendUnknown": "백엔드 상태를 읽을 수 없습니다", + "cpu": "CPU에서 실행되며 GPU가 필요하지 않습니다" + }, + "fineTune": { + "backend": "{{name}} 백엔드 설치됨", + "backendMissing": "파인튜닝 백엔드가 설치되어 있지 않습니다", + "backendUnknown": "백엔드 상태를 읽을 수 없습니다", + "gpu": "GPU 메모리: {{name}}, {{total}} 중 {{free}} 사용 가능", + "noGpu": "GPU를 찾을 수 없습니다. 학습은 CPU에서 실행되며 느립니다.", + "cluster": "메모리: 최적 노드의 {{total}}" + } + }, + "enable": { + "admin": "백엔드에서 백엔드를 설치하면 이 도구를 사용할 수 있습니다.", + "user": "관리자에게 백엔드 설치를 요청하세요." + } + }, + "checks": { + "heading": "시작하기 전에 확인", + "live": "설정을 바꿀 때마다 업데이트됩니다.", + "meterLabel": "{{used}}% 사용 중, {{total}} 중 {{free}} 사용 가능", + "openBackends": "백엔드 열기", + "barOk": "시작을 막는 항목이 없습니다.", + "barWarn_one": "경고 {{count}}개. 그래도 시작할 수 있습니다.", + "barWarn_other": "경고 {{count}}개. 그래도 시작할 수 있습니다.", + "title": { + "model": "모델", + "data": "데이터셋", + "backend": "백엔드", + "memory": "메모리", + "disk": "디스크", + "reward": "보상 함수", + "resume": "재개", + "access": "모델 접근", + "type": "양자화 유형", + "source": "소스", + "name": "이름" + }, + "fineTune": { + "modelOk": "{{model}} 학습 중입니다.", + "modelMissing": "학습할 모델을 입력하세요.", + "dataOk": "데이터는 {{source}}에서 옵니다.", + "dataMissing": "데이터셋을 추가하세요: Hugging Face id 또는 업로드한 파일.", + "backendOk": "{{name}}이(가) 설치되어 있습니다.", + "backendMissing": "파인튜닝 백엔드가 설치되어 있지 않아 작업이 시작되지 않을 수 있습니다.", + "backendOther": "{{name}}은(는) 설치된 파인튜닝 백엔드({{list}}) 중에 없습니다.", + "backendUnknown": "설치된 백엔드를 읽을 수 없습니다.", + "memoryGpu": "{{name}}: {{total}} 중 {{free}} 사용 가능. 배치 크기 {{batch}}, 시퀀스 길이 {{seq}}, 그래디언트 누적 {{accum}}. 작업에 필요한 양은 모델과 이 설정에 따라 다르며, LocalAI는 작업 시작 전에 이를 추정하지 않습니다.", + "memoryCluster": "최적 노드({{node}})의 {{total}}. 배치 크기 {{batch}}, 시퀀스 길이 {{seq}}. 작업에 필요한 양은 모델과 이 설정에 따라 다릅니다.", + "memoryCpu": "GPU를 찾을 수 없습니다. 학습은 CPU를 사용하며 RAM {{total}} 중 {{free}}가 사용 가능합니다. 느릴 것으로 예상됩니다. 배치 크기 {{batch}}, 시퀀스 길이 {{seq}}.", + "memoryUnknown": "이 계정은 컴퓨터의 메모리를 읽을 수 없습니다. 배치 크기 {{batch}}, 시퀀스 길이 {{seq}}. 작업에 필요한 양은 모델과 이 설정에 따라 다릅니다.", + "disk": "모델 디스크에 {{free}} 사용 가능. 다운로드와 체크포인트에 공간이 필요합니다.", + "diskLow": "모델 디스크에 {{free}}만 사용 가능. 다운로드와 체크포인트에 공간이 필요합니다.", + "rewardMissing": "GRPO에는 보상 함수가 최소 하나 필요합니다. 추가 옵션에서 하나를 추가하세요.", + "resume": "학습이 {{path}}에서 이어집니다.", + "accessNone": "토큰이 설정되지 않았습니다. 접근이 제한된 모델은 추가 옵션에서 Hugging Face 토큰이 필요합니다.", + "accessSet": "Hugging Face 토큰이 설정되어 있습니다." + }, + "quantize": { + "modelOk": "{{model}}을(를) 양자화합니다.", + "modelMissing": "양자화할 모델을 입력하세요.", + "typeMissing": "사용자 지정 양자화 유형을 입력하세요.", + "backendOk": "{{name}}이(가) 설치되어 있습니다.", + "backendMissing": "양자화 백엔드가 설치되어 있지 않아 작업이 시작되지 않을 수 있습니다.", + "backendOther": "{{name}}은(는) 설치된 양자화 백엔드({{list}}) 중에 없습니다.", + "backendUnknown": "설치된 백엔드를 읽을 수 없습니다.", + "memory": "RAM {{total}} 중 {{free}}가 사용 가능합니다. 변환은 CPU에서 실행됩니다.", + "disk": "모델 디스크에 {{free}} 사용 가능. 다운로드, 변환된 파일, 결과가 디스크에 기록됩니다.", + "diskLow": "모델 디스크에 {{free}}만 사용 가능. 다운로드, 변환된 파일, 결과가 디스크에 기록됩니다.", + "accessNone": "토큰이 설정되지 않았습니다. 접근이 제한된 모델은 추가 옵션에서 Hugging Face 토큰이 필요합니다.", + "accessSet": "Hugging Face 토큰이 설정되어 있습니다." + }, + "import": { + "sourceOk_huggingface": "Hugging Face 저장소로 인식되었습니다. 가져올 때 LocalAI가 파일을 확인합니다.", + "sourceOk_url": "URL 형식이 인식되었습니다. 내용은 확인하지 않았습니다.", + "sourceOk_config": "모델 구성 파일로 인식되었습니다. 읽은 뒤 있는 그대로 사용합니다.", + "sourceOk_oci": "OCI 이미지 참조로 인식되었습니다.", + "sourceOk_ollama": "Ollama 모델 참조로 인식되었습니다.", + "sourceOk_local": "LocalAI를 실행하는 컴퓨터의 경로로 인식되었습니다.", + "sourceOk": "소스가 인식되었습니다.", + "sourceUnknown": "LocalAI가 아는 형식이 아닌 것 같습니다. 그래도 시도하며, 실패할 수 있습니다.", + "backendDownload": "{{name}}이(가) 아직 설치되어 있지 않습니다. LocalAI가 먼저 다운로드합니다.", + "backendChosen": "{{name}}이(가) 선택되어 설치되어 있습니다.", + "backendAuto": "가져오기가 시작되면 LocalAI가 소스의 파일을 보고 백엔드를 고릅니다.", + "nameTaken": "{{name}}이라는 모델이 이미 설치되어 있습니다.", + "nameFree": "{{name}}은(는) 사용 중이 아닙니다.", + "nameAuto": "모델은 소스 이름을 따라 명명됩니다.", + "disk": "모델 디스크에 {{free}} 사용 가능. 다운로드 크기는 가져오기가 시작되면 알 수 있습니다.", + "memory_gpu": "지금 GPU 메모리 {{free}}이(가) 사용 가능합니다. 이 모델에 필요한 양은 가져오기가 시작되면 알 수 있습니다.", + "memory_ram": "지금 RAM {{free}}이(가) 사용 가능합니다. 이 모델에 필요한 양은 가져오기가 시작되면 알 수 있습니다.", + "memory_cluster": "최적 노드에 {{free}}이(가) 사용 가능합니다. 이 모델에 필요한 양은 가져오기가 시작되면 알 수 있습니다.", + "memory": "지금 메모리 {{free}}이(가) 사용 가능합니다. 이 모델에 필요한 양은 가져오기가 시작되면 알 수 있습니다." + } + }, + "form": { + "key": "키", + "value": "값", + "addOption": "옵션 추가", + "copy": "클립보드에 복사", + "more": "추가 옵션", + "moreHint": "방법, 백엔드, 어댑터, 옵티마이저, 평가, 보상 함수", + "baseModel": "기본 모델", + "model": "모델", + "modelHint": "Hugging Face id 또는 이 호스트의 경로.", + "data": "내 데이터", + "dataset": "데이터셋 소스", + "datasetHint": "Hugging Face 데이터셋 id 또는 파일 업로드.", + "split": "분할", + "upload": "대신 파일 업로드", + "uploadHint": ".json, .jsonl 또는 .csv", + "removeFile": "파일 제거", + "train": "학습 대상", + "kind": "학습 종류", + "kinds": { + "adapter": { + "title": "어댑터", + "text": "작은 어댑터를 학습하고 기본 가중치는 건드리지 않습니다. 보통 이 방식을 선택하며 메모리가 적게 듭니다." + }, + "full": { + "title": "전체 모델", + "text": "모든 가중치를 변경합니다. 어댑터보다 여러 배의 메모리가 필요합니다." + } + }, + "epochs": "에포크", + "batch": "배치 크기", + "lr": "학습률", + "backend": "백엔드", + "method": "학습 방법", + "adapterKind": "어댑터 종류", + "rank": "랭크", + "alpha": "알파", + "dropout": "드롭아웃", + "gradAccum": "그래디언트 누적 스텝", + "warmup": "워밍업 스텝", + "seqLen": "최대 시퀀스 길이", + "optimizer": "옵티마이저", + "maxSteps": "최대 스텝", + "maxStepsHint": "0이면 자동입니다.", + "saveSteps": "저장 스텝", + "saveLimit": "총 저장 한도", + "saveLimitHint": "0이면 모든 체크포인트를 유지합니다.", + "weightDecay": "가중치 감쇠", + "seed": "시드", + "seedHint": "0이면 무작위 시드를 고릅니다.", + "precision": "혼합 정밀도", + "auto": "자동", + "targetModules": "대상 모듈", + "targetModulesHint": "쉼표로 구분합니다. 비워 두면 백엔드 기본값을 사용합니다.", + "gradCheckpointing": "그래디언트 체크포인팅 (느리지만 메모리를 적게 사용)", + "token": "Hugging Face 토큰", + "tokenHint": "접근이 제한된 모델에만 필요합니다.", + "evalEnable": "학습 중 평가", + "evalStrategy": "평가 전략", + "evalSteps_": "스텝", + "evalEpoch": "에포크", + "evalSteps": "평가 스텝", + "evalStepsHint": "0이면 저장 스텝과 같게 설정됩니다.", + "evalSplit": "평가 분할", + "evalDataset": "평가 데이터셋", + "evalDatasetPlaceholder": "별도 데이터셋", + "evalRatio": "자동 분할 비율", + "rewards": "보상 함수 (GRPO)", + "rewardsHint": "GRPO에는 보상 함수가 최소 하나 필요합니다. 기본 제공 함수를 고르거나 직접 추가하세요.", + "resume": "체크포인트에서 재개", + "clearCheckpoint": "체크포인트 지우기", + "extra": "추가 옵션", + "extraHint": "백엔드별 키와 값 쌍입니다.", + "summaryFull": "전체 모델", + "summary_lora": "LoRA 어댑터", + "summary_loha": "LoHa 어댑터", + "summary_lokr": "LoKr 어댑터", + "recipe": "{{data}}를 사용해 {{method}}로 {{model}}의 {{kind}}를 {{epochs}} 에포크 학습합니다.", + "recipeEmpty": "모델과 데이터셋을 선택하면 학습 내용을 볼 수 있습니다." + }, + "rewards": { + "format_reward": "... 형식과 그 다음 답변 형식을 확인합니다.", + "reasoning_accuracy_reward": " 내용을 데이터셋의 답변 열과 비교합니다.", + "length_reward": "답변이 목표 길이에 얼마나 가까운지 점수를 매깁니다.", + "xml_tag_reward": "열고 닫은 XML 태그가 올바른지 점수를 매깁니다.", + "no_repetition_reward": "반복되는 n-gram에 감점을 줍니다.", + "code_execution_reward": "Python 코드 블록의 문법이 유효한지 확인합니다.", + "param_target_length": "목표 길이", + "fnName": "함수 이름", + "fnBody": "함수 본문", + "fnHint": "completions와 kwargs를 받아 float 리스트를 반환해야 합니다. re, math, json, string을 사용할 수 있습니다.", + "add": "추가", + "cancel": "취소", + "custom": "사용자 지정 보상 함수 추가" + }, + "fineTune": { + "title": "파인튜닝", + "lede": "내 데이터로 어댑터를 학습한 뒤, 대화할 수 있는 모델로 내보냅니다.", + "importConfig": "구성 가져오기", + "exportConfig": "구성 내보내기", + "newJob": "새 작업", + "badConfig": "파일을 읽을 수 없습니다. 이 페이지에서 내보낸 JSON 구성을 사용하세요.", + "start": "파인튜닝 시작", + "starting": "시작 중...", + "resume": "학습 재개", + "resuming": "체크포인트에서 재개 중:", + "clear": "지우기", + "barNote": "시작하기 전까지는 아무것도 바뀌지 않습니다.", + "jobMeta": "{{backend}}의 {{kind}} {{method}}, 시작 {{started}}", + "failed": "학습 실패", + "failedAt": "{{total}} 스텝 중 {{step}} 스텝에서 학습이 중지되었습니다", + "noMessage": "백엔드가 메시지를 보내지 않았습니다.", + "finished": "학습이 완료되었습니다.", + "finishedLoss": "학습이 완료되었습니다. 마지막 손실은 {{loss}}입니다.", + "stopped": "작업이 중지되었습니다. 유지하도록 선택한 경우 체크포인트가 보존됩니다.", + "tryTitle": "다음에 할 일", + "tryText": "서버가 메모리 부족을 보고했습니다. 아래의 각 변경 사항은 설정 사본에 적용되며, 시작을 누르기 전까지 아무것도 실행되지 않습니다.", + "tryPlain": "설정을 다시 열고, 메시지가 가리키는 항목을 바꾼 뒤 새 작업을 시작하세요.", + "fixBatch": "배치 크기를 1로 낮추기", + "fixCheckpointing": "그래디언트 체크포인팅 켜기 (느리지만 메모리를 적게 사용)", + "retryChanges": "이 변경 사항을 적용해 설정 열기", + "reuse": "설정 다시 열기", + "resumeFrom": "{{step}} 스텝부터 재개", + "status": { + "queued": "대기 중", + "loading_model": "모델 불러오는 중", + "loading_dataset": "데이터셋 불러오는 중", + "training": "학습 중", + "saving": "저장 중", + "completed": "완료됨", + "failed": "실패", + "stopped": "중지됨" + } + }, + "stop": { + "title": "학습을 중지할까요?", + "text": "지금 저장된 체크포인트가 있으면 지금까지 학습된 내용을 재개하거나 내보낼 수 있습니다.", + "keep": "중지하고 체크포인트 유지", + "discard": "체크포인트 없이 중지", + "cancel": "학습 계속하기", + "cancelDelete": "취소" + }, + "chart": { + "loss": "손실", + "learning_rate": "학습률", + "grad_norm": "그래디언트 노름", + "metric": "지표", + "step": "스텝", + "eval": "평가 손실", + "waiting": "더 많은 스텝을 기다리는 중입니다.", + "window": "{{total}} 중 {{from}}~{{to}} 스텝", + "windowEval": "{{total}} 중 {{from}}~{{to}} 스텝. 빈 점은 평가 손실입니다.", + "aria": "학습 스텝에 따른 {{metric}}. 마지막 값은 {{step}} 스텝의 {{last}}입니다.", + "readout": "{{step}} 스텝: {{value}}", + "dataTitle": "숫자 보기", + "emptyText": "작업이 두 스텝을 보고하면 차트가 그려집니다." + }, + "log": { + "title": "로그", + "note": "이 페이지가 열린 뒤 작업이 보고한 내용입니다.", + "only": "경고와 오류만", + "copy": "복사", + "copied": "복사됨", + "download": "다운로드", + "empty": "아직 보고된 내용이 없습니다.", + "noneFiltered": "경고나 오류가 없습니다." + }, + "checkpoints": { + "title": "체크포인트", + "step": "스텝", + "epoch": "에포크", + "loss": "손실", + "path": "경로", + "actions": "작업", + "resume": "재개", + "resumeTitle": "이 체크포인트에서 학습 재개", + "export": "내보내기", + "exportTitle": "이 체크포인트 내보내기" + }, + "export": { + "title": "모델로 내보내기", + "lede": "내보내기는 결과를 LocalAI가 불러올 수 있는 모델로 바꿉니다.", + "checkpoint": "체크포인트", + "final": "최종 모델 (출력 디렉터리)", + "checkpointOption": "{{step}} 스텝 (손실 {{loss}})", + "format": "내보내기 형식", + "lora": "LoRA 어댑터", + "merged16": "병합 (16비트)", + "merged4": "병합 (4비트)", + "quant": "양자화", + "name": "모델 이름", + "nameHint": "비워 두면 작업으로부터 이름을 생성합니다.", + "go": "내보내기", + "exporting": "내보내는 중...", + "working": "내보내기 진행 중...", + "done": "모델을 내보냈고 \"{{name}}\"(으)로 등록했습니다", + "failed": "내보내기 실패: {{message}}", + "unknown": "알 수 없는 오류", + "nextTitle": "{{name}}이(가) 이제 모델입니다", + "nextText": "LocalAI에 등록되었습니다. 이 모델과 채팅을 열거나 모델 페이지에서 찾으세요.", + "chat": "{{name}}과(와) 채팅", + "models": "모델에서 열기", + "archive": "아카이브 다운로드" + }, + "jobs": { + "title": "이전 작업", + "count_one": "이 서버에 {{count}}개 보관됨", + "count_other": "이 서버에 {{count}}개 보관됨", + "loading": "작업 불러오는 중", + "emptyTitle": "아직 파인튜닝 작업이 없습니다", + "emptyText": "위에서 작업을 설정하세요. 작업은 상태와 함께 여기에 나타나며, 나중에 설정을 재사용할 수 있습니다.", + "model": "모델", + "kind": "종류", + "status": "상태", + "started": "시작 시간", + "actions": "작업", + "open": "이 작업 열기", + "reuse": "재사용", + "reuseTitle": "이 작업의 설정을 새 작업에 사용", + "delete": "삭제", + "deleteTitle": "이 작업과 데이터 삭제", + "deleteDialogTitle": "이 작업을 삭제할까요?", + "deleteDialogText": "작업을 체크포인트와 내보낸 모델과 함께 삭제합니다. 이 작업은 되돌릴 수 없습니다." + }, + "quantize": { + "title": "양자화", + "lede": "모델을 더 작게 만들어 더 적은 메모리에 맞춥니다. 결과는 가져올 수 있는 GGUF 파일입니다.", + "newJob": "새 작업", + "sectionModel": "모델", + "model": "모델", + "modelHint": "Hugging Face id 또는 이 호스트의 경로.", + "sectionPrecision": "정밀도", + "type": "양자화 유형", + "typeHint": "숫자가 작을수록 파일이 작아지고 품질이 조금 떨어집니다.", + "custom": "사용자 지정...", + "customLabel": "사용자 지정 유형", + "customPlaceholder": "예: iq4_nl", + "moreHint": "백엔드, Hugging Face 토큰", + "tokenHint": "접근이 제한된 모델에만 필요합니다.", + "recipe": "{{model}}을(를) {{type}} GGUF로 변환합니다.", + "recipeEmpty": "모델을 입력하면 만들어질 결과를 볼 수 있습니다.", + "start": "양자화 ({{type}})", + "barNote": "시작하기 전까지는 아무것도 바뀌지 않습니다.", + "jobMeta": "{{backend}}의 {{type}}, 시작 {{started}}", + "failed": "양자화 실패", + "finished": "양자화 완료: {{type}}.", + "stopped": "작업이 중지되었습니다.", + "tryText": "설정을 다시 열고, 메시지가 가리키는 항목을 바꾼 뒤 새 작업을 시작하세요. 새 백엔드는 오래된 백엔드가 지원하지 않는 아키텍처를 지원할 수 있습니다.", + "outputTitle": "결과", + "outputText": "{{type}}의 GGUF 파일입니다. LocalAI에서 쓰려면 가져오거나 다운로드하세요.", + "file": "파일", + "importName": "모델 이름", + "importNamePlaceholder": "비워 두면 자동 생성", + "import": "LocalAI로 가져오기", + "importing": "가져오는 중...", + "importFailed": "가져오기 실패", + "importFailedWith": "가져오기 실패: {{message}}", + "download": "GGUF 다운로드", + "nextTitle": "{{name}}이(가) 이제 모델입니다", + "nextText": "LocalAI에 등록되었습니다. 이 모델과 채팅을 열거나 모델 페이지에서 찾으세요.", + "chat": "{{name}}과(와) 채팅", + "models": "모델에서 열기", + "imported": "{{name}}(으)로 가져왔습니다", + "emptyTitle": "아직 양자화 작업이 없습니다", + "emptyText": "위에서 작업을 설정하세요. 작업은 상태와 함께 여기에 나타납니다.", + "deleteDialogText": "작업을 변환된 파일과 함께 삭제합니다. 이 작업은 되돌릴 수 없습니다.", + "startFailed": "작업을 시작하지 못했습니다", + "stopFailed": "작업을 중지하지 못했습니다", + "deleteFailed": "작업을 삭제하지 못했습니다", + "status": { + "queued": "대기 중", + "downloading": "다운로드 중", + "converting": "변환 중", + "quantizing": "양자화 중", + "completed": "완료됨", + "failed": "실패", + "stopped": "중지됨" + } + }, + "off": { + "badge": "사용할 수 없음", + "fineTune": { + "title": "이 계정은 파인튜닝을 할 수 없습니다", + "text": "파인튜닝은 별도의 권한이며, 이 계정에는 그 권한이 없습니다." + }, + "quantize": { + "title": "이 계정은 양자화를 할 수 없습니다", + "text": "양자화는 별도의 권한이며, 이 계정에는 그 권한이 없습니다." + }, + "admin": "관리자가 사용자 및 키 메뉴에서 켤 수 있습니다.", + "askAdmin": "관리자에게 이 계정에 대해 켜 달라고 요청하세요.", + "back": "홈으로 이동" + } +} diff --git a/core/http/react-ui/public/locales/ko/traffic.json b/core/http/react-ui/public/locales/ko/traffic.json new file mode 100644 index 000000000..ea7f541ff --- /dev/null +++ b/core/http/react-ui/public/locales/ko/traffic.json @@ -0,0 +1,12 @@ +{ + "overview": { + "usageErrorTitle": "사용량을 읽지 못했습니다." + }, + "host": { + "errorTitle": "호스트 정보를 읽지 못했습니다." + }, + "models": { + "noMatch": "“{{query}}”와 일치하는 모델이 없습니다." + }, + "clearSearch": "검색 지우기" +} diff --git a/core/http/react-ui/public/locales/ko/upgrade.json b/core/http/react-ui/public/locales/ko/upgrade.json new file mode 100644 index 000000000..9ca8ee81b --- /dev/null +++ b/core/http/react-ui/public/locales/ko/upgrade.json @@ -0,0 +1,46 @@ +{ + "badge": { + "available": "업데이트 가능", + "availableVersion": "업데이트 가능: {{version}}" + }, + "trigger": { + "all": "모두 업그레이드 ({{count}})", + "running": "{{total}}개 중 {{current}}번째 업그레이드 중", + "summary": "{{done}}개 완료, {{failed}}개 실패, {{skipped}}개 건너뜀", + "summaryOk": "{{done}}개 완료", + "review": "확인" + }, + "dialog": { + "title": "모두 업그레이드", + "intro": "업그레이드할 항목을 선택하세요. 각 항목은 하나씩 차례로 업그레이드됩니다. 하나가 실패해도 나머지는 계속됩니다.", + "selection": "선택", + "selectAll": "모두 선택", + "selectNone": "선택 해제", + "selectedCount_one": "{{count}}개 선택됨", + "selectedCount_other": "{{count}}개 선택됨", + "item": "{{name}} 업그레이드", + "versions": "{{from}} → {{to}}", + "blocked": "시작할 수 없음", + "start": "선택 항목 업그레이드", + "starting": "업그레이드 중...", + "retry": "실패한 항목 다시 시도 ({{count}})", + "again": "새로 선택", + "close": "닫기", + "closeRunning": "닫기 (계속 실행됨)", + "runningNote": "이 창을 닫아도 업그레이드는 중단되지 않습니다.", + "progress": "{{total}}개 중 {{current}}번째 업그레이드 중", + "summary": "{{done}}개 완료, {{failed}}개 실패, {{skipped}}개 건너뜀", + "empty": "업그레이드할 항목이 없습니다." + }, + "item": { + "queued": "대기 중", + "upgrading": "업그레이드 중", + "done": "완료", + "failed": "실패", + "skipped": "건너뜀" + }, + "reason": { + "checkAgain": "다시 확인", + "checking": "확인 중..." + } +} diff --git a/core/http/react-ui/public/locales/ko/workspace.json b/core/http/react-ui/public/locales/ko/workspace.json new file mode 100644 index 000000000..986af6993 --- /dev/null +++ b/core/http/react-ui/public/locales/ko/workspace.json @@ -0,0 +1,671 @@ +{ + "wellKnown": { + "revert": "이전 버전으로 되돌리기", + "view": "보기", + "form": "양식", + "save": "저장", + "cancel": "취소", + "notSet": "설정되지 않음", + "moveUp": "위로 이동", + "moveDown": "아래로 이동", + "remove": "삭제", + "savedAt": "{{time}}에 저장됨", + "notSaved": "아직 저장되지 않았습니다.", + "notSavedPrefilled": "아직 저장되지 않았습니다. 일부 값은 자동으로 채워졌으며, 저장하면 유지됩니다.", + "loadFailed": "이 설정을 불러오지 못했습니다. 페이지를 새로 고쳐 다시 시도하세요.", + "invalid": "이 설정에 오류가 있으며, 이전의 유효한 버전이 없습니다.", + "invalidFallback": "이 설정에 오류가 있습니다. 마지막으로 유효했던 버전을 보여 줍니다.", + "conflict": "편집하는 동안 이 설정이 바뀌었습니다. 어시스턴트나 다른 탭에서 바꾸었을 가능성이 높습니다. 최신 값은 다음과 같습니다:", + "useTheirs": "최신 값 사용", + "keepMine": "내 변경 사항 유지", + "editWithAssistant": "어시스턴트로 편집", + "from": { + "account": "계정에서", + "browser": "브라우저에서" + }, + "check": { + "required": "필수 항목입니다.", + "tooLong": "최대 {{max}}자까지 입력할 수 있습니다.", + "format": "값의 형식이 올바르지 않습니다." + }, + "source": { + "label": "원본", + "hint": "설정을 텍스트로 보여 줍니다. 저장할 때 같은 방식으로 검사합니다.", + "notJson": "텍스트에 구문 오류가 있습니다. 따옴표, 쉼표, 중괄호를 확인하세요.", + "notObject": "텍스트는 {로 시작하고 }로 끝나야 합니다." + }, + "persona": { + "title": "어시스턴트", + "lede": "모든 채팅에서 어시스턴트가 사용하는 이름과 지침입니다." + }, + "user": { + "title": "나", + "lede": "어시스턴트가 대화 상대를 알기 위해 읽습니다. 모든 채팅에서 모델에 전달됩니다." + }, + "ui": { + "title": "모양", + "lede": "LocalAI가 표시되는 방식입니다. 변경 사항은 바로 반영되며, 저장하면 유지됩니다." + }, + "shortcuts": { + "title": "단축 프롬프트", + "lede": "모든 채팅의 입력창 위에 표시되는 프롬프트입니다.", + "edit": "단축 프롬프트 편집", + "defaults": "기본 단축 프롬프트", + "none": "단축 프롬프트가 없습니다. 추가하거나, 빈 목록을 저장해 숨기세요.", + "label": "라벨", + "prompt": "프롬프트", + "send": "바로 보내기", + "add": "단축 프롬프트 추가", + "invalid": "일부 단축 프롬프트에 오류가 있습니다.", + "assistantPrompt": "내 단축 프롬프트를 바꿔 줘: " + }, + "home": { + "editLayout": "레이아웃 편집", + "ask": "어시스턴트에게 묻기", + "askPrompt": "내 홈 페이지를 바꿔 줘: ", + "add": "위젯 추가", + "newTitle": "새 위젯", + "noWidgets": "아직 위젯이 없습니다.", + "type": "유형", + "title": "제목", + "source": "데이터 파일", + "noSourceNeeded": "데이터 파일이 필요하지 않음", + "types": { + "list": "목록", + "metric": "지표", + "chats": "최근 채팅", + "page": "페이지" + }, + "left": "{{count}} 남음", + "fromChats": "채팅에서", + "noChats": "아직 채팅이 없습니다.", + "noData": "아직 데이터가 없습니다. 어시스턴트가 작성할 수 있습니다", + "nothing": "표시할 내용이 없습니다.", + "noSource": "이 위젯에는 데이터 파일이 없습니다. 레이아웃 편집에서 선택하세요.", + "broken": "이 위젯의 데이터에 오류가 있으며, 이전의 유효한 버전이 없습니다.", + "stale": "마지막으로 유효했던 데이터를 보여 줍니다. 최신 데이터에 오류가 있습니다.", + "updated": "{{when}}에 업데이트됨", + "footer": { + "noData": "아직 데이터가 없습니다.", + "invalid": "데이터에 오류가 있습니다. 마지막 정상 버전을 보여 줍니다.", + "revert": "되돌리기", + "missing": "이 위젯의 데이터가 없습니다.", + "fix": "어시스턴트에게 고쳐 달라고 하기", + "fixPrompt": "홈 위젯이 읽는 {{file}} 파일이 없거나 손상되었습니다. 고쳐 주세요: ", + "waiting": "채팅에서 트리거를 만들기를 기다리는 중입니다.", + "openChat": "채팅 열기", + "running": "지금 업데이트 중…", + "openRun": "실행 열기", + "paused": "일시 중지됨 · 이전 일정 {{schedule}}", + "resume": "다시 시작", + "failedEmpty": "업데이트에 실패했고 아직 데이터가 없습니다.", + "details": "자세히", + "retry": "다시 시도", + "stale": "마지막 업데이트에 실패했습니다. {{when}}의 데이터를 표시합니다.", + "staleToday": "마지막 업데이트에 실패했습니다. 오늘 이전의 데이터를 표시합니다.", + "earlier": "이전", + "firstRun": "첫 실행 {{when}}", + "firstRunFile": "파일이 도착하면 실행", + "runNow": "지금 실행", + "fresh": "{{schedule}} · {{time}} 업데이트", + "pause": "일시 중지", + "fromPage": "{{title}} 페이지에서", + "fromFile": "내 파일에서", + "next": "다음 실행 {{when}}", + "cost": "실행당 약 {{tokens}} 토큰", + "costRemote": "원격 모델에서 실행당 약 {{tokens}} 토큰", + "cadence": { + "paused": "일시 중지됨", + "hourly": "매시간", + "daily": "매일", + "weekdays": "평일", + "weekly": "매주", + "monthly": "매월", + "file": "새 파일마다" + }, + "scheduleNote_one": "위젯 {{count}}개가 일정에 따라 업데이트됩니다. 실행에는 내 모델이 쓰이고 사용량에 포함됩니다.", + "scheduleNote_other": "위젯 {{count}}개가 일정에 따라 업데이트됩니다. 실행에는 내 모델이 쓰이고 사용량에 포함됩니다." + }, + "opensSite": "{{host}}: 새 탭에서 열립니다", + "linkAsk": "이 위젯이 링크를 열려고 합니다", + "invalid": "홈 레이아웃에 오류가 있으며, 이전의 유효한 버전이 없습니다.", + "invalidFallback": "홈 레이아웃에 오류가 있습니다. 홈은 마지막으로 유효했던 버전을 보여 줍니다.", + "page": { + "gone": "이 페이지를 더 이상 사용할 수 없습니다", + "goneHint": "\"{{name}}\" 페이지가 이동되었거나 삭제되었거나 더 이상 열 수 없습니다.", + "noWidget": "이 페이지에는 위젯 보기가 없습니다", + "noWidgetHint": "어시스턴트에게 \"{{name}}\" 페이지에 홈용 간단한 보기를 만들어 달라고 요청하세요.", + "off": "공유 페이지가 꺼져 있습니다", + "offHint": "관리자가 이 서버의 공유 페이지를 껐습니다.", + "fix": "홈 사용자 지정", + "failed": "페이지를 불러오지 못했습니다", + "retry": "다시 시도", + "changed": "허용한 뒤 이 페이지가 바뀌었습니다", + "notAllowed": "아직 이 페이지를 허용하지 않았습니다", + "allowHint": "공유 폴더의 페이지입니다. 내 권한으로 실행되기 전에 검토하세요.", + "allow": "이 페이지 허용", + "frameTitle": "{{title}} (홈의 페이지)", + "refresh": "새로 고침", + "open": "페이지 열기", + "add": "페이지 추가", + "limit": "홈에는 페이지를 최대 {{max}}개까지 표시합니다. 다른 페이지를 추가하려면 하나를 제거하세요.", + "noneEligible": "아직 홈용 간단한 보기가 있는 페이지가 없습니다. 어시스턴트에게 페이지에 추가해 달라고 요청하세요.", + "pick": "페이지", + "unavailable": "{{name}} (사용할 수 없음)", + "sharedLabel": "공유", + "sizes": { + "small": "작게", + "medium": "보통", + "large": "크게" + } + } + }, + "fields": { + "persona": { + "name": { + "label": "이름", + "help": "어시스턴트가 자신을 부르는 이름입니다." + }, + "system_prompt": { + "label": "시스템 프롬프트", + "help": "모든 채팅에 추가됩니다. 채팅의 프롬프트와 에이전트 지침 뒤에 붙습니다." + } + }, + "user": { + "name": { + "label": "이름", + "help": "어시스턴트가 나를 부르는 이름입니다." + }, + "language": { + "label": "언어", + "help": "어시스턴트가 답변할 때 사용하는 언어입니다." + }, + "bio": { + "label": "나에 대해", + "help": "어시스턴트가 모든 채팅에서 읽는 몇 줄입니다." + }, + "timezone": { + "label": "시간대", + "help": "답변의 날짜와 시간에 사용됩니다." + } + }, + "ui": { + "theme": { + "label": "테마", + "help": "시스템은 기기 설정을 따릅니다.", + "options": { + "dark": "다크", + "light": "라이트", + "system": "시스템" + } + }, + "accent": { + "label": "강조 색상", + "help": "버튼과 링크에 사용합니다. 비워 두면 기본값을 사용합니다." + }, + "density": { + "label": "밀도", + "help": "조밀하게 설정하면 한 화면에 더 많이 표시됩니다.", + "options": { + "comfortable": "여유 있게", + "compact": "조밀하게" + } + }, + "defaultModel": { + "label": "기본 모델", + "help": "새 채팅이 시작될 때 사용하는 모델입니다." + } + } + }, + "saveInvalid": "일부 값이 올바르지 않습니다. 확인한 뒤 다시 저장하세요.", + "saveTooLarge": "설정이 너무 커서 저장할 수 없습니다. 항목 몇 개를 지우고 다시 시도하세요.", + "saveFailed": "저장하지 못했습니다. 다시 시도하세요.", + "details": "세부 정보" + }, + "discovery": { + "ex": { + "title": "LocalAI를 나에게 맞게 만들기", + "lede": "채팅에서 말해 보세요. 어시스턴트가 저장하고, 나중에 바꾸거나 되돌릴 수 있어요.", + "hide": "숨기기", + "hiddenToast": "예시를 숨겼어요.", + "saveFailed": "저장되지 않음: {{message}}", + "card1": { + "kicker": "나와 어시스턴트", + "prompt": "저를 {{firstName}}(이)라고 부르고 답변은 짧게 해 주세요", + "promptNoName": "제 이름을 기억하고 답변은 짧게 해 주세요", + "what": "내 이름과 어시스턴트의 답변 방식을 저장해요" + }, + "card2": { + "kicker": "페이지", + "prompt": "작업 보드를 만들어 주세요", + "what": "사이드바에서 여는 페이지를 추가해요" + }, + "card3": { + "kicker": "매일 아침", + "prompt": "매일 아침 받은 편지함을 여기에 요약해 주세요", + "promptNoMail": "매일 아침 어제의 채팅 목록을 여기에 보여 주세요", + "what": "홈 위젯과 직접 확인하는 트리거를 추가해요" + }, + "quote": "“{{text}}”", + "donePrefix": "완료했어요.", + "doneLink": "계정에서 변경", + "ready": "또는 준비된 페이지로 시작하세요:", + "readyTasks": "작업 보드", + "readyReading": "읽기 목록", + "readyFailed": "페이지를 복사하지 못했어요: {{message}}", + "learn": "사용 방법", + "inComposer": "원하면 수정한 뒤 Enter를 누르세요. 보내기 전에는 아무것도 저장되지 않아요.", + "live": "프롬프트를 메시지 입력창에 추가했어요." + }, + "home": { + "customise": "홈 꾸미기" + }, + "action": { + "undo": "실행 취소", + "openAccount": "계정에서 열기", + "openPage": "페이지 열기", + "openHome": "홈 열기", + "turnBackOn": "계정에서 다시 켜기" + }, + "change": { + "profilePersona": "내 이름과 어시스턴트에게 주는 지침을 저장했어요. 모든 채팅이 다음 메시지부터 사용해요.", + "profile": "프로필에 저장했어요.", + "persona": "어시스턴트 지침을 저장했어요. 모든 채팅이 다음 메시지부터 사용해요.", + "shortcuts": "단축 프롬프트를 저장했어요.", + "home": "홈 페이지를 저장했어요. 홈에 변경 사항이 보여요.", + "ui": "화면 설정을 저장했어요.", + "pageCreated": "{{title}} 페이지를 만들었어요.", + "pageChanged": "{{title}} 페이지를 바꿨어요.", + "offersOff": "어시스턴트의 제안이 꺼져 있어요.", + "undoing": "되돌리는 중…", + "undone": "되돌렸어요.", + "undoFailed": "되돌리지 못했어요: {{message}}" + }, + "toast": { + "shortcutAdded": "입력창에 추가했어요: {{label}}", + "pageCreated": "{{title}}이(가) 사이드바의 페이지에 있어요", + "open": "열기", + "homeChanged": "홈을 업데이트했어요.", + "themeChanged": "테마를 변경했어요." + }, + "files": { + "learn": "이 파일들이 LocalAI가 나에게 동작하는 방식을 바꿔요.", + "tag": { + "profile": "내 프로필", + "persona": "어시스턴트", + "shortcuts": "바로 가기", + "home": "홈 레이아웃", + "ui": "모양", + "page": "페이지" + } + }, + "privacy": "어시스턴트는 모든 채팅을 시작할 때 이름, 이 텍스트, 언어, 시간대를 받아요. 보내고 싶지 않은 항목은 비워 두세요.", + "versions": { + "label": "버전", + "title": "이전 버전", + "loading": "기록을 읽는 중…", + "none": "이전 버전이 아직 없어요.", + "current": "현재", + "invalid": "오류가 있음", + "byAssistant": "어시스턴트가 변경", + "byAssistantIn": "“{{chat}}” 채팅에서 어시스턴트가 변경", + "byYou": "내가 변경", + "undo": "되돌리기", + "upload": "업로드함", + "revert": "복원", + "note": "복원해도 더 새로운 버전은 이 목록에 남아요.", + "invalidBadge": "잘못됨", + "revertGood": "마지막 정상 버전으로 복원" + }, + "help": { + "title": "도움말과 제안", + "sub": "LocalAI가 할 수 있는 일을 보여 주는 방법들이에요. 필요 없는 것은 끄세요.", + "offers": "어시스턴트의 제안", + "offersHint": "하루에 최대 한 번, 작업 중에는 절대 하지 않아요. 예를 들어 말씀하신 내용을 기억해 둘지 물어볼 때예요.", + "new": "새 기능 알림", + "newHint": "무언가 처음 나타나면 사이드바에 짧은 “새 기능” 라벨이 표시돼요.", + "examples": "홈의 예시", + "examplesShown": "시도해 볼 것이 남아 있는 동안 홈에 “LocalAI를 나에게 맞게 만들기” 카드가 표시돼요.", + "examplesHidden": "“LocalAI를 나에게 맞게 만들기” 카드를 숨겼어요.", + "showExamples": "예시 다시 표시", + "examplesBack": "예시가 홈에 다시 표시돼요.", + "dismissed": "어시스턴트가 배운 내용", + "dismissedHint_one": "제안 {{count}}개를 거절했어요. 초기화할 때까지 숨겨져 있어요.", + "dismissedHint_other": "제안 {{count}}개를 거절했어요. 초기화할 때까지 숨겨져 있어요.", + "forget": "제안에 대해 어시스턴트가 배운 내용 지우기", + "forgotten": "완료했어요. 어시스턴트가 다시 제안할 수 있어요.", + "learn": "바꿀 수 있는 것 알아보기:", + "dismissedNone": "아직 없어요. 제안에 답하면 어시스턴트가 여기에 기억해 둬요." + } + }, + "tabs": { + "shared": "공유", + "trashCount_one": "휴지통에 항목 {{count}}개", + "trashCount_other": "휴지통에 항목 {{count}}개" + }, + "trash": { + "empty": "휴지통이 비어 있습니다.", + "restore": "복원", + "emptyAll": "휴지통 비우기", + "purge": "영구 삭제", + "purgeNamed": "{{name}} 영구 삭제", + "files_one": "파일 {{count}}개", + "files_other": "파일 {{count}}개", + "folders_one": "폴더 {{count}}개", + "folders_other": "폴더 {{count}}개", + "listJoin": ", ", + "summary": "휴지통: {{what}}.", + "removed": "영구 삭제됨: {{what}}.", + "confirmAll": { + "title": "휴지통을 비우시겠습니까?", + "message": "{{what}}이(가) 이전 버전과 함께 영구 삭제됩니다. 되돌릴 수 없습니다." + }, + "confirmOne": { + "title": "영구 삭제하시겠습니까?", + "message": "{{path}}: {{what}}이(가) 이전 버전과 함께 영구 삭제됩니다. 되돌릴 수 없습니다." + } + }, + "shared": { + "adminTitle": "공유 폴더", + "adminLede": "여러 사람이 함께 쓰는 폴더입니다. 멤버는 페이지를 열 수 있고, 멤버의 채팅은 파일을 읽거나 편집할 수 있습니다.", + "userTitle": "나와 공유됨", + "userLede": "관리자가 나와 공유한 폴더입니다. 페이지는 페이지 메뉴에 있고, 내 채팅에서 파일을 쓸 수 있습니다.", + "new": "새 공유 폴더", + "nameLabel": "폴더 이름", + "nameHint": "글자, 숫자, 공백, 마침표, 하이픈, 밑줄로 최대 64자. 글자나 숫자로 시작하세요.", + "create": "만들기", + "cancel": "취소", + "rename": "이름 바꾸기", + "renameSave": "저장", + "remove": "제거", + "manage": "멤버", + "menu": "{{name}}의 다른 작업", + "pages_one": "페이지 {{count}}개", + "pages_other": "페이지 {{count}}개", + "pagesNone": "페이지 없음", + "people_one": "{{count}}명", + "people_other": "{{count}}명", + "membersNone": "멤버 없음", + "everyone": "모든 사용자", + "everyoneOption": "모든 사용자 (전체)", + "everyoneHint": "나중에 가입하는 사용자를 포함한 모든 사용자", + "everyoneCan": { + "read": "모두 읽기 가능", + "write": "모두 편집 가능" + }, + "mode": { + "read": "읽기 가능", + "write": "편집 가능" + }, + "addTitle": "멤버 추가", + "person": "사용자", + "access": "권한", + "accessFor": "{{name}}의 권한", + "add": "추가", + "allAdded": "모두 이미 멤버입니다.", + "usersUnavailable": "사용자 목록을 불러오지 못했습니다. 모든 사용자는 추가할 수 있습니다.", + "membersTitle": "멤버", + "noMembers": "아직 이 폴더를 쓸 수 있는 사람이 없습니다. 사용자나 모든 사용자를 추가하세요.", + "removeMember": "{{name}} 제거", + "paneNote": "변경은 바로 적용됩니다. 열려 있는 채팅은 다음에 열 때 반영됩니다.", + "created": "“{{name}}”을(를) 만들었습니다. 사용할 사람을 추가하세요.", + "removed": "“{{name}}”을(를) 제거했습니다. 페이지는 실행 전에 다시 허락을 묻습니다.", + "restored": "“{{name}}”이(가) 멤버와 함께 돌아왔습니다.", + "memberAdded": "{{name}}: {{mode}}.", + "memberRemoved": "{{name}}은(는) 이제 “{{folder}}”을(를) 쓸 수 없습니다.", + "failed": "실패: {{message}}", + "loadFailed": "공유 폴더를 불러오지 못했습니다.", + "emptyAdminTitle": "아직 공유 폴더가 없습니다", + "emptyAdminBody": "공유 폴더에는 다른 사람이 읽거나 편집할 수 있는 파일과 페이지가 있습니다. 폴더를 만들고 멤버를 추가하세요.", + "factMembers": "사용자 또는 모두에게 읽기나 편집 권한 부여", + "factChats": "멤버의 채팅이 폴더의 파일을 봅니다", + "factUndo": "폴더 제거는 되돌릴 수 있습니다", + "emptyUserTitle": "아직 나와 공유된 것이 없습니다", + "emptyUserBody": "관리자가 폴더를 공유하면 여기에 표시되고, 페이지는 페이지 메뉴에 나타납니다." + }, + "preview": { + "label": "미리보기", + "failed": "이 파일을 표시할 수 없습니다.", + "loading": "{{name}} 미리보기를 불러오는 중", + "failedTitle": "{{name}}을(를) 표시할 수 없습니다.", + "tooLarge": "이 파일은 너무 커서 여기서 미리 볼 수 없습니다. 다운로드해서 여세요.", + "none": "이 파일 형식은 미리보기가 없습니다.", + "typeFile": "{{ext}} 파일", + "typeUnknown": "파일", + "sheets": "시트", + "sheetLabel": "시트 {{name}}", + "sheetEmpty": "이 시트는 비어 있습니다.", + "row": "행", + "showingRows": "{{total}}행 중 {{rows}}행 표시", + "showingCols": "{{total}}열 중 {{cols}}열 표시.", + "downloadRest": "나머지는 파일을 다운로드해서 보세요.", + "textOnly": "텍스트 미리보기이며 서식은 표시되지 않습니다.", + "truncated": "텍스트가 잘렸습니다.", + "noText": "이 파일에는 표시할 텍스트가 없습니다.", + "slide": "슬라이드 {{n}}", + "notes": "발표자 노트", + "pdfTitle": "PDF {{name}}", + "pdfFallback": "PDF가 여기 표시되지 않으면 다운로드하세요.", + "htmlTitle": "{{name}} 미리보기", + "imageFailed": "이미지를 표시할 수 없습니다.", + "zoomIn": "원래 크기로 보기", + "zoomOut": "창에 맞추기", + "items_one": "{{count}}개 항목", + "items_other": "{{count}}개 항목", + "textCut": "처음 {{size}}만 표시합니다.", + "actions": "파일 작업" + }, + "managed": { + "group": "LocalAI가 관리", + "marker": "LocalAI가 관리", + "about": { + "localai": "LocalAI가 읽는 설정: 페르소나, 프로필, 바로가기, 홈, 테마.", + "chats": "채팅마다 하나의 폴더와 그 채팅의 파일." + }, + "confirm": { + "deleteTitle": "{{name}}을(를) 휴지통으로 옮길까요?", + "renameTitle": "{{name}}의 이름을 바꿀까요?", + "delete": "휴지통으로 이동", + "rename": "이름 바꾸기", + "restore": "휴지통에서 복원할 수 있습니다.", + "renameNote": "LocalAI는 이 이름으로만 읽으므로, 이름을 바꾸면 없어진 것과 같습니다." + }, + "loses": { + "settings": "LocalAI가 페르소나, 프로필, 바로가기, 홈 레이아웃, 테마를 잊습니다.", + "chats": "모든 채팅이 파일을 잃습니다: 첨부한 파일과 어시스턴트가 그곳에 쓴 파일.", + "persona": "어시스턴트가 이름과 지침을 잃습니다.", + "user": "어시스턴트가 당신에 대해 들은 내용과 제안에 대한 당신의 답을 잊습니다.", + "shortcuts": "메시지 입력란에 기본 바로가기가 다시 표시됩니다.", + "home": "홈의 위젯이 사라집니다.", + "ui": "테마, 강조 색상, 밀도, 기본 모델, 고정한 페이지가 기본값으로 돌아갑니다." + } + }, + "newFile": "새 파일", + "newFolderName": "새 폴더", + "newFileName": "제목 없음.md", + "fileName": "파일 이름", + "downloadOne": "{{name}} 다운로드", + "moveTo": "이동…", + "uploadMenu": "업로드 옵션 더보기", + "uploadFiles": "파일 업로드", + "uploadFolder": "폴더 업로드", + "loading": "폴더를 불러오는 중", + "loadFailed": "{{folder}}을(를) 열 수 없습니다.", + "types": { + "folder": "폴더", + "image": "이미지", + "pdf": "PDF", + "sheet": "스프레드시트", + "doc": "문서", + "slides": "프레젠테이션", + "archive": "압축 파일", + "audio": "오디오", + "video": "동영상", + "code": "코드", + "text": "텍스트", + "file": "파일" + }, + "columns": { + "name": "이름", + "modified": "수정한 날짜", + "type": "유형", + "size": "크기", + "actions": "작업" + }, + "view": { + "label": "보기", + "list": "목록", + "grid": "미리보기" + }, + "select": { + "one": "{{name}} 선택", + "all": "모두 선택", + "bar": "선택한 항목", + "clear": "선택 해제", + "count_one": "{{count}}개 선택됨", + "count_other": "{{count}}개 선택됨", + "start": "선택" + }, + "menu": { + "label": "{{name}} 작업", + "more": "{{name}} 추가 작업", + "openFolder": "열기", + "preview": "미리보기", + "downloadMany_one": "파일 {{count}}개 다운로드", + "downloadMany_other": "파일 {{count}}개 다운로드", + "moveMany_one": "항목 {{count}}개 이동…", + "moveMany_other": "항목 {{count}}개 이동…", + "deleteMany_one": "항목 {{count}}개를 휴지통으로 이동", + "deleteMany_other": "항목 {{count}}개를 휴지통으로 이동" + }, + "keys": { + "delete": "Del" + }, + "crumbs": { + "hidden": "상위 폴더 보기" + }, + "naming": { + "empty": "이름을 입력하세요.", + "slash": "이름에는 / 또는 \\를 사용할 수 없습니다.", + "taken": "이 폴더에 같은 이름이 이미 있습니다." + }, + "toast": { + "undo": "실행 취소", + "deletedOne": "{{name}}을(를) 휴지통으로 이동했습니다.", + "deleted_one": "항목 {{count}}개를 휴지통으로 이동했습니다.", + "deleted_other": "항목 {{count}}개를 휴지통으로 이동했습니다.", + "restored": "복원했습니다.", + "renamed": "이름을 {{name}}(으)로 변경했습니다.", + "renameUndone": "이름을 되돌렸습니다.", + "movedOne": "{{name}}을(를) {{folder}}(으)로 이동했습니다.", + "moved_one": "항목 {{count}}개를 {{folder}}(으)로 이동했습니다.", + "moved_other": "항목 {{count}}개를 {{folder}}(으)로 이동했습니다.", + "moveUndone": "원래 위치로 되돌렸습니다." + }, + "search": { + "placeholder": "이 폴더에서 필터링", + "resultsFor": "“{{query}}”이(가) 포함된 파일", + "back": "폴더로 돌아가기", + "noneBody": "파일의 이름이나 내용에 일치하는 항목이 없습니다. 다른 단어로 검색해 보세요.", + "page": "{{n}}페이지", + "sheet": "시트 “{{name}}”", + "slide": "슬라이드 {{n}}", + "indexing_one": "문서 {{count}}개를 아직 색인하는 중입니다. 잠시 후 다시 검색하면 포함됩니다.", + "indexing_other": "문서 {{count}}개를 아직 색인하는 중입니다. 잠시 후 다시 검색하면 포함됩니다.", + "limited": "결과 제한됨: 검색이 모든 파일을 살펴보기 전에 한도에서 멈췄습니다. 나머지를 보려면 폴더 안에서 검색하거나 다른 단어를 사용하세요.", + "filtering_one": "이 폴더에서 “{{query}}”와(과) 일치하는 항목 {{count}}개", + "filtering_other": "이 폴더에서 “{{query}}”와(과) 일치하는 항목 {{count}}개", + "everywhere": "모든 파일에서 “{{query}}” 검색", + "everywhereShort": "모든 파일에서 검색", + "noMatch": "이 폴더에는 “{{query}}”와(과) 일치하는 항목이 없습니다.", + "clear": "필터 지우기" + }, + "emptyHome": { + "title": "내 파일이 여기에 있습니다" + }, + "emptyFolder": { + "title": "이 폴더는 비어 있습니다", + "body": "이 페이지 아무 곳에나 파일이나 폴더를 끌어다 놓거나, Ctrl+V로 붙여넣거나, 컴퓨터에서 업로드하세요. 어시스턴트도 채팅에서 이곳에 파일을 저장할 수 있습니다.", + "bodyTouch": "기기에서 파일을 업로드하세요. 어시스턴트도 채팅에서 이곳에 파일을 저장할 수 있습니다.", + "ask": "어시스턴트에게 요청" + }, + "drop": { + "title": "놓으면 {{folder}}에 업로드됩니다", + "body": "파일과 폴더 전체를 올릴 수 있습니다.", + "limit": "파일과 폴더 전체, 각각 최대 {{size}}." + }, + "conflict": { + "title_one": "같은 이름의 파일이 이미 있습니다", + "title_other": "같은 이름의 파일 {{count}}개가 이미 있습니다", + "body_one": "{{folder}}에 같은 이름의 파일이 이미 있습니다.", + "body_other": "{{folder}}에 같은 이름의 파일이 이미 있습니다.", + "skip_one": "건너뛰기", + "skip_other": "모두 건너뛰기", + "keep": "둘 다 유지", + "replace_one": "바꾸기", + "replace_other": "모두 바꾸기", + "more_one": "외 {{count}}개", + "more_other": "외 {{count}}개", + "note": "둘 다 유지하면 새 파일에 다른 이름이 붙습니다. 바꾼 파일은 기록에서 되돌릴 수 있습니다.", + "cancel": "업로드 취소" + }, + "move": { + "title_one": "항목 {{count}}개 이동", + "title_other": "항목 {{count}}개 이동", + "items_one": "항목 {{count}}개", + "items_other": "항목 {{count}}개", + "cancel": "취소", + "here": "{{folder}}(으)로 이동", + "failed": "이 폴더를 읽을 수 없습니다.", + "noFolders": "여기에는 폴더가 없습니다. 여기로 이동하거나 위로 올라가세요." + }, + "uploads": { + "waiting": "대기 중", + "progress": "{{loaded}} / {{total}}", + "rate": "{{rate}}/초", + "left": "{{time}} 남음", + "renamed": "{{name}}(으)로 저장됨", + "done": "업로드 완료", + "canceled": "취소됨", + "cancel": "취소", + "cancelOne": "{{name}} 업로드 취소", + "retry": "다시 시도", + "retryOne": "{{name}} 다시 시도", + "dismiss": "닫기", + "dismissOne": "{{name}} 닫기", + "show": "보기", + "showOne": "{{name}} 보기", + "meterOne": "{{name}}: {{percent}}%", + "meterAll": "전체 업로드: {{percent}}%", + "cancelAll": "모두 취소", + "retryAll": "모두 다시 시도", + "fold": "목록 숨기기", + "unfold": "목록 보기", + "close": "닫기", + "head": { + "sending_one": "파일 {{count}}개 업로드 중", + "sending_other": "파일 {{count}}개 업로드 중", + "failed_one": "업로드 {{count}}개 실패", + "failed_other": "업로드 {{count}}개 실패", + "done_one": "파일 {{count}}개 업로드 완료", + "done_other": "파일 {{count}}개 업로드 완료" + }, + "say": { + "start_one": "파일 {{count}}개를 업로드하는 중입니다.", + "start_other": "파일 {{count}}개를 업로드하는 중입니다.", + "done_one": "업로드 완료: 파일 {{count}}개.", + "done_other": "업로드 완료: 파일 {{count}}개.", + "doneFailed": "업로드 완료: {{count}}개 성공, {{failed}}개 실패.", + "failed": "{{name}}을(를) 업로드할 수 없습니다." + }, + "errors": { + "tooBig": "이 서버의 제한({{limit}})보다 큽니다.", + "tooLarge": "파일이 너무 커서 서버가 거부했습니다.", + "off": "이 서버에서는 파일 기능이 꺼져 있습니다.", + "invalid": "서버가 이 파일을 거부했습니다.", + "network": "연결이 끊어졌습니다.", + "server": "서버가 저장할 수 없었습니다." + }, + "said": "서버 응답: {{reason}}." + } +} diff --git a/core/http/react-ui/public/locales/pt-BR/addons.json b/core/http/react-ui/public/locales/pt-BR/addons.json index f86ee9466..72fcd468f 100644 --- a/core/http/react-ui/public/locales/pt-BR/addons.json +++ b/core/http/react-ui/public/locales/pt-BR/addons.json @@ -3,9 +3,9 @@ "blocked": "Uma ação anterior neste complemento ainda não foi confirmada. Verifique o estado dela ou descarte-a primeiro.", "checkStatus": "Verificar estado", "dismiss": "Descartar", - "failed": "O proprietário não concluiu a ação. Nada foi alterado. Tente de novo para iniciar uma nova tentativa.", - "notApplied": "O proprietário informou a ação como concluída, mas o complemento não mudou. Tente novamente.", - "unknown": "O proprietário ainda não confirmou a ação. Ela ainda pode ser aplicada. Verifique o estado ou descarte-a antes de iniciar outra ação." + "failed": "A ação não terminou. Nada foi alterado. Tente de novo para iniciar uma nova tentativa.", + "notApplied": "A ação foi informada como concluída, mas o complemento não mudou. Tente de novo para iniciar uma nova tentativa.", + "unknown": "A ação ainda não foi confirmada. Ela ainda pode ser aplicada. Verifique o estado ou descarte-a antes de iniciar outra ação." }, "approval": { "title": "Aprovação necessária", @@ -37,14 +37,16 @@ "emptyTitle": "Ainda não há apps", "firstRun": { "browse": "Browse add-ons", - "title": "Welcome to Apps", - "lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.", - "featuredLabel": "Featured add-ons", + "title": "Ainda não há complementos", + "lead": "Complementos dão ao LocalAI novas páginas e ferramentas de chat. Eles rodam separados e só pedem o que declaram.", + "featuredLabel": "Comece com um destes", "featuredFailed": "The list of add-ons could not be read.", - "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons." + "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons.", + "factChecked": "Verificados nas suas fontes antes de rodar", + "factRemove": "Desligue ou remova quando quiser" }, "headless": "Roda em segundo plano e não tem página própria.", - "listLabel": "Apps", + "listLabel": "Complementos instalados", "loading": "Carregando apps", "manage": "Gerenciar", "open": "Abrir", @@ -56,10 +58,14 @@ "tools_one": "{{count}} ferramenta", "tools_other": "{{count}} ferramentas", "version": "Versão {{version}}", - "working": "Aguardando a confirmação do proprietário.", + "working": "Aguardando a confirmação.", "noMatchTitle": "No add-on matches", - "noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.", - "clearSearch": "Clear search" + "noMatchBody": "Nada instalado corresponde a \"{{query}}\". Tente outras palavras.", + "clearSearch": "Clear search", + "loadError": { + "title": "Os complementos não estão disponíveis agora.", + "paused": "Páginas e ferramentas dos complementos ficam pausadas até ele voltar." + } }, "browse": { "confirmTitle": "Instalar {{name}}?", @@ -69,7 +75,7 @@ "fromLibrary": "de {{library}}", "install": "Instalar", "installedDetail": "Versão {{version}}, geração {{generation}}", - "installedOther": "Instalado {{version}} (outra versão)", + "installedOther": "Você tem {{version}}", "installedSame": "Instalado {{version}}", "installedState": "Instalado", "installing": "Instalando", @@ -85,7 +91,8 @@ "backendFailed": "The backend was not installed.", "alias": "Alias", "kindLabel": "Kind", - "installedOn": "{{name}} is installed and on." + "installedOn": "{{name}} is installed and on.", + "installedChip": "Instalado" }, "catalog": { "allLibraries": "Todas as bibliotecas", @@ -115,8 +122,8 @@ "pin": "Pin {{name}}", "pinLabel": "Pin", "pinned": "Pinned", - "running": "Ativado", - "search": "Search add-ons", + "running": "Em execução", + "search": "Pesquisar complementos instalados", "status": { "failed": "failed", "starting": "starting" @@ -129,7 +136,7 @@ "denied": "O servidor recusou. Você pode não ter permissão, ou o complemento mudou. A lista foi atualizada.", "invalid": "O servidor não pôde aceitar esta solicitação.", "network": "Não foi possível alcançar o servidor. Verifique sua conexão.", - "unavailable": "O proprietário do complemento não está disponível agora. Tente de novo em instantes.", + "unavailable": "Os complementos não estão disponíveis agora. Tente de novo em instantes.", "unknownOutcome": "Não foi possível confirmar o resultado. Verifique o estado antes de tentar de novo." }, "installed": { @@ -174,14 +181,14 @@ }, "hints": { "id": "Letras minúsculas, dígitos e hifens. Não pode mudar depois.", - "reference": "O nome de uma credencial que o proprietário configurou. Nunca cole um token ou senha aqui.", + "reference": "O nome de uma credencial que um administrador configurou neste servidor. Nunca cole um token ou senha aqui.", "url": "Um endereço https sem credenciais nem string de consulta.", "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.", "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.", "mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched." }, "lastRefresh": "Atualizada {{when}}, {{count}} entradas", - "lead": "Uma biblioteca é um documento de catálogo que lista complementos. Credenciais nunca são digitadas aqui: uma biblioteca privada indica uma credencial que o proprietário configurou.", + "lead": "Uma biblioteca é um índice que lista complementos, backends ou modelos. Credenciais nunca são digitadas aqui: uma biblioteca privada de complementos indica uma credencial que um administrador configurou neste servidor.", "listLabel": "Bibliotecas", "loading": "Carregando bibliotecas", "notRefreshed": "Ainda não atualizada", @@ -251,10 +258,11 @@ "hasUI": "Tem páginas", "headless": "Sem interface, sem páginas", "health": "Saúde", + "installedAt": "Instalado", "host": "API do host", "interface": "Interface", "logs": "Logs", - "logsNone": "A saída dos processos dos complementos não é coletada nesta versão. A saúde e as tentativas de reinício acima são o que o proprietário pode informar.", + "logsNone": "A saída dos processos dos complementos não é coletada nesta versão. A saúde e as tentativas de reinício acima são tudo o que o LocalAI pode informar.", "observed": "Estado observado", "protocol": "Protocolo", "restarts": "Tentativas de reinício", @@ -338,14 +346,15 @@ "state": { "failed": "Com falha", "label": "Estado: {{state}}", - "running": "Ativado", + "running": "Em execução", "starting": "Iniciando", "stopped": "Desativado", - "update-pending": "Atualização pendente" + "update-pending": "Atualização pendente", + "updateTo": "Atualização {{version}}" }, - "supporting": "Apps que o proprietário executa para você e as bibliotecas de onde vêm.", + "supporting": "Software que você instala para dar ao LocalAI novas páginas e ferramentas de chat. Cada um roda separado e só pede o que declara.", "tabs": { - "apps": "Meus complementos", + "apps": "Instalados", "browse": "Encontrar complementos", "label": "Seções de complementos", "libraries": "Fontes" @@ -516,8 +525,8 @@ "unknownOutcome": "O resultado não é conhecido. Leia o estado de novo: a mesma ação não é enviada duas vezes.", "auth": "Entre novamente.", "denied": "Somente administradores podem fazer isto, na própria sessão do navegador.", - "network": "Não foi possível alcançar o proprietário.", - "unavailable": "O proprietário não está disponível." + "network": "Não foi possível alcançar o serviço de complementos.", + "unavailable": "O serviço de complementos não está disponível." } }, "updates": { @@ -591,18 +600,30 @@ "auth": "Entre novamente para continuar.", "denied": "Somente um administrador pode fazer isso na própria sessão do navegador.", "network": "A rede não está acessível.", - "unavailable": "O proprietário do complemento não está disponível. Tente novamente." + "unavailable": "O serviço de complementos não está disponível. Tente de novo." + }, + "upgradeNamed": "Atualizar {{name}}", + "why": { + "role": "Há uma atualização disponível. Somente um administrador pode instalá-la.", + "needs-check": "Uma atualização foi informada, mas os detalhes ainda não foram confirmados. Verifique novamente para confirmar.", + "check-failed": "A última verificação falhou, então a atualização não pode ser confirmada. Verifique novamente.", + "incompatible": "Existe uma atualização, mas ela não pode rodar aqui:", + "integrity": "A biblioteca lista esta versão com conteúdo diferente. Ela não é oferecida como atualização.", + "recovery": "Os dados deste complemento precisam de recuperação primeiro. Restaure um backup em Detalhes e depois atualize.", + "current": "A última verificação não encontrou atualização. O aviso pode estar desatualizado. Verifique novamente.", + "unpinned": "A biblioteca informa uma atualização, mas não indica qual build instalar, então não é possível atualizar daqui. Verifique novamente mais tarde." }, - "reviewButton": "Atualizações ({{count}})", - "reviewTitle": "Revisar atualizações de complementos", - "reviewDescription": "Nada é atualizado até você escolher aqui. Cada atualização faz antes um backup dos dados do complemento.", "checkAll": "Verificar atualizações", "checkingAll": "Verificando...", "checkAllDone": "{{count}} complementos verificados.", "checkAllFailed": "{{count}} não puderam ser verificados.", - "upgradeNamed": "Atualizar {{name}}", - "toastDone": "{{name}} atualizado para {{version}}.", - "toastFailed": "{{name}} não foi atualizado: {{message}}" + "checkResult": { + "done": "Verificado.", + "failed": "A verificação não foi concluída.", + "justNow": "Verificado agora mesmo." + }, + "waitingService": "O serviço de complementos está iniciando. O LocalAI pergunta de novo sobre a atualização em {{seconds}} s.", + "checkAgain": "Verificar agora" }, "install": { "stepsLabel": "Install steps", @@ -639,17 +660,19 @@ "addsTools_other": "Adds {{count}} tools", "openNamed": "Open {{name}}", "removeTitle": "Remove {{name}}?", - "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted." + "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted.", + "removeNamed": "Remover {{name}}…" }, "preview": { "screenshots": "Screenshots", - "adds": "What it adds", + "adds": "O que ele pode fazer", "page": "A page of its own in LocalAI", "addsNothing": "Nothing you can see. It works in the background.", "version": "Version", "id": "Identifier", "sourceUnknown": "Not known", - "moreDetails": "Health, trust and updates" + "moreDetails": "Health, trust and updates", + "updateTitle": "Atualizar para {{version}}" }, "flow": { "installAndTurnOn": "Instalar e ativar", @@ -663,6 +686,66 @@ "install": "Download, verify and install", "turn-on": "Turn on" }, - "seeInList": "See it in My add-ons" + "seeInList": "Ver em Instalados", + "install": "Instalar", + "installNamed": "Instalar {{name}}", + "shadowWarning": "Este complemento substitui o {{tool}} integrado. Outro complemento ({{name}}) já faz isso. Só um pode estar ativo; você escolhe qual em Configurações > Ferramentas. Até você escolher, o que foi instalado primeiro o fornece.", + "skillShadow": "Este complemento substitui a habilidade integrada {{skill}}.", + "skillShadowWarning": "Este complemento substitui a habilidade integrada {{skill}}. Outro complemento ({{name}}) já faz isso. Só um pode estar ativo; você escolhe qual em Configurações > Ferramentas. Até você escolher, o que foi instalado primeiro o fornece." + }, + "family": { + "pages": "Procurando páginas que você ou um agente criou? Elas estão em Páginas." + }, + "filter": { + "label": "Mostrar", + "all": "Todos", + "running": "Em execução", + "off": "Desligados", + "showAll": "Mostrar todos", + "none": { + "all": "Nenhum complemento para mostrar.", + "running": "Nenhum complemento está em execução.", + "off": "Todos os complementos estão em execução." + } + }, + "attention": { + "label": "Precisa de atenção", + "failedTitle": "{{name}} parou.", + "failedRestarts_one": "Falhou ao iniciar {{count}} vez seguida.", + "failedRestarts_other": "Falhou ao iniciar {{count}} vezes seguidas.", + "failedOnce": "Ele não iniciou.", + "toolsOff_one": "Sua {{count}} ferramenta de chat fica desligada até ele rodar de novo.", + "toolsOff_other": "Suas {{count}} ferramentas de chat ficam desligadas até ele rodar de novo.", + "pageOff": "A página dele fica indisponível até ele rodar de novo.", + "updateTitle": "{{name}} {{version}} está disponível.", + "updateBody": "Você tem {{current}}. Você vê o que muda e confirma antes de qualquer substituição." + }, + "meta": { + "builtin": "Integrado", + "builtinLong": "Vem com o LocalAI", + "signed": "Assinado · {{library}}", + "checked": "Verificado · {{library}}", + "devBuild": "Build de desenvolvimento", + "library": "biblioteca {{library}}" + }, + "skills": { + "title_one": "{{count}} habilidade de chat", + "title_other": "{{count}} habilidades de chat", + "replaces": "substitui a habilidade integrada", + "serves": "usada nos chats", + "notServing": "não está em uso agora", + "serveIn": "Usar para {{skill}} nos chats", + "saveFailed": "Não foi possível salvar a escolha." + }, + "settingsTab": { + "overview": "Visão geral", + "settings": "Configurações", + "tabsLabel": "Detalhes de {{name}}", + "off": "Ative {{name}} para mudar as configurações dele.", + "pageLink": "Configurações", + "controls": { + "loadFailed": "Não foi possível ler as configurações", + "saveFailed": "Não foi possível salvar as configurações: {{message}}" + } } } diff --git a/core/http/react-ui/public/locales/pt-BR/admin.json b/core/http/react-ui/public/locales/pt-BR/admin.json index bb095ea65..5e44957d0 100644 --- a/core/http/react-ui/public/locales/pt-BR/admin.json +++ b/core/http/react-ui/public/locales/pt-BR/admin.json @@ -107,6 +107,72 @@ "assistant": "Assistente LocalAI", "distributed": "Distribuído", "responses": "Respostas" + }, + "loadFailedTitle": "Não foi possível carregar as configurações.", + "egress": { + "title": "Rede dos comandos", + "intro": "Os comandos no Sandbox alcançam a rede só por um proxy que pergunta ao LocalAI sobre cada conexão. Abaixo você define o limite, a predefinição de um chat novo, as portas, os registros extras e a retenção da auditoria; cada pessoa escolhe a predefinição de cada chat no menu Ferramentas dele, até o limite, e permite um host bloqueado num cartão.", + "serves": "Os comandos rodam isolados no complemento {{name}}", + "servesHost": "Os comandos rodam nesta máquina pelo {{name}}", + "servesNone": "Nenhum Sandbox roda comandos ainda", + "hostNote": "O backend do host roda comandos nesta máquina com a rede dela: estas configurações não se aplicam.", + "counts": "Últimas 24 horas: {{allowed}} permitidas, {{blocked}} bloqueadas.", + "private": "Deixar a regra de um administrador alcançar redes privadas", + "privateWarning": "Um host permitido por um administrador passa a alcançar endereços dentro da sua rede. Uma predefinição sozinha nunca faz isso; os endereços de metadados da nuvem e do servidor continuam recusados.", + "typePhrase": "Para ligar, digite: {{phrase}}", + "registries": "Registros de pacotes ({{count}})", + "showAudit": "Mostrar a auditoria", + "hideAudit": "Ocultar a auditoria", + "loadFailed": "Não foi possível ler as configurações de rede do Sandbox", + "audit": { + "verdict": "Resultado", + "host": "Host", + "user": "Usuário", + "at": "Quando", + "reason": "Motivo", + "bytes": "Bytes enviados / recebidos", + "refresh": "Atualizar", + "empty": "Nenhum pedido corresponde.", + "loadFailed": "Não foi possível ler a auditoria", + "verdicts": { + "any": "Qualquer resultado", + "allow": "Permitido", + "block": "Bloqueado", + "done": "Concluído" + } + } + }, + "addons": { + "title": "Complementos", + "hint": "Configurações que os complementos trazem", + "intro": "Cada complemento mantém as configurações na própria página, na aba Configurações dos detalhes. Abra um para mudá-las.", + "off": "Desativado. Ative-o para mudar as configurações.", + "open": "Abrir configurações", + "openNamed": "Abrir as configurações de {{name}}", + "count_one": "{{count}} complemento com configurações", + "count_other": "{{count}} complementos com configurações" + }, + "hosts": { + "add": "Adicionar", + "listLabel": "Hosts sempre permitidos", + "remove": "Remover {{host}}", + "empty": "Nenhum host ainda. Escreva um nome de host, ou host:porta para fixar a porta.", + "hint": "Somente nomes exatos. Adicione :porta para permitir uma única porta; caso contrário, valem as portas de rede do Sandbox.", + "count": "{{n}}/{{max}}", + "errors": { + "empty": "Escreva um nome de host.", + "wildcard": "\"{{value}}\": escreva um nome de host exato, não um padrão.", + "shape": "\"{{value}}\" não é um nome de host. Escreva só o host, sem esquema nem caminho.", + "port": "\"{{value}}\": a porta deve ir de 1 a 65535, e 22 e 25 são sempre recusadas.", + "private": "\"{{value}}\" é um endereço privado ou local. Ative antes a chave da rede privada.", + "numeric": "\"{{value}}\": escreva um endereço IP como quatro números decimais.", + "duplicate": "\"{{value}}\" já está na lista.", + "full": "A lista comporta no máximo {{max}} hosts." + } + }, + "network": { + "title": "Rede dos comandos", + "where": "{{name}} mantém na própria página as configurações de rede dos comandos que executa." } }, "backends": { @@ -276,5 +342,75 @@ "subtitle": "Modelos carregados nesta instância do LocalAI e a capacidade do host que utilizam.", "scaleOut": "Adicionar máquinas", "hideScaleOut": "Ocultar configuração" + }, + "settingsTools": { + "intro": "Os chats têm algumas ferramentas integradas que um complemento pode substituir. Quando o complemento está instalado, rodando e ligado em um chat, o chat usa a ferramenta do complemento, com o mesmo nome.", + "empty": "Não há ferramentas integradas para gerenciar neste servidor.", + "loadFailed": "Não foi possível ler as ferramentas integradas", + "switch": "Ligado", + "byBuiltin": "Integrada", + "byAddon": "Complemento: {{name}}", + "byNone": "Desligada", + "unavailable": "Este servidor não consegue executá-la: {{reason}}.", + "installSandbox": "Instale o Sandbox para rodar comandos isolados", + "replaceExplain": "Estes complementos substituem {{tool}}: o escolhido o fornece em cada chat em que está ligado e rodando, e o integrado fica oculto. Só um pode estar ativo. Até você escolher, o que foi instalado primeiro o fornece.", + "defaultInstalledFirst": "Padrão: instalado primeiro", + "installedOn": "Instalado em {{when}}", + "choose_one": "{{count}} complemento substitui {{tool}}: escolha qual usar.", + "choose_other": "{{count}} complementos substituem {{tool}}: escolha qual usar.", + "labels": { + "bash": "Comandos (bash)", + "web_search": "Pesquisa na web", + "web_fetch": "Ler páginas da web", + "list_agents": "Agentes salvos" + }, + "descriptions": { + "bash": "Roda comandos e código para os chats diretamente nesta máquina quando nenhum Sandbox fornece o bash. O complemento Sandbox o substitui por comandos isolados.", + "web_search": "Pesquisa na web para os chats sem chave (DuckDuckGo, depois Brave Search, depois Bing), ou com sua instância SearXNG quando houver uma definida.", + "web_fetch": "Lê para os chats uma página ou arquivo público da web pelo endereço. Endereços dentro da sua rede são recusados, a menos que você os permita.", + "list_agents": "Permite que um chat liste os agentes salvos do usuário e inicie um como subagente. Cada chat o ativa no menu Ferramentas." + }, + "bash": { + "switch": "Deixar os chats rodarem comandos nesta máquina", + "modeLegend": "Quem pode rodar comandos", + "ask": "Perguntar toda vez.", + "askHint": "Só os chats de administradores o recebem, e cada comando espera o sim deles no chat.", + "on": "Permitir para todos.", + "onHint": "Os chats de todos os usuários rodam comandos aqui sem perguntar, inclusive execuções agendadas e subagentes.", + "default": "Não definido: vale o padrão ({{mode}}).", + "warning": "Os comandos rodam como o usuário do LocalAI, sem isolamento e com a rede desta máquina: podem ler o que o LocalAI lê e acessar a internet, a sua rede e a API do LocalAI. Cada comando é registrado. Instale o Sandbox para isolá-los." + }, + "modes": { + "off": "desligado", + "ask": "perguntar toda vez", + "on": "permitido para todos" + }, + "skillsTitle": "Habilidades que complementos substituem", + "skillsIntro": "Um complemento pode substituir uma habilidade integrada pela sua, escrita para as suas ferramentas. Se vários substituírem a mesma, escolha qual é usada.", + "skillLabel": "Habilidade {{skill}}", + "skillNotRunning": "O complemento escolhido não está em execução: a habilidade integrada é usada.", + "toolNotRunning": "O complemento que substitui esta ferramenta não está em execução: a ferramenta integrada é usada.", + "stopped": "parado", + "webTest": { + "intro": "Executa uma pesquisa com cada provedor das configurações salvas e mostra o que cada um fez. Aplique suas alterações antes.", + "queryLabel": "Consulta", + "run": "Testar pesquisa", + "running": "Testando…", + "answered": "Os chats recebem os resultados de {{host}}.", + "noneAnswered": "Nenhum provedor respondeu.", + "failed": "Não foi possível executar o teste: {{error}}", + "results_one": "{{count}} resultado", + "results_other": "{{count}} resultados", + "outcomes": { + "ok": "Funciona", + "no_results": "Sem resultados", + "challenged": "Verificação antirrobô", + "blocked": "Bloqueado", + "timeout": "Lento demais", + "error": "Erro", + "skipped": "Ignorado" + } + }, + "addonSettings": "Configurações de {{name}}" } } diff --git a/core/http/react-ui/public/locales/pt-BR/agents.json b/core/http/react-ui/public/locales/pt-BR/agents.json index 7cc352985..7904c263e 100644 --- a/core/http/react-ui/public/locales/pt-BR/agents.json +++ b/core/http/react-ui/public/locales/pt-BR/agents.json @@ -2,15 +2,13 @@ "title": "Agentes", "subtitle": "Gerenciar agentes de IA autônomos", "actions": { - "agentHub": "Central de Agentes", "import": "Importar", "createAgent": "Criar Agente", "edit": "Editar", "chat": "Conversar", "export": "Exportar", "delete": "Excluir", - "pause": "Pausar", - "resume": "Retomar" + "newAgent": "Novo agente" }, "table": { "name": "Nome", @@ -27,13 +25,9 @@ "empty": { "noConfigured": "Nenhum agente configurado", "noConfiguredText": "Crie um agente para começar com fluxos de trabalho autônomos de IA.", - "browseHub": "Não sabe por onde começar? Explore a <1>Central de Agentes para encontrar configurações de agentes prontas que você pode importar.", "noMatching": "Nenhum agente correspondente", - "noMatchingText": "Nenhum agente corresponde a \"{{query}}\"" - }, - "sections": { - "yourAgents": "Seus Agentes", - "otherUsersAgents": "Agentes de Outros Usuários" + "noMatchingText": "Nenhum agente corresponde a \"{{query}}\"", + "clearSearch": "Limpar busca" }, "deleteDialog": { "title": "Excluir Agente", @@ -44,12 +38,411 @@ "loadFailed": "Falha ao carregar agentes: {{message}}", "deleted": "Agente \"{{name}}\" excluído", "deleteFailed": "Falha ao excluir agente: {{message}}", - "paused": "Agente \"{{name}}\" pausado", - "resumed": "Agente \"{{name}}\" retomado", - "pauseFailed": "Falha ao pausar agente: {{message}}", - "resumeFailed": "Falha ao retomar agente: {{message}}", "exported": "Agente \"{{name}}\" exportado", - "exportFailed": "Falha ao exportar agente: {{message}}", - "parseFailed": "Falha ao analisar o arquivo do agente: {{message}}" + "parseFailed": "Falha ao analisar o arquivo do agente: {{message}}", + "notAnAgent": "The file is not an exported agent", + "deletedUndo": "“{{name}}” excluído.", + "undoLate": "“{{name}}” já foi excluído.", + "restored": "“{{name}}” restaurado." + }, + "facts": { + "addons": "Add-ons", + "connector": "MCP connector" + }, + "agent": { + "triggers": "Triggers", + "newTrigger": "New trigger", + "noTriggers": "No trigger starts this agent.", + "untitled": "Untitled chat", + "triggerOn": "On", + "triggerOff": "Off", + "origin": { + "trigger": "Started by a trigger", + "connector": "Started by a connector" + }, + "noRunsText": "Press Chat to start a conversation with this agent. Chats that ran with it, and the ones its triggers start, are listed here.", + "missingText": "It may have been deleted, or it belongs to another user.", + "backToList": "Voltar aos agentes" + }, + "record": { + "where": "A run is a chat that ran with the agent, including the ones its triggers and connectors started.", + "none": "No runs yet" + }, + "create": { + "sections": { + "BasicInfo": "Basic info", + "ModelSettings": "Model", + "PromptsGoals": "Instructions", + "MemorySettings": "Memory", + "Skills": "Skills", + "Addons": "Add-ons", + "MCP": "MCP servers", + "export": "Export" + }, + "name": "Name", + "description": "Description", + "descriptionHelp": "One line that says what the agent is for. It shows in the list.", + "model": "Model", + "modelPlaceholder": "Any model (the chat picks one)", + "modelHelp": "Leave it empty and a chat with this agent uses the model you pick in the chat. Set one to fix it.", + "anyModel": "Any model", + "instructions": "Instructions", + "instructionsHelp": "What the agent always follows. Every chat with it starts from this text.", + "collections": "Collections", + "collectionsHint": "Collection name", + "collectionsHelp": "The memory collections a chat with this agent may search. The list is exact: an empty list grants none.", + "skillsHelp": "The skills a chat with this agent may read. The list is exact: an empty list grants none.", + "noSkills": "No skills yet.", + "manageSkills": "Write or manage skills", + "addonsHelp": "The add-ons whose tools a chat with this agent may use.", + "noAddons": "No add-on offers tools to a model.", + "mcpHelp": "The MCP servers of the fixed model and the MCP connectors a chat with this agent may use.", + "mcpServers": "MCP servers", + "mcpHint": "Server name", + "mcpConnectors": "MCP connectors", + "noConnectors": "No MCP connectors yet.", + "manageConnectors": "Manage MCP connectors", + "connectorOff": "off", + "gone": "not found", + "countCollections_one": "{{count}} collection", + "countCollections_other": "{{count}} collections", + "countAddons_one": "{{count}} add-on", + "countAddons_other": "{{count}} add-ons", + "needsName": "Needs a name", + "loadFailed": "Failed to load the agent: {{message}}", + "saveFailed": "Save failed: {{message}}", + "created": "Agent \"{{name}}\" created", + "updated": "Agent \"{{name}}\" updated", + "exportSummary": "Download as JSON", + "exportHelp": "Download the definition of this agent as a JSON file. It holds no id, no dates and no secrets. Import it on another instance from the agents list.", + "exportButton": "Export agent", + "importNew": "Imported from an exported agent. Read the form, then save.", + "importLegacy": "Imported from the export of an older agent. Name, description, model, instructions and skills were read; the rest has no counterpart here.", + "importDropped": "Not imported: {{keys}}.", + "subEdit": "A chat that is running keeps the version it started with. Its next turn uses the new one.", + "configNote": "The definition this form will save.", + "previewNew": "The agent this form will save.", + "previewEdit": "The agent this form will save, and how it differs from the saved one.", + "default": "Default", + "network": "Rede dos comandos em execuções sem supervisão", + "networkHelp": "Quando um gatilho ou um conector roda este agente, os comandos do Sandbox dele alcançam esta parte da rede, até o limite definido por um administrador. Uma execução nunca pergunta: um host fora dela é bloqueado. Um chat que você inicia com este agente também começa assim.", + "networkPresets": { + "off": "Desligada", + "registries": "Registros de pacotes", + "all": "Todos os sites públicos" + } + }, + "run": { + "workingLine": "Working" + }, + "jobs": { + "title": "Triggers", + "newTask": "New trigger", + "createTask": "Create trigger", + "loading": "Loading triggers", + "loadFailed": "Failed to load: {{message}}", + "actionFailed": "The action failed: {{message}}", + "summary": { + "stopped": "{{count}} skipped", + "lastFailed_one": "The last run of {{names}} failed.", + "lastFailed_other": "The last run failed for {{names}}." + }, + "tasks": { + "title": "Triggers, {{count}}", + "caption": "Triggers", + "task": "Trigger", + "noSchedule": "No schedule", + "noAgent": "No agent", + "fileKind": "When a file arrives", + "open": "Open", + "started": "Trigger \"{{name}}\" started", + "toggleLabel": "Run {{name}} by itself", + "toggleFailed": "Could not change the trigger: {{message}}", + "deleted": "Trigger deleted", + "noneYet": "You have no triggers yet.", + "noModel": "No model", + "next": "Próxima" + }, + "history": { + "filterTask": "Trigger", + "allTasks": "All triggers", + "task": "Trigger", + "unknownTask": "Unknown trigger", + "cancelledLine": "Skipped", + "doneLine": "Finished. Open the chat for the answer.", + "doneFile": "Finished for {{file}}. Open the chat for the answer.", + "pendingLine": "Waiting to start", + "emptyText": "A run starts when you press Run now, when a schedule fires, or when a file arrives in a watched folder.", + "openRun": "Open the chat", + "reason": "Reason", + "file": "Started by the file {{file}}." + }, + "status": { + "done": "Done", + "failed": "Failed", + "running": "Working", + "queued": "Waiting", + "skipped": "Skipped" + }, + "empty": { + "title": "No triggers yet", + "text": "A trigger starts a chat for you by itself: at a time, or when a file arrives in a folder. Each run is an ordinary chat that you can open.", + "step1": "Write what the chat should do.", + "step2": "Pick an agent, or a model, to run it.", + "step3": "Choose when it runs, or press Run now." + }, + "task": { + "back": "Triggers", + "missingTitle": "Trigger not found", + "agent": "Agent", + "model": "Model", + "schedule": "Schedule", + "cap": "Most runs a day", + "watches": "Watches the folder {{folder}}", + "noRuns": "This trigger has not run yet.", + "runMeta": "took {{time}}", + "prompt": "What to do" + }, + "edit": { + "titleNew": "New trigger", + "subNew": "Name it, write what to do and choose when it runs.", + "subEdit": "Changes apply from the next run.", + "save": "Save trigger", + "create": "Create trigger", + "nameRequired": "A trigger needs a name", + "promptRequired": "Write what the trigger should do", + "created": "Trigger created", + "updated": "Trigger updated", + "sections": { + "basics": "Basics", + "prompt": "What to do", + "schedule": "When", + "limits": "Limits" + }, + "summary": { + "basics": "{{name}}, {{model}}", + "basicsNoModel": "{{name}}, no agent or model yet", + "promptEmpty": "Nothing to do yet", + "folderEmpty": "Needs a folder", + "cap": "Up to {{count}} runs a day", + "capDefault": "Default limit" + }, + "name": "Name", + "namePlaceholder": "daily-digest", + "noAgent": "No agent", + "model": "Model", + "enabled": "On", + "enabledHint": "A trigger that is off keeps its schedule but does not run.", + "prompt": "What to do", + "promptPlaceholder": "Summarise what is new in my Reports folder.", + "promptHint": "This text starts the chat each time the trigger fires.", + "capHint": "The most chats this trigger may start in one day. Empty uses the default.", + "previewEdit": "What the next run would send, and what differs from the saved trigger.", + "previewNew": "What the first run would send.", + "promptAsSent": "The text a run starts with", + "noChanges": "Nothing differs from the saved trigger.", + "loadingTask": "Loading trigger", + "changed_one": "{{count}} change", + "changed_other": "{{count}} changes" + }, + "subtitle": "Inicie um agente por agenda ou quando um arquivo chegar. Cada execução é um chat que você pode abrir." + }, + "views": { + "label": "Área de agentes", + "agents": "Agentes" + }, + "now": { + "failedTitle": "{{name}} falhou.", + "workingTitle": "{{name}} está trabalhando.", + "openRun": "Abrir a execução" + }, + "load": { + "agents": "Não foi possível carregar seus agentes.", + "agentsStale": "Não foi possível atualizar a lista. Ela mostra o estado anterior.", + "agent": "Não foi possível carregar este agente.", + "agentStale": "Não foi possível atualizar esta página. Ela mostra o estado anterior.", + "agentForm": "Não foi possível carregar este agente para edição.", + "triggers": "Não foi possível carregar seus gatilhos.", + "triggersStale": "Não foi possível atualizar os gatilhos. Eles mostram o estado anterior.", + "trigger": "Não foi possível carregar este gatilho.", + "triggerStale": "Não foi possível atualizar este gatilho. Ele mostra o estado anterior.", + "tasks": "Não foi possível carregar as tarefas.", + "tasksStale": "Não foi possível atualizar as tarefas. Elas mostram o estado anterior." + }, + "meta": { + "anyModel": "Qualquer modelo", + "addons_one": "{{count}} complemento", + "addons_other": "{{count}} complementos" + }, + "chips": { + "lastFailed": "A última execução falhou" + }, + "tasks": { + "title": "Tarefas de agentes", + "allUsers": "Todos os usuários", + "new": "Nova tarefa", + "newTitle": "Nova tarefa", + "editTitle": "Editar tarefa", + "name": "Nome", + "description": "Descrição", + "runner": "Executada por", + "byModel": "Um modelo", + "byAgent": "Um dos seus agentes", + "model": "Modelo", + "agent": "Agente", + "pick": "Escolha…", + "prompt": "Prompt", + "promptHint": "Escreva um parâmetro como {{example}}; cada execução pede o valor dele.", + "webhooksKept_one": "Esta tarefa tem {{count}} webhook. Ele fica como está; altere webhooks pela API.", + "webhooksKept_other": "Esta tarefa tem {{count}} webhooks. Eles ficam como estão; altere webhooks pela API.", + "save": "Salvar tarefa", + "cancel": "Cancelar", + "saved": "Tarefa \"{{name}}\" salva", + "runTitle": "Executar {{name}}", + "runNow": "Executar agora", + "started": "Tarefa \"{{name}}\" iniciada", + "tasks": "Tarefas", + "off": "Desligada", + "run": "Executar", + "edit": "Editar", + "delete": "Excluir", + "deleted": "Tarefa \"{{name}}\" excluída", + "task": "Tarefa", + "cancelJob": "Cancelar", + "rerun": "Executar de novo", + "rerunStarted": "Iniciada de novo", + "stillRunning": "Ainda em execução.", + "noResult": "Sem resultado.", + "traces_one": "{{count}} etapa", + "traces_other": "{{count}} etapas", + "webhookSent": "O webhook foi chamado.", + "webhookFailed": "O webhook falhou: {{message}}", + "actionFailed": "Não funcionou: {{message}}", + "state": { + "pending": "Aguardando", + "running": "Em execução", + "completed": "Concluída", + "failed": "Falhou", + "cancelled": "Cancelada" + }, + "lede": "Prompts que um modelo ou um dos seus agentes executa em segundo plano. Inicie aqui ou de um script pela API.", + "searchLabel": "Pesquisar tarefas", + "filter": { + "label": "Mostrar tarefas", + "all": "Todas", + "on": "Ligadas", + "off": "Desligadas" + }, + "noMatch": "Nenhuma tarefa corresponde.", + "on": "Ligada", + "offLine": "Desligada. Ela não roda até você ligá-la.", + "onDemand": "Roda quando você inicia", + "asksFor": "Pede {{list}}", + "webhooks_one": "{{count}} webhook", + "webhooks_other": "{{count}} webhooks", + "runsCount_one": "{{count}} execução", + "runsCount_other": "{{count}} execuções", + "runsOf_one": "{{count}} execução de {{name}}", + "runsOf_other": "{{count}} execuções de {{name}}", + "toggleLabel": "{{name}} pode rodar", + "turnedOn": "\"{{name}}\" está ligada", + "turnedOff": "\"{{name}}\" está desligada", + "moreFor": "Mais ações para {{name}}", + "open": "Abrir", + "openTask": "Abrir {{name}}", + "runFor": "Executar {{name}}", + "rerunFor": "Executar {{name}} de novo", + "jobMoreFor": "Mais ações para esta execução de {{name}}", + "detailsFor": "Detalhes da execução de {{name}}", + "openChat": "Abrir o chat", + "deleteJob": "Excluir execução", + "jobDeleted": "Execução excluída", + "cancelled": "Execução interrompida", + "confirmJob": { + "title": "Excluir esta execução?", + "message": "A resposta e as etapas são excluídas. Não dá para desfazer." + }, + "line": { + "pending": "Aguardando para começar", + "running": "Trabalhando", + "cancelled": "Interrompida antes de terminar" + }, + "startedBy": { + "manual": "Iniciada aqui", + "api": "Iniciada pela API", + "cron": "Iniciada por um agendamento" + }, + "output": { + "answer": "Resposta", + "error": "Erro", + "values": "Valores" + }, + "traceKind": { + "reasoning": "Raciocínio", + "tool_call": "Ferramenta", + "tool_result": "Resultado da ferramenta", + "status": "Status" + }, + "empty": { + "title": "Nenhuma tarefa ainda", + "body": "Uma tarefa é um prompt que você escreve uma vez e executa muitas. Coloque {{example}} onde um valor muda, e cada execução pede esse valor.", + "fact1": "Um modelo ou um dos seus agentes a executa", + "fact2": "Inicie aqui ou de um script pela API", + "fact3": "Cada execução guarda a resposta e as etapas" + }, + "sheet": { + "optional": "(opcional)", + "newDesc": "Escreva o prompt uma vez e execute sempre que precisar.", + "editDesc": "As mudanças valem a partir da próxima execução.", + "about": "Sobre", + "nameHelp": "Scripts usam este nome para executar a tarefa pela API.", + "descHelp": "Uma linha que diz para que serve a tarefa.", + "who": "Quem executa", + "modelHelp": "O modelo responde ao prompt sem ferramentas.", + "agentHelp": "O agente responde com as próprias instruções e ferramentas.", + "what": "O que faz", + "promptPlaceholder": "Por exemplo: Resuma o ticket {{example}} em três linhas.", + "inputs": "Cada execução pede:", + "noInputs": "Este prompt não pede nada. Cada execução o envia como está.", + "on": "Ligada", + "onHelp": "Uma tarefa desligada mantém as execuções, mas não pode começar.", + "runDesc": "Preencha os valores desta execução.", + "runNone": "Esta tarefa não pede nada. Ela executa o prompt como está.", + "preview": "O que a tarefa recebe" + }, + "errors": { + "name": "Dê um nome à tarefa.", + "prompt": "Escreva o que a tarefa deve fazer.", + "model": "Escolha um modelo.", + "agent": "Escolha um agente." + }, + "pane": { + "label": "Detalhes da tarefa", + "overview": "Visão geral", + "runs": "Execuções", + "settings": "Configurações", + "inputs": "Pede", + "noInputs": "Nada", + "changed": "Alterada", + "api": "Executar pela API", + "apiHint": "Envie os valores como um objeto JSON. A resposta traz o id da execução para consultar o resultado.", + "noRuns": "Nenhuma execução ainda. Execute a tarefa e a resposta aparece aqui.", + "webhooks": "Webhooks", + "noWebhooks": "Nenhum. Adicione webhooks pela API para saber quando uma execução termina.", + "deleteTitle": "Excluir esta tarefa", + "deleteHelp": "As execuções ficam até o fim do prazo de retenção." + }, + "history": { + "title": "Histórico de execuções", + "filterLabel": "Mostrar execuções", + "all": "Todas", + "active": "Em andamento", + "completed": "Concluídas", + "failed": "Falharam", + "cancelled": "Canceladas", + "allTasks": "Todas as tarefas", + "emptyBody": "Execute uma tarefa e a resposta aparece aqui.", + "more": "Mostrar mais {{count}}" + } } } diff --git a/core/http/react-ui/public/locales/pt-BR/auth.json b/core/http/react-ui/public/locales/pt-BR/auth.json index dfc9b3f7b..6988bf753 100644 --- a/core/http/react-ui/public/locales/pt-BR/auth.json +++ b/core/http/react-ui/public/locales/pt-BR/auth.json @@ -47,12 +47,15 @@ "account": { "title": "Conta", "subtitle": "Perfil, credenciais e chaves de API", + "subtitleLocal": "Quem você é, e como o assistente e o LocalAI se comportam para você", "unavailable": "Conta indisponível", "unavailableText": "A autenticação deve estar habilitada para gerenciar sua conta.", "tabs": { "profile": "Perfil", "security": "Segurança", - "apiKeys": "Chaves de API" + "apiKeys": "Chaves de API", + "about": "Sobre você e o assistente", + "appearance": "Aparência" }, "profile": { "displayName": "Nome de exibição", @@ -112,6 +115,9 @@ "pausedToast": "API key paused", "resumedToast": "API key resumed", "pauseFailed": "Failed to update API key: {{message}}" + }, + "usage": { + "loadFailedTitle": "Não foi possível carregar seu uso." } }, "notFound": { diff --git a/core/http/react-ui/public/locales/pt-BR/chat.json b/core/http/react-ui/public/locales/pt-BR/chat.json index 7c7c1ca74..c7e2da27f 100644 --- a/core/http/react-ui/public/locales/pt-BR/chat.json +++ b/core/http/react-ui/public/locales/pt-BR/chat.json @@ -25,22 +25,15 @@ "yes": "Sim", "gpuLayers": "Camadas GPU" }, - "context": { - "label": "Contexto: {{percent}}%", - "labelWithTokens": "Contexto: {{percent}}% ({{tokens}} tokens)" - }, "settings": { "title": "Configurações do Chat", "manageMode": "Modo de gerenciamento", - "manageModeDesc": "Permita que este chat instale modelos, alterne backends e edite configurações conversando com o LocalAI.", - "systemPrompt": "Instrução do sistema", - "systemPromptPlaceholder": "Você é um assistente útil...", - "temperature": "Temperatura", - "topP": "Top P", - "topK": "Top K", - "contextSize": "Tamanho do contexto", - "contextSizePlaceholder": "2048", - "clearHistory": "Limpar histórico do chat" + "serverKept": "O servidor guarda este chat. O modelo e o agente são definidos no início.", + "manageModeOn": "Este chat tem as ferramentas de gestão do LocalAI. Use /assistant para iniciar outro.", + "agent": "Agente", + "agentHelp": "Este chat usa o agente {{name}}: as instruções dele e só as ferramentas que ele concede.", + "editAgent": "Editar agente", + "deleteHelp": "Apagar este chat e as mensagens dele para sempre." }, "empty": { "manageTitle": "Gerencie o LocalAI conversando", @@ -61,7 +54,6 @@ "Gerar ideias" ], "recent": "Recente", - "noMessages": "Nenhuma mensagem ainda", "hintEnter": "Enter para enviar", "hintShiftEnter": "Shift+Enter para nova linha", "hintAttach": "Anexar arquivos" @@ -71,31 +63,18 @@ }, "actions": { "copy": "Copiar", - "edit": "Editar", - "editMessage": "Editar mensagem", - "save": "Salvar", - "cancel": "Cancelar", "regenerate": "Regenerar", - "branch": "Ramificar daqui", - "jumpToLatest": "Ir para a mais recente" + "jumpToLatest": "Ir para a mais recente", + "reuse": "Editar e enviar de novo" }, "streaming": { "transferring": "Transferindo modelo...", "transferringTo": "Transferindo o modelo para {{node}}..." }, - "tokens": { - "perSec": "{{count}} tok/s", - "peak": "Pico: {{count}} tok/s", - "usage": "{{prompt}}p + {{completion}}c = {{total}}" - }, "input": { - "placeholder": "Mensagem...", - "attachFile": "Anexar arquivo", "send": "Enviar mensagem", "stopGenerating": "Parar a geração", - "canvasTitle": "Canvas — extrai blocos de código e mídia para um painel lateral para visualização, cópia e download", - "canvasLabel": "Canvas", - "openCanvas": "Abrir painel do canvas" + "placeholderQueue": "O modelo está trabalhando. Uma mensagem enviada agora espera o próximo passo dele." }, "deleteAllDialog": { "title": "Excluir Todos os Chats", @@ -107,8 +86,7 @@ "copied": "Copiado para a área de transferência", "copyFailed": "Não foi possível copiar para a área de transferência", "chatCopied": "Chat copiado para a área de transferência", - "forked": "Novo chat criado", - "pdfScannedAsImages": "{{name}} não tem camada de texto (digitalização). {{count}} de {{total}} páginas anexadas como imagens para um modelo de visão." + "newChatForModel": "Um chat mantém o modelo dele. Novo chat iniciado com {{model}}." }, "menu": { "trigger": "Chats", @@ -118,23 +96,497 @@ "noMatch": "Nenhuma conversa corresponde à sua busca", "noConversations": "Nenhuma conversa ainda", "rename": "Renomear", - "duplicate": "Duplicar chat", "copyChat": "Copiar chat", "exportMarkdown": "Exportar como Markdown", "deleteChat": "Excluir chat", "newChat": "Novo chat", "clearAll": "Limpar tudo", - "deleteAllTitle": "Excluir todas as conversas" + "deleteAllTitle": "Excluir todas as conversas", + "untitled": "Novo chat", + "origin": { + "trigger": "Iniciado por um gatilho", + "connector": "De um conector" + } }, "message": { - "you": "Você" + "you": "Você", + "loopRun": "Loop, execução {{run}}" }, - "addons": { - "note": "An add-on that is off here does nothing in this chat.", - "label": "Add-ons", - "title": "Add-on tools for this chat", - "heading": "Add-on tools", - "selectAll": "Select all", - "deselectAll": "Deselect all" + "tools": { + "label": "Ferramentas", + "title": "Ferramentas que este chat pode usar", + "heading": "Ferramentas deste chat", + "draftNote": "Um novo chat começa com estas opções. Uma ferramenta desligada não é oferecida ao modelo.", + "note": "Uma ferramenta desligada aqui não é oferecida ao modelo neste chat.", + "count": "{{on}} de {{total}} ligadas", + "selectAll": "Ligar todas", + "disableAll": "Desligar todas", + "groupOn": "Todas ligadas", + "groupOff": "Todas desligadas", + "groupOnLabel": "Ligar tudo em {{group}}", + "groupOffLabel": "Desligar tudo em {{group}}", + "groupCount": "{{on}} de {{total}}", + "optIn": "Desligada até você ligar. Ligar todas não a altera.", + "empty": "Não há nada para ligar neste chat.", + "failed": "Não foi possível salvar. O interruptor voltou ao que era.", + "unavailable": "Indisponível neste chat", + "toolCount_one": "{{count}} ferramenta", + "toolCount_other": "{{count}} ferramentas" + }, + "skills": { + "label": "Habilidades", + "manage": "Gerenciar habilidades", + "title": "Habilidades que este chat pode usar", + "heading": "Habilidades deste chat", + "count": "{{on}} de {{total}} ativas", + "selectAll": "Selecionar todas", + "disableAll": "Desativar todas", + "selectShown": "Selecionar as exibidas", + "disableShown": "Desativar as exibidas", + "search": "Buscar habilidades", + "noMatch": "Nenhuma habilidade corresponde a “{{query}}”.", + "later": "Habilidades que você adicionar depois começam ativas.", + "failed": "Não foi possível salvar. Nada mudou.", + "source": { + "builtin": "Integrada", + "personal": "Pessoal", + "shared": "Compartilhada", + "folder": "Espaço de trabalho", + "addon": "{{name}}" + }, + "replacedBy": "Substituída por {{name}}", + "fallback_one": "{{skills}} integrada em uso: a habilidade própria do complemento {{name}} está desligada ou não está em execução aqui.", + "fallback_other": "{{skills}} integradas em uso: as habilidades próprias do complemento {{name}} estão desligadas ou não estão em execução aqui." + }, + "agent": { + "label": "Agente", + "title": "Agente salvo para este chat", + "heading": "Começar com um agente", + "note": "O agente dá ao chat as instruções dele e exatamente as ferramentas que concede.", + "none": "Sem agente", + "fixed": "O agente deste chat" + }, + "assistant": { + "chatTitle": "Assistente do LocalAI" + }, + "slash": { + "delete": { + "label": "Apagar chat", + "desc": "Apagar esta conversa" + }, + "assistant": { + "desc": "Iniciar um chat que pode gerir o LocalAI" + }, + "settings": { + "desc": "Modo foco, agente e informações do modelo" + }, + "group": { + "context": "Contexto", + "skills": "Skills e agentes", + "help": "Ajuda" + }, + "models": { + "label": "Modelos", + "desc": "Abrir a lista de modelos" + }, + "skill": { + "label": "Usar uma skill", + "desc": "Escolher uma das skills deste chat" + }, + "skills": { + "label": "Gerenciar habilidades", + "desc": "Abrir a página de habilidades para escrevê-las e alterá-las" + }, + "agent": { + "label": "Pedir a um agente salvo", + "desc": "Passar uma tarefa para um dos seus agentes", + "enable": "Ativar os agentes salvos neste chat", + "enableDesc": "Depois escolha o agente" + }, + "compact": { + "label": "Compactar o contexto", + "desc": "Resumir as mensagens antigas para abrir espaço para o modelo" + }, + "clear": { + "label": "Limpar o contexto", + "desc": "Recomeçar neste chat; as mensagens ficam" + }, + "attach": { + "label": "Anexar arquivos", + "desc": "Escolher arquivos para enviar", + "clearDesc": "Remover os arquivos que aguardam o envio" + }, + "help": { + "label": "Todos os comandos", + "desc": "Listar cada comando e o que ele faz" + }, + "about": { + "label": "Sobre este chat", + "desc": "Modelo, ferramentas, skills e tamanho do contexto" + }, + "args": { + "skill": "Skills", + "agent": "Agentes salvos", + "model": "Modelos", + "attach": "Arquivos", + "loop": "Loops", + "goal": "Objetivo" + }, + "badge": { + "builtin": "embutida", + "addon": "complemento", + "shared": "compartilhada", + "yours": "sua" + }, + "collides": "só com /skill", + "errors": { + "skill": "Este chat não tem uma skill chamada \"{{name}}\".", + "agent": "Você não tem um agente salvo chamado \"{{name}}\".", + "agentTask": "Escreva a tarefa para {{name}} depois do nome.", + "agentsOff": "Os agentes salvos estão desativados neste chat. Ative-os no menu Ferramentas." + }, + "loop": { + "label": "Repetir um prompt", + "desc": "Execute um prompt várias vezes neste chat: /loop 5m verifique o build", + "listDesc": "Mostrar os loops deste chat", + "stopDesc": "Parar um loop, ou todos", + "stopAll": "Todos os loops", + "stopAllDesc": "Parar todos os loops deste chat" + }, + "goal": { + "label": "Objetivo", + "desc": "Defina um objetivo em que o chat continua trabalhando sozinho", + "clearDesc": "Remover o objetivo", + "resumeDesc": "Continuar com o objetivo" + } + }, + "shortcuts": { + "onMessage": "Numa mensagem: copiar, editar, regenerar", + "editLast": "Editar a sua última mensagem" + }, + "files": { + "title": "Arquivos deste chat", + "chip": "Arquivos", + "chipTitle": "Arquivos deste chat: o que você anexou e o que o assistente criou ou alterou", + "close": "Fechar arquivos", + "emptyTitle": "Ainda não há arquivos neste chat", + "emptyBody": "Solte arquivos aqui para anexá-los à sua próxima mensagem. Arquivos que o assistente criar ou alterar também aparecem aqui.", + "dropHint": "Solte arquivos aqui para anexá-los à sua próxima mensagem.", + "groups": { + "uploaded": "Você enviou", + "created": "Criados e alterados", + "working": "Arquivos de trabalho" + }, + "turn": "turno {{n}}", + "media": "No contexto", + "mediaTitle": "O modelo recebeu este arquivo como imagem ou vídeo com sua mensagem", + "changedTag": "Editado", + "changedTitle": "O assistente alterou este arquivo", + "download": "Baixar {{name}}", + "error": "Não foi possível ler os arquivos deste chat.", + "workingNote": "Scripts, dados e logs que o assistente criou no caminho. Ficam na pasta de trabalho deste chat." + }, + "steps": { + "verb": { + "read": "Leu", + "list": "Listou", + "write": "Salvou", + "create": "Criou", + "change": "Alterou", + "move": "Moveu", + "delete": "Excluiu", + "search": "Pesquisou", + "run": "Executou", + "fetch": "Buscou", + "websearch": "Pesquisou na web:", + "webfetch": "Leu", + "delegate": "Consultou um agente", + "used": "Usou" + }, + "worked": "Trabalhou por {{duration}}", + "thoughtFor": "Pensou por {{duration}}", + "working": "Trabalhando", + "thinking": "Pensando", + "noMatches": "nenhum resultado", + "noResults": "nenhum resultado", + "emptyFolder": "pasta vazia", + "size": "{{size}}", + "seconds": "{{value}} s", + "minutes": "{{min}} min {{sec}} s", + "tool": "Ferramenta", + "input": "Entrada", + "showLess": "Mostrar menos", + "running": "Em execução", + "failed": "Falhou", + "count_one": "{{count}} etapa", + "count_other": "{{count}} etapas", + "files_one": "{{count}} arquivo", + "files_other": "{{count}} arquivos", + "items_one": "{{count}} item", + "items_other": "{{count}} itens", + "matches_one": "{{count}} resultado", + "matches_other": "{{count}} resultados", + "results_one": "{{count}} resultado", + "results_other": "{{count}} resultados", + "lines_one": "{{count}} linha", + "lines_other": "{{count}} linhas", + "showAll_one": "Mostrar todas as {{count}} linhas", + "showAll_other": "Mostrar todas as {{count}} linhas", + "details": "Mostrar detalhes", + "openFile": "Abrir {{path}} em Arquivos", + "savedFiles": "Salvo nos seus arquivos:", + "fileOp": { + "changed": "alterado", + "deleted": "excluído" + }, + "moreFiles": "e mais {{count}}", + "alsoSavedWorking_one": "Também salvou {{count}} arquivo de trabalho", + "alsoSavedWorking_other": "Também salvou {{count}} arquivos de trabalho", + "savedWorking_one": "Salvou {{count}} arquivo de trabalho", + "savedWorking_other": "Salvou {{count}} arquivos de trabalho", + "workingTitle": "Arquivo de trabalho (um script, um log ou um rascunho): abrir {{path}}" + }, + "error": { + "title": "A resposta falhou", + "kept": "O que foi escrito até agora fica acima.", + "retry": "Tentar de novo", + "detail": "Detalhe técnico", + "reason": { + "unknown": "O agente parou por causa de um erro. Tente de novo para executar o turno outra vez.", + "model_backend_missing": "Não foi possível iniciar o modelo \"{{model}}\": o backend dele não está instalado.", + "model_backend_missing_anon": "Não foi possível iniciar o modelo: o backend dele não está instalado.", + "model_cooldown": "O modelo \"{{model}}\" falhou ao iniciar há pouco e está em pausa.", + "model_cooldown_anon": "O modelo falhou ao iniciar há pouco e está em pausa.", + "model_load_failed": "Não foi possível iniciar o modelo \"{{model}}\".", + "model_load_failed_anon": "Não foi possível iniciar o modelo.", + "model_not_found": "O modelo \"{{model}}\" não foi encontrado.", + "model_not_found_anon": "O modelo não foi encontrado.", + "upstream_key": "O upstream recusou a chave.", + "upstream_path": "O upstream não aceita esta requisição nesse endereço.", + "upstream_unreachable": "Não foi possível alcançar o endereço do upstream.", + "upstream": "O provedor por trás do modelo retornou um erro.", + "timeout": "O modelo demorou demais para responder.", + "cancelled": "O turno foi cancelado antes de o modelo responder." + }, + "hint": { + "model_backend_missing": "Escolha outro modelo ou peça a um administrador que instale o backend.", + "model_backend_missing_admin": "Escolha outro modelo ou instale o backend pela página Backends.", + "model_cooldown": "Tente de novo em {{seconds}} segundos.", + "model_load_failed": "Confira a configuração do modelo e o backend dele e tente de novo.", + "model_not_found": "Escolha outro modelo ou instale este pela página Modelos.", + "upstream_key": "Confira a chave de API nas configurações do modelo.", + "upstream_path": "O endereço do upstream deve ser o endpoint completo, por exemplo .../v1/chat/completions.", + "upstream_unreachable": "Confira o endereço nas configurações do modelo.", + "upstream": "Tente de novo em instantes. Se continuar falhando, confira o endereço e a chave do provedor.", + "timeout": "Tente de novo ou use um modelo menor ou mais rápido.", + "cancelled": "Tente de novo quando quiser." + }, + "openBackends": "Abrir Backends" + }, + "allChats": { + "title": "Todos os chats", + "supporting": "Todos os chats salvos. Pesquise, renomeie, exporte ou exclua.", + "loadFailed": "Não foi possível carregar seus chats.", + "emptyTitle": "Nenhum chat ainda", + "emptyBody": "Um chat começa no Início: digite uma mensagem e envie. Depois disso, seus chats aparecem aqui.", + "start": "Começar um chat" + }, + "foot": { + "state": { + "ready": "Pronto", + "nomodel": "Nenhum modelo", + "sending": "Enviando", + "loading": "Carregando o modelo", + "thinking": "Pensando", + "tool": "Executando {{tool}}", + "answering": "Respondendo", + "waiting": "Aguardando você", + "stopped": "Parado", + "failed": "Falhou", + "interrupted": "Interrompido", + "paused": "Pausado", + "compacting": "Compactando o contexto", + "clearing": "Limpando o contexto" + }, + "tool": { + "search": "Pesquisando", + "list": "Listando", + "read": "Lendo", + "create": "Criando", + "write": "Escrevendo", + "change": "Alterando", + "move": "Movendo", + "delete": "Excluindo", + "run": "Executando um comando", + "fetch": "Buscando", + "delegate": "Consultando um subagente" + }, + "hint": "Enter para enviar · Shift+Enter para uma nova linha", + "meter": { + "label": "Contexto {{pct}}% cheio. Ver contexto e velocidade", + "labelNoLimit": "Contexto com {{used}} tokens. Ver contexto e velocidade" + }, + "details": { + "title": "Contexto e velocidade", + "context": "Contexto", + "contextOf": "{{used}} de {{limit}} tokens ({{pct}}%)", + "contextUsed": "{{used}} tokens", + "contextLeft": "Restam {{left}} tokens", + "contextHelp": "O contexto é o que o modelo lê a cada requisição: as instruções, este chat até agora e os resultados das ferramentas.", + "nearFull": "Este chat está perto do limite. O servidor compacta a parte mais antiga para que o modelo possa continuar. A transcrição mantém todas as mensagens.", + "limitUnknown": "O servidor não informou o tamanho da janela de contexto.", + "lastRequest": "Última requisição", + "model": "Modelo", + "backend": "Backend", + "node": "Nó", + "tokensIn": "Tokens de entrada", + "tokensOut": "Tokens de saída", + "firstToken": "Tempo até o primeiro token", + "promptTime": "Processamento do prompt", + "speed": "Velocidade", + "speedLive": "Velocidade estimada durante o streaming", + "average": "Média das últimas {{count}} requisições", + "note": "Estes números vêm das requisições que esta página viu. O servidor não os guarda, então recarregar apaga." + }, + "paused": { + "after": "após {{count}} etapas", + "atLimit": "no limite de etapas", + "continue": "Continuar", + "continueTitle": "O modelo usou todas as etapas de um turno. Deixe-o continuar de onde parou.", + "message": "Continue de onde você parou." + } + }, + "attachments": { + "label": "Arquivos anexados", + "open": "Abrir {{name}}", + "showMore": "Mostrar mais {{count}}", + "missing": "Arquivo não está mais disponível" + }, + "preview": { + "open": "Abrir prévia", + "created": "Criado pelo assistente", + "changed": "Alterado pelo assistente", + "yours": "Você enviou", + "fromReply": "Da resposta", + "download": "Baixar", + "downloadCode": "Baixar código", + "copy": "Copiar", + "copied": "Copiado", + "openInFiles": "Abrir em Arquivos", + "actions": "Ações do arquivo", + "opened": "Prévia aberta: {{name}}", + "chip": { + "created": "Criado", + "changed": "Alterado", + "label": "{{verb}} {{name}}, abrir prévia", + "row": "Arquivos desta resposta" + } + }, + "meter": { + "usedOf": "{{used}} / {{limit}} tokens ({{pct}}%)", + "used": "{{used}} tokens", + "speed": "{{speed}} tok/s", + "title": "Contexto usado pela última solicitação ao modelo", + "ofLimit": "{{used}} / {{limit}} tokens", + "left": "restam {{left}}" + }, + "goal": { + "label": "Objetivo", + "states": { + "active": "Em andamento", + "paused": "Pausado", + "done": "Alcançado", + "blocked": "Precisa de você" + }, + "pausedBy": { + "user": "Você pausou.", + "turn_limit": "Usou todos os turnos automáticos. Retome para dar mais.", + "step_limit": "Usou todos os passos. Retome para dar mais.", + "error": "Um turno falhou. Retome para tentar de novo.", + "interrupted": "O servidor parou durante um turno. Retome para continuar." + }, + "blockedNote": "Precisa de você: {{note}}", + "doneNote": "{{note}}", + "replaced": "Substituído: {{text}}", + "pause": "Pausar", + "pauseTitle": "Parar o turno em andamento e pausar o objetivo", + "resume": "Retomar", + "resumeTitle": "Continuar com o objetivo", + "clear": "Remover", + "clearTitle": "Remover o objetivo", + "progress": "Objetivo · turno {{turn}} de {{max}}", + "progressStart": "Objetivo · começando", + "markers": { + "set": "Objetivo definido", + "replaced": "Objetivo substituído", + "continue": "Continuando rumo ao objetivo · turno {{turn}}", + "continueStart": "Continuando rumo ao objetivo", + "paused": "Objetivo pausado", + "resumed": "Objetivo retomado", + "done": "Objetivo alcançado", + "blocked": "Objetivo bloqueado", + "cleared": "Objetivo removido" + }, + "announce": { + "set": "Objetivo definido: {{text}}", + "paused": "Objetivo pausado. {{why}}", + "resumed": "Objetivo retomado", + "done": "Objetivo alcançado: {{note}}", + "blocked": "O objetivo precisa de você: {{note}}", + "cleared": "Objetivo removido" + }, + "card": { + "title": "Objetivo", + "none": "Este chat não tem objetivo. Digite /goal e o que você quer que seja feito, e o chat continua trabalhando nisso sozinho.", + "turns": "Turno {{turn}} de {{max}}", + "steps": "{{steps}} de {{max}} passos", + "clear": "/goal clear remove.", + "resume": "/goal resume continua.", + "turnsLabel": "Turnos automáticos", + "stepsLabel": "Passos", + "setLabel": "Definido", + "stateLabel": "Estado", + "noteLabel": "Nota" + }, + "errors": { + "empty": "Escreva o objetivo depois de /goal.", + "notAllowed": "Só um chat que você iniciou pode ter um objetivo." + } + }, + "invoked": { + "skill": "Skill: {{name}}", + "agent": "Agente: {{name}}" + }, + "marker": { + "compacted": "Contexto compactado: de {{before}} para {{after}} tokens", + "cleared": "Contexto limpo", + "clearedHint": "O modelo não vê as mensagens acima desta linha." + }, + "cmdcard": { + "close": "Fechar", + "helpTitle": "Comandos", + "helpSkills_one": "Skill: {{names}}. Digite / e o nome dela.", + "helpSkills_other": "{{count}} skills: {{names}}. Digite / e o nome.", + "helpKeys": "Digite / na caixa para abrir a lista. Tab completa, Enter executa, Esc fecha.", + "aboutTitle": "Este chat", + "model": "Modelo", + "state": "Estado", + "loaded": "Carregado", + "notLoaded": "Não carregado", + "backend": "Backend", + "tools": "Ferramentas", + "toolsOn_one": "{{count}} ativa", + "toolsOn_other": "{{count}} ativas", + "skills": "Skills", + "skillsOn_one": "{{count}} skill", + "skillsOn_other": "{{count}} skills", + "commands": "Comandos", + "isolated": "Rodam em uma sandbox isolada", + "host": "Rodam nesta máquina", + "noCommands": "Não podem rodar", + "context": "Contexto", + "contextOf": "{{used}} de {{limit}} tokens", + "contextUsed": "{{used}} tokens", + "unknown": "Ainda não se sabe" } } diff --git a/core/http/react-ui/public/locales/pt-BR/chats.json b/core/http/react-ui/public/locales/pt-BR/chats.json new file mode 100644 index 000000000..59d1808f9 --- /dev/null +++ b/core/http/react-ui/public/locales/pt-BR/chats.json @@ -0,0 +1,581 @@ +{ + "proposal": { + "loading": "Lendo o gatilho proposto...", + "unread": "Não foi possível ler o gatilho proposto: {{message}}", + "removed": "Este gatilho foi removido.", + "retry": "Tentar de novo", + "inactive": "Gatilho proposto. Ele não executa nada até você criá-lo.", + "name": "Nome", + "schedule": "Quando (por exemplo daily 08:00)", + "folder": "Pasta a observar", + "saveEdit": "Salvar", + "cancelEdit": "Cancelar", + "create": "Criar gatilho", + "edit": "Editar", + "dismiss": "Recusar", + "receipt": { + "scheduled": "Agendado:", + "watching": "Observando:", + "next": "Próxima execução {{when}}", + "view": "Ver gatilho", + "undo": "Desfazer", + "undone": "Desfeito:", + "again": "Agendar de novo", + "declined": "Recusado:", + "restore": "Restaurar", + "blocked": { + "ran": "Ele já foi executado uma vez; remova-o na página Gatilhos.", + "edited": "Você o editou depois; altere ou remova na página Gatilhos." + } + }, + "announce": { + "accepted": "{{name}} agendado.", + "undone": "{{name}} desfeito. Não está agendado.", + "declined": "{{name}} recusado.", + "reopened": "{{name}} espera sua resposta de novo." + }, + "plan": { + "whenLabel": "Quando", + "runsAsLabel": "Executa como", + "readsLabel": "Lê", + "writesLabel": "Escreve", + "costLabel": "Custo", + "stopLabel": "Para parar", + "when": "{{schedule}}, {{zone}}. Primeira execução {{when}}.", + "whenNoFirst": "{{schedule}}, {{zone}}.", + "whenFile": "Quando um arquivo chega em {{folder}}.", + "runsAs": "Você", + "runsAsModel": "Você, com {{model}}", + "writesWidget": ", mostrado no widget \"{{widget}}\" da Início", + "costLocal_one": "Cerca de {{tokens}} tokens por execução em um modelo local. No máximo {{count}} execução por dia.", + "costLocal_other": "Cerca de {{tokens}} tokens por execução em um modelo local. No máximo {{count}} execuções por dia.", + "costRemote_one": "Cerca de {{tokens}} tokens por execução em {{model}}, um serviço remoto. No máximo {{count}} execução por dia.", + "costRemote_other": "Cerca de {{tokens}} tokens por execução em {{model}}, um serviço remoto. No máximo {{count}} execuções por dia.", + "costUnknown_one": "Os tokens de uma execução são conhecidos após a primeira. No máximo {{count}} execução por dia.", + "costUnknown_other": "Os tokens de uma execução são conhecidos após a primeira. No máximo {{count}} execuções por dia.", + "remoteNote": "{{model}} é um serviço remoto: o que uma execução lê sai deste servidor.", + "stopWidget": "Pause pelo widget ou exclua em Gatilhos", + "stop": "Desligue ou exclua em Gatilhos" + } + }, + "mcpPage": { + "title": "Conectores MCP", + "supporting": "Conecte servidores MCP para dar aos seus chats e agentes mais ferramentas, como busca ou suas próprias APIs.", + "family": "Bots para Telegram ou Slack são conectores de mensagens.", + "related": "Relacionados", + "chats": "Chats", + "add": "Adicionar conector", + "learn": "Como o MCP funciona", + "loading": "Carregando seus conectores", + "loadFailed": "Não foi possível carregar seus conectores.", + "forbidden": "O MCP não está disponível para a sua conta.", + "empty": { + "title": "Nenhum conector ainda", + "body": "Um conector liga um servidor MCP ao LocalAI. Seus chats e agentes podem então usar as ferramentas dele, por exemplo para buscar na sua documentação ou ler uma agenda.", + "fact1": "Qualquer servidor MCP por http ou https", + "fact2": "Tokens e cabeçalhos salvos com criptografia", + "fact3": "Teste antes que os chats o usem" + }, + "toolbar": { + "search": "Buscar conectores", + "testAll": "Testar todos", + "testingAll": "Testando", + "testedAll": "Os conectores ligados foram testados." + }, + "filter": { + "label": "Mostrar", + "all": "Todos", + "on": "Ligados", + "attention": "Precisam de atenção", + "off": "Desligados", + "showAll": "Mostrar todos", + "none": { + "all": "Nenhum conector.", + "on": "Nenhum conector está ligado.", + "attention": "Nada precisa da sua atenção.", + "off": "Todos os conectores estão ligados." + } + }, + "noMatch": { + "title": "Nenhum conector corresponde a “{{query}}”", + "body": "Busque pelo nome ou pelo endereço.", + "clear": "Limpar a busca" + }, + "state": { + "label": "Status: {{state}}", + "ok": "Conectado", + "auth": "Precisa de acesso", + "error": "Sem conexão", + "unchecked": "Não testado", + "off": "Desligado" + }, + "row": { + "open": "{{name}}, {{state}}. Ver detalhes", + "toggle": "Usar {{name}} nos chats", + "menu": "Ações de {{name}}", + "offLine": "Desligado. Chats e agentes não o usam.", + "notChecked": "Ainda não testado. Teste para ver as ferramentas.", + "noTools": "Conectado, mas o servidor não oferece ferramentas.", + "tools_one": "{{count}} ferramenta", + "tools_other": "{{count}} ferramentas", + "checked": "Testado {{when}}", + "tokenSaved": "Token salvo", + "headersSaved": "Cabeçalhos salvos", + "envSaved": "Variáveis salvas" + }, + "actions": { + "details": "Detalhes", + "test": "Testar a conexão", + "testNamed": "Testar a conexão com {{name}}", + "testing": "Testando", + "edit": "Editar", + "reauth": "Substituir o token", + "remove": "Remover" + }, + "details": { + "use": "Usar nos chats", + "useHelp": "Chats sem agente usam todos os conectores ligados. Um agente salvo usa só os que você escolher para ele.", + "lastCheck": "Último teste", + "never": "Ainda não testado neste navegador.", + "okAt": "Conectado {{when}}.", + "testedAt": "Testado {{when}}.", + "errorTitle": "O último teste não conseguiu conectar.", + "authTitle": "O servidor recusou o acesso.", + "tools": "Ferramentas", + "toolsUnknown": "Teste a conexão para ver as ferramentas.", + "toolsNone": "O servidor não oferece ferramentas.", + "toolsSearch": "Buscar ferramentas", + "noToolMatch": "Nenhuma ferramenta corresponde a “{{query}}”.", + "noDescription": "Sem descrição.", + "connection": "Conexão", + "kind": "Tipo", + "address": "Endereço", + "command": "Comando", + "added": "Adicionado", + "queryHidden": "A parte do endereço depois de ? está oculta, porque pode conter uma chave.", + "signIn": "Acesso", + "token": "Token bearer", + "headers": "Cabeçalhos", + "env": "Variáveis", + "saved": "Salvo", + "none": "Nenhum", + "encrypted": "Salvo com criptografia no servidor. Nunca mais é exibido." + }, + "sheet": { + "addTitle": "Adicionar conector", + "editTitle": "Editar {{name}}", + "addDesc": "Conecte um servidor MCP. Você o testa antes que os chats o usem.", + "editDesc": "Os segredos salvos continuam, a menos que você os substitua ou remova.", + "testDesc": "O LocalAI se conecta com o que você salvou e lista as ferramentas.", + "close": "Fechar", + "kind": "Tipo", + "remote": "Servidor remoto", + "remoteHelp": "Um servidor MCP que esta máquina alcança por http ou https.", + "local": "Comando local", + "localHelp": "Um programa que o LocalAI inicia nesta máquina. Só administradores.", + "basics": "Conexão", + "name": "Nome", + "nameHelp": "Aparece no menu Ferramentas de um chat.", + "url": "Endereço", + "urlHelp": "Servidores em uma rede privada são recusados, a menos que um administrador os permita.", + "command": "Comando", + "commandHelp": "O programa a iniciar, sem shell.", + "args": "Argumentos", + "argsHelp": "Um argumento por linha.", + "signIn": "Acesso", + "signInHelp": "Só se o servidor pedir uma chave.", + "envIntro": "Variáveis que o comando recebe, por exemplo uma chave de API.", + "token": "Token bearer", + "tokenHelp": "Enviado como cabeçalho Authorization.", + "tokenSaved": "Há um token salvo. Digite um novo para substituí-lo.", + "tokenSavedShort": "Salvo", + "clearToken": "Remover o token salvo", + "headers": "Cabeçalhos", + "headersHelp": "Outros cabeçalhos de que o servidor precisa, como um cabeçalho com a chave de API.", + "headersSaved": "Os cabeçalhos estão salvos. Novas linhas substituem todos.", + "clearHeaders": "Remover os cabeçalhos salvos", + "headerName": "Nome do cabeçalho", + "headerValue": "Valor", + "addHeader": "Adicionar um cabeçalho", + "env": "Variáveis de ambiente", + "envSaved": "As variáveis estão salvas. Novas linhas substituem todas.", + "clearEnv": "Remover as variáveis salvas", + "envName": "Variável", + "addEnv": "Adicionar uma variável", + "removeRow": "Remover a linha {{n}}", + "showValue": "Mostrar o valor", + "hideValue": "Ocultar o valor", + "encrypted": "Tokens, cabeçalhos e variáveis são salvos com criptografia no servidor e nunca mais são exibidos.", + "cancel": "Cancelar", + "saving": "Salvando", + "saveAndTest": "Salvar e testar", + "savedOn": "Salvo. Está ligado.", + "savedOff": "Salvo. Fica desligado até você ligá-lo.", + "testing": "Conectando a {{name}}…", + "okTitle": "Conectado.", + "okBody_one": "{{name}} oferece {{count}} ferramenta.", + "okBody_other": "{{name}} oferece {{count}} ferramentas.", + "okNoTools": "O servidor não oferece ferramentas.", + "failTitle": "Não foi possível conectar.", + "failHint": "Confira o endereço e teste de novo.", + "failAuth": "Confira o token ou os cabeçalhos.", + "back": "Editar os dados", + "retry": "Testar de novo", + "done": "Concluído", + "keepOff": "Manter desligado", + "turnOn": "Ligar", + "discardTitle": "Descartar as alterações?", + "discardBody": "O que você digitou neste formulário será perdido.", + "discard": "Descartar", + "keepEditing": "Continuar editando" + }, + "errors": { + "nameRequired": "Dê um nome a ele.", + "nameTaken": "Outro conector tem esse nome.", + "urlRequired": "Digite o endereço do servidor.", + "urlInvalid": "Este não é um endereço válido.", + "urlScheme": "O endereço deve começar com http:// ou https://.", + "urlCredentials": "Coloque as credenciais no token ou em um cabeçalho, não no endereço.", + "commandRequired": "Digite o comando a iniciar." + }, + "remove": { + "title": "Remover {{name}}?", + "body": "O conector é excluído com o token, os cabeçalhos e as variáveis salvos. Um agente salvo que o usa não pode mais chamar as ferramentas dele.", + "confirm": "Remover", + "pending": "Removendo" + }, + "toasts": { + "removed": "“{{name}}” removido.", + "on": "“{{name}}” está ligado.", + "off": "“{{name}}” está desligado." + }, + "import": { + "title": "Servidores guardados neste navegador", + "text": "A antiga página de chats guardava estes servidores MCP neste navegador. Importe-os para mantê-los no servidor, onde qualquer navegador e sessão pode usá-los. Eles são criados desligados. Os tokens e cabeçalhos vão para o servidor e depois são removidos deste navegador.", + "button": "Importar {{count}} servidores deste navegador", + "buttonOne": "Importar 1 servidor deste navegador", + "busy": "Importando", + "headersCount": "{{count}} cabeçalhos", + "done": "{{count}} importados. Estão desligados: teste e ligue-os abaixo.", + "failed": "{{name}} não foi importado: {{error}}", + "left": "Os servidores que não foram importados continuam neste navegador." + } + }, + "connectorsPage": { + "loadFailed": "Não foi possível carregar seus conectores.", + "waitingTitle": "Aguardando o complemento", + "waitingText": "Estes vínculos são mantidos, mas só funcionam quando o complemento deles estiver instalado e ligado." + }, + "tools": { + "commands": { + "title": "Comandos não podem ser executados aqui", + "none": "Nenhum complemento que executa código está instalado, então o assistente pode escrever arquivos, mas não executar scripts nem comandos.", + "off": "O complemento Sandbox está desligado neste chat. Ligue-o no menu Ferramentas para o assistente poder executar código.", + "unavailable": "O complemento Sandbox está instalado, mas não consegue executar comandos neste servidor.", + "unavailableWhy": "O complemento Sandbox não consegue executar comandos neste servidor: {{reason}}", + "install": "Instalar o Sandbox", + "openSandbox": "Abrir o complemento Sandbox", + "askInstall": "Peça a um administrador para instalar o complemento Sandbox.", + "askFix": "Peça a um administrador para corrigir isso.", + "hide": "Ocultar", + "hideLabel": "Ocultar este aviso", + "chipTitle": "Este chat não pode executar código nem comandos. Selecione para ver detalhes.", + "hostTitle": "Os comandos rodam nesta máquina", + "host": "Os comandos rodam nesta máquina, com os arquivos e a rede dela, sem isolamento. Instale o Sandbox para isolá-los.", + "hostOff": "O Sandbox está desligado neste chat, então os comandos rodam nesta máquina, com os arquivos e a rede dela, sem isolamento. Ligue o Sandbox neste menu para isolá-los.", + "hostUnavailable": "O Sandbox não consegue rodar aqui, então os comandos rodam nesta máquina, com os arquivos e a rede dela, sem isolamento.", + "hostUnavailableWhy": "O Sandbox não consegue rodar aqui ({{reason}}), então os comandos rodam nesta máquina, com os arquivos e a rede dela, sem isolamento.", + "hostChipTitle": "Os comandos deste chat rodam nesta máquina, sem isolamento. Selecione para ver detalhes." + }, + "consent": { + "title": "Rodar este comando nesta máquina?", + "body": "Os comandos rodam nesta máquina sem isolamento. Eles podem ler os arquivos que o LocalAI lê e acessar a internet e a sua rede. Instale o Sandbox para isolá-los.", + "once": "Rodar uma vez", + "always": "Sempre nesta instância", + "deny": "Não rodar", + "alwaysHint": "\"Sempre nesta instância\" deixa os chats de todos os usuários rodarem comandos aqui sem perguntar (Configurações > Ferramentas).", + "askAdmin": "Só um administrador pode permitir comandos nesta máquina. Peça a um, ou rode o comando você mesmo.", + "expired": "Sem resposta: o turno que pediu terminou, então o comando não foi executado." + }, + "provider": { + "sandbox": "Sandbox", + "addon": "Complemento {{name}}", + "host": "Esta máquina, sem isolamento", + "hostOnce": "Esta máquina, permitido uma vez", + "hostDenied": "Não rodou: recusado", + "hostTimeout": "Não rodou: sem resposta", + "hostAdmin": "Não rodou: precisa de um administrador" + }, + "builtins": "Ferramentas integradas", + "builtin": { + "web": "Web", + "saved_agents": "Agentes salvos", + "triggers": "Gatilhos", + "shell": "Executar comandos", + "files": "Seus arquivos", + "media": "Imagens e áudio", + "collections": "Coleções", + "subagents": "Subagentes" + }, + "builtinBy": "{{tools}} · integradas", + "builtinByAddon": "{{tools}} · servidas por {{provider}}", + "servedBy": "Servido pelo complemento {{provider}}", + "servedByTool": "{{tool}}: {{provider}}", + "servedByBuiltin": "integrada", + "sandbox": { + "starting": "O Sandbox está iniciando. O LocalAI verifica de novo em {{seconds}} s." + }, + "fallbackBuiltin": "{{addon}} substitui {{tool}}, mas agora não pode atender este chat (parado, desligado aqui ou não concedido): {{tool}} integrada é usada.", + "fallbackNone": "{{addon}} substitui {{tool}}, mas agora não pode atender este chat, e {{tool}} integrada está desligada: este chat não tem {{tool}}.", + "shell": { + "machine": "Esta máquina", + "isolated": "Os comandos rodam isolados" + } + }, + "vision": { + "describedBy": "Este modelo não consegue ver imagens. Escolha outro modelo, ou a imagem será descrita por {{model}}.", + "none": "Este modelo não consegue ver imagens, e nenhum modelo aqui pode descrevê-las. Escolha um modelo que consiga ver imagens." + }, + "egress": { + "label": "Rede", + "preset": { + "off": "Desligada", + "registries": "Registros de pacotes", + "all": "Todos os sites públicos" + }, + "presetHint": { + "off": "Os comandos no Sandbox não alcançam nenhuma rede.", + "registries": "Os comandos alcançam só os registros de pacotes (PyPI, npm, Go, crates.io, RubyGems, Maven Central, versões do GitHub).", + "all": "Os comandos alcançam qualquer site público." + }, + "limited": "Limitado pelo seu administrador", + "limitedBy": "Seu administrador permite no máximo: {{limit}}.", + "hostNetwork": "Os comandos são executados nesta máquina e usam a rede dela. Os limites de rede exigem o complemento Sandbox.", + "panel": "Regras e pedidos ({{count}})", + "rules": "Regras deste chat", + "cards": "Pedidos deste chat", + "ruleAllow": "Permitido neste chat", + "ruleDeny": "Negado neste chat", + "aboveLimit": "acima do limite, não se aplica", + "remove": "Remover", + "removeLabel": "Remover a regra de {{host}}", + "card": { + "pending": "Aguardando resposta", + "allowedOnce": "Permitido uma vez", + "allowedChat": "Permitido neste chat", + "denied": "Negado", + "expired": "Sem resposta", + "refused": "Recusado: o limite de rede do servidor não permite" + }, + "consent": { + "title": "Acesso à rede", + "body": "Um comando neste chat quer alcançar", + "once": "Permitir uma vez", + "chat": "Permitir neste chat", + "deny": "Negar", + "preset": "Este chat: {{preset}}. Limite do seu administrador: {{limit}}.", + "adminOnly": "Este host está dentro da sua rede: só um administrador pode permitir.", + "answered": "{{state}} por {{who}}, {{time}}", + "answeredNoWho": "{{state}}, {{time}}", + "you": "você" + }, + "blocked": { + "titleOff": "O acesso à rede está desligado", + "titleCapped": "O acesso à rede está limitado", + "bodyOff": "O acesso à rede do Sandbox está desligado neste servidor. Um comando pediu para alcançar", + "bodyCapped": "Este servidor só permite {{limit}} para a rede do Sandbox. Um comando pediu para alcançar", + "adminOff": "Um administrador pode ativá-lo nas configurações de {{name}}.", + "adminCapped": "Um administrador pode aumentar o limite nas configurações de {{name}}.", + "adminLink": "Abrir as configurações de {{name}}", + "askAdmin": "Peça a um administrador deste servidor para alterar isso.", + "rowOff": "Desligada: o administrador não permitiu o acesso à rede.", + "runner": "o complemento que executa os comandos" + }, + "allowedHosts": "Sempre permitidos pelo seu administrador:", + "allowedHostsOff": "Eles valem quando a rede está ligada.", + "allowedHostsNote": "{{count}} hosts são sempre permitidos pelo seu administrador.", + "limitNote": "Rede deste chat. O administrador define o máximo que um chat pode alcançar.", + "asksAbove": "Este chat pede {{choice}}; o limite do administrador é {{limit}}." + }, + "inbox": { + "unread": "{{count}} não lidas", + "markAll": "Marcar tudo como lido", + "delete": "Excluir", + "pushOn": "Ativar notificações do navegador", + "pushOff": "Desativar notificações do navegador", + "pushDenied": "As notificações do navegador estão bloqueadas para este site.", + "pushUnavailable": "Este servidor não pode guardar assinaturas push.", + "clearAll": "Limpar tudo", + "clearRead": "Limpar lidas", + "cancel": "Cancelar", + "markRead": "Marcar como lida", + "cleared_one": "{{count}} notificação removida.", + "cleared_other": "{{count}} notificações removidas.", + "confirmAll_one": "Remover {{count}} notificação? Isso não pode ser desfeito.", + "confirmAll_other": "Remover todas as {{count}} notificações? Isso não pode ser desfeito.", + "confirmRead_one": "Remover {{count}} notificação lida? As não lidas continuam.", + "confirmRead_other": "Remover {{count}} notificações lidas? As não lidas continuam.", + "onlyRead": "Só as {{count}} lidas", + "showing": "Mostrando {{shown}} de {{total}}", + "showMore": "Mostrar mais", + "title": "Notificações", + "buttonUnread_one": "Notificações, {{count}} não lida", + "buttonUnread_other": "Notificações, {{count}} não lidas", + "filterLabel": "Mostrar notificações", + "filterAll": "Todas", + "filterUnread": "Não lidas", + "filterFailed": "Com falha", + "more": "Mais ações", + "close": "Fechar", + "day": { + "today": "Hoje", + "yesterday": "Ontem", + "earlier": "Antes" + }, + "emptyTitle": "Nenhuma notificação ainda", + "emptyBody": "Os resultados dos seus gatilhos e conectores aparecem aqui, inclusive do trabalho que terminou enquanto você estava fora.", + "caughtUp": "Você está em dia.", + "caughtUpTitle": "Você está em dia", + "caughtUpBody": "Não há notificações não lidas.", + "noFailedTitle": "Nada falhou", + "noFailedBody": "Execuções e conectores que falham aparecem aqui.", + "loadError": "Não foi possível carregar suas notificações.", + "source": { + "trigger": "Gatilho", + "connector": "Conector", + "system": "LocalAI" + }, + "itemUnread": "{{title}}, não lida", + "arrived": "Nova notificação: {{title}}", + "arrivedMany_one": "{{count}} nova notificação", + "arrivedMany_other": "{{count}} novas notificações" + }, + "change": { + "loading": "Lendo a alteração do gatilho...", + "unread": "Não foi possível ler a alteração do gatilho: {{message}}", + "removed": "Esta alteração não está mais guardada.", + "none": "nenhum", + "on": "Ligado", + "off": "Desligado (pausado)", + "becomes": "passa a", + "neverRan": "Ainda não foi executado", + "lastRun": "Última execução", + "lastRunValue": "{{status}}, {{when}}", + "runStatus": { + "queued": "Na fila", + "running": "Em execução", + "done": "Concluída", + "failed": "Falhou", + "skipped": "Pulada" + }, + "updateTitle": "Alterar {{name}}", + "deleteTitle": "Excluir {{name}}?", + "updateHelp": "Alteração proposta. Nada muda até você aplicá-la.", + "deleteHelp": "Nada é excluído até você clicar em Excluir. Você pode desfazer por um dia.", + "stale": "O gatilho foi alterado ou removido depois da proposta, então a alteração não pode ser aplicada. Peça a alteração de novo.", + "apply": "Aplicar", + "delete": "Excluir", + "keep": "Manter", + "field": { + "name": "Nome", + "prompt": "Prompt", + "when": "Quando", + "time_zone": "Fuso horário", + "daily_cap": "Limite diário", + "enabled": "Estado", + "reads": "Lê", + "writes": "Escreve", + "widget": "Widget da Início", + "estimated_tokens": "Tokens por execução", + "agent": "Agente", + "model": "Modelo" + }, + "receipt": { + "update": "Alterado:", + "pause": "Pausado:", + "resume": "Retomado:", + "delete": "Excluído:", + "fields": "O que mudou: {{fields}}", + "dismissed": "Alteração descartada:", + "kept": "Mantido:", + "undone": "Desfeito:", + "restored": "Restaurado:" + }, + "blocked": { + "expired": "O prazo para desfazer acabou; altere o gatilho na página Gatilhos.", + "edited": "O gatilho foi alterado desde então; altere-o na página Gatilhos.", + "removed": "O gatilho foi removido desde então.", + "full": "Você já tem o máximo de gatilhos; remova um na página Gatilhos primeiro." + }, + "announce": { + "applied": "{{name}} alterado.", + "deleted": "{{name}} excluído.", + "dismissed": "Alteração de {{name}} descartada.", + "kept": "{{name}} mantido.", + "undone": "Alteração de {{name}} desfeita.", + "restored": "{{name}} restaurado." + } + }, + "loops": { + "region": "Loops deste chat", + "unit": { + "seconds_one": "{{count}} segundo", + "seconds_other": "{{count}} segundos", + "minutes_one": "{{count}} minuto", + "minutes_other": "{{count}} minutos", + "hours_one": "{{count}} hora", + "hours_other": "{{count}} horas" + }, + "unitShort": { + "seconds_one": "{{count}} s", + "seconds_other": "{{count}} s", + "minutes_one": "{{count}} min", + "minutes_other": "{{count}} min", + "hours_one": "{{count}} h", + "hours_other": "{{count}} h" + }, + "confirm": { + "every": "Executar isto a cada {{every}} até você parar?", + "selfPaced": "Executar isto de novo sempre que o modelo pedir, até você parar?", + "start": "Iniciar", + "cancel": "Cancelar" + }, + "chip": { + "name": "Loop", + "every": "a cada {{every}}", + "selfPaced": "no ritmo do modelo", + "run_one": "execução {{count}}", + "run_other": "execução {{count}}", + "paused": "pausado", + "running": "em andamento", + "next": "próxima às {{time}}", + "ended": "encerrado", + "wait": "Aguardando: {{wait}}" + }, + "actions": { + "pause": "Pausar", + "resume": "Retomar", + "stop": "Parar", + "dismiss": "Dispensar" + }, + "state": { + "pending": "aguarda sua confirmação", + "active": "ativo", + "paused": "pausado", + "ended": "encerrado" + }, + "list": { + "title": "Loops deste chat", + "close": "Fechar", + "empty": "Este chat não tem loops.", + "hint": "Para parar um, digite /loop stop e o código do início da linha dele." + }, + "slash": { + "usage": "Digite /loop, um intervalo se quiser e o que repetir. Por exemplo: /loop 5m verifique o build.", + "notFound": "Nenhum loop deste chat corresponde a {{id}}.", + "noChat": "Envie uma primeira mensagem e depois inicie um loop neste chat.", + "stopped_one": "{{count}} loop parado.", + "stopped_other": "{{count}} loops parados." + } + } +} diff --git a/core/http/react-ui/public/locales/pt-BR/collections.json b/core/http/react-ui/public/locales/pt-BR/collections.json index c492b69c7..ef4d0a779 100644 --- a/core/http/react-ui/public/locales/pt-BR/collections.json +++ b/core/http/react-ui/public/locales/pt-BR/collections.json @@ -4,13 +4,13 @@ "newPlaceholder": "Nome da nova coleção...", "actions": { "create": "Criar", - "creating": "Criando...", "details": "Detalhes", "reset": "Redefinir", "delete": "Excluir", "viewDetails": "Ver detalhes", "resetCollection": "Redefinir coleção", - "deleteCollection": "Excluir coleção" + "deleteCollection": "Excluir coleção", + "cancel": "Cancelar" }, "sections": { "yourCollections": "Suas Coleções", @@ -19,7 +19,8 @@ "empty": { "title": "Nenhuma coleção ainda", "text": "As coleções permitem organizar documentos em bases de conhecimento que os agentes podem pesquisar usando RAG (Retrieval-Augmented Generation). Crie uma coleção acima para começar.", - "noPersonal": "Você ainda não tem coleções." + "noPersonal": "Você ainda não tem coleções.", + "learn": "Como a memória funciona" }, "deleteDialog": { "title": "Excluir Coleção", @@ -32,12 +33,29 @@ "confirm": "Redefinir" }, "toasts": { - "loadFailed": "Falha ao carregar coleções: {{message}}", "created": "Coleção \"{{name}}\" criada", "createFailed": "Falha ao criar a coleção: {{message}}", "deleted": "Coleção \"{{name}}\" excluída", "deleteFailed": "Falha ao excluir a coleção: {{message}}", "reset": "Coleção \"{{name}}\" redefinida", "resetFailed": "Falha ao redefinir a coleção: {{message}}" + }, + "loadFailed": "Não foi possível carregar suas coleções.", + "sources": { + "loadFailed": "Não foi possível carregar as fontes web." + }, + "entries": { + "loadFailed": "Não foi possível carregar os arquivos." + }, + "starting": { + "title": "A memória está iniciando.", + "text": "O servidor ainda está preparando o serviço de memória. Esta página tenta de novo sozinha." + }, + "embedding": { + "missingTitle": "Nenhum modelo de embeddings instalado.", + "missingText": "As coleções usam {{model}} para indexar os arquivos e pesquisar neles. Até que ele seja instalado, adicionar arquivos e pesquisar falham.", + "noneSet": "Nenhum modelo de embeddings está definido para as coleções (collections_embedding_model), então adicionar arquivos e pesquisar falham.", + "install": "Encontrar em Modelos", + "askAdmin": "Peça a um administrador para instalá-lo." } } diff --git a/core/http/react-ui/public/locales/pt-BR/common.json b/core/http/react-ui/public/locales/pt-BR/common.json index ed3f55833..c79c35e72 100644 --- a/core/http/react-ui/public/locales/pt-BR/common.json +++ b/core/http/react-ui/public/locales/pt-BR/common.json @@ -112,26 +112,22 @@ "gigabytes": "GB", "terabytes": "TB" }, - "updateReview": { - "title": "Revisar atualizações", - "description": "Nada é atualizado até você escolher aqui.", - "close": "Fechar", - "done": "Concluído", - "selectAll": "Selecionar tudo", - "select": "Selecionar {{name}}", - "unknownVersion": "desconhecida", - "latest": "mais recente", - "blocked": "Não pode ser atualizado aqui:", - "upgradeSelected": "Atualizar selecionados ({{count}})", - "upgradeAll": "Atualizar todos ({{count}})", - "running": "Atualizando...", - "queued": "Aguardando", - "statusRunning": "Atualizando", - "statusDone": "Atualizado", - "statusFailed": "Falhou", - "summary": "{{succeeded}} atualizados, {{failed}} com falha.", - "empty": "Nada para atualizar.", - "statusStarted": "Iniciado", - "summaryStarted": "{{succeeded}} iniciados, {{failed}} com falha." + "ui": { + "tryAgain": "Tentar novamente", + "undo": "Desfazer", + "loadError": { + "title": "Não foi possível carregar.", + "serverSaid": "O servidor respondeu “{{message}}”.", + "nothingChanged": "Nada foi alterado." + } + }, + "chip": { + "running": "Em execução", + "starting": "Iniciando", + "stopped": "Parado", + "failed": "Falhou", + "updateAvailable": "Atualização disponível", + "ready": "Pronto", + "noRunsYet": "Nenhuma execução" } } diff --git a/core/http/react-ui/public/locales/pt-BR/explorer.json b/core/http/react-ui/public/locales/pt-BR/explorer.json new file mode 100644 index 000000000..b768c39c3 --- /dev/null +++ b/core/http/react-ui/public/locales/pt-BR/explorer.json @@ -0,0 +1,57 @@ +{ + "brand": "Explorer de {{name}}", + "openApp": "Abrir LocalAI", + "close": "Fechar", + "copy": "Copiar", + "copied": "Copiado", + "loading": "Carregando swarms", + "title": "Swarms públicos", + "lede": "Um swarm é um grupo de máquinas que compartilham a inferência de modelos por uma rede peer-to-peer. Esta lista mostra os swarms cujos donos escolheram torná-los públicos. Qualquer pessoa com o token de um swarm pode entrar nele.", + "count_one": "{{count}} swarm online", + "count_other": "{{count}} swarms online", + "sorted": "Mais workers primeiro", + "workers_one": "worker", + "workers_other": "workers", + "tokenTitle": "O início e o fim do token. Copie-o em Como entrar.", + "warning": "O Explorer é uma lista da comunidade de tokens de swarm. Qualquer pessoa pode usar um token para enviar trabalho a um swarm ou compartilhar recursos com ele. Use por sua conta e risco.", + "listed": "O swarm está na lista.", + "empty": { + "title": "Nenhum swarm tem workers online", + "text": "Só são listados swarms com pelo menos um worker online. Liste o seu para torná-lo público." + }, + "off": { + "title": "Este servidor não é um explorer", + "text": "A página do Explorer precisa de um servidor LocalAI iniciado no modo explorer. Este não tem uma lista de swarms para mostrar." + }, + "error": { + "title": "Não foi possível carregar os swarms", + "text": "O servidor do explorer não respondeu. Verifique se ele está em execução e tente novamente.", + "retry": "Tentar novamente" + }, + "join": { + "open": "Como entrar", + "title": "Entrar em {{name}}", + "text": "Inicie um worker do LocalAI com o token deste swarm.", + "token": "Token", + "federated": "Cluster federado {{id}}. Inicie um nó nele com qualquer um dos comandos.", + "docker": "Com Docker", + "cli": "Com a linha de comando", + "noCommands": "Os workers deste swarm compartilham computação pelo token acima. O token é o que um nó precisa para entrar.", + "docs": "Leia como os swarms funcionam" + }, + "list": { + "open": "Listar um swarm", + "title": "Listar um swarm", + "text": "Adicione um swarm à lista pública. Ele aparece quando tiver um worker online.", + "name": "Nome", + "description": "Descrição", + "token": "Token", + "tokenHint": "O token de rede do swarm, como o servidor o exibiu.", + "public": "A listagem publica o token. Qualquer pessoa que veja a lista pode usar os workers do seu swarm.", + "understood": "Entendo que o token se torna público.", + "cancel": "Cancelar", + "add": "Listar este swarm", + "adding": "Listando...", + "failed": "Não foi possível listar o swarm." + } +} diff --git a/core/http/react-ui/public/locales/pt-BR/home.json b/core/http/react-ui/public/locales/pt-BR/home.json index cbcdb42c7..19a21a43d 100644 --- a/core/http/react-ui/public/locales/pt-BR/home.json +++ b/core/http/react-ui/public/locales/pt-BR/home.json @@ -25,7 +25,10 @@ "title": "Gerencie o LocalAI conversando", "description": "Instale modelos, alterne backends, edite configurações e verifique o status conversando com o LocalAI.", "open": "Abrir assistente", - "tooltip": "Gerencie o LocalAI conversando" + "tooltip": "Gerencie o LocalAI conversando", + "noModel": "Escolha primeiro um modelo de chat.", + "startFailed": "Não foi possível iniciar o chat do assistente: {{message}}", + "chatTitle": "Assistente do LocalAI" }, "input": { "placeholder": "Mensagem...", @@ -79,13 +82,17 @@ "step2Title": "Instale um Modelo", "step2Body": "Clique em instalar para baixar e configurá-lo automaticamente.", "step3Title": "Comece a Conversar", - "step3Body": "Converse com seu modelo direto do navegador ou use a API." + "step3Body": "Converse com seu modelo direto do navegador ou use a API.", + "sandboxTitle": "Execute código com segurança (opcional)", + "sandboxBody": "Instale o complemento Sandbox para que o modelo execute código e comandos isolados. Sem ele, os comandos rodam direto nesta máquina, com acesso total à rede e nenhum isolamento." }, "browseGallery": "Explorar Galeria de Modelos", "importModel": "Importar Modelo", "docs": "Documentação", "noModelsTitle": "Nenhum Modelo Disponível", - "noModelsBody": "Ainda não há modelos instalados. Peça ao administrador para configurar modelos para você poder começar a conversar." + "noModelsBody": "Ainda não há modelos instalados. Peça ao administrador para configurar modelos para você poder começar a conversar.", + "sandboxInstall": "Instalar Sandbox", + "sandboxSkip": "Pular" }, "starters": { "title": "Recomendados para o seu hardware", @@ -120,7 +127,23 @@ "modelsSummary": "Explore, instale e gerencie modelos", "create": "Criar", "createSummary": "Abra uma sessão de chat, imagem ou voz", - "operate": "Operar", - "operateSummary": "{{models}} modelos configurados · nós, atividade e rastreamentos" + "operate": "Administração", + "operateSummary": "{{models}} modelos configurados · nós, atividade e rastreamentos", + "allChats": "Todos os chats" + }, + "slash": { + "agents": { + "label": "Agentes", + "desc": "Seus agentes salvos, gatilhos e habilidades" + }, + "mcp": { + "label": "Conectores MCP", + "desc": "Servidores MCP que seus chats podem usar" + }, + "memory": { + "label": "Memória", + "desc": "Coleções em que seus chats podem pesquisar" + }, + "complete": "completar" } } diff --git a/core/http/react-ui/public/locales/pt-BR/library.json b/core/http/react-ui/public/locales/pt-BR/library.json new file mode 100644 index 000000000..afc8ec4dc --- /dev/null +++ b/core/http/react-ui/public/locales/pt-BR/library.json @@ -0,0 +1,114 @@ +{ + "usedBy": { + "label": "Usado por", + "none": "Ainda não usado", + "line": "Usado por {{name}}", + "lineMore": "Usado por {{name}} +{{count}}", + "all": "todas as skills", + "allTitle": "Este agente carrega todas as skills", + "remove": "Remover de {{agent}}", + "open": "Abrir {{agent}}", + "unread": "Não foi possível ler o uso agora.", + "unreadShort": "Uso desconhecido" + }, + "notUsed": { + "lead": "Ainda não usado.", + "skill": "Ela continua na biblioteca até você adicioná-la a algum lugar. Experimente primeiro ou adicione-a a um agente.", + "collection": "Ela continua na biblioteca até você adicioná-la a algum lugar. Pesquise-a aqui ou adicione-a a um agente.", + "skillShort": "Ela continua na lista até você adicioná-la a um agente." + }, + "tryInContext": "Experimente em um contexto", + "add": { + "button": "Adicionar a...", + "title": "Adicionar {{name}} a", + "agents": "Agentes", + "noAgents": "Ainda não há agentes. Crie um agente e depois adicione-o aqui.", + "createAgent": "Criar um agente", + "added": "Adicionado", + "action": "Adicionar", + "addToAgent": "Adicionar a {{agent}}", + "working": "Adicionando...", + "estimate": "As estimativas usam caracteres divididos por quatro.", + "toolsMode": "Carregado quando o modelo pedir", + "tokens": "+{{count}} tokens por mensagem (estimativa)", + "collectionNote": "Um agente pode pesquisar as coleções da sua lista.", + "kbOn": "Ativa a base de conhecimento dele", + "noAgentNamed": "Nenhum agente se chama {{name}}. Crie um agente com esse nome para usar esta coleção.", + "close": "Fechar", + "skillNote": "Um agente pode ler as skills da sua lista." + }, + "toasts": { + "skillAdded": "{{name}} adicionado a {{agent}}", + "skillRemoved": "{{name}} removido de {{agent}}", + "skillsOff": "{{name}} removido de {{agent}}. As skills agora estão desativadas para ele.", + "collectionAdded": "{{name}} adicionado a {{agent}}", + "collectionRemoved": "{{name}} removido de {{agent}}", + "undone": "Alteração desfeita", + "updateFailed": "Não foi possível atualizar {{agent}}: {{message}}", + "undo": "Desfazer", + "dismiss": "Dispensar" + }, + "simulate": { + "button": "Simular uma mensagem", + "title": "Simular uma mensagem", + "desc": "Escolha um contexto e veja o que ele carregaria. Nada é enviado a um modelo.", + "close": "Fechar", + "context": "Contexto", + "contextCollection": "Uma coleção sozinha", + "contextAgent": "Agente: {{name}}", + "collectionOn": "Coleção", + "noCollections": "Ainda não há coleções.", + "contextHintCollection": "Pesquisa a coleção com a sua mensagem e mostra as passagens retornadas, com suas pontuações. Nenhum agente ou modelo está envolvido.", + "contextHintAgent": "Mostra o que este agente mantém no prompt, além das passagens que a base de conhecimento dele acrescentaria a esta mensagem.", + "message": "Mensagem", + "messagePlaceholder": "Digite o que uma pessoa poderia perguntar", + "passages": "Passagens", + "run": "Executar", + "running": "Executando...", + "onlyForTest": "Apenas para este teste", + "removeExtra": "Tirar {{name}} deste teste", + "tryAlso": "Experimente algo ainda não adicionado", + "tryAlsoPick": "Escolha uma skill ou uma coleção", + "extraSkill": "Skill {{name}}", + "extraCollection": "Coleção {{name}}", + "budgetTitle": "O que este contexto acrescenta", + "budgetTotal": "Cerca de {{count}} tokens além da sua mensagem", + "budgetMessage": "Sua mensagem: {{count}} tokens", + "budgetNote": "Estimativas: caracteres divididos por quatro. A janela do modelo não é conhecida aqui, então não há parcela de um limite.", + "instructions": "Instruções", + "skills": "Skills", + "memory": "Memória", + "skillsTitle": "Skills que este contexto carrega", + "skillsNone": "Nenhuma skill está ativa para este agente.", + "skillsPrompt": "No prompt de cada mensagem.", + "skillsTools": "Lida somente quando o modelo pedir uma.", + "skillsBoth": "No prompt, e lida de novo quando o modelo pedir.", + "skillsAll": "Todas as skills estão ativas para este agente.", + "skillMissing": "{{name}} está na lista, mas nenhuma skill tem esse nome.", + "skillTokens": "{{count}} tokens", + "skillTokensTools": "0 no início", + "memoryTitle": "Memória que seria enviada", + "memoryNone": "Nenhuma coleção está na lista deste agente. Adicione uma ao agente para usá-la.", + "memoryNoCollection": "A base de conhecimento deste agente está ativa, mas ainda não existe uma coleção chamada {{name}}.", + "memoryTools": "Este agente pesquisa somente quando o modelo pedir. Nada é acrescentado à mensagem no início.", + "memoryTesting": "Coleção {{name}}, apenas para este teste", + "memoryFrom": "De {{name}}", + "passagesCount": "{{count}} passagens, ordenadas por similaridade", + "noPassages": "Nada foi retornado para esta mensagem.", + "pickMessage": "Digite uma mensagem primeiro.", + "searchFailed": "A pesquisa falhou: {{message}}", + "score": "Pontuação {{score}}", + "tokens": "{{count}} tokens", + "noAgents": "Ainda não há agentes. Você ainda pode pesquisar uma coleção sozinha.", + "backendNote": "Uma simulação de modelo não está disponível aqui, então nenhuma resposta é simulada." + }, + "search": { + "failed": "A pesquisa falhou: {{message}}" + }, + "agentBudget": "As skills selecionadas acrescentam cerca de {{count}} tokens a cada mensagem (estimativa: caracteres divididos por quatro).", + "agentKb": { + "reads": "Este agente lê a coleção chamada {{name}}.", + "exists": "Este agente lê a coleção chamada {{name}}. Você pode preenchê-la e pesquisá-la em Memória.", + "created": "Este agente lê a coleção chamada {{name}}. Ela é criada quando você salvar." + } +} diff --git a/core/http/react-ui/public/locales/pt-BR/media.json b/core/http/react-ui/public/locales/pt-BR/media.json index 8d9e11055..cda786660 100644 --- a/core/http/react-ui/public/locales/pt-BR/media.json +++ b/core/http/react-ui/public/locales/pt-BR/media.json @@ -246,7 +246,7 @@ "api": { "title": "Uso da API e modelos compatíveis", "summary": "Use esta voz fora do navegador ou verifique o suporte do modelo", - "body": "Envie a URI de voz estável do perfil com cada requisição de fala. O LocalAI resolve o áudio de referência privado e a transcrição no momento da geração; o YAML do modelo não é modificado.", + "body": "Envie a URI de voz estável do perfil com cada requisição de fala. O LocalAI resolve o áudio de referência privado e a transcrição no momento da geração; as configurações dos seus modelos não são alteradas.", "compatibleModels": "Modelos compatíveis instalados", "noInstalledModels": "Nenhum ainda. Instale um dos modelos verificados pelo servidor acima antes de enviar esta requisição.", "curlExample": "Exemplo com cURL", @@ -265,7 +265,7 @@ }, "deleteDialog": { "title": "Excluir esta voz?", - "message": "“{{name}}” e seu áudio de referência serão removidos permanentemente. O YAML dos modelos existentes não é alterado.", + "message": "“{{name}}” e seu áudio de referência serão removidos permanentemente. Os modelos configurados com esta voz não são alterados.", "deleting": "Excluindo…" }, "toasts": { diff --git a/core/http/react-ui/public/locales/pt-BR/modelEditor.json b/core/http/react-ui/public/locales/pt-BR/modelEditor.json index ebb42ddb7..57a9aaf64 100644 --- a/core/http/react-ui/public/locales/pt-BR/modelEditor.json +++ b/core/http/react-ui/public/locales/pt-BR/modelEditor.json @@ -34,5 +34,9 @@ "title": "Nenhum campo configurado", "text": "Use a barra de busca acima para encontrar e adicionar campos de configuração." } + }, + "proxyVision": { + "label": "Este modelo consegue ver imagens", + "hint": "Ative se o modelo upstream aceita imagens. O LocalAI não consegue verificar isso. As imagens anexadas vão para o modelo junto com a mensagem." } } diff --git a/core/http/react-ui/public/locales/pt-BR/models.json b/core/http/react-ui/public/locales/pt-BR/models.json index 3f0e5c97a..c6baaa5a1 100644 --- a/core/http/react-ui/public/locales/pt-BR/models.json +++ b/core/http/react-ui/public/locales/pt-BR/models.json @@ -1,5 +1,6 @@ { "lifecycle": { + "supporting": "Encontre um modelo que caiba nesta máquina, instale-o e cuide dos que você já tem.", "title": "Models", "navLabel": "Model lifecycle", "views": { "explore": "Explore", "installed": "Installed" }, @@ -82,6 +83,10 @@ "delete": "Excluir" }, "filters": { + "sheetDone": "Mostrar modelos", + "sheetTitle": "Filtros", + "sheetButtonCount": "Filtros ({{count}})", + "sheetButton": "Filtros", "all": "Todos", "llm": "Chat", "image": "Imagem", diff --git a/core/http/react-ui/public/locales/pt-BR/nav.json b/core/http/react-ui/public/locales/pt-BR/nav.json index 2bfa43be9..ddb6c3ba7 100644 --- a/core/http/react-ui/public/locales/pt-BR/nav.json +++ b/core/http/react-ui/public/locales/pt-BR/nav.json @@ -10,12 +10,13 @@ "changeLanguage": "Alterar idioma", "logout": "Sair", "accountSettings": "Configurações da conta", + "yourSettings": "Suas configurações", "account": "Conta", "accountFor": "Conta: {{name}}", "sections": { "recognition": "Reconhecimento", "build": "Construir", - "operate": "Operar", + "operate": "Administração", "workspace": "Espaço de trabalho" }, "operate": { @@ -66,7 +67,14 @@ "logs": "Logs", "trafficModels": "Models", "gpuHost": "GPU and host", - "prometheus": "Prometheus" + "prometheus": "Prometheus", + "chats": "Chats", + "files": "Arquivos", + "triggers": "Gatilhos", + "mcpConnectors": "Conectores MCP", + "chatsAdmin": "Chats de todos os usuários", + "pages": "Páginas", + "allChats": "Todos os chats" }, "footer": { "github": "GitHub", @@ -99,7 +107,15 @@ "import": "Adicione um modelo de um arquivo, uma URL ou um repositório do Hugging Face.", "voices": "Identifique falantes e mantenha uma biblioteca de perfis de voz.", "faces": "Detecte, compare e associe rostos.", - "addons": "Instale complementos e abra os apps que eles oferecem." - } - } + "addons": "Instale complementos e abra os apps que eles oferecem.", + "triggers": "Inicie um chat sozinho, em um horário agendado ou quando um arquivo chegar." + }, + "operateSupporting": "O que roda neste servidor, como ele é usado e como está configurado." + }, + "pinnedPages": "Páginas fixadas", + "new": "Novo", + "recentChats": "Chats recentes", + "newChat": "Novo chat", + "untitledChat": "Chat sem título", + "chatRunning": "Em andamento" } diff --git a/core/http/react-ui/public/locales/pt-BR/operate.json b/core/http/react-ui/public/locales/pt-BR/operate.json new file mode 100644 index 000000000..c850eb208 --- /dev/null +++ b/core/http/react-ui/public/locales/pt-BR/operate.json @@ -0,0 +1,57 @@ +{ + "backends": { + "filterLabel": "Filtrar backends", + "check": "Verificar atualizações", + "reinstallBackend": "Reinstalar backend", + "deleteBackend": "Excluir backend", + "updateAllTitle": "Atualizar todos os backends", + "updateAllIntro": "Escolha quais backends atualizar. Cada um é atualizado separadamente, um após o outro. Uma falha não interrompe os demais.", + "updateSelected": "Atualizar selecionados", + "loadFailedTitle": "Não foi possível carregar o catálogo de backends.", + "filtersButton": "Filtros", + "filtersDone": "Concluir", + "filterGroups": { + "state": "Estado", + "type": "Tipo", + "show": "Mostrar", + "library": "Biblioteca" + }, + "showVariants": "Variantes", + "showDevelopment": "Builds de desenvolvimento", + "allLibraries": "Todas as bibliotecas", + "moreActions": "Mais ações de backends", + "installFromUrl": "Instalar a partir de URL…", + "manageLibraries": "Gerenciar bibliotecas…", + "count_one": "{{count}} backend", + "count_other": "{{count}} backends", + "countOf": "{{shown}} de {{total}} backends", + "activeFilters": "Filtros ativos", + "removeFilter": "Remover filtro: {{name}}", + "checking": "Procurando atualizações…", + "updatesAvailable_one": "{{count}} atualização disponível", + "updatesAvailable_other": "{{count}} atualizações disponíveis", + "reviewUpdates": "Revisar", + "close": "Fechar", + "installedLoadFailedTitle": "Não foi possível carregar os backends instalados." + }, + "capacity": { + "diskChip": "Disco quase cheio" + }, + "activity": { + "kind": { + "addon": "complemento" + }, + "verb": { + "upgrading": "Atualizando {{kind}}", + "failedUpgrade": "Não foi possível atualizar {{kind}}" + }, + "filter": { + "addons": "Complementos" + }, + "upgradeVersions": "{{from}} para {{to}}", + "rowUpgraded": "Atualizado em {{duration}}", + "rowUpgradedPlain": "Atualizado", + "viewInAddons": "Ver em Complementos", + "upgradeWaiting": "Aguardando o serviço de complementos iniciar. Próxima verificação em {{seconds}} s." + } +} diff --git a/core/http/react-ui/public/locales/pt-BR/pages.json b/core/http/react-ui/public/locales/pt-BR/pages.json new file mode 100644 index 000000000..513c0daf0 --- /dev/null +++ b/core/http/react-ui/public/locales/pt-BR/pages.json @@ -0,0 +1,138 @@ +{ + "subtitle": "Pequenas ferramentas que o assistente fez para você. Elas abrem aqui, em um sandbox, e guardam os dados nos seus arquivos.", + "card": { + "pin": "Fixar na barra lateral", + "unpin": "Desafixar da barra lateral", + "changedBy": { + "you": "Alterada {{when}} por você", + "assistant": "Alterada {{when}} pelo assistente" + }, + "menu": "Mais para {{title}}", + "files": "Mostrar os arquivos", + "rename": "Renomear", + "renameLabel": "Novo nome da pasta", + "renameHint": "Letras minúsculas, dígitos e hifens.", + "renameSave": "Salvar", + "renameCancel": "Cancelar", + "renameFailed": "A página não foi renomeada: {{message}}", + "delete": "Excluir página", + "deleteTitle": "Excluir «{{title}}»?", + "deleteMessage": "A pasta {{folder}} vai para a Lixeira. Você pode restaurá-la em Arquivos.", + "deleted": "{{title}} está na Lixeira.", + "deleteFailed": "A página não foi excluída: {{message}}" + }, + "empty": { + "text": "Uma página é uma pequena ferramenta, como um quadro de tarefas ou uma lista de leitura. Peça uma ao assistente em um chat ou comece com uma página pronta.", + "starting": "Copiando a página", + "starterFailed": "A página não foi copiada: {{message}}", + "ask": "Pedir uma página", + "askPrompt": "Crie uma página que ", + "starterReading": "Começar com uma lista de leitura", + "note": "Esta entrada sai da barra lateral quando você não tem páginas. Ela volta com a próxima.", + "learn": "Como as páginas funcionam" + }, + "askNew": "Pedir uma nova página", + "yours": "Suas", + "filter": "Filtrar páginas", + "shared": { + "yours": "Suas", + "group": "Compartilhadas com você", + "note": "Uma página compartilhada só roda depois que você a permite. Se o autor a mudar, você será perguntado de novo.", + "offNotice": "As páginas compartilhadas estão desligadas neste servidor. Um administrador as desligou.", + "off": "As páginas compartilhadas estão desligadas neste servidor. Um administrador as desligou.", + "gone": "Esta página não está mais na pasta compartilhada, ou você não tem mais acesso a ela.", + "review": "Revisar", + "from": "De {{author}}", + "menu": "Menu da página", + "badge": { + "notAllowed": "Ainda não permitida", + "changed": "Mudou desde que você a permitiu", + "allowed": "Permitida" + } + }, + "viewer": { + "stopAllowing": "Deixar de permitir esta página", + "authorChanged": "{{author}} mudou esta página. A versão que você permitiu continua rodando até você revisar a mudança.", + "sandboxShared": "Isolada. Roda como você, apenas em {{folder}}, e não acessa a rede." + }, + "trust": { + "title": "Abrir \"{{title}}\"?", + "desc": "Esta página vem de uma pasta compartilhada. As páginas rodam como você, então confira antes de rodar.", + "who": "Escrita por {{author}}", + "whereLabel": "Pasta compartilhada", + "whereWhen": ", alterada {{when}}", + "someone": "alguém", + "close": "Fechar", + "canTitle": "Ela pode", + "canStillTitle": "Ela ainda pode", + "cannotTitle": "Ela não pode", + "items": { + "files": "Ler e gravar arquivos na própria pasta", + "readOnly": "Você só pode ler esta pasta compartilhada, então a página não consegue salvar as mudanças.", + "chat": "Escrever uma mensagem em um chat", + "chatNote": "Você vê a mensagem no chat antes de o assistente responder.", + "noFiles": "Ler seus outros arquivos ou chats", + "noNet": "Acessar a rede ou outros sites", + "noNetNote": "Um limite: ela pode carregar outro endereço no próprio quadro. Isso envia uma requisição para fora, e então a página para.", + "noTools": "Usar ferramentas ou mudar suas configurações" + }, + "warn": "Ela age como você dentro da própria pasta. Se {{author}} a mudar, você será perguntado de novo.", + "viewSource": "Ver o código", + "cancel": "Cancelar", + "allowOnce": "Permitir uma vez", + "allow": "Permitir", + "changed": { + "title": "\"{{title}}\" mudou", + "desc": "Você permitiu uma versão anterior. Confira o que mudou antes de rodar de novo.", + "who": "Alterada por {{author}} {{when}}", + "allowed": "Você permitiu a versão de {{date}}", + "what": "O que mudou", + "same": "O que ela pode fazer continua igual.", + "warn": "Se você permitir, esta versão roda sempre que você a abrir, até {{author}} mudá-la de novo.", + "view": "Ver as mudanças", + "lines": "{{before}} → {{after}} linhas", + "code": "Um script, folha de estilo ou módulo da página mudou: {{files}}" + }, + "pill": { + "notYet": "Ainda não permitida", + "not": "Não permitida", + "once": "Permitida nesta visita", + "allowed": "Permitida", + "changed": "Mudou desde que você a permitiu" + }, + "from": "De {{author}}", + "frame": { + "chrome": "Página compartilhada de {{folder}}. Ela não rodou." + }, + "blocked": { + "title": "Esta página não rodou", + "body": "Você escolheu não rodá-la. Ela vem da pasta compartilhada {{folder}} de {{author}}. Nada nela rodou e nada foi salvo.", + "review": "Revisar e permitir" + }, + "waiting": { + "title": "Aguardando você", + "body": "Nada nesta página roda até você permitir." + }, + "stopped": { + "revoked": "Você deixou de permitir esta página. Ela parou e nada nela roda até você permitir de novo.", + "refused": "Esta página não é mais permitida, então foi parada. Revise para permitir de novo.", + "off": "As páginas compartilhadas foram desligadas neste servidor, então esta página foi parada." + }, + "changedMeanwhile": "A página mudou enquanto você a revisava. Esta é a nova versão.", + "sourceTitle": "Código de \"{{title}}\"", + "sourceLines_one": "{{count}} linha", + "sourceLines_other": "{{count}} linhas", + "sourceNote": "O código é mostrado como texto. Nada dele roda aqui." + }, + "frame": { + "filesFailed": "Arquivos ao lado desta página não carregaram: {{files}}. O console do navegador mostra o motivo.", + "tooLarge": "Esta página inclui mais de {{limit}} de arquivos da pasta, então alguns ficaram de fora. Carregue os dados com fetch().", + "dismiss": "Fechar" + }, + "link": { + "label": "A página quer abrir um link", + "open": "Abrir {{site}}", + "cancel": "Cancelar", + "lookalike": "Este endereço usa letras de outros alfabetos e é lido como {{display}}. Ele pode imitar outro site: confira antes de abrir." + } +} diff --git a/core/http/react-ui/public/locales/pt-BR/skills.json b/core/http/react-ui/public/locales/pt-BR/skills.json index 4cb2a6f99..0efe54713 100644 --- a/core/http/react-ui/public/locales/pt-BR/skills.json +++ b/core/http/react-ui/public/locales/pt-BR/skills.json @@ -1,79 +1,83 @@ { "title": "Habilidades", - "subtitle": "Gerenciar habilidades de agentes (instruções e recursos reutilizáveis)", + "subtitle": "Instruções que todos os seus chats podem usar quando uma tarefa corresponde. Os agentes usam as que você adiciona a eles.", "unavailable": { - "subtitle": "O serviço de habilidades não está disponível ou o índice está sendo reconstruído. Tente novamente em um momento.", - "retry": "Tentar novamente" + "subtitle": "As habilidades precisam de Arquivos, e Arquivos está desativado neste servidor." }, "actions": { "newSkill": "Nova habilidade", "createSkill": "Criar habilidade", - "import": "Importar", - "importing": "Importando...", - "gitRepos": "Repositórios Git", "edit": "Editar", - "delete": "Excluir", - "export": "Exportar", - "sync": "Sincronizar", - "addRepo": "Adicionar repositório", - "adding": "Adicionando...", - "remove": "Remover", - "enable": "Habilitar", - "disable": "Desabilitar" + "delete": "Excluir" }, "search": { "placeholder": "Buscar habilidades..." }, - "git": { - "title": "Repositórios Git", - "description": "Adicione repositórios Git para obter habilidades. As habilidades aparecerão na lista após a sincronização.", - "urlPlaceholder": "https://github.com/usuário/repo ou git@github.com:usuário/repo.git", - "noRepos": "Nenhum repositório Git configurado. Adicione um acima.", - "disabled": "Desabilitado", - "removeRepo": "Remover repositório" - }, "card": { "noDescription": "Sem descrição", "readOnly": "Somente leitura", "editTitle": "Editar habilidade", "deleteTitle": "Excluir habilidade", - "exportTitle": "Exportar como .tar.gz" + "invalid": "Skipped: not a valid skill", + "invalidWhy": "Esta habilidade foi ignorada: {{reason}}", + "shadowed": "Hidden by a {{layer}} skill", + "shadowedLong": "Hidden by a {{layer}} skill of the same name", + "providedBy": "Fornecida por {{name}}", + "replacedBy": "Substituída por {{name}}", + "notActive": "Inativa: a habilidade integrada é usada", + "notActiveBy": "Inativa: {{name}} a fornece", + "fallback": "Em uso porque a habilidade do complemento {{name}} está desligada ou não está em execução" }, "empty": { "title": "Nenhuma habilidade encontrada", "text": "Crie uma habilidade ou importe uma para começar.", - "noPersonal": "Você ainda não tem habilidades." - }, - "sections": { - "yourSkills": "Suas Habilidades", - "otherUsersSkills": "Habilidades de Outros Usuários" - }, - "deleteDialog": { - "title": "Excluir Habilidade", - "message": "Excluir a habilidade \"{{name}}\"? Esta ação não pode ser desfeita.", - "confirm": "Excluir" - }, - "removeRepoDialog": { - "title": "Remover Repositório Git", - "message": "Remover este repositório Git? As habilidades dele não estarão mais disponíveis.", - "confirm": "Remover" + "stepWrite": "Write a skill: a name, a line that says when to use it, and the instructions.", + "stepTry": "Check it in the list: it shows who uses it and what it says.", + "stepAdd": "Add it to an agent when you want it used." }, "toasts": { "loadFailed": "Falha ao carregar habilidades", "deleted": "Habilidade \"{{name}}\" excluída", - "deleteFailed": "Falha ao excluir a habilidade", - "exported": "Habilidade \"{{name}}\" exportada", - "exportFailed": "Falha na exportação", - "imported": "Habilidade importada de \"{{file}}\"", - "importFailed": "Falha na importação", - "loadReposFailed": "Falha ao carregar repositórios Git", - "repoAdded": "Repositório Git adicionado e sincronizando", - "addRepoFailed": "Falha ao adicionar o repositório", - "synced": "Repositório sincronizado", - "syncFailed": "Falha na sincronização", - "toggled": "Repositório alternado", - "toggleFailed": "Falha ao alternar", - "removed": "Repositório removido", - "removeFailed": "Falha na remoção" + "deleteFailed": "Falha ao excluir a habilidade" + }, + "layers": { + "label": "Layer", + "personal": "Personal", + "shared": "Shared", + "builtin": "Built-in", + "folder": "Workspace", + "addon": "Complemento" + }, + "pane": { + "loading": "Loading...", + "loadFailed": "The skill could not be read.", + "tokensExplain": "A chat reads a skill only when a task matches its description, so it adds nothing until then. About {{count}} tokens when read (estimate)." + }, + "edit": { + "back": "Back to skills", + "titleNew": "New skill", + "titleEdit": "Edit: {{name}}", + "basic": "Basic information", + "content": "Content", + "layer": "Layer", + "layerHelp": "Personal is yours alone. Shared is read by every user.", + "name": "Name (lowercase, hyphens only)", + "nameLocked": "Name cannot be changed after creation.", + "description": "Description (when to use it)", + "descriptionHelp": "A chat reads this line to decide whether the skill fits the task.", + "body": "Skill content (markdown)", + "cancel": "Cancel", + "save": "Save changes", + "create": "Create skill", + "saving": "Saving...", + "created": "Skill created", + "updated": "Skill updated", + "required": "A name and a description are required", + "saveFailed": "Save failed", + "loadFailed": "Failed to load the skill", + "readOnly": "Esta habilidade é somente leitura aqui. Uma habilidade integrada vem com o servidor, e uma dos seus arquivos é alterada em Arquivos." + }, + "load": { + "title": "Não foi possível carregar suas habilidades." } } diff --git a/core/http/react-ui/public/locales/pt-BR/swarm.json b/core/http/react-ui/public/locales/pt-BR/swarm.json new file mode 100644 index 000000000..0302ed690 --- /dev/null +++ b/core/http/react-ui/public/locales/pt-BR/swarm.json @@ -0,0 +1,5 @@ +{ + "nodes": { + "retry": "Tentar novamente" + } +} diff --git a/core/http/react-ui/public/locales/pt-BR/tools.json b/core/http/react-ui/public/locales/pt-BR/tools.json new file mode 100644 index 000000000..2f420c8c7 --- /dev/null +++ b/core/http/react-ui/public/locales/pt-BR/tools.json @@ -0,0 +1,468 @@ +{ + "steps": { + "label": "Progresso", + "setup": "Configurar", + "check": "Verificar", + "run": "Executar", + "result": "Resultado" + }, + "run": { + "stop": "Parar", + "connecting": "Conectando ao job. Aguardando o primeiro evento de progresso.", + "progress": "Progresso", + "stages": "Etapas", + "step": "passo", + "of": "de", + "epoch": "época", + "tokens": "tokens/s", + "eta": "cerca de {{time}} restantes", + "etaSeconds": "cerca de {{seconds}} s restantes" + }, + "landing": { + "lede": "O que você pode criar nesta máquina e o que cada ferramenta precisa antes.", + "machine": "Esta máquina", + "gpu": "GPU", + "gpuFree": "{{free}} de {{total}} livres", + "noGpu": "Nenhuma GPU encontrada", + "cluster": "{{total}} no melhor nó ({{node}})", + "clusterNode": "um worker", + "ram": "Memória", + "ramFree": "RAM, {{free}} livres", + "disk": "Disco de modelos", + "diskFree": "livres", + "backends": "Backends", + "noBackends": "nenhum instalado para estas ferramentas", + "open": "Abrir", + "openTool": "Abrir {{tool}}", + "install": "Instalar", + "ready": "Pronto", + "needsBackend": "Backend necessário", + "experimental": "Experimental", + "needs": "O que ela precisa", + "flow": "O caminho de um modelo encontrado até um com o qual você pode conversar", + "flowNote": "Cada etapa termina com um modelo que você pode carregar.", + "now": { + "running": { + "fineTune": "Ajustando {{model}}", + "quantize": "Quantizando {{model}} para {{type}}" + }, + "failed": { + "fineTune": "Falha ao ajustar {{model}}", + "quantize": "Falha ao quantizar {{model}} para {{type}}" + } + }, + "groups": { + "make": { + "title": "Criar um modelo", + "text": "Comece com um modelo encontrado e termine com um que caiba na máquina e conheça os seus dados." + }, + "recognise": { + "title": "Reconhecer", + "text": "Quem está falando e de quem é o rosto em uma foto." + }, + "automate": { + "title": "Automatizar", + "text": "Assistentes, suas instruções e sua memória." + } + }, + "sentence": { + "import": "Traga um modelo do Hugging Face, uma URL, uma referência do Ollama ou OCI, ou um arquivo desta máquina. O LocalAI escolhe o backend e grava a configuração.", + "quantize": "Reduza um modelo para que ele caiba em menos memória. Converte um modelo do Hugging Face para GGUF na precisão que você escolher.", + "fine-tune": "Ensine a um modelo os seus próprios dados com um adaptador e depois exporte-o como um modelo com o qual você pode conversar." + }, + "line": { + "import": { + "noSetup": "Sem configuração. O backend é baixado quando for necessário.", + "disk": "{{free}} livres no disco de modelos" + }, + "quantize": { + "backend": "backend {{name}} instalado", + "backendMissing": "O backend de quantização não está instalado", + "backendUnknown": "Não foi possível ler o status do backend", + "cpu": "Roda na CPU, sem necessidade de GPU" + }, + "fineTune": { + "backend": "backend {{name}} instalado", + "backendMissing": "Nenhum backend de ajuste fino está instalado", + "backendUnknown": "Não foi possível ler o status do backend", + "gpu": "Memória da GPU: {{name}}, {{free}} de {{total}} livres", + "noGpu": "Nenhuma GPU encontrada. O treino roda na CPU e é lento.", + "cluster": "Memória: {{total}} no melhor nó" + } + }, + "enable": { + "admin": "Instale o backend em Backends e esta ferramenta estará pronta.", + "user": "Peça a um administrador para instalar o backend." + } + }, + "checks": { + "heading": "Verifique antes de começar", + "live": "Atualiza conforme você muda a configuração.", + "meterLabel": "{{used}} por cento em uso, {{free}} livres de {{total}}", + "openBackends": "Abrir Backends", + "barOk": "Nada impede o início.", + "barWarn_one": "{{count}} aviso. Você ainda pode iniciar.", + "barWarn_other": "{{count}} avisos. Você ainda pode iniciar.", + "title": { + "model": "Modelo", + "data": "Dataset", + "backend": "Backend", + "memory": "Memória", + "disk": "Disco", + "reward": "Funções de recompensa", + "resume": "Retomando", + "access": "Acesso ao modelo", + "type": "Tipo de quantização", + "source": "Origem", + "name": "Nome" + }, + "fineTune": { + "modelOk": "Treinando {{model}}.", + "modelMissing": "Informe o modelo a treinar.", + "dataOk": "Os dados vêm de {{source}}.", + "dataMissing": "Adicione um dataset: um id do Hugging Face ou um arquivo enviado.", + "backendOk": "{{name}} está instalado.", + "backendMissing": "Nenhum backend de ajuste fino está instalado, então o job pode não iniciar.", + "backendOther": "{{name}} não está entre os backends de ajuste fino instalados ({{list}}).", + "backendUnknown": "Não foi possível ler os backends instalados.", + "memoryGpu": "{{name}}: {{free}} de {{total}} livres. Tamanho do lote {{batch}}, comprimento da sequência {{seq}}, acumulação de gradiente {{accum}}. Quanto o job precisa depende do modelo e destas configurações, e o LocalAI não estima isso antes de o job começar.", + "memoryCluster": "{{total}} no melhor nó ({{node}}). Tamanho do lote {{batch}}, comprimento da sequência {{seq}}. Quanto o job precisa depende do modelo e destas configurações.", + "memoryCpu": "Nenhuma GPU encontrada. O treino usa a CPU e {{free}} de {{total}} de RAM estão livres. Espere que seja lento. Tamanho do lote {{batch}}, comprimento da sequência {{seq}}.", + "memoryUnknown": "Esta conta não consegue ler a memória da máquina. Tamanho do lote {{batch}}, comprimento da sequência {{seq}}. Quanto o job precisa depende do modelo e destas configurações.", + "disk": "{{free}} livres no disco de modelos. Downloads e checkpoints precisam de espaço.", + "diskLow": "Só {{free}} livres no disco de modelos. Downloads e checkpoints precisam de espaço.", + "rewardMissing": "GRPO precisa de pelo menos uma função de recompensa. Adicione uma em Mais opções.", + "resume": "O treino continua a partir de {{path}}.", + "accessNone": "Nenhum token definido. Um modelo com acesso restrito precisa de um token do Hugging Face em Mais opções.", + "accessSet": "Um token do Hugging Face está definido." + }, + "quantize": { + "modelOk": "Quantizando {{model}}.", + "modelMissing": "Informe o modelo a quantizar.", + "typeMissing": "Informe o tipo de quantização personalizado.", + "backendOk": "{{name}} está instalado.", + "backendMissing": "Nenhum backend de quantização está instalado, então o job pode não iniciar.", + "backendOther": "{{name}} não está entre os backends de quantização instalados ({{list}}).", + "backendUnknown": "Não foi possível ler os backends instalados.", + "memory": "{{free}} de {{total}} de RAM livres. A conversão roda na CPU.", + "disk": "{{free}} livres no disco de modelos. O download, o arquivo convertido e o resultado são gravados em disco.", + "diskLow": "Só {{free}} livres no disco de modelos. O download, o arquivo convertido e o resultado são gravados em disco.", + "accessNone": "Nenhum token definido. Um modelo com acesso restrito precisa de um token do Hugging Face em Mais opções.", + "accessSet": "Um token do Hugging Face está definido." + }, + "import": { + "sourceOk_huggingface": "Reconhecido como um repositório do Hugging Face. O LocalAI verifica os arquivos dele ao importar.", + "sourceOk_url": "Formato de URL reconhecido. O conteúdo não foi verificado.", + "sourceOk_config": "Reconhecido como um arquivo de configuração de modelo. Ele é lido e usado como está.", + "sourceOk_oci": "Reconhecido como uma referência de imagem OCI.", + "sourceOk_ollama": "Reconhecido como uma referência de modelo do Ollama.", + "sourceOk_local": "Reconhecido como um caminho na máquina que executa o LocalAI.", + "sourceOk": "A origem foi reconhecida.", + "sourceUnknown": "Isto não parece com nenhum formato que o LocalAI conheça. Ele vai tentar mesmo assim, e pode falhar.", + "backendDownload": "{{name}} ainda não está instalado. O LocalAI o baixa primeiro.", + "backendChosen": "{{name}} foi escolhido e está instalado.", + "backendAuto": "O LocalAI escolhe o backend pelos arquivos da origem quando a importação começa.", + "nameTaken": "Já existe um modelo instalado chamado {{name}}.", + "nameFree": "{{name}} não está em uso.", + "nameAuto": "O modelo recebe o nome da origem.", + "disk": "{{free}} livres no disco de modelos. O tamanho do download é conhecido quando a importação começa.", + "memory_gpu": "{{free}} de memória da GPU estão livres agora. O que este modelo precisa é conhecido quando a importação começa.", + "memory_ram": "{{free}} de RAM estão livres agora. O que este modelo precisa é conhecido quando a importação começa.", + "memory_cluster": "{{free}} estão disponíveis no melhor nó. O que este modelo precisa é conhecido quando a importação começa.", + "memory": "{{free}} de memória estão livres agora. O que este modelo precisa é conhecido quando a importação começa." + } + }, + "form": { + "key": "Chave", + "value": "Valor", + "addOption": "Adicionar opção", + "copy": "Copiar para a área de transferência", + "more": "Mais opções", + "moreHint": "método, backend, adaptador, otimizador, avaliação, funções de recompensa", + "baseModel": "Modelo base", + "model": "Modelo", + "modelHint": "Um id do Hugging Face ou um caminho nesta máquina.", + "data": "Seus dados", + "dataset": "Origem do dataset", + "datasetHint": "Um id de dataset do Hugging Face, ou envie um arquivo.", + "split": "Divisão", + "upload": "Enviar um arquivo no lugar", + "uploadHint": ".json, .jsonl ou .csv", + "removeFile": "Remover o arquivo", + "train": "O que treinar", + "kind": "Tipo de treino", + "kinds": { + "adapter": { + "title": "Adaptador", + "text": "Treina um adaptador pequeno e mantém os pesos base intactos. A escolha usual, e precisa de pouca memória." + }, + "full": { + "title": "Modelo completo", + "text": "Altera todos os pesos. Precisa de várias vezes a memória de um adaptador." + } + }, + "epochs": "Épocas", + "batch": "Tamanho do lote", + "lr": "Taxa de aprendizado", + "backend": "Backend", + "method": "Método de treino", + "adapterKind": "Tipo de adaptador", + "rank": "Rank", + "alpha": "Alfa", + "dropout": "Dropout", + "gradAccum": "Passos de acumulação de gradiente", + "warmup": "Passos de aquecimento", + "seqLen": "Comprimento máximo da sequência", + "optimizer": "Otimizador", + "maxSteps": "Máximo de passos", + "maxStepsHint": "0 para automático.", + "saveSteps": "Passos para salvar", + "saveLimit": "Limite total de salvamentos", + "saveLimitHint": "0 mantém todos os checkpoints.", + "weightDecay": "Decaimento de peso", + "seed": "Semente", + "seedHint": "0 escolhe uma semente aleatória.", + "precision": "Precisão mista", + "auto": "Automático", + "targetModules": "Módulos alvo", + "targetModulesHint": "Separados por vírgula. Deixe em branco para o padrão do backend.", + "gradCheckpointing": "Checkpointing de gradiente (mais lento, usa menos memória)", + "token": "Token do Hugging Face", + "tokenHint": "Só é necessário para modelos com acesso restrito.", + "evalEnable": "Avaliar durante o treino", + "evalStrategy": "Estratégia de avaliação", + "evalSteps_": "Passos", + "evalEpoch": "Época", + "evalSteps": "Passos de avaliação", + "evalStepsHint": "0 usa os passos para salvar.", + "evalSplit": "Divisão de avaliação", + "evalDataset": "Dataset de avaliação", + "evalDatasetPlaceholder": "Um dataset separado", + "evalRatio": "Proporção da divisão automática", + "rewards": "Funções de recompensa (GRPO)", + "rewardsHint": "GRPO precisa de pelo menos uma função de recompensa. Escolha as integradas ou adicione as suas.", + "resume": "Retomar do checkpoint", + "clearCheckpoint": "Limpar o checkpoint", + "extra": "Opções extras", + "extraHint": "Pares de chave e valor específicos do backend.", + "summaryFull": "modelo completo", + "summary_lora": "adaptador LoRA", + "summary_loha": "adaptador LoHa", + "summary_lokr": "adaptador LoKr", + "recipe": "Treinar um {{kind}} de {{model}} com {{method}}, {{epochs}} épocas sobre {{data}}.", + "recipeEmpty": "Escolha um modelo e um dataset para ver o que será treinado." + }, + "rewards": { + "format_reward": "Verifica o formato ... seguido da resposta.", + "reasoning_accuracy_reward": "Compara o conteúdo de com a coluna de resposta do dataset.", + "length_reward": "Pontua o quanto a resposta se aproxima de um comprimento alvo.", + "xml_tag_reward": "Pontua tags XML abertas e fechadas corretamente.", + "no_repetition_reward": "Penaliza n-gramas repetidos.", + "code_execution_reward": "Verifica se os blocos de código Python têm sintaxe válida.", + "param_target_length": "Comprimento alvo", + "fnName": "Nome da função", + "fnBody": "Corpo da função", + "fnHint": "Recebe completions e kwargs e deve retornar uma lista de floats. re, math, json e string estão disponíveis.", + "add": "Adicionar", + "cancel": "Cancelar", + "custom": "Adicionar uma função de recompensa personalizada" + }, + "fineTune": { + "title": "Ajuste fino", + "lede": "Treine um adaptador com os seus próprios dados e depois exporte-o como um modelo com o qual você pode conversar.", + "importConfig": "Importar configuração", + "exportConfig": "Exportar configuração", + "newJob": "Novo job", + "badConfig": "Não foi possível ler o arquivo. Use uma configuração JSON exportada desta página.", + "start": "Iniciar ajuste fino", + "starting": "Iniciando...", + "resume": "Retomar treino", + "resuming": "Retomando do checkpoint:", + "clear": "Limpar", + "barNote": "Nada é alterado até você iniciar.", + "jobMeta": "{{kind}} {{method}} em {{backend}}, iniciado {{started}}", + "failed": "O treino falhou", + "failedAt": "O treino parou no passo {{step}} de {{total}}", + "noMessage": "O backend não enviou mensagem.", + "finished": "Treino concluído.", + "finishedLoss": "Treino concluído. A última perda foi {{loss}}.", + "stopped": "O job foi interrompido. Um checkpoint é mantido se você escolheu mantê-lo.", + "tryTitle": "O que fazer a seguir", + "tryText": "O servidor informou que a memória acabou. Cada alteração abaixo é aplicada a uma cópia da configuração, e nada começa até você pressionar Iniciar.", + "tryPlain": "Abra a configuração de novo, altere o que a mensagem aponta e inicie um novo job.", + "fixBatch": "Reduzir o tamanho do lote para 1", + "fixCheckpointing": "Ativar o checkpointing de gradiente (mais lento, usa menos memória)", + "retryChanges": "Abrir a configuração com estas alterações", + "reuse": "Abrir a configuração de novo", + "resumeFrom": "Retomar do passo {{step}}", + "status": { + "queued": "Na fila", + "loading_model": "Carregando modelo", + "loading_dataset": "Carregando dataset", + "training": "Treinando", + "saving": "Salvando", + "completed": "Concluído", + "failed": "Falhou", + "stopped": "Parado" + } + }, + "stop": { + "title": "Parar o treino?", + "text": "Um checkpoint salvo agora permite retomar ou exportar o que foi aprendido até aqui.", + "keep": "Parar e manter um checkpoint", + "discard": "Parar sem checkpoint", + "cancel": "Continuar treinando", + "cancelDelete": "Cancelar" + }, + "chart": { + "loss": "Perda", + "learning_rate": "Taxa de aprendizado", + "grad_norm": "Norma do gradiente", + "metric": "Métrica", + "step": "passo", + "eval": "Perda de avaliação", + "waiting": "Aguardando mais passos.", + "window": "Passos de {{from}} a {{to}} de {{total}}", + "windowEval": "Passos de {{from}} a {{to}} de {{total}}. Os pontos vazios são a perda de avaliação.", + "aria": "{{metric}} ao longo dos passos de treino. O último valor é {{last}} no passo {{step}}.", + "readout": "passo {{step}}: {{value}}", + "dataTitle": "Mostrar os números", + "emptyText": "O gráfico é desenhado quando o job informar dois passos." + }, + "log": { + "title": "Log", + "note": "O que o job informou desde que esta página foi aberta.", + "only": "Somente avisos e erros", + "copy": "Copiar", + "copied": "Copiado", + "download": "Baixar", + "empty": "Nada informado ainda.", + "noneFiltered": "Nenhum aviso ou erro." + }, + "checkpoints": { + "title": "Checkpoints", + "step": "Passo", + "epoch": "Época", + "loss": "Perda", + "path": "Caminho", + "actions": "Ações", + "resume": "Retomar", + "resumeTitle": "Retomar o treino a partir deste checkpoint", + "export": "Exportar", + "exportTitle": "Exportar este checkpoint" + }, + "export": { + "title": "Exportar como modelo", + "lede": "Exportar transforma o resultado em um modelo que o LocalAI pode carregar.", + "checkpoint": "Checkpoint", + "final": "Modelo final (diretório de saída)", + "checkpointOption": "Passo {{step}} (perda {{loss}})", + "format": "Formato de exportação", + "lora": "Adaptador LoRA", + "merged16": "Mesclado (16 bits)", + "merged4": "Mesclado (4 bits)", + "quant": "Quantização", + "name": "Nome do modelo", + "nameHint": "Deixe em branco para gerar um nome a partir do job.", + "go": "Exportar", + "exporting": "Exportando...", + "working": "Exportação em andamento...", + "done": "Modelo exportado e registrado como \"{{name}}\"", + "failed": "A exportação falhou: {{message}}", + "unknown": "erro desconhecido", + "nextTitle": "{{name}} agora é um modelo", + "nextText": "Ele está registrado no LocalAI. Abra um chat com ele ou encontre-o em Modelos.", + "chat": "Conversar com {{name}}", + "models": "Abrir em Modelos", + "archive": "Baixar arquivo" + }, + "jobs": { + "title": "Jobs anteriores", + "count_one": "{{count}} mantido neste servidor", + "count_other": "{{count}} mantidos neste servidor", + "loading": "Carregando jobs", + "emptyTitle": "Nenhum job de ajuste fino ainda", + "emptyText": "Configure um acima. Ele aparece aqui com o seu status, e você pode reutilizar a configuração depois.", + "model": "Modelo", + "kind": "Tipo", + "status": "Status", + "started": "Iniciado", + "actions": "Ações", + "open": "Abrir este job", + "reuse": "Reutilizar", + "reuseTitle": "Usar a configuração deste job em um novo job", + "delete": "Excluir", + "deleteTitle": "Excluir este job e os seus dados", + "deleteDialogTitle": "Excluir este job?", + "deleteDialogText": "Isto exclui o job com os seus checkpoints e qualquer modelo exportado. Não pode ser desfeito." + }, + "quantize": { + "title": "Quantizar", + "lede": "Reduza um modelo para que ele caiba em menos memória. O resultado é um arquivo GGUF que você pode importar.", + "newJob": "Novo job", + "sectionModel": "Modelo", + "model": "Modelo", + "modelHint": "Um id do Hugging Face ou um caminho nesta máquina.", + "sectionPrecision": "Precisão", + "type": "Tipo de quantização", + "typeHint": "Um número menor significa arquivo menor e um pouco menos de qualidade.", + "custom": "Personalizado...", + "customLabel": "Tipo personalizado", + "customPlaceholder": "por exemplo iq4_nl", + "moreHint": "backend, token do Hugging Face", + "tokenHint": "Só é necessário para modelos com acesso restrito.", + "recipe": "Converter {{model}} para GGUF em {{type}}.", + "recipeEmpty": "Informe um modelo para ver o que será criado.", + "start": "Quantizar ({{type}})", + "barNote": "Nada é alterado até você iniciar.", + "jobMeta": "{{type}} em {{backend}}, iniciado {{started}}", + "failed": "A quantização falhou", + "finished": "Quantização concluída: {{type}}.", + "stopped": "O job foi interrompido.", + "tryText": "Abra a configuração de novo, altere o que a mensagem aponta e inicie um novo job. Um backend mais novo pode suportar uma arquitetura que um mais antigo não suporta.", + "outputTitle": "O resultado", + "outputText": "Um arquivo GGUF em {{type}}. Importe-o para usar no LocalAI, ou baixe-o.", + "file": "Arquivo", + "importName": "Nome do modelo", + "importNamePlaceholder": "Gerado se vazio", + "import": "Importar para o LocalAI", + "importing": "Importando...", + "importFailed": "A importação falhou", + "importFailedWith": "A importação falhou: {{message}}", + "download": "Baixar GGUF", + "nextTitle": "{{name}} agora é um modelo", + "nextText": "Ele está registrado no LocalAI. Abra um chat com ele ou encontre-o em Modelos.", + "chat": "Conversar com {{name}}", + "models": "Abrir em Modelos", + "imported": "importado como {{name}}", + "emptyTitle": "Nenhum job de quantização ainda", + "emptyText": "Configure um acima. Ele aparece aqui com o seu status.", + "deleteDialogText": "Isto exclui o job com os seus arquivos convertidos. Não pode ser desfeito.", + "startFailed": "Não foi possível iniciar o job", + "stopFailed": "Não foi possível parar o job", + "deleteFailed": "Não foi possível excluir o job", + "status": { + "queued": "Na fila", + "downloading": "Baixando", + "converting": "Convertendo", + "quantizing": "Quantizando", + "completed": "Concluído", + "failed": "Falhou", + "stopped": "Parado" + } + }, + "off": { + "badge": "Não disponível para você", + "fineTune": { + "title": "Sua conta não pode fazer ajuste fino", + "text": "O ajuste fino é uma permissão separada, e esta conta não a tem." + }, + "quantize": { + "title": "Sua conta não pode quantizar", + "text": "A quantização é uma permissão separada, e esta conta não a tem." + }, + "admin": "Um administrador pode ativá-la em Usuários e chaves.", + "askAdmin": "Peça a um administrador para ativá-la na sua conta.", + "back": "Ir para o Início" + } +} diff --git a/core/http/react-ui/public/locales/pt-BR/traffic.json b/core/http/react-ui/public/locales/pt-BR/traffic.json new file mode 100644 index 000000000..74c7a69c9 --- /dev/null +++ b/core/http/react-ui/public/locales/pt-BR/traffic.json @@ -0,0 +1,12 @@ +{ + "overview": { + "usageErrorTitle": "Não foi possível ler o uso." + }, + "host": { + "errorTitle": "Não foi possível ler o host." + }, + "models": { + "noMatch": "Nenhum modelo corresponde a “{{query}}”." + }, + "clearSearch": "Limpar busca" +} diff --git a/core/http/react-ui/public/locales/pt-BR/upgrade.json b/core/http/react-ui/public/locales/pt-BR/upgrade.json new file mode 100644 index 000000000..a007a385f --- /dev/null +++ b/core/http/react-ui/public/locales/pt-BR/upgrade.json @@ -0,0 +1,46 @@ +{ + "badge": { + "available": "Atualização disponível", + "availableVersion": "Atualização disponível: {{version}}" + }, + "trigger": { + "all": "Atualizar tudo ({{count}})", + "running": "Atualizando {{current}} de {{total}}", + "summary": "{{done}} atualizados, {{failed}} com falha, {{skipped}} ignorados", + "summaryOk": "{{done}} atualizados", + "review": "Revisar" + }, + "dialog": { + "title": "Atualizar tudo", + "intro": "Escolha o que atualizar. Cada item é atualizado separadamente, um após o outro. Uma falha não interrompe os demais.", + "selection": "Seleção", + "selectAll": "Selecionar tudo", + "selectNone": "Limpar seleção", + "selectedCount_one": "{{count}} selecionado", + "selectedCount_other": "{{count}} selecionados", + "item": "Atualizar {{name}}", + "versions": "{{from}} para {{to}}", + "blocked": "Não pode iniciar", + "start": "Atualizar selecionados", + "starting": "Atualizando...", + "retry": "Tentar de novo os que falharam ({{count}})", + "again": "Nova seleção", + "close": "Fechar", + "closeRunning": "Fechar (continua em execução)", + "runningNote": "Fechar esta janela não interrompe as atualizações.", + "progress": "Atualizando {{current}} de {{total}}", + "summary": "{{done}} atualizados, {{failed}} com falha, {{skipped}} ignorados", + "empty": "Nada para atualizar." + }, + "item": { + "queued": "Na fila", + "upgrading": "Atualizando", + "done": "Concluído", + "failed": "Falhou", + "skipped": "Ignorado" + }, + "reason": { + "checkAgain": "Verificar novamente", + "checking": "Verificando..." + } +} diff --git a/core/http/react-ui/public/locales/pt-BR/workspace.json b/core/http/react-ui/public/locales/pt-BR/workspace.json new file mode 100644 index 000000000..92626d29b --- /dev/null +++ b/core/http/react-ui/public/locales/pt-BR/workspace.json @@ -0,0 +1,671 @@ +{ + "wellKnown": { + "revert": "Reverter para a versão anterior", + "view": "Visualizar", + "form": "Formulário", + "save": "Salvar", + "cancel": "Cancelar", + "notSet": "Não definido", + "moveUp": "Mover para cima", + "moveDown": "Mover para baixo", + "remove": "Remover", + "savedAt": "Salvo às {{time}}", + "notSaved": "Ainda não salvo.", + "notSavedPrefilled": "Ainda não salvo. Alguns valores já foram preenchidos para você; Salvar os mantém.", + "loadFailed": "Não foi possível carregar estas configurações. Recarregue a página para tentar de novo.", + "invalid": "Estas configurações têm erros, e não há uma versão válida anterior.", + "invalidFallback": "Estas configurações têm erros. Aqui aparece a última versão válida delas.", + "conflict": "Estas configurações mudaram enquanto você as editava, provavelmente pelo assistente ou em outra aba. Os valores mais recentes são:", + "useTheirs": "Usar os valores mais recentes", + "keepMine": "Manter minhas alterações", + "editWithAssistant": "Editar com o assistente", + "from": { + "account": "Da sua conta", + "browser": "Do seu navegador" + }, + "check": { + "required": "Obrigatório.", + "tooLong": "No máximo {{max}} caracteres.", + "format": "Este valor está com o formato errado." + }, + "source": { + "label": "Origem", + "hint": "As configurações como texto. Elas são verificadas do mesmo jeito ao salvar.", + "notJson": "O texto tem um erro de sintaxe. Confira as aspas, as vírgulas e as chaves.", + "notObject": "O texto deve começar com { e terminar com }." + }, + "persona": { + "title": "Assistente", + "lede": "Nome e instruções que o assistente usa em todos os chats." + }, + "user": { + "title": "Você", + "lede": "O assistente lê isto para saber com quem está falando. Isso chega ao modelo em todos os chats." + }, + "ui": { + "title": "Aparência", + "lede": "Como o LocalAI aparece para você. As mudanças aparecem na hora; Salvar as mantém." + }, + "shortcuts": { + "title": "Atalhos", + "lede": "Os prompts acima do campo de mensagem de cada chat.", + "edit": "Editar atalhos", + "defaults": "Atalhos padrão", + "none": "Nenhum atalho. Adicione um, ou salve uma lista vazia para ocultá-los.", + "label": "Rótulo", + "prompt": "Prompt", + "send": "Enviar na hora", + "add": "Adicionar atalho", + "invalid": "Alguns atalhos têm erros.", + "assistantPrompt": "Altere meus atalhos: " + }, + "home": { + "editLayout": "Editar layout", + "ask": "Perguntar ao assistente", + "askPrompt": "Altere minha página Home: ", + "add": "Adicionar widget", + "newTitle": "Novo widget", + "noWidgets": "Nenhum widget ainda.", + "type": "Tipo", + "title": "Título", + "source": "Arquivo de dados", + "noSourceNeeded": "Nenhum arquivo de dados necessário", + "types": { + "list": "Lista", + "metric": "Métrica", + "chats": "Últimos chats", + "page": "Página" + }, + "left": "{{count}} restantes", + "fromChats": "dos chats", + "noChats": "Nenhum chat ainda.", + "noData": "Ainda não há dados. O assistente pode escrever", + "nothing": "Nada aqui.", + "noSource": "Este widget não tem arquivo de dados. Escolha um em Editar layout.", + "broken": "Os dados deste widget têm erros, e não há uma versão válida anterior.", + "stale": "Mostrando os últimos dados válidos. Os dados mais recentes têm erros.", + "updated": "Atualizado {{when}}", + "footer": { + "noData": "Ainda sem dados.", + "invalid": "Os dados têm um erro. Mostrando a última versão boa.", + "revert": "Reverter", + "missing": "Os dados deste widget estão faltando.", + "fix": "Pedir ao assistente para corrigir", + "fixPrompt": "O widget da Início lê {{file}}, que está faltando ou quebrado. Corrija: ", + "waiting": "Aguardando você criar o gatilho dele no chat.", + "openChat": "Abrir o chat", + "running": "Atualizando agora…", + "openRun": "Abrir a execução", + "paused": "Pausado · era {{schedule}}", + "resume": "Retomar", + "failedEmpty": "A atualização falhou e ainda não há dados.", + "details": "Detalhes", + "retry": "Tentar de novo", + "stale": "A última atualização falhou. Mostrando os dados de {{when}}.", + "staleToday": "A última atualização falhou. Mostrando dados anteriores de hoje.", + "earlier": "antes", + "firstRun": "Primeira execução {{when}}", + "firstRunFile": "Executa quando chega um arquivo", + "runNow": "Executar agora", + "fresh": "{{schedule}} · atualizado {{time}}", + "pause": "Pausar", + "fromPage": "Da página {{title}}", + "fromFile": "Dos seus arquivos", + "next": "Próxima execução {{when}}", + "cost": "cerca de {{tokens}} tokens por execução", + "costRemote": "cerca de {{tokens}} tokens por execução em um modelo remoto", + "cadence": { + "paused": "Pausado", + "hourly": "A cada hora", + "daily": "Diário", + "weekdays": "Dias úteis", + "weekly": "Semanal", + "monthly": "Mensal", + "file": "A cada arquivo novo" + }, + "scheduleNote_one": "{{count}} widget é atualizado por agendamento. As execuções usam seus modelos e contam no seu uso.", + "scheduleNote_other": "{{count}} widgets são atualizados por agendamento. As execuções usam seus modelos e contam no seu uso." + }, + "opensSite": "{{host}}: abre em uma nova aba", + "linkAsk": "Este widget quer abrir um link", + "invalid": "O layout da sua Home tem erros, e não há uma versão válida anterior.", + "invalidFallback": "O layout da sua Home tem erros. Home mostra a última versão válida dele.", + "page": { + "gone": "Esta página não está mais disponível", + "goneHint": "A página \"{{name}}\" foi movida ou excluída, ou você não pode mais abri-la.", + "noWidget": "Esta página não tem visualização de widget", + "noWidgetHint": "Peça ao assistente para dar à página \"{{name}}\" uma visualização compacta para o Início.", + "off": "As páginas compartilhadas estão desativadas", + "offHint": "Um administrador desativou as páginas compartilhadas neste servidor.", + "fix": "Personalizar Início", + "failed": "A página não carregou", + "retry": "Tentar novamente", + "changed": "Esta página mudou desde que você a permitiu", + "notAllowed": "Você ainda não permitiu esta página", + "allowHint": "Ela vem de uma pasta compartilhada. Revise-a antes que seja executada em seu nome.", + "allow": "Permitir esta página", + "frameTitle": "{{title}} (página no Início)", + "refresh": "Atualizar", + "open": "Abrir página", + "add": "Adicionar uma página", + "limit": "O Início mostra no máximo {{max}} páginas; remova uma para adicionar outra.", + "noneEligible": "Nenhuma página tem ainda uma visualização compacta para o Início. Peça ao assistente para adicionar uma a uma página.", + "pick": "Página", + "unavailable": "{{name}} (indisponível)", + "sharedLabel": "compartilhada", + "sizes": { + "small": "Pequeno", + "medium": "Médio", + "large": "Grande" + } + } + }, + "fields": { + "persona": { + "name": { + "label": "Nome", + "help": "Como o assistente se chama." + }, + "system_prompt": { + "label": "Prompt do sistema", + "help": "Adicionado a todos os chats, depois do prompt do chat e das instruções do agente." + } + }, + "user": { + "name": { + "label": "Nome", + "help": "Como o assistente chama você." + }, + "language": { + "label": "Idioma", + "help": "O idioma em que o assistente responde." + }, + "bio": { + "label": "Sobre você", + "help": "Algumas linhas que o assistente lê em todos os chats." + }, + "timezone": { + "label": "Fuso horário", + "help": "Para datas e horas nas respostas." + } + }, + "ui": { + "theme": { + "label": "Tema", + "help": "Sistema segue a configuração do seu dispositivo.", + "options": { + "dark": "Escuro", + "light": "Claro", + "system": "Sistema" + } + }, + "accent": { + "label": "Cor de destaque", + "help": "Para botões e links. Vazio mantém o padrão." + }, + "density": { + "label": "Densidade", + "help": "Compacta mostra mais conteúdo em uma tela.", + "options": { + "comfortable": "Confortável", + "compact": "Compacta" + } + }, + "defaultModel": { + "label": "Modelo padrão", + "help": "O modelo com que um novo chat começa." + } + } + }, + "saveInvalid": "Alguns valores não são válidos. Confira e salve de novo.", + "saveTooLarge": "Estas configurações são grandes demais para salvar. Remova algumas entradas e tente de novo.", + "saveFailed": "Não foi possível salvar. Tente de novo.", + "details": "Detalhes" + }, + "discovery": { + "ex": { + "title": "Faça o LocalAI do seu jeito", + "lede": "Diga em um chat. O assistente salva, e você pode alterar ou desfazer.", + "hide": "Ocultar", + "hiddenToast": "Exemplos ocultos.", + "saveFailed": "Não salvo: {{message}}", + "card1": { + "kicker": "Você e o assistente", + "prompt": "Me chame de {{firstName}} e mantenha as respostas curtas", + "promptNoName": "Lembre meu nome e mantenha as respostas curtas", + "what": "Salva seu nome e como o assistente responde" + }, + "card2": { + "kicker": "Uma página", + "prompt": "Crie um quadro de tarefas para mim", + "what": "Adiciona uma página que você abre pela barra lateral" + }, + "card3": { + "kicker": "Toda manhã", + "prompt": "Toda manhã, resuma aqui minha caixa de entrada", + "promptNoMail": "Toda manhã, liste aqui os chats de ontem", + "what": "Adiciona um widget em Home e um gatilho que você confirma" + }, + "quote": "«{{text}}»", + "donePrefix": "Pronto.", + "doneLink": "Altere em Account", + "ready": "Ou comece com uma página pronta:", + "readyTasks": "Quadro de tarefas", + "readyReading": "Lista de leitura", + "readyFailed": "A página não foi copiada: {{message}}", + "learn": "Como isso funciona", + "inComposer": "Edite se quiser e pressione Enter. Nada é salvo até você enviar.", + "live": "Texto adicionado à caixa de mensagem." + }, + "home": { + "customise": "Personalizar Home" + }, + "action": { + "undo": "Desfazer", + "openAccount": "Abrir em Account", + "openPage": "Abrir página", + "openHome": "Abrir Home", + "turnBackOn": "Ative de novo em Account" + }, + "change": { + "profilePersona": "Seu nome e suas instruções para o assistente foram salvos. Todo chat os usa a partir da sua próxima mensagem.", + "profile": "Salvo no seu perfil.", + "persona": "As instruções do assistente foram salvas. Todo chat as usa a partir da sua próxima mensagem.", + "shortcuts": "Seus atalhos foram salvos.", + "home": "Sua página Home foi salva. Home mostra a mudança.", + "ui": "Suas configurações de aparência foram salvas.", + "pageCreated": "A página {{title}} foi criada.", + "pageChanged": "A página {{title}} foi alterada.", + "offersOff": "As sugestões do assistente estão desativadas.", + "undoing": "Desfazendo…", + "undone": "Desfeito.", + "undoFailed": "Não desfeito: {{message}}" + }, + "toast": { + "shortcutAdded": "Adicionado à sua caixa de mensagem: {{label}}", + "pageCreated": "{{title}} está em Pages, na barra lateral", + "open": "Abrir", + "homeChanged": "Home atualizado.", + "themeChanged": "Tema alterado." + }, + "files": { + "learn": "Esses arquivos mudam como o LocalAI funciona para você.", + "tag": { + "profile": "Seu perfil", + "persona": "Assistente", + "shortcuts": "Atalhos", + "home": "Layout de Home", + "ui": "Aparência", + "page": "Página" + } + }, + "privacy": "O assistente recebe seu nome, este texto, seu idioma e seu fuso horário no início de cada chat. Deixe um campo vazio para não enviá-lo.", + "versions": { + "label": "Versões", + "title": "Versões anteriores", + "loading": "Lendo o histórico…", + "none": "Ainda não há versão anterior.", + "current": "atual", + "invalid": "tem um erro", + "byAssistant": "Pelo assistente", + "byAssistantIn": "Pelo assistente em «{{chat}}»", + "byYou": "Por você", + "undo": "Um desfazer", + "upload": "Enviado", + "revert": "Reverter", + "note": "Reverter mantém a versão mais nova nesta lista.", + "invalidBadge": "Inválida", + "revertGood": "Reverter para a última versão boa" + }, + "help": { + "title": "Ajuda e sugestões", + "sub": "Formas de o LocalAI mostrar o que sabe fazer. Desative o que você não precisa.", + "offers": "Propostas do assistente", + "offersHint": "No máximo uma por dia, nunca durante uma tarefa: por exemplo, para lembrar algo que você disse.", + "new": "Avisos sobre novidades", + "newHint": "Uma etiqueta curta «Novo» na barra lateral na primeira vez que algo aparece.", + "examples": "Exemplos em Home", + "examplesShown": "Os cartões «Faça o LocalAI do seu jeito» aparecem em Home enquanto você ainda tem algo para testar.", + "examplesHidden": "Você ocultou os cartões «Faça o LocalAI do seu jeito».", + "showExamples": "Mostrar os exemplos de novo", + "examplesBack": "Os exemplos voltaram para Home.", + "dismissed": "O que o assistente aprendeu", + "dismissedHint_one": "Você recusou {{count}} proposta. Ela fica oculta até você redefinir.", + "dismissedHint_other": "Você recusou {{count}} propostas. Elas ficam ocultas até você redefinir.", + "forget": "Esquecer o que o assistente aprendeu sobre minhas sugestões", + "forgotten": "Pronto. O assistente pode voltar a propor.", + "learn": "Veja o que você pode mudar:", + "dismissedNone": "Nada ainda. Quando você responde a uma proposta, o assistente lembra aqui." + } + }, + "tabs": { + "shared": "Compartilhado", + "trashCount_one": "{{count}} item na Lixeira", + "trashCount_other": "{{count}} itens na Lixeira" + }, + "trash": { + "empty": "A lixeira está vazia.", + "restore": "Restaurar", + "emptyAll": "Esvaziar a lixeira", + "purge": "Excluir permanentemente", + "purgeNamed": "Excluir {{name}} permanentemente", + "files_one": "{{count}} arquivo", + "files_other": "{{count}} arquivos", + "folders_one": "{{count}} pasta", + "folders_other": "{{count}} pastas", + "listJoin": ", ", + "summary": "Na lixeira: {{what}}.", + "removed": "Excluído permanentemente: {{what}}.", + "confirmAll": { + "title": "Esvaziar a lixeira?", + "message": "Isto exclui permanentemente {{what}}, com as versões anteriores. Não é possível desfazer." + }, + "confirmOne": { + "title": "Excluir permanentemente?", + "message": "{{path}}: isto exclui permanentemente {{what}}, com as versões anteriores. Não é possível desfazer." + } + }, + "shared": { + "adminTitle": "Pastas compartilhadas", + "adminLede": "Pastas que várias pessoas usam. Os membros abrem as páginas delas, e os chats deles podem ler ou editar os arquivos.", + "userTitle": "Compartilhado com você", + "userLede": "Pastas que um administrador compartilhou com você. As páginas estão em Páginas, e seus chats podem usar os arquivos.", + "new": "Nova pasta compartilhada", + "nameLabel": "Nome da pasta", + "nameHint": "Até 64 letras, dígitos, espaços, pontos, hifens ou sublinhados. Comece com uma letra ou um dígito.", + "create": "Criar", + "cancel": "Cancelar", + "rename": "Renomear", + "renameSave": "Salvar", + "remove": "Remover", + "manage": "Membros", + "menu": "Mais ações para {{name}}", + "pages_one": "{{count}} página", + "pages_other": "{{count}} páginas", + "pagesNone": "Nenhuma página", + "people_one": "{{count}} pessoa", + "people_other": "{{count}} pessoas", + "membersNone": "Nenhum membro", + "everyone": "Todos", + "everyoneOption": "Todos (todos os usuários)", + "everyoneHint": "Todos os usuários, inclusive os que entrarem depois", + "everyoneCan": { + "read": "Todos podem ler", + "write": "Todos podem editar" + }, + "mode": { + "read": "Pode ler", + "write": "Pode editar" + }, + "addTitle": "Adicionar um membro", + "person": "Pessoa", + "access": "Acesso", + "accessFor": "Acesso de {{name}}", + "add": "Adicionar", + "allAdded": "Todos já são membros.", + "usersUnavailable": "Não foi possível carregar a lista de usuários. Você ainda pode adicionar Todos.", + "membersTitle": "Membros", + "noMembers": "Ninguém pode usar esta pasta ainda. Adicione uma pessoa ou Todos.", + "removeMember": "Remover {{name}}", + "paneNote": "A mudança vale na hora. Um chat aberto a vê na próxima vez que for aberto.", + "created": "“{{name}}” criada. Adicione as pessoas que podem usá-la.", + "removed": "“{{name}}” removida. As páginas dela pedem permissão de novo antes de rodar.", + "restored": "“{{name}}” voltou, com seus membros.", + "memberAdded": "{{name}}: {{mode}}.", + "memberRemoved": "{{name}} não pode mais usar “{{folder}}”.", + "failed": "Não concluído: {{message}}", + "loadFailed": "Não foi possível carregar as pastas compartilhadas.", + "emptyAdminTitle": "Ainda não há pastas compartilhadas", + "emptyAdminBody": "Uma pasta compartilhada guarda arquivos e páginas que outras pessoas podem ler ou editar. Crie uma e depois adicione membros.", + "factMembers": "Dê a uma pessoa ou a todos acesso de leitura ou edição", + "factChats": "Os chats dos membros veem os arquivos da pasta", + "factUndo": "Remover uma pasta pode ser desfeito", + "emptyUserTitle": "Ainda não há nada compartilhado com você", + "emptyUserBody": "Quando um administrador compartilhar uma pasta com você, ela aparece aqui e as páginas dela em Páginas." + }, + "preview": { + "label": "Pré-visualização", + "failed": "Não foi possível mostrar este arquivo.", + "loading": "Carregando a prévia de {{name}}", + "failedTitle": "Não foi possível mostrar {{name}}.", + "tooLarge": "Este arquivo é grande demais para a prévia. Baixe-o para abrir.", + "none": "Não há prévia para este tipo de arquivo.", + "typeFile": "Arquivo {{ext}}", + "typeUnknown": "Arquivo", + "sheets": "Planilhas", + "sheetLabel": "Planilha {{name}}", + "sheetEmpty": "Esta planilha está vazia.", + "row": "Linha", + "showingRows": "Mostrando {{rows}} de {{total}} linhas", + "showingCols": "e {{cols}} de {{total}} colunas.", + "downloadRest": "Baixe o arquivo para ver o resto.", + "textOnly": "Prévia do texto, sem formatação.", + "truncated": "O texto foi cortado.", + "noText": "Este arquivo não tem texto para mostrar.", + "slide": "Slide {{n}}", + "notes": "Anotações do apresentador", + "pdfTitle": "PDF {{name}}", + "pdfFallback": "Se o PDF não aparecer aqui, baixe-o.", + "htmlTitle": "Prévia de {{name}}", + "imageFailed": "Não foi possível mostrar a imagem.", + "zoomIn": "Mostrar em tamanho real", + "zoomOut": "Ajustar ao painel", + "items_one": "{{count}} item", + "items_other": "{{count}} itens", + "textCut": "Mostrando os primeiros {{size}}.", + "actions": "Ações do arquivo" + }, + "managed": { + "group": "Gerenciado pelo LocalAI", + "marker": "gerenciado pelo LocalAI", + "about": { + "localai": "Configurações que o LocalAI lê: persona, perfil, atalhos, Início, tema.", + "chats": "Uma pasta por chat, com os arquivos do chat." + }, + "confirm": { + "deleteTitle": "Mover {{name}} para a Lixeira?", + "renameTitle": "Renomear {{name}}?", + "delete": "Mover para a Lixeira", + "rename": "Renomear", + "restore": "Você pode restaurá-lo da Lixeira.", + "renameNote": "O LocalAI só o lê com este nome; depois de renomear, é como se ele não existisse." + }, + "loses": { + "settings": "O LocalAI esquece sua persona, seu perfil, seus atalhos, o layout do Início e seu tema.", + "chats": "Cada chat perde seus arquivos: o que você anexou e o que o assistente escreveu lá.", + "persona": "O assistente perde seu nome e suas instruções.", + "user": "O assistente esquece o que você contou sobre você e suas respostas às ofertas dele.", + "shortcuts": "A caixa de mensagem volta a mostrar os atalhos padrão.", + "home": "O Início perde seus widgets.", + "ui": "Tema, cor de destaque, densidade, modelo padrão e páginas fixadas voltam ao padrão." + } + }, + "newFile": "Novo arquivo", + "newFolderName": "Nova pasta", + "newFileName": "Sem título.md", + "fileName": "Nome do arquivo", + "downloadOne": "Baixar {{name}}", + "moveTo": "Mover para…", + "uploadMenu": "Mais opções de envio", + "uploadFiles": "Enviar arquivos", + "uploadFolder": "Enviar uma pasta", + "loading": "Carregando a pasta", + "loadFailed": "Não foi possível abrir {{folder}}.", + "types": { + "folder": "Pasta", + "image": "Imagem", + "pdf": "PDF", + "sheet": "Planilha", + "doc": "Documento", + "slides": "Apresentação", + "archive": "Arquivo compactado", + "audio": "Áudio", + "video": "Vídeo", + "code": "Código", + "text": "Texto", + "file": "Arquivo" + }, + "columns": { + "name": "Nome", + "modified": "Modificado", + "type": "Tipo", + "size": "Tamanho", + "actions": "Ações" + }, + "view": { + "label": "Visualização", + "list": "Lista", + "grid": "Miniaturas" + }, + "select": { + "one": "Selecionar {{name}}", + "all": "Selecionar tudo", + "bar": "Itens selecionados", + "clear": "Limpar a seleção", + "count_one": "{{count}} selecionado", + "count_other": "{{count}} selecionados", + "start": "Selecionar" + }, + "menu": { + "label": "Ações para {{name}}", + "more": "Mais ações para {{name}}", + "openFolder": "Abrir", + "preview": "Visualizar", + "downloadMany_one": "Baixar {{count}} arquivo", + "downloadMany_other": "Baixar {{count}} arquivos", + "moveMany_one": "Mover {{count}} item para…", + "moveMany_other": "Mover {{count}} itens para…", + "deleteMany_one": "Mover {{count}} item para a Lixeira", + "deleteMany_other": "Mover {{count}} itens para a Lixeira" + }, + "keys": { + "delete": "Del" + }, + "crumbs": { + "hidden": "Mostrar as pastas acima" + }, + "naming": { + "empty": "Digite um nome.", + "slash": "Um nome não pode conter / nem \\.", + "taken": "Já existe algo com esse nome nesta pasta." + }, + "toast": { + "undo": "Desfazer", + "deletedOne": "{{name}} foi movido para a Lixeira.", + "deleted_one": "{{count}} item foi movido para a Lixeira.", + "deleted_other": "{{count}} itens foram movidos para a Lixeira.", + "restored": "Restaurado.", + "renamed": "Renomeado para {{name}}.", + "renameUndone": "Nome restaurado.", + "movedOne": "{{name}} foi movido para {{folder}}.", + "moved_one": "{{count}} item foi movido para {{folder}}.", + "moved_other": "{{count}} itens foram movidos para {{folder}}.", + "moveUndone": "Devolvido ao lugar." + }, + "search": { + "placeholder": "Filtrar esta pasta", + "resultsFor": "Arquivos que contêm “{{query}}”", + "back": "Voltar à pasta", + "noneBody": "Nenhum nome ou texto nos seus arquivos corresponde. Tente outras palavras.", + "page": "página {{n}}", + "sheet": "planilha “{{name}}”", + "slide": "slide {{n}}", + "indexing_one": "{{count}} documento ainda está sendo indexado: pesquise de novo em instantes para incluí-lo.", + "indexing_other": "{{count}} documentos ainda estão sendo indexados: pesquise de novo em instantes para incluí-los.", + "limited": "Resultados limitados: a busca parou nos seus limites antes de examinar todos os arquivos. Pesquise em uma pasta ou use outras palavras para ver o restante.", + "filtering_one": "{{count}} item desta pasta corresponde a “{{query}}”.", + "filtering_other": "{{count}} itens desta pasta correspondem a “{{query}}”.", + "everywhere": "Pesquisar “{{query}}” em todos os arquivos", + "everywhereShort": "Pesquisar em todos os arquivos", + "noMatch": "Nada nesta pasta corresponde a “{{query}}”.", + "clear": "Limpar o filtro" + }, + "emptyHome": { + "title": "Seus arquivos ficam aqui" + }, + "emptyFolder": { + "title": "Esta pasta está vazia", + "body": "Solte arquivos ou pastas em qualquer lugar desta página, cole com Ctrl+V ou envie do seu computador. O assistente também pode salvar arquivos aqui a partir de uma conversa.", + "bodyTouch": "Envie arquivos do seu dispositivo. O assistente também pode salvar arquivos aqui a partir de uma conversa.", + "ask": "Pedir ao assistente" + }, + "drop": { + "title": "Solte para enviar para {{folder}}", + "body": "Arquivos e pastas inteiras são bem-vindos.", + "limit": "Arquivos e pastas inteiras, de até {{size}} cada." + }, + "conflict": { + "title_one": "Já existe um arquivo com este nome", + "title_other": "Já existem {{count}} arquivos com estes nomes", + "body_one": "{{folder}} já tem um arquivo com este nome.", + "body_other": "{{folder}} já tem arquivos com estes nomes.", + "skip_one": "Ignorar", + "skip_other": "Ignorar todos", + "keep": "Manter ambos", + "replace_one": "Substituir", + "replace_other": "Substituir todos", + "more_one": "e mais {{count}}", + "more_other": "e mais {{count}}", + "note": "Manter ambos dá um novo nome ao arquivo novo. Um arquivo substituído pode ser recuperado pelo Histórico.", + "cancel": "Cancelar o envio" + }, + "move": { + "title_one": "Mover {{count}} item", + "title_other": "Mover {{count}} itens", + "items_one": "{{count}} item", + "items_other": "{{count}} itens", + "cancel": "Cancelar", + "here": "Mover para {{folder}}", + "failed": "Não foi possível ler esta pasta.", + "noFolders": "Não há pastas aqui. Mova para cá ou volte um nível." + }, + "uploads": { + "waiting": "Aguardando", + "progress": "{{loaded}} de {{total}}", + "rate": "{{rate}}/s", + "left": "faltam {{time}}", + "renamed": "Salvo como {{name}}", + "done": "Enviado", + "canceled": "Cancelado", + "cancel": "Cancelar", + "cancelOne": "Cancelar o envio de {{name}}", + "retry": "Tentar de novo", + "retryOne": "Tentar {{name}} de novo", + "dismiss": "Dispensar", + "dismissOne": "Dispensar {{name}}", + "show": "Mostrar", + "showOne": "Mostrar {{name}}", + "meterOne": "{{name}}: {{percent}}%", + "meterAll": "Todos os envios: {{percent}}%", + "cancelAll": "Cancelar tudo", + "retryAll": "Tentar tudo de novo", + "fold": "Ocultar a lista", + "unfold": "Mostrar a lista", + "close": "Fechar", + "head": { + "sending_one": "Enviando {{count}} arquivo", + "sending_other": "Enviando {{count}} arquivos", + "failed_one": "{{count}} envio falhou", + "failed_other": "{{count}} envios falharam", + "done_one": "{{count}} arquivo enviado", + "done_other": "{{count}} arquivos enviados" + }, + "say": { + "start_one": "Enviando {{count}} arquivo.", + "start_other": "Enviando {{count}} arquivos.", + "done_one": "Envio concluído: {{count}} arquivo.", + "done_other": "Envios concluídos: {{count}} arquivos.", + "doneFailed": "Envios concluídos: {{count}} enviados, {{failed}} com falha.", + "failed": "Não foi possível enviar {{name}}." + }, + "errors": { + "tooBig": "Maior que o limite de {{limit}} deste servidor.", + "tooLarge": "O servidor recusou por ser grande demais.", + "off": "Os arquivos estão desativados neste servidor.", + "invalid": "O servidor recusou este arquivo.", + "network": "A conexão foi interrompida.", + "server": "O servidor não conseguiu salvar." + }, + "said": "O servidor respondeu: {{reason}}." + } +} diff --git a/core/http/react-ui/public/locales/zh-CN/addons.json b/core/http/react-ui/public/locales/zh-CN/addons.json index a76142c25..103a5d081 100644 --- a/core/http/react-ui/public/locales/zh-CN/addons.json +++ b/core/http/react-ui/public/locales/zh-CN/addons.json @@ -3,9 +3,9 @@ "blocked": "此附加组件上先前的操作尚未确认。请先检查其状态或忽略它。", "checkStatus": "检查状态", "dismiss": "忽略", - "failed": "所有者未完成该操作,未做任何更改。再次尝试将开始新的尝试。", - "notApplied": "所有者报告操作已完成,但附加组件没有变化。请重试。", - "unknown": "所有者尚未确认该操作。它可能仍会被应用。请先检查状态,或忽略它,再开始其他操作。" + "failed": "该操作未完成,未做任何更改。再次尝试将开始新的尝试。", + "notApplied": "该操作已报告完成,但附加组件没有变化。再次尝试将开始新的尝试。", + "unknown": "该操作尚未确认,它可能仍会被应用。请先检查状态,或忽略它,再开始其他操作。" }, "approval": { "title": "需要审批", @@ -37,14 +37,16 @@ "emptyTitle": "还没有应用", "firstRun": { "browse": "Browse add-ons", - "title": "Welcome to Apps", - "lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.", - "featuredLabel": "Featured add-ons", + "title": "还没有附加组件", + "lead": "附加组件为 LocalAI 增加新页面和聊天工具。它们独立运行,只请求列出的权限。", + "featuredLabel": "从其中一个开始", "featuredFailed": "The list of add-ons could not be read.", - "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons." + "noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons.", + "factChecked": "运行前会对照你的来源进行校验", + "factRemove": "随时可以关闭或移除" }, "headless": "在后台运行,没有自己的页面。", - "listLabel": "应用", + "listLabel": "已安装的附加组件", "loading": "正在加载应用", "manage": "管理", "open": "打开", @@ -56,10 +58,14 @@ "tools_one": "{{count}} tool", "tools_other": "{{count}} 个工具", "version": "版本 {{version}}", - "working": "正在等待所有者确认。", + "working": "正在等待确认。", "noMatchTitle": "No add-on matches", - "noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.", - "clearSearch": "Clear search" + "noMatchBody": "没有已安装的项目匹配“{{query}}”。请换个词试试。", + "clearSearch": "Clear search", + "loadError": { + "title": "附加组件暂时不可用。", + "paused": "在它恢复之前,附加组件的页面和工具会暂停。" + } }, "browse": { "confirmTitle": "安装 {{name}}?", @@ -69,7 +75,7 @@ "fromLibrary": "来自 {{library}}", "install": "安装", "installedDetail": "版本 {{version}},第 {{generation}} 代", - "installedOther": "已安装 {{version}}(其他版本)", + "installedOther": "你当前是 {{version}}", "installedSame": "已安装 {{version}}", "installedState": "已安装", "installing": "正在安装", @@ -85,7 +91,8 @@ "backendFailed": "The backend was not installed.", "alias": "Alias", "kindLabel": "Kind", - "installedOn": "{{name}} is installed and on." + "installedOn": "{{name}} is installed and on.", + "installedChip": "已安装" }, "catalog": { "allLibraries": "所有库", @@ -115,8 +122,8 @@ "pin": "Pin {{name}}", "pinLabel": "Pin", "pinned": "Pinned", - "running": "已打开", - "search": "Search add-ons", + "running": "运行中", + "search": "搜索已安装的附加组件", "status": { "failed": "failed", "starting": "starting" @@ -129,7 +136,7 @@ "denied": "服务器拒绝了此请求。你可能没有权限,或附加组件已变更。列表已刷新。", "invalid": "服务器无法接受此请求。", "network": "无法连接服务器,请检查网络。", - "unavailable": "附加组件所有者暂时不可用,请稍后重试。", + "unavailable": "附加组件暂时不可用,请稍后重试。", "unknownOutcome": "无法确认结果。请先检查状态,再重试。" }, "installed": { @@ -174,14 +181,14 @@ }, "hints": { "id": "小写字母、数字和连字符,之后不能更改。", - "reference": "所有者已配置的凭据名称。切勿在此粘贴令牌或密码。", + "reference": "管理员在此服务器上配置的凭据名称。切勿在此粘贴令牌或密码。", "url": "不含凭据和查询字符串的 https 地址。", "identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.", "format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.", "mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched." }, "lastRefresh": "{{when}} 已刷新,{{count}} 个条目", - "lead": "库是列出附加组件的目录文档。凭据绝不在此输入:私有库只引用所有者配置的凭据。", + "lead": "库是列出附加组件、后端或模型的索引。凭据绝不在此输入:私有附加组件库只引用管理员在此服务器上配置的凭据。", "listLabel": "库", "loading": "正在加载库", "notRefreshed": "尚未刷新", @@ -251,10 +258,11 @@ "hasUI": "有页面", "headless": "无界面,没有页面", "health": "健康状况", + "installedAt": "安装时间", "host": "主机 API", "interface": "界面", "logs": "日志", - "logsNone": "此版本不收集附加组件进程的输出。上面的健康状况和重启尝试是所有者能报告的内容。", + "logsNone": "此版本不收集附加组件进程的输出。上面的健康状况和重启尝试是 LocalAI 能报告的全部内容。", "observed": "观测状态", "protocol": "协议", "restarts": "重启尝试次数", @@ -338,14 +346,15 @@ "state": { "failed": "失败", "label": "状态:{{state}}", - "running": "已打开", + "running": "运行中", "starting": "启动中", "stopped": "已关闭", - "update-pending": "等待更新" + "update-pending": "等待更新", + "updateTo": "更新 {{version}}" }, - "supporting": "所有者为你运行的应用,以及它们的来源库。", + "supporting": "你安装的软件,为 LocalAI 增加新页面和聊天工具。每个都独立运行,只请求它列出的权限。", "tabs": { - "apps": "我的附加组件", + "apps": "已安装", "browse": "查找附加组件", "label": "附加组件分区", "libraries": "来源" @@ -516,8 +525,8 @@ "unknownOutcome": "结果未知。请重新读取状态:相同的操作不会发送两次。", "auth": "请重新登录。", "denied": "只有管理员才能在自己的浏览器会话中执行此操作。", - "network": "无法连接到所有者。", - "unavailable": "所有者不可用。" + "network": "无法连接到附加组件服务。", + "unavailable": "附加组件服务不可用。" } }, "updates": { @@ -591,18 +600,30 @@ "auth": "请重新登录以继续。", "denied": "只有管理员才能在自己的浏览器会话中执行此操作。", "network": "无法连接到网络。", - "unavailable": "附加组件所有者不可用。请重试。" + "unavailable": "附加组件服务不可用,请重试。" + }, + "upgradeNamed": "升级 {{name}}", + "why": { + "role": "有可用更新,只有管理员可以升级。", + "needs-check": "已报告有更新,但详情尚未确认。请重新检查以确认。", + "check-failed": "上次检查失败,无法确认更新。请重新检查。", + "incompatible": "存在更新,但无法在此运行:", + "integrity": "库中列出的此版本内容不同,不会作为更新提供。", + "recovery": "此插件的数据需要先恢复。请在详情中恢复备份,然后再升级。", + "current": "上次检查未发现更新,该提示可能已过期。请重新检查。", + "unpinned": "库报告有更新,但没有指明要安装的构建,因此无法在此升级。请稍后再检查。" }, - "reviewButton": "更新 ({{count}})", - "reviewTitle": "查看插件更新", - "reviewDescription": "在此处确认之前,不会升级任何内容。每次升级都会先备份插件的数据。", "checkAll": "检查更新", "checkingAll": "正在检查...", "checkAllDone": "已检查 {{count}} 个插件。", "checkAllFailed": "{{count}} 个无法检查。", - "upgradeNamed": "升级 {{name}}", - "toastDone": "{{name}} 已升级到 {{version}}。", - "toastFailed": "{{name}} 未升级:{{message}}" + "checkResult": { + "done": "已检查。", + "failed": "检查未完成。", + "justNow": "刚刚检查过。" + }, + "waitingService": "插件服务正在启动。LocalAI 将在 {{seconds}} 秒后再次查询升级状态。", + "checkAgain": "立即检查" }, "install": { "stepsLabel": "Install steps", @@ -639,17 +660,19 @@ "addsTools_other": "Adds {{count}} tools", "openNamed": "Open {{name}}", "removeTitle": "Remove {{name}}?", - "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted." + "removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted.", + "removeNamed": "移除 {{name}}…" }, "preview": { "screenshots": "Screenshots", - "adds": "What it adds", + "adds": "它能做什么", "page": "A page of its own in LocalAI", "addsNothing": "Nothing you can see. It works in the background.", "version": "Version", "id": "Identifier", "sourceUnknown": "Not known", - "moreDetails": "Health, trust and updates" + "moreDetails": "Health, trust and updates", + "updateTitle": "更新到 {{version}}" }, "flow": { "installAndTurnOn": "安装并打开", @@ -663,6 +686,66 @@ "install": "Download, verify and install", "turn-on": "Turn on" }, - "seeInList": "See it in My add-ons" + "seeInList": "在“已安装”中查看", + "install": "安装", + "installNamed": "安装 {{name}}", + "shadowWarning": "此插件会替换内置的 {{tool}}。另一个插件({{name}})已经替换了它。只能有一个生效;你可以在 设置 > 工具 中选择。 在你选择之前,由最先安装的插件提供。", + "skillShadow": "此插件会替换内置技能 {{skill}}。", + "skillShadowWarning": "此插件会替换内置技能 {{skill}}。另一个插件({{name}})已经替换了它。只能有一个生效;你可以在 设置 > 工具 中选择。 在你选择之前,由最先安装的插件提供。" + }, + "family": { + "pages": "在找你或智能体创建的页面?它们在页面中。" + }, + "filter": { + "label": "显示", + "all": "全部", + "running": "运行中", + "off": "已关闭", + "showAll": "显示全部", + "none": { + "all": "没有可显示的附加组件。", + "running": "没有正在运行的附加组件。", + "off": "所有附加组件都在运行。" + } + }, + "attention": { + "label": "需要处理", + "failedTitle": "{{name}} 已停止。", + "failedRestarts_one": "连续 {{count}} 次启动失败。", + "failedRestarts_other": "连续 {{count}} 次启动失败。", + "failedOnce": "它没有启动。", + "toolsOff_one": "在它重新运行前,它的 {{count}} 个聊天工具不可用。", + "toolsOff_other": "在它重新运行前,它的 {{count}} 个聊天工具不可用。", + "pageOff": "在它重新运行前,它的页面不可用。", + "updateTitle": "{{name}} {{version}} 已可用。", + "updateBody": "你当前是 {{current}}。替换前你会看到变更并进行确认。" + }, + "meta": { + "builtin": "内置", + "builtinLong": "随 LocalAI 提供", + "signed": "已签名 · {{library}}", + "checked": "已校验 · {{library}}", + "devBuild": "开发版本", + "library": "{{library}} 库" + }, + "skills": { + "title_one": "{{count}} 个聊天技能", + "title_other": "{{count}} 个聊天技能", + "replaces": "替换内置技能", + "serves": "在聊天中使用", + "notServing": "当前未使用", + "serveIn": "在聊天中用于 {{skill}}", + "saveFailed": "无法保存选择。" + }, + "settingsTab": { + "overview": "概览", + "settings": "设置", + "tabsLabel": "{{name}} 的详情", + "off": "启用 {{name}} 后才能更改它的设置。", + "pageLink": "设置", + "controls": { + "loadFailed": "无法读取设置", + "saveFailed": "无法保存设置:{{message}}" + } } } diff --git a/core/http/react-ui/public/locales/zh-CN/admin.json b/core/http/react-ui/public/locales/zh-CN/admin.json index 64601d2c7..3cebd0981 100644 --- a/core/http/react-ui/public/locales/zh-CN/admin.json +++ b/core/http/react-ui/public/locales/zh-CN/admin.json @@ -107,6 +107,72 @@ "assistant": "LocalAI Assistant", "distributed": "分布式", "responses": "响应" + }, + "loadFailedTitle": "无法加载设置。", + "egress": { + "title": "命令的网络", + "intro": "沙盒中的命令只能通过一个代理访问网络,代理对每个连接都会询问 LocalAI。在下方设置限制、新聊天的预设、端口、额外仓库和审计保留时间;每个人在聊天的工具菜单中于限制内选择预设,并在卡片上允许被阻止的主机。", + "serves": "命令在 {{name}} 插件中隔离运行", + "servesHost": "命令通过 {{name}} 在这台机器上运行", + "servesNone": "尚无沙盒运行命令", + "hostNote": "主机后端使用这台机器的网络运行命令:这些设置对其不适用。", + "counts": "最近 24 小时:允许 {{allowed}},阻止 {{blocked}}。", + "private": "允许管理员的规则访问私有网络", + "privateWarning": "管理员允许的主机将可以访问你内部网络中的地址。仅靠预设永远不会;云元数据和服务器地址仍被拒绝。", + "typePhrase": "要开启,请输入:{{phrase}}", + "registries": "软件包仓库({{count}})", + "showAudit": "显示审计", + "hideAudit": "隐藏审计", + "loadFailed": "无法读取沙盒网络设置", + "audit": { + "verdict": "结果", + "host": "主机", + "user": "用户", + "at": "时间", + "reason": "原因", + "bytes": "上传 / 下载字节", + "refresh": "刷新", + "empty": "没有匹配的请求。", + "loadFailed": "无法读取审计", + "verdicts": { + "any": "所有结果", + "allow": "已允许", + "block": "已阻止", + "done": "已结束" + } + } + }, + "addons": { + "title": "附加组件", + "hint": "附加组件带来的设置", + "intro": "每个附加组件都把设置放在自己的页面上,即详情中的“设置”选项卡。打开一个即可更改。", + "off": "已关闭。启用后才能更改它的设置。", + "open": "打开设置", + "openNamed": "打开 {{name}} 的设置", + "count_one": "{{count}} 个有设置的附加组件", + "count_other": "{{count}} 个有设置的附加组件" + }, + "hosts": { + "add": "添加", + "listLabel": "始终允许的主机", + "remove": "移除 {{host}}", + "empty": "还没有主机。输入主机名,或输入 主机:端口 来固定端口。", + "hint": "只接受确切的名称。加上 :端口 只允许一个端口;否则适用沙盒网络端口设置。", + "count": "{{n}}/{{max}}", + "errors": { + "empty": "请输入主机名。", + "wildcard": "“{{value}}”:请输入确切的主机名,不要使用通配模式。", + "shape": "“{{value}}”不是主机名。只写主机,不要带协议或路径。", + "port": "“{{value}}”:端口必须在 1 到 65535 之间,22 和 25 始终被拒绝。", + "private": "“{{value}}”是私有或本地地址。请先打开私有网络开关。", + "numeric": "“{{value}}”:IP 地址请写成四个十进制数。", + "duplicate": "“{{value}}”已在列表中。", + "full": "列表最多包含 {{max}} 个主机。" + } + }, + "network": { + "title": "命令的网络", + "where": "{{name}} 在自己的页面上保存其所运行命令的网络设置。" } }, "backends": { @@ -253,5 +319,75 @@ "subtitle": "此 LocalAI 实例上已加载的模型及其占用的主机容量。", "scaleOut": "添加机器", "hideScaleOut": "隐藏设置" + }, + "settingsTools": { + "intro": "聊天有一些内置工具,可以由插件替换。插件已安装、正在运行并在某个聊天中开启时,该聊天会以相同的名称使用插件的工具。", + "empty": "此服务器上没有可管理的内置工具。", + "loadFailed": "无法读取内置工具", + "switch": "开启", + "byBuiltin": "内置", + "byAddon": "插件:{{name}}", + "byNone": "关闭", + "unavailable": "此服务器无法运行它:{{reason}}。", + "installSandbox": "安装 Sandbox 以隔离运行命令", + "replaceExplain": "这些插件会替换 {{tool}}:所选插件在其开启并运行的每个聊天中提供该工具,内置工具会被隐藏。只能有一个生效。 在你选择之前,由最先安装的插件提供。", + "defaultInstalledFirst": "默认:最先安装", + "installedOn": "安装于 {{when}}", + "choose_one": "{{count}} 个插件替换了 {{tool}}:请选择要使用的插件。", + "choose_other": "{{count}} 个插件替换了 {{tool}}:请选择要使用的插件。", + "labels": { + "bash": "命令(bash)", + "web_search": "网页搜索", + "web_fetch": "读取网页", + "list_agents": "已保存的智能体" + }, + "descriptions": { + "bash": "当没有 Sandbox 提供 bash 时,直接在这台机器上为聊天运行命令和代码。Sandbox 插件会将其替换为隔离运行的命令。", + "web_search": "无需密钥为聊天搜索网页(依次使用 DuckDuckGo、Brave Search、Bing),设置了 SearXNG 实例时则使用该实例。", + "web_fetch": "按地址为聊天读取公开的网页或文件。除非您允许,否则内部网络地址会被拒绝。", + "list_agents": "允许聊天列出用户已保存的智能体,并将其中一个作为子智能体启动。每个聊天需在其工具菜单中开启。" + }, + "bash": { + "switch": "允许聊天在这台机器上运行命令", + "modeLegend": "谁可以运行命令", + "ask": "每次询问。", + "askHint": "只有管理员的聊天可以使用,并且每条命令都要等待其在聊天中确认。", + "on": "允许所有人。", + "onHint": "所有用户的聊天都可以无需询问在此运行命令,包括计划运行和子代理。", + "default": "未设置:使用默认值({{mode}})。", + "warning": "命令以运行 LocalAI 的用户身份执行,没有隔离,并使用这台机器的网络:它们可以读取 LocalAI 能读取的内容,并访问互联网、你的网络和 LocalAI API。每条命令都会被记录。安装 Sandbox 以隔离运行。" + }, + "modes": { + "off": "关闭", + "ask": "每次询问", + "on": "允许所有人" + }, + "skillsTitle": "被插件替换的技能", + "skillsIntro": "插件可以用为其工具编写的技能替换内置技能。如果多个插件替换同一个技能,请选择使用哪一个。", + "skillLabel": "技能 {{skill}}", + "skillNotRunning": "所选插件未运行:使用内置技能。", + "toolNotRunning": "替代此工具的插件未运行:使用内置工具。", + "stopped": "已停止", + "webTest": { + "intro": "用已保存设置中的每个搜索提供方运行一次搜索,并显示每个提供方的结果。请先应用你的更改。", + "queryLabel": "查询", + "run": "测试搜索", + "running": "正在测试…", + "answered": "聊天将获得 {{host}} 的结果。", + "noneAnswered": "没有提供方作出回应。", + "failed": "无法运行测试:{{error}}", + "results_one": "{{count}} 条结果", + "results_other": "{{count}} 条结果", + "outcomes": { + "ok": "正常", + "no_results": "无结果", + "challenged": "机器人验证", + "blocked": "已拦截", + "timeout": "太慢", + "error": "错误", + "skipped": "已跳过" + } + }, + "addonSettings": "{{name}} 的设置" } } diff --git a/core/http/react-ui/public/locales/zh-CN/agents.json b/core/http/react-ui/public/locales/zh-CN/agents.json index 972488fcf..a9fadc21d 100644 --- a/core/http/react-ui/public/locales/zh-CN/agents.json +++ b/core/http/react-ui/public/locales/zh-CN/agents.json @@ -2,15 +2,13 @@ "title": "智能体", "subtitle": "管理自主 AI 智能体", "actions": { - "agentHub": "Agent Hub", "import": "导入", "createAgent": "创建智能体", "edit": "编辑", "chat": "聊天", "export": "导出", "delete": "删除", - "pause": "暂停", - "resume": "恢复" + "newAgent": "新建智能体" }, "table": { "name": "名称", @@ -27,13 +25,9 @@ "empty": { "noConfigured": "未配置智能体", "noConfiguredText": "创建一个智能体以开始自主 AI 工作流。", - "browseHub": "不知从何开始?浏览 <1>Agent Hub 寻找现成的智能体配置以便导入。", "noMatching": "没有匹配的智能体", - "noMatchingText": "没有智能体匹配 \"{{query}}\"" - }, - "sections": { - "yourAgents": "您的智能体", - "otherUsersAgents": "其他用户的智能体" + "noMatchingText": "没有智能体匹配 \"{{query}}\"", + "clearSearch": "清除搜索" }, "deleteDialog": { "title": "删除智能体", @@ -44,12 +38,411 @@ "loadFailed": "加载智能体失败:{{message}}", "deleted": "智能体 \"{{name}}\" 已删除", "deleteFailed": "删除智能体失败:{{message}}", - "paused": "智能体 \"{{name}}\" 已暂停", - "resumed": "智能体 \"{{name}}\" 已恢复", - "pauseFailed": "暂停智能体失败:{{message}}", - "resumeFailed": "恢复智能体失败:{{message}}", "exported": "智能体 \"{{name}}\" 已导出", - "exportFailed": "导出智能体失败:{{message}}", - "parseFailed": "解析智能体文件失败:{{message}}" + "parseFailed": "解析智能体文件失败:{{message}}", + "notAnAgent": "The file is not an exported agent", + "deletedUndo": "已删除“{{name}}”。", + "undoLate": "“{{name}}”已被删除。", + "restored": "已恢复“{{name}}”。" + }, + "facts": { + "addons": "Add-ons", + "connector": "MCP connector" + }, + "agent": { + "triggers": "Triggers", + "newTrigger": "New trigger", + "noTriggers": "No trigger starts this agent.", + "untitled": "Untitled chat", + "triggerOn": "On", + "triggerOff": "Off", + "origin": { + "trigger": "Started by a trigger", + "connector": "Started by a connector" + }, + "noRunsText": "Press Chat to start a conversation with this agent. Chats that ran with it, and the ones its triggers start, are listed here.", + "missingText": "It may have been deleted, or it belongs to another user.", + "backToList": "返回智能体" + }, + "record": { + "where": "A run is a chat that ran with the agent, including the ones its triggers and connectors started.", + "none": "No runs yet" + }, + "create": { + "sections": { + "BasicInfo": "Basic info", + "ModelSettings": "Model", + "PromptsGoals": "Instructions", + "MemorySettings": "Memory", + "Skills": "Skills", + "Addons": "Add-ons", + "MCP": "MCP servers", + "export": "Export" + }, + "name": "Name", + "description": "Description", + "descriptionHelp": "One line that says what the agent is for. It shows in the list.", + "model": "Model", + "modelPlaceholder": "Any model (the chat picks one)", + "modelHelp": "Leave it empty and a chat with this agent uses the model you pick in the chat. Set one to fix it.", + "anyModel": "Any model", + "instructions": "Instructions", + "instructionsHelp": "What the agent always follows. Every chat with it starts from this text.", + "collections": "Collections", + "collectionsHint": "Collection name", + "collectionsHelp": "The memory collections a chat with this agent may search. The list is exact: an empty list grants none.", + "skillsHelp": "The skills a chat with this agent may read. The list is exact: an empty list grants none.", + "noSkills": "No skills yet.", + "manageSkills": "Write or manage skills", + "addonsHelp": "The add-ons whose tools a chat with this agent may use.", + "noAddons": "No add-on offers tools to a model.", + "mcpHelp": "The MCP servers of the fixed model and the MCP connectors a chat with this agent may use.", + "mcpServers": "MCP servers", + "mcpHint": "Server name", + "mcpConnectors": "MCP connectors", + "noConnectors": "No MCP connectors yet.", + "manageConnectors": "Manage MCP connectors", + "connectorOff": "off", + "gone": "not found", + "countCollections_one": "{{count}} collection", + "countCollections_other": "{{count}} collections", + "countAddons_one": "{{count}} add-on", + "countAddons_other": "{{count}} add-ons", + "needsName": "Needs a name", + "loadFailed": "Failed to load the agent: {{message}}", + "saveFailed": "Save failed: {{message}}", + "created": "Agent \"{{name}}\" created", + "updated": "Agent \"{{name}}\" updated", + "exportSummary": "Download as JSON", + "exportHelp": "Download the definition of this agent as a JSON file. It holds no id, no dates and no secrets. Import it on another instance from the agents list.", + "exportButton": "Export agent", + "importNew": "Imported from an exported agent. Read the form, then save.", + "importLegacy": "Imported from the export of an older agent. Name, description, model, instructions and skills were read; the rest has no counterpart here.", + "importDropped": "Not imported: {{keys}}.", + "subEdit": "A chat that is running keeps the version it started with. Its next turn uses the new one.", + "configNote": "The definition this form will save.", + "previewNew": "The agent this form will save.", + "previewEdit": "The agent this form will save, and how it differs from the saved one.", + "default": "Default", + "network": "无人值守运行中命令的网络", + "networkHelp": "当触发器或连接器运行此代理时,其沙盒命令可访问的网络范围,不超过管理员设置的限制。运行时从不询问:范围外的主机会被阻止。用此代理开始的聊天也以此开始。", + "networkPresets": { + "off": "关闭", + "registries": "软件包仓库", + "all": "所有公共网站" + } + }, + "run": { + "workingLine": "Working" + }, + "jobs": { + "title": "Triggers", + "newTask": "New trigger", + "createTask": "Create trigger", + "loading": "Loading triggers", + "loadFailed": "Failed to load: {{message}}", + "actionFailed": "The action failed: {{message}}", + "summary": { + "stopped": "{{count}} skipped", + "lastFailed_one": "The last run of {{names}} failed.", + "lastFailed_other": "The last run failed for {{names}}." + }, + "tasks": { + "title": "Triggers, {{count}}", + "caption": "Triggers", + "task": "Trigger", + "noSchedule": "No schedule", + "noAgent": "No agent", + "fileKind": "When a file arrives", + "open": "Open", + "started": "Trigger \"{{name}}\" started", + "toggleLabel": "Run {{name}} by itself", + "toggleFailed": "Could not change the trigger: {{message}}", + "deleted": "Trigger deleted", + "noneYet": "You have no triggers yet.", + "noModel": "No model", + "next": "下次运行" + }, + "history": { + "filterTask": "Trigger", + "allTasks": "All triggers", + "task": "Trigger", + "unknownTask": "Unknown trigger", + "cancelledLine": "Skipped", + "doneLine": "Finished. Open the chat for the answer.", + "doneFile": "Finished for {{file}}. Open the chat for the answer.", + "pendingLine": "Waiting to start", + "emptyText": "A run starts when you press Run now, when a schedule fires, or when a file arrives in a watched folder.", + "openRun": "Open the chat", + "reason": "Reason", + "file": "Started by the file {{file}}." + }, + "status": { + "done": "Done", + "failed": "Failed", + "running": "Working", + "queued": "Waiting", + "skipped": "Skipped" + }, + "empty": { + "title": "No triggers yet", + "text": "A trigger starts a chat for you by itself: at a time, or when a file arrives in a folder. Each run is an ordinary chat that you can open.", + "step1": "Write what the chat should do.", + "step2": "Pick an agent, or a model, to run it.", + "step3": "Choose when it runs, or press Run now." + }, + "task": { + "back": "Triggers", + "missingTitle": "Trigger not found", + "agent": "Agent", + "model": "Model", + "schedule": "Schedule", + "cap": "Most runs a day", + "watches": "Watches the folder {{folder}}", + "noRuns": "This trigger has not run yet.", + "runMeta": "took {{time}}", + "prompt": "What to do" + }, + "edit": { + "titleNew": "New trigger", + "subNew": "Name it, write what to do and choose when it runs.", + "subEdit": "Changes apply from the next run.", + "save": "Save trigger", + "create": "Create trigger", + "nameRequired": "A trigger needs a name", + "promptRequired": "Write what the trigger should do", + "created": "Trigger created", + "updated": "Trigger updated", + "sections": { + "basics": "Basics", + "prompt": "What to do", + "schedule": "When", + "limits": "Limits" + }, + "summary": { + "basics": "{{name}}, {{model}}", + "basicsNoModel": "{{name}}, no agent or model yet", + "promptEmpty": "Nothing to do yet", + "folderEmpty": "Needs a folder", + "cap": "Up to {{count}} runs a day", + "capDefault": "Default limit" + }, + "name": "Name", + "namePlaceholder": "daily-digest", + "noAgent": "No agent", + "model": "Model", + "enabled": "On", + "enabledHint": "A trigger that is off keeps its schedule but does not run.", + "prompt": "What to do", + "promptPlaceholder": "Summarise what is new in my Reports folder.", + "promptHint": "This text starts the chat each time the trigger fires.", + "capHint": "The most chats this trigger may start in one day. Empty uses the default.", + "previewEdit": "What the next run would send, and what differs from the saved trigger.", + "previewNew": "What the first run would send.", + "promptAsSent": "The text a run starts with", + "noChanges": "Nothing differs from the saved trigger.", + "loadingTask": "Loading trigger", + "changed_one": "{{count}} change", + "changed_other": "{{count}} changes" + }, + "subtitle": "按计划或在文件到达时启动智能体。每次运行都是一个可以打开的聊天。" + }, + "views": { + "label": "智能体区域", + "agents": "智能体" + }, + "now": { + "failedTitle": "{{name}} 运行失败。", + "workingTitle": "{{name}} 正在运行。", + "openRun": "打开运行记录" + }, + "load": { + "agents": "无法加载你的智能体。", + "agentsStale": "无法刷新列表,显示的是之前的内容。", + "agent": "无法加载此智能体。", + "agentStale": "无法刷新此页面,显示的是之前的内容。", + "agentForm": "无法加载此智能体进行编辑。", + "triggers": "无法加载你的触发器。", + "triggersStale": "无法刷新触发器,显示的是之前的内容。", + "trigger": "无法加载此触发器。", + "triggerStale": "无法刷新此触发器,显示的是之前的内容。", + "tasks": "无法加载任务。", + "tasksStale": "无法刷新任务,显示的是之前的内容。" + }, + "meta": { + "anyModel": "任意模型", + "addons_one": "{{count}} 个插件", + "addons_other": "{{count}} 个插件" + }, + "chips": { + "lastFailed": "上次运行失败" + }, + "tasks": { + "title": "智能体任务", + "allUsers": "所有用户", + "new": "新建任务", + "newTitle": "新建任务", + "editTitle": "编辑任务", + "name": "名称", + "description": "描述", + "runner": "执行者", + "byModel": "模型", + "byAgent": "你的某个智能体", + "model": "模型", + "agent": "智能体", + "pick": "请选择…", + "prompt": "提示词", + "promptHint": "参数写成 {{example}};每次运行都会询问它的值。", + "webhooksKept_one": "此任务有 {{count}} 个 Webhook,会保持不变;请通过 API 修改 Webhook。", + "webhooksKept_other": "此任务有 {{count}} 个 Webhook,会保持不变;请通过 API 修改 Webhook。", + "save": "保存任务", + "cancel": "取消", + "saved": "已保存任务“{{name}}”", + "runTitle": "运行 {{name}}", + "runNow": "立即运行", + "started": "已开始任务“{{name}}”", + "tasks": "任务", + "off": "已关闭", + "run": "运行", + "edit": "编辑", + "delete": "删除", + "deleted": "已删除任务“{{name}}”", + "task": "任务", + "cancelJob": "取消", + "rerun": "再次运行", + "rerunStarted": "已再次开始", + "stillRunning": "仍在运行。", + "noResult": "没有结果。", + "traces_one": "{{count}} 个步骤", + "traces_other": "{{count}} 个步骤", + "webhookSent": "已调用 Webhook。", + "webhookFailed": "Webhook 失败:{{message}}", + "actionFailed": "操作失败:{{message}}", + "state": { + "pending": "等待中", + "running": "运行中", + "completed": "已完成", + "failed": "失败", + "cancelled": "已取消" + }, + "lede": "由模型或你的某个智能体在后台运行的提示词。可以在这里启动,也可以在脚本中通过 API 启动。", + "searchLabel": "搜索任务", + "filter": { + "label": "显示任务", + "all": "全部", + "on": "已开启", + "off": "已关闭" + }, + "noMatch": "没有匹配的任务。", + "on": "已开启", + "offLine": "已关闭。开启之前不会运行。", + "onDemand": "在你启动时运行", + "asksFor": "需要 {{list}}", + "webhooks_one": "{{count}} 个 Webhook", + "webhooks_other": "{{count}} 个 Webhook", + "runsCount_one": "{{count}} 次运行", + "runsCount_other": "{{count}} 次运行", + "runsOf_one": "{{name}} 的 {{count}} 次运行", + "runsOf_other": "{{name}} 的 {{count}} 次运行", + "toggleLabel": "允许 {{name}} 运行", + "turnedOn": "“{{name}}”已开启", + "turnedOff": "“{{name}}”已关闭", + "moreFor": "{{name}} 的更多操作", + "open": "打开", + "openTask": "打开 {{name}}", + "runFor": "运行 {{name}}", + "rerunFor": "再次运行 {{name}}", + "jobMoreFor": "{{name}} 本次运行的更多操作", + "detailsFor": "{{name}} 运行详情", + "openChat": "打开对话", + "deleteJob": "删除运行记录", + "jobDeleted": "已删除运行记录", + "cancelled": "已停止运行", + "confirmJob": { + "title": "删除这次运行?", + "message": "它的回答和步骤会被删除,且无法撤销。" + }, + "line": { + "pending": "等待开始", + "running": "运行中", + "cancelled": "在完成前已停止" + }, + "startedBy": { + "manual": "在此处启动", + "api": "通过 API 启动", + "cron": "按计划启动" + }, + "output": { + "answer": "回答", + "error": "错误", + "values": "参数值" + }, + "traceKind": { + "reasoning": "思考", + "tool_call": "工具", + "tool_result": "工具结果", + "status": "状态" + }, + "empty": { + "title": "还没有任务", + "body": "任务是写一次、可多次运行的提示词。在会变化的地方写 {{example}},每次运行都会询问它的值。", + "fact1": "由模型或你的某个智能体运行", + "fact2": "在这里启动,或在脚本中通过 API 启动", + "fact3": "每次运行都会保留回答和步骤" + }, + "sheet": { + "optional": "(可选)", + "newDesc": "提示词只需写一次,需要时随时运行。", + "editDesc": "更改从下一次运行开始生效。", + "about": "基本信息", + "nameHelp": "脚本通过这个名称用 API 运行任务。", + "descHelp": "用一句话说明任务的用途。", + "who": "由谁运行", + "modelHelp": "模型在不使用工具的情况下回答提示词。", + "agentHelp": "智能体用它自己的指令和工具回答。", + "what": "做什么", + "promptPlaceholder": "例如:用三行总结工单 {{example}}。", + "inputs": "每次运行需要:", + "noInputs": "这个提示词不需要任何参数,每次运行都原样发送。", + "on": "开启", + "onHelp": "关闭的任务会保留运行记录,但无法启动。", + "runDesc": "填写这次运行使用的值。", + "runNone": "这个任务不需要任何参数,会原样运行提示词。", + "preview": "任务收到的内容" + }, + "errors": { + "name": "请给任务命名。", + "prompt": "请写下任务要做什么。", + "model": "请选择一个模型。", + "agent": "请选择一个智能体。" + }, + "pane": { + "label": "任务详情", + "overview": "概览", + "runs": "运行", + "settings": "设置", + "inputs": "需要", + "noInputs": "无", + "changed": "修改于", + "api": "通过 API 运行", + "apiHint": "以 JSON 对象发送参数值。响应中包含运行 ID,可用于查询结果。", + "noRuns": "还没有运行过。运行任务后,回答会显示在这里。", + "webhooks": "Webhook", + "noWebhooks": "无。通过 API 添加 Webhook,以便在运行结束时收到通知。", + "deleteTitle": "删除此任务", + "deleteHelp": "它的运行记录会保留到保存期结束。" + }, + "history": { + "title": "运行记录", + "filterLabel": "显示运行", + "all": "全部", + "active": "进行中", + "completed": "已完成", + "failed": "失败", + "cancelled": "已取消", + "allTasks": "所有任务", + "emptyBody": "运行一个任务后,回答会显示在这里。", + "more": "再显示 {{count}} 条" + } } } diff --git a/core/http/react-ui/public/locales/zh-CN/auth.json b/core/http/react-ui/public/locales/zh-CN/auth.json index 5ec188691..21a60bf8d 100644 --- a/core/http/react-ui/public/locales/zh-CN/auth.json +++ b/core/http/react-ui/public/locales/zh-CN/auth.json @@ -47,12 +47,15 @@ "account": { "title": "账户", "subtitle": "个人资料、凭证和 API 密钥", + "subtitleLocal": "您是谁,以及助手和 LocalAI 为您如何工作", "unavailable": "账户不可用", "unavailableText": "必须启用身份验证才能管理您的账户。", "tabs": { "profile": "个人资料", "security": "安全", - "apiKeys": "API 密钥" + "apiKeys": "API 密钥", + "about": "关于您和助手", + "appearance": "外观" }, "profile": { "displayName": "显示名称", @@ -112,6 +115,9 @@ "pausedToast": "API key paused", "resumedToast": "API key resumed", "pauseFailed": "Failed to update API key: {{message}}" + }, + "usage": { + "loadFailedTitle": "无法加载你的用量。" } }, "notFound": { diff --git a/core/http/react-ui/public/locales/zh-CN/chat.json b/core/http/react-ui/public/locales/zh-CN/chat.json index 56381d065..6457c45e1 100644 --- a/core/http/react-ui/public/locales/zh-CN/chat.json +++ b/core/http/react-ui/public/locales/zh-CN/chat.json @@ -25,22 +25,15 @@ "yes": "是", "gpuLayers": "GPU 层数" }, - "context": { - "label": "上下文:{{percent}}%", - "labelWithTokens": "上下文:{{percent}}% ({{tokens}} 个 token)" - }, "settings": { "title": "聊天设置", "manageMode": "管理模式", - "manageModeDesc": "允许此聊天通过与 LocalAI 对话来安装模型、切换后端和编辑配置。", - "systemPrompt": "系统提示", - "systemPromptPlaceholder": "你是一个有用的助手...", - "temperature": "温度", - "topP": "Top P", - "topK": "Top K", - "contextSize": "上下文大小", - "contextSizePlaceholder": "2048", - "clearHistory": "清除聊天记录" + "serverKept": "服务器保存此对话。模型和智能体在开始时确定。", + "manageModeOn": "此对话拥有 LocalAI 管理工具。使用 /assistant 开始新的对话。", + "agent": "智能体", + "agentHelp": "此对话使用智能体 {{name}}:它的指令以及它授予的工具。", + "editAgent": "编辑智能体", + "deleteHelp": "永久删除此对话及其消息。" }, "empty": { "manageTitle": "通过聊天管理 LocalAI", @@ -61,7 +54,6 @@ "头脑风暴" ], "recent": "最近", - "noMessages": "暂无消息", "hintEnter": "回车发送", "hintShiftEnter": "Shift+回车换行", "hintAttach": "附加文件" @@ -71,29 +63,17 @@ }, "actions": { "copy": "复制", - "edit": "编辑", - "editMessage": "编辑消息", - "save": "保存", - "cancel": "取消", "regenerate": "重新生成", - "jumpToLatest": "Jump to latest" + "jumpToLatest": "Jump to latest", + "reuse": "编辑后重新发送" }, "streaming": { "transferring": "正在传输模型...", "transferringTo": "正在传输模型到 {{node}}..." }, - "tokens": { - "perSec": "{{count}} tok/s", - "peak": "峰值:{{count}} tok/s", - "usage": "{{prompt}}p + {{completion}}c = {{total}}" - }, "input": { - "placeholder": "消息...", - "attachFile": "附加文件", "stopGenerating": "停止生成", - "canvasTitle": "Canvas — 将代码块和媒体提取到侧边栏以便预览、复制和下载", - "canvasLabel": "Canvas", - "openCanvas": "打开 Canvas 面板" + "placeholderQueue": "模型正在工作。现在发送的消息会等待它的下一步。" }, "deleteAllDialog": { "title": "删除所有聊天", @@ -104,7 +84,7 @@ "selectModel": "请选择一个模型", "copied": "已复制到剪贴板", "copyFailed": "无法复制到剪贴板", - "pdfScannedAsImages": "{{name}} 没有文本层(扫描件)。已将 {{total}} 页中的 {{count}} 页作为图片附加,供视觉模型使用。" + "newChatForModel": "对话会保留其模型。已使用 {{model}} 开始新对话。" }, "menu": { "trigger": "聊天", @@ -118,17 +98,492 @@ "deleteChat": "删除聊天", "newChat": "新对话", "clearAll": "清除全部", - "deleteAllTitle": "删除所有对话" + "deleteAllTitle": "删除所有对话", + "untitled": "新对话", + "origin": { + "trigger": "由触发器启动", + "connector": "来自连接器" + } }, "message": { - "you": "You" + "you": "You", + "loopRun": "循环第 {{run}} 次" }, - "addons": { - "note": "An add-on that is off here does nothing in this chat.", - "label": "Add-ons", - "title": "Add-on tools for this chat", - "heading": "Add-on tools", - "selectAll": "Select all", - "deselectAll": "Deselect all" + "tools": { + "label": "工具", + "title": "此对话可以使用的工具", + "heading": "此对话的工具", + "draftNote": "新聊天将按这些开关开始。关闭的工具不会提供给模型。", + "note": "在此关闭的工具不会在此对话中提供给模型。", + "count": "已开启 {{on}}/{{total}}", + "selectAll": "全部开启", + "disableAll": "全部关闭", + "groupOn": "全开", + "groupOff": "全关", + "groupOnLabel": "开启{{group}}中的全部", + "groupOffLabel": "关闭{{group}}中的全部", + "groupCount": "{{on}}/{{total}}", + "optIn": "需手动开启。“全部开启”不会开启它。", + "empty": "此对话中没有可切换的项。", + "failed": "无法保存。开关已恢复原状。", + "unavailable": "此对话中不可用", + "toolCount_one": "{{count}} 个工具", + "toolCount_other": "{{count}} 个工具" + }, + "skills": { + "label": "技能", + "manage": "管理技能", + "title": "此对话可以使用的技能", + "heading": "此对话的技能", + "count": "已开启 {{on}} / {{total}}", + "selectAll": "全选", + "disableAll": "全部关闭", + "selectShown": "选择当前显示的", + "disableShown": "关闭当前显示的", + "search": "搜索技能", + "noMatch": "没有与“{{query}}”匹配的技能。", + "later": "之后添加的技能默认开启。", + "failed": "保存失败。未作任何更改。", + "source": { + "builtin": "内置", + "personal": "个人", + "shared": "共享", + "folder": "工作区", + "addon": "{{name}}" + }, + "replacedBy": "已被 {{name}} 替换", + "fallback_one": "正在使用内置 {{skills}}:{{name}} 插件自己的技能在此处已关闭或未运行。", + "fallback_other": "正在使用内置 {{skills}}:{{name}} 插件自己的技能在此处已关闭或未运行。" + }, + "agent": { + "label": "智能体", + "title": "此对话的已保存智能体", + "heading": "使用智能体开始", + "note": "智能体为对话提供其指令以及它授予的工具。", + "none": "不使用智能体", + "fixed": "此对话的智能体" + }, + "assistant": { + "chatTitle": "LocalAI 助手" + }, + "slash": { + "delete": { + "label": "删除对话", + "desc": "删除此对话" + }, + "assistant": { + "desc": "开始一个可以管理 LocalAI 的对话" + }, + "settings": { + "desc": "专注模式、智能体和模型信息" + }, + "group": { + "context": "上下文", + "skills": "技能和智能体", + "help": "帮助" + }, + "models": { + "label": "模型", + "desc": "打开模型列表" + }, + "skill": { + "label": "运行技能", + "desc": "选择此对话的一个技能" + }, + "skills": { + "label": "管理技能", + "desc": "打开技能页面来编写和修改技能" + }, + "agent": { + "label": "交给已保存的智能体", + "desc": "把任务交给你的一个智能体", + "enable": "为此对话开启已保存的智能体", + "enableDesc": "然后选择智能体" + }, + "compact": { + "label": "压缩上下文", + "desc": "总结较早的消息,为模型腾出空间" + }, + "clear": { + "label": "清空上下文", + "desc": "在此对话中重新开始,消息保留" + }, + "attach": { + "label": "添加附件", + "desc": "选择要发送的文件", + "clearDesc": "移除等待发送的文件" + }, + "help": { + "label": "全部命令", + "desc": "列出每个命令及其作用" + }, + "about": { + "label": "关于此对话", + "desc": "模型、工具、技能和上下文大小" + }, + "args": { + "skill": "技能", + "agent": "已保存的智能体", + "model": "模型", + "attach": "文件", + "loop": "循环", + "goal": "目标" + }, + "badge": { + "builtin": "内置", + "addon": "附加组件", + "shared": "共享", + "yours": "你的" + }, + "collides": "仅通过 /skill", + "errors": { + "skill": "此对话没有名为“{{name}}”的技能。", + "agent": "你没有名为“{{name}}”的已保存智能体。", + "agentTask": "请在 {{name}} 名称后写下任务。", + "agentsOff": "此对话已关闭已保存的智能体。请在“工具”菜单中开启。" + }, + "loop": { + "label": "重复提示词", + "desc": "在此聊天中反复运行一个提示词:/loop 5m 检查构建", + "listDesc": "显示此聊天的循环", + "stopDesc": "停止一个循环或全部循环", + "stopAll": "所有循环", + "stopAllDesc": "停止此聊天的所有循环" + }, + "goal": { + "label": "目标", + "desc": "设定一个聊天会自己持续推进的目标", + "clearDesc": "清除目标", + "resumeDesc": "继续推进目标" + } + }, + "shortcuts": { + "onMessage": "在消息上:复制、编辑、重新生成", + "editLast": "编辑你的最后一条消息" + }, + "files": { + "title": "此对话中的文件", + "chip": "文件", + "chipTitle": "此对话中的文件:你附加的文件,以及助手创建或修改的文件", + "close": "关闭文件", + "emptyTitle": "此对话中还没有文件", + "emptyBody": "将文件拖到这里,即可附加到下一条消息。助手创建或修改的文件也会显示在这里。", + "dropHint": "将文件拖到这里,即可附加到下一条消息。", + "groups": { + "uploaded": "你提供的文件", + "created": "创建和修改的文件", + "working": "工作文件" + }, + "turn": "第 {{n}} 轮", + "media": "在上下文中", + "mediaTitle": "模型已随你的消息以图片或视频形式收到此文件", + "changedTag": "已编辑", + "changedTitle": "助手修改了此文件", + "download": "下载 {{name}}", + "error": "无法读取此对话的文件。", + "workingNote": "助手在处理过程中生成的脚本、数据和日志,保存在此对话的工作文件夹中。" + }, + "steps": { + "verb": { + "read": "已读取", + "list": "已列出", + "write": "已保存", + "create": "已创建", + "change": "已修改", + "move": "已移动", + "delete": "已删除", + "search": "已搜索", + "run": "已运行", + "fetch": "已获取", + "websearch": "已在网上搜索", + "webfetch": "已读取", + "delegate": "已询问智能体", + "used": "已使用" + }, + "worked": "工作了 {{duration}}", + "thoughtFor": "思考了 {{duration}}", + "working": "工作中", + "thinking": "思考中", + "noMatches": "无结果", + "noResults": "无结果", + "emptyFolder": "空文件夹", + "size": "{{size}}", + "seconds": "{{value}} 秒", + "minutes": "{{min}} 分 {{sec}} 秒", + "tool": "工具", + "input": "输入", + "showLess": "收起", + "running": "运行中", + "failed": "失败", + "count_one": "{{count}} 步", + "count_other": "{{count}} 步", + "files_one": "{{count}} 个文件", + "files_other": "{{count}} 个文件", + "items_one": "{{count}} 项", + "items_other": "{{count}} 项", + "matches_one": "{{count}} 个结果", + "matches_other": "{{count}} 个结果", + "results_one": "{{count}} 个结果", + "results_other": "{{count}} 个结果", + "lines_one": "{{count}} 行", + "lines_other": "{{count}} 行", + "showAll_one": "显示全部 {{count}} 行", + "showAll_other": "显示全部 {{count}} 行", + "details": "显示详情", + "openFile": "在文件中打开 {{path}}", + "savedFiles": "已保存到你的文件:", + "fileOp": { + "changed": "已更改", + "deleted": "已删除" + }, + "moreFiles": "还有 {{count}} 个", + "alsoSavedWorking_one": "另外保存了 {{count}} 个工作文件", + "alsoSavedWorking_other": "另外保存了 {{count}} 个工作文件", + "savedWorking_one": "保存了 {{count}} 个工作文件", + "savedWorking_other": "保存了 {{count}} 个工作文件", + "workingTitle": "工作文件(脚本、日志或草稿):打开 {{path}}" + }, + "error": { + "title": "回复失败", + "kept": "到目前为止写下的内容保留在上方。", + "retry": "重试", + "detail": "技术详情", + "reason": { + "unknown": "智能体因错误而停止。请重试以再次运行该轮。", + "model_backend_missing": "无法启动模型“{{model}}”:其后端未安装。", + "model_backend_missing_anon": "无法启动模型:其后端未安装。", + "model_cooldown": "模型“{{model}}”刚才启动失败,正在冷却。", + "model_cooldown_anon": "模型刚才启动失败,正在冷却。", + "model_load_failed": "无法启动模型“{{model}}”。", + "model_load_failed_anon": "无法启动模型。", + "model_not_found": "找不到模型“{{model}}”。", + "model_not_found_anon": "找不到该模型。", + "upstream_key": "上游拒绝了该密钥。", + "upstream_path": "上游在该地址不接受此请求。", + "upstream_unreachable": "无法连接到上游地址。", + "upstream": "模型背后的提供方返回了错误。", + "timeout": "模型响应时间过长。", + "cancelled": "在模型回答之前,该轮已被取消。" + }, + "hint": { + "model_backend_missing": "请选择其他模型,或请管理员安装该后端。", + "model_backend_missing_admin": "请选择其他模型,或在“后端”页面安装该后端。", + "model_cooldown": "请在 {{seconds}} 秒后重试。", + "model_load_failed": "请检查模型的配置及其后端,然后重试。", + "model_not_found": "请选择其他模型,或在“模型”页面安装此模型。", + "upstream_key": "请检查模型设置中的 API 密钥。", + "upstream_path": "上游地址必须是完整的端点,例如 .../v1/chat/completions。", + "upstream_unreachable": "请检查模型设置中的地址。", + "upstream": "请稍后重试。如果持续失败,请检查提供方的地址和密钥。", + "timeout": "请重试,或使用更小或更快的模型。", + "cancelled": "准备好后请重试。" + }, + "openBackends": "打开后端" + }, + "allChats": { + "title": "全部对话", + "supporting": "你保存的所有对话。可以搜索、重命名、导出或删除。", + "loadFailed": "无法加载你的对话。", + "emptyTitle": "还没有对话", + "emptyBody": "对话从首页开始:输入消息并发送。之后你的对话会列在这里。", + "start": "开始对话" + }, + "foot": { + "state": { + "ready": "就绪", + "nomodel": "没有模型", + "sending": "发送中", + "loading": "正在加载模型", + "thinking": "思考中", + "tool": "正在运行 {{tool}}", + "answering": "回答中", + "waiting": "等待你操作", + "stopped": "已停止", + "failed": "失败", + "interrupted": "已中断", + "paused": "已暂停", + "compacting": "正在压缩上下文", + "clearing": "正在清空上下文" + }, + "tool": { + "search": "正在搜索", + "list": "正在列出", + "read": "正在读取", + "create": "正在创建", + "write": "正在写入", + "change": "正在修改", + "move": "正在移动", + "delete": "正在删除", + "run": "正在运行命令", + "fetch": "正在获取", + "delegate": "正在询问子代理" + }, + "hint": "Enter 发送 · Shift+Enter 换行", + "meter": { + "label": "上下文已用 {{pct}}%。查看上下文和速度", + "labelNoLimit": "上下文 {{used}} 个 token。查看上下文和速度" + }, + "details": { + "title": "上下文和速度", + "context": "上下文", + "contextOf": "{{used}} / {{limit}} 个 token({{pct}}%)", + "contextUsed": "{{used}} 个 token", + "contextLeft": "剩余 {{left}} 个 token", + "contextHelp": "上下文是模型每次请求时读取的内容:指令、目前为止的对话以及工具结果。", + "nearFull": "此对话接近上限。服务器会压缩较早的部分,让模型可以继续。对话记录会保留每条消息。", + "limitUnknown": "服务器没有说明上下文窗口有多大。", + "lastRequest": "上一次请求", + "model": "模型", + "backend": "后端", + "node": "节点", + "tokensIn": "输入 token", + "tokensOut": "输出 token", + "firstToken": "首个 token 用时", + "promptTime": "提示词处理", + "speed": "速度", + "speedLive": "速度(流式输出时估算)", + "average": "最近 {{count}} 次请求的平均值", + "note": "这些数字来自本页面看到的请求。服务器不保存它们,刷新页面后会清空。" + }, + "paused": { + "after": "已执行 {{count}} 步", + "atLimit": "已达步数上限", + "continue": "继续", + "continueTitle": "模型已用完一轮的全部步数。让它从停下的地方继续。", + "message": "请从你停下的地方继续。" + } + }, + "attachments": { + "label": "附件", + "open": "打开 {{name}}", + "showMore": "再显示 {{count}} 个", + "missing": "文件已不可用" + }, + "preview": { + "open": "打开预览", + "created": "由助手创建", + "changed": "由助手修改", + "yours": "你提供的", + "fromReply": "来自回答", + "download": "下载", + "downloadCode": "下载代码", + "copy": "复制", + "copied": "已复制", + "openInFiles": "在文件中打开", + "actions": "文件操作", + "opened": "已打开预览:{{name}}", + "chip": { + "created": "已创建", + "changed": "已修改", + "label": "{{verb}} {{name}},打开预览", + "row": "本回答的文件" + } + }, + "meter": { + "usedOf": "{{used}} / {{limit}} 个 token({{pct}}%)", + "used": "{{used}} 个 token", + "speed": "{{speed}} token/秒", + "title": "上一次模型请求使用的上下文", + "ofLimit": "{{used}} / {{limit}} 个 token", + "left": "剩余 {{left}}" + }, + "goal": { + "label": "目标", + "states": { + "active": "进行中", + "paused": "已暂停", + "done": "已达成", + "blocked": "需要你" + }, + "pausedBy": { + "user": "你暂停了它。", + "turn_limit": "已用完所有自动轮次。继续可获得更多轮次。", + "step_limit": "已用完所有步骤。继续可获得更多步骤。", + "error": "某一轮失败了。继续即可重试。", + "interrupted": "服务器在某一轮中停止了。继续即可接着进行。" + }, + "blockedNote": "需要你提供:{{note}}", + "doneNote": "{{note}}", + "replaced": "已替换:{{text}}", + "pause": "暂停", + "pauseTitle": "停止当前这一轮并暂停目标", + "resume": "继续", + "resumeTitle": "继续推进目标", + "clear": "清除", + "clearTitle": "清除目标", + "progress": "目标 · 第 {{turn}} 轮,共 {{max}} 轮", + "progressStart": "目标 · 正在开始", + "markers": { + "set": "已设定目标", + "replaced": "已替换目标", + "continue": "继续推进目标 · 第 {{turn}} 轮", + "continueStart": "继续推进目标", + "paused": "目标已暂停", + "resumed": "目标已继续", + "done": "目标已达成", + "blocked": "目标受阻", + "cleared": "目标已清除" + }, + "announce": { + "set": "已设定目标:{{text}}", + "paused": "目标已暂停。{{why}}", + "resumed": "目标已继续", + "done": "目标已达成:{{note}}", + "blocked": "目标需要你:{{note}}", + "cleared": "目标已清除" + }, + "card": { + "title": "目标", + "none": "这个聊天还没有目标。输入 /goal 和你想完成的事,聊天会自己持续推进。", + "turns": "第 {{turn}} 轮,共 {{max}} 轮", + "steps": "{{steps}} / {{max}} 步", + "clear": "/goal clear 可清除它。", + "resume": "/goal resume 可继续它。", + "turnsLabel": "自动轮次", + "stepsLabel": "步骤", + "setLabel": "设定时间", + "stateLabel": "状态", + "noteLabel": "说明" + }, + "errors": { + "empty": "请在 /goal 后写下目标。", + "notAllowed": "只有你自己开始的聊天才能设定目标。" + } + }, + "invoked": { + "skill": "技能:{{name}}", + "agent": "智能体:{{name}}" + }, + "marker": { + "compacted": "上下文已压缩:从 {{before}} 到 {{after}} 个 token", + "cleared": "上下文已清空", + "clearedHint": "模型看不到此线以上的消息。" + }, + "cmdcard": { + "close": "关闭", + "helpTitle": "命令", + "helpSkills_one": "技能:{{names}}。输入 / 和名称。", + "helpSkills_other": "{{count}} 个技能:{{names}}。输入 / 和名称。", + "helpKeys": "在输入框中输入 / 打开列表。Tab 补全,Enter 运行,Esc 关闭。", + "aboutTitle": "此对话", + "model": "模型", + "state": "状态", + "loaded": "已加载", + "notLoaded": "未加载", + "backend": "后端", + "tools": "工具", + "toolsOn_one": "已开启 {{count}} 个", + "toolsOn_other": "已开启 {{count}} 个", + "skills": "技能", + "skillsOn_one": "{{count}} 个技能", + "skillsOn_other": "{{count}} 个技能", + "commands": "命令", + "isolated": "在隔离的沙箱中运行", + "host": "在本机上运行", + "noCommands": "无法运行", + "context": "上下文", + "contextOf": "{{used}} / {{limit}} 个 token", + "contextUsed": "{{used}} 个 token", + "unknown": "尚不清楚" } } diff --git a/core/http/react-ui/public/locales/zh-CN/chats.json b/core/http/react-ui/public/locales/zh-CN/chats.json new file mode 100644 index 000000000..c4c7f1197 --- /dev/null +++ b/core/http/react-ui/public/locales/zh-CN/chats.json @@ -0,0 +1,581 @@ +{ + "proposal": { + "loading": "正在读取建议的触发器...", + "unread": "无法读取建议的触发器:{{message}}", + "removed": "此触发器已被删除。", + "retry": "重试", + "inactive": "建议的触发器。在你创建它之前不会运行任何内容。", + "name": "名称", + "schedule": "何时(例如 daily 08:00)", + "folder": "要监视的文件夹", + "saveEdit": "保存", + "cancelEdit": "取消", + "create": "创建触发器", + "edit": "编辑", + "dismiss": "拒绝", + "receipt": { + "scheduled": "已安排:", + "watching": "监视中:", + "next": "下次运行 {{when}}", + "view": "查看触发器", + "undo": "撤销", + "undone": "已撤销:", + "again": "重新安排", + "declined": "已拒绝:", + "restore": "恢复", + "blocked": { + "ran": "它已经运行过一次;请在触发器页面中删除。", + "edited": "之后你编辑过它;请在触发器页面中修改或删除。" + } + }, + "announce": { + "accepted": "已安排 {{name}}。", + "undone": "已撤销 {{name}}。它未被安排。", + "declined": "已拒绝 {{name}}。", + "reopened": "{{name}} 再次等待你的答复。" + }, + "plan": { + "whenLabel": "时间", + "runsAsLabel": "运行身份", + "readsLabel": "读取", + "writesLabel": "写入", + "costLabel": "成本", + "stopLabel": "如何停止", + "when": "{{schedule}},{{zone}}。首次运行 {{when}}。", + "whenNoFirst": "{{schedule}},{{zone}}。", + "whenFile": "当有文件进入 {{folder}} 时。", + "runsAs": "你", + "runsAsModel": "你,使用 {{model}}", + "writesWidget": ",显示在主页的“{{widget}}”小组件中", + "costLocal_other": "在本地模型上每次运行约 {{tokens}} 个 token。每天最多 {{count}} 次。", + "costRemote_other": "在远程服务 {{model}} 上每次运行约 {{tokens}} 个 token。每天最多 {{count}} 次。", + "costUnknown_other": "每次运行的 token 数在首次运行后可知。每天最多 {{count}} 次。", + "costLocal_one": "在本地模型上每次运行约 {{tokens}} 个 token。每天最多 {{count}} 次。", + "costRemote_one": "在远程服务 {{model}} 上每次运行约 {{tokens}} 个 token。每天最多 {{count}} 次。", + "costUnknown_one": "每次运行的 token 数在首次运行后可知。每天最多 {{count}} 次。", + "remoteNote": "{{model}} 是远程服务:运行读取的内容会离开此服务器。", + "stopWidget": "在小组件中暂停,或在触发器中删除", + "stop": "在触发器中关闭或删除" + } + }, + "mcpPage": { + "title": "MCP 连接器", + "supporting": "连接 MCP 服务器,为你的对话和智能体提供更多工具,例如搜索或你自己的 API。", + "family": "Telegram 或 Slack 机器人属于消息连接器。", + "related": "相关", + "chats": "对话", + "add": "添加连接器", + "learn": "MCP 如何工作", + "loading": "正在加载你的连接器", + "loadFailed": "无法加载你的连接器。", + "forbidden": "你的账户无法使用 MCP。", + "empty": { + "title": "还没有连接器", + "body": "连接器把一个 MCP 服务器接入 LocalAI。之后你的对话和智能体就能使用它的工具,例如搜索你的文档或读取日历。", + "fact1": "支持任何 http 或 https 的 MCP 服务器", + "fact2": "令牌和请求头加密保存", + "fact3": "在对话使用前先测试" + }, + "toolbar": { + "search": "搜索连接器", + "testAll": "全部测试", + "testingAll": "正在测试", + "testedAll": "已测试所有已开启的连接器。" + }, + "filter": { + "label": "显示", + "all": "全部", + "on": "已开启", + "attention": "需要处理", + "off": "已关闭", + "showAll": "显示全部", + "none": { + "all": "没有连接器。", + "on": "没有开启的连接器。", + "attention": "没有需要处理的连接器。", + "off": "所有连接器都已开启。" + } + }, + "noMatch": { + "title": "没有与“{{query}}”匹配的连接器", + "body": "按名称或地址搜索。", + "clear": "清除搜索" + }, + "state": { + "label": "状态:{{state}}", + "ok": "已连接", + "auth": "需要登录", + "error": "无法连接", + "unchecked": "未测试", + "off": "已关闭" + }, + "row": { + "open": "{{name}},{{state}}。查看详情", + "toggle": "在对话中使用 {{name}}", + "menu": "{{name}} 的操作", + "offLine": "已关闭。对话和智能体不会使用它。", + "notChecked": "尚未测试。测试后可查看它的工具。", + "noTools": "已连接,但服务器没有提供工具。", + "tools_one": "{{count}} 个工具", + "tools_other": "{{count}} 个工具", + "checked": "测试于{{when}}", + "tokenSaved": "已保存令牌", + "headersSaved": "已保存请求头", + "envSaved": "已保存变量" + }, + "actions": { + "details": "详情", + "test": "测试连接", + "testNamed": "测试与 {{name}} 的连接", + "testing": "正在测试", + "edit": "编辑", + "reauth": "更换令牌", + "remove": "移除" + }, + "details": { + "use": "在对话中使用", + "useHelp": "没有智能体的对话会使用所有已开启的连接器。已保存的智能体只使用你为它选择的连接器。", + "lastCheck": "上次测试", + "never": "尚未在此浏览器中测试。", + "okAt": "{{when}}连接成功。", + "testedAt": "测试于{{when}}。", + "errorTitle": "上次测试无法连接。", + "authTitle": "服务器拒绝了登录。", + "tools": "工具", + "toolsUnknown": "测试连接后可查看工具。", + "toolsNone": "服务器没有提供工具。", + "toolsSearch": "搜索工具", + "noToolMatch": "没有与“{{query}}”匹配的工具。", + "noDescription": "没有描述。", + "connection": "连接", + "kind": "类型", + "address": "地址", + "command": "命令", + "added": "添加时间", + "queryHidden": "地址中 ? 之后的部分已隐藏,因为其中可能含有密钥。", + "signIn": "登录", + "token": "Bearer 令牌", + "headers": "请求头", + "env": "变量", + "saved": "已保存", + "none": "无", + "encrypted": "在服务器上加密保存,不会再次显示。" + }, + "sheet": { + "addTitle": "添加连接器", + "editTitle": "编辑 {{name}}", + "addDesc": "连接一个 MCP 服务器。在对话使用前先测试它。", + "editDesc": "已保存的密钥会保留,除非你更换或移除它们。", + "testDesc": "LocalAI 使用你保存的设置进行连接,并列出工具。", + "close": "关闭", + "kind": "类型", + "remote": "远程服务器", + "remoteHelp": "这台机器通过 http 或 https 访问的 MCP 服务器。", + "local": "本地命令", + "localHelp": "LocalAI 在这台机器上启动的程序。仅限管理员。", + "basics": "连接", + "name": "名称", + "nameHelp": "显示在对话的“工具”菜单中。", + "url": "地址", + "urlHelp": "私有网络中的服务器会被拒绝,除非管理员允许。", + "command": "命令", + "commandHelp": "要启动的程序,不经过 shell。", + "args": "参数", + "argsHelp": "每行一个参数。", + "signIn": "登录", + "signInHelp": "仅在服务器要求密钥时填写。", + "envIntro": "命令获得的变量,例如 API 密钥。", + "token": "Bearer 令牌", + "tokenHelp": "作为 Authorization 请求头发送。", + "tokenSaved": "已保存一个令牌。输入新令牌即可替换。", + "tokenSavedShort": "已保存", + "clearToken": "移除已保存的令牌", + "headers": "请求头", + "headersHelp": "服务器需要的其他请求头,例如包含 API 密钥的请求头。", + "headersSaved": "已保存请求头。新的行会替换全部请求头。", + "clearHeaders": "移除已保存的请求头", + "headerName": "请求头名称", + "headerValue": "值", + "addHeader": "添加请求头", + "env": "环境变量", + "envSaved": "已保存变量。新的行会替换全部变量。", + "clearEnv": "移除已保存的变量", + "envName": "变量", + "addEnv": "添加变量", + "removeRow": "移除第 {{n}} 行", + "showValue": "显示值", + "hideValue": "隐藏值", + "encrypted": "令牌、请求头和变量在服务器上加密保存,不会再次显示。", + "cancel": "取消", + "saving": "正在保存", + "saveAndTest": "保存并测试", + "savedOn": "已保存,已开启。", + "savedOff": "已保存。在你开启之前保持关闭。", + "testing": "正在连接 {{name}}…", + "okTitle": "已连接。", + "okBody_one": "{{name}} 提供 {{count}} 个工具。", + "okBody_other": "{{name}} 提供 {{count}} 个工具。", + "okNoTools": "服务器没有提供工具。", + "failTitle": "无法连接。", + "failHint": "请检查地址,然后再次测试。", + "failAuth": "请检查令牌或请求头。", + "back": "编辑信息", + "retry": "再次测试", + "done": "完成", + "keepOff": "保持关闭", + "turnOn": "开启", + "discardTitle": "放弃更改?", + "discardBody": "你在此表单中输入的内容将会丢失。", + "discard": "放弃", + "keepEditing": "继续编辑" + }, + "errors": { + "nameRequired": "请输入名称。", + "nameTaken": "已有其他连接器使用此名称。", + "urlRequired": "请输入服务器地址。", + "urlInvalid": "这不是有效的地址。", + "urlScheme": "地址必须以 http:// 或 https:// 开头。", + "urlCredentials": "请把凭据放在令牌或请求头中,不要放在地址里。", + "commandRequired": "请输入要启动的命令。" + }, + "remove": { + "title": "移除 {{name}}?", + "body": "连接器及其已保存的令牌、请求头和变量都会被删除。使用它的已保存智能体将无法再调用它的工具。", + "confirm": "移除", + "pending": "正在移除" + }, + "toasts": { + "removed": "已移除“{{name}}”。", + "on": "“{{name}}”已开启。", + "off": "“{{name}}”已关闭。" + }, + "import": { + "title": "保存在此浏览器中的服务器", + "text": "旧的对话页面把这些 MCP 服务器保存在此浏览器中。导入后它们会保存在服务器上,任何浏览器和会话都能使用。导入的服务器处于关闭状态。它们的令牌和请求头会发送到服务器,然后从此浏览器中移除。", + "button": "从此浏览器导入 {{count}} 个服务器", + "buttonOne": "从此浏览器导入 1 个服务器", + "busy": "正在导入", + "headersCount": "{{count}} 个请求头", + "done": "已导入 {{count}} 个。它们处于关闭状态:请在下方测试并开启。", + "failed": "{{name}} 未导入:{{error}}", + "left": "未导入的服务器仍保留在此浏览器中。" + } + }, + "connectorsPage": { + "loadFailed": "无法加载你的连接器。", + "waitingTitle": "等待对应的插件", + "waitingText": "这些绑定会被保留,但要等对应插件安装并开启后才会运行。" + }, + "tools": { + "commands": { + "title": "此处无法运行命令", + "none": "未安装可运行代码的插件,因此助手可以写入文件,但无法运行脚本或命令。", + "off": "此对话已关闭 Sandbox 插件。在“工具”菜单中开启后,助手即可运行代码。", + "unavailable": "Sandbox 插件已安装,但无法在此服务器上运行命令。", + "unavailableWhy": "Sandbox 插件无法在此服务器上运行命令:{{reason}}", + "install": "安装 Sandbox", + "openSandbox": "打开 Sandbox 插件", + "askInstall": "请联系管理员安装 Sandbox 插件。", + "askFix": "请联系管理员修复。", + "hide": "隐藏", + "hideLabel": "隐藏此提示", + "chipTitle": "此对话无法运行代码或命令。选择以查看详情。", + "hostTitle": "命令在这台机器上运行", + "host": "命令在这台机器上运行,可访问其文件和网络,没有隔离。安装 Sandbox 以隔离运行。", + "hostOff": "此聊天已关闭 Sandbox,因此命令在这台机器上运行,可访问其文件和网络,没有隔离。在此菜单中打开 Sandbox 以隔离运行。", + "hostUnavailable": "Sandbox 无法在此运行,因此命令在这台机器上运行,可访问其文件和网络,没有隔离。", + "hostUnavailableWhy": "Sandbox 无法在此运行({{reason}}),因此命令在这台机器上运行,可访问其文件和网络,没有隔离。", + "hostChipTitle": "此聊天中的命令在这台机器上运行,没有隔离。选择以查看详情。" + }, + "consent": { + "title": "在这台机器上运行此命令?", + "body": "命令在这台机器上运行,没有隔离。它们可以读取 LocalAI 能读取的文件,并访问互联网和你的网络。安装 Sandbox 以隔离运行。", + "once": "运行一次", + "always": "在此实例上始终允许", + "deny": "不运行", + "alwaysHint": "“在此实例上始终允许”会让所有用户的聊天无需询问即可在此运行命令(设置 > 工具)。", + "askAdmin": "只有管理员可以允许在这台机器上运行命令。请询问管理员,或自己运行该命令。", + "expired": "未回应:提出请求的这一轮已结束,因此命令没有运行。" + }, + "provider": { + "sandbox": "Sandbox", + "addon": "插件 {{name}}", + "host": "这台机器,未隔离", + "hostOnce": "这台机器,已允许一次", + "hostDenied": "未运行:已拒绝", + "hostTimeout": "未运行:无应答", + "hostAdmin": "未运行:需要管理员" + }, + "builtins": "内置工具", + "builtin": { + "web": "网页", + "saved_agents": "已保存的智能体", + "triggers": "触发器", + "shell": "运行命令", + "files": "你的文件", + "media": "图片和音频", + "collections": "知识库", + "subagents": "子代理" + }, + "builtinBy": "{{tools}} · 内置", + "builtinByAddon": "{{tools}} · 由 {{provider}} 提供", + "servedBy": "由附加组件 {{provider}} 提供", + "servedByTool": "{{tool}}: {{provider}}", + "servedByBuiltin": "内置", + "sandbox": { + "starting": "Sandbox 正在启动。LocalAI 将在 {{seconds}} 秒后再次检查。" + }, + "fallbackBuiltin": "{{addon}} 替代了 {{tool}},但现在无法为此聊天提供服务(已停止、在此关闭或未授权):使用内置 {{tool}}。", + "fallbackNone": "{{addon}} 替代了 {{tool}},但现在无法为此聊天提供服务,且内置 {{tool}} 已关闭:此聊天没有 {{tool}}。", + "shell": { + "machine": "本机", + "isolated": "命令在隔离环境中运行" + } + }, + "vision": { + "describedBy": "此模型无法查看图片。请选择其他模型,否则图片将由 {{model}} 描述。", + "none": "此模型无法查看图片,这里也没有模型可以描述图片。请选择一个可以查看图片的模型。" + }, + "egress": { + "label": "网络", + "preset": { + "off": "关闭", + "registries": "软件包仓库", + "all": "所有公共网站" + }, + "presetHint": { + "off": "沙盒中的命令无法访问任何网络。", + "registries": "命令只能访问软件包仓库(PyPI、npm、Go、crates.io、RubyGems、Maven Central、GitHub 发布)。", + "all": "命令可以访问所有公共网站。" + }, + "limited": "受管理员限制", + "limitedBy": "管理员最多允许:{{limit}}。", + "hostNetwork": "命令在这台机器上运行,并使用它的网络。网络限制需要 Sandbox 插件。", + "panel": "规则和请求({{count}})", + "rules": "此聊天的规则", + "cards": "此聊天的请求", + "ruleAllow": "在此聊天中允许", + "ruleDeny": "在此聊天中拒绝", + "aboveLimit": "超出限制,不生效", + "remove": "移除", + "removeLabel": "移除 {{host}} 的规则", + "card": { + "pending": "等待回答", + "allowedOnce": "已允许一次", + "allowedChat": "在此聊天中允许", + "denied": "已拒绝", + "expired": "未回答", + "refused": "已拒绝:服务器的网络限制不允许" + }, + "consent": { + "title": "网络访问", + "body": "此聊天中的一条命令想要访问", + "once": "允许一次", + "chat": "在此聊天中允许", + "deny": "拒绝", + "preset": "此聊天:{{preset}}。管理员设置的限制:{{limit}}。", + "adminOnly": "此主机位于你的内部网络中:只有管理员可以允许。", + "answered": "{{who}}{{state}},{{time}}", + "answeredNoWho": "{{state}},{{time}}", + "you": "你" + }, + "blocked": { + "titleOff": "网络访问已关闭", + "titleCapped": "网络访问受到限制", + "bodyOff": "此服务器已关闭沙盒的网络访问。有一条命令想访问", + "bodyCapped": "此服务器只允许沙盒网络使用:{{limit}}。有一条命令想访问", + "adminOff": "管理员可以在 {{name}} 的设置中开启。", + "adminCapped": "管理员可以在 {{name}} 的设置中提高限制。", + "adminLink": "打开 {{name}} 的设置", + "askAdmin": "请让此服务器的管理员更改。", + "rowOff": "关闭:管理员尚未允许网络访问。", + "runner": "运行命令的附加组件" + }, + "allowedHosts": "管理员始终允许:", + "allowedHostsOff": "网络开启时生效。", + "allowedHostsNote": "管理员始终允许 {{count}} 个主机。", + "limitNote": "此对话的网络。管理员决定对话最多能访问的范围。", + "asksAbove": "此对话请求“{{choice}}”;管理员的限制是“{{limit}}”。" + }, + "inbox": { + "unread": "{{count}} 条未读", + "markAll": "全部标为已读", + "delete": "删除", + "pushOn": "开启浏览器通知", + "pushOff": "关闭浏览器通知", + "pushDenied": "此网站的浏览器通知已被阻止。", + "pushUnavailable": "此服务器无法保存推送订阅。", + "clearAll": "全部清除", + "clearRead": "清除已读", + "cancel": "取消", + "markRead": "标为已读", + "cleared_other": "已删除 {{count}} 条通知。", + "confirmAll_other": "删除全部 {{count}} 条通知?此操作无法撤销。", + "confirmRead_other": "删除 {{count}} 条已读通知?未读通知会保留。", + "onlyRead": "仅 {{count}} 条已读", + "showing": "显示 {{shown}} / {{total}}", + "showMore": "显示更多", + "cleared_one": "已删除 {{count}} 条通知。", + "confirmAll_one": "删除全部 {{count}} 条通知?此操作无法撤销。", + "confirmRead_one": "删除 {{count}} 条已读通知?未读通知会保留。", + "title": "通知", + "buttonUnread_one": "通知,{{count}} 条未读", + "buttonUnread_other": "通知,{{count}} 条未读", + "filterLabel": "显示通知", + "filterAll": "全部", + "filterUnread": "未读", + "filterFailed": "失败", + "more": "更多操作", + "close": "关闭", + "day": { + "today": "今天", + "yesterday": "昨天", + "earlier": "更早" + }, + "emptyTitle": "还没有通知", + "emptyBody": "触发器和连接器的结果会显示在这里,包括你不在时完成的工作。", + "caughtUp": "你已全部看完。", + "caughtUpTitle": "你已全部看完", + "caughtUpBody": "没有未读通知。", + "noFailedTitle": "没有失败的项目", + "noFailedBody": "失败的运行和连接器会显示在这里。", + "loadError": "无法加载你的通知。", + "source": { + "trigger": "触发器", + "connector": "连接器", + "system": "LocalAI" + }, + "itemUnread": "{{title}},未读", + "arrived": "新通知:{{title}}", + "arrivedMany_one": "{{count}} 条新通知", + "arrivedMany_other": "{{count}} 条新通知" + }, + "change": { + "loading": "正在读取触发器更改...", + "unread": "无法读取触发器更改:{{message}}", + "removed": "此更改已不再保留。", + "none": "无", + "on": "开启", + "off": "关闭(已暂停)", + "becomes": "变为", + "neverRan": "尚未运行", + "lastRun": "上次运行", + "lastRunValue": "{{status}},{{when}}", + "runStatus": { + "queued": "排队中", + "running": "运行中", + "done": "已完成", + "failed": "失败", + "skipped": "已跳过" + }, + "updateTitle": "更改 {{name}}", + "deleteTitle": "删除 {{name}}?", + "updateHelp": "建议的更改。在你应用之前不会有任何变化。", + "deleteHelp": "在你点击删除之前不会删除任何内容。你可以在一天内撤销。", + "stale": "此建议提出后触发器已被更改或删除,因此无法应用。请重新请求更改。", + "apply": "应用", + "delete": "删除", + "keep": "保留", + "field": { + "name": "名称", + "prompt": "提示词", + "when": "运行时间", + "time_zone": "时区", + "daily_cap": "每日上限", + "enabled": "状态", + "reads": "读取", + "writes": "写入", + "widget": "主页小组件", + "estimated_tokens": "每次运行的令牌数", + "agent": "智能体", + "model": "模型" + }, + "receipt": { + "update": "已更改:", + "pause": "已暂停:", + "resume": "已恢复:", + "delete": "已删除:", + "fields": "更改内容:{{fields}}", + "dismissed": "已忽略更改:", + "kept": "已保留:", + "undone": "已撤销:", + "restored": "已恢复:" + }, + "blocked": { + "expired": "撤销时限已过;请在触发器页面更改。", + "edited": "此后触发器已被更改;请在触发器页面更改。", + "removed": "此后触发器已被删除。", + "full": "你的触发器已达上限;请先在触发器页面删除一个。" + }, + "announce": { + "applied": "已更改 {{name}}。", + "deleted": "已删除 {{name}}。", + "dismissed": "已忽略对 {{name}} 的更改。", + "kept": "已保留 {{name}}。", + "undone": "已撤销对 {{name}} 的更改。", + "restored": "已恢复 {{name}}。" + } + }, + "loops": { + "region": "此聊天的循环", + "unit": { + "seconds_one": "{{count}} 秒", + "seconds_other": "{{count}} 秒", + "minutes_one": "{{count}} 分钟", + "minutes_other": "{{count}} 分钟", + "hours_one": "{{count}} 小时", + "hours_other": "{{count}} 小时" + }, + "unitShort": { + "seconds_one": "{{count}} 秒", + "seconds_other": "{{count}} 秒", + "minutes_one": "{{count}} 分钟", + "minutes_other": "{{count}} 分钟", + "hours_one": "{{count}} 小时", + "hours_other": "{{count}} 小时" + }, + "confirm": { + "every": "每 {{every}} 运行一次,直到你停止?", + "selfPaced": "每当模型要求时再次运行,直到你停止?", + "start": "开始", + "cancel": "取消" + }, + "chip": { + "name": "循环", + "every": "每 {{every}}", + "selfPaced": "由模型决定节奏", + "run_one": "第 {{count}} 次", + "run_other": "第 {{count}} 次", + "paused": "已暂停", + "running": "正在运行", + "next": "下次 {{time}}", + "ended": "已结束", + "wait": "正在等待:{{wait}}" + }, + "actions": { + "pause": "暂停", + "resume": "继续", + "stop": "停止", + "dismiss": "关闭" + }, + "state": { + "pending": "等待你的确认", + "active": "运行中", + "paused": "已暂停", + "ended": "已结束" + }, + "list": { + "title": "此聊天的循环", + "close": "关闭", + "empty": "此聊天没有循环。", + "hint": "要停止一个循环,输入 /loop stop 加上该行开头的代码。" + }, + "slash": { + "usage": "输入 /loop、可选的间隔和要重复的内容。例如:/loop 5m 检查构建。", + "notFound": "此聊天中没有与 {{id}} 匹配的循环。", + "noChat": "先发送第一条消息,然后在此聊天中启动循环。", + "stopped_one": "已停止 {{count}} 个循环。", + "stopped_other": "已停止 {{count}} 个循环。" + } + } +} diff --git a/core/http/react-ui/public/locales/zh-CN/collections.json b/core/http/react-ui/public/locales/zh-CN/collections.json index b6b3838a2..bc191b6d3 100644 --- a/core/http/react-ui/public/locales/zh-CN/collections.json +++ b/core/http/react-ui/public/locales/zh-CN/collections.json @@ -4,13 +4,13 @@ "newPlaceholder": "新集合名称...", "actions": { "create": "创建", - "creating": "创建中...", "details": "详情", "reset": "重置", "delete": "删除", "viewDetails": "查看详情", "resetCollection": "重置集合", - "deleteCollection": "删除集合" + "deleteCollection": "删除集合", + "cancel": "取消" }, "sections": { "yourCollections": "您的集合", @@ -19,7 +19,8 @@ "empty": { "title": "暂无集合", "text": "集合让您可以将文档组织成知识库,智能体可以通过 RAG(检索增强生成)进行搜索。在上方创建一个集合以开始。", - "noPersonal": "您还没有任何集合。" + "noPersonal": "您还没有任何集合。", + "learn": "了解记忆的工作方式" }, "deleteDialog": { "title": "删除集合", @@ -32,12 +33,29 @@ "confirm": "重置" }, "toasts": { - "loadFailed": "加载集合失败:{{message}}", "created": "集合 \"{{name}}\" 已创建", "createFailed": "创建集合失败:{{message}}", "deleted": "集合 \"{{name}}\" 已删除", "deleteFailed": "删除集合失败:{{message}}", "reset": "集合 \"{{name}}\" 已重置", "resetFailed": "重置集合失败:{{message}}" + }, + "loadFailed": "无法加载你的集合。", + "sources": { + "loadFailed": "无法加载网页来源。" + }, + "entries": { + "loadFailed": "无法加载文件。" + }, + "starting": { + "title": "记忆服务正在启动。", + "text": "服务器仍在准备记忆服务。此页面会自动重试。" + }, + "embedding": { + "missingTitle": "未安装嵌入模型。", + "missingText": "集合使用 {{model}} 为文件建立索引并进行搜索。安装之前,添加文件和搜索都会失败。", + "noneSet": "未为集合设置嵌入模型(collections_embedding_model),因此添加文件和搜索都会失败。", + "install": "在模型中查找", + "askAdmin": "请让管理员安装它。" } } diff --git a/core/http/react-ui/public/locales/zh-CN/common.json b/core/http/react-ui/public/locales/zh-CN/common.json index cbfdb37ea..904e29980 100644 --- a/core/http/react-ui/public/locales/zh-CN/common.json +++ b/core/http/react-ui/public/locales/zh-CN/common.json @@ -111,26 +111,22 @@ "gigabytes": "GB", "terabytes": "TB" }, - "updateReview": { - "title": "查看更新", - "description": "在此处确认之前,不会更新任何内容。", - "close": "关闭", - "done": "完成", - "selectAll": "全选", - "select": "选择 {{name}}", - "unknownVersion": "未知", - "latest": "最新", - "blocked": "无法在此升级:", - "upgradeSelected": "升级所选 ({{count}})", - "upgradeAll": "全部升级 ({{count}})", - "running": "正在升级...", - "queued": "等待中", - "statusRunning": "正在升级", - "statusDone": "已升级", - "statusFailed": "失败", - "summary": "已升级 {{succeeded}} 项,失败 {{failed}} 项。", - "empty": "没有可升级的内容。", - "statusStarted": "已开始", - "summaryStarted": "已开始 {{succeeded}} 项,失败 {{failed}} 项。" + "ui": { + "tryAgain": "重试", + "undo": "撤销", + "loadError": { + "title": "无法加载。", + "serverSaid": "服务器返回“{{message}}”。", + "nothingChanged": "没有任何更改。" + } + }, + "chip": { + "running": "运行中", + "starting": "正在启动", + "stopped": "已停止", + "failed": "失败", + "updateAvailable": "有可用更新", + "ready": "就绪", + "noRunsYet": "尚无运行" } } diff --git a/core/http/react-ui/public/locales/zh-CN/explorer.json b/core/http/react-ui/public/locales/zh-CN/explorer.json new file mode 100644 index 000000000..a99400484 --- /dev/null +++ b/core/http/react-ui/public/locales/zh-CN/explorer.json @@ -0,0 +1,57 @@ +{ + "brand": "{{name}} 探索", + "openApp": "打开 LocalAI", + "close": "关闭", + "copy": "复制", + "copied": "已复制", + "loading": "正在加载 swarm", + "title": "公开的 swarm", + "lede": "swarm 是通过点对点网络共享模型推理的一组机器。此列表显示其所有者选择公开的 swarm。持有 swarm 令牌的任何人都可以加入。", + "count_one": "{{count}} 个 swarm 在线", + "count_other": "{{count}} 个 swarm 在线", + "sorted": "工作节点最多的优先", + "workers_one": "工作节点", + "workers_other": "工作节点", + "tokenTitle": "令牌的开头和结尾。请从“如何加入”处复制。", + "warning": "Explorer 是社区维护的 swarm 令牌列表。任何人都可以使用令牌向 swarm 发送任务,或与之共享资源。请自行承担风险。", + "listed": "该 swarm 已列出。", + "empty": { + "title": "没有 swarm 的工作节点在线", + "text": "只列出至少有一个在线工作节点的 swarm。列出您的 swarm 即可将其公开。" + }, + "off": { + "title": "此服务器不是 explorer", + "text": "Explorer 页面需要以 explorer 模式启动的 LocalAI 服务器。此服务器没有可显示的 swarm 列表。" + }, + "error": { + "title": "无法加载 swarm", + "text": "explorer 服务器没有响应。请检查它是否正在运行,然后重试。", + "retry": "重试" + }, + "join": { + "open": "如何加入", + "title": "加入 {{name}}", + "text": "使用此 swarm 的令牌启动一个 LocalAI 工作节点。", + "token": "令牌", + "federated": "联邦集群 {{id}}。使用以下任一命令在其上启动节点。", + "docker": "使用 Docker", + "cli": "使用命令行", + "noCommands": "此 swarm 的工作节点通过其令牌共享算力。节点加入所需的就是上方的令牌。", + "docs": "了解 swarm 的工作原理" + }, + "list": { + "open": "列出 swarm", + "title": "列出 swarm", + "text": "将 swarm 添加到公开列表。它有在线工作节点后才会显示。", + "name": "名称", + "description": "描述", + "token": "令牌", + "tokenHint": "swarm 的网络令牌,即服务器打印的内容。", + "public": "列出即会公开令牌。任何查看列表的人都可以使用您 swarm 的工作节点。", + "understood": "我了解令牌将会公开。", + "cancel": "取消", + "add": "列出此 swarm", + "adding": "正在列出...", + "failed": "无法列出该 swarm。" + } +} diff --git a/core/http/react-ui/public/locales/zh-CN/home.json b/core/http/react-ui/public/locales/zh-CN/home.json index e6af6c163..45f5fd8e0 100644 --- a/core/http/react-ui/public/locales/zh-CN/home.json +++ b/core/http/react-ui/public/locales/zh-CN/home.json @@ -25,7 +25,10 @@ "title": "通过聊天管理 LocalAI", "description": "通过与 LocalAI 对话来安装模型、切换后端、编辑配置和查看状态。", "open": "打开助手", - "tooltip": "通过聊天管理 LocalAI" + "tooltip": "通过聊天管理 LocalAI", + "noModel": "请先选择一个聊天模型。", + "startFailed": "无法启动助手聊天:{{message}}", + "chatTitle": "LocalAI 助手" }, "input": { "placeholder": "消息...", @@ -79,13 +82,17 @@ "step2Title": "安装模型", "step2Body": "点击安装即可自动下载并配置。", "step3Title": "开始聊天", - "step3Body": "直接在浏览器中与模型聊天,或使用 API。" + "step3Body": "直接在浏览器中与模型聊天,或使用 API。", + "sandboxTitle": "安全运行代码(可选)", + "sandboxBody": "安装 Sandbox 附加组件,让模型在隔离环境中运行代码和命令。没有它,命令会直接在这台机器上运行,拥有完整的网络访问权限且没有隔离。" }, "browseGallery": "浏览模型库", "importModel": "导入模型", "docs": "文档", "noModelsTitle": "暂无可用模型", - "noModelsBody": "尚未安装模型。请联系您的管理员设置模型,以便开始聊天。" + "noModelsBody": "尚未安装模型。请联系您的管理员设置模型,以便开始聊天。", + "sandboxInstall": "安装 Sandbox", + "sandboxSkip": "跳过" }, "connect": { "title": "一个端点,所有 API", @@ -105,7 +112,23 @@ "modelsSummary": "Explore, install, and manage models", "create": "Create", "createSummary": "Open a chat, image or voice session", - "operate": "Operate", - "operateSummary": "{{models}} models configured · nodes, activity and traces" + "operate": "管理", + "operateSummary": "{{models}} models configured · nodes, activity and traces", + "allChats": "全部对话" + }, + "slash": { + "agents": { + "label": "智能体", + "desc": "你保存的智能体、触发器和技能" + }, + "mcp": { + "label": "MCP 连接器", + "desc": "你的对话可以使用的 MCP 服务器" + }, + "memory": { + "label": "记忆", + "desc": "你的对话可以搜索的集合" + }, + "complete": "补全" } } diff --git a/core/http/react-ui/public/locales/zh-CN/library.json b/core/http/react-ui/public/locales/zh-CN/library.json new file mode 100644 index 000000000..1e961f85e --- /dev/null +++ b/core/http/react-ui/public/locales/zh-CN/library.json @@ -0,0 +1,114 @@ +{ + "usedBy": { + "label": "使用方", + "none": "尚未使用", + "line": "被 {{name}} 使用", + "lineMore": "被 {{name}} 使用 +{{count}}", + "all": "所有技能", + "allTitle": "此智能体会加载所有技能", + "remove": "从 {{agent}} 中移除", + "open": "打开 {{agent}}", + "unread": "暂时无法读取使用情况。", + "unreadShort": "使用情况未知" + }, + "notUsed": { + "lead": "尚未使用。", + "skill": "在您将其添加到某处之前,它会保留在库中。可先试用,或将其添加到智能体。", + "collection": "在您将其添加到某处之前,它会保留在库中。可在此搜索,或将其添加到智能体。", + "skillShort": "在您将其添加到智能体之前,它会保留在列表中。" + }, + "tryInContext": "在上下文中试用", + "add": { + "button": "添加到...", + "title": "将 {{name}} 添加到", + "agents": "智能体", + "noAgents": "还没有智能体。创建一个智能体后再在此添加。", + "createAgent": "创建智能体", + "added": "已添加", + "action": "添加", + "addToAgent": "添加到 {{agent}}", + "working": "正在添加...", + "estimate": "估算方式:字符数除以四。", + "toolsMode": "模型请求时加载", + "tokens": "每条消息 +{{count}} 个令牌(估算)", + "collectionNote": "智能体可以搜索其列表中的集合。", + "kbOn": "开启其知识库", + "noAgentNamed": "没有名为 {{name}} 的智能体。请创建同名智能体以使用此集合。", + "close": "关闭", + "skillNote": "智能体可以读取其列表中的技能。" + }, + "toasts": { + "skillAdded": "已将 {{name}} 添加到 {{agent}}", + "skillRemoved": "已从 {{agent}} 移除 {{name}}", + "skillsOff": "已从 {{agent}} 移除 {{name}}。该智能体的技能现已关闭。", + "collectionAdded": "已将 {{name}} 添加到 {{agent}}", + "collectionRemoved": "已从 {{agent}} 移除 {{name}}", + "undone": "已撤销更改", + "updateFailed": "无法更新 {{agent}}:{{message}}", + "undo": "撤销", + "dismiss": "关闭" + }, + "simulate": { + "button": "模拟一条消息", + "title": "模拟一条消息", + "desc": "选择一个上下文,查看它会加载什么。不会向模型发送任何内容。", + "close": "关闭", + "context": "上下文", + "contextCollection": "仅集合", + "contextAgent": "智能体:{{name}}", + "collectionOn": "集合", + "noCollections": "还没有集合。", + "contextHintCollection": "用您的消息搜索集合,并显示返回的段落及其分数。不涉及智能体或模型。", + "contextHintAgent": "显示此智能体在提示词中保留的内容,以及其知识库为此消息添加的段落。", + "message": "消息", + "messagePlaceholder": "输入一个人可能会问的问题", + "passages": "段落", + "run": "运行", + "running": "正在运行...", + "onlyForTest": "仅用于此测试", + "removeExtra": "从此测试中移除 {{name}}", + "tryAlso": "尝试尚未添加的内容", + "tryAlsoPick": "选择一个技能或集合", + "extraSkill": "技能 {{name}}", + "extraCollection": "集合 {{name}}", + "budgetTitle": "此上下文添加的内容", + "budgetTotal": "在您的消息周围约 {{count}} 个令牌", + "budgetMessage": "您的消息:{{count}} 个令牌", + "budgetNote": "估算:字符数除以四。此处不知道模型的窗口大小,因此不显示占上限的比例。", + "instructions": "指令", + "skills": "技能", + "memory": "记忆库", + "skillsTitle": "此上下文加载的技能", + "skillsNone": "此智能体未开启任何技能。", + "skillsPrompt": "在每条消息的提示词中。", + "skillsTools": "仅在模型请求时读取。", + "skillsBoth": "在提示词中,并在模型请求时再次读取。", + "skillsAll": "此智能体已开启所有技能。", + "skillMissing": "{{name}} 在列表中,但没有该名称的技能。", + "skillTokens": "{{count}} 个令牌", + "skillTokensTools": "开始时为 0", + "memoryTitle": "将发送的记忆内容", + "memoryNone": "此智能体的列表中没有集合。请将一个集合添加到智能体以使用它。", + "memoryNoCollection": "此智能体的知识库已开启,但尚不存在名为 {{name}} 的集合。", + "memoryTools": "此智能体仅在模型请求时搜索。开始时不会向消息添加任何内容。", + "memoryTesting": "集合 {{name}},仅用于此测试", + "memoryFrom": "来自 {{name}}", + "passagesCount": "{{count}} 个段落,按相似度排序", + "noPassages": "此消息没有返回任何结果。", + "pickMessage": "请先输入消息。", + "searchFailed": "搜索失败:{{message}}", + "score": "分数 {{score}}", + "tokens": "{{count}} 个令牌", + "noAgents": "还没有智能体。您仍可单独搜索集合。", + "backendNote": "此处无法进行模型试运行,因此不模拟回答。" + }, + "search": { + "failed": "搜索失败:{{message}}" + }, + "agentBudget": "所选技能会为每条消息增加约 {{count}} 个令牌(估算:字符数除以四)。", + "agentKb": { + "reads": "此智能体读取名为 {{name}} 的集合。", + "exists": "此智能体读取名为 {{name}} 的集合。您可以在记忆库中填充并搜索它。", + "created": "此智能体读取名为 {{name}} 的集合。保存时会创建该集合。" + } +} diff --git a/core/http/react-ui/public/locales/zh-CN/modelEditor.json b/core/http/react-ui/public/locales/zh-CN/modelEditor.json new file mode 100644 index 000000000..b546d33bc --- /dev/null +++ b/core/http/react-ui/public/locales/zh-CN/modelEditor.json @@ -0,0 +1,6 @@ +{ + "proxyVision": { + "label": "此模型可以查看图片", + "hint": "当上游模型接受图片时打开。LocalAI 无法检查这一点。附加的图片随后会随消息一起发送给模型。" + } +} diff --git a/core/http/react-ui/public/locales/zh-CN/models.json b/core/http/react-ui/public/locales/zh-CN/models.json index 524e49933..9cc6609b5 100644 --- a/core/http/react-ui/public/locales/zh-CN/models.json +++ b/core/http/react-ui/public/locales/zh-CN/models.json @@ -1,5 +1,6 @@ { "lifecycle": { + "supporting": "找到适合这台机器的模型并安装,管理已有的模型。", "title": "Models", "navLabel": "Model lifecycle", "views": { "explore": "Explore", "installed": "Installed" }, @@ -81,6 +82,10 @@ "delete": "删除" }, "filters": { + "sheetDone": "显示模型", + "sheetTitle": "筛选", + "sheetButtonCount": "筛选({{count}})", + "sheetButton": "筛选", "all": "全部", "llm": "LLM", "image": "图像", diff --git a/core/http/react-ui/public/locales/zh-CN/nav.json b/core/http/react-ui/public/locales/zh-CN/nav.json index 700107889..e45dbfaab 100644 --- a/core/http/react-ui/public/locales/zh-CN/nav.json +++ b/core/http/react-ui/public/locales/zh-CN/nav.json @@ -10,12 +10,13 @@ "changeLanguage": "更改语言", "logout": "退出登录", "accountSettings": "账户设置", + "yourSettings": "你的设置", "account": "账户", "accountFor": "账户:{{name}}", "sections": { "recognition": "识别", "build": "构建", - "operate": "运维", + "operate": "管理", "workspace": "工作区" }, "operate": { @@ -66,7 +67,14 @@ "logs": "Logs", "trafficModels": "Models", "gpuHost": "GPU and host", - "prometheus": "Prometheus" + "prometheus": "Prometheus", + "chats": "聊天会话", + "files": "文件", + "triggers": "触发器", + "mcpConnectors": "MCP 连接器", + "chatsAdmin": "所有用户的聊天", + "pages": "页面", + "allChats": "全部对话" }, "footer": { "github": "GitHub", @@ -99,7 +107,15 @@ "import": "从文件、URL 或 Hugging Face 仓库添加模型。", "voices": "识别说话人,并维护声音档案库。", "faces": "检测、比较和匹配人脸。", - "addons": "安装附加组件并打开它们提供的应用。" - } - } + "addons": "安装附加组件并打开它们提供的应用。", + "triggers": "按计划或在文件到达时自动开始对话。" + }, + "operateSupporting": "此服务器上运行的内容、使用情况和配置。" + }, + "pinnedPages": "已固定的页面", + "new": "新", + "recentChats": "最近对话", + "newChat": "新对话", + "untitledChat": "未命名对话", + "chatRunning": "进行中" } diff --git a/core/http/react-ui/public/locales/zh-CN/operate.json b/core/http/react-ui/public/locales/zh-CN/operate.json new file mode 100644 index 000000000..80a6c6566 --- /dev/null +++ b/core/http/react-ui/public/locales/zh-CN/operate.json @@ -0,0 +1,57 @@ +{ + "backends": { + "filterLabel": "筛选后端", + "check": "检查更新", + "reinstallBackend": "重新安装后端", + "deleteBackend": "删除后端", + "updateAllTitle": "更新所有后端", + "updateAllIntro": "选择要更新的后端。每个后端会单独、依次更新。某一项失败不会中断其他项。", + "updateSelected": "更新所选", + "loadFailedTitle": "无法加载后端目录。", + "filtersButton": "筛选", + "filtersDone": "完成", + "filterGroups": { + "state": "状态", + "type": "类型", + "show": "显示", + "library": "库" + }, + "showVariants": "变体构建", + "showDevelopment": "开发构建", + "allLibraries": "所有库", + "moreActions": "更多后端操作", + "installFromUrl": "从 URL 安装…", + "manageLibraries": "管理库…", + "count_one": "{{count}} 个后端", + "count_other": "{{count}} 个后端", + "countOf": "{{shown}} / {{total}} 个后端", + "activeFilters": "已启用的筛选", + "removeFilter": "移除筛选:{{name}}", + "checking": "正在检查更新…", + "updatesAvailable_one": "{{count}} 个更新可用", + "updatesAvailable_other": "{{count}} 个更新可用", + "reviewUpdates": "查看", + "close": "关闭", + "installedLoadFailedTitle": "无法加载已安装的后端。" + }, + "capacity": { + "diskChip": "磁盘快满了" + }, + "activity": { + "kind": { + "addon": "插件" + }, + "verb": { + "upgrading": "正在升级{{kind}}", + "failedUpgrade": "无法升级{{kind}}" + }, + "filter": { + "addons": "插件" + }, + "upgradeVersions": "{{from}} 到 {{to}}", + "rowUpgraded": "已升级,用时 {{duration}}", + "rowUpgradedPlain": "已升级", + "viewInAddons": "在插件中查看", + "upgradeWaiting": "正在等待插件服务启动。{{seconds}} 秒后再次检查。" + } +} diff --git a/core/http/react-ui/public/locales/zh-CN/pages.json b/core/http/react-ui/public/locales/zh-CN/pages.json new file mode 100644 index 000000000..43f8580ab --- /dev/null +++ b/core/http/react-ui/public/locales/zh-CN/pages.json @@ -0,0 +1,138 @@ +{ + "subtitle": "助手为你制作的小工具。它们在这里的沙盒中打开,数据保存在你的文件里。", + "card": { + "pin": "固定到侧边栏", + "unpin": "从侧边栏取消固定", + "changedBy": { + "you": "{{when}}由你修改", + "assistant": "{{when}}由助手修改" + }, + "menu": "{{title}} 的更多操作", + "files": "显示其文件", + "rename": "重命名", + "renameLabel": "新文件夹名称", + "renameHint": "小写字母、数字和连字符。", + "renameSave": "保存", + "renameCancel": "取消", + "renameFailed": "页面未能重命名:{{message}}", + "delete": "删除页面", + "deleteTitle": "删除“{{title}}”?", + "deleteMessage": "文件夹 {{folder}} 将移到回收站。你可以在“文件”中恢复。", + "deleted": "{{title}} 已在回收站中。", + "deleteFailed": "页面未能删除:{{message}}" + }, + "empty": { + "text": "页面是一个小工具,比如任务看板或阅读清单。可以在聊天中让助手做一个,或从现成的页面开始。", + "starting": "正在复制页面", + "starterFailed": "页面未能复制:{{message}}", + "ask": "请求创建页面", + "askPrompt": "帮我做一个页面,", + "starterReading": "从阅读清单开始", + "note": "没有页面时,此入口会从侧边栏消失。创建下一个页面后会重新出现。", + "learn": "页面的工作原理" + }, + "askNew": "请求创建新页面", + "yours": "你的页面", + "filter": "筛选页面", + "shared": { + "yours": "你的", + "group": "与你共享", + "note": "共享页面只有在你允许后才会运行。如果作者修改了它,会再次询问你。", + "offNotice": "此服务器已关闭共享页面。管理员关闭了它们。", + "off": "此服务器已关闭共享页面。管理员关闭了它们。", + "gone": "此页面已不在共享文件夹中,或你已无权访问。", + "review": "审核", + "from": "来自 {{author}}", + "menu": "页面菜单", + "badge": { + "notAllowed": "尚未允许", + "changed": "在你允许后已更改", + "allowed": "已允许" + } + }, + "viewer": { + "stopAllowing": "不再允许此页面", + "authorChanged": "{{author}} 修改了此页面。在你审核更改之前,你允许的版本会继续运行。", + "sandboxShared": "沙盒中运行。以你的身份运行,仅限 {{folder}},无法访问网络。" + }, + "trust": { + "title": "打开“{{title}}”?", + "desc": "此页面来自共享文件夹。页面以你的身份运行,请在运行前检查。", + "who": "作者:{{author}}", + "whereLabel": "共享文件夹", + "whereWhen": ",更改于 {{when}}", + "someone": "某人", + "close": "关闭", + "canTitle": "它可以", + "canStillTitle": "它仍然可以", + "cannotTitle": "它不能", + "items": { + "files": "读写它自己文件夹中的文件", + "readOnly": "你只能读取此共享文件夹,因此页面无法保存它的更改。", + "chat": "向聊天写入消息", + "chatNote": "助手回答前,你会在聊天中看到这条消息。", + "noFiles": "读取你的其他文件或聊天", + "noNet": "访问网络或其他网站", + "noNetNote": "一个限制:它可以在自己的框架中加载另一个地址。这会向外发送一个请求,然后页面停止。", + "noTools": "使用工具或更改你的设置" + }, + "warn": "它在自己的文件夹中以你的身份行事。如果 {{author}} 修改它,会再次询问你。", + "viewSource": "查看源代码", + "cancel": "取消", + "allowOnce": "允许一次", + "allow": "允许", + "changed": { + "title": "“{{title}}”已更改", + "desc": "你允许的是较早的版本。再次运行前,请检查更改的内容。", + "who": "由 {{author}} 于 {{when}} 更改", + "allowed": "你允许的是 {{date}} 的版本", + "what": "更改内容", + "same": "它能做的事与之前相同。", + "warn": "如果你允许,每次打开都会运行此版本,直到 {{author}} 再次修改它。", + "view": "查看更改", + "lines": "{{before}} → {{after}} 行", + "code": "页面的脚本、样式表或模块已更改:{{files}}" + }, + "pill": { + "notYet": "尚未允许", + "not": "未允许", + "once": "仅本次访问允许", + "allowed": "已允许", + "changed": "在你允许后已更改" + }, + "from": "来自 {{author}}", + "frame": { + "chrome": "来自 {{folder}} 的共享页面。它尚未运行。" + }, + "blocked": { + "title": "此页面尚未运行", + "body": "你选择不运行它。它来自 {{author}} 的共享文件夹 {{folder}}。其中没有任何内容运行,也没有保存任何内容。", + "review": "审核并允许" + }, + "waiting": { + "title": "等待你的确认", + "body": "在你允许之前,此页面中的任何内容都不会运行。" + }, + "stopped": { + "revoked": "你已不再允许此页面。它已停止,在你再次允许之前不会运行任何内容。", + "refused": "此页面已不再被允许,因此已停止。审核后可再次允许。", + "off": "此服务器已关闭共享页面,因此此页面已停止。" + }, + "changedMeanwhile": "你审核时页面已更改。这是新版本。", + "sourceTitle": "“{{title}}”的源代码", + "sourceLines_one": "{{count}} 行", + "sourceLines_other": "{{count}} 行", + "sourceNote": "源代码以文本显示。这里不会运行任何内容。" + }, + "frame": { + "filesFailed": "此页面旁边的文件未能加载:{{files}}。浏览器控制台会说明原因。", + "tooLarge": "此页面从其文件夹嵌入的文件超过 {{limit}},因此省略了部分文件。请改用 fetch() 加载数据。", + "dismiss": "关闭" + }, + "link": { + "label": "页面想要打开一个链接", + "open": "打开 {{site}}", + "cancel": "取消", + "lookalike": "此地址使用了其他文字的字母,读作 {{display}}。它可能在模仿其他网站:打开前请先核对。" + } +} diff --git a/core/http/react-ui/public/locales/zh-CN/skills.json b/core/http/react-ui/public/locales/zh-CN/skills.json index f9468f1e0..a6203dc2d 100644 --- a/core/http/react-ui/public/locales/zh-CN/skills.json +++ b/core/http/react-ui/public/locales/zh-CN/skills.json @@ -1,79 +1,83 @@ { "title": "技能", - "subtitle": "管理智能体技能(可重用的指令和资源)", + "subtitle": "任务匹配时,你的所有聊天都可以使用的指示。智能体使用你添加给它们的技能。", "unavailable": { - "subtitle": "技能服务不可用或索引正在重建。请稍后再试。", - "retry": "重试" + "subtitle": "技能需要“文件”功能,而此服务器已关闭“文件”。" }, "actions": { "newSkill": "新技能", "createSkill": "创建技能", - "import": "导入", - "importing": "正在导入...", - "gitRepos": "Git 仓库", "edit": "编辑", - "delete": "删除", - "export": "导出", - "sync": "同步", - "addRepo": "添加仓库", - "adding": "添加中...", - "remove": "移除", - "enable": "启用", - "disable": "禁用" + "delete": "删除" }, "search": { "placeholder": "搜索技能..." }, - "git": { - "title": "Git 仓库", - "description": "添加 Git 仓库以拉取技能。同步后技能将出现在列表中。", - "urlPlaceholder": "https://github.com/user/repo 或 git@github.com:user/repo.git", - "noRepos": "未配置 Git 仓库。请在上方添加。", - "disabled": "已禁用", - "removeRepo": "移除仓库" - }, "card": { "noDescription": "无描述", "readOnly": "只读", "editTitle": "编辑技能", "deleteTitle": "删除技能", - "exportTitle": "导出为 .tar.gz" + "invalid": "Skipped: not a valid skill", + "invalidWhy": "已跳过此技能:{{reason}}", + "shadowed": "Hidden by a {{layer}} skill", + "shadowedLong": "Hidden by a {{layer}} skill of the same name", + "providedBy": "由 {{name}} 提供", + "replacedBy": "已被 {{name}} 替换", + "notActive": "未生效:使用内置技能", + "notActiveBy": "未生效:由 {{name}} 提供", + "fallback": "因 {{name}} 插件的技能已关闭或未运行而使用" }, "empty": { "title": "未找到技能", "text": "创建或导入一个技能以开始。", - "noPersonal": "您还没有任何技能。" - }, - "sections": { - "yourSkills": "您的技能", - "otherUsersSkills": "其他用户的技能" - }, - "deleteDialog": { - "title": "删除技能", - "message": "删除技能 \"{{name}}\"?此操作无法撤销。", - "confirm": "删除" - }, - "removeRepoDialog": { - "title": "移除 Git 仓库", - "message": "移除此 Git 仓库?其中的技能将不再可用。", - "confirm": "移除" + "stepWrite": "Write a skill: a name, a line that says when to use it, and the instructions.", + "stepTry": "Check it in the list: it shows who uses it and what it says.", + "stepAdd": "Add it to an agent when you want it used." }, "toasts": { "loadFailed": "加载技能失败", "deleted": "技能 \"{{name}}\" 已删除", - "deleteFailed": "删除技能失败", - "exported": "技能 \"{{name}}\" 已导出", - "exportFailed": "导出失败", - "imported": "已从 \"{{file}}\" 导入技能", - "importFailed": "导入失败", - "loadReposFailed": "加载 Git 仓库失败", - "repoAdded": "Git 仓库已添加并正在同步", - "addRepoFailed": "添加仓库失败", - "synced": "仓库已同步", - "syncFailed": "同步失败", - "toggled": "仓库已切换", - "toggleFailed": "切换失败", - "removed": "仓库已移除", - "removeFailed": "移除失败" + "deleteFailed": "删除技能失败" + }, + "layers": { + "label": "Layer", + "personal": "Personal", + "shared": "Shared", + "builtin": "Built-in", + "folder": "Workspace", + "addon": "插件" + }, + "pane": { + "loading": "Loading...", + "loadFailed": "The skill could not be read.", + "tokensExplain": "A chat reads a skill only when a task matches its description, so it adds nothing until then. About {{count}} tokens when read (estimate)." + }, + "edit": { + "back": "Back to skills", + "titleNew": "New skill", + "titleEdit": "Edit: {{name}}", + "basic": "Basic information", + "content": "Content", + "layer": "Layer", + "layerHelp": "Personal is yours alone. Shared is read by every user.", + "name": "Name (lowercase, hyphens only)", + "nameLocked": "Name cannot be changed after creation.", + "description": "Description (when to use it)", + "descriptionHelp": "A chat reads this line to decide whether the skill fits the task.", + "body": "Skill content (markdown)", + "cancel": "Cancel", + "save": "Save changes", + "create": "Create skill", + "saving": "Saving...", + "created": "Skill created", + "updated": "Skill updated", + "required": "A name and a description are required", + "saveFailed": "Save failed", + "loadFailed": "Failed to load the skill", + "readOnly": "此技能在这里是只读的。内置技能随服务器提供,来自你文件的技能请在“文件”中修改。" + }, + "load": { + "title": "无法加载你的技能。" } } diff --git a/core/http/react-ui/public/locales/zh-CN/swarm.json b/core/http/react-ui/public/locales/zh-CN/swarm.json new file mode 100644 index 000000000..a3ab148a9 --- /dev/null +++ b/core/http/react-ui/public/locales/zh-CN/swarm.json @@ -0,0 +1,5 @@ +{ + "nodes": { + "retry": "重试" + } +} diff --git a/core/http/react-ui/public/locales/zh-CN/tools.json b/core/http/react-ui/public/locales/zh-CN/tools.json new file mode 100644 index 000000000..a3280e35e --- /dev/null +++ b/core/http/react-ui/public/locales/zh-CN/tools.json @@ -0,0 +1,468 @@ +{ + "steps": { + "label": "进度", + "setup": "设置", + "check": "检查", + "run": "运行", + "result": "结果" + }, + "run": { + "stop": "停止", + "connecting": "正在连接任务。等待第一个进度事件。", + "progress": "进度", + "stages": "阶段", + "step": "步", + "of": "/", + "epoch": "轮次", + "tokens": "tokens/s", + "eta": "约剩 {{time}}", + "etaSeconds": "约剩 {{seconds}} 秒" + }, + "landing": { + "lede": "在这台机器上可以做什么,以及每个工具首先需要什么。", + "machine": "本机", + "gpu": "GPU", + "gpuFree": "{{total}} 中 {{free}} 可用", + "noGpu": "未找到 GPU", + "cluster": "最佳节点({{node}})上的 {{total}}", + "clusterNode": "一个工作节点", + "ram": "内存", + "ramFree": "RAM,可用 {{free}}", + "disk": "模型磁盘", + "diskFree": "可用", + "backends": "后端", + "noBackends": "这些工具尚未安装任何后端", + "open": "打开", + "openTool": "打开 {{tool}}", + "install": "安装", + "ready": "就绪", + "needsBackend": "需要后端", + "experimental": "实验性", + "needs": "所需条件", + "flow": "从找到的模型到可以对话的模型的路径", + "flowNote": "每个步骤都以可加载的模型结束。", + "now": { + "running": { + "fineTune": "正在微调 {{model}}", + "quantize": "正在将 {{model}} 量化为 {{type}}" + }, + "failed": { + "fineTune": "微调 {{model}} 失败", + "quantize": "将 {{model}} 量化为 {{type}} 失败" + } + }, + "groups": { + "make": { + "title": "制作模型", + "text": "从找到的模型开始,最终得到适合本机并了解您数据的模型。" + }, + "recognise": { + "title": "识别", + "text": "识别正在说话的人,以及照片中是谁的脸。" + }, + "automate": { + "title": "自动化", + "text": "智能体、它们的指令和它们的记忆。" + } + }, + "sentence": { + "import": "从 Hugging Face、URL、Ollama 或 OCI 引用,或从本机文件导入模型。LocalAI 会选择后端并写入配置。", + "quantize": "缩小模型,使其占用更少内存。以您选择的精度将 Hugging Face 模型转换为 GGUF。", + "fine-tune": "用适配器让模型学习您自己的数据,然后将其导出为可以对话的模型。" + }, + "line": { + "import": { + "noSetup": "无需设置。需要时会下载后端。", + "disk": "模型磁盘上可用 {{free}}" + }, + "quantize": { + "backend": "已安装 {{name}} 后端", + "backendMissing": "未安装量化后端", + "backendUnknown": "无法读取后端状态", + "cpu": "在 CPU 上运行,无需 GPU" + }, + "fineTune": { + "backend": "已安装 {{name}} 后端", + "backendMissing": "未安装微调后端", + "backendUnknown": "无法读取后端状态", + "gpu": "GPU 显存:{{name}},{{total}} 中 {{free}} 可用", + "noGpu": "未找到 GPU。训练将在 CPU 上运行,速度较慢。", + "cluster": "内存:最佳节点上的 {{total}}" + } + }, + "enable": { + "admin": "从后端页面安装后端,即可使用此工具。", + "user": "请管理员安装后端。" + } + }, + "checks": { + "heading": "开始前检查", + "live": "随您修改设置而更新。", + "meterLabel": "已用 {{used}}%,{{total}} 中 {{free}} 可用", + "openBackends": "打开后端", + "barOk": "没有任何项目阻止启动。", + "barWarn_one": "{{count}} 条警告。仍可以启动。", + "barWarn_other": "{{count}} 条警告。仍可以启动。", + "title": { + "model": "模型", + "data": "数据集", + "backend": "后端", + "memory": "内存", + "disk": "磁盘", + "reward": "奖励函数", + "resume": "恢复", + "access": "模型访问", + "type": "量化类型", + "source": "来源", + "name": "名称" + }, + "fineTune": { + "modelOk": "正在训练 {{model}}。", + "modelMissing": "请输入要训练的模型。", + "dataOk": "数据来自 {{source}}。", + "dataMissing": "请添加数据集:Hugging Face id 或上传的文件。", + "backendOk": "{{name}} 已安装。", + "backendMissing": "未安装微调后端,任务可能无法启动。", + "backendOther": "{{name}} 不在已安装的微调后端({{list}})中。", + "backendUnknown": "无法读取已安装的后端。", + "memoryGpu": "{{name}}:{{total}} 中 {{free}} 可用。批次大小 {{batch}},序列长度 {{seq}},梯度累积 {{accum}}。任务所需的内存取决于模型和这些设置,LocalAI 在任务开始前不做估算。", + "memoryCluster": "最佳节点({{node}})上的 {{total}}。批次大小 {{batch}},序列长度 {{seq}}。任务所需的内存取决于模型和这些设置。", + "memoryCpu": "未找到 GPU。训练使用 CPU,RAM {{total}} 中有 {{free}} 可用。预计会很慢。批次大小 {{batch}},序列长度 {{seq}}。", + "memoryUnknown": "此账户无法读取机器的内存。批次大小 {{batch}},序列长度 {{seq}}。任务所需的内存取决于模型和这些设置。", + "disk": "模型磁盘上可用 {{free}}。下载和检查点需要空间。", + "diskLow": "模型磁盘上仅剩 {{free}} 可用。下载和检查点需要空间。", + "rewardMissing": "GRPO 至少需要一个奖励函数。请在“更多选项”中添加。", + "resume": "训练将从 {{path}} 继续。", + "accessNone": "未设置令牌。受限模型需要在“更多选项”中设置 Hugging Face 令牌。", + "accessSet": "已设置 Hugging Face 令牌。" + }, + "quantize": { + "modelOk": "正在量化 {{model}}。", + "modelMissing": "请输入要量化的模型。", + "typeMissing": "请输入自定义量化类型。", + "backendOk": "{{name}} 已安装。", + "backendMissing": "未安装量化后端,任务可能无法启动。", + "backendOther": "{{name}} 不在已安装的量化后端({{list}})中。", + "backendUnknown": "无法读取已安装的后端。", + "memory": "RAM {{total}} 中有 {{free}} 可用。转换在 CPU 上运行。", + "disk": "模型磁盘上可用 {{free}}。下载、转换后的文件和结果都会写入磁盘。", + "diskLow": "模型磁盘上仅剩 {{free}} 可用。下载、转换后的文件和结果都会写入磁盘。", + "accessNone": "未设置令牌。受限模型需要在“更多选项”中设置 Hugging Face 令牌。", + "accessSet": "已设置 Hugging Face 令牌。" + }, + "import": { + "sourceOk_huggingface": "识别为 Hugging Face 仓库。导入时 LocalAI 会检查其中的文件。", + "sourceOk_url": "已识别 URL 格式。其内容尚未检查。", + "sourceOk_config": "识别为模型配置文件。将按原样读取并使用。", + "sourceOk_oci": "识别为 OCI 镜像引用。", + "sourceOk_ollama": "识别为 Ollama 模型引用。", + "sourceOk_local": "识别为运行 LocalAI 的机器上的路径。", + "sourceOk": "已识别来源。", + "sourceUnknown": "这看起来不是 LocalAI 支持的任何格式。仍会尝试,但可能失败。", + "backendDownload": "{{name}} 尚未安装。LocalAI 会先下载它。", + "backendChosen": "已选择并安装 {{name}}。", + "backendAuto": "导入开始时,LocalAI 会根据来源的文件选择后端。", + "nameTaken": "名为 {{name}} 的模型已安装。", + "nameFree": "{{name}} 未被使用。", + "nameAuto": "模型将以来源命名。", + "disk": "模型磁盘上可用 {{free}}。导入开始后即可知道下载大小。", + "memory_gpu": "当前 GPU 显存可用 {{free}}。此模型需要多少,导入开始后即可知道。", + "memory_ram": "当前 RAM 可用 {{free}}。此模型需要多少,导入开始后即可知道。", + "memory_cluster": "最佳节点上可用 {{free}}。此模型需要多少,导入开始后即可知道。", + "memory": "当前内存可用 {{free}}。此模型需要多少,导入开始后即可知道。" + } + }, + "form": { + "key": "键", + "value": "值", + "addOption": "添加选项", + "copy": "复制到剪贴板", + "more": "更多选项", + "moreHint": "方法、后端、适配器、优化器、评估、奖励函数", + "baseModel": "基础模型", + "model": "模型", + "modelHint": "Hugging Face id,或本机上的路径。", + "data": "您的数据", + "dataset": "数据集来源", + "datasetHint": "Hugging Face 数据集 id,或上传文件。", + "split": "划分", + "upload": "改为上传文件", + "uploadHint": ".json、.jsonl 或 .csv", + "removeFile": "移除文件", + "train": "训练内容", + "kind": "训练类型", + "kinds": { + "adapter": { + "title": "适配器", + "text": "训练一个小型适配器,保持基础权重不变。这是常见的选择,所需内存很少。" + }, + "full": { + "title": "完整模型", + "text": "修改每一个权重。所需内存是适配器的数倍。" + } + }, + "epochs": "轮次", + "batch": "批次大小", + "lr": "学习率", + "backend": "后端", + "method": "训练方法", + "adapterKind": "适配器类型", + "rank": "秩", + "alpha": "Alpha", + "dropout": "Dropout", + "gradAccum": "梯度累积步数", + "warmup": "预热步数", + "seqLen": "最大序列长度", + "optimizer": "优化器", + "maxSteps": "最大步数", + "maxStepsHint": "0 表示自动。", + "saveSteps": "保存步数", + "saveLimit": "保存总数上限", + "saveLimitHint": "0 表示保留所有检查点。", + "weightDecay": "权重衰减", + "seed": "随机种子", + "seedHint": "0 表示随机选择种子。", + "precision": "混合精度", + "auto": "自动", + "targetModules": "目标模块", + "targetModulesHint": "以逗号分隔。留空则使用后端默认值。", + "gradCheckpointing": "梯度检查点(较慢,但占用更少内存)", + "token": "Hugging Face 令牌", + "tokenHint": "仅受限模型需要。", + "evalEnable": "训练期间评估", + "evalStrategy": "评估策略", + "evalSteps_": "步", + "evalEpoch": "轮次", + "evalSteps": "评估步数", + "evalStepsHint": "0 表示与保存步数相同。", + "evalSplit": "评估划分", + "evalDataset": "评估数据集", + "evalDatasetPlaceholder": "单独的数据集", + "evalRatio": "自动划分比例", + "rewards": "奖励函数(GRPO)", + "rewardsHint": "GRPO 至少需要一个奖励函数。可选择内置函数,或添加您自己的。", + "resume": "从检查点恢复", + "clearCheckpoint": "清除检查点", + "extra": "额外选项", + "extraHint": "特定于后端的键值对。", + "summaryFull": "完整模型", + "summary_lora": "LoRA 适配器", + "summary_loha": "LoHa 适配器", + "summary_lokr": "LoKr 适配器", + "recipe": "使用 {{method}} 在 {{data}} 上训练 {{model}} 的 {{kind}},共 {{epochs}} 轮。", + "recipeEmpty": "选择模型和数据集,即可查看将要训练的内容。" + }, + "rewards": { + "format_reward": "检查 ... 再接答案的格式。", + "reasoning_accuracy_reward": "将 的内容与数据集的答案列进行比较。", + "length_reward": "评估答案与目标长度的接近程度。", + "xml_tag_reward": "评估 XML 标签是否正确地成对打开和关闭。", + "no_repetition_reward": "对重复的 n-gram 进行扣分。", + "code_execution_reward": "检查 Python 代码块的语法是否有效。", + "param_target_length": "目标长度", + "fnName": "函数名", + "fnBody": "函数体", + "fnHint": "接收 completions 和 kwargs,并必须返回浮点数列表。可以使用 re、math、json 和 string。", + "add": "添加", + "cancel": "取消", + "custom": "添加自定义奖励函数" + }, + "fineTune": { + "title": "微调", + "lede": "用您自己的数据训练适配器,然后将其导出为可以对话的模型。", + "importConfig": "导入配置", + "exportConfig": "导出配置", + "newJob": "新建任务", + "badConfig": "无法读取该文件。请使用从此页面导出的 JSON 配置。", + "start": "开始微调", + "starting": "正在启动...", + "resume": "恢复训练", + "resuming": "正在从检查点恢复:", + "clear": "清除", + "barNote": "在您开始之前,不会更改任何内容。", + "jobMeta": "{{backend}} 上的 {{kind}} {{method}},开始于 {{started}}", + "failed": "训练失败", + "failedAt": "训练在第 {{step}} 步(共 {{total}} 步)时停止", + "noMessage": "后端没有提供消息。", + "finished": "训练完成。", + "finishedLoss": "训练完成。最后的损失为 {{loss}}。", + "stopped": "任务已停止。如果您选择保留,检查点会被保留。", + "tryTitle": "下一步该怎么做", + "tryText": "服务器报告内存不足。下面的每项更改都会应用到设置的副本上,在您点击“开始”之前不会启动任何内容。", + "tryPlain": "重新打开设置,修改消息所指的内容,然后启动新任务。", + "fixBatch": "将批次大小降至 1", + "fixCheckpointing": "开启梯度检查点(较慢,但占用更少内存)", + "retryChanges": "应用这些更改并打开设置", + "reuse": "重新打开设置", + "resumeFrom": "从第 {{step}} 步恢复", + "status": { + "queued": "排队中", + "loading_model": "正在加载模型", + "loading_dataset": "正在加载数据集", + "training": "训练中", + "saving": "保存中", + "completed": "已完成", + "failed": "失败", + "stopped": "已停止" + } + }, + "stop": { + "title": "停止训练?", + "text": "立即保存的检查点可让您恢复训练,或导出目前已学到的内容。", + "keep": "停止并保留检查点", + "discard": "停止且不保留检查点", + "cancel": "继续训练", + "cancelDelete": "取消" + }, + "chart": { + "loss": "损失", + "learning_rate": "学习率", + "grad_norm": "梯度范数", + "metric": "指标", + "step": "步", + "eval": "评估损失", + "waiting": "等待更多步数。", + "window": "第 {{from}} 至 {{to}} 步,共 {{total}} 步", + "windowEval": "第 {{from}} 至 {{to}} 步,共 {{total}} 步。空心点为评估损失。", + "aria": "训练步数上的 {{metric}}。第 {{step}} 步的最后一个值为 {{last}}。", + "readout": "第 {{step}} 步:{{value}}", + "dataTitle": "显示数值", + "emptyText": "任务报告两个步骤后,图表即会绘制。" + }, + "log": { + "title": "日志", + "note": "自打开此页面以来任务报告的内容。", + "only": "仅显示警告和错误", + "copy": "复制", + "copied": "已复制", + "download": "下载", + "empty": "尚无报告。", + "noneFiltered": "没有警告或错误。" + }, + "checkpoints": { + "title": "检查点", + "step": "步", + "epoch": "轮次", + "loss": "损失", + "path": "路径", + "actions": "操作", + "resume": "恢复", + "resumeTitle": "从此检查点恢复训练", + "export": "导出", + "exportTitle": "导出此检查点" + }, + "export": { + "title": "导出为模型", + "lede": "导出会将结果转换为 LocalAI 可以加载的模型。", + "checkpoint": "检查点", + "final": "最终模型(输出目录)", + "checkpointOption": "第 {{step}} 步(损失 {{loss}})", + "format": "导出格式", + "lora": "LoRA 适配器", + "merged16": "合并(16 位)", + "merged4": "合并(4 位)", + "quant": "量化", + "name": "模型名称", + "nameHint": "留空则根据任务自动生成。", + "go": "导出", + "exporting": "正在导出...", + "working": "正在导出...", + "done": "模型已导出并注册为“{{name}}”", + "failed": "导出失败:{{message}}", + "unknown": "未知错误", + "nextTitle": "{{name}} 现已成为模型", + "nextText": "它已在 LocalAI 中注册。打开与它的对话,或在模型页面中找到它。", + "chat": "与 {{name}} 对话", + "models": "在模型中打开", + "archive": "下载归档" + }, + "jobs": { + "title": "早前的任务", + "count_one": "此服务器保留 {{count}} 个", + "count_other": "此服务器保留 {{count}} 个", + "loading": "正在加载任务", + "emptyTitle": "还没有微调任务", + "emptyText": "在上方进行设置。任务会连同状态显示在这里,之后您可以复用其设置。", + "model": "模型", + "kind": "类型", + "status": "状态", + "started": "开始时间", + "actions": "操作", + "open": "打开此任务", + "reuse": "复用", + "reuseTitle": "将此任务的设置用于新任务", + "delete": "删除", + "deleteTitle": "删除此任务及其数据", + "deleteDialogTitle": "删除此任务?", + "deleteDialogText": "这将删除任务及其检查点和任何已导出的模型。此操作无法撤销。" + }, + "quantize": { + "title": "量化", + "lede": "缩小模型,使其占用更少内存。结果是可以导入的 GGUF 文件。", + "newJob": "新建任务", + "sectionModel": "模型", + "model": "模型", + "modelHint": "Hugging Face id,或本机上的路径。", + "sectionPrecision": "精度", + "type": "量化类型", + "typeHint": "数字越小,文件越小,质量会略有下降。", + "custom": "自定义...", + "customLabel": "自定义类型", + "customPlaceholder": "例如 iq4_nl", + "moreHint": "后端、Hugging Face 令牌", + "tokenHint": "仅受限模型需要。", + "recipe": "将 {{model}} 转换为 {{type}} 格式的 GGUF。", + "recipeEmpty": "输入模型,即可查看将要生成的内容。", + "start": "量化({{type}})", + "barNote": "在您开始之前,不会更改任何内容。", + "jobMeta": "{{backend}} 上的 {{type}},开始于 {{started}}", + "failed": "量化失败", + "finished": "量化完成:{{type}}。", + "stopped": "任务已停止。", + "tryText": "重新打开设置,修改消息所指的内容,然后启动新任务。较新的后端可能支持较旧后端不支持的架构。", + "outputTitle": "结果", + "outputText": "{{type}} 格式的 GGUF 文件。导入后即可在 LocalAI 中使用,也可以直接下载。", + "file": "文件", + "importName": "模型名称", + "importNamePlaceholder": "留空则自动生成", + "import": "导入到 LocalAI", + "importing": "正在导入...", + "importFailed": "导入失败", + "importFailedWith": "导入失败:{{message}}", + "download": "下载 GGUF", + "nextTitle": "{{name}} 现已成为模型", + "nextText": "它已在 LocalAI 中注册。打开与它的对话,或在模型页面中找到它。", + "chat": "与 {{name}} 对话", + "models": "在模型中打开", + "imported": "已导入为 {{name}}", + "emptyTitle": "还没有量化任务", + "emptyText": "在上方进行设置。任务会连同状态显示在这里。", + "deleteDialogText": "这将删除任务及其转换后的文件。此操作无法撤销。", + "startFailed": "启动任务失败", + "stopFailed": "停止任务失败", + "deleteFailed": "删除任务失败", + "status": { + "queued": "排队中", + "downloading": "下载中", + "converting": "转换中", + "quantizing": "量化中", + "completed": "已完成", + "failed": "失败", + "stopped": "已停止" + } + }, + "off": { + "badge": "您无法使用", + "fineTune": { + "title": "您的账户无法进行微调", + "text": "微调是一项单独的权限,此账户没有该权限。" + }, + "quantize": { + "title": "您的账户无法进行量化", + "text": "量化是一项单独的权限,此账户没有该权限。" + }, + "admin": "管理员可以在“用户和密钥”中开启该权限。", + "askAdmin": "请管理员为您的账户开启该权限。", + "back": "前往首页" + } +} diff --git a/core/http/react-ui/public/locales/zh-CN/traffic.json b/core/http/react-ui/public/locales/zh-CN/traffic.json new file mode 100644 index 000000000..72ed520b8 --- /dev/null +++ b/core/http/react-ui/public/locales/zh-CN/traffic.json @@ -0,0 +1,12 @@ +{ + "overview": { + "usageErrorTitle": "无法读取用量。" + }, + "host": { + "errorTitle": "无法读取主机信息。" + }, + "models": { + "noMatch": "没有与“{{query}}”匹配的模型。" + }, + "clearSearch": "清除搜索" +} diff --git a/core/http/react-ui/public/locales/zh-CN/upgrade.json b/core/http/react-ui/public/locales/zh-CN/upgrade.json new file mode 100644 index 000000000..328f539e2 --- /dev/null +++ b/core/http/react-ui/public/locales/zh-CN/upgrade.json @@ -0,0 +1,46 @@ +{ + "badge": { + "available": "有可用更新", + "availableVersion": "有可用更新:{{version}}" + }, + "trigger": { + "all": "全部升级({{count}})", + "running": "正在升级 {{current}}/{{total}}", + "summary": "已升级 {{done}} 个,失败 {{failed}} 个,跳过 {{skipped}} 个", + "summaryOk": "已升级 {{done}} 个", + "review": "查看" + }, + "dialog": { + "title": "全部升级", + "intro": "选择要升级的项目。每个项目会单独、依次升级。某一项失败不会中断其他项目。", + "selection": "选择", + "selectAll": "全选", + "selectNone": "全不选", + "selectedCount_one": "已选 {{count}} 项", + "selectedCount_other": "已选 {{count}} 项", + "item": "升级 {{name}}", + "versions": "{{from}} → {{to}}", + "blocked": "无法开始", + "start": "升级所选", + "starting": "正在升级...", + "retry": "重试失败项({{count}})", + "again": "重新选择", + "close": "关闭", + "closeRunning": "关闭(继续运行)", + "runningNote": "关闭此窗口不会停止升级。", + "progress": "正在升级 {{current}}/{{total}}", + "summary": "已升级 {{done}} 个,失败 {{failed}} 个,跳过 {{skipped}} 个", + "empty": "没有可升级的项目。" + }, + "item": { + "queued": "排队中", + "upgrading": "升级中", + "done": "完成", + "failed": "失败", + "skipped": "已跳过" + }, + "reason": { + "checkAgain": "重新检查", + "checking": "检查中..." + } +} diff --git a/core/http/react-ui/public/locales/zh-CN/workspace.json b/core/http/react-ui/public/locales/zh-CN/workspace.json new file mode 100644 index 000000000..10e4b28d1 --- /dev/null +++ b/core/http/react-ui/public/locales/zh-CN/workspace.json @@ -0,0 +1,671 @@ +{ + "wellKnown": { + "revert": "恢复到上一个版本", + "view": "查看", + "form": "表单", + "save": "保存", + "cancel": "取消", + "notSet": "未设置", + "moveUp": "上移", + "moveDown": "下移", + "remove": "移除", + "savedAt": "保存于 {{time}}", + "notSaved": "尚未保存。", + "notSavedPrefilled": "尚未保存。部分值已为您填好;点击保存即可保留。", + "loadFailed": "无法加载这些设置。请刷新页面后重试。", + "invalid": "这些设置有错误,且没有更早的有效版本。", + "invalidFallback": "这些设置有错误。此处显示的是它们最后一个有效版本。", + "conflict": "这些设置在你编辑时发生了变化,可能是助手或其他标签页所改。较新的值如下:", + "useTheirs": "使用较新的值", + "keepMine": "保留我的更改", + "editWithAssistant": "通过助手编辑", + "from": { + "account": "来自您的账户", + "browser": "来自您的浏览器" + }, + "check": { + "required": "必填。", + "tooLong": "最多 {{max}} 个字符。", + "format": "此值的格式不正确。" + }, + "source": { + "label": "源文件", + "hint": "以文本形式显示的设置。保存时会以同样的方式检查。", + "notJson": "文本有语法错误。请检查引号、逗号和花括号。", + "notObject": "文本必须以 { 开头、以 } 结尾。" + }, + "persona": { + "title": "助手", + "lede": "助手在每次聊天中使用的名称和指令。" + }, + "user": { + "title": "您", + "lede": "助手会读取此内容,以了解对话对象。它会在每次聊天中传递给模型。" + }, + "ui": { + "title": "外观", + "lede": "LocalAI 对您的显示方式。更改会立即生效;保存即可保留。" + }, + "shortcuts": { + "title": "快捷指令", + "lede": "每次聊天输入框上方的提示词。", + "edit": "编辑快捷指令", + "defaults": "默认快捷指令", + "none": "没有快捷指令。添加一个,或保存空列表以隐藏。", + "label": "标签", + "prompt": "提示词", + "send": "立即发送", + "add": "添加快捷指令", + "invalid": "部分快捷指令有错误。", + "assistantPrompt": "修改我的快捷指令:" + }, + "home": { + "editLayout": "编辑布局", + "ask": "询问助手", + "askPrompt": "修改我的主页:", + "add": "添加小组件", + "newTitle": "新小组件", + "noWidgets": "还没有小组件。", + "type": "类型", + "title": "标题", + "source": "数据文件", + "noSourceNeeded": "无需数据文件", + "types": { + "list": "列表", + "metric": "指标", + "chats": "最近的聊天", + "page": "页面" + }, + "left": "剩余 {{count}}", + "fromChats": "来自聊天", + "noChats": "还没有聊天。", + "noData": "还没有数据。助手可以写入", + "nothing": "这里没有内容。", + "noSource": "此小组件没有数据文件。请在“编辑布局”中选择一个。", + "broken": "此小组件的数据有错误,且没有更早的有效版本。", + "stale": "正在显示最后一份有效数据。最新数据有错误。", + "updated": "更新于 {{when}}", + "footer": { + "noData": "暂无数据。", + "invalid": "数据有错误。正在显示最后一个正常版本。", + "revert": "还原", + "missing": "此小组件的数据缺失。", + "fix": "请助手修复", + "fixPrompt": "主页小组件读取的 {{file}} 缺失或已损坏。请修复:", + "waiting": "等待你在聊天中创建它的触发器。", + "openChat": "打开聊天", + "running": "正在更新…", + "openRun": "打开这次运行", + "paused": "已暂停 · 原为 {{schedule}}", + "resume": "继续", + "failedEmpty": "更新失败,目前还没有数据。", + "details": "详情", + "retry": "重试", + "stale": "上次更新失败。正在显示 {{when}} 的数据。", + "staleToday": "上次更新失败。正在显示今天较早的数据。", + "earlier": "之前", + "firstRun": "首次运行 {{when}}", + "firstRunFile": "有文件进入时运行", + "runNow": "立即运行", + "fresh": "{{schedule}} · 更新于 {{time}}", + "pause": "暂停", + "fromPage": "来自页面 {{title}}", + "fromFile": "来自你的文件", + "next": "下次运行 {{when}}", + "cost": "每次运行约 {{tokens}} 个 token", + "costRemote": "在远程模型上每次运行约 {{tokens}} 个 token", + "cadence": { + "paused": "已暂停", + "hourly": "每小时", + "daily": "每天", + "weekdays": "工作日", + "weekly": "每周", + "monthly": "每月", + "file": "有新文件时" + }, + "scheduleNote_one": "{{count}} 个小组件按计划更新。运行会使用你的模型并计入你的用量。", + "scheduleNote_other": "{{count}} 个小组件按计划更新。运行会使用你的模型并计入你的用量。" + }, + "opensSite": "{{host}}:在新标签页中打开", + "linkAsk": "此小组件想要打开一个链接", + "invalid": "你的主页布局有错误,且没有更早的有效版本。", + "invalidFallback": "你的主页布局有错误。主页显示的是它最后一个有效版本。", + "page": { + "gone": "此页面已不可用", + "goneHint": "页面“{{name}}”已被移动或删除,或者你已无法打开它。", + "noWidget": "此页面没有小组件视图", + "noWidgetHint": "请让助手为页面“{{name}}”制作一个用于主页的紧凑视图。", + "off": "共享页面已关闭", + "offHint": "管理员已在此服务器上关闭共享页面。", + "fix": "自定义主页", + "failed": "页面未能加载", + "retry": "重试", + "changed": "此页面在你允许后已更改", + "notAllowed": "你尚未允许此页面", + "allowHint": "它来自共享文件夹。在它以你的身份运行之前请先检查。", + "allow": "允许此页面", + "frameTitle": "{{title}}(主页上的页面)", + "refresh": "刷新", + "open": "打开页面", + "add": "添加页面", + "limit": "主页最多显示 {{max}} 个页面;请先移除一个再添加。", + "noneEligible": "还没有页面具有用于主页的紧凑视图。请让助手为某个页面添加一个。", + "pick": "页面", + "unavailable": "{{name}}(不可用)", + "sharedLabel": "共享", + "sizes": { + "small": "小", + "medium": "中", + "large": "大" + } + } + }, + "fields": { + "persona": { + "name": { + "label": "名称", + "help": "助手对自己的称呼。" + }, + "system_prompt": { + "label": "系统提示词", + "help": "会添加到每次聊天中,位于聊天自身的提示词和代理指令之后。" + } + }, + "user": { + "name": { + "label": "名称", + "help": "助手如何称呼您。" + }, + "language": { + "label": "语言", + "help": "助手回答时使用的语言。" + }, + "bio": { + "label": "关于您", + "help": "助手在每次聊天中阅读的几行内容。" + }, + "timezone": { + "label": "时区", + "help": "用于回答中的日期和时间。" + } + }, + "ui": { + "theme": { + "label": "主题", + "help": "“系统”跟随您设备的设置。", + "options": { + "dark": "深色", + "light": "浅色", + "system": "系统" + } + }, + "accent": { + "label": "强调色", + "help": "用于按钮和链接。留空则使用默认值。" + }, + "density": { + "label": "密度", + "help": "紧凑模式可在一屏内显示更多内容。", + "options": { + "comfortable": "舒适", + "compact": "紧凑" + } + }, + "defaultModel": { + "label": "默认模型", + "help": "新聊天开始时使用的模型。" + } + } + }, + "saveInvalid": "部分值无效。请检查后再次保存。", + "saveTooLarge": "这些设置太大,无法保存。请删除一些条目后重试。", + "saveFailed": "无法保存。请重试。", + "details": "详细信息" + }, + "discovery": { + "ex": { + "title": "打造你的 LocalAI", + "lede": "在聊天中说出来。助手会保存下来,你可以修改或撤销。", + "hide": "隐藏这些", + "hiddenToast": "示例已隐藏。", + "saveFailed": "未保存:{{message}}", + "card1": { + "kicker": "你和助手", + "prompt": "叫我{{firstName}},回答简短一些", + "promptNoName": "记住我的名字,回答简短一些", + "what": "保存你的名字和助手的回答方式" + }, + "card2": { + "kicker": "一个页面", + "prompt": "帮我做一个任务看板", + "what": "添加一个可从侧边栏打开的页面" + }, + "card3": { + "kicker": "每天早上", + "prompt": "每天早上在这里总结我的收件箱", + "promptNoMail": "每天早上在这里列出昨天的聊天", + "what": "添加一个主页小组件和一个需要你确认的触发器" + }, + "quote": "「{{text}}」", + "donePrefix": "完成。", + "doneLink": "在账户中修改", + "ready": "或从现成的页面开始:", + "readyTasks": "任务看板", + "readyReading": "阅读清单", + "readyFailed": "页面未能复制:{{message}}", + "learn": "工作原理", + "inComposer": "需要的话可以先修改,然后按 Enter。发送之前不会保存任何内容。", + "live": "提示已添加到消息框。" + }, + "home": { + "customise": "自定义主页" + }, + "action": { + "undo": "撤销", + "openAccount": "在账户中打开", + "openPage": "打开页面", + "openHome": "打开主页", + "turnBackOn": "在账户中重新开启" + }, + "change": { + "profilePersona": "已保存你的名字和给助手的指示。从你的下一条消息起,每个聊天都会使用它们。", + "profile": "已保存到你的个人资料。", + "persona": "已保存助手的指示。从你的下一条消息起,每个聊天都会使用它们。", + "shortcuts": "已保存你的快捷指令。", + "home": "已保存你的主页。主页会显示这项更改。", + "ui": "已保存你的外观设置。", + "pageCreated": "已创建页面 {{title}}。", + "pageChanged": "已修改页面 {{title}}。", + "offersOff": "助手的建议已关闭。", + "undoing": "正在撤销…", + "undone": "已撤销。", + "undoFailed": "未能撤销:{{message}}" + }, + "toast": { + "shortcutAdded": "已添加到你的输入框:{{label}}", + "pageCreated": "{{title}} 已添加到侧边栏的“页面”中", + "open": "打开", + "homeChanged": "主页已更新。", + "themeChanged": "主题已更改。" + }, + "files": { + "learn": "这些文件决定 LocalAI 如何为你工作。", + "tag": { + "profile": "你的资料", + "persona": "助手", + "shortcuts": "快捷方式", + "home": "主页布局", + "ui": "外观", + "page": "页面" + } + }, + "privacy": "每次聊天开始时,助手会收到你的名字、这段文字、你的语言和时区。把某个字段留空,就不会发送它。", + "versions": { + "label": "版本", + "title": "较早的版本", + "loading": "正在读取历史记录…", + "none": "还没有更早的版本。", + "current": "当前", + "invalid": "有错误", + "byAssistant": "由助手修改", + "byAssistantIn": "由助手在“{{chat}}”中修改", + "byYou": "由你修改", + "undo": "一次撤销", + "upload": "已上传", + "revert": "还原", + "note": "还原后,较新的版本仍保留在此列表中。", + "invalidBadge": "无效", + "revertGood": "还原到上一个正常版本" + }, + "help": { + "title": "帮助和建议", + "sub": "LocalAI 向你展示自身功能的方式。不需要的可以关闭。", + "offers": "助手的提议", + "offersHint": "每天最多一次,绝不在任务进行中出现,例如提议记住你说过的事。", + "new": "新功能提示", + "newHint": "某项内容首次出现时,侧边栏会显示一个简短的“新”标签。", + "examples": "主页上的示例", + "examplesShown": "只要还有可尝试的内容,“打造你的 LocalAI”卡片就会显示在主页上。", + "examplesHidden": "你已隐藏“打造你的 LocalAI”卡片。", + "showExamples": "重新显示示例", + "examplesBack": "示例已回到主页。", + "dismissed": "助手学到的内容", + "dismissedHint_one": "你拒绝了 {{count}} 条提议。在你重置之前,它们会一直隐藏。", + "dismissedHint_other": "你拒绝了 {{count}} 条提议。在你重置之前,它们会一直隐藏。", + "forget": "忘掉助手从我的提示中学到的内容", + "forgotten": "完成。助手可能会再次提议。", + "learn": "了解你可以更改什么:", + "dismissedNone": "暂无。你回应提议后,助手会记在这里。" + } + }, + "tabs": { + "shared": "共享", + "trashCount_one": "回收站中有 {{count}} 项", + "trashCount_other": "回收站中有 {{count}} 项" + }, + "trash": { + "empty": "回收站是空的。", + "restore": "恢复", + "emptyAll": "清空回收站", + "purge": "永久删除", + "purgeNamed": "永久删除 {{name}}", + "files_one": "{{count}} 个文件", + "files_other": "{{count}} 个文件", + "folders_one": "{{count}} 个文件夹", + "folders_other": "{{count}} 个文件夹", + "listJoin": ",", + "summary": "回收站中有:{{what}}。", + "removed": "已永久删除:{{what}}。", + "confirmAll": { + "title": "清空回收站?", + "message": "这将永久删除 {{what}}及其历史版本,且无法撤销。" + }, + "confirmOne": { + "title": "永久删除?", + "message": "{{path}}:这将永久删除 {{what}}及其历史版本,且无法撤销。" + } + }, + "shared": { + "adminTitle": "共享文件夹", + "adminLede": "多人共用的文件夹。成员可以打开其中的页面,他们的聊天可以读取或编辑这些文件。", + "userTitle": "与你共享", + "userLede": "管理员与你共享的文件夹。其页面在“页面”中,你的聊天可以使用这些文件。", + "new": "新建共享文件夹", + "nameLabel": "文件夹名称", + "nameHint": "最多 64 个字母、数字、空格、点、短横线或下划线,须以字母或数字开头。", + "create": "创建", + "cancel": "取消", + "rename": "重命名", + "renameSave": "保存", + "remove": "移除", + "manage": "成员", + "menu": "{{name}} 的更多操作", + "pages_one": "{{count}} 个页面", + "pages_other": "{{count}} 个页面", + "pagesNone": "没有页面", + "people_one": "{{count}} 人", + "people_other": "{{count}} 人", + "membersNone": "没有成员", + "everyone": "所有人", + "everyoneOption": "所有人(全部用户)", + "everyoneHint": "全部用户,包括以后加入的用户", + "everyoneCan": { + "read": "所有人可读", + "write": "所有人可编辑" + }, + "mode": { + "read": "可读", + "write": "可编辑" + }, + "addTitle": "添加成员", + "person": "用户", + "access": "权限", + "accessFor": "{{name}} 的权限", + "add": "添加", + "allAdded": "所有人都已是成员。", + "usersUnavailable": "无法加载用户列表。你仍可以添加“所有人”。", + "membersTitle": "成员", + "noMembers": "还没有人可以使用此文件夹。请添加用户或“所有人”。", + "removeMember": "移除 {{name}}", + "paneNote": "更改立即生效。已打开的聊天会在下次打开时看到。", + "created": "已创建“{{name}}”。请添加可以使用它的人。", + "removed": "已移除“{{name}}”。其页面在运行前会再次请求许可。", + "restored": "“{{name}}”已恢复,成员保持不变。", + "memberAdded": "{{name}}:{{mode}}。", + "memberRemoved": "{{name}} 已无法使用“{{folder}}”。", + "failed": "未完成:{{message}}", + "loadFailed": "无法加载共享文件夹。", + "emptyAdminTitle": "还没有共享文件夹", + "emptyAdminBody": "共享文件夹存放其他人可以读取或编辑的文件和页面。先创建一个,再添加成员。", + "factMembers": "给某个人或所有人读取或编辑权限", + "factChats": "成员的聊天可以看到文件夹中的文件", + "factUndo": "移除文件夹可以撤销", + "emptyUserTitle": "还没有与你共享的内容", + "emptyUserBody": "管理员与你共享文件夹后,它会显示在这里,其页面会显示在“页面”中。" + }, + "preview": { + "label": "预览", + "failed": "无法显示此文件。", + "loading": "正在加载 {{name}} 的预览", + "failedTitle": "无法显示 {{name}}。", + "tooLarge": "此文件太大,无法在这里预览。请下载后打开。", + "none": "此类型的文件没有预览。", + "typeFile": "{{ext}} 文件", + "typeUnknown": "文件", + "sheets": "工作表", + "sheetLabel": "工作表 {{name}}", + "sheetEmpty": "此工作表为空。", + "row": "行", + "showingRows": "显示 {{total}} 行中的 {{rows}} 行", + "showingCols": "以及 {{total}} 列中的 {{cols}} 列。", + "downloadRest": "下载文件查看其余内容。", + "textOnly": "文本预览,不显示格式。", + "truncated": "文本已截断。", + "noText": "此文件没有可显示的文本。", + "slide": "幻灯片 {{n}}", + "notes": "演讲者备注", + "pdfTitle": "PDF {{name}}", + "pdfFallback": "如果 PDF 没有显示,请下载。", + "htmlTitle": "{{name}} 的预览", + "imageFailed": "无法显示图片。", + "zoomIn": "按原始大小显示", + "zoomOut": "适应面板", + "items_one": "{{count}} 项", + "items_other": "{{count}} 项", + "textCut": "显示前 {{size}}。", + "actions": "文件操作" + }, + "managed": { + "group": "由 LocalAI 管理", + "marker": "由 LocalAI 管理", + "about": { + "localai": "LocalAI 读取的设置:角色、个人资料、快捷方式、主页、主题。", + "chats": "每个对话一个文件夹,存放该对话的文件。" + }, + "confirm": { + "deleteTitle": "将 {{name}} 移到回收站?", + "renameTitle": "重命名 {{name}}?", + "delete": "移到回收站", + "rename": "重命名", + "restore": "你可以从回收站恢复它。", + "renameNote": "LocalAI 只按这个名称读取它,重命名后就像它不存在一样。" + }, + "loses": { + "settings": "LocalAI 会忘记你的角色、个人资料、快捷方式、主页布局和主题。", + "chats": "每个对话都会失去它的文件:你附加的文件和助手在那里写的文件。", + "persona": "助手会失去它的名字和指令。", + "user": "助手会忘记你告诉它的关于你的信息,以及你对它的建议的回答。", + "shortcuts": "消息框会重新显示默认快捷方式。", + "home": "主页会失去它的小组件。", + "ui": "你的主题、强调色、密度、默认模型和固定的页面会恢复为默认值。" + } + }, + "newFile": "新建文件", + "newFolderName": "新建文件夹", + "newFileName": "未命名.md", + "fileName": "文件名", + "downloadOne": "下载 {{name}}", + "moveTo": "移动到…", + "uploadMenu": "更多上传选项", + "uploadFiles": "上传文件", + "uploadFolder": "上传文件夹", + "loading": "正在加载文件夹", + "loadFailed": "无法打开 {{folder}}。", + "types": { + "folder": "文件夹", + "image": "图片", + "pdf": "PDF", + "sheet": "电子表格", + "doc": "文档", + "slides": "演示文稿", + "archive": "压缩包", + "audio": "音频", + "video": "视频", + "code": "代码", + "text": "文本", + "file": "文件" + }, + "columns": { + "name": "名称", + "modified": "修改时间", + "type": "类型", + "size": "大小", + "actions": "操作" + }, + "view": { + "label": "视图", + "list": "列表", + "grid": "缩略图" + }, + "select": { + "one": "选择 {{name}}", + "all": "全选", + "bar": "已选项目", + "clear": "取消选择", + "count_one": "已选 {{count}} 项", + "count_other": "已选 {{count}} 项", + "start": "选择" + }, + "menu": { + "label": "{{name}} 的操作", + "more": "{{name}} 的更多操作", + "openFolder": "打开", + "preview": "预览", + "downloadMany_one": "下载 {{count}} 个文件", + "downloadMany_other": "下载 {{count}} 个文件", + "moveMany_one": "将 {{count}} 项移动到…", + "moveMany_other": "将 {{count}} 项移动到…", + "deleteMany_one": "将 {{count}} 项移到回收站", + "deleteMany_other": "将 {{count}} 项移到回收站" + }, + "keys": { + "delete": "Del" + }, + "crumbs": { + "hidden": "显示上级文件夹" + }, + "naming": { + "empty": "请输入名称。", + "slash": "名称不能包含 / 或 \\。", + "taken": "此文件夹中已有同名项目。" + }, + "toast": { + "undo": "撤销", + "deletedOne": "已将 {{name}} 移到回收站。", + "deleted_one": "已将 {{count}} 项移到回收站。", + "deleted_other": "已将 {{count}} 项移到回收站。", + "restored": "已还原。", + "renamed": "已重命名为 {{name}}。", + "renameUndone": "已恢复原名称。", + "movedOne": "已将 {{name}} 移动到 {{folder}}。", + "moved_one": "已将 {{count}} 项移动到 {{folder}}。", + "moved_other": "已将 {{count}} 项移动到 {{folder}}。", + "moveUndone": "已移回原位置。" + }, + "search": { + "placeholder": "筛选此文件夹", + "resultsFor": "包含“{{query}}”的文件", + "back": "返回文件夹", + "noneBody": "你的文件中没有匹配的名称或文本。换个词试试。", + "page": "第 {{n}} 页", + "sheet": "工作表“{{name}}”", + "slide": "第 {{n}} 张幻灯片", + "indexing_one": "还有 {{count}} 个文档正在建立索引,请稍后再搜索以包含它。", + "indexing_other": "还有 {{count}} 个文档正在建立索引,请稍后再搜索以包含它们。", + "limited": "结果有限:搜索在查看所有文件之前已达到上限。请在某个文件夹中搜索或换用其他词语以查看其余结果。", + "filtering_one": "此文件夹中有 {{count}} 项与“{{query}}”匹配。", + "filtering_other": "此文件夹中有 {{count}} 项与“{{query}}”匹配。", + "everywhere": "在所有文件中搜索“{{query}}”", + "everywhereShort": "搜索所有文件", + "noMatch": "此文件夹中没有与“{{query}}”匹配的项目。", + "clear": "清除筛选" + }, + "emptyHome": { + "title": "你的文件都在这里" + }, + "emptyFolder": { + "title": "此文件夹为空", + "body": "将文件或文件夹拖放到此页面任意位置,用 Ctrl+V 粘贴,或从电脑上传。助手也可以在聊天中把文件保存到这里。", + "bodyTouch": "从设备上传文件。助手也可以在聊天中把文件保存到这里。", + "ask": "询问助手" + }, + "drop": { + "title": "松开即可上传到 {{folder}}", + "body": "支持文件和整个文件夹。", + "limit": "支持文件和整个文件夹,每个最大 {{size}}。" + }, + "conflict": { + "title_one": "已存在同名文件", + "title_other": "已存在 {{count}} 个同名文件", + "body_one": "{{folder}} 中已有同名文件。", + "body_other": "{{folder}} 中已有这些同名文件。", + "skip_one": "跳过", + "skip_other": "全部跳过", + "keep": "保留两者", + "replace_one": "替换", + "replace_other": "全部替换", + "more_one": "另有 {{count}} 个", + "more_other": "另有 {{count}} 个", + "note": "选择“保留两者”会为新文件另起名称。被替换的文件可以从历史记录中找回。", + "cancel": "取消上传" + }, + "move": { + "title_one": "移动 {{count}} 项", + "title_other": "移动 {{count}} 项", + "items_one": "{{count}} 项", + "items_other": "{{count}} 项", + "cancel": "取消", + "here": "移动到 {{folder}}", + "failed": "无法读取此文件夹。", + "noFolders": "这里没有文件夹。可移动到此处,或返回上一级。" + }, + "uploads": { + "waiting": "等待中", + "progress": "{{loaded}} / {{total}}", + "rate": "{{rate}}/秒", + "left": "剩余 {{time}}", + "renamed": "已另存为 {{name}}", + "done": "已上传", + "canceled": "已取消", + "cancel": "取消", + "cancelOne": "取消上传 {{name}}", + "retry": "重试", + "retryOne": "重试 {{name}}", + "dismiss": "关闭", + "dismissOne": "关闭 {{name}}", + "show": "显示", + "showOne": "显示 {{name}}", + "meterOne": "{{name}}:{{percent}}%", + "meterAll": "全部上传:{{percent}}%", + "cancelAll": "全部取消", + "retryAll": "全部重试", + "fold": "隐藏列表", + "unfold": "显示列表", + "close": "关闭", + "head": { + "sending_one": "正在上传 {{count}} 个文件", + "sending_other": "正在上传 {{count}} 个文件", + "failed_one": "{{count}} 个上传失败", + "failed_other": "{{count}} 个上传失败", + "done_one": "已上传 {{count}} 个文件", + "done_other": "已上传 {{count}} 个文件" + }, + "say": { + "start_one": "正在上传 {{count}} 个文件。", + "start_other": "正在上传 {{count}} 个文件。", + "done_one": "上传完成:{{count}} 个文件。", + "done_other": "上传完成:{{count}} 个文件。", + "doneFailed": "上传完成:{{count}} 个成功,{{failed}} 个失败。", + "failed": "无法上传 {{name}}。" + }, + "errors": { + "tooBig": "超过此服务器 {{limit}} 的限制。", + "tooLarge": "文件太大,服务器已拒绝。", + "off": "此服务器已关闭文件功能。", + "invalid": "服务器拒绝了此文件。", + "network": "连接已断开。", + "server": "服务器无法保存。" + }, + "said": "服务器返回:{{reason}}。" + } +} diff --git a/core/http/react-ui/scripts/i18n-check.mjs b/core/http/react-ui/scripts/i18n-check.mjs new file mode 100644 index 000000000..33d1342d6 --- /dev/null +++ b/core/http/react-ui/scripts/i18n-check.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** + * Locale completeness ratchet. + * + * English (public/locales/en) is the source of truth. Every other locale must + * have every namespace file and every key of English. `fallbackLng: 'en'` + * hides a gap at run time, so a locale that never got a new page shows English + * text with no error anywhere. This gate makes the gap visible in CI. + * + * Existing gaps are recorded in i18n-baseline.json as a count of missing keys + * per locale and namespace (a missing file counts all its keys). The gate + * only lets that number go down: + * + * - a locale and namespace with more missing keys than the baseline fails; + * - one with fewer fails too, until you lower the baseline, so a fixed gap + * cannot come back unnoticed; + * - a namespace file that a locale lacks fails whatever the baseline says: + * the browser asks the server for every namespace of the language it + * shows, and a missing file answers 404 on every page load. + * + * node scripts/i18n-check.mjs # check against the baseline + * node scripts/i18n-check.mjs --write # save the current gaps as baseline + * node scripts/i18n-check.mjs --report # list every missing key + */ +import { readFileSync, writeFileSync, readdirSync, existsSync } from 'node:fs' +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') +const LOCALES = join(ROOT, 'public/locales') +const BASELINE = join(ROOT, 'i18n-baseline.json') +const SOURCE = 'en' + +// A leaf is a string, number, boolean or null; objects nest and arrays are +// leaves, which is how i18next treats them with returnObjects. +function leaves(value, prefix = '', out = []) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + for (const [key, child] of Object.entries(value)) leaves(child, prefix ? `${prefix}.${key}` : key, out) + } else { + out.push(prefix) + } + return out +} + +function readKeys(file) { + return new Set(leaves(JSON.parse(readFileSync(file, 'utf8')))) +} + +const dirs = (path) => readdirSync(path, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name).sort() +const namespaces = (locale) => readdirSync(join(LOCALES, locale)).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5)).sort() + +/** Returns the 'locale/namespace' names of the files a locale lacks. */ +export function missingFiles() { + const out = [] + for (const locale of dirs(LOCALES).filter((l) => l !== SOURCE)) { + for (const ns of namespaces(SOURCE)) { + if (!existsSync(join(LOCALES, locale, `${ns}.json`))) out.push(`${locale}/${ns}`) + } + } + return out +} + +/** Returns { 'locale/namespace': [missing keys] } for every gap. */ +export function gaps() { + const found = {} + const source = Object.fromEntries(namespaces(SOURCE).map((ns) => [ns, readKeys(join(LOCALES, SOURCE, `${ns}.json`))])) + for (const locale of dirs(LOCALES).filter((l) => l !== SOURCE)) { + for (const [ns, keys] of Object.entries(source)) { + const file = join(LOCALES, locale, `${ns}.json`) + const have = existsSync(file) ? readKeys(file) : new Set() + const missing = [...keys].filter((k) => !have.has(k)).sort() + if (missing.length) found[`${locale}/${ns}`] = missing + } + } + return found +} + +function main(argv) { + const found = gaps() + const counts = Object.fromEntries(Object.entries(found).map(([name, keys]) => [name, keys.length])) + if (argv.includes('--report')) { + for (const [name, keys] of Object.entries(found)) console.log(`${name}: ${keys.length} missing\n ${keys.join('\n ')}`) + return 0 + } + if (argv.includes('--write')) { + writeFileSync(BASELINE, JSON.stringify(counts, null, 2) + '\n') + console.log(`i18n baseline written: ${Object.keys(counts).length} locale namespaces with gaps`) + return 0 + } + const baseline = existsSync(BASELINE) ? JSON.parse(readFileSync(BASELINE, 'utf8')) : {} + const problems = missingFiles().map((name) => `${name}.json is missing. Add the file with the keys of English, translated.`) + for (const name of new Set([...Object.keys(counts), ...Object.keys(baseline)])) { + const now = counts[name] || 0 + const was = baseline[name] || 0 + if (now > was) { + const sample = (found[name] || []).slice(0, 5).join(', ') + problems.push(`${name}: ${now} keys missing, baseline allows ${was} (for example ${sample}). Add the keys to the locale.`) + } else if (now < was) { + problems.push(`${name}: ${now} keys missing, baseline says ${was}. Lower the baseline with: node scripts/i18n-check.mjs --write`) + } + } + if (problems.length) { + console.error(`i18n completeness failed:\n${problems.map((p) => ' ' + p).join('\n')}`) + return 1 + } + const total = Object.values(counts).reduce((a, b) => a + b, 0) + console.log(`i18n: no new gaps (${total} keys missing in ${Object.keys(counts).length} locale namespaces, all in the baseline).`) + return 0 +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) process.exit(main(process.argv.slice(2))) diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 938be3626..bb3dec384 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -248,34 +248,6 @@ min-height: 72px; } -.sidebar-logo-link { - display: flex; - align-items: center; - min-width: 0; - /* Line the mark up with the nav icons: row margin plus row padding. */ - padding-left: var(--spacing-sm); - border-radius: var(--radius-md); -} - -.sidebar-logo-img { - display: block; - width: 136px; - max-width: 100%; - height: auto; - max-height: 64px; - object-fit: contain; -} - -.sidebar-logo-icon { - display: none; -} - -.sidebar-logo-icon-img { - display: block; - width: 36px; - height: 36px; - object-fit: contain; -} .sidebar-close-btn { display: none; @@ -301,6 +273,13 @@ overflow-y: auto; } +/* A short nav pinned under the scrolling one (Administration). It keeps the + .sidebar-nav item treatment but does not grow or scroll. */ +.sidebar-nav--pinned { + flex: none; + overflow: visible; +} + .sidebar-section { padding: var(--spacing-xs) 0; } @@ -777,26 +756,7 @@ font-size: 0.75rem; } -/* App boot fallback (rendered while initial i18n namespaces load) */ -.app-boot-spinner { - position: fixed; - inset: 0; - display: flex; - align-items: center; - justify-content: center; - background: var(--color-bg-primary); -} -.app-boot-spinner-dot { - width: 36px; - height: 36px; - border-radius: 50%; - border: 3px solid var(--color-border-subtle); - border-top-color: var(--color-primary); - animation: app-boot-spin 0.8s linear infinite; -} -@keyframes app-boot-spin { - to { transform: rotate(360deg); } -} +/* The boot screen is the app shell: ShellFallback in components/RouteFallback.jsx. */ /* Operations strip: always exactly one line. Anything that does not fit here belongs on /app/activity. */ @@ -829,9 +789,8 @@ border-radius: 50%; border: 2px solid var(--color-primary-light); border-top-color: var(--color-primary); - animation: operationsStripSpin 0.9s linear infinite; + animation: dk-spin 0.9s linear infinite; } -@keyframes operationsStripSpin { to { transform: rotate(360deg); } } .operations-strip__verb { flex: none; color: var(--color-text-secondary); white-space: nowrap; } .operations-strip__name { @@ -946,7 +905,7 @@ border: 2px solid var(--color-border-default); border-top-color: var(--color-primary); border-radius: 50%; - animation: spin 0.8s linear infinite; + animation: dk-spin 0.8s linear infinite; display: inline-block; } @@ -964,7 +923,7 @@ background: var(--color-primary); border-radius: var(--radius-full); transition: width var(--duration-slow) var(--ease-spring); - animation: opsBarBreathe 1.4s ease-in-out infinite; + animation: pulse 1.4s ease-in-out infinite; } /* Inline install indicator — used in table rows (Models, Backends) */ @@ -987,85 +946,7 @@ white-space: nowrap; } -/* Toast — a pill on the inverse surface; success and error are solid fills. - The stack sits at the bottom centre and rises in 16px. */ -.toast-container { - position: fixed; - left: 50%; - bottom: var(--spacing-md); - transform: translateX(-50%); - width: min(440px, calc(100vw - 32px)); - z-index: 1100; - display: flex; - flex-direction: column; - align-items: center; - gap: var(--spacing-sm); - pointer-events: none; -} - -.toast { - pointer-events: auto; - display: flex; - align-items: center; - gap: var(--spacing-sm); - width: 100%; - min-height: 48px; - padding: var(--spacing-sm) var(--spacing-sm) var(--spacing-sm) var(--spacing-md); - background: var(--dk-inverse); - border: 0; - border-radius: var(--radius-xl); - box-shadow: var(--shadow-lg); - color: var(--dk-on-inverse); - font-family: var(--font-sans); - font-size: var(--text-sm); - line-height: 1.4; - animation: dk-toast-in var(--dk-dur-2) var(--dk-ease-out) both; -} - -.toast-enter { - opacity: 0; - transform: translateY(16px); -} - -.toast-exit { - animation: dk-toast-out var(--dk-dur-1) ease-in both; -} - -.toast-success { background: var(--dk-ok); color: var(--dk-on-ok); } -.toast-error { background: var(--dk-error); color: var(--dk-on-error); } -.toast-warning { background: var(--dk-warn); color: var(--dk-on-warn); } -.toast-info { background: var(--dk-inverse); color: var(--dk-on-inverse); } - -.toast > span { flex: 1 1 auto; min-width: 0; } - -.toast-close { - margin-left: auto; - background: none; - border: none; - border-radius: var(--radius-sm); - color: inherit; - opacity: 0.8; - cursor: pointer; - min-width: 32px; - height: 32px; - display: inline-grid; - place-items: center; - transition: background-color var(--dk-dur-1) ease, opacity var(--dk-dur-1) ease, - transform var(--dk-dur-1) var(--dk-ease-out); -} -.toast-close:hover { opacity: 1; background: color-mix(in srgb, currentColor 16%, transparent); } -.toast-close:active { transform: scale(var(--dk-press-scale)); } -.toast :focus-visible { outline-color: currentColor; } -.toast-link { - font-size: var(--text-sm); - font-weight: var(--font-weight-semibold); - color: inherit; - opacity: 0.9; - text-decoration: underline; - white-space: nowrap; - margin-left: var(--spacing-xs); -} -.toast-link:hover { opacity: 1; } +/* Toast: moved to components/ui/ui.css. */ /* Chat error trace link */ .chat-error-trace-link { @@ -1082,36 +963,7 @@ text-decoration: underline; } -/* Spinner */ -.spinner { - display: flex; - align-items: center; - justify-content: center; -} -.spinner-ring { - border: 3px solid var(--color-border-subtle); - border-top-color: var(--color-primary); - border-radius: 50%; - animation: spin 0.8s linear infinite; -} -.spinner-sm .spinner-ring { width: 16px; height: 16px; } -.spinner-md .spinner-ring { width: 24px; height: 24px; } -.spinner-lg .spinner-ring { width: 40px; height: 40px; } - -/* Skeleton shimmer placeholders */ -.skeleton { - display: block; - border-radius: var(--radius-sm); - background: linear-gradient(90deg, var(--dk-inset) 0%, var(--dk-hover) 50%, var(--dk-inset) 100%); - background-size: 200% 100%; - animation: skeletonShimmer 1.3s linear infinite; -} -.skeleton--line { height: 0.85em; margin: 0.35em 0; width: 100%; } -.skeleton--block { height: 100%; width: 100%; border-radius: var(--radius-md); } -@keyframes skeletonShimmer { - from { background-position: 200% 0; } - to { background-position: -200% 0; } -} +/* Spinner and skeleton: moved to components/ui/ui.css. */ /* Model selector */ .model-selector { @@ -1140,32 +992,14 @@ width: 100%; max-width: var(--page-max, var(--page-max-default)); margin: 0 auto; - animation: fadeIn var(--duration-normal) var(--ease-default); + animation: dk-veil-in var(--duration-normal) var(--ease-default); } .page--narrow { --page-max: var(--page-max-narrow); } .page--medium { --page-max: var(--page-max-medium); } .page--wide { --page-max: var(--page-max-wide); } -.page-header { - margin-bottom: var(--spacing-xl); -} - -/* The editorial title scale from the site: larger, set tighter, and on a line - height near 1 so a two-word title reads as a statement rather than a label. - The typeface is unchanged — DESIGN.md keeps the existing type system — so the - whole difference is scale, tracking and leading, which is where the site gets - its voice from in the first place. */ -.page-title { - font-family: var(--font-sans); - font-size: var(--text-3xl); - font-weight: var(--font-weight-bold); - letter-spacing: -0.03em; - line-height: 1.1; - margin-bottom: var(--spacing-xs); - color: var(--color-text-primary); - text-wrap: balance; -} +/* .page-header and .page-title: moved to components/ui/ui.css. */ /* Mid hierarchy tier — between page title and the xs uppercase group labels */ .section-heading { @@ -1178,18 +1012,6 @@ margin: 0; } -.page-header--editorial { - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: var(--spacing-lg); - flex-wrap: wrap; - margin-bottom: var(--space-section, var(--spacing-xl)); -} -.page-header__lead { display: flex; flex-direction: column; gap: var(--spacing-xs); min-width: 0; } -.page-header__supporting { color: var(--color-text-secondary); font-size: var(--text-base); margin: 0; max-width: 60ch; } -.page-header__meta { display: flex; align-items: center; gap: var(--spacing-sm); flex-wrap: wrap; } - .page-subtitle { font-size: var(--text-sm); color: var(--color-text-secondary); @@ -1448,36 +1270,8 @@ button.card:active { accent-color: var(--color-primary); cursor: pointer; } -/* Buttons — flat. Hover swaps the surface, press scales to .97, focus is a - 2px ring with a 2px offset. Danger is a tinted wash, never a solid fill. */ -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: var(--spacing-xs); - padding: 0 14px; - min-height: var(--control-h); - min-width: var(--control-h); - border-radius: var(--radius-md); - font-size: var(--text-sm); - font-family: inherit; - font-weight: var(--font-weight-bold); - letter-spacing: 0; - cursor: pointer; - border: 1px solid transparent; - transition: background-color var(--dk-dur-1) ease, - color var(--dk-dur-1) ease, - border-color var(--dk-dur-1) ease, - transform var(--dk-dur-1) var(--dk-ease-out); - text-decoration: none; - white-space: nowrap; -} - -.btn:focus-visible { - outline: 2px solid var(--color-focus-ring); - outline-offset: 2px; - box-shadow: none; -} +/* Buttons (.btn and its variants): moved to components/ui/ui.css, where + they are aliases of the kit button. */ /* Global focus ring for any interactive that is not a .btn: a 2px outline in the accent with a 2px offset. It is wrapped in :where() so component rules @@ -1488,61 +1282,6 @@ button.card:active { outline-offset: 2px; } -.btn-primary { - background: var(--color-primary); - color: var(--color-primary-text); -} -.btn-primary:hover:not(:disabled) { - background: var(--color-primary-hover); -} - -.btn-secondary { - background: var(--color-bg-tertiary); - color: var(--color-text-primary); - border-color: transparent; -} -.btn-secondary:hover:not(:disabled) { - background: var(--color-bg-hover); -} - -.btn-ghost { - background: transparent; - color: var(--color-text-secondary); - border-color: transparent; -} -.btn-ghost:hover:not(:disabled) { - background: var(--color-bg-hover); - color: var(--color-text-primary); -} - -.btn-danger { - background: var(--color-error-light); - color: var(--dk-error-text); - border-color: transparent; -} -.btn-danger:hover:not(:disabled) { - background: color-mix(in srgb, var(--dk-error-wash) 82%, var(--dk-error)); - color: var(--dk-error-text); -} - -.btn-sm { - padding: 0 var(--spacing-sm); - min-height: var(--control-h-sm); - min-width: var(--control-h-sm); - font-size: var(--text-xs); - letter-spacing: 0; -} - -.btn:active:not(:disabled) { - transform: scale(var(--dk-press-scale)); -} - -.btn:disabled { - opacity: 0.5; - cursor: not-allowed; - transform: none; -} - /* Native checkboxes, radios and sliders take the accent. */ input[type="checkbox"], input[type="radio"], @@ -1814,20 +1553,6 @@ select.input { align-items: center; } -/* Softer delete button for dense tables — the destructive confirm dialog - already owns the "are you sure" affordance, so the button itself doesn't - need to scream. Keeps the delete red readable without dominating rows. */ -.btn.btn-danger-ghost { - background: transparent; - color: var(--dk-error-text); - border-color: transparent; -} -.btn.btn-danger-ghost:hover:not(:disabled) { - background: var(--color-error-light); - color: var(--dk-error-text); - border-color: transparent; -} - /* Small count pill used inside tabs ("(3) ↑ 2") so update counts are glanceable without extra rows of UI. */ .tab-pill { @@ -2668,135 +2393,22 @@ button.collapsible-header:focus-visible { margin-top: var(--spacing-sm); } -/* Empty state */ -/* Empty state — editorial: eyebrow rule + large mono headline + lede. - Existing pages pass `icon`, `title`, `text` children into .empty-state as - nested elements; the rules below re-style each without JSX changes. */ -.empty-state { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: var(--spacing-md); - text-align: left; - padding: var(--spacing-3xl) var(--spacing-xl); - max-width: 640px; - margin: 0 auto; - animation: fadeIn var(--duration-slow) var(--ease-spring); -} - -/* Center the column within its parent without shrinking type. */ -.empty-state > * { max-width: 100%; } - -.empty-state-icon { - font-size: 1.5rem; - color: var(--color-primary); - opacity: 0.7; - margin: 0; -} - -.empty-state-title { - font-family: var(--font-sans); - font-size: clamp(1.5rem, 3vw, var(--text-3xl)); - font-weight: var(--font-weight-semibold); - letter-spacing: -0.02em; - color: var(--color-text-primary); - margin: 0; - line-height: 1.15; -} - -.empty-state-text { - color: var(--color-text-secondary); - font-size: var(--text-base); - line-height: var(--leading-normal); - max-width: 52ch; - margin: 0; -} - -/* Secondary to .empty-state-text: a hint about a default that may be narrowing - the result, so it must not compete with the reason stated above it. */ -.empty-state-hint { - color: var(--color-text-muted); - font-size: var(--text-sm); - line-height: var(--leading-normal); - max-width: 52ch; - margin: 0; -} - -/* Opt-in editorial sub-parts — pages can adopt these class names over time */ -.empty-state__eyebrow { - display: inline-flex; - align-items: center; - gap: var(--spacing-sm); - font-family: var(--font-mono); - font-size: 0.625rem; - letter-spacing: 0.24em; - text-transform: uppercase; - color: var(--color-text-muted); -} -.empty-state__eyebrow::before { - content: ""; - display: block; - width: 24px; - height: 1px; - background: var(--color-border-strong); -} - -.empty-state__actions { - display: flex; - gap: var(--spacing-sm); - flex-wrap: wrap; - margin-top: var(--spacing-xs); -} +/* Empty state: moved to components/ui/ui.css. */ /* Animations */ -@keyframes spin { - to { transform: rotate(360deg); } -} -@keyframes slideIn { - from { opacity: 0; transform: translateX(12px); } - to { opacity: 1; transform: translateX(0); } -} -@keyframes toastSlideIn { - from { opacity: 0; transform: translateX(12px); } - to { opacity: 1; transform: translateX(0); } -} -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} -@keyframes popIn { - from { opacity: 0; transform: scale(0.96) translateY(4px); } - to { opacity: 1; transform: scale(1) translateY(0); } -} @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } -@keyframes attentionPulse { - 0%, 100% { opacity: 0.65; } - 50% { opacity: 1; } -} -@keyframes opsBarBreathe { - 0%, 100% { opacity: 0.85; } - 50% { opacity: 1; } -} -@keyframes breathingRing { - 0%, 100% { box-shadow: inset 0 0 0 1.5px var(--color-primary-light); } - 50% { box-shadow: inset 0 0 0 2px var(--color-primary); } -} -@keyframes messageSlideIn { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } -} @keyframes dropdownIn { from { opacity: 0; transform: translateY(-4px); } @@ -2808,7 +2420,7 @@ button.collapsible-header:focus-visible { /* Fill backwards, not both: a finished animation must not leave a transform on the page, because that would make fixed overlays (dialog veils) cover only the main column instead of the viewport. */ - animation: pageReveal 250ms ease-out backwards; + animation: homeRise var(--dur-page) var(--ease-out) backwards; display: flex; flex-direction: column; flex: 1; @@ -2816,15 +2428,11 @@ button.collapsible-header:focus-visible { min-width: 0; } -@keyframes pageReveal { - from { opacity: 0; transform: translateY(6px); } - to { opacity: 1; transform: translateY(0); } -} /* Orchestrated child stagger - opt-in via .reveal-stagger on a container. Each direct child reveals with an incremental delay driven by --reveal-index. */ .reveal-stagger > * { - animation: pageReveal var(--duration-reveal) var(--ease-reveal) both; + animation: homeRise var(--duration-reveal) var(--ease-reveal) both; animation-delay: calc(var(--reveal-index, 0) * 60ms); } @@ -2996,7 +2604,7 @@ button.collapsible-header:focus-visible { min-width: 0; padding-bottom: var(--spacing-md); border-bottom: 1px solid var(--color-border-divider); - animation: messageSlideIn 250ms ease-out; + animation: homeRise var(--dur-base) var(--ease-out); } /* A transcript, not bubbles. Rounded filled asymmetric bubbles fight a system @@ -3344,10 +2952,6 @@ button.collapsible-header:focus-visible { /* Inline streaming speed indicator */ /* Thinking dots animation */ -@keyframes thinkingBounce { - 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } - 40% { transform: scale(1); opacity: 1; } -} /* Staging progress indicator (replaces thinking dots during model transfer) */ @@ -3944,7 +3548,7 @@ button.collapsible-header:focus-visible { justify-content: center; padding: var(--spacing-xl); background: var(--color-modal-backdrop); - animation: fadeIn var(--duration-fast) var(--ease-default); + animation: dk-veil-in var(--duration-fast) var(--ease-default); } .lightbox__img { max-width: 92vw; @@ -4169,12 +3773,7 @@ button.collapsible-header:focus-visible { /* Mobile reflow polish — phone-only (<640) layout adjustments for page chrome that was designed flex-row on desktop. */ @media (max-width: 639px) { - /* Page header: stack title block + inline-action cluster vertically */ - .page-header { - flex-direction: column; - align-items: stretch; - gap: var(--spacing-md); - } + /* Page header on a phone: components/ui/ui.css. */ /* Filter chip rows scroll horizontally instead of wrapping into walls */ .filter-bar { @@ -4211,245 +3810,6 @@ button.collapsible-header:focus-visible { } } -/* Canvas panel: the wide view that opens beside the thread on demand. It is - shared by Chat and the Agent chat, so it speaks only kit roles. */ -.canvas-panel { - position: relative; - width: 52%; - max-width: 820px; - flex-shrink: 0; - display: flex; - flex-direction: column; - background: var(--dk-card); - box-shadow: inset 1px 0 0 var(--dk-edge); - overflow: hidden; - animation: canvasSlideIn var(--dk-dur-3) var(--dk-ease-out) both; -} -@keyframes canvasSlideIn { - from { opacity: 0; transform: translateX(24px); } - to { opacity: 1; transform: translateX(0); } -} -/* Drag-to-resize handle on the panel's left edge. */ -.canvas-resize-handle { - position: absolute; - top: 0; - left: -3px; - width: 7px; - height: 100%; - cursor: col-resize; - z-index: 2; - background: transparent; - transition: background var(--dk-dur-1) var(--dk-ease-out); -} -.canvas-resize-handle:hover, -.canvas-resize-handle:active { background: var(--dk-accent-wash); } -/* On a narrow window the canvas takes the whole page area; no edge-drag. */ -@media (max-width: 1024px) { - .canvas-resize-handle { display: none; } - .canvas-panel { - position: absolute; - inset: 0; - z-index: 40; - width: 100% !important; - max-width: none !important; - } -} -.canvas-header-actions { display: inline-flex; gap: 2px; } -/* Fullscreen: the canvas covers the viewport. */ -.canvas-panel--fullscreen { - position: fixed; - inset: 0; - width: 100% !important; - max-width: none !important; - z-index: var(--dk-z-veil); - box-shadow: none; -} -.canvas-panel-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - height: 52px; - padding: 0 10px 0 16px; - box-shadow: inset 0 -1px 0 var(--dk-edge); - flex-shrink: 0; -} -.canvas-panel-title { - font-family: var(--font-mono); - font-weight: 600; - font-size: 13px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.canvas-icobtn, -.canvas-textbtn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - border: 0; - background: none; - color: var(--color-text-secondary); - font: inherit; - cursor: pointer; - transition: background var(--dk-dur-1) var(--dk-ease-out), color var(--dk-dur-1) var(--dk-ease-out); -} -.canvas-icobtn { width: 34px; height: 34px; border-radius: var(--dk-radius-1); font-size: 17px; } -.canvas-textbtn { height: 30px; padding: 0 10px; border-radius: var(--dk-radius-1); font-size: 13px; font-weight: 600; } -.canvas-icobtn:hover, -.canvas-textbtn:hover { background: var(--dk-hover); color: var(--dk-text); } -.canvas-icobtn:focus-visible, -.canvas-textbtn:focus-visible, -.canvas-panel-tab:focus-visible, -.canvas-toggle-btn:focus-visible { outline: 2px solid var(--dk-accent-text); outline-offset: 2px; } -.canvas-panel-tabs { - overflow-x: auto; - display: flex; - gap: 2px; - padding: 6px 8px; - box-shadow: inset 0 -1px 0 var(--dk-edge); - flex-shrink: 0; - scrollbar-width: thin; -} -.canvas-panel-tab { - display: inline-flex; - align-items: center; - gap: 8px; - height: 32px; - padding: 0 12px; - border: 0; - border-radius: var(--dk-radius-1); - background: none; - color: var(--color-text-secondary); - font-family: var(--font-mono); - font-size: 12.5px; - font-weight: 600; - cursor: pointer; - white-space: nowrap; - flex-shrink: 0; - transition: background var(--dk-dur-1) var(--dk-ease-out); -} -.canvas-panel-tab:hover { background: var(--dk-hover); } -.canvas-panel-tab.active { - background: var(--dk-inset); - color: var(--dk-text); - box-shadow: inset 0 0 0 1px var(--dk-edge); -} -.canvas-panel-tab span { - max-width: 140px; - overflow: hidden; - text-overflow: ellipsis; -} -.canvas-panel-toolbar { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 12px; - box-shadow: inset 0 -1px 0 var(--dk-edge); - flex-shrink: 0; -} -.canvas-toggle-group { - display: inline-flex; - gap: 2px; - padding: 2px; - border-radius: 9px; - background: var(--dk-inset); - box-shadow: inset 0 0 0 1px var(--dk-edge); -} -.canvas-toggle-btn { - height: 28px; - padding: 0 10px; - border: 0; - border-radius: 7px; - background: none; - color: var(--color-text-secondary); - font: inherit; - font-size: 12.5px; - font-weight: 600; - cursor: pointer; -} -.canvas-toggle-btn.active { - background: var(--dk-card); - color: var(--dk-text); - box-shadow: inset 0 0 0 1px var(--dk-edge); -} -.canvas-panel-body { - flex: 1; - overflow: auto; - padding: 0; - min-height: 0; - background: var(--dk-inset); -} -.canvas-panel-body pre { - margin: 0; - padding: 16px 20px; - background: none; - color: var(--color-text-secondary); - font-family: var(--font-mono); - font-size: 13px; - line-height: 1.7; -} - -/* Artifact card (inline in messages) */ -.artifact-card { - display: flex; - align-items: center; - gap: var(--spacing-sm); - padding: var(--spacing-sm) var(--spacing-md); - border: 1px solid var(--color-border-default); - border-radius: var(--radius-md); - cursor: pointer; - background: var(--color-bg-tertiary); - margin: var(--spacing-sm) 0; - transition: border-color 150ms; -} -.artifact-card:hover { - border-color: var(--color-primary); -} -.artifact-card-icon { - font-size: 1.1rem; - color: var(--color-primary); - flex-shrink: 0; -} -.artifact-card-info { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 2px; -} -.artifact-card-title { - font-weight: 600; - font-size: 0.8125rem; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.artifact-card-lang { - font-size: 0.7rem; - color: var(--color-text-muted); -} -.artifact-card-actions { - display: flex; - gap: 4px; - flex-shrink: 0; -} -.artifact-card-actions button { - background: none; - border: none; - color: var(--color-text-muted); - cursor: pointer; - padding: 4px 6px; - border-radius: var(--radius-sm); - font-size: 0.75rem; - transition: all 150ms; -} -.artifact-card-actions button:hover { - color: var(--color-primary); - background: var(--color-primary-light); -} - /* Resource cards (below agent messages) */ .resource-cards { display: flex; @@ -4498,81 +3858,6 @@ button.collapsible-header:focus-visible { border-color: var(--color-primary); } -/* Canvas preview types */ -.canvas-preview-iframe { - display: block; - width: 100%; - height: 100%; - min-height: 320px; - border: none; - background: white; -} -.canvas-preview-image { - display: block; - max-width: 100%; - margin: 16px auto; - border-radius: var(--dk-radius-2); -} -.canvas-preview-svg { - display: flex; - justify-content: center; - padding: var(--spacing-md); -} -.canvas-preview-svg svg { - max-width: 100%; - height: auto; -} -.canvas-preview-markdown { - padding: 16px 20px; - line-height: 1.65; -} -.canvas-audio-wrapper { - display: flex; - flex-direction: column; - align-items: center; - gap: var(--spacing-md); - padding: var(--spacing-lg); -} -.canvas-audio-icon { - font-size: 2rem; - color: var(--color-primary); -} -.canvas-url-card { - display: flex; - align-items: center; - gap: var(--spacing-sm); - padding: var(--spacing-md); -} -.canvas-url-card a { - color: var(--color-primary); - word-break: break-all; -} - -/* Canvas mode toggle */ -.canvas-mode-toggle { - display: flex; - align-items: center; - gap: 6px; - font-size: 0.75rem; - color: var(--color-text-secondary); -} -.canvas-mode-toggle .canvas-mode-label { - font-weight: 500; - cursor: pointer; -} -.canvas-mode-toggle .toggle { - transform: scale(0.8); -} - -@media (max-width: 768px) { - .canvas-panel { - position: fixed; - inset: 0; - width: 100%; - z-index: 50; - } -} - @media (max-width: 640px) { .card-grid { grid-template-columns: 1fr; @@ -4654,10 +3939,6 @@ button.collapsible-header:focus-visible { animation: dk-dialog-in var(--dk-dur-3) var(--dk-ease-spring) both; will-change: transform, opacity; } -@keyframes slideUp { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } -} .confirm-dialog-header { display: flex; align-items: center; @@ -4686,52 +3967,6 @@ button.collapsible-header:focus-visible { } /* The confirm button is the same danger wash as every other danger button. */ -/* ─── Home: the console ──────────────────────────────────────────────────── - One command bar first, memory folded into a one-line strip, and a list of - conversations worth resuming. Flat surfaces, solid bars, no glow. Colours - are the kit roles (--dk-*). Focus is a 2 px outline with an offset. */ -.home-page { - display: flex; - flex-direction: column; - flex: 1; - width: 100%; - padding: var(--spacing-xl) var(--spacing-lg) 96px; -} -.home-col { - width: 100%; - max-width: 880px; - margin: 0 auto; - min-width: 0; -} -/* The reveal must not leave a transform behind: it would trap the menus that - hang below the command bar under the sections that follow it. */ -.reveal-stagger.home-col > * { animation-fill-mode: backwards; } -.home-col button:focus-visible, -.home-col a:focus-visible, -.home-col summary:focus-visible { outline: 2px solid var(--dk-accent-text); outline-offset: 2px; } - -.home-header { - display: flex; - align-items: baseline; - flex-wrap: wrap; - gap: 14px; - padding: 0 0 18px; -} -.home-greeting { - margin: 0; - font-family: var(--font-sans); - font-size: 28px; - line-height: 1.2; - font-weight: 650; - letter-spacing: -0.02em; -} -.home-date { - font-family: var(--font-mono); - font-size: 13px; - font-weight: 500; - color: var(--dk-muted); -} - /* Shared small controls */ .home-dot { flex: none; @@ -4818,340 +4053,8 @@ button.collapsible-header:focus-visible { } .home-link:hover { color: var(--dk-text); } -/* Command bar */ -.home-cmd { - position: relative; - padding: 12px; - border-radius: var(--dk-radius-3); - background: var(--dk-card); - box-shadow: inset 0 0 0 1px var(--dk-control-edge), var(--dk-shadow-rest); - transition: box-shadow var(--dk-dur-2) var(--dk-ease-out); -} -.home-cmd:focus-within { box-shadow: inset 0 0 0 1px var(--dk-accent-text), var(--dk-shadow-rest); } -.home-cmd__top { - display: flex; - align-items: center; - gap: 8px; - padding-bottom: 10px; - min-width: 0; -} -.home-pick { position: relative; min-width: 0; } -.home-chip, -.home-cmd__top .chat-mcp-dropdown > .btn { - display: inline-flex; - align-items: center; - gap: 8px; - height: 32px; - min-width: 0; - max-width: 100%; - padding: 0 10px; - border: 0; - border-radius: var(--dk-radius-1); - background: var(--dk-card); - box-shadow: inset 0 0 0 1px var(--dk-control-edge); - color: var(--dk-text); - font-family: var(--font-mono); - font-size: 13px; - font-weight: 600; - text-transform: none; - letter-spacing: 0; - white-space: nowrap; - cursor: pointer; - transition: background var(--dk-dur-1) var(--dk-ease-out), transform var(--dk-dur-1) var(--dk-ease-out); -} -.home-chip:hover:not(:disabled), -.home-cmd__top .chat-mcp-dropdown > .btn:hover { background: var(--dk-hover); } -.home-chip:active:not(:disabled), -.home-cmd__top .chat-mcp-dropdown > .btn:active { transform: scale(var(--dk-press-scale)); } -.home-chip:disabled { cursor: default; color: var(--dk-muted); } -.home-chip .lai-icon { width: 16px; height: 16px; flex: none; color: var(--color-text-secondary); } -.home-chip__text { min-width: 0; overflow: hidden; text-overflow: ellipsis; } -/* The MCP trigger is the app's shared dropdown; this restyles it as a chip. */ -.home-cmd__top .chat-mcp-dropdown > .btn { font-family: inherit; font-weight: 550; color: var(--color-text-secondary); } -.home-cmd__top .chat-mcp-dropdown > .btn .chat-mcp-badge { - height: auto; - min-width: 0; - padding: 2px 6px; - border-radius: 99px; - background: var(--dk-accent-wash); - color: var(--dk-accent-text); - font-family: var(--font-mono); - font-size: 11px; -} -.home-slash-hint { - display: inline-flex; - align-items: center; - gap: 6px; - margin-left: auto; - color: var(--dk-muted); - font-size: 13px; - white-space: nowrap; -} - -/* Menus: the model list and the slash list */ -.home-menu, -.dk-cmdlist.home-slash { - background: color-mix(in srgb, var(--dk-card) 94%, var(--dk-text)); - border: 1px solid var(--dk-edge); - border-radius: var(--dk-radius-2); - box-shadow: var(--dk-shadow-float); -} -.home-menu { - position: absolute; - z-index: 120; - top: calc(100% + 6px); - left: 0; - min-width: min(400px, calc(100vw - 48px)); - max-width: min(560px, calc(100vw - 48px)); - padding: 6px; - animation: homeRise var(--dk-dur-2) var(--dk-ease-out) both; -} -.home-menu__filter { - width: 100%; - height: 36px; - margin-bottom: 4px; - padding: 0 10px; - border: 0; - border-radius: var(--dk-radius-1); - background: var(--dk-inset); - box-shadow: inset 0 0 0 1px var(--dk-edge); - color: var(--dk-text); - font: inherit; - font-size: 13px; -} -.home-menu__filter:focus-visible { outline: 2px solid var(--dk-accent-text); outline-offset: 0; } -.home-menu__list { list-style: none; margin: 0; padding: 0; max-height: 320px; overflow: auto; outline: none; } -.home-menu__head { - padding: 8px 10px 4px; - color: var(--dk-muted); - font-size: 11px; - font-weight: 600; - letter-spacing: .09em; - text-transform: uppercase; -} -.home-menu__empty { padding: 12px 10px; color: var(--dk-muted); font-size: 13px; } -.home-menu__item { - display: flex; - align-items: center; - gap: 12px; - min-height: 40px; - padding: 6px 10px; - border-radius: var(--dk-radius-1); - color: var(--color-text-secondary); - cursor: pointer; -} -.home-menu__item[data-active="true"] { background: var(--dk-hover); color: var(--dk-text); } -.home-menu__item code { min-width: 0; overflow: hidden; text-overflow: ellipsis; font-family: var(--font-mono); font-size: 13px; font-weight: 600; color: var(--dk-text); background: none; padding: 0; white-space: nowrap; } -.home-menu__item small { margin-left: auto; font-family: var(--font-mono); font-size: 12px; color: var(--dk-muted); white-space: nowrap; } -.home-menu__check { margin-left: auto; color: var(--dk-accent-text); } -.dk-cmdlist.home-slash { - position: absolute; - z-index: 130; - top: calc(100% + 6px); - left: 0; - right: 0; - width: auto; - max-height: 360px; - animation: homeRise var(--dk-dur-2) var(--dk-ease-out) both; -} -.dk-cmdlist.home-slash { overflow: hidden; padding: 6px 6px 0; } -.home-slash__scroll { display: flex; flex-direction: column; gap: 2px; max-height: 300px; overflow: auto; overscroll-behavior: contain; } -.home-slash .dk-cmd[aria-selected="true"] { background: var(--dk-hover); } -.home-slash .dk-cmd-foot { margin-top: 2px; padding-bottom: 8px; } - -.home-well { - position: relative; - border-radius: var(--dk-radius-2); - background: var(--dk-inset); - box-shadow: inset 0 0 0 1px var(--dk-edge); -} -.home-textarea { - display: block; - width: 100%; - min-height: 88px; - padding: 14px 16px; - border: 0; - outline: 0; - resize: none; - background: none; - color: var(--dk-text); - font: inherit; - font-size: 16px; - line-height: 1.5; -} -.home-textarea::placeholder { color: var(--dk-muted); } -.home-file-tags { display: flex; flex-wrap: wrap; gap: 6px; padding-bottom: 10px; } -.home-file-tag { - display: inline-flex; - align-items: center; - gap: 6px; - max-width: 100%; - padding: 4px 8px; - border-radius: 6px; - background: var(--dk-inset); - box-shadow: inset 0 0 0 1px var(--dk-edge); - color: var(--color-text-secondary); - font-size: 12px; -} -.home-file-tag__thumb { width: 20px; height: 20px; border-radius: 4px; object-fit: cover; } -.home-file-tag__name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.home-file-tag button { - display: inline-grid; - place-items: center; - padding: 2px; - border: 0; - border-radius: 4px; - background: none; - color: var(--dk-muted); - cursor: pointer; -} -.home-file-tag button:hover { background: var(--dk-hover); color: var(--dk-text); } -.home-cmd__foot { display: flex; align-items: center; gap: 4px; padding-top: 10px; } -.home-attach-buttons { display: flex; gap: 2px; } -.home-attach-btn { - display: grid; - place-items: center; - width: 32px; - height: 32px; - border: 0; - border-radius: var(--dk-radius-1); - background: none; - color: var(--color-text-secondary); - font-size: 18px; - cursor: pointer; - transition: background var(--dk-dur-1) var(--dk-ease-out), color var(--dk-dur-1) var(--dk-ease-out), transform var(--dk-dur-1) var(--dk-ease-out); -} -.home-attach-btn:hover { background: var(--dk-hover); color: var(--dk-text); } -.home-attach-btn:active { transform: scale(var(--dk-press-scale)); } -.home-input-hint { flex: 1; margin-right: 12px; color: var(--dk-muted); font-size: 13px; text-align: right; } - -/* Send: a solid fill, a top highlight and a bottom edge (no glow), and the - Enter glyph in a darker cell that teaches the shortcut. */ -.home-send-btn { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 10px; - height: 40px; - margin-left: auto; - padding: 0 6px 0 16px; - border: 0; - border-radius: var(--dk-radius-2); - background: var(--dk-accent); - color: var(--dk-on-accent); - font: inherit; - font-size: 14px; - font-weight: 600; - white-space: nowrap; - cursor: pointer; - box-shadow: inset 0 1px 0 rgb(255 255 255 / .18), inset 0 -1px 0 rgb(0 0 0 / .2), var(--dk-shadow-rest); - transition: background var(--dk-dur-1) var(--dk-ease-out), box-shadow var(--dk-dur-1) var(--dk-ease-out), transform var(--dk-dur-1) var(--dk-ease-out); -} -.home-send-btn__cell { - display: grid; - place-items: center; - width: 28px; - height: 28px; - border-radius: 8px; - background: rgb(0 0 0 / .18); -} -.home-send-btn__glyph { - width: 16px; - height: 16px; - fill: none; - stroke: currentColor; - stroke-width: 2; - stroke-linecap: round; - stroke-linejoin: round; - transition: transform var(--dk-dur-2) var(--dk-ease-out); -} -.home-send-btn:hover:not(:disabled) { background: color-mix(in srgb, var(--dk-accent) 88%, white); } -.home-send-btn:hover:not(:disabled) .home-send-btn__glyph { transform: translateX(-2px); } -.home-send-btn:active:not(:disabled) { - transform: scale(var(--dk-press-scale)); - background: color-mix(in srgb, var(--dk-accent) 82%, black); - box-shadow: inset 0 1px 2px rgb(0 0 0 / .3); -} -.home-send-btn:focus-visible { outline: 2px solid var(--dk-accent-text); outline-offset: 2px; } -.home-send-btn[data-empty], -.home-send-btn:disabled { - background: var(--dk-inset); - color: var(--dk-muted); - box-shadow: inset 0 0 0 1px var(--dk-edge); - cursor: not-allowed; -} -.home-send-btn[data-empty] .home-send-btn__cell, -.home-send-btn:disabled .home-send-btn__cell { background: transparent; } -.home-send-btn[data-loading] { pointer-events: none; } -.home-send-btn[data-loading] > * { visibility: hidden; } -.home-send-btn[data-loading]::after { - content: ""; - position: absolute; - inset: 0; - width: 16px; - height: 16px; - margin: auto; - border-radius: 50%; - border: 2px solid currentColor; - border-right-color: transparent; - animation: dk-spin 700ms linear infinite; -} /* Under the command bar: notices, the memory strip, the assistant line */ -.home-below { display: flex; flex-direction: column; gap: 12px; margin-top: 12px; } -.home-notice { - display: grid; - grid-template-columns: auto 1fr; - gap: 4px 14px; - padding: 16px; - border-radius: var(--dk-radius-2); - background: var(--dk-error-wash); - color: var(--dk-text); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dk-error) 40%, transparent); -} -.home-notice > .lai-icon { margin-top: 2px; color: var(--dk-error-text); } -.home-notice h3 { margin: 0; font-size: 15px; font-weight: 650; } -.home-notice p { grid-column: 2; margin: 0; color: var(--color-text-secondary); font-size: 14px; } -.home-notice__acts { grid-column: 2; display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } -.home-notice details { grid-column: 2; margin-top: 6px; font-size: 13px; color: var(--color-text-secondary); } -.home-notice summary { width: max-content; border-radius: 6px; cursor: pointer; } -.home-notice pre { - margin: 8px 0 0; - padding: 10px 12px; - border-radius: var(--dk-radius-1); - background: var(--dk-inset); - font-family: var(--font-mono); - font-size: 12px; - line-height: 1.5; - white-space: pre-wrap; - overflow-wrap: anywhere; -} -.home-tip { - display: flex; - align-items: center; - gap: 10px; - padding: 0 4px; - color: var(--dk-muted); - font-size: 13px; -} -.home-tip b { color: var(--color-text-secondary); font-weight: 600; } -.home-tip > .lai-icon { flex: none; width: 16px; height: 16px; color: var(--dk-accent-text); } -.home-tip__close { - display: grid; - place-items: center; - flex: none; - width: 32px; - height: 32px; - margin-left: auto; - border: 0; - border-radius: var(--dk-radius-1); - background: none; - color: var(--color-text-secondary); - cursor: pointer; -} -.home-tip__close:hover { background: var(--dk-hover); color: var(--dk-text); } - /* Memory strip */ .home-strip { position: relative; @@ -5187,7 +4090,7 @@ button.home-strip__head:hover:not(:disabled) { background: var(--dk-hover); } .home-strip[data-open] .home-strip__caret { transform: rotate(180deg); } .home-strip[data-mode="error"] .home-strip__head > .lai-icon:first-child { color: var(--dk-error-text); } .home-strip--skeleton { padding: 16px 14px; } -.home-strip--skeleton .skeleton { height: 12px; margin: 0; background: var(--dk-hover); animation: homePulse 1.4s ease-in-out infinite; } +.home-strip--skeleton .skeleton { height: 12px; margin: 0; background: var(--dk-hover); animation: pulse 1.4s ease-in-out infinite; } .home-bar { position: relative; display: block; @@ -5291,6 +4194,8 @@ button.home-strip__head:hover:not(:disabled) { background: var(--dk-hover); } .home-resume__head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding-bottom: 4px; } .home-resume__head h2 { margin: 0; font-size: 15px; font-weight: 650; letter-spacing: 0; } .home-resume__keys { display: inline-flex; align-items: center; gap: 4px; color: var(--dk-muted); font-size: 12px; } +.home-resume__head .home-resume__keys { margin-left: auto; } +.home-resume__all { align-self: center; min-height: 30px; padding: 0 9px; } .home-day__label { display: flex; align-items: baseline; @@ -5575,13 +4480,8 @@ button.home-strip__head:hover:not(:disabled) { background: var(--dk-hover); } .home-connect__foot { display: flex; justify-content: flex-end; margin-top: 12px; } @keyframes homeRise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } } -@keyframes homePulse { 0%, 100% { opacity: 1; } 50% { opacity: .5; } } @media (max-width: 640px) { - .home-page { padding: var(--spacing-sm) 16px 64px; } - .home-greeting { font-size: 24px; } - .home-cmd { padding: 10px; } - .home-slash-hint, .home-input-hint, .home-resume__keys, .home-connect__compat, @@ -5662,7 +4562,7 @@ button.home-strip__head:hover:not(:disabled) { background: var(--dk-hover); } max-width: var(--page-max-wide); margin: 0 auto; width: 100%; - animation: fadeIn var(--duration-normal) var(--ease-default); + animation: dk-veil-in var(--duration-normal) var(--ease-default); } .biometrics-page__header { @@ -5892,11 +4792,7 @@ button.home-strip__head:hover:not(:disabled) { background: var(--dk-hover); } } .biometrics-mediainput__meter.recording .lai-icon { color: var(--color-error); - animation: biometrics-pulse 1.2s ease-in-out infinite; -} -@keyframes biometrics-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.35; } + animation: pulse 1.2s ease-in-out infinite; } .biometrics-mediainput__error { @@ -7510,102 +6406,11 @@ button.home-strip__head:hover:not(:disabled) { background: var(--dk-hover); } } } -/* Lazy-route loading fallback. Delayed ~150ms so fast chunk loads don't flash. */ -.route-fallback { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - min-height: 40vh; - opacity: 0; - animation: route-fallback-in var(--duration-normal) var(--ease-out, ease-out) 150ms forwards; -} -@keyframes route-fallback-in { to { opacity: 1; } } -@media (prefers-reduced-motion: reduce) { - .route-fallback { opacity: 1; animation: none; } -} +/* Route fallback: moved to components/ui/ui.css. */ -/* Hub layout: a page-level tab bar (the kit's dk-hubtabs) above the page body. - Build and Operate use it in place of a second navigation rail. */ -.hub-layout { - display: flex; - flex-direction: column; - padding-top: var(--spacing-md); -} -/* The bar lines up with the page padding and caps at the widest page. */ -.hub-bar { - display: flex; - flex-direction: column; - gap: var(--spacing-sm); - width: 100%; - max-width: var(--page-max-default); - margin: 0 auto; - padding: 0 var(--spacing-xl); - min-width: 0; -} -/* The bar scrolls sideways on its own; it never widens the page. */ -.hub-bar .dk-hubtabs { align-self: flex-start; } -/* Second row for a tab that owns several routes: quiet text links. */ -.hub-subnav { - display: flex; - gap: var(--spacing-xs); - max-width: 100%; - overflow-x: auto; - scrollbar-width: none; -} -.hub-subnav::-webkit-scrollbar { display: none; } -.hub-subnav__link { - flex: none; - display: inline-flex; - align-items: center; - height: 32px; - padding: 0 var(--spacing-sm); - border-radius: var(--radius-sm); - color: var(--color-text-muted); - font-size: var(--text-sm); - font-weight: var(--font-weight-semibold); - text-decoration: none; - white-space: nowrap; - transition: background-color var(--duration-fast) ease, color var(--duration-fast) ease; -} -.hub-subnav__link:hover { background: var(--color-bg-hover); color: var(--color-text-primary); } -.hub-subnav__link[aria-current="page"] { - color: var(--color-accent-text, var(--dk-accent-text)); - box-shadow: inset 0 -2px 0 var(--dk-accent); - border-radius: var(--radius-sm) var(--radius-sm) 0 0; -} -.hub-body { - flex: 1 1 auto; - min-width: 0; - width: 100%; -} -/* A medium page is centred in the content area, but the tab bar above it is - flush with the page edge, so the title sat 60px to the right of the first - tab. Inside a hub the medium column starts where the bar starts. */ -.hub-body .page--medium { - margin-left: max(0px, calc((100% - var(--page-max-default)) / 2)); - margin-right: auto; -} -@media (max-width: 768px) { - .hub-layout { padding-top: var(--spacing-sm); } - .hub-bar { padding: 0 var(--spacing-md); } - /* Let the bar run to the screen edge so a half-visible tab says "scroll". */ - .hub-bar .dk-hubtabs { align-self: stretch; } -} +/* Hub layout: moved to components/hub/hub.css. */ -.status-pill { - display: inline-flex; - align-items: center; - gap: var(--spacing-xs); - font-size: var(--text-sm); - color: var(--color-text-secondary); -} -.status-pill__dot { width: 7px; height: 7px; border-radius: var(--radius-full); background: var(--color-text-muted); flex-shrink: 0; } -.status-pill--success .status-pill__dot { background: var(--color-success); } -.status-pill--warning .status-pill__dot { background: var(--color-warning); } -.status-pill--error .status-pill__dot { background: var(--color-error); } -.status-pill--info .status-pill__dot { background: var(--color-info); } -.status-pill--muted .status-pill__dot { background: var(--color-text-muted); } +/* .status-pill is a StatusChip now: components/ui/ui.css. */ /* ──────────────────── Voice Library ──────────────────── */ @@ -7672,7 +6477,6 @@ button.home-strip__head:hover:not(:disabled) { background: var(--dk-hover); } .local-running__more { border-top: 1px solid var(--color-border-subtle); } .local-model-table th:first-child, .local-model-table td:first-child { width: 30%; } -@keyframes node-inspector-scrim-in { from { opacity: 0; } to { opacity: 1; } } /* Rendered Markdown --------------------------------------------------------- Gallery descriptions, backend descriptions and voice notes are all @@ -8014,6 +6818,18 @@ button.home-strip__head:hover:not(:disabled) { background: var(--dk-hover); } font-variant-numeric: tabular-nums; } .nav-badge--error { background: var(--color-error-light); color: var(--dk-error-text); } +/* The one-time "New" label of an entry: text, not a count, and no motion. */ +.nav-new { + margin-left: auto; + padding: 0 6px; + border-radius: var(--radius-full); + background: var(--color-primary-light); + color: var(--dk-accent-text); + font-family: var(--font-mono); + font-size: var(--text-xs); + font-weight: 600; + line-height: 18px; +} /* Collapsed, the label is gone and the item centres its icon: an inline badge would shove that icon off the rail's axis. Pinning it to the corner keeps the count visible without moving anything else. */ @@ -8069,13 +6885,7 @@ button.home-strip__head:hover:not(:disabled) { background: var(--dk-hover); } .text-meta { font-size: var(--text-xs); color: var(--color-text-muted); } .text-mono { font-family: var(--font-mono); } -/* The "waiting for the first response" wrapper, previously repeated inline in - 25 places with identical values. */ -.loading-center { - display: flex; - justify-content: center; - padding: var(--spacing-xl); -} +/* .loading-center: moved to components/ui/ui.css. */ /* Grow-to-fill inside a .hstack. Named rather than inline because "which child absorbs the slack" is a layout decision worth being able to grep for. */ @@ -9209,7 +8019,7 @@ button.home-strip__head:hover:not(:disabled) { background: var(--dk-hover); } padding-block: var(--spacing-3xl); } -.empty-state--page .empty-state-icon { font-size: 2rem; } +.empty-state--page .empty-state-icon { font-size: var(--fs-title); } .empty-state--page .empty-state-text { text-wrap: balance; } /* ========================================================================== @@ -9342,24 +8152,7 @@ button.home-strip__head:hover:not(:disabled) { background: var(--dk-hover); } .page--app > .tabs { flex: none; } .page--app > .view-bar { flex: none; } -/* A split view needs a ceiling to scroll inside: without it the page ran past - the viewport and over the footer. The hub layout sizes to content for every - other page, so only the split-view routes are pinned, scoped with :has(). */ -.hub-layout:has(.page--app) { - flex: 1; - min-height: 0; -} - -.hub-layout:has(.page--app) > .hub-body { - min-height: 0; - display: flex; - flex-direction: column; -} - -@media (max-width: 900px) { - .hub-layout:has(.page--app) { flex: none; } - .hub-layout:has(.page--app) > .hub-body { display: block; } -} +/* The pinned hub layout for split views: components/hub/hub.css. */ /* Refetching is not the same event as first load. A first load has nothing to show, so it gets a skeleton; a refetch already has a list on screen and must @@ -9421,13 +8214,9 @@ button.home-strip__head:hover:not(:disabled) { background: var(--dk-hover); } .entity-rail__meta--pending { color: var(--color-text-tertiary); font-style: italic; - animation: entity-rail-pending 1.6s var(--ease-default) infinite; + animation: pulse 1.6s var(--ease-default) infinite; } -@keyframes entity-rail-pending { - 0%, 100% { opacity: 0.55; } - 50% { opacity: 1; } -} @media (prefers-reduced-motion: reduce) { .entity-rail__meta--pending { animation: none; opacity: 0.75; } diff --git a/core/http/react-ui/src/App.jsx b/core/http/react-ui/src/App.jsx index 1ce249a08..27d97d7d6 100644 --- a/core/http/react-ui/src/App.jsx +++ b/core/http/react-ui/src/App.jsx @@ -11,6 +11,8 @@ import { useAuth } from './context/AuthContext' import RouteFallback from './components/RouteFallback' import { hubs, hubOwnsPath } from './components/hub/hubConfig' import Icon from './components/Icon' +// eslint-disable-next-line no-unused-vars +import AppearanceSync from './components/wellknown/AppearanceSync' import { displayVersion } from './utils/version' const COLLAPSED_KEY = 'localai_sidebar_collapsed' @@ -23,7 +25,14 @@ const COLLAPSED_KEY = 'localai_sidebar_collapsed' // A model's own page (/app/models/) is a child of the Models list, which // stays mounted behind it so Back finds the list as it was. It shares the // list's key for the same reason. +// The chat page fills the window and scrolls its own thread. Its sibling +// pages under /app/chats/ (connectors, conversion) are ordinary pages. +const CHAT_PAGE = /\/chats(\/(?!(skills|agents|triggers|connectors|conversion)\/?$)[^/]+)?\/?$/ + function pageTransitionKey(pathname) { + // A new chat and an open one are one page: starting or leaving a chat must + // not mount it again. + if (CHAT_PAGE.test(pathname)) return '/app/chats' for (const h of hubs) { if (hubOwnsPath(h, pathname)) return `hub:${h.id}` } @@ -36,7 +45,7 @@ export default function App() { const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { try { return localStorage.getItem(COLLAPSED_KEY) === 'true' } catch (_) { return false } }) - const { toasts, addToast, removeToast } = useToast() + const { toasts, addToast, removeToast, dropToast } = useToast() const [version, setVersion] = useState('') const location = useLocation() const navigate = useNavigate() @@ -45,7 +54,7 @@ export default function App() { const branding = useBranding() const { t } = useTranslation('nav') const hamburgerRef = useRef(null) - const isChatRoute = location.pathname.match(/\/chat(\/|$)/) || location.pathname.match(/\/agents\/[^/]+\/(chat|runs\/[^/]+)\/?$/) + const isChatRoute = CHAT_PAGE.test(location.pathname) useEffect(() => { systemApi.version() @@ -102,6 +111,7 @@ export default function App() { return (
      + setSidebarOpen(false)} />
      @@ -187,7 +197,8 @@ export default function App() { )}
      - + {/* On a chat page the stack rises above the composer. */} +
      ) } diff --git a/core/http/react-ui/src/addons/AddonRoute.jsx b/core/http/react-ui/src/addons/AddonRoute.jsx index 5bf1979ae..5963e6ed1 100644 --- a/core/http/react-ui/src/addons/AddonRoute.jsx +++ b/core/http/react-ui/src/addons/AddonRoute.jsx @@ -16,6 +16,8 @@ import AddonErrorBoundary from './AddonErrorBoundary' import { useAddonRegistry } from './useAddonRegistry' import { appStatus } from './registryState' import { resolveSubpath } from './sdk' +import { hasAddonSettings } from './hostControls' +import { addonSettingsAddress } from '../utils/addonSettings' import '../components/addons/addons.css' // The shell route of an add-on page: /app/addons/:addonId/[/...]. @@ -264,7 +266,15 @@ export default function AddonRoute() { // survives the moment it is withdrawn. return (
      - {/* The add-on draws its own title; the shell adds none. */} + {/* The add-on draws its own title; the shell adds none. Its settings + live on the Settings tab of its details, and the shell links there. */} + {!isStale && isAdmin && app && hasAddonSettings(app) && ( +
      + + {t('settingsTab.pageLink')} + +
      + )} {isStale && (
      {t('shell.stale', { title })} diff --git a/core/http/react-ui/src/addons/AddonSlot.jsx b/core/http/react-ui/src/addons/AddonSlot.jsx index 709ccb6e9..b1483ad1d 100644 --- a/core/http/react-ui/src/addons/AddonSlot.jsx +++ b/core/http/react-ui/src/addons/AddonSlot.jsx @@ -3,6 +3,7 @@ import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'r import { useTranslation } from 'react-i18next' import Icon from '../components/Icon' import { RegistryContext } from './registryContext' +import { useAuth } from '../context/AuthContext' import { useSlotContributions } from './useAddonRegistry' import SlotBoundary from './SlotBoundary' import { SLOTS, slotProps } from './slots' @@ -22,14 +23,16 @@ import { SLOTS, slotProps } from './slots' // A component contribution in its own root. The add-on's scoped style sheet // reaches only this element (see cssScope.js). -function Contributed({ c, props, label }) { +// A section is a block: an inline-flex root would lay a form out in a row. +function Contributed({ c, props, label, block = false }) { const View = c.Component + const Root = block ? 'div' : 'span' return ( - + - + ) } @@ -154,20 +157,32 @@ function ComposerMenu({ items, props, label, openLabel }) { ) } -// settings.sections: one titled section per contribution, under a heading the -// host draws with the add-on's own title and icon. -function Sections({ items, props, label }) { +// settings.sections: one section per contribution, drawn on the add-on's own +// page (the Settings tab of its details, see AddonSettings.jsx), where the +// page already names the add-on. Drawn for several add-ons at once, each +// section has a heading with the add-on's title and icon. +function Sections({ items, props, label, own }) { return ( -
      +
      {items.filter(c => c.kind === 'component').map(c => ( -
      -
      - {c.appIcon - ? - : } -

      {c.addonTitle}

      -
      - +
      + {!own && ( +
      + {c.appIcon + ? + : } +

      {c.addonTitle}

      +
      + )} +
      ))}
      @@ -183,17 +198,37 @@ function NavEntries({ items, props, label }) { ) } +// files.preview: the first contribution that handles the file's extension +// draws it, in a frame the host owns. The host's own view stays below it. +function Preview({ items, props, label }) { + const c = items.find(x => x.kind === 'component') + if (!c) return null + return ( +
      + +
      + ) +} + +// settings.sections is drawn for administrators only, as it was when the +// sections were part of the Settings page; context.addonId narrows it to one +// add-on. The id is the host's filter, never a prop of the contribution. export default function AddonSlot({ slot, context, enabled = true }) { const { t } = useTranslation('addons') - const items = useSlotContributions(slot, enabled) + const { isAdmin } = useAuth() + const sections = slot === 'settings.sections' + const on = enabled && (!sections || isAdmin) + const only = sections && typeof context?.addonId === 'string' && context.addonId !== '' ? context.addonId : undefined + const items = useSlotContributions(slot, on, slot === 'files.preview' ? context?.extension : undefined, only) const props = useMemo(() => slotProps(slot, context), [slot, context]) - if (!enabled || items.length === 0 || props === null) return null + if (!on || items.length === 0 || props === null) return null const label = t('slots.unavailable') switch (SLOTS[slot].render) { case 'inline': return case 'menu-item': return - case 'section': return + case 'section': return case 'nav': return + case 'preview': return default: return null } } diff --git a/core/http/react-ui/src/addons/ProposalCard.jsx b/core/http/react-ui/src/addons/ProposalCard.jsx deleted file mode 100644 index e31953faf..000000000 --- a/core/http/react-ui/src/addons/ProposalCard.jsx +++ /dev/null @@ -1,159 +0,0 @@ -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { addonsApi } from '../utils/addonsClient' -import { AuthContext } from '../context/authState' -import { cancelCall, confirmCall, proposalFacts } from './approval' -import { resultText, validProposalId } from './slots' - -const SAMPLE_CHARS = 1200 -const STATE_KEYS = ['pending', 'applied', 'cancelled', 'expired', 'executing', 'rejected', 'outcome-unknown', 'abandoned-unknown'] - -// The host's card for one stored proposal, drawn inline where a chat tool -// answered with a reference. It loads the authoritative record from the host -// by identifier: the model's text is never the preview. Its only actions are -// the host's authenticated Confirm and Cancel, sent exactly like the approval -// frame sends them (the proposer's own browser session, the proposal named in -// X-LocalAI-Confirm, the digest that was displayed). -// -// The card forgets what it loaded when the person logs out, another person -// signs in, the add-ons feature is withdrawn, or the host stops answering for -// the proposal, so a revoked user is left with a notice and no data. -export default function ProposalCard({ proposalId, onResolved }) { - const { t, i18n } = useTranslation('addons') - const auth = useContext(AuthContext) - const principal = auth && auth.user ? String(auth.user.id || auth.user.email || '') : '' - const featureOn = !auth || !auth.authEnabled || (typeof auth.hasFeature === 'function' && auth.hasFeature('addons')) - const signedOut = !!auth && !auth.loading && !!auth.authEnabled && !auth.user - const withheld = signedOut || !featureOn - - const [proposal, setProposal] = useState(null) - const [phase, setPhase] = useState('loading') - const [busy, setBusy] = useState(false) - const [now, setNow] = useState(() => Date.now()) - const resolved = useRef('') - const onResolvedRef = useRef(onResolved) - onResolvedRef.current = onResolved - - const load = useCallback((signal) => { - if (!validProposalId(proposalId)) { setPhase('unavailable'); return Promise.resolve() } - return addonsApi.call(`/api/addons/proposals/${encodeURIComponent(proposalId)}`, { signal }) - .then((p) => { - if (!p || p.id !== proposalId) { setProposal(null); setPhase('unavailable'); return } - setProposal(p) - setPhase('ready') - }) - .catch((e) => { - if (e && e.name === 'AbortError') return - // A denial or a lost session: drop what was shown. - setProposal(null) - setPhase(e && (e.status === 401 || e.status === 403 || e.kind === 'denied' || e.kind === 'unauthenticated') ? 'withdrawn' : 'unavailable') - }) - }, [proposalId]) - - // Everything on screen is dropped when the principal changes or goes away, - // and loaded again under the new one. - useEffect(() => { - setProposal(null) - resolved.current = '' - if (withheld) { setPhase('withdrawn'); return undefined } - setPhase('loading') - const controller = new AbortController() - load(controller.signal) - return () => controller.abort() - }, [proposalId, principal, withheld, load]) - - // Coming back to the page asks the host again: a revocation or an expiry - // that happened in the meantime shows up here. - useEffect(() => { - if (withheld) return undefined - const refresh = () => { if (document.visibilityState !== 'hidden') load() } - window.addEventListener('focus', refresh) - document.addEventListener('visibilitychange', refresh) - return () => { - window.removeEventListener('focus', refresh) - document.removeEventListener('visibilitychange', refresh) - } - }, [withheld, load]) - - const facts = useMemo(() => proposalFacts(proposal, now), [proposal, now]) - useEffect(() => { - if (!facts.expiresAt || facts.expired || facts.state !== 'pending') return undefined - const timer = setTimeout(() => setNow(Date.now()), Math.max(250, facts.expiresAt - Date.now() + 50)) - return () => clearTimeout(timer) - }, [facts.expiresAt, facts.expired, facts.state]) - - // The decision of a person, or a state the host reached, is reported once. - useEffect(() => { - if (!proposal || !facts.settled || resolved.current === `${proposal.id}:${facts.state}`) return - resolved.current = `${proposal.id}:${facts.state}` - if (typeof onResolvedRef.current === 'function') onResolvedRef.current(proposal) - }, [proposal, facts.settled, facts.state]) - - const send = useCallback(async (call) => { - if (!call || busy) return - setBusy(true) - try { - const next = await addonsApi.call(call.path, { method: call.method, headers: call.headers, body: call.body }) - if (next && next.id === proposalId) setProposal(next) - setPhase('ready') - } catch (e) { - // The host answered no, or did not answer: read the record again and - // show what it says, never a guess. - if (e && (e.status === 401 || e.status === 403) && !proposal) setPhase('withdrawn') - else setPhase('rejected') - load() - } finally { - setBusy(false) - } - }, [busy, proposalId, proposal, load]) - - const stateKey = STATE_KEYS.includes(facts.state) ? facts.state : 'unknown' - const shownState = facts.state === 'pending' && facts.expired ? 'expired' : stateKey - const sample = facts.sample === undefined ? '' : resultText(facts.sample).slice(0, SAMPLE_CHARS) - const notice = (() => { - if (!proposal) return '' - if (facts.blocked && facts.state === 'pending') return t('proposal.blocked') - if (facts.state === 'pending' && facts.expired) return t('proposal.expired') - if (facts.state === 'rejected') return t('proposal.rejectedState') - if (facts.unknownOutcome) return t('proposal.unknown') - return '' - })() - - return ( -
      -

      {t('proposal.title')}

      - {phase === 'loading' && !proposal &&

      {t('proposal.loading')}

      } - {phase === 'withdrawn' && !proposal &&

      {t('proposal.withdrawn')}

      } - {phase === 'unavailable' && !proposal &&

      {t('proposal.unavailable')}

      } - {proposal && ( - <> -
      -
      {t('proposal.addon')}
      {facts.addonId}
      -
      {t('proposal.database')}
      {facts.requestedName || facts.database}
      -
      {t('proposal.operation')}
      {facts.operation || facts.action}
      -
      {t('proposal.effects')}
      {facts.count}
      - {facts.expiresAt &&
      {t('approval.expires')}
      {new Date(facts.expiresAt).toLocaleTimeString(i18n.language)}
      } -
      {t('approval.state')}
      {t(`approval.states.${shownState}`)}
      -
      - {sample && ( -
      -

      {t('proposal.sample')}

      -
      {sample}
      -
      - )} - {notice &&

      {notice}

      } - {phase === 'rejected' &&

      {t('approval.rejected')}

      } -
      - {facts.unknownOutcome && ( - - )} - - -
      - - )} -
      - ) -} diff --git a/core/http/react-ui/src/addons/hostComponents.jsx b/core/http/react-ui/src/addons/hostComponents.jsx index 5883dc0ed..e33b7fdeb 100644 --- a/core/http/react-ui/src/addons/hostComponents.jsx +++ b/core/http/react-ui/src/addons/hostComponents.jsx @@ -3,6 +3,10 @@ import Icon from '../components/Icon' import LoadingSpinner from '../components/LoadingSpinner' import CatalogEmpty from '../components/catalog/CatalogEmpty' import StatusPill from '../components/StatusPill' +import { useId } from 'react' +import HostSwitch from '../components/ui/Switch' +import HostCallout from '../components/ui/Callout' +import HostStatusChip from '../components/ui/StatusChip' // Small wrappers over components the host already ships: an add-on gets the // shape it needs and nothing else, so no host component, hook or context is @@ -10,7 +14,7 @@ import StatusPill from '../components/StatusPill' const VARIANTS = new Set(['primary', 'secondary', 'ghost', 'danger']) -export function Button({ variant = 'secondary', size, icon, busy, children, type, disabled, onClick, title, ...rest }) { +export function Button({ variant = 'secondary', size, icon, busy, children, type, disabled, onClick, title, testId, ...rest }) { const classes = ['dk-btn', `dk-btn--${VARIANTS.has(variant) ? variant : 'secondary'}`, size === 'sm' && 'dk-btn--sm'].filter(Boolean).join(' ') return ( - + + } +
      l.id), { ready: !needsApps || registry.apps.length > 0 }) + return links.map(link => { const title = link.titleKey ? t(link.titleKey) : link.title return ( +
      `nav-item nav-item--sub${isActive ? ' active' : ''}${link.stopped ? ' nav-item--muted' : ''}`} @@ -43,6 +47,7 @@ export default function AppsPinnedLinks({ onClose }) { : } {title} +
      ) }) } diff --git a/core/http/react-ui/src/components/CanvasPanel.jsx b/core/http/react-ui/src/components/CanvasPanel.jsx deleted file mode 100644 index e10c1b2ee..000000000 --- a/core/http/react-ui/src/components/CanvasPanel.jsx +++ /dev/null @@ -1,281 +0,0 @@ -import { useState, useEffect, useRef } from 'react' -import { useTranslation } from 'react-i18next' -import { renderMarkdown } from '../utils/markdown' -import { getArtifactIcon, extensionForLanguage } from '../utils/artifacts' -import { safeHref } from '../utils/url' -import { copyToClipboard } from '../utils/clipboard' -import DOMPurify from 'dompurify' -import hljs from '../utils/hljs' -import Icon from './Icon' -import { apiUrl } from '../utils/basePath' - -const WIDTH_KEY = 'localai_canvas_width' -const MIME_BY_EXT = { html: 'text/html', svg: 'image/svg+xml', json: 'application/json', css: 'text/css' } - -// Renders HTML in a sandboxed iframe loaded from a same-origin URL. srcDoc -// would inherit the app's CSP and block CDN scripts, so the code is posted to -// the server, which serves it back with a preview-specific CSP. -function HtmlPreviewFrame({ code, title }) { - const [src, setSrc] = useState(null) - useEffect(() => { - let cancelled = false - setSrc(null) - fetch(apiUrl('/api/canvas/preview'), { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code }), - }) - .then(r => (r.ok ? r.json() : Promise.reject(new Error(String(r.status))))) - .then(d => { if (!cancelled) setSrc(apiUrl('/api/canvas/preview/' + d.id)) }) - .catch(() => {}) - return () => { cancelled = true } - }, [code]) - if (!src) return
      - return