feat(ui): Per model backend logs and various fixes (#9028)

* feat(gallery): Switch to expandable box instead of pop-over and display model files

Signed-off-by: Richard Palethorpe <io@richiejp.com>

* feat(ui, backends): Add individual backend logging

Signed-off-by: Richard Palethorpe <io@richiejp.com>

* fix(ui): Set the context settings from the model config

Signed-off-by: Richard Palethorpe <io@richiejp.com>

---------

Signed-off-by: Richard Palethorpe <io@richiejp.com>
This commit is contained in:
Richard Palethorpe
2026-03-18 07:31:26 +00:00
committed by GitHub
parent eef808d921
commit 35d509d8e7
40 changed files with 1195 additions and 206 deletions

View File

@@ -136,6 +136,8 @@ func New(opts ...config.AppOption) (*Application, error) {
loadRuntimeSettingsFromFile(options)
}
application.ModelLoader().SetBackendLoggingEnabled(options.EnableBackendLogging)
// turn off any process that was started by GRPC if the context is canceled
go func() {
<-options.Context.Done()
@@ -382,6 +384,12 @@ func loadRuntimeSettingsFromFile(options *config.ApplicationConfig) {
}
}
if settings.EnableBackendLogging != nil {
if !options.EnableBackendLogging {
options.EnableBackendLogging = *settings.EnableBackendLogging
}
}
xlog.Debug("Runtime settings loaded from runtime_settings.json")
}

View File

@@ -3,8 +3,10 @@ package backend
import (
"context"
"fmt"
"time"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/trace"
"github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/LocalAI/pkg/model"
)
@@ -18,6 +20,7 @@ func Detection(
opts := ModelOptions(modelConfig, appConfig)
detectionModel, err := loader.Load(opts...)
if err != nil {
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
return nil, err
}
@@ -25,9 +28,35 @@ func Detection(
return nil, fmt.Errorf("could not load detection model")
}
var startTime time.Time
if appConfig.EnableTracing {
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
startTime = time.Now()
}
res, err := detectionModel.Detect(context.Background(), &proto.DetectOptions{
Src: sourceFile,
})
if appConfig.EnableTracing {
errStr := ""
if err != nil {
errStr = err.Error()
}
trace.RecordBackendTrace(trace.BackendTrace{
Timestamp: startTime,
Duration: time.Since(startTime),
Type: trace.BackendTraceDetection,
ModelName: modelConfig.Name,
Backend: modelConfig.Backend,
Summary: trace.TruncateString(sourceFile, 200),
Error: errStr,
Data: map[string]any{
"source_file": sourceFile,
},
})
}
return res, err
}

View File

