Merge branch 'master' into mr/watchdog-keep-tracking-failed-stop

This commit is contained in:
hcharbonnier authored and GitHub committed 2026-10-23 17:50:22 +02:00
commit 3d2248d8fd
120 files changed
+3599 -2445

No files matched your search

+2 -1
View File
@@ -40,9 +40,10 @@ export function createFaceRecognition(ctx, api, identity) {
function FeatureOff() {
return (
<main className="page idn-page idn-off" data-testid="face-off">
<ui.PageHeader title={t('faces.title')} supporting={t('faces.lede')} />
<section className="idn-needed dk-card">
<span className="dk-badge dk-badge--warn"><Icon name="lock" /> {t('off.badge')}</span>
<h1 className="idn-h2">{t('off.face.title')}</h1>
<h2 className="idn-h2">{t('off.face.title')}</h2>
<p className="idn-needed__text">{t('off.face.text')}</p>
<details className="idn-disclosure" open>
<summary><Icon name="chevron-right" /> {t('needed.whatTitle')}</summary>
+2 -2
View File
@@ -918,8 +918,8 @@ export function createFineTune(ctx, api, shared) {
return (
<div className="page page--medium bt-page" data-testid="fine-tune-page">
<ui.PageHeader
title={<>{t('fineTune.title')} <span className="dk-badge dk-badge--warn bt-experimental">{t('landing.experimental')}</span></>}
supporting={t('fineTune.lede')}
title={t('fineTune.title')}
supporting={<><span className="dk-badge dk-badge--warn bt-experimental">{t('landing.experimental')}</span> {t('fineTune.lede')}</>}
actions={(
<div className="bt-head-acts">
{selectedJob && (
+8 -2
View File
@@ -14,7 +14,7 @@
.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-experimental { vertical-align: middle; margin-right: 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); }
@@ -43,6 +43,12 @@
.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; }
/* A step that is not done yet shows its number, so an open circle reads as
"to do" and not as a checkbox that failed to tick. */
.bt-page { counter-reset: bt-step; }
.bt-section { counter-increment: bt-step; }
.bt-section[data-done="false"] .bt-section__mark { color: var(--dk-muted); font: var(--dk-weight-bold) var(--dk-text-12) / 1 var(--dk-font-mono); }
.bt-section[data-done="false"] .bt-section__mark::before { content: counter(bt-step); }
.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); }
@@ -101,7 +107,7 @@
.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 { position: sticky; margin-top: var(--dk-space-4); 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; }
@@ -42,7 +42,7 @@ export function createKnownList(ctx) {
<div className="dk-empty-icon"><Icon name={kind === 'face' ? 'smile' : 'mic'} /></div>
<h3 className="dk-empty-title">{t(`${kind}.emptyTitle`)}</h3>
<p className="dk-empty-text">{t(`${kind}.emptyText`)}</p>
<button type="button" className="dk-btn dk-btn--primary" onClick={() => onEnrol()} disabled={!canEnrol}><Icon name="plus" /> {t(`${kind}.enrol`)}</button>
<button type="button" className="dk-btn dk-btn--primary" onClick={() => onEnrol()} disabled={!canEnrol} data-testid="enrol-open"><Icon name="plus" /> {t(`${kind}.enrol`)}</button>
</div>
)
}
@@ -314,9 +314,12 @@ export function createWorkbench(ctx, deps) {
<section className="idn-known" aria-labelledby={`${kind}-known-title`}>
<div className="idn-known__head">
<h2 className="idn-h2" id={`${kind}-known-title`}>{t(`${kind}.knownTitle`)} <span className="dk-hubtab-count">{known}</span></h2>
<button type="button" className="dk-btn dk-btn--secondary" onClick={() => openSheet({})} disabled={!model} data-testid="enrol-open">
<Icon name="plus" /> {t(`${kind}.enrol`)}
</button>
{/* With nobody saved yet the empty state below carries the one enrol button. */}
{(known > 0 || reg.extras.length > 0) && (
<button type="button" className="dk-btn dk-btn--secondary" onClick={() => openSheet({})} disabled={!model} data-testid="enrol-open">
<Icon name="plus" /> {t(`${kind}.enrol`)}
</button>
)}
</div>
<p className="dk-hint idn-known__note" data-testid="registry-note">{t(reg.searched ? 'known.noteSearched' : 'known.note', { noun })}</p>
<KnownList
+8 -2
View File
@@ -14,7 +14,7 @@
.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-experimental { vertical-align: middle; margin-right: 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); }
@@ -43,6 +43,12 @@
.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; }
/* A step that is not done yet shows its number, so an open circle reads as
"to do" and not as a checkbox that failed to tick. */
.bt-page { counter-reset: bt-step; }
.bt-section { counter-increment: bt-step; }
.bt-section[data-done="false"] .bt-section__mark { color: var(--dk-muted); font: var(--dk-weight-bold) var(--dk-text-12) / 1 var(--dk-font-mono); }
.bt-section[data-done="false"] .bt-section__mark::before { content: counter(bt-step); }
.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); }
@@ -101,7 +107,7 @@
.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 { position: sticky; margin-top: var(--dk-space-4); 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; }
+2 -2
View File
@@ -417,8 +417,8 @@ export function createQuantize(ctx, api, shared) {
return (
<div className="page page--medium bt-page quantize-page" data-testid="quantize-page">
<ui.PageHeader
title={<>{t('quantize.title')} <span className="dk-badge dk-badge--warn bt-experimental">{t('landing.experimental')}</span></>}
supporting={t('quantize.lede')}
title={t('quantize.title')}
supporting={<><span className="dk-badge dk-badge--warn bt-experimental">{t('landing.experimental')}</span> {t('quantize.lede')}</>}
actions={selectedJob && (
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => setSelectedJob(null)} data-testid="job-back">
<Icon name="plus" /> {t('quantize.newJob')}
+8 -2
View File
@@ -14,7 +14,7 @@
.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-experimental { vertical-align: middle; margin-right: 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); }
@@ -43,6 +43,12 @@
.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; }
/* A step that is not done yet shows its number, so an open circle reads as
"to do" and not as a checkbox that failed to tick. */
.bt-page { counter-reset: bt-step; }
.bt-section { counter-increment: bt-step; }
.bt-section[data-done="false"] .bt-section__mark { color: var(--dk-muted); font: var(--dk-weight-bold) var(--dk-text-12) / 1 var(--dk-font-mono); }
.bt-section[data-done="false"] .bt-section__mark::before { content: counter(bt-step); }
.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); }
@@ -101,7 +107,7 @@
.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 { position: sticky; margin-top: var(--dk-space-4); 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; }
@@ -154,7 +154,7 @@ export default function LineageView({
<Icon name="arrow-left" /> {t('studio.lineage.back')}
</button>
<div>
<h1 className="studio-lineage__title" title={root.title || undefined}>{clip(root.title, 72) || label(root.type)}</h1>
<h2 className="studio-lineage__title" title={root.title || undefined}>{clip(root.title, 72) || label(root.type)}</h2>
<p className="studio-lineage__sub">
{members.length > 1 ? t('studio.lineage.sub', { count: members.length }) : t('studio.lineage.subOne')}
{' · '}{relativeTime(members[members.length - 1].createdAt)}
@@ -266,7 +266,7 @@ export function ComposeCard({
return (
<form className="ws-compose" onSubmit={onSubmit} ref={ws?.composeRef} data-testid="ws-compose" data-rerun={ws?.rerunning || undefined} noValidate>
<header className="ws-compose__head">
<h1 className="page-title ws-title">{icon && <Icon name={icon} />}{title}</h1>
<h2 className="ws-title">{icon && <Icon name={icon} />}{title}</h2>
{lede && <span className="ws-lede">{lede}</span>}
</header>
{handoff}
@@ -42,21 +42,29 @@
/* 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-head { padding: var(--spacing-xl) var(--spacing-xl) 0; }
.studio-head .page-header { margin-bottom: var(--dk-space-3); }
/* One row at any width: eight tabs do not fit a narrow window, so the row
scrolls sideways instead of dropping the last tab onto a second line. */
.studio-tabs {
display: flex;
flex-wrap: wrap;
flex-wrap: nowrap;
gap: var(--dk-space-1);
padding: var(--dk-space-4) var(--spacing-xl) 0;
padding: 3px var(--dk-space-2) 3px var(--spacing-xl);
overflow-x: auto;
scrollbar-width: none;
}
.studio-tabs::-webkit-scrollbar { display: none; }
.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);
padding: 0 10px;
border: 0;
border-radius: var(--dk-radius-3);
flex: none;
background: transparent;
color: var(--dk-muted);
font: var(--dk-weight-semibold) var(--dk-text-13) / 1 var(--dk-font-body);
@@ -73,8 +81,8 @@
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; }
.studio-head { padding: var(--dk-space-4) var(--dk-space-4) 0; }
.studio-tabs { padding-left: var(--dk-space-4); padding-right: var(--dk-space-4); }
}
/* --- Composer ------------------------------------------------------------ */
@@ -166,6 +166,9 @@
text-align: right;
}
.ws-chip__input::placeholder { color: var(--dk-muted); opacity: 1; }
/* The label of an input chip is set off from what is typed (or its placeholder)
by a hairline, so "Voice" and "Optional speaker or voice ID" read as two things. */
.ws-chip--input > span { align-self: stretch; display: inline-flex; align-items: center; padding-right: var(--dk-space-3); border-right: 1px solid var(--dk-edge); color: var(--dk-text); }
/* 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. */
+19 -1
View File
@@ -16,6 +16,7 @@ import {
} from '../utils/capabilities'
import { upscaleModels } from '../utils/upscale'
import Icon from '../components/Icon'
import PageHeader from '../components/PageHeader'
import '../components/studio/studio.css'
// One table for the six generators: the capability that makes a modality
@@ -34,6 +35,14 @@ const MODALITIES = [
const OVERVIEW_TAB = { key: 'overview', icon: 'compass' }
// Short addresses people type or older links carry, and the tab each opens.
// The 3D tab is keyed `threed`, but /studio/3d is the address anyone expects.
const TAB_ALIASES = { '3d': 'threed', image: 'images' }
export function resolveTab(pathTab, keys) {
const key = TAB_ALIASES[pathTab] || pathTab
return keys.includes(key) ? key : 'overview'
}
const TAB_COMPONENTS = {
diarization: Diarization,
images: ImageGen,
@@ -82,7 +91,7 @@ export default function Studio() {
// Overview is the fallback for anything unrecognised or gated off. Landing on
// Images was never a decision, only the first entry in an array.
const activeTab = tabs.some(tab => tab.key === pathTab) ? pathTab : 'overview'
const activeTab = resolveTab(pathTab, tabs.map(tab => tab.key))
const setTab = (key) => navigate(key === 'overview' ? '/app/studio' : `/app/studio/${key}`)
@@ -101,6 +110,15 @@ export default function Studio() {
return (
<div>
{/* One page title for every tab, at the size of every other add-on page.
The card of a generator below it is a section, not a second title. */}
<div className="studio-head">
<PageHeader
title={t('studio.overview.title')}
supporting={activeTab === 'overview' ? t('studio.overview.subtitle') : undefined}
actions={activeTab === 'overview' ? <span className="studio-key-hint"><kbd className="dk-kbd">/</kbd> {t('studio.composer.startTyping')}</span> : undefined}
/>
</div>
<div className="studio-tabs">
{tabs.map(tab => {
const dot = dotFor(tab)
@@ -1,7 +1,6 @@
import { useCallback, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import PageHeader from '../components/PageHeader'
import Icon from '../components/Icon'
// eslint-disable-next-line no-unused-vars
import StudioComposer from '../components/studio/StudioComposer'
@@ -80,12 +79,6 @@ export default function StudioOverview({ modalities, upscalers, modelsLoading, m
return (
<div data-testid="studio-overview" className="page-pad studio-front">
<PageHeader
title={t('studio.overview.title')}
supporting={t('studio.overview.subtitle')}
actions={<span className="studio-key-hint"><kbd className="dk-kbd">/</kbd> {t('studio.composer.startTyping')}</span>}
/>
{modelsError ? (
<div className="studio-note studio-note--error studio-note--standalone" role="alert" data-testid="studio-models-error">
<h3>{t('studio.error.title')}</h3>
+17 -11
View File
@@ -3,6 +3,7 @@
// listing for this person; what the page offers follows it, and the host
// refuses the rest.
import { describeError } from './lib/errors.js'
import { canShare } from './lib/layout.js'
export function createDatabase(ctx, ui, api, hooks, parts) {
const { sdk, h, R, Fragment, t } = ctx
@@ -100,7 +101,7 @@ export function createDatabase(ctx, ui, api, hooks, parts) {
<header className="tbl-head">
<div className="tbl-head__main">
<div>{back}</div>
<h1 className="tbl-title" data-testid="tbl-db-name">{name}</h1>
<h1 className="page-title tbl-title" data-testid="tbl-db-name">{name}</h1>
<div className="tbl-db__meta">
<RoleBadge role={resource.role} />
{!isOwner && resource.owner ? <span className="tbl-muted">{t('db.sharedBy', { name: resource.owner })}</span> : null}
@@ -109,9 +110,12 @@ export function createDatabase(ctx, ui, api, hooks, parts) {
</div>
{isOwner ? (
<div className="tbl-toolbar__group">
<Button size="sm" icon="users" onClick={() => open(t('share.title', { name }), ({ close }) => (
<sharing.Sharing resource={{ id, name }} sharing={data.listing.sharing} onChanged={view.refresh} onLost={onLost} close={close} />
))}>{t('db.share')}</Button>
{/* Without named accounts there is nobody to share with, so no button. */}
{canShare(data.listing) ? (
<Button size="sm" icon="users" onClick={() => open(t('share.title', { name }), ({ close }) => (
<sharing.Sharing resource={{ id, name }} sharing={data.listing.sharing} onChanged={view.refresh} onLost={onLost} close={close} />
))}>{t('db.share')}</Button>
) : null}
<Button size="sm" icon="edit" onClick={() => open(t('rename.title'), ({ close }) => (
<databases.NameDialog initial={name} label={t('rename.submit')} close={close} onDone={() => view.refresh()}
submit={(n, operationId) => api.rename(id, n, db.revision(), { operationId })} />
@@ -127,13 +131,15 @@ export function createDatabase(ctx, ui, api, hooks, parts) {
{view.state === 'error' ? <Problem error={view.error} onRetry={view.reload} /> : null}
{notice ? <Banner tone="success" testid="tbl-notice">{notice}</Banner> : null}
<nav className="tbl-tables" aria-label={t('db.tables')}>
<ul className="tbl-tabs" data-testid="tbl-tables">
{tables.map(x => (
<li key={x.name}>
<button type="button" className={current && current.name === x.name ? 'tbl-tab tbl-tab--on' : 'tbl-tab'} aria-current={current && current.name === x.name ? 'true' : undefined} onClick={() => select(x.name)} data-testid="tbl-table-tab">{x.name}</button>
</li>
))}
</ul>
{tables.length > 0 ? (
<ul className="tbl-tabs" data-testid="tbl-tables">
{tables.map(x => (
<li key={x.name}>
<button type="button" className={current && current.name === x.name ? 'tbl-tab tbl-tab--on' : 'tbl-tab'} aria-current={current && current.name === x.name ? 'true' : undefined} onClick={() => select(x.name)} data-testid="tbl-table-tab">{x.name}</button>
</li>
))}
</ul>
) : null}
{isOwner ? <Button size="sm" icon="plus" onClick={() => open(t('db.newTable'), ({ close }) => <schema.TableNameDialog db={db} onDone={changedSchema} onLost={onLost} close={close} />)}>{t('db.newTable')}</Button> : null}
</nav>
{current ? (
+1 -1
View File
@@ -179,7 +179,7 @@ export function createDatabases(ctx, ui, api, hooks) {
<section className="tbl-page" data-testid="tbl-list-page" aria-labelledby="tbl-list-title">
<header className="tbl-head">
<div>
<h1 id="tbl-list-title" className="tbl-title">{t('list.heading')}</h1>
<h1 id="tbl-list-title" className="page-title tbl-title">{t('list.heading')}</h1>
<p className="tbl-muted">{t('list.intro')}</p>
</div>
{canCreate && data && data.resources.length > 0 ? <Button variant="primary" icon="plus" onClick={create}>{t('list.new')}</Button> : null}
+2 -1
View File
@@ -201,7 +201,8 @@ export function createSchema(ctx, ui, fields, hooks) {
}
function ColumnList({ table, actions }) {
if (table.columns.length === 0) return <p className="tbl-muted">{t('grid.noColumns.title')}</p>
// The grid above already says the table has no columns; saying it again here only repeats it.
if (table.columns.length === 0) return null
return (
<ul className="tbl-columns" data-testid="tbl-columns">
{table.columns.map(c => (
+8 -3
View File
@@ -7,6 +7,7 @@ import { setAll, toggle, allSelected } from './lib/batch.js'
import { display } from './lib/codecs.js'
import { describeError } from './lib/errors.js'
import { exportArguments } from './lib/export.js'
import { addRowsPlace } from './lib/layout.js'
const CACHE_LIMIT = 40
@@ -112,6 +113,10 @@ export function createTableView(ctx, ui, api, hooks, rows, filters, exporter) {
<exporter.ExportDialog db={db} table={table} args={built.arguments} filtered={hasFilter} close={close} onLost={onLost} />
))
}
// One primary action per screen: the empty state of a table with no rows
// carries its own Add rows button, so the toolbar keeps its button for the
// other cases.
const addPlace = addRowsPlace({ canWrite, columnCount: columns.length, loaded: Boolean(shown), rowCount: pageRows.length, filtered: hasFilter })
let body
if (columns.length === 0) {
body = <EmptyState icon="table" title={t('grid.noColumns.title')} text={db.role === 'owner' ? t('grid.noColumns.owner') : t('grid.noColumns.other')} />
@@ -122,7 +127,7 @@ export function createTableView(ctx, ui, api, hooks, rows, filters, exporter) {
} else if (shown && pageRows.length === 0) {
body = hasFilter
? <EmptyState icon="search" title={t('grid.noMatch.title')} text={t('grid.noMatch.text')}><Button onClick={clearFilter}>{t('filter.clear')}</Button></EmptyState>
: <EmptyState icon="table" title={t('grid.noRows.title')} text={t('grid.noRows.text')}>{canWrite ? <Button variant="primary" icon="plus" onClick={addRows}>{t('grid.addRows')}</Button> : null}</EmptyState>
: <EmptyState icon="table" title={t('grid.noRows.title')} text={t('grid.noRows.text')}>{addPlace === 'empty' ? <Button variant="primary" icon="plus" onClick={addRows}>{t('grid.addRows')}</Button> : null}</EmptyState>
} else if (shown) {
body = (
<div className="tbl-gridwrap" data-testid="tbl-grid-wrap">
@@ -191,9 +196,9 @@ export function createTableView(ctx, ui, api, hooks, rows, filters, exporter) {
<Button size="sm" icon="refresh" onClick={() => { setBump(n => n + 1) }}>{t('grid.refresh')}</Button>
<Button size="sm" icon="download" disabled={columns.length === 0} onClick={exportFile}>{t('export.action')}</Button>
</div>
{canWrite ? (
{addPlace === 'toolbar' ? (
<div className="tbl-toolbar__group">
<Button size="sm" variant="primary" icon="plus" disabled={columns.length === 0} onClick={addRows}>{t('grid.addRows')}</Button>
<Button size="sm" variant="primary" icon="plus" onClick={addRows}>{t('grid.addRows')}</Button>
</div>
) : null}
</div>
+18
View File
@@ -0,0 +1,18 @@
// Which of two controls a screen offers, so a screen never carries two for the
// same action. Pure rules, no page code.
// Sharing needs named accounts to share with. An installation with one local
// owner has none, and the listing says so; the page then has no Share button.
export function canShare(listing) {
return Boolean(listing && listing.sharing)
}
// Where "Add rows" is offered for the open table:
// 'toolbar' the toolbar button, for a table that has rows or a filter in use
// 'empty' the button of the empty state, for a table with columns and no rows
// 'none' a reader, or a table with no columns to fill
export function addRowsPlace({ canWrite, columnCount, loaded, rowCount, filtered }) {
if (!canWrite || columnCount === 0) return 'none'
if (loaded && rowCount === 0 && !filtered) return 'empty'
return 'toolbar'
}
+2 -2
View File
@@ -9,7 +9,7 @@
.tbl-page { display: flex; flex-direction: column; gap: var(--dk-space-4, 16px); min-width: 0; }
.tbl-head { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: var(--dk-space-3, 12px); }
.tbl-head__main { display: flex; flex-direction: column; gap: var(--dk-space-2, 8px); min-width: 0; }
.tbl-title { margin: 0; font-size: var(--dk-text-18, 18px); font-weight: var(--dk-weight-semibold, 600); overflow-wrap: anywhere; }
.tbl-title { margin: 0; overflow-wrap: anywhere; }
.tbl-subtitle { margin: 0; font-size: var(--dk-text-14, 14px); font-weight: var(--dk-weight-semibold, 600); }
.tbl-muted { margin: 0; color: var(--dk-muted); }
.tbl-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@@ -36,7 +36,7 @@ a.tbl-db__name:hover { text-decoration: underline; }
.tbl-tabs { display: flex; flex-wrap: wrap; gap: var(--dk-space-1, 4px); min-width: 0; }
.tbl-tab { min-height: 32px; padding: 0 var(--dk-space-3, 12px); border: 1px solid transparent; border-radius: var(--dk-radius-1, 4px); background: transparent; color: var(--dk-text); font: inherit; cursor: pointer; }
.tbl-tab:hover { background: var(--dk-hover); }
.tbl-tab--on { border-color: var(--dk-edge); background: var(--dk-inset); font-weight: var(--dk-weight-semibold, 600); }
.tbl-tab--on { border-color: transparent; background: var(--dk-accent-wash); color: var(--dk-accent-text); font-weight: var(--dk-weight-semibold, 600); }
/* Toolbar, selection bar, pager */
.tbl-tableview { display: flex; flex-direction: column; gap: var(--dk-space-3, 12px); min-width: 0; }
+23
View File
@@ -0,0 +1,23 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { addRowsPlace, canShare } from '../src/lib/layout.js'
test('layout: Share is offered only where the installation has named accounts', () => {
assert.equal(canShare({ sharing: true }), true)
assert.equal(canShare({ sharing: false }), false)
assert.equal(canShare({}), false)
assert.equal(canShare(null), false)
})
test('layout: a table with no rows offers Add rows once, in its empty state', () => {
const base = { canWrite: true, columnCount: 2, loaded: true, rowCount: 0, filtered: false }
assert.equal(addRowsPlace(base), 'empty')
assert.equal(addRowsPlace({ ...base, rowCount: 3 }), 'toolbar')
assert.equal(addRowsPlace({ ...base, filtered: true }), 'toolbar')
assert.equal(addRowsPlace({ ...base, loaded: false }), 'toolbar')
})
test('layout: a reader, or a table with no columns, is offered no Add rows', () => {
assert.equal(addRowsPlace({ canWrite: false, columnCount: 2, loaded: true, rowCount: 0, filtered: false }), 'none')
assert.equal(addRowsPlace({ canWrite: true, columnCount: 0, loaded: true, rowCount: 0, filtered: false }), 'none')
})
+2 -1
View File
@@ -10,7 +10,8 @@
.talk-page a:focus-visible,
.talk-page input:focus-visible { outline: 2px solid var(--dk-accent-text); outline-offset: 2px; }
.talk-hd { display: flex; align-items: center; gap: 12px; min-height: 56px; padding: 8px 0; }
.talk-hd__title { margin: 0; font-size: var(--dk-text-20, 20px); font-weight: var(--dk-weight-semibold, 600); }
/* The title takes the host's page-title size, like every other add-on page. */
.talk-hd__title { margin: 0; }
.talk-hd__sub { color: var(--dk-muted); font-family: var(--font-mono); font-size: 12px; }
.talk-hd__acts { display: flex; align-items: center; gap: 2px; margin-left: auto; }
.talk-icobtn {
+34 -24
View File
@@ -29,6 +29,7 @@ export function createSpeech(ctx) {
const selected = useMemo(() => models.list.find(m => m.id === model) || null, [models.list, model])
const cloning = Boolean(selected?.voice_cloning)
const noModels = models.status === 'ready' && models.list.length === 0
useEffect(() => {
if (!cloning) { setProfiles([]); setVoiceId(''); return undefined }
@@ -56,35 +57,44 @@ export function createSpeech(ctx) {
return (
<main className="page vc-speech" data-testid="voice-speech">
<ui.PageHeader title={t('speech.title')} supporting={t('speech.lede')} />
<ui.PageHeader title={t('voices.title')} supporting={t('speech.lede')} />
{tabs}
<label className="vc-field">
<span>{t('speech.model')}</span>
<ui.ModelSelector value={model} onChange={setModel} options={models.list.map(m => m.id)} loading={models.status === 'loading'} />
</label>
{cloning ? (
<form className="vc-speech__form dk-card" onSubmit={e => { e.preventDefault(); run() }} aria-label={t('voices.tab.speech')}>
<label className="vc-field">
<span>{t('speech.voice')}</span>
<select className="dk-input" value={voiceId} onChange={e => setVoiceId(e.target.value)} aria-label={t('speech.voice')}>
<option value="">{t('speech.defaultVoice')}</option>
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
<span className="dk-label">{t('speech.model')}</span>
<ui.ModelSelector value={model} onChange={setModel} options={models.list.map(m => m.id)} loading={models.status === 'loading'} />
{noModels && (
<span className="dk-hint" data-testid="speech-no-models">
{t('speech.noModels')} <a href={`${ctx.basePath}/app/models`}>{t('speech.noModelsLink')}</a>
</span>
)}
</label>
) : (
{cloning ? (
<label className="vc-field">
<span className="dk-label">{t('speech.voice')}</span>
<select className="dk-input" value={voiceId} onChange={e => setVoiceId(e.target.value)} aria-label={t('speech.voice')}>
<option value="">{t('speech.defaultVoice')}</option>
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</label>
) : (
<label className="vc-field">
<span className="dk-label">{t('speech.voice')}</span>
<input className="dk-input" value={manualVoice} placeholder={t('speech.voicePlaceholder')} onChange={e => setManualVoice(e.target.value)} />
</label>
)}
<label className="vc-field">
<span>{t('speech.voice')}</span>
<input className="dk-input" value={manualVoice} onChange={e => setManualVoice(e.target.value)} />
<span className="dk-label">{t('speech.text')}</span>
<textarea className="dk-input" rows={5} value={text} placeholder={t('speech.textPlaceholder')} onChange={e => setText(e.target.value)} />
</label>
)}
<label className="vc-field">
<span>{t('speech.text')}</span>
<textarea className="dk-input" rows={5} value={text} onChange={e => setText(e.target.value)} />
</label>
<label className="vc-field">
<span>{t('speech.instructions')}</span>
<input className="dk-input" value={instructions} onChange={e => setInstructions(e.target.value)} />
</label>
<ui.Button variant="primary" busy={busy} onClick={run}>{t('speech.generate')}</ui.Button>
<label className="vc-field">
<span className="dk-label">{t('speech.instructions')}</span>
<input className="dk-input" value={instructions} onChange={e => setInstructions(e.target.value)} />
</label>
<div className="vc-speech__acts">
<ui.Button variant="primary" type="submit" busy={busy}>{t('speech.generate')}</ui.Button>
</div>
</form>
{error && <ui.ErrorWithTraceLink message={error} />}
{audioUrl && <ui.WaveformPlayer src={audioUrl} label={t('speech.result')} />}
{!viewer.hasFeature('audio_speech') && <p className="vc-note">{t('speech.noAccess')}</p>}
+3 -2
View File
@@ -366,14 +366,15 @@ JSON`
return (
<main className="page voice-library-page" data-testid="voice-library">
<ui.PageHeader
title={<>{t('voiceLibrary.title')} <span className="dk-hubtab-count">{profiles.length}</span></>}
title={t('voices.title')}
supporting={t('voiceLibrary.subtitle')}
actions={(
<div className="idn-speech__acts">
{cloningModels.length > 0
? <span className="dk-badge dk-badge--ok"><Icon name="check" /> {t(cloningModels.length === 1 ? 'voiceLibrary.summary.modelsReady_one' : 'voiceLibrary.summary.modelsReady_other', { count: cloningModels.length })}</span>
: <span className="dk-badge dk-badge--warn">{t('voiceLibrary.summary.noModels')}</span>}
{newButton('dk-btn dk-btn--secondary')}
{/* With no voices the empty state below carries the one primary action. */}
{profiles.length > 0 && newButton('dk-btn dk-btn--secondary')}
</div>
)}
/>
+2 -1
View File
@@ -38,10 +38,11 @@ export function createVoiceRecognition(ctx, api, identity) {
function FeatureOff({ feature, tabs }) {
return (
<main className="page idn-page idn-off" data-testid="voice-off">
<ui.PageHeader title={t('voices.title')} supporting={t('voices.lede')} />
{tabs}
<section className="idn-needed dk-card">
<span className="dk-badge dk-badge--warn"><Icon name="lock" /> {t('off.badge')}</span>
<h1 className="idn-h2">{t(`off.${feature}.title`)}</h1>
<h2 className="idn-h2">{t(`off.${feature}.title`)}</h2>
<p className="idn-needed__text">{t(`off.${feature}.text`)}</p>
<details className="idn-disclosure" open>
<summary><Icon name="chevron-right" /> {t('needed.whatTitle')}</summary>
+4
View File
@@ -11,6 +11,10 @@ export default {
"speech.instructions": "Instructions (optional)",
"speech.generate": "Generate speech",
"speech.result": "Result",
"speech.noModels": "No text-to-speech model is installed.",
"speech.noModelsLink": "Get one from the model gallery.",
"speech.voicePlaceholder": "Optional speaker or voice ID",
"speech.textPlaceholder": "What should it say?",
"speech.noAccess": "Your account has no access to text-to-speech. The server will refuse the request.",
"voices.title": "Voices",
"voices.lede": "Recognise who is speaking, and keep voices that text-to-speech can use.",
+4
View File
@@ -1,5 +1,9 @@
.vc-undo { display: flex; gap: var(--dk-space-2, 8px); align-items: center; }
.vc-field { display: flex; flex-direction: column; gap: var(--dk-space-1, 4px); margin-bottom: var(--dk-space-3, 12px); }
.vc-speech__form { display: flex; flex-direction: column; gap: var(--dk-space-3, 12px); max-width: 640px; padding: var(--dk-space-4, 16px) var(--dk-space-5, 20px); margin-bottom: var(--dk-space-3, 12px); }
.vc-speech__form .vc-field { margin-bottom: 0; }
.vc-speech__form .dk-hint a { color: var(--dk-accent-text, var(--dk-accent)); }
.vc-speech__acts { display: flex; justify-content: flex-start; }
.vc-note { color: var(--dk-text-muted, var(--dk-text)); }
/* From pages/identity.css: the rules the library and create pages and the clip input use. */
+3 -3
View File
@@ -84,13 +84,13 @@ test('render: an older page name draws nothing itself; the shell sends it to its
}
})
test('render: each tab draws exactly one h1, its own title', async () => {
test('render: each tab draws exactly one h1, the one Voice title (the add form keeps its own)', async () => {
const { mod, sdk } = await load()
const addon = await mod.createAddon(sdk)
const h1s = (html) => [...html.matchAll(/<h1[^>]*>(.*?)<\/h1>/g)].map(m => m[1].replace(/<[^>]+>/g, '').trim())
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: '' }))), ['Voices'])
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'recording' }))), ['Voices'])
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'speech' }))), ['Speak text'])
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'library' }))), ['Speech voices 0'])
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'speech' }))), ['Voices'])
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'library' }))), ['Voices'])
assert.deepEqual(h1s(renderToString(React.createElement(addon.Component, { page: 'voice', path: 'library/new' }))), ['Add a speech voice'])
})
+27
View File
@@ -37,3 +37,30 @@ test('speech: a failed answer surfaces the API message', async () => {
const core = { fetch: async () => ({ ok: false, status: 403, json: async () => ({ error: { message: 'feature not enabled for your account' } }), blob: async () => null }) }
await assert.rejects(() => speak(core, { model: 'm', input: 'x' }), /feature not enabled for your account/)
})
test('speech: the tab draws the one Voice title, a styled form and no second title', async () => {
const { mod, sdk } = await load()
const addon = await mod.createAddon(sdk)
const html = renderToString(React.createElement(addon.Component, { page: 'voice', path: 'speech' }))
assert.deepEqual([...html.matchAll(/<h1[^>]*>(.*?)<\/h1>/g)].map(m => m[1]), ['Voices'])
assert.doesNotMatch(html, /Speak text/)
assert.match(html, /<form class="vc-speech__form dk-card"/)
assert.match(html, /placeholder="Optional speaker or voice ID"/)
})
test('library: the tab keeps the one Voice title and shows no count in it', async () => {
const { mod, sdk } = await load()
const addon = await mod.createAddon(sdk)
const html = renderToString(React.createElement(addon.Component, { page: 'voice', path: 'library' }))
assert.deepEqual([...html.matchAll(/<h1[^>]*>(.*?)<\/h1>/g)].map(m => m[1]), ['Voices'])
// While the list loads, or when it is empty, the empty state carries the one add button.
assert.doesNotMatch(html, /data-testid="voice-library-new"/)
})
test('off notice: a viewer without the feature gets the page title and a section heading, one h1', async () => {
const { mod, sdk } = await load({ viewer: { isAdmin: () => false, hasFeature: (f) => f !== 'voice_recognition' } })
const addon = await mod.createAddon(sdk)
const html = renderToString(React.createElement(addon.Component, { page: 'voice', path: 'recognition' }))
assert.deepEqual([...html.matchAll(/<h1[^>]*>(.*?)<\/h1>/g)].map(m => m[1]), ['Voices'])
assert.match(html, /<h2 class="idn-h2">/)
})
+1 -1
View File
@@ -1,5 +1,5 @@
IK_LLAMA_VERSION?=15e7ecacddd87455f3fbc4805bc0508df01fa8cc
IK_LLAMA_VERSION?=c12b5ac33be74ea4fd09a5beed371725ddf2afe1
LLAMA_REPO?=https://github.com/ikawrakow/ik_llama.cpp
CMAKE_ARGS?=
+1 -1
View File
@@ -1,5 +1,5 @@
LLAMA_VERSION?=c0fd78626405c6d9b934efef0ec7291fcc20e3d6
LLAMA_VERSION?=a37cc46104eb50900d5ab4a9ba02a0abdac039db
LLAMA_REPO?=https://github.com/ggerganov/llama.cpp
CMAKE_ARGS?=
@@ -1,10 +1,10 @@
diff --git a/common/common.cpp b/common/common.cpp
index aca1949..428b120 100644
index 6ef3b78..037a00b 100644
--- a/common/common.cpp
+++ b/common/common.cpp
@@ -1662,8 +1662,10 @@ struct llama_context_params common_context_params_to_llama(const common_params &
@@ -1716,8 +1716,10 @@ struct llama_context_params common_context_params_to_llama(const common_params &
auto cparams = llama_context_default_params();
cparams.n_ctx = params.n_ctx;
- cparams.n_seq_max = params.n_parallel;
- cparams.n_rs_seq = params.speculative.need_n_rs_seq();
@@ -16,10 +16,10 @@ index aca1949..428b120 100644
cparams.n_outputs_max_per_seq = std::max(params.n_outputs_max_per_seq, 0);
cparams.n_batch = params.n_batch;
diff --git a/common/common.h b/common/common.h
index 04ffbcd..5daa023 100644
index 70d6a67..93e2269 100644
--- a/common/common.h
+++ b/common/common.h
@@ -455,6 +455,9 @@ struct common_params {
@@ -460,6 +460,9 @@ struct common_params {
int32_t n_keep = 0; // number of tokens to keep from initial prompt
int32_t n_chunks = -1; // max number of chunks to process (-1 = unlimited)
int32_t n_parallel = 1; // number of parallel sequences to decode
@@ -30,13 +30,13 @@ index 04ffbcd..5daa023 100644
int32_t n_outputs_max = 0; // max outputs in a batch (0 = n_batch)
int32_t n_outputs_max_per_seq = 1; // max outputs per sequence
diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp
index edb8e2d..9c88feb 100644
index 296893f..ef38f90 100644
--- a/tools/server/server-context.cpp
+++ b/tools/server/server-context.cpp
@@ -48,6 +48,13 @@ static common_speculative_output_limits server_output_limits(const common_params
@@ -60,6 +60,13 @@ static common_speculative_output_limits server_output_limits(const common_params
auto result = common_speculative_get_output_limits(
params.n_batch, params.n_parallel, common_speculative_n_max(&params.speculative));
+ // Score tasks output logits for every candidate token, so reserve room
+ // for a bounded candidate tail per parallel slot.
+ if (params.score_enabled) {
@@ -47,10 +47,10 @@ index edb8e2d..9c88feb 100644
result.total = std::max<int32_t>(1, result.total);
result.per_seq = std::max<int32_t>(1, result.per_seq);
return result;
@@ -243,6 +250,26 @@ struct server_slot {
@@ -325,6 +332,26 @@ struct server_slot {
std::vector<completion_token_output> generated_token_probs;
+ // SERVER_TASK_TYPE_SCORE: shared-prefix token logprobs harvested
+ // incrementally across batch views (NaN = not yet produced)
+ std::vector<float> score_logprobs;
@@ -74,7 +74,7 @@ index edb8e2d..9c88feb 100644
bool has_next_token = true;
bool has_new_line = false;
bool truncated = false;
@@ -346,6 +373,10 @@ struct server_slot {
@@ -433,6 +460,10 @@ struct server_slot {
}
generated_tokens.clear();
generated_token_probs.clear();
@@ -83,12 +83,12 @@ index edb8e2d..9c88feb 100644
+ score_suffix_pending = false;
+ score_divergence = -1;
json_schema = json();
task_prev = std::move(task);
@@ -2308,6 +2339,227 @@ private:
@@ -2571,6 +2602,227 @@ private:
queue_results.send(std::move(res));
}
+ // log(sum(exp(logits))) with max-subtraction for stability — the
+ // log_softmax denominator shared by every token read from one output
+ static double score_log_denom(const float * logits, int32_t n_vocab) {
@@ -313,15 +313,15 @@ index edb8e2d..9c88feb 100644
//
// Functions to process the task
//
@@ -2468,6 +2720,7 @@ private:
case SERVER_TASK_TYPE_EMBEDDING:
@@ -2957,6 +3209,7 @@ private:
case SERVER_TASK_TYPE_RERANK:
case SERVER_TASK_TYPE_TTS:
case SERVER_TASK_TYPE_DECISION:
+ case SERVER_TASK_TYPE_SCORE:
{
// special case: if input is provided via CLI, tokenize it first
// otherwise, no need to tokenize as it's already done inside the HTTP thread
@@ -2986,6 +3239,13 @@ private:
@@ -3549,6 +3802,13 @@ private:
break; // stop any further processing
}
}
@@ -333,12 +333,12 @@ index edb8e2d..9c88feb 100644
+ abort_all_slots("update_score_suffixes() failed: " + std::string(e.what()));
+ }
}
void pre_decode() {
@@ -3325,6 +3585,16 @@ private:
void process_tts_slots() {
@@ -3987,6 +4247,16 @@ private:
n_past = std::min(n_past, slot.alora_invocation_start - 1);
}
+ // score tasks need the logits that predict the first candidate
+ // token, so the last shared-prompt token must be (re-)decoded
+ // even when the cache already covers it
@@ -350,12 +350,12 @@ index edb8e2d..9c88feb 100644
+ }
+
const auto n_cache_reuse = slot.task->params.n_cache_reuse;
const bool can_cache_reuse =
@@ -3578,8 +3848,12 @@ private:
@@ -4257,8 +4527,12 @@ private:
bool do_checkpoint = params_base.n_ctx_checkpoints > 0;
- // make checkpoints only for completion tasks
- do_checkpoint = do_checkpoint && slot.task->type == SERVER_TASK_TYPE_COMPLETION;
+ // make checkpoints for completion tasks, and for score tasks at the
@@ -364,10 +364,10 @@ index edb8e2d..9c88feb 100644
+ // prompt for every candidate of a scoring call
+ do_checkpoint = do_checkpoint && (slot.task->type == SERVER_TASK_TYPE_COMPLETION ||
+ slot.task->type == SERVER_TASK_TYPE_SCORE);
// make a checkpoint of the parts of the memory that cannot be rolled back.
// checkpoints are created only if:
@@ -3806,16 +4080,49 @@ private:
@@ -4356,16 +4630,49 @@ private:
// embedding requires all tokens in the batch to be output;
// MTP also wants logits at every prompt position so the
// streaming hook can mirror t_h_nextn into ctx_dft.
@@ -388,7 +388,7 @@ index edb8e2d..9c88feb 100644
batch.set_decision_order(batch.size() - 1, slot.task->decision.order[slot.prompt.n_tokens()]);
}
slot.prompt.tokens.push_back(cur_tok);
+ // score tasks: break at the shared-prompt boundary so the checkpoint
+ // below lands exactly there — the other candidates of the same
+ // scoring call re-process only their own tokens. Also break at the
@@ -418,10 +418,10 @@ index edb8e2d..9c88feb 100644
// break at the last user message, or at user messages at least min step past the last checkpoint
if (do_checkpoint && spans.is_user_start(slot.prompt.n_tokens())) {
const auto pos = slot.prompt.n_tokens();
@@ -3719,6 +4026,15 @@ private:
@@ -4408,6 +4715,15 @@ private:
const bool is_user_start = spans.is_user_start(n_tokens_start);
const bool is_last_user_message = n_tokens_start == last_user_pos;
+ // a batch starting at the score boundary or divergence point must
+ // always checkpoint — min-step spacing would otherwise suppress it
+ // and every candidate / next scoring call would re-process the prompt
@@ -434,7 +434,7 @@ index edb8e2d..9c88feb 100644
// entire prompt has been processed
if (slot.prompt.n_tokens() == slot.task->n_tokens()) {
slot.state = SLOT_STATE_DONE_PROMPT;
@@ -3734,8 +4050,8 @@ private:
@@ -4423,8 +4739,8 @@ private:
slot.init_sampler();
} else {
// skip ordinary mid-prompt checkpoints, unless the batch starts a user
@@ -445,10 +445,10 @@ index edb8e2d..9c88feb 100644
do_checkpoint = false;
}
}
@@ -3752,10 +4068,10 @@ private:
@@ -4441,10 +4757,10 @@ private:
// do not checkpoint after mtmd chunks
do_checkpoint = do_checkpoint && !has_mtmd;
- // no need to create checkpoints that are too close together, unless it's the last user message
+ // no need to create checkpoints that are too close together, unless it's the last user message or the score boundary
do_checkpoint = do_checkpoint && (
@@ -457,11 +457,11 @@ index edb8e2d..9c88feb 100644
+ is_last_user_message || near_prompt_end || is_score_boundary ||
n_tokens_start > slot.prompt.checkpoints.back().n_tokens + params_base.checkpoint_min_step);
SLT_DBG(slot, "main/do_checkpoint = %s, pos_min = %d, pos_max = %d\n", do_checkpoint ? "yes" : "no", pos_min, pos_max);
@@ -3943,6 +4259,13 @@ private:
@@ -4690,6 +5006,13 @@ private:
}
}
+ // score slots harvest logprobs from every view that contains
+ // their outputs, not just the one holding the final token
+ if (slot.task && slot.task->type == SERVER_TASK_TYPE_SCORE &&
@@ -472,10 +472,10 @@ index edb8e2d..9c88feb 100644
if (!is_inside_view(slot.i_batch)) {
// the required token not in this sub-batch, skip
return;
@@ -3971,6 +4294,25 @@ private:
@@ -4718,6 +5041,25 @@ private:
return;
}
+ if (slot.task->type == SERVER_TASK_TYPE_SCORE) {
+ // shared-prefix logprobs (and every candidate's first
+ // suffix logprob) were accumulated per view above;
@@ -496,16 +496,16 @@ index edb8e2d..9c88feb 100644
+ }
+
GGML_ASSERT(slot.task->need_sampling());
// prompt evaluated for next-token prediction
diff --git a/tools/server/server-task.h b/tools/server/server-task.h
index 8c5fa9a..4b38805 100644
index 810cefb..3e1ab8d 100644
--- a/tools/server/server-task.h
+++ b/tools/server/server-task.h
@@ -12,11 +12,26 @@
#include "server-common.h"
@@ -13,11 +13,26 @@
#include "mtmd-helper.h"
+// SERVER_TASK_TYPE_SCORE emits one logits output per candidate token (plus
+// the forced last-token output), and the context's output budget
+// (n_outputs_max) is reserved up front — so candidate length must be
@@ -527,12 +527,12 @@ index 8c5fa9a..4b38805 100644
SERVER_TASK_TYPE_DECISION,
+ SERVER_TASK_TYPE_SCORE,
SERVER_TASK_TYPE_INFILL,
SERVER_TASK_TYPE_TRANSCRIBE_RT,
SERVER_TASK_TYPE_CANCEL,
SERVER_TASK_TYPE_CONTROL,
@@ -156,6 +171,18 @@ struct server_task {
@@ -168,6 +183,18 @@ struct server_task {
task_params params;
server_tokens tokens;
+ // used by SERVER_TASK_TYPE_SCORE: `tokens` holds the shared prefix
+ // (prompt + longest common candidate token prefix) and logprobs are
+ // returned for its tokens from n_score_prompt onward. Each candidate's
@@ -548,18 +548,18 @@ index 8c5fa9a..4b38805 100644
// only used by CLI, this allow tokenizing CLI inputs on server side
// we need this because mtmd_context and vocab are not accessible outside of server_context
bool cli = false;
@@ -234,6 +261,7 @@ struct server_task {
switch (type) {
@@ -249,6 +276,7 @@ struct server_task {
case SERVER_TASK_TYPE_COMPLETION:
case SERVER_TASK_TYPE_INFILL:
case SERVER_TASK_TYPE_TRANSCRIBE_RT:
+ case SERVER_TASK_TYPE_SCORE:
return true;
default:
return false;
@@ -509,6 +537,25 @@ struct server_task_result_decision : server_task_result {
case SERVER_TASK_TYPE_DECISION:
return !decision.labels.empty();
@@ -558,6 +586,25 @@ struct server_task_result_decision : server_task_result {
virtual json to_json() override;
};
+struct server_task_result_score : server_task_result {
+ // log P(token | prefix) for the shared-prefix tokens after
+ // n_score_prompt, in order; NaN marks positions the decode never
@@ -1,850 +0,0 @@
diff --git a/tools/mtmd/mtmd-helper-gen.cpp b/tools/mtmd/mtmd-helper-gen.cpp
index 5fb7ea9..b554bf2 100644
--- a/tools/mtmd/mtmd-helper-gen.cpp
+++ b/tools/mtmd/mtmd-helper-gen.cpp
@@ -50,29 +50,38 @@ static llama_token find_special_token(const llama_vocab * vocab, const std::stri
return LLAMA_TOKEN_NULL;
}
+static void put_bytes(std::vector<char> & buf, const void * p, size_t n) {
+ const char * c = (const char *) p;
+ buf.insert(buf.end(), c, c + n);
+}
+
+// data_sz == UINT32_MAX writes the "unknown length" sentinel (streaming), same as ffmpeg does on a pipe
+static void write_wav16_header(std::vector<char> & buf, uint32_t data_sz, int32_t rate) {
+ const uint32_t riff_sz = data_sz == UINT32_MAX ? UINT32_MAX : 36 + data_sz;
+ const uint32_t fmt_sz = 16, byte_rate = (uint32_t) rate * 2;
+ const uint16_t fmt = 1, ch = 1, align = 2, bits = 16;
+ const uint32_t rate32 = (uint32_t) rate;
+ put_bytes(buf, "RIFF", 4); put_bytes(buf, &riff_sz, 4); put_bytes(buf, "WAVE", 4);
+ put_bytes(buf, "fmt ", 4); put_bytes(buf, &fmt_sz, 4);
+ put_bytes(buf, &fmt, 2); put_bytes(buf, &ch, 2); put_bytes(buf, &rate32, 4);
+ put_bytes(buf, &byte_rate, 4); put_bytes(buf, &align, 2); put_bytes(buf, &bits, 2);
+ put_bytes(buf, "data", 4); put_bytes(buf, &data_sz, 4);
+}
+
+static void append_wav16_pcm(std::vector<char> & buf, const float * pcm, size_t n) {
+ for (size_t i = 0; i < n; i++) {
+ int16_t s = (int16_t) (std::max(-1.0f, std::min(1.0f, pcm[i])) * 32767.0f);
+ put_bytes(buf, &s, 2);
+ }
+}
+
static bool write_wav16(std::vector<char> & buf, const std::vector<float> & pcm, int32_t rate) {
// RIFF chunk sizes are 32-bit; refuse to emit a file with a truncated header
if (pcm.size() > ((size_t) UINT32_MAX - 36) / 2) {
return false;
}
- const uint32_t data_sz = (uint32_t) (pcm.size() * 2);
- const uint32_t riff_sz = 36 + data_sz;
- const uint32_t fmt_sz = 16, byte_rate = (uint32_t) rate * 2;
- const uint16_t fmt = 1, ch = 1, align = 2, bits = 16;
- const uint32_t rate32 = (uint32_t) rate;
- auto put = [&](const void * p, size_t n) {
- const char * c = (const char *) p;
- buf.insert(buf.end(), c, c + n);
- };
- put("RIFF", 4); put(&riff_sz, 4); put("WAVE", 4);
- put("fmt ", 4); put(&fmt_sz, 4);
- put(&fmt, 2); put(&ch, 2); put(&rate32, 4);
- put(&byte_rate, 4); put(&align, 2); put(&bits, 2);
- put("data", 4); put(&data_sz, 4);
- for (float v : pcm) {
- int16_t s = (int16_t) (std::max(-1.0f, std::min(1.0f, v)) * 32767.0f);
- put(&s, 2);
- }
+ write_wav16_header(buf, (uint32_t) (pcm.size() * 2), rate);
+ append_wav16_pcm(buf, pcm.data(), pcm.size());
return true;
}
@@ -92,6 +101,8 @@ public:
// set out_stop on end-of-speech, h_state_out must be null if no frame is generated
virtual int32_t step_gen(llama_token sampled, const float * h_state_in, const float ** h_state_out, bool * out_stop) = 0;
virtual int32_t get_output(int32_t * out_sample_rate, const char ** out_data, size_t * out_data_len, int64_t * out_n_samples) = 0;
+ // forces any buffered codes through code2wav now, regardless of window_frames
+ virtual int32_t flush() { return 0; }
protected:
llama_context * lctx;
@@ -121,6 +132,9 @@ public:
prompt_batch.reset();
n_prompt = 0;
prompt_pos = 0;
+ stream = false;
+ pcm_sent = 0;
+ wav_header_sent = false;
}
int32_t set_input(const mtmd_helper_gen_audio_inp * inp) override {
@@ -208,6 +222,7 @@ public:
top_p = inp->top_p > 0 ? inp->top_p : def.top_p;
seed = inp->seed;
out_type = inp->out_type;
+ stream = inp->stream;
// the prompt above holds the whole text stream up to tts_eos, so every generated
// frame adds tts_pad on top of the codes embedding
@@ -301,31 +316,60 @@ public:
}
int32_t get_output(int32_t * out_sample_rate, const char ** out_data, size_t * out_data_len, int64_t * out_n_samples) override {
- if (!flush_gen_wav()) {
- return 1;
+ *out_sample_rate = info.sample_rate;
+
+ if (!stream) {
+ // one-shot call: force out whatever's left, regardless of window_frames
+ if (!flush_gen_wav()) {
+ return 1;
+ }
+ if (out_n_samples) {
+ *out_n_samples = (int64_t) audio_pcm.size();
+ }
+ if (out_type == MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM) {
+ *out_data = (const char *) audio_pcm.data();
+ *out_data_len = audio_pcm.size() * sizeof(float);
+ return 0;
+ }
+ out_buf.clear();
+ if (!write_wav16(out_buf, audio_pcm, info.sample_rate)) {
+ LOG_ERR("mtmd_helper_gen_audio: output too large for WAV\n");
+ return 1;
+ }
+ *out_data = out_buf.data();
+ *out_data_len = out_buf.size();
+ return 0;
}
- *out_sample_rate = info.sample_rate;
+ // streaming: only return audio produced since the previous call
+ const size_t n_new = audio_pcm.size() - pcm_sent;
if (out_n_samples) {
- *out_n_samples = (int64_t) audio_pcm.size();
+ *out_n_samples = (int64_t) n_new;
}
if (out_type == MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM) {
- *out_data = (const char *) audio_pcm.data();
- *out_data_len = audio_pcm.size() * sizeof(float);
+ *out_data = (const char *) (audio_pcm.data() + pcm_sent);
+ *out_data_len = n_new * sizeof(float);
+ pcm_sent = audio_pcm.size();
return 0;
}
out_buf.clear();
- if (!write_wav16(out_buf, audio_pcm, info.sample_rate)) {
- LOG_ERR("mtmd_helper_gen_audio: output too large for WAV\n");
- return 1;
+ if (!wav_header_sent) {
+ write_wav16_header(out_buf, UINT32_MAX, info.sample_rate);
+ wav_header_sent = true;
}
+ append_wav16_pcm(out_buf, audio_pcm.data() + pcm_sent, n_new);
+ pcm_sent = audio_pcm.size();
*out_data = out_buf.data();
*out_data_len = out_buf.size();
return 0;
}
+ int32_t flush() override {
+ return flush_gen_wav() ? 0 : 1;
+ }
+
private:
bool ensure_cache() {
if (specials_ok) {
@@ -369,7 +413,7 @@ private:
LOG_ERR("mtmd_helper_gen_audio: mmproj has no speaker/audio encoder\n");
return false;
}
- const std::string marker = mtmd_default_marker();
+ const std::string marker = mtmd_get_marker(mctx);
mtmd_input_text text{ marker.c_str(), marker.size(), false, true };
mtmd_input_chunks * chunks = mtmd_input_chunks_init();
const mtmd_bitmap * bptr = bitmap;
@@ -455,6 +499,9 @@ private:
std::vector<float> h_state_buf;
mtmd_helper_gen_audio_outtype out_type = MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
std::vector<char> out_buf;
+ bool stream = false;
+ size_t pcm_sent = 0; // samples already returned by get_output()
+ bool wav_header_sent = false;
};
// settings that only live in the reference's per-pack yaml, not in the checkpoint
@@ -1022,6 +1069,14 @@ void mtmd_helper_gen_audio_reset(mtmd_helper_gen_audio * ctx) {
}
}
+struct mtmd_helper_gen_audio_inp mtmd_helper_gen_audio_inp_default(void) {
+ mtmd_helper_gen_audio_inp inp{};
+ inp.top_k = 50;
+ inp.top_p = 1.0f;
+ inp.out_type = MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
+ return inp;
+}
+
int32_t mtmd_helper_gen_audio_set_input(mtmd_helper_gen_audio * ctx, const mtmd_helper_gen_audio_inp * inp) {
if (!ctx->pipeline) {
LOG_ERR("mtmd_helper_gen_audio: unsupported or missing gen-audio pipeline\n");
@@ -1058,3 +1113,10 @@ int32_t mtmd_helper_gen_audio_get_output(mtmd_helper_gen_audio * ctx, int32_t *
}
return ctx->pipeline->get_output(out_sample_rate, out_data, out_data_len, out_n_samples);
}
+
+int32_t mtmd_helper_gen_audio_flush(mtmd_helper_gen_audio * ctx) {
+ if (!ctx->pipeline) {
+ return 1;
+ }
+ return ctx->pipeline->flush();
+}
diff --git a/tools/mtmd/mtmd-helper.h b/tools/mtmd/mtmd-helper.h
index 7436230..acbfefc 100644
--- a/tools/mtmd/mtmd-helper.h
+++ b/tools/mtmd/mtmd-helper.h
@@ -204,6 +204,7 @@ enum mtmd_helper_gen_audio_outtype {
MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV, // WAV PCM 16-bit LE, mono
};
struct mtmd_helper_gen_audio_inp {
+ bool stream; // if true, output() must be called after each step_gen()
llama_seq_id seq_id;
const char * prompt;
@@ -219,6 +220,8 @@ struct mtmd_helper_gen_audio_inp {
enum mtmd_helper_gen_audio_outtype out_type;
};
+MTMD_API struct mtmd_helper_gen_audio_inp mtmd_helper_gen_audio_inp_default(void);
+
MTMD_API mtmd_helper_gen_audio * mtmd_helper_gen_audio_init(
struct llama_context * lctx,
struct mtmd_context * mctx);
@@ -250,6 +253,8 @@ MTMD_API int32_t mtmd_helper_gen_audio_step_gen(
// out_data valid until next get_output() or reset() call
// out_n_samples (optional, can be NULL) receives the number of generated PCM samples
+// if inp->stream is true: returns only audio produced since the previous call, and
+// *out_data_len == 0 whenever a full window_frames batch hasn't accumulated yet
MTMD_API int32_t mtmd_helper_gen_audio_get_output(
mtmd_helper_gen_audio * ctx,
int32_t * out_sample_rate,
@@ -257,6 +262,10 @@ MTMD_API int32_t mtmd_helper_gen_audio_get_output(
size_t * out_data_len,
int64_t * out_n_samples);
+// forces any buffered codes through code2wav now, regardless of window_frames;
+// call once when generation has ended, before the last get_output() in stream mode
+MTMD_API int32_t mtmd_helper_gen_audio_flush(mtmd_helper_gen_audio * ctx);
+
#ifdef __cplusplus
} // extern "C"
#endif
@@ -283,8 +292,41 @@ struct mtmd_helper_gen_audio_deleter {
};
using gen_audio_ptr = std::unique_ptr<mtmd_helper_gen_audio, mtmd_helper_gen_audio_deleter>;
struct gen_audio {
+
+ // sub-struct, RAII wrapper for mtmd_helper_gen_audio_inp
+ struct inp {
+ mtmd_helper_gen_audio_inp data = mtmd_helper_gen_audio_inp_default();
+ std::string prompt_str;
+ std::string lang_str;
+ mtmd::bitmap_ptr speaker_ref_ptr;
+
+ inp() = default;
+ inp(inp &&) = default;
+ inp & operator=(inp &&) = default;
+ inp(const inp &) = delete;
+ inp & operator=(const inp &) = delete;
+
+ void set_prompt (std::string p) { prompt_str = std::move(p); }
+ void set_lang (std::string l) { lang_str = std::move(l); }
+ void set_speaker_ref(mtmd::bitmap_ptr bmp) { speaker_ref_ptr = std::move(bmp); }
+
+ // pointers are only valid as long as *this is alive
+ const mtmd_helper_gen_audio_inp * get() {
+ data.prompt = prompt_str.c_str();
+ data.prompt_len = prompt_str.size();
+ data.lang = lang_str.empty() ? nullptr : lang_str.c_str();
+ data.speaker_ref = speaker_ref_ptr.get();
+ return &data;
+ }
+ };
+
gen_audio_ptr ctx;
- gen_audio(struct llama_context * lctx, struct mtmd_context * mctx) : ctx(mtmd_helper_gen_audio_init(lctx, mctx)) {}
+ void init(struct llama_context * lctx, struct mtmd_context * mctx) {
+ ctx.reset(mtmd_helper_gen_audio_init(lctx, mctx));
+ }
+ bool valid() const {
+ return ctx.get() != nullptr;
+ }
void reset() {
mtmd_helper_gen_audio_reset(ctx.get());
}
@@ -300,6 +342,9 @@ struct gen_audio {
int32_t get_output(int32_t * out_sample_rate, const char ** out_data, size_t * out_data_len, int64_t * out_n_samples = nullptr) {
return mtmd_helper_gen_audio_get_output(ctx.get(), out_sample_rate, out_data, out_data_len, out_n_samples);
}
+ int32_t flush() {
+ return mtmd_helper_gen_audio_flush(ctx.get());
+ }
};
} // namespace mtmd_helper
diff --git a/tools/server/server-context.cpp b/tools/server/server-context.cpp
index 9c88feb..064bb51 100644
--- a/tools/server/server-context.cpp
+++ b/tools/server/server-context.cpp
@@ -17,6 +17,7 @@
#include "speculative.h"
#include "mtmd.h"
#include "mtmd-helper.h"
+#include "base64.hpp"
#include <algorithm>
#include <cstddef>
@@ -41,8 +42,9 @@ constexpr int HTTP_POLLING_SECONDS = 1;
static common_speculative_output_limits server_output_limits(const common_params & params) {
if (params.embedding ||
- (params.pooling_type != LLAMA_POOLING_TYPE_UNSPECIFIED && params.pooling_type != LLAMA_POOLING_TYPE_NONE)) {
- return { params.n_batch, 1 };
+ (params.pooling_type != LLAMA_POOLING_TYPE_UNSPECIFIED && params.pooling_type != LLAMA_POOLING_TYPE_NONE) ||
+ !params.mmproj.path.empty()) { // gen-audio (TTS) capability isn't known until the mmproj loads, size generously
+ return { params.n_batch, params.n_batch };
}
auto result = common_speculative_get_output_limits(
@@ -215,6 +217,30 @@ struct server_slot {
mtmd_context * mctx = nullptr;
mtmd::batch_ptr mbatch = nullptr;
+ struct tts_ctx {
+ mtmd_helper::gen_audio ctx;
+ const float * h_state;
+ llama_token sampled;
+ int32_t n_decoded;
+ bool is_supported() const {
+ return ctx.valid();
+ }
+ void reset() {
+ // mtmd_helper_gen_audio_reset() dereferences its argument before it
+ // null-checks the pipeline, and the pipeline is only allocated for
+ // models that actually carry a gen-audio mmproj. server_slot::reset()
+ // runs for every slot of every model, so without this guard any
+ // non-TTS model segfaults during slot initialization.
+ if (is_supported()) {
+ ctx.reset();
+ }
+ h_state = nullptr;
+ sampled = LLAMA_TOKEN_NULL;
+ n_decoded = 0;
+ }
+ };
+ tts_ctx tts;
+
// speculative decoding
common_speculative * spec;
@@ -395,6 +421,8 @@ struct server_slot {
// clear multimodal state
mbatch.reset();
+
+ tts.reset();
}
void init_sampler() const {
@@ -871,6 +899,14 @@ public:
server_decision_context decision;
+ bool has_cap_tts() const {
+ return mctx != nullptr && mtmd_gen_audio_get_info(mctx).type != MTMD_GEN_AUDIO_TYPE_NONE;
+ }
+
+ bool has_cap_chat() const {
+ return mctx == nullptr || mtmd_helper_model_can_chat(ctx_tgt, mctx);
+ }
+
server_queue queue_tasks;
server_response queue_results;
@@ -1350,6 +1386,10 @@ private:
slot.mctx = mctx;
slot.prompt.tokens.has_mtmd = mctx != nullptr;
+ if (has_cap_tts()) {
+ slot.tts.ctx.init(ctx_tgt, mctx);
+ }
+
SLT_TRC(slot, "new slot, n_ctx = %d\n", slot.n_ctx);
slot.callback_on_release = [this](int id_slot) {
@@ -1830,6 +1870,28 @@ private:
SLT_DBG(slot, "launching slot : %s\n", safe_json_to_str(slot.to_json()).c_str());
+ if (task.type == SERVER_TASK_TYPE_TTS) {
+ GGML_ASSERT(has_cap_tts()); // should already checked in route handler
+ if (!slot.tts.is_supported()) {
+ slot.tts.ctx.init(ctx_tgt, slot.mctx);
+ }
+
+ // TTS slots never enter the shared batch: pre_decode() returns early for
+ // them and process_tts_slots() drives them instead, so they skip the
+ // prompt-cache bookkeeping that clears this sequence between requests.
+ // The gen-audio pipeline always decodes from position 0, and its own
+ // reset() only clears host-side buffers, so without this the second and
+ // later tasks on a slot decode over the previous request's tokens and
+ // step_prompt() fails immediately.
+ slot.prompt_clear();
+
+ task.tts_inp.data.seq_id = slot.id;
+ if (slot.tts.ctx.set_input(task.tts_inp.get()) != 0) {
+ send_error(task, "failed to process TTS prompt", ERROR_TYPE_SERVER);
+ return false;
+ }
+ }
+
// initialize samplers
if (task.need_sampling()) {
try {
@@ -1847,6 +1909,9 @@ private:
// TODO: getting pre sampling logits is not yet supported with backend sampling
use_backend_sampling &= !need_pre_sample_logits;
+ // TODO: check verify if this actually works with TTS
+ use_backend_sampling &= task.type != SERVER_TASK_TYPE_TTS;
+
// TODO: tmp until backend sampling is fully implemented
if (use_backend_sampling) {
llama_set_sampler(ctx_tgt, slot.id, common_sampler_get(slot.smpl.get()));
@@ -1872,9 +1937,13 @@ private:
slot.task = std::make_unique<const server_task>(std::move(task));
- slot.state = slot.task->is_child()
- ? SLOT_STATE_WAIT_OTHER // wait for the parent to process prompt
- : SLOT_STATE_STARTED;
+ if (slot.task->type == SERVER_TASK_TYPE_TTS) {
+ slot.state = SLOT_STATE_PROCESSING_PROMPT;
+ } else {
+ slot.state = slot.task->is_child()
+ ? SLOT_STATE_WAIT_OTHER // wait for the parent to process prompt
+ : SLOT_STATE_STARTED;
+ }
// reset server kill-switch counter
n_empty_consecutive = 0;
@@ -2139,6 +2208,18 @@ private:
queue_results.send(std::move(res));
}
+ void send_tts_result(server_slot & slot, int32_t sample_rate, const char * data, size_t data_len, bool final) {
+ auto res = std::make_unique<server_task_result_tts>();
+
+ res->id = slot.task->id;
+ res->index = slot.task->index;
+ res->sample_rate = sample_rate;
+ res->audio.assign(data, data_len);
+ res->final = final;
+
+ queue_results.send(std::move(res));
+ }
+
void send_final_response(server_slot & slot) {
auto res = std::make_unique<server_task_result_cmpl_final>();
@@ -2721,6 +2802,7 @@ private:
case SERVER_TASK_TYPE_RERANK:
case SERVER_TASK_TYPE_DECISION:
case SERVER_TASK_TYPE_SCORE:
+ case SERVER_TASK_TYPE_TTS:
{
// special case: if input is provided via CLI, tokenize it first
// otherwise, no need to tokenize as it's already done inside the HTTP thread
@@ -3179,6 +3261,14 @@ private:
return;
}
+ // note: TTS slots bypass the shared batch entirely
+ try {
+ process_tts_slots();
+ } catch (const std::exception & e) {
+ SRV_ERR("process_tts_slots() failed: %s\n", e.what());
+ abort_all_slots("process_tts_slots() failed: " + std::string(e.what()));
+ }
+
GGML_ASSERT(batch.slot_batched || batch.size() == 0);
if (batch.slot_batched) {
@@ -3248,10 +3338,77 @@ private:
}
}
+ void process_tts_slots() {
+ iterate(slots, [&](server_slot & slot) {
+ if (!slot.is_processing() || slot.task->type != SERVER_TASK_TYPE_TTS) {
+ return;
+ }
+
+ llama_set_embeddings(ctx_tgt, true);
+
+ if (slot.state == SLOT_STATE_PROCESSING_PROMPT) {
+ const int32_t ret = slot.tts.ctx.step_prompt(llama_n_batch(ctx_tgt));
+ if (ret < 0) {
+ send_error(slot, "TTS prompt processing failed", ERROR_TYPE_SERVER);
+ slot.release();
+ } else if (ret == 0) {
+ slot.tts.sampled = common_sampler_sample(slot.smpl.get(), ctx_tgt, -1);
+ common_sampler_accept(slot.smpl.get(), slot.tts.sampled, true);
+ slot.tts.h_state = llama_get_embeddings_ith(ctx_tgt, -1);
+ slot.state = SLOT_STATE_GENERATING;
+ }
+ return;
+ }
+
+ const int32_t n_predict = slot.task->params.n_predict > 0 ? slot.task->params.n_predict : 512;
+ if (slot.tts.n_decoded >= n_predict || llama_vocab_is_eog(vocab, slot.tts.sampled)) {
+ int32_t sample_rate = 0;
+ const char * data = nullptr;
+ size_t data_len = 0;
+ // generation truly ends here: force out any sub-window remainder still buffered
+ if (slot.tts.ctx.flush() != 0 || slot.tts.ctx.get_output(&sample_rate, &data, &data_len) != 0) {
+ send_error(slot, "failed to finalize TTS output", ERROR_TYPE_SERVER);
+ } else {
+ send_tts_result(slot, sample_rate, data, data_len, true);
+ }
+ slot.release();
+ return;
+ }
+
+ const float * h_state_next = nullptr;
+ if (slot.tts.ctx.step_gen(slot.tts.sampled, slot.tts.h_state, &h_state_next) != 0) {
+ send_error(slot, "TTS generation failed", ERROR_TYPE_SERVER);
+ slot.release();
+ return;
+ }
+ slot.tts.h_state = h_state_next;
+ slot.tts.n_decoded++;
+
+ slot.tts.sampled = common_sampler_sample(slot.smpl.get(), ctx_tgt, -1);
+ common_sampler_accept(slot.smpl.get(), slot.tts.sampled, true);
+
+ if (slot.task->params.stream) {
+ int32_t sample_rate = 0;
+ const char * data = nullptr;
+ size_t data_len = 0;
+ if (slot.tts.ctx.get_output(&sample_rate, &data, &data_len) != 0) {
+ send_error(slot, "TTS streaming output failed", ERROR_TYPE_SERVER);
+ slot.release();
+ } else if (data_len > 0) {
+ send_tts_result(slot, sample_rate, data, data_len, false);
+ }
+ }
+ });
+ }
+
void pre_decode() {
// apply context-shift if needed
// TODO: simplify and improve
iterate(slots, [&](server_slot & slot) {
+ if (slot.task && slot.task->type == SERVER_TASK_TYPE_TTS) {
+ // TTS slots drive their own decode loop in process_tts_slots(), never enter the shared batch
+ return;
+ }
if (slot.state == SLOT_STATE_GENERATING && slot.prompt.n_tokens() + 1 >= slot.n_ctx) {
if (!params_base.ctx_shift) {
// this check is redundant (for good)
@@ -3324,7 +3481,7 @@ private:
// determine which slots are generating and drafting
iterate(slots, [&](server_slot & slot) {
- if (slot.state != SLOT_STATE_GENERATING) {
+ if (slot.state != SLOT_STATE_GENERATING || slot.task->type == SERVER_TASK_TYPE_TTS) {
return;
}
@@ -3458,7 +3615,7 @@ private:
return; // batch is full, skip remaining slots
}
- if (!slot.is_processing()) {
+ if (!slot.is_processing() || slot.task->type == SERVER_TASK_TYPE_TTS) {
return;
}
@@ -4678,6 +4835,8 @@ server_context_meta server_context::get_meta() const {
/* has_inp_image */ impl->chat_params.allow_image,
/* has_inp_audio */ impl->chat_params.allow_audio,
/* has_inp_video */ impl->chat_params.allow_video,
+ /* has_cap_chat */ impl->has_cap_chat(),
+ /* has_cap_tts */ impl->has_cap_tts(),
/* json_ui_settings */ impl->json_ui_settings,
/* slot_n_ctx */ impl->n_ctx_slot(),
/* pooling_type */ llama_pooling_type(impl->ctx_tgt),
@@ -4751,6 +4910,11 @@ std::unique_ptr<server_res_generator> server_routes::handle_completions_impl(
res->set_req(&req); // will also set spipe if needed
+ if (!ctx_server.has_cap_chat()) {
+ res->error(format_error_response("this server does not support chat/completions", ERROR_TYPE_NOT_SUPPORTED));
+ return res;
+ }
+
int32_t sse_ping_interval = params.sse_ping_interval;
try {
@@ -5776,6 +5940,150 @@ void server_routes::init_routes() {
return res;
};
+ this->post_tts = [this](const server_http_req & req) {
+ auto res = create_response();
+ res->set_req(&req); // will also set spipe if needed
+
+ if (!ctx_server.has_cap_tts()) {
+ res->error(format_error_response("this server does not support audio generation", ERROR_TYPE_NOT_SUPPORTED));
+ return res;
+ }
+
+ const json body = json::parse(req.body);
+
+ std::string prompt = json_value(body, "input", json_value(body, "prompt", std::string()));
+ if (prompt.empty()) {
+ res->error(format_error_response("\"input\" must be a non-empty string", ERROR_TYPE_INVALID_REQUEST));
+ return res;
+ }
+
+ const std::string response_format = json_value(body, "response_format", std::string("wav"));
+ const bool stream = json_value(body, "stream", false);
+
+ server_task task(SERVER_TASK_TYPE_TTS);
+ task.tts_inp.set_prompt(prompt);
+ task.tts_inp.set_lang(json_value(body, "lang", std::string()));
+ task.tts_inp.data.top_k = json_value(body, "top_k", 0);
+ task.tts_inp.data.top_p = json_value(body, "top_p", 0.0f);
+ task.tts_inp.data.stream = stream;
+ task.tts_inp.data.out_type = response_format == "pcm"
+ ? MTMD_HELPER_GEN_AUDIO_OUTTYPE_PCM
+ : MTMD_HELPER_GEN_AUDIO_OUTTYPE_WAV;
+ task.params.stream = stream;
+ task.params.n_predict = json_value(body, "n_predict", -1);
+ task.params.sampling = params.sampling; // baseline defaults, then apply overrides below
+ task.params.sampling.penalty_repeat = json_value(body, "repeat_penalty", 1.05f);
+ task.params.sampling.penalty_last_n = -1;
+ if (task.tts_inp.data.top_k > 0) {
+ task.params.sampling.top_k = task.tts_inp.data.top_k;
+ }
+ if (task.tts_inp.data.top_p > 0) {
+ task.params.sampling.top_p = task.tts_inp.data.top_p;
+ }
+
+ // speaker reference: either an uploaded form file ("speaker_ref") or a base64 JSON field ("speaker_ref_b64")
+ const unsigned char * speaker_ref_data = nullptr;
+ size_t speaker_ref_len = 0;
+ std::string speaker_ref_b64_decoded;
+
+ auto speaker_ref_file = req.files.find("speaker_ref");
+ if (speaker_ref_file != req.files.end()) {
+ speaker_ref_data = speaker_ref_file->second.data.data();
+ speaker_ref_len = speaker_ref_file->second.data.size();
+ } else {
+ std::string speaker_ref_b64 = json_value(body, "speaker_ref_b64", std::string());
+ if (!speaker_ref_b64.empty()) {
+ speaker_ref_b64_decoded = base64::decode(speaker_ref_b64);
+ speaker_ref_data = (const unsigned char *) speaker_ref_b64_decoded.data();
+ speaker_ref_len = speaker_ref_b64_decoded.size();
+ }
+ }
+
+ if (speaker_ref_len > 0) {
+ auto wrapper = mtmd_helper_bitmap_init_from_buf(ctx_server.mctx, speaker_ref_data, speaker_ref_len, false, ctx_server.init_opt);
+ if (!wrapper.bitmap) {
+ res->error(format_error_response("failed to decode \"speaker_ref\"", ERROR_TYPE_INVALID_REQUEST));
+ return res;
+ }
+ task.tts_inp.set_speaker_ref(mtmd::bitmap_ptr(wrapper.bitmap));
+ } else {
+ // SRV_WRN expands __VA_ARGS__ without the GNU comma-elision extension,
+ // so a bare format string leaves a trailing comma and will not compile
+ SRV_WRN("%s", "no speaker reference provided, the model may behave randomly\n");
+ }
+
+ auto & rd = res->rd;
+ task.id = rd.get_new_id();
+ rd.post_task(std::move(task));
+
+ const std::string content_type = response_format == "pcm" ? "audio/L16" : "audio/wav";
+
+ if (!stream) {
+ auto result = rd.next(req.should_stop);
+ if (!result) {
+ GGML_ASSERT(req.should_stop());
+ return res; // connection is closed
+ }
+ if (result->is_error()) {
+ res->error(result->to_json());
+ return res;
+ }
+ auto * tts_res = dynamic_cast<server_task_result_tts *>(result.get());
+ GGML_ASSERT(tts_res != nullptr);
+ res->status = 200;
+ res->content_type = content_type;
+ res->data = std::move(tts_res->audio);
+ return res;
+ } else {
+ auto first_result = rd.next(req.should_stop);
+ if (!first_result) {
+ GGML_ASSERT(req.should_stop());
+ return res; // connection is closed
+ }
+ if (first_result->is_error()) {
+ res->error(first_result->to_json());
+ return res;
+ }
+ auto * first_tts_res = dynamic_cast<server_task_result_tts *>(first_result.get());
+ GGML_ASSERT(first_tts_res != nullptr);
+
+ res->status = 200;
+ res->content_type = content_type;
+ res->data = std::move(first_tts_res->audio);
+ bool is_done = first_tts_res->final;
+
+ res->set_next([res_this = res.get(), is_done](std::string & output) mutable -> bool {
+ if (is_done) {
+ return false;
+ }
+ if (res_this->should_stop()) {
+ return false;
+ }
+ if (!res_this->data.empty()) {
+ output = std::move(res_this->data);
+ res_this->data.clear();
+ return true;
+ }
+
+ server_response_reader & rd = res_this->rd;
+ if (!rd.has_next()) {
+ return false;
+ }
+ auto result = rd.next([&res_this]() { return res_this->should_stop(); });
+ if (!result || result->is_error()) {
+ return false;
+ }
+ auto * tts_res = dynamic_cast<server_task_result_tts *>(result.get());
+ GGML_ASSERT(tts_res != nullptr);
+ output = std::move(tts_res->audio);
+ is_done = tts_res->final;
+ return true;
+ });
+ }
+
+ return res;
+ };
+
this->get_lora_adapters = [this](const server_http_req & req) {
auto res = create_response();
diff --git a/tools/server/server-context.h b/tools/server/server-context.h
index c554bb9..f025792 100644
--- a/tools/server/server-context.h
+++ b/tools/server/server-context.h
@@ -23,6 +23,8 @@ struct server_context_meta {
bool has_inp_image;
bool has_inp_audio;
bool has_inp_video;
+ bool has_cap_chat;
+ bool has_cap_tts;
json json_ui_settings;
int slot_n_ctx;
enum llama_pooling_type pooling_type;
@@ -152,6 +154,7 @@ struct server_routes {
server_http_context::handler_t post_embeddings;
server_http_context::handler_t post_embeddings_oai;
server_http_context::handler_t post_rerank;
+ server_http_context::handler_t post_tts;
server_http_context::handler_t post_systemone;
server_http_context::handler_t get_lora_adapters;
server_http_context::handler_t post_lora_adapters;
diff --git a/tools/server/server-task.cpp b/tools/server/server-task.cpp
index a5c33c0..7e59745 100644
--- a/tools/server/server-task.cpp
+++ b/tools/server/server-task.cpp
@@ -1506,6 +1506,17 @@ json server_task_result_decision::to_json() {
};
}
+//
+// server_task_result_tts
+//
+json server_task_result_tts::to_json() {
+ return json {
+ {"sample_rate", sample_rate},
+ {"n_bytes", audio.size()},
+ {"final", final},
+ };
+}
+
//
// server_task_result_error
//
diff --git a/tools/server/server-task.h b/tools/server/server-task.h
index 4b38805..d79ea4f 100644
--- a/tools/server/server-task.h
+++ b/tools/server/server-task.h
@@ -10,6 +10,7 @@
// TODO: prevent including the whole server-common.h as we only use server_tokens
#include "server-common.h"
+#include "mtmd-helper.h"
// SERVER_TASK_TYPE_SCORE emits one logits output per candidate token (plus
@@ -43,6 +44,7 @@ enum server_task_type {
SERVER_TASK_TYPE_SLOT_ERASE,
SERVER_TASK_TYPE_GET_LORA,
SERVER_TASK_TYPE_SET_LORA,
+ SERVER_TASK_TYPE_TTS,
};
// TODO: change this to more generic "response_format" to replace the "format_response_*" in server-common
@@ -225,6 +227,9 @@ struct server_task {
// used by SERVER_TASK_TYPE_SET_LORA
std::map<int, float> set_lora; // mapping adapter ID -> scale
+ // used by SERVER_TASK_TYPE_TTS
+ mtmd_helper::gen_audio::inp tts_inp;
+
server_task() = default;
server_task(server_task_type type) : type(type) {}
@@ -249,6 +254,7 @@ struct server_task {
switch (type) {
case SERVER_TASK_TYPE_COMPLETION:
case SERVER_TASK_TYPE_INFILL:
+ case SERVER_TASK_TYPE_TTS:
return true;
case SERVER_TASK_TYPE_DECISION:
return !decision.labels.empty();
@@ -521,5 +527,15 @@ struct server_task_result_embd : server_task_result {
json to_json_oaicompat();
};
+struct server_task_result_tts : server_task_result {
+ std::string audio; // raw bytes for this chunk (WAV or PCM, per request's out_type)
+ int32_t sample_rate = 0;
+ bool final = false; // true for the last chunk of a request
+
+ virtual bool is_stop() override { return final; }
+
+ virtual json to_json() override;
+};
+
struct server_task_result_rerank : server_task_result {
float score = -1e6;
+1 -1
View File
@@ -11,7 +11,7 @@ JOBS?=$(shell nproc --ignore=1 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || e
# vllm.cpp version
VLLM_CPP_REPO?=https://github.com/mudler/vllm.cpp
VLLM_CPP_VERSION?=8af8a3e907330847d35cf223d93c2ba66a1c9389
VLLM_CPP_VERSION?=c9e6bb7b3ae7f7676ea35f570d5a54af12a6a73b
# MLX GEMM provider (darwin/metal only; see the metal branch below for why).
# Consumed as the prebuilt pip wheel: building MLX from source needs `xcrun
+1 -1
View File
@@ -1,2 +1,2 @@
git+https://github.com/Blaizzy/mlx-vlm@v0.4.4
git+https://github.com/Blaizzy/mlx-vlm@v0.6.12
mlx[cpu]
@@ -1,2 +1,2 @@
git+https://github.com/Blaizzy/mlx-vlm@v0.4.4
git+https://github.com/Blaizzy/mlx-vlm@v0.6.12
mlx[cuda12]
@@ -1,2 +1,2 @@
git+https://github.com/Blaizzy/mlx-vlm@v0.4.4
git+https://github.com/Blaizzy/mlx-vlm@v0.6.12
mlx[cuda13]
@@ -1,2 +1,2 @@
git+https://github.com/Blaizzy/mlx-vlm@v0.4.4
git+https://github.com/Blaizzy/mlx-vlm@v0.6.12
mlx[cuda12]
@@ -1,2 +1,2 @@
git+https://github.com/Blaizzy/mlx-vlm@v0.4.4
git+https://github.com/Blaizzy/mlx-vlm@v0.6.12
mlx[cuda13]
+1 -1
View File
@@ -1,3 +1,3 @@
git+https://github.com/Blaizzy/mlx-vlm@v0.4.4
git+https://github.com/Blaizzy/mlx-vlm@v0.6.12
torch
torchvision
@@ -71,6 +71,10 @@ func (stubClient) ListKnownBackends(_ context.Context) ([]schema.KnownBackend, e
return []schema.KnownBackend{}, nil
}
func (stubClient) InstallLibraryBackend(_ context.Context, _ localaitools.InstallLibraryBackendRequest) (*localaitools.InstallLibraryBackendResult, error) {
return &localaitools.InstallLibraryBackendResult{Status: "installed", Name: "stub-backend"}, nil
}
func (stubClient) InstallBackend(_ context.Context, _ localaitools.InstallBackendRequest) (string, error) {
return "stub-backend-job", nil
}
+236 -61
View File
@@ -2,37 +2,38 @@ import { test, expect } from '@playwright/test'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { ADMIN, MEMBER, app, sessionFor, mockAddons } from './addons-fixtures.js'
import { ADMIN, MEMBER, app, installedAddon, release, sessionFor, mockAddons } from './addons-fixtures.js'
import { buildAddonBundles, installStudioAddons, packagedApp } from './studio-addon.js'
import { FACE_MODEL, clipFile, faces, match, mockIdentity, people, photoFile } from './identity-fixtures.js'
// Studio and Talk are packaged add-ons, not built-in tiles: the specs install
// the real packages. No page is built in today, so `builtin` is empty unless a
// spec makes one up to check the built-in rules.
async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true, builtin = [], studio = true, user = MEMBER } = {}) {
async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true, builtin = [], studio = true, user = MEMBER, installed, releases } = {}) {
let saved = pins
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(user, { addons }) }))
await page.route('**/api/features', route => route.fulfill({ json: { addon_navigation: true } }))
await page.route('**/api/addons/builtin', route => route.fulfill({ json: { builtin } }))
// mockAddons installs the Apps routes with the real response shape ({ apps }).
// The packages are served from the build output, so a page that opens one runs it.
if (studio) await installStudioAddons(page, { apps: [packagedApp('studio'), packagedApp('talk'), ...apps] })
else await mockAddons(page, { apps })
let mocked = null
if (studio) mocked = await installStudioAddons(page, { apps: [packagedApp('studio'), packagedApp('talk'), ...apps] })
else mocked = await mockAddons(page, { apps, installed, releases })
await page.route('**/api/addons/pins', async route => {
if (route.request().method() === 'PUT') saved = route.request().postDataJSON()
await route.fulfill({ json: saved })
})
return { saved: () => saved }
return { saved: () => saved, dispatchLog: () => (mocked ? mocked.log.dispatch : []), mocked: () => mocked }
}
test.describe('Add-on navigation', () => {
test('the sidebar lists the pinned apps under Apps, in pin order, and no Show all link', async ({ page }) => {
test('the sidebar lists the pinned apps under Add-ons, in pin order, and no Show all link', async ({ page }) => {
await setup(page)
await page.goto('/app')
const nav = page.getByRole('navigation')
await expect(nav.getByRole('link', { name: 'Chat' })).toBeVisible()
// Apps, then the pinned apps in pin order; Apps is the only add-on entry.
await expect(nav.getByRole('link', { name: 'Apps', exact: true })).toHaveCount(1)
await expect(nav.getByRole('link', { name: 'Add-ons', exact: true })).toHaveCount(1)
const pinned = nav.getByTestId('apps-pinned-link')
await expect(pinned).toHaveCount(2)
await expect(pinned.nth(0)).toHaveAttribute('data-app', 'studio')
@@ -42,8 +43,8 @@ test.describe('Add-on navigation', () => {
await expect(nav.getByTestId('apps-show-all')).toHaveCount(0)
await expect(nav.getByText('Show all')).toHaveCount(0)
const group = await nav.locator('a.nav-item').evaluateAll(els => els.map(e => e.textContent.trim()))
const at = group.indexOf('Apps')
expect(group.slice(at, at + 4)).toEqual(['Apps', 'Studio', 'Talk'])
const at = group.indexOf('Add-ons')
expect(group.slice(at, at + 4)).toEqual(['Add-ons', 'Studio', 'Talk'])
await expect(pinned.nth(0)).toHaveAttribute('href', /\/app\/addons\/studio\//)
// There is no Pinned section.
await expect(page.getByTestId('pinned-nav')).toHaveCount(0)
@@ -59,12 +60,12 @@ test.describe('Add-on navigation', () => {
await page.unroute('**/api/addons/pins')
await page.route('**/api/addons/pins', route => route.fulfill({ json: [] }))
await page.goto('/app')
await expect(nav.getByRole('link', { name: 'Apps', exact: true })).toHaveCount(1)
await expect(nav.getByRole('link', { name: 'Add-ons', exact: true })).toHaveCount(1)
await expect(nav.getByTestId('apps-show-all')).toHaveCount(0)
await expect(nav.getByTestId('apps-pinned-link')).toHaveCount(0)
})
test('the icon rail shows Apps only, centred, with no pinned app', async ({ page }) => {
test('the icon rail shows Add-ons only, centred, with no pinned app', async ({ page }) => {
await setup(page)
for (const size of [{ width: 1280, height: 800, collapsed: true }, { width: 1000, height: 800, collapsed: false }]) {
await page.setViewportSize({ width: size.width, height: size.height })
@@ -85,87 +86,176 @@ test.describe('Add-on navigation', () => {
}
})
test('the Apps entry opens the drawer, where pinned add-ons are shown and their order can be changed', async ({ page }) => {
test('the Add-ons entry opens the drawer, where pinned add-ons are shown and their order can be changed', async ({ page }) => {
const server = await setup(page, { pins: ['studio', 'talk'] })
await page.goto('/app')
await page.getByRole('navigation').getByRole('link', { name: 'Apps' }).click()
await page.getByRole('navigation').getByRole('link', { name: 'Add-ons', exact: true }).click()
await expect(page).toHaveURL(/\/app\/addons$/)
await expect(page.getByTestId('addon-tile')).toHaveCount(2)
await expect(page.getByTestId('pin-toggle')).toHaveCount(2)
for (const toggle of await page.getByTestId('pin-toggle').all()) {
await expect(toggle).toHaveAttribute('aria-pressed', 'true')
await expect(toggle).toHaveText('Pinned')
}
await page.locator('[data-testid="addon-tile"][data-app="studio"]').getByTestId('pin-later').click()
// A bare pin button or pair of arrows is gone: each card has one overflow menu.
await expect(page.getByTestId('pin-toggle')).toHaveCount(0)
await expect(page.getByTestId('pin-later')).toHaveCount(0)
await expect(page.getByRole('button', { name: /^Actions for / })).toHaveCount(2)
const studio = page.locator('[data-testid="addon-tile"][data-app="studio"]')
await studio.getByRole('button', { name: /^Actions for / }).click()
await expect(page.getByRole('menuitem', { name: 'Remove from sidebar' })).toBeVisible()
await page.getByRole('menuitem', { name: /^Move .* later$/ }).click()
await expect.poll(() => server.saved()).toEqual(['talk', 'studio'])
})
test('the drawer lists tiles, searches them and pins from the keyboard', async ({ page }) => {
const server = await setup(page, { pins: [], apps: [app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' })] })
test('the card menu offers Show in sidebar, Details and Remove, and pins from the keyboard', async ({ page }) => {
const server = await setup(page, { pins: [], user: ADMIN, apps: [app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' })] })
await page.goto('/app/addons')
await expect(page.getByTestId('addon-tile')).toHaveCount(3)
await page.getByTestId('addon-search').fill('note')
await expect(page.getByTestId('addon-tile')).toHaveCount(1)
const toggle = page.getByTestId('pin-toggle').first()
await expect(toggle).toHaveAttribute('aria-pressed', 'false')
await expect(toggle).toHaveAccessibleName(/Pin Notes/)
await expect(toggle).toHaveText('Pin')
await toggle.focus()
const menu = page.getByRole('button', { name: 'Actions for Notes' })
await menu.focus()
await page.keyboard.press('Enter')
await expect(toggle).toHaveAttribute('aria-pressed', 'true')
await expect(toggle).toHaveText('Pinned')
expect(server.saved()).toEqual(['notes'])
await expect(page.getByRole('menuitem')).toHaveText(['Show in sidebar', 'Details', 'Remove'])
await page.keyboard.press('Enter')
await expect.poll(() => server.saved()).toEqual(['notes'])
await menu.click()
await expect(page.getByRole('menuitem', { name: 'Remove from sidebar' })).toBeVisible()
})
test('pinned apps come first in the grid, in pin order, and the detail view has the same pin control', async ({ page }) => {
const server = await setup(page, {
test('a reader sees no Remove in the card menu', async ({ page }) => {
await setup(page, { pins: [], apps: [app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' })] })
await page.goto('/app/addons')
await page.getByRole('button', { name: 'Actions for Notes' }).click()
await expect(page.getByRole('menuitem', { name: 'Show in sidebar' })).toBeVisible()
await expect(page.getByRole('menuitem', { name: 'Remove', exact: true })).toHaveCount(0)
})
test('the card says what the add-on does and what it adds', async ({ page }) => {
await setup(page, { pins: [], studio: false, apps: [app({ id: 'notes', title: 'Notes', description: 'Keep notes.', state: 'running', mainPage: '/app/addons/notes/home', tools: ['add', 'list'], toolCount: 2 }), app({ id: 'quiet', title: 'Quiet', description: 'Works in the background.', hasUI: false, mainPage: undefined, tools: [], toolCount: 0 })] })
await page.goto('/app/addons')
const notes = page.locator('[data-testid="addon-tile"][data-app="notes"]')
await expect(notes).toContainText('Keep notes.')
await expect(notes.getByTestId('tile-adds')).toContainText('Adds a page')
await expect(notes.getByTestId('tile-adds')).toContainText('2 chat tools')
await expect(page.locator('[data-testid="addon-tile"][data-app="quiet"]').getByTestId('tile-adds')).toHaveCount(0)
})
test('pinned apps come first in the grid, in pin order', async ({ page }) => {
await setup(page, {
pins: ['notes'],
apps: [app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' }), app({ id: 'zeta', title: 'Zeta', enabled: false, state: 'stopped' })],
apps: [app({ id: 'zeta', title: 'Zeta', enabled: false, state: 'stopped' }), app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' })],
})
await page.setViewportSize({ width: 1280, height: 800 })
await page.goto('/app/addons')
const tiles = page.getByTestId('addon-tile')
await expect(tiles.first()).toHaveAttribute('data-app', 'notes')
await expect(tiles.first().getByTestId('pin-toggle')).toHaveText('Pinned')
await expect(tiles.nth(1).getByTestId('pin-toggle')).toHaveText('Pin')
// The tile pin and the detail pin are one state.
await tiles.first().getByRole('button', { name: /^Actions for / }).click()
await expect(page.getByRole('menuitem', { name: 'Remove from sidebar' })).toBeVisible()
})
test('selecting a card opens the preview pane with what the add-on is, adds and offers', async ({ page }) => {
await setup(page, { pins: [], studio: false, user: ADMIN, apps: [app({ id: 'indexer', title: 'Indexer', description: 'Indexes your files.', enabled: false, state: 'stopped', hasUI: false, mainPage: undefined, tools: ['query', 'export'], toolCount: 2, version: '0.4.0', admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=indexer' } })], installed: [installedAddon({ id: 'indexer', title: 'Indexer', sourceId: 'main', enabled: false, state: 'stopped', version: '0.4.0' })] })
await page.setViewportSize({ width: 1360, height: 860 })
await page.goto('/app/addons')
await expect(page.getByTestId('addon-detail')).toHaveCount(0)
await page.getByRole('button', { name: /^Indexer, Off/ }).click()
const pane = page.getByTestId('addon-detail')
await expect(pane).toBeVisible()
await expect(page).toHaveURL(/detail=indexer/)
await expect(pane.getByRole('heading', { name: 'Indexer' })).toBeVisible()
await expect(pane.getByRole('img', { name: 'State: Off' })).toBeVisible()
await expect(pane).toContainText('Indexes your files.')
await expect(pane.getByTestId('app-tools')).toContainText('2 chat tools')
await expect(pane.getByTestId('app-tools')).toContainText('query, export')
await expect(pane).toContainText('Runs in the background')
await expect(pane.getByTestId('preview-facts')).toContainText('0.4.0')
await expect(pane.getByTestId('preview-source')).toHaveText('main')
await expect(pane.getByRole('button', { name: 'Turn on' })).toBeVisible()
await expect(pane.getByRole('button', { name: 'Remove' })).toBeVisible()
// No screenshot slot unless the add-on provides images.
await expect(pane.getByTestId('addon-shots')).toHaveCount(0)
await pane.getByRole('button', { name: 'Close' }).click()
await expect(page.getByTestId('addon-detail')).toHaveCount(0)
})
test('the preview pane shows screenshot slots only for an add-on that ships images', async ({ page }) => {
await setup(page, { pins: [], studio: false, apps: [app({ id: 'notes', title: 'Notes', screenshots: ['/api/addons/apps/notes/icon?generation=3'] })] })
await page.goto('/app/addons?detail=notes')
await expect(page.getByTestId('addon-detail').getByTestId('addon-shots').getByRole('listitem')).toHaveCount(1)
})
test('a member sees the pane without Remove and without the source', async ({ page }) => {
await setup(page, { pins: [], studio: false, apps: [app({ id: 'notes', title: 'Notes' })] })
await page.goto('/app/addons?detail=notes')
const pane = page.getByTestId('addon-detail')
await expect(pane.getByRole('heading', { name: 'Notes' })).toBeVisible()
await expect(pane.getByRole('button', { name: 'Remove' })).toHaveCount(0)
await expect(pane.getByTestId('preview-source')).toHaveCount(0)
})
test('the pane menu is the same sidebar control as the card menu', async ({ page }) => {
const server = await setup(page, { pins: ['notes'], studio: false, apps: [app({ id: 'notes', title: 'Notes' }), app({ id: 'zeta', title: 'Zeta', enabled: false, state: 'stopped' })] })
await page.setViewportSize({ width: 1360, height: 860 })
await page.goto('/app/addons?detail=zeta')
const detail = page.getByTestId('addon-detail')
const head = detail.getByTestId('pin-toggle')
await expect(head).toHaveText('Pin')
await head.click()
await expect(head).toHaveText('Pinned')
await expect(head).toHaveAttribute('aria-pressed', 'true')
await detail.getByRole('button', { name: 'Actions for Zeta' }).click()
await page.getByRole('menuitem', { name: 'Show in sidebar' }).click()
await expect.poll(() => server.saved()).toEqual(['notes', 'zeta'])
await head.click()
await expect(head).toHaveText('Pin')
await detail.getByRole('button', { name: 'Actions for Zeta' }).click()
await page.getByRole('menuitem', { name: 'Remove from sidebar' }).click()
await expect.poll(() => server.saved()).toEqual(['notes'])
})
test('the first-run card shows to an administrator with nothing installed and leads to Browse', async ({ page }) => {
await setup(page, { user: ADMIN, studio: false, pins: [] })
test('the first-run card offers three featured add-ons and a way to find more', async ({ page }) => {
await setup(page, { user: ADMIN, studio: false, pins: [], releases: ['notes', 'tables', 'voice', 'extra'].map(id => release(id, { description: `${id} does a thing.`, ui: id === 'notes' ? { mainPage: '/app/addons/notes/home' } : undefined })) })
await page.goto('/app/addons')
const card = page.getByTestId('addons-first-run')
await expect(card).toBeVisible()
await expect(card.getByRole('listitem')).toHaveCount(3)
await expect(card).toContainText('official library is registered by default')
await expect(card.getByRole('heading', { name: 'Add your first add-on' })).toBeVisible()
const featured = card.getByTestId('featured').getByRole('listitem')
await expect(featured).toHaveCount(3)
await expect(featured.first()).toContainText('does a thing.')
await expect(featured.first()).toContainText('Adds a page')
await expect(card.getByRole('button', { name: 'Install and turn on' })).toHaveCount(3)
// One way into Find add-ons while the card shows: the bar's button waits.
await expect(page.getByRole('link', { name: 'Find add-ons' })).toHaveCount(1)
await expect(page.getByTestId('addons-add')).toHaveCount(0)
await expect(page.getByTestId('addon-tile')).toHaveCount(0)
await card.getByTestId('addons-first-run-browse').click()
await expect(page).toHaveURL(/tab=browse/)
await expect(page.getByTestId('addons-first-run')).toHaveCount(0)
})
test('Install and turn on asks once, installs, then turns the add-on on', async ({ page }) => {
const state = await setup(page, { user: ADMIN, studio: false, pins: [], releases: [release('notes', { name: 'Notes' })] })
await page.goto('/app/addons')
await page.getByRole('button', { name: 'Install and turn on' }).click()
const dialog = page.getByRole('alertdialog').or(page.getByRole('dialog'))
await expect(dialog).toContainText('Install and turn on Notes?')
await dialog.getByRole('button', { name: 'Install and turn on' }).click()
await expect.poll(() => state.dispatchLog().filter(d => ['install', 'enable'].includes(d.action)).map(d => d.action)).toEqual(['install', 'enable'])
})
test('a search with no result says no add-on matches, not that none are installed', async ({ page }) => {
await setup(page, { user: ADMIN, studio: false, pins: [], apps: [app({ id: 'notes', title: 'Notes' })] })
await page.goto('/app/addons')
await page.getByTestId('addon-search').fill('zzz')
await expect(page.getByRole('heading', { name: 'No add-on matches' })).toBeVisible()
await expect(page.getByText('No add-ons yet')).toHaveCount(0)
await expect(page.getByTestId('addons-first-run')).toHaveCount(0)
await page.getByRole('button', { name: 'Clear search' }).click()
await expect(page.getByTestId('addon-tile')).toHaveCount(1)
})
test('the first-run card is hidden once an add-on is installed', async ({ page }) => {
await setup(page, { user: ADMIN, pins: [] })
await page.goto('/app/addons')
await expect(page.getByTestId('addon-tile').first()).toBeVisible()
await expect(page.getByTestId('addons-first-run')).toHaveCount(0)
await expect(page.getByTestId('addons-add')).toBeVisible()
})
test('the first-run card is hidden from a caller who cannot install add-ons', async ({ page }) => {
await setup(page, { studio: false, pins: [] })
await page.goto('/app/addons')
await expect(page.getByRole('heading', { name: 'No apps yet' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'No add-ons yet' })).toBeVisible()
await expect(page.getByTestId('addons-first-run')).toHaveCount(0)
})
@@ -180,7 +270,8 @@ test.describe('Add-on navigation', () => {
test('a pinned tile can be moved later and the new order is saved', async ({ page }) => {
const server = await setup(page, { pins: ['studio', 'talk'] })
await page.goto('/app/addons')
await page.getByTestId('addon-tile').filter({ hasText: 'Studio' }).getByTestId('pin-later').click()
await page.getByTestId('addon-tile').filter({ hasText: 'Studio' }).getByRole('button', { name: /^Actions for / }).click()
await page.getByRole('menuitem', { name: /^Move .* later$/ }).click()
await expect.poll(() => server.saved()).toEqual(['talk', 'studio'])
})
@@ -189,7 +280,8 @@ test.describe('Add-on navigation', () => {
const eight = nine.slice(0, 8).map(a => a.id)
await setup(page, { apps: nine, pins: eight })
await page.goto('/app/addons')
await page.getByTestId('addon-tile').filter({ hasText: 'App 8' }).getByTestId('pin-toggle').click()
await page.getByTestId('addon-tile').filter({ hasText: 'App 8' }).getByRole('button', { name: /^Actions for / }).click()
await page.getByRole('menuitem', { name: 'Show in sidebar' }).click()
await expect(page.getByRole('alertdialog').or(page.getByRole('dialog'))).toContainText(/unpin/i)
})
@@ -201,7 +293,7 @@ test.describe('Add-on navigation', () => {
await page.route(/\/api\/addons\/apps$/, route => { appsCalled = true; return route.fulfill({ status: 403, json: {} }) })
await page.goto('/app/addons')
await expect(page.getByTestId('addon-tile')).toHaveCount(1)
await expect(page.getByTestId('addons-browse')).toHaveCount(0)
await expect(page.getByTestId('addons-add')).toHaveCount(0)
await expect(page.getByRole('alert')).toHaveCount(0)
expect(appsCalled).toBe(false)
})
@@ -254,19 +346,17 @@ test.describe('Add-on navigation', () => {
await setup(page, { apps: [app({ id: 'notes', title: 'Notes', enabled: false, state: 'stopped', admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=notes' } })] })
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor({ ...MEMBER, role: 'admin' }, { addons: true }) }))
await page.goto('/app/addons?detail=notes')
await expect(page.getByTestId('addon-detail').getByRole('button', { name: 'Start' })).toBeVisible()
await expect(page.getByTestId('addon-detail').getByRole('button', { name: 'Turn on' })).toBeVisible()
})
test('the pin control has a visible focus state and a name', async ({ page }) => {
test('the card menu has a visible focus state and a name', async ({ page }) => {
await setup(page)
await page.goto('/app/addons')
const toggle = page.getByTestId('pin-toggle').first()
await toggle.focus()
const outline = await toggle.evaluate(el => getComputedStyle(el).outlineStyle)
expect(outline).toBe('solid')
const width = await toggle.evaluate(el => parseFloat(getComputedStyle(el).outlineWidth))
expect(width).toBeGreaterThan(0)
await expect(toggle).toHaveAccessibleName(/(Pin|Unpin) /)
const trigger = page.getByRole('button', { name: /^Actions for / }).first()
await trigger.focus()
await expect(trigger).toBeFocused()
await expect(trigger).toHaveAttribute('aria-haspopup', 'menu')
await expect(trigger).toHaveAccessibleName(/^Actions for .+/)
})
test('a pin of an add-on that is no longer listed is not drawn in the drawer and shows no error', async ({ page }) => {
@@ -416,6 +506,65 @@ test.describe('Add-on navigation: the Voice recognition page', () => {
await expectVoiceTabs(page, 'recognition')
await expect(page.locator('[data-testid="addon-surface"][data-addon="voice"]')).toBeVisible()
await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet')
// With nobody saved there is one enrol button, in the empty state.
await expect(page.getByRole('button', { name: /Enrol a speaker/ })).toHaveCount(1)
})
test('an add-on that is installed but off says it is off, by its name, and offers Turn on', async ({ page }) => {
const server = await setup(page, { user: ADMIN, studio: false, pins: [], apps: [app({ id: 'fine-tuning', title: 'Fine-tuning', enabled: false, state: 'stopped', mainPage: undefined, admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=fine-tuning' } })] })
await page.goto('/app/addons/fine-tuning/jobs')
const state = page.getByTestId('addon-unavailable')
await expect(state).toHaveAttribute('data-state', 'off')
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Fine-tuning')
await expect(state.getByRole('heading', { name: 'Fine-tuning is off' })).toBeVisible()
await expect(state).not.toContainText('fine-tuning')
await state.getByRole('button', { name: 'Turn on' }).click()
await expect.poll(() => server.mocked().log.start.length).toBe(1)
expect(server.mocked().log.start[0].id).toBe('fine-tuning')
})
test('an off add-on shows a reader who can turn it on, and no button', async ({ page }) => {
await setup(page, { studio: false, pins: [], apps: [app({ id: 'fine-tuning', title: 'Fine-tuning', enabled: false, state: 'stopped', mainPage: undefined })] })
await page.goto('/app/addons/fine-tuning/jobs')
const state = page.getByTestId('addon-unavailable')
await expect(state.getByRole('heading', { name: 'Fine-tuning is off' })).toBeVisible()
await expect(state).toContainText('An administrator can turn it on.')
await expect(state.getByRole('button', { name: 'Turn on' })).toHaveCount(0)
})
test('an add-on that is not installed says so and sends an administrator to Find add-ons', async ({ page }) => {
await setup(page, { user: ADMIN, studio: false, pins: [], apps: [] })
await page.goto('/app/addons/fine-tuning/jobs')
const state = page.getByTestId('addon-unavailable')
await expect(state).toHaveAttribute('data-state', 'not-installed')
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Fine-tuning')
await expect(state.getByRole('heading', { name: 'Fine-tuning is not installed' })).toBeVisible()
await expect(state.getByRole('link', { name: 'Find add-ons' })).toHaveAttribute('href', '/app/addons?tab=browse&q=fine-tuning')
})
test('a reader of an add-on that is not installed gets no link to Find add-ons', async ({ page }) => {
await setup(page, { studio: false, pins: [], apps: [] })
await page.goto('/app/addons/fine-tuning/jobs')
const state = page.getByTestId('addon-unavailable')
await expect(state.getByRole('heading', { name: 'Fine-tuning is not installed' })).toBeVisible()
await expect(state).toContainText('An administrator can install it.')
await expect(state.getByRole('link', { name: 'Find add-ons' })).toHaveCount(0)
})
test('a failed add-on shows its reason and, for an administrator, a link to its details', async ({ page }) => {
await setup(page, { user: ADMIN, studio: false, pins: [], apps: [app({ id: 'fine-tuning', title: 'Fine-tuning', state: 'failed', mainPage: undefined, ui: { entry: '/x.js', sdk: { min: 1, max: 1 }, pages: ['jobs'] } })] })
await page.goto('/app/addons/fine-tuning/jobs')
const state = page.getByTestId('addon-unavailable')
await expect(state).toHaveAttribute('data-state', 'failed')
await expect(state.getByRole('heading', { name: 'Fine-tuning failed to start' })).toBeVisible()
await expect(state).toContainText('stopped responding')
await expect(state.getByRole('link', { name: 'Details' })).toHaveAttribute('href', '/app/addons?detail=fine-tuning')
})
test('the page names a headless add-on by a capitalised id, not the bare identifier', async ({ page }) => {
await setup(page, { user: ADMIN, studio: false, pins: [], apps: [app({ id: 'indexer', title: 'indexer', enabled: false, state: 'stopped', mainPage: undefined, admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=indexer' } })] })
await page.goto('/app/addons/indexer/home')
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Indexer')
})
test('an old Voice link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
@@ -461,7 +610,7 @@ test.describe('Add-on navigation: the Voice recognition page', () => {
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/library$/)
await expect(page.getByTestId('voice-library')).toBeVisible()
await expectVoiceTabs(page, 'library')
await expectOneVoiceTitle(page, /^Speech voices/)
await expectOneVoiceTitle(page, 'Voices')
await page.getByTestId('voice-library-new').first().click()
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/library\/new$/)
@@ -473,7 +622,7 @@ test.describe('Add-on navigation: the Voice recognition page', () => {
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/speech$/)
await expect(page.getByTestId('voice-speech')).toBeVisible()
await expectVoiceTabs(page, 'speech')
await expectOneVoiceTitle(page, 'Speak text')
await expectOneVoiceTitle(page, 'Voices')
await tabs.getByRole('tab', { name: /^From a recording$/ }).click()
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/recording$/)
@@ -482,6 +631,20 @@ test.describe('Add-on navigation: the Voice recognition page', () => {
await expectOneVoiceTitle(page, 'Voices')
})
test('the Text to speech tab says where to get a model when none is installed', async ({ page }) => {
await setup(page, { apps: [voiceApp()] })
await asAdmin(page)
await serveVoice(page)
await mockIdentity(page)
// Registered last, so it wins over the fixture's model list.
await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: [] } }))
await page.goto('/app/addons/voice/voice/speech')
const hint = page.getByTestId('speech-no-models')
await expect(hint).toBeVisible({ timeout: 15_000 })
await expect(hint.getByRole('link', { name: /model gallery/ })).toHaveAttribute('href', /\/app\/models$/)
await expectOneVoiceTitle(page, 'Voices')
})
test('a viewer who is not an administrator gets no Library tab', async ({ page }) => {
await setup(page, { apps: [voiceApp()] })
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: true, voice_recognition: true }) }))
@@ -695,6 +858,18 @@ test.describe('Add-on navigation: the Face recognition page', () => {
await expect(page.getByTestId('tool-off')).toBeVisible()
})
test('Fine-tune titles itself with its name and keeps the Experimental badge out of the h1', async ({ page }) => {
await setup(page, { apps: [ftApp()] })
await asAdmin(page, { fine_tuning: true })
await page.route('**/api/fine-tuning/jobs', route => route.fulfill({ json: [] }))
await serveFineTuning(page)
await page.goto('/app/addons/fine-tuning/jobs')
await expect(page.getByTestId('fine-tune-page')).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Fine-tune')
await expect(page.locator('h1 .bt-experimental')).toHaveCount(0)
await expect(page.locator('.bt-experimental')).toBeVisible()
})
test('an old fine-tuning link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
await setup(page, { apps: [] })
await asAdmin(page)
@@ -52,7 +52,11 @@ test.describe('Library backends', () => {
await mockBackends(page)
await page.goto('/app/addons?tab=browse')
await expect(page.locator('.cat-item')).toHaveCount(2)
await expect(page.locator('.cat-item', { hasText: 'Tables' }).getByTestId('entry-kind')).toHaveText('Add-on')
// A backend says what it is on the entry; an add-on is the default and says it under Details.
const tables = page.locator('.cat-item', { hasText: 'Tables' })
await expect(tables.getByTestId('entry-kind')).toHaveCount(0)
await tables.getByText('Details', { exact: true }).click()
await expect(tables.getByTestId('details-kind')).toHaveText('Add-on')
const row = page.locator('.cat-item', { hasText: 'cpu-local-store' })
await expect(row.getByTestId('entry-kind')).toHaveText('Backend')
await expect(row.getByTestId('entry-kind')).toHaveAttribute('data-kind', 'backend')
@@ -83,7 +87,7 @@ test.describe('Library backends', () => {
expect(state.log.dispatch.filter(d => d.action === 'install')).toHaveLength(0)
// Once installed the entry says so and offers no second install.
await expect(row.getByText('Installed', { exact: true })).toBeVisible()
await expect(row.getByRole('button', { name: 'Install' })).toBeDisabled()
await expect(row.getByRole('button', { name: 'Install' })).toHaveCount(0)
})
test('browse: a refusal of the host is shown with its own reason', async ({ page }) => {
@@ -121,7 +125,7 @@ test.describe('Library backends', () => {
await mockAddons(page, { installed: [], releases: [backend()] })
await mockBackends(page)
await page.goto('/app/addons?tab=browse')
await page.locator('.cat-item', { hasText: 'cpu-local-store' }).getByRole('button', { name: 'Details' }).click()
await page.locator('.cat-item', { hasText: 'cpu-local-store' }).getByText('Details', { exact: true }).click()
await expect(page.getByTestId('details-kind')).toHaveText('Backend')
await expect(page.getByText('local-store', { exact: true })).toBeVisible()
await expect(page.getByText('workflows/w', { exact: false })).toBeVisible()
@@ -65,23 +65,23 @@ test.describe('Add-ons foundation through real servers', () => {
const { context, page } = await browserAs(browser, node, 'member')
await page.goto('/app/addons')
await expect(card(page)).toBeVisible({ timeout: 30_000 })
await expect(card(page).locator('.ad-tile__state')).toHaveText('Running')
await expect(card(page).locator('.ad-tile__state')).toHaveText('On')
await context.close()
}
})
test('foundation: a member sees the Apps list only and the server refuses every management request', async ({ browser }) => {
test('foundation: a member sees the add-on list only and the server refuses every management request', async ({ browser }) => {
for (const node of ['front-a', 'front-b', 'owner']) {
const { context, page } = await browserAs(browser, node, 'member')
await page.goto('/app/addons')
await expect(card(page)).toBeVisible({ timeout: 30_000 })
await expect(page.getByRole('tablist')).toHaveCount(0)
await expect(page.getByRole('button', { name: /^(Start|Stop)$/ })).toHaveCount(0)
await expect(page.getByRole('button', { name: /^Turn (on|off)$/ })).toHaveCount(0)
await expect(page.getByRole('link', { name: 'Manage' })).toHaveCount(0)
// The detail sheet hides them as well.
await page.goto(`/app/addons?detail=${ID}`)
await expect(sheetCard(page)).toBeVisible({ timeout: 30_000 })
await expect(page.getByRole('button', { name: /^(Start|Stop)$/ })).toHaveCount(0)
await expect(page.getByRole('button', { name: /^Turn (on|off)$/ })).toHaveCount(0)
await expect(page.getByRole('link', { name: 'Manage' })).toHaveCount(0)
// The page hides the controls; the server is what refuses.
for (const action of ['install', 'upgrade', 'enable', 'disable', 'uninstall', 'catalog-sources', 'addons-installed']) {
@@ -116,15 +116,15 @@ test.describe('Add-ons foundation through real servers', () => {
await expect(card(page)).toBeVisible({ timeout: 30_000 })
await expect(page.getByRole('tablist')).toBeVisible()
await page.goto(`/app/addons?detail=${ID}`)
await sheetCard(page).getByRole('button', { name: 'Stop' }).click()
await expect(sheetCard(page).getByRole('img', { name: 'State: Stopped' })).toBeVisible({ timeout: 30_000 })
await sheetCard(page).getByRole('button', { name: 'Turn off' }).click()
await expect(sheetCard(page).getByRole('img', { name: 'State: Off' })).toBeVisible({ timeout: 30_000 })
// The other frontend and the owner see the same stopped add-on: the
// administrator in the list with its state, a member not at all, because
// the owner lists a disabled add-on to administrators only.
for (const node of ['front-a', 'owner']) {
const other = await browserAs(browser, node, 'admin')
await other.page.goto(`/app/addons?detail=${ID}`)
await expect(sheetCard(other.page).getByRole('img', { name: 'State: Stopped' })).toBeVisible({ timeout: 30_000 })
await expect(sheetCard(other.page).getByRole('img', { name: 'State: Off' })).toBeVisible({ timeout: 30_000 })
await other.context.close()
const member = await browserAs(browser, node, 'member')
await member.page.goto('/app/addons')
@@ -132,8 +132,8 @@ test.describe('Add-ons foundation through real servers', () => {
await expect(card(member.page)).toHaveCount(0)
await member.context.close()
}
await sheetCard(page).getByRole('button', { name: 'Start' }).click()
await expect(sheetCard(page).getByRole('img', { name: 'State: Running' })).toBeVisible({ timeout: 30_000 })
await sheetCard(page).getByRole('button', { name: 'Turn on' }).click()
await expect(sheetCard(page).getByRole('img', { name: 'State: On' })).toBeVisible({ timeout: 30_000 })
await context.close()
})
+169 -131
View File
@@ -1,8 +1,8 @@
import { test, expect } from '@playwright/test'
import { ADMIN, MEMBER, HEADLESS, app, installedAddon, release, sessionFor, mockAddons, ok, fail } from './addons-fixtures.js'
// The add-ons page (src/pages/Addons.jsx): Apps for everyone, Browse,
// Installed and Libraries for administrators. The server is the authority for
// The add-ons page (src/pages/Addons.jsx): My add-ons for everyone, Find add-ons,
// Installed and Sources for administrators. The server is the authority for
// every answer, so these specs drive the page against a stand-in that gives
// the same refusals the real routes give, and assert what the page sent.
@@ -33,10 +33,10 @@ test.describe('Add-ons: apps', () => {
await page.goto('/app/addons')
const notes = page.locator('[data-app="notes"]')
await expect(notes.locator('.ad-tile__title')).toHaveText('Notes')
await expect(notes.locator('.ad-tile__state')).toHaveText('Running')
await expect(notes.locator('.ad-tile__state')).toHaveText('On')
await expect(notes.getByRole('link')).toHaveAttribute('href', '/app/addons/notes/home')
await expect(notes.locator('img.ad-icon')).toHaveAttribute('src', '/api/addons/apps/notes/icon?generation=3')
await expect(page.locator('[data-app="broken"] .ad-tile__state')).toHaveText('Stopped, failed')
await expect(page.locator('[data-app="broken"] .ad-tile__state')).toHaveText('Off, failed')
await expect(page.locator('[data-app="broken"]').getByRole('link')).toHaveCount(0)
await expect(page.locator('[data-app="indexer"]').getByRole('link')).toHaveCount(0)
// Every image is served by the host itself, never from another origin.
@@ -50,7 +50,7 @@ test.describe('Add-ons: apps', () => {
await mockAddons(page, { apps: [HEADLESS] })
await page.goto('/app/addons?detail=indexer')
const card = detail(page, 'indexer')
await expect(card.getByTestId('app-tools')).toContainText('3 tools')
await expect(card.getByTestId('app-tools')).toContainText('3 chat tools')
await expect(card.getByTestId('app-tools')).toContainText('query, export, reindex')
await expect(card.getByText('Runs in the background')).toBeVisible()
await expect(card.getByRole('link')).toHaveCount(0)
@@ -62,13 +62,13 @@ test.describe('Add-ons: apps', () => {
await page.goto('/app/addons?tab=libraries')
await expect(page.locator('[data-app="notes"]')).toBeVisible()
await expect(page.getByRole('tablist')).toHaveCount(0)
await expect(page.getByRole('button', { name: /^(Start|Stop)$/ })).toHaveCount(0)
await expect(page.getByRole('button', { name: /^Turn (on|off)$/ })).toHaveCount(0)
await expect(page.getByRole('link', { name: 'Manage' })).toHaveCount(0)
await expect(page.getByText('Add a library')).toHaveCount(0)
// The detail sheet offers the reader nothing to start, stop or manage either.
// The preview pane offers the reader nothing to turn on, turn off or manage either.
await page.goto('/app/addons?detail=notes')
await expect(detail(page, 'notes')).toBeVisible()
await expect(page.getByRole('button', { name: /^(Start|Stop)$/ })).toHaveCount(0)
await expect(page.getByRole('button', { name: /^Turn (on|off)$/ })).toHaveCount(0)
await expect(page.getByRole('link', { name: 'Manage' })).toHaveCount(0)
})
@@ -77,10 +77,9 @@ test.describe('Add-ons: apps', () => {
const state = await mockAddons(page, { apps: [app({ id: 'indexer', title: 'Indexer', enabled: false, state: 'stopped', generation: 4, mainPage: undefined, admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=indexer' } })] })
await page.goto('/app/addons?detail=indexer')
const card = detail(page, 'indexer')
await expect(card.getByRole('img', { name: 'State: Stopped' })).toBeVisible()
await expect(card.getByRole('link', { name: 'Manage' })).toHaveAttribute('href', '/app/addons?manage=indexer')
await card.getByRole('button', { name: 'Start' }).click()
await expect(card.getByRole('img', { name: 'State: Running' })).toBeVisible()
await expect(card.getByRole('img', { name: 'State: Off' })).toBeVisible()
await card.getByRole('button', { name: 'Turn on' }).click()
await expect(card.getByRole('img', { name: 'State: On' })).toBeVisible()
expect(state.log.start).toHaveLength(1)
expect(state.log.start[0].generation).toBe(4)
expect(state.log.start[0].operationId).toMatch(UUID)
@@ -101,7 +100,7 @@ test.describe('Add-ons: apps', () => {
},
})
await page.goto('/app/addons?detail=indexer')
await detail(page, 'indexer').getByRole('button', { name: 'Start' }).click()
await detail(page, 'indexer').getByRole('button', { name: 'Turn on' }).click()
await expect.poll(() => reads, { timeout: 15_000 }).toBeGreaterThanOrEqual(2)
expect(state.log.start).toHaveLength(1)
const ids = new Set([state.log.start[0].operationId, ...state.log.dispatch.filter(d => d.action === 'operation-status').map(d => d.operationId)])
@@ -150,7 +149,7 @@ test.describe('Add-ons: apps', () => {
})
await page.goto('/app/addons?detail=indexer')
const card = detail(page, 'indexer')
await card.getByRole('button', { name: 'Start' }).click()
await card.getByRole('button', { name: 'Turn on' }).click()
const notice = card.getByTestId('unknown-outcome')
await expect(notice).toBeVisible({ timeout: 40_000 })
expect(state.log.start).toHaveLength(1)
@@ -158,23 +157,23 @@ test.describe('Add-ons: apps', () => {
lose = false
// The list now shows the app running, but a new action on it is blocked
// until the unknown one is read or dismissed.
await expect(card.getByRole('button', { name: 'Stop' })).toBeVisible({ timeout: 15_000 })
await expect(card.getByRole('button', { name: 'Stop' })).toBeDisabled()
await expect(card.getByRole('button', { name: 'Turn off' })).toBeVisible({ timeout: 15_000 })
await expect(card.getByRole('button', { name: 'Turn off' })).toBeDisabled()
expect(state.log.stop).toHaveLength(0)
await notice.getByRole('button', { name: 'Check status' }).click()
await expect(notice).toHaveCount(0)
expect(state.log.start).toHaveLength(1)
// Stop is a new operation.
await card.getByRole('button', { name: 'Stop' }).click()
await card.getByRole('button', { name: 'Turn off' }).click()
await expect.poll(() => state.log.stop.length).toBe(1)
expect(state.log.stop[0].operationId).toMatch(UUID)
expect(state.log.stop[0].operationId).not.toBe(firstId)
await expect(card.getByRole('button', { name: 'Start' })).toBeVisible()
await expect(card.getByRole('button', { name: 'Turn on' })).toBeVisible()
// Start again: a fresh ID, applied by the owner, not replayed as a no-op.
await card.getByRole('button', { name: 'Start' }).click()
await card.getByRole('button', { name: 'Turn on' }).click()
await expect.poll(() => state.log.start.length).toBe(2)
expect(state.log.start[1].operationId).not.toBe(firstId)
await expect(card.getByRole('img', { name: 'State: Running' })).toBeVisible()
await expect(card.getByRole('img', { name: 'State: On' })).toBeVisible()
await expect(card.getByRole('alert')).toHaveCount(0)
expect(state.apps[0].enabled).toBe(true)
})
@@ -191,13 +190,13 @@ test.describe('Add-ons: apps', () => {
})
await page.goto('/app/addons?detail=indexer')
const card = detail(page, 'indexer')
await card.getByRole('button', { name: 'Start' }).click()
await card.getByRole('button', { name: 'Turn on' }).click()
const notice = card.getByTestId('unknown-outcome')
await expect(notice).toBeVisible({ timeout: 40_000 })
await expect(card.getByRole('button', { name: 'Start' })).toBeDisabled()
await expect(card.getByRole('button', { name: 'Turn on' })).toBeDisabled()
await notice.getByRole('button', { name: 'Dismiss' }).click()
await expect(notice).toHaveCount(0)
await card.getByRole('button', { name: 'Start' }).click()
await card.getByRole('button', { name: 'Turn on' }).click()
await expect.poll(() => state.log.start.length).toBe(2)
expect(state.log.start[1].operationId).not.toBe(state.log.start[0].operationId)
})
@@ -211,9 +210,9 @@ test.describe('Add-ons: apps', () => {
})
await page.goto('/app/addons?detail=indexer')
const card = detail(page, 'indexer')
await card.getByRole('button', { name: 'Start' }).click()
await card.getByRole('button', { name: 'Turn on' }).click()
await expect(card.getByRole('alert')).toContainText('did not change')
await expect(card.getByRole('img', { name: 'State: Stopped' })).toBeVisible()
await expect(card.getByRole('img', { name: 'State: Off' })).toBeVisible()
})
test('apps: a refused start shows the server denial and refreshes the list', async ({ page }) => {
@@ -224,8 +223,8 @@ test.describe('Add-ons: apps', () => {
})
await page.goto('/app/addons?detail=indexer')
const before = state.log.appsList
await detail(page, 'indexer').getByRole('button', { name: 'Start' }).click()
await expect(page.getByRole('alert').filter({ hasText: 'The server refused this' })).toBeVisible()
await detail(page, 'indexer').getByRole('button', { name: 'Turn on' }).click()
await expect(page.getByRole('alert').filter({ hasText: 'addon_error' })).toBeVisible()
await expect.poll(() => state.log.appsList).toBeGreaterThan(before)
})
@@ -324,6 +323,9 @@ test.describe('Add-ons: browse', () => {
await expect(page.locator('.cat-item')).toHaveCount(24)
await expect(page.getByText('31 results')).toBeVisible()
const tables = page.locator('.cat-item', { hasText: 'Tables' }).first()
// Trust and platform facts are folded into the entry, behind Details.
await expect(tables.getByText('linux/amd64')).toBeHidden()
await tables.getByText('Details', { exact: true }).click()
await expect(tables.getByText('Signature required')).toBeVisible()
await expect(tables.getByText('linux/amd64')).toBeVisible()
await expect(page.getByRole('group', { name: 'Libraries' }).getByRole('button', { name: 'Team' })).toBeVisible()
@@ -331,51 +333,101 @@ test.describe('Add-ons: browse', () => {
await expect(page).toHaveURL(/page=2/)
await expect(page.locator('.cat-item')).toHaveCount(7)
expect(state.log.dispatch.filter(d => d.action === 'catalog-search').at(-1).arguments.offset).toBe(24)
await page.getByRole('searchbox').or(page.getByLabel('Search the libraries')).first().fill('tables')
await page.getByRole('searchbox').or(page.getByLabel('Search add-ons in your sources')).first().fill('tables')
await expect(page.locator('.cat-item')).toHaveCount(1)
await expect(page).toHaveURL(/q=tables/)
expect(state.log.dispatch.filter(d => d.action === 'catalog-search').at(-1).arguments.text).toBe('tables')
await shoot(page, 'browse')
})
test('browse: installing asks first and leaves the add-on disabled until it is enabled', async ({ page }) => {
test('browse: Install and turn on asks once, installs and turns the add-on on', async ({ page }) => {
await signIn(page, ADMIN)
const state = await mockAddons(page, { installed: [], releases: [release('tables', { name: 'Tables' })] })
const state = await mockAddons(page, { installed: [], releases: [release('tables', { name: 'Tables', description: 'Spreadsheets.', ui: { mainPage: '/app/addons/tables/home' } })] })
await page.goto('/app/addons?tab=browse')
const row = page.locator('.cat-item', { hasText: 'Tables' })
await row.getByRole('button', { name: 'Install' }).click()
await expect(row).toContainText('Spreadsheets.')
await expect(row.getByTestId('entry-adds')).toHaveText('Adds a page')
// One button on the entry, and Details is folded into it.
await expect(row.getByRole('button')).toHaveCount(1)
await row.getByRole('button', { name: 'Install and turn on' }).click()
const dialog = page.getByRole('alertdialog')
await expect(dialog).toContainText('Install Tables?')
await expect(dialog).toContainText('does not start the add-on')
await expect(dialog).toContainText('Install and turn on Tables?')
await expect(dialog).toContainText('sha256:aaaaaaaaaaaa')
expect(state.log.dispatch.filter(d => d.action === 'install')).toHaveLength(0)
await dialog.getByRole('button', { name: 'Install' }).click()
await expect(row.getByText('It is not enabled')).toBeVisible()
await dialog.getByRole('button', { name: 'Install and turn on' }).click()
// The same dialog stays open and shows the two steps, then the result.
await expect(dialog.getByTestId('flow-steps').getByRole('listitem')).toHaveCount(2)
await expect(dialog).toContainText('Tables is installed and on.')
await expect(dialog.getByTestId('flow-steps').locator('[data-step="install"]')).toHaveAttribute('data-state', 'done')
await expect(dialog.getByTestId('flow-steps').locator('[data-step="turn-on"]')).toHaveAttribute('data-state', 'done')
await dialog.getByRole('button', { name: 'Close' }).click()
await expect(page.getByRole('alertdialog')).toHaveCount(0)
await expect(row.getByText('Tables is installed and on.')).toBeVisible()
const install = state.log.dispatch.filter(d => d.action === 'install')
expect(install).toHaveLength(1)
expect(install[0].operationId).toMatch(UUID)
expect(install[0].arguments).toEqual({ sourceId: 'main', version: '1.0.0' })
expect(state.log.dispatch.filter(d => d.action === 'enable')).toHaveLength(0)
// Activation is its own step, on the Installed tab.
await tab(page, 'Installed').click()
const installed = page.locator('[data-addon="tables"]')
await expect(installed.getByText('Disabled')).toBeVisible()
await installed.getByRole('button', { name: 'Enable' }).click()
await expect(installed.getByText('Enabled', { exact: true })).toBeVisible()
expect(state.log.dispatch.filter(d => d.action === 'enable')).toHaveLength(1)
expect(state.log.dispatch.find(d => d.action === 'enable').generation).toBe(1)
const enable = state.log.dispatch.filter(d => d.action === 'enable')
expect(enable).toHaveLength(1)
expect(enable[0].operationId).toMatch(UUID)
expect(enable[0].operationId).not.toBe(install[0].operationId)
expect(state.installed.find(a => a.id === 'tables').enabled).toBe(true)
})
test('browse: a failed turn on keeps the install, says why in the dialog and can be tried again', async ({ page }) => {
await signIn(page, ADMIN)
let attempts = 0
const state = await mockAddons(page, {
installed: [],
releases: [release('tables', { name: 'Tables' })],
dispatch: (body) => {
if (body.action !== 'enable') return null
attempts += 1
return attempts === 1 ? fail(403, 'addon_error') : null
},
})
await page.goto('/app/addons?tab=browse')
const row = page.locator('.cat-item', { hasText: 'Tables' })
await row.getByRole('button', { name: 'Install and turn on' }).click()
const dialog = page.getByRole('alertdialog')
await dialog.getByRole('button', { name: 'Install and turn on' }).click()
await expect(dialog.getByTestId('install-dialog-failed')).toContainText('Tables is installed but did not turn on.')
await expect(dialog.getByTestId('flow-steps').locator('[data-step="install"]')).toHaveAttribute('data-state', 'done')
await expect(dialog.getByTestId('flow-steps').locator('[data-step="turn-on"]')).toHaveAttribute('data-state', 'failed')
// Trying again turns it on; it does not install a second time.
await dialog.getByRole('button', { name: 'Try again' }).click()
await expect(dialog).toContainText('Tables is installed and on.')
expect(state.log.dispatch.filter(d => d.action === 'install')).toHaveLength(1)
expect(state.log.dispatch.filter(d => d.action === 'enable')).toHaveLength(2)
})
test('browse: an add-on that is installed but off offers Turn on and nothing else', async ({ page }) => {
await signIn(page, ADMIN)
const state = await mockAddons(page, { installed: [installedAddon({ id: 'tables', title: 'Tables', enabled: false, state: 'stopped', generation: 1, version: '1.0.0' })], releases: [release('tables', { name: 'Tables' })] })
await page.goto('/app/addons?tab=browse')
const row = page.locator('.cat-item', { hasText: 'Tables' })
await expect(row.getByRole('button')).toHaveCount(1)
await row.getByRole('button', { name: 'Turn on' }).click()
await expect.poll(() => state.log.dispatch.filter(d => d.action === 'enable').length).toBe(1)
expect(state.log.dispatch.filter(d => d.action === 'install')).toHaveLength(0)
})
})
// What the Installed tab used to show is the Details of an add-on in My add-ons:
// the list read for an administrator and its installed record go together.
const details = (id, over = {}, installedOver = {}) => ({
apps: [app({ id, title: installedOver.title || id, admin: { canStart: false, canStop: true, managementUrl: `/app/addons?manage=${id}` }, ...over })],
installed: [installedAddon({ id, title: installedOver.title || id, ...installedOver })],
})
test.describe('Add-ons: installed', () => {
test('installed: health is separate from enabled and the details show trust, compatibility and storage', async ({ page }) => {
await signIn(page, ADMIN)
await mockAddons(page, { installed: [installedAddon({ state: 'failed', restarts: 3, verification: 'sha256+sigstore' })] })
await page.goto('/app/addons?tab=installed')
const row = page.locator('[data-addon="notes"]')
await expect(row.getByText('Enabled', { exact: true })).toBeVisible()
await expect(row.getByRole('img', { name: 'State: Failed' })).toBeVisible()
await row.getByRole('button', { name: 'Details' }).click()
await mockAddons(page, details('notes', { state: 'failed' }, { state: 'failed', restarts: 3, verification: 'sha256+sigstore', title: 'Notes' }))
await page.goto('/app/addons?manage=notes')
const pane = page.getByTestId('addon-detail')
await expect(pane.getByRole('heading', { name: 'Notes' })).toBeVisible()
await expect(pane.getByRole('img', { name: 'State: Failed' }).first()).toBeVisible()
const sheet = page.getByTestId('addon-manage')
await expect(sheet.getByText('Wanted state')).toBeVisible()
await expect(sheet.getByRole('img', { name: 'State: Failed' })).toBeVisible()
@@ -394,14 +446,18 @@ test.describe('Add-ons: installed', () => {
test('installed: an update is shown only from the owner update field and upgrade sends that version', async ({ page }) => {
await signIn(page, ADMIN)
const candidate = 'sha256:' + 'b'.repeat(64)
const state = await mockAddons(page, { installed: [
installedAddon({ update: { available: true, state: 'update-available', version: '1.3.0', digest: candidate, checkedAt: '2026-10-01T00:00:00Z' } }),
installedAddon({ id: 'plain', title: 'Plain', version: '9.9.9' }),
] })
await page.goto('/app/addons?tab=installed')
await expect(page.locator('[data-addon="notes"]').getByText('Update 1.3.0')).toBeVisible()
await expect(page.locator('[data-addon="plain"]').getByText(/Update/)).toHaveCount(0)
await page.locator('[data-addon="notes"]').getByRole('button', { name: 'Details' }).click()
const admin = { canStart: false, canStop: true, managementUrl: '/app/addons?manage=notes' }
const state = await mockAddons(page, {
apps: [app({ update: 'update-pending', admin, title: 'Notes' }), app({ id: 'plain', title: 'Plain', admin: { ...admin, managementUrl: '/app/addons?manage=plain' } })],
installed: [
installedAddon({ update: { available: true, state: 'update-available', version: '1.3.0', digest: candidate, checkedAt: '2026-10-01T00:00:00Z' } }),
installedAddon({ id: 'plain', title: 'Plain', version: '9.9.9' }),
],
})
await page.goto('/app/addons')
await expect(page.locator('[data-testid="addon-tile"][data-app="notes"]').getByTestId('update-chip')).toHaveText('Update pending')
await expect(page.locator('[data-testid="addon-tile"][data-app="plain"]').getByTestId('update-chip')).toHaveCount(0)
await page.goto('/app/addons?manage=notes')
await page.getByTestId('addon-manage').getByRole('button', { name: 'Upgrade' }).click()
// The confirmation names the version and digest the owner resolved and
// nothing is sent until it is confirmed.
@@ -419,97 +475,79 @@ test.describe('Add-ons: installed', () => {
expect(sent.confirm).toBe(sent.operationId)
expect(state.log.dispatch.filter(d => d.action === 'upgrade')).toHaveLength(0)
// An add-on without the field says the status is unknown and has no Upgrade.
await page.goto('/app/addons?tab=installed&manage=plain')
await page.goto('/app/addons?manage=plain')
const sheet = page.getByTestId('addon-manage')
await expect(sheet.getByText('The update status is not known')).toBeVisible()
await expect(sheet.getByRole('button', { name: 'Upgrade' })).toHaveCount(0)
})
test('installed: uninstalling warns that data is kept before anything is submitted', async ({ page }) => {
test('installed: removing warns that data is kept before anything is submitted', async ({ page }) => {
await signIn(page, ADMIN)
const state = await mockAddons(page)
await page.goto('/app/addons?tab=installed&manage=notes')
await page.getByTestId('addon-manage').getByRole('button', { name: 'Uninstall' }).click()
const state = await mockAddons(page, details('notes', {}, { title: 'Notes' }))
await page.goto('/app/addons?detail=notes')
await page.getByTestId('addon-detail').getByRole('button', { name: 'Remove' }).click()
const dialog = page.getByRole('alertdialog')
await expect(dialog).toContainText('data, identity and grants are kept')
await expect(dialog).toContainText('Remove Notes?')
await expect(dialog).toContainText('Its data is kept')
expect(state.log.dispatch.filter(d => d.action === 'uninstall')).toHaveLength(0)
await dialog.getByRole('button', { name: 'Cancel' }).click()
expect(state.log.dispatch.filter(d => d.action === 'uninstall')).toHaveLength(0)
await page.getByTestId('addon-manage').getByRole('button', { name: 'Uninstall' }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Uninstall' }).click()
await page.getByTestId('addon-detail').getByRole('button', { name: 'Remove' }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Remove' }).click()
await expect.poll(() => state.log.dispatch.filter(d => d.action === 'uninstall').length).toBe(1)
})
test('installed: Escape cancels the uninstall dialog only, then closes the sheet, and focus returns', async ({ page }) => {
test('installed: Escape cancels the remove dialog only, then closes the pane, and focus returns to the card', async ({ page }) => {
await signIn(page, ADMIN)
await mockAddons(page)
await page.goto('/app/addons?tab=installed')
const manage = page.locator('[data-addon="notes"]').getByRole('button', { name: 'Details' })
await manage.click()
const sheet = page.getByTestId('addon-manage')
await expect(sheet).toBeVisible()
const uninstall = sheet.getByRole('button', { name: 'Uninstall' })
await uninstall.focus()
await mockAddons(page, details('notes', {}, { title: 'Notes' }))
await page.goto('/app/addons')
const card = page.locator('[data-testid="addon-tile"][data-app="notes"] .ad-tile__open')
await card.click()
const pane = page.getByTestId('addon-detail')
await expect(pane).toBeVisible()
const remove = pane.getByRole('button', { name: 'Remove' })
await remove.focus()
await page.keyboard.press('Enter')
const dialog = page.getByRole('alertdialog')
await expect(dialog).toBeVisible()
await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(sheet).toBeVisible()
await expect(uninstall).toBeFocused()
await expect(pane).toBeVisible()
await expect(remove).toBeFocused()
await page.keyboard.press('Escape')
await expect(sheet).toHaveCount(0)
await expect(manage).toBeFocused()
})
test('installed: an unknown enable blocks other actions on the add-on until it is read', async ({ page }) => {
test.setTimeout(90_000)
await signIn(page, ADMIN)
let phase = 'prepared'
const state = await mockAddons(page, {
installed: [installedAddon({ enabled: false, state: 'stopped' })],
dispatch: (body, st) => {
if (body.action === 'enable' && st.log.dispatch.filter(d => d.action === 'enable').length === 1) {
return fail(503, 'outcome_unknown', { operationId: body.operationId })
}
if (body.action === 'operation-status') return ok({ OperationID: body.operationId, AddonID: body.addonId, Phase: phase, Generation: 3 })
return null
},
})
await page.goto('/app/addons?tab=installed')
const row = page.locator('[data-addon="notes"]')
await row.getByRole('button', { name: 'Enable' }).click()
const notice = row.getByTestId('unknown-outcome')
await expect(notice).toBeVisible({ timeout: 40_000 })
await expect(row.getByRole('button', { name: 'Enable' })).toBeDisabled()
phase = 'failed'
await notice.getByRole('button', { name: 'Check status' }).click()
await expect(notice).toHaveCount(0)
await expect(row.getByRole('alert')).toContainText('did not complete')
// The failed operation is over: a new click is a new operation.
await row.getByRole('button', { name: 'Enable' }).click()
await expect.poll(() => state.log.dispatch.filter(d => d.action === 'enable').length).toBe(2)
const ids = state.log.dispatch.filter(d => d.action === 'enable').map(d => d.operationId)
expect(ids[1]).not.toBe(ids[0])
await expect(pane).toHaveCount(0)
await expect(card).toBeFocused()
})
test('installed: a headless add-on keeps its details, health and tools', async ({ page }) => {
await signIn(page, ADMIN)
await mockAddons(page, { installed: [installedAddon({ id: 'indexer', title: 'indexer', hasUI: false, mainPage: undefined, icon: undefined, tools: ['query', 'export'], toolCount: 2, state: 'running' })] })
await page.goto('/app/addons?tab=installed&manage=indexer')
await mockAddons(page, details('indexer', { hasUI: false, mainPage: undefined, icon: undefined, tools: ['query', 'export'], toolCount: 2, state: 'running' }, { hasUI: false, mainPage: undefined, icon: undefined, tools: ['query', 'export'], toolCount: 2, state: 'running', title: 'indexer' }))
await page.goto('/app/addons?manage=indexer')
const sheet = page.getByTestId('addon-manage')
await expect(sheet.getByText('Headless, no pages')).toBeVisible()
await expect(sheet.getByText('2: query, export')).toBeVisible()
await expect(sheet.getByRole('img', { name: 'State: Running' })).toBeVisible()
await expect(sheet.getByRole('img', { name: 'State: On' })).toBeVisible()
await expect(sheet.getByRole('link', { name: 'Open' })).toHaveCount(0)
})
test('installed: a refusal from the server is shown even though the tab was offered', async ({ page }) => {
test('installed: a refusal of the installed list leaves the pane without Details', async ({ page }) => {
await signIn(page, ADMIN)
await mockAddons(page, { dispatch: (body) => (body.action === 'addons-installed' ? fail(403, 'addon_error') : null) })
await mockAddons(page, { ...details('notes', {}, { title: 'Notes' }), dispatch: (body) => (body.action === 'addons-installed' ? fail(403, 'addon_error') : null) })
await page.goto('/app/addons?detail=notes')
await expect(page.getByTestId('addon-detail').getByRole('heading', { name: 'Notes' })).toBeVisible()
await expect(page.getByTestId('preview-source')).toHaveText('Not known')
await expect(page.getByTestId('addon-more')).toHaveCount(0)
})
test('installed: an old Installed address lands on My add-ons and the management link opens the details', async ({ page }) => {
await signIn(page, ADMIN)
await mockAddons(page, details('notes', {}, { title: 'Notes' }))
await page.goto('/app/addons?tab=installed')
await expect(page.getByRole('alert').filter({ hasText: 'The server refused this' })).toBeVisible()
await expect(page.locator('[data-addon]')).toHaveCount(0)
await expect(tab(page, 'My add-ons')).toHaveAttribute('aria-selected', 'true')
await expect(page.getByRole('tab', { name: 'Installed' })).toHaveCount(0)
await expect(page.getByTestId('addon-tile')).toHaveCount(1)
await page.goto('/app/addons?manage=notes')
await expect(page.getByTestId('addon-more')).toHaveAttribute('open', '')
})
})
@@ -551,8 +589,8 @@ test.describe('Add-ons: libraries', () => {
// A removal goes through the library route, the same one the other kinds use.
const removal = state.log.library.find(c => c.method === 'DELETE')
expect(removal.id).toBe('main')
await tab(page, 'Installed').click()
await expect(page.locator('[data-addon="notes"]')).toBeVisible()
await tab(page, 'My add-ons').click()
await expect(page.locator('[data-testid="addon-tile"][data-app="notes"]')).toBeVisible()
expect(state.installed).toHaveLength(1)
})
@@ -579,20 +617,20 @@ test.describe('Add-ons: access, keyboard, layout and language', () => {
await signIn(page, ADMIN)
const state = await mockAddons(page, { apps: [app({ admin })] })
await page.goto('/app/addons')
await tab(page, 'Apps').focus()
await tab(page, 'My add-ons').focus()
await page.keyboard.press('ArrowRight')
await expect(tab(page, 'Browse')).toBeFocused()
await expect(tab(page, 'Browse')).toHaveAttribute('aria-selected', 'true')
await expect(tab(page, 'Find add-ons')).toBeFocused()
await expect(tab(page, 'Find add-ons')).toHaveAttribute('aria-selected', 'true')
await expect(page).toHaveURL(/tab=browse/)
await page.keyboard.press('End')
await expect(tab(page, 'Libraries')).toHaveAttribute('aria-selected', 'true')
await expect(tab(page, 'Sources')).toHaveAttribute('aria-selected', 'true')
await page.keyboard.press('Home')
await expect(tab(page, 'Apps')).toHaveAttribute('aria-selected', 'true')
await expect(tab(page, 'My add-ons')).toHaveAttribute('aria-selected', 'true')
// The selected tab is the only one in the tab order.
await expect(tab(page, 'Browse')).toHaveAttribute('tabindex', '-1')
await expect(tab(page, 'Find add-ons')).toHaveAttribute('tabindex', '-1')
// Stop is reachable and operable with the keyboard alone, in the detail sheet.
await page.goto('/app/addons?detail=notes')
const stop = detail(page, 'notes').getByRole('button', { name: 'Stop' })
const stop = detail(page, 'notes').getByRole('button', { name: 'Turn off' })
await stop.focus()
await expect(stop).toBeFocused()
await page.keyboard.press('Enter')
@@ -610,7 +648,7 @@ test.describe('Add-ons: access, keyboard, layout and language', () => {
const lefts = await page.locator('.ad-tile').evaluateAll(cards => cards.map(c => Math.round(c.getBoundingClientRect().left)))
expect(new Set(lefts).size).toBe(1)
await shoot(page, 'apps-narrow')
await page.goto('/app/addons?tab=installed&manage=notes')
await page.goto('/app/addons?manage=notes')
await expect(page.getByTestId('addon-manage')).toBeVisible()
const sheetOverflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(sheetOverflow).toBeLessThanOrEqual(0)
@@ -623,7 +661,7 @@ test.describe('Add-ons: access, keyboard, layout and language', () => {
await page.addInitScript(() => localStorage.setItem('localai-theme', 'light'))
await page.goto('/app/addons')
await page.goto('/app/addons?detail=notes')
const read = () => page.locator('.ad-card').first().evaluate(el => {
const read = () => page.locator('.ad-pane').first().evaluate(el => {
const css = getComputedStyle(el)
const chip = getComputedStyle(el.querySelector('.ad-chip'))
return { card: css.backgroundColor, chip: chip.color, chipBg: chip.backgroundColor }
@@ -643,10 +681,10 @@ test.describe('Add-ons: access, keyboard, layout and language', () => {
await mockAddons(page)
await page.addInitScript(() => localStorage.setItem('localai-language', 'it'))
await page.goto('/app/addons')
await expect(page.getByRole('tab', { name: 'Sfoglia' })).toBeVisible()
await expect(page.getByRole('tab', { name: 'Librerie' })).toBeVisible()
await expect(page.getByRole('tab', { name: 'Trova componenti' })).toBeVisible()
await expect(page.getByRole('tab', { name: 'Fonti' })).toBeVisible()
await page.goto('/app/addons?detail=notes')
await expect(detail(page, 'notes').getByRole('img', { name: 'Stato: In esecuzione' })).toBeVisible()
await expect(detail(page, 'notes').getByRole('img', { name: 'Stato: Attivo' })).toBeVisible()
})
test('hub: under the legacy switch the Add-ons tab appears only for an account that holds the feature', async ({ page }) => {
@@ -12,7 +12,7 @@ import { randomUUID } from 'node:crypto'
const creds = JSON.parse(fs.readFileSync(process.env.ADDONS_FIXTURE, 'utf8'))
const BASE = process.env.ADDONS_UI_URL
const shots = process.env.ADDONS_SHOTS
const MANAGE = '/app/addons?tab=installed&manage=tables'
const MANAGE = '/app/addons?manage=tables'
async function shoot(page, name, target = 'recovery-panel') {
if (!shots) return
@@ -68,11 +68,11 @@ async function openManage(page) {
await expect(page.getByTestId('recovery-panel')).toBeVisible({ timeout: 25_000 })
}
// The sheet's own enable and disable buttons: the real lifecycle.
// The pane's own Turn on and Turn off buttons: the real lifecycle.
async function setEnabled(page, enabled) {
const sheet = page.getByTestId('addon-manage')
await sheet.getByRole('button', { name: enabled ? 'Enable' : 'Disable' }).click()
await expect(sheet.getByRole('button', { name: enabled ? 'Disable' : 'Enable' })).toBeVisible({ timeout: 30_000 })
const sheet = page.getByTestId('addon-detail')
await sheet.getByRole('button', { name: enabled ? 'Turn on' : 'Turn off' }).click()
await expect(sheet.getByRole('button', { name: enabled ? 'Turn off' : 'Turn on' })).toBeVisible({ timeout: 30_000 })
}
test.describe.configure({ mode: 'serial' })
@@ -154,7 +154,7 @@ test.describe('Add-on data recovery through the real owner', () => {
await expect(dialog).toBeVisible()
await expect(dialog).toContainText('Changes made after the backup are lost')
await expect(dialog).toContainText('nobody gets back access that was taken away')
await expect(dialog).toContainText('Stop the add-on first')
await expect(dialog).toContainText('Turn the add-on off first')
await shoot(admin.page, 'recovery-restore-running-1280', 'recovery-restore-dialog')
// Escape closes the dialog and gives the focus back.
await admin.page.keyboard.press('Escape')
@@ -237,7 +237,7 @@ test.describe('Add-on data recovery through the real owner', () => {
await admin.page.getByTestId('recovery-purge').click()
const dialog = admin.page.getByTestId('recovery-purge-dialog')
await expect(dialog).toBeVisible()
await expect(dialog).toContainText('Stop the add-on first')
await expect(dialog).toContainText('Turn the add-on off first')
await admin.page.keyboard.press('Escape')
await setEnabled(admin.page, false)
await admin.page.getByTestId('recovery-purge').click()
@@ -83,6 +83,14 @@ test.describe('Add-on shell', () => {
expect(requested).toEqual([])
})
test('an add-on that is not listed is titled with its name, not the raw identifier', async ({ page }) => {
await serve(page)
await mockAddons(page, { apps: [uiApp()] })
await page.goto('/app/addons/voice/home')
await expect(page.getByTestId('addon-unavailable')).toBeVisible()
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Voice')
})
test('a withdrawn add-on is unmounted on the next list and Reload brings it back', async ({ page }) => {
await serve(page)
const state = await mockAddons(page, { apps: [uiApp()] })
@@ -13,7 +13,7 @@ import { randomUUID } from 'node:crypto'
const creds = JSON.parse(fs.readFileSync(process.env.ADDONS_FIXTURE, 'utf8'))
const BASE = process.env.ADDONS_UI_URL
const shots = process.env.ADDONS_SHOTS
const MANAGE = '/app/addons?tab=installed&manage=tables'
const MANAGE = '/app/addons?manage=tables'
async function shoot(page, name, target, { settle = 350 } = {}) {
if (!shots) return
@@ -128,8 +128,8 @@ test.describe('Add-on updates and the explicit upgrade through the real owner',
await expect(admin.page.getByTestId('update-upgrade')).toBeVisible()
await shoot(admin.page, 'updates-available-1280', 'manage-updates')
// The installed list carries the badge.
await admin.page.goto('/app/addons?tab=installed')
await expect(admin.page.locator('[data-addon="tables"]').getByText('Update 0.1.0-dev')).toBeVisible({ timeout: 25_000 })
await admin.page.goto('/app/addons')
await expect(admin.page.locator('[data-testid="addon-tile"][data-app="tables"]').getByTestId('update-chip')).toBeVisible({ timeout: 25_000 })
await shoot(admin.page, 'updates-badge-1280')
await admin.context.close()
@@ -138,7 +138,7 @@ test.describe('Add-on updates and the explicit upgrade through the real owner',
await state.memberPage.goto('/app/addons')
const card = state.memberPage.locator('[data-app="tables"]')
await expect(card.getByTestId('update-chip')).toBeVisible({ timeout: 25_000 })
await expect(card.getByRole('img', { name: 'State: Running' })).toBeVisible()
await expect(card.locator('.ad-tile__state')).toHaveText('On')
await shoot(state.memberPage, 'updates-pending-chip-1280', 'update-chip')
const read = await pageFetch(state.memberPage, '/api/addons/tables/update')
expect(read.status).toBe(200)
@@ -308,8 +308,9 @@ test.describe('Add-on updates and the explicit upgrade through the real owner',
await expect(admin.page.getByTestId('update-times')).toContainText('Library last read')
await expect(admin.page.getByTestId('update-upgrade')).toHaveCount(0)
await shoot(admin.page, 'updates-check-failed-1280', 'manage-updates')
await admin.page.goto('/app/addons?tab=installed')
await expect(admin.page.locator('[data-addon="tables"]').getByText('Update check failed')).toBeVisible({ timeout: 25_000 })
await admin.page.goto('/app/addons')
await expect(admin.page.locator('[data-testid="addon-tile"][data-app="tables"]')).toBeVisible({ timeout: 25_000 })
await expect(admin.page.getByTestId('update-chip')).toHaveCount(0)
await admin.context.close()
// The member's list shows no mark for a check that failed.
await state.memberPage.goto('/app/addons')
+28 -2
View File
@@ -2,7 +2,7 @@ import { test, expect } from './coverage-fixtures.js'
import { chatOf, pair, mockChat, answerWith, CHATS_KEY } from './chat-fixtures.js'
import { MEMBER, app, sessionFor, mockAddons } from './addons-fixtures.js'
// The add-ons control in the chat header lists the add-ons that ship tools,
// The add-ons control in the chat composer, next to the MCP control, lists the add-ons that ship tools,
// one switch each, and shows how many are on. The /addons command of the
// composer flips all of them and stays in step with it.
@@ -57,6 +57,32 @@ test.describe('Chat add-ons control', () => {
await expect(page.getByTestId('chat-addons-menu')).toHaveCount(0)
})
test('sits right after the MCP control in the composer, not in the chat header', async ({ page }) => {
await setup(page, [NOTES])
await openChat(page, [chatOf('c1', 'Place', 'qwen3-8b', pair('hi', 'hello'))])
await expect(button(page)).toBeVisible()
await expect(page.getByTestId('chat-header').getByTestId('chat-addons')).toHaveCount(0)
const beside = await page.getByTestId('chat-addons').evaluate(el => {
const before = el.previousElementSibling
return Boolean(before && before.classList.contains('chat-mcp-dropdown') && before.textContent.includes('MCP') && el.parentElement === before.parentElement)
})
expect(beside).toBe(true)
})
test('says in one line that an add-on that is off does nothing in this chat', async ({ page }) => {
await setup(page, [NOTES, INDEXER, VIEWER])
await openChat(page, [chatOf('c1', 'Line', 'qwen3-8b', pair('hi', 'hello'))])
await button(page).click()
const menu = page.getByTestId('chat-addons-menu')
const note = menu.getByTestId('chat-addons-note')
await expect(note).toHaveText('An add-on that is off here does nothing in this chat.')
// One line: a single text node paragraph, tied to the control by its description.
await expect(button(page)).toHaveAttribute('aria-describedby', await note.getAttribute('id'))
// Only the add-ons that ship tools are listed under it.
await expect(items(page)).toHaveCount(2)
await expect(menu).not.toContainText('Viewer')
})
test('shows nothing when no add-on ships tools', async ({ page }) => {
await setup(page, [VIEWER, STOPPED])
await openChat(page, [chatOf('c1', 'None', 'qwen3-8b', pair('hi', 'hello'))])
@@ -103,7 +129,7 @@ test.describe('Chat add-ons control', () => {
await expect(page.getByTestId('chat-addons-count')).toHaveCount(0)
})
test('/addons flips all add-ons and the header control shows it, in step with the switches', async ({ page }) => {
test('/addons flips all add-ons and the composer control shows it, in step with the switches', async ({ page }) => {
await setup(page, [NOTES, INDEXER])
await openChat(page, [chatOf('c1', 'Slash', 'qwen3-8b', pair('hi', 'hello'))])
await input(page).fill('/addons')
+1 -1
View File
@@ -20,7 +20,7 @@ test.describe('Navigation', () => {
await expect(models.locator('.nav-label')).toHaveText('Models')
})
test('the sidebar has Chat, Models and Apps, no Settings and no Create tier', async ({ page }) => {
test('the sidebar has Chat, Models and Add-ons, no Settings and no Create tier', async ({ page }) => {
await page.goto('/app')
await expect(page.locator('.sidebar-section-title', { hasText: 'Create' })).toHaveCount(0)
for (const href of ['/app/chat', '/app/models', '/app/addons']) {
+1 -1
View File
@@ -9,7 +9,7 @@ test.describe('404', () => {
await expect(nf).toContainText('/app/no-such-page')
await expect(nf.getByRole('link', { name: 'Go home' })).toHaveAttribute('href', '/app')
const places = nf.getByRole('navigation', { name: 'Where to go' })
for (const name of ['Home', 'Models', 'Chat', 'Apps', 'Build', 'Operate']) {
for (const name of ['Home', 'Models', 'Chat', 'Add-ons', 'Build', 'Operate']) {
await expect(places.getByRole('link', { name })).toBeVisible()
}
// Settings is a tab of the Operate hub, so the places do not list it.
+3 -3
View File
@@ -34,9 +34,9 @@ test.describe('Settings: groups and search', () => {
seen.add(key)
}
}
// 54 settings plus the three branding images.
expect(seen.size).toBe(57)
await expect(page.getByLabel(/Search all 57 settings/)).toBeVisible()
// 55 settings plus the three branding images.
expect(seen.size).toBe(58)
await expect(page.getByLabel(/Search all 58 settings/)).toBeVisible()
})
test('a group lists its settings, and the memory group shows memory use now', async ({ page }) => {
@@ -39,6 +39,25 @@ test.describe('Studio add-on', () => {
await expect(page.locator('.studio-front').getByText(/studio\.[a-z]+\.[A-Za-z.]+/)).toHaveCount(0)
})
test('/studio/3d opens the 3D tab, and a generator tab has the one page title', async ({ page }) => {
await mockStudio(page)
await page.goto('/app/addons/studio/studio/3d')
await expect(page.locator('.studio-tab-active[data-tab="threed"]')).toBeVisible({ timeout: 15_000 })
await expect(page.locator('h1')).toHaveCount(1)
await expect(page.locator('h1.page-title')).toHaveText('Studio')
// The card of the generator is a section under that title.
await expect(page.getByTestId('ws-compose').getByRole('heading', { level: 2 })).toBeVisible()
})
test('the tab strip stays on one row at 1280 wide', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 })
await mockStudio(page)
await page.goto('/app/addons/studio/studio/tts')
await expect(page.locator('.studio-tab-active[data-tab="tts"]')).toBeVisible({ timeout: 15_000 })
const tops = await page.locator('.studio-tab').evaluateAll(tabs => [...new Set(tabs.map(el => Math.round(el.getBoundingClientRect().top)))])
expect(tops).toHaveLength(1)
})
test('a model id with a slash and a colon travels as ?model= and reaches the page', async ({ page }) => {
await mockStudio(page)
await page.goto('/app/studio/images/sd-1.5-lcm?size=768x768')
@@ -7,6 +7,6 @@ test.describe('Studio - Transform', () => {
test('Studio exposes a Transform tab that renders Audio Transform', async ({ page }) => {
await page.goto('/app/studio/transform')
await expect(page.locator('.studio-tab', { hasText: 'Transform' })).toBeVisible()
await expect(page.locator('h1.page-title', { hasText: 'Audio Transform' })).toBeVisible({ timeout: 15_000 })
await expect(page.locator('h2.ws-title', { hasText: 'Audio Transform' })).toBeVisible({ timeout: 15_000 })
})
})
@@ -33,7 +33,9 @@ test.describe('Workspace frame (Images)', () => {
test('the compose card has the model chip, the essentials, an estimate and one action', async ({ page }) => {
await setup(page)
await page.goto('/app/studio/images')
await expect(compose(page).getByRole('heading', { level: 1 })).toContainText('Image')
// The page title is Studio's own h1 above the tabs; the card heading is a section.
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Studio')
await expect(compose(page).getByRole('heading', { level: 2 })).toContainText('Image')
await expect(compose(page).getByRole('button', { name: 'flux.1-schnell' })).toBeVisible()
await expect(page.locator('[data-testid="ws-size"]')).toHaveValue('512x512')
await expect(page.locator('[data-testid="ws-count"]')).toHaveValue('1')
+3 -4
View File
@@ -919,10 +919,9 @@ test.describe('Tables UI', () => {
await page.getByTestId('tbl-name-dialog').getByLabel('Name').fill('Local notes')
await page.getByTestId('tbl-name-dialog').getByRole('button', { name: 'Create database' }).click()
await expect(page.getByTestId('tbl-db-name')).toHaveText('Local notes', { timeout: 25_000 })
await page.getByRole('button', { name: 'Share' }).click()
const off = page.getByTestId('tbl-sharing-disabled')
await expect(off).toBeVisible({ timeout: 25_000 })
await expect(off).toContainText('one local owner')
// Nobody to share with, so the page does not offer the button at all.
await expect(page.getByRole('button', { name: 'Rename' })).toBeVisible({ timeout: 25_000 })
await expect(page.getByRole('button', { name: 'Share' })).toHaveCount(0)
await shoot(page, 'tables-ui-local-sharing')
// No grant or picker request is ever made, and the host says the same.
expect(requests.filter(r => /\/grants|\/users/.test(r))).toEqual([])
+1 -1
View File
@@ -1 +1 @@
182
174
+107 -43
View File
@@ -37,13 +37,11 @@
"emptyTitle": "Noch keine Apps",
"firstRun": {
"browse": "Browse add-ons",
"browseBody": "Pick an add-on from a library and install it.",
"browseTitle": "Browse and install.",
"libraryBody": "The official library is registered by default. You can add more under Libraries.",
"libraryTitle": "Choose a library.",
"startBody": "Start an installed add-on from its tile. Add-ons that were running start again with the server.",
"startTitle": "Start it.",
"title": "Welcome to Apps"
"title": "Welcome to Apps",
"lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.",
"featuredLabel": "Featured add-ons",
"featuredFailed": "The list of add-ons could not be read.",
"noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons."
},
"headless": "Läuft im Hintergrund und hat keine eigene Seite.",
"listLabel": "Apps",
@@ -51,27 +49,26 @@
"manage": "Verwalten",
"open": "Öffnen",
"stale": "Die Liste konnte nicht aktualisiert werden. Die Anzeige ist möglicherweise veraltet.",
"start": "Starten",
"stop": "Stoppen",
"start": "Einschalten",
"stop": "Ausschalten",
"toolsMore_one": "und {{count}} weiteres",
"toolsMore_other": "und {{count}} weitere",
"tools_one": "{{count}} Werkzeug",
"tools_other": "{{count}} Werkzeuge",
"version": "Version {{version}}",
"working": "Warte auf die Bestätigung des Betreibers."
"working": "Warte auf die Bestätigung des Betreibers.",
"noMatchTitle": "No add-on matches",
"noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.",
"clearSearch": "Clear search"
},
"browse": {
"confirmBody": "Version {{version}} aus der Bibliothek {{library}} wird heruntergeladen und geprüft, bevor sie installiert wird.",
"confirmNotEnabled": "Die Installation startet das Add-on nicht. Du aktivierst es in einem eigenen Schritt.",
"confirmTitle": "{{name}} installieren?",
"details": "Details",
"detailsLead": "Bibliothekseintrag für {{id}}",
"emptyBody": "Füge im Tab Bibliotheken eine Bibliothek hinzu und aktualisiere sie.",
"emptyTitle": "Keine Add-ons gefunden",
"fromLibrary": "aus {{library}}",
"install": "Installieren",
"installedDetail": "Version {{version}}, Generation {{generation}}",
"installedNotEnabled": "Installiert. Es ist nicht aktiviert: Aktiviere es im Tab Installiert, wenn du bereit bist.",
"installedOther": "Installiert {{version}} (andere Version)",
"installedSame": "Installiert {{version}}",
"installedState": "Installiert",
@@ -87,7 +84,8 @@
"confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.",
"backendFailed": "The backend was not installed.",
"alias": "Alias",
"kindLabel": "Kind"
"kindLabel": "Kind",
"installedOn": "{{name}} is installed and on."
},
"catalog": {
"allLibraries": "Alle Bibliotheken",
@@ -117,13 +115,13 @@
"pin": "Pin {{name}}",
"pinLabel": "Pin",
"pinned": "Pinned",
"running": "Running",
"running": "An",
"search": "Search add-ons",
"status": {
"failed": "failed",
"starting": "starting"
},
"stopped": "Stopped",
"stopped": "Aus",
"unpin": "Unpin {{name}}"
},
"errors": {
@@ -135,21 +133,9 @@
"unknownOutcome": "Das Ergebnis konnte nicht bestätigt werden. Prüfe den Status, bevor du es erneut versuchst."
},
"installed": {
"disable": "Deaktivieren",
"disabled": "Deaktiviert",
"emptyBody": "Installiere ein Add-on über den Tab Durchsuchen.",
"emptyTitle": "Nichts ist installiert",
"enable": "Aktivieren",
"enabled": "Aktiviert",
"generation": "Generation {{generation}}",
"listLabel": "Installierte Add-ons",
"loading": "Installierte Add-ons werden geladen",
"manage": "Details",
"manageLead": "Verwaltungsansicht von {{id}}",
"missing": "{{id}} ist nicht installiert.",
"uninstall": "Deinstallieren",
"uninstallBody": "Das Add-on wird gestoppt und sein Paket nicht mehr verwendet. Seine Daten, Identität und Berechtigungen bleiben erhalten, eine erneute Installation übernimmt sie. Es wird nichts gelöscht.",
"uninstallTitle": "{{name}} deinstallieren?"
"disabled": "Aus",
"enabled": "An",
"missing": "{{id}} ist nicht installiert."
},
"libraries": {
"add": "Bibliothek hinzufügen",
@@ -169,7 +155,8 @@
"urlCredentials": "Die Adresse darf keinen Benutzernamen und kein Passwort enthalten.",
"urlQuery": "Die Adresse darf keinen Query-String und kein Fragment enthalten.",
"either": "Use the exact identity or the pattern, not both.",
"urlEmpty": "Enter the address of the index."
"urlEmpty": "Enter the address of the index.",
"mirrors": "Each mirror must start with https://, http://, github:, oci:// or file://, and at most 8 are allowed."
},
"fields": {
"enabled": "Aktiviert",
@@ -182,14 +169,16 @@
"url": "Katalog-URL",
"identityRegex": "Signer identity pattern (optional)",
"kind": "Kind",
"format": "Index format"
"format": "Index format",
"mirrors": "Mirrors"
},
"hints": {
"id": "Kleinbuchstaben, Ziffern und Bindestriche. Kann später nicht geändert werden.",
"reference": "Der Name einer Zugangsdaten-Konfiguration des Betreibers. Füge hier nie ein Token oder Passwort ein.",
"url": "Eine https-Adresse ohne Zugangsdaten und ohne Query-String.",
"identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.",
"format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it."
"format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.",
"mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched."
},
"lastRefresh": "Aktualisiert {{when}}, {{count}} Einträge",
"lead": "Eine Bibliothek ist ein Katalogdokument, das Add-ons auflistet. Zugangsdaten werden hier nie eingetippt: Eine private Bibliothek nennt eine Zugangsdaten-Referenz, die der Betreiber eingerichtet hat.",
@@ -241,7 +230,13 @@
"turnOff": "Turn off",
"turnOn": "Turn on",
"turnedOff": "Turned off {{name}}.",
"turnedOn": "Turned on {{name}}."
"turnedOn": "Turned on {{name}}.",
"manage": "Manage libraries",
"manageTitle": {
"backend": "Backend libraries",
"model": "Model libraries",
"addon": "Add-on libraries"
}
},
"manage": {
"api": "Add-on-API",
@@ -322,7 +317,18 @@
},
"layer": {
"close": "Schließen"
}
},
"off": {
"title": "{{name}} is off",
"body": "Turn it on to use its page.",
"reader": "An administrator can turn it on."
},
"notInstalled": {
"title": "{{name}} is not installed",
"body": "Find it in Find add-ons, then install and turn it on.",
"reader": "An administrator can install it."
},
"failedTitle": "{{name}} failed to start"
},
"slots": {
"unavailable": "Nicht verfügbar",
@@ -332,18 +338,17 @@
"state": {
"failed": "Fehlgeschlagen",
"label": "Zustand: {{state}}",
"running": "Läuft",
"running": "An",
"starting": "Startet",
"stopped": "Gestoppt",
"stopped": "Aus",
"update-pending": "Update ausstehend"
},
"supporting": "Apps, die dein Betreiber für dich ausführt, und die Bibliotheken, aus denen sie stammen.",
"tabs": {
"apps": "Apps",
"browse": "Durchsuchen",
"installed": "Installiert",
"apps": "Meine Add-ons",
"browse": "Add-ons finden",
"label": "Bereiche der Add-ons",
"libraries": "Bibliotheken"
"libraries": "Quellen"
},
"title": "Add-ons",
"trust": {
@@ -588,5 +593,64 @@
"network": "Das Netzwerk ist nicht erreichbar.",
"unavailable": "Der Add-on-Eigentümer ist nicht verfügbar. Bitte erneut versuchen."
}
},
"install": {
"stepsLabel": "Install steps",
"step": {
"resolving": "Resolve the library entry",
"verifying": "Verify the signature",
"size": "Check the image size",
"fetching": "Fetch the image",
"writing": "Write the metadata"
},
"state": {
"pending": "Waiting",
"active": "Running",
"done": "Done",
"failed": "Failed"
}
},
"settingsView": {
"loadFailed": "The library sources could not be read.",
"none": "No library sources of this kind.",
"listLabel": "Library sources",
"mirrors": "Mirrors: {{list}}"
},
"card": {
"showInSidebar": "In der Seitenleiste zeigen",
"hideFromSidebar": "Remove from sidebar",
"details": "Details",
"remove": "Entfernen",
"menu": "Actions for {{name}}",
"addsPage": "Fügt eine Seite hinzu",
"chatTools_one": "{{count}} chat tool",
"chatTools_other": "{{count}} chat tools",
"openNamed": "Open {{name}}",
"removeTitle": "Remove {{name}}?",
"removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted."
},
"preview": {
"screenshots": "Screenshots",
"adds": "What it adds",
"page": "A page of its own in LocalAI",
"addsNothing": "Nothing you can see. It works in the background.",
"version": "Version",
"id": "Identifier",
"sourceUnknown": "Not known",
"moreDetails": "Health, trust and updates"
},
"flow": {
"installAndTurnOn": "Installieren und einschalten",
"installing": "Installing {{name}}...",
"turningOn": "Turning on {{name}}...",
"installFailed": "{{name}} was not installed.",
"turnOnFailed": "{{name}} is installed but did not turn on.",
"confirmTitle": "Install and turn on {{name}}?",
"confirmBody": "Version {{version}} from the source {{library}} is downloaded and verified, installed, and turned on.",
"step": {
"install": "Download, verify and install",
"turn-on": "Turn on"
},
"seeInList": "See it in My add-ons"
}
}
@@ -122,5 +122,13 @@
},
"message": {
"you": "You"
},
"addons": {
"note": "An add-on that is off here does nothing in this chat.",
"label": "Add-ons",
"title": "Add-on tools for this chat",
"heading": "Add-on tools",
"selectAll": "Select all",
"deselectAll": "Deselect all"
}
}
@@ -61,7 +61,12 @@
"thisMachine": "Dieser Rechner",
"failover": "Failover",
"p2p": "P2P",
"addons": "Add-ons"
"addons": "Add-ons",
"voiceLibrary": "Voice Library",
"logs": "Logs",
"trafficModels": "Models",
"gpuHost": "GPU and host",
"prometheus": "Prometheus"
},
"footer": {
"github": "GitHub",
@@ -71,7 +76,9 @@
},
"console": {
"automation": "Automatisierung",
"training": "Training"
"training": "Training",
"expandNavigation": "Expand {{section}} navigation",
"collapseNavigation": "Collapse {{section}} navigation"
},
"hub": {
"status": "Status",
@@ -33,50 +33,47 @@
}
},
"apps": {
"emptyBody": "When an administrator installs and enables an add-on, it appears here.",
"emptyTitle": "No apps yet",
"emptyBody": "When an administrator installs and turns on an add-on, it appears here.",
"emptyTitle": "No add-ons yet",
"firstRun": {
"browse": "Browse add-ons",
"browseBody": "Pick an add-on from a library and install it.",
"browseTitle": "Browse and install.",
"libraryBody": "The official library is registered by default. You can add more under Libraries.",
"libraryTitle": "Choose a library.",
"startBody": "Start an installed add-on from its tile. Add-ons that were running start again with the server.",
"startTitle": "Start it.",
"title": "Welcome to Apps"
"browse": "Find add-ons",
"title": "Add your first add-on",
"lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.",
"featuredLabel": "Featured add-ons",
"featuredFailed": "The list of add-ons could not be read.",
"noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons."
},
"headless": "Runs in the background and has no page of its own.",
"listLabel": "Apps",
"loading": "Loading apps",
"listLabel": "My add-ons",
"loading": "Loading add-ons",
"manage": "Manage",
"open": "Open",
"stale": "The list could not be refreshed. What you see may be out of date.",
"start": "Start",
"stop": "Stop",
"start": "Turn on",
"stop": "Turn off",
"toolsMore_one": "and {{count}} more",
"toolsMore_other": "and {{count}} more",
"tools_one": "{{count}} tool",
"tools_other": "{{count}} tools",
"version": "Version {{version}}",
"working": "Waiting for the owner to confirm."
"working": "Waiting for the owner to confirm.",
"noMatchTitle": "No add-on matches",
"noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.",
"clearSearch": "Clear search"
},
"browse": {
"confirmBody": "Version {{version}} from the library {{library}} will be downloaded and verified before it is installed.",
"confirmNotEnabled": "Installing does not start the add-on. You enable it as a separate step.",
"confirmTitle": "Install {{name}}?",
"details": "Details",
"detailsLead": "Library entry for {{id}}",
"emptyBody": "Add a library on the Libraries tab, then refresh it.",
"emptyBody": "Add a source on the Sources tab, then refresh it.",
"emptyTitle": "No library entries found",
"fromLibrary": "from {{library}}",
"install": "Install",
"installedDetail": "Version {{version}}, generation {{generation}}",
"installedNotEnabled": "Installed. It is not enabled: enable it on the Installed tab when you are ready.",
"installedOther": "Installed {{version}} (another version)",
"installedSame": "Installed {{version}}",
"installedState": "Installed",
"installing": "Installing",
"search": "Search the libraries",
"search": "Search add-ons in your sources",
"kind": {
"addon": "Add-on",
"backend": "Backend"
@@ -87,7 +84,8 @@
"confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.",
"backendFailed": "The backend was not installed.",
"alias": "Alias",
"kindLabel": "Kind"
"kindLabel": "Kind",
"installedOn": "{{name}} is installed and on."
},
"catalog": {
"allLibraries": "All libraries",
@@ -106,8 +104,8 @@
"search": "Search"
},
"drawer": {
"add": "Add a new add-on",
"browse": "Browse add-ons",
"add": "Find add-ons",
"browse": "Find add-ons",
"builtinDetail": "This add-on ships with LocalAI.",
"close": "Close",
"limitBody": "The sidebar shows at most eight pinned add-ons. Unpin one first.",
@@ -117,13 +115,13 @@
"pin": "Pin {{name}}",
"pinLabel": "Pin",
"pinned": "Pinned",
"running": "Running",
"running": "On",
"search": "Search add-ons",
"status": {
"failed": "failed",
"starting": "starting"
},
"stopped": "Stopped",
"stopped": "Off",
"unpin": "Unpin {{name}}"
},
"errors": {
@@ -135,21 +133,9 @@
"unknownOutcome": "The result could not be confirmed. Check the status before you try again."
},
"installed": {
"disable": "Disable",
"disabled": "Disabled",
"emptyBody": "Install an add-on from the Browse tab.",
"emptyTitle": "Nothing is installed",
"enable": "Enable",
"enabled": "Enabled",
"generation": "Generation {{generation}}",
"listLabel": "Installed add-ons",
"loading": "Loading installed add-ons",
"manage": "Details",
"manageLead": "Management view of {{id}}",
"missing": "{{id}} is not installed.",
"uninstall": "Uninstall",
"uninstallBody": "The add-on stops and its package is removed from use. Its data, identity and grants are kept, so installing it again picks them up. Nothing is deleted.",
"uninstallTitle": "Uninstall {{name}}?"
"disabled": "Off",
"enabled": "On",
"missing": "{{id}} is not installed."
},
"libraries": {
"add": "Add a library",
@@ -236,8 +222,8 @@
"skipped_one": "{{count}} entry skipped: kind {{kinds}} is not read by this version",
"skipped_other": "{{count}} entries skipped: kind {{kinds}} is not read by this version",
"state": {
"enabled": "Enabled",
"disabled": "Disabled",
"enabled": "On",
"disabled": "Off",
"removed": "Removed",
"suspended": "Off (official libraries are off)"
},
@@ -287,7 +273,7 @@
"title": "This add-on is not available"
},
"reasons": {
"disabled": "The add-on was stopped. Start it again from the Add-ons page.",
"disabled": "The add-on is off. Turn it on from the Add-ons page.",
"removed": "The add-on is not installed, or you can no longer use it.",
"failed": "The add-on stopped responding. It is restarted automatically; reload to try again.",
"updated": "The add-on was updated. Reload to use the new version.",
@@ -331,7 +317,18 @@
},
"layer": {
"close": "Close"
}
},
"off": {
"title": "{{name}} is off",
"body": "Turn it on to use its page.",
"reader": "An administrator can turn it on."
},
"notInstalled": {
"title": "{{name}} is not installed",
"body": "Find it in Find add-ons, then install and turn it on.",
"reader": "An administrator can install it."
},
"failedTitle": "{{name}} failed to start"
},
"slots": {
"unavailable": "Unavailable",
@@ -341,18 +338,17 @@
"state": {
"failed": "Failed",
"label": "State: {{state}}",
"running": "Running",
"running": "On",
"starting": "Starting",
"stopped": "Stopped",
"stopped": "Off",
"update-pending": "Update pending"
},
"supporting": "Apps your owner runs for you, and the libraries they come from.",
"supporting": "Add-ons extend LocalAI with pages and chat tools. Find one, turn it on, and use it.",
"tabs": {
"apps": "Apps",
"browse": "Browse",
"installed": "Installed",
"apps": "My add-ons",
"browse": "Find add-ons",
"label": "Add-ons sections",
"libraries": "Libraries"
"libraries": "Sources"
},
"title": "Add-ons",
"trust": {
@@ -438,7 +434,7 @@
"purged": "The retained data was purged.",
"purgeLead": "Purging removes retained data of this add-on for good. It is not the deletion of a database and not an uninstall.",
"purgeTitle": "Purge retained data",
"purgeBody": "This removes what you select below and cannot be undone. The add-on must be uninstalled or disabled.",
"purgeBody": "This removes what you select below and cannot be undone. The add-on must be uninstalled or off.",
"scope": "What to remove",
"scopeData": "The data and every share of this add-on",
"scopeBackups": "All backups ({{count}})",
@@ -450,7 +446,7 @@
"warnAccess": "Shares are checked again: nobody gets back access that was taken away since the backup, or whose account is gone.",
"warnProposals": "Proposals that were waiting are cancelled. Operations with an unknown outcome are looked up and never applied again.",
"warnCopy": "The data that is replaced is kept as a recovery copy until you purge it.",
"stopFirst": "Stop the add-on first: disable it, then restore.",
"stopFirst": "Turn the add-on off first, then restore.",
"mapping": "This backup was made in local-owner mode. Account ID that takes its data",
"typeToConfirm": "Type {{id}} to confirm",
"typeToConfirmLabel": "Type the add-on ID to confirm",
@@ -502,7 +498,7 @@
},
"errors": {
"needs_recovery": "The data needs recovery first: restore a verified backup or purge it.",
"addon_running": "Stop the add-on first.",
"addon_running": "Turn the add-on off first.",
"busy": "Another recovery operation is running for this add-on.",
"backup_not_found": "That backup does not exist any more.",
"not_installed": "The add-on is not installed.",
@@ -619,5 +615,42 @@
"none": "No library sources of this kind.",
"listLabel": "Library sources",
"mirrors": "Mirrors: {{list}}"
},
"card": {
"showInSidebar": "Show in sidebar",
"hideFromSidebar": "Remove from sidebar",
"details": "Details",
"remove": "Remove",
"menu": "Actions for {{name}}",
"addsPage": "Adds a page",
"chatTools_one": "{{count}} chat tool",
"chatTools_other": "{{count}} chat tools",
"openNamed": "Open {{name}}",
"removeTitle": "Remove {{name}}?",
"removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted."
},
"preview": {
"screenshots": "Screenshots",
"adds": "What it adds",
"page": "A page of its own in LocalAI",
"addsNothing": "Nothing you can see. It works in the background.",
"version": "Version",
"id": "Identifier",
"sourceUnknown": "Not known",
"moreDetails": "Health, trust and updates"
},
"flow": {
"installAndTurnOn": "Install and turn on",
"installing": "Installing {{name}}...",
"turningOn": "Turning on {{name}}...",
"installFailed": "{{name}} was not installed.",
"turnOnFailed": "{{name}} is installed but did not turn on.",
"confirmTitle": "Install and turn on {{name}}?",
"confirmBody": "Version {{version}} from the source {{library}} is downloaded and verified, installed, and turned on.",
"step": {
"install": "Download, verify and install",
"turn-on": "Turn on"
},
"seeInList": "See it in My add-ons"
}
}
@@ -325,6 +325,7 @@
"title": "Add-on tools for this chat",
"heading": "Add-on tools",
"selectAll": "Select all",
"deselectAll": "Deselect all"
"deselectAll": "Deselect all",
"note": "An add-on that is off here does nothing in this chat."
}
}
@@ -66,7 +66,7 @@
"trafficModels": "Models",
"gpuHost": "GPU and host",
"prometheus": "Prometheus",
"addons": "Apps"
"addons": "Add-ons"
},
"footer": {
"github": "GitHub",
@@ -99,7 +99,7 @@
"import": "Add a model from a file, a URL or a Hugging Face repository.",
"voices": "Identify speakers and keep a library of voice profiles.",
"faces": "Detect, compare and match faces.",
"addons": "Install add-ons and open the apps they provide."
"addons": "Install, turn on and open add-ons."
}
}
}
+107 -43
View File
@@ -37,13 +37,11 @@
"emptyTitle": "Aún no hay apps",
"firstRun": {
"browse": "Browse add-ons",
"browseBody": "Pick an add-on from a library and install it.",
"browseTitle": "Browse and install.",
"libraryBody": "The official library is registered by default. You can add more under Libraries.",
"libraryTitle": "Choose a library.",
"startBody": "Start an installed add-on from its tile. Add-ons that were running start again with the server.",
"startTitle": "Start it.",
"title": "Welcome to Apps"
"title": "Welcome to Apps",
"lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.",
"featuredLabel": "Featured add-ons",
"featuredFailed": "The list of add-ons could not be read.",
"noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons."
},
"headless": "Se ejecuta en segundo plano y no tiene página propia.",
"listLabel": "Apps",
@@ -51,27 +49,26 @@
"manage": "Gestionar",
"open": "Abrir",
"stale": "No se pudo actualizar la lista. Lo que ves puede estar desactualizado.",
"start": "Iniciar",
"stop": "Detener",
"start": "Activar",
"stop": "Desactivar",
"toolsMore_one": "y {{count}} más",
"toolsMore_other": "y {{count}} más",
"tools_one": "{{count}} herramienta",
"tools_other": "{{count}} herramientas",
"version": "Versión {{version}}",
"working": "Esperando la confirmación del propietario."
"working": "Esperando la confirmación del propietario.",
"noMatchTitle": "No add-on matches",
"noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.",
"clearSearch": "Clear search"
},
"browse": {
"confirmBody": "La versión {{version}} de la biblioteca {{library}} se descargará y verificará antes de instalarse.",
"confirmNotEnabled": "Instalar no inicia el complemento. Lo habilitas en un paso aparte.",
"confirmTitle": "¿Instalar {{name}}?",
"details": "Detalles",
"detailsLead": "Entrada de biblioteca de {{id}}",
"emptyBody": "Añade una biblioteca en la pestaña Bibliotecas y actualízala.",
"emptyTitle": "No se encontraron complementos",
"fromLibrary": "de {{library}}",
"install": "Instalar",
"installedDetail": "Versión {{version}}, generación {{generation}}",
"installedNotEnabled": "Instalado. No está habilitado: habilítalo en la pestaña Instalados cuando estés listo.",
"installedOther": "Instalado {{version}} (otra versión)",
"installedSame": "Instalado {{version}}",
"installedState": "Instalado",
@@ -87,7 +84,8 @@
"confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.",
"backendFailed": "The backend was not installed.",
"alias": "Alias",
"kindLabel": "Kind"
"kindLabel": "Kind",
"installedOn": "{{name}} is installed and on."
},
"catalog": {
"allLibraries": "Todas las bibliotecas",
@@ -117,13 +115,13 @@
"pin": "Pin {{name}}",
"pinLabel": "Pin",
"pinned": "Pinned",
"running": "Running",
"running": "Activado",
"search": "Search add-ons",
"status": {
"failed": "failed",
"starting": "starting"
},
"stopped": "Stopped",
"stopped": "Desactivado",
"unpin": "Unpin {{name}}"
},
"errors": {
@@ -135,21 +133,9 @@
"unknownOutcome": "No se pudo confirmar el resultado. Comprueba el estado antes de volver a intentarlo."
},
"installed": {
"disable": "Deshabilitar",
"disabled": "Deshabilitado",
"emptyBody": "Instala un complemento desde la pestaña Explorar.",
"emptyTitle": "No hay nada instalado",
"enable": "Habilitar",
"enabled": "Habilitado",
"generation": "Generación {{generation}}",
"listLabel": "Complementos instalados",
"loading": "Cargando complementos instalados",
"manage": "Detalles",
"manageLead": "Vista de gestión de {{id}}",
"missing": "{{id}} no está instalado.",
"uninstall": "Desinstalar",
"uninstallBody": "El complemento se detiene y su paquete deja de usarse. Sus datos, identidad y permisos se conservan, así que al instalarlo de nuevo los recupera. No se elimina nada.",
"uninstallTitle": "¿Desinstalar {{name}}?"
"disabled": "Desactivado",
"enabled": "Activado",
"missing": "{{id}} no está instalado."
},
"libraries": {
"add": "Añadir una biblioteca",
@@ -169,7 +155,8 @@
"urlCredentials": "La dirección no debe contener usuario ni contraseña.",
"urlQuery": "La dirección no debe contener cadena de consulta ni fragmento.",
"either": "Use the exact identity or the pattern, not both.",
"urlEmpty": "Enter the address of the index."
"urlEmpty": "Enter the address of the index.",
"mirrors": "Each mirror must start with https://, http://, github:, oci:// or file://, and at most 8 are allowed."
},
"fields": {
"enabled": "Habilitada",
@@ -182,14 +169,16 @@
"url": "URL del catálogo",
"identityRegex": "Signer identity pattern (optional)",
"kind": "Kind",
"format": "Index format"
"format": "Index format",
"mirrors": "Mirrors"
},
"hints": {
"id": "Letras minúsculas, dígitos y guiones. No se puede cambiar después.",
"reference": "El nombre de una credencial que configuró el propietario. Nunca pegues aquí un token ni una contraseña.",
"url": "Una dirección https sin credenciales ni cadena de consulta.",
"identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.",
"format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it."
"format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.",
"mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched."
},
"lastRefresh": "Actualizada {{when}}, {{count}} entradas",
"lead": "Una biblioteca es un documento de catálogo que enumera complementos. Las credenciales nunca se escriben aquí: una biblioteca privada nombra una credencial que configuró el propietario.",
@@ -241,7 +230,13 @@
"turnOff": "Turn off",
"turnOn": "Turn on",
"turnedOff": "Turned off {{name}}.",
"turnedOn": "Turned on {{name}}."
"turnedOn": "Turned on {{name}}.",
"manage": "Manage libraries",
"manageTitle": {
"backend": "Backend libraries",
"model": "Model libraries",
"addon": "Add-on libraries"
}
},
"manage": {
"api": "API del complemento",
@@ -322,7 +317,18 @@
},
"layer": {
"close": "Cerrar"
}
},
"off": {
"title": "{{name}} is off",
"body": "Turn it on to use its page.",
"reader": "An administrator can turn it on."
},
"notInstalled": {
"title": "{{name}} is not installed",
"body": "Find it in Find add-ons, then install and turn it on.",
"reader": "An administrator can install it."
},
"failedTitle": "{{name}} failed to start"
},
"slots": {
"unavailable": "No disponible",
@@ -332,18 +338,17 @@
"state": {
"failed": "Con error",
"label": "Estado: {{state}}",
"running": "En ejecución",
"running": "Activado",
"starting": "Iniciando",
"stopped": "Detenido",
"stopped": "Desactivado",
"update-pending": "Actualización pendiente"
},
"supporting": "Las apps que tu propietario ejecuta por ti y las bibliotecas de las que provienen.",
"tabs": {
"apps": "Apps",
"browse": "Explorar",
"installed": "Instalados",
"apps": "Mis complementos",
"browse": "Buscar complementos",
"label": "Secciones de complementos",
"libraries": "Bibliotecas"
"libraries": "Fuentes"
},
"title": "Complementos",
"trust": {
@@ -588,5 +593,64 @@
"network": "No se puede acceder a la red.",
"unavailable": "El propietario del complemento no está disponible. Inténtalo de nuevo."
}
},
"install": {
"stepsLabel": "Install steps",
"step": {
"resolving": "Resolve the library entry",
"verifying": "Verify the signature",
"size": "Check the image size",
"fetching": "Fetch the image",
"writing": "Write the metadata"
},
"state": {
"pending": "Waiting",
"active": "Running",
"done": "Done",
"failed": "Failed"
}
},
"settingsView": {
"loadFailed": "The library sources could not be read.",
"none": "No library sources of this kind.",
"listLabel": "Library sources",
"mirrors": "Mirrors: {{list}}"
},
"card": {
"showInSidebar": "Mostrar en la barra lateral",
"hideFromSidebar": "Remove from sidebar",
"details": "Detalles",
"remove": "Quitar",
"menu": "Actions for {{name}}",
"addsPage": "Añade una página",
"chatTools_one": "{{count}} chat tool",
"chatTools_other": "{{count}} chat tools",
"openNamed": "Open {{name}}",
"removeTitle": "Remove {{name}}?",
"removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted."
},
"preview": {
"screenshots": "Screenshots",
"adds": "What it adds",
"page": "A page of its own in LocalAI",
"addsNothing": "Nothing you can see. It works in the background.",
"version": "Version",
"id": "Identifier",
"sourceUnknown": "Not known",
"moreDetails": "Health, trust and updates"
},
"flow": {
"installAndTurnOn": "Instalar y activar",
"installing": "Installing {{name}}...",
"turningOn": "Turning on {{name}}...",
"installFailed": "{{name}} was not installed.",
"turnOnFailed": "{{name}} is installed but did not turn on.",
"confirmTitle": "Install and turn on {{name}}?",
"confirmBody": "Version {{version}} from the source {{library}} is downloaded and verified, installed, and turned on.",
"step": {
"install": "Download, verify and install",
"turn-on": "Turn on"
},
"seeInList": "See it in My add-ons"
}
}
@@ -122,5 +122,13 @@
},
"message": {
"you": "You"
},
"addons": {
"note": "An add-on that is off here does nothing in this chat.",
"label": "Add-ons",
"title": "Add-on tools for this chat",
"heading": "Add-on tools",
"selectAll": "Select all",
"deselectAll": "Deselect all"
}
}
@@ -61,7 +61,12 @@
"thisMachine": "Esta máquina",
"failover": "Failover",
"p2p": "P2P",
"addons": "Complementos"
"addons": "Complementos",
"voiceLibrary": "Voice Library",
"logs": "Logs",
"trafficModels": "Models",
"gpuHost": "GPU and host",
"prometheus": "Prometheus"
},
"footer": {
"github": "GitHub",
@@ -71,7 +76,9 @@
},
"console": {
"automation": "Automatización",
"training": "Entrenamiento"
"training": "Entrenamiento",
"expandNavigation": "Expand {{section}} navigation",
"collapseNavigation": "Collapse {{section}} navigation"
},
"hub": {
"status": "Estado",
+159 -41
View File
@@ -37,13 +37,11 @@
"emptyTitle": "Aucune application pour le moment",
"firstRun": {
"browse": "Parcourir les modules",
"browseBody": "Choisissez un module dans une bibliothèque et installez-le.",
"browseTitle": "Parcourir et installer.",
"libraryBody": "La bibliothèque officielle est enregistrée par défaut. Vous pouvez en ajouter d'autres dans Bibliothèques.",
"libraryTitle": "Choisir une bibliothèque.",
"startBody": "Démarrez un module installé depuis sa vignette. Les modules qui étaient en cours d'exécution redémarrent avec le serveur.",
"startTitle": "Le démarrer.",
"title": "Bienvenue dans Applications"
"title": "Bienvenue dans Applications",
"lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.",
"featuredLabel": "Featured add-ons",
"featuredFailed": "The list of add-ons could not be read.",
"noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons."
},
"headless": "S'exécute en arrière-plan et n'a pas de page propre.",
"listLabel": "Applications",
@@ -51,8 +49,8 @@
"manage": "Gérer",
"open": "Ouvrir",
"stale": "La liste n'a pas pu être actualisée. Ce que vous voyez est peut-être périmé.",
"start": "Démarrer",
"stop": "Arrêter",
"start": "Activer",
"stop": "Désactiver",
"toolsMore_one": "et {{count}} de plus",
"toolsMore_many": "et {{count}} de plus",
"toolsMore_other": "et {{count}} de plus",
@@ -60,25 +58,36 @@
"tools_many": "{{count}} d'outils",
"tools_other": "{{count}} outils",
"version": "Version {{version}}",
"working": "En attente de la confirmation du propriétaire."
"working": "En attente de la confirmation du propriétaire.",
"noMatchTitle": "No add-on matches",
"noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.",
"clearSearch": "Clear search"
},
"browse": {
"confirmBody": "La version {{version}} de la bibliothèque {{library}} sera téléchargée et vérifiée avant d'être installée.",
"confirmNotEnabled": "L'installation ne démarre pas le module. Vous l'activez dans une étape distincte.",
"confirmTitle": "Installer {{name}} ?",
"details": "Détails",
"detailsLead": "Entrée de bibliothèque pour {{id}}",
"emptyBody": "Ajoutez une bibliothèque dans l'onglet Bibliothèques, puis actualisez-la.",
"emptyTitle": "Aucun module trouvé",
"fromLibrary": "depuis {{library}}",
"install": "Installer",
"installedDetail": "Version {{version}}, génération {{generation}}",
"installedNotEnabled": "Installé. Il n'est pas activé : activez-le dans l'onglet Installés quand vous êtes prêt.",
"installedOther": "{{version}} installée (autre version)",
"installedSame": "{{version}} installée",
"installedState": "Installé",
"installing": "Installation en cours",
"search": "Rechercher dans les bibliothèques"
"search": "Rechercher dans les bibliothèques",
"kind": {
"addon": "Add-on",
"backend": "Backend"
},
"installedBackend": "Installed",
"installedBackendNote": "Installed as a backend on this node. Models can use it now.",
"confirmBackendBody": "Version {{version}} of the backend from the library {{library}} will be downloaded, checked against its digest and signature, and installed on this node. The backend gallery is not changed.",
"confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.",
"backendFailed": "The backend was not installed.",
"alias": "Alias",
"kindLabel": "Kind",
"installedOn": "{{name}} is installed and on."
},
"catalog": {
"allLibraries": "Toutes les bibliothèques",
@@ -109,13 +118,13 @@
"pin": "Épingler {{name}}",
"pinLabel": "Épingler",
"pinned": "Épinglé",
"running": "En cours d'exécution",
"running": "Activé",
"search": "Rechercher des modules",
"status": {
"failed": "en échec",
"starting": "démarrage"
},
"stopped": "Arrêté",
"stopped": "Désactivé",
"unpin": "Désépingler {{name}}"
},
"errors": {
@@ -127,21 +136,9 @@
"unknownOutcome": "Le résultat n'a pas pu être confirmé. Vérifiez l'état avant de réessayer."
},
"installed": {
"disable": "Désactiver",
"disabled": "Désactivé",
"emptyBody": "Installez un module depuis l'onglet Parcourir.",
"emptyTitle": "Rien n'est installé",
"enable": "Activer",
"enabled": "Activé",
"generation": "Génération {{generation}}",
"listLabel": "Modules installés",
"loading": "Chargement des modules installés",
"manage": "Détails",
"manageLead": "Vue de gestion de {{id}}",
"missing": "{{id}} n'est pas installé.",
"uninstall": "Désinstaller",
"uninstallBody": "Le module s'arrête et son paquet n'est plus utilisé. Ses données, son identité et ses autorisations sont conservées : le réinstaller les retrouve. Rien n'est supprimé.",
"uninstallTitle": "Désinstaller {{name}} ?"
"missing": "{{id}} n'est pas installé."
},
"libraries": {
"add": "Ajouter une bibliothèque",
@@ -159,7 +156,10 @@
"required": "Obligatoire lorsqu'une signature est exigée.",
"url": "Saisissez une adresse http ou https.",
"urlCredentials": "L'adresse ne doit contenir ni nom d'utilisateur ni mot de passe.",
"urlQuery": "L'adresse ne doit contenir ni chaîne de requête ni fragment."
"urlQuery": "L'adresse ne doit contenir ni chaîne de requête ni fragment.",
"either": "Use the exact identity or the pattern, not both.",
"urlEmpty": "Enter the address of the index.",
"mirrors": "Each mirror must start with https://, http://, github:, oci:// or file://, and at most 8 are allowed."
},
"fields": {
"enabled": "Activée",
@@ -169,12 +169,19 @@
"name": "Nom",
"reference": "Référence d'identifiant (facultatif)",
"requireSignature": "Exiger une signature",
"url": "URL du catalogue"
"url": "URL du catalogue",
"identityRegex": "Signer identity pattern (optional)",
"kind": "Kind",
"format": "Index format",
"mirrors": "Mirrors"
},
"hints": {
"id": "Minuscules, chiffres et traits d'union. Il ne peut plus changer ensuite.",
"reference": "Le nom d'un identifiant configuré par le propriétaire. Ne collez jamais un jeton ou un mot de passe ici.",
"url": "Une adresse https sans identifiants ni chaîne de requête."
"url": "Une adresse https sans identifiants ni chaîne de requête.",
"identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.",
"format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.",
"mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched."
},
"lastRefresh": "Actualisée {{when}}, {{count}} entrées",
"lead": "Une bibliothèque est un document de catalogue qui liste des modules. Les identifiants ne se saisissent jamais ici : une bibliothèque privée nomme un identifiant configuré par le propriétaire.",
@@ -190,7 +197,49 @@
"removeTitle": "Retirer {{name}} ?",
"removed": "{{name}} retirée.",
"save": "Enregistrer",
"saved": "{{name}} enregistrée."
"saved": "{{name}} enregistrée.",
"default": "Default",
"emptyKindBody": "Add a library of this kind, or pick another filter.",
"emptyKindTitle": "No libraries of this kind",
"formats": {
"library": "Library index",
"gallery": "Gallery index"
},
"gallery": "Gallery index",
"kind": {
"addon": "Add-on",
"backend": "Backend",
"model": "Model"
},
"kindFilter": "Filter by kind",
"kinds": {
"all": "All",
"addon": "Add-ons",
"backend": "Backends",
"model": "Models"
},
"officialOff": "Official libraries are off in Settings. The default libraries stay listed, shown as off, and are not read. Libraries you added are not affected.",
"removeDefaultBody": "This is a default library that comes with LocalAI. It is removed from the list and stays removed at the next start. You can restore it from the list. Installed items are not touched.",
"removedDefault": "Removed {{name}}. It stays removed at the next start; restore it to bring it back.",
"restore": "Restore",
"skipped_one": "{{count}} entry skipped: kind {{kinds}} is not read by this version",
"skipped_other": "{{count}} entries skipped: kind {{kinds}} is not read by this version",
"state": {
"enabled": "On",
"disabled": "Off",
"removed": "Removed",
"suspended": "Off (official libraries are off)"
},
"turnOff": "Turn off",
"turnOn": "Turn on",
"turnedOff": "Turned off {{name}}.",
"turnedOn": "Turned on {{name}}.",
"manage": "Manage libraries",
"manageTitle": {
"backend": "Backend libraries",
"model": "Model libraries",
"addon": "Add-on libraries"
}
},
"manage": {
"api": "API des modules",
@@ -272,7 +321,18 @@
},
"layer": {
"close": "Fermer"
}
},
"off": {
"title": "{{name}} is off",
"body": "Turn it on to use its page.",
"reader": "An administrator can turn it on."
},
"notInstalled": {
"title": "{{name}} is not installed",
"body": "Find it in Find add-ons, then install and turn it on.",
"reader": "An administrator can install it."
},
"failedTitle": "{{name}} failed to start"
},
"slots": {
"unavailable": "Indisponible",
@@ -282,18 +342,17 @@
"state": {
"failed": "En échec",
"label": "État : {{state}}",
"running": "En cours d'exécution",
"running": "Activé",
"starting": "Démarrage",
"stopped": "Arrêté",
"stopped": "Désactivé",
"update-pending": "Mise à jour en attente"
},
"supporting": "Les applications que votre propriétaire exécute pour vous, et les bibliothèques dont elles proviennent.",
"tabs": {
"apps": "Applications",
"browse": "Parcourir",
"installed": "Installés",
"apps": "Mes add-ons",
"browse": "Trouver des add-ons",
"label": "Sections des modules",
"libraries": "Bibliothèques"
"libraries": "Sources"
},
"title": "Modules",
"trust": {
@@ -538,5 +597,64 @@
"network": "Le réseau est injoignable.",
"unavailable": "Le propriétaire du module n'est pas disponible. Réessayez."
}
},
"install": {
"stepsLabel": "Install steps",
"step": {
"resolving": "Resolve the library entry",
"verifying": "Verify the signature",
"size": "Check the image size",
"fetching": "Fetch the image",
"writing": "Write the metadata"
},
"state": {
"pending": "Waiting",
"active": "Running",
"done": "Done",
"failed": "Failed"
}
},
"settingsView": {
"loadFailed": "The library sources could not be read.",
"none": "No library sources of this kind.",
"listLabel": "Library sources",
"mirrors": "Mirrors: {{list}}"
},
"card": {
"showInSidebar": "Afficher dans la barre latérale",
"hideFromSidebar": "Remove from sidebar",
"details": "Détails",
"remove": "Supprimer",
"menu": "Actions for {{name}}",
"addsPage": "Ajoute une page",
"chatTools_one": "{{count}} chat tool",
"chatTools_other": "{{count}} chat tools",
"openNamed": "Open {{name}}",
"removeTitle": "Remove {{name}}?",
"removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted."
},
"preview": {
"screenshots": "Screenshots",
"adds": "What it adds",
"page": "A page of its own in LocalAI",
"addsNothing": "Nothing you can see. It works in the background.",
"version": "Version",
"id": "Identifier",
"sourceUnknown": "Not known",
"moreDetails": "Health, trust and updates"
},
"flow": {
"installAndTurnOn": "Installer et activer",
"installing": "Installing {{name}}...",
"turningOn": "Turning on {{name}}...",
"installFailed": "{{name}} was not installed.",
"turnOnFailed": "{{name}} is installed but did not turn on.",
"confirmTitle": "Install and turn on {{name}}?",
"confirmBody": "Version {{version}} from the source {{library}} is downloaded and verified, installed, and turned on.",
"step": {
"install": "Download, verify and install",
"turn-on": "Turn on"
},
"seeInList": "See it in My add-ons"
}
}
@@ -131,5 +131,13 @@
},
"message": {
"you": "Vous"
},
"addons": {
"note": "An add-on that is off here does nothing in this chat.",
"label": "Add-ons",
"title": "Add-on tools for this chat",
"heading": "Add-on tools",
"selectAll": "Select all",
"deselectAll": "Deselect all"
}
}
@@ -37,7 +37,8 @@
"memory": "Collections de documents que les agents peuvent consulter.",
"quantize": "Réduisez un modèle pour l'exécuter sur du matériel plus modeste.",
"skills": "Instructions réutilisables que les agents chargent à la demande.",
"voices": "Identifiez les locuteurs et conservez une bibliothèque de profils vocaux."
"voices": "Identifiez les locuteurs et conservez une bibliothèque de profils vocaux.",
"addons": "Install, turn on and open add-ons."
}
},
"operate": {
@@ -89,7 +90,8 @@
"logs": "Journaux",
"p2p": "P2P",
"prometheus": "Prometheus",
"trafficModels": "Modèles"
"trafficModels": "Modèles",
"addons": "Add-ons"
},
"footer": {
"github": "GitHub",
+105 -41
View File
@@ -37,13 +37,11 @@
"emptyTitle": "Belum ada aplikasi",
"firstRun": {
"browse": "Browse add-ons",
"browseBody": "Pick an add-on from a library and install it.",
"browseTitle": "Browse and install.",
"libraryBody": "The official library is registered by default. You can add more under Libraries.",
"libraryTitle": "Choose a library.",
"startBody": "Start an installed add-on from its tile. Add-ons that were running start again with the server.",
"startTitle": "Start it.",
"title": "Welcome to Apps"
"title": "Welcome to Apps",
"lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.",
"featuredLabel": "Featured add-ons",
"featuredFailed": "The list of add-ons could not be read.",
"noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons."
},
"headless": "Berjalan di latar belakang dan tidak punya halaman sendiri.",
"listLabel": "Aplikasi",
@@ -51,27 +49,26 @@
"manage": "Kelola",
"open": "Buka",
"stale": "Daftar tidak dapat diperbarui. Yang Anda lihat mungkin sudah usang.",
"start": "Mulai",
"stop": "Hentikan",
"start": "Aktifkan",
"stop": "Nonaktifkan",
"toolsMore_one": "dan {{count}} lagi",
"toolsMore_other": "dan {{count}} lagi",
"tools_one": "{{count}} alat",
"tools_other": "{{count}} alat",
"version": "Versi {{version}}",
"working": "Menunggu konfirmasi pemilik."
"working": "Menunggu konfirmasi pemilik.",
"noMatchTitle": "No add-on matches",
"noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.",
"clearSearch": "Clear search"
},
"browse": {
"confirmBody": "Versi {{version}} dari pustaka {{library}} akan diunduh dan diverifikasi sebelum dipasang.",
"confirmNotEnabled": "Pemasangan tidak menjalankan add-on. Anda mengaktifkannya sebagai langkah terpisah.",
"confirmTitle": "Pasang {{name}}?",
"details": "Detail",
"detailsLead": "Entri pustaka untuk {{id}}",
"emptyBody": "Tambahkan pustaka di tab Pustaka, lalu segarkan.",
"emptyTitle": "Add-on tidak ditemukan",
"fromLibrary": "dari {{library}}",
"install": "Pasang",
"installedDetail": "Versi {{version}}, generasi {{generation}}",
"installedNotEnabled": "Terpasang. Belum diaktifkan: aktifkan di tab Terpasang saat Anda siap.",
"installedOther": "Terpasang {{version}} (versi lain)",
"installedSame": "Terpasang {{version}}",
"installedState": "Terpasang",
@@ -87,7 +84,8 @@
"confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.",
"backendFailed": "The backend was not installed.",
"alias": "Alias",
"kindLabel": "Kind"
"kindLabel": "Kind",
"installedOn": "{{name}} is installed and on."
},
"catalog": {
"allLibraries": "Semua pustaka",
@@ -117,13 +115,13 @@
"pin": "Pin {{name}}",
"pinLabel": "Pin",
"pinned": "Pinned",
"running": "Running",
"running": "Aktif",
"search": "Search add-ons",
"status": {
"failed": "failed",
"starting": "starting"
},
"stopped": "Stopped",
"stopped": "Nonaktif",
"unpin": "Unpin {{name}}"
},
"errors": {
@@ -135,21 +133,9 @@
"unknownOutcome": "Hasil tidak dapat dikonfirmasi. Periksa statusnya sebelum mencoba lagi."
},
"installed": {
"disable": "Nonaktifkan",
"disabled": "Nonaktif",
"emptyBody": "Pasang add-on dari tab Jelajahi.",
"emptyTitle": "Belum ada yang terpasang",
"enable": "Aktifkan",
"enabled": "Aktif",
"generation": "Generasi {{generation}}",
"listLabel": "Add-on terpasang",
"loading": "Memuat add-on terpasang",
"manage": "Detail",
"manageLead": "Tampilan pengelolaan {{id}}",
"missing": "{{id}} tidak terpasang.",
"uninstall": "Copot",
"uninstallBody": "Add-on berhenti dan paketnya tidak lagi dipakai. Data, identitas, dan izinnya dipertahankan, jadi memasangnya lagi akan memakainya kembali. Tidak ada yang dihapus.",
"uninstallTitle": "Copot {{name}}?"
"missing": "{{id}} tidak terpasang."
},
"libraries": {
"add": "Tambah pustaka",
@@ -169,7 +155,8 @@
"urlCredentials": "Alamat tidak boleh berisi nama pengguna atau kata sandi.",
"urlQuery": "Alamat tidak boleh berisi query string atau fragmen.",
"either": "Use the exact identity or the pattern, not both.",
"urlEmpty": "Enter the address of the index."
"urlEmpty": "Enter the address of the index.",
"mirrors": "Each mirror must start with https://, http://, github:, oci:// or file://, and at most 8 are allowed."
},
"fields": {
"enabled": "Aktif",
@@ -182,14 +169,16 @@
"url": "URL katalog",
"identityRegex": "Signer identity pattern (optional)",
"kind": "Kind",
"format": "Index format"
"format": "Index format",
"mirrors": "Mirrors"
},
"hints": {
"id": "Huruf kecil, angka, dan tanda hubung. Tidak dapat diubah nanti.",
"reference": "Nama kredensial yang dikonfigurasi pemilik. Jangan pernah menempelkan token atau kata sandi di sini.",
"url": "Alamat https tanpa kredensial dan tanpa query string.",
"identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.",
"format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it."
"format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.",
"mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched."
},
"lastRefresh": "Disegarkan {{when}}, {{count}} entri",
"lead": "Pustaka adalah dokumen katalog yang mendaftar add-on. Kredensial tidak pernah diketik di sini: pustaka privat menyebut kredensial yang dikonfigurasi pemilik.",
@@ -241,7 +230,13 @@
"turnOff": "Turn off",
"turnOn": "Turn on",
"turnedOff": "Turned off {{name}}.",
"turnedOn": "Turned on {{name}}."
"turnedOn": "Turned on {{name}}.",
"manage": "Manage libraries",
"manageTitle": {
"backend": "Backend libraries",
"model": "Model libraries",
"addon": "Add-on libraries"
}
},
"manage": {
"api": "API add-on",
@@ -322,7 +317,18 @@
},
"layer": {
"close": "Tutup"
}
},
"off": {
"title": "{{name}} is off",
"body": "Turn it on to use its page.",
"reader": "An administrator can turn it on."
},
"notInstalled": {
"title": "{{name}} is not installed",
"body": "Find it in Find add-ons, then install and turn it on.",
"reader": "An administrator can install it."
},
"failedTitle": "{{name}} failed to start"
},
"slots": {
"unavailable": "Tidak tersedia",
@@ -332,18 +338,17 @@
"state": {
"failed": "Gagal",
"label": "Status: {{state}}",
"running": "Berjalan",
"running": "Aktif",
"starting": "Memulai",
"stopped": "Berhenti",
"stopped": "Nonaktif",
"update-pending": "Pembaruan tertunda"
},
"supporting": "Aplikasi yang dijalankan pemilik untuk Anda, beserta pustaka asalnya.",
"tabs": {
"apps": "Aplikasi",
"browse": "Jelajahi",
"installed": "Terpasang",
"apps": "Add-on saya",
"browse": "Cari add-on",
"label": "Bagian add-on",
"libraries": "Pustaka"
"libraries": "Sumber"
},
"title": "Add-on",
"trust": {
@@ -588,5 +593,64 @@
"network": "Jaringan tidak dapat dijangkau.",
"unavailable": "Pemilik add-on tidak tersedia. Coba lagi."
}
},
"install": {
"stepsLabel": "Install steps",
"step": {
"resolving": "Resolve the library entry",
"verifying": "Verify the signature",
"size": "Check the image size",
"fetching": "Fetch the image",
"writing": "Write the metadata"
},
"state": {
"pending": "Waiting",
"active": "Running",
"done": "Done",
"failed": "Failed"
}
},
"settingsView": {
"loadFailed": "The library sources could not be read.",
"none": "No library sources of this kind.",
"listLabel": "Library sources",
"mirrors": "Mirrors: {{list}}"
},
"card": {
"showInSidebar": "Tampilkan di bilah samping",
"hideFromSidebar": "Remove from sidebar",
"details": "Detail",
"remove": "Hapus",
"menu": "Actions for {{name}}",
"addsPage": "Menambah satu halaman",
"chatTools_one": "{{count}} chat tool",
"chatTools_other": "{{count}} chat tools",
"openNamed": "Open {{name}}",
"removeTitle": "Remove {{name}}?",
"removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted."
},
"preview": {
"screenshots": "Screenshots",
"adds": "What it adds",
"page": "A page of its own in LocalAI",
"addsNothing": "Nothing you can see. It works in the background.",
"version": "Version",
"id": "Identifier",
"sourceUnknown": "Not known",
"moreDetails": "Health, trust and updates"
},
"flow": {
"installAndTurnOn": "Pasang dan aktifkan",
"installing": "Installing {{name}}...",
"turningOn": "Turning on {{name}}...",
"installFailed": "{{name}} was not installed.",
"turnOnFailed": "{{name}} is installed but did not turn on.",
"confirmTitle": "Install and turn on {{name}}?",
"confirmBody": "Version {{version}} from the source {{library}} is downloaded and verified, installed, and turned on.",
"step": {
"install": "Download, verify and install",
"turn-on": "Turn on"
},
"seeInList": "See it in My add-ons"
}
}
@@ -122,5 +122,13 @@
},
"message": {
"you": "You"
},
"addons": {
"note": "An add-on that is off here does nothing in this chat.",
"label": "Add-ons",
"title": "Add-on tools for this chat",
"heading": "Add-on tools",
"selectAll": "Select all",
"deselectAll": "Deselect all"
}
}
@@ -62,7 +62,11 @@
"thisMachine": "Mesin ini",
"failover": "Failover",
"p2p": "P2P",
"addons": "Add-on"
"addons": "Add-on",
"logs": "Logs",
"trafficModels": "Models",
"gpuHost": "GPU and host",
"prometheus": "Prometheus"
},
"footer": {
"github": "GitHub",
@@ -72,7 +76,9 @@
},
"console": {
"automation": "Automasi",
"training": "Pelatihan"
"training": "Pelatihan",
"expandNavigation": "Expand {{section}} navigation",
"collapseNavigation": "Collapse {{section}} navigation"
},
"hub": {
"status": "Status",
+107 -43
View File
@@ -37,13 +37,11 @@
"emptyTitle": "Ancora nessuna app",
"firstRun": {
"browse": "Browse add-ons",
"browseBody": "Pick an add-on from a library and install it.",
"browseTitle": "Browse and install.",
"libraryBody": "The official library is registered by default. You can add more under Libraries.",
"libraryTitle": "Choose a library.",
"startBody": "Start an installed add-on from its tile. Add-ons that were running start again with the server.",
"startTitle": "Start it.",
"title": "Welcome to Apps"
"title": "Welcome to Apps",
"lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.",
"featuredLabel": "Featured add-ons",
"featuredFailed": "The list of add-ons could not be read.",
"noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons."
},
"headless": "Gira in background e non ha una pagina propria.",
"listLabel": "App",
@@ -51,27 +49,26 @@
"manage": "Gestisci",
"open": "Apri",
"stale": "Non è stato possibile aggiornare l'elenco. Quanto vedi potrebbe non essere aggiornato.",
"start": "Avvia",
"stop": "Ferma",
"start": "Attiva",
"stop": "Disattiva",
"toolsMore_one": "e altri {{count}}",
"toolsMore_other": "e altri {{count}}",
"tools_one": "{{count}} strumento",
"tools_other": "{{count}} strumenti",
"version": "Versione {{version}}",
"working": "In attesa della conferma del proprietario."
"working": "In attesa della conferma del proprietario.",
"noMatchTitle": "No add-on matches",
"noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.",
"clearSearch": "Clear search"
},
"browse": {
"confirmBody": "La versione {{version}} della libreria {{library}} verrà scaricata e verificata prima dell'installazione.",
"confirmNotEnabled": "L'installazione non avvia il componente. Lo abiliti in un passaggio separato.",
"confirmTitle": "Installare {{name}}?",
"details": "Dettagli",
"detailsLead": "Voce di libreria per {{id}}",
"emptyBody": "Aggiungi una libreria nella scheda Librerie, poi aggiornala.",
"emptyTitle": "Nessun componente trovato",
"fromLibrary": "da {{library}}",
"install": "Installa",
"installedDetail": "Versione {{version}}, generazione {{generation}}",
"installedNotEnabled": "Installato. Non è abilitato: abilitalo nella scheda Installati quando sei pronto.",
"installedOther": "Installato {{version}} (altra versione)",
"installedSame": "Installato {{version}}",
"installedState": "Installato",
@@ -87,7 +84,8 @@
"confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.",
"backendFailed": "The backend was not installed.",
"alias": "Alias",
"kindLabel": "Kind"
"kindLabel": "Kind",
"installedOn": "{{name}} is installed and on."
},
"catalog": {
"allLibraries": "Tutte le librerie",
@@ -117,13 +115,13 @@
"pin": "Pin {{name}}",
"pinLabel": "Pin",
"pinned": "Pinned",
"running": "Running",
"running": "Attivo",
"search": "Search add-ons",
"status": {
"failed": "failed",
"starting": "starting"
},
"stopped": "Stopped",
"stopped": "Disattivato",
"unpin": "Unpin {{name}}"
},
"errors": {
@@ -135,21 +133,9 @@
"unknownOutcome": "Non è stato possibile confermare il risultato. Controlla lo stato prima di riprovare."
},
"installed": {
"disable": "Disabilita",
"disabled": "Disabilitato",
"emptyBody": "Installa un componente dalla scheda Sfoglia.",
"emptyTitle": "Nulla è installato",
"enable": "Abilita",
"enabled": "Abilitato",
"generation": "Generazione {{generation}}",
"listLabel": "Componenti installati",
"loading": "Caricamento dei componenti installati",
"manage": "Dettagli",
"manageLead": "Vista di gestione di {{id}}",
"missing": "{{id}} non è installato.",
"uninstall": "Disinstalla",
"uninstallBody": "Il componente si ferma e il suo pacchetto non viene più usato. I suoi dati, la sua identità e i suoi permessi vengono conservati, quindi reinstallarlo li ritrova. Nulla viene eliminato.",
"uninstallTitle": "Disinstallare {{name}}?"
"disabled": "Disattivato",
"enabled": "Attivo",
"missing": "{{id}} non è installato."
},
"libraries": {
"add": "Aggiungi una libreria",
@@ -169,7 +155,8 @@
"urlCredentials": "L'indirizzo non deve contenere nome utente o password.",
"urlQuery": "L'indirizzo non deve contenere una stringa di query o un frammento.",
"either": "Use the exact identity or the pattern, not both.",
"urlEmpty": "Enter the address of the index."
"urlEmpty": "Enter the address of the index.",
"mirrors": "Each mirror must start with https://, http://, github:, oci:// or file://, and at most 8 are allowed."
},
"fields": {
"enabled": "Abilitata",
@@ -182,14 +169,16 @@
"url": "URL del catalogo",
"identityRegex": "Signer identity pattern (optional)",
"kind": "Kind",
"format": "Index format"
"format": "Index format",
"mirrors": "Mirrors"
},
"hints": {
"id": "Lettere minuscole, cifre e trattini. Non può cambiare in seguito.",
"reference": "Il nome di una credenziale configurata dal proprietario. Non incollare mai un token o una password qui.",
"url": "Un indirizzo https senza credenziali né stringa di query.",
"identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.",
"format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it."
"format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.",
"mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched."
},
"lastRefresh": "Aggiornata {{when}}, {{count}} voci",
"lead": "Una libreria è un documento di catalogo che elenca componenti. Le credenziali non si digitano mai qui: una libreria privata indica una credenziale configurata dal proprietario.",
@@ -241,7 +230,13 @@
"turnOff": "Turn off",
"turnOn": "Turn on",
"turnedOff": "Turned off {{name}}.",
"turnedOn": "Turned on {{name}}."
"turnedOn": "Turned on {{name}}.",
"manage": "Manage libraries",
"manageTitle": {
"backend": "Backend libraries",
"model": "Model libraries",
"addon": "Add-on libraries"
}
},
"manage": {
"api": "API del componente",
@@ -322,7 +317,18 @@
},
"layer": {
"close": "Chiudi"
}
},
"off": {
"title": "{{name}} is off",
"body": "Turn it on to use its page.",
"reader": "An administrator can turn it on."
},
"notInstalled": {
"title": "{{name}} is not installed",
"body": "Find it in Find add-ons, then install and turn it on.",
"reader": "An administrator can install it."
},
"failedTitle": "{{name}} failed to start"
},
"slots": {
"unavailable": "Non disponibile",
@@ -332,18 +338,17 @@
"state": {
"failed": "Errore",
"label": "Stato: {{state}}",
"running": "In esecuzione",
"running": "Attivo",
"starting": "In avvio",
"stopped": "Fermo",
"stopped": "Disattivato",
"update-pending": "Aggiornamento in attesa"
},
"supporting": "Le app che il proprietario esegue per te e le librerie da cui provengono.",
"tabs": {
"apps": "App",
"browse": "Sfoglia",
"installed": "Installati",
"apps": "I miei componenti",
"browse": "Trova componenti",
"label": "Sezioni dei componenti aggiuntivi",
"libraries": "Librerie"
"libraries": "Fonti"
},
"title": "Componenti aggiuntivi",
"trust": {
@@ -588,5 +593,64 @@
"network": "La rete non è raggiungibile.",
"unavailable": "Il proprietario del componente non è disponibile. Riprova."
}
},
"install": {
"stepsLabel": "Install steps",
"step": {
"resolving": "Resolve the library entry",
"verifying": "Verify the signature",
"size": "Check the image size",
"fetching": "Fetch the image",
"writing": "Write the metadata"
},
"state": {
"pending": "Waiting",
"active": "Running",
"done": "Done",
"failed": "Failed"
}
},
"settingsView": {
"loadFailed": "The library sources could not be read.",
"none": "No library sources of this kind.",
"listLabel": "Library sources",
"mirrors": "Mirrors: {{list}}"
},
"card": {
"showInSidebar": "Mostra nella barra laterale",
"hideFromSidebar": "Remove from sidebar",
"details": "Dettagli",
"remove": "Rimuovi",
"menu": "Actions for {{name}}",
"addsPage": "Aggiunge una pagina",
"chatTools_one": "{{count}} chat tool",
"chatTools_other": "{{count}} chat tools",
"openNamed": "Open {{name}}",
"removeTitle": "Remove {{name}}?",
"removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted."
},
"preview": {
"screenshots": "Screenshots",
"adds": "What it adds",
"page": "A page of its own in LocalAI",
"addsNothing": "Nothing you can see. It works in the background.",
"version": "Version",
"id": "Identifier",
"sourceUnknown": "Not known",
"moreDetails": "Health, trust and updates"
},
"flow": {
"installAndTurnOn": "Installa e attiva",
"installing": "Installing {{name}}...",
"turningOn": "Turning on {{name}}...",
"installFailed": "{{name}} was not installed.",
"turnOnFailed": "{{name}} is installed but did not turn on.",
"confirmTitle": "Install and turn on {{name}}?",
"confirmBody": "Version {{version}} from the source {{library}} is downloaded and verified, installed, and turned on.",
"step": {
"install": "Download, verify and install",
"turn-on": "Turn on"
},
"seeInList": "See it in My add-ons"
}
}
@@ -122,5 +122,13 @@
},
"message": {
"you": "You"
},
"addons": {
"note": "An add-on that is off here does nothing in this chat.",
"label": "Add-ons",
"title": "Add-on tools for this chat",
"heading": "Add-on tools",
"selectAll": "Select all",
"deselectAll": "Deselect all"
}
}
@@ -61,7 +61,12 @@
"thisMachine": "Questa macchina",
"failover": "Failover",
"p2p": "P2P",
"addons": "Componenti aggiuntivi"
"addons": "Componenti aggiuntivi",
"voiceLibrary": "Voice Library",
"logs": "Logs",
"trafficModels": "Models",
"gpuHost": "GPU and host",
"prometheus": "Prometheus"
},
"footer": {
"github": "GitHub",
@@ -71,7 +76,9 @@
},
"console": {
"automation": "Automazione",
"training": "Addestramento"
"training": "Addestramento",
"expandNavigation": "Expand {{section}} navigation",
"collapseNavigation": "Collapse {{section}} navigation"
},
"hub": {
"status": "Stato",
+107 -43
View File
@@ -37,13 +37,11 @@
"emptyTitle": "아직 앱이 없습니다",
"firstRun": {
"browse": "Browse add-ons",
"browseBody": "Pick an add-on from a library and install it.",
"browseTitle": "Browse and install.",
"libraryBody": "The official library is registered by default. You can add more under Libraries.",
"libraryTitle": "Choose a library.",
"startBody": "Start an installed add-on from its tile. Add-ons that were running start again with the server.",
"startTitle": "Start it.",
"title": "Welcome to Apps"
"title": "Welcome to Apps",
"lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.",
"featuredLabel": "Featured add-ons",
"featuredFailed": "The list of add-ons could not be read.",
"noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons."
},
"headless": "백그라운드에서 실행되며 자체 페이지가 없습니다.",
"listLabel": "앱",
@@ -51,27 +49,26 @@
"manage": "관리",
"open": "열기",
"stale": "목록을 새로 고칠 수 없습니다. 표시된 내용이 오래되었을 수 있습니다.",
"start": "시작",
"stop": "중지",
"start": "켜기",
"stop": "끄기",
"toolsMore_one": "and {{count}} more",
"toolsMore_other": "외 {{count}}개",
"tools_one": "{{count}} tool",
"tools_other": "도구 {{count}}개",
"version": "버전 {{version}}",
"working": "소유자의 확인을 기다리는 중입니다."
"working": "소유자의 확인을 기다리는 중입니다.",
"noMatchTitle": "No add-on matches",
"noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.",
"clearSearch": "Clear search"
},
"browse": {
"confirmBody": "라이브러리 {{library}}의 {{version}} 버전을 내려받아 검증한 뒤 설치합니다.",
"confirmNotEnabled": "설치해도 애드온은 시작되지 않습니다. 활성화는 별도의 단계입니다.",
"confirmTitle": "{{name}}을(를) 설치할까요?",
"details": "자세히",
"detailsLead": "{{id}}의 라이브러리 항목",
"emptyBody": "라이브러리 탭에서 라이브러리를 추가한 다음 새로 고치세요.",
"emptyTitle": "애드온을 찾을 수 없습니다",
"fromLibrary": "{{library}}에서",
"install": "설치",
"installedDetail": "버전 {{version}}, 세대 {{generation}}",
"installedNotEnabled": "설치되었습니다. 아직 활성화되지 않았습니다. 준비되면 설치됨 탭에서 활성화하세요.",
"installedOther": "{{version}} 설치됨(다른 버전)",
"installedSame": "{{version}} 설치됨",
"installedState": "설치됨",
@@ -87,7 +84,8 @@
"confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.",
"backendFailed": "The backend was not installed.",
"alias": "Alias",
"kindLabel": "Kind"
"kindLabel": "Kind",
"installedOn": "{{name}} is installed and on."
},
"catalog": {
"allLibraries": "모든 라이브러리",
@@ -117,13 +115,13 @@
"pin": "Pin {{name}}",
"pinLabel": "Pin",
"pinned": "Pinned",
"running": "Running",
"running": "켜짐",
"search": "Search add-ons",
"status": {
"failed": "failed",
"starting": "starting"
},
"stopped": "Stopped",
"stopped": "꺼짐",
"unpin": "Unpin {{name}}"
},
"errors": {
@@ -135,21 +133,9 @@
"unknownOutcome": "결과를 확인할 수 없습니다. 다시 시도하기 전에 상태를 확인하세요."
},
"installed": {
"disable": "비활성화",
"disabled": "비활성화됨",
"emptyBody": "찾아보기 탭에서 애드온을 설치하세요.",
"emptyTitle": "설치된 항목이 없습니다",
"enable": "활성화",
"enabled": "활성화됨",
"generation": "세대 {{generation}}",
"listLabel": "설치된 애드온",
"loading": "설치된 애드온을 불러오는 중",
"manage": "자세히",
"manageLead": "{{id}} 관리 보기",
"missing": "{{id}}은(는) 설치되어 있지 않습니다.",
"uninstall": "제거",
"uninstallBody": "애드온이 중지되고 패키지는 더 이상 사용되지 않습니다. 데이터, 신원, 권한은 보존되므로 다시 설치하면 이어서 사용합니다. 아무것도 삭제되지 않습니다.",
"uninstallTitle": "{{name}}을(를) 제거할까요?"
"disabled": "꺼짐",
"enabled": "켜짐",
"missing": "{{id}}은(는) 설치되어 있지 않습니다."
},
"libraries": {
"add": "라이브러리 추가",
@@ -169,7 +155,8 @@
"urlCredentials": "주소에 사용자 이름이나 비밀번호를 넣을 수 없습니다.",
"urlQuery": "주소에 쿼리 문자열이나 프래그먼트를 넣을 수 없습니다.",
"either": "Use the exact identity or the pattern, not both.",
"urlEmpty": "Enter the address of the index."
"urlEmpty": "Enter the address of the index.",
"mirrors": "Each mirror must start with https://, http://, github:, oci:// or file://, and at most 8 are allowed."
},
"fields": {
"enabled": "활성화됨",
@@ -182,14 +169,16 @@
"url": "카탈로그 URL",
"identityRegex": "Signer identity pattern (optional)",
"kind": "Kind",
"format": "Index format"
"format": "Index format",
"mirrors": "Mirrors"
},
"hints": {
"id": "소문자, 숫자, 하이픈. 나중에 변경할 수 없습니다.",
"reference": "소유자가 구성한 자격 증명의 이름. 토큰이나 비밀번호를 여기에 붙여넣지 마세요.",
"url": "자격 증명과 쿼리 문자열이 없는 https 주소.",
"identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.",
"format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it."
"format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.",
"mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched."
},
"lastRefresh": "{{when}}에 새로 고침, 항목 {{count}}개",
"lead": "라이브러리는 애드온을 나열하는 카탈로그 문서입니다. 자격 증명은 여기에 입력하지 않습니다. 비공개 라이브러리는 소유자가 구성한 자격 증명을 이름으로 가리킵니다.",
@@ -241,7 +230,13 @@
"turnOff": "Turn off",
"turnOn": "Turn on",
"turnedOff": "Turned off {{name}}.",
"turnedOn": "Turned on {{name}}."
"turnedOn": "Turned on {{name}}.",
"manage": "Manage libraries",
"manageTitle": {
"backend": "Backend libraries",
"model": "Model libraries",
"addon": "Add-on libraries"
}
},
"manage": {
"api": "애드온 API",
@@ -322,7 +317,18 @@
},
"layer": {
"close": "닫기"
}
},
"off": {
"title": "{{name}} is off",
"body": "Turn it on to use its page.",
"reader": "An administrator can turn it on."
},
"notInstalled": {
"title": "{{name}} is not installed",
"body": "Find it in Find add-ons, then install and turn it on.",
"reader": "An administrator can install it."
},
"failedTitle": "{{name}} failed to start"
},
"slots": {
"unavailable": "사용할 수 없음",
@@ -332,18 +338,17 @@
"state": {
"failed": "실패",
"label": "상태: {{state}}",
"running": "실행 중",
"running": "켜짐",
"starting": "시작 중",
"stopped": "중지됨",
"stopped": "꺼짐",
"update-pending": "업데이트 대기 중"
},
"supporting": "소유자가 대신 실행하는 앱과 그 출처 라이브러리입니다.",
"tabs": {
"apps": "앱",
"browse": "찾아보기",
"installed": "설치됨",
"apps": "내 애드온",
"browse": "애드온 찾기",
"label": "애드온 섹션",
"libraries": "라이브러리"
"libraries": "소스"
},
"title": "애드온",
"trust": {
@@ -588,5 +593,64 @@
"network": "네트워크에 연결할 수 없습니다.",
"unavailable": "애드온 소유자를 사용할 수 없습니다. 다시 시도하세요."
}
},
"install": {
"stepsLabel": "Install steps",
"step": {
"resolving": "Resolve the library entry",
"verifying": "Verify the signature",
"size": "Check the image size",
"fetching": "Fetch the image",
"writing": "Write the metadata"
},
"state": {
"pending": "Waiting",
"active": "Running",
"done": "Done",
"failed": "Failed"
}
},
"settingsView": {
"loadFailed": "The library sources could not be read.",
"none": "No library sources of this kind.",
"listLabel": "Library sources",
"mirrors": "Mirrors: {{list}}"
},
"card": {
"showInSidebar": "사이드바에 표시",
"hideFromSidebar": "Remove from sidebar",
"details": "자세히",
"remove": "제거",
"menu": "Actions for {{name}}",
"addsPage": "페이지를 추가합니다",
"chatTools_one": "{{count}} chat tool",
"chatTools_other": "{{count}} chat tools",
"openNamed": "Open {{name}}",
"removeTitle": "Remove {{name}}?",
"removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted."
},
"preview": {
"screenshots": "Screenshots",
"adds": "What it adds",
"page": "A page of its own in LocalAI",
"addsNothing": "Nothing you can see. It works in the background.",
"version": "Version",
"id": "Identifier",
"sourceUnknown": "Not known",
"moreDetails": "Health, trust and updates"
},
"flow": {
"installAndTurnOn": "설치하고 켜기",
"installing": "Installing {{name}}...",
"turningOn": "Turning on {{name}}...",
"installFailed": "{{name}} was not installed.",
"turnOnFailed": "{{name}} is installed but did not turn on.",
"confirmTitle": "Install and turn on {{name}}?",
"confirmBody": "Version {{version}} from the source {{library}} is downloaded and verified, installed, and turned on.",
"step": {
"install": "Download, verify and install",
"turn-on": "Turn on"
},
"seeInList": "See it in My add-ons"
}
}
@@ -122,5 +122,13 @@
},
"message": {
"you": "You"
},
"addons": {
"note": "An add-on that is off here does nothing in this chat.",
"label": "Add-ons",
"title": "Add-on tools for this chat",
"heading": "Add-on tools",
"selectAll": "Select all",
"deselectAll": "Deselect all"
}
}
@@ -61,7 +61,12 @@
"thisMachine": "이 머신",
"failover": "페일오버",
"p2p": "P2P",
"addons": "애드온"
"addons": "애드온",
"voiceLibrary": "Voice Library",
"logs": "Logs",
"trafficModels": "Models",
"gpuHost": "GPU and host",
"prometheus": "Prometheus"
},
"footer": {
"github": "GitHub",
@@ -71,7 +76,9 @@
},
"console": {
"automation": "자동화",
"training": "학습"
"training": "학습",
"expandNavigation": "Expand {{section}} navigation",
"collapseNavigation": "Collapse {{section}} navigation"
},
"hub": {
"status": "상태",
@@ -37,13 +37,11 @@
"emptyTitle": "Ainda não há apps",
"firstRun": {
"browse": "Browse add-ons",
"browseBody": "Pick an add-on from a library and install it.",
"browseTitle": "Browse and install.",
"libraryBody": "The official library is registered by default. You can add more under Libraries.",
"libraryTitle": "Choose a library.",
"startBody": "Start an installed add-on from its tile. Add-ons that were running start again with the server.",
"startTitle": "Start it.",
"title": "Welcome to Apps"
"title": "Welcome to Apps",
"lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.",
"featuredLabel": "Featured add-ons",
"featuredFailed": "The list of add-ons could not be read.",
"noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons."
},
"headless": "Roda em segundo plano e não tem página própria.",
"listLabel": "Apps",
@@ -51,27 +49,26 @@
"manage": "Gerenciar",
"open": "Abrir",
"stale": "Não foi possível atualizar a lista. O que você vê pode estar desatualizado.",
"start": "Iniciar",
"stop": "Parar",
"start": "Ativar",
"stop": "Desativar",
"toolsMore_one": "e mais {{count}}",
"toolsMore_other": "e mais {{count}}",
"tools_one": "{{count}} ferramenta",
"tools_other": "{{count}} ferramentas",
"version": "Versão {{version}}",
"working": "Aguardando a confirmação do proprietário."
"working": "Aguardando a confirmação do proprietário.",
"noMatchTitle": "No add-on matches",
"noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.",
"clearSearch": "Clear search"
},
"browse": {
"confirmBody": "A versão {{version}} da biblioteca {{library}} será baixada e verificada antes de ser instalada.",
"confirmNotEnabled": "Instalar não inicia o complemento. Você o habilita em uma etapa separada.",
"confirmTitle": "Instalar {{name}}?",
"details": "Detalhes",
"detailsLead": "Entrada de biblioteca de {{id}}",
"emptyBody": "Adicione uma biblioteca na aba Bibliotecas e depois atualize-a.",
"emptyTitle": "Nenhum complemento encontrado",
"fromLibrary": "de {{library}}",
"install": "Instalar",
"installedDetail": "Versão {{version}}, geração {{generation}}",
"installedNotEnabled": "Instalado. Não está habilitado: habilite-o na aba Instalados quando estiver pronto.",
"installedOther": "Instalado {{version}} (outra versão)",
"installedSame": "Instalado {{version}}",
"installedState": "Instalado",
@@ -87,7 +84,8 @@
"confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.",
"backendFailed": "The backend was not installed.",
"alias": "Alias",
"kindLabel": "Kind"
"kindLabel": "Kind",
"installedOn": "{{name}} is installed and on."
},
"catalog": {
"allLibraries": "Todas as bibliotecas",
@@ -117,13 +115,13 @@
"pin": "Pin {{name}}",
"pinLabel": "Pin",
"pinned": "Pinned",
"running": "Running",
"running": "Ativado",
"search": "Search add-ons",
"status": {
"failed": "failed",
"starting": "starting"
},
"stopped": "Stopped",
"stopped": "Desativado",
"unpin": "Unpin {{name}}"
},
"errors": {
@@ -135,21 +133,9 @@
"unknownOutcome": "Não foi possível confirmar o resultado. Verifique o estado antes de tentar de novo."
},
"installed": {
"disable": "Desabilitar",
"disabled": "Desabilitado",
"emptyBody": "Instale um complemento pela aba Explorar.",
"emptyTitle": "Nada está instalado",
"enable": "Habilitar",
"enabled": "Habilitado",
"generation": "Geração {{generation}}",
"listLabel": "Complementos instalados",
"loading": "Carregando complementos instalados",
"manage": "Detalhes",
"manageLead": "Visão de gerenciamento de {{id}}",
"missing": "{{id}} não está instalado.",
"uninstall": "Desinstalar",
"uninstallBody": "O complemento para e seu pacote deixa de ser usado. Seus dados, identidade e permissões são mantidos, então instalá-lo de novo os recupera. Nada é excluído.",
"uninstallTitle": "Desinstalar {{name}}?"
"disabled": "Desativado",
"enabled": "Ativado",
"missing": "{{id}} não está instalado."
},
"libraries": {
"add": "Adicionar uma biblioteca",
@@ -169,7 +155,8 @@
"urlCredentials": "O endereço não deve conter nome de usuário nem senha.",
"urlQuery": "O endereço não deve conter string de consulta nem fragmento.",
"either": "Use the exact identity or the pattern, not both.",
"urlEmpty": "Enter the address of the index."
"urlEmpty": "Enter the address of the index.",
"mirrors": "Each mirror must start with https://, http://, github:, oci:// or file://, and at most 8 are allowed."
},
"fields": {
"enabled": "Habilitada",
@@ -182,14 +169,16 @@
"url": "URL do catálogo",
"identityRegex": "Signer identity pattern (optional)",
"kind": "Kind",
"format": "Index format"
"format": "Index format",
"mirrors": "Mirrors"
},
"hints": {
"id": "Letras minúsculas, dígitos e hifens. Não pode mudar depois.",
"reference": "O nome de uma credencial que o proprietário configurou. Nunca cole um token ou senha aqui.",
"url": "Um endereço https sem credenciais nem string de consulta.",
"identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.",
"format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it."
"format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.",
"mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched."
},
"lastRefresh": "Atualizada {{when}}, {{count}} entradas",
"lead": "Uma biblioteca é um documento de catálogo que lista complementos. Credenciais nunca são digitadas aqui: uma biblioteca privada indica uma credencial que o proprietário configurou.",
@@ -241,7 +230,13 @@
"turnOff": "Turn off",
"turnOn": "Turn on",
"turnedOff": "Turned off {{name}}.",
"turnedOn": "Turned on {{name}}."
"turnedOn": "Turned on {{name}}.",
"manage": "Manage libraries",
"manageTitle": {
"backend": "Backend libraries",
"model": "Model libraries",
"addon": "Add-on libraries"
}
},
"manage": {
"api": "API do complemento",
@@ -322,7 +317,18 @@
},
"layer": {
"close": "Fechar"
}
},
"off": {
"title": "{{name}} is off",
"body": "Turn it on to use its page.",
"reader": "An administrator can turn it on."
},
"notInstalled": {
"title": "{{name}} is not installed",
"body": "Find it in Find add-ons, then install and turn it on.",
"reader": "An administrator can install it."
},
"failedTitle": "{{name}} failed to start"
},
"slots": {
"unavailable": "Indisponível",
@@ -332,18 +338,17 @@
"state": {
"failed": "Com falha",
"label": "Estado: {{state}}",
"running": "Em execução",
"running": "Ativado",
"starting": "Iniciando",
"stopped": "Parado",
"stopped": "Desativado",
"update-pending": "Atualização pendente"
},
"supporting": "Apps que o proprietário executa para você e as bibliotecas de onde vêm.",
"tabs": {
"apps": "Apps",
"browse": "Explorar",
"installed": "Instalados",
"apps": "Meus complementos",
"browse": "Encontrar complementos",
"label": "Seções de complementos",
"libraries": "Bibliotecas"
"libraries": "Fontes"
},
"title": "Complementos",
"trust": {
@@ -588,5 +593,64 @@
"network": "A rede não está acessível.",
"unavailable": "O proprietário do complemento não está disponível. Tente novamente."
}
},
"install": {
"stepsLabel": "Install steps",
"step": {
"resolving": "Resolve the library entry",
"verifying": "Verify the signature",
"size": "Check the image size",
"fetching": "Fetch the image",
"writing": "Write the metadata"
},
"state": {
"pending": "Waiting",
"active": "Running",
"done": "Done",
"failed": "Failed"
}
},
"settingsView": {
"loadFailed": "The library sources could not be read.",
"none": "No library sources of this kind.",
"listLabel": "Library sources",
"mirrors": "Mirrors: {{list}}"
},
"card": {
"showInSidebar": "Mostrar na barra lateral",
"hideFromSidebar": "Remove from sidebar",
"details": "Detalhes",
"remove": "Remover",
"menu": "Actions for {{name}}",
"addsPage": "Adiciona uma página",
"chatTools_one": "{{count}} chat tool",
"chatTools_other": "{{count}} chat tools",
"openNamed": "Open {{name}}",
"removeTitle": "Remove {{name}}?",
"removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted."
},
"preview": {
"screenshots": "Screenshots",
"adds": "What it adds",
"page": "A page of its own in LocalAI",
"addsNothing": "Nothing you can see. It works in the background.",
"version": "Version",
"id": "Identifier",
"sourceUnknown": "Not known",
"moreDetails": "Health, trust and updates"
},
"flow": {
"installAndTurnOn": "Instalar e ativar",
"installing": "Installing {{name}}...",
"turningOn": "Turning on {{name}}...",
"installFailed": "{{name}} was not installed.",
"turnOnFailed": "{{name}} is installed but did not turn on.",
"confirmTitle": "Install and turn on {{name}}?",
"confirmBody": "Version {{version}} from the source {{library}} is downloaded and verified, installed, and turned on.",
"step": {
"install": "Download, verify and install",
"turn-on": "Turn on"
},
"seeInList": "See it in My add-ons"
}
}
@@ -128,5 +128,13 @@
},
"message": {
"you": "Você"
},
"addons": {
"note": "An add-on that is off here does nothing in this chat.",
"label": "Add-ons",
"title": "Add-on tools for this chat",
"heading": "Add-on tools",
"selectAll": "Select all",
"deselectAll": "Deselect all"
}
}
@@ -62,7 +62,11 @@
"thisMachine": "Esta máquina",
"failover": "Failover",
"p2p": "P2P",
"addons": "Complementos"
"addons": "Complementos",
"logs": "Logs",
"trafficModels": "Models",
"gpuHost": "GPU and host",
"prometheus": "Prometheus"
},
"footer": {
"github": "GitHub",
@@ -37,13 +37,11 @@
"emptyTitle": "还没有应用",
"firstRun": {
"browse": "Browse add-ons",
"browseBody": "Pick an add-on from a library and install it.",
"browseTitle": "Browse and install.",
"libraryBody": "The official library is registered by default. You can add more under Libraries.",
"libraryTitle": "Choose a library.",
"startBody": "Start an installed add-on from its tile. Add-ons that were running start again with the server.",
"startTitle": "Start it.",
"title": "Welcome to Apps"
"title": "Welcome to Apps",
"lead": "Add-ons give LocalAI pages and chat tools. Start with one of these, or find more.",
"featuredLabel": "Featured add-ons",
"featuredFailed": "The list of add-ons could not be read.",
"noFeatured": "Your sources do not list an add-on yet. Add a source, then find add-ons."
},
"headless": "在后台运行,没有自己的页面。",
"listLabel": "应用",
@@ -51,27 +49,26 @@
"manage": "管理",
"open": "打开",
"stale": "无法刷新列表,当前显示的内容可能已过时。",
"start": "启动",
"stop": "停止",
"start": "打开",
"stop": "关闭",
"toolsMore_one": "and {{count}} more",
"toolsMore_other": "另有 {{count}} 个",
"tools_one": "{{count}} tool",
"tools_other": "{{count}} 个工具",
"version": "版本 {{version}}",
"working": "正在等待所有者确认。"
"working": "正在等待所有者确认。",
"noMatchTitle": "No add-on matches",
"noMatchBody": "Nothing in My add-ons matches \"{{query}}\". Try other words.",
"clearSearch": "Clear search"
},
"browse": {
"confirmBody": "将从库 {{library}} 下载 {{version}} 版本,并在安装前进行验证。",
"confirmNotEnabled": "安装不会启动附加组件,启用是单独的一步。",
"confirmTitle": "安装 {{name}}?",
"details": "详情",
"detailsLead": "{{id}} 的库条目",
"emptyBody": "在“库”标签页添加一个库,然后刷新它。",
"emptyTitle": "未找到附加组件",
"fromLibrary": "来自 {{library}}",
"install": "安装",
"installedDetail": "版本 {{version}},第 {{generation}} 代",
"installedNotEnabled": "已安装,但尚未启用:准备好后请在“已安装”标签页启用。",
"installedOther": "已安装 {{version}}(其他版本)",
"installedSame": "已安装 {{version}}",
"installedState": "已安装",
@@ -87,7 +84,8 @@
"confirmBackendNote": "A backend runs native code with the rights of LocalAI. Install it only from a library you trust.",
"backendFailed": "The backend was not installed.",
"alias": "Alias",
"kindLabel": "Kind"
"kindLabel": "Kind",
"installedOn": "{{name}} is installed and on."
},
"catalog": {
"allLibraries": "所有库",
@@ -117,13 +115,13 @@
"pin": "Pin {{name}}",
"pinLabel": "Pin",
"pinned": "Pinned",
"running": "Running",
"running": "已打开",
"search": "Search add-ons",
"status": {
"failed": "failed",
"starting": "starting"
},
"stopped": "Stopped",
"stopped": "已关闭",
"unpin": "Unpin {{name}}"
},
"errors": {
@@ -135,21 +133,9 @@
"unknownOutcome": "无法确认结果。请先检查状态,再重试。"
},
"installed": {
"disable": "禁用",
"disabled": "已禁用",
"emptyBody": "请在“浏览”标签页安装附加组件。",
"emptyTitle": "尚未安装任何内容",
"enable": "启用",
"enabled": "已启用",
"generation": "第 {{generation}} 代",
"listLabel": "已安装的附加组件",
"loading": "正在加载已安装的附加组件",
"manage": "详情",
"manageLead": "{{id}} 的管理视图",
"missing": "{{id}} 未安装。",
"uninstall": "卸载",
"uninstallBody": "附加组件将停止,其软件包不再使用。其数据、身份和授权会保留,再次安装时会沿用。不会删除任何内容。",
"uninstallTitle": "卸载 {{name}}?"
"disabled": "已关闭",
"enabled": "已打开",
"missing": "{{id}} 未安装。"
},
"libraries": {
"add": "添加库",
@@ -169,7 +155,8 @@
"urlCredentials": "地址不能包含用户名或密码。",
"urlQuery": "地址不能包含查询字符串或片段。",
"either": "Use the exact identity or the pattern, not both.",
"urlEmpty": "Enter the address of the index."
"urlEmpty": "Enter the address of the index.",
"mirrors": "Each mirror must start with https://, http://, github:, oci:// or file://, and at most 8 are allowed."
},
"fields": {
"enabled": "已启用",
@@ -182,14 +169,16 @@
"url": "目录 URL",
"identityRegex": "Signer identity pattern (optional)",
"kind": "Kind",
"format": "Index format"
"format": "Index format",
"mirrors": "Mirrors"
},
"hints": {
"id": "小写字母、数字和连字符,之后不能更改。",
"reference": "所有者已配置的凭据名称。切勿在此粘贴令牌或密码。",
"url": "不含凭据和查询字符串的 https 地址。",
"identityRegex": "An anchored pattern, from ^ to $, used instead of the exact identity. Backend libraries use it so builds from master and from tags both verify.",
"format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it."
"format": "A library index uses the shared library entry shape. A gallery index is a model or backend gallery, as the gallery flags name it.",
"mirrors": "Fallback addresses, one per line, tried in order when the main address cannot be fetched."
},
"lastRefresh": "{{when}} 已刷新,{{count}} 个条目",
"lead": "库是列出附加组件的目录文档。凭据绝不在此输入:私有库只引用所有者配置的凭据。",
@@ -241,7 +230,13 @@
"turnOff": "Turn off",
"turnOn": "Turn on",
"turnedOff": "Turned off {{name}}.",
"turnedOn": "Turned on {{name}}."
"turnedOn": "Turned on {{name}}.",
"manage": "Manage libraries",
"manageTitle": {
"backend": "Backend libraries",
"model": "Model libraries",
"addon": "Add-on libraries"
}
},
"manage": {
"api": "附加组件 API",
@@ -322,7 +317,18 @@
},
"layer": {
"close": "关闭"
}
},
"off": {
"title": "{{name}} is off",
"body": "Turn it on to use its page.",
"reader": "An administrator can turn it on."
},
"notInstalled": {
"title": "{{name}} is not installed",
"body": "Find it in Find add-ons, then install and turn it on.",
"reader": "An administrator can install it."
},
"failedTitle": "{{name}} failed to start"
},
"slots": {
"unavailable": "不可用",
@@ -332,18 +338,17 @@
"state": {
"failed": "失败",
"label": "状态:{{state}}",
"running": "运行中",
"running": "已打开",
"starting": "启动中",
"stopped": "已停止",
"stopped": "已关闭",
"update-pending": "等待更新"
},
"supporting": "所有者为你运行的应用,以及它们的来源库。",
"tabs": {
"apps": "应用",
"browse": "浏览",
"installed": "已安装",
"apps": "我的附加组件",
"browse": "查找附加组件",
"label": "附加组件分区",
"libraries": "库"
"libraries": "来源"
},
"title": "附加组件",
"trust": {
@@ -588,5 +593,64 @@
"network": "无法连接到网络。",
"unavailable": "附加组件所有者不可用。请重试。"
}
},
"install": {
"stepsLabel": "Install steps",
"step": {
"resolving": "Resolve the library entry",
"verifying": "Verify the signature",
"size": "Check the image size",
"fetching": "Fetch the image",
"writing": "Write the metadata"
},
"state": {
"pending": "Waiting",
"active": "Running",
"done": "Done",
"failed": "Failed"
}
},
"settingsView": {
"loadFailed": "The library sources could not be read.",
"none": "No library sources of this kind.",
"listLabel": "Library sources",
"mirrors": "Mirrors: {{list}}"
},
"card": {
"showInSidebar": "在侧边栏中显示",
"hideFromSidebar": "Remove from sidebar",
"details": "详情",
"remove": "移除",
"menu": "Actions for {{name}}",
"addsPage": "添加一个页面",
"chatTools_one": "{{count}} chat tool",
"chatTools_other": "{{count}} chat tools",
"openNamed": "Open {{name}}",
"removeTitle": "Remove {{name}}?",
"removeBody": "{{name}} turns off and is removed from this server. Its data is kept, so installing it again picks it up. Nothing is deleted."
},
"preview": {
"screenshots": "Screenshots",
"adds": "What it adds",
"page": "A page of its own in LocalAI",
"addsNothing": "Nothing you can see. It works in the background.",
"version": "Version",
"id": "Identifier",
"sourceUnknown": "Not known",
"moreDetails": "Health, trust and updates"
},
"flow": {
"installAndTurnOn": "安装并打开",
"installing": "Installing {{name}}...",
"turningOn": "Turning on {{name}}...",
"installFailed": "{{name}} was not installed.",
"turnOnFailed": "{{name}} is installed but did not turn on.",
"confirmTitle": "Install and turn on {{name}}?",
"confirmBody": "Version {{version}} from the source {{library}} is downloaded and verified, installed, and turned on.",
"step": {
"install": "Download, verify and install",
"turn-on": "Turn on"
},
"seeInList": "See it in My add-ons"
}
}
@@ -122,5 +122,13 @@
},
"message": {
"you": "You"
},
"addons": {
"note": "An add-on that is off here does nothing in this chat.",
"label": "Add-ons",
"title": "Add-on tools for this chat",
"heading": "Add-on tools",
"selectAll": "Select all",
"deselectAll": "Deselect all"
}
}
@@ -61,7 +61,12 @@
"thisMachine": "本机",
"failover": "故障转移",
"p2p": "P2P",
"addons": "附加组件"
"addons": "附加组件",
"voiceLibrary": "Voice Library",
"logs": "Logs",
"trafficModels": "Models",
"gpuHost": "GPU and host",
"prometheus": "Prometheus"
},
"footer": {
"github": "GitHub",
@@ -71,7 +76,9 @@
},
"console": {
"automation": "自动化",
"training": "训练"
"training": "训练",
"expandNavigation": "Expand {{section}} navigation",
"collapseNavigation": "Collapse {{section}} navigation"
},
"hub": {
"status": "状态",
+2 -1
View File
@@ -11,6 +11,7 @@ import { useAuth } from './context/AuthContext'
import RouteFallback from './components/RouteFallback'
import { hubs, hubOwnsPath } from './components/hub/hubConfig'
import Icon from './components/Icon'
import { displayVersion } from './utils/version'
const COLLAPSED_KEY = 'localai_sidebar_collapsed'
@@ -48,7 +49,7 @@ export default function App() {
useEffect(() => {
systemApi.version()
.then(data => setVersion(typeof data === 'string' ? data : (data?.version || '')))
.then(data => setVersion(displayVersion(typeof data === 'string' ? data : (data?.version || ''))))
.catch(() => {})
}, [])
+83 -16
View File
@@ -6,7 +6,12 @@ import PageHeader from '../components/PageHeader'
import CatalogEmpty from '../components/catalog/CatalogEmpty'
import LoadingSpinner from '../components/LoadingSpinner'
import ConfirmDialog from '../components/ConfirmDialog'
import { localizedText } from '../components/addons/addonState'
import Icon from '../components/Icon'
import { useAuth } from '../context/AuthContext'
import { addonsApi } from '../utils/addonsClient'
import { addonDisplayName, appActions, localizedText } from '../components/addons/addonState'
import { describeOutcome } from '../components/addons/errors'
import { useAddonActions } from '../components/addons/useAddonActions'
import AddonErrorBoundary from './AddonErrorBoundary'
import { useAddonRegistry } from './useAddonRegistry'
import { appStatus } from './registryState'
@@ -45,7 +50,14 @@ export default function AddonRoute() {
const app = registry.apps.find(a => a.id === addonId) || null
const instance = registry.instances.find(i => i.id === addonId) || null
const status = appStatus(app)
const title = (app && localizedText(app, 'title', i18n.language)) || addonId
// The add-on's own name. A list entry with no title carries its id as the
// title, and an add-on that is not listed has only its id: both say the id
// with a capital first letter, never the bare identifier.
const listedTitle = app ? localizedText(app, 'title', i18n.language) : ''
const title = listedTitle && listedTitle !== addonId ? listedTitle : addonDisplayName(addonId)
const { isAdmin } = useAuth()
// Turning on an add-on from this page is the same action as on My add-ons.
const actions = useAddonActions(addonsApi, { onSettled: () => { refresh() } })
const pages = app && app.ui ? app.ui.pages || [] : []
const subpath = splat || ''
const resolved = subpath ? resolveSubpath(subpath, pages) : null
@@ -135,19 +147,74 @@ export default function AddonRoute() {
// enabled, failed, no UI, bundle not loaded, no such page). Once the page
// mounts it draws its own title, even to say it is not enabled on this
// server, and the shell draws none.
const unavailable = (reason, extra) => (
<div className="page ad-page" data-testid="addon-unavailable" data-reason={reason}>
<PageHeader title={title} />
<CatalogEmpty icon="puzzle" title={t('shell.unavailable.title')} text={t(`shell.reasons.${REASONS.includes(reason) ? reason : 'import_failed'}`)} role="status">
<div className="ad-shell-actions">
// What the page says depends on what the add-on is: off (installed and
// turned off), not installed, or failed. Anything else keeps the generic
// title and the reason of the shell.
const unavailable = (reason, extra) => {
const known = REASONS.includes(reason) ? reason : 'import_failed'
let heading = t('shell.unavailable.title')
let text = t(`shell.reasons.${known}`)
let acts = extra
let state = ''
const control = appActions(app)
if (known === 'disabled' && app) {
state = 'off'
heading = t('shell.off.title', { name: title })
text = control.start ? t('shell.off.body') : t('shell.off.reader')
const startKey = `start:${addonId}:${app.generation}`
const working = actions.state[startKey]?.phase === 'working'
const outcome = actions.state[startKey]
acts = (
<>
{control.start && (
<button
type="button"
className="dk-btn dk-btn--primary dk-btn--sm"
disabled={working}
data-testid="addon-turn-on"
onClick={() => actions.run(startKey, {
addonId,
submit: (operationId, signal) => addonsApi.apps.start(addonId, { operationId, generation: app.generation }, signal),
verify: async (signal) => (await addonsApi.apps.list(signal)).find(a => a.id === addonId)?.enabled === true,
})}
>
<Icon name={working ? 'spinner' : 'toggle-on'} spin={working} /> {t('apps.start')}
</button>
)}
{outcome && outcome.outcome && describeOutcome(outcome.outcome, t, outcome.error) && (
<span className="ad-card__note" data-tone="error" role="alert">{describeOutcome(outcome.outcome, t, outcome.error)}</span>
)}
</>
)
} else if (known === 'removed' && !app && registry.allowed) {
state = 'not-installed'
heading = t('shell.notInstalled.title', { name: title })
text = isAdmin ? t('shell.notInstalled.body') : t('shell.notInstalled.reader')
acts = isAdmin ? <Link className="dk-btn dk-btn--primary dk-btn--sm" to={`/app/addons?tab=browse&q=${encodeURIComponent(addonId)}`} data-testid="addon-find">{t('drawer.browse')}</Link> : null
} else if (known === 'failed') {
state = 'failed'
heading = t('shell.failedTitle', { name: title })
acts = (
<>
{extra}
{back}
</div>
</CatalogEmpty>
{leaveDialog}
{reloadDialog}
</div>
)
{isAdmin && <Link className="dk-btn dk-btn--ghost dk-btn--sm" to={`/app/addons?detail=${encodeURIComponent(addonId)}`} data-testid="addon-details">{t('card.details')}</Link>}
</>
)
}
return (
<div className="page ad-page ad-page--shell" data-testid="addon-unavailable" data-reason={reason} data-state={state || undefined}>
<PageHeader title={title} />
<CatalogEmpty icon="puzzle" title={heading} text={text} role="status">
<div className="ad-shell-actions">
{acts}
{back}
</div>
</CatalogEmpty>
{leaveDialog}
{reloadDialog}
</div>
)
}
const reloadButton = (
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={onReload} disabled={reloading} data-testid="addon-reload">
{reloading ? t('shell.reloading') : t('shell.reload')}
@@ -168,7 +235,7 @@ export default function AddonRoute() {
if (instance && (instance.status === 'unavailable' || instance.status === 'error')) {
return unavailable(instance.reason || status, reloadButton)
}
if (!isStale && status !== 'ready') return unavailable(status === 'incompatible' ? 'incompatible_sdk' : status, status === 'incompatible' ? null : reloadButton)
if (!isStale && status !== 'ready') return unavailable(status === 'incompatible' ? 'incompatible_sdk' : status, status === 'incompatible' || status === 'disabled' ? null : reloadButton)
// No page in the address: the main page, or the first declared one.
if (!isStale && !subpath) {
@@ -177,7 +244,7 @@ export default function AddonRoute() {
}
if (!resolved && !isStale) {
return (
<div className="page ad-page" data-testid="addon-missing-page">
<div className="page ad-page ad-page--shell" data-testid="addon-missing-page">
<PageHeader title={title} />
<CatalogEmpty icon="puzzle" title={t('shell.pageMissing.title')} text={t('shell.pageMissing.body', { title, page: subpath.split('/')[0] || '' })} role="status">
<div className="ad-shell-actions">{back}</div>
@@ -0,0 +1,20 @@
/* 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'
// The overflow menu of an add-on card or its preview: Show in sidebar,
// Details and Remove. Reordering a pinned add-on lives here too, so the card
// carries no bare arrows. `onDetails` is left out where the details are
// already on screen; `onRemove` is left out for someone who cannot remove.
export default function AddonMenu({ name, pinned, onPin, onMove, onDetails, onRemove, compact = true }) {
const { t } = useTranslation('addons')
const items = [
{ key: 'pin', icon: 'pin', label: pinned ? t('card.hideFromSidebar') : t('card.showInSidebar'), onClick: onPin },
{ key: 'earlier', icon: 'chevron-up', label: t('drawer.moveEarlier', { name }), hidden: !pinned || !onMove, onClick: () => onMove(-1) },
{ key: 'later', icon: 'chevron-down', label: t('drawer.moveLater', { name }), hidden: !pinned || !onMove, onClick: () => onMove(1) },
{ key: 'details', icon: 'info', label: t('card.details'), hidden: !onDetails, onClick: onDetails },
{ divider: true, key: 'divider', hidden: !onRemove },
{ key: 'remove', icon: 'trash', label: t('card.remove'), danger: true, hidden: !onRemove, onClick: onRemove },
]
return <ActionMenu items={items} ariaLabel={t('card.menu', { name })} compact={compact} />
}
@@ -0,0 +1,128 @@
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Icon from '../Icon'
import { apiUrl } from '../../utils/basePath'
import { StateChip, AppIcon, UnknownNotice, UpdateChip } from './AddonBits'
import AddonMenu from './AddonMenu'
import { addedByAddon, appActions, localizedText, screenshotsOf } from './addonState'
import { describeOutcome } from './errors'
// The preview of one add-on, beside the list: what it is, what it adds, and
// what to do with it. It uses what the list already carries. `children` hold
// the operator facts an administrator may open (health, trust, updates).
export default function AddonPreview({ tile, language, title, pinned, onPin, onMove, onClose, actions, onStartStop, onRemove, isAdmin, source, links = [], children }) {
const { t } = useTranslation('addons')
const ref = useRef(null)
const app = tile.app
useEffect(() => { ref.current?.focus({ preventScroll: true }) }, [tile.id])
const onKeyDown = (e) => {
if (e.key === 'Escape' && !e.defaultPrevented) { e.preventDefault(); onClose() }
}
return (
<aside className="ad-pane" ref={ref} tabIndex={-1} data-testid="addon-detail" aria-labelledby="ad-pane-title" onKeyDown={onKeyDown}>
<div className="ad-pane__inner" data-app={tile.id}>
<div className="ad-pane__head">
{tile.source === 'builtin'
? <span className="ad-icon ad-icon--empty" aria-hidden="true"><Icon name={tile.iconName} /></span>
: <AppIcon src={tile.icon ? apiUrl(tile.icon) : ''} />}
<div className="ad-pane__lead">
<h2 className="ad-pane__title" id="ad-pane-title">{title}</h2>
{app && (
<div className="ad-card__chips">
<StateChip app={app} />
{app.update === 'update-pending' && <UpdateChip />}
</div>
)}
</div>
<AddonMenu name={title} pinned={pinned} onPin={onPin} onMove={pinned ? onMove : undefined} onRemove={onRemove} />
<button type="button" className="ad-pane__close" aria-label={t('drawer.close')} onClick={onClose}><Icon name="close" /></button>
</div>
{app ? <PreviewBody app={app} language={language} actions={actions} onStartStop={onStartStop} onRemove={onRemove} isAdmin={isAdmin} source={source} links={links}>{children}</PreviewBody> : <p className="ad-pane__text">{t('drawer.builtinDetail')}</p>}
</div>
</aside>
)
}
function PreviewBody({ app, language, actions, onStartStop, onRemove, isAdmin, source, links, children }) {
const { t } = useTranslation('addons')
const description = localizedText(app, 'description', language)
const added = addedByAddon(app)
const shots = screenshotsOf(app)
const control = appActions(app)
const startKey = `start:${app.id}:${app.generation}`
const stopKey = `stop:${app.id}:${app.generation}`
const pending = Boolean(actions.unknown[app.id])
const working = actions.state[startKey]?.phase === 'working' || actions.state[stopKey]?.phase === 'working'
const locked = working || pending
const result = actions.state[startKey]?.phase !== 'working' ? actions.state[startKey] : null
const stopResult = actions.state[stopKey]?.phase !== 'working' ? actions.state[stopKey] : null
const outcome = result || stopResult
return (
<div className="ad-pane__body">
{description && <p className="ad-pane__text">{description}</p>}
{shots.length > 0 && (
<ul className="ad-shots" data-testid="addon-shots" aria-label={t('preview.screenshots')}>
{shots.map(src => <li key={src}><img src={apiUrl(src)} alt="" loading="lazy" decoding="async" /></li>)}
</ul>
)}
<section className="ad-pane__section" aria-labelledby="ad-pane-adds">
<h3 className="ad-section__title" id="ad-pane-adds">{t('preview.adds')}</h3>
<ul className="ad-adds">
{added.page && <li data-testid="app-page"><Icon name="file" aria-hidden="true" /> <span>{t('preview.page')}</span></li>}
{added.tools > 0 && (
<li data-testid="app-tools">
<Icon name="wrench" aria-hidden="true" />
<span>
{t('card.chatTools', { count: added.tools })}
{added.toolNames.length > 0 && <>{': '}<span className="dk-mono">{added.toolNames.join(', ')}</span>{added.toolsMore > 0 ? ` ${t('apps.toolsMore', { count: added.toolsMore })}` : ''}</>}
</span>
</li>
)}
{!added.page && added.tools === 0 && <li className="ad-quiet">{t('preview.addsNothing')}</li>}
</ul>
{!added.page && <p className="ad-quiet">{t('apps.headless')}</p>}
{links.length > 0 && (
<div className="ad-pane__acts">
{links.map(link => <Link key={link.path} className="dk-btn dk-btn--ghost dk-btn--sm" to={link.path}>{link.label}</Link>)}
</div>
)}
</section>
<div className="ad-pane__acts">
{app.mainPage && (
<Link className="dk-btn dk-btn--primary dk-btn--sm" to={app.mainPage}>
<Icon name="external-link" /> {t('apps.open')}
</Link>
)}
{control.start && (
<button type="button" className={`dk-btn dk-btn--sm${app.mainPage ? '' : ' dk-btn--primary'}`} disabled={locked} onClick={() => onStartStop(app, 'start')}>
<Icon name={working ? 'spinner' : 'toggle-on'} spin={working} /> {t('apps.start')}
</button>
)}
{control.stop && (
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" disabled={locked} onClick={() => onStartStop(app, 'stop')}>
<Icon name={working ? 'spinner' : 'toggle-off'} spin={working} /> {t('apps.stop')}
</button>
)}
{onRemove && (
<button type="button" className="dk-btn dk-btn--danger dk-btn--sm" disabled={locked} onClick={onRemove} data-testid="pane-remove">
<Icon name="trash" /> {t('card.remove')}
</button>
)}
</div>
{working && <p className="ad-card__note" role="status">{t('apps.working')}</p>}
{pending
? <UnknownNotice addonId={app.id} actions={actions} className="ad-card__note" />
: outcome && outcome.outcome && describeOutcome(outcome.outcome, t, outcome.error) && (
<p className="ad-card__note" data-tone="error" role="alert">{describeOutcome(outcome.outcome, t, outcome.error)}</p>
)}
<dl className="dk-kv ad-kv ad-pane__facts" data-testid="preview-facts">
<dt>{t('preview.version')}</dt><dd>{app.version}</dd>
{isAdmin && <><dt>{t('trust.library')}</dt><dd data-testid="preview-source">{source ? `${source.sourceId || '—'}${source.channel ? ` · ${source.channel}` : ''}` : t('preview.sourceUnknown')}</dd></>}
<dt>{t('preview.id')}</dt><dd className="dk-mono">{app.id}</dd>
</dl>
{children}
</div>
)
}
@@ -3,41 +3,63 @@ import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Icon from '../Icon'
import { apiUrl } from '../../utils/basePath'
import { AppIcon } from './AddonBits'
import PinControl from './PinControl'
import { AppIcon, UpdateChip } from './AddonBits'
import AddonMenu from './AddonMenu'
import { addedByAddon } from './addonState'
// One tile of the drawer: icon, name, state and the pin control. A running
// tile opens the add-on; every other tile opens the detail view. The pin
// control is a real button with a name and a state.
export default function AddonTile({ tile, pinned, onPin, onMove, onOpenDetail }) {
// One add-on card: icon, name, state, what it does and what it adds. The card
// body selects the add-on (its preview opens); a running add-on with a page
// also has an Open link. Pinning, reordering, details and removal sit in the
// overflow menu, so nothing on the card is a bare icon.
export default function AddonTile({ tile, pinned, selected = false, onPin, onMove, onOpenDetail, onRemove }) {
const { t } = useTranslation('addons')
const { t: tn } = useTranslation('nav')
const title = tile.titleKey ? tn(tile.titleKey) : tile.title
const stateText = tile.state === 'running' ? t('drawer.running') : t('drawer.stopped')
const line = tile.statusLine ? t(`drawer.status.${tile.statusLine}`) : ''
const body = (
<>
{tile.source === 'builtin'
? <span className="ad-icon ad-icon--empty" aria-hidden="true"><Icon name={tile.iconName} /></span>
: <AppIcon src={tile.icon ? apiUrl(tile.icon) : ''} />}
<span className="ad-tile__text">
<span className="ad-tile__title">{title}</span>
<span className="ad-tile__state" data-state={tile.state}>{stateText}{line ? `, ${line}` : ''}</span>
</span>
</>
)
const added = tile.app ? addedByAddon(tile.app) : { page: tile.source === 'builtin', tools: 0 }
return (
<li className="ad-tile" data-testid="addon-tile" data-app={tile.id} data-state={tile.state}>
{tile.target
? <Link className="ad-tile__open" to={tile.target}>{body}</Link>
: <button type="button" className="ad-tile__open" onClick={() => onOpenDetail(tile.id)} aria-label={`${title}, ${stateText}`}>{body}</button>}
{pinned && (
<span className="ad-tile__order">
<button type="button" className="ad-tile__move" data-testid="pin-earlier" aria-label={t('drawer.moveEarlier', { name: title })} onClick={() => onMove(tile.id, -1)}><Icon name="chevron-up" aria-hidden="true" /></button>
<button type="button" className="ad-tile__move" data-testid="pin-later" aria-label={t('drawer.moveLater', { name: title })} onClick={() => onMove(tile.id, 1)}><Icon name="chevron-down" aria-hidden="true" /></button>
<li className="ad-tile" data-testid="addon-tile" data-app={tile.id} data-state={tile.state} data-selected={selected ? 'true' : undefined}>
<button
type="button"
className="ad-tile__open"
aria-pressed={selected}
aria-label={`${title}, ${stateText}${line ? `, ${line}` : ''}`}
onClick={() => onOpenDetail(tile.id)}
>
{tile.source === 'builtin'
? <span className="ad-icon ad-icon--empty" aria-hidden="true"><Icon name={tile.iconName} /></span>
: <AppIcon src={tile.icon ? apiUrl(tile.icon) : ''} />}
<span className="ad-tile__text">
<span className="ad-tile__title">{title}</span>
<span className="ad-tile__state" data-state={tile.state}>{stateText}{line ? `, ${line}` : ''}</span>
{tile.app && tile.app.update === 'update-pending' && <span className="ad-tile__update"><UpdateChip /></span>}
{tile.description && <span className="ad-tile__desc">{tile.description}</span>}
{(added.page || added.tools > 0) && (
<span className="ad-tile__adds" data-testid="tile-adds">
{added.page && <span className="ad-tile__add"><Icon name="file" aria-hidden="true" /> {t('card.addsPage')}</span>}
{added.tools > 0 && <span className="ad-tile__add"><Icon name="wrench" aria-hidden="true" /> {t('card.chatTools', { count: added.tools })}</span>}
</span>
)}
</span>
</button>
<div className="ad-tile__side">
<AddonMenu
name={title}
pinned={pinned}
onPin={() => onPin(tile.id)}
onMove={pinned ? (delta) => onMove(tile.id, delta) : undefined}
onDetails={() => onOpenDetail(tile.id)}
onRemove={onRemove && tile.app ? () => onRemove(tile) : undefined}
/>
</div>
{tile.target && (
<div className="ad-tile__foot">
<Link className="dk-btn dk-btn--sm" to={tile.target} aria-label={t('card.openNamed', { name: title })}>
<Icon name="external-link" /> {t('apps.open')}
</Link>
</div>
)}
<PinControl name={title} pinned={pinned} onToggle={() => onPin(tile.id)} />
</li>
)
}
@@ -11,15 +11,20 @@ import { navigationItems } from '../../addons/registryState'
import { useAuth } from '../../context/AuthContext'
import { FEATURE_ADDONS } from '../../utils/capabilities'
import SideSheet from '../SideSheet'
import AddonPreview from './AddonPreview'
import AddonSlot from '../../addons/AddonSlot'
import { useHubAuth } from '../hub/useHubAuth'
import AddonTile from './AddonTile'
import PinControl from './PinControl'
import ConfirmDialog from '../ConfirmDialog'
import { useNavigation } from '../../context/NavigationContext'
import { buildTiles, filterTiles, resolvePins } from '../../utils/addonTiles'
import { apiUrl } from '../../utils/basePath'
import { StateChip, AppIcon, UnknownNotice, UpdateChip } from './AddonBits'
import { appActions, localizedText, showFirstRun, toolSummary } from './addonState'
import { UnknownNotice } from './AddonBits'
import { OperatorFacts, UpgradeSummary } from './OperatorFacts'
import { AddonsError } from '../../utils/addonsApi'
import { newOperationId } from '../catalog/catalogState'
import { updateView, upgradeBody } from './updateState'
import FirstRun from './FirstRun'
import { listState } from './addonState'
import { describeError, describeOutcome } from './errors'
import { useAddonActions } from './useAddonActions'
@@ -28,7 +33,7 @@ import { useAddonActions } from './useAddonActions'
// cadence, slower in a hidden tab and backing off while the owner is
// unreachable. Start and stop are offered to administrators and the server
// refuses everyone else whatever this view shows.
export default function AppsView({ api }) {
export default function AppsView({ api, adapter }) {
const { t, i18n } = useTranslation('addons')
const [state, setState] = useState({ status: 'loading', apps: [], error: null, stale: false })
const [reload, setReload] = useState(0)
@@ -82,7 +87,24 @@ export default function AppsView({ api }) {
return () => { alive = false; ctl.abort(); clearTimeout(timer) }
}, [api, reload, canList])
const actions = useAddonActions(api, { onSettled: () => reloadRef.current() })
// The installed record of an add-on, for the administrator's Details in the
// pane. A change reads it again.
const [installedInfo, setInstalledInfo] = useState({})
const [installedReload, setInstalledReload] = useState(0)
const [checks, setChecks] = useState({})
const [upgraded, setUpgraded] = useState({})
const actions = useAddonActions(api, {
onSettled: (key, result) => {
reloadRef.current()
setInstalledReload(n => n + 1)
// The key names the generation the click saw, which the list moves past
// on success, so the outcome of an upgrade is kept by add-on.
const [verb, id] = String(key).split(':')
if (verb === 'upgrade' && result && result.outcome === 'completed') setUpgraded(u => ({ ...u, [id]: true }))
if (verb === 'upgrade' && result && result.outcome !== 'completed') setUpgraded(u => { const n = { ...u }; delete n[id]; return n })
},
adapter,
})
const startStop = useCallback((app, verb) => {
actions.run(`${verb}:${app.id}:${app.generation}`, {
@@ -97,13 +119,63 @@ export default function AppsView({ api }) {
})
}, [actions, api])
// Remove is the typed uninstall action: the data is kept, so installing the
// add-on again picks it up. The owner refuses anyone but an administrator.
const [removing, setRemoving] = useState(null)
const remove = useCallback((tile) => {
actions.run(`uninstall:${tile.id}:${tile.app.generation}`, {
addonId: tile.id,
submit: (operationId, signal) => adapter.submitLifecycle({ operationId, action: 'uninstall', addonId: tile.id, generation: 0 }, signal),
verify: async (signal) => !(await api.installed(signal)).some(a => a.id === tile.id),
})
}, [actions, adapter, api])
// The explicit upgrade: the digest of the installed package this page showed
// and the digest of the candidate the owner's check resolved. The owner
// checks both again and refuses before it changes anything.
const [confirmUpgrade, setConfirmUpgrade] = useState(null)
const upgrade = useCallback((addon) => {
actions.run(`upgrade:${addon.id}:${addon.generation}`, {
addonId: addon.id,
submit: (operationId, signal) => {
const body = upgradeBody(addon, operationId)
if (!body) return Promise.reject(new AddonsError({ kind: 'invalid' }))
return api.updates.upgrade(addon.id, body, signal)
},
verify: async (signal) => {
const now = (await api.installed(signal)).find(a => a.id === addon.id)
return Boolean(now) && now.generation > addon.generation
},
})
}, [actions, api])
// Asks the owner to read the library again. It never applies anything.
const aborter = useRef(null)
useEffect(() => {
aborter.current = new AbortController()
return () => aborter.current.abort()
}, [])
const check = useCallback(async (addon) => {
const signal = aborter.current.signal
setChecks(c => ({ ...c, [addon.id]: { phase: 'working' } }))
try {
const status = await api.updates.check(addon.id, { operationId: newOperationId() }, signal)
setChecks(c => ({ ...c, [addon.id]: { phase: 'done', throttled: Boolean(status && status.throttled) } }))
setInstalledReload(n => n + 1)
} catch (error) {
if (error && error.name === 'AbortError') return
setChecks(c => ({ ...c, [addon.id]: { phase: 'error', error } }))
}
}, [api])
const nav = useNavigation()
// Under the legacy switch the Build hub row draws the app.nav slot instead.
const legacy = useHubAuth().features.addon_navigation === false
const [query, setQuery] = useState('')
const [params, setParams] = useSearchParams()
const [limit, setLimit] = useState(false)
const detailId = params.get('detail') || ''
// The management link of an add-on (?manage=id) opens its Details.
const manageId = isAdmin ? (params.get('manage') || '') : ''
const detailId = params.get('detail') || manageId
const { t: tn } = useTranslation('nav')
const tiles = useMemo(() => buildTiles(state.apps, nav.builtin, { language: i18n.language }), [state.apps, nav.builtin, i18n.language])
@@ -118,13 +190,25 @@ export default function AppsView({ api }) {
}, [tiles, query, titleOf, pinOrder])
const detail = tiles.find(x => x.id === detailId) || null
const detailApp = detail ? detail.app : null
const detailAppId = detailApp ? detailApp.id : ''
const detailAppGeneration = detailApp ? detailApp.generation : 0
useEffect(() => {
if (!isAdmin || !detailAppId) return undefined
const ctl = new AbortController()
api.installed(ctl.signal)
.then(list => setInstalledInfo(Object.fromEntries(list.map(a => [a.id, a]))))
.catch(() => { /* the pane says the source is not known */ })
return () => ctl.abort()
}, [api, isAdmin, detailAppId, detailAppGeneration, installedReload])
const onPin = async (id) => {
if (pinnedIds.has(id)) { await nav.unpin(id); return }
const result = await nav.pin(id)
if (result.needsUnpin) setLimit(true)
}
const openDetail = (id) => setParams(prev => { const out = new URLSearchParams(prev); out.set('detail', id); return out })
const closeDetail = () => setParams(prev => { const out = new URLSearchParams(prev); out.delete('detail'); return out }, { replace: true })
const openDetail = (id) => setParams(prev => { const out = new URLSearchParams(prev); out.set('detail', id); out.delete('manage'); return out })
const closeDetail = () => setParams(prev => { const out = new URLSearchParams(prev); out.delete('detail'); out.delete('manage'); return out }, { replace: true })
if (state.status === 'loading') {
return <div className="op-loading" role="status" aria-label={t('apps.loading')}><LoadingSpinner size="lg" /></div>
@@ -141,6 +225,8 @@ export default function AppsView({ api }) {
)
}
const view = listState({ apps: state.apps, shownCount: shown.length, query, isAdmin, canList })
const firstRun = view === 'first-run'
return (
<div className="ad-apps">
{state.stale && (
@@ -149,59 +235,104 @@ export default function AppsView({ api }) {
<span>{t('apps.stale')}</span>
</div>
)}
<div className="ad-drawer__bar">
<input
type="search"
className="dk-input ad-drawer__search"
data-testid="addon-search"
aria-label={t('drawer.search')}
placeholder={t('drawer.search')}
value={query}
onChange={e => setQuery(e.target.value)}
/>
{isAdmin && (
<>
<Link className="dk-btn dk-btn--sm" to="/app/addons?tab=browse" data-testid="addons-browse"><Icon name="search" /> {t('drawer.browse')}</Link>
{Object.entries(actions.state).filter(([key, r]) => key.startsWith('uninstall:') && r.outcome && r.outcome !== 'completed' && !actions.unknown[key.split(':')[1]]).map(([key, r]) => (
<p key={key} className="ad-card__note" data-tone="error" role="alert" data-testid="remove-error">{describeOutcome(r.outcome, t, r.error)}</p>
))}
{Object.keys(actions.unknown).filter(id => actions.unknown[id].key.startsWith('uninstall:')).map(id => (
<UnknownNotice key={id} addonId={id} actions={actions} className="ad-card__note" />
))}
<div className="ad-split" data-open={detail ? 'true' : 'false'}>
<div className="ad-split__list">
<div className="ad-drawer__bar">
<input
type="search"
className="dk-input ad-drawer__search"
data-testid="addon-search"
aria-label={t('drawer.search')}
placeholder={t('drawer.search')}
value={query}
onChange={e => setQuery(e.target.value)}
/>
{/* One way into Browse per screen: the first-run card carries it while
nothing is installed, and the bar carries it afterwards. */}
{isAdmin && !firstRun && (
<Link className="dk-btn dk-btn--primary dk-btn--sm" to="/app/addons?tab=browse" data-testid="addons-add"><Icon name="plus" /> {t('drawer.add')}</Link>
</>
)}
</div>
{view === 'first-run' ? (
<FirstRun adapter={adapter} actions={actions} />
) : view === 'no-match' ? (
<CatalogEmpty icon="search" title={t('apps.noMatchTitle')} text={t('apps.noMatchBody', { query: query.trim() })} role="status">
<div className="ad-section__acts">
<button type="button" className="dk-btn dk-btn--sm" onClick={() => setQuery('')}>{t('apps.clearSearch')}</button>
{isAdmin && <Link className="dk-btn dk-btn--ghost dk-btn--sm" to="/app/addons?tab=browse">{t('drawer.browse')}</Link>}
</div>
</CatalogEmpty>
) : view === 'none' ? (
<CatalogEmpty icon="puzzle" title={t('apps.emptyTitle')} text={t('apps.emptyBody')} />
) : (
<ul className="ad-tiles" aria-label={t('apps.listLabel')}>
{shown.map(tile => (
<AddonTile key={tile.id} tile={tile} pinned={pinnedIds.has(tile.id)} selected={tile.id === detailId} onPin={onPin} onMove={nav.move} onOpenDetail={openDetail} onRemove={isAdmin ? setRemoving : undefined} />
))}
</ul>
)}
</div>
{detail && (
<AddonPreview
tile={detail}
title={titleOf(detail)}
language={i18n.language}
pinned={pinnedIds.has(detail.id)}
onPin={() => onPin(detail.id)}
onMove={(delta) => nav.move(detail.id, delta)}
onClose={() => { const id = detail.id; closeDetail(); requestAnimationFrame(() => document.querySelector(`[data-testid="addon-tile"][data-app="${CSS.escape(id)}"] .ad-tile__open`)?.focus()) }}
actions={actions}
onStartStop={startStop}
onRemove={isAdmin && detail.app ? () => setRemoving(detail) : undefined}
isAdmin={isAdmin}
source={installedInfo[detail.id]}
links={detail.app ? navigationItems([detail.app], { language: i18n.language }) : []}
>
{isAdmin && installedInfo[detail.id] && (
<details className="ad-pane__more" open={manageId === detail.id} data-testid="addon-more">
<summary>{t('preview.moreDetails')}</summary>
<OperatorFacts
addon={installedInfo[detail.id]}
api={api}
language={i18n.language}
actions={actions}
check={checks[detail.id]}
upgraded={Boolean(upgraded[detail.id])}
onCheck={() => check(installedInfo[detail.id])}
onUpgrade={() => setConfirmUpgrade(installedInfo[detail.id])}
/>
</details>
)}
</AddonPreview>
)}
</div>
{showFirstRun({ apps: state.apps, isAdmin, canList }) ? (
<section className="ad-card ad-first" data-testid="addons-first-run" aria-labelledby="ad-first-title">
<h2 className="ad-card__title" id="ad-first-title">{t('apps.firstRun.title')}</h2>
<ol className="ad-first__steps">
<li><strong>{t('apps.firstRun.libraryTitle')}</strong> {t('apps.firstRun.libraryBody')}</li>
<li><strong>{t('apps.firstRun.browseTitle')}</strong> {t('apps.firstRun.browseBody')}</li>
<li><strong>{t('apps.firstRun.startTitle')}</strong> {t('apps.firstRun.startBody')}</li>
</ol>
<div className="ad-section__acts">
<Link className="dk-btn dk-btn--primary" to="/app/addons?tab=browse" data-testid="addons-first-run-browse"><Icon name="store" /> {t('apps.firstRun.browse')}</Link>
</div>
</section>
) : shown.length === 0 ? (
<CatalogEmpty icon="puzzle" title={t('apps.emptyTitle')} text={t('apps.emptyBody')} />
) : (
<ul className="ad-tiles" aria-label={t('apps.listLabel')}>
{shown.map(tile => (
<AddonTile key={tile.id} tile={tile} pinned={pinnedIds.has(tile.id)} onPin={onPin} onMove={nav.move} onOpenDetail={openDetail} />
))}
</ul>
)}
{!legacy && <div className="ad-nav-slot"><AddonSlot slot="app.nav" context={null} /></div>}
{detail && (
<SideSheet title={titleOf(detail)} onClose={closeDetail} headActions={<PinControl variant="head" name={titleOf(detail)} pinned={pinnedIds.has(detail.id)} onToggle={() => onPin(detail.id)} />} closeLabel={t('drawer.close')} testId="addon-detail">
{detail.app
? (
<>
<ul className="ad-grid"><AppCard app={detail.app} t={t} language={i18n.language} actions={actions} onStartStop={startStop} /></ul>
{navigationItems([detail.app], { language: i18n.language }).map(link => (
<Link key={link.path} className="dk-btn dk-btn--ghost dk-btn--sm" to={link.path}>{link.label}</Link>
))}
</>
)
: <p>{t('drawer.builtinDetail')}</p>}
</SideSheet>
<ConfirmDialog
open={Boolean(removing)}
danger
title={removing ? t('card.removeTitle', { name: removing.title }) : ''}
message={removing ? <p>{t('card.removeBody', { name: removing.title })}</p> : null}
confirmLabel={t('card.remove')}
onConfirm={() => { const x = removing; setRemoving(null); closeDetail(); remove(x) }}
onCancel={() => setRemoving(null)}
/>
{manageId && state.status === 'ready' && !detail && (
<div className="op-notice" role="status"><Icon name="info" /><span>{t('installed.missing', { id: manageId })}</span></div>
)}
<ConfirmDialog
open={Boolean(confirmUpgrade)}
title={confirmUpgrade ? t('updates.confirm.title', { name: confirmUpgrade.title, version: updateView(confirmUpgrade.update).version }) : ''}
message={confirmUpgrade ? <UpgradeSummary addon={confirmUpgrade} t={t} /> : null}
confirmLabel={t('updates.confirm.action')}
onConfirm={() => { const a = confirmUpgrade; setConfirmUpgrade(null); upgrade(a) }}
onCancel={() => setConfirmUpgrade(null)}
/>
{limit && (
<SideSheet title={t('drawer.limitTitle')} onClose={() => setLimit(false)} closeLabel={t('drawer.close')} testId="pin-limit">
<p>{t('drawer.limitBody')}</p>
@@ -210,83 +341,3 @@ export default function AppsView({ api }) {
</div>
)
}
function AppCard({ app, t, language, actions, onStartStop }) {
const title = localizedText(app, 'title', language)
const description = localizedText(app, 'description', language)
const control = appActions(app)
const tools = toolSummary(app)
const startKey = `start:${app.id}:${app.generation}`
const stopKey = `stop:${app.id}:${app.generation}`
const pending = Boolean(actions.unknown[app.id])
const working = actions.state[startKey]?.phase === 'working' || actions.state[stopKey]?.phase === 'working'
const locked = working || pending
const result = actions.state[startKey]?.phase !== 'working' ? actions.state[startKey] : null
const stopResult = actions.state[stopKey]?.phase !== 'working' ? actions.state[stopKey] : null
const outcome = result || stopResult
const titleId = `app-${app.id}`
return (
<li className="ad-card" data-app={app.id} aria-labelledby={titleId}>
<div className="ad-card__head">
<AppIcon src={app.icon ? apiUrl(app.icon) : ''} />
<div className="ad-card__lead">
<h3 className="ad-card__title" id={titleId}>{title}</h3>
<p className="ad-card__meta">
<span className="dk-mono">{app.id}</span>
<span aria-hidden="true">·</span>
<span>{t('apps.version', { version: app.version })}</span>
</p>
</div>
<div className="ad-card__chips">
<StateChip app={app} />
{app.update === 'update-pending' && <UpdateChip />}
</div>
</div>
{description && <p className="ad-card__text">{description}</p>}
{!app.mainPage && tools.count > 0 && (
<p className="ad-card__tools" data-testid="app-tools">
<Icon name="wrench" aria-hidden="true" />
<span>
{t('apps.tools', { count: tools.count })}
{': '}
<span className="dk-mono">{tools.shown.join(', ')}</span>
{tools.more > 0 ? ` ${t('apps.toolsMore', { count: tools.more })}` : ''}
</span>
</p>
)}
{!app.mainPage && !app.hasUI && <p className="ad-card__quiet">{t('apps.headless')}</p>}
<div className="ad-card__acts">
{app.mainPage && (
<Link className="dk-btn dk-btn--primary dk-btn--sm" to={app.mainPage}>
<Icon name="external-link" /> {t('apps.open')}
</Link>
)}
{control.start && (
<button type="button" className="dk-btn dk-btn--sm" disabled={locked} onClick={() => onStartStop(app, 'start')}>
<Icon name={working ? 'spinner' : 'play'} spin={working} /> {t('apps.start')}
</button>
)}
{control.stop && (
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" disabled={locked} onClick={() => onStartStop(app, 'stop')}>
<Icon name={working ? 'spinner' : 'stop'} spin={working} /> {t('apps.stop')}
</button>
)}
{control.manage && (
<Link className="dk-btn dk-btn--ghost dk-btn--sm" to={control.manage}>
<Icon name="settings" /> {t('apps.manage')}
</Link>
)}
</div>
{working && <p className="ad-card__note" role="status">{t('apps.working')}</p>}
{pending
? <UnknownNotice addonId={app.id} actions={actions} className="ad-card__note" />
: outcome && outcome.outcome && <ActionResult outcome={outcome} t={t} />}
</li>
)
}
function ActionResult({ outcome, t }) {
const text = describeOutcome(outcome.outcome, t, outcome.error)
if (!text) return null
return <p className="ad-card__note" data-tone="error" role="alert">{text}</p>
}
@@ -4,18 +4,20 @@ import { useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Icon from '../Icon'
import ConfirmDialog from '../ConfirmDialog'
import SideSheet from '../SideSheet'
import CatalogView from '../catalog/CatalogView'
import { paramsToQuery, queryToParams } from '../catalog/catalogState'
import { AppIcon, Fact, InstallSteps, UnknownNotice } from './AddonBits'
import FlowStatus from './InstallFlow'
import InstallDialog from './InstallDialog'
import { shortDigest } from './addonState'
import { updateView } from './updateState'
import { describeError, describeOutcome } from './errors'
import { useAddonActions } from './useAddonActions'
// Library entries for this host. Install does not activate: the add-on is
// installed and stays disabled until an administrator enables it on the
// Installed tab, so every step that starts a process is its own decision.
// Library entries for this host. An add-on entry has one button, Install and
// turn on: one confirmation, then the install and the turn on run one after
// the other. An add-on that is installed but off offers Turn on. A backend has
// no on and off, so its button stays Install.
export default function BrowseView({ api, adapter }) {
const { t } = useTranslation('addons')
const [params, setParams] = useSearchParams()
@@ -26,7 +28,7 @@ export default function BrowseView({ api, adapter }) {
const [backendWork, setBackendWork] = useState({})
const [reload, setReload] = useState(0)
const [confirm, setConfirm] = useState(null)
const [details, setDetails] = useState(null)
const [dialog, setDialog] = useState(null)
const query = paramsToQuery(k => params.get(k))
const onQueryChange = useCallback((next) => {
@@ -55,7 +57,7 @@ export default function BrowseView({ api, adapter }) {
return () => ctl.abort()
}, [api, reload])
const actions = useAddonActions(api, { onSettled: () => setReload(n => n + 1) })
const actions = useAddonActions(api, { onSettled: () => setReload(n => n + 1), adapter })
const installBackend = async (entry) => {
setConfirm(null)
@@ -73,26 +75,21 @@ export default function BrowseView({ api, adapter }) {
setReload(n => n + 1)
}
const install = (entry) => {
if (entry.kind === 'backend') return installBackend(entry)
setConfirm(null)
actions.run(`install:${entry.key}`, {
addonId: entry.id,
submit: (operationId, signal) => adapter.submitLifecycle({ operationId, action: 'install', addonId: entry.id, sourceId: entry.sourceId, version: entry.version }, signal),
// A completed status is not proof for this click: the owner answers a
// replayed operation with its old status and applies nothing.
verify: async (signal) => (await api.installed(signal)).some(a => a.id === entry.id),
})
}
// A backend has its own job and its own confirmation. An add-on goes through
// the one dialog of Install and turn on, which stays open for the steps.
const install = (entry) => installBackend(entry)
const openInstall = (entry) => { if (entry.kind === 'backend') setConfirm(entry); else setDialog(entry) }
const renderEntry = (entry) => {
const isBackend = entry.kind === 'backend'
const have = isBackend ? installedBackends[entry.id] : installed[entry.id]
const key = `install:${entry.key}`
const bw = isBackend ? backendWork[key] : null
const st = isBackend ? null : actions.state[key]
const flow = isBackend ? null : actions.flows[`flow:${entry.key}`]
const pending = !isBackend && Boolean(actions.unknown[entry.id])
const working = isBackend ? bw?.phase === 'working' : st?.phase === 'working'
const working = isBackend ? bw?.phase === 'working' : ['install', 'turn-on'].includes(flow?.step)
const addsPage = Boolean(entry.ui && entry.ui.mainPage)
const off = !isBackend && have && have.enabled === false
return (
<article className="ad-entry" aria-labelledby={`entry-${entry.key}`}>
<AppIcon src="" />
@@ -107,21 +104,16 @@ export default function BrowseView({ api, adapter }) {
</p>
{entry.description && <p className="ad-entry__text">{entry.description}</p>}
<div className="ad-facts">
<Fact icon={isBackend ? 'cpu' : 'puzzle'}><span data-testid="entry-kind" data-kind={entry.kind}>{t(`browse.kind.${entry.kind}`, { defaultValue: entry.kind })}</span></Fact>
<Fact icon="cpu">{entry.platform.os}/{entry.platform.arch}</Fact>
<Fact icon={entry.policy.requireSignature ? 'shield' : 'info'} tone={entry.policy.requireSignature ? 'ok' : undefined}>
{entry.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}
</Fact>
{entry.channel && <Fact icon="tag">{entry.channel}</Fact>}
{isBackend && <Fact icon="cpu"><span data-testid="entry-kind" data-kind={entry.kind}>{t(`browse.kind.${entry.kind}`, { defaultValue: entry.kind })}</span></Fact>}
{!isBackend && addsPage && <Fact icon="file"><span data-testid="entry-adds">{t('card.addsPage')}</span></Fact>}
{isBackend && have && <Fact icon="check-circle" tone="ok">{t('browse.installedBackend')}</Fact>}
{!isBackend && have && <Fact icon="check-circle" tone="ok">{have.version === entry.version ? t('browse.installedSame', { version: have.version }) : t('browse.installedOther', { version: have.version })}</Fact>}
{!isBackend && have && updateView(have.update).state === 'update-available' && <Fact icon="arrow-up" tone="warn">{t('updates.badge.available', { version: updateView(have.update).version })}</Fact>}
</div>
<EntryFacts entry={entry} have={have} isBackend={isBackend} />
{pending && <UnknownNotice addonId={entry.id} actions={actions} />}
{!pending && st && !working && st.outcome !== 'completed' && (
<p className="ad-entry__note" data-tone="error" role="alert">{describeOutcome(st.outcome, t, st.error)}</p>
)}
{st && st.outcome === 'completed' && <p className="ad-entry__note" data-tone="ok" role="status">{t('browse.installedNotEnabled')}</p>}
{!pending && !(dialog && dialog.key === entry.key) && <FlowStatus flow={flow} name={entry.title} onRetry={flow?.step === 'failed' ? () => actions.installAndTurnOn(entry, { skipInstall: flow.failedStep === 'turn-on' }) : undefined} />}
{flow && flow.step === 'done' && !(dialog && dialog.key === entry.key) && <p className="ad-entry__note" data-tone="ok" role="status">{t('browse.installedOn', { name: entry.title })}</p>}
{bw && bw.phase !== 'done' && <InstallSteps job={bw.job} signed={entry.policy.requireSignature} />}
{bw?.phase === 'failed' && (
<p className="ad-entry__note" data-tone="error" role="alert">
@@ -131,12 +123,21 @@ export default function BrowseView({ api, adapter }) {
{bw?.phase === 'done' && <p className="ad-entry__note" data-tone="ok" role="status">{t('browse.installedBackendNote')}</p>}
</div>
<div className="ad-entry__acts">
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={() => setDetails(entry)}>
<Icon name="info" /> {t('browse.details')}
</button>
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={working || pending || Boolean(have)} onClick={() => setConfirm(entry)}>
<Icon name={working ? 'spinner' : 'download'} spin={working} /> {working ? t('browse.installing') : t('browse.install')}
</button>
{isBackend && !have && (
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={working} onClick={() => setConfirm(entry)}>
<Icon name={working ? 'spinner' : 'download'} spin={working} /> {working ? t('browse.installing') : t('browse.install')}
</button>
)}
{!isBackend && !have && (
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={working || pending} onClick={() => openInstall(entry)}>
<Icon name={working ? 'spinner' : 'download'} spin={working} /> {t('flow.installAndTurnOn')}
</button>
)}
{off && (
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={working || pending} onClick={() => actions.installAndTurnOn(entry, { skipInstall: true })}>
<Icon name={working ? 'spinner' : 'toggle-on'} spin={working} /> {t('apps.start')}
</button>
)}
</div>
</article>
)
@@ -155,42 +156,36 @@ export default function BrowseView({ api, adapter }) {
emptyTitle={t('browse.emptyTitle')}
emptyText={t('browse.emptyBody')}
/>
{details && <EntryDetails adapter={adapter} entry={details} onClose={() => setDetails(null)} />}
<InstallDialog entry={dialog} flow={dialog ? actions.flows[`flow:${dialog.key}`] : undefined} actions={actions} onClose={() => setDialog(null)} />
<ConfirmDialog
open={Boolean(confirm)}
title={confirm ? t('browse.confirmTitle', { name: confirm.title }) : ''}
message={confirm ? (
<div className="ad-confirm">
<p>{confirm.kind === 'backend' ? t('browse.confirmBackendBody', { version: confirm.version, library: confirm.sourceId }) : t('browse.confirmBody', { version: confirm.version, library: confirm.sourceId })}</p>
<p>{t('browse.confirmBackendBody', { version: confirm.version, library: confirm.sourceId })}</p>
<dl className="dk-kv">
<dt>{t('trust.digest')}</dt><dd className="dk-mono">{shortDigest(confirm.digest)}</dd>
<dt>{t('trust.verification')}</dt><dd>{confirm.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}</dd>
</dl>
<p>{confirm.kind === 'backend' ? t('browse.confirmBackendNote') : t('browse.confirmNotEnabled')}</p>
<p>{t('browse.confirmBackendNote')}</p>
</div>
) : null}
confirmLabel={t('browse.install')}
onConfirm={() => install(confirm)}
onConfirm={() => { const e = confirm; setConfirm(null); install(e) }}
onCancel={() => setConfirm(null)}
/>
</>
)
}
function EntryDetails({ adapter, entry, onClose }) {
// The details of an entry, folded into the entry itself: what it is made for,
// how it is verified and which build it is. They come from the library entry
// the list already has.
function EntryFacts({ entry: e, have, isBackend }) {
const { t } = useTranslation('addons')
const [state, setState] = useState({ status: 'loading', entry: null, error: null })
useEffect(() => {
const ctl = new AbortController()
adapter.details(entry.key, ctl.signal)
.then(full => setState({ status: 'ready', entry: full, error: null }))
.catch(error => { if (!ctl.signal.aborted) setState({ status: 'error', entry: null, error }) })
return () => ctl.abort()
}, [adapter, entry.key])
const e = state.entry || entry
return (
<SideSheet title={e.title} description={t('browse.detailsLead', { id: e.id })} onClose={onClose} closeLabel={t('catalog.close')} labelId="entry-details-title">
{state.status === 'error' && <p className="op-error" role="alert">{describeError(state.error, t)}</p>}
<details className="ad-entry__details" data-testid="entry-details">
<summary>{t('browse.details')}</summary>
<dl className="dk-kv ad-kv">
<dt>{t('browse.kindLabel')}</dt><dd data-testid="details-kind">{t(`browse.kind.${e.kind}`, { defaultValue: e.kind })}</dd>
<dt>{t('trust.version')}</dt><dd>{e.version}</dd>
@@ -201,12 +196,8 @@ function EntryDetails({ adapter, entry, onClose }) {
<dt>{t('trust.verification')}</dt><dd>{e.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}</dd>
{e.policy.requireSignature && (<><dt>{t('trust.issuer')}</dt><dd className="dk-mono ad-wrap">{e.policy.issuer}</dd><dt>{t('trust.identity')}</dt><dd className="dk-mono ad-wrap">{e.policy.identity || e.policy.identityRegex}</dd></>)}
<dt>{t('trust.digest')}</dt><dd className="dk-mono ad-wrap">{e.digest}</dd>
{e.installed && (<><dt>{t('browse.installedState')}</dt><dd>{t('browse.installedDetail', { version: e.installed.version, generation: e.installed.generation })}</dd></>)}
{!isBackend && have && (<><dt>{t('browse.installedState')}</dt><dd>{t('browse.installedDetail', { version: have.version, generation: have.generation })}</dd></>)}
</dl>
{e.installed && updateView(e.installed.update).state === 'update-available' && (
<p className="ad-entry__note" role="status" data-testid="details-update">{t('updates.browseUpdate', { version: updateView(e.installed.update).version })}</p>
)}
{e.description && <p className="ad-entry__text">{e.description}</p>}
</SideSheet>
</details>
)
}
@@ -0,0 +1,71 @@
/* 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 Icon from '../Icon'
import LoadingSpinner from '../LoadingSpinner'
import { AppIcon } from './AddonBits'
import FlowStatus from './InstallFlow'
import InstallDialog from './InstallDialog'
import { featuredEntries } from './addonState'
import { describeError } from './errors'
// Nothing is installed yet. An administrator sees three add-ons from the
// sources, each with one button that installs it and turns it on, and a link
// to everything else.
export default function FirstRun({ adapter, actions }) {
const { t } = useTranslation('addons')
const [state, setState] = useState({ status: 'loading', entries: [], error: null })
const [confirm, setConfirm] = useState(null)
useEffect(() => {
const ctl = new AbortController()
adapter.search({ text: '', sourceId: '', page: 1 }, ctl.signal)
.then(out => setState({ status: 'ready', entries: featuredEntries(out.entries), error: null }))
.catch(error => { if (!ctl.signal.aborted) setState({ status: 'error', entries: [], error }) })
return () => ctl.abort()
}, [adapter])
const flowOf = (entry) => actions.flows[`flow:${entry.key}`]
const busy = (entry) => ['install', 'turn-on'].includes(flowOf(entry)?.step) || Boolean(actions.unknown[entry.id])
return (
<section className="ad-card ad-first" data-testid="addons-first-run" aria-labelledby="ad-first-title">
<h2 className="ad-card__title" id="ad-first-title">{t('apps.firstRun.title')}</h2>
<p className="ad-card__text">{t('apps.firstRun.lead')}</p>
{state.status === 'loading' && <div className="op-loading" role="status" aria-label={t('catalog.loading')}><LoadingSpinner /></div>}
{state.status === 'error' && <p className="ad-card__note" data-tone="error" role="alert">{t('apps.firstRun.featuredFailed')} {describeError(state.error, t)}</p>}
{state.status === 'ready' && state.entries.length === 0 && <p className="ad-card__note" data-testid="first-run-none">{t('apps.firstRun.noFeatured')}</p>}
{state.entries.length > 0 && (
<ul className="ad-featured" aria-label={t('apps.firstRun.featuredLabel')} data-testid="featured">
{state.entries.map(entry => (
<li key={entry.key} className="ad-featured__item" data-addon={entry.id}>
<div className="ad-card__head">
<AppIcon src="" />
<div className="ad-card__lead">
<h3 className="ad-card__title">{entry.title}</h3>
<p className="ad-card__meta">{t('apps.version', { version: entry.version })}</p>
</div>
</div>
{entry.description && <p className="ad-card__text">{entry.description}</p>}
{entry.ui && entry.ui.mainPage && <p className="ad-tile__adds"><span className="ad-tile__add"><Icon name="file" aria-hidden="true" /> {t('card.addsPage')}</span></p>}
<FlowStatus flow={confirm && confirm.key === entry.key ? undefined : flowOf(entry)} name={entry.title} onRetry={flowOf(entry)?.step === 'failed' ? () => actions.installAndTurnOn(entry, { skipInstall: flowOf(entry).failedStep === 'turn-on' }) : undefined} />
<div className="ad-card__acts">
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={busy(entry)} onClick={() => setConfirm(entry)}>
<Icon name={busy(entry) ? 'spinner' : 'download'} spin={busy(entry)} /> {t('flow.installAndTurnOn')}
</button>
</div>
</li>
))}
</ul>
)}
<div className="ad-section__acts">
<Link className="dk-btn dk-btn--sm" to="/app/addons?tab=browse" data-testid="addons-first-run-browse"><Icon name="store" /> {t('apps.firstRun.browse')}</Link>
{state.status === 'ready' && state.entries.length === 0 && (
<Link className="dk-btn dk-btn--ghost dk-btn--sm" to="/app/addons?tab=libraries">{t('tabs.libraries')}</Link>
)}
</div>
<InstallDialog entry={confirm} flow={confirm ? actions.flows[`flow:${confirm.key}`] : undefined} actions={actions} onClose={() => setConfirm(null)} />
</section>
)
}
@@ -0,0 +1,115 @@
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
import { useEffect, useRef } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Icon from '../Icon'
import { pushLayer } from '../../utils/modalLayers'
import { UnknownNotice } from './AddonBits'
import { shortDigest } from './addonState'
import { describeOutcome } from './errors'
// The one dialog of Install and turn on. It asks once, then stays open and
// shows the two steps while they run: the install, then the turn on. It ends
// on the result, or on the reason a step failed with a way to try again.
export default function InstallDialog({ entry, flow, actions, onClose }) {
const { t } = useTranslation('addons')
const ref = useRef(null)
const step = flow ? flow.step : 'ask'
const running = step === 'install' || step === 'turn-on'
const closeRef = useRef(onClose)
closeRef.current = onClose
useEffect(() => {
if (!entry) return undefined
const opener = document.activeElement
const layer = pushLayer()
const dialog = ref.current
dialog?.querySelector('button:not([disabled])')?.focus()
const onKey = (e) => {
if (!layer.isTop()) return
if (e.key === 'Escape') {
if (dialog?.dataset.running === 'true') return
e.preventDefault(); closeRef.current(); return
}
if (e.key !== 'Tab' || !dialog) return
const items = Array.from(dialog.querySelectorAll('button:not([disabled]), a[href]'))
if (items.length === 0) return
const first = items[0]
const last = items[items.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)
layer.pop()
if (opener && opener !== document.body && document.contains(opener)) opener.focus?.()
}
}, [entry])
if (!entry) return null
const start = () => actions.installAndTurnOn(entry)
const retry = () => actions.installAndTurnOn(entry, { skipInstall: flow.failedStep === 'turn-on' })
const stepState = (id) => {
if (step === 'ask') return 'pending'
if (step === 'done') return 'done'
if (id === 'install') return step === 'install' ? 'active' : step === 'failed' && flow.failedStep === 'install' ? 'failed' : 'done'
return step === 'turn-on' ? 'active' : step === 'failed' && flow.failedStep === 'turn-on' ? 'failed' : 'pending'
}
const reason = step === 'failed' ? describeOutcome(flow.outcome, t, flow.error) : ''
return (
<div className="confirm-dialog-backdrop" onClick={running ? undefined : onClose}>
<div ref={ref} className="confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="install-dialog-title" data-testid="install-dialog" data-running={running ? 'true' : 'false'} data-step={step} onClick={(e) => e.stopPropagation()}>
<div className="confirm-dialog-header">
<span id="install-dialog-title" className="confirm-dialog-title">{t('flow.confirmTitle', { name: entry.title })}</span>
</div>
<div className="confirm-dialog-body ad-confirm">
{step === 'ask' && (
<>
<p>{t('flow.confirmBody', { version: entry.version, library: entry.sourceId })}</p>
<dl className="dk-kv">
<dt>{t('trust.digest')}</dt><dd className="dk-mono">{shortDigest(entry.digest)}</dd>
<dt>{t('trust.verification')}</dt><dd>{entry.policy.requireSignature ? t('trust.signatureRequired') : t('trust.digestOnly')}</dd>
</dl>
</>
)}
{step !== 'ask' && (
<ol className="ad-steps ad-steps--stack" data-testid="flow-steps" aria-label={t('install.stepsLabel')}>
{['install', 'turn-on'].map(id => {
const state = stepState(id)
return (
<li key={id} className="ad-steps__item" data-step={id} data-state={state}>
<Icon name={state === 'done' ? 'check-circle' : state === 'failed' ? 'alert-circle' : state === 'active' ? 'spinner' : 'circle'} spin={state === 'active'} />
<span>{t(`flow.step.${id}`)}</span>
<span className="dk-sr-only">{t(`install.state.${state}`)}</span>
</li>
)
})}
</ol>
)}
{step === 'done' && (
<p className="ad-entry__note" data-tone="ok" role="status">
{t('browse.installedOn', { name: entry.title })}
{entry.ui && entry.ui.mainPage && <> <Link to="/app/addons">{t('flow.seeInList')}</Link></>}
</p>
)}
{step === 'failed' && (
<p className="ad-entry__note" data-tone="error" role="alert" data-testid="install-dialog-failed">
{flow.failedStep === 'install' ? t('flow.installFailed', { name: entry.title }) : t('flow.turnOnFailed', { name: entry.title })}
{reason ? ` ${reason}` : ''}
</p>
)}
{step === 'failed' && actions.unknown[entry.id] && <UnknownNotice addonId={entry.id} actions={actions} />}
</div>
<div className="confirm-dialog-actions">
{step === 'ask' && <button type="button" className="btn btn-secondary btn-sm" onClick={onClose}>{t('catalog.cancel')}</button>}
{step === 'ask' && <button type="button" className="btn btn-primary btn-sm" onClick={start}>{t('flow.installAndTurnOn')}</button>}
{running && <button type="button" className="btn btn-primary btn-sm" disabled>{step === 'install' ? t('flow.installing', { name: entry.title }) : t('flow.turningOn', { name: entry.title })}</button>}
{step === 'failed' && <button type="button" className="btn btn-secondary btn-sm" onClick={onClose}>{t('catalog.close')}</button>}
{step === 'failed' && !actions.unknown[entry.id] && <button type="button" className="btn btn-primary btn-sm" onClick={retry}>{t('catalog.retry')}</button>}
{step === 'done' && <button type="button" className="btn btn-primary btn-sm" onClick={onClose}>{t('catalog.close')}</button>}
</div>
</div>
</div>
)
}
@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next'
import Icon from '../Icon'
import { describeOutcome } from './errors'
// Where an "Install and turn on" is, drawn inline under the add-on it works
// on. The text says it; the icon only supports it.
export default function FlowStatus({ flow, name, onRetry }) {
const { t } = useTranslation('addons')
if (!flow || flow.step === 'done') return null
if (flow.step === 'install' || flow.step === 'turn-on') {
return (
<p className="ad-flow" role="status" data-testid="flow-progress" data-step={flow.step}>
<Icon name="spinner" spin /> {flow.step === 'install' ? t('flow.installing', { name }) : t('flow.turningOn', { name })}
</p>
)
}
const reason = describeOutcome(flow.outcome, t, flow.error)
return (
<div className="ad-flow ad-flow--failed" role="alert" data-testid="flow-failed" data-step={flow.failedStep}>
<span>
{flow.failedStep === 'install' ? t('flow.installFailed', { name }) : t('flow.turnOnFailed', { name })}
{reason ? ` ${reason}` : ''}
</span>
{onRetry && (
<button type="button" className="dk-btn dk-btn--sm" onClick={onRetry}>
<Icon name="refresh" /> {t('catalog.retry')}
</button>
)}
</div>
)
}
@@ -1,423 +0,0 @@
/* 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, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Icon from '../Icon'
import LoadingSpinner from '../LoadingSpinner'
import ConfirmDialog from '../ConfirmDialog'
import SideSheet from '../SideSheet'
import CatalogEmpty from '../catalog/CatalogEmpty'
import { apiUrl } from '../../utils/basePath'
import { StateChip, AppIcon, Fact, UnknownNotice } from './AddonBits'
import { formatBytes, rangeText, shortDigest } from './addonState'
import { describeError, describeOutcome } from './errors'
import { useAddonActions } from './useAddonActions'
import { updateView, upgradeBody, formatWhen, UPDATE_TYPES } from './updateState'
import { newOperationId } from '../catalog/catalogState'
import { AddonsError } from '../../utils/addonsApi'
import RecoveryPanel from './RecoveryPanel'
// Everything the owner knows of what is installed: verified package facts,
// the desired state (enabled) and the observed one (health) apart, retained
// generations, data size and, when the owner can say so, an update. A headless
// add-on is managed here exactly like one with pages.
export default function InstalledView({ api, adapter, manage, onManage, onChanged }) {
const { t, i18n } = useTranslation('addons')
const [state, setState] = useState({ status: 'loading', addons: [], error: null })
const [reload, setReload] = useState(0)
const [confirm, setConfirm] = useState(null)
const [confirmUpgrade, setConfirmUpgrade] = useState(null)
// What the last check and the last upgrade of each add-on said, by add-on.
const [checks, setChecks] = useState({})
const [upgraded, setUpgraded] = useState({})
const aborter = useRef(null)
useEffect(() => {
aborter.current = new AbortController()
return () => aborter.current.abort()
}, [])
useEffect(() => {
const ctl = new AbortController()
api.installed(ctl.signal)
.then(addons => setState({ status: 'ready', addons, error: null }))
.catch(error => { if (!ctl.signal.aborted && error.name !== 'AbortError') setState(s => ({ status: s.status === 'ready' ? 'ready' : 'error', addons: s.addons, error })) })
return () => ctl.abort()
}, [api, reload])
const actions = useAddonActions(api, {
onSettled: (key, result) => {
setReload(n => n + 1)
if (onChanged) onChanged()
// The key names the generation the click saw, which the list moves past
// on success, so the outcome of an upgrade is kept by add-on.
const [verb, id] = String(key).split(':')
if (verb === 'upgrade' && result && result.outcome === 'completed') setUpgraded(u => ({ ...u, [id]: true }))
if (verb === 'upgrade' && result && result.outcome !== 'completed') setUpgraded(u => { const n = { ...u }; delete n[id]; return n })
},
})
const lifecycle = useCallback((addon, action) => {
actions.run(`${action}:${addon.id}:${addon.generation}`, {
addonId: addon.id,
submit: (operationId, signal) => adapter.submitLifecycle({
operationId,
action,
addonId: addon.id,
generation: action === 'enable' || action === 'disable' ? addon.generation : 0,
}, signal),
// A completed status is not proof for this click: the owner answers a
// replayed operation with its old status and applies nothing.
verify: async (signal) => {
const now = (await api.installed(signal)).find(a => a.id === addon.id)
if (action === 'uninstall') return !now
if (!now) return false
if (action === 'enable') return now.enabled === true
return now.enabled === false
},
})
}, [actions, adapter, api])
// The explicit upgrade: the digest of the installed package this page
// showed and the digest of the candidate the owner's check resolved. The
// owner checks both again and refuses before it changes anything.
const upgrade = useCallback((addon) => {
actions.run(`upgrade:${addon.id}:${addon.generation}`, {
addonId: addon.id,
submit: (operationId, signal) => {
const body = upgradeBody(addon, operationId)
if (!body) return Promise.reject(new AddonsError({ kind: 'invalid' }))
return api.updates.upgrade(addon.id, body, signal)
},
verify: async (signal) => {
const now = (await api.installed(signal)).find(a => a.id === addon.id)
return Boolean(now) && now.generation > addon.generation
},
})
}, [actions, api])
// Asks the owner to read the library again. It never applies anything.
const check = useCallback(async (addon) => {
const signal = aborter.current.signal
setChecks(c => ({ ...c, [addon.id]: { phase: 'working' } }))
try {
const status = await api.updates.check(addon.id, { operationId: newOperationId() }, signal)
setChecks(c => ({ ...c, [addon.id]: { phase: 'done', throttled: Boolean(status && status.throttled) } }))
setReload(n => n + 1)
} catch (error) {
if (error && error.name === 'AbortError') return
setChecks(c => ({ ...c, [addon.id]: { phase: 'error', error } }))
}
}, [api])
const selected = useMemo(() => state.addons.find(a => a.id === manage) || null, [state.addons, manage])
if (state.status === 'loading') return <div className="op-loading" role="status" aria-label={t('installed.loading')}><LoadingSpinner size="lg" /></div>
if (state.status === 'error') {
return (
<div className="op-error" role="alert">
<Icon name="alert-circle" />
<span>{describeError(state.error, t)}</span>
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={() => { setState({ status: 'loading', addons: [], error: null }); setReload(n => n + 1) }}>
<Icon name="refresh" /> {t('catalog.retry')}
</button>
</div>
)
}
if (state.addons.length === 0) {
return <CatalogEmpty icon="puzzle" title={t('installed.emptyTitle')} text={t('installed.emptyBody')} />
}
return (
<>
<ul className="cat-list" aria-label={t('installed.listLabel')}>
{state.addons.map(addon => (
<li key={addon.id} className="cat-item">
<InstalledRow addon={addon} t={t} actions={actions} onLifecycle={lifecycle} onManage={onManage} upgraded={Boolean(upgraded[addon.id])} />
</li>
))}
</ul>
{selected && (
<ManageSheet
addon={selected}
api={api}
t={t}
language={i18n.language}
actions={actions}
check={checks[selected.id]}
upgraded={Boolean(upgraded[selected.id])}
onCheck={() => check(selected)}
onUpgrade={() => setConfirmUpgrade(selected)}
onLifecycle={lifecycle}
onUninstall={() => setConfirm(selected)}
onClose={() => onManage('')}
/>
)}
{manage && !selected && state.status === 'ready' && (
<div className="op-notice" role="status"><Icon name="info" /><span>{t('installed.missing', { id: manage })}</span></div>
)}
<ConfirmDialog
open={Boolean(confirm)}
danger
title={confirm ? t('installed.uninstallTitle', { name: confirm.title }) : ''}
message={confirm ? <p>{t('installed.uninstallBody')}</p> : null}
confirmLabel={t('installed.uninstall')}
onConfirm={() => { const a = confirm; setConfirm(null); lifecycle(a, 'uninstall') }}
onCancel={() => setConfirm(null)}
/>
<ConfirmDialog
open={Boolean(confirmUpgrade)}
title={confirmUpgrade ? t('updates.confirm.title', { name: confirmUpgrade.title, version: updateView(confirmUpgrade.update).version }) : ''}
message={confirmUpgrade ? <UpgradeSummary addon={confirmUpgrade} t={t} /> : null}
confirmLabel={t('updates.confirm.action')}
onConfirm={() => { const a = confirmUpgrade; setConfirmUpgrade(null); upgrade(a) }}
onCancel={() => setConfirmUpgrade(null)}
/>
</>
)
}
function busy(actions, addon, verbs) {
if (actions.unknown[addon.id]) return true
return verbs.some(v => actions.state[`${v}:${addon.id}:${addon.generation}`]?.phase === 'working')
}
function latest(actions, addon, verbs) {
for (const v of verbs) {
const s = actions.state[`${v}:${addon.id}:${addon.generation}`]
if (s && s.phase !== 'working' && s.outcome) return s
}
return null
}
// The words for the outcome of an action. A typed refusal of the update
// routes has its own words; everything else is the shared description.
function outcomeText(result, t) {
const type = result && result.error && result.error.type
if (type && UPDATE_TYPES.has(type)) return t(`updates.errors.${type}`)
return describeOutcome(result.outcome, t, result.error)
}
function UpdateFact({ view, t }) {
switch (view.state) {
case 'update-available': return <Fact icon="arrow-up" tone="warn">{t('updates.badge.available', { version: view.version })}</Fact>
case 'incompatible': return <Fact icon="alert-circle" tone="warn">{t('updates.badge.incompatible', { version: view.version })}</Fact>
case 'check-failed': return <Fact icon="alert-circle" tone="error">{t('updates.badge.failed')}</Fact>
case 'integrity-error': return <Fact icon="alert-circle" tone="error">{t('updates.badge.integrity')}</Fact>
default: return null
}
}
function InstalledRow({ addon, t, actions, onLifecycle, onManage, upgraded }) {
const working = busy(actions, addon, ['enable', 'disable', 'upgrade', 'uninstall'])
const result = latest(actions, addon, ['enable', 'disable', 'upgrade', 'uninstall'])
const update = updateView(addon.update)
return (
<article className="ad-entry" aria-labelledby={`inst-${addon.id}`} data-addon={addon.id}>
<AppIcon src={addon.icon ? apiUrl(addon.icon) : ''} />
<div className="ad-entry__body">
<h3 className="ad-entry__title" id={`inst-${addon.id}`}>{addon.title}</h3>
<p className="ad-entry__meta">
<span className="dk-mono">{addon.id}</span>
<span aria-hidden="true">·</span>
<span>{t('apps.version', { version: addon.version })}</span>
<span aria-hidden="true">·</span>
<span>{t('installed.generation', { generation: addon.generation })}</span>
</p>
<div className="ad-facts">
<span className="ad-fact" data-tone={addon.enabled ? 'ok' : undefined}>{addon.enabled ? t('installed.enabled') : t('installed.disabled')}</span>
<StateChip app={addon} />
{addon.recovery && <Fact icon="alert-circle" tone={addon.recovery === 'needs-recovery' ? 'error' : 'warn'}>{t(`recovery.state.${addon.recovery}`)}</Fact>}
<Fact icon={addon.verification === 'sha256+sigstore' ? 'shield' : 'info'} tone={addon.verification === 'sha256+sigstore' ? 'ok' : undefined}>{t(`trust.mode.${addon.verification === 'sha256+sigstore' ? 'signed' : 'digest'}`)}</Fact>
<Fact icon="cpu">{addon.platform.os}/{addon.platform.arch}</Fact>
<UpdateFact view={update} t={t} />
</div>
{working && <p className="ad-entry__note" role="status">{t('apps.working')}</p>}
{upgraded && !working && <p className="ad-entry__note" data-tone="ok" role="status" data-testid="upgrade-done">{t('updates.done', { version: addon.version })}</p>}
{actions.unknown[addon.id]
? <UnknownNotice addonId={addon.id} actions={actions} />
: result && result.outcome !== 'completed' && (
<p className="ad-entry__note" data-tone="error" role="alert" data-testid="upgrade-error">{outcomeText(result, t)}</p>
)}
</div>
<div className="ad-entry__acts">
{addon.enabled ? (
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" disabled={working} onClick={() => onLifecycle(addon, 'disable')}>
<Icon name="stop" /> {t('installed.disable')}
</button>
) : (
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={working} onClick={() => onLifecycle(addon, 'enable')}>
<Icon name="play" /> {t('installed.enable')}
</button>
)}
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={() => onManage(addon.id)}>
<Icon name="settings" /> {t('installed.manage')}
</button>
</div>
</article>
)
}
function ManageSheet({ addon, api, t, language, actions, check, upgraded, onCheck, onUpgrade, onLifecycle, onUninstall, onClose }) {
const working = busy(actions, addon, ['enable', 'disable', 'upgrade', 'uninstall'])
const compat = addon.compatibility || {}
return (
<SideSheet
title={addon.title}
description={addon.description || t('installed.manageLead', { id: addon.id })}
onClose={onClose}
closeLabel={t('catalog.close')}
labelId="manage-title"
testId="addon-manage"
footer={(
<>
<button type="button" className="dk-btn dk-btn--danger dk-btn--sm" disabled={working} onClick={onUninstall}>
<Icon name="trash" /> {t('installed.uninstall')}
</button>
{addon.enabled ? (
<button type="button" className="dk-btn dk-btn--sm" disabled={working} onClick={() => onLifecycle(addon, 'disable')}>
<Icon name="stop" /> {t('installed.disable')}
</button>
) : (
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={working} onClick={() => onLifecycle(addon, 'enable')}>
<Icon name="play" /> {t('installed.enable')}
</button>
)}
</>
)}
>
<section className="ad-section" aria-labelledby="manage-health">
<h3 id="manage-health" className="ad-section__title">{t('manage.health')}</h3>
<dl className="dk-kv ad-kv">
<dt>{t('manage.desired')}</dt><dd>{addon.enabled ? t('installed.enabled') : t('installed.disabled')}</dd>
<dt>{t('manage.observed')}</dt><dd><StateChip app={addon} /></dd>
<dt>{t('manage.restarts')}</dt><dd>{addon.restarts}</dd>
</dl>
{addon.enabled && addon.state === 'failed' && <p className="ad-entry__note" data-tone="error" role="alert">{t('manage.failedNote', { count: addon.restarts })}</p>}
{addon.mainPage && <Link className="dk-btn dk-btn--ghost dk-btn--sm" to={addon.mainPage}><Icon name="external-link" /> {t('apps.open')}</Link>}
</section>
<section className="ad-section" aria-labelledby="manage-trust">
<h3 id="manage-trust" className="ad-section__title">{t('manage.trust')}</h3>
<dl className="dk-kv ad-kv">
<dt>{t('trust.verification')}</dt><dd>{t(`trust.mode.${addon.verification === 'sha256+sigstore' ? 'signed' : 'digest'}`)}</dd>
<dt>{t('trust.library')}</dt><dd>{addon.sourceId || '—'}</dd>
{addon.channel && (<><dt>{t('trust.channel')}</dt><dd>{addon.channel}</dd></>)}
{addon.selection && (<><dt>{t('trust.selection')}</dt><dd>{addon.selection}</dd></>)}
<dt>{t('trust.digest')}</dt><dd className="dk-mono ad-wrap" title={addon.digest}>{shortDigest(addon.digest)}</dd>
</dl>
</section>
<section className="ad-section" aria-labelledby="manage-compat">
<h3 id="manage-compat" className="ad-section__title">{t('manage.compatibility')}</h3>
<dl className="dk-kv ad-kv">
<dt>{t('trust.platform')}</dt><dd>{addon.platform.os}/{addon.platform.arch}</dd>
<dt>{t('manage.host')}</dt><dd>{rangeText(compat.host)}</dd>
<dt>{t('manage.api')}</dt><dd>{rangeText(compat.api)}</dd>
<dt>{t('manage.protocol')}</dt><dd>{rangeText(compat.protocol)}</dd>
<dt>{t('manage.dataFormat')}</dt><dd>{rangeText(compat.dataFormat)}</dd>
<dt>{t('manage.tools')}</dt><dd>{addon.toolCount}{addon.tools?.length ? `: ${addon.tools.join(', ')}` : ''}</dd>
<dt>{t('manage.interface')}</dt><dd>{addon.hasUI ? t('manage.hasUI') : t('manage.headless')}</dd>
</dl>
</section>
<section className="ad-section" aria-labelledby="manage-storage">
<h3 id="manage-storage" className="ad-section__title">{t('manage.storage')}</h3>
<dl className="dk-kv ad-kv">
<dt>{t('manage.data')}</dt><dd>{formatBytes(addon.storage?.bytes)}{addon.storage?.truncated ? ` ${t('manage.atLeast')}` : ''}</dd>
<dt>{t('manage.generations')}</dt><dd>{addon.generations}</dd>
</dl>
</section>
<section className="ad-section" aria-labelledby="manage-recovery" data-testid="manage-recovery">
<h3 id="manage-recovery" className="ad-section__title">{t('recovery.title')}</h3>
<RecoveryPanel api={api} addon={addon} />
</section>
<section className="ad-section" aria-labelledby="manage-updates" data-testid="manage-updates">
<h3 id="manage-updates" className="ad-section__title">{t('updates.title')}</h3>
<UpdatePanel addon={addon} t={t} language={language} actions={actions} check={check} upgraded={upgraded} working={working} onCheck={onCheck} onUpgrade={onUpgrade} />
</section>
<section className="ad-section" aria-labelledby="manage-logs">
<h3 id="manage-logs" className="ad-section__title">{t('manage.logs')}</h3>
<p className="ad-quiet">{t('manage.logsNone')}</p>
</section>
</SideSheet>
)
}
// The owner's update check of one add-on: what it says, how old it is, and the
// two actions. Check only reads; Upgrade opens a confirmation first.
function UpdatePanel({ addon, t, language, actions, check, upgraded, working, onCheck, onUpgrade }) {
const view = updateView(addon.update)
const checking = check && check.phase === 'working'
const upgrading = actions.state[`upgrade:${addon.id}:${addon.generation}`]?.phase === 'working'
const result = latest(actions, addon, ['upgrade'])
const checkedAt = formatWhen(view.checkedAt, language)
const lastSuccess = formatWhen(view.lastSuccess, language)
return (
<>
<p data-testid="update-state" data-state={view.state} className={view.state === 'check-failed' || view.state === 'integrity-error' ? 'ad-entry__note' : undefined} data-tone={view.tone === 'error' ? 'error' : undefined}>
{t(`updates.state.${view.state}`, { version: view.version })}
</p>
<dl className="dk-kv ad-kv">
<dt>{t('updates.installed')}</dt>
<dd>{addon.version} <span className="dk-mono ad-wrap" title={addon.digest}>{shortDigest(addon.digest)}</span></dd>
{view.version && (view.state === 'update-available' || view.state === 'incompatible' || view.state === 'integrity-error') && (
<>
<dt>{t('updates.candidate')}</dt>
<dd data-testid="update-candidate">{view.version} {view.digest && <span className="dk-mono ad-wrap" title={view.digest}>{shortDigest(view.digest)}</span>}</dd>
</>
)}
<dt>{t('trust.library')}</dt><dd>{addon.sourceId || '—'}{addon.channel ? ` · ${addon.channel}` : addon.selection ? ` · ${addon.selection}` : ''}</dd>
</dl>
{view.reasons.length > 0 && (
<div data-testid="update-reasons">
<p className="ad-quiet">{t('updates.reasons')}</p>
<ul className="ad-reasons">{view.reasons.map(r => <li key={r}>{t(`updates.reason.${r}`)}</li>)}</ul>
</div>
)}
{view.state === 'check-failed' && view.error && <p className="ad-entry__note" data-tone="error" role="status" data-testid="update-error">{t(`updates.class.${view.error}`)}</p>}
<p className="ad-quiet" data-testid="update-times">
{checkedAt ? t('updates.checkedAt', { when: checkedAt }) : t('updates.neverChecked')}
{lastSuccess ? ` · ${t('updates.lastSuccess', { when: lastSuccess })}` : ''}
</p>
{view.stale && <p className="ad-quiet" data-testid="update-stale">{t('updates.stale')}</p>}
{check && check.phase === 'done' && check.throttled && <p className="ad-quiet" role="status">{t('updates.throttled')}</p>}
{check && check.phase === 'error' && <p className="ad-entry__note" data-tone="error" role="alert">{describeError(check.error, t)}</p>}
{upgrading && <p className="ad-entry__note" role="status" data-testid="upgrade-progress">{t('updates.progress', { name: addon.title })}</p>}
{upgraded && !working && <p className="ad-entry__note" data-tone="ok" role="status">{t('updates.done', { version: addon.version })}</p>}
{actions.unknown[addon.id]
? <UnknownNotice addonId={addon.id} actions={actions} />
: result && result.outcome !== 'completed' && <p className="ad-entry__note" data-tone="error" role="alert">{outcomeText(result, t)}</p>}
<div className="ad-section__acts">
<button type="button" className="dk-btn dk-btn--sm" disabled={checking || working} onClick={onCheck} data-testid="update-check">
<Icon name={checking ? 'spinner' : 'refresh'} spin={checking} /> {checking ? t('updates.checking') : t('updates.checkNow')}
</button>
{view.canUpgrade && (
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={working} onClick={onUpgrade} data-testid="update-upgrade">
<Icon name="arrow-up" /> {t('updates.upgrade')}
</button>
)}
</div>
</>
)
}
// What the confirmation shows before an upgrade: which library and channel,
// which version and digest goes to which, and that the data is backed up.
function UpgradeSummary({ addon, t }) {
const view = updateView(addon.update)
return (
<div data-testid="upgrade-confirm">
<dl className="dk-kv ad-kv">
<dt>{t('updates.confirm.library')}</dt><dd>{addon.sourceId || '—'}</dd>
<dt>{t('updates.confirm.channel')}</dt><dd>{addon.channel || addon.selection || '—'}</dd>
<dt>{t('updates.confirm.from')}</dt><dd>{addon.version} <span className="dk-mono">{shortDigest(addon.digest)}</span></dd>
<dt>{t('updates.confirm.to')}</dt><dd>{view.version} <span className="dk-mono">{shortDigest(view.digest)}</span></dd>
</dl>
<p>{t('updates.confirm.body')}</p>
<p>{t('updates.confirm.data')}</p>
</div>
)
}
@@ -0,0 +1,175 @@
/* 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 Icon from '../Icon'
import { StateChip, Fact, UnknownNotice } from './AddonBits'
import { formatBytes, rangeText, shortDigest } from './addonState'
import { describeError, describeOutcome } from './errors'
import { updateView, formatWhen, UPDATE_TYPES } from './updateState'
import RecoveryPanel from './RecoveryPanel'
// What an administrator needs to run an add-on, shown in the preview pane under
// Details: health, trust, compatibility, storage, data recovery and updates.
// It reads the installed record of the add-on, which the host serves to
// administrators only; the host checks that again on every call.
export function OperatorFacts({ addon, api, language, actions, check, upgraded, onCheck, onUpgrade }) {
const { t } = useTranslation('addons')
const working = busy(actions, addon, ['start', 'stop', 'enable', 'disable', 'upgrade', 'uninstall'])
const compat = addon.compatibility || {}
return (
<div className="ad-facts-body" data-testid="addon-manage">
<section className="ad-section" aria-labelledby="manage-health">
<h3 id="manage-health" className="ad-section__title">{t('manage.health')}</h3>
<dl className="dk-kv ad-kv">
<dt>{t('manage.desired')}</dt><dd>{addon.enabled ? t('installed.enabled') : t('installed.disabled')}</dd>
<dt>{t('manage.observed')}</dt><dd><StateChip app={addon} /></dd>
<dt>{t('manage.restarts')}</dt><dd>{addon.restarts}</dd>
</dl>
{addon.enabled && addon.state === 'failed' && <p className="ad-entry__note" data-tone="error" role="alert">{t('manage.failedNote', { count: addon.restarts })}</p>}
</section>
<section className="ad-section" aria-labelledby="manage-trust">
<h3 id="manage-trust" className="ad-section__title">{t('manage.trust')}</h3>
<dl className="dk-kv ad-kv">
<dt>{t('trust.verification')}</dt><dd>{t(`trust.mode.${addon.verification === 'sha256+sigstore' ? 'signed' : 'digest'}`)}</dd>
<dt>{t('trust.library')}</dt><dd>{addon.sourceId || '—'}</dd>
{addon.channel && (<><dt>{t('trust.channel')}</dt><dd>{addon.channel}</dd></>)}
{addon.selection && (<><dt>{t('trust.selection')}</dt><dd>{addon.selection}</dd></>)}
<dt>{t('trust.digest')}</dt><dd className="dk-mono ad-wrap" title={addon.digest}>{shortDigest(addon.digest)}</dd>
</dl>
</section>
<section className="ad-section" aria-labelledby="manage-compat">
<h3 id="manage-compat" className="ad-section__title">{t('manage.compatibility')}</h3>
<dl className="dk-kv ad-kv">
<dt>{t('trust.platform')}</dt><dd>{addon.platform.os}/{addon.platform.arch}</dd>
<dt>{t('manage.host')}</dt><dd>{rangeText(compat.host)}</dd>
<dt>{t('manage.api')}</dt><dd>{rangeText(compat.api)}</dd>
<dt>{t('manage.protocol')}</dt><dd>{rangeText(compat.protocol)}</dd>
<dt>{t('manage.dataFormat')}</dt><dd>{rangeText(compat.dataFormat)}</dd>
<dt>{t('manage.tools')}</dt><dd>{addon.toolCount}{addon.tools?.length ? `: ${addon.tools.join(', ')}` : ''}</dd>
<dt>{t('manage.interface')}</dt><dd>{addon.hasUI ? t('manage.hasUI') : t('manage.headless')}</dd>
</dl>
</section>
<section className="ad-section" aria-labelledby="manage-storage">
<h3 id="manage-storage" className="ad-section__title">{t('manage.storage')}</h3>
<dl className="dk-kv ad-kv">
<dt>{t('manage.data')}</dt><dd>{formatBytes(addon.storage?.bytes)}{addon.storage?.truncated ? ` ${t('manage.atLeast')}` : ''}</dd>
<dt>{t('manage.generations')}</dt><dd>{addon.generations}</dd>
</dl>
</section>
<section className="ad-section" aria-labelledby="manage-recovery" data-testid="manage-recovery">
<h3 id="manage-recovery" className="ad-section__title">{t('recovery.title')}</h3>
<RecoveryPanel api={api} addon={addon} />
</section>
<section className="ad-section" aria-labelledby="manage-updates" data-testid="manage-updates">
<h3 id="manage-updates" className="ad-section__title">{t('updates.title')}</h3>
<UpdatePanel addon={addon} t={t} language={language} actions={actions} check={check} upgraded={upgraded} working={working} onCheck={onCheck} onUpgrade={onUpgrade} />
</section>
<section className="ad-section" aria-labelledby="manage-logs">
<h3 id="manage-logs" className="ad-section__title">{t('manage.logs')}</h3>
<p className="ad-quiet">{t('manage.logsNone')}</p>
</section>
</div>
)
}
function busy(actions, addon, verbs) {
if (actions.unknown[addon.id]) return true
return verbs.some(v => actions.state[`${v}:${addon.id}:${addon.generation}`]?.phase === 'working')
}
function latest(actions, addon, verbs) {
for (const v of verbs) {
const s = actions.state[`${v}:${addon.id}:${addon.generation}`]
if (s && s.phase !== 'working' && s.outcome) return s
}
return null
}
// The words for the outcome of an action. A typed refusal of the update
// routes has its own words; everything else is the shared description.
function outcomeText(result, t) {
const type = result && result.error && result.error.type
if (type && UPDATE_TYPES.has(type)) return t(`updates.errors.${type}`)
return describeOutcome(result.outcome, t, result.error)
}
// The owner's update check of one add-on: what it says, how old it is, and the
// two actions. Check only reads; Upgrade opens a confirmation first.
function UpdatePanel({ addon, t, language, actions, check, upgraded, working, onCheck, onUpgrade }) {
const view = updateView(addon.update)
const checking = check && check.phase === 'working'
const upgrading = actions.state[`upgrade:${addon.id}:${addon.generation}`]?.phase === 'working'
const result = latest(actions, addon, ['upgrade'])
const checkedAt = formatWhen(view.checkedAt, language)
const lastSuccess = formatWhen(view.lastSuccess, language)
return (
<>
<p data-testid="update-state" data-state={view.state} className={view.state === 'check-failed' || view.state === 'integrity-error' ? 'ad-entry__note' : undefined} data-tone={view.tone === 'error' ? 'error' : undefined}>
{t(`updates.state.${view.state}`, { version: view.version })}
</p>
<dl className="dk-kv ad-kv">
<dt>{t('updates.installed')}</dt>
<dd>{addon.version} <span className="dk-mono ad-wrap" title={addon.digest}>{shortDigest(addon.digest)}</span></dd>
{view.version && (view.state === 'update-available' || view.state === 'incompatible' || view.state === 'integrity-error') && (
<>
<dt>{t('updates.candidate')}</dt>
<dd data-testid="update-candidate">{view.version} {view.digest && <span className="dk-mono ad-wrap" title={view.digest}>{shortDigest(view.digest)}</span>}</dd>
</>
)}
<dt>{t('trust.library')}</dt><dd>{addon.sourceId || '—'}{addon.channel ? ` · ${addon.channel}` : addon.selection ? ` · ${addon.selection}` : ''}</dd>
</dl>
{view.reasons.length > 0 && (
<div data-testid="update-reasons">
<p className="ad-quiet">{t('updates.reasons')}</p>
<ul className="ad-reasons">{view.reasons.map(r => <li key={r}>{t(`updates.reason.${r}`)}</li>)}</ul>
</div>
)}
{view.state === 'check-failed' && view.error && <p className="ad-entry__note" data-tone="error" role="status" data-testid="update-error">{t(`updates.class.${view.error}`)}</p>}
<p className="ad-quiet" data-testid="update-times">
{checkedAt ? t('updates.checkedAt', { when: checkedAt }) : t('updates.neverChecked')}
{lastSuccess ? ` · ${t('updates.lastSuccess', { when: lastSuccess })}` : ''}
</p>
{view.stale && <p className="ad-quiet" data-testid="update-stale">{t('updates.stale')}</p>}
{check && check.phase === 'done' && check.throttled && <p className="ad-quiet" role="status">{t('updates.throttled')}</p>}
{check && check.phase === 'error' && <p className="ad-entry__note" data-tone="error" role="alert">{describeError(check.error, t)}</p>}
{upgrading && <p className="ad-entry__note" role="status" data-testid="upgrade-progress">{t('updates.progress', { name: addon.title })}</p>}
{upgraded && !working && <p className="ad-entry__note" data-tone="ok" role="status">{t('updates.done', { version: addon.version })}</p>}
{actions.unknown[addon.id]
? <UnknownNotice addonId={addon.id} actions={actions} />
: result && result.outcome !== 'completed' && <p className="ad-entry__note" data-tone="error" role="alert">{outcomeText(result, t)}</p>}
<div className="ad-section__acts">
<button type="button" className="dk-btn dk-btn--sm" disabled={checking || working} onClick={onCheck} data-testid="update-check">
<Icon name={checking ? 'spinner' : 'refresh'} spin={checking} /> {checking ? t('updates.checking') : t('updates.checkNow')}
</button>
{view.canUpgrade && (
<button type="button" className="dk-btn dk-btn--primary dk-btn--sm" disabled={working} onClick={onUpgrade} data-testid="update-upgrade">
<Icon name="arrow-up" /> {t('updates.upgrade')}
</button>
)}
</div>
</>
)
}
// What the confirmation shows before an upgrade: which library and channel,
// which version and digest goes to which, and that the data is backed up.
export function UpgradeSummary({ addon, t }) {
const view = updateView(addon.update)
return (
<div data-testid="upgrade-confirm">
<dl className="dk-kv ad-kv">
<dt>{t('updates.confirm.library')}</dt><dd>{addon.sourceId || '—'}</dd>
<dt>{t('updates.confirm.channel')}</dt><dd>{addon.channel || addon.selection || '—'}</dd>
<dt>{t('updates.confirm.from')}</dt><dd>{addon.version} <span className="dk-mono">{shortDigest(addon.digest)}</span></dd>
<dt>{t('updates.confirm.to')}</dt><dd>{view.version} <span className="dk-mono">{shortDigest(view.digest)}</span></dd>
</dl>
<p>{t('updates.confirm.body')}</p>
<p>{t('updates.confirm.data')}</p>
</div>
)
}
@@ -1,25 +0,0 @@
import { useTranslation } from 'react-i18next'
// The pin control of an add-on, on its tile and in its detail view: a button
// with a visible word and a pin that is filled when the add-on is pinned. The
// accessible name keeps the add-on's name so a list of them stays unambiguous.
export default function PinControl({ name, pinned, onToggle, variant = 'tile' }) {
const { t } = useTranslation('addons')
return (
<button
type="button"
className={variant === 'tile' ? 'ad-tile__pin' : 'dk-btn dk-btn--sm ad-pin'}
data-testid="pin-toggle"
data-pinned={pinned ? 'true' : 'false'}
aria-pressed={pinned}
aria-label={pinned ? t('drawer.unpin', { name }) : t('drawer.pin', { name })}
onClick={onToggle}
>
<svg className="ad-pin__icon lai-icon" viewBox="0 0 24 24" focusable="false" aria-hidden="true"
fill={pinned ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 3h6l-1 6 3 3v2H7v-2l3-3zM12 14v7" />
</svg>
<span className="ad-pin__label">{pinned ? t('drawer.pinned') : t('drawer.pinLabel')}</span>
</button>
)
}
+49 -5
View File
@@ -1,10 +1,12 @@
// Pure helpers for the add-on pages. No React and no network.
import { updateView } from './updateState.js'
// Tabs. Apps is for everyone; the other three are management and the server
// checks administration again on every call they make.
export const TABS = ['apps', 'browse', 'installed', 'libraries']
export const ADMIN_TABS = new Set(['browse', 'installed', 'libraries'])
// Tabs: My add-ons is for everyone; Find add-ons and Sources are management and
// the server checks administration again on every call they make. What the old
// Installed tab showed is now the Details of an add-on in My add-ons, so an old
// ?tab=installed address lands on My add-ons.
export const TABS = ['apps', 'browse', 'libraries']
export const ADMIN_TABS = new Set(['browse', 'libraries'])
export function resolveTab(requested, isAdmin) {
if (requested && TABS.includes(requested) && (isAdmin || !ADMIN_TABS.has(requested))) return requested
@@ -21,7 +23,25 @@ export function showFirstRun({ apps, isAdmin, canList = true }) {
return Boolean(isAdmin) && Boolean(canList) && Array.isArray(apps) && apps.length === 0
}
// The management link of an app opens the Installed tab on that add-on.
// What the My add-ons list shows. Nothing installed is a first run for someone
// who can install, and plain emptiness for anyone else. A search that finds
// nothing says so: it never claims that nothing is installed.
export function listState({ apps, shownCount, query, isAdmin, canList = true }) {
if (showFirstRun({ apps, isAdmin, canList })) return 'first-run'
if (shownCount > 0) return 'list'
if (String(query || '').trim() && Array.isArray(apps) && apps.length > 0) return 'no-match'
return 'none'
}
// The add-ons to offer when nothing is installed: the first entries of the
// sources that install as add-ons and are not installed yet. The sources do
// not mark any entry as featured, so this is their own order.
export function featuredEntries(entries, installedIds = [], limit = 3) {
const have = new Set(installedIds)
return (Array.isArray(entries) ? entries : []).filter(e => e && e.kind === 'addon' && !have.has(e.id)).slice(0, limit)
}
// The management link of an app opens the Details of that add-on.
export function resolveManage(params, isAdmin) {
if (!isAdmin) return ''
const id = params.get('manage') || ''
@@ -66,6 +86,14 @@ export function localizedText(app, field, language) {
return fallback || ''
}
// The name to show for an add-on the list does not carry (not installed, or
// not usable by this viewer): its identifier with a capital first letter, so
// the page says "Voice" and not "voice".
export function addonDisplayName(id) {
const text = String(id || '').trim()
return text ? text.charAt(0).toUpperCase() + text.slice(1) : ''
}
// Tool summary of a headless add-on: the names the server sent and how many
// more there are.
export function toolSummary(app, limit = 4) {
@@ -74,6 +102,22 @@ export function toolSummary(app, limit = 4) {
return { shown: tools.slice(0, limit), more: Math.max(0, count - Math.min(limit, tools.length)), count }
}
// What an add-on adds to LocalAI, from the facts the list already carries: a
// page (an add-on with a UI) and chat tools (the tools it offers the model).
// Nothing here is guessed: no field, no line.
export function addedByAddon(app) {
const tools = toolSummary(app)
return { page: Boolean(app && (app.hasUI || app.mainPage)), tools: tools.count, toolNames: tools.shown, toolsMore: tools.more }
}
// Screenshots an add-on ships, when the host lists them. The list carries none
// today, so the preview shows no slot; a path is used only when it is served by
// the host itself, as icons are.
export function screenshotsOf(app, limit = 6) {
const list = Array.isArray(app && app.screenshots) ? app.screenshots : []
return list.filter(src => typeof src === 'string' && src.startsWith('/') && !src.startsWith('//')).slice(0, limit)
}
export function shortDigest(digest) {
const text = String(digest || '')
const [algorithm, value] = text.includes(':') ? text.split(':') : ['', text]
@@ -9,6 +9,12 @@
.ad-page { display: flex; flex-direction: column; gap: var(--dk-space-4); min-width: 0; }
.ad-page .page-header { margin-bottom: 0; }
/* A page an add-on draws sits in this shell page and in its own .page, so its
content starts two page paddings in. The shell's own pages (not available,
no such page) take the same inset, so the title does not jump when an
add-on comes and goes. */
.ad-page--shell { padding-inline: calc(var(--spacing-xl) * 2); }
@media (max-width: 639px) { .ad-page--shell { padding-inline: calc(var(--spacing-md) * 2); } }
.ad-panel { min-width: 0; }
/* ---- Tabs ---------------------------------------------------------------- */
@@ -206,24 +212,22 @@
.ad-drawer__bar { display: flex; flex-wrap: wrap; gap: var(--dk-space-2, 8px); align-items: center; margin-bottom: var(--dk-space-4, 16px); }
.ad-drawer__search { flex: 1 1 14rem; min-width: 0; }
.ad-tiles { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: var(--dk-space-3, 12px); }
.ad-tile { position: relative; display: flex; align-items: stretch; border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-1); background: var(--dk-surface, transparent); }
.ad-tile__open { flex: 1; min-width: 0; display: flex; gap: var(--dk-space-3, 12px); align-items: center; padding: var(--dk-space-3, 12px); color: inherit; text-decoration: none; background: none; border: 0; text-align: start; cursor: pointer; }
.ad-tiles { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); gap: var(--dk-space-3, 12px); }
.ad-tile { position: relative; display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-1); background: var(--dk-card, var(--dk-surface, transparent)); }
.ad-tile[data-selected='true'] { border-color: var(--dk-accent); box-shadow: 0 0 0 1px var(--dk-accent); }
.ad-tile__open { flex: 1; min-width: 0; display: flex; gap: var(--dk-space-3, 12px); align-items: flex-start; padding: var(--dk-space-3, 12px) 2.75rem var(--dk-space-3, 12px) var(--dk-space-3, 12px); color: inherit; text-decoration: none; background: none; border: 0; text-align: start; cursor: pointer; border-radius: inherit; }
.ad-tile__open:hover { background: var(--dk-hover); }
.ad-tile__text { display: flex; flex-direction: column; min-width: 0; }
.ad-tile__text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.ad-tile__title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ad-tile__state { font-size: 0.8125rem; color: var(--dk-text-muted, var(--dk-text)); }
.ad-tile__state { font-size: var(--dk-text-12, 0.75rem); color: var(--dk-muted, var(--dk-text)); }
.ad-tile__state[data-state='running'] { color: var(--dk-ok-text); }
.ad-tile__desc { margin-top: var(--dk-space-1, 4px); font-size: var(--dk-text-13, 0.8125rem); line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
.ad-tile__adds { display: flex; flex-wrap: wrap; gap: 2px var(--dk-space-3, 12px); margin-top: var(--dk-space-1, 4px); font-size: var(--dk-text-12, 0.75rem); color: var(--dk-muted); }
.ad-tile__add { display: inline-flex; align-items: center; gap: 4px; }
.ad-tile[data-state='stopped'] .ad-icon { opacity: 0.5; }
.ad-tile__order { flex: none; display: flex; flex-direction: column; justify-content: center; }
.ad-tile__move { background: none; border: 0; padding: 0.125rem 0.5rem; color: var(--dk-text-muted, var(--dk-text)); cursor: pointer; }
.ad-tile__move:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: -2px; }
.ad-tile__pin { flex: none; width: 4.25rem; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.25rem; padding: 0 0.25rem; font: inherit; font-size: 0.75rem; background: none; border: 0; border-inline-start: 1px solid var(--dk-edge); color: var(--dk-text-muted, var(--dk-text)); cursor: pointer; }
.ad-tile__pin:hover { background: var(--dk-hover); }
.ad-pin__icon { width: 1.125rem; height: 1.125rem; flex: none; }
.ad-pin__label { white-space: nowrap; }
.ad-tile__pin[aria-pressed='true'], .ad-pin[aria-pressed='true'] { color: var(--dk-accent); }
.ad-pin { gap: 0.375rem; }
.ad-tile__open:focus-visible, .ad-tile__pin:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: -2px; }
.ad-tile__side { position: absolute; top: var(--dk-space-2, 8px); right: var(--dk-space-2, 8px); }
.ad-tile__foot { display: flex; padding: 0 var(--dk-space-3, 12px) var(--dk-space-3, 12px); }
.ad-tile__open:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: -2px; }
.nav-item--muted { opacity: 0.55; }
.nav-item--sub { padding-inline-start: 2.25rem; }
@media (max-width: 480px) { .ad-tiles { grid-template-columns: minmax(0, 1fr); } }
@@ -237,3 +241,51 @@
.ad-steps__item[data-state="done"] { color: var(--dk-ok-text); }
.ad-steps__item[data-state="failed"] { color: var(--dk-error-text); font-weight: 600; }
.ad-steps__pct { font-weight: 400; color: var(--dk-muted); }
/* ---- Preview pane ---------------------------------------------------------- */
.ad-split { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--dk-space-4, 16px); align-items: start; }
.ad-split[data-open='true'] { grid-template-columns: minmax(0, 1fr) minmax(20rem, 24rem); }
.ad-split__list { min-width: 0; display: flex; flex-direction: column; gap: var(--dk-space-3, 12px); }
.ad-pane { display: flex; flex-direction: column; min-width: 0; border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-3, 12px); background: var(--dk-card, var(--dk-surface)); position: sticky; top: var(--dk-space-4, 16px); max-height: calc(100vh - 2 * var(--dk-space-4, 16px)); overflow: auto; outline: none; }
.ad-pane__inner { display: contents; }
.ad-pane__head { display: flex; align-items: flex-start; gap: var(--dk-space-3, 12px); padding: var(--dk-space-4, 16px); border-bottom: 1px solid var(--dk-edge); }
.ad-pane__lead { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--dk-space-1, 4px); align-items: flex-start; }
.ad-pane__title { margin: 0; font-size: var(--dk-text-17, 1.0625rem); font-weight: var(--dk-weight-bold, 700); overflow-wrap: anywhere; }
.ad-pane__close { flex: none; display: inline-grid; place-items: center; width: 2rem; height: 2rem; border: 0; border-radius: var(--dk-radius-2, 8px); background: none; color: var(--dk-muted); cursor: pointer; }
.ad-pane__close:hover { background: var(--dk-hover); color: var(--dk-text); }
.ad-pane__close:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: -2px; }
.ad-pane__body { display: flex; flex-direction: column; gap: var(--dk-space-4, 16px); padding: var(--dk-space-4, 16px); min-width: 0; }
.ad-pane__text { margin: 0; font-size: var(--dk-text-13, 0.8125rem); line-height: 1.55; overflow-wrap: anywhere; }
.ad-pane__head + .ad-pane__text { padding: var(--dk-space-4, 16px); }
.ad-pane__section { display: flex; flex-direction: column; gap: var(--dk-space-2, 8px); }
.ad-pane__acts { display: flex; flex-wrap: wrap; gap: var(--dk-space-2, 8px); }
.ad-pane__facts { margin: 0; }
.ad-adds { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--dk-space-2, 8px); font-size: var(--dk-text-13, 0.8125rem); }
.ad-adds li { display: flex; gap: var(--dk-space-2, 8px); align-items: flex-start; overflow-wrap: anywhere; }
.ad-adds .lai-icon { margin-top: 2px; color: var(--dk-muted); }
.ad-shots { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); gap: var(--dk-space-2, 8px); }
.ad-shots img { display: block; width: 100%; aspect-ratio: 16 / 10; object-fit: cover; border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-2, 8px); background: var(--dk-inset); }
@media (max-width: 1179px) {
.ad-split[data-open='true'] { grid-template-columns: minmax(0, 1fr); }
.ad-pane { position: fixed; inset: 0; z-index: 60; max-height: none; border-radius: 0; border: 0; }
}
/* ---- First run and install flow ------------------------------------------- */
.ad-first { gap: var(--dk-space-3, 12px); }
.ad-featured { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 15rem), 1fr)); gap: var(--dk-space-3, 12px); }
.ad-featured__item { display: flex; flex-direction: column; gap: var(--dk-space-2, 8px); min-width: 0; padding: var(--dk-space-3, 12px); border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-2, 8px); background: var(--dk-surface, transparent); }
.ad-flow { margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: var(--dk-space-2, 8px); font-size: var(--dk-text-13, 0.8125rem); color: var(--dk-muted); }
.ad-flow--failed { color: var(--dk-error-text); }
.ad-entry__details { margin-top: var(--dk-space-2, 8px); font-size: var(--dk-text-13, 0.8125rem); }
.ad-entry__details > summary { cursor: pointer; color: var(--dk-muted); width: fit-content; }
.ad-entry__details > summary:hover { color: var(--dk-text); }
.ad-entry__details > summary:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: 2px; }
.ad-entry__details[open] > summary { margin-bottom: var(--dk-space-2, 8px); }
.ad-steps--stack { flex-direction: column; flex-wrap: nowrap; gap: var(--dk-space-2, 8px); margin: 0; }
.ad-tile__update { font-size: var(--dk-text-12, 0.75rem); color: var(--dk-warn-text); }
.ad-pane__more > summary { cursor: pointer; font-weight: var(--dk-weight-semibold, 600); width: fit-content; }
.ad-pane__more > summary:focus-visible { outline: 2px solid var(--dk-accent); outline-offset: 2px; }
.ad-pane__more[open] > summary { margin-bottom: var(--dk-space-3, 12px); }
.ad-facts-body { display: flex; flex-direction: column; gap: var(--dk-space-4, 16px); }
+27
View File
@@ -0,0 +1,27 @@
// Pure of React and of the network: the caller hands in the runner and the clients.
// The two steps of Install and turn on. `run` is the hook's own action runner,
// so each step has its own operation identity and keeps the unknown-outcome
// rule; `mark` receives where the flow is. Pure of React, so it is tested
// with a fake runner.
export async function runInstallAndTurnOn({ run, api, adapter, mark }, entry, { skipInstall = false } = {}) {
if (!skipInstall) {
mark({ step: 'install' })
const installed = await run(`install:${entry.key}`, {
addonId: entry.id,
submit: (operationId, signal) => adapter.submitLifecycle({ operationId, action: 'install', addonId: entry.id, sourceId: entry.sourceId, version: entry.version }, signal),
verify: async (signal) => (await api.installed(signal)).some(a => a.id === entry.id),
})
if (!installed) return null
if (installed.outcome !== 'completed') { mark({ step: 'failed', failedStep: 'install', outcome: installed.outcome, error: installed.error }); return installed }
}
mark({ step: 'turn-on' })
// Generation 0 sets no precondition: the add-on was just installed.
const on = await run(`enable:${entry.id}:0`, {
addonId: entry.id,
submit: (operationId, signal) => adapter.submitLifecycle({ operationId, action: 'enable', addonId: entry.id, generation: 0 }, signal),
verify: async (signal) => (await api.installed(signal)).some(a => a.id === entry.id && a.enabled === true),
})
if (!on) return null
mark(on.outcome === 'completed' ? { step: 'done' } : { step: 'failed', failedStep: 'turn-on', outcome: on.outcome, error: on.error })
return on
}
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more