mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
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:
733 files changed
+149964
-323
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -140,3 +140,4 @@ formal-verification/out/
|
||||
/backend/go/gemxcpp/build-*/
|
||||
/backend/go/gemxcpp/package/
|
||||
/backend/go/gemxcpp/gemxcpp
|
||||
addons/internal/uibuild/test/*/dist
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
# Written by build.mjs; the package build makes it.
|
||||
/dist/
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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), '')
|
||||
})
|
||||
@@ -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, [])
|
||||
})
|
||||
@@ -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>')
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
# Written by build.mjs; the package build makes it.
|
||||
/dist/
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
@@ -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`,
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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))
|
||||
@@ -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), '')
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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{} })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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$/)
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
# Written by build.mjs; the package build makes it.
|
||||
/dist/
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user