@@ -17,6 +17,7 @@ func ModelEmbedding(s string, tokens []int, loader *model.ModelLoader, modelConf
inferenceModel, err := loader.Load(opts...)
if err != nil {
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
return nil, err
}

View File

@@ -17,6 +17,7 @@ func ImageGeneration(height, width, step, seed int, positive_prompt, negative_pr
opts...,
)
if err != nil {
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
return nil, err
}

View File

@@ -65,6 +65,7 @@ func ModelInference(ctx context.Context, s string, messages schema.Messages, ima
opts := ModelOptions(*c, o)
inferenceModel, err := loader.Load(opts...)
if err != nil {
recordModelLoadFailure(o, c.Name, c.Backend, err, map[string]any{"model_file": modelFile})
return nil, err
}

View File

@@ -1,17 +1,36 @@
package backend
import (
"strings"
"math/rand"
"os"
"path/filepath"
"strings"
"time"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/trace"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/xlog"
)
// recordModelLoadFailure records a backend trace when model loading fails.
func recordModelLoadFailure(appConfig *config.ApplicationConfig, modelName, backend string, err error, data map[string]any) {
if !appConfig.EnableTracing {
return
}
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems)
trace.RecordBackendTrace(trace.BackendTrace{
Timestamp: time.Now(),
Type: trace.BackendTraceModelLoad,
ModelName: modelName,
Backend: backend,
Summary: "Model load failed",
Error: err.Error(),
Data: data,
})
}
func ModelOptions(c config.ModelConfig, so *config.ApplicationConfig, opts ...model.Option) []model.Option {
name := c.Name
if name == "" {

View File

@@ -15,6 +15,7 @@ func Rerank(request *proto.RerankRequest, loader *model.ModelLoader, appConfig *
opts := ModelOptions(modelConfig, appConfig)
rerankModel, err := loader.Load(opts...)
if err != nil {
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
return nil, err
}

View File

@@ -37,6 +37,7 @@ func SoundGeneration(
opts := ModelOptions(modelConfig, appConfig)
soundGenModel, err := loader.Load(opts...)
if err != nil {
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
return "", nil, err
}

View File

@@ -18,6 +18,7 @@ func TokenMetrics(
opts := ModelOptions(modelConfig, appConfig, model.WithModel(modelFile))
model, err := loader.Load(opts...)
if err != nil {
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
return nil, err
}

View File

@@ -18,6 +18,7 @@ func ModelTokenize(s string, loader *model.ModelLoader, modelConfig config.Model
opts := ModelOptions(modelConfig, appConfig)
inferenceModel, err = loader.Load(opts...)
if err != nil {
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
return schema.TokenizeResponse{}, err
}

View File

@@ -23,6 +23,7 @@ func ModelTranscription(audio, language string, translate, diarize bool, prompt
transcriptionModel, err := ml.Load(opts...)
if err != nil {
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
return nil, err
}

View File

@@ -31,6 +31,7 @@ func ModelTTS(
opts := ModelOptions(modelConfig, appConfig)
ttsModel, err := loader.Load(opts...)
if err != nil {
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
return "", nil, err
}
@@ -131,6 +132,7 @@ func ModelTTSStream(
opts := ModelOptions(modelConfig, appConfig)
ttsModel, err := loader.Load(opts...)
if err != nil {
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
return err
}

View File

@@ -17,6 +17,7 @@ func VAD(request *schema.VADRequest,
opts := ModelOptions(modelConfig, appConfig)
vadModel, err := ml.Load(opts...)
if err != nil {
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
return nil, err
}

View File

@@ -17,6 +17,7 @@ func VideoGeneration(height, width int32, prompt, negativePrompt, startImage, en
opts...,
)
if err != nil {
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
return nil, err
}

View File

@@ -21,6 +21,7 @@ type ApplicationConfig struct {
Debug bool
EnableTracing bool
TracingMaxItems int
EnableBackendLogging bool
GeneratedContentDir string
UploadDir string
@@ -213,6 +214,10 @@ var EnableTracing = func(o *ApplicationConfig) {
o.EnableTracing = true
}
var EnableBackendLogging = func(o *ApplicationConfig) {
o.EnableBackendLogging = true
}
var EnableWatchDogIdleCheck = func(o *ApplicationConfig) {
o.WatchDog = true
o.WatchDogIdle = true
@@ -743,6 +748,7 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
debug := o.Debug
tracingMaxItems := o.TracingMaxItems
enableTracing := o.EnableTracing
enableBackendLogging := o.EnableBackendLogging
cors := o.CORS
csrf := o.CSRF
corsAllowOrigins := o.CORSAllowOrigins
@@ -816,6 +822,7 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
Debug: &debug,
TracingMaxItems: &tracingMaxItems,
EnableTracing: &enableTracing,
EnableBackendLogging: &enableBackendLogging,
CORS: &cors,
CSRF: &csrf,
CORSAllowOrigins: &corsAllowOrigins,
@@ -944,6 +951,9 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
if settings.TracingMaxItems != nil {
o.TracingMaxItems = *settings.TracingMaxItems
}
if settings.EnableBackendLogging != nil {
o.EnableBackendLogging = *settings.EnableBackendLogging
}
if settings.CORS != nil {
o.CORS = *settings.CORS
}

View File

@@ -36,8 +36,9 @@ type RuntimeSettings struct {
ContextSize *int `json:"context_size,omitempty"`
F16 *bool `json:"f16,omitempty"`
Debug *bool `json:"debug,omitempty"`
EnableTracing *bool `json:"enable_tracing,omitempty"`
TracingMaxItems *int `json:"tracing_max_items,omitempty"`
EnableTracing *bool `json:"enable_tracing,omitempty"`
TracingMaxItems *int `json:"tracing_max_items,omitempty"`
EnableBackendLogging *bool `json:"enable_backend_logging,omitempty"`
// Security/CORS settings
CORS *bool `json:"cors,omitempty"`

View File

@@ -136,6 +136,12 @@ func UpdateSettingsEndpoint(app *application.Application) echo.HandlerFunc {
appConfig.ApiKeys = append(envKeys, runtimeKeys...)
}
// Update backend logging dynamically
if settings.EnableBackendLogging != nil {
app.ModelLoader().SetBackendLoggingEnabled(*settings.EnableBackendLogging)
xlog.Info("Updated backend logging setting", "enableBackendLogging", *settings.EnableBackendLogging)
}
// Update watchdog dynamically for settings that don't require restart
if settings.ForceEvictionWhenBusy != nil {
currentWD := app.ModelLoader().GetWatchDog()

View File

@@ -29,8 +29,10 @@ type APIExchangeResponse struct {
type APIExchange struct {
Timestamp time.Time `json:"timestamp"`
Duration time.Duration `json:"duration"`
Request APIExchangeRequest `json:"request"`
Response APIExchangeResponse `json:"response"`
Error string `json:"error,omitempty"`
}
var traceBuffer *circularbuffer.Queue[APIExchange]
@@ -108,13 +110,18 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
}
c.Response().Writer = mw
err = next(c)
if err != nil {
c.Response().Writer = mw.ResponseWriter // Restore original writer if error
return err
handlerErr := next(c)
// Restore original writer unconditionally
c.Response().Writer = mw.ResponseWriter
// Determine response status (use 500 if handler errored and no status was set)
status := c.Response().Status
if status == 0 && handlerErr != nil {
status = http.StatusInternalServerError
}
// Create exchange log
// Create exchange log (always, even on error)
requestHeaders := c.Request().Header.Clone()
requestBody := make([]byte, len(body))
copy(requestBody, body)
@@ -123,6 +130,7 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
copy(responseBody, resBody.Bytes())
exchange := APIExchange{
Timestamp: startTime,
Duration: time.Since(startTime),
Request: APIExchangeRequest{
Method: c.Request().Method,
Path: c.Path(),
@@ -130,11 +138,14 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
Body: &requestBody,
},
Response: APIExchangeResponse{
Status: c.Response().Status,
Status: status,
Headers: &responseHeaders,
Body: &responseBody,
},
}
if handlerErr != nil {
exchange.Error = handlerErr.Error()
}
select {
case logChan <- exchange:
@@ -142,7 +153,7 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
xlog.Warn("Trace channel full, dropping trace")
}
return nil
return handlerErr
}
}
}

View File

@@ -0,0 +1,64 @@
import { test, expect } from '@playwright/test'
test.describe('Backend Logs', () => {
test('model detail page shows title', async ({ page }) => {
await page.goto('/app/backend-logs/mock-model')
await expect(page.locator('.page-title')).toContainText('mock-model')
})
test('no back arrow link on detail page', async ({ page }) => {
await page.goto('/app/backend-logs/mock-model')
await expect(page.locator('a[href="/app/backend-logs"]')).not.toBeVisible()
})
test('filter buttons are visible', async ({ page }) => {
await page.goto('/app/backend-logs/mock-model')
await expect(page.locator('button', { hasText: 'All' })).toBeVisible()
await expect(page.locator('button', { hasText: 'stdout' })).toBeVisible()
await expect(page.locator('button', { hasText: 'stderr' })).toBeVisible()
})
test('filter buttons toggle active state', async ({ page }) => {
await page.goto('/app/backend-logs/mock-model')
const allBtn = page.locator('button', { hasText: 'All' })
const stdoutBtn = page.locator('button', { hasText: 'stdout' })
// All is active by default
await expect(allBtn).toHaveClass(/btn-primary/)
// Click stdout
await stdoutBtn.click()
await expect(stdoutBtn).toHaveClass(/btn-primary/)
await expect(allBtn).not.toHaveClass(/btn-primary/)
})
test('export button is present', async ({ page }) => {
await page.goto('/app/backend-logs/mock-model')
await expect(page.locator('button', { hasText: 'Export' })).toBeVisible()
})
test('auto-scroll checkbox is present', async ({ page }) => {
await page.goto('/app/backend-logs/mock-model')
await expect(page.locator('text=Auto-scroll')).toBeVisible()
})
test('clear button is present', async ({ page }) => {
await page.goto('/app/backend-logs/mock-model')
await expect(page.locator('button', { hasText: 'Clear' })).toBeVisible()
})
test('details toggle button is present and toggles', async ({ page }) => {
await page.goto('/app/backend-logs/mock-model')
// "Text only" button visible by default (details are shown)
const toggleBtn = page.locator('button', { hasText: 'Text only' })
await expect(toggleBtn).toBeVisible()
// Click to hide details
await toggleBtn.click()
// Button label changes to "Show details"
await expect(page.locator('button', { hasText: 'Show details' })).toBeVisible()
})
})

View File

@@ -0,0 +1,29 @@
import { test, expect } from '@playwright/test'
test.describe('Manage Page - Backend Logs Link', () => {
test('models table shows terminal icon for logs', async ({ page }) => {
await page.goto('/app/manage')
// Wait for models to load
await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 })
// Check for terminal icon (backend logs link)
const terminalIcon = page.locator('a[title="Backend logs"] i.fa-terminal')
await expect(terminalIcon.first()).toBeVisible()
})
test('terminal icon links to backend-logs page', async ({ page }) => {
await page.goto('/app/manage')
await expect(page.locator('.table')).toBeVisible({ timeout: 10_000 })
const logsLink = page.locator('a[title="Backend logs"]').first()
await expect(logsLink).toBeVisible()
// Link uses href="#" with onClick for navigation
const href = await logsLink.getAttribute('href')
expect(href).toBe('#')
// Click and verify navigation
await logsLink.click()
await expect(page).toHaveURL(/\/app\/backend-logs\//)
})
})

View File

@@ -0,0 +1,36 @@
import { test, expect } from '@playwright/test'
test.describe('Settings - Backend Logging', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/app/settings')
// Wait for settings to load
await expect(page.locator('h3', { hasText: 'Tracing' })).toBeVisible({ timeout: 10_000 })
})
test('backend logging toggle is visible in tracing section', async ({ page }) => {
await expect(page.locator('text=Enable Backend Logging')).toBeVisible()
})
test('backend logging toggle can be toggled', async ({ page }) => {
// Find the checkbox associated with backend logging
const section = page.locator('div', { has: page.locator('text=Enable Backend Logging') })
const checkbox = section.locator('input[type="checkbox"]').last()
// Toggle on
const wasChecked = await checkbox.isChecked()
await checkbox.locator('..').click()
if (wasChecked) {
await expect(checkbox).not.toBeChecked()
} else {
await expect(checkbox).toBeChecked()
}
})
test('save shows toast', async ({ page }) => {
// Click save button
await page.locator('button', { hasText: 'Save' }).click()
// Verify toast appears
await expect(page.locator('text=Settings saved')).toBeVisible({ timeout: 5_000 })
})
})

View File

@@ -0,0 +1,50 @@
import { test, expect } from '@playwright/test'
test.describe('Traces - Error Display', () => {
test.beforeEach(async ({ page }) => {
// Mock API traces with sample data so the table renders
await page.route('**/api/traces', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify([
{
request: { method: 'POST', path: '/v1/chat/completions' },
response: { status: 200 },
error: null,
},
]),
})
})
// Mock backend traces with sample data
await page.route('**/api/backend-traces', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify([
{
type: 'model_load',
timestamp: Date.now() * 1_000_000,
model_name: 'mock-model',
summary: 'Loaded model',
duration: 500_000_000,
error: null,
},
]),
})
})
await page.goto('/app/traces')
await expect(page.locator('text=Tracing is')).toBeVisible({ timeout: 10_000 })
})
test('API traces tab has Result column header', async ({ page }) => {
// API tab is active by default
await expect(page.locator('th', { hasText: 'Result' })).toBeVisible()
})
test('backend traces tab shows model_load type if present', async ({ page }) => {
// Switch to backend traces tab
await page.locator('button', { hasText: 'Backend Traces' }).click()
// The table should be visible with Type column
await expect(page.locator('th', { hasText: 'Type' })).toBeVisible()
})
})

View File

@@ -2109,7 +2109,7 @@
height: 4px;
appearance: none;
-webkit-appearance: none;
background: var(--color-bg-tertiary);
background: var(--color-border-default);
border-radius: 2px;
outline: none;
}

View File

@@ -0,0 +1,297 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import { useParams, useSearchParams, useOutletContext, Link } from 'react-router-dom'
import { backendLogsApi } from '../utils/api'
import { formatTimestamp } from '../utils/format'
import { apiUrl } from '../utils/basePath'
import LoadingSpinner from '../components/LoadingSpinner'
function wsUrl(path) {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${window.location.host}${apiUrl(path)}`
}
const STREAM_BADGE = {
stdout: { bg: 'rgba(59,130,246,0.15)', color: '#60a5fa', label: 'stdout' },
stderr: { bg: 'rgba(239,68,68,0.15)', color: '#f87171', label: 'stderr' },
}
// Detail view: log lines for a specific model
function BackendLogsDetail({ modelId }) {
const { addToast } = useOutletContext()
const [searchParams] = useSearchParams()
const fromTimestamp = searchParams.get('from')
const [lines, setLines] = useState([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState('all')
const [autoScroll, setAutoScroll] = useState(true)
const [showDetails, setShowDetails] = useState(true)
const [wsConnected, setWsConnected] = useState(false)
const logContainerRef = useRef(null)
const wsRef = useRef(null)
const reconnectTimerRef = useRef(null)
const loadingRef = useRef(true)
const scrolledToTimestampRef = useRef(false)
const pendingLinesRef = useRef([])
const flushTimerRef = useRef(null)
// Keep loadingRef in sync
useEffect(() => { loadingRef.current = loading }, [loading])
// Auto-scroll to bottom when new lines arrive
useEffect(() => {
if (autoScroll && logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
}
}, [lines, autoScroll])
// WebSocket connection with reconnect
const connectWebSocket = useCallback(() => {
if (wsRef.current && wsRef.current.readyState <= 1) return
const url = wsUrl(`/ws/backend-logs/${encodeURIComponent(modelId)}`)
const ws = new WebSocket(url)
wsRef.current = ws
ws.onopen = () => {
setWsConnected(true)
setLoading(false)
}
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data)
if (msg.type === 'initial') {
setLines(Array.isArray(msg.lines) ? msg.lines : [])
setLoading(false)
} else if (msg.type === 'line' && msg.line) {
// Batch incoming lines to reduce renders
pendingLinesRef.current.push(msg.line)
if (!flushTimerRef.current) {
flushTimerRef.current = requestAnimationFrame(() => {
const batch = pendingLinesRef.current
pendingLinesRef.current = []
flushTimerRef.current = null
setLines(prev => prev.concat(batch))
})
}
}
} catch {
// ignore parse errors
}
}
ws.onclose = () => {
setWsConnected(false)
reconnectTimerRef.current = setTimeout(connectWebSocket, 3000)
}
ws.onerror = () => {
// Fall back to REST if WebSocket fails on first connect
if (loadingRef.current) {
backendLogsApi.getLines(modelId)
.then(data => setLines(Array.isArray(data) ? data : []))
.catch(() => {})
.finally(() => setLoading(false))
}
}
}, [modelId])
useEffect(() => {
connectWebSocket()
return () => {
if (wsRef.current) wsRef.current.close()
if (reconnectTimerRef.current) clearTimeout(reconnectTimerRef.current)
if (flushTimerRef.current) cancelAnimationFrame(flushTimerRef.current)
}
}, [connectWebSocket])
// Scroll to timestamp if `from` query param is set (once)
useEffect(() => {
if (!fromTimestamp || scrolledToTimestampRef.current || !logContainerRef.current || lines.length === 0) return
const fromDate = new Date(fromTimestamp).getTime()
const lineElements = logContainerRef.current.querySelectorAll('[data-log-line]')
for (const el of lineElements) {
const lineTime = new Date(el.dataset.timestamp).getTime()
if (lineTime >= fromDate) {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
el.style.background = 'rgba(59,130,246,0.1)'
setTimeout(() => { el.style.background = '' }, 3000)
scrolledToTimestampRef.current = true
break
}
}
}, [fromTimestamp, lines])
const filteredLines = useMemo(
() => filter === 'all' ? lines : lines.filter(l => l.stream === filter),
[lines, filter]
)
const handleClear = async () => {
try {
await backendLogsApi.clear(modelId)
setLines([])
addToast('Logs cleared', 'success')
} catch (err) {
addToast(`Failed to clear: ${err.message}`, 'error')
}
}
const handleExport = () => {
const blob = new Blob([JSON.stringify(filteredLines, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `backend-logs-${modelId}-${new Date().toISOString().slice(0, 10)}.json`
a.click()
URL.revokeObjectURL(url)
}
return (
<div className="page">
<div className="page-header">
<div>
<h1 className="page-title" style={{ marginBottom: 0 }}>
<i className="fas fa-terminal" style={{ fontSize: '0.8em', marginRight: 'var(--spacing-sm)' }} />
{modelId}
</h1>
<p className="page-subtitle" style={{ marginTop: 'var(--spacing-xs)' }}>Backend process output</p>
</div>
</div>
{/* Toolbar */}
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)', alignItems: 'center', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: 2 }}>
{['all', 'stdout', 'stderr'].map(f => (
<button
key={f}
className={`btn btn-sm ${filter === f ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setFilter(f)}
>
{f === 'all' ? 'All' : f}
</button>
))}
</div>
<button className="btn btn-danger btn-sm" onClick={handleClear}><i className="fas fa-trash" /> Clear</button>
<button className="btn btn-secondary btn-sm" onClick={handleExport} disabled={filteredLines.length === 0}>
<i className="fas fa-download" /> Export
</button>
<button
className={`btn btn-sm ${showDetails ? 'btn-secondary' : 'btn-primary'}`}
onClick={() => setShowDetails(prev => !prev)}
title={showDetails ? 'Hide timestamps and stream labels for easier copying' : 'Show timestamps and stream labels'}
>
<i className={`fas ${showDetails ? 'fa-eye-slash' : 'fa-eye'}`} /> {showDetails ? 'Text only' : 'Show details'}
</button>
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)', fontSize: '0.8125rem' }}>
<span style={{
display: 'inline-block',
width: 8, height: 8,
borderRadius: '50%',
background: wsConnected ? 'var(--color-success)' : 'var(--color-text-muted)',
}} />
<span style={{ color: 'var(--color-text-secondary)' }}>
{wsConnected ? 'Live' : 'Reconnecting...'}
</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer', marginLeft: 'var(--spacing-sm)' }}>
<input
type="checkbox"
checked={autoScroll}
onChange={(e) => setAutoScroll(e.target.checked)}
/>
<span style={{ color: 'var(--color-text-secondary)' }}>Auto-scroll</span>
</label>
</div>
</div>
{/* Log output */}
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<LoadingSpinner size="lg" />
</div>
) : filteredLines.length === 0 ? (
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-terminal" /></div>
<h2 className="empty-state-title">No log lines</h2>
<p className="empty-state-text">
{filter !== 'all'
? `No ${filter} output. Try switching to "All".`
: 'Log output will appear here as the backend process runs.'}
</p>
</div>
) : (
<div
ref={logContainerRef}
style={{
background: 'var(--color-bg-primary)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
overflow: 'auto',
maxHeight: 'calc(100vh - 280px)',
fontFamily: 'JetBrains Mono, Consolas, monospace',
fontSize: '0.75rem',
lineHeight: '1.5',
}}
>
{filteredLines.map((line, i) => {
const badge = STREAM_BADGE[line.stream] || STREAM_BADGE.stdout
return (
<div
key={i}
data-log-line
data-timestamp={line.timestamp}
style={{
display: 'flex',
gap: showDetails ? 'var(--spacing-sm)' : undefined,
padding: '2px var(--spacing-sm)',
borderBottom: '1px solid var(--color-border-subtle, rgba(255,255,255,0.03))',
alignItems: 'flex-start',
}}
>
{showDetails && (<>
<span style={{ color: 'var(--color-text-muted)', flexShrink: 0, minWidth: 90 }}>
{formatTimestamp(line.timestamp)}
</span>
<span style={{
background: badge.bg, color: badge.color,
padding: '0 4px', borderRadius: 'var(--radius-sm)',
fontSize: '0.625rem', fontWeight: 500, flexShrink: 0,
lineHeight: '1.5',
}}>
{badge.label}
</span>
</>)}
<span style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-all', flex: 1 }}>
{line.text}
</span>
</div>
)
})}
</div>
)}
</div>
)
}
export default function BackendLogs() {
const { modelId } = useParams()
if (modelId) {
return <BackendLogsDetail modelId={decodeURIComponent(modelId)} />
}
// No model specified — redirect to System page
return (
<div className="page">
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-terminal" /></div>
<h2 className="empty-state-title">No model selected</h2>
<p className="empty-state-text">
View backend logs for a specific model from the{' '}
<Link to="/app/manage" style={{ color: 'var(--color-primary)' }}>System page</Link>.
</p>
</div>
</div>
)
}

