mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 01:48:06 -04:00
* 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>
366 lines
12 KiB
Go
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]) + "..."
|
|
}
|