From 4d7bdc6ff242952cfae6b2c5ab085970b2ef2344 Mon Sep 17 00:00:00 2001 From: mudler-agent Date: Thu, 8 Oct 2026 22:03:07 +0200 Subject: [PATCH] feat(ui): redesign the web UI around a shared kit and a calm palette (#12526) * build(ui): vendor the shared UI kit snapshot at 0.2.0 The restyle needs the kit's tokens, motion layer and component classes. Take a pinned snapshot instead of depending on the kit at build time, and keep a lock file with the version and per-file checksums so a later update shows exactly what changed. The product theme stays outside the vendored directory. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): add the ink and teal theme and bridge the old variables Define the product colours as the shared UI kit's roles, for light and dark, in theme-localai.css. The kit's contrast check passes on every pair. theme.css keeps the existing --color-* and --shadow-* names but now points each at a role, so App.css and the pages get the new palette without edits. Radii move to the kit scale. index.html now sets data-theme before first paint with the same rule as ThemeContext (stored choice, otherwise dark), because the contract layout of the theme file no longer defaults to dark by itself. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): restyle the shared chrome with the UI kit grammar Adjust the shared classes so every page picks up the same interaction language without per-page edits: - Sidebar sits on the canvas and the current row lifts onto a card. Section labels are tracked uppercase, the badge is a soft pill, and the phone drawer leaves the tab order when closed. - Buttons are flat: hover swaps the surface, press scales to .97, focus is a 2px ring with a 2px offset, danger is a tinted wash. - Inputs use the card surface and the control edge; switches, tabs, filter chips, badges and cards follow the same rules. Cards no longer lift on hover; only linked or button cards react. - Menus and popovers scale in from the trigger corner with 40px items. Dialogs get a veil fade and a spring settle. Toasts become pills at the bottom centre. - The page transition is a 250 ms fade with a 6px rise. It fills backwards so a finished animation no longer leaves a transform that confined dialog veils to the main column. The focus-ring test now checks the outline instead of a box shadow, and new specs cover the theme roles, the first-paint theme and the sidebar lift. Assisted-by: Claude Code:claude-sonnet-5-5 * fix(ui): move leftover hard-coded colours onto the theme roles The YAML editor restated the old blue palette in JavaScript, and a few pages kept literal blues, indigo and violet tints, or fallbacks that only applied because a variable was never defined. Point them at the theme variables so they follow light and dark and the new palette. The status badges in the account pages built their tint by appending "22" to a variable, which is not valid once the variable is defined, so they had no background. Use the wash roles instead. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): raise the type scale and control size toward the kit Body and list text moves to 15px and the rest of the scale follows the kit's 12/13/15/17/21/32/44 steps. Page titles, section headings and stat values are bold with tighter tracking; titles are 32px. Buttons, inputs, selects, tabs and nav rows are 40px high with the 12px radius, compact controls 32px. Tabs become a segmented control. The sidebar widens to 240px (64px collapsed) and nav rows get more room. Identifiers and counts in the split views use the mono face, and the stat grid becomes separate inset tiles. The Geist stack stays: it is bundled, and the thin look came from the size, weight and negative tracking, not the face. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): separate cards, panes and floating surfaces from the canvas Cards, the Models and Installed split panes, the composers and the confirm dialog use a stronger card edge, the rest shadow and the 20px radius, so they read as layers in dark as well as light. Menus and popovers move to a float surface (the hover tone in dark) with the float shadow. The selected rail row gets an accent wash and a 3px accent edge. The send buttons are a clear accent when there is something to send and a quiet inset when not; the Home button carries data-empty for that, since submitting an empty box does nothing. The assistant card becomes an accent wash with a square icon. New surfaces spec checks the pane edge, the selected row, both send buttons and the popover in both themes. The voice library empty-state spec now waits for the layout to settle before comparing two boxes. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): tidy the sidebar header and mark the current row with a dot The header gives the configured horizontal logo a fixed width and centres it in a 72px band, lined up with the nav icons. The collapsed rail shows the configured icon logo centred, and its nav rows become 40px tiles centred in the 64px rail. The current row gets the kit's accent dot, hidden in the rail. The theme, language and account controls stay in the sidebar footer: the app has no global search or command palette to put in a top bar, so a bar would only hold controls that already have a place. Assisted-by: Claude Code:claude-sonnet-5-5 * fix(ui): centre the avatar in the collapsed sidebar rail The collapsed avatar link was set to "flex: 0", which gives it a zero flex basis; with min-width: 0 the link shrank to its padding and the icon overflowed from the link's left edge, about 14px right of the icon column. Use "flex: 0 0 auto" in the collapsed and tablet rail. The footer controls now share the nav icon column in the expanded sidebar too (6px footer padding, 40px control boxes), and the tablet rail gets the same footer padding and hidden language code as the collapsed one. New spec measures the centre x of the nav icons, mark, avatar, language, theme and collapse icons in the collapsed, expanded and tablet states, in both themes, and asserts they agree within 1px. Assisted-by: Claude Code:claude-sonnet-5-5 * fix(ui): size the console and settings rails and stack settings on phones The Operate console rail, the Settings section rail and the account tab bar still used 13px text and the old underline tabs. They now use the 15px nav size, 40px rows and the segmented tab control. Form row labels are 15px with 13px hints. On a phone the Settings section rail sat beside the form and squeezed every row into a few characters. Below 720px the rail stacks above the content as a scrolling row and form rows wrap their control below the label. The save button no longer carries the icon font class, which drew a missing glyph before its label. The language menu is wide enough to keep Bahasa Indonesia on one line. Assisted-by: Claude Code:claude-sonnet-5-5 * build(ui): update the vendored UI kit snapshot to 0.3.0 Take the 0.3.0 snapshot: the sprite now carries the full outline icon set, and the new icons/fa-map.json maps Font Awesome names to icon ids. The map lets the app move off Font Awesome in the following commits. The lock file is regenerated with the new checksums. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): add an Icon component backed by the kit sprite Icon draws an inline svg that points into the kit's outline sprite. The sprite is inlined into the page once, so the references resolve under any base path and in the embedded build without a request. Icons size with the font (1em), take currentColor, hide from assistive tech unless given a title, and spin on request. An unknown id draws a neutral circle. FaIcon and iconFromFa resolve Font Awesome names through the kit's map, for names that arrive at run time. iconHtml does the same for markup built as a string. The GitHub and Apple marks are small local glyphs, as the kit ships no brand marks. Assisted-by: Claude Code:claude-sonnet-5-5 * refactor(ui): draw shared components and helpers with Icon Replace the Font Awesome elements in the shared components and in the utility modules with the Icon component. Lookup tables now hold kit icon ids instead of class strings. Code-block copy buttons and artifact cards, which build HTML strings, use iconHtml and a sanitizer-safe slot. Assisted-by: Claude Code:claude-sonnet-5-5 * refactor(ui): draw model, backend and account pages with Icon Replace the Font Awesome elements on the home, models, backends, import, settings, login, account and users pages with the Icon component. Assisted-by: Claude Code:claude-sonnet-5-5 * refactor(ui): draw chat, studio and recognition pages with Icon Replace the Font Awesome elements on the chat, media generation, talk and face and voice pages with the Icon component. The talk status table keeps its spin and pulse states as Icon props. The connected and error states now use a dotted circle and an alert circle, so they differ from the idle ring by shape as well as by colour. Assisted-by: Claude Code:claude-sonnet-5-5 * refactor(ui): draw agent, node and operate pages with Icon Replace the Font Awesome elements on the agents, skills, collections, jobs, fine-tune, quantize, nodes, swarm, usage, traces and activity pages with the Icon component. Two class strings on layout elements held leftover button and icon classes from an earlier merge; they are cleaned up so the elements keep only their own classes. Assisted-by: Claude Code:claude-sonnet-5-5 * style(ui): size and align icons for the svg component Icon rules that targeted the font element now target the svg: the descendant "i" selectors in App.css and auth.css become ".lai-icon". The svg is 1.2em with a 2 unit line so it matches the visual size of the old glyphs at the 12 to 16px sizes the app uses, sits on the text baseline, and follows the context font size. Large empty-state marks get a lighter line. Menu icons get a 16px box and the readiness badge icons keep their 20px circle with padding. Add the pulse used by the talk status. Assisted-by: Claude Code:claude-sonnet-5-5 * build(ui): remove Font Awesome No source file references the icon font any more. Drop the package and its stylesheet import. The build no longer ships the solid, regular and brand font files. Assisted-by: Claude Code:claude-sonnet-5-5 * fix(ui): keep focus traps off the svg use references The dialog and drawer focus traps collect focusable elements with a "[href]" selector. An icon's use element carries an href, so it became the first "focusable" element and Tab at the end of the dialog stopped there instead of wrapping to the first button. Match "a[href]" instead. Assisted-by: Claude Code:claude-sonnet-5-5 * style(ui): keep icon sizes overridable and set the line width per svg Give the icon base rule zero specificity so a rule that sizes one icon (nav column, menu box, avatar, language switcher) wins whatever its order in the file. The sprite symbols fix their own line width; the inlined copy drops it so the width set on each svg applies, as the --lai-stroke custom property, and large marks can use a lighter line. Pin the avatar and the language globe to the boxes the sidebar alignment spec expects. Import the map as JSON with an import attribute so Node can load it in the spec. Assisted-by: Claude Code:claude-sonnet-5-5 * test(ui): select icons by the svg markup and cover the sprite Specs that found icons by their Font Awesome class now select the svg by its data-icon. The dead-icon audit checks that every svg resolves to a sprite symbol and has a size. The class hygiene spec fails on any remaining Font Awesome class. A new spec checks every mapped icon id has a symbol, that the sprite is inlined once, that an icon paints at the root and under a forwarded path prefix, and that Font Awesome names map as documented. Assisted-by: Claude Code:claude-sonnet-5-5 * build(ui): update the vendored UI kit snapshot to 0.4.0 Take the 0.4.0 snapshot: hub tabs with count and attention badges, the six chart series tokens and the grid colour in the theme contract, and sample themes on a calmer palette. The kit headers are renamed and the lock file is regenerated with the new checksums, as for the earlier snapshots. Assisted-by: Claude Code:claude-sonnet-5-5 * style(ui): switch the theme to the calm palette Rewrite the LocalAI theme on the calm palette: a muted teal accent on a near-neutral green-grey canvas, desaturated status colours, no glow and no coloured shadows. The theme fills every role of the shared UI kit's 0.4.0 theme contract for light and dark, including the six chart series and the grid line. The bridge in theme.css keeps the old --color-* names working, adds the dark surface ladder (card, raised, float) and a strong edge, and points the fixed data hues at the chart series. Two values differ from the first sketch. The dark text on the accent fill is #021512 instead of #04201d: it reads 5.58:1 on the fill at rest and 6.4:1 on the hover fill, against 5.08:1 at rest for the lighter value. The light control edge is #6b7d7a. The kit's contrast script passes for all text pairs (4.5:1), control and focus pairs (3:1) and series colours (3:1). Leftovers that no longer fit the palette are fixed: the usage chart takes the six series colours in order, the audio and animation canvases fall back to the new accent, the face box loses its glow, and two gradient fills are now flat. The theme tests expect the new canvas colours. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): replace the console rail with hub tab bars Build and Operate no longer open a second navigation rail beside the page. Each is a hub: one row of the kit's hub tabs above the page, with count and attention badges that scroll sideways on a phone. Every URL and route stays as it was, plus a new /app/build landing page that lists the Build tools with a line each. Build tabs: Overview, Agents, Skills, Memory, Jobs, Fine-Tune, Quantize, Import, Voices (recognition and library) and Faces. Operate tabs: Status, This machine, Swarm (distributed mode only), Runtime (backends, activity, failover), Traffic (usage, traces, middleware) and Settings (settings, users), plus the API link. A tab that holds several pages shows a second row of links, and a sub-page such as a node detail keeps its tab highlighted. The feature and admin gates decide which tabs are drawn, and badges show only values the Operate summary already has. The sidebar lists Build and Operate under a Workspace label next to the Create group. The voice library moves under Build and the model import page gains the Build tab bar. The old rail styles, the rail signals and the console config are removed, and the Operate overview docs describe the tab bar. The specs that drove the rail now drive the tabs, and a new spec covers the tab for each route, gating, badges and the phone layout. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): rebuild Home as a calm console Home now opens on one command bar: the model chip shows which models are warm, the MCP chip and attach buttons sit beside it, and Send is a solid button with an Enter glyph. Typing "/" opens a grouped, keyboard-driven action list built on the kit command list; every action has a destination in the product. Memory use folds into a one-line strip that opens into the loaded models, with Stop per model and Stop all. It opens by itself while a model is being staged and after a failure, and shows nodes and aggregate memory in a cluster. The list of resident models carries no per-model size because the API reports none. "Jump back in" lists the conversations stored in the browser, one card per day, with j and k to move, Enter to resume and delete with an undo toast. First run keeps the install steps and the recommended models. The assistant prompt is a dismissible line, the library links are one quiet row and the API section is collapsed. Chat accepts an empty new-chat hand-off for /new. Assisted-by: Claude Code:claude-sonnet-5-5 * test(ui): cover the Home console Update the Home specs for the new structure and add specs for the slash menu, the model chip, the memory strip (expand, stop, staging, failure, cluster), the resume list (grouping, j/k, Enter, delete with undo), first run, the send hand-off, a non-admin user and the phone layout. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): add the fit, disk and cleanup helpers for the models page Pure functions and hooks that the rebuilt Models page reads, with node tests for the rules. modelLedger turns an estimate and the memory budget into one of three verdicts (fits, spills to CPU, over) with the headroom in bytes, and reads the models disk from the resources reading. The disk counts as low under 10 percent or under 20 GB free, and is absent when the server reports none or runs as a cluster controller. cleanupPlan ranks installed models from what the API reports: loaded, pinned, or named by an agent, a task, a failover chain or an alias keeps a model protected; another installed build of the same gallery model is a duplicate; disabled models rank above idle ones. The API records no last use or use count, so none is used. When a lookup fails, nothing is called safe. useModelRemoval holds a removal in the browser for an undo window and sends the existing delete call only when the window ends. Leaving the page drops the batch without deleting anything. The undo toast takes optional labels so other pages can reuse it. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): rebuild Models as a ledger with a disk strip and cleanup review Explore is one dense table. Each row carries the size, a solid memory bar and the headroom in words ("3.7 free", "+1.5 on CPU", "0.9 over"), worked out from the estimate at the chosen context length. Capability chips show the server's count for each facet, search keeps its meaning and "/" jumps to it, and a density switch (also "d") picks comfortable or compact rows. Selection is a surface step and a check, never a rail. Arrow keys move, Enter installs and Esc closes the inspector, which keeps the fit summary, VRAM by context chart, variants, files, links, tags and licence. A failed install shows its error in the row with a Retry that dismisses the old failure first. A failed or empty listing says which it is, and a host with no GPU is measured against memory and says so. Installed uses the same table with state filters that carry counts, a state per row, Load or Stop on the row, the row menu and the sort by size. Sizes come from the files the gallery lists, so a model it does not know shows a dash. A strip in the header shows the free space on the models disk. It turns amber under 10 percent or under 20 GB free, hides when the server reports no disk or runs as a cluster controller, and opens the cleanup review. Explore says how much an install leaves free. The review ranks installed models as Safe to remove, Probably safe and Your call from real facts only, lists protected models with the reason, and says plainly that usage history is not recorded. A sticky bar shows what a choice frees. Confirming runs a dry run that checks again and lists what will go. Removal waits 30 seconds with an undo; nothing is deleted before that, and leaving the page deletes nothing. The old rail, filter band and popover styles are removed. Assisted-by: Claude Code:claude-sonnet-5-5 * test(ui): cover the Models ledger, Installed table and cleanup review Update the Models, lifecycle, cluster fit, height, search focus and surfaces specs for the table and inspector, keeping what each one checks. New specs, on a shared 41-model gallery stub with three machine profiles: the fit bar and headroom words for a 24 GB card, an 8 GB laptop and a host with no GPU; facet counts, search, "/" and Escape; selection, arrow keys, Enter to install, density; the disk strip when normal, low and hidden; and the states (loading, empty, offline, install failed, phone). Installed covers filters with counts, row actions, the row menu, sizes and sort. The cleanup specs cover grouping, protected models, the honest-data note, the effect bar, the dry run, the undo window, a failed delete, leaving the page, and the phone sheet. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): add the placement helpers and the estimate hooks The Placement section and the model page need the same few rules, so they sit in plain functions that can be read and tested alone. placement.js holds what gpu_layers, tensor_split and main_gpu mean (unset asks for every layer and the llama.cpp engine trims it, zero is CPU only, 99999999 is the value LocalAI itself writes for all layers), the device list taken from the resources reading, the split by free memory, the part of an estimate that grows with context (read from two lengths, since that term is linear), the fit states with their limit (95 percent of free memory, and the leftover has to fit in system memory too), and a bisection for the largest layer count whose estimate fits. The estimate returns one total and no layer count, so the search runs over 1 to 256 and stops at the first count that no longer changes it. modelWalk.js keeps the order of the list a model page was opened from, in memory and in session storage, for the previous and next buttons. usePlacementEstimate reads /api/models/vram-estimate for a choice, again at twice the context, and with every layer, and keeps readings for the session. useModelPage reads a gallery entry by name, an estimate by context size (from the model's own files when the gallery does not list it), the builds and the loaded models. usePlacementConfig edits the four placement keys of an installed model and saves only what changed. useModelActions is the Load, Stop, disable, pin and remove logic of the Installed table, shared with the model page. MemoryBar is one solid bar with a tick at the capacity of its pool; over capacity it grows past the tick and the tick turns red. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): add the Placement section to the model editor Run this model on: CPU only (gpu_layers: 0), Auto (the key stays unset) or Custom. Custom takes a number, has an All layers button that writes 99999999, and shows a slider only when the estimate reports the model's layer count, which it does not today. Context size has presets and a number field because the KV cache follows it. With two or more GPUs there is a split (written as percentages, with a button that takes them from the free memory of each card) and a main GPU. A bar per GPU and one for system memory show what other programs use, the model's weights and working memory, and the part that grows with context, with the room left or how far over it is. Under them a verdict in plain words: Fits in GPU, Spills to CPU, Too many layers for the GPU, Runs on CPU only, No GPU found, Not enough memory. It says "slower" and never a multiplier, because the estimate has none. Fit it for me asks the estimate for the largest layer count that fits the free GPU memory and says what it set, with Undo; it is hidden when the estimate is unavailable or the host has no GPU. Loading shows skeletons, an unavailable estimate shows a note with Retry, and a server that schedules onto other machines shows no bars, because its device list is the controller's. The editor shows the section for an installed model, with a link in its section rail. Auto sends null for the key, since a patch only merges, and a null read back opens as Auto. The docs describe the section and what each mode writes. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): open a model on its own page A model has an address, /app/models/, for an installed model and a gallery entry alike. Open it from the arrow at the end of a row, a double click, "o" on the selected row, the inspector's Open details button, or a tap on a phone. The title block holds the main action: Install with a chevron that chooses the build, or Load and Stop with a menu (disable, pin, edit configuration, logs, delete with a confirm). A strip answers whether it fits, what it does and what installing leaves free. Tabs: Overview (about, a memory bar, state, the pages it opens in, and the agents, tasks, chains and aliases that name it); Fit and memory (verdict, context sizes, the bar split into weights and context, and memory by context against the limit, with a data table); Variants and files (builds with size and fit, install any, the files of the chosen build). For an installed model also Usage and history, which says what the API does not record instead of drawing an empty chart, Configuration, which is the Placement section with the file it writes and a link to the full editor, and Logs, the backend log viewer without its page. Keys 1 to 6 switch tabs, [ ] and j k walk the list the page was opened from, Esc or Backspace go back. The list stays mounted behind the page, so Back finds its view, search, filters, selection and scroll as they were, and focus returns to the row's arrow. The page covers loading, an unknown name with the closest matches, the gallery being out of reach, an install in progress with Cancel, and a failed install with Retry. The docs describe the page and its keys. Assisted-by: Claude Code:claude-sonnet-5-5 * test(ui): cover the model page and the Placement section New specs for the model page: reaching it from Explore, Installed, a double click, "o", a pasted link and a phone tap; the walker and Back with the search, a filter, the selection, the Installed view and the scroll kept, and no second read of the gallery; the title block, the answer strip, tabs by click, keys and arrows; Fit and memory, builds and files with the install call each one makes; an installed model's actions, used-by, the honest usage tab, configuration and logs; loading, an unknown name, offline, an install in flight and a failed one; and the phone. New specs for Placement: every mode and the keys it writes, the slider only when a layer count exists, the context presets, the bars and every verdict, two GPUs, no GPU, a cluster, an unread machine, a loading and an unavailable estimate, Fit it for me and Undo, and the section in the model editor with its save. The phone tap on a row now opens the page, so the two phone specs that expected the inspector as the page check the page and keep the inspector check for a window between a phone and a desk. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): link Studio results and open workspaces from a prompt Each workspace now records the result it was made from (parentId and an edge kind such as take, animate or to-3d) and reads a prompt, model, size, count and source from the query string, so one page can hand work to another. A source result is fetched from the server's own output file and becomes the start image, the picture for 3D, or the audio file. A note on the page says when the source loaded or could not be loaded. Diarization had no history; it now keeps the file name, the model and a speaker count, never the recording. Prompts are cut at 2000 characters when stored. The pure helpers (type suggestion, grouping, lineage layout, favourites, clearing) have node tests. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): rebuild the Studio front page as a composer with your work The front page is a prompt box with a chip per type, a type suggestion from the words, starters, and the options each workspace accepts. Generate opens the workspace with those filled in. A type with no model is a dashed chip that shows a gallery model, its size, memory need and an Install button only when picked; the typed words stay while it installs. Under it, Your work lists results from every workspace as a masonry with filters, counts, favourites and a Clear history action. Results made from each other stack into a project tile and open as a lineage board with a dock for running a new take or branching to the next step; steps the destination cannot start from yet are disabled with the reason. The docs describe the page, what is stored in the browser, and the query parameters a workspace accepts. Assisted-by: Claude Code:claude-sonnet-5-5 * test(ui): cover the Studio composer, your work and the lineage view Specs for the type suggestion, the keys, hand-off to each workspace, the install path for a missing model, the masonry filters, favourites and clearing, stacking, the lineage board, new take and branch, steps that are disabled with a reason, and the phone layout. Existing Studio specs move from lanes to chips with the same intent. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): add the shared Studio workspace frame and move Images onto it The seven Studio workspaces get one layout: a row of type tabs, a compose card (optional sources as chips, a prompt with starters, a model chip, the essential options as chips, an Advanced fold that names what is inside, the memory the model needs, and one action with the reason when it cannot run), a run area, and a strip of recent results of the type. The run area shows a job card with the time that has passed and an indeterminate bar, because these endpoints report no phase or percentage; a failure with what the server said and one action; or the result with a toolbar: Favourite (the list the front page keeps), Download, Use in (the hand-off targets, disabled with the reason when a destination cannot start from the result), Re-run with edits (the take's values go back in the form, changed fields are outlined and listed) and Lineage. A type with no model shows the install note from the front page. Images is the first workspace on the frame. It keeps its size, count, steps, seed, negative prompt, source image and reference images, and its history writes, including the parent link and edge of a hand-off run. useMediaHistory.addEntry now returns the id of the entry it stored. The docs describe the workspace page. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): move Video onto the workspace frame Video keeps its size list, duration, frame rate, steps, seed, CFG scale, frame count, negative prompt, start and end image and avatar audio. The start and end image are source chips, the avatar audio opens the recording and paste input from a chip, and the rest sit in the Advanced fold. A start image from a hand-off shows as a chip with its picture. Results play in the video player with the shared toolbar. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): move TTS onto the workspace frame TTS keeps the saved-voice picker for cloning models, the typed voice for the others, the voice library deep link, and the delivery instructions, which now sit in the Advanced fold. The result is the waveform player with the words under it. The stored entry also keeps the voice id so Re-run with edits can select the same saved voice. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): move Sound onto the workspace frame Sound keeps its Simple and Advanced modes and every field of both: the description, instrumental, vocal language, caption, lyrics, BPM, duration, key, language, time signature and think mode. The mode switch, instrumental and duration are in the compose card, the rest in a More options fold. The stored entry keeps all of the fields, so Re-run with edits restores the form as it was. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): move Transform onto the workspace frame Transform keeps its audio and reference inputs with upload and record, the echo test, the key=value parameters (now in the Advanced fold), the input and output spectra and the three waveform players. The audio that was chosen shows before the run, waiting to be transformed. Re-run with edits puts back the model and parameters and fetches the audio and reference the server kept for that run. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): move 3D onto the workspace frame 3D keeps the picture input with paste and webcam, the animation operations a model declares, quality and background, the shape and material steps, guidance and seed, the GLB and animation viewers, the remesh control and the download. A 3D result now has a title from the motion prompt when it has no label, so the strip and the front page name animation results by what was asked. Re-run with edits is shown disabled with the reason, because only a small thumbnail of the picture is kept. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): move Diarization onto the workspace frame Diarization keeps its model and recording inputs, the option to prepare speakers to remember, the clean-speech previews, naming and remembering a speaker, and the history entry with only the file name, model and counts. The result now shows a timeline with one lane per speaker, the talk time of each speaker, and the segments with their start time and text. RTTM, SRT (only when the run has text) and JSON are built in the browser from the result. The helpers for talk time, axis ticks and the two text formats have node tests. Assisted-by: Claude Code:claude-sonnet-5-5 * refactor(ui): remove the styles and lists the old workspace layout used Nothing renders the two-column workbench, the control column, the old history lists, the generation progress tiles, the TTS voice picker or the result echo any more. The inline-style baseline drops with them. Assisted-by: Claude Code:claude-sonnet-5-5 * test(ui): cover the workspace frame and one run per type Specs for the type tabs, the compose card and its reason when Generate cannot run, starters, the Advanced fold, the job card with no invented progress, a failed run and its one action, the install note, the strip with its favourites filter, Use in with its disabled steps, Lineage, the parent link, Re-run with edits and its list of changes, deleting and clearing, and the hand-off note. One run through each of Video, TTS, Sound, Transform, 3D and Diarization, the phone layout of all seven, and reduced motion. Existing Studio specs move from the old control column to the compose card with the same intent. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): rebuild the chat thread with raised user turns, prose replies and one-line activity Your messages are raised blocks on the right at a 760 px measure and the model's replies are plain prose under its name and a warm or not loaded dot. Reasoning, tool calls and their results fold into one quiet line that opens inline into steps. Code blocks carry a Copy button and a Canvas button that opens that block in the canvas, image attachments are thumbnails that open in the lightbox, and files are chips. Per-message actions show on hover, on focus and on the last turn, and a turn takes focus so the arrow keys and C, E, R and B work. A failed reply keeps the text written so far and shows the reason with one Retry action. The Agent chat page keeps the older rules: the new styles are scoped to the chat page and use their own class names. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): use the Home command bar as the Chat composer Chat now ends in the same object as Home: the model chip, the MCP chip, a Canvas chip, the message box with attach buttons, a solid Send and the hint line, with the slash menu on the kit command list. The slash menu lists what Chat can do today (switch model, new chat, conversations, manage mode, canvas, find, settings, export, clear). While a reply is streaming Send becomes Stop, which Esc also presses, and Up in an empty box edits your last message. Attached images show as thumbnails and a line under the bar carries the speed and the token count. HomeComposer takes optional props for this (extra chips, its own slash list, Stop, paste, a stricter Enter); Home passes none of them. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): open conversations from a Ctrl K menu with day groups, undo and a slim header The conversations list opens as a centred menu on Ctrl or Cmd K. It groups chats by day like the Home resume list, shows the model that answered and the time, searches names and message text, and moves with the arrow keys. Enter opens a chat, F2 renames it and Delete removes it. Removing a chat hides the row and shows the kit undo toast; the chat is deleted for good only when the undo time ends. Rename, duplicate, copy and export are on each row, as before. The header is one slim bar: the Chats button, the chat name (click to rename), a context meter when the context size is known, settings and a More menu with rename, duplicate, copy, export, model info, keyboard shortcuts and clear. A dialog lists the shortcuts the page answers to. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): show loaded state, capabilities and fit in the Chat model switcher The model chip in Chat opens the same list as Home, grouped as Loaded now and Installed. Each row says warm or not loaded and marks models that understand images. When the list opens, the page reads the host memory once and asks the server to estimate each listed model at the chat's context size (up to twelve, three at a time), then shows what the model needs and whether it fits: free memory, how much would run on the CPU, or how far over the machine it is. A model with no estimate shows no fit text, and no load time is shown because the API does not report one. A memory bar closes the list. The picker takes the model list from the page when it has one, and useModels can skip its own request. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): move chat settings into a sheet and add find in chat, jump to latest and a wider canvas Settings open as a kit sheet: the system prompt, temperature, top P and top K (each says "model default" until it is changed and has a Reset), the context size with quick sizes and a note that it only drives the meter, Manage mode and Focus mode, the model info for admins with its Edit config button, and Clear conversation behind a confirmation. The old slide-out drawer and the model info panel are gone. Ctrl or Cmd Shift F (or the search button, or /find) opens a search bar over the thread. It marks matches in the messages already on the page, shows "n of m" and steps with Enter and Shift+Enter. Nothing is sent to the server. Jump to latest is a pill above the composer. Esc stops a reply, then closes the search, then closes the canvas. The canvas panel gets the kit look: tabs, a Code and Preview switch, Copy and Download, a full-page layout on narrow windows, and translated labels. The Agent chat page shares it and gets the same look. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): add the empty, no-model, loading and phone states to Chat An empty chat opens with the composer under one line, starters to try, whether the model is loaded, and the Jump back in list: the same rows as Home, read from the chats the page holds. With no chat model installed, an install card offers the starter models for this hardware, the gallery and import, and the composer stays so the text is not lost. While a reply waits for a model, a load card shows what the page knows: the phase the server names, the node, the bytes and the time left when the server reports them, and a progress bar. A model that is just not loaded yet gets a plain note, with no invented phases or estimates. The foot warns when the context is nearly full. On a phone the header drops its labels, the model list and the settings open as sheets from the bottom, per-message actions stay in view and the canvas takes the whole page. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): rebuild Talk as a calm voice page over what the connection really does Talk is one stage and one transcript. The stage has the pipeline chip, the voice and language chips, an outline orb that follows the real microphone and playback levels, a heading and a sentence for the current state, and the controls. The transcript lists You, Reply, Tool and Result lines and can be copied. Session settings (instructions, voice, language, tools, Manage mode and the pipeline's parts) open in a sheet. The states are the ones the code reaches: no pipeline model, idle, connecting, listening, thinking (also while a tool runs), speaking, an interrupted reply (the server cancelled it; a note marks the cut), a blocked microphone, a link that failed during a session, and any other error with its reason and a link to the traces. Push to talk and hands-free are not on the page, so they are not shown. Diagnostics keep their waveform, spectrum and stats, drawn in theme colours. The page text moves into the talk namespace, and the old Talk and visualizer styles and the inline-style count go down with the rebuild. Assisted-by: Claude Code:claude-sonnet-5-5 * refactor(ui): remove the chat styles and strings the rebuilt page replaced The settings drawer, the model info panel, the bubble avatars, the conversation menu popover, the context bar, the recent strip, the staging bar, the file badges and the focus-mode rules have no user now. Their rules, the Chat page's focus class and seven unused empty-state strings are removed. The Agent chat page keeps the shared message, sidebar and input rules it still renders with. Assisted-by: Claude Code:claude-sonnet-5-5 * docs: describe the rebuilt Chat and Talk pages Add a Chat page under features (thread, message actions and keys, the message box and its slash actions, the model list with loaded state and fit, conversations on Ctrl K, settings, find, canvas and the empty, no-model and loading states) and a Talk section to the realtime API page with the states the page shows. Manage mode now turns on from the chat settings or /assistant, and the client MCP steps point at the MCP chip. Assisted-by: Claude Code:claude-sonnet-5-5 * fix(ui): settle the rough edges of the new Chat and Talk pages The undo toast sat under the conversations menu, so the Undo button could not be pressed while the menu was open; the menu, the sheets and the fullscreen canvas now stay below the toast layer. Esc in a rename box saved the text through the blur that follows it; it now cancels. The image viewer closed on Esc only when the page did not re-render on the same key, so its key listener is registered once and reads the latest handlers. Keys on a focused message no longer type their letter into the editor they open, "/" from outside a text field starts a command as it does on Home, and Esc leaves the page's own dialogs alone. Code in the canvas is highlighted for languages that have no preview. The conversations menu drops its key hints on a phone so Clear all stays in view. Talk hides Test tone while connecting and calls a server error "Something went wrong", since the call can still be open. Assisted-by: Claude Code:claude-sonnet-5-5 * test(ui): cover the rebuilt Chat and Talk pages Specs for the thread layout and the activity fold, code blocks, image thumbnails and the viewer, per-message actions and their keys, a failed reply with its one Retry, Stop and Esc while streaming, the composer and every slash action, the conversations menu (groups, search, resume, rename, delete with undo that ends by itself, one chat left), the model switcher with loaded state, vision and fit text from stubbed estimates, the settings sheet, the canvas panel, find in chat, Jump to latest, the empty, no-model and loading states, the phone layout and reduced motion. Talk is driven over a fake WebRTC link through idle, connecting, listening, thinking, speaking, interrupted, blocked, lost, error and no pipeline, its settings sheet and its phone layout. Node tests cover the message text helpers and the conversation grouping. The existing chat specs move to the new structure with the same intent: the transcript spec now describes the raised turn and the prose reply, and the render smoke accepts Talk's own header. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): keep a bounded run log for agents in the browser The server keeps no run history for an agent, so a run is one task and the events until the agent answers, written to browser storage while the page watches the stream: up to 50 runs per agent, task, step and answer text only. Stored chats from the earlier agent chat page read as runs with stable ids. A run still marked running five minutes after its last event reads as stopped. Helpers read an agent's config into chips, build the list of changed fields against the saved config, hide secret values and offer starting points. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): rebuild the Agents area around runs The Agents page shows what needs a look (work in flight, a run that failed in the last day), then each agent with its model, attached memory and skills, and a strip of its last 14 runs. An agent has its own page: model, tools, memory, skills, instructions, a task box and its runs. A run has an address, shows the thread while it works (steps folded into one line, the tool in use, the answer as it arrives) and settles into a report about a second and a half after the agent answers: task, outcome, follow-ups, evidence and steps, with wide tables opening wider on demand. A failure says in plain words what happened and offers Run again. Create and edit fold into sections with a ready mark and a one-line summary, start from a template or an optional model-written draft, and open a preview sheet with the config as saved and the changes against the saved agent. Status becomes a quiet panel in the same language, and the old chat link opens the agent page. There is no Stop, approval, steer, version or dry-run control, because the agent API has no call behind them. Assisted-by: Claude Code:claude-sonnet-5-5 * test(ui): cover the Agents launcher, agent page, runs and editor Specs for the Now strip and run strip, search and the empty state, the agent page, starting a run, the live thread, settling into the report, the run address across a reload and for a run from another browser, follow-ups with their history, failures, the folding editor with ready marks, templates, the preview sheet with hidden secrets and changes, the status page, and the phone, 1440 and 2560 layouts. Assisted-by: Claude Code:claude-sonnet-5-5 * docs: describe runs and the new agent create flow Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): add helpers for tasks, schedules and job outcomes Reads a cron expression the way the server does (five fields or an @ shortcut), checks it, and puts the common shapes in words. The next run is left out on purpose, because the schedule follows the server clock, which the browser cannot read. Also groups jobs by day, sums the last seven days, and gives each job one outcome line from its result or error. A rerun call starts a new job with the same parameters and media. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): rebuild the Jobs area around runs The Jobs page opens with one sentence about the last seven days, then the tasks (model, schedule in words, last 14 jobs, enabled switch, Run now) and a run history grouped by day. Each row has an outcome sentence and opens to the error or the start of the result with one next action. Deleting a task waits 30 seconds with an undo button. A task opens as a page with its recent runs, its prompt with the gaps marked and its schedule. The task form folds into sections, takes a schedule as a preset or a checked cron expression, warns about prompt gaps the schedule does not fill, and has a preview sheet. A job opens as a document: task, outcome, delivery and the recorded steps; a failed job says what happened and offers Run again. Run now now sends attached media through the job call, which is the only one that takes it. "Clear History" only ever cancelled running jobs, so it is now called Stop running jobs. Webhook headers of a saved task show as JSON instead of [object Object]. Assisted-by: Claude Code:claude-sonnet-5-5 * test(ui): cover the Jobs page, task pages and job pages Assisted-by: Claude Code:claude-sonnet-5-5 * docs: describe the Jobs page and the task form Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): add helpers that say who uses a skill or a collection An agent loads a skill when skills are on and the skill is in its selection (an empty selection means every skill). It reads the one collection that carries its own name, when its knowledge base is on. The helpers derive that from the saved agent configs, build the config that adds or removes a skill or a collection, and estimate tokens as characters divided by four. Removing the last selected skill switches skills off, because an empty selection would mean every skill. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): rebuild Skills and Memory as one library Skills and collections sit in a list with an open item beside it. Each row says who uses it, read from the saved agent configs, or says it is not used yet. Chat reads neither, so it is never named. An item opens in a pane with a Used by strip (names link to the agent, a small x removes it, with undo) and an Add to menu that shows what the addition costs. A collection can be added only to the agent that carries its name. The Memory pane searches the collection alone and shows ranked passages with scores, lists web sources with their refresh interval and the files, shows the server message when an upload fails, and names the endpoints and where files stay. The Simulate a message sheet runs a collection search and shows an agent's skills with a token estimate. It runs no model. The collection details route now opens the same page. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): show use and cost in the agent form pickers Each skill in the agent form says which other agents use it and what it adds to every message, with a total for the selection. The memory section names the collection the agent reads. Assisted-by: Claude Code:claude-sonnet-5-5 * test(ui): cover the Skills and Memory libraries Specs for the used-by lines (including an agent that uses every skill), the filters, search, add to agent, remove with undo, the last-skill case, an unreadable agent list, the empty states, git repositories, the Memory question box, sources, uploads that fail, the Simulate sheet with the parts the API can run, the agent form hints and the phone layout. Assisted-by: Claude Code:claude-sonnet-5-5 * docs: describe the Skills and Memory libraries Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): add helpers for the Operate status page and backend rows Pure functions for the parts that need rules. They work out the memory pool the page measures (GPU memory, system memory, or the workers of a cluster that are answering), which pools are too full, the headline and the four ledger rows, the geometry of the capacity chart, and what removing a backend would leave without a runtime (models name their backend, and a meta backend names the concrete one it points at). A second set says what a backend row states: installing, queued, removing, failed, update available, current or absent. LocalAI keeps no memory history, so the chart reads a bounded buffer of readings the page took itself and says so. A reading with no total is dropped rather than drawn as zero. Two hooks are shared by the pages that need them. One retries a failed operation after moving the failure into the record. The other holds a cancel for an undo window, because the server cannot take a cancel back. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): rebuild Operate Status, This machine, Backends, Activity and Logs Status opens with one sentence ("2 things need you", or "Everything is running") and four rows: Needs you, Capacity, Running now and Recent failures. A row with a problem opens by itself and holds the button that deals with it: Update a backend, Retry or Dismiss a failed operation, Unload a model. A quiet row stays one line. A new installation gets a first-run screen, a cluster sums the memory of the workers that are answering, and a page still waiting for an answer says so. The chart under the rows is drawn from readings the page took while it was open and is labelled that way, because LocalAI keeps no memory history. This machine leads with GPU memory as one bar, then host memory split by running model, then VRAM, RAM, CPU and disk with a bar each. The running models become a kit table with the same menu and stop dialog. Backends is one list with Installed and Catalog views. A row says what the backend is doing (a progress bar with Cancel, Queued, Failed with Retry, Update 1.2.0, Current), carries the one button that matters, and opens in place. Removing a backend names the models and the meta backends that would stop working. Check for updates, Update all, From URL and a first-run recommendation for llama-cpp are in the header. Activity keeps its three sections as quiet rows. Cancel waits eight seconds with an undo toast, because the server cannot take a cancel back; a cancelled install can be started again from the record. Logs gets a process list, a picker, stream and text filters, Follow and Times switches, and a Clear with an undo window. Not shown, because the API has no data for them: GPU temperature and power, a size per backend, an earlier version to roll back to, a dependency lookup beyond the models and meta backends that name a backend, and models that failed to load. Assisted-by: Claude Code:claude-sonnet-5-5 * test(ui): cover Operate Status, This machine, Backends, Activity and Logs New specs for the Status headline and ledger (healthy, needs attention, one thing, a full memory pool alone, loading, first run, cluster), its actions (Update, Retry, Dismiss, Unload with its dialog), the capacity chart built from readings taken while the page is open and bounded, the phone layout, no coloured edge on a row, and reduced motion. The Backends specs cover the two views, install progress with Cancel and its undo window, Retry on a failed install, Update, Update all, Check for updates, removal with the models and meta backends it would break, Install from URL, the first-run recommendation, a cluster, and a phone. Activity gains cancel with undo, Cancel now, a second cancel, leaving the page, progress, and starting a cancelled install again. Logs covers the stream and text filters, Follow, Times, Export, Clear with undo, the process picker and list. This machine covers the GPU strip, several GPUs, no GPU and Add a machine. Existing specs keep their intent and follow the new structure: rows open in place instead of in a pane, Update replaces Upgrade, the notice spec now pins that an update is a row state and not a banner or a rail, and a cancel waits for its undo window. Assisted-by: Claude Code:claude-sonnet-5-5 * docs: describe the rebuilt Operate Status, Backends and Activity pages Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): add helpers for the Swarm pages Pure functions for what the pages work out from the cluster API: a node's state in words, which nodes a placement rule may use, what a rule would ask for, what a drain or a lost node would leave without service, the nodes a bulk backend update reaches, and the join commands for a worker, a peer instance and a memory shard. Hooks read the roster, the loaded replicas and the rules. Everything runs in the browser from data the page already holds, and says when it cannot see free memory or disk. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): rebuild the Swarm hub: nodes, node page, placement rules, failover Nodes is a sortable table with comfortable and compact rows, a Needs attention filter by reason, a map of the cluster that is not drawn on a phone, the running models, and a bulk backend update for the nodes that drifted. A node is a page: state, vitals, a drain preview computed from the loaded replicas and the rules, tabs for models, backends, logs and capacity and labels, and Remove that asks for the node's name. Placement rules are written as sentences, show where each model is loaded now, and edit in a side sheet with a preview of the nodes a draft could use. Deleting a rule waits a few seconds so it can be taken back. Failover keeps its chains, adds what the router does when a worker stops answering and a per-node preview of what would stop. Add a node covers a registered worker, a peer instance and a memory shard, with a command to copy and a live line that says when the machine arrived. P2P keeps its page in the same vocabulary, and the node logs page follows the local logs page. Previews are labelled as worked out in the browser. Per-GPU readings and node events are not drawn because the API does not return them. Failover moves to Swarm when distributed mode is on. Legacy fleet components and their styles are removed. Assisted-by: Claude Code:claude-sonnet-5-5 * test(ui): cover the Swarm hub New specs for adding a node (each join method, the command, copy, waiting and found, approve, a single install, P2P, a phone), placement rules (sentences, where models are loaded, the preview matrix, the sheet and its preview, delete with undo) and failover on a cluster. Node detail covers its tabs, the drain preview and its dialog, resume, remove with the typed name, a node that stopped answering, and unload. The nodes specs follow the new structure and keep their intent: the table, filters, grouping, pagination, bulk actions, the map, and running models with stop, logs and the loading, error and empty states. The scheduling, failover, P2P, hub and smoke specs follow the renames. Assisted-by: Claude Code:claude-sonnet-5-5 * docs: describe the rebuilt Swarm pages Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): add helpers for the Traffic pages Pure functions for what the pages work out from the usage ledger, the trace summary, the trace buffers and the resources reading: the shared time window, grouping, sorting and filtering of usage rows, chart series and axes that start at zero, the overview figures, per-model statistics, the state of a trace and the words for a failure, the backend operations that ran during a request, CSV export, the Prometheus metric list and scrape config, and a bounded buffer of host readings. A figure whose source cannot say is null, never zero. The trace summary call takes the window in hours, and a helper reads /metrics with its status. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): rebuild the Traffic hub: overview, usage, models, host, traces, middleware Traffic opens on an overview: five figures (requests, failed, p95, tokens in and out) and three charts, each naming its source. A second row of links reaches Usage, Models, GPU and host, Traces, Middleware and Prometheus, and one time window is shared by the first three. Usage groups by model, user or API key, filters, sorts, opens a row on its own chart, exports the rows it holds as CSV or JSON in the browser, and keeps the opt-in cost estimate and the quota forecast. A user who is not an admin sees only their own numbers. Models joins the ledger, the backend-operation buffer and the loaded models. GPU and host shows the current reading and two charts of readings taken since the page opened. Traces gets filters, a settings strip and an explained off state. An API request is a page: the error LocalAI recorded, a timeline with the backend operations that ran meanwhile, and bodies that stay closed until revealed. Middleware draws the pipeline as five steps and shows the rules of the selected step. Prometheus documents /metrics, checks it against the server and gives a scrape config to copy. Alerts is not built: LocalAI has no alert rules. Per-model latency percentiles, GPU utilisation and compare with the previous period are not drawn because the API does not return them. Legacy usage, trace and middleware styles and the usage source components are removed. Assisted-by: Claude Code:claude-sonnet-5-5 * test(ui): cover the Traffic hub New specs for the overview (figures, charts with a data table and arrow key readout, failed and first-run and tracing-off states, the shared window, a phone), usage (group by, filters, sort, export, cost, quotas, a non-admin, empty and loading), models, GPU and host (snapshot, the since-opened labelling, a cluster), the traces list, a trace page (the real error, the timeline, reveal, no headers, a trace that left the buffer), Prometheus and the Middleware pipeline, with shared fixtures. The usage, traces, middleware, hub and smoke specs follow the new structure and keep their intent. Assisted-by: Claude Code:claude-sonnet-5-5 * docs: describe the Traffic hub Add an operations page for the Traffic tab: which record each page reads, what it leaves out and why, the trace page and its reveal, the GPU and host readings kept since the page opened, and the Prometheus endpoint. Link it from the operations index, the tracing page and the middleware page. Assisted-by: Claude Code:claude-sonnet-5-5 * fix(ui): let metric names wrap in the Prometheus table on a phone The long metric names pushed the type and "on this server" columns out of view. Names now wrap inside the table. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): rebuild Settings with groups by intent, search, a pending bar and history The fifteen sections become eight groups by intent: memory and models, speed and defaults, backends and galleries, access and security, debugging and traces, agents and responses, swarm and sharing, look and feel. Search covers names, descriptions, keys and the old section name, and says where a result used to be. Edits wait in a bar with Discard, Show diff and Apply. The diff lists old and new values and the checks the browser can make: durations parse the way Go parses them, a GPU memory budget is one the server accepts, a gallery box holds JSON, and warnings repeat what the handler and the field text say. Apply sends only the changed keys. Undo saves the previous values again; it is a new save, not a rollback. History lists the changes applied from this browser, since LocalAI keeps no settings log, and Revert stages the old value. A value is marked as changed only where the built-in default is known from the CLI defaults. A row says "Applies now" or "Needs restart" only where the handler or the docs say so. Three things were wrong before and are fixed with the rebuild: the gallery boxes and the shared API keys box were sent under names the server ignores, the "Enable CSRF Protection" switch showed the disable flag the wrong way round, and every save restarted peer-to-peer networking because every field was sent. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): rebuild Users and keys, Account, sign-in, invite and the 404 page Users and keys is a tabbed page under the Settings tab: people, invites and API keys. The people table filters by state and role, sorts, approves or disables (disabling offers an undo that sets the status back), and opens a side sheet for one person's features, model allow-list and limits. Role, password reset and delete sit in the row menu; delete asks for the name. Invites choose a lifetime of 1, 7 or 30 days and show the link once. API keys can be created with a lifetime, are shown once in full, can be paused, and are revoked after a ten second undo window in which nothing is sent. LocalAI lists keys only to their owner, so the tab shows the signed-in person's own keys and says so. Account has Profile, Security, API keys and Usage. Usage shows the last 30 days, tokens by model and the limits an admin set. The Security tab now shows for a GitHub or SSO account and says the password is not theirs to change. Sign-in asks for one field per step and draws a provider button only for a provider /api/auth/status lists. It has the notice for a sign-up that waits for approval, the first-admin screen, the key-only screen and the invite page. An address outside the app now gets the 404 page too, which names the address and lists the places the sidebar lists, with the same gates. Assisted-by: Claude Code:claude-sonnet-5-5 * test(ui): cover Settings, Users and keys, Account, sign-in and the 404 page Settings: groups, search by name, key and old section, the changed marker only where a default is known, apply hints, the pending bar and diff, the checks, apply sending only changed keys, undo as a second save, discard, history, the CSRF inversion and the gallery and API key wire forms, and the phone layout. Users and keys: the table, filters, sort, approve, disable with undo, the row menu, the access sheet, invites, key creation with a one-time reveal, the ten second revoke with undo and with a page leave, and the non-admin redirect. Account, each sign-in variant (error, pending, first admin, key-only, invite, provider buttons) and the 404 page have specs too. Fixtures are shared with the screenshot scripts. Existing specs follow the new structure. Assisted-by: Claude Code:claude-sonnet-5-5 * docs: describe the rebuilt Settings, Users and keys, Account and sign-in pages Runtime settings: the eight groups and where each old section went, search, the pending bar, the diff and its checks, apply, undo, the history, and which settings show a default or an apply note and why. Authentication: the sign-in screen variants, the Account tabs, key lifetimes, the one-time key reveal, the revoke undo window, and the fact that keys are listed only to their owner. Assisted-by: Claude Code:claude-sonnet-5-5 * fix(ui): let the Settings undo toast stand alone and read back a generated P2P token The saved message and the undo toast sat on the same spot at the bottom of the page. The undo toast now carries the saved message. A new P2P token is made by the server when the page sends 0. The page reads it back after the save so the field shows the token and not the placeholder. Assisted-by: Claude Code:claude-sonnet-5-5 * fix(ui): fit the users table, API keys and Account figures on a phone On a phone the users table dropped its Role and Status columns off the screen edge with the row actions. The role and state now sit under the name, so the actions stay in view. API key rows no longer put the key icon on a line of its own, and the three Account figures keep one row. Assisted-by: Claude Code:claude-sonnet-5-5 * test(ui): cover the phone users table, reduced motion and the empty Account state Assisted-by: Claude Code:claude-sonnet-5-5 * fix(ui): drop the apply note from three settings the save handler does not mention Size-aware eviction, automatic backend upgrades and development backends said Applies now, but nothing in the handler or the docs says when they take effect. A row now carries a note only where the code or the docs say so. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): rebuild Voices and Faces as one identity family Voices is one page with three tabs: Speakers (voiceprints for recognising who is speaking), Speech voices (the text-to-speech reference library, kept apart because it is a different store) and From a recording (a link into the diarization workspace). Faces uses the same layout. Who is this and Same person? give the answer in a sentence with the real distance and cut-off, a word for how far inside the cut-off it sits, and a distance scale with the cut-off drawn on it. The cut-off slider re-reads the answer in the browser; the identify call sends the cut-off, and verify uses the threshold the model returns. The old confidence percentage is gone because it is not a probability. The server has no list call, so the people list stays in the browser and the page says so. After a search that asked for more people than it got back, a saved person the server did not return is marked, and people the server returned that the browser does not know are listed. Nothing is claimed from a short or cut-off search. Enrolling is a sheet: sample, name, labels, permission. A copy of the sample in the browser is opt-in, and an administrator can also keep the recording as a speech voice in the same step. Removing a person waits ten seconds behind an Undo toast and sends nothing before then. Errors say what happened (no face found, model missing, call failed), a blocked or missing microphone is explained, and a missing model or a missing permission renders a page that says what turns the feature on instead of a redirect. Analyze, detect and raw embedding move under More tools, with attribute guesses off by default. Assisted-by: Claude Code:claude-sonnet-5-5 * test(ui): cover the Voices and Faces pages Specs for who is this (match, no match, working, failed, missing model), the cut-off slider, same person, a blocked, allowed and insecure microphone, the registry notes and the not-on-the-server marks, the enrol sheet and its opt-in copy, delete with undo on a fake clock, the disabled and no-permission states, the phone layout, reduced motion and Faces. Existing library and diarization specs follow the new structure and keep their intent. Node tests cover the distance words, scale layout, stored list and error mapping. Assisted-by: Claude Code:claude-sonnet-5-5 * docs: describe the Voices and Faces pages Add a WebUI section to the voice and face recognition pages: the two tools, the cut-off, what the people list is and why it can be stale, the undo window, and what is stored where. Point the Voice Library and Fish Audio notes at Build, Voices, Speech voices. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): rebuild the Build landing, Fine-tune, Quantize, Import and Explorer The Build landing says what each tool is for and what it needs from the machine: the installed backend, the GPU memory, RAM and disk the server reports, and a job that is running or the newest one when it failed. A tool that cannot run says why and what enables it. Fine-tune and Quantize share one page: set up, a check list that is redrawn as the form changes, a run view with progress, stages and a log, and a result with real next steps (export, import, chat, Models). The checks state only what the server reports. A job needs no estimate the server cannot make, so none is invented. Stop on a fine-tuning job asks whether to keep a checkpoint, a failed job shows the server's message, and a memory failure offers two changes that are applied to a copy of the setup. Import is a guided flow: source, review, import, done. The server returns no preview before an import starts, so the review reads the spelling of the source, prints the request the form will send and runs the checks that can be made early. The estimate that arrives when the import starts is set against free memory and disk. The ambiguity picker and the Write YAML tab stay. Explorer shows what GET /networks returns and lists a swarm with POST /network/add, with a join sheet that carries the token and commands. Build tools the account may not use say so instead of redirecting. Assisted-by: Claude Code:claude-sonnet-5-5 * test(ui): cover the Build landing, the tool pages, Import and Explorer Specs for the landing (a tool ready, missing a backend, with no GPU, a running or failed job, a feature switched off, a member without admin, phone, reduced motion), the shared tool pattern for Fine-tune and Quantize (set up, live checks, start request, running with progress, chart and log, the stop choice, failure with the server message, finish with next steps, earlier jobs, the account-disabled page, phone), Import (source detection, review, checks, ambiguity, running with the estimate against free memory, done, Write YAML, phone) and Explorer (list, join, list a swarm, empty, not an explorer, retry, phone). Existing specs follow the new structure and keep their intent. Node tests cover the machine facts, tool status, checks, log lines, source detection, the import request and the join commands. Assisted-by: Claude Code:claude-sonnet-5-5 * docs: describe the Build tool pages, the import flow and the Explorer Fine-tuning and quantization now describe the set up, check, run and result steps and what the check list can and cannot say. The import section explains the review step and why the size and memory appear only after the import starts. The distributed page describes the Explorer list, the join sheet and what listing a swarm publishes. Assisted-by: Claude Code:claude-sonnet-5-5 * feat(ui): turn the hardware recommendations into a "Best for this machine" shelf The shelf in the Models inspector put five columns into a 400 px pane, so long model ids wrapped letter by letter underneath the size and the memory figures. Each row now stacks the tag, the id and the size and memory facts beside one Install button, and the id wraps inside its own column. Once a model is installed the shelf narrows to the best fit and keeps the others behind a "N more that fit" toggle. Specs cover the ranking, the layout, the narrowing and the install request against a gallery fixture that carries the 4K estimate the shelf sizes against. Assisted-by: Claude Code:claude-sonnet-5-5 * fix(ui): quiet the Studio tab markers and say their state in words The type tabs drew a saturated green dot for every modality that has a model. The dot now uses a text colour, filled when a model is installed and hollow when none is, and each tab carries "(model installed)" or "(no model installed)" as hidden text so the state is not only a colour. Assisted-by: Claude Code:claude-sonnet-5-5 * fix(ui): stack the model editor empty state and drop its section hues "No fields configured" sat in a flex row, so the icon, the title and the text ran together. It now uses the stacked empty-state layout. The section icons took a different status colour each (amber, red, green); they now share one quiet colour, with the accent on the current section. Assisted-by: Claude Code:claude-sonnet-5-5 * fix(ui): keep the Home memory sentence whole on a phone and drop side rails On a 390 px screen the memory strip clipped "2 models loaded" to make room for the figure. The sentence now takes the first line and the figure and device wrap under it. The sweep also removed coloured left rails from the editor section rail, the skill editor list, the install strip and the audio transform notice (now an outlined note), plus unused chat rules that carried rails and two glow animations that nothing referenced. Assisted-by: Claude Code:claude-sonnet-5-5 * fix(ui): line up hub pages, list the model templates, and stop clipped text Medium-width pages inside Build and Operate were centred while the tab bar above them was flush left, so the title started 60 px right of the first tab. They now start at the bar's edge. Add Model offered nine templates as a grid of identical cards with chip clouds and inline styles. It is now one list of rows, each with the field names it fills in on a single muted line. Two clipped strings are fixed: the Studio voice field cut its placeholder mid-word, and the phone job list ended the schedule line in an ellipsis. The recommendation shelf also separates size and memory with a dot, and the docs describe the shelf. Assisted-by: Claude Code:claude-sonnet-5-5 * fix(ui): keep the hidden Studio tab state inside its tab The hidden state text added to each type tab was absolutely positioned against the page, so on a phone it sat outside the scrolling tab row and widened the page by hundreds of pixels. The tab is now the containing block. Assisted-by: Claude Code:claude-sonnet-5-5 * test(ui): cover the on-disk sizes on the Installed table, model page and cleanup sheet The fixtures stub GET /api/models/storage. The default report is empty, so existing specs keep the gallery estimates. makeStorage() builds a report from files and the models that use them, the way the server does, and storageSpec() is a models directory with shared and missing files. New specs cover the Size column and its shared line, the fallback when the call fails or the user is not an admin, the files list on the model page, a missing file, the bytes a removal frees with shared files, and the cleanup findings. Node tests cover the storage helpers and the batch arithmetic. Assisted-by: Claude Code:claude-sonnet-5-5 * test(ui): wait for the page before pressing keys and ticking the clock Two specs failed in loaded full runs and passed alone. The Alt+1 to Alt+7 spec pressed a key before the composer had armed its key handler. The capacity chart spec advanced the fake clock before the poller had mounted, so it counted fewer readings than it expected. Both now wait for the page to mount. The key spec retries a press that lands during a re-render, and the clock spec advances in small steps and polls for the row count. Assisted-by: Claude Code:claude-sonnet-5-5 * fix(ui): hide the Installed footer when the storage report is empty An empty report from the storage call made the footer read "0.0 GB on disk" next to sizes taken from the gallery estimate. An empty report says nothing about the disk, so the footer now shows only the model count. A spec covers it. Assisted-by: Claude Code:claude-sonnet-5-5 * fix(ui): keep Explore pane actions inside the pane The inspector actions sat in a non-wrapping flex row beside the title, so the buttons ran past the pane edge once it got narrow. The row now takes its own line and wraps. The primary action (Install, Retry, Open) comes first. Manage installation becomes a ghost button, and Open details moves to the end of the row, so one action stands out and the others are quiet. No action or test id is removed. Add a spec that checks, in light and dark at several widths and with a pane forced to 320 px, that every action stays inside the pane box and that the pane keeps its inner padding. Co-Authored-By: Claude Sonnet 5.5 * refactor(ui): drop the type chips from the Studio composer The Studio tabs and the composer's type chips listed the same seven modes, so the page said the same thing twice. Keep the tabs as the one place to switch modes. The composer now shows the type it will open as a small label in its header. The type suggestion from the typed words stays as the quiet hint line under the prompt, and Alt+1 to Alt+7 still pick a type. The composer root carries data-type, data-types and data-missing so tests can read the state. Specs pick a type through a shared Alt+digit helper and read a missing model from the tab dot instead of a chip. Remove the unused chip locale strings and CSS. Co-Authored-By: Claude Sonnet 5.5 * refactor(ui): remove the section crumb above page titles Page headers drew a small uppercase crumb with a short rule before it above the title. On the hub pages it repeated the hub name, so Build sat above a heading that also said Build. PageHeader now renders only the title, the supporting line and the actions. Drop the eyebrow prop, the route-derived section name, its CSS and the unused section helper, and remove the explicit eyebrow props from the pages that passed one. Pages stay reachable through the sidebar and the hub tab bar. Add a spec that checks several pages show their title with nothing ahead of it in the header. Co-Authored-By: Claude Sonnet 5.5 * refactor(ui): remove left accent rails from tiles, rows and quotes Several surfaces marked state with a coloured strip on the left edge. Replace each one with a cue that is not a rail: - Stat cards lose the strip; the icon and value still carry the colour. - The highlighted card is a raised surface with a firmer edge. - The selected rail row is an accent wash with a hairline outline. - The status stripe on rail items is a small status dot. - The active failover row is a tinted row. - Quotes in markdown and chat prose are italic instead of barred. - The variant detail panel has a full hairline border. Add a spec that walks the main routes in light and dark and fails on a left border thicker than 1px, a sideways inset shadow, a narrow absolute strip in ::before or ::after, or a narrow tall child pinned to a left edge. Co-Authored-By: Claude Sonnet 5.5 --------- Co-authored-by: Ettore Di Giacinto Co-authored-by: Claude Sonnet 5.5 --- core/http/react-ui/bun.lock | 2 - core/http/react-ui/e2e/access-fixtures.js | 156 + .../e2e/account-api-key-pause.spec.js | 2 +- core/http/react-ui/e2e/account-page.spec.js | 133 + core/http/react-ui/e2e/activity-page.spec.js | 134 +- core/http/react-ui/e2e/add-node.spec.js | 163 + core/http/react-ui/e2e/admin-console.spec.js | 29 +- core/http/react-ui/e2e/agent-jobs-run.spec.js | 101 + .../http/react-ui/e2e/agent-jobs-task.spec.js | 227 + core/http/react-ui/e2e/agent-jobs.spec.js | 291 + core/http/react-ui/e2e/agents-edit.spec.js | 126 + core/http/react-ui/e2e/agents-fixtures.js | 139 + core/http/react-ui/e2e/agents-runs.spec.js | 316 + core/http/react-ui/e2e/agents.spec.js | 126 +- .../http/react-ui/e2e/audio-transform.spec.js | 2 + core/http/react-ui/e2e/backend-logs.spec.js | 160 +- .../react-ui/e2e/backends-lifecycle.spec.js | 44 +- .../react-ui/e2e/backends-management.spec.js | 104 +- .../http/react-ui/e2e/backends-notice.spec.js | 38 +- .../react-ui/e2e/backends-runtime.spec.js | 307 + core/http/react-ui/e2e/build-landing.spec.js | 161 + core/http/react-ui/e2e/build-tools.spec.js | 513 ++ .../e2e/canonical-resource-navigation.spec.js | 6 +- .../react-ui/e2e/chat-canvas-find.spec.js | 184 + core/http/react-ui/e2e/chat-composer.spec.js | 226 + core/http/react-ui/e2e/chat-errors.spec.js | 24 +- core/http/react-ui/e2e/chat-fixtures.js | 146 + core/http/react-ui/e2e/chat-history.spec.js | 215 + .../react-ui/e2e/chat-model-loading.spec.js | 13 +- .../react-ui/e2e/chat-model-switcher.spec.js | 145 + .../react-ui/e2e/chat-pdf-attachment.spec.js | 14 +- .../e2e/chat-polling-selection.spec.js | 18 +- core/http/react-ui/e2e/chat-settings.spec.js | 170 + core/http/react-ui/e2e/chat-states.spec.js | 200 + core/http/react-ui/e2e/chat-thread.spec.js | 237 + .../http/react-ui/e2e/chat-transcript.spec.js | 73 +- core/http/react-ui/e2e/chrome-audit.spec.js | 20 +- core/http/react-ui/e2e/class-hygiene.spec.js | 52 +- core/http/react-ui/e2e/collections.spec.js | 18 +- core/http/react-ui/e2e/console-narrow.spec.js | 64 +- core/http/react-ui/e2e/design-system.spec.js | 101 +- .../react-ui/e2e/diarization-profiles.spec.js | 4 +- .../http/react-ui/e2e/discover-height.spec.js | 6 +- .../e2e/discover-search-focus.spec.js | 6 +- core/http/react-ui/e2e/explorer.spec.js | 160 + .../react-ui/e2e/failover-overview.spec.js | 110 +- core/http/react-ui/e2e/forking-chat.spec.js | 44 +- core/http/react-ui/e2e/home-console.spec.js | 730 ++ core/http/react-ui/e2e/home-fixtures.js | 98 + core/http/react-ui/e2e/home-lanes.spec.js | 78 +- core/http/react-ui/e2e/home-redesign.spec.js | 75 +- core/http/react-ui/e2e/hub-tabs.spec.js | 232 + core/http/react-ui/e2e/icons.spec.js | 104 + core/http/react-ui/e2e/identity-fixtures.js | 110 + core/http/react-ui/e2e/identity.spec.js | 530 ++ core/http/react-ui/e2e/import-flow.spec.js | 279 + .../react-ui/e2e/import-form-layout.spec.js | 14 +- .../e2e/installed-model-logs-link.spec.js | 2 +- core/http/react-ui/e2e/jobs-fixtures.js | 159 + core/http/react-ui/e2e/ledger-fixtures.js | 548 ++ .../react-ui/e2e/library-agent-form.spec.js | 24 + core/http/react-ui/e2e/library-fixtures.js | 173 + .../react-ui/e2e/library-simulate.spec.js | 111 + core/http/react-ui/e2e/local-machine.spec.js | 85 +- core/http/react-ui/e2e/login-states.spec.js | 219 + core/http/react-ui/e2e/media-history.spec.js | 2 +- core/http/react-ui/e2e/memory.spec.js | 224 + .../http/react-ui/e2e/middleware-page.spec.js | 23 +- core/http/react-ui/e2e/model-page.spec.js | 649 ++ .../http/react-ui/e2e/model-placement.spec.js | 573 ++ core/http/react-ui/e2e/models-cleanup.spec.js | 398 + .../react-ui/e2e/models-cluster-fit.spec.js | 10 +- .../e2e/models-explore-pane-actions.spec.js | 64 + core/http/react-ui/e2e/models-gallery.spec.js | 306 +- .../e2e/models-installed-ledger.spec.js | 227 + core/http/react-ui/e2e/models-ledger.spec.js | 491 ++ .../react-ui/e2e/models-lifecycle.spec.js | 41 +- .../e2e/models-recommended-panel.spec.js | 88 +- core/http/react-ui/e2e/models-storage.spec.js | 290 + core/http/react-ui/e2e/navigation.spec.js | 63 +- core/http/react-ui/e2e/no-edge-rails.spec.js | 69 + core/http/react-ui/e2e/nodes-detail.spec.js | 197 +- .../e2e/nodes-fleet-dashboard.spec.js | 777 +- .../nodes-per-node-backend-actions.spec.js | 19 +- core/http/react-ui/e2e/nodes-roster.spec.js | 10 +- core/http/react-ui/e2e/not-found.spec.js | 62 + core/http/react-ui/e2e/operate-fixtures.js | 173 + .../react-ui/e2e/operate-overview.spec.js | 104 +- core/http/react-ui/e2e/operate-status.spec.js | 271 + .../react-ui/e2e/operations-strip.spec.js | 2 + core/http/react-ui/e2e/p2p.spec.js | 93 +- .../react-ui/e2e/page-header-no-crumb.spec.js | 23 + .../react-ui/e2e/page-render-smoke.spec.js | 16 +- .../http/react-ui/e2e/placement-rules.spec.js | 219 + core/http/react-ui/e2e/scheduling.spec.js | 51 +- .../e2e/settings-backend-logging.spec.js | 73 +- core/http/react-ui/e2e/settings-fixtures.js | 54 + core/http/react-ui/e2e/settings-page.spec.js | 404 + .../react-ui/e2e/sidebar-alignment.spec.js | 90 + core/http/react-ui/e2e/skills.spec.js | 179 +- .../http/react-ui/e2e/studio-composer.spec.js | 563 ++ core/http/react-ui/e2e/studio-fixtures.js | 237 + core/http/react-ui/e2e/studio-lineage.spec.js | 245 + .../http/react-ui/e2e/studio-overview.spec.js | 74 +- core/http/react-ui/e2e/studio-work.spec.js | 143 + .../react-ui/e2e/studio-workbench.spec.js | 23 +- .../react-ui/e2e/studio-workspace.spec.js | 424 + core/http/react-ui/e2e/surfaces.spec.js | 115 + core/http/react-ui/e2e/swarm-fixtures.js | 185 + core/http/react-ui/e2e/talk.spec.js | 321 + core/http/react-ui/e2e/taste-polish.spec.js | 127 + .../react-ui/e2e/threed-animation.spec.js | 3 +- core/http/react-ui/e2e/threed-gen.spec.js | 2 +- core/http/react-ui/e2e/tools-fixtures.js | 154 + core/http/react-ui/e2e/traces-latency.spec.js | 2 +- core/http/react-ui/e2e/traces-live.spec.js | 6 +- .../http/react-ui/e2e/traces-metadata.spec.js | 20 +- core/http/react-ui/e2e/traces-page.spec.js | 176 + .../react-ui/e2e/traces-pagination.spec.js | 27 +- core/http/react-ui/e2e/traces.spec.js | 16 +- core/http/react-ui/e2e/traffic-fixtures.js | 261 + .../react-ui/e2e/traffic-models-host.spec.js | 125 + .../react-ui/e2e/traffic-overview.spec.js | 150 + .../e2e/traffic-prometheus-middleware.spec.js | 112 + core/http/react-ui/e2e/traffic-usage.spec.js | 164 + .../http/react-ui/e2e/usage-dashboard.spec.js | 18 +- core/http/react-ui/e2e/users-page.spec.js | 389 + .../react-ui/e2e/users-tab-gating.spec.js | 17 +- .../react-ui/e2e/voice-library-empty.spec.js | 4 + core/http/react-ui/e2e/voice-library.spec.js | 37 +- core/http/react-ui/eslint.config.js | 5 +- core/http/react-ui/index.html | 9 + core/http/react-ui/inline-style-baseline.txt | 2 +- core/http/react-ui/package-lock.json | 9 - core/http/react-ui/package.json | 1 - .../react-ui/public/locales/de/admin.json | 12 - core/http/react-ui/public/locales/de/nav.json | 27 +- .../react-ui/public/locales/en/admin.json | 200 +- .../react-ui/public/locales/en/agents.json | 621 +- .../http/react-ui/public/locales/en/auth.json | 91 +- .../public/locales/en/biometrics.json | 292 + .../http/react-ui/public/locales/en/chat.json | 190 +- .../public/locales/en/collections.json | 125 +- .../react-ui/public/locales/en/explorer.json | 57 + .../http/react-ui/public/locales/en/home.json | 148 +- .../public/locales/en/importModel.json | 86 +- .../react-ui/public/locales/en/library.json | 112 + .../react-ui/public/locales/en/media.json | 390 +- .../react-ui/public/locales/en/models.json | 523 +- core/http/react-ui/public/locales/en/nav.json | 35 +- .../react-ui/public/locales/en/operate.json | 455 + .../react-ui/public/locales/en/skills.json | 58 +- .../react-ui/public/locales/en/swarm.json | 607 ++ .../http/react-ui/public/locales/en/talk.json | 145 + .../react-ui/public/locales/en/tools.json | 472 + .../react-ui/public/locales/en/traffic.json | 539 ++ .../react-ui/public/locales/es/admin.json | 12 - core/http/react-ui/public/locales/es/nav.json | 27 +- .../react-ui/public/locales/id/admin.json | 12 - core/http/react-ui/public/locales/id/nav.json | 27 +- .../react-ui/public/locales/it/admin.json | 12 - core/http/react-ui/public/locales/it/nav.json | 27 +- .../react-ui/public/locales/ko/admin.json | 12 - core/http/react-ui/public/locales/ko/nav.json | 27 +- .../react-ui/public/locales/pt-BR/admin.json | 12 - .../react-ui/public/locales/pt-BR/nav.json | 27 +- .../react-ui/public/locales/zh-CN/admin.json | 12 - .../react-ui/public/locales/zh-CN/nav.json | 27 +- core/http/react-ui/src/App.css | 7765 ++++------------- core/http/react-ui/src/App.jsx | 36 +- .../react-ui/src/components/ActionMenu.jsx | 7 +- .../src/components/AmbiguityAlert.jsx | 140 +- .../src/components/AnimationOptions.jsx | 3 +- .../src/components/AnimationViewer.jsx | 2 +- .../react-ui/src/components/CanvasPanel.jsx | 64 +- .../react-ui/src/components/ChatsMenu.jsx | 511 +- .../src/components/ClientMCPDropdown.jsx | 9 +- .../src/components/ConfigFieldRenderer.jsx | 11 +- .../react-ui/src/components/ConfirmDialog.jsx | 5 +- core/http/react-ui/src/components/Dialog.jsx | 67 + .../react-ui/src/components/EmptyState.jsx | 3 +- .../src/components/EntityActionListEditor.jsx | 5 +- .../src/components/ErrorWithTraceLink.jsx | 5 +- .../src/components/FailoverChainStatus.jsx | 3 +- .../src/components/FailoverTargetsEditor.jsx | 13 +- .../react-ui/src/components/FieldBrowser.jsx | 3 +- .../react-ui/src/components/FilterBar.jsx | 7 +- .../react-ui/src/components/GalleryLoader.jsx | 13 +- .../src/components/GenerationProgress.jsx | 29 - .../react-ui/src/components/GlbViewer.jsx | 7 +- .../react-ui/src/components/HomeConnect.jsx | 122 +- core/http/react-ui/src/components/Icon.jsx | 48 + .../react-ui/src/components/ImageSelector.jsx | 80 +- .../src/components/LanguageSwitcher.jsx | 5 +- .../http/react-ui/src/components/Lightbox.jsx | 25 +- .../react-ui/src/components/MediaHistory.jsx | 77 - .../react-ui/src/components/MetaBadgeRow.jsx | 9 +- core/http/react-ui/src/components/Modal.jsx | 2 +- .../src/components/ModelMultiSelect.jsx | 3 +- .../react-ui/src/components/ModelSelector.jsx | 21 +- .../src/components/NodeDistributionChip.jsx | 5 +- .../src/components/NodeInstallPicker.jsx | 25 +- .../react-ui/src/components/OperationCard.jsx | 51 +- .../react-ui/src/components/OperationsBar.jsx | 13 +- .../react-ui/src/components/PageHeader.jsx | 18 +- .../src/components/PatternListEditor.jsx | 5 +- core/http/react-ui/src/components/Popover.jsx | 2 +- .../src/components/RecommendedModels.jsx | 82 +- .../src/components/RequireFeature.jsx | 6 +- .../react-ui/src/components/ResourceCards.jsx | 3 +- .../src/components/ResourceMonitor.jsx | 152 - .../react-ui/src/components/ResourceRow.jsx | 9 +- .../src/components/RouterCandidatesEditor.jsx | 9 +- .../src/components/RouterPoliciesEditor.jsx | 5 +- .../src/components/SearchableSelect.jsx | 11 +- .../react-ui/src/components/SideSheet.jsx | 65 + core/http/react-ui/src/components/Sidebar.jsx | 104 +- .../react-ui/src/components/Sparkline.jsx | 40 - .../react-ui/src/components/StarterModels.jsx | 46 +- .../http/react-ui/src/components/StatCard.jsx | 4 +- .../src/components/TemplateSelector.jsx | 69 +- .../react-ui/src/components/ThemeToggle.jsx | 3 +- .../react-ui/src/components/ThreeDHistory.jsx | 76 - core/http/react-ui/src/components/Toast.jsx | 13 +- .../src/components/UnifiedMCPDropdown.jsx | 15 +- .../src/components/UserGroupSection.jsx | 5 +- .../src/components/VoiceVisualizer.jsx | 167 +- .../src/components/agents/AgentBits.jsx | 107 + .../src/components/agents/JobBits.jsx | 59 + .../react-ui/src/components/agents/Prose.jsx | 50 + .../src/components/agents/RunTaskDialog.jsx | 174 + .../src/components/audio/WaveformPlayer.jsx | 2 +- .../biometrics/DistributionBars.jsx | 4 +- .../biometrics/EmbeddingInspector.jsx | 3 +- .../components/biometrics/EnrollmentList.jsx | 7 +- .../src/components/biometrics/MatchGauge.jsx | 3 +- .../src/components/biometrics/MediaInput.jsx | 23 +- .../src/components/biometrics/TabSwitch.jsx | 4 +- .../src/components/chat/ActivityFold.jsx | 214 + .../src/components/chat/ChatHeader.jsx | 125 + .../src/components/chat/ChatMessage.jsx | 249 + .../src/components/chat/ChatSettingsSheet.jsx | 246 + .../src/components/chat/EmptyChat.jsx | 58 + .../react-ui/src/components/chat/FindBar.jsx | 47 + .../react-ui/src/components/chat/LoadCard.jsx | 44 + .../src/components/chat/NoModelCard.jsx | 32 + .../src/components/chat/ShortcutsDialog.jsx | 58 + .../src/components/chat/chatActions.js | 27 + .../react-ui/src/components/chat/chatText.js | 59 + .../src/components/chat/chatText.test.js | 51 + .../src/components/chat/findInThread.js | 54 + .../src/components/console/ConsoleLayout.jsx | 161 - .../src/components/console/consoleConfig.js | 137 - .../src/components/home/HomeComposer.jsx | 248 + .../src/components/home/HomeFirstRun.jsx | 47 + .../src/components/home/HomeMemoryStrip.jsx | 178 + .../src/components/home/HomeModelPicker.jsx | 225 + .../src/components/home/HomeResume.jsx | 152 + .../src/components/home/HomeUndoToast.jsx | 67 + .../src/components/home/homeActions.js | 41 + .../react-ui/src/components/home/memory.js | 54 + .../react-ui/src/components/hub/HubLayout.jsx | 136 + .../react-ui/src/components/hub/hubConfig.js | 261 + .../react-ui/src/components/hub/useHubAuth.js | 23 + .../react-ui/src/components/iconGlyphs.jsx | 11 + .../react-ui/src/components/iconSprite.js | 26 + .../src/components/identity/ClipInput.jsx | 213 + .../src/components/identity/DistanceScale.jsx | 42 + .../src/components/identity/EnrolSheet.jsx | 164 + .../src/components/identity/ErrorNote.jsx | 26 + .../src/components/identity/FeatureOff.jsx | 21 + .../src/components/identity/KnownList.jsx | 109 + .../src/components/identity/ModelNeeded.jsx | 81 + .../src/components/identity/MoreFaceTools.jsx | 260 + .../components/identity/MoreVoiceTools.jsx | 222 + .../src/components/identity/Verdict.jsx | 22 + .../src/components/identity/Workbench.jsx | 363 + .../src/components/identity/useRegistry.js | 76 + .../src/components/library/LibraryBits.jsx | 182 + .../src/components/library/SimulateSheet.jsx | 340 + .../src/components/models/CleanupSheet.jsx | 562 ++ .../src/components/models/DiskStrip.jsx | 36 + .../src/components/models/ExploreTable.jsx | 223 + .../src/components/models/FacetBar.jsx | 40 + .../src/components/models/FitCell.jsx | 39 + .../src/components/models/MemoryBar.css | 21 + .../src/components/models/MemoryBar.jsx | 45 + .../src/components/models/page/ConfigTab.jsx | 99 + .../src/components/models/page/FitTab.jsx | 130 + .../src/components/models/page/LogsTab.jsx | 17 + .../components/models/page/OverviewTab.jsx | 252 + .../src/components/models/page/UsageTab.jsx | 154 + .../components/models/page/VariantsTab.jsx | 200 + .../src/components/models/page/VramChart.css | 24 + .../src/components/models/page/VramChart.jsx | 92 + .../src/components/models/page/fitFacts.js | 40 + .../src/components/models/page/model-page.css | 279 + .../models/placement/PlacementSection.jsx | 504 ++ .../components/models/placement/placement.css | 102 + .../react-ui/src/components/models/rowKeys.js | 92 + .../src/components/nodes/AttentionCallout.jsx | 31 - .../src/components/nodes/CapacityEditor.jsx | 17 +- .../src/components/nodes/ClusterOverview.jsx | 95 - .../src/components/nodes/ClusterPulse.jsx | 18 - .../src/components/nodes/HostOverview.jsx | 192 +- .../src/components/nodes/KeyValueChips.jsx | 5 +- .../src/components/nodes/LocalMachineView.jsx | 39 +- .../src/components/nodes/LocalModelTable.jsx | 179 +- .../components/nodes/LocalRunningModels.jsx | 114 +- .../src/components/nodes/ModelFleetTable.jsx | 70 - .../src/components/nodes/ModelInspector.jsx | 98 - .../src/components/nodes/NodeFleetTable.jsx | 92 - .../src/components/nodes/NodeInspector.jsx | 143 - .../src/components/nodes/NodePanel.jsx | 64 - .../src/components/nodes/StatusPill.jsx | 11 - .../components/nodes/useInspectorDrawer.js | 90 - .../src/components/operate/BackendsTable.jsx | 150 + .../src/components/operate/CapacityChart.jsx | 176 + .../src/components/operate/LedgerRow.jsx | 45 + .../src/components/operate/useUnloadModel.jsx | 60 + .../src/components/settings/HistorySheet.jsx | 57 + .../src/components/settings/PendingBar.jsx | 63 + .../src/components/settings/SettingField.jsx | 193 + .../src/components/split/DetailHeader.jsx | 25 +- .../src/components/split/EntityRail.jsx | 9 +- .../src/components/studio/HandoffNote.jsx | 30 + .../src/components/studio/InstallNote.jsx | 128 + .../src/components/studio/LineageView.jsx | 381 + .../src/components/studio/StarButton.jsx | 28 + .../src/components/studio/StudioComposer.jsx | 258 + .../src/components/studio/Timeline.jsx | 31 + .../src/components/studio/WorkMasonry.jsx | 192 + .../src/components/studio/WorkThumb.jsx | 57 + .../src/components/studio/Workspace.jsx | 564 ++ .../react-ui/src/components/studio/studio.css | 640 ++ .../src/components/studio/workspace.css | 615 ++ .../src/components/swarm/AddNodeFlow.jsx | 234 + .../src/components/swarm/CommandBlock.jsx | 35 + .../src/components/swarm/LeaveList.jsx | 39 + .../src/components/swarm/MemoryMeter.jsx | 41 + .../src/components/swarm/ModelPicker.jsx | 93 + .../react-ui/src/components/swarm/NodeMap.jsx | 93 + .../src/components/swarm/NodeState.jsx | 19 + .../src/components/swarm/NodesTable.jsx | 158 + .../src/components/swarm/RuleSheet.jsx | 262 + .../src/components/swarm/RunningModels.jsx | 244 + .../src/components/swarm/SwarmDialog.jsx | 77 + .../src/components/talk/SessionSheet.jsx | 154 + .../react-ui/src/components/tools/Checks.jsx | 66 + .../react-ui/src/components/tools/JobLog.jsx | 75 + .../src/components/tools/LossChart.jsx | 164 + .../src/components/tools/StageLine.jsx | 21 + .../react-ui/src/components/tools/ToolOff.jsx | 28 + .../src/components/tools/ToolSteps.jsx | 23 + .../react-ui/src/components/tools/tools.css | 268 + .../components/traffic/BackendTraceDetail.jsx | 179 + .../src/components/traffic/PercentChart.jsx | 125 + .../src/components/traffic/TrafficChart.jsx | 161 + .../src/components/traffic/WindowSwitch.jsx | 28 + .../src/components/users/AccessSheet.jsx | 243 + .../src/components/users/ApiKeysPanel.jsx | 255 + .../src/components/users/InvitesPanel.jsx | 192 + .../src/components/users/UsersPanel.jsx | 382 + .../src/contexts/OperateSummaryContext.jsx | 23 +- core/http/react-ui/src/hooks/use3DHistory.js | 11 +- core/http/react-ui/src/hooks/useAgentChat.js | 182 - .../react-ui/src/hooks/useBuildDuplicates.js | 51 + .../react-ui/src/hooks/useCleanupFacts.js | 104 + .../react-ui/src/hooks/useDelayedAction.js | 51 + .../http/react-ui/src/hooks/useFacetCounts.js | 73 + .../src/hooks/useGalleryEnrichment.js | 7 +- .../react-ui/src/hooks/useLibraryFacts.js | 70 + .../react-ui/src/hooks/useLoadedModels.js | 21 + .../react-ui/src/hooks/useMediaCapture.js | 7 +- .../react-ui/src/hooks/useMediaHistory.js | 20 +- .../react-ui/src/hooks/useModelActions.js | 124 + core/http/react-ui/src/hooks/useModelFit.js | 61 + core/http/react-ui/src/hooks/useModelPage.js | 140 + .../react-ui/src/hooks/useModelRemoval.js | 84 + core/http/react-ui/src/hooks/useModelSizes.js | 59 + .../react-ui/src/hooks/useModelStorage.js | 30 + .../react-ui/src/hooks/useModelSuggestion.js | 102 + core/http/react-ui/src/hooks/useModels.js | 6 +- .../react-ui/src/hooks/useOperationActions.js | 55 + .../react-ui/src/hooks/usePlacementConfig.js | 89 + .../src/hooks/usePlacementEstimate.js | 115 + .../react-ui/src/hooks/useStudioHandoff.js | 66 + core/http/react-ui/src/hooks/useStudioWork.js | 64 + core/http/react-ui/src/hooks/useSwarm.js | 95 + core/http/react-ui/src/hooks/useTraffic.js | 127 + .../react-ui/src/hooks/useUndoableCancel.js | 52 + core/http/react-ui/src/hooks/useWideLayout.js | 18 + core/http/react-ui/src/hooks/useWorkspace.js | 155 + core/http/react-ui/src/i18n/index.js | 5 + core/http/react-ui/src/index.css | 10 +- core/http/react-ui/src/main.jsx | 5 +- core/http/react-ui/src/pages/Account.jsx | 660 +- core/http/react-ui/src/pages/Activity.jsx | 201 +- core/http/react-ui/src/pages/AddNode.jsx | 32 + core/http/react-ui/src/pages/AgentChat.jsx | 811 -- core/http/react-ui/src/pages/AgentCreate.jsx | 676 +- .../react-ui/src/pages/AgentJobDetails.jsx | 484 +- core/http/react-ui/src/pages/AgentJobs.jsx | 980 ++- core/http/react-ui/src/pages/AgentPage.jsx | 323 + core/http/react-ui/src/pages/AgentRun.jsx | 650 ++ core/http/react-ui/src/pages/AgentStatus.jsx | 387 +- .../react-ui/src/pages/AgentTaskDetails.jsx | 1182 ++- core/http/react-ui/src/pages/Agents.jsx | 419 +- .../react-ui/src/pages/AudioTransform.jsx | 350 +- core/http/react-ui/src/pages/BackendLogs.jsx | 394 +- core/http/react-ui/src/pages/Backends.jsx | 1572 ++-- .../http/react-ui/src/pages/BuildOverview.jsx | 230 + core/http/react-ui/src/pages/Chat.jsx | 1558 ++-- .../react-ui/src/pages/CollectionDetails.jsx | 538 -- core/http/react-ui/src/pages/Collections.jsx | 875 +- core/http/react-ui/src/pages/Diarization.jsx | 218 +- core/http/react-ui/src/pages/Explorer.jsx | 314 +- .../react-ui/src/pages/FaceRecognition.jsx | 630 +- core/http/react-ui/src/pages/Failover.jsx | 230 +- core/http/react-ui/src/pages/FineTune.jsx | 1902 ++-- core/http/react-ui/src/pages/Home.jsx | 747 +- core/http/react-ui/src/pages/ImageGen.jsx | 282 +- core/http/react-ui/src/pages/ImportModel.jsx | 403 +- .../react-ui/src/pages/InstalledBackends.jsx | 544 -- .../react-ui/src/pages/InstalledModels.jsx | 966 +- core/http/react-ui/src/pages/Login.jsx | 483 +- core/http/react-ui/src/pages/Middleware.jsx | 1280 ++- core/http/react-ui/src/pages/ModelEditor.jsx | 114 +- core/http/react-ui/src/pages/ModelPage.jsx | 647 ++ core/http/react-ui/src/pages/Models.jsx | 1309 +-- .../react-ui/src/pages/NodeBackendLogs.jsx | 258 +- core/http/react-ui/src/pages/NodeDetail.jsx | 633 +- core/http/react-ui/src/pages/Nodes.jsx | 619 +- core/http/react-ui/src/pages/NotFound.jsx | 43 +- .../react-ui/src/pages/OperateOverview.jsx | 606 +- core/http/react-ui/src/pages/P2P.jsx | 639 +- core/http/react-ui/src/pages/Prometheus.jsx | 141 + core/http/react-ui/src/pages/Quantize.jsx | 719 +- core/http/react-ui/src/pages/Scheduling.jsx | 733 +- core/http/react-ui/src/pages/Settings.jsx | 895 +- core/http/react-ui/src/pages/SkillEdit.jsx | 49 +- core/http/react-ui/src/pages/Skills.jsx | 883 +- core/http/react-ui/src/pages/Sound.jsx | 284 +- core/http/react-ui/src/pages/Studio.jsx | 87 +- .../react-ui/src/pages/StudioOverview.jsx | 227 +- core/http/react-ui/src/pages/TTS.jsx | 296 +- core/http/react-ui/src/pages/Talk.jsx | 646 +- core/http/react-ui/src/pages/ThreeDGen.jsx | 240 +- core/http/react-ui/src/pages/TracePage.jsx | 198 + core/http/react-ui/src/pages/Traces.jsx | 1108 +-- core/http/react-ui/src/pages/TrafficHost.jsx | 174 + .../http/react-ui/src/pages/TrafficModels.jsx | 190 + .../react-ui/src/pages/TrafficOverview.jsx | 227 + core/http/react-ui/src/pages/Usage.jsx | 1200 +-- .../src/pages/Usage/SourceMixRibbon.jsx | 83 - .../src/pages/Usage/SourceTimeChart.jsx | 148 - .../react-ui/src/pages/Usage/SourcesTab.jsx | 176 - .../react-ui/src/pages/Usage/SourcesTable.jsx | 244 - core/http/react-ui/src/pages/Users.jsx | 1075 +-- core/http/react-ui/src/pages/VideoGen.jsx | 293 +- core/http/react-ui/src/pages/VoiceLibrary.jsx | 422 +- .../react-ui/src/pages/VoiceProfileCreate.jsx | 122 +- .../react-ui/src/pages/VoiceRecognition.jsx | 601 +- core/http/react-ui/src/pages/access.css | 181 + core/http/react-ui/src/pages/agent-jobs.css | 181 + core/http/react-ui/src/pages/agents.css | 501 ++ core/http/react-ui/src/pages/auth.css | 767 +- core/http/react-ui/src/pages/chat.css | 966 ++ core/http/react-ui/src/pages/explorer.css | 53 + core/http/react-ui/src/pages/identity.css | 296 + core/http/react-ui/src/pages/import.css | 114 + core/http/react-ui/src/pages/library.css | 208 + core/http/react-ui/src/pages/login.css | 48 + .../http/react-ui/src/pages/models-ledger.css | 765 ++ core/http/react-ui/src/pages/notfound.css | 15 + core/http/react-ui/src/pages/operate.css | 436 + core/http/react-ui/src/pages/settings.css | 180 + core/http/react-ui/src/pages/swarm.css | 394 + core/http/react-ui/src/pages/talk.css | 229 + core/http/react-ui/src/pages/traffic.css | 360 + core/http/react-ui/src/router.jsx | 108 +- core/http/react-ui/src/theme-localai.css | 130 + core/http/react-ui/src/theme.css | 354 +- core/http/react-ui/src/utils/access.js | 107 + core/http/react-ui/src/utils/access.test.js | 73 + .../react-ui/src/utils/agentChatHistory.js | 15 - .../src/utils/agentChatHistory.test.js | 29 - .../react-ui/src/utils/agentConfigTools.js | 107 + core/http/react-ui/src/utils/agentInfo.js | 63 + .../http/react-ui/src/utils/agentInfo.test.js | 82 + core/http/react-ui/src/utils/agentJobText.js | 78 + core/http/react-ui/src/utils/agentJobs.js | 323 + .../http/react-ui/src/utils/agentJobs.test.js | 127 + core/http/react-ui/src/utils/agentRuns.js | 359 + .../http/react-ui/src/utils/agentRuns.test.js | 157 + core/http/react-ui/src/utils/api.js | 30 +- core/http/react-ui/src/utils/artifacts.js | 31 +- core/http/react-ui/src/utils/backendRows.js | 50 + .../react-ui/src/utils/backendRows.test.js | 48 + core/http/react-ui/src/utils/cleanupPlan.js | 181 + core/http/react-ui/src/utils/cmTheme.js | 205 +- core/http/react-ui/src/utils/destinations.js | 36 + core/http/react-ui/src/utils/diarization.js | 89 + .../react-ui/src/utils/diarization.test.js | 65 + core/http/react-ui/src/utils/entityGroups.js | 10 +- core/http/react-ui/src/utils/faIcon.js | 27 + .../react-ui/src/utils/homeConversations.js | 148 + .../src/utils/homeConversations.test.js | 50 + core/http/react-ui/src/utils/iconHtml.js | 18 + core/http/react-ui/src/utils/identity.js | 146 + core/http/react-ui/src/utils/identity.test.js | 71 + core/http/react-ui/src/utils/lastModel.js | 15 + core/http/react-ui/src/utils/library.js | 148 + core/http/react-ui/src/utils/library.test.js | 100 + core/http/react-ui/src/utils/libraryText.js | 16 + core/http/react-ui/src/utils/markdown.js | 42 +- .../react-ui/src/utils/mediaHistoryStore.js | 29 + core/http/react-ui/src/utils/modelLedger.js | 113 + .../react-ui/src/utils/modelLedger.test.js | 122 + core/http/react-ui/src/utils/modelStorage.js | 102 + .../react-ui/src/utils/modelStorage.test.js | 110 + .../http/react-ui/src/utils/modelTemplates.js | 26 +- core/http/react-ui/src/utils/modelWalk.js | 64 + .../http/react-ui/src/utils/modelWalk.test.js | 34 + core/http/react-ui/src/utils/operateStatus.js | 207 + .../react-ui/src/utils/operateStatus.test.js | 210 + core/http/react-ui/src/utils/placement.js | 235 + .../http/react-ui/src/utils/placement.test.js | 182 + .../http/react-ui/src/utils/referenceAudio.js | 39 + core/http/react-ui/src/utils/section.js | 18 - core/http/react-ui/src/utils/settings.test.js | 157 + .../react-ui/src/utils/settingsPending.js | 210 + .../http/react-ui/src/utils/settingsSchema.js | 418 + core/http/react-ui/src/utils/studioWork.js | 507 ++ .../react-ui/src/utils/studioWork.test.js | 263 + core/http/react-ui/src/utils/swarm.js | 261 + core/http/react-ui/src/utils/swarm.test.js | 144 + core/http/react-ui/src/utils/tools.js | 443 + core/http/react-ui/src/utils/tools.test.js | 204 + core/http/react-ui/src/utils/traffic.js | 730 ++ core/http/react-ui/src/utils/traffic.test.js | 260 + .../react-ui/src/vendor/ui-kit/.ui-kit.lock | 10 + .../http/react-ui/src/vendor/ui-kit/README.md | 11 + .../react-ui/src/vendor/ui-kit/components.css | 1063 +++ .../src/vendor/ui-kit/icons/fa-map.json | 233 + .../src/vendor/ui-kit/icons/sprite.svg | 171 + .../react-ui/src/vendor/ui-kit/motion.css | 185 + .../src/vendor/ui-kit/theme-contract.md | 113 + .../src/vendor/ui-kit/themes/sample-ink.css | 119 + .../src/vendor/ui-kit/themes/sample-paper.css | 119 + .../react-ui/src/vendor/ui-kit/tokens.css | 72 + docs/content/advanced/model-configuration.md | 42 + docs/content/features/agents.md | 30 +- docs/content/features/audio-cpp.md | 2 +- docs/content/features/authentication.md | 23 +- docs/content/features/backends.md | 45 +- docs/content/features/chat.md | 73 + docs/content/features/distributed-mode.md | 30 +- .../features/distributed_inferencing.md | 8 +- docs/content/features/face-recognition.md | 6 + docs/content/features/fine-tuning.md | 14 +- docs/content/features/localai-assistant.md | 4 +- docs/content/features/mcp.md | 2 +- docs/content/features/model-failover.md | 15 +- docs/content/features/model-gallery.md | 116 +- docs/content/features/openai-realtime.md | 16 + docs/content/features/quantization.md | 14 +- docs/content/features/runtime-settings.md | 33 +- docs/content/features/studio.md | 74 + docs/content/features/text-to-audio.md | 4 +- docs/content/features/tracing.md | 6 + docs/content/features/voice-recognition.md | 15 + docs/content/getting-started/first-agent.md | 2 +- docs/content/getting-started/models.md | 65 +- docs/content/operations/_index.en.md | 1 + docs/content/operations/activity.md | 34 +- docs/content/operations/middleware.md | 4 +- docs/content/operations/overview.md | 146 +- docs/content/operations/traffic.md | 98 + 579 files changed, 77619 insertions(+), 30833 deletions(-) create mode 100644 core/http/react-ui/e2e/access-fixtures.js create mode 100644 core/http/react-ui/e2e/account-page.spec.js create mode 100644 core/http/react-ui/e2e/add-node.spec.js create mode 100644 core/http/react-ui/e2e/agent-jobs-run.spec.js create mode 100644 core/http/react-ui/e2e/agent-jobs-task.spec.js create mode 100644 core/http/react-ui/e2e/agent-jobs.spec.js create mode 100644 core/http/react-ui/e2e/agents-edit.spec.js create mode 100644 core/http/react-ui/e2e/agents-fixtures.js create mode 100644 core/http/react-ui/e2e/agents-runs.spec.js create mode 100644 core/http/react-ui/e2e/backends-runtime.spec.js create mode 100644 core/http/react-ui/e2e/build-landing.spec.js create mode 100644 core/http/react-ui/e2e/build-tools.spec.js create mode 100644 core/http/react-ui/e2e/chat-canvas-find.spec.js create mode 100644 core/http/react-ui/e2e/chat-composer.spec.js create mode 100644 core/http/react-ui/e2e/chat-fixtures.js create mode 100644 core/http/react-ui/e2e/chat-history.spec.js create mode 100644 core/http/react-ui/e2e/chat-model-switcher.spec.js create mode 100644 core/http/react-ui/e2e/chat-settings.spec.js create mode 100644 core/http/react-ui/e2e/chat-states.spec.js create mode 100644 core/http/react-ui/e2e/chat-thread.spec.js create mode 100644 core/http/react-ui/e2e/explorer.spec.js create mode 100644 core/http/react-ui/e2e/home-console.spec.js create mode 100644 core/http/react-ui/e2e/home-fixtures.js create mode 100644 core/http/react-ui/e2e/hub-tabs.spec.js create mode 100644 core/http/react-ui/e2e/icons.spec.js create mode 100644 core/http/react-ui/e2e/identity-fixtures.js create mode 100644 core/http/react-ui/e2e/identity.spec.js create mode 100644 core/http/react-ui/e2e/import-flow.spec.js create mode 100644 core/http/react-ui/e2e/jobs-fixtures.js create mode 100644 core/http/react-ui/e2e/ledger-fixtures.js create mode 100644 core/http/react-ui/e2e/library-agent-form.spec.js create mode 100644 core/http/react-ui/e2e/library-fixtures.js create mode 100644 core/http/react-ui/e2e/library-simulate.spec.js create mode 100644 core/http/react-ui/e2e/login-states.spec.js create mode 100644 core/http/react-ui/e2e/memory.spec.js create mode 100644 core/http/react-ui/e2e/model-page.spec.js create mode 100644 core/http/react-ui/e2e/model-placement.spec.js create mode 100644 core/http/react-ui/e2e/models-cleanup.spec.js create mode 100644 core/http/react-ui/e2e/models-explore-pane-actions.spec.js create mode 100644 core/http/react-ui/e2e/models-installed-ledger.spec.js create mode 100644 core/http/react-ui/e2e/models-ledger.spec.js create mode 100644 core/http/react-ui/e2e/models-storage.spec.js create mode 100644 core/http/react-ui/e2e/no-edge-rails.spec.js create mode 100644 core/http/react-ui/e2e/not-found.spec.js create mode 100644 core/http/react-ui/e2e/operate-fixtures.js create mode 100644 core/http/react-ui/e2e/operate-status.spec.js create mode 100644 core/http/react-ui/e2e/page-header-no-crumb.spec.js create mode 100644 core/http/react-ui/e2e/placement-rules.spec.js create mode 100644 core/http/react-ui/e2e/settings-fixtures.js create mode 100644 core/http/react-ui/e2e/settings-page.spec.js create mode 100644 core/http/react-ui/e2e/sidebar-alignment.spec.js create mode 100644 core/http/react-ui/e2e/studio-composer.spec.js create mode 100644 core/http/react-ui/e2e/studio-fixtures.js create mode 100644 core/http/react-ui/e2e/studio-lineage.spec.js create mode 100644 core/http/react-ui/e2e/studio-work.spec.js create mode 100644 core/http/react-ui/e2e/studio-workspace.spec.js create mode 100644 core/http/react-ui/e2e/surfaces.spec.js create mode 100644 core/http/react-ui/e2e/swarm-fixtures.js create mode 100644 core/http/react-ui/e2e/talk.spec.js create mode 100644 core/http/react-ui/e2e/taste-polish.spec.js create mode 100644 core/http/react-ui/e2e/tools-fixtures.js create mode 100644 core/http/react-ui/e2e/traces-page.spec.js create mode 100644 core/http/react-ui/e2e/traffic-fixtures.js create mode 100644 core/http/react-ui/e2e/traffic-models-host.spec.js create mode 100644 core/http/react-ui/e2e/traffic-overview.spec.js create mode 100644 core/http/react-ui/e2e/traffic-prometheus-middleware.spec.js create mode 100644 core/http/react-ui/e2e/traffic-usage.spec.js create mode 100644 core/http/react-ui/e2e/users-page.spec.js create mode 100644 core/http/react-ui/public/locales/en/biometrics.json create mode 100644 core/http/react-ui/public/locales/en/explorer.json create mode 100644 core/http/react-ui/public/locales/en/library.json create mode 100644 core/http/react-ui/public/locales/en/operate.json create mode 100644 core/http/react-ui/public/locales/en/swarm.json create mode 100644 core/http/react-ui/public/locales/en/talk.json create mode 100644 core/http/react-ui/public/locales/en/tools.json create mode 100644 core/http/react-ui/public/locales/en/traffic.json create mode 100644 core/http/react-ui/src/components/Dialog.jsx delete mode 100644 core/http/react-ui/src/components/GenerationProgress.jsx create mode 100644 core/http/react-ui/src/components/Icon.jsx delete mode 100644 core/http/react-ui/src/components/MediaHistory.jsx delete mode 100644 core/http/react-ui/src/components/ResourceMonitor.jsx create mode 100644 core/http/react-ui/src/components/SideSheet.jsx delete mode 100644 core/http/react-ui/src/components/Sparkline.jsx delete mode 100644 core/http/react-ui/src/components/ThreeDHistory.jsx create mode 100644 core/http/react-ui/src/components/agents/AgentBits.jsx create mode 100644 core/http/react-ui/src/components/agents/JobBits.jsx create mode 100644 core/http/react-ui/src/components/agents/Prose.jsx create mode 100644 core/http/react-ui/src/components/agents/RunTaskDialog.jsx create mode 100644 core/http/react-ui/src/components/chat/ActivityFold.jsx create mode 100644 core/http/react-ui/src/components/chat/ChatHeader.jsx create mode 100644 core/http/react-ui/src/components/chat/ChatMessage.jsx create mode 100644 core/http/react-ui/src/components/chat/ChatSettingsSheet.jsx create mode 100644 core/http/react-ui/src/components/chat/EmptyChat.jsx create mode 100644 core/http/react-ui/src/components/chat/FindBar.jsx create mode 100644 core/http/react-ui/src/components/chat/LoadCard.jsx create mode 100644 core/http/react-ui/src/components/chat/NoModelCard.jsx create mode 100644 core/http/react-ui/src/components/chat/ShortcutsDialog.jsx create mode 100644 core/http/react-ui/src/components/chat/chatActions.js create mode 100644 core/http/react-ui/src/components/chat/chatText.js create mode 100644 core/http/react-ui/src/components/chat/chatText.test.js create mode 100644 core/http/react-ui/src/components/chat/findInThread.js delete mode 100644 core/http/react-ui/src/components/console/ConsoleLayout.jsx delete mode 100644 core/http/react-ui/src/components/console/consoleConfig.js create mode 100644 core/http/react-ui/src/components/home/HomeComposer.jsx create mode 100644 core/http/react-ui/src/components/home/HomeFirstRun.jsx create mode 100644 core/http/react-ui/src/components/home/HomeMemoryStrip.jsx create mode 100644 core/http/react-ui/src/components/home/HomeModelPicker.jsx create mode 100644 core/http/react-ui/src/components/home/HomeResume.jsx create mode 100644 core/http/react-ui/src/components/home/HomeUndoToast.jsx create mode 100644 core/http/react-ui/src/components/home/homeActions.js create mode 100644 core/http/react-ui/src/components/home/memory.js create mode 100644 core/http/react-ui/src/components/hub/HubLayout.jsx create mode 100644 core/http/react-ui/src/components/hub/hubConfig.js create mode 100644 core/http/react-ui/src/components/hub/useHubAuth.js create mode 100644 core/http/react-ui/src/components/iconGlyphs.jsx create mode 100644 core/http/react-ui/src/components/iconSprite.js create mode 100644 core/http/react-ui/src/components/identity/ClipInput.jsx create mode 100644 core/http/react-ui/src/components/identity/DistanceScale.jsx create mode 100644 core/http/react-ui/src/components/identity/EnrolSheet.jsx create mode 100644 core/http/react-ui/src/components/identity/ErrorNote.jsx create mode 100644 core/http/react-ui/src/components/identity/FeatureOff.jsx create mode 100644 core/http/react-ui/src/components/identity/KnownList.jsx create mode 100644 core/http/react-ui/src/components/identity/ModelNeeded.jsx create mode 100644 core/http/react-ui/src/components/identity/MoreFaceTools.jsx create mode 100644 core/http/react-ui/src/components/identity/MoreVoiceTools.jsx create mode 100644 core/http/react-ui/src/components/identity/Verdict.jsx create mode 100644 core/http/react-ui/src/components/identity/Workbench.jsx create mode 100644 core/http/react-ui/src/components/identity/useRegistry.js create mode 100644 core/http/react-ui/src/components/library/LibraryBits.jsx create mode 100644 core/http/react-ui/src/components/library/SimulateSheet.jsx create mode 100644 core/http/react-ui/src/components/models/CleanupSheet.jsx create mode 100644 core/http/react-ui/src/components/models/DiskStrip.jsx create mode 100644 core/http/react-ui/src/components/models/ExploreTable.jsx create mode 100644 core/http/react-ui/src/components/models/FacetBar.jsx create mode 100644 core/http/react-ui/src/components/models/FitCell.jsx create mode 100644 core/http/react-ui/src/components/models/MemoryBar.css create mode 100644 core/http/react-ui/src/components/models/MemoryBar.jsx create mode 100644 core/http/react-ui/src/components/models/page/ConfigTab.jsx create mode 100644 core/http/react-ui/src/components/models/page/FitTab.jsx create mode 100644 core/http/react-ui/src/components/models/page/LogsTab.jsx create mode 100644 core/http/react-ui/src/components/models/page/OverviewTab.jsx create mode 100644 core/http/react-ui/src/components/models/page/UsageTab.jsx create mode 100644 core/http/react-ui/src/components/models/page/VariantsTab.jsx create mode 100644 core/http/react-ui/src/components/models/page/VramChart.css create mode 100644 core/http/react-ui/src/components/models/page/VramChart.jsx create mode 100644 core/http/react-ui/src/components/models/page/fitFacts.js create mode 100644 core/http/react-ui/src/components/models/page/model-page.css create mode 100644 core/http/react-ui/src/components/models/placement/PlacementSection.jsx create mode 100644 core/http/react-ui/src/components/models/placement/placement.css create mode 100644 core/http/react-ui/src/components/models/rowKeys.js delete mode 100644 core/http/react-ui/src/components/nodes/AttentionCallout.jsx delete mode 100644 core/http/react-ui/src/components/nodes/ClusterOverview.jsx delete mode 100644 core/http/react-ui/src/components/nodes/ClusterPulse.jsx delete mode 100644 core/http/react-ui/src/components/nodes/ModelFleetTable.jsx delete mode 100644 core/http/react-ui/src/components/nodes/ModelInspector.jsx delete mode 100644 core/http/react-ui/src/components/nodes/NodeFleetTable.jsx delete mode 100644 core/http/react-ui/src/components/nodes/NodeInspector.jsx delete mode 100644 core/http/react-ui/src/components/nodes/NodePanel.jsx delete mode 100644 core/http/react-ui/src/components/nodes/StatusPill.jsx delete mode 100644 core/http/react-ui/src/components/nodes/useInspectorDrawer.js create mode 100644 core/http/react-ui/src/components/operate/BackendsTable.jsx create mode 100644 core/http/react-ui/src/components/operate/CapacityChart.jsx create mode 100644 core/http/react-ui/src/components/operate/LedgerRow.jsx create mode 100644 core/http/react-ui/src/components/operate/useUnloadModel.jsx create mode 100644 core/http/react-ui/src/components/settings/HistorySheet.jsx create mode 100644 core/http/react-ui/src/components/settings/PendingBar.jsx create mode 100644 core/http/react-ui/src/components/settings/SettingField.jsx create mode 100644 core/http/react-ui/src/components/studio/HandoffNote.jsx create mode 100644 core/http/react-ui/src/components/studio/InstallNote.jsx create mode 100644 core/http/react-ui/src/components/studio/LineageView.jsx create mode 100644 core/http/react-ui/src/components/studio/StarButton.jsx create mode 100644 core/http/react-ui/src/components/studio/StudioComposer.jsx create mode 100644 core/http/react-ui/src/components/studio/Timeline.jsx create mode 100644 core/http/react-ui/src/components/studio/WorkMasonry.jsx create mode 100644 core/http/react-ui/src/components/studio/WorkThumb.jsx create mode 100644 core/http/react-ui/src/components/studio/Workspace.jsx create mode 100644 core/http/react-ui/src/components/studio/studio.css create mode 100644 core/http/react-ui/src/components/studio/workspace.css create mode 100644 core/http/react-ui/src/components/swarm/AddNodeFlow.jsx create mode 100644 core/http/react-ui/src/components/swarm/CommandBlock.jsx create mode 100644 core/http/react-ui/src/components/swarm/LeaveList.jsx create mode 100644 core/http/react-ui/src/components/swarm/MemoryMeter.jsx create mode 100644 core/http/react-ui/src/components/swarm/ModelPicker.jsx create mode 100644 core/http/react-ui/src/components/swarm/NodeMap.jsx create mode 100644 core/http/react-ui/src/components/swarm/NodeState.jsx create mode 100644 core/http/react-ui/src/components/swarm/NodesTable.jsx create mode 100644 core/http/react-ui/src/components/swarm/RuleSheet.jsx create mode 100644 core/http/react-ui/src/components/swarm/RunningModels.jsx create mode 100644 core/http/react-ui/src/components/swarm/SwarmDialog.jsx create mode 100644 core/http/react-ui/src/components/talk/SessionSheet.jsx create mode 100644 core/http/react-ui/src/components/tools/Checks.jsx create mode 100644 core/http/react-ui/src/components/tools/JobLog.jsx create mode 100644 core/http/react-ui/src/components/tools/LossChart.jsx create mode 100644 core/http/react-ui/src/components/tools/StageLine.jsx create mode 100644 core/http/react-ui/src/components/tools/ToolOff.jsx create mode 100644 core/http/react-ui/src/components/tools/ToolSteps.jsx create mode 100644 core/http/react-ui/src/components/tools/tools.css create mode 100644 core/http/react-ui/src/components/traffic/BackendTraceDetail.jsx create mode 100644 core/http/react-ui/src/components/traffic/PercentChart.jsx create mode 100644 core/http/react-ui/src/components/traffic/TrafficChart.jsx create mode 100644 core/http/react-ui/src/components/traffic/WindowSwitch.jsx create mode 100644 core/http/react-ui/src/components/users/AccessSheet.jsx create mode 100644 core/http/react-ui/src/components/users/ApiKeysPanel.jsx create mode 100644 core/http/react-ui/src/components/users/InvitesPanel.jsx create mode 100644 core/http/react-ui/src/components/users/UsersPanel.jsx delete mode 100644 core/http/react-ui/src/hooks/useAgentChat.js create mode 100644 core/http/react-ui/src/hooks/useBuildDuplicates.js create mode 100644 core/http/react-ui/src/hooks/useCleanupFacts.js create mode 100644 core/http/react-ui/src/hooks/useDelayedAction.js create mode 100644 core/http/react-ui/src/hooks/useFacetCounts.js create mode 100644 core/http/react-ui/src/hooks/useLibraryFacts.js create mode 100644 core/http/react-ui/src/hooks/useLoadedModels.js create mode 100644 core/http/react-ui/src/hooks/useModelActions.js create mode 100644 core/http/react-ui/src/hooks/useModelFit.js create mode 100644 core/http/react-ui/src/hooks/useModelPage.js create mode 100644 core/http/react-ui/src/hooks/useModelRemoval.js create mode 100644 core/http/react-ui/src/hooks/useModelSizes.js create mode 100644 core/http/react-ui/src/hooks/useModelStorage.js create mode 100644 core/http/react-ui/src/hooks/useModelSuggestion.js create mode 100644 core/http/react-ui/src/hooks/useOperationActions.js create mode 100644 core/http/react-ui/src/hooks/usePlacementConfig.js create mode 100644 core/http/react-ui/src/hooks/usePlacementEstimate.js create mode 100644 core/http/react-ui/src/hooks/useStudioHandoff.js create mode 100644 core/http/react-ui/src/hooks/useStudioWork.js create mode 100644 core/http/react-ui/src/hooks/useSwarm.js create mode 100644 core/http/react-ui/src/hooks/useTraffic.js create mode 100644 core/http/react-ui/src/hooks/useUndoableCancel.js create mode 100644 core/http/react-ui/src/hooks/useWideLayout.js create mode 100644 core/http/react-ui/src/hooks/useWorkspace.js create mode 100644 core/http/react-ui/src/pages/AddNode.jsx delete mode 100644 core/http/react-ui/src/pages/AgentChat.jsx create mode 100644 core/http/react-ui/src/pages/AgentPage.jsx create mode 100644 core/http/react-ui/src/pages/AgentRun.jsx create mode 100644 core/http/react-ui/src/pages/BuildOverview.jsx delete mode 100644 core/http/react-ui/src/pages/CollectionDetails.jsx delete mode 100644 core/http/react-ui/src/pages/InstalledBackends.jsx create mode 100644 core/http/react-ui/src/pages/ModelPage.jsx create mode 100644 core/http/react-ui/src/pages/Prometheus.jsx create mode 100644 core/http/react-ui/src/pages/TracePage.jsx create mode 100644 core/http/react-ui/src/pages/TrafficHost.jsx create mode 100644 core/http/react-ui/src/pages/TrafficModels.jsx create mode 100644 core/http/react-ui/src/pages/TrafficOverview.jsx delete mode 100644 core/http/react-ui/src/pages/Usage/SourceMixRibbon.jsx delete mode 100644 core/http/react-ui/src/pages/Usage/SourceTimeChart.jsx delete mode 100644 core/http/react-ui/src/pages/Usage/SourcesTab.jsx delete mode 100644 core/http/react-ui/src/pages/Usage/SourcesTable.jsx create mode 100644 core/http/react-ui/src/pages/access.css create mode 100644 core/http/react-ui/src/pages/agent-jobs.css create mode 100644 core/http/react-ui/src/pages/agents.css create mode 100644 core/http/react-ui/src/pages/chat.css create mode 100644 core/http/react-ui/src/pages/explorer.css create mode 100644 core/http/react-ui/src/pages/identity.css create mode 100644 core/http/react-ui/src/pages/import.css create mode 100644 core/http/react-ui/src/pages/library.css create mode 100644 core/http/react-ui/src/pages/login.css create mode 100644 core/http/react-ui/src/pages/models-ledger.css create mode 100644 core/http/react-ui/src/pages/notfound.css create mode 100644 core/http/react-ui/src/pages/operate.css create mode 100644 core/http/react-ui/src/pages/settings.css create mode 100644 core/http/react-ui/src/pages/swarm.css create mode 100644 core/http/react-ui/src/pages/talk.css create mode 100644 core/http/react-ui/src/pages/traffic.css create mode 100644 core/http/react-ui/src/theme-localai.css create mode 100644 core/http/react-ui/src/utils/access.js create mode 100644 core/http/react-ui/src/utils/access.test.js delete mode 100644 core/http/react-ui/src/utils/agentChatHistory.js delete mode 100644 core/http/react-ui/src/utils/agentChatHistory.test.js create mode 100644 core/http/react-ui/src/utils/agentConfigTools.js create mode 100644 core/http/react-ui/src/utils/agentInfo.js create mode 100644 core/http/react-ui/src/utils/agentInfo.test.js create mode 100644 core/http/react-ui/src/utils/agentJobText.js create mode 100644 core/http/react-ui/src/utils/agentJobs.js create mode 100644 core/http/react-ui/src/utils/agentJobs.test.js create mode 100644 core/http/react-ui/src/utils/agentRuns.js create mode 100644 core/http/react-ui/src/utils/agentRuns.test.js create mode 100644 core/http/react-ui/src/utils/backendRows.js create mode 100644 core/http/react-ui/src/utils/backendRows.test.js create mode 100644 core/http/react-ui/src/utils/cleanupPlan.js create mode 100644 core/http/react-ui/src/utils/destinations.js create mode 100644 core/http/react-ui/src/utils/diarization.js create mode 100644 core/http/react-ui/src/utils/diarization.test.js create mode 100644 core/http/react-ui/src/utils/faIcon.js create mode 100644 core/http/react-ui/src/utils/homeConversations.js create mode 100644 core/http/react-ui/src/utils/homeConversations.test.js create mode 100644 core/http/react-ui/src/utils/iconHtml.js create mode 100644 core/http/react-ui/src/utils/identity.js create mode 100644 core/http/react-ui/src/utils/identity.test.js create mode 100644 core/http/react-ui/src/utils/lastModel.js create mode 100644 core/http/react-ui/src/utils/library.js create mode 100644 core/http/react-ui/src/utils/library.test.js create mode 100644 core/http/react-ui/src/utils/libraryText.js create mode 100644 core/http/react-ui/src/utils/mediaHistoryStore.js create mode 100644 core/http/react-ui/src/utils/modelLedger.js create mode 100644 core/http/react-ui/src/utils/modelLedger.test.js create mode 100644 core/http/react-ui/src/utils/modelStorage.js create mode 100644 core/http/react-ui/src/utils/modelStorage.test.js create mode 100644 core/http/react-ui/src/utils/modelWalk.js create mode 100644 core/http/react-ui/src/utils/modelWalk.test.js create mode 100644 core/http/react-ui/src/utils/operateStatus.js create mode 100644 core/http/react-ui/src/utils/operateStatus.test.js create mode 100644 core/http/react-ui/src/utils/placement.js create mode 100644 core/http/react-ui/src/utils/placement.test.js create mode 100644 core/http/react-ui/src/utils/referenceAudio.js delete mode 100644 core/http/react-ui/src/utils/section.js create mode 100644 core/http/react-ui/src/utils/settings.test.js create mode 100644 core/http/react-ui/src/utils/settingsPending.js create mode 100644 core/http/react-ui/src/utils/settingsSchema.js create mode 100644 core/http/react-ui/src/utils/studioWork.js create mode 100644 core/http/react-ui/src/utils/studioWork.test.js create mode 100644 core/http/react-ui/src/utils/swarm.js create mode 100644 core/http/react-ui/src/utils/swarm.test.js create mode 100644 core/http/react-ui/src/utils/tools.js create mode 100644 core/http/react-ui/src/utils/tools.test.js create mode 100644 core/http/react-ui/src/utils/traffic.js create mode 100644 core/http/react-ui/src/utils/traffic.test.js create mode 100644 core/http/react-ui/src/vendor/ui-kit/.ui-kit.lock create mode 100644 core/http/react-ui/src/vendor/ui-kit/README.md create mode 100644 core/http/react-ui/src/vendor/ui-kit/components.css create mode 100644 core/http/react-ui/src/vendor/ui-kit/icons/fa-map.json create mode 100644 core/http/react-ui/src/vendor/ui-kit/icons/sprite.svg create mode 100644 core/http/react-ui/src/vendor/ui-kit/motion.css create mode 100644 core/http/react-ui/src/vendor/ui-kit/theme-contract.md create mode 100644 core/http/react-ui/src/vendor/ui-kit/themes/sample-ink.css create mode 100644 core/http/react-ui/src/vendor/ui-kit/themes/sample-paper.css create mode 100644 core/http/react-ui/src/vendor/ui-kit/tokens.css create mode 100644 docs/content/features/chat.md create mode 100644 docs/content/features/studio.md create mode 100644 docs/content/operations/traffic.md diff --git a/core/http/react-ui/bun.lock b/core/http/react-ui/bun.lock index 4d9ce9572..00fe52074 100644 --- a/core/http/react-ui/bun.lock +++ b/core/http/react-ui/bun.lock @@ -15,7 +15,6 @@ "@codemirror/view": "^6.36.8", "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist-mono": "^5.2.7", - "@fortawesome/fontawesome-free": "^6.7.2", "@lezer/highlight": "^1.2.1", "@modelcontextprotocol/ext-apps": "^1.2.2", "@modelcontextprotocol/sdk": "^1.30.0", @@ -188,7 +187,6 @@ "@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.2.7", "", {}, "sha512-ZKlZ5sjtalb2TwXKs400mAGDlt/+2ENLNySPx0wTz3bP3mWARCsUW+rpxzZc7e05d2qGch70pItt3K4qttbIYA=="], - "@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@6.7.2", "", {}, "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA=="], "@gulpjs/to-absolute-glob": ["@gulpjs/to-absolute-glob@4.0.0", "", { "dependencies": { "is-negated-glob": "^1.0.0" } }, "sha512-kjotm7XJrJ6v+7knhPaRgaT6q8F8K2jiafwYdNHLzmV0uGLuZY43FK6smNSHUPrhq5kX2slCUy+RGG/xGqmIKA=="], diff --git a/core/http/react-ui/e2e/access-fixtures.js b/core/http/react-ui/e2e/access-fixtures.js new file mode 100644 index 000000000..c12bab615 --- /dev/null +++ b/core/http/react-ui/e2e/access-fixtures.js @@ -0,0 +1,156 @@ +// Fixtures for Users and keys, Account, sign-in and the invite page: auth +// status in each variant the server can answer, five users, invites, keys, +// quota rules and a month of usage, and one function that answers the calls +// those pages make and records the writes. +// +// Pure of Playwright imports so a script that is not a test can use it too. + +const iso = (offsetMs) => new Date(Date.now() + offsetMs).toISOString() +const DAY = 86_400_000 +const HOUR = 3_600_000 + +export const ADMIN = { id: 'u-alice', email: 'alice@lab.example', name: 'alice', role: 'admin', provider: 'local' } +export const MEMBER = { id: 'u-bob', email: 'bob@lab.example', name: 'bob', role: 'user', provider: 'github' } + +export function statusFixture(variant = 'admin', extra = {}) { + const base = { authEnabled: true, staticApiKeyRequired: false, providers: ['local', 'github'], hasUsers: true, registrationMode: 'approval', user: null } + const variants = { + admin: { user: { ...ADMIN, permissions: {} } }, + member: { user: { ...MEMBER, provider: 'local', permissions: { chat: true } } }, + oauthMember: { user: { ...MEMBER, permissions: { chat: true } } }, + signedOut: {}, + localOnly: { providers: ['local'] }, + oauthOnly: { providers: ['github', 'oidc'] }, + firstAdmin: { providers: ['local'], hasUsers: false, registrationMode: 'approval' }, + keyOnly: { authEnabled: false, staticApiKeyRequired: true, providers: [], hasUsers: false, registrationMode: '' }, + inviteOnly: { providers: ['local'], registrationMode: 'invite' }, + } + return { ...base, ...variants[variant], ...extra } +} + +export const FEATURES = { + api_features: [ + { key: 'chat', label: 'Chat completions', default: true }, + { key: 'images', label: 'Image generation', default: true }, + { key: 'audio_speech', label: 'Audio speech', default: true }, + { key: 'embeddings', label: 'Embeddings', default: true }, + ], + agent_features: [ + { key: 'agents', label: 'Agents', default: false }, + { key: 'skills', label: 'Skills', default: false }, + ], + general_features: [{ key: 'fine_tuning', label: 'Fine-tuning', default: false }], + models: ['qwen3-8b-instruct', 'bge-m3', 'kokoro-82m', 'gemma-3-12b-it'], +} + +const allOn = { chat: true, images: true, audio_speech: true, embeddings: true, agents: false, skills: false, fine_tuning: false } + +export function usersFixture() { + return [ + { id: 'u-alice', email: 'alice@lab.example', name: 'alice', role: 'admin', status: 'active', provider: 'local', createdAt: iso(-90 * DAY), permissions: allOn }, + { id: 'u-bob', email: 'bob@lab.example', name: 'bob', role: 'user', status: 'active', provider: 'github', createdAt: iso(-60 * DAY), + permissions: { ...allOn, agents: true, skills: true }, allowed_models: { enabled: true, models: ['qwen3-8b-instruct', 'bge-m3'] }, quotas: [] }, + { id: 'u-carol', email: 'carol@lab.example', name: 'carol', role: 'user', status: 'active', provider: 'oidc', createdAt: iso(-30 * DAY), + permissions: { ...allOn, agents: true }, allowed_models: { enabled: false, models: [] }, + quotas: [{ id: 'q1', model: '', max_requests: null, max_total_tokens: 2_000_000, window: '1d', current_requests: 0, current_total_tokens: 1_180_000 }] }, + { id: 'u-dave', email: 'dave@lab.example', name: 'dave', role: 'user', status: 'pending', provider: 'local', createdAt: iso(-1 * DAY), permissions: allOn, allowed_models: { enabled: false, models: [] } }, + { id: 'u-erin', email: 'erin@lab.example', name: '', role: 'user', status: 'disabled', provider: 'local', createdAt: iso(-120 * DAY), permissions: allOn, allowed_models: { enabled: false, models: [] } }, + ] +} + +export function invitesFixture() { + return [ + { id: 'i1', codePrefix: '3f9a1c2d', createdAt: iso(-1 * DAY), expiresAt: iso(6 * DAY), usedAt: null, usedBy: null, createdBy: { id: 'u-alice', name: 'alice' } }, + { id: 'i2', codePrefix: '77be0a14', createdAt: iso(-5 * DAY), expiresAt: iso(2 * DAY), usedAt: iso(-3 * DAY), usedBy: { id: 'u-dave', name: 'dave' }, createdBy: { id: 'u-alice', name: 'alice' } }, + { id: 'i3', codePrefix: '9c11d5e0', createdAt: iso(-9 * DAY), expiresAt: iso(-2 * DAY), usedAt: null, usedBy: null, createdBy: { id: 'u-alice', name: 'alice' } }, + ] +} + +export function keysFixture() { + return [ + { id: 'k1', name: 'ci-bot', keyPrefix: 'lai-3f9a1c', role: 'admin', createdAt: iso(-35 * DAY), lastUsed: iso(-2 * HOUR), disabled: false, expiresAt: iso(55 * DAY) }, + { id: 'k2', name: 'home-assistant', keyPrefix: 'lai-71bc09', role: 'admin', createdAt: iso(-270 * DAY), lastUsed: iso(-3 * DAY), disabled: true }, + { id: 'k3', name: 'notebook', keyPrefix: 'lai-a02d44', role: 'admin', createdAt: iso(-200 * DAY), lastUsed: null, disabled: false }, + ] +} + +export const QUOTAS = [ + { id: 'q1', model: '', max_requests: null, max_total_tokens: 2_000_000, window: '1d', current_requests: 0, current_total_tokens: 1_180_000, resets_at: iso(5 * HOUR) }, + { id: 'q2', model: '', max_requests: 300, max_total_tokens: null, window: '1h', current_requests: 212, current_total_tokens: 0, resets_at: iso(HOUR / 2) }, + { id: 'q3', model: 'qwen3-8b-instruct', max_requests: null, max_total_tokens: 1_100_000, window: '1d', current_requests: 0, current_total_tokens: 1_030_000, resets_at: iso(5 * HOUR) }, +] + +export function myUsageFixture() { + const usage = [] + const models = [['qwen3-8b-instruct', 181_000, 120], ['bge-m3', 44_000, 90], ['kokoro-82m', 31_000, 30], ['gemma-3-12b-it', 35_000, 35]] + for (const [model, total, requests] of models) { + usage.push({ bucket: '2026-10-07', model, prompt_tokens: Math.round(total * 0.74), completion_tokens: Math.round(total * 0.26), total_tokens: total, request_count: requests }) + } + const totals = usage.reduce((t, b) => ({ + prompt_tokens: t.prompt_tokens + b.prompt_tokens, completion_tokens: t.completion_tokens + b.completion_tokens, + total_tokens: t.total_tokens + b.total_tokens, request_count: t.request_count + b.request_count, + }), { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, request_count: 0 }) + return { usage, totals } +} + +// Answers the auth calls the pages make. `writes` collects every call that +// changes something, as { method, path, body }. Pass `status` to pick the +// auth status variant. +export async function mockAccess(page, { status = 'admin', statusExtra = {}, users = usersFixture(), invites = invitesFixture(), keys = keysFixture(), quotas = QUOTAS } = {}) { + const state = { writes: [], users, invites, keys, quotas, failNext: null } + const body = (route) => { try { return route.request().postDataJSON() } catch { return null } } + const record = (route, path) => state.writes.push({ method: route.request().method(), path, body: body(route) }) + const json = (route, data, status = 200) => route.fulfill({ status, json: data }) + + await page.route('**/api/auth/status', route => json(route, statusFixture(status, statusExtra))) + await page.route('**/api/auth/me', route => json(route, { user: statusFixture(status).user })) + await page.route('**/api/auth/quota', route => json(route, { quotas: state.quotas })) + await page.route('**/api/auth/usage?*', route => json(route, myUsageFixture())) + await page.route('**/api/auth/admin/features', route => json(route, FEATURES)) + await page.route('**/api/auth/admin/users', route => json(route, { users: state.users })) + await page.route('**/api/auth/admin/users/*/**', async route => { + const req = route.request() + const path = new URL(req.url()).pathname + if (req.method() === 'GET' && path.endsWith('/quotas')) return json(route, state.quotas.slice(0, 1)) + record(route, path) + if (state.failNext) { const error = state.failNext; state.failNext = null; return json(route, { error }, 400) } + return json(route, { message: 'ok' }) + }) + await page.route('**/api/auth/admin/users/*', async route => { + const req = route.request() + if (req.method() === 'GET') return route.fallback() + record(route, new URL(req.url()).pathname) + return json(route, { message: 'ok' }) + }) + await page.route('**/api/auth/admin/invites', async route => { + if (route.request().method() === 'POST') { + record(route, '/api/auth/admin/invites') + return json(route, { id: 'i-new', code: 'c0ffee00c0ffee00c0ffee00c0ffee00', expiresAt: iso(7 * DAY), createdAt: iso(0) }, 201) + } + return json(route, { invites: state.invites }) + }) + await page.route('**/api/auth/admin/invites/*', async route => { + record(route, new URL(route.request().url()).pathname) + return json(route, { message: 'ok' }) + }) + await page.route('**/api/auth/api-keys', async route => { + if (route.request().method() === 'POST') { + record(route, '/api/auth/api-keys') + return json(route, { key: 'lai-9d41f0c27be84a0c92aa5d6b', id: 'k-new', name: body(route)?.name || 'key', keyPrefix: 'lai-9d41f0', role: 'user', createdAt: iso(0) }, 201) + } + return json(route, { keys: state.keys }) + }) + await page.route('**/api/auth/api-keys/*', async route => { + const req = route.request() + record(route, new URL(req.url()).pathname) + if (req.method() === 'DELETE') state.keys = state.keys.filter(k => !req.url().endsWith(`/${k.id}`)) + return json(route, { message: 'ok' }) + }) + await page.route('**/api/auth/profile', async route => { record(route, '/api/auth/profile'); return json(route, { message: 'ok' }) }) + await page.route('**/api/auth/password', async route => { + record(route, '/api/auth/password') + if (state.failNext) { const e = state.failNext; state.failNext = null; return json(route, e, 400) } + return json(route, { message: 'ok' }) + }) + return state +} diff --git a/core/http/react-ui/e2e/account-api-key-pause.spec.js b/core/http/react-ui/e2e/account-api-key-pause.spec.js index 149a9cb85..8dde161aa 100644 --- a/core/http/react-ui/e2e/account-api-key-pause.spec.js +++ b/core/http/react-ui/e2e/account-api-key-pause.spec.js @@ -44,7 +44,7 @@ test.describe('Account API keys pause', () => { }) await page.goto('/app/account') - await page.getByRole('button', { name: /API Keys/ }).click() + await page.getByRole('tab', { name: 'API keys' }).click() }) test('shows paused badges and resume buttons', async ({ page }) => { diff --git a/core/http/react-ui/e2e/account-page.spec.js b/core/http/react-ui/e2e/account-page.spec.js new file mode 100644 index 000000000..de86b62b8 --- /dev/null +++ b/core/http/react-ui/e2e/account-page.spec.js @@ -0,0 +1,133 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockAccess } from './access-fixtures.js' + +test.describe('Account', () => { + let state + test.beforeEach(async ({ page }) => { + state = await mockAccess(page) + await page.goto('/app/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 }) => { + const tabs = await page.getByRole('tab').allTextContents() + expect(tabs).toEqual(['Profile', 'Security', 'API keys', 'Usage']) + await expect(page.getByRole('tab', { name: 'Profile' })).toHaveAttribute('aria-selected', 'true') + await page.getByRole('tab', { name: 'Usage' }).click() + await expect(page).toHaveURL(/tab=usage/) + await page.goBack().catch(() => {}) + await page.goto('/app/account?tab=keys') + await expect(page.getByTestId('api-keys-panel')).toBeVisible() + }) + + test('profile saves the name and the picture address', async ({ page }) => { + const save = page.getByRole('button', { name: 'Save' }) + await expect(save).toBeDisabled() + await page.getByLabel('Display name').fill('Alice L') + await page.getByLabel('Avatar URL').fill('https://example.org/a.png') + await expect(save).toBeEnabled() + await save.click() + await expect(page.getByText('Profile updated')).toBeVisible() + expect(state.writes[0]).toMatchObject({ method: 'PUT', path: '/api/auth/profile', body: { name: 'Alice L', avatar_url: 'https://example.org/a.png' } }) + await expect(page.getByTestId('account-profile')).toContainText('alice@lab.example') + await expect(page.getByTestId('account-profile')).toContainText('admin · local sign-in') + }) + + test('security changes a local password and checks the confirmation', async ({ page }) => { + await page.getByRole('tab', { name: 'Security' }).click() + const form = page.getByTestId('account-security') + await form.getByLabel('Current password').fill('old-password-123') + await form.getByLabel('New password').fill('new-password-1234') + await form.getByLabel('Confirm password').fill('different-1234') + await form.getByRole('button', { name: 'Change password' }).click() + await expect(page.getByText('Passwords do not match')).toBeVisible() + expect(state.writes).toEqual([]) + await form.getByLabel('Confirm password').fill('new-password-1234') + await form.getByRole('button', { name: 'Change password' }).click() + await expect(page.getByText('Password changed')).toBeVisible() + expect(state.writes[0].body).toEqual({ current_password: 'old-password-123', new_password: 'new-password-1234', acknowledge_weak_password: false }) + await expect(form.getByLabel('Current password')).toHaveValue('') + }) + + test('a weak password can be sent anyway after the person says so', async ({ page }) => { + state.failNext = { error: 'Password is weak', overridable: true } + await page.getByRole('tab', { name: 'Security' }).click() + const form = page.getByTestId('account-security') + await form.getByLabel('Current password').fill('old-password-123') + await form.getByLabel('New password').fill('password1234') + await form.getByLabel('Confirm password').fill('password1234') + await form.getByRole('button', { name: 'Change password' }).click() + await expect(form.getByRole('alert')).toContainText('Password is weak') + await form.getByLabel('Use this password anyway').check() + await form.getByRole('button', { name: 'Change password' }).click() + await expect(page.getByText('Password changed')).toBeVisible() + expect(state.writes[1].body.acknowledge_weak_password).toBe(true) + }) + + test('usage shows the last 30 days, tokens by model and the limits an admin set', async ({ page }) => { + await page.getByRole('tab', { name: 'Usage' }).click() + const usage = page.getByTestId('account-usage') + await expect(usage.getByTestId('usage-requests')).toHaveText('275') + await expect(usage).toContainText('Tokens in') + await expect(usage.getByRole('list', { name: 'Tokens by model' }).locator('li')).toHaveCount(4) + await expect(usage.getByRole('list', { name: 'Tokens by model' }).locator('li').first()).toContainText('qwen3-8b-instruct') + const limits = page.getByTestId('account-limits').locator('li') + await expect(limits).toHaveCount(3) + await expect(limits.first()).toContainText('All models, tokens per day') + await expect(limits.first()).toContainText('1.2M of 2M') + await expect(limits.nth(1)).toContainText('requests per hour') + await expect(limits.nth(2).getByRole('progressbar')).toHaveAttribute('aria-valuenow', '94') + await expect(usage.getByRole('link', { name: 'Open full usage' })).toHaveAttribute('href', '/app/usage') + }) +}) + +test.describe('Account: other people and states', () => { + test('a person who signs in with GitHub is told the password is not theirs to change', async ({ page }) => { + await mockAccess(page, { status: 'oauthMember' }) + await page.goto('/app/account?tab=security') + await expect(page.getByTestId('account-oauth-only')).toContainText('not available for github accounts') + await expect(page.getByLabel('Current password')).toHaveCount(0) + }) + + test('a person who is not an admin sees their own account, usage and limits', async ({ page }) => { + await mockAccess(page, { status: 'member' }) + 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) + }) + + test('without authentication there is no account', 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() + }) + + 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') + }) + + test('a person with no limits and no requests sees calm empty states', async ({ page }) => { + await mockAccess(page, { quotas: [] }) + await page.route('**/api/auth/usage?*', route => route.fulfill({ json: { usage: [], totals: {} } })) + await page.goto('/app/account?tab=usage') + await expect(page.getByText('No requests from you in the last 30 days.')).toBeVisible() + await expect(page.getByText('No limits are set on your account.')).toBeVisible() + }) +}) + +test.describe('Account: phone', () => { + test.use({ viewport: { width: 390, height: 844 } }) + test('every tab fits the screen', async ({ page }) => { + await mockAccess(page) + for (const tab of ['profile', 'security', 'keys', 'usage']) { + await page.goto(`/app/account?tab=${tab}`) + await expect(page.getByRole('tabpanel')).toBeVisible() + await page.waitForTimeout(150) + expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth), tab).toBe(false) + } + }) +}) diff --git a/core/http/react-ui/e2e/activity-page.spec.js b/core/http/react-ui/e2e/activity-page.spec.js index 09f9b46fe..0f1ddc435 100644 --- a/core/http/react-ui/e2e/activity-page.spec.js +++ b/core/http/react-ui/e2e/activity-page.spec.js @@ -40,7 +40,139 @@ test('lists live operations and cancels one from a labelled button', async ({ pa await expect(card).toContainText('22%') await card.locator('.operation-card__cancel').click() - expect(cancelledPath).toBe('/api/operations/job-gemma/cancel') + // A cancel waits for its undo window. Nothing has been stopped yet. + await expect(page.getByTestId('activity-undo-toast')).toContainText('Cancelling gemma-3-27b-it') + await expect(card).toContainText('Cancelling unless you undo') + expect(cancelledPath).toBe('') + await expect.poll(() => cancelledPath, { timeout: 15_000 }).toBe('/api/operations/job-gemma/cancel') +}) + +const download = (id, over = {}) => ({ + id, + name: id, + jobID: `job-${id}`, + progress: 22, + taskType: 'installation', + isBackend: false, + isQueued: false, + isDeletion: false, + cancellable: true, + phase: 'downloading', + ...over, +}) + +test('undoing a cancel keeps the download running and never calls the server', async ({ page }) => { + await stub(page, { operations: [download('gemma-3-27b-it')] }) + const calls = [] + await page.route('**/api/operations/job-gemma-3-27b-it/*', (route) => { + calls.push(new URL(route.request().url()).pathname) + return route.fulfill({ contentType: 'application/json', body: '{}' }) + }) + await page.goto('/app/activity') + + const card = page.locator('.operation-card').filter({ hasText: 'gemma-3-27b-it' }) + await card.locator('.operation-card__cancel').click() + await page.getByTestId('activity-undo-toast').getByRole('button', { name: 'Undo' }).click() + + await expect(page.getByTestId('activity-undo-toast')).toHaveCount(0) + await expect(card.locator('.operation-card__cancel')).toBeVisible() + await expect(card).not.toContainText('Cancelling unless you undo') + // The window is 8 seconds; wait it out to be sure nothing was queued. + await page.waitForTimeout(9_000) + expect(calls).toEqual([]) +}) + +test('closing the undo toast cancels at once', async ({ page }) => { + await stub(page, { operations: [download('gemma-3-27b-it')] }) + const calls = [] + await page.route('**/api/operations/job-gemma-3-27b-it/cancel', (route) => { + calls.push('cancel') + return route.fulfill({ contentType: 'application/json', body: '{}' }) + }) + await page.goto('/app/activity') + await page.locator('.operation-card__cancel').click() + await page.getByTestId('activity-undo-toast').getByRole('button', { name: 'Cancel now' }).click() + await expect.poll(() => calls).toEqual(['cancel']) +}) + +test('cancelling a second download ends the first one\'s window', async ({ page }) => { + await stub(page, { operations: [download('alpha'), download('beta')] }) + const calls = [] + await page.route('**/api/operations/*/cancel', (route) => { + calls.push(new URL(route.request().url()).pathname) + return route.fulfill({ contentType: 'application/json', body: '{}' }) + }) + await page.goto('/app/activity') + await page.locator('.operation-card').filter({ hasText: 'alpha' }).locator('.operation-card__cancel').click() + await page.locator('.operation-card').filter({ hasText: 'beta' }).locator('.operation-card__cancel').click() + // One toast at a time, and the first cancel went through. + await expect(page.getByTestId('activity-undo-toast')).toHaveCount(1) + await expect(page.getByTestId('activity-undo-toast')).toContainText('Cancelling beta') + await expect.poll(() => calls).toEqual(['/api/operations/job-alpha/cancel']) +}) + +test('leaving the page runs a cancel that was still waiting', async ({ page }) => { + await stub(page, { operations: [download('gemma-3-27b-it')] }) + const calls = [] + await page.route('**/api/operations/job-gemma-3-27b-it/cancel', (route) => { + calls.push('cancel') + return route.fulfill({ contentType: 'application/json', body: '{}' }) + }) + await page.goto('/app/activity') + await page.locator('.operation-card__cancel').click() + await expect(page.getByTestId('activity-undo-toast')).toBeVisible() + await page.getByRole('link', { name: 'Backends', exact: true }).first().click() + await expect.poll(() => calls).toEqual(['cancel']) +}) + +test('a download shows its progress, its size and the time left', async ({ page }) => { + await stub(page, { + operations: [download('gemma-3-27b-it', { progress: 38, currentBytes: 1.4 * 1024 ** 3, totalBytes: 3.8 * 1024 ** 3 })], + }) + await page.goto('/app/activity') + const card = page.locator('.operation-card').filter({ hasText: 'gemma-3-27b-it' }) + await expect(card.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '38') + await expect(card).toContainText('1.4 GB / 3.8 GB') + await expect(card.getByRole('button', { name: 'Pause gemma-3-27b-it and keep downloaded data' })).toBeVisible() +}) + +test('a cancelled install can be started again from the record', async ({ page }) => { + await stub(page, { + history: [{ + id: 'whisper-large-v3', name: 'whisper-large-v3', jobID: 'job-w', isBackend: false, taskType: 'installation', + outcome: 'cancelled', startedAt: '2026-07-28T13:40:00Z', finishedAt: '2026-07-28T13:41:00Z', + }, { + id: 'piper', name: 'piper', jobID: 'job-p', isBackend: true, taskType: 'deletion', + outcome: 'cancelled', startedAt: '2026-07-28T13:40:00Z', finishedAt: '2026-07-28T13:41:00Z', + }], + }) + const calls = [] + await page.route('**/api/models/install/**', (route) => { + calls.push(new URL(route.request().url()).pathname) + return route.fulfill({ contentType: 'application/json', body: '{}' }) + }) + await page.goto('/app/activity') + // A cancelled removal is not an install, so it offers nothing to start. + await expect(page.locator('.activity-row__resume')).toHaveCount(1) + await page.locator('.activity-row').filter({ hasText: 'whisper-large-v3' }).locator('.activity-row__resume').click() + await expect.poll(() => calls).toEqual(['/api/models/install/whisper-large-v3']) +}) + +test('the empty state names the next step', async ({ page }) => { + await stub(page) + await page.goto('/app/activity') + await expect(page.locator('.activity-empty')).toContainText('No operations since startup') + await expect(page.locator('.activity-empty').getByRole('link', { name: 'Browse models' })).toHaveAttribute('href', '/app/models') +}) + +test('fits a phone, with the actions under the name', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await stub(page, { operations: [download('gemma-3-27b-it')] }) + await page.goto('/app/activity') + const card = page.locator('.operation-card').filter({ hasText: 'gemma-3-27b-it' }) + await expect(card.locator('.operation-card__cancel')).toBeVisible() + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + expect(overflow).toBeLessThanOrEqual(1) }) test('pauses a model download without invoking destructive cancel', async ({ page }) => { diff --git a/core/http/react-ui/e2e/add-node.spec.js b/core/http/react-ui/e2e/add-node.spec.js new file mode 100644 index 000000000..ace0f3e5b --- /dev/null +++ b/core/http/react-ui/e2e/add-node.spec.js @@ -0,0 +1,163 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockSwarm, clusterNodes } from './swarm-fixtures.js' + +// "Add a node": which way the machine joins, the one command to run on it, and +// a live line that says when it arrived. "Found" is a real node that was not in +// the roster when the page opened, never a timer. + +const command = page => page.getByTestId('command-block').last() + +test.describe('Add a node', () => { + test('starts on a registered worker with a copyable command that carries no secret', async ({ page }) => { + await mockSwarm(page) + await page.goto('/app/nodes/add') + await expect(page.getByRole('heading', { name: 'Add a node' })).toBeVisible({ timeout: 15_000 }) + await expect(page.getByRole('radio', { name: 'Registered worker' })).toHaveAttribute('aria-checked', 'true') + await expect(command(page)).toContainText('LOCALAI_REGISTER_TO="http://127.0.0.1:8089"') + await expect(command(page)).toContainText('LOCALAI_REGISTRATION_TOKEN="$TOKEN"') + await expect(command(page)).toContainText('localai/localai:latest-cpu worker') + await expect(page.getByText('This page never shows it.')).toBeVisible() + await expect(page.getByRole('link', { name: 'All nodes' })).toHaveAttribute('href', '/app/nodes') + // A cluster needs no step for turning distributed mode on. + await expect(page.getByText('Turn on distributed mode first')).toHaveCount(0) + }) + + test('changes the command with the hardware, the way to run it and the worker type', async ({ page }) => { + await mockSwarm(page) + await page.goto('/app/nodes/add') + await page.getByRole('radio', { name: 'CUDA 12', exact: true }).click() + await expect(command(page)).toContainText('--gpus all') + await expect(command(page)).toContainText('latest-gpu-nvidia-cuda-12 worker') + await page.getByRole('radio', { name: 'Agent worker' }).click() + await expect(command(page)).toContainText('latest-gpu-nvidia-cuda-12 agent-worker') + await page.getByRole('button', { name: 'Dev' }).click() + await expect(command(page)).toContainText('master-gpu-nvidia-cuda-12 agent-worker') + await page.getByRole('radio', { name: 'CLI' }).click() + await expect(command(page)).toContainText('local-ai agent-worker') + await expect(command(page)).toContainText('--registration-token "$LOCALAI_REGISTRATION_TOKEN"') + // Hardware means nothing to a plain command. + await expect(page.getByRole('radio', { name: 'CUDA 12', exact: true })).toHaveCount(0) + }) + + test('copies the command and says so', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']).catch(() => {}) + await mockSwarm(page) + await page.goto('/app/nodes/add') + const copy = page.getByRole('button', { name: 'Copy command' }).last() + await copy.click() + await expect(copy).toContainText('Copied') + const text = await command(page).locator('code').innerText() + expect(text).toContain('LOCALAI_REGISTER_TO') + }) + + test('waits for a worker, then shows it when one registers, and approves it', async ({ page }) => { + let polls = 0 + const known = clusterNodes() + const arrived = { id: 'n-new', name: 'gpu-box-9', node_type: 'backend', address: '10.0.4.19:50051', status: 'pending', version: 'v3.9.0', labels: {}, last_heartbeat: new Date().toISOString() } + const log = await mockSwarm(page, { onNodes: () => { polls += 1; return polls < 3 ? known : [...known, arrived] } }) + await page.goto('/app/nodes/add') + + const wait = page.getByRole('status').filter({ hasText: 'Listening for a worker' }) + await expect(wait).toBeVisible({ timeout: 15_000 }) + await expect(page.getByTestId('found-node')).toHaveCount(0) + + const found = page.getByTestId('found-node') + await expect(found).toBeVisible({ timeout: 20_000 }) + await expect(found).toContainText('gpu-box-9') + await expect(found).toContainText('Waiting for approval') + await expect(page.getByRole('heading', { name: 'A machine arrived' })).toBeVisible() + await expect(wait).toHaveCount(0) + await expect(found.getByRole('link', { name: 'Open node' })).toHaveAttribute('href', '/app/nodes/n-new') + + await found.getByRole('button', { name: 'Approve' }).click() + await expect.poll(() => log.filter(entry => entry.path === '/api/nodes/n-new/approve').length).toBe(1) + await expect(page.getByText('gpu-box-9 approved')).toBeVisible() + }) + + test('does not treat the nodes it already knew as arrivals', async ({ page }) => { + await mockSwarm(page) + await page.goto('/app/nodes/add') + await expect(page.getByRole('status').filter({ hasText: 'Listening for a worker' })).toBeVisible({ timeout: 15_000 }) + await page.waitForTimeout(3500) + await expect(page.getByTestId('found-node')).toHaveCount(0) + }) + + test('a single install starts with turning distributed mode on, as text it can copy', async ({ page }) => { + await mockSwarm(page, { nodes: null, features: { distributed: false } }) + await page.goto('/app/nodes/add') + await expect(page.getByRole('heading', { name: 'Turn on distributed mode first' })).toBeVisible({ timeout: 15_000 }) + await expect(page.getByTestId('command-block').first()).toContainText('local-ai run --distributed') + await expect(page.getByTestId('command-block').first()).toContainText('--distributed-nats') + await expect(page.getByRole('link', { name: 'distributed mode documentation' })).toHaveAttribute('href', 'https://localai.io/features/distributed-mode/') + await expect(page.getByRole('heading', { name: 'Run this on the new machine' })).toBeVisible() + }) + + test.describe('over P2P', () => { + const p2p = { token: 'net-token-1', stats: { federated: { online: 1, total: 1 }, llama_cpp_workers: { online: 0, total: 0 }, mlx_workers: { online: 0, total: 0 } } } + + test('a peer instance joins with the network token', async ({ page }) => { + await mockSwarm(page, { p2p }) + await page.goto('/app/nodes/add?join=peer') + await expect(page.getByRole('radio', { name: 'Peer instance' })).toHaveAttribute('aria-checked', 'true', { timeout: 15_000 }) + await expect(command(page)).toContainText('-e TOKEN="net-token-1"') + await expect(command(page)).toContainText('run --federated --p2p') + await expect(page.getByText('1 peers online')).toBeVisible() + }) + + test('a memory shard offers llama.cpp RPC and MLX', async ({ page }) => { + await mockSwarm(page, { p2p }) + await page.goto('/app/nodes/add?join=shard') + await expect(command(page)).toContainText('worker p2p-llama-cpp-rpc', { timeout: 15_000 }) + await page.getByRole('radio', { name: 'MLX (Apple Silicon)' }).click() + await expect(command(page)).toContainText('worker p2p-mlx') + await expect(command(page)).toContainText('latest-metal-darwin-arm64') + // MLX runs on a Mac: GPU build targets do not apply. + await expect(page.getByRole('radio', { name: 'CUDA 12', exact: true })).toHaveCount(0) + }) + + test('says to turn P2P on first when it is off', async ({ page }) => { + await mockSwarm(page) + await page.goto('/app/nodes/add?join=peer') + await expect(page.getByRole('heading', { name: 'Turn on P2P first' })).toBeVisible({ timeout: 15_000 }) + await expect(page.getByTestId('command-block').first()).toContainText('run --p2p') + await expect(page.getByTestId('command-block').last()).toContainText('your-token-here') + }) + + test('shows a new peer when the online count goes up', async ({ page }) => { + let calls = 0 + await mockSwarm(page) + await page.route('**/api/p2p/token', route => route.fulfill({ status: 200, contentType: 'text/plain', body: 'net-token-1' })) + await page.route('**/api/p2p/stats', route => { + calls += 1 + const online = calls < 3 ? 1 : 2 + return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ federated: { online, total: 2 }, llama_cpp_workers: { online: 0, total: 0 }, mlx_workers: { online: 0, total: 0 } }) }) + }) + await page.goto('/app/nodes/add?join=peer') + await expect(page.getByTestId('found-peer')).toContainText('A new peer joined: 2 online', { timeout: 20_000 }) + }) + }) + + test('first run of a cluster carries the same steps in place', async ({ page }) => { + await mockSwarm(page, { nodes: [] }) + await page.goto('/app/nodes') + await expect(page.getByTestId('swarm-first-run')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByRole('heading', { name: 'How should it join?' })).toBeVisible() + await expect(command(page)).toContainText('LOCALAI_REGISTER_TO') + }) + + test('a phone scrolls the command sideways instead of the page', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await mockSwarm(page) + await page.goto('/app/nodes/add') + const block = command(page).locator('pre') + await expect(block).toBeVisible({ timeout: 15_000 }) + const { scrollWidth, clientWidth } = await block.evaluate(el => ({ scrollWidth: el.scrollWidth, clientWidth: el.clientWidth })) + expect(scrollWidth).toBeGreaterThan(clientWidth) + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390) + const copy = await page.getByRole('button', { name: 'Copy command' }).last().boundingBox() + expect(copy.x + copy.width).toBeLessThanOrEqual(390) + // The method switch scrolls too; none of its choices is cut off for good. + await page.getByRole('radio', { name: 'Memory shard' }).scrollIntoViewIfNeeded() + await expect(page.getByRole('radio', { name: 'Memory shard' })).toBeInViewport() + }) +}) diff --git a/core/http/react-ui/e2e/admin-console.spec.js b/core/http/react-ui/e2e/admin-console.spec.js index 6bd459feb..7a50d60a7 100644 --- a/core/http/react-ui/e2e/admin-console.spec.js +++ b/core/http/react-ui/e2e/admin-console.spec.js @@ -1,30 +1,33 @@ import { test, expect } from './coverage-fixtures.js' test.describe('Admin console', () => { - test('admin pages render inside the grouped console rail', async ({ page }) => { + test('admin pages render under the Operate tab bar', async ({ page }) => { await page.goto('/app/backends') - const rail = page.locator('.console-rail') - await expect(rail).toBeVisible() - // Four groups since the overview landed: Inference folded into Runtime - // (both are "the runtime right now"), Access and System into Administration. - for (const group of ['Runtime', 'Cluster', 'Observability', 'Administration']) { - await expect(rail.locator('.console-group-title', { hasText: group })).toBeVisible() + const bar = page.locator('.dk-hubtabs') + await expect(bar).toBeVisible() + // Six tabs for the single-node case; Swarm only appears with distributed + // mode on. The tab that owns the current page carries aria-current. + for (const name of ['Status', 'This machine', 'Runtime', 'Traffic', 'Settings']) { + await expect(bar.getByRole('link', { name, exact: false })).toBeVisible() } + await expect(bar.locator('[data-hub-tab="runtime"]')).toHaveAttribute('aria-current', 'page') }) - test('console rail cross-navigates between admin pages', async ({ page }) => { + test('the tab bar cross-navigates between admin pages', async ({ page }) => { await page.goto('/app/backends') - const settings = page.locator('.console-rail a.nav-item[href="/app/settings"]') + const settings = page.locator('.dk-hubtabs a[href="/app/settings"]') await expect(settings).toBeVisible() await settings.click() await expect(page).toHaveURL(/\/app\/settings/) - // Rail persists across admin navigation (layout route, not per-page chrome) - await expect(page.locator('.console-rail')).toBeVisible() + // The bar persists across admin navigation (layout route, not per-page chrome) + await expect(page.locator('.dk-hubtabs')).toBeVisible() + await expect(page.locator('.dk-hubtabs [data-hub-tab="settings"]')).toHaveAttribute('aria-current', 'page') }) - test('rail links to external API docs', async ({ page }) => { + test('the bar links to the external API docs', async ({ page }) => { await page.goto('/app/settings') - const api = page.locator('.console-rail a[href$="/swagger/index.html"]') + const api = page.locator('.dk-hubtabs a[href$="/swagger/index.html"]') await expect(api).toBeVisible() + await expect(api).toHaveAttribute('target', '_blank') }) }) diff --git a/core/http/react-ui/e2e/agent-jobs-run.spec.js b/core/http/react-ui/e2e/agent-jobs-run.spec.js new file mode 100644 index 000000000..86483f640 --- /dev/null +++ b/core/http/react-ui/e2e/agent-jobs-run.spec.js @@ -0,0 +1,101 @@ +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 new file mode 100644 index 000000000..6fb9793f1 --- /dev/null +++ b/core/http/react-ui/e2e/agent-jobs-task.spec.js @@ -0,0 +1,227 @@ +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.') + await sheet.getByRole('tab', { name: /Changes/ }).click() + 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 new file mode 100644 index 000000000..ad8804645 --- /dev/null +++ b/core/http/react-ui/e2e/agent-jobs.spec.js @@ -0,0 +1,291 @@ +import { test, expect } from './coverage-fixtures.js' +import { mockJobs, fixtureData } from './jobs-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 +} + +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.') + }) + + 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) + 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) + }) + + 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 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('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('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.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('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('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() + 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([]) + }) + + 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.describe('Jobs page on a phone', () => { + test.use({ viewport: { width: 390, height: 844 } }) + + 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) + }) +}) diff --git a/core/http/react-ui/e2e/agents-edit.spec.js b/core/http/react-ui/e2e/agents-edit.spec.js new file mode 100644 index 000000000..60192d454 --- /dev/null +++ b/core/http/react-ui/e2e/agents-edit.spec.js @@ -0,0 +1,126 @@ +import { test, expect } from './coverage-fixtures.js' +import { installFakeStream, mockAgents, RESEARCH_CONFIG } from './agents-fixtures.js' + +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) + }) + + test('a new agent starts with sections that need a name and a model', async ({ page }) => { + await installFakeStream(page) + await mockAgents(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') + }) + + test('a template fills the form and saves nothing', async ({ page }) => { + await installFakeStream(page) + const seen = await mockAgents(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([]) + }) + + 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('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') + + 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') + + await sheet.getByRole('tab', { name: /Changes/ }).click() + 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 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.') + }) + + 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('creating an agent sends the form and offers no Dry run it cannot run', async ({ page }) => { + await installFakeStream(page) + const seen = await mockAgents(page) + 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.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' }) + }) + + 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('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') + }) +}) diff --git a/core/http/react-ui/e2e/agents-fixtures.js b/core/http/react-ui/e2e/agents-fixtures.js new file mode 100644 index 000000000..427e38134 --- /dev/null +++ b/core/http/react-ui/e2e/agents-fixtures.js @@ -0,0 +1,139 @@ +// 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. + +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.' }, + }, + '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.' }, + }, +} + +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' } }, + ], + Connectors: [], Actions: [], Filters: [], DynamicPrompts: [], +} + +// 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 function emit(page, type, data) { + return page.evaluate(([t, d]) => window.__emit(t, d), [type, data]) +} + +// 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' }], +} = {}) { + 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 => { + const req = route.request() + if (req.method() === 'POST') { + seen.saves.push({ method: 'POST', body: req.postDataJSON() }) + return route.fulfill({ json: { status: 'ok' } }) + } + const statuses = Object.fromEntries(Object.entries(agents).map(([n, a]) => [n, a.active])) + return route.fulfill({ json: { agents: Object.keys(agents), statuses } }) + }) + await page.route(/\/api\/agents\/[^/?]+(\/[a-z]+)?(\?.*)?$/, async 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 } }) + }) + 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] } +} + +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 }]) +} diff --git a/core/http/react-ui/e2e/agents-runs.spec.js b/core/http/react-ui/e2e/agents-runs.spec.js new file mode 100644 index 000000000..9d992476a --- /dev/null +++ b/core/http/react-ui/e2e/agents-runs.spec.js @@ -0,0 +1,316 @@ +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.spec.js b/core/http/react-ui/e2e/agents.spec.js index d4d65d9e5..e58ea01f6 100644 --- a/core/http/react-ui/e2e/agents.spec.js +++ b/core/http/react-ui/e2e/agents.spec.js @@ -1,18 +1,23 @@ import { test, expect } from './coverage-fixtures.js' +import { installFakeStream, mockAgents, seedRuns, storedRun, emit, AGENTS } from './agents-fixtures.js' -// Agents feature page (src/pages/Agents.jsx). +// Agents launcher (src/pages/Agents.jsx) and the routes around it. test.describe('Agents page', () => { - test.beforeEach(async ({ page }) => { - await page.goto('/app/agents') - }) - test('renders the agents list and empty state', async ({ 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() }) + test('the empty state teaches with starting points', async ({ 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 } }, @@ -20,12 +25,12 @@ test.describe('Agents page', () => { await page.route('**/api/agents/existing-agent/observables', route => route.fulfill({ json: { History: [] }, })) - await page.reload() - + 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([ @@ -33,18 +38,109 @@ test.describe('Agents page', () => { create.click(), ]) // Wait for AgentCreate.jsx to actually render, not just for the URL to - // change. Ending the test the instant the route matched let the component - // mount race the coverage teardown — its ~400 lines were collected only - // when the render won, swinging total UI coverage ~1pp run-to-run. + // 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 chat', async ({ page }) => { - await page.goto('/app/agents/demo/chat?user_id=test-user') - + 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/demo/status?user_id=test-user') + await expect(status).toHaveAttribute('href', '/app/agents/research-assistant/status?user_id=test-user') await expect(status).toHaveAttribute('target', '_blank') - await expect(page).toHaveURL(/\/app\/agents\/demo\/chat\?user_id=test-user$/) + 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 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('no Now strip when nothing needs a look', async ({ page }) => { + await installFakeStream(page) + await mockAgents(page, { agents: { 'code-reviewer': AGENTS['code-reviewer'] } }) + await page.goto('/app/agents') + await expect(page.getByTestId('agents-list')).toBeVisible() + await expect(page.getByTestId('agents-now')).toHaveCount(0) }) }) diff --git a/core/http/react-ui/e2e/audio-transform.spec.js b/core/http/react-ui/e2e/audio-transform.spec.js index c428e95f8..306ae77af 100644 --- a/core/http/react-ui/e2e/audio-transform.spec.js +++ b/core/http/react-ui/e2e/audio-transform.spec.js @@ -143,6 +143,8 @@ test.describe('Audio Transform', () => { await expect(page.getByText('mic.wav')).toBeVisible() // Set a backend tuning param so the form posts params[noise_gate]=true. + // The key=value box lives in the Advanced fold. + await page.getByRole('button', { name: /Advanced parameters/ }).click() await page.locator('.textarea').fill('noise_gate=true') await page.getByRole('button', { name: /Transform/ }).last().click() diff --git a/core/http/react-ui/e2e/backend-logs.spec.js b/core/http/react-ui/e2e/backend-logs.spec.js index 0390aa6bc..716e936b4 100644 --- a/core/http/react-ui/e2e/backend-logs.spec.js +++ b/core/http/react-ui/e2e/backend-logs.spec.js @@ -1,64 +1,142 @@ import { test, expect } from './coverage-fixtures.js' +import { LOG_LINES, mockOperate } from './operate-fixtures.js' + +// Runtime, Logs: what a backend process printed, read live over the log +// stream. The viewer filters by stream and by text, follows the end, shows or +// hides times, exports, and clears (with an undo window). + +async function openLogs(page, { lines = LOG_LINES, path = '/app/backend-logs/qwen3-8b-instruct' } = {}) { + await mockOperate(page) + await page.routeWebSocket('**/ws/backend-logs/**', ws => { + ws.send(JSON.stringify({ type: 'initial', lines })) + }) + await page.goto(path) +} + +const lineRows = page => page.locator('[data-log-line]') test.describe('Backend Logs', () => { test('model detail page shows title', async ({ page }) => { - await page.goto('/app/backend-logs/mock-model') - await expect(page.locator('.page-title')).toContainText('mock-model') + await openLogs(page) + await expect(page.locator('.page-title')).toContainText('qwen3-8b-instruct') }) - test('no back arrow link on detail page', async ({ page }) => { - await page.goto('/app/backend-logs/mock-model') - await expect(page.locator('a[href="/app/backend-logs"]')).not.toBeVisible() + test('the way out is the Backends list, which is where a process comes from', async ({ page }) => { + await openLogs(page) + await expect(page.locator('.lg-back')).toHaveAttribute('href', '/app/backends?view=installed') }) - test('filter buttons are visible', async ({ page }) => { - await page.goto('/app/backend-logs/mock-model') - await expect(page.locator('button', { hasText: 'All' })).toBeVisible() - await expect(page.locator('button', { hasText: 'stdout' })).toBeVisible() - await expect(page.locator('button', { hasText: 'stderr' })).toBeVisible() + test('reads the lines the process printed, with the error line marked', async ({ page }) => { + await openLogs(page) + await expect(lineRows(page)).toHaveCount(5) + await expect(lineRows(page).nth(3)).toContainText('out of memory') + await expect(lineRows(page).nth(3)).toHaveAttribute('data-stream', 'stderr') + await expect(lineRows(page).nth(3)).toContainText('err') }) - test('filter buttons toggle active state', async ({ page }) => { - await page.goto('/app/backend-logs/mock-model') - - const allBtn = page.locator('button', { hasText: 'All' }) - const stdoutBtn = page.locator('button', { hasText: 'stdout' }) - - // All is active by default - await expect(allBtn).toHaveClass(/btn-primary/) - - // Click stdout - await stdoutBtn.click() - await expect(stdoutBtn).toHaveClass(/btn-primary/) - await expect(allBtn).not.toHaveClass(/btn-primary/) + test('stream buttons narrow the lines and say which is on', async ({ page }) => { + await openLogs(page) + const all = page.getByRole('button', { name: 'All', exact: true }) + const stderr = page.getByRole('button', { name: 'stderr', exact: true }) + await expect(all).toHaveAttribute('aria-pressed', 'true') + await stderr.click() + await expect(stderr).toHaveAttribute('aria-pressed', 'true') + await expect(all).toHaveAttribute('aria-pressed', 'false') + await expect(lineRows(page)).toHaveCount(1) + await page.getByRole('button', { name: 'stdout', exact: true }).click() + await expect(lineRows(page)).toHaveCount(4) }) - test('export button is present', async ({ page }) => { - await page.goto('/app/backend-logs/mock-model') - await expect(page.locator('button', { hasText: 'Export' })).toBeVisible() + test('the filter keeps the lines that contain the text', async ({ page }) => { + await openLogs(page) + await page.getByRole('textbox', { name: 'Filter lines' }).fill('backend') + await expect(lineRows(page)).toHaveCount(2) + await page.getByRole('textbox', { name: 'Filter lines' }).fill('nothing like this') + await expect(page.getByText('No line contains that text.')).toBeVisible() }) - test('auto-scroll checkbox is present', async ({ page }) => { - await page.goto('/app/backend-logs/mock-model') - await expect(page.locator('text=Auto-scroll')).toBeVisible() + test('Times hides the time and the stream label for easier copying', async ({ page }) => { + await openLogs(page) + const times = page.getByRole('switch', { name: 'Times' }) + await expect(times).toHaveAttribute('aria-checked', 'true') + await expect(page.locator('.lg-line__time').first()).toBeVisible() + await times.click() + await expect(times).toHaveAttribute('aria-checked', 'false') + await expect(page.locator('.lg-line__time')).toHaveCount(0) + await expect(page.locator('.lg-line__stream')).toHaveCount(0) }) - test('clear button is present', async ({ page }) => { - await page.goto('/app/backend-logs/mock-model') - await expect(page.locator('button', { hasText: 'Clear' })).toBeVisible() + test('Follow is on by default and can be turned off', async ({ page }) => { + await openLogs(page) + const follow = page.getByRole('switch', { name: 'Follow' }) + await expect(follow).toHaveAttribute('aria-checked', 'true') + await follow.click() + await expect(follow).toHaveAttribute('aria-checked', 'false') }) - test('details toggle button is present and toggles', async ({ page }) => { - await page.goto('/app/backend-logs/mock-model') + test('Export offers the visible lines as a file', async ({ page }) => { + await openLogs(page) + const download = page.waitForEvent('download') + await page.getByRole('button', { name: 'Export' }).click() + expect((await download).suggestedFilename()).toMatch(/^backend-logs-qwen3-8b-instruct-\d{4}-\d{2}-\d{2}\.json$/) + }) - // "Text only" button visible by default (details are shown) - const toggleBtn = page.locator('button', { hasText: 'Text only' }) - await expect(toggleBtn).toBeVisible() + test('Clear hides the lines at once and wipes them when the window ends', async ({ page }) => { + await openLogs(page) + const calls = [] + await page.route('**/api/backend-logs/qwen3-8b-instruct/clear', route => { calls.push('clear'); return route.fulfill({ json: {} }) }) + await page.getByRole('button', { name: 'Clear' }).click() + await expect(lineRows(page)).toHaveCount(0) + await expect(page.getByTestId('logs-undo-toast')).toContainText('Clearing the log') + expect(calls).toEqual([]) + await expect.poll(() => calls, { timeout: 15_000 }).toEqual(['clear']) + }) - // Click to hide details - await toggleBtn.click() + test('Undo brings the lines back and never calls the server', async ({ page }) => { + await openLogs(page) + const calls = [] + await page.route('**/api/backend-logs/qwen3-8b-instruct/clear', route => { calls.push('clear'); return route.fulfill({ json: {} }) }) + await page.getByRole('button', { name: 'Clear' }).click() + await page.getByTestId('logs-undo-toast').getByRole('button', { name: 'Undo' }).click() + await expect(lineRows(page)).toHaveCount(5) + await page.waitForTimeout(7_000) + expect(calls).toEqual([]) + }) - // Button label changes to "Show details" - await expect(page.locator('button', { hasText: 'Show details' })).toBeVisible() + test('says so when the process has printed nothing', async ({ page }) => { + await openLogs(page, { lines: [] }) + await expect(page.getByText('No log lines')).toBeVisible() + await expect(page.getByText('Log output will appear here as the backend process runs.')).toBeVisible() + }) + + test('a picker switches to another process', async ({ page }) => { + await openLogs(page) + await page.getByRole('combobox', { name: 'Process' }).selectOption('whisper-large-v3') + await expect(page).toHaveURL(/\/app\/backend-logs\/whisper-large-v3$/) + }) + + test('the Logs tab lists the processes that have output', async ({ page }) => { + await mockOperate(page) + await page.goto('/app/backends') + await page.locator('.hub-subnav').getByRole('link', { name: 'Logs' }).click() + await expect(page).toHaveURL(/\/app\/backend-logs$/) + const list = page.getByTestId('logs-processes') + await expect(list.getByRole('link', { name: /qwen3-8b-instruct/ })).toHaveAttribute('href', '/app/backend-logs/qwen3-8b-instruct') + await expect(page.locator('.dk-hubtabs [data-hub-tab="runtime"]')).toHaveAttribute('aria-current', 'page') + }) + + test('says so when no process has printed anything', async ({ page }) => { + await mockOperate(page) + await page.route('**/api/backend-logs', route => route.fulfill({ json: [] })) + await page.goto('/app/backend-logs') + await expect(page.getByText('No process has printed anything')).toBeVisible() + }) + + test('fits a phone', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await openLogs(page) + await expect(lineRows(page)).toHaveCount(5) + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + expect(overflow).toBeLessThanOrEqual(1) }) }) diff --git a/core/http/react-ui/e2e/backends-lifecycle.spec.js b/core/http/react-ui/e2e/backends-lifecycle.spec.js index 28ebc88a7..7de4b6215 100644 --- a/core/http/react-ui/e2e/backends-lifecycle.spec.js +++ b/core/http/react-ui/e2e/backends-lifecycle.spec.js @@ -117,12 +117,19 @@ 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') - await expect(page.locator('[data-testid="backends-installed-pane"]')).toContainText('llama-cpp') + // 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') await expect(page).toHaveURL(/[?&]target=worker-1(?:&|$)/) await page.getByRole('link', { name: 'Catalog', exact: true }).click() await expect(page).toHaveURL(/[?&]target=worker-1(?:&|$)/) await expect(page.getByText(/installing only on GPU worker/i)).toBeVisible() + // The row opened from the URL; a click closes it and a second one opens it. + // Neither touches the scope. + await backendRow(page, 'llama-cpp').click() + await expect(page).not.toHaveURL(/[?&]backend=/) + await expect(page).toHaveURL(/[?&]target=worker-1(?:&|$)/) await backendRow(page, 'llama-cpp').click() await expect(page).toHaveURL(/[?&]backend=llama-cpp(?:&|$)/) await expect(page).toHaveURL(/[?&]target=worker-1(?:&|$)/) @@ -136,8 +143,8 @@ test.describe('Backends lifecycle page', () => { }) await page.goto('/app/backends?view=installed&backend=llama-cpp') - await expect(page.getByRole('button', { name: /upgrade to v1\.1\.0/i })).toBeVisible() - await page.getByRole('button', { name: 'Actions for llama-cpp' }).click() + await expect(page.getByRole('button', { name: /update to v1\.1\.0/i })).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() await reinstall.click() @@ -147,7 +154,7 @@ 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^="Upgrade to"]')).toBeVisible() + await expect(page.locator('button[title^="Update to"]')).toBeVisible() await expect(page.locator('button[title="Reinstall"]')).toBeVisible() }) @@ -158,7 +165,7 @@ test.describe('Backends lifecycle page', () => { })) await page.goto('/app/backends?view=installed&backend=llama-cpp') - await page.getByRole('button', { name: 'Actions for llama-cpp' }).click() + await page.getByRole('button', { name: 'Actions for llama-cpp', exact: true }).click() await page.getByRole('menuitem', { name: 'Reinstall backend' }).click() const detail = page.locator('[data-testid="backends-installed-pane"]') @@ -172,7 +179,7 @@ test.describe('Backends lifecycle page', () => { })) await page.goto('/app/backends?view=installed') - await page.getByRole('button', { name: /upgrade all/i }).click() + await page.getByRole('button', { name: /update all/i }).click() await expect(page.getByRole('alert')).toContainText('upgrade registry unavailable') }) @@ -199,7 +206,7 @@ test.describe('Backends lifecycle page', () => { }) await page.goto('/app/backends?view=installed') - await page.getByRole('button', { name: /upgrade all/i }).click() + await page.getByRole('button', { name: /update all/i }).click() await expect(page.getByRole('alert')).toContainText('first registry unavailable') await expect.poll(() => laterUpgradeRequests).toBe(1) @@ -269,7 +276,7 @@ test.describe('Backends lifecycle page', () => { }) await page.goto('/app/backends?view=installed&backend=llama-cpp') - await page.getByRole('button', { name: 'Actions for llama-cpp' }).click() + await page.getByRole('button', { name: 'Actions for llama-cpp', exact: true }).click() await page.getByRole('menuitem', { name: 'Delete backend' }).click() await expect(page.getByRole('alertdialog')).toContainText('Delete backend llama-cpp?') expect(deleteRequests).toBe(0) @@ -278,18 +285,23 @@ test.describe('Backends lifecycle page', () => { await expect.poll(() => deleteRequests).toBe(1) }) - test('narrow detail Back restores focus to the originating backend', async ({ page }) => { + test('on a phone a backend opens in place and closes again, with its action in reach', async ({ page }) => { await page.setViewportSize({ width: 390, height: 800 }) await page.goto('/app/backends?view=installed') const backend = backendRow(page, 'llama-cpp') - await backend.click() - await expect(page.locator('[data-testid="backends-installed-pane"]')).toContainText('llama-cpp') - await expect(backend).not.toBeVisible() - - await page.locator('[data-testid="backends-installed-back"]').click() - await expect(backend).toBeVisible() - await expect(backend).toBeFocused() + // The row is a card: name, state and the action, no sideways scroll. + await expect(backend.getByRole('button', { name: 'Update llama-cpp' })).toBeVisible() + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + expect(overflow).toBeLessThanOrEqual(1) + + await backend.click() + await expect(backend).toHaveAttribute('data-selected', 'true') + await expect(page.locator('[data-testid="backends-installed-pane"]')).toContainText('v1.1.0') + await expect(backend).toBeVisible() + + await backend.getByRole('button', { name: /Hide details for llama-cpp/ }).click() + await expect(page.locator('[data-testid="backends-installed-pane"]')).toHaveCount(0) }) }) diff --git a/core/http/react-ui/e2e/backends-management.spec.js b/core/http/react-ui/e2e/backends-management.spec.js index 34408c92b..16c9362da 100644 --- a/core/http/react-ui/e2e/backends-management.spec.js +++ b/core/http/react-ui/e2e/backends-management.spec.js @@ -1,8 +1,9 @@ import { test, expect } from './coverage-fixtures.js' -// Backends admin page (src/pages/Backends.jsx). -const PANE = '[data-testid="backends-pane"]' -const railItem = (page, name) => page.locator(`[data-entity="${name}"]`) +// Backends admin page (src/pages/Backends.jsx): one list with an Installed and +// a Catalog view. A row is a name, a version, a state and the one action; it +// opens in place into the rest. +const row = (page, name) => page.locator(`[data-entity="${name}"]`) test.describe('Backends management page', () => { test.beforeEach(async ({ page }) => { @@ -12,7 +13,7 @@ test.describe('Backends management page', () => { test('renders the management header and gallery tabs', async ({ page }) => { await expect(page).toHaveURL(/\/app\/backends$/) await expect(page.getByRole('heading', { name: 'Backend Management' })).toBeVisible() - await expect(page.getByRole('button', { name: 'Manual Install' })).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() }) @@ -24,8 +25,8 @@ test.describe('Backends management page', () => { await expect(search).toHaveValue('whisper') }) - test('Manual Install reveals the OCI install form', async ({ page }) => { - await page.getByRole('button', { name: 'Manual Install' }).click() + test('From URL reveals the OCI install form', async ({ page }) => { + await page.getByRole('button', { name: 'From URL' }).click() await expect(page.getByPlaceholder('oci://quay.io/example/backend:latest')).toBeVisible() }) }) @@ -52,14 +53,11 @@ test.describe('Backends management page - Markdown descriptions', () => { }) }) await page.goto('/app/backends') - // Rendered means the rail has entries. The old gate waited on a column - // header, and there are no columns now. - await expect(railItem(page, 'markdown-backend')).toBeVisible({ timeout: 10_000 }) + await expect(row(page, 'markdown-backend')).toBeVisible({ timeout: 10_000 }) }) - test('the pane lede shows the description as clean text, not raw Markdown', async ({ page }) => { - await railItem(page, 'markdown-backend').click() - const cell = page.locator('.detail-pane__lede') + test('the row shows the description as clean text, not raw Markdown', async ({ page }) => { + const cell = row(page, 'markdown-backend').locator('.dk-table-sub') await expect(cell).toHaveText(STRIPPED_DESCRIPTION) // The syntax itself must be gone, not merely rendered somewhere. @@ -71,23 +69,27 @@ test.describe('Backends management page - Markdown descriptions', () => { await expect(cell.locator('h1')).toHaveCount(0) }) - test("the lede's tooltip carries the stripped text, not raw Markdown", async ({ page }) => { - await railItem(page, 'markdown-backend').click() - await expect(page.locator('.detail-pane__lede')).toHaveAttribute('title', STRIPPED_DESCRIPTION) + test("the row's tooltip carries the stripped text, not raw Markdown", async ({ page }) => { + await expect(row(page, 'markdown-backend').locator('.dk-table-sub')).toHaveAttribute('title', STRIPPED_DESCRIPTION) }) - test('a backend with no description renders no lede rather than a blank one', async ({ page }) => { - // The table needed a placeholder because an empty cell in a grid of full - // ones reads as a fault. The pane has no grid to keep aligned, so it omits - // the line - but must never print "undefined". - await railItem(page, 'plain-backend').click() - await expect(page.locator(PANE)).toContainText('plain-backend') - await expect(page.locator('.detail-pane__lede')).toHaveCount(0) - await expect(page.locator(PANE)).not.toContainText('undefined') + test('opening the row renders the Markdown, with its link', async ({ page }) => { + await row(page, 'markdown-backend').click() + const detail = page.getByTestId('backend-detail') + await expect(detail.locator('.bk-detail__desc h1')).toHaveText('InsightFace') + await expect(detail.locator('.bk-detail__desc a[href="https://example.com/docs"]')).toBeVisible() + }) + + test('a backend with no description renders no blank line and never "undefined"', async ({ page }) => { + await expect(row(page, 'plain-backend')).toContainText('plain-backend') + await expect(row(page, 'plain-backend')).not.toContainText('undefined') + await row(page, 'plain-backend').click() + await expect(page.getByTestId('backend-detail')).not.toContainText('undefined') + await expect(page.locator('.bk-detail__desc')).toHaveCount(0) }) }) -test.describe('Backends gallery - split view', () => { +test.describe('Backends gallery - list', () => { test.beforeEach(async ({ page }) => { await page.route('**/api/backends*', (route) => { route.fulfill({ @@ -102,46 +104,50 @@ test.describe('Backends gallery - split view', () => { }) }) await page.goto('/app/backends') - await expect(railItem(page, 'llama-cpp')).toBeVisible({ timeout: 10_000 }) + await expect(row(page, 'llama-cpp')).toBeVisible({ timeout: 10_000 }) }) - test('the gallery renders no table', async ({ page }) => { + test('is a table with columns, not a rail and a pane', async ({ page }) => { await expect(page.locator('[data-testid="backends"]')).toBeVisible() - await expect(page.locator('table thead th')).toHaveCount(0) + await expect(page.locator('table thead th').first()).toBeVisible() + await expect(page.locator('[data-testid="backends-pane"]')).toHaveCount(0) }) - test('with nothing selected the pane describes the host', async ({ page }) => { - await expect(page.locator(PANE)).toContainText('This host') - await expect(page.locator('[data-testid="backends-back"]')).toHaveCount(0) + test('opening a row shows its facts, and the chevron closes it', async ({ page }) => { + await row(page, 'llama-cpp').click() + const detail = page.getByTestId('backend-detail') + await expect(detail).toContainText('MIT') + await expect(detail).toContainText('chat') + await row(page, 'llama-cpp').getByRole('button', { name: /Hide details for llama-cpp/ }).click() + await expect(page.getByTestId('backend-detail')).toHaveCount(0) }) - test('choosing a backend turns the pane into its detail, and back returns', async ({ page }) => { - await railItem(page, 'llama-cpp').click() - await expect(page.locator(PANE)).toContainText('llama-cpp') - await expect(page.locator(PANE)).toContainText('MIT') - await expect(page.locator(PANE)).not.toContainText('This host') - - await page.locator('[data-testid="backends-back"]').click() - await expect(page.locator(PANE)).toContainText('This host') + test('only one row is open at a time', async ({ page }) => { + await row(page, 'llama-cpp').click() + await row(page, 'whisper').click() + await expect(page.getByTestId('backend-detail')).toHaveCount(1) + await expect(page.getByTestId('backend-detail')).toContainText('transcript') + await expect(page).toHaveURL(/[?&]backend=whisper/) }) - test('the selection lives in the URL and survives a reload', async ({ page }) => { - await railItem(page, 'whisper').click() + test('the open row lives in the URL and survives a reload', async ({ page }) => { + await row(page, 'whisper').click() await expect(page).toHaveURL(/[?&]backend=whisper/) await page.reload() - await expect(railItem(page, 'whisper')).toBeVisible({ timeout: 10_000 }) - await expect(page.locator('[data-testid="backends-back"]')).toBeVisible() + await expect(row(page, 'whisper')).toBeVisible({ timeout: 10_000 }) + await expect(page.getByTestId('backend-detail')).toContainText('transcript') }) - - test('the rail groups while browsing and flattens on a query', async ({ page }) => { - await expect(page.locator('[data-testid^="backends-rail-group-"]').first()).toBeVisible() - await page.locator('input[placeholder*="Search backends"]').fill('llama') - await expect(page.locator('[data-testid^="backends-rail-group-"]')).toHaveCount(0) + test('a filter chip narrows the list', async ({ page }) => { + await page.getByRole('button', { name: 'Image', exact: true }).click() + await expect(page).toHaveURL(/[?&]state=image/) + await expect(row(page, 'diffusers')).toBeVisible() + await expect(row(page, 'llama-cpp')).toHaveCount(0) }) test('an installed backend states its version, an absent one says so', async ({ page }) => { - await expect(railItem(page, 'llama-cpp')).toContainText('v1.52.0') - await expect(railItem(page, 'diffusers')).toContainText('not installed') + await expect(row(page, 'llama-cpp')).toContainText('v1.52.0') + await expect(row(page, 'llama-cpp')).toContainText('Current') + await expect(row(page, 'diffusers')).toContainText('Not installed') }) }) diff --git a/core/http/react-ui/e2e/backends-notice.spec.js b/core/http/react-ui/e2e/backends-notice.spec.js index cf7b5cbeb..882d47653 100644 --- a/core/http/react-ui/e2e/backends-notice.spec.js +++ b/core/http/react-ui/e2e/backends-notice.spec.js @@ -1,23 +1,23 @@ import { test, expect } from './coverage-fixtures.js' +import { UPGRADE_LLAMA, mockOperate } from './operate-fixtures.js' -// A notice is a hairline with a coloured left edge, not a filled panel. A tint -// makes every notice shout at the weight of an error, which is how notices stop -// being read — and it is the same treatment the Operate overview uses for the -// rows that want a decision. +// 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 +// 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('the backends notice is an edge, not a filled card', async ({ page }) => { - // The upgrade banner is the notice worth pinning, so make one exist. - await page.route('**/api/backends/upgrades', route => route.fulfill({ - json: { 'llama-cpp': { backend_name: 'llama-cpp', installed_version: '0.9.4', available_version: '0.9.7' } }, - })) - await page.goto('/app/backends') - const notice = page.locator('.bk-notice', { hasText: /update/i }).first() - await expect(notice).toBeVisible() - const s = await notice.evaluate(el => { - const cs = getComputedStyle(el) - return { bg: cs.backgroundColor, left: parseFloat(cs.borderLeftWidth), top: parseFloat(cs.borderTopWidth) } - }) - expect(s.bg).toMatch(/rgba\(0, 0, 0, 0\)|transparent/) - expect(s.left).toBeGreaterThanOrEqual(3) - expect(s.top).toBeLessThanOrEqual(1) +test('an update is a row state and a header button, 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: 'Update all (1)' })).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. + await expect(page.locator('.bk-notice')).toHaveCount(0) + const edges = await page.evaluate(() => [...document.querySelectorAll('.bk-table tr, .op-notice, .bk-recommend, .bk-manual')] + .map(el => parseFloat(getComputedStyle(el).borderLeftWidth))) + expect(Math.max(0, ...edges)).toBeLessThanOrEqual(1) }) diff --git a/core/http/react-ui/e2e/backends-runtime.spec.js b/core/http/react-ui/e2e/backends-runtime.spec.js new file mode 100644 index 000000000..fe208d62a --- /dev/null +++ b/core/http/react-ui/e2e/backends-runtime.spec.js @@ -0,0 +1,307 @@ +import { test, expect } from './coverage-fixtures.js' +import { + CATALOG, FAILED_OP, INSTALLED, RUNNING_OP, UPGRADE_LLAMA, catalogBackend, installedBackend, mockOperate, +} from './operate-fixtures.js' + +// Runtime, Backends: Installed and Catalog as two views of one list. A row says +// what the backend is doing now, carries the one button that matters, and opens +// into the rest. + +const row = (page, name) => page.locator(`[data-entity="${name}"]`) +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 } + +test.describe('Backends views', () => { + test('Installed and Catalog carry their counts, and the current one says so', async ({ page }) => { + await mockOperate(page) + await page.goto('/app/backends?view=installed') + const installed = page.getByRole('link', { name: 'Installed', exact: true }) + const catalog = page.getByRole('link', { name: 'Catalog', exact: true }) + await expect(installed).toHaveAttribute('aria-current', 'page') + await expect(installed).toContainText('3') + await expect(catalog).toContainText('6') + await expect(row(page, 'llama-cpp')).toBeVisible() + await catalog.click() + await expect(catalog).toHaveAttribute('aria-current', 'page') + await expect(row(page, 'vllm')).toBeVisible() + }) + + test('the Installed list puts what has an update first', async ({ page }) => { + await mockOperate(page, { upgrades: { whisper: { backend_name: 'whisper', installed_version: '1.7.2', available_version: '1.8.0' } } }) + await page.goto('/app/backends?view=installed') + await expect(page.getByTestId('backend-row').first()).toHaveAttribute('data-entity', 'whisper') + await expect(row(page, 'whisper')).toContainText('Update 1.8.0') + }) + + test('an empty Installed list sends you to the catalog', async ({ page }) => { + await mockOperate(page, { installed: [] }) + await page.goto('/app/backends?view=installed') + await expect(page.getByText('No backends installed yet')).toBeVisible() + await page.getByRole('link', { name: 'Open the catalog' }).click() + await expect(page.getByRole('link', { name: 'Catalog', exact: true })).toHaveAttribute('aria-current', 'page') + }) + + test('a first run recommends llama-cpp and installs it with one button', async ({ page }) => { + await mockOperate(page, { catalog: CATALOG.map(b => ({ ...b, installed: false })), installed: [] }) + const calls = [] + await page.route('**/api/backends/install/llama-cpp', route => { calls.push('install'); return jsonOk(route) }) + await page.goto('/app/backends') + const recommend = page.getByTestId('backends-recommend') + await expect(recommend).toContainText('Start with llama-cpp') + await recommend.getByRole('button', { name: 'Install llama-cpp' }).click() + await expect.poll(() => calls).toEqual(['install']) + }) + + test('no recommendation once something is installed', async ({ page }) => { + await mockOperate(page) + await page.goto('/app/backends') + await expect(row(page, 'llama-cpp')).toBeVisible() + await expect(page.getByTestId('backends-recommend')).toHaveCount(0) + }) +}) + +test.describe('Backend install progress', () => { + test('shows progress in the row and offers Cancel with an undo window', async ({ page }) => { + await mockOperate(page, { operations: [VLLM_INSTALLING] }) + const calls = [] + await page.route('**/api/operations/job-vllm/cancel', route => { calls.push('cancel'); return jsonOk(route) }) + await page.goto('/app/backends') + + const vllm = row(page, 'vllm') + await expect(vllm.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '6') + await expect(vllm).toContainText('6%') + await vllm.getByRole('button', { name: 'Cancel vllm' }).click() + await expect(page.getByTestId('backends-undo-toast')).toContainText('Cancelling vllm') + expect(calls).toEqual([]) + await expect.poll(() => calls, { timeout: 15_000 }).toEqual(['cancel']) + }) + + test('Undo keeps the install going', async ({ page }) => { + await mockOperate(page, { operations: [VLLM_INSTALLING] }) + const calls = [] + await page.route('**/api/operations/job-vllm/cancel', route => { calls.push('cancel'); return jsonOk(route) }) + await page.goto('/app/backends') + await row(page, 'vllm').getByRole('button', { name: 'Cancel vllm' }).click() + await page.getByTestId('backends-undo-toast').getByRole('button', { name: 'Undo' }).click() + await expect(page.getByTestId('backends-undo-toast')).toHaveCount(0) + await expect(row(page, 'vllm').getByRole('progressbar')).toBeVisible() + await page.waitForTimeout(9_000) + expect(calls).toEqual([]) + }) + + test('a queued install says so and a failed one says why, with Retry', async ({ page }) => { + await mockOperate(page, { + operations: [ + { ...VLLM_INSTALLING, isQueued: true, progress: 0 }, + { ...FAILED_OP, id: 'diffusers', name: 'diffusers', fullName: 'localai@diffusers', jobID: 'job-diff', isBackend: true, error: 'no space left on device' }, + ], + }) + const calls = [] + await page.route('**/api/backends/install/diffusers', route => { calls.push('install'); return jsonOk(route) }) + await page.goto('/app/backends') + await expect(row(page, 'vllm')).toContainText('Queued') + await expect(row(page, 'diffusers')).toContainText('Failed') + await expect(row(page, 'diffusers')).toContainText('no space left on device') + await row(page, 'diffusers').getByRole('button', { name: 'Retry diffusers' }).click() + await expect.poll(() => calls).toEqual(['install']) + }) +}) + +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() + 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 page.getByRole('button', { name: 'Update all (2)' }).click() + await expect.poll(() => calls.sort()).toEqual(['llama-cpp', 'whisper']) + }) + + test('Check for updates asks the server to check and says what it found', async ({ page }) => { + await mockOperate(page) + 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 expect.poll(() => checks).toBe(1) + await expect(page.getByRole('button', { name: 'Update all (1)' })).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 expect(page.getByText('Every installed backend is current')).toBeVisible() + }) +}) + +test.describe('Removing a backend', () => { + test('names the models that would stop working', async ({ page }) => { + await mockOperate(page) + const calls = [] + await page.route('**/api/backends/system/delete/llama-cpp', route => { calls.push('delete'); return jsonOk(route) }) + await page.goto('/app/backends?view=installed') + await row(page, 'llama-cpp').getByRole('button', { name: 'Actions for llama-cpp' }).click() + await page.getByRole('menuitem', { name: 'Delete backend' }).click() + + const dialog = page.getByRole('alertdialog') + await expect(dialog).toContainText('Delete backend llama-cpp?') + await expect(dialog.getByTestId('backend-remove-warning')).toContainText('2 models ask for this backend') + await expect(dialog.getByTestId('backend-remove-warning')).toContainText('gemma-3-12b-it, qwen3-8b-instruct') + expect(calls).toEqual([]) + await dialog.getByRole('button', { name: 'Delete', exact: true }).click() + await expect.poll(() => calls).toEqual(['delete']) + }) + + test('a backend nothing depends on has no warning', async ({ page }) => { + await mockOperate(page) + await page.goto('/app/backends?view=installed') + await row(page, 'kokoro').getByRole('button', { name: 'Actions for kokoro' }).click() + await page.getByRole('menuitem', { name: 'Delete backend' }).click() + await expect(page.getByRole('alertdialog')).toContainText('Delete backend kokoro?') + await expect(page.getByTestId('backend-remove-warning')).toHaveCount(0) + }) + + test('a meta backend that points at the one removed is named', async ({ page }) => { + await mockOperate(page, { + installed: [ + installedBackend('llama-cpp', '0.9.4', { Metadata: { version: '0.9.4', meta_backend_for: 'cuda12-llama-cpp' } }), + installedBackend('cuda12-llama-cpp', '0.9.4'), + ], + models: { data: [] }, + }) + await page.goto('/app/backends?view=installed&show_all=1') + await row(page, 'cuda12-llama-cpp').getByRole('button', { name: 'Actions for cuda12-llama-cpp' }).click() + await page.getByRole('menuitem', { name: 'Delete backend' }).click() + await expect(page.getByTestId('backend-remove-warning')).toContainText('llama-cpp points at it and will stop resolving.') + }) + + test('keeps a model that uses a backend visible in the open row', async ({ page }) => { + await mockOperate(page) + await page.goto('/app/backends?view=installed&backend=whisper') + await expect(page.getByTestId('backend-used-by')).toContainText('Used by 1 model: whisper-large-v3') + }) + + test('a system backend is protected and offers no removal', async ({ page }) => { + await mockOperate(page, { installed: [installedBackend('cpu-ggml', '1.0.0', { IsSystem: true })] }) + await page.goto('/app/backends?view=installed') + await expect(row(page, 'cpu-ggml')).toContainText('Protected') + await expect(row(page, 'cpu-ggml').getByRole('button', { name: /Actions for/ })).toHaveCount(0) + }) +}) + +test.describe('Install from URL and the catalog', () => { + test('posts the image, the name and the alias', async ({ page }) => { + await mockOperate(page) + 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 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') + await page.getByRole('button', { name: 'Install', exact: true }).click() + await expect.poll(() => bodies).toEqual([{ uri: 'oci://quay.io/example/backend:latest', name: 'my-backend', alias: 'mine' }]) + }) + + 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 page.getByRole('button', { name: 'Install', exact: true }).click() + await expect(page.getByRole('alert')).toContainText('Enter an image, URL or path') + }) + + test('a catalog row installs a backend that is not there', async ({ page }) => { + await mockOperate(page) + const calls = [] + await page.route('**/api/backends/install/vllm', route => { calls.push('vllm'); return jsonOk(route) }) + await page.goto('/app/backends') + await row(page, 'vllm').getByRole('button', { name: 'Install vllm' }).click() + await expect.poll(() => calls).toEqual(['vllm']) + }) + + test('the open row links to the logs list', async ({ page }) => { + await mockOperate(page) + await page.goto('/app/backends?view=installed&backend=llama-cpp') + await page.getByTestId('backend-detail').getByRole('link', { name: 'Logs' }).click() + await expect(page).toHaveURL(/\/app\/backend-logs$/) + }) + + test('shows no size, no rollback and no version history, because the API has none', async ({ page }) => { + await mockOperate(page) + await page.goto('/app/backends?view=installed&backend=llama-cpp') + await expect(page.getByTestId('backend-detail')).not.toContainText(/roll back|rollback|history/i) + await expect(page.locator('.bk-table thead')).not.toContainText(/size/i) + }) +}) + +test.describe('Backends in a cluster', () => { + const nodes = [ + { id: 'n1', name: 'gpu-box-1', status: 'healthy', node_type: 'backend' }, + { id: 'n2', name: 'gpu-box-2', status: 'healthy', node_type: 'backend' }, + ] + + test('a meta backend installs on all nodes, with a way to choose', async ({ page }) => { + await mockOperate(page, { distributed: true, nodes, catalog: [catalogBackend('vllm', { isMeta: true })], installed: [] }) + await page.goto('/app/backends') + const vllm = row(page, 'vllm') + await expect(vllm.getByRole('button', { name: 'Install vllm' })).toContainText('Install on all') + await vllm.getByRole('button', { name: 'More install options' }).click() + await expect(page.getByRole('menuitem', { name: 'Install on specific nodes…' })).toBeVisible() + }) + + test('a hardware-specific build goes straight to choosing nodes', async ({ page }) => { + await mockOperate(page, { distributed: true, nodes, catalog: [catalogBackend('cuda12-vllm')], installed: [] }) + await page.goto('/app/backends') + await expect(row(page, 'cuda12-vllm').getByRole('button', { name: 'Choose nodes…' })).toBeVisible() + }) + + test('the Nodes column shows where a backend is installed', async ({ page }) => { + await mockOperate(page, { + distributed: true, + nodes, + installed: [installedBackend('llama-cpp', '0.9.4', { Nodes: [{ node_id: 'n1', node_name: 'gpu-box-1', node_status: 'healthy', version: '0.9.4' }] })], + }) + await page.goto('/app/backends?view=installed') + await expect(page.locator('.bk-table thead')).toContainText('Nodes') + await expect(row(page, 'llama-cpp')).toContainText('gpu-box-1') + }) +}) + +test.describe('Backends layout', () => { + test('a catalog row fits a phone with its progress and Cancel', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }) + await mockOperate(page, { operations: [VLLM_INSTALLING], installed: INSTALLED }) + await page.goto('/app/backends') + const vllm = row(page, 'vllm') + await expect(vllm.getByRole('progressbar')).toBeVisible() + await expect(vllm.getByRole('button', { name: 'Cancel vllm' })).toBeVisible() + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) + expect(overflow).toBeLessThanOrEqual(1) + }) + + test('the install bar does not move when motion is reduced', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + await mockOperate(page, { operations: [VLLM_INSTALLING] }) + await page.goto('/app/backends') + const duration = await row(page, 'vllm').locator('.dk-progress-bar').evaluate(el => parseFloat(getComputedStyle(el).transitionDuration)) + expect(duration).toBeLessThan(0.001) + }) +}) diff --git a/core/http/react-ui/e2e/build-landing.spec.js b/core/http/react-ui/e2e/build-landing.spec.js new file mode 100644 index 000000000..5696fc6d3 --- /dev/null +++ b/core/http/react-ui/e2e/build-landing.spec.js @@ -0,0 +1,161 @@ +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() + await expect(landing.getByRole('heading', { name: 'Recognise' })).toBeVisible() + 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/build-tools.spec.js b/core/http/react-ui/e2e/build-tools.spec.js new file mode 100644 index 000000000..e63047f24 --- /dev/null +++ b/core/http/react-ui/e2e/build-tools.spec.js @@ -0,0 +1,513 @@ +import { test, expect } from './coverage-fixtures.js' +import { CPU_HOST, GPU_HOST, ftEvents, ftJob, mockFineTune, mockMachine, mockQuantize, qzJob } from './tools-fixtures.js' + +// Fine-tune and Quantize share one pattern: set up, check before you start (the +// list redraws as the form changes), run with progress and a log, then a result +// with real next steps. Every number on these pages comes from the stubs below. + +const MODEL = 'TinyLlama/TinyLlama-1.1B-Chat-v1.0' +const expectStep = (page, key, state) => expect(page.locator(`[data-testid="tool-steps"] [data-step="${key}"]`)).toHaveAttribute('data-state', state) +const check = (page, id) => page.locator(`[data-testid="tool-checks"] [data-check="${id}"]`) + +async function boot(page, { host = GPU_HOST, ft = {}, qz = {} } = {}) { + await mockMachine(page, { host }) + const ftState = { jobs: [], ...ft } + const qzState = { jobs: [], ...qz } + await mockFineTune(page, ftState) + await mockQuantize(page, qzState) + return { ftState, qzState } +} + +async function fillFineTune(page) { + await page.fill('#ft-model', MODEL) + await page.fill('#ft-dataset', 'tatsu-lab/alpaca') +} + +test.describe('Fine-tune: set up and check', () => { + test('starts empty: Set up is current, Start is off, and the check says what is missing', async ({ page }) => { + await boot(page) + await page.goto('/app/fine-tune') + await expect(page.getByTestId('fine-tune-form')).toBeVisible() + await expectStep(page, 'setup', 'current') + await expect(page.getByTestId('ft-start')).toBeDisabled() + await expect(check(page, 'model')).toHaveAttribute('data-tone', 'fail') + await expect(check(page, 'data')).toContainText('Add a dataset') + await expect(page.getByTestId('ft-bar')).toContainText('Enter the model to train.') + await expect(page.getByTestId('ft-recipe')).toContainText('Choose a model and a dataset') + }) + + test('a model and a dataset move to Check and switch Start on', async ({ page }) => { + await boot(page) + await page.goto('/app/fine-tune') + await fillFineTune(page) + await expectStep(page, 'check', 'current') + await expect(page.getByTestId('ft-start')).toBeEnabled() + await expect(check(page, 'model')).toHaveAttribute('data-tone', 'ok') + await expect(check(page, 'data')).toContainText('Data comes from tatsu-lab/alpaca') + await expect(check(page, 'backend')).toContainText('trl is installed') + await expect(check(page, 'memory')).toContainText('RTX 4070: 9.8 GB of 12 GB free') + await expect(check(page, 'memory')).toContainText('LocalAI does not estimate it before the job starts') + await expect(check(page, 'memory').getByRole('img')).toHaveAttribute('aria-label', /18 percent in use/) + await expect(check(page, 'disk')).toContainText('118 GB free on the models disk') + await expect(page.getByTestId('ft-recipe')).toContainText('Train a LoRA adapter of TinyLlama-1.1B-Chat-v1.0 with SFT, 3 epochs over tatsu-lab/alpaca.') + await expect(page.getByTestId('ft-bar')).toContainText('Nothing blocks the start.') + }) + + test('the check list redraws as the form changes', async ({ page }) => { + await boot(page) + await page.goto('/app/fine-tune') + await fillFineTune(page) + await expect(check(page, 'memory')).toContainText('Batch size 2') + await page.fill('#ft-batch', '4') + await expect(check(page, 'memory')).toContainText('Batch size 4') + await page.fill('#ft-epochs', '5') + await expect(page.getByTestId('ft-recipe')).toContainText('5 epochs') + await page.getByRole('radio', { name: /Full model/ }).check() + await expect(page.getByTestId('ft-recipe')).toContainText('full model') + + // GRPO needs a reward function: a warning that goes when one is chosen. + await page.getByTestId('ft-more-toggle').click() + await page.selectOption('#ft-method', 'grpo') + await expect(check(page, 'reward')).toHaveAttribute('data-tone', 'warn') + await expect(page.getByTestId('ft-bar')).toContainText('1 warning. You can still start.') + await expect(page.getByTestId('ft-start')).toBeEnabled() + await page.getByLabel('format_reward').check() + await expect(check(page, 'reward')).toHaveCount(0) + await expect(page.getByTestId('ft-bar')).toContainText('Nothing blocks the start.') + }) + + test('with no GPU the memory check warns and names the RAM', async ({ page }) => { + await boot(page, { host: CPU_HOST }) + await page.goto('/app/fine-tune') + await fillFineTune(page) + await expect(check(page, 'memory')).toHaveAttribute('data-tone', 'warn') + await expect(check(page, 'memory')).toContainText('No GPU found. Training uses the CPU and 20 GB of 32 GB RAM is free.') + }) + + test('with no backend installed it warns, links to Backends, and still lets you start', async ({ page }) => { + await boot(page, { ft: { backends: [] } }) + await page.goto('/app/fine-tune') + await fillFineTune(page) + await expect(check(page, 'backend')).toHaveAttribute('data-tone', 'warn') + await expect(check(page, 'backend')).toContainText('No fine-tuning backend is installed') + await expect(check(page, 'backend').getByRole('link', { name: 'Open Backends' })).toHaveAttribute('href', '/app/backends') + await expect(page.getByTestId('ft-start')).toBeEnabled() + }) + + test('a user who cannot read the machine still gets a check list', async ({ page }) => { + await boot(page, { host: null }) + await page.goto('/app/fine-tune') + await fillFineTune(page) + await expect(check(page, 'memory')).toContainText('This account cannot read the machine') + await expect(check(page, 'disk')).toHaveCount(0) + }) + + test('Start sends the request the form describes and opens the job', async ({ page }) => { + const { ftState } = await boot(page) + await page.goto('/app/fine-tune') + await fillFineTune(page) + await page.fill('#ft-split', 'train') + await page.fill('#ft-lr', '5e-5') + await page.getByTestId('ft-start').click() + await expect(page.getByTestId('job-view')).toBeVisible() + expect(ftState.started).toHaveLength(1) + expect(ftState.started[0]).toMatchObject({ + model: MODEL, backend: 'trl', training_method: 'sft', training_type: 'lora', dataset_source: 'tatsu-lab/alpaca', + dataset_split: 'train', num_epochs: 3, batch_size: 2, learning_rate: 0.00005, adapter_rank: 16, + }) + expect(ftState.started[0].extra_options).toMatchObject({ max_seq_length: '2048', eval_strategy: 'no' }) + await expectStep(page, 'run', 'current') + }) + + test('an uploaded file is the dataset, and is sent to the upload endpoint first', async ({ page }) => { + const { ftState } = await boot(page) + await page.goto('/app/fine-tune') + await page.fill('#ft-model', MODEL) + await page.setInputFiles('#ft-dataset-file', { name: 'pairs.jsonl', mimeType: 'application/json', buffer: Buffer.from('{"text":"a"}\n') }) + await expect(check(page, 'data')).toContainText('pairs.jsonl') + await page.getByTestId('ft-start').click() + await expect(page.getByTestId('job-view')).toBeVisible() + expect(ftState.started[0].dataset_source).toBe('/data/datasets/upload.jsonl') + }) + + test('a refused start shows the server message and keeps the form', async ({ page }) => { + await boot(page, { ft: { startError: 'failed to load backend trl: not found' } }) + await page.goto('/app/fine-tune') + await fillFineTune(page) + await page.getByTestId('ft-start').click() + await expect(page.getByTestId('tool-error')).toContainText('failed to load backend trl: not found') + await expect(page.getByTestId('fine-tune-form')).toBeVisible() + await expect(page.locator('#ft-model')).toHaveValue(MODEL) + }) + + test('More options carries the method, adapter and evaluation settings', async ({ page }) => { + await boot(page) + await page.goto('/app/fine-tune') + await expect(page.locator('#ft-method')).toHaveCount(0) + await page.getByTestId('ft-more-toggle').click() + for (const id of ['#ft-backend', '#ft-method', '#ft-type', '#ft-rank', '#ft-optimizer', '#ft-hf-token', '#ft-seq-len']) await expect(page.locator(id)).toBeVisible() + await page.getByRole('switch', { name: 'Evaluate while training' }).click() + await expect(page.locator('#ft-eval-strategy')).toBeVisible() + }) +}) + +test.describe('Fine-tune: run, fail, finish', () => { + const open = async (page) => { + await page.getByTestId('ft-jobs').getByRole('button', { name: MODEL }).click() + await expect(page.getByTestId('job-view')).toBeVisible() + } + + test('a running job shows progress, stages, chart and log from its events', async ({ page }) => { + await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(540) } } }) + await page.goto('/app/fine-tune') + await open(page) + await expectStep(page, 'run', 'current') + const view = page.getByTestId('job-view') + await expect(view.locator('.bt-job__percent')).toContainText('60') + await expect(view).toContainText('step 540 of 900') + await expect(view).toContainText('epoch 1.8 of 3') + await expect(view).toContainText('about 12 min left') + await expect(view).toContainText('312 tokens/s') + await expect(view.getByRole('progressbar', { name: 'Progress' })).toHaveAttribute('aria-valuenow', '60') + await expect(page.locator('[data-testid="job-stages"] [data-state="current"]')).toHaveText('Training') + // The chart: a line, the evaluation loss as hollow dots, the last value. + await expect(page.getByTestId('job-chart').locator('.bt-chart__line')).toBeVisible() + await expect(page.getByTestId('job-chart').locator('.bt-chart__eval')).toHaveCount(5) + await expect(page.getByTestId('job-chart')).toContainText('Hollow dots are the evaluation loss') + await page.getByRole('tab', { name: 'Learning rate' }).click() + await expect(page.getByTestId('job-chart').getByRole('heading', { name: 'Learning rate' })).toBeVisible() + await expect(page.getByTestId('job-chart').locator('.bt-chart__eval')).toHaveCount(0) + // The log is what the events said, and the filter shows warnings only. + await expect(page.getByTestId('job-log')).toContainText('step 530/900 loss') + await page.getByTestId('job-log-filter').check() + await expect(page.getByTestId('job-log')).toContainText('No warnings or errors.') + }) + + test('Stop asks whether to keep a checkpoint and sends the answer', async ({ page }) => { + const { ftState } = await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(100) } } }) + await page.goto('/app/fine-tune') + await open(page) + await page.getByTestId('job-stop').click() + await expect(page.getByTestId('stop-dialog')).toContainText('Stop training?') + await page.getByTestId('stop-keep').click() + await expect(page.getByTestId('stop-dialog')).toHaveCount(0) + expect(ftState.stopped).toEqual([{ id: 'job-run', save: 'true' }]) + await page.getByTestId('job-stop').click() + await page.getByTestId('stop-discard').click() + expect(ftState.stopped[1]).toEqual({ id: 'job-run', save: 'false' }) + await page.getByTestId('job-stop').click() + await page.getByRole('button', { name: 'Keep training' }).click() + expect(ftState.stopped).toHaveLength(2) + }) + + test('a failed job shows the server message, offers the fixes for a memory failure, and applies them to a copy', async ({ page }) => { + const message = 'torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 1.34 GiB.' + await boot(page, { + ft: { + jobs: [ftJob('job-f', 'failed', { message })], + checkpoints: { 'job-f': [{ path: '/data/finetune/job-f/checkpoint-200', step: 200, epoch: 0.66, loss: 1.4012, created_at: '2026-10-08 10:10' }] }, + }, + }) + await page.goto('/app/fine-tune') + await open(page) + await expectStep(page, 'run', 'failed') + await expect(page.getByTestId('job-failed')).toHaveAttribute('role', 'alert') + await expect(page.getByTestId('job-failed-message')).toHaveText(message) + await expect(page.getByTestId('job-try')).toContainText('Lower the batch size to 1') + await expect(page.getByRole('button', { name: 'Resume from step 200' })).toBeVisible() + await page.getByTestId('job-retry').click() + // Back on the form, with the setup restored and the two fixes applied. + await expect(page.getByTestId('fine-tune-form')).toBeVisible() + await expect(page.locator('#ft-model')).toHaveValue(MODEL) + await expect(page.locator('#ft-batch')).toHaveValue('1') + await page.getByTestId('ft-more-toggle').click() + await expect(page.getByLabel(/Gradient checkpointing/)).toBeChecked() + }) + + test('a failure that is not about memory gets no invented advice', async ({ page }) => { + await boot(page, { ft: { jobs: [ftJob('job-f', 'failed', { message: 'dataset not found: tatsu-lab/alpaca' })] } }) + await page.goto('/app/fine-tune') + await open(page) + await expect(page.getByTestId('job-failed-message')).toHaveText('dataset not found: tatsu-lab/alpaca') + await expect(page.getByTestId('job-try')).not.toContainText('batch size') + await expect(page.getByTestId('job-try')).toContainText('change what the message points at') + }) + + test('Resume from a checkpoint puts it in the form', async ({ page }) => { + await boot(page, { + ft: { + jobs: [ftJob('job-f', 'failed', { message: 'stopped' })], + checkpoints: { 'job-f': [{ path: '/data/finetune/job-f/checkpoint-200', step: 200, epoch: 0.66, loss: 1.4012, created_at: 'x' }] }, + }, + }) + await page.goto('/app/fine-tune') + await open(page) + await page.getByRole('button', { name: 'Resume from step 200' }).click() + await expect(page.getByText('Resuming from checkpoint:')).toBeVisible() + await expect(page.getByTestId('fine-tune-form')).toContainText('/data/finetune/job-f/checkpoint-200') + await expect(page.getByTestId('ft-start')).toContainText('Resume training') + }) + + test('a finished job offers the export, and an exported model leads to Chat and Models', async ({ page }) => { + const { ftState } = await boot(page, { + ft: { + jobs: [ftJob('job-d', 'completed')], + checkpoints: { 'job-d': [{ path: '/data/finetune/job-d/checkpoint-500', step: 500, epoch: 1.66, loss: 0.8911, created_at: 'x' }] }, + }, + }) + await page.goto('/app/fine-tune') + await open(page) + await expectStep(page, 'result', 'current') + await expect(page.getByTestId('job-finished')).toContainText('Training finished.') + await expect(page.getByTestId('job-checkpoints')).toContainText('1.4012'.replace('1.4012', '0.8911')) + await page.fill('#ft-export-name', 'tinyllama-ft') + await page.selectOption('#ft-export-format', 'gguf') + await page.fill('#ft-export-quant', 'q5_k_m') + await page.getByRole('region', { name: 'Export as a model' }).getByRole('button', { name: 'Export', exact: true }).click() + expect(ftState.exported[0]).toMatchObject({ name: 'tinyllama-ft', export_format: 'gguf', quantization_method: 'q5_k_m', checkpoint_path: '/data/finetune/job-d', model: MODEL }) + }) + + test('an exported job links to Chat, Models and the archive', async ({ page }) => { + await boot(page, { ft: { jobs: [ftJob('job-d', 'completed', { export_status: 'completed', export_model_name: 'tinyllama-ft' })] } }) + await page.goto('/app/fine-tune') + await open(page) + const next = page.getByTestId('export-next') + await expect(next).toContainText('tinyllama-ft is a model now') + await expect(next.getByRole('link', { name: 'Chat with tinyllama-ft' })).toHaveAttribute('href', '/app/chat/tinyllama-ft') + await expect(next.getByRole('link', { name: 'Open in Models' })).toHaveAttribute('href', '/app/models?view=installed') + await expect(next.getByRole('link', { name: 'Download archive' })).toHaveAttribute('href', /jobs\/job-d\/download/) + }) + + test('New job returns to the form', async ({ page }) => { + await boot(page, { ft: { jobs: [ftJob('job-d', 'completed')] } }) + await page.goto('/app/fine-tune') + await open(page) + await page.getByTestId('job-back').click() + await expect(page.getByTestId('fine-tune-form')).toBeVisible() + }) +}) + +test.describe('Fine-tune: earlier jobs', () => { + test('lists jobs with kind and status, reuses a setup and deletes after a confirmation', async ({ page }) => { + const { ftState } = await boot(page, { + ft: { jobs: [ftJob('job-1', 'completed', { model: 'Qwen2.5-0.5B', training_method: 'dpo', config: { model: 'Qwen2.5-0.5B', dataset_source: 'x/y', training_method: 'dpo', batch_size: 3 } }), ftJob('job-2', 'failed', { model: 'gemma-2-2b', message: 'unsupported' })] }, + }) + await page.goto('/app/fine-tune') + const jobs = page.getByTestId('ft-jobs') + await expect(jobs).toContainText('2 kept on this server') + await expect(jobs.locator('tbody tr')).toHaveCount(2) + await expect(jobs.locator('tbody tr').first()).toContainText('lora, dpo') + await expect(jobs.locator('tbody tr').nth(1)).toContainText('unsupported') + await jobs.locator('tbody tr').first().getByRole('button', { name: 'Reuse' }).click() + await expect(page.locator('#ft-model')).toHaveValue('Qwen2.5-0.5B') + await expect(page.locator('#ft-dataset')).toHaveValue('x/y') + await expect(page.locator('#ft-batch')).toHaveValue('3') + await jobs.locator('tbody tr').nth(1).getByRole('button', { name: 'Delete' }).click() + await expect(page.getByRole('alertdialog')).toContainText('Delete this job?') + await page.getByRole('button', { name: 'Cancel' }).click() + expect(ftState.deleted).toEqual([]) + await jobs.locator('tbody tr').nth(1).getByRole('button', { name: 'Delete' }).click() + await page.getByTestId('delete-confirm').click() + await expect.poll(() => ftState.deleted).toEqual(['job-2']) + }) + + test('with no jobs it names the next step', async ({ page }) => { + await boot(page) + await page.goto('/app/fine-tune') + await expect(page.getByTestId('ft-jobs').locator('.dk-empty-title')).toHaveText('No fine-tuning jobs yet') + }) +}) + +test.describe('Quantize', () => { + test('setup: a model is needed, the type can be custom, and the check lists the facts', async ({ page }) => { + await boot(page) + await page.goto('/app/quantize') + await expectStep(page, 'setup', 'current') + await expect(page.getByTestId('qz-start')).toBeDisabled() + await page.fill('#qz-model', 'meta-llama/Llama-3.2-1B') + await expectStep(page, 'check', 'current') + await expect(page.getByTestId('qz-start')).toContainText('Quantize (q4_k_m)') + await expect(check(page, 'backend')).toContainText('llama-cpp-quantization is installed') + await expect(check(page, 'memory')).toContainText('20 GB of 32 GB RAM is free. Conversion runs on the CPU.') + await expect(check(page, 'disk')).toContainText('118 GB free on the models disk') + await expect(page.getByTestId('qz-recipe')).toContainText('Convert Llama-3.2-1B to GGUF at q4_k_m.') + await page.selectOption('#qz-type', 'q8_0') + await expect(page.getByTestId('qz-recipe')).toContainText('at q8_0') + await page.selectOption('#qz-type', '__custom__') + await expect(check(page, 'type')).toHaveAttribute('data-tone', 'fail') + await expect(page.getByTestId('qz-start')).toBeDisabled() + await page.fill('#qz-custom', 'iq4_nl') + await expect(page.getByTestId('qz-start')).toContainText('Quantize (iq4_nl)') + await expect(page.getByTestId('qz-start')).toBeEnabled() + }) + + test('no backend: a warning and a link, and the start stays possible', async ({ page }) => { + await boot(page, { qz: { backends: [] } }) + await page.goto('/app/quantize') + await page.fill('#qz-model', 'meta-llama/Llama-3.2-1B') + await expect(check(page, 'backend')).toHaveAttribute('data-tone', 'warn') + await expect(check(page, 'backend').getByRole('link', { name: 'Open Backends' })).toBeVisible() + await expect(page.getByTestId('qz-start')).toBeEnabled() + }) + + test('Start sends the request, with the token under extra options, and opens the job', async ({ page }) => { + const { qzState } = await boot(page) + await page.goto('/app/quantize') + await page.fill('#qz-model', 'meta-llama/Llama-3.2-1B') + await page.getByRole('button', { name: /More options/ }).click() + await page.fill('#qz-token', 'hf_secret') + await page.getByTestId('qz-start').click() + await expect(page.getByTestId('job-view')).toBeVisible() + expect(qzState.started[0]).toEqual({ model: 'meta-llama/Llama-3.2-1B', backend: 'llama-cpp-quantization', quantization_type: 'q4_k_m', extra_options: { hf_token: 'hf_secret' } }) + await expectStep(page, 'run', 'current') + }) + + test('a running job shows the stage, progress and log, and Stop sends the stop', async ({ page }) => { + const events = [{ job_id: 'qz1', status: 'downloading', progress_percent: 10, message: 'Downloading model files' }, { job_id: 'qz1', status: 'converting', progress_percent: 42, message: 'converting tensor 84 of 201' }] + const { qzState } = await boot(page, { qz: { jobs: [qzJob('qz1', 'converting')], events: { qz1: events } } }) + await page.goto('/app/quantize') + await page.getByTestId('qz-jobs').getByRole('button', { name: 'meta-llama/Llama-3.2-1B' }).click() + const view = page.getByTestId('job-view') + await expect(view.locator('.bt-job__percent')).toContainText('42') + await expect(view.getByRole('progressbar', { name: 'Progress' })).toHaveAttribute('aria-valuenow', '42') + await expect(view).toContainText('converting tensor 84 of 201') + await expect(page.locator('[data-testid="job-stages"] [data-state="current"]')).toHaveText('Converting') + await expect(page.locator('[data-testid="job-stages"] [data-state="done"]')).toHaveCount(2) + await expect(page.getByTestId('job-log')).toContainText('Downloading model files') + await page.getByTestId('job-stop').click() + expect(qzState.stopped).toEqual(['qz1']) + }) + + test('a failed job shows the server message and reuses its setup', async ({ page }) => { + await boot(page, { qz: { jobs: [qzJob('qz9', 'failed', { model: 'acme/odd-model', quantization_type: 'q5_k_m', message: 'unsupported architecture: OddForCausalLM' })] } }) + await page.goto('/app/quantize') + await page.getByTestId('qz-jobs').getByRole('button', { name: 'acme/odd-model' }).click() + await expectStep(page, 'run', 'failed') + await expect(page.getByTestId('job-failed-message')).toHaveText('unsupported architecture: OddForCausalLM') + await page.getByTestId('job-retry').click() + await expect(page.locator('#qz-model')).toHaveValue('acme/odd-model') + await expect(page.locator('#qz-type')).toHaveValue('q5_k_m') + }) + + test('a finished job imports under a name and then leads to Chat and Models', async ({ page }) => { + const { qzState } = await boot(page, { qz: { jobs: [qzJob('qz2', 'completed', { output_file: '/data/quant/qz2/model-q4_k_m.gguf' })] } }) + await page.goto('/app/quantize') + await page.getByTestId('qz-jobs').getByRole('button', { name: 'meta-llama/Llama-3.2-1B' }).click() + await expectStep(page, 'result', 'current') + await expect(page.getByTestId('job-finished')).toContainText('Quantization finished: q4_k_m.') + await expect(page.getByTestId('quantize-output')).toContainText('/data/quant/qz2/model-q4_k_m.gguf') + await expect(page.getByRole('link', { name: 'Download GGUF' })).toHaveAttribute('href', /jobs\/qz2\/download/) + await page.fill('#qz-import-name', 'llama-1b-q4') + await page.getByTestId('quantize-import').click() + const next = page.getByTestId('quantize-next') + await expect(next).toContainText('llama-1b-q4 is a model now') + expect(qzState.imported).toEqual([{ name: 'llama-1b-q4' }]) + await expect(next.getByRole('link', { name: 'Chat with llama-1b-q4' })).toHaveAttribute('href', '/app/chat/llama-1b-q4') + await expect(next.getByRole('link', { name: 'Open in Models' })).toHaveAttribute('href', '/app/models?view=installed') + }) + + test('earlier jobs: imported mark, failure line, delete after a confirmation', async ({ page }) => { + const { qzState } = await boot(page, { + qz: { jobs: [qzJob('qz0', 'completed', { import_status: 'completed', import_model_name: 'llama-3.2-1b-q4_k_m' }), qzJob('qz9', 'failed', { model: 'acme/odd-model', message: 'unsupported architecture' })] }, + }) + await page.goto('/app/quantize') + const jobs = page.getByTestId('qz-jobs') + await expect(jobs.locator('tbody tr').first()).toContainText('imported as llama-3.2-1b-q4_k_m') + await expect(jobs.locator('tbody tr').nth(1)).toContainText('unsupported architecture') + await jobs.locator('tbody tr').nth(1).getByRole('button', { name: 'Delete' }).click() + await page.getByTestId('delete-confirm').click() + await expect.poll(() => qzState.deleted).toEqual(['qz9']) + }) + + test('with no jobs it names the next step', async ({ page }) => { + await boot(page) + await page.goto('/app/quantize') + await expect(page.getByTestId('qz-jobs').locator('.dk-empty-title')).toHaveText('No quantization jobs yet') + }) +}) + +test.describe('Disabled: the account lacks the permission', () => { + const member = (permissions) => (page) => page.route('**/api/auth/status', route => route.fulfill({ + json: { authEnabled: true, staticApiKeyRequired: false, providers: ['local'], user: { id: 'u1', name: 'Sam', role: 'user', permissions } }, + })) + + test('Fine-tune says the account cannot use it, who can change that, and where to go', async ({ page }) => { + await boot(page) + await member({ chat: true })(page) + await page.goto('/app/fine-tune') + const off = page.getByTestId('tool-off') + await expect(off).toHaveAttribute('data-cause', 'account') + await expect(off).toContainText('Your account cannot fine-tune') + await expect(off).toContainText('Ask an administrator') + await expect(off.getByRole('link', { name: 'Go to Home' })).toHaveAttribute('href', '/app') + await expect(page.getByTestId('fine-tune-form')).toHaveCount(0) + }) + + test('Quantize has its own words', async ({ page }) => { + await boot(page) + await member({ chat: true, fine_tuning: true })(page) + await page.goto('/app/quantize') + await expect(page.getByTestId('tool-off')).toContainText('Your account cannot quantize') + await page.goto('/app/fine-tune') + await expect(page.getByTestId('fine-tune-form')).toBeVisible() + }) + + test('an administrator is never shown the page', async ({ page }) => { + await boot(page) + await page.goto('/app/fine-tune') + await expect(page.getByTestId('fine-tune-form')).toBeVisible() + await expect(page.getByTestId('tool-off')).toHaveCount(0) + }) +}) + +test.describe('Build tools: phone', () => { + test.use({ viewport: { width: 390, height: 844 } }) + const noOverflow = (page) => page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth) + + test('Fine-tune setup and check fit the screen', async ({ page }) => { + await boot(page, { ft: { jobs: [ftJob('job-1', 'completed'), ftJob('job-2', 'failed', { message: 'x' })] } }) + await page.goto('/app/fine-tune') + await fillFineTune(page) + await page.getByTestId('ft-more-toggle').click() + await expect(check(page, 'memory')).toBeVisible() + expect(await noOverflow(page)).toBe(false) + await expect(page.getByTestId('ft-start')).toBeVisible() + }) + + test('a running Fine-tune job fits the screen', async ({ page }) => { + await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(540) } } }) + await page.goto('/app/fine-tune') + await page.getByTestId('ft-jobs').getByRole('button', { name: MODEL }).click() + await expect(page.getByTestId('job-chart').locator('.bt-chart__line')).toBeVisible() + expect(await noOverflow(page)).toBe(false) + }) + + test('Quantize finished fits the screen', async ({ page }) => { + await boot(page, { qz: { jobs: [qzJob('qz2', 'completed', { output_file: '/data/quant/qz2/a-very-long-file-name-q4_k_m.gguf' })] } }) + await page.goto('/app/quantize') + await page.getByTestId('qz-jobs').getByRole('button', { name: 'meta-llama/Llama-3.2-1B' }).click() + await expect(page.getByTestId('quantize-output')).toBeVisible() + expect(await noOverflow(page)).toBe(false) + }) + + test('the disabled page fits the screen', async ({ page }) => { + await boot(page) + 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/quantize') + await expect(page.getByTestId('tool-off')).toBeVisible() + expect(await noOverflow(page)).toBe(false) + }) +}) + +test.describe('Build tools: reduced motion', () => { + test('the job card does not animate', async ({ page }) => { + await page.emulateMedia({ reducedMotion: 'reduce' }) + await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(100) } } }) + await page.goto('/app/fine-tune') + await page.getByTestId('ft-jobs').getByRole('button', { name: MODEL }).click() + const duration = await page.getByTestId('job-view').locator('.bt-job').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 7e2835589..aae3749d3 100644 --- a/core/http/react-ui/e2e/canonical-resource-navigation.spec.js +++ b/core/http/react-ui/e2e/canonical-resource-navigation.spec.js @@ -49,8 +49,8 @@ test.describe('Canonical resource navigation', () => { await expect(sidebar.getByRole('link', { name: 'Discover', exact: true })).toHaveCount(0) await sidebar.getByRole('link', { name: 'Operate', exact: true }).click() - const operateRail = page.locator('.console-rail') - await expect(operateRail.getByRole('link', { name: /Backends/ })).toBeVisible() - await expect(operateRail.getByRole('link', { name: /Host/ })).toHaveCount(0) + 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-canvas-find.spec.js b/core/http/react-ui/e2e/chat-canvas-find.spec.js new file mode 100644 index 000000000..5d3316ac0 --- /dev/null +++ b/core/http/react-ui/e2e/chat-canvas-find.spec.js @@ -0,0 +1,184 @@ +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 new file mode 100644 index 000000000..48c85c27d --- /dev/null +++ b/core/http/react-ui/e2e/chat-composer.spec.js @@ -0,0 +1,226 @@ +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', '/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', '/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-errors.spec.js b/core/http/react-ui/e2e/chat-errors.spec.js index b49817ec7..2562001d3 100644 --- a/core/http/react-ui/e2e/chat-errors.spec.js +++ b/core/http/react-ui/e2e/chat-errors.spec.js @@ -28,10 +28,10 @@ test.describe('Chat - Error Handling', () => { 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 expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - await page.locator('.chat-input').fill('Hello') - await page.locator('.chat-send-btn').click() + 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 }) }) @@ -50,10 +50,10 @@ test.describe('Chat - Error Handling', () => { }) await page.goto('/app/chat') - await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) + await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - await page.locator('.chat-input').fill('Hello') - await page.locator('.chat-send-btn').click() + 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() @@ -76,10 +76,10 @@ test.describe('Chat - Error Handling', () => { }) await page.goto('/app/chat') - await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) + await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - await page.locator('.chat-input').fill('Hello') - await page.locator('.chat-send-btn').click() + 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 }) }) @@ -96,10 +96,10 @@ test.describe('Chat - Error Handling', () => { }) await page.goto('/app/chat') - await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) + await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - await page.locator('.chat-input').fill('Hello') - await page.locator('.chat-send-btn').click() + 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 new file mode 100644 index 000000000..5fc80b04c --- /dev/null +++ b/core/http/react-ui/e2e/chat-fixtures.js @@ -0,0 +1,146 @@ +// 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' + +export { CHATS_KEY, seedChats } + +const GB = 1024 * 1024 * 1024 + +export const MODELS = [ + { id: 'qwen3-8b', capabilities: ['FLAG_CHAT'] }, + { id: 'gemma-3-12b', capabilities: ['FLAG_CHAT', 'FLAG_VISION'] }, + { 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` +// 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, { + loaded: loaded.map(id => ({ id, backend: 'llama-cpp' })), + models: models.map(m => m.id), + chatModels: models.map(m => m.id), + ...(resources ? { resources } : {}), + ...(features ? { features } : {}), + }) + // Capabilities with the vision flag, which mockHome leaves out. + 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` + + `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }], usage })}\n\n` + + 'data: [DONE]\n\n' +} + +export async function answerWith(page, text) { + let body = null + await page.route('**/v1/chat/completions', (route) => { + body = route.request().postDataJSON() + route.fulfill({ status: 200, contentType: 'text/event-stream', body: sse(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 } = {}) { + await page.route('**/api/pipeline-models', route => route.fulfill({ + json: pipelines + ? [ + { name: 'voice-assistant', voice: 'alloy', vad: 'silero-vad', transcription: 'whisper-large-v3-turbo', llm: 'qwen3-8b', tts: 'kokoro-82m' }, + { name: 'voice-it', voice: 'paola', vad: 'silero-vad', transcription: 'whisper-small', llm: 'gemma-3-12b', tts: 'piper-it' }, + ] + : [], + })) + await page.route('**/v1/realtime/calls', route => route.fulfill({ json: { sdp: 'v=0' } })) + await page.addInitScript(({ micError }) => { + window.__talk = { dc: null, pc: null, sent: [], hold: false } + class FakePC { + constructor() { this.connectionState = 'new'; this.iceGatheringState = 'complete'; this.localDescription = null; window.__talk.pc = this } + addTrack() {} + createDataChannel() { + const dc = { readyState: 'open', send: (m) => window.__talk.sent.push(JSON.parse(m)), close() {}, onmessage: null } + window.__talk.dc = dc + return dc + } + async createOffer() { return { type: 'offer', sdp: 'v=0' } } + async setLocalDescription(d) { this.localDescription = d } + async setRemoteDescription() { + if (window.__talk.hold) return new Promise(() => {}) + this.connectionState = 'connected' + this.onconnectionstatechange && this.onconnectionstatechange() + } + close() { this.connectionState = 'closed' } + getStats() { return Promise.resolve(new Map()) } + } + window.RTCPeerConnection = FakePC + navigator.mediaDevices.getUserMedia = async () => { + if (micError) { const e = new Error('denied'); e.name = micError; throw e } + return new MediaStream() + } + window.__talk.emit = (ev) => window.__talk.dc.onmessage({ data: JSON.stringify(ev) }) + window.__talk.fail = () => { window.__talk.pc.connectionState = 'failed'; window.__talk.pc.onconnectionstatechange() } + }, { micError }) +} diff --git a/core/http/react-ui/e2e/chat-history.spec.js b/core/http/react-ui/e2e/chat-history.spec.js new file mode 100644 index 000000000..05f024ed5 --- /dev/null +++ b/core/http/react-ui/e2e/chat-history.spec.js @@ -0,0 +1,215 @@ +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', () => { + test.beforeEach(async ({ page }) => { + 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()) + 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() + 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 index e8e564d21..9a48a0cf8 100644 --- a/core/http/react-ui/e2e/chat-model-loading.spec.js +++ b/core/http/react-ui/e2e/chat-model-loading.spec.js @@ -70,15 +70,16 @@ test.describe('Chat - model loading', () => { }) await page.goto('/app/chat') - await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) + await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - await page.locator('.chat-input').fill('Hello') - await page.locator('.chat-send-btn').click() + 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.locator('.chat-staging-progress')).toBeVisible({ timeout: 10_000 }) - await expect(page.locator('.chat-staging-label')).toContainText('nvidia-thor') - await expect(page.locator('.chat-staging-pct')).toContainText('41%') + 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 }) diff --git a/core/http/react-ui/e2e/chat-model-switcher.spec.js b/core/http/react-ui/e2e/chat-model-switcher.spec.js new file mode 100644 index 000000000..2360448fc --- /dev/null +++ b/core/http/react-ui/e2e/chat-model-switcher.spec.js @@ -0,0 +1,145 @@ +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 index eaf1d0fb1..1bb6bdd23 100644 --- a/core/http/react-ui/e2e/chat-pdf-attachment.spec.js +++ b/core/http/react-ui/e2e/chat-pdf-attachment.spec.js @@ -32,7 +32,7 @@ async function openChat(page) { }) }) await page.goto('/app/chat') - await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) + await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) } test.describe('Chat - PDF attachments', () => { @@ -44,15 +44,15 @@ test.describe('Chat - PDF attachments', () => { }) await openChat(page) - await page.locator('input[type=file]').setInputFiles({ + await page.locator('input[type=file][accept*="pdf"]').setInputFiles({ name: 'report.pdf', mimeType: 'application/pdf', buffer: buildPdf('Quarterly revenue grew 42 percent'), }) - await expect(page.locator('.chat-file-name', { hasText: 'report.pdf' })).toBeVisible() + await expect(page.locator('.home-file-tag', { hasText: 'report.pdf' })).toBeVisible() - await page.locator('.chat-input').fill('Summarize') - await page.locator('.chat-send-btn').click() + 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') @@ -62,14 +62,14 @@ test.describe('Chat - PDF attachments', () => { test('rejects a PDF that cannot be parsed instead of attaching garbage', async ({ page }) => { await openChat(page) - await page.locator('input[type=file]').setInputFiles({ + 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('.chat-file-name', { hasText: 'broken.pdf' })).toHaveCount(0) + await expect(page.locator('.home-file-tag', { hasText: 'broken.pdf' })).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 index 62620d0e3..ae40c74f7 100644 --- a/core/http/react-ui/e2e/chat-polling-selection.spec.js +++ b/core/http/react-ui/e2e/chat-polling-selection.spec.js @@ -52,12 +52,12 @@ test.describe('Chat - /api/operations polling (#9904)', () => { const { getOperationsHits } = await setupChatPage(page) await page.goto('/app/chat') - await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) + await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - await page.locator('.chat-input').fill('Hi') - await page.locator('.chat-send-btn').click() + await page.getByTestId('chat-input').fill('Hi') + await page.getByTestId('chat-send').click() - const assistantContent = page.locator('.chat-message-assistant .chat-message-content').first() + 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. @@ -114,12 +114,12 @@ test.describe('Chat - copy button (#9904)', () => { }) await page.goto('/app/chat') - await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 }) + await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 }) - await page.locator('.chat-input').fill('Hi') - await page.locator('.chat-send-btn').click() + await page.getByTestId('chat-input').fill('Hi') + await page.getByTestId('chat-send').click() - const assistantBubble = page.locator('.chat-message-assistant .chat-message-bubble').first() + 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. @@ -135,7 +135,7 @@ test.describe('Chat - copy button (#9904)', () => { } }) - await assistantBubble.locator('.chat-message-actions button').first().click() + 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-settings.spec.js b/core/http/react-ui/e2e/chat-settings.spec.js new file mode 100644 index 000000000..fd9a78a46 --- /dev/null +++ b/core/http/react-ui/e2e/chat-settings.spec.js @@ -0,0 +1,170 @@ +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.' }) + }) + + 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 new file mode 100644 index 000000000..e628315d0 --- /dev/null +++ b/core/http/react-ui/e2e/chat-states.spec.js @@ -0,0 +1,200 @@ +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 new file mode 100644 index 000000000..1361d6133 --- /dev/null +++ b/core/http/react-ui/e2e/chat-thread.spec.js @@ -0,0 +1,237 @@ +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-transcript.spec.js b/core/http/react-ui/e2e/chat-transcript.spec.js index 8164e8652..b095c24f7 100644 --- a/core/http/react-ui/e2e/chat-transcript.spec.js +++ b/core/http/react-ui/e2e/chat-transcript.spec.js @@ -1,55 +1,56 @@ import { test, expect } from './coverage-fixtures.js' +import { chatOf, pair, mockChat, openChat } from './chat-fixtures.js' -// Chat reads as a transcript rather than a bubble thread (mock 04). +// 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 CHAT = { - chats: [{ - id: 'c1', name: 'Transcript', model: 'mock-model', - history: [ - { role: 'user', content: 'Which backends do I have?' }, - { role: 'assistant', content: 'Seven are installed.' }, - ], - }], - activeChatId: 'c1', -} +const THREAD = chatOf('c1', 'Transcript', 'qwen3-8b', pair('Which backends do I have?', 'Seven are installed.')) -test.describe('Chat transcript', () => { +test.describe('Chat thread layout', () => { test.beforeEach(async ({ page }) => { - await page.addInitScript(chat => { - localStorage.setItem('localai_chats_data', JSON.stringify(chat)) - }, CHAT) - await page.goto('/app/chat') + await mockChat(page) + await openChat(page, [THREAD]) + await expect(page.getByTestId('chat-message').first()).toBeVisible() }) - test('neither role is a filled, rounded bubble', async ({ page }) => { - const user = page.locator('.chat-message-user .chat-message-content').first() - await expect(user).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: s.borderTopLeftRadius, shadow: s.boxShadow } + return { radius: parseFloat(s.borderTopLeftRadius), bg: s.backgroundColor, shadow: s.boxShadow } }) - // A rounded filled bubble carries the speaker in shape and side; a - // transcript carries it in words, which survives being read aloud. - expect(cs.radius).toBe('0px') - expect(cs.shadow).toBe('none') + 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('both turns run full width in one column, not left and right', async ({ page }) => { - const user = page.locator('.chat-message-user').first() - const assistant = page.locator('.chat-message-assistant').first() - const [u, a] = [await user.boundingBox(), await assistant.boundingBox()] - expect(Math.abs(u.x - a.x)).toBeLessThan(2) + 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('.chat-message-user .chat-message-model')).toHaveText('You') - await expect(page.locator('.chat-message-assistant .chat-message-model').first()) - .toHaveText('mock-model') + 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('turns are separated by a rule', async ({ page }) => { - const border = await page.locator('.chat-message').first() - .evaluate(el => getComputedStyle(el).borderBottomStyle) - expect(border).toBe('solid') + 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/chrome-audit.spec.js b/core/http/react-ui/e2e/chrome-audit.spec.js index 43dd8525a..fcebdfc34 100644 --- a/core/http/react-ui/e2e/chrome-audit.spec.js +++ b/core/http/react-ui/e2e/chrome-audit.spec.js @@ -1,9 +1,9 @@ import { test, expect } from './coverage-fixtures.js' // A standing guard against the two defects an earlier automated edit left -// scattered through the pages: icons stripped of their fa-* class (which render -// nothing at all), and controls left with the user agent's own chrome, which is -// a pale grey button on a dark ground. +// scattered through the pages: icons that render nothing at all (here, an svg +// whose sprite target is missing or that has no size), and controls left with +// the user agent's own chrome, which is a pale grey button on a dark ground. const ROUTES = [ '/app', '/app/chat', '/app/models', '/app/studio', '/app/talk', '/app/agents', '/app/skills', '/app/collections', '/app/agent-jobs', @@ -37,10 +37,16 @@ test('no page renders a dead icon or a default-chrome control', async ({ page }) out.push(`default-chrome: "${(el.textContent || '').trim().slice(0, 24)}" [${el.className}]`) } } - for (const i of document.querySelectorAll('i')) { - if (!/\bfa-/.test((i.className || '').toString())) { - out.push(`dead-icon: [${i.className}]`) - } + for (const svg of document.querySelectorAll('svg[data-icon]')) { + const use = svg.querySelector('use') + const target = use && document.querySelector(use.getAttribute('href')) + const box = svg.getBoundingClientRect() + if (use && !target) out.push(`dead-icon: no sprite symbol for [${svg.dataset.icon}]`) + if (box.width > 0 && (box.width < 4 || box.height < 4)) out.push(`dead-icon: [${svg.dataset.icon}] is ${box.width}x${box.height}`) + } + // A leftover icon-font element draws nothing now that the font is gone. + for (const i of document.querySelectorAll('i[class*="fa-"], .fas, .far, .fab')) { + out.push(`dead-icon: font class left on [${i.className}]`) } return [...new Set(out)] }) diff --git a/core/http/react-ui/e2e/class-hygiene.spec.js b/core/http/react-ui/e2e/class-hygiene.spec.js index f9a1a9625..6da868edf 100644 --- a/core/http/react-ui/e2e/class-hygiene.spec.js +++ b/core/http/react-ui/e2e/class-hygiene.spec.js @@ -3,18 +3,11 @@ import { readFileSync, readdirSync, statSync } from 'node:fs' import { join, dirname, relative } from 'node:path' import { fileURLToPath } from 'node:url' -// Font Awesome's `fas` / `far` / `fab` classes do not draw an icon on their -// own — they set `font-family: "Font Awesome 6 Free"` and a weight on whatever -// element carries them, and the matching `fa-*` class supplies the glyph -// through ::before. Put them on a