View File

@@ -1,10 +1,10 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { useNavigate, useOutletContext } from 'react-router-dom'
import { backendsApi } from '../utils/api'
import React from 'react'
import { useOperations } from '../hooks/useOperations'
import LoadingSpinner from '../components/LoadingSpinner'
import { renderMarkdown } from '../utils/markdown'
import Modal from '../components/Modal'
export default function Backends() {
const { addToast } = useOutletContext()
@@ -21,7 +21,7 @@ export default function Backends() {
const [manualUri, setManualUri] = useState('')
const [manualName, setManualName] = useState('')
const [manualAlias, setManualAlias] = useState('')
const [selectedBackend, setSelectedBackend] = useState(null)
const [expandedRow, setExpandedRow] = useState(null)
const debounceRef = useRef(null)
const [allBackends, setAllBackends] = useState([])
@@ -246,6 +246,7 @@ export default function Backends() {
<table className="table">
<thead>
<tr>
<th style={{ width: 30 }}></th>
<th style={{ width: 40 }}></th>
<SortHeader col="name">Backend</SortHeader>
<th>Description</th>
@@ -256,12 +257,21 @@ export default function Backends() {
</tr>
</thead>
<tbody>
{backends.map(b => {
{backends.map((b, idx) => {
const op = getBackendOp(b)
const isProcessing = !!op
const isExpanded = expandedRow === idx
return (
<tr key={b.name || b.id}>
<React.Fragment key={b.name || b.id}>
<tr
onClick={() => setExpandedRow(isExpanded ? null : idx)}
style={{ cursor: 'pointer' }}
>
{/* Chevron */}
<td style={{ width: 30 }}>
<i className={`fas fa-chevron-${isExpanded ? 'down' : 'right'}`} style={{ fontSize: '0.625rem', color: 'var(--color-text-muted)', transition: 'transform 150ms' }} />
</td>
{/* Icon */}
<td>
{b.icon ? (
@@ -279,12 +289,7 @@ export default function Backends() {
{/* Name */}
<td>
<span
style={{ fontWeight: 500, cursor: 'pointer', color: 'var(--color-primary)' }}
onClick={() => setSelectedBackend(b)}
>
{b.name || b.id}
</span>
<span style={{ fontWeight: 500 }}>{b.name || b.id}</span>
</td>
{/* Description */}
@@ -343,10 +348,7 @@ export default function Backends() {
{/* Actions */}
<td>
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }}>
<button className="btn btn-secondary btn-sm" onClick={() => setSelectedBackend(b)} title="Details">
<i className="fas fa-info-circle" />
</button>
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }} onClick={e => e.stopPropagation()}>
{b.installed ? (
<>
<button className="btn btn-secondary btn-sm" onClick={() => handleInstall(b.name || b.id)} title="Reinstall" disabled={isProcessing}>
@@ -364,6 +366,15 @@ export default function Backends() {
</div>
</td>
</tr>
{/* Expanded detail row */}
{isExpanded && (
<tr>
<td colSpan="8" style={{ padding: 0 }}>
<BackendDetail backend={b} />
</td>
</tr>
)}
</React.Fragment>
)
})}
</tbody>
@@ -389,106 +400,67 @@ export default function Backends() {
</div>
)}
{/* Detail Modal */}
{selectedBackend && (
<Modal onClose={() => setSelectedBackend(null)}>
<div style={{ padding: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 'var(--spacing-md)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
{selectedBackend.icon ? (
<img src={selectedBackend.icon} alt="" style={{ width: 48, height: 48, borderRadius: 'var(--radius-md)' }} />
) : (
<div style={{
width: 48, height: 48, borderRadius: 'var(--radius-md)',
background: 'var(--color-bg-tertiary)', display: 'flex',
alignItems: 'center', justifyContent: 'center',
}}>
<i className="fas fa-cog" style={{ fontSize: '1.25rem', color: 'var(--color-text-muted)' }} />
</div>
)}
<div>
<h3 style={{ fontWeight: 600, fontSize: '1.125rem' }}>{selectedBackend.name || selectedBackend.id}</h3>
{selectedBackend.installed && <span className="badge badge-success">Installed</span>}
</div>
</div>
<button className="btn btn-secondary btn-sm" onClick={() => setSelectedBackend(null)}>
<i className="fas fa-xmark" />
</button>
</div>
{/* Description */}
{selectedBackend.description && (
<div style={{ marginBottom: 'var(--spacing-md)' }}>
<div
style={{ fontSize: '0.875rem', color: 'var(--color-text-secondary)', lineHeight: 1.6 }}
dangerouslySetInnerHTML={{ __html: renderMarkdown(selectedBackend.description) }}
/>
</div>
)}
{/* Tags */}
{selectedBackend.tags && selectedBackend.tags.length > 0 && (
<div style={{ marginBottom: 'var(--spacing-md)' }}>
<span className="form-label">Tags</span>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
{selectedBackend.tags.map(tag => (
<span key={tag} className="badge badge-info" style={{ fontSize: '0.6875rem' }}>{tag}</span>
))}
</div>
</div>
)}
{/* URLs */}
{selectedBackend.urls && selectedBackend.urls.length > 0 && (
<div style={{ marginBottom: 'var(--spacing-md)' }}>
<span className="form-label">Links</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{selectedBackend.urls.map((url, i) => (
<a key={i} href={url} target="_blank" rel="noopener noreferrer" style={{ fontSize: '0.8125rem', color: 'var(--color-primary)', wordBreak: 'break-all' }}>
<i className="fas fa-external-link-alt" style={{ marginRight: 4 }} />{url}
</a>
))}
</div>
</div>
)}
{/* Repository / License */}
<div style={{ display: 'flex', gap: 'var(--spacing-md)', marginBottom: 'var(--spacing-md)' }}>
{selectedBackend.gallery && (
<div>
<span className="form-label">Repository</span>
<p style={{ fontSize: '0.8125rem' }}>{typeof selectedBackend.gallery === 'string' ? selectedBackend.gallery : selectedBackend.gallery.name || '-'}</p>
</div>
)}
{selectedBackend.license && (
<div>
<span className="form-label">License</span>
<p style={{ fontSize: '0.8125rem' }}>{selectedBackend.license}</p>
</div>
)}
</div>
{/* Actions */}
<div style={{ display: 'flex', gap: 'var(--spacing-sm)', justifyContent: 'flex-end', borderTop: '1px solid var(--color-border-subtle)', paddingTop: 'var(--spacing-md)' }}>
{selectedBackend.installed ? (
<>
<button className="btn btn-secondary btn-sm" onClick={() => { handleInstall(selectedBackend.name || selectedBackend.id); setSelectedBackend(null) }}>
<i className="fas fa-rotate" /> Reinstall
</button>
<button className="btn btn-danger btn-sm" onClick={() => { handleDelete(selectedBackend.name || selectedBackend.id); setSelectedBackend(null) }}>
<i className="fas fa-trash" /> Delete
</button>
</>
) : (
<button className="btn btn-primary btn-sm" onClick={() => { handleInstall(selectedBackend.name || selectedBackend.id); setSelectedBackend(null) }}>
<i className="fas fa-download" /> Install
</button>
)}
<button className="btn btn-secondary btn-sm" onClick={() => setSelectedBackend(null)}>Close</button>
</div>
</div>
</Modal>
)}
</div>
)
}
function BackendDetailRow({ label, children }) {
if (!children) return null
return (
<tr>
<td style={{ fontWeight: 500, fontSize: '0.8125rem', color: 'var(--color-text-secondary)', whiteSpace: 'nowrap', verticalAlign: 'top', padding: '6px 12px 6px 0' }}>
{label}
</td>
<td style={{ fontSize: '0.8125rem', padding: '6px 0' }}>{children}</td>
</tr>
)
}
function BackendDetail({ backend }) {
return (
<div style={{ padding: 'var(--spacing-md) var(--spacing-lg)', background: 'var(--color-bg-primary)', borderTop: '1px solid var(--color-border-subtle)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
<BackendDetailRow label="Description">
{backend.description && (
<div
style={{ color: 'var(--color-text-secondary)', lineHeight: 1.6 }}
dangerouslySetInnerHTML={{ __html: renderMarkdown(backend.description) }}
/>
)}
</BackendDetailRow>
<BackendDetailRow label="Repository">
{backend.gallery && (
<span className="badge badge-info" style={{ fontSize: '0.6875rem' }}>
{typeof backend.gallery === 'string' ? backend.gallery : backend.gallery.name || '-'}
</span>
)}
</BackendDetailRow>
<BackendDetailRow label="License">
{backend.license && <span>{backend.license}</span>}
</BackendDetailRow>
<BackendDetailRow label="Tags">
{backend.tags?.length > 0 && (
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{backend.tags.map(tag => (
<span key={tag} className="badge badge-info" style={{ fontSize: '0.6875rem' }}>{tag}</span>
))}
</div>
)}
</BackendDetailRow>
<BackendDetailRow label="Links">
{backend.urls?.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
{backend.urls.map((url, i) => (
<a key={i} href={url} target="_blank" rel="noopener noreferrer" style={{ fontSize: '0.8125rem', color: 'var(--color-primary)', wordBreak: 'break-all' }}>
<i className="fas fa-external-link-alt" style={{ marginRight: 4, fontSize: '0.6875rem' }} />{url}
</a>
))}
</div>
)}
</BackendDetailRow>
</tbody>
</table>
</div>
)
}

View File

@@ -354,6 +354,9 @@ export default function Chat() {
modelsApi.getConfigJson(model).then(cfg => {
if (cancelled) return
setModelInfo(cfg)
if (cfg?.context_size > 0 && activeChat) {
updateChatSettings(activeChat.id, { contextSize: cfg.context_size })
}
const hasMcp = !!(cfg?.mcp?.remote || cfg?.mcp?.stdio)
setMcpAvailable(hasMcp)
if (!hasMcp && activeChat?.mcpMode) {

View File

@@ -204,6 +204,14 @@ export default function Manage() {
>
<i className="fas fa-pen-to-square" />
</a>
<a
href="#"
onClick={(e) => { e.preventDefault(); navigate(`/app/backend-logs/${encodeURIComponent(model.id)}`) }}
style={{ fontSize: '0.75rem', color: 'var(--color-primary)' }}
title="Backend logs"
>
<i className="fas fa-terminal" />
</a>
</div>
</td>
<td>

View File

@@ -3,8 +3,7 @@ import { useNavigate, useOutletContext } from 'react-router-dom'
import { modelsApi } from '../utils/api'
import { useOperations } from '../hooks/useOperations'
import { useResources } from '../hooks/useResources'
import { formatBytes } from '../utils/format'
import Modal from '../components/Modal'
import React from 'react'
const LOADING_PHRASES = [
@@ -135,7 +134,8 @@ export default function Models() {
const [sort, setSort] = useState('')
const [order, setOrder] = useState('asc')
const [installing, setInstalling] = useState(new Set())
const [selectedModel, setSelectedModel] = useState(null)
const [expandedRow, setExpandedRow] = useState(null)
const [expandedFiles, setExpandedFiles] = useState(false)
const [stats, setStats] = useState({ total: 0, installed: 0, repositories: 0 })
const debounceRef = useRef(null)
@@ -322,6 +322,7 @@ export default function Models() {
<table className="table" style={{ minWidth: '800px' }}>
<thead>
<tr>
<th style={{ width: '30px' }}></th>
<th style={{ width: '60px' }}></th>
<th style={{ cursor: 'pointer' }} onClick={() => handleSort('name')}>
Model Name {sort === 'name' && <i className={`fas fa-arrow-${order === 'asc' ? 'up' : 'down'}`} style={{ fontSize: '0.625rem' }} />}
@@ -335,14 +336,23 @@ export default function Models() {
</tr>
</thead>
<tbody>
{models.map(model => {
{models.map((model, idx) => {
const name = model.name || model.id
const installing = isInstalling(name)
const progress = getOperationProgress(name)
const fit = fitsGpu(model.estimated_vram_bytes)
const isExpanded = expandedRow === idx
return (
<tr key={name}>
<React.Fragment key={name}>
<tr
onClick={() => { setExpandedRow(isExpanded ? null : idx); setExpandedFiles(false) }}
style={{ cursor: 'pointer' }}
>
{/* Chevron */}
<td style={{ width: 30 }}>
<i className={`fas fa-chevron-${isExpanded ? 'down' : 'right'}`} style={{ fontSize: '0.625rem', color: 'var(--color-text-muted)', transition: 'transform 150ms' }} />
</td>
{/* Icon */}
<td>
<div style={{
@@ -440,14 +450,7 @@ export default function Models() {
{/* Actions */}
<td>
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }}>
<button
className="btn btn-secondary btn-sm"
onClick={() => setSelectedModel(model)}
title="Details"
>
<i className="fas fa-info-circle" />
</button>
<div style={{ display: 'flex', gap: 'var(--spacing-xs)', justifyContent: 'flex-end' }} onClick={e => e.stopPropagation()}>
{model.installed ? (
<>
<button className="btn btn-secondary btn-sm" onClick={() => handleInstall(name)} title="Reinstall">
@@ -470,6 +473,15 @@ export default function Models() {
</div>
</td>
</tr>
{/* Expanded detail row */}
{isExpanded && (
<tr>
<td colSpan="7" style={{ padding: 0 }}>
<ModelDetail model={model} fit={fit} expandedFiles={expandedFiles} setExpandedFiles={setExpandedFiles} />
</td>
</tr>
)}
</React.Fragment>
)
})}
</tbody>
@@ -493,74 +505,125 @@ export default function Models() {
</div>
)}
{/* Detail Modal */}
{selectedModel && (
<Modal onClose={() => setSelectedModel(null)}>
{/* Modal header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: 'var(--spacing-md)', borderBottom: '1px solid var(--color-border-subtle)',
}}>
<h3 style={{ fontSize: '1rem', fontWeight: 600 }}>{selectedModel.name}</h3>
<button className="btn btn-secondary btn-sm" onClick={() => setSelectedModel(null)}>
<i className="fas fa-times" />
</button>
</div>
{/* Modal body */}
<div style={{ padding: 'var(--spacing-md)', overflowY: 'auto', flex: 1 }}>
{/* Icon */}
{selectedModel.icon && (
<div style={{
width: 48, height: 48, borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-subtle)', overflow: 'hidden',
marginBottom: 'var(--spacing-md)',
}}>
<img src={selectedModel.icon} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
</div>
</div>
)
}
function DetailRow({ label, children }) {
if (!children) return null
return (
<tr>
<td style={{ fontWeight: 500, fontSize: '0.8125rem', color: 'var(--color-text-secondary)', whiteSpace: 'nowrap', verticalAlign: 'top', padding: '6px 12px 6px 0' }}>
{label}
</td>
<td style={{ fontSize: '0.8125rem', padding: '6px 0' }}>{children}</td>
</tr>
)
}
function ModelDetail({ model, fit, expandedFiles, setExpandedFiles }) {
const files = model.additionalFiles || model.files || []
return (
<div style={{ padding: 'var(--spacing-md) var(--spacing-lg)', background: 'var(--color-bg-primary)', borderTop: '1px solid var(--color-border-subtle)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
<DetailRow label="Description">
{model.description && (
<span style={{ color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>{model.description}</span>
)}
{/* Description */}
{selectedModel.description && (
<p style={{ fontSize: '0.875rem', color: 'var(--color-text-secondary)', lineHeight: 1.6, marginBottom: 'var(--spacing-md)' }}>
{selectedModel.description}
</p>
</DetailRow>
<DetailRow label="Gallery">
{model.gallery && (
<span className="badge badge-info" style={{ fontSize: '0.6875rem' }}>
{typeof model.gallery === 'string' ? model.gallery : model.gallery.name || '—'}
</span>
)}
{/* Size/VRAM */}
{(selectedModel.estimated_size_display || selectedModel.estimated_vram_display) && (
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginBottom: 'var(--spacing-md)' }}>
{selectedModel.estimated_size_display && <div>Size: {selectedModel.estimated_size_display}</div>}
{selectedModel.estimated_vram_display && <div>VRAM: {selectedModel.estimated_vram_display}</div>}
</div>
)}
{/* Tags */}
{selectedModel.tags?.length > 0 && (
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap', marginBottom: 'var(--spacing-md)' }}>
{selectedModel.tags.map(tag => (
<span key={tag} className="badge badge-info">{tag}</span>
</DetailRow>
<DetailRow label="Size">
{model.estimated_size_display && model.estimated_size_display !== '0 B' ? model.estimated_size_display : null}
</DetailRow>
<DetailRow label="VRAM">
{model.estimated_vram_display && model.estimated_vram_display !== '0 B' ? (
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{model.estimated_vram_display}
{fit !== null && (
<span style={{ fontSize: '0.75rem', color: fit ? 'var(--color-success)' : 'var(--color-error)' }}>
<i className="fas fa-microchip" /> {fit ? 'Fits in GPU' : 'May not fit in GPU'}
</span>
)}
</span>
) : null}
</DetailRow>
<DetailRow label="License">
{model.license && <span>{model.license}</span>}
</DetailRow>
<DetailRow label="Tags">
{model.tags?.length > 0 && (
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap' }}>
{model.tags.map(tag => (
<span key={tag} className="badge badge-info" style={{ fontSize: '0.6875rem' }}>{tag}</span>
))}
</div>
)}
{/* Links */}
{selectedModel.urls?.length > 0 && (
<div style={{ marginBottom: 'var(--spacing-md)' }}>
<h4 style={{ fontSize: '0.8125rem', fontWeight: 600, marginBottom: 'var(--spacing-xs)' }}>Links</h4>
{selectedModel.urls.map((url, i) => (
<a key={i} href={url} target="_blank" rel="noopener noreferrer" style={{ display: 'block', fontSize: '0.8125rem', color: 'var(--color-primary)', marginBottom: '2px' }}>
{url}
</DetailRow>
<DetailRow label="Links">
{model.urls?.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
{model.urls.map((url, i) => (
<a key={i} href={url} target="_blank" rel="noopener noreferrer" style={{ fontSize: '0.8125rem', color: 'var(--color-primary)', wordBreak: 'break-all' }}>
<i className="fas fa-external-link-alt" style={{ marginRight: 4, fontSize: '0.6875rem' }} />{url}
</a>
))}
</div>
)}
</div>
{/* Modal footer */}
<div style={{
padding: 'var(--spacing-sm) var(--spacing-md)',
borderTop: '1px solid var(--color-border-subtle)',
display: 'flex', justifyContent: 'flex-end',
}}>
<button className="btn btn-secondary btn-sm" onClick={() => setSelectedModel(null)}>Close</button>
</div>
</Modal>
)}
</DetailRow>
{model.trustRemoteCode && (
<DetailRow label="Warning">
<span className="badge badge-error" style={{ fontSize: '0.6875rem' }}>
<i className="fas fa-circle-exclamation" /> Requires Trust Remote Code
</span>
</DetailRow>
)}
{files.length > 0 && (
<DetailRow label="Files">
<div>
<button
className="btn btn-secondary btn-sm"
onClick={(e) => { e.stopPropagation(); setExpandedFiles(!expandedFiles) }}
style={{ marginBottom: expandedFiles ? 'var(--spacing-sm)' : 0 }}
>
<i className={`fas fa-chevron-${expandedFiles ? 'down' : 'right'}`} style={{ fontSize: '0.5rem', marginRight: 4 }} />
{files.length} file{files.length !== 1 ? 's' : ''}
</button>
{expandedFiles && (
<div style={{ border: '1px solid var(--color-border)', borderRadius: 'var(--radius-md)', overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.75rem' }}>
<thead>
<tr style={{ background: 'var(--color-bg-tertiary)' }}>
<th style={{ padding: '4px 8px', textAlign: 'left', fontWeight: 500 }}>Filename</th>
<th style={{ padding: '4px 8px', textAlign: 'left', fontWeight: 500 }}>URI</th>
<th style={{ padding: '4px 8px', textAlign: 'left', fontWeight: 500 }}>SHA256</th>
</tr>
</thead>
<tbody>
{files.map((f, i) => (
<tr key={i} style={{ borderTop: '1px solid var(--color-border-subtle)' }}>
<td style={{ padding: '4px 8px', fontFamily: 'monospace' }}>{f.filename || '—'}</td>
<td style={{ padding: '4px 8px', wordBreak: 'break-all', maxWidth: 300 }}>{f.uri || '—'}</td>
<td style={{ padding: '4px 8px', fontFamily: 'monospace', fontSize: '0.6875rem', color: 'var(--color-text-muted)' }}>
{f.sha256 ? f.sha256.substring(0, 16) + '...' : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</DetailRow>
)}
</tbody>
</table>
</div>
)
}

View File

@@ -300,6 +300,9 @@ export default function Settings() {
<SettingRow label="Max Items" description="Maximum number of trace items to retain (0 = unlimited)">
<input className="input" type="number" style={{ width: 120 }} value={settings.tracing_max_items ?? ''} onChange={(e) => update('tracing_max_items', parseInt(e.target.value) || 0)} placeholder="100" disabled={!settings.enable_tracing} />
</SettingRow>
<SettingRow label="Enable Backend Logging" description="Capture backend process output per model (without requiring debug mode)">
<Toggle checked={settings.enable_backend_logging} onChange={(v) => update('enable_backend_logging', v)} />
</SettingRow>
</div>
</div>

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useOutletContext } from 'react-router-dom'
import { tracesApi, settingsApi } from '../utils/api'
import { formatTimestamp } from '../utils/format'
import LoadingSpinner from '../components/LoadingSpinner'
import Toggle from '../components/Toggle'
import SettingRow from '../components/SettingRow'
@@ -19,12 +20,6 @@ function formatDuration(ns) {
return `${(ns / 1_000_000_000).toFixed(2)}s`
}
function formatTimestamp(ts) {
if (!ts) return '-'
const d = new Date(ts)
return d.toLocaleTimeString() + '.' + String(d.getMilliseconds()).padStart(3, '0')
}
function decodeTraceBody(body) {
if (!body) return ''
try {
@@ -75,6 +70,8 @@ const TYPE_COLORS = {
sound_generation: { bg: 'rgba(20,184,166,0.15)', color: '#2dd4bf' },
rerank: { bg: 'rgba(99,102,241,0.15)', color: '#818cf8' },
tokenize: { bg: 'rgba(107,114,128,0.15)', color: '#9ca3af' },
detection: { bg: 'rgba(14,165,233,0.15)', color: '#38bdf8' },
model_load: { bg: 'rgba(239,68,68,0.15)', color: '#f87171' },
}
function typeBadgeStyle(type) {
@@ -221,6 +218,18 @@ function BackendTraceDetail({ trace }) {
</div>
)}
{/* Backend logs link */}
{trace.model_name && (
<div style={{ marginBottom: 'var(--spacing-md)' }}>
<a
href={`/app/backend-logs/${encodeURIComponent(trace.model_name)}${trace.timestamp ? `?from=${encodeURIComponent(trace.timestamp)}` : ''}`}
style={{ fontSize: '0.8125rem', color: 'var(--color-primary)', textDecoration: 'none', display: 'inline-flex', alignItems: 'center', gap: 'var(--spacing-xs)' }}
>
<i className="fas fa-terminal" /> View backend logs
</a>
</div>
)}
{/* Audio snippet */}
{trace.data && <AudioSnippet data={trace.data} />}
@@ -234,6 +243,16 @@ function BackendTraceDetail({ trace }) {
function ApiTraceDetail({ trace }) {
return (
<div style={{ padding: 'var(--spacing-md)', background: 'var(--color-bg-secondary)', borderBottom: '1px solid var(--color-border)' }}>
{trace.error && (
<div style={{
background: 'var(--color-error-light)', border: '1px solid var(--color-error-border)',
borderRadius: 'var(--radius-md)', padding: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)',
display: 'flex', alignItems: 'center', gap: 'var(--spacing-xs)',
}}>
<i className="fas fa-exclamation-triangle" style={{ color: 'var(--color-error)' }} />
<span style={{ color: 'var(--color-error)', fontSize: '0.8125rem', fontFamily: 'monospace', wordBreak: 'break-all' }}>{trace.error}</span>
</div>
)}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--spacing-md)' }}>
<div>
<h4 style={{ fontSize: '0.8125rem', fontWeight: 600, marginBottom: 'var(--spacing-xs)' }}>Request Body</h4>
@@ -452,6 +471,7 @@ export default function Traces() {
<th>Method</th>
<th>Path</th>
<th>Status</th>
<th style={{ width: '40px' }}>Result</th>
</tr>
</thead>
<tbody>
@@ -462,10 +482,15 @@ export default function Traces() {
<td><span className="badge badge-info">{trace.request?.method || '-'}</span></td>
<td style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: '0.8125rem' }}>{trace.request?.path || '-'}</td>
<td><span className={`badge ${(trace.response?.status || 0) < 400 ? 'badge-success' : 'badge-error'}`}>{trace.response?.status || '-'}</span></td>
<td style={{ textAlign: 'center' }}>
{trace.error
? <i className="fas fa-times-circle" style={{ color: 'var(--color-error)' }} title={trace.error} />
: <i className="fas fa-check-circle" style={{ color: 'var(--color-success)' }} />}
</td>
</tr>
{expandedRow === i && (
<tr>
<td colSpan="4" style={{ padding: 0 }}>
<td colSpan="5" style={{ padding: 0 }}>
<ApiTraceDetail trace={trace} />
</td>
</tr>

View File

@@ -27,6 +27,7 @@ import AgentTaskDetails from './pages/AgentTaskDetails'
import AgentJobDetails from './pages/AgentJobDetails'
import ModelEditor from './pages/ModelEditor'
import ImportModel from './pages/ImportModel'
import BackendLogs from './pages/BackendLogs'
import Explorer from './pages/Explorer'
import Login from './pages/Login'
import NotFound from './pages/NotFound'
@@ -54,6 +55,7 @@ const appChildren = [
{ path: 'backends', element: <Backends /> },
{ path: 'settings', element: <Settings /> },
{ path: 'traces', element: <Traces /> },
{ path: 'backend-logs/:modelId', element: <BackendLogs /> },
{ path: 'p2p', element: <P2P /> },
{ path: 'agents', element: <Agents /> },
{ path: 'agents/new', element: <AgentCreate /> },

View File

@@ -140,6 +140,13 @@ export const settingsApi = {
save: (body) => postJSON(API_CONFIG.endpoints.settings, body),
}
// Backend Logs API
export const backendLogsApi = {
listModels: () => fetchJSON(API_CONFIG.endpoints.backendLogs),
getLines: (modelId) => fetchJSON(API_CONFIG.endpoints.backendLogsModel(modelId)),
clear: (modelId) => postJSON(API_CONFIG.endpoints.clearBackendLogs(modelId), {}),
}
// Traces API
export const tracesApi = {
get: () => fetchJSON(API_CONFIG.endpoints.traces),

View File

@@ -33,6 +33,11 @@ export const API_CONFIG = {
backendTraces: '/api/backend-traces',
clearBackendTraces: '/api/backend-traces/clear',
// Backend Logs
backendLogs: '/api/backend-logs',
backendLogsModel: (modelId) => `/api/backend-logs/${encodeURIComponent(modelId)}`,
clearBackendLogs: (modelId) => `/api/backend-logs/${encodeURIComponent(modelId)}/clear`,
// P2P
p2pWorkers: '/api/p2p/workers',
p2pFederation: '/api/p2p/federation',

View File

@@ -12,6 +12,12 @@ export function percentColor(pct) {
return 'var(--color-success)'
}
export function formatTimestamp(ts) {
if (!ts) return '-'
const d = new Date(ts)
return d.toLocaleTimeString() + '.' + String(d.getMilliseconds()).padStart(3, '0')
}
export function vendorColor(vendor) {
if (!vendor) return 'var(--color-accent)'
const v = vendor.toLowerCase()

View File

@@ -2,16 +2,29 @@ package routes
import (
"cmp"
"encoding/json"
"fmt"
"net/http"
"slices"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/services"
"github.com/mudler/LocalAI/core/trace"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/xlog"
)
var backendLogsUpgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func RegisterUIRoutes(app *echo.Echo,
cl *config.ModelConfigLoader,
ml *model.ModelLoader,
@@ -74,4 +87,119 @@ func RegisterUIRoutes(app *echo.Echo,
return c.NoContent(204)
})
// Backend logs REST endpoints
app.GET("/api/backend-logs", func(c echo.Context) error {
return c.JSON(200, ml.BackendLogs().ListModels())
})
app.GET("/api/backend-logs/:modelId", func(c echo.Context) error {
modelID := c.Param("modelId")
return c.JSON(200, ml.BackendLogs().GetLines(modelID))
})
app.POST("/api/backend-logs/:modelId/clear", func(c echo.Context) error {
ml.BackendLogs().Clear(c.Param("modelId"))
return c.NoContent(204)
})
// Backend logs WebSocket endpoint for real-time streaming
app.GET("/ws/backend-logs/:modelId", func(c echo.Context) error {
modelID := c.Param("modelId")
ws, err := backendLogsUpgrader.Upgrade(c.Response(), c.Request(), nil)
if err != nil {
return err
}
defer ws.Close()
ws.SetReadLimit(4096)
// Set up ping/pong for keepalive
ws.SetReadDeadline(time.Now().Add(90 * time.Second))
ws.SetPongHandler(func(string) error {
ws.SetReadDeadline(time.Now().Add(90 * time.Second))
return nil
})
conn := &backendLogsConn{Conn: ws}
// Send existing lines as initial batch
existingLines := ml.BackendLogs().GetLines(modelID)
initialMsg := map[string]any{
"type": "initial",
"lines": existingLines,
}
if err := conn.writeJSON(initialMsg); err != nil {
xlog.Debug("WebSocket backend-logs initial write failed", "error", err)
return nil
}
// Subscribe to new lines
lineCh, unsubscribe := ml.BackendLogs().Subscribe(modelID)
defer unsubscribe()
// Handle close from client side
closeCh := make(chan struct{})
go func() {
for {
_, _, err := ws.ReadMessage()
if err != nil {
close(closeCh)
return
}
}
}()
// Ping ticker for keepalive
pingTicker := time.NewTicker(30 * time.Second)
defer pingTicker.Stop()
// Forward new lines to WebSocket
for {
select {
case line, ok := <-lineCh:
if !ok {
return nil
}
lineMsg := map[string]any{
"type": "line",
"line": line,
}
if err := conn.writeJSON(lineMsg); err != nil {
xlog.Debug("WebSocket backend-logs write error", "error", err)
return nil
}
case <-pingTicker.C:
if err := conn.writePing(); err != nil {
return nil
}
case <-closeCh:
return nil
}
}
})
}
// backendLogsConn wraps a websocket connection with a mutex for safe concurrent writes
type backendLogsConn struct {
*websocket.Conn
mu sync.Mutex
}
func (c *backendLogsConn) writeJSON(v any) error {
c.mu.Lock()
defer c.mu.Unlock()
c.Conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
data, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("marshal error: %w", err)
}
return c.Conn.WriteMessage(websocket.TextMessage, data)
}
func (c *backendLogsConn) writePing() error {
c.mu.Lock()
defer c.mu.Unlock()
c.Conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
return c.Conn.WriteMessage(websocket.PingMessage, nil)
}

View File

@@ -23,6 +23,8 @@ const (
BackendTraceSoundGeneration BackendTraceType = "sound_generation"
BackendTraceRerank BackendTraceType = "rerank"
BackendTraceTokenize BackendTraceType = "tokenize"
BackendTraceDetection BackendTraceType = "detection"
BackendTraceModelLoad BackendTraceType = "model_load"
)
type BackendTrace struct {

View File

@@ -0,0 +1,168 @@
package model
import (
"sort"
"sync"
"time"
"github.com/emirpasic/gods/v2/queues/circularbuffer"
)
// BackendLogLine represents a single line of output from a backend process.
type BackendLogLine struct {
Timestamp time.Time `json:"timestamp"`
Stream string `json:"stream"` // "stdout" or "stderr"
Text string `json:"text"`
}
// backendLogBuffer wraps a circular buffer for a single model's logs
// and tracks subscribers for real-time streaming.
type backendLogBuffer struct {
mu sync.Mutex
queue *circularbuffer.Queue[BackendLogLine]
subscribers map[int]chan BackendLogLine
nextSubID int
}
// BackendLogStore stores per-model backend process output in circular buffers
// and supports real-time subscriptions for WebSocket streaming.
type BackendLogStore struct {
mu sync.RWMutex // protects the buffers map only
buffers map[string]*backendLogBuffer
maxLines int
}
// NewBackendLogStore creates a new BackendLogStore with a maximum number of
// lines retained per model.
func NewBackendLogStore(maxLinesPerModel int) *BackendLogStore {
if maxLinesPerModel <= 0 {
maxLinesPerModel = 1000
}
return &BackendLogStore{
buffers: make(map[string]*backendLogBuffer),
maxLines: maxLinesPerModel,
}
}
// getOrCreateBuffer returns the buffer for modelID, creating it if needed.
func (s *BackendLogStore) getOrCreateBuffer(modelID string) *backendLogBuffer {
s.mu.RLock()
buf, ok := s.buffers[modelID]
s.mu.RUnlock()
if ok {
return buf
}
s.mu.Lock()
buf, ok = s.buffers[modelID]
if !ok {
buf = &backendLogBuffer{
queue: circularbuffer.New[BackendLogLine](s.maxLines),
subscribers: make(map[int]chan BackendLogLine),
}
s.buffers[modelID] = buf
}
s.mu.Unlock()
return buf
}
// AppendLine adds a log line for the given model. The buffer is lazily created.
// All active subscribers for this model are notified (non-blocking).
func (s *BackendLogStore) AppendLine(modelID, stream, text string) {
line := BackendLogLine{
Timestamp: time.Now(),
Stream: stream,
Text: text,
}
buf := s.getOrCreateBuffer(modelID)
buf.mu.Lock()
buf.queue.Enqueue(line)
for _, ch := range buf.subscribers {
select {
case ch <- line:
default:
}
}
buf.mu.Unlock()
}
// GetLines returns a copy of all log lines for a model, or an empty slice.
func (s *BackendLogStore) GetLines(modelID string) []BackendLogLine {
s.mu.RLock()
buf, ok := s.buffers[modelID]
s.mu.RUnlock()
if !ok {
return []BackendLogLine{}
}
buf.mu.Lock()
lines := buf.queue.Values()
buf.mu.Unlock()
return lines
}
// ListModels returns a sorted list of model IDs that have log buffers.
func (s *BackendLogStore) ListModels() []string {
s.mu.RLock()
models := make([]string, 0, len(s.buffers))
for id := range s.buffers {
models = append(models, id)
}
s.mu.RUnlock()
sort.Strings(models)
return models
}
// Clear removes all log lines for a model but keeps the buffer entry.
func (s *BackendLogStore) Clear(modelID string) {
s.mu.RLock()
buf, ok := s.buffers[modelID]
s.mu.RUnlock()
if !ok {
return
}
buf.mu.Lock()
buf.queue.Clear()
buf.mu.Unlock()
}
// Remove deletes the buffer entry for a model entirely.
func (s *BackendLogStore) Remove(modelID string) {
s.mu.Lock()
if buf, ok := s.buffers[modelID]; ok {
buf.mu.Lock()
for _, ch := range buf.subscribers {
close(ch)
}
buf.mu.Unlock()
delete(s.buffers, modelID)
}
s.mu.Unlock()
}
// Subscribe returns a channel that receives new log lines for the given model
// in real-time, plus an unsubscribe function. The channel has a buffer of 100
// lines to absorb short bursts without blocking the writer.
func (s *BackendLogStore) Subscribe(modelID string) (chan BackendLogLine, func()) {
ch := make(chan BackendLogLine, 100)
buf := s.getOrCreateBuffer(modelID)
buf.mu.Lock()
id := buf.nextSubID
buf.nextSubID++
buf.subscribers[id] = ch
buf.mu.Unlock()
unsubscribe := func() {
buf.mu.Lock()
if _, exists := buf.subscribers[id]; exists {
delete(buf.subscribers, id)
close(ch)
}
buf.mu.Unlock()
}
return ch, unsubscribe
}

View File

@@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/mudler/LocalAI/pkg/system"
@@ -33,6 +34,8 @@ type ModelLoader struct {
lruEvictionMaxRetries int // Maximum number of retries when waiting for busy models
lruEvictionRetryInterval time.Duration // Interval between retries when waiting for busy models
onUnloadHooks []ModelUnloadHook
backendLogs *BackendLogStore
backendLoggingEnabled atomic.Bool
}
// NewModelLoader creates a new ModelLoader instance.
@@ -45,6 +48,7 @@ func NewModelLoader(system *system.SystemState) *ModelLoader {
externalBackends: make(map[string]string),
lruEvictionMaxRetries: 30, // Default: 30 retries
lruEvictionRetryInterval: 1 * time.Second, // Default: 1 second
backendLogs: NewBackendLogStore(1000),
}
return nml
@@ -72,6 +76,18 @@ func (ml *ModelLoader) GetWatchDog() *WatchDog {
return ml.wd
}
func (ml *ModelLoader) BackendLogs() *BackendLogStore {
return ml.backendLogs
}
func (ml *ModelLoader) SetBackendLoggingEnabled(enabled bool) {
ml.backendLoggingEnabled.Store(enabled)
}
func (ml *ModelLoader) BackendLoggingEnabled() bool {
return ml.backendLoggingEnabled.Load()
}
// SetLRUEvictionRetrySettings updates the LRU eviction retry settings
func (ml *ModelLoader) SetLRUEvictionRetrySettings(maxRetries int, retryInterval time.Duration) {
ml.mu.Lock()

View File

@@ -159,19 +159,27 @@ func (ml *ModelLoader) startProcess(grpcProcess, id string, serverAddress string
go func() {
t, err := tail.TailFile(grpcControlProcess.StderrPath(), tail.Config{Follow: true})
if err != nil {
xlog.Debug("Could not tail stderr")
xlog.Error("Could not tail stderr", "process", grpcProcess)
return
}
for line := range t.Lines {
xlog.Debug("GRPC stderr", "id", strings.Join([]string{id, serverAddress}, "-"), "line", line.Text)
if ml.backendLogs != nil && ml.backendLoggingEnabled.Load() {
ml.backendLogs.AppendLine(id, "stderr", line.Text)
}
}
}()
go func() {
t, err := tail.TailFile(grpcControlProcess.StdoutPath(), tail.Config{Follow: true})
if err != nil {
xlog.Debug("Could not tail stdout")
xlog.Error("Could not tail stdout", "process", grpcProcess)
return
}
for line := range t.Lines {
xlog.Debug("GRPC stdout", "id", strings.Join([]string{id, serverAddress}, "-"), "line", line.Text)
if ml.backendLogs != nil && ml.backendLoggingEnabled.Load() {
ml.backendLogs.AppendLine(id, "stdout", line.Text)
}
}
}()

View File

@@ -98,6 +98,7 @@ func main() {
config.WithDynamicConfigDir(dataDir),
config.WithGeneratedContentDir(generatedDir),
config.EnableTracing,
config.EnableBackendLogging,
)
if err != nil {
fmt.Fprintf(os.Stderr, "error creating application: %v\n", err)