mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-30 03:25:42 -04:00
* fix(traces): cap backend trace Data field so the admin UI stays responsive The previous fix (#9946) capped API trace bodies but missed backend traces, which carry the same blast radius: - LLM backend traces store the full chat messages JSON, full response, and full streaming deltas. Every agent-pool reasoning step ships the full RAG-augmented history (50-500 KiB per trace, often 100+ traces queued). - TTS / audio_transform / transcript traces embed a 30s audio snippet as base64, around 1.3 MiB per trace. Both blow the /api/backend-traces JSON past tens of MiB. The admin Traces page then keeps re-downloading and re-parsing the buffer faster than the 5s auto-refresh and stays in the loading state forever, the same symptom the API-side fix addressed. Apply two complementary caps, both honoring LOCALAI_TRACING_MAX_BODY_BYTES: Option A (safety net in core/trace): RecordBackendTrace walks the Data map recursively and replaces any string value larger than the cap with "<truncated: N bytes>". Catches anything a future producer forgets. Option B (head-preserving at the producer): - core/backend/llm.go: TruncateToBytes on messages, response, and chat_deltas content/reasoning_content so the leading content stays readable in the UI. - core/trace/audio_snippet.go: omit audio_wav_base64 when the encoded blob would exceed the cap (truncated base64 is undecodable). The quality metrics still ship and the UI's WaveformPlayer simply skips when the field is absent. TruncateToBytes is bounded to <= maxBytes so Option A leaves the producer's head-preserving output alone instead of replacing it with the bare marker. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-7 * fix(react-ui): expose tracing_max_body_bytes in Settings and Traces panels The setting was already plumbed through env (LOCALAI_TRACING_MAX_BODY_BYTES), CLI flag, and the runtime_settings.json GET/PUT schema, but neither the main Settings page nor the inline Traces panel offered an input for it. Admins hitting the "Traces UI stuck loading" symptom had to know to set an env var or PUT raw JSON to /api/settings to dial the cap. Add a "Max Body Bytes" row next to "Max Items" in both places. Same input type, same disabled-when-tracing-off semantics, placeholder shows the 65536 default so users see what they're inheriting. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-7 * test(react-ui): disambiguate Max Items locator after adding Max Body Bytes The Tracing settings panel now has two number inputs. The previous spec matched 'input[type="number"]' which became ambiguous and triggered a Playwright strict-mode violation in CI. Switch to getByPlaceholder('100') for Max Items and add a parallel spec for the new Max Body Bytes field using getByPlaceholder('65536'). Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-7 --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
177 lines
5.6 KiB
Go
177 lines
5.6 KiB
Go
package backend
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/trace"
|
|
"github.com/mudler/LocalAI/pkg/grpc"
|
|
"github.com/mudler/LocalAI/pkg/grpc/proto"
|
|
"github.com/mudler/LocalAI/pkg/model"
|
|
"github.com/mudler/LocalAI/pkg/utils"
|
|
)
|
|
|
|
// AudioTransformOptions carries per-request tuning for the unary transform.
|
|
type AudioTransformOptions struct {
|
|
// Params is forwarded verbatim to the backend (e.g. LocalVQE reads
|
|
// params["noise_gate"] / params["noise_gate_threshold_dbfs"]).
|
|
Params map[string]string
|
|
}
|
|
|
|
// AudioTransformOutputs are the on-disk paths of the persisted artifacts —
|
|
// the user-visible Dst plus copies of the inputs the backend actually saw.
|
|
// Inputs are persisted because the React UI history needs to display past
|
|
// runs, and rejecting them once the temp dir is cleaned up would defeat
|
|
// the point.
|
|
type AudioTransformOutputs struct {
|
|
Dst string
|
|
AudioPath string
|
|
ReferencePath string
|
|
}
|
|
|
|
// ModelAudioTransform runs the unary AudioTransform RPC and returns the
|
|
// generated output path plus the persisted input paths. `audioPath` is
|
|
// required; `referencePath` is optional (empty => backend zero-fills the
|
|
// reference channel).
|
|
func ModelAudioTransform(
|
|
ctx context.Context,
|
|
audioPath, referencePath string,
|
|
opts AudioTransformOptions,
|
|
loader *model.ModelLoader,
|
|
appConfig *config.ApplicationConfig,
|
|
modelConfig config.ModelConfig,
|
|
) (AudioTransformOutputs, *proto.AudioTransformResult, error) {
|
|
mopts := ModelOptions(modelConfig, appConfig)
|
|
transformModel, err := loader.Load(mopts...)
|
|
if err != nil {
|
|
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
|
|
return AudioTransformOutputs{}, nil, err
|
|
}
|
|
if transformModel == nil {
|
|
return AudioTransformOutputs{}, nil, fmt.Errorf("could not load audio-transform model %q", modelConfig.Model)
|
|
}
|
|
|
|
audioDir := filepath.Join(appConfig.GeneratedContentDir, "audio")
|
|
if err := os.MkdirAll(audioDir, 0750); err != nil {
|
|
return AudioTransformOutputs{}, nil, fmt.Errorf("failed creating audio directory: %s", err)
|
|
}
|
|
|
|
dst := filepath.Join(audioDir, utils.GenerateUniqueFileName(audioDir, "transform", ".wav"))
|
|
|
|
persistedAudio, err := persistAudioInput(audioPath, audioDir, "transform-input", ".wav")
|
|
if err != nil {
|
|
return AudioTransformOutputs{}, nil, fmt.Errorf("persist input audio: %w", err)
|
|
}
|
|
persistedRef := ""
|
|
if referencePath != "" {
|
|
persistedRef, err = persistAudioInput(referencePath, audioDir, "transform-ref", ".wav")
|
|
if err != nil {
|
|
return AudioTransformOutputs{}, nil, fmt.Errorf("persist reference: %w", err)
|
|
}
|
|
}
|
|
|
|
var startTime time.Time
|
|
if appConfig.EnableTracing {
|
|
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
|
startTime = time.Now()
|
|
}
|
|
|
|
res, err := transformModel.AudioTransform(ctx, &proto.AudioTransformRequest{
|
|
AudioPath: audioPath,
|
|
ReferencePath: referencePath,
|
|
Dst: dst,
|
|
Params: opts.Params,
|
|
})
|
|
|
|
if appConfig.EnableTracing {
|
|
errStr := ""
|
|
if err != nil {
|
|
errStr = err.Error()
|
|
}
|
|
data := map[string]any{
|
|
"audio_path": audioPath,
|
|
"reference_path": referencePath,
|
|
"dst": dst,
|
|
"params": opts.Params,
|
|
}
|
|
if err == nil && res != nil {
|
|
data["sample_rate"] = res.SampleRate
|
|
data["samples"] = res.Samples
|
|
data["reference_provided"] = res.ReferenceProvided
|
|
if snippet := trace.AudioSnippet(dst, appConfig.TracingMaxBodyBytes); snippet != nil {
|
|
maps.Copy(data, snippet)
|
|
}
|
|
}
|
|
trace.RecordBackendTrace(trace.BackendTrace{
|
|
Timestamp: startTime,
|
|
Duration: time.Since(startTime),
|
|
Type: trace.BackendTraceAudioTransform,
|
|
ModelName: modelConfig.Name,
|
|
Backend: modelConfig.Backend,
|
|
Summary: trace.TruncateString(filepath.Base(audioPath), 200),
|
|
Error: errStr,
|
|
Data: data,
|
|
})
|
|
}
|
|
|
|
if err != nil {
|
|
return AudioTransformOutputs{}, nil, err
|
|
}
|
|
return AudioTransformOutputs{
|
|
Dst: dst,
|
|
AudioPath: persistedAudio,
|
|
ReferencePath: persistedRef,
|
|
}, res, nil
|
|
}
|
|
|
|
// ModelAudioTransformStream opens the bidirectional AudioTransformStream RPC
|
|
// and returns the underlying stream client. The caller is responsible for
|
|
// sending the initial Config message, subsequent Frame messages, and for
|
|
// calling CloseSend when input is done. The returned stream's Recv reports
|
|
// EOF when the backend has finished emitting frames.
|
|
func ModelAudioTransformStream(
|
|
ctx context.Context,
|
|
loader *model.ModelLoader,
|
|
appConfig *config.ApplicationConfig,
|
|
modelConfig config.ModelConfig,
|
|
) (grpc.AudioTransformStreamClient, error) {
|
|
mopts := ModelOptions(modelConfig, appConfig)
|
|
transformModel, err := loader.Load(mopts...)
|
|
if err != nil {
|
|
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
|
|
return nil, err
|
|
}
|
|
if transformModel == nil {
|
|
return nil, fmt.Errorf("could not load audio-transform model %q", modelConfig.Model)
|
|
}
|
|
return transformModel.AudioTransformStream(ctx)
|
|
}
|
|
|
|
// persistAudioInput copies a transient input file (typically a multipart
|
|
// upload that lives in an os.TempDir slated for cleanup) into the long-lived
|
|
// GeneratedContentDir under a unique name, so the React UI can replay it
|
|
// from history.
|
|
func persistAudioInput(srcPath, dir, prefix, ext string) (string, error) {
|
|
src, err := os.Open(srcPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() { _ = src.Close() }()
|
|
dst := filepath.Join(dir, utils.GenerateUniqueFileName(dir, prefix, ext))
|
|
out, err := os.Create(dst)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer func() { _ = out.Close() }()
|
|
if _, err := io.Copy(out, src); err != nil {
|
|
return "", err
|
|
}
|
|
return dst, nil
|
|
}
|