feat(addons): add the add-on runtime, navigation and first-party add-ons

Add the add-on runtime to LocalAI: manifests, the host operation broker,
admin gating by account role, the add-on UI bundle builder and the SDK
for add-on pages. Move the navigation to Chat, Models, Add-ons and
Settings, with pinned add-ons in the sidebar and an Add-ons drawer.

Ship the first-party add-ons (voice, face, fine-tuning, quantization) as
packages for linux/amd64, linux/arm64 and darwin/arm64. Intel macOS
(darwin/amd64) is enabled in the platform files.

Squashed from the review branch. The tree is unchanged; the pre-squash
history is kept on a local backup branch.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-10-20 20:54:43 +00:00
1 parent 6fc2bc4967
commit 1604a67c1f
733 files changed
+149964 -323

No files matched your search

+7
View File
@@ -150,3 +150,10 @@ compromised and we've shipped malicious signed images:
page if/when added.
- **Tarball/HTTP backend signing.** Cosign can sign arbitrary blobs, but
for now non-OCI backends keep using the `sha256:` field in YAML.
Embedded consumers with host-provisioned private Sigstore trust can use
`cosignverify.NewVerifierWithTrustedMaterial`. It bypasses public TUF fetching,
not signature, identity, SCT, or Rekor checks. Never construct this input from a
registry attachment or catalog response. `NewVerifier` retains its public TUF
default. Hermetic cryptographic regressions live in `offline_internal_test.go`;
the optional live registry tests remain separate and are not acceptance evidence.
+26
View File
@@ -92,6 +92,32 @@ The in-process MCP server runs inside the same LocalAI binary that serves chat.
The user chose KISS. Every mutating tool has a safety rule (`prompts/10_safety.md` rule 1) that requires the LLM to summarise the action and wait for explicit user confirmation before calling it. There is no `plan_*`/`apply_*` two-step in code. If you add a mutating tool, do **not** add per-tool confirmation logic in Go — instead, list the new tool name in `prompts/10_safety.md` so the LLM knows it falls under the confirmation rule.
## Scoped exception: add-on tools are not assistant tools
The prompt-only confirmation above applies to the admin catalog in
`pkg/mcp/localaitools/` and to nothing else. Tools of installed add-ons (for
example Tables) follow a different, code-enforced rule, documented in
[docs/content/features/tables.md](../docs/content/features/tables.md):
- They are never registered in this catalog and no `/api/addons` route appears
in `toolToHTTPRoute`. `TestToolHTTPRouteMappingComplete` stays unchanged, and
the consumer matrix test (`core/services/addons/tools`) fails if a tool name
or route of the add-on surface appears here.
- A change proposed by an add-on tool is a stored proposal. Only the proposer's
own browser session confirms it, with the digest of what the host card showed.
A model's "yes" or an argument named `approved` is never authority.
- REST-to-MCP parity is intentionally partial for add-ons: the lifecycle
mutations (install, enable, disable, upgrade, uninstall, library changes) are
deliberately **not** exposed as assistant tools, because the assistant is one
process-wide server with no per-call principal and would inherit prompt-only
authorization. Do not add them without a per-call actor and a host approval
card.
- New code that discovers or executes tools must be classified in
`tools.Consumers` (`core/services/addons/tools/consumers.go`) as route or
reject. The matrix test scans the sources and fails for an unclassified file.
Do not rewrite the unrelated assistant tools to match this rule.
## Distributed mode
The in-memory MCP server runs only on the head node (where the chat handler runs). `inproc.Client` wraps services that are already distributed-aware (`GalleryService` coordinates with workers; `ListNodes` reads the NATS-populated registry). No NATS routing of MCP tools — the admin surface lives on the head, period.
+426
View File
@@ -0,0 +1,426 @@
---
name: 'add-on packages'
# Builds, checks, signs and publishes the first-party add-on OCI packages
# (Tables, Voice, Face, Fine-tuning and Quantization). This workflow is
# independent of the backend image workflows: it has its own registry input,
# its own signer identity and its own jobs.
#
# Event policy (the rules live in scripts/addons/channel.sh):
# pull_request validate and build legs, publish nothing, no secrets
# push to master publish the development channel
# push to other branch publish nothing
# push of a v<semver> tag prepare the immutable version, move no alias
# release published promote latest, for a stable non-draft release only
#
# Every add-on has its own platform file (addons/<id>/package/platforms.yaml),
# its own package legs and its own repository. Tables publishes to
# ADDON_OCI_REPOSITORY, which is the repository <namespace>/tables. Each other
# add-on publishes to <namespace>/<id>, where the namespace is
# ADDON_OCI_REPOSITORY without its last path element. A publishing event fails
# for an add-on until one of its platforms is enabled.
#
# Aliases move only in the promote job, after every platform leg passed, the
# index was pushed by digest, signed and verified.
on:
push:
branches: ['**']
tags: ['v*']
paths:
- 'addons/**'
- 'pkg/addons/**'
- 'scripts/addons/**'
- 'gallery/addons/**'
- '.github/workflows/addons-package.yml'
pull_request:
paths:
- 'addons/**'
- 'pkg/addons/**'
- 'scripts/addons/**'
- 'gallery/addons/**'
- '.github/workflows/addons-package.yml'
release:
types: [published]
permissions:
contents: read
jobs:
validate:
name: validate package rules
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: read
strategy:
fail-fast: true
matrix:
addon: [tables, voice, face, fine-tuning, quantization]
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Install the host UI build tools
# The package checks build the add-on UI with the bundler the host UI
# already installs.
run: npm ci --prefix core/http/react-ui
- name: Generate the gRPC stubs the packaging checks compile
# The generated stubs are not in the tree and the Go packages of the
# host import them. Same pinned tools as the test workflow.
if: matrix.addon == 'tables'
run: |
curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v26.1/protoc-26.1-linux-x86_64.zip -o protoc.zip
sudo unzip -j -d /usr/local/bin protoc.zip bin/protoc
rm protoc.zip
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@1958fcbe2ca8bd93af633f11e97d44e567e945af
PATH="$PATH:$HOME/go/bin" make protogen-go
- name: Build the UI of this add-on
env:
ADDON: ${{ matrix.addon }}
run: node "addons/$ADDON/ui/build.mjs"
- name: Fill the module cache the packaging checks read
# package_test.sh runs offline (GOPROXY=off) against its own cache.
# The suite covers every add-on, so one leg runs it.
if: matrix.addon == 'tables'
run: |
GOPATH="$PWD/.cache/addons/go" GOMODCACHE="$PWD/.cache/addons/go/pkg/mod" \
GOCACHE="$PWD/.cache/addons/go-build" go mod download
- name: Run the packaging checks
if: matrix.addon == 'tables'
run: sh scripts/addons/package_test.sh
plan:
name: plan the event
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
env:
ADDON_OCI_REPOSITORY: ${{ vars.ADDON_OCI_REPOSITORY }}
outputs:
publish: ${{ steps.plan.outputs.publish }}
immutable: ${{ steps.plan.outputs.immutable }}
aliases: ${{ steps.plan.outputs.aliases }}
channel: ${{ steps.plan.outputs.channel }}
version: ${{ steps.plan.outputs.version }}
matrix: ${{ steps.plan.outputs.matrix }}
addons: ${{ steps.plan.outputs.addons }}
has_platforms: ${{ steps.plan.outputs.has_platforms }}
registry: ${{ steps.plan.outputs.registry }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Decide what this event may publish
id: plan
env:
EVENT: ${{ github.event_name }}
REF: ${{ github.ref }}
SHA: ${{ github.sha }}
DRAFT: ${{ github.event.release.draft || false }}
PRERELEASE: ${{ github.event.release.prerelease || false }}
run: |
set -eu
flags=""
[ "$DRAFT" = "true" ] && flags="$flags --draft"
[ "$PRERELEASE" = "true" ] && flags="$flags --prerelease"
# shellcheck disable=SC2086
sh scripts/addons/channel.sh --event "$EVENT" --ref "$REF" --sha "$SHA" $flags > decision.txt
cat decision.txt
version=$(sed -n 's/^version=//p' decision.txt)
publish=$(sed -n 's/^publish=//p' decision.txt)
[ -n "$version" ] || version="0.0.0-dev.${SHA%"${SHA#????????????}"}"
if [ "$publish" = "true" ]; then
# Fail closed: a publishing event needs the namespace input and a
# non-empty release platform matrix. There is no default registry.
if [ -z "$ADDON_OCI_REPOSITORY" ]; then
echo "ADDON_OCI_REPOSITORY is not configured; refusing to publish" >&2
exit 1
fi
fi
# One set of legs for each add-on, from its own platform file. Only
# the "no enabled platform" answer is tolerated, and only on an event
# that publishes nothing: that add-on gets no legs. On a publishing
# event it fails the plan. A missing or malformed platform file, or
# any other error, fails the plan on every event.
include='[]'
for id in tables voice face fine-tuning quantization; do
cfg="addons/$id/package/platforms.yaml"
err="matrix-$id.err"
if rows=$(go run ./scripts/addons/cmd/addonpkg matrix --config "$cfg" 2>"$err"); then
:
elif grep -q 'no enabled platform' "$err"; then
if [ "$publish" = "true" ]; then
echo "add-on $id has no enabled platform; refusing to publish" >&2
exit 1
fi
continue
else
echo "cannot read the platform matrix of add-on $id" >&2
cat "$err" >&2
exit 1
fi
include=$(printf '%s' "$rows" | jq -c --argjson all "$include" --arg addon "$id" '$all + [.include[] | . + {addon: $addon}]')
done
matrix=$(printf '%s' "$include" | jq -c '{include: .}')
addons=$(printf '%s' "$include" | jq -c 'map(.addon) | unique')
has=$(printf '%s' "$include" | jq 'length > 0')
{
cat decision.txt
echo "version=$version"
echo "matrix=$matrix"
echo "addons=$addons"
echo "has_platforms=$has"
echo "registry=${ADDON_OCI_REPOSITORY%%/*}"
} >> "$GITHUB_OUTPUT"
package:
name: package ${{ matrix.addon }} ${{ matrix.platform }}
needs: [plan, validate]
if: needs.plan.outputs.has_platforms == 'true'
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
permissions:
contents: read
strategy:
fail-fast: true
matrix: ${{ fromJSON(needs.plan.outputs.matrix) }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- uses: actions/setup-node@v6
with:
node-version: '22'
- name: Build the UI
env:
ADDON: ${{ matrix.addon }}
run: |
npm ci --prefix core/http/react-ui
node "addons/$ADDON/ui/build.mjs"
- name: Package this platform
env:
ADDON: ${{ matrix.addon }}
VERSION: ${{ needs.plan.outputs.version }}
PLATFORM: ${{ matrix.platform }}
SHA: ${{ github.sha }}
run: |
sh scripts/addons/package.sh --config "addons/$ADDON/package/platforms.yaml" --layout "layout" --version "$VERSION" --platform "$PLATFORM" --revision "$SHA"
- name: Native runtime check
env:
ADDON: ${{ matrix.addon }}
VERSION: ${{ needs.plan.outputs.version }}
PLATFORM: ${{ matrix.platform }}
run: |
go run ./scripts/addons/cmd/addonpkg native-check --config "addons/$ADDON/package/platforms.yaml" --layout layout --platform "$PLATFORM" --version "$VERSION"
- uses: actions/upload-artifact@v7
with:
name: layout-${{ matrix.addon }}-${{ matrix.slug }}
path: layout
if-no-files-found: error
retention-days: 7
publish:
name: publish and sign ${{ matrix.addon }}
needs: [plan, validate, package]
if: needs.plan.outputs.publish == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 60
environment: addons-release
concurrency:
group: addons-publish-${{ matrix.addon }}-${{ needs.plan.outputs.immutable }}
cancel-in-progress: false
permissions:
contents: read
id-token: write
strategy:
# A leg that is pushing must finish, so one failure never cancels another.
fail-fast: false
matrix:
addon: ${{ fromJSON(needs.plan.outputs.addons) }}
env:
ADDON_OCI_BASE: ${{ vars.ADDON_OCI_REPOSITORY }}
ADDON: ${{ matrix.addon }}
# cosign gates --registry-referrers-mode=oci-1-1 behind this flag.
COSIGN_EXPERIMENTAL: '1'
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Choose the repository of this add-on
# Tables publishes to the configured repository. Every other add-on
# publishes to <namespace>/<id>. The later steps read
# ADDON_OCI_REPOSITORY.
run: |
set -eu
case "$ADDON_OCI_BASE" in
*/*) ;;
*) echo "ADDON_OCI_REPOSITORY needs a namespace and a name" >&2; exit 1 ;;
esac
if [ "$ADDON" = tables ]; then
repo="$ADDON_OCI_BASE"
else
repo="${ADDON_OCI_BASE%/*}/$ADDON"
fi
echo "ADDON_OCI_REPOSITORY=$repo" >> "$GITHUB_ENV"
- uses: actions/download-artifact@v8
with:
pattern: layout-${{ matrix.addon }}-*
path: legs
- name: Join the platform legs into one index
id: merge
env:
VERSION: ${{ needs.plan.outputs.version }}
SHA: ${{ github.sha }}
run: |
set -eu
go run ./scripts/addons/cmd/addonpkg merge --config "addons/$ADDON/package/platforms.yaml" \
--out merged --version "$VERSION" --revision "$SHA" legs/*
- name: Registry login
uses: docker/login-action@v3
with:
registry: ${{ needs.plan.outputs.registry }}
username: ${{ secrets.ADDON_OCI_USERNAME }}
password: ${{ secrets.ADDON_OCI_PASSWORD }}
- name: Push the platform digests and the index
id: push
env:
IMMUTABLE: ${{ needs.plan.outputs.immutable }}
run: |
set -eu
go run ./scripts/addons/cmd/addonpkg publish --config "addons/$ADDON/package/platforms.yaml" \
--layout merged --repository "$ADDON_OCI_REPOSITORY" --tag "$IMMUTABLE" --adopt-existing | tee push.txt
echo "digest=$(sed -n 's/^digest=//p' push.txt)" >> "$GITHUB_OUTPUT"
echo "children=$(sed -n 's/^children=//p' push.txt)" >> "$GITHUB_OUTPUT"
- uses: sigstore/cosign-installer@v3
with:
cosign-release: 'v2.6.5'
- name: Sign by digest
id: sign
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: |
set -eu
cosign sign --yes --recursive \
--new-bundle-format \
--registry-referrers-mode=oci-1-1 \
"${ADDON_OCI_REPOSITORY}@${DIGEST}"
- name: Verify the signature with the exact add-on workflow identity
id: verify
env:
DIGEST: ${{ steps.push.outputs.digest }}
CHILDREN: ${{ steps.push.outputs.children }}
IDENTITY: https://github.com/${{ github.repository }}/.github/workflows/addons-package.yml@${{ github.ref }}
ISSUER: https://token.actions.githubusercontent.com
run: |
set -eu
for d in "$DIGEST" $(echo "$CHILDREN" | tr ',' ' '); do
cosign verify --new-bundle-format \
--certificate-identity "$IDENTITY" \
--certificate-oidc-issuer "$ISSUER" \
"${ADDON_OCI_REPOSITORY}@${d}" > /dev/null
done
- name: Keep the digest for the promote job
# A matrix job cannot hand one output to the next job, so the digest of
# each add-on travels in an artifact of its own.
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: printf '%s\n' "$DIGEST" > digest.txt
- uses: actions/upload-artifact@v7
with:
name: digest-${{ matrix.addon }}
path: digest.txt
if-no-files-found: error
retention-days: 7
promote:
name: promote channel aliases ${{ matrix.addon }}
needs: [plan, validate, publish]
if: needs.plan.outputs.publish == 'true' && needs.plan.outputs.aliases != ''
runs-on: ubuntu-24.04
timeout-minutes: 20
environment: addons-release
# One promotion at a time per channel, and a queued run never cancels a
# running one. The freshness check below makes a slow, older run a no-op.
concurrency:
group: addons-promote-${{ matrix.addon }}-${{ needs.plan.outputs.channel }}
cancel-in-progress: false
permissions:
contents: read
strategy:
fail-fast: false
matrix:
addon: ${{ fromJSON(needs.plan.outputs.addons) }}
env:
ADDON_OCI_BASE: ${{ vars.ADDON_OCI_REPOSITORY }}
ADDON: ${{ matrix.addon }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Choose the repository of this add-on
# The same rule as in publish: Tables keeps the configured repository,
# every other add-on uses <namespace>/<id>.
run: |
set -eu
case "$ADDON_OCI_BASE" in
*/*) ;;
*) echo "ADDON_OCI_REPOSITORY needs a namespace and a name" >&2; exit 1 ;;
esac
if [ "$ADDON" = tables ]; then
repo="$ADDON_OCI_BASE"
else
repo="${ADDON_OCI_BASE%/*}/$ADDON"
fi
echo "ADDON_OCI_REPOSITORY=$repo" >> "$GITHUB_ENV"
- uses: actions/download-artifact@v8
with:
name: digest-${{ matrix.addon }}
path: digest
- name: Registry login
uses: docker/login-action@v3
with:
registry: ${{ needs.plan.outputs.registry }}
username: ${{ secrets.ADDON_OCI_USERNAME }}
password: ${{ secrets.ADDON_OCI_PASSWORD }}
- name: Move the aliases
env:
ALIASES: ${{ needs.plan.outputs.aliases }}
VERSION: ${{ needs.plan.outputs.version }}
SHA: ${{ github.sha }}
REF: ${{ github.ref }}
EVENT: ${{ github.event_name }}
run: |
set -eu
DIGEST=$(tr -d '[:space:]' < digest/digest.txt)
case "$DIGEST" in
sha256:????????????????????????????????????????????????????????????????) ;;
*) echo "the digest of $ADDON is missing or malformed" >&2; exit 1 ;;
esac
for alias in $(echo "$ALIASES" | tr ',' ' '); do
go run ./scripts/addons/cmd/addonpkg promote \
--repository "$ADDON_OCI_REPOSITORY" --alias "$alias" \
--digest "$DIGEST" --version "$VERSION" --revision "$SHA" \
--ref "$REF" --event "$EVENT" --git-dir .git
done
+1
View File
@@ -140,3 +140,4 @@ formal-verification/out/
/backend/go/gemxcpp/build-*/
/backend/go/gemxcpp/package/
/backend/go/gemxcpp/gemxcpp
addons/internal/uibuild/test/*/dist
+15
View File
@@ -0,0 +1,15 @@
// SPDX-License-Identifier: MIT
// Command face is the executable of the Face add-on. It serves no tools:
// the add-on works in the browser, through the SDK and the core API.
package main
import (
"os"
"github.com/mudler/LocalAI/addons/internal/idle"
)
func main() {
os.Exit(idle.Run(os.Args))
}
+25
View File
@@ -0,0 +1,25 @@
{
"schemaVersion": 1,
"id": "face",
"version": "0.1.0",
"executable": "face",
"os": "linux",
"arch": "amd64",
"host": { "min": 1, "max": 1 },
"api": { "min": 1, "max": 1 },
"protocol": { "min": 1, "max": 1 },
"dataFormat": { "min": 1, "max": 1 },
"capabilities": {
"ui": {
"entry": "ui/face.js",
"css": ["ui/face.css"],
"sdk": { "min": 2, "max": 2 },
"pages": ["recognition"],
"navigation": [],
"mainPage": "recognition",
"icon": "ui/icon.png",
"title": "Face",
"description": "Find out whose face is in a photo, or whether two photos show the same person."
}
}
}
+38
View File
@@ -0,0 +1,38 @@
# Release platform matrix of the face add-on package.
#
# A platform ships only while "enabled" is true. Enable a platform in the same
# change that shows its native check passing on a runner of that platform;
# until then it stays listed and disabled. The release workflow fails closed
# when no platform is enabled, and it never builds a platform that is not
# enabled here.
#
# runner: the GitHub runner label that builds and checks the platform
# natively. Required for an enabled platform.
# native_check: "executable-start": the packaged executable is extracted on
# the native runner and started. Required for an enabled platform.
# runtime: files besides the executable that the platform needs at run
# time, relative to the package root. They are taken from the
# runtime directory of the build and must exist.
schema: 1
addon: face
executable: face
manifest: addons/face/manifest.json
# The directory whose content is laid at the package root (ui/face.js ...).
ui: addons/face/ui/dist
platforms:
- platform: linux/amd64
enabled: true
runner: ubuntu-24.04
native_check: executable-start
- platform: linux/arm64
enabled: true
runner: ubuntu-24.04-arm
native_check: executable-start
- platform: darwin/arm64
enabled: true
runner: macos-14
native_check: executable-start
- platform: darwin/amd64
enabled: true
runner: macos-15-intel
native_check: executable-start
+2
View File
@@ -0,0 +1,2 @@
# Written by build.mjs; the package build makes it.
/dist/
+13
View File
@@ -0,0 +1,13 @@
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
import { buildAddonUI } from '../../internal/uibuild/uibuild.mjs'
const here = dirname(fileURLToPath(import.meta.url))
export async function build() {
return buildAddonUI({ root: here, name: 'face', rgb: [20, 100, 90] })
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
build().then((dir) => console.log(`built ${dir}`), (e) => { console.error(e); process.exit(1) })
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "localai-face-ui",
"private": true,
"version": "0.1.0",
"description": "Native UI of the Face add-on: a standalone ES module that the host loads after its own build.",
"type": "module",
"scripts": { "build": "node build.mjs", "test": "node test/run.mjs" }
}
+116
View File
@@ -0,0 +1,116 @@
// The Faces page: who is this, same person, the people this browser knows and
// what is stored. Ported from pages/FaceRecognition.jsx. The known-faces list
// and the enrol sheet are the shared views inside the shared Workbench.
// Detect and analyze, and the raw faceprint, sit behind a disclosure.
import { createClipInput } from '../../../internal/identity-ui/src/ClipInput.jsx'
import { createWorkbench } from '../../../internal/identity-ui/src/Workbench.jsx'
import { createKnownList } from '../../../internal/identity-ui/src/KnownList.jsx'
import { createEnrolSheet } from '../../../internal/identity-ui/src/EnrolSheet.jsx'
import { createVerdict } from '../../../internal/identity-ui/src/Verdict.jsx'
import { createDistanceScale } from '../../../internal/identity-ui/src/DistanceScale.jsx'
import { createErrorNote } from '../../../internal/identity-ui/src/ErrorNote.jsx'
import { createModelNeeded } from '../../../internal/identity-ui/src/ModelNeeded.jsx'
import { createBoundingBoxCanvas } from '../../../internal/identity-ui/src/BoundingBoxCanvas.jsx'
import { createIdentityRegistry } from '../../../internal/identity-ui/src/registry.js'
import { createMoreFaceTools } from './MoreFaceTools.jsx'
const CAP_FACE_RECOGNITION = 'FLAG_FACE_RECOGNITION'
// `identity` is the shared helper module (identity.js), passed as in the Voice
// add-on. The Faces page has no tab counts, so it reads nothing from it.
// eslint-disable-next-line no-unused-vars
export function createFaceRecognition(ctx, api, identity) {
const { h, t, Icon, ui, viewer } = ctx
const { useCallback, useEffect, useMemo, useState } = ctx.React
const { ClipInput } = createClipInput(ctx)
const { KnownList } = createKnownList(ctx)
const { Verdict } = createVerdict(ctx)
const { DistanceScale } = createDistanceScale(ctx)
const { ErrorNote } = createErrorNote(ctx)
const { useRegistry } = createIdentityRegistry(ctx)
const { BoundingBoxCanvas } = createBoundingBoxCanvas(ctx)
const { EnrolSheet } = createEnrolSheet(ctx, { ClipInput, ErrorNote, api })
const { ModelNeeded } = createModelNeeded(ctx, api)
const { MoreFaceTools } = createMoreFaceTools(ctx, api)
const { Workbench } = createWorkbench(ctx, { ClipInput, KnownList, EnrolSheet, Verdict, DistanceScale, ErrorNote, useRegistry, BoundingBoxCanvas, api })
// Shown in place of a page whose feature the signed-in user may not use. It
// replaces a silent redirect: the person learns what is off and what turns it on.
function FeatureOff() {
return (
<main className="page idn-page idn-off" data-testid="face-off">
<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>
<p className="idn-needed__text">{t('off.face.text')}</p>
<details className="idn-disclosure" open>
<summary><Icon name="chevron-right" /> {t('needed.whatTitle')}</summary>
<p className="idn-disclosure__text">{t('off.face.what')}</p>
</details>
</section>
</main>
)
}
// The face models that are installed: the capability list of the core,
// narrowed to the face-recognition flag, as the core hook useModels does.
function useFaceModels() {
const [state, setState] = useState({ models: [], loading: true })
const load = useCallback(async (silent) => {
if (!silent) setState(s => ({ ...s, loading: true }))
try {
const data = await api.models.listCapabilities()
const models = (data?.data || []).filter(m => !m.disabled && m.capabilities?.includes(CAP_FACE_RECOGNITION))
setState({ models, loading: false })
} catch {
setState({ models: [], loading: false })
}
}, [])
useEffect(() => { load(false) }, [load])
return { ...state, refetch: useCallback(() => load(true), [load]) }
}
function FacesPage({ path }) {
const isAdmin = viewer.isAdmin()
const [model, setModel] = useState(() => ctx.modelFrom(path) || '')
const { models, loading, refetch } = useFaceModels()
const names = useMemo(() => models.map(m => m.id), [models])
const needModel = !loading && names.length === 0
return (
<main className="page idn-page" data-testid="faces-page">
<ui.PageHeader
title={t('faces.title')}
supporting={t('faces.lede')}
actions={!needModel ? (
<div className="idn-modelpick">
<ui.ModelSelector value={model} onChange={setModel} capability={CAP_FACE_RECOGNITION} options={names} loading={loading} triggerClassName="idn-model" />
</div>
) : null}
/>
<Workbench
kind="face" model={model}
needed={needModel ? <ModelNeeded kind="face" isAdmin={isAdmin} onInstalled={refetch} /> : null}
more={needModel ? null : (
<details className="idn-disclosure idn-disclosure--page idn-more">
<summary><Icon name="chevron-right" /> {t('face.moreTitle')} <span className="dk-hint">{t('face.moreHint')}</span></summary>
<MoreFaceTools model={model} />
</details>
)}
/>
</main>
)
}
// The page is for a viewer who holds face_recognition (spec, "Classification":
// the gate of the page is the gate of the core route, and the library inside
// it has no stricter gate). The server checks every /v1/face/* call again. A
// viewer without the feature gets the notice and no call.
function FaceRecognition(props) {
if (!viewer.hasFeature('face_recognition')) return <FeatureOff />
return <FacesPage {...props} />
}
return { FaceRecognition }
}
+246
View File
@@ -0,0 +1,246 @@
// The two developer tools behind the face workbench: detect faces and guess
// attributes (guesses, often wrong), and the raw faceprint. Ported from
// components/identity/MoreFaceTools.jsx; the shared widgets come from the SDK.
import { createBoundingBoxCanvas } from '../../../internal/identity-ui/src/BoundingBoxCanvas.jsx'
const TABS = [
{ id: 'analyze', icon: 'chart-bar', label: 'Detect and analyze' },
{ id: 'embed', icon: 'code', label: 'Embedding' },
]
export function createMoreFaceTools(ctx, api) {
const { h, Fragment, Icon, ui } = ctx
const { useMemo, useState } = ctx.React
const { BoundingBoxCanvas } = createBoundingBoxCanvas(ctx)
function MoreFaceTools({ model }) {
const [tab, setTab] = useState('analyze')
return (
<div className="idn-more__body">
<ui.TabSwitch tabs={TABS} value={tab} onChange={setTab} />
<div className="biometrics-page__body">
{tab === 'analyze' && <AnalyzeTab model={model} />}
{tab === 'embed' && <EmbedTab model={model} />}
</div>
</div>
)
}
function AnalyzeTab({ model }) {
const [img, setImg] = useState(null)
const [actions, setActions] = useState({ age: false, gender: false, emotion: false, race: false })
const [antiSpoofing, setAntiSpoofing] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [result, setResult] = useState(null)
const [focusIdx, setFocusIdx] = useState(0)
const submit = async (e) => {
e.preventDefault()
if (!model) { ctx.toast('Select a face model first', 'warning'); return }
if (!img) { ctx.toast('Add an image to analyze', 'warning'); return }
setLoading(true); setError(null); setResult(null); setFocusIdx(0)
try {
const data = await api.face.analyze({
model,
img: img.dataUrl,
actions: Object.entries(actions).filter(([, v]) => v).map(([k]) => k),
anti_spoofing: antiSpoofing,
})
setResult(data)
if (!data?.faces?.length) ctx.toast('No face detected in the image', 'warning')
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const boxes = useMemo(() => (result?.faces || []).map((f, i) => ({
x: f.region.x, y: f.region.y, w: f.region.w, h: f.region.h,
label: f.dominant_emotion || f.dominant_gender || `Face ${i + 1}`,
sublabel: f.age ? `~${Math.round(f.age)}y` : null,
tone: i === focusIdx ? 'accent' : 'default',
})), [result, focusIdx])
const faces = result?.faces || []
const focus = faces[focusIdx]
return (
<form className="biometrics-twocol" onSubmit={submit}>
<aside className="biometrics-panel">
<h2 className="biometrics-panel__title">Analyze a face</h2>
<ui.MediaInput mode="image" label="Source image" value={img} onChange={setImg} idPrefix="face-analyze" />
<fieldset className="biometrics-fieldset">
<legend>Guess attributes <small>(off by default, often wrong)</small></legend>
<div className="biometrics-chipset" role="group">
{['age', 'gender', 'emotion', 'race'].map(k => (
<label key={k} className={`biometrics-chip ${actions[k] ? 'active' : ''}`}>
<input type="checkbox" checked={actions[k]} onChange={(e) => setActions(a => ({ ...a, [k]: e.target.checked }))} />
<span>{k}</span>
</label>
))}
</div>
</fieldset>
<div className="form-row">
<div className="form-row__label">
<span className="form-row__label-text">Anti-spoofing</span>
<span className="form-row__hint">Reject photos-of-photos (requires model support).</span>
</div>
<label className="biometrics-switch">
<input type="checkbox" checked={antiSpoofing} onChange={(e) => setAntiSpoofing(e.target.checked)} />
<span aria-hidden="true" />
</label>
</div>
<button type="submit" className="btn btn-primary btn-full" disabled={loading || !img}>
{loading ? <><ui.Spinner size="sm" /> Analyzing…</> : <><Icon name="sparkles" /> Analyze</>}
</button>
</aside>
<section className="biometrics-results">
{loading && <div className="biometrics-empty"><ui.Spinner size="lg" /></div>}
{error && <ui.ErrorWithTraceLink message={error} />}
{!loading && !error && !result && (
<EmptyState icon="smile"
title="Drop a portrait to analyze"
body="The backend will detect each face and return age, gender, emotion, and race distributions — with an optional liveness check." />
)}
{result && img && (
<>
<div className="biometrics-split">
<div className="biometrics-split__media">
<BoundingBoxCanvas src={img.dataUrl} boxes={boxes} alt="Analyzed source" />
{faces.length > 1 && (
<div className="biometrics-facepicker" role="tablist" aria-label="Select face">
{faces.map((_, i) => (
<button key={i} type="button"
className={`biometrics-facepicker__chip ${i === focusIdx ? 'active' : ''}`}
onClick={() => setFocusIdx(i)}
aria-pressed={i === focusIdx}>
Face {i + 1}
</button>
))}
</div>
)}
</div>
<div className="biometrics-split__aside">
{focus && (
<>
<div className="biometrics-summary card">
<div className="biometrics-summary__head">
<h3><Icon name="user" /> Face {focusIdx + 1}</h3>
{antiSpoofing && <LivenessPill isReal={focus.is_real} score={focus.antispoof_score} />}
</div>
<dl className="biometrics-summary__grid">
{focus.age != null && <><dt>Age</dt><dd>~{Math.round(focus.age)}</dd></>}
{focus.dominant_gender && <><dt>Gender</dt><dd>{focus.dominant_gender}</dd></>}
{focus.dominant_emotion && <><dt>Emotion</dt><dd>{focus.dominant_emotion}</dd></>}
{focus.dominant_race && <><dt>Race</dt><dd>{focus.dominant_race}</dd></>}
{focus.face_confidence != null && <><dt>Detection</dt><dd>{(focus.face_confidence * 100).toFixed(1)}%</dd></>}
</dl>
</div>
<ui.DistributionBars title="Gender" icon="gender" distribution={focus.gender} dominant={focus.dominant_gender} />
<ui.DistributionBars title="Emotion" icon="smile" distribution={focus.emotion} dominant={focus.dominant_emotion} />
<ui.DistributionBars title="Race" icon="globe" distribution={focus.race} dominant={focus.dominant_race} />
</>
)}
</div>
</div>
<ResponseDetails data={result} />
</>
)}
</section>
</form>
)
}
function EmbedTab({ model }) {
const [img, setImg] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [result, setResult] = useState(null)
const [elapsedMs, setElapsedMs] = useState(null)
const submit = async (e) => {
e.preventDefault()
if (!model) { ctx.toast('Select a face model first', 'warning'); return }
if (!img) { ctx.toast('Add an image', 'warning'); return }
setLoading(true); setError(null); setResult(null)
const started = performance.now()
try {
const data = await api.face.embed({ model, img: img.dataUrl })
setElapsedMs(performance.now() - started)
setResult(data)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<form className="biometrics-twocol" onSubmit={submit}>
<aside className="biometrics-panel">
<h2 className="biometrics-panel__title">Get a raw embedding</h2>
<p className="biometrics-panel__note">
Returns a single face embedding vector. This is the same representation the backend uses internally for verify, identify, and compare.
</p>
<ui.MediaInput mode="image" label="Image" value={img} onChange={setImg} idPrefix="face-embed" />
<button type="submit" className="btn btn-primary btn-full" disabled={loading || !img}>
{loading ? <><ui.Spinner size="sm" /> Embedding…</> : <><Icon name="code" /> Extract vector</>}
</button>
</aside>
<section className="biometrics-results">
{loading && <div className="biometrics-empty"><ui.Spinner size="lg" /></div>}
{error && <ui.ErrorWithTraceLink message={error} />}
{!loading && !error && !result && (
<EmptyState icon="code"
title="Get a face embedding"
body="For developers — retrieve the raw vector for a face to store, search, or cluster outside of LocalAI." />
)}
{result && (
<ui.EmbeddingInspector embedding={result.embedding} dim={result.dim} model={result.model} elapsedMs={elapsedMs} />
)}
</section>
</form>
)
}
function LivenessPill({ isReal, score }) {
if (isReal == null) {
return <span className="biometrics-pill muted"><Icon name="help-circle" /> Not checked</span>
}
return (
<span className={`biometrics-pill ${isReal ? 'good' : 'bad'}`}>
<Icon name={isReal ? 'user-shield' : 'mask'} />
{isReal ? 'Real' : 'Spoof'}
{score != null && <small>{(score * 100).toFixed(0)}%</small>}
</span>
)
}
// Not ui.EmptyState: this is the biometrics empty card of the core page.
function EmptyState({ icon, title, body }) {
return (
<div className="biometrics-empty">
<Icon name={icon} aria-hidden="true" />
<h3>{title}</h3>
<p>{body}</p>
</div>
)
}
function ResponseDetails({ data }) {
return (
<details className="biometrics-response">
<summary><Icon name="chevron-right" /> Raw response</summary>
<pre>{JSON.stringify(data, null, 2)}</pre>
</details>
)
}
return { MoreFaceTools }
}
+35
View File
@@ -0,0 +1,35 @@
// The core routes the Face add-on uses, reached with the signed-in user's own
// session (sdk.core.fetch). Paths and bodies are the ones of faceApi in
// core/http/react-ui/src/utils/api.js; only the transport changed. register
// takes { model, name, img, labels }, identify { model, img, ... }.
import { createIdentityApi } from '../../../internal/identity-ui/src/registry.js'
export function createApi(ctx) {
const { json } = ctx
const query = (params = {}) => {
const q = new URLSearchParams()
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== '') q.set(k, String(v))
const s = q.toString()
return s ? `?${s}` : ''
}
// /v1/voice/* and /v1/face/*: the shared views read both kinds; the page only
// uses the face one. A failed call keeps its status (a 404 on forget reads as
// already gone).
const identity = createIdentityApi(ctx)
return {
identity,
face: identity.face,
models: {
listCapabilities: () => json('/api/models/capabilities'),
// The gallery list, as modelsApi.list builds it: empty parameters are left out.
list: (params) => json(`/api/models${query(params)}`),
// Starts a gallery install (modelsApi.install).
install: (id) => json(`/api/models/install/${encodeURIComponent(id)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }),
},
// The running installs, polled while one is in progress.
operations: {
list: () => json('/api/operations'),
},
raw: ctx.core,
}
}
+67
View File
@@ -0,0 +1,67 @@
import en from './i18n/en.js'
import { modelFrom as pickModel } from './lib/model.js'
// The one object every page factory of the Face add-on receives. It binds the
// SDK once: React, the shared components, the session-bound core fetch, the
// translation and the small adapters that replace the host router and toasts.
export function createContext(sdk) {
const { React } = sdk
const h = React.createElement
const Fragment = React.Fragment
sdk.locale.define('en', Object.fromEntries(Object.entries(en).filter(([, v]) => typeof v === 'string')))
const lists = Object.fromEntries(Object.entries(en).filter(([, v]) => Array.isArray(v)))
const t = (key, vars) => sdk.locale.t(key, key, vars)
// A list of strings or objects (the "why" and "stored" notes).
const list = (key) => lists[key] || []
// A link to a page of this add-on, as an anchor the shell navigates.
function Link({ page, query, className, children, ...rest }) {
const href = sdk.navigation.href(query ? `${page}?${query}` : page)
return h('a', {
href, className,
onClick: (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; e.preventDefault(); sdk.navigation.navigate(query ? `${page}?${query}` : page) },
...rest,
}, children)
}
const useSearch = () => new URLSearchParams(sdk.navigation.current().search)
// The model a page works on: ?model=<id> first (a moved link carries it
// there, because a model id may hold "/" or ":" that a path segment cannot),
// then the first path segment of a link typed by hand.
const modelFrom = (path) => pickModel(sdk.navigation.current().search, path)
const toast = (message, kind = 'info') => (sdk.notify[kind] || sdk.notify.info)(message)
// The path prefix LocalAI is served under, read the way the host reads it
// (utils/basePath.js: the <base href> element). document.baseURI would be the
// address of the current page when there is no such element.
const basePath = (() => {
if (typeof document === 'undefined') return ''
const el = document.querySelector('base[href]')
if (!el) return ''
try { return new URL(el.getAttribute('href')).pathname.replace(/\/+$/, '') } catch { return '' }
})()
const json = async (path, init) => {
const response = await sdk.core.fetch(path, init)
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body?.error?.message || `HTTP ${response.status}`)
}
return response.json()
}
// The undo bar of a delayed action: text and an Undo button. Replaces the
// host's toast with an undo, which the SDK does not expose.
function UndoBar({ text, undoLabel, onUndo }) {
return h('div', { className: 'vc-undo', role: 'status' }, h('span', null, text), h(sdk.ui.Button, { variant: 'ghost', size: 'sm', onClick: onUndo }, undoLabel))
}
return {
sdk, React, h, Fragment, t, list, Icon: sdk.Icon, ui: sdk.ui, hooks: sdk.hooks, viewer: sdk.viewer, core: sdk.core,
Link, useSearch, modelFrom, toast, json, UndoBar, basePath,
openPanel: (title, render) => sdk.layers.openPanel({ title, render }),
markDirty: (flag) => sdk.markDirty(flag),
}
}
+198
View File
@@ -0,0 +1,198 @@
// Generated once from the host locale files; edit by hand from here on.
export default {
"faces.title": "Faces",
"faces.lede": "Find out whose face is in a photo, or whether two photos show the same person. Photos go only to the model running on this server.",
"tool.aria": "Recognition tool",
"tool.sameTab": "Same person?",
"tool.clip": "Clip",
"tool.clipA": "Clip A",
"tool.clipB": "Clip B",
"tool.photo": "Photo",
"tool.photoA": "Photo A",
"tool.photoB": "Photo B",
"tool.compare": "Compare",
"tool.comparing": "Comparing",
"tool.workingAria": "Working on it",
"face.person": "person",
"face.print": "faceprint",
"face.whoTab": "Who is this",
"face.whoLede": "Matches a photo against the people you enrolled.",
"face.sameLede": "Two photos, one question. Nobody has to be enrolled.",
"face.whoGo": "Find who this is",
"face.working": "Searching",
"face.whoNote": "Searches the people enrolled on this server. The photo you test with is not stored.",
"face.sameNote": "The answer comes from one faceprint per photo. Nothing is stored.",
"face.workingText": "Comparing the photo with the faceprints the server holds.",
"face.probably": "Probably {{name}}",
"face.matchText": "{{name}}'s faceprint is the closest of {{count}} compared, {{distance}} away. Anything under {{cutoff}} counts as the same person.",
"face.nobody": "Nobody enrolled looks like this",
"face.missText": "The closest was {{name}} at {{distance}}, past the cut-off of {{cutoff}}. That usually means a new person, or a photo that is blurred, turned away or in poor light.",
"face.noneEnrolled": "Nobody is enrolled to compare with",
"face.noneEnrolledText": "Enrol a person first, then look up a photo against them.",
"face.noneReturned": "The server returned no people",
"face.noneReturnedText": "It holds no faceprints made with this model. If the server restarted, enrolled faces are gone: enrol them again.",
"face.enrolThis": "Enrol this face",
"face.same": "Probably the same person",
"face.different": "Probably different people",
"face.knownTitle": "Known people",
"face.enrol": "Enrol a person",
"face.enrolTitle": "Enrol a person",
"face.enrolIntro": "The server keeps a faceprint and a name. It does not keep the photo.",
"face.emptyTitle": "Nobody enrolled in this browser yet",
"face.emptyText": "Enrol a person with one clear photo, then test a photo against them.",
"face.agreedHint": "I have their permission, or another lawful basis, to store a faceprint of them.",
"face.keepHint": "Lets you see the photo and enrol again after a server restart. Off by default. It is stored in this browser only.",
"face.antiSpoof": "Check for photos of photos",
"face.antiSpoofHint": "Needs a model that supports it.",
"face.real": "Real",
"face.spoof": "Spoof",
"face.found": "Face in photo {{n}}",
"face.neededTitle": "Faces need a face model",
"face.neededText": "Face recognition turns a photo into a faceprint and compares faceprints. It runs on a face-recognition model, and none is installed on this server.",
"face.neededWhat": "Install a model that supports face recognition. Once one is installed, this page turns on by itself. Users also need the Face recognition permission.",
"face.moreTitle": "More tools",
"face.moreHint": "detect faces, guess traits, get the raw faceprint",
"face.why": [
"The server matches one face per photo. A group photo is matched on a single face.",
"Light, angle, glasses and blur change a face a lot.",
"This is a similarity score, not a security check. Do not use it to unlock anything."
],
"face.stored": [
{
"where": "On the server",
"what": "A {{print}} (numbers that describe the face, not the picture), a name and your labels. They are kept in memory and are lost when the server restarts. The photo is not kept."
},
{
"where": "In this browser",
"what": "The name, labels and time of each person you enrolled. A copy of the photo only if you tick Keep a copy when enrolling."
},
{
"where": "Photos you test with",
"what": "They go to the model on this server to be compared and are not kept."
}
],
"same.text": "The two {{print}}s are {{distance}} apart. Anything under {{cutoff}} counts as the same person.",
"same.distance": "distance",
"word.strong": "Strong match",
"word.likely": "Likely match",
"word.close": "Close call",
"word.near": "Just past the cut-off",
"scale.aria": "Distance scale with the cut-off at {{cutoff}}. Lower means more alike. {{dots}}",
"scale.cutoff": "cut-off {{value}}",
"scale.axis": "distance between {{print}}s, lower means more alike",
"scale.oneDot": "one dot per enrolled {{person}}",
"scale.onePair": "one dot for the pair",
"ranks.aria": "Closest people, nearest first",
"ranks.within": "within cut-off",
"ranks.far": "too far",
"cutoff.label": "Cut-off",
"cutoff.default": "model default",
"cutoff.reset": "Reset to {{value}}",
"cutoff.stricter": "Stricter",
"cutoff.looser": "Looser",
"cutoff.explain": "Stricter means fewer wrong matches and more misses. The answer above updates as you move it. The server is not asked again.",
"how.title": "How to read this.",
"how.body": "Distance is how far apart two {{print}}s are, 0 meaning identical. The cut-off is the line between same and different. The word Strong or Likely says how far inside the line a result sits. It is not a probability.",
"why.title": "Why this might be wrong",
"why.short": "The clip is short: {{s}} s. Three seconds or more of speech works better.",
"clip.mic": "microphone",
"clip.camera": "camera",
"clip.record": "Record",
"clip.takePhoto": "Take a photo",
"clip.chooseAudio": "Choose a file",
"clip.choosePhoto": "Choose a photo",
"clip.readAloud": "Read this aloud, normal pace",
"clip.remove": "Remove",
"clip.blocked": "The {{thing}} is blocked. Allow it in the browser's site settings and try again, or choose a file instead.",
"clip.insecure": "A browser only opens the {{thing}} on https or on localhost. You are on {{origin}}. Open this page through localhost or add TLS, or choose a file.",
"clip.unsupported": "This browser cannot open the {{thing}}. Choose a file instead.",
"clip.missing": "No {{thing}} was found on this device. Choose a file instead.",
"clip.failed": "The {{thing}} could not be opened. Choose a file instead.",
"clip.allowed": "Microphone allowed.",
"clip.capture": "Capture",
"clip.cancel": "Cancel",
"clip.recording": "Recording, {{s}} s",
"clip.micReady": "Microphone ready",
"clip.stop": "Stop",
"clip.start": "Start recording",
"clip.recorded": "Recording",
"clip.photographed": "Photo",
"clip.uploaded": "Uploaded file",
"clip.goodLength": "good length",
"clip.short": "short",
"clip.checkLevel": "check level",
"clip.preview": "Preview",
"clip.photoAlt": "The photo you chose",
"known.caption": "People saved in this browser, with their {{noun}} state",
"known.name": "Name",
"known.added": "Added",
"known.actions": "Actions",
"known.best": "best match",
"known.notOnServer": "not on the server",
"known.notInBrowser": "not in this browser's list",
"known.removing": "removing",
"known.goneText": "The last search did not return this person. The server may have restarted, so the {{print}} is gone, or it was enrolled with another model.",
"known.extraText": "The server returned this person but this browser has no record of them.",
"known.noLabels": "no labels",
"known.undo": "Undo",
"known.reenrolSaved": "Re-enrol from saved copy",
"known.enrolAgain": "Enrol again",
"known.play": "Play the saved sample of {{name}}",
"known.forget": "Remove {{name}}",
"known.note": "This list is kept in this browser. The server has no list call, so it cannot be checked until you run a search.",
"known.noteSearched": "This list is kept in this browser. The last search checked it against the server; a person it did not return is marked.",
"stored.title": "What is stored, and where",
"enrol.close": "Close",
"enrol.cancel": "Cancel",
"enrol.submit": "Enrol",
"enrol.working": "Enrolling",
"enrol.ready": "Ready to enrol.",
"enrol.stillNeeded": "Still needed: {{list}}.",
"enrol.needModel": "a model",
"enrol.needRecording": "a recording",
"enrol.needPhoto": "a photo",
"enrol.needName": "a name",
"enrol.needTranscript": "a transcript",
"enrol.needPermission": "permission",
"enrol.recordingStep": "Recording",
"enrol.photoStep": "Photo",
"enrol.recordingLabel": "Recording",
"enrol.photoLabel": "Photo",
"enrol.whoStep": "Who is it",
"enrol.name": "Name",
"enrol.namePlaceholder": "e.g. Marta Rossi",
"enrol.labels": "Labels",
"enrol.labelsHint": "optional, one per line as key: value",
"enrol.asSpeech": "Also keep it as a speech voice",
"enrol.asSpeechHint": "Text-to-speech can then speak in this voice. Needs the exact transcript.",
"enrol.transcript": "Exact transcript",
"enrol.permissionStep": "Permission",
"enrol.agreed": "This person agreed",
"enrol.keep": "Keep a copy in this browser",
"toast.enrolled": "Enrolled {{name}}",
"toast.enrolledBoth": "Enrolled {{name}} and saved a speech voice",
"toast.tooBig": "The copy was too large for browser storage, so it was not kept.",
"toast.speechFailed": "Enrolled, but the speech voice could not be saved: {{message}}",
"toast.removing": "{{name}} will be removed.",
"toast.removed": "Removed {{name}}",
"toast.alreadyGone": "{{name}} was already gone from the server.",
"error.retry": "Try again",
"error.traces": "Open traces",
"needed.badge": "Not enabled on this server",
"needed.backend": "backend {{backend}}",
"needed.install": "Install",
"needed.installing": "Installing",
"needed.started": "Installing {{name}}. Progress is in Operations.",
"needed.loadFailed": "Models could not be listed.",
"needed.noneListed": "No matching model is listed in the galleries.",
"needed.browse": "Browse all models",
"needed.askAdmin": "Ask an administrator to install one.",
"needed.whatTitle": "What turns this on",
"off.badge": "Not enabled for your account",
"off.voice.title": "Voice recognition is off for your account",
"off.voice.text": "An administrator has not allowed you to use voice recognition.",
"off.voice.what": "An administrator can turn on the Voice recognition permission for your user in Users and keys.",
"off.face.title": "Face recognition is off for your account",
"off.face.text": "An administrator has not allowed you to use face recognition.",
"off.face.what": "An administrator can turn on the Face recognition permission for your user in Users and keys."
}
+27
View File
@@ -0,0 +1,27 @@
import { createContext } from './ctx.jsx'
import { createApi } from './api.js'
import { createFaceRecognition } from './FaceRecognition.jsx'
import * as identity from '../../../internal/identity-ui/src/identity.js'
// The second argument is for tests: the host passes the SDK only.
export function createAddon(sdk, options = {}) {
const ctx = createContext(sdk)
// The JSX of every factory compiles to calls of `h`; ctx binds it once and
// each factory reads it from ctx. This `h` is for the JSX in this file.
// eslint-disable-next-line no-unused-vars
const { h } = ctx
const api = createApi(ctx)
const pages = {
recognition: createFaceRecognition(ctx, api, identity).FaceRecognition,
...options.pages,
}
function Component({ page, path }) {
const Page = pages[page]
return Page ? <Page path={path || ''} /> : null
}
return { Component, components: {}, chatActions: [], dispose() {} }
}
// For tests: the context of an SDK, built the way createAddon builds it.
export const __ctxFor = (sdk) => createContext(sdk)
+8
View File
@@ -0,0 +1,8 @@
// The model a page works on. A moved link carries it as ?model=<id>, because
// a model id may hold "/" or ":" that an add-on path segment refuses; a link
// typed by hand may still carry it as the first path segment.
export function modelFrom(search, path) {
const fromQuery = new URLSearchParams(search || '').get('model')
if (fromQuery) return fromQuery
return String(path || '').split('/')[0]
}
+39
View File
@@ -0,0 +1,39 @@
.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-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. */
.idn-clip { display: flex; flex-direction: column; gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-3); background: var(--dk-inset); border: 1px solid var(--dk-edge); min-width: 0; }
.idn-clip__head { display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-2); min-height: 28px; }
.idn-clip__choices { display: flex; flex-direction: column; gap: var(--dk-space-3); align-items: flex-start; }
.idn-clip__buttons { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; }
.idn-clip__prompt { margin: 0; padding: var(--dk-space-3); border-radius: var(--dk-radius-2); background: var(--dk-card); border: 1px solid var(--dk-edge); font-size: var(--dk-text-15); line-height: 1.5; }
.idn-clip__prompt .dk-eyebrow { display: block; margin-bottom: 4px; }
.idn-clip__hint, .idn-clip__notice { display: flex; align-items: flex-start; gap: var(--dk-space-2); margin: 0; font-size: var(--dk-text-13); color: var(--dk-muted); }
.idn-clip__hint .lai-icon, .idn-clip__notice .lai-icon { flex: none; margin-top: 2px; }
.idn-clip__notice { padding: var(--dk-space-2) var(--dk-space-3); border-radius: var(--dk-radius-2); background: var(--dk-card); border: 1px solid var(--dk-edge); color: var(--dk-text); }
.idn-clip__notice[role="alert"] { background: var(--dk-warn-wash); border-color: transparent; color: var(--dk-warn-text); }
.idn-clip__live { display: flex; flex-direction: column; gap: var(--dk-space-3); }
.idn-clip__video { width: 100%; max-width: 420px; border-radius: var(--dk-radius-2); background: var(--dk-canvas); }
.idn-clip__rec { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); font-size: var(--dk-text-15); }
.idn-clip__card { display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.idn-clip__meta { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; min-width: 0; }
.idn-clip__name { min-width: 0; overflow-wrap: anywhere; font-size: var(--dk-text-15); }
.idn-clip__img { max-width: 100%; max-height: 260px; width: auto; border-radius: var(--dk-radius-2); object-fit: contain; align-self: flex-start; background: var(--dk-canvas); }
.idn-clip__notes { margin: 0; padding-left: 18px; font-size: var(--dk-text-13); color: var(--dk-warn-text); }
.idn-clip .voice-profile-player, .idn-clip .waveform-player { min-width: 0; }
.idn-disclosure > summary { display: flex; align-items: center; gap: var(--dk-space-2); min-height: 40px; cursor: pointer; font-weight: var(--dk-weight-semibold); font-size: var(--dk-text-15); list-style: none; }
.idn-disclosure > summary::-webkit-details-marker { display: none; }
.idn-disclosure > summary .lai-icon { color: var(--dk-muted); transition: transform var(--dk-dur-1) var(--dk-ease-out); }
.idn-disclosure[open] > summary .lai-icon:first-child { transform: rotate(90deg); }
.idn-disclosure > summary .dk-hint { font-weight: 400; }
.idn-avatar { display: inline-grid; place-items: center; flex: none; width: 36px; height: 36px; border-radius: 50%; overflow: hidden; background: var(--dk-inset); border: 1px solid var(--dk-edge); font: var(--dk-weight-bold) 12px var(--dk-font-mono); color: var(--dk-text); }
.idn-avatar img { width: 100%; height: 100%; object-fit: cover; }
.idn-avatar[data-tone="warn"] { background: var(--dk-warn-wash); color: var(--dk-warn-text); border-color: transparent; }
.idn-error { display: grid; grid-template-columns: 32px minmax(0, 1fr); gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-2); background: var(--dk-error-wash); color: var(--dk-error-text); }
.idn-error__icon { font-size: 22px; padding-top: 2px; }
.idn-error__title { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-15) / 1.3 var(--dk-font-display); }
.idn-error__text, .idn-error__raw { margin: 4px 0 0; font-size: var(--dk-text-13); }
.idn-error__raw { opacity: .85; overflow-wrap: anywhere; }
.idn-error__acts { display: flex; gap: var(--dk-space-2); flex-wrap: wrap; margin-top: var(--dk-space-3); }
.idn-error .dk-btn { color: inherit; }
+17
View File
@@ -0,0 +1,17 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { modelFrom } from '../src/lib/model.js'
test('the model comes from ?model first and keeps slashes, colons and percent signs', () => {
assert.equal(modelFrom('model=org%2Fname%3Av1', 'ignored'), 'org/name:v1')
})
test('without ?model the model is the first path segment', () => {
assert.equal(modelFrom('', 'speechbrain/extra'), 'speechbrain')
assert.equal(modelFrom('x=1', 'speechbrain'), 'speechbrain')
})
test('no model gives an empty string', () => {
assert.equal(modelFrom('', ''), '')
assert.equal(modelFrom(undefined, undefined), '')
})
+86
View File
@@ -0,0 +1,86 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { dirname, join, resolve } from 'node:path'
import { load } from './render.test.mjs'
const here = dirname(fileURLToPath(import.meta.url))
const require = createRequire(join(resolve(here, '../../../../core/http/react-ui'), 'package.json'))
const React = require('react')
const { renderToString } = require('react-dom/server')
test('recognition: the first paint has the faces page test id', async () => {
const { mod, sdk } = await load()
const addon = await mod.createAddon(sdk)
const html = renderToString(React.createElement(addon.Component, { page: 'recognition', path: '' }))
assert.match(html, /data-testid="faces-page"/)
})
test('recognition: a viewer without face_recognition gets the off notice', async () => {
const { mod, sdk } = await load({ viewer: { isAdmin: () => false, hasFeature: (f) => f !== 'face_recognition' } })
const addon = await mod.createAddon(sdk)
const html = renderToString(React.createElement(addon.Component, { page: 'recognition', path: '' }))
assert.match(html, /data-testid="face-off"/)
assert.doesNotMatch(html, /idn-bench/)
})
test('recognition: register, identify and forget use the core face routes', async () => {
const { createApi } = await import('../src/api.js')
const seen = []
const ctx = { core: { fetch: async (p, i) => { seen.push([p, i?.method]); return { ok: true, json: async () => ({}) } } }, json: async (p, i) => { seen.push([p, i?.method]); return {} } }
const api = createApi(ctx)
await api.face.register({ model: 'm', img: 'x', name: 'a' })
await api.face.identify({ model: 'm', img: 'x' })
await api.face.forget({ model: 'm', id: 'a' })
assert.deepEqual(seen.map(s => s[0]), ['/v1/face/register', '/v1/face/identify', '/v1/face/forget'])
assert.deepEqual(seen.map(s => s[1]), ['POST', 'POST', 'POST'])
})
test('recognition: any viewer who holds face_recognition gets the library, administrator or not', async () => {
const { mod, sdk } = await load({ viewer: { isAdmin: () => false, hasFeature: () => true } })
const addon = await mod.createAddon(sdk)
const html = renderToString(React.createElement(addon.Component, { page: 'recognition', path: '' }))
assert.match(html, /idn-bench/)
assert.doesNotMatch(html, /data-testid="face-off"/)
})
test('recognition: the model of the path reaches the model selection', async () => {
const seen = []
const names = ['Button', 'Spinner', 'EmptyState', 'Badge', 'Stack', 'Text', 'PageHeader', 'ConfirmDialog', 'ErrorWithTraceLink', 'MediaInput', 'WaveformPlayer', 'DistributionBars', 'EmbeddingInspector', 'TabSwitch']
const components = Object.fromEntries(names.map(n => [n, (p) => React.createElement('div', null, p.title || p.label || p.children || null)]))
components.PageHeader = (p) => React.createElement('header', null, p.title, p.actions)
components.ModelSelector = (p) => { seen.push(p.value); return React.createElement('div') }
const { mod, sdk } = await load({ components })
const addon = await mod.createAddon(sdk)
renderToString(React.createElement(addon.Component, { page: 'recognition', path: 'insightface' }))
assert.deepEqual(seen, ['insightface'])
})
test('recognition: the bundle has the face canvas, the face strings and no host import', async () => {
const { readFileSync } = await import('node:fs')
const { build } = await import('../build.mjs')
const dir = await build()
const js = readFileSync(join(dir, 'ui/face.js'), 'utf8')
assert.match(js, /biometrics-bbox/)
assert.doesNotMatch(js, /from ['"]react(-dom)?/)
assert.match(js, /faces\.title/)
})
test('recognition: every translation key the sources read is in the Face bundle', async () => {
const { readFileSync, readdirSync, statSync } = await import('node:fs')
const { default: en } = await import('../src/i18n/en.js')
const roots = [join(here, '../src'), join(here, '../../../internal/identity-ui/src')]
const files = []
const walk = (d) => { for (const f of readdirSync(d)) { const p = join(d, f); if (statSync(p).isDirectory()) walk(p); else if (/\.jsx?$/.test(p) && !p.includes('i18n')) files.push(p) } }
roots.forEach(walk)
const missing = []
for (const f of files) {
for (const m of readFileSync(f, 'utf8').matchAll(/\bt\(\s*'([a-zA-Z]+\.[a-zA-Z0-9_.]+)'/g)) {
// The voice branch of a shared view is never reached on this page.
if (m[1].startsWith('voice.')) continue
if (!(m[1] in en)) missing.push(`${f.split('/').pop()}: ${m[1]}`)
}
}
assert.deepEqual(missing, [])
})
+68
View File
@@ -0,0 +1,68 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { dirname, join, resolve } from 'node:path'
import { build } from '../build.mjs'
const here = dirname(fileURLToPath(import.meta.url))
const hostUi = resolve(here, '../../../../core/http/react-ui')
const require = createRequire(join(hostUi, 'package.json'))
const React = require('react')
const { renderToString } = require('react-dom/server')
export async function load(extra = {}) {
const dir = await build()
const mod = await import(pathToFileURL(join(dir, 'ui/face.js')).href + `?t=${Date.now()}`)
const { createHostSDK } = await import(pathToFileURL(join(hostUi, 'src/addons/sdk.js')).href)
const stub = (tag) => (p) => React.createElement(tag, { 'data-stub': tag }, p.title || p.label || p.children || null)
const calls = []
const controller = new AbortController()
const sdk = createHostSDK({
addonId: 'face', generation: 1, signal: controller.signal, pages: ['recognition'],
host: {
React,
components: Object.fromEntries(['Button', 'Spinner', 'EmptyState', 'Badge', 'Stack', 'Text', 'PageHeader', 'ConfirmDialog', 'ErrorWithTraceLink', 'ModelSelector', 'MediaInput', 'WaveformPlayer', 'DistributionBars', 'EmbeddingInspector', 'TabSwitch'].map(n => [n, stub(n === 'PageHeader' ? 'header' : 'div')])),
hooks: { useMediaCapture: () => ({}), useObjectUrl: () => null, useDelayedAction: () => ({ pending: null, schedule() {}, undo() {} }) },
viewer: { isAdmin: () => true, hasFeature: () => true },
basePath: '',
fetch: async (url, init) => { calls.push([url, init]); return { ok: true, json: async () => ({ data: [] }), blob: async () => new Blob([]) } },
Icon: () => null, theme: { mode: () => 'light', subscribe: () => () => {} }, language: () => 'en', subscribeLanguage: () => () => {}, api: {},
...extra,
},
bindings: { current: () => ({ page: 'recognition', path: '', search: '' }), openLayer: () => ({ close() {} }), notify() {} },
})
return { mod, sdk, controller, calls }
}
test('render: createAddon returns a page component and dispose', async () => {
const { mod, sdk, controller } = await load()
const addon = await mod.createAddon(sdk)
assert.equal(typeof addon.Component, 'function')
assert.equal(typeof addon.dispose, 'function')
addon.dispose()
controller.abort('done')
})
test('render: an unknown page draws nothing', async () => {
const { mod, sdk } = await load()
const addon = await mod.createAddon(sdk)
assert.equal(renderToString(React.createElement(addon.Component, { page: 'nope', path: '' })), '')
})
test('render: the manifest declares the recognition page, the main page and SDK 2', async () => {
const { readFileSync } = await import('node:fs')
const m = JSON.parse(readFileSync(join(here, '../../manifest.json'), 'utf8'))
assert.equal(m.id, 'face')
assert.deepEqual(m.capabilities.ui.pages, ['recognition'])
assert.deepEqual(m.capabilities.ui.navigation, [])
assert.equal(m.capabilities.ui.mainPage, 'recognition')
assert.deepEqual(m.capabilities.ui.sdk, { min: 2, max: 2 })
assert.deepEqual(m.capabilities.tools ?? [], [])
})
test('render: a registered page is drawn with its path', async () => {
const { mod, sdk } = await load()
const addon = await mod.createAddon(sdk, { pages: { recognition: ({ path }) => React.createElement('p', null, `page:${path}`) } })
assert.equal(renderToString(React.createElement(addon.Component, { page: 'recognition', path: 'abc' })), '<p>page:abc</p>')
})
+23
View File
@@ -0,0 +1,23 @@
// Runs the node unit tests of the Voice UI package and prints one JSON line
// per test, the same shape as the host's runner (core/http/react-ui/tests/unit):
// {"case":"unit: <name>","status":"passed"|"failed"|"skipped"}
import { run } from 'node:test'
import { readdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const names = readdirSync(here).filter(f => f.endsWith('.test.mjs')).sort()
let failed = false
const stream = run({ files: names.map(f => join(here, f)), concurrency: 1 })
stream.on('test:pass', ({ name, skip, todo, nesting }) => {
if (nesting > 0) return
console.log(JSON.stringify({ case: `unit: ${name}`, status: skip || todo ? 'skipped' : 'passed' }))
})
stream.on('test:fail', ({ name, nesting, details }) => {
if (nesting > 0) return
failed = true
console.log(JSON.stringify({ case: `unit: ${name}`, status: 'failed', error: String(details && details.error && details.error.message || 'failed').slice(0, 400) }))
})
stream.on('end', () => { process.exit(failed ? 1 : 0) })
stream.resume()
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: MIT
// Command fine-tuning is the executable of the fine-tuning add-on. It serves
// the typed tools of the add-on and reaches the job engine through the host
// operations; the add-on UI works in the browser through the SDK.
package main
import (
"os"
"github.com/mudler/LocalAI/addons/fine-tuning/internal/tools"
"github.com/mudler/LocalAI/addons/internal/hostclient"
"github.com/mudler/LocalAI/addons/internal/protocol"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
func main() {
os.Exit(protocol.Run(os.Args, func(s protocol.Startup) contract.Runtime {
return tools.New(hostclient.New(s), s.DataDir)
}))
}
@@ -0,0 +1,142 @@
// SPDX-License-Identifier: MIT
package tools
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sync"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// maxReceipts bounds the receipts the journal keeps. A repeat of an older
// operation is not answered from memory any more; the host asks for a receipt
// only for a recent, unsettled operation.
const maxReceipts = 1024
// job is what the add-on knows of one resource a tool created: a job it
// started or a dataset it sent. The engine job ID
// never leaves the add-on: a model names the job by its resource.
type job struct {
JobID string `json:"jobId,omitempty"`
Name string `json:"name"`
// Dataset marks a resource that stands for an inline dataset and not for a
// job; Ref is the reference the host issued for it.
Dataset bool `json:"dataset,omitempty"`
Ref string `json:"ref,omitempty"`
// Revision counts the changes a tool made to the job. It is the data
// revision of the resource, so a stale approval is noticed.
Revision uint64 `json:"revision"`
}
type journalFile struct {
Jobs map[string]job `json:"jobs"`
Receipts map[string]contract.Receipt `json:"receipts"`
Order []string `json:"order"`
}
// journal maps resource IDs to engine job IDs and keeps the receipts of the
// operations that changed something. It is one JSON file in the data
// directory, written through a temporary file and renamed over the old one.
type journal struct {
mu sync.Mutex
path string
data journalFile
}
func openJournal(dir string) *journal {
j := &journal{path: filepath.Join(dir, "journal.json")}
j.data = journalFile{Jobs: map[string]job{}, Receipts: map[string]contract.Receipt{}}
if b, err := os.ReadFile(j.path); err == nil {
var f journalFile
if json.Unmarshal(b, &f) == nil {
if f.Jobs != nil {
j.data.Jobs = f.Jobs
}
if f.Receipts != nil {
j.data.Receipts = f.Receipts
}
j.data.Order = f.Order
}
}
return j
}
func (j *journal) job(resource string) (job, bool) {
j.mu.Lock()
defer j.mu.Unlock()
v, ok := j.data.Jobs[resource]
return v, ok
}
func (j *journal) receipt(op string) (contract.Receipt, bool) {
j.mu.Lock()
defer j.mu.Unlock()
r, ok := j.data.Receipts[op]
return r, ok
}
// commit stores the receipt of an operation and, when upd is not nil, the new
// state of a job in one write. A failed write leaves the memory as it was, so
// the caller reports the failure and nothing claims a state that is not on disk.
func (j *journal) commit(resource string, upd *job, r contract.Receipt) error {
j.mu.Lock()
defer j.mu.Unlock()
next := journalFile{Jobs: map[string]job{}, Receipts: map[string]contract.Receipt{}, Order: append([]string(nil), j.data.Order...)}
for k, v := range j.data.Jobs {
next.Jobs[k] = v
}
for k, v := range j.data.Receipts {
next.Receipts[k] = v
}
if upd != nil {
next.Jobs[resource] = *upd
}
if _, seen := next.Receipts[r.OperationID]; !seen {
next.Order = append(next.Order, r.OperationID)
}
next.Receipts[r.OperationID] = r
for len(next.Order) > maxReceipts {
delete(next.Receipts, next.Order[0])
next.Order = next.Order[1:]
}
if err := writeAtomic(j.path, next); err != nil {
return err
}
j.data = next
return nil
}
func writeAtomic(path string, v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), "journal-*.tmp")
if err != nil {
return err
}
name := tmp.Name()
fail := func(e error) error { _ = tmp.Close(); _ = os.Remove(name); return e }
if _, err := tmp.Write(b); err != nil {
return fail(err)
}
if err := tmp.Sync(); err != nil {
return fail(err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(name)
return err
}
if err := os.Chmod(name, 0o600); err != nil {
_ = os.Remove(name)
return errors.Join(err)
}
if err := os.Rename(name, path); err != nil {
_ = os.Remove(name)
return err
}
return nil
}
+557
View File
@@ -0,0 +1,557 @@
// SPDX-License-Identifier: MIT
// Package tools is the runtime of the fine-tuning add-on executable. A job that
// a tool starts is a resource of the host: the host reserves the resource ID,
// and this package remembers which engine job it stands for. A tool can only
// control a job that a tool started, because only those have a resource.
package tools
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"regexp"
"strings"
"time"
"unicode/utf8"
"github.com/mudler/LocalAI/addons/internal/hostclient"
"github.com/mudler/LocalAI/addons/internal/protocol"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// Tool names. They are the entries of capabilities.tools in the manifest and
// the exact values of arguments.tool.
const (
ToolResourceExists = "resource-exists"
ToolList = "fine-tune-list"
ToolStart = "fine-tune-start"
ToolDataset = "fine-tune-dataset"
ToolStatus = "fine-tune-status"
ToolCheckpoints = "fine-tune-checkpoints"
ToolStop = "fine-tune-stop"
ToolExport = "fine-tune-export"
)
// Tools is the declared tool set of this add-on, in manifest order.
//
// fine-tune-list is a read tool that names no resource: it lists the jobs of the
// calling user, the ones started in the UI included. The host runs it as that
// user and the host operation scopes the answer to the same user. Every other
// tool works on a resource that a tool created.
var Tools = []string{ToolResourceExists, ToolList, ToolStart, ToolDataset, ToolStatus, ToolCheckpoints, ToolStop, ToolExport}
// Engine is the host operation channel. *hostclient.Client implements it, and
// a nil client answers hostclient.ErrNoHostOps.
type Engine interface {
Do(ctx context.Context, operationID, op string, args, out any) error
}
var _ Engine = (*hostclient.Client)(nil)
// schema is the schema revision of every job. The data revision counts the
// changes a tool made.
const schema uint64 = 1
var (
modelName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
exportFormats = map[string]bool{"lora": true, "merged_16bit": true, "merged_4bit": true, "gguf": true}
)
const maxNameRunes = 80
// maxDatasetBytes is the bound of an inline dataset, the one of the host
// operation.
const maxDatasetBytes = 900 << 10
var datasetExt = []string{".jsonl", ".json", ".csv", ".txt"}
type runtime struct {
eng Engine
j *journal
now func() time.Time
}
// New returns the runtime of the executable. dataDir holds the journal.
func New(eng Engine, dataDir string) contract.Runtime {
return &runtime{eng: eng, j: openJournal(dataDir), now: time.Now}
}
// startArgs are the arguments of fine-tune-start. The host adds "tool"; the
// name is the label the host shows in the approval card and the resource list.
type startArgs struct {
Tool string `json:"tool"`
Name string `json:"name"`
Model string `json:"model"`
Backend string `json:"backend"`
TrainingType string `json:"trainingType"`
TrainingMethod string `json:"trainingMethod"`
DatasetSource string `json:"datasetSource"`
DatasetSplit string `json:"datasetSplit"`
LearningRate float64 `json:"learningRate"`
NumEpochs int64 `json:"numEpochs"`
BatchSize int64 `json:"batchSize"`
MaxSteps int64 `json:"maxSteps"`
AdapterRank int64 `json:"adapterRank"`
AdapterAlpha int64 `json:"adapterAlpha"`
}
// engineStart is the argument object of finetune.start. It has exactly the
// fields the host operation accepts and none of the add-on's own.
type engineStart struct {
Model string `json:"model"`
Backend string `json:"backend,omitempty"`
TrainingType string `json:"trainingType,omitempty"`
TrainingMethod string `json:"trainingMethod,omitempty"`
DatasetSource string `json:"datasetSource"`
DatasetSplit string `json:"datasetSplit,omitempty"`
LearningRate float64 `json:"learningRate,omitempty"`
NumEpochs int64 `json:"numEpochs,omitempty"`
BatchSize int64 `json:"batchSize,omitempty"`
MaxSteps int64 `json:"maxSteps,omitempty"`
AdapterRank int64 `json:"adapterRank,omitempty"`
AdapterAlpha int64 `json:"adapterAlpha,omitempty"`
}
// datasetArgs are the arguments of fine-tune-dataset. The name is the file
// name of the dataset and the label of the resource.
type datasetArgs struct {
Tool string `json:"tool"`
Name string `json:"name"`
Content string `json:"content"`
}
type stopArgs struct {
Tool string `json:"tool"`
SaveCheckpoint bool `json:"saveCheckpoint"`
}
type exportArgs struct {
Tool string `json:"tool"`
ExportFormat string `json:"exportFormat"`
Name string `json:"name"`
}
// decode reads the arguments into a closed struct: an unknown field or
// trailing data is a bad request.
func decode(raw json.RawMessage, v any) error {
if len(raw) == 0 {
raw = json.RawMessage(`{}`)
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return protocol.ErrBadRequest
}
if _, err := dec.Token(); err != io.EOF {
return protocol.ErrBadRequest
}
return nil
}
func toolOf(env contract.Envelope) (string, error) {
var a struct {
Tool string `json:"tool"`
}
if json.Unmarshal(env.Request.Arguments, &a) != nil || a.Tool == "" {
return "", protocol.ErrBadRequest
}
return a.Tool, nil
}
func payload(v any) json.RawMessage { b, _ := json.Marshal(v); return b }
// mapErr turns the refusal of a host operation into the protocol error that
// tells the owner what happened. A failure that does not say the request was
// wrong stays as it is, and the owner reads it as uncertain.
func mapErr(err error) error {
var he *contract.HostError
if !errors.As(err, &he) {
return err
}
switch he.Code {
case contract.HostBadRequest, contract.HostTooLarge:
return fmt.Errorf("%w: %s", protocol.ErrBadRequest, he.Message)
case contract.HostNotFound:
return fmt.Errorf("%w: %s", protocol.ErrNotFound, he.Message)
case contract.HostConflict:
return fmt.Errorf("%w: %s", protocol.ErrConflict, he.Message)
case contract.HostFeature, contract.HostScope:
return fmt.Errorf("%w: %s", protocol.ErrForbidden, he.Message)
}
return err
}
// jobOf is the job of the resource. A dataset resource is not a job.
func (r *runtime) jobOf(env contract.Envelope) (job, error) {
j, ok := r.entryOf(env)
if !ok || j.Dataset {
return job{}, protocol.ErrNotFound
}
return j, nil
}
func (r *runtime) entryOf(env contract.Envelope) (job, bool) {
if env.Request.ResourceID == "" {
return job{}, false
}
return r.j.job(env.Request.ResourceID)
}
func (r *runtime) Read(ctx context.Context, env contract.Envelope) (contract.Result, error) {
tool, err := toolOf(env)
if err != nil {
return contract.Result{}, err
}
op := env.Request.OperationID
switch tool {
case ToolResourceExists:
j, ok := r.j.job(env.Request.ResourceID)
if !ok {
return contract.Result{Payload: payload(map[string]bool{"exists": false})}, nil
}
if j.Dataset {
// The reference lives in the host and the journal; there is no
// engine job to ask.
return contract.Result{Payload: payload(map[string]bool{"exists": true})}, nil
}
err := r.eng.Do(ctx, op, "finetune.get", map[string]string{"jobId": j.JobID}, nil)
var he *contract.HostError
switch {
case err == nil:
return contract.Result{Payload: payload(map[string]bool{"exists": true})}, nil
case errors.As(err, &he) && he.Code == contract.HostNotFound:
return contract.Result{Payload: payload(map[string]bool{"exists": false})}, nil
}
// The engine could not answer. The host settles a creation only on an
// explicit answer, so this is an error and never "does not exist".
return contract.Result{}, err
case ToolList:
// No resource: the host runs this read as the calling user and the
// host operation lists only that user's jobs.
var a struct {
Tool string `json:"tool"`
}
if err := decode(env.Request.Arguments, &a); err != nil {
return contract.Result{}, err
}
var got json.RawMessage
if err := r.eng.Do(ctx, op, "finetune.list", nil, &got); err != nil {
return contract.Result{}, mapErr(err)
}
return contract.Result{Payload: got}, nil
case ToolStatus:
j, ok := r.entryOf(env)
if !ok {
return contract.Result{}, protocol.ErrNotFound
}
if j.Dataset {
return contract.Result{Payload: payload(map[string]any{
"name": j.Name, "revision": contract.Revision{Schema: schema, Data: j.Revision}, "kind": "dataset", "datasetRef": j.Ref,
})}, nil
}
var got map[string]any
if err := r.eng.Do(ctx, op, "finetune.get", map[string]string{"jobId": j.JobID}, &got); err != nil {
return contract.Result{}, mapErr(err)
}
// The engine job ID stays in the add-on. The name and the revision
// are what the host reads to label and track the resource.
delete(got, "id")
got["name"] = j.Name
got["revision"] = contract.Revision{Schema: schema, Data: j.Revision}
return contract.Result{Payload: payload(got)}, nil
case ToolCheckpoints:
j, err := r.jobOf(env)
if err != nil {
return contract.Result{}, err
}
var got json.RawMessage
if err := r.eng.Do(ctx, op, "finetune.checkpoints", map[string]string{"jobId": j.JobID}, &got); err != nil {
return contract.Result{}, mapErr(err)
}
return contract.Result{Payload: got}, nil
}
return contract.Result{}, protocol.ErrBadRequest
}
func (r *runtime) checkStart(a startArgs) error {
if a.Model == "" || a.DatasetSource == "" || len([]rune(a.Name)) > maxNameRunes ||
a.LearningRate < 0 || a.NumEpochs < 0 || a.BatchSize < 0 || a.MaxSteps < 0 || a.AdapterRank < 0 || a.AdapterAlpha < 0 {
return protocol.ErrBadRequest
}
return nil
}
func checkExport(a exportArgs) error {
if a.ExportFormat != "" && !exportFormats[a.ExportFormat] {
return protocol.ErrBadRequest
}
if a.Name != "" && !modelName.MatchString(a.Name) {
return protocol.ErrBadRequest
}
return nil
}
func (r *runtime) Preview(_ context.Context, env contract.Envelope) (contract.Preview, error) {
tool, err := toolOf(env)
if err != nil {
return contract.Preview{}, err
}
out := contract.Preview{OperationID: env.Request.OperationID, ExpiresAt: r.now().Add(time.Minute)}
switch tool {
case ToolStart:
var a startArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return out, err
}
if err := r.checkStart(a); err != nil {
return out, err
}
// The resource does not exist yet; an operation that already
// committed is replayed by Apply, so a repeat is not a clash.
if _, taken := r.j.job(env.Request.ResourceID); taken {
if _, done := r.j.receipt(env.Request.OperationID); !done {
return out, protocol.ErrConflict
}
}
out.Revision = contract.Revision{Schema: schema, Data: 0}
out.Summary = payload(map[string]any{"count": 1, "sample": map[string]string{"action": "start fine-tuning job", "name": nameOf(a), "model": a.Model}})
return out, nil
case ToolDataset:
var a datasetArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return out, err
}
if err := checkDataset(a); err != nil {
return out, err
}
if _, taken := r.j.job(env.Request.ResourceID); taken {
if _, done := r.j.receipt(env.Request.OperationID); !done {
return out, protocol.ErrConflict
}
}
out.Revision = contract.Revision{Schema: schema, Data: 0}
// The card shows the name and the size, never the content.
out.Summary = payload(map[string]any{"count": 1, "sample": map[string]any{"action": "send dataset", "name": a.Name, "bytes": len(a.Content)}})
return out, nil
case ToolStop, ToolExport:
j, err := r.jobOf(env)
if err != nil {
return out, err
}
sample := map[string]any{"name": j.Name}
if tool == ToolStop {
var a stopArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return out, err
}
sample["action"], sample["saveCheckpoint"] = "stop fine-tuning job", a.SaveCheckpoint
} else {
var a exportArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return out, err
}
if err := checkExport(a); err != nil {
return out, err
}
sample["action"], sample["exportFormat"] = "export fine-tuned model", a.ExportFormat
}
out.Revision = contract.Revision{Schema: schema, Data: j.Revision}
out.Summary = payload(map[string]any{"count": 1, "sample": sample})
return out, nil
}
return out, protocol.ErrBadRequest
}
// checkDataset applies the rules of the host operation early, so a card is not
// shown for a dataset the host would refuse.
func checkDataset(a datasetArgs) error {
if a.Content == "" || len(a.Content) > maxDatasetBytes || !utf8.ValidString(a.Content) || len(a.Name) == 0 || len(a.Name) > 255 || strings.ContainsAny(a.Name, `/\`) || a.Name == "." || a.Name == ".." {
return protocol.ErrBadRequest
}
for _, ext := range datasetExt {
if strings.HasSuffix(a.Name, ext) && len(a.Name) > len(ext) {
return nil
}
}
return protocol.ErrBadRequest
}
func nameOf(a startArgs) string {
if a.Name != "" {
return a.Name
}
return a.Model
}
func (r *runtime) Apply(ctx context.Context, env contract.Envelope, rev contract.Revision) (contract.Receipt, error) {
tool, err := toolOf(env)
if err != nil {
return contract.Receipt{}, err
}
// An operation that already committed is answered from its receipt: the
// engine ran once and runs no more.
if rec, ok := r.j.receipt(env.Request.OperationID); ok {
return rec, nil
}
switch tool {
case ToolStart:
return r.applyStart(ctx, env)
case ToolDataset:
return r.applyDataset(ctx, env)
case ToolStop:
var a stopArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return contract.Receipt{}, err
}
return r.mutate(ctx, env, rev, "finetune.stop", func(id string) any {
return map[string]any{"jobId": id, "saveCheckpoint": a.SaveCheckpoint}
})
case ToolExport:
var a exportArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return contract.Receipt{}, err
}
if err := checkExport(a); err != nil {
return contract.Receipt{}, err
}
return r.mutate(ctx, env, rev, "finetune.export", func(id string) any {
args := map[string]any{"jobId": id}
if a.ExportFormat != "" {
args["exportFormat"] = a.ExportFormat
}
if a.Name != "" {
args["name"] = a.Name
}
return args
})
}
return contract.Receipt{}, protocol.ErrBadRequest
}
func (r *runtime) applyStart(ctx context.Context, env contract.Envelope) (contract.Receipt, error) {
var a startArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return contract.Receipt{}, err
}
if err := r.checkStart(a); err != nil {
return contract.Receipt{}, err
}
res, op := env.Request.ResourceID, env.Request.OperationID
if res == "" {
return contract.Receipt{}, protocol.ErrBadRequest
}
if _, taken := r.j.job(res); taken {
return contract.Receipt{}, protocol.ErrConflict
}
var started struct {
ID string `json:"id"`
Status string `json:"status"`
Message string `json:"message"`
}
err := r.eng.Do(ctx, op, "finetune.start", engineStart{
Model: a.Model, Backend: a.Backend, TrainingType: a.TrainingType, TrainingMethod: a.TrainingMethod,
DatasetSource: a.DatasetSource, DatasetSplit: a.DatasetSplit, LearningRate: a.LearningRate,
NumEpochs: a.NumEpochs, BatchSize: a.BatchSize, MaxSteps: a.MaxSteps, AdapterRank: a.AdapterRank, AdapterAlpha: a.AdapterAlpha,
}, &started)
if err != nil {
return contract.Receipt{}, mapErr(err)
}
if started.ID == "" {
return contract.Receipt{}, errors.New("the engine started a job and returned no ID")
}
rec := contract.Receipt{
OperationID: op, State: contract.ReceiptCommitted, Revision: contract.Revision{Schema: schema, Data: 1},
Result: contract.Result{Payload: payload(map[string]string{"status": started.Status, "message": started.Message})},
}
// If this write fails the job runs without a mapping. The error is
// uncertain, so the host asks again with the same operation ID and the
// engine answers it with the same job.
if err := r.j.commit(res, &job{JobID: started.ID, Name: nameOf(a), Revision: 1}, rec); err != nil {
return contract.Receipt{}, err
}
return rec, nil
}
// applyDataset sends the inline dataset through the host operation, which
// returns a reference only the calling user can use, and remembers it for the
// resource. The reference is in the receipt the person sees and in the status
// of the resource, which only the owner of the resource can read.
func (r *runtime) applyDataset(ctx context.Context, env contract.Envelope) (contract.Receipt, error) {
var a datasetArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return contract.Receipt{}, err
}
if err := checkDataset(a); err != nil {
return contract.Receipt{}, err
}
res, op := env.Request.ResourceID, env.Request.OperationID
if res == "" {
return contract.Receipt{}, protocol.ErrBadRequest
}
if _, taken := r.j.job(res); taken {
return contract.Receipt{}, protocol.ErrConflict
}
var stored struct {
DatasetRef string `json:"datasetRef"`
}
if err := r.eng.Do(ctx, op, "finetune.dataset", map[string]string{"name": a.Name, "content": a.Content}, &stored); err != nil {
return contract.Receipt{}, mapErr(err)
}
if stored.DatasetRef == "" {
return contract.Receipt{}, errors.New("the engine stored a dataset and returned no reference")
}
rec := contract.Receipt{
OperationID: op, State: contract.ReceiptCommitted, Revision: contract.Revision{Schema: schema, Data: 1},
Result: contract.Result{Payload: payload(map[string]string{"datasetRef": stored.DatasetRef})},
}
if err := r.j.commit(res, &job{Name: a.Name, Dataset: true, Ref: stored.DatasetRef, Revision: 1}, rec); err != nil {
return contract.Receipt{}, err
}
return rec, nil
}
// mutate runs a host operation on the job of the resource, once, if the
// resource is still at the revision the person approved.
func (r *runtime) mutate(ctx context.Context, env contract.Envelope, rev contract.Revision, op string, args func(jobID string) any) (contract.Receipt, error) {
j, err := r.jobOf(env)
if err != nil {
return contract.Receipt{}, err
}
id := env.Request.OperationID
if rev.Schema != schema || rev.Data != j.Revision {
return contract.Receipt{OperationID: id, State: contract.ReceiptRejected,
Result: contract.Result{Payload: payload(map[string]any{"error": "stale", "revision": contract.Revision{Schema: schema, Data: j.Revision}})}}, nil
}
var out json.RawMessage
if err := r.eng.Do(ctx, id, op, args(j.JobID), &out); err != nil {
return contract.Receipt{}, mapErr(err)
}
next := j
next.Revision++
rec := contract.Receipt{OperationID: id, State: contract.ReceiptCommitted, Revision: contract.Revision{Schema: schema, Data: next.Revision}}
if len(out) > 0 && json.Valid(out) {
rec.Result.Payload = out
} else {
rec.Result.Payload = payload(map[string]any{})
}
if err := r.j.commit(env.Request.ResourceID, &next, rec); err != nil {
return contract.Receipt{}, err
}
return rec, nil
}
func (r *runtime) Receipt(_ context.Context, op string) (contract.Receipt, error) {
if rec, ok := r.j.receipt(op); ok {
return rec, nil
}
return contract.Receipt{}, protocol.ErrNotFound
}
// OpenOutput refuses: no tool of this add-on produces a file.
func (r *runtime) OpenOutput(context.Context, contract.Envelope, string) (io.ReadCloser, error) {
return nil, protocol.ErrNotFound
}
@@ -0,0 +1,615 @@
// SPDX-License-Identifier: MIT
package tools
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
"github.com/mudler/LocalAI/addons/internal/hostclient"
"github.com/mudler/LocalAI/addons/internal/protocol"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
var ctx = context.Background()
type call struct {
opID, op string
args map[string]any
}
// fakeEngine records host operations and answers from canned results. A
// result of type error is returned as the failure of that operation.
type fakeEngine struct {
mu sync.Mutex
result map[string]any
calls []call
}
func (f *fakeEngine) Do(_ context.Context, opID, op string, args, out any) error {
f.mu.Lock()
defer f.mu.Unlock()
var a map[string]any
if args != nil {
b, _ := json.Marshal(args)
_ = json.Unmarshal(b, &a)
}
f.calls = append(f.calls, call{opID, op, a})
r, ok := f.result[op]
if !ok {
r = map[string]any{}
}
if err, isErr := r.(error); isErr {
return err
}
if out != nil {
b, _ := json.Marshal(r)
return json.Unmarshal(b, out)
}
return nil
}
func (f *fakeEngine) ops() []string {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for _, c := range f.calls {
out = append(out, c.op)
}
return out
}
func (f *fakeEngine) count(op string) int {
n := 0
for _, o := range f.ops() {
if o == op {
n++
}
}
return n
}
func (f *fakeEngine) last(op string) call {
f.mu.Lock()
defer f.mu.Unlock()
for i := len(f.calls) - 1; i >= 0; i-- {
if f.calls[i].op == op {
return f.calls[i]
}
}
return call{}
}
func envelope(tool, opID, resource, args string) contract.Envelope {
m := map[string]any{}
if args != "" {
_ = json.Unmarshal([]byte(args), &m)
}
m["tool"] = tool
raw, _ := json.Marshal(m)
return contract.Envelope{
Request: contract.Request{AddonID: "fine-tuning", ResourceID: resource, OperationID: opID, Generation: 1, Action: "apply", Arguments: raw},
Authority: contract.Authority{Actor: contract.Actor{UserID: "u1", CredentialID: "c1", Mode: "named-user"}, Scope: "apply", ExpiresAt: time.Now().Add(time.Minute)},
}
}
const startJSON = `{"name":"my run","model":"org/base","datasetSource":"org/data"}`
// started returns a runtime that already holds the job job-1 for resource res1.
func started(t *testing.T) (contract.Runtime, *fakeEngine) {
t.Helper()
eng := &fakeEngine{result: map[string]any{"finetune.start": map[string]any{"id": "job-1", "status": "queued"}}}
rt := New(eng, t.TempDir())
rec, err := rt.Apply(ctx, envelope("fine-tune-start", "create-res1", "res1", startJSON), contract.Revision{Schema: 1})
if err != nil || rec.State != contract.ReceiptCommitted {
t.Fatalf("setup: %v %+v", err, rec)
}
return rt, eng
}
func TestStartCallsTheEngineOnceAndRemembersTheJob(t *testing.T) {
eng := &fakeEngine{result: map[string]any{"finetune.start": map[string]any{"id": "job-1", "status": "queued"}}}
rt := New(eng, t.TempDir())
rec, err := rt.Apply(ctx, envelope("fine-tune-start", "op1", "res1", startJSON), contract.Revision{Schema: 1, Data: 0})
if err != nil || rec.State != contract.ReceiptCommitted || rec.Revision.Schema == 0 {
t.Fatalf("%v %+v", err, rec)
}
if got := eng.ops(); !reflect.DeepEqual(got, []string{"finetune.start"}) {
t.Fatalf("ops %v", got)
}
// The same operation again does not start a second job.
again, err := rt.Apply(ctx, envelope("fine-tune-start", "op1", "res1", startJSON), contract.Revision{Schema: 1, Data: 1})
if err != nil || again.State != contract.ReceiptCommitted {
t.Fatalf("%v %+v", err, again)
}
if n := eng.count("finetune.start"); n != 1 {
t.Fatalf("started %d jobs", n)
}
}
func TestStartSendsOnlyTheFieldsOfTheEngineOperation(t *testing.T) {
eng := &fakeEngine{result: map[string]any{"finetune.start": map[string]any{"id": "job-1", "status": "queued"}}}
rt := New(eng, t.TempDir())
args := `{"name":"my run","model":"org/base","datasetSource":"org/data","numEpochs":2,"learningRate":0.0002,"adapterRank":8}`
if _, err := rt.Apply(ctx, envelope("fine-tune-start", "op1", "res1", args), contract.Revision{Schema: 1}); err != nil {
t.Fatal(err)
}
c := eng.last("finetune.start")
if c.opID != "op1" {
t.Fatalf("the call must carry the operation of the request, got %q", c.opID)
}
for _, banned := range []string{"tool", "name", "resourceId"} {
if _, ok := c.args[banned]; ok {
t.Fatalf("the engine must not receive %q: %v", banned, c.args)
}
}
if c.args["model"] != "org/base" || c.args["numEpochs"] != float64(2) || c.args["adapterRank"] != float64(8) {
t.Fatalf("args %v", c.args)
}
}
func TestPreviewOfStartHasANonzeroRevisionAndCreatesNothing(t *testing.T) {
eng := &fakeEngine{}
rt := New(eng, t.TempDir())
p, err := rt.Preview(ctx, envelope("fine-tune-start", "op1", "res1", startJSON))
if err != nil || p.Revision.Schema == 0 || len(eng.ops()) != 0 {
t.Fatalf("%v %+v %v", err, p, eng.ops())
}
if !json.Valid(p.Summary) {
t.Fatalf("summary %s", p.Summary)
}
if _, err := rt.Preview(ctx, envelope("fine-tune-start", "op1", "res1", `{"name":"x","model":"","datasetSource":"d"}`)); !errors.Is(err, protocol.ErrBadRequest) {
t.Fatalf("an empty model must be refused, got %v", err)
}
}
func TestStatusReadsTheJobOfTheResource(t *testing.T) {
rt, eng := started(t)
eng.result["finetune.get"] = map[string]any{"id": "job-1", "status": "training", "model": "org/base"}
res, err := rt.Read(ctx, envelope("fine-tune-status", "r1", "res1", ""))
if err != nil {
t.Fatal(err)
}
if got := eng.last("finetune.get").args["jobId"]; got != "job-1" {
t.Fatalf("jobId %v", got)
}
var v map[string]any
if err := json.Unmarshal(res.Payload, &v); err != nil {
t.Fatal(err)
}
if v["name"] != "my run" || v["status"] != "training" {
t.Fatalf("payload %v", v)
}
rev, _ := v["revision"].(map[string]any)
if rev["schema"] == nil || rev["schema"] == float64(0) {
t.Fatalf("inspect needs a revision: %v", v)
}
if _, has := v["id"]; has {
t.Fatalf("the engine job ID must not reach the model: %v", v)
}
if _, has := v["progressPercent"]; has {
t.Fatalf("a snapshot percent is unreliable and must not be reported: %v", v)
}
}
func TestCheckpointsReadTheJobOfTheResource(t *testing.T) {
rt, eng := started(t)
eng.result["finetune.checkpoints"] = map[string]any{"checkpoints": []any{map[string]any{"step": 10}}}
res, err := rt.Read(ctx, envelope("fine-tune-checkpoints", "r1", "res1", ""))
if err != nil || !strings.Contains(string(res.Payload), `"step":10`) {
t.Fatalf("%v %s", err, res.Payload)
}
if eng.last("finetune.checkpoints").args["jobId"] != "job-1" {
t.Fatal("wrong job")
}
}
func TestAToolCannotControlAJobItDidNotStart(t *testing.T) {
eng := &fakeEngine{}
rt := New(eng, t.TempDir())
if _, err := rt.Apply(ctx, envelope("fine-tune-stop", "op2", "nope", `{}`), contract.Revision{Schema: 1}); !errors.Is(err, protocol.ErrNotFound) {
t.Fatalf("stop: %v", err)
}
if _, err := rt.Apply(ctx, envelope("fine-tune-export", "op3", "nope", `{}`), contract.Revision{Schema: 1}); !errors.Is(err, protocol.ErrNotFound) {
t.Fatalf("export: %v", err)
}
if _, err := rt.Read(ctx, envelope("fine-tune-status", "r1", "nope", "")); !errors.Is(err, protocol.ErrNotFound) {
t.Fatalf("status: %v", err)
}
if _, err := rt.Preview(ctx, envelope("fine-tune-stop", "op2", "nope", `{}`)); !errors.Is(err, protocol.ErrNotFound) {
t.Fatalf("preview: %v", err)
}
if n := len(eng.ops()); n != 0 {
t.Fatalf("the engine was called %d times: %v", n, eng.ops())
}
}
func TestStopAndExportUseTheStoredJobAndAdvanceTheRevision(t *testing.T) {
rt, eng := started(t)
eng.result["finetune.stop"] = map[string]any{}
eng.result["finetune.export"] = map[string]any{"model": "my-model"}
p, err := rt.Preview(ctx, envelope("fine-tune-stop", "op2", "res1", `{"saveCheckpoint":true}`))
if err != nil || p.Revision.Schema == 0 {
t.Fatalf("%v %+v", err, p)
}
rec, err := rt.Apply(ctx, envelope("fine-tune-stop", "op2", "res1", `{"saveCheckpoint":true}`), p.Revision)
if err != nil || rec.State != contract.ReceiptCommitted {
t.Fatalf("%v %+v", err, rec)
}
c := eng.last("finetune.stop")
if c.args["jobId"] != "job-1" || c.args["saveCheckpoint"] != true {
t.Fatalf("stop args %v", c.args)
}
if rec.Revision.Data <= p.Revision.Data {
t.Fatalf("the revision must advance: %+v then %+v", p.Revision, rec.Revision)
}
// A repeat of the same operation is answered from the receipt.
if _, err := rt.Apply(ctx, envelope("fine-tune-stop", "op2", "res1", `{"saveCheckpoint":true}`), p.Revision); err != nil || eng.count("finetune.stop") != 1 {
t.Fatalf("stop ran %d times (%v)", eng.count("finetune.stop"), err)
}
// Export names the format and the model, and never a path.
ex, err := rt.Apply(ctx, envelope("fine-tune-export", "op3", "res1", `{"exportFormat":"gguf","name":"my-model"}`), rec.Revision)
if err != nil || ex.State != contract.ReceiptCommitted || !strings.Contains(string(ex.Result.Payload), "my-model") {
t.Fatalf("%v %+v", err, ex)
}
if a := eng.last("finetune.export").args; a["jobId"] != "job-1" || a["exportFormat"] != "gguf" || a["name"] != "my-model" {
t.Fatalf("export args %v", a)
}
}
func TestAStaleRevisionIsRejectedAndTheEngineIsNotCalled(t *testing.T) {
rt, eng := started(t)
rec, err := rt.Apply(ctx, envelope("fine-tune-stop", "op2", "res1", `{}`), contract.Revision{Schema: 1, Data: 99})
if err != nil || rec.State != contract.ReceiptRejected {
t.Fatalf("%v %+v", err, rec)
}
if eng.count("finetune.stop") != 0 {
t.Fatal("the engine was called")
}
}
func TestHostRefusalsMapToProtocolErrors(t *testing.T) {
rt, eng := started(t)
for code, want := range map[string]error{
contract.HostFeature: protocol.ErrForbidden, contract.HostBadRequest: protocol.ErrBadRequest,
contract.HostNotFound: protocol.ErrNotFound, contract.HostConflict: protocol.ErrConflict,
} {
eng.result["finetune.export"] = &contract.HostError{Code: code, Message: "m"}
_, err := rt.Apply(ctx, envelope("fine-tune-export", "op-"+code, "res1", `{}`), contract.Revision{Schema: 1, Data: 1})
if !errors.Is(err, want) {
t.Fatalf("%s: %v, want %v", code, err, want)
}
}
eng.result["finetune.export"] = &contract.HostError{Code: contract.HostUnavailable, Message: "m"}
_, err := rt.Apply(ctx, envelope("fine-tune-export", "op-u", "res1", `{}`), contract.Revision{Schema: 1, Data: 1})
if err == nil || errors.Is(err, protocol.ErrBadRequest) || errors.Is(err, protocol.ErrForbidden) || errors.Is(err, protocol.ErrNotFound) || errors.Is(err, protocol.ErrConflict) {
t.Fatalf("an unavailable engine must stay uncertain, got %v", err)
}
}
func exists(t *testing.T, rt contract.Runtime, res string) (bool, error) {
t.Helper()
r, err := rt.Read(ctx, envelope("resource-exists", "x-"+res, res, ""))
if err != nil {
return false, err
}
var v struct {
Exists bool `json:"exists"`
}
_ = json.Unmarshal(r.Payload, &v)
return v.Exists, nil
}
func TestResourceExistsAnswersFromTheJournalAndTheEngine(t *testing.T) {
rt, eng := started(t)
eng.result["finetune.get"] = map[string]any{"id": "job-1", "status": "training"}
if ok, err := exists(t, rt, "res1"); err != nil || !ok {
t.Fatalf("mapped: %v %v", ok, err)
}
if ok, err := exists(t, rt, "other"); err != nil || ok {
t.Fatalf("unmapped: %v %v", ok, err)
}
// The job was deleted in the UI: the engine says not found.
eng.result["finetune.get"] = &contract.HostError{Code: contract.HostNotFound, Message: "gone"}
if ok, err := exists(t, rt, "res1"); err != nil || ok {
t.Fatalf("deleted job: %v %v", ok, err)
}
// The engine cannot answer: unknown, never false.
eng.result["finetune.get"] = &contract.HostError{Code: contract.HostUnavailable, Message: "down"}
if _, err := exists(t, rt, "res1"); err == nil {
t.Fatal("an unavailable engine must not read as absent")
}
eng.result["finetune.get"] = hostclient.ErrNoHostOps
if _, err := exists(t, rt, "res1"); err == nil {
t.Fatal("no host endpoint must not read as absent")
}
}
func TestJournalSurvivesARestart(t *testing.T) {
dir := t.TempDir()
eng := &fakeEngine{result: map[string]any{"finetune.start": map[string]any{"id": "job-1", "status": "queued"}, "finetune.get": map[string]any{"status": "training"}}}
if _, err := New(eng, dir).Apply(ctx, envelope("fine-tune-start", "op1", "res1", startJSON), contract.Revision{Schema: 1}); err != nil {
t.Fatal(err)
}
again := New(eng, dir)
if _, err := again.Read(ctx, envelope("fine-tune-status", "r1", "res1", "")); err != nil {
t.Fatal(err)
}
if eng.last("finetune.get").args["jobId"] != "job-1" {
t.Fatal("the restarted runtime lost the job")
}
// The receipt of the first run answers a repeat after the restart.
if _, err := again.Apply(ctx, envelope("fine-tune-start", "op1", "res1", startJSON), contract.Revision{Schema: 1}); err != nil || eng.count("finetune.start") != 1 {
t.Fatalf("started %d times (%v)", eng.count("finetune.start"), err)
}
if r, err := again.Receipt(ctx, "op1"); err != nil || r.State != contract.ReceiptCommitted {
t.Fatalf("%v %+v", err, r)
}
if _, err := again.Receipt(ctx, "never"); !errors.Is(err, protocol.ErrNotFound) {
t.Fatalf("an unknown operation must be not found, got %v", err)
}
// The journal is a plain file written through a temporary one.
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.Contains(e.Name(), ".tmp") {
t.Fatalf("a temporary file was left: %s", e.Name())
}
}
if _, err := os.Stat(filepath.Join(dir, "journal.json")); err != nil {
t.Fatal(err)
}
}
func TestWithoutAHostEndpointEveryToolRefuses(t *testing.T) {
var nilClient *hostclient.Client
dir := t.TempDir()
rt := New(nilClient, dir)
if _, err := rt.Apply(ctx, envelope("fine-tune-start", "op1", "res1", startJSON), contract.Revision{Schema: 1}); !errors.Is(err, hostclient.ErrNoHostOps) {
t.Fatalf("start: %v", err)
}
// Seed a job as an earlier run with an endpoint would have.
seed := New(&fakeEngine{result: map[string]any{"finetune.start": map[string]any{"id": "job-1"}}}, dir)
if _, err := seed.Apply(ctx, envelope("fine-tune-start", "op0", "res1", startJSON), contract.Revision{Schema: 1}); err != nil {
t.Fatal(err)
}
rt = New(nilClient, dir)
for _, tool := range []string{"fine-tune-status", "fine-tune-checkpoints", "resource-exists"} {
if _, err := rt.Read(ctx, envelope(tool, "r-"+tool, "res1", "")); !errors.Is(err, hostclient.ErrNoHostOps) {
t.Fatalf("%s: %v", tool, err)
}
}
for _, tool := range []string{"fine-tune-stop", "fine-tune-export"} {
if _, err := rt.Apply(ctx, envelope(tool, "a-"+tool, "res1", `{}`), contract.Revision{Schema: 1, Data: 1}); !errors.Is(err, hostclient.ErrNoHostOps) {
t.Fatalf("%s: %v", tool, err)
}
}
}
func TestAnUnknownToolIsABadRequest(t *testing.T) {
rt := New(&fakeEngine{}, t.TempDir())
if _, err := rt.Read(ctx, envelope("fine-tune-nope", "r1", "res1", "")); !errors.Is(err, protocol.ErrBadRequest) {
t.Fatalf("read: %v", err)
}
if _, err := rt.Apply(ctx, envelope("fine-tune-nope", "a1", "res1", ""), contract.Revision{Schema: 1}); !errors.Is(err, protocol.ErrBadRequest) {
t.Fatalf("apply: %v", err)
}
if _, err := rt.OpenOutput(ctx, envelope("fine-tune-status", "o1", "res1", ""), "h"); !errors.Is(err, protocol.ErrNotFound) {
t.Fatalf("output: %v", err)
}
}
func loadManifest(t *testing.T) contract.Manifest {
t.Helper()
b, err := os.ReadFile("../../manifest.json")
if err != nil {
t.Fatal(err)
}
m, err := contract.ParseManifest(b)
if err != nil {
t.Fatalf("the manifest must satisfy the contract: %v", err)
}
return m
}
func TestManifestDeclaresTheToolsAndTheResourceMapping(t *testing.T) {
m := loadManifest(t)
if m.ID != "fine-tuning" || m.Capabilities.UI.MainPage != "jobs" || !reflect.DeepEqual(m.Capabilities.UI.Pages, []string{"jobs"}) {
t.Fatalf("manifest %+v", m)
}
if m.Capabilities.UI.SDK.Min != 2 || m.Capabilities.UI.SDK.Max != 2 {
t.Fatalf("sdk %+v", m.Capabilities.UI.SDK)
}
if !reflect.DeepEqual(m.Capabilities.Tools, Tools) {
t.Fatalf("manifest tools %v, package tools %v", m.Capabilities.Tools, Tools)
}
r := m.Capabilities.Resources
if r == nil || r.Noun != "fine-tuning job" || r.Create != "fine-tune-start" || r.Inspect != "fine-tune-status" {
t.Fatalf("resources %+v", r)
}
kinds := map[string]string{}
for _, d := range m.Capabilities.ToolDefinitions {
kinds[d.Name] = d.Kind
}
want := map[string]string{"fine-tune-list": "read", "fine-tune-status": "read", "fine-tune-checkpoints": "read", "fine-tune-start": "create", "fine-tune-dataset": "create", "fine-tune-stop": "write", "fine-tune-export": "write"}
if !reflect.DeepEqual(kinds, want) {
t.Fatalf("kinds %v", kinds)
}
for _, d := range m.Capabilities.ToolDefinitions {
if d.Name == "fine-tune-list" && !contract.IsResourceless(d) {
t.Fatal("the list tool must declare no resource")
}
if d.Name != "fine-tune-list" && contract.IsResourceless(d) {
t.Fatalf("%s must not be resource-less", d.Name)
}
if len(d.Description) >= 400 || !strings.Contains(d.Description, "started") {
t.Fatalf("%s: the description must be short and say that only jobs a tool started can be controlled", d.Name)
}
}
}
func TestNoToolArgumentNamesAUserOrAPath(t *testing.T) {
m := loadManifest(t)
for _, d := range m.Capabilities.ToolDefinitions {
var s struct {
Properties map[string]json.RawMessage `json:"properties"`
}
if err := json.Unmarshal(d.InputSchema, &s); err != nil {
t.Fatal(err)
}
for name := range s.Properties {
lower := strings.ToLower(name)
if contract.ForbiddenToolProperty(name) || strings.Contains(lower, "path") || strings.Contains(lower, "dir") {
t.Fatalf("%s: property %q names an identity or a path", d.Name, name)
}
}
}
}
func TestStartSchemaHasTheTask20FieldsAndTheNameTheHostNeeds(t *testing.T) {
m := loadManifest(t)
for _, d := range m.Capabilities.ToolDefinitions {
if d.Name != "fine-tune-start" {
continue
}
var s struct {
Properties map[string]json.RawMessage `json:"properties"`
Required []string `json:"required"`
}
_ = json.Unmarshal(d.InputSchema, &s)
var got []string
for k := range s.Properties {
got = append(got, k)
}
want := []string{"name", "model", "backend", "trainingType", "trainingMethod", "datasetSource", "datasetSplit", "learningRate", "numEpochs", "batchSize", "maxSteps", "adapterRank", "adapterAlpha"}
if len(got) != len(want) {
t.Fatalf("properties %v", got)
}
for _, w := range want {
if _, ok := s.Properties[w]; !ok {
t.Fatalf("missing %s", w)
}
}
return
}
t.Fatal("no start definition")
}
func TestListReadsTheCallersJobsWithoutAResource(t *testing.T) {
eng := &fakeEngine{result: map[string]any{"finetune.list": map[string]any{"jobs": []any{map[string]any{"id": "ui-job", "status": "completed"}}}}}
rt := New(eng, t.TempDir())
res, err := rt.Read(ctx, envelope("fine-tune-list", "r1", "", ""))
if err != nil || !strings.Contains(string(res.Payload), "ui-job") {
t.Fatalf("%v %s", err, res.Payload)
}
if got := eng.ops(); !reflect.DeepEqual(got, []string{"finetune.list"}) {
t.Fatalf("ops %v", got)
}
if c := eng.last("finetune.list"); c.args != nil {
t.Fatalf("the list takes no argument, got %v", c.args)
}
if _, err := rt.Read(ctx, envelope("fine-tune-list", "r2", "", `{"userId":"other"}`)); !errors.Is(err, protocol.ErrBadRequest) {
t.Fatalf("an argument must be refused, got %v", err)
}
var nilClient *hostclient.Client
if _, err := New(nilClient, t.TempDir()).Read(ctx, envelope("fine-tune-list", "r3", "", "")); !errors.Is(err, hostclient.ErrNoHostOps) {
t.Fatalf("no endpoint: %v", err)
}
}
const datasetJSON = `{"name":"data.jsonl","content":"{\"text\":\"a\"}\n"}`
func TestDatasetIsSentOnceAndItsRefComesBackOnlyThroughTheResource(t *testing.T) {
eng := &fakeEngine{result: map[string]any{"finetune.dataset": map[string]any{"datasetRef": "0000000a-data.jsonl"}}}
rt := New(eng, t.TempDir())
p, err := rt.Preview(ctx, envelope("fine-tune-dataset", "create-d1", "d1", datasetJSON))
if err != nil || p.Revision.Schema == 0 || len(eng.ops()) != 0 {
t.Fatalf("%v %+v %v", err, p, eng.ops())
}
if strings.Contains(string(p.Summary), "text") {
t.Fatalf("the approval card must not carry the content: %s", p.Summary)
}
rec, err := rt.Apply(ctx, envelope("fine-tune-dataset", "create-d1", "d1", datasetJSON), p.Revision)
if err != nil || rec.State != contract.ReceiptCommitted || !strings.Contains(string(rec.Result.Payload), "0000000a-data.jsonl") {
t.Fatalf("%v %+v", err, rec)
}
if _, err := rt.Apply(ctx, envelope("fine-tune-dataset", "create-d1", "d1", datasetJSON), p.Revision); err != nil || eng.count("finetune.dataset") != 1 {
t.Fatalf("sent %d times (%v)", eng.count("finetune.dataset"), err)
}
if a := eng.last("finetune.dataset").args; a["name"] != "data.jsonl" || a["content"] == nil || len(a) != 2 {
t.Fatalf("host operation args %v", a)
}
// The resource is the dataset: its status gives the reference to the owner of the resource.
st, err := rt.Read(ctx, envelope("fine-tune-status", "r1", "d1", ""))
if err != nil || !strings.Contains(string(st.Payload), `"datasetRef":"0000000a-data.jsonl"`) || !strings.Contains(string(st.Payload), `"kind":"dataset"`) {
t.Fatalf("%v %s", err, st.Payload)
}
if n := len(eng.ops()); n != 1 {
t.Fatalf("the status of a dataset must not call the engine: %v", eng.ops())
}
if ok, err := exists(t, rt, "d1"); err != nil || !ok {
t.Fatalf("exists %v %v", ok, err)
}
// A dataset is not a job.
for _, tool := range []string{"fine-tune-checkpoints"} {
if _, err := rt.Read(ctx, envelope(tool, "r2", "d1", "")); !errors.Is(err, protocol.ErrNotFound) {
t.Fatalf("%s: %v", tool, err)
}
}
if _, err := rt.Apply(ctx, envelope("fine-tune-stop", "s1", "d1", `{}`), contract.Revision{Schema: 1, Data: 1}); !errors.Is(err, protocol.ErrNotFound) {
t.Fatalf("stop: %v", err)
}
}
func TestDatasetRefusesWhatTheHostWouldRefuse(t *testing.T) {
rt := New(&fakeEngine{}, t.TempDir())
big := strings.Repeat("a", 900<<10+1)
for name, args := range map[string]string{
"empty": `{"name":"d.jsonl","content":""}`,
"too large": `{"name":"d.jsonl","content":"` + big + `"}`,
"a folder": `{"name":"a/d.jsonl","content":"x"}`,
"parent": `{"name":"../d.jsonl","content":"x"}`,
"wrong type": `{"name":"d.exe","content":"x"}`,
"only the type": `{"name":".jsonl","content":"x"}`,
"extra field": `{"name":"d.jsonl","content":"x","path":"/etc"}`,
} {
if _, err := rt.Preview(ctx, envelope("fine-tune-dataset", "o-"+name, "d9", args)); !errors.Is(err, protocol.ErrBadRequest) {
t.Fatalf("%s: %v", name, err)
}
}
// The largest allowed dataset passes.
ok := `{"name":"d.jsonl","content":"` + strings.Repeat("a", 900<<10) + `"}`
if _, err := rt.Preview(ctx, envelope("fine-tune-dataset", "o-ok", "d9", ok)); err != nil {
t.Fatalf("900 KiB: %v", err)
}
}
func TestStartPassesADatasetRefAndKeepsNoJobWhenTheHostRefusesIt(t *testing.T) {
eng := &fakeEngine{result: map[string]any{"finetune.start": map[string]any{"id": "job-1", "status": "queued"}}}
rt := New(eng, t.TempDir())
args := `{"name":"run","model":"org/base","datasetSource":"0000000a-data.jsonl"}`
if _, err := rt.Apply(ctx, envelope("fine-tune-start", "create-r1", "r1", args), contract.Revision{Schema: 1}); err != nil {
t.Fatal(err)
}
if eng.last("finetune.start").args["datasetSource"] != "0000000a-data.jsonl" {
t.Fatalf("args %v", eng.last("finetune.start").args)
}
// The host refuses the reference (the per-user check is proven in the host tests); the add-on only maps the refusal and keeps nothing.
eng.result["finetune.start"] = &contract.HostError{Code: contract.HostBadRequest, Message: "datasetSource is not a dataset reference of this user"}
_, err := rt.Apply(ctx, envelope("fine-tune-start", "create-r2", "r2", `{"name":"run","model":"org/base","datasetSource":"0000000b-theirs.jsonl"}`), contract.Revision{Schema: 1})
if !errors.Is(err, protocol.ErrBadRequest) {
t.Fatalf("want a bad request, got %v", err)
}
if _, err := rt.Read(ctx, envelope("fine-tune-status", "r3", "r2", "")); !errors.Is(err, protocol.ErrNotFound) {
t.Fatalf("a refused start must not leave a job: %v", err)
}
if _, err := rt.Receipt(ctx, "create-r2"); !errors.Is(err, protocol.ErrNotFound) {
t.Fatalf("a refused start must not leave a receipt: %v", err)
}
}
+285
View File
@@ -0,0 +1,285 @@
{
"schemaVersion": 1,
"id": "fine-tuning",
"version": "0.1.0",
"executable": "fine-tuning",
"os": "linux",
"arch": "amd64",
"host": {
"min": 1,
"max": 1
},
"api": {
"min": 1,
"max": 1
},
"protocol": {
"min": 1,
"max": 1
},
"dataFormat": {
"min": 1,
"max": 1
},
"capabilities": {
"tools": [
"resource-exists",
"fine-tune-list",
"fine-tune-start",
"fine-tune-dataset",
"fine-tune-status",
"fine-tune-checkpoints",
"fine-tune-stop",
"fine-tune-export"
],
"toolDefinitions": [
{
"name": "fine-tune-list",
"kind": "read",
"description": "List the fine-tuning jobs of the person you are talking to, newest first, including jobs started in the web page. A model can only control jobs it started.",
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
},
{
"name": "fine-tune-start",
"kind": "create",
"description": "Propose a new fine-tuning job. A person confirms it in the approval card; nothing starts before that. datasetSource is a Hugging Face ID or a reference from fine-tune-dataset. A model can only control jobs it started.",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "A label for the job, 1 to 80 characters.",
"maxLength": 80
},
"model": {
"type": "string",
"description": "Base model as a gallery or Hugging Face ID. A local path is refused.",
"maxLength": 256
},
"backend": {
"type": "string",
"description": "Fine-tuning backend. The default is trl.",
"maxLength": 64
},
"trainingType": {
"type": "string",
"description": "Kind of training. The default is lora.",
"enum": [
"lora",
"loha",
"lokr",
"full"
]
},
"trainingMethod": {
"type": "string",
"description": "Training method. The default is sft.",
"enum": [
"sft",
"dpo",
"grpo",
"rloo",
"reward",
"kto",
"orpo"
]
},
"datasetSource": {
"type": "string",
"description": "Dataset as a Hugging Face ID or a reference from fine-tune-dataset. A local path is refused.",
"maxLength": 256
},
"datasetSplit": {
"type": "string",
"description": "Dataset split, such as train.",
"maxLength": 64
},
"learningRate": {
"type": "number",
"description": "Learning rate. Zero keeps the default.",
"minimum": 0
},
"numEpochs": {
"type": "integer",
"description": "Number of epochs. Zero keeps the default.",
"minimum": 0,
"maximum": 1000
},
"batchSize": {
"type": "integer",
"description": "Batch size. Zero keeps the default.",
"minimum": 0,
"maximum": 4096
},
"maxSteps": {
"type": "integer",
"description": "Stop after this many steps. Zero means no limit.",
"minimum": 0
},
"adapterRank": {
"type": "integer",
"description": "Rank of the adapter. Zero keeps the default.",
"minimum": 0,
"maximum": 1024
},
"adapterAlpha": {
"type": "integer",
"description": "Alpha of the adapter. Zero keeps the default.",
"minimum": 0,
"maximum": 4096
}
},
"required": [
"name",
"model",
"datasetSource"
],
"additionalProperties": false
}
},
{
"name": "fine-tune-dataset",
"kind": "create",
"description": "Propose sending a small text dataset (up to 900 KiB, a .jsonl, .json, .csv or .txt file) to the server. A person confirms it. The reference it returns is the datasetSource of fine-tune-start, works for that person only and is lost when the server restarts. A model can only control jobs it started.",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "File name of the dataset, ending in .jsonl, .json, .csv or .txt. No folder.",
"maxLength": 255
},
"content": {
"type": "string",
"description": "The dataset as UTF-8 text, 1 byte to 900 KiB.",
"maxLength": 921600
}
},
"required": [
"name",
"content"
],
"additionalProperties": false
}
},
{
"name": "fine-tune-status",
"kind": "read",
"description": "Show the status of a fine-tuning job that a tool started, or the reference of a dataset that fine-tune-dataset sent. A model can only control jobs it started.",
"inputSchema": {
"type": "object",
"properties": {
"resourceId": {
"type": "string",
"description": "ID of the fine-tuning job, as returned by a job start. Only a job that a tool started has one."
}
},
"required": [
"resourceId"
],
"additionalProperties": false
}
},
{
"name": "fine-tune-checkpoints",
"kind": "read",
"description": "List the saved checkpoints of a fine-tuning job that a tool started, with step and time. A model can only control jobs it started.",
"inputSchema": {
"type": "object",
"properties": {
"resourceId": {
"type": "string",
"description": "ID of the fine-tuning job, as returned by a job start. Only a job that a tool started has one."
}
},
"required": [
"resourceId"
],
"additionalProperties": false
}
},
{
"name": "fine-tune-stop",
"kind": "write",
"description": "Propose stopping a fine-tuning job that a tool started. A person confirms it. A model can only control jobs it started.",
"inputSchema": {
"type": "object",
"properties": {
"resourceId": {
"type": "string",
"description": "ID of the fine-tuning job, as returned by a job start. Only a job that a tool started has one."
},
"saveCheckpoint": {
"type": "boolean",
"description": "Keep a checkpoint of the work so far. The default is false."
}
},
"required": [
"resourceId"
],
"additionalProperties": false
}
},
{
"name": "fine-tune-export",
"kind": "write",
"description": "Propose registering the result of a fine-tuning job that a tool started as a model. Administrators only. A model can only control jobs it started.",
"inputSchema": {
"type": "object",
"properties": {
"resourceId": {
"type": "string",
"description": "ID of the fine-tuning job, as returned by a job start. Only a job that a tool started has one."
},
"exportFormat": {
"type": "string",
"description": "Export format. The default is the engine default.",
"enum": [
"lora",
"merged_16bit",
"merged_4bit",
"gguf"
]
},
"name": {
"type": "string",
"description": "Name of the model, 1 to 64 letters, digits, dots, dashes or underscores. Leave out to let the engine choose.",
"maxLength": 64
}
},
"required": [
"resourceId"
],
"additionalProperties": false
}
}
],
"resources": {
"noun": "fine-tuning job",
"create": "fine-tune-start",
"inspect": "fine-tune-status"
},
"ui": {
"entry": "ui/fine-tuning.js",
"css": [
"ui/fine-tuning.css"
],
"sdk": {
"min": 2,
"max": 2
},
"pages": [
"jobs"
],
"navigation": [],
"mainPage": "jobs",
"icon": "ui/icon.png",
"title": "Fine-tuning",
"description": "Train an adapter on your own data, then export it as a model you can chat with."
}
}
}
+38
View File
@@ -0,0 +1,38 @@
# Release platform matrix of the fine-tuning add-on package.
#
# A platform ships only while "enabled" is true. Enable a platform in the same
# change that shows its native check passing on a runner of that platform;
# until then it stays listed and disabled. The release workflow fails closed
# when no platform is enabled, and it never builds a platform that is not
# enabled here.
#
# runner: the GitHub runner label that builds and checks the platform
# natively. Required for an enabled platform.
# native_check: "executable-start": the packaged executable is extracted on
# the native runner and started. Required for an enabled platform.
# runtime: files besides the executable that the platform needs at run
# time, relative to the package root. They are taken from the
# runtime directory of the build and must exist.
schema: 1
addon: fine-tuning
executable: fine-tuning
manifest: addons/fine-tuning/manifest.json
# The directory whose content is laid at the package root (ui/fine-tuning.js ...).
ui: addons/fine-tuning/ui/dist
platforms:
- platform: linux/amd64
enabled: true
runner: ubuntu-24.04
native_check: executable-start
- platform: linux/arm64
enabled: true
runner: ubuntu-24.04-arm
native_check: executable-start
- platform: darwin/arm64
enabled: true
runner: macos-14
native_check: executable-start
- platform: darwin/amd64
enabled: true
runner: macos-15-intel
native_check: executable-start
+2
View File
@@ -0,0 +1,2 @@
# Written by build.mjs; the package build makes it.
/dist/
+13
View File
@@ -0,0 +1,13 @@
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
import { buildAddonUI } from '../../internal/uibuild/uibuild.mjs'
const here = dirname(fileURLToPath(import.meta.url))
export async function build() {
return buildAddonUI({ root: here, name: 'fine-tuning', rgb: [92, 74, 150] })
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
build().then((dir) => console.log(`built ${dir}`), (e) => { console.error(e); process.exit(1) })
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "localai-fine-tuning-ui",
"private": true,
"version": "0.1.0",
"description": "Native UI of the fine-tuning add-on: a standalone ES module that the host loads after its own build.",
"type": "module",
"scripts": { "build": "node build.mjs", "test": "node test/run.mjs" }
}
File diff suppressed because it is too large. Load diff
+169
View File
@@ -0,0 +1,169 @@
// The drawing is made in the size it is shown at, so the text stays readable: a
// wide one on a desktop, a narrow one on a phone.
const SIZES = {
wide: { W: 720, H: 260, PAD: { top: 16, right: 64, bottom: 36, left: 56 } },
compact: { W: 340, H: 240, PAD: { top: 16, right: 48, bottom: 36, left: 44 } },
}
// The factory binds the context once. The drawing is the one of
// components/tools/LossChart.jsx; only the translator and React come from ctx.
export function createLossChart(ctx) {
const { h, Fragment, t } = ctx
const { useEffect, useMemo, useRef, useState } = ctx.React
function useCompact() {
const query = '(max-width: 640px)'
const [compact, setCompact] = useState(() => typeof window !== 'undefined' && window.matchMedia(query).matches)
useEffect(() => {
const mq = window.matchMedia(query)
const on = () => setCompact(mq.matches)
mq.addEventListener('change', on)
return () => mq.removeEventListener('change', on)
}, [])
return compact
}
function tickText(v, exp) {
if (!Number.isFinite(v)) return ''
if (exp) return v.toExponential(1)
if (Math.abs(v) >= 100) return v.toFixed(0)
if (Math.abs(v) >= 1) return v.toFixed(2)
return v.toFixed(3)
}
// The metrics of a fine-tuning run as one thin line, with a tab for each metric.
// Loss also marks the evaluation loss as hollow dots when the run reports it.
// The end of the line carries its value, the axes are labelled, and a data table
// sits behind a disclosure for anyone who wants the numbers.
function LossChart({ events, totalSteps }) {
const [metric, setMetric] = useState('loss')
const [hover, setHover] = useState(null)
const svgRef = useRef(null)
const compact = useCompact()
const { W, H, PAD } = SIZES[compact ? 'compact' : 'wide']
const series = useMemo(() => ({
loss: events.filter(e => e.loss > 0),
learning_rate: events.filter(e => e.learning_rate > 0),
grad_norm: events.filter(e => e.grad_norm > 0),
}), [events])
const evalPoints = useMemo(() => events.filter(e => e.eval_loss > 0), [events])
const data = series[metric]
const exp = metric === 'learning_rate'
const key = metric
const tabs = ['loss', 'learning_rate', 'grad_norm']
const geometry = useMemo(() => {
if (data.length < 2) return null
const values = data.map(e => e[key]).concat(metric === 'loss' ? evalPoints.map(e => e.eval_loss) : [])
const lo = Math.min(...values)
const hi = Math.max(...values)
const span = hi - lo || Math.abs(hi) || 1
const yMin = metric === 'loss' ? Math.max(0, lo - span * 0.08) : lo - span * 0.08
const yMax = hi + span * 0.08
const steps = data.map(e => e.current_step)
const xMin = Math.min(...steps)
const xMax = Math.max(totalSteps || 0, ...steps)
const cw = W - PAD.left - PAD.right
const ch = H - PAD.top - PAD.bottom
const x = (s) => PAD.left + ((s - xMin) / (xMax - xMin || 1)) * cw
const y = (v) => PAD.top + (1 - (v - yMin) / (yMax - yMin || 1)) * ch
const yTicks = Array.from({ length: 4 }, (_, i) => yMin + ((yMax - yMin) * i) / 3)
const xTicks = Array.from({ length: compact ? 3 : 5 }, (_, i) => Math.round(xMin + ((xMax - xMin) * i) / (compact ? 2 : 4)))
return { x, y, yTicks, xTicks, xMin, xMax, cw, ch }
}, [data, evalPoints, key, metric, totalSteps, W, H, PAD, compact])
const move = (e) => {
if (!geometry || !svgRef.current) return
const rect = svgRef.current.getBoundingClientRect()
const px = ((e.clientX - rect.left) / rect.width) * W
const step = geometry.xMin + ((px - PAD.left) / geometry.cw) * (geometry.xMax - geometry.xMin)
let best = data[0]
for (const d of data) if (Math.abs(d.current_step - step) < Math.abs(best.current_step - step)) best = d
setHover(best)
}
const last = data[data.length - 1]
const first = data[0]
const readout = hover || last
return (
<section className="bt-chart dk-card" aria-labelledby="bt-chart-title" data-testid="job-chart">
<header className="bt-chart__head">
<div>
<h2 className="bt-h2" id="bt-chart-title">{t(`chart.${metric}`)}</h2>
<p className="dk-hint">
{geometry
? t(metric === 'loss' && evalPoints.length ? 'chart.windowEval' : 'chart.window', { from: first.current_step, to: last.current_step, total: totalSteps || last.current_step })
: t('chart.waiting')}
</p>
</div>
<div className="dk-segmented" role="tablist" aria-label={t('chart.metric')}>
{tabs.map(id => (
<button key={id} type="button" role="tab" className="dk-seg" aria-selected={metric === id} onClick={() => { setMetric(id); setHover(null) }}>
{t(`chart.${id}`)}
</button>
))}
</div>
</header>
{geometry ? (
<>
<svg
ref={svgRef}
className="bt-chart__svg"
viewBox={`0 0 ${W} ${H}`}
role="img"
aria-label={t('chart.aria', { metric: t(`chart.${metric}`), last: tickText(last[key], exp), step: last.current_step })}
onMouseMove={move}
onMouseLeave={() => setHover(null)}
>
{geometry.yTicks.map((v, i) => (
<g key={i}>
<line className="bt-chart__grid" x1={PAD.left} x2={W - PAD.right} y1={geometry.y(v)} y2={geometry.y(v)} />
<text className="bt-chart__tick" x={PAD.left - 8} y={geometry.y(v) + 4} textAnchor="end">{tickText(v, exp)}</text>
</g>
))}
{geometry.xTicks.map((s, i) => (
<text key={i} className="bt-chart__tick" x={geometry.x(s)} y={H - PAD.bottom + 16} textAnchor="middle">{s}</text>
))}
<text className="bt-chart__axis" x={PAD.left + geometry.cw / 2} y={H - 4} textAnchor="middle">{t('chart.step')}</text>
<polyline className="bt-chart__line" points={data.map(e => `${geometry.x(e.current_step)},${geometry.y(e[key])}`).join(' ')} />
{metric === 'loss' && evalPoints.map((e, i) => (
<circle key={i} className="bt-chart__eval" cx={geometry.x(e.current_step)} cy={geometry.y(e.eval_loss)} r="4" />
))}
<circle className="bt-chart__end" cx={geometry.x(last.current_step)} cy={geometry.y(last[key])} r="4" />
<text className="bt-chart__label" x={geometry.x(last.current_step) + 10} y={geometry.y(last[key]) + 4}>{tickText(last[key], exp)}</text>
{hover && <line className="bt-chart__cursor" x1={geometry.x(hover.current_step)} x2={geometry.x(hover.current_step)} y1={PAD.top} y2={H - PAD.bottom} />}
</svg>
<p className="bt-chart__readout dk-mono" aria-live="off">
{t('chart.readout', { step: readout.current_step, value: tickText(readout[key], exp) })}
</p>
<details className="bt-chart__data">
<summary>{t('chart.dataTitle')}</summary>
<div className="dk-table-wrap bt-chart__table">
<table className="dk-table dk-table--compact">
<caption className="dk-sr-only">{t('chart.dataTitle')}</caption>
<thead><tr><th>{t('chart.step')}</th><th className="dk-num">{t(`chart.${metric}`)}</th>{metric === 'loss' && <th className="dk-num">{t('chart.eval')}</th>}</tr></thead>
<tbody>
{data.slice(-12).map(e => (
<tr key={e.current_step} data-row>
<td>{e.current_step}</td>
<td className="dk-num">{tickText(e[key], exp)}</td>
{metric === 'loss' && <td className="dk-num">{e.eval_loss > 0 ? tickText(e.eval_loss, false) : ''}</td>}
</tr>
))}
</tbody>
</table>
</div>
</details>
</>
) : (
<p className="bt-chart__empty">{t('chart.emptyText')}</p>
)}
</section>
)
}
return { LossChart }
}
+37
View File
@@ -0,0 +1,37 @@
// The core routes the fine-tuning add-on uses, reached with the signed-in
// user's own session (sdk.core.fetch). Paths and bodies are the ones of
// fineTuneApi and resourcesApi in core/http/react-ui/src/utils/api.js; only the
// transport changed.
export function createApi(ctx) {
const { json } = ctx
const enc = encodeURIComponent
const post = (path, data) => json(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
return {
fineTune: {
listBackends: () => json('/api/fine-tuning/backends'),
startJob: (data) => post('/api/fine-tuning/jobs', data),
listJobs: () => json('/api/fine-tuning/jobs'),
getJob: (id) => json(`/api/fine-tuning/jobs/${enc(id)}`),
stopJob: (id, saveCheckpoint) => json(`/api/fine-tuning/jobs/${enc(id)}/stop?save_checkpoint=${saveCheckpoint ? 'true' : 'false'}`, { method: 'POST' }),
deleteJob: (id) => json(`/api/fine-tuning/jobs/${enc(id)}`, { method: 'DELETE' }),
listCheckpoints: (id) => json(`/api/fine-tuning/jobs/${enc(id)}/checkpoints`),
exportModel: (id, data) => post(`/api/fine-tuning/jobs/${enc(id)}/export`, data),
// The browser sets the multipart boundary, so no Content-Type is given.
uploadDataset: (file) => {
const formData = new FormData()
formData.append('file', file)
return json('/api/fine-tuning/datasets', { method: 'POST', body: formData })
},
// The response is a stream of events; the caller reads it with readSse
// and aborts the signal to close it.
progress: (id, signal) => ctx.core.fetch(`/api/fine-tuning/jobs/${enc(id)}/progress`, { signal }),
},
// The one request useResources makes: the machine facts for the checks.
resources: {
get: () => json('/api/resources'),
},
// The link the browser follows to download the archive of a job.
downloadHref: (id) => `${ctx.basePath}/api/fine-tuning/jobs/${enc(id)}/download`,
}
}
+68
View File
@@ -0,0 +1,68 @@
import en from './i18n/en.js'
import { modelFrom as pickModel } from './lib/model.js'
// The one object every factory of the fine-tuning add-on receives. It binds the
// SDK once: React and the JSX helper, the shared components, the
// session-bound core fetch, the translation and the small adapters that replace
// the host router and toasts. The shared job views (addons/internal/tools-ui)
// read h, Fragment, t, Icon, React, viewer and basePath from it.
export function createContext(sdk) {
const { React } = sdk
const h = React.createElement
const Fragment = React.Fragment
sdk.locale.define('en', Object.fromEntries(Object.entries(en).filter(([, v]) => typeof v === 'string')))
const t = (key, vars) => sdk.locale.t(key, key, vars)
// The text of a key, or the fallback when the bundle has none.
const tOr = (key, fallback) => {
const text = t(key)
return text === key ? fallback : text
}
// The SDK translator has no plural rule: the host's `_one` and `_other`
// variants are picked here.
const plural = (key, count, vars) => t(`${key}_${count === 1 ? 'one' : 'other'}`, { count, ...vars })
// A link to a page of this add-on, as an anchor the shell navigates.
function Link({ page, query, className, children, ...rest }) {
const href = sdk.navigation.href(query ? `${page}?${query}` : page)
return h('a', {
href, className,
onClick: (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; e.preventDefault(); sdk.navigation.navigate(query ? `${page}?${query}` : page) },
...rest,
}, children)
}
const useSearch = () => new URLSearchParams(sdk.navigation.current().search)
// The model a page works on: ?model=<id> first, then the first path segment.
const modelFrom = (path) => pickModel(sdk.navigation.current().search, path)
const toast = (message, kind = 'info') => (sdk.notify[kind] || sdk.notify.info)(message)
// The path prefix LocalAI is served under, read the way the host reads it
// (utils/basePath.js: the <base href> element).
const basePath = (() => {
if (typeof document === 'undefined') return ''
const el = document.querySelector('base[href]')
if (!el) return ''
try { return new URL(el.getAttribute('href')).pathname.replace(/\/+$/, '') } catch { return '' }
})()
// A JSON call of a core route. A failure keeps the server's message.
const json = async (path, init) => {
const response = await sdk.core.fetch(path, init)
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body?.error?.message || body?.error || `HTTP ${response.status}`)
}
return response.json()
}
return {
sdk, React, h, Fragment, t, tOr, plural, Icon: sdk.Icon, ui: sdk.ui, hooks: sdk.hooks, viewer: sdk.viewer, core: sdk.core,
Link, useSearch, modelFrom, toast, json, basePath,
openPanel: (title, render) => sdk.layers.openPanel({ title, render }),
// A dialog the host draws: focus, Escape and the veil are the host's.
openDialog: (spec) => sdk.layers.openDialog(spec),
markDirty: (flag) => sdk.markDirty(flag),
}
}
+300
View File
@@ -0,0 +1,300 @@
// Generated once from the host locale file public/locales/en/tools.json; edit by hand from here on.
// The last three keys belong to this add-on.
export default {
"steps.label": "Progress",
"steps.setup": "Set up",
"steps.check": "Check",
"steps.run": "Run",
"steps.result": "Result",
"run.stop": "Stop",
"run.connecting": "Connecting to the job. Waiting for the first progress event.",
"run.progress": "Progress",
"run.stages": "Stages",
"run.step": "step",
"run.of": "of",
"run.epoch": "epoch",
"run.tokens": "tokens/s",
"run.eta": "about {{time}} left",
"run.etaSeconds": "about {{seconds}} s left",
"checks.heading": "Check before you start",
"checks.live": "Updates as you change the setup.",
"checks.meterLabel": "{{used}} percent in use, {{free}} free of {{total}}",
"checks.openBackends": "Open Backends",
"checks.barOk": "Nothing blocks the start.",
"checks.barWarn_one": "{{count}} warning. You can still start.",
"checks.barWarn_other": "{{count}} warnings. You can still start.",
"checks.title.model": "Model",
"checks.title.data": "Dataset",
"checks.title.backend": "Backend",
"checks.title.memory": "Memory",
"checks.title.disk": "Disk",
"checks.title.reward": "Reward functions",
"checks.title.resume": "Resuming",
"checks.title.access": "Model access",
"checks.title.type": "Quantization type",
"checks.title.source": "Source",
"checks.title.name": "Name",
"checks.fineTune.modelOk": "Training {{model}}.",
"checks.fineTune.modelMissing": "Enter the model to train.",
"checks.fineTune.dataOk": "Data comes from {{source}}.",
"checks.fineTune.dataMissing": "Add a dataset: a Hugging Face id or an uploaded file.",
"checks.fineTune.backendOk": "{{name}} is installed.",
"checks.fineTune.backendMissing": "No fine-tuning backend is installed, so the job may fail to start.",
"checks.fineTune.backendOther": "{{name}} is not among the installed fine-tuning backends ({{list}}).",
"checks.fineTune.backendUnknown": "The installed backends could not be read.",
"checks.fineTune.memoryGpu": "{{name}}: {{free}} of {{total}} free. Batch size {{batch}}, sequence length {{seq}}, gradient accumulation {{accum}}. How much the job needs depends on the model and these settings, and LocalAI does not estimate it before the job starts.",
"checks.fineTune.memoryCluster": "{{total}} on the best node ({{node}}). Batch size {{batch}}, sequence length {{seq}}. How much the job needs depends on the model and these settings.",
"checks.fineTune.memoryCpu": "No GPU found. Training uses the CPU and {{free}} of {{total}} RAM is free. Expect it to be slow. Batch size {{batch}}, sequence length {{seq}}.",
"checks.fineTune.memoryUnknown": "This account cannot read the machine's memory. Batch size {{batch}}, sequence length {{seq}}. How much the job needs depends on the model and these settings.",
"checks.fineTune.disk": "{{free}} free on the models disk. Downloads and checkpoints need room.",
"checks.fineTune.diskLow": "Only {{free}} free on the models disk. Downloads and checkpoints need room.",
"checks.fineTune.rewardMissing": "GRPO needs at least one reward function. Add one under More options.",
"checks.fineTune.resume": "Training continues from {{path}}.",
"checks.fineTune.accessNone": "No token set. A gated model needs a Hugging Face token under More options.",
"checks.fineTune.accessSet": "A Hugging Face token is set.",
"checks.quantize.modelOk": "Quantizing {{model}}.",
"checks.quantize.modelMissing": "Enter the model to quantize.",
"checks.quantize.typeMissing": "Enter the custom quantization type.",
"checks.quantize.backendOk": "{{name}} is installed.",
"checks.quantize.backendMissing": "No quantization backend is installed, so the job may fail to start.",
"checks.quantize.backendOther": "{{name}} is not among the installed quantization backends ({{list}}).",
"checks.quantize.backendUnknown": "The installed backends could not be read.",
"checks.quantize.memory": "{{free}} of {{total}} RAM is free. Conversion runs on the CPU.",
"checks.quantize.disk": "{{free}} free on the models disk. The download, the converted file and the result are written to disk.",
"checks.quantize.diskLow": "Only {{free}} free on the models disk. The download, the converted file and the result are written to disk.",
"checks.quantize.accessNone": "No token set. A gated model needs a Hugging Face token under More options.",
"checks.quantize.accessSet": "A Hugging Face token is set.",
"checks.import.sourceOk_huggingface": "Reads as a Hugging Face repository. LocalAI looks at its files when you import.",
"checks.import.sourceOk_url": "URL format recognised. Its contents have not been checked.",
"checks.import.sourceOk_config": "Reads as a model configuration file. It is read and used as it is.",
"checks.import.sourceOk_oci": "Reads as an OCI image reference.",
"checks.import.sourceOk_ollama": "Reads as an Ollama model reference.",
"checks.import.sourceOk_local": "Reads as a path on the machine that runs LocalAI.",
"checks.import.sourceOk": "The source is recognised.",
"checks.import.sourceUnknown": "This does not look like any form LocalAI knows. It will still try, and may fail.",
"checks.import.backendDownload": "{{name}} is not installed yet. LocalAI downloads it first.",
"checks.import.backendChosen": "{{name}} is chosen and installed.",
"checks.import.backendAuto": "LocalAI picks the backend from the source's files when the import starts.",
"checks.import.nameTaken": "A model named {{name}} is already installed.",
"checks.import.nameFree": "{{name}} is not in use.",
"checks.import.nameAuto": "The model is named after the source.",
"checks.import.disk": "{{free}} free on the models disk. The download size is known once the import starts.",
"checks.import.memory_gpu": "{{free}} of GPU memory is free now. What this model needs is known once the import starts.",
"checks.import.memory_ram": "{{free}} of RAM is free now. What this model needs is known once the import starts.",
"checks.import.memory_cluster": "{{free}} is available on the best node. What this model needs is known once the import starts.",
"checks.import.memory": "{{free}} of memory is free now. What this model needs is known once the import starts.",
"form.key": "Key",
"form.value": "Value",
"form.addOption": "Add option",
"form.copy": "Copy to clipboard",
"form.more": "More options",
"form.moreHint": "method, backend, adapter, optimizer, evaluation, reward functions",
"form.baseModel": "Base model",
"form.model": "Model",
"form.modelHint": "A Hugging Face id, or a path on this host.",
"form.data": "Your data",
"form.dataset": "Dataset source",
"form.datasetHint": "A Hugging Face dataset id, or upload a file.",
"form.split": "Split",
"form.upload": "Upload a file instead",
"form.uploadHint": ".json, .jsonl or .csv",
"form.removeFile": "Remove the file",
"form.train": "What to train",
"form.kind": "Kind of training",
"form.kinds.adapter.title": "Adapter",
"form.kinds.adapter.text": "Trains a small adapter and leaves the base weights alone. The usual choice, and it needs little memory.",
"form.kinds.full.title": "Full model",
"form.kinds.full.text": "Changes every weight. It needs several times the memory of an adapter.",
"form.epochs": "Epochs",
"form.batch": "Batch size",
"form.lr": "Learning rate",
"form.backend": "Backend",
"form.method": "Training method",
"form.adapterKind": "Adapter kind",
"form.rank": "Rank",
"form.alpha": "Alpha",
"form.dropout": "Dropout",
"form.gradAccum": "Gradient accumulation steps",
"form.warmup": "Warmup steps",
"form.seqLen": "Max sequence length",
"form.optimizer": "Optimizer",
"form.maxSteps": "Max steps",
"form.maxStepsHint": "0 for automatic.",
"form.saveSteps": "Save steps",
"form.saveLimit": "Save total limit",
"form.saveLimitHint": "0 keeps every checkpoint.",
"form.weightDecay": "Weight decay",
"form.seed": "Seed",
"form.seedHint": "0 picks a random seed.",
"form.precision": "Mixed precision",
"form.auto": "Auto",
"form.targetModules": "Target modules",
"form.targetModulesHint": "Comma-separated. Leave blank for the backend default.",
"form.gradCheckpointing": "Gradient checkpointing (slower, uses less memory)",
"form.token": "Hugging Face token",
"form.tokenHint": "Only needed for gated models.",
"form.evalEnable": "Evaluate while training",
"form.evalStrategy": "Evaluation strategy",
"form.evalSteps_": "Steps",
"form.evalEpoch": "Epoch",
"form.evalSteps": "Evaluation steps",
"form.evalStepsHint": "0 matches save steps.",
"form.evalSplit": "Evaluation split",
"form.evalDataset": "Evaluation dataset",
"form.evalDatasetPlaceholder": "A separate dataset",
"form.evalRatio": "Auto-split ratio",
"form.rewards": "Reward functions (GRPO)",
"form.rewardsHint": "GRPO needs at least one reward function. Pick built-in ones or add your own.",
"form.resume": "Resume from checkpoint",
"form.clearCheckpoint": "Clear the checkpoint",
"form.liquidHint": "The dataset must be preprocessed by LFM2AudioChatMapper: a directory of arrow files the LFM2 data loader can read.",
"form.liquidVoice": "Speech voice",
"form.liquidInherit": "Inherit from the system prompt",
"form.liquidVal": "Validation dataset",
"form.extra": "Extra options",
"form.extraHint": "Backend-specific key and value pairs.",
"form.summaryFull": "full model",
"form.summary_lora": "LoRA adapter",
"form.summary_loha": "LoHa adapter",
"form.summary_lokr": "LoKr adapter",
"form.recipe": "Train a {{kind}} of {{model}} with {{method}}, {{epochs}} epochs over {{data}}.",
"form.recipeEmpty": "Choose a model and a dataset to see what will be trained.",
"rewards.format_reward": "Checks the <think>...</think> then answer format.",
"rewards.reasoning_accuracy_reward": "Compares the <answer> content to the dataset's answer column.",
"rewards.length_reward": "Scores how close the answer is to a target length.",
"rewards.xml_tag_reward": "Scores properly opened and closed XML tags.",
"rewards.no_repetition_reward": "Penalizes repeated n-grams.",
"rewards.code_execution_reward": "Checks that Python code blocks are valid syntax.",
"rewards.param_target_length": "Target length",
"rewards.fnName": "Function name",
"rewards.fnBody": "Function body",
"rewards.fnHint": "Receives completions and kwargs and must return a list of floats. re, math, json and string are available.",
"rewards.add": "Add",
"rewards.cancel": "Cancel",
"rewards.custom": "Add a custom reward function",
"fineTune.title": "Fine-tune",
"fineTune.lede": "Train an adapter on your own data, then export it as a model you can chat with.",
"fineTune.importConfig": "Import config",
"fineTune.exportConfig": "Export config",
"fineTune.newJob": "New job",
"fineTune.badConfig": "Could not read that file. Use a JSON config exported from this page.",
"fineTune.start": "Start fine-tuning",
"fineTune.starting": "Starting...",
"fineTune.resume": "Resume training",
"fineTune.resuming": "Resuming from checkpoint:",
"fineTune.clear": "Clear",
"fineTune.barNote": "Nothing is changed until you start.",
"fineTune.jobMeta": "{{kind}} {{method}} on {{backend}}, started {{started}}",
"fineTune.failed": "Training failed",
"fineTune.failedAt": "Training stopped at step {{step}} of {{total}}",
"fineTune.noMessage": "The backend gave no message.",
"fineTune.finished": "Training finished.",
"fineTune.finishedLoss": "Training finished. The last loss was {{loss}}.",
"fineTune.stopped": "The job was stopped. A checkpoint is kept when you chose to keep it.",
"fineTune.tryTitle": "What to do next",
"fineTune.tryText": "The server reported that memory ran out. Each change below is applied to a copy of the setup, and nothing starts until you press Start.",
"fineTune.tryPlain": "Open the setup again, change what the message points at, and start a new job.",
"fineTune.fixBatch": "Lower the batch size to 1",
"fineTune.fixCheckpointing": "Turn on gradient checkpointing (slower, uses less memory)",
"fineTune.retryChanges": "Open the setup with these changes",
"fineTune.reuse": "Open the setup again",
"fineTune.resumeFrom": "Resume from step {{step}}",
"fineTune.status.queued": "Queued",
"fineTune.status.loading_model": "Loading model",
"fineTune.status.loading_dataset": "Loading dataset",
"fineTune.status.training": "Training",
"fineTune.status.saving": "Saving",
"fineTune.status.completed": "Completed",
"fineTune.status.failed": "Failed",
"fineTune.status.stopped": "Stopped",
"stop.title": "Stop training?",
"stop.text": "A checkpoint saved now lets you resume or export what has been learned so far.",
"stop.keep": "Stop and keep a checkpoint",
"stop.discard": "Stop without a checkpoint",
"stop.cancel": "Keep training",
"stop.cancelDelete": "Cancel",
"chart.loss": "Loss",
"chart.learning_rate": "Learning rate",
"chart.grad_norm": "Gradient norm",
"chart.metric": "Metric",
"chart.step": "step",
"chart.eval": "Eval loss",
"chart.waiting": "Waiting for more steps.",
"chart.window": "Steps {{from}} to {{to}} of {{total}}",
"chart.windowEval": "Steps {{from}} to {{to}} of {{total}}. Hollow dots are the evaluation loss.",
"chart.aria": "{{metric}} over training steps. The last value is {{last}} at step {{step}}.",
"chart.readout": "step {{step}}: {{value}}",
"chart.dataTitle": "Show the numbers",
"chart.emptyText": "The chart draws once the job has reported two steps.",
"log.title": "Log",
"log.note": "What the job reported since this page opened.",
"log.only": "Warnings and errors only",
"log.copy": "Copy",
"log.copied": "Copied",
"log.download": "Download",
"log.empty": "Nothing reported yet.",
"log.noneFiltered": "No warnings or errors.",
"checkpoints.title": "Checkpoints",
"checkpoints.step": "Step",
"checkpoints.epoch": "Epoch",
"checkpoints.loss": "Loss",
"checkpoints.path": "Path",
"checkpoints.actions": "Actions",
"checkpoints.resume": "Resume",
"checkpoints.resumeTitle": "Resume training from this checkpoint",
"checkpoints.export": "Export",
"checkpoints.exportTitle": "Export this checkpoint",
"export.title": "Export as a model",
"export.lede": "Exporting turns the result into a model LocalAI can load.",
"export.checkpoint": "Checkpoint",
"export.final": "Final model (output directory)",
"export.checkpointOption": "Step {{step}} (loss {{loss}})",
"export.format": "Export format",
"export.lora": "LoRA adapter",
"export.merged16": "Merged (16-bit)",
"export.merged4": "Merged (4-bit)",
"export.quant": "Quantization",
"export.name": "Model name",
"export.nameHint": "Leave blank to generate one from the job.",
"export.go": "Export",
"export.exporting": "Exporting...",
"export.working": "Export in progress...",
"export.done": "Model exported and registered as \"{{name}}\"",
"export.failed": "Export failed: {{message}}",
"export.unknown": "unknown error",
"export.nextTitle": "{{name}} is a model now",
"export.nextText": "It is registered with LocalAI. Open a chat with it, or find it in Models.",
"export.chat": "Chat with {{name}}",
"export.models": "Open in Models",
"export.archive": "Download archive",
"jobs.title": "Earlier jobs",
"jobs.count_one": "{{count}} kept on this server",
"jobs.count_other": "{{count}} kept on this server",
"jobs.loading": "Loading jobs",
"jobs.emptyTitle": "No fine-tuning jobs yet",
"jobs.emptyText": "Set one up above. It appears here with its status, and you can reuse its setup later.",
"jobs.model": "Model",
"jobs.kind": "Kind",
"jobs.status": "Status",
"jobs.started": "Started",
"jobs.actions": "Actions",
"jobs.open": "Open this job",
"jobs.reuse": "Reuse",
"jobs.reuseTitle": "Use this job's setup for a new job",
"jobs.delete": "Delete",
"jobs.deleteTitle": "Delete this job and its data",
"jobs.deleteDialogTitle": "Delete this job?",
"jobs.deleteDialogText": "This deletes the job with its checkpoints and any exported model. It cannot be undone.",
"off.badge": "Not available to you",
"off.fineTune.title": "Your account cannot fine-tune",
"off.fineTune.text": "Fine-tuning is a separate permission, and this account does not have it.",
"off.quantize.title": "Your account cannot quantize",
"off.quantize.text": "Quantization is a separate permission, and this account does not have it.",
"off.admin": "An administrator can turn it on under Users and keys.",
"off.askAdmin": "Ask an administrator to turn it on for your account.",
"off.back": "Go to Home",
"landing.experimental": "Experimental",
"run.unknownPercent": "Progress not reported yet",
"export.adminOnly.title": "Exporting a model is for administrators",
"export.adminOnly.text": "Exporting adds a model to this server for everyone who can use it. Ask an administrator to export this job."
}
+46
View File
@@ -0,0 +1,46 @@
import { createContext } from './ctx.jsx'
import { createApi } from './api.js'
import { createFineTune } from './FineTune.jsx'
import * as tools from '../../../internal/tools-ui/src/tools.js'
import { readSse } from '../../../internal/tools-ui/src/sse.js'
import { createToolSteps } from '../../../internal/tools-ui/src/ToolSteps.jsx'
import { createChecks } from '../../../internal/tools-ui/src/Checks.jsx'
import { createStageLine } from '../../../internal/tools-ui/src/StageLine.jsx'
import { createJobLog } from '../../../internal/tools-ui/src/JobLog.jsx'
import { createToolOff } from '../../../internal/tools-ui/src/ToolOff.jsx'
// What the page and the shared job views are built from, for one SDK.
function build(sdk) {
const ctx = createContext(sdk)
const api = createApi(ctx)
const shared = {
tools, readSse,
ToolSteps: createToolSteps(ctx), Checks: createChecks(ctx), StageLine: createStageLine(ctx),
JobLog: createJobLog(ctx), ToolOff: createToolOff(ctx),
}
return { ctx, api, parts: createFineTune(ctx, api, shared) }
}
// The second argument is for tests: the host passes the SDK only.
export function createAddon(sdk, options = {}) {
const { ctx, parts } = build(sdk)
// The JSX of every factory compiles to calls of `h`; ctx binds it once and
// each factory reads it from ctx. This `h` is for the JSX in this file.
// eslint-disable-next-line no-unused-vars
const { h } = ctx
const pages = {
jobs: parts.FineTune,
...options.pages,
}
function Component({ page, path }) {
const Page = pages[page]
return Page ? <Page path={path || ''} /> : null
}
return { Component, components: {}, chatActions: [], dispose() {} }
}
// For tests: the context, the API and the parts of the page, built the way
// createAddon builds them.
export const __partsFor = (sdk) => build(sdk)
export const __ctxFor = (sdk) => createContext(sdk)
+8
View File
@@ -0,0 +1,8 @@
// The model a page works on. A moved link carries it as ?model=<id>, because
// a model id may hold "/" or ":" that an add-on path segment refuses; a link
// typed by hand may still carry it as the first path segment.
export function modelFrom(search, path) {
const fromQuery = new URLSearchParams(search || '').get('model')
if (fromQuery) return fromQuery
return String(path || '').split('/')[0]
}
+22
View File
@@ -0,0 +1,22 @@
// The statuses in which a job is working. They are the stages of the stage line.
export const ACTIVE = ['queued', 'loading_model', 'loading_dataset', 'training', 'saving']
// How far a job is, in percent, or null when that is not known. The engine
// reads 0 for a job that is running but has reported nothing yet (a snapshot
// has no percent of its own), so a 0 while the job works means "unknown", and
// the page must not draw it as 0%. Steps, when they are known, give a better
// answer than that 0.
export function percentOf(event) {
if (!event) return null
const status = event.status
if (status === 'completed') return 100
const percent = Number(event.progress_percent) || 0
if (percent > 0) return Math.min(percent, 100)
if (ACTIVE.includes(status)) {
const done = Number(event.current_step) || 0
const total = Number(event.total_steps) || 0
if (done > 0 && total > 0) return Math.min((done / total) * 100, 100)
return null
}
return percent
}
+271
View File
@@ -0,0 +1,271 @@
/* ==========================================================================
Build tools: the landing, Fine-tune, Quantize and the shared pieces Import
uses. Built on the kit's card, field, table, badge, chip, switch, progress,
meter, segmented, dialog and sheet classes. What is here is what the kit does
not say: the step line, the check list, the stage line, the job card, the
chart and the log. Colours, radii and spacing are kit roles (--dk-*).
Surfaces are flat and nothing sits on a coloured rail at the edge. Motion is
switched off for reduced motion at the end of this file.
========================================================================== */
.bt-page { display: flex; flex-direction: column; gap: var(--dk-space-5); min-width: 0; }
.bt-page :is(a, button, summary, select, input, textarea, [tabindex]):focus-visible { outline: 2px solid var(--dk-accent-text); outline-offset: 2px; }
.bt-page > .page-header { margin-bottom: 0; }
.bt-h1 { margin: 0; font: var(--dk-weight-bold) var(--dk-text-32) / 1.15 var(--dk-font-display); letter-spacing: -.02em; text-wrap: balance; }
.bt-h2 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-17) / 1.3 var(--dk-font-display); color: var(--dk-text); }
.bt-h3 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-15) / 1.3 var(--dk-font-display); color: var(--dk-text); }
.bt-experimental { vertical-align: middle; margin-left: var(--dk-space-2); }
.bt-head-acts { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; justify-content: flex-end; }
.bt-stack { display: flex; flex-direction: column; gap: var(--dk-space-4); min-width: 0; }
.bt-empty { color: var(--dk-muted); }
.bt-block { display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.bt-block.dk-card { padding: var(--dk-space-4) var(--dk-space-5); }
.bt-block__head { display: flex; align-items: baseline; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-block__head .dk-hint { margin: 0; }
/* ---- Step line -------------------------------------------------------------- */
.bt-steps { list-style: none; margin: 0; padding: 0; display: flex; align-items: center; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-step { display: inline-flex; align-items: center; gap: var(--dk-space-2); color: var(--dk-muted); font-size: var(--dk-text-13); font-weight: var(--dk-weight-semibold); }
.bt-step:not(:last-child)::after { content: ''; width: 48px; height: 2px; margin-left: var(--dk-space-2); border-radius: 2px; background: var(--dk-edge); }
.bt-step[data-state="done"]:not(:last-child)::after { background: var(--dk-accent); }
.bt-step__mark { display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--dk-edge); font: var(--dk-weight-bold) var(--dk-text-12) / 1 var(--dk-font-mono); color: var(--dk-muted); box-sizing: border-box; }
.bt-step__mark .lai-icon { font-size: 13px; }
.bt-step[data-state="done"] .bt-step__mark { background: var(--dk-accent); border-color: var(--dk-accent); color: var(--dk-on-accent); }
.bt-step[data-state="current"] { color: var(--dk-text); }
.bt-step[data-state="current"] .bt-step__mark { border-color: var(--dk-accent-text); color: var(--dk-accent-text); }
.bt-step[data-state="failed"] { color: var(--dk-text); }
.bt-step[data-state="failed"] .bt-step__mark { background: var(--dk-error-wash); border-color: var(--dk-error); color: var(--dk-error-text); }
.bt-step[data-state="done"] { color: var(--dk-text); }
/* ---- Form sections ----------------------------------------------------------- */
.bt-form-page { display: flex; flex-direction: column; gap: var(--dk-space-5); min-width: 0; }
.bt-section { display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.bt-section__title { margin: 0; display: flex; align-items: center; gap: var(--dk-space-3); font: var(--dk-weight-semibold) var(--dk-text-17) / 1.3 var(--dk-font-display); }
.bt-section__mark { display: inline-grid; place-items: center; width: 24px; height: 24px; flex: none; border-radius: 50%; border: 2px solid var(--dk-edge); box-sizing: border-box; color: var(--dk-on-accent); }
.bt-section__mark .lai-icon { font-size: 13px; }
.bt-section[data-done="true"] .bt-section__mark { background: var(--dk-accent); border-color: var(--dk-accent); }
.bt-section__body { display: flex; flex-direction: column; gap: var(--dk-space-3); padding-left: calc(24px + var(--dk-space-3)); min-width: 0; }
.bt-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--dk-space-3); }
.bt-pair--wide { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
.bt-triple { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--dk-space-3); }
.bt-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: var(--dk-space-3); }
.bt-form { display: flex; flex-direction: column; gap: var(--dk-space-3); }
.bt-actions { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; }
.bt-choices { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0; min-width: 0; border-radius: var(--dk-radius-2); overflow: hidden; border: 1px solid var(--dk-edge); }
.bt-choices > .dk-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.bt-choice { display: flex; align-items: flex-start; gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); background: var(--dk-card); cursor: pointer; }
.bt-choice + .bt-choice { border-top: 1px solid var(--dk-edge); }
.bt-choice:hover { background: var(--dk-hover); }
.bt-choice[data-checked="true"] { background: var(--dk-accent-wash); }
.bt-choice b { display: block; font-size: var(--dk-text-15); }
.bt-choice__text { display: block; color: var(--dk-muted); font-size: var(--dk-text-13); line-height: 1.45; }
.bt-choice input { margin-top: 3px; flex: none; }
.bt-upload { position: relative; display: flex; align-items: center; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-upload__input { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; }
.bt-upload__input:focus-visible + label { outline: 2px solid var(--dk-accent-text); outline-offset: 2px; }
.bt-more { border-top: 1px solid var(--dk-edge); padding-top: var(--dk-space-2); }
.bt-more__toggle { display: flex; align-items: center; gap: var(--dk-space-2); width: 100%; min-height: 40px; padding: 0; background: none; border: 0; color: var(--dk-text); font: inherit; font-size: var(--dk-text-15); text-align: left; cursor: pointer; }
.bt-more__toggle { flex-wrap: wrap; }
.bt-more__toggle .dk-hint { margin: 0; min-width: 0; }
.bt-more__body { display: flex; flex-direction: column; gap: var(--dk-space-4); padding: var(--dk-space-3) 0 var(--dk-space-2); min-width: 0; }
.bt-switchrow { display: flex; align-items: center; gap: var(--dk-space-3); }
.bt-eval { display: flex; flex-direction: column; gap: var(--dk-space-3); }
.bt-kv { display: flex; flex-direction: column; gap: var(--dk-space-2); align-items: flex-start; }
.bt-kv__row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; gap: var(--dk-space-2); width: 100%; align-items: center; }
.bt-banner { display: flex; align-items: center; gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-2); background: var(--dk-accent-wash); color: var(--dk-text); font-size: var(--dk-text-13); flex-wrap: wrap; }
.bt-banner code { overflow-wrap: anywhere; }
.bt-rewards, .bt-liquid { display: flex; flex-direction: column; gap: var(--dk-space-3); padding-top: var(--dk-space-3); border-top: 1px solid var(--dk-edge); }
.bt-rewards .dk-hint, .bt-liquid .dk-hint { margin: 0; }
.bt-reward { display: flex; flex-direction: column; gap: var(--dk-space-2); padding: var(--dk-space-3); border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-2); background: var(--dk-card); min-width: 0; }
.bt-reward[data-on="true"] { background: var(--dk-accent-wash); border-color: transparent; }
.bt-reward__name { display: block; font-size: var(--dk-text-13); font-weight: var(--dk-weight-semibold); }
.bt-reward__head { display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-2); }
.bt-reward-draft { display: flex; flex-direction: column; gap: var(--dk-space-3); }
.bt-code { margin: 0; padding: var(--dk-space-3); border-radius: var(--dk-radius-1); background: var(--dk-inset); font: var(--dk-text-12) / 1.5 var(--dk-font-mono); overflow-x: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.bt-recipe { margin: 0; padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-2); background: var(--dk-inset); font-size: var(--dk-text-15); line-height: 1.5; overflow-wrap: anywhere; }
/* ---- Check list --------------------------------------------------------------- */
.bt-checks-card { padding: var(--dk-space-2) var(--dk-space-5); }
.bt-checks { list-style: none; margin: 0; padding: 0; }
.bt-check { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: var(--dk-space-3); padding: var(--dk-space-3) 0; }
.bt-check + .bt-check { border-top: 1px solid var(--dk-edge); }
.bt-check__mark { font-size: 20px; line-height: 1; color: var(--dk-muted); padding-top: 1px; }
.bt-check[data-tone="ok"] .bt-check__mark { color: var(--dk-ok-text); }
.bt-check[data-tone="warn"] .bt-check__mark { color: var(--dk-warn-text); }
.bt-check[data-tone="fail"] .bt-check__mark { color: var(--dk-error-text); }
.bt-check__head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--dk-space-3); }
.bt-check__title { font-weight: var(--dk-weight-semibold); font-size: var(--dk-text-15); }
.bt-check__value { font-size: var(--dk-text-13); color: var(--dk-text); text-align: right; overflow-wrap: anywhere; }
.bt-check__text { margin: 2px 0 0; font-size: var(--dk-text-13); line-height: 1.5; color: var(--dk-muted); overflow-wrap: anywhere; }
.bt-check[data-tone="fail"] .bt-check__text { color: var(--dk-error-text); }
.bt-check__meter { margin-top: var(--dk-space-2); }
/* ---- Action bar --------------------------------------------------------------- */
.bt-bar { position: sticky; bottom: 0; z-index: var(--dk-z-sticky); display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-4); flex-wrap: wrap; padding: var(--dk-space-3) 0; background: var(--dk-canvas); border-top: 1px solid var(--dk-edge); }
.bt-bar__text { margin: 0; flex: 1 1 280px; min-width: 0; font-size: var(--dk-text-13); line-height: 1.5; color: var(--dk-muted); overflow-wrap: anywhere; }
.bt-bar__acts { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; margin-left: auto; }
/* ---- Alerts and statuses ------------------------------------------------------- */
.bt-alert { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: var(--dk-space-3); padding: var(--dk-space-4); border-radius: var(--dk-radius-3); background: var(--dk-error-wash); color: var(--dk-error-text); }
.bt-alert .lai-icon { font-size: 20px; margin-top: 1px; }
.bt-alert__title { margin: 0; font-weight: var(--dk-weight-bold); font-size: var(--dk-text-15); }
.bt-alert__text { margin: 2px 0 0; font-size: var(--dk-text-13); line-height: 1.5; overflow-wrap: anywhere; white-space: pre-wrap; }
.bt-status { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); font-size: var(--dk-text-13); overflow-wrap: anywhere; }
.bt-status--error { color: var(--dk-error-text); }
.bt-path { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; font-size: var(--dk-text-12); }
.bt-pathrow { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); min-width: 0; }
/* ---- Job card ----------------------------------------------------------------- */
.bt-job { display: flex; flex-direction: column; gap: var(--dk-space-3); padding: var(--dk-space-4) var(--dk-space-5); min-width: 0; }
.bt-job__top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-job__who { min-width: 0; }
.bt-job__title { margin: 0; font: var(--dk-weight-bold) var(--dk-text-17) / 1.3 var(--dk-font-display); overflow-wrap: anywhere; }
.bt-job__meta { margin: 2px 0 0; font-size: var(--dk-text-13); color: var(--dk-muted); overflow-wrap: anywhere; }
.bt-job__acts { display: flex; align-items: center; gap: var(--dk-space-2); }
.bt-job__figures { display: flex; align-items: baseline; gap: var(--dk-space-5); flex-wrap: wrap; font-size: var(--dk-text-13); color: var(--dk-muted); }
.bt-job__figures b { color: var(--dk-text); }
.bt-job__percent { font: var(--dk-weight-bold) var(--dk-text-44) / 1 var(--dk-font-display); letter-spacing: -.02em; color: var(--dk-text); font-variant-numeric: tabular-nums; }
.bt-job__percent small { font-size: var(--dk-text-21); margin-left: 2px; color: var(--dk-muted); }
.bt-job__wait { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); color: var(--dk-muted); font-size: var(--dk-text-13); }
.bt-job__message { margin: 0; overflow-wrap: anywhere; }
.bt-job__done { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); color: var(--dk-ok-text); font-size: var(--dk-text-15); font-weight: var(--dk-weight-semibold); }
.bt-stages { list-style: none; margin: 0; padding: 0; display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: var(--dk-space-2); }
.bt-stage { display: flex; flex-direction: column; gap: 6px; min-width: 0; font-size: var(--dk-text-12); color: var(--dk-muted); }
.bt-stage__bar { height: 3px; border-radius: 3px; background: var(--dk-edge); }
.bt-stage[data-state="done"] .bt-stage__bar { background: var(--dk-muted); }
.bt-stage[data-state="current"] { color: var(--dk-text); font-weight: var(--dk-weight-bold); }
.bt-stage[data-state="current"] .bt-stage__bar { background: var(--dk-accent); }
.bt-stage[data-state="failed"] { color: var(--dk-error-text); font-weight: var(--dk-weight-bold); }
.bt-stage[data-state="failed"] .bt-stage__bar { background: var(--dk-error); }
.bt-stage__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bt-try { display: flex; flex-direction: column; gap: var(--dk-space-3); padding: var(--dk-space-4) var(--dk-space-5); }
.bt-try .dk-hint { margin: 0; }
.bt-try__acts { display: flex; gap: var(--dk-space-2); flex-wrap: wrap; }
.bt-next { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: var(--dk-space-3); padding: var(--dk-space-4) var(--dk-space-5); }
.bt-next .dk-hint { margin: 2px 0 var(--dk-space-3); }
.bt-next__mark { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; background: var(--dk-ok-wash); color: var(--dk-ok-text); font-size: 20px; }
.bt-next__acts { display: flex; gap: var(--dk-space-2); flex-wrap: wrap; }
.bt-out { display: flex; flex-direction: column; gap: var(--dk-space-3); }
.bt-out__name { max-width: 420px; }
/* ---- Chart -------------------------------------------------------------------- */
.bt-chart { padding: var(--dk-space-4) var(--dk-space-5); display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.bt-chart__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-chart__head .dk-hint { margin: 2px 0 0; }
.bt-chart__svg { width: 100%; height: auto; display: block; overflow: visible; }
.bt-chart__grid { stroke: var(--dk-edge); stroke-width: 1; }
.bt-chart__tick { fill: var(--dk-muted); font: 11px var(--dk-font-mono); }
.bt-chart__axis { fill: var(--dk-muted); font: 11px var(--dk-font-body); }
.bt-chart__line { fill: none; stroke: var(--dk-series-1); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.bt-chart__eval { fill: var(--dk-card); stroke: var(--dk-series-2); stroke-width: 2; }
.bt-chart__end { fill: var(--dk-series-1); }
.bt-chart__label { fill: var(--dk-text); font: 700 12px var(--dk-font-mono); }
.bt-chart__cursor { stroke: var(--dk-muted); stroke-dasharray: 3 3; stroke-width: 1; }
.bt-chart__readout { margin: 0; font-size: var(--dk-text-12); color: var(--dk-muted); }
.bt-chart__empty { margin: 0; color: var(--dk-muted); font-size: var(--dk-text-13); }
.bt-chart__data summary { cursor: pointer; font-size: var(--dk-text-13); color: var(--dk-muted); min-height: 32px; display: flex; align-items: center; }
.bt-chart__table { --dk-table-max: 240px; margin-top: var(--dk-space-2); }
/* ---- Log ---------------------------------------------------------------------- */
.bt-log { padding: var(--dk-space-4) var(--dk-space-5); display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.bt-log__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-log__head .dk-hint { margin: 2px 0 0; }
.bt-log__tools { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; }
.bt-log__body { max-height: 280px; overflow: auto; padding: var(--dk-space-3); border-radius: var(--dk-radius-2); background: var(--dk-inset); font: var(--dk-text-12) / 1.6 var(--dk-font-mono); }
.bt-log__line { display: grid; grid-template-columns: 64px 44px minmax(0, 1fr); gap: var(--dk-space-3); }
.bt-log__time, .bt-log__tag { color: var(--dk-muted); }
.bt-log__text { overflow-wrap: anywhere; white-space: pre-wrap; }
.bt-log__line[data-level="warn"] .bt-log__text, .bt-log__line[data-level="warn"] .bt-log__tag { color: var(--dk-warn-text); }
.bt-log__line[data-level="err"] .bt-log__text, .bt-log__line[data-level="err"] .bt-log__tag { color: var(--dk-error-text); }
.bt-log__empty { margin: 0; color: var(--dk-muted); }
/* ---- Jobs table --------------------------------------------------------------- */
.bt-jobs { display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.bt-empty-card { padding: var(--dk-space-5); border-radius: var(--dk-radius-3); border: 1px solid var(--dk-edge); background: var(--dk-card); }
.bt-linkcell { display: block; padding: 0; background: none; border: 0; font: inherit; color: inherit; text-align: left; cursor: pointer; max-width: 100%; }
.bt-linkcell:hover .dk-table-name { text-decoration: underline; }
.bt-jobs__msg { color: var(--dk-error-text); overflow-wrap: anywhere; }
.bt-rowacts { display: flex; align-items: center; justify-content: flex-end; gap: var(--dk-space-1); }
/* ---- Landing ------------------------------------------------------------------ */
.bt-machine { display: flex; flex-direction: column; gap: var(--dk-space-3); background: var(--dk-inset); }
.bt-machine__facts { margin: 0; display: flex; flex-wrap: wrap; gap: var(--dk-space-2) var(--dk-space-5); }
.bt-machine__facts > div { min-width: 0; }
.bt-machine__facts dt { display: flex; align-items: center; gap: 6px; font-size: var(--dk-text-12); color: var(--dk-muted); }
.bt-machine__facts dd { margin: 2px 0 0; font-size: var(--dk-text-13); }
.bt-machine__backends { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; font-size: var(--dk-text-13); color: var(--dk-muted); }
.bt-now { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-3); background: var(--dk-accent-wash); }
.bt-now[data-kind="failed"] { background: var(--dk-error-wash); color: var(--dk-error-text); }
.bt-now__mark { font-size: 20px; }
.bt-now__title { margin: 0; font-weight: var(--dk-weight-bold); font-size: var(--dk-text-15); overflow-wrap: anywhere; }
.bt-now__text { margin: 2px 0 0; font-size: var(--dk-text-13); overflow-wrap: anywhere; }
.bt-groups { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--dk-space-6); }
.bt-group { display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.bt-group__head { display: flex; align-items: baseline; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-group__head .dk-hint { margin: 0; }
.bt-flow { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; }
.bt-flow__list { list-style: none; margin: 0; padding: 0; display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; }
.bt-flow__item { display: inline-flex; align-items: center; gap: var(--dk-space-2); color: var(--dk-muted); }
.bt-flow .dk-chip { cursor: default; }
.bt-flow .dk-hint { margin: 0; }
.bt-tools { list-style: none; margin: 0; padding: 0; border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-3); background: var(--dk-card); overflow: hidden; }
.bt-tool { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; gap: var(--dk-space-4); align-items: start; padding: var(--dk-space-4) var(--dk-space-5); }
.bt-tool + .bt-tool { border-top: 1px solid var(--dk-edge); }
.bt-tool__icon { display: grid; place-items: center; width: 40px; height: 40px; border-radius: var(--dk-radius-2); background: var(--dk-inset); color: var(--dk-muted); font-size: 18px; }
.bt-tool__title { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; font: var(--dk-weight-bold) var(--dk-text-15) / 1.3 var(--dk-font-display); }
.bt-tool__text { margin: 4px 0 0; max-width: 62ch; font-size: var(--dk-text-13); line-height: 1.5; color: var(--dk-muted); }
.bt-tool__why { margin: var(--dk-space-2) 0 0; font-size: var(--dk-text-13); color: var(--dk-warn-text); }
.bt-tool__end { display: flex; flex-direction: column; align-items: flex-end; gap: var(--dk-space-2); }
.bt-needs { list-style: none; margin: var(--dk-space-2) 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: var(--dk-space-1) var(--dk-space-4); }
.bt-needs li { display: inline-flex; align-items: center; gap: 6px; font-size: var(--dk-text-12); color: var(--dk-muted); }
.bt-needs li[data-tone="ok"] .lai-icon { color: var(--dk-ok-text); }
.bt-needs li[data-tone="warn"] { color: var(--dk-warn-text); }
/* ---- Disabled (account) --------------------------------------------------------- */
.bt-off { padding-top: var(--dk-space-5); }
.bt-off__card { display: flex; flex-direction: column; align-items: flex-start; gap: var(--dk-space-3); padding: var(--dk-space-5); background: var(--dk-inset); }
.bt-off__text { margin: 0; max-width: 56ch; font-size: var(--dk-text-15); line-height: 1.5; }
.bt-off__card .dk-hint { margin: 0; }
.bt-off__acts { display: flex; gap: var(--dk-space-2); flex-wrap: wrap; }
/* ---- Phone ---------------------------------------------------------------------- */
@media (max-width: 640px) {
.bt-section__body { padding-left: 0; }
.bt-pair, .bt-pair--wide, .bt-triple { grid-template-columns: minmax(0, 1fr); }
.bt-steps { gap: 6px; flex-wrap: nowrap; }
.bt-step { gap: 4px; }
.bt-step__label { font-size: var(--dk-text-12); }
.bt-step__mark { width: 22px; height: 22px; }
.bt-step:not(:last-child)::after { width: 8px; margin-left: 0; }
.bt-tool { grid-template-columns: 40px minmax(0, 1fr); }
.bt-tool__end { grid-column: 1 / -1; flex-direction: row; justify-content: flex-start; flex-wrap: wrap; }
.bt-now { grid-template-columns: 24px minmax(0, 1fr); }
.bt-now .dk-btn { grid-column: 1 / -1; justify-self: start; }
.bt-kv__row { grid-template-columns: minmax(0, 1fr) auto; }
.bt-kv__row .dk-input:nth-child(2) { grid-column: 1 / -1; grid-row: 2; }
.bt-log__line { grid-template-columns: 56px minmax(0, 1fr); }
.bt-log__tag { display: none; }
.bt-more__toggle .dk-hint { flex-basis: 100%; padding-left: 24px; }
.bt-rowacts { flex-direction: column; align-items: flex-end; gap: 0; }
.bt-jobs .dk-table { table-layout: fixed; }
.bt-jobs .dk-table th:nth-child(3) { width: 112px; }
.bt-jobs .dk-table th:last-child { width: 84px; }
.bt-jobs .dk-table td:first-child { overflow-wrap: anywhere; min-width: 0; }
.bt-jobs .dk-table .dk-table-name, .bt-jobs .dk-table .dk-table-sub { white-space: normal; overflow: visible; text-overflow: clip; overflow-wrap: anywhere; }
.bt-bar__acts { width: 100%; }
.bt-bar__acts .dk-btn--primary { flex: 1 1 auto; }
.bt-job__percent { font-size: var(--dk-text-32); }
.bt-stage__label { font-size: 11px; }
.bt-next { grid-template-columns: minmax(0, 1fr); }
.bt-block.dk-card, .bt-job, .bt-chart, .bt-log, .bt-try, .bt-next { padding-inline: var(--dk-space-4); }
.bt-checks-card { padding-inline: var(--dk-space-4); }
}
@media (prefers-reduced-motion: reduce) {
.bt-page * { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }
}
/* Added by the add-on: the buttons of the stop and delete layers. The host draws the layer; the foot is ours. */
.bt-dialogacts { display: flex; gap: var(--dk-space-2, 8px); justify-content: flex-end; flex-wrap: wrap; }
+49
View File
@@ -0,0 +1,49 @@
import { createRequire } from 'node:module'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { dirname, join, resolve } from 'node:path'
import { build } from '../build.mjs'
export const here = dirname(fileURLToPath(import.meta.url))
export const hostUi = resolve(here, '../../../../core/http/react-ui')
const require = createRequire(join(hostUi, 'package.json'))
export const React = require('react')
export const { renderToString } = require('react-dom/server')
const COMPONENTS = ['Button', 'Spinner', 'EmptyState', 'Badge', 'Stack', 'Text', 'PageHeader', 'ConfirmDialog', 'ErrorWithTraceLink', 'ModelSelector', 'MediaInput', 'WaveformPlayer', 'DistributionBars', 'EmbeddingInspector', 'TabSwitch']
// Builds the bundle and draws it through the real registration path: the host
// SDK, the module's createAddon and its Component. Options: viewer, fetch,
// components, layers (the openLayer binding), language.
export async function load(extra = {}) {
const dir = await build()
const mod = await import(pathToFileURL(join(dir, 'ui/fine-tuning.js')).href + `?t=${Date.now()}`)
const { createHostSDK } = await import(pathToFileURL(join(hostUi, 'src/addons/sdk.js')).href)
const stub = (tag) => (p) => React.createElement(tag, { 'data-stub': tag }, p.title || p.label || p.children || null)
const calls = []
const layers = []
const controller = new AbortController()
const { viewer, components, layerSpec, fetch, ...rest } = extra
const stubs = Object.fromEntries(COMPONENTS.map(n => [n, stub('div')]))
stubs.PageHeader = (p) => React.createElement('header', { 'data-stub': 'header' }, p.title, p.supporting, p.actions)
const sdk = createHostSDK({
addonId: 'fine-tuning', generation: 1, signal: controller.signal, pages: ['jobs'],
host: {
React,
components: { ...stubs, ...components },
hooks: {},
viewer: viewer || { isAdmin: () => true, hasFeature: () => true },
basePath: '/base',
fetch: fetch || (async (url, init) => { calls.push([url, init]); return { ok: true, json: async () => [], blob: async () => new Blob([]) } }),
Icon: ({ name }) => React.createElement('i', { 'data-icon': name }), theme: { mode: () => 'light', subscribe: () => () => {} }, language: () => 'en', subscribeLanguage: () => () => {}, api: {},
...rest,
},
bindings: {
current: () => ({ page: 'jobs', path: '', search: '' }),
openLayer: (kind, spec) => { layers.push({ kind, spec }); return { close() {} } },
notify() {},
},
})
return { mod, sdk, controller, calls, layers }
}
export const draw = (addon, props) => renderToString(React.createElement(addon.Component, props))
+17
View File
@@ -0,0 +1,17 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { modelFrom } from '../src/lib/model.js'
test('the model comes from ?model first and keeps slashes, colons and percent signs', () => {
assert.equal(modelFrom('model=org%2Fname%3Av1', 'ignored'), 'org/name:v1')
})
test('without ?model the model is the first path segment', () => {
assert.equal(modelFrom('', 'speechbrain/extra'), 'speechbrain')
assert.equal(modelFrom('x=1', 'speechbrain'), 'speechbrain')
})
test('no model gives an empty string', () => {
assert.equal(modelFrom('', ''), '')
assert.equal(modelFrom(undefined, undefined), '')
})
+248
View File
@@ -0,0 +1,248 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { load, draw, here, hostUi, React, renderToString } from './load.mjs'
import { createApi } from '../src/api.js'
import en from '../src/i18n/en.js'
const read = (p) => readFileSync(p, 'utf8')
const src = join(here, '../src')
const toolsUi = join(here, '../../../internal/tools-ui/src')
const files = (dir) => readdirSync(dir, { recursive: true }).filter(f => /\.(jsx?|mjs)$/.test(f)).map(f => join(dir, f))
const ownText = files(src).map(read).join('\n')
const sharedText = files(toolsUi).map(read).join('\n')
const flatten = (obj, prefix = '') => Object.entries(obj).flatMap(([k, v]) =>
typeof v === 'string' ? [[`${prefix}${k}`, v]] : v && typeof v === 'object' && !Array.isArray(v) ? flatten(v, `${prefix}${k}.`) : [])
const job = (extra = {}) => ({ id: 'job-1234567890', model: 'org/base', backend: 'trl', training_type: 'lora', training_method: 'sft', created_at: '2026-10-18T10:00:00Z', ...extra })
test('page: the first paint has the core test ids of the form and the job list', async () => {
const { mod, sdk } = await load()
const html = draw(await mod.createAddon(sdk), { page: 'jobs', path: '' })
for (const id of ['fine-tune-page', 'fine-tune-form', 'tool-steps', 'tool-checks', 'ft-bar', 'ft-start', 'ft-recipe', 'ft-jobs', 'ft-more-toggle']) {
if (id === 'ft-more-toggle') continue // inside the closed disclosure
assert.match(html, new RegExp(`data-testid="${id}"`), id)
}
})
test('page: a viewer without fine_tuning gets the off notice, no form and no core call', async () => {
const { mod, sdk, calls } = await load({ viewer: { isAdmin: () => false, hasFeature: (f) => f !== 'fine_tuning' } })
const html = draw(await mod.createAddon(sdk), { page: 'jobs', path: '' })
assert.match(html, /data-testid="tool-off"/)
assert.match(html, /Your account cannot fine-tune/)
assert.doesNotMatch(html, /fine-tune-form/)
assert.deepEqual(calls, [])
})
test('page: the API helper uses the core fine-tuning paths and bodies', async () => {
const seen = []
const reply = { ok: true, json: async () => ({}) }
const ctx = {
core: { fetch: async (p, i) => { seen.push([p, i]); return reply } },
json: async (p, i) => { seen.push([p, i]); return {} },
}
const api = createApi(ctx)
await api.fineTune.listBackends()
await api.fineTune.listJobs()
await api.fineTune.getJob('a b')
await api.fineTune.startJob({ model: 'm' })
await api.fineTune.stopJob('j1', true)
await api.fineTune.stopJob('j1', false)
await api.fineTune.deleteJob('j1')
await api.fineTune.listCheckpoints('j1')
await api.fineTune.exportModel('j1', { name: 'n' })
await api.resources.get()
const paths = seen.map(s => `${s[1]?.method || 'GET'} ${s[0]}`)
assert.deepEqual(paths, [
'GET /api/fine-tuning/backends', 'GET /api/fine-tuning/jobs', 'GET /api/fine-tuning/jobs/a%20b',
'POST /api/fine-tuning/jobs', 'POST /api/fine-tuning/jobs/j1/stop?save_checkpoint=true',
'POST /api/fine-tuning/jobs/j1/stop?save_checkpoint=false', 'DELETE /api/fine-tuning/jobs/j1',
'GET /api/fine-tuning/jobs/j1/checkpoints', 'POST /api/fine-tuning/jobs/j1/export', 'GET /api/resources',
])
assert.equal(seen[3][1].body, JSON.stringify({ model: 'm' }))
assert.equal(seen[3][1].headers['Content-Type'], 'application/json')
})
test('page: the dataset upload is a FormData body with no Content-Type header', async () => {
const seen = []
const ctx = { json: async (p, i) => { seen.push([p, i]); return { path: '/x/y.jsonl' } } }
const out = await createApi(ctx).fineTune.uploadDataset(new Blob(['{}'], { type: 'application/json' }))
assert.equal(out.path, '/x/y.jsonl')
const [path, init] = seen[0]
assert.equal(path, '/api/fine-tuning/datasets')
assert.equal(init.method, 'POST')
assert.ok(init.body instanceof FormData)
assert.equal(init.headers, undefined)
})
test('page: a failed upload or call throws the server message', async () => {
const { mod, sdk } = await load({ fetch: async () => ({ ok: false, status: 413, json: async () => ({ error: { message: 'too big' } }) }) })
const { api } = await mod.__partsFor(sdk)
await assert.rejects(api.fineTune.uploadDataset(new Blob(['x'])), /too big/)
await assert.rejects(api.fineTune.listJobs(), /too big/)
})
test('page: the progress stream is read through core.fetch with the abort signal', async () => {
const seen = []
const ctx = { core: { fetch: async (p, i) => { seen.push([p, i]); return { ok: true } } } }
const controller = new AbortController()
await createApi(ctx).fineTune.progress('j 1', controller.signal)
assert.equal(seen[0][0], '/api/fine-tuning/jobs/j%201/progress')
assert.equal(seen[0][1].signal, controller.signal)
})
test('page: the download link carries the base path', async () => {
const { mod, sdk } = await load()
// The base path is read from the <base href> element, as the host reads it.
globalThis.document = { querySelector: () => ({ getAttribute: () => 'http://localhost/base/' }) }
let ExportPanel
try { ExportPanel = (await mod.__partsFor(sdk)).parts.ExportPanel } finally { delete globalThis.document }
const html = renderToString(React.createElement(ExportPanel, { job: job({ status: 'completed', export_status: 'completed', export_model_name: 'my-model' }) }))
assert.match(html, /href="\/base\/api\/fine-tuning\/jobs\/job-1234567890\/download"/)
assert.match(html, /data-testid="export-next"/)
assert.match(html, /href="\/base\/app\/chat\/my-model"/)
})
test('progress: a running job whose percent reads 0 shows an unknown progress, not 0%', async () => {
const { mod, sdk } = await load()
const { JobView } = (await mod.__partsFor(sdk)).parts
const html = renderToString(React.createElement(JobView, {
job: job({ status: 'training' }),
seed: { latest: { status: 'training', progress_percent: 0, current_step: 0, total_steps: 100 } },
onStop() {}, onReuse() {}, onResume() {}, onTerminal() {}, exportCheckpoint() {},
}))
assert.match(html, /data-testid="job-view"/)
assert.match(html, /data-indeterminate/)
assert.doesNotMatch(html, /bt-job__percent">0/)
assert.match(html, /Progress not reported yet/)
})
test('progress: a running job with a percent shows it', async () => {
const { mod, sdk } = await load()
const { JobView } = (await mod.__partsFor(sdk)).parts
const html = renderToString(React.createElement(JobView, {
job: job({ status: 'training' }),
seed: { latest: { status: 'training', progress_percent: 40, current_step: 40, total_steps: 100 } },
onStop() {}, onReuse() {}, onResume() {}, onTerminal() {}, exportCheckpoint() {},
}))
assert.match(html, /bt-job__percent">40<small>/)
assert.match(html, /aria-valuenow="40"/)
assert.doesNotMatch(html, /data-indeterminate/)
assert.match(html, /data-testid="job-stop"/)
})
test('progress: a failed job shows its message and the retry control', async () => {
const { mod, sdk } = await load()
const { JobView } = (await mod.__partsFor(sdk)).parts
const html = renderToString(React.createElement(JobView, {
job: job({ status: 'failed', message: 'CUDA out of memory' }),
seed: { latest: null }, onStop() {}, onReuse() {}, onResume() {}, onTerminal() {}, exportCheckpoint() {},
}))
assert.match(html, /data-testid="job-failed-message"[^>]*>CUDA out of memory/)
assert.match(html, /data-testid="job-retry"/)
assert.match(html, /data-testid="job-try"/)
})
test('export: the export panel and the checkpoint export buttons are for an administrator', async () => {
const done = job({ status: 'completed' })
const seed = { checkpoints: [{ path: '/srv/c1', step: 10, loss: 0.5, epoch: 1 }] }
const admin = await load({ viewer: { isAdmin: () => true, hasFeature: () => true } })
const a = (await admin.mod.__partsFor(admin.sdk)).parts
const adminPanel = renderToString(React.createElement(a.ExportPanel, { job: done, seed }))
assert.match(adminPanel, /id="ft-export-format"/)
assert.doesNotMatch(adminPanel, /data-testid="export-admin-only"/)
const adminJob = renderToString(React.createElement(a.JobView, { job: done, seed, onStop() {}, onReuse() {}, onResume() {}, onTerminal() {}, exportCheckpoint() {} }))
assert.match(adminJob, /data-testid="job-checkpoints"/)
assert.match(adminJob, /data-icon="save"/)
const member = await load({ viewer: { isAdmin: () => false, hasFeature: () => true } })
const m = (await member.mod.__partsFor(member.sdk)).parts
const memberPanel = renderToString(React.createElement(m.ExportPanel, { job: done, seed }))
assert.match(memberPanel, /data-testid="export-admin-only"/)
assert.match(memberPanel, /Exporting a model is for administrators/)
assert.doesNotMatch(memberPanel, /ft-export-format/)
const memberJob = renderToString(React.createElement(m.JobView, { job: done, seed, onStop() {}, onReuse() {}, onResume() {}, onTerminal() {}, exportCheckpoint() {} }))
assert.match(memberJob, /data-testid="job-checkpoints"/)
assert.doesNotMatch(memberJob, /data-icon="save"/)
})
test('dialogs: the stop and delete dialogs are host layers with the core test ids', async () => {
const { mod, sdk } = await load()
const { StopDialogBody, DeleteDialogBody } = (await mod.__partsFor(sdk)).parts
const stop = renderToString(React.createElement(StopDialogBody, { onChoose() {}, close() {} }))
for (const id of ['stop-dialog', 'stop-discard', 'stop-keep']) assert.match(stop, new RegExp(`data-testid="${id}"`), id)
const del = renderToString(React.createElement(DeleteDialogBody, { onConfirm() {}, close() {} }))
for (const id of ['delete-dialog', 'delete-confirm']) assert.match(del, new RegExp(`data-testid="${id}"`), id)
})
test('dialogs: choosing in the stop dialog stops with or without a checkpoint and closes the layer', async () => {
const { mod, sdk } = await load()
const { StopDialogBody } = (await mod.__partsFor(sdk)).parts
const out = []
const tree = StopDialogBody({ onChoose: (keep) => out.push(['choose', keep]), close: () => out.push(['close']) })
const buttons = []
const walk = (n) => { if (!n || typeof n !== 'object') return; if (n.props?.['data-testid']?.startsWith('stop-') && n.props.onClick) buttons.push(n); [].concat(n.props?.children || []).forEach(walk) }
walk(tree)
buttons.find(b => b.props['data-testid'] === 'stop-keep').props.onClick()
buttons.find(b => b.props['data-testid'] === 'stop-discard').props.onClick()
assert.deepEqual(out, [['close'], ['choose', true], ['close'], ['choose', false]])
})
test('chart: the loss chart draws a line from two steps and a waiting note before', async () => {
const { mod, sdk } = await load()
const { LossChart } = (await mod.__partsFor(sdk)).parts
const some = renderToString(React.createElement(LossChart, { events: [{ current_step: 1, loss: 2 }, { current_step: 2, loss: 1.5 }], totalSteps: 10 }))
assert.match(some, /data-testid="job-chart"/)
assert.match(some, /<polyline class="bt-chart__line"/)
const none = renderToString(React.createElement(LossChart, { events: [], totalSteps: 10 }))
assert.match(none, /The chart draws once the job has reported two steps/)
})
test('plural: the job count picks the singular and the plural key', async () => {
const { mod, sdk } = await load()
const { ctx } = await mod.__partsFor(sdk)
assert.equal(ctx.plural('jobs.count', 1), '1 kept on this server')
assert.equal(ctx.plural('jobs.count', 3), '3 kept on this server')
})
test('parity: every data-testid of the core page and chart is in the add-on or the shared views', () => {
const core = ['pages/FineTune.jsx', 'components/tools/LossChart.jsx'].map(f => read(join(hostUi, 'src', f))).join('\n')
const ids = new Set([...core.matchAll(/(?:data-testid|testId)="([^"]+)"/g)].map(m => m[1]))
assert.ok(ids.size >= 20, `found only ${ids.size} test ids in the core sources`)
const mine = ownText + sharedText
for (const id of ids) assert.ok(mine.includes(`"${id}"`) || mine.includes(`'${id}'`), `missing data-testid ${id}`)
})
test('i18n: every literal key the add-on and the shared views read is in en.js', () => {
const keys = new Set()
for (const text of [ownText, sharedText]) for (const m of text.matchAll(/\bt\(\s*'([A-Za-z0-9_.]+)'/g)) keys.add(m[1])
assert.ok(keys.size > 100, `found only ${keys.size} literal keys`)
const missing = [...keys].filter(k => typeof en[k] !== 'string')
assert.deepEqual(missing, [])
})
test('i18n: en.js repeats the host strings and adds only the keys the add-on owns', () => {
const tools = Object.fromEntries(flatten(JSON.parse(read(join(hostUi, 'public/locales/en/tools.json')))))
const own = []
for (const [k, v] of Object.entries(en)) {
if (k in tools) assert.equal(v, tools[k], k)
else own.push(k)
}
assert.deepEqual(own.sort(), ['export.adminOnly.text', 'export.adminOnly.title', 'run.unknownPercent'])
assert.equal(Object.keys(en).some(k => k.startsWith('quantize.')), false)
})
test('style: the stylesheet starts with the shared tools rules unchanged', () => {
assert.ok(read(join(src, 'style.css')).startsWith(read(join(toolsUi, 'tools.css'))))
})
test('source: no file imports a core UI file or the router', () => {
for (const f of files(src)) {
const text = read(f)
assert.doesNotMatch(text, /from\s+['"][^'"]*core\/http\/react-ui/, f)
assert.doesNotMatch(text, /from\s+['"](react|react-dom|react-router-dom|react-i18next)['"]/, f)
assert.doesNotMatch(text, /\bEventSource\b/, f)
}
})
@@ -0,0 +1,32 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { percentOf, ACTIVE } from '../src/lib/progress.js'
test('a final status reads as a known percent: completed is 100', () => {
assert.equal(percentOf({ status: 'completed', progress_percent: 0 }), 100)
})
test('a running job with a percent of 0 is unknown, not 0', () => {
for (const status of ACTIVE) assert.equal(percentOf({ status, progress_percent: 0 }), null, status)
assert.equal(percentOf({ status: 'training' }), null)
assert.equal(percentOf({ status: 'training', progress_percent: 0, current_step: 0, total_steps: 100 }), null)
})
test('a running job with a percent above 0 reports it, capped at 100', () => {
assert.equal(percentOf({ status: 'training', progress_percent: 42.4 }), 42.4)
assert.equal(percentOf({ status: 'training', progress_percent: 140 }), 100)
})
test('a running job with steps but no percent is computed from the steps', () => {
assert.equal(percentOf({ status: 'training', progress_percent: 0, current_step: 25, total_steps: 100 }), 25)
})
test('no event is unknown', () => {
assert.equal(percentOf(null), null)
assert.equal(percentOf(undefined), null)
})
test('a failed or stopped job keeps the percent it reported, and 0 there is a real 0', () => {
assert.equal(percentOf({ status: 'failed', progress_percent: 0 }), 0)
assert.equal(percentOf({ status: 'stopped', progress_percent: 30 }), 30)
})
@@ -0,0 +1,50 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { load, draw, here, React } from './load.mjs'
test('render: createAddon returns a page component and dispose', async () => {
const { mod, sdk, controller } = await load()
const addon = await mod.createAddon(sdk)
assert.equal(typeof addon.Component, 'function')
assert.equal(typeof addon.dispose, 'function')
addon.dispose()
controller.abort('done')
})
test('render: an unknown page draws nothing', async () => {
const { mod, sdk } = await load()
const addon = await mod.createAddon(sdk)
assert.equal(draw(addon, { page: 'nope', path: '' }), '')
})
test('render: the manifest declares one page, the main page, SDK 2 and no navigation or hub entry', () => {
const m = JSON.parse(readFileSync(join(here, '../../manifest.json'), 'utf8'))
assert.equal(m.id, 'fine-tuning')
assert.deepEqual(m.capabilities.ui.pages, ['jobs'])
assert.equal(m.capabilities.ui.mainPage, 'jobs')
assert.deepEqual(m.capabilities.ui.sdk, { min: 2, max: 2 })
assert.equal(m.capabilities.ui.title, 'Fine-tuning')
assert.equal(m.capabilities.ui.hub, undefined)
})
test('render: a registered page is drawn with its path', async () => {
const { mod, sdk } = await load()
const addon = await mod.createAddon(sdk, { pages: { jobs: ({ path }) => React.createElement('p', null, `page:${path}`) } })
assert.equal(draw(addon, { page: 'jobs', path: 'abc' }), '<p>page:abc</p>')
})
test('render: the jobs page is drawn by the real registration path, which binds the JSX helper', async () => {
// A helper that lost its binding throws here: the page draws JSX in every
// component the factories build.
const { mod, sdk } = await load()
const addon = await mod.createAddon(sdk)
const html = draw(addon, { page: 'jobs', path: '' })
assert.match(html, /data-testid="fine-tune-page"/)
assert.match(html, /data-testid="fine-tune-form"/)
assert.match(html, /data-testid="tool-steps"/)
assert.match(html, /data-testid="ft-bar"/)
assert.match(html, /data-testid="ft-jobs"/)
assert.match(html, /Fine-tune/)
})
+23
View File
@@ -0,0 +1,23 @@
// Runs the node unit tests of the fine-tuning UI package and prints one JSON line
// per test, the same shape as the host's runner (core/http/react-ui/tests/unit):
// {"case":"unit: <name>","status":"passed"|"failed"|"skipped"}
import { run } from 'node:test'
import { readdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const names = readdirSync(here).filter(f => f.endsWith('.test.mjs')).sort()
let failed = false
const stream = run({ files: names.map(f => join(here, f)), concurrency: 1 })
stream.on('test:pass', ({ name, skip, todo, nesting }) => {
if (nesting > 0) return
console.log(JSON.stringify({ case: `unit: ${name}`, status: skip || todo ? 'skipped' : 'passed' }))
})
stream.on('test:fail', ({ name, nesting, details }) => {
if (nesting > 0) return
failed = true
console.log(JSON.stringify({ case: `unit: ${name}`, status: 'failed', error: String(details && details.error && details.error.message || 'failed').slice(0, 400) }))
})
stream.on('end', () => { process.exit(failed ? 1 : 0) })
stream.resume()
+106
View File
@@ -0,0 +1,106 @@
// SPDX-License-Identifier: MIT
// Package hostclient is the add-on side of the host operation channel. An
// add-on process uses it to ask the host for an operation on behalf of a
// request it is serving.
package hostclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"net/url"
"strconv"
"time"
"github.com/mudler/LocalAI/addons/internal/protocol"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// ErrNoHostOps says the host gave this add-on no host operation channel.
var ErrNoHostOps = errors.New("the host offers no host operations to this add-on")
// ErrBadAnswer says the host answered in a form the contract does not allow.
var ErrBadAnswer = errors.New("the host answered a host operation with an invalid response")
// Client calls host operations. A nil Client is valid and offers none.
type Client struct {
endpoint, credential string
max int64
http *http.Client
}
// New returns nil when the startup holds no endpoint or an endpoint that is
// not http://127.0.0.1:<port>.
func New(startup protocol.Startup) *Client {
u, err := url.Parse(startup.HostEndpoint)
if err != nil || startup.HostEndpoint == "" || startup.HostCredential == "" || u.Scheme != "http" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.Hostname() != "127.0.0.1" {
return nil
}
if p, err := strconv.Atoi(u.Port()); err != nil || p < 1 || p > 65535 {
return nil
}
max := startup.Limits.PayloadBytes
if max <= 0 {
max = contract.DefaultLimits().PayloadBytes
}
t := &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext, MaxResponseHeaderBytes: 16 << 10}
return &Client{endpoint: startup.HostEndpoint, credential: startup.HostCredential, max: max, http: &http.Client{Transport: t}}
}
// Do calls op for the request operationID. args is marshalled to a JSON
// object (nil is an empty one); the result is decoded into out when out is
// not nil. A refusal of the host is returned as *contract.HostError.
func (c *Client) Do(ctx context.Context, operationID, op string, args, out any) error {
if c == nil {
return ErrNoHostOps
}
raw := json.RawMessage(`{}`)
if args != nil {
b, err := json.Marshal(args)
if err != nil {
return err
}
raw = b
}
body, err := json.Marshal(contract.HostRequest{OperationID: operationID, Op: op, Arguments: raw})
if err != nil {
return err
}
if int64(len(body)) > c.max {
return &contract.HostError{Code: contract.HostTooLarge, Message: "request too large"}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/host", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set(contract.HostCredentialHeader, c.credential)
req.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
b, err := io.ReadAll(io.LimitReader(resp.Body, c.max+1))
if err != nil || int64(len(b)) > c.max {
return ErrBadAnswer
}
var r contract.HostResponse
if json.Unmarshal(b, &r) != nil {
return ErrBadAnswer
}
if r.Error != nil {
return r.Error
}
if resp.StatusCode != http.StatusOK {
return ErrBadAnswer
}
if out != nil && len(r.Result) > 0 {
return json.Unmarshal(r.Result, out)
}
return nil
}
+71
View File
@@ -0,0 +1,71 @@
// SPDX-License-Identifier: MIT
package hostclient
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/mudler/LocalAI/addons/internal/protocol"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
func startupFor(endpoint string) protocol.Startup {
return protocol.Startup{HostEndpoint: endpoint, HostCredential: "secret", Limits: contract.DefaultLimits()}
}
func TestDoSendsTheCredentialAndTheBody(t *testing.T) {
var gotCred string
var gotReq contract.HostRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotCred = r.Header.Get("X-Addon-Host-Credential")
_ = json.NewDecoder(r.Body).Decode(&gotReq)
_ = json.NewEncoder(w).Encode(contract.HostResponse{Result: json.RawMessage(`{"id":"j1"}`)})
}))
defer srv.Close()
c := New(startupFor(srv.URL))
if c == nil {
t.Fatal("no client for a loopback endpoint")
}
var out struct{ ID string }
if err := c.Do(context.Background(), "op1", "finetune.get", map[string]string{"jobId": "j1"}, &out); err != nil {
t.Fatal(err)
}
if gotCred != "secret" || gotReq.OperationID != "op1" || gotReq.Op != "finetune.get" || string(gotReq.Arguments) != `{"jobId":"j1"}` || out.ID != "j1" {
t.Fatalf("%q %+v %+v", gotCred, gotReq, out)
}
}
func TestDoTurnsAnErrorAnswerIntoAHostError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_ = json.NewEncoder(w).Encode(contract.HostResponse{Error: &contract.HostError{Code: "scope", Message: "no"}})
}))
defer srv.Close()
err := New(startupFor(srv.URL)).Do(context.Background(), "op1", "finetune.start", nil, nil)
var he *contract.HostError
if !errors.As(err, &he) || he.Code != "scope" {
t.Fatalf("%v", err)
}
}
func TestAStartupWithoutAnEndpointGivesANilClient(t *testing.T) {
c := New(protocol.Startup{})
if c != nil {
t.Fatal("expected nil")
}
if err := c.Do(context.Background(), "op1", "finetune.get", nil, nil); !errors.Is(err, ErrNoHostOps) {
t.Fatalf("%v", err)
}
}
func TestANonLoopbackEndpointGivesANilClient(t *testing.T) {
for _, e := range []string{"http://10.0.0.1:80", "http://example.com:80", "https://127.0.0.1:80", "http://127.0.0.1", "http://127.0.0.1:80/x", "http://localhost:80", "http://127.0.0.1:0", "http://user@127.0.0.1:80", "http://[::1]:80"} {
if New(startupFor(e)) != nil {
t.Errorf("%s: got a client", e)
}
}
}
@@ -0,0 +1,57 @@
export function createBoundingBoxCanvas(ctx) {
const { h } = ctx
const { useEffect, useRef, useState } = ctx.React
// BoundingBoxCanvas: overlay face-detection rectangles on the user-supplied image.
// boxes: [{ x, y, w, h, label?, sublabel?, tone? }]
// tone: 'default' | 'success' | 'warning' | 'error' | 'accent'
function BoundingBoxCanvas({ src, boxes = [], alt = '' }) {
const wrapRef = useRef(null)
const imgRef = useRef(null)
const [dims, setDims] = useState({ w: 0, h: 0, natW: 0, natH: 0 })
useEffect(() => {
const update = () => {
if (!wrapRef.current || !imgRef.current) return
const rect = imgRef.current.getBoundingClientRect()
setDims({ w: rect.width, h: rect.height, natW: imgRef.current.naturalWidth || 1, natH: imgRef.current.naturalHeight || 1 })
}
update()
const ro = new ResizeObserver(update)
if (imgRef.current) ro.observe(imgRef.current)
window.addEventListener('resize', update)
return () => {
ro.disconnect()
window.removeEventListener('resize', update)
}
}, [src])
const sx = dims.natW ? dims.w / dims.natW : 1
const sy = dims.natH ? dims.h / dims.natH : 1
return (
<div ref={wrapRef} className="biometrics-bbox">
{src && <img ref={imgRef} src={src} alt={alt} onLoad={(e) => {
setDims({
w: e.target.getBoundingClientRect().width,
h: e.target.getBoundingClientRect().height,
natW: e.target.naturalWidth,
natH: e.target.naturalHeight,
})
}} />}
{boxes.map((b, i) => (
<div key={i} className={`biometrics-bbox__box tone-${b.tone || 'accent'}`}
style={{ left: `${b.x * sx}px`, top: `${b.y * sy}px`, width: `${b.w * sx}px`, height: `${b.h * sy}px` }}>
{(b.label || b.sublabel) && (
<div className="biometrics-bbox__tag">
{b.label && <strong>{b.label}</strong>}
{b.sublabel && <span>{b.sublabel}</span>}
</div>
)}
</div>
))}
</div>
)
}
return { BoundingBoxCanvas }
}
@@ -0,0 +1,225 @@
// The clip input shared by the Voice and Face add-ons: record or choose one
// sample, then show how long and how loud it is. Ported from
// components/identity/ClipInput.jsx; the host pieces come from the context.
import { judgeClip } from './identity.js'
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result.split(',')[1] || reader.result)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
// Read a clip in the browser: how long it is and how loud. Nothing is sent.
async function readClip(blob) {
const Ctx = window.AudioContext || window.webkitAudioContext
if (!Ctx || !blob) return null
const audio = new Ctx()
try {
const buf = await audio.decodeAudioData(await blob.arrayBuffer())
const data = buf.getChannelData(0)
let peak = 0
let hot = 0
for (let i = 0; i < data.length; i += 1) {
const v = Math.abs(data[i])
if (v > peak) peak = v
if (v >= 0.99) hot += 1
}
return { seconds: buf.duration, peak, clipped: hot / data.length > 0.001 }
} catch {
return null
} finally {
audio.close().catch(() => {})
}
}
// A permission state read without asking: 'denied', 'prompt', 'granted' or ''.
function useMicState(React, enabled) {
const { useState, useEffect } = React
const [state, setState] = useState('')
useEffect(() => {
if (!enabled || !navigator.permissions?.query) return undefined
let status
let live = true
navigator.permissions.query({ name: 'microphone' }).then((s) => {
if (!live) return
status = s
setState(s.state)
s.onchange = () => setState(s.state)
}).catch(() => {})
return () => { live = false; if (status) status.onchange = null }
}, [enabled])
return state
}
export function createClipInput(ctx) {
const { h, t, Icon, ui, hooks } = ctx
const { useEffect, useRef, useState } = ctx.React
const { WaveformPlayer } = ui
// One sample, in the kit's own look: a drop of choices before there is a
// sample, a card with the facts after. It is the input for both the Voices and
// the Faces pages.
//
// kind 'audio' or 'image'
// value null or { base64, blob, dataUrl, mime, source, name }
// onChange gets the same shape, or null on Remove
// idPrefix the file input is `${idPrefix}-${kind}-file`
function ClipInput({ kind, label, value, onChange, idPrefix, hint, prompt, onFacts }) {
const fileRef = useRef(null)
const cap = hooks.useMediaCapture(kind)
const [facts, setFacts] = useState(null)
const [busy, setBusy] = useState(false)
const micState = useMicState(ctx.React, kind === 'audio')
const secure = typeof window === 'undefined' || (window.isSecureContext ?? true)
const live = kind === 'audio' ? t('clip.mic') : t('clip.camera')
useEffect(() => {
let current = true
setFacts(null)
onFacts?.(null)
if (kind === 'audio' && value?.blob) readClip(value.blob).then((f) => { if (current) { setFacts(f); onFacts?.(f) } })
return () => { current = false }
}, [kind, value]) // eslint-disable-line react-hooks/exhaustive-deps
const take = async (file, source = 'file') => {
setBusy(true)
try {
const base64 = await fileToBase64(file)
const dataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error)
reader.onload = () => resolve(reader.result)
reader.readAsDataURL(file)
})
const name = source === 'paste' ? `pasted-image.${(file.type.split('/')[1] || 'png').replace('+xml', '')}` : file.name
onChange({ base64, blob: file, dataUrl, mime: file.type, source, name })
} finally {
setBusy(false)
}
}
const onFile = (e) => {
const f = e.target.files?.[0]
if (f) take(f)
else onChange(null)
}
const onPaste = (e) => {
if (kind !== 'image') return
const item = Array.from(e.clipboardData?.items || []).find(entry => entry.type.startsWith('image/'))
const f = item?.getAsFile()
if (!f) return
e.preventDefault()
take(f, 'paste')
}
const record = async () => {
if (cap.recording) { cap.stopRecording(); return }
const pending = cap.startRecording()
if (!pending) return
const result = await pending
cap.stop()
onChange({ ...result, source: 'live', name: 'recording.wav' })
}
const snap = () => {
const shot = cap.snap()
if (shot) { cap.stop(); onChange({ ...shot, source: 'live', name: 'photo.png' }) }
}
const clear = () => {
onChange(null)
if (fileRef.current) fileRef.current.value = ''
}
const playUrl = hooks.useObjectUrl(kind === 'audio' ? value?.blob : null)
const verdict = facts ? judgeClip(facts) : null
const blocked = cap.errorKind === 'denied' || micState === 'denied'
const inputId = `${idPrefix}-${kind}-file`
return (
<div className="idn-clip" onPaste={onPaste} data-state={value ? 'filled' : 'empty'} data-testid={`${idPrefix}-clip`}>
<div className="idn-clip__head">
<span className="dk-eyebrow">{label}</span>
{value && <button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={clear}><Icon name="close" /> {t('clip.remove')}</button>}
</div>
<input ref={fileRef} id={inputId} type="file" className="dk-sr-only" accept={kind === 'audio' ? 'audio/*' : 'image/*'} onChange={onFile} aria-label={label} />
{!value && !cap.active && (
<div className="idn-clip__choices">
{prompt && <p className="idn-clip__prompt"><span className="dk-eyebrow">{t('clip.readAloud')}</span>{prompt}</p>}
<div className="idn-clip__buttons">
{cap.supported && !blocked && (
<button type="button" className="dk-btn dk-btn--secondary" onClick={cap.start}>
<Icon name={kind === 'audio' ? 'mic' : 'camera'} /> {kind === 'audio' ? t('clip.record') : t('clip.takePhoto')}
</button>
)}
<button type="button" className="dk-btn dk-btn--secondary" onClick={() => fileRef.current?.click()} disabled={busy}>
<Icon name="upload" /> {kind === 'audio' ? t('clip.chooseAudio') : t('clip.choosePhoto')}
</button>
</div>
{(!cap.supported || blocked || cap.error) && (
<p className="idn-clip__notice" role={blocked ? 'alert' : 'status'} data-testid="clip-notice">
<Icon name={blocked || !secure ? 'lock' : 'info'} />
<span>
{blocked && t('clip.blocked', { thing: live })}
{!blocked && !cap.supported && !secure && t('clip.insecure', { thing: live, origin: window.location.origin })}
{!blocked && !cap.supported && secure && t('clip.unsupported', { thing: live })}
{!blocked && cap.supported && cap.error && t(cap.errorKind === 'missing' ? 'clip.missing' : 'clip.failed', { thing: live })}
</span>
</p>
)}
{cap.supported && !blocked && !cap.error && micState === 'granted' && kind === 'audio' && (
<p className="idn-clip__hint" data-testid="mic-allowed"><Icon name="check" /> {t('clip.allowed')}</p>
)}
{hint && <p className="idn-clip__hint">{hint}</p>}
</div>
)}
{!value && cap.active && kind === 'image' && (
<div className="idn-clip__live">
<video ref={cap.videoRef} autoPlay muted playsInline className="idn-clip__video" />
<div className="idn-clip__buttons">
<button type="button" className="dk-btn dk-btn--primary" onClick={snap}><Icon name="circle-dot" /> {t('clip.capture')}</button>
<button type="button" className="dk-btn dk-btn--ghost" onClick={cap.stop}>{t('clip.cancel')}</button>
</div>
</div>
)}
{!value && cap.active && kind === 'audio' && (
<div className="idn-clip__live">
<p className="idn-clip__rec" role="status"><span className="dk-dot dk-dot--error" data-live={cap.recording ? 'true' : undefined} /> {cap.recording ? t('clip.recording', { s: cap.elapsed.toFixed(1) }) : t('clip.micReady')}</p>
<div className="idn-clip__buttons">
<button type="button" className={`dk-btn ${cap.recording ? 'dk-btn--primary' : 'dk-btn--secondary'}`} onClick={record}>
<Icon name={cap.recording ? 'stop' : 'circle'} /> {cap.recording ? t('clip.stop') : t('clip.start')}
</button>
<button type="button" className="dk-btn dk-btn--ghost" onClick={cap.stop} disabled={cap.recording}>{t('clip.cancel')}</button>
</div>
</div>
)}
{value && (
<div className="idn-clip__card">
<div className="idn-clip__meta">
<Icon name={value.source === 'live' ? (kind === 'audio' ? 'mic' : 'camera') : kind === 'audio' ? 'file' : 'image'} />
<strong className="idn-clip__name">{value.source === 'live' ? t(kind === 'audio' ? 'clip.recorded' : 'clip.photographed') : value.name || t('clip.uploaded')}</strong>
{facts && <span className="dk-chip dk-chip--sm dk-mono">{facts.seconds.toFixed(1)} s</span>}
{verdict?.level === 'good' && <span className="dk-badge dk-badge--ok"><Icon name="check" /> {t('clip.goodLength')}</span>}
{verdict?.level === 'warn' && <span className="dk-badge dk-badge--warn"><Icon name="warning" /> {facts.seconds < 3 ? t('clip.short') : t('clip.checkLevel')}</span>}
</div>
{kind === 'audio'
? <WaveformPlayer src={playUrl || value.dataUrl} height={56} label={t('clip.preview')} />
: <img className="idn-clip__img" src={value.dataUrl} alt={t('clip.photoAlt')} />}
{verdict?.level === 'warn' && <ul className="idn-clip__notes">{verdict.notes.map(n => <li key={n}>{n}</li>)}</ul>}
</div>
)}
</div>
)
}
return { ClipInput }
}
@@ -0,0 +1,45 @@
import { labelRows, pct, scaleMax, scaleTicks } from './identity.js'
const at = (value, max) => ({ '--at': pct(value, max) })
export function createDistanceScale(ctx) {
const { h, t } = ctx
// One scale for the whole answer: distance runs left to right, the cut-off is
// a line across it, and each enrolled person (or the single pair) is a dot.
// Lower means more alike. The text under it repeats the numbers, so the dots
// are never the only way to read the result.
//
// dots [{ id, label, distance, within, best }] one per person, any order
// cutoff where "same" ends
function DistanceScale({ dots, cutoff, noun, single = false }) {
const max = scaleMax(cutoff, dots.map(d => d.distance))
const ordered = [...dots].sort((a, b) => a.distance - b.distance)
const rows = labelRows(ordered.map(d => (d.distance / max) * 100))
const plot = { '--rows': Math.max(...rows, 0) }
const summary = ordered.map(d => `${d.label} ${d.distance.toFixed(2)}`).join(', ')
return (
<figure className="idn-scale" data-testid="distance-scale">
<div className="idn-scale__plot" style={plot} role="img" aria-label={t('scale.aria', { cutoff: cutoff.toFixed(2), dots: summary })}>
<span className="idn-scale__cut" style={at(cutoff, max)}><span className="idn-scale__cut-label dk-mono">{t('scale.cutoff', { value: cutoff.toFixed(2) })}</span></span>
<span className="idn-scale__track"><span className="idn-scale__inside" style={at(cutoff, max)} /></span>
{ordered.map((d, i) => (
<span key={d.id} className="idn-scale__dot" data-best={d.best ? 'true' : undefined} data-within={d.within ? 'true' : 'false'} data-row={rows[i]} style={at(d.distance, max)}>
<span className="idn-scale__mark" />
<span className="idn-scale__name"><strong>{d.label}</strong> <span className="dk-mono">{d.distance.toFixed(2)}</span></span>
</span>
))}
<span className="idn-scale__ticks" aria-hidden="true">
{scaleTicks(max).map(v => <span key={v} className="idn-scale__tick dk-mono" style={at(v, max)}>{v}</span>)}
</span>
</div>
<figcaption className="idn-scale__caption">
<span>{t('scale.axis', { print: noun })}</span>
<span>{single ? t('scale.onePair') : t('scale.oneDot', { person: noun === 'faceprint' ? t('face.person') : t('voice.speaker') })}</span>
</figcaption>
</figure>
)
}
return { DistanceScale }
}
@@ -0,0 +1,148 @@
import { labelsText, parseLabels } from './identity.js'
import { createReferenceAudio } from './referenceAudio.js'
// A saved copy of a sample lives in localStorage, which is small. A larger one
// is not kept, and the sheet says so instead of failing quietly.
const KEEP_LIMIT = 1_500_000
const READ_ALOUD = 'The harbour was quiet before the ferry arrived. Two gulls argued over a crust, and somebody carried a ladder down to the water.'
// The form that enrols one person. The shell draws it in a side panel
// (ctx.openPanel), which has no footer slot, so the actions close the form.
// deps = { ClipInput, ErrorNote, api }
export function createEnrolSheet(ctx, deps) {
const { h, Fragment, t, Icon, ui } = ctx
const { useMemo, useState } = ctx.React
const { ClipInput, ErrorNote, api } = deps
const { normalizeAudioSample } = createReferenceAudio(ctx)
// Enrol one person: a sample, who it is, and permission. For a voice, an admin
// can also keep the same recording as a speech voice, which is a different
// store with its own transcript and consent record.
function EnrolSheet({ kind, model, canSpeech, prefill, onClose, onDone }) {
const isFace = kind === 'face'
const [sample, setSample] = useState(prefill?.sample || null)
const [name, setName] = useState(prefill?.name || '')
const [labels, setLabels] = useState(labelsText(prefill?.labels).replaceAll(', ', '\n'))
const [asSpeech, setAsSpeech] = useState(false)
const [transcript, setTranscript] = useState('')
const [agreed, setAgreed] = useState(false)
const [keep, setKeep] = useState(false)
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
const needs = useMemo(() => {
const out = []
if (!model) out.push(t('enrol.needModel'))
if (!sample) out.push(t(isFace ? 'enrol.needPhoto' : 'enrol.needRecording'))
if (!name.trim()) out.push(t('enrol.needName'))
if (asSpeech && !transcript.trim()) out.push(t('enrol.needTranscript'))
if (!agreed) out.push(t('enrol.needPermission'))
return out
}, [model, sample, name, asSpeech, transcript, agreed, isFace])
const submit = async (e) => {
e.preventDefault()
if (needs.length || busy) return
setBusy(true)
setError(null)
let data
try {
const parsed = parseLabels(labels)
data = isFace
? await api.identity.face.register({ model, name: name.trim(), img: sample.dataUrl, labels: parsed })
: await api.identity.voice.register({ model, name: name.trim(), audio: sample.dataUrl, labels: parsed })
const small = sample.dataUrl.length <= KEEP_LIMIT
const kept = keep && small
const entry = {
id: data.id,
name: data.name,
labels: parsed,
model,
registeredAt: data.registered_at || new Date().toISOString(),
...(kept ? (isFace ? { thumbnail: sample.dataUrl } : { sampleUrl: sample.dataUrl }) : {}),
}
let speechError = null
if (asSpeech && canSpeech && !isFace) {
try {
const wav = await normalizeAudioSample(sample)
const form = new FormData()
form.append('name', name.trim())
form.append('description', '')
form.append('language', '')
form.append('transcript', transcript.trim())
form.append('consent_confirmed', 'true')
form.append('audio', wav.blob, 'reference.wav')
await api.voiceProfiles.create(form)
} catch (err) {
speechError = err
}
}
onDone(entry, { keptTooBig: keep && !small, speechError, asSpeech })
} catch (err) {
setError(err)
} finally {
setBusy(false)
}
}
return (
<form id="idn-enrol-form" className="idn-sheet" onSubmit={submit} data-testid="enrol-sheet">
<p className="dk-hint">{t(`${kind}.enrolIntro`)}</p>
<section className="idn-sheet__step">
<h3 className="idn-sheet__h"><span className="idn-sheet__n">1</span> {t(isFace ? 'enrol.photoStep' : 'enrol.recordingStep')}</h3>
<ClipInput kind={isFace ? 'image' : 'audio'} label={t(isFace ? 'enrol.photoLabel' : 'enrol.recordingLabel')} value={sample} onChange={setSample} idPrefix={`${kind}-enrol`} prompt={isFace ? null : READ_ALOUD} />
</section>
<section className="idn-sheet__step">
<h3 className="idn-sheet__h"><span className="idn-sheet__n">2</span> {t('enrol.whoStep')}</h3>
<div className="dk-field">
<label className="dk-label" htmlFor={`${kind}-enrol-name`}>{t('enrol.name')}</label>
<input id={`${kind}-enrol-name`} className="dk-input" value={name} onChange={(e) => setName(e.target.value)} placeholder={t('enrol.namePlaceholder')} autoComplete="off" />
</div>
<div className="dk-field">
<label className="dk-label" htmlFor={`${kind}-enrol-labels`}>{t('enrol.labels')} <span className="dk-hint">{t('enrol.labelsHint')}</span></label>
<textarea id={`${kind}-enrol-labels`} className="dk-textarea" rows={2} placeholder={'team: platform'} value={labels} onChange={(e) => setLabels(e.target.value)} />
</div>
{canSpeech && !isFace && (
<div className="dk-field">
<label className="dk-choice idn-sheet__choice">
<input className="dk-check" type="checkbox" checked={asSpeech} onChange={(e) => setAsSpeech(e.target.checked)} />
<span><strong>{t('enrol.asSpeech')}</strong><span className="dk-hint">{t('enrol.asSpeechHint')}</span></span>
</label>
{asSpeech && (
<>
<label className="dk-label" htmlFor="voice-enrol-transcript">{t('enrol.transcript')}</label>
<textarea id="voice-enrol-transcript" className="dk-textarea" rows={3} maxLength={4000} value={transcript} onChange={(e) => setTranscript(e.target.value)} />
</>
)}
</div>
)}
</section>
<section className="idn-sheet__step">
<h3 className="idn-sheet__h"><span className="idn-sheet__n">3</span> {t('enrol.permissionStep')}</h3>
<label className="dk-choice idn-sheet__choice">
<input className="dk-check" type="checkbox" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />
<span><strong>{t('enrol.agreed')}</strong><span className="dk-hint">{t(`${kind}.agreedHint`)}</span></span>
</label>
<label className="dk-choice idn-sheet__choice">
<input className="dk-check" type="checkbox" checked={keep} onChange={(e) => setKeep(e.target.checked)} />
<span><strong>{t('enrol.keep')}</strong><span className="dk-hint">{t(`${kind}.keepHint`)}</span></span>
</label>
</section>
{error && <ErrorNote error={error} kind={kind} />}
<div className="idn-sheet__foot">
<span className="idn-sheet__need" data-testid="enrol-needs">{needs.length ? t('enrol.stillNeeded', { list: needs.join(', ') }) : t('enrol.ready')}</span>
<ui.Button variant="ghost" onClick={onClose}>{t('enrol.cancel')}</ui.Button>
<ui.Button variant="primary" type="submit" busy={busy} disabled={needs.length > 0}>
{busy ? t('enrol.working') : <>{t('enrol.submit')} <Icon name="check" /></>}
</ui.Button>
</div>
</form>
)
}
return { EnrolSheet }
}
@@ -0,0 +1,27 @@
import { explainError } from './identity.js'
export function createErrorNote(ctx) {
const { h, t, Icon } = ctx
// A failed call in plain words first, the server's own message second, and a
// way to the traces. `onRetry` adds a button when trying again makes sense.
// The trace page belongs to the host, so the link leaves the add-on.
function ErrorNote({ error, kind, onRetry }) {
const { title, body } = explainError(error, kind)
return (
<div className="idn-error" role="alert" data-testid="identity-error">
<span className="idn-error__icon" aria-hidden="true"><Icon name="alert-circle" /></span>
<div className="idn-error__body">
<h3 className="idn-error__title">{title}</h3>
<p className="idn-error__text">{body}</p>
{error?.message && <p className="idn-error__raw dk-mono">{error.message}</p>}
<div className="idn-error__acts">
{onRetry && <button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={onRetry}><Icon name="refresh" /> {t('error.retry')}</button>}
<a className="dk-btn dk-btn--ghost dk-btn--sm" href={`${ctx.basePath}/app/traces?tab=backend`}><Icon name="waveform" /> {t('error.traces')}</a>
</div>
</div>
</div>
)
}
return { ErrorNote }
}
@@ -0,0 +1,109 @@
import { ageText, initials, labelsText } from './identity.js'
export function createKnownList(ctx) {
const { h, Fragment, t, Icon } = ctx
const { useRef } = ctx.React
function Avatar({ entry, tone }) {
return (
<span className="idn-avatar" data-tone={tone} aria-hidden="true">
{entry.thumbnail ? <img src={entry.thumbnail} alt="" /> : initials(entry.name)}
</span>
)
}
function PlaySample({ entry }) {
const audio = useRef(null)
if (!entry.sampleUrl) return null
const play = () => {
if (!audio.current) audio.current = new Audio(entry.sampleUrl)
audio.current.currentTime = 0
audio.current.play().catch(() => {})
}
return (
<button type="button" className="dk-btn dk-btn--ghost dk-btn--icon dk-btn--sm" onClick={play} aria-label={t('known.play', { name: entry.name })}>
<Icon name="play" />
</button>
)
}
// The registry as a table: who is saved in this browser, since when, and
// whether the server still seems to have them. Names and labels are the only
// facts shown; the saved sample, when the person chose to keep one, plays.
function KnownList({ entries, extras, missing, waiting, bestId, onDelete, onUndo, onAgain, noun, onEnrol, kind, canEnrol = true }) {
const rows = [
...entries.map(e => ({ ...e, local: true })),
...extras.map(e => ({ ...e, local: false })),
]
if (rows.length === 0) {
return (
<div className="idn-known__empty dk-empty" data-testid="known-empty">
<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>
</div>
)
}
const language = ctx.sdk.locale.language()
return (
<div className="dk-table-wrap dk-table-wrap--flat" role="region" aria-label={t(`${kind}.knownTitle`)} tabIndex={0}>
<table className="dk-table" data-testid="known-list">
<caption className="dk-sr-only">{t('known.caption', { noun })}</caption>
<thead>
<tr>
<th scope="col">{t('known.name')}</th>
<th scope="col" className="dk-hide-phone">{t('known.added')}</th>
<th scope="col"><span className="dk-sr-only">{t('known.actions')}</span></th>
</tr>
</thead>
<tbody>
{rows.map(row => {
const gone = row.local && missing.has(row.id)
const pending = waiting[row.id]
const best = row.id === bestId
return (
<tr key={row.id} data-row data-selected={best ? 'true' : undefined} data-pending={pending ? 'true' : undefined} data-testid={`known-row-${row.id}`}>
<td>
<span className="idn-who">
<Avatar entry={row} tone={gone ? 'warn' : undefined} />
<span className="idn-who__text">
<span className="dk-table-name">
{row.name}
{best && <span className="dk-badge dk-badge--ok"><Icon name="check" /> {t('known.best')}</span>}
{gone && <span className="dk-badge dk-badge--warn" data-testid="not-on-server"><Icon name="warning" /> {t('known.notOnServer')}</span>}
{!row.local && <span className="dk-badge" data-testid="not-in-browser">{t('known.notInBrowser')}</span>}
{pending && <span className="dk-badge dk-badge--error">{t('known.removing')}</span>}
</span>
<span className="dk-table-sub" data-error={gone ? true : undefined}>
{gone ? t('known.goneText', { print: noun }) : !row.local ? t('known.extraText') : labelsText(row.labels) || t('known.noLabels')}
</span>
{gone && !pending && <button type="button" className="dk-btn dk-btn--secondary dk-btn--sm idn-again" onClick={() => onAgain(row)}>{row.sampleUrl || row.thumbnail ? t('known.reenrolSaved') : t('known.enrolAgain')}</button>}
</span>
</span>
</td>
<td className="dk-hide-phone dk-mono idn-when">{row.registeredAt ? ageText(row.registeredAt, Date.now(), language) : ''}</td>
<td className="idn-actions">
{pending ? (
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => onUndo(row.id)}>{t('known.undo')}</button>
) : (
<>
<PlaySample entry={row} />
<button type="button" className="dk-btn dk-btn--ghost dk-btn--icon dk-btn--sm" onClick={() => onDelete(row)} aria-label={t('known.forget', { name: row.name })}>
<Icon name="trash" />
</button>
</>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
return { KnownList }
}
@@ -0,0 +1,99 @@
// The disabled state: recognition runs on a model, and none is installed.
// It says so, offers the gallery models that carry the right tag, and says
// what turns the feature on. `kind` is 'voice' or 'face'.
export function createModelNeeded(ctx, api) {
const { h, t, Icon } = ctx
const { useCallback, useEffect, useState } = ctx.React
// The running installs, polled while one this page started is in progress.
// The operations feed is admin-only on the server, so it is not read for
// anyone else. Completion rule of the core hook: a finished or failed op.
function useOperations(active) {
const [operations, setOperations] = useState([])
useEffect(() => {
if (!active) return undefined
let stop = false
const tick = async () => {
try {
const data = await api.operations.list()
if (!stop) setOperations(data?.operations || (Array.isArray(data) ? data : []))
} catch { /* the next tick tries again */ }
}
tick()
const timer = setInterval(tick, 2000)
return () => { stop = true; clearInterval(timer) }
}, [active])
return operations
}
function ModelNeeded({ kind, onInstalled, isAdmin }) {
const [models, setModels] = useState([])
const [loading, setLoading] = useState(true)
const [failed, setFailed] = useState(false)
const [started, setStarted] = useState(() => new Set())
const operations = useOperations(isAdmin && started.size > 0)
const load = useCallback(async () => {
try {
const res = await api.models.list({ tag: kind === 'face' ? 'face-recognition' : 'voice-recognition', items: 4, page: 1, sort: 'name', order: 'asc' })
setModels((res?.models || []).filter(m => !m.installed).slice(0, 3))
setFailed(false)
} catch {
setFailed(true)
} finally {
setLoading(false)
}
}, [kind])
useEffect(() => { load() }, [load])
// When an install finishes, tell the page to look for the model again.
useEffect(() => {
if (started.size === 0) return
const done = [...started].some(id => operations.some(op => op.name === id && (op.completed || op.error)))
if (done) { onInstalled?.(); load() }
}, [operations, started, onInstalled, load])
const install = async (model) => {
setStarted(prev => new Set(prev).add(model.id))
try {
await api.models.install(model.id)
ctx.toast(t('needed.started', { name: model.name }), 'success')
} catch (err) {
setStarted(prev => { const next = new Set(prev); next.delete(model.id); return next })
ctx.toast(err.message, 'error')
}
}
const busy = (model) => started.has(model.id) || operations.some(op => op.name === model.id && !op.completed && !op.error)
return (
<section className="idn-needed dk-card" data-testid="model-needed" aria-labelledby={`${kind}-needed-title`}>
<span className="dk-badge dk-badge--warn"><Icon name="plug-off" /> {t('needed.badge')}</span>
<h2 className="idn-h2" id={`${kind}-needed-title`}>{t(`${kind}.neededTitle`)}</h2>
<p className="idn-needed__text">{t(`${kind}.neededText`)}</p>
{isAdmin && !loading && models.length > 0 && (
<ul className="idn-needed__list">
{models.map(model => (
<li key={model.id}>
<span><strong className="dk-mono">{model.name}</strong><span className="dk-hint">{model.backend ? t('needed.backend', { backend: model.backend }) : ''}</span></span>
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" disabled={busy(model)} onClick={() => install(model)}>
<Icon name={busy(model) ? 'spinner' : 'download'} spin={busy(model)} /> {busy(model) ? t('needed.installing') : t('needed.install')}
</button>
</li>
))}
</ul>
)}
{isAdmin && !loading && models.length === 0 && (
<p className="dk-hint">{failed ? t('needed.loadFailed') : t('needed.noneListed')}{' '}<a className="dk-link" href={`${ctx.basePath}/app/models`}>{t('needed.browse')}</a></p>
)}
{!isAdmin && <p className="dk-hint">{t('needed.askAdmin')}</p>}
<details className="idn-disclosure">
<summary><Icon name="chevron-right" /> {t('needed.whatTitle')}</summary>
<p className="idn-disclosure__text">{t(`${kind}.neededWhat`)}</p>
</details>
</section>
)
}
return { ModelNeeded }
}
@@ -0,0 +1,23 @@
const WORD = { strong: 'ok', likely: 'ok', close: 'warn', near: 'warn', far: '' }
export function createVerdict(ctx) {
const { h, t, Icon } = ctx
// The answer in a sentence: a headline, a word for how strong it is, and one
// plain line with the real numbers. `tone` picks the icon: match, miss, none.
function Verdict({ tone, title, word, children, testId = 'verdict' }) {
return (
<div className="idn-verdict" data-tone={tone} data-testid={testId} role="status">
<span className="idn-verdict__icon" aria-hidden="true"><Icon name={tone === 'match' ? 'check' : tone === 'miss' ? 'help-circle' : 'minus'} /></span>
<div className="idn-verdict__body">
<h3 className="idn-verdict__title">
{title}
{word && <span className={`dk-badge${WORD[word] ? ` dk-badge--${WORD[word]}` : ''}`}>{t(`word.${word}`)}</span>}
</h3>
<p className="idn-verdict__text">{children}</p>
</div>
</div>
)
}
return { Verdict }
}
@@ -0,0 +1,345 @@
import { FACE_CUTOFF, VOICE_CUTOFF, rankMatches, strength } from './identity.js'
const KINDS = {
voice: {
mode: 'audio', cutoff: VOICE_CUTOFF, print: 'voiceprint', key: 'localai_voice_enrollments',
probe: 'audio', pair: ['audio1', 'audio2'], minTopK: 25,
},
face: {
mode: 'image', cutoff: FACE_CUTOFF, print: 'faceprint', key: 'localai_face_enrollments',
probe: 'img', pair: ['img1', 'img2'], minTopK: 25,
},
}
// The whole identity tool, for voices or for faces: who is this, same person,
// the people this browser knows, and what is stored. The two kinds differ in
// the sample (audio or photo), the calls and the default cut-off; everything
// else is shared so the pages read as one family.
//
// deps = { ClipInput, KnownList, EnrolSheet, Verdict, DistanceScale, ErrorNote,
// useRegistry, BoundingBoxCanvas, api }. BoundingBoxCanvas is for the
// face kind only; the voice add-on does not pass it.
export function createWorkbench(ctx, deps) {
const { h, Fragment, t, Icon, ui } = ctx
const { useCallback, useEffect, useMemo, useState } = ctx.React
const { ClipInput, KnownList, EnrolSheet, Verdict, DistanceScale, ErrorNote, useRegistry, BoundingBoxCanvas, api } = deps
function CutoffControl({ value, onChange, modelDefault, kind }) {
const same = Math.abs(value - modelDefault) < 0.0005
const fill = { '--dk-fill': `${((value - 0.05) / 0.95) * 100}%` }
return (
<div className="idn-cutoff">
<div className="idn-cutoff__head">
<label htmlFor={`${kind}-cutoff`}><strong>{t('cutoff.label')}</strong> <span className="dk-mono" data-testid="cutoff-value">{value.toFixed(2)}</span></label>
{same
? <span className="dk-badge">{t('cutoff.default')}</span>
: <button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={() => onChange(modelDefault)}>{t('cutoff.reset', { value: modelDefault.toFixed(2) })}</button>}
</div>
<div className="idn-cutoff__row">
<span className="dk-hint">{t('cutoff.stricter')}</span>
<input id={`${kind}-cutoff`} className="dk-range" type="range" min="0.05" max="1" step="0.01" value={value} style={fill}
onChange={(e) => onChange(parseFloat(e.target.value))} />
<span className="dk-hint">{t('cutoff.looser')}</span>
</div>
<p className="dk-hint">{t('cutoff.explain')}</p>
</div>
)
}
function HowToRead({ print }) {
return (
<p className="idn-how"><strong>{t('how.title')}</strong> {t('how.body', { print })}</p>
)
}
function WhyWrong({ kind, notes }) {
const list = [...notes, ...ctx.list(`${kind}.why`)]
return (
<details className="idn-disclosure">
<summary><Icon name="chevron-right" /> {t('why.title')}</summary>
<ul className="idn-disclosure__list" data-testid="why-list">{list.map(n => <li key={n}>{n}</li>)}</ul>
</details>
)
}
function Stored({ kind, print }) {
const points = ctx.list(`${kind}.stored`)
return (
<details className="idn-disclosure idn-disclosure--page" data-testid="stored-note">
<summary><Icon name="chevron-right" /> {t('stored.title')}</summary>
<dl className="idn-stored">
{points.map(p => <div key={p.where}><dt>{p.where}</dt><dd>{String(p.what).replace(/\{\{\s*print\s*\}\}/g, print)}</dd></div>)}
</dl>
</details>
)
}
function Workbench({ kind, model, canSpeech = false, more = null, needed = null, onCount }) {
const cfg = KINDS[kind]
const call = api.identity[kind]
const isFace = kind === 'face'
const noun = t(`${kind}.print`)
const [tool, setTool] = useState('identify')
const [probe, setProbe] = useState(null)
const [probeFacts, setProbeFacts] = useState(null)
const [clipA, setClipA] = useState(null)
const [clipB, setClipB] = useState(null)
const [antiSpoof, setAntiSpoof] = useState(false)
const [find, setFind] = useState({ status: 'idle' })
const [pair, setPair] = useState({ status: 'idle' })
const [findCut, setFindCut] = useState(cfg.cutoff)
const [pairCut, setPairCut] = useState(null)
const onForgot = useCallback((entry, outcome) => {
if (outcome === 'gone') ctx.toast(t('toast.removed', { name: entry.name }), 'info')
else if (outcome === 'already-gone') ctx.toast(t('toast.alreadyGone', { name: entry.name }), 'warning')
else ctx.toast(outcome.message, 'error')
}, [])
const forgetCall = useCallback((body) => call.forget(body), [call])
const reg = useRegistry({ storageKey: cfg.key, forget: forgetCall, onForgot })
const visibleEntries = reg.entries
const known = visibleEntries.length
useEffect(() => { onCount?.(known) }, [known, onCount])
const identify = async () => {
if (!model || !probe) return
setFind({ status: 'working' })
const topK = Math.max(cfg.minTopK, known * 2)
try {
const data = await call.identify({ model, [cfg.probe]: probe.dataUrl, top_k: topK, threshold: findCut })
const matches = data?.matches || []
reg.noteSearch(matches, topK)
setFind({ status: 'done', matches, cutoffSent: findCut, searched: known })
} catch (err) {
setFind({ status: 'failed', error: err })
}
}
const compare = async () => {
if (!model || !clipA || !clipB) return
setPair({ status: 'working' })
setPairCut(null)
try {
const body = { model, [cfg.pair[0]]: clipA.dataUrl, [cfg.pair[1]]: clipB.dataUrl }
if (isFace) body.anti_spoofing = antiSpoof
const data = await call.verify(body)
setPair({ status: 'done', data })
} catch (err) {
setPair({ status: 'failed', error: err })
}
}
const ranked = useMemo(() => (find.status === 'done' ? rankMatches(find.matches, findCut) : []), [find, findCut])
const best = ranked[0]
const nameOf = (m) => m.name || m.id
const onEnrolled = (entry, extra) => {
reg.add(entry)
ctx.toast(t(extra.asSpeech && !extra.speechError ? 'toast.enrolledBoth' : 'toast.enrolled', { name: entry.name }), 'success')
if (extra.keptTooBig) ctx.toast(t('toast.tooBig'), 'warning')
if (extra.speechError) ctx.toast(t('toast.speechFailed', { message: extra.speechError.message }), 'error')
}
// The enrol form opens in the shell's side panel. The panel is drawn
// outside the page root, so its content carries the add-on scope.
const openSheet = (prefill) => {
ctx.openPanel(t(`${kind}.enrolTitle`), ({ close }) => (
<div data-addon={ctx.sdk.addonId}>
<EnrolSheet kind={kind} model={model} canSpeech={canSpeech} prefill={prefill} onClose={close}
onDone={(entry, extra) => { close(); onEnrolled(entry, extra) }} />
</div>
))
}
const again = async (row) => {
const saved = row.sampleUrl || row.thumbnail
if (!saved) { openSheet({ name: row.name, labels: row.labels }); return }
try {
const data = await call.register(isFace
? { model, name: row.name, img: saved, labels: row.labels || {} }
: { model, name: row.name, audio: saved, labels: row.labels || {} })
reg.replace(row.id, { ...row, id: data.id, registeredAt: data.registered_at || new Date().toISOString(), model })
ctx.toast(t('toast.enrolled', { name: row.name }), 'success')
} catch (err) {
ctx.toast(err.message, 'error')
}
}
const noteFor = (r) => {
const d = r.distance.toFixed(2)
const c = findCut.toFixed(2)
const n = find.matches.length
if (r.within) return t(`${kind}.matchText`, { name: nameOf(r), distance: d, cutoff: c, count: n })
return t(`${kind}.missText`, { name: nameOf(r), distance: d, cutoff: c })
}
const pairResult = pair.status === 'done' ? pair.data : null
const pairModelCut = pairResult?.threshold || cfg.cutoff
const pairActive = pairCut ?? pairModelCut
const pairWithin = pairResult ? pairResult.distance <= pairActive : false
const pairWord = pairResult ? strength(pairResult.distance, pairActive) : null
const working = find.status === 'working' || pair.status === 'working'
const busyText = (text) => <><ui.Spinner size="sm" /> {text}</>
return (
<div className="idn-bench" data-kind={kind}>
{needed}
{!needed && <section className="idn-tool dk-card" aria-label={t('tool.aria')}>
<div className="idn-tool__bar">
<div className="dk-segmented" role="tablist" aria-label={t('tool.aria')}>
<button type="button" role="tab" className="dk-seg" aria-selected={tool === 'identify'} onClick={() => setTool('identify')}>{t(`${kind}.whoTab`)}</button>
<button type="button" role="tab" className="dk-seg" aria-selected={tool === 'same'} onClick={() => setTool('same')}>{t('tool.sameTab')}</button>
</div>
<p className="dk-hint idn-tool__lede">{tool === 'identify' ? t(`${kind}.whoLede`) : t(`${kind}.sameLede`)}</p>
</div>
{tool === 'identify' && (
<div className="idn-tool__body" role="tabpanel">
<ClipInput kind={cfg.mode} label={t(isFace ? 'tool.photo' : 'tool.clip')} value={probe} onChange={(v) => { setProbe(v); setFind({ status: 'idle' }) }} idPrefix={`${kind}-probe`} onFacts={setProbeFacts} />
<div className="idn-tool__go">
<button type="button" className="dk-btn dk-btn--primary" onClick={identify} disabled={!model || !probe || find.status === 'working'} data-testid="identify-go">
{find.status === 'working' ? busyText(t(`${kind}.working`)) : <>{t(`${kind}.whoGo`)} <Icon name="arrow-right" /></>}
</button>
<span className="dk-hint">{t(`${kind}.whoNote`, { count: known })}</span>
</div>
{find.status === 'working' && (
<div className="idn-working" aria-busy="true" data-testid="identify-working">
<div className="dk-progress" role="progressbar" aria-label={t('tool.workingAria')} data-indeterminate><span className="dk-progress-bar" /></div>
<p className="dk-hint">{t(`${kind}.workingText`, { count: known })}</p>
</div>
)}
{find.status === 'failed' && <ErrorNote error={find.error} kind={kind} onRetry={identify} />}
{find.status === 'done' && (
<div className="idn-result" data-testid="identify-result">
{!best && (
<Verdict tone="none" title={t(known ? `${kind}.noneReturned` : `${kind}.noneEnrolled`)}>
{t(known ? `${kind}.noneReturnedText` : `${kind}.noneEnrolledText`)}
</Verdict>
)}
{best && best.within && (
<Verdict tone="match" title={t(`${kind}.probably`, { name: nameOf(best) })} word={best.word}>{noteFor(best)}</Verdict>
)}
{best && !best.within && (
<Verdict tone="miss" title={t(`${kind}.nobody`)} word={best.word === 'near' ? 'near' : null}>{noteFor(best)}</Verdict>
)}
{best && (
<>
<DistanceScale cutoff={findCut} noun={noun} dots={ranked.slice(0, 8).map(r => ({ id: r.id, label: nameOf(r).split(/\s+/)[0], distance: r.distance, within: r.within, best: r.rank === 1 && r.within }))} />
<ol className="idn-ranks" aria-label={t('ranks.aria')}>
{ranked.slice(0, 5).map(r => (
<li key={r.id} data-within={r.within ? 'true' : 'false'} data-testid="rank-row">
<span className="idn-ranks__n dk-mono">#{r.rank}</span>
<span className="idn-ranks__name">{nameOf(r)}</span>
<span className="dk-mono">{r.distance.toFixed(2)}</span>
{r.within ? <span className="dk-badge dk-badge--ok"><Icon name="check" /> {t('ranks.within')}</span> : <span className="dk-badge">{t('ranks.far')}</span>}
</li>
))}
</ol>
<div className="idn-result__tune">
<CutoffControl value={findCut} onChange={setFindCut} modelDefault={cfg.cutoff} kind={`${kind}-find`} />
<HowToRead print={noun} />
</div>
</>
)}
<WhyWrong kind={kind} notes={probeFacts ? (probeFacts.seconds < 3 ? [t('why.short', { s: probeFacts.seconds.toFixed(1) })] : []) : []} />
{(!best || !best.within) && (
<button type="button" className="dk-btn dk-btn--secondary idn-result__enrol" onClick={() => openSheet({ sample: probe })}>
<Icon name="plus" /> {t(`${kind}.enrolThis`)}
</button>
)}
</div>
)}
</div>
)}
{tool === 'same' && (
<div className="idn-tool__body" role="tabpanel">
<div className="idn-pair">
<ClipInput kind={cfg.mode} label={t(isFace ? 'tool.photoA' : 'tool.clipA')} value={clipA} onChange={(v) => { setClipA(v); setPair({ status: 'idle' }) }} idPrefix={`${kind}-a`} />
<ClipInput kind={cfg.mode} label={t(isFace ? 'tool.photoB' : 'tool.clipB')} value={clipB} onChange={(v) => { setClipB(v); setPair({ status: 'idle' }) }} idPrefix={`${kind}-b`} />
</div>
{isFace && (
<label className="dk-choice idn-tool__option">
<input className="dk-check" type="checkbox" checked={antiSpoof} onChange={(e) => setAntiSpoof(e.target.checked)} />
<span>{t('face.antiSpoof')} <span className="dk-hint">{t('face.antiSpoofHint')}</span></span>
</label>
)}
<div className="idn-tool__go">
<button type="button" className="dk-btn dk-btn--primary" onClick={compare} disabled={!model || !clipA || !clipB || pair.status === 'working'} data-testid="compare-go">
{pair.status === 'working' ? busyText(t('tool.comparing')) : <>{t('tool.compare')} <Icon name="arrow-right" /></>}
</button>
<span className="dk-hint">{t(`${kind}.sameNote`)}</span>
</div>
{pair.status === 'working' && (
<div className="idn-working" aria-busy="true" data-testid="compare-working">
<div className="dk-progress" role="progressbar" aria-label={t('tool.workingAria')} data-indeterminate><span className="dk-progress-bar" /></div>
</div>
)}
{pair.status === 'failed' && <ErrorNote error={pair.error} kind={kind} onRetry={compare} />}
{pairResult && (
<div className="idn-result" data-testid="compare-result">
<Verdict tone={pairWithin ? 'match' : 'miss'} title={t(pairWithin ? `${kind}.same` : `${kind}.different`)} word={pairWord === 'far' ? null : pairWord}>
{t('same.text', { print: noun, distance: pairResult.distance.toFixed(2), cutoff: pairActive.toFixed(2) })}
</Verdict>
<DistanceScale single cutoff={pairActive} noun={noun} dots={[{ id: 'pair', label: t('same.distance'), distance: pairResult.distance, within: pairWithin, best: pairWithin }]} />
{isFace && BoundingBoxCanvas && (pairResult.img1_area || pairResult.img2_area) && (
<div className="idn-pair idn-pair--boxes">
{[[clipA, pairResult.img1_area, pairResult.img1_is_real], [clipB, pairResult.img2_area, pairResult.img2_is_real]].map(([clip, area, real], i) => (
<figure key={i} className="idn-face">
<BoundingBoxCanvas src={clip?.dataUrl} boxes={area ? [{ ...area, label: real === false ? t('face.spoof') : null, tone: 'accent' }] : []} alt={t('face.found', { n: i + 1 })} />
{antiSpoof && real != null && <figcaption><span className={`dk-badge dk-badge--${real ? 'ok' : 'error'}`}>{real ? t('face.real') : t('face.spoof')}</span></figcaption>}
</figure>
))}
</div>
)}
<div className="idn-result__tune">
<CutoffControl value={pairActive} onChange={setPairCut} modelDefault={pairModelCut} kind={`${kind}-pair`} />
<HowToRead print={noun} />
</div>
<WhyWrong kind={kind} notes={[]} />
</div>
)}
</div>
)}
</section>}
<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>
</div>
<p className="dk-hint idn-known__note" data-testid="registry-note">{t(reg.searched ? 'known.noteSearched' : 'known.note', { noun })}</p>
<KnownList
entries={visibleEntries} extras={reg.extras} missing={reg.missing} waiting={reg.waiting}
bestId={best?.within ? best.id : null} noun={noun} kind={kind}
onDelete={(row) => reg.schedule(row)} onUndo={reg.undo} onAgain={again} onEnrol={() => openSheet({})} canEnrol={!!model}
/>
</section>
<Stored kind={kind} print={noun} />
{more}
{reg.latest && (
<ctx.UndoBar
key={reg.latest.id}
text={t('toast.removing', { name: reg.latest.name })}
undoLabel={t('known.undo')}
onUndo={() => reg.undo(reg.latest.id)}
/>
)}
{working && <span className="dk-sr-only" role="status">{t('tool.workingAria')}</span>}
</div>
)
}
return { Workbench }
}
+146
View File
@@ -0,0 +1,146 @@
// SPDX-License-Identifier: MIT
// Pure helpers for the Voices and Faces pages: how a distance reads in plain
// words, how the scale is drawn, what the browser keeps about a person, and
// how a failed call is explained. No React here, so node can test it.
// The cut-offs the server applies when a request does not send one.
export const VOICE_CUTOFF = 0.25
export const FACE_CUTOFF = 0.35
// Distance in plain words. A distance is how far apart two voiceprints (or
// faceprints) are: 0 means identical. The word says how far inside or outside
// the cut-off a result sits. It is not a probability.
export function strength(distance, cutoff) {
if (!(cutoff > 0) || !Number.isFinite(distance)) return 'far'
const ratio = distance / cutoff
if (ratio <= 0.6) return 'strong'
if (ratio <= 0.9) return 'likely'
if (ratio <= 1) return 'close'
if (ratio <= 1.25) return 'near'
return 'far'
}
export const isWithin = (distance, cutoff) => Number.isFinite(distance) && distance <= cutoff
// Sort by distance and mark each row against the cut-off. The server marks
// matches against the cut-off it was sent; marking again here lets the cut-off
// slider move without another call.
export function rankMatches(matches, cutoff) {
return [...(matches || [])]
.filter(m => Number.isFinite(m.distance))
.sort((a, b) => a.distance - b.distance)
.map((m, i) => ({ ...m, rank: i + 1, within: isWithin(m.distance, cutoff), word: strength(m.distance, cutoff) }))
}
// The top of the scale: far enough to show the cut-off and every dot, rounded
// up to a tenth so the ticks land on round numbers.
export function scaleMax(cutoff, distances = []) {
const far = Math.max(cutoff * 2.4, ...distances.map(d => d * 1.12), 0.1)
return Math.min(2, Math.ceil(far * 10) / 10)
}
export function scaleTicks(max) {
const step = max > 1 ? 0.5 : max > 0.5 ? 0.1 : 0.05
const out = []
for (let v = 0; v <= max + 1e-9; v += step) out.push(Math.round(v * 1000) / 1000)
return out
}
export const pct = (value, max) => `${Math.max(0, Math.min(100, (value / max) * 100))}%`
// Label rows so close dots do not print on top of each other: a label that
// would sit within `gap` percent of the last one on its row drops to the next
// of three rows, and the row with the most room when all three are crowded.
export function labelRows(positions, gap = 24) {
const last = [-Infinity, -Infinity, -Infinity]
return positions.map((p) => {
let row = last.findIndex(l => p - l >= gap)
if (row === -1) row = last.indexOf(Math.min(...last))
last[row] = p
return row
})
}
export function ageText(iso, now = Date.now(), locale) {
const t = new Date(iso).getTime()
if (!Number.isFinite(t)) return ''
const days = Math.round((now - t) / 86400000)
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' })
if (Math.abs(days) >= 1) return rtf.format(-days, 'day')
const hours = Math.round((now - t) / 3600000)
if (hours >= 1) return rtf.format(-hours, 'hour')
return rtf.format(0, 'second') === 'now' ? 'just now' : rtf.format(0, 'second')
}
export function parseLabels(text) {
const out = {}
if (!text) return out
for (const line of text.split('\n')) {
const idx = line.indexOf(':')
if (idx === -1) continue
const k = line.slice(0, idx).trim()
const v = line.slice(idx + 1).trim()
if (k) out[k] = v
}
return out
}
export const labelsText = (labels) => Object.entries(labels || {}).map(([k, v]) => `${k}: ${v}`).join(', ')
export const initials = (name) => (name || '?').trim().split(/\s+/).map(p => p[0] || '').join('').slice(0, 2).toUpperCase() || '?'
// The browser-side list of people. The server has no list call, so this is a
// record of what this browser enrolled, kept in localStorage. `keep` is the
// slice of stored fields: a name, labels, a time, and only when the person
// chose it, a copy of the sample.
export function loadList(key, storage = globalThis.localStorage) {
try {
const parsed = JSON.parse(storage.getItem(key) || '[]')
return Array.isArray(parsed) ? parsed : []
} catch { return [] }
}
export function saveList(key, list, storage = globalThis.localStorage) {
try { storage.setItem(key, JSON.stringify(list.slice(0, 50))) } catch { /* quota or blocked */ }
}
// Which saved people did the last search not return? Only a search that asked
// for more people than it got back can say so: then the server returned
// everything it holds for this model. Otherwise nothing is claimed.
export function missingFromServer(entries, result) {
if (!result || !result.complete) return new Set()
const seen = new Set(result.ids)
return new Set(entries.filter(e => !seen.has(e.id)).map(e => e.id))
}
// A plain sentence for a failed call. `kind` is 'voice' or 'face'.
export function explainError(err, kind) {
const status = err?.status
const message = String(err?.message || '')
if (kind === 'face' && /no face|face not|could not (find|detect)/i.test(message)) {
return { title: 'No face found in this photo', body: 'Try a sharper, front-facing photo with the face in clear light.' }
}
if (kind === 'voice' && /too short|no speech|empty audio|no audio/i.test(message)) {
return { title: 'The clip has too little speech', body: 'Use a clip with at least a few seconds of one person speaking.' }
}
if (status === 404 || /model .*(not found|not loaded)|no such model/i.test(message)) {
return { title: 'The server cannot find this model', body: 'Pick another model, or install it from the model list.' }
}
if (status === 501 || status === 412) {
return { title: 'This model cannot do that', body: 'It does not support this call. Pick a model made for recognition.' }
}
if (status === 400 || status === 422) {
return { title: 'The server could not read the sample', body: 'Check that the file is a normal audio or image file, then try again.' }
}
return { title: 'The call failed', body: 'The server answered with an error. Try again; if it repeats, open the trace.' }
}
// Read a clip in the browser: how long it is and how loud. Nothing is sent.
export function judgeClip({ seconds, peak, clipped }, minSeconds = 3) {
const notes = []
if (!(seconds > 0)) return { level: 'unknown', notes }
if (seconds < minSeconds) notes.push(`Short: ${seconds.toFixed(1)} s. Three seconds or more of speech works better.`)
if (peak != null && peak < 0.05) notes.push('Very quiet. Move closer to the microphone or raise the level.')
if (clipped) notes.push('The sound is clipped, which distorts the voice.')
return { level: notes.length ? 'warn' : 'good', notes }
}
@@ -0,0 +1,42 @@
// A reference recording for a speech voice: decoded and written back as 24 kHz
// 16-bit mono WAV, the form the voice library stores. Ported from
// utils/referenceAudio.js; the WAV writer is the host's, handed in by the SDK
// as ctx.hooks.audioBufferToWavBlob.
export const MAX_AUDIO_BYTES = 50 * 1024 * 1024
const REFERENCE_SAMPLE_RATE = 24000
function base64ToArrayBuffer(value) {
const binary = window.atob(value)
const bytes = new Uint8Array(binary.length)
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
return bytes.buffer
}
export function createReferenceAudio(ctx) {
async function normalizeAudioSample(sample) {
const source = sample.blob?.arrayBuffer
? await sample.blob.arrayBuffer()
: base64ToArrayBuffer(sample.base64)
const AudioCtx = window.AudioContext || window.webkitAudioContext
if (!AudioCtx) throw new Error('Web Audio API is not available in this browser')
const context = new AudioCtx()
try {
const decoded = await context.decodeAudioData(source.slice(0))
const blob = ctx.hooks.audioBufferToWavBlob(decoded, REFERENCE_SAMPLE_RATE)
if (blob.size > MAX_AUDIO_BYTES) throw new Error('Normalized audio is larger than 50 MiB')
return {
...sample,
blob,
dataUrl: URL.createObjectURL(blob),
objectUrl: true,
mime: 'audio/wav',
duration: decoded.duration,
sampleRate: REFERENCE_SAMPLE_RATE,
name: sample.name || 'recording.wav',
}
} finally {
await context.close().catch(() => {})
}
}
return { MAX_AUDIO_BYTES, normalizeAudioSample }
}
+122
View File
@@ -0,0 +1,122 @@
// The calls and the local record that the identity views share. Ported from
// components/identity/useRegistry.js and the voiceApi/faceApi helpers of
// utils/api.js. Calls go to the core routes (/v1/voice/*, /v1/face/*) with the
// signed-in user's own session, through ctx.core.fetch.
import { loadList, missingFromServer, saveList } from './identity.js'
// A failed call keeps its status and parsed body, the way utils/api.js
// handleResponse does: the registry reads status 404, explainError reads the
// status and the message.
async function post(ctx, path, body) {
const response = await ctx.core.fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) {
let message = `HTTP ${response.status}`
let errorBody = null
try {
errorBody = await response.json()
if (errorBody?.error?.message) message = errorBody.error.message
else if (errorBody?.error) message = errorBody.error
} catch { /* the answer was not JSON */ }
const err = new Error(message)
err.status = response.status
err.body = errorBody
throw err
}
// forget answers 204 with no body; the core helper hands back the response
// for anything that is not JSON, and nothing here reads it.
const type = response.headers?.get?.('content-type')
if (response.status === 204 || (type && !type.includes('application/json'))) return null
return response.json()
}
const CALLS = ['verify', 'analyze', 'embed', 'register', 'identify', 'forget']
// { voice: { verify, analyze, embed, register, identify, forget }, face: {...} }
export function createIdentityApi(ctx) {
const forKind = (kind) => Object.fromEntries(CALLS.map((call) => [call, (body) => post(ctx, `/v1/${kind}/${call}`, body)]))
return { voice: forKind('voice'), face: forKind('face') }
}
export function createIdentityRegistry(ctx) {
const { useCallback, useEffect, useMemo, useState } = ctx.React
// The people this browser enrolled, plus what the last search learned about
// the server. The server has no list call, so the list is a local record: it
// can be out of date after a restart, and the only evidence is a search that
// asked for everyone and did not get someone back.
//
// storageKey localStorage key of the saved list
// forget the real call, `({ id }) => Promise`
// onForgot (entry, outcome) after the call: 'gone', 'already-gone' or an Error
function useRegistry({ storageKey, forget, onForgot }) {
const [entries, setEntries] = useState(() => loadList(storageKey))
const [search, setSearch] = useState(null)
const [latest, setLatest] = useState(null)
useEffect(() => { saveList(storageKey, entries) }, [storageKey, entries])
const run = useCallback(async (_key, entry) => {
try {
await forget({ id: entry.id })
setEntries(prev => prev.filter(e => e.id !== entry.id))
setSearch(prev => (prev ? { ...prev, extras: prev.extras.filter(e => e.id !== entry.id) } : prev))
onForgot?.(entry, 'gone')
} catch (err) {
if (err?.status === 404) {
setEntries(prev => prev.filter(e => e.id !== entry.id))
onForgot?.(entry, 'already-gone')
} else {
onForgot?.(entry, err)
}
}
}, [forget, onForgot])
const forgetting = ctx.hooks.useDelayedAction(run)
const waiting = forgetting.waiting || {}
const add = useCallback((entry) => setEntries(prev => [entry, ...prev.filter(e => e.id !== entry.id)]), [])
// Swap a row for the same person under the id the server just made.
const replace = useCallback((oldId, entry) => setEntries(prev => prev.map(e => (e.id === oldId ? entry : e))), [])
const schedule = useCallback((entry) => {
setLatest(entry)
forgetting.schedule(entry.id, entry)
}, [forgetting.schedule]) // eslint-disable-line react-hooks/exhaustive-deps
const undo = useCallback((id) => {
forgetting.cancel?.(id)
setLatest(prev => (prev?.id === id ? null : prev))
}, [forgetting.cancel]) // eslint-disable-line react-hooks/exhaustive-deps
const finish = useCallback((id) => {
forgetting.flush?.(id)
setLatest(prev => (prev?.id === id ? null : prev))
}, [forgetting.flush]) // eslint-disable-line react-hooks/exhaustive-deps
// Record what a search returned. `asked` is the top_k it sent.
const noteSearch = useCallback((matches, asked) => {
const ids = matches.map(m => m.id)
const known = new Set(entries.map(e => e.id))
setSearch({
ids,
complete: matches.length < asked,
extras: matches.filter(m => !known.has(m.id)).map(m => ({ id: m.id, name: m.name || m.id, labels: m.labels || {} })),
})
}, [entries])
const missing = useMemo(() => missingFromServer(entries, search), [entries, search])
return {
entries, add, replace, missing, searched: !!search, extras: search?.extras || [],
waiting, latest: latest && waiting[latest.id] ? latest : null,
schedule, undo, finish, noteSearch,
}
}
return { useRegistry }
}
@@ -0,0 +1,49 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import * as identity from '../src/identity.js'
test('loadList returns an empty list for a missing or corrupt value', () => {
assert.deepEqual(identity.loadList('localai_voice_enrollments', { getItem: () => '{not json' }), [])
assert.deepEqual(identity.loadList('localai_voice_enrollments', { getItem: () => null }), [])
assert.deepEqual(identity.loadList('localai_voice_enrollments', { getItem: () => '{"a":1}' }), [])
})
test('loadList keeps a stored list', () => {
assert.deepEqual(identity.loadList('k', { getItem: () => JSON.stringify([{ id: 'a' }]) }), [{ id: 'a' }])
})
test('saveList keeps at most 50 people and never throws', () => {
let saved
identity.saveList('k', Array.from({ length: 60 }, (_, i) => ({ id: i })), { setItem: (_k, v) => { saved = v } })
assert.equal(JSON.parse(saved).length, 50)
assert.doesNotThrow(() => identity.saveList('k', [], { setItem: () => { throw new Error('quota') } }))
})
test('missingFromServer claims nothing from an incomplete search', () => {
assert.equal(identity.missingFromServer([{ id: 'a' }], { complete: false, ids: [] }).size, 0)
assert.deepEqual([...identity.missingFromServer([{ id: 'a' }, { id: 'b' }], { complete: true, ids: ['a'] })], ['b'])
})
test('the identity calls post JSON to the core voice and face routes', async () => {
const { createIdentityApi } = await import('../src/registry.js')
const seen = []
const ctx = { core: { fetch: async (path, init) => { seen.push([path, init.method, JSON.parse(init.body)]); return { ok: true, json: async () => ({ ok: 1 }) } } } }
const api = createIdentityApi(ctx)
await api.voice.identify({ model: 'm', audio: 'x' })
await api.face.forget({ id: 'a' })
assert.deepEqual(seen, [['/v1/voice/identify', 'POST', { model: 'm', audio: 'x' }], ['/v1/face/forget', 'POST', { id: 'a' }]])
})
test('a failed identity call keeps its status and message, so a 404 on forget can be told apart', async () => {
const { createIdentityApi } = await import('../src/registry.js')
const ctx = { core: { fetch: async () => ({ ok: false, status: 404, json: async () => ({ error: { message: 'no such id' } }) }) } }
await assert.rejects(createIdentityApi(ctx).voice.forget({ id: 'a' }), (err) => err.status === 404 && err.message === 'no such id')
const plain = { core: { fetch: async () => ({ ok: false, status: 500, json: async () => { throw new Error('not json') } }) } }
await assert.rejects(createIdentityApi(plain).voice.verify({}), (err) => err.status === 500 && err.message === 'HTTP 500')
})
test('an empty answer (forget replies 204) is not parsed as JSON', async () => {
const { createIdentityApi } = await import('../src/registry.js')
const ctx = { core: { fetch: async () => ({ ok: true, status: 204, json: async () => { throw new Error('empty body') } }) } }
assert.equal(await createIdentityApi(ctx).voice.forget({ id: 'a' }), null)
})
+49
View File
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: MIT
// Package idle is the executable of an add-on that has a UI and no tools. The
// contract requires an executable for every add-on, so such an add-on ships
// this one: it speaks the add-on protocol, declares nothing and refuses every
// operation. All of its work happens in the browser, through the SDK.
package idle
import (
"context"
"errors"
"io"
"github.com/mudler/LocalAI/addons/internal/protocol"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// ErrNoTools is what every operation answers.
var ErrNoTools = errors.New("this add-on declares no tools")
// Runtime implements contract.Runtime with no tools.
type Runtime struct{}
func (Runtime) Read(context.Context, contract.Envelope) (contract.Result, error) {
return contract.Result{}, ErrNoTools
}
func (Runtime) Preview(context.Context, contract.Envelope) (contract.Preview, error) {
return contract.Preview{}, ErrNoTools
}
func (Runtime) Apply(context.Context, contract.Envelope, contract.Revision) (contract.Receipt, error) {
return contract.Receipt{}, ErrNoTools
}
func (Runtime) Receipt(context.Context, string) (contract.Receipt, error) {
return contract.Receipt{}, ErrNoTools
}
func (Runtime) OpenOutput(context.Context, contract.Envelope, string) (io.ReadCloser, error) {
return nil, ErrNoTools
}
// Run is the whole behaviour of a UI add-on executable. It is protocol.Run
// with the runtime that refuses every operation. It returns the process exit
// code.
func Run(args []string) int {
return protocol.Run(args, func(protocol.Startup) contract.Runtime { return Runtime{} })
}
+41
View File
@@ -0,0 +1,41 @@
// SPDX-License-Identifier: MIT
package idle
import (
"context"
"errors"
"testing"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
var _ contract.Runtime = Runtime{}
func TestEveryOperationRefusesBecauseTheAddonDeclaresNoTools(t *testing.T) {
ctx := context.Background()
rt := Runtime{}
if _, err := rt.Read(ctx, contract.Envelope{}); !errors.Is(err, ErrNoTools) {
t.Fatalf("Read: %v", err)
}
if _, err := rt.Preview(ctx, contract.Envelope{}); !errors.Is(err, ErrNoTools) {
t.Fatalf("Preview: %v", err)
}
if _, err := rt.Apply(ctx, contract.Envelope{}, contract.Revision{}); !errors.Is(err, ErrNoTools) {
t.Fatalf("Apply: %v", err)
}
if _, err := rt.Receipt(ctx, "x"); !errors.Is(err, ErrNoTools) {
t.Fatalf("Receipt: %v", err)
}
if _, err := rt.OpenOutput(ctx, contract.Envelope{}, "x"); !errors.Is(err, ErrNoTools) {
t.Fatalf("OpenOutput: %v", err)
}
}
func TestRunRefusesWithoutTheProtocolFlag(t *testing.T) {
if got := Run([]string{"voice"}); got != 2 {
t.Fatalf("exit code %d, want 2", got)
}
if got := Run([]string{"voice", "--other"}); got != 2 {
t.Fatalf("exit code %d, want 2", got)
}
}
+211
View File
@@ -0,0 +1,211 @@
// SPDX-License-Identifier: MIT
// Package protocol speaks the LocalAI addon runtime protocol: the
// startup handshake, authenticated loopback requests, previews, applies,
// receipts and outputs. It mirrors the host's wire types instead of importing
// the owner's packages, so an add-on executable links none of the host.
package protocol
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"io"
"net"
"net/http"
"os"
"path/filepath"
"time"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// Registration, Startup, ApplyRequest, ReceiptRequest and OutputRequest are the
// wire shapes the owner sends. Field names are the protocol and must not drift;
// the integration suite starts the real executable under the real supervisor.
type Registration struct {
AddonID string `json:"addonId"`
Generation uint64 `json:"generation"`
Incarnation string `json:"incarnation"`
Protocol int `json:"protocol"`
Endpoint string `json:"endpoint"`
Capabilities contract.Capabilities `json:"capabilities"`
}
type Startup struct {
Registration Registration `json:"registration"`
Credential string `json:"credential"`
Limits contract.Limits `json:"limits"`
DataDir string `json:"dataDir,omitempty"`
// HostEndpoint and HostCredential are set only for an add-on that the host
// setting names. They reach the host operation channel.
HostEndpoint string `json:"hostEndpoint,omitempty"`
HostCredential string `json:"hostCredential,omitempty"`
}
type ApplyRequest struct {
Envelope contract.Envelope `json:"envelope"`
Revision contract.Revision `json:"revision"`
}
type ReceiptRequest struct {
OperationID string `json:"operationId"`
}
type OutputRequest struct {
Envelope contract.Envelope `json:"envelope"`
Handle string `json:"handle"`
}
// Errors a Runtime returns map to protocol status codes. Any other error is a
// 503: the owner treats it as uncertain and never as a rejection.
var (
ErrBadRequest = errors.New("invalid request")
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrForbidden = errors.New("forbidden")
)
// ReadStartup decodes the startup object from the inherited descriptor and
// returns whatever the decoder buffered beyond it, for the parent-death watch.
func ReadStartup(parent io.Reader) (Startup, io.Reader, error) {
d := json.NewDecoder(parent)
var s Startup
if err := d.Decode(&s); err != nil {
return s, nil, err
}
return s, io.MultiReader(d.Buffered(), parent), nil
}
// ExitWithParent ends the process when the owner closes the startup channel.
func ExitWithParent(rest io.Reader) {
go func() { _, _ = io.Copy(io.Discard, rest); os.Exit(0) }()
}
// Handler authenticates every request with the incarnation credential and
// header, then validates the envelope's binding to this addon and generation.
func Handler(startup Startup, rt contract.Runtime) http.Handler {
reg := startup.Registration
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := subtle.ConstantTimeCompare([]byte(r.Header.Get("Authorization")), []byte("Bearer "+startup.Credential))
inc := subtle.ConstantTimeCompare([]byte(r.Header.Get("X-LocalAI-Incarnation")), []byte(reg.Incarnation))
if auth&inc != 1 || startup.Credential == "" {
http.Error(w, "denied", http.StatusUnauthorized)
return
}
if r.Method != http.MethodPost || r.Header.Get("Content-Type") != "application/json" {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
r.Body = http.MaxBytesReader(w, r.Body, startup.Limits.EnvelopeBytes)
defer func() { _ = r.Body.Close() }()
decoder := json.NewDecoder(r.Body)
decoder.DisallowUnknownFields()
read := func(v any) bool {
if decoder.Decode(v) != nil || decoder.Decode(new(any)) != io.EOF {
http.Error(w, "invalid request", http.StatusBadRequest)
return false
}
return true
}
valid := func(v contract.Envelope, scope string) bool {
if v.Validate(time.Now(), startup.Limits) != nil || v.Authority.Scope != scope || v.Request.AddonID != reg.AddonID || v.Request.Generation != reg.Generation {
http.Error(w, "invalid authority", http.StatusForbidden)
return false
}
return true
}
reply := func(v any, err error) {
switch {
case err == nil:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
case errors.Is(err, ErrBadRequest):
http.Error(w, "invalid request", http.StatusBadRequest)
case errors.Is(err, ErrNotFound):
http.Error(w, "not found", http.StatusNotFound)
case errors.Is(err, ErrForbidden):
http.Error(w, "forbidden", http.StatusForbidden)
case errors.Is(err, ErrConflict):
http.Error(w, "conflict", http.StatusConflict)
default:
http.Error(w, "unavailable", http.StatusServiceUnavailable)
}
}
ctx := r.Context()
switch r.URL.Path {
case "/health":
reply(reg, nil)
case "/read":
var v contract.Envelope
if read(&v) && valid(v, "read") {
out, err := rt.Read(ctx, v)
reply(out, err)
}
case "/preview":
var v contract.Envelope
if read(&v) && valid(v, "preview") {
out, err := rt.Preview(ctx, v)
reply(out, err)
}
case "/apply":
var v ApplyRequest
if read(&v) && valid(v.Envelope, "apply") {
out, err := rt.Apply(ctx, v.Envelope, v.Revision)
reply(out, err)
}
case "/receipt":
var v ReceiptRequest
if read(&v) {
out, err := rt.Receipt(ctx, v.OperationID)
reply(out, err)
}
case "/output":
var v OutputRequest
if !read(&v) || !valid(v.Envelope, "output") {
return
}
body, err := rt.OpenOutput(ctx, v.Envelope, v.Handle)
if err != nil {
reply(nil, err)
return
}
defer func() { _ = body.Close() }()
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = io.Copy(w, io.LimitReader(body, startup.Limits.ArtifactBytes+1))
default:
http.NotFound(w, r)
}
})
}
// Serve runs the protocol on an already bound listener until ctx ends.
func Serve(ctx context.Context, l net.Listener, startup Startup, rt contract.Runtime) error {
srv := &http.Server{Handler: Handler(startup, rt), ReadHeaderTimeout: time.Second, ReadTimeout: 2 * time.Second, IdleTimeout: time.Second, MaxHeaderBytes: 16 << 10}
go func() { <-ctx.Done(); _ = srv.Close() }()
if err := srv.Serve(l); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
// Run is the whole behaviour of an add-on executable: the owner starts it with
// "--localai-addon-v1", descriptor 3 is a bound loopback listener and
// descriptor 4 carries the startup object. The factory builds the runtime
// from that object, so a runtime can use the host fields of it. It returns the
// process exit code.
func Run(args []string, factory func(Startup) contract.Runtime) int {
if len(args) != 2 || args[1] != "--localai-addon-v1" {
return 2
}
startup, rest, err := ReadStartup(os.NewFile(4, "startup"))
if err != nil || !filepath.IsAbs(startup.DataDir) {
return 3
}
ExitWithParent(rest)
listener, err := net.FileListener(os.NewFile(3, "listener"))
if err != nil {
return 6
}
if err := Serve(context.Background(), listener, startup, factory(startup)); err != nil {
return 8
}
return 0
}
+21
View File
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: MIT
package protocol
import (
"testing"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
func TestRunRefusesWithoutTheProtocolFlagAndBuildsNoRuntime(t *testing.T) {
built := false
factory := func(Startup) contract.Runtime { built = true; return nil }
for _, args := range [][]string{{"x"}, {"x", "--other"}, {"x", "--localai-addon-v1", "extra"}} {
if got := Run(args, factory); got != 2 {
t.Fatalf("%v: exit code %d, want 2", args, got)
}
}
if built {
t.Fatal("the factory ran before the protocol flag was checked")
}
}
+77
View File
@@ -0,0 +1,77 @@
import { formatBytes } from './format.js'
const ICON = { ok: 'check-circle', warn: 'warning', fail: 'alert-circle', info: 'info' }
// Byte counts arrive raw; the text shows them as sizes.
function withSizes(vals) {
const out = { ...(vals || {}) }
for (const key of ['free', 'total', 'used']) {
if (typeof out[key] === 'number') out[key] = formatBytes(out[key])
else if (key in out) out[key] = '?'
}
return out
}
function valueText(value) {
if (!value) return ''
if (typeof value === 'string') return value
return `${formatBytes(value.free)} / ${formatBytes(value.total)}`
}
// A checklist: one row per check with a mark, a title, one sentence, an optional
// value and an optional meter of how much of a resource is in use. Rows are
// computed from facts the server reported, so the list can be redrawn on every
// change of the form. The backends page belongs to the host, so the link
// leaves the add-on.
export function createChecks(ctx) {
const { h, Fragment, t, Icon } = ctx
// The host translator picks a variant by context (`key_gpu`); the SDK one has
// no such rule, so the variant is tried first and the plain key second.
const withContext = (key, vars, context) => {
if (context) {
const variant = `${key}_${context}`
const text = t(variant, vars)
if (text !== variant) return text
}
return t(key, vars)
}
function Checks({ checks, isAdmin = false, label, testId = 'tool-checks' }) {
return (
<ul className="bt-checks" aria-label={label} data-testid={testId}>
{checks.map(check => {
const vals = withSizes(check.vals)
const used = check.meter?.total > 0 ? Math.min(100, Math.round((check.meter.used / check.meter.total) * 100)) : null
return (
<li key={check.id} className="bt-check" data-tone={check.tone} data-check={check.id}>
<span className="bt-check__mark"><Icon name={ICON[check.tone] || 'info'} /></span>
<div className="bt-check__main">
<div className="bt-check__head">
<span className="bt-check__title">{t(`checks.title.${check.id}`)}</span>
{check.value && <span className="bt-check__value dk-mono">{valueText(check.value)}</span>}
</div>
<p className="bt-check__text">
{withContext(`checks.${check.key}`, vals, check.vals?.kind || check.vals?.where)}
{check.action === 'backends' && isAdmin && (
<> <a className="dk-link" href={`${ctx.basePath}/app/backends`}>{t('checks.openBackends')}</a></>
)}
</p>
{used != null && (
<div
className="dk-meter bt-check__meter"
role="img"
aria-label={t('checks.meterLabel', { used, free: formatBytes(Math.max(0, check.meter.total - check.meter.used)), total: formatBytes(check.meter.total) })}
>
<span className={`dk-meter-seg${check.id === 'disk' ? ' dk-meter-seg--b' : ''}`} style={{ '--dk-w': `${used}%` }} />
</div>
)}
</div>
</li>
)
})}
</ul>
)
}
return Checks
}
+76
View File
@@ -0,0 +1,76 @@
import { logText } from './tools.js'
const pad = (n) => String(n).padStart(2, '0')
const stamp = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
// What the job has reported since this page opened, one line per status change
// or message. The server sends progress events and keeps no log, so a line from
// before the page opened is not here, and the heading says so.
export function createJobLog(ctx) {
const { h, t, Icon } = ctx
const { useEffect, useRef, useState } = ctx.React
function JobLog({ lines, name = 'job' }) {
const [only, setOnly] = useState(false)
const [copied, setCopied] = useState(false)
const bodyRef = useRef(null)
const shown = only ? lines.filter(l => l.level !== 'info') : lines
useEffect(() => {
const el = bodyRef.current
if (el) el.scrollTop = el.scrollHeight
}, [shown.length])
const copy = () => {
navigator.clipboard?.writeText(logText(shown)).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}).catch(() => {})
}
const download = () => {
const url = URL.createObjectURL(new Blob([logText(shown)], { type: 'text/plain' }))
const a = document.createElement('a')
a.href = url
a.download = `${name}-log.txt`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
return (
<section className="bt-log dk-card" aria-labelledby="bt-log-title" data-testid="job-log">
<header className="bt-log__head">
<div>
<h2 className="bt-h2" id="bt-log-title">{t('log.title')}</h2>
<p className="dk-hint">{t('log.note')}</p>
</div>
<div className="bt-log__tools">
<label className="dk-choice">
<input className="dk-check" type="checkbox" checked={only} onChange={e => setOnly(e.target.checked)} data-testid="job-log-filter" />
{t('log.only')}
</label>
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={copy} disabled={shown.length === 0}>
<Icon name={copied ? 'check' : 'copy'} /> {copied ? t('log.copied') : t('log.copy')}
</button>
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={download} disabled={shown.length === 0}>
<Icon name="download" /> {t('log.download')}
</button>
</div>
</header>
<div className="bt-log__body" ref={bodyRef} role="log" aria-live="off" tabIndex={0} aria-label={t('log.title')}>
{shown.length === 0 ? (
<p className="bt-log__empty">{only ? t('log.noneFiltered') : t('log.empty')}</p>
) : shown.map((line, i) => (
<div key={i} className="bt-log__line" data-level={line.level}>
<span className="bt-log__time">{stamp(line.at)}</span>
<span className="bt-log__tag">{line.tag}</span>
<span className="bt-log__text">{line.text}</span>
</div>
))}
</div>
</section>
)
}
return JobLog
}
@@ -0,0 +1,24 @@
// The stages a job passes through, as a row of segments. The current stage is
// named in bold; stages before it are filled. `stages` are status ids and
// `labels` maps each to its words.
export function createStageLine(ctx) {
const { h } = ctx
function StageLine({ stages, labels, status, failed = false, label }) {
const at = stages.indexOf(status)
return (
<ol className="bt-stages" aria-label={label} data-testid="job-stages">
{stages.map((stage, i) => {
const state = i < at ? 'done' : i === at ? (failed ? 'failed' : 'current') : 'todo'
return (
<li key={stage} className="bt-stage" data-state={state} aria-current={state === 'current' ? 'step' : undefined}>
<span className="bt-stage__bar" aria-hidden="true" />
<span className="bt-stage__label">{labels[stage]}</span>
</li>
)
})}
</ol>
)
}
return StageLine
}
+27
View File
@@ -0,0 +1,27 @@
// Shown in place of a Build tool the signed-in person may not use. It replaces a
// silent redirect: it says that the account lacks the permission and who can
// grant it, and points at the tools the person can open. `tool` is 'fineTune' or
// 'quantize'. When `feature` is given and the viewer holds it, the children are
// drawn instead of the notice, so a page wraps its body in this component. The
// way back is the host's home page, so the link leaves the add-on.
export function createToolOff(ctx) {
const { h, Fragment, t, Icon } = ctx
function ToolOff({ tool, feature, children }) {
if (feature && ctx.viewer.hasFeature(feature)) return <>{children}</>
return (
<main className="page page--medium bt-page bt-off" data-testid="tool-off" data-cause="account">
<section className="bt-off__card dk-card">
<span className="dk-badge dk-badge--warn"><Icon name="lock" /> {t('off.badge')}</span>
<h1 className="bt-h1">{t(`off.${tool}.title`)}</h1>
<p className="bt-off__text">{t(`off.${tool}.text`)}</p>
<p className="dk-hint">{ctx.viewer.isAdmin() ? t('off.admin') : t('off.askAdmin')}</p>
<div className="bt-off__acts">
<a className="dk-btn dk-btn--primary" href={`${ctx.basePath}/app`}><Icon name="home" /> {t('off.back')}</a>
</div>
</section>
</main>
)
}
return ToolOff
}
@@ -0,0 +1,25 @@
// The line above a tool that says where the job is: set up, check, run, result.
// `current` is the index of the step the person is on. A step before it is done.
// `failed` marks the current step as failed instead of in progress.
export function createToolSteps(ctx) {
const { h, Icon } = ctx
function ToolSteps({ steps, current, failed = false, label }) {
return (
<ol className="bt-steps" aria-label={label} data-testid="tool-steps">
{steps.map((step, i) => {
const state = i < current ? 'done' : i === current ? (failed ? 'failed' : 'current') : 'todo'
return (
<li key={step.key} className="bt-step" data-state={state} data-step={step.key} aria-current={i === current ? 'step' : undefined}>
<span className="bt-step__mark" aria-hidden="true">
{state === 'done' ? <Icon name="check" /> : state === 'failed' ? <Icon name="close" /> : i + 1}
</span>
<span className="bt-step__label">{step.label}</span>
</li>
)
})}
</ol>
)
}
return ToolSteps
}
+8
View File
@@ -0,0 +1,8 @@
// Copied unchanged from core/http/react-ui/src/utils/format.js (formatBytes).
export function formatBytes(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
+42
View File
@@ -0,0 +1,42 @@
// Reads a server-sent-event stream from a fetch response. EventSource is not
// used: the SDK does not wrap it, and it cannot carry the options of
// sdk.core.fetch (the session and the base path). Each `data:` line holds one
// JSON event; comments and lines that are not JSON are skipped. The read stops
// when the stream ends or the signal aborts.
export async function readSse(response, onEvent, signal) {
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const emit = (block) => {
for (const line of block.split('\n')) {
if (!line.startsWith('data:')) continue
let event
try { event = JSON.parse(line.slice(5).trim()) } catch { continue }
onEvent(event)
}
}
const stop = () => { reader.cancel().catch(() => {}) }
if (signal) {
if (signal.aborted) { stop(); return }
signal.addEventListener('abort', stop, { once: true })
}
try {
for (;;) {
if (signal?.aborted) return
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let at
while ((at = buffer.indexOf('\n\n')) >= 0) {
const block = buffer.slice(0, at)
buffer = buffer.slice(at + 2)
emit(block)
if (signal?.aborted) return
}
}
buffer += decoder.decode()
if (buffer.trim()) emit(buffer)
} finally {
signal?.removeEventListener('abort', stop)
}
}
+268
View File
@@ -0,0 +1,268 @@
/* ==========================================================================
Build tools: the landing, Fine-tune, Quantize and the shared pieces Import
uses. Built on the kit's card, field, table, badge, chip, switch, progress,
meter, segmented, dialog and sheet classes. What is here is what the kit does
not say: the step line, the check list, the stage line, the job card, the
chart and the log. Colours, radii and spacing are kit roles (--dk-*).
Surfaces are flat and nothing sits on a coloured rail at the edge. Motion is
switched off for reduced motion at the end of this file.
========================================================================== */
.bt-page { display: flex; flex-direction: column; gap: var(--dk-space-5); min-width: 0; }
.bt-page :is(a, button, summary, select, input, textarea, [tabindex]):focus-visible { outline: 2px solid var(--dk-accent-text); outline-offset: 2px; }
.bt-page > .page-header { margin-bottom: 0; }
.bt-h1 { margin: 0; font: var(--dk-weight-bold) var(--dk-text-32) / 1.15 var(--dk-font-display); letter-spacing: -.02em; text-wrap: balance; }
.bt-h2 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-17) / 1.3 var(--dk-font-display); color: var(--dk-text); }
.bt-h3 { margin: 0; font: var(--dk-weight-semibold) var(--dk-text-15) / 1.3 var(--dk-font-display); color: var(--dk-text); }
.bt-experimental { vertical-align: middle; margin-left: var(--dk-space-2); }
.bt-head-acts { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; justify-content: flex-end; }
.bt-stack { display: flex; flex-direction: column; gap: var(--dk-space-4); min-width: 0; }
.bt-empty { color: var(--dk-muted); }
.bt-block { display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.bt-block.dk-card { padding: var(--dk-space-4) var(--dk-space-5); }
.bt-block__head { display: flex; align-items: baseline; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-block__head .dk-hint { margin: 0; }
/* ---- Step line -------------------------------------------------------------- */
.bt-steps { list-style: none; margin: 0; padding: 0; display: flex; align-items: center; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-step { display: inline-flex; align-items: center; gap: var(--dk-space-2); color: var(--dk-muted); font-size: var(--dk-text-13); font-weight: var(--dk-weight-semibold); }
.bt-step:not(:last-child)::after { content: ''; width: 48px; height: 2px; margin-left: var(--dk-space-2); border-radius: 2px; background: var(--dk-edge); }
.bt-step[data-state="done"]:not(:last-child)::after { background: var(--dk-accent); }
.bt-step__mark { display: inline-grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--dk-edge); font: var(--dk-weight-bold) var(--dk-text-12) / 1 var(--dk-font-mono); color: var(--dk-muted); box-sizing: border-box; }
.bt-step__mark .lai-icon { font-size: 13px; }
.bt-step[data-state="done"] .bt-step__mark { background: var(--dk-accent); border-color: var(--dk-accent); color: var(--dk-on-accent); }
.bt-step[data-state="current"] { color: var(--dk-text); }
.bt-step[data-state="current"] .bt-step__mark { border-color: var(--dk-accent-text); color: var(--dk-accent-text); }
.bt-step[data-state="failed"] { color: var(--dk-text); }
.bt-step[data-state="failed"] .bt-step__mark { background: var(--dk-error-wash); border-color: var(--dk-error); color: var(--dk-error-text); }
.bt-step[data-state="done"] { color: var(--dk-text); }
/* ---- Form sections ----------------------------------------------------------- */
.bt-form-page { display: flex; flex-direction: column; gap: var(--dk-space-5); min-width: 0; }
.bt-section { display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.bt-section__title { margin: 0; display: flex; align-items: center; gap: var(--dk-space-3); font: var(--dk-weight-semibold) var(--dk-text-17) / 1.3 var(--dk-font-display); }
.bt-section__mark { display: inline-grid; place-items: center; width: 24px; height: 24px; flex: none; border-radius: 50%; border: 2px solid var(--dk-edge); box-sizing: border-box; color: var(--dk-on-accent); }
.bt-section__mark .lai-icon { font-size: 13px; }
.bt-section[data-done="true"] .bt-section__mark { background: var(--dk-accent); border-color: var(--dk-accent); }
.bt-section__body { display: flex; flex-direction: column; gap: var(--dk-space-3); padding-left: calc(24px + var(--dk-space-3)); min-width: 0; }
.bt-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--dk-space-3); }
.bt-pair--wide { grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); }
.bt-triple { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--dk-space-3); }
.bt-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: var(--dk-space-3); }
.bt-form { display: flex; flex-direction: column; gap: var(--dk-space-3); }
.bt-actions { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; }
.bt-choices { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0; min-width: 0; border-radius: var(--dk-radius-2); overflow: hidden; border: 1px solid var(--dk-edge); }
.bt-choices > .dk-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.bt-choice { display: flex; align-items: flex-start; gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); background: var(--dk-card); cursor: pointer; }
.bt-choice + .bt-choice { border-top: 1px solid var(--dk-edge); }
.bt-choice:hover { background: var(--dk-hover); }
.bt-choice[data-checked="true"] { background: var(--dk-accent-wash); }
.bt-choice b { display: block; font-size: var(--dk-text-15); }
.bt-choice__text { display: block; color: var(--dk-muted); font-size: var(--dk-text-13); line-height: 1.45; }
.bt-choice input { margin-top: 3px; flex: none; }
.bt-upload { position: relative; display: flex; align-items: center; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-upload__input { position: absolute; width: 1px; height: 1px; opacity: 0; overflow: hidden; }
.bt-upload__input:focus-visible + label { outline: 2px solid var(--dk-accent-text); outline-offset: 2px; }
.bt-more { border-top: 1px solid var(--dk-edge); padding-top: var(--dk-space-2); }
.bt-more__toggle { display: flex; align-items: center; gap: var(--dk-space-2); width: 100%; min-height: 40px; padding: 0; background: none; border: 0; color: var(--dk-text); font: inherit; font-size: var(--dk-text-15); text-align: left; cursor: pointer; }
.bt-more__toggle { flex-wrap: wrap; }
.bt-more__toggle .dk-hint { margin: 0; min-width: 0; }
.bt-more__body { display: flex; flex-direction: column; gap: var(--dk-space-4); padding: var(--dk-space-3) 0 var(--dk-space-2); min-width: 0; }
.bt-switchrow { display: flex; align-items: center; gap: var(--dk-space-3); }
.bt-eval { display: flex; flex-direction: column; gap: var(--dk-space-3); }
.bt-kv { display: flex; flex-direction: column; gap: var(--dk-space-2); align-items: flex-start; }
.bt-kv__row { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; gap: var(--dk-space-2); width: 100%; align-items: center; }
.bt-banner { display: flex; align-items: center; gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-2); background: var(--dk-accent-wash); color: var(--dk-text); font-size: var(--dk-text-13); flex-wrap: wrap; }
.bt-banner code { overflow-wrap: anywhere; }
.bt-rewards, .bt-liquid { display: flex; flex-direction: column; gap: var(--dk-space-3); padding-top: var(--dk-space-3); border-top: 1px solid var(--dk-edge); }
.bt-rewards .dk-hint, .bt-liquid .dk-hint { margin: 0; }
.bt-reward { display: flex; flex-direction: column; gap: var(--dk-space-2); padding: var(--dk-space-3); border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-2); background: var(--dk-card); min-width: 0; }
.bt-reward[data-on="true"] { background: var(--dk-accent-wash); border-color: transparent; }
.bt-reward__name { display: block; font-size: var(--dk-text-13); font-weight: var(--dk-weight-semibold); }
.bt-reward__head { display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-2); }
.bt-reward-draft { display: flex; flex-direction: column; gap: var(--dk-space-3); }
.bt-code { margin: 0; padding: var(--dk-space-3); border-radius: var(--dk-radius-1); background: var(--dk-inset); font: var(--dk-text-12) / 1.5 var(--dk-font-mono); overflow-x: auto; white-space: pre-wrap; overflow-wrap: anywhere; }
.bt-recipe { margin: 0; padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-2); background: var(--dk-inset); font-size: var(--dk-text-15); line-height: 1.5; overflow-wrap: anywhere; }
/* ---- Check list --------------------------------------------------------------- */
.bt-checks-card { padding: var(--dk-space-2) var(--dk-space-5); }
.bt-checks { list-style: none; margin: 0; padding: 0; }
.bt-check { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: var(--dk-space-3); padding: var(--dk-space-3) 0; }
.bt-check + .bt-check { border-top: 1px solid var(--dk-edge); }
.bt-check__mark { font-size: 20px; line-height: 1; color: var(--dk-muted); padding-top: 1px; }
.bt-check[data-tone="ok"] .bt-check__mark { color: var(--dk-ok-text); }
.bt-check[data-tone="warn"] .bt-check__mark { color: var(--dk-warn-text); }
.bt-check[data-tone="fail"] .bt-check__mark { color: var(--dk-error-text); }
.bt-check__head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--dk-space-3); }
.bt-check__title { font-weight: var(--dk-weight-semibold); font-size: var(--dk-text-15); }
.bt-check__value { font-size: var(--dk-text-13); color: var(--dk-text); text-align: right; overflow-wrap: anywhere; }
.bt-check__text { margin: 2px 0 0; font-size: var(--dk-text-13); line-height: 1.5; color: var(--dk-muted); overflow-wrap: anywhere; }
.bt-check[data-tone="fail"] .bt-check__text { color: var(--dk-error-text); }
.bt-check__meter { margin-top: var(--dk-space-2); }
/* ---- Action bar --------------------------------------------------------------- */
.bt-bar { position: sticky; bottom: 0; z-index: var(--dk-z-sticky); display: flex; align-items: center; justify-content: space-between; gap: var(--dk-space-4); flex-wrap: wrap; padding: var(--dk-space-3) 0; background: var(--dk-canvas); border-top: 1px solid var(--dk-edge); }
.bt-bar__text { margin: 0; flex: 1 1 280px; min-width: 0; font-size: var(--dk-text-13); line-height: 1.5; color: var(--dk-muted); overflow-wrap: anywhere; }
.bt-bar__acts { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; margin-left: auto; }
/* ---- Alerts and statuses ------------------------------------------------------- */
.bt-alert { display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: var(--dk-space-3); padding: var(--dk-space-4); border-radius: var(--dk-radius-3); background: var(--dk-error-wash); color: var(--dk-error-text); }
.bt-alert .lai-icon { font-size: 20px; margin-top: 1px; }
.bt-alert__title { margin: 0; font-weight: var(--dk-weight-bold); font-size: var(--dk-text-15); }
.bt-alert__text { margin: 2px 0 0; font-size: var(--dk-text-13); line-height: 1.5; overflow-wrap: anywhere; white-space: pre-wrap; }
.bt-status { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); font-size: var(--dk-text-13); overflow-wrap: anywhere; }
.bt-status--error { color: var(--dk-error-text); }
.bt-path { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: bottom; font-size: var(--dk-text-12); }
.bt-pathrow { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); min-width: 0; }
/* ---- Job card ----------------------------------------------------------------- */
.bt-job { display: flex; flex-direction: column; gap: var(--dk-space-3); padding: var(--dk-space-4) var(--dk-space-5); min-width: 0; }
.bt-job__top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-job__who { min-width: 0; }
.bt-job__title { margin: 0; font: var(--dk-weight-bold) var(--dk-text-17) / 1.3 var(--dk-font-display); overflow-wrap: anywhere; }
.bt-job__meta { margin: 2px 0 0; font-size: var(--dk-text-13); color: var(--dk-muted); overflow-wrap: anywhere; }
.bt-job__acts { display: flex; align-items: center; gap: var(--dk-space-2); }
.bt-job__figures { display: flex; align-items: baseline; gap: var(--dk-space-5); flex-wrap: wrap; font-size: var(--dk-text-13); color: var(--dk-muted); }
.bt-job__figures b { color: var(--dk-text); }
.bt-job__percent { font: var(--dk-weight-bold) var(--dk-text-44) / 1 var(--dk-font-display); letter-spacing: -.02em; color: var(--dk-text); font-variant-numeric: tabular-nums; }
.bt-job__percent small { font-size: var(--dk-text-21); margin-left: 2px; color: var(--dk-muted); }
.bt-job__wait { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); color: var(--dk-muted); font-size: var(--dk-text-13); }
.bt-job__message { margin: 0; overflow-wrap: anywhere; }
.bt-job__done { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); color: var(--dk-ok-text); font-size: var(--dk-text-15); font-weight: var(--dk-weight-semibold); }
.bt-stages { list-style: none; margin: 0; padding: 0; display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: var(--dk-space-2); }
.bt-stage { display: flex; flex-direction: column; gap: 6px; min-width: 0; font-size: var(--dk-text-12); color: var(--dk-muted); }
.bt-stage__bar { height: 3px; border-radius: 3px; background: var(--dk-edge); }
.bt-stage[data-state="done"] .bt-stage__bar { background: var(--dk-muted); }
.bt-stage[data-state="current"] { color: var(--dk-text); font-weight: var(--dk-weight-bold); }
.bt-stage[data-state="current"] .bt-stage__bar { background: var(--dk-accent); }
.bt-stage[data-state="failed"] { color: var(--dk-error-text); font-weight: var(--dk-weight-bold); }
.bt-stage[data-state="failed"] .bt-stage__bar { background: var(--dk-error); }
.bt-stage__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bt-try { display: flex; flex-direction: column; gap: var(--dk-space-3); padding: var(--dk-space-4) var(--dk-space-5); }
.bt-try .dk-hint { margin: 0; }
.bt-try__acts { display: flex; gap: var(--dk-space-2); flex-wrap: wrap; }
.bt-next { display: grid; grid-template-columns: 40px minmax(0, 1fr); gap: var(--dk-space-3); padding: var(--dk-space-4) var(--dk-space-5); }
.bt-next .dk-hint { margin: 2px 0 var(--dk-space-3); }
.bt-next__mark { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 50%; background: var(--dk-ok-wash); color: var(--dk-ok-text); font-size: 20px; }
.bt-next__acts { display: flex; gap: var(--dk-space-2); flex-wrap: wrap; }
.bt-out { display: flex; flex-direction: column; gap: var(--dk-space-3); }
.bt-out__name { max-width: 420px; }
/* ---- Chart -------------------------------------------------------------------- */
.bt-chart { padding: var(--dk-space-4) var(--dk-space-5); display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.bt-chart__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-chart__head .dk-hint { margin: 2px 0 0; }
.bt-chart__svg { width: 100%; height: auto; display: block; overflow: visible; }
.bt-chart__grid { stroke: var(--dk-edge); stroke-width: 1; }
.bt-chart__tick { fill: var(--dk-muted); font: 11px var(--dk-font-mono); }
.bt-chart__axis { fill: var(--dk-muted); font: 11px var(--dk-font-body); }
.bt-chart__line { fill: none; stroke: var(--dk-series-1); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.bt-chart__eval { fill: var(--dk-card); stroke: var(--dk-series-2); stroke-width: 2; }
.bt-chart__end { fill: var(--dk-series-1); }
.bt-chart__label { fill: var(--dk-text); font: 700 12px var(--dk-font-mono); }
.bt-chart__cursor { stroke: var(--dk-muted); stroke-dasharray: 3 3; stroke-width: 1; }
.bt-chart__readout { margin: 0; font-size: var(--dk-text-12); color: var(--dk-muted); }
.bt-chart__empty { margin: 0; color: var(--dk-muted); font-size: var(--dk-text-13); }
.bt-chart__data summary { cursor: pointer; font-size: var(--dk-text-13); color: var(--dk-muted); min-height: 32px; display: flex; align-items: center; }
.bt-chart__table { --dk-table-max: 240px; margin-top: var(--dk-space-2); }
/* ---- Log ---------------------------------------------------------------------- */
.bt-log { padding: var(--dk-space-4) var(--dk-space-5); display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.bt-log__head { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-log__head .dk-hint { margin: 2px 0 0; }
.bt-log__tools { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; }
.bt-log__body { max-height: 280px; overflow: auto; padding: var(--dk-space-3); border-radius: var(--dk-radius-2); background: var(--dk-inset); font: var(--dk-text-12) / 1.6 var(--dk-font-mono); }
.bt-log__line { display: grid; grid-template-columns: 64px 44px minmax(0, 1fr); gap: var(--dk-space-3); }
.bt-log__time, .bt-log__tag { color: var(--dk-muted); }
.bt-log__text { overflow-wrap: anywhere; white-space: pre-wrap; }
.bt-log__line[data-level="warn"] .bt-log__text, .bt-log__line[data-level="warn"] .bt-log__tag { color: var(--dk-warn-text); }
.bt-log__line[data-level="err"] .bt-log__text, .bt-log__line[data-level="err"] .bt-log__tag { color: var(--dk-error-text); }
.bt-log__empty { margin: 0; color: var(--dk-muted); }
/* ---- Jobs table --------------------------------------------------------------- */
.bt-jobs { display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.bt-empty-card { padding: var(--dk-space-5); border-radius: var(--dk-radius-3); border: 1px solid var(--dk-edge); background: var(--dk-card); }
.bt-linkcell { display: block; padding: 0; background: none; border: 0; font: inherit; color: inherit; text-align: left; cursor: pointer; max-width: 100%; }
.bt-linkcell:hover .dk-table-name { text-decoration: underline; }
.bt-jobs__msg { color: var(--dk-error-text); overflow-wrap: anywhere; }
.bt-rowacts { display: flex; align-items: center; justify-content: flex-end; gap: var(--dk-space-1); }
/* ---- Landing ------------------------------------------------------------------ */
.bt-machine { display: flex; flex-direction: column; gap: var(--dk-space-3); background: var(--dk-inset); }
.bt-machine__facts { margin: 0; display: flex; flex-wrap: wrap; gap: var(--dk-space-2) var(--dk-space-5); }
.bt-machine__facts > div { min-width: 0; }
.bt-machine__facts dt { display: flex; align-items: center; gap: 6px; font-size: var(--dk-text-12); color: var(--dk-muted); }
.bt-machine__facts dd { margin: 2px 0 0; font-size: var(--dk-text-13); }
.bt-machine__backends { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; font-size: var(--dk-text-13); color: var(--dk-muted); }
.bt-now { display: grid; grid-template-columns: 24px minmax(0, 1fr) auto; align-items: center; gap: var(--dk-space-3); padding: var(--dk-space-3) var(--dk-space-4); border-radius: var(--dk-radius-3); background: var(--dk-accent-wash); }
.bt-now[data-kind="failed"] { background: var(--dk-error-wash); color: var(--dk-error-text); }
.bt-now__mark { font-size: 20px; }
.bt-now__title { margin: 0; font-weight: var(--dk-weight-bold); font-size: var(--dk-text-15); overflow-wrap: anywhere; }
.bt-now__text { margin: 2px 0 0; font-size: var(--dk-text-13); overflow-wrap: anywhere; }
.bt-groups { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--dk-space-6); }
.bt-group { display: flex; flex-direction: column; gap: var(--dk-space-3); min-width: 0; }
.bt-group__head { display: flex; align-items: baseline; gap: var(--dk-space-3); flex-wrap: wrap; }
.bt-group__head .dk-hint { margin: 0; }
.bt-flow { display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; }
.bt-flow__list { list-style: none; margin: 0; padding: 0; display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; }
.bt-flow__item { display: inline-flex; align-items: center; gap: var(--dk-space-2); color: var(--dk-muted); }
.bt-flow .dk-chip { cursor: default; }
.bt-flow .dk-hint { margin: 0; }
.bt-tools { list-style: none; margin: 0; padding: 0; border: 1px solid var(--dk-edge); border-radius: var(--dk-radius-3); background: var(--dk-card); overflow: hidden; }
.bt-tool { display: grid; grid-template-columns: 40px minmax(0, 1fr) auto; gap: var(--dk-space-4); align-items: start; padding: var(--dk-space-4) var(--dk-space-5); }
.bt-tool + .bt-tool { border-top: 1px solid var(--dk-edge); }
.bt-tool__icon { display: grid; place-items: center; width: 40px; height: 40px; border-radius: var(--dk-radius-2); background: var(--dk-inset); color: var(--dk-muted); font-size: 18px; }
.bt-tool__title { margin: 0; display: flex; align-items: center; gap: var(--dk-space-2); flex-wrap: wrap; font: var(--dk-weight-bold) var(--dk-text-15) / 1.3 var(--dk-font-display); }
.bt-tool__text { margin: 4px 0 0; max-width: 62ch; font-size: var(--dk-text-13); line-height: 1.5; color: var(--dk-muted); }
.bt-tool__why { margin: var(--dk-space-2) 0 0; font-size: var(--dk-text-13); color: var(--dk-warn-text); }
.bt-tool__end { display: flex; flex-direction: column; align-items: flex-end; gap: var(--dk-space-2); }
.bt-needs { list-style: none; margin: var(--dk-space-2) 0 0; padding: 0; display: flex; flex-wrap: wrap; gap: var(--dk-space-1) var(--dk-space-4); }
.bt-needs li { display: inline-flex; align-items: center; gap: 6px; font-size: var(--dk-text-12); color: var(--dk-muted); }
.bt-needs li[data-tone="ok"] .lai-icon { color: var(--dk-ok-text); }
.bt-needs li[data-tone="warn"] { color: var(--dk-warn-text); }
/* ---- Disabled (account) --------------------------------------------------------- */
.bt-off { padding-top: var(--dk-space-5); }
.bt-off__card { display: flex; flex-direction: column; align-items: flex-start; gap: var(--dk-space-3); padding: var(--dk-space-5); background: var(--dk-inset); }
.bt-off__text { margin: 0; max-width: 56ch; font-size: var(--dk-text-15); line-height: 1.5; }
.bt-off__card .dk-hint { margin: 0; }
.bt-off__acts { display: flex; gap: var(--dk-space-2); flex-wrap: wrap; }
/* ---- Phone ---------------------------------------------------------------------- */
@media (max-width: 640px) {
.bt-section__body { padding-left: 0; }
.bt-pair, .bt-pair--wide, .bt-triple { grid-template-columns: minmax(0, 1fr); }
.bt-steps { gap: 6px; flex-wrap: nowrap; }
.bt-step { gap: 4px; }
.bt-step__label { font-size: var(--dk-text-12); }
.bt-step__mark { width: 22px; height: 22px; }
.bt-step:not(:last-child)::after { width: 8px; margin-left: 0; }
.bt-tool { grid-template-columns: 40px minmax(0, 1fr); }
.bt-tool__end { grid-column: 1 / -1; flex-direction: row; justify-content: flex-start; flex-wrap: wrap; }
.bt-now { grid-template-columns: 24px minmax(0, 1fr); }
.bt-now .dk-btn { grid-column: 1 / -1; justify-self: start; }
.bt-kv__row { grid-template-columns: minmax(0, 1fr) auto; }
.bt-kv__row .dk-input:nth-child(2) { grid-column: 1 / -1; grid-row: 2; }
.bt-log__line { grid-template-columns: 56px minmax(0, 1fr); }
.bt-log__tag { display: none; }
.bt-more__toggle .dk-hint { flex-basis: 100%; padding-left: 24px; }
.bt-rowacts { flex-direction: column; align-items: flex-end; gap: 0; }
.bt-jobs .dk-table { table-layout: fixed; }
.bt-jobs .dk-table th:nth-child(3) { width: 112px; }
.bt-jobs .dk-table th:last-child { width: 84px; }
.bt-jobs .dk-table td:first-child { overflow-wrap: anywhere; min-width: 0; }
.bt-jobs .dk-table .dk-table-name, .bt-jobs .dk-table .dk-table-sub { white-space: normal; overflow: visible; text-overflow: clip; overflow-wrap: anywhere; }
.bt-bar__acts { width: 100%; }
.bt-bar__acts .dk-btn--primary { flex: 1 1 auto; }
.bt-job__percent { font-size: var(--dk-text-32); }
.bt-stage__label { font-size: 11px; }
.bt-next { grid-template-columns: minmax(0, 1fr); }
.bt-block.dk-card, .bt-job, .bt-chart, .bt-log, .bt-try, .bt-next { padding-inline: var(--dk-space-4); }
.bt-checks-card { padding-inline: var(--dk-space-4); }
}
@media (prefers-reduced-motion: reduce) {
.bt-page * { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }
}
+443
View File
@@ -0,0 +1,443 @@
// What the Build pages work out from data the server already returns: what this
// machine has, which tools it can run, what a job needs before it starts, how a
// source is spelled, and how a job's progress events read as a log.
//
// Nothing here estimates what a job will use or how long it will take. The
// server reports no model size before a job starts and no duration, so the
// checks state the facts that are known (free memory, free disk, whether a
// backend is installed, what the form holds) and say that the rest depends on
// the model and the hardware. A check carries a `key` and `vals` for the page to
// translate, so the wording stays out of this file.
const finite = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null)
// ---- This machine ------------------------------------------------------------
// The readings from GET /api/resources, in the shape the pages use. null when
// the answer is missing (the endpoint is for admins, so a user with only the
// tool's permission gets none). Every field is null rather than 0 when the
// server did not report it: 0 free bytes would be a claim.
export function machineFacts(resources) {
if (!resources || typeof resources !== 'object') return null
const gpus = Array.isArray(resources.gpus) ? resources.gpus.filter(Boolean) : []
const hasGpu = resources.type === 'gpu' && gpus.length > 0
const sum = (key) => gpus.reduce((n, g) => n + (finite(g[key]) ?? 0), 0)
const ram = resources.ram || null
const disk = resources.disk || null
const cluster = resources.cluster?.enabled && finite(resources.cluster.total_memory) > 0 ? resources.cluster : null
return {
hasGpu,
gpuName: hasGpu ? (gpus[0].name || 'GPU') + (gpus.length > 1 ? ` x${gpus.length}` : '') : '',
vramTotal: hasGpu ? sum('total_vram') : null,
vramFree: hasGpu ? sum('free_vram') : null,
ramTotal: finite(ram?.total),
ramFree: finite(ram?.available) ?? finite(ram?.free),
diskTotal: finite(disk?.total),
diskFree: finite(disk?.available),
cluster: cluster
? { node: cluster.node_name || '', total: cluster.total_memory, isGpu: !!cluster.is_gpu, nodes: cluster.node_count || 0 }
: null,
}
}
// ---- Jobs ------------------------------------------------------------------------
export const FT_STAGES = ['queued', 'loading_model', 'loading_dataset', 'training', 'saving']
export const QZ_STAGES = ['queued', 'downloading', 'converting', 'quantizing']
export const TERMINAL = ['completed', 'failed', 'stopped']
export function isActive(job, stages) {
return !!job && stages.includes(job.status)
}
// The newest job that is running, else the newest job when it failed. Used by the
// Build landing to put one line above the list.
export function jobToSurface(jobs, stages) {
const list = Array.isArray(jobs) ? jobs : []
const byNewest = [...list].sort((a, b) => Date.parse(b.created_at || 0) - Date.parse(a.created_at || 0))
const running = byNewest.find(j => stages.includes(j.status))
if (running) return { job: running, kind: 'running' }
if (byNewest[0]?.status === 'failed') return { job: byNewest[0], kind: 'failed' }
return null
}
// The phase a log line belongs to: info, warn or err.
export function logLevel(event) {
const text = `${event?.message || ''}`
if (event?.status === 'failed' || /\b(error|traceback|exception|failed|out of memory|oom)\b/i.test(text)) return 'err'
if (/\bwarn(ing)?\b/i.test(text)) return 'warn'
return 'info'
}
const num = (v, digits) => (Number.isFinite(v) ? v.toFixed(digits) : null)
// Lines to add to the log for one progress event. Only what the event holds:
// a status change, a new message, and for fine-tuning the step with its loss and
// learning rate. Returns [] when the event adds nothing.
export function logLines(event, previous, now = new Date()) {
if (!event) return []
const lines = []
const level = logLevel(event)
const tag = String(event.status || '').replace(/_/g, ' ')
if (event.status && event.status !== previous?.status) {
lines.push({ at: now, level: event.status === 'failed' ? 'err' : 'info', tag: 'status', text: tag })
}
const message = `${event.message || ''}`.trim()
if (message && message !== `${previous?.message || ''}`.trim()) {
lines.push({ at: now, level, tag: level === 'err' ? 'err' : level === 'warn' ? 'warn' : 'info', text: message })
}
if (event.total_steps > 0 && event.current_step > 0 && event.current_step !== previous?.current_step && event.loss > 0) {
const parts = [`step ${event.current_step}/${event.total_steps}`, `loss ${num(event.loss, 4)}`]
if (event.eval_loss > 0) parts.push(`eval_loss ${num(event.eval_loss, 4)}`)
if (event.learning_rate > 0) parts.push(`lr ${event.learning_rate.toExponential(1)}`)
lines.push({ at: now, level: 'info', tag: 'info', text: parts.join(' ') })
}
return lines
}
export const LOG_LIMIT = 400
export function appendLog(log, lines) {
if (lines.length === 0) return log
const next = log.concat(lines)
return next.length > LOG_LIMIT ? next.slice(next.length - LOG_LIMIT) : next
}
export function logText(lines) {
const pad = (n) => String(n).padStart(2, '0')
return lines
.map(l => `${pad(l.at.getHours())}:${pad(l.at.getMinutes())}:${pad(l.at.getSeconds())} ${l.tag.padEnd(6)} ${l.text}`)
.join('\n')
}
// An out-of-memory failure, judged from the server's own message.
export function looksLikeMemoryFailure(message) {
return /out of memory|\boom\b|cuda error: out of memory|cannot allocate memory|killed/i.test(`${message || ''}`)
}
// ---- The tool landing --------------------------------------------------------------
const names = (list) => (Array.isArray(list) ? list.map(b => b?.name || b).filter(Boolean) : [])
// What one tool needs from this machine, read from real data. `state` is
// 'ready' or 'needs-backend'; `needs` lists lines for the card, each with a tone
// ('ok', 'warn' or 'info') and a translation key.
//
// facts machineFacts(resources), or null
// backends { fineTune: [...] | null, quantize: [...] | null }; null when the
// list could not be read
export function toolStatus(tool, { facts, backends }) {
if (tool === 'import') {
const needs = [{ tone: 'info', key: 'import.noSetup' }]
if (facts?.diskFree != null) needs.push({ tone: 'ok', key: 'import.disk', vals: { free: facts.diskFree } })
return { state: 'ready', needs }
}
if (tool === 'quantize') {
const list = names(backends?.quantize)
const needs = []
if (backends?.quantize == null) {
needs.push({ tone: 'info', key: 'quantize.backendUnknown' })
return { state: 'ready', needs }
}
if (list.length === 0) {
needs.push({ tone: 'warn', key: 'quantize.backendMissing' })
return { state: 'needs-backend', needs }
}
needs.push({ tone: 'ok', key: 'quantize.backend', vals: { name: list[0] } })
needs.push({ tone: 'info', key: 'quantize.cpu' })
return { state: 'ready', needs }
}
if (tool === 'fine-tune') {
const list = names(backends?.fineTune)
const needs = []
let state = 'ready'
if (backends?.fineTune == null) {
needs.push({ tone: 'info', key: 'fineTune.backendUnknown' })
} else if (list.length === 0) {
needs.push({ tone: 'warn', key: 'fineTune.backendMissing' })
state = 'needs-backend'
} else {
needs.push({ tone: 'ok', key: 'fineTune.backend', vals: { name: list[0] } })
}
if (facts?.cluster) {
needs.push({ tone: 'info', key: 'fineTune.cluster', vals: { total: facts.cluster.total, node: facts.cluster.node } })
} else if (facts?.hasGpu) {
needs.push({ tone: 'ok', key: 'fineTune.gpu', vals: { name: facts.gpuName, free: facts.vramFree, total: facts.vramTotal } })
} else if (facts) {
needs.push({ tone: 'warn', key: 'fineTune.noGpu' })
}
return { state, needs }
}
return { state: 'ready', needs: [] }
}
// ---- Pre-flight checks -------------------------------------------------------------
// A check: id, tone ('ok', 'warn', 'fail' or 'info'), key and vals for the text,
// an optional short `value`, and an optional `meter` { used, total } in bytes.
// A 'fail' blocks the start; a 'warn' does not.
const GB5 = 5 * 1024 ** 3
function backendCheck(list, wanted, kind) {
if (list == null) return { id: 'backend', tone: 'info', key: `${kind}.backendUnknown` }
const have = names(list)
if (have.length === 0) return { id: 'backend', tone: 'warn', key: `${kind}.backendMissing`, vals: { name: wanted }, action: 'backends' }
if (wanted && !have.includes(wanted)) return { id: 'backend', tone: 'warn', key: `${kind}.backendOther`, vals: { name: wanted, list: have.join(', ') }, action: 'backends' }
return { id: 'backend', tone: 'ok', key: `${kind}.backendOk`, vals: { name: wanted || have[0] }, value: wanted || have[0] }
}
function diskCheck(facts, kind) {
if (facts?.diskFree == null) return null
const low = facts.diskFree < GB5
return {
id: 'disk',
tone: low ? 'warn' : 'ok',
key: low ? `${kind}.diskLow` : `${kind}.disk`,
vals: { free: facts.diskFree },
meter: facts.diskTotal > 0 ? { used: facts.diskTotal - facts.diskFree, total: facts.diskTotal } : null,
}
}
export function fineTuneChecks({ form, facts, backends }) {
const checks = []
const model = `${form.model || ''}`.trim()
const hasData = !!(`${form.datasetSource || ''}`.trim() || form.datasetFileName)
checks.push(model
? { id: 'model', tone: 'ok', key: 'fineTune.modelOk', vals: { model }, value: model }
: { id: 'model', tone: 'fail', key: 'fineTune.modelMissing' })
checks.push(hasData
? { id: 'data', tone: 'ok', key: 'fineTune.dataOk', vals: { source: form.datasetFileName || form.datasetSource } }
: { id: 'data', tone: 'fail', key: 'fineTune.dataMissing' })
checks.push(backendCheck(backends?.fineTune, form.backend, 'fineTune'))
const setup = {
batch: form.batchSize,
seq: form.maxSeqLength,
accum: form.gradAccum,
checkpointing: form.gradCheckpointing,
full: form.trainingType === 'full',
}
if (facts?.cluster) {
checks.push({ id: 'memory', tone: 'info', key: 'fineTune.memoryCluster', vals: { ...setup, total: facts.cluster.total, node: facts.cluster.node } })
} else if (facts?.hasGpu) {
checks.push({
id: 'memory',
tone: 'info',
key: 'fineTune.memoryGpu',
vals: { ...setup, name: facts.gpuName, free: facts.vramFree, total: facts.vramTotal },
value: { free: facts.vramFree, total: facts.vramTotal },
meter: { used: facts.vramTotal - facts.vramFree, total: facts.vramTotal },
})
} else if (facts) {
checks.push({
id: 'memory',
tone: 'warn',
key: 'fineTune.memoryCpu',
vals: { ...setup, free: facts.ramFree, total: facts.ramTotal },
meter: facts.ramTotal > 0 && facts.ramFree != null ? { used: facts.ramTotal - facts.ramFree, total: facts.ramTotal } : null,
})
} else {
checks.push({ id: 'memory', tone: 'info', key: 'fineTune.memoryUnknown', vals: setup })
}
const disk = diskCheck(facts, 'fineTune')
if (disk) checks.push(disk)
if (form.trainingMethod === 'grpo' && !(form.rewardCount > 0)) {
checks.push({ id: 'reward', tone: 'warn', key: 'fineTune.rewardMissing' })
}
if (form.resumeFrom) checks.push({ id: 'resume', tone: 'info', key: 'fineTune.resume', vals: { path: form.resumeFrom } })
if (model.includes('/') && !form.hfToken) checks.push({ id: 'access', tone: 'info', key: 'fineTune.accessNone' })
if (form.hfToken) checks.push({ id: 'access', tone: 'ok', key: 'fineTune.accessSet' })
return checks
}
export function quantizeChecks({ form, facts, backends }) {
const checks = []
const model = `${form.model || ''}`.trim()
checks.push(model
? { id: 'model', tone: 'ok', key: 'quantize.modelOk', vals: { model }, value: model }
: { id: 'model', tone: 'fail', key: 'quantize.modelMissing' })
if (form.useCustom && !`${form.customType || ''}`.trim()) {
checks.push({ id: 'type', tone: 'fail', key: 'quantize.typeMissing' })
}
checks.push(backendCheck(backends?.quantize, form.backend, 'quantize'))
if (facts?.ramTotal > 0 && facts.ramFree != null) {
checks.push({
id: 'memory',
tone: 'info',
key: 'quantize.memory',
vals: { free: facts.ramFree, total: facts.ramTotal },
meter: { used: facts.ramTotal - facts.ramFree, total: facts.ramTotal },
})
}
const disk = diskCheck(facts, 'quantize')
if (disk) checks.push(disk)
if (model.includes('/') && !form.hfToken) checks.push({ id: 'access', tone: 'info', key: 'quantize.accessNone' })
if (form.hfToken) checks.push({ id: 'access', tone: 'ok', key: 'quantize.accessSet' })
return checks
}
export const blocked = (checks) => checks.some(c => c.tone === 'fail')
export const warned = (checks) => checks.filter(c => c.tone === 'warn').length
// ---- Import: what the source says about itself ---------------------------------------
// Reads the spelling of an import source, with no request. `kind` is one of
// 'huggingface', 'url', 'oci', 'ollama', 'local', 'config' (a YAML file) or
// 'unknown'. `ref` is the repository, file or image name; `file` the file name
// when the source names one. null for an empty field.
export function detectSource(input) {
const uri = `${input || ''}`.trim()
if (!uri) return null
const lower = uri.toLowerCase()
const fileOf = (path) => {
const last = path.split(/[?#]/)[0].split('/').filter(Boolean).pop() || ''
return /\.[a-z0-9]{2,6}$/i.test(last) ? last : ''
}
const isYaml = (name) => /\.ya?ml$/i.test(name)
const hf = uri.match(/^(?:huggingface:\/\/|hf:\/\/|https?:\/\/(?:www\.)?huggingface\.co\/)(.+)$/i)
if (hf) {
const parts = hf[1].replace(/^\/+/, '').split('/').filter(Boolean)
const ref = parts.slice(0, 2).join('/')
const file = parts.length > 2 ? fileOf(parts.slice(2).join('/')) : ''
return { kind: 'huggingface', ref, file, ok: parts.length >= 2, scheme: lower.startsWith('http') ? 'https' : lower.split('://')[0] }
}
if (/^(oci|ocifile):\/\//i.test(uri)) return { kind: 'oci', ref: uri.replace(/^[a-z]+:\/\//i, ''), file: '', ok: true, scheme: lower.split('://')[0] }
if (/^ollama:\/\//i.test(uri)) return { kind: 'ollama', ref: uri.replace(/^ollama:\/\//i, ''), file: '', ok: true, scheme: 'ollama' }
if (/^https?:\/\//i.test(uri)) {
const file = fileOf(uri.replace(/^https?:\/\/[^/]+/i, ''))
return { kind: isYaml(file) ? 'config' : 'url', ref: uri.replace(/^https?:\/\//i, ''), file, ok: true, scheme: lower.split('://')[0] }
}
if (/^file:\/\//i.test(uri) || uri.startsWith('/') || uri.startsWith('./') || uri.startsWith('~/')) {
const path = uri.replace(/^file:\/\//i, '')
const file = fileOf(path)
return { kind: isYaml(file) ? 'config' : 'local', ref: path, file, ok: true, scheme: 'file' }
}
// owner/repo with no scheme: the importer tries it as a Hugging Face id.
if (/^[\w.-]+\/[\w.-]+$/.test(uri)) return { kind: 'huggingface', ref: uri, file: '', ok: true, scheme: '' }
return { kind: 'unknown', ref: uri, file: '', ok: false, scheme: '' }
}
// The request the Import form sends, built from its fields. One place, so the
// preview on the page is the request that goes out.
export function importRequest(uri, prefs, customPrefs, overrideBackend) {
const out = {}
const backend = overrideBackend !== undefined ? overrideBackend : prefs.backend
if (backend) out.backend = backend
if (prefs.name?.trim()) out.name = prefs.name.trim()
if (prefs.description?.trim()) out.description = prefs.description.trim()
if (prefs.quantizations?.trim()) out.quantizations = prefs.quantizations.trim()
if (prefs.mmproj_quantizations?.trim()) out.mmproj_quantizations = prefs.mmproj_quantizations.trim()
if (prefs.embeddings) out.embeddings = 'true'
if (prefs.type?.trim()) out.type = prefs.type.trim()
if (prefs.pipeline_type?.trim()) out.pipeline_type = prefs.pipeline_type.trim()
if (prefs.scheduler_type?.trim()) out.scheduler_type = prefs.scheduler_type.trim()
if (prefs.enable_parameters?.trim()) out.enable_parameters = prefs.enable_parameters.trim()
if (prefs.cuda) out.cuda = true
for (const cp of customPrefs || []) {
if (cp.key.trim() && cp.value.trim()) out[cp.key.trim()] = cp.value.trim()
}
return { uri: `${uri || ''}`.trim(), preferences: Object.keys(out).length > 0 ? out : null }
}
// The request as YAML-like text for the preview. Scalars are quoted only when a
// plain scalar would be read as something else.
export function requestText(req) {
const scalar = (v) => {
if (typeof v !== 'string') return String(v)
return /^[\w./:@~+-][\w ./:@~+,-]*$/.test(v) && !/^(true|false|null|yes|no|\d+)$/i.test(v) ? v : JSON.stringify(v)
}
const lines = [`uri: ${scalar(req.uri)}`]
if (req.preferences) {
lines.push('preferences:')
for (const [k, v] of Object.entries(req.preferences)) lines.push(` ${k}: ${scalar(v)}`)
} else {
lines.push('preferences: none (LocalAI decides)')
}
return lines.join('\n')
}
// The checks for an import that can be made before it starts. The size and the
// memory it needs are known only after the import starts, so they are not here.
export function importChecks({ source, prefs, backends, installedNames, facts }) {
const checks = []
if (!source) return checks
checks.push(source.ok
? { id: 'source', tone: 'ok', key: 'import.sourceOk', vals: { kind: source.kind } }
: { id: 'source', tone: 'warn', key: 'import.sourceUnknown' })
const chosen = prefs.backend
if (chosen) {
const known = (backends || []).find(b => b.name === chosen)
checks.push(known && !known.installed
? { id: 'backend', tone: 'info', key: 'import.backendDownload', vals: { name: chosen } }
: { id: 'backend', tone: 'ok', key: 'import.backendChosen', vals: { name: chosen } })
} else {
checks.push({ id: 'backend', tone: 'info', key: 'import.backendAuto' })
}
const name = `${prefs.name || ''}`.trim()
if (name) {
const taken = (installedNames || []).includes(name)
checks.push(taken
? { id: 'name', tone: 'warn', key: 'import.nameTaken', vals: { name } }
: { id: 'name', tone: 'ok', key: 'import.nameFree', vals: { name } })
} else {
checks.push({ id: 'name', tone: 'info', key: 'import.nameAuto' })
}
if (facts?.diskFree != null) {
checks.push({
id: 'disk',
tone: facts.diskFree < GB5 ? 'warn' : 'ok',
key: 'import.disk',
vals: { free: facts.diskFree },
meter: facts.diskTotal > 0 ? { used: facts.diskTotal - facts.diskFree, total: facts.diskTotal } : null,
})
}
if (facts) {
const freeMemory = facts.cluster ? facts.cluster.total : facts.hasGpu ? facts.vramFree : facts.ramFree
if (freeMemory != null) {
checks.push({ id: 'memory', tone: 'info', key: 'import.memory', vals: { free: freeMemory, where: facts.cluster ? 'cluster' : facts.hasGpu ? 'gpu' : 'ram' } })
}
}
return checks
}
// How an import's own estimate sits against this machine, once the server has
// returned one. 'fits', 'tight' (over 85% of the free memory) or 'over'; null
// when either number is missing.
export function fitFor(neededBytes, freeBytes) {
if (!(neededBytes > 0) || !(freeBytes > 0)) return null
const ratio = neededBytes / freeBytes
if (ratio > 1) return 'over'
if (ratio > 0.85) return 'tight'
return 'fits'
}
// ---- Explorer ------------------------------------------------------------------------
// A token cut for display: its first and last characters. The token itself is
// what the copy button and the join commands carry.
export function shortToken(token) {
const t = `${token || ''}`
return t.length <= 16 ? t : `${t.slice(0, 6)}…${t.slice(-6)}`
}
export function workerCount(network) {
return (network?.Clusters || []).reduce((n, c) => n + (c?.Workers?.length || 0), 0)
}
// Networks with the most workers first, then by name. The server sorts by cluster
// count; the page counts workers, which is what a person wants to join.
export function sortNetworks(list) {
return [...(Array.isArray(list) ? list : [])].sort((a, b) =>
workerCount(b) - workerCount(a) || `${a.name}`.localeCompare(`${b.name}`))
}
// The two commands that start a federated node on a network, as the server's own
// explorer page prints them.
export function joinCommands(token, networkId) {
const env = `ADDRESS=":80" LOCALAI_P2P_NETWORK_ID=${networkId} LOCALAI_P2P_LOGLEVEL=debug TOKEN="${token}"`
return {
docker: `docker run -d --restart=always -e ADDRESS=":80" -e LOCALAI_P2P_NETWORK_ID=${networkId} -e LOCALAI_P2P_LOGLEVEL=debug --name local-ai -e TOKEN="${token}" --net host -ti localai/localai:master federated --debug`,
cli: `${env} local-ai federated --debug`,
}
}
@@ -0,0 +1,26 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
// The add-on builder refuses a bundle that imports a core UI file, so these
// files are copies. They must stay byte-equal to the core originals.
const here = dirname(fileURLToPath(import.meta.url))
const src = join(here, '../src')
const core = resolve(here, '../../../../core/http/react-ui/src')
const read = (p) => readFileSync(p, 'utf8')
test('src/tools.js is a byte copy of the core utils/tools.js', () => {
assert.equal(read(join(src, 'tools.js')), read(join(core, 'utils/tools.js')))
})
test('src/tools.css is a byte copy of the core components/tools/tools.css', () => {
assert.equal(read(join(src, 'tools.css')), read(join(core, 'components/tools/tools.css')))
})
test('src/format.js holds formatBytes exactly as the core utils/format.js does', () => {
const fn = (text) => /export function formatBytes[\s\S]*?\n}\n/.exec(text)?.[0]
assert.ok(fn(read(join(core, 'utils/format.js'))), 'formatBytes found in core')
assert.equal(fn(read(join(src, 'format.js'))), fn(read(join(core, 'utils/format.js'))))
})
@@ -0,0 +1,2 @@
/dist/
/src/style.css
@@ -0,0 +1,40 @@
import { createToolSteps } from '../../../src/ToolSteps.jsx'
import { createChecks } from '../../../src/Checks.jsx'
import { createStageLine } from '../../../src/StageLine.jsx'
import { createJobLog } from '../../../src/JobLog.jsx'
import { createToolOff } from '../../../src/ToolOff.jsx'
// A page host built the way an add-on builds one: a context from the SDK, the
// shared factories, and a registration of pages that the shell draws.
function contextFor(sdk) {
const { React } = sdk
const h = React.createElement
return {
sdk, React, h, Fragment: React.Fragment,
t: (key, vars) => sdk.locale.t(key, key, vars),
Icon: sdk.Icon, ui: sdk.ui, viewer: sdk.viewer, basePath: '',
}
}
export function createAddon(sdk, options = {}) {
const ctx = contextFor(sdk)
const { h } = ctx
const props = options.props || {}
const ToolSteps = createToolSteps(ctx)
const Checks = createChecks(ctx)
const StageLine = createStageLine(ctx)
const JobLog = createJobLog(ctx)
const ToolOff = createToolOff(ctx)
const pages = {
steps: () => <ToolSteps {...props.steps} />,
checks: () => <Checks {...props.checks} />,
stage: () => <StageLine {...props.stage} />,
log: () => <JobLog {...props.log} />,
off: () => <ToolOff {...props.off}><p>children</p></ToolOff>,
}
function Component({ page }) {
const Page = pages[page]
return Page ? <Page /> : null
}
return { Component, components: {}, chatActions: [], dispose() {} }
}
@@ -0,0 +1,109 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { copyFileSync, readFileSync } from 'node:fs'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { dirname, join, resolve } from 'node:path'
import { buildAddonUI } from '../../uibuild/uibuild.mjs'
const here = dirname(fileURLToPath(import.meta.url))
const hostUi = resolve(here, '../../../../core/http/react-ui')
const require = createRequire(join(hostUi, 'package.json'))
const React = require('react')
const { renderToString } = require('react-dom/server')
const flatten = (obj, prefix = '') => Object.entries(obj).flatMap(([k, v]) =>
typeof v === 'string' ? [[`${prefix}${k}`, v]] : v && typeof v === 'object' && !Array.isArray(v) ? flatten(v, `${prefix}${k}.`) : [])
// The bundle is built from the shared source and loaded through createAddon,
// the path the host takes, so a helper that loses its binding fails here.
async function load({ admin = true, features = [] } = {}) {
const root = join(here, 'fixture')
copyFileSync(join(here, '../src/tools.css'), join(root, 'src/style.css'))
const dir = await buildAddonUI({ root, name: 'fixture', rgb: [1, 2, 3] })
const mod = await import(pathToFileURL(join(dir, 'ui/fixture.js')).href + `?t=${Date.now()}`)
const { createHostSDK } = await import(pathToFileURL(join(hostUi, 'src/addons/sdk.js')).href)
const sdk = createHostSDK({
addonId: 'fixture', generation: 1, signal: new AbortController().signal, pages: ['steps', 'checks', 'stage', 'log', 'off'],
host: {
React, components: {}, hooks: {}, viewer: { isAdmin: () => admin, hasFeature: (f) => features.includes(f) },
basePath: '', fetch: async () => ({ ok: true, json: async () => ({}) }),
Icon: ({ name }) => React.createElement('i', { 'data-icon': name }), theme: { mode: () => 'light', subscribe: () => () => {} },
language: () => 'en', subscribeLanguage: () => () => {}, api: {},
},
bindings: { current: () => ({ page: 'steps', path: '', search: '' }), openLayer: () => ({ close() {} }), notify() {} },
})
const tools = JSON.parse(readFileSync(join(hostUi, 'public/locales/en/tools.json'), 'utf8'))
sdk.locale.define('en', Object.fromEntries(flatten(tools)))
return { mod, sdk }
}
const draw = async (page, props, viewer) => {
const { mod, sdk } = await load(viewer)
const addon = await mod.createAddon(sdk, { props: { [page]: props } })
return renderToString(React.createElement(addon.Component, { page, path: '' }))
}
test('ToolSteps marks the steps before the current one as done', async () => {
const html = await draw('steps', { steps: [{ key: 'a', label: 'One' }, { key: 'b', label: 'Two' }, { key: 'c', label: 'Three' }], current: 1, label: 'Progress' })
assert.match(html, /data-testid="tool-steps"/)
assert.match(html, /<li[^>]*data-state="done"[^>]*data-step="a"/)
assert.match(html, /data-state="current"/)
assert.match(html, /data-state="todo"/)
assert.match(html, /data-icon="check"/)
})
test('ToolSteps shows a failed current step with a close mark', async () => {
const html = await draw('steps', { steps: [{ key: 'a', label: 'One' }], current: 0, failed: true })
assert.match(html, /data-state="failed"/)
assert.match(html, /data-icon="close"/)
})
test('Checks draws one row per check, with sizes and the meter', async () => {
const GB = 1024 ** 3
const html = await draw('checks', {
checks: [{ id: 'disk', key: 'disk.ok', tone: 'ok', vals: { free: 8 * GB, total: 16 * GB }, meter: { used: 8 * GB, total: 16 * GB } }],
})
assert.match(html, /data-testid="tool-checks"/)
assert.match(html, /data-check="disk"/)
assert.match(html, /data-tone="ok"/)
assert.match(html, /dk-meter-seg--b/)
assert.match(html, /--dk-w:50%/)
})
test('Checks links to the backends only for an administrator', async () => {
const checks = [{ id: 'backend', key: 'backend.missing', tone: 'fail', action: 'backends', vals: {} }]
assert.match(await draw('checks', { checks, isAdmin: true }), /href="\/app\/backends"/)
assert.doesNotMatch(await draw('checks', { checks, isAdmin: false }), /\/app\/backends/)
})
test('StageLine fills the stages before the current one', async () => {
const html = await draw('stage', { stages: ['queued', 'training', 'saving'], labels: { queued: 'Queued', training: 'Training', saving: 'Saving' }, status: 'training' })
assert.match(html, /data-testid="job-stages"/)
assert.match(html, /Training/)
assert.equal((html.match(/data-state="done"/g) || []).length, 1)
assert.equal((html.match(/data-state="current"/g) || []).length, 1)
})
test('JobLog lists the lines it is given, with the heading from the bundle', async () => {
const html = await draw('log', { lines: [{ at: new Date(2026, 0, 1, 9, 5, 7), level: 'info', tag: 'job', text: 'started' }], name: 'ft' })
assert.match(html, /data-testid="job-log"/)
assert.match(html, /09:05:07/)
assert.match(html, />started</)
assert.doesNotMatch(html, /log\.title/)
})
test('ToolOff shows the notice when the feature is missing', async () => {
const html = await draw('off', { tool: 'fineTune', feature: 'fine_tuning' }, { admin: false, features: [] })
assert.match(html, /data-testid="tool-off"/)
assert.match(html, /data-cause="account"/)
assert.doesNotMatch(html, /children/)
assert.doesNotMatch(html, /off\.fineTune\.title/)
assert.match(html, /href="\/app"/)
})
test('ToolOff draws its children when the feature is held', async () => {
const html = await draw('off', { tool: 'fineTune', feature: 'fine_tuning' }, { features: ['fine_tuning'] })
assert.match(html, /<p>children<\/p>/)
assert.doesNotMatch(html, /tool-off/)
})
@@ -0,0 +1,28 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSse } from '../src/sse.js'
function stream(chunks) {
const enc = new TextEncoder()
return { body: new ReadableStream({ start(c) { for (const x of chunks) c.enqueue(enc.encode(x)); c.close() } }) }
}
test('readSse yields one event per data block, across chunk boundaries', async () => {
const got = []
await readSse(stream(['data: {"a":1}\n\nda', 'ta: {"a":2}\n\n']), (e) => got.push(e))
assert.deepEqual(got, [{ a: 1 }, { a: 2 }])
})
test('readSse ignores comments and events that are not JSON', async () => {
const got = []
await readSse(stream([': keep-alive\n\ndata: not json\n\ndata: {"ok":true}\n\n']), (e) => got.push(e))
assert.deepEqual(got, [{ ok: true }])
})
test('readSse stops when the signal aborts', async () => {
const ctl = new AbortController()
const got = []
const p = readSse(stream(['data: {"a":1}\n\n']), (e) => { got.push(e); ctl.abort() }, ctl.signal)
await p
assert.equal(got.length, 1)
})
@@ -0,0 +1,204 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
appendLog, blocked, detectSource, fineTuneChecks, fitFor, importChecks, importRequest, jobToSurface,
joinCommands, logLines, logLevel, LOG_LIMIT, looksLikeMemoryFailure, machineFacts, quantizeChecks,
requestText, shortToken, sortNetworks, toolStatus, warned, workerCount,
} from '../src/tools.js'
const GB = 1024 ** 3
const gpuHost = {
type: 'gpu',
gpus: [{ name: 'RTX 4070', total_vram: 12 * GB, free_vram: 9 * GB }],
ram: { total: 32 * GB, available: 20 * GB, free: 10 * GB },
disk: { total: 500 * GB, available: 118 * GB },
}
const cpuHost = { type: 'ram', gpus: null, ram: { total: 32 * GB, free: 12 * GB }, disk: { total: 500 * GB, available: 2 * GB } }
test('machineFacts reads the host and leaves unknowns null', () => {
const f = machineFacts(gpuHost)
assert.equal(f.hasGpu, true)
assert.equal(f.gpuName, 'RTX 4070')
assert.equal(f.vramFree, 9 * GB)
assert.equal(f.ramFree, 20 * GB)
assert.equal(f.diskFree, 118 * GB)
const c = machineFacts(cpuHost)
assert.equal(c.hasGpu, false)
assert.equal(c.vramFree, null)
assert.equal(c.ramFree, 12 * GB)
assert.equal(machineFacts(null), null)
assert.equal(machineFacts({}).diskFree, null)
})
test('machineFacts names several GPUs and reads the cluster block', () => {
const f = machineFacts({ ...gpuHost, gpus: [gpuHost.gpus[0], gpuHost.gpus[0]], cluster: { enabled: true, total_memory: 80 * GB, node_name: 'w1', is_gpu: true, node_count: 3 } })
assert.equal(f.gpuName, 'RTX 4070 x2')
assert.equal(f.vramTotal, 24 * GB)
assert.deepEqual(f.cluster, { node: 'w1', total: 80 * GB, isGpu: true, nodes: 3 })
})
test('toolStatus: quantize and fine-tune say what is missing and why', () => {
const facts = machineFacts(cpuHost)
assert.equal(toolStatus('quantize', { facts, backends: { quantize: [] } }).state, 'needs-backend')
const q = toolStatus('quantize', { facts, backends: { quantize: [{ name: 'llama-cpp-quantization' }] } })
assert.equal(q.state, 'ready')
assert.equal(q.needs[0].vals.name, 'llama-cpp-quantization')
const none = toolStatus('fine-tune', { facts, backends: { fineTune: [{ name: 'trl' }] } })
assert.equal(none.state, 'ready')
assert.ok(none.needs.some(n => n.key === 'fineTune.noGpu' && n.tone === 'warn'))
const gpu = toolStatus('fine-tune', { facts: machineFacts(gpuHost), backends: { fineTune: [{ name: 'trl' }] } })
assert.ok(gpu.needs.some(n => n.key === 'fineTune.gpu' && n.vals.free === 9 * GB))
assert.equal(toolStatus('fine-tune', { facts: null, backends: { fineTune: null } }).needs[0].key, 'fineTune.backendUnknown')
assert.ok(toolStatus('import', { facts, backends: {} }).needs.some(n => n.key === 'import.disk'))
})
test('jobToSurface prefers a running job, then a newest failure', () => {
const stages = ['queued', 'training']
const jobs = [
{ id: 'a', status: 'completed', created_at: '2026-10-01T10:00:00Z' },
{ id: 'b', status: 'training', created_at: '2026-10-02T10:00:00Z' },
{ id: 'c', status: 'failed', created_at: '2026-10-03T10:00:00Z' },
]
assert.deepEqual(jobToSurface(jobs, stages), { job: jobs[1], kind: 'running' })
assert.deepEqual(jobToSurface([jobs[0], jobs[2]], stages), { job: jobs[2], kind: 'failed' })
assert.equal(jobToSurface([jobs[2], { id: 'd', status: 'completed', created_at: '2026-10-04T10:00:00Z' }], stages), null)
assert.equal(jobToSurface(null, stages), null)
})
test('log lines come from what the event holds and nothing else', () => {
const at = new Date(2026, 9, 8, 10, 2, 11)
const first = logLines({ status: 'training', message: 'Loading model', current_step: 0, total_steps: 900 }, null, at)
assert.deepEqual(first.map(l => l.text), ['training', 'Loading model'])
const same = logLines({ status: 'training', message: 'Loading model' }, { status: 'training', message: 'Loading model' }, at)
assert.deepEqual(same, [])
const step = logLines({ status: 'training', message: '', current_step: 10, total_steps: 900, loss: 2.2871, learning_rate: 0.0001 }, { status: 'training', current_step: 0 }, at)
assert.equal(step[0].text, 'step 10/900 loss 2.2871 lr 1.0e-4')
const failed = logLines({ status: 'failed', message: 'CUDA out of memory' }, { status: 'training' }, at)
assert.deepEqual(failed.map(l => l.level), ['err', 'err'])
})
test('logLevel flags warnings and failures from the message', () => {
assert.equal(logLevel({ status: 'training', message: 'Warning: checkpoint slow' }), 'warn')
assert.equal(logLevel({ status: 'training', message: 'torch.OutOfMemoryError: CUDA out of memory' }), 'err')
assert.equal(logLevel({ status: 'training', message: 'step 3' }), 'info')
})
test('appendLog keeps the newest lines', () => {
const lines = Array.from({ length: LOG_LIMIT + 5 }, (_, i) => ({ at: new Date(), level: 'info', tag: 'info', text: String(i) }))
const out = appendLog([], lines)
assert.equal(out.length, LOG_LIMIT)
assert.equal(out[out.length - 1].text, String(LOG_LIMIT + 4))
assert.equal(appendLog(out, []), out)
})
test('looksLikeMemoryFailure reads the server message', () => {
assert.equal(looksLikeMemoryFailure('CUDA out of memory. Tried to allocate 1.34 GiB'), true)
assert.equal(looksLikeMemoryFailure('dataset not found'), false)
})
const form = { model: 'TinyLlama/TinyLlama-1.1B-Chat-v1.0', datasetSource: 'tatsu-lab/alpaca', backend: 'trl', batchSize: 2, maxSeqLength: 2048, gradAccum: 4, gradCheckpointing: false, trainingType: 'lora', trainingMethod: 'sft', rewardCount: 0 }
test('fine-tune checks block on a missing model or dataset and warn on the rest', () => {
const facts = machineFacts(gpuHost)
const ok = fineTuneChecks({ form, facts, backends: { fineTune: [{ name: 'trl' }] } })
assert.equal(blocked(ok), false)
assert.equal(ok.find(c => c.id === 'memory').vals.batch, 2)
assert.ok(ok.find(c => c.id === 'disk').meter.used > 0)
const bad = fineTuneChecks({ form: { ...form, model: '', datasetSource: '' }, facts, backends: { fineTune: [{ name: 'trl' }] } })
assert.equal(blocked(bad), true)
assert.deepEqual(bad.filter(c => c.tone === 'fail').map(c => c.id).sort(), ['data', 'model'])
const missing = fineTuneChecks({ form, facts: machineFacts(cpuHost), backends: { fineTune: [] } })
assert.equal(blocked(missing), false)
assert.ok(warned(missing) >= 3)
assert.equal(missing.find(c => c.id === 'backend').action, 'backends')
assert.equal(missing.find(c => c.id === 'disk').key, 'fineTune.diskLow')
})
test('fine-tune checks follow the form as it changes', () => {
const facts = machineFacts(gpuHost)
const grpo = fineTuneChecks({ form: { ...form, trainingMethod: 'grpo' }, facts, backends: { fineTune: [{ name: 'trl' }] } })
assert.ok(grpo.some(c => c.id === 'reward'))
const withReward = fineTuneChecks({ form: { ...form, trainingMethod: 'grpo', rewardCount: 2 }, facts, backends: { fineTune: [{ name: 'trl' }] } })
assert.ok(!withReward.some(c => c.id === 'reward'))
const file = fineTuneChecks({ form: { ...form, datasetSource: '', datasetFileName: 'data.jsonl' }, facts, backends: null })
assert.equal(file.find(c => c.id === 'data').tone, 'ok')
assert.equal(file.find(c => c.id === 'backend').tone, 'info')
assert.equal(fineTuneChecks({ form: { ...form, hfToken: 'x' }, facts: null, backends: null }).find(c => c.id === 'access').tone, 'ok')
})
test('quantize checks need a model, a custom type when asked, and say about the backend', () => {
const facts = machineFacts(cpuHost)
const base = { model: 'meta-llama/Llama-3.2-1B', backend: 'llama-cpp-quantization', useCustom: false, customType: '' }
const ok = quantizeChecks({ form: base, facts, backends: { quantize: [{ name: 'llama-cpp-quantization' }] } })
assert.equal(blocked(ok), false)
assert.ok(ok.some(c => c.id === 'memory'))
const custom = quantizeChecks({ form: { ...base, useCustom: true }, facts, backends: { quantize: [{ name: 'llama-cpp-quantization' }] } })
assert.equal(blocked(custom), true)
assert.equal(blocked(quantizeChecks({ form: { ...base, model: ' ' }, facts, backends: null })), true)
})
test('detectSource reads the spelling of each source', () => {
assert.deepEqual(detectSource('huggingface://Qwen/Qwen3-8B'), { kind: 'huggingface', ref: 'Qwen/Qwen3-8B', file: '', ok: true, scheme: 'huggingface' })
assert.equal(detectSource('hf://owner/repo').kind, 'huggingface')
const url = detectSource('https://huggingface.co/owner/repo/resolve/main/model.Q4_K_M.gguf')
assert.equal(url.kind, 'huggingface')
assert.equal(url.file, 'model.Q4_K_M.gguf')
assert.equal(detectSource('owner/repo').kind, 'huggingface')
assert.equal(detectSource('https://example.com/model.gguf').file, 'model.gguf')
assert.equal(detectSource('https://example.com/config.yaml').kind, 'config')
assert.equal(detectSource('oci://registry.example.com/model:tag').kind, 'oci')
assert.equal(detectSource('ollama://llama3.2:3b').ref, 'llama3.2:3b')
assert.equal(detectSource('file:///models/model.gguf').kind, 'local')
assert.equal(detectSource('/models/config.yml').kind, 'config')
assert.equal(detectSource('hf://onlyone').ok, false)
assert.equal(detectSource('nonsense').kind, 'unknown')
assert.equal(detectSource(' '), null)
})
test('importRequest builds the request the form sends, and the preview prints it', () => {
const prefs = { backend: '', name: ' my-model ', description: '', quantizations: 'q4_k_m', mmproj_quantizations: '', embeddings: true, type: '', pipeline_type: '', scheduler_type: '', enable_parameters: '', cuda: false }
const req = importRequest(' hf://o/r ', prefs, [{ key: 'x', value: 'y' }, { key: '', value: 'z' }])
assert.deepEqual(req, { uri: 'hf://o/r', preferences: { name: 'my-model', quantizations: 'q4_k_m', embeddings: 'true', x: 'y' } })
assert.equal(importRequest('hf://o/r', { ...prefs, name: '', quantizations: '', embeddings: false }, []).preferences, null)
assert.equal(importRequest('hf://o/r', { ...prefs }, [], 'piper').preferences.backend, 'piper')
assert.equal(requestText(req), 'uri: hf://o/r\npreferences:\n name: my-model\n quantizations: q4_k_m\n embeddings: "true"\n x: y')
assert.match(requestText(importRequest('hf://o/r', { ...prefs, name: '', quantizations: '', embeddings: false }, [])), /preferences: none/)
})
test('importChecks state only what is known before the import starts', () => {
const facts = machineFacts(gpuHost)
const prefs = { backend: 'vllm', name: 'taken' }
const backends = [{ name: 'vllm', installed: false }, { name: 'llama-cpp', installed: true }]
const checks = importChecks({ source: detectSource('hf://o/r'), prefs, backends, installedNames: ['taken'], facts })
const by = (id) => checks.find(c => c.id === id)
assert.equal(by('source').tone, 'ok')
assert.equal(by('backend').key, 'import.backendDownload')
assert.equal(by('name').tone, 'warn')
assert.equal(by('disk').vals.free, 118 * GB)
assert.equal(by('memory').vals.where, 'gpu')
assert.equal(importChecks({ source: null, prefs, backends: [], installedNames: [], facts }).length, 0)
const auto = importChecks({ source: detectSource('nonsense'), prefs: { backend: '', name: '' }, backends: [], installedNames: [], facts: null })
assert.deepEqual(auto.map(c => c.key), ['import.sourceUnknown', 'import.backendAuto', 'import.nameAuto'])
})
test('fitFor compares the import estimate with free memory', () => {
assert.equal(fitFor(4 * GB, 10 * GB), 'fits')
assert.equal(fitFor(9 * GB, 10 * GB), 'tight')
assert.equal(fitFor(11 * GB, 10 * GB), 'over')
assert.equal(fitFor(0, 10 * GB), null)
assert.equal(fitFor(4 * GB, null), null)
})
test('explorer helpers shorten a token, count workers and write the join commands', () => {
assert.equal(shortToken('short'), 'short')
assert.equal(shortToken('abcdefghijklmnopqrstuvwxyz'), 'abcdef…uvwxyz')
const a = { name: 'b', Clusters: [{ Workers: ['1', '2'] }, { Workers: ['3'] }] }
const b = { name: 'a', Clusters: [{ Workers: ['1', '2', '3'] }] }
const c = { name: 'c', Clusters: [{ Workers: ['1'] }] }
assert.equal(workerCount(a), 3)
assert.deepEqual(sortNetworks([c, a, b]).map(n => n.name), ['a', 'b', 'c'])
const cmd = joinCommands('TOK', 'net-1')
assert.match(cmd.docker, /LOCALAI_P2P_NETWORK_ID=net-1 .*TOKEN="TOK".* federated --debug$/)
assert.match(cmd.cli, /^ADDRESS=":80" LOCALAI_P2P_NETWORK_ID=net-1 .* local-ai federated --debug$/)
})
+8
View File
@@ -0,0 +1,8 @@
{
"name": "localai-addon-uibuild",
"private": true,
"version": "0.1.0",
"description": "Shared bundle builder of the first-party add-on UIs.",
"type": "module",
"scripts": { "test": "node --test test/uibuild.test.mjs" }
}
@@ -0,0 +1,2 @@
import useObjectUrl from '../../../../../../core/http/react-ui/src/hooks/useObjectUrl.js'
export function createAddon() { return { use: useObjectUrl, dispose() {} } }
@@ -0,0 +1 @@
.fx { color: inherit; }
@@ -0,0 +1,2 @@
import 'react'
export function createAddon() { return { dispose() {} } }
@@ -0,0 +1 @@
.fx { color: inherit; }
@@ -0,0 +1,4 @@
export function createAddon(sdk) {
const h = sdk.React.createElement
return { Component: () => <p className="fx">fixture</p>, dispose() {} }
}
@@ -0,0 +1 @@
.fx { color: inherit; }
@@ -0,0 +1,47 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { existsSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { buildAddonUI } from '../uibuild.mjs'
const here = dirname(fileURLToPath(import.meta.url))
// The solid icon is 128x128 RGBA; read the first pixel's colour from the PNG.
async function firstPixel(png) {
const { inflateSync } = await import('node:zlib')
let off = 8, idat = []
while (off < png.length) {
const len = png.readUInt32BE(off)
if (png.toString('latin1', off + 4, off + 8) === 'IDAT') idat.push(png.subarray(off + 8, off + 8 + len))
off += 12 + len
}
const raw = inflateSync(Buffer.concat(idat))
return [...raw.subarray(64 * (128 * 4 + 1) + 1 + 64 * 4, 64 * (128 * 4 + 1) + 1 + 64 * 4 + 3)]
}
test('buildAddonUI writes the module, the style sheet and a PNG icon', async () => {
const dist = await buildAddonUI({ root: join(here, 'fixture'), name: 'fixture', rgb: [26, 62, 138] })
assert.ok(existsSync(join(dist, 'ui/fixture.js')))
assert.equal(readFileSync(join(dist, 'ui/fixture.css'), 'utf8'), readFileSync(join(here, 'fixture/src/style.css'), 'utf8'))
const png = readFileSync(join(dist, 'ui/icon.png'))
assert.deepEqual([...png.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10])
assert.ok(png.length < 256 * 1024, 'the icon must fit the 256 KiB manifest bound')
assert.deepEqual(await firstPixel(png), [26, 62, 138])
})
test('the bundle is an ES module that exports createAddon and calls h for JSX', async () => {
const dist = await buildAddonUI({ root: join(here, 'fixture'), name: 'fixture', rgb: [1, 2, 3] })
const js = readFileSync(join(dist, 'ui/fixture.js'), 'utf8')
assert.match(js, /export\s*\{[^}]*createAddon/)
assert.match(js, /\bh\(\s*["']p["']/)
assert.doesNotMatch(js, /from\s*['"]react/)
})
test('a bare React import makes the build fail', async () => {
await assert.rejects(buildAddonUI({ root: join(here, 'fixture-react'), name: 'x', rgb: [1, 2, 3] }), /must not import React.*react/)
})
test('a relative import of a host UI file makes the build fail', async () => {
await assert.rejects(buildAddonUI({ root: join(here, 'fixture-core'), name: 'x', rgb: [1, 2, 3] }), /host UI file .*useObjectUrl\.js/)
})
+91
View File
@@ -0,0 +1,91 @@
// Builds the UI package files of a first-party add-on, laid out as the
// package root expects them: dist/ui/<name>.js (one ES module), dist/ui/<name>.css
// and dist/ui/icon.png. It uses the bundler the host UI already has installed
// (core/http/react-ui/node_modules), compiles JSX to calls of `h`, and never
// bundles React: the host's React arrives through the SDK. The same method as
// addons/tables/ui/build.mjs, for add-ons that share this builder.
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import { dirname, join, resolve, sep } from 'node:path'
import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { deflateSync } from 'node:zlib'
const here = dirname(fileURLToPath(import.meta.url))
const hostUi = resolve(here, '../../../core/http/react-ui')
const require = createRequire(join(hostUi, 'package.json'))
// A solid rounded square, 128 by 128, written by hand so a package build needs
// no image tool and no download.
function solidIcon([r, g, b]) {
const size = 128
const row = size * 4 + 1
const px = Buffer.alloc(size * row)
const radius = 22
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
const cx = Math.min(Math.max(x, radius), size - 1 - radius)
const cy = Math.min(Math.max(y, radius), size - 1 - radius)
const inside = (x - cx) ** 2 + (y - cy) ** 2 <= radius * radius
const o = y * row + 1 + x * 4
px[o] = r; px[o + 1] = g; px[o + 2] = b; px[o + 3] = inside ? 255 : 0
}
}
const table = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0 })
const crc = (buf) => { let c = 0xffffffff; for (const byte of buf) c = table[(c ^ byte) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0 }
const chunk = (type, data) => {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length)
const body = Buffer.concat([Buffer.from(type), data])
const sum = Buffer.alloc(4); sum.writeUInt32BE(crc(body))
return Buffer.concat([len, body, sum])
}
const head = Buffer.alloc(13)
head.writeUInt32BE(size, 0); head.writeUInt32BE(size, 4)
head[8] = 8; head[9] = 6
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', head), chunk('IDAT', deflateSync(px, { level: 9 })), chunk('IEND', Buffer.alloc(0))])
}
// Fails the build when a module of the host UI tree (src/ or node_modules/)
// is pulled into the bundle: the add-on uses the host's shared components
// through the SDK, never a second copy.
function hostGuard() {
const prefix = hostUi + sep
return {
name: 'addon-host-guard',
load(id) {
if (id.startsWith(prefix)) throw new Error(`the bundle must not include the host UI file ${id}; use the SDK instead`)
return null
},
}
}
const reactImport = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)(['"])(react|react-dom)(?:\/[^'"]*)?\1/
export async function buildAddonUI({ root, name, rgb }) {
const out = join(root, 'dist')
const { build: bundle } = await import(require.resolve('rolldown'))
rmSync(out, { recursive: true, force: true })
mkdirSync(join(out, 'ui'), { recursive: true })
await bundle({
input: join(root, 'src/index.jsx'),
// React stays a visible import so the check below can see it. The host
// gives the add-on React through the SDK, so any such import is refused.
external: [/^react(-dom)?(\/|$)/],
plugins: [hostGuard()],
output: { file: join(out, `ui/${name}.js`), format: 'esm', minify: false, sourcemap: false },
transform: { jsx: { runtime: 'classic', pragma: 'h', pragmaFrag: 'Fragment' } },
logLevel: 'warn',
})
copyFileSync(join(root, 'src/style.css'), join(out, `ui/${name}.css`))
writeFileSync(join(out, 'ui/icon.png'), solidIcon(rgb))
const js = readFileSync(join(out, `ui/${name}.js`), 'utf8')
const bad = reactImport.exec(js)
if (bad) throw new Error(`the bundle must not import React: found ${bad[0]}`)
if (/\brequire\(/.test(js)) throw new Error('the bundle must not call require()')
return out
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
const [root, name, r, g, b] = process.argv.slice(2)
buildAddonUI({ root: resolve(root), name, rgb: [Number(r), Number(g), Number(b)] })
.then((dir) => console.log(`built ${dir}`), (e) => { console.error(e); process.exit(1) })
}
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: MIT
// Command quantization is the executable of the quantization add-on. It serves
// the typed tools of the add-on and reaches the job engine through the host
// operations; the add-on UI works in the browser through the SDK.
package main
import (
"os"
"github.com/mudler/LocalAI/addons/internal/hostclient"
"github.com/mudler/LocalAI/addons/internal/protocol"
"github.com/mudler/LocalAI/addons/quantization/internal/tools"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
func main() {
os.Exit(protocol.Run(os.Args, func(s protocol.Startup) contract.Runtime {
return tools.New(hostclient.New(s), s.DataDir)
}))
}
@@ -0,0 +1,138 @@
// SPDX-License-Identifier: MIT
package tools
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sync"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// maxReceipts bounds the receipts the journal keeps. A repeat of an older
// operation is not answered from memory any more; the host asks for a receipt
// only for a recent, unsettled operation.
const maxReceipts = 1024
// job is what the add-on knows of one resource a tool created: a job it
// started. The engine job ID never leaves the add-on: a model names the job by
// its resource.
type job struct {
JobID string `json:"jobId,omitempty"`
Name string `json:"name"`
// Revision counts the changes a tool made to the job. It is the data
// revision of the resource, so a stale approval is noticed.
Revision uint64 `json:"revision"`
}
type journalFile struct {
Jobs map[string]job `json:"jobs"`
Receipts map[string]contract.Receipt `json:"receipts"`
Order []string `json:"order"`
}
// journal maps resource IDs to engine job IDs and keeps the receipts of the
// operations that changed something. It is one JSON file in the data
// directory, written through a temporary file and renamed over the old one.
type journal struct {
mu sync.Mutex
path string
data journalFile
}
func openJournal(dir string) *journal {
j := &journal{path: filepath.Join(dir, "journal.json")}
j.data = journalFile{Jobs: map[string]job{}, Receipts: map[string]contract.Receipt{}}
if b, err := os.ReadFile(j.path); err == nil {
var f journalFile
if json.Unmarshal(b, &f) == nil {
if f.Jobs != nil {
j.data.Jobs = f.Jobs
}
if f.Receipts != nil {
j.data.Receipts = f.Receipts
}
j.data.Order = f.Order
}
}
return j
}
func (j *journal) job(resource string) (job, bool) {
j.mu.Lock()
defer j.mu.Unlock()
v, ok := j.data.Jobs[resource]
return v, ok
}
func (j *journal) receipt(op string) (contract.Receipt, bool) {
j.mu.Lock()
defer j.mu.Unlock()
r, ok := j.data.Receipts[op]
return r, ok
}
// commit stores the receipt of an operation and, when upd is not nil, the new
// state of a job in one write. A failed write leaves the memory as it was, so
// the caller reports the failure and nothing claims a state that is not on disk.
func (j *journal) commit(resource string, upd *job, r contract.Receipt) error {
j.mu.Lock()
defer j.mu.Unlock()
next := journalFile{Jobs: map[string]job{}, Receipts: map[string]contract.Receipt{}, Order: append([]string(nil), j.data.Order...)}
for k, v := range j.data.Jobs {
next.Jobs[k] = v
}
for k, v := range j.data.Receipts {
next.Receipts[k] = v
}
if upd != nil {
next.Jobs[resource] = *upd
}
if _, seen := next.Receipts[r.OperationID]; !seen {
next.Order = append(next.Order, r.OperationID)
}
next.Receipts[r.OperationID] = r
for len(next.Order) > maxReceipts {
delete(next.Receipts, next.Order[0])
next.Order = next.Order[1:]
}
if err := writeAtomic(j.path, next); err != nil {
return err
}
j.data = next
return nil
}
func writeAtomic(path string, v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), "journal-*.tmp")
if err != nil {
return err
}
name := tmp.Name()
fail := func(e error) error { _ = tmp.Close(); _ = os.Remove(name); return e }
if _, err := tmp.Write(b); err != nil {
return fail(err)
}
if err := tmp.Sync(); err != nil {
return fail(err)
}
if err := tmp.Close(); err != nil {
_ = os.Remove(name)
return err
}
if err := os.Chmod(name, 0o600); err != nil {
_ = os.Remove(name)
return errors.Join(err)
}
if err := os.Rename(name, path); err != nil {
_ = os.Remove(name)
return err
}
return nil
}
+421
View File
@@ -0,0 +1,421 @@
// SPDX-License-Identifier: MIT
// Package tools is the runtime of the quantization add-on executable. A job
// that a tool starts is a resource of the host: the host reserves the resource
// ID, and this package remembers which engine job it stands for. A tool can only
// control a job that a tool started, because only those have a resource.
package tools
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"regexp"
"time"
"github.com/mudler/LocalAI/addons/internal/hostclient"
"github.com/mudler/LocalAI/addons/internal/protocol"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// Tool names. They are the entries of capabilities.tools in the manifest and
// the exact values of arguments.tool.
const (
ToolResourceExists = "resource-exists"
ToolList = "quantize-list"
ToolStart = "quantize-start"
ToolStatus = "quantize-status"
ToolStop = "quantize-stop"
ToolImport = "quantize-import"
)
// Tools is the declared tool set of this add-on, in manifest order.
//
// quantize-list is a read tool that names no resource: it lists the jobs of the
// calling user, the ones started in the UI included. The host runs it as that
// user and the host operation scopes the answer to the same user. Every other
// tool works on a resource that a tool created.
var Tools = []string{ToolResourceExists, ToolList, ToolStart, ToolStatus, ToolStop, ToolImport}
// Engine is the host operation channel. *hostclient.Client implements it, and
// a nil client answers hostclient.ErrNoHostOps.
type Engine interface {
Do(ctx context.Context, operationID, op string, args, out any) error
}
var _ Engine = (*hostclient.Client)(nil)
// schema is the schema revision of every job. The data revision counts the
// changes a tool made.
const schema uint64 = 1
var (
modelName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
// quantType is the rule of the host operation for a quantization name.
quantType = regexp.MustCompile(`^[a-z0-9_]{1,32}$`)
)
const maxNameRunes = 80
type runtime struct {
eng Engine
j *journal
now func() time.Time
}
// New returns the runtime of the executable. dataDir holds the journal.
func New(eng Engine, dataDir string) contract.Runtime {
return &runtime{eng: eng, j: openJournal(dataDir), now: time.Now}
}
// startArgs are the arguments of quantize-start. The host adds "tool"; the
// name is the label the host shows in the approval card and the resource list.
// There is no token: a secret never travels through a tool.
type startArgs struct {
Tool string `json:"tool"`
Name string `json:"name"`
Model string `json:"model"`
Backend string `json:"backend"`
QuantizationType string `json:"quantizationType"`
}
// engineStart is the argument object of quantize.start. It has exactly the
// fields the host operation accepts and none of the add-on's own.
type engineStart struct {
Model string `json:"model"`
Backend string `json:"backend,omitempty"`
QuantizationType string `json:"quantizationType,omitempty"`
}
type stopArgs struct {
Tool string `json:"tool"`
}
type importArgs struct {
Tool string `json:"tool"`
Name string `json:"name"`
}
// decode reads the arguments into a closed struct: an unknown field or
// trailing data is a bad request.
func decode(raw json.RawMessage, v any) error {
if len(raw) == 0 {
raw = json.RawMessage(`{}`)
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
return protocol.ErrBadRequest
}
if _, err := dec.Token(); err != io.EOF {
return protocol.ErrBadRequest
}
return nil
}
func toolOf(env contract.Envelope) (string, error) {
var a struct {
Tool string `json:"tool"`
}
if json.Unmarshal(env.Request.Arguments, &a) != nil || a.Tool == "" {
return "", protocol.ErrBadRequest
}
return a.Tool, nil
}
func payload(v any) json.RawMessage { b, _ := json.Marshal(v); return b }
// mapErr turns the refusal of a host operation into the protocol error that
// tells the owner what happened. A failure that does not say the request was
// wrong stays as it is, and the owner reads it as uncertain.
func mapErr(err error) error {
var he *contract.HostError
if !errors.As(err, &he) {
return err
}
switch he.Code {
case contract.HostBadRequest, contract.HostTooLarge:
return fmt.Errorf("%w: %s", protocol.ErrBadRequest, he.Message)
case contract.HostNotFound:
return fmt.Errorf("%w: %s", protocol.ErrNotFound, he.Message)
case contract.HostConflict:
return fmt.Errorf("%w: %s", protocol.ErrConflict, he.Message)
case contract.HostFeature, contract.HostScope:
return fmt.Errorf("%w: %s", protocol.ErrForbidden, he.Message)
}
return err
}
// jobOf is the job of the resource, or not found: a tool cannot control a job
// it did not start.
func (r *runtime) jobOf(env contract.Envelope) (job, error) {
if env.Request.ResourceID == "" {
return job{}, protocol.ErrNotFound
}
j, ok := r.j.job(env.Request.ResourceID)
if !ok {
return job{}, protocol.ErrNotFound
}
return j, nil
}
func (r *runtime) Read(ctx context.Context, env contract.Envelope) (contract.Result, error) {
tool, err := toolOf(env)
if err != nil {
return contract.Result{}, err
}
op := env.Request.OperationID
switch tool {
case ToolResourceExists:
j, ok := r.j.job(env.Request.ResourceID)
if !ok {
return contract.Result{Payload: payload(map[string]bool{"exists": false})}, nil
}
err := r.eng.Do(ctx, op, "quantize.get", map[string]string{"jobId": j.JobID}, nil)
var he *contract.HostError
switch {
case err == nil:
return contract.Result{Payload: payload(map[string]bool{"exists": true})}, nil
case errors.As(err, &he) && he.Code == contract.HostNotFound:
return contract.Result{Payload: payload(map[string]bool{"exists": false})}, nil
}
// The engine could not answer. The host settles a creation only on an
// explicit answer, so this is an error and never "does not exist".
return contract.Result{}, err
case ToolList:
// No resource: the host runs this read as the calling user and the
// host operation lists only that user's jobs.
var a struct {
Tool string `json:"tool"`
}
if err := decode(env.Request.Arguments, &a); err != nil {
return contract.Result{}, err
}
var got json.RawMessage
if err := r.eng.Do(ctx, op, "quantize.list", nil, &got); err != nil {
return contract.Result{}, mapErr(err)
}
return contract.Result{Payload: got}, nil
case ToolStatus:
j, err := r.jobOf(env)
if err != nil {
return contract.Result{}, err
}
var got map[string]any
if err := r.eng.Do(ctx, op, "quantize.get", map[string]string{"jobId": j.JobID}, &got); err != nil {
return contract.Result{}, mapErr(err)
}
// The engine job ID stays in the add-on. The name and the revision
// are what the host reads to label and track the resource.
if got == nil {
got = map[string]any{}
}
delete(got, "id")
got["name"] = j.Name
got["revision"] = contract.Revision{Schema: schema, Data: j.Revision}
return contract.Result{Payload: payload(got)}, nil
}
return contract.Result{}, protocol.ErrBadRequest
}
func checkStart(a startArgs) error {
if a.Model == "" || len([]rune(a.Name)) > maxNameRunes || (a.QuantizationType != "" && !quantType.MatchString(a.QuantizationType)) {
return protocol.ErrBadRequest
}
return nil
}
func checkImport(a importArgs) error {
if a.Name != "" && !modelName.MatchString(a.Name) {
return protocol.ErrBadRequest
}
return nil
}
func nameOf(a startArgs) string {
if a.Name != "" {
return a.Name
}
return a.Model
}
func (r *runtime) Preview(_ context.Context, env contract.Envelope) (contract.Preview, error) {
tool, err := toolOf(env)
if err != nil {
return contract.Preview{}, err
}
out := contract.Preview{OperationID: env.Request.OperationID, ExpiresAt: r.now().Add(time.Minute)}
switch tool {
case ToolStart:
var a startArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return out, err
}
if err := checkStart(a); err != nil {
return out, err
}
// The resource does not exist yet; an operation that already
// committed is replayed by Apply, so a repeat is not a clash.
if _, taken := r.j.job(env.Request.ResourceID); taken {
if _, done := r.j.receipt(env.Request.OperationID); !done {
return out, protocol.ErrConflict
}
}
out.Revision = contract.Revision{Schema: schema, Data: 0}
out.Summary = payload(map[string]any{"count": 1, "sample": map[string]string{
"action": "start quantization job", "name": nameOf(a), "model": a.Model, "quantizationType": a.QuantizationType,
}})
return out, nil
case ToolStop, ToolImport:
j, err := r.jobOf(env)
if err != nil {
return out, err
}
sample := map[string]any{"name": j.Name}
if tool == ToolStop {
var a stopArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return out, err
}
sample["action"] = "stop quantization job"
} else {
var a importArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return out, err
}
if err := checkImport(a); err != nil {
return out, err
}
sample["action"], sample["modelName"] = "import quantized model", a.Name
}
out.Revision = contract.Revision{Schema: schema, Data: j.Revision}
out.Summary = payload(map[string]any{"count": 1, "sample": sample})
return out, nil
}
return out, protocol.ErrBadRequest
}
func (r *runtime) Apply(ctx context.Context, env contract.Envelope, rev contract.Revision) (contract.Receipt, error) {
tool, err := toolOf(env)
if err != nil {
return contract.Receipt{}, err
}
// An operation that already committed is answered from its receipt: the
// engine ran once and runs no more.
if rec, ok := r.j.receipt(env.Request.OperationID); ok {
return rec, nil
}
switch tool {
case ToolStart:
return r.applyStart(ctx, env)
case ToolStop:
var a stopArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return contract.Receipt{}, err
}
return r.mutate(ctx, env, rev, "quantize.stop", func(id string) any {
return map[string]any{"jobId": id}
})
case ToolImport:
var a importArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return contract.Receipt{}, err
}
if err := checkImport(a); err != nil {
return contract.Receipt{}, err
}
return r.mutate(ctx, env, rev, "quantize.import", func(id string) any {
args := map[string]any{"jobId": id}
if a.Name != "" {
args["name"] = a.Name
}
return args
})
}
return contract.Receipt{}, protocol.ErrBadRequest
}
func (r *runtime) applyStart(ctx context.Context, env contract.Envelope) (contract.Receipt, error) {
var a startArgs
if err := decode(env.Request.Arguments, &a); err != nil {
return contract.Receipt{}, err
}
if err := checkStart(a); err != nil {
return contract.Receipt{}, err
}
res, op := env.Request.ResourceID, env.Request.OperationID
if res == "" {
return contract.Receipt{}, protocol.ErrBadRequest
}
if _, taken := r.j.job(res); taken {
return contract.Receipt{}, protocol.ErrConflict
}
var started struct {
ID string `json:"id"`
Status string `json:"status"`
Message string `json:"message"`
}
err := r.eng.Do(ctx, op, "quantize.start", engineStart{Model: a.Model, Backend: a.Backend, QuantizationType: a.QuantizationType}, &started)
if err != nil {
return contract.Receipt{}, mapErr(err)
}
if started.ID == "" {
return contract.Receipt{}, errors.New("the engine started a job and returned no ID")
}
rec := contract.Receipt{
OperationID: op, State: contract.ReceiptCommitted, Revision: contract.Revision{Schema: schema, Data: 1},
Result: contract.Result{Payload: payload(map[string]string{"status": started.Status, "message": started.Message})},
}
// If this write fails the job runs without a mapping. The error is
// uncertain, so the host asks again with the same operation ID and the
// engine answers it with the same job.
if err := r.j.commit(res, &job{JobID: started.ID, Name: nameOf(a), Revision: 1}, rec); err != nil {
return contract.Receipt{}, err
}
return rec, nil
}
// mutate runs a host operation on the job of the resource, once, if the
// resource is still at the revision the person approved.
func (r *runtime) mutate(ctx context.Context, env contract.Envelope, rev contract.Revision, op string, args func(jobID string) any) (contract.Receipt, error) {
j, err := r.jobOf(env)
if err != nil {
return contract.Receipt{}, err
}
id := env.Request.OperationID
if rev.Schema != schema || rev.Data != j.Revision {
return contract.Receipt{OperationID: id, State: contract.ReceiptRejected,
Result: contract.Result{Payload: payload(map[string]any{"error": "stale", "revision": contract.Revision{Schema: schema, Data: j.Revision}})}}, nil
}
var out json.RawMessage
if err := r.eng.Do(ctx, id, op, args(j.JobID), &out); err != nil {
return contract.Receipt{}, mapErr(err)
}
next := j
next.Revision++
rec := contract.Receipt{OperationID: id, State: contract.ReceiptCommitted, Revision: contract.Revision{Schema: schema, Data: next.Revision}}
if len(out) > 0 && json.Valid(out) {
rec.Result.Payload = out
} else {
rec.Result.Payload = payload(map[string]any{})
}
if err := r.j.commit(env.Request.ResourceID, &next, rec); err != nil {
return contract.Receipt{}, err
}
return rec, nil
}
func (r *runtime) Receipt(_ context.Context, op string) (contract.Receipt, error) {
if rec, ok := r.j.receipt(op); ok {
return rec, nil
}
return contract.Receipt{}, protocol.ErrNotFound
}
// OpenOutput refuses: no tool of this add-on produces a file.
func (r *runtime) OpenOutput(context.Context, contract.Envelope, string) (io.ReadCloser, error) {
return nil, protocol.ErrNotFound
}
@@ -0,0 +1,515 @@
// SPDX-License-Identifier: MIT
package tools
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"time"
"github.com/mudler/LocalAI/addons/internal/hostclient"
"github.com/mudler/LocalAI/addons/internal/protocol"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
var ctx = context.Background()
type call struct {
opID, op string
args map[string]any
}
// fakeEngine records host operations and answers from canned results. A
// result of type error is returned as the failure of that operation.
type fakeEngine struct {
mu sync.Mutex
result map[string]any
calls []call
}
func (f *fakeEngine) Do(_ context.Context, opID, op string, args, out any) error {
f.mu.Lock()
defer f.mu.Unlock()
var a map[string]any
if args != nil {
b, _ := json.Marshal(args)
_ = json.Unmarshal(b, &a)
}
f.calls = append(f.calls, call{opID, op, a})
r, ok := f.result[op]
if !ok {
r = map[string]any{}
}
if err, isErr := r.(error); isErr {
return err
}
if out != nil {
b, _ := json.Marshal(r)
return json.Unmarshal(b, out)
}
return nil
}
func (f *fakeEngine) ops() []string {
f.mu.Lock()
defer f.mu.Unlock()
var out []string
for _, c := range f.calls {
out = append(out, c.op)
}
return out
}
func (f *fakeEngine) count(op string) int {
n := 0
for _, o := range f.ops() {
if o == op {
n++
}
}
return n
}
func (f *fakeEngine) last(op string) call {
f.mu.Lock()
defer f.mu.Unlock()
for i := len(f.calls) - 1; i >= 0; i-- {
if f.calls[i].op == op {
return f.calls[i]
}
}
return call{}
}
func envelope(tool, opID, resource, args string) contract.Envelope {
m := map[string]any{}
if args != "" {
_ = json.Unmarshal([]byte(args), &m)
}
m["tool"] = tool
raw, _ := json.Marshal(m)
return contract.Envelope{
Request: contract.Request{AddonID: "quantization", ResourceID: resource, OperationID: opID, Generation: 1, Action: "apply", Arguments: raw},
Authority: contract.Authority{Actor: contract.Actor{UserID: "u1", CredentialID: "c1", Mode: "named-user"}, Scope: "apply", ExpiresAt: time.Now().Add(time.Minute)},
}
}
const startJSON = `{"name":"my quant","model":"org/base","quantizationType":"q4_k_m"}`
// started returns a runtime that already holds the job job-1 for resource res1.
func started(t *testing.T) (contract.Runtime, *fakeEngine) {
t.Helper()
eng := &fakeEngine{result: map[string]any{"quantize.start": map[string]any{"id": "job-1", "status": "queued"}}}
rt := New(eng, t.TempDir())
rec, err := rt.Apply(ctx, envelope("quantize-start", "create-res1", "res1", startJSON), contract.Revision{Schema: 1})
if err != nil || rec.State != contract.ReceiptCommitted {
t.Fatalf("setup: %v %+v", err, rec)
}
return rt, eng
}
func TestStartCallsTheEngineOnceAndRemembersTheJob(t *testing.T) {
eng := &fakeEngine{result: map[string]any{"quantize.start": map[string]any{"id": "job-1", "status": "queued"}}}
rt := New(eng, t.TempDir())
rec, err := rt.Apply(ctx, envelope("quantize-start", "op1", "res1", startJSON), contract.Revision{Schema: 1, Data: 0})
if err != nil || rec.State != contract.ReceiptCommitted || rec.Revision.Schema == 0 {
t.Fatalf("%v %+v", err, rec)
}
if got := eng.ops(); !reflect.DeepEqual(got, []string{"quantize.start"}) {
t.Fatalf("ops %v", got)
}
again, err := rt.Apply(ctx, envelope("quantize-start", "op1", "res1", startJSON), contract.Revision{Schema: 1, Data: 1})
if err != nil || again.State != contract.ReceiptCommitted {
t.Fatalf("%v %+v", err, again)
}
if n := eng.count("quantize.start"); n != 1 {
t.Fatalf("started %d jobs", n)
}
// A different operation on the same resource is a clash, not a second job.
if _, err := rt.Apply(ctx, envelope("quantize-start", "op9", "res1", startJSON), contract.Revision{Schema: 1}); !errors.Is(err, protocol.ErrConflict) {
t.Fatalf("want a conflict, got %v", err)
}
}
func TestStartSendsOnlyTheFieldsOfTheEngineOperation(t *testing.T) {
eng := &fakeEngine{result: map[string]any{"quantize.start": map[string]any{"id": "job-1", "status": "queued"}}}
rt := New(eng, t.TempDir())
args := `{"name":"my quant","model":"org/base","backend":"llama-cpp-quantization","quantizationType":"q5_k_m"}`
if _, err := rt.Apply(ctx, envelope("quantize-start", "op1", "res1", args), contract.Revision{Schema: 1}); err != nil {
t.Fatal(err)
}
c := eng.last("quantize.start")
if c.opID != "op1" {
t.Fatalf("the call must carry the operation of the request, got %q", c.opID)
}
if len(c.args) != 3 || c.args["model"] != "org/base" || c.args["backend"] != "llama-cpp-quantization" || c.args["quantizationType"] != "q5_k_m" {
t.Fatalf("args %v", c.args)
}
}
func TestPreviewOfStartHasANonzeroRevisionAndCreatesNothing(t *testing.T) {
eng := &fakeEngine{}
rt := New(eng, t.TempDir())
p, err := rt.Preview(ctx, envelope("quantize-start", "op1", "res1", startJSON))
if err != nil || p.Revision.Schema == 0 || len(eng.ops()) != 0 {
t.Fatalf("%v %+v %v", err, p, eng.ops())
}
if !json.Valid(p.Summary) || !strings.Contains(string(p.Summary), "q4_k_m") {
t.Fatalf("summary %s", p.Summary)
}
for name, args := range map[string]string{
"empty model": `{"name":"x","model":""}`,
"bad type": `{"name":"x","model":"m","quantizationType":"Q4 K"}`,
"long name": `{"name":"` + strings.Repeat("n", 81) + `","model":"m"}`,
"a token": `{"name":"x","model":"m","hfToken":"hf_x"}`,
"unknown part": `{"name":"x","model":"m","path":"/etc"}`,
} {
if _, err := rt.Preview(ctx, envelope("quantize-start", "op-"+name, "res1", args)); !errors.Is(err, protocol.ErrBadRequest) {
t.Fatalf("%s: %v", name, err)
}
}
}
func TestStatusReadsTheJobOfTheResource(t *testing.T) {
rt, eng := started(t)
eng.result["quantize.get"] = map[string]any{"id": "job-1", "status": "quantizing", "model": "org/base"}
res, err := rt.Read(ctx, envelope("quantize-status", "r1", "res1", ""))
if err != nil {
t.Fatal(err)
}
if got := eng.last("quantize.get").args["jobId"]; got != "job-1" {
t.Fatalf("jobId %v", got)
}
var v map[string]any
if err := json.Unmarshal(res.Payload, &v); err != nil {
t.Fatal(err)
}
if v["name"] != "my quant" || v["status"] != "quantizing" {
t.Fatalf("payload %v", v)
}
rev, _ := v["revision"].(map[string]any)
if rev["schema"] == nil || rev["schema"] == float64(0) {
t.Fatalf("inspect needs a revision: %v", v)
}
if _, has := v["id"]; has {
t.Fatalf("the engine job ID must not reach the model: %v", v)
}
}
func TestAToolCannotControlAJobItDidNotStart(t *testing.T) {
eng := &fakeEngine{}
rt := New(eng, t.TempDir())
if _, err := rt.Apply(ctx, envelope("quantize-stop", "op2", "nope", `{}`), contract.Revision{Schema: 1}); !errors.Is(err, protocol.ErrNotFound) {
t.Fatalf("stop: %v", err)
}
if _, err := rt.Apply(ctx, envelope("quantize-import", "op3", "nope", `{}`), contract.Revision{Schema: 1}); !errors.Is(err, protocol.ErrNotFound) {
t.Fatalf("import: %v", err)
}
if _, err := rt.Read(ctx, envelope("quantize-status", "r1", "nope", "")); !errors.Is(err, protocol.ErrNotFound) {
t.Fatalf("status: %v", err)
}
if _, err := rt.Preview(ctx, envelope("quantize-stop", "op2", "nope", `{}`)); !errors.Is(err, protocol.ErrNotFound) {
t.Fatalf("preview: %v", err)
}
if n := len(eng.ops()); n != 0 {
t.Fatalf("the engine was called %d times: %v", n, eng.ops())
}
}
func TestStopAndImportUseTheStoredJobAndAdvanceTheRevision(t *testing.T) {
rt, eng := started(t)
eng.result["quantize.import"] = map[string]any{"model": "my-model"}
p, err := rt.Preview(ctx, envelope("quantize-stop", "op2", "res1", `{}`))
if err != nil || p.Revision.Schema == 0 {
t.Fatalf("%v %+v", err, p)
}
rec, err := rt.Apply(ctx, envelope("quantize-stop", "op2", "res1", `{}`), p.Revision)
if err != nil || rec.State != contract.ReceiptCommitted {
t.Fatalf("%v %+v", err, rec)
}
c := eng.last("quantize.stop")
if len(c.args) != 1 || c.args["jobId"] != "job-1" {
t.Fatalf("stop args %v (a quantization job has no checkpoint to save)", c.args)
}
if rec.Revision.Data <= p.Revision.Data {
t.Fatalf("the revision must advance: %+v then %+v", p.Revision, rec.Revision)
}
if _, err := rt.Apply(ctx, envelope("quantize-stop", "op2", "res1", `{}`), p.Revision); err != nil || eng.count("quantize.stop") != 1 {
t.Fatalf("stop ran %d times (%v)", eng.count("quantize.stop"), err)
}
// Import names the model and never a path.
ex, err := rt.Apply(ctx, envelope("quantize-import", "op3", "res1", `{"name":"my-model"}`), rec.Revision)
if err != nil || ex.State != contract.ReceiptCommitted || !strings.Contains(string(ex.Result.Payload), "my-model") {
t.Fatalf("%v %+v", err, ex)
}
if a := eng.last("quantize.import").args; len(a) != 2 || a["jobId"] != "job-1" || a["name"] != "my-model" {
t.Fatalf("import args %v", a)
}
if _, err := rt.Preview(ctx, envelope("quantize-import", "op4", "res1", `{"name":"../x"}`)); !errors.Is(err, protocol.ErrBadRequest) {
t.Fatalf("a bad model name must be refused, got %v", err)
}
if _, err := rt.Apply(ctx, envelope("quantize-import", "op5", "res1", `{"name":"a b"}`), ex.Revision); !errors.Is(err, protocol.ErrBadRequest) {
t.Fatalf("a bad model name must be refused at apply, got %v", err)
}
}
func TestAStaleRevisionIsRejectedAndTheEngineIsNotCalled(t *testing.T) {
rt, eng := started(t)
rec, err := rt.Apply(ctx, envelope("quantize-stop", "op2", "res1", `{}`), contract.Revision{Schema: 1, Data: 99})
if err != nil || rec.State != contract.ReceiptRejected {
t.Fatalf("%v %+v", err, rec)
}
if eng.count("quantize.stop") != 0 {
t.Fatal("the engine was called")
}
}
func TestHostRefusalsMapToProtocolErrors(t *testing.T) {
rt, eng := started(t)
for code, want := range map[string]error{
contract.HostFeature: protocol.ErrForbidden, contract.HostBadRequest: protocol.ErrBadRequest,
contract.HostNotFound: protocol.ErrNotFound, contract.HostConflict: protocol.ErrConflict,
} {
eng.result["quantize.import"] = &contract.HostError{Code: code, Message: "m"}
_, err := rt.Apply(ctx, envelope("quantize-import", "op-"+code, "res1", `{}`), contract.Revision{Schema: 1, Data: 1})
if !errors.Is(err, want) {
t.Fatalf("%s: %v, want %v", code, err, want)
}
}
eng.result["quantize.import"] = &contract.HostError{Code: contract.HostUnavailable, Message: "m"}
_, err := rt.Apply(ctx, envelope("quantize-import", "op-u", "res1", `{}`), contract.Revision{Schema: 1, Data: 1})
if err == nil || errors.Is(err, protocol.ErrBadRequest) || errors.Is(err, protocol.ErrForbidden) || errors.Is(err, protocol.ErrNotFound) || errors.Is(err, protocol.ErrConflict) {
t.Fatalf("an unavailable engine must stay uncertain, got %v", err)
}
}
func exists(t *testing.T, rt contract.Runtime, res string) (bool, error) {
t.Helper()
r, err := rt.Read(ctx, envelope("resource-exists", "x-"+res, res, ""))
if err != nil {
return false, err
}
var v struct {
Exists bool `json:"exists"`
}
_ = json.Unmarshal(r.Payload, &v)
return v.Exists, nil
}
func TestResourceExistsAnswersFromTheJournalAndTheEngine(t *testing.T) {
rt, eng := started(t)
eng.result["quantize.get"] = map[string]any{"id": "job-1", "status": "quantizing"}
if ok, err := exists(t, rt, "res1"); err != nil || !ok {
t.Fatalf("mapped: %v %v", ok, err)
}
if ok, err := exists(t, rt, "other"); err != nil || ok {
t.Fatalf("unmapped: %v %v", ok, err)
}
eng.result["quantize.get"] = &contract.HostError{Code: contract.HostNotFound, Message: "gone"}
if ok, err := exists(t, rt, "res1"); err != nil || ok {
t.Fatalf("deleted job: %v %v", ok, err)
}
eng.result["quantize.get"] = &contract.HostError{Code: contract.HostUnavailable, Message: "down"}
if _, err := exists(t, rt, "res1"); err == nil {
t.Fatal("an unavailable engine must not read as absent")
}
eng.result["quantize.get"] = hostclient.ErrNoHostOps
if _, err := exists(t, rt, "res1"); err == nil {
t.Fatal("no host endpoint must not read as absent")
}
}
func TestJournalSurvivesARestart(t *testing.T) {
dir := t.TempDir()
eng := &fakeEngine{result: map[string]any{"quantize.start": map[string]any{"id": "job-1", "status": "queued"}, "quantize.get": map[string]any{"status": "quantizing"}}}
if _, err := New(eng, dir).Apply(ctx, envelope("quantize-start", "op1", "res1", startJSON), contract.Revision{Schema: 1}); err != nil {
t.Fatal(err)
}
again := New(eng, dir)
if _, err := again.Read(ctx, envelope("quantize-status", "r1", "res1", "")); err != nil {
t.Fatal(err)
}
if eng.last("quantize.get").args["jobId"] != "job-1" {
t.Fatal("the restarted runtime lost the job")
}
if _, err := again.Apply(ctx, envelope("quantize-start", "op1", "res1", startJSON), contract.Revision{Schema: 1}); err != nil || eng.count("quantize.start") != 1 {
t.Fatalf("started %d times (%v)", eng.count("quantize.start"), err)
}
if r, err := again.Receipt(ctx, "op1"); err != nil || r.State != contract.ReceiptCommitted {
t.Fatalf("%v %+v", err, r)
}
if _, err := again.Receipt(ctx, "never"); !errors.Is(err, protocol.ErrNotFound) {
t.Fatalf("an unknown operation must be not found, got %v", err)
}
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.Contains(e.Name(), ".tmp") {
t.Fatalf("a temporary file was left: %s", e.Name())
}
}
if _, err := os.Stat(filepath.Join(dir, "journal.json")); err != nil {
t.Fatal(err)
}
}
func TestWithoutAHostEndpointEveryToolRefuses(t *testing.T) {
var nilClient *hostclient.Client
dir := t.TempDir()
rt := New(nilClient, dir)
if _, err := rt.Apply(ctx, envelope("quantize-start", "op1", "res1", startJSON), contract.Revision{Schema: 1}); !errors.Is(err, hostclient.ErrNoHostOps) {
t.Fatalf("start: %v", err)
}
seed := New(&fakeEngine{result: map[string]any{"quantize.start": map[string]any{"id": "job-1"}}}, dir)
if _, err := seed.Apply(ctx, envelope("quantize-start", "op0", "res1", startJSON), contract.Revision{Schema: 1}); err != nil {
t.Fatal(err)
}
rt = New(nilClient, dir)
for _, tool := range []string{"quantize-status", "resource-exists"} {
if _, err := rt.Read(ctx, envelope(tool, "r-"+tool, "res1", "")); !errors.Is(err, hostclient.ErrNoHostOps) {
t.Fatalf("%s: %v", tool, err)
}
}
if _, err := rt.Read(ctx, envelope("quantize-list", "r-list", "", "")); !errors.Is(err, hostclient.ErrNoHostOps) {
t.Fatalf("list: %v", err)
}
for _, tool := range []string{"quantize-stop", "quantize-import"} {
if _, err := rt.Apply(ctx, envelope(tool, "a-"+tool, "res1", `{}`), contract.Revision{Schema: 1, Data: 1}); !errors.Is(err, hostclient.ErrNoHostOps) {
t.Fatalf("%s: %v", tool, err)
}
}
}
func TestAnUnknownToolIsABadRequest(t *testing.T) {
rt := New(&fakeEngine{}, t.TempDir())
if _, err := rt.Read(ctx, envelope("quantize-nope", "r1", "res1", "")); !errors.Is(err, protocol.ErrBadRequest) {
t.Fatalf("read: %v", err)
}
if _, err := rt.Apply(ctx, envelope("quantize-nope", "a1", "res1", ""), contract.Revision{Schema: 1}); !errors.Is(err, protocol.ErrBadRequest) {
t.Fatalf("apply: %v", err)
}
if _, err := rt.OpenOutput(ctx, envelope("quantize-status", "o1", "res1", ""), "h"); !errors.Is(err, protocol.ErrNotFound) {
t.Fatalf("output: %v", err)
}
}
func TestListReadsTheCallersJobsWithoutAResource(t *testing.T) {
eng := &fakeEngine{result: map[string]any{"quantize.list": map[string]any{"jobs": []any{map[string]any{"id": "ui-job", "status": "completed"}}}}}
rt := New(eng, t.TempDir())
res, err := rt.Read(ctx, envelope("quantize-list", "r1", "", ""))
if err != nil || !strings.Contains(string(res.Payload), "ui-job") {
t.Fatalf("%v %s", err, res.Payload)
}
if got := eng.ops(); !reflect.DeepEqual(got, []string{"quantize.list"}) {
t.Fatalf("ops %v", got)
}
if c := eng.last("quantize.list"); c.args != nil {
t.Fatalf("the list takes no argument, got %v", c.args)
}
if _, err := rt.Read(ctx, envelope("quantize-list", "r2", "", `{"userId":"other"}`)); !errors.Is(err, protocol.ErrBadRequest) {
t.Fatalf("an argument must be refused, got %v", err)
}
}
func loadManifest(t *testing.T) contract.Manifest {
t.Helper()
b, err := os.ReadFile("../../manifest.json")
if err != nil {
t.Fatal(err)
}
m, err := contract.ParseManifest(b)
if err != nil {
t.Fatalf("the manifest must satisfy the contract: %v", err)
}
return m
}
func TestManifestDeclaresTheToolsAndTheResourceMapping(t *testing.T) {
m := loadManifest(t)
if m.ID != "quantization" || m.Capabilities.UI.MainPage != "jobs" || !reflect.DeepEqual(m.Capabilities.UI.Pages, []string{"jobs"}) || m.Capabilities.UI.Title == nil || m.Capabilities.UI.Title.Text != "Quantization" {
t.Fatalf("manifest %+v", m)
}
if m.Capabilities.UI.SDK.Min != 2 || m.Capabilities.UI.SDK.Max != 2 {
t.Fatalf("sdk %+v", m.Capabilities.UI.SDK)
}
if !reflect.DeepEqual(m.Capabilities.Tools, Tools) {
t.Fatalf("manifest tools %v, package tools %v", m.Capabilities.Tools, Tools)
}
r := m.Capabilities.Resources
if r == nil || r.Noun != "quantization job" || r.Create != "quantize-start" || r.Inspect != "quantize-status" {
t.Fatalf("resources %+v", r)
}
kinds := map[string]string{}
for _, d := range m.Capabilities.ToolDefinitions {
kinds[d.Name] = d.Kind
}
want := map[string]string{"quantize-list": "read", "quantize-status": "read", "quantize-start": "create", "quantize-stop": "write", "quantize-import": "write"}
if !reflect.DeepEqual(kinds, want) {
t.Fatalf("kinds %v", kinds)
}
for _, d := range m.Capabilities.ToolDefinitions {
if d.Name == "quantize-list" && !contract.IsResourceless(d) {
t.Fatal("the list tool must declare no resource")
}
if d.Name != "quantize-list" && contract.IsResourceless(d) {
t.Fatalf("%s must not be resource-less", d.Name)
}
if len(d.Description) >= 400 || !strings.Contains(d.Description, "started") {
t.Fatalf("%s: the description must be short and say that only jobs a tool started can be controlled", d.Name)
}
}
}
func TestNoToolArgumentNamesAUserAPathOrASecret(t *testing.T) {
m := loadManifest(t)
for _, d := range m.Capabilities.ToolDefinitions {
var s struct {
Properties map[string]json.RawMessage `json:"properties"`
}
if err := json.Unmarshal(d.InputSchema, &s); err != nil {
t.Fatal(err)
}
for name := range s.Properties {
lower := strings.ToLower(name)
if contract.ForbiddenToolProperty(name) || strings.Contains(lower, "path") || strings.Contains(lower, "dir") || strings.Contains(lower, "token") {
t.Fatalf("%s: property %q names an identity, a path or a secret", d.Name, name)
}
}
}
}
func TestStartSchemaHasTheHostOperationFieldsAndTheNameTheHostNeeds(t *testing.T) {
m := loadManifest(t)
for _, d := range m.Capabilities.ToolDefinitions {
if d.Name != "quantize-start" {
continue
}
var s struct {
Properties map[string]json.RawMessage `json:"properties"`
Required []string `json:"required"`
}
_ = json.Unmarshal(d.InputSchema, &s)
var got []string
for k := range s.Properties {
got = append(got, k)
}
if len(got) != 4 {
t.Fatalf("properties %v", got)
}
for _, w := range []string{"name", "model", "backend", "quantizationType"} {
if _, ok := s.Properties[w]; !ok {
t.Fatalf("missing %s", w)
}
}
if !reflect.DeepEqual(s.Required, []string{"name", "model"}) {
t.Fatalf("required %v", s.Required)
}
return
}
t.Fatal("no start definition")
}
+163
View File
@@ -0,0 +1,163 @@
{
"schemaVersion": 1,
"id": "quantization",
"version": "0.1.0",
"executable": "quantization",
"os": "linux",
"arch": "amd64",
"host": {
"min": 1,
"max": 1
},
"api": {
"min": 1,
"max": 1
},
"protocol": {
"min": 1,
"max": 1
},
"dataFormat": {
"min": 1,
"max": 1
},
"capabilities": {
"tools": [
"resource-exists",
"quantize-list",
"quantize-start",
"quantize-status",
"quantize-stop",
"quantize-import"
],
"toolDefinitions": [
{
"name": "quantize-list",
"kind": "read",
"description": "List the quantization jobs of the person you are talking to, newest first, including jobs started in the web page. A model can only control jobs it started.",
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
},
{
"name": "quantize-start",
"kind": "create",
"description": "Propose a new quantization job. A person confirms it in the approval card; nothing starts before that. A model can only control jobs it started.",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "A label for the job, 1 to 80 characters.",
"maxLength": 80
},
"model": {
"type": "string",
"description": "Model to quantize as a gallery or Hugging Face ID. A local path is refused.",
"maxLength": 256
},
"backend": {
"type": "string",
"description": "Quantization backend. The default is llama-cpp-quantization.",
"maxLength": 64
},
"quantizationType": {
"type": "string",
"description": "Quantization type, such as q4_k_m (lowercase letters, digits and underscores). The default is q4_k_m.",
"maxLength": 32
}
},
"required": [
"name",
"model"
],
"additionalProperties": false
}
},
{
"name": "quantize-status",
"kind": "read",
"description": "Show the status of a quantization job that a tool started. A model can only control jobs it started.",
"inputSchema": {
"type": "object",
"properties": {
"resourceId": {
"type": "string",
"description": "ID of the quantization job, as returned by a job start. Only a job that a tool started has one."
}
},
"required": [
"resourceId"
],
"additionalProperties": false
}
},
{
"name": "quantize-stop",
"kind": "write",
"description": "Propose stopping a quantization job that a tool started. A person confirms it. A model can only control jobs it started.",
"inputSchema": {
"type": "object",
"properties": {
"resourceId": {
"type": "string",
"description": "ID of the quantization job, as returned by a job start. Only a job that a tool started has one."
}
},
"required": [
"resourceId"
],
"additionalProperties": false
}
},
{
"name": "quantize-import",
"kind": "write",
"description": "Propose registering the result of a quantization job that a tool started as a model. Administrators only. A model can only control jobs it started.",
"inputSchema": {
"type": "object",
"properties": {
"resourceId": {
"type": "string",
"description": "ID of the quantization job, as returned by a job start. Only a job that a tool started has one."
},
"name": {
"type": "string",
"description": "Name of the model, 1 to 64 letters, digits, dots, dashes or underscores. Leave out to let the engine choose.",
"maxLength": 64
}
},
"required": [
"resourceId"
],
"additionalProperties": false
}
}
],
"resources": {
"noun": "quantization job",
"create": "quantize-start",
"inspect": "quantize-status"
},
"ui": {
"entry": "ui/quantization.js",
"css": [
"ui/quantization.css"
],
"sdk": {
"min": 2,
"max": 2
},
"pages": [
"jobs"
],
"navigation": [],
"mainPage": "jobs",
"icon": "ui/icon.png",
"title": "Quantization",
"description": "Shrink a model to a smaller quantized file, then import it so you can chat with it."
}
}
}
@@ -0,0 +1,38 @@
# Release platform matrix of the quantization add-on package.
#
# A platform ships only while "enabled" is true. Enable a platform in the same
# change that shows its native check passing on a runner of that platform;
# until then it stays listed and disabled. The release workflow fails closed
# when no platform is enabled, and it never builds a platform that is not
# enabled here.
#
# runner: the GitHub runner label that builds and checks the platform
# natively. Required for an enabled platform.
# native_check: "executable-start": the packaged executable is extracted on
# the native runner and started. Required for an enabled platform.
# runtime: files besides the executable that the platform needs at run
# time, relative to the package root. They are taken from the
# runtime directory of the build and must exist.
schema: 1
addon: quantization
executable: quantization
manifest: addons/quantization/manifest.json
# The directory whose content is laid at the package root (ui/quantization.js ...).
ui: addons/quantization/ui/dist
platforms:
- platform: linux/amd64
enabled: true
runner: ubuntu-24.04
native_check: executable-start
- platform: linux/arm64
enabled: true
runner: ubuntu-24.04-arm
native_check: executable-start
- platform: darwin/arm64
enabled: true
runner: macos-14
native_check: executable-start
- platform: darwin/amd64
enabled: true
runner: macos-15-intel
native_check: executable-start
+2
View File
@@ -0,0 +1,2 @@
# Written by build.mjs; the package build makes it.
/dist/
+13
View File
@@ -0,0 +1,13 @@
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
import { buildAddonUI } from '../../internal/uibuild/uibuild.mjs'
const here = dirname(fileURLToPath(import.meta.url))
export async function build() {
return buildAddonUI({ root: here, name: 'quantization', rgb: [38, 110, 128] })
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {
build().then((dir) => console.log(`built ${dir}`), (e) => { console.error(e); process.exit(1) })
}
+8
View File
@@ -0,0 +1,8 @@
{
"name": "localai-quantization-ui",
"private": true,
"version": "0.1.0",
"description": "Native UI of the quantization add-on: a standalone ES module that the host loads after its own build.",
"type": "module",
"scripts": { "build": "node build.mjs", "test": "node test/run.mjs" }
}
+598
View File
@@ -0,0 +1,598 @@
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config */
// The quantization page, ported from core/http/react-ui/src/pages/Quantize.jsx
// with the port table: React, the translator, the shared components, the
// session-bound fetch and the viewer come from ctx; the job views are the
// shared ones of addons/internal/tools-ui. Class names, test ids and keys are
// the core page's.
import { percentOf, ACTIVE } from './lib/progress.js'
const QUANT_PRESETS = [
'q2_k', 'q3_k_s', 'q3_k_m', 'q3_k_l',
'q4_0', 'q4_k_s', 'q4_k_m',
'q5_0', 'q5_k_s', 'q5_k_m',
'q6_k', 'q8_0', 'f16',
]
const DEFAULT_QUANT = 'q4_k_m'
const FALLBACK_BACKEND = 'llama-cpp-quantization'
const BADGE = {
queued: '', downloading: 'dk-badge--warn', converting: 'dk-badge--warn', quantizing: 'dk-badge--accent',
completed: 'dk-badge--ok', failed: 'dk-badge--error', stopped: '',
}
const startedAt = (job) => {
const d = new Date(job.created_at)
return Number.isNaN(d.getTime()) ? (job.created_at || '') : d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
// `shared` is the shared job views: { tools, readSse, ToolSteps, Checks, StageLine,
// JobLog, ToolOff }. Returns the page and, for tests, the parts it is made of.
export function createQuantize(ctx, api, shared) {
const { h, Fragment, t, Icon, ui, viewer } = ctx
const { useState, useEffect, useRef, useCallback, useMemo } = ctx.React
const { QZ_STAGES, TERMINAL, appendLog, blocked, logLines, machineFacts, quantizeChecks, warned } = shared.tools
const { ToolSteps, Checks, StageLine, JobLog, ToolOff, readSse } = shared
function StatusBadge({ status }) {
return <span className={`dk-badge ${BADGE[status] || ''}`} data-status={status}>{ctx.tOr(`quantize.status.${status}`, String(status || ''))}</span>
}
function Field({ id, label, hint, className = '', children }) {
return (
<div className={`dk-field ${className}`.trim()}>
<label className="dk-label" htmlFor={id}>{label}</label>
{children}
{hint && <p className="dk-hint">{hint}</p>}
</div>
)
}
function Section({ done, title, children }) {
return (
<section className="bt-section" data-done={done ? 'true' : 'false'}>
<h2 className="bt-section__title">
<span className="bt-section__mark" aria-hidden="true">{done ? <Icon name="check" /> : null}</span>
{title}
</h2>
<div className="bt-section__body">{children}</div>
</section>
)
}
// The foot of the delete layer. The host draws the dialog around it.
function DeleteDialogBody({ onConfirm, close }) {
return (
<div className="bt-dialogacts" data-testid="delete-dialog">
<button type="button" className="dk-btn dk-btn--ghost" onClick={close}>{t('stop.cancelDelete')}</button>
<button type="button" className="dk-btn dk-btn--danger" onClick={() => { close(); onConfirm() }} data-testid="delete-confirm">{t('jobs.delete')}</button>
</div>
)
}
// What to do with a finished quantization: import it as a model, then use it.
function ResultPanel({ job, onRefresh }) {
const [modelName, setModelName] = useState('')
const [importing, setImporting] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
if (job?.import_status !== 'importing') return undefined
const timer = setInterval(() => { onRefresh().catch(() => {}) }, 3000)
return () => clearInterval(timer)
}, [job?.import_status, onRefresh])
if (!job || job.status !== 'completed') return null
const handleImport = async () => {
setImporting(true)
setError('')
try {
await api.quantize.importModel(job.id, { name: modelName || undefined })
await onRefresh()
} catch (e) {
setError(e.message || t('quantize.importFailed'))
} finally {
setImporting(false)
}
}
const imported = job.import_status === 'completed' && job.import_model_name
const download = api.downloadHref(job.id)
return (
<>
{imported && (
<section className="bt-next dk-card" data-testid="quantize-next">
<span className="bt-next__mark"><Icon name="check-circle" /></span>
<div>
<h2 className="bt-h2">{t('quantize.nextTitle', { name: job.import_model_name })}</h2>
<p className="dk-hint">{t('quantize.nextText')}</p>
<div className="bt-next__acts">
<a className="dk-btn dk-btn--primary" href={`${ctx.basePath}/app/chat/${encodeURIComponent(job.import_model_name)}`}><Icon name="message" /> {t('quantize.chat', { name: job.import_model_name })}</a>
<a className="dk-btn dk-btn--secondary" href={`${ctx.basePath}/app/models?view=installed`}><Icon name="cube" /> {t('quantize.models')}</a>
<a className="dk-btn dk-btn--ghost" href={download} download><Icon name="download" /> {t('quantize.download')}</a>
</div>
</div>
</section>
)}
<section className="bt-block dk-card" aria-labelledby="bt-qz-out" data-testid="quantize-output">
<h2 className="bt-h2" id="bt-qz-out">{t('quantize.outputTitle')}</h2>
<p className="dk-hint">{t('quantize.outputText', { type: job.quantization_type })}</p>
{job.output_file && <p className="bt-pathrow"><span className="dk-label">{t('quantize.file')}</span> <span className="dk-mono bt-path" title={job.output_file}>{job.output_file}</span></p>}
{error && <p className="bt-status bt-status--error" role="alert">{error}</p>}
{job.import_status === 'failed' && <p className="bt-status bt-status--error" role="alert">{t('quantize.importFailedWith', { message: job.import_message })}</p>}
{!imported && (
<div className="bt-out">
{job.import_status === 'importing' ? (
<p className="bt-status" role="status"><ui.Spinner size="sm" /> {t('quantize.importing')} {job.import_message}</p>
) : (
<>
<Field id="qz-import-name" label={t('quantize.importName')} className="bt-out__name">
<input id="qz-import-name" className="dk-input dk-input--mono" placeholder={t('quantize.importNamePlaceholder')} value={modelName} onChange={e => setModelName(e.target.value)} />
</Field>
<div className="bt-actions">
<button type="button" className="dk-btn dk-btn--primary" onClick={handleImport} disabled={importing} aria-busy={importing || undefined} data-testid="quantize-import">
<Icon name="save" /> {t('quantize.import')}
</button>
<a className="dk-btn dk-btn--secondary" href={download} download><Icon name="download" /> {t('quantize.download')}</a>
</div>
</>
)}
</div>
)}
</section>
</>
)
}
// A working, failed or finished job: progress, the log and what to do with
// the result. The server streams progress events while the job is active; a
// finished job is read from the job record. `seed` starts the view with an
// event already read; tests use it to draw a state the stream would reach.
function JobView({ job, onStop, onReuse, onTerminal, onRefresh, seed }) {
const [latest, setLatest] = useState(seed?.latest ?? null)
const [log, setLog] = useState([])
const previousRef = useRef(null)
const terminalRef = useRef(onTerminal)
terminalRef.current = onTerminal
useEffect(() => {
previousRef.current = null
setLatest(null)
setLog([])
if (!job || TERMINAL.includes(job.status)) return undefined
// The progress stream is a fetch read with readSse: it carries the
// session and the base path of sdk.core.fetch. Unmounting aborts it.
const controller = new AbortController()
const onEvent = (data) => {
setLatest(data)
setLog(prev => appendLog(prev, logLines(data, previousRef.current)))
previousRef.current = data
if (TERMINAL.includes(data.status)) { controller.abort(); terminalRef.current?.() }
}
;(async () => {
try {
const response = await api.quantize.progress(job.id, controller.signal)
if (response.ok) await readSse(response, onEvent, controller.signal)
} catch (_) { /* the stream ended; the job list poll shows the state */ }
})()
return () => controller.abort()
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the job id: the effect restarts per job, not per poll refresh
}, [job?.id])
const status = latest?.status || job.status
const active = ACTIVE.includes(status)
const failed = status === 'failed'
// A working job can read 0% before it has reported anything. That 0 is
// unknown, not a measurement, so the page draws no figure for it.
const progress = percentOf(latest ? { ...latest, status } : status === 'completed' ? { status } : null)
const known = progress != null
const message = latest?.message || job.message || ''
const stageLabels = Object.fromEntries(QZ_STAGES.map(s => [s, t(`quantize.status.${s}`)]))
return (
<div className="bt-stack" data-testid="job-view" data-status={status}>
{failed && (
<section className="bt-alert" role="alert" data-testid="job-failed">
<Icon name="alert-circle" />
<div>
<p className="bt-alert__title">{t('quantize.failed')}</p>
<p className="bt-alert__text" data-testid="job-failed-message">{message || t('fineTune.noMessage')}</p>
</div>
</section>
)}
<section className="bt-job dk-card" aria-labelledby="bt-job-title">
<div className="bt-job__top">
<div className="bt-job__who">
<h2 className="bt-job__title" id="bt-job-title">{job.model}</h2>
<p className="bt-job__meta">
{t('quantize.jobMeta', { type: job.quantization_type, backend: job.backend, started: startedAt(job) })}
<span className="dk-mono"> · {job.id?.slice(0, 8)}</span>
</p>
</div>
<div className="bt-job__acts">
<StatusBadge status={status} />
{active && (
<button type="button" className="dk-btn dk-btn--secondary dk-btn--sm" onClick={() => onStop(job.id)} data-testid="job-stop">
<Icon name="stop" /> {t('run.stop')}
</button>
)}
</div>
</div>
{(latest || status === 'completed') && (
<div className="bt-job__figures">
{known
? <span className="bt-job__percent">{progress.toFixed(0)}<small>%</small></span>
: <span className="dk-hint" data-testid="job-percent-unknown">{t('run.unknownPercent')}</span>}
</div>
)}
{(active || latest) && (
<div
className={`dk-progress${failed ? ' dk-progress--error' : status === 'completed' ? ' dk-progress--ok' : ''}`}
role="progressbar" aria-valuemin={0} aria-valuemax={100}
aria-valuenow={known ? Math.round(progress) : undefined} aria-label={t('run.progress')}
data-indeterminate={known ? undefined : ''}
style={known ? { '--dk-value': `${progress}%` } : undefined}
>
<span className="dk-progress-bar" />
</div>
)}
{active && <StageLine stages={QZ_STAGES} labels={stageLabels} status={status} failed={failed} label={t('run.stages')} />}
{!failed && message && <p className="dk-hint bt-job__message">{message}</p>}
{status === 'completed' && <p className="bt-job__done" data-testid="job-finished"><Icon name="check-circle" /> {t('quantize.finished', { type: job.quantization_type })}</p>}
{status === 'stopped' && <p className="dk-hint">{t('quantize.stopped')}</p>}
</section>
{failed && (
<section className="bt-try dk-card" data-testid="job-try">
<h2 className="bt-h2">{t('fineTune.tryTitle')}</h2>
<p className="dk-hint">{t('quantize.tryText')}</p>
<div className="bt-try__acts">
<button type="button" className="dk-btn dk-btn--primary" onClick={() => onReuse(job)} data-testid="job-retry"><Icon name="refresh" /> {t('fineTune.reuse')}</button>
</div>
</section>
)}
{(active || log.length > 0) && <JobLog lines={log} name={`quantize-${job.id?.slice(0, 8)}`} />}
<ResultPanel job={job} onRefresh={onRefresh} />
</div>
)
}
// The machine facts for the checks: the one request the page makes, as a
// poll that waits while the tab is hidden and catches up on return.
function useResources(interval) {
const [resources, setResources] = useState(null)
useEffect(() => {
let off = false
const read = async () => {
if (typeof document !== 'undefined' && document.hidden) return
try {
const data = await api.resources.get()
if (!off) setResources(data)
} catch (_) { /* the next tick tries again */ }
}
read()
const timer = setInterval(read, interval)
document.addEventListener('visibilitychange', read)
return () => { off = true; clearInterval(timer); document.removeEventListener('visibilitychange', read) }
}, [interval])
return resources
}
function QuantizeBody({ path }) {
const isAdmin = viewer.isAdmin()
const resources = useResources(10000)
const facts = useMemo(() => machineFacts(resources), [resources])
// A deep link names the model: ?model=<id> or the first path segment.
const [model, setModel] = useState(() => ctx.modelFrom(path) || '')
const [quantType, setQuantType] = useState(DEFAULT_QUANT)
const [customQuantType, setCustomQuantType] = useState('')
const [useCustomQuant, setUseCustomQuant] = useState(false)
const [backend, setBackend] = useState(FALLBACK_BACKEND)
const [hfToken, setHfToken] = useState('')
const [backends, setBackends] = useState(null)
const [showMore, setShowMore] = useState(false)
const [jobs, setJobs] = useState([])
const [jobsLoaded, setJobsLoaded] = useState(false)
const [selectedJob, setSelectedJob] = useState(null)
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
const loadJobs = useCallback(async () => {
try {
const data = await api.quantize.listJobs()
const list = Array.isArray(data) ? data : []
setJobs(list)
setSelectedJob(prev => {
if (!prev) return prev
const fresh = list.find(j => j.id === prev.id)
return fresh || prev
})
} catch (_) { /* the next poll tries again */ } finally {
setJobsLoaded(true)
}
}, [])
useEffect(() => {
api.quantize.listBackends().then(b => {
const list = Array.isArray(b) ? b : []
setBackends(list)
if (list.length) setBackend(list[0].name)
}).catch(() => setBackends(null))
loadJobs()
const interval = setInterval(loadJobs, 10000)
return () => clearInterval(interval)
}, [loadJobs])
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setSubmitting(true)
try {
const req = {
model,
backend: backend || FALLBACK_BACKEND,
quantization_type: useCustomQuant ? customQuantType : quantType,
}
if (hfToken) req.extra_options = { hf_token: hfToken }
const resp = await api.quantize.startJob(req)
setModel('')
await loadJobs()
setSelectedJob(await api.quantize.getJob(resp.id))
} catch (err) {
setError(err.message || t('quantize.startFailed'))
} finally {
setSubmitting(false)
}
}
const handleStop = async (jobId) => {
try {
await api.quantize.stopJob(jobId)
await loadJobs()
} catch (err) {
setError(err.message || t('quantize.stopFailed'))
}
}
const confirmDelete = async (jobId) => {
try {
await api.quantize.deleteJob(jobId)
if (selectedJob?.id === jobId) setSelectedJob(null)
await loadJobs()
} catch (err) {
setError(err.message || t('quantize.deleteFailed'))
}
}
const askDelete = (jobId) => ctx.openDialog({
title: t('jobs.deleteDialogTitle'),
description: t('quantize.deleteDialogText'),
render: ({ close }) => <DeleteDialogBody close={close} onConfirm={() => confirmDelete(jobId)} />,
})
// Put a job's setup back in the form.
const reuse = (job) => {
setModel(job.model || '')
if (job.backend) setBackend(job.backend)
const type = job.quantization_type || DEFAULT_QUANT
if (QUANT_PRESETS.includes(type)) { setUseCustomQuant(false); setQuantType(type) } else { setUseCustomQuant(true); setCustomQuantType(type) }
setSelectedJob(null)
}
const effectiveQuantType = useCustomQuant ? customQuantType : quantType
const backendNames = backends && backends.length > 0 ? backends.map(b => b.name) : [FALLBACK_BACKEND]
const checks = useMemo(() => quantizeChecks({
form: { model, backend, useCustom: useCustomQuant, customType: customQuantType, hfToken: hfToken.trim() },
facts,
backends: { quantize: backends },
}), [model, backend, useCustomQuant, customQuantType, hfToken, facts, backends])
const stop = blocked(checks)
const warnings = warned(checks)
const firstMissing = checks.find(c => c.tone === 'fail')
const steps = [
{ key: 'setup', label: t('steps.setup') },
{ key: 'check', label: t('steps.check') },
{ key: 'run', label: t('steps.run') },
{ key: 'result', label: t('steps.result') },
]
let current = stop ? 0 : 1
let failed = false
if (selectedJob) {
if (selectedJob.status === 'failed') { current = 2; failed = true } else if (TERMINAL.includes(selectedJob.status)) current = 3
else current = 2
}
const refreshSelected = useCallback(async () => {
if (!selectedJob) return
const updated = await api.quantize.getJob(selectedJob.id)
setSelectedJob(updated)
await loadJobs()
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the job id: the effect restarts per job, not per poll refresh
}, [selectedJob?.id, loadJobs])
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')}
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')}
</button>
)}
/>
<ToolSteps steps={steps} current={current} failed={failed} label={t('steps.label')} />
{error && (
<div className="bt-alert" role="alert" data-testid="tool-error">
<Icon name="warning" />
<div><p className="bt-alert__text">{error}</p></div>
</div>
)}
{selectedJob ? (
<JobView job={selectedJob} onStop={handleStop} onReuse={reuse} onTerminal={loadJobs} onRefresh={refreshSelected} />
) : (
<form onSubmit={handleSubmit} className="bt-form-page" data-testid="quantize-form">
<Section done={!!model.trim()} title={t('quantize.sectionModel')}>
<Field id="qz-model" label={t('quantize.model')} hint={t('quantize.modelHint')}>
<input id="qz-model" className="dk-input dk-input--mono" placeholder="meta-llama/Llama-3.2-1B" value={model} onChange={e => setModel(e.target.value)} required />
</Field>
</Section>
<Section done={!useCustomQuant || !!customQuantType.trim()} title={t('quantize.sectionPrecision')}>
<div className="bt-pair">
<Field id="qz-type" label={t('quantize.type')} hint={t('quantize.typeHint')}>
<span className="dk-select-wrap">
<select
id="qz-type" className="dk-select" value={useCustomQuant ? '__custom__' : quantType}
onChange={e => {
if (e.target.value === '__custom__') setUseCustomQuant(true)
else { setUseCustomQuant(false); setQuantType(e.target.value) }
}}
>
{QUANT_PRESETS.map(q => <option key={q} value={q}>{q}</option>)}
<option value="__custom__">{t('quantize.custom')}</option>
</select>
</span>
</Field>
{useCustomQuant && (
<Field id="qz-custom" label={t('quantize.customLabel')}>
<input id="qz-custom" className="dk-input dk-input--mono" placeholder={t('quantize.customPlaceholder')} value={customQuantType} onChange={e => setCustomQuantType(e.target.value)} required />
</Field>
)}
</div>
<div className="bt-more">
<button type="button" className="bt-more__toggle" onClick={() => setShowMore(v => !v)} aria-expanded={showMore} aria-controls="qz-more">
<Icon name={showMore ? 'chevron-down' : 'chevron-right'} />
<b>{t('form.more')}</b>
{!showMore && <span className="dk-hint">{t('quantize.moreHint')}</span>}
</button>
{showMore && (
<div className="bt-more__body" id="qz-more">
<div className="bt-pair">
<Field id="qz-backend" label={t('form.backend')}>
<span className="dk-select-wrap">
<select id="qz-backend" className="dk-select" value={backend} onChange={e => setBackend(e.target.value)}>
{backendNames.map(b => <option key={b} value={b}>{b}</option>)}
</select>
</span>
</Field>
<Field id="qz-token" label={t('form.token')} hint={t('quantize.tokenHint')}>
<input id="qz-token" type="password" className="dk-input dk-input--mono" placeholder="hf_..." value={hfToken} onChange={e => setHfToken(e.target.value)} autoComplete="off" />
</Field>
</div>
</div>
)}
</div>
</Section>
<p className="bt-recipe" data-testid="qz-recipe">
{model.trim() ? t('quantize.recipe', { model: model.split('/').pop(), type: effectiveQuantType || '?' }) : t('quantize.recipeEmpty')}
</p>
<section className="bt-block" aria-labelledby="bt-check-title">
<header className="bt-block__head">
<h2 className="bt-h2" id="bt-check-title">{t('checks.heading')}</h2>
<p className="dk-hint">{t('checks.live')}</p>
</header>
<div className="dk-card bt-checks-card">
<Checks checks={checks} isAdmin={isAdmin} label={t('checks.heading')} />
</div>
</section>
<div className="bt-bar" data-testid="qz-bar">
<p className="bt-bar__text" role="status">
{stop ? t(`checks.${firstMissing.key}`) : warnings > 0 ? ctx.plural('checks.barWarn', warnings) : t('checks.barOk')}
{' '}{t('quantize.barNote')}
</p>
<div className="bt-bar__acts">
<button type="submit" className="dk-btn dk-btn--primary" disabled={submitting || stop} aria-busy={submitting || undefined} data-testid="qz-start">
{submitting
? <><ui.Spinner size="sm" /> {t('fineTune.starting')}</>
: <><Icon name="play" /> {t('quantize.start', { type: effectiveQuantType || '' })}</>}
</button>
</div>
</div>
</form>
)}
<section className="bt-jobs" aria-labelledby="bt-jobs-title" data-testid="qz-jobs">
<header className="bt-block__head">
<h2 className="bt-h2" id="bt-jobs-title">{t('jobs.title')}</h2>
{jobs.length > 0 && <p className="dk-hint">{ctx.plural('jobs.count', jobs.length)}</p>}
</header>
{jobs.length === 0 ? (
<div className="dk-empty bt-empty-card">
<div className="dk-empty-icon"><Icon name="cube" /></div>
<h3 className="dk-empty-title">{jobsLoaded ? t('quantize.emptyTitle') : t('jobs.loading')}</h3>
{jobsLoaded && <p className="dk-empty-text">{t('quantize.emptyText')}</p>}
</div>
) : (
<div className="dk-table-wrap" role="region" aria-labelledby="bt-jobs-title" tabIndex={0}>
<table className="dk-table">
<caption className="dk-sr-only">{t('jobs.title')}</caption>
<thead>
<tr>
<th>{t('jobs.model')}</th>
<th className="dk-hide-phone">{t('quantize.type')}</th>
<th>{t('jobs.status')}</th>
<th className="dk-hide-phone">{t('jobs.started')}</th>
<th><span className="dk-sr-only">{t('jobs.actions')}</span></th>
</tr>
</thead>
<tbody>
{jobs.map(job => {
const active = QZ_STAGES.includes(job.status)
return (
<tr key={job.id} data-row data-selected={selectedJob?.id === job.id ? 'true' : undefined} data-error={job.status === 'failed' ? '' : undefined}>
<td>
<button type="button" className="bt-linkcell" onClick={() => setSelectedJob(job)} title={t('jobs.open')}>
<span className="dk-table-name">{job.model}</span>
</button>
{job.import_status === 'completed' && <span className="dk-table-sub">{t('quantize.imported', { name: job.import_model_name })}</span>}
{job.status === 'failed' && job.message && <span className="dk-table-sub bt-jobs__msg">{job.message}</span>}
</td>
<td className="dk-hide-phone dk-mono">{job.quantization_type}</td>
<td><StatusBadge status={job.status} /></td>
<td className="dk-hide-phone dk-mono">{startedAt(job)}</td>
<td>
<div className="bt-rowacts">
{active ? (
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={() => handleStop(job.id)}><Icon name="stop" /> {t('run.stop')}</button>
) : (
<>
<button type="button" className="dk-btn dk-btn--ghost dk-btn--sm" onClick={() => reuse(job)} title={t('jobs.reuseTitle')}>{t('jobs.reuse')}</button>
<button type="button" className="dk-btn dk-btn--ghost dk-btn--icon dk-btn--sm" onClick={() => askDelete(job.id)} aria-label={t('jobs.delete')} title={t('jobs.deleteTitle')}><Icon name="trash" /></button>
</>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</section>
</div>
)
}
// The page. The off notice replaces it for an account without the feature,
// so none of the page's requests run for that account.
function Quantize({ path }) {
return (
<ToolOff tool="quantize" feature="quantization">
<QuantizeBody path={path} />
</ToolOff>
)
}
return { Quantize, JobView, ResultPanel, DeleteDialogBody, StatusBadge }
}
+30
View File
@@ -0,0 +1,30 @@
// The core routes the quantization add-on uses, reached with the signed-in
// user's own session (sdk.core.fetch). Paths and bodies are the ones of
// quantizationApi and resourcesApi in core/http/react-ui/src/utils/api.js; only
// the transport changed.
export function createApi(ctx) {
const { json } = ctx
const enc = encodeURIComponent
const post = (path, data) => json(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) })
return {
quantize: {
listBackends: () => json('/api/quantization/backends'),
startJob: (data) => post('/api/quantization/jobs', data),
listJobs: () => json('/api/quantization/jobs'),
getJob: (id) => json(`/api/quantization/jobs/${enc(id)}`),
stopJob: (id) => json(`/api/quantization/jobs/${enc(id)}/stop`, { method: 'POST' }),
deleteJob: (id) => json(`/api/quantization/jobs/${enc(id)}`, { method: 'DELETE' }),
importModel: (id, data) => post(`/api/quantization/jobs/${enc(id)}/import`, data),
// The response is a stream of events; the caller reads it with readSse
// and aborts the signal to close it.
progress: (id, signal) => ctx.core.fetch(`/api/quantization/jobs/${enc(id)}/progress`, { signal }),
},
// The one request the page makes for the machine facts of the checks.
resources: {
get: () => json('/api/resources'),
},
// The link the browser follows to download the result of a job.
downloadHref: (id) => `${ctx.basePath}/api/quantization/jobs/${enc(id)}/download`,
}
}
Loaded 100 of 733 files, more files were not shown because too many files have changed in this diff. Show more