Files
LocalAI/core/trace/backend_trace.go
Richard Palethorpe 9058a2bb46 feat: Add 3d generation UI/API and trellis2cpp backend (#10979)
* feat(3d): add Generate3D RPC, FLAG_3D capability, and /v1/3d/generations endpoint

Adds the plumbing for image-conditioned 3D asset generation (binary
glTF / GLB output), modeled on the video generation path:

- backend.proto: Generate3D RPC + Generate3DRequest (staged image src,
  glb dst, seed/step/cfg_scale/texture_steps, quality and background
  enums, params map for backend-specific extras)
- pkg/grpc: thread Generate3D through client, server, embed, base and
  the backend interfaces; connection-evicting and distributed-node
  wrappers (in-flight tracking + file staging) included
- core/config: FLAG_3D usecase (guessed only for the trellis2cpp
  backend), '3d' canonical usecase string mapped to the Generate3D
  method, and a '3d' output modality
- REST: POST /v1/3d/generations (+ unversioned alias) returning
  OpenAIResponse with a /generated-3d URL or b64_json; conditioning
  image accepted as URL, base64, or data URI; quality/background
  validated at the edge; .glb served as model/gltf-binary
- auth: '3d' route feature (default ON); /api/instructions entry

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(trellis2cpp): add the trellis2.cpp image-to-3D backend

Wraps localai-org/trellis2cpp (C++/GGML port of Microsoft TRELLIS.2,
pbr-textures branch) as a Go+purego backend, following the
stablediffusion-ggml pattern:

- backend/go/trellis2cpp: purego bindings to the flat C ABI (v9,
  asserted at startup), eager pipeline load with model-set validation
  (refuses non-trellis GGUFs; degrades coarse/geometry-only/textured
  exactly like the upstream demo), Generate3D via t2_generate +
  t2_bake_glb writing a binary glTF to dst. Weight-free unit tests
  cover resolution/validation/param mapping — CI never downloads the
  multi-GB GGUF set or runs inference.
- CPU SIMD variants build into per-variant directories (the shared
  libggml sonames collide across variants, unlike sd-ggml's flat
  renamed-.so scheme); run.sh picks one via /proc/cpuinfo.
- CI wiring: backend-matrix entries (cpu, cuda12/13, vulkan
  amd64+arm64, l4t, l4t-cuda13, darwin metal), index.yaml meta +
  latest/master image entries, bump_deps tracking of the pbr-textures
  branch, changed-backends.js mapping, top-level Makefile targets.
- Importer: auto-detects trellis GGUF repos/URIs (registered before
  llama-cpp so the .gguf match isn't stolen) and expands any trellis
  URI to the full 10-file component set spanning the three LocalAI-io
  HF repos.
- Gallery: trellis2-4b (full PBR + 1024 cascade) and
  trellis2-4b-geometry (512 untextured) with verified sha256s.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(ui): 3D generation page with native GLB viewer and IndexedDB history

Adds a Studio tab + /app/3d page for the new image-to-3D endpoint:

- GlbViewer ports the trellis2cpp demo's dependency-free WebGL2
  renderer (quaternion trackball, metallic-roughness PBR, ACES,
  hidden-line wireframe with a bounded index budget) and pairs it with
  a minimal GLB parser for the two forms t2_bake_glb emits — dense
  vertex-PBR (linear COLOR_0 + _METALLIC_ROUGHNESS, uploaded as
  normalized integers) and the opt-in UV-atlas textured form. Parsing
  happens before any GL so stats and errors render without WebGL2.
- use3DHistory stores past generations (params, input thumbnail, and
  the GLB blob itself) in IndexedDB with keep-newest-20 eviction —
  GLBs are multi-MB binaries localStorage can't hold — and the page
  offers a download button for the active GLB.
- Wiring: CAP_3D capability constant (FLAG_3D — the exact string
  /api/models/capabilities serves), threeDApi, router entries, Studio
  tab, vite dev proxy, en locale keys.
- e2e: render-smoke entry plus a focused spec that feeds a real
  one-triangle vertex-PBR GLB through the parser/viewer and exercises
  IndexedDB persistence, selection, deletion, and API errors.

Assisted-by: Claude:claude-fable-5 [Claude Code]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(3d): address API correctness and UX issues

Keep 3D generation on the LocalAI-specific /3d/generations route and ensure authentication and permissions cover it.

Propagate distributed transfer failures, publish a portable ARM64 backend image, honor importer overrides, and align discovery, upload validation, and touch controls.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(3d): add previewable print remeshing

Add a single-detail CGAL Alpha Wrap workflow for existing Trellis GLBs, including PBR reprojection, API documentation, tracing, and an in-browser preview before download.

Allow the remesh route to enforce its 512 MiB upload cap independently of the smaller global default so generated high-resolution meshes can be processed.

Assisted-by: Codex:gpt-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* build(trellis2cpp): centralize remesh dependency pins

Assisted-by: Codex:GPT-5 [apply_patch] [exec_command]
Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(kokoros): implement Generate3D stub for new proto RPC

The Generate3D RPC added to backend.proto for the trellis2cpp backend
made tonic's generated Backend trait require generate3_d, breaking the
kokoros-grpc build. Return unimplemented like the other unsupported
modalities.

Assisted-by: Claude Code:claude-fable-5
Signed-off-by: Richard Palethorpe <io@richiejp.com>

---------

Signed-off-by: Richard Palethorpe <io@richiejp.com>
Co-authored-by: localai-org-maint-bot <bot-opensource@localaisrl.com>
2026-07-29 16:15:04 +02:00

366 lines
12 KiB
Go

package trace
import (
"encoding/json"
"fmt"
"maps"
"math"
"slices"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/emirpasic/gods/v2/queues/circularbuffer"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/xlog"
)
type BackendTraceType string
const (
BackendTraceLLM BackendTraceType = "llm"
BackendTraceEmbedding BackendTraceType = "embedding"
BackendTraceTranscription BackendTraceType = "transcription"
BackendTraceImageGeneration BackendTraceType = "image_generation"
BackendTraceVideoGeneration BackendTraceType = "video_generation"
BackendTrace3DGeneration BackendTraceType = "3d_generation"
BackendTrace3DRemesh BackendTraceType = "3d_remesh"
BackendTraceTTS BackendTraceType = "tts"
BackendTraceSoundGeneration BackendTraceType = "sound_generation"
BackendTraceRerank BackendTraceType = "rerank"
BackendTraceTokenize BackendTraceType = "tokenize"
BackendTraceDetection BackendTraceType = "detection"
BackendTraceDepth BackendTraceType = "depth"
BackendTraceFaceVerify BackendTraceType = "face_verify"
BackendTraceFaceAnalyze BackendTraceType = "face_analyze"
BackendTraceVoiceVerify BackendTraceType = "voice_verify"
BackendTraceVoiceAnalyze BackendTraceType = "voice_analyze"
BackendTraceVoiceEmbed BackendTraceType = "voice_embed"
BackendTraceAudioTransform BackendTraceType = "audio_transform"
BackendTraceModelLoad BackendTraceType = "model_load"
BackendTraceScore BackendTraceType = "score"
BackendTraceTokenClassify BackendTraceType = "token_classify"
BackendTracePatternPII BackendTraceType = "pattern_pii"
BackendTraceVectorStore BackendTraceType = "vector_store"
)
type BackendTrace struct {
// ID identifies this trace for the lifetime of the process, so the list
// endpoint can return trimmed entries and clients can fetch the full
// record back from /api/backend-traces/:id.
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Duration time.Duration `json:"duration"`
Type BackendTraceType `json:"type"`
ModelName string `json:"model_name"`
Backend string `json:"backend"`
Summary string `json:"summary"`
// Body is the full request payload sent to the backend, when one
// applies (currently: cloud-proxy passthrough forwards). Summary
// is a short preview for the trace list; Body is the full
// payload shown when the row is expanded. Capped by the recorder
// to keep the in-memory ring buffer bounded.
Body string `json:"body,omitempty"`
Error string `json:"error,omitempty"`
Data map[string]any `json:"data"`
}
// MaxTraceBodyBytes caps the per-trace stored request body. Roomy
// enough to keep typical chat histories intact while preventing a
// runaway buffer when a caller streams MB-scale payloads.
const MaxTraceBodyBytes = 1 << 20
var (
backendTraceBuffer *circularbuffer.Queue[*BackendTrace]
backendMu sync.Mutex
backendLogChan = make(chan *BackendTrace, 100)
backendInitOnce sync.Once
backendTraceIDSeq atomic.Uint64
)
// backendMaxBodyBytes caps each captured string value in a BackendTrace.Data
// field to keep the /api/backend-traces JSON small enough for the admin UI to
// load on every 5s auto-refresh. Mirrors the API-trace body cap added in
// commit 61bf34ea: without it a chatty LLM workload (full message history per
// trace) or any TTS run (~1.3 MiB of audio_wav_base64 per trace) blows the
// payload past tens of MiB and locks the Traces page in a loading state.
//
// 0 disables the cap. Guarded by backendMu; refreshed on EVERY
// InitBackendTracingIfEnabled call — see below.
var backendMaxBodyBytes int
func InitBackendTracingIfEnabled(maxItems, maxBodyBytes int) {
backendInitOnce.Do(func() {
if maxItems <= 0 {
maxItems = 100
}
backendMu.Lock()
backendTraceBuffer = circularbuffer.New[*BackendTrace](maxItems)
backendMu.Unlock()
go func() {
for t := range backendLogChan {
backendMu.Lock()
if backendTraceBuffer != nil {
backendTraceBuffer.Enqueue(t)
}
backendMu.Unlock()
}
}()
})
// The body cap tracks the LATEST call, not the first: tracing_max_body_bytes
// is runtime-mutable via the settings API (ApplyRuntimeSettings), and every
// recording path calls this right before RecordBackendTrace with the current
// appConfig value. Freezing the cap on first init meant a raised setting let
// producers (e.g. trace.AudioSnippet, which reads the live value) embed
// payloads that this recorder then stomped with the "<truncated: N bytes>"
// marker — corrupting audio_wav_base64 into an unplayable string. maxItems
// keeps first-call semantics: resizing the ring buffer would drop entries.
backendMu.Lock()
backendMaxBodyBytes = maxBodyBytes
backendMu.Unlock()
}
func RecordBackendTrace(t BackendTrace) {
backendMu.Lock()
maxBody := backendMaxBodyBytes
backendMu.Unlock()
// Always walk Data, even with no body cap configured: besides capping
// oversized strings (maxBody > 0), the walk replaces non-finite floats
// (Inf/NaN) that encoding/json cannot marshal. A single such value — e.g. a
// -Inf dBFS audio metric from a silent clip — would otherwise fail the whole
// /api/backend-traces response and blank the Traces UI.
if t.Data != nil {
t.Data = sanitizeData(t.Data, maxBody)
}
if t.ID == "" {
t.ID = strconv.FormatUint(backendTraceIDSeq.Add(1), 10)
}
select {
case backendLogChan <- &t:
default:
xlog.Warn("Backend trace channel full, dropping trace")
}
}
// sanitizeData walks a trace Data map (recursing into nested maps and slices)
// and makes every value safe for the /api/backend-traces JSON response:
//
// - When maxBytes > 0, any string longer than maxBytes is replaced with a
// fixed-size marker that names the original byte count. The replacement is
// intentionally short and not valid base64/JSON: it flags "this was dropped"
// cheaply rather than keeping a partial value the UI might try to render.
// - Non-finite floats (Inf/NaN) are replaced with nil regardless of maxBytes,
// because encoding/json refuses to marshal them and one bad value would fail
// the entire response.
//
// Other scalars (ints, bools, finite floats) pass through untouched so
// structural fields like total_deltas or audio_sample_rate remain useful.
//
// The walk is copy-on-write: it runs on every RecordBackendTrace call, and in
// the common case nothing needs rewriting, so containers are only re-allocated
// on the paths that actually changed and untouched values keep their original
// interface boxes instead of paying a per-value re-boxing allocation.
func sanitizeData(data map[string]any, maxBytes int) map[string]any {
out, _ := sanitizeMap(data, maxBytes)
return out
}
func sanitizeMap(m map[string]any, maxBytes int) (map[string]any, bool) {
var out map[string]any
for k, v := range m {
nv, changed := sanitizeValue(v, maxBytes)
if changed && out == nil {
// First change: fork the map. Entries already visited were
// unchanged, so a full copy then overwriting as we go is exact.
out = make(map[string]any, len(m))
maps.Copy(out, m)
}
if out != nil {
out[k] = nv
}
}
if out == nil {
return m, false
}
return out, true
}
func sanitizeSlice(s []any, maxBytes int) ([]any, bool) {
var out []any
for i, v := range s {
nv, changed := sanitizeValue(v, maxBytes)
if changed && out == nil {
out = make([]any, len(s))
copy(out, s)
}
if out != nil {
out[i] = nv
}
}
if out == nil {
return s, false
}
return out, true
}
func sanitizeValue(v any, maxBytes int) (any, bool) {
switch val := v.(type) {
case string:
if maxBytes > 0 && len(val) > maxBytes {
return fmt.Sprintf("<truncated: %d bytes>", len(val)), true
}
return v, false
case float64:
if math.IsInf(val, 0) || math.IsNaN(val) {
return nil, true
}
return v, false
case float32:
if f := float64(val); math.IsInf(f, 0) || math.IsNaN(f) {
return nil, true
}
return v, false
case map[string]any:
return sanitizeMap(val, maxBytes)
case []any:
return sanitizeSlice(val, maxBytes)
default:
return v, false
}
}
func GetBackendTraces() []BackendTrace {
backendMu.Lock()
if backendTraceBuffer == nil {
backendMu.Unlock()
return []BackendTrace{}
}
ptrs := backendTraceBuffer.Values()
backendMu.Unlock()
traces := make([]BackendTrace, len(ptrs))
for i, p := range ptrs {
traces[i] = *p
}
slices.SortFunc(traces, func(a, b BackendTrace) int {
return b.Timestamp.Compare(a.Timestamp)
})
return traces
}
// GetBackendTracesPage returns the newest-first window
// [offset, offset+limit) of the backend trace buffer plus the total number
// of buffered traces. A limit <= 0 means "no bound".
func GetBackendTracesPage(offset, limit int) ([]BackendTrace, int) {
all := GetBackendTraces()
total := len(all)
if offset < 0 {
offset = 0
}
if offset >= total {
return []BackendTrace{}, total
}
page := all[offset:]
if limit > 0 && limit < len(page) {
page = page[:limit]
}
out := make([]BackendTrace, len(page))
copy(out, page)
return out, total
}
// GetBackendTrace returns the buffered trace with the given ID.
func GetBackendTrace(id string) (BackendTrace, bool) {
for _, t := range GetBackendTraces() {
if t.ID == id {
return t, true
}
}
return BackendTrace{}, false
}
// SummarizeBackendTrace drops the heavy fields (the full request Body and the
// Data map, which carries things like base64 audio snippets and complete
// input_text payloads) while keeping everything the trace list renders. The
// full record stays reachable by ID. Without this the list response grew to
// tens of megabytes and the UI re-fetched it every few seconds.
func SummarizeBackendTrace(t BackendTrace) BackendTrace {
t.Body = ""
t.Data = nil
return t
}
func ClearBackendTraces() {
backendMu.Lock()
if backendTraceBuffer != nil {
backendTraceBuffer.Clear()
}
backendMu.Unlock()
}
func GenerateLLMSummary(messages schema.Messages, prompt string) string {
if len(messages) > 0 {
last := messages[len(messages)-1]
text := ""
switch content := last.Content.(type) {
case string:
text = content
default:
b, err := json.Marshal(content)
if err == nil {
text = string(b)
}
}
if text != "" {
return TruncateString(text, 200)
}
}
if prompt != "" {
return TruncateString(prompt, 200)
}
return ""
}
func TruncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
// TruncateToBytes caps a string at exactly maxBytes, preserving the leading
// content and appending a marker so the UI knows the value was clipped.
// Unlike TruncateString it guarantees output <= maxBytes, which matters for
// fields that feed back into the trace pipeline: capDataStrings in
// RecordBackendTrace re-checks size and would otherwise replace a producer's
// head-preserving truncation with the bare marker, losing the prefix.
//
// maxBytes <= 0 disables the cap, matching backendMaxBodyBytes semantics.
func TruncateToBytes(s string, maxBytes int) string {
if maxBytes <= 0 || len(s) <= maxBytes {
return s
}
suffix := fmt.Sprintf("...[truncated, %d bytes]", len(s))
if len(suffix) >= maxBytes {
// Pathologically small caps can't fit the marker; fall back to a
// hard cut so the contract (output <= maxBytes) still holds.
return s[:maxBytes]
}
return s[:maxBytes-len(suffix)] + suffix
}
// TruncateBytes is the []byte counterpart of TruncateString — it copies
// at most maxLen bytes, avoiding a full string([]byte) allocation when
// the input is a large request body.
func TruncateBytes(b []byte, maxLen int) string {
if len(b) <= maxLen {
return string(b)
}
return string(b[:maxLen]) + "..."
}