-
+
setOverrideExpanded(v => !v)}
aria-expanded={overrideExpanded}
>
-
+
Override variant for selected nodes…
{overrideExpanded && (
@@ -604,11 +605,11 @@ export default function NodeInstallPicker({
{rowState?.status === 'installing' ? (
- Installing
+ Installing
) : rowState?.status === 'done' ? (
- Installed
+ Installed
) : rowState?.status === 'error' ? (
- Failed
+ Failed
{rowState.error}
) : suit === 'installed' ? (
@@ -627,7 +628,7 @@ export default function NodeInstallPicker({
) : suit === 'override' ? (
- Override
+ Override
) : (
@@ -695,7 +696,7 @@ export default function NodeInstallPicker({
{errorCount > 0 && !submitting && (
- Retry failed nodes
+ Retry failed nodes
)}
@@ -708,7 +709,7 @@ export default function NodeInstallPicker({
disabled={submitting || counts.selected === 0 || showMismatchConfirm}
>
{submitting ? (
- <> Installing…>
+ <> Installing…>
) : (
<>Install on {counts.selected} {counts.selected === 1 ? 'node' : 'nodes'}>
)}
diff --git a/core/http/react-ui/src/components/OperationCard.jsx b/core/http/react-ui/src/components/OperationCard.jsx
index fb6d71bc3..3c2d604aa 100644
--- a/core/http/react-ui/src/components/OperationCard.jsx
+++ b/core/http/react-ui/src/components/OperationCard.jsx
@@ -1,6 +1,9 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { useId, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { formatBytes } from '../utils/format'
+import { cssVars } from '../utils/modelLedger'
+import Icon from './Icon'
const phaseKeys = {
resolving: 'activity.phase.resolving',
@@ -29,8 +32,8 @@ function formatEta(seconds) {
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
}
-export default function OperationCard({ operation, onCancel, onPause, onDismiss, onRetry }) {
- const { t } = useTranslation('admin')
+export default function OperationCard({ operation, onCancel, onPause, onDismiss, onRetry, cancelling = false }) {
+ const { t } = useTranslation('operate')
const nodes = Array.isArray(operation.nodes) ? operation.nodes : []
// Holds only what the user chose. The default has to stay a live
// expression: an operation appears in /api/operations as soon as it is
@@ -49,7 +52,7 @@ export default function OperationCard({ operation, onCancel, onPause, onDismiss,
let icon
let verb
if (failed) {
- icon =
+ icon =
// The failure phrase has to name the work that actually failed. A removal
// or a staging job reported as a failed install describes the opposite of
// what happened, and would make the missing Retry button look like a bug.
@@ -57,13 +60,13 @@ export default function OperationCard({ operation, onCancel, onPause, onDismiss,
else if (operation.taskType === 'staging') verb = t('activity.verb.failedStaging')
else verb = t('activity.verb.failed', { kind })
} else if (operation.isQueued) {
- icon =
+ icon =
verb = t('activity.verb.queued')
} else if (operation.taskType === 'staging') {
- icon =
+ icon =
verb = t('activity.verb.staging')
} else if (operation.isDeletion) {
- icon =
+ icon =
verb = t('activity.verb.removing', { kind })
} else {
icon =
@@ -82,7 +85,9 @@ export default function OperationCard({ operation, onCancel, onPause, onDismiss,
// stopped where it broke and a queued one has not moved, so neither has a
// bar worth drawing.
const showProgress = !failed && !operation.isQueued && operation.progress > 0
- const canCancel = operation.cancellable && !failed
+ // A cancel waiting in its undo window takes the buttons away: pressing Cancel
+ // twice would only end the window early.
+ const canCancel = operation.cancellable && !failed && !cancelling
// Retrying means reconstructing an install call out of the operation, which
// is page knowledge. The card offers the button only when the page handed it
// a handler, so the control can never be present with nothing behind it.
@@ -95,26 +100,26 @@ export default function OperationCard({ operation, onCancel, onPause, onDismiss,
const showNodesList = nodes.length > 0 && (nodesOpen || !showNodesToggle)
return (
-
+
{icon}
{name}
-
- {kind}
-
+ {kind}
{nodes.length > 1 && (
-
- {t('activity.nodeCount', { count: nodes.length })}
-
+ {t('activity.nodeCount', { count: nodes.length })}
)}
{verb}
{operation.nodeName && {t('activity.toNode', { node: operation.nodeName })} }
+ {cancelling && {t('activity.cancelling')} }
{failed && {operation.error} }
{!failed && phaseKey && {t(phaseKey)} }
{/* Phases and byte counters exist only on the managed-artifact
@@ -144,7 +149,7 @@ export default function OperationCard({ operation, onCancel, onPause, onDismiss,
aria-valuemax={100}
aria-label={t('activity.progressLabel', { name })}
>
-
+
)}
@@ -154,7 +159,7 @@ export default function OperationCard({ operation, onCancel, onPause, onDismiss,
{canCancel && (
onPause?.(operation.jobID)}
aria-label={t('activity.pauseLabel', { name })}
>
@@ -166,7 +171,7 @@ export default function OperationCard({ operation, onCancel, onPause, onDismiss,
// identical "Cancel" buttons with nothing to tell them apart.
onCancel?.(operation.jobID)}
aria-label={t('activity.cancelLabel', { name })}
>
@@ -176,7 +181,7 @@ export default function OperationCard({ operation, onCancel, onPause, onDismiss,
{canRetry && (
onRetry(operation)}
aria-label={t('activity.retryLabel', { name })}
>
@@ -186,12 +191,12 @@ export default function OperationCard({ operation, onCancel, onPause, onDismiss,
{failed && (
onDismiss?.(operation.jobID)}
title={t('activity.moveToHistory')}
- aria-label={t('activity.moveToHistory')}
+ aria-label={t('activity.dismissLabel', { name })}
>
-
+ {t('activity.dismiss')}
)}
@@ -207,7 +212,7 @@ export default function OperationCard({ operation, onCancel, onPause, onDismiss,
aria-controls={listId}
onClick={() => setNodesOpenOverride(!nodesOpen)}
>
-
+
{nodesOpen ? t('activity.hideNodes') : t('activity.showNodes', { count: nodes.length })}
)}
@@ -238,7 +243,7 @@ export default function OperationCard({ operation, onCancel, onPause, onDismiss,
)}
{node.percentage > 0 && node.percentage < 100 && (
)}
diff --git a/core/http/react-ui/src/components/OperationsBar.jsx b/core/http/react-ui/src/components/OperationsBar.jsx
index c18b10a31..cccf63fe1 100644
--- a/core/http/react-ui/src/components/OperationsBar.jsx
+++ b/core/http/react-ui/src/components/OperationsBar.jsx
@@ -6,6 +6,7 @@ import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useOperations } from '../hooks/useOperations'
import { formatBytes } from '../utils/format'
+import Icon from './Icon'
const artifactPhaseKeys = {
resolving: 'activity.phase.resolving',
@@ -88,7 +89,7 @@ export default function OperationsBar() {
let verb = ''
if (isFinished) {
modifier = 'operations-strip--done'
- icon =
+ icon =
// The completion phrase has to match the work that just ended: a removal
// that reports "Installed" reads as the opposite of what happened.
if (shown.isDeletion) verb = t('activity.verb.removed', { kind })
@@ -96,7 +97,7 @@ export default function OperationsBar() {
else verb = t('activity.verb.installed', { kind })
} else if (shown.error) {
modifier = 'operations-strip--error'
- icon =
+ icon =
// Same split as the card, so the two surfaces never describe one failed
// job differently: a removal reported as a failed install is the opposite
// of what happened.
@@ -105,15 +106,15 @@ export default function OperationsBar() {
else verb = t('activity.verb.failed', { kind })
} else if (shown.isQueued) {
modifier = 'operations-strip--queued'
- icon =
+ icon =
verb = t('activity.verb.queued')
} else if (shown.taskType === 'staging') {
modifier = 'operations-strip--staging'
- icon =
+ icon =
verb = t('activity.verb.staging')
} else if (shown.isDeletion) {
modifier = 'operations-strip--removing'
- icon =
+ icon =
verb = t('activity.verb.removing', { kind })
} else {
icon =
@@ -201,7 +202,7 @@ export default function OperationsBar() {
title={shown.error ? t('activity.moveToHistory') : t('activity.hide')}
aria-label={shown.error ? t('activity.moveToHistory') : t('activity.hide')}
>
-
+
)
diff --git a/core/http/react-ui/src/components/PageHeader.jsx b/core/http/react-ui/src/components/PageHeader.jsx
index 59146bdb2..761b463c5 100644
--- a/core/http/react-ui/src/components/PageHeader.jsx
+++ b/core/http/react-ui/src/components/PageHeader.jsx
@@ -1,20 +1,10 @@
-import { useLocation } from 'react-router-dom'
-import { useTranslation } from 'react-i18next'
-import { sectionKeyForPath } from '../utils/section'
-
-// Editorial page header: left eyebrow + title + supporting line, right-aligned
-// meta/actions slot. The eyebrow defaults to the page's section/console name
-// (derived from the route) so headers stay consistent without per-page wiring;
-// pass `eyebrow` to override, or `eyebrow={null}` to suppress it.
-export default function PageHeader({ eyebrow, title, supporting, actions, className = '' }) {
- const { t } = useTranslation('nav')
- const { pathname } = useLocation()
- const autoKey = sectionKeyForPath(pathname)
- const resolvedEyebrow = eyebrow !== undefined ? eyebrow : (autoKey ? t(autoKey) : null)
+// Page header: title, supporting line, and a right-aligned meta/actions slot.
+// There is no eyebrow or breadcrumb above the title: the sidebar and the hub
+// tab bar already say where the page lives, so the title stands alone.
+export default function PageHeader({ title, supporting, actions, className = '' }) {
return (
- {resolvedEyebrow &&
{resolvedEyebrow} }
{title &&
{title} }
{supporting &&
{supporting}
}
diff --git a/core/http/react-ui/src/components/PatternListEditor.jsx b/core/http/react-ui/src/components/PatternListEditor.jsx
index e0122286a..bcff8151a 100644
--- a/core/http/react-ui/src/components/PatternListEditor.jsx
+++ b/core/http/react-ui/src/components/PatternListEditor.jsx
@@ -1,5 +1,6 @@
import { useMemo } from 'react'
import SearchableSelect from './SearchableSelect'
+import Icon from './Icon'
// Editor for a pattern detector's pii_detection.patterns: a list of
// operator-defined secret patterns. Value is an array of
@@ -93,13 +94,13 @@ export default function PatternListEditor({ value, onChange }) {
onClick={() => remove(i)}
style={{ padding: '2px 8px', fontSize: '0.75rem' }}
aria-label="Remove pattern">
-
+
))}
- Add pattern
+ Add pattern
)
diff --git a/core/http/react-ui/src/components/Popover.jsx b/core/http/react-ui/src/components/Popover.jsx
index ff61dc323..b9b9ca02e 100644
--- a/core/http/react-ui/src/components/Popover.jsx
+++ b/core/http/react-ui/src/components/Popover.jsx
@@ -93,7 +93,7 @@ export default function Popover({ anchor, open, onClose, children, ariaLabel })
ref={popoverRef}
role="dialog"
aria-label={ariaLabel}
- className="popover card"
+ className={`popover card${pos.flipped ? ' popover--up' : ''}`}
style={{ top: pos.top, left: pos.left }}
>
{children}
diff --git a/core/http/react-ui/src/components/RecommendedModels.jsx b/core/http/react-ui/src/components/RecommendedModels.jsx
index e42205945..c54301878 100644
--- a/core/http/react-ui/src/components/RecommendedModels.jsx
+++ b/core/http/react-ui/src/components/RecommendedModels.jsx
@@ -2,22 +2,27 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { modelsApi } from '../utils/api'
import { useRecommendedModels, isNvfp4Name } from '../hooks/useRecommendedModels'
+import Icon from './Icon'
const CONTENT_ID = 'rec-models-content'
-// "Recommended for your hardware" at the top of Models Explore's zero state. Shares
-// the hardware-fit ranking with the empty-state starter widget via
+// "Best for this machine": a short shelf in the Models page's empty inspector.
+// It shares the hardware-fit ranking with the empty-state starter widget via
// useRecommendedModels.
//
-// It is a section rather than a dismissable card. This is the one thing the
-// page has to say about the machine it is running on: hiding it behind a close
-// button treated it as an interruption, and a box with its own border read as
-// something bolted onto a pane that is otherwise hairline sections. The
-// collapse and dismissal state, and their storage keys, are gone with it.
-export default function RecommendedModels({ addToast }) {
+// There is no recommendation endpoint. The hook ranks the chat gallery against
+// the host's resources and the per-model estimate, so the shelf is only as
+// good as those two reads and renders nothing when they give no fit.
+//
+// It is a section rather than a dismissable card: the one thing the page has
+// to say about the machine it runs on is not an interruption to be closed.
+// Once something is installed the person has started, so the shelf narrows to
+// the best fit and keeps the rest one click away (progressive disclosure).
+export default function RecommendedModels({ addToast, installedCount = 0 }) {
const { t } = useTranslation('models')
const { recommended, tier, loading } = useRecommendedModels({ count: 4 })
const [installing, setInstalling] = useState(() => new Set())
+ const [expanded, setExpanded] = useState(false)
if (loading) return null
if (!recommended || recommended.length === 0) return null
@@ -38,51 +43,62 @@ export default function RecommendedModels({ addToast }) {
}
const isGpu = tier.id !== 'cpu'
+ const narrowed = installedCount > 0 && recommended.length > 1
+ const visible = narrowed && !expanded ? recommended.slice(0, 1) : recommended
return (
- // A section, not a card. This sits inside a pane that is otherwise hairline
- // sections, so a bordered, dismissable box read as something bolted on —
- // and the one thing the page has to say about this host is not an
- // interruption to be closed.
-
-
- {t('recommended.title')}
-
-
+
+
{t('recommended.title')}
+
{isGpu ? t('recommended.gpuNote') : t('recommended.cpuNote')}
-
+
- {recommended.map((m, i) => {
+ {visible.map((m, i) => {
const busy = installing.has(m.name)
+ const vram = isGpu && m.vramDisplay ? m.vramDisplay : ''
return (
-
- {i === 0 ? t('recommended.bestFit') : t('recommended.alternative')}
-
- {m.name}
-
- {isNvfp4Name(m.name) && NVFP4 }
- {m.sizeDisplay}
-
-
- {isGpu && m.vramDisplay ? m.vramDisplay : ''}
-
+
+
+ {i === 0 ? t('recommended.bestFit') : t('recommended.alternative')}
+
+ {m.name}
+
+ {isNvfp4Name(m.name) && NVFP4 }
+ {m.sizeDisplay}
+ {vram && {t('recommended.needs', { vram })} }
+
+
install(m.name)}
>
{busy
- ? (<> {t('recommended.installing')}>)
- : (<> {t('recommended.install')}>)}
+ ? (<> {t('recommended.installing')}>)
+ : (<> {t('recommended.install')}>)}
)
})}
+ {narrowed && (
+ setExpanded(v => !v)}
+ >
+ {expanded
+ ? t('recommended.fewer')
+ : t('recommended.more', { count: recommended.length - 1 })}
+
+ )}
)
}
diff --git a/core/http/react-ui/src/components/RequireFeature.jsx b/core/http/react-ui/src/components/RequireFeature.jsx
index 97823cb0f..f9cf2f5e8 100644
--- a/core/http/react-ui/src/components/RequireFeature.jsx
+++ b/core/http/react-ui/src/components/RequireFeature.jsx
@@ -1,10 +1,12 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
-export default function RequireFeature({ feature, children }) {
+// `disabled` is shown instead of the redirect when the viewer lacks the feature,
+// for pages that should say what is off and what turns it on.
+export default function RequireFeature({ feature, disabled, children }) {
const { isAdmin, hasFeature, authEnabled, user, loading } = useAuth()
if (loading) return null
if (authEnabled && !user) return
- if (!isAdmin && !hasFeature(feature)) return
+ if (!isAdmin && !hasFeature(feature)) return disabled ||
return children
}
diff --git a/core/http/react-ui/src/components/ResourceCards.jsx b/core/http/react-ui/src/components/ResourceCards.jsx
index c90353907..6f20be9f1 100644
--- a/core/http/react-ui/src/components/ResourceCards.jsx
+++ b/core/http/react-ui/src/components/ResourceCards.jsx
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { getArtifactIcon, inferMetadataType } from '../utils/artifacts'
import { apiUrl } from '../utils/basePath'
+import Icon from './Icon'
export default function ResourceCards({ metadata, onOpenArtifact, messageIndex, agentName }) {
const [expanded, setExpanded] = useState(false)
@@ -49,7 +50,7 @@ export default function ResourceCards({ metadata, onOpenArtifact, messageIndex,
{item.type === 'image' ? (
) : (
-
+
)}
{item.title}
diff --git a/core/http/react-ui/src/components/ResourceMonitor.jsx b/core/http/react-ui/src/components/ResourceMonitor.jsx
deleted file mode 100644
index 81161113d..000000000
--- a/core/http/react-ui/src/components/ResourceMonitor.jsx
+++ /dev/null
@@ -1,152 +0,0 @@
-import { useResources } from '../hooks/useResources'
-import { formatBytes, percentColor, vendorColor } from '../utils/format'
-
-export default function ResourceMonitor() {
- const { resources, loading, error } = useResources()
-
- return (
-
- )
-}
-
-export function ResourceMonitorView({
- resources,
- loading = false,
- unavailable = false,
- title = 'System Resources',
- loadingText = 'Loading resources...',
- unavailableText = 'Resource data unavailable',
- emptyText = 'No resource data reported',
- copy = {},
- testId,
-}) {
- if (loading) {
- return {loadingText}
- }
-
- if (unavailable || !resources) {
- return {unavailableText}
- }
-
- const gpus = resources.gpus || []
- const aggregate = resources.aggregate || {}
- const ram = resources.ram || aggregate.ram || {}
- const isGpu = resources.type === 'gpu' && gpus.length > 0
- const hasRam = ram.total != null || ram.total_bytes != null || ram.used != null || ram.used_bytes != null || ram.usage_percent != null
- const hasStorage = resources.storage_size != null
- const labels = {
- gpuCount: count => `${count} GPUs`,
- reclaimer: 'Reclaimer Active',
- used: 'Used',
- total: 'Total',
- systemRam: 'System RAM',
- memory: 'Memory',
- totalVram: 'Total VRAM',
- storage: 'Models storage',
- ...copy,
- }
-
- if (!isGpu && !hasRam && !hasStorage) {
- return {emptyText}
- }
-
- return (
-
-
-
- {title}
-
-
- {isGpu && gpus.length > 1 && (
- {labels.gpuCount(gpus.length)}
- )}
- {resources.reclaimer_enabled && (
- {labels.reclaimer}
- )}
-
-
-
- {isGpu ? (
-
- {gpus.map((gpu, i) => {
- const pct = gpu.usage_percent || 0
- const color = percentColor(pct)
- const vColor = vendorColor(gpu.vendor)
- return (
-
-
-
- {gpu.name || `GPU ${i}`}
-
- {gpu.vendor && (
-
- {gpu.vendor}
-
- )}
-
-
-
-
- {pct.toFixed(0)}%
-
-
-
- {labels.used}: {formatBytes(gpu.used_vram)}
- {labels.total}: {formatBytes(gpu.total_vram)}
-
-
- )
- })}
-
- ) : hasRam ? (
- /* RAM display */
-
-
- {labels.systemRam}
-
- {labels.memory}
-
-
-
-
-
- {(ram.usage_percent || 0).toFixed(0)}%
-
-
-
- {labels.used}: {formatBytes(ram.used ?? ram.used_bytes ?? 0)}
- {labels.total}: {formatBytes(ram.total ?? ram.total_bytes ?? 0)}
-
-
- ) : null}
-
- {/* Aggregate for multi-GPU */}
- {isGpu && aggregate.gpu_count > 1 && (
-
- {labels.totalVram}
-
- {formatBytes(aggregate.used_memory)} / {formatBytes(aggregate.total_memory)} ({aggregate.usage_percent?.toFixed(1)}%)
-
-
- )}
-
- {/* Storage */}
- {resources.storage_size != null && (
-
- {labels.storage}
-
- {formatBytes(resources.storage_size)}
-
-
- )}
-
- )
-}
diff --git a/core/http/react-ui/src/components/ResourceRow.jsx b/core/http/react-ui/src/components/ResourceRow.jsx
index 09fa20ed9..3c09098b6 100644
--- a/core/http/react-ui/src/components/ResourceRow.jsx
+++ b/core/http/react-ui/src/components/ResourceRow.jsx
@@ -1,4 +1,5 @@
import { Fragment } from 'react'
+import Icon from './Icon'
// ResourceRow renders the visible row + its conditional detail row as a pair
// of s, so the existing .table styling keeps applying and the Manage page
@@ -46,7 +47,7 @@ export function ChevronCell({ expanded }) {
return (
-
+
)
@@ -54,16 +55,16 @@ export function ChevronCell({ expanded }) {
// IconCell renders the 48px brand icon shell — the same one the Install
// gallery uses. `icon` is the image URL (from gallery metadata); when absent
-// or broken we fall back to a FontAwesome glyph so custom-imported items
+// or broken we fall back to an icon so custom-imported items
// still get a placeholder instead of an empty square.
-export function IconCell({ icon, fallback = 'fa-cube', alt = '' }) {
+export function IconCell({ icon, fallback = 'cube', alt = '' }) {
return (
{icon ? (
) : (
-
+
)}
diff --git a/core/http/react-ui/src/components/RouterCandidatesEditor.jsx b/core/http/react-ui/src/components/RouterCandidatesEditor.jsx
index a90bc3a58..e3116da13 100644
--- a/core/http/react-ui/src/components/RouterCandidatesEditor.jsx
+++ b/core/http/react-ui/src/components/RouterCandidatesEditor.jsx
@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import { useFormContext } from '../contexts/FormContext'
import SearchableModelSelect from './SearchableModelSelect'
+import Icon from './Icon'
// RouterCandidatesEditor renders the routing table for a router model.
// Each row binds a downstream model to a SET of policy labels it can
@@ -63,7 +64,7 @@ export default function RouterCandidatesEditor({ value, onChange }) {
className="btn btn-secondary btn-sm self-start"
onClick={add}
>
- Add candidate
+ Add candidate
)
@@ -103,7 +104,7 @@ function CandidateRow({ index, total, row, knownLabels, knownLabelSet, onChange,
title="Move up (smaller priority)"
style={{ padding: '0 6px' }}
>
-
+
-
+
{index === 0 ? 'tried first' : index === total - 1 ? 'tried last (fallback-class)' : ''}
@@ -132,7 +133,7 @@ function CandidateRow({ index, total, row, knownLabels, knownLabelSet, onChange,
onClick={onRemove}
title="Remove candidate"
>
-
+
diff --git a/core/http/react-ui/src/components/RouterPoliciesEditor.jsx b/core/http/react-ui/src/components/RouterPoliciesEditor.jsx
index 7b5416370..1fd8a7fa6 100644
--- a/core/http/react-ui/src/components/RouterPoliciesEditor.jsx
+++ b/core/http/react-ui/src/components/RouterPoliciesEditor.jsx
@@ -1,4 +1,5 @@
import { useMemo } from 'react'
+import Icon from './Icon'
// RouterPoliciesEditor renders the label vocabulary the score
// classifier ranks for each request. The shape mirrors
@@ -58,7 +59,7 @@ export default function RouterPoliciesEditor({ value, onChange }) {
className="btn btn-secondary btn-sm self-start"
onClick={add}
>
- Add policy
+ Add policy
)
@@ -98,7 +99,7 @@ function PolicyRow({ row, duplicate, onChange, onRemove }) {
onClick={onRemove}
title="Remove policy"
>
-
+
)
diff --git a/core/http/react-ui/src/components/SearchableSelect.jsx b/core/http/react-ui/src/components/SearchableSelect.jsx
index 9f154e1c3..f302d7a98 100644
--- a/core/http/react-ui/src/components/SearchableSelect.jsx
+++ b/core/http/react-ui/src/components/SearchableSelect.jsx
@@ -1,10 +1,11 @@
import { useState, useEffect, useRef, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
+import Icon from './Icon'
export default function SearchableSelect({
value, onChange, options, placeholder = 'Select...',
allOption, searchPlaceholder = 'Search...',
- disabled = false, style, className = '',
+ disabled = false, style, className = '', triggerClassName = '',
}) {
const { t } = useTranslation('common')
const [open, setOpen] = useState(false)
@@ -113,12 +114,12 @@ export default function SearchableSelect({
{ if (!disabled) { setOpen(!open); setQuery(''); setFocusIndex(-1) } }}
- style={{
+ style={triggerClassName ? undefined : {
width: '100%', padding: 'var(--spacing-xs) var(--spacing-sm)', fontSize: '0.8125rem',
cursor: disabled ? 'not-allowed' : 'pointer',
display: 'flex', alignItems: 'center', gap: '6px',
@@ -128,8 +129,8 @@ export default function SearchableSelect({
opacity: disabled ? 0.5 : 1,
}}
>
- {displayLabel}
-
+ {displayLabel}
+
{open && (
{
+ const opener = document.activeElement
+ const sheet = sheetRef.current
+ const first = sheet?.querySelector('.dk-sheet-body input:not([disabled]), .dk-sheet-body button:not([disabled]), .dk-sheet-body select')
+ ;(first || sheet?.querySelector('button'))?.focus()
+ const onKey = (e) => {
+ if (e.key === 'Escape') {
+ if (e.target?.closest?.('[role="listbox"]') || e.target?.getAttribute?.('aria-expanded') === 'true') return
+ e.preventDefault(); e.stopPropagation(); closeRef.current(); return
+ }
+ if (e.key !== 'Tab' || !sheet) return
+ const focusable = Array.from(sheet.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'))
+ if (focusable.length === 0) return
+ const head = focusable[0]
+ const last = focusable[focusable.length - 1]
+ if (e.shiftKey && document.activeElement === head) { e.preventDefault(); last.focus() }
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); head.focus() }
+ }
+ document.addEventListener('keydown', onKey, true)
+ return () => {
+ document.removeEventListener('keydown', onKey, true)
+ if (opener && document.contains(opener)) opener.focus?.()
+ }
+ }, [])
+
+ return createPortal(
+
+
e.stopPropagation()}
+ >
+
+
+
+
{title}
+ {description &&
{description}
}
+
+
+
+
{children}
+ {footer &&
{footer}
}
+
+
,
+ document.body,
+ )
+}
diff --git a/core/http/react-ui/src/components/Sidebar.jsx b/core/http/react-ui/src/components/Sidebar.jsx
index ade36e267..46722fa02 100644
--- a/core/http/react-ui/src/components/Sidebar.jsx
+++ b/core/http/react-ui/src/components/Sidebar.jsx
@@ -7,33 +7,31 @@ import { useAuth } from '../context/AuthContext'
import { useBranding } from '../contexts/BrandingContext'
import { apiUrl } from '../utils/basePath'
import { preloadRoute } from '../router'
-import { consoles, firstVisiblePath, consolePaths } from './console/consoleConfig'
+import { hubs, hubEntryPath, hubOwnsPath } from './hub/hubConfig'
+import { topDestinations, createDestinations } from '../utils/destinations'
import { useOperations } from '../hooks/useOperations'
+import Icon from './Icon'
const COLLAPSED_KEY = 'localai_sidebar_collapsed'
const SECTIONS_KEY = 'localai_sidebar_sections'
-const topItems = [
- { path: '/app', icon: 'fas fa-home', labelKey: 'items.home' },
- { path: '/app/models', icon: 'fas fa-cubes', labelKey: 'items.models', adminOnly: true },
-]
+// Create stays inline (frequent, one-click creative destinations). Build and
+// Operate sit under Workspace as single entries; each opens a hub whose tab
+// bar lives in hub/hubConfig.js (shared with HubLayout). The inline entries
+// are shared with the 404 page, which lists the same destinations.
+const topItems = topDestinations
-// Create stays inline (frequent, one-click creative destinations). The Build
-// and Operate tiers are single entries that open a secondary console rail —
-// their items live in console/consoleConfig.js (shared with ConsoleLayout).
const sections = [
{
id: 'create',
titleKey: 'sections.create',
- items: [
- { path: '/app/chat', icon: 'fas fa-comments', labelKey: 'items.chat' },
- { path: '/app/studio', icon: 'fas fa-palette', labelKey: 'items.studio' },
- { path: '/app/talk', icon: 'fas fa-phone', labelKey: 'items.talk' },
- ],
+ items: createDestinations,
},
+ // Items come from the hubs (hubConfig.js) and carry their own gating.
+ { id: 'workspace', titleKey: 'sections.workspace', hubs: true },
]
-function NavItem({ item, onClose, collapsed }) {
+function NavItem({ item, onClose, collapsed, active, badge }) {
const { t } = useTranslation('nav')
const label = t(item.labelKey)
// Warm the route's lazy chunk before the user clicks. Touch fires ~150ms
@@ -45,7 +43,7 @@ function NavItem({ item, onClose, collapsed }) {
to={item.path}
end={item.path === '/app'}
className={({ isActive }) =>
- `nav-item ${isActive ? 'active' : ''}`
+ `nav-item ${(active ?? isActive) ? 'active' : ''}`
}
onClick={onClose}
onMouseEnter={preload}
@@ -53,8 +51,9 @@ function NavItem({ item, onClose, collapsed }) {
onTouchStart={preload}
title={collapsed ? label : undefined}
>
-
+
{label}
+ {badge}
)
}
@@ -120,6 +119,7 @@ export default function Sidebar({ isOpen, onClose }) {
// Auto-expand section containing the active route
useEffect(() => {
for (const section of sections) {
+ if (!section.items) continue
const match = section.items.some(item => location.pathname.startsWith(item.path))
if (match && !openSections[section.id]) {
setOpenSections(prev => {
@@ -159,17 +159,25 @@ export default function Sidebar({ isOpen, onClose }) {
}
const visibleTopItems = topItems.filter(filterItem)
- // Shared shape for the console gating helpers (consoleConfig.js).
+ // Shared shape for the hub gating helpers (hubConfig.js).
const auth = { isAdmin, authEnabled, hasFeature, features }
- // One badge, on the always-visible sidebar entry. The console rail only
- // exists while the user is on an Operate route and can be collapsed, so
- // badging the rail item instead would let the count disappear entirely.
+ // One badge, on the always-visible sidebar entry. The Operate tab bar only
+ // exists while the user is on an Operate route, so badging a tab instead
+ // would let the count disappear entirely.
const failedOps = operations.filter((op) => op.error).length
const activeOps = operations.length
- // Inline sections (Create) carry no gating; a plain filterItem pass suffices.
- const getVisibleSectionItems = (section) => section.items.filter(filterItem)
+ // Create carries no gating beyond filterItem. Workspace lists one entry per
+ // hub the viewer can use; its target is the hub's overview.
+ const getVisibleSectionItems = (section) => {
+ if (!section.hubs) return section.items.filter(filterItem)
+ return hubs.flatMap(hub => {
+ const path = hubEntryPath(hub, auth)
+ if (!path) return []
+ return [{ path, icon: hub.icon, labelKey: hub.titleKey, hub }]
+ })
+ }
return (
<>
@@ -194,7 +202,7 @@ export default function Sidebar({ isOpen, onClose }) {
onClick={onClose}
aria-label={t('closeMenu')}
>
-
+
@@ -224,12 +232,23 @@ export default function Sidebar({ isOpen, onClose }) {
title={collapsed ? sectionTitle : undefined}
>
{sectionTitle}
-
+
{showItems && (
{visibleItems.map(item => (
-
+ 0 ? (
+ 0 ? ' nav-badge--error' : ''}`}>
+ {failedOps > 0 ? failedOps : activeOps}
+
+ ) : null}
+ />
))}
)}
@@ -237,35 +256,6 @@ export default function Sidebar({ isOpen, onClose }) {
)
})}
- {/* Console tiers (Build, Operate): a single entry that opens a
- secondary rail. Hidden when the viewer can see none of its items. */}
- {consoles.map(config => {
- const target = firstVisiblePath(config, auth)
- if (!target) return null
- const active = consolePaths(config).some(p => location.pathname.startsWith(p))
- const label = t(config.titleKey)
- return (
-
- `nav-item ${active ? 'active' : ''}`}
- onClick={onClose}
- onMouseEnter={() => preloadRoute(target)}
- onFocus={() => preloadRoute(target)}
- onTouchStart={() => preloadRoute(target)}
- title={collapsed ? label : undefined}
- >
-
- {label}
- {config.groups.some(g => g.items.some(i => i.badge === 'operations')) && activeOps > 0 && (
- 0 ? ' nav-badge--error' : ''}`}>
- {failedOps > 0 ? failedOps : activeOps}
-
- )}
-
-
- )
- })}
{/* Footer */}
@@ -283,12 +273,12 @@ export default function Sidebar({ isOpen, onClose }) {
{user.avatarUrl ? (
) : (
-
+
)}
{user.name || user.email}
-
+
)}
@@ -299,7 +289,7 @@ export default function Sidebar({ isOpen, onClose }) {
onClick={toggleCollapse}
title={collapsed ? t('expandSidebar') : t('collapseSidebar')}
>
-
+
diff --git a/core/http/react-ui/src/components/Sparkline.jsx b/core/http/react-ui/src/components/Sparkline.jsx
deleted file mode 100644
index 372cd8b5f..000000000
--- a/core/http/react-ui/src/components/Sparkline.jsx
+++ /dev/null
@@ -1,40 +0,0 @@
-// A bare trend line: stroke, no fill, no axes, no gridlines.
-//
-// It sits under a figure that already states the value, so its only job is to
-// say what shape got us here. An emphasised endpoint marks "now", because the
-// most recent point is the one being read.
-//
-// aria-hidden: the number above it is the accessible content, and a
-// twelve-point series read aloud is noise.
-export default function Sparkline({ points, tone = 'primary', width = 120, height = 28 }) {
- if (!Array.isArray(points) || points.length < 2) return null
-
- const max = Math.max(...points)
- // A flat run of zeroes would otherwise divide by zero and draw nothing;
- // pinning it to the baseline is the honest picture of "no traffic".
- const scale = max > 0 ? max : 1
- const step = width / (points.length - 1)
- const y = v => height - 2 - (v / scale) * (height - 4)
-
- const path = points.map((v, i) => `${(i * step).toFixed(1)},${y(v).toFixed(1)}`).join(' ')
- const lastX = width
- const lastY = y(points[points.length - 1])
-
- return (
-
-
-
-
- )
-}
diff --git a/core/http/react-ui/src/components/StarterModels.jsx b/core/http/react-ui/src/components/StarterModels.jsx
index abeabfa5c..0aa709632 100644
--- a/core/http/react-ui/src/components/StarterModels.jsx
+++ b/core/http/react-ui/src/components/StarterModels.jsx
@@ -2,6 +2,9 @@ import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { modelsApi } from '../utils/api'
import { useRecommendedModels, isNvfp4Name } from '../hooks/useRecommendedModels'
+import { useOperations } from '../hooks/useOperations'
+import { fillStyle } from './home/memory'
+import Icon from './Icon'
// Offline CPU suggestions do not claim a measured GPU fit.
const CPU_FALLBACK = [
@@ -14,6 +17,7 @@ const CPU_FALLBACK = [
export default function StarterModels({ addToast, onInstallStarted }) {
const { t } = useTranslation('home')
const { recommended, tier, loading } = useRecommendedModels({ count: 4 })
+ const { operations } = useOperations()
const [installing, setInstalling] = useState(() => new Set())
// While the hardware probe + gallery query are in flight, render nothing
@@ -46,11 +50,11 @@ export default function StarterModels({ addToast, onInstallStarted }) {
}
return (
-
+
- {t('starters.title')}
+ {t('starters.title')}
-
+
{t(`starters.tier.${tier.id}`)}
@@ -58,23 +62,43 @@ export default function StarterModels({ addToast, onInstallStarted }) {
{tier.id === 'cpu' ? t('starters.cpuNote') : t('starters.gpuNote')}
- {items.map(c => {
- const busy = installing.has(c.name)
+ {items.map((c, i) => {
+ // The install shows up in the operations list once the server has
+ // taken it. Its progress is the only progress there is to show.
+ const op = operations.find(o => o.name === c.name && !o.isBackend && !o.isDeletion)
+ const busy = installing.has(c.name) || !!op
return (
- {c.name}
- {isNvfp4Name(c.name) && NVFP4 }
- {c.size && {c.size} }
+
+ {c.name}
+
+ {c.size}
+ {isNvfp4Name(c.name) && NVFP4 }
+
+
install(c.name)}
>
{busy
- ? (<> {t('starters.installing')}>)
- : (<> {t('starters.install')}>)}
+ ? (<> {t('starters.installing')}>)
+ : (<> {t('starters.install')}>)}
+ {op && op.progress > 0 && (
+
+
+
+ )}
)
})}
diff --git a/core/http/react-ui/src/components/StatCard.jsx b/core/http/react-ui/src/components/StatCard.jsx
index a6e1d95f8..6c9bc8dd9 100644
--- a/core/http/react-ui/src/components/StatCard.jsx
+++ b/core/http/react-ui/src/components/StatCard.jsx
@@ -1,3 +1,5 @@
+import Icon from './Icon'
+
// StatCard renders a single cluster/dashboard metric card. The left accent
// bar + icon chip color is driven by `accentVar` (a CSS custom property name,
// e.g. "--color-success") so the card reads as semantic without the caller
@@ -32,7 +34,7 @@ export default function StatCard({ icon, label, value, color, accentVar, onClick
{value}
-
+
)
diff --git a/core/http/react-ui/src/components/TemplateSelector.jsx b/core/http/react-ui/src/components/TemplateSelector.jsx
index 799508dfa..d1b4f1dc6 100644
--- a/core/http/react-ui/src/components/TemplateSelector.jsx
+++ b/core/http/react-ui/src/components/TemplateSelector.jsx
@@ -1,61 +1,32 @@
import MODEL_TEMPLATES from '../utils/modelTemplates'
+import Icon from './Icon'
+// The ways to start a model config, read top to bottom. A list rather than a
+// grid of equal cards: these are alternatives to scan, and the field names
+// each one pre-fills sit on one muted line instead of a cloud of chips.
export default function TemplateSelector({ onSelect }) {
return (
-
-
+
+
Choose a template to get started. You can add or remove fields in the next step.
-
-
+
)
}
diff --git a/core/http/react-ui/src/components/ThemeToggle.jsx b/core/http/react-ui/src/components/ThemeToggle.jsx
index 964e37917..a42ac2cda 100644
--- a/core/http/react-ui/src/components/ThemeToggle.jsx
+++ b/core/http/react-ui/src/components/ThemeToggle.jsx
@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next'
import { useTheme } from '../contexts/ThemeContext'
+import Icon from './Icon'
export default function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
@@ -14,7 +15,7 @@ export default function ThemeToggle() {
aria-label={label}
>
{/* key on theme so the icon remounts and replays the rotate/fade */}
-
+
)
}
diff --git a/core/http/react-ui/src/components/ThreeDHistory.jsx b/core/http/react-ui/src/components/ThreeDHistory.jsx
deleted file mode 100644
index e135b4781..000000000
--- a/core/http/react-ui/src/components/ThreeDHistory.jsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import { memo, useState } from 'react'
-import { relativeTime } from '../utils/format'
-import { useTranslation } from 'react-i18next'
-
-// ThreeDHistory — sibling of MediaHistory for IndexedDB-backed 3D entries
-// (see use3DHistory). Reuses MediaHistory's markup, CSS classes, and testids
-// so styling and e2e helpers carry over; the differences are the entry shape
-// (input-image thumbnail, quality subtitle) and the Blob-backed source.
-// Deliberately a plain vertical list — no showcase/gallery mode.
-export default memo(function ThreeDHistory({ entries, selectedId, onSelect, onDelete, onClearAll }) {
- const { t } = useTranslation('media')
- const [expanded, setExpanded] = useState(true)
-
- return (
-
-
setExpanded(!expanded)}
- style={{ display: 'flex', alignItems: 'center' }}
- >
-
- {t('history.title')} ({entries.length})
- {entries.length > 0 && (
- { e.stopPropagation(); onClearAll() }}
- >
-
-
- )}
-
- {expanded && (
-
- {entries.length === 0 ? (
-
{t('history.empty')}
- ) : (
- entries.map(entry => (
-
onSelect(entry.id)}
- data-testid="media-history-item"
- >
-
- {entry.inputThumb ? (
-
- ) : (
-
- )}
-
-
-
- {entry.inputs?.prompt?.data || entry.name || entry.model}
- {relativeTime(entry.createdAt)}
-
-
- {entry.params?.quality ? `${entry.params.quality} · ` : ''}{entry.model}
-
-
-
{ e.stopPropagation(); onDelete(entry.id) }}
- data-testid="media-history-delete"
- >
-
-
-
- ))
- )}
-
- )}
-
- )
-})
diff --git a/core/http/react-ui/src/components/Toast.jsx b/core/http/react-ui/src/components/Toast.jsx
index 7b9cdb5c2..1cc5b7290 100644
--- a/core/http/react-ui/src/components/Toast.jsx
+++ b/core/http/react-ui/src/components/Toast.jsx
@@ -1,5 +1,6 @@
import { useState, useCallback, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
+import Icon from './Icon'
let toastId = 0
@@ -31,10 +32,10 @@ export function useToast() {
}
const iconMap = {
- success: 'fa-circle-check',
- error: 'fa-circle-exclamation',
- warning: 'fa-triangle-exclamation',
- info: 'fa-circle-info',
+ success: 'check-circle',
+ error: 'alert-circle',
+ warning: 'warning',
+ info: 'info',
}
const colorMap = {
@@ -69,7 +70,7 @@ function ToastItem({ toast, onRemove }) {
return (
-
+
{toast.message}
{toast.link && (
{toast.link.text}
@@ -79,7 +80,7 @@ function ToastItem({ toast, onRemove }) {
className="toast-close"
aria-label={t('actions.close')}
>
-
+
)
diff --git a/core/http/react-ui/src/components/UnifiedMCPDropdown.jsx b/core/http/react-ui/src/components/UnifiedMCPDropdown.jsx
index a903a13fd..d9ca0ea46 100644
--- a/core/http/react-ui/src/components/UnifiedMCPDropdown.jsx
+++ b/core/http/react-ui/src/components/UnifiedMCPDropdown.jsx
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import { loadClientMCPServers, addClientMCPServer, removeClientMCPServer } from '../utils/mcpClientStorage'
+import Icon from './Icon'
export default function UnifiedMCPDropdown({
// Server MCP props
@@ -120,7 +121,7 @@ export default function UnifiedMCPDropdown({
title="MCP servers, prompts, and resources"
onClick={handleOpen}
>
-
MCP
+
MCP
{totalBadge > 0 && (
{totalBadge}
)}
@@ -144,7 +145,7 @@ export default function UnifiedMCPDropdown({
{/* Servers tab */}
{activeTab === 'servers' && serverMCPAvailable && (
mcpServersLoading ? (
-
Loading servers...
+
Loading servers...
) : serverListError ? (
Failed to discover MCP servers: {serverListError}
) : mcpServerList.length === 0 ? (
@@ -190,7 +191,7 @@ export default function UnifiedMCPDropdown({
Client MCP Servers
setAddDialog(!addDialog)}>
- Add
+ Add
{addDialog && (
@@ -244,7 +245,7 @@ export default function UnifiedMCPDropdown({
{server.name}
- {server.headers?.Authorization && }
+ {server.headers?.Authorization && }
{status === 'connecting' ? 'Connecting...' :
@@ -259,7 +260,7 @@ export default function UnifiedMCPDropdown({
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleRemoveClient(server.id) }}
title="Remove server"
>
-
+
)
@@ -296,7 +297,7 @@ export default function UnifiedMCPDropdown({
>
) : mcpPromptsLoading ? (
- Loading prompts...
+ Loading prompts...
) : mcpPromptList.length === 0 ? (
No MCP prompts available
) : (
@@ -325,7 +326,7 @@ export default function UnifiedMCPDropdown({
{/* Resources tab */}
{activeTab === 'resources' && resourcesAvailable && (
mcpResourcesLoading ? (
- Loading resources...
+ Loading resources...
) : mcpResourceList.length === 0 ? (
No MCP resources available
) : (
diff --git a/core/http/react-ui/src/components/UserGroupSection.jsx b/core/http/react-ui/src/components/UserGroupSection.jsx
index 14af2a2e4..7248d6d0c 100644
--- a/core/http/react-ui/src/components/UserGroupSection.jsx
+++ b/core/http/react-ui/src/components/UserGroupSection.jsx
@@ -1,4 +1,5 @@
import { useState } from 'react'
+import Icon from './Icon'
/**
* UserGroupSection — collapsible section showing other users' resources.
@@ -107,7 +108,7 @@ export default function UserGroupSection({ title, userGroups, userMap, currentUs
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(v => !v) } }}
aria-expanded={open}
>
-
+
{title}
{totalUsers} user{totalUsers !== 1 ? 's' : ''}
@@ -155,7 +156,7 @@ function UserSubSection({ uid, displayName, initials, avatarUrl, count, itemKey,
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(v => !v) } }}
aria-expanded={open}
>
-
+
{avatarUrl ?
: initials}
diff --git a/core/http/react-ui/src/components/VoiceVisualizer.jsx b/core/http/react-ui/src/components/VoiceVisualizer.jsx
index 13b4d15db..960244292 100644
--- a/core/http/react-ui/src/components/VoiceVisualizer.jsx
+++ b/core/http/react-ui/src/components/VoiceVisualizer.jsx
@@ -1,21 +1,38 @@
import { useRef, useEffect } from 'react'
-// Self-contained voice visualizer for the Talk page. Owns its own AudioContext
-// and analysers (built from the output stream and the mic stream) so it
-// does not touch the existing WebRTC/diagnostics graph. Renders frequency bars
-// scaled by the mic level while listening and the assistant's output level
-// while speaking; a gentle idle animation otherwise.
-const BARS = 32
+// The Talk page's orb. A calm outline that breathes while idle, follows the
+// microphone while it listens and the reply's audio while it speaks. It owns
+// its own AudioContext and analysers (built from the output stream and
+// the mic stream), so it does not touch the WebRTC or diagnostics graph. The
+// levels are the real ones; nothing is simulated except the idle breathing.
+//
+// `status` is what the page is doing (connecting, listening, thinking,
+// speaking, error and so on) and `interrupted` marks a reply that was cut off.
+// Colours come from custom properties on the canvas, so the theme owns them.
-export default function VoiceVisualizer({ audioRef, micStreamRef, status, active }) {
+function readColors(canvas) {
+ const cs = getComputedStyle(canvas)
+ const get = (name, fallback) => cs.getPropertyValue(name).trim() || fallback
+ return {
+ ring: get('--orb-ring', '#cccccc'),
+ line: get('--orb-line', '#444444'),
+ accent: get('--orb-accent', '#0b6663'),
+ warn: get('--orb-warn', '#8a5a00'),
+ error: get('--orb-error', '#b3261e'),
+ quiet: get('--orb-quiet', '#888888'),
+ fill: get('--orb-fill', '#eeeeee'),
+ }
+}
+
+export default function VoiceVisualizer({ audioRef, micStreamRef, status, active, interrupted = false }) {
const canvasRef = useRef(null)
const rafRef = useRef(null)
const acRef = useRef(null)
const outRef = useRef(null)
const micRef = useRef(null)
- // Keep the latest status without restarting the animation loop.
- const statusRef = useRef(status)
- statusRef.current = status
+ // Keep the latest state without restarting the animation loop.
+ const stateRef = useRef({ status, interrupted })
+ stateRef.current = { status, interrupted }
useEffect(() => {
let setupTimer
@@ -32,63 +49,125 @@ export default function VoiceVisualizer({ audioRef, micStreamRef, status, active
ac.createMediaStreamSource(audioRef.current.srcObject).connect(a)
outRef.current = a
}
- if (!micRef.current && micStreamRef.current) {
+ if (!micRef.current && micStreamRef.current && micStreamRef.current.getAudioTracks().length > 0) {
const a = ac.createAnalyser(); a.fftSize = 1024; a.smoothingTimeConstant = 0.75
ac.createMediaStreamSource(micStreamRef.current).connect(a)
micRef.current = a
}
- } catch { /* analyser unavailable; idle animation still renders */ }
+ } catch { /* analyser unavailable; the idle breathing still draws */ }
}
- // The draw loop always runs (idle wave); analysers attach only once
- // connected, and streams can arrive a beat after connect.
+ // The draw loop always runs; analysers attach once connected, and the
+ // streams can arrive a beat after connect.
if (active) {
setup()
setupTimer = setInterval(() => { if (outRef.current && micRef.current) clearInterval(setupTimer); else setup() }, 400)
}
- const draw = () => {
+ const reduce = typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
+ let level = 0
+ let t = 0
+ let spin = 0
+ let last = 0
+
+ const draw = (now) => {
rafRef.current = requestAnimationFrame(draw)
const canvas = canvasRef.current
if (!canvas) return
+ const dt = Math.min(0.05, ((now - last) / 1000) || 0.016)
+ last = now
+ if (!reduce) { t += dt; spin += dt * (stateRef.current.status === 'connecting' ? 3.2 : 2.0) }
const ctx = canvas.getContext('2d')
- const dpr = window.devicePixelRatio || 1
+ const dpr = Math.min(2, window.devicePixelRatio || 1)
const w = canvas.clientWidth || 1
const h = canvas.clientHeight || 1
if (canvas.width !== Math.round(w * dpr)) { canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr) }
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
ctx.clearRect(0, 0, w, h)
+ const c = readColors(canvas)
- const st = statusRef.current
- const an = st === 'listening' ? micRef.current
- : st === 'speaking' ? outRef.current
- : (outRef.current || micRef.current)
- let data = null
- if (an) { data = new Uint8Array(an.frequencyBinCount); an.getByteFrequencyData(data) }
+ const { status: st, interrupted: cut } = stateRef.current
+ const an = st === 'listening' ? micRef.current : st === 'speaking' ? outRef.current : null
+ let target = 0.02
+ if (an) {
+ const data = new Uint8Array(an.frequencyBinCount)
+ an.getByteFrequencyData(data)
+ let sum = 0
+ const n = Math.max(1, Math.floor(data.length * 0.4))
+ for (let i = 0; i < n; i++) sum += data[i]
+ target = Math.min(1, (sum / n / 255) * 2.2)
+ } else if (st === 'speaking') {
+ target = 0.3
+ }
+ level += (target - level) * (cut ? 0.3 : 0.14)
- const color = getComputedStyle(canvas).getPropertyValue('--viz-color').trim() || '#4f8cff'
- ctx.fillStyle = color
- const slot = w / BARS
- const bw = slot * 0.5
- const radius = bw / 2
- const now = Date.now()
- for (let i = 0; i < BARS; i++) {
- let level
- if (data) {
- const idx = Math.floor((i / BARS) * (data.length * 0.55))
- level = data[idx] / 255
- } else {
- level = 0.10 + 0.05 * Math.sin(now / 320 + i * 0.45)
+ const cx = w / 2
+ const cy = h / 2
+ const R = Math.min(w, h) * 0.3
+ const failed = st === 'error' || st === 'lost' || st === 'blocked'
+ const still = st === 'disconnected' || st === 'connecting' || failed
+ const amp = still ? 0.012 : 0.02 + 0.16 * level
+
+ ctx.lineWidth = 1.25
+ ctx.strokeStyle = c.ring
+ ctx.beginPath(); ctx.arc(cx, cy, R * 1.42, 0, Math.PI * 2); ctx.stroke()
+ ctx.setLineDash([2, 7])
+ ctx.beginPath(); ctx.arc(cx, cy, R * 1.72, 0, Math.PI * 2); ctx.stroke()
+ ctx.setLineDash([])
+
+ if (failed) {
+ ctx.strokeStyle = c.error
+ ctx.lineWidth = 2
+ ctx.setLineDash([6, 8])
+ ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.stroke()
+ ctx.setLineDash([])
+ return
+ }
+
+ const color = st === 'speaking' ? c.accent : cut ? c.warn : (st === 'thinking' || still) ? c.quiet : c.line
+ ctx.beginPath()
+ for (let i = 0; i <= 120; i++) {
+ const a = (i / 120) * Math.PI * 2
+ const k = 0.5 * Math.sin(a * 3 + t * 1.2) + 0.3 * Math.sin(a * 5 - t * 1.7) + 0.2 * Math.sin(a * 8 + t * 2.3)
+ const rr = R * (1 + amp * k * 2.2)
+ const x = cx + Math.cos(a) * rr
+ const y = cy + Math.sin(a) * rr
+ if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y)
+ }
+ ctx.closePath()
+ ctx.fillStyle = c.fill
+ ctx.fill()
+ ctx.lineWidth = 2.25
+ ctx.strokeStyle = color
+ ctx.stroke()
+
+ if (st === 'thinking' || st === 'connecting') {
+ ctx.strokeStyle = c.accent
+ ctx.lineWidth = 3.5
+ ctx.lineCap = 'round'
+ ctx.beginPath(); ctx.arc(cx, cy, R * 1.42, spin, spin + 1.1); ctx.stroke()
+ ctx.beginPath(); ctx.arc(cx, cy, R * 1.42, spin + Math.PI, spin + Math.PI + 0.45); ctx.stroke()
+ ctx.lineCap = 'butt'
+ }
+ if (st === 'speaking' || (st === 'listening' && level > 0.12)) {
+ ctx.strokeStyle = st === 'speaking' ? c.accent : c.line
+ ctx.globalAlpha = 0.35
+ ctx.lineWidth = 1.5
+ ctx.beginPath(); ctx.arc(cx, cy, R * (1.15 + level * 0.25), 0, Math.PI * 2); ctx.stroke()
+ ctx.globalAlpha = 1
+ }
+ if (cut) {
+ ctx.strokeStyle = c.warn
+ ctx.lineWidth = 2
+ for (let q = 0; q < 8; q++) {
+ const aa = (q / 8) * Math.PI * 2 + 0.2
+ ctx.beginPath()
+ ctx.moveTo(cx + Math.cos(aa) * R * 1.14, cy + Math.sin(aa) * R * 1.14)
+ ctx.lineTo(cx + Math.cos(aa) * R * 1.3, cy + Math.sin(aa) * R * 1.3)
+ ctx.stroke()
}
- const bh = Math.max(bw, level * h)
- const x = i * slot + (slot - bw) / 2
- const y = (h - bh) / 2
- // rounded bar
- ctx.beginPath()
- ctx.roundRect ? ctx.roundRect(x, y, bw, bh, radius) : ctx.rect(x, y, bw, bh)
- ctx.fill()
}
}
- draw()
+ rafRef.current = requestAnimationFrame(draw)
return () => {
clearInterval(setupTimer)
@@ -102,5 +181,5 @@ export default function VoiceVisualizer({ audioRef, micStreamRef, status, active
acRef.current = null; outRef.current = null; micRef.current = null
}, [])
- return
+ return
}
diff --git a/core/http/react-ui/src/components/agents/AgentBits.jsx b/core/http/react-ui/src/components/agents/AgentBits.jsx
new file mode 100644
index 000000000..93750a741
--- /dev/null
+++ b/core/http/react-ui/src/components/agents/AgentBits.jsx
@@ -0,0 +1,107 @@
+// eslint-disable-next-line no-unused-vars
+import { Link } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import { recordStats, stripOf, STRIP_LENGTH } from '../../utils/agentRuns'
+import Icon from '../Icon'
+
+// A state in words with its dot. The dot never carries the meaning alone.
+const STATUS_DOT = { running: 'accent', done: 'ok', failed: 'error', stopped: '', ready: '', paused: 'warn' }
+
+export function StatusMark({ status, label }) {
+ const { t } = useTranslation('agents')
+ const dot = STATUS_DOT[status]
+ return (
+
+
+ {label || t(`state.${status}`)}
+
+ )
+}
+
+// The last 14 runs, oldest first: round finished, square failed, hollow
+// stopped. Empty places keep the strip the same width for every agent.
+export function RunStrip({ runs }) {
+ const { t } = useTranslation('agents')
+ const strip = stripOf(runs)
+ const pad = Math.max(0, STRIP_LENGTH - strip.length)
+ const describe = strip.length
+ ? t('strip.label', { count: strip.length, done: strip.filter(s => s.status === 'done').length, failed: strip.filter(s => s.status === 'failed').length })
+ : t('strip.none')
+ return (
+
+ {Array.from({ length: pad }).map((_, i) => )}
+ {strip.map(s => )}
+
+ )
+}
+
+// "93% of 41 runs finished, median 38 s", or the honest empty line.
+export function RecordLine({ runs }) {
+ const { t } = useTranslation('agents')
+ if (runs.length === 0) return {t('record.none')}
+ const s = recordStats(runs)
+ const pct = Math.round((s.finished / s.total) * 100)
+ return (
+
+ {t('record.line', { pct, count: s.total })}
+
+ )
+}
+
+export function Chip({ to, icon, children, mono = false, count, title }) {
+ const cls = `ag-chip${mono ? ' ag-chip--mono' : ''}${to ? ' ag-chip--link' : ''}`
+ const body = (
+ <>
+ {icon && }
+ {children}
+ {count != null && {count} }
+ >
+ )
+ return to
+ ? {body}
+ : {body}
+}
+
+// Memory and skills an agent has switched on, as chips that open the shared
+// Library pages. An agent with none says so.
+export function LibraryChips({ info, kind }) {
+ const { t } = useTranslation('agents')
+ if (kind === 'memory') {
+ if (info.memory.length === 0) return {t('chips.none')}
+ return info.memory.map(m => (
+ {t(`chips.memory.${m === 'knowledge' ? 'knowledge' : 'longTerm'}`)}
+ ))
+ }
+ if (info.skills.length === 0) return {t('chips.none')}
+ return info.skills.map(s => (
+ {s === 'all' ? t('chips.allSkills') : s}
+ ))
+}
+
+export function formatWhen(ts, now = Date.now()) {
+ if (!ts) return ''
+ const d = new Date(ts)
+ const today = new Date(now)
+ const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+ if (d.toDateString() === today.toDateString()) return time
+ const yesterday = new Date(now - 86400000)
+ if (d.toDateString() === yesterday.toDateString()) return `${yesterdayWord()} ${time}`
+ const sameYear = d.getFullYear() === today.getFullYear()
+ return d.toLocaleDateString([], sameYear ? { month: 'short', day: 'numeric' } : { year: 'numeric', month: 'short', day: 'numeric' })
+}
+
+function yesterdayWord() {
+ try {
+ return new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' }).format(-1, 'day')
+ } catch {
+ return 'Yesterday'
+ }
+}
+
+export function userQuery(userId) {
+ return userId ? `?user_id=${encodeURIComponent(userId)}` : ''
+}
+
+export function agentPath(name, userId, tail = '') {
+ return `/app/agents/${encodeURIComponent(name)}${tail}${userQuery(userId)}`
+}
diff --git a/core/http/react-ui/src/components/agents/JobBits.jsx b/core/http/react-ui/src/components/agents/JobBits.jsx
new file mode 100644
index 000000000..5b0770dcc
--- /dev/null
+++ b/core/http/react-ui/src/components/agents/JobBits.jsx
@@ -0,0 +1,59 @@
+// eslint-disable-next-line no-unused-vars
+import { Fragment } from 'react'
+import { useTranslation } from 'react-i18next'
+// eslint-disable-next-line no-unused-vars
+import { StatusMark } from './AgentBits'
+import { jobMark, stripOfJobs, STRIP } from '../../utils/agentJobs'
+import { scheduleWords } from '../../utils/agentJobText'
+
+// A job's state in the words the server uses, with the Agents area's dot.
+export function JobMark({ status }) {
+ const { t } = useTranslation('agents')
+ return
+}
+
+// The schedule of a task: words first, the expression under them in mono.
+export function ScheduleText({ expr, off = false }) {
+ const { t, i18n } = useTranslation('agents')
+ if (!expr) return {t('jobs.tasks.noSchedule')}
+ const words = scheduleWords(expr, t, i18n.language)
+ return (
+
+ {words || t('jobs.cron.custom')}
+ {expr}
+
+ )
+}
+
+// The last 14 jobs of a task, oldest first: round finished, square failed,
+// hollow cancelled. Empty places keep every task's strip the same width.
+export function JobStrip({ jobs }) {
+ const { t } = useTranslation('agents')
+ const strip = stripOfJobs(jobs)
+ const pad = Math.max(0, STRIP - strip.length)
+ const label = strip.length
+ ? t('strip.label', { count: strip.length, done: strip.filter(s => s.status === 'done').length, failed: strip.filter(s => s.status === 'failed').length })
+ : t('strip.none')
+ return (
+
+ {Array.from({ length: pad }).map((_, i) => )}
+ {strip.map(s => )}
+
+ )
+}
+
+// A prompt template with its gaps marked. A gap with a value shows the value;
+// a gap without one stays as written, in the warning colour.
+export function GappedPrompt({ prompt, params }) {
+ const parts = String(prompt || '').split(/(\{\{\s*\.[A-Za-z_]\w*\s*\}\})/g)
+ return (
+
+ {parts.map((part, i) => {
+ const m = part.match(/^\{\{\s*\.([A-Za-z_]\w*)\s*\}\}$/)
+ if (!m) return {part}
+ const has = params && Object.prototype.hasOwnProperty.call(params, m[1]) && params[m[1]] !== ''
+ return {has ? params[m[1]] : part}
+ })}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/agents/Prose.jsx b/core/http/react-ui/src/components/agents/Prose.jsx
new file mode 100644
index 000000000..db28e8c5b
--- /dev/null
+++ b/core/http/react-ui/src/components/agents/Prose.jsx
@@ -0,0 +1,50 @@
+import { useLayoutEffect, useMemo, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
+import { renderMarkdown, highlightAll, enhanceCodeBlocks } from '../../utils/markdown'
+
+// Markdown prose. Code blocks get Copy; tables and code blocks that may be
+// wider than the column get "Open wider", which breaks them out of the column
+// until pressed again.
+export default function Prose({ text, live = false }) {
+ const { t } = useTranslation('agents')
+ const ref = useRef(null)
+ const html = useMemo(() => renderMarkdown(text || ''), [text])
+ // The markup is set and decorated here, not through dangerouslySetInnerHTML:
+ // React would put the plain markup back on a later render and drop the
+ // buttons added below.
+ useLayoutEffect(() => {
+ const root = ref.current
+ if (!root) return
+ root.innerHTML = html
+ highlightAll(root)
+ enhanceCodeBlocks(root)
+ root.querySelectorAll('table, .code-block').forEach((el) => {
+ // Content that is, or may be, wider than the column gets the button: a
+ // code block that overflows, a table that overflows or has many columns.
+ const isCode = el.matches('.code-block')
+ const scroller = isCode ? el.querySelector('pre') : el
+ if (!scroller) return
+ const overflows = scroller.scrollWidth > scroller.clientWidth + 2
+ const manyColumns = !isCode && el.querySelectorAll('thead th').length >= 4
+ if (!overflows && !manyColumns) return
+ const wrap = document.createElement('div')
+ wrap.className = 'ag-wide'
+ const bar = document.createElement('div')
+ bar.className = 'ag-wide__bar'
+ const btn = document.createElement('button')
+ btn.type = 'button'
+ btn.className = 'ag-wide__btn'
+ btn.setAttribute('aria-pressed', 'false')
+ btn.textContent = t('run.openWider')
+ btn.addEventListener('click', () => {
+ const on = wrap.toggleAttribute('data-wide')
+ btn.textContent = on ? t('run.backToColumn') : t('run.openWider')
+ btn.setAttribute('aria-pressed', String(on))
+ })
+ bar.appendChild(btn)
+ el.parentNode.insertBefore(wrap, el)
+ wrap.append(bar, el)
+ })
+ }, [html, t])
+ return
+}
diff --git a/core/http/react-ui/src/components/agents/RunTaskDialog.jsx b/core/http/react-ui/src/components/agents/RunTaskDialog.jsx
new file mode 100644
index 000000000..c340780dc
--- /dev/null
+++ b/core/http/react-ui/src/components/agents/RunTaskDialog.jsx
@@ -0,0 +1,174 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { useTranslation } from 'react-i18next'
+import { agentJobsApi, fileToBase64 } from '../../utils/api'
+import { parseKeyValues, promptParams } from '../../utils/agentJobs'
+import Icon from '../Icon'
+
+const MEDIA = [
+ { type: 'images', icon: 'image' },
+ { type: 'videos', icon: 'video' },
+ { type: 'audios', icon: 'headphones' },
+ { type: 'files', icon: 'file' },
+]
+
+// "Run now" for a task: the values its prompt uses, optional extra parameters
+// and optional media, then one call. Without media the call is the run-by-name
+// one the page always used. With media it is the job call, which is the only one
+// that takes attachments.
+export default function RunTaskDialog({ task, onClose, onStarted, addToast }) {
+ const { t } = useTranslation('agents')
+ const gaps = useMemo(() => promptParams(task.prompt), [task.prompt])
+ const [values, setValues] = useState(() => Object.fromEntries(gaps.map(g => [g, task.cron_parameters?.[g] ?? ''])))
+ const [more, setMore] = useState('')
+ const [media, setMedia] = useState({ images: [], videos: [], audios: [], files: [] })
+ const [busy, setBusy] = useState(false)
+ const dialogRef = useRef(null)
+ const fileRef = useRef(null)
+ const typeRef = useRef('images')
+ const onCloseRef = useRef(onClose)
+ onCloseRef.current = onClose
+
+ useEffect(() => {
+ const opener = document.activeElement
+ const dialog = dialogRef.current
+ dialog?.querySelector('input, textarea, button')?.focus()
+ const onKey = (e) => {
+ if (e.key === 'Escape') { e.preventDefault(); onCloseRef.current(); return }
+ if (e.key !== 'Tab' || !dialog) return
+ const focusable = Array.from(dialog.querySelectorAll('button:not([disabled]), input:not([type="file"]), textarea, [tabindex]:not([tabindex="-1"])'))
+ if (focusable.length === 0) return
+ const first = focusable[0]
+ const last = focusable[focusable.length - 1]
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
+ }
+ document.addEventListener('keydown', onKey)
+ return () => {
+ document.removeEventListener('keydown', onKey)
+ if (opener && document.contains(opener)) opener.focus?.()
+ }
+ }, [])
+
+ const attach = async (e) => {
+ const type = typeRef.current
+ for (const file of e.target.files) {
+ const base64 = await fileToBase64(file)
+ const url = `data:${file.type};base64,${base64}`
+ setMedia(prev => ({ ...prev, [type]: [...prev[type], { url, name: file.name }] }))
+ }
+ e.target.value = ''
+ }
+
+ const start = async () => {
+ setBusy(true)
+ try {
+ const params = {}
+ for (const g of gaps) if (values[g] !== '') params[g] = values[g]
+ Object.assign(params, parseKeyValues(more))
+ const attached = MEDIA.some(m => media[m.type].length > 0)
+ let res
+ if (attached) {
+ const body = { task_id: task.id, parameters: params }
+ for (const m of MEDIA) if (media[m.type].length) body[m.type] = media[m.type].map(x => x.url)
+ res = await agentJobsApi.executeJob(body)
+ } else {
+ res = await agentJobsApi.executeTask(task.name || task.id, params)
+ }
+ addToast(t('jobs.run.started', { name: task.name }), 'success')
+ onStarted?.(res)
+ onClose()
+ } catch (err) {
+ addToast(t('jobs.run.failed', { message: err.message }), 'error')
+ setBusy(false)
+ }
+ }
+
+ return createPortal(
+
+
e.stopPropagation()}
+ >
+
+
+
{t('jobs.run.title', { name: task.name })}
+
{t('jobs.run.desc')}
+
+
+
+
+
+
+ {gaps.length === 0 ? (
+
{t('jobs.run.noGaps')}
+ ) : gaps.map(g => (
+
+ {g}
+ setValues(v => ({ ...v, [g]: e.target.value }))}
+ onKeyDown={(e) => { if (e.key === 'Enter' && !busy) { e.preventDefault(); start() } }}
+ />
+
+ ))}
+
+
+ {t('jobs.run.media')}{MEDIA.some(m => media[m.type].length) && {MEDIA.reduce((n, m) => n + media[m.type].length, 0)} }
+ {t('jobs.run.mediaHint')}
+ {MEDIA.map(m => (
+
+
+ {t(`jobs.run.${m.type}`)} ({media[m.type].length})
+ { typeRef.current = m.type; fileRef.current?.click() }}>
+ {t('jobs.run.add')}
+
+
+ {media[m.type].map((item, i) => (
+
+ {item.name || item.url.slice(0, 40)}
+ setMedia(prev => ({ ...prev, [m.type]: prev[m.type].filter((_, j) => j !== i) }))}
+ >
+
+
+
+ ))}
+
+ ))}
+
+
+
+
+ {t('jobs.run.cancel')}
+
+ {busy ? <> {t('jobs.run.starting')}> : <> {t('jobs.run.start')}>}
+
+
+
+
,
+ document.body,
+ )
+}
diff --git a/core/http/react-ui/src/components/audio/WaveformPlayer.jsx b/core/http/react-ui/src/components/audio/WaveformPlayer.jsx
index 00c2e56e8..a0ab4d835 100644
--- a/core/http/react-ui/src/components/audio/WaveformPlayer.jsx
+++ b/core/http/react-ui/src/components/audio/WaveformPlayer.jsx
@@ -44,7 +44,7 @@ export default function WaveformPlayer({
const accent =
getComputedStyle(canvas).getPropertyValue('--audio-wave').trim() ||
getComputedStyle(canvas).getPropertyValue('--color-primary').trim() ||
- '#4f8cff'
+ '#74bdb5'
ctx.fillStyle = dimmed ? withAlpha(accent, 0.32) : accent
const mid = cssH / 2
const barW = Math.max(1, cssW / peaks.length)
diff --git a/core/http/react-ui/src/components/biometrics/DistributionBars.jsx b/core/http/react-ui/src/components/biometrics/DistributionBars.jsx
index 53c95f719..0b9a30590 100644
--- a/core/http/react-ui/src/components/biometrics/DistributionBars.jsx
+++ b/core/http/react-ui/src/components/biometrics/DistributionBars.jsx
@@ -1,3 +1,5 @@
+import Icon from '../Icon'
+
// DistributionBars — one horizontal bar per label, width proportional to value.
// distribution: Record (values are probabilities 0..1 or any positive scale).
// dominant: string — highlighted row.
@@ -9,7 +11,7 @@ export default function DistributionBars({ title, distribution, dominant, icon }
return (
- {icon && }
+ {icon && }
{title}
{dominant && {dominant} }
diff --git a/core/http/react-ui/src/components/biometrics/EmbeddingInspector.jsx b/core/http/react-ui/src/components/biometrics/EmbeddingInspector.jsx
index e94a76e94..8c274157f 100644
--- a/core/http/react-ui/src/components/biometrics/EmbeddingInspector.jsx
+++ b/core/http/react-ui/src/components/biometrics/EmbeddingInspector.jsx
@@ -1,4 +1,5 @@
import { useMemo, useRef, useEffect, useState } from 'react'
+import Icon from '../Icon'
// EmbeddingInspector — compact visualization of a raw vector returned by /v1/face|voice/embed.
// embedding: number[] (can be large). dim: int. model: string.
@@ -79,7 +80,7 @@ export default function EmbeddingInspector({ embedding, dim, model, elapsedMs })
-
+
{copied ? ' Copied' : ' Copy JSON'}
diff --git a/core/http/react-ui/src/components/biometrics/EnrollmentList.jsx b/core/http/react-ui/src/components/biometrics/EnrollmentList.jsx
index 663af3bdd..7773ee2c2 100644
--- a/core/http/react-ui/src/components/biometrics/EnrollmentList.jsx
+++ b/core/http/react-ui/src/components/biometrics/EnrollmentList.jsx
@@ -1,3 +1,4 @@
+import Icon from '../Icon'
// EnrollmentList — grid of enrolled subjects (face or voice).
// entries: [{ id, name, labels?, thumbnail?, registeredAt?, sampleUrl? }]
// mode: 'image' | 'audio' — controls the card visual.
@@ -5,7 +6,7 @@ export default function EnrollmentList({ entries, onDelete, mode = 'image', high
if (!entries || entries.length === 0) {
return (
-
+
No one enrolled yet. Add a sample using the form on the left to start building your identification store.
)
@@ -35,13 +36,13 @@ export default function EnrollmentList({ entries, onDelete, mode = 'image', high
)}
{e.registeredAt && (
- {formatTime(e.registeredAt)}
+ {formatTime(e.registeredAt)}
)}
onDelete(e)}
aria-label={`Forget ${e.name}`} title="Forget this enrollment">
-
+
)
diff --git a/core/http/react-ui/src/components/biometrics/MatchGauge.jsx b/core/http/react-ui/src/components/biometrics/MatchGauge.jsx
index cf1090afb..cf5c2e85f 100644
--- a/core/http/react-ui/src/components/biometrics/MatchGauge.jsx
+++ b/core/http/react-ui/src/components/biometrics/MatchGauge.jsx
@@ -1,3 +1,4 @@
+import Icon from '../Icon'
// MatchGauge — distance vs threshold as a single horizontal meter.
// distance, threshold numeric (cosine distance, lower = closer).
// Scale is 0 → max (default 2× threshold or 1.0) so the threshold sits near the middle.
@@ -13,7 +14,7 @@ export default function MatchGauge({ distance, threshold, confidence, verified,
aria-label={`${label || 'Match'}: ${verified ? 'match' : 'no match'} at distance ${distance?.toFixed?.(3) ?? '?'} (threshold ${threshold?.toFixed?.(3) ?? '?'})`}>
-
+
{verified ? 'Match' : 'No match'}
{confidence != null && (
diff --git a/core/http/react-ui/src/components/biometrics/MediaInput.jsx b/core/http/react-ui/src/components/biometrics/MediaInput.jsx
index 28143d8cd..4618c5272 100644
--- a/core/http/react-ui/src/components/biometrics/MediaInput.jsx
+++ b/core/http/react-ui/src/components/biometrics/MediaInput.jsx
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { useMediaCapture } from '../../hooks/useMediaCapture'
import { fileToBase64 } from '../../utils/api'
+import Icon from '../Icon'
// MediaInput — one control, three ways to supply a sample.
// mode: 'image' | 'audio'. onChange receives null | { base64, dataUrl, mime, source }.
@@ -14,7 +15,7 @@ function UnsupportedNotice({ mode }) {
if (!isSecure) {
return (
-
+
{thing} needs a secure origin
@@ -29,7 +30,7 @@ function UnsupportedNotice({ mode }) {
}
return (
-
+
Live {thing} not available
@@ -137,12 +138,12 @@ export default function MediaInput({ mode, label, value, onChange, onError, maxB
setTab('file')}>
- Upload
+ Upload
setTab('live')}>
-
+
{mode === 'image' ? ' Webcam' : ' Record'}
@@ -158,7 +159,7 @@ export default function MediaInput({ mode, label, value, onChange, onError, maxB
onChange={handleFile}
/>
{mode === 'image' && (
-
Paste an image from the clipboard
+
Paste an image from the clipboard
)}
)}
@@ -168,7 +169,7 @@ export default function MediaInput({ mode, label, value, onChange, onError, maxB
{!cap.supported &&
}
{cap.supported && !cap.active && (
-
+
{mode === 'image' ? ' Start webcam' : ' Enable microphone'}
)}
@@ -177,7 +178,7 @@ export default function MediaInput({ mode, label, value, onChange, onError, maxB
- Capture
+ Capture
Stop
@@ -186,12 +187,12 @@ export default function MediaInput({ mode, label, value, onChange, onError, maxB
{cap.active && mode === 'audio' && (
-
+
{cap.recording ? `Recording… ${cap.elapsed.toFixed(1)}s` : 'Microphone ready'}
-
+
{cap.recording ? ' Stop' : ' Record'}
Close
@@ -211,11 +212,11 @@ export default function MediaInput({ mode, label, value, onChange, onError, maxB
:
}
-
+
{value.source === 'live' ? ' Captured' : value.source === 'paste' ? ' Pasted image' : ` ${value.name || 'Uploaded'}`}
-
+
diff --git a/core/http/react-ui/src/components/biometrics/TabSwitch.jsx b/core/http/react-ui/src/components/biometrics/TabSwitch.jsx
index a49d33941..79d1de80d 100644
--- a/core/http/react-ui/src/components/biometrics/TabSwitch.jsx
+++ b/core/http/react-ui/src/components/biometrics/TabSwitch.jsx
@@ -1,3 +1,5 @@
+import Icon from '../Icon'
+
export default function TabSwitch({ tabs, value, onChange }) {
return (
@@ -12,7 +14,7 @@ export default function TabSwitch({ tabs, value, onChange }) {
className={`biometrics-tab ${active ? 'active' : ''}`}
onClick={() => onChange(t.id)}
>
- {t.icon &&
}
+ {t.icon &&
}
{t.label}
)
diff --git a/core/http/react-ui/src/components/chat/ActivityFold.jsx b/core/http/react-ui/src/components/chat/ActivityFold.jsx
new file mode 100644
index 000000000..690e308d4
--- /dev/null
+++ b/core/http/react-ui/src/components/chat/ActivityFold.jsx
@@ -0,0 +1,214 @@
+import { useEffect, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { renderMarkdown, highlightAll } from '../../utils/markdown'
+// eslint-disable-next-line no-unused-vars
+import MCPAppFrame from '../MCPAppFrame'
+import Icon from '../Icon'
+
+// What the thread says about reasoning and tool use: one quiet line that opens
+// inline into the steps. Everything it shows is already in the history
+// (thinking, tool_call and tool_result entries); nothing here is measured or
+// timed, because the history carries no durations.
+
+function formatToolContent(raw) {
+ try {
+ const data = JSON.parse(raw)
+ const name = data.name || 'unknown'
+ let params = data.arguments || data.input || data.result || data.parameters || {}
+ if (typeof params === 'string') {
+ try { params = JSON.parse(params) } catch { /* keep as string */ }
+ }
+ if (typeof params === 'string') return { name, entries: [], fallback: params }
+ const entries = typeof params === 'object' && params !== null ? Object.entries(params) : []
+ return { name, entries, fallback: null }
+ } catch {
+ return { name: null, entries: [], fallback: raw }
+ }
+}
+
+// eslint-disable-next-line no-unused-vars
+function ToolParams({ entries, fallback }) {
+ if (fallback) return
{fallback}
+ if (entries.length === 0) return null
+ return (
+
+ {entries.map(([k, v]) => {
+ const val = typeof v === 'string' ? v : JSON.stringify(v, null, 2)
+ return (
+
+
{k}
+ 120 ? 'cx-param__long' : undefined}>{val}
+
+ )
+ })}
+
+ )
+}
+
+function callName(item, fallback) {
+ try { return JSON.parse(item.content)?.name || fallback } catch { return fallback }
+}
+
+// "Thought · read_file", "Thought · 3 tool calls": what happened, in the fewest
+// words that still say it.
+function summarise(items, t) {
+ const parts = []
+ if (items.some(i => i.role === 'thinking' || i.role === 'reasoning')) parts.push(t('activity.thought'))
+ const calls = items.filter(i => i.role === 'tool_call')
+ if (calls.length === 1) parts.push(callName(calls[0], t('activity.tool')))
+ else if (calls.length > 1) parts.push(t('activity.toolCalls', { count: calls.length }))
+ if (parts.length === 0) parts.push(t('activity.result'))
+ return parts.join(' · ')
+}
+
+// eslint-disable-next-line no-unused-vars
+function FoldFrame({ summary, open, live, shimmer, onToggle, icon, children, id }) {
+ const bodyId = `${id}-body`
+ return (
+
+
+
+ {summary}
+
+
+ {open &&
{children}
}
+
+ )
+}
+
+// One run of thinking and tool entries between two messages.
+export function ActivityGroup({ items, getClientForTool, id }) {
+ const { t } = useTranslation('chat')
+ const [expanded, setExpanded] = useState(false)
+ const contentRef = useRef(null)
+
+ useEffect(() => {
+ if (expanded && contentRef.current) highlightAll(contentRef.current)
+ }, [expanded])
+
+ if (!items || items.length === 0) return null
+
+ // A tool result that came with an app UI is shown on its own, not folded.
+ const appUIItems = items.filter(item => item.role === 'tool_result' && item.appUI)
+ const regularItems = items.filter(item => !(item.role === 'tool_result' && item.appUI))
+ const icon = regularItems.some(i => i.role === 'thinking' || i.role === 'reasoning') ? 'lightbulb' : 'wrench'
+
+ return (
+ <>
+ {regularItems.length > 0 && (
+
setExpanded(v => !v)}
+ >
+
+ {regularItems.map((item, idx) => {
+ if (item.role === 'thinking' || item.role === 'reasoning') {
+ return (
+
+ {t('activity.thought')}
+
+
+ )
+ }
+ const isCall = item.role === 'tool_call'
+ const parsed = formatToolContent(item.content)
+ const name = parsed.name || t('activity.tool')
+ return (
+
+
+ {isCall ? t('activity.toolCall') : t('activity.result')} {name}
+
+
+
+ )
+ })}
+
+
+ )}
+ {appUIItems.map((item, idx) => (
+
+
{item.appUI.toolName}
+
+
+ ))}
+ >
+ )
+}
+
+// The same line while the reply is still coming: open while the model thinks
+// or calls a tool, folded once the answer starts. The reader can override both.
+export function StreamingActivity({ reasoning, toolCalls, hasResponse }) {
+ const { t } = useTranslation('chat')
+ const contentRef = useRef(null)
+ const [manualCollapse, setManualCollapse] = useState(null)
+ const hasTools = !!toolCalls && toolCalls.length > 0
+ const autoExpanded = (!!reasoning || hasTools) && !hasResponse
+ const expanded = manualCollapse !== null ? !manualCollapse : autoExpanded
+
+ useEffect(() => {
+ if (expanded && contentRef.current) contentRef.current.scrollTop = contentRef.current.scrollHeight
+ }, [reasoning, expanded])
+
+ useEffect(() => { setManualCollapse(null) }, [hasResponse])
+
+ if (!reasoning && !hasTools) return null
+
+ const lastTool = hasTools ? toolCalls[toolCalls.length - 1] : null
+ const label = reasoning
+ ? (hasResponse ? t('activity.thought') : t('activity.thinking'))
+ : (lastTool.type === 'tool_call' ? lastTool.name : t('activity.toolResult', { name: lastTool.name }))
+
+ return (
+
setManualCollapse(expanded)}
+ >
+
+ {reasoning && (
+
+
+
+ )}
+ {hasTools && toolCalls.map((tc, idx) => {
+ if (tc.type === 'tool_result') {
+ return (
+
+ {t('activity.toolResult', { name: tc.name })}
+
+
+ )
+ }
+ const parsed = formatToolContent(JSON.stringify(tc, null, 2))
+ return (
+
+ {t('activity.toolCall')} {tc.name || tc.type}
+
+
+ )
+ })}
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/chat/ChatHeader.jsx b/core/http/react-ui/src/components/chat/ChatHeader.jsx
new file mode 100644
index 000000000..2099afa00
--- /dev/null
+++ b/core/http/react-ui/src/components/chat/ChatHeader.jsx
@@ -0,0 +1,125 @@
+import { useEffect, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
+import { fillStyle } from '../home/memory'
+// eslint-disable-next-line no-unused-vars
+import ActionMenu from '../ActionMenu'
+import Icon from '../Icon'
+
+// The slim bar over the thread: the conversations list (Ctrl K), the chat's
+// name (click to rename), how full the context is, find, settings and the rest.
+export default function ChatHeader({
+ historyMenu, manageMode, name, onRename, renaming, setRenaming,
+ contextPercent, contextTokens, contextSize,
+ onFind, findOpen, onSettings, settingsOpen, moreItems,
+}) {
+ const { t } = useTranslation('chat')
+ const inputRef = useRef(null)
+ const cancelledRef = useRef(false)
+
+ // The page owns `renaming`, so the More menu can open the box too.
+ useEffect(() => {
+ if (!renaming) return
+ cancelledRef.current = false
+ inputRef.current?.focus()
+ inputRef.current?.select()
+ }, [renaming])
+
+ // Esc cancels; the box also loses focus as it goes away, and that blur must
+ // not save what was typed.
+ const finish = (save) => {
+ if (!save) cancelledRef.current = true
+ else if (cancelledRef.current) { cancelledRef.current = false; return }
+ const next = inputRef.current?.value.trim()
+ if (save && next && next !== name) onRename(next)
+ setRenaming(false)
+ }
+
+ const warn = contextPercent !== null && contextPercent > 70
+ const hot = contextPercent !== null && contextPercent > 90
+
+ return (
+
+ {historyMenu}
+ {manageMode && (
+
+
+ {t('header.manageModeTooltip')}
+
+ )}
+
+ {renaming ? (
+ finish(true)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') { e.preventDefault(); finish(true) }
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); finish(false) }
+ }}
+ aria-label={t('header.chatName')}
+ />
+ ) : (
+ setRenaming(true)}>
+ {name}
+
+ )}
+
+
+ {contextPercent !== null && (
+ 0
+ ? t('context.labelWithTokens', { percent: Math.round(contextPercent), tokens: contextTokens })
+ : t('context.label', { percent: Math.round(contextPercent) })}
+ data-testid="chat-context"
+ >
+ {t('context.title')}
+
+
+
+ {Math.round(contextPercent)}%
+
+ )}
+
+
+ {onFind && (
+
+
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/chat/ChatMessage.jsx b/core/http/react-ui/src/components/chat/ChatMessage.jsx
new file mode 100644
index 000000000..b5018f70c
--- /dev/null
+++ b/core/http/react-ui/src/components/chat/ChatMessage.jsx
@@ -0,0 +1,249 @@
+import { memo, useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import { renderMarkdown } from '../../utils/markdown'
+import { renderMarkdownWithArtifacts } from '../../utils/artifacts'
+import { editableMessageText, escapeHtml, messageText, splitError } from './chatText'
+// eslint-disable-next-line no-unused-vars
+import { ActivityGroup, StreamingActivity } from './ActivityFold'
+import Icon from '../Icon'
+
+// Assistant prose. The HTML string is memoised, so React leaves the node alone
+// while the page re-renders for other reasons (polling, streaming elsewhere):
+// a text selection inside an answer survives.
+// eslint-disable-next-line no-unused-vars
+function Prose({ html, className = '' }) {
+ return
+}
+
+function fileIcon(f) {
+ return f.type === 'image' ? 'image' : f.type === 'audio' ? 'headphones' : f.type === 'video' ? 'video' : 'file'
+}
+
+// eslint-disable-next-line no-unused-vars
+function UserBody({ content, files, onOpenImage }) {
+ const { t } = useTranslation('chat')
+ const text = messageText(content)
+ const images = Array.isArray(content) ? content.filter(c => c.type === 'image_url') : []
+ const videos = Array.isArray(content) ? content.filter(c => c.type === 'video_url') : []
+ // An image that is in the message shows as a thumbnail; its entry in the file
+ // list would only repeat it.
+ const chips = (files || []).filter(f => !(f.type === 'image' && images.length > 0))
+ return (
+ <>
+ {(images.length > 0 || videos.length > 0 || chips.length > 0) && (
+
+ {images.map((img, i) => (
+
onOpenImage?.(images.map((x, n) => ({ url: x.image_url.url, alt: t('message.attachedImage', { n: n + 1 }) })), i)}
+ aria-label={t('message.openImage', { n: i + 1 })}
+ >
+
+
+ ))}
+ {videos.map((vid, i) => (
+
+ ))}
+ {chips.map((f, i) => (
+
+
+ {f.name}
+
+ ))}
+
+ )}
+ {text &&
}
+ >
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function ErrorCard({ message, kept, onRetry, canRetry }) {
+ const { t } = useTranslation('chat')
+ return (
+
+
+
+
{t('error.title')}
+
{message}{kept ? ` ${t('error.kept')}` : ''}
+
+
+ {t('error.detail')}
+ {message}
+
+
+
+ )
+}
+
+// One turn. The reader's messages are raised blocks on the right, the model's
+// are plain prose with its name above. Actions show on hover, on focus and on
+// the last turn; the turn itself takes focus so the arrow keys and C, E, R and
+// B work from the keyboard.
+function ChatMessage({
+ msg, index, isLast, model, warm, canvasMode, busy,
+ editing, draft, onDraft, activityItems, actions, onOpenImage, getClientForTool,
+}) {
+ const { t } = useTranslation('chat')
+ const isUser = msg.role === 'user'
+ const isAssistant = msg.role === 'assistant'
+ const text = typeof msg.content === 'string' ? msg.content : ''
+ const failure = isAssistant ? splitError(text) : null
+ const shown = failure ? failure.text : text
+
+ const html = useMemo(() => {
+ if (isUser || !shown) return ''
+ return canvasMode ? renderMarkdownWithArtifacts(shown, index) : renderMarkdown(shown)
+ }, [isUser, shown, canvasMode, index])
+
+ const canEdit = (isUser || isAssistant) && !busy && editableMessageText(msg) !== null
+
+ const onKeyDown = (e) => {
+ if (e.target !== e.currentTarget || e.metaKey || e.ctrlKey || e.altKey) return
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
+ e.preventDefault()
+ actions.focusRelative(index, e.key === 'ArrowDown' ? 1 : -1)
+ } else if (e.key === 'c') {
+ e.preventDefault()
+ actions.copy(msg.content)
+ } else if (e.key === 'e' && canEdit) {
+ // The editor takes focus on this key press: keep the letter out of it.
+ e.preventDefault()
+ actions.startEdit(index, msg)
+ } else if (e.key === 'r' && isAssistant && !busy) {
+ e.preventDefault()
+ actions.regenerate(index)
+ } else if (e.key === 'b' && isAssistant && !busy) {
+ e.preventDefault()
+ actions.branch(index)
+ }
+ }
+
+ return (
+
+ {isAssistant && (
+
+
+ {model && {model} }
+
+ )}
+ {!isUser && !isAssistant && {t('message.system')}
}
+ {activityItems && (
+
+ )}
+
+ {editing ? (
+
+ ) : isUser ? (
+
+
+
+ ) : (
+ <>
+ {shown && }
+ {failure && (
+ actions.regenerate(index)}
+ />
+ )}
+ >
+ )}
+
+ {!editing && (
+
+ actions.copy(msg.content)} title={t('actions.copy')} aria-label={t('actions.copy')}>
+
+
+ {canEdit && (
+ actions.startEdit(index, msg)} title={t('actions.edit')} aria-label={t('actions.edit')}>
+
+
+ )}
+ {isAssistant && !busy && (
+ actions.regenerate(index)} title={t('actions.regenerate')} aria-label={t('actions.regenerate')}>
+
+
+ )}
+ {isAssistant && !busy && (
+ actions.branch(index)} title={t('actions.branch')} aria-label={t('actions.branch')}>
+
+
+ )}
+
+ )}
+
+ )
+}
+
+// Tool-less rows that sit between messages: a run of reasoning and tool entries
+// that no assistant message follows yet.
+export function ActivityRow({ items, getClientForTool, id }) {
+ return (
+
+ )
+}
+
+// The reply while it is still coming: the activity line, the prose so far with a
+// caret at its end, and, before anything has arrived, whatever the page says
+// the model is waiting for (a load card, or quiet dots).
+export function StreamingTurn({ model, warm, content, reasoning, toolCalls, waiting }) {
+ const html = useMemo(() => (content ? renderMarkdown(content) : ''), [content])
+ const idle = !content && !reasoning && toolCalls.length === 0
+ return (
+
+
+
+ {model && {model} }
+
+
+ {content && }
+ {idle && waiting}
+
+ )
+}
+
+export default memo(ChatMessage)
diff --git a/core/http/react-ui/src/components/chat/ChatSettingsSheet.jsx b/core/http/react-ui/src/components/chat/ChatSettingsSheet.jsx
new file mode 100644
index 000000000..e39b2099f
--- /dev/null
+++ b/core/http/react-ui/src/components/chat/ChatSettingsSheet.jsx
@@ -0,0 +1,246 @@
+import { useEffect, useRef } from 'react'
+import { createPortal } from 'react-dom'
+import { useTranslation } from 'react-i18next'
+import { cssVars } from '../../utils/modelLedger'
+import Icon from '../Icon'
+
+const CONTEXT_STEPS = [4096, 8192, 16384, 32768]
+
+// eslint-disable-next-line no-unused-vars
+function Switch({ checked, onChange, label }) {
+ return (
+
onChange(!checked)}
+ />
+ )
+}
+
+// One sampling setting. null means "the model's own default": the slider sits
+// at a typical value, the label says so, and Reset puts the chat back to it.
+// eslint-disable-next-line no-unused-vars
+function Slider({ id, label, value, fallback, min, max, step, onChange, onReset, parse }) {
+ const { t } = useTranslation('chat')
+ const shown = value ?? fallback
+ const pct = ((shown - min) / (max - min)) * 100
+ return (
+
+
+ {label}
+
+ {value === null || value === undefined ? t('settings.modelDefault') : value}
+
+ {value !== null && value !== undefined && (
+ {t('settings.reset')}
+ )}
+
+
onChange(parse(e.target.value))}
+ />
+
{min} {max}
+
+ )
+}
+
+// Everything that shapes the next reply, in one sheet: the system prompt,
+// sampling, the context size, the behaviour switches and, for admins, what the
+// model is. Settings apply from the next message.
+export default function ChatSettingsSheet({
+ chat, isAdmin, onUpdate, focusMode, onFocusMode, modelInfo, onEditConfig, onClear, onClose,
+}) {
+ const { t } = useTranslation('chat')
+ const sheetRef = useRef(null)
+ const closeRef = useRef(null)
+ const openerRef = useRef(null)
+
+ useEffect(() => {
+ openerRef.current = document.activeElement
+ closeRef.current?.focus()
+ const onKey = (e) => {
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose(); return }
+ if (e.key !== 'Tab' || !sheetRef.current) return
+ const focusable = Array.from(sheetRef.current.querySelectorAll('button:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'))
+ if (focusable.length === 0) return
+ const first = focusable[0]
+ const last = focusable[focusable.length - 1]
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
+ }
+ window.addEventListener('keydown', onKey, true)
+ return () => {
+ window.removeEventListener('keydown', onKey, true)
+ const el = openerRef.current
+ if (el && document.contains(el)) el.focus?.()
+ }
+ }, [onClose])
+
+ const set = (patch) => onUpdate(patch)
+ const used = chat.tokenUsage?.total || 0
+ const percent = chat.contextSize ? Math.min(100, Math.round((used / chat.contextSize) * 100)) : null
+
+ return createPortal(
+
+
e.stopPropagation()}
+ >
+
+
+
+
{t('settings.title')}
+
{t('settings.appliesNext')}
+
+
+
+
+
+
+
+
+ {t('settings.systemPrompt')}
+
+
+
+ {t('settings.sampling')}
+ set({ temperature: v })} onReset={() => set({ temperature: null })}
+ />
+ set({ topP: v })} onReset={() => set({ topP: null })}
+ />
+ parseInt(v, 10)}
+ onChange={(v) => set({ topK: v })} onReset={() => set({ topK: null })}
+ />
+
+
+
+ {t('settings.contextWindow')}
+
+
+ {t('settings.contextSize')}
+
+
+ {CONTEXT_STEPS.map(n => (
+ set({ contextSize: n })}
+ >
+ {n / 1024}k
+
+ ))}
+
+
set({ contextSize: parseInt(e.target.value, 10) || null })}
+ placeholder={t('settings.contextSizePlaceholder')}
+ />
+
+ {percent !== null
+ ? t('settings.contextUsing', { used, size: chat.contextSize, percent })
+ : t('settings.contextHelp')}
+
+
+
+
+
+ {t('settings.behaviour')}
+ {isAdmin && (
+
+
+ {t('settings.manageMode')}
+ {t('settings.manageModeDesc')}
+
+ set({ localaiAssistant: next })}
+ label={t('settings.manageMode')}
+ />
+
+ )}
+
+
+ {t('settings.focusMode')}
+ {t('settings.focusModeDesc')}
+
+
+
+
+
+ {isAdmin && chat.model && modelInfo && (
+
+ {t('header.modelInfoTitle', { model: chat.model })}
+
+ {modelInfo.backend &&
{t('modelInfo.backend')} {modelInfo.backend} }
+ {modelInfo.parameters?.model &&
{t('modelInfo.modelFile')} {modelInfo.parameters.model} }
+ {modelInfo.context_size > 0 &&
{t('modelInfo.contextSize')} {modelInfo.context_size} }
+ {modelInfo.threads > 0 &&
{t('modelInfo.threads')} {modelInfo.threads} }
+ {(modelInfo.mcp?.remote || modelInfo.mcp?.stdio) &&
{t('modelInfo.mcp')} {t('modelInfo.configured')} }
+ {modelInfo.template?.chat_message &&
{t('modelInfo.chatTemplate')} {t('modelInfo.yes')} }
+ {modelInfo.gpu_layers > 0 &&
{t('modelInfo.gpuLayers')} {modelInfo.gpu_layers} }
+
+
+ {t('header.editConfig')}
+
+
+ )}
+
+
+ {t('settings.conversation')}
+
+ {t('settings.clearHelp')}
+
+ {t('settings.clearHistory')}
+
+
+
+
+
+
,
+ document.body,
+ )
+}
diff --git a/core/http/react-ui/src/components/chat/EmptyChat.jsx b/core/http/react-ui/src/components/chat/EmptyChat.jsx
new file mode 100644
index 000000000..7432d47ff
--- /dev/null
+++ b/core/http/react-ui/src/components/chat/EmptyChat.jsx
@@ -0,0 +1,58 @@
+import { useTranslation } from 'react-i18next'
+// eslint-disable-next-line no-unused-vars
+import HomeResume from '../home/HomeResume'
+// eslint-disable-next-line no-unused-vars
+import NoModelCard from './NoModelCard'
+import Icon from '../Icon'
+
+// The top of an empty chat: one line that says what the page is for.
+export function EmptyHead({ name, manage }) {
+ const { t } = useTranslation('chat')
+ return (
+
+
{manage ? t('empty.manageTitle') : t('empty.title')}
+ {name}
+
+ )
+}
+
+// Under the composer of an empty chat: starters to try, whether the model is
+// ready, and the conversations you can go back to. With no model installed it
+// is the install card instead.
+export function EmptyUnder({
+ noModel, isAdmin, addToast, onInstallStarted, manage, model, warm, starters, onStarter,
+ conversations, leavingId, onResume, onDelete,
+}) {
+ const { t } = useTranslation('chat')
+ if (noModel) {
+ return
+ }
+ return (
+ <>
+ {manage && {t('empty.manageText')}
}
+
+ {t('empty.try')}
+ {starters.map(prompt => (
+ onStarter(prompt)}>
+ {prompt}
+
+ ))}
+
+ {model && (
+
+
+ {warm ? t('empty.warmReady', { model }) : t('empty.coldReady', { model })}
+
+ )}
+ {conversations.length > 0 && (
+
+ )}
+ {t('empty.hintKeys')}
+ >
+ )
+}
diff --git a/core/http/react-ui/src/components/chat/FindBar.jsx b/core/http/react-ui/src/components/chat/FindBar.jsx
new file mode 100644
index 000000000..59b994a5b
--- /dev/null
+++ b/core/http/react-ui/src/components/chat/FindBar.jsx
@@ -0,0 +1,47 @@
+import { useEffect, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
+import Icon from '../Icon'
+
+// Search in this chat: a bar under the header with the count and the arrows.
+// Enter goes to the next match, Shift+Enter to the previous one, Esc closes.
+export default function FindBar({ query, onQuery, index, count, onStep, onClose, focusToken }) {
+ const { t } = useTranslation('chat')
+ const inputRef = useRef(null)
+
+ // Opening it again, from the keyboard, puts the cursor back in the box.
+ useEffect(() => {
+ inputRef.current?.focus()
+ inputRef.current?.select()
+ }, [focusToken])
+
+ return (
+
+
+ onQuery(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') { e.preventDefault(); onStep(e.shiftKey ? -1 : 1) }
+ else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose() }
+ }}
+ placeholder={t('find.placeholder')}
+ aria-label={t('find.placeholder')}
+ data-testid="chat-find-input"
+ />
+
+ {query ? (count > 0 ? t('find.count', { current: index + 1, total: count }) : t('find.none')) : ''}
+
+ onStep(-1)} disabled={count === 0} title={t('find.previous')} aria-label={t('find.previous')}>
+
+
+ onStep(1)} disabled={count === 0} title={t('find.next')} aria-label={t('find.next')}>
+
+
+
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/chat/LoadCard.jsx b/core/http/react-ui/src/components/chat/LoadCard.jsx
new file mode 100644
index 000000000..431424b61
--- /dev/null
+++ b/core/http/react-ui/src/components/chat/LoadCard.jsx
@@ -0,0 +1,44 @@
+import { useTranslation } from 'react-i18next'
+import { fillStyle } from '../home/memory'
+import { gbLabel, gbNumber } from '../../utils/modelLedger'
+import Icon from '../Icon'
+
+// What the reply is waiting for while the model comes up. It shows only what the
+// page knows: the phase the server names (preparing, installing the backend,
+// staging files, loading), the node, the bytes and the remaining time when the
+// server reports them. A model that is simply not loaded yet has no phases to
+// show, so the card says so and nothing else.
+//
+// `progress` is { label, progress, detail, sent, total } from the page, or null.
+export default function LoadCard({ model, progress }) {
+ const { t } = useTranslation('chat')
+ const pct = progress?.progress > 0 ? Math.min(100, progress.progress) : 0
+ return (
+
+
+
+ {t('load.title')}
+ {model && {model}}
+
+ {progress && (
+
+
+
+ {progress.label}
+ {progress.total > 0 && (
+ {t('load.bytes', { sent: gbNumber(progress.sent), total: gbLabel(progress.total) })}
+ )}
+ {pct > 0 && (
+
+
+
+ )}
+
+
+ )}
+ {pct > 0 &&
{Math.round(pct)}%
}
+ {progress?.detail &&
{progress.detail}
}
+
{progress ? t('load.queued') : t('load.cold')}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/chat/NoModelCard.jsx b/core/http/react-ui/src/components/chat/NoModelCard.jsx
new file mode 100644
index 000000000..49439078e
--- /dev/null
+++ b/core/http/react-ui/src/components/chat/NoModelCard.jsx
@@ -0,0 +1,32 @@
+import { useTranslation } from 'react-i18next'
+import { useNavigate } from 'react-router-dom'
+// eslint-disable-next-line no-unused-vars
+import StarterModels from '../StarterModels'
+import Icon from '../Icon'
+
+// Chat has nothing to talk to: no chat model is installed. Say so, offer the
+// way forward (the starter models for this hardware, the gallery, importing
+// your own) and leave the composer where it is, so what you typed stays.
+export default function NoModelCard({ isAdmin, addToast, onInstallStarted }) {
+ const { t } = useTranslation('chat')
+ const navigate = useNavigate()
+ return (
+
+ {t('noModel.title')}
+ {isAdmin ? t('noModel.body') : t('noModel.bodyMember')}
+ {isAdmin && (
+ <>
+
+
+ navigate('/app/models')}>
+ {t('noModel.gallery')}
+
+ navigate('/app/import-model')}>
+ {t('noModel.import')}
+
+
+ >
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/chat/ShortcutsDialog.jsx b/core/http/react-ui/src/components/chat/ShortcutsDialog.jsx
new file mode 100644
index 000000000..d5841ccd4
--- /dev/null
+++ b/core/http/react-ui/src/components/chat/ShortcutsDialog.jsx
@@ -0,0 +1,58 @@
+import { useEffect, useRef } from 'react'
+import { createPortal } from 'react-dom'
+import { useTranslation } from 'react-i18next'
+
+const MOD = () => (typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || '') ? '⌘' : 'Ctrl')
+
+// The keys the chat page answers to. Every row is a key that does something.
+export default function ShortcutsDialog({ onClose, canFind }) {
+ const { t } = useTranslation('chat')
+ const ref = useRef(null)
+ const mod = MOD()
+
+ useEffect(() => {
+ const el = ref.current
+ el?.focus()
+ const onKey = (e) => {
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose() }
+ }
+ window.addEventListener('keydown', onKey, true)
+ return () => window.removeEventListener('keydown', onKey, true)
+ }, [onClose])
+
+ const rows = [
+ [t('shortcuts.conversations'), [mod, 'K']],
+ ...(canFind ? [[t('shortcuts.find'), [mod, 'Shift', 'F']]] : []),
+ [t('shortcuts.actions'), ['/']],
+ [t('shortcuts.send'), ['Enter']],
+ [t('shortcuts.newline'), ['Shift', 'Enter']],
+ [t('shortcuts.editLast'), ['↑'], t('shortcuts.inEmptyBox')],
+ [t('shortcuts.stop'), ['Esc']],
+ [t('shortcuts.moveMessages'), ['↑', '↓']],
+ [t('shortcuts.onMessage'), ['C', 'E', 'R', 'B']],
+ ]
+
+ return createPortal(
+ <>
+
+
+
{t('shortcuts.title')}
+
+ {rows.map(([label, keys, note]) => (
+
+
{label}
+
+ {keys.map(k => {k} )}
+ {note && {note} }
+
+
+ ))}
+
+
+ {t('shortcuts.close')}
+
+
+ >,
+ document.body,
+ )
+}
diff --git a/core/http/react-ui/src/components/chat/chatActions.js b/core/http/react-ui/src/components/chat/chatActions.js
new file mode 100644
index 000000000..9052bc8cb
--- /dev/null
+++ b/core/http/react-ui/src/components/chat/chatActions.js
@@ -0,0 +1,27 @@
+// The slash actions on the Chat composer. They are the same shape as Home's
+// (see components/home/homeActions.js) and every one has a function on the page
+// today. `needs` hides an action the person cannot use right now.
+
+export const CHAT_SLASH_GROUPS = ['chat', 'view']
+
+export const CHAT_SLASH_ACTIONS = [
+ { id: 'model', cmd: '/model', icon: 'cube', group: 'chat', needs: 'models' },
+ { id: 'new', cmd: '/new', icon: 'plus', group: 'chat' },
+ { id: 'chats', cmd: '/chats', icon: 'history', group: 'chat' },
+ { id: 'assistant', cmd: '/assistant', icon: 'user-shield', group: 'chat', needs: 'admin' },
+ { id: 'canvas', cmd: '/canvas', icon: 'columns', group: 'view' },
+ { id: 'find', cmd: '/find', icon: 'search', group: 'view', needs: 'thread' },
+ { id: 'settings', cmd: '/settings', icon: 'sliders', group: 'view' },
+ { id: 'export', cmd: '/export', icon: 'export', group: 'view', needs: 'thread' },
+ { id: 'clear', cmd: '/clear', icon: 'eraser', group: 'view', needs: 'thread' },
+]
+
+// `ctx`: { isAdmin, hasModels, hasThread }
+export function availableChatActions(ctx) {
+ return CHAT_SLASH_ACTIONS.filter(a => {
+ if (a.needs === 'admin') return ctx.isAdmin
+ if (a.needs === 'models') return ctx.hasModels
+ if (a.needs === 'thread') return ctx.hasThread
+ return true
+ })
+}
diff --git a/core/http/react-ui/src/components/chat/chatText.js b/core/http/react-ui/src/components/chat/chatText.js
new file mode 100644
index 000000000..aa51e10d4
--- /dev/null
+++ b/core/http/react-ui/src/components/chat/chatText.js
@@ -0,0 +1,59 @@
+// Small pure helpers for the chat thread. They read the message shapes useChat
+// stores (a string, or a list of typed blocks) and never change them.
+
+export function messageText(content) {
+ if (typeof content === 'string') return content
+ if (Array.isArray(content)) return content.find(b => b?.type === 'text')?.text || ''
+ return ''
+}
+
+// The text a message can be edited as, or null when it has none (a message made
+// only of attachments, for example).
+export function editableMessageText(message) {
+ if (typeof message.content === 'string') return message.content
+ if (!Array.isArray(message.content)) return null
+ const textBlock = message.content.find(block => block?.type === 'text')
+ return typeof textBlock?.text === 'string' ? textBlock.text : null
+}
+
+// The edited text goes back into the same block, so attachments and the file
+// text a user message carries survive an edit.
+export function withEditedMessageText(message, text) {
+ if (typeof message.content === 'string') return { ...message, content: text }
+ const textIndex = message.content.findIndex(block => block?.type === 'text')
+ return {
+ ...message,
+ content: message.content.map((block, index) =>
+ index === textIndex ? { ...block, text } : block
+ ),
+ }
+}
+
+// useChat appends a failure to the answer as "Error: ..." after a blank line,
+// or as the whole message when nothing was written yet. Only that shape counts
+// as an error: an answer that quotes "Error:" mid-sentence is just an answer.
+const ERROR_TAIL = /(?:^|\n\n)Error: ([\s\S]*)$/
+
+export function splitError(content) {
+ if (typeof content !== 'string') return null
+ const m = ERROR_TAIL.exec(content)
+ if (!m) return null
+ return { text: content.slice(0, m.index).trimEnd(), message: m[1].trim() }
+}
+
+export function escapeHtml(text) {
+ return String(text).replace(/[&<>"']/g, c => (
+ { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]
+ ))
+}
+
+// True for the roles that fold into the one-line activity summary.
+export function isActivityRole(role) {
+ return role === 'thinking' || role === 'reasoning' || role === 'tool_call' || role === 'tool_result'
+}
+
+export function isTyping(el) {
+ if (!el) return false
+ const tag = el.tagName
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable
+}
diff --git a/core/http/react-ui/src/components/chat/chatText.test.js b/core/http/react-ui/src/components/chat/chatText.test.js
new file mode 100644
index 000000000..d2ed03848
--- /dev/null
+++ b/core/http/react-ui/src/components/chat/chatText.test.js
@@ -0,0 +1,51 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import {
+ editableMessageText, escapeHtml, isActivityRole, messageText, splitError, withEditedMessageText,
+} from './chatText.js'
+
+test('a message is a string or a list of blocks, and the text is the first text block', () => {
+ assert.equal(messageText('hello'), 'hello')
+ assert.equal(messageText([{ type: 'image_url', image_url: { url: 'x' } }, { type: 'text', text: 'look' }]), 'look')
+ assert.equal(messageText(null), '')
+ assert.equal(messageText([{ type: 'image_url' }]), '')
+})
+
+test('only a message with a text block can be edited, and an edit keeps the other blocks', () => {
+ const withFile = {
+ role: 'user',
+ content: [
+ { type: 'text', text: 'what does it say' },
+ { type: 'text', text: '\n\n--- File: a.txt ---\nsecret\n--- End of a.txt ---' },
+ ],
+ files: [{ name: 'a.txt', type: 'file' }],
+ }
+ assert.equal(editableMessageText({ role: 'user', content: 'plain' }), 'plain')
+ assert.equal(editableMessageText(withFile), 'what does it say')
+ assert.equal(editableMessageText({ role: 'user', content: [{ type: 'image_url' }] }), null)
+ assert.equal(editableMessageText({ role: 'user', content: null }), null)
+
+ const edited = withEditedMessageText(withFile, 'edited')
+ assert.equal(edited.content[0].text, 'edited')
+ assert.equal(edited.content[1], withFile.content[1])
+ assert.deepEqual(edited.files, withFile.files)
+ assert.equal(withEditedMessageText({ role: 'user', content: 'a' }, 'b').content, 'b')
+})
+
+test('an error is the tail the chat hook appends, not any mention of the word', () => {
+ assert.deepEqual(splitError('Error: out of memory'), { text: '', message: 'out of memory' })
+ assert.deepEqual(splitError('Half an answer\n\nError: link dropped'), { text: 'Half an answer', message: 'link dropped' })
+ assert.equal(splitError('It prints Error: file not found when the path is wrong.'), null)
+ assert.equal(splitError('Fine answer'), null)
+ assert.equal(splitError(null), null)
+})
+
+test('activity roles are the four that fold into one line', () => {
+ for (const role of ['thinking', 'reasoning', 'tool_call', 'tool_result']) assert.equal(isActivityRole(role), true)
+ for (const role of ['user', 'assistant', 'system']) assert.equal(isActivityRole(role), false)
+})
+
+test('escaping keeps typed text from becoming markup', () => {
+ assert.equal(escapeHtml('"a" & \'b\' '), '<b>"a" & 'b'</b>')
+})
diff --git a/core/http/react-ui/src/components/chat/findInThread.js b/core/http/react-ui/src/components/chat/findInThread.js
new file mode 100644
index 000000000..936e0b2bf
--- /dev/null
+++ b/core/http/react-ui/src/components/chat/findInThread.js
@@ -0,0 +1,54 @@
+// Find in chat, done in the browser on what is already on the page. Nothing is
+// sent to the server: the search covers the messages that are loaded here.
+
+const SKIP = 'button, textarea, input, .cx-acts, .cx-who, .code-block__head, script, style, mark.cx-hit'
+
+export function clearFind(root) {
+ if (!root) return
+ root.querySelectorAll('mark.cx-hit').forEach((mark) => {
+ const parent = mark.parentNode
+ if (!parent) return
+ parent.replaceChild(document.createTextNode(mark.textContent), mark)
+ parent.normalize()
+ })
+}
+
+// Wrap each case-insensitive match of `query` in and
+// return the marks in reading order.
+export function applyFind(root, query) {
+ clearFind(root)
+ const q = (query || '').toLowerCase()
+ if (!root || !q) return []
+
+ const nodes = []
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
+ acceptNode(node) {
+ if (!node.nodeValue || !node.nodeValue.toLowerCase().includes(q)) return NodeFilter.FILTER_REJECT
+ if (node.parentElement?.closest(SKIP)) return NodeFilter.FILTER_REJECT
+ return NodeFilter.FILTER_ACCEPT
+ },
+ })
+ while (walker.nextNode()) nodes.push(walker.currentNode)
+
+ const marks = []
+ for (const node of nodes) {
+ const text = node.nodeValue
+ const lower = text.toLowerCase()
+ const frag = document.createDocumentFragment()
+ let from = 0
+ let at = lower.indexOf(q)
+ while (at !== -1) {
+ if (at > from) frag.appendChild(document.createTextNode(text.slice(from, at)))
+ const mark = document.createElement('mark')
+ mark.className = 'cx-hit'
+ mark.textContent = text.slice(at, at + q.length)
+ frag.appendChild(mark)
+ marks.push(mark)
+ from = at + q.length
+ at = lower.indexOf(q, from)
+ }
+ if (from < text.length) frag.appendChild(document.createTextNode(text.slice(from)))
+ node.parentNode.replaceChild(frag, node)
+ }
+ return marks
+}
diff --git a/core/http/react-ui/src/components/console/ConsoleLayout.jsx b/core/http/react-ui/src/components/console/ConsoleLayout.jsx
deleted file mode 100644
index f0f5f32f9..000000000
--- a/core/http/react-ui/src/components/console/ConsoleLayout.jsx
+++ /dev/null
@@ -1,161 +0,0 @@
-import { useState, useEffect, Suspense } from 'react'
-import { NavLink, Outlet, useOutletContext, useLocation } from 'react-router-dom'
-import { useTranslation } from 'react-i18next'
-import { useAuth } from '../../context/AuthContext'
-import { apiUrl } from '../../utils/basePath'
-import { preloadRoute } from '../../router'
-import RouteFallback from '../RouteFallback'
-import { isConsoleItemVisible } from './consoleConfig'
-import { OperateSummaryProvider, useOperateSummary } from '../../contexts/OperateSummaryContext'
-
-// The App wraps the outlet in key={pathname}, so this layout remounts on every
-// sub-navigation. Tracking the last-entered console id across mounts lets us
-// play the rail's entrance animation only when actually entering a console
-// (from outside), not when switching items within it — otherwise it flashes.
-let lastConsoleId = null
-
-// /api/features rarely changes; cache it across remounts so the rail renders
-// the correct (gated) item set immediately instead of flashing the wrong set
-// while a fresh fetch resolves on every sub-navigation.
-let featuresCache = {}
-const CONSOLE_RAIL_COLLAPSED_KEY = 'localai_console_rail_collapsed'
-
-// Generic secondary-rail layout shared by the Build and Operate consoles.
-// Driven entirely by a config from consoleConfig.js, so the rail, its gating,
-// and the sidebar entry that opens it stay in sync. Mounted as a PATHLESS
-// route in router.jsx — wrapped pages keep their existing flat URLs.
-
-function RailItem({ item, label, collapsed }) {
- // Null outside Operate, where no provider is mounted — the rail then renders
- // exactly as it did before signals existed.
- const summary = useOperateSummary()
- const signal = item.signal ? summary?.signals?.[item.signal] : null
-
- if (item.external) {
- return (
-
-
- {label}
-
-
- )
- }
- return (
- `nav-item ${isActive ? 'active' : ''}`}
- onMouseEnter={() => preloadRoute(item.path)}
- onFocus={() => preloadRoute(item.path)}
- aria-label={collapsed ? label : undefined}
- title={collapsed ? label : undefined}
- >
-
- {label}
- {/* Ambient, and hidden from assistive tech: it changes under the reader
- and is never the only place a fact appears. The overview states the
- same things in prose, where they can be read deliberately. */}
- {signal != null && {signal} }
-
- )
-}
-
-function ConsoleLayoutInner({ config }) {
- const { t } = useTranslation('nav')
- const { isAdmin, authEnabled, hasFeature } = useAuth()
- const [features, setFeatures] = useState(featuresCache)
- const [railOpen, setRailOpen] = useState(false)
- const [railCollapsed, setRailCollapsed] = useState(() => {
- try { return localStorage.getItem(CONSOLE_RAIL_COLLAPSED_KEY) === 'true' } catch (_) { return false }
- })
- const location = useLocation()
- // Forward the App-level outlet context (e.g. addToast) — a nested bare
- // would otherwise shadow it with undefined and crash pages.
- const outletContext = useOutletContext()
- // True only when entering this console fresh; false on item-to-item nav.
- const [entering] = useState(() => {
- const fresh = lastConsoleId !== config.id
- lastConsoleId = config.id
- return fresh
- })
-
- useEffect(() => {
- fetch(apiUrl('/api/features'))
- .then(r => r.json())
- .then(f => { featuresCache = f; setFeatures(f) })
- .catch(() => {})
- }, [])
-
- const auth = { isAdmin, authEnabled, hasFeature, features }
-
- const toggleRailCollapsed = () => {
- const next = !railCollapsed
- try { localStorage.setItem(CONSOLE_RAIL_COLLAPSED_KEY, String(next)) } catch (_) { /* ignore */ }
- setRailCollapsed(next)
- }
-
- return (
-
-
-
-
-
- {t(config.titleKey)}
-
- setRailOpen(open => !open)}
- >
-
-
-
-
-
-
-
- {config.groups.map((group, gi) => {
- const items = group.items.filter(item => isConsoleItemVisible(item, auth))
- if (items.length === 0) return null
- return (
-
- {group.titleKey &&
{t(group.titleKey)}
}
- {items.map(item => (
-
- ))}
-
- )
- })}
-
-
-
- {/* Own Suspense so a lazy page shows the loader in the body while the
- rail stays put (instead of bubbling to App's boundary). */}
- }>
-
-
-
-
- )
-}
-
-// The summary provider wraps the Operate console and nothing else. That is the
-// whole of "poll only while the user is in Operate": elsewhere the provider is
-// not mounted, so no timer exists to gate. Build gets the plain layout.
-export default function ConsoleLayout({ config }) {
- if (config.id !== 'operate') return
- return (
-
-
-
- )
-}
diff --git a/core/http/react-ui/src/components/console/consoleConfig.js b/core/http/react-ui/src/components/console/consoleConfig.js
deleted file mode 100644
index d910c6e6b..000000000
--- a/core/http/react-ui/src/components/console/consoleConfig.js
+++ /dev/null
@@ -1,137 +0,0 @@
-// Console configuration shared by ConsoleLayout (renders the rail) and Sidebar
-// (renders the single tier entry + computes its active state). Keeping the tier
-// definitions in one place means the sidebar entry, its active-path matching,
-// and the console rail can never drift out of sync.
-//
-// A console is a tier that opens a secondary rail instead of expanding inline.
-// Each item carries its own gating (adminOnly / authOnly / feature /
-// requiresAgentPool) so visibility matches the legacy sidebar exactly. Note
-// `/api/features` only emits: agents, mcp, fine_tuning, quantization,
-// distributed, localai_assistant — capability-style flags (face_recognition,
-// skills, …) come from hasFeature(), not the features map.
-
-// Recognition (Faces/Voices) lives as a GROUP inside the Build console rather
-// than its own tier — it's an AI capability you build with, so keeping it next
-// to Automation/Training avoids it feeling split off on its own.
-export const buildConsole = {
- id: 'build',
- titleKey: 'sections.build',
- icon: 'fas fa-screwdriver-wrench',
- groups: [
- {
- titleKey: 'console.automation',
- items: [
- { path: '/app/agents', icon: 'fas fa-robot', labelKey: 'items.agents', feature: 'agents', requiresAgentPool: true },
- { path: '/app/skills', icon: 'fas fa-wand-magic-sparkles', labelKey: 'items.skills', feature: 'skills', requiresAgentPool: true },
- { path: '/app/collections', icon: 'fas fa-database', labelKey: 'items.memory', feature: 'collections', requiresAgentPool: true },
- { path: '/app/agent-jobs', icon: 'fas fa-tasks', labelKey: 'items.jobs', feature: 'mcp', requiresAgentPool: true },
- ],
- },
- {
- titleKey: 'console.training',
- items: [
- { path: '/app/fine-tune', icon: 'fas fa-graduation-cap', labelKey: 'items.fineTune', feature: 'fine_tuning' },
- { path: '/app/quantize', icon: 'fas fa-compress', labelKey: 'items.quantize', feature: 'quantization' },
- ],
- },
- {
- titleKey: 'sections.recognition',
- items: [
- { path: '/app/face', icon: 'fas fa-face-smile', labelKey: 'items.faces', feature: 'face_recognition' },
- { path: '/app/voice', icon: 'fas fa-microphone-lines', labelKey: 'items.voices', feature: 'voice_recognition' },
- ],
- },
- ],
-}
-
-// Four groups, not six. Inference and Activity were both "the runtime right
-// now"; Access and System were both administration. Six headings over thirteen
-// items is a list with extra steps. Nothing is removed and no gate changes —
-// the heading an item sits under is the only thing that moves.
-//
-// Overview leads the first group so firstVisiblePath() returns it without
-// needing to know it exists. Before it, opening Operate landed on Backends
-// because Backends happened to be written first.
-//
-// `signal` names a value the rail renders beside the label (see
-// OperateSummaryContext). It is orientation while inside Operate, NOT an
-// alarm: the rail exists only on Operate routes and can be collapsed, which is
-// precisely why the operations badge stays on the sidebar entry. Nothing
-// urgent may depend on a rail signal alone.
-export const operateConsole = {
- id: 'operate',
- titleKey: 'sections.operate',
- icon: 'fas fa-sliders',
- groups: [
- {
- titleKey: 'operate.runtime',
- items: [
- { path: '/app/operate', icon: 'fas fa-gauge-high', labelKey: 'items.overview', adminOnly: true, signal: 'attention' },
- // The Nodes route under the name it has on a single-node install,
- // where it shows this host and what is loaded on it. With distributed
- // mode on, the Cluster group's Nodes entry takes over instead.
- { path: '/app/nodes', icon: 'fas fa-desktop', labelKey: 'items.thisMachine', adminOnly: true, unlessFeature: 'distributed', signal: 'running' },
- { path: '/app/backends', icon: 'fas fa-server', labelKey: 'items.backends', adminOnly: true, signal: 'backends' },
- { path: '/app/voice-library', icon: 'fas fa-wave-square', labelKey: 'items.voiceLibrary', adminOnly: true },
- { path: '/app/activity', icon: 'fas fa-download', labelKey: 'items.activity', adminOnly: true, badge: 'operations', signal: 'activity' },
- { path: '/app/failover', icon: 'fas fa-shuffle', labelKey: 'items.failover', adminOnly: true },
- ],
- },
- {
- titleKey: 'operate.cluster',
- items: [
- { path: '/app/nodes', icon: 'fas fa-network-wired', labelKey: 'items.nodes', adminOnly: true, feature: 'distributed', signal: 'nodes' },
- { path: '/app/scheduling', icon: 'fas fa-calendar-alt', labelKey: 'items.scheduling', adminOnly: true, feature: 'distributed' },
- { path: '/app/p2p', icon: 'fas fa-circle-nodes', labelKey: 'items.swarm', adminOnly: true },
- ],
- },
- {
- titleKey: 'operate.observability',
- items: [
- { path: '/app/usage', icon: 'fas fa-chart-bar', labelKey: 'items.usage', adminOnly: true, signal: 'usage' },
- { path: '/app/traces', icon: 'fas fa-chart-line', labelKey: 'items.traces', adminOnly: true, signal: 'traces' },
- ],
- },
- {
- titleKey: 'operate.administration',
- items: [
- { path: '/app/users', icon: 'fas fa-users', labelKey: 'items.users', adminOnly: true, authOnly: true },
- { path: '/app/middleware', icon: 'fas fa-shield-halved', labelKey: 'items.middleware', adminOnly: true },
- { path: '/app/settings', icon: 'fas fa-cog', labelKey: 'items.settings', adminOnly: true },
- { href: '/swagger/index.html', icon: 'fas fa-code', labelKey: 'items.api', external: true, adminOnly: true },
- ],
- },
- ],
-}
-
-export const consoles = [buildConsole, operateConsole]
-
-// Single source of truth for item visibility — mirrors the legacy sidebar
-// filterItem + section gates.
-export function isConsoleItemVisible(item, { isAdmin, authEnabled, hasFeature, features }) {
- if (item.adminOnly && !isAdmin) return false
- if (item.authOnly && !authEnabled) return false
- if (item.requiresAgentPool && features.agents === false) return false
- if (item.feature && features[item.feature] === false) return false
- if (item.feature && !hasFeature(item.feature)) return false
- // Hidden until /api/features has answered: showing it and then swapping it
- // for the cluster entry would move the rail under the user's pointer.
- if (item.unlessFeature && features[item.unlessFeature] !== false) return false
- return true
-}
-
-// All nav paths a console owns (for the sidebar entry's active-state match).
-export function consolePaths(config) {
- return config.groups.flatMap(g => g.items.filter(i => i.path).map(i => i.path))
-}
-
-// The page the tier's single sidebar entry links to: its first visible item.
-// Returns null when nothing is visible (so the entry can be hidden entirely).
-export function firstVisiblePath(config, auth) {
- for (const group of config.groups) {
- for (const item of group.items) {
- if (item.path && isConsoleItemVisible(item, auth)) return item.path
- }
- }
- return null
-}
diff --git a/core/http/react-ui/src/components/home/HomeComposer.jsx b/core/http/react-ui/src/components/home/HomeComposer.jsx
new file mode 100644
index 000000000..1053c5921
--- /dev/null
+++ b/core/http/react-ui/src/components/home/HomeComposer.jsx
@@ -0,0 +1,248 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+// eslint-disable-next-line no-unused-vars
+import { useTranslation, Trans } from 'react-i18next'
+import { availableActions, filterActions, SLASH_GROUPS } from './homeActions'
+import Icon from '../Icon'
+
+// The Enter glyph is not in the kit sprite. A return arrow in a darker cell
+// teaches the shortcut next to the label.
+// eslint-disable-next-line no-unused-vars
+function EnterGlyph() {
+ return (
+
+
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function Marked({ text, upto }) {
+ if (!upto) return text
+ return (<>{text.slice(0, upto)} {text.slice(upto)}>)
+}
+
+// The command bar: model chip, MCP chip, message box, attach buttons, Send and
+// the slash menu. It owns only the transient menu state; the message, files and
+// what Send does belong to the page.
+//
+// Home uses it as is. Chat uses the same bar and adds what a conversation
+// needs, all optional:
+// chips more chips after the MCP chip (the Canvas chip)
+// slash { actions, groups, label, desc, groupLabel } for its own menu
+// streaming swaps Send for a Stop button that calls onStop
+// onPaste, onKeyDownExtra (return true to take the key), fileAccept, rows,
+// testIds and class names for the pieces tests and the thread reach for
+export default function HomeComposer({
+ message, onMessage, onSubmit, canSend, sending, sendTitle, textareaRef,
+ picker, mcp, chips, files, onRemoveFile, onAttach,
+ placeholder, slashContext, onRunAction, slash,
+ streaming = false, onStop, stopTitle, onPaste, onKeyDownExtra,
+ fileAccept = '.txt,.md,.pdf', rows = 3, strictEnter = false, testId = 'home-composer',
+ textareaTestId, textareaClass = '', sendId, sendTestId, hint,
+}) {
+ const { t } = useTranslation('home')
+ const imageRef = useRef(null)
+ const audioRef = useRef(null)
+ const fileRef = useRef(null)
+ const [active, setActive] = useState(0)
+ const [dismissed, setDismissed] = useState(false)
+
+ const groupList = slash?.groups || SLASH_GROUPS
+ const labelOf = useCallback((a) => (slash ? slash.label(a) : t(`slash.${a.id}.label`)), [t, slash])
+ const descOf = (a) => (slash ? slash.desc(a) : t(`slash.${a.id}.desc`))
+ const groupLabel = (g) => (slash ? slash.groupLabel(g) : t(`slash.group.${g}`))
+ const actions = useMemo(
+ () => (slash ? slash.actions : availableActions(slashContext)),
+ [slash, slashContext],
+ )
+ // The menu belongs to a message that is only a command: it starts with a
+ // slash and holds no space. "/etc/hosts how do I..." is just a message.
+ const query = message.startsWith('/') && !/\s/.test(message) ? message.slice(1).toLowerCase() : null
+ const items = useMemo(
+ () => (query === null ? [] : filterActions(actions, query, labelOf, groupList)),
+ [query, actions, labelOf, groupList],
+ )
+ const open = query !== null && items.length > 0 && !dismissed
+ const safeActive = Math.min(active, Math.max(0, items.length - 1))
+
+ useEffect(() => { setActive(0); setDismissed(false) }, [message])
+
+ useEffect(() => {
+ if (!open) return
+ document.getElementById(`home-slash-opt-${safeActive}`)?.scrollIntoView?.({ block: 'nearest' })
+ }, [open, safeActive])
+
+ // Copy the list before clearing the input, which empties the live FileList.
+ const attach = (kind, input) => {
+ const list = Array.from(input.files || [])
+ input.value = ''
+ if (list.length) onAttach(kind, list)
+ }
+
+ const run = (action) => {
+ onMessage('')
+ onRunAction(action.id)
+ }
+
+ const onKeyDown = (e) => {
+ if (e.nativeEvent.isComposing) return
+ if (!open && onKeyDownExtra?.(e)) return
+ if (open) {
+ const n = items.length
+ if (e.key === 'ArrowDown') { e.preventDefault(); setActive((safeActive + 1) % n); return }
+ if (e.key === 'ArrowUp') { e.preventDefault(); setActive((safeActive + n - 1) % n); return }
+ if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); run(items[safeActive]); return }
+ if (e.key === 'Escape') { e.preventDefault(); setDismissed(true); return }
+ }
+ // With strictEnter only a bare Enter sends: Ctrl, Cmd and Alt Enter add a
+ // line, as the Chat box always did.
+ const bare = !strictEnter || (!e.ctrlKey && !e.metaKey && !e.altKey && e.keyCode !== 229)
+ if (e.key === 'Enter' && !e.shiftKey && bare) {
+ e.preventDefault()
+ onSubmit()
+ }
+ }
+
+ const activeId = open ? `home-slash-opt-${safeActive}` : undefined
+
+ // Group labels appear once, above the first item of each group.
+ const groupStart = {}
+ items.forEach((a, i) => { if (!(a.group in groupStart)) groupStart[a.group] = i })
+
+ return (
+
+ )
+}
diff --git a/core/http/react-ui/src/components/home/HomeFirstRun.jsx b/core/http/react-ui/src/components/home/HomeFirstRun.jsx
new file mode 100644
index 000000000..dcae6a4ec
--- /dev/null
+++ b/core/http/react-ui/src/components/home/HomeFirstRun.jsx
@@ -0,0 +1,47 @@
+import { useTranslation } from 'react-i18next'
+// eslint-disable-next-line no-unused-vars
+import StarterModels from '../StarterModels'
+import Icon from '../Icon'
+
+// First run: no model is installed yet. Three steps, with the recommended
+// models for the detected hardware inside the second one, so the way forward
+// is on the page without a separate wizard.
+export default function HomeFirstRun({ addToast, onInstallStarted, onGallery, onImport }) {
+ const { t } = useTranslation('home')
+ return (
+
+
+ 1
+
+
{t('wizard.steps.step1Title')}
+
{t('wizard.steps.step1Body')}
+
+
+ {t('wizard.browseGallery')}
+
+
+ {t('wizard.importModel')}
+
+
+
+
+
+ 2
+
+
{t('wizard.steps.step2Title')}
+
{t('wizard.steps.step2Body')}
+
+
+
+
+
+
+ 3
+
+ {t('wizard.steps.step3Title')}
+ {t('wizard.steps.step3Body')}
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/home/HomeMemoryStrip.jsx b/core/http/react-ui/src/components/home/HomeMemoryStrip.jsx
new file mode 100644
index 000000000..0d371641a
--- /dev/null
+++ b/core/http/react-ui/src/components/home/HomeMemoryStrip.jsx
@@ -0,0 +1,178 @@
+import { useTranslation } from 'react-i18next'
+import { hostMemory, memoryFigure, fillStyle, percent } from './memory'
+import Icon from '../Icon'
+
+// eslint-disable-next-line no-unused-vars
+function Bar({ pct, thin = false, label }) {
+ return (
+ = 90 ? 'warn' : undefined}
+ >
+
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function Figure({ used, total }) {
+ const f = memoryFigure(used, total)
+ if (!f) return null
+ return {f.used} / {f.total} {f.unit}
+}
+
+// One line about memory that opens into the list of loaded models. It opens by
+// itself while a model is being staged and after a failure, because memory and
+// models are the message then.
+//
+// The bar is the memory in use on the device, drawn as one solid fill. The API
+// reports no per-model figure, so the list carries the model and its engine and
+// no size.
+export default function HomeMemoryStrip({
+ open, onOpenChange, models, resources, cluster, stagingOp, failedOp, onStop, onStopAll,
+}) {
+ const { t } = useTranslation('home')
+ const count = models.length
+ const host = hostMemory(resources)
+ const clustered = !!cluster
+ const body = clustered || count > 0 || host || stagingOp || failedOp
+
+ let lead
+ let text
+ let mode = ''
+ if (failedOp) {
+ mode = 'error'
+ lead =
+ text = {failedOp.name || failedOp.id} {t('strip.failed')}
+ } else if (stagingOp) {
+ lead =
+ text = (
+
+ {t('strip.staging', { name: stagingOp.name || stagingOp.id })}
+ {stagingOp.nodeName ? ` ${t('strip.stagingTo', { node: stagingOp.nodeName })}` : ''}
+
+ )
+ } else if (clustered) {
+ lead =
+ text = (
+
+ {count} {t('strip.modelsOn', { count })}{' '}
+ {cluster.healthyCount}/{cluster.totalCount} {t('strip.nodes', { count: cluster.totalCount })}
+
+ )
+ } else {
+ lead = 0 ? '' : ' home-dot--cold'}`} aria-hidden="true" />
+ text = count > 0
+ ? {count} {t('strip.modelsLoaded', { count })}
+ : {t('strip.noneLoaded')}
+ }
+
+ const memUsed = clustered ? cluster.usedMem : host?.used
+ const memTotal = clustered ? cluster.totalMem : host?.total
+ const memPct = clustered ? percent(cluster.usedMem, cluster.totalMem) : host?.pct
+ const showMem = !failedOp && memTotal > 0
+
+ return (
+
+ onOpenChange(!open)}
+ disabled={!body}
+ >
+ {lead}
+ {text}
+ {stagingOp && stagingOp.progress > 0 && !failedOp && (
+ <>
+
+ {Math.round(stagingOp.progress)}%
+ >
+ )}
+ {showMem && !(stagingOp && stagingOp.progress > 0) && (
+ <>
+ {!clustered && }
+
+ >
+ )}
+ {!clustered && host && !failedOp && !stagingOp && (
+
+ {host.isGpu ? (host.gpuCount > 1 ? t('strip.gpus', { count: host.gpuCount }) : (host.device || t('resourceGpu'))) : t('resourceRam')}
+
+ )}
+ {clustered && !failedOp && !stagingOp && cluster.nodes.length > 0 && (
+
+ {cluster.nodes.slice(0, 3).map(n => (
+
+ {n.name}
+
+
+ ))}
+
+ )}
+
+
+
+ {open && body && (
+
+ {clustered && cluster.nodes.length > 0 && (
+
+ {cluster.nodes.map(n => (
+
+
+ {n.name}
+
+
+
+ ))}
+
+ )}
+
+ {count > 0 ? (
+
+ {[...models].sort((a, b) => a.id.localeCompare(b.id)).map(m => (
+
+
+
+ {m.id}
+ {/* The engine is shown when the model has a config to read it
+ from, and left out otherwise. */}
+ {m.backend && {m.backend} }
+
+ onStop(m.id)}
+ title={t('loadedModels.stop')}
+ aria-label={`${t('loadedModels.stop')}: ${m.id}`}
+ >
+
+
+
+ ))}
+
+ ) : (
+
{t('statusLine.noModelsLoaded')}
+ )}
+
+ {!clustered && host?.isGpu && host.ram && (
+
+ {t('resourceRam')}
+
+
+
+ )}
+
+ {count > 1 && (
+
+ {t('loadedModels.stopAll')}
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/home/HomeModelPicker.jsx b/core/http/react-ui/src/components/home/HomeModelPicker.jsx
new file mode 100644
index 000000000..d65ca82e3
--- /dev/null
+++ b/core/http/react-ui/src/components/home/HomeModelPicker.jsx
@@ -0,0 +1,225 @@
+// eslint-disable-next-line no-unused-vars
+import { Fragment, forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useModels } from '../../hooks/useModels'
+import { readLastModel, writeLastModel } from '../../utils/lastModel'
+import Icon from '../Icon'
+
+// How many models before the list gets a filter box.
+const FILTER_FROM = 8
+
+// The model chip on the Home command bar and its listbox. A model is "warm"
+// when the server reports it loaded (the same /system list the status strip
+// reads), "cold" otherwise. Nothing about load time is shown: the API does not
+// report it for an installed model.
+//
+// Chat uses the same chip and adds, all optional:
+// models, loading the page already holds the list, so no second fetch
+// grouped "Loaded now" and "Installed" instead of one list
+// describe(name) { vision, size, fit: { tone, text } } for a row
+// onOpen called when the list opens (to read memory and sizes)
+// footer a node under the list (the memory bar)
+// showWarm false for a list whose names are not loadable models (Talk's pipelines)
+// labels { title, heading, label, none } to replace the chat wording
+const HomeModelPicker = forwardRef(function HomeModelPicker(
+ {
+ value, onChange, capability, loadedIds, disabled = false, placeholder,
+ models: givenModels, loading: givenLoading, grouped = false, describe, onOpen, footer,
+ showWarm = true, labels,
+ },
+ ref,
+) {
+ const { t } = useTranslation('home')
+ const own = useModels(capability, { enabled: !givenModels })
+ const models = givenModels || own.models
+ const loading = givenModels ? !!givenLoading : own.loading
+ const names = useMemo(() => models.map(m => m.id), [models])
+ const [open, setOpen] = useState(false)
+ const [query, setQuery] = useState('')
+ const [active, setActive] = useState(0)
+ const rootRef = useRef(null)
+ const chipRef = useRef(null)
+ const menuRef = useRef(null)
+ const filterRef = useRef(null)
+
+ // Same defaulting as ModelSelector: the remembered model if it is still
+ // there, otherwise the first one. Auto-select is not a user choice, so it is
+ // not written back.
+ useEffect(() => {
+ if (names.length > 0 && (!value || !names.includes(value))) {
+ const remembered = readLastModel(capability)
+ onChange(remembered && names.includes(remembered) ? remembered : names[0])
+ }
+ }, [names, value, onChange, capability])
+
+ // Loaded models first when the list is grouped; each group keeps its order.
+ const shown = useMemo(() => {
+ const q = query.trim().toLowerCase()
+ const found = q ? names.filter(n => n.toLowerCase().includes(q)) : names
+ if (!grouped) return found
+ return [...found.filter(n => loadedIds?.has(n)), ...found.filter(n => !loadedIds?.has(n))]
+ }, [names, query, grouped, loadedIds])
+
+ const close = useCallback((restoreFocus = false) => {
+ setOpen(false)
+ setQuery('')
+ if (restoreFocus) chipRef.current?.focus()
+ }, [])
+
+ const openMenu = useCallback(() => {
+ if (disabled || names.length === 0) return
+ setActive(Math.max(0, names.indexOf(value)))
+ setQuery('')
+ setOpen(true)
+ onOpen?.()
+ }, [disabled, names, value, onOpen])
+
+ useImperativeHandle(ref, () => ({ open: openMenu, focus: () => chipRef.current?.focus() }), [openMenu])
+
+ useEffect(() => {
+ if (!open) return undefined
+ const onDown = (e) => { if (rootRef.current && !rootRef.current.contains(e.target)) close() }
+ document.addEventListener('mousedown', onDown)
+ return () => document.removeEventListener('mousedown', onDown)
+ }, [open, close])
+
+ // Focus moves into the menu so the arrow keys work, and back to the chip when
+ // it closes by keyboard.
+ useEffect(() => {
+ if (!open) return
+ const el = filterRef.current || menuRef.current
+ el?.focus()
+ }, [open])
+
+ useEffect(() => {
+ if (!open) return
+ menuRef.current?.querySelector('[data-active="true"]')?.scrollIntoView?.({ block: 'nearest' })
+ }, [open, active, shown])
+
+ const pick = useCallback((name) => {
+ writeLastModel(capability, name)
+ onChange(name)
+ close(true)
+ }, [capability, onChange, close])
+
+ const onMenuKey = (e) => {
+ const n = shown.length
+ if (e.key === 'ArrowDown') { e.preventDefault(); if (n) setActive(a => (a + 1) % n) }
+ else if (e.key === 'ArrowUp') { e.preventDefault(); if (n) setActive(a => (a + n - 1) % n) }
+ else if (e.key === 'Home' && !query) { e.preventDefault(); setActive(0) }
+ else if (e.key === 'End' && !query) { e.preventDefault(); setActive(Math.max(0, n - 1)) }
+ else if (e.key === 'Enter') { e.preventDefault(); if (shown[active]) pick(shown[active]) }
+ else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true) }
+ else if (e.key === 'Tab') close()
+ }
+
+ const isWarm = (name) => loadedIds?.has(name)
+ const label = value || placeholder || (loading ? t('picker.loading') : (labels?.none || t('picker.none')))
+ const activeId = shown[active] ? `home-model-opt-${active}` : undefined
+
+ return (
+
+ (open ? close() : openMenu())}
+ onKeyDown={(e) => { if (e.key === 'ArrowDown' && !open) { e.preventDefault(); openMenu() } }}
+ >
+
+ {showWarm && }
+ {label}
+
+
+ {open && (
+
+ )}
+
+ )
+})
+
+export default HomeModelPicker
diff --git a/core/http/react-ui/src/components/home/HomeResume.jsx b/core/http/react-ui/src/components/home/HomeResume.jsx
new file mode 100644
index 000000000..b9cc04785
--- /dev/null
+++ b/core/http/react-ui/src/components/home/HomeResume.jsx
@@ -0,0 +1,152 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { groupConversations } from '../../utils/homeConversations'
+import Icon from '../Icon'
+
+// Rows shown before "Show more". Enough to resume yesterday's work without a
+// wall of history.
+const INITIAL_ROWS = 8
+const MAX_ROWS = 60
+
+function isTyping(el) {
+ if (!el) return false
+ const tag = el.tagName
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable
+}
+
+// eslint-disable-next-line no-unused-vars
+function EnterKey() {
+ return ↵
+}
+
+// "Jump back in": conversations from this browser, one card per day. A row
+// opens its conversation in Chat. j and k move between rows, Enter resumes,
+// Delete removes with an undo.
+export default function HomeResume({ items, leavingId, onResume, onDelete, emptyHint }) {
+ const { t, i18n } = useTranslation('home')
+ const [expanded, setExpanded] = useState(false)
+ const listRef = useRef(null)
+
+ const visible = useMemo(
+ () => items.slice(0, expanded ? MAX_ROWS : INITIAL_ROWS),
+ [items, expanded],
+ )
+ const hidden = Math.min(items.length, MAX_ROWS) - visible.length
+ const groups = useMemo(() => groupConversations(visible), [visible])
+
+ const fmtTime = useMemo(() => new Intl.DateTimeFormat(i18n.language, { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }), [i18n.language])
+ const fmtDay = useMemo(() => new Intl.DateTimeFormat(i18n.language, { weekday: 'short', day: 'numeric', month: 'short' }), [i18n.language])
+ const fmtDate = useMemo(() => new Intl.DateTimeFormat(i18n.language, { day: 'numeric', month: 'short' }), [i18n.language])
+
+ // The row is about to go, so focus moves to its neighbour. Keyboard users keep
+ // their place in the list.
+ const remove = (conv, li) => {
+ const rows = Array.from(listRef.current?.querySelectorAll('.home-row__open') || [])
+ const mine = li.querySelector('.home-row__open')
+ const at = rows.indexOf(mine)
+ const next = rows[at + 1] || rows[at - 1]
+ if (next && li.contains(document.activeElement)) next.focus()
+ onDelete(conv)
+ }
+
+ // j and k from anywhere on the page that is not a text field.
+ useEffect(() => {
+ const onKey = (e) => {
+ if (e.metaKey || e.ctrlKey || e.altKey) return
+ if (e.key !== 'j' && e.key !== 'k') return
+ if (isTyping(document.activeElement)) return
+ const rows = Array.from(listRef.current?.querySelectorAll('.home-row__open') || [])
+ if (!rows.length) return
+ e.preventDefault()
+ const cur = rows.indexOf(document.activeElement)
+ const next = e.key === 'j' ? Math.min(rows.length - 1, cur + 1) : Math.max(0, cur < 0 ? 0 : cur - 1)
+ rows[next].focus()
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [])
+
+ if (items.length === 0) {
+ return (
+
+
{t('jump.heading')}
+
+
{t('jump.emptyTitle')} {t('jump.emptyBody')}
+ {emptyHint}
+
+
+ )
+ }
+
+ return (
+
+
+
{t('jump.heading')}
+
+ j k {t('jump.move')} {t('jump.resume')}
+
+
+
+ {groups.map(g => {
+ const first = g.items[0]
+ const label = t(`jump.group.${g.key}`)
+ const date = g.key === 'today' || g.key === 'yesterday' ? fmtDay.format(first.updatedAt) : ''
+ return (
+
+
{label}{date && {date} }
+
+ {g.items.map(c => (
+ {
+ if (e.key === 'Delete' && e.target.classList.contains('home-row__open')) {
+ e.preventDefault()
+ remove(c, e.currentTarget)
+ }
+ }}
+ >
+ onResume(c)}>
+
+ {g.key === 'older' ? fmtDate.format(c.updatedAt) : fmtTime.format(c.updatedAt)}
+
+
+ {c.title}
+ {c.preview && (
+ {c.preview}
+ )}
+
+
+ {c.hasImage && }
+ {c.model || t('jump.noModel')}
+
+
+ {t('jump.messages', { count: c.count })}
+ {t('jump.resume')}
+
+
+ remove(c, e.currentTarget.closest('li'))}
+ title={t('jump.delete')}
+ aria-label={t('jump.deleteNamed', { title: c.title })}
+ >
+
+
+
+ ))}
+
+
+ )
+ })}
+
+ {hidden > 0 && (
+ setExpanded(true)}>
+ {t('jump.showMore', { count: hidden })}
+
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/home/HomeUndoToast.jsx b/core/http/react-ui/src/components/home/HomeUndoToast.jsx
new file mode 100644
index 000000000..8efc81f5b
--- /dev/null
+++ b/core/http/react-ui/src/components/home/HomeUndoToast.jsx
@@ -0,0 +1,67 @@
+import { useCallback, useEffect, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
+import { undoStyle } from './memory'
+import Icon from '../Icon'
+
+// The kit's undo toast. The bar counts the time down; hover and focus pause the
+// bar in CSS, and the timer here pauses with it. When the time ends, or the
+// toast is dismissed, onExpire runs and the action becomes final.
+//
+// Other pages reuse it: they pass their own labels and test id, and the two
+// strings fall back to Home's. `dismissible={false}` drops the close button, for
+// an action where closing the toast must not finish it early.
+export default function HomeUndoToast({
+ message, onUndo, onExpire, duration = 6000,
+ undoLabel, dismissLabel, testId = 'home-undo-toast', dismissible = true,
+}) {
+ const { t } = useTranslation('home')
+ const ref = useRef(null)
+ const left = useRef(duration)
+ const startedAt = useRef(0)
+ const timer = useRef(null)
+ const expire = useRef(onExpire)
+ expire.current = onExpire
+
+ const run = useCallback(() => {
+ clearTimeout(timer.current)
+ startedAt.current = Date.now()
+ timer.current = setTimeout(() => expire.current(), left.current)
+ }, [])
+
+ const pause = useCallback(() => {
+ if (!timer.current) return
+ clearTimeout(timer.current)
+ timer.current = null
+ left.current = Math.max(500, left.current - (Date.now() - startedAt.current))
+ }, [])
+
+ useEffect(() => {
+ run()
+ return () => clearTimeout(timer.current)
+ }, [run])
+
+ return (
+
+
{ if (!timer.current) run() }}
+ onFocus={pause}
+ onBlur={(e) => { if (!ref.current.contains(e.relatedTarget) && !timer.current) run() }}
+ >
+ {message}
+ {undoLabel ?? t('jump.undo')}
+ {dismissible && (
+ expire.current()}>
+
+
+ )}
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/home/homeActions.js b/core/http/react-ui/src/components/home/homeActions.js
new file mode 100644
index 000000000..a5b175bb8
--- /dev/null
+++ b/core/http/react-ui/src/components/home/homeActions.js
@@ -0,0 +1,41 @@
+// The slash actions on the Home command bar. Every entry has a destination in
+// the product today; the page decides what each one does (navigate, open the
+// model list, start a chat). `needs` hides an action the person cannot use:
+// the admin pages, the assistant, or stopping a model when none is loaded.
+
+export const SLASH_GROUPS = ['chat', 'models', 'go']
+
+export const SLASH_ACTIONS = [
+ { id: 'model', cmd: '/model', icon: 'cube', group: 'chat', needs: 'models' },
+ { id: 'new', cmd: '/new', icon: 'plus', group: 'chat' },
+ { id: 'chat', cmd: '/chat', icon: 'chat', group: 'chat' },
+ { id: 'assistant', cmd: '/assistant', icon: 'user-shield', group: 'chat', needs: 'assistant' },
+ { id: 'gallery', cmd: '/gallery', icon: 'download', group: 'models', needs: 'admin' },
+ { id: 'installed', cmd: '/installed', icon: 'boxes', group: 'models', needs: 'admin' },
+ { id: 'import', cmd: '/import', icon: 'upload', group: 'models', needs: 'admin' },
+ { id: 'stop', cmd: '/stop', icon: 'stop', group: 'models', needs: 'loaded' },
+ { id: 'studio', cmd: '/studio', icon: 'palette', group: 'go' },
+ { id: 'settings', cmd: '/settings', icon: 'settings', group: 'go', needs: 'admin' },
+ { id: 'docs', cmd: '/docs', icon: 'book', group: 'go' },
+]
+
+// `ctx`: { isAdmin, assistantAvailable, hasModels, loadedCount }
+export function availableActions(ctx) {
+ return SLASH_ACTIONS.filter(a => {
+ if (a.needs === 'admin') return ctx.isAdmin
+ if (a.needs === 'assistant') return ctx.isAdmin && ctx.assistantAvailable
+ if (a.needs === 'models') return ctx.hasModels
+ if (a.needs === 'loaded') return ctx.isAdmin && ctx.loadedCount > 0
+ return true
+ })
+}
+
+// `query` is what follows the slash, lowercase. A command matches when its name
+// starts with the query or its label contains it. Order stays grouped.
+export function filterActions(actions, query, labelOf, groups = SLASH_GROUPS) {
+ const q = query.trim()
+ const matched = q
+ ? actions.filter(a => a.cmd.slice(1).startsWith(q) || labelOf(a).toLowerCase().includes(q))
+ : actions
+ return groups.flatMap(g => matched.filter(a => a.group === g))
+}
diff --git a/core/http/react-ui/src/components/home/memory.js b/core/http/react-ui/src/components/home/memory.js
new file mode 100644
index 000000000..314d9d73d
--- /dev/null
+++ b/core/http/react-ui/src/components/home/memory.js
@@ -0,0 +1,54 @@
+// Memory figures for the Home status strip.
+
+const K = 1024
+const UNITS = ['B', 'KB', 'MB', 'GB', 'TB']
+
+function trim(value) {
+ return String(parseFloat(value.toFixed(1)))
+}
+
+// "8.8 / 24 GB": both numbers in the unit of the total, so the pair reads at a
+// glance and the unit is written once.
+export function memoryFigure(used, total) {
+ if (!(total > 0)) return null
+ const i = Math.min(UNITS.length - 1, Math.max(0, Math.floor(Math.log(total) / Math.log(K))))
+ const scale = Math.pow(K, i)
+ return { used: trim((used || 0) / scale), total: trim(total / scale), unit: UNITS[i] }
+}
+
+export function percent(used, total) {
+ if (!(total > 0)) return 0
+ return Math.max(0, Math.min(100, (used / total) * 100))
+}
+
+// CSS custom property for a bar's fill, so the width lives in the stylesheet.
+export function fillStyle(pct) {
+ return { '--home-fill': `${pct}%` }
+}
+
+// What the strip reads for this host: the unified resources reading, which is
+// GPU memory when a GPU is present and system RAM otherwise.
+export function hostMemory(resources) {
+ if (!resources) return null
+ const aggregate = resources.aggregate || {}
+ const gpus = Array.isArray(resources.gpus) ? resources.gpus : []
+ const isGpu = resources.type === 'gpu' && gpus.length > 0
+ const total = aggregate.total_memory || (isGpu ? 0 : resources.ram?.total) || 0
+ const used = aggregate.used_memory ?? (isGpu ? 0 : resources.ram?.used) ?? 0
+ if (!(total > 0)) return null
+ const ram = resources.ram && resources.ram.total > 0 ? { used: resources.ram.used || 0, total: resources.ram.total } : null
+ return {
+ isGpu,
+ used,
+ total,
+ pct: aggregate.usage_percent != null ? aggregate.usage_percent : percent(used, total),
+ gpuCount: gpus.length,
+ device: isGpu ? (gpus[0]?.name || '') : '',
+ ram,
+ }
+}
+
+// CSS custom property for the undo toast's countdown bar.
+export function undoStyle(ms) {
+ return { '--dk-undo': `${ms / 1000}s` }
+}
diff --git a/core/http/react-ui/src/components/hub/HubLayout.jsx b/core/http/react-ui/src/components/hub/HubLayout.jsx
new file mode 100644
index 000000000..149555b57
--- /dev/null
+++ b/core/http/react-ui/src/components/hub/HubLayout.jsx
@@ -0,0 +1,136 @@
+import { Suspense } from 'react'
+import { Link, Outlet, useOutletContext, useLocation } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import { apiUrl } from '../../utils/basePath'
+import { preloadRoute } from '../../router'
+import RouteFallback from '../RouteFallback'
+import { resolveTabs, visibleItems } from './hubConfig'
+import { useHubAuth } from './useHubAuth'
+import { OperateSummaryProvider, useOperateSummary } from '../../contexts/OperateSummaryContext'
+import Icon from '../Icon'
+
+// Signals come from the Operate summary and only exist inside Operate. They
+// are never the only place a fact appears: the Status page states the same
+// things in prose. The attention count is the one that asks for a look.
+function TabBadge({ signal, summary }) {
+ const value = signal && summary?.signals ? summary.signals[signal.key] : null
+ if (value == null) return null
+ if (signal.kind === 'attention') {
+ return {value}
+ }
+ if (signal.kind === 'error') {
+ return {value}
+ }
+ return {value}
+}
+
+function HubTab({ tab, current, auth, summary, t }) {
+ const items = visibleItems(tab, auth)
+ // A tab links to its first visible route. A tab that is only on screen
+ // because the viewer stands on one of its routes links to the route they
+ // are on.
+ const target = (items[0] || tab.items[0]).path
+ const label = t(tab.labelKey)
+ return (
+ preloadRoute(target)}
+ onFocus={() => preloadRoute(target)}
+ >
+ {tab.icon && }
+ {label}
+ {(tab.signals || []).map(signal => (
+
+ ))}
+
+ )
+}
+
+// The second row, for a tab that owns several routes. Quiet text links, so the
+// bar above stays the only strong navigation.
+function SubNav({ tab, auth, pathname, t }) {
+ const items = visibleItems(tab, auth)
+ if (items.length < 2) return null
+ return (
+
+ {items.map(item => {
+ // An item marked `exact` is the hub's own landing page: the pages under
+ // it have items of their own, so a prefix match would light two links.
+ const active = item.exact ? pathname === item.path : (pathname === item.path || pathname.startsWith(item.path + '/'))
+ return (
+ preloadRoute(item.path)}
+ onFocus={() => preloadRoute(item.path)}
+ >
+ {t(item.labelKey)}
+
+ )
+ })}
+
+ )
+}
+
+function HubLayoutInner({ config }) {
+ const { t } = useTranslation('nav')
+ const auth = useHubAuth()
+ const location = useLocation()
+ const summary = useOperateSummary()
+ // Forward the App-level outlet context (e.g. addToast): a nested bare
+ // would otherwise shadow it with undefined and crash pages.
+ const outletContext = useOutletContext()
+
+ const { tabs, current } = resolveTabs(config, auth, location.pathname)
+ const external = (config.external || []).filter(item => !item.adminOnly || auth.isAdmin)
+
+ return (
+
+
+
+ {/* Own Suspense so a lazy page shows the loader in the body while the
+ tab bar stays put (instead of bubbling to App's boundary). */}
+ }>
+
+
+
+
+ )
+}
+
+// The summary provider wraps the Operate hub and nothing else. That is the
+// whole of "poll only while the user is in Operate": elsewhere the provider is
+// not mounted, so no timer exists to gate. Build gets the plain layout.
+export default function HubLayout({ config }) {
+ if (config.id !== 'operate') return
+ return (
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/hub/hubConfig.js b/core/http/react-ui/src/components/hub/hubConfig.js
new file mode 100644
index 000000000..0647a4332
--- /dev/null
+++ b/core/http/react-ui/src/components/hub/hubConfig.js
@@ -0,0 +1,261 @@
+// Hub configuration shared by HubLayout (renders the tab bar), the Sidebar
+// (renders the single entry for each hub and computes its active state), App
+// (page-transition key).
+//
+// A hub is a page-level tab bar. Each tab owns one or more existing routes
+// (`items`). A tab with several routes shows a second, quieter row of links
+// under the bar. Routes stay flat (`/app/agents`, `/app/nodes/:id`); a tab is
+// highlighted when the current path is one of its item paths or sits under
+// one, so sub-pages such as `nodes/:id` keep the right tab lit.
+//
+// Gating is per item and decides the tab: a tab shows when at least one of its
+// items is visible to the viewer, so a tab nobody can use is never drawn.
+// `/api/features` only emits: agents, mcp, fine_tuning, quantization,
+// distributed, localai_assistant. Capability-style flags (face_recognition,
+// skills, ...) come from hasFeature(), not the features map.
+//
+// Item and tab fields:
+// path route the item links to
+// labelKey `nav` i18n key of the item label
+// adminOnly, authOnly, feature, requiresAgentPool, unlessFeature gating
+// match extra path prefixes that belong to the tab (sub-pages)
+// showWhen tab only: `/api/features` flag that must be on
+// revealOnRoute tab only: draw the tab while the viewer is on one of its
+// routes, even when `showWhen` is off
+// signals tab only: values from the Operate summary (see
+// OperateSummaryContext), each drawn as a neutral count
+// (kind `count`) or an attention badge (kind `attention`,
+// or `error` for failures)
+
+export const buildHub = {
+ id: 'build',
+ titleKey: 'sections.build',
+ icon: 'wrench',
+ overviewPath: '/app/build',
+ tabs: [
+ {
+ id: 'overview',
+ labelKey: 'items.overview',
+ icon: 'home',
+ items: [{ path: '/app/build', labelKey: 'items.overview' }],
+ },
+ {
+ id: 'agents',
+ labelKey: 'items.agents',
+ descriptionKey: 'hub.descriptions.agents',
+ icon: 'robot',
+ items: [{ path: '/app/agents', labelKey: 'items.agents', feature: 'agents', requiresAgentPool: true }],
+ },
+ {
+ id: 'skills',
+ labelKey: 'items.skills',
+ descriptionKey: 'hub.descriptions.skills',
+ icon: 'sparkles',
+ items: [{ path: '/app/skills', labelKey: 'items.skills', feature: 'skills', requiresAgentPool: true }],
+ },
+ {
+ id: 'memory',
+ labelKey: 'items.memory',
+ descriptionKey: 'hub.descriptions.memory',
+ icon: 'database',
+ items: [{ path: '/app/collections', labelKey: 'items.memory', feature: 'collections', requiresAgentPool: true }],
+ },
+ {
+ id: 'jobs',
+ labelKey: 'items.jobs',
+ descriptionKey: 'hub.descriptions.jobs',
+ icon: 'checklist',
+ items: [{ path: '/app/agent-jobs', labelKey: 'items.jobs', feature: 'mcp', requiresAgentPool: true }],
+ },
+ {
+ id: 'fine-tune',
+ labelKey: 'items.fineTune',
+ descriptionKey: 'hub.descriptions.fineTune',
+ icon: 'graduation-cap',
+ items: [{ path: '/app/fine-tune', labelKey: 'items.fineTune', feature: 'fine_tuning' }],
+ },
+ {
+ id: 'quantize',
+ labelKey: 'items.quantize',
+ descriptionKey: 'hub.descriptions.quantize',
+ icon: 'minimize',
+ items: [{ path: '/app/quantize', labelKey: 'items.quantize', feature: 'quantization' }],
+ },
+ {
+ id: 'import',
+ labelKey: 'hub.import',
+ descriptionKey: 'hub.descriptions.import',
+ icon: 'download',
+ items: [{ path: '/app/import-model', labelKey: 'hub.import', adminOnly: true }],
+ },
+ {
+ id: 'voices',
+ labelKey: 'items.voices',
+ descriptionKey: 'hub.descriptions.voices',
+ icon: 'mic',
+ // One Voices page with Speakers, Speech voices and From a recording as its
+ // own tabs, so the bar has no second row.
+ items: [{ path: '/app/voice', labelKey: 'items.voices', feature: 'voice_recognition' }],
+ match: ['/app/voice-library'],
+ },
+ {
+ id: 'faces',
+ labelKey: 'items.faces',
+ descriptionKey: 'hub.descriptions.faces',
+ icon: 'smile',
+ items: [{ path: '/app/face', labelKey: 'items.faces', feature: 'face_recognition' }],
+ },
+ ],
+}
+
+// Six tabs for what used to be thirteen rail rows in four groups. Nothing is
+// removed and no gate changes. Where several routes share a tab, the second
+// row names them.
+export const operateHub = {
+ id: 'operate',
+ titleKey: 'sections.operate',
+ icon: 'sliders',
+ overviewPath: '/app/operate',
+ tabs: [
+ {
+ id: 'status',
+ labelKey: 'hub.status',
+ icon: 'gauge',
+ signals: [{ key: 'attention', kind: 'attention' }],
+ items: [{ path: '/app/operate', labelKey: 'hub.status', adminOnly: true }],
+ },
+ {
+ id: 'machine',
+ labelKey: 'items.thisMachine',
+ icon: 'monitor',
+ // The Nodes route under the name it has on a single-node install, where
+ // it shows this host and what is loaded on it. With distributed mode on,
+ // the Swarm tab takes over the same route.
+ signals: [{ key: 'running', kind: 'count' }],
+ items: [{ path: '/app/nodes', labelKey: 'items.thisMachine', adminOnly: true, unlessFeature: 'distributed' }],
+ match: ['/app/node-backend-logs'],
+ },
+ {
+ id: 'swarm',
+ labelKey: 'items.swarm',
+ icon: 'network',
+ showWhen: 'distributed',
+ revealOnRoute: true,
+ signals: [{ key: 'nodes', kind: 'count' }],
+ items: [
+ { path: '/app/nodes', labelKey: 'items.nodes', adminOnly: true, feature: 'distributed' },
+ { path: '/app/scheduling', labelKey: 'items.scheduling', adminOnly: true, feature: 'distributed' },
+ // Failover is a Swarm page on a cluster. A single install keeps it under
+ // Runtime, where its model failover chains still apply.
+ { path: '/app/failover', labelKey: 'items.failover', adminOnly: true, feature: 'distributed' },
+ { path: '/app/p2p', labelKey: 'items.p2p', adminOnly: true },
+ ],
+ match: ['/app/node-backend-logs'],
+ },
+ {
+ id: 'runtime',
+ labelKey: 'hub.runtime',
+ icon: 'server',
+ signals: [{ key: 'backends', kind: 'attention' }, { key: 'activity', kind: 'count' }],
+ items: [
+ { path: '/app/backends', labelKey: 'items.backends', adminOnly: true },
+ { path: '/app/activity', labelKey: 'items.activity', adminOnly: true },
+ { path: '/app/backend-logs', labelKey: 'items.logs', adminOnly: true },
+ { path: '/app/failover', labelKey: 'items.failover', adminOnly: true, unlessFeature: 'distributed' },
+ ],
+ },
+ {
+ id: 'traffic',
+ labelKey: 'hub.traffic',
+ icon: 'chart-line',
+ signals: [{ key: 'traces', kind: 'error' }],
+ // Eight pages behind one tab. Alerts is not among them: LocalAI has no
+ // alert rules, so there is nothing for that page to hold.
+ items: [
+ { path: '/app/traffic', labelKey: 'items.overview', adminOnly: true, exact: true },
+ { path: '/app/usage', labelKey: 'items.usage', adminOnly: true },
+ { path: '/app/traffic/models', labelKey: 'items.trafficModels', adminOnly: true },
+ { path: '/app/traffic/host', labelKey: 'items.gpuHost', adminOnly: true },
+ { path: '/app/traces', labelKey: 'items.traces', adminOnly: true },
+ { path: '/app/middleware', labelKey: 'items.middleware', adminOnly: true },
+ { path: '/app/traffic/prometheus', labelKey: 'items.prometheus', adminOnly: true },
+ ],
+ },
+ {
+ id: 'settings',
+ labelKey: 'items.settings',
+ icon: 'settings',
+ items: [
+ { path: '/app/settings', labelKey: 'items.settings', adminOnly: true },
+ { path: '/app/users', labelKey: 'items.users', adminOnly: true, authOnly: true },
+ ],
+ },
+ ],
+ // Links that leave the app. Drawn at the end of the bar, not as a tab.
+ external: [
+ { href: '/swagger/index.html', icon: 'code', labelKey: 'items.api', adminOnly: true },
+ ],
+}
+
+export const hubs = [buildHub, operateHub]
+
+// Single source of truth for item visibility.
+export function isHubItemVisible(item, { isAdmin, authEnabled, hasFeature, features }) {
+ if (item.adminOnly && !isAdmin) return false
+ if (item.authOnly && !authEnabled) return false
+ if (item.requiresAgentPool && features.agents === false) return false
+ if (item.feature && features[item.feature] === false) return false
+ if (item.feature && !hasFeature(item.feature)) return false
+ // Hidden until /api/features has answered: showing it and then swapping it
+ // for the cluster tab would move the bar under the user's pointer.
+ if (item.unlessFeature && features[item.unlessFeature] !== false) return false
+ return true
+}
+
+export function visibleItems(tab, auth) {
+ return tab.items.filter(item => isHubItemVisible(item, auth))
+}
+
+// A tab with a `showWhen` flag stays hidden until /api/features says it is on.
+function tabGateOpen(tab, auth) {
+ return !tab.showWhen || auth.features[tab.showWhen] === true
+}
+
+export function isTabVisible(tab, auth) {
+ return tabGateOpen(tab, auth) && visibleItems(tab, auth).length > 0
+}
+
+function pathIn(pathname, p) {
+ return pathname === p || pathname.startsWith(p + '/')
+}
+
+export function tabOwnsPath(tab, pathname) {
+ return tab.items.some(i => pathIn(pathname, i.path)) || (tab.match || []).some(p => pathIn(pathname, p))
+}
+
+// The tabs to draw for a viewer on `pathname`, in order, and which one is
+// current. A tab that is gated off still shows while the viewer stands on one
+// of its routes when it says `revealOnRoute`, so a direct link to such a page
+// keeps its tab.
+export function resolveTabs(hub, auth, pathname) {
+ const shown = hub.tabs.filter(tab => isTabVisible(tab, auth))
+ const owned = shown.some(tab => tabOwnsPath(tab, pathname))
+ // Only when no drawn tab owns the route does a gated-off tab step in.
+ const tabs = owned ? shown : hub.tabs.filter(tab => shown.includes(tab)
+ || (tab.revealOnRoute && tab.items.some(i => (!i.adminOnly || auth.isAdmin) && pathIn(pathname, i.path))))
+ const current = tabs.find(tab => tabOwnsPath(tab, pathname)) || null
+ return { tabs, current }
+}
+
+export function hubOwnsPath(hub, pathname) {
+ return hub.tabs.some(tab => tabOwnsPath(tab, pathname))
+}
+
+// The page the hub's single sidebar entry links to: its overview, when the
+// viewer can see at least one tab besides it. Returns null when nothing is
+// visible (so the entry can be hidden entirely).
+export function hubEntryPath(hub, auth) {
+ const real = hub.tabs.filter(tab => tab.id !== 'overview' && isTabVisible(tab, auth))
+ if (real.length === 0) return null
+ return hub.overviewPath
+}
diff --git a/core/http/react-ui/src/components/hub/useHubAuth.js b/core/http/react-ui/src/components/hub/useHubAuth.js
new file mode 100644
index 000000000..c73a715ff
--- /dev/null
+++ b/core/http/react-ui/src/components/hub/useHubAuth.js
@@ -0,0 +1,23 @@
+import { useState, useEffect } from 'react'
+import { useAuth } from '../../context/AuthContext'
+import { apiUrl } from '../../utils/basePath'
+
+// /api/features rarely changes; cache it across remounts so a hub renders the
+// correct (gated) set immediately instead of flashing the wrong set while a
+// fresh fetch resolves.
+let featuresCache = {}
+
+// The shape the gating helpers in hubConfig.js read.
+export function useHubAuth() {
+ const { isAdmin, authEnabled, hasFeature } = useAuth()
+ const [features, setFeatures] = useState(featuresCache)
+
+ useEffect(() => {
+ fetch(apiUrl('/api/features'))
+ .then(r => r.json())
+ .then(f => { featuresCache = f; setFeatures(f) })
+ .catch(() => {})
+ }, [])
+
+ return { isAdmin, authEnabled, hasFeature, features }
+}
diff --git a/core/http/react-ui/src/components/iconGlyphs.jsx b/core/http/react-ui/src/components/iconGlyphs.jsx
new file mode 100644
index 000000000..fc4e844b0
--- /dev/null
+++ b/core/http/react-ui/src/components/iconGlyphs.jsx
@@ -0,0 +1,11 @@
+// Marks the kit does not ship: brand logos. Each entry draws filled shapes in
+// currentColor on a 24x24 grid, so they follow the text colour and size like
+// every other icon.
+export const BRAND_GLYPHS = {
+ github: (
+
+ ),
+ apple: (
+
+ ),
+}
diff --git a/core/http/react-ui/src/components/iconSprite.js b/core/http/react-ui/src/components/iconSprite.js
new file mode 100644
index 000000000..b7ec80f55
--- /dev/null
+++ b/core/http/react-ui/src/components/iconSprite.js
@@ -0,0 +1,26 @@
+import spriteSource from '../vendor/ui-kit/icons/sprite.svg?raw'
+
+// Every symbol id in the vendored sprite, without the dk-icon- prefix.
+export const SPRITE_PREFIX = 'dk-icon-'
+export const SPRITE_IDS = new Set(
+ Array.from(spriteSource.matchAll(/id="dk-icon-([^"]+)"/g), (m) => m[1]),
+)
+
+const HOLDER_ID = 'lai-icon-sprite'
+
+// Put the sprite into the page once, so that resolves
+// from any component or portal. An inline sprite needs no request and has no
+// dependence on the base path the app is served from (root, reverse-proxy
+// subpath or the Go-embedded build).
+export function ensureSprite() {
+ if (typeof document === 'undefined' || !document.body) return
+ if (document.getElementById(HOLDER_ID)) return
+ const holder = document.createElement('div')
+ holder.id = HOLDER_ID
+ holder.className = 'lai-icon-sprite'
+ holder.setAttribute('aria-hidden', 'true')
+ // The symbols fix their own line width. Drop it so the width set on each svg
+ // (the --lai-stroke custom property in App.css) applies.
+ holder.innerHTML = spriteSource.replaceAll(' stroke-width="1.75"', '')
+ document.body.prepend(holder)
+}
diff --git a/core/http/react-ui/src/components/identity/ClipInput.jsx b/core/http/react-ui/src/components/identity/ClipInput.jsx
new file mode 100644
index 000000000..c86ccb487
--- /dev/null
+++ b/core/http/react-ui/src/components/identity/ClipInput.jsx
@@ -0,0 +1,213 @@
+import { useEffect, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useMediaCapture } from '../../hooks/useMediaCapture'
+import useObjectUrl from '../../hooks/useObjectUrl'
+import { fileToBase64 } from '../../utils/api'
+import { judgeClip } from '../../utils/identity'
+// eslint-disable-next-line no-unused-vars
+import WaveformPlayer from '../audio/WaveformPlayer'
+import Icon from '../Icon'
+
+// Read a clip in the browser: how long it is and how loud. Nothing is sent.
+async function readClip(blob) {
+ const Ctx = window.AudioContext || window.webkitAudioContext
+ if (!Ctx || !blob) return null
+ const ctx = new Ctx()
+ try {
+ const buf = await ctx.decodeAudioData(await blob.arrayBuffer())
+ const data = buf.getChannelData(0)
+ let peak = 0
+ let hot = 0
+ for (let i = 0; i < data.length; i += 1) {
+ const v = Math.abs(data[i])
+ if (v > peak) peak = v
+ if (v >= 0.99) hot += 1
+ }
+ return { seconds: buf.duration, peak, clipped: hot / data.length > 0.001 }
+ } catch {
+ return null
+ } finally {
+ ctx.close().catch(() => {})
+ }
+}
+
+// A permission state read without asking: 'denied', 'prompt', 'granted' or ''.
+function useMicState(enabled) {
+ const [state, setState] = useState('')
+ useEffect(() => {
+ if (!enabled || !navigator.permissions?.query) return undefined
+ let status
+ let live = true
+ navigator.permissions.query({ name: 'microphone' }).then((s) => {
+ if (!live) return
+ status = s
+ setState(s.state)
+ s.onchange = () => setState(s.state)
+ }).catch(() => {})
+ return () => { live = false; if (status) status.onchange = null }
+ }, [enabled])
+ return state
+}
+
+// One sample, in the kit's own look: a drop of choices before there is a
+// sample, a card with the facts after. It is the input for both the Voices and
+// the Faces pages.
+//
+// kind 'audio' or 'image'
+// value null or { base64, blob, dataUrl, mime, source, name }
+// onChange gets the same shape, or null on Remove
+// idPrefix the file input is `${idPrefix}-${kind}-file`
+export default function ClipInput({ kind, label, value, onChange, idPrefix, hint, prompt, onFacts }) {
+ const { t } = useTranslation('biometrics')
+ const fileRef = useRef(null)
+ const cap = useMediaCapture(kind)
+ const [facts, setFacts] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const micState = useMicState(kind === 'audio')
+ const secure = typeof window === 'undefined' || (window.isSecureContext ?? true)
+ const live = kind === 'audio' ? t('clip.mic') : t('clip.camera')
+
+ useEffect(() => {
+ let current = true
+ setFacts(null)
+ onFacts?.(null)
+ if (kind === 'audio' && value?.blob) readClip(value.blob).then((f) => { if (current) { setFacts(f); onFacts?.(f) } })
+ return () => { current = false }
+ }, [kind, value]) // eslint-disable-line react-hooks/exhaustive-deps
+
+ const take = async (file, source = 'file') => {
+ setBusy(true)
+ try {
+ const base64 = await fileToBase64(file)
+ const dataUrl = await new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onerror = () => reject(reader.error)
+ reader.onload = () => resolve(reader.result)
+ reader.readAsDataURL(file)
+ })
+ const name = source === 'paste' ? `pasted-image.${(file.type.split('/')[1] || 'png').replace('+xml', '')}` : file.name
+ onChange({ base64, blob: file, dataUrl, mime: file.type, source, name })
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const onFile = (e) => {
+ const f = e.target.files?.[0]
+ if (f) take(f)
+ else onChange(null)
+ }
+
+ const onPaste = (e) => {
+ if (kind !== 'image') return
+ const item = Array.from(e.clipboardData?.items || []).find(entry => entry.type.startsWith('image/'))
+ const f = item?.getAsFile()
+ if (!f) return
+ e.preventDefault()
+ take(f, 'paste')
+ }
+
+ const record = async () => {
+ if (cap.recording) { cap.stopRecording(); return }
+ const pending = cap.startRecording()
+ if (!pending) return
+ const result = await pending
+ cap.stop()
+ onChange({ ...result, source: 'live', name: 'recording.wav' })
+ }
+
+ const snap = () => {
+ const shot = cap.snap()
+ if (shot) { cap.stop(); onChange({ ...shot, source: 'live', name: 'photo.png' }) }
+ }
+
+ const clear = () => {
+ onChange(null)
+ if (fileRef.current) fileRef.current.value = ''
+ }
+
+ const playUrl = useObjectUrl(kind === 'audio' ? value?.blob : null)
+ const verdict = facts ? judgeClip(facts) : null
+ const blocked = cap.errorKind === 'denied' || micState === 'denied'
+ const inputId = `${idPrefix}-${kind}-file`
+
+ return (
+
+
+ {label}
+ {value && {t('clip.remove')} }
+
+
+
+
+ {!value && !cap.active && (
+
+ {prompt &&
{t('clip.readAloud')} {prompt}
}
+
+ {cap.supported && !blocked && (
+
+ {kind === 'audio' ? t('clip.record') : t('clip.takePhoto')}
+
+ )}
+ fileRef.current?.click()} disabled={busy}>
+ {kind === 'audio' ? t('clip.chooseAudio') : t('clip.choosePhoto')}
+
+
+ {(!cap.supported || blocked || cap.error) && (
+
+
+
+ {blocked && t('clip.blocked', { thing: live })}
+ {!blocked && !cap.supported && !secure && t('clip.insecure', { thing: live, origin: window.location.origin })}
+ {!blocked && !cap.supported && secure && t('clip.unsupported', { thing: live })}
+ {!blocked && cap.supported && cap.error && t(cap.errorKind === 'missing' ? 'clip.missing' : 'clip.failed', { thing: live })}
+
+
+ )}
+ {cap.supported && !blocked && !cap.error && micState === 'granted' && kind === 'audio' && (
+
{t('clip.allowed')}
+ )}
+ {hint &&
{hint}
}
+
+ )}
+
+ {!value && cap.active && kind === 'image' && (
+
+
+
+ {t('clip.capture')}
+ {t('clip.cancel')}
+
+
+ )}
+
+ {!value && cap.active && kind === 'audio' && (
+
+
{cap.recording ? t('clip.recording', { s: cap.elapsed.toFixed(1) }) : t('clip.micReady')}
+
+
+ {cap.recording ? t('clip.stop') : t('clip.start')}
+
+ {t('clip.cancel')}
+
+
+ )}
+
+ {value && (
+
+
+
+ {value.source === 'live' ? t(kind === 'audio' ? 'clip.recorded' : 'clip.photographed') : value.name || t('clip.uploaded')}
+ {facts && {facts.seconds.toFixed(1)} s }
+ {verdict?.level === 'good' && {t('clip.goodLength')} }
+ {verdict?.level === 'warn' && {facts.seconds < 3 ? t('clip.short') : t('clip.checkLevel')} }
+
+ {kind === 'audio'
+ ?
+ :
}
+ {verdict?.level === 'warn' &&
{verdict.notes.map(n => {n} )} }
+
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/identity/DistanceScale.jsx b/core/http/react-ui/src/components/identity/DistanceScale.jsx
new file mode 100644
index 000000000..95c44f6e1
--- /dev/null
+++ b/core/http/react-ui/src/components/identity/DistanceScale.jsx
@@ -0,0 +1,42 @@
+import { useTranslation } from 'react-i18next'
+import { labelRows, pct, scaleMax, scaleTicks } from '../../utils/identity'
+
+const at = (value, max) => ({ '--at': pct(value, max) })
+
+// One scale for the whole answer: distance runs left to right, the cut-off is
+// a line across it, and each enrolled person (or the single pair) is a dot.
+// Lower means more alike. The text under it repeats the numbers, so the dots
+// are never the only way to read the result.
+//
+// dots [{ id, label, distance, within, best }] one per person, any order
+// cutoff where "same" ends
+export default function DistanceScale({ dots, cutoff, noun, single = false }) {
+ const { t } = useTranslation('biometrics')
+ const max = scaleMax(cutoff, dots.map(d => d.distance))
+ const ordered = [...dots].sort((a, b) => a.distance - b.distance)
+ const rows = labelRows(ordered.map(d => (d.distance / max) * 100))
+ const plot = { '--rows': Math.max(...rows, 0) }
+ const summary = ordered.map(d => `${d.label} ${d.distance.toFixed(2)}`).join(', ')
+
+ return (
+
+
+ {t('scale.cutoff', { value: cutoff.toFixed(2) })}
+
+ {ordered.map((d, i) => (
+
+
+ {d.label} {d.distance.toFixed(2)}
+
+ ))}
+
+ {scaleTicks(max).map(v => {v} )}
+
+
+
+ {t('scale.axis', { print: noun })}
+ {single ? t('scale.onePair') : t('scale.oneDot', { person: noun === 'faceprint' ? t('face.person') : t('voice.speaker') })}
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/identity/EnrolSheet.jsx b/core/http/react-ui/src/components/identity/EnrolSheet.jsx
new file mode 100644
index 000000000..6ca654848
--- /dev/null
+++ b/core/http/react-ui/src/components/identity/EnrolSheet.jsx
@@ -0,0 +1,164 @@
+import { useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { faceApi, voiceApi, voiceProfilesApi } from '../../utils/api'
+import { labelsText, parseLabels } from '../../utils/identity'
+import { normalizeAudioSample } from '../../utils/referenceAudio'
+// eslint-disable-next-line no-unused-vars
+import ErrorNote from './ErrorNote'
+// eslint-disable-next-line no-unused-vars
+import LoadingSpinner from '../LoadingSpinner'
+// eslint-disable-next-line no-unused-vars
+import SideSheet from '../SideSheet'
+import Icon from '../Icon'
+// eslint-disable-next-line no-unused-vars
+import ClipInput from './ClipInput'
+
+// A saved copy of a sample lives in localStorage, which is small. A larger one
+// is not kept, and the sheet says so instead of failing quietly.
+const KEEP_LIMIT = 1_500_000
+
+const READ_ALOUD = 'The harbour was quiet before the ferry arrived. Two gulls argued over a crust, and somebody carried a ladder down to the water.'
+
+// Enrol one person: a sample, who it is, and permission. For a voice, an admin
+// can also keep the same recording as a speech voice, which is a different
+// store with its own transcript and consent record.
+export default function EnrolSheet({ kind, model, canSpeech, prefill, onClose, onDone }) {
+ const { t } = useTranslation('biometrics')
+ const isFace = kind === 'face'
+ const [sample, setSample] = useState(prefill?.sample || null)
+ const [name, setName] = useState(prefill?.name || '')
+ const [labels, setLabels] = useState(labelsText(prefill?.labels).replaceAll(', ', '\n'))
+ const [asSpeech, setAsSpeech] = useState(false)
+ const [transcript, setTranscript] = useState('')
+ const [agreed, setAgreed] = useState(false)
+ const [keep, setKeep] = useState(false)
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+
+ const needs = useMemo(() => {
+ const out = []
+ if (!model) out.push(t('enrol.needModel'))
+ if (!sample) out.push(t(isFace ? 'enrol.needPhoto' : 'enrol.needRecording'))
+ if (!name.trim()) out.push(t('enrol.needName'))
+ if (asSpeech && !transcript.trim()) out.push(t('enrol.needTranscript'))
+ if (!agreed) out.push(t('enrol.needPermission'))
+ return out
+ }, [model, sample, name, asSpeech, transcript, agreed, isFace, t])
+
+ const submit = async (e) => {
+ e.preventDefault()
+ if (needs.length || busy) return
+ setBusy(true)
+ setError(null)
+ let data
+ try {
+ const parsed = parseLabels(labels)
+ data = isFace
+ ? await faceApi.register({ model, name: name.trim(), img: sample.dataUrl, labels: parsed })
+ : await voiceApi.register({ model, name: name.trim(), audio: sample.dataUrl, labels: parsed })
+ const small = sample.dataUrl.length <= KEEP_LIMIT
+ const kept = keep && small
+ const entry = {
+ id: data.id,
+ name: data.name,
+ labels: parsed,
+ model,
+ registeredAt: data.registered_at || new Date().toISOString(),
+ ...(kept ? (isFace ? { thumbnail: sample.dataUrl } : { sampleUrl: sample.dataUrl }) : {}),
+ }
+ let speechError = null
+ if (asSpeech && canSpeech && !isFace) {
+ try {
+ const wav = await normalizeAudioSample(sample)
+ const form = new FormData()
+ form.append('name', name.trim())
+ form.append('description', '')
+ form.append('language', '')
+ form.append('transcript', transcript.trim())
+ form.append('consent_confirmed', 'true')
+ form.append('audio', wav.blob, 'reference.wav')
+ await voiceProfilesApi.create(form)
+ } catch (err) {
+ speechError = err
+ } finally {
+ // The normalised copy only exists for this call.
+ }
+ }
+ onDone(entry, { keptTooBig: keep && !small, speechError, asSpeech })
+ } catch (err) {
+ setError(err)
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const footer = (
+ <>
+ {needs.length ? t('enrol.stillNeeded', { list: needs.join(', ') }) : t('enrol.ready')}
+ {t('enrol.cancel')}
+ 0 || busy}>
+ {busy ? <> {t('enrol.working')}> : <>{t('enrol.submit')} >}
+
+ >
+ )
+
+ return (
+
+
+
+ 1 {t(isFace ? 'enrol.photoStep' : 'enrol.recordingStep')}
+
+
+
+
+
+
+
+ {error && }
+
+
+ )
+}
+
diff --git a/core/http/react-ui/src/components/identity/ErrorNote.jsx b/core/http/react-ui/src/components/identity/ErrorNote.jsx
new file mode 100644
index 000000000..99732fc88
--- /dev/null
+++ b/core/http/react-ui/src/components/identity/ErrorNote.jsx
@@ -0,0 +1,26 @@
+// eslint-disable-next-line no-unused-vars
+import { Link } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import { explainError } from '../../utils/identity'
+import Icon from '../Icon'
+
+// A failed call in plain words first, the server's own message second, and a
+// way to the traces. `onRetry` adds a button when trying again makes sense.
+export default function ErrorNote({ error, kind, onRetry }) {
+ const { t } = useTranslation('biometrics')
+ const { title, body } = explainError(error, kind)
+ return (
+
+
+
+
{title}
+
{body}
+ {error?.message &&
{error.message}
}
+
+ {onRetry && {t('error.retry')} }
+ {t('error.traces')}
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/identity/FeatureOff.jsx b/core/http/react-ui/src/components/identity/FeatureOff.jsx
new file mode 100644
index 000000000..57d24e469
--- /dev/null
+++ b/core/http/react-ui/src/components/identity/FeatureOff.jsx
@@ -0,0 +1,21 @@
+import { useTranslation } from 'react-i18next'
+import Icon from '../Icon'
+
+// Shown in place of a page whose feature the signed-in user may not use. It
+// replaces a silent redirect: the person learns what is off and what turns it on.
+export default function FeatureOff({ feature }) {
+ const { t } = useTranslation('biometrics')
+ return (
+
+
+ {t('off.badge')}
+ {t(`off.${feature}.title`)}
+ {t(`off.${feature}.text`)}
+
+ {t('needed.whatTitle')}
+ {t(`off.${feature}.what`)}
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/identity/KnownList.jsx b/core/http/react-ui/src/components/identity/KnownList.jsx
new file mode 100644
index 000000000..65d7ea975
--- /dev/null
+++ b/core/http/react-ui/src/components/identity/KnownList.jsx
@@ -0,0 +1,109 @@
+import { useRef } from 'react'
+import { useTranslation } from 'react-i18next'
+import { ageText, initials, labelsText } from '../../utils/identity'
+import Icon from '../Icon'
+
+// eslint-disable-next-line no-unused-vars
+function Avatar({ entry, tone }) {
+ return (
+
+ {entry.thumbnail ? : initials(entry.name)}
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function PlaySample({ entry }) {
+ const { t } = useTranslation('biometrics')
+ const audio = useRef(null)
+ if (!entry.sampleUrl) return null
+ const play = () => {
+ if (!audio.current) audio.current = new Audio(entry.sampleUrl)
+ audio.current.currentTime = 0
+ audio.current.play().catch(() => {})
+ }
+ return (
+
+
+
+ )
+}
+
+// The registry as a table: who is saved in this browser, since when, and
+// whether the server still seems to have them. Names and labels are the only
+// facts shown; the saved sample, when the person chose to keep one, plays.
+export default function KnownList({ entries, extras, missing, waiting, bestId, onDelete, onUndo, onAgain, noun, onEnrol, kind, canEnrol = true }) {
+ const { t, i18n } = useTranslation('biometrics')
+ const rows = [
+ ...entries.map(e => ({ ...e, local: true })),
+ ...extras.map(e => ({ ...e, local: false })),
+ ]
+
+ if (rows.length === 0) {
+ return (
+
+
+
{t(`${kind}.emptyTitle`)}
+
{t(`${kind}.emptyText`)}
+
onEnrol()} disabled={!canEnrol}> {t(`${kind}.enrol`)}
+
+ )
+ }
+
+ return (
+
+
+ {t('known.caption', { noun })}
+
+
+ {t('known.name')}
+ {t('known.added')}
+ {t('known.actions')}
+
+
+
+ {rows.map(row => {
+ const gone = row.local && missing.has(row.id)
+ const pending = waiting[row.id]
+ const best = row.id === bestId
+ return (
+
+
+
+
+
+
+ {row.name}
+ {best && {t('known.best')} }
+ {gone && {t('known.notOnServer')} }
+ {!row.local && {t('known.notInBrowser')} }
+ {pending && {t('known.removing')} }
+
+
+ {gone ? t('known.goneText', { print: noun }) : !row.local ? t('known.extraText') : labelsText(row.labels) || t('known.noLabels')}
+
+ {gone && !pending && onAgain(row)}>{row.sampleUrl || row.thumbnail ? t('known.reenrolSaved') : t('known.enrolAgain')} }
+
+
+
+ {row.registeredAt ? ageText(row.registeredAt, Date.now(), i18n.language) : ''}
+
+ {pending ? (
+ onUndo(row.id)}>{t('known.undo')}
+ ) : (
+ <>
+
+ onDelete(row)} aria-label={t('known.forget', { name: row.name })}>
+
+
+ >
+ )}
+
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/identity/ModelNeeded.jsx b/core/http/react-ui/src/components/identity/ModelNeeded.jsx
new file mode 100644
index 000000000..bf830917d
--- /dev/null
+++ b/core/http/react-ui/src/components/identity/ModelNeeded.jsx
@@ -0,0 +1,81 @@
+import { useCallback, useEffect, useState } from 'react'
+// eslint-disable-next-line no-unused-vars
+import { Link } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import { useOperations } from '../../hooks/useOperations'
+import { modelsApi } from '../../utils/api'
+import Icon from '../Icon'
+
+// The disabled state: recognition runs on a model, and none is installed.
+// It says so, offers the gallery models that carry the right tag, and says
+// what turns the feature on. `kind` is 'voice' or 'face'.
+export default function ModelNeeded({ kind, onInstalled, addToast, isAdmin }) {
+ const { t } = useTranslation('biometrics')
+ const { operations } = useOperations()
+ const [models, setModels] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [failed, setFailed] = useState(false)
+ const [started, setStarted] = useState(() => new Set())
+
+ const load = useCallback(async () => {
+ try {
+ const res = await modelsApi.list({ tag: kind === 'face' ? 'face-recognition' : 'voice-recognition', items: 4, page: 1, sort: 'name', order: 'asc' })
+ setModels((res?.models || []).filter(m => !m.installed).slice(0, 3))
+ setFailed(false)
+ } catch {
+ setFailed(true)
+ } finally {
+ setLoading(false)
+ }
+ }, [kind])
+
+ useEffect(() => { load() }, [load])
+
+ // When an install finishes, tell the page to look for the model again.
+ useEffect(() => {
+ if (started.size === 0) return
+ const done = [...started].some(id => operations.some(op => op.name === id && (op.completed || op.error)))
+ if (done) { onInstalled?.(); load() }
+ }, [operations, started, onInstalled, load])
+
+ const install = async (model) => {
+ setStarted(prev => new Set(prev).add(model.id))
+ try {
+ await modelsApi.install(model.id)
+ addToast(t('needed.started', { name: model.name }), 'success')
+ } catch (err) {
+ setStarted(prev => { const next = new Set(prev); next.delete(model.id); return next })
+ addToast(err.message, 'error')
+ }
+ }
+
+ const busy = (model) => started.has(model.id) || operations.some(op => op.name === model.id && !op.completed && !op.error)
+
+ return (
+
+ {t('needed.badge')}
+ {t(`${kind}.neededTitle`)}
+ {t(`${kind}.neededText`)}
+ {isAdmin && !loading && models.length > 0 && (
+
+ {models.map(model => (
+
+ {model.name} {model.backend ? t('needed.backend', { backend: model.backend }) : ''}
+ install(model)}>
+ {busy(model) ? t('needed.installing') : t('needed.install')}
+
+
+ ))}
+
+ )}
+ {isAdmin && !loading && models.length === 0 && (
+ {failed ? t('needed.loadFailed') : t('needed.noneListed')}{' '} {t('needed.browse')}
+ )}
+ {!isAdmin && {t('needed.askAdmin')}
}
+
+ {t('needed.whatTitle')}
+ {t(`${kind}.neededWhat`)}
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/identity/MoreFaceTools.jsx b/core/http/react-ui/src/components/identity/MoreFaceTools.jsx
new file mode 100644
index 000000000..fc4af1ffe
--- /dev/null
+++ b/core/http/react-ui/src/components/identity/MoreFaceTools.jsx
@@ -0,0 +1,260 @@
+import { useMemo, useState } from 'react'
+// eslint-disable-next-line no-unused-vars
+import LoadingSpinner from '../LoadingSpinner'
+// eslint-disable-next-line no-unused-vars
+import ErrorWithTraceLink from '../ErrorWithTraceLink'
+// eslint-disable-next-line no-unused-vars
+import TabSwitch from '../biometrics/TabSwitch'
+// eslint-disable-next-line no-unused-vars
+import MediaInput from '../biometrics/MediaInput'
+// eslint-disable-next-line no-unused-vars
+import BoundingBoxCanvas from '../biometrics/BoundingBoxCanvas'
+// eslint-disable-next-line no-unused-vars
+import DistributionBars from '../biometrics/DistributionBars'
+// eslint-disable-next-line no-unused-vars
+import EmbeddingInspector from '../biometrics/EmbeddingInspector'
+import { faceApi } from '../../utils/api'
+import Icon from '../Icon'
+
+// The developer tools behind the face workbench: detect faces and guess
+// attributes (guesses, often wrong), and the raw faceprint.
+const TABS = [
+ { id: 'analyze', icon: 'chart-bar', label: 'Detect and analyze' },
+ { id: 'embed', icon: 'code', label: 'Embedding' },
+]
+
+export default function MoreFaceTools({ model, addToast }) {
+ const [tab, setTab] = useState('analyze')
+ return (
+
+
+
+ {tab === 'analyze' &&
}
+ {tab === 'embed' &&
}
+
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function AnalyzeTab({ model, addToast }) {
+ const [img, setImg] = useState(null)
+ const [actions, setActions] = useState({ age: false, gender: false, emotion: false, race: false })
+ const [antiSpoofing, setAntiSpoofing] = useState(false)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [result, setResult] = useState(null)
+ const [focusIdx, setFocusIdx] = useState(0)
+
+ const submit = async (e) => {
+ e.preventDefault()
+ if (!model) { addToast('Select a face model first', 'warning'); return }
+ if (!img) { addToast('Add an image to analyze', 'warning'); return }
+ setLoading(true); setError(null); setResult(null); setFocusIdx(0)
+ try {
+ const body = {
+ model,
+ img: img.dataUrl,
+ actions: Object.entries(actions).filter(([, v]) => v).map(([k]) => k),
+ anti_spoofing: antiSpoofing,
+ }
+ const data = await faceApi.analyze(body)
+ setResult(data)
+ if (!data?.faces?.length) addToast('No face detected in the image', 'warning')
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const boxes = useMemo(() => (result?.faces || []).map((f, i) => ({
+ x: f.region.x, y: f.region.y, w: f.region.w, h: f.region.h,
+ label: f.dominant_emotion || f.dominant_gender || `Face ${i + 1}`,
+ sublabel: f.age ? `~${Math.round(f.age)}y` : null,
+ tone: i === focusIdx ? 'accent' : 'default',
+ })), [result, focusIdx])
+
+ const faces = result?.faces || []
+ const focus = faces[focusIdx]
+
+ return (
+
+
+
+
+ {loading &&
}
+ {error && }
+ {!loading && !error && !result && (
+
+ )}
+ {result && img && (
+ <>
+
+
+
+ {faces.length > 1 && (
+
+ {faces.map((_, i) => (
+ setFocusIdx(i)}
+ aria-pressed={i === focusIdx}>
+ Face {i + 1}
+
+ ))}
+
+ )}
+
+
+ {focus && (
+ <>
+
+
+
Face {focusIdx + 1}
+ {antiSpoofing && }
+
+
+ {focus.age != null && <>Age ~{Math.round(focus.age)} >}
+ {focus.dominant_gender && <>Gender {focus.dominant_gender} >}
+ {focus.dominant_emotion && <>Emotion {focus.dominant_emotion} >}
+ {focus.dominant_race && <>Race {focus.dominant_race} >}
+ {focus.face_confidence != null && <>Detection {(focus.face_confidence * 100).toFixed(1)}% >}
+
+
+
+
+
+ >
+ )}
+
+
+
+ >
+ )}
+
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function EmbedTab({ model, addToast }) {
+ const [img, setImg] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [result, setResult] = useState(null)
+ const [elapsedMs, setElapsedMs] = useState(null)
+
+ const submit = async (e) => {
+ e.preventDefault()
+ if (!model) { addToast('Select a face model first', 'warning'); return }
+ if (!img) { addToast('Add an image', 'warning'); return }
+ setLoading(true); setError(null); setResult(null)
+ const started = performance.now()
+ try {
+ const data = await faceApi.embed({ model, img: img.dataUrl })
+ setElapsedMs(performance.now() - started)
+ setResult(data)
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+ Get a raw embedding
+
+ Returns a single face embedding vector. This is the same representation the backend uses internally for verify, identify, and compare.
+
+
+
+ {loading ? <> Embedding…> : <> Extract vector>}
+
+
+
+ {loading &&
}
+ {error && }
+ {!loading && !error && !result && (
+
+ )}
+ {result && (
+
+ )}
+
+
+ )
+}
+
+// ──────────────────────────── Small shared bits ────────────────────────────
+
+// eslint-disable-next-line no-unused-vars
+function LivenessPill({ isReal, score }) {
+ if (isReal == null) {
+ return Not checked
+ }
+ return (
+
+
+ {isReal ? 'Real' : 'Spoof'}
+ {score != null && {(score * 100).toFixed(0)}% }
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function EmptyState({ icon, title, body }) {
+ return (
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function ResponseDetails({ data }) {
+ return (
+
+ Raw response
+ {JSON.stringify(data, null, 2)}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/identity/MoreVoiceTools.jsx b/core/http/react-ui/src/components/identity/MoreVoiceTools.jsx
new file mode 100644
index 000000000..ed3f52fa5
--- /dev/null
+++ b/core/http/react-ui/src/components/identity/MoreVoiceTools.jsx
@@ -0,0 +1,222 @@
+import { useMemo, useState } from 'react'
+// eslint-disable-next-line no-unused-vars
+import LoadingSpinner from '../LoadingSpinner'
+// eslint-disable-next-line no-unused-vars
+import ErrorWithTraceLink from '../ErrorWithTraceLink'
+// eslint-disable-next-line no-unused-vars
+import TabSwitch from '../biometrics/TabSwitch'
+// eslint-disable-next-line no-unused-vars
+import MediaInput from '../biometrics/MediaInput'
+// eslint-disable-next-line no-unused-vars
+import WaveformStrip from '../biometrics/WaveformStrip'
+// eslint-disable-next-line no-unused-vars
+import DistributionBars from '../biometrics/DistributionBars'
+// eslint-disable-next-line no-unused-vars
+import EmbeddingInspector from '../biometrics/EmbeddingInspector'
+import { voiceApi } from '../../utils/api'
+import Icon from '../Icon'
+
+// The two developer tools that sit behind the voice workbench: what a clip
+// sounds like (age, gender, emotion are guesses) and the raw voiceprint.
+const TABS = [
+ { id: 'analyze', icon: 'waveform', label: 'Analyze' },
+ { id: 'embed', icon: 'code', label: 'Embedding' },
+]
+
+const TONE_FOR_SEGMENT = ['accent', 'info', 'success', 'warning', 'data1', 'data2']
+
+export default function MoreVoiceTools({ model, addToast }) {
+ const [tab, setTab] = useState('analyze')
+ return (
+
+
+
+ {tab === 'analyze' &&
}
+ {tab === 'embed' &&
}
+
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function AnalyzeTab({ model, addToast }) {
+ const [audio, setAudio] = useState(null)
+ const [actions, setActions] = useState({ age: false, gender: false, emotion: false })
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [result, setResult] = useState(null)
+ const [focusIdx, setFocusIdx] = useState(0)
+
+ const submit = async (e) => {
+ e.preventDefault()
+ if (!model) { addToast('Select a speaker model first', 'warning'); return }
+ if (!audio) { addToast('Add an audio clip', 'warning'); return }
+ setLoading(true); setError(null); setResult(null); setFocusIdx(0)
+ try {
+ const data = await voiceApi.analyze({
+ model,
+ audio: audio.dataUrl,
+ actions: Object.entries(actions).filter(([, v]) => v).map(([k]) => k),
+ })
+ setResult(data)
+ if (!data?.segments?.length) addToast('No speech segments detected', 'warning')
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const segments = useMemo(() => result?.segments || [], [result])
+ const focus = segments[focusIdx]
+ const waveformSegments = useMemo(() => segments.map((s, i) => ({
+ start: s.start, end: s.end,
+ label: s.dominant_emotion || s.dominant_gender || `#${i + 1}`,
+ tone: i === focusIdx ? 'accent' : TONE_FOR_SEGMENT[i % TONE_FOR_SEGMENT.length],
+ })), [segments, focusIdx])
+
+ return (
+
+
+
+
+ {loading &&
}
+ {error && }
+ {!loading && !error && !result && (
+
+ )}
+ {result && audio && (
+ <>
+
+ {segments.length > 1 && (
+
+ {segments.map((s, i) => (
+ setFocusIdx(i)}
+ aria-pressed={i === focusIdx}>
+ #{i + 1} {s.start.toFixed(1)}s–{s.end.toFixed(1)}s
+
+ ))}
+
+ )}
+ {focus && (
+
+
+
+
+
Segment {focusIdx + 1}
+ · {focus.start.toFixed(2)}s – {focus.end.toFixed(2)}s
+
+
+
+ {focus.age != null && <>Age ~{Math.round(focus.age)} >}
+ {focus.dominant_gender && <>Gender {focus.dominant_gender} >}
+ {focus.dominant_emotion && <>Emotion {focus.dominant_emotion} >}
+
+
+
+
+
+
+ )}
+
+ >
+ )}
+
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function EmbedTab({ model, addToast }) {
+ const [audio, setAudio] = useState(null)
+ const [loading, setLoading] = useState(false)
+ const [error, setError] = useState(null)
+ const [result, setResult] = useState(null)
+ const [elapsedMs, setElapsedMs] = useState(null)
+
+ const submit = async (e) => {
+ e.preventDefault()
+ if (!model) { addToast('Select a speaker model first', 'warning'); return }
+ if (!audio) { addToast('Add an audio clip', 'warning'); return }
+ setLoading(true); setError(null); setResult(null)
+ const started = performance.now()
+ try {
+ const data = await voiceApi.embed({ model, audio: audio.dataUrl })
+ setElapsedMs(performance.now() - started)
+ setResult(data)
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+ Get a raw speaker embedding
+
+ Returns a speaker-encoder vector — the same representation the backend uses internally for verify and identify.
+
+
+
+ {loading ? <> Embedding…> : <> Extract vector>}
+
+
+
+ {loading &&
}
+ {error && }
+ {!loading && !error && !result && (
+
+ )}
+ {result && (
+
+ )}
+
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function EmptyState({ icon, title, body }) {
+ return (
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function ResponseDetails({ data }) {
+ return (
+
+ Raw response
+ {JSON.stringify(data, null, 2)}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/identity/Verdict.jsx b/core/http/react-ui/src/components/identity/Verdict.jsx
new file mode 100644
index 000000000..a3bc3109d
--- /dev/null
+++ b/core/http/react-ui/src/components/identity/Verdict.jsx
@@ -0,0 +1,22 @@
+import { useTranslation } from 'react-i18next'
+import Icon from '../Icon'
+
+const WORD = { strong: 'ok', likely: 'ok', close: 'warn', near: 'warn', far: '' }
+
+// The answer in a sentence: a headline, a word for how strong it is, and one
+// plain line with the real numbers. `tone` picks the icon: match, miss, none.
+export default function Verdict({ tone, title, word, children, testId = 'verdict' }) {
+ const { t } = useTranslation('biometrics')
+ return (
+
+
+
+
+ {title}
+ {word && {t(`word.${word}`)} }
+
+
{children}
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/identity/Workbench.jsx b/core/http/react-ui/src/components/identity/Workbench.jsx
new file mode 100644
index 000000000..18aee5215
--- /dev/null
+++ b/core/http/react-ui/src/components/identity/Workbench.jsx
@@ -0,0 +1,363 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { faceApi, voiceApi } from '../../utils/api'
+import { FACE_CUTOFF, VOICE_CUTOFF, rankMatches, strength } from '../../utils/identity'
+import { UNDO_WINDOW_MS } from '../../hooks/useDelayedAction'
+// eslint-disable-next-line no-unused-vars
+import BoundingBoxCanvas from '../biometrics/BoundingBoxCanvas'
+// eslint-disable-next-line no-unused-vars
+import HomeUndoToast from '../home/HomeUndoToast'
+// eslint-disable-next-line no-unused-vars
+import LoadingSpinner from '../LoadingSpinner'
+import Icon from '../Icon'
+// eslint-disable-next-line no-unused-vars
+import ClipInput from './ClipInput'
+// eslint-disable-next-line no-unused-vars
+import DistanceScale from './DistanceScale'
+// eslint-disable-next-line no-unused-vars
+import EnrolSheet from './EnrolSheet'
+// eslint-disable-next-line no-unused-vars
+import ErrorNote from './ErrorNote'
+// eslint-disable-next-line no-unused-vars
+import KnownList from './KnownList'
+import { useRegistry } from './useRegistry'
+// eslint-disable-next-line no-unused-vars
+import Verdict from './Verdict'
+
+const KINDS = {
+ voice: {
+ api: voiceApi, mode: 'audio', cutoff: VOICE_CUTOFF, print: 'voiceprint', key: 'localai_voice_enrollments',
+ probe: 'audio', pair: ['audio1', 'audio2'], minTopK: 25,
+ },
+ face: {
+ api: faceApi, mode: 'image', cutoff: FACE_CUTOFF, print: 'faceprint', key: 'localai_face_enrollments',
+ probe: 'img', pair: ['img1', 'img2'], minTopK: 25,
+ },
+}
+
+// eslint-disable-next-line no-unused-vars
+function CutoffControl({ value, onChange, modelDefault, kind }) {
+ const { t } = useTranslation('biometrics')
+ const same = Math.abs(value - modelDefault) < 0.0005
+ const fill = { '--dk-fill': `${((value - 0.05) / 0.95) * 100}%` }
+ return (
+
+
+ {t('cutoff.label')} {value.toFixed(2)}
+ {same
+ ? {t('cutoff.default')}
+ : onChange(modelDefault)}>{t('cutoff.reset', { value: modelDefault.toFixed(2) })} }
+
+
+ {t('cutoff.stricter')}
+ onChange(parseFloat(e.target.value))} />
+ {t('cutoff.looser')}
+
+
{t('cutoff.explain')}
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function HowToRead({ kind, print }) {
+ const { t } = useTranslation('biometrics')
+ return (
+ {t('how.title')} {t('how.body', { print })}
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function WhyWrong({ kind, notes }) {
+ const { t } = useTranslation('biometrics')
+ const list = [...notes, ...t(`${kind}.why`, { returnObjects: true })]
+ return (
+
+ {t('why.title')}
+
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function Stored({ kind, print }) {
+ const { t } = useTranslation('biometrics')
+ const points = t(`${kind}.stored`, { returnObjects: true, print })
+ return (
+
+ {t('stored.title')}
+
+ {points.map(p =>
{p.where} {p.what} )}
+
+
+ )
+}
+
+// The whole identity tool, for voices or for faces: who is this, same person,
+// the people this browser knows, and what is stored. The two kinds differ in
+// the sample (audio or photo), the calls and the default cut-off; everything
+// else is shared so the pages read as one family.
+export default function Workbench({ kind, model, addToast, canSpeech = false, more = null, needed = null, onCount }) {
+ const cfg = KINDS[kind]
+ const isFace = kind === 'face'
+ const { t } = useTranslation('biometrics')
+ const noun = t(`${kind}.print`)
+ const [tool, setTool] = useState('identify')
+ const [probe, setProbe] = useState(null)
+ const [probeFacts, setProbeFacts] = useState(null)
+ const [clipA, setClipA] = useState(null)
+ const [clipB, setClipB] = useState(null)
+ const [antiSpoof, setAntiSpoof] = useState(false)
+ const [find, setFind] = useState({ status: 'idle' })
+ const [pair, setPair] = useState({ status: 'idle' })
+ const [findCut, setFindCut] = useState(cfg.cutoff)
+ const [pairCut, setPairCut] = useState(null)
+ const [sheet, setSheet] = useState(null)
+
+ const onForgot = useCallback((entry, outcome) => {
+ if (outcome === 'gone') addToast(t('toast.removed', { name: entry.name }), 'info')
+ else if (outcome === 'already-gone') addToast(t('toast.alreadyGone', { name: entry.name }), 'warning')
+ else addToast(outcome.message, 'error')
+ }, [addToast, t])
+ const forgetCall = useCallback((body) => cfg.api.forget(body), [cfg])
+ const reg = useRegistry({ storageKey: cfg.key, forget: forgetCall, onForgot })
+
+ const visibleEntries = reg.entries
+ const known = visibleEntries.length
+ useEffect(() => { onCount?.(known) }, [known, onCount])
+
+ const identify = async () => {
+ if (!model || !probe) return
+ setFind({ status: 'working' })
+ const topK = Math.max(cfg.minTopK, known * 2)
+ try {
+ const data = await cfg.api.identify({ model, [cfg.probe]: probe.dataUrl, top_k: topK, threshold: findCut })
+ const matches = data?.matches || []
+ reg.noteSearch(matches, topK)
+ setFind({ status: 'done', matches, cutoffSent: findCut, searched: known })
+ } catch (err) {
+ setFind({ status: 'failed', error: err })
+ }
+ }
+
+ const compare = async () => {
+ if (!model || !clipA || !clipB) return
+ setPair({ status: 'working' })
+ setPairCut(null)
+ try {
+ const body = { model, [cfg.pair[0]]: clipA.dataUrl, [cfg.pair[1]]: clipB.dataUrl }
+ if (isFace) body.anti_spoofing = antiSpoof
+ const data = await cfg.api.verify(body)
+ setPair({ status: 'done', data })
+ } catch (err) {
+ setPair({ status: 'failed', error: err })
+ }
+ }
+
+ const ranked = useMemo(() => (find.status === 'done' ? rankMatches(find.matches, findCut) : []), [find, findCut])
+ const best = ranked[0]
+ const nameOf = (m) => m.name || m.id
+
+ const onEnrolled = (entry, extra) => {
+ reg.add(entry)
+ setSheet(null)
+ addToast(t(extra.asSpeech && !extra.speechError ? 'toast.enrolledBoth' : 'toast.enrolled', { name: entry.name }), 'success')
+ if (extra.keptTooBig) addToast(t('toast.tooBig'), 'warning')
+ if (extra.speechError) addToast(t('toast.speechFailed', { message: extra.speechError.message }), 'error')
+ }
+
+ const again = async (row) => {
+ const saved = row.sampleUrl || row.thumbnail
+ if (!saved) { setSheet({ name: row.name, labels: row.labels }); return }
+ try {
+ const data = await cfg.api.register(isFace
+ ? { model, name: row.name, img: saved, labels: row.labels || {} }
+ : { model, name: row.name, audio: saved, labels: row.labels || {} })
+ reg.replace(row.id, { ...row, id: data.id, registeredAt: data.registered_at || new Date().toISOString(), model })
+ addToast(t('toast.enrolled', { name: row.name }), 'success')
+ } catch (err) {
+ addToast(err.message, 'error')
+ }
+ }
+
+ const noteFor = (r) => {
+ const d = r.distance.toFixed(2)
+ const c = findCut.toFixed(2)
+ const n = find.matches.length
+ if (r.within) return t(`${kind}.matchText`, { name: nameOf(r), distance: d, cutoff: c, count: n })
+ return t(`${kind}.missText`, { name: nameOf(r), distance: d, cutoff: c })
+ }
+
+ const pairResult = pair.status === 'done' ? pair.data : null
+ const pairModelCut = pairResult?.threshold || cfg.cutoff
+ const pairActive = pairCut ?? pairModelCut
+ const pairWithin = pairResult ? pairResult.distance <= pairActive : false
+ const pairWord = pairResult ? strength(pairResult.distance, pairActive) : null
+
+ const working = find.status === 'working' || pair.status === 'working'
+
+ return (
+
+ {needed}
+ {!needed &&
+
+
+ setTool('identify')}>{t(`${kind}.whoTab`)}
+ setTool('same')}>{t('tool.sameTab')}
+
+
{tool === 'identify' ? t(`${kind}.whoLede`) : t(`${kind}.sameLede`)}
+
+
+ {tool === 'identify' && (
+
+
{ setProbe(v); setFind({ status: 'idle' }) }} idPrefix={`${kind}-probe`} onFacts={setProbeFacts} />
+
+
+ {find.status === 'working' ? <> {t(`${kind}.working`)}> : <>{t(`${kind}.whoGo`)} >}
+
+ {t(`${kind}.whoNote`, { count: known })}
+
+
+ {find.status === 'working' && (
+
+
+
{t(`${kind}.workingText`, { count: known })}
+
+ )}
+
+ {find.status === 'failed' && }
+
+ {find.status === 'done' && (
+
+ {!best && (
+
+ {t(known ? `${kind}.noneReturnedText` : `${kind}.noneEnrolledText`)}
+
+ )}
+ {best && best.within && (
+
{noteFor(best)}
+ )}
+ {best && !best.within && (
+
{noteFor(best)}
+ )}
+
+ {best && (
+ <>
+
({ id: r.id, label: nameOf(r).split(/\s+/)[0], distance: r.distance, within: r.within, best: r.rank === 1 && r.within }))} />
+
+ {ranked.slice(0, 5).map(r => (
+
+ #{r.rank}
+ {nameOf(r)}
+ {r.distance.toFixed(2)}
+ {r.within ? {t('ranks.within')} : {t('ranks.far')} }
+
+ ))}
+
+
+
+
+
+ >
+ )}
+
+ {(!best || !best.within) && (
+ setSheet({ sample: probe })}>
+ {t(`${kind}.enrolThis`)}
+
+ )}
+
+ )}
+
+ )}
+
+ {tool === 'same' && (
+
+
+ { setClipA(v); setPair({ status: 'idle' }) }} idPrefix={`${kind}-a`} />
+ { setClipB(v); setPair({ status: 'idle' }) }} idPrefix={`${kind}-b`} />
+
+ {isFace && (
+
+ setAntiSpoof(e.target.checked)} />
+ {t('face.antiSpoof')} {t('face.antiSpoofHint')}
+
+ )}
+
+
+ {pair.status === 'working' ? <> {t('tool.comparing')}> : <>{t('tool.compare')} >}
+
+ {t(`${kind}.sameNote`)}
+
+
+ {pair.status === 'working' && (
+
+ )}
+ {pair.status === 'failed' &&
}
+
+ {pairResult && (
+
+
+ {t('same.text', { print: noun, distance: pairResult.distance.toFixed(2), cutoff: pairActive.toFixed(2) })}
+
+
+ {isFace && (pairResult.img1_area || pairResult.img2_area) && (
+
+ {[[clipA, pairResult.img1_area, pairResult.img1_is_real], [clipB, pairResult.img2_area, pairResult.img2_is_real]].map(([clip, area, real], i) => (
+
+
+ {antiSpoof && real != null && {real ? t('face.real') : t('face.spoof')} }
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ )}
+
+ )}
+ }
+
+
+
+
{t(`${kind}.knownTitle`)} {known}
+ setSheet({})} disabled={!model} data-testid="enrol-open">
+ {t(`${kind}.enrol`)}
+
+
+ {t(reg.searched ? 'known.noteSearched' : 'known.note', { noun })}
+ reg.schedule(row)} onUndo={reg.undo} onAgain={again} onEnrol={() => setSheet({})} canEnrol={!!model}
+ />
+
+
+
+ {more}
+
+ {sheet && (
+
setSheet(null)} onDone={onEnrolled} />
+ )}
+ {reg.latest && (
+ reg.undo(reg.latest.id)}
+ onExpire={() => reg.finish(reg.latest.id)}
+ undoLabel={t('known.undo')}
+ dismissible={false}
+ testId="forget-undo-toast"
+ />
+ )}
+ {working && {t('tool.workingAria')} }
+
+ )
+}
diff --git a/core/http/react-ui/src/components/identity/useRegistry.js b/core/http/react-ui/src/components/identity/useRegistry.js
new file mode 100644
index 000000000..973612f39
--- /dev/null
+++ b/core/http/react-ui/src/components/identity/useRegistry.js
@@ -0,0 +1,76 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useDelayedAction } from '../../hooks/useDelayedAction'
+import { loadList, missingFromServer, saveList } from '../../utils/identity'
+
+// The people this browser enrolled, plus what the last search learned about
+// the server. The server has no list call, so the list is a local record: it
+// can be out of date after a restart, and the only evidence is a search that
+// asked for everyone and did not get someone back.
+//
+// storageKey localStorage key of the saved list
+// forget the real call, `({ id }) => Promise`
+// onForgot (entry, outcome) after the call: 'gone', 'already-gone' or an Error
+export function useRegistry({ storageKey, forget, onForgot }) {
+ const [entries, setEntries] = useState(() => loadList(storageKey))
+ const [search, setSearch] = useState(null)
+ const [latest, setLatest] = useState(null)
+
+ useEffect(() => { saveList(storageKey, entries) }, [storageKey, entries])
+
+ const run = useCallback(async (_key, entry) => {
+ try {
+ await forget({ id: entry.id })
+ setEntries(prev => prev.filter(e => e.id !== entry.id))
+ setSearch(prev => (prev ? { ...prev, extras: prev.extras.filter(e => e.id !== entry.id) } : prev))
+ onForgot?.(entry, 'gone')
+ } catch (err) {
+ if (err?.status === 404) {
+ setEntries(prev => prev.filter(e => e.id !== entry.id))
+ onForgot?.(entry, 'already-gone')
+ } else {
+ onForgot?.(entry, err)
+ }
+ }
+ }, [forget, onForgot])
+
+ const forgetting = useDelayedAction(run)
+
+ const add = useCallback((entry) => setEntries(prev => [entry, ...prev.filter(e => e.id !== entry.id)]), [])
+
+ // Swap a row for the same person under the id the server just made.
+ const replace = useCallback((oldId, entry) => setEntries(prev => prev.map(e => (e.id === oldId ? entry : e))), [])
+
+ const schedule = useCallback((entry) => {
+ setLatest(entry)
+ forgetting.schedule(entry.id, entry)
+ }, [forgetting])
+
+ const undo = useCallback((id) => {
+ forgetting.cancel(id)
+ setLatest(prev => (prev?.id === id ? null : prev))
+ }, [forgetting])
+
+ const finish = useCallback((id) => {
+ forgetting.flush(id)
+ setLatest(prev => (prev?.id === id ? null : prev))
+ }, [forgetting])
+
+ // Record what a search returned. `asked` is the top_k it sent.
+ const noteSearch = useCallback((matches, asked) => {
+ const ids = matches.map(m => m.id)
+ const known = new Set(entries.map(e => e.id))
+ setSearch({
+ ids,
+ complete: matches.length < asked,
+ extras: matches.filter(m => !known.has(m.id)).map(m => ({ id: m.id, name: m.name || m.id, labels: m.labels || {} })),
+ })
+ }, [entries])
+
+ const missing = useMemo(() => missingFromServer(entries, search), [entries, search])
+
+ return {
+ entries, add, replace, missing, searched: !!search, extras: search?.extras || [],
+ waiting: forgetting.waiting, latest: latest && forgetting.waiting[latest.id] ? latest : null,
+ schedule, undo, finish, noteSearch,
+ }
+}
diff --git a/core/http/react-ui/src/components/library/LibraryBits.jsx b/core/http/react-ui/src/components/library/LibraryBits.jsx
new file mode 100644
index 000000000..1efc30614
--- /dev/null
+++ b/core/http/react-ui/src/components/library/LibraryBits.jsx
@@ -0,0 +1,182 @@
+import { useEffect, useRef, useState } from 'react'
+// eslint-disable-next-line no-unused-vars
+import { Link } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import Icon from '../Icon'
+import { agentHref } from '../../utils/libraryText'
+import { agentSkills, estimateTokens, passageScore, passageSource, passageText, skillLoadTokens, skillsMode } from '../../utils/library'
+
+// The strip in a pane: who uses this, each name a link to the agent with a
+// small x to take the item away, and the Add to... menu at the end.
+export function UsedByStrip({ users, status, onRemove, busy, children }) {
+ const { t } = useTranslation('library')
+ return (
+
+
{t('usedBy.label')}
+ {status === 'failed' ? (
+
{t('usedBy.unread')}
+ ) : status !== 'ready' ? (
+
...
+ ) : users.length === 0 ? (
+
{t('usedBy.none')}
+ ) : (
+
+ {users.map(u => (
+
+ {u.name}
+ {u.all && {t('usedBy.all')} }
+ onRemove(u.name)}
+ >
+
+
+
+ ))}
+
+ )}
+
{children}
+
+ )
+}
+
+// The Add to... menu. A skill can go to any agent; a collection can go only to
+// the agent that carries its name, because that is the collection an agent
+// reads. Each row says what the addition costs where that can be worked out.
+export function AddToMenu({ kind, name, skill, agents, allSkillNames, status, onAdd, busy, buttonClass = 'dk-btn dk-btn--secondary dk-btn--sm' }) {
+ const { t } = useTranslation('library')
+ const [open, setOpen] = useState(false)
+ const root = useRef(null)
+
+ useEffect(() => {
+ if (!open) return undefined
+ const onDown = e => { if (root.current && !root.current.contains(e.target)) setOpen(false) }
+ const onKey = e => { if (e.key === 'Escape') setOpen(false) }
+ document.addEventListener('mousedown', onDown)
+ document.addEventListener('keydown', onKey)
+ return () => {
+ document.removeEventListener('mousedown', onDown)
+ document.removeEventListener('keydown', onKey)
+ }
+ }, [open])
+
+ const rows = kind === 'collection' ? agents.filter(a => a.name === name) : agents
+ const hasAgent = rows.length > 0
+
+ const rowFor = agent => {
+ if (kind === 'collection') {
+ const on = !!agent.config?.enable_kb
+ return { on, hint: on ? '' : t('add.kbOn') }
+ }
+ const s = agentSkills(agent.config, allSkillNames)
+ const on = s.names.includes(name)
+ if (on) return { on, hint: '' }
+ const tokens = skillLoadTokens(skill, agent.config)
+ const hint = skillsMode(agent.config) === 'tools' ? t('add.toolsMode') : t('add.tokens', { count: tokens })
+ return { on, hint }
+ }
+
+ return (
+
+ setOpen(v => !v)}
+ data-testid="add-to-button"
+ >
+ {t('add.button')}
+
+ {open && (
+
+
{t('add.title', { name })}
+ {kind === 'skill' && hasAgent &&
{t('add.estimate')}
}
+ {kind === 'collection' &&
{t('add.collectionNote')}
}
+ {status === 'failed' ? (
+
{t('usedBy.unread')}
+ ) : !hasAgent ? (
+
+
{kind === 'collection' ? t('add.noAgentNamed', { name }) : t('add.noAgents')}
+
{t('add.createAgent')}
+
+ ) : (
+ <>
+
{t('add.agents')}
+
+ {rows.map(agent => {
+ const { on, hint } = rowFor(agent)
+ return (
+
+
+ {agent.name}
+ {hint && {hint} }
+
+ {on ? (
+ {t('add.added')}
+ ) : (
+ { await onAdd(agent.name); setOpen(false) }}
+ >
+ {t('add.action')}
+
+ )}
+
+ )
+ })}
+
+ >
+ )}
+
+ )}
+
+ )
+}
+
+// A similarity score as a number and a short bar.
+export function ScoreBar({ score, label }) {
+ const width = score == null ? 0 : Math.max(0, Math.min(1, score)) * 100
+ return (
+
+ {score == null ? '-' : score.toFixed(2)}
+
+
+ )
+}
+
+
+// Passages a search returned, best first, each with where it came from, an
+// estimate of its size and its score.
+export function PassageList({ results }) {
+ const { t } = useTranslation('library')
+ return (
+
+ {results.map((r, i) => {
+ const score = passageScore(r)
+ const source = passageSource(r)
+ const text = passageText(r)
+ return (
+
+ {i + 1}
+
+
{text}
+
+ {source && {source} }
+ {t('simulate.tokens', { count: estimateTokens(text) })}
+
+
+
+
+ )
+ })}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/library/SimulateSheet.jsx b/core/http/react-ui/src/components/library/SimulateSheet.jsx
new file mode 100644
index 000000000..6c8459a62
--- /dev/null
+++ b/core/http/react-ui/src/components/library/SimulateSheet.jsx
@@ -0,0 +1,340 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+// eslint-disable-next-line no-unused-vars
+import { Link } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import Icon from '../Icon'
+import { agentCollectionsApi } from '../../utils/api'
+import {
+ agentSkills, estimateTokens, kbResults, kbSearchesEveryMessage, passageText, skillLoadTokens, skillsMode,
+} from '../../utils/library'
+// eslint-disable-next-line no-unused-vars
+import { PassageList } from './LibraryBits'
+import { agentHref } from '../../utils/libraryText'
+
+const COLLECTION = '__collection__'
+
+// What a context would load for a message, from the parts the API can run: a
+// search on a collection (real passages with their scores) and the skills an
+// agent has switched on. It cannot run a model, so it never shows an answer.
+// All token numbers are estimates: characters divided by four.
+//
+// agents, agentsStatus from useLibraryFacts
+// skills the skill list, with content
+// collections collection names
+// seed { kind: 'skill' | 'collection', name } to try an
+// item that is not in the chosen context
+export default function SimulateSheet(props) {
+ if (!props.open) return null
+ return
+}
+
+// eslint-disable-next-line no-unused-vars
+function Sheet({ onClose, agents, agentsStatus, skills, collections, seed }) {
+ const { t } = useTranslation('library')
+ const sheetRef = useRef(null)
+ const closeRef = useRef(null)
+ const openerRef = useRef(null)
+
+ const firstContext = seed?.kind === 'skill' && agents.length > 0 ? agents[0].name
+ : (collections.length > 0 || agents.length === 0 ? COLLECTION : agents[0].name)
+ const [context, setContext] = useState(firstContext)
+ const [collection, setCollection] = useState(seed?.kind === 'collection' ? seed.name : (collections[0] || ''))
+ const [message, setMessage] = useState('')
+ const [maxResults, setMaxResults] = useState(5)
+ // Items tried only in this sheet. They are never saved to an agent.
+ const [extras, setExtras] = useState(() => (seed ? [{ kind: seed.kind, name: seed.name }] : []))
+ const [state, setState] = useState({ phase: 'idle' })
+
+ useEffect(() => {
+ openerRef.current = document.activeElement
+ closeRef.current?.focus()
+ return () => {
+ const opener = openerRef.current
+ if (opener && typeof opener.focus === 'function') opener.focus()
+ }
+ }, [])
+
+ useEffect(() => {
+ const onKey = e => {
+ if (e.key === 'Escape') { e.stopPropagation(); onClose(); return }
+ if (e.key !== 'Tab' || !sheetRef.current) return
+ const focusable = sheetRef.current.querySelectorAll('a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled])')
+ if (focusable.length === 0) return
+ const first = focusable[0]
+ const last = focusable[focusable.length - 1]
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [onClose])
+
+ const allSkillNames = useMemo(() => skills.map(s => s.name), [skills])
+ const agent = agents.find(a => a.name === context) || null
+ const isCollection = context === COLLECTION
+
+ // Changing the context clears the last result: it described another one.
+ const pickContext = value => { setContext(value); setState({ phase: 'idle' }) }
+
+ const extraSkills = extras.filter(e => e.kind === 'skill').map(e => e.name)
+ const extraCollections = extras.filter(e => e.kind === 'collection').map(e => e.name)
+
+ const run = async () => {
+ const text = message.trim()
+ if (!text) { setState({ phase: 'error', message: t('simulate.pickMessage') }); return }
+ setState({ phase: 'running' })
+ // Which collections to search, and why.
+ const targets = []
+ if (isCollection) {
+ if (collection) targets.push({ name: collection, why: 'alone' })
+ } else if (agent) {
+ if (kbSearchesEveryMessage(agent.config) && collections.includes(agent.name)) targets.push({ name: agent.name, why: 'agent' })
+ for (const name of extraCollections) if (!targets.some(x => x.name === name)) targets.push({ name, why: 'test' })
+ }
+ const limit = isCollection ? maxResults : (agent ? kbResults(agent.config) : maxResults)
+ try {
+ const groups = await Promise.all(targets.map(async target => {
+ const data = await agentCollectionsApi.search(target.name, text, limit)
+ const results = Array.isArray(data?.results) ? data.results : []
+ return { ...target, results }
+ }))
+ setState({ phase: 'done', groups, text })
+ } catch (err) {
+ setState({ phase: 'error', message: t('simulate.searchFailed', { message: err.message }) })
+ }
+ }
+
+ // What the agent keeps around a message, estimated.
+ const budget = useMemo(() => {
+ if (!agent || state.phase !== 'done') return null
+ const sk = agentSkills(agent.config, allSkillNames)
+ const names = [...sk.names, ...extraSkills.filter(n => !sk.names.includes(n))]
+ const loaded = names.map(name => {
+ const skill = skills.find(s => s.name === name)
+ return { name, missing: !skill, tokens: skill ? skillLoadTokens(skill, agent.config) : 0, test: extraSkills.includes(name) && !sk.names.includes(name) }
+ })
+ const instructions = estimateTokens(agent.config?.system_prompt || '')
+ const skillTokens = loaded.reduce((n, s) => n + s.tokens, 0)
+ const memoryTokens = state.groups.reduce((n, g) => n + g.results.reduce((m, r) => m + estimateTokens(passageText(r)), 0), 0)
+ const total = instructions + skillTokens + memoryTokens
+ return { loaded, instructions, skillTokens, memoryTokens, total, message: estimateTokens(state.text), allOn: sk.all }
+ }, [agent, state, skills, allSkillNames, extraSkills])
+
+ const addExtra = item => setExtras(prev => (prev.some(e => e.kind === item.kind && e.name === item.name) ? prev : [...prev, item]))
+ const dropExtra = item => { setExtras(prev => prev.filter(e => !(e.kind === item.kind && e.name === item.name))); setState({ phase: 'idle' }) }
+
+ const pct = n => (budget && budget.total > 0 ? Math.round((n / budget.total) * 100) : 0)
+
+ return (
+
+
e.stopPropagation()}
+ >
+
+
+
+
{t('simulate.title')}
+
{t('simulate.desc')}
+
+
+
+
+
+
+
+
+
{t('simulate.context')}
+
+ pickContext(e.target.value)}>
+ {t('simulate.contextCollection')}
+ {agents.map(a => {t('simulate.contextAgent', { name: a.name })} )}
+
+
+
{isCollection ? t('simulate.contextHintCollection') : t('simulate.contextHintAgent')}
+ {agentsStatus === 'ready' && agents.length === 0 &&
{t('simulate.noAgents')}
}
+
+
+ {isCollection && (
+
+
+
{t('simulate.collectionOn')}
+ {collections.length === 0 ? (
+
{t('simulate.noCollections')}
+ ) : (
+
+ { setCollection(e.target.value); setState({ phase: 'idle' }) }}>
+ {collections.map(c => {c} )}
+
+
+ )}
+
+
+ {t('simulate.passages')}
+ setMaxResults(parseInt(e.target.value, 10) || 5)} />
+
+
+ )}
+
+
+ {t('simulate.message')}
+ { setMessage(e.target.value); if (state.phase === 'error') setState({ phase: 'idle' }) }}
+ />
+
+
+ {!isCollection && (skills.length > 0 || collections.length > 0) && (
+
+ {t('simulate.tryAlso')}
+
+
+
+
+ )}
+
+ {extras.length > 0 && (
+
+
{t('simulate.onlyForTest')}
+
+ {extras.map(e => (
+
+
+ {e.name}
+ dropExtra(e)}>
+
+
+
+ ))}
+
+
+ )}
+
+
+
+ {state.phase === 'running' ? t('simulate.running') : t('simulate.run')}
+
+ {t('simulate.backendNote')}
+
+
+
+ {state.phase === 'error' &&
{state.message}
}
+
+
+ {state.phase === 'done' && (
+
+ {!isCollection && budget && (
+
+ {t('simulate.budgetTitle')}
+
+
+
+
+
+ {t('simulate.budgetTotal', { count: budget.total })}
+
+ {t('simulate.instructions')} {budget.instructions}
+ {t('simulate.skills')} {budget.skillTokens}
+ {t('simulate.memory')} {budget.memoryTokens}
+
+ {t('simulate.budgetMessage', { count: budget.message })}. {t('simulate.budgetNote')}
+
+ )}
+
+ {!isCollection && budget && (
+
+ {t('simulate.skillsTitle')} {budget.loaded.length}
+ {budget.loaded.length === 0 ? (
+ {t('simulate.skillsNone')}
+ ) : (
+ <>
+
+ {budget.allOn && `${t('simulate.skillsAll')} `}
+ {t(`simulate.skills${skillsMode(agent.config) === 'tools' ? 'Tools' : skillsMode(agent.config) === 'both' ? 'Both' : 'Prompt'}`)}
+
+
+ {budget.loaded.map(s => (
+
+ {s.name}
+ {s.test && {t('simulate.onlyForTest')} }
+
+ {s.missing ? t('simulate.skillMissing', { name: s.name }) : (skillsMode(agent.config) === 'tools' ? t('simulate.skillTokensTools') : t('simulate.skillTokens', { count: s.tokens }))}
+
+
+ ))}
+
+ >
+ )}
+
+ )}
+
+
+ {t('simulate.memoryTitle')}
+ {!isCollection && agent && !agent.config?.enable_kb && extraCollections.length === 0 && {t('simulate.memoryNone')}
}
+ {!isCollection && agent && agent.config?.enable_kb && !collections.includes(agent.name) && (
+ {t('simulate.memoryNoCollection', { name: agent.name })}
+ )}
+ {!isCollection && agent && agent.config?.enable_kb && collections.includes(agent.name) && !kbSearchesEveryMessage(agent.config) && (
+ {t('simulate.memoryTools')}
+ )}
+ {state.groups.map(group => (
+
+
+ {group.why === 'test' ? t('simulate.memoryTesting', { name: group.name }) : t('simulate.memoryFrom', { name: group.name })}
+ {' '}
+ {group.results.length > 0 && <>{'· '}{t('simulate.passagesCount', { count: group.results.length })}>}
+
+ {group.results.length === 0 ? (
+
{t('simulate.noPassages')}
+ ) : (
+
+ )}
+
+ ))}
+
+
+ {!isCollection && agent && (
+
{agent.name}
+ )}
+
+ )}
+
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/models/CleanupSheet.jsx b/core/http/react-ui/src/components/models/CleanupSheet.jsx
new file mode 100644
index 000000000..5c8f377ab
--- /dev/null
+++ b/core/http/react-ui/src/components/models/CleanupSheet.jsx
@@ -0,0 +1,562 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+// eslint-disable-next-line no-unused-vars
+import ConfirmDialog from '../ConfirmDialog'
+import Icon from '../Icon'
+import { useModels } from '../../hooks/useModels'
+import { useGalleryEnrichment } from '../../hooks/useGalleryEnrichment'
+import { useModelSizes } from '../../hooks/useModelSizes'
+import { useModelStorage } from '../../hooks/useModelStorage'
+import { useCleanupFacts } from '../../hooks/useCleanupFacts'
+import { useBuildDuplicates } from '../../hooks/useBuildDuplicates'
+import { systemApi } from '../../utils/api'
+import { buildCleanupPlan, totalSize, UNDO_MS } from '../../utils/cleanupPlan'
+import { diskEntry } from '../../utils/modelStorage'
+import { gbLabel } from '../../utils/modelLedger'
+
+const TIERS = ['safe', 'probably', 'call']
+
+// Which models are running right now. The capabilities list says where a model
+// is loaded in a cluster; /system says what this host has loaded.
+function useRunning(enabled, refreshToken) {
+ const [ids, setIds] = useState(() => new Set())
+ const [ready, setReady] = useState(false)
+ const read = useCallback(async () => {
+ try {
+ const info = await systemApi.info()
+ setIds(new Set((Array.isArray(info?.loaded_models) ? info.loaded_models : []).map(m => m.id)))
+ } catch {
+ setIds(new Set())
+ } finally {
+ setReady(true)
+ }
+ }, [])
+ useEffect(() => { if (enabled) read() }, [enabled, refreshToken, read])
+ return { ids, ready, read }
+}
+
+function refText(t, ref) {
+ return t(`cleanup.ref.${ref.kind}`, { name: ref.name })
+}
+
+// What a model gives back when removed alone: its own files. Zero is a real
+// answer when every file is shared, and reads as zero, not as "unknown".
+function sizeText(t, item) {
+ if (item.sizeSource === 'disk') return gbLabel(item.size)
+ return item.size ? gbLabel(item.size) : t('cleanup.sizeUnknown')
+}
+
+// The line under a model's reason: files it shares with other installed models
+// (they stay on disk), and files its config names that are gone.
+// eslint-disable-next-line no-unused-vars
+function FactLines({ t, item }) {
+ if (item.sharedWith.length === 0 && item.missing.length === 0) return null
+ return (
+ <>
+ {item.sharedWith.length > 0 && (
+
+ {t('cleanup.sharedWith', {
+ size: gbLabel(item.sharedWith.reduce((n, s) => n + s.bytes, 0)),
+ models: item.sharedWith.map(s => s.model).join(', '),
+ })}
+
+ )}
+ {item.missing.length > 0 && (
+
+ {t('cleanup.missing.item', { count: item.missing.length })}
+
+ )}
+ >
+ )
+}
+
+function reasonText(t, reason) {
+ const base = t(`cleanup.reason.${reason.key}`, { keep: reason.keep })
+ return reason.unverified ? `${base} ${t('cleanup.reason.unverified')}` : base
+}
+
+function protectedText(t, reasons) {
+ return reasons.map(r => (
+ r.key === 'usedBy'
+ ? t('cleanup.reason.usedBy', { by: r.refs.map(ref => refText(t, ref)).join(', ') })
+ : t(`cleanup.reason.${r.key}`)
+ )).join(' ')
+}
+
+// The review sheet. It opens from the disk strip and ranks installed models by
+// how safe each is to remove, using only what the API reports. It owns its data
+// so nothing is fetched until it is open.
+//
+// disk diskState() of the models volume
+// hiddenIds models waiting in an undo window, which no longer count
+// removalPending a batch is waiting or being removed
+// refreshToken changes when the installed list changed under the page
+// onRemove(items) hand the confirmed batch to the page, which owns the undo
+export default function CleanupSheet(props) {
+ // Mounted only while open, so nothing is fetched, and no selection lingers,
+ // until the user asks for the review.
+ if (!props.open) return null
+ return
+}
+
+function Sheet({ onClose, disk, hiddenIds, removalPending, refreshToken, onRemove }) {
+ const open = true
+ const { t } = useTranslation('models')
+ const { models, loading: modelsLoading, error: modelsError, refetch } = useModels()
+ const { enrichModel, loaded: galleryLoaded } = useGalleryEnrichment(open)
+ const running = useRunning(open, refreshToken)
+ const facts = useCleanupFacts(open)
+ const storage = useModelStorage(open, refreshToken)
+ const [selected, setSelected] = useState(() => new Set())
+ const [confirm, setConfirm] = useState(null)
+ const [checking, setChecking] = useState(false)
+ const [showProtected, setShowProtected] = useState(false)
+ const sheetRef = useRef(null)
+ const closeRef = useRef(null)
+ const openerRef = useRef(null)
+
+ useEffect(() => { if (open) refetch() }, [open, refreshToken, refetch])
+
+ const isRunning = useCallback(model => (
+ !model.disabled && (running.ids.has(model.id) || (Array.isArray(model.loaded_on) && model.loaded_on.length > 0))
+ ), [running.ids])
+
+ const visible = useMemo(
+ () => models.filter(m => !hiddenIds.has(m.id)),
+ [models, hiddenIds],
+ )
+ const ids = useMemo(() => visible.map(m => m.id), [visible])
+ const galleryIds = useMemo(
+ () => new Set(ids.filter(id => enrichModel(id))),
+ [ids, enrichModel],
+ )
+ // The gallery's estimate is only asked for a model the on-disk report has
+ // nothing on, and only after that report has answered.
+ const estimable = storage.status === 'loading'
+ ? []
+ : ids.filter(id => galleryIds.has(id) && !diskEntry(storage.index, id))
+ const sizes = useModelSizes(estimable, open)
+ const { duplicates, loading: dupLoading } = useBuildDuplicates(
+ ids,
+ id => !!enrichModel(id)?.has_variants,
+ open && galleryLoaded,
+ )
+
+ const plan = useMemo(() => buildCleanupPlan({
+ models: visible.map(m => ({
+ id: m.id,
+ backend: m.backend,
+ disabled: !!m.disabled,
+ pinned: !!m.pinned,
+ source: m.source,
+ running: isRunning(m),
+ })),
+ sizes,
+ storage: storage.index,
+ references: facts.references,
+ duplicates,
+ galleryIds,
+ verified: facts.verified,
+ }), [visible, sizes, storage.index, facts.references, facts.verified, duplicates, galleryIds, isRunning])
+
+ const ready = !modelsLoading && running.ready && facts.loaded && galleryLoaded && storage.status !== 'loading'
+ const byId = useMemo(() => {
+ const map = new Map()
+ for (const tier of TIERS) for (const item of plan.groups[tier]) map.set(item.id, { ...item, tier })
+ return map
+ }, [plan])
+
+ // A selection never outlives its row: a model that became protected, or left
+ // the list, drops out of it.
+ useEffect(() => {
+ setSelected(prev => {
+ const next = new Set([...prev].filter(id => byId.has(id)))
+ return next.size === prev.size ? prev : next
+ })
+ }, [byId])
+
+ // Focus goes to Close on open and back to the opener when the sheet goes.
+ useEffect(() => {
+ openerRef.current = document.activeElement
+ closeRef.current?.focus()
+ return () => {
+ const opener = openerRef.current
+ if (opener && typeof opener.focus === 'function') opener.focus()
+ }
+ }, [])
+
+ // Escape closes the sheet, or the confirm above it, and Tab stays inside the
+ // sheet.
+ useEffect(() => {
+ const onKey = (e) => {
+ if (e.key === 'Escape' && !confirm) {
+ e.preventDefault()
+ onClose()
+ return
+ }
+ if (e.key !== 'Tab' || confirm || !sheetRef.current) return
+ const focusable = sheetRef.current.querySelectorAll('button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])')
+ if (focusable.length === 0) return
+ const first = focusable[0]
+ const last = focusable[focusable.length - 1]
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [confirm, onClose])
+
+ const toggle = (id) => setSelected(prev => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+ const toggleTier = (tier) => setSelected(prev => {
+ const items = plan.groups[tier]
+ const all = items.every(item => prev.has(item.id))
+ const next = new Set(prev)
+ for (const item of items) {
+ if (all) next.delete(item.id)
+ else next.add(item.id)
+ }
+ return next
+ })
+
+ const chosen = [...selected].map(id => byId.get(id)).filter(Boolean)
+ const frees = totalSize(chosen, storage.index)
+ const afterFree = disk ? disk.free + frees.bytes : null
+ const reclaimable = totalSize(TIERS.flatMap(tier => plan.groups[tier]), storage.index)
+ // Models whose config names files that are not on disk, safe to remove or not.
+ const brokenModels = [...TIERS.flatMap(tier => plan.groups[tier]), ...plan.protected]
+ .filter(item => item.missing.length > 0)
+ .map(item => item.id)
+ const modelCount = TIERS.reduce((n, tier) => n + plan.groups[tier].length, 0)
+
+ // The dry run: look again before asking. What was safe a minute ago may have
+ // been loaded or named by an agent since.
+ const review = async () => {
+ setChecking(true)
+ const [next] = await Promise.all([facts.refresh(), running.read()])
+ setChecking(false)
+ // The picked items are kept as they were chosen: what the look finds in use
+ // is reported against them, not silently dropped from the selection.
+ setConfirm({ recheck: next, picked: chosen })
+ }
+
+ const confirmed = (() => {
+ if (!confirm) return null
+ // Re-plan against the fresh references. Anything now protected is dropped
+ // from the batch, and the dialog says so.
+ const fresh = confirm.recheck
+ const refs = fresh?.references || facts.references
+ const stillOk = []
+ const dropped = []
+ for (const item of confirm.picked) {
+ const model = visible.find(m => m.id === item.id)
+ if ((refs.get(item.id) || []).length > 0 || (model && isRunning(model))) dropped.push(item)
+ else stillOk.push(item)
+ }
+ return { stillOk, dropped }
+ })()
+
+ const nothingLeft = !!confirmed && confirmed.stillOk.length === 0
+ const confirmedFrees = confirmed ? totalSize(confirmed.stillOk, storage.index) : { bytes: 0, unknown: 0 }
+ const confirmBody = confirmed && (nothingLeft ? (
+
+
+ {t('cleanup.confirm.dropped', { names: confirmed.dropped.map(i => i.id).join(', ') })}
+
+
+ ) : (
+
+
+ {afterFree != null
+ ? t('cleanup.confirm.effect', { freed: gbLabel(confirmedFrees.bytes), after: gbLabel(disk.free + confirmedFrees.bytes), now: gbLabel(disk.free) })
+ : t('cleanup.confirm.effectNoDisk', { freed: gbLabel(confirmedFrees.bytes) })}
+ {confirmedFrees.unknown > 0 && ` ${t('cleanup.confirm.unknownSizes', { count: confirmedFrees.unknown })}`}
+
+
+ {t('cleanup.confirm.caption')}
+
+ {confirmed.stillOk.map(item => (
+
+ {item.id}
+ {reasonText(t, item.reason)}
+ {sizeText(t, item)}
+
+ ))}
+
+
+ {confirmed.dropped.length > 0 && (
+
+ {t('cleanup.confirm.dropped', { names: confirmed.dropped.map(i => i.id).join(', ') })}
+
+ )}
+
+ {t('cleanup.confirm.checked')}
+
+
{t('cleanup.confirm.hold', { seconds: Math.round(UNDO_MS / 1000) })}
+
+ ))
+
+ const doRemove = () => {
+ const batch = confirmed.stillOk
+ setConfirm(null)
+ if (batch.length === 0) return
+ onRemove(batch)
+ setSelected(new Set())
+ }
+
+ const pct = (bytes) => (disk && disk.total > 0 ? bytes / disk.total : 0)
+ const freedFraction = disk ? Math.min(pct(frees.bytes), pct(disk.models)) : 0
+
+ return (
+ <>
+
+
e.stopPropagation()}
+ >
+
+
+
+
{t('cleanup.title')}
+ {disk && (
+
+ {t('cleanup.desc', { free: gbLabel(disk.free), total: gbLabel(disk.total), models: gbLabel(disk.models) })}
+
+ )}
+
+
+
+
+
+
+
+ {disk && (
+
+
+
+
+ {freedFraction > 0 && }
+
+
+ {t('cleanup.legend.other')} {gbLabel(disk.other)}
+ {t('cleanup.legend.models')} {gbLabel(disk.models)}
+ {freedFraction > 0 && {t('cleanup.legend.frees')} {gbLabel(frees.bytes)} }
+ {t('cleanup.legend.free')} {gbLabel(disk.free)}
+
+
+ )}
+
+
+
+
+ {t('cleanup.noUsage.title')} {t('cleanup.noUsage.text')}
+
+
+ {ready && modelCount + plan.protected.length > 0 && (
+
+
+ {storage.status === 'ready' ? t('cleanup.sizes.disk') : t('cleanup.sizes.estimate')}
+
+ )}
+ {ready && brokenModels.length > 0 && (
+
+ {t('cleanup.missing.note', { models: brokenModels.join(', ') })}
+
+ )}
+ {!facts.verified && facts.loaded && (
+
+ {t('cleanup.unverified')}
+
+ )}
+ {modelsError && (
+
+ {t('lifecycle.errors.loadList', { message: modelsError })}
+
+ )}
+
+ {!ready && (
+
+ {[0, 1, 2, 3].map(i => )}
+
+ )}
+
+ {ready && modelCount === 0 && (
+
+
{t('cleanup.empty.title')}
+
{t('cleanup.empty.text')}
+
+ )}
+
+ {ready && TIERS.map(tier => {
+ const items = plan.groups[tier]
+ if (items.length === 0) return null
+ const sum = totalSize(items, storage.index)
+ const allOn = items.every(item => selected.has(item.id))
+ return (
+
+
+
{t(`cleanup.tier.${tier}.title`)}
+
+ {t('cleanup.count', { count: items.length })}
+ {sum.bytes > 0 && <> · {gbLabel(sum.bytes)}>}
+
+ toggleTier(tier)}>
+ {allOn ? t('cleanup.clearGroup') : t('cleanup.selectAll')}
+
+
+ {t(`cleanup.tier.${tier}.hint`)}
+
+
+ )
+ })}
+
+ {ready && plan.protected.length > 0 && (
+
+ setShowProtected(v => !v)}
+ >
+
+ {t('cleanup.protected.title')}
+
+ {t('cleanup.count', { count: plan.protected.length })} · {t('cleanup.protected.never')}
+
+
+ {showProtected && (
+
+ {plan.protected.map(item => (
+
+
+
+ {item.id}
+ {protectedText(t, item.reasons)}
+
+
+ {item.sizeSource === 'disk' || item.size ? sizeText(t, item) : ''}
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+
+ {chosen.length === 0 ? (
+ <>
+ {t('cleanup.effect.none')}
+
+ {ready && modelCount > 0
+ ? (reclaimable.bytes > 0
+ ? t('cleanup.effect.couldFree', { count: modelCount, amount: gbLabel(reclaimable.bytes) })
+ : t('cleanup.effect.couldFreeUnknown', { count: modelCount }))
+ : ''}
+
+ >
+ ) : (
+ <>
+ {t('cleanup.effect.selected', { count: chosen.length })}
+
+ {afterFree != null
+ ? t('cleanup.effect.frees', { freed: gbLabel(frees.bytes), after: gbLabel(afterFree), now: gbLabel(disk.free) })
+ : t('cleanup.effect.freesNoDisk', { freed: gbLabel(frees.bytes) })}
+ {frees.unknown > 0 && ` ${t('cleanup.effect.unknown', { count: frees.unknown })}`}
+
+ >
+ )}
+ {removalPending && {t('cleanup.effect.pending')} }
+
+ {chosen.length > 0 && (
+
setSelected(new Set())}>
+ {t('cleanup.clear')}
+
+ )}
+ {chosen.length === 0 && plan.groups.safe.length > 0 && (
+
setSelected(new Set(plan.groups.safe.map(item => item.id)))}
+ >
+ {t('cleanup.pickSafe', { count: plan.groups.safe.length })}
+
+ )}
+
+ {t('cleanup.remove', { count: chosen.length })}
+
+
+ {dupLoading &&
{t('cleanup.checkingBuilds')} }
+
+
+
+ setConfirm(null)}
+ />
+ >
+ )
+}
+
+function wStyle(fraction) {
+ return { '--dk-w': `${Math.max(0, Math.min(1, fraction)) * 100}%` }
+}
+
diff --git a/core/http/react-ui/src/components/models/DiskStrip.jsx b/core/http/react-ui/src/components/models/DiskStrip.jsx
new file mode 100644
index 000000000..7152a403f
--- /dev/null
+++ b/core/http/react-ui/src/components/models/DiskStrip.jsx
@@ -0,0 +1,36 @@
+import { useTranslation } from 'react-i18next'
+import { gbLabel, fitStyle } from '../../utils/modelLedger'
+import Icon from '../Icon'
+
+// The models disk, as a small strip in the page header. It shows what is free,
+// turns amber when the disk is low (under 10 percent or under 20 GB free), and
+// opens the cleanup review. The caller hides it when the server reports no disk.
+export default function DiskStrip({ disk, onOpen, open }) {
+ const { t } = useTranslation('models')
+ if (!disk) return null
+ const otherFraction = disk.total > 0 ? disk.other / disk.total : 0
+ const modelsFraction = disk.total > 0 ? disk.models / disk.total : 0
+ return (
+
+
+
+ {t('disk.free', { amount: gbLabel(disk.free) })}
+ {disk.low && {t('disk.low')} }
+
+
+
+
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/models/ExploreTable.jsx b/core/http/react-ui/src/components/models/ExploreTable.jsx
new file mode 100644
index 000000000..dfde78069
--- /dev/null
+++ b/core/http/react-ui/src/components/models/ExploreTable.jsx
@@ -0,0 +1,223 @@
+import { useCallback, useEffect, useRef } from 'react'
+import Icon from '../Icon'
+import FitCell from './FitCell'
+import { rowKeyDown, useRestoreRowFocus } from './rowKeys'
+import { fitFor, fitStyle, gbLabel } from '../../utils/modelLedger'
+import { publishWalk } from '../../utils/modelWalk'
+import { ENTITY_GROUPS, groupForEntity } from '../../utils/entityGroups'
+
+const GROUP_ORDER = ['text', 'vision', 'audio', 'visual', 'other']
+
+function SortHead({ col, label, sort, order, onSort, className }) {
+ const active = sort === col
+ return (
+
+ onSort(col)}>
+ {label}
+
+
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function SkeletonRows({ count = 8 }) {
+ return (
+
+ {Array.from({ length: count }, (_, i) => (
+
+
+
+
+
+
+
+ ))}
+
+ )
+}
+
+// The Explore ledger: one dense table of gallery models, each row carrying its
+// own fit on this machine. Selection is a surface step and a check mark.
+// Arrow keys move it, Enter installs, Esc closes the inspector.
+export default function ExploreTable({
+ models, loading, grouped, collapsedGroups, onToggleGroup,
+ selectedName, onSelect, onOpen, onInstall, onRetry,
+ estimates, pendingEstimates, contextSize, contextLabel, budget, ramAvailable,
+ isInstalling, progressOf, failedOp,
+ sort, order, onSort, density, t,
+}) {
+ const bodyRef = useRef(null)
+ useRestoreRowFocus(selectedName, bodyRef)
+
+ const entries = models.map(model => ({ model, name: model.name || model.id }))
+ // The order a reader expects: what most people install first, then the rest.
+ // The shared group list is ordered for matching (specific before general),
+ // which is a different job.
+ const sections = grouped
+ ? [...ENTITY_GROUPS].sort((a, b) => GROUP_ORDER.indexOf(a.id) - GROUP_ORDER.indexOf(b.id))
+ .map(g => ({ id: g.id, label: t(g.labelKey), icon: g.icon, items: entries.filter(e => groupForEntity(e.model).id === g.id) }))
+ .filter(s => s.items.length > 0)
+ : [{ id: null, items: entries }]
+ const names = sections.flatMap(s => (s.id && collapsedGroups.has(s.id) ? [] : s.items.map(e => e.name)))
+
+ const move = useCallback((name) => {
+ onSelect(name, { replace: true })
+ const el = bodyRef.current?.querySelector(`[data-entity="${CSS.escape(name)}"]`)
+ el?.focus()
+ }, [onSelect])
+
+ // What "previous" and "next" on a model's page walk: the rows on screen, in
+ // the order they are shown, without the ones a collapsed group hides.
+ const walkOrder = names.join('\n')
+ useEffect(() => { publishWalk('explore', walkOrder ? walkOrder.split('\n') : []) }, [walkOrder])
+
+ // The row that takes Tab: the selected one, else the first.
+ const tabbable = names.includes(selectedName) ? selectedName : names[0]
+
+ const renderRow = ({ model, name }) => {
+ const est = estimates[name]
+ const vram = est?.estimates?.[String(contextSize)]?.vramBytes
+ const fit = fitFor(vram, budget, ramAvailable)
+ const selected = name === selectedName
+ const installing = isInstalling(name)
+ const progress = progressOf(name)
+ const failed = !installing && !model.installed ? failedOp(name) : null
+ const sub = [model.backend, model.license].filter(Boolean).join(' · ')
+
+ return (
+ onSelect(name)}
+ onDoubleClick={() => onOpen?.(name)}
+ onKeyDown={e => rowKeyDown(e, {
+ names,
+ current: name,
+ move,
+ enter: n => { if (!model.installed && !isInstalling(n)) onInstall(n) },
+ close: () => onSelect(null),
+ })}
+ >
+
+
+
+
+ {name}
+
+ {failed ? failed.error : (sub || ' ')}
+ {!failed && model.has_variants && {t('ledger.builds')} }
+
+
+
+ {est?.sizeBytes ? gbLabel(est.sizeBytes) : (pendingEstimates?.has(name) ? : '—')}
+
+
+
+
+
+
+ {installing ? (
+
+
+ {progress > 0 ? t('table.installingPct', { percent: Math.round(progress) }) : t('table.installing')}
+ {progress > 0 && (
+
+ )}
+
+ ) : model.installed ? (
+ {t('table.installed')}
+ ) : failed ? (
+ { e.stopPropagation(); onRetry(name, failed) }}
+ >
+ {t('ledger.retry')}
+
+ ) : (
+ { e.stopPropagation(); onInstall(name) }}
+ >
+ {t('actions.install')}
+
+ )}
+ { e.stopPropagation(); onOpen?.(name) }}
+ >
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+ {t('ledger.caption', { context: contextLabel })}
+
+
+ {t('ledger.selected')}
+
+ {t('ledger.columns.size')}
+ {t('ledger.columns.fit', { context: contextLabel })}
+
+
+
+ {loading && models.length === 0 ? (
+
+ ) : (
+
+ {sections.map(section => (
+ section.id ? [
+
+
+ onToggleGroup(section.id)}
+ >
+
+
+ {section.label}
+ {section.items.length}
+
+
+ ,
+ ...(collapsedGroups.has(section.id) ? [] : section.items.map(renderRow)),
+ ] : section.items.map(renderRow)
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/models/FacetBar.jsx b/core/http/react-ui/src/components/models/FacetBar.jsx
new file mode 100644
index 000000000..ae6a2a2da
--- /dev/null
+++ b/core/http/react-ui/src/components/models/FacetBar.jsx
@@ -0,0 +1,40 @@
+import Icon from '../Icon'
+
+// The capability facets as filter chips with counts. A chip is a button with
+// aria-pressed, which the kit draws as a check on the accent wash. A count is
+// the number of gallery entries the server reports for that facet under the
+// current search and backend; it is left out until the server has answered,
+// rather than shown as a zero.
+export default function FacetBar({ facets, active, counts, stale, isAvailable, onToggle, t, ariaLabel }) {
+ // Until the server has answered once, the only honest chips are the one that
+ // resets and the ones in use. The rest appear with their counts.
+ const answered = counts && Object.keys(counts).length > 0
+ return (
+
+ {facets.map(f => {
+ const isAll = f.key === ''
+ const pressed = isAll ? active.length === 0 : active.includes(f.key)
+ const count = counts?.[f.key]
+ // A facet nothing matches is clutter, unless it is the one in use.
+ if (!isAll && !pressed && (count === 0 || !answered)) return null
+ const available = isAvailable(f.key)
+ return (
+ onToggle(f.key)}
+ >
+ {f.icon && }
+ {t(f.labelKey)}
+ {typeof count === 'number' && {count} }
+
+ )
+ })}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/models/FitCell.jsx b/core/http/react-ui/src/components/models/FitCell.jsx
new file mode 100644
index 000000000..83c460bc7
--- /dev/null
+++ b/core/http/react-ui/src/components/models/FitCell.jsx
@@ -0,0 +1,39 @@
+import { fitStyle, gbLabel, gbNumber } from '../../utils/modelLedger'
+import Icon from '../Icon'
+
+// What a row says about this machine: a solid bar, the memory the model needs
+// at the chosen context, and the headroom in words ("3.7 free", "+1.5 on CPU",
+// "0.9 over"). The words are the answer; the bar and its colour repeat it.
+//
+// fit is the result of fitFor(), or null while there is no estimate.
+export default function FitCell({ fit, pending, t, contextLabel }) {
+ if (!fit) {
+ return (
+
+ {pending ? t('rail.sizing') : — }
+
+ )
+ }
+ const amount = gbNumber(fit.amount)
+ const words = fit.state === 'fits'
+ ? t('ledger.fit.free', { amount })
+ : fit.state === 'spill'
+ ? t('ledger.fit.spill', { amount })
+ : t('ledger.fit.over', { amount })
+ const explain = t(`ledger.fit.${fit.state}Title`, { amount: gbLabel(fit.amount), context: contextLabel, need: gbLabel(fit.need) })
+ return (
+
+
+
+
+
+ {gbLabel(fit.need)}
+
+
+ {fit.state !== 'fits' && }
+ {words}
+ {explain}
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/models/MemoryBar.css b/core/http/react-ui/src/components/models/MemoryBar.css
new file mode 100644
index 000000000..166d7bbab
--- /dev/null
+++ b/core/http/react-ui/src/components/models/MemoryBar.css
@@ -0,0 +1,21 @@
+/* One memory bar: solid segments with a surface gap, a tick at the pool's
+ capacity. Over capacity the bar grows past the tick and the tick turns red.
+ Built on the kit's meter; only what the kit does not say is here. */
+
+.memorybar { display: grid; gap: var(--dk-space-2); min-width: 0; }
+
+.memorybar__meter { height: 14px; overflow: visible; }
+
+.memorybar__seg { min-width: 0; }
+.memorybar__seg--other { background: var(--dk-control-edge); opacity: .55; }
+.memorybar__seg--model { background: var(--dk-accent); }
+.memorybar__seg--kv { background: color-mix(in srgb, var(--dk-accent) 55%, var(--dk-card)); }
+.memorybar__seg--cpu { background: var(--dk-warn); }
+
+.memorybar__swatch--other { background: var(--dk-control-edge); opacity: .55; }
+.memorybar__swatch--model { background: var(--dk-accent); }
+.memorybar__swatch--kv { background: color-mix(in srgb, var(--dk-accent) 55%, var(--dk-card)); }
+.memorybar__swatch--cpu { background: var(--dk-warn); }
+
+.memorybar[data-over="true"] .memorybar__limit { background: var(--dk-error); }
+.memorybar__legend { margin-top: 0; }
diff --git a/core/http/react-ui/src/components/models/MemoryBar.jsx b/core/http/react-ui/src/components/models/MemoryBar.jsx
new file mode 100644
index 000000000..7b3e3d46d
--- /dev/null
+++ b/core/http/react-ui/src/components/models/MemoryBar.jsx
@@ -0,0 +1,45 @@
+import { cssVars, gbLabel } from '../../utils/modelLedger'
+import './MemoryBar.css'
+
+// One bar for one pool of memory (a GPU, or system memory).
+//
+// capacity what the pool holds, in bytes. The tick sits here.
+// segments [{ key, label, bytes, tone }] in the order they are drawn. tone
+// is 'other' (what else uses the pool), 'model' (the model's own
+// share), 'kv' (the part that grows with context) or 'cpu'.
+// free bytes left after the segments, or negative when over.
+//
+// The bar is as wide as the pool until the segments outgrow it. Then it grows
+// past the tick, and the tick turns red: over is shown as over, never clipped
+// to look like it fits. Solid segments, a surface gap between them, no glow.
+export default function MemoryBar({ capacity, segments, ariaLabel, testId, legend = true }) {
+ const sum = segments.reduce((total, seg) => total + Math.max(0, seg.bytes), 0)
+ const scale = Math.max(capacity, sum) || 1
+ const over = sum > capacity
+ const share = bytes => `${Math.max(0, (bytes / scale) * 100).toFixed(2)}%`
+ return (
+
+
+ {segments.filter(seg => seg.bytes > 0).map(seg => (
+
+ ))}
+
+
+ {legend && (
+
+ {segments.filter(seg => seg.bytes > 0).map(seg => (
+
+
+ {seg.label} {gbLabel(seg.bytes)}
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/models/page/ConfigTab.jsx b/core/http/react-ui/src/components/models/page/ConfigTab.jsx
new file mode 100644
index 000000000..ec18e4176
--- /dev/null
+++ b/core/http/react-ui/src/components/models/page/ConfigTab.jsx
@@ -0,0 +1,99 @@
+import { useMemo } from 'react'
+// eslint-disable-next-line no-unused-vars
+import { Link } from 'react-router-dom'
+import { fromState } from '../../../utils/editorNav'
+import { PLACEMENT_KEYS, usePlacementConfig } from '../../../hooks/usePlacementConfig'
+import Icon from '../../Icon'
+// eslint-disable-next-line no-unused-vars
+import PlacementSection from '../placement/PlacementSection'
+
+// The configuration file as it would read after saving, with the lines the
+// Placement section writes set apart. Plain text in a scrolling box: it is a
+// preview, not an editor.
+// eslint-disable-next-line no-unused-vars
+function GeneratedFile({ text, t }) {
+ const lines = useMemo(() => text.replace(/\n$/, '').split('\n'), [text])
+ return (
+
+
{t('page.config.generated')}
+
+ {lines.map((line, i) => {
+ const key = /^([A-Za-z_][\w-]*):/.exec(line)?.[1]
+ const written = key && PLACEMENT_KEYS.includes(key)
+ return (
+
+ {line}{'\n'}
+
+ )
+ })}
+
+
+ )
+}
+
+// Configuration: the Placement section on its own, with a save bar, and a way
+// into the full editor for everything else. It edits the same file the editor
+// does, through the same patch endpoint.
+export default function ConfigTab({ view }) {
+ const { t, id, running, location } = view
+ const config = usePlacementConfig(id, {
+ onSaved: () => view.toast?.(t('page.config.saved'), 'success'),
+ onError: err => view.toast?.(t('page.config.saveFailed', { message: err.message }), 'error'),
+ })
+
+ if (config.status === 'loading') {
+ return (
+
+
+
+ )
+ }
+ if (config.status === 'error') {
+ return (
+
+
+
+ {t('page.config.loadFailed', { message: config.error })}
+
+ {t('ledger.retry')}
+
+
+
+ )
+ }
+
+ return (
+
+
+ {t('page.config.fullEditor')}
+
+ )}
+ />
+
+
+
+
+ {config.saving ? t('page.config.saving') : config.dirty ? t('page.config.save') : t('page.config.savedLabel')}
+
+ {running && {t('page.config.appliesNext')} }
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/models/page/FitTab.jsx b/core/http/react-ui/src/components/models/page/FitTab.jsx
new file mode 100644
index 000000000..c53752fe8
--- /dev/null
+++ b/core/http/react-ui/src/components/models/page/FitTab.jsx
@@ -0,0 +1,130 @@
+import { PAGE_CONTEXTS } from '../../../hooks/useModelPage'
+import { FIT_LIMIT, gbLabel } from '../../../utils/modelLedger'
+import { contextLabel } from '../../../utils/placement'
+import Icon from '../../Icon'
+// eslint-disable-next-line no-unused-vars
+import MemoryBar from '../MemoryBar'
+// eslint-disable-next-line no-unused-vars
+import VramChart from './VramChart'
+import { fitWords, memorySegments } from './fitFacts'
+
+// Fit and memory: will this build run here, at which context length, and how
+// the memory is spent. Every figure is the server's estimate for the build and
+// the context on screen, measured against the memory the Models list uses.
+export default function FitTab({ view }) {
+ const { t, estimateState, fit, budget, contextSize, setContextSize, variants, build, autoBuild, pickBuild, id, installed } = view
+ const words = fitWords(view)
+ const points = PAGE_CONTEXTS
+ .map(ctx => ({ ctx, bytes: estimateState.data?.estimates?.[String(ctx)]?.vramBytes || 0 }))
+ .filter(point => point.bytes > 0)
+ const grows = points.length > 1 && Math.max(...points.map(p => p.bytes)) > Math.min(...points.map(p => p.bytes)) * 1.01
+ const activeBuild = build || autoBuild || id
+ const pool = budget.hasGpu ? t('page.fit.poolGpu', { memory: gbLabel(fit?.total || 0) }) : t('page.fit.poolRam', { memory: gbLabel(fit?.total || 0) })
+ const where = budget.scope === 'cluster' && budget.nodeName ? t('ledger.summary.onNode', { node: budget.nodeName }) : ''
+
+ return (
+
+
+
{t('page.tabs.fit')}{where}
+ {variants.length > 1 && (
+
+ {variants.map(v => (
+ pickBuild(v.model)}
+ >
+ {v.quantization || v.model}
+
+ ))}
+
+ )}
+
+
+ {estimateState.status === 'loading' && (
+
+
+
+
+ )}
+
+ {estimateState.status === 'unavailable' && (
+
+
+
{t('page.fit.unavailableTitle')}
+
{installed ? t('page.fit.unavailableInstalled') : t('page.fit.unavailableText')}
+
+ )}
+
+ {estimateState.status === 'ready' && (
+ <>
+ {words ? (
+
+
+ {words.title}. {words.text}
+
+ ) : (
+
{t('page.fit.noMachine')}
+ )}
+
+
+
{t('page.fit.context')}
+
+ {PAGE_CONTEXTS.map(ctx => (
+ setContextSize(ctx)}
+ >
+ {contextLabel(ctx)}
+
+ ))}
+
+
+
+ {fit && (
+
+
+ {pool}
+
+ {fit.limit - fit.need < 0
+ ? t('placement.bar.over', { amount: gbLabel(fit.need - fit.limit) })
+ : t('placement.bar.freeAfter', { amount: gbLabel(fit.limit - fit.need) })}
+
+
+
+
{t('page.fit.limitNote', { percent: Math.round(FIT_LIMIT * 100) })}
+
+ )}
+
+ {fit && grows && (
+
+ )}
+ {fit && !grows && points.length > 1 && (
+
{t('page.fit.flat')}
+ )}
+
{t('page.fit.footnote')}
+ >
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/models/page/LogsTab.jsx b/core/http/react-ui/src/components/models/page/LogsTab.jsx
new file mode 100644
index 000000000..6e8b7542e
--- /dev/null
+++ b/core/http/react-ui/src/components/models/page/LogsTab.jsx
@@ -0,0 +1,17 @@
+// eslint-disable-next-line no-unused-vars
+import { BackendLogsPanel } from '../../../pages/BackendLogs'
+
+// The backend's own output for this model, in the viewer the Operate section
+// uses, without its page around it.
+export default function LogsTab({ view }) {
+ const { t, id } = view
+ return (
+
+
+
{t('page.tabs.logs')}
+ {t('page.logs.note')}
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/models/page/OverviewTab.jsx b/core/http/react-ui/src/components/models/page/OverviewTab.jsx
new file mode 100644
index 000000000..df2b43cea
--- /dev/null
+++ b/core/http/react-ui/src/components/models/page/OverviewTab.jsx
@@ -0,0 +1,252 @@
+// eslint-disable-next-line no-unused-vars
+import { Link } from 'react-router-dom'
+import { useCleanupFacts } from '../../../hooks/useCleanupFacts'
+import { renderMarkdown, stripMarkdown } from '../../../utils/markdown'
+import { safeHref } from '../../../utils/url'
+import { formatBytes } from '../../../utils/format'
+import { gbLabel } from '../../../utils/modelLedger'
+import { diskEntry } from '../../../utils/modelStorage'
+import { contextLabel } from '../../../utils/placement'
+import Icon from '../../Icon'
+// eslint-disable-next-line no-unused-vars
+import MemoryBar from '../MemoryBar'
+import { fitWords, memorySegments } from './fitFacts'
+
+// The answer to "should I use this": three plain statements in one strip. Fit on
+// this machine, what the model does, and what happens next (install it, or its
+// state when it is already here).
+// eslint-disable-next-line no-unused-vars
+function AnswerStrip({ view }) {
+ const { t, entry, installed, running, profile, useCases, installing, progress, failedOp, estimateState, leaves, sizeBytes } = view
+ const words = fitWords(view)
+ const tags = (entry?.tags || []).filter(tag => !['gguf'].includes(String(tag).toLowerCase())).slice(0, 4)
+ return (
+
+
+ {t('page.answer.fit')}
+ {words ? (
+ <>
+
+ {words.title}
+
+ {words.text}
+ >
+ ) : (
+
+ {estimateState.status === 'loading' ? t('page.answer.fitLoading') : t('page.answer.fitNone')}
+
+ )}
+
+
+
{t('page.answer.does')}
+
+ {(installed && useCases.length > 0 ? useCases.map(u => t(`lifecycle.open.${u.labelKey}`)) : tags).map(label => (
+ {label}
+ ))}
+
+ {installed && useCases.some(u => u.route) && (
+
+ {t('page.answer.opensIn', { useCases: useCases.filter(u => u.route).map(u => t(`lifecycle.open.${u.labelKey}`)).join(', ') })}
+
+ )}
+ {!installed && tags.length > 0 &&
{t('page.answer.opensAfter')} }
+
+
+ {installed ? t('page.answer.state') : t('page.answer.install')}
+ {installing ? (
+ <>
+ {progress > 0 ? t('page.answer.installingPct', { percent: progress }) : t('page.answer.installing')}
+ {t('page.answer.installingNote')}
+ >
+ ) : failedOp ? (
+ <>
+ {t('page.answer.failed')}
+ {failedOp.error}
+ >
+ ) : installed ? (
+ <>
+
+ {profile.disabled ? t('lifecycle.states.disabled') : running ? t('lifecycle.states.running') : t('lifecycle.states.idle')}
+
+ {running ? t('page.answer.runningNote') : profile.disabled ? t('page.answer.disabledNote') : t('page.answer.idleNote')}
+ >
+ ) : (
+ <>
+ {sizeBytes ? gbLabel(sizeBytes) : t('page.answer.notInstalled')}
+
+ {leaves !== null && leaves !== undefined
+ ? (leaves < 0 ? t('disk.notEnough', { amount: gbLabel(-leaves), size: gbLabel(sizeBytes) }) : t('page.answer.leaves', { amount: gbLabel(leaves) }))
+ : t('page.answer.notInstalledNote')}
+
+ >
+ )}
+
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function GlanceBar({ view }) {
+ const { t, fit, budget, estimateState, contextSize } = view
+ if (!fit) return null
+ const segments = memorySegments(estimateState.data, contextSize, fit.need, t)
+ const pool = budget.hasGpu ? t('page.fit.poolGpu', { memory: gbLabel(fit.total) }) : t('page.fit.poolRam', { memory: gbLabel(fit.total) })
+ const left = fit.limit - fit.need
+ return (
+
+
+
{t('page.overview.glance')}
+ view.goTab('fit')}>
+ {t('page.tabs.fit')}
+
+
+
+ {pool}
+
+ {left < 0 ? t('placement.bar.over', { amount: gbLabel(-left) }) : t('placement.bar.freeAfter', { amount: gbLabel(left) })}
+
+
+
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function UsedBy({ view }) {
+ const { t, id } = view
+ const facts = useCleanupFacts(true)
+ const refs = facts.references.get(id) || []
+ return (
+
+ {t('page.usedBy.title')}
+ {!facts.loaded ? (
+
+ ) : refs.length === 0 ? (
+ {facts.verified ? t('page.usedBy.none') : t('page.usedBy.unknown')}
+ ) : (
+
+ {refs.map(ref => (
+
+
+ {ref.kind === 'agent'
+ ? {ref.name}
+ : ref.kind === 'chain'
+ ? {ref.name}
+ : ref.name}
+
+ {t(`page.usedBy.kind.${ref.kind}`)}
+
+ ))}
+
+ )}
+ {facts.loaded && !facts.verified && refs.length > 0 && {t('page.usedBy.partial')}
}
+
+ )
+}
+
+export default function OverviewTab({ view }) {
+ const { t, entry, profile, installed, running, backend, license, estimateState, useCases, lede, storage } = view
+ const onDisk = diskEntry(storage?.index, view.id)
+ // A one-line description is already the line under the title; saying it again
+ // as a paragraph adds nothing. Longer prose, and anything with markup, stays.
+ const description = entry?.description && !(lede && !/[\n#*_`\[]/.test(entry.description) && stripMarkdown(entry.description).trim() === lede)
+ ? entry.description
+ : null
+ const maxContext = estimateState.data?.modelMaxContext
+ const tags = Array.isArray(entry?.tags) ? entry.tags : []
+ const urls = Array.isArray(entry?.urls) ? entry.urls : []
+ return (
+
+
+
+
+ {t('page.overview.about')}
+ {description ? (
+
+ ) : !lede && (
+ {t('lifecycle.detail.noDescription')}
+ )}
+
+ {backend && (<>{t('detail.backend')} {backend} >)}
+ {entry?.gallery && (<>{t('detail.gallery')} {typeof entry.gallery === 'string' ? entry.gallery : entry.gallery.name} >)}
+ {license && (<>{t('detail.license')} {license} >)}
+ {maxContext > 0 && (<>{t('page.overview.maxContext')} {t('page.overview.tokens', { size: contextLabel(maxContext) })} >)}
+ {tags.length > 0 && (
+ <>
+ {t('detail.tags')}
+ {tags.map(tag => {tag} )}
+ >
+ )}
+ {urls.length > 0 && (
+ <>
+ {t('detail.links')}
+
+
+
+ >
+ )}
+ {entry?.trustRemoteCode && (<>{t('detail.warning')} {t('detail.requiresTrustRemoteCode')} >)}
+
+
+
+
+
+ {installed && (
+
+ {t('page.overview.status')}
+
+ {t('lifecycle.detail.state')}
+ {profile.disabled ? t('lifecycle.states.disabled') : running ? t('lifecycle.states.running') : t('lifecycle.states.idle')}
+ {t('lifecycle.detail.backend')}
+ {profile.backend || t('lifecycle.detail.auto')}
+ {onDisk && (
+ <>
+ {t('lifecycle.detail.size')}
+
+ {formatBytes(onDisk.size)}
+ {onDisk.shared > 0 && {'\u00b7'} {t('lifecycle.detail.sharedBytes', { size: formatBytes(onDisk.shared) })} }
+ {'\u00b7'}
+ view.goTab('usage')}>{t('page.files.view')}
+
+ >
+ )}
+ {onDisk?.missing.length > 0 && (
+ <>
+ {t('lifecycle.detail.missingFiles')}
+ {t('lifecycle.detail.missingCount', { count: onDisk.missing.length })}
+ >
+ )}
+ {profile.pinned && (<>{t('lifecycle.detail.pinned')} {t('lifecycle.detail.yes')} >)}
+ {profile.source && (<>{t('lifecycle.detail.source')} {profile.source} >)}
+ {useCases.length > 0 && (
+ <>
+ {t('lifecycle.open.title')}
+
+
+ {useCases.map(u => u.route
+ ? {t(`lifecycle.open.${u.labelKey}`)}
+ : {t(`lifecycle.open.${u.labelKey}`)} )}
+
+
+ >
+ )}
+
+
+ )}
+ {installed && }
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/models/page/UsageTab.jsx b/core/http/react-ui/src/components/models/page/UsageTab.jsx
new file mode 100644
index 000000000..44012d3cf
--- /dev/null
+++ b/core/http/react-ui/src/components/models/page/UsageTab.jsx
@@ -0,0 +1,154 @@
+// eslint-disable-next-line no-unused-vars
+import { Link } from 'react-router-dom'
+import { useCleanupFacts } from '../../../hooks/useCleanupFacts'
+import { formatBytes } from '../../../utils/format'
+import { modelPath } from '../../../utils/modelWalk'
+import { diskEntry, filesOf } from '../../../utils/modelStorage'
+// eslint-disable-next-line no-unused-vars
+import NodeDistributionChip from '../../NodeDistributionChip'
+import Icon from '../../Icon'
+
+const MISSING = ['requests', 'ttft', 'loads', 'changes']
+
+// The files this model uses on disk, with their size and who else uses them.
+// A file another installed model uses stays on disk when this one is removed,
+// and a name the config gives that is not on disk is flagged. Shown to admins,
+// who can read the report; for anyone else the section says why it is absent.
+// eslint-disable-next-line no-unused-vars
+function FilesOnDisk({ view }) {
+ const { t, id, storage } = view
+ const entry = diskEntry(storage.index, id)
+ const rows = filesOf(storage.index, id)
+ return (
+
+
+
{t('page.files.title')}
+ {entry && {t('page.files.total', { size: formatBytes(entry.size), count: entry.files.length })} }
+
+ {storage.status === 'loading' ? (
+
+ ) : !entry ? (
+
+ {storage.status === 'ready' ? t('storage.empty') : t('page.files.unavailable')}
+
+ ) : (
+ <>
+ {entry.missing.length > 0 && (
+
+
+ {t('page.files.missing')}
+
+ )}
+
+
+ {t('page.files.caption', { model: id })}
+
+
+ {t('storage.columns.file')}
+ {t('storage.columns.size')}
+ {t('storage.columns.status')}
+
+
+
+ {rows.map(f => (
+
+ {f.path}
+ {f.missing ? '\u2014' : formatBytes(f.size)}
+
+ {f.missing && {t('storage.status.missing')} }
+ {!f.missing && f.others.length > 0 && (
+
+ {t('page.files.sharedWith')}{' '}
+ {f.others.map((other, i) => (
+ {i > 0 && ', '} {other}
+ ))}
+
+ )}
+
+
+ ))}
+
+
+
+ {entry.shared > 0 && {t('page.files.sharedNote')}
}
+ >
+ )}
+
+ )
+}
+
+// Usage and history. The API keeps no per-model request count, no time to first
+// token and no record of loads or configuration changes, so there is nothing to
+// chart and the tab says so, naming what will appear and what is missing. What
+// the server does know about the model today (its state, who uses it) is listed
+// as facts, so the tab is not empty of the truth.
+export default function UsageTab({ view }) {
+ const { t, id, profile, running, storage } = view
+ const disk = diskEntry(storage?.index, id)
+ const facts = useCleanupFacts(true)
+ const refs = facts.references.get(id) || []
+ const kinds = ['agent', 'task', 'chain', 'alias']
+ .map(kind => ({ kind, count: refs.filter(ref => ref.kind === kind).length }))
+ .filter(item => item.count > 0)
+ const nodes = Array.isArray(profile?.loaded_on) ? profile.loaded_on : []
+ return (
+
+
+
+
{t('page.usage.title')}
+
+
+
+
{t('page.usage.emptyTitle')}
+
{t('page.usage.emptyText')}
+
+
+ {MISSING.map(key => (
+
+
+ {t(`page.usage.missing.${key}.name`)}
+ {t(`page.usage.missing.${key}.why`)}
+
+ {t('page.usage.notRecorded')}
+
+ ))}
+
+
+
+
+
+
{t('page.usage.known')}
+
+
+ {t('lifecycle.detail.state')}
+ {profile.disabled ? t('lifecycle.states.disabled') : running ? t('lifecycle.states.running') : t('lifecycle.states.idle')}
+ {nodes.length > 0 && (
+ <>
+ {t('lifecycle.detail.distributed')}
+
+ >
+ )}
+ {disk && (
+ <>
+ {t('lifecycle.detail.size')}
+
+ {formatBytes(disk.size)}
+ {disk.shared > 0 && {'\u00b7'} {t('lifecycle.detail.sharedBytes', { size: formatBytes(disk.shared) })} }
+
+ >
+ )}
+ {t('page.usedBy.title')}
+
+ {!facts.loaded
+ ? t('page.usedBy.loading')
+ : kinds.length === 0
+ ? (facts.verified ? t('page.usedBy.none') : t('page.usedBy.unknown'))
+ : kinds.map(item => t(`page.usage.count.${item.kind}`, { count: item.count })).join(', ')}
+
+
+
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/models/page/VariantsTab.jsx b/core/http/react-ui/src/components/models/page/VariantsTab.jsx
new file mode 100644
index 000000000..c95e8e2cc
--- /dev/null
+++ b/core/http/react-ui/src/components/models/page/VariantsTab.jsx
@@ -0,0 +1,200 @@
+import { useEffect, useState } from 'react'
+import { modelsApi } from '../../../utils/api'
+import { useGalleryEntry } from '../../../hooks/useModelPage'
+import { fitFor, gbNumber } from '../../../utils/modelLedger'
+import { contextLabel } from '../../../utils/placement'
+import { formatBytes } from '../../../utils/format'
+import Icon from '../../Icon'
+
+function variantFeatureLabel(feature, t) {
+ return t(`variants.features.${feature}`, { defaultValue: feature.toUpperCase() })
+}
+
+// Estimates for every build at the context on screen, so each row can say how
+// it fits without opening it. One request per build, started together: an
+// entry offers a handful.
+function useBuildFits(variants, contextSize) {
+ const [bytes, setBytes] = useState({})
+ const key = variants.map(v => v.model).join('\n')
+ useEffect(() => {
+ let cancelled = false
+ variants.forEach(v => {
+ modelsApi.estimate(v.model, [contextSize])
+ .then(data => {
+ const value = data?.estimates?.[String(contextSize)]?.vramBytes
+ if (!cancelled && value > 0) setBytes(prev => ({ ...prev, [`${v.model}|${contextSize}`]: value }))
+ })
+ .catch(() => { /* a build with no estimate falls back to the server's own fits flag */ })
+ })
+ return () => { cancelled = true }
+ // The list is read by its names; a new array with the same names is the same list.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [key, contextSize])
+ return bytes
+}
+
+// Variants and files: the builds of one model with their size and fit, one of
+// them chosen for the Install button, and the files that build downloads.
+export default function VariantsTab({ view }) {
+ const {
+ t, id, entry, variants, variantsState, build, autoBuild, pickBuild, install, installing, installedIds,
+ budget, ramAvailable, contextSize, progress,
+ } = view
+ const activeBuild = build || autoBuild || id
+ const bytesByBuild = useBuildFits(variants, contextSize)
+ const buildEntry = useGalleryEntry(activeBuild !== id ? activeBuild : null)
+ const filesEntry = activeBuild === id ? entry : buildEntry.entry
+ const files = filesEntry?.additionalFiles || filesEntry?.files || []
+ const activeVariant = variants.find(v => v.model === activeBuild)
+ const isInstalled = installedIds.has(activeBuild)
+
+ const fitOf = (variant) => {
+ const bytes = bytesByBuild[`${variant.model}|${contextSize}`]
+ const fit = fitFor(bytes, budget, ramAvailable)
+ if (fit) {
+ return {
+ state: fit.state,
+ text: fit.state === 'fits'
+ ? (budget.hasGpu ? t('page.variants.fitsGpu') : t('page.variants.fitsMemory'))
+ : fit.state === 'spill'
+ ? t('page.variants.spill', { amount: gbNumber(fit.amount) })
+ : t('page.variants.over', { amount: gbNumber(fit.amount) }),
+ }
+ }
+ if (variant.fits === true) return { state: 'fits', text: t('page.variants.fitsServer') }
+ if (variant.fits === false) return { state: 'over', text: t('variants.doesNotFit') }
+ return { state: 'none', text: t('ledger.fit.noEstimate') }
+ }
+
+ return (
+
+
+
+
{t('variants.title')}
+ {t('page.variants.fitAt', { context: contextLabel(contextSize) })}
+
+ {variantsState.status === 'loading' && (
+
+ )}
+ {variantsState.status === 'error' && (
+ {t('page.variants.error')}
+ )}
+ {variants.length > 0 ? (
+
+ ) : variantsState.status !== 'loading' && variantsState.status !== 'error' && (
+ {t('page.variants.single')}
+ )}
+
+
+
+
+
{t('detail.files')}
+
+ {[activeVariant?.quantization, t('detail.fileCount', { count: files.length })].filter(Boolean).join(', ')}
+
+
+ {buildEntry.status === 'loading' && activeBuild !== id ? (
+
+ ) : files.length === 0 ? (
+ {buildEntry.status === 'missing' || buildEntry.status === 'error' ? t('variants.detailsUnavailable', { variant: activeBuild }) : t('page.variants.noFiles')}
+ ) : (
+
+
+ {t('page.variants.filesCaption', { build: activeBuild })}
+
+
+ {t('detail.filename')}
+ {t('page.variants.source')}
+ {t('page.variants.status')}
+ {t('detail.sha256')}
+
+
+
+ {files.map((f, i) => (
+
+ {f.filename || '—'}
+ {f.uri || '—'}
+ {installing && activeBuild === (build || autoBuild || id) && !isInstalled ? t('page.variants.downloading', { percent: progress }) : isInstalled ? t('page.variants.onDisk') : t('page.variants.notDownloaded')}
+ {f.sha256 ? `${f.sha256.slice(0, 16)}…` : '—'}
+
+ ))}
+
+
+
+ )}
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/models/page/VramChart.css b/core/http/react-ui/src/components/models/page/VramChart.css
new file mode 100644
index 000000000..721c58913
--- /dev/null
+++ b/core/http/react-ui/src/components/models/page/VramChart.css
@@ -0,0 +1,24 @@
+/* Memory by context. Solid bars from zero; the kit's chart parts do the rest. */
+.vramchart { margin: 0; display: grid; gap: var(--dk-space-3); }
+.vramchart .dk-chart-plot { max-width: 760px; }
+.vramchart__bar { fill: color-mix(in srgb, var(--dk-accent) 68%, var(--dk-card)); }
+.vramchart__col { cursor: pointer; }
+.vramchart__hit { fill: transparent; }
+.vramchart__col[data-selected="true"] .vramchart__bar { fill: var(--dk-accent); }
+.vramchart__col[data-over="true"] .vramchart__bar { fill: color-mix(in srgb, var(--dk-warn) 70%, var(--dk-card)); }
+.vramchart__col[data-over="true"][data-selected="true"] .vramchart__bar { fill: var(--dk-warn); }
+.vramchart__col:hover .vramchart__bar { opacity: .85; }
+.vramchart__value { text-anchor: middle; fill: var(--dk-muted); }
+.vramchart__col[data-selected="true"] .vramchart__value { fill: var(--dk-text); font-weight: var(--dk-weight-semibold); }
+.vramchart__tick { text-anchor: middle; }
+.vramchart__col[data-selected="true"] .vramchart__tick { fill: var(--dk-text); font-weight: var(--dk-weight-semibold); }
+.vramchart__limit-label { text-anchor: start; }
+
+/* Labels sit over the bars; a click on them belongs to the bar below. */
+.vramchart text, .vramchart .dk-chart-threshold { pointer-events: none; }
+
+/* The chart is drawn in a 640 unit box that shrinks with the screen. On a phone
+ the same type would be too small to read, so it is drawn larger. */
+@media (max-width: 640px) {
+ .vramchart .dk-chart-value, .vramchart .vramchart__tick, .vramchart .dk-chart-threshold-label { font-size: 17px; }
+}
diff --git a/core/http/react-ui/src/components/models/page/VramChart.jsx b/core/http/react-ui/src/components/models/page/VramChart.jsx
new file mode 100644
index 000000000..9452f5dfa
--- /dev/null
+++ b/core/http/react-ui/src/components/models/page/VramChart.jsx
@@ -0,0 +1,92 @@
+import { gbLabel } from '../../../utils/modelLedger'
+import { contextLabel } from '../../../utils/placement'
+import './VramChart.css'
+
+const W = 640
+const H = 230
+const LEFT = 8
+const RIGHT = 8
+const TOP = 34
+const BOTTOM = 26
+
+// Memory by context length: one solid bar per length, from zero, with the limit
+// drawn as a labelled dashed line. Bars over the limit change colour and the
+// words next to the chart say the same thing, so colour never carries it alone.
+// A data table sits behind the chart, as the kit asks of every chart.
+//
+// points [{ ctx, bytes }]
+// limit bytes, the most this machine lets a model use
+// selected the context the rest of the page is looking at
+// poolLabel names the limit line ("22.8 GB GPU memory")
+export default function VramChart({ points, limit, selected, onPick, title, poolLabel, t }) {
+ if (points.length < 2) return null
+ const top = Math.max(limit, ...points.map(p => p.bytes)) * 1.08
+ const plotW = W - LEFT - RIGHT
+ const plotH = H - TOP - BOTTOM
+ const slot = plotW / points.length
+ const barW = Math.min(64, slot * 0.7)
+ const y = bytes => TOP + plotH - (bytes / top) * plotH
+ const limitY = y(limit)
+ return (
+
+
+
{title}
+
+
+
+ {points.map((p, i) => {
+ const x = LEFT + slot * i + (slot - barW) / 2
+ const over = p.bytes > limit
+ const on = p.ctx === selected
+ const barY = y(p.bytes)
+ return (
+ onPick(p.ctx)}
+ >
+
+
+ {(p.bytes / 1024 ** 3).toFixed(1)}
+ {contextLabel(p.ctx)}
+
+ )
+ })}
+
+ {poolLabel}
+
+
+ {t('page.fit.dataTable')}
+
+
+
+
+ {t('page.fit.context')}
+ {t('page.fit.needs')}
+ {t('page.fit.verdict')}
+
+
+
+ {points.map(p => (
+
+ {contextLabel(p.ctx)}
+ {gbLabel(p.bytes)}
+ {p.bytes > limit ? t('page.fit.tableOver') : t('page.fit.tableFits')}
+
+ ))}
+
+
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/models/page/fitFacts.js b/core/http/react-ui/src/components/models/page/fitFacts.js
new file mode 100644
index 000000000..43ec2bd0e
--- /dev/null
+++ b/core/http/react-ui/src/components/models/page/fitFacts.js
@@ -0,0 +1,40 @@
+import { gbLabel } from '../../../utils/modelLedger'
+import { contextLabel, splitByContext } from '../../../utils/placement'
+
+// The segments of the bar for one model at one context, from the figures the
+// estimate returned for it.
+//
+// The estimate gives one number per context length. Its context term is linear,
+// so two lengths give the part that grows with context exactly (see
+// splitByContext). With a single reading the bar is one undivided segment.
+export function memorySegments(estimate, contextSize, need, t) {
+ const points = Object.entries(estimate?.estimates || {})
+ .map(([ctx, point]) => ({ ctx: Number(ctx), bytes: Number(point.vramBytes) || 0 }))
+ .filter(point => point.bytes > 0)
+ .sort((a, b) => a.ctx - b.ctx)
+ const here = points.find(point => point.ctx === contextSize)
+ const other = points.find(point => point.ctx !== contextSize)
+ const parts = here && other
+ ? (other.ctx > here.ctx ? splitByContext(here, other) : splitByContext(other, here))
+ : null
+ if (!parts) return [{ key: 'model', label: t('placement.legend.model'), bytes: need, tone: 'model' }]
+ // kv is the term at the first point of the pair; scale it to this context.
+ const kv = Math.min(need, parts.slope * contextSize)
+ return [
+ { key: 'weights', label: t('page.fit.weights'), bytes: need - kv, tone: 'model' },
+ { key: 'kv', label: t('placement.legend.kv'), bytes: kv, tone: 'kv' },
+ ]
+}
+
+// Words for the one thing the strip says about fit. The states are the ones
+// fitFor() returns, and the sentence repeats what the bar below shows.
+export function fitWords(view) {
+ const { fit, t, contextSize, budget } = view
+ if (!fit) return null
+ const context = contextLabel(contextSize)
+ const need = gbLabel(fit.need)
+ if (fit.state === 'fits') return { title: budget.hasGpu ? t('page.fit.fitsGpu') : t('page.fit.fitsMemory'), text: t('page.fit.fitsText', { need, free: gbLabel(fit.limit), context, spare: gbLabel(fit.amount) }), tone: 'ok' }
+ if (fit.state === 'spill') return { title: t('page.fit.spill'), text: t('ledger.summary.spill', { need, cpu: gbLabel(fit.amount), context }), tone: 'warn' }
+ return { title: t('page.fit.over'), text: t(budget.hasGpu ? 'ledger.summary.overGpu' : 'ledger.summary.overRam', { need, over: gbLabel(fit.amount), context }), tone: 'error' }
+}
+
diff --git a/core/http/react-ui/src/components/models/page/model-page.css b/core/http/react-ui/src/components/models/page/model-page.css
new file mode 100644
index 000000000..d650afe6a
--- /dev/null
+++ b/core/http/react-ui/src/components/models/page/model-page.css
@@ -0,0 +1,279 @@
+/* ==========================================================================
+ The model page: one model at its own address. A title block with the main
+ action, an answer strip, a quiet tab strip, and a panel per tab.
+
+ Built on the kit's hub tabs, chips, segmented control, table, meter, chart
+ and progress classes. What is here is only what the kit does not say: the
+ page layout, the title block, the strip and the tab panels. Flat surfaces,
+ a one-pixel edge, solid bars, no glow.
+ ========================================================================== */
+
+.modelpage { gap: 0; }
+
+.modelpage-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--dk-space-3);
+ margin-bottom: var(--dk-space-3);
+ flex: none;
+}
+
+.modelpage-walk {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--dk-space-1);
+ padding: 2px;
+ border-radius: var(--dk-radius-2);
+ background: var(--dk-inset);
+}
+.modelpage-walk__count {
+ min-width: 64px;
+ text-align: center;
+ font: var(--dk-weight-regular) var(--dk-text-12) / 1 var(--dk-font-mono);
+ font-variant-numeric: tabular-nums;
+ color: var(--dk-muted);
+}
+
+/* --- Title block ---------------------------------------------------------- */
+
+.modelpage-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: var(--dk-space-5);
+ flex: none;
+}
+.modelpage-title { min-width: 0; display: grid; gap: var(--dk-space-2); }
+.modelpage-name {
+ margin: 0;
+ font-size: 26px;
+ font-weight: var(--dk-weight-bold);
+ letter-spacing: -0.02em;
+ line-height: 1.15;
+ overflow-wrap: anywhere;
+}
+.modelpage-chips { display: flex; flex-wrap: wrap; align-items: center; gap: var(--dk-space-2); }
+.modelpage-chip { cursor: default; }
+a.modelpage-chip { cursor: pointer; text-decoration: none; color: inherit; }
+.modelpage-state { font-size: var(--dk-text-13); font-weight: var(--dk-weight-semibold); gap: var(--dk-space-2); }
+.modelpage-state[data-state="running"] { color: var(--dk-ok-text); }
+.modelpage-state[data-state="disabled"], .modelpage-state[data-state="idle"] { color: var(--dk-muted); }
+.modelpage-lede {
+ margin: 0;
+ max-width: 78ch;
+ color: var(--dk-text);
+ font-size: var(--dk-text-15);
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.modelpage-primary {
+ flex: none;
+ display: grid;
+ justify-items: end;
+ gap: var(--dk-space-2);
+ max-width: 360px;
+}
+.modelpage-primary__row { display: inline-flex; align-items: center; gap: var(--dk-space-2); }
+.modelpage-primary__sub { font-size: var(--dk-text-12); color: var(--dk-muted); text-align: right; }
+.modelpage-primary__sub--short { color: var(--dk-error-text); font-weight: var(--dk-weight-semibold); }
+
+.modelpage-split { display: inline-flex; gap: 2px; }
+.modelpage-split__main { border-top-right-radius: 4px; border-bottom-right-radius: 4px; }
+.modelpage-split__chevron { border-top-left-radius: 4px; border-bottom-left-radius: 4px; }
+.modelpage-builds { min-width: 240px; }
+
+.modelpage-install { display: grid; gap: var(--dk-space-2); width: min(340px, 100%); }
+.modelpage-install__head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--dk-space-3); font-size: var(--dk-text-13); }
+.modelpage-install__foot { display: flex; justify-content: space-between; align-items: center; gap: var(--dk-space-3); }
+.modelpage-install__foot .modelpage-primary__sub { text-align: left; font-family: var(--dk-font-mono); }
+
+.modelpage-callout { margin: var(--dk-space-3) 0 0; }
+
+/* --- Tabs ------------------------------------------------------------------ */
+
+.modelpage-tabs { margin-top: var(--dk-space-4); flex: none; }
+.modelpage-tabs .dk-hubtab-count { margin-left: var(--dk-space-2); }
+.modelpage-panel {
+ flex: 1 0 auto;
+ min-width: 0;
+ padding-top: var(--dk-space-5);
+}
+.modelpage-panel:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: 2px; border-radius: var(--dk-radius-1); }
+
+.modelpage-keys {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--dk-space-2) var(--dk-space-5);
+ margin: var(--dk-space-6) 0 0;
+ font-size: var(--dk-text-12);
+ color: var(--dk-muted);
+}
+.modelpage-keys span { display: inline-flex; align-items: center; gap: 4px; }
+
+.modelpage-section-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: var(--dk-space-2) var(--dk-space-4); margin-bottom: var(--dk-space-3); }
+.modelpage-section-title { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-17) / 1.3 var(--dk-font-body); }
+.modelpage-section-note { font-size: var(--dk-text-12); color: var(--dk-muted); }
+
+/* --- Overview -------------------------------------------------------------- */
+
+.modelpage-overview { display: grid; gap: var(--dk-space-5); }
+
+.modelpage-answer {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0;
+ padding: 0;
+ overflow: hidden;
+}
+.modelpage-answer__cell { display: grid; align-content: start; gap: var(--dk-space-2); padding: var(--dk-space-4) var(--dk-space-5); min-width: 0; }
+.modelpage-answer__cell + .modelpage-answer__cell { border-left: 1px solid var(--dk-edge); }
+.modelpage-answer__head { display: inline-flex; align-items: center; gap: var(--dk-space-2); font-size: var(--dk-text-17); }
+.modelpage-answer__head[data-tone="error"] { color: var(--dk-error-text); }
+.modelpage-answer__text { font-size: var(--dk-text-13); color: var(--dk-muted); overflow-wrap: anywhere; }
+.modelpage-answer__chips { display: flex; flex-wrap: wrap; gap: var(--dk-space-2); }
+
+.modelpage-columns { display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, 420px); gap: var(--dk-space-6); align-items: start; }
+.modelpage-about { min-width: 0; display: grid; gap: var(--dk-space-4); }
+.modelpage-prose { max-width: 70ch; font-size: var(--dk-text-15); }
+.modelpage-facts { row-gap: var(--dk-space-2); }
+.modelpage-facts dt { color: var(--dk-muted); font-size: var(--dk-text-13); padding: var(--dk-space-1) 0; }
+.modelpage-facts dd { margin: 0; font-family: var(--dk-font-body); padding: var(--dk-space-1) 0; min-width: 0; font-size: var(--dk-text-13); overflow-wrap: anywhere; }
+.modelpage-tags { display: flex; flex-wrap: wrap; gap: var(--dk-space-2); }
+.modelpage-links { display: grid; gap: var(--dk-space-1); }
+
+.modelpage-aside { display: grid; gap: var(--dk-space-6); min-width: 0; }
+.modelpage-glance, .modelpage-status, .modelpage-usedby { display: grid; gap: var(--dk-space-3); min-width: 0; align-content: start; }
+.modelpage-glance .modelpage-section-head { margin-bottom: 0; }
+.modelpage-glance__link { display: inline-flex; align-items: center; gap: 4px; background: none; border: 0; padding: 0; cursor: pointer; font-size: var(--dk-text-13); }
+.modelpage-glance__head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--dk-space-3); flex-wrap: wrap; }
+.modelpage-glance__pool { font-weight: var(--dk-weight-semibold); font-size: var(--dk-text-13); }
+.modelpage-glance__free { font: var(--dk-weight-regular) var(--dk-text-12) / 1.2 var(--dk-font-mono); color: var(--dk-muted); font-variant-numeric: tabular-nums; }
+.modelpage-glance__free--over { color: var(--dk-error-text); font-weight: var(--dk-weight-semibold); }
+
+.modelpage-usedby__list { list-style: none; margin: 0; padding: 0; display: grid; }
+.modelpage-usedby__list li { display: flex; justify-content: space-between; align-items: baseline; gap: var(--dk-space-3); padding: var(--dk-space-2) 0; border-bottom: 1px solid var(--dk-edge); font-size: var(--dk-text-13); }
+.modelpage-usedby__name { min-width: 0; overflow-wrap: anywhere; font-weight: var(--dk-weight-semibold); }
+.modelpage-usedby__loading { display: block; height: 12px; width: 60%; }
+
+/* --- Fit and memory ---------------------------------------------------------- */
+
+.modelpage-fit { display: grid; gap: var(--dk-space-4); max-width: 860px; }
+.modelpage-fit .modelpage-section-head { margin-bottom: 0; }
+.modelpage-builds-seg { max-width: 100%; overflow-x: auto; scrollbar-width: none; }
+.modelpage-banner {
+ display: flex;
+ gap: var(--dk-space-3);
+ align-items: flex-start;
+ padding: var(--dk-space-3) var(--dk-space-4);
+ border-radius: var(--dk-radius-2);
+ font-size: var(--dk-text-13);
+}
+.modelpage-banner .lai-icon { flex: none; margin-top: 1px; font-size: 18px; }
+.modelpage-banner[data-tone="ok"] { background: var(--dk-ok-wash); color: var(--dk-ok-text); }
+.modelpage-banner[data-tone="warn"] { background: var(--dk-warn-wash); color: var(--dk-warn-text); }
+.modelpage-banner[data-tone="error"] { background: var(--dk-error-wash); color: var(--dk-error-text); }
+.modelpage-context { display: flex; flex-wrap: wrap; align-items: center; gap: var(--dk-space-3); }
+.modelpage-pool { display: grid; gap: var(--dk-space-2); }
+.modelpage-fit__loading { display: grid; gap: var(--dk-space-3); }
+.modelpage-fit__skeleton { display: block; height: 44px; border-radius: var(--dk-radius-2); }
+.modelpage-fit__skeleton-block { display: block; height: 160px; border-radius: var(--dk-radius-2); }
+.modelpage-fit__none { padding: var(--dk-space-5); }
+
+/* --- Variants and files ---------------------------------------------------------- */
+
+.modelpage-variants { display: grid; gap: var(--dk-space-6); max-width: 1000px; }
+.modelpage-table { --dk-table-max: none; }
+.modelpage-table__radio { width: 1%; padding-right: 0 !important; }
+.modelpage-table__source { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dk-muted); }
+.modelpage-inline-badge { margin-left: var(--dk-space-2); }
+.modelpage-fitcell { display: inline-flex; align-items: center; gap: var(--dk-space-2); font-size: var(--dk-text-13); }
+.modelpage-fitcell[data-fit="spill"] { color: var(--dk-warn-text); }
+.modelpage-fitcell[data-fit="over"] { color: var(--dk-error-text); }
+.modelpage-variants__loading { display: block; height: 120px; border-radius: var(--dk-radius-2); }
+
+/* --- Usage ----------------------------------------------------------------------- */
+
+.modelpage-usage { display: grid; gap: var(--dk-space-6); max-width: 760px; }
+.modelpage-files__path { overflow-wrap: anywhere; white-space: normal; font-size: var(--dk-text-12); }
+.modelpage-files__note { margin-top: var(--dk-space-3); }
+.modelpage-files__shared { font-size: var(--dk-text-13); color: var(--dk-muted); overflow-wrap: anywhere; }
+.modelpage-usage__empty { padding: var(--dk-space-5); }
+.modelpage-usage__missing { list-style: none; margin: var(--dk-space-3) 0 0; padding: 0; display: grid; }
+.modelpage-usage__missing li { display: flex; justify-content: space-between; align-items: center; gap: var(--dk-space-4); padding: var(--dk-space-3) 0; border-bottom: 1px solid var(--dk-edge); font-size: var(--dk-text-13); }
+.modelpage-usage__missing li > span:first-child { display: grid; gap: 2px; min-width: 0; }
+
+/* --- Configuration and logs --------------------------------------------------------- */
+
+.modelpage-config { display: grid; gap: var(--dk-space-5); max-width: 860px; }
+.modelpage-yaml { display: grid; gap: var(--dk-space-2); }
+.modelpage-yaml__body {
+ margin: 0;
+ padding: var(--dk-space-3) 0;
+ max-height: 260px;
+ overflow: auto;
+ border-radius: var(--dk-radius-2);
+ background: var(--dk-inset);
+ font: var(--dk-weight-regular) var(--dk-text-12) / 1.7 var(--dk-font-mono);
+ color: var(--dk-text);
+}
+.modelpage-yaml__line { display: block; padding: 0 var(--dk-space-4); white-space: pre; }
+.modelpage-yaml__line--written { background: var(--dk-accent-wash); color: var(--dk-accent-text); font-weight: var(--dk-weight-semibold); }
+.modelpage-config__save { display: flex; align-items: center; gap: var(--dk-space-3); flex-wrap: wrap; }
+
+.modelpage-logs { display: grid; gap: var(--dk-space-3); }
+.modelpage-logs .modelpage-section-head { margin-bottom: 0; }
+.logs-embedded__note { margin: 0; }
+
+/* --- States -------------------------------------------------------------------------- */
+
+.modelpage-skeleton { display: grid; gap: var(--dk-space-3); }
+.modelpage-skeleton .dk-skeleton { display: block; }
+.modelpage-skeleton__title { width: 40%; height: 32px; }
+.modelpage-skeleton__chips { width: 28%; height: 20px; }
+.modelpage-skeleton__lede { width: 60%; height: 14px; }
+.modelpage-skeleton__body { height: 220px; margin-top: var(--dk-space-5); border-radius: var(--dk-radius-3); }
+.modelpage-empty { margin-top: var(--dk-space-6); }
+.modelpage-matches { list-style: none; margin: var(--dk-space-3) 0; padding: 0; display: grid; gap: var(--dk-space-1); }
+
+/* --- Narrow ----------------------------------------------------------------------------- */
+
+@media (max-width: 900px) {
+ .modelpage-columns { grid-template-columns: minmax(0, 1fr); }
+ .modelpage-answer { grid-template-columns: minmax(0, 1fr); }
+ .modelpage-answer__cell + .modelpage-answer__cell { border-left: 0; border-top: 1px solid var(--dk-edge); }
+}
+
+@media (max-width: 640px) {
+ .modelpage-head { flex-direction: column; gap: var(--dk-space-4); }
+ .modelpage-name { font-size: 22px; }
+ .modelpage-primary { justify-items: stretch; max-width: none; width: 100%; }
+ .modelpage-primary__row { width: 100%; }
+ .modelpage-primary__row .dk-btn:not(.dk-btn--icon) { flex: 1; }
+ .modelpage-split { width: 100%; }
+ .modelpage-primary__sub { text-align: left; }
+ .modelpage-install { width: 100%; }
+}
+
+@media (pointer: coarse) {
+ .modelpage-keys { display: none; }
+}
+
+.modelpage-table .dk-table-sub { max-width: none; white-space: normal; overflow-wrap: anywhere; }
+.modelpage-table .dk-table-sub .modelpage-inline-badge:first-child { margin-left: 0; }
+
+/* A grid child is as wide as its content unless told otherwise; a table inside
+ one would push the whole page sideways instead of scrolling in its own box. */
+.modelpage-variants > section, .modelpage-usage > section, .modelpage-columns > *, .modelpage-fit > * { min-width: 0; }
+.modelpage-variants__size { display: none; }
+@media (max-width: 640px) {
+ .modelpage-variants__size { display: inline; }
+ .modelpage-table .dk-table-actions .dk-btn { padding-inline: var(--dk-space-2); }
+}
+
+/* The more menu is a tap target of its own beside Load or Stop. */
+.modelpage-primary .action-menu__trigger { width: 40px; height: 40px; opacity: 1; color: var(--dk-text); border-color: var(--dk-edge); border-radius: var(--dk-radius-2); }
+.modelpage-primary .action-menu__trigger:hover { background: var(--dk-hover); }
diff --git a/core/http/react-ui/src/components/models/placement/PlacementSection.jsx b/core/http/react-ui/src/components/models/placement/PlacementSection.jsx
new file mode 100644
index 000000000..c54f464de
--- /dev/null
+++ b/core/http/react-ui/src/components/models/placement/PlacementSection.jsx
@@ -0,0 +1,504 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useResources } from '../../../hooks/useResources'
+import { DEFAULT_CONTEXT, usePlacementEstimate } from '../../../hooks/usePlacementEstimate'
+import { FIT_LIMIT, cssVars, gbLabel } from '../../../utils/modelLedger'
+import {
+ ALL_LAYERS, CONTEXT_PRESETS, contextLabel, devicesFrom, estimateLayers, formatSplit, isAllLayers,
+ parseSplit, placementFit, placementMode, searchLayers, shares, splitByFree,
+} from '../../../utils/placement'
+import Icon from '../../Icon'
+import './placement.css'
+// eslint-disable-next-line no-unused-vars
+import MemoryBar from '../MemoryBar'
+
+// The Placement section: where a model runs and how much of it goes to the GPU.
+// It edits three keys of the model's configuration (gpu_layers, tensor_split,
+// main_gpu) and reads a fourth (context_size) because the KV cache grows with
+// it. It owns no config state: the model editor and the model page each hold
+// the values and hand them in.
+//
+// model the installed model's name; the estimate is asked about it.
+// values { gpu_layers, tensor_split, main_gpu, context_size } as the
+// editor holds them. An absent key is unset.
+// onChange (path, value) => void. A value of undefined unsets the key.
+//
+// Everything on screen is worked out from three things the server returns: the
+// device list in /api/resources, and the vram-estimate figure for the choice.
+// The estimate carries one number per request, so the per-device bars split it
+// by tensor_split, and the part that grows with context comes from a second
+// reading at twice the context. Nothing is guessed beyond that.
+export default function PlacementSection({ model, values, onChange, headerAction, testId = 'placement' }) {
+ const { t } = useTranslation('models')
+ const { resources, loading: resourcesLoading, error: resourcesError } = useResources()
+ const devices = useMemo(() => devicesFrom(resources), [resources])
+
+ const mode = placementMode(values.gpu_layers)
+ const layers = estimateLayers(mode, values.gpu_layers)
+ const contextValue = Number(values.context_size) > 0 ? Number(values.context_size) : 0
+ const estimate = usePlacementEstimate({
+ model,
+ contextSize: contextValue || undefined,
+ layers,
+ enabled: !resourcesLoading,
+ })
+
+ const gpus = devices.gpus
+ const hasGpu = devices.kind === 'gpu'
+ const multi = hasGpu && gpus.length > 1 && mode !== 'cpu'
+ const split = parseSplit(values.tensor_split)
+ const layerCount = estimate.data?.layerCount || 0
+
+ // The last "Fit it for me", so it can be undone. Cleared by any other edit.
+ const [applied, setApplied] = useState(null)
+ const [fitting, setFitting] = useState(false)
+ const [fitNote, setFitNote] = useState(null)
+
+ const edit = (path, value) => {
+ setApplied(null)
+ setFitNote(null)
+ onChange(path, value)
+ }
+
+ const choose = (next) => {
+ if (next === mode) return
+ if (next === 'cpu') edit('gpu_layers', 0)
+ else if (next === 'auto') edit('gpu_layers', undefined)
+ else edit('gpu_layers', layerCount > 0 ? layerCount : ALL_LAYERS)
+ }
+
+ // --- the layer count field -------------------------------------------
+ const allLayers = isAllLayers(values.gpu_layers)
+ const shownLayers = allLayers ? t('placement.layersAll') : String(values.gpu_layers ?? '')
+ const [layerText, setLayerText] = useState(shownLayers)
+ const typing = useRef(false)
+ useEffect(() => {
+ if (!typing.current) setLayerText(shownLayers)
+ }, [shownLayers])
+
+ const commitLayers = (text) => {
+ const trimmed = text.trim().toLowerCase()
+ if (trimmed === '') return
+ if (trimmed === 'all' || trimmed === t('placement.layersAll').toLowerCase()) { edit('gpu_layers', ALL_LAYERS); return }
+ if (!/^\d+$/.test(trimmed)) return
+ const n = Number(trimmed)
+ // Zero is a real choice, but it belongs to CPU only; keep the card honest.
+ edit('gpu_layers', n)
+ }
+
+ const [contextText, setContextText] = useState(contextValue ? String(contextValue) : '')
+ const typingContext = useRef(false)
+ useEffect(() => {
+ if (!typingContext.current) setContextText(contextValue ? String(contextValue) : '')
+ }, [contextValue])
+
+ // --- fit state -------------------------------------------------------
+ const fit = placementFit({
+ mode,
+ gpuBytes: estimate.data?.gpuBytes,
+ allBytes: estimate.data?.allBytes,
+ devices,
+ })
+ const ready = estimate.status === 'ready'
+ const effectiveContext = contextValue || DEFAULT_CONTEXT
+ const need = gbLabel
+ const copy = (key, extra = {}) => t(`placement.fit.${key}`, {
+ need: need(fit.gpuNeed || fit.cpuNeed),
+ gpu: need(fit.gpuNeed),
+ cpu: need(fit.cpuNeed),
+ allNeed: need(estimate.data?.allBytes || 0),
+ free: need(fit.gpuFree * FIT_LIMIT),
+ ramFree: need(fit.ramFree || 0),
+ context: contextLabel(effectiveContext),
+ ...extra,
+ })
+
+ const tone = { fits: 'ok', cpu: 'ok', nogpu: 'ok', spill: 'warn', trimmed: 'warn', toomany: 'error', 'cpu-over': 'error', 'nogpu-over': 'error', 'spill-over': 'error', 'trimmed-over': 'error' }[fit.state]
+
+ // --- bars --------------------------------------------------------------
+ const bars = []
+ if (ready && devices.kind !== 'cluster') {
+ const kvShare = estimate.data.parts && estimate.data.gpuBytes > 0
+ ? estimate.data.parts.kv / estimate.data.gpuBytes
+ : null
+ if (hasGpu && mode !== 'cpu') {
+ const parts = shares(gpus.length, split)
+ gpus.forEach((g, i) => {
+ const onDevice = fit.gpuNeed * parts[i]
+ const kv = kvShare === null ? 0 : onDevice * kvShare
+ bars.push({
+ key: `gpu-${g.index}`,
+ label: gpus.length > 1 ? t('placement.bar.gpuNamed', { index: g.index, name: g.name }) : t('placement.bar.gpu', { name: g.name }),
+ capacity: g.total,
+ free: g.free - onDevice,
+ segments: [
+ { key: 'other', label: t('placement.legend.other'), bytes: g.used, tone: 'other' },
+ { key: 'model', label: kvShare === null ? t('placement.legend.model') : t('placement.legend.weights'), bytes: onDevice - kv, tone: 'model' },
+ { key: 'kv', label: t('placement.legend.kv'), bytes: kv, tone: 'kv' },
+ ],
+ })
+ })
+ }
+ if (devices.ram && (fit.cpuNeed > 0 || mode === 'cpu' || !hasGpu)) {
+ bars.push({
+ key: 'ram',
+ label: t('placement.bar.ram'),
+ capacity: devices.ram.total,
+ free: devices.ram.free - fit.cpuNeed,
+ segments: [
+ { key: 'other', label: t('placement.legend.other'), bytes: devices.ram.total - devices.ram.free, tone: 'other' },
+ { key: 'model', label: t('placement.legend.model'), bytes: fit.cpuNeed, tone: 'model' },
+ ],
+ })
+ }
+ }
+
+ // --- Fit it for me -------------------------------------------------------
+ const canFit = hasGpu && estimate.status !== 'unavailable'
+ const fitForMe = async () => {
+ setFitting(true)
+ setFitNote(null)
+ const previous = { gpu_layers: values.gpu_layers }
+ const limit = gpus.reduce((sum, g) => sum + g.free, 0) * FIT_LIMIT
+ const result = await searchLayers(estimate.bytesAt, limit)
+ setFitting(false)
+ if (result.kind === 'all') {
+ onChange('gpu_layers', ALL_LAYERS)
+ setApplied({ previous, text: t('placement.fitApplied.all') })
+ } else if (result.kind === 'layers') {
+ onChange('gpu_layers', result.layers)
+ setApplied({ previous, text: t('placement.fitApplied.layers', { count: result.layers }) })
+ } else {
+ setApplied(null)
+ setFitNote(t(`placement.fitFailed.${result.kind}`, { free: gbLabel(limit), need: gbLabel(result.bytes || 0) }))
+ }
+ }
+ const undo = () => {
+ if (!applied) return
+ onChange('gpu_layers', applied.previous.gpu_layers)
+ setApplied(null)
+ }
+
+ const noGpuReason = devices.kind === 'none' ? t('placement.noGpu') : null
+ const modeWrites = {
+ cpu: 'gpu_layers: 0',
+ auto: t('placement.modes.autoWrites'),
+ custom: `gpu_layers: ${allLayers ? ALL_LAYERS : (Number.isFinite(Number(values.gpu_layers)) && mode === 'custom' ? Number(values.gpu_layers) : 'N')}`,
+ }
+ const modeOptions = ['cpu', 'auto', 'custom']
+
+ const splitPercent = split && split.length === gpus.length
+ ? split.map(p => Math.round((p / split.reduce((a, b) => a + b, 0)) * 100))
+ : shares(gpus.length, null).map(p => Math.round(p * 100))
+ const setSplitPercent = (index, percent) => {
+ const clamped = Math.max(0, Math.min(100, Number.isFinite(percent) ? percent : 0))
+ if (gpus.length === 2) {
+ const pair = index === 0 ? [clamped, 100 - clamped] : [100 - clamped, clamped]
+ edit('tensor_split', formatSplit(pair))
+ return
+ }
+ const next = [...splitPercent]
+ next[index] = clamped
+ if (next.every(p => p === 0)) return
+ edit('tensor_split', formatSplit(next))
+ }
+
+ return (
+
+
+
{t('placement.title')}
+ gpu_layers, tensor_split, main_gpu
+ {headerAction && {headerAction} }
+
+
+
+
{t('placement.runOn')}
+
+ {modeOptions.map(option => {
+ const disabled = option !== 'cpu' && devices.kind === 'none'
+ return (
+ choose(option)}
+ >
+ {t(`placement.modes.${option}`)}
+ {modeWrites[option]}
+
+ )
+ })}
+
+ {noGpuReason &&
{noGpuReason}
}
+ {mode === 'auto' && !noGpuReason && (
+
{t('placement.autoHint')}
+ )}
+ {resourcesError && !resources && (
+
{t('placement.noMachine')}
+ )}
+
+
+ {mode === 'custom' && (
+
+
+ {t('placement.layers')}
+ { typing.current = true }}
+ onChange={e => { setLayerText(e.target.value); commitLayers(e.target.value) }}
+ onBlur={() => { typing.current = false; setLayerText(shownLayers) }}
+ />
+ edit('gpu_layers', ALL_LAYERS)}
+ >
+ {t('placement.allLayers')}
+
+
+ {layerCount > 0 ? (
+
+ edit('gpu_layers', Number(e.target.value))}
+ />
+
+ {t('placement.sliderValue', { count: allLayers ? layerCount : Math.min(Number(values.gpu_layers) || 0, layerCount), total: layerCount })}
+
+
+ ) : (
+
{t('placement.noSlider', { all: ALL_LAYERS })}
+ )}
+
+ )}
+
+
+
+
{t('placement.context')}
+
+ {CONTEXT_PRESETS.map(size => (
+ edit('context_size', size)}
+ >
+ {contextLabel(size)}
+
+ ))}
+
+
{ typingContext.current = true }}
+ onChange={e => {
+ const text = e.target.value
+ setContextText(text)
+ if (/^\d+$/.test(text.trim()) && Number(text) > 0) edit('context_size', Number(text))
+ }}
+ onBlur={() => { typingContext.current = false; setContextText(contextValue ? String(contextValue) : '') }}
+ />
+
+
+ {contextValue ? t('placement.contextHint') : t('placement.contextUnset', { context: contextLabel(DEFAULT_CONTEXT) })}
+
+
+
+ {multi && (
+
+
+ {t('placement.split.title')}
+ edit('tensor_split', formatSplit(splitByFree(gpus)))}
+ >
+ {t('placement.split.byFree')}
+
+
+
+ {gpus.map((g, i) => (
+
+ {t('placement.split.share', { index: g.index })}
+
+ { if (/^\d{0,3}$/.test(e.target.value)) setSplitPercent(i, Number(e.target.value || 0)) }}
+ />
+ %
+
+
+ ))}
+
+ {gpus.length === 2 && (
+
+ setSplitPercent(0, Number(e.target.value))}
+ />
+ {splitPercent[0]} / {splitPercent[1]}
+
+ )}
+
+
{t('placement.split.main')}
+
+ {gpus.map(g => (
+ edit('main_gpu', String(g.index))}
+ >
+ {t('placement.split.gpu', { index: g.index })}
+
+ ))}
+
+
+
+ )}
+
+
+
{t('placement.memory')}
+ {devices.kind === 'cluster' ? (
+
{t('placement.cluster')}
+ ) : estimate.status === 'loading' || (resourcesLoading && !resources) ? (
+
+
+
+
+ ) : estimate.status === 'unavailable' ? (
+
+
+
+
{t('placement.unavailable.title')}
+
{t('placement.unavailable.text')}
+
+ {t('placement.unavailable.retry')}
+
+
+
+ ) : (
+ <>
+ {estimate.data?.sizeBytes > 0 && (
+
+ {t('placement.weights', { size: gbLabel(estimate.data.sizeBytes), context: contextLabel(effectiveContext) })}
+
+ )}
+ {bars.map(bar => (
+
+
+ {bar.label}
+
+ {bar.free < 0
+ ? t('placement.bar.over', { amount: gbLabel(-bar.free) })
+ : t('placement.bar.freeAfter', { amount: gbLabel(bar.free) })}
+
+
+
s.bytes > 0).map(s => `${s.label} ${gbLabel(s.bytes)}`).join(', '),
+ capacity: gbLabel(bar.capacity),
+ })}
+ />
+
+ ))}
+ {bars.length > 0 && (
+
+ {[...new Map(bars.flatMap(b => b.segments).filter(s => s.bytes > 0).map(s => [s.tone, s])).values()].map(s => (
+
+
+ {s.label}
+
+ ))}
+
+ )}
+ {bars.length === 0 && ready &&
{t('placement.noBars')}
}
+ >
+ )}
+
+
+ {ready && fit.state !== 'unknown' && devices.kind !== 'cluster' && (
+
+
+
+ {t(`placement.fit.${fit.state}.title`)}
+ {copy(`${fit.state}.text`)}
+
+
+ )}
+
+ {(canFit || applied || fitNote) && devices.kind !== 'cluster' && (
+
+ {canFit && (
+
+ {t('placement.fitForMe')}
+
+ )}
+ {applied && (
+
+ {applied.text}{' '}
+ {t('placement.undo')}
+
+ )}
+ {fitNote &&
{fitNote}
}
+
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/models/placement/placement.css b/core/http/react-ui/src/components/models/placement/placement.css
new file mode 100644
index 000000000..47a2e1be6
--- /dev/null
+++ b/core/http/react-ui/src/components/models/placement/placement.css
@@ -0,0 +1,102 @@
+/* The Placement section of the model editor and the model page. Radio cards
+ for where the model runs, a layer field, context chips, a bar per memory
+ pool and a verdict in words. Selection is an outline and a stronger surface,
+ never a rail on the edge. Bars are solid. */
+
+.placement { display: grid; gap: var(--dk-space-5); min-width: 0; }
+
+.placement__head { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--dk-space-2) var(--dk-space-3); }
+.placement__title { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-17) / 1.3 var(--dk-font-body); }
+.placement__keys { font-size: var(--dk-text-12); color: var(--dk-muted); }
+.placement__head-action { margin-left: auto; }
+
+.placement__block { display: grid; gap: var(--dk-space-3); min-width: 0; }
+.placement__hint { margin: 0; }
+
+.placement__modes { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--dk-space-3); }
+.placement__mode {
+ display: grid;
+ gap: 2px;
+ padding: var(--dk-space-3) var(--dk-space-4);
+ text-align: left;
+ min-width: 0;
+ border: 1px solid var(--dk-control-edge);
+ border-radius: var(--dk-radius-2);
+ background: var(--dk-card);
+ color: var(--dk-text);
+ cursor: pointer;
+ transition: background var(--dk-dur-1) ease, border-color var(--dk-dur-1) ease;
+}
+.placement__mode:hover:not(:disabled) { background: var(--dk-hover); }
+.placement__mode:active:not(:disabled) { transform: scale(var(--dk-press-scale)); }
+.placement__mode:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: 2px; }
+.placement__mode:disabled { opacity: .5; cursor: not-allowed; }
+.placement__mode[aria-checked="true"] {
+ border-color: var(--dk-accent);
+ background: var(--dk-accent-wash);
+ box-shadow: inset 0 0 0 1px var(--dk-accent);
+}
+.placement__mode-name { font-weight: var(--dk-weight-semibold); font-size: var(--dk-text-15); }
+.placement__mode-writes { font-size: var(--dk-text-12); color: var(--dk-muted); overflow-wrap: anywhere; }
+
+.placement__row { display: flex; align-items: center; gap: var(--dk-space-3); min-width: 0; }
+.placement__row--wrap { flex-wrap: wrap; }
+.placement__number { width: 120px; flex: none; text-align: right; }
+.placement__number--small { width: 72px; }
+.placement__slider { max-width: 560px; }
+
+.placement__split { display: flex; flex-wrap: wrap; gap: var(--dk-space-3) var(--dk-space-5); }
+.placement__split-item { display: grid; gap: var(--dk-space-1); }
+.placement__split-input { display: inline-flex; align-items: center; gap: var(--dk-space-2); }
+
+.placement__bar { display: grid; gap: var(--dk-space-2); }
+.placement__bar-head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--dk-space-3); flex-wrap: wrap; }
+.placement__bar-name { font-weight: var(--dk-weight-semibold); font-size: var(--dk-text-13); }
+.placement__bar-free { font: var(--dk-weight-regular) var(--dk-text-12) / 1.2 var(--dk-font-mono); color: var(--dk-muted); font-variant-numeric: tabular-nums; }
+.placement__bar-free--over { color: var(--dk-error-text); font-weight: var(--dk-weight-semibold); }
+.placement__legend { margin-top: var(--dk-space-1); }
+
+.placement__memory { padding-top: var(--dk-space-1); }
+.placement__skeleton { display: grid; gap: var(--dk-space-4); }
+.placement__skeleton-bar { display: block; height: 14px; border-radius: var(--dk-radius-pill); }
+
+.placement__unavailable {
+ display: flex;
+ gap: var(--dk-space-3);
+ padding: var(--dk-space-4);
+ border-radius: var(--dk-radius-2);
+ background: var(--dk-inset);
+ color: var(--dk-text);
+}
+.placement__unavailable .lai-icon { flex: none; margin-top: 2px; font-size: 18px; color: var(--dk-muted); }
+.placement__unavailable p { margin: var(--dk-space-1) 0 var(--dk-space-3); color: var(--dk-muted); font-size: var(--dk-text-13); }
+
+.placement__verdict {
+ display: flex;
+ gap: var(--dk-space-3);
+ align-items: flex-start;
+ padding: var(--dk-space-3) var(--dk-space-4);
+ border-radius: var(--dk-radius-2);
+ font-size: var(--dk-text-13);
+}
+.placement__verdict .lai-icon { flex: none; margin-top: 1px; font-size: 18px; }
+.placement__verdict-text { display: grid; gap: 2px; min-width: 0; }
+.placement__verdict-text strong { font-size: var(--dk-text-15); }
+.placement__verdict[data-tone="ok"] { background: var(--dk-ok-wash); color: var(--dk-ok-text); }
+.placement__verdict[data-tone="warn"] { background: var(--dk-warn-wash); color: var(--dk-warn-text); }
+.placement__verdict[data-tone="error"] { background: var(--dk-error-wash); color: var(--dk-error-text); }
+
+.placement__fitrow { display: flex; flex-wrap: wrap; align-items: center; gap: var(--dk-space-3); }
+.placement__applied { margin: 0; font-size: var(--dk-text-13); color: var(--dk-muted); }
+.placement__undo { background: none; border: 0; padding: 0; cursor: pointer; font: inherit; }
+
+@media (max-width: 640px) {
+ .placement__modes { grid-template-columns: minmax(0, 1fr); }
+ .placement__number { width: 100%; }
+ .placement__number--small { width: 72px; }
+ .placement__row { flex-wrap: wrap; }
+}
+
+/* The context chips are seven wide. On a phone they scroll instead of being cut. */
+.placement .dk-segmented { max-width: 100%; overflow-x: auto; scrollbar-width: none; }
+.placement .dk-segmented::-webkit-scrollbar { display: none; }
diff --git a/core/http/react-ui/src/components/models/rowKeys.js b/core/http/react-ui/src/components/models/rowKeys.js
new file mode 100644
index 000000000..c825a7033
--- /dev/null
+++ b/core/http/react-ui/src/components/models/rowKeys.js
@@ -0,0 +1,92 @@
+import { useEffect, useRef } from 'react'
+
+// Keyboard for a ledger table. Shared by Explore and Installed so both answer
+// to the same keys.
+
+export function isTypingTarget(el) {
+ if (!el || !el.tagName) return false
+ const tag = el.tagName
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable
+}
+
+// Key handling for one focused row.
+// names the navigable row names in screen order
+// current the name of the row that has focus
+// move (name) => void, moves selection and focus
+// enter (name) => void, the row's primary action
+// close () => void
+// Only keys aimed at the row itself count: an Enter on a button inside the row
+// belongs to that button.
+export function rowKeyDown(e, { names, current, move, enter, close }) {
+ if (e.altKey || e.ctrlKey || e.metaKey) return
+ const index = names.indexOf(current)
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
+ if (e.target !== e.currentTarget) return
+ e.preventDefault()
+ const next = names[Math.max(0, Math.min(names.length - 1, index + (e.key === 'ArrowDown' ? 1 : -1)))]
+ if (next && next !== current) move(next)
+ } else if (e.key === 'Home' || e.key === 'End') {
+ if (e.target !== e.currentTarget) return
+ e.preventDefault()
+ const next = e.key === 'Home' ? names[0] : names[names.length - 1]
+ if (next && next !== current) move(next)
+ } else if (e.key === 'Enter') {
+ if (e.target !== e.currentTarget) return
+ e.preventDefault()
+ enter?.(current)
+ } else if (e.key === 'Escape') {
+ close?.()
+ }
+}
+
+// When the selection clears (the inspector's close button, Escape, or the
+// browser's Back), put keyboard focus back on the row that opened it, so a
+// narrow layout that hid the table while the inspector was open does not drop
+// the user on the page body.
+export function useRestoreRowFocus(selectedId, containerRef) {
+ const last = useRef(null)
+ useEffect(() => {
+ if (selectedId) {
+ last.current = selectedId
+ return undefined
+ }
+ const id = last.current
+ if (!id) return undefined
+ const frame = window.requestAnimationFrame(() => {
+ const row = containerRef.current?.querySelector(`[data-entity="${CSS.escape(id)}"]`)
+ row?.scrollIntoView({ block: 'nearest' })
+ row?.focus({ preventScroll: true })
+ })
+ return () => window.cancelAnimationFrame(frame)
+ }, [selectedId, containerRef])
+}
+
+// Keys that work anywhere on a ledger page: "/" jumps to search, "d" flips the
+// row density, "o" opens the selected model's own page, Escape closes the
+// inspector. They stand down while the user is
+// typing and while a dialog (the cleanup sheet, a confirmation) is open.
+export function useLedgerKeys({ enabled, searchRef, onToggleDensity, hasSelection, onClose, onOpen }) {
+ const latest = useRef({})
+ latest.current = { onToggleDensity, hasSelection, onClose, onOpen }
+ useEffect(() => {
+ if (!enabled) return undefined
+ const onKey = (e) => {
+ if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey) return
+ if (isTypingTarget(e.target)) return
+ if (document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]')) return
+ if (e.key === '/') {
+ e.preventDefault()
+ searchRef.current?.focus()
+ searchRef.current?.select()
+ } else if (e.key === 'd' || e.key === 'D') {
+ latest.current.onToggleDensity()
+ } else if ((e.key === 'o' || e.key === 'O') && latest.current.hasSelection && latest.current.onOpen) {
+ latest.current.onOpen()
+ } else if (e.key === 'Escape' && latest.current.hasSelection) {
+ latest.current.onClose()
+ }
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [enabled, searchRef])
+}
diff --git a/core/http/react-ui/src/components/nodes/AttentionCallout.jsx b/core/http/react-ui/src/components/nodes/AttentionCallout.jsx
deleted file mode 100644
index b3e22169a..000000000
--- a/core/http/react-ui/src/components/nodes/AttentionCallout.jsx
+++ /dev/null
@@ -1,31 +0,0 @@
-export default function AttentionCallout({ nodes, onApprove }) {
- const pending = nodes.filter(n => n.status === 'pending')
- const unhealthy = nodes.filter(n => n.status === 'unhealthy' || n.status === 'offline')
- if (pending.length === 0 && unhealthy.length === 0) return null
-
- if (pending.length > 0) {
- const first = pending[0]
- const extra = pending.length - 1
- return (
-
-
- {' '}
- {pending.length} node{pending.length > 1 ? 's' : ''} awaiting approval
- {' - '}{first.name}{extra > 0 ? ` +${extra} more` : ''}
-
- onApprove(first.id)}>
- Approve {first.name}
-
-
- )
- }
- return (
-
-
- {' '}
- {unhealthy.length} node{unhealthy.length > 1 ? 's' : ''} unhealthy
- {' - '}{unhealthy.map(n => n.name).slice(0, 3).join(', ')}
-
-
- )
-}
diff --git a/core/http/react-ui/src/components/nodes/CapacityEditor.jsx b/core/http/react-ui/src/components/nodes/CapacityEditor.jsx
index 2501d8127..3b702cc52 100644
--- a/core/http/react-ui/src/components/nodes/CapacityEditor.jsx
+++ b/core/http/react-ui/src/components/nodes/CapacityEditor.jsx
@@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'
import { nodesApi } from '../../utils/api'
import LoadingSpinner from '../LoadingSpinner'
import { formatVRAM } from './nodeStatus'
+import Icon from '../Icon'
/**
* Inline editor for a node's per-model replica capacity.
@@ -148,7 +149,7 @@ export default function CapacityEditor({ node, loadedModelCounts, onUpdate, conf
-
+
- {saving ? : <> Save>}
+ {saving ? : <> Save>}
-
+
{isOverride && (
- {resetting ? : <> Reset>}
+ {resetting ? : <> Reset>}
)}
>
@@ -255,7 +256,7 @@ export default function CapacityEditor({ node, loadedModelCounts, onUpdate, conf
-
+
- {budgetSaving ? : <> Save>}
+ {budgetSaving ? : <> Save>}
-
+
{hasBudget && (
- {budgetClearing ? : <> Clear>}
+ {budgetClearing ? : <> Clear>}
)}
>
diff --git a/core/http/react-ui/src/components/nodes/ClusterOverview.jsx b/core/http/react-ui/src/components/nodes/ClusterOverview.jsx
deleted file mode 100644
index 85d0d8d75..000000000
--- a/core/http/react-ui/src/components/nodes/ClusterOverview.jsx
+++ /dev/null
@@ -1,95 +0,0 @@
-import { formatCapacity } from './nodeStatus'
-
-const ATTENTION = [
- ['all', 'Needs attention'],
- ['pending', 'Pending approval'],
- ['offlineOrUnhealthy', 'Offline / unhealthy'],
- ['lowVRAM', 'Low VRAM'],
- ['lowRAM', 'Low RAM'],
- ['lowDisk', 'Low models disk'],
-]
-
-// `single` drops the "N of M nodes reporting" coverage lines, which describe a
-// fleet and read as a fault when the "fleet" is the one host being viewed.
-export function CapacityGauge({ label, metric, cpu = false, tone, single = false, noDataText = 'No data' }) {
- const reporting = metric.reportingCount > 0
- const percent = reporting ? Math.round(metric.usagePercent) : 0
- const value = cpu
- ? `${Number(metric.busyCoreEquivalents.toFixed(1))} busy / ${metric.totalLogicalCores} cores`
- : formatCapacity(metric.used, metric.total)
- const available = cpu
- ? `${Number(metric.idleCoreEquivalents.toFixed(1))} idle · load ${metric.load1.toFixed(2)}`
- : reporting ? `${formatCapacity(metric.available, metric.total).split(' / ')[0]} available` : noDataText
-
- return (
-
- {label} capacity
-
-
-
- {reporting && }
-
-
{reporting ? `${percent}%` : '—'}
-
- {reporting ? value : 'No data'}
- {available}
- {!single && Capacity coverage: {metric.reportingCount} of {metric.reportingCount + metric.unknownCount} nodes reporting; {metric.unknownCount} unknown. }
- {!single && metric.unknownCount > 0 && {metric.unknownCount} node{metric.unknownCount === 1 ? '' : 's'} unavailable
}
-
- )
-}
-
-export default function ClusterOverview({ summary, activeAttention, onAttentionSelect }) {
- const { health } = summary
- const total = Math.max(health.total, 1)
- const segments = [
- ['healthy', health.healthy],
- ['draining', health.draining],
- ['unhealthy', health.pending + health.unhealthy + health.offline + health.other],
- ]
- let cursor = 0
-
- return <>
-
-
-
Fleet health
-
{health.healthy} healthy of {health.total} nodes
-
- {segments.map(([status, count]) => {
- const start = cursor
- const width = count / total * 100
- cursor += width
- return
- })}
-
-
- {segments.map(([status, count]) => {
- const label = status === 'unhealthy' ? 'Attention' : status[0].toUpperCase() + status.slice(1)
- const percentage = health.total ? (count / health.total * 100).toFixed(1) : '0.0'
- return
{label}{count} · {percentage}%
- })}
-
-
-
-
-
-
-
-
-
-
- {summary.attentionNodeCount} node{summary.attentionNodeCount === 1 ? '' : 's'} need attention
-
- {ATTENTION.map(([key, label]) => {
- const count = key === 'all' ? summary.attentionNodeCount : summary.attention[key].length
- return (
- onAttentionSelect(activeAttention === key ? null : key)}>
- {label} {count}
-
- )
- })}
-
-
- >
-}
diff --git a/core/http/react-ui/src/components/nodes/ClusterPulse.jsx b/core/http/react-ui/src/components/nodes/ClusterPulse.jsx
deleted file mode 100644
index f66ffa1ed..000000000
--- a/core/http/react-ui/src/components/nodes/ClusterPulse.jsx
+++ /dev/null
@@ -1,18 +0,0 @@
-import { formatVRAM } from './nodeStatus'
-
-export default function ClusterPulse({ nodes }) {
- const total = nodes.length
- const healthy = nodes.filter(n => n.status === 'healthy').length
- const draining = nodes.filter(n => n.status === 'draining').length
- const usedVRAM = nodes.reduce((s, n) =>
- (n.total_vram && n.available_vram != null) ? s + (n.total_vram - n.available_vram) : s, 0)
- const vramStr = formatVRAM(usedVRAM)
- return (
-
- {total} {total === 1 ? 'node' : 'nodes'}
- {' · '}{healthy} healthy
- {draining > 0 && <>{' · '}{draining} draining >}
- {vramStr && <>{' · '}{vramStr} VRAM in use>}
-
- )
-}
diff --git a/core/http/react-ui/src/components/nodes/HostOverview.jsx b/core/http/react-ui/src/components/nodes/HostOverview.jsx
index 6f347a5e7..c33ecf678 100644
--- a/core/http/react-ui/src/components/nodes/HostOverview.jsx
+++ b/core/http/react-ui/src/components/nodes/HostOverview.jsx
@@ -1,65 +1,169 @@
-import { CapacityGauge } from './ClusterOverview'
-import { formatBytes } from './nodeStatus'
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { useTranslation } from 'react-i18next'
+import { formatBytes, formatCapacity } from './nodeStatus'
+import { cssVars, gbLabel } from '../../utils/modelLedger'
-// The single-node counterpart of ClusterOverview: the same four capacity
-// gauges, fed from this host instead of summed across workers. The lead cell
-// trades fleet health, which has nothing to say about one machine, for where
-// the machine's memory is going: one bar segment per running model.
+// The single-node view of capacity: where the GPU memory stands, what the
+// running models hold in host memory, and the four readings of the machine
+// (VRAM, RAM, CPU, models disk). The numbers are the host's own, read through
+// the same fleet maths a cluster uses, so the two never disagree.
+//
+// LocalAI reports GPU memory per device and resident memory per model process.
+// It does not report GPU memory per model, so no model is shown with a GPU size.
-const SEGMENTS = 5
-const MIN_SEGMENT = 0.8
-// The legend grid has three columns: three models, or two and a count.
-const LEGEND_COLUMNS = 3
+const SERIES = 6
+// The legend lists three models, or two and a count.
+const LEGEND_MAX = 3
+
+function GpuStrip({ resources, summary }) {
+ const { t } = useTranslation('operate')
+ const gpus = Array.isArray(resources?.gpus) ? resources.gpus : []
+ const reading = summary.vram
+ if (resources && !(reading.reportingCount > 0)) {
+ return (
+
+
{t('machine.gpuMemory')}
+
{t('machine.noGpu')}
+
+ )
+ }
+ if (!resources) return null
+ const pct = Math.round(reading.usagePercent)
+ return (
+
+
{t('machine.gpuMemoryUsed', { used: gbLabel(reading.used), total: gbLabel(reading.total) })}
+
+ = 97 ? ' dk-meter-seg--error' : pct >= 90 ? ' dk-meter-seg--warn' : ''}`}
+ style={cssVars({ '--dk-w': `${Math.min(100, reading.usagePercent).toFixed(1)}%` })}
+ />
+
+
+ {t('machine.gpuFree', { free: gbLabel(reading.available) })} {t('machine.gpuFreeHint')}
+
+ {gpus.length > 1 && (
+
+ {gpus.map((gpu, index) => {
+ const used = Math.max(0, Number(gpu.used_vram) || 0)
+ const total = Math.max(0, Number(gpu.total_vram) || 0)
+ return (
+
+ {gpu.name || t('machine.gpuN', { n: index + 1 })}
+
+
+
+ {gbLabel(used)} / {gbLabel(total)}
+
+ )
+ })}
+
+ )}
+
+ )
+}
function MemoryShare({ models, ramTotal }) {
+ const { t } = useTranslation('operate')
const measured = models
.filter(model => model.rss_bytes != null && model.rss_bytes > 0)
.sort((left, right) => right.rss_bytes - left.rss_bytes)
const modelBytes = measured.reduce((sum, model) => sum + model.rss_bytes, 0)
const scale = ramTotal > 0 ? ramTotal : modelBytes
- const named = measured.length > LEGEND_COLUMNS ? LEGEND_COLUMNS - 1 : measured.length
- let cursor = 0
+ const named = measured.length > LEGEND_MAX ? LEGEND_MAX - 1 : measured.length
return (
-
-
This machine
-
- {models.length} running
- {modelBytes > 0 ? `${formatBytes(modelBytes)} resident${ramTotal > 0 ? ` of ${formatBytes(ramTotal)} RAM` : ''}` : 'no memory readings yet'}
-
-
`${model.model_name} ${formatBytes(model.rss_bytes)}`).join(', ') || 'No models using memory'}>
-
- {scale > 0 && measured.map((model, index) => {
- const start = cursor
- // A floor so a model that is small next to the host still shows
- // up as a sliver rather than vanishing; the label carries the size.
- const width = Math.max(MIN_SEGMENT, model.rss_bytes / scale * 100)
- cursor += width
- return {`${model.model_name}: ${formatBytes(model.rss_bytes)}`}
- })}
-
-
- {measured.slice(0, named).map((model, index) => (
-
- {model.model_name}
- {formatBytes(model.rss_bytes)}
-
+
+
{t('machine.inMemory')}
+
+ {t('machine.runningCount', { count: models.length })} {' '}
+
+ {modelBytes > 0
+ ? `${formatBytes(modelBytes)} ${t('machine.resident')}${ramTotal > 0 ? ` ${t('machine.ofRam', { total: formatBytes(ramTotal) })}` : ''}`
+ : t('machine.noReadings')}
+
+
+
`${model.model_name} ${formatBytes(model.rss_bytes)}`).join(', ') || t('machine.noneInMemory')}
+ >
+ {scale > 0 && measured.map((model, index) => (
+
))}
- {measured.length > named &&
Others {measured.length - named} more
}
+ {measured.length > 0 && (
+
+ {measured.slice(0, named).map((model, index) => (
+
+
+ {model.model_name} {formatBytes(model.rss_bytes)}
+
+ ))}
+ {measured.length > named && {t('machine.othersCount', { count: measured.length - named })} }
+
+ )}
)
}
-export default function HostOverview({ summary, models, ramTotal }) {
+function Fact({ label, metric, cpu = false, noDataText }) {
+ const { t } = useTranslation('operate')
+ const reporting = metric.reportingCount > 0
+ const percent = reporting ? Math.round(metric.usagePercent) : 0
+ const value = cpu
+ ? `${Number(metric.busyCoreEquivalents.toFixed(1))} busy / ${metric.totalLogicalCores} cores`
+ : formatCapacity(metric.used, metric.total)
+ const detail = cpu
+ ? `${Number(metric.idleCoreEquivalents.toFixed(1))} idle · load ${metric.load1.toFixed(2)}`
+ : reporting ? `${formatCapacity(metric.available, metric.total).split(' / ')[0]} ${t('machine.available')}` : noDataText
return (
-
-
-
-
-
-
+
+ {label}
+
+ {reporting && (
+ = 97 ? ' dk-meter-seg--error' : percent >= 90 ? ' dk-meter-seg--warn' : ''}`}
+ style={cssVars({ '--dk-w': `${Math.min(100, metric.usagePercent).toFixed(1)}%` })}
+ />
+ )}
+
+ {reporting ? `${percent}%` : '—'}
+
+ {reporting ? value : t('machine.noData')}
+ {detail}
+
+
+ )
+}
+
+export default function HostOverview({ summary, models, ramTotal, resources }) {
+ const { t } = useTranslation('operate')
+ return (
+
+
+
+
+
+
+
{t('machine.theMachine')}
+
+
)
}
diff --git a/core/http/react-ui/src/components/nodes/KeyValueChips.jsx b/core/http/react-ui/src/components/nodes/KeyValueChips.jsx
index 1d6baba45..4ef5cc9c1 100644
--- a/core/http/react-ui/src/components/nodes/KeyValueChips.jsx
+++ b/core/http/react-ui/src/components/nodes/KeyValueChips.jsx
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from 'react'
import { suggestKeys, suggestValues } from '../../utils/nodeLabelSuggestions'
+import Icon from '../Icon'
/**
* Controlled chip-builder for { key: value } maps. Replaces the prior
@@ -142,7 +143,7 @@ export default function KeyValueChips({
title="Remove"
className="kvchips__chip-remove"
>
-
+
))}
@@ -158,7 +159,7 @@ export default function KeyValueChips({
disabled={!k.trim()}
aria-label={addLabel}
>
- Add
+ Add
{options.length > 0 && (
diff --git a/core/http/react-ui/src/components/nodes/LocalMachineView.jsx b/core/http/react-ui/src/components/nodes/LocalMachineView.jsx
index 9f95f5322..4b349c176 100644
--- a/core/http/react-ui/src/components/nodes/LocalMachineView.jsx
+++ b/core/http/react-ui/src/components/nodes/LocalMachineView.jsx
@@ -1,20 +1,21 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import PageHeader from '../PageHeader'
import HostOverview from './HostOverview'
import LocalRunningModels from './LocalRunningModels'
import { useLocalMachine } from '../../hooks/useLocalMachine'
import { hostAsNode } from '../../utils/localHost'
import { summarizeFleet } from '../../utils/nodeFleet'
+import Icon from '../Icon'
+import '../../pages/operate.css'
-// What the Nodes page shows when distributed mode is off. It used to be only
-// an "enable distributed mode" card, which left a single-node install with no
-// page listing what was loaded and no way to stop it short of the API. The
-// host is treated as a fleet of one so the gauges and the running-models table
-// match what a cluster operator sees; the distributed setup moves behind a
-// button, since most single-node installs are single-node on purpose.
+// What the Nodes route shows when distributed mode is off: the host and what is
+// loaded on it. The host is treated as a fleet of one so the capacity readings
+// and the running-models table match what a cluster operator sees. The setup
+// for more than one machine stays behind a button, since most single-node
+// installs are single-node on purpose.
export default function LocalMachineView({ addToast, scaleOut }) {
- const { t } = useTranslation('admin')
+ const { t } = useTranslation('operate')
const machine = useLocalMachine()
const [showScaleOut, setShowScaleOut] = useState(false)
const summary = useMemo(() => {
@@ -23,13 +24,23 @@ export default function LocalMachineView({ addToast, scaleOut }) {
}, [machine.resources])
return (
-
-
setShowScaleOut(value => !value)}>
- {showScaleOut ? t('localMachine.hideScaleOut') : t('localMachine.scaleOut')}
- } />
+
+
+
+
{t('machine.title')}
+
{t('machine.subtitle')}
+
+ setShowScaleOut(value => !value)}
+ >
+ {showScaleOut ? t('machine.hideScaleOut') : t('machine.scaleOut')}
+
+
{showScaleOut && scaleOut}
-
+
)
diff --git a/core/http/react-ui/src/components/nodes/LocalModelTable.jsx b/core/http/react-ui/src/components/nodes/LocalModelTable.jsx
index 81f7475dc..3040b3fc0 100644
--- a/core/http/react-ui/src/components/nodes/LocalModelTable.jsx
+++ b/core/http/react-ui/src/components/nodes/LocalModelTable.jsx
@@ -1,84 +1,125 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { useTranslation } from 'react-i18next'
import ActionMenu from '../ActionMenu'
-import { SortButton } from './ModelFleetTable'
+import Icon from '../Icon'
import { formatBytes } from './nodeStatus'
import { uptime } from '../../utils/localHost'
+import { cssVars } from '../../utils/modelLedger'
-// The single-node counterpart of ModelFleetTable. A cluster row is about
-// placement (replicas, nodes, in-flight work); a row here is about one
-// process on this host, so the columns are what that process is costing.
+// The models this host has in memory, one row each. A row here is one backend
+// process, so the columns say what that process costs: its resident memory (the
+// host's RAM, not GPU memory, which LocalAI does not report per model), its
+// share of CPU, and how long it has been up.
-function UsageCell({ percent, label, tone, title, emptyLabel = 'No data' }) {
- if (percent == null) return {emptyLabel}
+function SortHead({ column, label, sort, onSortChange, className, t }) {
+ const active = sort.key === column
+ const nextDirection = active && sort.direction === 'asc' ? 'desc' : 'asc'
+ return (
+
+ onSortChange({ key: column, direction: nextDirection })}
+ aria-label={t('machine.sortBy', { column: label.toLowerCase() }) + (active ? `, ${sort.direction}ending` : '')}
+ >
+ {label} {active && }
+
+
+ )
+}
+
+function Usage({ percent, label, title, emptyLabel }) {
+ if (percent == null) return {emptyLabel}
const clamped = Math.min(100, Math.max(0, percent))
return (
-
+
+
+
+
+ {label}
+
)
}
export default function LocalModelTable({ models, onViewLogs, onStop, stoppingName, sort, onSortChange, now = Date.now() }) {
+ const { t } = useTranslation('operate')
return (
-
-
-
-
-
-
-
-
- Actions
-
- {models.map(model => {
- const up = uptime(model.started_at, now)
- const stopping = stoppingName === model.model_name
- return (
-
-
- {model.model_name}
- {model.pid != null && PID {model.pid} }
-
- {model.backend ? {model.backend} : Unknown }
-
-
-
-
- {/* CPU is a delta between two server readings, so a process seen
- for the first time has none yet; that is not "no data". */}
-
-
- {up ?? Unknown }
-
- onViewLogs(model),
- }, {
- divider: true,
- }, {
- key: 'stop',
- icon: 'fa-stop',
- label: stopping ? 'Stopping…' : 'Stop model…',
- danger: true,
- disabled: !!stoppingName,
- onClick: invoker => onStop(model, invoker),
- }]}
- />
-
-
- )
- })}
+
+
+
+
+
+
+
+
+
+ {t('machine.columns.actions')}
+
+
+
+ {models.map(model => {
+ const up = uptime(model.started_at, now)
+ const stopping = stoppingName === model.model_name
+ return (
+
+
+ {model.model_name}
+ {model.pid != null && PID {model.pid} }
+
+ {model.backend || {t('machine.unknown')} }
+
+
+
+
+ {/* CPU is a delta between two server readings, so a process
+ seen for the first time has none yet; that is not "no data". */}
+
+
+ {up ?? {t('machine.unknown')} }
+
+ onViewLogs(model),
+ }, {
+ divider: true,
+ }, {
+ key: 'stop',
+ icon: 'stop',
+ label: stopping ? t('machine.stopping') : t('machine.stopModel'),
+ danger: true,
+ disabled: !!stoppingName,
+ onClick: invoker => onStop(model, invoker),
+ }]}
+ />
+
+
+ )
+ })}
+
)
diff --git a/core/http/react-ui/src/components/nodes/LocalRunningModels.jsx b/core/http/react-ui/src/components/nodes/LocalRunningModels.jsx
index 22ec8583d..fcafa106c 100644
--- a/core/http/react-ui/src/components/nodes/LocalRunningModels.jsx
+++ b/core/http/react-ui/src/components/nodes/LocalRunningModels.jsx
@@ -1,18 +1,23 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { useMemo, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
import { backendControlApi } from '../../utils/api'
import { filterLocalModels, sortLocalModels } from '../../utils/localHost'
import ConfirmDialog from '../ConfirmDialog'
import LoadingSpinner from '../LoadingSpinner'
import LocalModelTable from './LocalModelTable'
+import Icon from '../Icon'
// Running models on this machine: search, sort, logs and stop. Takes the
-// polled data from useLocalMachine rather than fetching it, so a page that
-// also draws gauges from the same poll does not ask twice.
+// polled data from useLocalMachine (or the Operate summary) rather than
+// fetching it, so a page that also draws capacity from the same poll does not
+// ask twice.
//
-// `limit` turns it into a preview (the Operate overview): the heaviest models
+// `limit` turns it into a preview (the Status page): the heaviest models
// first, no search, and a link to the full view.
export default function LocalRunningModels({ machine, addToast, limit, moreHref }) {
+ const { t } = useTranslation('operate')
const navigate = useNavigate()
const { rows, state, error, refresh } = machine
const [query, setQuery] = useState('')
@@ -44,11 +49,11 @@ export default function LocalRunningModels({ machine, addToast, limit, moreHref
setStoppingName(model.model_name)
try {
await backendControlApi.shutdown({ model: model.model_name })
- addToast?.(`Stopped ${model.model_name}`, 'success')
+ addToast?.(t('unload.done', { name: model.model_name }), 'success')
} catch (err) {
- addToast?.(`Could not stop ${model.model_name}: ${err.message || err}`, 'error')
+ addToast?.(t('unload.failed', { name: model.model_name, message: err.message || err }), 'error')
} finally {
- await refresh()
+ await refresh?.()
setConfirmStop(null)
setStoppingName(null)
stoppingRef.current = false
@@ -58,36 +63,77 @@ export default function LocalRunningModels({ machine, addToast, limit, moreHref
const hidden = limit ? Math.max(0, rows.length - visible.length) : 0
return (
-
- {/* A preview sits under its own section heading, which says this. */}
- {!limit &&
-
Running models Loaded on this machine, with the memory and CPU each backend process is using
- {state === 'loaded' &&
{rows.length} running }
-
}
- {state === 'loading' &&
Loading running models…
}
- {state === 'error' &&
Unable to load running models {error} refresh()}>Retry
}
- {state === 'loaded' && rows.length === 0 && (
-
-
-
No models running
-
A model loads on its first request, or when you start it from Models. It will appear here while it is in memory.
+
+ {!limit && (
+
+
+ {t('machine.runningTitle')}
+ {t('machine.runningBody')}
+
+ {state === 'loaded' &&
{t('machine.runningCount', { count: rows.length })} }
)}
- {state === 'loaded' && rows.length > 0 && <>
- {!limit &&
setQuery(event.target.value)} />
}
-
navigate(`/app/backend-logs/${encodeURIComponent(model.model_name)}`)}
- onStop={promptStop} />
- {moreHref && (
-
- {hidden > 0 && {hidden} more not shown }
- Open this machine
-
- )}
- >}
-
+ {state === 'loading' && (
+ {t('machine.loading')}
+ )}
+ {state === 'error' && (
+
+
+ {t('machine.loadFailed')}
+ {error}
+ refresh?.()}>{t('machine.retry')}
+
+ )}
+ {state === 'loaded' && rows.length === 0 && (
+
+
+ {t('machine.emptyTitle')}
+
+ {t('machine.emptyBefore')} {t('machine.emptyLink')}. {t('machine.emptyAfter')}
+
+
+ )}
+ {state === 'loaded' && rows.length > 0 && (
+ <>
+ {!limit && (
+
+ setQuery(event.target.value)}
+ />
+
+ )}
+ navigate(`/app/backend-logs/${encodeURIComponent(model.model_name)}`)}
+ onStop={promptStop}
+ />
+ {moreHref && (
+
+ {hidden > 0 && {t('machine.moreHidden', { count: hidden })} }
+ {t('machine.openMachine')}
+
+ )}
+ >
+ )}
+
)
}
diff --git a/core/http/react-ui/src/components/nodes/ModelFleetTable.jsx b/core/http/react-ui/src/components/nodes/ModelFleetTable.jsx
deleted file mode 100644
index 238208748..000000000
--- a/core/http/react-ui/src/components/nodes/ModelFleetTable.jsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { timeAgo } from './nodeStatus'
-import ActionMenu from '../ActionMenu'
-
-export function SortButton({ column, label, sort, onSortChange }) {
- const active = sort.key === column
- const nextDirection = active && sort.direction === 'asc' ? 'desc' : 'asc'
- return (
-
onSortChange({ key: column, direction: nextDirection })}
- aria-label={`Sort by ${label.toLowerCase()}${active ? `, ${sort.direction}ending` : ''}`}>
- {label} {active && }
-
- )
-}
-
-export default function ModelFleetTable({ models, selectedName, inspectorOpen, onInspect, onViewLogs, onStop, stoppingName, sort, onSortChange }) {
- return (
-
-
-
-
-
-
-
- Backends
-
- Actions
-
- {models.map(model => {
- const selected = selectedName === model.model_name
- const expanded = selected && inspectorOpen
- return (
-
- onInspect(model, event.currentTarget)}>{model.model_name}
- {model.replica_count}
- {model.node_count}
- {model.in_flight}
- {model.backend_types.length ? model.backend_types.map(backend => {backend} ) : Unknown }
- {model.last_used ? timeAgo(model.last_used) : Never }
-
- onViewLogs(model, invoker),
- }, {
- divider: true,
- }, {
- key: 'stop',
- icon: 'fa-stop',
- label: stoppingName === model.model_name ? 'Stopping…' : 'Stop model…',
- danger: true,
- disabled: !!stoppingName,
- onClick: invoker => onStop(model, invoker),
- }]}
- />
-
-
- )
- })}
-
-
- )
-}
diff --git a/core/http/react-ui/src/components/nodes/ModelInspector.jsx b/core/http/react-ui/src/components/nodes/ModelInspector.jsx
deleted file mode 100644
index 6c058d478..000000000
--- a/core/http/react-ui/src/components/nodes/ModelInspector.jsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import { useEffect, useRef } from 'react'
-import StatusPill from './StatusPill'
-import { timeAgo } from './nodeStatus'
-import useInspectorDrawer from './useInspectorDrawer'
-
-function groupReplicasByNode(replicas, nodes) {
- const nodeById = new Map(nodes.map(node => [node.id, node]))
- const groups = new Map()
- replicas.forEach(replica => {
- const nodeId = String(replica?.node_id ?? '').trim()
- const key = nodeId || `unknown:${replica?.id ?? groups.size}`
- if (!groups.has(key)) groups.set(key, { nodeId, node: nodeById.get(nodeId) || null, replicas: [] })
- groups.get(key).replicas.push(replica)
- })
- return [...groups.values()]
-}
-
-function latestUse(replicas) {
- const values = replicas.map(replica => ({ value: replica.last_used, time: Date.parse(replica.last_used) })).filter(entry => Number.isFinite(entry.time))
- return values.length ? values.sort((left, right) => right.time - left.time)[0].value : null
-}
-
-export default function ModelInspector({ model, nodes, open, onClose, onOpenNode, onViewLogs, focusNodeId }) {
- const closeRef = useRef(null)
- const drawerRef = useRef(null)
- const nodeButtonRefs = useRef(new Map())
- const modal = useInspectorDrawer(open, onClose, drawerRef)
-
- useEffect(() => {
- if (open) closeRef.current?.focus()
- }, [open, model?.model_name])
-
- useEffect(() => {
- if (open && focusNodeId) nodeButtonRefs.current.get(focusNodeId)?.focus()
- }, [open, focusNodeId])
-
- if (!open || !model) return null
- const nodeGroups = groupReplicasByNode(model.replicas, nodes)
-
- return <>
-
-
- >
-}
diff --git a/core/http/react-ui/src/components/nodes/NodeFleetTable.jsx b/core/http/react-ui/src/components/nodes/NodeFleetTable.jsx
deleted file mode 100644
index fdec881b7..000000000
--- a/core/http/react-ui/src/components/nodes/NodeFleetTable.jsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import StatusPill from './StatusPill'
-import { formatBytes, timeAgo } from './nodeStatus'
-import { capacityReading, groupNodes, nodeLifecycleAction } from '../../utils/nodeFleet'
-
-function MetricCell({ total, available, tone }) {
- const reading = capacityReading(total, available)
- if (!reading) return
No data
- const percent = Math.round(reading.usagePercent)
- return (
-
-
-
{formatBytes(reading.used)} / {formatBytes(reading.total)}
-
- )
-}
-
-function CapacityCell({ node }) {
- return
-}
-
-function SortButton({ column, label, sort, onSortChange }) {
- const active = sort.key === column
- const nextDirection = active && sort.direction === 'asc' ? 'desc' : 'asc'
- return (
-
onSortChange({ key: column, direction: nextDirection })}
- aria-label={`Sort by ${label.toLowerCase()}${active ? `, ${sort.direction}ending` : ''}`}>
- {label} {active && }
-
- )
-}
-
-export default function NodeFleetTable({ nodes, selectedIds, onSelectionChange, onInspect, sort, onSortChange, groupBy, onApprove }) {
- const groups = groupNodes(nodes, groupBy)
- const visibleIds = nodes.map(node => node.id)
- const selectedVisible = visibleIds.filter(id => selectedIds.has(id)).length
- const setMany = (ids, selected) => {
- const next = new Set(selectedIds)
- ids.forEach(id => selected ? next.add(id) : next.delete(id))
- onSelectionChange(next)
- }
-
- return (
-
-
- {nodes.length === 0 &&
No nodes match the current view.
}
-
- )
-}
diff --git a/core/http/react-ui/src/components/nodes/NodeInspector.jsx b/core/http/react-ui/src/components/nodes/NodeInspector.jsx
deleted file mode 100644
index 51860bf5e..000000000
--- a/core/http/react-ui/src/components/nodes/NodeInspector.jsx
+++ /dev/null
@@ -1,143 +0,0 @@
-import { useEffect, useRef, useState } from 'react'
-import StatusPill from './StatusPill'
-import { formatBytes, formatCapacity, timeAgo, modelStateConfig } from './nodeStatus'
-import { nodesApi } from '../../utils/api'
-import { capacityReading, nodeLifecycleAction } from '../../utils/nodeFleet'
-import useInspectorDrawer from './useInspectorDrawer'
-
-function InspectorMetric({ label, children }) {
- return
{label} {children}
-}
-
-function ResourceBar({ label, total, available, tone }) {
- const reading = capacityReading(total, available)
- if (!reading) return
No data
- const percent = Math.round(reading.usagePercent)
- return (
-
-
{label} {formatBytes(reading.used)} / {formatBytes(reading.total)} {formatBytes(reading.available)} free
-
-
- )
-}
-
-export default function NodeInspector({ node, open, onClose, onApprove, onDrain, onResume, onBack, backLabel }) {
- const [backends, setBackends] = useState(null)
- const [backendError, setBackendError] = useState('')
- const [models, setModels] = useState(null)
- const [modelError, setModelError] = useState('')
- const nodeId = node?.id
- const backRef = useRef(null)
- const closeRef = useRef(null)
- const drawerRef = useRef(null)
- const hasBack = Boolean(onBack)
- const modal = useInspectorDrawer(open, onClose, drawerRef)
-
- useEffect(() => {
- if (open) (hasBack ? backRef : closeRef).current?.focus()
- }, [open, nodeId, hasBack])
-
- useEffect(() => {
- if (!open || !nodeId) return undefined
- let current = true
- setBackends(null)
- setBackendError('')
- nodesApi.getBackends(nodeId).then(data => {
- if (current) setBackends(Array.isArray(data) ? data : [])
- }).catch(error => {
- if (current) setBackendError(error.message || 'Unable to load backends')
- })
- return () => { current = false }
- }, [open, nodeId])
-
- useEffect(() => {
- if (!open || !nodeId) return undefined
- let current = true
- setModels(null)
- setModelError('')
- nodesApi.getModels(nodeId).then(data => {
- if (current) setModels(Array.isArray(data) ? data : [])
- }).catch(error => {
- if (current) setModelError(error.message || 'Unable to load models')
- })
- return () => { current = false }
- }, [open, nodeId])
-
- if (!open || !node) return null
- const cpuKnown = node.cpu_logical_cores > 0 && Number.isFinite(node.cpu_usage_percent) && Number.isFinite(node.cpu_load_1)
- const disk = capacityReading(node.total_disk, node.available_disk)
- const lifecycleAction = nodeLifecycleAction(node.status)
-
- return <>
-
-
- >
-}
diff --git a/core/http/react-ui/src/components/nodes/NodePanel.jsx b/core/http/react-ui/src/components/nodes/NodePanel.jsx
deleted file mode 100644
index 623db0093..000000000
--- a/core/http/react-ui/src/components/nodes/NodePanel.jsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import { useNavigate } from 'react-router-dom'
-import StatusPill from './StatusPill'
-import ModelChip from './ModelChip'
-import ActionMenu from '../ActionMenu'
-import { formatVRAM } from './nodeStatus'
-
-export default function NodePanel({ node, models = [], onApprove, onDrain, onResume, onRemove }) {
- const navigate = useNavigate()
- const isAgent = node.node_type === 'agent'
- const open = () => navigate(`/app/nodes/${node.id}`)
- const usedVRAM = node.total_vram && node.available_vram != null ? node.total_vram - node.available_vram : null
- const usedRAM = node.total_ram && node.available_ram != null ? node.total_ram - node.available_ram : null
-
- return (
-
-
{ if (e.key === 'Enter') open() }}>
-
-
-
- {node.name}
- {node.address}
-
-
e.stopPropagation()}>
- {node.status === 'pending' && (
-
onApprove(node.id)}>
- Approve
-
- )}
-
onResume(node.id) },
- { key: 'drain', icon: 'fa-pause', label: 'Drain', hidden: node.status === 'draining' || node.status === 'pending', onClick: () => onDrain(node.id) },
- { divider: true, hidden: node.status === 'pending' },
- { key: 'remove', icon: 'fa-trash', label: 'Remove from cluster', danger: true, onClick: () => onRemove(node) },
- ]}
- />
-
-
-
- {!isAgent && (
- <>
-
- {node.total_vram > 0 && (
- VRAM {formatVRAM(usedVRAM) || '0'} / {formatVRAM(node.total_vram)}
- )}
- {node.total_ram > 0 && (
- RAM {formatVRAM(usedRAM) || '0'} / {formatVRAM(node.total_ram)}
- )}
- {node.in_flight_count || 0} in-flight
-
-
- {models.length === 0
- ? No models loaded
- : models.map(m => )}
-
- >
- )}
-
-
- )
-}
diff --git a/core/http/react-ui/src/components/nodes/StatusPill.jsx b/core/http/react-ui/src/components/nodes/StatusPill.jsx
deleted file mode 100644
index 4e4c4c5b5..000000000
--- a/core/http/react-ui/src/components/nodes/StatusPill.jsx
+++ /dev/null
@@ -1,11 +0,0 @@
-import { statusConfig } from './nodeStatus'
-
-export default function StatusPill({ status }) {
- const cfg = statusConfig[status] || statusConfig.unhealthy
- return (
-
-
- {cfg.label}
-
- )
-}
diff --git a/core/http/react-ui/src/components/nodes/useInspectorDrawer.js b/core/http/react-ui/src/components/nodes/useInspectorDrawer.js
deleted file mode 100644
index 723035772..000000000
--- a/core/http/react-ui/src/components/nodes/useInspectorDrawer.js
+++ /dev/null
@@ -1,90 +0,0 @@
-import { useEffect, useRef, useState } from 'react'
-
-const MOBILE_DRAWER_QUERY = '(max-width: 768px)'
-const FOCUSABLE = 'button:not(:disabled), [href], input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex]:not([tabindex="-1"])'
-
-function isolateBackground(drawer) {
- const changed = new Map()
- let current = drawer
-
- while (current?.parentElement && current.parentElement !== document.documentElement) {
- const parent = current.parentElement
- for (const sibling of parent.children) {
- if (sibling === current || sibling.classList.contains('node-inspector__scrim') || changed.has(sibling)) continue
- changed.set(sibling, {
- ariaHidden: sibling.getAttribute('aria-hidden'),
- inert: sibling.hasAttribute('inert'),
- })
- sibling.setAttribute('aria-hidden', 'true')
- sibling.setAttribute('inert', '')
- }
- current = parent
- }
-
- return () => {
- for (const [element, previous] of changed) {
- if (previous.ariaHidden === null) element.removeAttribute('aria-hidden')
- else element.setAttribute('aria-hidden', previous.ariaHidden)
- if (!previous.inert) element.removeAttribute('inert')
- }
- }
-}
-
-export default function useInspectorDrawer(open, onClose, drawerRef) {
- const onCloseRef = useRef(onClose)
- const [modal, setModal] = useState(() => typeof window !== 'undefined' && window.matchMedia(MOBILE_DRAWER_QUERY).matches)
-
- useEffect(() => { onCloseRef.current = onClose }, [onClose])
-
- useEffect(() => {
- const media = window.matchMedia(MOBILE_DRAWER_QUERY)
- const update = event => setModal(event.matches)
- setModal(media.matches)
- media.addEventListener('change', update)
- return () => media.removeEventListener('change', update)
- }, [])
-
- useEffect(() => {
- if (!open) return undefined
- const drawer = drawerRef.current
- if (!drawer) return undefined
- const previousOverflow = document.body.style.overflow
- const restoreBackground = modal ? isolateBackground(drawer) : () => {}
- if (modal) document.body.style.overflow = 'hidden'
-
- const handleKeyDown = event => {
- const blockingModal = [...document.querySelectorAll('[aria-modal="true"]')].some(element => element !== drawer)
- if (event.defaultPrevented || blockingModal) return
- if (event.key === 'Escape') {
- event.preventDefault()
- onCloseRef.current()
- return
- }
- if (!modal || event.key !== 'Tab') return
- const focusable = [...drawer.querySelectorAll(FOCUSABLE)].filter(element => element.getClientRects().length > 0)
- if (!focusable.length) {
- event.preventDefault()
- drawer.focus()
- return
- }
- const first = focusable[0]
- const last = focusable[focusable.length - 1]
- if (event.shiftKey && (document.activeElement === first || !drawer.contains(document.activeElement))) {
- event.preventDefault()
- last.focus()
- } else if (!event.shiftKey && (document.activeElement === last || !drawer.contains(document.activeElement))) {
- event.preventDefault()
- first.focus()
- }
- }
-
- document.addEventListener('keydown', handleKeyDown)
- return () => {
- document.removeEventListener('keydown', handleKeyDown)
- restoreBackground()
- if (modal) document.body.style.overflow = previousOverflow
- }
- }, [drawerRef, modal, open])
-
- return modal
-}
diff --git a/core/http/react-ui/src/components/operate/BackendsTable.jsx b/core/http/react-ui/src/components/operate/BackendsTable.jsx
new file mode 100644
index 000000000..db148b358
--- /dev/null
+++ b/core/http/react-ui/src/components/operate/BackendsTable.jsx
@@ -0,0 +1,150 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { useId } from 'react'
+import { useTranslation } from 'react-i18next'
+import { cssVars } from '../../utils/modelLedger'
+import { versionLabel } from '../../utils/backendRows'
+import Icon from '../Icon'
+
+function SortHead({ col, label, sort, order, onSort, className }) {
+ const active = sort === col
+ return (
+
+ onSort(col)}>
+ {label}
+
+
+
+ )
+}
+
+// The state of a backend as words, a mark and, while an install runs, a bar.
+// Colour never says it alone: every state has a word.
+export function StateCell({ state }) {
+ const { t } = useTranslation('operate')
+ switch (state.kind) {
+ case 'installing':
+ return (
+
+ 0 ? undefined : ''}
+ >
+
+
+ {state.progress > 0 ? `${state.progress}%` : t('backends.state.starting')}
+
+ )
+ case 'queued':
+ return
{t('backends.state.queued')}
+ case 'removing':
+ return
{t('backends.state.removing')}
+ case 'failed':
+ return
{t('backends.state.failed')}
+ case 'update':
+ return (
+
+ {state.to ? t('backends.state.updateTo', { version: state.to }) : t('backends.state.update')}
+
+ )
+ case 'current':
+ return
{t('backends.state.current')}
+ default:
+ return
{t('backends.state.absent')}
+ }
+}
+
+// One table for both lists. A row is a name, a version, a state and the one
+// thing to do about it; the rest opens in a detail row under it. The open row
+// is the selected backend, so it lives in the URL and survives a reload.
+//
+// rows [{ name, description, version, state, nodes, badge, action, detail }]
+export default function BackendsTable({
+ rows, expanded, onToggle, showNodes, sort, order, onSort, caption, busy, children,
+}) {
+ const { t } = useTranslation('operate')
+ const baseId = useId()
+ const cols = 4 + (showNodes ? 1 : 0)
+ return (
+
+
+ {caption}
+
+
+ {onSort
+ ?
+ : {t('backends.columns.backend')} }
+ {t('backends.columns.version')}
+ {onSort
+ ?
+ : {t('backends.columns.state')} }
+ {showNodes && {t('backends.columns.nodes')} }
+ {t('backends.columns.actions')}
+
+
+
+ {rows.flatMap((row, index) => {
+ const open = expanded === row.name
+ const detailId = `${baseId}-d${index}`
+ const main = (
+ onToggle(row.name)}
+ >
+
+ {row.name}
+
+ {row.state.kind === 'failed' ? row.state.error : (row.description || ' ')}
+
+
+ {versionLabel(row.version) || '—'}
+ {row.badge}
+ {showNodes && {row.nodes} }
+
+ {/* A control in the row must not also open the row. */}
+ event.stopPropagation()} onKeyDown={event => event.stopPropagation()}>
+ {row.action}
+ onToggle(row.name)}
+ >
+
+
+
+
+
+ )
+ const detail = (
+
+
+
+
+
{open ? row.detail : null}
+
+
+
+
+ )
+ return [main, detail]
+ })}
+ {children}
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/operate/CapacityChart.jsx b/core/http/react-ui/src/components/operate/CapacityChart.jsx
new file mode 100644
index 000000000..47e84ff08
--- /dev/null
+++ b/core/http/react-ui/src/components/operate/CapacityChart.jsx
@@ -0,0 +1,176 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { chartGeometry } from '../../utils/operateStatus'
+import { cssVars, gbLabel } from '../../utils/modelLedger'
+
+function clock(t, withSeconds) {
+ return new Date(t).toLocaleTimeString([], withSeconds
+ ? { hour: '2-digit', minute: '2-digit', second: '2-digit' }
+ : { hour: '2-digit', minute: '2-digit' })
+}
+
+// How long the samples cover, in words that match what the axis shows.
+function spanWords(ms, t) {
+ const minutes = Math.max(1, Math.round(ms / 60_000))
+ if (minutes < 60) return t('chart.spanMinutes', { count: minutes })
+ return t('chart.spanHours', { value: (ms / 3_600_000).toFixed(1) })
+}
+
+// The memory pool as it is now (one bar, solid, with the capacity as its
+// edge) and, once there are two readings, as it has been since the page was
+// opened. LocalAI stores no memory history, so the chart says what it is: the
+// page's own readings. The axis starts at zero, the capacity is a labelled
+// line, the line is labelled at its end, and a data table sits behind it.
+export default function CapacityChart({ memory, samples, labelKey, scopeKey = 'chart.scope', waitingKey = 'chart.waiting' }) {
+ const { t } = useTranslation('operate')
+ const figureRef = useRef(null)
+ // The plot is drawn at the width it is shown at, so its text keeps its size
+ // from a phone to a wide screen instead of scaling with a fixed box.
+ const [width, setWidth] = useState(640)
+ const hasChart = Array.isArray(samples) && samples.length >= 2
+ useEffect(() => {
+ const el = figureRef.current
+ if (!hasChart || !el) return undefined
+ const measure = () => {
+ const w = Math.round(el.getBoundingClientRect().width)
+ if (w > 0) setWidth(Math.max(300, Math.min(920, w)))
+ }
+ measure()
+ if (typeof ResizeObserver === 'undefined') return undefined
+ const observer = new ResizeObserver(measure)
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [hasChart])
+ const geometry = useMemo(() => chartGeometry(samples, {
+ width,
+ height: width < 520 ? 190 : 220,
+ left: 50,
+ right: 92,
+ }), [samples, width])
+ const [cursor, setCursor] = useState(null)
+
+ if (!memory) return null
+ const pct = Math.round(memory.pct)
+ const title = t(labelKey)
+ const withSeconds = geometry ? geometry.spanMs < 10 * 60_000 : false
+ const active = cursor != null && geometry ? geometry.points[Math.min(cursor, geometry.points.length - 1)] : null
+
+ const move = (event) => {
+ if (!geometry) return
+ const n = geometry.points.length
+ const at = cursor == null ? n - 1 : cursor
+ if (event.key === 'ArrowLeft') setCursor(Math.max(0, at - 1))
+ else if (event.key === 'ArrowRight') setCursor(Math.min(n - 1, at + 1))
+ else if (event.key === 'Home') setCursor(0)
+ else if (event.key === 'End') setCursor(n - 1)
+ else if (event.key === 'Escape') setCursor(null)
+ else return
+ event.preventDefault()
+ }
+
+ const hover = (event) => {
+ if (!geometry) return
+ const box = event.currentTarget.getBoundingClientRect()
+ const x = ((event.clientX - box.left) / box.width) * geometry.width
+ let best = 0
+ geometry.points.forEach((p, i) => { if (Math.abs(p.x - x) < Math.abs(geometry.points[best].x - x)) best = i })
+ setCursor(best)
+ }
+
+ return (
+
+
+ {title}
+ {t(scopeKey)}
+
+
+
+
+ = 97 ? ' dk-meter-seg--error' : pct >= 90 ? ' dk-meter-seg--warn' : ''}`}
+ style={cssVars({ '--dk-w': `${Math.min(100, memory.pct).toFixed(1)}%` })}
+ />
+
+
+ {gbLabel(memory.used)} {t('chart.of', { total: gbLabel(memory.total) })}
+ · {pct}%
+
+
+
+ {geometry ? (
+
+
+ {active
+ ? {clock(active.t, true)} · {gbLabel(active.used)}
+ : {t('chart.hint', { span: spanWords(geometry.spanMs, t) })} }
+
+ setCursor(null)}
+ onBlur={() => setCursor(null)}
+ >
+
+
+ 0
+ {t('chart.gbTick', { value: Math.round(geometry.mid / 1024 ** 3) })}
+
+
+ {t('chart.capacity', { value: gbLabel(geometry.capacity) })}
+
+ {geometry.xTicks.map(tick => (
+ {clock(tick.t, withSeconds)}
+ ))}
+
+
+
+ {t('chart.now', { value: gbLabel(geometry.end.used) })}
+
+ {active && (
+ <>
+
+
+ >
+ )}
+
+
+ {t('chart.dataTable')}
+
+
+
+
+ {t('chart.time')}
+ {t('chart.inUse')}
+ {t('chart.capacityColumn')}
+
+
+
+ {[...samples].reverse().map(sample => (
+
+ {clock(sample.t, true)}
+ {gbLabel(sample.used)}
+ {gbLabel(sample.total)}
+
+ ))}
+
+
+
+
+
+ ) : (
+ {t(waitingKey)}
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/operate/LedgerRow.jsx b/core/http/react-ui/src/components/operate/LedgerRow.jsx
new file mode 100644
index 000000000..0bed1d20b
--- /dev/null
+++ b/core/http/react-ui/src/components/operate/LedgerRow.jsx
@@ -0,0 +1,45 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { useId } from 'react'
+import { useTranslation } from 'react-i18next'
+import Icon from '../Icon'
+
+const MARK = {
+ ok: 'check-circle',
+ warn: 'warning',
+ error: 'alert-circle',
+ idle: 'circle',
+ active: 'circle-dot',
+}
+
+// One row of the Status ledger: a mark, a name, one line, and a disclosure.
+// A row with a problem arrives open; every other row is one line. The state
+// is said in words as well as in the mark, so colour never carries it alone.
+export default function LedgerRow({ id, level, open, onToggle, title, summary, children, loading = false }) {
+ const { t } = useTranslation('operate')
+ const bodyId = useId()
+ return (
+
+
+
+ {title}
+ {summary}
+
+ {open ? t('status.close') : t('status.open')}
+
+
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/operate/useUnloadModel.jsx b/core/http/react-ui/src/components/operate/useUnloadModel.jsx
new file mode 100644
index 000000000..feb5c53c3
--- /dev/null
+++ b/core/http/react-ui/src/components/operate/useUnloadModel.jsx
@@ -0,0 +1,60 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { useCallback, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { backendControlApi } from '../../utils/api'
+import ConfirmDialog from '../ConfirmDialog'
+
+// Stopping a loaded model, with the same words and the same call as the
+// running-models table on This machine: ask first, then POST /backend/shutdown.
+// The next request that uses the model loads it again, which the dialog says.
+//
+// `onDone` runs after the call settles so the caller can read the models again.
+export function useUnloadModel({ addToast, onDone }) {
+ const { t } = useTranslation('operate')
+ const [target, setTarget] = useState(null)
+ const [busy, setBusy] = useState(false)
+ const busyRef = useRef(false)
+ const invokerRef = useRef(null)
+
+ const ask = useCallback((model, invoker) => {
+ invokerRef.current = invoker || null
+ setTarget(model)
+ }, [])
+
+ const cancel = useCallback(() => {
+ setTarget(null)
+ requestAnimationFrame(() => invokerRef.current?.focus())
+ }, [])
+
+ const confirm = useCallback(async () => {
+ if (!target || busyRef.current) return
+ busyRef.current = true
+ setBusy(true)
+ try {
+ await backendControlApi.shutdown({ model: target.id })
+ addToast?.(t('unload.done', { name: target.id }), 'success')
+ } catch (err) {
+ addToast?.(t('unload.failed', { name: target.id, message: err.message || String(err) }), 'error')
+ } finally {
+ await onDone?.()
+ setTarget(null)
+ setBusy(false)
+ busyRef.current = false
+ }
+ }, [target, addToast, onDone, t])
+
+ const dialog = (
+
+ )
+ return { ask, dialog, busy }
+}
diff --git a/core/http/react-ui/src/components/settings/HistorySheet.jsx b/core/http/react-ui/src/components/settings/HistorySheet.jsx
new file mode 100644
index 000000000..47eb0855b
--- /dev/null
+++ b/core/http/react-ui/src/components/settings/HistorySheet.jsx
@@ -0,0 +1,57 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import SideSheet from '../SideSheet'
+import { FIELD_BY_KEY } from '../../utils/settingsSchema'
+import { displayValue } from '../../utils/settingsPending'
+
+function when(at) {
+ const d = new Date(at)
+ const now = new Date()
+ const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+ if (d.toDateString() === now.toDateString()) return `Today ${time}`
+ return `${d.toLocaleDateString([], { day: 'numeric', month: 'short' })} ${time}`
+}
+
+// The settings changed from this browser, newest first. LocalAI keeps no
+// settings log of its own, so this is the browser's own list and it says so.
+// Revert puts the old value back as an edit; nothing applies until Apply.
+export default function HistorySheet({ entries, onRevert, onClear, onClose }) {
+ return (
+
0 ? (
+ Clear this list
+ ) : null}
+ >
+ {entries.length === 0 ? (
+
+
Nothing applied yet
+
Settings you apply from this browser are listed here, up to the last 50.
+
+ ) : (
+
+ {entries.map((e, i) => {
+ const field = FIELD_BY_KEY[e.key]
+ if (!field) return null
+ const canRevert = !field.sensitive && e.from !== null
+ return (
+
+
+ {field.label}
+
+ {field.sensitive ? 'changed' : <>{displayValue(field, e.from)} → {displayValue(field, e.to)}>}
+
+
+
+ {when(e.at)}
+ {canRevert && onRevert(e)}>Revert }
+
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/settings/PendingBar.jsx b/core/http/react-ui/src/components/settings/PendingBar.jsx
new file mode 100644
index 000000000..7c9819cf6
--- /dev/null
+++ b/core/http/react-ui/src/components/settings/PendingBar.jsx
@@ -0,0 +1,63 @@
+import Icon from '../Icon'
+import { displayValue, restartChanges } from '../../utils/settingsPending'
+
+// The bar that appears when the page holds edits the server has not seen. It
+// says how many, offers Discard, Show diff and Apply, and opens a diff of the
+// old and new values with the checks the browser can make on them.
+//
+// changes visible changes (see settingsPending.visibleChanges)
+// checks [{ level, text }]; an 'error' disables Apply
+export default function PendingBar({ changes, checks, saving, showDiff, onToggleDiff, onApply, onDiscard }) {
+ const restart = restartChanges(changes).length
+ const blocked = checks.some(c => c.level === 'error')
+ return (
+
+ {showDiff && (
+
+
+ {changes.map(({ field, from, to }) => (
+
+
+ {field.label}
+ {field.key}
+
+
+ {displayValue(field, from)}
+
+ {displayValue(field, to)}
+ {field.apply === 'restart'
+ ? Needs restart
+ : field.apply === 'live' ? Applies now : null}
+
+
+ ))}
+
+ {checks.length > 0 && (
+
+ {checks.map((c, i) => (
+
+
+ {c.text}
+
+ ))}
+
+ )}
+
+ )}
+
+
+ {changes.length} pending
+ {restart > 0 && · {restart} need{restart === 1 ? 's' : ''} a restart }
+ {blocked && · fix the errors to apply }
+
+
+ Discard
+ {showDiff ? 'Hide diff' : 'Show diff'}
+
+ {saving ? 'Applying…' : 'Apply'}
+
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/settings/SettingField.jsx b/core/http/react-ui/src/components/settings/SettingField.jsx
new file mode 100644
index 000000000..98ff41e25
--- /dev/null
+++ b/core/http/react-ui/src/components/settings/SettingField.jsx
@@ -0,0 +1,193 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import SearchableModelSelect from '../SearchableModelSelect'
+import Icon from '../Icon'
+import { CAP_CHAT } from '../../utils/capabilities'
+import {
+ FIELD_BY_KEY, getValue, setValue, isChanged, hasDefault, groupOf, OLD_SECTIONS,
+} from '../../utils/settingsSchema'
+import { displayValue } from '../../utils/settingsPending'
+
+// What applying the field does, in words. Only a field the code speaks about
+// has a hint (see settingsSchema.js); the others say nothing.
+function ApplyHint({ field }) {
+ if (field.apply === 'restart') {
+ return
Needs restart
+ }
+ if (field.apply === 'live') {
+ return
Applies now
+ }
+ return null
+}
+
+function coerceInt(field, text) {
+ const floor = field.min ?? 0
+ return Math.max(floor, parseInt(text, 10) || floor)
+}
+
+function Control({ field, settings, onChange, id, labelId, disabled, assets }) {
+ const value = getValue(field, settings)
+ const set = (v) => onChange(setValue(field, settings, v))
+
+ switch (field.kind) {
+ case 'bool':
+ return (
+
set(!value)}
+ />
+ )
+ case 'int':
+ return (
+ set(coerceInt(field, e.target.value))}
+ />
+ )
+ case 'duration':
+ return (
+ set(e.target.value)}
+ />
+ )
+ case 'percent': {
+ const pct = Math.round(Number(value) * 100)
+ return (
+
+ set(parseInt(e.target.value, 10) / 100)}
+ />
+ {pct}%
+
+ )
+ }
+ case 'select':
+ return (
+
+ set(e.target.value)}>
+ {field.options.map(o => {o} )}
+
+
+ )
+ case 'model':
+ return (
+
+
+
+ )
+ case 'json':
+ case 'lines':
+ return (
+ set(e.target.value)}
+ />
+ )
+ case 'token':
+ return (
+
+ set(e.target.value)}
+ />
+ set('0')}>
+ Generate
+
+ {value && (
+ set('')}>
+
+
+ )}
+
+ )
+ case 'asset': {
+ const a = assets.state(field.asset)
+ return (
+
+
+ {a.url ? : }
+
+
+ {a.busy ? 'Uploading…' : 'Upload'}
+ {
+ const file = e.target.files?.[0]
+ e.target.value = ''
+ if (file) assets.upload(field.asset, file)
+ }}
+ />
+
+ {a.custom && (
+
assets.reset(field.asset)}>
+ Reset
+
+ )}
+
+ )
+ }
+ default:
+ return (
+ set(e.target.value)}
+ />
+ )
+ }
+}
+
+// One setting: its name and words on the left, its control on the right, and
+// under the words what is worth knowing (where it was, its key, when it
+// applies, whether it differs from the default and how to put that back).
+export default function SettingField({ field, settings, onChange, assets, showWhere, pending }) {
+ const id = `st-${field.key.replace(/[^a-z0-9_]/gi, '-')}`
+ const labelId = `${id}-label`
+ const needsOn = !field.needs || !!getValue(FIELD_BY_KEY[field.needs], settings)
+ const needsValue = !field.needsValue || getValue(FIELD_BY_KEY[field.needsValue[0]], settings) === field.needsValue[1]
+ const disabled = !needsOn || !needsValue
+ const changed = isChanged(field, settings)
+ const wide = field.kind === 'json' || field.kind === 'lines'
+ const where = groupOf(field.group)?.label
+ const was = OLD_SECTIONS[field.was]
+
+ return (
+
+
+
+ {field.label}
+ {pending && Draft }
+ {changed && Changed }
+
+
{field.desc}
+
+ {showWhere &&
{where}{was && was !== where ? <> · was {was}> : null} }
+ {!field.asset &&
{field.key}}
+
+ {changed && hasDefault(field) && (
+
+ default {displayValue(field, field.default)}
+ {' · '}
+ onChange(setValue(field, settings, field.default))}>Reset
+
+ )}
+ {disabled &&
Needs the setting above it }
+
+
+
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/split/DetailHeader.jsx b/core/http/react-ui/src/components/split/DetailHeader.jsx
index 5fbb3ae08..56a24900b 100644
--- a/core/http/react-ui/src/components/split/DetailHeader.jsx
+++ b/core/http/react-ui/src/components/split/DetailHeader.jsx
@@ -1,3 +1,4 @@
+import Icon from '../Icon'
// DetailHeader is the top of the pane once something is selected: the way back
// out, what you are looking at, and what you can do to it.
//
@@ -5,19 +6,33 @@
// the URL on all three surfaces, so leaving the detail is a real navigation
// rather than a second click on the thing you just opened.
export default function DetailHeader({
- icon, name, lede, ledeTitle, actions, onBack, backLabel, warning,
+ icon, name, lede, ledeTitle, actions, onBack, backLabel, warning, closeIcon = false,
testId = 'detail',
}) {
return (
<>
- {onBack && (
+ {onBack && closeIcon && (
+ // A close button on the corner, for an inspector beside a table: the
+ // table stays on screen, so the control reads as dismissing, not leaving.
+
+
+
+ )}
+ {onBack && !closeIcon && (
- {backLabel}
+ {backLabel}
)}
- {icon &&
}
+ {icon &&
}
{name}
{lede && (
@@ -31,7 +46,7 @@ export default function DetailHeader({
{warning && (
- {warning}
+ {warning}
)}
>
diff --git a/core/http/react-ui/src/components/split/EntityRail.jsx b/core/http/react-ui/src/components/split/EntityRail.jsx
index 5b166c8b9..772b0b6c8 100644
--- a/core/http/react-ui/src/components/split/EntityRail.jsx
+++ b/core/http/react-ui/src/components/split/EntityRail.jsx
@@ -1,4 +1,5 @@
import { useEffect, useRef } from 'react'
+import Icon from '../Icon'
// EntityRail is the scannable half of SplitView: one line per entity, grouped
// while browsing and flat while searching.
@@ -15,7 +16,7 @@ import { useEffect, useRef } from 'react'
// items: [{ id, name, icon, meta, metaTone, stripe, groupId }]
// groups: [{ id, label, icon }] - pass null, or grouped=false, for a flat list.
// metaTone: 'ok' | 'bad' | 'warn' | 'busy'
-// stripe: 'run' | 'idle' | 'err' | 'off' - a left edge for surfaces read by
+// stripe: 'run' | 'idle' | 'err' | 'off' - a status dot for surfaces read by
// condition before they are read by name. Omit where state is not
// the point.
export default function EntityRail({
@@ -146,8 +147,8 @@ export default function EntityRail({
data-testid={`${testId}-group-${group.id}`}
onClick={() => onToggleGroup(group.id)}
>
-
- {group.icon &&
}
+
+ {group.icon &&
}
{group.label}
{inGroup.length}
@@ -176,7 +177,7 @@ function RailItem({ item, selected, tabbable, onSelect, onKeyDown, testId }) {
onKeyDown={onKeyDown}
>
{item.stripe &&
}
- {item.icon &&
}
+ {item.icon &&
}
{item.name}
{item.meta && (
diff --git a/core/http/react-ui/src/components/studio/HandoffNote.jsx b/core/http/react-ui/src/components/studio/HandoffNote.jsx
new file mode 100644
index 000000000..a1b48c41e
--- /dev/null
+++ b/core/http/react-ui/src/components/studio/HandoffNote.jsx
@@ -0,0 +1,30 @@
+import { useTranslation } from 'react-i18next'
+import Icon from '../Icon'
+
+// A workspace opened from the Studio front page with a result as its starting
+// point says so, and says plainly when that result could not be loaded. It
+// renders nothing when the page was opened any other way.
+//
+// source what useHandoffSource() returned
+// handoff what useStudioHandoff() returned
+// onClear drop the source and keep the rest of the form
+export default function HandoffNote({ source, handoff, onClear, wanted = true }) {
+ const { t } = useTranslation('media')
+ if (!handoff.from || !wanted || source.status === 'none') return null
+ const name = source.item?.title || t('studio.handoff.aResult')
+ const failed = source.status === 'missing' || source.status === 'error'
+ return (
+
+
+
+ {source.status === 'loading' && t('studio.handoff.loading', { name })}
+ {source.status === 'ready' && t('studio.handoff.ready', { name })}
+ {source.status === 'missing' && t('studio.handoff.missing')}
+ {source.status === 'error' && t('studio.handoff.error', { name })}
+
+ {onClear && source.status === 'ready' && (
+ {t('studio.handoff.remove')}
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/studio/InstallNote.jsx b/core/http/react-ui/src/components/studio/InstallNote.jsx
new file mode 100644
index 000000000..c9f7ca2df
--- /dev/null
+++ b/core/http/react-ui/src/components/studio/InstallNote.jsx
@@ -0,0 +1,128 @@
+import { useEffect, useMemo, useState } from 'react'
+// eslint-disable-next-line no-unused-vars
+import { Link, useOutletContext } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import Icon from '../Icon'
+import { modelsApi } from '../../utils/api'
+import { useModelSuggestion } from '../../hooks/useModelSuggestion'
+import { useOperations } from '../../hooks/useOperations'
+import { useResources } from '../../hooks/useResources'
+import { hostMemory } from '../home/memory'
+import { gbLabel, cssVars } from '../../utils/modelLedger'
+
+// What a missing model needs, shown only when the person picks that type (or a
+// hand-off needs it). A model, a size, the memory it needs, the memory that is
+// free, and one Install button. Nothing starts until they press it, and the
+// words they typed stay where they are: this note sits inside the composer and
+// the composer never unmounts while a model installs.
+//
+// typeLabel the type's display name, "Video"
+// onChanged called while an install runs so the caller can re-read which
+// models are installed; the note goes away once one is
+export default function InstallNote({ type, typeLabel, onChanged, className = '' }) {
+ const { t } = useTranslation('media')
+ const outlet = useOutletContext()
+ const addToast = outlet?.addToast
+ const { state, suggestion } = useModelSuggestion(type, true)
+ const { operations } = useOperations()
+ const { resources } = useResources(15000)
+ const [started, setStarted] = useState('')
+ const [failure, setFailure] = useState('')
+
+ const name = suggestion?.name
+ const op = useMemo(
+ () => name && operations.find(o => o.name === name && !o.isBackend && !o.isDeletion),
+ [operations, name],
+ )
+ const busy = !!op || (!!started && started === name)
+
+ // While the install runs, ask again which models exist. The server adds the
+ // model when the download ends, and this note unmounts when it does.
+ useEffect(() => {
+ if (!busy) return undefined
+ const id = setInterval(() => onChanged?.(), 3000)
+ return () => clearInterval(id)
+ }, [busy, onChanged])
+
+ const memory = hostMemory(resources)
+ const free = memory ? Math.max(0, memory.total - memory.used) : 0
+
+ const install = async () => {
+ setFailure('')
+ setStarted(name)
+ try {
+ await modelsApi.install(name)
+ addToast?.(t('studio.install.started', { model: name }), 'success')
+ } catch (err) {
+ setStarted('')
+ setFailure(err.message)
+ }
+ }
+
+ if (state === 'loading' || state === 'idle') {
+ return (
+
+
{t('studio.install.title', { type: typeLabel })}
+
+
+ )
+ }
+
+ if (state !== 'ready') {
+ return (
+
+
{t('studio.install.title', { type: typeLabel })}
+
{state === 'error' ? t('studio.install.galleryDown') : t('studio.install.noneListed', { type: typeLabel })}
+
+ {t('studio.install.browse')}
+
+
+ )
+ }
+
+ const size = suggestion.sizeBytes > 0 ? gbLabel(suggestion.sizeBytes) : (suggestion.sizeDisplay || null)
+ const need = suggestion.vramBytes > 0 ? gbLabel(suggestion.vramBytes) : (suggestion.vramDisplay || null)
+ const progress = op && op.progress > 0 ? Math.round(op.progress) : 0
+
+ return (
+
+
{t('studio.install.title', { type: typeLabel })}
+
+ {name}
+ {' '}
+ {size ? t('studio.install.size', { size }) : t('studio.install.sizeUnknown')}
+ {' '}
+ {need ? t('studio.install.need', { need }) : t('studio.install.needUnknown')}
+ {memory && ' '}
+ {memory && t('studio.install.free', { free: gbLabel(free) })}
+ {' '}
+ {t('studio.install.nothingStarts')}
+
+
+ {busy ? (
+
+
+ {t('studio.install.installing', { model: name })}{progress > 0 ? ` ${progress}%` : ''}
+ {progress > 0 && (
+
+
+
+ )}
+
+ ) : (
+
+ {t('studio.install.button', { model: name })}
+
+ )}
+ {failure &&
{t('studio.install.failed', { message: failure })} }
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/studio/LineageView.jsx b/core/http/react-ui/src/components/studio/LineageView.jsx
new file mode 100644
index 000000000..95cb2a829
--- /dev/null
+++ b/core/http/react-ui/src/components/studio/LineageView.jsx
@@ -0,0 +1,381 @@
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Icon from '../Icon'
+// eslint-disable-next-line no-unused-vars
+import InstallNote from './InstallNote'
+// eslint-disable-next-line no-unused-vars
+import StarButton from './StarButton'
+// eslint-disable-next-line no-unused-vars
+import WorkThumb from './WorkThumb'
+import { relativeTime } from '../../utils/format'
+import { cssVars } from '../../utils/modelLedger'
+import {
+ NEXT_STEPS, TYPE_ICON, TYPE_INFO, canTake, handoffPath, layoutLineage, neighbour, projectOf, suggestNext,
+} from '../../utils/studioWork'
+
+// A prompt can be a paragraph; a title is one line of it.
+function clip(text, max) {
+ const s = String(text || '').trim()
+ return s.length > max ? `${s.slice(0, max).trimEnd()}…` : s
+}
+
+function isTyping(el) {
+ if (!el) return false
+ const tag = el.tagName
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable
+}
+
+// A result, or a stack of results that came from each other, as a board: what it
+// was made from on the left, what came out of it on the right, the path through
+// the selection drawn heavier, and one dashed step suggested from the models that
+// are installed.
+//
+// "Run as a new take" and "Branch from here" open the workspace with the source
+// and the words filled in. They do not run anything here. A step the destination
+// workspace cannot start from yet is listed but disabled, with the reason.
+export default function LineageView({
+ items, workId, modalities, defaultModel, onClose, onToggleFavourite, onHandoff, onModelsChanged,
+}) {
+ const { t } = useTranslation('media')
+ const members = useMemo(() => projectOf(items, workId), [items, workId])
+ const [selectedId, setSelectedId] = useState(workId)
+ const [expanded, setExpanded] = useState(false)
+ const [draft, setDraft] = useState(null)
+ const boardRef = useRef(null)
+
+ // The set of types that have a model, and the first model of each.
+ const installed = useMemo(() => new Set(modalities.filter(m => m.installed.length > 0).map(m => m.key)), [modalities])
+ const label = (key) => t(`studio.tabs.${key}`)
+
+ const selected = members?.find(m => m.id === selectedId) || members?.[members.length - 1] || null
+ const suggestion = selected ? suggestNext(selected.type, installed) : null
+ const shownStep = draft ? { to: draft.to, edge: draft.edge } : suggestion
+ const ghost = selected && shownStep
+ ? { id: `ghost:${selected.id}`, from: `res:${selected.id}`, to: shownStep.to, edge: shownStep.edge, draft: !!draft }
+ : null
+ const layout = useMemo(
+ () => (members ? layoutLineage(members, { selectedId: selected?.id, ghost }) : null),
+ // ghost is derived from the three values below
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [members, selected?.id, ghost?.to, ghost?.edge, ghost?.draft],
+ )
+
+ const startDraft = (step) => {
+ if (!step || !step.supported) return
+ const text = step.edge === 'variation' ? (selected.title || '') : t(`studio.steps.${step.edge}.template`, { defaultValue: '' })
+ setDraft({ to: step.to, edge: step.edge, text })
+ setExpanded(true)
+ }
+
+ const take = () => {
+ const ok = canTake(selected)
+ if (!ok.ok) return
+ const textLike = TYPE_INFO[selected.type].input === 'text'
+ onHandoff(handoffPath(selected.type, {
+ prompt: textLike ? selected.title : '',
+ model: selected.model,
+ size: selected.params?.size || '',
+ from: selected.id,
+ edge: 'take',
+ }))
+ }
+
+ const openDraft = () => {
+ if (!draft) return
+ const textLike = TYPE_INFO[draft.to].input === 'text'
+ const ids = modalities.find(m => m.key === draft.to)?.installed || []
+ onHandoff(handoffPath(draft.to, {
+ prompt: textLike ? draft.text : '',
+ model: defaultModel(draft.to, ids),
+ from: selected.id,
+ edge: draft.edge,
+ }))
+ }
+
+ // Keys: arrows walk the board, B branches, Esc closes the draft, then the
+ // dock, then the view.
+ const keys = useRef({})
+ keys.current = { layout, selected, draft, expanded, suggestion, startDraft }
+ // A layout effect, so the keys work from the first painted frame.
+ useLayoutEffect(() => {
+ const onKey = (e) => {
+ const s = keys.current
+ if (e.key === 'Escape') {
+ if (s.draft) setDraft(null)
+ else if (s.expanded) setExpanded(false)
+ else onClose()
+ return
+ }
+ if (isTyping(e.target) || e.ctrlKey || e.metaKey || e.altKey || !s.layout) return
+ const dir = { ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'up', ArrowDown: 'down' }[e.key]
+ if (dir) {
+ e.preventDefault()
+ const next = neighbour(s.layout, s.selected?.id, dir)
+ if (next) { setSelectedId(next); setDraft(null) }
+ } else if (e.key === 'b' || e.key === 'B') {
+ const step = s.suggestion || (NEXT_STEPS[s.selected?.type] || []).find(x => x.supported)
+ if (step) { e.preventDefault(); s.startDraft(step) }
+ }
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ }, [onClose])
+
+ // Keep the selected node in view when it changes, sideways on a phone.
+ useEffect(() => {
+ const node = boardRef.current?.querySelector('[data-selected="true"]')
+ node?.scrollIntoView?.({ block: 'nearest', inline: 'center' })
+ }, [selected?.id])
+
+ if (!members || !selected || !layout) {
+ return (
+
+
{t('studio.lineage.back')}
+
{t('studio.lineage.goneTitle')} {t('studio.lineage.goneBody')}
+
+ )
+ }
+
+ const root = members[0]
+ const steps = NEXT_STEPS[selected.type] || []
+ const takeOk = canTake(selected)
+ const branchStep = suggestion || steps.find(s => s.supported) || null
+ const draftInstalled = draft ? installed.has(draft.to) : true
+ const draftInfo = draft ? TYPE_INFO[draft.to] : null
+ const draftTextLike = draftInfo?.input === 'text'
+ const canOpenDraft = !!draft && draftInstalled && (!draftTextLike || draft.text.trim().length > 0)
+
+ return (
+
+
+
+
+
+
+ {layout.edges.map(e => (
+
+ ))}
+
+ {layout.edges.filter(e => e.label).map(e => (
+
+ {t(`studio.edges.${e.label}`)}
+
+ ))}
+ {layout.nodes.map(n => {
+ const pos = cssVars({ '--x': `${n.x}px`, '--y': `${n.y}px`, '--w': `${n.w}px`, '--h': `${n.h}px` })
+ if (n.kind === 'source') {
+ return (
+
+ {n.input === 'text'
+ ? {n.title}
+ : }
+
+ {n.input === 'text' ? t('studio.lineage.prompt') : t('studio.lineage.file')}
+ {relativeTime(n.createdAt)}
+
+
+ )
+ }
+ if (n.kind === 'ghost') {
+ const missing = !installed.has(n.ghost.to)
+ return (
+
startDraft(steps.find(s => s.edge === n.ghost.edge))}
+ >
+ {t(`studio.steps.${n.ghost.edge}.title`)}
+ {missing ? t('studio.lineage.needsModel', { type: label(n.ghost.to) }) : (modalities.find(m => m.key === n.ghost.to)?.installed[0] || '')}
+ {n.ghost.draft ? t('studio.lineage.draft') : t('studio.lineage.suggested')}
+
+ )
+ }
+ const m = n.item
+ const isSel = m.id === selected.id
+ return (
+
{ setSelectedId(m.id); setDraft(null) }}
+ >
+
+ {isSel && }
+
+ {m.title || t(`studio.work.untitled.${m.type}`)}
+ {m.model || label(m.type)}
+
+
+ )
+ })}
+
+
+
+
+
+
+ {draft ? : }
+
+
+ {draft ? (
+ <>
+ {t('studio.lineage.newBranch', { step: t(`studio.steps.${draft.edge}.title`) })}
+ {t('studio.lineage.fromResult', { title: selected.title || label(selected.type) })}
+ >
+ ) : (
+ <>
+ {selected.title || t(`studio.work.untitled.${selected.type}`)}
+ {[selected.model, selected.elapsedMs ? t('studio.lineage.took', { seconds: Math.round(selected.elapsedMs / 1000) }) : '', relativeTime(selected.createdAt)].filter(Boolean).join(' · ')}
+ >
+ )}
+
+
+ {draft ? (
+ <>
+ setDraft(null)} data-testid="studio-draft-cancel">{t('studio.lineage.cancel')}
+
+ {t('studio.lineage.openIn', { type: label(draft.to) })}
+
+ >
+ ) : (
+ <>
+
+ {t('studio.lineage.take')}
+
+ startDraft(branchStep)} data-testid="studio-branch" title={branchStep ? undefined : t('studio.lineage.noBranch', { type: label(selected.type) })}>
+ {t('studio.lineage.branch')}
+
+ >
+ )}
+ setExpanded(v => !v)} data-testid="studio-dock-toggle">
+
+
+
+
+ {(!takeOk.ok || !branchStep) && !draft && (
+
+ {!takeOk.ok && t('studio.lineage.noInput')}
+ {!takeOk.ok && !branchStep && ' '}
+ {!branchStep && t('studio.lineage.noBranch', { type: label(selected.type) })}
+
+ )}
+
+ {expanded && (draft ? (
+
+
{t('studio.lineage.makeFrom')}
+
+ {steps.map(step => {
+ const bad = !step.supported
+ return (
+ startDraft(step)}
+ >
+ {t(`studio.steps.${step.edge}.title`)}
+
+ )
+ })}
+
+ {!draftInstalled &&
}
+ {draftTextLike ? (
+ <>
+
{t('studio.lineage.describe')}
+
setDraft(d => ({ ...d, text: e.target.value }))} />
+ >
+ ) : (
+ {t(`studio.steps.${draft.edge}.plain`)}
+ )}
+ {t('studio.lineage.keeps')}
+
+ ) : (
+
+
+
+
{t('studio.lineage.promptLabel')}
+
{selected.title || t(`studio.work.untitled.${selected.type}`)}
+
{[selected.model, selected.params?.size, selected.params?.speakers ? t('studio.lineage.speakers', { count: selected.params.speakers }) : ''].filter(Boolean).join(' · ')}
+
+
{t('studio.lineage.continue')}
+
+ {steps.length === 0 && {t('studio.lineage.noBranch', { type: label(selected.type) })} }
+ {steps.map(step => {
+ const bad = !step.supported
+ const missing = !bad && !installed.has(step.to)
+ return (
+
+ startDraft(step)} data-testid={`step-${step.edge}`} data-state={bad ? 'unsupported' : missing ? 'missing' : 'ready'}>
+
+
+ {t(`studio.steps.${step.edge}.title`)}
+
+ {bad
+ ? t('studio.lineage.cannotStart', { page: label(step.to), what: t(`studio.kinds.${step.source}`) })
+ : t(`studio.steps.${step.edge}.why`)}
+
+
+ {!bad && {missing ? t('studio.lineage.needsModel', { type: label(step.to) }) : t('studio.lineage.ready')} }
+
+
+ )
+ })}
+
+
+
+
+ ))}
+
+
+ )
+}
+
+// The large preview in the dock. Pictures and clips play from the server's own
+// file; a mesh shows the picture it came from, because the viewer lives in its
+// workspace.
+// eslint-disable-next-line no-unused-vars
+function Preview({ item, t }) {
+ const kind = TYPE_INFO[item.type].kind
+ if (kind === 'image' && item.url) return
+ if (kind === 'video' && item.url) return
+ if (kind === 'audio' && item.url) {
+ return (
+
+ )
+ }
+ return
+}
diff --git a/core/http/react-ui/src/components/studio/StarButton.jsx b/core/http/react-ui/src/components/studio/StarButton.jsx
new file mode 100644
index 000000000..860cb67db
--- /dev/null
+++ b/core/http/react-ui/src/components/studio/StarButton.jsx
@@ -0,0 +1,28 @@
+import { useTranslation } from 'react-i18next'
+
+// The favourite star. The kit's icon set has no star, so it is drawn here; it
+// is one path and takes its colour from the text colour like every other icon.
+export function StarGlyph({ className = '' }) {
+ return (
+
+
+
+ )
+}
+
+export default function StarButton({ on, onToggle, className = '' }) {
+ const { t } = useTranslation('media')
+ return (
+ { e.stopPropagation(); e.preventDefault(); onToggle() }}
+ >
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/studio/StudioComposer.jsx b/core/http/react-ui/src/components/studio/StudioComposer.jsx
new file mode 100644
index 000000000..eadeaa5e0
--- /dev/null
+++ b/core/http/react-ui/src/components/studio/StudioComposer.jsx
@@ -0,0 +1,258 @@
+import { useEffect, useMemo, useRef } from 'react'
+import { useTranslation } from 'react-i18next'
+import Icon from '../Icon'
+// eslint-disable-next-line no-unused-vars
+import InstallNote from './InstallNote'
+import { useResources } from '../../hooks/useResources'
+import { useModelNeed } from '../../hooks/useModelSuggestion'
+import { modelBudget } from '../../utils/modelBudget'
+import { fitFor, gbLabel, gbNumber } from '../../utils/modelLedger'
+import {
+ NEXT_STEPS, TYPE_ICON, TYPE_INFO, TYPE_ORDER, handoffPath, suggestType,
+} from '../../utils/studioWork'
+
+// The edge a hand-off carries when the chosen source feeds this type.
+const EDGE_FOR = { images: 'variation', video: 'animate', threed: 'to-3d', transform: 'transform', diarization: 'diarize' }
+
+// Which results can start which type.
+function eligibleSources(type, items) {
+ const wanted = TYPE_INFO[type].source
+ if (!wanted) return []
+ const from = Object.keys(NEXT_STEPS).filter(k => NEXT_STEPS[k].some(s => s.supported && s.to === type && s.edge === EDGE_FOR[type]))
+ return items.filter(i => from.includes(i.type) && i.url).slice(0, 8)
+}
+
+function isTyping(el) {
+ if (!el) return false
+ const tag = el.tagName
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable
+}
+
+// "What do you want to make?"
+//
+// One box. The workspaces are the tabs above it, so the composer carries no
+// type control of its own: it starts on the first type with a model, shows
+// which type it will open, and suggests another from the words as a quiet hint
+// (taken with the link or Alt+Enter, or Alt+1..7 to pick directly). Typing
+// never switches by itself. Generate does not run anything here: it opens the
+// workspace with the prompt and the options the workspace accepts.
+export default function StudioComposer({
+ draft, setDraft, modalities, items, onHandoff, onModelsChanged, defaultModel,
+}) {
+ const { t } = useTranslation('media')
+ const textRef = useRef(null)
+ const { resources } = useResources(30000)
+
+ const types = useMemo(() => TYPE_ORDER.filter(k => modalities.some(m => m.key === k)), [modalities])
+ const infoFor = (key) => modalities.find(m => m.key === key)
+ // Until one is picked, open on the first type that has a model, so a machine
+ // that can only read aloud does not open on a type it cannot run.
+ const firstReady = types.find(k => (infoFor(k)?.installed.length || 0) > 0)
+ const type = types.includes(draft.type) ? draft.type : (firstReady || types[0])
+ const modality = infoFor(type)
+ const info = TYPE_INFO[type]
+ const installed = modality?.installed || []
+ const missing = installed.length === 0
+ const model = installed.includes(draft.models[type]) ? draft.models[type] : (defaultModel(type, installed) || '')
+ const label = (key) => t(`studio.tabs.${key}`)
+
+ const guess = useMemo(() => {
+ const g = suggestType(draft.text)
+ return g && g !== type && types.includes(g) ? g : null
+ }, [draft.text, type, types])
+
+ const sources = useMemo(() => eligibleSources(type, items), [type, items])
+ const source = sources.find(s => s.id === draft.sourceId) || null
+
+ const need = useModelNeed(missing ? '' : model)
+ const fit = need ? fitFor(need, modelBudget(resources)) : null
+
+ const textLike = info.input === 'text'
+ const canGo = !missing && (!textLike || draft.text.trim().length > 0)
+ const why = missing ? t('studio.composer.whyModel', { type: label(type) })
+ : (textLike && !draft.text.trim()) ? t('studio.composer.whyPrompt') : ''
+
+ const set = (patch) => setDraft(d => ({ ...d, ...patch }))
+ const pickType = (key) => setDraft(d => ({ ...d, type: key, sourceId: '' }))
+
+ const go = () => {
+ if (!canGo) return
+ onHandoff(handoffPath(type, {
+ prompt: textLike ? draft.text : '',
+ model,
+ size: info.sizes ? (draft.sizes[type] || info.defaultSize) : '',
+ count: info.maxCount ? draft.count : 0,
+ from: source?.id || '',
+ edge: source ? EDGE_FOR[type] : '',
+ }))
+ }
+
+ // Keys: "/" to the prompt, Alt+1..7 to a type, Ctrl+Enter to generate, Alt+Enter
+ // to take the suggestion. Held in a ref so one listener sees the latest state.
+ const keyState = useRef({})
+ keyState.current = { go, guess, types, textLike }
+ useEffect(() => {
+ const onKey = (e) => {
+ const s = keyState.current
+ if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey && !isTyping(e.target)) {
+ e.preventDefault()
+ textRef.current?.focus()
+ return
+ }
+ if (e.altKey && !e.ctrlKey && !e.metaKey && /^Digit[1-7]$/.test(e.code)) {
+ const key = s.types[Number(e.code.slice(5)) - 1]
+ if (key) { e.preventDefault(); pickType(key) }
+ return
+ }
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); s.go(); return }
+ if (e.altKey && e.key === 'Enter' && s.guess) { e.preventDefault(); pickType(s.guess) }
+ }
+ window.addEventListener('keydown', onKey)
+ return () => window.removeEventListener('keydown', onKey)
+ // pickType only calls setDraft, which is stable.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+
+ // The box grows with what is typed, up to a limit.
+ useEffect(() => {
+ const el = textRef.current
+ if (!el) return
+ el.style.height = 'auto'
+ el.style.height = `${Math.min(220, el.scrollHeight)}px`
+ }, [draft.text, type])
+
+ const starters = (t(`studio.starters.${type}`, { returnObjects: true }))
+ const starterList = Array.isArray(starters) ? starters : []
+ const guessMissing = guess && (infoFor(guess)?.installed.length || 0) === 0
+ const noneInstalled = modalities.every(m => m.installed.length === 0)
+
+ return (
+
+
+
{t('studio.composer.title')}
+ {t('studio.composer.lede')}
+ {label(type)}
+
+
+
+ {textLike ? (
+
set({ text: e.target.value })}
+ />
+ ) : (
+ {t(`studio.composer.fileLead.${type}`)}
+ )}
+
+
+ {guess && !guessMissing && (
+ <>
+ {t('studio.composer.soundsLike', { type: label(guess) })}
+ pickType(guess)}>{t('studio.composer.switchTo', { type: label(guess) })}
+ Alt Enter
+ >
+ )}
+ {guess && guessMissing && (
+ <>
+ {t('studio.composer.soundsLikeMissing', { type: label(guess) })}
+ pickType(guess)}>{t('studio.composer.seeNeeds')}
+ >
+ )}
+
+
+
+ {textLike && !draft.text && starterList.length > 0 && (
+
+ {t('studio.composer.starters')}
+ {starterList.map(s => (
+ { set({ text: s.text }); textRef.current?.focus() }}>{s.label}
+ ))}
+
+ )}
+
+ {missing && }
+ {missing && noneInstalled && {t('studio.composer.firstRun')}
}
+
+
+
+ setDraft(d => ({ ...d, models: { ...d.models, [type]: e.target.value } }))}
+ >
+ {missing ? {t('studio.composer.noModel')} : installed.map(id => {id} )}
+
+
+
+ {info.sizes && (
+
+ setDraft(d => ({ ...d, sizes: { ...d.sizes, [type]: e.target.value } }))}
+ >
+ {info.sizes.map(s => {s.replace('x', ' × ')} )}
+
+
+ )}
+
+ {info.maxCount && (
+
+ set({ count: Number(e.target.value) })}
+ >
+ {Array.from({ length: info.maxCount }, (_, i) => i + 1).map(n => {t('studio.composer.countOption', { count: n })} )}
+
+
+ )}
+
+ {info.source && (
+
+ set({ sourceId: e.target.value })}
+ >
+ {info.needsSource ? t('studio.composer.sourceOnPage') : t('studio.composer.sourceNone')}
+ {sources.map(s => {(s.title || t(`studio.work.untitled.${s.type}`)).slice(0, 48)} )}
+
+
+ )}
+
+ {fit && (
+
+ {fit.state === 'fits' && t('studio.composer.fit.fits', { need: gbLabel(fit.need), amount: gbNumber(fit.amount) })}
+ {fit.state === 'spill' && t('studio.composer.fit.spill', { need: gbLabel(fit.need), amount: gbNumber(fit.amount) })}
+ {fit.state === 'over' && t('studio.composer.fit.over', { need: gbLabel(fit.need), amount: gbNumber(fit.amount) })}
+
+ )}
+
+
+ {why && {why} }
+
+
+ {textLike ? t('studio.composer.generate') : t('studio.composer.openType', { type: label(type) })}
+ Ctrl Enter
+
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/studio/Timeline.jsx b/core/http/react-ui/src/components/studio/Timeline.jsx
new file mode 100644
index 000000000..f7d98f135
--- /dev/null
+++ b/core/http/react-ui/src/components/studio/Timeline.jsx
@@ -0,0 +1,31 @@
+import { useTranslation } from 'react-i18next'
+import { axisTicks, clock } from '../../utils/diarization'
+import { cssVars } from '../../utils/modelLedger'
+
+// Who spoke when: one lane per speaker, a bar for each stretch of speech, and a
+// time axis. Drawn from the run's own segments; nothing here is estimated.
+// The segment list under it is the same data as text.
+//
+// rows speakerRows(result)
+// length the run's length in seconds
+export default function Timeline({ rows, length }) {
+ const { t } = useTranslation('media')
+ const ticks = axisTicks(length)
+ return (
+
+
+ {ticks.map(tick => {clock(tick.at)} )}
+
+ {rows.map(row => (
+
+ {row.name || row.id}
+
+ {row.segments.map((seg, i) => (
+
+ ))}
+
+
+ ))}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/studio/WorkMasonry.jsx b/core/http/react-ui/src/components/studio/WorkMasonry.jsx
new file mode 100644
index 000000000..f9881a41c
--- /dev/null
+++ b/core/http/react-ui/src/components/studio/WorkMasonry.jsx
@@ -0,0 +1,192 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Icon from '../Icon'
+// eslint-disable-next-line no-unused-vars
+import ConfirmDialog from '../ConfirmDialog'
+// eslint-disable-next-line no-unused-vars
+import WorkThumb from './WorkThumb'
+// eslint-disable-next-line no-unused-vars
+import StarButton, { StarGlyph } from './StarButton'
+import { relativeTime } from '../../utils/format'
+import {
+ TYPE_ICON, TYPE_ORDER, columnCount, countByType, dealColumns, filterWork, groupWork,
+} from '../../utils/studioWork'
+
+// "Your work": every result this browser has a record of, newest first, as a
+// masonry. Related results (one made from another) stack into a project tile.
+//
+// Filters count results, not tiles, so "Images 10" means ten pictures even when
+// four of them sit in one stack. Grouping only applies under All: a type or
+// Favourites filter lists the results themselves, which is what the count says.
+export default function WorkMasonry({
+ items, ready, types, filter, onFilter, group, onGroup, onOpen, onToggleFavourite, onClear,
+}) {
+ const { t } = useTranslation('media')
+ const [confirming, setConfirming] = useState(false)
+ const [columns, setColumns] = useState(3)
+ const host = useRef(null)
+
+ const counts = useMemo(() => countByType(items), [items])
+ const visible = useMemo(() => filterWork(items, filter), [items, filter])
+ const tiles = useMemo(() => {
+ if (filter === 'all' && group) return groupWork(visible)
+ return visible.map(item => ({ kind: 'single', id: item.id, item, latest: item.createdAt }))
+ }, [visible, filter, group])
+
+ // Column count follows the width of the page, not the window, so the sidebar
+ // being open or shut changes it too.
+ useEffect(() => {
+ const el = host.current
+ if (!el || typeof ResizeObserver === 'undefined') return undefined
+ const update = () => setColumns(columnCount(el.clientWidth))
+ update()
+ const ro = new ResizeObserver(update)
+ ro.observe(el)
+ return () => ro.disconnect()
+ }, [])
+
+ const cols = useMemo(() => dealColumns(tiles, columns), [tiles, columns])
+ const tabs = ['all', 'favourites', ...TYPE_ORDER.filter(k => types.includes(k))]
+ const label = (key) => key === 'all' ? t('studio.work.all')
+ : key === 'favourites' ? t('studio.work.favourites')
+ : t(`studio.tabs.${key}`)
+
+ return (
+
+
+
{t('studio.work.title')}
+ {t('studio.work.results', { count: items.length })}
+
+ {filter === 'all' && (
+
+ onGroup(!group)}
+ data-testid="studio-group-switch"
+ />
+ {t('studio.work.group')}
+
+ )}
+ {items.length > 0 && (
+ setConfirming(true)} data-testid="studio-clear-history">
+ {t('studio.work.clear')}
+
+ )}
+
+
+
+
+ {tabs.map(key => (
+ onFilter(key)}
+ >
+ {key === 'favourites' && }
+ {label(key)}
+ {counts[key]}
+
+ ))}
+
+
+
+ {!ready ? (
+
+ {Array.from({ length: columns }, (_, c) => (
+
+ ))}
+
+ ) : tiles.length === 0 ? (
+
+ {items.length === 0 ? t('studio.work.emptyTitle') : t('studio.work.emptyFilterTitle', { filter: label(filter) })}
+ {items.length === 0 ? t('studio.work.emptyBody') : t('studio.work.emptyFilterBody')}
+
+ ) : (
+
+ {cols.map((column, c) => (
+
+ {column.map(tile => tile.kind === 'project'
+ ?
+ :
)}
+
+ ))}
+
+ )}
+
+
+ { setConfirming(false); onClear() }}
+ onCancel={() => setConfirming(false)}
+ />
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function SingleTile({ item, t, onOpen, onToggleFavourite }) {
+ return (
+
+ onOpen(item.id)} aria-label={t('studio.work.open', { title: item.title || t(`studio.tabs.${item.type}`) })}>
+
+
+ {t(`studio.tabs.${item.type}`)}
+
+
+ {item.title || t(`studio.work.untitled.${item.type}`)}
+ {[item.model, relativeTime(item.createdAt)].filter(Boolean).join(' · ')}
+
+
+ onToggleFavourite(item.id)} className="studio-tile__star" />
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function ProjectTile({ tile, t, onOpen }) {
+ const chain = tile.items
+ const shown = chain.slice(0, 4)
+ const latest = chain[chain.length - 1]
+ return (
+
+ onOpen(latest.id)} aria-label={t('studio.work.openProject', { title: tile.title || t('studio.work.project'), count: chain.length })}>
+
+
+
+ {t('studio.work.nResults', { count: chain.length })}
+
+
+ {tile.title || t('studio.work.project')}
+
+ {shown.map((m, i) => (
+
+ {i > 0 && }
+
+
+ ))}
+ {chain.length > shown.length && +{chain.length - shown.length} }
+
+
+
+ {relativeTime(tile.latest)}
+
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/studio/WorkThumb.jsx b/core/http/react-ui/src/components/studio/WorkThumb.jsx
new file mode 100644
index 000000000..97cd1ec61
--- /dev/null
+++ b/core/http/react-ui/src/components/studio/WorkThumb.jsx
@@ -0,0 +1,57 @@
+import { useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import Icon from '../Icon'
+import { aspectOf, TYPE_ICON, TYPE_INFO, waveBars } from '../../utils/studioWork'
+import { cssVars } from '../../utils/modelLedger'
+
+// What a result looks like on a tile or a node.
+//
+// images the picture the server kept
+// video its first frame, read from the file
+// 3D the small picture it was made from, else a mesh placeholder
+// audio a drawn waveform. It is a placeholder that is the same for the
+// same result every time; it is not the sound
+// diarization one bar per speaker the run found
+//
+// A picture whose file is gone from the server (the output folder was cleaned)
+// falls back to the type's icon rather than a broken image.
+export default function WorkThumb({ item, compact = false }) {
+ const { t } = useTranslation('media')
+ const [failed, setFailed] = useState(false)
+ const kind = TYPE_INFO[item.type].kind
+ const ratio = compact ? undefined : Math.min(1.8, Math.max(0.55, 1 / aspectOf(item)))
+ const frame = ratio ? cssVars({ '--studio-ratio': ratio }) : undefined
+
+ let body
+ if (kind === 'image' && item.url && !failed) {
+ body = setFailed(true)} />
+ } else if (kind === 'video' && item.url && !failed) {
+ // #t=0.1 asks for a frame past the black first one; preload=metadata keeps it to one small range request.
+ body = setFailed(true)} />
+ } else if (kind === 'mesh' && item.thumb) {
+ body =
+ } else if (kind === 'mesh') {
+ body =
+ } else if (kind === 'audio') {
+ body = (
+
+ {waveBars(item.id, compact ? 28 : 44).map((h, i) => )}
+
+ )
+ } else if (kind === 'speakers') {
+ const n = Math.max(1, Math.min(6, Number(item.params?.speakers) || 1))
+ body = (
+
+ {Array.from({ length: n }, (_, i) => )}
+
+ )
+ } else {
+ body =
+ }
+
+ return (
+
+ {body}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/studio/Workspace.jsx b/core/http/react-ui/src/components/studio/Workspace.jsx
new file mode 100644
index 000000000..9fe5d3896
--- /dev/null
+++ b/core/http/react-ui/src/components/studio/Workspace.jsx
@@ -0,0 +1,564 @@
+import { useEffect, useId, useMemo, useRef, useState } from 'react'
+// eslint-disable-next-line no-unused-vars
+import { Link } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import Icon from '../Icon'
+// eslint-disable-next-line no-unused-vars
+import ModelSelector from '../ModelSelector'
+// eslint-disable-next-line no-unused-vars
+import InstallNote from './InstallNote'
+// eslint-disable-next-line no-unused-vars
+import WorkThumb from './WorkThumb'
+// eslint-disable-next-line no-unused-vars
+import { StarGlyph } from './StarButton'
+import { findParent } from '../../hooks/useWorkspace'
+import { useResources } from '../../hooks/useResources'
+import { useModelNeed } from '../../hooks/useModelSuggestion'
+import { modelBudget } from '../../utils/modelBudget'
+import { cssVars, fitFor, gbLabel, gbNumber } from '../../utils/modelLedger'
+import { relativeTime } from '../../utils/format'
+import { TYPE_ICON } from '../../utils/studioWork'
+import './studio.css'
+import './workspace.css'
+
+// The shared frame of the seven Studio workspaces. A page keeps its own fields,
+// endpoints and viewer; this file gives them one shape: a compose card (sources,
+// prompt, a model chip, the essential options, an Advanced fold, an estimate and
+// one action), a run area (a job card, a failure or the result with its
+// toolbar), and a strip of recent results of the same type.
+
+export function Workspace({ type, children }) {
+ return (
+
+ {children}
+
+ )
+}
+
+// A labelled control. `changed` outlines it when "Re-run with edits" has moved
+// it away from the take it started from.
+export function Field({ label, htmlFor, changed, hint, children, className = '' }) {
+ return (
+
+ {label &&
{label} }
+ {children}
+ {hint &&
{hint}
}
+
+ )
+}
+
+// A native select drawn as a chip, for the few options that are always shown.
+export function ChipSelect({ label, value, onChange, options, changed, mono = true, testId, disabled, id }) {
+ const items = options.map(o => (typeof o === 'object' ? o : { value: o, label: o }))
+ return (
+
+ onChange(e.target.value)}
+ >
+ {items.map(o => {o.label} )}
+
+
+ )
+}
+
+// An on/off option drawn as a chip. Pressed shows the kit's check mark.
+export function ToggleChip({ label, on, onChange, changed, testId }) {
+ return (
+ onChange(!on)}>
+ {label}
+
+ )
+}
+
+// A short typed value drawn as a chip with its name in front: a duration, a
+// frame rate.
+export function ChipInput({ label, value, onChange, placeholder, type = 'text', step, min, changed, testId, width, id: given }) {
+ const generated = useId()
+ const id = given || generated
+ return (
+
+ {label}
+ onChange(e.target.value)}
+ />
+
+ )
+}
+
+// The model picker, drawn as a chip. It is the page's own ModelSelector
+// (search, last model remembered); the fit sits in the estimate line.
+export function ModelChip({ value, onChange, capability, options, loading, disabled, changed }) {
+ return (
+
+
+
+ )
+}
+
+// Short example prompts, shown while the prompt is empty. The same starters the
+// Studio front page offers for the type.
+export function Starters({ type, onPick }) {
+ const { t } = useTranslation('media')
+ const list = t(`studio.starters.${type}`, { returnObjects: true })
+ if (!Array.isArray(list) || list.length === 0) return null
+ return (
+
+ {t('studio.composer.starters')}
+ {list.map(s => onPick(s.text)}>{s.label} )}
+
+ )
+}
+
+// A source that is optional: a dashed chip that opens a file picker. With a
+// file chosen it shows the name and a way to remove it.
+export function SourceChip({ label, accept, multiple = false, name, count = 0, preview, onFiles, onClear, testId }) {
+ const id = useId()
+ const filled = !!name || count > 0
+ return (
+
+
+ {preview ? : }
+
+ {label}
+ {filled && {name || String(count)} }
+
+
+ { if (e.target.files?.length) onFiles(multiple ? [...e.target.files] : e.target.files[0]); e.target.value = '' }}
+ />
+ {filled && onClear && (
+
+ )}
+
+ )
+}
+
+// A source the run cannot start without: a recording. A drop area with a Choose
+// button; with a file chosen it shows the name and size.
+export function SourceDrop({ label, accept, file, onFile, hint, testId, inputId }) {
+ const { t } = useTranslation('media')
+ const generated = useId()
+ const id = inputId || generated
+ const [hover, setHover] = useState(false)
+ const kb = file ? file.size / 1024 : 0
+ const size = !file ? '' : kb >= 1024 ? `${(kb / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(kb))} KB`
+ return (
+ { e.preventDefault(); setHover(true) }}
+ onDragLeave={() => setHover(false)}
+ onDrop={(e) => { e.preventDefault(); setHover(false); const f = e.dataTransfer.files?.[0]; if (f) onFile(f) }}
+ >
+
+
+ {file ? file.name : label}
+ {file ? size : (hint || t('studio.workspace.dropHint'))}
+
+ {file ? t('studio.workspace.replace') : t('studio.workspace.choose')}
+ { const f = e.target.files?.[0]; if (f) onFile(f); e.target.value = '' }}
+ />
+ {file && onFile(null)}> }
+
+ )
+}
+
+// A source chip that opens a panel with its own input (record, paste).
+export function PanelChip({ label, open, onToggle, filled, controls }) {
+ return (
+
+
+
+ {label}
+
+
+ )
+}
+
+// The Advanced fold: what is inside is named on the bar, so closing it does not
+// hide that something is set.
+export function Fold({ label, summary, open, onToggle, id, children }) {
+ const body = useId()
+ const bodyId = id || body
+ return (
+
+
+ {label}
+ {summary && {summary} }
+
+
+ {open &&
{children}
}
+
+ )
+}
+
+// The memory a model needs and whether it fits, when the gallery can say. No
+// time is promised: nothing in the server can say how long a run will take.
+// eslint-disable-next-line no-unused-vars
+function Estimate({ model }) {
+ const { t } = useTranslation('media')
+ const { resources } = useResources(30000)
+ const need = useModelNeed(model)
+ const fit = need ? fitFor(need, modelBudget(resources)) : null
+ if (!fit) return
+ return (
+
+
+ {fit.state === 'fits' && t('studio.composer.fit.fits', { need: gbLabel(fit.need), amount: gbNumber(fit.amount) })}
+ {fit.state === 'spill' && t('studio.composer.fit.spill', { need: gbLabel(fit.need), amount: gbNumber(fit.amount) })}
+ {fit.state === 'over' && t('studio.composer.fit.over', { need: gbLabel(fit.need), amount: gbNumber(fit.amount) })}
+
+ )
+}
+
+// The compose card.
+//
+// icon, title, lede what this workspace is
+// handoff the hand-off note, when the page was opened from a result
+// sources optional source chips (SourceChip, PanelChip)
+// panels what a PanelChip opens
+// children the prompt (and anything that belongs with it)
+// options the model chip and the essential options
+// fold the Advanced fold
+// model the chosen model, for the fit line
+// noModel { type, label, onChanged } when no model is installed
+// changes what "Re-run with edits" has changed, from useWorkspace().changes
+// submit { label, busyLabel, busy, disabled, why, icon }
+export function ComposeCard({
+ ws, icon, title, lede, onSubmit, handoff, sources, panels, children, options, fold, model, noModel, changes = [], submit,
+}) {
+ const { t } = useTranslation('media')
+ return (
+
+
+ {icon && }{title}
+ {lede && {lede} }
+
+ {handoff}
+ {sources && {sources}
}
+ {panels}
+ {children}
+ {noModel && (
+
+ )}
+ {options && {options}
}
+ {fold}
+
+
+ {changes.length > 0 && (
+
+ {t('studio.workspace.changedFrom')}
+ {changes.map(c => {c.text}{c.long ? ` ${t('studio.workspace.edited')}` : ''})}
+
+ )}
+
+ {submit.why && {submit.why} }
+
+ {!submit.busy && }
+ {submit.busy ? submit.busyLabel : (ws?.rerunning ? t('studio.workspace.runAgain') : submit.label)}
+
+
+
+
+ )
+}
+
+// --- The run area -------------------------------------------------------------
+
+export function RunArea({ children }) {
+ return
+}
+
+export function EmptyRun({ icon, text }) {
+ return (
+
+ )
+}
+
+function useElapsed() {
+ const [seconds, setSeconds] = useState(0)
+ useEffect(() => {
+ const id = setInterval(() => setSeconds(s => s + 1), 1000)
+ return () => clearInterval(id)
+ }, [])
+ return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
+}
+
+// While a request is out. The server reports no phase and no percentage on
+// these endpoints, so the bar is indeterminate and the only number shown is the
+// time that has really passed.
+//
+// label "Making your picture"
+// detail model, size and count as the request carries them
+// tiles how many placeholder tiles to draw (pictures), 0 for none
+export function JobCard({ label, detail, tiles = 0, ratio = 1 }) {
+ const elapsed = useElapsed()
+ const n = Math.min(Math.max(tiles, 0), 4)
+ return (
+
+
+
{label} {elapsed}
+
+ {detail &&
{detail}
}
+
+ {n > 0 && (
+
+ {Array.from({ length: n }, (_, i) => )}
+
+ )}
+
+ )
+}
+
+// A run that did not finish: what the server said, that nothing was lost, and
+// one action.
+export function FailedCard({ message, onRetry, retryLabel }) {
+ const { t } = useTranslation('media')
+ return (
+
+
+
+
{t('studio.workspace.failed.title')}
+
{message}
+
{t('studio.workspace.failed.note')}
+
+
+
+ )
+}
+
+// --- The result ---------------------------------------------------------------
+
+// eslint-disable-next-line no-unused-vars
+function UseInMenu({ ws, item }) {
+ const { t } = useTranslation('media')
+ const [open, setOpen] = useState(false)
+ const root = useRef(null)
+ const steps = ws.steps(item)
+ useEffect(() => {
+ if (!open) return undefined
+ const onDown = (e) => { if (root.current && !root.current.contains(e.target)) setOpen(false) }
+ const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
+ document.addEventListener('mousedown', onDown)
+ document.addEventListener('keydown', onKey)
+ return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey) }
+ }, [open])
+ const none = steps.length === 0
+ return (
+
+ setOpen(v => !v)}
+ >
+ {t('studio.workspace.useIn')}
+
+ {open && (
+
+ {none &&
{t('studio.lineage.noBranch', { type: t(`studio.tabs.${ws.type}`) })}
}
+ {steps.map(step => (
+
{ setOpen(false); ws.sendTo(item, step) }}
+ >
+
+
+ {step.title}
+
+ {step.blocked
+ ? t('studio.lineage.cannotStart', { page: t(`studio.tabs.${step.to}`), what: t(`studio.kinds.${step.source}`) })
+ : step.missingModel ? t('studio.lineage.needsModel', { type: t(`studio.tabs.${step.to}`) }) : t(`studio.steps.${step.edge}.why`)}
+
+
+
+ ))}
+
+ )}
+
+ )
+}
+
+// The result and what can be done with it.
+//
+// item the stored result, when there is one (Favourite, Use in and
+// Lineage need it); a result that was not stored has only Download
+// title the heading; defaults to the prompt
+// meta small facts under the viewer: model, size, seed
+// download { href, name, testId }
+// onRerun load this result's values into the form
+// actions extra toolbar buttons that belong to the type (exports)
+export function ResultCard({ ws, item, title, meta = [], download, onRerun, actions, children, footer, testId = 'ws-result' }) {
+ const { t } = useTranslation('media')
+ const parent = useMemo(() => findParent(item), [item])
+ const take = item ? ws.canTake(item) : { ok: false }
+ const heading = title || item?.title || t(`studio.work.untitled.${ws.type}`)
+ const edgeLabel = item?.edge ? t(`studio.edges.${item.edge}`, { defaultValue: item.edge }) : ''
+ return (
+
+
+ {children}
+
+ {meta.filter(Boolean).map((m, i) => {m} )}
+ {item?.parentId && (
+ parent
+ ? {t('studio.workspace.from')} {parent.title || t(`studio.work.untitled.${parent.type}`)} ({edgeLabel})
+ : {t('studio.workspace.fromGone', { edge: edgeLabel })}
+ )}
+ {footer}
+
+
+ )
+}
+
+// A card in the result's shape for something that is not a result yet: the
+// audio you chose, waiting to be transformed.
+export function ViewCard({ title, sub, children, testId = 'ws-view' }) {
+ return (
+
+
+
+
{title}
+ {sub &&
{sub}
}
+
+
+ {children}
+
+ )
+}
+
+// --- The strip ----------------------------------------------------------------
+
+const STRIP_MAX = 24
+
+// Recent results of this type, from the shared history. Click one to show it
+// above; click it again to go back to the latest.
+export function ResultsStrip({ ws, selectedId, activeId, onSelect, onDelete, onClear }) {
+ const { t } = useTranslation('media')
+ const [filter, setFilter] = useState('all')
+ const favouriteCount = ws.items.filter(i => i.favourite).length
+ const shown = (filter === 'favourites' ? ws.items.filter(i => i.favourite) : ws.items).slice(0, STRIP_MAX)
+ return (
+
+
+ {shown.length === 0 ? (
+ {filter === 'favourites' ? t('studio.workspace.strip.noFavourites') : t('history.empty')}
+ ) : (
+
+ {shown.map(item => (
+
+ onSelect(item.id)}>
+
+
+ {item.title || t(`studio.work.untitled.${ws.type}`)}
+ {relativeTime(item.createdAt)}
+
+
+ {item.favourite && }
+ onDelete(item.id)}>
+
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/studio/studio.css b/core/http/react-ui/src/components/studio/studio.css
new file mode 100644
index 000000000..f38dc0e09
--- /dev/null
+++ b/core/http/react-ui/src/components/studio/studio.css
@@ -0,0 +1,640 @@
+/* ==========================================================================
+ Studio front page: the composer, your work, and the lineage board.
+
+ Built on the kit's chip, segmented, switch, badge, select, button, skeleton
+ and meter classes. What is here is what the kit does not say: the masonry,
+ the stack, the board with its edges, and the dock. Selection is an outline,
+ a check or a surface step, never a rail. Surfaces are flat with a hairline;
+ the only shadows are the kit's two. No glow.
+ ========================================================================== */
+
+.studio-front { max-width: 1240px; }
+
+.studio-key-hint {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--dk-space-2);
+ color: var(--dk-muted);
+ font-size: var(--dk-text-13);
+}
+
+.studio-eyebrow {
+ font: var(--dk-weight-semibold) 11px / 1 var(--dk-font-body);
+ letter-spacing: .08em;
+ text-transform: uppercase;
+ color: var(--dk-muted);
+}
+
+.studio-link {
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--dk-accent-text);
+ font: var(--dk-weight-semibold) var(--dk-text-13) / 1.4 var(--dk-font-body);
+ text-decoration: underline;
+ text-underline-offset: 3px;
+ text-decoration-thickness: 1px;
+ cursor: pointer;
+}
+.studio-link:hover { text-decoration-thickness: 2px; }
+
+/* --- Type tabs ------------------------------------------------------------- */
+
+/* The seven types, as one row above every Studio page. The current one is
+ outlined and lifted onto a card; there is no underline and no rail. */
+.studio-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--dk-space-1);
+ padding: var(--dk-space-4) var(--spacing-xl) 0;
+}
+.studio-tab {
+ position: relative; /* contains the hidden state text so it cannot widen the scrolling row */
+ display: inline-flex;
+ align-items: center;
+ gap: var(--dk-space-2);
+ height: 38px;
+ padding: 0 var(--dk-space-3);
+ border: 0;
+ border-radius: var(--dk-radius-3);
+ background: transparent;
+ color: var(--dk-muted);
+ font: var(--dk-weight-semibold) var(--dk-text-13) / 1 var(--dk-font-body);
+ cursor: pointer;
+ transition: background-color var(--dk-dur-1) ease, color var(--dk-dur-1) ease, transform var(--dk-dur-1) var(--dk-ease-out);
+}
+.studio-tab:hover { background: var(--dk-hover); color: var(--dk-text); }
+.studio-tab:active { transform: scale(var(--dk-press-scale)); }
+.studio-tab:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: 2px; }
+.studio-tab .lai-icon { font-size: 16px; }
+.studio-tab-active, .studio-tab-active:hover {
+ background: var(--dk-card);
+ color: var(--dk-text);
+ box-shadow: inset 0 0 0 1.5px var(--dk-text);
+}
+@media (max-width: 720px) {
+ .studio-tabs { flex-wrap: nowrap; overflow-x: auto; padding-left: var(--dk-space-4); padding-right: var(--dk-space-4); scrollbar-width: none; }
+ .studio-tab { flex: none; }
+}
+
+/* --- Composer ------------------------------------------------------------ */
+
+.studio-composer {
+ background: var(--dk-card);
+ border-radius: var(--dk-radius-4);
+ box-shadow: inset 0 0 0 1px var(--dk-edge);
+ padding-bottom: var(--dk-space-4);
+ transition: box-shadow var(--dk-dur-2) var(--dk-ease-out);
+}
+.studio-composer:focus-within { box-shadow: inset 0 0 0 1px var(--dk-control-edge); }
+
+.studio-composer__head {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: var(--dk-space-3);
+ padding: var(--dk-space-4) var(--dk-space-5) 0;
+}
+.studio-composer__head h2 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-17) / 1.2 var(--dk-font-display); letter-spacing: -.01em; }
+.studio-composer__head span { color: var(--dk-muted); font-size: var(--dk-text-13); }
+.studio-composer__head .studio-composer__now { margin-left: auto; font: var(--dk-weight-semibold) var(--dk-text-12) / 1 var(--dk-font-mono); letter-spacing: .06em; text-transform: uppercase; }
+
+.studio-type { height: 34px; }
+
+.studio-composer__box { padding: var(--dk-space-2) var(--dk-space-5) 0; }
+
+.studio-prompt {
+ display: block;
+ width: 100%;
+ min-height: 58px;
+ max-height: 220px;
+ padding: var(--dk-space-2) 0;
+ border: 0;
+ outline: 0;
+ resize: none;
+ background: transparent;
+ color: var(--dk-text);
+ font: 450 var(--dk-text-17) / 1.45 var(--dk-font-body);
+}
+.studio-prompt::placeholder { color: var(--dk-muted); }
+.studio-prompt--boxed {
+ min-height: 84px;
+ padding: var(--dk-space-3);
+ border-radius: var(--dk-radius-2);
+ background: var(--dk-card);
+ box-shadow: inset 0 0 0 1px var(--dk-control-edge);
+ font-size: var(--dk-text-15);
+}
+.studio-prompt--boxed:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: 2px; }
+.studio-prompt:focus-visible:not(.studio-prompt--boxed) { outline: none; }
+
+.studio-composer__file {
+ margin: 0;
+ padding: var(--dk-space-4) 0 var(--dk-space-2);
+ max-width: 62ch;
+ color: var(--dk-muted);
+ font-size: var(--dk-text-15);
+}
+
+.studio-hint {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--dk-space-2);
+ min-height: 20px;
+ color: var(--dk-muted);
+ font-size: var(--dk-text-13);
+}
+.studio-hint:empty { min-height: 0; }
+.studio-hint b { color: var(--dk-text); font-weight: var(--dk-weight-semibold); }
+
+.studio-starters {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--dk-space-2);
+ padding: var(--dk-space-2) var(--dk-space-5) 0;
+}
+.studio-starters .studio-eyebrow { margin-right: var(--dk-space-1); }
+
+.studio-options {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--dk-space-2);
+ margin-top: var(--dk-space-4);
+ padding: var(--dk-space-4) var(--dk-space-4) 0;
+ border-top: 1px solid var(--dk-edge);
+}
+.studio-opt { max-width: 100%; }
+.studio-opt .dk-select { max-width: 100%; text-overflow: ellipsis; font-size: var(--dk-text-13); }
+.studio-opt--source .dk-select { max-width: 240px; }
+
+.studio-fit { color: var(--dk-muted); font-size: var(--dk-text-12); }
+.studio-fit[data-fit="spill"] { color: var(--dk-warn-text); }
+.studio-fit[data-fit="over"] { color: var(--dk-error-text); }
+
+.studio-go { display: inline-flex; align-items: center; gap: var(--dk-space-3); margin-left: auto; flex-wrap: wrap; justify-content: flex-end; }
+.studio-go__why { color: var(--dk-muted); font-size: var(--dk-text-13); }
+.studio-go .dk-kbd { margin-left: var(--dk-space-1); }
+
+.studio-firstrun { margin: var(--dk-space-3) var(--dk-space-5) 0; max-width: 62ch; color: var(--dk-muted); font-size: var(--dk-text-13); }
+
+/* --- Notes: install and error -------------------------------------------- */
+
+.studio-note {
+ display: grid;
+ gap: var(--dk-space-2);
+ margin: var(--dk-space-3) var(--dk-space-4) 0;
+ padding: var(--dk-space-3) var(--dk-space-4);
+ border-radius: var(--dk-radius-3);
+ background: var(--dk-inset);
+ box-shadow: inset 0 0 0 1px var(--dk-edge);
+}
+.studio-note h3 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-15) / 1.3 var(--dk-font-display); }
+.studio-note p { margin: 0; max-width: 66ch; color: var(--dk-text); font-size: var(--dk-text-13); line-height: 1.5; }
+.studio-note__model { font-family: var(--dk-font-mono); font-size: var(--dk-text-12); overflow-wrap: anywhere; }
+.studio-note__row { display: flex; align-items: center; flex-wrap: wrap; gap: var(--dk-space-3); }
+.studio-note__skeleton { width: 60%; }
+.studio-note__progress { display: inline-flex; align-items: center; gap: var(--dk-space-2); font-size: var(--dk-text-13); }
+.studio-note__meter { width: 160px; height: 8px; }
+.studio-note__error { color: var(--dk-error-text); font-size: var(--dk-text-13); }
+.studio-note--standalone { margin: 0; padding: var(--dk-space-4) var(--dk-space-5); border-radius: var(--dk-radius-4); }
+.studio-note--error { background: var(--dk-error-wash); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dk-error) 40%, transparent); }
+.studio-note--error h3 { color: var(--dk-error-text); }
+
+/* --- Your work ------------------------------------------------------------ */
+
+.studio-work { margin-top: var(--dk-space-6); }
+.studio-work__head { display: flex; align-items: center; flex-wrap: wrap; gap: var(--dk-space-3); }
+.studio-work__head h2 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-21) / 1.2 var(--dk-font-display); letter-spacing: -.015em; }
+.studio-work__count { color: var(--dk-muted); font: 500 12px / 1 var(--dk-font-mono); }
+.studio-work__tools { display: inline-flex; align-items: center; gap: var(--dk-space-3); margin-left: auto; flex-wrap: wrap; }
+.studio-work__group { display: inline-flex; align-items: center; gap: var(--dk-space-2); color: var(--dk-text); font-size: var(--dk-text-13); cursor: pointer; }
+
+.studio-filters { display: flex; flex-wrap: wrap; gap: var(--dk-space-1); margin: var(--dk-space-3) 0 var(--dk-space-4); }
+.studio-filter {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 30px;
+ padding: 0 11px;
+ border: 0;
+ border-radius: var(--dk-radius-1);
+ background: transparent;
+ color: var(--dk-text);
+ font: 500 var(--dk-text-13) / 1 var(--dk-font-body);
+ cursor: pointer;
+ transition: background-color var(--dk-dur-1) ease, color var(--dk-dur-1) ease, transform var(--dk-dur-1) var(--dk-ease-out);
+}
+.studio-filter:hover { background: var(--dk-hover); }
+.studio-filter:active { transform: scale(var(--dk-press-scale)); }
+.studio-filter[aria-selected="true"] { background: var(--dk-inverse); color: var(--dk-on-inverse); font-weight: var(--dk-weight-semibold); }
+.studio-filter__n { font: 500 11px / 1 var(--dk-font-mono); opacity: .8; }
+.studio-filter__icon { width: 14px; height: 14px; }
+
+.studio-masonry { display: flex; align-items: flex-start; gap: var(--dk-space-4); }
+.studio-col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: var(--dk-space-4); }
+.studio-masonry-host { min-width: 0; }
+
+.studio-skel { border-radius: var(--dk-radius-3); height: 160px; }
+.studio-skel--tall { height: 240px; }
+.studio-skel--composer { height: 220px; border-radius: var(--dk-radius-4); }
+.studio-skel--board { height: 360px; border-radius: var(--dk-radius-4); }
+
+.studio-empty {
+ display: grid;
+ gap: var(--dk-space-1);
+ max-width: 560px;
+ padding: var(--dk-space-5) var(--dk-space-5);
+ border: 1.5px dashed var(--dk-control-edge);
+ border-radius: var(--dk-radius-4);
+ color: var(--dk-muted);
+ font-size: var(--dk-text-13);
+}
+.studio-empty b { color: var(--dk-text); font-size: var(--dk-text-15); }
+
+/* --- Tiles ---------------------------------------------------------------- */
+
+.studio-tile, .studio-stack { position: relative; animation: studio-in var(--dk-dur-3) var(--dk-ease-out) both; }
+@keyframes studio-in { from { opacity: 0; transform: translateY(8px); } }
+
+.studio-tile__open, .studio-stack__open {
+ display: block;
+ width: 100%;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: inherit;
+ text-align: left;
+ font: inherit;
+ cursor: pointer;
+}
+.studio-tile__open {
+ overflow: hidden;
+ border-radius: var(--dk-radius-3);
+ background: var(--dk-card);
+ box-shadow: inset 0 0 0 1px var(--dk-edge);
+ transition: transform var(--dk-dur-2) var(--dk-ease-out), box-shadow var(--dk-dur-2) var(--dk-ease-out);
+}
+.studio-tile__open:hover { box-shadow: inset 0 0 0 1px var(--dk-control-edge), var(--dk-shadow-float); }
+.studio-tile__open:active, .studio-stack__open:active .studio-stack__in { transform: scale(.985); }
+
+.studio-tile__media { position: relative; display: block; background: var(--dk-inset); }
+.studio-badge {
+ position: absolute;
+ left: 8px;
+ top: 8px;
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ height: 24px;
+ padding: 0 8px;
+ border-radius: var(--dk-radius-1);
+ /* Over a picture, so a fixed dark chip rather than a theme colour. */
+ background: rgb(10 16 15 / .72);
+ color: #f1f5f4;
+ font: 500 11px / 1 var(--dk-font-mono);
+}
+.studio-badge .lai-icon { width: 13px; height: 13px; }
+
+.studio-tile__cap { display: grid; gap: 4px; padding: 10px 12px 12px; }
+.studio-tile__title {
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ font-size: var(--dk-text-13);
+ line-height: 1.35;
+ color: var(--dk-text);
+ overflow-wrap: anywhere;
+}
+.studio-tile__title--project { font-weight: var(--dk-weight-semibold); font-size: var(--dk-text-15); }
+.studio-tile__cap small { color: var(--dk-muted); font: 500 11.5px / 1.2 var(--dk-font-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.studio-tile__star { position: absolute; right: 8px; top: 8px; }
+
+.studio-star {
+ display: grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ padding: 0;
+ border: 0;
+ border-radius: var(--dk-radius-1);
+ background: rgb(10 16 15 / .72);
+ color: #f1f5f4;
+ cursor: pointer;
+ transition: transform var(--dk-dur-1) var(--dk-ease-out), background-color var(--dk-dur-1) ease;
+}
+.studio-star:hover { background: rgb(10 16 15 / .88); }
+.studio-star:active { transform: scale(var(--dk-press-scale)); }
+.studio-star svg, .studio-star__glyph { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linejoin: round; }
+.studio-star[aria-pressed="true"] svg { fill: currentColor; }
+.studio-detail__fav .studio-star { background: var(--dk-inset); color: var(--dk-text); box-shadow: inset 0 0 0 1px var(--dk-edge); }
+.studio-filter__icon.studio-star__glyph { width: 14px; height: 14px; }
+
+/* Project stack: two paper layers behind the cover. */
+.studio-stack { padding-bottom: 10px; }
+.studio-stack::before, .studio-stack::after {
+ content: "";
+ position: absolute;
+ height: calc(100% - 10px);
+ border-radius: var(--dk-radius-3);
+ background: var(--dk-card);
+ box-shadow: inset 0 0 0 1px var(--dk-edge);
+ transition: transform var(--dk-dur-2) var(--dk-ease-out);
+}
+.studio-stack::before { bottom: 0; left: 22px; right: 22px; background: var(--dk-inset); }
+.studio-stack::after { bottom: 5px; left: 11px; right: 11px; }
+.studio-stack:hover::after { transform: translateY(4px); }
+.studio-stack:hover::before { transform: translateY(8px); }
+.studio-stack__in {
+ position: relative;
+ z-index: 1;
+ display: block;
+ overflow: hidden;
+ border-radius: var(--dk-radius-3);
+ background: var(--dk-card);
+ box-shadow: inset 0 0 0 1px var(--dk-edge);
+ transition: transform var(--dk-dur-2) var(--dk-ease-out), box-shadow var(--dk-dur-2) var(--dk-ease-out);
+}
+.studio-stack__open:hover .studio-stack__in { box-shadow: inset 0 0 0 1px var(--dk-control-edge), var(--dk-shadow-float); }
+
+.studio-strip { display: flex; align-items: center; flex-wrap: wrap; gap: 0; row-gap: 4px; }
+.studio-strip__step { display: inline-flex; align-items: center; }
+.studio-strip__glyph {
+ display: grid;
+ place-items: center;
+ width: 24px;
+ height: 24px;
+ border-radius: var(--dk-radius-1);
+ background: var(--dk-inset);
+ box-shadow: inset 0 0 0 1px var(--dk-control-edge);
+ color: var(--dk-text);
+ font-size: 13px;
+}
+.studio-strip__glyph--ghost { background: transparent; box-shadow: none; border: 1.5px dashed var(--dk-control-edge); color: var(--dk-muted); }
+.studio-strip__dash { display: inline-block; width: 8px; height: 1.5px; background: var(--dk-control-edge); }
+.studio-strip__dash--ghost { background: none; border-top: 1.5px dashed var(--dk-control-edge); height: 0; }
+.studio-strip__more { margin-left: 6px; color: var(--dk-muted); font: 500 12px / 1 var(--dk-font-mono); }
+
+/* --- Thumbnails ------------------------------------------------------------ */
+
+.studio-thumb {
+ position: relative;
+ display: block;
+ width: 100%;
+ overflow: hidden;
+ background: var(--dk-inset);
+ color: var(--dk-muted);
+}
+.studio-thumb--image, .studio-thumb--video { aspect-ratio: 1 / var(--studio-ratio, 1); }
+.studio-thumb--image img, .studio-thumb--video video, .studio-thumb--mesh img {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+}
+.studio-thumb--mesh, .studio-thumb--audio, .studio-thumb--speakers { aspect-ratio: 16 / 9; }
+.studio-thumb__glyph { position: absolute; left: 50%; top: 50%; width: 34px; height: 34px; margin: -17px 0 0 -17px; }
+.studio-thumb--mesh img { object-fit: contain; padding: 14px; }
+
+.studio-thumb--compact { width: 100%; height: 84px; aspect-ratio: auto; flex: none; }
+
+.studio-wave { position: absolute; inset: 0; display: flex; align-items: center; gap: 2px; padding: 0 12px; }
+.studio-wave i { flex: 1 1 0; min-width: 1px; height: calc(var(--h) * 70%); border-radius: 2px; background: var(--dk-accent); opacity: .8; }
+
+.studio-lanes { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: center; gap: 6px; padding: 0 14px; }
+.studio-lanes i { position: relative; display: block; height: 8px; border-radius: 4px; background: var(--dk-edge); }
+.studio-lanes i::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: var(--from);
+ right: calc(100% - var(--to));
+ border-radius: 4px;
+ background: var(--dk-accent);
+ opacity: .85;
+}
+.studio-lanes i[data-speaker="1"]::after { background: var(--dk-series-2); }
+.studio-lanes i[data-speaker="2"]::after { background: var(--dk-series-3); }
+.studio-lanes i[data-speaker="3"]::after { background: var(--dk-series-4); }
+
+/* --- Lineage view ----------------------------------------------------------- */
+
+.studio-lineage { display: grid; gap: var(--dk-space-4); }
+.studio-lineage__head { display: flex; align-items: flex-start; gap: var(--dk-space-4); flex-wrap: wrap; }
+.studio-lineage__head > div { min-width: 0; flex: 1 1 240px; }
+.studio-lineage__title { margin: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; font: var(--dk-weight-bold) 26px / 1.15 var(--dk-font-display); letter-spacing: -.02em; overflow-wrap: anywhere; }
+.studio-lineage__sub { margin: 4px 0 0; color: var(--dk-muted); font-size: var(--dk-text-13); }
+
+.studio-board {
+ position: relative;
+ overflow: auto;
+ border-radius: var(--dk-radius-4);
+ background: var(--dk-inset);
+ box-shadow: inset 0 0 0 1px var(--dk-edge);
+ -webkit-overflow-scrolling: touch;
+}
+.studio-board:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: 2px; }
+.studio-board__canvas { position: relative; width: var(--w); height: var(--h); min-width: 100%; }
+.studio-edges { position: absolute; left: 0; top: 0; pointer-events: none; overflow: visible; }
+.studio-edges path { fill: none; stroke: var(--dk-control-edge); stroke-width: 1.5; transition: stroke var(--dk-dur-2) ease, stroke-width var(--dk-dur-2) ease; }
+.studio-edges path[data-chain] { stroke: var(--dk-text); stroke-width: 2.25; }
+.studio-edges path[data-ghost] { stroke-dasharray: 5 5; }
+.studio-edges path[data-ghost][data-chain] { stroke-width: 2; }
+
+.studio-edge-label {
+ position: absolute;
+ left: var(--x);
+ top: var(--y);
+ transform: translate(-50%, -50%);
+ height: 22px;
+ padding: 0 9px;
+ border-radius: var(--dk-radius-1);
+ background: var(--dk-inset);
+ box-shadow: inset 0 0 0 1px var(--dk-edge);
+ color: var(--dk-muted);
+ font: 500 11px / 22px var(--dk-font-body);
+ white-space: nowrap;
+ pointer-events: none;
+}
+.studio-edge-label[data-chain] { color: var(--dk-text); font-weight: var(--dk-weight-semibold); box-shadow: inset 0 0 0 1px var(--dk-text); }
+.studio-edge-label[data-ghost] { box-shadow: none; }
+
+.studio-node {
+ position: absolute;
+ left: var(--x);
+ top: var(--y);
+ width: var(--w);
+ height: var(--h);
+ display: flex;
+ flex-direction: column;
+ padding: 0;
+ overflow: hidden;
+ border: 0;
+ border-radius: var(--dk-radius-3);
+ background: var(--dk-card);
+ box-shadow: inset 0 0 0 1px var(--dk-edge);
+ color: var(--dk-text);
+ font: inherit;
+ text-align: left;
+ animation: studio-node-in var(--dk-dur-3) var(--dk-ease-out) both;
+ transition: transform var(--dk-dur-2) var(--dk-ease-spring), box-shadow var(--dk-dur-2) var(--dk-ease-out);
+}
+@keyframes studio-node-in { from { opacity: 0; transform: translateY(10px) scale(.96); } }
+button.studio-node { cursor: pointer; }
+button.studio-node:hover { box-shadow: inset 0 0 0 1px var(--dk-control-edge); }
+button.studio-node:active { transform: scale(.98); }
+.studio-node[data-selected="true"] { transform: translateY(-3px); box-shadow: inset 0 0 0 2px var(--dk-text), var(--dk-shadow-float); z-index: 3; }
+.studio-node[data-rel="0"] .studio-thumb { opacity: .55; }
+.studio-node__label { display: grid; gap: 3px; min-width: 0; padding: 7px 10px 8px; }
+.studio-node__label b { display: flex; align-items: center; gap: 6px; min-width: 0; font: var(--dk-weight-semibold) 12px / 1.2 var(--dk-font-body); white-space: nowrap; overflow: hidden; }
+.studio-node__label b span { overflow: hidden; text-overflow: ellipsis; }
+.studio-node__label b .lai-icon { width: 14px; height: 14px; flex: none; color: var(--dk-muted); }
+.studio-node__label small { color: var(--dk-muted); font: 500 10.5px / 1.2 var(--dk-font-mono); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.studio-node__quote {
+ flex: 1;
+ min-height: 0;
+ padding: 10px 12px 0;
+ display: -webkit-box;
+ -webkit-line-clamp: 4;
+ -webkit-box-orient: vertical;
+ max-height: calc(4 * 1.4em + 10px);
+ overflow: hidden;
+ color: var(--dk-text);
+ font: 450 11.5px / 1.4 var(--dk-font-body);
+ overflow-wrap: anywhere;
+}
+.studio-node__file { flex: 1; display: grid; place-items: center; color: var(--dk-muted); font-size: 30px; }
+.studio-picked { position: absolute; right: 6px; top: 6px; display: grid; place-items: center; width: 22px; height: 22px; border-radius: var(--dk-radius-1); background: var(--dk-inverse); color: var(--dk-on-inverse); z-index: 2; }
+.studio-picked .lai-icon { width: 13px; height: 13px; }
+
+.studio-node--ghost {
+ justify-content: center;
+ gap: 6px;
+ padding: 12px;
+ overflow: visible;
+ background: transparent;
+ box-shadow: none;
+ border: 1.5px dashed var(--dk-control-edge);
+}
+button.studio-node--ghost:hover { background: var(--dk-card); box-shadow: none; }
+.studio-node--ghost[aria-pressed="true"] { border-style: solid; border-color: var(--dk-text); background: var(--dk-card); box-shadow: var(--dk-shadow-float); }
+.studio-node--ghost b { display: flex; align-items: center; gap: 7px; font: var(--dk-weight-semibold) 13px / 1.2 var(--dk-font-body); }
+.studio-node--ghost small { color: var(--dk-muted); font: 500 11px / 1.35 var(--dk-font-body); overflow-wrap: anywhere; }
+.studio-node--ghost .dk-badge { align-self: flex-start; }
+
+/* --- Dock ------------------------------------------------------------------- */
+
+.studio-dock {
+ position: sticky;
+ bottom: var(--dk-space-3);
+ z-index: var(--dk-z-sticky);
+ border-radius: var(--dk-radius-4);
+ background: var(--dk-card);
+ box-shadow: inset 0 0 0 1px var(--dk-control-edge), var(--dk-shadow-float);
+ overflow: hidden;
+}
+.studio-dock__row { display: flex; align-items: center; gap: var(--dk-space-3); padding: 8px 10px; }
+.studio-dock__thumb { flex: none; width: 76px; height: 48px; overflow: hidden; border-radius: var(--dk-radius-1); background: var(--dk-inset); display: grid; place-items: center; color: var(--dk-muted); font-size: 20px; }
+.studio-dock__thumb .studio-thumb { height: 48px; }
+.studio-dock__who { display: grid; gap: 2px; min-width: 0; flex: 1 1 auto; }
+.studio-dock__who b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--dk-weight-semibold) var(--dk-text-13) / 1.3 var(--dk-font-body); }
+.studio-dock__who small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dk-muted); font: 500 11.5px / 1.2 var(--dk-font-mono); }
+.studio-dock__actions { display: inline-flex; align-items: center; gap: var(--dk-space-2); flex: none; }
+.studio-dock__hint { margin: 0; padding: 0 var(--dk-space-4) var(--dk-space-3); color: var(--dk-muted); font-size: var(--dk-text-12); }
+.studio-dock__body { border-top: 1px solid var(--dk-edge); padding: var(--dk-space-4) var(--dk-space-4); max-height: min(60vh, 560px); overflow: auto; }
+
+.studio-detail { display: grid; grid-template-columns: minmax(0, 340px) minmax(0, 1fr); gap: var(--dk-space-5); }
+.studio-detail__preview { min-width: 0; border-radius: var(--dk-radius-3); overflow: hidden; background: var(--dk-inset); align-self: start; }
+.studio-detail__preview img, .studio-detail__preview video { display: block; width: 100%; max-height: 360px; object-fit: contain; }
+.studio-detail__audio { display: grid; gap: var(--dk-space-2); padding-bottom: var(--dk-space-2); }
+.studio-detail__audio audio { width: calc(100% - 16px); margin: 0 8px; }
+.studio-detail__info { display: grid; gap: var(--dk-space-2); align-content: start; min-width: 0; }
+.studio-detail__prompt { margin: 0; font-size: var(--dk-text-15); overflow-wrap: anywhere; }
+.studio-detail__meta { margin: 0 0 var(--dk-space-3); color: var(--dk-muted); font: 500 12px / 1.4 var(--dk-font-mono); }
+.studio-detail__foot { grid-column: 1 / -1; display: flex; align-items: center; gap: var(--dk-space-4); }
+.studio-detail__fav { display: inline-flex; align-items: center; gap: var(--dk-space-2); font-size: var(--dk-text-13); cursor: pointer; }
+
+.studio-steps { display: grid; gap: var(--dk-space-1); margin: 0; padding: 0; list-style: none; }
+.studio-steps__none { color: var(--dk-muted); font-size: var(--dk-text-13); }
+.studio-step {
+ display: grid;
+ grid-template-columns: 36px minmax(0, 1fr) auto;
+ gap: var(--dk-space-3);
+ align-items: center;
+ width: 100%;
+ padding: 8px 8px;
+ border: 0;
+ border-radius: var(--dk-radius-2);
+ background: transparent;
+ color: var(--dk-text);
+ text-align: left;
+ font: inherit;
+ cursor: pointer;
+ transition: background-color var(--dk-dur-1) ease, transform var(--dk-dur-1) var(--dk-ease-out);
+}
+.studio-step:hover:not(:disabled) { background: var(--dk-hover); }
+.studio-step:active:not(:disabled) { transform: scale(var(--dk-press-scale-nav)); }
+.studio-step:disabled { cursor: not-allowed; opacity: .7; }
+.studio-step__icon { display: grid; place-items: center; width: 36px; height: 36px; border-radius: var(--dk-radius-2); background: var(--dk-inset); color: var(--dk-text); }
+.studio-step__text { display: grid; gap: 2px; min-width: 0; }
+.studio-step__text b { font: var(--dk-weight-semibold) var(--dk-text-13) / 1.3 var(--dk-font-body); }
+.studio-step__text small { color: var(--dk-muted); font-size: var(--dk-text-12); }
+
+.studio-draft { display: grid; gap: var(--dk-space-3); }
+.studio-draft__targets { display: flex; flex-wrap: wrap; gap: var(--dk-space-2); }
+.studio-draft .studio-note { margin: 0; }
+.studio-draft__plain { margin: 0; color: var(--dk-muted); font-size: var(--dk-text-13); max-width: 62ch; }
+.studio-draft__meta { margin: 0; color: var(--dk-muted); font: 500 12px / 1.4 var(--dk-font-mono); }
+
+/* --- Hand-off note on a workspace -------------------------------------------- */
+
+.studio-handoff {
+ display: flex;
+ align-items: center;
+ gap: var(--dk-space-2);
+ margin-bottom: var(--dk-space-3);
+ padding: var(--dk-space-2) var(--dk-space-3);
+ border-radius: var(--dk-radius-2);
+ background: var(--dk-inset);
+ box-shadow: inset 0 0 0 1px var(--dk-edge);
+ color: var(--dk-text);
+ font-size: var(--dk-text-13);
+}
+.studio-handoff__text { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
+.studio-handoff--failed { background: var(--dk-warn-wash); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dk-warn) 40%, transparent); color: var(--dk-warn-text); }
+
+/* --- Phone ---------------------------------------------------------------- */
+
+@media (max-width: 720px) {
+ .studio-composer__head { padding: var(--dk-space-3) var(--dk-space-4) 0; }
+ .studio-composer__box, .studio-starters { padding-left: var(--dk-space-4); padding-right: var(--dk-space-4); }
+ .studio-options { padding-left: var(--dk-space-3); padding-right: var(--dk-space-3); }
+ .studio-go { margin-left: 0; width: 100%; justify-content: space-between; }
+ .studio-go .dk-btn { flex: 1 1 auto; }
+ .studio-go .dk-kbd { display: none; }
+ .studio-key-hint { display: none; }
+ .studio-opt--source .dk-select { max-width: 100%; }
+ .studio-work__tools { margin-left: 0; }
+ .studio-filters { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 2px; }
+ .studio-filter { flex: none; }
+ .studio-lineage__title { font-size: 22px; }
+ .studio-dock__row { flex-wrap: wrap; }
+ .studio-dock__who { flex-basis: calc(100% - 100px); }
+ .studio-dock__actions { width: 100%; flex-wrap: wrap; justify-content: flex-end; }
+ .studio-dock__actions .dk-btn--sm:not(.dk-btn--icon) { flex: 1 1 auto; min-width: 0; padding: 0 10px; }
+ .studio-detail { grid-template-columns: minmax(0, 1fr); }
+ .studio-dock__thumb { width: 64px; }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .studio-tile, .studio-stack, .studio-node { animation: none; }
+ .studio-stack::before, .studio-stack::after, .studio-tile__open, .studio-stack__in, .studio-node, .studio-edges path { transition: none; }
+}
diff --git a/core/http/react-ui/src/components/studio/workspace.css b/core/http/react-ui/src/components/studio/workspace.css
new file mode 100644
index 000000000..1f939b2e9
--- /dev/null
+++ b/core/http/react-ui/src/components/studio/workspace.css
@@ -0,0 +1,615 @@
+/* ==========================================================================
+ Studio workspaces: one template for the seven types.
+
+ A compose card, a run area, a strip of recent results. Built on the kit's
+ button, select, chip, progress and skeleton classes; what is here is what the
+ kit does not say. Selection is an outline or a check, never a rail. Surfaces
+ are flat with a hairline and there is no glow.
+ ========================================================================== */
+
+.ws {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: var(--dk-space-5);
+ align-content: start;
+ max-width: 980px;
+ margin: 0;
+}
+
+.ws-link {
+ color: var(--dk-accent-text);
+ font: var(--dk-weight-semibold) var(--dk-text-13) / 1.4 var(--dk-font-body);
+ text-decoration: underline;
+ text-underline-offset: 3px;
+ text-decoration-thickness: 1px;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+}
+.ws-link:hover { text-decoration-thickness: 2px; }
+
+/* --- Compose card --------------------------------------------------------- */
+
+.ws-compose {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: var(--dk-space-3);
+ padding: var(--dk-space-4) var(--dk-space-5) var(--dk-space-4);
+ border-radius: var(--dk-radius-4);
+ background: var(--dk-card);
+ box-shadow: inset 0 0 0 1px var(--dk-edge);
+ transition: box-shadow var(--dk-dur-2) var(--dk-ease-out);
+}
+.ws-compose:focus-within { box-shadow: inset 0 0 0 1px var(--dk-control-edge); }
+.ws-compose[data-rerun] { box-shadow: inset 0 0 0 1.5px var(--dk-accent); }
+
+.ws-compose__head { display: flex; align-items: baseline; flex-wrap: wrap; gap: var(--dk-space-3); }
+.ws-title {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--dk-space-2);
+ margin: 0;
+ font: var(--dk-weight-semibold) var(--dk-text-17) / 1.2 var(--dk-font-display);
+ letter-spacing: -.01em;
+}
+.ws-title .lai-icon { color: var(--dk-muted); }
+.ws-lede { color: var(--dk-muted); font-size: var(--dk-text-13); }
+
+.ws-sources { display: flex; flex-wrap: wrap; gap: var(--dk-space-2); }
+.ws-panel {
+ padding: var(--dk-space-3);
+ border-radius: var(--dk-radius-3);
+ background: var(--dk-inset);
+ box-shadow: inset 0 0 0 1px var(--dk-edge);
+}
+
+.ws-source {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ min-height: 40px;
+ border-radius: var(--dk-radius-3);
+ border: 1.5px dashed var(--dk-control-edge);
+ background: transparent;
+ color: var(--dk-text);
+ font: inherit;
+ transition: background-color var(--dk-dur-1) ease;
+}
+.ws-source:hover, .ws-source:focus-within { background: var(--dk-hover); }
+.ws-source--btn { cursor: pointer; padding: 0; }
+.ws-source__pick {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--dk-space-2);
+ min-height: 38px;
+ padding: 0 var(--dk-space-3);
+ cursor: pointer;
+ font-size: var(--dk-text-13);
+}
+.ws-source__pick img { width: 28px; height: 28px; border-radius: var(--dk-radius-1); object-fit: cover; }
+.ws-source__text { display: grid; line-height: 1.2; text-align: left; }
+.ws-source__text b { font-weight: var(--dk-weight-semibold); }
+.ws-source__text small { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dk-muted); font: 500 11px / 1.3 var(--dk-font-mono); }
+.ws-source--filled { border-style: solid; border-color: var(--dk-edge); background: var(--dk-inset); }
+.ws-source--filled .ws-source__pick { padding-right: var(--dk-space-1); }
+.ws-source__input { position: absolute; inline-size: 1px; block-size: 1px; opacity: 0; pointer-events: none; }
+.ws-source:focus-within { outline: 2px solid var(--dk-accent); outline-offset: 2px; }
+.ws-source__x {
+ display: grid;
+ place-items: center;
+ width: 28px;
+ height: 28px;
+ margin-right: 6px;
+ padding: 0;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ color: var(--dk-muted);
+ cursor: pointer;
+}
+.ws-source__x:hover { background: var(--dk-hover); color: var(--dk-text); }
+
+.ws-prompt,
+.ws-compose .ws-prompt.textarea {
+ display: block;
+ width: 100%;
+ min-height: 96px;
+ padding: var(--dk-space-3);
+ border: 0;
+ border-radius: var(--dk-radius-3);
+ background: var(--dk-inset);
+ box-shadow: inset 0 0 0 1px var(--dk-edge);
+ color: var(--dk-text);
+ font: 450 var(--dk-text-15) / 1.5 var(--dk-font-body);
+ resize: vertical;
+ outline: 0;
+}
+.ws-prompt::placeholder { color: var(--dk-muted); opacity: 1; }
+.ws-compose .ws-prompt[data-changed] { box-shadow: 0 0 0 2px var(--dk-accent); }
+.ws-prompt:hover { box-shadow: inset 0 0 0 1px var(--dk-control-edge); }
+.ws-prompt:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: 2px; }
+
+.ws-starters { display: flex; flex-wrap: wrap; align-items: center; gap: var(--dk-space-2); }
+
+.ws-note { margin: 0; }
+
+.ws-options { display: flex; flex-wrap: wrap; align-items: center; gap: var(--dk-space-2); }
+.ws-chip { max-width: 100%; }
+.ws-chip .dk-select { height: 36px; max-width: 100%; text-overflow: ellipsis; font-size: var(--dk-text-13); font-weight: var(--dk-weight-semibold); }
+.ws-chip[data-changed] .dk-select,
+.ws-chip[data-changed] .ws-model { box-shadow: 0 0 0 2px var(--dk-accent); border-color: transparent; }
+
+.ws-chip--input {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--dk-space-2);
+ height: 36px;
+ padding: 0 var(--dk-space-3);
+ border: 1px solid var(--dk-control-edge);
+ border-radius: var(--dk-radius-2);
+ background: var(--dk-card);
+ color: var(--dk-muted);
+ font: var(--dk-weight-semibold) var(--dk-text-13) / 1 var(--dk-font-body);
+ cursor: text;
+}
+.ws-chip--input:hover { border-color: color-mix(in srgb, var(--dk-control-edge) 70%, var(--dk-text)); }
+.ws-chip--input:focus-within { outline: 2px solid var(--dk-accent); outline-offset: 2px; }
+.ws-chip--input[data-changed] { box-shadow: 0 0 0 2px var(--dk-accent); border-color: transparent; }
+.ws-chip__input {
+ width: 64px;
+ min-width: 0;
+ border: 0;
+ outline: 0;
+ background: transparent;
+ color: var(--dk-text);
+ font: var(--dk-weight-semibold) var(--dk-text-13) / 1 var(--dk-font-mono);
+ text-align: right;
+}
+.ws-chip__input::placeholder { color: var(--dk-muted); opacity: 1; }
+/* A wide field holds a sentence-length placeholder ("Optional speaker or voice
+ ID"), which 120px cut off mid-word. Left-aligned text, and it gives way on a
+ phone instead of overflowing the row. */
+.ws-chip__input[data-width="wide"] { width: 15rem; text-align: left; text-overflow: ellipsis; }
+
+.ws-toggle { height: 36px; }
+.ws-toggle[data-changed] { box-shadow: 0 0 0 2px var(--dk-accent); }
+
+.ws-chip--model { position: relative; min-width: 0; }
+.ws-chip--model > div { min-width: 0 !important; }
+.ws-model {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--dk-space-2);
+ height: 36px;
+ max-width: 100%;
+ padding: 0 var(--dk-space-3);
+ border: 1px solid var(--dk-control-edge);
+ border-radius: var(--dk-radius-2);
+ background: var(--dk-card);
+ color: var(--dk-text);
+ font: var(--dk-weight-semibold) var(--dk-text-13) / 1 var(--dk-font-mono);
+ cursor: pointer;
+}
+.ws-model:hover:not(:disabled) { background: var(--dk-hover); }
+.ws-model:disabled { opacity: .5; cursor: not-allowed; }
+.ws-model:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: 2px; }
+.ws-model .searchable-select__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
+.ws-model .searchable-select__chevron { flex: none; width: 14px; height: 14px; color: var(--dk-muted); }
+
+/* Fields (Advanced fold, and fields of the types that have them inline). */
+.ws-field { display: grid; gap: 6px; align-content: start; min-width: 0; border-radius: var(--dk-radius-2); }
+.ws-field[data-changed] .dk-input,
+.ws-field[data-changed] .dk-select,
+.ws-field[data-changed] .ws-prompt,
+.ws-field[data-changed] .textarea { box-shadow: 0 0 0 2px var(--dk-accent); border-color: transparent; }
+.ws-label { font: var(--dk-weight-semibold) 11px / 1.2 var(--dk-font-body); letter-spacing: .07em; text-transform: uppercase; color: var(--dk-muted); }
+.ws-hint { margin: 0; color: var(--dk-muted); font-size: var(--dk-text-12); line-height: 1.4; }
+.ws-field .dk-input, .ws-field .dk-select { height: 38px; font-size: var(--dk-text-13); }
+.ws-field .dk-textarea, .ws-field .textarea {
+ min-height: 64px;
+ padding: var(--dk-space-2) var(--dk-space-3);
+ border: 1px solid var(--dk-control-edge);
+ border-radius: var(--dk-radius-2);
+ background: var(--dk-card);
+ color: var(--dk-text);
+ font: var(--dk-weight-regular) var(--dk-text-13) / 1.5 var(--dk-font-body);
+ resize: vertical;
+}
+.ws-field .textarea:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: 2px; }
+.ws-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: var(--dk-space-3); }
+.ws-grid--wide { grid-template-columns: 1fr; }
+.ws-check { display: inline-flex; align-items: center; gap: var(--dk-space-2); min-height: 38px; font-size: var(--dk-text-13); cursor: pointer; }
+.ws-check input { width: 16px; height: 16px; accent-color: var(--dk-accent); }
+
+/* A required source: a drop area. */
+.ws-drop {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: var(--dk-space-3);
+ padding: var(--dk-space-3) var(--dk-space-4);
+ border: 1.5px dashed var(--dk-control-edge);
+ border-radius: var(--dk-radius-3);
+ color: var(--dk-text);
+ transition: background-color var(--dk-dur-1) ease;
+}
+.ws-drop:focus-within { outline: 2px solid var(--dk-accent); outline-offset: 2px; }
+.ws-drop--hover { background: var(--dk-accent-wash); border-color: var(--dk-accent); }
+.ws-drop--filled { border-style: solid; border-color: var(--dk-edge); background: var(--dk-inset); }
+.ws-drop > .lai-icon { flex: none; font-size: 20px; color: var(--dk-muted); }
+.ws-drop__text { display: grid; flex: 1; min-width: 0; line-height: 1.3; }
+.ws-drop__text b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--dk-text-15); }
+.ws-drop__text small { color: var(--dk-muted); font-size: var(--dk-text-12); }
+.ws-drop .ws-source__x { margin-right: 0; }
+
+/* The saved-voice line under the TTS prompt. */
+.ws-voicebar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--dk-space-3); }
+.ws-voicebar__who { display: inline-flex; align-items: center; gap: var(--dk-space-2); font-size: var(--dk-text-13); }
+.ws-voicebar__who small { color: var(--dk-muted); font: 500 12px / 1 var(--dk-font-mono); }
+.ws-avatar { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; background: var(--dk-inset); box-shadow: inset 0 0 0 1px var(--dk-edge); font: var(--dk-weight-semibold) 10px / 1 var(--dk-font-body); color: var(--dk-text); }
+.ws-hint--error { color: var(--dk-error-text); }
+
+/* The Advanced fold. */
+.ws-fold { border-top: 1px solid var(--dk-edge); }
+.ws-fold__bar {
+ display: flex;
+ align-items: center;
+ gap: var(--dk-space-3);
+ width: 100%;
+ min-height: 44px;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--dk-text);
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+.ws-fold__bar b { font: var(--dk-weight-semibold) var(--dk-text-15) / 1 var(--dk-font-body); }
+.ws-fold__sum { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dk-muted); font: 500 12px / 1 var(--dk-font-mono); }
+.ws-fold__bar .lai-icon { margin-left: auto; color: var(--dk-muted); }
+.ws-fold__bar:hover .lai-icon { color: var(--dk-text); }
+.ws-fold__bar:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: 2px; border-radius: var(--dk-radius-1); }
+.ws-fold__body { display: grid; gap: var(--dk-space-3); padding: var(--dk-space-1) 0 var(--dk-space-3); }
+
+/* The foot: estimate, what changed, the reason, the action. */
+.ws-foot { display: flex; flex-wrap: wrap; align-items: center; gap: var(--dk-space-3) var(--dk-space-4); padding-top: var(--dk-space-1); }
+.ws-estimate { display: inline-flex; align-items: center; gap: var(--dk-space-2); min-height: 20px; color: var(--dk-muted); font: 500 12px / 1.3 var(--dk-font-mono); }
+.ws-estimate:empty { display: none; }
+.ws-estimate[data-fit="spill"] { color: var(--dk-warn-text); }
+.ws-estimate[data-fit="over"] { color: var(--dk-error-text); }
+.ws-changes { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--dk-muted); font-size: var(--dk-text-12); }
+.ws-changes code {
+ padding: 3px 8px;
+ border-radius: var(--dk-radius-1);
+ background: var(--dk-accent-wash);
+ color: var(--dk-accent-text);
+ font: 500 11.5px / 1.2 var(--dk-font-mono);
+}
+.ws-go { display: inline-flex; align-items: center; gap: var(--dk-space-3); margin-left: auto; flex-wrap: wrap; justify-content: flex-end; }
+.ws-go__why { color: var(--dk-muted); font-size: var(--dk-text-13); }
+
+/* --- Run area -------------------------------------------------------------- */
+
+.ws-run { display: grid; gap: var(--dk-space-4); min-width: 0; background: none; border: 0; padding: 0; min-height: 0; border-radius: 0; }
+.ws-empty {
+ display: flex;
+ width: 100%;
+ align-items: center;
+ gap: var(--dk-space-3);
+ padding: var(--dk-space-4) var(--dk-space-5);
+ border: 1.5px dashed var(--dk-control-edge);
+ border-radius: var(--dk-radius-4);
+ color: var(--dk-muted);
+ font-size: var(--dk-text-13);
+}
+.ws-empty p { margin: 0; }
+.ws-empty .lai-icon { font-size: 20px; }
+
+.ws-job {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 260px);
+ gap: var(--dk-space-5);
+ align-items: center;
+ padding: var(--dk-space-5);
+ border-radius: var(--dk-radius-4);
+ background: var(--dk-card);
+ box-shadow: inset 0 0 0 1px var(--dk-edge);
+}
+.ws-job:not(:has(.ws-job__tiles)) { grid-template-columns: 1fr; }
+.ws-job__text { display: grid; gap: var(--dk-space-3); }
+.ws-job__title { display: flex; align-items: center; gap: var(--dk-space-2); margin: 0; font: var(--dk-weight-semibold) var(--dk-text-17) / 1.2 var(--dk-font-display); }
+.ws-job__time { margin-left: auto; color: var(--dk-muted); font: 500 12px / 1 var(--dk-font-mono); }
+.ws-job__detail { margin: 0; color: var(--dk-muted); font: 500 12px / 1.4 var(--dk-font-mono); overflow-wrap: anywhere; }
+.ws-job__tiles { display: grid; gap: var(--dk-space-2); }
+.ws-job__tiles--n1 { grid-template-columns: 1fr; }
+.ws-job__tiles--n2, .ws-job__tiles--n3, .ws-job__tiles--n4 { grid-template-columns: repeat(2, 1fr); }
+.ws-job__tile { display: block; width: 100%; height: auto; aspect-ratio: var(--ratio, 1); border-radius: var(--dk-radius-2); }
+@media (prefers-reduced-motion: reduce) {
+ .ws-job .dk-progress[data-indeterminate] .dk-progress-bar { animation: none; width: 100%; opacity: .45; }
+}
+
+.ws-failed {
+ display: flex;
+ gap: var(--dk-space-3);
+ padding: var(--dk-space-4) var(--dk-space-5);
+ border-radius: var(--dk-radius-4);
+ background: var(--dk-error-wash);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dk-error) 40%, transparent);
+ color: var(--dk-text);
+}
+.ws-failed > .lai-icon { flex: none; margin-top: 2px; font-size: 20px; color: var(--dk-error-text); }
+.ws-failed__body { display: grid; gap: var(--dk-space-2); min-width: 0; }
+.ws-failed h3 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-17) / 1.3 var(--dk-font-display); color: var(--dk-error-text); }
+.ws-failed p { margin: 0; max-width: 72ch; font-size: var(--dk-text-13); line-height: 1.5; overflow-wrap: anywhere; }
+.ws-failed__note { color: var(--dk-muted); }
+.ws-failed__row { display: flex; align-items: center; flex-wrap: wrap; gap: var(--dk-space-3); margin-top: var(--dk-space-1); }
+
+/* --- Result ----------------------------------------------------------------- */
+
+.ws-result {
+ position: relative;
+ min-width: 0;
+ overflow: visible;
+ border-radius: var(--dk-radius-4);
+ background: var(--dk-card);
+ box-shadow: inset 0 0 0 1px var(--dk-edge);
+}
+.ws-result__head { display: flex; align-items: center; flex-wrap: wrap; gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); border-bottom: 1px solid var(--dk-edge); }
+.ws-result__who { min-width: 0; flex: 1 1 220px; }
+.ws-result__who h2 { margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--dk-weight-semibold) var(--dk-text-15) / 1.3 var(--dk-font-display); }
+.ws-result__who p { margin: 2px 0 0; color: var(--dk-muted); font: 500 12px / 1.3 var(--dk-font-mono); }
+.ws-result__tools { display: inline-flex; align-items: center; flex-wrap: wrap; gap: var(--dk-space-2); }
+.ws-tool {
+ display: grid;
+ place-items: center;
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ border: 0;
+ border-radius: var(--dk-radius-2);
+ background: transparent;
+ color: var(--dk-text);
+ font-size: 18px;
+ cursor: pointer;
+ text-decoration: none;
+ transition: background-color var(--dk-dur-1) ease, transform var(--dk-dur-1) var(--dk-ease-out);
+}
+.ws-tool:hover { background: var(--dk-hover); }
+.ws-tool:active { transform: scale(var(--dk-press-scale)); }
+.ws-tool:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: 1px; }
+.ws-tool .studio-star__glyph { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linejoin: round; }
+.ws-tool[aria-pressed="true"] .studio-star__glyph { fill: currentColor; }
+.ws-tool[aria-pressed="true"] { color: var(--dk-warn-text); }
+
+.ws-result__view { background: var(--dk-inset); padding: var(--dk-space-4); display: grid; justify-items: center; align-content: start; gap: var(--dk-space-3); min-width: 0; }
+.ws-result__view > * { max-width: 100%; }
+.ws-result__view img, .ws-result__view video { max-width: 100%; max-height: min(70vh, 640px); border-radius: var(--dk-radius-2); }
+.ws-result__meta { display: flex; flex-wrap: wrap; gap: var(--dk-space-1) var(--dk-space-4); padding: var(--dk-space-3) var(--dk-space-4); border-top: 1px solid var(--dk-edge); color: var(--dk-muted); font: 500 12px / 1.4 var(--dk-font-mono); }
+.ws-result__meta:empty { display: none; }
+.ws-result__meta a { color: var(--dk-accent-text); }
+
+.ws-grid-images { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--dk-space-3); width: 100%; }
+.ws-grid-images--one { grid-template-columns: minmax(0, 560px); justify-content: center; }
+.ws-grid-images .media-result-thumb { border-radius: var(--dk-radius-2); }
+.ws-grid-images .media-result-thumb img { width: 100%; max-height: min(70vh, 640px); object-fit: contain; background: var(--dk-card); }
+
+/* Audio viewers keep the player's own waveform and controls. */
+.ws-audio { display: grid; gap: var(--dk-space-3); width: 100%; }
+.ws-audio .audio-waveform-player { background: var(--dk-card); border-radius: var(--dk-radius-3); box-shadow: inset 0 0 0 1px var(--dk-edge); padding: var(--dk-space-3); }
+.ws-quote { margin: 0; max-width: 70ch; color: var(--dk-text); font-size: var(--dk-text-15); line-height: 1.5; justify-self: start; overflow-wrap: anywhere; }
+
+/* Use in menu. */
+.ws-menu { min-width: 280px; z-index: 30; }
+.ws-menu__none { margin: 0; padding: var(--dk-space-3); color: var(--dk-muted); font-size: var(--dk-text-13); }
+.ws-menu__item { height: auto; min-height: 48px; padding: var(--dk-space-2) var(--dk-space-3); align-items: flex-start; }
+.ws-menu__item .lai-icon { margin-top: 2px; }
+.ws-menu__text { display: grid; gap: 3px; line-height: 1.25; }
+.ws-menu__text b { font-weight: var(--dk-weight-semibold); font-size: var(--dk-text-13); }
+.ws-menu__text small { color: var(--dk-muted); font-size: var(--dk-text-12); }
+.ws-menu__item[data-state="missing"] small { color: var(--dk-warn-text); }
+.ws-menu__item:disabled { opacity: 1; pointer-events: auto; cursor: not-allowed; }
+.ws-menu__item:disabled b, .ws-menu__item:disabled .lai-icon { opacity: .55; }
+
+/* --- Strip ------------------------------------------------------------------- */
+
+.ws-strip__head { display: flex; align-items: center; flex-wrap: wrap; gap: var(--dk-space-3); }
+.ws-strip__head h2 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-17) / 1.2 var(--dk-font-display); letter-spacing: -.01em; }
+.ws-strip__count { color: var(--dk-muted); font: 500 12px / 1 var(--dk-font-mono); }
+.ws-strip__tools { display: inline-flex; align-items: center; flex-wrap: wrap; gap: var(--dk-space-3); margin-left: auto; }
+.ws-strip__filters { margin: 0; }
+.ws-strip__clear {
+ display: grid;
+ place-items: center;
+ width: 30px;
+ height: 30px;
+ border: 0;
+ border-radius: var(--dk-radius-1);
+ background: transparent;
+ color: var(--dk-muted);
+ cursor: pointer;
+}
+.ws-strip__clear:hover { background: var(--dk-hover); color: var(--dk-text); }
+.ws-strip__empty { margin: var(--dk-space-3) 0 0; color: var(--dk-muted); font-size: var(--dk-text-13); }
+.ws-strip__list { display: flex; flex-wrap: wrap; gap: var(--dk-space-3); margin: var(--dk-space-3) 0 0; padding: 0; list-style: none; }
+
+.ws-tile { position: relative; width: 132px; }
+.ws-tile__open {
+ display: grid;
+ width: 100%;
+ gap: 6px;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+.ws-tile__open .studio-thumb { height: 98px; border-radius: var(--dk-radius-2); box-shadow: inset 0 0 0 1px var(--dk-edge); transition: box-shadow var(--dk-dur-1) ease; }
+.ws-tile__open:hover .studio-thumb { box-shadow: inset 0 0 0 1px var(--dk-control-edge); }
+.ws-tile__open[aria-pressed="true"] .studio-thumb { box-shadow: inset 0 0 0 2px var(--dk-text); }
+.ws-tile__open:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: 2px; border-radius: var(--dk-radius-2); }
+.ws-tile[data-active] .ws-tile__title { font-weight: var(--dk-weight-semibold); }
+.ws-tile__cap { display: grid; gap: 2px; min-width: 0; }
+.ws-tile__title { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--dk-text-12); color: var(--dk-text); }
+.ws-tile__cap small { color: var(--dk-muted); font: 500 11px / 1.2 var(--dk-font-mono); }
+.ws-tile__star {
+ position: absolute;
+ left: 6px;
+ top: 6px;
+ display: grid;
+ place-items: center;
+ width: 24px;
+ height: 24px;
+ border-radius: var(--dk-radius-1);
+ background: rgb(10 16 15 / .72);
+ color: #f1f5f4;
+ pointer-events: none;
+}
+.ws-tile__star svg { width: 13px; height: 13px; fill: currentColor; stroke: currentColor; stroke-width: 1.5; stroke-linejoin: round; }
+.ws-tile__del {
+ position: absolute;
+ right: 6px;
+ top: 6px;
+ display: grid;
+ place-items: center;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ border: 0;
+ border-radius: var(--dk-radius-1);
+ background: rgb(10 16 15 / .72);
+ color: #f1f5f4;
+ font-size: 14px;
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity var(--dk-dur-1) ease;
+}
+.ws-tile:hover .ws-tile__del, .ws-tile__del:focus-visible { opacity: 1; }
+@media (hover: none) { .ws-tile__del { opacity: 1; } }
+
+/* The studio-thumb inside a tile fills the tile width. */
+.ws-tile .studio-thumb--compact { width: 100%; }
+
+/* --- Phone ------------------------------------------------------------------- */
+
+@media (max-width: 640px) {
+ .ws { gap: var(--dk-space-4); }
+ .ws-compose { padding: var(--dk-space-4); }
+ .ws-job { grid-template-columns: 1fr; padding: var(--dk-space-4); }
+ .ws-go { margin-left: 0; width: 100%; justify-content: space-between; }
+ .ws-go__btn { flex: 1 1 auto; justify-content: center; }
+ .ws-strip__tools { margin-left: 0; }
+ .ws-tile { width: calc(50% - var(--dk-space-3) / 2); }
+ .ws-result__tools { width: 100%; }
+ .ws-result__tools .dk-anchor { position: static; }
+ .ws-menu { top: auto; left: var(--dk-space-3); right: var(--dk-space-3); min-width: 0; max-width: none; }
+}
+
+/* --- Inputs of the audio types ------------------------------------------------ */
+
+.ws-inputs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--dk-space-4); align-items: start; }
+.ws-inputs--one { grid-template-columns: minmax(0, 1fr); }
+.ws-compose .ws-inputs .form-group { margin: 0; }
+.ws-compose .ws-inputs .form-label,
+.ws-compose .ws-inputs .biometrics-mediainput__label { font: var(--dk-weight-semibold) 11px / 1.2 var(--dk-font-body); letter-spacing: .07em; text-transform: uppercase; color: var(--dk-muted); }
+.ws-compose .ws-inputs .form-help { color: var(--dk-muted); font-size: var(--dk-text-12); }
+.ws-compose .audio-transform-echo { margin-top: var(--dk-space-1); }
+.ws-chip-group { display: contents; }
+.ws-optin { display: grid; gap: var(--dk-space-1); }
+.ws-optin .ws-hint { max-width: 72ch; }
+.ws-tool--text { width: auto; padding: 0 var(--dk-space-2); font: var(--dk-weight-semibold) var(--dk-text-13) / 1 var(--dk-font-mono); }
+
+/* --- Viewers ------------------------------------------------------------------- */
+
+.ws-viewer { display: grid; gap: var(--dk-space-3); width: 100%; min-width: 0; }
+.ws-result__view .glb-viewer, .ws-viewer .glb-viewer { width: 100%; }
+.ws-viewer .threed-remesh-controls { background: var(--dk-card); border-radius: var(--dk-radius-3); box-shadow: inset 0 0 0 1px var(--dk-edge); border: 0; }
+.ws-audio .audio-spectrogram__canvas-wrap--empty { height: 140px; }
+
+/* Diarization: timeline, talk time, segments. */
+.ws-diar { display: grid; gap: var(--dk-space-3); width: 100%; min-width: 0; justify-items: stretch; }
+.ws-diar h3 { margin: var(--dk-space-2) 0 0; font: var(--dk-weight-semibold) var(--dk-text-15) / 1.3 var(--dk-font-display); }
+.ws-diar__head { display: flex; align-items: center; gap: var(--dk-space-3); }
+.ws-diar__audio { width: 100%; }
+
+.ws-timeline { display: grid; gap: var(--dk-space-2); padding: var(--dk-space-3); border-radius: var(--dk-radius-3); background: var(--dk-card); box-shadow: inset 0 0 0 1px var(--dk-edge); }
+.ws-timeline__axis, .ws-timeline__lane { display: grid; grid-template-columns: 132px minmax(0, 1fr); gap: var(--dk-space-3); align-items: center; }
+.ws-timeline__axis { position: relative; height: 18px; display: block; margin-left: calc(132px + var(--dk-space-3)); }
+.ws-timeline__axis span { position: absolute; left: var(--at); transform: translateX(-50%); color: var(--dk-muted); font: 500 11px / 1 var(--dk-font-mono); }
+.ws-timeline__axis span:first-child { transform: none; }
+.ws-timeline__axis span:last-child { transform: translateX(-100%); }
+.ws-timeline__who { display: inline-flex; align-items: center; gap: var(--dk-space-2); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--dk-weight-semibold) 12px / 1 var(--dk-font-mono); }
+.ws-timeline__who i, .ws-speaker h4 i, .ws-segments li strong::before {
+ flex: none;
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ border-radius: 3px;
+ background: var(--sp, var(--dk-series-1));
+}
+.ws-timeline__track { position: relative; height: 22px; border-radius: var(--dk-radius-1); background: var(--dk-inset); box-shadow: inset 0 0 0 1px var(--dk-edge); }
+.ws-timeline__track b { position: absolute; left: var(--from); width: var(--len); top: 0; bottom: 0; border-radius: 3px; background: var(--sp, var(--dk-series-1)); }
+[data-speaker="0"] { --sp: var(--dk-series-1); }
+[data-speaker="1"] { --sp: var(--dk-series-2); }
+[data-speaker="2"] { --sp: var(--dk-series-3); }
+[data-speaker="3"] { --sp: var(--dk-series-4); }
+[data-speaker="4"] { --sp: var(--dk-series-5); }
+[data-speaker="5"] { --sp: var(--dk-series-6); }
+
+.ws-speakers { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: var(--dk-space-3); margin: 0; padding: 0; list-style: none; }
+.ws-speaker { display: grid; gap: var(--dk-space-2); align-content: start; padding: var(--dk-space-3); border-radius: var(--dk-radius-3); background: var(--dk-card); box-shadow: inset 0 0 0 1px var(--dk-edge); }
+.ws-speaker h4 { display: flex; align-items: center; gap: var(--dk-space-2); margin: 0; font: var(--dk-weight-semibold) var(--dk-text-13) / 1.2 var(--dk-font-mono); overflow-wrap: anywhere; }
+.ws-speaker p { margin: 0; color: var(--dk-text); font-size: var(--dk-text-13); line-height: 1.4; }
+.ws-speaker .ws-speaker__talk { color: var(--dk-muted); font: 500 12px / 1.3 var(--dk-font-mono); }
+.ws-speaker__bar { display: block; height: 4px; border-radius: 2px; background: var(--dk-inset); box-shadow: inset 0 0 0 1px var(--dk-edge); overflow: hidden; }
+.ws-speaker__bar b { display: block; width: var(--w); height: 100%; background: var(--sp, var(--dk-series-1)); }
+
+.ws-segments { display: grid; margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--dk-edge); }
+.ws-segments li {
+ display: grid;
+ grid-template-columns: 52px minmax(0, 150px) minmax(0, 1fr);
+ grid-template-areas: "time who range" ". text text";
+ gap: 2px var(--dk-space-3);
+ align-items: baseline;
+ padding: var(--dk-space-2) 0;
+ border-bottom: 1px solid var(--dk-edge);
+ font-size: var(--dk-text-13);
+}
+.ws-segments__time { grid-area: time; color: var(--dk-muted); font: 500 12px / 1.3 var(--dk-font-mono); }
+.ws-segments li strong { grid-area: who; display: inline-flex; align-items: center; gap: 6px; min-width: 0; font: var(--dk-weight-semibold) 12px / 1.3 var(--dk-font-mono); overflow-wrap: anywhere; }
+.ws-segments__range { grid-area: range; color: var(--dk-muted); font: 500 11px / 1.3 var(--dk-font-mono); justify-self: end; }
+.ws-segments li p { grid-area: text; margin: 0; color: var(--dk-text); font-size: var(--dk-text-15); line-height: 1.5; }
+
+@media (max-width: 640px) {
+ .ws-timeline__axis span:nth-child(even) { display: none; }
+ .ws-inputs { grid-template-columns: minmax(0, 1fr); }
+ .ws-timeline__axis { margin-left: calc(104px + var(--dk-space-3)); }
+ .ws-timeline__axis, .ws-timeline__lane { grid-template-columns: 104px minmax(0, 1fr); }
+ .ws-segments li { grid-template-columns: 44px minmax(0, 1fr); grid-template-areas: "time who" ". range" ". text"; }
+ .ws-segments__range { justify-self: start; }
+}
+
+/* The kit has no file input; the browser's own button is quieted to match. */
+.ws-compose input[type="file"] {
+ max-width: 100%;
+ padding: var(--dk-space-2);
+ border: 1px solid var(--dk-control-edge);
+ border-radius: var(--dk-radius-2);
+ background: var(--dk-card);
+ color: var(--dk-text);
+ font-size: var(--dk-text-13);
+}
+.ws-compose input[type="file"]::file-selector-button {
+ margin-right: var(--dk-space-3);
+ padding: 6px var(--dk-space-3);
+ border: 0;
+ border-radius: var(--dk-radius-1);
+ background: var(--dk-inset);
+ box-shadow: inset 0 0 0 1px var(--dk-edge);
+ color: var(--dk-text);
+ font: var(--dk-weight-semibold) var(--dk-text-13) / 1 var(--dk-font-body);
+ cursor: pointer;
+}
+.ws-strip__empty { text-align: left; }
diff --git a/core/http/react-ui/src/components/swarm/AddNodeFlow.jsx b/core/http/react-ui/src/components/swarm/AddNodeFlow.jsx
new file mode 100644
index 000000000..e202c5c74
--- /dev/null
+++ b/core/http/react-ui/src/components/swarm/AddNodeFlow.jsx
@@ -0,0 +1,234 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import { nodesApi, p2pApi } from '../../utils/api'
+import { usePolling } from '../../hooks/usePolling'
+import { useImageSelector } from '../ImageSelector'
+import { DISTRIBUTED_COMMAND, p2pCommand, workerCommand } from '../../utils/swarm'
+import CommandBlock from './CommandBlock'
+import NodeState from './NodeState'
+import Icon from '../Icon'
+
+const METHODS = ['worker', 'peer', 'shard']
+
+function Step({ n, title, children, id }) {
+ return (
+
+ {n}
+
+
{title}
+ {children}
+
+
+ )
+}
+
+function Segmented({ label, value, onChange, options }) {
+ return (
+
+ {options.map(([key, text]) => (
+ onChange(key)}>{text}
+ ))}
+
+ )
+}
+
+// Reads the P2P token and the number of peers online, for the two methods that
+// join over P2P. `enabled` is false for a registered worker, which uses neither.
+function useP2P(enabled) {
+ const [token, setToken] = useState(null)
+ const [stats, setStats] = useState(null)
+ const read = async () => {
+ const [tokenResult, statsResult] = await Promise.allSettled([p2pApi.getToken(), p2pApi.getStats()])
+ if (tokenResult.status === 'fulfilled') {
+ const value = tokenResult.value
+ setToken((typeof value === 'string' ? value : value?.token || '').trim())
+ } else {
+ setToken('')
+ }
+ if (statsResult.status === 'fulfilled') setStats(statsResult.value)
+ }
+ usePolling(read, 3000, { enabled })
+ const online = stats ? (stats.federated?.online ?? 0) + (stats.llama_cpp_workers?.online ?? 0) + (stats.mlx_workers?.online ?? 0) : null
+ return { token, online, loaded: token !== null }
+}
+
+function Elapsed({ since }) {
+ const [now, setNow] = useState(() => Date.now())
+ useEffect(() => {
+ const id = setInterval(() => setNow(Date.now()), 1000)
+ return () => clearInterval(id)
+ }, [])
+ const seconds = Math.max(0, Math.floor((now - since) / 1000))
+ return {Math.floor(seconds / 60)}:{String(seconds % 60).padStart(2, '0')}
+}
+
+// "Add a node": which way the machine joins, the one command to run on it, and
+// a live line that says when it has arrived. It reads the roster the page
+// already polls, so "found" is a real node that was not there when the page
+// opened, never a timer.
+//
+// nodes the roster (array), or null when this install is not a cluster
+// single this install is not in distributed mode yet
+// initialMethod 'worker' | 'peer' | 'shard', the join method shown first
+export default function AddNodeFlow({ nodes, single, addToast, onApproved, initialMethod }) {
+ const { t } = useTranslation('swarm')
+ const [method, setMethod] = useState(METHODS.includes(initialMethod) ? initialMethod : 'worker')
+ const [shard, setShard] = useState('llama')
+ const [flavor, setFlavor] = useState('docker')
+ const [nodeType, setNodeType] = useState('backend')
+ const hardware = useImageSelector('cpu')
+ const [startedAt] = useState(() => Date.now())
+ const p2p = useP2P(method !== 'worker')
+ const [approving, setApproving] = useState(null)
+
+ // The roster at the moment the page opened. Anything else is new.
+ const baseline = useRef(null)
+ if (baseline.current === null && Array.isArray(nodes)) baseline.current = new Set(nodes.map(node => node.id))
+ const p2pBaseline = useRef(null)
+ if (p2pBaseline.current === null && p2p.online !== null) p2pBaseline.current = p2p.online
+
+ const arrived = useMemo(() => (
+ Array.isArray(nodes) && baseline.current ? nodes.filter(node => !baseline.current.has(node.id)) : []
+ ), [nodes])
+
+ const frontend = typeof window === 'undefined' ? '' : window.location.origin
+ const command = method === 'worker'
+ ? workerCommand({ flavor, nodeType, option: hardware.option, dev: hardware.dev, frontend })
+ : p2pCommand({ method: method === 'peer' ? 'peer' : shard === 'mlx' ? 'mlx' : 'shard', option: hardware.option, dev: hardware.dev, token: p2p.token, flavor })
+
+ const approve = async (node) => {
+ setApproving(node.id)
+ try {
+ await nodesApi.approve(node.id)
+ addToast?.(t('toast.approved', { name: node.name }), 'success')
+ await onApproved?.()
+ } catch (error) {
+ addToast?.(error.message, 'error')
+ } finally {
+ setApproving(null)
+ }
+ }
+
+ const needsDistributed = single && method === 'worker'
+ const p2pOff = method !== 'worker' && p2p.loaded && !p2p.token
+ let n = 0
+ const next = () => { n += 1; return n }
+ const p2pFound = method !== 'worker' && p2p.online !== null && p2pBaseline.current !== null && p2p.online > p2pBaseline.current
+
+ return (
+
+
+ [key, t(`add.join.${key}`)])}
+ />
+ {t(`add.join.${method}Sub`)}
+ {method === 'shard' && (
+
+ )}
+
+
+ {needsDistributed && (
+
+ {t('add.distributed.sub')}
+
+
+ {t('add.distributed.docs')}{' '}
+ {t('add.distributed.docsLink')}
+
+
+ )}
+
+ {p2pOff && (
+
+ {t('add.p2pOff.sub')}
+
+
+ )}
+
+
+
+ {flavor === 'docker' && !(method === 'shard' && shard === 'mlx') && (
+
+ {hardware.options.map(option => (
+ hardware.setSelected(option.key)}
+ >
+ {option.label}
+
+ ))}
+
+ )}
+
+ {method === 'worker' && (
+
+ )}
+ {flavor === 'docker' && (
+
hardware.setDev(!hardware.dev)} title={t('add.run.devTitle')}>{t('add.run.dev')}
+ )}
+
+
+
+ {method === 'worker' ? t('add.run.tokenNote') : t('add.run.p2pNote')}
+
+
+
+
0 || p2pFound ? t('add.wait.foundTitle') : t('add.wait.title')}>
+ {method === 'worker' && arrived.length === 0 && (
+
+
+ {t('add.wait.listening')}
+
+ )}
+ {method === 'worker' && arrived.map(node => (
+
+
+ {node.name}
+ {node.address}
+
+
+
+ {node.status === 'pending' && (
+ approve(node)}>{t('actions.approve')}
+ )}
+ {t('add.wait.open')}
+
+
+ ))}
+ {method !== 'worker' && !p2pFound && (
+
+
+
+ {p2p.online === null ? t('add.wait.p2pListening') : t('add.wait.p2pCount', { count: p2p.online })}
+
+
+ )}
+ {method !== 'worker' && p2pFound && (
+
+ {t('add.wait.p2pFound', { count: p2p.online })}
+ {t('add.wait.p2pOpen')}
+
+ )}
+ {arrived.length === 0 && !p2pFound && {t(method === 'worker' ? 'add.wait.stuck' : 'add.wait.p2pStuck')}
}
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/swarm/CommandBlock.jsx b/core/http/react-ui/src/components/swarm/CommandBlock.jsx
new file mode 100644
index 000000000..12d68b59b
--- /dev/null
+++ b/core/http/react-ui/src/components/swarm/CommandBlock.jsx
@@ -0,0 +1,35 @@
+import { useEffect, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { copyToClipboard } from '../../utils/clipboard'
+import Icon from '../Icon'
+
+// A command to paste into a terminal, with a Copy button. The block scrolls
+// sideways on a narrow screen instead of wrapping, because a wrapped command
+// is a broken command. The text is real text: select it, or copy it.
+export default function CommandBlock({ command, label, addToast }) {
+ const { t } = useTranslation('swarm')
+ const [copied, setCopied] = useState(false)
+ const timer = useRef(null)
+ useEffect(() => () => clearTimeout(timer.current), [])
+
+ const copy = async () => {
+ const ok = await copyToClipboard(command)
+ if (ok) {
+ setCopied(true)
+ clearTimeout(timer.current)
+ timer.current = setTimeout(() => setCopied(false), 2000)
+ addToast?.(t('command.copied'), 'success', 2000)
+ } else {
+ addToast?.(t('command.copyFailed'), 'error')
+ }
+ }
+
+ return (
+
+
{command}
+
+ {copied ? t('command.done') : t('command.copy')}
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/swarm/LeaveList.jsx b/core/http/react-ui/src/components/swarm/LeaveList.jsx
new file mode 100644
index 000000000..7544cbf6b
--- /dev/null
+++ b/core/http/react-ui/src/components/swarm/LeaveList.jsx
@@ -0,0 +1,39 @@
+import { useTranslation } from 'react-i18next'
+import Icon from '../Icon'
+
+const ICON = { inflight: 'clock', stays: 'check', blocked: 'alert-circle', reload: 'refresh' }
+
+function selectorText(selector) {
+ return Object.entries(selector || {}).map(([k, v]) => `${k}=${v}`).join(', ')
+}
+
+// The lines of a drain or lost-node preview. Each line is one sentence about one
+// model, so a person can read what changes without a legend.
+//
+// `lost` words the same facts for a node that is already gone: nothing is
+// "finishing first" on it.
+export default function LeaveList({ items, lost = false }) {
+ const { t } = useTranslation('swarm')
+ if (items.length === 0) return {t(lost ? 'leave.emptyLost' : 'leave.empty')}
+ return (
+
+ {items.map(item => (
+
+
+
+ {item.kind === 'inflight' && t(lost ? 'leave.inflightLost' : 'leave.inflight', { count: item.count })}
+ {item.kind === 'stays' && <>
+ {item.model} {t('leave.staysOn', { nodes: item.on.join(', ') })}
+ >}
+ {item.kind === 'blocked' && <>
+ {item.model} {t('leave.blocked', { selector: selectorText(item.selector) })}
+ >}
+ {item.kind === 'reload' && <>
+ {item.model} {t(item.candidates > 0 ? 'leave.reload' : 'leave.reloadNone', { count: item.candidates })}
+ >}
+
+
+ ))}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/swarm/MemoryMeter.jsx b/core/http/react-ui/src/components/swarm/MemoryMeter.jsx
new file mode 100644
index 000000000..327dd2903
--- /dev/null
+++ b/core/http/react-ui/src/components/swarm/MemoryMeter.jsx
@@ -0,0 +1,41 @@
+import { useTranslation } from 'react-i18next'
+import { formatBytes } from '../nodes/nodeStatus'
+import { memoryOf } from '../../utils/swarm'
+import { cssVars } from '../../utils/modelLedger'
+
+// One node's memory as a bar and two lines of words. A node that reports no
+// memory says so; a bar for it would be an invention.
+export default function MemoryMeter({ node }) {
+ const { t } = useTranslation('swarm')
+ const memory = memoryOf(node)
+ if (!memory) return {t('table.noReading')}
+ const percent = Math.round(memory.usagePercent)
+ const level = percent >= 90 ? ' dk-meter-seg--error' : percent >= 75 ? ' dk-meter-seg--warn' : ''
+ const ram = capacityLine(node)
+ return (
+
+
+
+
+
+ {formatBytes(memory.used)} / {formatBytes(memory.total)}
+
+
+ {memory.kind === 'gpu'
+ ? [node.gpu_vendor || t('table.gpu'), ram].filter(Boolean).join(' · ')
+ : t('table.cpuOnly')}
+
+
+ )
+
+ function capacityLine(n) {
+ const total = Number(n.total_ram)
+ const free = Number(n.available_ram)
+ if (!(total > 0) || !Number.isFinite(free)) return ''
+ return t('table.ramLine', { used: formatBytes(Math.max(0, total - free)), total: formatBytes(total) })
+ }
+}
diff --git a/core/http/react-ui/src/components/swarm/ModelPicker.jsx b/core/http/react-ui/src/components/swarm/ModelPicker.jsx
new file mode 100644
index 000000000..be6cabd92
--- /dev/null
+++ b/core/http/react-ui/src/components/swarm/ModelPicker.jsx
@@ -0,0 +1,93 @@
+import { useEffect, useId, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useModels } from '../../hooks/useModels'
+
+const LIMIT = 50
+
+// A model name, typed or picked. A rule may be written before its model is
+// installed, so what is typed is kept as it is: the list only helps. Aliases
+// are offered beside models, each with the model it resolves to.
+//
+// hints { [name]: 'alias of x' } shown under a name and searched with it
+export default function ModelPicker({ id, value, onChange, hints = {} }) {
+ const { t } = useTranslation('swarm')
+ const { models } = useModels()
+ const listId = useId()
+ const [open, setOpen] = useState(false)
+ const [active, setActive] = useState(-1)
+ const wrapRef = useRef(null)
+
+ useEffect(() => {
+ const away = event => { if (wrapRef.current && !wrapRef.current.contains(event.target)) setOpen(false) }
+ document.addEventListener('mousedown', away)
+ return () => document.removeEventListener('mousedown', away)
+ }, [])
+
+ const needle = (value || '').toLowerCase()
+ const matches = useMemo(() => {
+ const names = [...new Set([...models.map(m => m.id), ...Object.keys(hints)])]
+ return names.filter(name => name.toLowerCase().includes(needle) || (hints[name] || '').toLowerCase().includes(needle)).slice(0, LIMIT)
+ }, [models, hints, needle])
+
+ const pick = name => { onChange(name); setOpen(false); setActive(-1) }
+
+ const onKeyDown = event => {
+ if (event.key === 'ArrowDown') {
+ event.preventDefault()
+ if (!open) { setOpen(true); return }
+ setActive(i => Math.min(i + 1, matches.length - 1))
+ } else if (event.key === 'ArrowUp') {
+ event.preventDefault()
+ setActive(i => Math.max(i - 1, 0))
+ } else if (event.key === 'Enter' && open) {
+ event.preventDefault()
+ pick(active >= 0 ? matches[active] : (matches.length > 0 && !matches.includes(value) ? matches[0] : value))
+ } else if (event.key === 'Escape' && open) {
+ event.preventDefault()
+ setOpen(false)
+ setActive(-1)
+ }
+ }
+
+ return (
+
+
= 0 ? `${listId}-${active}` : undefined}
+ autoComplete="off"
+ spellCheck={false}
+ placeholder={t('sheet.modelPlaceholder')}
+ value={value}
+ onChange={event => { onChange(event.target.value); setOpen(true); setActive(-1) }}
+ onFocus={() => setOpen(true)}
+ onKeyDown={onKeyDown}
+ />
+ {open && (
+
+ {matches.map((name, index) => (
+
{ event.preventDefault(); pick(name) }}
+ onMouseEnter={() => setActive(index)}
+ >
+
+ {name}
+ {hints[name] && {hints[name]} }
+
+
+ ))}
+ {matches.length === 0 &&
{t('sheet.pickerEmpty', { name: value })}
}
+
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/swarm/NodeMap.jsx b/core/http/react-ui/src/components/swarm/NodeMap.jsx
new file mode 100644
index 000000000..e54fb75d3
--- /dev/null
+++ b/core/http/react-ui/src/components/swarm/NodeMap.jsx
@@ -0,0 +1,93 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { Link } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import { cssVars } from '../../utils/modelLedger'
+import { formatBytes } from '../nodes/nodeStatus'
+import { memoryOf } from '../../utils/swarm'
+import NodeState from './NodeState'
+
+const CARD = 84
+const GAP = 10
+// A map of a thousand nodes is not a map. Past this the list is the tool.
+export const MAP_LIMIT = 24
+
+// Where requests go: this instance on the left, one card per worker on the
+// right. A line is a registered worker; a dashed line is a worker that gets no
+// traffic now (it is waiting, draining or not answering). It shows the same
+// facts as the list and no health number the list lacks.
+//
+// `rows` are the loaded replicas, or null until they have been read.
+export default function NodeMap({ nodes, rows }) {
+ const { t } = useTranslation('swarm')
+ const shown = nodes.slice(0, MAP_LIMIT)
+ const height = shown.length * CARD + Math.max(0, shown.length - 1) * GAP
+ const mid = height / 2
+ const modelNames = node => {
+ if (!rows) return null
+ return [...new Set(rows.filter(r => r.node_id === node.id && (!r.state || r.state === 'loaded')).map(r => r.model_name))]
+ }
+
+ return (
+
+
+
+ {t('map.instance')}
+ {t('map.instanceSub')}
+
+
{t('map.bus')}
+
+
+ {shown.map((node, index) => {
+ const y = index * (CARD + GAP) + CARD / 2
+ const routed = node.status === 'healthy'
+ const down = node.status === 'unhealthy' || node.status === 'offline'
+ return (
+
+ )
+ })}
+
+
+ {shown.map(node => {
+ const memory = memoryOf(node)
+ const names = modelNames(node)
+ return (
+
+
+
+ {node.name}
+
+ {memory ? (memory.kind === 'gpu' ? `${formatBytes(memory.used)} / ${formatBytes(memory.total)}` : t('map.cpuOnly')) : ''}
+
+
+
+
+ {node.node_type === 'agent'
+ ? t('map.agent')
+ : names === null ? ' '
+ : names.length === 0 ? t('map.noModels')
+ : names.join(', ')}
+
+
+
+ )
+ })}
+
+ {nodes.length > shown.length && (
+
{t('map.more', { count: nodes.length - shown.length })}
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/swarm/NodeState.jsx b/core/http/react-ui/src/components/swarm/NodeState.jsx
new file mode 100644
index 000000000..b27cf8afc
--- /dev/null
+++ b/core/http/react-ui/src/components/swarm/NodeState.jsx
@@ -0,0 +1,19 @@
+import { useTranslation } from 'react-i18next'
+import { nodeState } from '../../utils/swarm'
+import Icon from '../Icon'
+
+const ICON = { ok: 'check-circle', warn: 'warning', error: 'alert-circle', idle: 'circle' }
+
+// A node's state as a mark and a word. The word always says it: the colour only
+// repeats it.
+export default function NodeState({ node }) {
+ const { t } = useTranslation('swarm')
+ const state = nodeState(node)
+ const icon = state.key === 'draining' ? 'pause' : ICON[state.level]
+ return (
+
+
+ {t(`state.${state.key}`)}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/swarm/NodesTable.jsx b/core/http/react-ui/src/components/swarm/NodesTable.jsx
new file mode 100644
index 000000000..0dc5434c6
--- /dev/null
+++ b/core/http/react-ui/src/components/swarm/NodesTable.jsx
@@ -0,0 +1,158 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { Link, useNavigate } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import { groupNodes, nodeLifecycleAction } from '../../utils/nodeFleet'
+import { isDown } from '../../utils/swarm'
+import { timeAgo } from '../nodes/nodeStatus'
+import MemoryMeter from './MemoryMeter'
+import NodeState from './NodeState'
+import Icon from '../Icon'
+
+function SortHead({ col, label, sort, onSort, className }) {
+ const active = sort.key === col
+ return (
+
+ onSort({ key: col, direction: active && sort.direction === 'asc' ? 'desc' : 'asc' })}>
+ {label}
+
+
+
+ )
+}
+
+// The sortable table of nodes: one row each, the state in words, memory as a
+// bar, and the node's page one click away. Selection and the density switch
+// belong to the page; the table only draws them.
+export default function NodesTable({
+ nodes, reasons, selectedIds, onSelectionChange, sort, onSortChange, groupBy, onApprove, compact, busyId,
+}) {
+ const { t } = useTranslation('swarm')
+ const navigate = useNavigate()
+ const groups = groupNodes(nodes, groupBy)
+ const visibleIds = nodes.map(node => node.id)
+ const selectedVisible = visibleIds.filter(id => selectedIds.has(id)).length
+ const setMany = (ids, selected) => {
+ const next = new Set(selectedIds)
+ ids.forEach(id => (selected ? next.add(id) : next.delete(id)))
+ onSelectionChange(next)
+ }
+ const cols = 9
+
+ const open = (event, node) => {
+ if (event.target.closest('a, button, input, label')) return
+ navigate(`/app/nodes/${encodeURIComponent(node.id)}`)
+ }
+
+ return (
+
+ )
+}
diff --git a/core/http/react-ui/src/components/swarm/RuleSheet.jsx b/core/http/react-ui/src/components/swarm/RuleSheet.jsx
new file mode 100644
index 000000000..5bc51c2e1
--- /dev/null
+++ b/core/http/react-ui/src/components/swarm/RuleSheet.jsx
@@ -0,0 +1,262 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { useTranslation } from 'react-i18next'
+import ModelPicker from './ModelPicker'
+import KeyValueChips from '../nodes/KeyValueChips'
+import { rulePlan, selectorOf } from '../../utils/swarm'
+import Icon from '../Icon'
+
+function selectorObject(value) {
+ return selectorOf({ node_selector: value })
+}
+
+function configMode(config) {
+ if (!config) return 'placement'
+ if (config.spread_all) return 'spread'
+ if (config.min_replicas > 0 || config.max_replicas > 0) return 'autoscaling'
+ return 'placement'
+}
+
+function Presets({ value, onChange, presets, label }) {
+ return (
+
+ {presets.map(({ v, l }) => (
+ onChange(v)}>{l || v}
+ ))}
+
+ )
+}
+
+// What the draft rule would do with the nodes as they are now, in sentences. It
+// is a reading of the roster and the labels; the scheduler also weighs free
+// memory and disk when a model loads, which this cannot see.
+export function RulePreview({ draft, nodes }) {
+ const { t } = useTranslation('swarm')
+ const plan = useMemo(() => rulePlan(draft, nodes || []), [draft, nodes])
+ const ready = Array.isArray(nodes)
+ const names = list => list.map(item => item.name).join(', ')
+ let body
+ if (!draft.model_name) body = {t('sheet.preview.pickModel')}
+ else if (!ready) body = {t('sheet.preview.noRoster')}
+ else if (plan.kind === 'placement' && Object.keys(plan.selector).length === 0) body = {t('sheet.preview.needLabels')}
+ else if (plan.eligible.length === 0) {
+ body = {t('sheet.preview.none', { labels: Object.entries(plan.selector).map(([k, v]) => `${k}=${v}`).join(', ') || t('sheet.preview.anyNode') })}
+ } else if (plan.kind === 'spread') {
+ body = {draft.model_name} {t('sheet.preview.spread', { count: plan.eligible.length, nodes: names(plan.eligible) })}
+ } else if (plan.kind === 'autoscale') {
+ body = <>
+ {draft.model_name} {t('sheet.preview.autoscale', { count: plan.wanted, nodes: plan.planned.map(p => `${p.node.name}${p.replicas > 1 ? ` ×${p.replicas}` : ''}`).join(', ') })}
+ {plan.shortfall > 0 && {t('sheet.preview.shortfall', { count: plan.shortfall })} }
+ >
+ } else {
+ body = {draft.model_name} {t('sheet.preview.placement', { count: plan.eligible.length, nodes: names(plan.eligible) })}
+ }
+ return (
+
+ {t('sheet.preview.title')} {t('preview.label')}
+
+ {t('sheet.preview.how')}
+
+ )
+}
+
+// The editor for one rule, in a side sheet so the list stays in view behind it
+// and a rule is easy to look at twice. On a phone it rises from the bottom.
+//
+// initial the rule being edited, or a starting draft for a new one
+// editing true when `initial` is a saved rule (its model is then fixed)
+export default function RuleSheet({ initial, editing, labels, aliases, nodes, onSave, onClose }) {
+ const { t } = useTranslation('swarm')
+ const [mode, setMode] = useState(() => configMode(initial))
+ const [modelName, setModelName] = useState(initial?.model_name || '')
+ const [selector, setSelector] = useState(() => selectorObject(initial?.node_selector))
+ const [minReplicas, setMinReplicas] = useState(initial?.min_replicas ?? 1)
+ const [maxReplicas, setMaxReplicas] = useState(initial?.max_replicas ?? 0)
+ // An empty routing policy means "inherit the cluster default", and a
+ // threshold of 0 inherits too, so they stay out of effect until set.
+ const [routePolicy, setRoutePolicy] = useState(initial?.route_policy || '')
+ const [balanceAbs, setBalanceAbs] = useState(initial?.balance_abs_threshold ?? 0)
+ const [balanceRel, setBalanceRel] = useState(initial?.balance_rel_threshold ?? 0)
+ const [minPrefix, setMinPrefix] = useState(initial?.min_prefix_match ?? 0)
+ const [saving, setSaving] = useState(false)
+ const sheetRef = useRef(null)
+ const closeRef = useRef(onClose)
+ closeRef.current = onClose
+
+ useEffect(() => {
+ const opener = document.activeElement
+ const sheet = sheetRef.current
+ sheet?.querySelector('input:not([readonly]), button.dk-seg')?.focus()
+ const onKey = (e) => {
+ if (e.key === 'Escape') {
+ // An open suggestion list takes its own Escape and closes first.
+ if (e.target?.closest?.('[role="listbox"]') || e.target?.getAttribute?.('aria-expanded') === 'true') return
+ e.preventDefault(); e.stopPropagation(); closeRef.current(); return
+ }
+ if (e.key !== 'Tab' || !sheet) return
+ const focusable = Array.from(sheet.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea, [tabindex]:not([tabindex="-1"])'))
+ if (focusable.length === 0) return
+ const first = focusable[0]
+ const last = focusable[focusable.length - 1]
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
+ }
+ document.addEventListener('keydown', onKey, true)
+ return () => {
+ document.removeEventListener('keydown', onKey, true)
+ if (opener && document.contains(opener)) opener.focus?.()
+ }
+ }, [])
+
+ const hasSelector = Object.keys(selector).length > 0
+ const aliasHints = Object.fromEntries(Object.entries(aliases || {}).map(([name, target]) => [name, t('sheet.aliasOf', { target })]))
+ const aliasTarget = (aliases || {})[modelName]
+
+ const valid = () => {
+ if (!modelName) return false
+ if (mode === 'placement') return hasSelector
+ if (mode === 'spread') return true
+ return minReplicas > 0 || maxReplicas > 0
+ }
+
+ const config = {
+ model_name: modelName,
+ node_selector: hasSelector ? selector : undefined,
+ min_replicas: mode === 'autoscaling' ? minReplicas : 0,
+ max_replicas: mode === 'autoscaling' ? maxReplicas : 0,
+ spread_all: mode === 'spread',
+ route_policy: routePolicy,
+ balance_abs_threshold: balanceAbs,
+ balance_rel_threshold: balanceRel,
+ min_prefix_match: minPrefix,
+ }
+
+ const submit = async () => {
+ setSaving(true)
+ try { await onSave(config) } finally { setSaving(false) }
+ }
+
+ const modeHelp = mode === 'placement' ? t('sheet.help.placement') : mode === 'spread' ? t('sheet.help.spread') : t('sheet.help.autoscaling')
+
+ return createPortal(
+
+
e.stopPropagation()}
+ >
+
+
+
+
{editing ? t('sheet.editTitle') : t('sheet.newTitle')}
+
{t('sheet.desc')}
+
+
+
+
+
+ {t('sheet.model')}
+ {editing ? (
+
+ ) : (
+
+ )}
+ {/* An alias is a stable name for whichever model serves it, so a rule
+ on one is a rule on a slot. Say so where it is chosen. */}
+ {aliasTarget && (
+ {t('sheet.aliasNote', { name: modelName, target: aliasTarget })}
+ )}
+
+
+
+
{t('sheet.how')}
+
+ {['placement', 'autoscaling', 'spread'].map(key => (
+ setMode(key)}>{t(`sheet.mode.${key}`)}
+ ))}
+
+
{modeHelp}
+
+
+
+ {mode === 'placement' ? t('sheet.selector') : t('sheet.selectorOptional')}
+ setSelector(prev => ({ ...prev, [k]: v }))}
+ onRemove={k => setSelector(prev => { const next = { ...prev }; delete next[k]; return next })}
+ placeholderKey={t('sheet.selectorKey')}
+ placeholderValue={t('sheet.selectorValue')}
+ ariaLabel="Node selector"
+ ariaLabelKey="Selector key"
+ ariaLabelValue="Selector value"
+ addLabel="Add selector"
+ suggestions={labels}
+ />
+
+ {mode === 'placement' ? t('sheet.selectorHelp.placement') : hasSelector ? t('sheet.selectorHelp.some') : t('sheet.selectorHelp.empty')}
+
+
+
+ {mode === 'autoscaling' && (
+
+
+
{t('sheet.min')}
+
setMinReplicas(parseInt(e.target.value) || 0)} />
+
+
+
+
{t('sheet.max')}
+
setMaxReplicas(parseInt(e.target.value) || 0)} />
+
+
+
+ )}
+
+
+
+
+ {t('sheet.routing')}
+ setRoutePolicy(e.target.value)}>
+ {t('sheet.routingDefault')}
+ {t('sheet.routingRound')}
+ {t('sheet.routingPrefix')}
+
+ {t('sheet.routingHelp')}
+
+
+ {routePolicy === 'prefix_cache' && (
+
+ )}
+
+
+
+ {t('actions.cancel')}
+ {t('sheet.save')}
+
+
+
,
+ document.body,
+ )
+}
diff --git a/core/http/react-ui/src/components/swarm/RunningModels.jsx b/core/http/react-ui/src/components/swarm/RunningModels.jsx
new file mode 100644
index 000000000..02140611c
--- /dev/null
+++ b/core/http/react-ui/src/components/swarm/RunningModels.jsx
@@ -0,0 +1,244 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { Fragment, useId, useMemo, useRef, useState } from 'react'
+import { Link, useNavigate } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import { backendControlApi } from '../../utils/api'
+import { filterModels, groupModels, paginateModels, sortModels } from '../../utils/nodeFleet'
+import { timeAgo } from '../nodes/nodeStatus'
+import ConfirmDialog from '../ConfirmDialog'
+import ActionMenu from '../ActionMenu'
+import LoadingSpinner from '../LoadingSpinner'
+import Icon from '../Icon'
+
+function SortHead({ col, label, sort, onSort, className }) {
+ const active = sort.key === col
+ return (
+
+ onSort({ key: col, direction: active && sort.direction === 'asc' ? 'desc' : 'asc' })}>
+ {label}
+
+
+
+ )
+}
+
+function byNode(replicas, nodes) {
+ const names = new Map(nodes.map(node => [node.id, node]))
+ const groups = new Map()
+ replicas.forEach(replica => {
+ const id = String(replica?.node_id ?? '').trim()
+ const key = id || `unknown:${replica?.id ?? groups.size}`
+ if (!groups.has(key)) groups.set(key, { id, node: names.get(id) || null, replicas: [] })
+ groups.get(key).replicas.push(replica)
+ })
+ return [...groups.values()]
+}
+
+// What is running across the cluster, one row per model. A row opens to show
+// which node holds each replica and the logs of each. Reading it is one
+// controller query, so the page asks only when this view is shown.
+export default function RunningModels({ nodes, replicas, addToast }) {
+ const { t } = useTranslation('swarm')
+ const navigate = useNavigate()
+ const baseId = useId()
+ const [query, setQuery] = useState('')
+ const [sort, setSort] = useState({ key: 'model_name', direction: 'asc' })
+ const [page, setPage] = useState(1)
+ const [open, setOpen] = useState(null)
+ const [confirm, setConfirm] = useState(null)
+ const [stopping, setStopping] = useState(null)
+ const stopRunning = useRef(false)
+ const invoker = useRef(null)
+
+ const grouped = useMemo(() => groupModels(replicas.rows), [replicas.rows])
+ const filtered = useMemo(() => filterModels(grouped, query), [grouped, query])
+ const ordered = useMemo(() => sortModels(filtered, sort), [filtered, sort])
+ const pagination = useMemo(() => paginateModels(ordered, page), [ordered, page])
+ const current = pagination.page
+
+ const logs = (nodeId, key) => navigate(`/app/node-backend-logs/${encodeURIComponent(nodeId)}/${encodeURIComponent(key)}`)
+ const openLogs = (model) => {
+ const list = model.replicas || []
+ if (list.length === 1 && list[0].node_id) logs(list[0].node_id, `${model.model_name}#${list[0].replica_index ?? 0}`)
+ else setOpen(model.model_name)
+ }
+
+ const stop = () => {
+ const model = confirm
+ if (!model || stopRunning.current) return
+ stopRunning.current = true
+ setStopping(model.model_name)
+ void (async () => {
+ let failure = null
+ try { await backendControlApi.shutdown({ model: model.model_name }) } catch (error) { failure = error }
+ try { await replicas.load(true) } catch { /* the table keeps what it had */ }
+ if (failure) addToast?.(t('models.stopFailed', { name: model.model_name, message: failure.message || String(failure) }), 'warning')
+ else addToast?.(t('models.stopped', { name: model.model_name, replicas: t('models.nReplicas', { count: model.replica_count }), nodes: t('models.nNodes', { count: model.node_count }) }), 'success')
+ setConfirm(null)
+ setStopping(null)
+ stopRunning.current = false
+ })()
+ }
+
+ if (replicas.state === 'idle' || replicas.state === 'loading') {
+ return {t('models.loading')} {t('models.loadingSub')}
+ }
+ if (replicas.state === 'error') {
+ return (
+
+
+ {t('models.error')}
+ {replicas.error}
+ replicas.retry()}>{t('models.retry')}
+
+ )
+ }
+ if (grouped.length === 0) {
+ return {t('models.none')} {t('models.noneSub')}
+ }
+
+ return (
+
+
+
+
+ { setQuery(event.target.value); setPage(1) }}
+ />
+
+
{t('models.inView', { count: ordered.length })}
+
+
+
+ {t('models.caption')}
+
+
+
+
+
+
+ {t('models.backends')}
+
+ {t('models.actions')}
+
+
+
+ {pagination.items.map((model, index) => {
+ const isOpen = open === model.model_name
+ const detailId = `${baseId}-m${index}`
+ return (
+
+ { if (!event.target.closest('button, a, [role="menu"]')) setOpen(isOpen ? null : model.model_name) }}>
+ {model.model_name}
+ {model.replica_count}
+ {model.node_count}
+ {model.in_flight}
+ {model.backend_types.length ? model.backend_types.join(', ') : {t('models.unknown')} }
+ {model.last_used ? timeAgo(model.last_used) : {t('models.never')} }
+
+
+ openLogs(model) },
+ { divider: true },
+ {
+ key: 'stop',
+ icon: 'stop',
+ label: stopping === model.model_name ? t('models.stopping') : t('models.stop'),
+ danger: true,
+ disabled: !!stopping,
+ onClick: node => { invoker.current = node; setConfirm(model) },
+ },
+ ]}
+ />
+ setOpen(isOpen ? null : model.model_name)}
+ >
+
+
+
+
+
+
+
+
+
+
+ {isOpen && byNode(model.replicas, nodes).map(group => {
+ const name = group.node?.name || group.id || t('models.unknownNode')
+ return (
+
+
+ {group.node
+ ? {name}
+ : {name} }
+ {t('models.replicaCount', { count: group.replicas.length })}
+
+
+ {group.replicas.map(replica => {
+ const number = Number.isFinite(replica.replica_index) ? replica.replica_index + 1 : null
+ const key = `${model.model_name}#${replica.replica_index ?? 0}`
+ return (
+
+ {t('models.replica', { n: number ?? '—' })}
+ {replica.address || t('models.noAddress')}
+ {group.id && (
+ logs(group.id, key)}
+ >
+ {t('models.logs')}
+
+ )}
+
+ )
+ })}
+
+
+ )
+ })}
+
+
+
+
+
+
+ )
+ })}
+
+
+
+
+ {t('pager.page', { page: current, total: pagination.totalPages })}
+ setPage(current - 1)}>{t('pager.previous')}
+ setPage(current + 1)}>{t('pager.next')}
+
+
{ setConfirm(null); requestAnimationFrame(() => invoker.current?.focus?.()) }}
+ />
+
+ )
+}
diff --git a/core/http/react-ui/src/components/swarm/SwarmDialog.jsx b/core/http/react-ui/src/components/swarm/SwarmDialog.jsx
new file mode 100644
index 000000000..9b2b4f2d3
--- /dev/null
+++ b/core/http/react-ui/src/components/swarm/SwarmDialog.jsx
@@ -0,0 +1,77 @@
+import { useEffect, useRef } from 'react'
+import { createPortal } from 'react-dom'
+import { useTranslation } from 'react-i18next'
+import Icon from '../Icon'
+
+// The kit's dialog for the Swarm pages: a title, one sentence, a body and a
+// footer. It traps focus, closes on Escape and on a click on the veil, and
+// gives focus back to what opened it. `pending` holds it open while a call runs.
+//
+// Pass the buttons as `foot`; the first focusable control in the body gets focus
+// unless `initialFocus` names a selector.
+export default function SwarmDialog({
+ title, desc, children, foot, onClose, pending = false, testId, role = 'dialog', wide = false, initialFocus,
+}) {
+ const { t } = useTranslation('swarm')
+ const ref = useRef(null)
+ const closeRef = useRef(onClose)
+ closeRef.current = onClose
+ const pendingRef = useRef(pending)
+ pendingRef.current = pending
+ const titleId = `sw-dialog-${testId || 'x'}-title`
+
+ useEffect(() => {
+ const opener = document.activeElement
+ const dialog = ref.current
+ const first = (initialFocus && dialog?.querySelector(initialFocus)) || dialog?.querySelector('input, select, textarea, .sw-dialog__primary') || dialog?.querySelector('button')
+ first?.focus()
+ const onKey = (e) => {
+ if (e.key === 'Escape') {
+ e.preventDefault()
+ e.stopPropagation()
+ if (!pendingRef.current) closeRef.current()
+ return
+ }
+ if (e.key !== 'Tab' || !dialog) return
+ const focusable = Array.from(dialog.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'))
+ if (focusable.length === 0) return
+ const head = focusable[0]
+ const last = focusable[focusable.length - 1]
+ if (e.shiftKey && document.activeElement === head) { e.preventDefault(); last.focus() }
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); head.focus() }
+ }
+ document.addEventListener('keydown', onKey, true)
+ return () => {
+ document.removeEventListener('keydown', onKey, true)
+ if (opener && document.contains(opener)) opener.focus?.()
+ }
+ }, [initialFocus])
+
+ return createPortal(
+ { if (!pending) onClose() }}>
+
e.stopPropagation()}
+ >
+
+
+
{title}
+ {desc &&
{desc}
}
+
+
+
+
+
+
{children}
+ {foot &&
{foot}
}
+
+
,
+ document.body,
+ )
+}
diff --git a/core/http/react-ui/src/components/talk/SessionSheet.jsx b/core/http/react-ui/src/components/talk/SessionSheet.jsx
new file mode 100644
index 000000000..97f31f7b1
--- /dev/null
+++ b/core/http/react-ui/src/components/talk/SessionSheet.jsx
@@ -0,0 +1,154 @@
+import { useEffect, useRef } from 'react'
+import { createPortal } from 'react-dom'
+import { useTranslation } from 'react-i18next'
+// eslint-disable-next-line no-unused-vars
+import ClientMCPDropdown from '../ClientMCPDropdown'
+import Icon from '../Icon'
+
+// Everything about the call that is not the call itself: what the model is told,
+// the voice and language, tools, Manage mode and the pipeline's parts. The
+// pipeline and Manage mode are fixed once a session starts (the server reads
+// them when the call is made), so they lock while connected.
+export default function SessionSheet({
+ connected, isAdmin, instructions, onInstructions, voice, onVoice, language, onLanguage,
+ manageMode, onManageMode, pipeline, onEditPipeline,
+ activeServerIds, onToggleServer, onServerAdded, onServerRemoved, connectionStatuses, getConnectedTools,
+ onClose,
+}) {
+ const { t } = useTranslation('talk')
+ const sheetRef = useRef(null)
+ const closeRef = useRef(null)
+ const openerRef = useRef(null)
+
+ useEffect(() => {
+ openerRef.current = document.activeElement
+ closeRef.current?.focus()
+ const onKey = (e) => {
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose(); return }
+ if (e.key !== 'Tab' || !sheetRef.current) return
+ const focusable = Array.from(sheetRef.current.querySelectorAll('button:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'))
+ if (focusable.length === 0) return
+ const first = focusable[0]
+ const last = focusable[focusable.length - 1]
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
+ }
+ window.addEventListener('keydown', onKey, true)
+ return () => {
+ window.removeEventListener('keydown', onKey, true)
+ const el = openerRef.current
+ if (el && document.contains(el)) el.focus?.()
+ }
+ }, [onClose])
+
+ const slots = pipeline && !pipeline.self_contained
+ ? [
+ ['vad', pipeline.vad],
+ ['transcription', pipeline.transcription],
+ ['llm', pipeline.llm],
+ ['tts', pipeline.tts],
+ ]
+ : []
+
+ return createPortal(
+
+
e.stopPropagation()}
+ >
+
+
+
+
{t('settings.title')}
+
{t('settings.desc')}
+
+
+
+
+
+
+
+
+
+ {t('settings.instructions')}
+ onInstructions(e.target.value)}
+ placeholder={t('settings.instructionsPlaceholder')}
+ aria-label={t('settings.instructions')}
+ />
+ {t('settings.instructionsHelp')}
+
+
+
+ {t('settings.tools')}
+
+ {isAdmin && (
+
+
+ {t('settings.manage')}
+ {connected ? t('settings.manageLocked') : t('settings.manageDesc')}
+
+ onManageMode(!manageMode)}
+ />
+
+ )}
+
+
+ {pipeline && (
+
+ {t('settings.pipeline', { name: pipeline.name })}
+ {pipeline.self_contained ? (
+ {t('settings.selfContained')}
+ ) : (
+
+ {slots.map(([key, value]) => (
+
{t(`settings.slot.${key}`)} {value || '—'}
+ ))}
+
+ )}
+ {!connected && (
+
+ {pipeline.self_contained ? t('settings.editConfig') : t('settings.editPipeline')}
+
+ )}
+
+ )}
+
+
+
,
+ document.body,
+ )
+}
diff --git a/core/http/react-ui/src/components/tools/Checks.jsx b/core/http/react-ui/src/components/tools/Checks.jsx
new file mode 100644
index 000000000..2e68a2d8c
--- /dev/null
+++ b/core/http/react-ui/src/components/tools/Checks.jsx
@@ -0,0 +1,66 @@
+// eslint-disable-next-line no-unused-vars
+import { Link } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import { formatBytes } from '../../utils/format'
+import Icon from '../Icon'
+import './tools.css'
+
+const ICON = { ok: 'check-circle', warn: 'warning', fail: 'alert-circle', info: 'info' }
+
+// Byte counts arrive raw; the text shows them as sizes.
+function withSizes(vals) {
+ const out = { ...(vals || {}) }
+ for (const key of ['free', 'total', 'used']) {
+ if (typeof out[key] === 'number') out[key] = formatBytes(out[key])
+ else if (key in out) out[key] = '?'
+ }
+ return out
+}
+
+function valueText(value) {
+ if (!value) return ''
+ if (typeof value === 'string') return value
+ return `${formatBytes(value.free)} / ${formatBytes(value.total)}`
+}
+
+// A checklist: one row per check with a mark, a title, one sentence, an optional
+// value and an optional meter of how much of a resource is in use. Rows are
+// computed from facts the server reported, so the list can be redrawn on every
+// change of the form.
+export default function Checks({ checks, isAdmin = false, label, testId = 'tool-checks' }) {
+ const { t } = useTranslation('tools')
+ return (
+
+ {checks.map(check => {
+ const vals = withSizes(check.vals)
+ const used = check.meter?.total > 0 ? Math.min(100, Math.round((check.meter.used / check.meter.total) * 100)) : null
+ return (
+
+
+
+
+ {t(`checks.title.${check.id}`)}
+ {check.value && {valueText(check.value)} }
+
+
+ {t(`checks.${check.key}`, { ...vals, context: check.vals?.kind || check.vals?.where })}
+ {check.action === 'backends' && isAdmin && (
+ <> {t('checks.openBackends')}>
+ )}
+
+ {used != null && (
+
+
+
+ )}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/tools/JobLog.jsx b/core/http/react-ui/src/components/tools/JobLog.jsx
new file mode 100644
index 000000000..b6858c4fa
--- /dev/null
+++ b/core/http/react-ui/src/components/tools/JobLog.jsx
@@ -0,0 +1,75 @@
+import { useEffect, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { logText } from '../../utils/tools'
+import Icon from '../Icon'
+import './tools.css'
+
+const pad = (n) => String(n).padStart(2, '0')
+const stamp = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
+
+// What the job has reported since this page opened, one line per status change
+// or message. The server sends progress events and keeps no log, so a line from
+// before the page opened is not here, and the heading says so.
+export default function JobLog({ lines, name = 'job' }) {
+ const { t } = useTranslation('tools')
+ const [only, setOnly] = useState(false)
+ const [copied, setCopied] = useState(false)
+ const bodyRef = useRef(null)
+ const shown = only ? lines.filter(l => l.level !== 'info') : lines
+
+ useEffect(() => {
+ const el = bodyRef.current
+ if (el) el.scrollTop = el.scrollHeight
+ }, [shown.length])
+
+ const copy = () => {
+ navigator.clipboard?.writeText(logText(shown)).then(() => {
+ setCopied(true)
+ setTimeout(() => setCopied(false), 1500)
+ }).catch(() => {})
+ }
+ const download = () => {
+ const url = URL.createObjectURL(new Blob([logText(shown)], { type: 'text/plain' }))
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `${name}-log.txt`
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+ }
+
+ return (
+
+
+
+ {shown.length === 0 ? (
+
{only ? t('log.noneFiltered') : t('log.empty')}
+ ) : shown.map((line, i) => (
+
+ {stamp(line.at)}
+ {line.tag}
+ {line.text}
+
+ ))}
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/tools/LossChart.jsx b/core/http/react-ui/src/components/tools/LossChart.jsx
new file mode 100644
index 000000000..886a00e57
--- /dev/null
+++ b/core/http/react-ui/src/components/tools/LossChart.jsx
@@ -0,0 +1,164 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import './tools.css'
+
+// The drawing is made in the size it is shown at, so the text stays readable: a
+// wide one on a desktop, a narrow one on a phone.
+const SIZES = {
+ wide: { W: 720, H: 260, PAD: { top: 16, right: 64, bottom: 36, left: 56 } },
+ compact: { W: 340, H: 240, PAD: { top: 16, right: 48, bottom: 36, left: 44 } },
+}
+
+function useCompact() {
+ const query = '(max-width: 640px)'
+ const [compact, setCompact] = useState(() => typeof window !== 'undefined' && window.matchMedia(query).matches)
+ useEffect(() => {
+ const mq = window.matchMedia(query)
+ const on = () => setCompact(mq.matches)
+ mq.addEventListener('change', on)
+ return () => mq.removeEventListener('change', on)
+ }, [])
+ return compact
+}
+
+function tickText(v, exp) {
+ if (!Number.isFinite(v)) return ''
+ if (exp) return v.toExponential(1)
+ if (Math.abs(v) >= 100) return v.toFixed(0)
+ if (Math.abs(v) >= 1) return v.toFixed(2)
+ return v.toFixed(3)
+}
+
+// The metrics of a fine-tuning run as one thin line, with a tab for each metric.
+// Loss also marks the evaluation loss as hollow dots when the run reports it.
+// The end of the line carries its value, the axes are labelled, and a data table
+// sits behind a disclosure for anyone who wants the numbers.
+export default function LossChart({ events, totalSteps }) {
+ const { t } = useTranslation('tools')
+ const [metric, setMetric] = useState('loss')
+ const [hover, setHover] = useState(null)
+ const svgRef = useRef(null)
+ const compact = useCompact()
+ const { W, H, PAD } = SIZES[compact ? 'compact' : 'wide']
+
+ const series = useMemo(() => ({
+ loss: events.filter(e => e.loss > 0),
+ learning_rate: events.filter(e => e.learning_rate > 0),
+ grad_norm: events.filter(e => e.grad_norm > 0),
+ }), [events])
+ const evalPoints = useMemo(() => events.filter(e => e.eval_loss > 0), [events])
+ const data = series[metric]
+ const exp = metric === 'learning_rate'
+ const key = metric
+ const tabs = ['loss', 'learning_rate', 'grad_norm']
+
+ const geometry = useMemo(() => {
+ if (data.length < 2) return null
+ const values = data.map(e => e[key]).concat(metric === 'loss' ? evalPoints.map(e => e.eval_loss) : [])
+ const lo = Math.min(...values)
+ const hi = Math.max(...values)
+ const span = hi - lo || Math.abs(hi) || 1
+ const yMin = metric === 'loss' ? Math.max(0, lo - span * 0.08) : lo - span * 0.08
+ const yMax = hi + span * 0.08
+ const steps = data.map(e => e.current_step)
+ const xMin = Math.min(...steps)
+ const xMax = Math.max(totalSteps || 0, ...steps)
+ const cw = W - PAD.left - PAD.right
+ const ch = H - PAD.top - PAD.bottom
+ const x = (s) => PAD.left + ((s - xMin) / (xMax - xMin || 1)) * cw
+ const y = (v) => PAD.top + (1 - (v - yMin) / (yMax - yMin || 1)) * ch
+ const yTicks = Array.from({ length: 4 }, (_, i) => yMin + ((yMax - yMin) * i) / 3)
+ const xTicks = Array.from({ length: compact ? 3 : 5 }, (_, i) => Math.round(xMin + ((xMax - xMin) * i) / (compact ? 2 : 4)))
+ return { x, y, yTicks, xTicks, xMin, xMax, cw, ch }
+ }, [data, evalPoints, key, metric, totalSteps, W, H, PAD, compact])
+
+ const move = (e) => {
+ if (!geometry || !svgRef.current) return
+ const rect = svgRef.current.getBoundingClientRect()
+ const px = ((e.clientX - rect.left) / rect.width) * W
+ const step = geometry.xMin + ((px - PAD.left) / geometry.cw) * (geometry.xMax - geometry.xMin)
+ let best = data[0]
+ for (const d of data) if (Math.abs(d.current_step - step) < Math.abs(best.current_step - step)) best = d
+ setHover(best)
+ }
+
+ const last = data[data.length - 1]
+ const first = data[0]
+ const readout = hover || last
+
+ return (
+
+
+
+
{t(`chart.${metric}`)}
+
+ {geometry
+ ? t(metric === 'loss' && evalPoints.length ? 'chart.windowEval' : 'chart.window', { from: first.current_step, to: last.current_step, total: totalSteps || last.current_step })
+ : t('chart.waiting')}
+
+
+
+ {tabs.map(id => (
+ { setMetric(id); setHover(null) }}>
+ {t(`chart.${id}`)}
+
+ ))}
+
+
+ {geometry ? (
+ <>
+ setHover(null)}
+ >
+ {geometry.yTicks.map((v, i) => (
+
+
+ {tickText(v, exp)}
+
+ ))}
+ {geometry.xTicks.map((s, i) => (
+ {s}
+ ))}
+ {t('chart.step')}
+ `${geometry.x(e.current_step)},${geometry.y(e[key])}`).join(' ')} />
+ {metric === 'loss' && evalPoints.map((e, i) => (
+
+ ))}
+
+ {tickText(last[key], exp)}
+ {hover && }
+
+
+ {t('chart.readout', { step: readout.current_step, value: tickText(readout[key], exp) })}
+
+
+ {t('chart.dataTitle')}
+
+
+ {t('chart.dataTitle')}
+ {t('chart.step')} {t(`chart.${metric}`)} {metric === 'loss' && {t('chart.eval')} }
+
+ {data.slice(-12).map(e => (
+
+ {e.current_step}
+ {tickText(e[key], exp)}
+ {metric === 'loss' && {e.eval_loss > 0 ? tickText(e.eval_loss, false) : ''} }
+
+ ))}
+
+
+
+
+ >
+ ) : (
+ {t('chart.emptyText')}
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/tools/StageLine.jsx b/core/http/react-ui/src/components/tools/StageLine.jsx
new file mode 100644
index 000000000..46e184cdb
--- /dev/null
+++ b/core/http/react-ui/src/components/tools/StageLine.jsx
@@ -0,0 +1,21 @@
+import './tools.css'
+
+// The stages a job passes through, as a row of segments. The current stage is
+// named in bold; stages before it are filled. `stages` are status ids and
+// `labels` maps each to its words.
+export default function StageLine({ stages, labels, status, failed = false, label }) {
+ const at = stages.indexOf(status)
+ return (
+
+ {stages.map((stage, i) => {
+ const state = i < at ? 'done' : i === at ? (failed ? 'failed' : 'current') : 'todo'
+ return (
+
+
+ {labels[stage]}
+
+ )
+ })}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/tools/ToolOff.jsx b/core/http/react-ui/src/components/tools/ToolOff.jsx
new file mode 100644
index 000000000..6df7e1719
--- /dev/null
+++ b/core/http/react-ui/src/components/tools/ToolOff.jsx
@@ -0,0 +1,28 @@
+// eslint-disable-next-line no-unused-vars
+import { Link } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import { useAuth } from '../../context/AuthContext'
+import Icon from '../Icon'
+import './tools.css'
+
+// Shown in place of a Build tool the signed-in person may not use. It replaces a
+// silent redirect: it says that the account lacks the permission and who can
+// grant it, and points at the tools the person can open. `tool` is 'fineTune' or
+// 'quantize'.
+export default function ToolOff({ tool }) {
+ const { t } = useTranslation('tools')
+ const { isAdmin } = useAuth()
+ return (
+
+
+ {t('off.badge')}
+ {t(`off.${tool}.title`)}
+ {t(`off.${tool}.text`)}
+ {isAdmin ? t('off.admin') : t('off.askAdmin')}
+
+ {t('off.back')}
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/tools/ToolSteps.jsx b/core/http/react-ui/src/components/tools/ToolSteps.jsx
new file mode 100644
index 000000000..709269050
--- /dev/null
+++ b/core/http/react-ui/src/components/tools/ToolSteps.jsx
@@ -0,0 +1,23 @@
+import Icon from '../Icon'
+import './tools.css'
+
+// The line above a tool that says where the job is: set up, check, run, result.
+// `current` is the index of the step the person is on. A step before it is done.
+// `failed` marks the current step as failed instead of in progress.
+export default function ToolSteps({ steps, current, failed = false, label }) {
+ return (
+
+ {steps.map((step, i) => {
+ const state = i < current ? 'done' : i === current ? (failed ? 'failed' : 'current') : 'todo'
+ return (
+
+
+ {state === 'done' ? : state === 'failed' ? : i + 1}
+
+ {step.label}
+
+ )
+ })}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/tools/tools.css b/core/http/react-ui/src/components/tools/tools.css
new file mode 100644
index 000000000..756aea52b
--- /dev/null
+++ b/core/http/react-ui/src/components/tools/tools.css
@@ -0,0 +1,268 @@
+/* ==========================================================================
+ Build tools: the landing, Fine-tune, Quantize and the shared pieces Import
+ uses. Built on the kit's card, field, table, badge, chip, switch, progress,
+ meter, segmented, dialog and sheet classes. What is here is what the kit does
+ not say: the step line, the check list, the stage line, the job card, the
+ chart and the log. Colours, radii and spacing are kit roles (--dk-*).
+ Surfaces are flat and nothing sits on a coloured rail at the edge. Motion is
+ switched off for reduced motion at the end of this file.
+ ========================================================================== */
+
+.bt-page { display: flex; flex-direction: column; gap: var(--dk-space-5); min-width: 0; }
+.bt-page :is(a, button, summary, select, input, textarea, [tabindex]):focus-visible { outline: 2px solid var(--dk-accent-text); outline-offset: 2px; }
+.bt-page > .page-header { margin-bottom: 0; }
+.bt-h1 { margin: 0; font: var(--dk-weight-bold) var(--dk-text-32) / 1.15 var(--dk-font-display); letter-spacing: -.02em; text-wrap: balance; }
+.bt-h2 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-17) / 1.3 var(--dk-font-display); color: var(--dk-text); }
+.bt-h3 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-15) / 1.3 var(--dk-font-display); color: var(--dk-text); }
+.bt-experimental { vertical-align: middle; margin-left: var(--dk-space-2); }
+.bt-head-acts { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; justify-content: flex-end; }
+.bt-stack { display: flex; flex-direction: column; gap: var(--dk-space-4); min-width: 0; }
+.bt-empty { color: var(--dk-muted); }
+.bt-block { display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
+.bt-block.dk-card { padding: var(--dk-space-4) var(--dk-space-5); }
+.bt-block__head { display: flex; align-items: baseline; gap: var(--dk-space-3); flex-wrap: wrap; }
+.bt-block__head .dk-hint { margin: 0; }
+
+/* ---- Step line -------------------------------------------------------------- */
+.bt-steps { list-style: none; margin: 0; padding: 0; display: flex; align-items: center; gap: var(--dk-space-3); flex-wrap: wrap; }
+.bt-step { display: inline-flex; align-items: center; gap: var(--dk-space-2); color: var(--dk-muted); font-size: var(--dk-text-13); font-weight: var(--dk-weight-semibold); }
+.bt-step:not(:last-child)::after { content: ''; width: 48px; height: 2px; margin-left: var(--dk-space-2); border-radius: 2px; background: var(--dk-edge); }
+.bt-step[data-state="done"]:not(:last-child)::after { background: var(--dk-accent); }
+.bt-step__mark { display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--dk-edge); font: var(--dk-weight-bold) var(--dk-text-12) / 1 var(--dk-font-mono); color: var(--dk-muted); box-sizing: border-box; }
+.bt-step__mark .lai-icon { font-size: 13px; }
+.bt-step[data-state="done"] .bt-step__mark { background: var(--dk-accent); border-color: var(--dk-accent); color: var(--dk-on-accent); }
+.bt-step[data-state="current"] { color: var(--dk-text); }
+.bt-step[data-state="current"] .bt-step__mark { border-color: var(--dk-accent-text); color: var(--dk-accent-text); }
+.bt-step[data-state="failed"] { color: var(--dk-text); }
+.bt-step[data-state="failed"] .bt-step__mark { background: var(--dk-error-wash); border-color: var(--dk-error); color: var(--dk-error-text); }
+.bt-step[data-state="done"] { color: var(--dk-text); }
+
+/* ---- Form sections ----------------------------------------------------------- */
+.bt-form-page { display: flex; flex-direction: column; gap: var(--dk-space-5); min-width: 0; }
+.bt-section { display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
+.bt-section__title { margin: 0; display: flex; align-items: center; gap: var(--dk-space-3); font: var(--dk-weight-semibold) var(--dk-text-17) / 1.3 var(--dk-font-display); }
+.bt-section__mark { display: inline-grid; place-items: center; width: 24px; height: 24px; flex: none; border-radius: 50%; border: 2px solid var(--dk-edge); box-sizing: border-box; color: var(--dk-on-accent); }
+.bt-section__mark .lai-icon { font-size: 13px; }
+.bt-section[data-done="true"] .bt-section__mark { background: var(--dk-accent); border-color: var(--dk-accent); }
+.bt-section__body { display: flex; flex-direction: column; gap: var(--dk-space-3); padding-left: calc(24px + var(--dk-space-3)); min-width: 0; }
+.bt-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--dk-space-3); }
+.bt-pair--wide { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
+.bt-triple { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--dk-space-3); }
+.bt-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: var(--dk-space-3); }
+.bt-form { display: flex; flex-direction: column; gap: var(--dk-space-3); }
+.bt-actions { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; }
+.bt-choices { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0; min-width: 0; border-radius: var(--dk-radius-2); overflow: hidden; border: 1px solid var(--dk-edge); }
+.bt-choices > .dk-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
+.bt-choice { display: flex; align-items: flex-start; gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); background: var(--dk-card); cursor: pointer; }
+.bt-choice + .bt-choice { border-top: 1px solid var(--dk-edge); }
+.bt-choice:hover { background: var(--dk-hover); }
+.bt-choice[data-checked="true"] { background: var(--dk-accent-wash); }
+.bt-choice b { display: block; font-size: var(--dk-text-15); }
+.bt-choice__text { display: block; color: var(--dk-muted); font-size: var(--dk-text-13); line-height: 1.45; }
+.bt-choice input { margin-top: 3px; flex: none; }
+.bt-upload { position: relative; display: flex; align-items: center; gap: var(--dk-space-3); flex-wrap: wrap; }
+.bt-upload__input { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; }
+.bt-upload__input:focus-visible + label { outline: 2px solid var(--dk-accent-text); outline-offset: 2px; }
+.bt-more { border-top: 1px solid var(--dk-edge); padding-top: var(--dk-space-2); }
+.bt-more__toggle { display: flex; align-items: center; gap: var(--dk-space-2); width: 100%; min-height: 40px; padding: 0; background: none; border: 0; color: var(--dk-text); font: inherit; font-size: var(--dk-text-15); text-align: left; cursor: pointer; }
+.bt-more__toggle { flex-wrap: wrap; }
+.bt-more__toggle .dk-hint { margin: 0; min-width: 0; }
+.bt-more__body { display: flex; flex-direction: column; gap: var(--dk-space-4); padding: var(--dk-space-3) 0 var(--dk-space-2); min-width: 0; }
+.bt-switchrow { display: flex; align-items: center; gap: var(--dk-space-3); }
+.bt-eval { display: flex; flex-direction: column; gap: var(--dk-space-3); }
+.bt-kv { display: flex; flex-direction: column; gap: var(--dk-space-2); align-items: flex-start; }
+.bt-kv__row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; gap: var(--dk-space-2); width: 100%; align-items: center; }
+.bt-banner { display: flex; align-items: center; gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-2); background: var(--dk-accent-wash); color: var(--dk-text); font-size: var(--dk-text-13); flex-wrap: wrap; }
+.bt-banner code { overflow-wrap: anywhere; }
+.bt-rewards, .bt-liquid { display: flex; flex-direction: column; gap: var(--dk-space-3); padding-top: var(--dk-space-3); border-top: 1px solid var(--dk-edge); }
+.bt-rewards .dk-hint, .bt-liquid .dk-hint { margin: 0; }
+.bt-reward { display: flex; flex-direction: column; gap: var(--dk-space-2); padding: var(--dk-space-3); border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-2); background: var(--dk-card); min-width: 0; }
+.bt-reward[data-on="true"] { background: var(--dk-accent-wash); border-color: transparent; }
+.bt-reward__name { display: block; font-size: var(--dk-text-13); font-weight: var(--dk-weight-semibold); }
+.bt-reward__head { display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-2); }
+.bt-reward-draft { display: flex; flex-direction: column; gap: var(--dk-space-3); }
+.bt-code { margin: 0; padding: var(--dk-space-3); border-radius: var(--dk-radius-1); background: var(--dk-inset); font: var(--dk-text-12) / 1.5 var(--dk-font-mono); overflow-x: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
+.bt-recipe { margin: 0; padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-2); background: var(--dk-inset); font-size: var(--dk-text-15); line-height: 1.5; overflow-wrap: anywhere; }
+
+/* ---- Check list --------------------------------------------------------------- */
+.bt-checks-card { padding: var(--dk-space-2) var(--dk-space-5); }
+.bt-checks { list-style: none; margin: 0; padding: 0; }
+.bt-check { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: var(--dk-space-3); padding: var(--dk-space-3) 0; }
+.bt-check + .bt-check { border-top: 1px solid var(--dk-edge); }
+.bt-check__mark { font-size: 20px; line-height: 1; color: var(--dk-muted); padding-top: 1px; }
+.bt-check[data-tone="ok"] .bt-check__mark { color: var(--dk-ok-text); }
+.bt-check[data-tone="warn"] .bt-check__mark { color: var(--dk-warn-text); }
+.bt-check[data-tone="fail"] .bt-check__mark { color: var(--dk-error-text); }
+.bt-check__head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--dk-space-3); }
+.bt-check__title { font-weight: var(--dk-weight-semibold); font-size: var(--dk-text-15); }
+.bt-check__value { font-size: var(--dk-text-13); color: var(--dk-text); text-align: right; overflow-wrap: anywhere; }
+.bt-check__text { margin: 2px 0 0; font-size: var(--dk-text-13); line-height: 1.5; color: var(--dk-muted); overflow-wrap: anywhere; }
+.bt-check[data-tone="fail"] .bt-check__text { color: var(--dk-error-text); }
+.bt-check__meter { margin-top: var(--dk-space-2); }
+
+/* ---- Action bar --------------------------------------------------------------- */
+.bt-bar { position: sticky; bottom: 0; z-index: var(--dk-z-sticky); display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-4); flex-wrap: wrap; padding: var(--dk-space-3) 0; background: var(--dk-canvas); border-top: 1px solid var(--dk-edge); }
+.bt-bar__text { margin: 0; flex: 1 1 280px; min-width: 0; font-size: var(--dk-text-13); line-height: 1.5; color: var(--dk-muted); overflow-wrap: anywhere; }
+.bt-bar__acts { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; margin-left: auto; }
+
+/* ---- Alerts and statuses ------------------------------------------------------- */
+.bt-alert { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: var(--dk-space-3); padding: var(--dk-space-4); border-radius: var(--dk-radius-3); background: var(--dk-error-wash); color: var(--dk-error-text); }
+.bt-alert .lai-icon { font-size: 20px; margin-top: 1px; }
+.bt-alert__title { margin: 0; font-weight: var(--dk-weight-bold); font-size: var(--dk-text-15); }
+.bt-alert__text { margin: 2px 0 0; font-size: var(--dk-text-13); line-height: 1.5; overflow-wrap: anywhere; white-space: pre-wrap; }
+.bt-status { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); font-size: var(--dk-text-13); overflow-wrap: anywhere; }
+.bt-status--error { color: var(--dk-error-text); }
+.bt-path { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; font-size: var(--dk-text-12); }
+.bt-pathrow { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); min-width: 0; }
+
+/* ---- Job card ----------------------------------------------------------------- */
+.bt-job { display: flex; flex-direction: column; gap: var(--dk-space-3); padding: var(--dk-space-4) var(--dk-space-5); min-width: 0; }
+.bt-job__top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--dk-space-3); flex-wrap: wrap; }
+.bt-job__who { min-width: 0; }
+.bt-job__title { margin: 0; font: var(--dk-weight-bold) var(--dk-text-17) / 1.3 var(--dk-font-display); overflow-wrap: anywhere; }
+.bt-job__meta { margin: 2px 0 0; font-size: var(--dk-text-13); color: var(--dk-muted); overflow-wrap: anywhere; }
+.bt-job__acts { display: flex; align-items: center; gap: var(--dk-space-2); }
+.bt-job__figures { display: flex; align-items: baseline; gap: var(--dk-space-5); flex-wrap: wrap; font-size: var(--dk-text-13); color: var(--dk-muted); }
+.bt-job__figures b { color: var(--dk-text); }
+.bt-job__percent { font: var(--dk-weight-bold) var(--dk-text-44) / 1 var(--dk-font-display); letter-spacing: -.02em; color: var(--dk-text); font-variant-numeric: tabular-nums; }
+.bt-job__percent small { font-size: var(--dk-text-21); margin-left: 2px; color: var(--dk-muted); }
+.bt-job__wait { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); color: var(--dk-muted); font-size: var(--dk-text-13); }
+.bt-job__message { margin: 0; overflow-wrap: anywhere; }
+.bt-job__done { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); color: var(--dk-ok-text); font-size: var(--dk-text-15); font-weight: var(--dk-weight-semibold); }
+
+.bt-stages { list-style: none; margin: 0; padding: 0; display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: var(--dk-space-2); }
+.bt-stage { display: flex; flex-direction: column; gap: 6px; min-width: 0; font-size: var(--dk-text-12); color: var(--dk-muted); }
+.bt-stage__bar { height: 3px; border-radius: 3px; background: var(--dk-edge); }
+.bt-stage[data-state="done"] .bt-stage__bar { background: var(--dk-muted); }
+.bt-stage[data-state="current"] { color: var(--dk-text); font-weight: var(--dk-weight-bold); }
+.bt-stage[data-state="current"] .bt-stage__bar { background: var(--dk-accent); }
+.bt-stage[data-state="failed"] { color: var(--dk-error-text); font-weight: var(--dk-weight-bold); }
+.bt-stage[data-state="failed"] .bt-stage__bar { background: var(--dk-error); }
+.bt-stage__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.bt-try { display: flex; flex-direction: column; gap: var(--dk-space-3); padding: var(--dk-space-4) var(--dk-space-5); }
+.bt-try .dk-hint { margin: 0; }
+.bt-try__acts { display: flex; gap: var(--dk-space-2); flex-wrap: wrap; }
+.bt-next { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: var(--dk-space-3); padding: var(--dk-space-4) var(--dk-space-5); }
+.bt-next .dk-hint { margin: 2px 0 var(--dk-space-3); }
+.bt-next__mark { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; background: var(--dk-ok-wash); color: var(--dk-ok-text); font-size: 20px; }
+.bt-next__acts { display: flex; gap: var(--dk-space-2); flex-wrap: wrap; }
+.bt-out { display: flex; flex-direction: column; gap: var(--dk-space-3); }
+.bt-out__name { max-width: 420px; }
+
+/* ---- Chart -------------------------------------------------------------------- */
+.bt-chart { padding: var(--dk-space-4) var(--dk-space-5); display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
+.bt-chart__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--dk-space-3); flex-wrap: wrap; }
+.bt-chart__head .dk-hint { margin: 2px 0 0; }
+.bt-chart__svg { width: 100%; height: auto; display: block; overflow: visible; }
+.bt-chart__grid { stroke: var(--dk-edge); stroke-width: 1; }
+.bt-chart__tick { fill: var(--dk-muted); font: 11px var(--dk-font-mono); }
+.bt-chart__axis { fill: var(--dk-muted); font: 11px var(--dk-font-body); }
+.bt-chart__line { fill: none; stroke: var(--dk-series-1); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
+.bt-chart__eval { fill: var(--dk-card); stroke: var(--dk-series-2); stroke-width: 2; }
+.bt-chart__end { fill: var(--dk-series-1); }
+.bt-chart__label { fill: var(--dk-text); font: 700 12px var(--dk-font-mono); }
+.bt-chart__cursor { stroke: var(--dk-muted); stroke-dasharray: 3 3; stroke-width: 1; }
+.bt-chart__readout { margin: 0; font-size: var(--dk-text-12); color: var(--dk-muted); }
+.bt-chart__empty { margin: 0; color: var(--dk-muted); font-size: var(--dk-text-13); }
+.bt-chart__data summary { cursor: pointer; font-size: var(--dk-text-13); color: var(--dk-muted); min-height: 32px; display: flex; align-items: center; }
+.bt-chart__table { --dk-table-max: 240px; margin-top: var(--dk-space-2); }
+
+/* ---- Log ---------------------------------------------------------------------- */
+.bt-log { padding: var(--dk-space-4) var(--dk-space-5); display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
+.bt-log__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--dk-space-3); flex-wrap: wrap; }
+.bt-log__head .dk-hint { margin: 2px 0 0; }
+.bt-log__tools { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; }
+.bt-log__body { max-height: 280px; overflow: auto; padding: var(--dk-space-3); border-radius: var(--dk-radius-2); background: var(--dk-inset); font: var(--dk-text-12) / 1.6 var(--dk-font-mono); }
+.bt-log__line { display: grid; grid-template-columns: 64px 44px minmax(0, 1fr); gap: var(--dk-space-3); }
+.bt-log__time, .bt-log__tag { color: var(--dk-muted); }
+.bt-log__text { overflow-wrap: anywhere; white-space: pre-wrap; }
+.bt-log__line[data-level="warn"] .bt-log__text, .bt-log__line[data-level="warn"] .bt-log__tag { color: var(--dk-warn-text); }
+.bt-log__line[data-level="err"] .bt-log__text, .bt-log__line[data-level="err"] .bt-log__tag { color: var(--dk-error-text); }
+.bt-log__empty { margin: 0; color: var(--dk-muted); }
+
+/* ---- Jobs table --------------------------------------------------------------- */
+.bt-jobs { display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
+.bt-empty-card { padding: var(--dk-space-5); border-radius: var(--dk-radius-3); border: 1px solid var(--dk-edge); background: var(--dk-card); }
+.bt-linkcell { display: block; padding: 0; background: none; border: 0; font: inherit; color: inherit; text-align: left; cursor: pointer; max-width: 100%; }
+.bt-linkcell:hover .dk-table-name { text-decoration: underline; }
+.bt-jobs__msg { color: var(--dk-error-text); overflow-wrap: anywhere; }
+.bt-rowacts { display: flex; align-items: center; justify-content: flex-end; gap: var(--dk-space-1); }
+
+/* ---- Landing ------------------------------------------------------------------ */
+.bt-machine { display: flex; flex-direction: column; gap: var(--dk-space-3); background: var(--dk-inset); }
+.bt-machine__facts { margin: 0; display: flex; flex-wrap: wrap; gap: var(--dk-space-2) var(--dk-space-5); }
+.bt-machine__facts > div { min-width: 0; }
+.bt-machine__facts dt { display: flex; align-items: center; gap: 6px; font-size: var(--dk-text-12); color: var(--dk-muted); }
+.bt-machine__facts dd { margin: 2px 0 0; font-size: var(--dk-text-13); }
+.bt-machine__backends { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; font-size: var(--dk-text-13); color: var(--dk-muted); }
+.bt-now { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-3); background: var(--dk-accent-wash); }
+.bt-now[data-kind="failed"] { background: var(--dk-error-wash); color: var(--dk-error-text); }
+.bt-now__mark { font-size: 20px; }
+.bt-now__title { margin: 0; font-weight: var(--dk-weight-bold); font-size: var(--dk-text-15); overflow-wrap: anywhere; }
+.bt-now__text { margin: 2px 0 0; font-size: var(--dk-text-13); overflow-wrap: anywhere; }
+.bt-groups { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--dk-space-6); }
+.bt-group { display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
+.bt-group__head { display: flex; align-items: baseline; gap: var(--dk-space-3); flex-wrap: wrap; }
+.bt-group__head .dk-hint { margin: 0; }
+.bt-flow { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; }
+.bt-flow__list { list-style: none; margin: 0; padding: 0; display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; }
+.bt-flow__item { display: inline-flex; align-items: center; gap: var(--dk-space-2); color: var(--dk-muted); }
+.bt-flow .dk-chip { cursor: default; }
+.bt-flow .dk-hint { margin: 0; }
+.bt-tools { list-style: none; margin: 0; padding: 0; border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-3); background: var(--dk-card); overflow: hidden; }
+.bt-tool { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; gap: var(--dk-space-4); align-items: start; padding: var(--dk-space-4) var(--dk-space-5); }
+.bt-tool + .bt-tool { border-top: 1px solid var(--dk-edge); }
+.bt-tool__icon { display: grid; place-items: center; width: 40px; height: 40px; border-radius: var(--dk-radius-2); background: var(--dk-inset); color: var(--dk-muted); font-size: 18px; }
+.bt-tool__title { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; font: var(--dk-weight-bold) var(--dk-text-15) / 1.3 var(--dk-font-display); }
+.bt-tool__text { margin: 4px 0 0; max-width: 62ch; font-size: var(--dk-text-13); line-height: 1.5; color: var(--dk-muted); }
+.bt-tool__why { margin: var(--dk-space-2) 0 0; font-size: var(--dk-text-13); color: var(--dk-warn-text); }
+.bt-tool__end { display: flex; flex-direction: column; align-items: flex-end; gap: var(--dk-space-2); }
+.bt-needs { list-style: none; margin: var(--dk-space-2) 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: var(--dk-space-1) var(--dk-space-4); }
+.bt-needs li { display: inline-flex; align-items: center; gap: 6px; font-size: var(--dk-text-12); color: var(--dk-muted); }
+.bt-needs li[data-tone="ok"] .lai-icon { color: var(--dk-ok-text); }
+.bt-needs li[data-tone="warn"] { color: var(--dk-warn-text); }
+
+/* ---- Disabled (account) --------------------------------------------------------- */
+.bt-off { padding-top: var(--dk-space-5); }
+.bt-off__card { display: flex; flex-direction: column; align-items: flex-start; gap: var(--dk-space-3); padding: var(--dk-space-5); background: var(--dk-inset); }
+.bt-off__text { margin: 0; max-width: 56ch; font-size: var(--dk-text-15); line-height: 1.5; }
+.bt-off__card .dk-hint { margin: 0; }
+.bt-off__acts { display: flex; gap: var(--dk-space-2); flex-wrap: wrap; }
+
+/* ---- Phone ---------------------------------------------------------------------- */
+@media (max-width: 640px) {
+ .bt-section__body { padding-left: 0; }
+ .bt-pair, .bt-pair--wide, .bt-triple { grid-template-columns: minmax(0, 1fr); }
+ .bt-steps { gap: 6px; flex-wrap: nowrap; }
+ .bt-step { gap: 4px; }
+ .bt-step__label { font-size: var(--dk-text-12); }
+ .bt-step__mark { width: 22px; height: 22px; }
+ .bt-step:not(:last-child)::after { width: 8px; margin-left: 0; }
+ .bt-tool { grid-template-columns: 40px minmax(0, 1fr); }
+ .bt-tool__end { grid-column: 1 / -1; flex-direction: row; justify-content: flex-start; flex-wrap: wrap; }
+ .bt-now { grid-template-columns: 24px minmax(0, 1fr); }
+ .bt-now .dk-btn { grid-column: 1 / -1; justify-self: start; }
+ .bt-kv__row { grid-template-columns: minmax(0, 1fr) auto; }
+ .bt-kv__row .dk-input:nth-child(2) { grid-column: 1 / -1; grid-row: 2; }
+ .bt-log__line { grid-template-columns: 56px minmax(0, 1fr); }
+ .bt-log__tag { display: none; }
+ .bt-more__toggle .dk-hint { flex-basis: 100%; padding-left: 24px; }
+ .bt-rowacts { flex-direction: column; align-items: flex-end; gap: 0; }
+ .bt-jobs .dk-table { table-layout: fixed; }
+ .bt-jobs .dk-table th:nth-child(3) { width: 112px; }
+ .bt-jobs .dk-table th:last-child { width: 84px; }
+ .bt-jobs .dk-table td:first-child { overflow-wrap: anywhere; min-width: 0; }
+ .bt-jobs .dk-table .dk-table-name, .bt-jobs .dk-table .dk-table-sub { white-space: normal; overflow: visible; text-overflow: clip; overflow-wrap: anywhere; }
+ .bt-bar__acts { width: 100%; }
+ .bt-bar__acts .dk-btn--primary { flex: 1 1 auto; }
+ .bt-job__percent { font-size: var(--dk-text-32); }
+ .bt-stage__label { font-size: 11px; }
+ .bt-next { grid-template-columns: minmax(0, 1fr); }
+ .bt-block.dk-card, .bt-job, .bt-chart, .bt-log, .bt-try, .bt-next { padding-inline: var(--dk-space-4); }
+ .bt-checks-card { padding-inline: var(--dk-space-4); }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .bt-page * { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }
+}
diff --git a/core/http/react-ui/src/components/traffic/BackendTraceDetail.jsx b/core/http/react-ui/src/components/traffic/BackendTraceDetail.jsx
new file mode 100644
index 000000000..13911e08c
--- /dev/null
+++ b/core/http/react-ui/src/components/traffic/BackendTraceDetail.jsx
@@ -0,0 +1,179 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import WaveformPlayer from '../audio/WaveformPlayer'
+import Icon from '../Icon'
+import { durationText, nsToMs } from '../../utils/traffic'
+
+const AUDIO_DATA_KEYS = new Set([
+ 'audio_wav_base64', 'audio_duration_s', 'audio_snippet_s',
+ 'audio_sample_rate', 'audio_samples', 'audio_rms_dbfs',
+ 'audio_peak_dbfs', 'audio_dc_offset',
+])
+
+function formatValue(value) {
+ if (value === null || value === undefined) return 'null'
+ if (typeof value === 'boolean') return value ? 'true' : 'false'
+ if (typeof value === 'object') return JSON.stringify(value)
+ return String(value)
+}
+
+function formatLarge(value) {
+ if (typeof value === 'string') {
+ try { return JSON.stringify(JSON.parse(value), null, 2) } catch { return value }
+ }
+ if (typeof value === 'object') return JSON.stringify(value, null, 2)
+ return String(value)
+}
+
+function isLarge(value) {
+ if (typeof value === 'string') return value.length > 120
+ if (typeof value === 'object' && value !== null) return JSON.stringify(value).length > 120
+ return false
+}
+
+function isPlainObject(value) {
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
+}
+
+// A base64 WAV payload as a blob: object URL for the waveform player. A data:
+// URL would play in but the peaks renderer fetch()es the src, and the
+// CSP's connect-src only allows blob:. Decoding to a Blob also tolerates a
+// payload that is not base64, such as the "" marker older
+// servers stamped into oversized fields, by giving null instead of a broken
+// player.
+function useWavObjectURL(b64) {
+ const [url, setUrl] = useState(null)
+ useEffect(() => {
+ if (!b64) {
+ setUrl(null)
+ return undefined
+ }
+ let objectUrl = null
+ try {
+ const bin = atob(b64)
+ const bytes = new Uint8Array(bin.length)
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
+ objectUrl = URL.createObjectURL(new Blob([bytes], { type: 'audio/wav' }))
+ setUrl(objectUrl)
+ } catch {
+ setUrl(null)
+ }
+ return () => { if (objectUrl) URL.revokeObjectURL(objectUrl) }
+ }, [b64])
+ return url
+}
+
+function AudioSnippet({ data }) {
+ const { t } = useTranslation('traffic')
+ const audioUrl = useWavObjectURL(data?.audio_wav_base64)
+ if (!data?.audio_wav_base64) return null
+ const metrics = [
+ [t('backend.audio.duration'), `${data.audio_duration_s} s`],
+ [t('backend.audio.rate'), `${data.audio_sample_rate} Hz`],
+ [t('backend.audio.rms'), `${data.audio_rms_dbfs} dBFS`],
+ [t('backend.audio.peak'), `${data.audio_peak_dbfs} dBFS`],
+ [t('backend.audio.samples'), data.audio_samples],
+ [t('backend.audio.snippet'), `${data.audio_snippet_s} s`],
+ [t('backend.audio.dc'), data.audio_dc_offset],
+ ]
+ return (
+
+ {t('backend.audio.title')}
+ {audioUrl
+ ?
+ : (
+
+ {t('backend.audio.truncated')}
+
+ )}
+
+ {metrics.map(([label, value]) => (
+
{label} {value}
+ ))}
+
+
+ )
+}
+
+// The data fields of a backend trace, nested objects and long values opening
+// in place.
+function DataFields({ data, nested }) {
+ const { t } = useTranslation('traffic')
+ const [expanded, setExpanded] = useState({})
+ const fields = Object.entries(data).filter(([key]) => !AUDIO_DATA_KEYS.has(key))
+ if (fields.length === 0) return null
+ return (
+
+ {!nested &&
{t('backend.fields')} }
+
+
+ )
+}
+
+// The row of a backend operation, opened in place.
+export default function BackendTraceDetail({ trace }) {
+ const { t } = useTranslation('traffic')
+ const running = trace.status === 'running'
+ const ms = nsToMs(trace.duration)
+ return (
+
+
+
{t('backend.type')} {trace.type || '-'}
+
{t('backend.model')} {trace.model_name || '-'}
+
{t('backend.backend')} {trace.backend || '-'}
+
{t('backend.duration')} {ms == null ? '-' : durationText(ms)}{running ? ` (${t('backend.running')})` : ''}
+
+ {trace.error && (
+
{trace.error}
+ )}
+ {trace.model_name && (
+ // /app/backend-logs/:modelId is the unified entry point: standalone it
+ // streams local logs, in distributed mode it resolves the model to the
+ // worker(s) that host it.
+
+ {t('backend.logs')}
+
+ )}
+ {trace.data &&
}
+ {trace.body && (
+
+ {t('backend.body')}
+ {formatLarge(trace.body)}
+
+ )}
+ {trace.data && Object.keys(trace.data).length > 0 &&
}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/traffic/PercentChart.jsx b/core/http/react-ui/src/components/traffic/PercentChart.jsx
new file mode 100644
index 000000000..3a2817be5
--- /dev/null
+++ b/core/http/react-ui/src/components/traffic/PercentChart.jsx
@@ -0,0 +1,125 @@
+
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { percentGeometry } from '../../utils/traffic'
+
+function clock(t, seconds) {
+ return new Date(t).toLocaleTimeString([], seconds
+ ? { hour: '2-digit', minute: '2-digit', second: '2-digit' }
+ : { hour: '2-digit', minute: '2-digit' })
+}
+
+// One percentage over the readings the page has taken, on a fixed 0 to 100
+// axis. Like the memory chart it is only what was read while the page was open,
+// and it says so in the sub line. Same four things as every chart here: a
+// readout, the arrow keys, a text label at the end of the line and a data table.
+export default function PercentChart({ testId, title, sub, samples, series = 1 }) {
+ const { t } = useTranslation('traffic')
+ const figureRef = useRef(null)
+ const [width, setWidth] = useState(520)
+ const [cursor, setCursor] = useState(null)
+ const has = Array.isArray(samples) && samples.length >= 2
+
+ useEffect(() => {
+ const el = figureRef.current
+ if (!has || !el) return undefined
+ const measure = () => {
+ const w = Math.round(el.getBoundingClientRect().width)
+ if (w > 0) setWidth(Math.max(280, Math.min(920, w)))
+ }
+ measure()
+ if (typeof ResizeObserver === 'undefined') return undefined
+ const observer = new ResizeObserver(measure)
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [has])
+
+ const geometry = useMemo(() => percentGeometry(samples, { width, height: width < 480 ? 160 : 180 }), [samples, width])
+ const seconds = geometry ? geometry.spanMs < 10 * 60_000 : false
+ const active = cursor != null && geometry ? geometry.points[Math.min(cursor, geometry.points.length - 1)] : null
+
+ const move = (event) => {
+ if (!geometry) return
+ const n = geometry.points.length
+ const at = cursor == null ? n - 1 : cursor
+ if (event.key === 'ArrowLeft') setCursor(Math.max(0, at - 1))
+ else if (event.key === 'ArrowRight') setCursor(Math.min(n - 1, at + 1))
+ else if (event.key === 'Home') setCursor(0)
+ else if (event.key === 'End') setCursor(n - 1)
+ else if (event.key === 'Escape') setCursor(null)
+ else return
+ event.preventDefault()
+ }
+ const hover = (event) => {
+ if (!geometry) return
+ const box = event.currentTarget.getBoundingClientRect()
+ const x = ((event.clientX - box.left) / box.width) * geometry.width
+ let best = 0
+ geometry.points.forEach((p, i) => { if (Math.abs(p.x - x) < Math.abs(geometry.points[best].x - x)) best = i })
+ setCursor(best)
+ }
+
+ return (
+
+
+
{title}
+ {sub && {sub} }
+
+ {geometry ? (
+ <>
+
+ {active
+ ? {clock(active.t, true)} · {Math.round(active.value)}%
+ : {t('chart.hint')} }
+
+ setCursor(null)}
+ onBlur={() => setCursor(null)}
+ >
+ {geometry.ticks.map(tick => (
+
+
+ {tick.value}%
+
+ ))}
+ {geometry.xTicks.map(tick => (
+ {clock(tick.t, seconds)}
+ ))}
+
+
+ {t('chart.now', { value: `${Math.round(geometry.end.value)}%` })}
+ {active && (
+ <>
+
+
+ >
+ )}
+
+
+ {t('chart.dataTable')}
+
+
+ {t('chart.time')} {title}
+
+ {[...samples].reverse().map(s => (
+ {clock(s.t, true)} {Math.round(s.value)}%
+ ))}
+
+
+
+
+ >
+ ) : (
+ {t('host.waiting')}
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/traffic/TrafficChart.jsx b/core/http/react-ui/src/components/traffic/TrafficChart.jsx
new file mode 100644
index 000000000..fbf600216
--- /dev/null
+++ b/core/http/react-ui/src/components/traffic/TrafficChart.jsx
@@ -0,0 +1,161 @@
+
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { barGeometry, compactCount } from '../../utils/traffic'
+
+// Stacked bars on a zero-based axis, drawn as SVG at the width they are shown
+// at, with the kit's chart classes. Every chart carries the same four things:
+// - a text readout, filled by hover or by the arrow keys (Left, Right, Home,
+// End, Escape clears), so a value never needs a pointer;
+// - a legend whenever there are two series or more, named in text;
+// - the unit and the source in the sub line, so the reader knows what the
+// numbers are and where they come from;
+// - a data table behind a disclosure, with every value as text.
+//
+// columns [{ key, label, tick, segments: [{ id, value }] }], oldest first
+// groups [{ id, name, series, tone }]: `series` is 1 to 6 or 'other' (the
+// kit's series tokens); `tone: 'failed'` draws the status colour,
+// which is named in the legend and the table as well.
+// format how a value reads in the readout and the table
+export default function TrafficChart({ testId, title, sub, columns, groups, format = compactCount, unit, emptyLabel }) {
+ const { t } = useTranslation('traffic')
+ const figureRef = useRef(null)
+ const [width, setWidth] = useState(640)
+ const [cursor, setCursor] = useState(null)
+
+ useEffect(() => {
+ const el = figureRef.current
+ if (!el) return undefined
+ const measure = () => {
+ const w = Math.round(el.getBoundingClientRect().width)
+ if (w > 0) setWidth(Math.max(280, Math.min(1000, w)))
+ }
+ measure()
+ if (typeof ResizeObserver === 'undefined') return undefined
+ const observer = new ResizeObserver(measure)
+ observer.observe(el)
+ return () => observer.disconnect()
+ }, [])
+
+ const geometry = useMemo(() => barGeometry(columns, {
+ width,
+ height: width < 520 ? 180 : 210,
+ left: 44,
+ right: 8,
+ }), [columns, width])
+
+ const toneOf = id => groups.find(g => g.id === id)
+ const classOf = id => {
+ const g = toneOf(id)
+ if (g?.tone === 'failed') return 'tf-bar--failed'
+ return g?.series === 'other' ? 'dk-series-other' : `dk-series-${g?.series || 1}`
+ }
+
+ const n = columns.length
+ const active = cursor != null && cursor < n ? geometry.cols[cursor] : null
+ const activeColumn = active ? columns[cursor] : null
+
+ const move = (event) => {
+ const at = cursor == null ? n - 1 : cursor
+ if (event.key === 'ArrowLeft') setCursor(Math.max(0, at - 1))
+ else if (event.key === 'ArrowRight') setCursor(Math.min(n - 1, at + 1))
+ else if (event.key === 'Home') setCursor(0)
+ else if (event.key === 'End') setCursor(n - 1)
+ else if (event.key === 'Escape') setCursor(null)
+ else return
+ event.preventDefault()
+ }
+
+ const hover = (event) => {
+ const box = event.currentTarget.getBoundingClientRect()
+ const x = ((event.clientX - box.left) / box.width) * geometry.width
+ let best = 0
+ geometry.cols.forEach((c, i) => { if (Math.abs(c.cx - x) < Math.abs(geometry.cols[best].cx - x)) best = i })
+ setCursor(best)
+ }
+
+ const readout = activeColumn
+ ? [`${activeColumn.tick ?? activeColumn.label}`, ...activeColumn.segments
+ .filter(seg => groups.length > 1 || seg.value > 0)
+ .map(seg => `${toneOf(seg.id)?.name ?? seg.id} ${format(seg.value)}`)].join(' · ')
+ : null
+
+ const empty = geometry.max === 1 && geometry.cols.every(c => c.total === 0)
+
+ return (
+
+
+
{title}
+ {sub && {sub} }
+
+
+ {readout
+ ? {readout}
+ : {empty && emptyLabel ? emptyLabel : t('chart.hint')} }
+
+ setCursor(null)}
+ onBlur={() => setCursor(null)}
+ >
+ {geometry.ticks.map(tick => (
+
+
+ {compactCount(tick.value)}
+
+ ))}
+ {geometry.cols.map((col, i) => (
+
+ {col.segs.map(seg => (
+
+ ))}
+ {i % geometry.every === 0 && (
+ {columns[i].tick ?? columns[i].label}
+ )}
+
+ ))}
+ {active && }
+
+ {groups.length > 1 && (
+
+ {groups.map(g => (
+
+
+ {g.name}
+
+ ))}
+
+ )}
+
+ {t('chart.dataTable')}
+
+
+
+
+ {t('chart.time')}
+ {groups.map(g => {g.name} )}
+
+
+
+ {[...columns].reverse().map(col => (
+
+ {col.label}
+ {groups.map(g => (
+ {format(col.segments.find(s => s.id === g.id)?.value ?? 0)}
+ ))}
+
+ ))}
+
+
+
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/traffic/WindowSwitch.jsx b/core/http/react-ui/src/components/traffic/WindowSwitch.jsx
new file mode 100644
index 000000000..144c44290
--- /dev/null
+++ b/core/http/react-ui/src/components/traffic/WindowSwitch.jsx
@@ -0,0 +1,28 @@
+
+import { useTranslation } from 'react-i18next'
+import { WINDOWS } from '../../utils/traffic'
+import { useTrafficWindow } from '../../hooks/useTraffic'
+
+// One window for every Traffic page: the choice made here is the one the next
+// page opens with. Four windows, because the trace summary stops at 7 days and
+// the usage ledger groups by day, week, month or all.
+export default function WindowSwitch() {
+ const { t } = useTranslation('traffic')
+ const { id, setWindow } = useTrafficWindow()
+ return (
+
+ {WINDOWS.map(w => (
+ setWindow(w.id)}
+ >
+ {t(`window.${w.id}`)}
+
+ ))}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/users/AccessSheet.jsx b/core/http/react-ui/src/components/users/AccessSheet.jsx
new file mode 100644
index 000000000..a7ab39608
--- /dev/null
+++ b/core/http/react-ui/src/components/users/AccessSheet.jsx
@@ -0,0 +1,243 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { useState } from 'react'
+import { adminUsersApi } from '../../utils/api'
+import { initialOf } from '../../utils/access'
+import SideSheet from '../SideSheet'
+import Icon from '../Icon'
+
+const WINDOW_OPTIONS = [
+ { value: '1m', label: '1 minute' },
+ { value: '5m', label: '5 minutes' },
+ { value: '1h', label: '1 hour' },
+ { value: '6h', label: '6 hours' },
+ { value: '1d', label: '1 day' },
+ { value: '7d', label: '7 days' },
+ { value: '30d', label: '30 days' },
+]
+
+function FeatureGroup({ title, features, permissions, onToggle, onAll }) {
+ if (!features.length) return null
+ return (
+
+
+
{title}
+
+ onAll(features, true)}>All
+ onAll(features, false)}>None
+
+
+
+ {features.map(f => (
+
+ {f.label}
+ onToggle(f.key)}
+ />
+
+ ))}
+
+
+ )
+}
+
+// What one user may use: features, models and limits. Saving makes the same
+// calls as before: permissions, the model allow-list, then the quota rules that
+// were removed, then those that changed or are new.
+//
+// Account actions that do not belong to the access form (reset password, role,
+// delete) sit at the bottom and are handled by the page.
+export default function AccessSheet({ user, featureMeta, availableModels, onClose, onSave, addToast, actions }) {
+ const [permissions, setPermissions] = useState({ ...(user.permissions || {}) })
+ const [allowedModels, setAllowedModels] = useState(user.allowed_models || { enabled: false, models: [] })
+ const [quotas, setQuotas] = useState((user.quotas || []).map(q => ({ ...q, _dirty: false })))
+ const [deletedQuotaIds, setDeletedQuotaIds] = useState([])
+ const [saving, setSaving] = useState(false)
+
+ const apiFeatures = featureMeta?.api_features || []
+ const agentFeatures = featureMeta?.agent_features || []
+ const generalFeatures = featureMeta?.general_features || []
+
+ const toggleFeature = (key) => setPermissions(prev => ({ ...prev, [key]: !prev[key] }))
+ const setAllFeatures = (features, value) => {
+ setPermissions(prev => {
+ const updated = { ...prev }
+ features.forEach(f => { updated[f.key] = value })
+ return updated
+ })
+ }
+ const toggleModel = (model) => {
+ setAllowedModels(prev => {
+ const models = prev.models || []
+ const has = models.includes(model)
+ return { ...prev, models: has ? models.filter(m => m !== model) : [...models, model] }
+ })
+ }
+ const setAllModels = (value) => setAllowedModels(prev => ({ ...prev, models: value ? [...(availableModels || [])] : [] }))
+
+ const addQuota = () => {
+ setQuotas(prev => [...prev, {
+ id: null, model: '', max_requests: null, max_total_tokens: null, window: '1h',
+ current_requests: 0, current_total_tokens: 0, _dirty: true, _new: true,
+ }])
+ }
+ const updateQuota = (idx, field, value) => setQuotas(prev => prev.map((q, i) => i === idx ? { ...q, [field]: value, _dirty: true } : q))
+ const removeQuota = (idx) => {
+ const q = quotas[idx]
+ if (q.id && !q._new) setDeletedQuotaIds(prev => [...prev, q.id])
+ setQuotas(prev => prev.filter((_, i) => i !== idx))
+ }
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ await adminUsersApi.setPermissions(user.id, permissions)
+ await adminUsersApi.setModels(user.id, allowedModels)
+ for (const qid of deletedQuotaIds) await adminUsersApi.deleteQuota(user.id, qid)
+ for (const q of quotas) {
+ if (q._dirty || q._new) {
+ await adminUsersApi.setQuota(user.id, {
+ model: q.model,
+ max_requests: q.max_requests || null,
+ max_total_tokens: q.max_total_tokens || null,
+ window: q.window,
+ })
+ }
+ }
+ // Refetch so the page holds server-assigned ids and current usage.
+ let freshQuotas = []
+ try {
+ const qData = await adminUsersApi.getQuotas(user.id)
+ freshQuotas = Array.isArray(qData) ? qData : qData.quotas || []
+ } catch {
+ freshQuotas = quotas.map(q => ({ ...q, _dirty: false, _new: false }))
+ }
+ onSave(user.id, permissions, allowedModels, freshQuotas)
+ addToast(`Permissions updated for ${user.name || user.email}`, 'success')
+ onClose()
+ } catch (err) {
+ addToast(`Failed to update permissions: ${err.message}`, 'error')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const who = user.name || user.email
+ return (
+
+ Cancel
+
+ {saving ? 'Saving...' : 'Save access'}
+
+ >}
+ >
+
+
What they can use
+
+
+
+
+
Models
+
+
+
+ Restrict to specific models
+ {allowedModels.enabled ? `${(allowedModels.models || []).length} allowed` : 'All models are accessible'}
+
+ setAllowedModels(prev => ({ ...prev, enabled: !prev.enabled }))}
+ />
+
+ {allowedModels.enabled && (
+ <>
+
+ setAllModels(true)}>All
+ setAllModels(false)}>None
+
+
+ >
+ )}
+
+
+
Limits
+
+ {quotas.length === 0 ? (
+ No limits: unlimited access
+ ) : (
+
+ {quotas.map((q, idx) => {
+ const reqPct = (q.max_requests && !q._new) ? Math.min(100, Math.round(((q.current_requests ?? 0) / q.max_requests) * 100)) : null
+ const tokPct = (q.max_total_tokens && !q._new) ? Math.min(100, Math.round(((q.current_total_tokens ?? 0) / q.max_total_tokens) * 100)) : null
+ return (
+
+
+
+ updateQuota(idx, 'model', e.target.value)}>
+ All models
+ {(availableModels || []).map(m => {m} )}
+
+
+
+ updateQuota(idx, 'window', e.target.value)}>
+ {WINDOW_OPTIONS.map(w => per {w.label} )}
+
+
+
removeQuota(idx)}>
+
+
+
+
+
+ )
+ })}
+
+ )}
+ Add rule
+
+
+ {actions}
+
+
+ )
+}
diff --git a/core/http/react-ui/src/components/users/ApiKeysPanel.jsx b/core/http/react-ui/src/components/users/ApiKeysPanel.jsx
new file mode 100644
index 000000000..0fefeb76c
--- /dev/null
+++ b/core/http/react-ui/src/components/users/ApiKeysPanel.jsx
@@ -0,0 +1,255 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { useState, useEffect, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { apiKeysApi } from '../../utils/api'
+import { copyToClipboard } from '../../utils/clipboard'
+import { keyState, timeUntil, KEY_EXPIRIES } from '../../utils/access'
+import { useDelayedAction, UNDO_WINDOW_MS } from '../../hooks/useDelayedAction'
+import LoadingSpinner from '../LoadingSpinner'
+import Icon from '../Icon'
+
+function formatDate(d) {
+ if (!d) return ''
+ return new Date(d).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
+}
+
+function Seconds({ until }) {
+ const [now, setNow] = useState(Date.now())
+ useEffect(() => {
+ const id = setInterval(() => setNow(Date.now()), 500)
+ return () => clearInterval(id)
+ }, [])
+ return Math.max(0, Math.ceil((until - now) / 1000))
+}
+
+// The caller's own API keys: create one, see it once, pause or resume it, and
+// revoke it with ten seconds to change your mind. LocalAI lists a person's
+// keys only to that person, so this panel is the same for an admin and a user.
+//
+// A key is shown once, in full, when it is created; the list holds only its
+// prefix. Revoking is a call made after the undo window ends, so Undo sends
+// nothing.
+export default function ApiKeysPanel({ addToast, intro }) {
+ const { t } = useTranslation('auth')
+ const [keys, setKeys] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [creating, setCreating] = useState(false)
+ const [name, setName] = useState('')
+ const [expiresIn, setExpiresIn] = useState('')
+ const [reveal, setReveal] = useState(null)
+ const [pauseFormId, setPauseFormId] = useState(null)
+ const [pauseMode, setPauseMode] = useState('indefinite')
+ const [pauseUntil, setPauseUntil] = useState('')
+ const [pauseBusyId, setPauseBusyId] = useState(null)
+
+ const fetchKeys = useCallback(async () => {
+ setLoading(true)
+ try {
+ const data = await apiKeysApi.list()
+ setKeys(data.keys || [])
+ } catch (err) {
+ addToast(t('account.apiKeys.loadFailed', { message: err.message }), 'error')
+ } finally {
+ setLoading(false)
+ }
+ }, [addToast, t])
+
+ useEffect(() => { fetchKeys() }, [fetchKeys])
+
+ const revokeNow = useCallback(async (id) => {
+ try {
+ await apiKeysApi.revoke(id)
+ setKeys(prev => prev.filter(k => k.id !== id))
+ addToast(t('account.apiKeys.revoked'), 'success')
+ } catch (err) {
+ addToast(t('account.apiKeys.revokeFailed', { message: err.message }), 'error')
+ }
+ }, [addToast, t])
+ const revoking = useDelayedAction(revokeNow)
+
+ const handleCreate = async (e) => {
+ e.preventDefault()
+ if (!name.trim()) return
+ setCreating(true)
+ try {
+ const data = await apiKeysApi.create(name.trim(), expiresIn)
+ setReveal({ key: data.key, name: data.name || name.trim(), copied: false })
+ setName('')
+ await fetchKeys()
+ addToast(t('account.apiKeys.createdToast'), 'success')
+ } catch (err) {
+ addToast(t('account.apiKeys.createFailed', { message: err.message }), 'error')
+ } finally {
+ setCreating(false)
+ }
+ }
+
+ const copyKey = async () => {
+ const ok = await copyToClipboard(reveal.key)
+ if (ok) setReveal(r => ({ ...r, copied: true }))
+ addToast(ok ? t('account.apiKeys.copiedToast') : t('account.apiKeys.copyFailed'), ok ? 'success' : 'error')
+ }
+
+ const isPaused = (k) => k.disabled || (k.pausedUntil && new Date(k.pausedUntil) > new Date())
+
+ const applyPause = async (id, disabled, pausedUntil) => {
+ setPauseBusyId(id)
+ try {
+ await apiKeysApi.setPause(id, disabled, pausedUntil)
+ setPauseFormId(null)
+ setPauseUntil('')
+ await fetchKeys()
+ addToast(t(disabled || pausedUntil ? 'account.apiKeys.pausedToast' : 'account.apiKeys.resumedToast'), 'success')
+ } catch (err) {
+ addToast(t('account.apiKeys.pauseFailed', { message: err.message }), 'error')
+ } finally {
+ setPauseBusyId(null)
+ }
+ }
+
+ const submitPause = (id) => {
+ if (pauseMode === 'until') {
+ if (!pauseUntil) return
+ applyPause(id, false, new Date(pauseUntil).toISOString())
+ } else {
+ applyPause(id, true, null)
+ }
+ }
+
+ const openPauseForm = (id) => {
+ setPauseMode('indefinite')
+ setPauseUntil('')
+ setPauseFormId(id)
+ }
+
+ return (
+
+ {intro &&
{intro}
}
+
+
+
+ {t('account.apiKeys.create')}
+ setName(e.target.value)}
+ />
+
+
+
{t('account.apiKeys.expires')}
+
+ setExpiresIn(e.target.value)}>
+ {KEY_EXPIRIES.map(o => {o.label} )}
+
+
+
+
+ {creating ? : <> {t('account.apiKeys.createButton')}>}
+
+
+
+ {reveal && (
+
+
{t('account.apiKeys.copyNow')}
+
+ {reveal.key}
+
+ {reveal.copied ? t('account.apiKeys.copied') : t('account.apiKeys.copy')}
+
+
+
+ {t('account.apiKeys.revealNote', { name: reveal.name })}
+ setReveal(null)}>{t('account.apiKeys.copiedIt')}
+
+
+ )}
+
+ {loading ? (
+
+ ) : keys.length === 0 ? (
+
+
+
{t('account.apiKeys.empty')}
+
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/users/InvitesPanel.jsx b/core/http/react-ui/src/components/users/InvitesPanel.jsx
new file mode 100644
index 000000000..948aa93c9
--- /dev/null
+++ b/core/http/react-ui/src/components/users/InvitesPanel.jsx
@@ -0,0 +1,192 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { useState, useEffect, useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { adminInvitesApi } from '../../utils/api'
+import { copyToClipboard } from '../../utils/clipboard'
+import { inviteState, timeUntil, INVITE_LIFETIMES } from '../../utils/access'
+import LoadingSpinner from '../LoadingSpinner'
+import Dialog from '../Dialog'
+import Icon from '../Icon'
+
+const inviteUrl = (code) => `${window.location.origin}/invite/${code}`
+
+// Invite links. A link is shown once, when it is created: the list keeps only
+// the first characters of the code, so a link that was not copied cannot be
+// recovered, only revoked and made again.
+export default function InvitesPanel({ addToast, registrationMode }) {
+ const { t } = useTranslation('auth')
+ const [invites, setInvites] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [creating, setCreating] = useState(false)
+ const [hours, setHours] = useState(168)
+ const [codes, setCodes] = useState({})
+ const [fresh, setFresh] = useState(null)
+ const [confirm, setConfirm] = useState(null)
+
+ const fetchInvites = useCallback(async () => {
+ setLoading(true)
+ try {
+ const data = await adminInvitesApi.list()
+ setInvites(Array.isArray(data) ? data : data.invites || [])
+ } catch (err) {
+ addToast(`Failed to load invites: ${err.message}`, 'error')
+ } finally {
+ setLoading(false)
+ }
+ }, [addToast])
+
+ useEffect(() => { fetchInvites() }, [fetchInvites])
+
+ const create = async () => {
+ setCreating(true)
+ try {
+ const resp = await adminInvitesApi.create(hours)
+ if (resp && resp.id && resp.code) {
+ setCodes(prev => ({ ...prev, [resp.id]: resp.code }))
+ setFresh({ id: resp.id, code: resp.code, copied: false })
+ }
+ addToast('Invite link created', 'success')
+ fetchInvites()
+ } catch (err) {
+ addToast(`Failed to create invite: ${err.message}`, 'error')
+ } finally {
+ setCreating(false)
+ }
+ }
+
+ const copy = async (code, id) => {
+ const ok = await copyToClipboard(inviteUrl(code))
+ if (ok && fresh?.id === id) setFresh(f => ({ ...f, copied: true }))
+ addToast(ok ? 'Invite URL copied to clipboard' : 'Failed to copy URL', ok ? 'success' : 'error')
+ }
+
+ const revoke = async (invite) => {
+ setConfirm(null)
+ try {
+ await adminInvitesApi.delete(invite.id)
+ setInvites(prev => prev.filter(x => x.id !== invite.id))
+ if (fresh?.id === invite.id) setFresh(null)
+ addToast('Invite revoked', 'success')
+ } catch (err) {
+ addToast(`Failed to revoke invite: ${err.message}`, 'error')
+ }
+ }
+
+ return (
+
+
+
New invite link valid for
+
+ {INVITE_LIFETIMES.map(o => (
+ setHours(o.hours)}>{o.label}
+ ))}
+
+
+ {creating ? 'Creating...' : 'Generate Invite Link'}
+
+
+
+
+
+
+ {fresh && (
+
+
{t('invites.copyNow')}
+
+ {inviteUrl(fresh.code)}
+ copy(fresh.code, fresh.id)}>
+ {fresh.copied ? t('account.apiKeys.copied') : t('account.apiKeys.copy')}
+
+
+
+ {t('invites.revealNote')}
+ setFresh(null)}>{t('invites.done')}
+
+
+ )}
+
+ {loading && invites.length === 0 ? (
+
+ ) : invites.length === 0 ? (
+
+
+
No invite links
+
Generate an invite link to let someone register.
+
+ ) : (
+
+
+ Invite links with who made and used them
+
+
+ Invite Link
+ Created By
+ Used By
+ Expires
+ Actions
+
+
+
+ {invites.map(inv => {
+ const state = inviteState(inv)
+ const code = inv.code || codes[inv.id]
+ return (
+
+
+
+
+ {state === 'open' ? 'Available' : state === 'used' ? 'Used' : 'Expired'}
+
+
+ {state === 'open' && code ? (
+
+ {inviteUrl(code)}
+ copy(code, inv.id)} title="Copy invite URL"> Copy
+
+ ) : (
+ {inv.codePrefix || '???'}…
+ )}
+
+ {inv.createdBy?.name || inv.createdBy?.id || '-'}
+ {inv.usedBy?.name || inv.usedBy?.id || '—'}
+
+ {inv.expiresAt ? (
+ <>
+ {state === 'used' ? '—' : timeUntil(inv.expiresAt)}
+ {new Date(inv.expiresAt).toLocaleDateString()}
+ >
+ ) : '-'}
+
+
+ {state === 'open' && (
+ setConfirm(inv)} title="Revoke invite">
+ Revoke
+
+ )}
+
+
+ )
+ })}
+
+
+
+ )}
+ {registrationMode && (
+
{t('registration.label', { mode: registrationMode })} {t(`registration.${registrationMode}`, { defaultValue: '' })}
+ )}
+
+ {confirm && (
+
setConfirm(null)} labelId="iv-revoke-title" closeLabel="Close"
+ foot={<>
+ setConfirm(null)}>Cancel
+ revoke(confirm)}>Revoke
+ >}
+ >
+ Revoke the invite link that starts with {confirm.codePrefix || '…'} ?
+
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/components/users/UsersPanel.jsx b/core/http/react-ui/src/components/users/UsersPanel.jsx
new file mode 100644
index 000000000..2b312f5c3
--- /dev/null
+++ b/core/http/react-ui/src/components/users/UsersPanel.jsx
@@ -0,0 +1,382 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { useState, useEffect, useCallback, useMemo } from 'react'
+import { useTranslation } from 'react-i18next'
+import { useAuth } from '../../context/AuthContext'
+import { adminUsersApi } from '../../utils/api'
+import { filterUsers, filterCounts, accessSummary, initialOf, userState, USER_FILTERS } from '../../utils/access'
+import LoadingSpinner from '../LoadingSpinner'
+import ActionMenu from '../ActionMenu'
+import Dialog from '../Dialog'
+import HomeUndoToast from '../home/HomeUndoToast'
+import AccessSheet from './AccessSheet'
+import Icon from '../Icon'
+
+const FILTER_LABELS = { all: 'All', pending: 'Pending', admins: 'Admins', disabled: 'Disabled' }
+const STATE_LABELS = { active: 'Active', pending: 'Pending', disabled: 'Disabled' }
+
+const FALLBACK_FEATURES = {
+ api_features: [
+ { key: 'chat', label: 'Chat Completions', default: true },
+ { key: 'images', label: 'Image Generation', default: true },
+ { key: 'audio_speech', label: 'Audio Speech / TTS', default: true },
+ { key: 'audio_transcription', label: 'Audio Transcription', default: true },
+ { key: 'vad', label: 'Voice Activity Detection', default: true },
+ { key: 'detection', label: 'Detection', default: true },
+ { key: 'video', label: 'Video Generation', default: true },
+ { key: 'embeddings', label: 'Embeddings', default: true },
+ { key: 'sound', label: 'Sound Generation', default: true },
+ ],
+ agent_features: [
+ { key: 'agents', label: 'Agents', default: false },
+ { key: 'skills', label: 'Skills', default: false },
+ { key: 'collections', label: 'Collections', default: false },
+ { key: 'mcp_jobs', label: 'MCP CI Jobs', default: false },
+ ],
+ general_features: [{ key: 'fine_tuning', label: 'Fine-Tuning', default: false }],
+}
+
+const SORTERS = {
+ name: (a, b) => (a.name || a.email || '').localeCompare(b.name || b.email || ''),
+ provider: (a, b) => (a.provider || '').localeCompare(b.provider || ''),
+ role: (a, b) => (a.role || '').localeCompare(b.role || ''),
+ status: (a, b) => (a.status || '').localeCompare(b.status || ''),
+ created: (a, b) => new Date(a.createdAt || 0) - new Date(b.createdAt || 0),
+}
+
+function Avatar({ user }) {
+ return user.avatarUrl
+ ?
+ : {initialOf(user)}
+}
+
+// The people who can sign in: filter, search, sort, approve or disable, and
+// open one person's access. Role, password reset and delete sit in the row's
+// menu. Every call is the one the page made before.
+export default function UsersPanel({ addToast, onInvite, registrationMode }) {
+ const { t } = useTranslation('auth')
+ const { user: currentUser } = useAuth()
+ const [users, setUsers] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [query, setQuery] = useState('')
+ const [filter, setFilter] = useState('all')
+ const [sort, setSort] = useState({ key: null, dir: 'asc' })
+ const [editingUser, setEditingUser] = useState(null)
+ const [featureMeta, setFeatureMeta] = useState(null)
+ const [availableModels, setAvailableModels] = useState([])
+ const [deleting, setDeleting] = useState(null)
+ const [typed, setTyped] = useState('')
+ const [deleteBusy, setDeleteBusy] = useState(false)
+ const [resetUser, setResetUser] = useState(null)
+ const [newPassword, setNewPassword] = useState('')
+ const [resetBusy, setResetBusy] = useState(false)
+ const [weakWarning, setWeakWarning] = useState('')
+ const [acknowledgeWeak, setAcknowledgeWeak] = useState(false)
+ const [undo, setUndo] = useState(null)
+
+ const fetchUsers = useCallback(async () => {
+ setLoading(true)
+ try {
+ const data = await adminUsersApi.list()
+ setUsers(Array.isArray(data) ? data : data.users || [])
+ } catch (err) {
+ addToast(`Failed to load users: ${err.message}`, 'error')
+ } finally {
+ setLoading(false)
+ }
+ }, [addToast])
+
+ const fetchFeatures = useCallback(async () => {
+ try {
+ const data = await adminUsersApi.getFeatures()
+ setFeatureMeta(data)
+ setAvailableModels(data.models || [])
+ } catch {
+ // The features endpoint may be unavailable: use the built-in list.
+ setFeatureMeta(FALLBACK_FEATURES)
+ }
+ }, [])
+
+ useEffect(() => { fetchUsers(); fetchFeatures() }, [fetchUsers, fetchFeatures])
+
+ const isSelf = (u) => currentUser && (u.id === currentUser.id || u.email === currentUser.email)
+ const label = (u) => u.name || u.email
+
+ const setStatus = async (u, status, { undoable } = {}) => {
+ try {
+ await adminUsersApi.setStatus(u.id, status)
+ setUsers(prev => prev.map(x => x.id === u.id ? { ...x, status } : x))
+ if (undoable) setUndo({ id: Date.now(), user: u, from: u.status, text: `Disabled ${label(u)}. Undo sets them back to active.` })
+ else addToast(`${status === 'active' ? 'Approved' : 'Disabled'} ${label(u)}`, 'success')
+ } catch (err) {
+ addToast(`Failed to ${status === 'active' ? 'approve' : 'disable'} user: ${err.message}`, 'error')
+ }
+ }
+
+ const undoDisable = async () => {
+ const entry = undo
+ setUndo(null)
+ if (!entry) return
+ try {
+ await adminUsersApi.setStatus(entry.user.id, entry.from === 'disabled' ? 'active' : entry.from)
+ setUsers(prev => prev.map(x => x.id === entry.user.id ? { ...x, status: entry.from === 'disabled' ? 'active' : entry.from } : x))
+ addToast(`${label(entry.user)} is active again`, 'success')
+ } catch (err) {
+ addToast(`Failed to restore user: ${err.message}`, 'error')
+ }
+ }
+
+ const toggleRole = async (u) => {
+ const role = u.role === 'admin' ? 'user' : 'admin'
+ try {
+ await adminUsersApi.setRole(u.id, role)
+ setUsers(prev => prev.map(x => x.id === u.id ? { ...x, role } : x))
+ addToast(`${label(u)} is now ${role}`, 'success')
+ } catch (err) {
+ addToast(`Failed to update role: ${err.message}`, 'error')
+ }
+ }
+
+ const confirmDelete = async () => {
+ const u = deleting
+ setDeleteBusy(true)
+ try {
+ await adminUsersApi.delete(u.id)
+ setUsers(prev => prev.filter(x => x.id !== u.id))
+ addToast('User deleted', 'success')
+ setDeleting(null)
+ } catch (err) {
+ addToast(`Failed to delete user: ${err.message}`, 'error')
+ } finally {
+ setDeleteBusy(false)
+ }
+ }
+
+ const openReset = (u) => { setResetUser(u); setNewPassword(''); setWeakWarning(''); setAcknowledgeWeak(false) }
+
+ const confirmReset = async () => {
+ if (!resetUser || newPassword.length === 0) return
+ setResetBusy(true)
+ try {
+ await adminUsersApi.resetPassword(resetUser.id, newPassword, acknowledgeWeak)
+ addToast(`Password reset for ${label(resetUser)}`, 'success')
+ setResetUser(null)
+ setNewPassword('')
+ setWeakWarning('')
+ setAcknowledgeWeak(false)
+ } catch (err) {
+ if (err.body?.overridable) setWeakWarning(err.body.error || err.message)
+ else addToast(`Failed to reset password: ${err.message}`, 'error')
+ } finally {
+ setResetBusy(false)
+ }
+ }
+
+ const counts = useMemo(() => filterCounts(users), [users])
+ const filtered = useMemo(() => filterUsers(users, { query, filter }), [users, query, filter])
+ const rows = useMemo(() => (
+ sort.key ? [...filtered].sort((a, b) => sort.dir === 'asc' ? SORTERS[sort.key](a, b) : SORTERS[sort.key](b, a)) : filtered
+ ), [filtered, sort])
+ const toggleSort = (key) => setSort(s => s.key === key ? { key, dir: s.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' })
+
+ const sortTh = (key, text, className) => (
+
+ toggleSort(key)}>
+ {text}{sort.key === key && }
+
+
+ )
+
+ const menuItems = (u) => [
+ { key: 'access', icon: 'shield', label: 'Edit access', hidden: u.role === 'admin', onClick: () => setEditingUser(u) },
+ { key: 'role', icon: u.role === 'admin' ? 'arrow-down' : 'arrow-up', label: u.role === 'admin' ? 'Make user' : 'Make admin', onClick: () => toggleRole(u) },
+ { key: 'password', icon: 'key', label: 'Reset password', hidden: !!u.provider && u.provider !== 'local', onClick: () => openReset(u) },
+ { divider: true },
+ { key: 'delete', icon: 'trash', label: 'Delete…', danger: true, onClick: () => { setDeleting(u); setTyped('') } },
+ ]
+
+ const handlePermissionSave = (userId, perms, models, quotas) => {
+ setUsers(prev => prev.map(u => u.id === userId ? { ...u, permissions: perms, allowed_models: models, quotas } : u))
+ }
+
+ const sheetActions = (u) => (
+ <>
+ Account
+
+ {(!u.provider || u.provider === 'local') && (
+ { setEditingUser(null); openReset(u) }}>Reset password
+ )}
+ { setEditingUser(null); toggleRole(u) }}>Make admin
+ { setEditingUser(null); setDeleting(u); setTyped('') }}>Delete…
+
+ >
+ )
+
+ const deleteName = deleting ? label(deleting) : ''
+
+ return (
+
+
+
+
+ setQuery(e.target.value)} />
+
+
+ {USER_FILTERS.map(f => (
+ setFilter(f)}>
+ {FILTER_LABELS[f]}{f !== 'all' || counts.all ? {counts[f]} : null}
+
+ ))}
+
+
+
+ Invite someone
+
+
+
+ {loading && users.length === 0 ? (
+
+ ) : rows.length === 0 ? (
+
+
+
{query || filter !== 'all' ? 'No matching users' : 'No users'}
+
{query || filter !== 'all' ? 'Try a different search or filter.' : 'No registered users found.'}
+ {(query || filter !== 'all') &&
{ setQuery(''); setFilter('all') }}>Clear filters }
+
+ ) : (
+
+
+ Users with sign-in method, role, access and status
+
+
+ {sortTh('name', 'User')}
+ {sortTh('provider', 'Sign-in', 'dk-hide-phone')}
+ {sortTh('role', 'Role', 'dk-hide-phone')}
+ Access
+ {sortTh('status', 'Status', 'dk-hide-phone')}
+ Actions
+
+
+
+ {rows.map(u => {
+ const state = userState(u)
+ const self = isSelf(u)
+ return (
+
+
+
+
+
+ {u.name || '(no name)'}{self && you }{u.role}
+ {u.email}
+ {STATE_LABELS[state]}
+
+
+
+ {u.provider || 'local'}
+ {u.role}
+
+ {u.role === 'admin' ? (
+ {accessSummary(u, featureMeta)}
+ ) : (
+ setEditingUser(u)} aria-label={`Edit access for ${label(u)}`} title="Edit access">
+ {accessSummary(u, featureMeta)}
+
+ )}
+
+
+ {STATE_LABELS[state]}
+ {u.createdAt ? `Created ${new Date(u.createdAt).toLocaleDateString()}` : ''}
+
+
+ {!self && (
+
+ {state !== 'active' ? (
+
setStatus(u, 'active')} title="Approve user">
+ {state === 'disabled' ? 'Enable' : 'Approve'}
+
+ ) : (
+
setStatus(u, 'disabled', { undoable: true })} title="Disable user" aria-label={`Disable ${label(u)}`}>
+
+
+ )}
+
+
+ )}
+
+
+ )
+ })}
+
+
+
+ )}
+
+ {registrationMode && (
+
{t('registration.label', { mode: registrationMode })} {t(`registration.${registrationMode}`, { defaultValue: '' })}
+ )}
+
+ {editingUser && featureMeta && (
+
setEditingUser(null)} onSave={handlePermissionSave} actions={sheetActions(editingUser)}
+ />
+ )}
+
+ {resetUser && (
+ setResetUser(null)} pending={resetBusy}
+ description={`Set a new password for ${label(resetUser)}. All existing sessions will be invalidated.`}
+ foot={<>
+ setResetUser(null)} disabled={resetBusy}>Cancel
+
+ {resetBusy ? 'Resetting...' : 'Reset Password'}
+
+ >}
+ >
+
+ New password
+ { setNewPassword(e.target.value); setWeakWarning(''); setAcknowledgeWeak(false) }}
+ onKeyDown={e => { if (e.key === 'Enter' && newPassword.length > 0) confirmReset() }}
+ />
+
+ {weakWarning && (
+
+
{weakWarning}
+
+ setAcknowledgeWeak(e.target.checked)} />
+ Use this password anyway
+
+
+ )}
+
+ )}
+
+ {deleting && (
+ setDeleting(null)} pending={deleteBusy}
+ description="This also removes their sessions and API keys. It cannot be undone."
+ foot={<>
+ setDeleting(null)} disabled={deleteBusy}>Cancel
+
+ {deleteBusy ? 'Deleting...' : 'Delete'}
+
+ >}
+ >
+
+ Type {deleteName} to confirm
+ setTyped(e.target.value)} />
+
+
+ )}
+
+ {undo && (
+ setUndo(null)}
+ />
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/contexts/OperateSummaryContext.jsx b/core/http/react-ui/src/contexts/OperateSummaryContext.jsx
index c48ff3d82..e747c0d10 100644
--- a/core/http/react-ui/src/contexts/OperateSummaryContext.jsx
+++ b/core/http/react-ui/src/contexts/OperateSummaryContext.jsx
@@ -3,6 +3,7 @@ import { backendsApi, modelsApi, nodesApi, resourcesApi, systemApi, tracesApi }
import { usePolling } from '../hooks/usePolling'
import { useOperations } from '../hooks/useOperations'
import { useDistributedMode } from '../hooks/useDistributedMode'
+import { appendSample, capacityOf } from '../utils/operateStatus'
// The state of the installation, assembled once for everything in the Operate
// console: the rail's per-item signals and the overview's attention list.
@@ -11,7 +12,7 @@ import { useDistributedMode } from '../hooks/useDistributedMode'
// consumer running its own setInterval against the same endpoint was the
// defect it was created to fix. Four endpoints doing that would be worse.
//
-// The provider is mounted by ConsoleLayout for the Operate console only, so
+// The provider is mounted by HubLayout for the Operate hub only, so
// "poll only while the user is in Operate" needs no route check: away from
// Operate the provider is not mounted and nothing runs.
//
@@ -44,6 +45,14 @@ export function OperateSummaryProvider({ children, pollInterval = POLL_INTERVAL_
const [traces, setTraces] = useState(null)
const [installed, setInstalled] = useState({ backends: null, models: null })
const [running, setRunning] = useState(null)
+ // The models this process holds in memory, for the Status page's capacity
+ // row. Null until read, and on a cluster controller, whose models live on
+ // the workers.
+ const [loadedModels, setLoadedModels] = useState(null)
+ // Readings of the memory pool taken while Operate is open, newest last. The
+ // API keeps no history, so this is all the chart can honestly draw.
+ const [samples, setSamples] = useState([])
+ const [ready, setReady] = useState(false)
const { operations } = useOperations()
// The cluster API answers 503 when distributed mode is off, so asking for it
// on a single-node install is a guaranteed miss on every tick. The rail gates
@@ -75,11 +84,15 @@ export function OperateSummaryProvider({ children, pollInterval = POLL_INTERVAL_
models: mi?.data?.length ?? null,
})
setRunning(Array.isArray(sys?.loaded_models) ? sys.loaded_models.length : null)
+ setLoadedModels(Array.isArray(sys?.loaded_models) ? sys.loaded_models : null)
+ const nodeList = Array.isArray(n) ? n : (n?.nodes || [])
+ setSamples(current => appendSample(current, capacityOf({ resources: r, nodes: nodeList, distributed }), Date.now()))
+ setReady(true)
}, [distributed])
// Wait for the cluster probe: several sources depend on the mode, and a
// first tick taken before it answers would ask a cluster for local state.
- usePolling(fetchSummary, pollInterval, { enabled: !distributedLoading })
+ const { refetch } = usePolling(fetchSummary, pollInterval, { enabled: !distributedLoading })
const value = useMemo(() => {
const upgradeList = Object.values(upgrades || {})
@@ -120,6 +133,10 @@ export function OperateSummaryProvider({ children, pollInterval = POLL_INTERVAL_
operations,
traces,
installed,
+ loadedModels,
+ samples,
+ ready,
+ refresh: refetch,
// null until the cluster probe answers, so a consumer can wait rather
// than render the single-node view on a cluster for one frame.
distributed: distributedLoading ? null : distributed,
@@ -135,7 +152,7 @@ export function OperateSummaryProvider({ children, pollInterval = POLL_INTERVAL_
running: running || null,
},
}
- }, [upgrades, nodes, resources, resourcesLoaded, operations, traces, installed, running, distributed, distributedLoading])
+ }, [upgrades, nodes, resources, resourcesLoaded, operations, traces, installed, running, loadedModels, samples, ready, refetch, distributed, distributedLoading])
return (
diff --git a/core/http/react-ui/src/hooks/use3DHistory.js b/core/http/react-ui/src/hooks/use3DHistory.js
index 2a6220d73..6352bcda5 100644
--- a/core/http/react-ui/src/hooks/use3DHistory.js
+++ b/core/http/react-ui/src/hooks/use3DHistory.js
@@ -11,7 +11,8 @@ import { generateId } from '../utils/format'
// Entry: { id, createdAt, name, model,
// params: { seed, steps, textureSteps, guidance, quality, background },
// inputThumb, // small dataURL of the conditioning image
-// glb } // Blob
+// glb, // Blob
+// parentId, edge, label } // optional: the result it was made from
const DB_NAME = 'localai-3d-history'
const DB_VERSION = 1
@@ -100,8 +101,12 @@ export function use3DHistory() {
return () => { historyListeners.delete(refresh) }
}, [refresh])
- const addEntry = useCallback(async ({ model, params, inputThumb, glb, name, inputs, operation, outputType }) => {
- const entry = { id: generateId(), createdAt: Date.now(), model, params, inputThumb, glb, name, inputs, operation, outputType }
+ const addEntry = useCallback(async ({ model, params, inputThumb, glb, name, inputs, operation, outputType, parentId, edge, label }) => {
+ const entry = {
+ id: generateId(), createdAt: Date.now(), model, params, inputThumb, glb, name, inputs, operation, outputType,
+ ...(parentId ? { parentId, edge: edge || 'to-3d' } : {}),
+ ...(label ? { label: String(label).slice(0, 2000) } : {}),
+ }
try {
await idbPutAndEvict(entry)
await refresh()
diff --git a/core/http/react-ui/src/hooks/useAgentChat.js b/core/http/react-ui/src/hooks/useAgentChat.js
deleted file mode 100644
index 27511b277..000000000
--- a/core/http/react-ui/src/hooks/useAgentChat.js
+++ /dev/null
@@ -1,182 +0,0 @@
-import { useState, useCallback, useEffect } from 'react'
-import { generateId } from '../utils/format'
-import { useDebouncedEffect } from './useDebounce'
-
-const STORAGE_KEY_PREFIX = 'localai_agent_chats_'
-
-function storageKey(agentName) {
- return STORAGE_KEY_PREFIX + agentName
-}
-
-function loadConversations(agentName) {
- try {
- const stored = localStorage.getItem(storageKey(agentName))
- if (stored) {
- const data = JSON.parse(stored)
- if (data && Array.isArray(data.conversations)) {
- return data
- }
- }
- } catch (_e) {
- localStorage.removeItem(storageKey(agentName))
- }
- return null
-}
-
-function saveConversations(agentName, conversations, activeId) {
- try {
- const data = {
- conversations: conversations.map(c => ({
- id: c.id,
- name: c.name,
- messages: c.messages,
- createdAt: c.createdAt,
- updatedAt: c.updatedAt,
- })),
- activeId,
- lastSaved: Date.now(),
- }
- localStorage.setItem(storageKey(agentName), JSON.stringify(data))
- } catch (err) {
- if (err.name === 'QuotaExceededError' || err.code === 22) {
- console.warn('localStorage quota exceeded for agent chats')
- }
- }
-}
-
-function createConversation() {
- return {
- id: generateId(),
- name: 'New Chat',
- messages: [],
- createdAt: Date.now(),
- updatedAt: Date.now(),
- }
-}
-
-export function useAgentChat(agentName) {
- const [conversations, setConversations] = useState(() => {
- const stored = loadConversations(agentName)
- if (stored && stored.conversations.length > 0) return stored.conversations
- return [createConversation()]
- })
-
- const [activeId, setActiveId] = useState(() => {
- const stored = loadConversations(agentName)
- if (stored && stored.activeId) return stored.activeId
- return conversations[0]?.id
- })
-
- const activeConversation = conversations.find(c => c.id === activeId) || conversations[0]
-
- useDebouncedEffect(() => saveConversations(agentName, conversations, activeId), [agentName, conversations, activeId])
-
- // Save immediately on unmount
- useEffect(() => {
- return () => {
- saveConversations(agentName, conversations, activeId)
- }
- }, [agentName, conversations, activeId])
-
- const addConversation = useCallback(() => {
- const conv = createConversation()
- setConversations(prev => [conv, ...prev])
- setActiveId(conv.id)
- return conv
- }, [])
-
- const switchConversation = useCallback((id) => {
- setActiveId(id)
- }, [])
-
- const deleteConversation = useCallback((id) => {
- setConversations(prev => {
- if (prev.length <= 1) return prev
- const filtered = prev.filter(c => c.id !== id)
- const newActiveId = id === activeId && filtered.length > 0 ? filtered[0].id : activeId
- if (id === activeId) {
- setActiveId(newActiveId)
- }
- saveConversations(agentName, filtered, newActiveId)
- return filtered
- })
- }, [activeId, agentName])
-
- const deleteAllConversations = useCallback(() => {
- const conv = createConversation()
- setConversations([conv])
- setActiveId(conv.id)
- saveConversations(agentName, [conv], conv.id)
- }, [agentName])
-
- const renameConversation = useCallback((id, name) => {
- setConversations(prev => prev.map(c =>
- c.id === id ? { ...c, name, updatedAt: Date.now() } : c
- ))
- }, [])
-
- const addMessage = useCallback((msg) => {
- setConversations(prev => prev.map(c => {
- if (c.id !== activeId) return c
- const updated = {
- ...c,
- messages: [...c.messages, msg],
- updatedAt: Date.now(),
- }
- // Auto-name from first user message
- if (c.messages.length === 0 && msg.sender === 'user') {
- const text = msg.content || ''
- updated.name = text.slice(0, 40) + (text.length > 40 ? '...' : '')
- }
- return updated
- }))
- }, [activeId])
-
- // Add a message to a specific conversation by ID, regardless of which is active.
- // Used by SSE handlers to pin responses to the conversation that initiated the request.
- const addMessageToConversation = useCallback((conversationId, msg) => {
- setConversations(prev => prev.map(c => {
- if (c.id !== conversationId) return c
- const updated = {
- ...c,
- messages: [...c.messages, msg],
- updatedAt: Date.now(),
- }
- if (c.messages.length === 0 && msg.sender === 'user') {
- const text = msg.content || ''
- updated.name = text.slice(0, 40) + (text.length > 40 ? '...' : '')
- }
- return updated
- }))
- }, [])
-
- const clearMessages = useCallback(() => {
- setConversations(prev => {
- const updated = prev.map(c =>
- c.id === activeId ? { ...c, messages: [], updatedAt: Date.now() } : c
- )
- // Save immediately so a page refresh doesn't restore the old messages
- saveConversations(agentName, updated, activeId)
- return updated
- })
- }, [activeId, agentName])
-
- const getMessages = useCallback(() => {
- return activeConversation?.messages || []
- }, [activeConversation])
-
- return {
- conversations,
- activeConversation,
- activeId,
- addConversation,
- switchConversation,
- deleteConversation,
- deleteAllConversations,
- renameConversation,
- addMessage,
- addMessageToConversation,
- clearMessages,
- getMessages,
- }
-}
diff --git a/core/http/react-ui/src/hooks/useBuildDuplicates.js b/core/http/react-ui/src/hooks/useBuildDuplicates.js
new file mode 100644
index 000000000..d15099edf
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useBuildDuplicates.js
@@ -0,0 +1,51 @@
+import { useEffect, useState } from 'react'
+import { modelsApi } from '../utils/api'
+import { findDuplicates } from '../utils/cleanupPlan'
+
+const CONCURRENCY = 3
+
+// Which installed models are an extra build of another installed model.
+//
+// The gallery says, for an entry that declares variants, which builds exist and
+// which one it would pick on this host. Only entries that are installed
+// themselves are asked, so a gallery of a thousand models costs a handful of
+// requests. A build installed without its parent entry is not found this way;
+// that is a known gap, not a claim.
+//
+// Returns { duplicates, loading }.
+export function useBuildDuplicates(installedIds, hasVariants, enabled) {
+ const [described, setDescribed] = useState({})
+ const [loading, setLoading] = useState(false)
+ const candidates = enabled ? installedIds.filter(id => hasVariants(id)) : []
+ const key = candidates.join('\n')
+
+ useEffect(() => {
+ if (!enabled) return undefined
+ const todo = candidates.filter(id => !(id in described))
+ if (todo.length === 0) return undefined
+ let cancelled = false
+ let cursor = 0
+ setLoading(true)
+ const worker = async () => {
+ while (!cancelled && cursor < todo.length) {
+ const id = todo[cursor++]
+ let value = null
+ try {
+ value = await modelsApi.variants(id)
+ } catch {
+ // Unknown: this entry simply contributes no duplicates.
+ }
+ if (!cancelled) setDescribed(prev => ({ ...prev, [id]: value }))
+ }
+ }
+ Promise.all(Array.from({ length: Math.min(CONCURRENCY, todo.length) }, worker))
+ .then(() => { if (!cancelled) setLoading(false) })
+ return () => { cancelled = true }
+ // candidates is summarised by key.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [key, enabled])
+
+ const usable = {}
+ for (const id of candidates) if (described[id]) usable[id] = described[id]
+ return { duplicates: findDuplicates(installedIds, usable), loading }
+}
diff --git a/core/http/react-ui/src/hooks/useCleanupFacts.js b/core/http/react-ui/src/hooks/useCleanupFacts.js
new file mode 100644
index 000000000..83a0dc5ce
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useCleanupFacts.js
@@ -0,0 +1,104 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { agentJobsApi, agentsApi, failoverApi, modelsApi } from '../utils/api'
+import { agentModels, collectReferences } from '../utils/cleanupPlan'
+
+const AGENT_CONFIG_CONCURRENCY = 4
+
+// A feature that is not built into this server answers 404 or 501. That is an
+// answer ("no agents exist"), not a failed lookup.
+const absent = err => err?.status === 404 || err?.status === 501
+
+async function pool(items, limit, fn) {
+ const results = new Array(items.length)
+ let cursor = 0
+ const run = async () => {
+ while (cursor < items.length) {
+ const i = cursor++
+ results[i] = await fn(items[i])
+ }
+ }
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run))
+ return results
+}
+
+// Who names which model, read from the same endpoints the Agents, Jobs and
+// Failover pages read.
+//
+// Returns { references, verified, loading, refresh }. `verified` is false when
+// any lookup failed for a reason other than "this feature is not installed";
+// the cleanup plan then refuses to call anything safe, because something it
+// could not see might use the model.
+export function useCleanupFacts(enabled) {
+ const [state, setState] = useState({ references: new Map(), verified: true, loading: false, loaded: false })
+ const run = useRef(0)
+
+ const refresh = useCallback(async () => {
+ const id = ++run.current
+ setState(prev => ({ ...prev, loading: true }))
+ let verified = true
+ const settle = async (promise, fallback) => {
+ try {
+ return await promise
+ } catch (err) {
+ if (!absent(err)) verified = false
+ return fallback
+ }
+ }
+
+ const [agentList, taskList, chainList, aliasList] = await Promise.all([
+ settle(agentsApi.list(true), null),
+ settle(agentJobsApi.listTasks(true), []),
+ settle(failoverApi.list(), { chains: [] }),
+ settle(modelsApi.listAliases(), []),
+ ])
+
+ // Agent names, with the user each belongs to so an admin's other users are
+ // covered. The list gives names only; the model lives in each config.
+ const named = []
+ for (const name of Array.isArray(agentList?.agents) ? agentList.agents : []) {
+ named.push({ name: typeof name === 'string' ? name : name?.name, userId: undefined })
+ }
+ for (const [userId, group] of Object.entries(agentList?.user_groups || {})) {
+ for (const name of group?.agents || []) {
+ named.push({ name: typeof name === 'string' ? name : name?.name, userId })
+ }
+ }
+ const agents = (await pool(named.filter(a => a.name), AGENT_CONFIG_CONCURRENCY, async agent => {
+ try {
+ const config = await agentsApi.getConfig(agent.name, agent.userId)
+ return { name: agent.name, models: agentModels(config) }
+ } catch (err) {
+ if (!absent(err)) verified = false
+ return { name: agent.name, models: [] }
+ }
+ }))
+
+ // The admin listing groups other users' tasks under user_groups the same
+ // way; the flat list is the caller's own.
+ const ownTasks = Array.isArray(taskList) ? taskList : (taskList?.tasks || [])
+ const tasks = [
+ ...ownTasks,
+ ...Object.values(taskList?.user_groups || {}).flatMap(group => group?.tasks || []),
+ ]
+
+ const aliases = {}
+ for (const alias of Array.isArray(aliasList) ? aliasList : []) aliases[alias.name] = alias.target
+
+ if (id !== run.current) return null
+ const references = collectReferences({
+ agents,
+ tasks: tasks.filter(task => task && task.model),
+ chains: chainList?.chains || [],
+ aliases,
+ })
+ const next = { references, verified, loading: false, loaded: true }
+ setState(next)
+ return next
+ }, [])
+
+ useEffect(() => {
+ if (enabled) refresh()
+ }, [enabled, refresh])
+
+ return { ...state, refresh }
+}
diff --git a/core/http/react-ui/src/hooks/useDelayedAction.js b/core/http/react-ui/src/hooks/useDelayedAction.js
new file mode 100644
index 000000000..6f80fb21a
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useDelayedAction.js
@@ -0,0 +1,51 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+// How long a delayed action waits before anything is sent.
+export const UNDO_WINDOW_MS = 10000
+
+// An action that waits for its undo window, then runs. Nothing goes to the
+// server until the window ends, so Undo is not a second call: the first was
+// never made.
+//
+// run the real call, given the key it was scheduled under
+//
+// Several actions can wait at once, each under its own key. Leaving the page
+// runs the ones still waiting, because the person asked for them.
+export function useDelayedAction(run, ms = UNDO_WINDOW_MS) {
+ const [waiting, setWaiting] = useState({})
+ const timers = useRef(new Map())
+ const runRef = useRef(run)
+ runRef.current = run
+
+ const settle = useCallback((key, doRun) => {
+ const entry = timers.current.get(key)
+ if (!entry) return
+ clearTimeout(entry.timer)
+ timers.current.delete(key)
+ setWaiting(prev => { const next = { ...prev }; delete next[key]; return next })
+ if (doRun) runRef.current(key, entry.payload)
+ }, [])
+
+ const schedule = useCallback((key, payload) => {
+ if (timers.current.has(key)) return
+ const timer = setTimeout(() => settle(key, true), ms)
+ timers.current.set(key, { timer, payload })
+ setWaiting(prev => ({ ...prev, [key]: { payload, until: Date.now() + ms } }))
+ }, [ms, settle])
+
+ const cancel = useCallback((key) => settle(key, false), [settle])
+ const flush = useCallback((key) => settle(key, true), [settle])
+
+ useEffect(() => {
+ const live = timers.current
+ return () => {
+ for (const [key, entry] of live) {
+ clearTimeout(entry.timer)
+ runRef.current(key, entry.payload)
+ }
+ live.clear()
+ }
+ }, [])
+
+ return { waiting, schedule, cancel, flush }
+}
diff --git a/core/http/react-ui/src/hooks/useFacetCounts.js b/core/http/react-ui/src/hooks/useFacetCounts.js
new file mode 100644
index 000000000..109eecfbd
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useFacetCounts.js
@@ -0,0 +1,73 @@
+import { useEffect, useRef, useState } from 'react'
+import { modelsApi } from '../utils/api'
+
+const CONCURRENCY = 4
+const SETTLE_MS = 400
+
+// How many gallery entries each capability facet matches, as the server counts
+// them. The listing paginates and filters on the server, so the only honest
+// count is the server's own: one request per facet that asks for a single item
+// and reads `availableModels` from the reply. Nothing is counted in the browser.
+//
+// The counts follow the search term, the backend and the one-row-per-model
+// choice, which change what a facet means, but not the facets that are switched
+// on, which would make every chip but the pressed ones read zero.
+//
+// Returns { counts, stale }. counts is { [facetKey]: number } for the facets
+// answered so far; a key that has not been answered is absent, so the chip shows
+// no count instead of a wrong one. When the scope changes the previous counts
+// stay on screen, marked stale, until the new ones arrive: chips that vanish and
+// return on every keystroke are worse than numbers that are briefly one search
+// behind.
+export function useFacetCounts({ keys, term, backend, collapse, ready }) {
+ const [counts, setCounts] = useState({})
+ const [answeredScope, setAnsweredScope] = useState(null)
+ const cache = useRef(new Map())
+ const scope = `${term}\u0000${backend}\u0000${collapse ? 1 : 0}`
+ const keyList = keys.join(',')
+
+ useEffect(() => {
+ if (!ready) return undefined
+ const known = cache.current.get(scope) || {}
+ if (Object.keys(known).length > 0) {
+ setCounts(known)
+ setAnsweredScope(scope)
+ }
+ const todo = keys.filter(key => !(key in known))
+ if (todo.length === 0) return undefined
+
+ let cancelled = false
+ const timer = setTimeout(() => {
+ let cursor = 0
+ const worker = async () => {
+ while (!cancelled && cursor < todo.length) {
+ const key = todo[cursor++]
+ try {
+ const params = { page: 1, items: 1 }
+ if (key) params.tag = key
+ if (term) params.term = term
+ if (backend) params.backend = backend
+ if (collapse) params.collapse_variants = 'true'
+ const data = await modelsApi.list(params)
+ if (typeof data?.availableModels === 'number') {
+ const next = { ...(cache.current.get(scope) || {}), [key]: data.availableModels }
+ cache.current.set(scope, next)
+ if (!cancelled) {
+ setCounts(next)
+ setAnsweredScope(scope)
+ }
+ }
+ } catch {
+ // No count is better than a wrong one.
+ }
+ }
+ }
+ for (let i = 0; i < Math.min(CONCURRENCY, todo.length); i++) worker()
+ }, SETTLE_MS)
+ return () => { cancelled = true; clearTimeout(timer) }
+ // keys is summarised by keyList.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [scope, keyList, ready])
+
+ return { counts, stale: answeredScope !== scope }
+}
diff --git a/core/http/react-ui/src/hooks/useGalleryEnrichment.js b/core/http/react-ui/src/hooks/useGalleryEnrichment.js
index bd1171eb9..cc5343902 100644
--- a/core/http/react-ui/src/hooks/useGalleryEnrichment.js
+++ b/core/http/react-ui/src/hooks/useGalleryEnrichment.js
@@ -10,12 +10,15 @@ import { modelsApi, backendsApi } from '../utils/api'
//
// Items not present in the gallery (custom imports, external OCI installs)
// resolve to `null` — callers fall back to a neutral icon + "no description".
-export function useGalleryEnrichment() {
+export function useGalleryEnrichment(enabled = true) {
const [modelMap, setModelMap] = useState(() => new Map())
const [backendMap, setBackendMap] = useState(() => new Map())
const [loaded, setLoaded] = useState(false)
useEffect(() => {
+ // The whole gallery is a large request. A caller that only needs it some of
+ // the time passes enabled=false until then.
+ if (!enabled) return undefined
let cancelled = false
Promise.allSettled([
modelsApi.list({ items: 9999, page: 1 }),
@@ -44,7 +47,7 @@ export function useGalleryEnrichment() {
setLoaded(true)
})
return () => { cancelled = true }
- }, [])
+ }, [enabled])
const enrichModel = useCallback((name) => (name ? modelMap.get(name) || null : null), [modelMap])
const enrichBackend = useCallback((name) => (name ? backendMap.get(name) || null : null), [backendMap])
diff --git a/core/http/react-ui/src/hooks/useLibraryFacts.js b/core/http/react-ui/src/hooks/useLibraryFacts.js
new file mode 100644
index 000000000..2ea7d19cc
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useLibraryFacts.js
@@ -0,0 +1,70 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { agentsApi } from '../utils/api'
+
+const CONFIG_CONCURRENCY = 4
+
+// A server without the agent pool answers 404 or 501. That means "no agents",
+// which is an answer; anything else means the lookup failed.
+const absent = err => err?.status === 404 || err?.status === 501
+
+async function pool(items, limit, fn) {
+ const out = new Array(items.length)
+ let cursor = 0
+ const run = async () => {
+ while (cursor < items.length) {
+ const i = cursor++
+ out[i] = await fn(items[i])
+ }
+ }
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, run))
+ return out
+}
+
+// Which agents exist and what each one has switched on, from the same two
+// endpoints the Agents page reads: the list, then each agent's saved config.
+// The Library pages use it to say who uses a skill or a collection.
+//
+// `status` is 'loading', 'ready' or 'failed'. A failed lookup says nothing
+// about use, so the pages hide their "Used by" lines instead of claiming
+// "Not used yet".
+export function useLibraryFacts() {
+ const [state, setState] = useState({ status: 'loading', agents: [] })
+ const run = useRef(0)
+
+ const refresh = useCallback(async () => {
+ const id = ++run.current
+ let failed = false
+ let list = null
+ try {
+ list = await agentsApi.list(false)
+ } catch (err) {
+ if (!absent(err)) failed = true
+ }
+ const names = (Array.isArray(list?.agents) ? list.agents : [])
+ .map(a => (typeof a === 'string' ? a : a?.name))
+ .filter(Boolean)
+ const agents = await pool(names, CONFIG_CONCURRENCY, async name => {
+ try {
+ return { name, config: await agentsApi.getConfig(name) }
+ } catch (err) {
+ if (!absent(err)) failed = true
+ return { name, config: null }
+ }
+ })
+ if (id !== run.current) return
+ setState({ status: failed ? 'failed' : 'ready', agents })
+ }, [])
+
+ useEffect(() => { refresh() }, [refresh])
+
+ // Save a changed config for one agent, then show it at once.
+ const saveConfig = useCallback(async (name, config) => {
+ await agentsApi.update(name, config)
+ setState(prev => ({
+ ...prev,
+ agents: prev.agents.map(a => (a.name === name ? { ...a, config } : a)),
+ }))
+ }, [])
+
+ return { ...state, refresh, saveConfig }
+}
diff --git a/core/http/react-ui/src/hooks/useLoadedModels.js b/core/http/react-ui/src/hooks/useLoadedModels.js
new file mode 100644
index 000000000..3d85fc323
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useLoadedModels.js
@@ -0,0 +1,21 @@
+import { useCallback, useMemo, useState } from 'react'
+import { systemApi } from '../utils/api'
+import { usePolling } from './usePolling'
+
+// The models the server holds in memory right now, polled the way Home polls
+// them. A model in this set is "warm": a message to it starts at once. Any other
+// installed model is "not loaded" and has to load first.
+export function useLoadedModels(intervalMs = 5000) {
+ const [loaded, setLoaded] = useState([])
+ const read = useCallback(async () => {
+ try {
+ const info = await systemApi.info()
+ setLoaded(Array.isArray(info?.loaded_models) ? info.loaded_models : [])
+ } catch {
+ // Keep the last reading: a missed poll must not turn every model cold.
+ }
+ }, [])
+ const { refetch } = usePolling(read, intervalMs)
+ const ids = useMemo(() => new Set(loaded.map(m => m.id)), [loaded])
+ return { loaded, ids, refetch }
+}
diff --git a/core/http/react-ui/src/hooks/useMediaCapture.js b/core/http/react-ui/src/hooks/useMediaCapture.js
index ff888e076..dc3451065 100644
--- a/core/http/react-ui/src/hooks/useMediaCapture.js
+++ b/core/http/react-ui/src/hooks/useMediaCapture.js
@@ -61,6 +61,9 @@ export function useMediaCapture(mode) {
const [active, setActive] = useState(false)
const [recording, setRecording] = useState(false)
const [error, setError] = useState(null)
+ // 'denied' when the person or the browser refused the device, 'missing' when
+ // there is none, 'other' for anything else.
+ const [errorKind, setErrorKind] = useState(null)
const [elapsed, setElapsed] = useState(0)
const streamRef = useRef(null)
@@ -95,6 +98,7 @@ export function useMediaCapture(mode) {
return
}
setError(null)
+ setErrorKind(null)
try {
const constraints = mode === 'audio'
? { audio: true }
@@ -107,6 +111,7 @@ export function useMediaCapture(mode) {
setActive(true)
} catch (e) {
setError(e?.message || 'Could not access device')
+ setErrorKind(e?.name === 'NotAllowedError' || e?.name === 'SecurityError' ? 'denied' : e?.name === 'NotFoundError' ? 'missing' : 'other')
stopStream()
}
}, [mode, supported, stopStream])
@@ -207,7 +212,7 @@ export function useMediaCapture(mode) {
useEffect(() => () => stopStream(), [stopStream])
return {
- supported, active, recording, error, elapsed,
+ supported, active, recording, error, errorKind, elapsed,
videoRef, start, stop: stopStream, snap, startRecording, stopRecording,
}
}
diff --git a/core/http/react-ui/src/hooks/useMediaHistory.js b/core/http/react-ui/src/hooks/useMediaHistory.js
index 6889cd640..f7c9d3a00 100644
--- a/core/http/react-ui/src/hooks/useMediaHistory.js
+++ b/core/http/react-ui/src/hooks/useMediaHistory.js
@@ -1,16 +1,14 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'
import { generateId } from '../utils/format'
+import { MEDIA_STORAGE_KEYS as STORAGE_KEYS, FAVOURITES_KEY, clearAllMediaHistory } from '../utils/mediaHistoryStore'
-const STORAGE_KEYS = {
- image: 'localai_image_history',
- video: 'localai_video_history',
- tts: 'localai_tts_history',
- sound: 'localai_sound_history',
- 'audio-transform': 'localai_audio_transform_history',
-}
+export { FAVOURITES_KEY, clearAllMediaHistory }
const SAVE_DEBOUNCE_MS = 500
const MAX_ENTRIES = 100
+// A prompt is a sentence or a paragraph. This keeps one runaway paste from
+// filling the quota that every other entry shares.
+const MAX_PROMPT_CHARS = 2000
// Read every store at once, without mounting a hook per media type.
//
@@ -80,14 +78,17 @@ export function useMediaHistory(mediaType) {
}
}, [storageKey])
- const addEntry = useCallback(({ prompt, model, params, results }) => {
+ // parentId and edge link a result to the one it was made from (a hand-off
+ // from another Studio workspace); they are written only when there is one.
+ const addEntry = useCallback(({ prompt, model, params, results, parentId, edge }) => {
const entry = {
id: generateId(),
- prompt,
+ prompt: typeof prompt === 'string' ? prompt.slice(0, MAX_PROMPT_CHARS) : prompt,
model,
params: params || {},
results: results || [],
createdAt: Date.now(),
+ ...(parentId ? { parentId, edge: edge || 'take' } : {}),
}
dirty.current = true
setEntries(prev => {
@@ -95,6 +96,7 @@ export function useMediaHistory(mediaType) {
if (updated.length > MAX_ENTRIES) updated.length = MAX_ENTRIES
return updated
})
+ return entry.id
}, [])
const deleteEntry = useCallback((id) => {
diff --git a/core/http/react-ui/src/hooks/useModelActions.js b/core/http/react-ui/src/hooks/useModelActions.js
new file mode 100644
index 000000000..ec6821d79
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useModelActions.js
@@ -0,0 +1,124 @@
+import { useCallback, useRef, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import { backendControlApi, modelsApi } from '../utils/api'
+
+// The lifecycle actions of an installed model: load it, stop it, turn it off,
+// pin it, remove it. The Installed table and the model page share this so a
+// button means the same thing wherever it sits.
+//
+// addToast shows the outcome.
+// afterAction async, runs after an action succeeded, so the caller can read
+// its lists again.
+//
+// Stop and remove do not run at once: they set `confirmDialog`, which the
+// caller renders with ConfirmDialog, and run when it is confirmed.
+export function useModelActions({ addToast, afterAction }) {
+ const { t } = useTranslation('models')
+ const [pendingActions, setPendingActions] = useState(() => new Set())
+ const [actionErrors, setActionErrors] = useState({})
+ const [confirmDialog, setConfirmDialog] = useState(null)
+ const after = useRef(afterAction)
+ after.current = afterAction
+
+ const setPending = useCallback((name, pending) => {
+ setPendingActions(previous => {
+ const next = new Set(previous)
+ if (pending) next.add(name)
+ else next.delete(name)
+ return next
+ })
+ }, [])
+
+ const runAction = useCallback(async (modelName, action, request, successMessage) => {
+ setPending(modelName, true)
+ setActionErrors(previous => ({ ...previous, [modelName]: null }))
+ try {
+ await request()
+ if (successMessage) addToast(successMessage, 'success')
+ await after.current?.()
+ return true
+ } catch (err) {
+ setActionErrors(previous => ({
+ ...previous,
+ [modelName]: t('lifecycle.errors.action', { action, model: modelName, message: err.message }),
+ }))
+ return false
+ } finally {
+ setPending(modelName, false)
+ }
+ }, [addToast, setPending, t])
+
+ const load = useCallback(modelName => runAction(
+ modelName,
+ t('lifecycle.actionNames.load'),
+ () => backendControlApi.load({ model: modelName }),
+ t('lifecycle.toasts.loaded', { model: modelName }),
+ ), [runAction, t])
+
+ const stop = useCallback(modelName => {
+ setConfirmDialog({
+ title: t('lifecycle.confirm.stopTitle'),
+ message: t('lifecycle.confirm.stopMessage', { model: modelName }),
+ confirmLabel: t('lifecycle.actions.stop'),
+ danger: true,
+ onConfirm: async () => {
+ setConfirmDialog(null)
+ await runAction(
+ modelName,
+ t('lifecycle.actionNames.stop'),
+ () => backendControlApi.shutdown({ model: modelName }),
+ t('lifecycle.toasts.stopped', { model: modelName }),
+ )
+ },
+ })
+ }, [runAction, t])
+
+ const toggleState = useCallback((modelName, disabled) => {
+ const operation = disabled ? 'enable' : 'disable'
+ return runAction(
+ modelName,
+ t(`lifecycle.actionNames.${operation}`),
+ () => modelsApi.toggleState(modelName, operation),
+ t(`lifecycle.toasts.${operation}d`, { model: modelName }),
+ )
+ }, [runAction, t])
+
+ const togglePinned = useCallback((modelName, pinned) => {
+ const operation = pinned ? 'unpin' : 'pin'
+ return runAction(
+ modelName,
+ t(`lifecycle.actionNames.${operation}`),
+ () => modelsApi.togglePinned(modelName, operation),
+ t(`lifecycle.toasts.${operation}ned`, { model: modelName }),
+ )
+ }, [runAction, t])
+
+ // onDeleted runs only when the model really went.
+ const remove = useCallback((modelName, onDeleted) => {
+ setConfirmDialog({
+ title: t('lifecycle.confirm.deleteTitle'),
+ message: t('lifecycle.confirm.deleteMessage', { model: modelName }),
+ confirmLabel: t('lifecycle.actions.delete'),
+ danger: true,
+ onConfirm: async () => {
+ setConfirmDialog(null)
+ const deleted = await runAction(
+ modelName,
+ t('lifecycle.actionNames.delete'),
+ () => modelsApi.deleteByName(modelName),
+ t('lifecycle.toasts.deleted', { model: modelName }),
+ )
+ if (deleted) onDeleted?.()
+ },
+ })
+ }, [runAction, t])
+
+ const reload = useCallback(() => runAction(
+ 'models',
+ t('lifecycle.actionNames.update'),
+ modelsApi.reload,
+ t('lifecycle.toasts.updated'),
+ ), [runAction, t])
+
+ return { pendingActions, actionErrors, confirmDialog, setConfirmDialog, load, stop, toggleState, togglePinned, remove, reload }
+}
diff --git a/core/http/react-ui/src/hooks/useModelFit.js b/core/http/react-ui/src/hooks/useModelFit.js
new file mode 100644
index 000000000..9a09b3dde
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useModelFit.js
@@ -0,0 +1,61 @@
+import { useEffect, useMemo, useRef, useState } from 'react'
+import { resourcesApi } from '../utils/api'
+import { modelBudget } from '../utils/modelBudget'
+import { fitFor } from '../utils/modelLedger'
+import { readEstimate, DEFAULT_CONTEXT } from './usePlacementEstimate'
+
+// How many models get an estimate when the switcher opens, and how many run at
+// once. A long list is estimated from the top; the rest show no fit text, which
+// is honest: nothing has been measured for them.
+const MAX_MODELS = 12
+const CONCURRENCY = 3
+
+// What the model switcher can say about fit, read when it opens (`openCount`
+// goes up by one each time, and 0 means it never has): one
+// reading of this host's memory and one estimate per listed model at the chat's
+// context size. A model with no estimate (a file the server cannot read, an
+// unsupported format) gets no fit text.
+export function useModelFit({ openCount, names, contextSize }) {
+ const [resources, setResources] = useState(null)
+ const [sizes, setSizes] = useState({})
+ const ctx = contextSize > 0 ? contextSize : DEFAULT_CONTEXT
+ const key = names.slice(0, MAX_MODELS).join('\n')
+ const ctxRef = useRef(ctx)
+ ctxRef.current = ctx
+
+ useEffect(() => {
+ if (!openCount) return undefined
+ let cancelled = false
+ resourcesApi.get().then(r => { if (!cancelled) setResources(r) }).catch(() => {})
+ return () => { cancelled = true }
+ }, [openCount])
+
+ useEffect(() => {
+ if (!openCount || !key) return undefined
+ let cancelled = false
+ const queue = key.split('\n')
+ let cursor = 0
+ const worker = async () => {
+ while (!cancelled && cursor < queue.length) {
+ const name = queue[cursor++]
+ const reading = await readEstimate(name, ctxRef.current, null)
+ if (cancelled) return
+ setSizes(prev => ({ ...prev, [`${name}|${ctxRef.current}`]: reading || false }))
+ }
+ }
+ for (let i = 0; i < CONCURRENCY; i++) worker()
+ return () => { cancelled = true }
+ }, [openCount, key, ctx])
+
+ const budget = useMemo(() => modelBudget(resources), [resources])
+ const ramAvailable = resources?.ram?.available ?? resources?.ram?.free ?? null
+
+ // { bytes, sizeBytes, fit } for a model, or null when there is no reading.
+ const reading = (name) => {
+ const r = sizes[`${name}|${ctx}`]
+ if (!r) return null
+ return { bytes: r.bytes, sizeBytes: r.sizeBytes, fit: fitFor(r.bytes, budget, ramAvailable) }
+ }
+
+ return { resources, budget, reading }
+}
diff --git a/core/http/react-ui/src/hooks/useModelPage.js b/core/http/react-ui/src/hooks/useModelPage.js
new file mode 100644
index 000000000..9d779447a
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useModelPage.js
@@ -0,0 +1,140 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { modelsApi, systemApi } from '../utils/api'
+import { rememberSize } from './useModelSizes'
+
+// The contexts the Fit tab draws, the same set the Explore table asks for.
+export const PAGE_CONTEXTS = [8192, 16384, 32768, 65536, 131072, 262144]
+
+// Gallery rows to read when looking one entry up by exact name. The term is the
+// whole name, so the entry is always in the match set; the size only has to
+// outlast the fuzzy matches that share its prefix.
+const LOOKUP_ITEMS = 100
+
+// One gallery entry by its name.
+//
+// status 'idle' (no name), 'loading', 'ready', 'missing' (the gallery answered
+// and holds no such entry) or 'error' (it could not be read).
+// entry the listing row, with its files, tags, links and licence.
+// matches the other entries the lookup returned, for a "did you mean".
+//
+// Read once per name; `reload` asks again after an error.
+export function useGalleryEntry(name) {
+ const [state, setState] = useState({ status: name ? 'loading' : 'idle', entry: null, matches: [] })
+ const [nonce, setNonce] = useState(0)
+
+ useEffect(() => {
+ if (!name) {
+ setState({ status: 'idle', entry: null, matches: [] })
+ return undefined
+ }
+ let cancelled = false
+ setState(prev => (prev.entry && (prev.entry.name || prev.entry.id) === name ? prev : { status: 'loading', entry: null, matches: [] }))
+ modelsApi.list({ term: name, items: LOOKUP_ITEMS })
+ .then(data => {
+ if (cancelled) return
+ const rows = data?.models || []
+ const entry = rows.find(m => (m.name || m.id) === name || m.id === name) || null
+ setState(entry
+ ? { status: 'ready', entry, matches: [] }
+ : { status: 'missing', entry: null, matches: rows.slice(0, 5) })
+ })
+ .catch(() => {
+ if (!cancelled) setState({ status: 'error', entry: null, matches: [] })
+ })
+ return () => { cancelled = true }
+ }, [name, nonce])
+
+ const reload = useCallback(() => setNonce(n => n + 1), [])
+ return { ...state, reload }
+}
+
+// Size and memory by context for one model or build.
+//
+// The gallery endpoint knows every listed entry. An installed model the gallery
+// does not list (an import, a hand-written config) has no entry, so it is
+// estimated from its own files, once per context.
+//
+// status 'idle', 'loading', 'ready' or 'unavailable'.
+// data { sizeBytes, estimates: { [context]: { vramBytes } }, modelMaxContext }
+export function useModelEstimate(name, { installed = false, enabled = true } = {}) {
+ const [held, setHeld] = useState({ status: 'idle', data: null, name: null })
+ // The reading held is for one name. For any other it is not an answer yet.
+ const state = held.name === name ? held : { status: name && enabled ? 'loading' : 'idle', data: null }
+ const setState = useCallback(next => setHeld({ ...next, name }), [name])
+ const installedRef = useRef(installed)
+ installedRef.current = installed
+
+ useEffect(() => {
+ if (!name || !enabled) {
+ setState({ status: 'idle', data: null })
+ return undefined
+ }
+ let cancelled = false
+ setState({ status: 'loading', data: null })
+ const finish = data => {
+ if (cancelled) return
+ if (data && (data.sizeBytes || Object.keys(data.estimates || {}).length > 0)) {
+ if (data.sizeBytes) rememberSize(name, data.sizeBytes)
+ setState({ status: 'ready', data })
+ } else {
+ setState({ status: 'unavailable', data: null })
+ }
+ }
+ modelsApi.estimate(name, PAGE_CONTEXTS)
+ .then(finish)
+ .catch(async () => {
+ if (!installedRef.current) { finish(null); return }
+ try {
+ const readings = await Promise.all(PAGE_CONTEXTS.map(ctx => modelsApi.estimateVram({ model: name, context_size: ctx })))
+ const estimates = {}
+ readings.forEach((r, i) => {
+ if (Number(r?.vram_bytes) > 0) estimates[String(PAGE_CONTEXTS[i])] = { vramBytes: Number(r.vram_bytes) }
+ })
+ finish({
+ sizeBytes: Number(readings.find(r => r?.size_bytes)?.size_bytes) || 0,
+ estimates,
+ modelMaxContext: Number(readings.find(r => r?.model_max_context)?.model_max_context) || 0,
+ })
+ } catch {
+ finish(null)
+ }
+ })
+ return () => { cancelled = true }
+ }, [name, enabled, setState])
+
+ return state
+}
+
+// The build list of a gallery entry that offers several. An entry that declares
+// none returns { status: 'none' } without asking.
+export function useVariants(name, declared) {
+ const [state, setState] = useState({ status: declared ? 'loading' : 'none', data: null })
+ useEffect(() => {
+ if (!name || !declared) {
+ setState({ status: 'none', data: null })
+ return undefined
+ }
+ let cancelled = false
+ setState({ status: 'loading', data: null })
+ modelsApi.variants(name)
+ .then(data => { if (!cancelled) setState({ status: Array.isArray(data?.variants) && data.variants.length > 0 ? 'ready' : 'none', data }) })
+ .catch(() => { if (!cancelled) setState({ status: 'error', data: null }) })
+ return () => { cancelled = true }
+ }, [name, declared])
+ return state
+}
+
+// Which models the server holds in memory right now.
+export function useLoadedModels() {
+ const [loaded, setLoaded] = useState(() => new Set())
+ const refresh = useCallback(async () => {
+ try {
+ const info = await systemApi.info()
+ setLoaded(new Set((Array.isArray(info?.loaded_models) ? info.loaded_models : []).map(m => m.id)))
+ } catch {
+ setLoaded(new Set())
+ }
+ }, [])
+ useEffect(() => { refresh() }, [refresh])
+ return { loaded, refresh }
+}
diff --git a/core/http/react-ui/src/hooks/useModelRemoval.js b/core/http/react-ui/src/hooks/useModelRemoval.js
new file mode 100644
index 000000000..5977a0696
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useModelRemoval.js
@@ -0,0 +1,84 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+// A removal with an undo window, done in the browser.
+//
+// The server has no "hold then delete" call, so the hold is a delay on this
+// side: start() only hides the models and starts the window. Nothing is sent to
+// the server until the window ends. Undo drops the batch and nothing was ever
+// sent. If the page is closed or the user leaves it during the window, the batch
+// is dropped as well and nothing is deleted: a delete the user can no longer
+// see or undo must not happen.
+//
+// When the window ends, each model goes through the same delete call the row
+// menu uses, one at a time. A model that started running in the meantime is
+// skipped, not deleted.
+//
+// deleteModel(id) the existing delete call
+// loadRunning() resolves to a Set of the model ids loaded right now
+// onSettled(result) { removed, failed: [{ id, message }], skipped: [id] }
+// onAbandoned(ids) the page was left with a batch waiting
+//
+// Returns { pending, start, undo, commit }. pending is null, or
+// { ids, items, phase: 'waiting' | 'removing' }.
+export function useModelRemoval({ deleteModel, loadRunning, onSettled, onAbandoned }) {
+ const [pending, setPending] = useState(null)
+ const ref = useRef(null)
+ const latest = useRef({})
+ latest.current = { deleteModel, loadRunning, onSettled, onAbandoned }
+
+ const start = useCallback((items) => {
+ if (ref.current || items.length === 0) return false
+ const batch = { ids: items.map(item => item.id), items, phase: 'waiting' }
+ ref.current = batch
+ setPending(batch)
+ return true
+ }, [])
+
+ const undo = useCallback(() => {
+ if (!ref.current || ref.current.phase !== 'waiting') return
+ ref.current = null
+ setPending(null)
+ }, [])
+
+ const commit = useCallback(async () => {
+ const batch = ref.current
+ if (!batch || batch.phase !== 'waiting') return
+ batch.phase = 'removing'
+ setPending({ ...batch })
+ const removed = []
+ const failed = []
+ const skipped = []
+ // Looked at once, when the window ends, not when the batch was chosen.
+ let running = new Set()
+ try {
+ running = await latest.current.loadRunning()
+ } catch {
+ // Unknown. The delete call itself still refuses what the server refuses.
+ }
+ for (const id of batch.ids) {
+ if (running.has(id)) {
+ skipped.push(id)
+ continue
+ }
+ try {
+ await latest.current.deleteModel(id)
+ removed.push(id)
+ } catch (err) {
+ failed.push({ id, message: err?.message || '' })
+ }
+ }
+ ref.current = null
+ setPending(null)
+ latest.current.onSettled?.({ removed, failed, skipped })
+ }, [])
+
+ useEffect(() => () => {
+ const batch = ref.current
+ if (batch && batch.phase === 'waiting') {
+ ref.current = null
+ latest.current.onAbandoned?.(batch.ids)
+ }
+ }, [])
+
+ return { pending, start, undo, commit }
+}
diff --git a/core/http/react-ui/src/hooks/useModelSizes.js b/core/http/react-ui/src/hooks/useModelSizes.js
new file mode 100644
index 000000000..72a0d1e36
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useModelSizes.js
@@ -0,0 +1,59 @@
+import { useEffect, useState } from 'react'
+import { modelsApi } from '../utils/api'
+
+// How many estimates to ask for at once. The same reason as the gallery page:
+// an estimate can take seconds on a cold server and must not take every
+// connection the browser has.
+const CONCURRENCY = 4
+
+// Download size of a gallery model, in bytes, or null when the gallery cannot
+// say. The server reports no size for a model on disk, so the size of the files
+// the gallery lists is the best figure there is. Kept for the page session: the
+// gallery does not change under it, and the Installed table, the cleanup sheet
+// and the Explore rows all ask about the same models.
+const cache = new Map()
+const inflight = new Set()
+
+export function rememberSize(id, bytes) {
+ if (id && typeof bytes === 'number' && bytes > 0) cache.set(id, bytes)
+}
+
+// useModelSizes returns { id: bytes } for the ids it knows so far, filling in
+// the rest in the background. Only ids the gallery lists are asked about: any
+// other would answer 404 and waste a slot.
+export function useModelSizes(ids, enabled = true) {
+ const [, bump] = useState(0)
+ const key = enabled ? ids.join('\n') : ''
+
+ useEffect(() => {
+ if (!enabled) return undefined
+ const queue = ids.filter(id => !cache.has(id) && !inflight.has(id))
+ if (queue.length === 0) return undefined
+ let cancelled = false
+ let cursor = 0
+ const worker = async () => {
+ while (!cancelled && cursor < queue.length) {
+ const id = queue[cursor++]
+ inflight.add(id)
+ try {
+ const est = await modelsApi.estimate(id, [8192])
+ if (typeof est?.sizeBytes === 'number' && est.sizeBytes > 0) cache.set(id, est.sizeBytes)
+ } catch {
+ // No figure is a legitimate answer: the row shows "unknown".
+ } finally {
+ inflight.delete(id)
+ }
+ if (!cancelled) bump(n => n + 1)
+ }
+ }
+ for (let i = 0; i < Math.min(CONCURRENCY, queue.length); i++) worker()
+ return () => { cancelled = true }
+ // ids is summarised by key so a new array with the same members does not
+ // restart the queue.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [key, enabled])
+
+ const sizes = {}
+ for (const id of ids) if (cache.has(id)) sizes[id] = cache.get(id)
+ return sizes
+}
diff --git a/core/http/react-ui/src/hooks/useModelStorage.js b/core/http/react-ui/src/hooks/useModelStorage.js
new file mode 100644
index 000000000..dbd4e9191
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useModelStorage.js
@@ -0,0 +1,30 @@
+import { useEffect, useState } from 'react'
+import { modelsApi } from '../utils/api'
+import { indexStorage } from '../utils/modelStorage'
+
+// The on-disk size report, or the reason there is none.
+//
+// status 'loading' until the first answer, then 'ready' or 'unavailable'
+// index indexStorage() of the report; null unless ready
+//
+// The endpoint is for admins. A refusal, an old server without the endpoint
+// and a failed read all end the same way, 'unavailable', and the callers fall
+// back to the gallery's estimates. It is never an error on the page.
+export function useModelStorage(enabled = true, refreshToken = 0) {
+ const [state, setState] = useState({ status: 'loading', index: null })
+
+ useEffect(() => {
+ if (!enabled) return undefined
+ let cancelled = false
+ modelsApi.getStorage()
+ .then(report => {
+ if (cancelled) return
+ const index = indexStorage(report)
+ setState(index ? { status: 'ready', index } : { status: 'unavailable', index: null })
+ })
+ .catch(() => { if (!cancelled) setState({ status: 'unavailable', index: null }) })
+ return () => { cancelled = true }
+ }, [enabled, refreshToken])
+
+ return state
+}
diff --git a/core/http/react-ui/src/hooks/useModelSuggestion.js b/core/http/react-ui/src/hooks/useModelSuggestion.js
new file mode 100644
index 000000000..6d66ab25f
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useModelSuggestion.js
@@ -0,0 +1,102 @@
+import { useEffect, useState } from 'react'
+import { modelsApi } from '../utils/api'
+import { TYPE_INFO } from '../utils/studioWork'
+
+// One gallery model to suggest for a Studio type that has none installed.
+//
+// The gallery has no "best for images" flag, so this reads what it does have:
+// models carrying the type's tag, in the gallery's own order, not yet installed.
+// It asks for the size of the first few and suggests the smallest download whose
+// size is known; if no size is known it suggests the first. Whatever the server
+// does not report stays null and the note says it is unknown, never a number
+// made up to fill the line.
+//
+// Results are kept per tag for the session, so picking the chip again, or
+// switching between two types, does not ask twice.
+const cache = new Map()
+const CANDIDATES = 4
+const DEFAULT_CTX = 4096
+
+async function lookup(tag) {
+ const data = await modelsApi.list({ tag, items: 8, page: 1 })
+ const candidates = (data?.models || []).filter(m => !m.installed).slice(0, CANDIDATES)
+ if (candidates.length === 0) return null
+ const sized = await Promise.all(candidates.map(async (m) => {
+ const name = m.name || m.id
+ try {
+ const e = await modelsApi.estimate(name, [DEFAULT_CTX])
+ const ctx = e?.estimates?.[String(DEFAULT_CTX)]
+ return {
+ name,
+ description: m.description || '',
+ sizeBytes: e?.sizeBytes ?? null,
+ sizeDisplay: e?.sizeDisplay ?? null,
+ vramBytes: ctx?.vramBytes ?? null,
+ vramDisplay: ctx?.vramDisplay ?? null,
+ }
+ } catch {
+ return { name, description: m.description || '', sizeBytes: null, sizeDisplay: null, vramBytes: null, vramDisplay: null }
+ }
+ }))
+ const known = sized.filter(c => c.sizeBytes > 0).sort((a, b) => a.sizeBytes - b.sizeBytes)
+ return known[0] || sized[0]
+}
+
+// state: 'idle' (not asked), 'loading', 'ready' (suggestion set), 'none' (the
+// gallery has nothing for this type), 'error' (the gallery is out of reach).
+export function useModelSuggestion(type, enabled) {
+ const tag = TYPE_INFO[type]?.tag
+ const [state, setState] = useState({ state: 'idle', suggestion: null })
+
+ useEffect(() => {
+ if (!enabled || !tag) { setState({ state: 'idle', suggestion: null }); return undefined }
+ if (cache.has(tag)) {
+ const hit = cache.get(tag)
+ setState({ state: hit ? 'ready' : 'none', suggestion: hit })
+ return undefined
+ }
+ let cancelled = false
+ setState({ state: 'loading', suggestion: null })
+ lookup(tag)
+ .then((hit) => {
+ cache.set(tag, hit)
+ if (!cancelled) setState({ state: hit ? 'ready' : 'none', suggestion: hit })
+ })
+ .catch(() => { if (!cancelled) setState({ state: 'error', suggestion: null }) })
+ return () => { cancelled = true }
+ }, [tag, enabled])
+
+ return state
+}
+
+// The memory an installed model needs, when the gallery can say. Installed
+// models carry no size in the capabilities list, so this asks the same estimate
+// endpoint by name; a model that is not a gallery entry answers with nothing and
+// the chip simply shows no fit.
+const needCache = new Map()
+
+export function useModelNeed(modelId) {
+ const [need, setNeed] = useState(() => needCache.get(modelId) ?? null)
+ useEffect(() => {
+ if (!modelId) { setNeed(null); return undefined }
+ if (needCache.has(modelId)) { setNeed(needCache.get(modelId)); return undefined }
+ let cancelled = false
+ setNeed(null)
+ modelsApi.estimate(modelId, [DEFAULT_CTX])
+ .then((e) => {
+ const ctx = e?.estimates?.[String(DEFAULT_CTX)]
+ const value = ctx?.vramBytes > 0 ? ctx.vramBytes : null
+ needCache.set(modelId, value)
+ if (!cancelled) setNeed(value)
+ })
+ .catch(() => { needCache.set(modelId, null) })
+ return () => { cancelled = true }
+ }, [modelId])
+ return need
+}
+
+// Test hook: forget what was looked up.
+export function resetSuggestionCache() {
+ cache.clear()
+ needCache.clear()
+}
diff --git a/core/http/react-ui/src/hooks/useModels.js b/core/http/react-ui/src/hooks/useModels.js
index a82cb3875..3cb4c74c0 100644
--- a/core/http/react-ui/src/hooks/useModels.js
+++ b/core/http/react-ui/src/hooks/useModels.js
@@ -1,7 +1,8 @@
import { useState, useEffect, useCallback } from 'react'
import { modelsApi } from '../utils/api'
-export function useModels(capability) {
+// `enabled: false` skips the request, for a caller that already holds the list.
+export function useModels(capability, { enabled = true } = {}) {
const [models, setModels] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -39,8 +40,9 @@ export function useModels(capability) {
const refetch = useCallback(() => fetchModels({ silent: true }), [fetchModels])
useEffect(() => {
+ if (!enabled) { setLoading(false); return }
fetchModels()
- }, [fetchModels])
+ }, [fetchModels, enabled])
return { models, loading, error, refetch }
}
diff --git a/core/http/react-ui/src/hooks/useOperationActions.js b/core/http/react-ui/src/hooks/useOperationActions.js
new file mode 100644
index 000000000..c330d1aed
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useOperationActions.js
@@ -0,0 +1,55 @@
+import { useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { backendsApi, modelsApi, nodesApi } from '../utils/api'
+import { useOperations } from './useOperations'
+
+// Retry only ever means "install this again". A failed deletion would need the
+// delete endpoint and a staging operation is driven by the router rather than
+// by a user action, so neither is retryable.
+export function isRetryable(op) {
+ return Boolean(op?.error) && !op.isDeletion && op.taskType !== 'staging'
+}
+
+// What a failed operation offers: install again, or let it go. Shared by the
+// Activity page and the Status page's "Needs you" row, so both do the same
+// thing with the same calls.
+export function useOperationActions(addToast) {
+ const { t } = useTranslation('operate')
+ const { dismissFailedOp } = useOperations()
+
+ const retry = useCallback(async (op) => {
+ // Dismiss before reinstalling, never after: the reinstall reuses the same
+ // opcache key, and overwriting a failed entry in place skips recordTerminal
+ // so the failure would never reach the record. Dismissing first is what
+ // puts it there.
+ //
+ // By jobID, because the guarantee only holds while both calls address the
+ // same job. Two ops can share an id (a local and a node-scoped install of
+ // one backend), and dismissing by id could retire the other one instead,
+ // leaving this failure to be overwritten in place by the reinstall below.
+ await dismissFailedOp(op.jobID)
+ // fullName is the gallery-qualified id the install endpoints expect;
+ // `name` has the repo prefix stripped for display. Node-scoped ops already
+ // had their prefix removed server side, so fullName is the bare slug there.
+ const target = op.fullName || op.id
+ try {
+ if (op.nodeID) {
+ await nodesApi.installBackend(op.nodeID, target)
+ } else if (op.isBackend) {
+ await backendsApi.install(target)
+ } else {
+ // The variant is not on the payload yet, so a pinned model retries as
+ // an auto-select. ui_api.go already reads ?variant= at enqueue and
+ // stores it on the ManagementOp; it only has to reach the
+ // /api/operations payload and this call. Until then Retry stays,
+ // because nothing here tells a pinned install from an unpinned one and
+ // dropping it would cost every model the button.
+ await modelsApi.install(target)
+ }
+ } catch (err) {
+ addToast?.(t('activity.retryFailed', { message: err.message }), 'error')
+ }
+ }, [dismissFailedOp, addToast, t])
+
+ return { retry, dismiss: dismissFailedOp }
+}
diff --git a/core/http/react-ui/src/hooks/usePlacementConfig.js b/core/http/react-ui/src/hooks/usePlacementConfig.js
new file mode 100644
index 000000000..72e82a9c3
--- /dev/null
+++ b/core/http/react-ui/src/hooks/usePlacementConfig.js
@@ -0,0 +1,89 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import YAML from 'yaml'
+import { modelsApi } from '../utils/api'
+
+// The keys the Placement section writes, and the one it reads.
+export const PLACEMENT_KEYS = ['gpu_layers', 'tensor_split', 'main_gpu', 'context_size']
+
+function pick(config) {
+ const values = {}
+ for (const key of PLACEMENT_KEYS) {
+ if (config && config[key] !== undefined && config[key] !== null) values[key] = config[key]
+ }
+ return values
+}
+
+// A model's placement keys, held apart from the rest of its configuration so
+// the model page can edit just these and save them as a patch.
+//
+// status 'loading', 'ready' or 'error'.
+// values the keys now on screen (an absent key is unset).
+// preview the configuration file as it would read after saving.
+// dirty whether `values` differs from what is saved.
+// save writes the changed keys; an unset key is sent as null, which the
+// patch endpoint stores as no value.
+export function usePlacementConfig(name, { onSaved, onError } = {}) {
+ const [state, setState] = useState({ status: 'loading', parsed: null, saved: {}, error: '' })
+ const [values, setValues] = useState({})
+ const [saving, setSaving] = useState(false)
+ const [nonce, setNonce] = useState(0)
+
+ useEffect(() => {
+ let cancelled = false
+ setState(prev => (prev.parsed ? prev : { status: 'loading', parsed: null, saved: {}, error: '' }))
+ modelsApi.getEditConfig(name)
+ .then(data => {
+ if (cancelled) return
+ let parsed = {}
+ try { parsed = YAML.parse(data?.config || '') || {} } catch { parsed = {} }
+ const saved = pick(parsed)
+ setState({ status: 'ready', parsed, saved, error: '' })
+ setValues(saved)
+ })
+ .catch(err => { if (!cancelled) setState({ status: 'error', parsed: null, saved: {}, error: err.message }) })
+ return () => { cancelled = true }
+ }, [name, nonce])
+
+ const setValue = useCallback((path, value) => {
+ setValues(prev => {
+ const next = { ...prev }
+ if (value === undefined) delete next[path]
+ else next[path] = value
+ return next
+ })
+ }, [])
+
+ const dirty = PLACEMENT_KEYS.some(key => JSON.stringify(values[key]) !== JSON.stringify(state.saved[key]))
+
+ const preview = useMemo(() => {
+ if (!state.parsed) return ''
+ const merged = { ...state.parsed }
+ for (const key of PLACEMENT_KEYS) {
+ if (values[key] === undefined) delete merged[key]
+ else merged[key] = values[key]
+ }
+ try { return YAML.stringify(merged) } catch { return '' }
+ }, [state.parsed, values])
+
+ const save = useCallback(async () => {
+ const patch = {}
+ for (const key of PLACEMENT_KEYS) {
+ if (JSON.stringify(values[key]) === JSON.stringify(state.saved[key])) continue
+ patch[key] = values[key] === undefined ? null : values[key]
+ }
+ if (Object.keys(patch).length === 0) return
+ setSaving(true)
+ try {
+ await modelsApi.patchConfig(name, patch)
+ setNonce(n => n + 1)
+ onSaved?.()
+ } catch (err) {
+ onError?.(err)
+ } finally {
+ setSaving(false)
+ }
+ }, [name, values, state.saved, onSaved, onError])
+
+ const reload = useCallback(() => setNonce(n => n + 1), [])
+ return { status: state.status, error: state.error, values, setValue, preview, dirty, save, saving, reload }
+}
diff --git a/core/http/react-ui/src/hooks/usePlacementEstimate.js b/core/http/react-ui/src/hooks/usePlacementEstimate.js
new file mode 100644
index 000000000..87a057ad3
--- /dev/null
+++ b/core/http/react-ui/src/hooks/usePlacementEstimate.js
@@ -0,0 +1,115 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { modelsApi } from '../utils/api'
+import { splitByContext } from '../utils/placement'
+
+// What the server estimates when the file sets no context size.
+export const DEFAULT_CONTEXT = 8192
+
+const DEBOUNCE_MS = 350
+
+// Readings already made this session, keyed by what was asked. A failed or
+// empty reading is never kept, so "Retry" really asks again.
+const readings = new Map()
+
+// One call to /api/models/vram-estimate, reduced to the figures the editor
+// uses. null means the estimate has nothing to say: the file is missing, still
+// downloading, or in a format it does not cover (the endpoint answers 200 with
+// only a message in that case).
+//
+// `layers` null asks for every layer: the endpoint reads a zero or an absent
+// gpu_layers as "all".
+export function readEstimate(model, contextSize, layers) {
+ const key = `${model}|${contextSize}|${layers ?? 'all'}`
+ const known = readings.get(key)
+ if (known) return known
+ const body = { model, context_size: contextSize }
+ if (layers != null) body.gpu_layers = layers
+ const promise = modelsApi.estimateVram(body)
+ .then(data => {
+ const bytes = Number(data?.vram_bytes)
+ if (!(bytes > 0)) { readings.delete(key); return null }
+ return {
+ bytes,
+ sizeBytes: Number(data.size_bytes) || 0,
+ maxContext: Number(data.model_max_context) || 0,
+ // Not sent by the server today. Read when present, so a slider can use
+ // it the day the estimate reports the model's layer count.
+ layerCount: Number(data.block_count) || 0,
+ }
+ })
+ .catch(() => { readings.delete(key); return null })
+ readings.set(key, promise)
+ return promise
+}
+
+// The estimate behind the Placement section for the current choice.
+//
+// status 'idle' (nothing to estimate), 'loading' (first reading),
+// 'ready', or 'unavailable'.
+// data { gpuBytes, allBytes, parts, sizeBytes, maxContext, layerCount }
+// once a reading exists. `parts` splits gpuBytes into the part that
+// grows with context and the rest, or is null.
+// refreshing a newer reading is on its way and `data` is the previous one.
+//
+// Three readings at most: the choice itself, the same choice at twice the
+// context (the difference is the context-dependent part), and every layer on
+// the GPU (what a partial choice leaves behind).
+export function usePlacementEstimate({ model, contextSize, layers, enabled = true }) {
+ const [state, setState] = useState({ status: 'idle', data: null, refreshing: false })
+ const [nonce, setNonce] = useState(0)
+ const contextRef = useRef(contextSize)
+ contextRef.current = contextSize
+
+ useEffect(() => {
+ if (!enabled || !model) {
+ setState({ status: 'idle', data: null, refreshing: false })
+ return undefined
+ }
+ let cancelled = false
+ setState(prev => (prev.data ? { ...prev, refreshing: true } : { status: 'loading', data: null, refreshing: false }))
+ const timer = setTimeout(async () => {
+ const ctx = contextSize > 0 ? contextSize : DEFAULT_CONTEXT
+ const [here, grown, all] = await Promise.all([
+ readEstimate(model, ctx, layers),
+ readEstimate(model, ctx * 2, layers),
+ layers != null ? readEstimate(model, ctx, null) : null,
+ ])
+ if (cancelled) return
+ if (!here) {
+ setState({ status: 'unavailable', data: null, refreshing: false })
+ return
+ }
+ setState({
+ status: 'ready',
+ refreshing: false,
+ data: {
+ gpuBytes: here.bytes,
+ allBytes: layers != null ? (all?.bytes ?? here.bytes) : here.bytes,
+ parts: splitByContext({ ctx, bytes: here.bytes }, grown ? { ctx: ctx * 2, bytes: grown.bytes } : null),
+ sizeBytes: here.sizeBytes,
+ maxContext: here.maxContext,
+ layerCount: here.layerCount,
+ },
+ })
+ }, DEBOUNCE_MS)
+ return () => {
+ cancelled = true
+ clearTimeout(timer)
+ }
+ }, [model, contextSize, layers, enabled, nonce])
+
+ const retry = useCallback(() => {
+ setState({ status: 'loading', data: null, refreshing: false })
+ setNonce(n => n + 1)
+ }, [])
+
+ // For the layer search: the estimate in bytes at a layer count, at the
+ // context now on screen. null when the endpoint cannot say.
+ const bytesAt = useCallback(async (layerCount) => {
+ const ctx = contextRef.current > 0 ? contextRef.current : DEFAULT_CONTEXT
+ const reading = await readEstimate(model, ctx, layerCount)
+ return reading ? reading.bytes : null
+ }, [model])
+
+ return { ...state, retry, bytesAt }
+}
diff --git a/core/http/react-ui/src/hooks/useStudioHandoff.js b/core/http/react-ui/src/hooks/useStudioHandoff.js
new file mode 100644
index 000000000..7a718a6e1
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useStudioHandoff.js
@@ -0,0 +1,66 @@
+import { useEffect, useMemo, useState } from 'react'
+import { useSearchParams } from 'react-router-dom'
+import { readAllMediaHistory } from './useMediaHistory'
+import { apiUrl } from '../utils/basePath'
+import { collectWork, readHandoff, sourceUrlFor } from '../utils/studioWork'
+
+// What the Studio front page hands to a workspace: a prompt, a model, a size, a
+// count, and the id of the result the new one grows from. A workspace calls this
+// once and seeds its own form from it; with no query string it returns empty
+// values and the workspace behaves as it always has.
+export function useStudioHandoff() {
+ const [params] = useSearchParams()
+ const key = params.toString()
+ // The query string is the only input, so its text is the dependency.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ return useMemo(() => readHandoff(params), [key])
+}
+
+// The file a hand-off starts from, fetched from the server's own output URL.
+//
+// status 'none' no source was asked for
+// 'loading' fetching
+// 'ready' `blob` and `item` are set
+// 'missing' the result is no longer in this browser's history
+// 'error' the file could not be fetched (the server may have
+// cleaned it up)
+//
+// Only the browser-storage lists are searched. A 3D result has no file URL, and
+// nothing accepts a mesh as a source yet.
+export function useHandoffSource(handoff, enabled = true) {
+ const { from, edge } = handoff
+ const [state, setState] = useState({ status: from && enabled ? 'loading' : 'none', blob: null, item: null })
+
+ useEffect(() => {
+ if (!from || !enabled) { setState({ status: 'none', blob: null, item: null }); return undefined }
+ const item = collectWork(readAllMediaHistory(), []).find(i => i.id === from) || null
+ const url = sourceUrlFor(item, edge)
+ if (!item || !url) { setState({ status: 'missing', blob: null, item }); return undefined }
+ let cancelled = false
+ setState({ status: 'loading', blob: null, item })
+ fetch(url.startsWith('http') || url.startsWith('data:') ? url : apiUrl(url))
+ .then(res => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.blob() })
+ .then(blob => { if (!cancelled) setState({ status: 'ready', blob, item }) })
+ .catch(() => { if (!cancelled) setState({ status: 'error', blob: null, item }) })
+ return () => { cancelled = true }
+ }, [from, edge, enabled])
+
+ return state
+}
+
+// A blob as the { base64, dataUrl, mime } shape the image inputs hold.
+export function blobToImageInput(blob) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader()
+ reader.onload = () => {
+ const dataUrl = String(reader.result)
+ resolve({ base64: dataUrl.split(',')[1] || '', dataUrl, mime: blob.type || 'image/png', source: 'upload' })
+ }
+ reader.onerror = reject
+ reader.readAsDataURL(blob)
+ })
+}
+
+export function blobToFile(blob, name) {
+ return new File([blob], name, { type: blob.type || 'application/octet-stream' })
+}
diff --git a/core/http/react-ui/src/hooks/useStudioWork.js b/core/http/react-ui/src/hooks/useStudioWork.js
new file mode 100644
index 000000000..90b86b894
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useStudioWork.js
@@ -0,0 +1,64 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { clearAllMediaHistory, FAVOURITES_KEY, readAllMediaHistory } from './useMediaHistory'
+import { use3DHistory } from './use3DHistory'
+import { collectWork, pruneIds, toggleId } from '../utils/studioWork'
+
+function readFavourites() {
+ try {
+ const stored = JSON.parse(localStorage.getItem(FAVOURITES_KEY) || '[]')
+ return Array.isArray(stored) ? stored.filter(id => typeof id === 'string') : []
+ } catch {
+ return []
+ }
+}
+
+function writeFavourites(ids) {
+ try {
+ if (ids.length === 0) localStorage.removeItem(FAVOURITES_KEY)
+ else localStorage.setItem(FAVOURITES_KEY, JSON.stringify(ids))
+ } catch { /* quota or private mode: the star simply does not stick */ }
+}
+
+// Everything the Studio front page shows as "your work": the entries each
+// workspace stored, in one list, with favourites. The browser-storage lists are
+// read once on mount (they change only while a workspace is open, and this page
+// mounts fresh when you come back); 3D arrives asynchronously from IndexedDB.
+//
+// `ready` is false until 3D has answered, so the page can show a skeleton
+// instead of flashing an empty state for work that is a moment away.
+export function useStudioWork() {
+ const [media, setMedia] = useState(() => readAllMediaHistory())
+ const [favourites, setFavourites] = useState(readFavourites)
+ const { entries: threeD, clearAll: clearThreeD } = use3DHistory()
+ const [ready, setReady] = useState(false)
+
+ // use3DHistory starts with [] and fills from IndexedDB; one tick later it has
+ // answered either way, which is all "ready" needs to mean.
+ useEffect(() => {
+ const id = setTimeout(() => setReady(true), 0)
+ return () => clearTimeout(id)
+ }, [])
+ useEffect(() => { if (threeD.length > 0) setReady(true) }, [threeD])
+
+ const items = useMemo(
+ () => collectWork(media, threeD, { favourites }),
+ [media, threeD, favourites],
+ )
+
+ const toggleFavourite = useCallback((id) => {
+ setFavourites(prev => {
+ const next = toggleId(pruneIds(prev, items), id)
+ writeFavourites(next)
+ return next
+ })
+ }, [items])
+
+ const clearHistory = useCallback(async () => {
+ clearAllMediaHistory()
+ setMedia(readAllMediaHistory())
+ setFavourites([])
+ await clearThreeD()
+ }, [clearThreeD])
+
+ return { items, ready, toggleFavourite, clearHistory }
+}
diff --git a/core/http/react-ui/src/hooks/useSwarm.js b/core/http/react-ui/src/hooks/useSwarm.js
new file mode 100644
index 000000000..46a111933
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useSwarm.js
@@ -0,0 +1,95 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { nodesApi } from '../utils/api'
+import { usePolling } from './usePolling'
+
+// The cluster roster, read the way the Nodes page always read it.
+//
+// status 'loading' | 'ready' | 'single' | 'error'
+//
+// A single-node server never mounts the cluster routes, so /api/nodes answers
+// 404 there (503 when they are mounted without a registry). Both mean "this is
+// not a cluster", which is a state of the page and not an error. Any other
+// failure keeps the last roster and reports `error`, so a blip does not blank
+// the page.
+export function useNodeList({ interval = 5000, enabled = true } = {}) {
+ const [nodes, setNodes] = useState([])
+ const [status, setStatus] = useState('loading')
+
+ const read = useCallback(async () => {
+ try {
+ const data = await nodesApi.list()
+ setNodes(Array.isArray(data) ? data : [])
+ setStatus('ready')
+ } catch (error) {
+ const single = error.status === 404 || error.status === 503
+ || error.message?.includes('503') || error.message?.includes('Service Unavailable')
+ setStatus(single ? 'single' : 'error')
+ }
+ }, [])
+
+ const { refetch } = usePolling(read, interval, { enabled })
+ return { nodes, status, refetch }
+}
+
+// Every loaded replica in the cluster (GET /api/nodes/models). It is one
+// controller query, so nothing reads it until `load()` is called, and it is read
+// once and kept until `load(true)` asks again.
+//
+// state 'idle' | 'loading' | 'loaded' | 'error'
+export function useReplicas() {
+ const [rows, setRows] = useState([])
+ const [state, setState] = useState('idle')
+ const [error, setError] = useState('')
+ const started = useRef(false)
+
+ const load = useCallback(async (force = false) => {
+ if (started.current && !force) return
+ started.current = true
+ setState(current => (current === 'loaded' ? current : 'loading'))
+ try {
+ const data = await nodesApi.allModels()
+ setRows(Array.isArray(data) ? data : [])
+ setError('')
+ setState('loaded')
+ } catch (err) {
+ setError(err.message || '')
+ setState('error')
+ }
+ }, [])
+
+ const retry = useCallback(() => { started.current = false; return load() }, [load])
+ return { rows, state, error, load, retry }
+}
+
+// The placement rules (GET /api/nodes/scheduling). A failure reads as no rules:
+// a single install has none, and nothing here should block a page on them.
+export function useRules() {
+ const [rules, setRules] = useState([])
+ const [loaded, setLoaded] = useState(false)
+ const read = useCallback(async () => {
+ try {
+ const data = await nodesApi.listScheduling()
+ setRules(Array.isArray(data) ? data : [])
+ } catch {
+ setRules([])
+ } finally {
+ setLoaded(true)
+ }
+ }, [])
+ useEffect(() => { read() }, [read])
+ return { rules, loaded, refresh: read }
+}
+
+// True below the width where the map is not drawn and tables become cards.
+export function usePhone(query = '(max-width: 640px)') {
+ const [phone, setPhone] = useState(() => (typeof window !== 'undefined' && window.matchMedia ? window.matchMedia(query).matches : false))
+ useEffect(() => {
+ if (!window.matchMedia) return undefined
+ const list = window.matchMedia(query)
+ const on = () => setPhone(list.matches)
+ on()
+ list.addEventListener('change', on)
+ return () => list.removeEventListener('change', on)
+ }, [query])
+ return phone
+}
diff --git a/core/http/react-ui/src/hooks/useTraffic.js b/core/http/react-ui/src/hooks/useTraffic.js
new file mode 100644
index 000000000..22f41f786
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useTraffic.js
@@ -0,0 +1,127 @@
+import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'
+import { useAuth } from '../context/AuthContext'
+import { apiUrl } from '../utils/basePath'
+import { settingsApi, tracesApi, usageApi } from '../utils/api'
+import { DEFAULT_WINDOW, WINDOWS, windowById } from '../utils/traffic'
+import { usePolling } from './usePolling'
+
+// ---------------------------------------------------------------------------
+// The time window, shared by every Traffic page
+// ---------------------------------------------------------------------------
+
+// A tiny external store, so the choice made on the Overview is still the one on
+// Usage and on Models without a provider above the router. It lives for the
+// session; a reload starts again on 24 hours.
+const KEY = 'localai.traffic.window'
+const listeners = new Set()
+let current = DEFAULT_WINDOW
+try {
+ const saved = window.sessionStorage.getItem(KEY)
+ if (WINDOWS.some(w => w.id === saved)) current = saved
+} catch { /* no session storage: the default stands */ }
+
+function subscribe(fn) {
+ listeners.add(fn)
+ return () => listeners.delete(fn)
+}
+
+export function setTrafficWindow(id) {
+ if (!WINDOWS.some(w => w.id === id) || id === current) return
+ current = id
+ try { window.sessionStorage.setItem(KEY, id) } catch { /* ignore */ }
+ listeners.forEach(fn => fn())
+}
+
+export function useTrafficWindow() {
+ const id = useSyncExternalStore(subscribe, () => current, () => DEFAULT_WINDOW)
+ return { id, window: windowById(id), setWindow: setTrafficWindow }
+}
+
+// ---------------------------------------------------------------------------
+// Usage
+// ---------------------------------------------------------------------------
+
+// The ledger rows the pages draw. An admin sees every user's; anyone else sees
+// their own, which is all the server will give them. /api/usage works without
+// auth (the local user), /api/auth/usage is the signed-in path, and both are
+// kept as they were so quotas and per-key rows stay consistent.
+export function useUsage(period, { sources = false } = {}) {
+ const { isAdmin, authEnabled, loading: authLoading } = useAuth()
+ const [state, setState] = useState({ loading: true, error: null, rows: [], totals: null, quotas: [], keys: null })
+ const [nonce, setNonce] = useState(0)
+ const reload = useCallback(() => setNonce(n => n + 1), [])
+
+ useEffect(() => {
+ if (authLoading) return undefined
+ let cancelled = false
+ setState(s => ({ ...s, loading: true, error: null }))
+ ;(async () => {
+ try {
+ const own = authEnabled ? '/api/auth/usage' : '/api/usage'
+ const admin = authEnabled ? '/api/auth/admin/usage' : '/api/usage/all'
+ const get = async url => {
+ const res = await fetch(apiUrl(`${url}?period=${period}`))
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ return res.json()
+ }
+ const [mine, quota, all, keys] = await Promise.all([
+ isAdmin ? Promise.resolve(null) : get(own),
+ authEnabled ? usageApi.getMyQuotas().catch(() => null) : Promise.resolve(null),
+ isAdmin ? get(admin) : Promise.resolve(null),
+ sources && authEnabled
+ ? (isAdmin ? usageApi.getAdminSources(period) : usageApi.getMySources(period)).catch(() => null)
+ : Promise.resolve(null),
+ ])
+ if (cancelled) return
+ const data = isAdmin ? all : mine
+ setState({
+ loading: false,
+ error: null,
+ rows: data?.usage || [],
+ totals: data?.totals || null,
+ quotas: quota?.quotas || [],
+ keys,
+ })
+ } catch (error) {
+ if (!cancelled) setState(s => ({ ...s, loading: false, error }))
+ }
+ })()
+ return () => { cancelled = true }
+ }, [period, isAdmin, authEnabled, authLoading, sources, nonce])
+
+ return { ...state, isAdmin, authEnabled, reload }
+}
+
+// ---------------------------------------------------------------------------
+// Traces
+// ---------------------------------------------------------------------------
+
+// Whether the server records requests. Null until the settings answer, and
+// after a failure: a page that does not know does not claim either state.
+export function useTracingEnabled() {
+ const [state, setState] = useState({ enabled: null, backend: null, settings: null })
+ const reload = useCallback(() => {
+ settingsApi.get()
+ .then(data => setState({ enabled: !!data.enable_tracing, backend: !!data.enable_backend_logging, settings: data }))
+ .catch(() => {})
+ }, [])
+ useEffect(() => { reload() }, [reload])
+ return { ...state, reload, setSettings: settings => setState(s => ({ ...s, settings })) }
+}
+
+// The counted view of the trace buffer over `hours`, polled while the page is
+// open. `error` is set when the endpoint cannot be read; the totals then stay
+// null so no figure is drawn at zero.
+export function useTraceSummary(hours, { intervalMs = 15_000, enabled = true } = {}) {
+ const [state, setState] = useState({ loading: true, summary: null, error: null })
+ const read = useCallback(async () => {
+ try {
+ const summary = await tracesApi.summary(hours)
+ setState({ loading: false, summary, error: null })
+ } catch (error) {
+ setState({ loading: false, summary: null, error })
+ }
+ }, [hours])
+ const { refetch } = usePolling(read, intervalMs, { enabled })
+ return { ...state, refetch }
+}
diff --git a/core/http/react-ui/src/hooks/useUndoableCancel.js b/core/http/react-ui/src/hooks/useUndoableCancel.js
new file mode 100644
index 000000000..e8767f1c6
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useUndoableCancel.js
@@ -0,0 +1,52 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+// How long a cancel waits before anything is stopped. The server has no hold on
+// a cancel (it ends the job and removes its partial data), so this wait in the
+// browser is the whole undo. Pause is the call that keeps the data.
+export const CANCEL_UNDO_MS = 8000
+
+// A cancel that waits for its undo window, then runs.
+//
+// operations the live operations, so a job that finished while the window
+// was open is not cancelled afterwards (that would only fail)
+// cancel the real call, taking a jobID
+//
+// Only one waits at a time: asking for a second ends the first window, so a
+// toast never hides another. Leaving the page runs the waiting cancel, because
+// the person asked for it.
+export function useUndoableCancel({ operations, cancel }) {
+ const [waitingFor, setWaitingFor] = useState(null)
+ const waiting = useRef(null)
+ const live = useRef(operations)
+ live.current = operations
+ const cancelRef = useRef(cancel)
+ cancelRef.current = cancel
+
+ const commit = useCallback(() => {
+ const entry = waiting.current
+ if (!entry) return
+ waiting.current = null
+ setWaitingFor(null)
+ if (live.current.some(op => op.jobID === entry.jobID)) cancelRef.current(entry.jobID)
+ }, [])
+
+ const request = useCallback((jobID) => {
+ const op = live.current.find(o => o.jobID === jobID)
+ if (!op) return
+ commit()
+ waiting.current = { jobID, name: op.name || op.id }
+ setWaitingFor(waiting.current)
+ }, [commit])
+
+ const undo = useCallback(() => {
+ waiting.current = null
+ setWaitingFor(null)
+ }, [])
+
+ useEffect(() => () => {
+ const entry = waiting.current
+ if (entry && live.current.some(op => op.jobID === entry.jobID)) cancelRef.current(entry.jobID)
+ }, [])
+
+ return { waitingFor, request, undo, commit }
+}
diff --git a/core/http/react-ui/src/hooks/useWideLayout.js b/core/http/react-ui/src/hooks/useWideLayout.js
new file mode 100644
index 000000000..bbe96736f
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useWideLayout.js
@@ -0,0 +1,18 @@
+import { useEffect, useState } from 'react'
+
+const QUERY = '(min-width: 901px)'
+
+// True when the list and the open item sit side by side. On a phone they take
+// turns, so a page must not open an item the person did not ask for.
+export function useWideLayout() {
+ const read = () => (typeof window !== 'undefined' && typeof window.matchMedia === 'function' ? window.matchMedia(QUERY).matches : true)
+ const [wide, setWide] = useState(read)
+ useEffect(() => {
+ if (typeof window.matchMedia !== 'function') return undefined
+ const mq = window.matchMedia(QUERY)
+ const onChange = () => setWide(mq.matches)
+ mq.addEventListener('change', onChange)
+ return () => mq.removeEventListener('change', onChange)
+ }, [])
+ return wide
+}
diff --git a/core/http/react-ui/src/hooks/useWorkspace.js b/core/http/react-ui/src/hooks/useWorkspace.js
new file mode 100644
index 000000000..bfa61b2b3
--- /dev/null
+++ b/core/http/react-ui/src/hooks/useWorkspace.js
@@ -0,0 +1,155 @@
+import { useCallback, useMemo, useRef, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import { useModels } from './useModels'
+import { FAVOURITES_KEY } from './useMediaHistory'
+import { readAllMediaHistory } from './useMediaHistory'
+import {
+ CAP_3D, CAP_3D_ANIMATION, CAP_AUDIO_TRANSFORM, CAP_DIARIZATION, CAP_IMAGE, CAP_SOUND_GENERATION, CAP_TTS, CAP_VIDEO,
+} from '../utils/capabilities'
+import {
+ NEXT_STEPS, TYPE_INFO, TYPE_ORDER, canTake, collectWork, handoffPath, toggleId,
+} from '../utils/studioWork'
+
+// The model capabilities that make each Studio type usable. 3D accepts either
+// the mesh or the animation flag, as the Studio tab dots do.
+const TYPE_CAPABILITIES = {
+ images: [CAP_IMAGE],
+ video: [CAP_VIDEO],
+ threed: [CAP_3D, CAP_3D_ANIMATION],
+ tts: [CAP_TTS],
+ sound: [CAP_SOUND_GENERATION],
+ transform: [CAP_AUDIO_TRANSFORM],
+ diarization: [CAP_DIARIZATION],
+}
+
+function readFavourites() {
+ try {
+ const stored = JSON.parse(localStorage.getItem(FAVOURITES_KEY) || '[]')
+ return Array.isArray(stored) ? stored.filter(id => typeof id === 'string') : []
+ } catch {
+ return []
+ }
+}
+
+function writeFavourites(ids) {
+ try {
+ if (ids.length === 0) localStorage.removeItem(FAVOURITES_KEY)
+ else localStorage.setItem(FAVOURITES_KEY, JSON.stringify(ids))
+ } catch { /* quota or private mode: the star simply does not stick */ }
+}
+
+// Which Studio types have a model installed, from one unfiltered read of the
+// capabilities list. `loading` is true until the first answer, so a workspace
+// does not flash its "no model" note while the list is still on its way.
+export function useInstalledTypes() {
+ const { models, loading, error, refetch } = useModels()
+ const byType = useMemo(() => {
+ const out = {}
+ for (const type of TYPE_ORDER) {
+ out[type] = models
+ .filter(m => !m.disabled && m.capabilities?.some(cap => TYPE_CAPABILITIES[type].includes(cap)))
+ .map(m => m.id)
+ }
+ return out
+ }, [models])
+ return { byType, loading, error, refetch }
+}
+
+// What the fold names when it is closed: the settings that hold a value, else
+// what is inside.
+export function foldSummary(set, all) {
+ return (set.length > 0 ? set : all).join(', ')
+}
+
+// How one value changed since the take it was started from, in words a person
+// would use: a short value shows both sides, a long one just says it was edited.
+export function describeChange(label, from, to) {
+ const a = String(from ?? '')
+ const b = String(to ?? '')
+ if (a.length > 24 || b.length > 24) return { key: label, text: label, long: true }
+ return { key: label, text: `${label} ${a || 'empty'} to ${b || 'empty'}` }
+}
+
+// Everything the shared workspace frame needs from one page: its recent
+// results as work items, favourites, the "Use in" targets, the lineage link
+// and the "Re-run with edits" comparison.
+//
+// type the Studio key ('images', 'video', ...)
+// entries the page's own history entries, newest first (live, so a result
+// made a moment ago is already there)
+//
+// Favourites are the same list the Studio front page keeps, so a star set here
+// shows there.
+export function useWorkspace({ type, entries }) {
+ const { t } = useTranslation('media')
+ const navigate = useNavigate()
+ const info = TYPE_INFO[type]
+ const [favourites, setFavourites] = useState(readFavourites)
+ const installed = useInstalledTypes()
+ const composeRef = useRef(null)
+ const [base, setBase] = useState(null)
+
+ const items = useMemo(
+ () => collectWork(info.media ? { [info.media]: entries } : {}, info.media ? [] : entries, { favourites }),
+ [entries, favourites, info.media],
+ )
+ const itemById = useCallback((id) => (id ? items.find(i => i.id === id) || null : null), [items])
+
+ const toggleFavourite = useCallback((id) => {
+ setFavourites((prev) => {
+ const next = toggleId(prev, id)
+ writeFavourites(next)
+ return next
+ })
+ }, [])
+
+ const openLineage = useCallback((item) => navigate(`/app/studio?work=${encodeURIComponent(item.id)}`), [navigate])
+
+ // The steps this kind of result can go on to, each marked with whether the
+ // destination can start from it and whether a model for it is installed.
+ const steps = useCallback((item) => (NEXT_STEPS[type] || []).map(step => ({
+ ...step,
+ title: t(`studio.steps.${step.edge}.title`),
+ type: step.to,
+ missingModel: step.supported && (installed.byType[step.to]?.length || 0) === 0 && !installed.loading,
+ blocked: item ? !step.supported : true,
+ })), [type, t, installed.byType, installed.loading])
+
+ const sendTo = useCallback((item, step) => {
+ if (!item || !step?.supported) return
+ const textLike = TYPE_INFO[step.to].input === 'text'
+ const prompt = !textLike ? ''
+ : step.edge === 'variation' ? item.title
+ : t(`studio.steps.${step.edge}.template`, { defaultValue: '' })
+ navigate(handoffPath(step.to, { prompt, from: item.id, edge: step.edge }))
+ }, [navigate, t])
+
+ // Re-run with edits: the take's values are loaded into the form and kept as
+ // the comparison. `begin` is called by the page after it has set its own
+ // state; `changes` is computed from what the form holds now.
+ const begin = useCallback((values) => {
+ setBase(values)
+ composeRef.current?.scrollIntoView?.({ block: 'start', behavior: 'smooth' })
+ }, [])
+ const end = useCallback(() => setBase(null), [])
+ const changes = useCallback((current, labels) => {
+ if (!base) return []
+ return Object.keys(labels)
+ .filter(key => String(base[key] ?? '') !== String(current[key] ?? ''))
+ .map(key => ({ ...describeChange(labels[key], base[key], current[key]), field: key }))
+ }, [base])
+
+ return {
+ type, items, itemById, favourites, toggleFavourite, openLineage, steps, sendTo, canTake,
+ installed, composeRef, rerunning: !!base, begin, end, changes,
+ }
+}
+
+// The result a hand-off or a lineage link names, looked up across all stores,
+// for the "from ..." line under a result.
+export function findParent(item) {
+ if (!item?.parentId) return null
+ const all = collectWork(readAllMediaHistory(), [])
+ return all.find(i => i.id === item.parentId) || null
+}
diff --git a/core/http/react-ui/src/i18n/index.js b/core/http/react-ui/src/i18n/index.js
index f12e4e904..f73ec10ec 100644
--- a/core/http/react-ui/src/i18n/index.js
+++ b/core/http/react-ui/src/i18n/index.js
@@ -22,15 +22,20 @@ export const NAMESPACES = [
'auth',
'home',
'chat',
+ 'talk',
'studio',
'models',
'agents',
'skills',
'collections',
+ 'library',
'biometrics',
'media',
'tools',
'admin',
+ 'operate',
+ 'swarm',
+ 'traffic',
'usage',
'explorer',
]
diff --git a/core/http/react-ui/src/index.css b/core/http/react-ui/src/index.css
index 738606065..c796c91a6 100644
--- a/core/http/react-ui/src/index.css
+++ b/core/http/react-ui/src/index.css
@@ -17,7 +17,7 @@ body {
font-weight: var(--font-weight-regular);
line-height: var(--leading-normal);
font-feature-settings: "ss01", "ss03", "cv11";
- letter-spacing: -0.005em;
+ letter-spacing: 0;
min-height: 100%;
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
@@ -53,10 +53,10 @@ h1, h2, h3, h4, h5, h6 {
color: var(--color-text-primary);
line-height: var(--leading-tight);
}
-h1 { font-family: var(--font-sans); font-size: clamp(1.75rem, 1.2rem + 2.4vw, var(--text-4xl)); font-weight: 600; letter-spacing: -0.02em; }
-h2 { font-family: var(--font-sans); font-size: clamp(1.4rem, 1.05rem + 1.6vw, var(--text-3xl)); font-weight: 600; letter-spacing: -0.015em; }
-h3 { font-size: var(--text-xl); font-weight: var(--font-weight-medium); letter-spacing: -0.01em; }
-h4 { font-size: var(--text-lg); font-weight: var(--font-weight-medium); letter-spacing: -0.005em; }
+h1 { font-family: var(--font-sans); font-size: clamp(1.75rem, 1.2rem + 2.4vw, var(--text-4xl)); font-weight: var(--font-weight-bold); letter-spacing: -0.03em; }
+h2 { font-family: var(--font-sans); font-size: clamp(1.4rem, 1.05rem + 1.6vw, var(--text-3xl)); font-weight: var(--font-weight-bold); letter-spacing: -0.02em; }
+h3 { font-size: var(--text-xl); font-weight: var(--font-weight-bold); letter-spacing: -0.015em; }
+h4 { font-size: var(--text-lg); font-weight: var(--font-weight-semibold); letter-spacing: -0.01em; }
h5 { font-size: var(--text-base);font-weight: var(--font-weight-semibold); }
h6 {
font-size: var(--text-xs); font-weight: var(--font-weight-semibold);
diff --git a/core/http/react-ui/src/main.jsx b/core/http/react-ui/src/main.jsx
index 28ce5fde1..9ca611062 100644
--- a/core/http/react-ui/src/main.jsx
+++ b/core/http/react-ui/src/main.jsx
@@ -7,9 +7,12 @@ import { AuthProvider } from './context/AuthContext'
import { OperationsProvider } from './contexts/OperationsContext'
import { router } from './router'
import './i18n'
-import '@fortawesome/fontawesome-free/css/all.min.css'
import '@fontsource-variable/geist'
import '@fontsource-variable/geist-mono'
+import './vendor/ui-kit/tokens.css'
+import './theme-localai.css'
+import './vendor/ui-kit/motion.css'
+import './vendor/ui-kit/components.css'
import './index.css'
import './theme.css'
import './App.css'
diff --git a/core/http/react-ui/src/pages/Account.jsx b/core/http/react-ui/src/pages/Account.jsx
index 38323c222..e80170dd4 100644
--- a/core/http/react-ui/src/pages/Account.jsx
+++ b/core/http/react-ui/src/pages/Account.jsx
@@ -1,34 +1,36 @@
-import { useState, useEffect, useCallback } from 'react'
-import { useOutletContext } from 'react-router-dom'
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { useState, useEffect, useRef } from 'react'
+import { Link, useOutletContext, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../context/AuthContext'
-import { apiKeysApi, profileApi } from '../utils/api'
+import { profileApi, usageApi } from '../utils/api'
+import { totalsOf, groupUsage, compactCount } from '../utils/traffic'
+import { timeUntil, initialOf } from '../utils/access'
import LoadingSpinner from '../components/LoadingSpinner'
-import PageHeader from '../components/PageHeader'
-import SettingRow from '../components/SettingRow'
-import ConfirmDialog from '../components/ConfirmDialog'
-import './auth.css'
-
-function formatDate(d) {
- if (!d) return '-'
- return new Date(d).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
-}
+import ApiKeysPanel from '../components/users/ApiKeysPanel'
+import Icon from '../components/Icon'
+import './access.css'
const TABS = [
- { id: 'profile', icon: 'fa-user', labelKey: 'account.tabs.profile' },
- { id: 'security', icon: 'fa-lock', labelKey: 'account.tabs.security' },
- { id: 'apikeys', icon: 'fa-key', labelKey: 'account.tabs.apiKeys' },
+ { id: 'profile', labelKey: 'account.tabs.profile' },
+ { id: 'security', labelKey: 'account.tabs.security' },
+ { id: 'keys', labelKey: 'account.tabs.apiKeys' },
+ { id: 'usage', labelKey: 'account.tabs.usage' },
]
+const WINDOW_WORDS = { '1m': 'minute', '5m': '5 minutes', '1h': 'hour', '6h': '6 hours', '1d': 'day', '7d': 'week', '30d': '30 days' }
+
function ProfileTab({ addToast }) {
const { t } = useTranslation('auth')
const { user, refresh } = useAuth()
const [name, setName] = useState(user?.name || '')
const [avatarUrl, setAvatarUrl] = useState(user?.avatarUrl || '')
const [saving, setSaving] = useState(false)
+ const [avatarOk, setAvatarOk] = useState(true)
useEffect(() => { if (user?.name) setName(user.name) }, [user?.name])
useEffect(() => { setAvatarUrl(user?.avatarUrl || '') }, [user?.avatarUrl])
+ useEffect(() => { setAvatarOk(true) }, [avatarUrl])
const hasChanges = (name.trim() && name.trim() !== user?.name) || (avatarUrl.trim() !== (user?.avatarUrl || ''))
@@ -47,79 +49,36 @@ function ProfileTab({ addToast }) {
}
}
+ const shown = avatarUrl.trim() || user?.avatarUrl
return (
-
- {/* User info header */}
-
-
- {user?.avatarUrl ? (
-
- ) : (
-
- )}
-
-
-
{user?.email}
-
-
- {user?.role}
-
-
- {user?.provider || 'local'}
-
-
-
+
+
+
{t('account.profile.displayName')}
+
setName(e.target.value)} aria-describedby="ac-name-hint" />
+
{t('account.profile.displayNameDescription')}
-
- {/* Profile form */}
-
-
-
- setName(e.target.value)}
- disabled={saving}
- maxLength={100}
- />
-
-
-
-
setAvatarUrl(e.target.value)}
- disabled={saving}
- maxLength={512}
- placeholder={t('account.profile.avatarUrlPlaceholder')}
- />
- {avatarUrl.trim() && (
-
{ e.target.style.display = 'none' }}
- onLoad={(e) => { e.target.style.display = 'block' }}
- />
- )}
-
-
-
-
-
- {saving
- ? <> {t('account.profile.saving')}>
- : <> {t('account.profile.save')}>}
-
-
-
-
+
+
{t('account.profile.avatarUrl')}
+
setAvatarUrl(e.target.value)} aria-describedby="ac-avatar-hint" />
+
{t('account.profile.avatarUrlDescription')}
+
+
+
+ {shown && avatarOk
+ ? setAvatarOk(false)} />
+ : {initialOf(user || {})} }
+
+
+ {user?.email}
+ {user?.role} · {user?.provider || 'local'} {t('account.profile.signIn')}
+
+
+
+
+ {saving ? <> {t('account.profile.saving')}> : <> {t('account.profile.save')}>}
+
+
+
)
}
@@ -127,7 +86,6 @@ function SecurityTab({ addToast }) {
const { t } = useTranslation('auth')
const { user } = useAuth()
const isLocal = user?.provider === 'local'
-
const [currentPw, setCurrentPw] = useState('')
const [newPw, setNewPw] = useState('')
const [confirmPw, setConfirmPw] = useState('')
@@ -135,6 +93,15 @@ function SecurityTab({ addToast }) {
const [weakWarning, setWeakWarning] = useState('')
const [acknowledgeWeak, setAcknowledgeWeak] = useState(false)
+ if (!isLocal) {
+ return (
+
+
+
{t('account.security.oauthOnly', { provider: user?.provider || 'OAuth' })}
+
+ )
+ }
+
const handleSubmit = async (e) => {
e.preventDefault()
if (newPw !== confirmPw) {
@@ -145,386 +112,134 @@ function SecurityTab({ addToast }) {
try {
await profileApi.changePassword(currentPw, newPw, acknowledgeWeak)
addToast(t('account.security.changed'), 'success')
- setCurrentPw('')
- setNewPw('')
- setConfirmPw('')
- setWeakWarning('')
- setAcknowledgeWeak(false)
+ setCurrentPw(''); setNewPw(''); setConfirmPw(''); setWeakWarning(''); setAcknowledgeWeak(false)
} catch (err) {
- if (err.body?.overridable) {
- setWeakWarning(err.body.error || err.message)
- } else {
- addToast(err.message, 'error')
- }
+ if (err.body?.overridable) setWeakWarning(err.body.error || err.message)
+ else addToast(err.message, 'error')
} finally {
setSaving(false)
}
}
- if (!isLocal) {
- return (
-
-
-
- {t('account.security.oauthOnly', { provider: user?.provider || 'OAuth' })}
-
-
- )
- }
-
return (
-
-
-
- setCurrentPw(e.target.value)}
- placeholder={t('account.security.currentPasswordPlaceholder')}
- disabled={saving}
- required
- />
-
-
- {
- setNewPw(e.target.value)
- setWeakWarning('')
- setAcknowledgeWeak(false)
- }}
- placeholder={t('account.security.newPasswordPlaceholder')}
- disabled={saving}
- required
- />
-
-
- setConfirmPw(e.target.value)}
- placeholder={t('account.security.confirmPasswordPlaceholder')}
- disabled={saving}
- required
- />
-
- {weakWarning && (
-
-
-
{weakWarning}
-
- setAcknowledgeWeak(e.target.checked)}
- disabled={saving}
- />
- Use this password anyway
-
-
-
- )}
+
+
+
{t('account.security.currentPassword')}
+
setCurrentPw(e.target.value)} />
+
{t('account.security.currentPasswordDescription')}
-
-
- {saving
- ? <> {t('account.security.changing')}>
- : t('account.security.changePassword')}
+
+
{t('account.security.newPassword')}
+
{ setNewPw(e.target.value); setWeakWarning(''); setAcknowledgeWeak(false) }}
+ />
+
{t('account.security.newPasswordDescription')}
+
+
+
{t('account.security.confirmPassword')}
+
setConfirmPw(e.target.value)} />
+
{t('account.security.confirmPasswordDescription')}
+
+ {weakWarning && (
+
+
{weakWarning}
+
+ setAcknowledgeWeak(e.target.checked)} />
+ {t('account.security.useAnyway')}
+
+
+ )}
+
+
+ {saving ? <> {t('account.security.changing')}> : t('account.security.changePassword')}
)
}
-function ApiKeysTab({ addToast }) {
+// The caller's own use over the last 30 days and the limits an admin set on
+// them. The full page, with every window and the cost estimate, is Usage.
+function UsageTab() {
const { t } = useTranslation('auth')
- const [keys, setKeys] = useState([])
- const [loading, setLoading] = useState(true)
- const [creating, setCreating] = useState(false)
- const [newKeyName, setNewKeyName] = useState('')
- const [newKeyPlaintext, setNewKeyPlaintext] = useState(null)
- const [revokingId, setRevokingId] = useState(null)
- const [confirmDialog, setConfirmDialog] = useState(null)
- const [pauseFormId, setPauseFormId] = useState(null)
- const [pauseMode, setPauseMode] = useState('indefinite')
- const [pauseUntil, setPauseUntil] = useState('')
- const [pauseBusyId, setPauseBusyId] = useState(null)
+ const [state, setState] = useState({ loading: true, error: null, usage: null, quotas: [] })
- const fetchKeys = useCallback(async () => {
- setLoading(true)
- try {
- const data = await apiKeysApi.list()
- setKeys(data.keys || [])
- } catch (err) {
- addToast(t('account.apiKeys.loadFailed', { message: err.message }), 'error')
- } finally {
- setLoading(false)
- }
- }, [addToast, t])
-
- useEffect(() => { fetchKeys() }, [fetchKeys])
-
- const handleCreate = async (e) => {
- e.preventDefault()
- if (!newKeyName.trim()) return
- setCreating(true)
- try {
- const data = await apiKeysApi.create(newKeyName.trim())
- setNewKeyPlaintext(data.key)
- setNewKeyName('')
- await fetchKeys()
- addToast(t('account.apiKeys.createdToast'), 'success')
- } catch (err) {
- addToast(t('account.apiKeys.createFailed', { message: err.message }), 'error')
- } finally {
- setCreating(false)
- }
- }
-
- const handleRevoke = async (id, name) => {
- setConfirmDialog({
- title: t('account.apiKeys.revokeTitle'),
- message: t('account.apiKeys.revokeMessage', { name }),
- confirmLabel: t('account.apiKeys.revoke'),
- danger: true,
- onConfirm: async () => {
- setConfirmDialog(null)
- setRevokingId(id)
- try {
- await apiKeysApi.revoke(id)
- setKeys(prev => prev.filter(k => k.id !== id))
- addToast(t('account.apiKeys.revoked'), 'success')
- } catch (err) {
- addToast(t('account.apiKeys.revokeFailed', { message: err.message }), 'error')
- } finally {
- setRevokingId(null)
- }
- },
+ useEffect(() => {
+ let live = true
+ Promise.all([
+ usageApi.getMyUsage('month'),
+ usageApi.getMyQuotas().catch(() => ({ quotas: [] })),
+ ]).then(([usage, q]) => {
+ if (live) setState({ loading: false, error: null, usage, quotas: q?.quotas || [] })
+ }).catch(err => {
+ if (live) setState({ loading: false, error: err, usage: null, quotas: [] })
})
- }
+ return () => { live = false }
+ }, [])
- const isPaused = (k) => k.disabled || (k.pausedUntil && new Date(k.pausedUntil) > new Date())
-
- const applyPause = async (id, disabled, pausedUntil) => {
- setPauseBusyId(id)
- try {
- await apiKeysApi.setPause(id, disabled, pausedUntil)
- setPauseFormId(null)
- setPauseUntil('')
- await fetchKeys()
- addToast(t(disabled || pausedUntil ? 'account.apiKeys.pausedToast' : 'account.apiKeys.resumedToast'), 'success')
- } catch (err) {
- addToast(t('account.apiKeys.pauseFailed', { message: err.message }), 'error')
- } finally {
- setPauseBusyId(null)
- }
- }
-
- const submitPause = (id) => {
- if (pauseMode === 'until') {
- if (!pauseUntil) return
- applyPause(id, false, new Date(pauseUntil).toISOString())
- } else {
- applyPause(id, true, null)
- }
- }
-
- const openPauseForm = (id) => {
- setPauseMode('indefinite')
- setPauseUntil('')
- setPauseFormId(id)
- }
-
- const copyToClipboard = (text) => {
- if (navigator.clipboard?.writeText) {
- navigator.clipboard.writeText(text).then(
- () => addToast(t('account.apiKeys.copiedToast'), 'success'),
- () => fallbackCopy(text),
- )
- } else {
- fallbackCopy(text)
- }
- }
-
- const fallbackCopy = (text) => {
- const ta = document.createElement('textarea')
- ta.value = text
- ta.style.position = 'fixed'
- ta.style.opacity = '0'
- document.body.appendChild(ta)
- ta.select()
- try {
- document.execCommand('copy')
- addToast(t('account.apiKeys.copiedToast'), 'success')
- } catch (_) {
- addToast(t('account.apiKeys.copyFailed'), 'error')
- }
- document.body.removeChild(ta)
+ if (state.loading) return
+ if (state.error) {
+ return {t('account.usage.loadFailed', { message: state.error.message })}
}
+ const buckets = state.usage?.usage || []
+ const totals = totalsOf(buckets)
+ const byModel = groupUsage(buckets, 'model').sort((a, b) => b.total - a.total).slice(0, 8)
+ const max = Math.max(1, ...byModel.map(r => r.total))
return (
-
- {/* Create key form */}
-
-
-
-
- setNewKeyName(e.target.value)}
- disabled={creating}
- maxLength={64}
- />
-
- {creating ? : <> {t('account.apiKeys.createButton')}>}
-
-
-
-
-
+
+
+ {t('account.usage.lede')} {t('account.usage.openFull')}
+
+
+
{t('account.usage.requests')} {compactCount(totals.requests)}
+
{t('account.usage.tokensIn')} {compactCount(totals.prompt)}
+
{t('account.usage.tokensOut')} {compactCount(totals.completion)}
+
- {/* Newly created key banner */}
- {newKeyPlaintext && (
-
-
-
- {t('account.apiKeys.copyNow')}
-
-
-
- {newKeyPlaintext}
-
- copyToClipboard(newKeyPlaintext)}>
-
-
- setNewKeyPlaintext(null)}>
-
-
-
-
- )}
-
- {/* Keys list */}
- {loading ? (
-
-
-
- ) : keys.length === 0 ? (
-
-
-
- {t('account.apiKeys.empty')}
-
-
+ {byModel.length === 0 ? (
+
{t('account.usage.none')}
) : (
-
- {keys.map((k) => (
-
-
-
-
-
- {k.name}
- {isPaused(k) && (
-
- {k.pausedUntil && !k.disabled
- ? t('account.apiKeys.pausedUntil', { date: formatDate(k.pausedUntil) })
- : t('account.apiKeys.paused')}
-
- )}
-
-
- {k.keyPrefix}... · {formatDate(k.createdAt)}
- {k.lastUsed && <> · {t('account.apiKeys.lastUsed', { date: formatDate(k.lastUsed) })}>}
-
-
- {isPaused(k) ? (
-
applyPause(k.id, false, null)}
- disabled={pauseBusyId === k.id}
- >
- {pauseBusyId === k.id ? : <> {t('account.apiKeys.resume')}>}
-
- ) : (
-
(pauseFormId === k.id ? setPauseFormId(null) : openPauseForm(k.id))}
- >
- {t('account.apiKeys.pause')}
-
- )}
-
handleRevoke(k.id, k.name)}
- disabled={revokingId === k.id}
- title={t('account.apiKeys.revokeKey')}
- >
- {revokingId === k.id ? : }
-
-
- {pauseFormId === k.id && !isPaused(k) && (
-
-
- setPauseMode('indefinite')}
- />
- {t('account.apiKeys.pauseIndefinitely')}
-
-
- setPauseMode('until')}
- />
- {t('account.apiKeys.pauseUntil')}
-
- {pauseMode === 'until' && (
- setPauseUntil(e.target.value)}
- />
- )}
- submitPause(k.id)}
- disabled={pauseBusyId === k.id || (pauseMode === 'until' && !pauseUntil)}
- >
- {t('account.apiKeys.pauseConfirm')}
-
-
- )}
-
+
+ {byModel.map(r => (
+
+ {r.name}
+
+ {compactCount(r.total)}
+
))}
-
+
+ )}
+
+
{t('account.usage.limits')} {t('account.usage.limitsSub')}
+ {state.quotas.length === 0 ? (
+
{t('account.usage.noLimits')}
+ ) : (
+
+ {state.quotas.flatMap(q => {
+ const where = `${q.model || t('account.usage.allModels')}, `
+ const per = `per ${WINDOW_WORDS[q.window] || q.window}`
+ const rows = []
+ if (q.max_total_tokens != null) rows.push({ id: `${q.id}-t`, text: `${where}${t('account.usage.tokens')} ${per}`, cur: q.current_total_tokens || 0, max: q.max_total_tokens, q })
+ if (q.max_requests != null) rows.push({ id: `${q.id}-r`, text: `${where}${t('account.usage.requests').toLowerCase()} ${per}`, cur: q.current_requests || 0, max: q.max_requests, q })
+ return rows
+ }).map(r => {
+ const pct = r.max > 0 ? Math.min(100, Math.round((r.cur / r.max) * 100)) : 0
+ return (
+
+ {r.text}
+ = 90 ? ' dk-progress--error' : ''}`} role="progressbar" aria-label={r.text} aria-valuemin="0" aria-valuemax="100" aria-valuenow={pct} style={{ '--dk-value': `${pct}%` }}>
+
+
+ {compactCount(r.cur)} of {compactCount(r.max)}
+ {r.q.resets_at && {t('account.usage.resets', { when: timeUntil(r.q.resets_at) })} }
+
+ )
+ })}
+
)}
-
setConfirmDialog(null)}
- />
)
}
@@ -533,47 +248,62 @@ export default function Account() {
const { addToast } = useOutletContext()
const { t } = useTranslation('auth')
const { authEnabled, user } = useAuth()
- const [activeTab, setActiveTab] = useState('profile')
+ const [params, setParams] = useSearchParams()
+ const tab = TABS.some(x => x.id === params.get('tab')) ? params.get('tab') : 'profile'
+ const refs = useRef({})
if (!authEnabled) {
return (
-
-
-
{t('account.unavailable')}
-
{t('account.unavailableText')}
+
+
+
{t('account.unavailable')}
+
{t('account.unavailableText')}
)
}
- // Filter tabs: hide security tab for OAuth-only users
- const isLocal = user?.provider === 'local'
- const visibleTabs = isLocal ? TABS : TABS.filter(tab => tab.id !== 'security')
+ const select = (id, focus = false) => {
+ setParams(id === 'profile' ? {} : { tab: id }, { replace: true })
+ if (focus) refs.current[id]?.focus()
+ }
+ const onKey = (e) => {
+ const i = TABS.findIndex(x => x.id === tab)
+ if (e.key === 'ArrowRight') { e.preventDefault(); select(TABS[(i + 1) % TABS.length].id, true) }
+ else if (e.key === 'ArrowLeft') { e.preventDefault(); select(TABS[(i + TABS.length - 1) % TABS.length].id, true) }
+ else if (e.key === 'Home') { e.preventDefault(); select(TABS[0].id, true) }
+ else if (e.key === 'End') { e.preventDefault(); select(TABS[TABS.length - 1].id, true) }
+ }
return (
-
- {/* Header */}
-
+
+
+
+
{t('account.title')}
+
{t('account.subtitle')}
+
+ {user?.name || user?.email} · {user?.role}
+
- {/* Tab bar */}
-
- {visibleTabs.map(tab => (
+
+ {TABS.map(x => (
setActiveTab(tab.id)}
- className={`auth-tab ${activeTab === tab.id ? 'active' : ''}`}
+ key={x.id} ref={el => { refs.current[x.id] = el }} type="button" role="tab" id={`ac-tab-${x.id}`}
+ className="dk-tab" aria-selected={tab === x.id} aria-controls={`ac-panel-${x.id}`} tabIndex={tab === x.id ? 0 : -1}
+ onClick={() => select(x.id)}
>
-
- {t(tab.labelKey)}
+ {t(x.labelKey)}
))}
- {/* Tab content */}
- {activeTab === 'profile' &&
}
- {activeTab === 'security' &&
}
- {activeTab === 'apikeys' &&
}
+
+ {tab === 'profile' &&
}
+ {tab === 'security' &&
}
+ {tab === 'keys' &&
}
+ {tab === 'usage' &&
}
+
)
}
diff --git a/core/http/react-ui/src/pages/Activity.jsx b/core/http/react-ui/src/pages/Activity.jsx
index 1df124bba..cda588a94 100644
--- a/core/http/react-ui/src/pages/Activity.jsx
+++ b/core/http/react-ui/src/pages/Activity.jsx
@@ -1,16 +1,16 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { useOperations } from '../hooks/useOperations'
-import { modelsApi, backendsApi, nodesApi } from '../utils/api'
-// eslint-plugin-react is not configured here, so eslint cannot see that an
-// import used only inside JSX is used at all. Link, PageHeader and
-// OperationCard are each referenced from JSX only.
-// eslint-disable-next-line no-unused-vars
import { Link, useOutletContext } from 'react-router-dom'
-// eslint-disable-next-line no-unused-vars
+import { useOperations } from '../hooks/useOperations'
+import { useOperationActions, isRetryable } from '../hooks/useOperationActions'
+import { CANCEL_UNDO_MS, useUndoableCancel } from '../hooks/useUndoableCancel'
+import { backendsApi, modelsApi } from '../utils/api'
import PageHeader from '../components/PageHeader'
-// eslint-disable-next-line no-unused-vars
import OperationCard from '../components/OperationCard'
+import HomeUndoToast from '../components/home/HomeUndoToast'
+import Icon from '../components/Icon'
+import './operate.css'
const FILTERS = [
{ id: 'all', labelKey: 'activity.filter.all' },
@@ -29,9 +29,9 @@ function matchesFilter(entry, filter) {
}
const outcomeIcon = {
- completed: 'fas fa-check',
- failed: 'fas fa-circle-exclamation',
- cancelled: 'fas fa-ban',
+ completed: 'check-circle',
+ failed: 'alert-circle',
+ cancelled: 'ban',
}
function timeOfDay(iso) {
@@ -72,61 +72,33 @@ function recordSummary(record, t) {
return duration ? t('activity.rowInstalled', { duration }) : t('activity.rowInstalledPlain')
}
-// Retry only ever means "install this again". A failed deletion would need the
-// delete endpoint and a staging operation is driven by the router rather than
-// by a user action, so neither is retryable from here.
-function isRetryable(op) {
- return Boolean(op.error) && !op.isDeletion && op.taskType !== 'staging'
-}
-
export default function Activity() {
- const { t } = useTranslation('admin')
+ const { t } = useTranslation('operate')
const outlet = useOutletContext()
const addToast = outlet?.addToast
- const { operations, history, fetchHistory, clearHistory, cancelOperation, pauseOperation, dismissFailedOp } = useOperations()
+ const { operations, history, fetchHistory, clearHistory, cancelOperation, pauseOperation } = useOperations()
+ const { retry, dismiss } = useOperationActions(addToast)
const [filter, setFilter] = useState('all')
useEffect(() => { fetchHistory() }, [fetchHistory])
- const retryOperation = useCallback(async (op) => {
- // Dismiss before reinstalling, never after: the reinstall reuses the same
- // opcache key, and overwriting a failed entry in place skips recordTerminal
- // so the failure would never reach the record. Dismissing first is what
- // puts it there.
- //
- // By jobID, because the guarantee only holds while both calls address the
- // same job. Two ops can share an id (a local and a node-scoped install of
- // one backend), and dismissing by id could retire the other one instead,
- // leaving this failure to be overwritten in place by the reinstall below.
- await dismissFailedOp(op.jobID)
- // fullName is the gallery-qualified id the install endpoints expect;
- // `name` has the repo prefix stripped for display. Node-scoped ops already
- // had their prefix removed server side, so fullName is the bare slug there.
- const target = op.fullName || op.id
+ const cancelling = useUndoableCancel({ operations, cancel: cancelOperation })
+
+ // Starting a cancelled install again. A job that was paused kept its partial
+ // download and continues; one that was cancelled starts over. The record does
+ // not say which, so the button says what it does in both cases.
+ const startAgain = useCallback(async (record) => {
try {
- if (op.nodeID) {
- await nodesApi.installBackend(op.nodeID, target)
- } else if (op.isBackend) {
- await backendsApi.install(target)
- } else {
- // The variant is not on the payload YET, so a pinned model retries as
- // an auto-select: someone who chose a specific quant, watched it fail
- // at 90% and pressed Retry gets a different build, with nothing on
- // screen saying so. Worth closing, and close to closed: ui_api.go
- // already reads ?variant= at enqueue and stores it on the ManagementOp,
- // so it only has to reach the /api/operations payload and this call.
- // Until then Retry stays, because nothing here distinguishes a pinned
- // install from an unpinned one and dropping it would cost every model
- // the button, including the common plain install that hit a network
- // error.
- await modelsApi.install(target)
- }
+ if (record.nodeID) throw new Error(t('activity.startAgainNode'))
+ if (record.isBackend) await backendsApi.install(record.id || record.name)
+ else await modelsApi.install(record.id || record.name)
+ addToast?.(t('activity.startedAgain', { name: record.name }), 'info')
} catch (err) {
addToast?.(t('activity.retryFailed', { message: err.message }), 'error')
}
- }, [dismissFailedOp, addToast, t])
+ }, [addToast, t])
- const live = useMemo(
+ const liveOps = useMemo(
() => operations.filter((op) => !op.error && matchesFilter(op, filter)),
[operations, filter],
)
@@ -147,10 +119,7 @@ export default function Activity() {
//
// "Nothing running" must also not be said while a failure is waiting for a
// decision, so both counts get a clause. Each clause is dropped when its
- // count is zero rather than rendered as a literal 0: the ordinary happy path
- // would otherwise put "0 needs attention" under the page title on every
- // render, which reads as a report about failures rather than the absence of
- // one.
+ // count is zero rather than rendered as a literal 0.
const runningTotal = operations.filter((op) => !op.error).length
const failingTotal = operations.length - runningTotal
const summaryClauses = []
@@ -159,28 +128,26 @@ export default function Activity() {
let supporting
if (summaryClauses.length > 0) supporting = summaryClauses.join(' ')
else if (history.length > 0) supporting = t('activity.summaryQuiet', { count: history.length })
- // Saying "0 operations since startup" directly above "No operations since
- // startup" states the same nothing twice.
else supporting = t('activity.summaryIdle')
return (
-
+
0 ? (
-
+
{t('activity.clearHistory')}
) : null}
/>
-
+
{FILTERS.map((entry) => (
setFilter(entry.id)}
>
@@ -189,53 +156,72 @@ export default function Activity() {
))}
- {live.length > 0 && (
-
-
- {t('activity.inProgress')} {live.length}
+ {liveOps.length > 0 && (
+
+
+ {t('activity.inProgress')} {liveOps.length}
- {live.map((op) => (
-
- ))}
+
+ {liveOps.map((op) => (
+
+ ))}
+
)}
{failing.length > 0 && (
-
-
+
+
{t('activity.needsAttention')} {failing.length}
- {failing.map((op) => (
-
- ))}
+
+ {failing.map((op) => (
+
+ ))}
+
)}
{records.length > 0 && (
-
-
+
+
{t('activity.record')} {records.length}
-
+
{records.map((record) => (
-
+
{record.name}
{recordSummary(record, t)}
{timeOfDay(record.finishedAt)}
-
- {record.isBackend ? t('activity.viewInBackends') : t('activity.viewInModels')}
-
+
+ {record.outcome === 'cancelled' && record.taskType !== 'deletion' && record.taskType !== 'staging' && (
+ startAgain(record)}
+ >
+ {t('activity.startAgain')}
+
+ )}
+
+ {record.isBackend ? t('activity.viewInBackends') : t('activity.viewInModels')}
+
+
))}
@@ -246,24 +232,37 @@ export default function Activity() {
{/* A chip that matches nothing is not an empty system. Telling someone
with three model installs on record that nothing has ever run, while
the line above them counts those same three, is simply false. */}
- {live.length === 0 && failing.length === 0 && records.length === 0 && (
+ {liveOps.length === 0 && failing.length === 0 && records.length === 0 && (
filter === 'all' ? (
-
-
-
{t('activity.emptyTitle')}
-
{t('activity.emptyBody')}
-
{t('activity.browseModels')}
+
+
+
{t('activity.emptyTitle')}
+
{t('activity.emptyBody')}
+
{t('activity.browseModels')}
) : (
-
-
-
{t('activity.emptyFiltered')}
-
setFilter('all')}>
+
+
+
{t('activity.emptyFiltered')}
+
setFilter('all')}>
{t('activity.showAll')}
)
)}
+
+ {cancelling.waitingFor && (
+
+ )}
)
}
diff --git a/core/http/react-ui/src/pages/AddNode.jsx b/core/http/react-ui/src/pages/AddNode.jsx
new file mode 100644
index 000000000..78d38da17
--- /dev/null
+++ b/core/http/react-ui/src/pages/AddNode.jsx
@@ -0,0 +1,32 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { Link, useOutletContext, useSearchParams } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import { useNodeList } from '../hooks/useSwarm'
+import AddNodeFlow from '../components/swarm/AddNodeFlow'
+import LoadingSpinner from '../components/LoadingSpinner'
+import Icon from '../components/Icon'
+import './swarm.css'
+
+// "Add a node": one command to run on the new machine, and a line that updates
+// when it arrives. On an install that is not distributed yet it starts with the
+// step that turns that on.
+export default function AddNode() {
+ const { addToast } = useOutletContext()
+ const { t } = useTranslation('swarm')
+ const { nodes, status, refetch } = useNodeList({ interval: 3000 })
+ const single = status === 'single'
+ const [params] = useSearchParams()
+
+ return (
+
+
{t('add.back')}
+
+ {t('add.title')}
+ {t('add.sub')}
+
+ {status === 'loading'
+ ?
+ :
}
+
+ )
+}
diff --git a/core/http/react-ui/src/pages/AgentChat.jsx b/core/http/react-ui/src/pages/AgentChat.jsx
deleted file mode 100644
index 3533dc7db..000000000
--- a/core/http/react-ui/src/pages/AgentChat.jsx
+++ /dev/null
@@ -1,811 +0,0 @@
-import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
-import { useParams, useOutletContext, useSearchParams } from 'react-router-dom'
-import { agentsApi } from '../utils/api'
-import { apiUrl } from '../utils/basePath'
-import { renderMarkdown, highlightAll, enhanceCodeBlocks } from '../utils/markdown'
-import { extractCodeArtifacts, extractMetadataArtifacts, renderMarkdownWithArtifacts } from '../utils/artifacts'
-import CanvasPanel from '../components/CanvasPanel'
-import ResourceCards from '../components/ResourceCards'
-import ConfirmDialog from '../components/ConfirmDialog'
-import { useAgentChat } from '../hooks/useAgentChat'
-import { relativeTime, normalizeTimestampMs } from '../utils/format'
-import { copyToClipboard } from '../utils/clipboard'
-import { chatHistoryFor } from '../utils/agentChatHistory'
-
-function getLastMessagePreview(conv) {
- if (!conv.messages || conv.messages.length === 0) return ''
- for (let i = conv.messages.length - 1; i >= 0; i--) {
- const msg = conv.messages[i]
- if (msg.sender === 'user' || msg.sender === 'agent') {
- return (msg.content || '').slice(0, 40).replace(/\n/g, ' ')
- }
- }
- return ''
-}
-
-function stripHtml(html) {
- if (!html) return ''
- return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()
-}
-
-function summarizeStatus(text) {
- const plain = stripHtml(text)
- // Extract a short label from "Thinking: ...", "Reasoning: ...", etc.
- const match = plain.match(/^(Thinking|Reasoning|Action taken|Result)[:\s]*/i)
- if (match) return match[1]
- return plain.length > 60 ? plain.slice(0, 60) + '...' : plain
-}
-
-function AgentActivityGroup({ items }) {
- const [expanded, setExpanded] = useState(false)
- if (!items || items.length === 0) return null
-
- const latest = items[items.length - 1]
- const summary = summarizeStatus(latest.content)
-
- return (
-
-
-
-
-
-
setExpanded(!expanded)}>
-
- {summary}
- {items.length > 1 && +{items.length - 1} }
-
-
-
- {expanded && (
-
- {items.map((item, idx) => (
-
-
{new Date(item.timestamp).toLocaleTimeString()}
-
{item.content}
-
- ))}
-
- )}
-
-
- )
-}
-
-export default function AgentChat() {
- const { name } = useParams()
- const { addToast } = useOutletContext()
- const [searchParams] = useSearchParams()
- const userId = searchParams.get('user_id') || undefined
-
- const {
- conversations, activeConversation, activeId,
- addConversation, switchConversation, deleteConversation,
- deleteAllConversations, renameConversation, addMessage, addMessageToConversation, clearMessages,
- } = useAgentChat(name)
-
- const messages = activeConversation?.messages || []
-
- const [input, setInput] = useState('')
- const [processingChatId, setProcessingChatId] = useState(null)
- const [canvasMode, setCanvasMode] = useState(false)
- const [canvasOpen, setCanvasOpen] = useState(false)
- const [selectedArtifactId, setSelectedArtifactId] = useState(null)
- const [sidebarOpen, setSidebarOpen] = useState(true)
- const [editingName, setEditingName] = useState(null)
- const [editName, setEditName] = useState('')
- const [chatSearch, setChatSearch] = useState('')
- const [confirmDialog, setConfirmDialog] = useState(null)
- const [streamContent, setStreamContent] = useState('')
- const [streamReasoning, setStreamReasoning] = useState('')
- const [streamToolCalls, setStreamToolCalls] = useState([])
- const messagesEndRef = useRef(null)
- const messagesRef = useRef(null)
- const textareaRef = useRef(null)
- const stickToBottomRef = useRef(true)
- const eventSourceRef = useRef(null)
- const messageIdCounter = useRef(0)
- const addMessageRef = useRef(addMessage)
- addMessageRef.current = addMessage
- const addMessageToConvRef = useRef(addMessageToConversation)
- addMessageToConvRef.current = addMessageToConversation
- const activeIdRef = useRef(activeId)
- activeIdRef.current = activeId
- // Tracks which conversation initiated the current request — SSE responses
- // are pinned to this ID so switching tabs doesn't misdirect them.
- const processingChatIdRef = useRef(null)
- // Maps backend messageID → conversationId for robust SSE routing across navigations.
- const pendingRequestsRef = useRef(new Map())
-
- const processing = processingChatId === activeId
-
- const nextId = useCallback(() => {
- messageIdCounter.current += 1
- return messageIdCounter.current
- }, [])
-
- // Connect to SSE endpoint — only reconnect when agent name changes
- useEffect(() => {
- const url = apiUrl(agentsApi.sseUrl(name, userId))
- const es = new EventSource(url)
- eventSourceRef.current = es
-
- es.addEventListener('json_message', (e) => {
- try {
- const data = JSON.parse(e.data)
- const sender = data.sender || (data.role === 'user' ? 'user' : 'agent')
- // Skip user message echoes — already added locally in handleSend
- if (sender === 'user') return
- const msg = {
- id: nextId(),
- sender,
- content: data.content || data.message || '',
- // Backend timestamp encoding varies by deploy mode (RFC3339 string,
- // Unix ms, or Unix ns); normalize to JS milliseconds.
- timestamp: normalizeTimestampMs(data.timestamp),
- }
- if (data.metadata && Object.keys(data.metadata).length > 0) {
- msg.metadata = data.metadata
- }
- // Route to conversation: try messageID mapping first, then processingChatIdRef, then active
- const msgId = data.message_id || ''
- const baseId = msgId.replace(/-agent$/, '')
- const targetId = pendingRequestsRef.current.get(baseId)
- || processingChatIdRef.current
- || activeIdRef.current
- addMessageToConvRef.current(targetId, msg)
- // Clear streaming + processing state when the final agent message arrives
- if (sender === 'agent') {
- pendingRequestsRef.current.delete(baseId)
- processingChatIdRef.current = null
- setProcessingChatId(null)
- setStreamContent('')
- setStreamReasoning('')
- setStreamToolCalls([])
- }
- } catch (_err) {
- // ignore malformed messages
- }
- })
-
- es.addEventListener('json_message_status', (e) => {
- try {
- const data = JSON.parse(e.data)
- if (data.status === 'processing') {
- // Track which conversation is processing so responses go to the right place.
- // Only set if not already pinned by handleSend (avoids race when user switches conversations).
- if (!processingChatIdRef.current) {
- processingChatIdRef.current = activeIdRef.current
- setProcessingChatId(activeIdRef.current)
- }
- setStreamContent('')
- setStreamReasoning('')
- setStreamToolCalls([])
- } else if (data.status === 'completed') {
- // Don't clear processingChatIdRef, processingChatId, or streaming state here —
- // they'll be cleared when the agent's json_message arrives,
- // so reasoning and tool calls remain visible until the response replaces them
- // and late-arriving messages still route to the correct conversation.
- }
- } catch (_err) {
- // ignore
- }
- })
-
- es.addEventListener('stream_event', (e) => {
- try {
- const data = JSON.parse(e.data)
- if (data.type === 'reasoning') {
- setStreamReasoning(prev => prev + (data.content || ''))
- } else if (data.type === 'content') {
- setStreamContent(prev => prev + (data.content || ''))
- } else if (data.type === 'tool_call') {
- const name = data.tool_name || ''
- const args = data.tool_args || ''
- setStreamToolCalls(prev => {
- if (name) {
- return [...prev, { name, args }]
- }
- if (prev.length === 0) return prev
- const updated = [...prev]
- updated[updated.length - 1] = { ...updated[updated.length - 1], args: updated[updated.length - 1].args + args }
- return updated
- })
- } else if (data.type === 'tool_result') {
- const tname = data.tool_name || ''
- setStreamToolCalls(prev => {
- const updated = [...prev]
- const idx = updated.findLastIndex(tc => tc.name === tname && !tc.result)
- if (idx >= 0) {
- updated[idx] = { ...updated[idx], result: data.tool_result || 'done' }
- }
- return updated
- })
- } else if (data.type === 'done') {
- // One agent turn runs several internal LLM generations (tool
- // selection, reasoning, final answer) over the same SSE channel;
- // 'done' marks the boundary between them. Reset the accumulated
- // text so an internal generation's output doesn't merge into the
- // next one's bubble — the final json_message carries the
- // authoritative full answer anyway.
- setStreamContent('')
- setStreamReasoning('')
- }
- } catch (_err) {
- // ignore
- }
- })
-
- es.addEventListener('status', (e) => {
- const text = e.data
- if (!text) return
- const targetId = processingChatIdRef.current || activeIdRef.current
- addMessageToConvRef.current(targetId, {
- id: nextId(),
- sender: 'system',
- content: text,
- timestamp: Date.now(),
- })
- })
-
- es.addEventListener('json_error', (e) => {
- try {
- const data = JSON.parse(e.data)
- addToast(data.error || data.message || 'Agent error', 'error')
- } catch (_err) {
- addToast('Agent error', 'error')
- }
- processingChatIdRef.current = null
- setProcessingChatId(null)
- })
-
- es.onerror = () => {
- addToast('SSE connection lost, attempting to reconnect...', 'warning')
- }
-
- return () => {
- es.close()
- eventSourceRef.current = null
- processingChatIdRef.current = null
- pendingRequestsRef.current.clear()
- }
- }, [name, userId, addToast, nextId])
-
- // Track whether the user is pinned to the bottom. If they scroll up
- // while a response is streaming, stop forcing them back down.
- useEffect(() => {
- const el = messagesRef.current
- if (!el) return
- const onScroll = () => {
- const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight
- stickToBottomRef.current = distanceFromBottom < 80
- }
- el.addEventListener('scroll', onScroll, { passive: true })
- return () => el.removeEventListener('scroll', onScroll)
- }, [])
-
- // Auto-scroll only when the user hasn't scrolled away from the bottom.
- useEffect(() => {
- if (!stickToBottomRef.current) return
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
- }, [messages, streamContent, streamReasoning, streamToolCalls])
-
- // When switching conversations, snap to bottom and re-pin.
- useEffect(() => {
- stickToBottomRef.current = true
- messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
- }, [activeId])
-
- // Highlight code blocks + add per-block copy buttons (parity with Chat). A
- // MutationObserver on the messages container fires reliably for streamed and
- // loaded messages; it disconnects while mutating so its own edits do not
- // retrigger it.
- useEffect(() => {
- const el = messagesRef.current
- if (!el) return
- let obs
- const run = () => {
- obs?.disconnect()
- highlightAll(el)
- enhanceCodeBlocks(el)
- obs?.observe(el, { childList: true, subtree: true })
- }
- obs = new MutationObserver(run)
- run()
- return () => obs.disconnect()
- }, [])
-
- const agentMessages = useMemo(() => messages.filter(m => m.sender === 'agent'), [messages])
- const codeArtifacts = useMemo(
- () => canvasMode ? extractCodeArtifacts(agentMessages, 'sender', 'agent') : [],
- [agentMessages, canvasMode]
- )
- const metaArtifacts = useMemo(
- () => canvasMode ? extractMetadataArtifacts(messages, name) : [],
- [messages, canvasMode, name]
- )
- const artifacts = useMemo(() => [...codeArtifacts, ...metaArtifacts], [codeArtifacts, metaArtifacts])
-
- const prevArtifactCountRef = useRef(0)
- useEffect(() => {
- prevArtifactCountRef.current = artifacts.length
- }, [activeId])
- useEffect(() => {
- if (artifacts.length > prevArtifactCountRef.current && artifacts.length > 0) {
- setSelectedArtifactId(artifacts[artifacts.length - 1].id)
- if (!canvasOpen) setCanvasOpen(true)
- }
- prevArtifactCountRef.current = artifacts.length
- }, [artifacts])
-
- // Event delegation for artifact cards
- useEffect(() => {
- const el = messagesRef.current
- if (!el || !canvasMode) return
- const handler = (e) => {
- const openBtn = e.target.closest('.artifact-card-open')
- const downloadBtn = e.target.closest('.artifact-card-download')
- const card = e.target.closest('.artifact-card')
- if (downloadBtn) {
- e.stopPropagation()
- const id = downloadBtn.dataset.artifactId
- const artifact = artifacts.find(a => a.id === id)
- if (artifact?.code) {
- const blob = new Blob([artifact.code], { type: 'text/plain' })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = artifact.title || 'download.txt'
- a.click()
- URL.revokeObjectURL(url)
- }
- return
- }
- if (openBtn || card) {
- const id = (openBtn || card).dataset.artifactId
- if (id) {
- setSelectedArtifactId(id)
- setCanvasOpen(true)
- }
- }
- }
- el.addEventListener('click', handler)
- return () => el.removeEventListener('click', handler)
- }, [canvasMode, artifacts])
-
- const openArtifactById = useCallback((id) => {
- setSelectedArtifactId(id)
- setCanvasOpen(true)
- }, [])
-
- const handleSend = useCallback(async () => {
- const msg = input.trim()
- if (!msg || processing) return
- setInput('')
- if (textareaRef.current) textareaRef.current.style.height = 'auto'
- // The conversation's earlier turns, taken before this message is added: the
- // agent sees only the conversation the user is looking at.
- const history = chatHistoryFor(messages)
- // Add user message locally immediately (like standard chat)
- addMessage({ id: nextId(), sender: 'user', content: msg, timestamp: Date.now() })
- setProcessingChatId(activeId)
- processingChatIdRef.current = activeId
- try {
- const resp = await agentsApi.chat(name, msg, userId, history)
- // Map backend messageID → conversation so SSE events route correctly
- if (resp && resp.message_id) {
- pendingRequestsRef.current.set(resp.message_id, activeId)
- }
- } catch (err) {
- addToast(`Failed to send message: ${err.message}`, 'error')
- processingChatIdRef.current = null
- setProcessingChatId(null)
- }
- }, [input, processing, name, activeId, addToast, userId, addMessage, nextId, messages])
-
- const handleKeyDown = (e) => {
- if (
- e.key === 'Enter' &&
- !e.shiftKey &&
- !e.ctrlKey &&
- !e.metaKey &&
- !e.altKey &&
- !e.nativeEvent?.isComposing &&
- e.keyCode !== 229
- ) {
- e.preventDefault()
- handleSend()
- }
- }
-
- const copyMessage = async (content) => {
- const ok = await copyToClipboard(content)
- addToast(
- ok ? 'Copied to clipboard' : 'Could not copy to clipboard',
- ok ? 'success' : 'error',
- ok ? 2000 : 3000,
- )
- }
-
- const senderToRole = (sender) => {
- if (sender === 'agent') return 'assistant'
- if (sender === 'user') return 'user'
- return 'system'
- }
-
- const startRename = (id, currentName) => {
- setEditingName(id)
- setEditName(currentName)
- }
-
- const finishRename = () => {
- if (editingName && editName.trim()) {
- renameConversation(editingName, editName.trim())
- }
- setEditingName(null)
- }
-
- const filteredConversations = chatSearch.trim()
- ? conversations.filter(c => {
- const q = chatSearch.toLowerCase()
- if ((c.name || '').toLowerCase().includes(q)) return true
- return c.messages?.some(m => {
- return (m.content || '').toLowerCase().includes(q)
- })
- })
- : conversations
-
- return (
-
- {/* Conversation sidebar */}
-
-
- addConversation()}>
- New Chat
-
- {
- setConfirmDialog({
- title: 'Delete All Conversations',
- message: 'Delete all conversations? This cannot be undone.',
- confirmLabel: 'Delete All',
- danger: true,
- onConfirm: () => { setConfirmDialog(null); deleteAllConversations() },
- })
- }}
- title="Delete all conversations"
- style={{ padding: '6px 8px' }}
- >
-
-
-
-
-
-
-
- setChatSearch(e.target.value)}
- placeholder="Search conversations..."
- />
- {chatSearch && (
- setChatSearch('')}>
-
-
- )}
-
-
-
-
- {filteredConversations.map(conv => (
-
switchConversation(conv.id)}
- >
-
- {editingName === conv.id ? (
-
setEditName(e.target.value)}
- onBlur={finishRename}
- onKeyDown={(e) => e.key === 'Enter' && finishRename()}
- autoFocus
- onClick={(e) => e.stopPropagation()}
- style={{ padding: '2px 4px', fontSize: '0.8125rem' }}
- />
- ) : (
-
-
- startRename(conv.id, conv.name)}
- >
- {processingChatId === conv.id && }
- {conv.name}
-
- {relativeTime(conv.updatedAt)}
-
-
- {getLastMessagePreview(conv) || 'No messages yet'}
-
-
- )}
-
- { e.stopPropagation(); startRename(conv.id, conv.name) }}
- title="Rename"
- >
-
-
- {conversations.length > 1 && (
- { e.stopPropagation(); deleteConversation(conv.id) }}
- title="Delete conversation"
- >
-
-
- )}
-
-
- ))}
- {filteredConversations.length === 0 && chatSearch && (
-
- No conversations match your search
-
- )}
-
-
-
-
- {/* Header */}
-
-
setSidebarOpen(prev => !prev)}
- title={sidebarOpen ? 'Hide chat list' : 'Show chat list'}
- style={{ flexShrink: 0 }}
- >
-
-
-
-
- {name}
-
-
-
-
- Canvas
-
- {
- setCanvasMode(e.target.checked)
- if (!e.target.checked) setCanvasOpen(false)
- }}
- />
-
-
-
-
-
- {canvasMode && artifacts.length > 0 && !canvasOpen && (
-
{ setSelectedArtifactId(artifacts[0]?.id); setCanvasOpen(true) }}
- title="Open canvas panel"
- >
- {artifacts.length}
-
- )}
-
- Status
-
-
clearMessages()} disabled={messages.length === 0} title="Clear chat history">
- Clear
-
-
-
-
- {/* Messages */}
-
- {messages.length === 0 && !processing && (
-
-
-
-
-
Chat with {name}
-
Send a message to start a conversation with this agent.
-
- Enter to send
- Shift+Enter for newline
-
-
- )}
- {(() => {
- const elements = []
- let systemBuf = []
- const flushSystem = (key) => {
- if (systemBuf.length > 0) {
- elements.push(
)
- systemBuf = []
- }
- }
- messages.forEach((msg, idx) => {
- const role = senderToRole(msg.sender)
- if (role === 'system') {
- systemBuf.push(msg)
- return
- }
- flushSystem(idx)
- elements.push(
-
-
-
-
-
-
- {role === 'user' ? (
-
{msg.content}
- ) : (
-
- )}
-
- {role === 'assistant' && msg.metadata && (
-
- )}
-
- copyMessage(msg.content)} title="Copy">
-
-
-
-
- {new Date(msg.timestamp).toLocaleTimeString()}
-
-
-
- )
- })
- flushSystem('end')
- return elements
- })()}
- {processing && (streamReasoning || streamContent || streamToolCalls.length > 0) && (
-
-
-
-
-
- {streamReasoning && (
-
-
-
- {streamContent ? 'Thinking' : 'Thinking...'}
-
-
-
-
- )}
- {streamToolCalls.length > 0 && (
-
- {streamToolCalls.map((tc, idx) => (
-
-
-
- {tc.name}
-
- {tc.result ? 'done' : 'calling...'}
-
-
- {tc.args && (
-
- {(() => { try { return JSON.stringify(JSON.parse(tc.args), null, 2) } catch { return tc.args } })()}
-
- )}
- {tc.result && (
-
- {tc.result}
-
- )}
-
- ))}
-
- )}
- {streamContent && (
-
-
-
-
- )}
-
-
- )}
- {processing && !streamReasoning && !streamContent && streamToolCalls.length === 0 && (
-
- )}
-
-
-
- {/* Input area */}
-
-
- {
- setInput(e.target.value)
- const ta = e.target
- ta.style.height = 'auto'
- ta.style.height = Math.min(ta.scrollHeight, 150) + 'px'
- }}
- onKeyDown={handleKeyDown}
- placeholder="Type a message..."
- disabled={processing}
- rows={1}
- />
-
-
-
-
-
-
- {canvasOpen && artifacts.length > 0 && (
-
setCanvasOpen(false)}
- />
- )}
- setConfirmDialog(null)}
- />
-
- )
-}
diff --git a/core/http/react-ui/src/pages/AgentCreate.jsx b/core/http/react-ui/src/pages/AgentCreate.jsx
index 9c3da60e8..617b1d6fc 100644
--- a/core/http/react-ui/src/pages/AgentCreate.jsx
+++ b/core/http/react-ui/src/pages/AgentCreate.jsx
@@ -1,12 +1,24 @@
-import { useState, useEffect, useMemo, useRef } from 'react'
-import { useParams, useNavigate, useLocation, useOutletContext, useSearchParams } from 'react-router-dom'
-import { agentsApi, skillsApi } from '../utils/api'
+import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
+import { createPortal } from 'react-dom'
+// eslint-disable-next-line no-unused-vars
+import { Link, useParams, useNavigate, useLocation, useOutletContext, useSearchParams } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import { agentsApi, agentCollectionsApi, skillsApi, chatApi } from '../utils/api'
+import { useLibraryFacts } from '../hooks/useLibraryFacts'
+import { estimateTokens, skillLoadTokens, skillUsers, sumTokens } from '../utils/library'
+import { AGENT_TEMPLATES, diffConfig, displayValue, maskSecrets, summarise } from '../utils/agentConfigTools'
+import './agents.css'
+import './library.css'
+// eslint-disable-next-line no-unused-vars
import SearchableModelSelect from '../components/SearchableModelSelect'
-import PageHeader from '../components/PageHeader'
+// eslint-disable-next-line no-unused-vars
import UnsavedChangesGuard from '../components/UnsavedChangesGuard'
import { CAP_CHAT, CAP_TRANSCRIPT, CAP_TTS } from '../utils/capabilities'
+// eslint-disable-next-line no-unused-vars
import Toggle from '../components/Toggle'
+// eslint-disable-next-line no-unused-vars
import SettingRow from '../components/SettingRow'
+import Icon from '../components/Icon'
// --- MCP STDIO helpers ---
@@ -57,6 +69,7 @@ function buildStdioJson(list) {
// --- Form field components ---
+// eslint-disable-next-line no-unused-vars
function FormField({ field, value, onChange, disabled }) {
const id = `field-${field.name}`
const label = field.required
@@ -153,6 +166,7 @@ function FormField({ field, value, onChange, disabled }) {
// --- ConfigForm for connectors/actions/filters/dynamic_prompts ---
+// eslint-disable-next-line no-unused-vars
function ConfigForm({ items, fieldGroups, onChange, onRemove, onAdd, itemType, typeField, addButtonText }) {
const typeOptions = [
{ value: '', label: `Select a ${itemType} type` },
@@ -188,7 +202,7 @@ function ConfigForm({ items, fieldGroups, onChange, onRemove, onAdd, itemType, t
{label} #{index + 1}
onRemove(index)}>
-
+
@@ -241,7 +255,7 @@ function ConfigForm({ items, fieldGroups, onChange, onRemove, onAdd, itemType, t
)
})}
- {addButtonText}
+ {addButtonText}
)
@@ -250,16 +264,16 @@ function ConfigForm({ items, fieldGroups, onChange, onRemove, onAdd, itemType, t
// --- Section definitions ---
const SECTIONS = [
- { id: 'BasicInfo', icon: 'fa-info-circle', label: 'Basic Info' },
- { id: 'ModelSettings', icon: 'fa-brain', label: 'Model Settings' },
- { id: 'MemorySettings', icon: 'fa-database', label: 'Memory' },
- { id: 'PromptsGoals', icon: 'fa-bullseye', label: 'Prompts & Goals' },
- { id: 'AdvancedSettings', icon: 'fa-cog', label: 'Advanced' },
- { id: 'MCP', icon: 'fa-server', label: 'MCP Servers' },
- { id: 'connectors', icon: 'fa-plug', label: 'Connectors' },
- { id: 'actions', icon: 'fa-bolt', label: 'Actions' },
- { id: 'filters', icon: 'fa-filter', label: 'Filters' },
- { id: 'dynamic_prompts', icon: 'fa-wand-magic-sparkles', label: 'Dynamic Prompts' },
+ { id: 'BasicInfo', icon: 'info', label: 'Basic Info' },
+ { id: 'ModelSettings', icon: 'brain', label: 'Model Settings' },
+ { id: 'MemorySettings', icon: 'database', label: 'Memory' },
+ { id: 'PromptsGoals', icon: 'target', label: 'Prompts & Goals' },
+ { id: 'AdvancedSettings', icon: 'settings', label: 'Advanced' },
+ { id: 'MCP', icon: 'server', label: 'MCP Servers' },
+ { id: 'connectors', icon: 'plug', label: 'Connectors' },
+ { id: 'actions', icon: 'bolt', label: 'Actions' },
+ { id: 'filters', icon: 'filter', label: 'Filters' },
+ { id: 'dynamic_prompts', icon: 'sparkles', label: 'Dynamic Prompts' },
]
// Fields handled by custom editors in the MCP section
@@ -287,14 +301,22 @@ export default function AgentCreate() {
const navigate = useNavigate()
const location = useLocation()
const { addToast } = useOutletContext()
+ const { t } = useTranslation(['agents', 'library'])
const [searchParams] = useSearchParams()
const userId = searchParams.get('user_id') || undefined
+ const templateId = searchParams.get('template') || ''
const isEdit = !!name
const importedConfig = location.state?.importedConfig || null
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
- const [activeSection, setActiveSection] = useState('BasicInfo')
+ // Sections fold. Basics is open at first; the rest open on demand.
+ const [openSections, setOpenSections] = useState(() => new Set(['BasicInfo']))
+ const [savedConfig, setSavedConfig] = useState(null)
+ const [previewOpen, setPreviewOpen] = useState(false)
+ const [previewTab, setPreviewTab] = useState('config')
+ const [draftText, setDraftText] = useState('')
+ const [drafting, setDrafting] = useState(false)
const [meta, setMeta] = useState(null)
const [form, setForm] = useState({})
// Snapshot of the form as first loaded, for the unsaved-changes guard.
@@ -309,6 +331,15 @@ export default function AgentCreate() {
const [stdioServers, setStdioServers] = useState([])
const [availableSkills, setAvailableSkills] = useState([])
const [selectedSkills, setSelectedSkills] = useState([])
+ // Who else uses a skill, and whether this agent's collection exists, so the
+ // pickers can say "not used yet" without sending anyone to another page.
+ const library = useLibraryFacts()
+ const [collectionNames, setCollectionNames] = useState(null)
+ useEffect(() => {
+ agentCollectionsApi.list(false)
+ .then(data => setCollectionNames((Array.isArray(data?.collections) ? data.collections : []).map(c => (typeof c === 'string' ? c : c?.name))))
+ .catch(() => setCollectionNames(null))
+ }, [])
// Group metadata Fields by tags.section
const fieldsBySection = useMemo(() => {
@@ -331,7 +362,7 @@ export default function AgentCreate() {
const hiddenInDistributed = new Set(['actions', 'connectors', 'filters', 'dynamic_prompts'])
items = items.filter(s => !hiddenInDistributed.has(s.id))
}
- if (isEdit) items.push({ id: 'export', icon: 'fa-download', label: 'Export' })
+ if (isEdit) items.push({ id: 'export', icon: 'download', label: 'Export' })
return items
}, [isEdit, meta])
@@ -377,6 +408,17 @@ export default function AgentCreate() {
if (Array.isArray(sourceConfig.selected_skills)) setSelectedSkills(sourceConfig.selected_skills)
}
+ // A starting point fills the form; nothing is saved until the person saves.
+ if (!sourceConfig && templateId) {
+ const tpl = AGENT_TEMPLATES.find(x => x.id === templateId)
+ if (tpl) {
+ for (const key of ['name', 'description', 'system_prompt']) {
+ if (key in initialForm && tpl[key]) initialForm[key] = tpl[key]
+ }
+ }
+ }
+
+ if (config) setSavedConfig(config)
initialFormRef.current = initialForm
setForm(initialForm)
} catch (err) {
@@ -386,14 +428,45 @@ export default function AgentCreate() {
}
}
init()
- }, [name, isEdit, importedConfig, addToast])
+ }, [name, isEdit, importedConfig, templateId, addToast])
const updateField = (fieldName, value) => {
setForm(prev => ({ ...prev, [fieldName]: value }))
}
+ // The config exactly as it is saved: the form, the lists and the MCP and
+ // skills choices. The preview shows this object and Save sends it.
+ const buildPayload = () => {
+ const payload = { ...form }
+ // Convert number fields
+ if (meta?.Fields) {
+ for (const field of meta.Fields) {
+ if (field.type === 'number' && payload[field.name] !== '' && payload[field.name] != null) {
+ payload[field.name] = Number(payload[field.name])
+ }
+ }
+ }
+ payload.connectors = connectors
+ payload.actions = actions
+ payload.filters = filters
+ payload.dynamic_prompts = dynamicPrompts
+ payload.mcp_servers = mcpHttpServers.filter(s => s.url)
+ // Send STDIO servers as JSON string in expected format
+ if (mcpJsonMode && mcpRawJson.trim()) {
+ // In JSON editor mode, use the raw JSON directly
+ payload.mcp_stdio_servers = mcpRawJson
+ } else if (stdioServers.length > 0) {
+ payload.mcp_stdio_servers = buildStdioJson(stdioServers)
+ }
+ // Send selected skills
+ if (selectedSkills.length > 0) {
+ payload.selected_skills = selectedSkills
+ }
+ return payload
+ }
+
const handleSubmit = async (e) => {
- e.preventDefault()
+ e?.preventDefault?.()
if (!form.name?.toString().trim()) {
addToast('Agent name is required', 'warning')
return
@@ -404,32 +477,7 @@ export default function AgentCreate() {
}
setSaving(true)
try {
- const payload = { ...form }
- // Convert number fields
- if (meta?.Fields) {
- for (const field of meta.Fields) {
- if (field.type === 'number' && payload[field.name] !== '' && payload[field.name] != null) {
- payload[field.name] = Number(payload[field.name])
- }
- }
- }
- payload.connectors = connectors
- payload.actions = actions
- payload.filters = filters
- payload.dynamic_prompts = dynamicPrompts
- payload.mcp_servers = mcpHttpServers.filter(s => s.url)
- // Send STDIO servers as JSON string in expected format
- if (mcpJsonMode && mcpRawJson.trim()) {
- // In JSON editor mode, use the raw JSON directly
- payload.mcp_stdio_servers = mcpRawJson
- } else if (stdioServers.length > 0) {
- payload.mcp_stdio_servers = buildStdioJson(stdioServers)
- }
- // Send selected skills
- if (selectedSkills.length > 0) {
- payload.selected_skills = selectedSkills
- }
-
+ const payload = buildPayload()
if (isEdit) {
await agentsApi.update(name, payload, userId)
addToast(`Agent "${form.name}" updated`, 'success')
@@ -485,7 +533,29 @@ export default function AgentCreate() {
))
}
- const renderSection = () => {
+ // Used by, from the other agents' saved configs. This agent's own choice is
+ // the checkbox, so it is left out.
+ const allSkillNames = availableSkills.map(sk => sk.name)
+ const skillHint = (skill) => {
+ const others = library.status === 'ready'
+ ? skillUsers(library.agents.filter(a => a.name !== (isEdit ? name : form.name)), skill.name, allSkillNames)
+ : null
+ const use = others === null ? '' : (others.length === 0 ? t('library:usedBy.none') : t('library:usedBy.line', { name: others.map(o => o.name).join(', ') }))
+ const cost = form.skills_mode === 'tools' ? t('library:add.toolsMode') : t('library:add.tokens', { count: estimateTokens(skill.content || '') })
+ return [use, cost].filter(Boolean).join(' · ')
+ }
+ const pickedSkills = selectedSkills.length === 0 ? availableSkills : availableSkills.filter(sk => selectedSkills.includes(sk.name))
+ const skillsBudget = form.skills_mode === 'tools'
+ ? t('library:add.toolsMode')
+ : t('library:agentBudget', { count: sumTokens(pickedSkills.map(sk => skillLoadTokens(sk, form))) })
+ const ownName = (isEdit ? name : form.name) || ''
+ const kbNote = collectionNames === null
+ ? t('library:agentKb.reads', { name: ownName || '...' })
+ : collectionNames.includes(ownName)
+ ? t('library:agentKb.exists', { name: ownName })
+ : t('library:agentKb.created', { name: ownName || '...' })
+
+ const renderSection = (activeSection) => {
switch (activeSection) {
case 'BasicInfo':
case 'ModelSettings':
@@ -499,7 +569,7 @@ export default function AgentCreate() {
{activeSection === 'AdvancedSettings' && form.enable_skills && availableSkills.length > 0 && (
-
+
Select Skills
Choose which skills this agent can use. If none selected, all available skills are included.
@@ -549,12 +619,17 @@ export default function AgentCreate() {
{skill.description.length > 80 ? skill.description.slice(0, 80) + '...' : skill.description}
)}
+
{skillHint(skill)}
))}
+ {skillsBudget}
)}
+ {activeSection === 'MemorySettings' && form.enable_kb && (
+ {kbNote}
+ )}
>
)
@@ -613,7 +688,7 @@ export default function AgentCreate() {
}
setMcpJsonMode(!mcpJsonMode)
}}>
-
+
{mcpJsonMode ? ' Form Editor' : ' JSON Editor'}
@@ -641,7 +716,7 @@ export default function AgentCreate() {
{/* STDIO Servers */}
-
+
STDIO Servers
Local command-based MCP servers (e.g. docker run).
@@ -650,7 +725,7 @@ export default function AgentCreate() {
Server #{idx + 1}
removeStdioServer(idx)}>
-
+
@@ -667,14 +742,14 @@ export default function AgentCreate() {
Arguments
addArg(idx)} style={{ fontSize: '0.7rem', padding: '2px 8px' }}>
- Add
+ Add
{(server.args || []).length === 0 &&
No arguments.
}
{(server.args || []).map((arg, ai) => (
updateArg(idx, ai, e.target.value)} placeholder="argument" />
- removeArg(idx, ai)}>
+ removeArg(idx, ai)}>
))}
@@ -682,28 +757,28 @@ export default function AgentCreate() {
Environment Variables
addEnv(idx)} style={{ fontSize: '0.7rem', padding: '2px 8px' }}>
- Add
+ Add
{(server.env || []).length === 0 &&
No environment variables.
}
{(server.env || []).map((env, ei) => (
updateEnv(idx, ei, e.target.value)} placeholder="KEY=VALUE" />
- removeEnv(idx, ei)}>
+ removeEnv(idx, ei)}>
))}
))}
- Add STDIO Server
+ Add STDIO Server
{/* HTTP Servers */}
-
+
HTTP Servers
MCP servers connected over HTTP.
@@ -712,7 +787,7 @@ export default function AgentCreate() {
HTTP Server #{idx + 1}
removeMcpHttp(idx)}>
-
+
{(meta?.MCPServers || [{ name: 'url', label: 'URL', type: 'text' }, { name: 'token', label: 'API Key', type: 'password' }]).map(f => (
@@ -728,7 +803,7 @@ export default function AgentCreate() {
))}
- Add HTTP Server
+ Add HTTP Server
>
@@ -805,7 +880,7 @@ export default function AgentCreate() {
className="btn btn-primary"
style={{ display: 'inline-flex', alignItems: 'center', textDecoration: 'none' }}
>
-
Export Agent
+
Export Agent
)
@@ -817,8 +892,8 @@ export default function AgentCreate() {
if (loading) {
return (
-
-
+
+
)
}
@@ -826,178 +901,323 @@ export default function AgentCreate() {
const dirty = initialFormRef.current != null &&
JSON.stringify(form) !== JSON.stringify(initialFormRef.current)
+ const leave = () => navigate(isEdit ? `/app/agents/${encodeURIComponent(name)}${userId ? `?user_id=${encodeURIComponent(userId)}` : ''}` : '/app/agents')
+
+ // What each fold says about itself: ready mark, one line, and, when editing,
+ // how many of its fields differ from the saved agent.
+ const payload = buildPayload()
+ const baseline = {}
+ if (savedConfig) for (const k of Object.keys(payload)) if (k in savedConfig) baseline[k] = savedConfig[k]
+ const diff = savedConfig ? diffConfig(baseline, payload) : diffConfig({}, payload)
+ const changedKeys = new Set(savedConfig ? diff.map(d => d.key) : [])
+ const listOf = { connectors, actions, filters, dynamic_prompts: dynamicPrompts }
+
+ const foldInfo = (s) => {
+ const fields = fieldsBySection[s.id] || []
+ let keys = fields.map(f => f.name)
+ let summary = ''
+ let state = 'empty'
+ if (s.id in listOf) {
+ const n = listOf[s.id].length
+ keys = [s.id]
+ summary = n ? t('create.countConfigured', { count: n }) : t('create.noneYet')
+ state = n ? 'ready' : 'empty'
+ } else if (s.id === 'MCP') {
+ keys = [...keys, 'mcp_servers', 'mcp_stdio_servers']
+ const n = stdioServers.length + mcpHttpServers.filter(x => x.url).length
+ summary = n ? t('create.countServers', { count: n }) : t('create.noneYet')
+ state = n ? 'ready' : 'empty'
+ } else if (s.id === 'export') {
+ summary = t('create.exportSummary')
+ state = 'ready'
+ } else {
+ summary = summarise(fields, form)
+ if (s.id === 'AdvancedSettings') {
+ keys = [...keys, 'selected_skills']
+ if (form.enable_skills && selectedSkills.length) summary = [summary, t('create.countSkills', { count: selectedSkills.length })].filter(Boolean).join(', ')
+ }
+ state = summary ? 'ready' : 'empty'
+ }
+ if (s.id === 'BasicInfo' && !form.name?.toString().trim()) { state = 'needs'; summary = t('create.needsName') }
+ if (s.id === 'ModelSettings' && !form.model?.toString().trim()) { state = 'needs'; summary = t('create.needsModel') }
+ const changed = keys.filter(k => changedKeys.has(k)).length
+ return { summary, state, changed }
+ }
+
+ const toggleSection = (id) => setOpenSections(prev => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
+
+ const applyTemplate = (tpl) => {
+ setForm(prev => {
+ const next = { ...prev }
+ for (const key of ['name', 'description', 'system_prompt']) {
+ if (!(key in prev)) continue
+ if (key === 'name' && isEdit) continue
+ next[key] = tpl[key] || ''
+ }
+ return next
+ })
+ setOpenSections(prev => new Set([...prev, 'BasicInfo']))
+ }
+
+ const draft = async () => {
+ const sentence = draftText.trim()
+ if (!sentence) return
+ if (!form.model?.toString().trim()) {
+ addToast(t('create.draftNeedsModel'), 'warning')
+ setOpenSections(prev => new Set([...prev, 'ModelSettings']))
+ return
+ }
+ setDrafting(true)
+ try {
+ const res = await chatApi.complete({
+ model: form.model,
+ temperature: 0.2,
+ messages: [
+ { role: 'system', content: 'You write the configuration of a software agent. Answer with one JSON object and nothing else. Keys: "name" (lowercase words joined by dashes, at most 30 characters), "description" (one sentence), "system_prompt" (the instructions the agent follows, three to six sentences).' },
+ { role: 'user', content: sentence },
+ ],
+ })
+ const text = res?.choices?.[0]?.message?.content || ''
+ const match = text.match(/\{[\s\S]*\}/)
+ const parsed = match ? JSON.parse(match[0]) : null
+ if (!parsed || typeof parsed !== 'object') throw new Error(t('create.draftBad'))
+ setForm(prev => {
+ const next = { ...prev }
+ for (const key of ['name', 'description', 'system_prompt']) {
+ if (key in prev && typeof parsed[key] === 'string' && parsed[key].trim() && !(key === 'name' && isEdit)) next[key] = parsed[key].trim()
+ }
+ return next
+ })
+ setOpenSections(prev => new Set([...prev, 'BasicInfo', 'PromptsGoals']))
+ addToast(t('create.drafted'), 'success')
+ } catch (err) {
+ addToast(t('create.draftFailed', { message: err.message }), 'error')
+ } finally {
+ setDrafting(false)
+ }
+ }
+
+ const title = isEdit ? t('create.titleEdit', { name }) : importedConfig ? t('create.titleImport') : t('create.titleCreate')
+ const saveLabel = isEdit ? t('create.save') : importedConfig ? t('create.titleImport') : t('create.titleCreate')
+
return (
-
+
-
+
+
+
+
{isEdit ? name : t('agent.back')}
+
{title}
+
{isEdit ? t('create.subEdit') : t('create.subCreate')}
+
+
+ { setPreviewTab('config'); setPreviewOpen(true) }} data-testid="agent-preview-open">
+ {t('create.preview')}
+ {diff.length > 0 && {diff.length} }
+
+
+ {t('create.discard')}
+
+
+ {saving
+ ? <> {t('create.saving')}>
+ : <> {saveLabel}>
+ }
+
+
+
- navigate('/app/agents')}>
- Back
+ {!isEdit && (
+
+ {t('create.startFrom')}
+
+ {AGENT_TEMPLATES.map(tpl => (
+ applyTemplate(tpl)} data-template={tpl.id}>
+ {t(`templates.${tpl.id}.label`)}
+
+ ))}
+
+
+ setDraftText(e.target.value)}
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); draft() } }}
+ />
+
+ {drafting ? : } {t('create.draft')}
+
+
+ {t('create.draftNote')}
+
+ )}
+
+
+ {visibleSections.map(s => {
+ const open = openSections.has(s.id)
+ const info = foldInfo(s)
+ return (
+
+
+ toggleSection(s.id)}
+ >
+
+ {s.label}
+ {info.summary}
+ {info.changed > 0 ? {t('create.changed', { count: info.changed })} : }
+
+
+
+ {open && (
+
+ {renderSection(s.id)}
+
+ )}
+
+ )
+ })}
+
+
+
+
+ {t('create.discard')}
+
+
+ {saving
+ ? <> {t('create.saving')}>
+ : <> {saveLabel}>
+ }
- }
- />
-
-
-
-
-
-
- {visibleSections.map(s => {
- let count = 0
- if (s.id === 'connectors') count = connectors.length
- else if (s.id === 'actions') count = actions.length
- else if (s.id === 'filters') count = filters.length
- else if (s.id === 'dynamic_prompts') count = dynamicPrompts.length
- return (
- setActiveSection(s.id)}
- >
-
- {s.label}
- {count > 0 && {count} }
-
- )
- })}
-
-
-
-
-
-
-
- s.id === activeSection)?.icon || 'fa-cog'} text-primary`} />
- {visibleSections.find(s => s.id === activeSection)?.label || activeSection}
-
- {renderSection()}
-
-
-
- navigate('/app/agents')}>
- Cancel
-
-
- {saving
- ? <> Saving...>
- : <> {isEdit ? 'Save Changes' : importedConfig ? 'Import Agent' : 'Create Agent'}>
- }
-
-
-
+
+ {previewOpen && (
+
{ setPreviewOpen(false); handleSubmit() }}
+ onClose={() => setPreviewOpen(false)}
+ />
+ )}
)
}
+
+// The config as it will be saved, and what differs from the saved agent.
+// Secret values are hidden here; they are saved as typed.
+// eslint-disable-next-line no-unused-vars
+function PreviewSheet({ tab, onTab, payload, diff, isEdit, saving, onSave, onClose }) {
+ const { t } = useTranslation('agents')
+ const sheetRef = useRef(null)
+ const closeRef = useRef(null)
+ const openerRef = useRef(null)
+
+ useEffect(() => {
+ openerRef.current = document.activeElement
+ closeRef.current?.focus()
+ const onKey = (e) => {
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose(); return }
+ if (e.key !== 'Tab' || !sheetRef.current) return
+ const focusable = Array.from(sheetRef.current.querySelectorAll('button:not([disabled]), [tabindex]:not([tabindex="-1"])'))
+ if (focusable.length === 0) return
+ const first = focusable[0]
+ const last = focusable[focusable.length - 1]
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
+ }
+ window.addEventListener('keydown', onKey, true)
+ return () => {
+ window.removeEventListener('keydown', onKey, true)
+ const el = openerRef.current
+ if (el && document.contains(el)) el.focus?.()
+ }
+ }, [onClose])
+
+ const shown = useCallback((v) => displayValue(maskSecrets(v)), [])
+ const json = useMemo(() => JSON.stringify(maskSecrets(payload), null, 2), [payload])
+
+ return createPortal(
+
+
e.stopPropagation()}
+ >
+
+
+
+
{t('create.previewTitle')}
+
{isEdit ? t('create.previewEdit') : t('create.previewNew')}
+
+
+
+
+
+
+
+ onTab('config')}>{t('create.tabConfig')}
+ onTab('changes')}>
+ {t('create.tabChanges')}{diff.length > 0 && {diff.length} }
+
+
+ {tab === 'config' ? (
+
+
{t('create.configNote')}
+
{json}
+
+ ) : (
+
+ {diff.length === 0 ? (
+
{isEdit ? t('create.noChanges') : t('create.nothingSet')}
+ ) : (
+ <>
+ {!isEdit &&
{t('create.allNew')}
}
+
+ {diff.map(d => (
+
+
{d.key}
+
+
{isEdit ? t('create.saved') : t('create.default')} {shown(d.before) || t('create.empty')}
+
{t('create.new')} {shown(d.after) || t('create.empty')}
+
+
+ ))}
+
+ >
+ )}
+
+ )}
+
+
+ {t('create.close')}
+
+ {isEdit ? t('create.save') : t('create.titleCreate')}
+
+
+
+
,
+ document.body,
+ )
+}
diff --git a/core/http/react-ui/src/pages/AgentJobDetails.jsx b/core/http/react-ui/src/pages/AgentJobDetails.jsx
index 66f4a46d3..e05fa06ab 100644
--- a/core/http/react-ui/src/pages/AgentJobDetails.jsx
+++ b/core/http/react-ui/src/pages/AgentJobDetails.jsx
@@ -1,102 +1,111 @@
import { useState, useEffect, useRef } from 'react'
-import { useParams, useNavigate, useOutletContext } from 'react-router-dom'
+// eslint-disable-next-line no-unused-vars
+import { Link, useParams, useNavigate, useOutletContext } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
import { agentJobsApi } from '../utils/api'
+import { copyToClipboard } from '../utils/clipboard'
+import { isActive, jobDurationMs } from '../utils/agentJobs'
+import { firstLine, formatDuration } from '../utils/agentRuns'
+// eslint-disable-next-line no-unused-vars
+import { JobMark, GappedPrompt } from '../components/agents/JobBits'
+import { dayWhen } from '../utils/agentJobText'
+// eslint-disable-next-line no-unused-vars
+import { Chip } from '../components/agents/AgentBits'
+// eslint-disable-next-line no-unused-vars
+import Prose from '../components/agents/Prose'
+// eslint-disable-next-line no-unused-vars
import LoadingSpinner from '../components/LoadingSpinner'
-import PageHeader from '../components/PageHeader'
+import Icon from '../components/Icon'
+import './chat.css'
+import './agents.css'
+import './agent-jobs.css'
-const traceColors = {
- reasoning: { bg: 'rgba(99,102,241,0.1)', border: 'rgba(99,102,241,0.3)', icon: 'fa-brain', color: 'var(--color-primary)' },
- tool_call: { bg: 'rgba(139,92,246,0.1)', border: 'rgba(139,92,246,0.3)', icon: 'fa-wrench', color: 'var(--color-accent)' },
- tool_result: { bg: 'rgba(34,197,94,0.1)', border: 'rgba(34,197,94,0.3)', icon: 'fa-check', color: 'var(--color-success)' },
- status: { bg: 'rgba(245,158,11,0.1)', border: 'rgba(245,158,11,0.3)', icon: 'fa-info-circle', color: 'var(--color-warning)' },
- stream_reasoning: { bg: 'rgba(99,102,241,0.06)', border: 'rgba(99,102,241,0.2)', icon: 'fa-lightbulb', color: 'var(--color-primary)' },
- stream_content: { bg: 'rgba(59,130,246,0.08)', border: 'rgba(59,130,246,0.25)', icon: 'fa-pen-nib', color: 'var(--color-info, #3b82f6)' },
- stream_tool_call: { bg: 'rgba(139,92,246,0.06)', border: 'rgba(139,92,246,0.2)', icon: 'fa-bolt', color: 'var(--color-accent)' },
+const TRACE_ICON = {
+ reasoning: 'brain', tool_call: 'wrench', tool_result: 'check', status: 'info',
+ stream_reasoning: 'lightbulb', stream_content: 'pencil', stream_tool_call: 'bolt',
}
-function TraceCard({ trace, index }) {
- const [expanded, setExpanded] = useState(true)
- const style = traceColors[trace.type] || traceColors.status
+function clock(ts) {
+ const d = new Date(ts)
+ return Number.isNaN(d.getTime()) ? '' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
+}
+// One entry of what the server recorded: a row that opens into what it holds.
+// eslint-disable-next-line no-unused-vars
+function TraceRow({ trace, index }) {
+ const { t } = useTranslation('agents')
+ const [open, setOpen] = useState(false)
+ const args = trace.arguments == null ? '' : (typeof trace.arguments === 'string' ? trace.arguments : JSON.stringify(trace.arguments, null, 2))
+ const title = trace.tool_name || t(`jobs.job.traceType.${trace.type}`, { defaultValue: trace.type || '?' })
+ const sub = trace.tool_name ? t(`jobs.job.traceType.${trace.type}`, { defaultValue: trace.type || '' }) : firstLine(trace.content, 110)
+ const has = !!(trace.content || args)
return (
-
-
setExpanded(!expanded)}
- className="ajd-trace__toggle"
- >
-
-
- {index + 1}
-
-
-
- {trace.type || 'unknown'}
-
- {trace.tool_name && (
-
- {trace.tool_name}
-
- )}
- {trace.timestamp && (
-
- {new Date(trace.timestamp).toLocaleTimeString()}
-
- )}
-
-
+
+ setOpen(v => !v)}>
+
+
+ {trace.tool_name ? {title} : title}
+ {sub && {sub} }
+
+ {clock(trace.timestamp)}
+
+ {index + 1}
- {expanded && (
-
- {trace.content && (
-
- {trace.content}
-
- )}
- {trace.arguments && (
-
-
Arguments:
-
- {typeof trace.arguments === 'string' ? trace.arguments : JSON.stringify(trace.arguments, null, 2)}
-
-
+ {open && (
+
+ {trace.content &&
{trace.content} }
+ {args && (
+ <>
+
{t('jobs.job.arguments')}
+
{args}
+ >
)}
)}
-
+
)
}
export default function AgentJobDetails() {
const { id } = useParams()
+ return
+}
+
+// eslint-disable-next-line no-unused-vars
+function JobDocument({ id }) {
+ const { t } = useTranslation('agents')
const navigate = useNavigate()
const { addToast } = useOutletContext()
const [job, setJob] = useState(null)
const [task, setTask] = useState(null)
const [loading, setLoading] = useState(true)
+ const [again, setAgain] = useState(false)
const intervalRef = useRef(null)
+ const taskRef = useRef(null)
useEffect(() => {
- if (!id) return
+ if (!id) return undefined
const fetchJob = async () => {
try {
const data = await agentJobsApi.getJob(id)
setJob(data)
- // Fetch associated task data
- if (data?.task_id && !task) {
+ // The task the job belongs to, for its name and prompt.
+ if (data?.task_id && !taskRef.current) {
+ taskRef.current = true
agentJobsApi.getTask(data.task_id).then(setTask).catch(() => {})
}
- // Stop polling when job is done
- if (data && data.status !== 'running' && data.status !== 'pending') {
+ // Stop polling when the job is done
+ if (data && !isActive(data)) {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
} catch (err) {
- addToast(`Failed to load job: ${err.message}`, 'error')
+ addToast(t('jobs.job.loadFailed', { message: err.message }), 'error')
} finally {
setLoading(false)
}
@@ -105,256 +114,167 @@ export default function AgentJobDetails() {
fetchJob()
intervalRef.current = setInterval(fetchJob, 2000)
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
- }, [id, addToast])
+ }, [id, addToast, t])
- const handleCancel = async () => {
+ const cancel = async () => {
try {
await agentJobsApi.cancelJob(id)
- addToast('Job cancelled', 'success')
+ addToast(t('jobs.job.cancelled'), 'success')
} catch (err) {
- addToast(`Cancel failed: ${err.message}`, 'error')
+ addToast(t('jobs.job.cancelFailed', { message: err.message }), 'error')
}
}
- const formatDate = (d) => d ? new Date(d).toLocaleString() : '-'
-
- const statusBadge = (status) => {
- const map = {
- pending: { cls: 'badge-warning', icon: 'fa-clock' },
- running: { cls: 'badge-info', icon: 'fa-spinner fa-spin' },
- completed: { cls: 'badge-success', icon: 'fa-check' },
- failed: { cls: 'badge-error', icon: 'fa-xmark' },
- cancelled: { cls: '', icon: 'fa-ban' },
+ const runAgain = async () => {
+ setAgain(true)
+ try {
+ const res = await agentJobsApi.rerunJob(job)
+ addToast(t('jobs.job.again'), 'success')
+ if (res?.job_id) navigate(`/app/agent-jobs/jobs/${res.job_id}`)
+ } catch (err) {
+ addToast(t('jobs.job.againFailed', { message: err.message }), 'error')
+ } finally {
+ setAgain(false)
}
- const m = map[status] || { cls: '', icon: 'fa-question' }
+ }
+
+ const copyLink = async () => {
+ const ok = await copyToClipboard(`${window.location.origin}${window.location.pathname}`)
+ addToast(t(ok ? 'jobs.job.linkCopied' : 'jobs.job.linkNotCopied'), ok ? 'success' : 'error', 2000)
+ }
+
+ if (loading) return
+ if (!job) {
return (
-
- {status || 'unknown'}
-
+
+
+
{t('jobs.job.back')}
+
+
{t('jobs.job.notFoundTitle')}
+
{t('jobs.job.notFoundText')}
+
+
+
)
}
- // Render the prompt with parameters substituted
- const renderPrompt = () => {
- if (!task?.prompt || !job?.parameters) return null
- let rendered = task.prompt
- Object.entries(job.parameters).forEach(([key, value]) => {
- rendered = rendered.replace(new RegExp(`\\{\\{\\s*\\.${key}\\s*\\}\\}`, 'g'), value)
- })
- return rendered
- }
-
- if (loading) return
- if (!job) return (
-
-
-
-
Job not found
-
navigate('/app/agent-jobs')}> Back
-
-
- )
-
- const renderedPrompt = renderPrompt()
+ const active = isActive(job)
+ const failed = job.status === 'failed'
+ const took = jobDurationMs(job)
+ const params = job.parameters && typeof job.parameters === 'object' ? job.parameters : {}
+ const paramCount = Object.keys(params).length
const traces = Array.isArray(job.traces) ? job.traces : []
+ const taskName = task?.name || job.task_id
+ const title = taskName ? t('jobs.job.title', { task: taskName, when: dayWhen(job.created_at) }) : t('jobs.job.titleUnknown', { id: job.id.slice(0, 8) })
+ const who = job.triggered_by || 'manual'
+ const media = [['images', job.images], ['videos', job.videos], ['audios', job.audios], ['files', job.files]].filter(([, list]) => list?.length)
+ const resultText = typeof job.result === 'string' ? job.result : job.result ? JSON.stringify(job.result, null, 2) : ''
+ const errorText = typeof job.error === 'string' ? job.error : job.error ? JSON.stringify(job.error, null, 2) : ''
return (
-
-
- navigate('/app/agent-jobs')}>
- Back
-
- {(job.status === 'running' || job.status === 'pending') && (
-
- Cancel
-
- )}
-
- }
- />
-
- {/* Status Card */}
-
-
-
-
- Job Status
-
- {statusBadge(job.status)}
-
-
-
-
-
-
Triggered By
-
{job.triggered_by || 'manual'}
-
-
-
Created
-
{formatDate(job.created_at)}
-
-
-
Started
-
{formatDate(job.started_at)}
-
-
-
Completed
-
{formatDate(job.completed_at)}
+
+
+
+
{t('jobs.job.back')}
+
+ {active
+ ? {t('jobs.job.cancel')}
+ : {t('jobs.job.runAgain')} }
+ {job.task_id && {t('jobs.job.openTask')}}
-
- {/* Prompt Template */}
- {task?.prompt && (
-
-
-
- Agent Prompt Template
-
-
- {task.prompt}
-
-
- )}
-
- {/* Cron Parameters */}
- {job.triggered_by === 'cron' && job.cron_parameters && Object.keys(job.cron_parameters).length > 0 && (
-
-
-
- Cron Parameters
-
-
- {Object.entries(job.cron_parameters).map(([k, v]) => (
-
- {k}={v}
-
- ))}
+
+ {title}
+
+
+ {t('jobs.job.startedBy', { who: t(`jobs.trigger.${who}`, { defaultValue: who }) })}
+ {took != null && {t('jobs.job.tookFor', { time: formatDuration(took) })} }
+ {job.id}
-
- )}
-
- {/* Job Parameters */}
- {job.parameters && Object.keys(job.parameters).length > 0 && (
-
-
-
- Job Parameters
-
-
- {Object.entries(job.parameters).map(([k, v]) => (
-
- {k}={v}
-
- ))}
+
+
+ {window.location.pathname}
+ {t('jobs.job.copyLink')}
+
+ {task?.model && {task.model} }
-
- )}
+
- {/* Rendered Prompt */}
- {renderedPrompt && renderedPrompt !== task?.prompt && (
-
-
-
- Rendered Prompt
-
-
- {renderedPrompt}
-
-
- )}
+
+
{t('jobs.job.task')}
+ {task?.prompt ? (
+ <>
+
+ {paramCount > 0 ? t('jobs.job.taskNoteParams', { count: paramCount }) : t('jobs.job.taskNote')}
+ >
+ ) : (
+ {t('jobs.job.taskUnknown')}
+ )}
+ {paramCount > 0 && !task?.prompt && (
+
+ {Object.entries(params).map(([k, v]) => {k}={String(v)} )}
+
+ )}
+
- {/* Result */}
- {job.result && (
-
-
-
- Result
-
-
- {typeof job.result === 'string' ? job.result : JSON.stringify(job.result, null, 2)}
-
-
- )}
+
+
{t('jobs.job.outcome')}
+ {job.status === 'completed' && (resultText ? : {t('jobs.job.outcomeNone')}
)}
+ {failed && (
+
+
{t('jobs.job.failTitle')}
+
{t('jobs.job.failText')}
+
{errorText || t('jobs.job.failNoMessage')}
+
+ {t('jobs.job.runAgainNow')}
+ {job.task_id && {t('jobs.job.openTask')}}
+
+
+ )}
+ {active && (
+
+
+ {job.status === 'pending' ? t('jobs.job.outcomeWaiting') : t('jobs.job.outcomeWorking')}
+
+ )}
+ {job.status === 'cancelled' && {t('jobs.job.outcomeCancelled')}
}
+
- {/* Error */}
- {job.error && (
-
-
-
- Error
-
-
- {typeof job.error === 'string' ? job.error : JSON.stringify(job.error, null, 2)}
-
-
- )}
+ {media.length > 0 && (
+
+
+
+ {media.map(([kind, list]) => {`${list.length} ${t(`jobs.run.${kind}`).toLowerCase()}`} )}
+
+
+ )}
- {/* Execution Traces */}
- {traces.length > 0 && (
-
-
-
- Execution Traces ({traces.length} steps)
-
- {traces.map((trace, i) => (
-
- ))}
-
- )}
-
- {/* Running indicator */}
- {(job.status === 'running' || job.status === 'pending') && (
-
-
- Polling for updates every 2 seconds...
-
- )}
-
- {/* Webhook Status */}
- {(job.webhook_sent !== undefined || job.webhook_error) && (
-
-
-
- Webhook Status
-
-
+ {(job.webhook_sent || job.webhook_error) && (
+
+
{t('jobs.job.delivery')}
{job.webhook_sent ? (
- <>
- Delivered
- {job.webhook_sent_at && (
-
- at {formatDate(job.webhook_sent_at)}
-
- )}
- >
- ) : job.webhook_error ? (
- <>
- Failed
- {job.webhook_error}
- >
+ {job.webhook_sent_at ? t('jobs.job.deliverySentAt', { when: dayWhen(job.webhook_sent_at) }) : t('jobs.job.deliverySent')}
) : (
- Pending
+ {t('jobs.job.deliveryFailed', { message: job.webhook_error })}
)}
-
-
- )}
+
+ )}
+
+
+
{t('jobs.job.trace')}
+ {traces.length === 0 ? (
+ {t('jobs.job.noTrace')}
+ ) : (
+ <>
+ {t('jobs.job.traceNote', { count: traces.length })}
+
+ {traces.map((trace, i) => )}
+
+ >
+ )}
+
+
)
}
diff --git a/core/http/react-ui/src/pages/AgentJobs.jsx b/core/http/react-ui/src/pages/AgentJobs.jsx
index a9f400f42..0a4dc3c19 100644
--- a/core/http/react-ui/src/pages/AgentJobs.jsx
+++ b/core/http/react-ui/src/pages/AgentJobs.jsx
@@ -1,53 +1,102 @@
-import { useState, useEffect, useCallback, useRef } from 'react'
-import { useNavigate, useOutletContext, useLocation } from 'react-router-dom'
+// eslint-disable-next-line no-unused-vars
+import { Fragment, useState, useEffect, useCallback, useMemo } from 'react'
+// eslint-disable-next-line no-unused-vars
+import { Link, useNavigate, useOutletContext, useLocation } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
import { agentJobsApi, modelsApi } from '../utils/api'
import { fromState } from '../utils/editorNav'
import { useModels } from '../hooks/useModels'
import { useAuth } from '../context/AuthContext'
import { useUserMap } from '../hooks/useUserMap'
+import { useModelRemoval } from '../hooks/useModelRemoval'
+import { UNDO_MS } from '../utils/cleanupPlan'
+import {
+ failingTasks, groupByDay, isActive, jobDurationMs, jobLine, jobsOf, statusCounts, weekSummary,
+} from '../utils/agentJobs'
+import { formatDuration } from '../utils/agentRuns'
+// eslint-disable-next-line no-unused-vars
import LoadingSpinner from '../components/LoadingSpinner'
-import PageHeader from '../components/PageHeader'
-import { fileToBase64 } from '../utils/api'
-import Modal from '../components/Modal'
+// eslint-disable-next-line no-unused-vars
import UserGroupSection from '../components/UserGroupSection'
+// eslint-disable-next-line no-unused-vars
import ConfirmDialog from '../components/ConfirmDialog'
+// eslint-disable-next-line no-unused-vars
+import ActionMenu from '../components/ActionMenu'
+// eslint-disable-next-line no-unused-vars
+import HomeUndoToast from '../components/home/HomeUndoToast'
+// eslint-disable-next-line no-unused-vars
+import RunTaskDialog from '../components/agents/RunTaskDialog'
+// eslint-disable-next-line no-unused-vars
+import { JobMark, JobStrip, ScheduleText } from '../components/agents/JobBits'
+import { jobSentence, scheduleWords } from '../utils/agentJobText'
+import { formatWhen } from '../components/agents/AgentBits'
+import Icon from '../components/Icon'
+import './agents.css'
+import './agent-jobs.css'
+
+const STATUS_ORDER = ['completed', 'failed', 'running', 'pending', 'cancelled']
+
+// One sentence about the week, built from the jobs the server returned.
+// eslint-disable-next-line no-unused-vars
+function WeekSentence({ jobs, tasks }) {
+ const { t } = useTranslation('agents')
+ if (jobs.length === 0) return
{t('jobs.summary.none')}
+ const w = weekSummary(jobs)
+ if (w.total === 0) return
{t('jobs.summary.quiet')}
+ const parts = []
+ if (w.finished) parts.push(t('jobs.summary.finished', { count: w.finished }))
+ if (w.failed) parts.push(t('jobs.summary.failed', { count: w.failed }))
+ if (w.stopped) parts.push(t('jobs.summary.stopped', { count: w.stopped }))
+ if (w.active) parts.push(t('jobs.summary.running', { count: w.active }))
+ const failing = failingTasks(tasks, jobs).map(x => x.name || x.id)
+ const names = failing.length > 3
+ ? t('jobs.summary.andMore', { names: failing.slice(0, 3).join(', '), count: failing.length - 3 })
+ : failing.join(', ')
+ return (
+
+ {t('jobs.summary.lead', { count: w.total })} {' '}
+ {parts.join(', ')}.
+ {failing.length > 0 && <> {t('jobs.summary.lastFailed', { count: failing.length, names })}>}
+
+ )
+}
+
+function clockOf(iso) {
+ const d = new Date(iso)
+ return Number.isNaN(d.getTime()) ? '' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
+}
export default function AgentJobs() {
const { addToast } = useOutletContext()
+ const { t, i18n } = useTranslation('agents')
const navigate = useNavigate()
const location = useLocation()
const { models } = useModels()
const { isAdmin, authEnabled, user } = useAuth()
const userMap = useUserMap()
- const [activeTab, setActiveTab] = useState('tasks')
const [tasks, setTasks] = useState([])
const [jobs, setJobs] = useState([])
const [loading, setLoading] = useState(true)
- const [jobFilter, setJobFilter] = useState('all')
+ const [statusFilter, setStatusFilter] = useState('all')
+ const [taskFilter, setTaskFilter] = useState('all')
+ const [tasksOpen, setTasksOpen] = useState(true)
+ const [expanded, setExpanded] = useState(() => new Set())
const [hasMCPModels, setHasMCPModels] = useState(false)
const [confirmDialog, setConfirmDialog] = useState(null)
const [taskUserGroups, setTaskUserGroups] = useState(null)
const [jobUserGroups, setJobUserGroups] = useState(null)
-
- // Execute modal state
- const [executeModal, setExecuteModal] = useState(null)
- const [executeTab, setExecuteTab] = useState('parameters')
- const [executeParams, setExecuteParams] = useState('')
- const [executeMultimedia, setExecuteMultimedia] = useState({ images: [], videos: [], audios: [], files: [] })
- const [executing, setExecuting] = useState(false)
- const fileInputRef = useRef(null)
- const fileTypeRef = useRef('images')
+ const [runTask, setRunTask] = useState(null)
const fetchData = useCallback(async () => {
const allUsers = isAdmin && authEnabled
try {
- const [t, j] = await Promise.allSettled([
+ const [tr, jr] = await Promise.allSettled([
agentJobsApi.listTasks(allUsers),
agentJobsApi.listJobs(allUsers),
])
- if (t.status === 'fulfilled') {
- const tv = t.value
- // Handle wrapped response (admin) or flat array
+ if (tr.status === 'fulfilled') {
+ const tv = tr.value
+ // The admin response wraps the list; a plain response is the list.
if (Array.isArray(tv)) {
setTasks(tv)
setTaskUserGroups(null)
@@ -55,12 +104,12 @@ export default function AgentJobs() {
setTasks(Array.isArray(tv.tasks) ? tv.tasks : [])
setTaskUserGroups(tv.user_groups || null)
} else {
- setTasks(Array.isArray(tv) ? tv : [])
+ setTasks([])
setTaskUserGroups(null)
}
}
- if (j.status === 'fulfilled') {
- const jv = j.value
+ if (jr.status === 'fulfilled') {
+ const jv = jr.value
if (Array.isArray(jv)) {
setJobs(jv)
setJobUserGroups(null)
@@ -68,16 +117,16 @@ export default function AgentJobs() {
setJobs(Array.isArray(jv.jobs) ? jv.jobs : [])
setJobUserGroups(jv.user_groups || null)
} else {
- setJobs(Array.isArray(jv) ? jv : [])
+ setJobs([])
setJobUserGroups(null)
}
}
} catch (err) {
- addToast(`Failed to load: ${err.message}`, 'error')
+ addToast(t('jobs.loadFailed', { message: err.message }), 'error')
} finally {
setLoading(false)
}
- }, [addToast, isAdmin, authEnabled])
+ }, [addToast, isAdmin, authEnabled, t])
useEffect(() => {
fetchData()
@@ -99,137 +148,137 @@ export default function AgentJobs() {
return () => { cancelled = true }
}, [models])
- const handleDeleteTask = async (id) => {
- setConfirmDialog({
- title: 'Delete Task',
- message: 'Delete this task?',
- confirmLabel: 'Delete',
- danger: true,
- onConfirm: async () => {
- setConfirmDialog(null)
- try {
- await agentJobsApi.deleteTask(id)
- addToast('Task deleted', 'success')
- fetchData()
- } catch (err) {
- addToast(`Failed to delete: ${err.message}`, 'error')
- }
- },
- })
+ // Deleting a task waits in the browser for the undo window; the delete call is
+ // the one the page always used (see useModelRemoval for the hold).
+ const removal = useModelRemoval({
+ deleteModel: id => agentJobsApi.deleteTask(id),
+ loadRunning: async () => new Set(),
+ onSettled: ({ removed, failed }) => {
+ fetchData()
+ if (removed.length > 0) addToast(t('jobs.tasks.deleted'), 'success')
+ for (const f of failed) addToast(t('jobs.tasks.deleteFailed', { message: f.message }), 'error')
+ },
+ onAbandoned: () => addToast(t('jobs.tasks.deleteAbandoned'), 'info'),
+ })
+ const hidden = new Set(removal.pending?.ids || [])
+ const keyOf = (task) => task.id || task.name
+ const visibleTasks = tasks.filter(task => !hidden.has(keyOf(task)))
+ const nameOf = useMemo(() => {
+ const map = new Map()
+ for (const task of tasks) map.set(task.id, task.name || task.id)
+ return (job) => map.get(job.task_id) || job.task_id || t('jobs.history.unknownTask')
+ }, [tasks, t])
+
+ const askDelete = (task) => setConfirmDialog({
+ title: t('jobs.tasks.deleteTitle'),
+ message: t('jobs.tasks.deleteMessage', { name: task.name || task.id }),
+ confirmLabel: t('jobs.tasks.deleteConfirm'),
+ danger: true,
+ onConfirm: () => {
+ setConfirmDialog(null)
+ removal.start([{ id: keyOf(task), name: task.name || task.id }])
+ },
+ })
+
+ const toggleEnabled = async (task) => {
+ const enabled = task.enabled === false
+ setTasks(prev => prev.map(x => (keyOf(x) === keyOf(task) ? { ...x, enabled } : x)))
+ try {
+ await agentJobsApi.updateTask(keyOf(task), { ...task, enabled })
+ addToast(t(enabled ? 'jobs.tasks.turnedOn' : 'jobs.tasks.turnedOff', { name: task.name || task.id }), 'success')
+ } catch (err) {
+ setTasks(prev => prev.map(x => (keyOf(x) === keyOf(task) ? { ...x, enabled: !enabled } : x)))
+ addToast(t('jobs.tasks.toggleFailed', { message: err.message }), 'error')
+ }
}
- const handleCancelJob = async (id) => {
+ const cancelJob = async (id) => {
try {
await agentJobsApi.cancelJob(id)
- addToast('Job cancelled', 'success')
+ addToast(t('jobs.history.cancelled'), 'success')
fetchData()
} catch (err) {
- addToast(`Failed to cancel: ${err.message}`, 'error')
+ addToast(t('jobs.history.cancelFailed', { message: err.message }), 'error')
}
}
- const handleClearHistory = async () => {
- setConfirmDialog({
- title: 'Clear Job History',
- message: 'Clear all job history?',
- confirmLabel: 'Clear',
- danger: true,
- onConfirm: async () => {
- setConfirmDialog(null)
- try {
- // Cancel all running jobs first, then refetch
- const running = jobs.filter(j => j.status === 'running' || j.status === 'pending')
- await Promise.all(running.map(j => agentJobsApi.cancelJob(j.id).catch(() => {})))
- addToast('Job history cleared', 'success')
- fetchData()
- } catch (err) {
- addToast(`Failed to clear: ${err.message}`, 'error')
- }
- },
- })
- }
-
- const openExecuteModal = (task) => {
- setExecuteModal(task)
- setExecuteTab('parameters')
- setExecuteParams('')
- setExecuteMultimedia({ images: [], videos: [], audios: [], files: [] })
- }
-
- const handleExecute = async () => {
- if (!executeModal) return
- setExecuting(true)
+ const runAgain = async (job) => {
try {
- // Parse parameters from key=value lines into a flat map
- const params = {}
- if (executeParams.trim()) {
- executeParams.split('\n').forEach(line => {
- const [key, ...rest] = line.split('=')
- if (key?.trim() && rest.length > 0) {
- params[key.trim()] = rest.join('=').trim()
- }
- })
- }
-
- await agentJobsApi.executeTask(executeModal.name || executeModal.id, params)
- addToast(`Task "${executeModal.name}" started`, 'success')
- setExecuteModal(null)
+ await agentJobsApi.rerunJob(job)
+ addToast(t('jobs.job.again'), 'success')
fetchData()
} catch (err) {
- addToast(`Failed to execute: ${err.message}`, 'error')
- } finally {
- setExecuting(false)
+ addToast(t('jobs.job.againFailed', { message: err.message }), 'error')
}
}
- const handleFileUpload = async (e, type) => {
- for (const file of e.target.files) {
- const base64 = await fileToBase64(file)
- const url = `data:${file.type};base64,${base64}`
- setExecuteMultimedia(prev => ({
- ...prev,
- [type]: [...prev[type], { url, name: file.name }]
- }))
- }
- e.target.value = ''
- }
+ // What this page's old "Clear History" did: cancel the jobs still going. The
+ // finished ones were never removed, so the label now says what happens.
+ const askStopRunning = () => setConfirmDialog({
+ title: t('jobs.stopTitle'),
+ message: t('jobs.stopMessage'),
+ confirmLabel: t('jobs.stopConfirm'),
+ danger: true,
+ onConfirm: async () => {
+ setConfirmDialog(null)
+ try {
+ const active = jobs.filter(isActive)
+ await Promise.all(active.map(j => agentJobsApi.cancelJob(j.id).catch(() => {})))
+ addToast(t('jobs.stopped'), 'success')
+ fetchData()
+ } catch (err) {
+ addToast(t('jobs.stopFailed', { message: err.message }), 'error')
+ }
+ },
+ })
- const removeMultimedia = (type, index) => {
- setExecuteMultimedia(prev => ({
- ...prev,
- [type]: prev[type].filter((_, i) => i !== index)
- }))
- }
+ const toggleRow = (id) => setExpanded(prev => {
+ const next = new Set(prev)
+ if (next.has(id)) next.delete(id)
+ else next.add(id)
+ return next
+ })
- const filteredJobs = jobFilter === 'all' ? jobs : jobs.filter(j => j.status === jobFilter)
+ // History: by task first, so the status counts follow the task chosen.
+ const byTask = taskFilter === 'all' ? jobs : jobs.filter(j => j.task_id === taskFilter)
+ const counts = statusCounts(byTask)
+ const shown = statusFilter === 'all' ? byTask : byTask.filter(j => j.status === statusFilter)
+ const groups = groupByDay(shown)
+ const filtered = statusFilter !== 'all' || taskFilter !== 'all'
- const statusBadge = (status) => {
- const cls = status === 'completed' ? 'badge-success' : status === 'failed' ? 'badge-error' : status === 'running' ? 'badge-info' : status === 'cancelled' ? '' : 'badge-warning'
- return
{status || 'unknown'}
- }
-
- const formatDate = (d) => {
- if (!d) return '-'
- return new Date(d).toLocaleString()
- }
+ const header = (actions = true) => (
+
+ {t('jobs.title')}
+ {actions && (
+
+
+
navigate('/app/agent-jobs/tasks/new')}>
+ {t('jobs.newTask')}
+
+
+ )}
+
+ )
// Wizard: no models installed
if (!loading && models.length === 0) {
return (
-
-
-
-
-
No Models Installed
-
- Agent Jobs require at least one model with MCP (Model Context Protocol) support. Install a model first, then configure MCP in the model settings.
-
-
-
navigate('/app/models')}>
- Browse Models
+
+ {header(false)}
+
+
{t('jobs.wizard.noModelsTitle')}
+
{t('jobs.wizard.noModelsText')}
+
@@ -240,28 +289,23 @@ export default function AgentJobs() {
// Wizard: models but no MCP
if (!loading && models.length > 0 && !hasMCPModels && tasks.length === 0) {
return (
-
-
-
-
-
MCP Not Configured
-
- You have models installed, but none have MCP (Model Context Protocol) enabled. Agent Jobs require MCP to interact with tools and external services. Edit a model configuration to add MCP servers.
-
-
-
Example MCP configuration (YAML):
-
{`mcp:
+
+ {header(false)}
+
+
{t('jobs.wizard.noMcpTitle')}
+
{t('jobs.wizard.noMcpText')}
+
{t('jobs.wizard.mcpExample')}
+
{`mcp:
stdio:
- name: my-tool
command: /path/to/tool
args: ["--flag"]`}
-
-
-
navigate('/app/models?view=installed')}>
- Manage Models
+
@@ -269,257 +313,292 @@ export default function AgentJobs() {
)
}
- return (
-
-
navigate('/app/agent-jobs/tasks/new')}>
- New Task
-
- }
- />
+ const emptyTasks = visibleTasks.length === 0 && !taskUserGroups && !removal.pending
-
- setActiveTab('tasks')}>
- Tasks ({tasks.length})
-
- setActiveTab('jobs')}>
- Job History ({jobs.length})
-
-
+ return (
+
+ {header()}
{loading ? (
-
- ) : activeTab === 'tasks' ? (
- tasks.length === 0 && !taskUserGroups ? (
-
-
-
No tasks defined
-
Create a task to get started with agent workflows.
-
navigate('/app/agent-jobs/tasks/new')}>
- Create Task
-
-
- ) : (
- <>
- {taskUserGroups &&
Your Tasks }
- {tasks.length === 0 ? (
-
You have no tasks yet.
- ) : (
-
- )}
-
- >
- )
+
) : (
<>
- {jobUserGroups &&
Your Jobs }
- {/* Job History Controls */}
-
-
- setJobFilter(e.target.value)} style={{ width: 'auto', minWidth: 140 }}>
- All Status
- Pending
- Running
- Completed
- Failed
- Cancelled
-
-
- {filteredJobs.length} job{filteredJobs.length !== 1 ? 's' : ''}
-
-
- {jobs.length > 0 && (
-
- Clear History
-
- )}
-
-
- {filteredJobs.length === 0 ? (
-
-
-
No jobs {jobFilter !== 'all' ? `with status "${jobFilter}"` : ''}
-
Execute a task to create a job.
+ {emptyTasks ? (
+
+
{t('jobs.empty.title')}
+
{t('jobs.empty.text')}
+
+ {t('jobs.empty.step1', { gap: '{{.name}}' })}
+ {t('jobs.empty.step2')}
+ {t('jobs.empty.step3')}
+
+
) : (
-
-
-
-
- Job ID
- Task
- Status
- Created
- Actions
-
-
-
- {filteredJobs.map(job => (
-
-
- navigate(`/app/agent-jobs/jobs/${job.id}`)} style={{ cursor: 'pointer', color: 'var(--color-primary)', fontFamily: 'var(--font-mono)', fontSize: '0.8125rem' }}>
- {job.id?.slice(0, 12)}...
-
-
- {job.task_id || '-'}
- {statusBadge(job.status)}
-
- {formatDate(job.created_at)}
-
-
-
- navigate(`/app/agent-jobs/jobs/${job.id}`)} title="View">
-
-
- {(job.status === 'running' || job.status === 'pending') && (
- handleCancelJob(job.id)} title="Cancel">
-
-
- )}
-
-
-
+ <>
+
+
+
+
+
{t('jobs.tasks.title', { count: visibleTasks.length })}
+ setTasksOpen(v => !v)}>
+ {tasksOpen ? t('jobs.tasks.hide') : t('jobs.tasks.show')}
+
+
+ {tasksOpen && (visibleTasks.length === 0 ? (
+ {t('jobs.tasks.noneYet')}
+ ) : (
+
+
+ {t('jobs.tasks.caption')}
+
+
+ {t('jobs.tasks.task')}
+ {t('jobs.tasks.schedule')}
+ {t('jobs.tasks.lastRuns')}
+ {t('jobs.tasks.enabled')}
+ {t('jobs.tasks.actions')}
+
+
+
+ {visibleTasks.map(task => {
+ const mine = jobsOf(jobs, task.id)
+ const last = mine[0]
+ const off = task.enabled === false
+ const label = task.name || task.id
+ return (
+
+
+ {label}
+
+ {task.description && {task.description} }
+ {task.model
+ ? {task.model}
+ : {t('jobs.tasks.noModel')} }
+
+ {task.cron ? (scheduleWords(task.cron, t, i18n.language) || t('jobs.cron.custom')) : t('jobs.tasks.noSchedule')}{last ? `. ${last.status === 'failed' ? t('jobs.tasks.lastRunFailedSentence') : t('jobs.tasks.lastRun', { when: formatWhen(last.created_at) })}` : ''}
+
+
+
+
+
+
+ {last
+ ? (last.status === 'failed'
+ ? <>{t('jobs.tasks.lastRun', { when: formatWhen(last.created_at) })}, {t('jobs.tasks.lastRunFailed')}>
+ : t('jobs.tasks.lastRun', { when: formatWhen(last.created_at) }))
+ : t('jobs.tasks.neverRun')}
+
+
+
+
+ toggleEnabled(task)}
+ />
+
+
+
+
setRunTask(task)} title={t('jobs.tasks.runNow')}>
+ {t('jobs.tasks.runNow')}
+
+
navigate(`/app/agent-jobs/tasks/${keyOf(task)}/edit`) },
+ { divider: true },
+ { key: 'delete', icon: 'trash', label: t('jobs.tasks.delete'), danger: true, onClick: () => askDelete(task) },
+ ]}
+ />
+
+
+
+ )
+ })}
+
+
+
+ ))}
+
+
+
+
+
{t('jobs.history.title')}
+
+
+ setStatusFilter('all')} data-filter="all">
+ {t('jobs.history.all')} {counts.all}
+
+ {STATUS_ORDER.filter(s => counts[s] || statusFilter === s).map(s => (
+ setStatusFilter(statusFilter === s ? 'all' : s)} data-filter={s}>
+ {t(`jobs.status.${s}`)} {counts[s] || 0}
+
))}
-
-
-
+
+ setTaskFilter(e.target.value)} aria-label={t('jobs.history.filterTask')}>
+ {t('jobs.history.allTasks')}
+ {tasks.map(task => {task.name || task.id} )}
+
+
+
+
+ {shown.length === 0 ? (
+
+
{filtered ? t('jobs.history.emptyFiltered') : t('jobs.history.emptyTitle')}
+ {!filtered &&
{t('jobs.history.emptyText')}
}
+ {filtered && (
+
{ setStatusFilter('all'); setTaskFilter('all') }}>
+ {t('jobs.history.clearFilters')}
+
+ )}
+
+ ) : (
+
+
+ {t('jobs.history.caption')}
+
+
+ {t('jobs.history.details', { name: '' })}
+ {t('jobs.history.outcome')}
+ {t('jobs.history.task')}
+ {t('jobs.history.what')}
+ {t('jobs.history.started')}
+ {t('jobs.history.took')}
+
+
+
+ {groups.map(g => (
+
+
+
+ {g.day ? t(`jobs.history.${g.day}`) : (g.date ? new Date(g.date).toLocaleDateString([], { weekday: 'short', month: 'short', day: 'numeric', year: new Date(g.date).getFullYear() === new Date().getFullYear() ? undefined : 'numeric' }) : '')}
+
+
+ {g.jobs.map(job => {
+ const open = expanded.has(job.id)
+ const name = nameOf(job)
+ const took = jobDurationMs(job)
+ const failed = job.status === 'failed'
+ return (
+
+
+
+ toggleRow(job.id)}
+ >
+
+
+
+
+
+ {name}
+ {jobSentence(job, t, 70)}{(job.started_at || job.created_at) && !isActive(job) ? `, ${clockOf(job.started_at || job.created_at)}` : ''}
+
+ {jobSentence(job, t)}
+ {clockOf(job.started_at || job.created_at)}
+ {took != null ? formatDuration(took) : ''}
+
+
+
+
+
+
+ {open && runAgain(job)} onCancel={() => cancelJob(job.id)} />}
+
+
+
+
+
+
+ )
+ })}
+
+ ))}
+
+
+
+ )}
+
+ >
)}
+ {taskUserGroups && (
+
(
+
+
+
+ {t('jobs.tasks.task')} {t('jobs.tasks.model')}
+
+
+ {(items || []).map(task => (
+
+ {task.name || task.id} {task.description && {task.description} }
+ {task.model || ''}
+
+ ))}
+
+
+
+ )}
+ />
+ )}
+
+ {jobUserGroups && (
+ (
+
+
+
+
+ {t('jobs.history.outcome')}
+ {t('jobs.history.task')}
+ {t('jobs.history.started')}
+
+
+
+ {(items || []).map(job => (
+
+
+ {job.task_id || ''}
+ {formatWhen(job.created_at)}
+
+ ))}
+
+
+
+ )}
+ />
+ )}
>
)}
- {activeTab === 'tasks' && taskUserGroups && (
- (
-
-
-
-
- Name
- Description
- Model
-
-
-
- {(items || []).map(task => (
-
- {task.name || task.id}
- {task.description || '-'}
- {task.model || '-'}
-
- ))}
-
-
-
- )}
- />
- )}
-
- {activeTab === 'jobs' && jobUserGroups && (
- (
-
-
-
-
- Job ID
- Task
- Status
- Created
-
-
-
- {(items || []).map(job => (
-
- {job.id?.slice(0, 12)}...
- {job.task_id || '-'}
- {statusBadge(job.status)}
- {formatDate(job.created_at)}
-
- ))}
-
-
-
- )}
- />
- )}
-
setConfirmDialog(null)}
/>
- {/* Execute Task Modal */}
- {executeModal && (
- setExecuteModal(null)}>
-
-
-
-
- Execute: {executeModal.name}
-
- setExecuteModal(null)}>
-
-
-
+ {runTask && (
+
setRunTask(null)} onStarted={() => fetchData()} />
+ )}
- {/* Tabs */}
-
- setExecuteTab('parameters')}>
- Parameters
-
- setExecuteTab('multimedia')}>
- Multimedia
-
-
-
- {executeTab === 'parameters' ? (
-
-
Parameters (key=value, one per line)
-
setExecuteParams(e.target.value)}
- rows={5}
- placeholder={`topic=AI trends\nformat=markdown`}
- />
-
- These will be available as {'{{.parameter_name}}'} in the prompt template.
-
-
- ) : (
-
- {['images', 'videos', 'audios', 'files'].map(type => (
-
-
-
-
- {type} ({executeMultimedia[type].length})
-
- { fileTypeRef.current = type; fileInputRef.current?.click() }}>
- Add
-
-
- {executeMultimedia[type].length > 0 && (
-
- {executeMultimedia[type].map((item, i) => (
-
- {item.name || item.url?.slice(0, 40)}
- removeMultimedia(type, i)} style={{ background: 'none', border: 'none', color: 'var(--color-error)', cursor: 'pointer', padding: '2px 4px' }}>
-
-
-
- ))}
-
- )}
-
- ))}
-
handleFileUpload(e, fileTypeRef.current)} />
-
- )}
-
-
- setExecuteModal(null)}>Cancel
-
- {executing ? <> Running...> : <> Execute>}
-
-
-
-
+ {removal.pending && (
+
)}
)
}
+
+// The open row of the history: why it failed with one next step, or the start
+// of what it returned, and the way into the whole run.
+// eslint-disable-next-line no-unused-vars
+function JobDetail({ job, t, onAgain, onCancel }) {
+ const failed = job.status === 'failed'
+ const line = jobLine(job, 1000)
+ const body = failed ? line.text : (typeof job.result === 'string' ? job.result : job.result ? JSON.stringify(job.result, null, 2) : '')
+ const active = isActive(job)
+ return (
+
+ {body ? (
+ <>
+
{failed ? t('jobs.history.error') : t('jobs.history.result')}
+
{body.length > 900 ? `${body.slice(0, 900)}…` : body}
+ >
+ ) : (
+
{active ? jobSentence(job, t) : failed ? t('jobs.history.noError') : t('jobs.history.noResult')}
+ )}
+
+ {active
+ ? {t('jobs.history.cancel')}
+ : {t('jobs.history.runAgain')} }
+ {t('jobs.history.openRun')}
+
+
+ )
+}
diff --git a/core/http/react-ui/src/pages/AgentPage.jsx b/core/http/react-ui/src/pages/AgentPage.jsx
new file mode 100644
index 000000000..30e5282af
--- /dev/null
+++ b/core/http/react-ui/src/pages/AgentPage.jsx
@@ -0,0 +1,323 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+// eslint-disable-next-line no-unused-vars
+import { Link, useLocation, useNavigate, useOutletContext, useParams, useSearchParams } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import { agentsApi } from '../utils/api'
+import { agentInfo, workingLine } from '../utils/agentInfo'
+import { clearRuns, effectiveStatus, loadRuns, newRunId, outcomeLine, recordStatus, firstLine, titleOf, STRIP_LENGTH } from '../utils/agentRuns'
+// eslint-disable-next-line no-unused-vars
+import { StatusMark, RunStrip, RecordLine, Chip, LibraryChips, formatWhen, agentPath } from '../components/agents/AgentBits'
+// eslint-disable-next-line no-unused-vars
+import ActionMenu from '../components/ActionMenu'
+// eslint-disable-next-line no-unused-vars
+import ConfirmDialog from '../components/ConfirmDialog'
+import Icon from '../components/Icon'
+import './agents.css'
+
+// An agent as a home: what it is, its record, a box to give it a task, and the
+// runs it has done. The facts come from its saved config; the record is the
+// log this browser keeps (see utils/agentRuns.js).
+export default function AgentPage() {
+ const { name } = useParams()
+ const { t } = useTranslation('agents')
+ const { addToast } = useOutletContext()
+ const navigate = useNavigate()
+ const location = useLocation()
+ const [searchParams] = useSearchParams()
+ const userId = searchParams.get('user_id') || undefined
+
+ const [config, setConfig] = useState(null)
+ const [active, setActive] = useState(null)
+ const [missing, setMissing] = useState(false)
+ const [loading, setLoading] = useState(true)
+ const [working, setWorking] = useState(null)
+ const [tick, setTick] = useState(0)
+ const [showAll, setShowAll] = useState(false)
+ const [task, setTask] = useState(() => location.state?.task || '')
+ const [confirm, setConfirm] = useState(null)
+ const taRef = useRef(null)
+
+ const load = useCallback(async () => {
+ try {
+ const [state, cfg] = await Promise.all([
+ agentsApi.get(name, userId),
+ agentsApi.getConfig(name, userId).catch(() => null),
+ ])
+ setActive(state?.active !== false)
+ setConfig(cfg)
+ setMissing(false)
+ } catch (err) {
+ if (err?.status === 404 || /not found/i.test(err?.message || '')) setMissing(true)
+ else addToast(t('toasts.loadFailed', { message: err.message }), 'error')
+ } finally {
+ setLoading(false)
+ }
+ }, [name, userId, addToast, t])
+
+ useEffect(() => { load() }, [load])
+
+ // Work in flight shows as "Working" on the page that opens the agent.
+ useEffect(() => {
+ let stop = false
+ const poll = async () => {
+ try {
+ const obs = await agentsApi.observables(name, userId)
+ if (!stop) setWorking(workingLine(obs?.History || []))
+ } catch (_e) { /* the badge simply stays off */ }
+ if (!stop) setTick(n => n + 1)
+ }
+ poll()
+ const id = setInterval(poll, 5000)
+ return () => { stop = true; clearInterval(id) }
+ }, [name, userId])
+
+ const info = useMemo(() => agentInfo(config), [config])
+ // tick re-reads the log while the page is open
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const runs = useMemo(() => loadRuns(name, userId), [name, userId, tick])
+ const liveRun = runs.find(r => effectiveStatus(r) === 'running')
+ const state = working || liveRun ? 'running' : active === false ? 'paused' : 'ready'
+ const paused = active === false
+
+ const start = () => {
+ const text = task.trim()
+ if (!text || paused) return
+ const id = newRunId()
+ navigate(agentPath(name, userId, `/runs/${id}`), { state: { task: text } })
+ }
+
+ const onKeyDown = (e) => {
+ if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey && !e.nativeEvent?.isComposing && e.keyCode !== 229) {
+ e.preventDefault()
+ start()
+ }
+ }
+
+ const pauseResume = async () => {
+ try {
+ if (paused) {
+ await agentsApi.resume(name, userId)
+ addToast(t('toasts.resumed', { name }), 'success')
+ } else {
+ await agentsApi.pause(name, userId)
+ addToast(t('toasts.paused', { name }), 'success')
+ }
+ load()
+ } catch (err) {
+ addToast(t(paused ? 'toasts.resumeFailed' : 'toasts.pauseFailed', { message: err.message }), 'error')
+ }
+ }
+
+ const exportAgent = async () => {
+ try {
+ const data = await agentsApi.export(name, userId)
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = `${name}.json`
+ document.body.appendChild(a)
+ a.click()
+ document.body.removeChild(a)
+ URL.revokeObjectURL(url)
+ addToast(t('toasts.exported', { name }), 'success')
+ } catch (err) {
+ addToast(t('toasts.exportFailed', { message: err.message }), 'error')
+ }
+ }
+
+ const askClear = () => setConfirm({
+ title: t('agent.clearTitle'),
+ message: t('agent.clearMessage', { name }),
+ confirmLabel: t('agent.clearConfirm'),
+ danger: true,
+ onConfirm: () => {
+ setConfirm(null)
+ clearRuns(name, userId)
+ setTick(n => n + 1)
+ addToast(t('agent.cleared'), 'success')
+ },
+ })
+
+ const askDelete = () => setConfirm({
+ title: t('deleteDialog.title'),
+ message: t('deleteDialog.message', { name }),
+ confirmLabel: t('deleteDialog.confirm'),
+ danger: true,
+ onConfirm: async () => {
+ setConfirm(null)
+ try {
+ await agentsApi.delete(name, userId)
+ addToast(t('toasts.deleted', { name }), 'success')
+ navigate('/app/agents')
+ } catch (err) {
+ addToast(t('toasts.deleteFailed', { message: err.message }), 'error')
+ }
+ },
+ })
+
+ if (loading) {
+ return
+ }
+
+ if (missing) {
+ return (
+
+
+
{t('agent.back')}
+
+
{t('agent.missingTitle', { name })}
+
{t('agent.missingText')}
+
{t('agent.back')}
+
+
+
+ )
+ }
+
+ const shown = showAll ? runs : runs.slice(0, STRIP_LENGTH)
+ const hasTools = info.tools.length + info.mcp.length > 0
+
+ return (
+
+
+
+
{t('agent.back')}
+
+
+
{t('agent.status')}
+
+
+
{t('actions.edit')}
+
+
+
+
+
+
+
+
{name}
+
+
+ {info.description && {info.description}
}
+
+
+
+ {t('facts.model')}
+ {info.model || {t('facts.noModel')} }
+ {t('facts.tools')}
+
+ {hasTools
+ ? <>
+ {info.tools.map(x => {x} )}
+ {info.mcp.map(x => {`mcp: ${x}`} )}
+ >
+ : {t('chips.none')} }
+
+ {t('facts.memory')}
+
+ {t('facts.skills')}
+
+ {info.instructions && (
+ <>
+ {t('facts.instructions')}
+ {firstLine(info.instructions, 220)}
+ >
+ )}
+ {info.schedule && (
+ <>
+ {t('facts.schedule')}
+ {info.schedule}
+ >
+ )}
+ {t('facts.record')}
+
+
+
+
+
+
{t('agent.taskLabel')}
+
setTask(e.target.value)}
+ onKeyDown={onKeyDown}
+ />
+
+ {t('agent.taskHint')}
+
+ {t('agent.start')}
+
+
+
+ {paused && (
+
+ {t('agent.pausedNote')} {t('actions.resume')}
+
+ )}
+
+
+
+
+
{t('agent.runs')}
+ {runs.length > 0 && {t('agent.runsCount', { count: runs.length })} }
+
+ {runs.length === 0 ? (
+
+
{t('agent.noRunsTitle')}
+
{t('agent.noRunsText')}
+
+ ) : (
+
+ {shown.map(r => {
+ const st = recordStatus(r)
+ const first = r.turns[0]
+ return (
+
+
+
+ {titleOf(first.task, 90)}
+ {st === 'stopped' && !first.outcome ? t('run.stoppedLine') : st === 'running' ? t('run.workingLine') : outcomeLine(r)}
+
+ {formatWhen(r.startedAt)}
+
+
+ )
+ })}
+
+ )}
+ {runs.length > STRIP_LENGTH && (
+ setShowAll(v => !v)}>
+ {showAll ? t('agent.showFewer') : t('agent.showOlder', { count: runs.length - STRIP_LENGTH })}
+
+ )}
+ {t('record.where')}
+
+
+
+
setConfirm(null)}
+ />
+
+ )
+}
diff --git a/core/http/react-ui/src/pages/AgentRun.jsx b/core/http/react-ui/src/pages/AgentRun.jsx
new file mode 100644
index 000000000..b93539234
--- /dev/null
+++ b/core/http/react-ui/src/pages/AgentRun.jsx
@@ -0,0 +1,650 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+// eslint-disable-next-line no-unused-vars
+import { Link, useLocation, useNavigate, useOutletContext, useParams, useSearchParams } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import { agentsApi } from '../utils/api'
+import { apiUrl } from '../utils/basePath'
+import { copyToClipboard } from '../utils/clipboard'
+import { agentInfo } from '../utils/agentInfo'
+import {
+ applyStatusLine, applyStreamEvent, durationMs, effectiveStatus, failTurn, finishTurn, firstLine, formatDuration,
+ historyForRun, lastTurn, loadRun, newRun, newRunId, newTurn, saveRun, stepCount, titleOf,
+} from '../utils/agentRuns'
+// eslint-disable-next-line no-unused-vars
+import { StatusMark, Chip, LibraryChips, formatWhen, agentPath } from '../components/agents/AgentBits'
+// eslint-disable-next-line no-unused-vars
+import Prose from '../components/agents/Prose'
+// eslint-disable-next-line no-unused-vars
+import ActionMenu from '../components/ActionMenu'
+import Icon from '../components/Icon'
+import './chat.css'
+import './agents.css'
+
+const SETTLE_MS = 1500
+
+// ---- Small parts ---------------------------------------------------------
+
+function resourcesOf(metadata, agent, userId) {
+ const out = []
+ Object.values(metadata || {}).forEach((values) => {
+ if (!Array.isArray(values)) return
+ values.forEach((v) => {
+ if (typeof v !== 'string' || !v) return
+ const web = /^https?:\/\//.test(v)
+ let label = v.split('/').pop() || v
+ if (web) { try { label = new URL(v).hostname } catch (_e) { label = v } }
+ const href = web ? v : apiUrl(`/api/agents/${encodeURIComponent(agent)}/files?path=${encodeURIComponent(v)}${userId ? `&user_id=${encodeURIComponent(userId)}` : ''}`)
+ out.push({ href, label, web })
+ })
+ })
+ return out
+}
+
+// eslint-disable-next-line no-unused-vars
+function Resources({ metadata, agent, userId }) {
+ const items = resourcesOf(metadata, agent, userId)
+ if (items.length === 0) return null
+ return (
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function AgentSwitcher({ name, userId }) {
+ const { t } = useTranslation('agents')
+ const navigate = useNavigate()
+ const [open, setOpen] = useState(false)
+ const [names, setNames] = useState(null)
+ const ref = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ agentsApi.list().then(d => setNames(Array.isArray(d.agents) ? d.agents : [])).catch(() => setNames([]))
+ const onDown = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false) }
+ const onKey = (e) => { if (e.key === 'Escape') setOpen(false) }
+ document.addEventListener('mousedown', onDown)
+ document.addEventListener('keydown', onKey)
+ return () => { document.removeEventListener('mousedown', onDown); document.removeEventListener('keydown', onKey) }
+ }, [open])
+
+ return (
+
+
setOpen(v => !v)} data-testid="agent-switcher">
+ {name}
+
+
+ {open && (
+
+ {(names || []).map(n => (
+ { setOpen(false); navigate(agentPath(n, userId)) }}
+ >
+ {n}
+ {n === name && }
+
+ ))}
+ {names && names.length === 0 && {t('run.noOtherAgents')} }
+
+ )}
+
+ )
+}
+
+function stepLabel(step, t) {
+ if (step.kind === 'tool') return {step.name}
+ if (step.kind === 'thought') return t('run.thought')
+ return t('run.statusLine')
+}
+
+function stepNote(step) {
+ if (step.kind === 'tool') return firstLine(step.args, 120)
+ return firstLine(step.text, 120)
+}
+
+// "Worked 26 s, 5 steps" folded to one line; opens into the steps.
+// eslint-disable-next-line no-unused-vars
+function Fold({ turn, running, now }) {
+ const { t } = useTranslation('agents')
+ const [open, setOpen] = useState(false)
+ const steps = turn.steps
+ if (steps.length === 0 && !running) return null
+ const ms = running ? now - turn.startedAt : durationMs(turn)
+ const count = stepCount(turn)
+ const label = running
+ ? t('run.workingFor', { time: formatDuration(ms), count })
+ : turn.status === 'failed'
+ ? t('run.stoppedAfter', { time: formatDuration(ms), count })
+ : t('run.workedFor', { time: formatDuration(ms), count })
+ return (
+
+
setOpen(v => !v)}>
+
+ {label}
+
+
+ {open && (
+
+ {steps.length === 0 && {t('run.noSteps')} }
+ {steps.map((s, i) => (
+
+ {stepLabel(s, t)}
+ {s.kind === 'tool' && s.args && {firstLine(s.args, 240)}
}
+ {s.kind === 'thought' && }
+ {s.kind === 'status' && {s.text}
}
+ {s.kind === 'tool' && s.result && {s.result} }
+
+ ))}
+
+ )}
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function FailPanel({ turn, onAgain, statusHref, compact = false }) {
+ const { t } = useTranslation('agents')
+ const sent = turn.errorKind === 'send'
+ return (
+
+
{sent ? t('run.failSendTitle') : t('run.failTitle')}
+
{sent ? t('run.failSendText') : t('run.failText')}
+ {turn.error &&
{turn.error}
}
+
+ {onAgain && {t('run.runAgain')} }
+ {!compact && statusHref && {t('run.seeStatus')}}
+
+
+ )
+}
+
+// ---- Page --------------------------------------------------------------
+
+export default function AgentRun() {
+ const { name, id } = useParams()
+ return
+}
+
+// eslint-disable-next-line no-unused-vars
+function AgentRunInner({ name, id }) {
+ const { t } = useTranslation('agents')
+ const { addToast } = useOutletContext()
+ const navigate = useNavigate()
+ const location = useLocation()
+ const [searchParams] = useSearchParams()
+ const userId = searchParams.get('user_id') || undefined
+
+ const startTask = location.state?.task
+ const [run, setRun] = useState(() => loadRun(name, id, userId))
+ const runRef = useRef(run)
+ const saveTimer = useRef(null)
+ const startedRef = useRef(false)
+ const [config, setConfig] = useState(null)
+ const [now, setNow] = useState(() => Date.now())
+ const [view, setView] = useState(() => (run && effectiveStatus(run) !== 'running' ? 'report' : 'live'))
+ const [pickedView, setPickedView] = useState(false)
+ const [settling, setSettling] = useState(false)
+ const [reconnecting, setReconnecting] = useState(false)
+ const [followUp, setFollowUp] = useState('')
+ const bodyRef = useRef(null)
+ const stickRef = useRef(true)
+
+ // The one place the run changes: keep the ref current for the stream
+ // handlers, render it, and write it to the log shortly after.
+ const commit = useCallback((update, { flush = false } = {}) => {
+ const next = update(runRef.current)
+ if (!next || next === runRef.current) return
+ runRef.current = next
+ setRun(next)
+ clearTimeout(saveTimer.current)
+ if (flush) saveRun(next)
+ else saveTimer.current = setTimeout(() => saveRun(runRef.current), 400)
+ }, [])
+
+ useEffect(() => () => {
+ clearTimeout(saveTimer.current)
+ if (runRef.current) saveRun(runRef.current)
+ }, [])
+
+ const updateLast = useCallback((fn, opts) => {
+ commit((r) => {
+ if (!r) return r
+ const turns = [...r.turns]
+ const last = turns[turns.length - 1]
+ const next = fn(last)
+ if (next === last) return r
+ turns[turns.length - 1] = next
+ return { ...r, turns }
+ }, opts)
+ }, [commit])
+
+ // Send a task to the agent: the first one starts the run, later ones are
+ // follow-ups carrying the turns before them as history.
+ const dispatch = useCallback(async (task, history) => {
+ try {
+ await agentsApi.chat(name, task, userId, history)
+ } catch (err) {
+ updateLast(turn => failTurn(turn, err.message, Date.now(), 'send'), { flush: true })
+ }
+ }, [name, userId, updateLast])
+
+ // A run opened with a task and no record is a new run: write it, then send.
+ useEffect(() => {
+ if (startedRef.current || runRef.current || !startTask) return
+ startedRef.current = true
+ const fresh = newRun(name, startTask, { userId, id })
+ runRef.current = fresh
+ setRun(fresh)
+ saveRun(fresh)
+ dispatch(startTask, [])
+ }, [startTask, name, userId, id, dispatch])
+
+ // The agent's event stream. One run is one task until the agent answers.
+ useEffect(() => {
+ const es = new EventSource(apiUrl(agentsApi.sseUrl(name, userId)))
+ const running = () => runRef.current && lastTurn(runRef.current)?.status === 'running'
+ const parse = (e) => { try { return JSON.parse(e.data) } catch (_err) { return null } }
+
+ es.onopen = () => setReconnecting(false)
+ es.addEventListener('json_message', (e) => {
+ const data = parse(e)
+ if (!data || !running()) return
+ const sender = data.sender || (data.role === 'user' ? 'user' : 'agent')
+ if (sender === 'user') return
+ const content = data.content || data.message || ''
+ const metadata = data.metadata && Object.keys(data.metadata).length ? data.metadata : null
+ updateLast(turn => finishTurn(turn, { content, metadata }), { flush: true })
+ })
+ es.addEventListener('stream_event', (e) => {
+ const data = parse(e)
+ if (data && running()) updateLast(turn => applyStreamEvent(turn, data))
+ })
+ es.addEventListener('status', (e) => {
+ if (e.data && running()) updateLast(turn => applyStatusLine(turn, e.data))
+ })
+ es.addEventListener('json_error', (e) => {
+ const data = parse(e)
+ if (running()) updateLast(turn => failTurn(turn, data?.error || data?.message || ''), { flush: true })
+ })
+ es.onerror = () => setReconnecting(true)
+ return () => es.close()
+ }, [name, userId, updateLast])
+
+ // Chips and the model name come from the saved config. Not needed to read
+ // the run, so a missing agent just leaves them out.
+ useEffect(() => {
+ agentsApi.getConfig(name, userId).then(setConfig).catch(() => setConfig(null))
+ }, [name, userId])
+
+ const turn = run ? lastTurn(run) : null
+ const status = run ? effectiveStatus(run, now) : 'stopped'
+ const running = status === 'running'
+
+ useEffect(() => {
+ if (!running) return
+ const timer = setInterval(() => setNow(Date.now()), 1000)
+ return () => clearInterval(timer)
+ }, [running])
+
+ // When the agent answers, wait a moment and settle into the report, unless
+ // the reader chose a view or the run failed (failure stays where it was seen).
+ const wasRunning = useRef(running)
+ useEffect(() => {
+ if (wasRunning.current && !running && status === 'done' && !pickedView) {
+ setSettling(true)
+ const timer = setTimeout(() => { setSettling(false); setView('report') }, SETTLE_MS)
+ wasRunning.current = running
+ return () => { clearTimeout(timer); setSettling(false) }
+ }
+ wasRunning.current = running
+ }, [running, status, pickedView])
+
+ // Keep the live thread at its end unless the reader scrolled up.
+ useEffect(() => {
+ const el = bodyRef.current
+ if (!el) return
+ const onScroll = () => { stickRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80 }
+ el.addEventListener('scroll', onScroll, { passive: true })
+ return () => el.removeEventListener('scroll', onScroll)
+ }, [])
+ useEffect(() => {
+ if (view === 'live' && stickRef.current && bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight
+ }, [run, view])
+
+ const pick = (v) => { setPickedView(true); setSettling(false); setView(v) }
+
+ const sendFollowUp = () => {
+ const text = followUp.trim()
+ if (!text || !runRef.current || running) return
+ const history = historyForRun(runRef.current)
+ setFollowUp('')
+ commit((r) => ({ ...r, turns: [...r.turns, newTurn(text)] }), { flush: true })
+ setPickedView(false)
+ setView('live')
+ dispatch(text, history)
+ }
+
+ const runAgain = (task) => navigate(agentPath(name, userId, `/runs/${newRunId()}`), { state: { task } })
+
+ const copyLink = async () => {
+ const ok = await copyToClipboard(`${window.location.origin}${window.location.pathname}`)
+ addToast(t(ok ? 'run.linkCopied' : 'run.linkNotCopied'), ok ? 'success' : 'error', 2000)
+ }
+
+ const statusHref = agentPath(name, userId, '/status')
+ const info = useMemo(() => agentInfo(config), [config])
+
+ if (!run) {
+ return (
+
+
+
+
+
+
{t('run.missingTitle')}
+
{t('run.missingText', { id })}
+
+ {t('run.openAgent', { name })}
+ {t('agent.back')}
+
+
+
+
+
+
+ )
+ }
+
+ const first = run.turns[0]
+ const lastMs = turn.endedAt ? turn.endedAt - first.startedAt : null
+ const elapsed = running ? now - turn.startedAt : lastMs
+ const canFollowUp = !running
+ const followUps = run.turns.slice(1)
+ const evidence = run.turns.flatMap((tn, ti) => tn.steps.filter(s => s.kind === 'tool' && s.result).map(s => ({ ...s, turn: ti })))
+
+ const dock = canFollowUp ? (
+
+
+
+
{t('run.followUpLabel')}
+
setFollowUp(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey && !e.nativeEvent?.isComposing && e.keyCode !== 229) {
+ e.preventDefault()
+ sendFollowUp()
+ }
+ }}
+ />
+
+ {t('run.followUpHint')}
+
+
+
+
+
+
+
+ ) : (
+
+
+
+ {reconnecting ? t('run.reconnecting') : t('run.dockWorking')}
+
+
+
+ )
+
+ return (
+
+
+
+
+ {view === 'live' ? (
+
+
+ {run.turns.map((tn, ti) => {
+ const isLast = ti === run.turns.length - 1
+ const tnRunning = isLast && running
+ const tnFailed = tn.status === 'failed'
+ return (
+
+
+ {ti === 0 ? t('run.task') : t('run.followUp')}
+ {tn.task}
+
+
+ {tnRunning && (
+ <>
+ {tn.live.content
+ ?
+ : (
+
+
+ {liveLine(tn, t)}
+
+ )}
+ >
+ )}
+ {tn.status === 'done' && (
+ <>
+
+
+ >
+ )}
+ {tnFailed &&
runAgain(first.task) : undefined} statusHref={statusHref} />}
+ {tn.status === 'running' && !tnRunning && (
+ {t('run.cutOff')}
+ )}
+
+ )
+ })}
+ {settling &&
{t('run.reportReady')}
}
+
+
+ ) : (
+
+
+
+ {titleOf(first.task)}
+
+
+ {name}
+ {t('run.started', { when: formatWhen(first.startedAt) })}
+ {durationMs(first) != null && !run.legacy && {formatDuration(durationMs(first))} }
+ {run.id}
+
+
+
+ {`${window.location.pathname}`}
+ {t('run.copyLink')}
+
+ {info.model && {info.model} }
+ {info.memory.length > 0 && }
+ {info.skills.length > 0 && }
+
+ {t('run.addressNote')}
+
+
+
+
{t('run.task')}
+ {first.task}
+
+ navigate(agentPath(name, userId), { state: { task: first.task } })}>
+ {t('run.reuseTask')}
+
+
+
+
+
+
{t('run.outcome')}
+ {first.status === 'done' && (
+ <>
+
+
+ >
+ )}
+ {first.status === 'failed' && runAgain(first.task)} statusHref={statusHref} />}
+ {first.status === 'running' && run.turns.length === 1 && running && (
+ {t('run.reportWorking')} pick('live')}>{t('run.watchLive')}
+ )}
+ {first.status !== 'done' && first.status !== 'failed' && !(running && run.turns.length === 1) && (
+ <>
+ {t('run.noAnswer')}
+
+ runAgain(first.task)}> {t('run.runAgain')}
+
+ >
+ )}
+
+
+ {followUps.length > 0 && (
+
+
{t('run.followUps')}
+ {followUps.map((tn, i) => (
+
+
{tn.task}
+ {tn.status === 'done' &&
}
+ {tn.status === 'failed' &&
}
+ {tn.status === 'running' && (
+
{t('run.reportWorking')} pick('live')}>{t('run.watchLive')}
+ )}
+
+ ))}
+
+ )}
+
+ {evidence.length > 0 &&
}
+
+
+
+
+ )}
+
+ {dock}
+
+
+ )
+}
+
+function liveLine(turn, t) {
+ const tools = turn.steps.filter(s => s.kind === 'tool')
+ const open = tools.findLast(s => s.result === undefined)
+ if (open) return t('run.using', { name: open.name })
+ if (turn.live.reasoning) return t('run.thinking')
+ return t('run.working')
+}
+
+// eslint-disable-next-line no-unused-vars
+function Evidence({ items }) {
+ const { t } = useTranslation('agents')
+ const [open, setOpen] = useState({})
+ return (
+
+
{t('run.evidence')}
+ {t('run.evidenceNote')}
+
+ {items.map((s, i) => (
+
+ {s.name}
+ {s.args && {firstLine(s.args, 160)}
}
+ {s.result}
+ {s.result.length > 180 && (
+ setOpen(o => ({ ...o, [i]: !o[i] }))}>
+ {open[i] ? t('run.showLess') : t('run.showMore')}
+
+ )}
+
+ ))}
+
+
+ )
+}
+
+// eslint-disable-next-line no-unused-vars
+function StepsList({ run }) {
+ const { t } = useTranslation('agents')
+ const multi = run.turns.length > 1
+ const total = run.turns.reduce((n, tn) => n + tn.steps.length, 0)
+ if (total === 0) return {t('run.noStepsReport')}
+ let n = 0
+ return (
+
+ {run.turns.flatMap((tn, ti) => {
+ if (tn.steps.length === 0) return []
+ const rows = tn.steps.map((s, si) => {
+ n += 1
+ return (
+
+ {n}
+
+ {stepLabel(s, t)}
+ {stepNote(s) && {stepNote(s)} }
+
+ {s.ts && tn.startedAt && !run.legacy && {`+${formatDuration(Math.max(0, s.ts - tn.startedAt))}`} }
+
+ )
+ })
+ return multi
+ ? [{ti === 0 ? t('run.task') : t('run.followUpN', { n: ti })} , ...rows]
+ : rows
+ })}
+
+ )
+}
diff --git a/core/http/react-ui/src/pages/AgentStatus.jsx b/core/http/react-ui/src/pages/AgentStatus.jsx
index c3acf0bec..c8ded1cd7 100644
--- a/core/http/react-ui/src/pages/AgentStatus.jsx
+++ b/core/http/react-ui/src/pages/AgentStatus.jsx
@@ -1,10 +1,20 @@
-import { useState, useEffect, useCallback } from 'react'
-import { useParams, useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'
+import { useState, useEffect, useCallback, useMemo } from 'react'
+// eslint-disable-next-line no-unused-vars
+import { Link, useParams, useOutletContext, useSearchParams } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
import { agentsApi } from '../utils/api'
import { apiUrl } from '../utils/basePath'
-import PageHeader from '../components/PageHeader'
+import { agentPath } from '../components/agents/AgentBits'
+import Icon, { FaIcon } from '../components/Icon'
+import './agents.css'
-function ObservableSummary({ observable }) {
+// The raw record of what an agent did, one entry per action, as the agent
+// reports it. A run's report says what came of a task; this page is for
+// reading the steps behind it.
+
+// The lines an entry shows when folded: what started it, what it called, what
+// came back and what went wrong. Each is a line the agent really reported.
+function summaryLines(observable, t) {
const creation = observable?.creation || {}
const completion = observable?.completion || {}
@@ -17,11 +27,11 @@ function ObservableSummary({ observable }) {
creationMsg = messages[messages.length - 1]?.content || ''
}
}
- if (typeof creationMsg === 'object') creationMsg = 'Multimedia message'
+ if (typeof creationMsg === 'object') creationMsg = t('status.multimedia')
- let funcDef = creation?.function_definition?.name ? `Function: ${creation.function_definition.name}` : ''
- let funcParams = creation?.function_params && Object.keys(creation.function_params).length > 0
- ? `Params: ${JSON.stringify(creation.function_params)}` : ''
+ const funcDef = creation?.function_definition?.name ? t('status.function', { name: creation.function_definition.name }) : ''
+ const funcParams = creation?.function_params && Object.keys(creation.function_params).length > 0
+ ? t('status.params', { params: JSON.stringify(creation.function_params) }) : ''
let completionMsg = ''
let toolCallSummary = ''
@@ -41,120 +51,110 @@ function ObservableSummary({ observable }) {
completionMsg = last?.message?.content || ''
}
- let actionResult = completion?.action_result ? String(completion.action_result).slice(0, 100) : ''
- let errorMsg = completion?.error || ''
+ const actionResult = completion?.action_result ? String(completion.action_result).slice(0, 100) : ''
+ const errorMsg = completion?.error || ''
let filterInfo = ''
if (completion?.filter_result) {
const fr = completion.filter_result
- if (fr.has_triggers && !fr.triggered_by) filterInfo = 'Failed to match triggers'
- else if (fr.triggered_by) filterInfo = `Triggered by ${fr.triggered_by}`
- if (fr.failed_by) filterInfo += `${filterInfo ? ', ' : ''}Failed by ${fr.failed_by}`
+ if (fr.has_triggers && !fr.triggered_by) filterInfo = t('status.noTrigger')
+ else if (fr.triggered_by) filterInfo = t('status.triggeredBy', { name: fr.triggered_by })
+ if (fr.failed_by) filterInfo += `${filterInfo ? ', ' : ''}${t('status.failedBy', { name: fr.failed_by })}`
}
const items = []
- if (creationMsg) items.push({ icon: 'fa-comment-dots', text: creationMsg, cls: 'creation' })
- if (funcDef) items.push({ icon: 'fa-code', text: funcDef, cls: 'creation' })
- if (funcParams) items.push({ icon: 'fa-sliders-h', text: funcParams, cls: 'creation' })
- if (toolCallSummary) items.push({ icon: 'fa-wrench', text: toolCallSummary, cls: 'tool-call' })
- if (completionMsg) items.push({ icon: 'fa-robot', text: completionMsg, cls: 'completion' })
- if (actionResult) items.push({ icon: 'fa-bolt', text: actionResult, cls: 'tool-call' })
- if (errorMsg) items.push({ icon: 'fa-exclamation-triangle', text: errorMsg, cls: 'error' })
- if (filterInfo) items.push({ icon: 'fa-shield-alt', text: filterInfo, cls: 'completion' })
-
- if (items.length === 0) return null
-
- return (
-
- {items.map((item, i) => (
-
-
- {item.text}
-
- ))}
-
- )
+ if (creationMsg) items.push({ text: creationMsg })
+ if (funcDef) items.push({ text: funcDef })
+ if (funcParams) items.push({ text: funcParams })
+ if (toolCallSummary) items.push({ text: toolCallSummary })
+ if (completionMsg) items.push({ text: completionMsg })
+ if (actionResult) items.push({ text: actionResult })
+ if (errorMsg) items.push({ text: errorMsg, kind: 'error' })
+ if (filterInfo) items.push({ text: filterInfo })
+ return items
}
-function ObservableCard({ observable, children: childNodes }) {
+// eslint-disable-next-line no-unused-vars
+function ObservableItem({ observable, children }) {
+ const { t } = useTranslation('agents')
const [expanded, setExpanded] = useState(false)
- const isComplete = !!observable.completion
+ const done = !!observable.completion
+ const failed = !!observable.completion?.error
const hasProgress = observable.progress?.length > 0
+ const lines = summaryLines(observable, t)
+ const state = !done ? 'running' : failed ? 'failed' : 'done'
return (
-
-
setExpanded(!expanded)}>
-
-
-
-
-
-
- {observable.name}
- #{observable.id}
- {!isComplete && }
-
-
-
-
-
-
+
+
setExpanded(v => !v)}>
+
+
+
+ {observable.name}
+ #{observable.id}
+
+ {lines.length > 0 && (
+
+ {lines.slice(0, 2).map((l, i) => {l.text} )}
+
+ )}
+
+
+
+ {t(`state.${state === 'done' ? 'done' : state}`)}
+
+
+
{expanded && (
-
- {/* Children (nested observables) */}
- {childNodes && childNodes.length > 0 && (
-
-
- Nested Observables
-
- {childNodes}
+
+ {children && children.length > 0 && (
+
+
{t('status.nested')}
+ {children}
)}
- {/* Progress entries */}
{hasProgress && (
-
-
Progress ({observable.progress.length})
+
+
{t('status.progress', { count: observable.progress.length })}
{observable.progress.map((p, i) => (
-
- {p.action_result &&
Action Result {p.action_result}
}
- {p.error &&
Error {p.error}
}
+
+ {p.action_result &&
{t('status.actionResult')} {p.action_result}
}
+ {p.error &&
{t('status.error')} {p.error}
}
{p.chat_completion_response?.choices?.length > 0 && (
- Response {' '}
+ {t('status.response')}
{p.chat_completion_response.choices.map((ch, ci) => (
- {ch.message?.content || '(tool call)'}
+ {ch.message?.content || t('status.toolCall')}
))}
)}
{p.agent_state && (
-
State {JSON.stringify(p.agent_state)}
+
{t('status.state')} {JSON.stringify(p.agent_state)}
)}
))}
)}
- {/* Completion */}
{observable.completion && (
-
-
Completion
+
+
{t('status.completion')}
{observable.completion.action_result && (
-
Action Result {observable.completion.action_result}
+
{t('status.actionResult')} {observable.completion.action_result}
)}
{observable.completion.error && (
-
Error {observable.completion.error}
+
{t('status.error')} {observable.completion.error}
)}
{observable.completion.filter_result && (
-
Filter {JSON.stringify(observable.completion.filter_result)}
+
{t('status.filter')} {JSON.stringify(observable.completion.filter_result)}
)}
)}
- {/* Raw JSON */}
-
- Raw JSON
- {JSON.stringify(observable, null, 2)}
+
+ {t('status.rawJson')}
+ {JSON.stringify(observable, null, 2)}
)}
@@ -178,21 +178,23 @@ function buildTree(observables) {
function renderTree(nodes) {
return nodes.map(node => (
-
+
{node.children.length > 0 ? renderTree(node.children) : null}
-
+
))
}
export default function AgentStatus() {
const { name } = useParams()
- const navigate = useNavigate()
+ const { t } = useTranslation('agents')
const { addToast } = useOutletContext()
const [searchParams] = useSearchParams()
const userId = searchParams.get('user_id') || undefined
const [observables, setObservables] = useState([])
const [status, setStatus] = useState(null)
const [loading, setLoading] = useState(true)
+ const [live, setLive] = useState(false)
+ const [only, setOnly] = useState('all')
const fetchData = useCallback(async () => {
try {
@@ -200,7 +202,7 @@ export default function AgentStatus() {
const history = Array.isArray(obsData) ? obsData : (obsData?.History || [])
setObservables(history)
} catch (err) {
- addToast(`Failed to load observables: ${err.message}`, 'error')
+ addToast(t('status.loadFailed', { message: err.message }), 'error')
}
try {
const statusData = await agentsApi.status(name, userId)
@@ -209,7 +211,7 @@ export default function AgentStatus() {
// status endpoint may fail if no actions have run yet
}
setLoading(false)
- }, [name, userId, addToast])
+ }, [name, userId, addToast, t])
useEffect(() => {
fetchData()
@@ -222,6 +224,7 @@ export default function AgentStatus() {
const url = apiUrl(agentsApi.sseUrl(name, userId))
const es = new EventSource(url)
+ es.onopen = () => setLive(true)
es.addEventListener('observable_update', (e) => {
try {
const data = JSON.parse(e.data)
@@ -244,7 +247,7 @@ export default function AgentStatus() {
} catch (_) { /* ignore */ }
})
- es.onerror = () => { /* reconnect handled by browser */ }
+ es.onerror = () => setLive(false) // reconnect is handled by the browser
return () => es.close()
}, [name, userId])
@@ -252,168 +255,78 @@ export default function AgentStatus() {
try {
await agentsApi.clearObservables(name, userId)
setObservables([])
- addToast('Observables cleared', 'success')
+ addToast(t('status.cleared'), 'success')
} catch (err) {
- addToast(`Failed to clear: ${err.message}`, 'error')
+ addToast(t('status.clearFailed', { message: err.message }), 'error')
}
}
- const tree = buildTree(observables)
+ const failedCount = observables.filter(o => o.completion?.error).length
+ const shown = useMemo(
+ () => (only === 'failed' ? observables.filter(o => o.completion?.error) : observables),
+ [observables, only],
+ )
+ const tree = buildTree(shown)
return (
-
-
-
-
{name} — Status>}
- supporting="Agent observables and activity history"
- actions={
-
-
navigate(`/app/agents/${encodeURIComponent(name)}/chat${userId ? `?user_id=${encodeURIComponent(userId)}` : ''}`)}>
- Chat
+
+
+
+
{name}
+
+
+
+ {live ? t('status.live') : t('status.notLive')}
+
+
+ {t('status.refresh')}
- navigate(`/app/agents/${encodeURIComponent(name)}/edit${userId ? `?user_id=${encodeURIComponent(userId)}` : ''}`)}>
- Edit
-
-
- Refresh
-
-
- Clear
+
+ {t('status.clear')}
- }
- />
+
- {/* Status summary */}
- {status && (
-
- {status.state && (
-
-
State
-
{status.state}
-
- )}
- {status.current_task && (
-
-
Current Task
-
{status.current_task}
-
- )}
-
-
Observables
-
{observables.length}
+
+
+
+ {t('status.stateLabel')}
+ {status?.state || {t('status.unknown')} }
+ {t('status.currentTask')}
+ {status?.current_task || {t('chips.none')} }
+ {t('status.records')}
+ {t('status.recordsValue', { count: observables.length, failed: failedCount })}
+
+
+ {loading ? (
+
+
-
- )}
-
- {loading ? (
-
-
-
- ) : tree.length === 0 ? (
-
-
-
No observables yet
-
Send a message to the agent to see its activity here.
-
navigate(`/app/agents/${encodeURIComponent(name)}/chat${userId ? `?user_id=${encodeURIComponent(userId)}` : ''}`)}>
- Chat with {name}
-
-
- ) : (
-
- {renderTree(tree)}
-
- )}
+ ) : observables.length === 0 ? (
+
+
{t('status.emptyTitle')}
+
{t('status.emptyText')}
+
+
{t('status.giveTask', { name })}
+
+
+ ) : (
+
+
+
+ setOnly('all')}>{t('status.all')}
+ setOnly('failed')}>{t('status.failedOnly')}
+
+
+ {tree.length === 0
+ ?
{t('status.noFailed')}
+ :
{renderTree(tree)}
}
+
+ )}
+
)
}
diff --git a/core/http/react-ui/src/pages/AgentTaskDetails.jsx b/core/http/react-ui/src/pages/AgentTaskDetails.jsx
index 6147ec42c..01545d815 100644
--- a/core/http/react-ui/src/pages/AgentTaskDetails.jsx
+++ b/core/http/react-ui/src/pages/AgentTaskDetails.jsx
@@ -1,511 +1,815 @@
-import { useState, useEffect, useCallback } from 'react'
-import { useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom'
+import { useState, useEffect, useMemo, useRef } from 'react'
+import { createPortal } from 'react-dom'
+// eslint-disable-next-line no-unused-vars
+import { Link, useParams, useNavigate, useOutletContext, useLocation } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
import { agentJobsApi } from '../utils/api'
import { basePath } from '../utils/basePath'
+// eslint-disable-next-line no-unused-vars
import ModelSelector from '../components/ModelSelector'
-import PageHeader from '../components/PageHeader'
+// eslint-disable-next-line no-unused-vars
+import UnsavedChangesGuard from '../components/UnsavedChangesGuard'
+// eslint-disable-next-line no-unused-vars
+import RunTaskDialog from '../components/agents/RunTaskDialog'
+// eslint-disable-next-line no-unused-vars
+import { JobMark, JobStrip, ScheduleText, GappedPrompt } from '../components/agents/JobBits'
+import { scheduleWords, cronErrorText, jobSentence } from '../utils/agentJobText'
+// eslint-disable-next-line no-unused-vars
+import { StatusMark, formatWhen } from '../components/agents/AgentBits'
import { CAP_CHAT } from '../utils/capabilities'
+import {
+ buildPreset, formatKeyValues, jobDurationMs, jobsOf, parseKeyValues, presetOf, promptParams, validateCron, STRIP,
+} from '../utils/agentJobs'
+import { firstLine, formatDuration } from '../utils/agentRuns'
+// eslint-disable-next-line no-unused-vars
import LoadingSpinner from '../components/LoadingSpinner'
+import Icon from '../components/Icon'
+import './agents.css'
+import './agent-jobs.css'
export default function AgentTaskDetails() {
const { id } = useParams()
- const navigate = useNavigate()
const location = useLocation()
- const { addToast } = useOutletContext()
const isNew = !id || location.pathname.endsWith('/new')
const isEdit = location.pathname.endsWith('/edit')
+ if (!isNew && !isEdit) return
+ return
+}
- const [task, setTask] = useState({
- name: '', description: '', model: '', prompt: '', context: '',
- enabled: true, cron: '', cron_parameters: '',
- webhooks: [], multimedia_sources: [],
- })
- const [loading, setLoading] = useState(!isNew)
- const [saving, setSaving] = useState(false)
- const [jobHistory, setJobHistory] = useState([])
- const [cronError, setCronError] = useState('')
+function startedBy(job, t) {
+ const who = job.triggered_by || 'manual'
+ return t('jobs.history.startedBy', { who: t(`jobs.trigger.${who}`, { defaultValue: who }) })
+}
+
+// ---- A task, read --------------------------------------------------------
+
+// eslint-disable-next-line no-unused-vars
+function TaskView({ id }) {
+ const { t } = useTranslation('agents')
+ const { addToast } = useOutletContext()
+ const [task, setTask] = useState(null)
+ const [jobs, setJobs] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [missing, setMissing] = useState(false)
+ const [running, setRunning] = useState(false)
+
+ const loadJobs = () => agentJobsApi.listJobs().then(list => {
+ if (Array.isArray(list)) setJobs(list)
+ }).catch(() => {})
useEffect(() => {
- if (!isNew && id) {
- agentJobsApi.getTask(id).then(data => {
- if (data) {
- setTask({
- name: data.name || '',
- description: data.description || '',
- model: data.model || '',
- prompt: data.prompt || '',
- context: data.context || '',
- enabled: data.enabled !== false,
- cron: data.cron || '',
- cron_parameters: typeof data.cron_parameters === 'object'
- ? Object.entries(data.cron_parameters).map(([k, v]) => `${k}=${v}`).join('\n')
- : (data.cron_parameters || ''),
- webhooks: Array.isArray(data.webhooks) ? data.webhooks : [],
- multimedia_sources: Array.isArray(data.multimedia_sources) ? data.multimedia_sources : [],
- })
- }
- setLoading(false)
- }).catch(err => {
- addToast(`Failed to load task: ${err.message}`, 'error')
- setLoading(false)
- })
+ let alive = true
+ agentJobsApi.getTask(id).then(data => {
+ if (!alive) return
+ if (data) setTask(data)
+ else setMissing(true)
+ setLoading(false)
+ }).catch(err => {
+ if (!alive) return
+ if (err?.status === 404 || /not found/i.test(err?.message || '')) setMissing(true)
+ else addToast(t('jobs.task.loadFailed', { message: err.message }), 'error')
+ setLoading(false)
+ })
+ loadJobs()
+ return () => { alive = false }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [id])
- // Fetch job history for this task
- agentJobsApi.listJobs().then(jobs => {
- if (Array.isArray(jobs)) {
- setJobHistory(jobs.filter(j => j.task_id === id).slice(0, 20))
- }
- }).catch(() => {})
- }
- }, [id, isNew, addToast])
+ const mine = useMemo(() => jobsOf(jobs, task?.id || id).slice(0, 20), [jobs, task, id])
- const updateField = (field, value) => {
- setTask(prev => ({ ...prev, [field]: value }))
- }
-
- const validateCron = (expr) => {
- if (!expr) { setCronError(''); return }
- const parts = expr.trim().split(/\s+/)
- if (parts.length < 5 || parts.length > 6) {
- setCronError('Cron must have 5 or 6 fields (min hour day month weekday [year])')
- } else {
- setCronError('')
+ const toggle = async () => {
+ const enabled = task.enabled === false
+ const before = task
+ setTask({ ...task, enabled })
+ try {
+ await agentJobsApi.updateTask(task.id || id, { ...task, enabled })
+ addToast(t(enabled ? 'jobs.tasks.turnedOn' : 'jobs.tasks.turnedOff', { name: task.name }), 'success')
+ } catch (err) {
+ setTask(before)
+ addToast(t('jobs.tasks.toggleFailed', { message: err.message }), 'error')
}
}
- // Webhook management
- const addWebhook = () => {
- updateField('webhooks', [...task.webhooks, { url: '', method: 'POST', headers: '{}', payload_template: '' }])
- }
- const updateWebhook = (i, field, value) => {
- const wh = [...task.webhooks]
- wh[i] = { ...wh[i], [field]: value }
- updateField('webhooks', wh)
- }
- const removeWebhook = (i) => {
- updateField('webhooks', task.webhooks.filter((_, idx) => idx !== i))
+ if (loading) return
+
+ if (missing || !task) {
+ return (
+
+
+
{t('jobs.task.back')}
+
+
{t('jobs.task.missingTitle')}
+
{t('jobs.task.missingText')}
+
+
+
+ )
}
- // Multimedia source management
- const addMultimediaSource = () => {
- updateField('multimedia_sources', [...task.multimedia_sources, { type: 'image', url: '', headers: '{}' }])
+ const off = task.enabled === false
+ const params = task.cron_parameters && typeof task.cron_parameters === 'object' ? task.cron_parameters : {}
+ const hasParams = Object.keys(params).length > 0
+ const webhooks = Array.isArray(task.webhooks) ? task.webhooks : []
+ const sources = Array.isArray(task.multimedia_sources) ? task.multimedia_sources : []
+ const origin = `${window.location.origin}${basePath}`
+ const byName = encodeURIComponent(task.name)
+
+ return (
+
+
+
+
{t('jobs.task.back')}
+
+
+ {t('jobs.task.edit')}
+
+ setRunning(true)}>
+ {t('jobs.task.runNow')}
+
+
+
+
+
+
+
{task.name}
+
+
+ {task.description && {task.description}
}
+
+
+
+ {t('jobs.task.model')}
+ {task.model || {t('jobs.tasks.noModel')} }
+ {t('jobs.task.schedule')}
+
+ {t('jobs.task.enabledSwitch')}
+
+
+
+ {t('jobs.task.webhooks')}
+ {webhooks.length ? t('jobs.edit.summary.webhooks', { count: webhooks.length }) : {t('jobs.task.none')} }
+ {t('jobs.task.media')}
+ {sources.length ? t('jobs.edit.summary.media', { count: sources.length }) : {t('jobs.task.none')} }
+
+
+
+
+
{t('jobs.task.recent')}
+ {mine.length > 0 && (
+
+
+ {t('jobs.task.recentNote', { count: Math.min(mine.length, STRIP) })}
+
+ )}
+
+ {mine.length === 0 ? (
+
+ ) : (
+
+ {mine.map(job => {
+ const took = jobDurationMs(job)
+ return (
+
+
+
+ {jobSentence(job, t)}
+ {took != null ? t('jobs.task.runMeta', { who: startedBy(job, t), time: formatDuration(took) }) : startedBy(job, t)}
+
+ {formatWhen(job.created_at)}
+
+
+ )
+ })}
+
+ )}
+
+
+
+
{t('jobs.task.prompt')}
+ {task.prompt ? : {t('jobs.edit.summary.promptEmpty')}
}
+ {promptParams(task.prompt).length > 0 && {t('jobs.task.promptNote')}
}
+ {task.context && (
+
+
{t('jobs.task.context')}
+
{task.context}
+
+ )}
+
+
+
+
{t('jobs.task.scheduleSection')}
+ {task.cron ? (
+ <>
+
+ {hasParams && (
+
+
{t('jobs.task.scheduleParams')}
+
+ {Object.entries(params).map(([k, v]) => {k}={String(v)} )}
+
+
+ )}
+ {t('jobs.cron.zone')}
+ >
+ ) : {t('jobs.task.noScheduleText')}
}
+
+
+ {webhooks.length > 0 && (
+
+
{t('jobs.task.webhooks')}
+
+ {webhooks.map((wh, i) => (
+ {wh.method || 'POST'} {wh.url}
+ ))}
+
+
+ )}
+
+ {sources.length > 0 && (
+
+
{t('jobs.task.media')}
+
+ {sources.map((ms, i) => (
+ {ms.type} {ms.url}
+ ))}
+
+
+ )}
+
+
+ {t('jobs.task.apiTitle')}
+ {t('jobs.task.apiExecute')}
+ {`curl -X POST ${origin}/api/agent/tasks/${byName}/execute`}
+ {t('jobs.task.apiMedia')}
+ {`curl -X POST ${origin}/api/agent/tasks/${byName}/execute \\
+ -H "Content-Type: application/json" \\
+ -d '{"multimedia": {"images": [{"url": "https://example.com/image.jpg"}]}}'`}
+ {t('jobs.task.apiStatus')}
+ {`curl ${origin}/api/agent/jobs/`}
+
+
+
+ {running && (
+
setRunning(false)}
+ onStarted={() => loadJobs()}
+ />
+ )}
+
+ )
+}
+
+// ---- A task, written -----------------------------------------------------
+
+const BLANK = {
+ name: '', description: '', model: '', prompt: '', context: '',
+ enabled: true, cron: '', cron_parameters: '',
+ webhooks: [], multimedia_sources: [],
+}
+
+// The API sends headers as an object; the form edits them as JSON text.
+const headersText = (h) => (h && typeof h === 'object' && Object.keys(h).length ? JSON.stringify(h) : typeof h === 'string' && h ? h : '{}')
+
+const SECTIONS = [
+ { id: 'basics', keys: ['name', 'model', 'description', 'enabled'] },
+ { id: 'prompt', keys: ['prompt', 'context'] },
+ { id: 'schedule', keys: ['cron', 'cron_parameters'] },
+ { id: 'media', keys: ['multimedia_sources'] },
+ { id: 'webhooks', keys: ['webhooks'] },
+]
+
+const show = (v) => (typeof v === 'object' ? JSON.stringify(v, null, 2) : typeof v === 'boolean' ? String(v) : String(v ?? ''))
+
+// eslint-disable-next-line no-unused-vars
+function TaskForm({ id, isNew }) {
+ const { t, i18n } = useTranslation('agents')
+ const navigate = useNavigate()
+ const { addToast } = useOutletContext()
+ const [task, setTask] = useState(BLANK)
+ const [loading, setLoading] = useState(!isNew)
+ const [saving, setSaving] = useState(false)
+ const [custom, setCustom] = useState(false)
+ const [open, setOpen] = useState(() => new Set(isNew ? ['basics', 'prompt', 'schedule'] : ['prompt', 'schedule']))
+ const [preview, setPreview] = useState(null)
+ const initial = useRef(isNew ? BLANK : null)
+
+ useEffect(() => {
+ if (isNew) return undefined
+ let alive = true
+ agentJobsApi.getTask(id).then(data => {
+ if (!alive) return
+ if (data) {
+ const loaded = {
+ name: data.name || '',
+ description: data.description || '',
+ model: data.model || '',
+ prompt: data.prompt || '',
+ context: data.context || '',
+ enabled: data.enabled !== false,
+ cron: data.cron || '',
+ cron_parameters: formatKeyValues(data.cron_parameters),
+ webhooks: (Array.isArray(data.webhooks) ? data.webhooks : []).map(w => ({ ...w, method: w.method || 'POST', headers: headersText(w.headers), payload_template: w.payload_template || '' })),
+ multimedia_sources: (Array.isArray(data.multimedia_sources) ? data.multimedia_sources : []).map(m => ({ ...m, headers: headersText(m.headers) })),
+ }
+ initial.current = loaded
+ setTask(loaded)
+ setCustom(presetOf(loaded.cron).kind === 'custom')
+ }
+ setLoading(false)
+ }).catch(err => {
+ if (!alive) return
+ addToast(t('jobs.task.loadFailed', { message: err.message }), 'error')
+ setLoading(false)
+ })
+ return () => { alive = false }
+ }, [id, isNew, addToast, t])
+
+ const set = (field, value) => setTask(prev => ({ ...prev, [field]: value }))
+ const setItem = (field, i, key, value) => setTask(prev => ({ ...prev, [field]: prev[field].map((x, j) => (j === i ? { ...x, [key]: value } : x)) }))
+ const removeItem = (field, i) => setTask(prev => ({ ...prev, [field]: prev[field].filter((_, j) => j !== i) }))
+
+ const cronError = validateCron(task.cron)
+ const preset = custom ? { kind: 'custom' } : presetOf(task.cron)
+ const timeOf = presetOf(task.cron).time || '09:00'
+ const gaps = promptParams(task.prompt)
+ const scheduleParams = parseKeyValues(task.cron_parameters)
+ const unset = gaps.filter(g => !(g in scheduleParams))
+
+ const changed = (section) => {
+ if (!initial.current) return []
+ return section.keys.filter(k => JSON.stringify(task[k]) !== JSON.stringify(initial.current[k]))
}
- const updateMultimediaSource = (i, field, value) => {
- const ms = [...task.multimedia_sources]
- ms[i] = { ...ms[i], [field]: value }
- updateField('multimedia_sources', ms)
- }
- const removeMultimediaSource = (i) => {
- updateField('multimedia_sources', task.multimedia_sources.filter((_, idx) => idx !== i))
+ const diff = SECTIONS.flatMap(s => changed(s)).map(key => ({ key, before: show(initial.current?.[key]), after: show(task[key]) }))
+ const dirty = !!initial.current && diff.length > 0
+
+ const toggleSection = (sid) => setOpen(prev => {
+ const next = new Set(prev)
+ if (next.has(sid)) next.delete(sid)
+ else next.add(sid)
+ return next
+ })
+
+ const pickPreset = (kind) => {
+ if (kind === 'custom') { setCustom(true); return }
+ setCustom(false)
+ if (kind === 'none') set('cron', '')
+ else set('cron', buildPreset(kind, timeOf))
}
- const handleSave = async (e) => {
- e.preventDefault()
- if (!task.name?.trim()) { addToast('Task name is required', 'warning'); return }
- if (cronError) { addToast('Fix cron expression errors first', 'warning'); return }
+ const summary = (sid) => {
+ if (sid === 'basics') {
+ if (!task.name.trim()) return { state: 'needs', text: t('jobs.edit.summary.basicsEmpty') }
+ return { state: 'ready', text: task.model ? t('jobs.edit.summary.basics', { name: task.name, model: task.model }) : t('jobs.edit.summary.basicsNoModel', { name: task.name }) }
+ }
+ if (sid === 'prompt') return { state: 'ready', text: task.prompt.trim() ? firstLine(task.prompt, 70) : t('jobs.edit.summary.promptEmpty') }
+ if (sid === 'schedule') {
+ if (cronError) return { state: 'needs', text: cronErrorText(cronError, t) }
+ if (!task.cron.trim()) return { state: 'ready', text: t('jobs.edit.summary.scheduleNone') }
+ return { state: 'ready', text: `${scheduleWords(task.cron, t, i18n.language) || t('jobs.cron.custom')}, ${task.cron.trim()}` }
+ }
+ if (sid === 'media') return { state: 'ready', text: task.multimedia_sources.length ? t('jobs.edit.summary.media', { count: task.multimedia_sources.length }) : t('jobs.edit.summary.mediaNone') }
+ return { state: 'ready', text: task.webhooks.length ? t('jobs.edit.summary.webhooks', { count: task.webhooks.length }) : t('jobs.edit.summary.webhooksNone') }
+ }
+
+ const save = async (e) => {
+ e?.preventDefault?.()
+ if (!task.name?.trim()) { addToast(t('jobs.edit.nameRequired'), 'warning'); setOpen(prev => new Set([...prev, 'basics'])); return }
+ if (cronError) { addToast(t('jobs.edit.cronFix'), 'warning'); setOpen(prev => new Set([...prev, 'schedule'])); return }
setSaving(true)
try {
const body = { ...task }
+ if (body.cron_parameters && typeof body.cron_parameters === 'string') body.cron_parameters = parseKeyValues(body.cron_parameters)
+ else delete body.cron_parameters
- // Parse cron_parameters from key=value lines to object
- if (body.cron_parameters && typeof body.cron_parameters === 'string') {
- const params = {}
- body.cron_parameters.split('\n').forEach(line => {
- const [key, ...rest] = line.split('=')
- if (key?.trim() && rest.length > 0) {
- params[key.trim()] = rest.join('=').trim()
- }
- })
- body.cron_parameters = params
- } else if (!body.cron_parameters || body.cron_parameters === '') {
- delete body.cron_parameters
- }
-
- // Parse webhook headers from JSON strings
- if (body.webhooks) {
- body.webhooks = body.webhooks.map(wh => ({
- ...wh,
- headers: typeof wh.headers === 'string' ? JSON.parse(wh.headers || '{}') : wh.headers,
- }))
- }
-
- // Parse multimedia source headers
- if (body.multimedia_sources) {
- body.multimedia_sources = body.multimedia_sources.map(ms => ({
- ...ms,
- headers: typeof ms.headers === 'string' ? JSON.parse(ms.headers || '{}') : ms.headers,
- }))
+ const parseHeaders = (text, where) => {
+ try { return JSON.parse(text || '{}') } catch { throw new Error(t('jobs.edit.headerJson', { where })) }
}
+ body.webhooks = body.webhooks.map((wh, i) => ({ ...wh, headers: typeof wh.headers === 'string' ? parseHeaders(wh.headers, `${t('jobs.task.webhooks')} ${i + 1}`) : wh.headers }))
+ body.multimedia_sources = body.multimedia_sources.map((ms, i) => ({ ...ms, headers: typeof ms.headers === 'string' ? parseHeaders(ms.headers, `${t('jobs.task.media')} ${i + 1}`) : ms.headers }))
if (isNew) {
await agentJobsApi.createTask(body)
- addToast('Task created', 'success')
+ addToast(t('jobs.edit.created'), 'success')
} else {
await agentJobsApi.updateTask(id, body)
- addToast('Task updated', 'success')
+ addToast(t('jobs.edit.updated'), 'success')
}
navigate('/app/agent-jobs')
} catch (err) {
- addToast(`Save failed: ${err.message}`, 'error')
+ addToast(t('jobs.edit.saveFailed', { message: err.message }), 'error')
} finally {
setSaving(false)
}
}
- const statusBadge = (status) => {
- const cls = status === 'completed' ? 'badge-success' : status === 'failed' ? 'badge-error' : status === 'running' ? 'badge-info' : status === 'cancelled' ? '' : 'badge-warning'
- return
{status || 'unknown'}
- }
+ if (loading) return
- const formatDate = (d) => d ? new Date(d).toLocaleString() : '-'
+ const leave = () => navigate('/app/agent-jobs')
+ const saveLabel = isNew ? t('jobs.edit.create') : t('jobs.edit.save')
- if (loading) return
-
- // View mode
- if (!isNew && !isEdit) {
- return (
-
-
- navigate('/app/agent-jobs')}>
- Back
-
- navigate(`/app/agent-jobs/tasks/${id}/edit`)}>
- Edit
-
-
- }
- />
-
- {/* Task Info */}
-
-
-
- Task Information
-
-
-
-
Model
-
{task.model || '-'}
-
-
-
Status
-
{task.enabled !== false ? Enabled : Disabled }
-
- {task.cron && (
-
-
Cron Schedule
-
{task.cron}
-
- )}
+ const renderSection = (sid) => {
+ if (sid === 'basics') {
+ return (
+
+
+ {t('jobs.edit.name')}
+ set('name', e.target.value)} placeholder={t('jobs.edit.namePlaceholder')} required />
- {task.prompt && (
-
-
Prompt Template
-
- {task.prompt}
-
-
- )}
- {task.context && (
-
-
Context
-
- {task.context}
-
-
- )}
-
-
- {/* API Usage Examples */}
-
-
-
- API Usage
-
-
+
+ {t('jobs.edit.model')}
+ set('model', model)} capability={CAP_CHAT} />
+
+
+ {t('jobs.edit.description')}
+ set('description', e.target.value)} placeholder={t('jobs.edit.descriptionPlaceholder')} />
+
+
+
set('enabled', !task.enabled)}
+ />
-
Execute by name
-
-{`curl -X POST ${window.location.origin}${basePath}/api/agent/tasks/${encodeURIComponent(task.name)}/execute`}
-
-
-
-
Execute with multimedia
-
-{`curl -X POST ${window.location.origin}${basePath}/api/agent/tasks/${encodeURIComponent(task.name)}/execute \\
- -H "Content-Type: application/json" \\
- -d '{"multimedia": {"images": [{"url": "https://example.com/image.jpg"}]}}'`}
-
-
-
-
Check job status
-
-{`curl ${window.location.origin}${basePath}/api/agent/jobs/`}
-
+
{t('jobs.edit.enabled')}
+
{t('jobs.edit.enabledHint')}
-
- {/* Webhooks */}
- {task.webhooks && task.webhooks.length > 0 && (
-
-
-
- Webhooks ({task.webhooks.length})
-
- {task.webhooks.map((wh, i) => (
-
-
-
{wh.method || 'POST'}
-
{wh.url}
+ )
+ }
+ if (sid === 'prompt') {
+ return (
+
+
+
{t('jobs.edit.prompt')}
+
set('prompt', e.target.value)}
+ placeholder={t('jobs.edit.promptPlaceholder', { topic: '{{.topic}}', format: '{{.format}}' })}
+ />
+ {t('jobs.edit.promptHint', { gap: '{{.name}}' })}
+
+ {gaps.length === 0
+ ? {t('jobs.edit.foundNone')}
+ : <>{t('jobs.edit.found')} {gaps.map(g => {`{{.${g}}}`} )}>}
+
+
+
+ {t('jobs.edit.context')}
+ set('context', e.target.value)} placeholder={t('jobs.edit.contextPlaceholder')} />
+
+
+ )
+ }
+ if (sid === 'schedule') {
+ const kinds = ['none', 'hourly', 'daily', 'weekdays', 'custom']
+ const words = !cronError && task.cron.trim() ? (scheduleWords(task.cron, t, i18n.language) || t('jobs.cron.custom')) : ''
+ return (
+
+
+
{t('jobs.edit.when')}
+
+ {kinds.map(k => (
+ pickPreset(k)}
+ >
+ {t(`jobs.edit.preset${k[0].toUpperCase()}${k.slice(1)}`)}
+
+ ))}
+
+
+ {(preset.kind === 'daily' || preset.kind === 'weekdays') && (
+
+ {t('jobs.edit.time')}
+ { if (e.target.value) set('cron', buildPreset(preset.kind, e.target.value)) }}
+ />
+
+ )}
+ {preset.kind === 'custom' && (
+
+
{t('jobs.edit.cron')}
+
set('cron', e.target.value)}
+ placeholder="0 */6 * * *"
+ aria-invalid={cronError ? 'true' : undefined}
+ aria-describedby={cronError ? 'aj-cron-e' : 'aj-cron-h'}
+ spellCheck={false}
+ />
+ {cronError
+ ?
{cronErrorText(cronError, t)}
+ :
{t('jobs.edit.cronHint')}
}
+
+ )}
+ {words && (
+
+ {words}. {t('jobs.cron.zone')}
+
+ )}
+ {task.cron.trim() && !cronError && (
+
+
{t('jobs.edit.scheduleParams')}
+
set('cron_parameters', e.target.value)}
+ placeholder={'topic=daily news\nformat=bullet points'}
+ />
+ {t('jobs.edit.scheduleParamsHint')}
+
+ )}
+ {task.cron.trim() && !cronError && unset.length > 0 && (
+
+ {t('jobs.edit.gapNoValue', { names: unset.map(g => `{{.${g}}}`).join(', ') })}
+
+ )}
+
+ )
+ }
+ if (sid === 'media') {
+ return (
+
+
{t('jobs.edit.mediaNote')}
+ {task.multimedia_sources.length === 0 &&
{t('jobs.edit.mediaEmpty')}
}
+ {task.multimedia_sources.map((ms, i) => (
+
+
+
+ {t('jobs.edit.sourceType')}
+
+ setItem('multimedia_sources', i, 'type', e.target.value)}>
+ {['image', 'video', 'audio', 'file'].map(k => {t(`jobs.edit.${k}`)} )}
+
+
+
+ {t('jobs.edit.url')}
+ setItem('multimedia_sources', i, 'url', e.target.value)} placeholder="https://example.com/media.jpg" />
+
+
removeItem('multimedia_sources', i)}>
+
+
- ))}
+
+ {t('jobs.edit.headers')}
+ setItem('multimedia_sources', i, 'headers', e.target.value)} placeholder='{"Authorization": "Bearer ..."}' />
+
+
+ ))}
+
+ set('multimedia_sources', [...task.multimedia_sources, { type: 'image', url: '', headers: '{}' }])}>
+ {t('jobs.edit.addSource')}
+
- )}
-
- {/* Job History */}
- {jobHistory.length > 0 && (
-
-
-
- Recent Jobs ({jobHistory.length})
-
-
-
-
- Job ID Status Created Actions
-
-
- {jobHistory.map(job => (
-
-
- {job.id?.slice(0, 12)}...
-
- {statusBadge(job.status)}
- {formatDate(job.created_at)}
-
- navigate(`/app/agent-jobs/jobs/${job.id}`)}>
- View
-
-
-
- ))}
-
-
+
+ )
+ }
+ return (
+
+
{t('jobs.edit.webhooksNote')}
+ {task.webhooks.length === 0 &&
{t('jobs.edit.webhooksEmpty')}
}
+ {task.webhooks.map((wh, i) => (
+
+
+
+ {t('jobs.edit.method')}
+
+ setItem('webhooks', i, 'method', e.target.value)}>
+ {['POST', 'PUT', 'PATCH'].map(m => {m} )}
+
+
+
+
+ {t('jobs.edit.url')}
+ setItem('webhooks', i, 'url', e.target.value)} placeholder="https://hooks.slack.com/..." />
+
+
removeItem('webhooks', i)}>
+
+
+
+
+ {t('jobs.edit.headers')}
+ setItem('webhooks', i, 'headers', e.target.value)} placeholder='{"Content-Type": "application/json"}' />
+
+
+
{t('jobs.edit.payload')}
+
setItem('webhooks', i, 'payload_template', e.target.value)}
+ placeholder={'{"text": "Job {{.Status}}: {{if .Error}}Error: {{.Error}}{{else}}{{.Result}}{{end}}"}'}
+ />
+ {t('jobs.edit.payloadHint', { vars: '{{.Job}} {{.Task}} {{.Result}} {{.Error}} {{.Status}}' })}
- )}
+ ))}
+
+ set('webhooks', [...task.webhooks, { url: '', method: 'POST', headers: '{}', payload_template: '' }])}>
+ {t('jobs.edit.addWebhook')}
+
+
)
}
- // Edit/Create form
return (
-
-
navigate('/app/agent-jobs')}>
- Back
-
- }
- />
-
-
- {/* Basic Info */}
-
-
Basic Information
-
-
- Task Name *
- updateField('name', e.target.value)} placeholder="my-task" required />
-
-
- Model
- updateField('model', model)} capability={CAP_CHAT} />
-
+
+
+
+
+
+
{t('jobs.edit.back')}
+
{isNew ? t('jobs.edit.titleNew') : t('jobs.edit.titleEdit', { name: initial.current?.name || task.name })}
+
{isNew ? t('jobs.edit.subNew') : t('jobs.edit.subEdit')}
-
- Description
- updateField('description', e.target.value)} placeholder="Brief description of what this task does" />
-
-
-
- updateField('enabled', e.target.checked)} />
- Enabled
-
-
-
-
- {/* Prompt Template */}
-
-
Prompt Template
-
-
Prompt
-
updateField('prompt', e.target.value)}
- rows={8}
- placeholder={`Write a summary about {{.topic}} in {{.format}} format.`}
- />
-
- Use {'{{.parameter_name}}'} for dynamic parameters. Parameters are provided when executing the task.
-
-
-
- Context (optional)
- updateField('context', e.target.value)} rows={3} placeholder="Additional context for the agent..." />
-
-
-
- {/* Cron Schedule */}
-
-
-
- Cron Schedule (optional)
-
-
-
Cron Expression
-
{ updateField('cron', e.target.value); validateCron(e.target.value) }}
- placeholder="0 */6 * * *"
- />
- {cronError &&
{cronError}
}
-
- Format: minute hour day month weekday (e.g., "0 */6 * * *" = every 6 hours)
-
-
- {task.cron && (
-
-
Cron Parameters (key=value, one per line)
-
updateField('cron_parameters', e.target.value)}
- rows={3}
- placeholder={`topic=daily news\nformat=bullet points`}
- />
-
- Default parameters used when the cron triggers the task.
-
-
- )}
-
-
- {/* Multimedia Sources */}
-
-
-
-
- Multimedia Sources (optional)
-
-
- Add Source
+
+ setPreview('prompt')} data-testid="task-preview-open">
+ {t('jobs.edit.preview')}
+ {diff.length > 0 && {diff.length} }
+
+ {t('jobs.edit.discard')}
+
+ {saving ? <> {t('jobs.edit.saving')}> : <> {saveLabel}>}
- {task.multimedia_sources.length === 0 ? (
- No multimedia sources configured.
- ) : (
- task.multimedia_sources.map((ms, i) => (
-
-
-
- Type
- updateMultimediaSource(i, 'type', e.target.value)}>
- Image
- Video
- Audio
- File
-
-
-
- URL
- updateMultimediaSource(i, 'url', e.target.value)} placeholder="https://example.com/media.jpg" />
-
-
removeMultimediaSource(i)} style={{ marginTop: 24 }}>
-
+
+
+
+ {SECTIONS.map(s => {
+ const isOpen = open.has(s.id)
+ const info = summary(s.id)
+ const n = changed(s).length
+ return (
+
+
+ toggleSection(s.id)}>
+
+ {t(`jobs.edit.sections.${s.id}`)}
+ {info.text}
+ {n > 0 ? {t('jobs.edit.changed', { count: n })} : }
+
-
-
- Headers (JSON)
- updateMultimediaSource(i, 'headers', e.target.value)} placeholder='{"Authorization": "Bearer ..."}' />
-
-
- ))
- )}
+
+ {isOpen &&
{renderSection(s.id)}
}
+
+ )
+ })}
- {/* Webhooks */}
-
-
-
-
- Webhooks (optional)
-
-
- Add Webhook
-
-
- {task.webhooks.length === 0 ? (
-
No webhooks configured.
- ) : (
- task.webhooks.map((wh, i) => (
-
-
-
- Method
- updateWebhook(i, 'method', e.target.value)}>
- POST
- PUT
- PATCH
-
-
-
- URL
- updateWebhook(i, 'url', e.target.value)} placeholder="https://hooks.slack.com/..." />
-
-
removeWebhook(i)} style={{ marginTop: 24 }}>
-
-
-
-
- Headers (JSON)
- updateWebhook(i, 'headers', e.target.value)} placeholder='{"Content-Type": "application/json"}' />
-
-
-
Payload Template (Go template syntax)
-
updateWebhook(i, 'payload_template', e.target.value)}
- rows={3}
- placeholder={`{"text": "Job {{.Status}}: {{if .Error}}Error: {{.Error}}{{else}}{{.Result}}{{end}}"}`}
- />
-
- Available: {'{{.Job}}'} {'{{.Task}}'} {'{{.Result}}'} {'{{.Error}}'} {'{{.Status}}'}
-
-
-
- ))
- )}
-
-
-
-
- {saving ? <> Saving...> : <> {isNew ? 'Create Task' : 'Save Changes'}>}
+
+ {t('jobs.edit.discard')}
+
+ {saving ? <> {t('jobs.edit.saving')}> : <> {saveLabel}>}
- navigate('/app/agent-jobs')}>Cancel
+
+ {preview && (
+ { setPreview(null); save() }}
+ onClose={() => setPreview(null)}
+ />
+ )}
)
}
+
+// What the next scheduled run would send, and what differs from the saved task.
+// eslint-disable-next-line no-unused-vars
+function PreviewSheet({ tab, onTab, task, params, diff, isNew, saving, onSave, onClose }) {
+ const { t } = useTranslation('agents')
+ const sheetRef = useRef(null)
+ const closeRef = useRef(null)
+
+ useEffect(() => {
+ const opener = document.activeElement
+ closeRef.current?.focus()
+ const onKey = (e) => {
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); onClose(); return }
+ if (e.key !== 'Tab' || !sheetRef.current) return
+ const focusable = Array.from(sheetRef.current.querySelectorAll('button:not([disabled]), [tabindex]:not([tabindex="-1"])'))
+ if (focusable.length === 0) return
+ const first = focusable[0]
+ const last = focusable[focusable.length - 1]
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
+ }
+ window.addEventListener('keydown', onKey, true)
+ return () => {
+ window.removeEventListener('keydown', onKey, true)
+ if (opener && document.contains(opener)) opener.focus?.()
+ }
+ }, [onClose])
+
+ return createPortal(
+
+
e.stopPropagation()}
+ >
+
+
+
+
{t('jobs.edit.previewTitle')}
+
{isNew ? t('jobs.edit.previewNew') : t('jobs.edit.previewEdit')}
+
+
+
+
+
+
+
+ onTab('prompt')}>{t('jobs.edit.tabPrompt')}
+ onTab('changes')}>
+ {t('jobs.edit.tabChanges')}{diff.length > 0 && {diff.length} }
+
+
+ {tab === 'prompt' ? (
+
+
{t('jobs.edit.promptAsSent')}
+ {task.prompt.trim() ?
:
{t('jobs.edit.emptyPrompt')}
}
+ {task.context.trim() && (
+
+
{t('jobs.task.context')}
+
{task.context}
+
+ )}
+
+ ) : (
+
+ {diff.length === 0 ? (
+
{isNew ? t('jobs.edit.nothingSet') : t('jobs.edit.noChanges')}
+ ) : (
+
+ {diff.map(d => (
+
+
{d.key}
+
+
{t('jobs.edit.saved')} {d.before || t('jobs.edit.empty')}
+
{t('jobs.edit.new')} {d.after || t('jobs.edit.empty')}
+
+
+ ))}
+
+ )}
+
+ )}
+
+
+ {t('jobs.edit.close')}
+ {isNew ? t('jobs.edit.create') : t('jobs.edit.save')}
+
+
+
,
+ document.body,
+ )
+}
diff --git a/core/http/react-ui/src/pages/Agents.jsx b/core/http/react-ui/src/pages/Agents.jsx
index c45539dea..caa24761a 100644
--- a/core/http/react-ui/src/pages/Agents.jsx
+++ b/core/http/react-ui/src/pages/Agents.jsx
@@ -1,12 +1,28 @@
-import { useState, useEffect, useCallback, useMemo } from 'react'
-import { useNavigate, useOutletContext } from 'react-router-dom'
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
+// eslint-disable-next-line no-unused-vars
+import { Link, useNavigate, useOutletContext } from 'react-router-dom'
+// eslint-disable-next-line no-unused-vars
import { useTranslation, Trans } from 'react-i18next'
import { agentsApi } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { useUserMap } from '../hooks/useUserMap'
+// eslint-disable-next-line no-unused-vars
import UserGroupSection from '../components/UserGroupSection'
+// eslint-disable-next-line no-unused-vars
import PageHeader from '../components/PageHeader'
+// eslint-disable-next-line no-unused-vars
import ConfirmDialog from '../components/ConfirmDialog'
+// eslint-disable-next-line no-unused-vars
+import ActionMenu from '../components/ActionMenu'
+import Icon from '../components/Icon'
+// eslint-disable-next-line no-unused-vars
+import { StatusMark, RunStrip, RecordLine, Chip, LibraryChips, agentPath } from '../components/agents/AgentBits'
+import { agentInfo, workingLine } from '../utils/agentInfo'
+import { effectiveStatus, loadRuns, outcomeLine, recordStatus } from '../utils/agentRuns'
+import { AGENT_TEMPLATES } from '../utils/agentConfigTools'
+import './agents.css'
+
+const DAY = 24 * 60 * 60 * 1000
export default function Agents() {
const { addToast } = useOutletContext()
@@ -20,6 +36,9 @@ export default function Agents() {
const [search, setSearch] = useState('')
const [userGroups, setUserGroups] = useState(null)
const [confirmDialog, setConfirmDialog] = useState(null)
+ const [configs, setConfigs] = useState({})
+ const [tick, setTick] = useState(0)
+ const asked = useRef(new Set())
const fetchAgents = useCallback(async () => {
try {
@@ -29,13 +48,17 @@ export default function Agents() {
if (data.agent_hub_url) setAgentHubURL(data.agent_hub_url)
setUserGroups(data.user_groups || null)
- // Fetch observable counts for each agent
- const agentsWithCounts = await Promise.all(
+ // An observable with no completion is an action still running: that is
+ // what "working" means here. The count stays for the status page link.
+ const withState = await Promise.all(
names.map(async (name) => {
let eventsCount = 0
+ let working = null
try {
const observables = await agentsApi.observables(name)
- eventsCount = observables?.History?.length || 0
+ const history = observables?.History || []
+ eventsCount = history.length
+ working = workingLine(history)
} catch (_err) {
eventsCount = 0
}
@@ -43,10 +66,12 @@ export default function Agents() {
name,
status: statuses[name] ? 'active' : 'paused',
eventsCount,
+ working,
}
})
)
- setAgents(agentsWithCounts)
+ setAgents(withState)
+ setTick(n => n + 1)
} catch (err) {
addToast(t('toasts.loadFailed', { message: err.message }), 'error')
} finally {
@@ -60,11 +85,49 @@ export default function Agents() {
return () => clearInterval(interval)
}, [fetchAgents])
+ // The model and what an agent has attached live in its saved config. Read
+ // each once; the list poll does not repeat it.
+ useEffect(() => {
+ agents.forEach(({ name }) => {
+ if (asked.current.has(name)) return
+ asked.current.add(name)
+ agentsApi.getConfig(name)
+ .then(cfg => setConfigs(prev => ({ ...prev, [name]: cfg })))
+ .catch(() => setConfigs(prev => ({ ...prev, [name]: null })))
+ })
+ }, [agents])
+
+ const rows = useMemo(() => agents.map(a => {
+ const runs = loadRuns(a.name)
+ return { ...a, runs, info: agentInfo(configs[a.name]), configLoaded: a.name in configs }
+ // tick re-reads the run log after every poll
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }), [agents, configs, tick])
+
const filtered = useMemo(() => {
- if (!search.trim()) return agents
- const q = search.toLowerCase()
- return agents.filter(a => a.name.toLowerCase().includes(q))
- }, [agents, search])
+ const q = search.trim().toLowerCase()
+ if (!q) return rows
+ return rows.filter(a => a.name.toLowerCase().includes(q) || a.info.description.toLowerCase().includes(q) || a.info.model.toLowerCase().includes(q))
+ }, [rows, search])
+
+ // Now: agents with work in flight, and agents whose latest run failed in
+ // the last day. Both are only what this browser and the observables know.
+ const now = useMemo(() => {
+ const items = []
+ const at = Date.now()
+ for (const a of rows) {
+ const live = a.runs.find(r => effectiveStatus(r, at) === 'running')
+ if (a.working || live) {
+ items.push({ kind: 'running', agent: a, runId: live?.id, line: a.working || live.turns.at(-1).task })
+ continue
+ }
+ const last = a.runs.find(r => !r.legacy) || a.runs[0]
+ if (last && recordStatus(last, at) === 'failed' && at - last.startedAt < DAY) {
+ items.push({ kind: 'failed', agent: a, runId: last.id, line: outcomeLine(last, 140) || last.turns[0].task })
+ }
+ }
+ return items
+ }, [rows])
const handleDelete = (name, userId) => {
setConfirmDialog({
@@ -133,55 +196,15 @@ export default function Agents() {
e.target.value = ''
}
- const statusBadge = (status) => {
- const cls = status === 'active' ? 'badge-success' : status === 'paused' ? 'badge-warning' : ''
- return {status || 'unknown'}
- }
+ const importLabel = (
+
+ {t('actions.import')}
+
+
+ )
return (
-
-
-
+
}
@@ -207,15 +227,26 @@ export default function Agents() {
{loading ? (
-
+
) : agents.length === 0 && !userGroups ? (
-
-
+
{t('empty.noConfigured')}
-
{t('empty.noConfiguredText')}
+
{t('empty.noConfiguredText')}
+
{t('empty.startWith')}
+
+ {AGENT_TEMPLATES.filter(x => x.id !== 'blank').map(x => (
+
+
+ {t(`templates.${x.id}.label`)}
+ {x.description}
+
+
{t('empty.useTemplate')}
+
+ ))}
+
{agentHubURL && (
-
+
)}
-
) : (
- <>
- {userGroups &&
{t('sections.yourAgents')} }
-
-
-
- setSearch(e.target.value)}
- />
-
-
- {t('search.summary', { shown: filtered.length, total: agents.length, count: agents.length })}
-
-
-
- {filtered.length === 0 ? (
-
-
-
{t('empty.noMatching')}
-
{t('empty.noMatchingText', { query: search })}
-
- ) : (
-
+
+ {now.length > 0 && (
+
+
{t('now.title')}
+
+ {now.map(item => (
+
+
+
+ {item.agent.name}
+ {item.line}
+
+
+
+ {t('now.open')}
+
+ {item.kind === 'failed' && (
+ r.id === item.runId)?.turns[0].task }}
+ >
+ {t('now.runAgain')}
+
+ )}
+
+
+ ))}
+
+
)}
- >
+
+
+
{userGroups ? t('sections.yourAgents') : t('sections.agents')}
+
+ {t('search.summary', { shown: filtered.length, total: agents.length, count: agents.length })}
+
+
+
+
+
+ setSearch(e.target.value)}
+ />
+
+
+
+ {filtered.length === 0 ? (
+
+
{t('empty.noMatching')}
+
{t('empty.noMatchingText', { query: search })}
+
+ ) : (
+
+ {filtered.map(agent => {
+ const name = agent.name
+ const isActive = agent.status === 'active'
+ const state = agent.working ? 'running' : isActive ? 'ready' : 'paused'
+ return (
+
+
+
+ {name}
+
+
+ {agent.info.description &&
{agent.info.description} }
+ {agent.configLoaded && (
+
+ {agent.info.model && {agent.info.model} }
+ {agent.info.memory.length > 0 && }
+ {agent.info.skills.length > 0 && }
+
+ )}
+
+
+
+
+
+
+
{t('actions.open')}
+
navigate(`/app/agents/${encodeURIComponent(name)}/edit`) },
+ { key: 'pause', icon: isActive ? 'pause' : 'play', label: isActive ? t('actions.pause') : t('actions.resume'), onClick: () => handlePauseResume(agent) },
+ { key: 'status', icon: 'chart-bar', label: t('actions.statusCount', { count: agent.eventsCount }), onClick: () => navigate(`/app/agents/${encodeURIComponent(name)}/status`) },
+ { key: 'export', icon: 'download', label: t('actions.export'), onClick: () => handleExport(name) },
+ { divider: true },
+ { key: 'delete', icon: 'trash', label: t('actions.delete'), danger: true, onClick: () => handleDelete(name) },
+ ]}
+ />
+
+
+ )
+ })}
+
+ )}
+ {t('record.where')}
+
+
)}
{userGroups && (
@@ -355,7 +388,7 @@ export default function Agents() {
currentUserId={user?.id}
itemKey="agents"
renderGroup={(items, userId) => (
-
+
@@ -370,42 +403,42 @@ export default function Agents() {
return (
- navigate(`/app/agents/${encodeURIComponent(a.name)}/chat?user_id=${encodeURIComponent(userId)}`)}>
- {a.name}
-
+ {a.name}
- {statusBadge(isActive ? 'active' : 'paused')}
+
-
+
handlePauseResume(a, userId)}
title={isActive ? t('actions.pause') : t('actions.resume')}
+ aria-label={isActive ? t('actions.pause') : t('actions.resume')}
>
-
+
navigate(`/app/agents/${encodeURIComponent(a.name)}/edit?user_id=${encodeURIComponent(userId)}`)}
title={t('actions.edit')}
+ aria-label={t('actions.edit')}
>
-
-
- navigate(`/app/agents/${encodeURIComponent(a.name)}/chat?user_id=${encodeURIComponent(userId)}`)}
- title={t('actions.chat')}
- >
-
+
handleExport(a.name, userId)}
title={t('actions.export')}
+ aria-label={t('actions.export')}
>
-
+
handleDelete(a.name, userId)}
title={t('actions.delete')}
+ aria-label={t('actions.delete')}
>
-
+
diff --git a/core/http/react-ui/src/pages/AudioTransform.jsx b/core/http/react-ui/src/pages/AudioTransform.jsx
index 6b75918f0..bd1f18101 100644
--- a/core/http/react-ui/src/pages/AudioTransform.jsx
+++ b/core/http/react-ui/src/pages/AudioTransform.jsx
@@ -1,19 +1,27 @@
import { useState, useEffect, useRef } from 'react'
+// eslint-disable-next-line no-unused-vars
import RequestPanel from '../components/RequestPanel'
import { useParams, useOutletContext } from 'react-router-dom'
-import ModelSelector from '../components/ModelSelector'
-import PageHeader from '../components/PageHeader'
import { CAP_AUDIO_TRANSFORM } from '../utils/capabilities'
-import LoadingSpinner from '../components/LoadingSpinner'
-import ErrorWithTraceLink from '../components/ErrorWithTraceLink'
+// eslint-disable-next-line no-unused-vars
import WaveformPlayer from '../components/audio/WaveformPlayer'
+// eslint-disable-next-line no-unused-vars
import Spectrogram from '../components/audio/Spectrogram'
import { audioTransformApi } from '../utils/api'
import { useMediaCapture } from '../hooks/useMediaCapture'
import useObjectUrl from '../hooks/useObjectUrl'
import { useMediaHistory } from '../hooks/useMediaHistory'
-import MediaHistory from '../components/MediaHistory'
+import { useStudioHandoff, useHandoffSource, blobToFile } from '../hooks/useStudioHandoff'
+import { apiUrl } from '../utils/basePath'
+// eslint-disable-next-line no-unused-vars
+import HandoffNote from '../components/studio/HandoffNote'
+import {
+ // eslint-disable-next-line no-unused-vars
+ Workspace, ComposeCard, ModelChip, Field, Fold, RunArea, JobCard, FailedCard, ResultCard, ViewCard, ResultsStrip, EmptyRun,
+} from '../components/studio/Workspace'
+import { foldSummary, useWorkspace } from '../hooks/useWorkspace'
import { useTranslation } from 'react-i18next'
+import Icon from '../components/Icon'
// AudioTransform — Studio tab for the audio_transform capability. Takes a
// primary audio file plus an optional reference (loopback for AEC, target
@@ -28,17 +36,29 @@ export default function AudioTransform() {
const { model: urlModel } = useParams()
const { addToast } = useOutletContext()
- const [model, setModel] = useState(urlModel || '')
+ // Opened from the Studio front page, the audio it was made from is the input.
+ const handoff = useStudioHandoff()
+ const [model, setModel] = useState(urlModel || handoff.model || '')
const [audioFile, setAudioFile] = useState(null)
const [referenceFile, setReferenceFile] = useState(null)
const [outputUrl, setOutputUrl] = useState(null)
const [paramsText, setParamsText] = useState('')
+ const [showAdvanced, setShowAdvanced] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
// What was actually sent, so the panel records rather than predicts.
const [lastRequest, setLastRequest] = useState(null)
const { addEntry, selectEntry, selectedEntry, historyProps } = useMediaHistory('audio-transform')
+ const ws = useWorkspace({ type: 'transform', entries: historyProps.entries })
+ const [lastId, setLastId] = useState(null)
+ const wantsSource = handoff.edge === 'transform' || handoff.edge === 'take'
+ const source = useHandoffSource(handoff, wantsSource)
+ useEffect(() => {
+ if (source.status !== 'ready') return
+ const base = (source.item?.url || '').split('?')[0].split('/').pop() || 'audio.wav'
+ setAudioFile(blobToFile(source.blob, base))
+ }, [source])
// Hidden element that plays the reference out the speakers while
// the mic records — the recording captures the user's voice plus the
@@ -56,6 +76,9 @@ export default function AudioTransform() {
return () => { if (outputUrl) URL.revokeObjectURL(outputUrl) }
}, [outputUrl])
+ const activeEntry = selectedEntry || (lastId ? historyProps.entries.find(e => e.id === lastId) : null) || null
+ const item = ws.itemById(activeEntry?.id)
+
const parseParams = () => {
const out = {}
for (const raw of paramsText.split('\n')) {
@@ -70,13 +93,13 @@ export default function AudioTransform() {
return out
}
- const handleProcess = async (e) => {
- e.preventDefault()
- if (!model) { addToast('Please select a model', 'warning'); return }
- if (!audioFile) { addToast('Please choose an audio file', 'warning'); return }
+ const run = async () => {
+ if (!model) { addToast(t('studio.workspace.transform.noModel'), 'warning'); return }
+ if (!audioFile) { addToast(t('studio.workspace.transform.noAudio'), 'warning'); return }
setLoading(true)
setError(null)
+ setLastId(null)
if (outputUrl) { URL.revokeObjectURL(outputUrl); setOutputUrl(null) }
// The audio itself is multipart, not JSON, so the panel records the fields
@@ -93,13 +116,13 @@ export default function AudioTransform() {
})
const url = URL.createObjectURL(blob)
setOutputUrl(url)
- addToast('Audio transformed', 'success')
+ addToast(t('studio.workspace.transform.done'), 'success')
if (serverUrl) {
// Save the persisted (input, reference, output) triple so a click
// in the History panel can later replay all three players. The
// server held onto the converted 16 kHz mono inputs — saving raw
// upload bytes in localStorage would blow past quota in a few runs.
- addEntry({
+ setLastId(addEntry({
prompt: describeRun(audioFile, referenceFile),
model,
params: parseParams(),
@@ -108,9 +131,12 @@ export default function AudioTransform() {
inputUrl ? { kind: 'input', url: inputUrl } : null,
refServerUrl ? { kind: 'reference', url: refServerUrl } : null,
].filter(Boolean),
- })
+ parentId: handoff.from || undefined,
+ edge: handoff.edge || undefined,
+ }))
}
selectEntry(null)
+ ws.end()
} catch (err) {
setError(err.message)
} finally {
@@ -118,6 +144,35 @@ export default function AudioTransform() {
}
}
+ // Re-run with edits: the model and the parameters go back into the form, and
+ // so do the audio and reference the server kept for that run.
+ const rerun = async (it) => {
+ const entry = historyProps.entries.find(e => e.id === it.id)
+ if (!entry) return
+ const next = {
+ model: entry.model || model,
+ params: Object.entries(entry.params || {}).map(([k, v]) => `${k}=${v}`).join('\n'),
+ }
+ setModel(next.model)
+ setParamsText(next.params)
+ if (next.params) setShowAdvanced(true)
+ selectEntry(null)
+ ws.begin(next)
+ const fetchFile = async (url, fallback) => {
+ const res = await fetch(url.startsWith('http') || url.startsWith('blob:') ? url : apiUrl(url))
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
+ return blobToFile(await res.blob(), url.split('?')[0].split('/').pop() || fallback)
+ }
+ const input = entry.results?.find(r => r.kind === 'input')
+ const reference = entry.results?.find(r => r.kind === 'reference')
+ try {
+ if (input) setAudioFile(await fetchFile(input.url, 'audio.wav'))
+ setReferenceFile(reference ? await fetchFile(reference.url, 'reference.wav') : null)
+ } catch {
+ addToast(t('studio.workspace.transform.inputGone'), 'warning')
+ }
+ }
+
// The echo-test playback listener is held in a ref so stopEchoTest can
// detach it without depending on the closure that registered it.
const echoEndedListenerRef = useRef(null)
@@ -130,11 +185,11 @@ export default function AudioTransform() {
const startEchoTest = async () => {
if (!referenceUrl) {
- addToast('Load a reference first', 'warning')
+ addToast(t('studio.workspace.transform.loadReference'), 'warning')
return
}
if (!echoCap.supported) {
- addToast('Browser does not expose getUserMedia', 'warning')
+ addToast(t('studio.workspace.transform.noMic'), 'warning')
return
}
try {
@@ -157,10 +212,10 @@ export default function AudioTransform() {
echoCap.stop()
const file = new File([result.blob], 'mic-echo-test.wav', { type: 'audio/wav' })
setAudioFile(file)
- addToast('Recorded (mic + reference echo). Click Transform to test AEC.', 'success')
+ addToast(t('studio.workspace.transform.recorded'), 'success')
} catch (err) {
detachEchoEndedListener()
- addToast(`Echo test failed: ${err?.message || err}`, 'error')
+ addToast(t('studio.workspace.transform.echoFailed', { message: err?.message || String(err) }), 'error')
}
}
@@ -173,17 +228,52 @@ export default function AudioTransform() {
echoCap.stop()
}
+ const labels = { model: t('studio.workspace.fields.model'), params: t('studio.workspace.fields.params') }
+ const changes = ws.changes({ model, params: paramsText }, labels)
+ const changed = new Set(changes.map(c => c.field))
+
+ const installed = ws.installed.byType.transform
+ const noModel = !ws.installed.loading && !ws.installed.error && installed.length === 0
+ const why = noModel ? t('studio.composer.whyModel', { type: t('studio.tabs.transform') })
+ : !audioFile ? t('studio.workspace.transform.whyAudio') : ''
+ const paramCount = Object.keys(parseParams()).length
+
return (
-
-
-
{t('audioTransform.title')}>} />
-
-
-
- {t('audioTransform.labels.model')}
-
-
-
+
+ { e.preventDefault(); run() }}
+ handoff={ setAudioFile(null)} />}
+ model={model}
+ noModel={noModel ? { type: 'transform', label: t('studio.tabs.transform'), onChanged: ws.installed.refetch } : null}
+ options={ }
+ fold={
+ setShowAdvanced(v => !v)}
+ id="transform-advanced-options"
+ >
+
+ setParamsText(e.target.value)}
+ placeholder={t('audioTransform.labels.advancedParametersPlaceholder')}
+ rows={4}
+ />
+
+
+ }
+ changes={changes}
+ submit={{ label: t('audioTransform.actions.transform'), busyLabel: t('audioTransform.actions.processing'), busy: loading, disabled: !model || !audioFile || noModel, why }}
+ >
+
- {referenceFile && (
-
-
-
-
- {t('audioTransform.input.echoNotice')}
+ {referenceFile && (
+
+
+
+
+ {t('audioTransform.input.echoNotice')}
+
+
+
+
+ {echoActive
+ ? <> {t('audioTransform.input.stopEchoTest')}>
+ : <> {t('audioTransform.input.echoTest')}>}
+
+ {echoActive && echoCap.recording && (
+
+ {t('studio.workspace.transform.recording', { seconds: echoCap.elapsed.toFixed(1) })}
-
-
-
- {echoActive
- ? <> {t('audioTransform.input.stopEchoTest')}>
- : <> {t('audioTransform.input.echoTest')}>}
-
- {echoActive && echoCap.recording && (
-
- recording {echoCap.elapsed.toFixed(1)}s
-
- )}
-
- {/* Hidden player for the reference clip during the echo test.
- Hidden because the user already has the WaveformPlayer in
- the preview pane — this is just the audible source. */}
-
+ )}
- )}
-
-
-
- {t('audioTransform.labels.advancedParameters')}
- — {t('audioTransform.labels.advancedParametersHelp')}
-
-
setParamsText(e.target.value)}
- placeholder={t('audioTransform.labels.advancedParametersPlaceholder')}
- rows={4}
- />
+ {/* Hidden player for the reference clip during the echo test.
+ Hidden because the user already has the WaveformPlayer in
+ the preview pane — this is just the audible source. */}
+
+ )}
+
-
- {loading ? <> {t('audioTransform.actions.processing')}> : <> {t('audioTransform.actions.transform')}>}
-
-
-
-
-
-
-
-
- {error ? (
-
- ) : selectedEntry ? (
-
+
+ {loading ? (
+
+ ) : error ? (
+
+ ) : selectedEntry ? (
+ r.kind === 'output')?.url, name: `audio-transform-${selectedEntry.model || 'output'}.wav` }}
+ onRerun={rerun}
+ >
+
{selectedEntry.results.map((r) => (
))}
-
{selectedEntry.prompt}
- ) : (
-
- {audioUrl && (
-
-
- {outputUrl ? (
-
- ) : (
-
-
{t('audioTransform.result.outputSpectrum')}
-
- {t('audioTransform.result.outputSpectrumHint')}
-
-
- )}
-
- )}
+
+ ) : outputUrl ? (
+
+
+
+
+
+
- {outputUrl && (
-
- )}
- {!audioUrl && !outputUrl && (
-
-
-
{t('audioTransform.empty')}
-
- )}
+
- )}
-
-
-
+
+ ) : audioUrl ? (
+
+
+
+
+
+
{t('audioTransform.result.outputSpectrum')}
+
+ {t('audioTransform.result.outputSpectrumHint')}
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+
)
}
@@ -331,6 +418,7 @@ function resultLabel(r) {
// mic-record tab. Emits a single File via onChange (recordings are wrapped
// as `File([blob], 'recording-XXX.wav', { type: 'audio/wav' })` so callers
// can treat them identically to uploaded files).
+// eslint-disable-next-line no-unused-vars
function AudioInput({ label, help, file, onChange }) {
const { t } = useTranslation('media')
const [tab, setTab] = useState('upload') // 'upload' | 'record'
@@ -380,7 +468,7 @@ function AudioInput({ label, help, file, onChange }) {
className={`audio-transform-input__tab${tab === 'upload' ? ' active' : ''}`}
onClick={() => setTab('upload')}
>
-
{t('audioTransform.input.upload')}
+
{t('audioTransform.input.upload')}
setTab('record')}
>
- {t('audioTransform.input.record')}
+ {t('audioTransform.input.record')}
@@ -403,12 +491,12 @@ function AudioInput({ label, help, file, onChange }) {
>
{hasFile ? (
- {file.name}
+ {file.name}
onChange(null)}>{t('audioTransform.input.clear')}
) : (
<>
- {t('audioTransform.input.uploadDescription')}
+ {t('audioTransform.input.uploadDescription')}
{t('audioTransform.input.uploadBrowse')}
@@ -422,19 +510,19 @@ function AudioInput({ label, help, file, onChange }) {
{!cap.supported && (
- {t('audioTransform.input.microphoneUnavailable')}
+ {t('audioTransform.input.microphoneUnavailable')}
)}
{cap.supported && (
<>
{!cap.recording && !recordPending && (
- {t('audioTransform.input.startRecording')}
+ {t('audioTransform.input.startRecording')}
)}
{cap.recording && (
- {t('audioTransform.input.stop')} ({cap.elapsed.toFixed(1)}s)
+ {t('audioTransform.input.stop')} ({cap.elapsed.toFixed(1)}s)
)}
{recordPending && !cap.recording && (
@@ -447,7 +535,7 @@ function AudioInput({ label, help, file, onChange }) {
)}
{hasFile && !cap.recording && (
- {file.name}
+ {file.name}
onChange(null)}>{t('audioTransform.input.clear')}
)}
diff --git a/core/http/react-ui/src/pages/BackendLogs.jsx b/core/http/react-ui/src/pages/BackendLogs.jsx
index 60e8e9438..3ebc5bec6 100644
--- a/core/http/react-ui/src/pages/BackendLogs.jsx
+++ b/core/http/react-ui/src/pages/BackendLogs.jsx
@@ -1,34 +1,49 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
-import { useParams, useSearchParams, useOutletContext, Link, Navigate } from 'react-router-dom'
+import { useParams, useSearchParams, useOutletContext, useNavigate, Link, Navigate } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
import { backendLogsApi, nodesApi } from '../utils/api'
import { formatTimestamp } from '../utils/format'
import { apiUrl } from '../utils/basePath'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
+import HomeUndoToast from '../components/home/HomeUndoToast'
import { useDistributedMode } from '../hooks/useDistributedMode'
+import Icon from '../components/Icon'
+import './operate.css'
function wsUrl(path) {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${window.location.host}${apiUrl(path)}`
}
-const STREAM_BADGE = {
- stdout: { bg: 'var(--color-info-light)', color: 'var(--color-log-info)', label: 'stdout' },
- stderr: { bg: 'var(--color-error-light)', color: 'var(--color-log-stderr)', label: 'stderr' },
-}
+const STREAM_LABEL = { stdout: 'out', stderr: 'err' }
+
+// How long a Clear waits before the server's copy is wiped. The call erases
+// every captured line and cannot be reversed, so the lines are hidden at once
+// and this wait is the undo.
+const CLEAR_UNDO_MS = 6000
// Detail view: log lines for a specific model
-function BackendLogsDetail({ modelId }) {
+// `embedded` drops the page chrome (header, page width) so the same viewer sits
+// inside another page, such as a tab of the model page.
+function BackendLogsDetail({ modelId, embedded = false }) {
+ const { t } = useTranslation('operate')
const { addToast } = useOutletContext()
+ const navigate = useNavigate()
const [searchParams] = useSearchParams()
const fromTimestamp = searchParams.get('from')
const [lines, setLines] = useState([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState('all')
+ const [text, setText] = useState('')
const [autoScroll, setAutoScroll] = useState(true)
const [showDetails, setShowDetails] = useState(true)
const [wsConnected, setWsConnected] = useState(false)
+ const [processes, setProcesses] = useState([])
+ // How many of the first lines a pending Clear is hiding. Zero when none is.
+ const [hidden, setHidden] = useState(0)
const logContainerRef = useRef(null)
const wsRef = useRef(null)
const reconnectTimerRef = useRef(null)
@@ -40,12 +55,23 @@ function BackendLogsDetail({ modelId }) {
// Keep loadingRef in sync
useEffect(() => { loadingRef.current = loading }, [loading])
+ // The processes that have output, for the picker. Read once: a new process
+ // appears the next time the page is opened.
+ useEffect(() => {
+ if (embedded) return undefined
+ let cancelled = false
+ backendLogsApi.listModels()
+ .then(list => { if (!cancelled) setProcesses(Array.isArray(list) ? list : []) })
+ .catch(() => {})
+ return () => { cancelled = true }
+ }, [embedded])
+
// Auto-scroll to bottom when new lines arrive
useEffect(() => {
if (autoScroll && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
}
- }, [lines, autoScroll])
+ }, [lines, autoScroll, hidden])
// WebSocket connection with reconnect
const connectWebSocket = useCallback(() => {
@@ -117,26 +143,32 @@ function BackendLogsDetail({ modelId }) {
const lineTime = new Date(el.dataset.timestamp).getTime()
if (lineTime >= fromDate) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
- el.style.background = 'rgba(59,130,246,0.1)'
- setTimeout(() => { el.style.background = '' }, 3000)
+ el.dataset.mark = 'true'
+ setTimeout(() => { delete el.dataset.mark }, 3000)
scrolledToTimestampRef.current = true
break
}
}
}, [fromTimestamp, lines])
- const filteredLines = useMemo(
- () => filter === 'all' ? lines : lines.filter(l => l.stream === filter),
- [lines, filter]
- )
+ const filteredLines = useMemo(() => {
+ const needle = text.trim().toLowerCase()
+ return lines.slice(hidden).filter(l => (filter === 'all' || l.stream === filter)
+ && (!needle || String(l.text || '').toLowerCase().includes(needle)))
+ }, [lines, filter, text, hidden])
- const handleClear = async () => {
+ // Hide now, wipe on the server when the window ends. Undo shows them again.
+ const clear = () => setHidden(lines.length)
+ const undoClear = () => setHidden(0)
+ const commitClear = async () => {
try {
await backendLogsApi.clear(modelId)
setLines([])
- addToast('Logs cleared', 'success')
+ addToast(t('logs.cleared'), 'success')
} catch (err) {
- addToast(`Failed to clear: ${err.message}`, 'error')
+ addToast(t('logs.clearFailed', { message: err.message }), 'error')
+ } finally {
+ setHidden(0)
}
}
@@ -150,55 +182,68 @@ function BackendLogsDetail({ modelId }) {
URL.revokeObjectURL(url)
}
- return (
-
-
{modelId}>}
- supporting="Backend process output"
- />
+ const picker = [...new Set([modelId, ...processes])].sort((a, b) => a.localeCompare(b))
+ const nothingToShow = filteredLines.length === 0
- {/* Toolbar */}
-
-
+ return (
+
+ {!embedded && (
+ <>
+
{t('logs.back')}
+
{modelId}}
+ supporting={t('logs.supporting')}
+ actions={picker.length > 1 ? (
+
+ {t('logs.process')}
+ navigate(`/app/backend-logs/${encodeURIComponent(e.target.value)}`)}
+ >
+ {picker.map(id => {id} )}
+
+
+ ) : null}
+ />
+ >
+ )}
+
+
+
{['all', 'stdout', 'stderr'].map(f => (
- setFilter(f)}
- >
- {f === 'all' ? 'All' : f}
+ setFilter(f)}>
+ {f === 'all' ? t('logs.all') : f}
))}
-
Clear
-
- Export
-
-
setShowDetails(prev => !prev)}
- title={showDetails ? 'Hide timestamps and stream labels for easier copying' : 'Show timestamps and stream labels'}
- >
- {showDetails ? 'Text only' : 'Show details'}
-
-
@@ -207,66 +252,55 @@ function BackendLogsDetail({ modelId }) {
- ) : filteredLines.length === 0 ? (
-
-
-
No log lines
-
+ ) : nothingToShow ? (
+
+
+
{t('logs.emptyTitle')}
+
{filter !== 'all'
- ? `No ${filter} output. Try switching to "All".`
- : 'Log output will appear here as the backend process runs.'}
+ ? t('logs.emptyStream', { stream: filter })
+ : text.trim() ? t('logs.emptyFilter') : t('logs.emptyBody')}
) : (
- {filteredLines.map((line, i) => {
- const badge = STREAM_BADGE[line.stream] || STREAM_BADGE.stdout
- return (
-
- {showDetails && (<>
-
- {formatTimestamp(line.timestamp)}
-
-
- {badge.label}
-
- >)}
-
- {line.text}
-
-
- )
- })}
+ {filteredLines.map((line, i) => (
+
+ {showDetails && (
+ <>
+ {formatTimestamp(line.timestamp)}
+ {STREAM_LABEL[line.stream] || 'out'}
+ >
+ )}
+ {line.text}
+
+ ))}
)}
+
+ {hidden > 0 && (
+
+ )}
)
}
@@ -309,11 +343,11 @@ function DistributedBackendLogsResolver({ modelId, fromTimestamp }) {
if (error) {
return (
-
-
-
-
Failed to resolve hosting nodes
-
{error.message}
+
+
+
+
Failed to resolve hosting nodes
+
{error.message}
)
@@ -329,13 +363,13 @@ function DistributedBackendLogsResolver({ modelId, fromTimestamp }) {
if (hits.length === 0) {
return (
-
-
-
-
Model not loaded on any worker
-
- {modelId} isn't currently loaded on any node in the cluster.
- Check the Nodes page to see which models are running where.
+
+
+
+
Model not loaded on any worker
+
+ {modelId} isn't currently loaded on any node in the cluster.
+ Check the Nodes page to see which models are running where.
@@ -356,33 +390,27 @@ function DistributedBackendLogsResolver({ modelId, fromTimestamp }) {
// Multiple workers host this model — let the operator pick.
return (
-
+
{modelId}>}
+ title={{modelId} }
supporting={`Hosted on ${hits.length} workers — pick one to view its logs.`}
/>
-
+
)
}
@@ -411,24 +439,74 @@ function BackendLogsRouter({ modelId }) {
return
}
+// With no model in the address the page lists the processes that have output,
+// each a link to its log. Reached from a backend's "Logs" link.
+function LogProcessList() {
+ const { t } = useTranslation('operate')
+ const [processes, setProcesses] = useState(null)
+ const [error, setError] = useState('')
+ useEffect(() => {
+ let cancelled = false
+ backendLogsApi.listModels()
+ .then(list => { if (!cancelled) setProcesses(Array.isArray(list) ? list : []) })
+ .catch(err => { if (!cancelled) { setError(err.message); setProcesses([]) } })
+ return () => { cancelled = true }
+ }, [])
+
+ return (
+
+
{t('logs.back')}
+
+ {processes === null ? (
+
+ ) : error ? (
+
{t('logs.listFailed', { message: error })}
+ ) : processes.length === 0 ? (
+
+
+
{t('logs.noProcessesTitle')}
+
+ {t('logs.noProcessesBody')} {t('logs.noProcessesLink')}.
+
+
+ ) : (
+
+ {[...processes].sort((a, b) => a.localeCompare(b)).map(id => (
+
+
+
+ {id}
+
+
+
+ ))}
+
+ )}
+
+ )
+}
+
export default function BackendLogs() {
const { modelId } = useParams()
if (modelId) {
return
}
-
- // No model specified — redirect to System page
- return (
-
-
-
-
No model selected
-
- View backend logs for a specific model from the{' '}
- Installed Models page.
-
-
-
- )
+ return
+}
+
+// The log viewer without the page around it, for the model page's Logs tab.
+// In distributed mode the local stream has nothing behind it, so the tab points
+// at the full page, which resolves the node that hosts the model.
+export function BackendLogsPanel({ modelId, fullPageLabel }) {
+ const { enabled: distributedMode, loading } = useDistributedMode()
+ if (loading) return
+ if (distributedMode) {
+ return (
+
+ {fullPageLabel}
+
+ )
+ }
+ return
}
diff --git a/core/http/react-ui/src/pages/Backends.jsx b/core/http/react-ui/src/pages/Backends.jsx
index 7a4172632..1ab1626f8 100644
--- a/core/http/react-ui/src/pages/Backends.jsx
+++ b/core/http/react-ui/src/pages/Backends.jsx
@@ -1,76 +1,102 @@
-import { useState, useEffect, useCallback, useRef } from 'react'
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
import { Link, useOutletContext, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
-import { backendsApi, nodesApi } from '../utils/api'
+import { backendsApi, modelsApi, nodesApi } from '../utils/api'
import { useDebouncedCallback } from '../hooks/useDebounce'
-import React from 'react'
import { useOperations } from '../hooks/useOperations'
import { useDistributedMode } from '../hooks/useDistributedMode'
-import LoadingSpinner from '../components/LoadingSpinner'
+import { CANCEL_UNDO_MS, useUndoableCancel } from '../hooks/useUndoableCancel'
+import { backendState, opForBackend, sortInstalled, versionLabel } from '../utils/backendRows'
+import { backendDependents, recommendedBackend } from '../utils/operateStatus'
import { renderMarkdown, stripMarkdown } from '../utils/markdown'
import { safeHref } from '../utils/url'
+import ActionMenu from '../components/ActionMenu'
import ConfirmDialog from '../components/ConfirmDialog'
-import Toggle from '../components/Toggle'
import NodeDistributionChip from '../components/NodeDistributionChip'
import NodeInstallPicker from '../components/NodeInstallPicker'
-import Popover from '../components/Popover'
-import SplitView from '../components/split/SplitView'
-import EntityRail from '../components/split/EntityRail'
-import DetailHeader from '../components/split/DetailHeader'
-import StatGrid from '../components/split/StatGrid'
-import { useResources } from '../hooks/useResources'
-import { ENTITY_GROUPS, groupForEntity } from '../utils/entityGroups'
-import InstalledBackends from './InstalledBackends'
+import BackendsTable from '../components/operate/BackendsTable'
+import HomeUndoToast from '../components/home/HomeUndoToast'
+import LoadingSpinner from '../components/LoadingSpinner'
+import Icon from '../components/Icon'
+import './operate.css'
+const CATALOG_FILTERS = [
+ { key: '', labelKey: 'backends.filters.all' },
+ { key: 'chat', labelKey: 'backends.filters.chat' },
+ { key: 'image', labelKey: 'backends.filters.image' },
+ { key: 'video', labelKey: 'backends.filters.video' },
+ { key: 'tts', labelKey: 'backends.filters.tts' },
+ { key: 'transcript', labelKey: 'backends.filters.stt' },
+ { key: 'vision', labelKey: 'backends.filters.vision' },
+]
+const CATALOG_STATES = CATALOG_FILTERS.map(f => f.key)
+const INSTALLED_STATES = new Set(['all', 'user', 'system', 'upgradable', 'offline'])
+const ITEMS_PER_PAGE = 60
+
+// Runtime, Backends: what can run models on this machine. One list of
+// backends with two views, Installed and Catalog. A row says what the backend
+// is doing now (installing with a bar, updating, current), carries the one
+// button that matters, and opens into everything else.
+//
+// What the API answers is what is shown. It returns a version and an update
+// check, but no size per backend, no earlier version to roll back to and no
+// dependency lookup; models name their backend, so "used by" is read from
+// those.
export default function Backends() {
const { addToast } = useOutletContext()
- const { t } = useTranslation('admin')
+ const { t } = useTranslation('operate')
const [searchParams, setSearchParams] = useSearchParams()
const activeView = searchParams.get('view') === 'installed' ? 'installed' : 'catalog'
- const { operations } = useOperations()
- const { resources } = useResources()
+ const { operations, cancelOperation } = useOperations()
const { enabled: distributedEnabled, nodes: clusterNodes, refetch: refetchNodes } = useDistributedMode()
+
const [loading, setLoading] = useState(true)
+ const [installedLoading, setInstalledLoading] = useState(true)
+ // Variants and development builds are told apart by the catalog, so the
+ // Installed list waits for it rather than flashing rows it then hides.
+ const [catalogReady, setCatalogReady] = useState(false)
const [search, setSearch] = useState(() => searchParams.get('q') || '')
- const [filter, setFilter] = useState(() => {
- const state = searchParams.get('state') || ''
- return ['', 'chat', 'image', 'video', 'tts', 'transcript', 'vision'].includes(state) ? state : ''
- })
const [sortBy, setSortBy] = useState('name')
const [sortOrder, setSortOrder] = useState('asc')
const [page, setPage] = useState(1)
- const [installedCount, setInstalledCount] = useState(0)
const [showManualInstall, setShowManualInstall] = useState(false)
const [manualUri, setManualUri] = useState('')
const [manualName, setManualName] = useState('')
const [manualAlias, setManualAlias] = useState('')
- // Which backend the pane is showing, or null for the host page. In the URL
- // for the same reasons as Models Explore: a backend is linkable, and Back leaves
- // the detail rather than the page.
- // True once any listing has come back. Distinguishes a cold start, which has
- // nothing to keep on screen, from a refetch, which does.
- const loadedOnce = useRef(false)
+ const [manualError, setManualError] = useState('')
const [confirmDialog, setConfirmDialog] = useState(null)
const [allBackends, setAllBackends] = useState([])
+ const [installed, setInstalled] = useState([])
+ const [models, setModels] = useState([])
const [upgrades, setUpgrades] = useState({})
const [upgradingAll, setUpgradingAll] = useState(false)
- const [showAllBackends, setShowAllBackends] = useState(() => searchParams.get('show_all') === '1')
- const [showDevelopment, setShowDevelopment] = useState(() => searchParams.get('development') === '1')
+ const [checking, setChecking] = useState(false)
const [preferDevLoaded, setPreferDevLoaded] = useState(false)
+ const [preferDev, setPreferDev] = useState(false)
+ const [pending, setPending] = useState(() => new Set())
+ const [errors, setErrors] = useState({})
+ const [globalError, setGlobalError] = useState('')
const [pickerBackend, setPickerBackend] = useState(null)
const [pickerInitialSelection, setPickerInitialSelection] = useState([])
- const [splitMenuOpen, setSplitMenuOpen] = useState(false)
- const [catalogErrors, setCatalogErrors] = useState({})
- const [catalogGlobalError, setCatalogGlobalError] = useState('')
- const [manualError, setManualError] = useState('')
- // Anchor for the split-button chevron. One pane, so one anchor.
- const splitMenuAnchorRef = useRef(null)
+ // True once any catalog listing has come back. Distinguishes a cold start,
+ // which has nothing to keep on screen, from a refetch, which does.
+ const loadedOnce = useRef(false)
+ const installedOnce = useRef(false)
+ const cancelling = useUndoableCancel({ operations, cancel: cancelOperation })
- // Target-node mode: set when navigated from /app/nodes via "+ Add backend".
- // The gallery page header banners the scope; rows collapse their split-button
- // to a single Install-on-this-node action; manual install posts to the
- // per-node endpoint.
const selectedName = searchParams.get('backend')
+ const targetNodeId = searchParams.get('target') || ''
+ const targetNode = targetNodeId ? clusterNodes.find(n => n.id === targetNodeId) || null : null
+
+ const requestedState = searchParams.get('state') || ''
+ const catalogFilter = CATALOG_STATES.includes(requestedState) ? requestedState : ''
+ const installedState = INSTALLED_STATES.has(requestedState || 'all') ? (requestedState || 'all') : 'all'
+ const showAll = searchParams.get('show_all') === '1'
+ // The server can ask for development builds to be preferred; the toggle then
+ // starts on, once, unless the URL already says otherwise.
+ const showDevelopment = searchParams.get('development') === '1' || (preferDev && !searchParams.has('development'))
+ const query = searchParams.get('q') || ''
const hrefForView = (view) => {
const next = new URLSearchParams(searchParams)
@@ -87,58 +113,28 @@ export default function Backends() {
}, { replace: true })
}, [setSearchParams])
- useEffect(() => {
- if (activeView !== 'catalog') return
- const nextSearch = searchParams.get('q') || ''
- const requestedState = searchParams.get('state') || ''
- const nextFilter = ['', 'chat', 'image', 'video', 'tts', 'transcript', 'vision'].includes(requestedState)
- ? requestedState
- : ''
- setSearch(nextSearch)
- setFilter(nextFilter)
- setShowAllBackends(searchParams.get('show_all') === '1')
- setShowDevelopment(searchParams.get('development') === '1')
- }, [activeView, searchParams])
-
- // Selection is a URL edit that preserves everything else in the query, so it
+ // Selecting is a URL edit that keeps everything else in the query, so it
// composes with the target-node scope rather than clobbering it.
const selectBackend = useCallback((name) => {
setSearchParams(prev => {
const next = new URLSearchParams(prev)
- if (name) next.set('backend', name)
+ if (name && name !== prev.get('backend')) next.set('backend', name)
else next.delete('backend')
return next
- }, { replace: !name })
- setSplitMenuOpen(false)
+ }, { replace: true })
}, [setSearchParams])
- const selectedBackend = selectedName
- ? (allBackends.find(b => (b.name || b.id) === selectedName) || null)
- : null
-
- const [collapsedGroups, setCollapsedGroups] = useState(() => new Set())
- const toggleGroup = useCallback((id) => {
- setCollapsedGroups(prev => {
- const next = new Set(prev)
- if (next.has(id)) next.delete(id)
- else next.add(id)
- return next
- })
- }, [])
-
- const targetNodeId = searchParams.get('target') || ''
- const targetNode = targetNodeId
- ? clusterNodes.find(n => n.id === targetNodeId) || null
- : null
-
const clearTarget = useCallback(() => {
const next = new URLSearchParams(searchParams)
next.delete('target')
setSearchParams(next, { replace: true })
}, [searchParams, setSearchParams])
- // The Popover component handles outside-click + Escape + focus return,
- // so we don't reimplement it here.
+ // The catalog search follows the URL, so history and links restore it.
+ useEffect(() => {
+ if (activeView !== 'catalog') return
+ setSearch(query)
+ }, [activeView, query])
const fetchBackends = useCallback(async () => {
try {
@@ -148,215 +144,258 @@ export default function Backends() {
const data = await backendsApi.list(params)
const list = Array.isArray(data?.backends) ? data.backends : Array.isArray(data) ? data : []
setAllBackends(list)
- setInstalledCount(list.filter(b => b.installed).length)
- // On first load, use server preference for development toggle
+ // On first load, use the server's preference for development builds.
if (!preferDevLoaded && data?.preferDevelopmentBackends) {
- setShowDevelopment(true)
+ setPreferDev(true)
setPreferDevLoaded(true)
}
} catch (err) {
- addToast(`Failed to load backends: ${err.message}`, 'error')
+ addToast(t('backends.loadFailed', { message: err.message }), 'error')
} finally {
loadedOnce.current = true
+ setCatalogReady(true)
setLoading(false)
}
- }, [activeView, search, sortBy, sortOrder, addToast])
+ }, [activeView, search, sortBy, sortOrder, addToast, t, preferDevLoaded])
const debouncedFetch = useDebouncedCallback(fetchBackends)
+ useEffect(() => { debouncedFetch() }, [debouncedFetch, fetchBackends])
- useEffect(() => {
- debouncedFetch()
- }, [debouncedFetch, fetchBackends])
+ const fetchInstalled = useCallback(async () => {
+ try {
+ setInstalledLoading(true)
+ const data = await backendsApi.listInstalled()
+ setInstalled(Array.isArray(data) ? data : [])
+ setGlobalError('')
+ } catch (err) {
+ setInstalled([])
+ setGlobalError(t('backends.installedLoadFailed', { message: err.message }))
+ } finally {
+ installedOnce.current = true
+ setInstalledLoading(false)
+ }
+ }, [t])
- // Re-fetch when operations change (install/delete completion)
+ // Install and delete change what is installed, so the lists are read again
+ // whenever the set of operations changes.
useEffect(() => {
if (!loading) fetchBackends()
+ fetchInstalled()
+ backendsApi.checkUpgrades().then(data => setUpgrades(data || {})).catch(() => {})
+ modelsApi.listCapabilities().then(data => setModels(Array.isArray(data?.data) ? data.data : [])).catch(() => {})
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [operations.length])
- // Fetch available upgrades
- useEffect(() => {
- backendsApi.checkUpgrades()
- .then(data => setUpgrades(data || {}))
- .catch(() => {})
- }, [operations.length])
+ const catalogByName = useMemo(
+ () => new Map(allBackends.map(b => [b.name || b.id, b])),
+ [allBackends],
+ )
+ const updateNames = Object.keys(upgrades)
+ const installedCount = installed.length
+ const recommended = activeView === 'catalog' && !search && !catalogFilter && installedOnce.current && installedCount === 0
+ ? recommendedBackend(allBackends)
+ : null
- // Client-side filtering by meta/development toggles and tag
- const filteredBackends = (() => {
- let result = allBackends
+ const markPending = (name, on) => setPending(current => {
+ const next = new Set(current)
+ if (on) next.add(name)
+ else next.delete(name)
+ return next
+ })
+ const setError = (name, message) => setErrors(current => ({ ...current, [name]: message }))
- // Hide concrete variants that are aliased by a meta backend unless
- // "Show all" is toggled. Standalone backends (no meta referencing them)
- // stay visible even when they don't declare capabilities themselves.
- if (!showAllBackends) {
- result = result.filter(b => b.isMeta || !b.isAlias)
- }
-
- // Hide development backends unless toggled on
- if (!showDevelopment) {
- result = result.filter(b => !b.isDevelopment)
- }
-
- // Apply tag filter
- if (filter) {
- result = result.filter(b => {
- const tags = (b.tags || []).map(t => t.toLowerCase())
- const name = (b.name || '').toLowerCase()
- const desc = (b.description || '').toLowerCase()
- const f = filter.toLowerCase()
- return tags.some(t => t.includes(f)) || name.includes(f) || desc.includes(f)
- })
- }
-
- return result
- })()
-
- // Client-side pagination
- const ITEMS_PER_PAGE = 60
- const totalPages = Math.max(1, Math.ceil(filteredBackends.length / ITEMS_PER_PAGE))
- const backends = filteredBackends.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE)
-
- const handleSearch = (value) => {
- setSearch(value)
- updateUrlParam('q', value)
- setPage(1)
- }
-
- const handleSort = (col) => {
- if (sortBy === col) {
- setSortOrder(prev => prev === 'asc' ? 'desc' : 'asc')
- } else {
- setSortBy(col)
- setSortOrder('asc')
- }
- setPage(1)
- }
+ // ---- Actions ----------------------------------------------------------
const handleInstall = async (id) => {
- setCatalogErrors(current => ({ ...current, [id]: '' }))
+ setError(id, '')
+ markPending(id, true)
try {
await backendsApi.install(id)
} catch (err) {
- // Distributed-mode 409 guard: surface the human message and steer the
- // user to the picker rather than failing silently. The error body has
- // a `code` field of "concrete_backend_requires_target".
+ // Distributed 409 guard: surface the human message and steer the user to
+ // the picker rather than failing silently. The error body has a `code`
+ // of "concrete_backend_requires_target".
const isConcreteGuard = err?.payload?.code === 'concrete_backend_requires_target'
|| (err?.message || '').includes('hardware-specific build')
if (isConcreteGuard && distributedEnabled) {
- const b = allBackends.find(x => x.id === id || x.name === id)
+ const b = catalogByName.get(id)
if (b) {
openPicker(b)
return
}
}
- setCatalogErrors(current => ({ ...current, [id]: `Install failed: ${err.message}` }))
+ setError(id, t('backends.installFailed', { message: err.message }))
+ } finally {
+ markPending(id, false)
}
}
- // Install a single gallery backend on a specific node, used in target-node
- // mode (the URL has ?target=
set from the Nodes page entry point).
- // The handler is async - we dispatch and let the global Operations panel
- // surface progress; no need to await completion here.
const handleInstallOnTarget = async (id) => {
if (!targetNode) return
- setCatalogErrors(current => ({ ...current, [id]: '' }))
+ setError(id, '')
try {
await nodesApi.installBackend(targetNode.id, id)
- addToast(`Installing ${id} on ${targetNode.name}...`, 'info')
- // The install runs async via the gallery job queue. Refetch shortly so
- // the Nodes column reflects "installing" state; the Operations panel
- // tracks the actual progress until completion.
- setTimeout(() => { fetchBackends(); refetchNodes() }, 1200)
+ addToast(t('backends.installingOn', { name: id, node: targetNode.name }), 'info')
+ setTimeout(() => { fetchBackends(); fetchInstalled(); refetchNodes() }, 1200)
} catch (err) {
- setCatalogErrors(current => ({
- ...current,
- [id]: `Install dispatch failed on ${targetNode.name}: ${err.message}`,
- }))
+ setError(id, t('backends.dispatchFailed', { node: targetNode.name, message: err.message }))
+ }
+ }
+
+ const handleRemoveFromTarget = async (name) => {
+ setError(name, '')
+ try {
+ await nodesApi.deleteBackend(targetNode.id, name)
+ addToast(t('backends.removedFrom', { name, node: targetNode.name }), 'success')
+ setTimeout(() => { fetchBackends(); fetchInstalled(); refetchNodes() }, 600)
+ } catch (err) {
+ setError(name, t('backends.removeFailed', { message: err.message }))
}
}
const openPicker = (b, initialSelection = []) => {
setPickerBackend(b)
setPickerInitialSelection(initialSelection)
- setSplitMenuOpen(false)
}
- // Returns the IDs of nodes that don't yet have this backend installed.
- // Used by the Nodes column "+" affordance to pre-select missing nodes.
+ // Healthy backend nodes that do not hold this backend yet, to pre-select in
+ // the node picker.
const missingNodesFor = (b) => {
- const installed = new Set((b?.nodes || []).map(n => n.node_id ?? n.NodeID))
+ const have = new Set((b?.nodes || []).map(n => n.node_id ?? n.NodeID))
return clusterNodes
- .filter(n => (!n.node_type || n.node_type === 'backend')
- && n.status === 'healthy'
- && !installed.has(n.id))
+ .filter(n => (!n.node_type || n.node_type === 'backend') && n.status === 'healthy' && !have.has(n.id))
.map(n => n.id)
}
- const handleDelete = async (id) => {
- setConfirmDialog({
- title: 'Delete Backend',
- message: `Delete backend ${id}?`,
- confirmLabel: 'Delete',
- danger: true,
- onConfirm: async () => {
- setConfirmDialog(null)
- setCatalogErrors(current => ({ ...current, [id]: '' }))
- try {
- await backendsApi.delete(id)
- addToast(`Deleting ${id}...`, 'info')
- setTimeout(fetchBackends, 1000)
- } catch (err) {
- setCatalogErrors(current => ({ ...current, [id]: `Delete failed: ${err.message}` }))
- }
- },
- })
+ const handleUpgrade = async (name) => {
+ setError(name, '')
+ markPending(name, true)
+ try {
+ await backendsApi.upgrade(name)
+ addToast(t('backends.updateStarted', { name }), 'info')
+ } catch (err) {
+ setError(name, t('backends.updateFailed', { message: err.message }))
+ } finally {
+ markPending(name, false)
+ }
}
- const handleUpgrade = async (id) => {
- setCatalogErrors(current => ({ ...current, [id]: '' }))
+ const handleReinstall = async (name) => {
+ setError(name, '')
+ markPending(name, true)
try {
- await backendsApi.upgrade(id)
- addToast(`Upgrading ${id}...`, 'info')
+ await backendsApi.install(name)
+ addToast(t('backends.reinstallStarted', { name }), 'info')
} catch (err) {
- setCatalogErrors(current => ({ ...current, [id]: `Upgrade failed: ${err.message}` }))
+ setError(name, t('backends.reinstallFailed', { message: err.message }))
+ } finally {
+ markPending(name, false)
}
}
const handleUpgradeAll = async () => {
- const names = Object.keys(upgrades)
- if (names.length === 0) return
+ if (updateNames.length === 0) return
setUpgradingAll(true)
- setCatalogGlobalError('')
+ setGlobalError('')
try {
- for (const name of names) {
- await backendsApi.upgrade(name)
+ const failures = []
+ for (const name of updateNames) {
+ try {
+ await backendsApi.upgrade(name)
+ } catch (err) {
+ failures.push(t('backends.updateAllFailed', { name, message: err.message }))
+ }
}
- addToast(`Upgrading ${names.length} backend${names.length > 1 ? 's' : ''}...`, 'info')
- } catch (err) {
- setCatalogGlobalError(`Upgrade failed: ${err.message}`)
+ if (failures.length > 0) {
+ setGlobalError(failures.join(' '))
+ return
+ }
+ addToast(t('backends.updateAllStarted', { count: updateNames.length }), 'info')
} finally {
setUpgradingAll(false)
}
}
+ const handleCheck = async () => {
+ setChecking(true)
+ try {
+ const data = await backendsApi.forceCheckUpgrades()
+ const found = data && typeof data === 'object' ? Object.keys(data).length : 0
+ setUpgrades(data || {})
+ addToast(found > 0 ? t('backends.checkFound', { count: found }) : t('backends.checkNone'), 'info')
+ } catch (err) {
+ addToast(t('backends.checkFailed', { message: err.message }), 'error')
+ } finally {
+ setChecking(false)
+ }
+ }
+
+ // Removing a backend can leave a model without a runtime. The models name
+ // the backend they ask for and an installed meta backend names the concrete
+ // one it points at, so both are read before asking, and the dialog says what
+ // would stop working.
+ const handleDelete = async (name, how) => {
+ let dependents = { models: [], metas: [] }
+ try {
+ const data = await modelsApi.listCapabilities()
+ const list = Array.isArray(data?.data) ? data.data : []
+ setModels(list)
+ dependents = backendDependents(name, { models: list, installed })
+ } catch {
+ dependents = backendDependents(name, { models, installed })
+ }
+ setConfirmDialog({
+ title: t('backends.deleteTitle'),
+ message: (
+ <>
+ {t('backends.deleteMessage', { name })}
+ {(dependents.models.length > 0 || dependents.metas.length > 0) && (
+
+
+
+ {dependents.models.length > 0 && (
+
{t('backends.deleteModels', { count: dependents.models.length, list: dependents.models.slice(0, 6).join(', ') + (dependents.models.length > 6 ? '…' : '') })}
+ )}
+ {dependents.metas.length > 0 && (
+
{t('backends.deleteMetas', { count: dependents.metas.length, list: dependents.metas.join(', ') })}
+ )}
+
+
+ )}
+ >
+ ),
+ confirmLabel: t('backends.delete'),
+ onConfirm: async () => {
+ setConfirmDialog(null)
+ setError(name, '')
+ try {
+ if (how === 'installed') await backendsApi.deleteInstalled(name)
+ else await backendsApi.delete(name)
+ addToast(how === 'installed' ? t('backends.deleteSucceeded', { name }) : t('backends.deleting', { name }), how === 'installed' ? 'success' : 'info')
+ if (selectedName === name) selectBackend(null)
+ setTimeout(() => { fetchBackends(); fetchInstalled() }, how === 'installed' ? 0 : 1000)
+ } catch (err) {
+ setError(name, t('backends.deleteFailed', { message: err.message }))
+ }
+ },
+ })
+ }
+
const handleManualInstall = async (e) => {
e.preventDefault()
setManualError('')
- if (!manualUri.trim()) { setManualError('Please enter a URI'); return }
+ if (!manualUri.trim()) { setManualError(t('backends.manualMissing')); return }
try {
if (targetNode) {
- // Target-node mode: route the manual install to the per-node endpoint
- // so the backend lands only on this worker, not the whole cluster.
- await nodesApi.installBackend(
- targetNode.id,
- manualName.trim() || '',
- {
- uri: manualUri.trim(),
- name: manualName.trim() || undefined,
- alias: manualAlias.trim() || undefined,
- },
- )
- addToast(`Installing on ${targetNode.name}…`, 'info')
- setTimeout(() => { fetchBackends(); refetchNodes() }, 600)
+ // Target-node mode: route the install to the per-node endpoint, so the
+ // backend lands only on this worker, not the whole cluster.
+ await nodesApi.installBackend(targetNode.id, manualName.trim() || '', {
+ uri: manualUri.trim(),
+ name: manualName.trim() || undefined,
+ alias: manualAlias.trim() || undefined,
+ })
+ addToast(t('backends.installingOnNode', { node: targetNode.name }), 'info')
+ setTimeout(() => { fetchBackends(); fetchInstalled(); refetchNodes() }, 600)
} else {
const body = { uri: manualUri.trim() }
if (manualName.trim()) body.name = manualName.trim()
@@ -368,414 +407,534 @@ export default function Backends() {
setManualAlias('')
setShowManualInstall(false)
} catch (err) {
- setManualError(`Install failed: ${err.message}`)
+ setManualError(t('backends.installFailed', { message: err.message }))
}
}
- // Check if a backend has an active operation
- const getBackendOp = (backend) => {
- if (!operations.length) return null
- return operations.find(op => op.name === backend.name || op.name === backend.id) || null
- }
-
- const handleToggleAllBackends = () => {
- setShowAllBackends(value => {
- updateUrlParam('show_all', value ? '' : '1')
- return !value
- })
- setPage(1)
- }
- const handleToggleDev = () => {
- setShowDevelopment(value => {
- updateUrlParam('development', value ? '' : '1')
- return !value
- })
+ const handleSearch = (value) => {
+ setSearch(value)
+ updateUrlParam('q', value)
setPage(1)
}
- const FILTERS = [
- { key: '', label: 'All', icon: 'fa-layer-group' },
- { key: 'chat', label: 'Chat', icon: 'fa-brain' },
- { key: 'image', label: 'Image', icon: 'fa-image' },
- { key: 'video', label: 'Video', icon: 'fa-video' },
- { key: 'tts', label: 'TTS', icon: 'fa-microphone' },
- { key: 'transcript', label: 'STT', icon: 'fa-headphones' },
- { key: 'vision', label: 'Vision', icon: 'fa-eye' },
+ const handleSort = (col) => {
+ if (sortBy === col) setSortOrder(prev => (prev === 'asc' ? 'desc' : 'asc'))
+ else { setSortBy(col); setSortOrder('asc') }
+ setPage(1)
+ }
+
+ // ---- Catalog rows -----------------------------------------------------
+
+ const catalogList = useMemo(() => {
+ let result = allBackends
+ // Concrete variants that a meta backend aliases are hidden unless "Show
+ // all" is on. Standalone backends stay visible.
+ if (!showAll) result = result.filter(b => b.isMeta || !b.isAlias)
+ if (!showDevelopment) result = result.filter(b => !b.isDevelopment)
+ if (catalogFilter) {
+ const f = catalogFilter.toLowerCase()
+ result = result.filter(b => {
+ const tags = (b.tags || []).map(x => x.toLowerCase())
+ return tags.some(x => x.includes(f)) || (b.name || '').toLowerCase().includes(f) || (b.description || '').toLowerCase().includes(f)
+ })
+ }
+ return result
+ }, [allBackends, showAll, showDevelopment, catalogFilter])
+ const totalPages = Math.max(1, Math.ceil(catalogList.length / ITEMS_PER_PAGE))
+ const catalogPage = catalogList.slice((page - 1) * ITEMS_PER_PAGE, page * ITEMS_PER_PAGE)
+
+ const detailFacts = (b) => (
+
+ {b.gallery && (<>{t('backends.facts.repository')} {typeof b.gallery === 'string' ? b.gallery : b.gallery.name || '—'} >)}
+ {b.license && (<>{t('backends.facts.license')} {b.license} >)}
+ {b.tags?.length > 0 && (
+ <>
+ {t('backends.facts.tags')}
+ {b.tags.map(tag => {tag} )}
+ >
+ )}
+ {b.urls?.length > 0 && (
+ <>
+ {t('backends.facts.links')}
+
+ {b.urls.map((url, i) => (
+
+ {url}
+
+ ))}
+
+ >
+ )}
+
+ )
+
+ const usedBy = (name) => {
+ const { models: using } = backendDependents(name, { models, installed })
+ if (using.length === 0) return null
+ return (
+
+ {t('backends.usedBy', { count: using.length })}{' '}
+ {using.slice(0, 6).join(', ')}{using.length > 6 ? '…' : ''}
+
+ )
+ }
+
+ const catalogRows = catalogPage.map(b => {
+ const name = b.name || b.id
+ const op = opForBackend(operations, name, b.id)
+ const upgrade = upgrades[name]
+ const state = backendState({ installed: b.installed, upgrade, op })
+ const busy = pending.has(name)
+ const isNodeScoped = Boolean(targetNode)
+ const onTarget = isNodeScoped && (b.nodes || []).some(n => (n.node_id ?? n.NodeID) === targetNode.id)
+
+ let action = null
+ if (state.kind === 'installing' || state.kind === 'queued') {
+ action = state.cancellable
+ ? cancelling.request(state.jobID)} aria-label={t('backends.cancelLabel', { name })}>{t('backends.cancel')}
+ : null
+ } else if (state.kind === 'removing') {
+ action = null
+ } else if (state.kind === 'failed') {
+ action = handleInstall(name)} aria-label={t('backends.retryLabel', { name })}>{t('backends.retry')}
+ } else if (isNodeScoped) {
+ action = onTarget
+ ? null
+ : handleInstallOnTarget(name)}> {t('backends.installOnNode', { node: targetNode.name })}
+ } else if (b.installed) {
+ action = upgrade
+ ? handleUpgrade(name)} aria-label={t('backends.updateLabel', { name })}>{t('backends.update')}
+ : null
+ } else if (distributedEnabled && !b.isMeta) {
+ // A hardware-specific build goes straight to the picker, because fanning
+ // a CPU build out to every node is the silent footgun the guard stops.
+ action = openPicker(b)}> {t('backends.chooseNodes')}
+ } else {
+ action = (
+ <>
+ handleInstall(name)} aria-label={t('backends.installLabel', { name })}>
+ {distributedEnabled ? t('backends.installAll') : t('backends.install')}
+
+ {distributedEnabled && (
+ openPicker(b) }]}
+ />
+ )}
+ >
+ )
+ }
+
+ const missing = distributedEnabled && !isNodeScoped ? missingNodesFor(b) : []
+ const detail = (
+
+ {errors[name] &&
{errors[name]}
}
+ {b.description && (
+
+ )}
+ {detailFacts(b)}
+ {distributedEnabled && !isNodeScoped && (
+
+
{t('backends.installedOn')}
+
+
+ {missing.length > 0 && state.kind !== 'installing' && (
+ openPicker(b, missing)} aria-label={t('backends.installMore')}>
+ {t('backends.moreNodes', { count: missing.length })}
+
+ )}
+
+
+ )}
+ {b.installed && usedBy(name)}
+
+ {isNodeScoped && onTarget && (
+ <>
+ handleInstallOnTarget(name)} title={t('backends.reinstallOn', { node: targetNode.name })}>
+ {t('backends.reinstall')}
+
+ handleRemoveFromTarget(name)} title={t('backends.removeFrom', { node: targetNode.name })}>
+ {t('backends.remove')}
+
+ >
+ )}
+ {!isNodeScoped && b.installed && state.kind !== 'installing' && state.kind !== 'removing' && (
+ <>
+ {upgrade && (
+ handleUpgrade(name)} title={t('backends.updateToTitle', { version: upgrade.available_version ? `v${upgrade.available_version}` : t('backends.latest') })}>
+ {t('backends.update')}
+
+ )}
+ handleInstall(name)} title={t('backends.reinstall')}>
+ {t('backends.reinstall')}
+
+ handleDelete(name, 'catalog')} title={t('backends.delete')}>
+ {t('backends.remove')}
+
+ >
+ )}
+ {t('backends.logs')}
+
+
+ )
+
+ return {
+ name,
+ description: stripMarkdown(b.description || '').slice(0, 160),
+ version: b.installed ? b.version : '',
+ state,
+ nodes: ,
+ action,
+ detail,
+ }
+ })
+
+ // ---- Installed rows ---------------------------------------------------
+
+ const flagsFor = (b) => {
+ const c = catalogByName.get(b.Name)
+ return { variant: !!c?.isAlias, development: !!c?.isDevelopment }
+ }
+ const offlineFor = (b) => (b.Nodes || b.nodes || []).some(node => {
+ const status = node.node_status || node.NodeStatus
+ return status && status !== 'healthy' && status !== 'draining'
+ })
+ const visibleBase = installed.filter(b => {
+ const f = flagsFor(b)
+ if (f.variant && !showAll) return false
+ if (f.development && !showDevelopment) return false
+ return true
+ })
+ const hiddenVariants = showAll ? 0 : installed.filter(b => flagsFor(b).variant).length
+ const hiddenDevelopment = showDevelopment ? 0 : installed.filter(b => flagsFor(b).development).length
+ const normalizedQuery = query.trim().toLowerCase()
+ const passesState = (b) => {
+ if (installedState === 'user') return !b.IsSystem
+ if (installedState === 'system') return !!b.IsSystem
+ if (installedState === 'upgradable') return !!upgrades[b.Name]
+ if (installedState === 'offline') return offlineFor(b)
+ return true
+ }
+ const installedVisible = sortInstalled(visibleBase.filter(b => passesState(b) && (
+ !normalizedQuery
+ || b.Name.toLowerCase().includes(normalizedQuery)
+ || (b.Metadata?.alias || '').toLowerCase().includes(normalizedQuery)
+ || (b.Metadata?.meta_backend_for || '').toLowerCase().includes(normalizedQuery)
+ )), upgrades)
+
+ const installedRows = installedVisible.map(b => {
+ const name = b.Name
+ const version = b.Metadata?.version || b.Version
+ const catalog = catalogByName.get(name)
+ const upgrade = upgrades[name]
+ const op = opForBackend(operations, name)
+ const state = backendState({ installed: true, upgrade, op })
+ const busy = pending.has(name) || state.kind === 'installing' || state.kind === 'queued' || state.kind === 'removing'
+ const nodes = b.Nodes || b.nodes || []
+
+ let action = null
+ if (state.kind === 'installing' || state.kind === 'queued') {
+ action = state.cancellable
+ ? cancelling.request(state.jobID)} aria-label={t('backends.cancelLabel', { name })}>{t('backends.cancel')}
+ : null
+ } else if (b.IsSystem) {
+ action = {t('backends.protected')}
+ } else {
+ action = (
+ <>
+ {upgrade && (
+ handleUpgrade(name)} aria-label={t('backends.updateLabel', { name })}>
+ {t('backends.update')}
+
+ )}
+ handleReinstall(name), disabled: busy },
+ { divider: true },
+ { key: 'delete', icon: 'trash', label: t('backends.deleteBackend'), danger: true, onClick: () => handleDelete(name, 'installed') },
+ ]}
+ />
+ >
+ )
+ }
+
+ const detail = (
+
+ {errors[name] &&
{errors[name]}
}
+ {catalog?.description &&
{stripMarkdown(catalog.description)}
}
+
+ {t('backends.facts.version')} {versionLabel(version) || '—'}
+ {upgrade && (<>{t('backends.facts.available')} {versionLabel(upgrade.available_version) || t('backends.state.update')} >)}
+ {t('backends.facts.managed')} {b.IsSystem ? t('backends.facts.system') : t('backends.facts.gallery')}
+ {b.Metadata?.uri && (<>{t('backends.facts.source')} {b.Metadata.uri} >)}
+ {b.Metadata?.digest && (<>{t('backends.facts.digest')} {b.Metadata.digest} >)}
+ {b.Metadata?.installed_at && (<>{t('backends.facts.installedAt')} {b.Metadata.installed_at} >)}
+
+ {distributedEnabled && nodes.length > 0 && (
+
+ {t('backends.installedOn')}
+
+
+ )}
+ {usedBy(name)}
+
+ {!b.IsSystem && upgrade && (
+ handleUpgrade(name)}>
+ {upgrade.available_version ? t('backends.updateTo', { version: upgrade.available_version }) : t('backends.update')}
+
+ )}
+ {t('backends.logs')}
+
+
+ )
+
+ return {
+ name,
+ description: stripMarkdown(catalog?.description || '').slice(0, 160),
+ version,
+ state,
+ nodes: ,
+ action,
+ detail,
+ }
+ })
+
+ const installedFilters = [
+ { key: 'all', label: t('backends.installedFilters.all'), count: visibleBase.length },
+ { key: 'user', label: t('backends.installedFilters.user'), count: visibleBase.filter(b => !b.IsSystem).length },
+ { key: 'system', label: t('backends.installedFilters.system'), count: visibleBase.filter(b => b.IsSystem).length },
+ ...(updateNames.length > 0 ? [{ key: 'upgradable', label: t('backends.installedFilters.updates'), count: visibleBase.filter(b => upgrades[b.Name]).length }] : []),
+ ...(distributedEnabled && visibleBase.some(offlineFor) ? [{ key: 'offline', label: t('backends.installedFilters.offline'), count: visibleBase.filter(offlineFor).length }] : []),
]
+ const showCatalogCold = activeView === 'catalog' && loading && !loadedOnce.current
+ const showInstalledCold = activeView === 'installed' && ((installedLoading && !installedOnce.current) || !catalogReady)
+
return (
-
-
-
{t('backends.title')}
- {activeView === 'catalog' && (
-
{backends.length} of {allBackends.length}
- )}
- {activeView === 'catalog' && (
-
- {Object.keys(upgrades).length > 0 && (
-
- Upgrade all ({Object.keys(upgrades).length})
-
- )}
-
setShowManualInstall(!showManualInstall)}>
- Manual Install
+
+
{t('backends.title')}
+
+
+
+
+ {t('backends.installedView')} {installedCount}
+
+
+ {t('backends.catalogView')} {allBackends.length}
+
+
+
+
+
+ (activeView === 'installed' ? updateUrlParam('q', e.target.value) : handleSearch(e.target.value))}
+ />
+
+
+
+ {updateNames.length > 0 && (
+
+
+ {upgradingAll ? t('backends.updating') : t('backends.updateAll', { count: updateNames.length })}
-
- )}
+ )}
+
+ {t('backends.check')}
+
+
setShowManualInstall(v => !v)}>
+ {t('backends.fromUrl')}
+
+
-
-
-
- {t('backends.lifecycle.catalog')}
-
-
-
- {t('backends.lifecycle.installed')}
-
-
-
- {/* Target-node banner: when this gallery is scoped to one node via
- ?target= (entered from /app/nodes), show the scope clearly and
- give a fast way to clear it. Visually a primary-tinted strip so the
- user knows they're in a special mode without it feeling alarming. */}
- {activeView === 'catalog' && targetNode && (
-
-
-
- Installing only on {targetNode.name}
-
-
-
- Clear
-
-
- )}
-
- {activeView === 'installed' ? (
-
- ) : (
- <>
- {catalogGlobalError && (
-
-
- {catalogGlobalError}
-
- )}
-
- {/* Upgrade Banner */}
- {Object.keys(upgrades).length > 0 && (
-
-
-
-
- {Object.keys(upgrades).length} backend{Object.keys(upgrades).length > 1 ? 's have' : ' has'} updates available
-
+
+ {activeView === 'catalog' ? (
+
+ {CATALOG_FILTERS.map(f => (
+ { updateUrlParam('state', f.key); setPage(1) }}
+ >
+ {t(f.labelKey)}
+
+ ))}
+ ) : (
+
+ {installedFilters.map(f => (
+ updateUrlParam('state', f.key, 'all')}
+ >
+ {f.label} {f.count}
+
+ ))}
+
+ )}
+
{ updateUrlParam('show_all', showAll ? '' : '1'); setPage(1) }}
>
-
- Upgrade All
+ {activeView === 'installed' && hiddenVariants > 0 ? t('backends.variantsCount', { count: hiddenVariants }) : t('backends.variants')}
+ { updateUrlParam('development', showDevelopment ? '0' : '1'); setPage(1) }}
+ >
+ {activeView === 'installed' && hiddenDevelopment > 0 ? t('backends.developmentCount', { count: hiddenDevelopment }) : t('backends.development')}
+
+
+
+
+ {activeView === 'catalog' && targetNode && (
+
+
+ {t('backends.targetBefore')} {targetNode.name}
+
+ {t('backends.clear')}
+
+
+ )}
+
+ {globalError && (
+
+
+ {globalError}
)}
{showManualInstall && (
-
-
-
- Install External Backend
-
-
-
-
OCI Image / URL / Path *
-
setManualUri(e.target.value)} placeholder="oci://quay.io/example/backend:latest" />
+
+ {t('backends.manualTitle')}
+
+
+ {t('backends.manualUri')}
+ setManualUri(e.target.value)} placeholder="oci://quay.io/example/backend:latest" />
-
-
Name (required for OCI)
-
setManualName(e.target.value)} placeholder="my-backend" />
+
+ {t('backends.manualName')}
+ setManualName(e.target.value)} placeholder="my-backend" />
-
-
Alias (optional)
-
setManualAlias(e.target.value)} placeholder="alias" />
+
+ {t('backends.manualAlias')}
+ setManualAlias(e.target.value)} placeholder="alias" />
-
- Install
-
+
{t('backends.install')}
- {manualError &&
{manualError}
}
+ {manualError &&
{manualError}
}
)}
- {/* The gallery, as a rail and a pane. Same shell as Models Explore, because it
- is the same defect: a seven-column table whose expand-row was the
- only place the repository, licence, tags and links could go. */}
- {loading && !loadedOnce.current ? (
-
+ {recommended && (
+
+
+
{t('backends.recommendTitle', { name: recommended.name || recommended.id })}
+
{t('backends.recommendBody')}
+
+
handleInstall(recommended.name || recommended.id)}>
+ {t('backends.installNamed', { name: recommended.name || recommended.id })}
+
+
+ )}
+
+ {showCatalogCold || showInstalledCold ? (
+
+ ) : activeView === 'installed' ? (
+ installed.length === 0 && !globalError ? (
+
+
+
{t('backends.emptyTitle')}
+
{t('backends.emptyBody')}
+
{t('backends.openCatalog')}
+
+ ) : installedRows.length === 0 ? (
+
+
+
{t('backends.noMatches')}
+
setSearchParams(prev => {
+ const next = new URLSearchParams(prev)
+ next.delete('q')
+ next.delete('state')
+ return next
+ }, { replace: true })}
+ >
+ {t('backends.clearFilters')}
+
+
+ ) : (
+
+ )
+ ) : catalogRows.length === 0 ? (
+
+
+
{t('backends.noneFound')}
+
{search || catalogFilter ? t('backends.noneFoundFiltered') : t('backends.noneFoundBody')}
+
) : (
-
- {/* The filters narrow the rail and nothing else, so they live with it. */}
-
-
-
- handleSearch(e.target.value)} />
-
+ <>
+
+
{t('backends.shown', { shown: catalogPage.length, total: allBackends.length })}
+ {totalPages > 1 && (
+
+ setPage(p => Math.max(1, p - 1))} disabled={page <= 1} aria-label={t('backends.prevPage')}>
+
+
+ {page} / {totalPages}
+ setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages} aria-label={t('backends.nextPage')}>
+
+
-
-
-
- {FILTERS.map(f => (
- { setFilter(f.key); updateUrlParam('state', f.key); setPage(1) }}
- >
-
- {f.label}
-
- ))}
-
-
-
Refine
-
-
-
-
- Show all
-
-
-
-
- Development
-
-
-
-
railItemForBackend(b, { getBackendOp, upgrades }))}
- groups={ENTITY_GROUPS.map(g => ({ id: g.id, label: BACKEND_GROUP_LABELS[g.id], icon: g.icon }))}
- grouped={!search.trim()}
- collapsedGroups={collapsedGroups}
- onToggleGroup={toggleGroup}
- busy={loading}
- selectedId={selectedName}
- onSelect={selectBackend}
- countLabel={`${backends.length} of ${allBackends.length}`}
- ariaLabel="Backends"
- testId="backends-rail"
- actions={
-
-
-
-
- }
- />
-
- {totalPages > 1 && (
-
- setPage(p => Math.max(1, p - 1))} disabled={page <= 1} aria-label="Previous page">
-
-
- {page} / {totalPages}
- setPage(p => Math.min(totalPages, p + 1))} disabled={page >= totalPages} aria-label="Next page">
-
-
-
- )}
- >
- }
- pane={backends.length === 0 ? (
-
-
-
No backends found
-
- {search || filter ? 'Try adjusting your search or filters.' : 'No backends available in the gallery.'}
-
-
- ) : selectedBackend ? (() => {
- const b = selectedBackend
- const name = b.name || b.id
- const op = getBackendOp(b)
- const isProcessing = !!op && !op.error
- const upgrade = upgrades[name]
-
- return (
-
-
selectBackend(null)}
- backLabel="All backends"
- actions={
- isProcessing ? (
-
-
-
-
- {op.isDeletion ? 'Deleting...' : op.isQueued ? 'Queued' : `Installing${op.progress > 0 ? ` · ${Math.round(op.progress)}%` : '...'}`}
-
-
- {op.progress > 0 && (
-
- )}
-
- ) : targetNode ? (
- // Target-node mode: one per-node action. The split button
- // is overkill when the URL has already pinned the scope.
- (b.nodes || []).some(n => (n.node_id ?? n.NodeID) === targetNode.id) ? (
- <>
- handleInstallOnTarget(name)} title={`Reinstall on ${targetNode.name}`}>
- Reinstall
-
- {
- setCatalogErrors(current => ({ ...current, [name]: '' }))
- try {
- await nodesApi.deleteBackend(targetNode.id, name)
- addToast(`Removed ${b.name} from ${targetNode.name}`, 'success')
- setTimeout(() => { fetchBackends(); refetchNodes() }, 600)
- } catch (err) {
- setCatalogErrors(current => ({ ...current, [name]: `Remove failed: ${err.message}` }))
- }
- }} title={`Remove from ${targetNode.name}`}>
- Remove
-
- >
- ) : (
- handleInstallOnTarget(name)} data-testid="backends-install">
- Install on {targetNode.name}
-
- )
- ) : b.installed ? (
- <>
- {upgrade && (
- handleUpgrade(name)} title={`Upgrade to ${upgrade.available_version ? 'v' + upgrade.available_version : 'latest'}`}>
- Upgrade
-
- )}
- handleInstall(name)} title="Reinstall">
- Reinstall
-
- handleDelete(name)} title="Delete">
- Delete
-
- >
- ) : distributedEnabled ? (
- // Auto-resolving (meta) entries keep fan-out as the
- // primary; a hardware-specific build routes straight to
- // the picker, because fanning a CPU build out to every
- // node is the silent footgun this guard exists to stop.
- b.isMeta ? (
-
- handleInstall(name)} title="Install on all nodes" style={{ borderTopRightRadius: 0, borderBottomRightRadius: 0 }} data-testid="backends-install">
- Install on all
-
- setSplitMenuOpen(v => !v)}
- aria-haspopup="menu"
- aria-expanded={splitMenuOpen}
- aria-label="More install options"
- >
-
-
-
- ) : (
- openPicker(b)} title="Choose nodes to install on" data-testid="backends-install">
- Choose nodes…
-
- )
- ) : (
- handleInstall(name)} title="Install" data-testid="backends-install">
- Install
-
- )
- }
- />
-
- {catalogErrors[name] && (
-
-
- {catalogErrors[name]}
-
- )}
-
-
-
- {/* Distribution is the one fact the pane can state that a row
- never could: which nodes hold a copy, and which do not. */}
- {distributedEnabled && !targetNode && (
-
-
Installed on
-
-
- {(() => {
- const missing = missingNodesFor(b)
- if (missing.length === 0 || isProcessing) return null
- return (
- openPicker(b, missing)}
- aria-label="Install on more nodes"
- >
- {missing.length} more
-
- )
- })()}
-
-
- )}
-
-
-
- )
- })() : (
-
)}
- />
+ >
)}
setConfirmDialog(null)}
/>
- {/* The split-button menu, anchored to the pane's own chevron. It used to
- be re-anchored per row; there is one selected backend now, so there is
- one anchor. Popover still gives us the card surface, outside-click,
- Escape and focus return. */}
- setSplitMenuOpen(false)}
- ariaLabel="Install options"
- >
-
- {
- setSplitMenuOpen(false)
- if (selectedBackend) openPicker(selectedBackend)
- }}
- >
-
- Install on specific nodes…
-
-
-
-
{ setPickerBackend(null); setPickerInitialSelection([]) }}
- onComplete={() => { fetchBackends(); refetchNodes() }}
+ onComplete={() => { fetchBackends(); fetchInstalled(); refetchNodes() }}
backend={pickerBackend}
nodes={clusterNodes}
allBackends={allBackends}
@@ -824,197 +958,19 @@ export default function Backends() {
initialSelection={pickerInitialSelection}
addToast={addToast}
/>
- >
+
+ {cancelling.waitingFor && (
+
)}
)
}
-
-function BackendDetailRow({ label, children }) {
- if (!children) return null
- return (
-
-
- {label}
-
- {children}
-
- )
-}
-
-function BackendDetail({ backend }) {
- return (
-
-
-
-
- {backend.description && (
-
- )}
-
-
- {backend.gallery && (
-
- {typeof backend.gallery === 'string' ? backend.gallery : backend.gallery.name || '-'}
-
- )}
-
-
- {backend.license && {backend.license} }
-
-
- {backend.tags?.length > 0 && (
-
- {backend.tags.map(tag => (
- {tag}
- ))}
-
- )}
-
-
- {backend.urls?.length > 0 && (
-
- {backend.urls.map((url, i) => (
-
- {url}
-
- ))}
-
- )}
-
-
-
-
- )
-}
-
-// The rail line spends its one fact on the thing that decides what you do
-// next: whether it is here, whether it is stale, whether it is moving.
-function railItemForBackend(backend, { getBackendOp, upgrades }) {
- const name = backend.name || backend.id
- const op = getBackendOp(backend)
- const processing = !!op && !op.error
- const upgrade = upgrades[name]
-
- let meta = backend.version ? `v${backend.version}` : 'not installed'
- let metaTone
- if (processing) {
- meta = op.isDeletion ? 'deleting' : op.isQueued ? 'queued' : `installing${op.progress > 0 ? ` ${Math.round(op.progress)}%` : ''}`
- metaTone = 'busy'
- } else if (upgrade) {
- meta = upgrade.available_version ? `v${backend.version} → v${upgrade.available_version}` : 'update available'
- metaTone = 'warn'
- } else if (backend.installed) {
- meta = backend.version ? `v${backend.version} · installed` : 'installed'
- metaTone = 'ok'
- }
-
- return { id: name, name, icon: groupForEntity(backend).icon, meta, metaTone, groupId: groupForEntity(backend).id }
-}
-
-function BackendSortButton({ col, label, sortBy, sortOrder, onSort }) {
- const active = sortBy === col
- return (
- onSort(col)}
- >
- {label}
- {active && }
-
- )
-}
-
-// BackendHostPane is the pane with nothing selected.
-//
-// A backend's fitness is not free memory, it is the accelerator and platform it
-// was built for, so this leads with what the host actually is. That is the
-// question the table never answered: it listed 37 runtimes and left "which of
-// these can even run here" entirely to the reader.
-function BackendHostPane({ resources, backends, installedCount, upgrades, onSelect, onUpgradeAll, upgradingAll }) {
- const gpu = resources?.gpus?.[0]
- const accelerator = gpu ? `${gpu.name}${gpu.vendor ? ` (${gpu.vendor})` : ''}` : null
- const staleNames = Object.keys(upgrades)
- // Not installed, and near the top of whatever order the gallery returned,
- // which is the gallery's own notion of prominence rather than one invented
- // here. Anything cleverer needs a ranking rule somebody owns.
- const suggestions = backends.filter(b => !b.installed).slice(0, 3)
-
- return (
-
-
-
This host
-
- {accelerator
- ? `${accelerator}. ${backends.length} backends in the gallery, ${installedCount} installed.`
- : `${backends.length} backends in the gallery, ${installedCount} installed.`}
-
-
- A backend is a runtime, so what decides it is your accelerator and platform rather than free memory.
- Pick one on the left for its builds, licence and repository.
-
-
-
- {staleNames.length > 0 && (
-
-
-
- {staleNames.length === 1
- ? '1 installed backend has a newer build.'
- : `${staleNames.length} installed backends have a newer build.`}
- {' '}{staleNames.slice(0, 3).join(', ')}{staleNames.length > 3 ? '…' : ''}
-
-
- Upgrade all
-
-
- )}
-
- {suggestions.length > 0 && (
-
-
-
Not installed yet
- {backends.length - installedCount} available
-
-
- {suggestions.map((b, i) => {
- const name = b.name || b.id
- return (
- onSelect(name)}
- >
-
-
- {name}
-
- {stripMarkdown(b.description).slice(0, 90) || '—'}
-
- {b.license || 'no licence'}
-
-
- )
- })}
-
-
- )}
-
- )
-}
-
-// Backends is not translated (6 t() calls in the whole page), so the shared
-// group ids get literal labels here rather than i18n keys.
-const BACKEND_GROUP_LABELS = {
- text: 'Text and reasoning',
- vision: 'Vision',
- audio: 'Speech and audio',
- visual: 'Image and video',
- other: 'Everything else',
-}
diff --git a/core/http/react-ui/src/pages/BuildOverview.jsx b/core/http/react-ui/src/pages/BuildOverview.jsx
new file mode 100644
index 000000000..d8130d4c7
--- /dev/null
+++ b/core/http/react-ui/src/pages/BuildOverview.jsx
@@ -0,0 +1,230 @@
+// Link and PageHeader are used in JSX only, which eslint cannot see.
+// eslint-disable-next-line no-unused-vars
+import { Link } from 'react-router-dom'
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+// eslint-disable-next-line no-unused-vars
+import PageHeader from '../components/PageHeader'
+import Icon from '../components/Icon'
+import { buildHub, isTabVisible, visibleItems } from '../components/hub/hubConfig'
+import { useHubAuth } from '../components/hub/useHubAuth'
+import { fineTuneApi, quantizationApi, resourcesApi } from '../utils/api'
+import { formatBytes } from '../utils/format'
+import { FT_STAGES, QZ_STAGES, jobToSurface, machineFacts, toolStatus } from '../utils/tools'
+import { preloadRoute } from '../router'
+import '../components/tools/tools.css'
+
+// The tools in three groups. A tool the viewer cannot use is left out, so a
+// group with nothing in it is left out too.
+const GROUPS = [
+ { id: 'make', tools: ['import', 'quantize', 'fine-tune'] },
+ { id: 'recognise', tools: ['voices', 'faces'] },
+ { id: 'automate', tools: ['agents', 'skills', 'memory', 'jobs'] },
+]
+const EXPERIMENTAL = new Set(['quantize', 'fine-tune'])
+const CHECKED = new Set(['import', 'quantize', 'fine-tune'])
+
+function sized(vals) {
+ const out = { ...(vals || {}) }
+ for (const key of ['free', 'total']) if (typeof out[key] === 'number') out[key] = formatBytes(out[key])
+ return out
+}
+
+// The Build landing: what each tool is for and what it needs from this machine.
+// The needs are read from the machine's own figures and the installed backends,
+// never guessed. A tool that cannot run says why and what enables it. One line
+// above the list shows a job that is running or the newest job when it failed.
+export default function BuildOverview() {
+ const { t } = useTranslation('tools')
+ const { t: tn } = useTranslation('nav')
+ const auth = useHubAuth()
+ const visible = useMemo(
+ () => buildHub.tabs.filter(tab => tab.id !== 'overview' && isTabVisible(tab, auth)),
+ [auth],
+ )
+ const ids = useMemo(() => new Set(visible.map(tab => tab.id)), [visible])
+ const hasFineTune = ids.has('fine-tune')
+ const hasQuantize = ids.has('quantize')
+
+ const [facts, setFacts] = useState(null)
+ const [backends, setBackends] = useState({ fineTune: null, quantize: null })
+ const [jobs, setJobs] = useState({ fineTune: [], quantize: [] })
+
+ useEffect(() => {
+ if (!auth.isAdmin) return undefined
+ let alive = true
+ resourcesApi.get().then(r => { if (alive) setFacts(machineFacts(r)) }).catch(() => {})
+ return () => { alive = false }
+ }, [auth.isAdmin])
+
+ useEffect(() => {
+ let alive = true
+ if (hasFineTune) {
+ fineTuneApi.listBackends().then(b => { if (alive) setBackends(prev => ({ ...prev, fineTune: Array.isArray(b) ? b : [] })) }).catch(() => {})
+ fineTuneApi.listJobs().then(j => { if (alive) setJobs(prev => ({ ...prev, fineTune: Array.isArray(j) ? j : [] })) }).catch(() => {})
+ }
+ if (hasQuantize) {
+ quantizationApi.listBackends().then(b => { if (alive) setBackends(prev => ({ ...prev, quantize: Array.isArray(b) ? b : [] })) }).catch(() => {})
+ quantizationApi.listJobs().then(j => { if (alive) setJobs(prev => ({ ...prev, quantize: Array.isArray(j) ? j : [] })) }).catch(() => {})
+ }
+ return () => { alive = false }
+ }, [hasFineTune, hasQuantize])
+
+ const now = useMemo(() => {
+ const ft = hasFineTune ? jobToSurface(jobs.fineTune, FT_STAGES) : null
+ const qz = hasQuantize ? jobToSurface(jobs.quantize, QZ_STAGES) : null
+ const picks = [ft && { ...ft, tool: 'fine-tune', path: '/app/fine-tune' }, qz && { ...qz, tool: 'quantize', path: '/app/quantize' }].filter(Boolean)
+ return picks.find(p => p.kind === 'running') || picks[0] || null
+ }, [jobs, hasFineTune, hasQuantize])
+
+ const byId = Object.fromEntries(visible.map(tab => [tab.id, tab]))
+ const groups = GROUPS.map(g => ({ ...g, list: g.tools.map(id => byId[id]).filter(Boolean) })).filter(g => g.list.length > 0)
+ const makeIds = (groups.find(g => g.id === 'make')?.list || []).map(tab => tab.id)
+
+ const quantBackend = Array.isArray(backends.quantize) ? backends.quantize.map(b => b.name) : []
+ const trainBackend = Array.isArray(backends.fineTune) ? backends.fineTune.map(b => b.name) : []
+ const installedBackends = [...new Set([...quantBackend, ...trainBackend])]
+
+ return (
+
+
+
+ {visible.length === 0 ? (
+
{tn('hub.overviewEmpty')}
+ ) : (
+ <>
+ {facts && (
+
+ {t('landing.machine')}
+
+
+
{t('landing.gpu')}
+
+ {facts.cluster
+ ? t('landing.cluster', sized({ total: facts.cluster.total, node: facts.cluster.node || t('landing.clusterNode') }))
+ : facts.hasGpu
+ ? <>{facts.gpuName} {t('landing.gpuFree', sized({ free: facts.vramFree, total: facts.vramTotal }))}>
+ : t('landing.noGpu')}
+
+
+ {facts.ramTotal != null && (
+
+
{t('landing.ram')}
+ {formatBytes(facts.ramTotal)} {facts.ramFree != null && t('landing.ramFree', sized({ free: facts.ramFree }))}
+
+ )}
+ {facts.diskFree != null && (
+
+
{t('landing.disk')}
+ {formatBytes(facts.diskFree)} {t('landing.diskFree')}
+
+ )}
+
+ {(hasFineTune || hasQuantize) && (
+
+ {t('landing.backends')}{' '}
+ {installedBackends.length > 0
+ ? installedBackends.map(name => {name} )
+ : {t('landing.noBackends')} }
+
+ )}
+
+ )}
+
+ {now && (
+
+
+
+
+ {t(`landing.now.${now.kind}.${now.tool === 'fine-tune' ? 'fineTune' : 'quantize'}`, {
+ model: now.job.model,
+ type: now.job.quantization_type || '',
+ })}
+
+ {now.job.message &&
{now.job.message}
}
+
+ {t('landing.open')}
+
+ )}
+
+
+ {groups.map(group => (
+
+
+ {group.id === 'make' && makeIds.length > 1 && (
+
+
+ {[...makeIds.map(id => ({ id, icon: byId[id].icon, label: tn(byId[id].labelKey) })), { id: 'chat', icon: 'chat', label: tn('items.chat') }].map((step, i) => (
+
+ {i > 0 && }
+ {step.label}
+
+ ))}
+
+
{t('landing.flowNote')}
+
+ )}
+
+
+ ))}
+
+ >
+ )}
+
+ )
+}
diff --git a/core/http/react-ui/src/pages/Chat.jsx b/core/http/react-ui/src/pages/Chat.jsx
index d714d4fde..3a9cd400d 100644
--- a/core/http/react-ui/src/pages/Chat.jsx
+++ b/core/http/react-ui/src/pages/Chat.jsx
@@ -3,39 +3,62 @@ import { useParams, useOutletContext, useNavigate, useLocation } from 'react-rou
import { useTranslation } from 'react-i18next'
import { fromState } from '../utils/editorNav'
import { useChat } from '../hooks/useChat'
-import ModelSelector from '../components/ModelSelector'
import { renderMarkdown, highlightAll, enhanceCodeBlocks } from '../utils/markdown'
import { extractCodeArtifacts, renderMarkdownWithArtifacts } from '../utils/artifacts'
+// eslint-disable-next-line no-unused-vars
import CanvasPanel from '../components/CanvasPanel'
-import Toggle from '../components/Toggle'
+// eslint-disable-next-line no-unused-vars
import { fileToBase64, modelsApi, mcpApi } from '../utils/api'
import { readAttachmentText } from '../utils/pdf'
import { CAP_CHAT } from '../utils/capabilities'
import { useMCPClient } from '../hooks/useMCPClient'
-import MCPAppFrame from '../components/MCPAppFrame'
+// eslint-disable-next-line no-unused-vars
import UnifiedMCPDropdown from '../components/UnifiedMCPDropdown'
+// eslint-disable-next-line no-unused-vars
+import HomeComposer from '../components/home/HomeComposer'
+// eslint-disable-next-line no-unused-vars
+import HomeModelPicker from '../components/home/HomeModelPicker'
+import { useModels } from '../hooks/useModels'
+import { useModelFit } from '../hooks/useModelFit'
+import { fillStyle, hostMemory, memoryFigure } from '../components/home/memory'
+import { gbLabel, gbNumber } from '../utils/modelLedger'
+import { CHAT_SLASH_GROUPS, availableChatActions } from '../components/chat/chatActions'
+import { isTyping } from '../components/chat/chatText'
+// eslint-disable-next-line no-unused-vars
+import ChatHeader from '../components/chat/ChatHeader'
+// eslint-disable-next-line no-unused-vars
+import ShortcutsDialog from '../components/chat/ShortcutsDialog'
+// eslint-disable-next-line no-unused-vars
+import ChatSettingsSheet from '../components/chat/ChatSettingsSheet'
+// eslint-disable-next-line no-unused-vars
+import FindBar from '../components/chat/FindBar'
+// eslint-disable-next-line no-unused-vars
+import LoadCard from '../components/chat/LoadCard'
+// eslint-disable-next-line no-unused-vars
+import { EmptyHead, EmptyUnder } from '../components/chat/EmptyChat'
+import { conversationsFromChats } from '../utils/homeConversations'
+import { applyFind, clearFind } from '../components/chat/findInThread'
+// eslint-disable-next-line no-unused-vars
+import HomeUndoToast from '../components/home/HomeUndoToast'
import { loadClientMCPServers } from '../utils/mcpClientStorage'
+// eslint-disable-next-line no-unused-vars
import ConfirmDialog from '../components/ConfirmDialog'
+// eslint-disable-next-line no-unused-vars
import ChatsMenu from '../components/ChatsMenu'
import { useAuth } from '../context/AuthContext'
import { useOperations } from '../hooks/useOperations'
-import { relativeTime } from '../utils/format'
+import { useLoadedModels } from '../hooks/useLoadedModels'
import { copyToClipboard } from '../utils/clipboard'
+import Icon from '../components/Icon'
+// eslint-disable-next-line no-unused-vars
+import Lightbox from '../components/Lightbox'
+// eslint-disable-next-line no-unused-vars
+import ChatMessage, { ActivityRow, StreamingTurn } from '../components/chat/ChatMessage'
+import { editableMessageText, withEditedMessageText, isActivityRole } from '../components/chat/chatText'
+import './chat.css'
const FOCUS_MODE_KEY = 'localai_chat_focus_mode'
-function getLastMessagePreview(chat) {
- if (!chat.history || chat.history.length === 0) return ''
- for (let i = chat.history.length - 1; i >= 0; i--) {
- const msg = chat.history[i]
- if (msg.role === 'user' || msg.role === 'assistant') {
- const text = typeof msg.content === 'string' ? msg.content : msg.content?.[0]?.text || ''
- return text.slice(0, 40).replace(/\n/g, ' ')
- }
- }
- return ''
-}
-
function serializeChatAsMarkdown(chat) {
let md = `# ${chat.name}\n\n`
md += `Model: ${chat.model || 'Unknown'}\n`
@@ -63,240 +86,6 @@ function downloadChatAsMarkdown(chat) {
URL.revokeObjectURL(url)
}
-function formatToolContent(raw) {
- try {
- const data = JSON.parse(raw)
- const name = data.name || 'unknown'
- let params = data.arguments || data.input || data.result || data.parameters || {}
- if (typeof params === 'string') {
- try { params = JSON.parse(params) } catch (_) { /* keep as string */ }
- }
- const entries = typeof params === 'object' && params !== null ? Object.entries(params) : []
- return { name, entries, fallback: null }
- } catch (_e) {
- return { name: null, entries: [], fallback: raw }
- }
-}
-
-function ToolParams({ entries, fallback }) {
- if (fallback) {
- return {fallback}
- }
- if (entries.length === 0) return null
- return (
-
- {entries.map(([k, v]) => {
- const val = typeof v === 'string' ? v : JSON.stringify(v, null, 2)
- const isLong = val.length > 120
- return (
-
- {k}:
- {val}
-
- )
- })}
-
- )
-}
-
-function ActivityGroup({ items, updateChatSettings, activeChat, getClientForTool }) {
- const { t } = useTranslation('chat')
- const [expanded, setExpanded] = useState(false)
- const contentRef = useRef(null)
-
- useEffect(() => {
- if (expanded && contentRef.current) highlightAll(contentRef.current)
- }, [expanded])
-
- if (!items || items.length === 0) return null
-
- // Separate out tool_result items that have appUI — they render outside the collapsed group
- const appUIItems = items.filter(item => item.role === 'tool_result' && item.appUI)
- const regularItems = items.filter(item => !(item.role === 'tool_result' && item.appUI))
-
- const labels = regularItems.map(item => {
- if (item.role === 'thinking' || item.role === 'reasoning') return t('activity.thought')
- if (item.role === 'tool_call') {
- try { return JSON.parse(item.content)?.name || t('activity.tool') } catch (_e) { return t('activity.tool') }
- }
- if (item.role === 'tool_result') {
- try { return t('activity.toolResult', { name: JSON.parse(item.content)?.name || t('activity.tool') }) } catch (_e) { return t('activity.result') }
- }
- return item.role
- })
- const summary = labels.join(' → ')
-
- return (
- <>
- {regularItems.length > 0 && (
-
-
-
-
-
-
setExpanded(!expanded)}>
- {summary}
-
-
- {expanded && (
-
- {regularItems.map((item, idx) => {
- if (item.role === 'thinking' || item.role === 'reasoning') {
- return (
-
-
{t('activity.thought')}
-
-
- )
- }
- const isCall = item.role === 'tool_call'
- const parsed = formatToolContent(item.content)
- return (
-
- {labels[idx]}
-
-
- )
- })}
-
- )}
-
-
- )}
- {appUIItems.map((item, idx) => (
-
-
-
-
-
- {item.appUI.toolName}
-
-
-
- ))}
- >
- )
-}
-
-function StreamingActivity({ reasoning, toolCalls, hasResponse }) {
- const { t } = useTranslation('chat')
- const hasContent = reasoning || (toolCalls && toolCalls.length > 0)
- if (!hasContent) return null
-
- const contentRef = useRef(null)
- const [manualCollapse, setManualCollapse] = useState(null)
-
- // Auto-expand while thinking or tool-calling, auto-collapse when response starts
- const autoExpanded = (reasoning || (toolCalls && toolCalls.length > 0)) && !hasResponse
- const expanded = manualCollapse !== null ? !manualCollapse : autoExpanded
-
- // Scroll to bottom of thinking content as it streams
- useEffect(() => {
- if (expanded && contentRef.current) {
- contentRef.current.scrollTop = contentRef.current.scrollHeight
- }
- }, [reasoning, expanded])
-
- // Reset manual override when streaming state changes significantly
- useEffect(() => {
- setManualCollapse(null)
- }, [hasResponse])
-
- const lastTool = toolCalls && toolCalls.length > 0 ? toolCalls[toolCalls.length - 1] : null
- const label = reasoning
- ? t('activity.thinking')
- : lastTool
- ? (lastTool.type === 'tool_call' ? lastTool.name : t('activity.toolResult', { name: lastTool.name }))
- : ''
-
- return (
-
-
-
-
-
-
setManualCollapse(expanded)}>
-
- {label}
-
-
-
- {expanded && reasoning && (
-
- )}
- {expanded && toolCalls && toolCalls.length > 0 && (
-
- {toolCalls.map((tc, idx) => {
- if (tc.type === 'tool_result') {
- return (
-
-
{t('activity.toolResult', { name: tc.name })}
-
-
- )
- }
- const parsed = formatToolContent(JSON.stringify(tc, null, 2))
- return (
-
- {tc.name || tc.type}
-
-
- )
- })}
-
- )}
-
-
- )
-}
-
-function UserMessageContent({ content, files }) {
- const text = typeof content === 'string' ? content : content?.[0]?.text || ''
- return (
- <>
- {text}
- {files && files.length > 0 && (
-
- {files.map((f, i) => (
-
-
- {f.name}
-
- ))}
-
- )}
- {Array.isArray(content) && content.filter(c => c.type === 'image_url').map((img, i) => (
-
- ))}
- {Array.isArray(content) && content.filter(c => c.type === 'video_url').map((vid, i) => (
-
- ))}
- >
- )
-}
-
-function editableMessageText(message) {
- if (typeof message.content === 'string') return message.content
- if (!Array.isArray(message.content)) return null
- const textBlock = message.content.find(block => block?.type === 'text')
- return typeof textBlock?.text === 'string' ? textBlock.text : null
-}
-
// formatLoadEta renders the server's remaining-seconds estimate. The server
// omits it entirely until its observed transfer rate is meaningful, so anything
// arriving here is worth showing.
@@ -308,17 +97,6 @@ function formatLoadEta(seconds) {
return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
}
-function withEditedMessageText(message, text) {
- if (typeof message.content === 'string') return { ...message, content: text }
- const textIndex = message.content.findIndex(block => block?.type === 'text')
- return {
- ...message,
- content: message.content.map((block, index) =>
- index === textIndex ? { ...block, text } : block
- ),
- }
-}
-
export default function Chat() {
const { model: urlModel } = useParams()
const { addToast } = useOutletContext()
@@ -354,6 +132,8 @@ export default function Chat() {
+ (modelLoading.node ? ` ${t('streaming.onNode', { node: modelLoading.node })}` : ''),
progress: modelLoading.progress || 0,
detail: eta ? t('streaming.eta', { value: eta }) : '',
+ sent: modelLoading.bytes_sent || 0,
+ total: modelLoading.total_bytes || 0,
}
}
if (stagingOp) {
@@ -382,16 +162,21 @@ export default function Chat() {
const [mcpResourceList, setMcpResourceList] = useState([])
const [mcpResourcesLoading, setMcpResourcesLoading] = useState(false)
const [modelInfo, setModelInfo] = useState(null)
- const [showModelInfo, setShowModelInfo] = useState(false)
+ const [find, setFind] = useState({ open: false, query: '', index: 0, count: 0, token: 0 })
const [canvasMode, setCanvasMode] = useState(false)
const [canvasOpen, setCanvasOpen] = useState(false)
const [selectedArtifactId, setSelectedArtifactId] = useState(null)
const [clientMCPServers, setClientMCPServers] = useState(() => loadClientMCPServers())
const [confirmDialog, setConfirmDialog] = useState(null)
- const [completionGlowIdx, setCompletionGlowIdx] = useState(-1)
+ const [lightbox, setLightbox] = useState(null)
+ const [renaming, setRenaming] = useState(false)
+ const [showShortcuts, setShowShortcuts] = useState(false)
+ const [pendingDelete, setPendingDelete] = useState(null)
+ const pendingDeleteRef = useRef(null)
const [editingMessageIndex, setEditingMessageIndex] = useState(null)
const [messageEditDraft, setMessageEditDraft] = useState('')
- const prevStreamingRef = useRef(false)
+ const pendingArtifactRef = useRef(null)
+ const { ids: loadedIds } = useLoadedModels()
const {
connect: mcpConnect, disconnect: mcpDisconnect, disconnectAll: mcpDisconnectAll,
getToolsForLLM, isClientTool, executeTool, connectionStatuses, getConnectedTools,
@@ -404,6 +189,20 @@ export default function Chat() {
const stickToBottomRef = useRef(true)
const [scrolledUp, setScrolledUp] = useState(false)
const chatsMenuRef = useRef(null)
+ const pickerRef = useRef(null)
+ const { models: chatModels, loading: chatModelsLoading } = useModels(CAP_CHAT)
+ const [fitOpenCount, setFitOpenCount] = useState(0)
+ const modelNames = useMemo(() => chatModels.map(m => m.id), [chatModels])
+ const modelFit = useModelFit({ openCount: fitOpenCount, names: modelNames, contextSize: activeChat?.contextSize })
+ const onPickerOpen = useCallback(() => setFitOpenCount(n => n + 1), [])
+ const hasThread = (activeChat?.history?.length || 0) > 0
+ const slashConfig = useMemo(() => ({
+ actions: availableChatActions({ isAdmin, hasModels: chatModels.length > 0, hasThread }),
+ groups: CHAT_SLASH_GROUPS,
+ label: (a) => t(`slash.${a.id}.label`),
+ desc: (a) => t(`slash.${a.id}.desc`),
+ groupLabel: (g) => t(`slash.group.${g}`),
+ }), [isAdmin, chatModels.length, hasThread, t])
// Focus mode: once a conversation has at least one message we slim the
// surrounding chrome (collapse the global app rail, fade non-essential
@@ -426,6 +225,7 @@ export default function Chat() {
() => canvasMode ? extractCodeArtifacts(activeChat?.history, 'role', 'assistant') : [],
[activeChat?.history, canvasMode]
)
+ const modelWarm = !!activeChat?.model && loadedIds.has(activeChat.model)
const prevArtifactCountRef = useRef(0)
useEffect(() => {
@@ -433,25 +233,14 @@ export default function Chat() {
}, [activeChat?.id])
useEffect(() => {
if (artifacts.length > prevArtifactCountRef.current && artifacts.length > 0) {
- setSelectedArtifactId(artifacts[artifacts.length - 1].id)
+ // A block opened from its own Canvas button is the one to show.
+ setSelectedArtifactId(pendingArtifactRef.current || artifacts[artifacts.length - 1].id)
+ pendingArtifactRef.current = null
if (!canvasOpen) setCanvasOpen(true)
}
prevArtifactCountRef.current = artifacts.length
}, [artifacts])
- // Completion glow: when streaming finishes, briefly highlight last assistant message
- useEffect(() => {
- if (prevStreamingRef.current && !isStreaming && activeChat?.history?.length > 0) {
- const lastIdx = activeChat.history.length - 1
- if (activeChat.history[lastIdx]?.role === 'assistant') {
- setCompletionGlowIdx(lastIdx)
- const timer = setTimeout(() => setCompletionGlowIdx(-1), 600)
- return () => clearTimeout(timer)
- }
- }
- prevStreamingRef.current = isStreaming
- }, [isStreaming, activeChat?.history?.length])
-
// Check MCP availability and fetch model config (admin-only endpoint)
useEffect(() => {
const model = activeChat?.model
@@ -661,15 +450,18 @@ export default function Chat() {
const data = JSON.parse(stored)
localStorage.removeItem('localai_index_chat_data')
- // Two entry shapes from Home:
+ // Three entry shapes from Home:
// - "compose-and-send": data.message present → open new chat,
// prefill the composer, click submit.
// - "open-assistant": no message, just data.localaiAssistant → open
// a fresh chat already in admin mode so the wizard can fire.
+ // - "new-chat": no message, data.newChat only → open an empty chat
+ // on the chosen model (the /new action on the command bar).
const hasMessage = !!data.message
const wantsAssistant = !!data.localaiAssistant
+ const wantsNewChat = !!data.newChat
- if (hasMessage || wantsAssistant) {
+ if (hasMessage || wantsAssistant || wantsNewChat) {
let targetChat = activeChat
if (data.newChat) {
targetChat = addChat(data.model || '', '', data.mcpMode || false)
@@ -750,8 +542,13 @@ export default function Chat() {
}
}, [focusActive])
- // Global keybindings: Cmd/Ctrl+K opens the chats menu; Esc exits focus
- // mode while it is engaged (without closing any open dialogs first).
+ // Global keybindings: Cmd/Ctrl+K opens the chats menu and Cmd/Ctrl+Shift+F
+ // searches this chat. Esc stops a reply that is streaming, else closes the
+ // search, else closes the canvas; it also exits focus mode while that is
+ // engaged. None of that fires while a menu or dialog is open: those take
+ // their own Esc first.
+ const escapeRef = useRef({})
+ escapeRef.current = { streaming: isStreaming, stop: stopGeneration, findOpen: find.open, canvasOpen }
useEffect(() => {
const onKey = (e) => {
const isMod = e.metaKey || e.ctrlKey
@@ -760,8 +557,30 @@ export default function Chat() {
chatsMenuRef.current?.toggle()
return
}
+ if (isMod && e.shiftKey && (e.key === 'f' || e.key === 'F')) {
+ e.preventDefault()
+ setFind(f => ({ ...f, open: true, token: f.token + 1 }))
+ return
+ }
+ // "/" from anywhere that is not a text field starts a command, as on Home.
+ if (e.key === '/' && !isMod && !e.altKey && !isTyping(document.activeElement) && !document.querySelector('[role="dialog"], [role="alertdialog"]')) {
+ const el = textareaRef.current
+ if (el) {
+ e.preventDefault()
+ setInput('/')
+ el.focus()
+ }
+ return
+ }
+ const overlay = document.querySelector('.home-menu, .cx-menu, .dk-cmdlist, [role="dialog"], [role="alertdialog"]')
+ if (e.key === 'Escape' && !overlay) {
+ const cur = escapeRef.current
+ if (cur.streaming) cur.stop()
+ else if (cur.findOpen) setFind(f => ({ ...f, open: false, query: '', index: 0, count: 0 }))
+ else if (cur.canvasOpen) setCanvasOpen(false)
+ }
if (e.key === 'Escape' && focusActive) {
- // Don't fight the chats menu / settings drawer / dialogs — they
+ // Don't fight the chats menu / settings sheet / dialogs: they
// each handle their own Esc and stop propagation when open.
setFocusOverride(true)
}
@@ -770,6 +589,33 @@ export default function Chat() {
return () => window.removeEventListener('keydown', onKey)
}, [focusActive])
+ // Find in chat: mark the matches in the thread, then show the current one.
+ // The search is the browser's own, over what this page has loaded.
+ useEffect(() => {
+ const root = messagesRef.current
+ if (!root) return
+ if (!find.open || !find.query) {
+ clearFind(root)
+ setFind(f => (f.count === 0 ? f : { ...f, count: 0 }))
+ return
+ }
+ const marks = applyFind(root, find.query)
+ setFind(f => (f.count === marks.length ? f : { ...f, count: marks.length, index: Math.min(f.index, Math.max(0, marks.length - 1)) }))
+ }, [find.open, find.query, activeChat?.history, canvasMode, isStreaming])
+
+ useEffect(() => {
+ const root = messagesRef.current
+ if (!root) return
+ root.querySelectorAll('mark.cx-hit').forEach((mark, i) => {
+ if (i === find.index) {
+ mark.setAttribute('data-cur', '')
+ mark.scrollIntoView({ block: 'center' })
+ } else {
+ mark.removeAttribute('data-cur')
+ }
+ })
+ }, [find.index, find.count, find.query])
+
// Highlight code blocks + add per-block copy buttons. A MutationObserver on
// the messages container is more reliable than render-keyed effects: it fires
// for loaded/switched chats AND for streaming token updates, regardless of
@@ -779,16 +625,21 @@ export default function Chat() {
const el = messagesRef.current
if (!el) return
let obs
+ const labels = {
+ copyLabel: t('actions.copy'),
+ canvasLabel: canvasMode ? undefined : t('input.canvasLabel'),
+ selector: '.cx-prose pre:not([data-enhanced])',
+ }
const run = () => {
obs?.disconnect()
highlightAll(el)
- enhanceCodeBlocks(el)
+ enhanceCodeBlocks(el, labels)
obs?.observe(el, { childList: true, subtree: true })
}
obs = new MutationObserver(run)
run()
return () => obs.disconnect()
- }, [activeChat?.id])
+ }, [activeChat?.id, canvasMode, t])
// Auto-grow textarea
const autoGrowTextarea = useCallback(() => {
@@ -802,11 +653,26 @@ export default function Chat() {
autoGrowTextarea()
}, [input, autoGrowTextarea])
- // Event delegation for artifact cards
+ // Event delegation for artifact cards and the Canvas button on a code block.
useEffect(() => {
const el = messagesRef.current
- if (!el || !canvasMode) return
+ if (!el) return
const handler = (e) => {
+ const canvasBtn = e.target.closest('.code-canvas-btn')
+ if (canvasBtn) {
+ const text = canvasBtn.closest('.code-block')?.querySelector('pre code')?.textContent || ''
+ const at = Number(canvasBtn.closest('[data-index]')?.dataset.index)
+ const all = extractCodeArtifacts(activeChat?.history, 'role', 'assistant')
+ const same = (a) => a.code.trim() === text.trim()
+ const match = all.find(a => a.messageIndex === at && same(a)) || all.find(same)
+ if (!match) return
+ pendingArtifactRef.current = match.id
+ setSelectedArtifactId(match.id)
+ setCanvasMode(true)
+ setCanvasOpen(true)
+ return
+ }
+ if (!canvasMode) return
const openBtn = e.target.closest('.artifact-card-open')
const downloadBtn = e.target.closest('.artifact-card-download')
const card = e.target.closest('.artifact-card')
@@ -835,11 +701,13 @@ export default function Chat() {
}
el.addEventListener('click', handler)
return () => el.removeEventListener('click', handler)
- }, [canvasMode, artifacts])
+ }, [canvasMode, artifacts, activeChat?.history])
- const handleFileChange = useCallback(async (e) => {
+ // Files from any of the three attach buttons. Text and PDF files carry their
+ // extracted text with them; a file that cannot be read is skipped with a toast.
+ const attachFiles = useCallback(async (_kind, list) => {
const newFiles = []
- for (const file of e.target.files) {
+ for (const file of list) {
const base64 = await fileToBase64(file)
const entry = { name: file.name, type: file.type, base64 }
if (!file.type.startsWith('image/') && !file.type.startsWith('audio/') && !file.type.startsWith('video/')) {
@@ -853,7 +721,6 @@ export default function Chat() {
newFiles.push(entry)
}
setFiles(prev => [...prev, ...newFiles])
- e.target.value = ''
}, [addToast, t])
const handlePaste = useCallback(async (e) => {
@@ -878,6 +745,7 @@ export default function Chat() {
}, [])
const handleSend = useCallback(async () => {
+ if (isStreaming) return
const msg = input.trim()
if (!msg && files.length === 0) return
if (!activeChat?.model) {
@@ -907,7 +775,7 @@ export default function Chat() {
},
} : {}
await sendMessage(msg, files, mcpOptions)
- }, [input, files, activeChat, sendMessage, addToast, getToolsForLLM, isClientTool, executeTool, hasAppUI, getAppResource, getToolDefinition])
+ }, [isStreaming, input, files, activeChat, sendMessage, addToast, getToolsForLLM, isClientTool, executeTool, hasAppUI, getAppResource, getToolDefinition])
const handleRegenerate = useCallback(async (targetIndex) => {
if (!activeChat || isStreaming) return
@@ -934,21 +802,19 @@ export default function Chat() {
await sendMessage(userContent, userFiles, { baseHistory, prebuiltContent: true })
}, [activeChat, isStreaming, sendMessage, updateChatSettings])
- const handleKeyDown = (e) => {
- // Only Enter (no modifiers, no IME composition) sends.
- // Shift+Enter, Ctrl+Enter, Meta+Enter, Alt+Enter all fall through to default textarea behavior (newline).
- if (
- e.key === 'Enter' &&
- !e.shiftKey &&
- !e.ctrlKey &&
- !e.metaKey &&
- !e.altKey &&
- !e.nativeEvent?.isComposing &&
- e.keyCode !== 229
- ) {
- e.preventDefault()
- handleSend()
+ // Up in an empty box edits your last message, as in most chat apps.
+ const onComposerKey = (e) => {
+ if (e.key !== 'ArrowUp' || input || e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return false
+ if (isStreaming || !activeChat) return false
+ for (let i = activeChat.history.length - 1; i >= 0; i--) {
+ const msg = activeChat.history[i]
+ if (msg.role === 'user' && editableMessageText(msg) !== null) {
+ e.preventDefault()
+ startMessageEdit(i, msg)
+ return true
+ }
}
+ return false
}
const copyMessage = async (content) => {
@@ -966,13 +832,93 @@ export default function Chat() {
addToast(ok ? t('toasts.chatCopied') : t('toasts.copyFailed'), ok ? 'success' : 'error', ok ? 2000 : 3000)
}
- const contextPercent = getContextUsagePercent()
+ // The thread as rows: a run of reasoning and tool entries folds into the
+ // assistant message that follows it, or stands alone when none does yet.
+ const history = activeChat?.history
+ const rows = useMemo(() => {
+ const out = []
+ let buf = []
+ ;(history || []).forEach((msg, i) => {
+ if (isActivityRole(msg.role)) { buf.push(msg); return }
+ if (buf.length > 0 && msg.role !== 'assistant') {
+ out.push({ kind: 'activity', key: i, items: buf })
+ buf = []
+ }
+ out.push({ kind: 'msg', index: i, msg, activity: buf.length > 0 ? buf : null })
+ buf = []
+ })
+ if (buf.length > 0) out.push({ kind: 'activity', key: 'end', items: buf })
+ return out
+ }, [history])
- // Recent chats for the empty state — exclude the current chat and any
- // empty placeholders, keep the four most recently updated.
- const recentChats = chats
- .filter(c => c.id !== activeChatId && (c.history?.length || 0) > 0)
- .slice(0, 4)
+ // Deleting a chat is final when its undo time ends. Until then the chat stays
+ // in storage and only its row is hidden; if it was the open one, the next
+ // chat opens in its place.
+ const deleteRef = useRef(deleteChat)
+ deleteRef.current = deleteChat
+ const commitDelete = useCallback(() => {
+ const pending = pendingDeleteRef.current
+ if (!pending) return
+ pendingDeleteRef.current = null
+ setPendingDelete(null)
+ deleteRef.current(pending.id)
+ }, [])
+ const commitRef = useRef(commitDelete)
+ commitRef.current = commitDelete
+ useEffect(() => () => commitRef.current(), [])
+
+ const visibleChats = useMemo(
+ () => chats.filter(c => c.id !== pendingDelete?.id),
+ [chats, pendingDelete],
+ )
+
+ const requestDelete = (chat) => {
+ commitDelete()
+ const wasActive = chat.id === activeChatId
+ if (wasActive) {
+ const next = chats.find(c => c.id !== chat.id)
+ if (next) switchChat(next.id)
+ }
+ const pending = { id: chat.id, name: chat.name, wasActive }
+ pendingDeleteRef.current = pending
+ setPendingDelete(pending)
+ }
+
+ const undoDelete = () => {
+ const pending = pendingDeleteRef.current
+ pendingDeleteRef.current = null
+ setPendingDelete(null)
+ if (pending?.wasActive) switchChat(pending.id)
+ }
+
+ // The message component is memoised, so it gets one stable set of actions
+ // that always call the latest handlers.
+ const actionsRef = useRef(null)
+ actionsRef.current = {
+ copyMessage, startMessageEdit, saveMessageEdit, cancelMessageEdit,
+ handleRegenerate, forkChat, addToast, t, activeChat,
+ }
+ const messageActions = useMemo(() => ({
+ copy: (content) => actionsRef.current.copyMessage(content),
+ startEdit: (index, message) => actionsRef.current.startMessageEdit(index, message),
+ saveEdit: () => actionsRef.current.saveMessageEdit(),
+ cancelEdit: () => actionsRef.current.cancelMessageEdit(),
+ regenerate: (index) => actionsRef.current.handleRegenerate(index),
+ branch: (index) => {
+ const a = actionsRef.current
+ a.forkChat(a.activeChat.id, index + 1)
+ a.addToast(a.t('toasts.forked'), 'success', 2000)
+ },
+ focusRelative: (index, delta) => {
+ const els = Array.from(messagesRef.current?.querySelectorAll('[data-testid="chat-message"]') || [])
+ const at = els.findIndex(el => Number(el.dataset.index) === index)
+ const next = els[at + delta]
+ if (next) { next.focus(); next.scrollIntoView({ block: 'nearest' }) }
+ },
+ }), [])
+ const openImage = useCallback((images, index) => setLightbox({ images, index }), [])
+
+ const contextPercent = getContextUsagePercent()
const promptDeleteAll = () => setConfirmDialog({
title: t('deleteAllDialog.title'),
@@ -982,644 +928,345 @@ export default function Chat() {
onConfirm: () => { setConfirmDialog(null); deleteAllChats() },
})
+ const promptClear = () => setConfirmDialog({
+ title: t('clearDialog.title'),
+ message: t('clearDialog.message'),
+ confirmLabel: t('clearDialog.confirm'),
+ danger: true,
+ onConfirm: () => { setConfirmDialog(null); clearHistory(activeChat.id) },
+ })
+
if (!activeChat) return null
- const layoutClasses = [
- 'chat-layout',
- isInConversation ? 'chat--has-messages' : '',
- focusActive ? 'chat--focus' : '',
- ].filter(Boolean).join(' ')
+ const openFind = () => setFind(f => ({ ...f, open: true, token: f.token + 1 }))
+ const closeFind = () => {
+ clearFind(messagesRef.current)
+ setFind(f => ({ ...f, open: false, query: '', index: 0, count: 0 }))
+ textareaRef.current?.focus()
+ }
+ const stepFind = (delta) => setFind(f => (f.count === 0 ? f : { ...f, index: (f.index + delta + f.count) % f.count }))
+
+ const isEmpty = activeChat.history.length === 0 && !isStreaming
+ const noModel = !activeChat.model && !chatModelsLoading && chatModels.length === 0
+ const conversations = isEmpty
+ ? conversationsFromChats(visibleChats.filter(c => c.id !== activeChatId))
+ : []
+
+ const toggleCanvasMode = () => {
+ const next = !canvasMode
+ setCanvasMode(next)
+ if (!next) setCanvasOpen(false)
+ }
+
+ // What a row of the model list says: whether it is loaded, what it can do and,
+ // where the server can estimate it, how it fits this machine. A model with no
+ // estimate gets no fit text.
+ const describeModel = (name, warm) => {
+ const vision = chatModels.find(m => m.id === name)?.capabilities?.includes('FLAG_VISION')
+ const reading = modelFit.reading(name)
+ let fit = null
+ if (warm) fit = { tone: 'ok', text: t('picker.readyNow') }
+ else if (reading?.fit) {
+ const f = reading.fit
+ fit = f.state === 'fits'
+ ? { tone: 'ok', text: t('picker.fits', { amount: gbNumber(f.amount) }) }
+ : f.state === 'spill'
+ ? { tone: 'warn', text: t('picker.spill', { amount: gbNumber(f.amount) }) }
+ : { tone: 'err', text: t('picker.over', { amount: gbNumber(f.amount) }) }
+ }
+ return { vision: !!vision, size: reading?.bytes ? gbLabel(reading.bytes) : null, fit }
+ }
+ const memory = hostMemory(modelFit.resources)
+ const memoryNumbers = memory ? memoryFigure(memory.used, memory.total) : null
+ const picker = (
+ updateChatSettings(activeChat.id, { model })}
+ capability={CAP_CHAT}
+ models={chatModels}
+ loading={chatModelsLoading}
+ loadedIds={loadedIds}
+ grouped
+ describe={describeModel}
+ onOpen={onPickerOpen}
+ footer={memoryNumbers && (
+
+ {memory.isGpu ? t('picker.gpuMemory') : t('picker.memory')}
+
+ {t('picker.memoryUsed', { used: memoryNumbers.used, total: memoryNumbers.total, unit: memoryNumbers.unit })}
+
+ )}
+ />
+ )
+
+ const mcp = (
+ {
+ const allNames = mcpServerList.filter(s => !s.error).map(s => s.name)
+ const allSelected = allNames.every(n => (activeChat.mcpServers || []).includes(n))
+ updateChatSettings(activeChat.id, { mcpServers: allSelected ? [] : allNames })
+ }}
+ onFetchServers={fetchMcpServers}
+ clientMCPActiveIds={activeChat.clientMCPServers || []}
+ onClientToggle={handleClientMCPToggle}
+ onClientAdded={handleClientMCPServerAdded}
+ onClientRemoved={handleClientMCPServerRemoved}
+ connectionStatuses={connectionStatuses}
+ getConnectedTools={getConnectedTools}
+ promptsAvailable={mcpAvailable}
+ mcpPromptList={mcpPromptList}
+ mcpPromptsLoading={mcpPromptsLoading}
+ onFetchPrompts={fetchMcpPrompts}
+ onSelectPrompt={handleSelectPrompt}
+ promptArgsDialog={mcpPromptArgsDialog}
+ promptArgsValues={mcpPromptArgsValues}
+ onPromptArgsChange={(name, value) => setMcpPromptArgsValues(prev => ({ ...prev, [name]: value }))}
+ onPromptArgsSubmit={handleExpandPromptWithArgs}
+ onPromptArgsCancel={() => setMcpPromptArgsDialog(null)}
+ resourcesAvailable={mcpAvailable}
+ mcpResourceList={mcpResourceList}
+ mcpResourcesLoading={mcpResourcesLoading}
+ onFetchResources={fetchMcpResources}
+ selectedResources={activeChat.mcpResources || []}
+ onToggleResource={toggleMcpResource}
+ />
+ )
+
+ const canvasChip = (
+
+
+
+ {t('input.canvasLabel')}
+
+ {canvasMode && artifacts.length > 0 && !canvasOpen && (
+ { setSelectedArtifactId(artifacts[0]?.id); setCanvasOpen(true) }}
+ >
+ {artifacts.length}
+
+ )}
+
+ )
+
+ const moreItems = [
+ { key: 'rename', icon: 'pencil', label: t('menu.rename'), onClick: () => setRenaming(true) },
+ { key: 'duplicate', icon: 'copy', label: t('menu.duplicate'), onClick: () => { if (forkChat(activeChat.id)) addToast(t('toasts.forked'), 'success', 2000) } },
+ { key: 'copy', icon: 'clipboard', label: t('menu.copyChat'), hidden: !hasThread, onClick: () => copyChatAsMarkdown(activeChat) },
+ { key: 'export', icon: 'export', label: t('menu.exportMarkdown'), hidden: !hasThread, onClick: () => downloadChatAsMarkdown(activeChat) },
+ { key: 'info', icon: 'info', label: t('header.modelInfo'), hidden: !(activeChat.model && isAdmin), onClick: () => setShowSettings(true) },
+ { key: 'keys', icon: 'keyboard', label: t('shortcuts.title'), onClick: () => setShowShortcuts(true) },
+ { divider: true },
+ { key: 'clear', icon: 'trash', label: t('clearDialog.confirm'), danger: true, hidden: !hasThread, onClick: promptClear },
+ ]
+
+ const runSlash = (id) => {
+ switch (id) {
+ case 'model': pickerRef.current?.open(); break
+ case 'new': addChat(activeChat.model); break
+ case 'chats': chatsMenuRef.current?.open(); break
+ case 'assistant': updateChatSettings(activeChat.id, { localaiAssistant: !activeChat.localaiAssistant }); break
+ case 'canvas': toggleCanvasMode(); break
+ case 'settings': setShowSettings(true); break
+ case 'find': openFind(); break
+ case 'export': downloadChatAsMarkdown(activeChat); break
+ case 'clear': promptClear(); break
+ default: break
+ }
+ }
return (
-
- {/* Chat main area */}
-
- {/* Header */}
-
-
addChat(activeChat.model)}
- onDelete={deleteChat}
- onDeleteAll={promptDeleteAll}
- onRename={renameChat}
- onExport={(chat) => downloadChatAsMarkdown(chat)}
- onCopyChat={(chat) => copyChatAsMarkdown(chat)}
- onDuplicate={(chat) => { if (forkChat(chat.id)) addToast(t('toasts.forked'), 'success', 2000) }}
+
+ {/* Conversation column */}
+
+
addChat(activeChat.model)}
+ onDelete={requestDelete}
+ onDeleteAll={promptDeleteAll}
+ onRename={renameChat}
+ onExport={(chat) => downloadChatAsMarkdown(chat)}
+ onCopyChat={(chat) => copyChatAsMarkdown(chat)}
+ onDuplicate={(chat) => { if (forkChat(chat.id)) addToast(t('toasts.forked'), 'success', 2000) }}
+ />
+ )}
+ manageMode={!!activeChat.localaiAssistant}
+ name={activeChat.name}
+ onRename={(name) => renameChat(activeChat.id, name)}
+ renaming={renaming}
+ setRenaming={setRenaming}
+ contextPercent={contextPercent}
+ contextTokens={activeChat.tokenUsage?.total || 0}
+ contextSize={activeChat.contextSize}
+ onFind={() => openFind()}
+ findOpen={find.open}
+ onSettings={() => setShowSettings(v => !v)}
+ settingsOpen={showSettings}
+ moreItems={moreItems}
+ />
+
+ {find.open && (
+ setFind(f => ({ ...f, query, index: 0 }))}
+ onStep={stepFind}
+ onClose={closeFind}
/>
- {activeChat.localaiAssistant && (
-
-
-
- )}
- {activeChat.name}
- updateChatSettings(activeChat.id, { model })}
- capability={CAP_CHAT}
- style={{ flex: '1 1 0', minWidth: 120 }}
- />
-
- {activeChat.model && isAdmin && (
- setShowModelInfo(prev => !prev)}
- title={t('header.modelInfo')}
- aria-pressed={showModelInfo}
- aria-controls="chat-model-info-panel"
- >
-
-
- )}
- setShowSettings(!showSettings)}
- title={t('header.chatSettings')}
- aria-pressed={showSettings}
- >
-
-
-
-
-
- {/* Model info panel */}
- {showModelInfo && modelInfo && (
-
-
-
{t('header.modelInfoTitle', { model: activeChat.model })}
-
- {isAdmin && activeChat.model && (
- navigate(`/app/model-editor/${encodeURIComponent(activeChat.model)}`, { state: fromState(location, 'Chat') })}
- title={t('header.editConfig')}
- >
- {t('header.editConfig')}
-
- )}
- setShowModelInfo(false)} title={t('header.close')}>
-
-
-
-
-
- {modelInfo.backend &&
{t('modelInfo.backend')} {modelInfo.backend}
}
- {modelInfo.parameters?.model &&
{t('modelInfo.modelFile')} {modelInfo.parameters.model}
}
- {modelInfo.context_size > 0 &&
{t('modelInfo.contextSize')} {modelInfo.context_size}
}
- {modelInfo.threads > 0 &&
{t('modelInfo.threads')} {modelInfo.threads}
}
- {(modelInfo.mcp?.remote || modelInfo.mcp?.stdio) &&
{t('modelInfo.mcp')} {t('modelInfo.configured')}
}
- {modelInfo.template?.chat_message &&
{t('modelInfo.chatTemplate')} {t('modelInfo.yes')}
}
- {modelInfo.gpu_layers > 0 &&
{t('modelInfo.gpuLayers')} {modelInfo.gpu_layers}
}
-
-
)}
- {/* Context window progress bar */}
- {contextPercent !== null && (
-
-
90 ? 'var(--color-error)' : contextPercent > 70 ? 'var(--color-warning)' : 'var(--color-primary)',
- }}
- />
-
- {activeChat.tokenUsage.total > 0
- ? t('context.labelWithTokens', { percent: Math.round(contextPercent), tokens: activeChat.tokenUsage.total })
- : t('context.label', { percent: Math.round(contextPercent) })}
-
-
- )}
-
- {/* Settings slide-out panel */}
-
setShowSettings(false)} />
-
-
- {t('settings.title')}
- setShowSettings(false)}>
-
-
-
-
- {isAdmin && (
-
-
-
- {t('settings.manageMode')}
-
-
- {t('settings.manageModeDesc')}
-
-
-
updateChatSettings(activeChat.id, { localaiAssistant: next })}
- />
-
- )}
-
-
-
- {t('settings.focusMode')}
-
-
- {t('settings.focusModeDesc')}
-
-
-
-
-
- {t('settings.systemPrompt')}
- updateChatSettings(activeChat.id, { systemPrompt: e.target.value })}
- rows={3}
- placeholder={t('settings.systemPromptPlaceholder')}
- />
-
-
-
- {t('settings.temperature')} {activeChat.temperature !== null ? `(${activeChat.temperature})` : ''}
-
-
updateChatSettings(activeChat.id, { temperature: parseFloat(e.target.value) })}
- className="chat-slider"
- />
-
0 2
-
-
-
- {t('settings.topP')} {activeChat.topP !== null ? `(${activeChat.topP})` : ''}
-
-
updateChatSettings(activeChat.id, { topP: parseFloat(e.target.value) })}
- className="chat-slider"
- />
-
0 1
-
-
-
- {t('settings.topK')} {activeChat.topK !== null ? `(${activeChat.topK})` : ''}
-
-
updateChatSettings(activeChat.id, { topK: parseInt(e.target.value) })}
- className="chat-slider"
- />
-
1 100
-
-
- {t('settings.contextSize')}
- updateChatSettings(activeChat.id, { contextSize: parseInt(e.target.value) || null })}
- placeholder={t('settings.contextSizePlaceholder')}
- />
-
-
- clearHistory(activeChat.id)}
- title={t('settings.clearHistory')}
- >
- {t('settings.clearHistory')}
-
-
-
-
-
- {/* Messages */}
-
- {activeChat.history.length === 0 && !isStreaming && (
-
-
{activeChat.localaiAssistant ? t('empty.manageTitle') : t('empty.startTitle')}
-
- {activeChat.localaiAssistant
- ? t('empty.manageText')
- : (activeChat.model ? t('empty.readyText', { model: activeChat.model }) : t('empty.selectModelText'))}
-
-
- {(activeChat.localaiAssistant
- ? t('empty.suggestionsManage', { returnObjects: true })
- : t('empty.suggestionsChat', { returnObjects: true })
- ).map((prompt) => (
- { setInput(prompt); textareaRef.current?.focus() }}
- >
- {prompt}
-
- ))}
-
- {recentChats.length > 0 && (
-
-
- {t('empty.recent')} ⌘K
-
-
- {recentChats.map(chat => (
- switchChat(chat.id)}
- title={chat.name}
- >
- {chat.name}
-
- {getLastMessagePreview(chat) || t('empty.noMessages')}
-
- {relativeTime(chat.updatedAt)}
-
- ))}
-
-
- )}
-
- {t('empty.hintEnter')}
- {t('empty.hintShiftEnter')}
- {t('empty.hintAttach')}
-
-
- )}
- {(() => {
- const elements = []
- let activityBuf = []
- const flushActivity = (key) => {
- if (activityBuf.length > 0) {
- elements.push(
-
- )
- activityBuf = []
- }
- }
- activeChat.history.forEach((msg, i) => {
- const isActivity = msg.role === 'thinking' || msg.role === 'reasoning' ||
- msg.role === 'tool_call' || msg.role === 'tool_result'
- if (isActivity) {
- activityBuf.push(msg)
- return
- }
- flushActivity(i)
- elements.push(
-
-
-
-
-
- {/* Both roles are labelled now that neither is a bubble.
- A transcript needs to say who is speaking; a bubble said
- it by shape and side. */}
- {msg.role === 'assistant' && activeChat.model && (
-
{activeChat.model}
- )}
- {msg.role === 'user' && (
-
{t('message.you')}
- )}
- {editingMessageIndex === i ? (
-
-
setMessageEditDraft(event.target.value)}
- onKeyDown={(event) => {
- if (event.key === 'Escape') cancelMessageEdit()
- }}
- aria-label={t('actions.editMessage')}
- />
-
-
- {t('actions.save')}
-
-
- {t('actions.cancel')}
-
-
-
- ) : (
-
- {msg.role === 'user' ? (
-
- ) : (
-
- )}
-
- )}
- {msg.role === 'assistant' && typeof msg.content === 'string' && msg.content.includes('Error:') && (
-
- {t('errors.viewTraces')}
-
- )}
- {editingMessageIndex !== i && (
-
- copyMessage(msg.content)} title={t('actions.copy')}>
-
-
- {(msg.role === 'user' || msg.role === 'assistant') &&
- editableMessageText(msg) !== null && !isStreaming && (
- startMessageEdit(i, msg)} title={t('actions.edit')}>
-
-
- )}
- {msg.role === 'assistant' && !isStreaming && (
- handleRegenerate(i)} title={t('actions.regenerate')}>
-
-
- )}
- {msg.role === 'assistant' && !isStreaming && (
- { forkChat(activeChat.id, i + 1); addToast(t('toasts.forked'), 'success', 2000) }}
- title={t('actions.branch')}
- >
-
-
- )}
-
- )}
-
-
- )
- })
- flushActivity('end')
- return elements
- })()}
-
- {/* Streaming activity (thinking + tools) */}
- {isStreaming && (streamingReasoning || streamingToolCalls.length > 0) && (
-
- )}
-
- {/* Streaming message */}
- {isStreaming && streamingContent && (
-
-
-
-
-
- {activeChat.model && (
-
{activeChat.model}
- )}
-
-
-
-
- {tokensPerSecond !== null && (
-
- {t('tokens.perSec', { count: tokensPerSecond })}
-
- )}
-
-
- )}
- {isStreaming && !streamingContent && !streamingReasoning && streamingToolCalls.length === 0 && (
-
-
-
-
-
-
- {loadProgress ? (
-
-
- {loadProgress.label}
-
- {loadProgress.progress > 0 && (
-
-
-
{Math.round(loadProgress.progress)}%
-
- )}
- {loadProgress.detail && (
-
{loadProgress.detail}
- )}
-
- ) : (
-
-
-
- )}
-
-
-
- )}
-
- {scrolledUp && (
-
{
- stickToBottomRef.current = true
- setScrolledUp(false)
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
- }}
- >
- {t('actions.jumpToLatest')}
-
- )}
-
-
- {/* Token info bar */}
- {(tokensPerSecond || maxTokensPerSecond || activeChat.tokenUsage?.total > 0) && (
-
- {tokensPerSecond !== null && {t('tokens.perSec', { count: tokensPerSecond })} }
- {maxTokensPerSecond !== null && !isStreaming && (
-
- {t('tokens.peak', { count: maxTokensPerSecond })}
-
- )}
- {activeChat.tokenUsage?.total > 0 && (
-
- {t('tokens.usage', { prompt: activeChat.tokenUsage.prompt, completion: activeChat.tokenUsage.completion, total: activeChat.tokenUsage.total })}
-
- )}
-
- )}
-
- {/* File badges */}
- {files.length > 0 && (
-
- {files.map((f, i) => {
- const isImage = f.type?.startsWith('image/') && f.base64
- return (
-
- {isImage ? (
-
- ) : (
-
- )}
- {f.name}
- setFiles(prev => prev.filter((_, idx) => idx !== i))} aria-label={`Remove ${f.name}`}>
-
-
-
- )
- })}
-
- )}
-
- {/* Input area */}
-
-
-
- {
- const next = !canvasMode
- setCanvasMode(next)
- if (!next) setCanvasOpen(false)
- }}
- aria-pressed={canvasMode}
- title={t('input.canvasTitle')}
- >
-
- {t('input.canvasLabel')}
- {canvasMode && artifacts.length > 0 && !canvasOpen && (
- {
- e.stopPropagation()
- setSelectedArtifactId(artifacts[0]?.id)
- setCanvasOpen(true)
- }}
- onKeyDown={(e) => {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault()
- e.stopPropagation()
- setSelectedArtifactId(artifacts[0]?.id)
- setCanvasOpen(true)
- }
- }}
- >
- {artifacts.length}
-
- )}
-
- {
- const allNames = mcpServerList.filter(s => !s.error).map(s => s.name)
- const allSelected = allNames.every(n => (activeChat.mcpServers || []).includes(n))
- updateChatSettings(activeChat.id, { mcpServers: allSelected ? [] : allNames })
- }}
- onFetchServers={fetchMcpServers}
- clientMCPActiveIds={activeChat.clientMCPServers || []}
- onClientToggle={handleClientMCPToggle}
- onClientAdded={handleClientMCPServerAdded}
- onClientRemoved={handleClientMCPServerRemoved}
- connectionStatuses={connectionStatuses}
- getConnectedTools={getConnectedTools}
- promptsAvailable={mcpAvailable}
- mcpPromptList={mcpPromptList}
- mcpPromptsLoading={mcpPromptsLoading}
- onFetchPrompts={fetchMcpPrompts}
- onSelectPrompt={handleSelectPrompt}
- promptArgsDialog={mcpPromptArgsDialog}
- promptArgsValues={mcpPromptArgsValues}
- onPromptArgsChange={(name, value) => setMcpPromptArgsValues(prev => ({ ...prev, [name]: value }))}
- onPromptArgsSubmit={handleExpandPromptWithArgs}
- onPromptArgsCancel={() => setMcpPromptArgsDialog(null)}
- resourcesAvailable={mcpAvailable}
- mcpResourceList={mcpResourceList}
- mcpResourcesLoading={mcpResourcesLoading}
- onFetchResources={fetchMcpResources}
- selectedResources={activeChat.mcpResources || []}
- onToggleResource={toggleMcpResource}
- />
-
-
fileInputRef.current?.click()}
- title={t('input.attachFile')}
- >
-
-
-
-
setInput(e.target.value)}
- onKeyDown={handleKeyDown}
- onPaste={handlePaste}
- placeholder={t('input.placeholder')}
- rows={1}
- disabled={isStreaming}
- />
- {isStreaming ? (
-
-
-
+ {/* Thread */}
+
+
+ {isEmpty &&
}
+
+ {rows.map((row) => (row.kind === 'activity' ? (
+
) : (
-
-
-
+
+ )))}
+
+ {isStreaming && (
+
+ ) : (
+
+ )}
+ />
)}
+
+ {scrolledUp && (
+
{
+ stickToBottomRef.current = true
+ setScrolledUp(false)
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
+ }}
+ >
+ {t('actions.jumpToLatest')}
+
+ )}
+
+
+ {/* Dock: the Home command bar under the thread */}
+
+
+
0 || files.length > 0)}
+ sendTitle={activeChat.model ? t('input.send') : t('input.selectModelFirst')}
+ textareaRef={textareaRef}
+ picker={picker}
+ mcp={mcp}
+ chips={canvasChip}
+ files={files}
+ onRemoveFile={(f) => setFiles(prev => prev.filter(x => x !== f))}
+ onAttach={attachFiles}
+ placeholder={activeChat.model ? t('input.placeholderModel', { model: activeChat.model }) : (noModel ? t('input.placeholderNoModel') : t('input.placeholderPick'))}
+ slash={slashConfig}
+ onRunAction={runSlash}
+ streaming={isStreaming}
+ onStop={stopGeneration}
+ stopTitle={t('input.stopGenerating')}
+ onPaste={handlePaste}
+ onKeyDownExtra={onComposerKey}
+ fileAccept="video/*,application/pdf,.txt,.md,.csv,.json"
+ rows={1}
+ strictEnter
+ testId="chat-composer"
+ textareaTestId="chat-input"
+ sendId="chat-submit-btn"
+ sendTestId="chat-send"
+ />
+
+ 90) || undefined}>
+ {isStreaming
+ ? (tokensPerSecond !== null ? `${t('tokens.perSec', { count: tokensPerSecond })} · ${t('tokens.generating')}` : t('tokens.generating'))
+ : (contextPercent !== null && contextPercent > 90
+ ? t('context.nearlyFull')
+ : (maxTokensPerSecond !== null ? t('tokens.peak', { count: maxTokensPerSecond }) : ''))}
+
+
+ {activeChat.tokenUsage?.total > 0 && (activeChat.contextSize
+ ? t('tokens.ofContext', { used: activeChat.tokenUsage.total, size: activeChat.contextSize })
+ : t('tokens.usage', { prompt: activeChat.tokenUsage.prompt, completion: activeChat.tokenUsage.completion, total: activeChat.tokenUsage.total }))}
+
+
+
+
+ {isEmpty && (
+
+ setFitOpenCount(n => n)}
+ manage={!!activeChat.localaiAssistant}
+ model={activeChat.model}
+ warm={modelWarm}
+ starters={t(activeChat.localaiAssistant ? 'empty.suggestionsManage' : 'empty.suggestionsChat', { returnObjects: true })}
+ onStarter={(prompt) => { setInput(prompt); textareaRef.current?.focus() }}
+ conversations={conversations}
+ leavingId={null}
+ onResume={(conv) => switchChat(conv.id)}
+ onDelete={(conv) => { const chat = chats.find(c => c.id === conv.id); if (chat) requestDelete(chat) }}
+ />
+
+ )}
{canvasOpen && artifacts.length > 0 && (
setCanvasOpen(false)}
/>
)}
+ {lightbox && (
+ setLightbox(prev => ({ ...prev, index }))}
+ onClose={() => setLightbox(null)}
+ />
+ )}
+ {showSettings && (
+ updateChatSettings(activeChat.id, patch)}
+ focusMode={focusModeEnabled}
+ onFocusMode={toggleFocusMode}
+ modelInfo={modelInfo}
+ onEditConfig={() => navigate(`/app/model-editor/${encodeURIComponent(activeChat.model)}`, { state: fromState(location, 'Chat') })}
+ onClear={() => { setShowSettings(false); promptClear() }}
+ onClose={() => setShowSettings(false)}
+ />
+ )}
+ {showShortcuts && setShowShortcuts(false)} canFind />}
+ {pendingDelete && (
+
+ )}
{
- try {
- const data = await agentCollectionsApi.entries(name, userId)
- setEntries(Array.isArray(data.entries) ? data.entries : [])
- } catch (err) {
- addToast(`Failed to load entries: ${err.message}`, 'error')
- }
- }, [name, addToast, userId])
-
- const fetchSources = useCallback(async () => {
- try {
- const data = await agentCollectionsApi.sources(name, userId)
- setSources(Array.isArray(data.sources) ? data.sources : [])
- } catch (err) {
- addToast(`Failed to load sources: ${err.message}`, 'error')
- }
- }, [name, addToast, userId])
-
- useEffect(() => {
- const load = async () => {
- setLoading(true)
- await Promise.allSettled([fetchEntries(), fetchSources()])
- setLoading(false)
- }
- load()
- }, [fetchEntries, fetchSources])
-
- const handleViewContent = async (entry) => {
- setViewEntry(entry)
- setViewContent(null)
- setViewLoading(true)
- try {
- const data = await agentCollectionsApi.entryContent(name, entry, userId)
- setViewContent(data)
- } catch (err) {
- addToast(`Failed to load entry content: ${err.message}`, 'error')
- setViewEntry(null)
- } finally {
- setViewLoading(false)
- }
- }
-
- const handleDeleteEntry = async (entry) => {
- setConfirmDialog({
- title: 'Delete Entry',
- message: 'Are you sure you want to delete this entry?',
- confirmLabel: 'Delete',
- danger: true,
- onConfirm: async () => {
- setConfirmDialog(null)
- try {
- await agentCollectionsApi.deleteEntry(name, entry, userId)
- addToast('Entry deleted', 'success')
- fetchEntries()
- } catch (err) {
- addToast(`Failed to delete entry: ${err.message}`, 'error')
- }
- },
- })
- }
-
- const handleUpload = async (e) => {
- e.preventDefault()
- if (!uploadFile) return
- setUploading(true)
- try {
- const formData = new FormData()
- formData.append('file', uploadFile)
- await agentCollectionsApi.upload(name, formData, userId)
- addToast('File uploaded successfully', 'success')
- setUploadFile(null)
- fetchEntries()
- } catch (err) {
- addToast(`Upload failed: ${err.message}`, 'error')
- } finally {
- setUploading(false)
- }
- }
-
- const handleSearch = async (e) => {
- e.preventDefault()
- if (!searchQuery.trim()) return
- setSearching(true)
- try {
- const data = await agentCollectionsApi.search(name, searchQuery, searchMaxResults, userId)
- setSearchResults(Array.isArray(data.results) ? data.results : [])
- } catch (err) {
- addToast(`Search failed: ${err.message}`, 'error')
- } finally {
- setSearching(false)
- }
- }
-
- const handleAddSource = async (e) => {
- e.preventDefault()
- if (!newSourceUrl.trim()) return
- setAddingSource(true)
- try {
- await agentCollectionsApi.addSource(name, newSourceUrl, newSourceInterval || undefined, userId)
- addToast('Source added', 'success')
- setNewSourceUrl('')
- setNewSourceInterval('')
- fetchSources()
- } catch (err) {
- addToast(`Failed to add source: ${err.message}`, 'error')
- } finally {
- setAddingSource(false)
- }
- }
-
- const handleRemoveSource = async (url) => {
- setConfirmDialog({
- title: 'Remove Source',
- message: 'Are you sure you want to remove this source?',
- confirmLabel: 'Remove',
- danger: true,
- onConfirm: async () => {
- setConfirmDialog(null)
- try {
- await agentCollectionsApi.removeSource(name, url, userId)
- addToast('Source removed', 'success')
- fetchSources()
- } catch (err) {
- addToast(`Failed to remove source: ${err.message}`, 'error')
- }
- },
- })
- }
-
- return (
-
-
-
-
-
-
- setActiveTab('entries')}>
- Entries
-
- setActiveTab('search')}>
- Search
-
- setActiveTab('sources')}>
- Sources
-
-
-
- {loading ? (
-
-
-
- ) : activeTab === 'entries' ? (
- <>
-
- setUploadFile(e.target.files[0] || null)}
- style={{ flex: 1, minWidth: 200 }}
- />
-
- {uploading ? <> Uploading...> : <> Upload>}
-
-
-
- {entries.length === 0 ? (
-
-
-
No entries in this collection. Upload a file to get started.
-
- ) : (
-
-
-
-
- Entry
- Actions
-
-
-
- {entries.map((entry, index) => (
-
-
-
- {typeof entry === 'string' ? entry : JSON.stringify(entry)}
-
-
-
-
- handleViewContent(entry)} title="View Content">
-
-
- handleDeleteEntry(entry)} title="Delete">
-
-
-
-
-
- ))}
-
-
-
- )}
- >
- ) : activeTab === 'search' ? (
- <>
-
-
- Query
- setSearchQuery(e.target.value)}
- placeholder="Enter search query..."
- />
-
-
- Max Results
- setSearchMaxResults(parseInt(e.target.value, 10) || 10)}
- />
-
-
- {searching ? <> Searching...> : <> Search>}
-
-
-
- {searchResults.length === 0 ? (
-
-
-
No results. Enter a query and click Search.
-
- ) : (
- searchResults.map((result, index) => (
-
-
- Similarity: {typeof result.similarity === 'number' ? result.similarity.toFixed(4) : (result.score != null ? Number(result.score).toFixed(4) : 'N/A')}
-
-
- {result.content || result.text || (typeof result === 'string' ? result : JSON.stringify(result))}
-
-
- ))
- )}
- >
- ) : (
- <>
-
-
- URL
- setNewSourceUrl(e.target.value)}
- placeholder="https://example.com/data"
- />
-
-
- Update Interval
- setNewSourceInterval(e.target.value)}
- placeholder="e.g. 60 (minutes)"
- />
-
-
- {addingSource ? <> Adding...> : <> Add Source>}
-
-
-
- {sources.length === 0 ? (
-
-
-
No external sources configured. Add a URL to start ingesting data.
-
- ) : (
-
-
-
-
- URL
- Interval
- Actions
-
-
-
- {sources.map((source, index) => (
-
-
- {typeof source === 'string' ? source : (source.url || JSON.stringify(source))}
-
-
- {(typeof source === 'object' && source.update_interval) ? source.update_interval : '-'}
-
-
-
- handleRemoveSource(typeof source === 'string' ? source : source.url)}
- title="Remove"
- >
-
-
-
-
-
- ))}
-
-
-
- )}
- >
- )}
-
-
setConfirmDialog(null)}
- />
-
- {/* Entry content modal */}
- {viewEntry && (
- setViewEntry(null)}>
-
e.stopPropagation()}>
-
-
{viewEntry}
- setViewEntry(null)}>
-
-
-
-
- {viewLoading ? (
-
-
-
- ) : viewContent ? (
- <>
-
-
-
- Chunks: {viewContent.chunk_count ?? '-'}
-
-
-
- {viewContent.content || '(empty)'}
-
- >
- ) : (
-
No content available.
- )}
-
-
-
- )}
-
- )
-}
diff --git a/core/http/react-ui/src/pages/Collections.jsx b/core/http/react-ui/src/pages/Collections.jsx
index 94eb005af..1753a9caf 100644
--- a/core/http/react-ui/src/pages/Collections.jsx
+++ b/core/http/react-ui/src/pages/Collections.jsx
@@ -1,220 +1,375 @@
-import { useState, useEffect, useCallback } from 'react'
-import { useNavigate, useOutletContext } from 'react-router-dom'
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
+import { useNavigate, useOutletContext, useParams, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { agentCollectionsApi } from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { useUserMap } from '../hooks/useUserMap'
+import { useLibraryFacts } from '../hooks/useLibraryFacts'
+import { useWideLayout } from '../hooks/useWideLayout'
+// eslint-disable-next-line no-unused-vars
import UserGroupSection from '../components/UserGroupSection'
+// eslint-disable-next-line no-unused-vars
import PageHeader from '../components/PageHeader'
+// eslint-disable-next-line no-unused-vars
import ConfirmDialog from '../components/ConfirmDialog'
+// eslint-disable-next-line no-unused-vars
+import HomeUndoToast from '../components/home/HomeUndoToast'
+import Icon from '../components/Icon'
+// eslint-disable-next-line no-unused-vars
+import { AddToMenu, PassageList, UsedByStrip } from '../components/library/LibraryBits'
+// eslint-disable-next-line no-unused-vars
+import SimulateSheet from '../components/library/SimulateSheet'
+import { usedByLine } from '../utils/libraryText'
+import { collectionUsers, entryName, normaliseSource, withKb, withoutKb } from '../utils/library'
+import { skillsApi } from '../utils/api'
+import './library.css'
+
+const DEFAULT_PASSAGES = 10
+
+const nameOf = c => (typeof c === 'string' ? c : c?.name)
+
+function when(ts) {
+ try { return new Date(ts).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) } catch { return '' }
+}
export default function Collections() {
const { addToast } = useOutletContext()
const navigate = useNavigate()
- const { t } = useTranslation('collections')
+ const { name: routeName } = useParams()
+ const [params] = useSearchParams()
+ const { t } = useTranslation(['collections', 'library'])
const { isAdmin, authEnabled, user } = useAuth()
const userMap = useUserMap()
+ const facts = useLibraryFacts()
+ const wide = useWideLayout()
+
const [collections, setCollections] = useState([])
const [loading, setLoading] = useState(true)
+ const [filter, setFilter] = useState('')
+ const [usage, setUsage] = useState('all')
+ const [showCreate, setShowCreate] = useState(false)
const [newName, setNewName] = useState('')
const [creating, setCreating] = useState(false)
const [userGroups, setUserGroups] = useState(null)
const [confirmDialog, setConfirmDialog] = useState(null)
+ const [sim, setSim] = useState(null)
+ const [skills, setSkills] = useState([])
+ const [undo, setUndo] = useState(null)
+ const [busy, setBusy] = useState(false)
+
+ const selectedUser = params.get('user_id') || undefined
+
+ const go = useCallback((name, userId) => {
+ if (!name) { navigate('/app/collections'); return }
+ navigate(`/app/collections/${encodeURIComponent(name)}${userId ? `?user_id=${encodeURIComponent(userId)}` : ''}`)
+ }, [navigate])
const fetchCollections = useCallback(async () => {
try {
const data = await agentCollectionsApi.list(isAdmin && authEnabled)
- setCollections(Array.isArray(data.collections) ? data.collections : [])
+ setCollections((Array.isArray(data.collections) ? data.collections : []).map(nameOf).filter(Boolean))
setUserGroups(data.user_groups || null)
} catch (err) {
- addToast(t('toasts.loadFailed', { message: err.message }), 'error')
+ addToast(t('collections:toasts.loadFailed', { message: err.message }), 'error')
} finally {
setLoading(false)
}
}, [addToast, isAdmin, authEnabled, t])
- useEffect(() => {
- fetchCollections()
- }, [fetchCollections])
+ useEffect(() => { fetchCollections() }, [fetchCollections])
- const handleCreate = async () => {
+ // Skills only feed the Simulate sheet.
+ useEffect(() => {
+ skillsApi.list(false)
+ .then(data => setSkills(Array.isArray(data) ? data : (Array.isArray(data?.skills) ? data.skills : [])))
+ .catch(() => setSkills([]))
+ }, [])
+
+ const known = facts.status === 'ready'
+ const usersOf = useMemo(() => {
+ const map = new Map()
+ for (const c of collections) map.set(c, collectionUsers(facts.agents, c))
+ return map
+ }, [collections, facts.agents])
+
+ const visible = useMemo(() => {
+ const q = filter.trim().toLowerCase()
+ return collections.filter(c => {
+ if (q && !c.toLowerCase().includes(q)) return false
+ if (known && usage === 'used' && usersOf.get(c).length === 0) return false
+ if (known && usage === 'unused' && usersOf.get(c).length > 0) return false
+ return true
+ })
+ }, [collections, filter, usage, known, usersOf])
+
+ const handleCreate = async (e) => {
+ e?.preventDefault?.()
const name = newName.trim()
if (!name) return
setCreating(true)
try {
await agentCollectionsApi.create(name)
- addToast(t('toasts.created', { name }), 'success')
+ addToast(t('collections:toasts.created', { name }), 'success')
setNewName('')
- fetchCollections()
+ setShowCreate(false)
+ await fetchCollections()
+ go(name)
} catch (err) {
- addToast(t('toasts.createFailed', { message: err.message }), 'error')
+ addToast(t('collections:toasts.createFailed', { message: err.message }), 'error')
} finally {
setCreating(false)
}
}
- const handleDelete = (name, userId) => {
+ const handleReset = (name, userId, after) => {
setConfirmDialog({
- title: t('deleteDialog.title'),
- message: t('deleteDialog.message', { name }),
- confirmLabel: t('deleteDialog.confirm'),
+ title: t('collections:resetDialog.title'),
+ message: t('collections:resetDialog.message', { name }),
+ confirmLabel: t('collections:resetDialog.confirm'),
danger: true,
onConfirm: async () => {
setConfirmDialog(null)
try {
await agentCollectionsApi.reset(name, userId)
- addToast(t('toasts.deleted', { name }), 'success')
+ addToast(t('collections:toasts.reset', { name }), 'success')
+ after?.()
fetchCollections()
} catch (err) {
- addToast(t('toasts.deleteFailed', { message: err.message }), 'error')
+ addToast(t('collections:toasts.resetFailed', { message: err.message }), 'error')
}
},
})
}
- const handleReset = (name, userId) => {
- setConfirmDialog({
- title: t('resetDialog.title'),
- message: t('resetDialog.message', { name }),
- confirmLabel: t('resetDialog.confirm'),
- danger: true,
- onConfirm: async () => {
- setConfirmDialog(null)
- try {
- await agentCollectionsApi.reset(name, userId)
- addToast(t('toasts.reset', { name }), 'success')
- fetchCollections()
- } catch (err) {
- addToast(t('toasts.resetFailed', { message: err.message }), 'error')
- }
+ // ---- Attach and detach -------------------------------------------------
+ const saveFor = async (agent, next) => {
+ setBusy(true)
+ try {
+ await facts.saveConfig(agent, next)
+ return true
+ } catch (err) {
+ addToast(t('library:toasts.updateFailed', { agent, message: err.message }), 'error')
+ return false
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const addToAgent = async (collection, agent) => {
+ const config = facts.agents.find(a => a.name === agent)?.config
+ if (!config) { addToast(t('library:toasts.updateFailed', { agent, message: t('library:usedBy.unread') }), 'error'); return }
+ const next = withKb(config)
+ if (!next) return
+ if (await saveFor(agent, next)) addToast(t('library:toasts.collectionAdded', { name: collection, agent }), 'success')
+ }
+
+ const removeFromAgent = async (collection, agent) => {
+ const config = facts.agents.find(a => a.name === agent)?.config
+ const next = config && withoutKb(config)
+ if (!next) return
+ if (!(await saveFor(agent, next))) return
+ setUndo({
+ id: Date.now(),
+ message: t('library:toasts.collectionRemoved', { name: collection, agent }),
+ restore: async () => {
+ if (await saveFor(agent, config)) addToast(t('library:toasts.undone'), 'success')
},
})
}
+ // A link can name a collection the list has not shown yet; the pane reads it
+ // from the server and says so if it is not there.
+ // Side by side, the first collection is open until another is picked. On a
+ // phone nothing opens until the person taps one.
+ const selectedName = routeName || (wide && !selectedUser ? (visible[0] || '') : '')
+ const selected = !selectedUser && selectedName ? selectedName : ''
+ const selectedOther = selectedUser && (userGroups?.[selectedUser]?.collections || []).map(nameOf).includes(selectedName) ? selectedName : ''
+ const open = !!(selected || selectedOther)
+ const empty = !loading && collections.length === 0 && !userGroups && !open
+
return (
-
-
-
-
-
-
- setNewName(e.target.value)}
- onKeyDown={(e) => { if (e.key === 'Enter') handleCreate() }}
- />
-
- {creating ? <> {t('actions.creating')}> : <> {t('actions.create')}>}
-
-
-
- {loading ? (
-
-
-
- ) : collections.length === 0 && !userGroups ? (
-
-
-
{t('empty.title')}
-
- {t('empty.text')}
-
-
- ) : (
- <>
- {userGroups &&
{t('sections.yourCollections')} }
- {collections.length === 0 ? (
-
{t('empty.noPersonal')}
- ) : (
-
- {collections.map((collection) => {
- const name = typeof collection === 'string' ? collection : collection.name
- return (
-
navigate(`/app/collections/${encodeURIComponent(name)}`)}>
-
-
- {name}
-
-
e.stopPropagation()}>
- navigate(`/app/collections/${encodeURIComponent(name)}`)} title={t('actions.viewDetails')}>
- {t('actions.details')}
-
- handleReset(name)} title={t('actions.resetCollection')}>
- {t('actions.reset')}
-
- handleDelete(name)} title={t('actions.deleteCollection')}>
-
-
-
-
- )
- })}
-
+
+
+ setSim({ seed: null })} data-testid="open-simulate">
+ {t('library:simulate.button')}
+
+ setShowCreate(v => !v)} data-testid="new-collection">
+ {t('collections:actions.newCollection')}
+
+
)}
- >
+ />
+
+ {showCreate && (
+
+ setNewName(e.target.value)}
+ autoFocus
+ />
+
+ {creating ? <> {t('collections:actions.creating')}> : <> {t('collections:actions.create')}>}
+
+
)}
- {userGroups && (
-
(
-
- {(items || []).map((col) => {
- const name = typeof col === 'string' ? col : col.name
- return (
-
-
-
- {name}
-
-
- navigate(`/app/collections/${encodeURIComponent(name)}?user_id=${encodeURIComponent(userId)}`)} title={t('actions.viewDetails')}>
- {t('actions.details')}
-
- handleReset(name, userId)} title={t('actions.resetCollection')}>
- {t('actions.reset')}
-
- handleDelete(name, userId)} title={t('actions.deleteCollection')}>
-
-
-
-
- )
- })}
+ {loading ? (
+
+ ) : empty ? (
+
+
+
{t('collections:empty.title')}
+
{t('collections:empty.text')}
+
+ 1 {t('collections:empty.stepCreate')}
+ 2 {t('collections:empty.stepFill')}
+ 3 {t('collections:empty.stepAsk')}
+
+
+ setShowCreate(true)}>
+ {t('collections:actions.newCollection')}
+
+
+
+ ) : (
+
+
+
+
+ setFilter(e.target.value)}
+ data-testid="collections-filter"
+ />
+
+
+ {['all', 'used', 'unused'].map(f => (
+ setUsage(f)}
+ data-testid={`collections-filter-${f}`}
+ >
+ {t(`collections:filter.${f}`)}
+
+ ))}
- )}
+
+ {collections.length === 0 ? (
+
{t('collections:empty.noPersonal')}
+ ) : visible.length === 0 ? (
+
{t('collections:filter.none')}
+ ) : (
+
+ {visible.map(c => (
+ go(c)}
+ data-testid={`collection-row-${c}`}
+ >
+
+
+ {c}
+ {usedByLine(t, usersOf.get(c), facts.status)}
+
+
+ ))}
+
+ )}
+
+ {userGroups && (
+
(
+
+ {(items || []).map(nameOf).map(c => (
+ go(c, userId)}
+ >
+
+ {c}
+
+ ))}
+
+ )}
+ />
+ )}
+
+
+
+ {open ? (
+ a.config)}
+ busy={busy}
+ addToast={addToast}
+ onBack={() => go('')}
+ onReset={() => handleReset(selectedName, selectedUser)}
+ onAdd={(agent) => addToAgent(selectedName, agent)}
+ onRemove={(agent) => removeFromAgent(selectedName, agent)}
+ onTry={() => setSim({ seed: { kind: 'collection', name: selectedName } })}
+ setConfirmDialog={setConfirmDialog}
+ />
+ ) : (
+
+
{t('collections:pane.pick')}
+
+ )}
+
+
+ )}
+
+
setSim(null)}
+ agents={facts.agents.filter(a => a.config)}
+ agentsStatus={facts.status}
+ skills={skills}
+ collections={collections}
+ seed={sim?.seed || null}
+ />
+
+ {undo && (
+ { const { restore } = undo; setUndo(null); restore() }}
+ onExpire={() => setUndo(null)}
/>
)}
@@ -230,3 +385,419 @@ export default function Collections() {
)
}
+
+const PATHS = [
+ ['POST', '/api/agents/collections/{name}/upload', 'upload'],
+ ['GET', '/api/agents/collections/{name}/entries', 'entries'],
+ ['POST', '/api/agents/collections/{name}/search', 'search'],
+ ['GET', '/api/agents/collections/{name}/sources', 'sources'],
+ ['POST', '/api/agents/collections/{name}/reset', 'reset'],
+]
+
+// eslint-disable-next-line no-unused-vars
+function CollectionPane({ t, name, userId, users, status, agents, busy, addToast, onBack, onReset, onAdd, onRemove, onTry, setConfirmDialog }) {
+ const own = !userId
+ const [entries, setEntries] = useState([])
+ const [sources, setSources] = useState([])
+ const [loaded, setLoaded] = useState(false)
+ const [loadFailed, setLoadFailed] = useState(false)
+ const [addOpen, setAddOpen] = useState(false)
+ const [uploadFile, setUploadFile] = useState(null)
+ const [attempts, setAttempts] = useState([])
+ const [newSourceUrl, setNewSourceUrl] = useState('')
+ const [newSourceInterval, setNewSourceInterval] = useState('')
+ const [addingSource, setAddingSource] = useState(false)
+ const [query, setQuery] = useState('')
+ const [maxResults, setMaxResults] = useState(DEFAULT_PASSAGES)
+ const [searching, setSearching] = useState(false)
+ const [answer, setAnswer] = useState(null)
+ const [view, setView] = useState(null)
+ const nextId = useRef(0)
+ const fileInput = useRef(null)
+ const unused = own && status === 'ready' && users.length === 0
+
+ const fetchEntries = useCallback(async () => {
+ try {
+ const data = await agentCollectionsApi.entries(name, userId)
+ setEntries(Array.isArray(data.entries) ? data.entries : [])
+ return true
+ } catch (err) {
+ addToast(t('collections:toasts.entriesFailed', { message: err.message }), 'error')
+ return false
+ }
+ }, [name, userId, addToast, t])
+
+ const fetchSources = useCallback(async () => {
+ try {
+ const data = await agentCollectionsApi.sources(name, userId)
+ setSources((Array.isArray(data.sources) ? data.sources : []).map(normaliseSource))
+ return true
+ } catch (err) {
+ addToast(t('collections:toasts.sourcesFailed', { message: err.message }), 'error')
+ return false
+ }
+ }, [name, userId, addToast, t])
+
+ useEffect(() => {
+ let live = true
+ Promise.all([fetchEntries(), fetchSources()]).then(results => {
+ if (!live) return
+ setLoadFailed(results.some(ok => !ok))
+ setLoaded(true)
+ })
+ return () => { live = false }
+ }, [fetchEntries, fetchSources])
+
+ const upload = async (file, existingId) => {
+ const id = existingId ?? ++nextId.current
+ setAttempts(prev => [...prev.filter(a => a.id !== id), { id, name: file.name, file, phase: 'uploading' }])
+ try {
+ const formData = new FormData()
+ formData.append('file', file)
+ await agentCollectionsApi.upload(name, formData, userId)
+ setAttempts(prev => prev.filter(a => a.id !== id))
+ addToast(t('collections:toasts.uploaded', { file: file.name }), 'success')
+ fetchEntries()
+ } catch (err) {
+ setAttempts(prev => prev.map(a => (a.id === id ? { ...a, phase: 'failed', message: err.message } : a)))
+ }
+ }
+
+ const handleUpload = (e) => {
+ e.preventDefault()
+ if (!uploadFile) return
+ const file = uploadFile
+ setUploadFile(null)
+ if (fileInput.current) fileInput.current.value = ''
+ upload(file)
+ }
+
+ const handleSearch = async (e) => {
+ e.preventDefault()
+ const q = query.trim()
+ if (!q) return
+ setSearching(true)
+ try {
+ const data = await agentCollectionsApi.search(name, q, maxResults, userId)
+ setAnswer({ query: q, results: Array.isArray(data.results) ? data.results : [], error: null })
+ } catch (err) {
+ setAnswer({ query: q, results: [], error: err.message })
+ addToast(t('library:search.failed', { message: err.message }), 'error')
+ } finally {
+ setSearching(false)
+ }
+ }
+
+ const handleAddSource = async (e) => {
+ e.preventDefault()
+ if (!newSourceUrl.trim()) return
+ setAddingSource(true)
+ try {
+ await agentCollectionsApi.addSource(name, newSourceUrl, newSourceInterval || undefined, userId)
+ addToast(t('collections:toasts.sourceAdded'), 'success')
+ setNewSourceUrl('')
+ setNewSourceInterval('')
+ fetchSources()
+ } catch (err) {
+ addToast(t('collections:toasts.sourceFailed', { message: err.message }), 'error')
+ } finally {
+ setAddingSource(false)
+ }
+ }
+
+ const removeSource = (url) => {
+ setConfirmDialog({
+ title: t('collections:removeSource.title'),
+ message: t('collections:removeSource.message'),
+ confirmLabel: t('collections:removeSource.confirm'),
+ danger: true,
+ onConfirm: async () => {
+ setConfirmDialog(null)
+ try {
+ await agentCollectionsApi.removeSource(name, url, userId)
+ addToast(t('collections:toasts.sourceRemoved'), 'success')
+ fetchSources()
+ } catch (err) {
+ addToast(t('collections:toasts.sourceRemoveFailed', { message: err.message }), 'error')
+ }
+ },
+ })
+ }
+
+ const openEntry = async (entry) => {
+ setView({ entry, loading: true, data: null })
+ try {
+ const data = await agentCollectionsApi.entryContent(name, entry, userId)
+ setView({ entry, loading: false, data })
+ } catch (err) {
+ addToast(t('collections:toasts.contentFailed', { message: err.message }), 'error')
+ setView(null)
+ }
+ }
+
+ const deleteEntry = (entry) => {
+ setConfirmDialog({
+ title: t('collections:deleteEntry.title'),
+ message: t('collections:deleteEntry.message'),
+ confirmLabel: t('collections:deleteEntry.confirm'),
+ danger: true,
+ onConfirm: async () => {
+ setConfirmDialog(null)
+ try {
+ await agentCollectionsApi.deleteEntry(name, entry, userId)
+ addToast(t('collections:toasts.entryDeleted'), 'success')
+ fetchEntries()
+ } catch (err) {
+ addToast(t('collections:toasts.entryDeleteFailed', { message: err.message }), 'error')
+ }
+ },
+ })
+ }
+
+ useEffect(() => {
+ if (!view) return undefined
+ const onKey = e => { if (e.key === 'Escape') setView(null) }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [view])
+
+ const failed = attempts.filter(a => a.phase === 'failed').length
+ const sub = loaded
+ ? t('collections:pane.counts', { files: entries.length, sources: sources.length })
+ : ''
+
+ return (
+ <>
+
+ {t('collections:pane.back')}
+
+
+
+
+
+ {failed > 0 && {t('collections:pane.failed', { count: failed })} }
+ setAddOpen(v => !v)} data-testid="add-source-toggle">
+ {t('collections:pane.addSource')}
+
+
+ {t('collections:actions.reset')}
+
+
+
+
+ {own && (
+ <>
+
+
+
+ {unused && (
+
+
{t('library:notUsed.collection')}
+
+
+ {t('library:tryInContext')}
+
+
+
+ )}
+ >
+ )}
+
+ {addOpen && (
+
+ )}
+
+ {attempts.length > 0 && (
+
+ {t('collections:entries.adding')}
+
+ {attempts.map(a => (
+
+
+
+ {a.name}
+ {a.phase === 'failed' ? t('collections:entries.uploadFailed') : t('collections:entries.indexing')}
+
+
+ {a.phase === 'failed' && (
+ <>
+ upload(a.file, a.id)}>
+ {t('collections:entries.retry')}
+
+ setAttempts(prev => prev.filter(x => x.id !== a.id))}>
+ {t('collections:entries.dismiss')}
+
+ >
+ )}
+
+ {a.phase === 'failed' && {a.message}
}
+ {a.phase === 'uploading' && (
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+ {t('collections:ask.title')}
+
+
+
+ setQuery(e.target.value)}
+ placeholder={t('collections:ask.placeholder')}
+ aria-label={t('collections:ask.label')}
+ />
+
+
+ {searching ? <> {t('collections:ask.searching')}> : t('collections:ask.search')}
+
+
+ {t('collections:ask.alone')}
+
+ {t('collections:ask.tune')}
+
+ {t('collections:ask.max')}
+ setMaxResults(parseInt(e.target.value, 10) || DEFAULT_PASSAGES)} />
+
+
+
+ {answer && (
+ answer.error ? (
+
{t('library:search.failed', { message: answer.error })}
+ ) : answer.results.length === 0 ? (
+
{t('collections:ask.none')}
+ ) : (
+
+
{t('collections:ask.ranked', { count: answer.results.length })}
+
+
+ )
+ )}
+
+
+
+
+ {t('collections:sources.title')} {sources.length}
+ {!loaded ? (
+ {t('collections:loading')}
+ ) : sources.length === 0 ? (
+ {t('collections:sources.none')}
+ ) : (
+
+ {sources.map(s => (
+
+
+
+ {s.url}
+
+ {s.interval > 0 ? t('collections:sources.every', { count: s.interval }) : t('collections:sources.manual')}
+ {' · '}
+ {s.lastUpdate ? t('collections:sources.updated', { when: when(s.lastUpdate) }) : t('collections:sources.never')}
+
+
+
+ removeSource(s.url)} aria-label={t('collections:sources.remove', { url: s.url })} title={t('collections:sources.removeTitle')}>
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ {t('collections:entries.title')} {entries.length}
+ {loadFailed && {t('collections:entries.failed')}
}
+ {loaded && entries.length === 0 && attempts.length === 0 ? (
+ {t('collections:entries.none')}
+ ) : entries.length > 0 && (
+
+ {entries.map((entry, i) => {
+ const label = entryName(entry)
+ return (
+
+
+ {label}
+
+ openEntry(entry)} aria-label={t('collections:entries.view', { name: label })} title={t('collections:entries.viewTitle')}>
+
+
+ deleteEntry(entry)} aria-label={t('collections:entries.delete', { name: label })} title={t('collections:entries.deleteTitle')}>
+
+
+
+
+ )
+ })}
+
+ )}
+
+
+
+ {t('collections:api.title')}
+
+
{t('collections:api.index')}
+
+ {PATHS.map(([verb, path, key]) => (
+ {verb} {path.replace('{name}', encodeURIComponent(name))}
+ ))}
+
+
{t('collections:api.privacy')}
+
+
+
+ {view && (
+ setView(null)}>
+
e.stopPropagation()} data-testid="entry-dialog">
+
+
+
{entryName(view.entry)}
+ {view.data &&
{t('collections:entries.chunks', { count: view.data.chunk_count ?? 0 })}
}
+
+
setView(null)}>
+
+
+ {view.loading ?
+ : view.data ?
{view.data.content || t('collections:entries.empty')}
+ :
{t('collections:entries.noContent')}
}
+
+
+
+ )}
+ >
+ )
+}
diff --git a/core/http/react-ui/src/pages/Diarization.jsx b/core/http/react-ui/src/pages/Diarization.jsx
index a2efd9ea7..66f86b884 100644
--- a/core/http/react-ui/src/pages/Diarization.jsx
+++ b/core/http/react-ui/src/pages/Diarization.jsx
@@ -1,15 +1,43 @@
// SPDX-License-Identifier: MIT
-import { useEffect, useRef, useState } from 'react'
+import { useEffect, useMemo, useRef, useState } from 'react'
+// eslint-disable-next-line no-unused-vars
import { Link, useParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
-import PageHeader from '../components/PageHeader'
-import ModelSelector from '../components/ModelSelector'
+// eslint-disable-next-line no-unused-vars
import Modal from '../components/Modal'
import { useAuth } from '../context/AuthContext'
import useObjectUrl from '../hooks/useObjectUrl'
import { CAP_DIARIZATION } from '../utils/capabilities'
import { diarizationApi, voiceApi } from '../utils/api'
import { rememberEnrollment } from '../utils/voiceEnrollments'
+import { clock, hasText, runLength, speakerRows, toRttm, toSrt } from '../utils/diarization'
+import { cssVars } from '../utils/modelLedger'
+import { useMediaHistory } from '../hooks/useMediaHistory'
+import { useStudioHandoff, useHandoffSource, blobToFile } from '../hooks/useStudioHandoff'
+import { useWorkspace } from '../hooks/useWorkspace'
+// eslint-disable-next-line no-unused-vars
+import HandoffNote from '../components/studio/HandoffNote'
+// eslint-disable-next-line no-unused-vars
+import Timeline from '../components/studio/Timeline'
+import {
+ // eslint-disable-next-line no-unused-vars
+ Workspace, ComposeCard, ModelChip, SourceDrop, RunArea, JobCard, FailedCard, ResultCard, ResultsStrip, EmptyRun,
+} from '../components/studio/Workspace'
+import Icon from '../components/Icon'
+
+// A text export built from the result in hand, handed to the browser as a file.
+// eslint-disable-next-line no-unused-vars
+function ExportButton({ label, build, name, type }) {
+ const save = () => {
+ const url = URL.createObjectURL(new Blob([build()], { type }))
+ const a = document.createElement('a')
+ a.href = url
+ a.download = name
+ a.click()
+ setTimeout(() => URL.revokeObjectURL(url), 1000)
+ }
+ return {label}
+}
export default function Diarization() {
const { t } = useTranslation('media')
@@ -17,7 +45,9 @@ export default function Diarization() {
const { model: initialModel } = useParams()
const { hasFeature } = useAuth()
const canRemember = hasFeature('voice_recognition')
- const [model, setModel] = useState(initialModel || '')
+ // Opened from the Studio front page, the audio it was made from is the input.
+ const handoff = useStudioHandoff()
+ const [model, setModel] = useState(initialModel || handoff.model || '')
const [file, setFile] = useState(null)
const [optIn, setOptIn] = useState(false)
const [result, setResult] = useState(null)
@@ -34,6 +64,16 @@ export default function Diarization() {
const timer = useRef(null)
const playback = useRef(0)
const url = useObjectUrl(file)
+ const { addEntry, selectEntry, selectedEntry, historyProps } = useMediaHistory('diarization')
+ const ws = useWorkspace({ type: 'diarization', entries: historyProps.entries })
+ const [lastId, setLastId] = useState(null)
+ const wantsSource = handoff.edge === 'diarize'
+ const source = useHandoffSource(handoff, wantsSource)
+ useEffect(() => {
+ if (source.status !== 'ready') return
+ const base = (source.item?.url || '').split('?')[0].split('/').pop() || 'audio.wav'
+ setFile(blobToFile(source.blob, base))
+ }, [source])
function stop() {
playback.current++
@@ -45,7 +85,7 @@ export default function Diarization() {
generation.current++
stop()
setResult(null); setSelected(null); setBusy(false); setSaving(false)
- setError(''); setSaveError(''); saveLock.current = false
+ setError(''); setSaveError(''); saveLock.current = false; setLastId(null)
}
useEffect(() => {
const player = audio.current
@@ -56,10 +96,10 @@ export default function Diarization() {
if (!canRemember) { setOptIn(false); setSelected(null) }
}, [canRemember])
- async function submit(event) {
- event.preventDefault()
+ async function run() {
if (!file || !model || busy) return
invalidate()
+ selectEntry(null)
const token = generation.current
const requested = canRemember && optIn
setBusy(true)
@@ -68,7 +108,13 @@ export default function Diarization() {
if (token !== generation.current) return
if (requested && !data.speaker_profiles) throw new Error(text('missingProfiles'))
// Keep the actual inference model with this export, not a later selection.
- setResult({ ...data, inferenceModel: model, speaker_profiles: requested ? data.speaker_profiles : undefined })
+ setResult({ ...data, inferenceModel: model, speaker_profiles: requested ? data.speaker_profiles : undefined, fileName: file.name })
+ // The front page lists this run. Only the file name, the model and two
+ // counts are kept: not the recording, not the transcript, no voice data.
+ const labels = new Set((data.segments || []).map(s => String(s.label ?? s.speaker)))
+ const speakers = data.speakers?.length || labels.size
+ const seconds = Math.round(Math.max(0, ...(data.segments || []).map(s => Number(s.end) || 0)))
+ setLastId(addEntry({ prompt: file.name, model, params: { speakers, seconds }, results: [], parentId: handoff.from || undefined, edge: handoff.edge || undefined }))
} catch (err) {
if (token === generation.current) setError(`${err.message}${requested ? ` ${text('unsupported')}` : ''}`)
} finally { if (token === generation.current) setBusy(false) }
@@ -111,52 +157,118 @@ export default function Diarization() {
}
// Raw labels are the only stable join key. Normalized IDs and array order can differ.
- const summaries = result?.speakers || Array.from(new Map((result?.segments || []).map(s => [String(s.label), { ...s, id: s.speaker }])).values())
+ const rows = useMemo(() => speakerRows(result), [result])
+ const length = runLength(result)
+ const item = ws.itemById(selectedEntry?.id || lastId)
+
+ const installed = ws.installed.byType.diarization
+ const noModel = !ws.installed.loading && !ws.installed.error && installed.length === 0
+ const why = noModel ? t('studio.composer.whyModel', { type: t('studio.tabs.diarization') })
+ : !file ? t('studio.workspace.diarization.whyFile') : ''
+ const showResult = !!result && !selectedEntry
+ const fileName = result?.fileName || file?.name || 'recording'
+
return (
-
-
-
-
- {text('model')}
- { if (value !== model) { invalidate(); setModel(value) } }} />
-
-
- {text('recording')}
- { invalidate(); setFile(e.target.files?.[0] || null) }} />
-
- {canRemember &&
-
{ invalidate(); setOptIn(e.target.checked) }} /> {text('optIn')}
-
{text('warning')} {text('manage')}
+
+ { e.preventDefault(); run() }}
+ handoff={ { invalidate(); setFile(null) }} />}
+ model={model}
+ noModel={noModel ? { type: 'diarization', label: t('studio.tabs.diarization'), onChanged: ws.installed.refetch } : null}
+ options={
+ { if (value !== model) { invalidate(); setModel(value) } }} />
+ }
+ submit={{ label: text('run'), busyLabel: text('running'), busy, disabled: !file || !model || noModel, why, icon: 'users' }}
+ >
+ { invalidate(); setFile(f) }} />
+ {canRemember &&
+
{ invalidate(); setOptIn(e.target.checked) }} /> {text('optIn')}
+
{text('warning')} {text('manage')}
}
- {text(busy ? 'running' : 'run')}
-
- {error && {error}
}
- {url && { if (end.current !== null && audio.current.currentTime >= end.current) stop() }} />}
- {result && <>
-
{text('speakers')}
- {canRemember && result.speaker_profiles && {text('stop')} }
-
-
- {summaries.map(summary => {
- const profile = result.speaker_profiles?.speakers.find(p => String(p.speaker) === String(summary.label))
- const knownName = summary.name || result.segments?.find(s => String(s.label) === String(summary.label) && s.name)?.name
- const usable = profile?.embedding?.length > 0 && !profile.unavailable_reason
- return
- {knownName || summary.id}
- {profile && canRemember && <>
- {text('duration', { seconds: profile.clean_duration })}
- {profile.intervals.map((interval, i) => preview(interval)}>{text('preview', { number: i + 1 })} )}
- {!usable && {text('insufficient')} {profile.unavailable_reason && ({profile.unavailable_reason}) }
}
- {!knownName && { stop(); setSelected(profile.speaker); setName(''); setSaveError('') }}>{text('nameAndRemember')} }
- >}
-
- })}
-
- {text('segments')}
-
- {result.segments?.map((segment, i) => {segment.name || segment.speaker} {segment.start}–{segment.end}s {segment.text}
)}
-
- >}
+
+
+
+ {busy ? (
+
+ ) : error ? (
+
+ ) : showResult ? (
+
+ toRttm(result, fileName)} />
+ {hasText(result) && toSrt(result)} />}
+ JSON.stringify(result, null, 2)} />
+ >}
+ onRerun={() => {}}
+ >
+
+ {length > 0 &&
}
+ {url &&
{ if (end.current !== null && audio.current.currentTime >= end.current) stop() }} />}
+
{text('speakers')}
+ {canRemember && result.speaker_profiles && {text('stop')} }
+
+
+ {rows.map(row => {
+ const profile = result.speaker_profiles?.speakers.find(p => String(p.speaker) === String(row.label))
+ const usable = profile?.embedding?.length > 0 && !profile.unavailable_reason
+ return
+ {row.name || row.id}
+ {t('studio.workspace.diarization.talk', { seconds: Math.round(row.seconds), percent: Math.round(row.share * 100) })}
+
+ {profile && canRemember && <>
+ {text('duration', { seconds: profile.clean_duration })}
+ {profile.intervals.map((interval, i) => preview(interval)}>{text('preview', { number: i + 1 })} )}
+ {!usable && {text('insufficient')} {profile.unavailable_reason && ({profile.unavailable_reason}) }
}
+ {!row.name && { stop(); setSelected(profile.speaker); setName(''); setSaveError('') }}>{text('nameAndRemember')} }
+ >}
+
+ })}
+
+ {text('segments')}
+
+ {result.segments?.map((segment, i) => {
+ const row = rows.find(r => r.label === String(segment.label ?? segment.speaker))
+ return
+ {clock(segment.start)}
+ {segment.name || segment.speaker}
+ {segment.start}–{segment.end}s
+ {segment.text && {segment.text}
}
+
+ })}
+
+
+
+ ) : selectedEntry ? (
+ {}}
+ >
+ {t('studio.workspace.diarization.notKept')}
+
+ ) : (
+
+ )}
+
+
+ { if (result) invalidate(); historyProps.onSelect(id) }}
+ onDelete={historyProps.onDelete}
+ onClear={historyProps.onClearAll}
+ />
{selected !== null && canRemember && { if (!saving) setSelected(null) }}>
{text('nameAndRemember')}
@@ -168,6 +280,6 @@ export default function Diarization() {
setSelected(null)}>{text('cancel')}
}
-
+
)
}
diff --git a/core/http/react-ui/src/pages/Explorer.jsx b/core/http/react-ui/src/pages/Explorer.jsx
index 3a79a4c68..cba0e679c 100644
--- a/core/http/react-ui/src/pages/Explorer.jsx
+++ b/core/http/react-ui/src/pages/Explorer.jsx
@@ -1,26 +1,302 @@
-import { useEffect, useRef } from 'react'
-import { useNavigate } from 'react-router-dom'
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { useCallback, useEffect, useRef, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+import { apiUrl } from '../utils/basePath'
+import { joinCommands, shortToken, sortNetworks, workerCount } from '../utils/tools'
+import { useBranding } from '../contexts/BrandingContext'
+import SideSheet from '../components/SideSheet'
+import ThemeToggle from '../components/ThemeToggle'
+import Icon from '../components/Icon'
+import LoadingSpinner from '../components/LoadingSpinner'
+import './explorer.css'
-export default function Explorer() {
- const navigate = useNavigate()
+const POLL_MS = 5000
+const DOCS = 'https://localai.io/features/distribute/'
+async function readError(response, fallback) {
+ try {
+ const data = await response.json()
+ if (typeof data?.error === 'string') return data.error
+ if (data?.error?.message) return data.error.message
+ } catch { /* not JSON */ }
+ return fallback
+}
+
+function CopyField({ label, text, testId }) {
+ const { t } = useTranslation('explorer')
+ const [copied, setCopied] = useState(false)
+ const timer = useRef(null)
+ useEffect(() => () => clearTimeout(timer.current), [])
+ const copy = () => {
+ navigator.clipboard?.writeText(text).then(() => {
+ setCopied(true)
+ clearTimeout(timer.current)
+ timer.current = setTimeout(() => setCopied(false), 1500)
+ }).catch(() => {})
+ }
return (
-
-
- LocalAI Explorer
-
-
- Network visualization and node explorer
-
-
-
-
-
Explorer visualization
-
+
+
+ {label}
+
+ {copied ? t('copied') : t('copy')}
+
-
navigate('/app')}>
- Back to Home
-
+
{text}
+
+ )
+}
+
+function JoinSheet({ network, onClose }) {
+ const { t } = useTranslation('explorer')
+ const federated = (network.Clusters || []).filter(c => c.Type === 'federated')
+ return (
+
{t('close')}}
+ >
+
+
+ {federated.length > 0 ? federated.map(cluster => {
+ const cmd = joinCommands(network.token, cluster.NetworkID)
+ return (
+
+
{t('join.federated', { id: cluster.NetworkID || '-' })}
+
+
+
+ )
+ }) : (
+
{t('join.noCommands')}
+ )}
+
{t('join.docs')}
+
{t('warning')}
+
+
+ )
+}
+
+function ListSheet({ onClose, onListed }) {
+ const { t } = useTranslation('explorer')
+ const [form, setForm] = useState({ name: '', description: '', token: '' })
+ const [understood, setUnderstood] = useState(false)
+ const [pending, setPending] = useState(false)
+ const [error, setError] = useState('')
+ const set = (key) => (e) => setForm(prev => ({ ...prev, [key]: e.target.value }))
+ const ready = form.name.trim() && form.description.trim() && form.token.trim() && understood
+
+ const submit = async (e) => {
+ e.preventDefault()
+ if (!ready) return
+ setPending(true)
+ setError('')
+ try {
+ const response = await fetch(apiUrl('/network/add'), {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: form.name.trim(), description: form.description.trim(), token: form.token.trim() }),
+ })
+ if (!response.ok) {
+ setError(await readError(response, t('list.failed')))
+ setPending(false)
+ return
+ }
+ onListed()
+ } catch {
+ setError(t('list.failed'))
+ setPending(false)
+ }
+ }
+
+ return (
+
+ {t('list.cancel')}
+
+ {pending ? <> {t('list.adding')}> : <> {t('list.add')}>}
+
+ >
+ )}
+ >
+
+
+ {t('list.name')}
+
+
+
+ {t('list.description')}
+
+
+
+
{t('list.token')}
+
+
{t('list.tokenHint')}
+
+ {t('list.public')}
+
+ setUnderstood(e.target.checked)} data-testid="list-understood" />
+ {t('list.understood')}
+
+ {error && {error}
}
+
+
+ )
+}
+
+// The Explorer: the public swarms a LocalAI explorer server knows about. It is a
+// separate server mode (`local-ai explorer`) with two endpoints: GET /networks
+// lists each listed token with its name, description and clusters (a cluster has
+// a type, a network id and its workers), and POST /network/add lists a new one.
+// The page shows exactly that. It has no map or graph because the server holds
+// no positions and no links between workers.
+export default function Explorer() {
+ const { t } = useTranslation('explorer')
+ const branding = useBranding()
+ const [state, setState] = useState('loading') // loading | ready | off | error
+ const [networks, setNetworks] = useState([])
+ const [sheet, setSheet] = useState(null) // { kind: 'list' } | { kind: 'join', network }
+ const [notice, setNotice] = useState('')
+
+ const load = useCallback(async () => {
+ try {
+ const response = await fetch(apiUrl('/networks'))
+ const type = response.headers.get('content-type') || ''
+ if (!response.ok || !type.includes('application/json')) {
+ // A LocalAI that is not in explorer mode has no /networks.
+ setState(prev => (prev === 'ready' ? prev : response.status === 404 || !type.includes('application/json') ? 'off' : 'error'))
+ return
+ }
+ const data = await response.json()
+ setNetworks(sortNetworks(Array.isArray(data) ? data : []))
+ setState('ready')
+ } catch {
+ setState(prev => (prev === 'ready' ? prev : 'error'))
+ }
+ }, [])
+
+ useEffect(() => {
+ load()
+ const timer = setInterval(load, POLL_MS)
+ return () => clearInterval(timer)
+ }, [load])
+
+ const listed = () => {
+ setSheet(null)
+ setNotice(t('listed'))
+ load()
+ }
+
+ return (
+
+
+
+
+
+
+
{t('title')}
+
{t('lede')}
+
+ {state === 'ready' && (
+ setSheet({ kind: 'list' })} data-testid="list-open">
+ {t('list.open')}
+
+ )}
+
+
+ {notice && {notice}
}
+
+ {state === 'loading' && (
+
+ {[0, 1, 2].map(i =>
)}
+
+ )}
+
+ {state === 'off' && (
+
+ )}
+
+ {state === 'error' && (
+
+
+ {t('error.title')}
+ {t('error.text')}
+ { setState('loading'); load() }}> {t('error.retry')}
+
+ )}
+
+ {state === 'ready' && networks.length === 0 && (
+
+
+ {t('empty.title')}
+ {t('empty.text')}
+ setSheet({ kind: 'list' })}> {t('list.open')}
+
+ )}
+
+ {state === 'ready' && networks.length > 0 && (
+ <>
+
+ {t('count', { count: networks.length })}
+ {t('sorted')}
+
+
+ {t('warning')}
+ >
+ )}
+
+
+ {sheet?.kind === 'list' &&
setSheet(null)} onListed={listed} />}
+ {sheet?.kind === 'join' && setSheet(null)} />}
)
}
diff --git a/core/http/react-ui/src/pages/FaceRecognition.jsx b/core/http/react-ui/src/pages/FaceRecognition.jsx
index 9185ea744..f34216385 100644
--- a/core/http/react-ui/src/pages/FaceRecognition.jsx
+++ b/core/http/react-ui/src/pages/FaceRecognition.jsx
@@ -1,602 +1,56 @@
-import { useEffect, useMemo, useState } from 'react'
+import { useMemo, useState } from 'react'
import { useOutletContext, useParams } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
+// eslint-disable-next-line no-unused-vars
import ModelSelector from '../components/ModelSelector'
-import LoadingSpinner from '../components/LoadingSpinner'
-import ErrorWithTraceLink from '../components/ErrorWithTraceLink'
-import TabSwitch from '../components/biometrics/TabSwitch'
-import MediaInput from '../components/biometrics/MediaInput'
-import BoundingBoxCanvas from '../components/biometrics/BoundingBoxCanvas'
-import MatchGauge from '../components/biometrics/MatchGauge'
-import DistributionBars from '../components/biometrics/DistributionBars'
-import EnrollmentList from '../components/biometrics/EnrollmentList'
-import EmbeddingInspector from '../components/biometrics/EmbeddingInspector'
+// eslint-disable-next-line no-unused-vars
+import PageHeader from '../components/PageHeader'
+import Icon from '../components/Icon'
+// eslint-disable-next-line no-unused-vars
+import ModelNeeded from '../components/identity/ModelNeeded'
+// eslint-disable-next-line no-unused-vars
+import MoreFaceTools from '../components/identity/MoreFaceTools'
+// eslint-disable-next-line no-unused-vars
+import Workbench from '../components/identity/Workbench'
+import { useAuth } from '../context/AuthContext'
+import { useModels } from '../hooks/useModels'
import { CAP_FACE_RECOGNITION } from '../utils/capabilities'
-import { faceApi } from '../utils/api'
-
-const TABS = [
- { id: 'analyze', icon: 'fas fa-chart-column', label: 'Analyze' },
- { id: 'compare', icon: 'fas fa-people-arrows', label: 'Compare' },
- { id: 'enroll', icon: 'fas fa-id-card', label: 'Enrollment' },
- { id: 'embed', icon: 'fas fa-code', label: 'Embedding' },
-]
-
-const ENROLL_KEY = 'localai_face_enrollments'
-
-function loadEnrollments() {
- try {
- const raw = localStorage.getItem(ENROLL_KEY)
- if (!raw) return []
- const parsed = JSON.parse(raw)
- return Array.isArray(parsed) ? parsed : []
- } catch (_) { return [] }
-}
-
-function saveEnrollments(list) {
- try { localStorage.setItem(ENROLL_KEY, JSON.stringify(list.slice(0, 50))) } catch (_) { /* quota */ }
-}
-
-// parse a textarea of "key: value" lines into a { key: value } object.
-function parseLabels(text) {
- const out = {}
- if (!text) return out
- for (const line of text.split('\n')) {
- const idx = line.indexOf(':')
- if (idx === -1) continue
- const k = line.slice(0, idx).trim()
- const v = line.slice(idx + 1).trim()
- if (k) out[k] = v
- }
- return out
-}
+import './identity.css'
+// The Faces page, in the same family as Voices: who is this, same person, the
+// people this browser knows, what is stored. Detect and analyze, and the raw
+// faceprint, sit behind a disclosure.
export default function FaceRecognition() {
+ const { t } = useTranslation('biometrics')
const { model: urlModel } = useParams()
const { addToast } = useOutletContext()
-
+ const { isAdmin } = useAuth()
const [model, setModel] = useState(urlModel || '')
- const [tab, setTab] = useState('analyze')
+ const { models, loading, refetch } = useModels(CAP_FACE_RECOGNITION)
+ const names = useMemo(() => models.map(m => m.id), [models])
+ const needModel = !loading && names.length === 0
return (
-
-
-
-
-
-
- {tab === 'analyze' &&
}
- {tab === 'compare' &&
}
- {tab === 'enroll' &&
}
- {tab === 'embed' &&
}
-
-
- )
-}
-
-// ──────────────────────────── Analyze ────────────────────────────
-
-function AnalyzeTab({ model, addToast }) {
- const [img, setImg] = useState(null)
- const [actions, setActions] = useState({ age: true, gender: true, emotion: true, race: true })
- const [antiSpoofing, setAntiSpoofing] = useState(false)
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState(null)
- const [result, setResult] = useState(null)
- const [focusIdx, setFocusIdx] = useState(0)
-
- const submit = async (e) => {
- e.preventDefault()
- if (!model) { addToast('Select a face model first', 'warning'); return }
- if (!img) { addToast('Add an image to analyze', 'warning'); return }
- setLoading(true); setError(null); setResult(null); setFocusIdx(0)
- try {
- const body = {
- model,
- img: img.dataUrl,
- actions: Object.entries(actions).filter(([, v]) => v).map(([k]) => k),
- anti_spoofing: antiSpoofing,
- }
- const data = await faceApi.analyze(body)
- setResult(data)
- if (!data?.faces?.length) addToast('No face detected in the image', 'warning')
- } catch (err) {
- setError(err.message)
- } finally {
- setLoading(false)
- }
- }
-
- const boxes = useMemo(() => (result?.faces || []).map((f, i) => ({
- x: f.region.x, y: f.region.y, w: f.region.w, h: f.region.h,
- label: f.dominant_emotion || f.dominant_gender || `Face ${i + 1}`,
- sublabel: f.age ? `~${Math.round(f.age)}y` : null,
- tone: i === focusIdx ? 'accent' : 'default',
- })), [result, focusIdx])
-
- const faces = result?.faces || []
- const focus = faces[focusIdx]
-
- return (
-
-
-
-
- {loading &&
}
- {error && }
- {!loading && !error && !result && (
-
+ ) : null}
+ />
+ : null}
+ more={needModel ? null : (
+
+ {t('face.moreTitle')} {t('face.moreHint')}
+
+
)}
- {result && img && (
- <>
-
-
-
- {faces.length > 1 && (
-
- {faces.map((_, i) => (
- setFocusIdx(i)}
- aria-pressed={i === focusIdx}>
- Face {i + 1}
-
- ))}
-
- )}
-
-
- {focus && (
- <>
-
-
-
Face {focusIdx + 1}
- {antiSpoofing && }
-
-
- {focus.age != null && <>Age ~{Math.round(focus.age)} >}
- {focus.dominant_gender && <>Gender {focus.dominant_gender} >}
- {focus.dominant_emotion && <>Emotion {focus.dominant_emotion} >}
- {focus.dominant_race && <>Race {focus.dominant_race} >}
- {focus.face_confidence != null && <>Detection {(focus.face_confidence * 100).toFixed(1)}% >}
-
-
-
-
-
- >
- )}
-
-
-
- >
- )}
-
-
- )
-}
-
-// ──────────────────────────── Compare ────────────────────────────
-
-function CompareTab({ model, addToast }) {
- const [img1, setImg1] = useState(null)
- const [img2, setImg2] = useState(null)
- const [antiSpoofing, setAntiSpoofing] = useState(false)
- const [threshold, setThreshold] = useState(null)
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState(null)
- const [result, setResult] = useState(null)
-
- const submit = async (e) => {
- e.preventDefault()
- if (!model) { addToast('Select a face model first', 'warning'); return }
- if (!img1 || !img2) { addToast('Add both images to compare', 'warning'); return }
- setLoading(true); setError(null); setResult(null)
- try {
- const body = { model, img1: img1.dataUrl, img2: img2.dataUrl, anti_spoofing: antiSpoofing }
- if (threshold != null) body.threshold = threshold
- const data = await faceApi.verify(body)
- setResult(data)
- if (threshold == null && data?.threshold) setThreshold(data.threshold)
- } catch (err) {
- setError(err.message)
- } finally {
- setLoading(false)
- }
- }
-
- // Re-compute verified locally when user drags the threshold slider post-response.
- const effective = useMemo(() => {
- if (!result) return null
- const t = threshold ?? result.threshold
- const verified = result.distance <= t
- const confidence = Math.max(0, Math.min(100, 100 * (1 - result.distance / t)))
- return { verified, confidence, threshold: t, distance: result.distance }
- }, [result, threshold])
-
- return (
-
-
-
-
- {loading &&
}
- {error && }
- {!loading && !error && !result && (
-
- )}
- {result && effective && (
- <>
-
-
-
Image 1
-
- {antiSpoofing && result.img1_is_real != null && (
-
- )}
-
-
-
-
-
Threshold {effective.threshold.toFixed(3)}
-
setThreshold(parseFloat(e.target.value))}
- aria-describedby="face-threshold-hint" />
-
- Drag to see how the verdict changes. The backend default is {result.threshold?.toFixed(3)}.
-
-
-
-
-
Image 2
-
- {antiSpoofing && result.img2_is_real != null && (
-
- )}
-
-
-
- >
- )}
-
-
- )
-}
-
-// ──────────────────────────── Enrollment (register / identify / forget) ────────────────────────────
-
-function EnrollTab({ model, addToast }) {
- const [enrolled, setEnrolled] = useState(loadEnrollments)
- const [enrollName, setEnrollName] = useState('')
- const [enrollLabels, setEnrollLabels] = useState('')
- const [enrollImg, setEnrollImg] = useState(null)
- const [enrolling, setEnrolling] = useState(false)
- const [enrollErr, setEnrollErr] = useState(null)
- const [lastEnrolled, setLastEnrolled] = useState(null)
-
- const [probeImg, setProbeImg] = useState(null)
- const [topK, setTopK] = useState(5)
- const [threshold, setThreshold] = useState(0.35)
- const [identifying, setIdentifying] = useState(false)
- const [identifyErr, setIdentifyErr] = useState(null)
- const [identifyResult, setIdentifyResult] = useState(null)
-
- useEffect(() => { saveEnrollments(enrolled) }, [enrolled])
-
- const enroll = async (e) => {
- e.preventDefault()
- if (!model) { addToast('Select a face model first', 'warning'); return }
- if (!enrollName.trim()) { addToast('Give this person a name', 'warning'); return }
- if (!enrollImg) { addToast('Add a sample image', 'warning'); return }
- setEnrolling(true); setEnrollErr(null)
- try {
- const data = await faceApi.register({
- model,
- name: enrollName.trim(),
- img: enrollImg.dataUrl,
- labels: parseLabels(enrollLabels),
- })
- const entry = {
- id: data.id,
- name: data.name,
- labels: parseLabels(enrollLabels),
- thumbnail: enrollImg.dataUrl,
- registeredAt: data.registered_at || new Date().toISOString(),
- }
- setEnrolled(prev => [entry, ...prev])
- setLastEnrolled(entry.id)
- setEnrollName(''); setEnrollLabels(''); setEnrollImg(null)
- addToast(`Enrolled ${entry.name}`, 'success')
- } catch (err) {
- setEnrollErr(err.message)
- } finally {
- setEnrolling(false)
- }
- }
-
- const forget = async (entry) => {
- try {
- await faceApi.forget({ id: entry.id })
- setEnrolled(prev => prev.filter(e => e.id !== entry.id))
- addToast(`Removed ${entry.name}`, 'info')
- } catch (err) {
- if (err.status === 404) {
- setEnrolled(prev => prev.filter(e => e.id !== entry.id))
- addToast(`${entry.name} was already gone from the backend store`, 'warning')
- } else {
- addToast(err.message, 'error')
- }
- }
- }
-
- const identify = async (e) => {
- e.preventDefault()
- if (!model) { addToast('Select a face model first', 'warning'); return }
- if (!probeImg) { addToast('Add a probe image', 'warning'); return }
- setIdentifying(true); setIdentifyErr(null); setIdentifyResult(null)
- try {
- const data = await faceApi.identify({
- model,
- img: probeImg.dataUrl,
- top_k: topK,
- threshold,
- })
- setIdentifyResult(data)
- if (!data?.matches?.length) addToast('No matches above threshold', 'info')
- } catch (err) {
- setIdentifyErr(err.message)
- } finally {
- setIdentifying(false)
- }
- }
-
- return (
-
-
- Enroll a face
-
-
- Name
- setEnrollName(e.target.value)} placeholder="e.g. Alice Johnson" />
-
-
- Labels (optional, one per line)
- setEnrollLabels(e.target.value)} />
-
-
-
- {enrolling ? <> Enrolling…> : <> Enroll>}
-
- {enrollErr &&
}
-
-
-
-
- Identify someone
-
-
-
-
- {identifying ? <> Searching…> : <> Identify>}
-
- {identifyErr &&
}
- {identifyResult && }
-
-
-
-
-
-
Enrolled {enrolled.length}
-
-
-
-
- )
-}
-
-function MatchesList({ matches, enrolled }) {
- if (!matches.length) {
- return
No candidates above threshold.
- }
- return (
-
- {matches.map((m, i) => {
- const record = enrolled.find(e => e.id === m.id)
- const conf = Math.max(0, Math.min(100, m.confidence ?? 0))
- return (
-
- #{i + 1}
-
- {record?.thumbnail
- ?
- :
{(m.name || '?').slice(0, 2).toUpperCase()} }
-
-
-
- {m.name || m.id}
- {m.match ? match
- : below threshold }
-
-
-
- distance {m.distance?.toFixed?.(4) ?? '—'}
- confidence {conf.toFixed(1)}%
-
-
-
- )
- })}
-
- )
-}
-
-// ──────────────────────────── Embedding ────────────────────────────
-
-function EmbedTab({ model, addToast }) {
- const [img, setImg] = useState(null)
- const [loading, setLoading] = useState(false)
- const [error, setError] = useState(null)
- const [result, setResult] = useState(null)
- const [elapsedMs, setElapsedMs] = useState(null)
-
- const submit = async (e) => {
- e.preventDefault()
- if (!model) { addToast('Select a face model first', 'warning'); return }
- if (!img) { addToast('Add an image', 'warning'); return }
- setLoading(true); setError(null); setResult(null)
- const started = performance.now()
- try {
- const data = await faceApi.embed({ model, img: img.dataUrl })
- setElapsedMs(performance.now() - started)
- setResult(data)
- } catch (err) {
- setError(err.message)
- } finally {
- setLoading(false)
- }
- }
-
- return (
-
-
- Get a raw embedding
-
- Returns a single face embedding vector. This is the same representation the backend uses internally for verify, identify, and compare.
-
-
-
- {loading ? <> Embedding…> : <> Extract vector>}
-
-
-
- {loading &&
}
- {error && }
- {!loading && !error && !result && (
-
- )}
- {result && (
-
- )}
-
-
- )
-}
-
-// ──────────────────────────── Small shared bits ────────────────────────────
-
-function LivenessPill({ isReal, score }) {
- if (isReal == null) {
- return
Not checked
- }
- return (
-
-
- {isReal ? 'Real' : 'Spoof'}
- {score != null && {(score * 100).toFixed(0)}% }
-
- )
-}
-
-function EmptyState({ icon, title, body }) {
- return (
-
- )
-}
-
-function ResponseDetails({ data }) {
- return (
-
- Raw response
- {JSON.stringify(data, null, 2)}
-
+ />
+
)
}
diff --git a/core/http/react-ui/src/pages/Failover.jsx b/core/http/react-ui/src/pages/Failover.jsx
index 39fdffc26..7511cebfc 100644
--- a/core/http/react-ui/src/pages/Failover.jsx
+++ b/core/http/react-ui/src/pages/Failover.jsx
@@ -1,94 +1,174 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
-import PageHeader from '../components/PageHeader'
import LoadingSpinner from '../components/LoadingSpinner'
-import ResponsiveTable from '../components/ResponsiveTable'
import StatusPill from '../components/StatusPill'
import useFailoverChains from '../hooks/useFailoverChains'
import { relative } from '../components/FailoverChainStatus'
+import { useNodeList, useReplicas, useRules } from '../hooks/useSwarm'
+import { isDown, isPlaceable, leavePreview, singleReplicaModels } from '../utils/swarm'
+import { timeAgo } from '../components/nodes/nodeStatus'
+import LeaveList from '../components/swarm/LeaveList'
+import Icon from '../components/Icon'
+import './swarm.css'
-// Failover: the Operate -> Runtime overview of every failover chain configured
-// on this profile. One row per chain: its live state, the target actually
-// serving it right now, a compact per-target health strip, and how long the
-// active target has held the chain. Sourced from the same hook (GET
-// /api/failover, kept live over the /api/failover/events SSE stream) that
-// drives the Model Editor's chain strip, so both surfaces always agree.
+const POLICY = ['notAnswering', 'requests', 'replicas', 'back']
+
+// Failover has two halves. Model failover chains are a feature of the model
+// config: one name served by an ordered list of targets, with the live health
+// of each, over GET /api/failover and its event stream. Node failover is what
+// the router and the health monitor already do when a worker stops answering;
+// the page says it in words and, for each node, works out from the loaded
+// replicas and the rules what would stop if it went, labelled as a preview.
+//
+// A single install has chains and no nodes, so it shows only the chains.
export default function Failover() {
const { t, i18n } = useTranslation('models')
+ const { t: ts } = useTranslation('swarm')
const { chains, loading, error } = useFailoverChains()
+ const { nodes, status } = useNodeList({ interval: 10000 })
+ const replicas = useReplicas()
+ const { rules } = useRules()
+ const [picked, setPicked] = useState(null)
+ const cluster = status === 'ready' && nodes.length > 0
+
+ const loadReplicas = replicas.load
+ useEffect(() => { if (cluster) void loadReplicas() }, [cluster, loadReplicas])
+
+ const candidates = useMemo(() => nodes.filter(node => (node.node_type || 'backend') === 'backend' && node.status !== 'pending'), [nodes])
+ const lost = nodes.filter(node => isDown(node))
+ // Start on the healthy node that holds the most models: it is the one whose
+ // loss costs the most, so it is the one worth reading first.
+ const busiest = [...candidates].filter(isPlaceable).sort((a, b) => ((b.model_count || 0) - (a.model_count || 0)) || ((b.in_flight_count || 0) - (a.in_flight_count || 0)))[0]
+ const subject = candidates.find(node => node.id === picked) || busiest || candidates[0] || null
+ const rows = replicas.state === 'loaded' ? replicas.rows : null
+ const preview = useMemo(
+ () => (subject && rows ? leavePreview({ node: subject, rows, nodes, rules }) : null),
+ [subject, rows, nodes, rules],
+ )
+ const single = useMemo(() => (rows ? singleReplicaModels(rows, nodes) : []), [rows, nodes])
return (
-
-
+
+
{t('failover.overview.title')}
+
{ts('failover.lede')}
- {error && (
-
- {error}
-
+ {cluster && lost.length > 0 && (
+
+
+ {ts('failover.lost', { names: lost.map(n => n.name).join(', '), count: lost.length })} {' '}
+ {ts('failover.lostSub', { when: lost[0].last_heartbeat ? timeAgo(lost[0].last_heartbeat) : ts('detail.never') })}{' '}
+ {ts('failover.openNode')}
+
+
)}
- {loading ? (
-
- ) : chains.length === 0 ? (
-
-
-
{t('failover.overview.empty.title')}
-
{t('failover.overview.empty.text')}
-
- {t('failover.overview.empty.cta')}
-
-
- ) : (
-
-
-
- {t('failover.overview.columns.chain')}
- {t('failover.overview.columns.state')}
- {t('failover.overview.columns.active')}
- {t('failover.overview.columns.targets')}
- {t('failover.overview.columns.changed')}
-
-
-
- {chains.map(chain => {
- const since = relative(chain.active_since, i18n.language)
- return (
-
-
-
- {chain.name}
-
-
-
-
-
- {chain.active}
-
-
- {(chain.targets || []).map(target => (
-
- ))}
-
-
-
- {since || '-'}
-
+ {cluster && (
+
+ {ts('failover.policyTitle')}
+
+ {POLICY.map(key => (
+
+
{ts(`failover.policy.${key}.title`)}
+ {ts(`failover.policy.${key}.text`)}
+
+ ))}
+
+ {ts('failover.policyNote')}
+
+ )}
+
+
+ {t('failover.overview.title')} {chains.length}
+ {t('failover.overview.subtitle')}
+
+ {error && (
+ {error}
+ )}
+
+ {loading ? (
+
+ ) : chains.length === 0 ? (
+
+
+
{t('failover.overview.empty.title')}
+
{t('failover.overview.empty.text')}
+
{t('failover.overview.empty.cta')}
+
+ ) : (
+
+
+ {t('failover.overview.title')}
+
+
+ {t('failover.overview.columns.chain')}
+ {t('failover.overview.columns.state')}
+ {t('failover.overview.columns.active')}
+ {t('failover.overview.columns.targets')}
+ {t('failover.overview.columns.changed')}
- )
- })}
-
-
+
+
+ {chains.map(chain => {
+ const since = relative(chain.active_since, i18n.language)
+ return (
+
+
+ {chain.name}
+
+
+ {chain.active}
+
+
+ {(chain.targets || []).map(target => (
+
+ ))}
+
+
+
+ {since || '-'}
+
+
+ )
+ })}
+
+
+
+ )}
+
+
+ {cluster && candidates.length > 0 && (
+
+ {ts('failover.tryTitle')} {ts('preview.label')}
+
+ {candidates.slice(0, 8).map(node => (
+ setPicked(node.id)}>{node.name}
+ ))}
+
+ {preview === null
+ ? {replicas.state === 'error' ? ts('leave.unavailable') : ts('failover.tryLoading')}
+ : }
+ {ts('leave.how')}
+
+ {single.length > 0 && (
+
+
+ {ts('failover.single', { count: single.length })} {' '}
+ {ts('failover.singleSub')}
+
+
+ {single.slice(0, 6).map(model => (
+
+ {model}
+ {ts('failover.addRule')}
+
+ ))}
+
+ {single.length > 6 &&
{ts('failover.singleMore', { count: single.length - 6 })}
}
+
+ )}
+
)}
)
diff --git a/core/http/react-ui/src/pages/FineTune.jsx b/core/http/react-ui/src/pages/FineTune.jsx
index 74475acaa..5cfa4456c 100644
--- a/core/http/react-ui/src/pages/FineTune.jsx
+++ b/core/http/react-ui/src/pages/FineTune.jsx
@@ -1,497 +1,346 @@
-import { useState, useEffect, useRef, useCallback } from 'react'
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
+import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
+import { Link } from 'react-router-dom'
+import { useTranslation } from 'react-i18next'
import { fineTuneApi } from '../utils/api'
-import LoadingSpinner from '../components/LoadingSpinner'
+import { useAuth } from '../context/AuthContext'
+import { useResources } from '../hooks/useResources'
+import { FT_STAGES, TERMINAL, appendLog, blocked, fineTuneChecks, logLines, looksLikeMemoryFailure, machineFacts, warned } from '../utils/tools'
import PageHeader from '../components/PageHeader'
-import SectionHeading from '../components/SectionHeading'
-import StatCard from '../components/StatCard'
-import EmptyState from '../components/EmptyState'
-import Toggle from '../components/Toggle'
-import ResponsiveTable from '../components/ResponsiveTable'
import UnsavedChangesGuard from '../components/UnsavedChangesGuard'
+import Dialog from '../components/Dialog'
+import Icon from '../components/Icon'
+import LoadingSpinner from '../components/LoadingSpinner'
+import ToolSteps from '../components/tools/ToolSteps'
+import Checks from '../components/tools/Checks'
+import StageLine from '../components/tools/StageLine'
+import JobLog from '../components/tools/JobLog'
+import LossChart from '../components/tools/LossChart'
+import '../components/tools/tools.css'
const TRAINING_METHODS = ['sft', 'dpo', 'grpo', 'rloo', 'reward', 'kto', 'orpo']
-const TRAINING_TYPES = ['lora', 'loha', 'lokr', 'full']
-const FALLBACK_BACKENDS = ['trl']
+const ADAPTER_KINDS = ['lora', 'loha', 'lokr']
+const FALLBACK_BACKEND = 'trl'
const OPTIMIZERS = ['adamw_torch', 'adamw_8bit', 'sgd', 'adafactor', 'prodigy']
const MIXED_PRECISION_OPTS = ['', 'fp16', 'bf16', 'no']
+const QUANT_PRESETS = ['q4_k_m', 'q5_k_m', 'q8_0', 'f16', 'q4_0', 'q5_0']
const BUILTIN_REWARDS = [
- { name: 'format_reward', description: 'Checks ... then answer format', params: [] },
- { name: 'reasoning_accuracy_reward', description: 'Compares content to dataset answer column', params: [] },
- { name: 'length_reward', description: 'Score based on proximity to target length', params: [{ key: 'target_length', default: '200', label: 'Target Length' }] },
- { name: 'xml_tag_reward', description: 'Scores properly opened/closed XML tags', params: [] },
- { name: 'no_repetition_reward', description: 'Penalizes n-gram repetition', params: [] },
- { name: 'code_execution_reward', description: 'Checks Python code block syntax validity', params: [] },
+ { name: 'format_reward', params: [] },
+ { name: 'reasoning_accuracy_reward', params: [] },
+ { name: 'length_reward', params: [{ key: 'target_length', default: '200' }] },
+ { name: 'xml_tag_reward', params: [] },
+ { name: 'no_repetition_reward', params: [] },
+ { name: 'code_execution_reward', params: [] },
]
-const ACTIVE_STATUSES = ['queued', 'loading_model', 'loading_dataset', 'training', 'saving']
-const TERMINAL_STATUSES = ['completed', 'stopped', 'failed']
-
-const statusBadgeClass = {
- queued: '',
- loading_model: 'badge-warning',
- loading_dataset: 'badge-warning',
- training: 'badge-info',
- saving: 'badge-info',
- completed: 'badge-success',
- failed: 'badge-error',
- stopped: '',
+const ACTIVE_STATUSES = FT_STAGES
+const BADGE = {
+ queued: '', loading_model: 'dk-badge--warn', loading_dataset: 'dk-badge--warn', training: 'dk-badge--accent',
+ saving: 'dk-badge--accent', completed: 'dk-badge--ok', failed: 'dk-badge--error', stopped: '',
}
+const EXTRA_HANDLED = ['max_seq_length', 'save_total_limit', 'hf_token', 'eval_strategy', 'eval_steps', 'eval_split', 'eval_dataset_source', 'eval_split_ratio', 'voice', 'val_dataset']
function StatusBadge({ status }) {
- return {status}
+ const { t } = useTranslation('tools')
+ return {t(`fineTune.status.${status}`, { defaultValue: String(status || '').replace(/_/g, ' ') })}
}
-function FormSection({ icon, title, children }) {
+function Field({ id, label, hint, className = '', children }) {
return (
-
-
-
+
+
{label}
+ {children}
+ {hint &&
{hint}
}
+
+ )
+}
+
+function Select({ id, value, onChange, children }) {
+ return (
+
+ {children}
+
+ )
+}
+
+function Section({ done, title, children }) {
+ return (
+
+
+ {done ? : null}
{title}
-
- {children}
+
+ {children}
)
}
function KeyValueEditor({ entries, onChange }) {
- const addEntry = () => onChange([...entries, { key: '', value: '' }])
- const removeEntry = (i) => onChange(entries.filter((_, idx) => idx !== i))
- const updateEntry = (i, field, val) => {
- onChange(entries.map((e, idx) => idx === i ? { ...e, [field]: val } : e))
- }
-
+ const { t } = useTranslation('tools')
+ const update = (i, field, val) => onChange(entries.map((e, idx) => (idx === i ? { ...e, [field]: val } : e)))
return (
-
+
{entries.map((entry, i) => (
-
-
updateEntry(i, 'key', e.target.value)}
- placeholder="Key"
- aria-label={`Extra option ${i + 1} key`}
- />
-
updateEntry(i, 'value', e.target.value)}
- placeholder="Value"
- aria-label={`Extra option ${i + 1} value`}
- />
-
removeEntry(i)} aria-label={`Remove extra option ${i + 1}`}>
-
+
+ update(i, 'key', e.target.value)} placeholder={t('form.key')} aria-label={`Extra option ${i + 1} key`} />
+ update(i, 'value', e.target.value)} placeholder={t('form.value')} aria-label={`Extra option ${i + 1} value`} />
+ onChange(entries.filter((_, idx) => idx !== i))} aria-label={`Remove extra option ${i + 1}`}>
+
))}
-
- Add option
+ onChange([...entries, { key: '', value: '' }])}>
+ {t('form.addOption')}
)
}
function CopyButton({ text }) {
+ const { t } = useTranslation('tools')
const [copied, setCopied] = useState(false)
- const handleCopy = (e) => {
+ const copy = (e) => {
e.stopPropagation()
- navigator.clipboard.writeText(text).then(() => {
- setCopied(true)
- setTimeout(() => setCopied(false), 1500)
- })
+ navigator.clipboard?.writeText(text).then(() => { setCopied(true); setTimeout(() => setCopied(false), 1500) }).catch(() => {})
}
return (
-
-
+
+
)
}
-function JobCard({ job, onSelect, onUseConfig, onDelete }) {
- return (
- onSelect(job)}>
-
-
- {job.model}
- {job.backend} / {job.training_method || 'sft'}
-
-
- { e.stopPropagation(); onUseConfig(job) }}
- title="Use this job's configuration for a new job"
- >
- Reuse
-
- {TERMINAL_STATUSES.includes(job.status) && (
- { e.stopPropagation(); onDelete(job.id) }}
- title="Delete this job and its data"
- aria-label="Delete job"
- >
-
-
- )}
-
-
-
-
- ID: {job.id?.slice(0, 8)}... | Created: {job.created_at}
-
- {job.output_dir && (
-
-
- {job.output_dir}
-
-
- )}
- {job.message && (
-
- {job.message}
-
- )}
-
- )
-}
-
-function formatEta(seconds) {
- if (!seconds || seconds <= 0) return '--'
+function etaText(t, seconds) {
+ if (!seconds || seconds <= 0) return ''
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
- const s = Math.floor(seconds % 60)
- if (h > 0) return `${h}h ${m}m`
- if (m > 0) return `${m}m ${s}s`
- return `${s}s`
+ if (h > 0) return t('run.eta', { time: `${h}h ${m}m` })
+ if (m > 0) return t('run.eta', { time: `${m} min` })
+ return t('run.etaSeconds', { seconds: Math.max(1, Math.floor(seconds)) })
}
-function formatAxisValue(val, decimals) {
- if (val >= 1) return val.toFixed(Math.min(decimals, 1))
- if (val >= 0.01) return val.toFixed(Math.min(decimals, 3))
- return val.toExponential(1)
+const kindOf = (job) => `${job.training_type || 'lora'}, ${job.training_method || 'sft'}`
+const startedAt = (job) => {
+ const d = new Date(job.created_at)
+ return Number.isNaN(d.getTime()) ? (job.created_at || '') : d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
-function SingleMetricChart({ data, valueKey, label, color, formatValue, events }) {
- const [tooltip, setTooltip] = useState(null)
- const svgRef = useRef(null)
-
- if (!data || data.length < 1) return null
-
- const pad = { top: 16, right: 12, bottom: 32, left: 52 }
- const W = 400, H = 220
- const cw = W - pad.left - pad.right
- const ch = H - pad.top - pad.bottom
-
- const steps = data.map(e => e.current_step)
- const values = data.map(e => e[valueKey])
-
- const minStep = Math.min(...steps), maxStep = Math.max(...steps)
- const stepRange = maxStep - minStep || 1
- const minVal = Math.min(...values), maxVal = Math.max(...values)
- const valRange = maxVal - minVal || 1
- const valPad = valRange * 0.05
- const yMin = Math.max(0, minVal - valPad), yMax = maxVal + valPad
- const yRange = yMax - yMin || 1
-
- const x = (step) => pad.left + ((step - minStep) / stepRange) * cw
- const y = (val) => pad.top + (1 - (val - yMin) / yRange) * ch
-
- const points = data.map(e => `${x(e.current_step)},${y(e[valueKey])}`).join(' ')
-
- const xTickCount = Math.min(5, data.length)
- const xTicks = Array.from({ length: xTickCount }, (_, i) => Math.round(minStep + (stepRange * i) / (xTickCount - 1)))
- const yTickCount = 4
- const yTicks = Array.from({ length: yTickCount }, (_, i) => yMin + (yRange * i) / (yTickCount - 1))
-
- // Epoch boundaries from the full events list if provided
- const epochBoundaries = []
- const evts = events || data
- for (let i = 1; i < evts.length; i++) {
- const prevEpoch = Math.floor(evts[i - 1].current_epoch || 0)
- const curEpoch = Math.floor(evts[i].current_epoch || 0)
- if (curEpoch > prevEpoch && curEpoch > 0) {
- epochBoundaries.push({ step: evts[i].current_step, epoch: curEpoch })
- }
- }
-
- const fmtVal = formatValue || ((v) => formatAxisValue(v, 3))
-
- const handleMouseMove = (e) => {
- if (!svgRef.current) return
- const rect = svgRef.current.getBoundingClientRect()
- const mx = ((e.clientX - rect.left) / rect.width) * W
- const step = minStep + ((mx - pad.left) / cw) * stepRange
- let nearest = data[0], bestDist = Infinity
- for (const d of data) {
- const dist = Math.abs(d.current_step - step)
- if (dist < bestDist) { bestDist = dist; nearest = d }
- }
- setTooltip({ x: x(nearest.current_step), y: y(nearest[valueKey]), data: nearest })
- }
-
- return (
-
-
-
- {label}
-
-
setTooltip(null)}
- >
- {yTicks.map((val, i) => (
-
- ))}
- {epochBoundaries.map((eb, i) => (
-
- ))}
-
-
- {xTicks.map((step, i) => (
- {step}
- ))}
-
- {yTicks.map((val, i) => (
- {fmtVal(val)}
- ))}
- Step
- {tooltip && (
-
-
-
-
-
- Step {tooltip.data.current_step}
-
-
- {fmtVal(tooltip.data[valueKey])}
-
-
- )}
-
-
- )
-}
-
-function ChartsGrid({ events }) {
- const lossData = events.filter(e => e.loss > 0)
- const evalData = events.filter(e => e.eval_loss > 0)
- const lrData = events.filter(e => e.learning_rate != null && e.learning_rate > 0)
- const gradNormData = events.filter(e => e.grad_norm != null && e.grad_norm > 0)
-
- const fmtExp = (v) => v.toExponential(1)
-
- if (lossData.length < 2 && lrData.length < 2 && gradNormData.length < 2) return null
-
- return (
-
-
- {evalData.length >= 1 ? (
-
- ) : (
-
-
- Eval loss, waiting for eval data
-
- )}
-
-
-
- )
-}
-
-function TrainingMonitor({ job, onStop }) {
+// A running, failed or finished job: progress, the loss chart, the log, the
+// checkpoints and what to do with the result. The server streams progress
+// events while the job is active; a finished job is read from the job record.
+function JobView({ job, onStop, onReuse, onResume, onTerminal, exportCheckpoint }) {
+ const { t } = useTranslation('tools')
const [events, setEvents] = useState([])
const [latest, setLatest] = useState(null)
+ const [log, setLog] = useState([])
const [connecting, setConnecting] = useState(true)
- const eventSourceRef = useRef(null)
+ const [stopOpen, setStopOpen] = useState(false)
+ const [checkpoints, setCheckpoints] = useState([])
+ const previousRef = useRef(null)
+ const terminalRef = useRef(onTerminal)
+ terminalRef.current = onTerminal
useEffect(() => {
- if (!job || !ACTIVE_STATUSES.includes(job.status)) {
- setConnecting(false)
- return
- }
-
- setConnecting(true)
- setLatest(null)
+ previousRef.current = null
setEvents([])
-
- const url = fineTuneApi.progressUrl(job.id)
- const es = new EventSource(url)
- eventSourceRef.current = es
-
+ setLatest(null)
+ setLog([])
+ if (!job || !ACTIVE_STATUSES.includes(job.status)) { setConnecting(false); return undefined }
+ setConnecting(true)
+ const es = new EventSource(fineTuneApi.progressUrl(job.id))
es.onmessage = (e) => {
try {
- setConnecting(false)
const data = JSON.parse(e.data)
+ setConnecting(false)
setLatest(data)
- if (data.loss > 0) {
- setEvents(prev => [...prev, data])
+ setLog(prev => appendLog(prev, logLines(data, previousRef.current)))
+ previousRef.current = data
+ if (data.loss > 0 || data.eval_loss > 0 || data.learning_rate > 0 || data.grad_norm > 0) {
+ setEvents(prev => {
+ const last = prev[prev.length - 1]
+ return last && last.current_step === data.current_step ? [...prev.slice(0, -1), data] : [...prev, data]
+ })
}
- if (TERMINAL_STATUSES.includes(data.status)) {
- es.close()
- }
- } catch (_) {}
- }
-
- es.onerror = () => {
- setConnecting(false)
- es.close()
- }
-
- return () => {
- es.close()
+ if (TERMINAL.includes(data.status)) { es.close(); terminalRef.current?.() }
+ } catch (_) { /* a partial frame */ }
}
+ es.onerror = () => { setConnecting(false); es.close() }
+ return () => es.close()
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the job id: the effect restarts per job, not per poll refresh
}, [job?.id])
- if (!job) return null
-
+ const status = latest?.status || job.status
+ const active = ACTIVE_STATUSES.includes(status)
+ const failed = status === 'failed'
const progress = Math.min(latest?.progress_percent || 0, 100)
- return (
-
-
- Training monitor
-
+ useEffect(() => {
+ if (!job || active) return
+ fineTuneApi.listCheckpoints(job.id).then(r => setCheckpoints(r.checkpoints || [])).catch(() => {})
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the job id: the effect restarts per job, not per poll refresh
+ }, [job?.id, active])
- {connecting && !latest && (
-
+ const message = latest?.message || job.message || ''
+ const lastCheckpoint = checkpoints.length ? checkpoints[checkpoints.length - 1] : null
+ const [fixes, setFixes] = useState({ batch: true, checkpointing: true })
+
+ const stageLabels = Object.fromEntries(FT_STAGES.map(s => [s, t(`fineTune.status.${s}`)]))
+
+ return (
+
+ {failed && (
+
+
+
+
+ {latest?.current_step > 0
+ ? t('fineTune.failedAt', { step: latest.current_step, total: latest.total_steps })
+ : t('fineTune.failed')}
+
+
{message || t('fineTune.noMessage')}
+
+
)}
- {latest && (
- <>
-
-
-
-
-
-
-
-
- {latest.extra_metrics?.tokens_per_second > 0 && (
-
+
+
+
+
{job.model}
+
+ {t('fineTune.jobMeta', { backend: job.backend || FALLBACK_BACKEND, method: job.training_method || 'sft', kind: job.training_type || 'lora', started: startedAt(job) })}
+ · {job.id?.slice(0, 8)}
+
+
+
+
+ {active && (
+ setStopOpen(true)} data-testid="job-stop">
+ {t('run.stop')}
+
)}
+
-
- >
- )}
-
-
-
- {latest?.message && (
-
- {latest.message}
-
- )}
-
- {ACTIVE_STATUSES.includes(latest?.status || job.status) && (
- onStop(job.id)}>
- Stop training
-
- )}
-
- )
-}
-
-function CheckpointsPanel({ job, onResume, onExportCheckpoint }) {
- const [checkpoints, setCheckpoints] = useState([])
- const [loading, setLoading] = useState(false)
-
- useEffect(() => {
- if (!job) return
- setLoading(true)
- fineTuneApi.listCheckpoints(job.id).then(r => {
- setCheckpoints(r.checkpoints || [])
- }).catch(() => {}).finally(() => setLoading(false))
- }, [job?.id])
-
- if (!job) return null
- if (loading) {
- return (
-
- Checkpoints
- Loading checkpoints...
+ {connecting && !latest && active ? (
+ {t('run.connecting')}
+ ) : (
+ <>
+ {(latest || status === 'completed') && (
+
+ {status === 'completed' ? '100' : progress.toFixed(0)}%
+ {latest && latest.total_steps > 0 && {t('run.step')} {latest.current_step} {t('run.of')} {latest.total_steps} }
+ {latest && latest.total_epochs > 0 && {t('run.epoch')} {latest.current_epoch?.toFixed(1)} {t('run.of')} {latest.total_epochs?.toFixed(0)} }
+ {active && etaText(t, latest?.eta_seconds) && {etaText(t, latest.eta_seconds)} }
+ {latest?.extra_metrics?.tokens_per_second > 0 && {latest.extra_metrics.tokens_per_second.toFixed(0)} {t('run.tokens')} }
+
+ )}
+ {(active || latest) && (
+
+
+
+ )}
+ {active && (
+
+ )}
+ {!failed && message && {message}
}
+ {status === 'completed' && (
+
+ {events.length && events[events.length - 1].loss > 0
+ ? t('fineTune.finishedLoss', { loss: events[events.length - 1].loss.toFixed(4) })
+ : t('fineTune.finished')}
+
+ )}
+ {status === 'stopped' && {t('fineTune.stopped')}
}
+ >
+ )}
- )
- }
- if (checkpoints.length === 0) return null
- return (
-
- Checkpoints
-
-
-
-
- Step
- Epoch
- Loss
- Created
- Path
- Actions
-
-
-
- {checkpoints.map(cp => (
-
- {cp.step}
- {cp.epoch?.toFixed(2)}
- {cp.loss?.toFixed(4)}
- {cp.created_at}
-
- {cp.path}
-
-
-
-
- onResume(cp)} title="Resume training from this checkpoint">
- Resume
-
- onExportCheckpoint(cp)} title="Export this checkpoint">
- Export
-
-
-
-
- ))}
-
-
-
-
+ {failed && (
+
+ )}
+
+ {(active || events.length > 0) &&
}
+ {(active || log.length > 0) &&
}
+
+ {!active && checkpoints.length > 0 && (
+
+ {t('checkpoints.title')}
+
+
+ {t('checkpoints.title')}
+ {t('checkpoints.step')} {t('checkpoints.epoch')} {t('checkpoints.loss')} {t('checkpoints.path')} {t('checkpoints.actions')}
+
+ {checkpoints.map(cp => (
+
+ {cp.step} {cp.created_at}
+ {cp.epoch?.toFixed(2)}
+ {cp.loss?.toFixed(4)}
+ {cp.path}
+
+
+ onResume(cp)} title={t('checkpoints.resumeTitle')}> {t('checkpoints.resume')}
+ exportCheckpoint(cp)} title={t('checkpoints.exportTitle')}> {t('checkpoints.export')}
+
+
+
+ ))}
+
+
+
+
+ )}
+
+ {stopOpen && (
+
setStopOpen(false)}
+ testId="stop-dialog"
+ labelId="bt-stop-title"
+ foot={(
+ <>
+ setStopOpen(false)}>{t('stop.cancel')}
+ { setStopOpen(false); onStop(job.id, false) }}>{t('stop.discard')}
+ { setStopOpen(false); onStop(job.id, true) }}>{t('stop.keep')}
+ >
+ )}
+ />
+ )}
+
)
}
-const QUANT_PRESETS = ['q4_k_m', 'q5_k_m', 'q8_0', 'f16', 'q4_0', 'q5_0']
-
+// What to do with a finished job: export it as a model, then use it.
function ExportPanel({ job, prefilledCheckpoint }) {
+ const { t } = useTranslation('tools')
const [checkpoints, setCheckpoints] = useState([])
const [exportFormat, setExportFormat] = useState('lora')
const [quantMethod, setQuantMethod] = useState('q4_k_m')
@@ -500,67 +349,61 @@ function ExportPanel({ job, prefilledCheckpoint }) {
const [exporting, setExporting] = useState(false)
const [message, setMessage] = useState('')
const [exportedModelName, setExportedModelName] = useState('')
+ const [exportFailed, setExportFailed] = useState(false)
const pollRef = useRef(null)
useEffect(() => {
if (!job) return
- fineTuneApi.listCheckpoints(job.id).then(r => {
- setCheckpoints(r.checkpoints || [])
- }).catch(() => {})
+ fineTuneApi.listCheckpoints(job.id).then(r => setCheckpoints(r.checkpoints || [])).catch(() => {})
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the job id: the effect restarts per job, not per poll refresh
}, [job?.id])
- // Apply prefilled checkpoint when set
- useEffect(() => {
- if (prefilledCheckpoint) {
- setSelectedCheckpoint(prefilledCheckpoint.path || '')
- }
- }, [prefilledCheckpoint])
+ useEffect(() => { if (prefilledCheckpoint) setSelectedCheckpoint(prefilledCheckpoint.path || '') }, [prefilledCheckpoint])
- // Sync export state from job (e.g. on initial load or job list refresh)
+ // Sync export state from the job record (first load, list refresh).
useEffect(() => {
if (!job) return
if (job.export_status === 'exporting') {
- setExporting(true)
- setMessage(job.export_message || 'Export in progress...')
+ setExporting(true); setExportFailed(false)
+ setMessage(job.export_message || t('export.working'))
} else if (job.export_status === 'completed' && job.export_model_name) {
- setExporting(false)
+ setExporting(false); setExportFailed(false)
setExportedModelName(job.export_model_name)
- setMessage(`Model exported and registered as "${job.export_model_name}"`)
+ setMessage(t('export.done', { name: job.export_model_name }))
} else if (job.export_status === 'failed') {
- setExporting(false)
- setMessage(`Export failed: ${job.export_message || 'unknown error'}`)
+ setExporting(false); setExportFailed(true)
+ setMessage(t('export.failed', { message: job.export_message || t('export.unknown') }))
}
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the job id: the effect restarts per job, not per poll refresh
}, [job?.export_status, job?.export_model_name, job?.export_message])
- // Poll for export completion
useEffect(() => {
- if (!exporting || !job) return
-
+ if (!exporting || !job) return undefined
pollRef.current = setInterval(async () => {
try {
const updated = await fineTuneApi.getJob(job.id)
if (updated.export_status === 'completed') {
- setExporting(false)
+ setExporting(false); setExportFailed(false)
const name = updated.export_model_name || modelName || 'exported model'
setExportedModelName(name)
- setMessage(`Model exported and registered as "${name}"`)
+ setMessage(t('export.done', { name }))
clearInterval(pollRef.current)
} else if (updated.export_status === 'failed') {
- setExporting(false)
- setMessage(`Export failed: ${updated.export_message || 'unknown error'}`)
+ setExporting(false); setExportFailed(true)
+ setMessage(t('export.failed', { message: updated.export_message || t('export.unknown') }))
clearInterval(pollRef.current)
} else if (updated.export_status === 'exporting' && updated.export_message) {
setMessage(updated.export_message)
}
- } catch (_) {}
+ } catch (_) { /* try again at the next tick */ }
}, 3000)
-
return () => clearInterval(pollRef.current)
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the job id: the effect restarts per job, not per poll refresh
}, [exporting, job?.id])
const handleExport = async () => {
- setExporting(true)
- setMessage('Export in progress...')
+ setExporting(true); setExportFailed(false)
+ setMessage(t('export.working'))
setExportedModelName('')
try {
await fineTuneApi.exportModel(job.id, {
@@ -570,120 +413,96 @@ function ExportPanel({ job, prefilledCheckpoint }) {
quantization_method: exportFormat === 'gguf' ? quantMethod : '',
model: job.model,
})
- // Polling will pick up completion/failure
} catch (e) {
- setMessage(`Export failed: ${e.message}`)
- setExporting(false)
+ setMessage(t('export.failed', { message: e.message }))
+ setExportFailed(true); setExporting(false)
}
}
- // Show export panel for completed, stopped, and failed jobs (checkpoints may exist)
- if (!job || !TERMINAL_STATUSES.includes(job.status)) return null
-
- const failed = message.includes('failed')
+ if (!job || !TERMINAL.includes(job.status)) return null
return (
-
- Export model
-
-
- {checkpoints.length > 0 && (
+ <>
+ {exportedModelName && !exportFailed && (
+
+
- Checkpoint
- setSelectedCheckpoint(e.target.value)} className="input">
- Final model (output directory)
- {checkpoints.map(cp => (
-
- Step {cp.step} (loss: {cp.loss?.toFixed(4)})
-
- ))}
-
-
- )}
-
-
-
- Export format
- setExportFormat(e.target.value)} className="input">
- LoRA adapter
- Merged (16-bit)
- Merged (4-bit)
- GGUF
-
-
- {exportFormat === 'gguf' && (
-
-
Quantization
-
setQuantMethod(e.target.value)}
- placeholder="e.g. q4_k_m, bf16, f32"
- className="input"
- />
-
- {QUANT_PRESETS.map(q => )}
-
+
{t('export.nextTitle', { name: exportedModelName })}
+
{t('export.nextText')}
+
+
+
+ )}
+
+ {t('export.title')}
+ {t('export.lede')}
+
+ {checkpoints.length > 0 && (
+
+ setSelectedCheckpoint(e.target.value)}>
+ {t('export.final')}
+ {checkpoints.map(cp => {t('export.checkpointOption', { step: cp.step, loss: cp.loss?.toFixed(4) })} )}
+
+
)}
-
-
-
-
Model name
-
setModelName(e.target.value)}
- placeholder="e.g. my-finetuned-model"
- className="input"
- />
-
Leave blank to generate one from the job.
-
-
-
-
- {exporting
- ? <> Exporting...>
- : <> Export>}
-
-
-
- {message && (
-
- {exporting &&
} {message}
- {exportedModelName && !failed && (
-
-
- Chat with {exportedModelName}
-
-
- Download archive
-
-
+
+
+ setExportFormat(e.target.value)}>
+ {t('export.lora')}
+ {t('export.merged16')}
+ {t('export.merged4')}
+ GGUF
+
+
+ {exportFormat === 'gguf' && (
+
+ setQuantMethod(e.target.value)} placeholder="q4_k_m, bf16, f32" />
+ {QUANT_PRESETS.map(q => )}
+
)}
- )}
-
-
+
+ setModelName(e.target.value)} placeholder="my-finetuned-model" />
+
+
+
+ {exporting ? <> {t('export.exporting')}> : <> {t('export.go')}>}
+
+
+ {message && (!exportedModelName || exportFailed) && (
+
+ {exporting && } {message}
+
+ )}
+
+
+ >
)
}
export default function FineTune() {
+ const { t } = useTranslation('tools')
+ const { isAdmin } = useAuth()
+ const { resources } = useResources(10000)
+ const facts = useMemo(() => machineFacts(resources), [resources])
+
const [jobs, setJobs] = useState([])
+ const [jobsLoaded, setJobsLoaded] = useState(false)
const [selectedJob, setSelectedJob] = useState(null)
- const [showForm, setShowForm] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
- const [backends, setBackends] = useState([])
+ const [backends, setBackends] = useState(null)
const [exportCheckpoint, setExportCheckpoint] = useState(null)
- // Baseline of the assembled config for the unsaved-changes guard.
+ const [deleting, setDeleting] = useState(null)
const initialConfigRef = useRef(null)
// Form state
const [model, setModel] = useState('')
- const [backend, setBackend] = useState('')
+ const [backend, setBackend] = useState(FALLBACK_BACKEND)
const [trainingMethod, setTrainingMethod] = useState('sft')
const [trainingType, setTrainingType] = useState('lora')
const [datasetSource, setDatasetSource] = useState('')
@@ -708,7 +527,6 @@ export default function FineTune() {
const [seed, setSeed] = useState(0)
const [mixedPrecision, setMixedPrecision] = useState('')
const [extraOptions, setExtraOptions] = useState([])
- // liquid-audio specific knobs (folded into extra_options on submit)
const [liquidAudioVoice, setLiquidAudioVoice] = useState('')
const [liquidAudioValDataset, setLiquidAudioValDataset] = useState('')
const [hfToken, setHfToken] = useState('')
@@ -721,7 +539,7 @@ export default function FineTune() {
const [evalSplit, setEvalSplit] = useState('')
const [evalDatasetSource, setEvalDatasetSource] = useState('')
const [evalSplitRatio, setEvalSplitRatio] = useState(0.1)
- const [rewardFunctions, setRewardFunctions] = useState([]) // [{type, name, code?, params?}]
+ const [rewardFunctions, setRewardFunctions] = useState([])
const [showAddCustomReward, setShowAddCustomReward] = useState(false)
const [customRewardName, setCustomRewardName] = useState('')
const [customRewardCode, setCustomRewardCode] = useState('')
@@ -729,8 +547,16 @@ export default function FineTune() {
const loadJobs = useCallback(async () => {
try {
const data = await fineTuneApi.listJobs()
- setJobs(data || [])
- } catch (_) {}
+ const list = data || []
+ setJobs(list)
+ setSelectedJob(prev => {
+ if (!prev) return prev
+ const fresh = list.find(j => j.id === prev.id)
+ return fresh ? { ...prev, ...fresh } : prev
+ })
+ } catch (_) { /* the next poll tries again */ } finally {
+ setJobsLoaded(true)
+ }
}, [])
useEffect(() => {
@@ -742,28 +568,26 @@ export default function FineTune() {
useEffect(() => {
fineTuneApi.listBackends()
.then(data => {
- const names = data && data.length > 0 ? data.map(b => b.name) : FALLBACK_BACKENDS
- setBackends(names)
- setBackend(prev => prev || names[0] || '')
- })
- .catch(() => {
- setBackends(FALLBACK_BACKENDS)
- setBackend(prev => prev || FALLBACK_BACKENDS[0])
+ const list = Array.isArray(data) ? data : []
+ setBackends(list)
+ if (list.length > 0) setBackend(prev => (list.some(b => b.name === prev) ? prev : list[0].name))
})
+ .catch(() => setBackends(null))
}, [])
+ const backendNames = backends && backends.length > 0 ? backends.map(b => b.name) : [FALLBACK_BACKEND]
+ const isAdapter = ADAPTER_KINDS.includes(trainingType)
+
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
setError('')
-
try {
let dsSource = datasetSource
if (datasetFile) {
const result = await fineTuneApi.uploadDataset(datasetFile)
dsSource = result.path
}
-
const extra = {}
if (maxSeqLength) extra.max_seq_length = String(maxSeqLength)
if (hfToken.trim()) extra.hf_token = hfToken.trim()
@@ -777,18 +601,13 @@ export default function FineTune() {
} else {
extra.eval_strategy = 'no'
}
- for (const { key, value } of extraOptions) {
- if (key.trim()) extra[key.trim()] = value
- }
- // Fold liquid-audio specific fields into extra_options. The Python
- // backend reads `voice` and `val_dataset` directly from there.
+ for (const { key, value } of extraOptions) if (key.trim()) extra[key.trim()] = value
+ // The Python backend reads `voice` and `val_dataset` from extra_options.
if (backend === 'liquid-audio') {
if (liquidAudioVoice) extra.voice = liquidAudioVoice
if (liquidAudioValDataset.trim()) extra.val_dataset = liquidAudioValDataset.trim()
}
- const isAdapterType = ['lora', 'loha', 'lokr'].includes(trainingType)
-
const req = {
model,
backend,
@@ -799,10 +618,10 @@ export default function FineTune() {
num_epochs: numEpochs,
batch_size: batchSize,
learning_rate: learningRate,
- adapter_rank: isAdapterType ? adapterRank : 0,
- adapter_alpha: isAdapterType ? adapterAlpha : 0,
- adapter_dropout: isAdapterType && adapterDropout > 0 ? adapterDropout : undefined,
- target_modules: isAdapterType && targetModules.trim() ? targetModules.split(',').map(s => s.trim()) : undefined,
+ adapter_rank: isAdapter ? adapterRank : 0,
+ adapter_alpha: isAdapter ? adapterAlpha : 0,
+ adapter_dropout: isAdapter && adapterDropout > 0 ? adapterDropout : undefined,
+ target_modules: isAdapter && targetModules.trim() ? targetModules.split(',').map(s => s.trim()) : undefined,
gradient_accumulation_steps: gradAccum,
warmup_steps: warmupSteps,
max_steps: maxSteps > 0 ? maxSteps : undefined,
@@ -818,31 +637,29 @@ export default function FineTune() {
}
const resp = await fineTuneApi.startJob(req)
- setShowForm(false)
setResumeFromCheckpoint('')
// Job submitted: rebaseline so leaving the page no longer warns.
initialConfigRef.current = JSON.stringify(getFormConfig())
await loadJobs()
-
- const newJob = { ...req, id: resp.id, status: 'queued', created_at: new Date().toISOString() }
- setSelectedJob(newJob)
+ setSelectedJob({ ...req, id: resp.id, status: 'queued', created_at: new Date().toISOString() })
} catch (err) {
setError(err.message)
}
setLoading(false)
}
- const handleStop = async (jobId) => {
+ const handleStop = async (jobId, saveCheckpoint = true) => {
try {
- await fineTuneApi.stopJob(jobId, true)
+ await fineTuneApi.stopJob(jobId, saveCheckpoint)
await loadJobs()
} catch (err) {
setError(err.message)
}
}
- const handleDelete = async (jobId) => {
- if (!window.confirm('Delete this job and all its data (checkpoints, exported model)? This cannot be undone.')) return
+ const confirmDelete = async () => {
+ const jobId = deleting
+ setDeleting(null)
try {
await fineTuneApi.deleteJob(jobId)
if (selectedJob?.id === jobId) setSelectedJob(null)
@@ -852,20 +669,15 @@ export default function FineTune() {
}
}
- const isAdapter = ['lora', 'loha', 'lokr'].includes(trainingType)
-
const getFormConfig = () => {
const extra = {}
- for (const { key, value } of extraOptions) {
- if (key.trim()) extra[key.trim()] = value
- }
+ for (const { key, value } of extraOptions) if (key.trim()) extra[key.trim()] = value
if (backend === 'liquid-audio') {
if (liquidAudioVoice) extra.voice = liquidAudioVoice
if (liquidAudioValDataset.trim()) extra.val_dataset = liquidAudioValDataset.trim()
}
return {
- model,
- backend,
+ model, backend,
training_method: trainingMethod,
training_type: trainingType,
adapter_rank: adapterRank,
@@ -883,8 +695,7 @@ export default function FineTune() {
save_steps: saveSteps,
weight_decay: weightDecay,
gradient_checkpointing: gradCheckpointing,
- optimizer,
- seed,
+ optimizer, seed,
mixed_precision: mixedPrecision,
max_seq_length: maxSeqLength,
eval_strategy: evalEnabled ? (evalStrategy || 'steps') : 'no',
@@ -906,10 +717,7 @@ export default function FineTune() {
if (config.adapter_alpha != null) setAdapterAlpha(Number(config.adapter_alpha))
if (config.adapter_dropout != null) setAdapterDropout(Number(config.adapter_dropout))
if (config.target_modules != null) {
- const modules = Array.isArray(config.target_modules)
- ? config.target_modules.join(', ')
- : String(config.target_modules)
- setTargetModules(modules)
+ setTargetModules(Array.isArray(config.target_modules) ? config.target_modules.join(', ') : String(config.target_modules))
}
if (config.dataset_source != null) setDatasetSource(config.dataset_source)
if (config.dataset_split != null) setDatasetSplit(config.dataset_split)
@@ -926,14 +734,14 @@ export default function FineTune() {
if (config.seed != null) setSeed(Number(config.seed))
if (config.mixed_precision != null) setMixedPrecision(config.mixed_precision)
- // Handle max_seq_length: top-level field or inside extra_options
+ // max_seq_length is a top-level field or sits inside extra_options.
if (config.max_seq_length != null) {
setMaxSeqLength(Number(config.max_seq_length))
} else if (config.extra_options?.max_seq_length != null) {
setMaxSeqLength(Number(config.extra_options.max_seq_length))
}
- // Eval options — detect enabled state from strategy
+ // Eval options: the strategy tells whether evaluation was on.
const restoreEval = (strategy, steps, split, src, ratio) => {
if (strategy != null && strategy !== 'no') {
setEvalEnabled(true)
@@ -947,41 +755,25 @@ export default function FineTune() {
if (ratio != null) setEvalSplitRatio(Number(ratio))
}
restoreEval(config.eval_strategy, config.eval_steps, config.eval_split, config.eval_dataset_source, config.eval_split_ratio)
- // Also restore from extra_options if present (overrides top-level)
+ // extra_options overrides the top level when it carries them.
const eo = config.extra_options
if (eo) restoreEval(eo.eval_strategy, eo.eval_steps, eo.eval_split, eo.eval_dataset_source, eo.eval_split_ratio)
+ if (eo?.save_total_limit != null) setSaveTotalLimit(Number(eo.save_total_limit))
- // Handle save_total_limit from extra_options
- if (config.extra_options?.save_total_limit != null) {
- setSaveTotalLimit(Number(config.extra_options.save_total_limit))
- }
+ // liquid-audio extras; they are also kept out of the free-form list below.
+ if (eo?.voice != null) setLiquidAudioVoice(String(eo.voice))
+ if (eo?.val_dataset != null) setLiquidAudioValDataset(String(eo.val_dataset))
- // Restore liquid-audio specific extras (also filtered out of the
- // freeform list below).
- if (config.extra_options?.voice != null) setLiquidAudioVoice(String(config.extra_options.voice))
- if (config.extra_options?.val_dataset != null) setLiquidAudioValDataset(String(config.extra_options.val_dataset))
-
- // Convert extra_options object to [{key, value}] entries, filtering out handled keys
if (config.extra_options && typeof config.extra_options === 'object') {
- const entries = Object.entries(config.extra_options)
- .filter(([k]) => !['max_seq_length', 'save_total_limit', 'hf_token', 'eval_strategy', 'eval_steps', 'eval_split', 'eval_dataset_source', 'eval_split_ratio', 'voice', 'val_dataset'].includes(k))
- .map(([key, value]) => ({ key, value: String(value) }))
- setExtraOptions(entries)
- }
-
- // Restore reward functions
- if (Array.isArray(config.reward_functions)) {
- setRewardFunctions(config.reward_functions)
- } else {
- setRewardFunctions([])
+ setExtraOptions(Object.entries(config.extra_options)
+ .filter(([k]) => !EXTRA_HANDLED.includes(k))
+ .map(([key, value]) => ({ key, value: String(value) })))
}
+ setRewardFunctions(Array.isArray(config.reward_functions) ? config.reward_functions : [])
}
const handleExportConfig = () => {
- const config = getFormConfig()
- const json = JSON.stringify(config, null, 2)
- const blob = new Blob([json], { type: 'application/json' })
- const url = URL.createObjectURL(blob)
+ const url = URL.createObjectURL(new Blob([JSON.stringify(getFormConfig(), null, 2)], { type: 'application/json' }))
const a = document.createElement('a')
a.href = url
a.download = 'finetune-config.json'
@@ -1001,12 +793,11 @@ export default function FineTune() {
const reader = new FileReader()
reader.onload = (ev) => {
try {
- const config = JSON.parse(ev.target.result)
- applyFormConfig(config)
- setShowForm(true)
+ applyFormConfig(JSON.parse(ev.target.result))
+ setSelectedJob(null)
setError('')
} catch {
- setError('Failed to parse config file. Please ensure it is valid JSON.')
+ setError(t('fineTune.badConfig'))
}
}
reader.readAsText(file)
@@ -1014,529 +805,492 @@ export default function FineTune() {
input.click()
}
- const handleUseConfig = (job) => {
- // Prefer the stored config if available, otherwise use the job fields
+ // Put a job's setup back in the form. `fixes` are the changes ticked on a
+ // memory failure: they are applied to this copy and nothing starts until the
+ // person presses Start.
+ const handleUseConfig = (job, fixes) => {
applyFormConfig(job.config || job)
setResumeFromCheckpoint('')
- setShowForm(true)
+ if (fixes?.batch) setBatchSize(1)
+ if (fixes?.checkpointing) setGradCheckpointing(true)
+ setSelectedJob(null)
}
const handleResumeFromCheckpoint = (checkpoint) => {
if (!selectedJob) return
- // Apply the original job's config
applyFormConfig(selectedJob.config || selectedJob)
setResumeFromCheckpoint(checkpoint.path)
setShowAdvanced(true)
- setShowForm(true)
+ setSelectedJob(null)
}
- const handleExportCheckpoint = (checkpoint) => {
- setExportCheckpoint(checkpoint)
- }
-
- // Lazy-init the baseline on first render; dirty when the open form diverges.
+ // Baseline for the unsaved-changes guard: lazily taken on first render.
if (initialConfigRef.current === null) initialConfigRef.current = JSON.stringify(getFormConfig())
const dirty = JSON.stringify(getFormConfig()) !== initialConfigRef.current
+ const checks = useMemo(() => fineTuneChecks({
+ form: {
+ model, datasetSource, datasetFileName: datasetFile?.name || '', backend, batchSize, maxSeqLength, gradAccum,
+ gradCheckpointing, trainingType, trainingMethod, rewardCount: rewardFunctions.length,
+ resumeFrom: resumeFromCheckpoint, hfToken: hfToken.trim(),
+ },
+ facts,
+ backends: { fineTune: backends },
+ }), [model, datasetSource, datasetFile, backend, batchSize, maxSeqLength, gradAccum, gradCheckpointing, trainingType, trainingMethod, rewardFunctions.length, resumeFromCheckpoint, hfToken, facts, backends])
+ const stop = blocked(checks)
+ const warnings = warned(checks)
+ const firstMissing = checks.find(c => c.tone === 'fail')
+
+ const steps = [
+ { key: 'setup', label: t('steps.setup') },
+ { key: 'check', label: t('steps.check') },
+ { key: 'run', label: t('steps.run') },
+ { key: 'result', label: t('steps.result') },
+ ]
+ let current = stop ? 0 : 1
+ let failed = false
+ if (selectedJob) {
+ if (selectedJob.status === 'failed') { current = 2; failed = true } else if (TERMINAL.includes(selectedJob.status)) current = 3
+ else current = 2
+ }
+
+ const kindWords = trainingType === 'full' ? t('form.summaryFull') : t(`form.summary_${trainingType}`)
+ const modelShort = model.split('/').pop() || model
+
return (
-
-
+
+
Fine-tuning Experimental >}
- supporting="Create and manage fine-tuning jobs"
- actions={
-
-
- Import config
-
-
setShowForm(!showForm)}>
-
- {showForm ? 'Cancel' : 'New job'}
+ title={<>{t('fineTune.title')} {t('landing.experimental')} >}
+ supporting={t('fineTune.lede')}
+ actions={(
+
+ {selectedJob && (
+ setSelectedJob(null)} data-testid="job-back">
+ {t('fineTune.newJob')}
+
+ )}
+
+ {t('fineTune.importConfig')}
- }
+ )}
/>
+
{error && (
-
-
{error}
+
)}
- {showForm && (
-
-
+ {selectedJob ? (
+ <>
+
+
+ >
+ ) : (
+
{resumeFromCheckpoint && (
-
-
-
Resuming from checkpoint: {resumeFromCheckpoint}
-
setResumeFromCheckpoint('')}>
- Clear
-
+
+
+ {t('fineTune.resuming')} {resumeFromCheckpoint}
+ setResumeFromCheckpoint('')}> {t('fineTune.clear')}
)}
-
-
-
- Backend
- setBackend(e.target.value)} className="input">
- {backends.length === 0 ? (
- No backends available
- ) : (
- backends.map(b => {b} )
- )}
-
-
-
- Training method
- setTrainingMethod(e.target.value)} className="input">
- {TRAINING_METHODS.map(m => {m.toUpperCase()} )}
-
-
-
-
Model
-
setModel(e.target.value)} placeholder="e.g. TinyLlama/TinyLlama-1.1B-Chat-v1.0" className="input" required />
-
A HuggingFace ID or a local path.
-
+
+
+
+
+
+ setDatasetSource(e.target.value)} placeholder="tatsu-lab/alpaca" />
+
+
+ setDatasetSplit(e.target.value)} placeholder="train" />
+
-
-
HuggingFace token
-
setHfToken(e.target.value)} placeholder="hf_..." className="input" />
-
Only needed for gated models.
-
-
-
-
-
-
- Training type
- setTrainingType(e.target.value)} className="input">
- {TRAINING_TYPES.map(t => {t} )}
-
-
- {isAdapter && (
- <>
-
- Rank
- setAdapterRank(Number(e.target.value))} className="input" min={1} />
-
-
- Alpha
- setAdapterAlpha(Number(e.target.value))} className="input" min={1} />
-
-
- Dropout
- setAdapterDropout(Number(e.target.value))} className="input" min={0} max={1} step={0.05} />
-
- >
- )}
-
- {isAdapter && (
-
-
Target modules
-
setTargetModules(e.target.value)} placeholder="e.g. q_proj, v_proj, k_proj, o_proj" className="input" />
-
Comma-separated. Leave blank for the backend default.
-
- )}
-
-
-
-
-
-
Source
-
setDatasetSource(e.target.value)} placeholder="e.g. tatsu-lab/alpaca" className="input" />
-
A HuggingFace ID, or leave blank and upload a file.
-
-
- Split
- setDatasetSplit(e.target.value)} placeholder="e.g. train" className="input" />
-
-
- Upload file
- setDatasetFile(e.target.files[0])} accept=".json,.jsonl,.csv" className="input input--file" />
-
-
-
-
- {trainingMethod === 'grpo' && (
-
-
- GRPO requires at least one reward function. Select built-in functions or add custom ones.
-
-
-
- {BUILTIN_REWARDS.map(builtin => {
- const isSelected = rewardFunctions.some(rf => rf.type === 'builtin' && rf.name === builtin.name)
- const selectedRf = rewardFunctions.find(rf => rf.type === 'builtin' && rf.name === builtin.name)
- return (
-
-
- {
- if (e.target.checked) {
- setRewardFunctions(prev => [...prev, { type: 'builtin', name: builtin.name }])
- } else {
- setRewardFunctions(prev => prev.filter(rf => !(rf.type === 'builtin' && rf.name === builtin.name)))
- }
- }}
- />
-
- {builtin.name}
- {builtin.description}
-
-
- {isSelected && builtin.params.length > 0 && (
-
- {builtin.params.map(param => (
-
-
- {param.label}
-
- {
- setRewardFunctions(prev => prev.map(rf =>
- rf.type === 'builtin' && rf.name === builtin.name
- ? { ...rf, params: { ...(rf.params || {}), [param.key]: e.target.value } }
- : rf
- ))
- }}
- />
-
- ))}
-
- )}
-
- )
- })}
-
-
- {rewardFunctions.filter(rf => rf.type === 'inline').map((rf, idx) => (
-
-
-
- {rf.name}
-
- setRewardFunctions(prev => prev.filter((_, i) => i !== rewardFunctions.indexOf(rf)))}
- >
-
-
-
-
{rf.code}
-
- ))}
-
- {showAddCustomReward ? (
-
-
- Function name
- setCustomRewardName(e.target.value)} placeholder="e.g. my_custom_reward" />
-
-
-
Function body
-
setCustomRewardCode(e.target.value)}
- placeholder={"return [1.0 if '' in c else 0.0 for c in completions]"}
- rows={4}
- />
-
- Receives completions, **kwargs and must return list[float].
- Available: re, math, json, string.
-
-
-
- {
- setRewardFunctions(prev => [...prev, {
- type: 'inline',
- name: customRewardName.trim(),
- code: customRewardCode,
- }])
- setCustomRewardName('')
- setCustomRewardCode('')
- setShowAddCustomReward(false)
- }}
- >
- Add
-
- { setShowAddCustomReward(false); setCustomRewardName(''); setCustomRewardCode('') }}
- >
- Cancel
-
-
-
+
+ setDatasetFile(e.target.files[0] || null)} accept=".json,.jsonl,.csv" />
+ {t('form.upload')}
+ {datasetFile ? (
+
+ {datasetFile.name}
+ setDatasetFile(null)}>
+
) : (
- setShowAddCustomReward(true)}>
- Add custom reward function
-
+ {t('form.uploadHint')}
)}
-
- )}
+
+
-
-
-
- Epochs
- setNumEpochs(Number(e.target.value))} className="input" min={1} />
-
-
- Batch size
- setBatchSize(Number(e.target.value))} className="input" min={1} />
-
-
-
-
- setShowAdvanced(!showAdvanced)}
- aria-expanded={showAdvanced}
- >
-
-
- Advanced options
-
+
+
setShowAdvanced(!showAdvanced)} aria-expanded={showAdvanced} aria-controls="ft-more" data-testid="ft-more-toggle">
+
+ {t('form.more')}
+ {!showAdvanced && {t('form.moreHint')} }
+
+ {showAdvanced && (
+
+
+
+ setBackend(e.target.value)}>
+ {backendNames.map(b => {b} )}
+
+
+
+ setTrainingMethod(e.target.value)}>
+ {TRAINING_METHODS.map(m => {m.toUpperCase()} )}
+
+
+ {isAdapter && (
+
+ setTrainingType(e.target.value)}>
+ {ADAPTER_KINDS.map(k => {k} )}
+
+
+ )}
+ {isAdapter && (
+ <>
+
+ setAdapterRank(Number(e.target.value))} min={1} />
+
+
+ setAdapterAlpha(Number(e.target.value))} min={1} />
+
+
+ setAdapterDropout(Number(e.target.value))} min={0} max={1} step={0.05} />
+
+ >
+ )}
+
+ setGradAccum(Number(e.target.value))} min={1} />
+
+
+ setWarmupSteps(Number(e.target.value))} min={0} />
+
+
+ setMaxSeqLength(Number(e.target.value))} min={64} />
+
+
+ setOptimizer(e.target.value)}>
+ {OPTIMIZERS.map(o => {o} )}
+
+
+
+ setMaxSteps(Number(e.target.value))} min={0} />
+
+
+ setSaveSteps(Number(e.target.value))} min={0} />
+
+
+ setSaveTotalLimit(Number(e.target.value))} min={0} />
+
+
+ setWeightDecay(Number(e.target.value))} min={0} step={0.01} />
+
+
+ setSeed(Number(e.target.value))} min={0} />
+
+
+ setMixedPrecision(e.target.value)}>
+ {MIXED_PRECISION_OPTS.map(o => {o || t('form.auto')} )}
+
+
+
+ {isAdapter && (
+
+ setTargetModules(e.target.value)} placeholder="q_proj, v_proj, k_proj, o_proj" />
+
+ )}
+
setGradCheckpointing(e.target.checked)} /> {t('form.gradCheckpointing')}
+
+ setHfToken(e.target.value)} placeholder="hf_..." autoComplete="off" />
+
- {showAdvanced && (
-
-
-
-
Max steps
-
setMaxSteps(Number(e.target.value))} className="input" min={0} />
-
0 for automatic.
+
-
- Save steps
- setSaveSteps(Number(e.target.value))} className="input" min={0} />
-
-
-
Save total limit
-
setSaveTotalLimit(Number(e.target.value))} className="input" min={0} />
-
0 keeps every checkpoint.
-
-
- Weight decay
- setWeightDecay(Number(e.target.value))} className="input" min={0} step={0.01} />
-
-
-
Seed
-
setSeed(Number(e.target.value))} className="input" min={0} />
-
0 picks a random seed.
-
-
- Mixed precision
- setMixedPrecision(e.target.value)} className="input">
- {MIXED_PRECISION_OPTS.map(o => {o || 'Auto'} )}
-
-
-
-
-
-
- Enable evaluation
-
- {evalEnabled && (
-
-
-
Eval strategy
-
setEvalStrategy(e.target.value)} className="input">
- Steps
- Epoch
-
+ {trainingMethod === 'grpo' && (
+
+
{t('form.rewards')}
+
{t('form.rewardsHint')}
+ {BUILTIN_REWARDS.map(builtin => {
+ const selected = rewardFunctions.find(rf => rf.type === 'builtin' && rf.name === builtin.name)
+ return (
+
+
+ {
+ if (e.target.checked) setRewardFunctions(prev => [...prev, { type: 'builtin', name: builtin.name }])
+ else setRewardFunctions(prev => prev.filter(rf => !(rf.type === 'builtin' && rf.name === builtin.name)))
+ }}
+ />
+ {builtin.name} {t(`rewards.${builtin.name}`)}
+
+ {selected && builtin.params.map(param => (
+
+ setRewardFunctions(prev => prev.map(rf => (rf.type === 'builtin' && rf.name === builtin.name ? { ...rf, params: { ...(rf.params || {}), [param.key]: e.target.value } } : rf)))}
+ />
+
+ ))}
+
+ )
+ })}
+ {rewardFunctions.filter(rf => rf.type === 'inline').map((rf, idx) => (
+
+
+ {rf.name}
+ setRewardFunctions(prev => prev.filter(x => x !== rf))}>
+
+
{rf.code}
+
+ ))}
+ {showAddCustomReward ? (
+
+
+ setCustomRewardName(e.target.value)} placeholder="my_custom_reward" />
+
+
+ setCustomRewardCode(e.target.value)} placeholder={"return [1.0 if '' in c else 0.0 for c in completions]"} rows={4} />
+
+
+ {
+ setRewardFunctions(prev => [...prev, { type: 'inline', name: customRewardName.trim(), code: customRewardCode }])
+ setCustomRewardName(''); setCustomRewardCode(''); setShowAddCustomReward(false)
+ }}
+ > {t('rewards.add')}
+ { setShowAddCustomReward(false); setCustomRewardName(''); setCustomRewardCode('') }}>{t('rewards.cancel')}
+
+
+ ) : (
+
setShowAddCustomReward(true)}> {t('rewards.custom')}
+ )}
+
+ )}
+
+ {resumeFromCheckpoint && (
+
+
+ setResumeFromCheckpoint(e.target.value)} />
+ setResumeFromCheckpoint('')} aria-label={t('form.clearCheckpoint')}>
-
-
Eval steps
-
setEvalSteps(Number(e.target.value))} className="input" min={0} />
-
0 matches save steps.
-
-
- Eval split
- setEvalSplit(e.target.value)} placeholder="e.g. validation" className="input" />
-
-
- Eval dataset source
- setEvalDatasetSource(e.target.value)} placeholder="Separate HF dataset" className="input" />
-
-
-
Auto-split ratio
-
setEvalSplitRatio(Number(e.target.value))} className="input" min={0.01} max={0.5} step={0.01} />
+
+ )}
+
+ {backend === 'liquid-audio' && (
+
+
Liquid Audio
+
{t('form.liquidHint')}
+
+
+ setLiquidAudioVoice(e.target.value)}>
+ {t('form.liquidInherit')}
+ {['us_male', 'us_female', 'uk_male', 'uk_female'].map(v => {v} )}
+
+
+
+ setLiquidAudioValDataset(e.target.value)} placeholder="/data/jenny_tts/val" />
+
)}
-
- {resumeFromCheckpoint && (
-
Resume from checkpoint
-
- setResumeFromCheckpoint(e.target.value)} className="input ft-kv__key" />
- setResumeFromCheckpoint('')} aria-label="Clear checkpoint">
-
-
-
+
{t('form.extra')}
+
{t('form.extraHint')}
+
- )}
-
- {backend === 'liquid-audio' && (
-
-
Liquid Audio
-
- Dataset must be preprocessed by LFM2AudioChatMapper (a directory of
- LFM2DataLoader-ready arrow files). See liquid_audio/examples/preprocess_jenny_tts.py
- {' '}for the conversion recipe.
-
-
-
- TTS voice
- setLiquidAudioVoice(e.target.value)} className="input">
- Inherit from system prompt
- us_male
- us_female
- uk_male
- uk_female
-
-
-
- Validation dataset
- setLiquidAudioValDataset(e.target.value)} placeholder="e.g. /data/jenny_tts/val" className="input" />
-
-
-
- )}
-
-
-
Extra options
-
Backend-specific key/value pairs.
-
-
- )}
+ )}
+
+
+
+
+ {model.trim() && (datasetSource.trim() || datasetFile)
+ ? t('form.recipe', { kind: kindWords, model: modelShort, method: trainingMethod.toUpperCase(), epochs: numEpochs, data: datasetFile?.name || datasetSource })
+ : t('form.recipeEmpty')}
+
+
+
+
+ {t('checks.heading')}
+ {t('checks.live')}
+
+
+
+
-
-
- {loading
- ? <> Starting...>
- : resumeFromCheckpoint
- ? <> Resume training>
- : <> Start fine-tuning>}
-
-
- Export config
-
+
+
+ {stop
+ ? t(`checks.${firstMissing.key}`)
+ : warnings > 0
+ ? t('checks.barWarn', { count: warnings })
+ : t('checks.barOk')}
+ {' '}{t('fineTune.barNote')}
+
+
+ {t('fineTune.exportConfig')}
+
+ {loading
+ ? <> {t('fineTune.starting')}>
+ : resumeFromCheckpoint
+ ? <> {t('fineTune.resume')}>
+ : <> {t('fineTune.start')}>}
+
+
)}
- {/* Either show job detail OR job list, not side-by-side */}
- {selectedJob ? (
-
-
-
setSelectedJob(null)}>
- Back to jobs
-
+
+
+ {jobs.length === 0 ? (
+
+
+
{jobsLoaded ? t('jobs.emptyTitle') : t('jobs.loading')}
+ {jobsLoaded &&
{t('jobs.emptyText')}
}
-
-
-
-
{selectedJob.model}
-
- {selectedJob.backend} / {selectedJob.training_method || 'sft'} | ID: {selectedJob.id?.slice(0, 8)}... | {selectedJob.created_at}
-
-
-
-
+ ) : (
+
+
+ {t('jobs.title')}
+
+
+ {t('jobs.model')}
+ {t('jobs.kind')}
+ {t('jobs.status')}
+ {t('jobs.started')}
+ {t('jobs.actions')}
+
+
+
+ {jobs.map(job => (
+
+
+ setSelectedJob(job)} title={t('jobs.open')}>
+ {job.model}
+
+ {job.backend} · {job.id?.slice(0, 8)}
+ {job.status === 'failed' && job.message && {job.message} }
+
+ {kindOf(job)}
+
+ {startedAt(job)}
+
+
+ handleUseConfig(job)} title={t('jobs.reuseTitle')}>{t('jobs.reuse')}
+ {TERMINAL.includes(job.status) && (
+ setDeleting(job.id)} aria-label={t('jobs.delete')} title={t('jobs.deleteTitle')}>
+ )}
+
+
+
+ ))}
+
+
-
-
-
-
- ) : (
-
-
Jobs
- {jobs.length === 0 ? (
-
setShowForm(true)}>
- New job
-
- }
- />
- ) : (
-
- {jobs.map(job => (
-
- ))}
-
+ )}
+
+
+ {deleting && (
+ setDeleting(null)}
+ testId="delete-dialog"
+ labelId="bt-delete-title"
+ foot={(
+ <>
+ setDeleting(null)}>{t('stop.cancelDelete')}
+ {t('jobs.delete')}
+ >
)}
-
+ />
)}
)
diff --git a/core/http/react-ui/src/pages/Home.jsx b/core/http/react-ui/src/pages/Home.jsx
index 621358266..22bbcc984 100644
--- a/core/http/react-ui/src/pages/Home.jsx
+++ b/core/http/react-ui/src/pages/Home.jsx
@@ -1,40 +1,73 @@
-import { useState, useEffect, useRef, useCallback } from 'react'
+import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { apiUrl } from '../utils/basePath'
import { useAuth } from '../context/AuthContext'
import { useBranding } from '../contexts/BrandingContext'
-import ModelSelector from '../components/ModelSelector'
import { CAP_CHAT } from '../utils/capabilities'
+// eslint-disable-next-line no-unused-vars
import UnifiedMCPDropdown from '../components/UnifiedMCPDropdown'
+// eslint-disable-next-line no-unused-vars
import ConfirmDialog from '../components/ConfirmDialog'
+// eslint-disable-next-line no-unused-vars
import HomeConnect from '../components/HomeConnect'
+// eslint-disable-next-line no-unused-vars
+import HomeComposer from '../components/home/HomeComposer'
+// eslint-disable-next-line no-unused-vars
+import HomeModelPicker from '../components/home/HomeModelPicker'
+// eslint-disable-next-line no-unused-vars
+import HomeMemoryStrip from '../components/home/HomeMemoryStrip'
+// eslint-disable-next-line no-unused-vars
+import HomeResume from '../components/home/HomeResume'
+// eslint-disable-next-line no-unused-vars
+import HomeFirstRun from '../components/home/HomeFirstRun'
+// eslint-disable-next-line no-unused-vars
+import HomeUndoToast from '../components/home/HomeUndoToast'
import { useResources } from '../hooks/useResources'
import { usePolling } from '../hooks/usePolling'
+import { useOperations } from '../hooks/useOperations'
import { fileToBase64, backendControlApi, systemApi, modelsApi, mcpApi, nodesApi } from '../utils/api'
import { readAttachmentText } from '../utils/pdf'
-import { API_CONFIG } from '../utils/config'
import { greetingKey } from '../utils/greeting'
-import StatusPill from '../components/StatusPill'
+import {
+ listConversations, setActiveConversation, removeConversation,
+} from '../utils/homeConversations'
+// eslint-disable-next-line no-unused-vars
import Skeleton from '../components/Skeleton'
-import SectionHeading from '../components/SectionHeading'
-import EmptyState from '../components/EmptyState'
-import StarterModels from '../components/StarterModels'
import { staggerStyle } from '../hooks/useStagger'
+import Icon from '../components/Icon'
+
+const DOCS_URL = 'https://localai.io'
+const ASSISTANT_TIP_KEY = 'localai_assistant_tip_dismissed'
+// How long a row stays visible while it leaves, before the undo toast takes over.
+const LEAVE_MS = 180
+
+function isTyping(el) {
+ if (!el) return false
+ const tag = el.tagName
+ return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable
+}
+
+function reducedMotion() {
+ try { return window.matchMedia('(prefers-reduced-motion: reduce)').matches } catch { return false }
+}
export default function Home() {
const navigate = useNavigate()
const { addToast } = useOutletContext()
- const { t } = useTranslation('home')
+ const { t, i18n } = useTranslation('home')
const { isAdmin } = useAuth()
const branding = useBranding()
const { resources } = useResources()
+ const { operations } = useOperations()
const [configuredModels, setConfiguredModels] = useState(null)
+ const [modelsFailed, setModelsFailed] = useState(false)
const configuredModelsRef = useRef(configuredModels)
configuredModelsRef.current = configuredModels
const [loadedModels, setLoadedModels] = useState([])
const [selectedModel, setSelectedModel] = useState('')
const [message, setMessage] = useState('')
+ const [sending, setSending] = useState(false)
const [imageFiles, setImageFiles] = useState([])
const [audioFiles, setAudioFiles] = useState([])
const [textFiles, setTextFiles] = useState([])
@@ -46,19 +79,27 @@ export default function Home() {
const [mcpSelectedServers, setMcpSelectedServers] = useState([])
const [clientMCPSelectedIds, setClientMCPSelectedIds] = useState([])
const [assistantAvailable, setAssistantAvailable] = useState(false)
- // Progressive disclosure: the big "Manage by chatting" CTA card is a
- // first-run affordance. Once the admin has clicked it, we collapse to
- // a small entry in the quick-links row so the home page doesn't keep
- // shouting at them about a feature they already know.
+ // Progressive disclosure: the assistant line is a first-run affordance. Once
+ // the admin has used it, or dismissed the line, it moves into the Library row
+ // and the /assistant action.
const [assistantUsed, setAssistantUsed] = useState(() => {
try { return localStorage.getItem('localai_assistant_used') === '1' } catch { return false }
})
+ const [tipDismissed, setTipDismissed] = useState(() => {
+ try { return localStorage.getItem(ASSISTANT_TIP_KEY) === '1' } catch { return false }
+ })
const [confirmDialog, setConfirmDialog] = useState(null)
const [distributedMode, setDistributedMode] = useState(false)
const [clusterData, setClusterData] = useState(null)
- const imageInputRef = useRef(null)
- const audioInputRef = useRef(null)
- const fileInputRef = useRef(null)
+ const [stripOpen, setStripOpen] = useState(false)
+ const [conversations, setConversations] = useState(() => listConversations())
+ const [leavingId, setLeavingId] = useState(null)
+ const [pendingDelete, setPendingDelete] = useState(null)
+ const textareaRef = useRef(null)
+ const pickerRef = useRef(null)
+ const stripRef = useRef(null)
+ const pendingRef = useRef(null)
+ const leaveTimer = useRef(null)
// Detect distributed mode + assistant feature availability in one fetch.
useEffect(() => {
@@ -91,12 +132,24 @@ export default function Home() {
const isGPU = totalVRAM > 0
const healthyCount = backendNodes.filter(n => n.status === 'healthy').length
const totalCount = backendNodes.length
+ const perNode = backendNodes.map(n => {
+ const total = isGPU ? (n.total_vram || 0) : (n.total_ram || 0)
+ const avail = isGPU ? n.available_vram : n.available_ram
+ return {
+ id: n.id || n.name,
+ name: n.name || n.id,
+ total,
+ used: total && avail != null ? total - avail : 0,
+ healthy: n.status === 'healthy',
+ }
+ })
setClusterData({
totalMem: isGPU ? totalVRAM : totalRAM,
usedMem: isGPU ? usedVRAM : usedRAM,
isGPU,
healthyCount,
totalCount,
+ nodes: perNode,
})
} catch { setClusterData(null) }
}, [])
@@ -114,11 +167,15 @@ export default function Home() {
}
if (v1Models?.data) {
setConfiguredModels(v1Models.data)
+ setModelsFailed(false)
} else if (configuredModelsRef.current === null) {
- setConfiguredModels([])
+ // Nothing answered and there is nothing to show yet. An empty list
+ // would read as "no models installed" and send a working install to
+ // the first-run steps, so it is an error until a reply arrives.
+ setModelsFailed(true)
}
- } catch (_e) {
- if (configuredModelsRef.current === null) setConfiguredModels([])
+ } catch {
+ if (configuredModelsRef.current === null) setModelsFailed(true)
}
}, [])
@@ -151,7 +208,10 @@ export default function Home() {
return () => { cancelled = true }
}, [selectedModel])
- const allFiles = [...imageFiles, ...audioFiles, ...textFiles]
+ const allFiles = useMemo(
+ () => [...imageFiles, ...audioFiles, ...textFiles],
+ [imageFiles, audioFiles, textFiles],
+ )
const addFiles = useCallback(async (fileList, setter) => {
const newFiles = []
@@ -171,6 +231,10 @@ export default function Home() {
setter(prev => [...prev, ...newFiles])
}, [addToast, t])
+ const attach = useCallback((kind, files) => {
+ addFiles(files, kind === 'image' ? setImageFiles : kind === 'audio' ? setAudioFiles : setTextFiles)
+ }, [addFiles])
+
const removeFile = useCallback((file) => {
const removeFn = (prev) => prev.filter(f => f !== file)
if (file.type?.startsWith('image/')) setImageFiles(removeFn)
@@ -220,8 +284,9 @@ export default function Home() {
newChat: true,
}
localStorage.setItem('localai_index_chat_data', JSON.stringify(chatData))
+ setSending(true)
navigate(`/app/chat/${encodeURIComponent(selectedModel)}`)
- }, [message, allFiles, selectedModel, mcpMode, mcpSelectedServers, clientMCPSelectedIds, addToast, navigate])
+ }, [message, allFiles, selectedModel, mcpMode, mcpSelectedServers, clientMCPSelectedIds, addToast, navigate, t])
// Quick-launch: open a fresh chat already in assistant mode without
// requiring an initial message or model selection. Useful when an admin
@@ -239,10 +304,16 @@ export default function Home() {
navigate('/app/chat')
}, [navigate, selectedModel])
- const handleSubmit = (e) => {
- if (e) e.preventDefault()
- doSubmit()
- }
+ // An empty new chat: Chat opens a fresh conversation on the chosen model.
+ const openNewChat = useCallback(() => {
+ localStorage.setItem('localai_index_chat_data', JSON.stringify({ model: selectedModel || '', mcpMode: false, newChat: true }))
+ navigate('/app/chat')
+ }, [navigate, selectedModel])
+
+ const dismissTip = useCallback(() => {
+ try { localStorage.setItem(ASSISTANT_TIP_KEY, '1') } catch { /* ignore */ }
+ setTipDismissed(true)
+ }, [])
const handleStopModel = async (modelName) => {
setConfirmDialog({
@@ -282,311 +353,142 @@ export default function Home() {
})
}
- const modelsLoading = configuredModels === null
- const hasModels = modelsLoading || configuredModels.length > 0
+ // ----- Conversations -----------------------------------------------------
+
+ const refreshConversations = useCallback(() => setConversations(listConversations()), [])
+
+ // Another tab can write chats, and Chat writes them after a short delay. Read
+ // again when the tab changes or comes back into focus.
+ useEffect(() => {
+ const onStorage = (e) => { if (!e.key || e.key === 'localai_chats_data') refreshConversations() }
+ window.addEventListener('storage', onStorage)
+ window.addEventListener('focus', refreshConversations)
+ return () => {
+ window.removeEventListener('storage', onStorage)
+ window.removeEventListener('focus', refreshConversations)
+ }
+ }, [refreshConversations])
+
+ // A delete is final when its undo time ends. Until then the chat stays in
+ // storage and only the row is hidden.
+ const commitDelete = useCallback(() => {
+ const id = pendingRef.current
+ if (!id) return
+ pendingRef.current = null
+ removeConversation(id)
+ setPendingDelete(null)
+ refreshConversations()
+ }, [refreshConversations])
+
+ const commitRef = useRef(commitDelete)
+ commitRef.current = commitDelete
+ useEffect(() => () => {
+ clearTimeout(leaveTimer.current)
+ commitRef.current()
+ }, [])
+
+ const requestDelete = useCallback((conv) => {
+ commitDelete()
+ clearTimeout(leaveTimer.current)
+ const hide = () => {
+ pendingRef.current = conv.id
+ setLeavingId(null)
+ setPendingDelete({ id: conv.id, title: conv.title })
+ }
+ if (reducedMotion()) {
+ hide()
+ } else {
+ setLeavingId(conv.id)
+ leaveTimer.current = setTimeout(hide, LEAVE_MS)
+ }
+ }, [commitDelete])
+
+ const undoDelete = useCallback(() => {
+ pendingRef.current = null
+ setPendingDelete(null)
+ }, [])
+
+ const resumeConversation = useCallback((conv) => {
+ commitDelete()
+ if (setActiveConversation(conv.id)) navigate('/app/chat')
+ else refreshConversations()
+ }, [commitDelete, navigate, refreshConversations])
+
+ const visibleConversations = useMemo(
+ () => conversations.filter(c => c.id !== pendingDelete?.id),
+ [conversations, pendingDelete],
+ )
+
+ // ----- Derived state -----------------------------------------------------
+
+ const modelsLoading = configuredModels === null && !modelsFailed
+ const hasModels = configuredModels === null || configuredModels.length > 0
+ const firstRun = configuredModels !== null && configuredModels.length === 0
const loadedCount = loadedModels.length
+ const loadedIds = useMemo(() => new Set(loadedModels.map(m => m.id)), [loadedModels])
- // Resource display - folded into the editorial status line.
- const resType = resources?.type
- const usagePct = resources?.aggregate?.usage_percent ?? resources?.ram?.usage_percent ?? 0
+ // Staging a model onto a worker is the one load this page can see: the
+ // operations list reports it. An OOM on a single host surfaces in Chat.
+ const stagingOp = operations.find(op => op.taskType === 'staging' && !op.error && !op.isQueued && !op.isDeletion)
+ const failedOp = operations.find(op => op.taskType === 'staging' && op.error)
+ const stripBusy = !!stagingOp || !!failedOp
+ useEffect(() => { if (stripBusy) setStripOpen(true) }, [stripBusy])
- return (
-
- {hasModels ? (
- <>
- {/* Editorial header */}
-
-
- {branding.instanceName}
-
{t(`greeting.${greetingKey()}`)}
-
- {/* Telemetry as figures rather than chips. A chip says a thing is
- true; a figure says how much, which is what someone opening the
- page at a glance is actually after. */}
-
-
- 0 ? ' home-stat__value--ok' : ''}`}>{loadedCount}
- {t('statusLine.loadedLabel')}
-
- {distributedMode && clusterData && (
-
- {clusterData.healthyCount}/{clusterData.totalCount}
- {t('statusLine.nodesLabel')}
-
- )}
- {!distributedMode && resources && (
-
- {usagePct.toFixed(0)}%
-
- {resType === 'gpu' ? t('resourceGpu') : t('resourceRam')}
-
-
- )}
-
-
+ const slashContext = useMemo(
+ () => ({ isAdmin, assistantAvailable, hasModels: hasModels && !firstRun, loadedCount }),
+ [isAdmin, assistantAvailable, hasModels, firstRun, loadedCount],
+ )
- {/* LocalAI Assistant — prominent CTA on first run. Once the
- admin has used it, the big card collapses to a small entry in
- the quick-links row below. */}
- {isAdmin && assistantAvailable && !assistantUsed && (
-
-
-
- {t('assistant.title')}
- {t('assistant.description')}
-
-
- {t('assistant.open')}
-
-
- )}
+ const runAction = useCallback((id) => {
+ switch (id) {
+ case 'model': pickerRef.current?.open(); break
+ case 'new': openNewChat(); break
+ case 'chat': navigate('/app/chat'); break
+ case 'assistant': openAssistantChat(); break
+ case 'gallery': navigate('/app/models'); break
+ case 'installed': navigate('/app/models?view=installed'); break
+ case 'import': navigate('/app/import-model'); break
+ case 'stop':
+ setStripOpen(true)
+ stripRef.current?.scrollIntoView?.({ block: 'nearest', behavior: reducedMotion() ? 'auto' : 'smooth' })
+ break
+ case 'studio': navigate('/app/studio'); break
+ case 'settings': navigate('/app/settings'); break
+ case 'docs': window.open(DOCS_URL, '_blank', 'noopener,noreferrer'); break
+ default: break
+ }
+ }, [navigate, openNewChat, openAssistantChat])
- {/* Chat input form */}
-
-
- {/* Model selector + MCP toggle */}
-
-
- {
- const allNames = mcpServerList.filter(s => !s.error).map(s => s.name)
- const allSelected = allNames.every(n => mcpSelectedServers.includes(n))
- setMcpSelectedServers(allSelected ? [] : allNames)
- }}
- onFetchServers={fetchMcpServers}
- clientMCPActiveIds={clientMCPSelectedIds}
- onClientToggle={(id) => setClientMCPSelectedIds(prev =>
- prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]
- )}
- onClientAdded={(server) => setClientMCPSelectedIds(prev => [...prev, server.id])}
- onClientRemoved={(id) => setClientMCPSelectedIds(prev => prev.filter(s => s !== id))}
- />
-
+ // "/" from anywhere that is not a text field starts a command.
+ useEffect(() => {
+ const onKey = (e) => {
+ if (e.key !== '/' || e.metaKey || e.ctrlKey || e.altKey) return
+ if (isTyping(document.activeElement)) return
+ const el = textareaRef.current
+ if (!el) return
+ e.preventDefault()
+ setMessage('/')
+ el.focus()
+ }
+ document.addEventListener('keydown', onKey)
+ return () => document.removeEventListener('keydown', onKey)
+ }, [])
- {/* File attachment tags */}
- {allFiles.length > 0 && (
-
- {allFiles.map((f, i) => (
-
-
- {f.name}
- removeFile(f)}>
-
-
-
- ))}
-
- )}
+ const dateLabel = useMemo(
+ () => new Intl.DateTimeFormat(i18n.language, { weekday: 'short', day: 'numeric', month: 'short' }).format(new Date()),
+ [i18n.language],
+ )
- {/* Input container with inline send */}
-
-
-
+ const cluster = distributedMode && clusterData ? clusterData : null
+ const hasInput = message.trim().length > 0 || allFiles.length > 0
+ const canSend = !!selectedModel && hasInput && !sending
+ const sendTitle = !selectedModel ? t('input.selectModelFirst') : t('input.sendMessage')
- {/* Quick links */}
-
- {isAdmin && (
- <>
- {assistantAvailable && assistantUsed && (
-
- {t('quickLinks.manageByChat')}
-
- )}
-
navigate('/app/models')}>
- {t('quickLinks.browseGallery')}
-
-
navigate('/app/models?view=installed')}>
- {t('quickLinks.installedModels')}
-
-
navigate('/app/import-model')}>
- {t('quickLinks.importModel')}
-
- >
- )}
-
- {t('quickLinks.documentation')}
-
-
+ // ----- Non-admin, nothing installed -------------------------------------
- {/* Jump back in. The quick-links row above is a set of first-run
- actions; these are the three places someone returns to, stated
- with what they currently hold rather than as bare labels. */}
-
-
{t('jump.heading')}
-
-
- navigate('/app/models')}>
- {t('jump.models')}
- {t('jump.modelsSummary')}
- →
-
-
-
- navigate('/app/studio')}>
- {t('jump.create')}
- {t('jump.createSummary')}
- →
-
-
-
- navigate('/app/operate')}>
- {t('jump.operate')}
- {t('jump.operateSummary', { models: configuredModels?.length ?? 0 })}
- →
-
-
-
-
-
- {/* Loaded models status */}
-
- {t('loadedModels.heading')}
- {modelsLoading ? (
-
- ) : loadedCount > 0 ? (
- <>
- {/* Lanes: uniform records read in sequence. The id is the
- identifier, so it is mono; the state is a pill because it is
- a state. /api/system-information carries only the id, so
- there is nothing honest to put in a backend or memory column
- without a server change. */}
-
- {[...loadedModels].sort((a, b) => a.id.localeCompare(b.id)).map((m, i) => (
-
- {m.id}
- {/* Which engine is serving it — the first thing worth
- knowing beside the name. Omitted rather than faked
- when the model has no config to read it from. */}
- {m.backend
- ? {m.backend}
- : }
-
- handleStopModel(m.id)}
- title={t('loadedModels.stop')}
- aria-label={t('loadedModels.stop')}
- >
-
-
-
- ))}
-
- {loadedCount > 1 && (
-
- {t('loadedModels.stopAll')}
-
- )}
- >
- ) : (
- {t('statusLine.noModelsLoaded')}
- )}
-
- >
- ) : isAdmin ? (
- /* No models installed - compact getting started */
-
-
-
-
-
-
1
-
-
{t('wizard.steps.step1Title')}
-
{t('wizard.steps.step1Body')}
-
-
-
-
2
-
-
{t('wizard.steps.step2Title')}
-
{t('wizard.steps.step2Body')}
-
-
-
-
3
-
-
{t('wizard.steps.step3Title')}
-
{t('wizard.steps.step3Body')}
-
-
-
-
-
-
-
-
navigate('/app/models')}>
- {t('wizard.browseGallery')}
-
-
navigate('/app/import-model')}>
- {t('wizard.importModel')}
-
-
- {t('wizard.docs')}
-
-
-
- ) : (
- /* No models available (non-admin) */
+ if (firstRun && !isAdmin) {
+ return (
+
@@ -594,14 +496,211 @@ export default function Home() {
{t('wizard.noModelsBody')}
- )}
+
+
+ )
+ }
-
+ const showTip = isAdmin && assistantAvailable && !assistantUsed && !tipDismissed && !firstRun
+ const picker = (
+
+ )
+ const mcp = (
+
{
+ const allNames = mcpServerList.filter(s => !s.error).map(s => s.name)
+ const allSelected = allNames.every(n => mcpSelectedServers.includes(n))
+ setMcpSelectedServers(allSelected ? [] : allNames)
+ }}
+ onFetchServers={fetchMcpServers}
+ clientMCPActiveIds={clientMCPSelectedIds}
+ onClientToggle={(id) => setClientMCPSelectedIds(prev =>
+ prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]
+ )}
+ onClientAdded={(server) => setClientMCPSelectedIds(prev => [...prev, server.id])}
+ onClientRemoved={(id) => setClientMCPSelectedIds(prev => prev.filter(s => s !== id))}
+ />
+ )
+
+ return (
+
+
+
+ {t(`greeting.${greetingKey()}`)}
+ {dateLabel}
+
+
+
+
+
+
+
+ {modelsFailed && configuredModels === null && (
+
+
+
{t('notice.loadFailedTitle')}
+
{t('notice.loadFailedBody')}
+
+ {t('notice.retry')}
+
+
+ )}
+
+ {failedOp && (
+
+
+
{t('notice.stagingFailedTitle', { name: failedOp.name || failedOp.id })}
+
{failedOp.nodeName ? t('notice.stagingFailedBodyNode', { node: failedOp.nodeName }) : t('notice.stagingFailedBody')}
+
+ navigate('/app/activity')}>{t('notice.openActivity')}
+
+
+ {t('notice.details')}
+ {failedOp.error}
+
+
+ )}
+
+
+ {modelsLoading ? (
+
+
+
+ ) : firstRun ? (
+
+
+
+ {t('strip.firstRun')}
+
+
+ ) : !modelsFailed || configuredModels !== null ? (
+
+ ) : null}
+
+
+ {showTip && (
+
+
+ {t('assistant.title')}. {t('assistant.description')}
+
+ {t('assistant.open')}
+
+
+
+
+
+ )}
+
+
+ {firstRun && (
+
+ navigate('/app/models')}
+ onImport={() => navigate('/app/import-model')}
+ />
+
+ )}
+
+
+
+ {(isAdmin ? ['/gallery', '/import'] : []).concat(isAdmin && assistantAvailable ? ['/assistant'] : [], ['/studio']).map(c => (
+ {c}
+ ))}
+
+ )}
+ />
+
+
+
+
{t('library.label')}
+ {isAdmin && (
+ <>
+
navigate('/app/models')}>
+ {t('quickLinks.browseGallery')} /gallery
+
+
navigate('/app/models?view=installed')}>
+ {t('quickLinks.installedModels')}
+ {configuredModels && {configuredModels.length}}
+
+
navigate('/app/import-model')}>
+ {t('quickLinks.importModel')} /import
+
+ {assistantAvailable && !showTip && (
+
+ {t('quickLinks.manageByChat')} /assistant
+
+ )}
+ >
+ )}
+
+ {t('quickLinks.documentation')} /docs
+
+
+
+
+
+
+ {pendingDelete && (
+
+ )}
{
+ if (source.status !== 'ready') return
+ blobToImageInput(source.blob).then((img) => { setSourceImage(img.base64); setSourcePreview(img.dataUrl); setSourceName(source.item?.title || '') }).catch(() => {})
+ }, [source])
const [lightboxIdx, setLightboxIdx] = useState(null)
// The body of the last request, kept so the panel can show what was actually
// sent rather than what the form currently holds.
@@ -45,15 +61,17 @@ export default function ImageGen() {
const displayImages = selectedEntry
? selectedEntry.results.map(r => ({ url: r.url, alt: selectedEntry.prompt }))
: images.map(img => ({ url: img.url || `data:image/png;base64,${img.b64_json}`, alt: prompt }))
+ const activeEntry = selectedEntry || (lastId ? historyProps.entries.find(e => e.id === lastId) : null) || null
+ const item = ws.itemById(activeEntry?.id)
- const handleGenerate = async (e) => {
- e.preventDefault()
+ const run = async () => {
if (!prompt.trim()) { addToast(t('image.toasts.noPrompt'), 'warning'); return }
if (!model) { addToast(t('image.toasts.noModel'), 'warning'); return }
setLoading(true)
setImages([])
setError(null)
+ setLastId(null)
let combinedPrompt = prompt.trim()
if (negativePrompt.trim()) combinedPrompt += '|' + negativePrompt.trim()
@@ -75,9 +93,10 @@ export default function ImageGen() {
} else {
const urlResults = results.filter(r => r.url && !r.url.startsWith('data:')).map(r => ({ url: r.url }))
if (urlResults.length) {
- addEntry({ prompt: prompt.trim(), model, params: { size, count, steps, seed, negativePrompt: negativePrompt.trim() || undefined }, results: urlResults })
+ setLastId(addEntry({ prompt: prompt.trim(), model, params: { size, count, steps, seed, negativePrompt: negativePrompt.trim() || undefined }, results: urlResults, parentId: handoff.from || undefined, edge: handoff.edge || undefined }))
}
selectEntry(null)
+ ws.end()
}
} catch (err) {
setError(err.message)
@@ -86,106 +105,161 @@ export default function ImageGen() {
}
}
- const handleSourceImage = async (e) => {
- if (e.target.files[0]) setSourceImage(await fileToBase64(e.target.files[0]))
- }
-
- const handleRefImages = async (e) => {
+ const handleRefImages = async (files) => {
const arr = []
- for (const f of e.target.files) arr.push(await fileToBase64(f))
+ for (const f of files) arr.push(await fileToBase64(f))
setRefImages(prev => [...prev, ...arr])
}
+ // Re-run with edits: this result's values go back into the form, and the form
+ // remembers them so what changes can be listed.
+ const rerun = (it) => {
+ const entry = historyProps.entries.find(e => e.id === it.id)
+ if (!entry) return
+ const p = entry.params || {}
+ const next = {
+ prompt: entry.prompt || '',
+ model: entry.model || model,
+ size: SIZES.includes(p.size) ? p.size : size,
+ count: String(Math.min(4, Number(p.count) || 1)),
+ steps: p.steps ? String(p.steps) : '',
+ seed: p.seed ? String(p.seed) : '',
+ negative: p.negativePrompt || '',
+ }
+ setPrompt(next.prompt); setModel(next.model); setSize(next.size); setCount(Number(next.count))
+ setSteps(next.steps); setSeed(next.seed); setNegativePrompt(next.negative)
+ if (next.steps || next.seed || next.negative) setShowAdvanced(true)
+ selectEntry(null)
+ ws.begin(next)
+ }
+
+ const labels = {
+ prompt: t('studio.workspace.fields.prompt'), model: t('studio.workspace.fields.model'), size: t('studio.workspace.fields.size'),
+ count: t('studio.workspace.fields.count'), steps: t('studio.workspace.fields.steps'), seed: t('studio.workspace.fields.seed'),
+ negative: t('studio.workspace.fields.negative'),
+ }
+ const current = { prompt, model, size, count: String(count), steps, seed, negative: negativePrompt }
+ const changes = ws.changes(current, labels)
+ const changed = new Set(changes.map(c => c.field))
+
+ const installed = ws.installed.byType.images
+ const noModel = !ws.installed.loading && !ws.installed.error && installed.length === 0
+ const why = noModel ? t('studio.composer.whyModel', { type: t('studio.tabs.images') })
+ : !prompt.trim() ? t('studio.composer.whyPrompt') : ''
+ const dims = parseSize(size)
+
return (
-
-
-
{t('image.title')}>} />
-
-
-
- {t('image.labels.model')}
-
-
-
- {t('image.labels.prompt')}
- setPrompt(e.target.value)} placeholder={t('image.labels.promptPlaceholder')} rows={3} onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleGenerate(e) } }} />
-
-
- {t('image.labels.negativePrompt')}
- setNegativePrompt(e.target.value)} placeholder={t('image.labels.negativePromptPlaceholder')} rows={2} />
-
-
-
-
+
+ }
+ changes={changes}
+ submit={{ label: t('image.actions.generate'), busyLabel: t('image.actions.generating'), busy: loading, disabled: !model || !prompt.trim() || noModel, why }}
+ >
+
setPrompt(e.target.value)}
+ placeholder={t('image.labels.promptPlaceholder')}
+ aria-label={t('image.labels.prompt')}
+ rows={3}
+ data-changed={changed.has('prompt') || undefined}
+ onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); run() } }}
+ />
+ {!prompt && }
+
- setShowAdvanced(!showAdvanced)}>
- {t('image.labels.advanced')}
-
- {showAdvanced && (
-
- )}
-
- setShowImageInputs(!showImageInputs)}>
- {t('image.labels.imageInputs')}
-
- {showImageInputs && (
- <>
- {t('image.labels.sourceImage')}
-
- {t('image.labels.refImages')}
-
- {refImages.length > 0 && {t('image.labels.refImagesAdded', { count: refImages.length })} }
-
- >
- )}
-
-
- {loading ? <> {t('image.actions.generating')}> : <> {t('image.actions.generate')}>}
-
-
-
-
-
-
-
-
- {loading ? (
-
- ) : error ? (
-
- ) : displayImages.length > 0 ? (
-
+
+ {loading ? (
+
+ ) : error ? (
+
+ ) : displayImages.length > 0 ? (
+
+
{displayImages.map((im, i) => (
setLightboxIdx(i)} title={t('image.actions.view')} aria-label={t('image.actions.view')}>
-
+
))}
- ) : (
-
- )}
-
- {lightboxIdx !== null && (
-
setLightboxIdx(null)} />
+
+ ) : (
+
)}
-
-
+
+
+
+
+ {lightboxIdx !== null && (
+ setLightboxIdx(null)} />
+ )}
+
)
}
diff --git a/core/http/react-ui/src/pages/ImportModel.jsx b/core/http/react-ui/src/pages/ImportModel.jsx
index c66706f96..259bde7b5 100644
--- a/core/http/react-ui/src/pages/ImportModel.jsx
+++ b/core/http/react-ui/src/pages/ImportModel.jsx
@@ -1,8 +1,11 @@
+/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'
-import { useNavigate, useOutletContext } from 'react-router-dom'
+import { Link, useNavigate, useOutletContext } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { modelsApi, backendsApi } from '../utils/api'
import { formatBytes } from '../utils/format'
+import { useResources } from '../hooks/useResources'
+import { detectSource, fitFor, importChecks, importRequest, machineFacts, requestText } from '../utils/tools'
import { createTransferRateSampler } from '../utils/transferRate'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
@@ -10,6 +13,11 @@ import CodeEditor from '../components/CodeEditor'
import SearchableSelect from '../components/SearchableSelect'
import AmbiguityAlert from '../components/AmbiguityAlert'
import ModalityChips from '../components/ModalityChips'
+import Icon from '../components/Icon'
+import ToolSteps from '../components/tools/ToolSteps'
+import Checks from '../components/tools/Checks'
+import '../components/tools/tools.css'
+import './import.css'
// Fallback list used when /backends/known fails — keeps the form usable
// with auto-detect only rather than showing an empty dropdown.
@@ -96,6 +104,15 @@ const URI_FORMATS = [
},
]
+// Shapes to start from. Choosing one fills the field with its placeholder text.
+const EXAMPLES = [
+ 'huggingface://owner/repo',
+ 'https://example.com/model.gguf',
+ 'file:///models/model.gguf',
+ 'oci://registry.example.com/model:tag',
+ 'ollama://llama3.2:3b',
+]
+
const DEFAULT_YAML = `name: my-model
backend: llama-cpp
parameters:
@@ -117,7 +134,9 @@ const SPLIT_MIN_WIDTH = 1024
export default function ImportModel() {
const navigate = useNavigate()
const { addToast } = useOutletContext()
- const { t } = useTranslation('importModel')
+ // The 'tools' namespace is loaded up front: the check list below uses it, and
+ // loading it on the first keystroke would suspend the page and drop the focus.
+ const { t } = useTranslation(['importModel', 'tools'])
// Which kind of input the user is giving: a source to resolve, or a YAML
// document to write. These are genuinely different inputs, unlike the
@@ -134,6 +153,11 @@ export default function ImportModel() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [yamlContent, setYamlContent] = useState(DEFAULT_YAML)
const [estimate, setEstimate] = useState(null)
+ // The finished import: { name }. Shown as the last step instead of leaving the page.
+ const [done, setDone] = useState(null)
+ const { resources } = useResources(20000)
+ const facts = useMemo(() => machineFacts(resources), [resources])
+ const [installedNames, setInstalledNames] = useState([])
// Full poll payload for the running job, not just its message: the endpoint
// already reports progress, phase and byte counts, and the page used to
// render only `message`.
@@ -205,6 +229,20 @@ export default function ImportModel() {
return () => { cancelled = true }
}, [addToast, t])
+ // The names already in use, for the name check. A failed read just leaves the
+ // check out.
+ useEffect(() => {
+ let cancelled = false
+ modelsApi.listV1()
+ .then(data => {
+ if (cancelled) return
+ const list = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : []
+ setInstalledNames(list.map(m => m?.id || m?.name).filter(Boolean))
+ })
+ .catch(() => {})
+ return () => { cancelled = true }
+ }, [])
+
const backendOptions = useMemo(
() => buildBackendOptions(backends, modalityFilter, t),
[backends, modalityFilter, t]
@@ -247,8 +285,8 @@ export default function ImportModel() {
pollRef.current = null
setIsSubmitting(false)
setJob(null)
+ setDone({ name: data.gallery_element_name || '' })
addToast(t('toasts.imported'), 'success')
- navigate('/app/models?view=installed')
return
}
if (data.error || (data.message && data.message.startsWith('error:'))) {
@@ -277,39 +315,26 @@ export default function ImportModel() {
console.error('Error polling job status:', err)
}
}, 1000)
- }, [addToast, navigate, t])
+ }, [addToast, t])
const handleImport = useCallback(async (overrideBackend) => {
if (!importUri.trim()) { addToast(t('toasts.noUri'), 'error'); return }
setIsSubmitting(true)
setEstimate(null)
+ setDone(null)
try {
- const prefsObj = {}
- const effectiveBackend = overrideBackend !== undefined ? overrideBackend : prefs.backend
- if (effectiveBackend) prefsObj.backend = effectiveBackend
- if (prefs.name.trim()) prefsObj.name = prefs.name.trim()
- if (prefs.description.trim()) prefsObj.description = prefs.description.trim()
- if (prefs.quantizations.trim()) prefsObj.quantizations = prefs.quantizations.trim()
- if (prefs.mmproj_quantizations.trim()) prefsObj.mmproj_quantizations = prefs.mmproj_quantizations.trim()
- if (prefs.embeddings) prefsObj.embeddings = 'true'
- if (prefs.type.trim()) prefsObj.type = prefs.type.trim()
- if (prefs.pipeline_type.trim()) prefsObj.pipeline_type = prefs.pipeline_type.trim()
- if (prefs.scheduler_type.trim()) prefsObj.scheduler_type = prefs.scheduler_type.trim()
- if (prefs.enable_parameters.trim()) prefsObj.enable_parameters = prefs.enable_parameters.trim()
- if (prefs.cuda) prefsObj.cuda = true
- customPrefs.forEach(cp => {
- if (cp.key.trim() && cp.value.trim()) prefsObj[cp.key.trim()] = cp.value.trim()
- })
-
- const result = await modelsApi.importUri({
- uri: importUri.trim(),
- preferences: Object.keys(prefsObj).length > 0 ? prefsObj : null,
- })
+ const request = importRequest(importUri, prefs, customPrefs, overrideBackend)
+ const result = await modelsApi.importUri(request)
const hasSize = result.estimated_size_display && result.estimated_size_display !== '0 B'
const hasVram = result.estimated_vram_display && result.estimated_vram_display !== '0 B'
if (hasSize || hasVram) {
- setEstimate({ sizeDisplay: result.estimated_size_display || '', vramDisplay: result.estimated_vram_display || '' })
+ setEstimate({
+ sizeDisplay: result.estimated_size_display || '',
+ vramDisplay: result.estimated_vram_display || '',
+ sizeBytes: Number(result.estimated_size_bytes) || 0,
+ vramBytes: Number(result.estimated_vram_bytes) || 0,
+ })
}
const jobId = result.uuid || result.ID
@@ -415,7 +440,7 @@ export default function ImportModel() {
- {t('actions.browseHF')}
+ {t('actions.browseHF')}
@@ -424,8 +449,8 @@ export default function ImportModel() {
const renderOptions = () => (
-
{t('form.backend')}
-
{t('form.backendHint')}
+
{t('form.backend')}
+
{t('form.backendHint')}
{backendsError && (
-
{t('form.backendErrorHint')}
+
{t('form.backendErrorHint')}
)}
{(() => {
if (!prefs.backend) return null
const selected = backends.find(b => b.name === prefs.backend)
if (!selected || selected.installed) return null
return (
-
-
+
+
{t('form.backendNotInstalled')}
)
@@ -457,90 +482,90 @@ export default function ImportModel() {
-
{t('form.modelName')}
-
updatePref('name', e.target.value)} placeholder={t('form.modelNamePlaceholder')} disabled={isSubmitting} />
-
{t('form.modelNameHint')}
+
{t('form.modelName')}
+
updatePref('name', e.target.value)} placeholder={t('form.modelNamePlaceholder')} disabled={isSubmitting} />
+
{t('form.modelNameHint')}
{showQuantizations && (
-
{t('form.quantizations')}
-
updatePref('quantizations', e.target.value)} placeholder={t('form.quantizationsPlaceholder')} disabled={isSubmitting} />
-
{t('form.quantizationsHint')}
+
{t('form.quantizations')}
+
updatePref('quantizations', e.target.value)} placeholder={t('form.quantizationsPlaceholder')} disabled={isSubmitting} />
+
{t('form.quantizationsHint')}
)}
{showMmprojQuantizations && (
-
{t('form.mmprojQuantizations')}
-
updatePref('mmproj_quantizations', e.target.value)} placeholder={t('form.mmprojQuantizationsPlaceholder')} disabled={isSubmitting} />
-
{t('form.mmprojQuantizationsHint')}
+
{t('form.mmprojQuantizations')}
+
updatePref('mmproj_quantizations', e.target.value)} placeholder={t('form.mmprojQuantizationsPlaceholder')} disabled={isSubmitting} />
+
{t('form.mmprojQuantizationsHint')}
)}
{showModelType && (
-
{t('form.modelType')}
-
updatePref('type', e.target.value)} placeholder={t('form.modelTypePlaceholder')} disabled={isSubmitting} />
-
{t('form.modelTypeHint')}
+
{t('form.modelType')}
+
updatePref('type', e.target.value)} placeholder={t('form.modelTypePlaceholder')} disabled={isSubmitting} />
+
{t('form.modelTypeHint')}
)}
{prefs.backend === 'diffusers' && (
<>
-
{t('form.pipelineType')}
-
updatePref('pipeline_type', e.target.value)} placeholder="StableDiffusionPipeline" disabled={isSubmitting} />
-
{t('form.pipelineTypeHint')}
+
{t('form.pipelineType')}
+
updatePref('pipeline_type', e.target.value)} placeholder="StableDiffusionPipeline" disabled={isSubmitting} />
+
{t('form.pipelineTypeHint')}
-
{t('form.schedulerType')}
-
updatePref('scheduler_type', e.target.value)} placeholder={t('form.schedulerTypePlaceholder')} disabled={isSubmitting} />
-
{t('form.schedulerTypeHint')}
+
{t('form.schedulerType')}
+
updatePref('scheduler_type', e.target.value)} placeholder={t('form.schedulerTypePlaceholder')} disabled={isSubmitting} />
+
{t('form.schedulerTypeHint')}
-
{t('form.enableParameters')}
-
updatePref('enable_parameters', e.target.value)} placeholder={t('form.enableParametersPlaceholder')} disabled={isSubmitting} />
-
{t('form.enableParametersHint')}
+
{t('form.enableParameters')}
+
updatePref('enable_parameters', e.target.value)} placeholder={t('form.enableParametersPlaceholder')} disabled={isSubmitting} />
+
{t('form.enableParametersHint')}
>
)}
-
{t('form.description')}
-
updatePref('description', e.target.value)} placeholder={t('form.descriptionPlaceholder')} disabled={isSubmitting} />
- {t('form.descriptionHint')}
+ {t('form.description')}
+ updatePref('description', e.target.value)} placeholder={t('form.descriptionPlaceholder')} disabled={isSubmitting} />
+ {t('form.descriptionHint')}
-
-
{t('form.customPreferences')}
-
- {t('actions.addCustom')}
+
+ {t('form.customPreferences')}
+
+ {t('actions.addCustom')}
- {t('form.customKeyValueHint')}
+ {t('form.customKeyValueHint')}
{customPrefs.map((cp, i) => (
updateCustomPref(i, 'key', e.target.value)}
@@ -549,7 +574,7 @@ export default function ImportModel() {
disabled={isSubmitting}
/>
updateCustomPref(i, 'value', e.target.value)}
@@ -559,12 +584,12 @@ export default function ImportModel() {
/>
removeCustomPref(i)}
disabled={isSubmitting}
aria-label={t('form.removePref')}
>
-
+
))}
@@ -584,39 +609,74 @@ export default function ImportModel() {
? `${formatBytes(job.bytesPerSecond)}/s`
: ''
+ // What the source says about itself, from its spelling alone. Nothing has been
+ // contacted: the importer reads the repository when the import starts.
+ const source = useMemo(() => detectSource(importUri), [importUri])
+ const request = useMemo(() => importRequest(importUri, prefs, customPrefs), [importUri, prefs, customPrefs])
+ const checks = useMemo(
+ () => importChecks({ source, prefs, backends, installedNames, facts }),
+ [source, prefs, backends, installedNames, facts],
+ )
+ const chosenBackend = backends.find(b => b.name === prefs.backend)
+
+ // Where the import is: nothing typed, reviewing, running, finished.
+ let current = 0
+ if (done) current = 3
+ else if (isSubmitting || job) current = 2
+ else if (source) current = 1
+ const steps = [
+ { key: 'source', label: t('steps.source') },
+ { key: 'review', label: t('steps.review') },
+ { key: 'import', label: t('steps.import') },
+ { key: 'done', label: t('steps.done') },
+ ]
+
+ // How the estimate the server returned sits against this machine.
+ const freeMemory = facts ? (facts.cluster ? facts.cluster.total : facts.hasGpu ? facts.vramFree : facts.ramFree) : null
+ const memoryFit = estimate ? fitFor(estimate.vramBytes, freeMemory) : null
+ const diskShort = estimate && facts?.diskFree != null && estimate.sizeBytes > facts.diskFree
+
+ const resetForAnother = () => {
+ setDone(null)
+ setEstimate(null)
+ setImportUri('')
+ setPrefs(DEFAULT_PREFS)
+ setCustomPrefs([])
+ }
+
return (
-
+
+
setTab('source')}
data-testid="import-tab-source"
>
-
{t('tabs.source')}
setTab('yaml')}
data-testid="import-tab-yaml"
>
-
{t('tabs.yaml')}
}
/>
+ {!isYaml && }
+
{!isYaml && (
{ e.preventDefault(); handleImport() }}
>
{/* The source field is the page. It is monospace because it holds
- something you paste rather than something you compose, and it
- carries its own commit button — the action used to live in the
- page header, outside the form, with a hidden submit button
- standing in so Enter still worked. */}
-
-
{t('form.modelUri')}
+ something you paste rather than something you compose. The
+ action sits in the bar at the foot of this form, which states
+ what it will do. */}
+
+ {t('form.modelUri')}
setImportUri(e.target.value)}
@@ -644,18 +703,22 @@ export default function ImportModel() {
spellCheck="false"
autoComplete="off"
/>
-
- {isSubmitting
- ? <> {t('actions.importing')}>
- : <> {t('actions.import')}>}
-
- {t('form.uriHint')}
+
+ {source
+ ? <>{t(`found.kind.${source.kind}`)} {t('found.untouched')} >
+ : {t('form.uriHint')} }
+
+ {!source && (
+
+
{t('found.examples')}
+
+ {EXAMPLES.map(example => (
+ setImportUri(example)} disabled={isSubmitting}>{example}
+ ))}
+
+
+ )}
{!isSplit && (
<>
@@ -667,13 +730,13 @@ export default function ImportModel() {
aria-controls="import-formats-panel"
onClick={() => setShowFormats(v => !v)}
>
-
+
{t('form.supportedFormats')}
{showFormats && {renderFormats()}
}
>
)}
-
+
{ambiguity && (
)}
- {/* Size and VRAM answer for the field above them. They used to be a
- banner pinned above the page header, furthest from the control
- that produced them. */}
+ {/* Size and memory answer for the field above them. They arrive when
+ the import starts, because the server reads the repository then
+ and not before. */}
{estimate && (
- {estimate.sizeDisplay && estimate.sizeDisplay !== '0 B' && (
-
- {t('estimate.downloadLabel')}
- {estimate.sizeDisplay}
-
+
+ {estimate.sizeDisplay && estimate.sizeDisplay !== '0 B' && (
+
+ {t('estimate.downloadLabel')}
+ {estimate.sizeDisplay}
+
+ )}
+ {estimate.vramDisplay && estimate.vramDisplay !== '0 B' && (
+
+ {t('estimate.vramLabel')}
+ {estimate.vramDisplay}
+
+ )}
+
+ {memoryFit && (
+
+ {t(`estimate.fit.${memoryFit}`, { free: formatBytes(freeMemory) })}
+
)}
- {estimate.vramDisplay && estimate.vramDisplay !== '0 B' && (
-
- {t('estimate.vramLabel')}
- {estimate.vramDisplay}
-
+ {diskShort && (
+
+ {t('estimate.diskShort', { free: formatBytes(facts.diskFree) })}
+
)}
)}
{job && (
-
+
{jobName || t('progress.working')}
{progressPct !== null && (
@@ -715,15 +790,15 @@ export default function ImportModel() {
{progressPct !== null && (
- {/* Runtime percentage — the one width a stylesheet cannot know. */}
-
+
)}
@@ -736,6 +811,63 @@ export default function ImportModel() {
)}
+ {done && (
+
+
+
+
{done.name ? t('done.titleNamed', { name: done.name }) : t('done.title')}
+
{t('done.text')}
+
+ {done.name && {t('done.chat', { name: done.name })}}
+ {t('done.models')}
+ {t('done.another')}
+
+
+
+ )}
+
+ {source && !done && !job && !isSubmitting && (
+ <>
+
+ {t('found.title')}
+
+ {t(`found.ref.${source.kind}`)}
+ {source.ref}
+ {source.file && <>{t('found.file')} {source.file} >}
+ {t('found.backend')}
+
+ {prefs.backend
+ ? <>{prefs.backend} {chosenBackend && !chosenBackend.installed ? t('found.backendDownload') : t('found.backendChosen')} >
+ : {t('found.backendAuto')} }
+
+
+
+ setShowOptions(true)} data-testid="import-adjust">
+ {t('found.adjust')}
+
+
+
+
+
+
+ {t('preview.title')}
+ {t('preview.note')}
+
+ {requestText(request)}
+
+
+
+
+ {t('checks.heading')}
+ {t('checks.note')}
+
+
+
+
+
+ >
+ )}
+
setShowOptions(v => !v)}
>
-
+
{t('form.options')}
{!showOptions && {t('form.optionsSummary')} }
{showOptions && renderOptions()}
+
+ {!done && (
+
+
+ {isSubmitting
+ ? t('bar.working')
+ : source
+ ? t('bar.ready', { ref: source.ref || importUri.trim() })
+ : t('bar.empty')}
+
+
+
+ {isSubmitting
+ ? <> {t('actions.importing')}>
+ : <> {t('actions.import')}>}
+
+
+
+ )}
{isSplit &&
}
@@ -760,29 +917,31 @@ export default function ImportModel() {
{isYaml && (
-
{t('form.yamlEditor')}
-
+
{t('form.yamlEditor')}
+
{ navigator.clipboard.writeText(yamlContent); addToast(t('toasts.copied'), 'success') }}
>
- {t('actions.copy')}
+ {t('actions.copy')}
{isSubmitting
? <> {t('actions.saving')}>
- : <> {t('actions.create')}>}
+ : <> {t('actions.create')}>}
-
+
{t('form.yamlHint')}
+
)}
diff --git a/core/http/react-ui/src/pages/InstalledBackends.jsx b/core/http/react-ui/src/pages/InstalledBackends.jsx
deleted file mode 100644
index a27a2c9d3..000000000
--- a/core/http/react-ui/src/pages/InstalledBackends.jsx
+++ /dev/null
@@ -1,544 +0,0 @@
-import { useCallback, useEffect, useRef, useState } from 'react'
-import { useSearchParams } from 'react-router-dom'
-import { useTranslation } from 'react-i18next'
-import { backendsApi } from '../utils/api'
-import ActionMenu from '../components/ActionMenu'
-import ConfirmDialog from '../components/ConfirmDialog'
-import FilterBar from '../components/FilterBar'
-import GalleryLoader from '../components/GalleryLoader'
-import NodeDistributionChip from '../components/NodeDistributionChip'
-import SplitView from '../components/split/SplitView'
-import EntityRail from '../components/split/EntityRail'
-import DetailHeader from '../components/split/DetailHeader'
-import StatGrid from '../components/split/StatGrid'
-import { stripMarkdown } from '../utils/markdown'
-
-const STATE_GROUPS = [
- { id: 'update', labelKey: 'backends.lifecycle.updateGroup', icon: 'fa-arrow-up' },
- { id: 'installed', labelKey: 'backends.lifecycle.installedGroup', icon: 'fa-check' },
-]
-
-const VALID_STATES = new Set(['all', 'user', 'system', 'upgradable', 'offline'])
-
-export default function InstalledBackends({
- addToast,
- catalogBackends,
- distributedEnabled,
- operations,
- upgrades,
- selectedName,
- onSelect,
-}) {
- const { t } = useTranslation('admin')
- const [searchParams, setSearchParams] = useSearchParams()
- const [backends, setBackends] = useState([])
- const [loading, setLoading] = useState(true)
- const loadedOnce = useRef(false)
- const [pending, setPending] = useState(() => new Set())
- const [errors, setErrors] = useState({})
- const [globalError, setGlobalError] = useState('')
- const [confirmDialog, setConfirmDialog] = useState(null)
- const [upgradingAll, setUpgradingAll] = useState(false)
- const [collapsedGroups, setCollapsedGroups] = useState(() => new Set())
-
- const query = searchParams.get('q') || ''
- const requestedState = searchParams.get('state') || 'all'
- const state = VALID_STATES.has(requestedState) ? requestedState : 'all'
- const showVariants = searchParams.get('show_all') === '1'
- const showDevelopment = searchParams.get('development') === '1'
-
- const updateParam = useCallback((key, value, defaultValue = '') => {
- setSearchParams(prev => {
- const next = new URLSearchParams(prev)
- if (!value || value === defaultValue) next.delete(key)
- else next.set(key, value)
- return next
- }, { replace: true })
- }, [setSearchParams])
-
- const fetchBackends = useCallback(async () => {
- try {
- setLoading(true)
- const data = await backendsApi.listInstalled()
- setBackends(Array.isArray(data) ? data : [])
- setGlobalError('')
- } catch (err) {
- setBackends([])
- setGlobalError(t('backends.lifecycle.loadFailed', { message: err.message }))
- } finally {
- loadedOnce.current = true
- setLoading(false)
- }
- }, [t])
-
- useEffect(() => { fetchBackends() }, [fetchBackends, operations.length])
-
- const catalogByName = new Map(catalogBackends.map(backend => [backend.name || backend.id, backend]))
- const flagsFor = (backend) => {
- const catalog = catalogByName.get(backend.Name)
- return {
- variant: !!catalog?.isAlias,
- development: !!catalog?.isDevelopment,
- }
- }
-
- const visibleBase = backends.filter(backend => {
- const flags = flagsFor(backend)
- if (flags.variant && !showVariants) return false
- if (flags.development && !showDevelopment) return false
- return true
- })
- const hiddenVariantCount = showVariants ? 0 : backends.filter(backend => flagsFor(backend).variant).length
- const hiddenDevelopmentCount = showDevelopment ? 0 : backends.filter(backend => flagsFor(backend).development).length
- const offlineFor = (backend) => (backend.Nodes || backend.nodes || []).some(node => {
- const status = node.node_status || node.NodeStatus
- return status && status !== 'healthy' && status !== 'draining'
- })
- const passesState = (backend) => {
- if (state === 'user') return !backend.IsSystem
- if (state === 'system') return !!backend.IsSystem
- if (state === 'upgradable') return !!upgrades[backend.Name]
- if (state === 'offline') return offlineFor(backend)
- return true
- }
- const normalizedQuery = query.trim().toLowerCase()
- const visibleBackends = visibleBase.filter(backend => passesState(backend) && (
- !normalizedQuery
- || backend.Name.toLowerCase().includes(normalizedQuery)
- || (backend.Metadata?.alias || '').toLowerCase().includes(normalizedQuery)
- || (backend.Metadata?.meta_backend_for || '').toLowerCase().includes(normalizedQuery)
- ))
- const selectedBackend = selectedName
- ? backends.find(backend => backend.Name === selectedName) || null
- : null
-
- const isProcessing = useCallback((name) => pending.has(name) || operations.some(operation => (
- operation.name === name && !operation.completed && !operation.error
- )), [operations, pending])
-
- const withPending = async (name, action) => {
- setPending(current => new Set(current).add(name))
- setErrors(current => ({ ...current, [name]: '' }))
- try {
- await action()
- } finally {
- setPending(current => {
- const next = new Set(current)
- next.delete(name)
- return next
- })
- }
- }
-
- const handleReinstall = async (name) => {
- try {
- await withPending(name, () => backendsApi.install(name))
- addToast(t('backends.lifecycle.reinstallStarted', { name }), 'info')
- } catch (err) {
- setErrors(current => ({
- ...current,
- [name]: t('backends.lifecycle.reinstallFailed', { message: err.message }),
- }))
- }
- }
-
- const handleUpgrade = async (name) => {
- try {
- await withPending(name, () => backendsApi.upgrade(name))
- addToast(t('backends.lifecycle.upgradeStarted', { name }), 'info')
- } catch (err) {
- setErrors(current => ({
- ...current,
- [name]: t('backends.lifecycle.upgradeFailed', { message: err.message }),
- }))
- }
- }
-
- const handleUpgradeAll = async () => {
- const names = Object.keys(upgrades)
- if (names.length === 0) return
- setUpgradingAll(true)
- setGlobalError('')
- try {
- const failures = []
- for (const name of names) {
- try {
- await backendsApi.upgrade(name)
- } catch (err) {
- failures.push(t('backends.lifecycle.upgradeAllFailed', { name, message: err.message }))
- }
- }
- if (failures.length > 0) {
- setGlobalError(failures.join(' '))
- return
- }
- addToast(t('backends.lifecycle.upgradeAllStarted', { count: names.length }), 'info')
- } finally {
- setUpgradingAll(false)
- }
- }
-
- const handleDelete = (name) => {
- setConfirmDialog({
- title: t('backends.lifecycle.deleteTitle'),
- message: t('backends.lifecycle.deleteMessage', { name }),
- confirmLabel: t('backends.lifecycle.delete'),
- onConfirm: async () => {
- setConfirmDialog(null)
- setErrors(current => ({ ...current, [name]: '' }))
- try {
- await backendsApi.deleteInstalled(name)
- addToast(t('backends.lifecycle.deleteSucceeded', { name }), 'success')
- onSelect(null)
- fetchBackends()
- } catch (err) {
- setErrors(current => ({
- ...current,
- [name]: t('backends.lifecycle.deleteFailed', { message: err.message }),
- }))
- }
- },
- })
- }
-
- const toggleGroup = (id) => {
- setCollapsedGroups(current => {
- const next = new Set(current)
- if (next.has(id)) next.delete(id)
- else next.add(id)
- return next
- })
- }
-
- const filters = [
- { key: 'all', label: t('backends.lifecycle.filterAll'), icon: 'fa-layer-group', count: visibleBase.length },
- { key: 'user', label: t('backends.lifecycle.filterUser'), icon: 'fa-download', count: visibleBase.filter(backend => !backend.IsSystem).length },
- { key: 'system', label: t('backends.lifecycle.filterSystem'), icon: 'fa-shield-alt', count: visibleBase.filter(backend => backend.IsSystem).length },
- ...(Object.keys(upgrades).length > 0 ? [{
- key: 'upgradable',
- label: t('backends.lifecycle.filterUpdates'),
- icon: 'fa-arrow-up',
- count: visibleBase.filter(backend => upgrades[backend.Name]).length,
- }] : []),
- ...(distributedEnabled && visibleBase.some(offlineFor) ? [{
- key: 'offline',
- label: t('backends.lifecycle.filterOffline'),
- icon: 'fa-exclamation-circle',
- count: visibleBase.filter(offlineFor).length,
- }] : []),
- ]
-
- if (loading && !loadedOnce.current) return
-
- return (
- <>
- {Object.keys(upgrades).length > 0 && (
-
-
-
- {t('backends.lifecycle.updatesAvailable', { count: Object.keys(upgrades).length })}
-
-
-
-
- {upgradingAll ? t('backends.lifecycle.upgrading') : t('backends.lifecycle.upgradeAll')}
-
-
-
- )}
-
- {globalError && (
-
-
- {globalError}
-
- )}
-
- {backends.length === 0 ? (
-
-
-
{t('backends.lifecycle.emptyTitle')}
-
{t('backends.lifecycle.emptyBody')}
-
- ) : (
- <>
-
updateParam('q', value)}
- searchPlaceholder={t('backends.lifecycle.searchPlaceholder')}
- filters={filters}
- activeFilter={state}
- onFilterChange={value => updateParam('state', value, 'all')}
- toggles={[
- {
- key: 'variants',
- label: hiddenVariantCount > 0
- ? t('backends.lifecycle.variantsCount', { count: hiddenVariantCount })
- : t('backends.lifecycle.variants'),
- icon: 'fa-cubes',
- checked: showVariants,
- onChange: () => updateParam('show_all', showVariants ? '' : '1'),
- },
- {
- key: 'development',
- label: hiddenDevelopmentCount > 0
- ? t('backends.lifecycle.developmentCount', { count: hiddenDevelopmentCount })
- : t('backends.lifecycle.development'),
- icon: 'fa-flask',
- checked: showDevelopment,
- onChange: () => updateParam('development', showDevelopment ? '' : '1'),
- },
- ]}
- />
-
- {visibleBackends.length === 0 ? (
-
-
-
{t('backends.lifecycle.noMatches')}
-
{
- setSearchParams(prev => {
- const next = new URLSearchParams(prev)
- next.delete('q')
- next.delete('state')
- return next
- }, { replace: true })
- }}>
- {t('backends.lifecycle.clearFilters')}
-
-
- ) : (
- railItemForBackend(backend, upgrades, isProcessing, t))}
- groups={STATE_GROUPS.map(group => ({ ...group, label: t(group.labelKey) }))}
- grouped={!query.trim()}
- collapsedGroups={collapsedGroups}
- onToggleGroup={toggleGroup}
- busy={loading}
- selectedId={selectedName}
- onSelect={onSelect}
- countLabel={t('backends.lifecycle.countLabel', { visible: visibleBackends.length, total: backends.length })}
- ariaLabel={t('backends.lifecycle.installedAria')}
- testId="backends-installed-rail"
- />
- )}
- pane={selectedBackend ? (
- onSelect(null)}
- onUpgrade={handleUpgrade}
- onReinstall={handleReinstall}
- onDelete={handleDelete}
- t={t}
- />
- ) : (
-
- )}
- />
- )}
- >
- )}
-
- setConfirmDialog(null)}
- />
- >
- )
-}
-
-function railItemForBackend(backend, upgrades, isProcessing, t) {
- const version = backend.Metadata?.version || backend.Version
- const upgrade = upgrades[backend.Name]
- let groupId = 'installed'
- let stripe = 'idle'
- let meta = version ? `v${version}` : t('backends.lifecycle.installed')
- let metaTone
-
- if (isProcessing(backend.Name)) {
- meta = t('backends.lifecycle.working')
- metaTone = 'busy'
- } else if (upgrade) {
- groupId = 'update'
- stripe = 'err'
- meta = upgrade.available_version
- ? `v${version} → v${upgrade.available_version}`
- : t('backends.lifecycle.updateAvailable')
- metaTone = 'warn'
- }
-
- return { id: backend.Name, name: backend.Name, icon: 'fa-server', meta, metaTone, stripe, groupId }
-}
-
-function InstalledBackendDetail({
- backend,
- catalog,
- upgrade,
- processing,
- error,
- distributedEnabled,
- onBack,
- onUpgrade,
- onReinstall,
- onDelete,
- t,
-}) {
- const name = backend.Name
- const version = backend.Metadata?.version || backend.Version
- const nodes = backend.Nodes || backend.nodes || []
-
- return (
-
-
- {t('backends.lifecycle.protected')}
-
- ) : (
- <>
- {upgrade && (
- onUpgrade(name)} disabled={processing}>
-
- {upgrade.available_version
- ? t('backends.lifecycle.upgradeTo', { version: upgrade.available_version })
- : t('backends.lifecycle.upgrade')}
-
- )}
- onReinstall(name),
- disabled: processing,
- },
- { divider: true },
- {
- key: 'delete',
- icon: 'fa-trash',
- label: t('backends.lifecycle.deleteBackend'),
- danger: true,
- onClick: () => onDelete(name),
- },
- ]}
- />
- >
- )}
- />
-
- {error && (
-
-
- {error}
-
- )}
-
-
-
- {distributedEnabled && nodes.length > 0 && (
-
- {t('backends.lifecycle.installedOn')}
-
-
- )}
-
-
-
-
- {catalog?.description && (
-
- {t('backends.lifecycle.description')}
- {stripMarkdown(catalog.description)}
-
- )}
- {backend.Metadata?.uri && (
-
- {t('backends.lifecycle.source')}
- {backend.Metadata.uri}
-
- )}
- {backend.Metadata?.digest && (
-
- {t('backends.lifecycle.digest')}
- {backend.Metadata.digest}
-
- )}
- {backend.Metadata?.installed_at && (
-
- {t('backends.lifecycle.installedAt')}
- {backend.Metadata.installed_at}
-
- )}
-
-
-
-
- )
-}
-
-function InstalledOverview({ backends, upgrades, onSelect, t }) {
- const staleNames = Object.keys(upgrades)
- return (
-
-
-
{t('backends.lifecycle.inventoryEyebrow')}
-
{t('backends.lifecycle.inventoryTitle', { count: backends.length })}
-
{t('backends.lifecycle.inventoryBody')}
-
-
backend.IsSystem).length },
- ]} />
- {staleNames.length > 0 && (
-
-
-
{t('backends.lifecycle.filterUpdates')}
-
-
- {staleNames.map(name => (
- onSelect(name)}>
- {t('backends.lifecycle.updateAvailable')}
- {name}
-
- ))}
-
-
- )}
-
- )
-}
diff --git a/core/http/react-ui/src/pages/InstalledModels.jsx b/core/http/react-ui/src/pages/InstalledModels.jsx
index 59db2f790..c8d5d5ad6 100644
--- a/core/http/react-ui/src/pages/InstalledModels.jsx
+++ b/core/http/react-ui/src/pages/InstalledModels.jsx
@@ -4,19 +4,22 @@ import { useTranslation } from 'react-i18next'
import { fromState } from '../utils/editorNav'
import ActionMenu from '../components/ActionMenu'
import ConfirmDialog from '../components/ConfirmDialog'
-import FilterBar from '../components/FilterBar'
-import GalleryLoader from '../components/GalleryLoader'
import NodeDistributionChip from '../components/NodeDistributionChip'
-import SplitView from '../components/split/SplitView'
-import EntityRail from '../components/split/EntityRail'
import DetailHeader from '../components/split/DetailHeader'
import StatGrid from '../components/split/StatGrid'
+import { rowKeyDown, useRestoreRowFocus } from '../components/models/rowKeys'
+import { gbLabel } from '../utils/modelLedger'
+import { diskEntry, sharedWith } from '../utils/modelStorage'
+import { formatBytes } from '../utils/format'
+import { publishWalk } from '../utils/modelWalk'
+import { useModelSizes } from '../hooks/useModelSizes'
+import { useModelStorage } from '../hooks/useModelStorage'
import { useModels } from '../hooks/useModels'
import { useGalleryEnrichment } from '../hooks/useGalleryEnrichment'
import { useOperations } from '../hooks/useOperations'
import useFailoverChains from '../hooks/useFailoverChains'
-import { backendControlApi, modelsApi, nodesApi, systemApi } from '../utils/api'
-import { formatBytes } from '../utils/format'
+import { modelsApi, nodesApi, systemApi } from '../utils/api'
+import { useModelActions } from '../hooks/useModelActions'
import { renderMarkdown, stripMarkdown } from '../utils/markdown'
import { safeHref } from '../utils/url'
import {
@@ -25,6 +28,7 @@ import {
CAP_SPEAKER_RECOGNITION, CAP_EMBEDDINGS, CAP_RERANK,
CAP_VAD, CAP_SCORE, CAP_DECISIONS,
} from '../utils/capabilities'
+import Icon from '../components/Icon'
const USE_CASES = [
{ cap: CAP_CHAT, labelKey: 'chat', route: id => `/app/chat/${encodeURIComponent(id)}` },
@@ -50,12 +54,6 @@ export function modelUseCases(model) {
))
}
-const MODEL_STATE_GROUPS = [
- { id: 'running', labelKey: 'running', icon: 'fa-circle-play' },
- { id: 'idle', labelKey: 'idle', icon: 'fa-pause' },
- { id: 'disabled', labelKey: 'disabled', icon: 'fa-ban' },
-]
-
export function ModelLifecycleDetailShell({
testId,
icon,
@@ -64,6 +62,7 @@ export function ModelLifecycleDetailShell({
ledeTitle,
onBack,
backLabel,
+ closeIcon,
warning,
actions,
stats,
@@ -80,13 +79,14 @@ export function ModelLifecycleDetailShell({
ledeTitle={ledeTitle}
onBack={onBack}
backLabel={backLabel}
+ closeIcon={closeIcon}
warning={warning}
actions={actions}
/>
{Array.isArray(stats) && }
{error && (
- {error}
+ {error}
)}
{children}
@@ -94,6 +94,20 @@ export function ModelLifecycleDetailShell({
)
}
+// A small glyph after a model's name that says one thing about it. The words
+// are on the glyph as its name and tooltip, not as text, so the same words in
+// the inspector are the only visible copy of them.
+function RowMark({ icon, label }) {
+ return (
+
+
+
+ )
+}
+
+// How many rows the loading state draws.
+const SKELETON_ROWS = 6
+
export default function InstalledModels({
addToast,
query,
@@ -101,41 +115,36 @@ export default function InstalledModels({
selectedName,
onQueryChange,
onStateChange,
+ onClearFilters,
onSelect,
+ onOpen,
+ hiddenIds,
+ refreshToken,
+ density,
+ onDensity,
+ searchRef,
+ onOpenCleanup,
+ disk,
}) {
const navigate = useNavigate()
const location = useLocation()
const { t } = useTranslation('models')
- const { models, loading, error: loadError, refetch } = useModels()
+ const { models: allModels, loading, error: loadError, refetch } = useModels()
const { enrichModel } = useGalleryEnrichment()
const { operations } = useOperations()
const { byName: failoverChains } = useFailoverChains()
const [loadedModelIds, setLoadedModelIds] = useState(() => new Set())
- // Admin-only storage report; a 403 for non-admins just hides the size
- // information, it never breaks the page.
- const [storage, setStorage] = useState(null)
- const storageByName = useMemo(() => {
- const map = {}
- for (const m of storage?.models || []) map[m.name] = m
- return map
- }, [storage])
- const storageSummary = useMemo(() => {
- if (!storage) return null
- let shared = 0
- let missing = 0
- for (const f of storage.files || []) {
- if (f.missing) missing++
- else if ((f.models || []).length > 1) shared += f.size_bytes
- }
- return { total: storage.total_bytes, shared, missing }
- }, [storage])
const [aliasTargets, setAliasTargets] = useState({})
const [distributedMode, setDistributedMode] = useState(false)
- const [pendingActions, setPendingActions] = useState(() => new Set())
- const [actionErrors, setActionErrors] = useState({})
- const [confirmDialog, setConfirmDialog] = useState(null)
- const [collapsedGroups, setCollapsedGroups] = useState(() => new Set())
+ // The on-disk sizes. Admin only: for anyone else, or when the read fails, the
+ // table falls back to the gallery's estimates and says so.
+ const storage = useModelStorage(true, refreshToken)
+ const [sort, setSort] = useState({ key: 'name', dir: 'asc' })
const loadedOnce = useRef(false)
+ const bodyRef = useRef(null)
+ // Models waiting in the cleanup sheet's undo window are out of the list: they
+ // are as good as gone, and showing them would invite a second action on them.
+ const models = allModels.filter(model => !hiddenIds?.has(model.id))
const fetchLoadedModels = useCallback(async () => {
try {
@@ -158,10 +167,20 @@ export default function InstalledModels({
}
}, [])
+ const afterAction = useCallback(async () => {
+ refetch()
+ await fetchLoadedModels()
+ }, [refetch, fetchLoadedModels])
+ const {
+ pendingActions, actionErrors, confirmDialog, setConfirmDialog,
+ load: handleLoad, stop: handleStop, toggleState: handleToggleState,
+ togglePinned: handleTogglePinned, remove, reload: handleReload,
+ } = useModelActions({ addToast, afterAction })
+ const handleDelete = modelName => remove(modelName, () => onSelect(null))
+
useEffect(() => {
fetchLoadedModels()
fetchAliases()
- modelsApi.getStorage().then(setStorage).catch(() => setStorage(null))
nodesApi.list().then(() => setDistributedMode(true)).catch(() => setDistributedMode(false))
}, [fetchAliases, fetchLoadedModels])
@@ -181,7 +200,7 @@ export default function InstalledModels({
useEffect(() => {
refetch()
fetchLoadedModels()
- }, [operations.length, fetchLoadedModels, refetch])
+ }, [operations.length, refreshToken, fetchLoadedModels, refetch])
const isRunning = useCallback(model => (
!model.disabled && (
@@ -190,171 +209,186 @@ export default function InstalledModels({
)
), [loadedModelIds])
- const filters = [
- { key: 'all', label: t('lifecycle.filters.all'), icon: 'fa-layer-group' },
- { key: 'running', label: t('lifecycle.filters.running'), icon: 'fa-circle-play' },
- { key: 'idle', label: t('lifecycle.filters.idle'), icon: 'fa-pause' },
- { key: 'disabled', label: t('lifecycle.filters.disabled'), icon: 'fa-ban' },
- { key: 'pinned', label: t('lifecycle.filters.pinned'), icon: 'fa-thumbtack' },
- { key: 'distributed', label: t('lifecycle.filters.distributed'), icon: 'fa-server' },
- ]
-
- const matchesState = model => {
- if (state === 'running') return isRunning(model)
- if (state === 'idle') return !model.disabled && !isRunning(model)
- if (state === 'disabled') return !!model.disabled
- if (state === 'pinned') return !!model.pinned
- if (state === 'distributed') return Array.isArray(model.loaded_on) && model.loaded_on.length > 0
+ const matchesStateKey = (model, key) => {
+ if (key === 'running') return isRunning(model)
+ if (key === 'idle') return !model.disabled && !isRunning(model)
+ if (key === 'disabled') return !!model.disabled
+ if (key === 'pinned') return !!model.pinned
+ if (key === 'distributed') return Array.isArray(model.loaded_on) && model.loaded_on.length > 0
return true
}
+ const countFor = key => models.filter(model => matchesStateKey(model, key)).length
+ const filters = [
+ { key: 'all', label: t('lifecycle.filters.all'), icon: 'layers' },
+ { key: 'running', label: t('lifecycle.filters.running'), icon: 'play-circle' },
+ { key: 'idle', label: t('lifecycle.filters.idle'), icon: 'pause' },
+ { key: 'disabled', label: t('lifecycle.filters.disabled'), icon: 'ban' },
+ { key: 'pinned', label: t('lifecycle.filters.pinned'), icon: 'pin' },
+ { key: 'distributed', label: t('lifecycle.filters.distributed'), icon: 'server' },
+ ].map(f => ({ ...f, count: f.key === 'all' ? models.length : countFor(f.key) }))
+
const normalizedQuery = query.trim().toLowerCase()
- const visibleModels = models.filter(model => (
- matchesState(model) && (
+ const matching = models.filter(model => (
+ matchesStateKey(model, state) && (
!normalizedQuery ||
model.id.toLowerCase().includes(normalizedQuery) ||
(model.backend || '').toLowerCase().includes(normalizedQuery)
)
))
+
+ // A size is what the model really takes on disk. Where the server gives no
+ // such figure (the model is not in its report, or the caller may not read it),
+ // the size of the files the gallery lists stands in, and a model the gallery
+ // does not list has none: the cell says so rather than guessing. The gallery
+ // is only asked after the report has answered, and only about the rest.
+ const diskOf = id => diskEntry(storage.index, id)
+ const estimable = storage.status === 'loading'
+ ? []
+ : models.filter(model => !diskOf(model.id) && enrichModel(model.id)).map(model => model.id)
+ const sizes = useModelSizes(estimable, true)
+ const sizeOf = id => {
+ const disk = diskOf(id)
+ return disk ? disk.size : sizes[id] ?? null
+ }
+
+ // The footer line: what the models directory holds in all, counting a file
+ // that several models use once, how much of it is shared, and how many
+ // references point at nothing.
+ const totals = useMemo(() => {
+ const index = storage.index
+ // An empty report says nothing about the disk: no line is better than "0.0 GB on disk".
+ if (!index || index.byPath.size === 0) return null
+ let shared = 0
+ let missing = 0
+ for (const f of index.byPath.values()) {
+ if (f.missing) missing += 1
+ else if (f.models.length > 1) shared += f.size
+ }
+ return { total: index.total, shared, missing }
+ }, [storage.index])
+
+ const direction = sort.dir === 'asc' ? 1 : -1
+ const visibleModels = [...matching].sort((a, b) => {
+ if (sort.key === 'size') {
+ // Unknown sizes sort last in both directions: a model with no reading is
+ // not the smallest one, it is the one we know least about.
+ const sa = sizeOf(a.id)
+ const sb = sizeOf(b.id)
+ if (sa == null && sb == null) return a.id.localeCompare(b.id)
+ if (sa == null) return 1
+ if (sb == null) return -1
+ return (sa - sb) * direction || a.id.localeCompare(b.id)
+ }
+ return a.id.localeCompare(b.id) * direction
+ })
const selectedModel = selectedName
? models.find(model => model.id === selectedName) || null
: null
- const toggleGroup = useCallback(id => {
- setCollapsedGroups(previous => {
- const next = new Set(previous)
- if (next.has(id)) next.delete(id)
- else next.add(id)
- return next
- })
- }, [])
+ const onSort = key => setSort(prev => (
+ prev.key === key ? { key, dir: prev.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' }
+ ))
- const setPending = (name, pending) => {
- setPendingActions(previous => {
- const next = new Set(previous)
- if (pending) next.add(name)
- else next.delete(name)
- return next
- })
- }
-
- const runAction = async (modelName, action, request, successMessage) => {
- setPending(modelName, true)
- setActionErrors(previous => ({ ...previous, [modelName]: null }))
- try {
- await request()
- if (successMessage) addToast(successMessage, 'success')
- refetch()
- await fetchLoadedModels()
- return true
- } catch (err) {
- setActionErrors(previous => ({
- ...previous,
- [modelName]: t('lifecycle.errors.action', { action, model: modelName, message: err.message }),
- }))
- return false
- } finally {
- setPending(modelName, false)
- }
- }
-
- const handleLoad = modelName => runAction(
- modelName,
- t('lifecycle.actionNames.load'),
- () => backendControlApi.load({ model: modelName }),
- t('lifecycle.toasts.loaded', { model: modelName }),
- )
-
- const handleStop = modelName => {
- setConfirmDialog({
- title: t('lifecycle.confirm.stopTitle'),
- message: t('lifecycle.confirm.stopMessage', { model: modelName }),
- confirmLabel: t('lifecycle.actions.stop'),
+ // The same menu on a row and in the inspector, so the two cannot drift.
+ const menuFor = model => [
+ {
+ key: 'toggle',
+ icon: model.disabled ? 'toggle-on' : 'toggle-off',
+ label: model.disabled ? t('lifecycle.actions.enable') : t('lifecycle.actions.disable'),
+ onClick: () => handleToggleState(model.id, model.disabled),
+ disabled: pendingActions.has(model.id),
+ },
+ {
+ key: 'pin',
+ icon: 'pin',
+ label: model.pinned ? t('lifecycle.actions.unpin') : t('lifecycle.actions.pin'),
+ onClick: () => handleTogglePinned(model.id, model.pinned),
+ disabled: pendingActions.has(model.id) || !!model.disabled,
+ },
+ {
+ key: 'edit',
+ icon: 'edit',
+ label: t('lifecycle.actions.edit'),
+ onClick: () => navigate(`/app/model-editor/${encodeURIComponent(model.id)}`, {
+ state: fromState(location, t('lifecycle.title')),
+ }),
+ },
+ {
+ key: 'logs',
+ icon: 'terminal',
+ label: t('lifecycle.actions.logs'),
+ onClick: () => navigate(`/app/backend-logs/${encodeURIComponent(model.id)}`),
+ },
+ { divider: true },
+ {
+ key: 'delete',
+ icon: 'trash',
+ label: t('lifecycle.actions.delete'),
danger: true,
- onConfirm: async () => {
- setConfirmDialog(null)
- await runAction(
- modelName,
- t('lifecycle.actionNames.stop'),
- () => backendControlApi.shutdown({ model: modelName }),
- t('lifecycle.toasts.stopped', { model: modelName }),
- )
- },
- })
+ onClick: () => handleDelete(model.id),
+ },
+ ]
+
+ const move = useCallback(name => {
+ onSelect(name, { replace: true })
+ bodyRef.current?.querySelector(`[data-entity="${CSS.escape(name)}"]`)?.focus()
+ }, [onSelect])
+ useRestoreRowFocus(selectedName, bodyRef)
+ const names = visibleModels.map(model => model.id)
+ // What "previous" and "next" on a model's page walk: this list, in this order.
+ const order = names.join('\n')
+ useEffect(() => { publishWalk('installed', order ? order.split('\n') : []) }, [order])
+ const tabbable = names.includes(selectedName) ? selectedName : names[0]
+
+ const stateOf = model => {
+ if (model.disabled) return { key: 'disabled', label: t('lifecycle.states.disabled') }
+ if (pendingActions.has(model.id)) return { key: 'busy', label: t('lifecycle.states.working') }
+ if (isRunning(model)) return { key: 'running', label: t('lifecycle.states.running') }
+ return { key: 'idle', label: t('lifecycle.states.idle') }
}
- const handleToggleState = (modelName, disabled) => {
- const operation = disabled ? 'enable' : 'disable'
- return runAction(
- modelName,
- t(`lifecycle.actionNames.${operation}`),
- () => modelsApi.toggleState(modelName, operation),
- t(`lifecycle.toasts.${operation}d`, { model: modelName }),
- )
- }
-
- const handleTogglePinned = (modelName, pinned) => {
- const operation = pinned ? 'unpin' : 'pin'
- return runAction(
- modelName,
- t(`lifecycle.actionNames.${operation}`),
- () => modelsApi.togglePinned(modelName, operation),
- t(`lifecycle.toasts.${operation}ned`, { model: modelName }),
- )
- }
-
- const handleDelete = modelName => {
- setConfirmDialog({
- title: t('lifecycle.confirm.deleteTitle'),
- message: t('lifecycle.confirm.deleteMessage', { model: modelName }),
- confirmLabel: t('lifecycle.actions.delete'),
- danger: true,
- onConfirm: async () => {
- setConfirmDialog(null)
- const deleted = await runAction(
- modelName,
- t('lifecycle.actionNames.delete'),
- () => modelsApi.deleteByName(modelName),
- t('lifecycle.toasts.deleted', { model: modelName }),
- )
- if (deleted) onSelect(null)
- },
- })
- }
-
- const handleReload = () => runAction(
- 'models',
- t('lifecycle.actionNames.update'),
- modelsApi.reload,
- t('lifecycle.toasts.updated'),
- )
-
- const railItems = visibleModels.map(model => {
+ const rowActions = model => {
+ const pending = pendingActions.has(model.id)
const running = isRunning(model)
- let groupId = 'idle'
- let stripe = 'idle'
- let meta = t('lifecycle.states.idle')
- let metaTone
- if (model.disabled) {
- groupId = 'disabled'
- stripe = 'off'
- meta = t('lifecycle.states.disabled')
- } else if (pendingActions.has(model.id)) {
- meta = t('lifecycle.states.working')
- metaTone = 'busy'
- } else if (running) {
- groupId = 'running'
- stripe = 'run'
- meta = t('lifecycle.states.running')
- metaTone = 'ok'
- }
- return { id: model.id, name: model.id, icon: 'fa-brain', groupId, stripe, meta, metaTone }
- })
-
- const groups = MODEL_STATE_GROUPS.map(group => ({
- id: group.id,
- icon: group.icon,
- label: t(`lifecycle.filters.${group.labelKey}`),
- }))
+ return (
+ <>
+ {!model.disabled && !running && (
+ { e.stopPropagation(); handleLoad(model.id) }} disabled={pending}>
+
+ {pending ? t('lifecycle.actions.loading') : t('lifecycle.actions.load')}
+
+ )}
+ {running && (
+ { e.stopPropagation(); handleStop(model.id) }} disabled={pending}>
+ {t('lifecycle.actions.stop')}
+
+ )}
+ {model.disabled && (
+ { e.stopPropagation(); handleToggleState(model.id, true) }} disabled={pending}>
+ {t('lifecycle.actions.enable')}
+
+ )}
+ e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
+
+
+ { e.stopPropagation(); onOpen?.(model.id) }}
+ >
+
+
+ >
+ )
+ }
const selectedPane = selectedModel ? (() => {
const enriched = enrichModel(selectedModel.id)
@@ -365,12 +399,13 @@ export default function InstalledModels({
return (
onSelect(null)}
- backLabel={t('lifecycle.installed.backToAll')}
+ backLabel={t('ledger.closeInspector')}
+ closeIcon
error={actionErrors[selectedModel.id]}
stats={[
{
@@ -383,13 +418,13 @@ export default function InstalledModels({
tone: running ? 'ok' : undefined,
},
{ label: t('lifecycle.detail.backend'), value: selectedModel.backend || t('lifecycle.detail.auto') },
- storageByName[selectedModel.id]
- ? { label: t('lifecycle.detail.size'), value: formatBytes(storageByName[selectedModel.id].size_bytes) }
+ sizeOf(selectedModel.id) != null
+ ? { label: t('ledger.columns.size'), value: gbLabel(sizeOf(selectedModel.id)) }
: null,
- storageByName[selectedModel.id]?.missing?.length
+ diskOf(selectedModel.id)?.missing.length > 0
? {
label: t('lifecycle.detail.missingFiles'),
- value: t('lifecycle.detail.missingCount', { count: storageByName[selectedModel.id].missing.length }),
+ value: t('lifecycle.detail.missingCount', { count: diskOf(selectedModel.id).missing.length }),
tone: 'warn',
}
: null,
@@ -399,58 +434,30 @@ export default function InstalledModels({
]}
actions={(
<>
+ onOpen?.(selectedModel.id)}
+ data-testid="inspector-open-page"
+ aria-keyshortcuts="o"
+ >
+ {t('page.openDetails')}
+
{!selectedModel.disabled && !running && (
- handleLoad(selectedModel.id)} disabled={pending}>
-
+ handleLoad(selectedModel.id)} disabled={pending}>
+
{pending ? t('lifecycle.actions.loading') : t('lifecycle.actions.load')}
)}
{running && (
- handleStop(selectedModel.id)} disabled={pending}>
- {t('lifecycle.actions.stop')}
+ handleStop(selectedModel.id)} disabled={pending}>
+ {t('lifecycle.actions.stop')}
)}
handleToggleState(selectedModel.id, selectedModel.disabled),
- disabled: pending,
- },
- {
- key: 'pin',
- icon: 'fa-thumbtack',
- label: selectedModel.pinned ? t('lifecycle.actions.unpin') : t('lifecycle.actions.pin'),
- onClick: () => handleTogglePinned(selectedModel.id, selectedModel.pinned),
- disabled: pending || !!selectedModel.disabled,
- },
- {
- key: 'edit',
- icon: 'fa-pen-to-square',
- label: t('lifecycle.actions.edit'),
- onClick: () => navigate(`/app/model-editor/${encodeURIComponent(selectedModel.id)}`, {
- state: fromState(location, t('lifecycle.title')),
- }),
- },
- {
- key: 'logs',
- icon: 'fa-terminal',
- label: t('lifecycle.actions.logs'),
- onClick: () => navigate(`/app/backend-logs/${encodeURIComponent(selectedModel.id)}`),
- },
- { divider: true },
- {
- key: 'delete',
- icon: 'fa-trash',
- label: t('lifecycle.actions.delete'),
- danger: true,
- onClick: () => handleDelete(selectedModel.id),
- },
- ]}
+ items={menuFor(selectedModel)}
/>
>
)}
@@ -458,18 +465,18 @@ export default function InstalledModels({
{(aliasTargets[selectedModel.id] || selectedModel.source === 'registry-only' || chain) && (
{selectedModel.source === 'registry-only' && (
-
- {t('lifecycle.detail.adopted')}
+
+ {t('lifecycle.detail.adopted')}
)}
{aliasTargets[selectedModel.id] && (
-
- {t('lifecycle.detail.alias', { target: aliasTargets[selectedModel.id] })}
+
+ {t('lifecycle.detail.alias', { target: aliasTargets[selectedModel.id] })}
)}
{chain && (
-
- {t('lifecycle.detail.chain', { target: chain.active })}
+
+