mirror of
https://github.com/mudler/LocalAI.git
synced 2026-03-31 13:15:51 -04:00
feat(ui, openai): Structured errors and link to traces in error toast (#9068)
First when sending errors over SSE we now clearly identify them as such instead of just sending the error string as a chat completion message. We use this in the UI to identify errors and link to them to the traces. Signed-off-by: Richard Palethorpe <io@richiejp.com>
This commit is contained in:
committed by
GitHub
parent
e0ab1a8b43
commit
8cd3f9fc47
@@ -779,25 +779,17 @@ func ChatEndpoint(cl *config.ModelConfigLoader, ml *model.ModelLoader, evaluator
|
||||
}
|
||||
xlog.Error("Stream ended with error", "error", err)
|
||||
|
||||
stopReason := FinishReasonStop
|
||||
resp := &schema.OpenAIResponse{
|
||||
ID: id,
|
||||
Created: created,
|
||||
Model: input.Model, // we have to return what the user sent here, due to OpenAI spec.
|
||||
Choices: []schema.Choice{
|
||||
{
|
||||
FinishReason: &stopReason,
|
||||
Index: 0,
|
||||
Delta: &schema.Message{Content: "Internal error: " + err.Error()},
|
||||
}},
|
||||
Object: "chat.completion.chunk",
|
||||
Usage: *usage,
|
||||
errorResp := schema.ErrorResponse{
|
||||
Error: &schema.APIError{
|
||||
Message: err.Error(),
|
||||
Type: "server_error",
|
||||
Code: "server_error",
|
||||
},
|
||||
}
|
||||
respData, marshalErr := json.Marshal(resp)
|
||||
respData, marshalErr := json.Marshal(errorResp)
|
||||
if marshalErr != nil {
|
||||
xlog.Error("Failed to marshal error response", "error", marshalErr)
|
||||
// Send a simple error message as fallback
|
||||
fmt.Fprintf(c.Response().Writer, "data: {\"error\":\"Internal error\"}\n\n")
|
||||
fmt.Fprintf(c.Response().Writer, "data: {\"error\":{\"message\":\"Internal error\",\"type\":\"server_error\"}}\n\n")
|
||||
} else {
|
||||
fmt.Fprintf(c.Response().Writer, "data: %s\n\n", respData)
|
||||
}
|
||||
|
||||
106
core/http/react-ui/e2e/chat-errors.spec.js
Normal file
106
core/http/react-ui/e2e/chat-errors.spec.js
Normal file
@@ -0,0 +1,106 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
async function setupChatPage(page) {
|
||||
// Mock capabilities endpoint so ModelSelector auto-selects a model
|
||||
await page.route('**/api/models/capabilities', (route) => {
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
data: [{ id: 'test-model', capabilities: ['FLAG_CHAT'] }],
|
||||
}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test.describe('Chat - Error Handling', () => {
|
||||
test('shows backend error message on HTTP error', async ({ page }) => {
|
||||
await setupChatPage(page)
|
||||
|
||||
await page.route('**/v1/chat/completions', (route) => {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: { message: 'Model failed to load', type: 'server_error', code: 500 },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto('/app/chat')
|
||||
// Wait for the model to be auto-selected (ModelSelector shows model name in button)
|
||||
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await page.locator('.chat-input').fill('Hello')
|
||||
await page.locator('.chat-send-btn').click()
|
||||
|
||||
await expect(page.getByRole('paragraph').filter({ hasText: 'Model failed to load' })).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('shows error with trace link on HTTP error', async ({ page }) => {
|
||||
await setupChatPage(page)
|
||||
|
||||
await page.route('**/v1/chat/completions', (route) => {
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
error: { message: 'Backend crashed unexpectedly', type: 'server_error', code: 500 },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto('/app/chat')
|
||||
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await page.locator('.chat-input').fill('Hello')
|
||||
await page.locator('.chat-send-btn').click()
|
||||
|
||||
await expect(page.getByRole('paragraph').filter({ hasText: 'Backend crashed unexpectedly' })).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.locator('.chat-error-trace-link')).toBeVisible()
|
||||
})
|
||||
|
||||
test('shows error from SSE error event during streaming', async ({ page }) => {
|
||||
await setupChatPage(page)
|
||||
|
||||
await page.route('**/v1/chat/completions', (route) => {
|
||||
const body = [
|
||||
'data: {"choices":[{"delta":{"content":"Hello"},"index":0}]}\n\n',
|
||||
'data: {"error":{"message":"Backend crashed mid-stream","type":"server_error"}}\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
].join('')
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/event-stream' },
|
||||
body,
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto('/app/chat')
|
||||
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await page.locator('.chat-input').fill('Hello')
|
||||
await page.locator('.chat-send-btn').click()
|
||||
|
||||
await expect(page.getByRole('paragraph').filter({ hasText: 'Backend crashed mid-stream' })).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
|
||||
test('shows generic HTTP error when no error body', async ({ page }) => {
|
||||
await setupChatPage(page)
|
||||
|
||||
await page.route('**/v1/chat/completions', (route) => {
|
||||
route.fulfill({
|
||||
status: 502,
|
||||
contentType: 'text/plain',
|
||||
body: 'Bad Gateway',
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto('/app/chat')
|
||||
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await page.locator('.chat-input').fill('Hello')
|
||||
await page.locator('.chat-send-btn').click()
|
||||
|
||||
await expect(page.getByRole('paragraph').filter({ hasText: 'HTTP 502' })).toBeVisible({ timeout: 10_000 })
|
||||
})
|
||||
})
|
||||
@@ -27,10 +27,15 @@ test.describe('Settings - Backend Logging', () => {
|
||||
})
|
||||
|
||||
test('save shows toast', async ({ page }) => {
|
||||
// Toggle a setting to enable the Save button (it's disabled when no changes)
|
||||
const section = page.locator('div', { has: page.locator('text=Enable Backend Logging') })
|
||||
const checkbox = section.locator('input[type="checkbox"]').last()
|
||||
await checkbox.locator('..').click()
|
||||
|
||||
// Click save button
|
||||
await page.locator('button', { hasText: 'Save' }).click()
|
||||
await page.locator('button', { hasText: /Save Changes/ }).click()
|
||||
|
||||
// Verify toast appears
|
||||
await expect(page.locator('text=Settings saved')).toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.locator('text=Settings saved successfully')).toBeVisible({ timeout: 5_000 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -31,8 +31,9 @@ test.describe('Traces Settings', () => {
|
||||
await expect(page.locator('text=Enable Tracing')).toBeVisible()
|
||||
|
||||
// The Toggle component is a <label> wrapping a hidden checkbox.
|
||||
// Target the checkbox within the settings panel.
|
||||
const checkbox = page.locator('input[type="checkbox"]')
|
||||
// Use .first() on the checkbox to target the Enable Tracing toggle
|
||||
// (it appears before the Enable Backend Logging toggle in the DOM).
|
||||
const checkbox = page.locator('input[type="checkbox"]').first()
|
||||
|
||||
// Initially enabled (server starts with tracing on)
|
||||
await expect(checkbox).toBeChecked()
|
||||
@@ -84,8 +85,8 @@ test.describe('Traces Settings', () => {
|
||||
await page.locator('button', { hasText: 'Tracing is' }).click()
|
||||
await expect(page.locator('text=Enable Tracing')).toBeVisible()
|
||||
|
||||
// Toggle tracing off
|
||||
await page.locator('input[type="checkbox"]').locator('..').click()
|
||||
// Toggle tracing off (first checkbox is the Enable Tracing toggle)
|
||||
await page.locator('input[type="checkbox"]').first().locator('..').click()
|
||||
|
||||
// Save
|
||||
await page.locator('button', { hasText: 'Save' }).click()
|
||||
|
||||
@@ -571,6 +571,30 @@
|
||||
padding: 2px;
|
||||
}
|
||||
.toast-close:hover { opacity: 1; }
|
||||
.toast-link {
|
||||
font-size: 0.75rem;
|
||||
color: inherit;
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
white-space: nowrap;
|
||||
margin-left: var(--spacing-xs);
|
||||
}
|
||||
.toast-link:hover { opacity: 1; }
|
||||
|
||||
/* Chat error trace link */
|
||||
.chat-error-trace-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
.chat-error-trace-link:hover {
|
||||
color: var(--color-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
.spinner {
|
||||
|
||||
11
core/http/react-ui/src/components/ErrorWithTraceLink.jsx
Normal file
11
core/http/react-ui/src/components/ErrorWithTraceLink.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function ErrorWithTraceLink({ message, style }) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', color: 'var(--color-error)', ...style }}>
|
||||
<i className="fas fa-circle-exclamation" style={{ fontSize: '3rem', marginBottom: 'var(--spacing-md)', opacity: 0.6 }} />
|
||||
<p>Error: {message}</p>
|
||||
<a href="/app/traces?tab=backend" className="chat-error-trace-link" style={{ justifyContent: 'center' }}>
|
||||
<i className="fas fa-wave-square" /> View traces for details
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,9 +5,9 @@ let toastId = 0
|
||||
export function useToast() {
|
||||
const [toasts, setToasts] = useState([])
|
||||
|
||||
const addToast = useCallback((message, type = 'info', duration = 5000) => {
|
||||
const addToast = useCallback((message, type = 'info', duration = 5000, options = {}) => {
|
||||
const id = ++toastId
|
||||
setToasts(prev => [...prev, { id, message, type }])
|
||||
setToasts(prev => [...prev, { id, message, type, link: options.link }])
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
setToasts(prev => prev.map(t => t.id === id ? { ...t, exiting: true } : t))
|
||||
@@ -69,6 +69,9 @@ function ToastItem({ toast, onRemove }) {
|
||||
<div ref={ref} className={`toast ${colorMap[toast.type] || 'toast-info'} ${toast.exiting ? 'toast-exit' : ''}`}>
|
||||
<i className={`fas ${iconMap[toast.type] || 'fa-circle-info'}`} />
|
||||
<span>{toast.message}</span>
|
||||
{toast.link && (
|
||||
<a href={toast.link.href} className="toast-link">{toast.link.text}</a>
|
||||
)}
|
||||
<button onClick={() => onRemove(toast.id)} className="toast-close" aria-label="Dismiss notification">
|
||||
<i className="fas fa-xmark" />
|
||||
</button>
|
||||
|
||||
25
core/http/react-ui/src/hooks/useChat.js
vendored
25
core/http/react-ui/src/hooks/useChat.js
vendored
@@ -6,6 +6,15 @@ const thinkingTagRegex = /<thinking>([\s\S]*?)<\/thinking>|<think>([\s\S]*?)<\/t
|
||||
const openThinkTagRegex = /<thinking>|<think>/
|
||||
const closeThinkTagRegex = /<\/thinking>|<\/think>/
|
||||
|
||||
async function extractHttpError(response) {
|
||||
let errorMsg = `HTTP ${response.status}`
|
||||
try {
|
||||
const errorData = await response.json()
|
||||
if (errorData.error?.message) errorMsg = errorData.error.message
|
||||
} catch (_) {}
|
||||
return errorMsg
|
||||
}
|
||||
|
||||
function extractThinking(text) {
|
||||
let regularContent = ''
|
||||
let thinkingContent = ''
|
||||
@@ -316,7 +325,7 @@ export function useChat(initialModel = '') {
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
throw new Error(await extractHttpError(response))
|
||||
}
|
||||
|
||||
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
|
||||
@@ -404,7 +413,7 @@ export function useChat(initialModel = '') {
|
||||
break
|
||||
|
||||
case 'error':
|
||||
newMessages.push({ role: 'assistant', content: `Error: ${eventData.message}` })
|
||||
newMessages.push({ role: 'assistant', content: `Error: ${eventData.message || eventData.error?.message || 'Unknown error'}` })
|
||||
break
|
||||
}
|
||||
} catch (_e) {
|
||||
@@ -461,7 +470,7 @@ export function useChat(initialModel = '') {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
throw new Error(await extractHttpError(response))
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
@@ -485,6 +494,16 @@ export function useChat(initialModel = '') {
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
|
||||
// Handle structured error events
|
||||
if (parsed.error) {
|
||||
const errMsg = typeof parsed.error === 'string'
|
||||
? parsed.error
|
||||
: parsed.error.message || 'Unknown error'
|
||||
rawContent += `\n\nError: ${errMsg}`
|
||||
setStreamingContent(rawContent)
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle MCP tool result events
|
||||
if (parsed?.type === 'mcp_tool_result') {
|
||||
currentToolCalls.push({
|
||||
|
||||
@@ -1142,6 +1142,11 @@ export default function Chat() {
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
{msg.role === 'assistant' && typeof msg.content === 'string' && msg.content.includes('Error:') && (
|
||||
<a href="/app/traces?tab=backend" className="chat-error-trace-link">
|
||||
<i className="fas fa-wave-square" /> View traces for details
|
||||
</a>
|
||||
)}
|
||||
<div className="chat-message-actions">
|
||||
<button onClick={() => copyMessage(msg.content)} title="Copy">
|
||||
<i className="fas fa-copy" />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef } from 'react'
|
||||
import { useParams, useOutletContext } from 'react-router-dom'
|
||||
import ModelSelector from '../components/ModelSelector'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
import ErrorWithTraceLink from '../components/ErrorWithTraceLink'
|
||||
import { imageApi, fileToBase64 } from '../utils/api'
|
||||
|
||||
const SIZES = ['256x256', '512x512', '768x768', '1024x1024']
|
||||
@@ -17,6 +18,7 @@ export default function ImageGen() {
|
||||
const [steps, setSteps] = useState('')
|
||||
const [seed, setSeed] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [images, setImages] = useState([])
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [showImageInputs, setShowImageInputs] = useState(false)
|
||||
@@ -32,6 +34,7 @@ export default function ImageGen() {
|
||||
|
||||
setLoading(true)
|
||||
setImages([])
|
||||
setError(null)
|
||||
|
||||
let combinedPrompt = prompt.trim()
|
||||
if (negativePrompt.trim()) combinedPrompt += '|' + negativePrompt.trim()
|
||||
@@ -47,7 +50,7 @@ export default function ImageGen() {
|
||||
setImages(data?.data || [])
|
||||
if (!data?.data?.length) addToast('No images generated', 'warning')
|
||||
} catch (err) {
|
||||
addToast(`Error: ${err.message}`, 'error')
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -131,6 +134,8 @@ export default function ImageGen() {
|
||||
<div className="media-result">
|
||||
{loading ? (
|
||||
<LoadingSpinner size="lg" />
|
||||
) : error ? (
|
||||
<ErrorWithTraceLink message={error} />
|
||||
) : images.length > 0 ? (
|
||||
<div className="media-result-grid">
|
||||
{images.map((img, i) => (
|
||||
|
||||
@@ -25,6 +25,7 @@ const SECTIONS = [
|
||||
export default function Settings() {
|
||||
const { addToast } = useOutletContext()
|
||||
const [settings, setSettings] = useState(null)
|
||||
const [initialSettings, setInitialSettings] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [resources, setResources] = useState(null)
|
||||
@@ -38,6 +39,7 @@ export default function Settings() {
|
||||
try {
|
||||
const data = await settingsApi.get()
|
||||
setSettings(data)
|
||||
setInitialSettings(structuredClone(data))
|
||||
} catch (err) {
|
||||
addToast(`Failed to load settings: ${err.message}`, 'error')
|
||||
} finally {
|
||||
@@ -56,6 +58,7 @@ export default function Settings() {
|
||||
setSaving(true)
|
||||
try {
|
||||
await settingsApi.save(settings)
|
||||
setInitialSettings(structuredClone(settings))
|
||||
addToast('Settings saved successfully', 'success')
|
||||
} catch (err) {
|
||||
addToast(`Save failed: ${err.message}`, 'error')
|
||||
@@ -97,6 +100,7 @@ export default function Settings() {
|
||||
if (loading) return <div className="page" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
|
||||
if (!settings) return <div className="page"><div className="empty-state"><p className="empty-state-text">Settings not available</p></div></div>
|
||||
|
||||
const isDirty = settings && initialSettings && JSON.stringify(settings) !== JSON.stringify(initialSettings)
|
||||
const watchdogEnabled = settings.watchdog_idle_enabled || settings.watchdog_busy_enabled
|
||||
|
||||
return (
|
||||
@@ -110,8 +114,8 @@ export default function Settings() {
|
||||
<h1 className="page-title">Settings</h1>
|
||||
<p className="page-subtitle">Configure LocalAI runtime settings</p>
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> Save</>}
|
||||
<button className={`btn ${isDirty ? 'btn-primary' : 'btn-secondary'}`} onClick={handleSave} disabled={saving || !isDirty}>
|
||||
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> {isDirty ? 'Save Changes' : 'Saved'}</>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef } from 'react'
|
||||
import { useParams, useOutletContext } from 'react-router-dom'
|
||||
import ModelSelector from '../components/ModelSelector'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
import ErrorWithTraceLink from '../components/ErrorWithTraceLink'
|
||||
import { soundApi } from '../utils/api'
|
||||
|
||||
export default function Sound() {
|
||||
@@ -21,6 +22,7 @@ export default function Sound() {
|
||||
const [language, setLanguage] = useState('')
|
||||
const [timesignature, setTimesignature] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [audioUrl, setAudioUrl] = useState(null)
|
||||
const audioRef = useRef(null)
|
||||
|
||||
@@ -49,6 +51,7 @@ export default function Sound() {
|
||||
|
||||
setLoading(true)
|
||||
setAudioUrl(null)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const blob = await soundApi.generate(body)
|
||||
@@ -57,7 +60,7 @@ export default function Sound() {
|
||||
addToast('Sound generated', 'success')
|
||||
setTimeout(() => audioRef.current?.play().catch(() => {}), 100)
|
||||
} catch (err) {
|
||||
addToast(`Error: ${err.message}`, 'error')
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -132,6 +135,8 @@ export default function Sound() {
|
||||
<div className="media-result">
|
||||
{loading ? (
|
||||
<LoadingSpinner size="lg" />
|
||||
) : error ? (
|
||||
<ErrorWithTraceLink message={error} />
|
||||
) : audioUrl ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--spacing-md)', width: '100%' }}>
|
||||
<audio ref={audioRef} controls src={audioUrl} style={{ width: '100%', maxWidth: '400px' }} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useRef } from 'react'
|
||||
import { useParams, useOutletContext } from 'react-router-dom'
|
||||
import ModelSelector from '../components/ModelSelector'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
import ErrorWithTraceLink from '../components/ErrorWithTraceLink'
|
||||
import { ttsApi } from '../utils/api'
|
||||
|
||||
export default function TTS() {
|
||||
@@ -10,6 +11,7 @@ export default function TTS() {
|
||||
const [model, setModel] = useState(urlModel || '')
|
||||
const [text, setText] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [audioUrl, setAudioUrl] = useState(null)
|
||||
const audioRef = useRef(null)
|
||||
|
||||
@@ -20,6 +22,7 @@ export default function TTS() {
|
||||
|
||||
setLoading(true)
|
||||
setAudioUrl(null)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const blob = await ttsApi.generate({ model, input: text.trim() })
|
||||
@@ -29,7 +32,7 @@ export default function TTS() {
|
||||
// Auto-play
|
||||
setTimeout(() => audioRef.current?.play(), 100)
|
||||
} catch (err) {
|
||||
addToast(`Error: ${err.message}`, 'error')
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -67,6 +70,8 @@ export default function TTS() {
|
||||
<div className="media-result">
|
||||
{loading ? (
|
||||
<LoadingSpinner size="lg" />
|
||||
) : error ? (
|
||||
<ErrorWithTraceLink message={error} />
|
||||
) : audioUrl ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--spacing-md)', width: '100%' }}>
|
||||
<audio ref={audioRef} controls src={audioUrl} style={{ width: '100%' }} />
|
||||
|
||||
@@ -74,7 +74,7 @@ export default function Talk() {
|
||||
if (!voiceEdited) setVoice(models[0].voice || '')
|
||||
}
|
||||
})
|
||||
.catch(err => addToast(`Failed to load pipeline models: ${err.message}`, 'error'))
|
||||
.catch(err => addToast(`Failed to load pipeline models: ${err.message}`, 'error', 5000, { link: { href: '/app/traces?tab=backend', text: 'View traces' } }))
|
||||
.finally(() => setModelsLoading(false))
|
||||
}, [])
|
||||
|
||||
@@ -461,6 +461,11 @@ export default function Talk() {
|
||||
}}>
|
||||
<i className={statusStyle.icon} style={{ color: statusStyle.color }} />
|
||||
<span style={{ fontWeight: 500, color: statusStyle.color }}>{statusText}</span>
|
||||
{status === 'error' && (
|
||||
<a href="/app/traces?tab=backend" className="chat-error-trace-link" style={{ marginLeft: 'auto' }}>
|
||||
<i className="fas fa-wave-square" /> View traces
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info note */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useOutletContext } from 'react-router-dom'
|
||||
import { useOutletContext, useSearchParams } from 'react-router-dom'
|
||||
import { tracesApi, settingsApi } from '../utils/api'
|
||||
import { formatTimestamp } from '../utils/format'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
@@ -283,13 +283,15 @@ function ApiTraceDetail({ trace }) {
|
||||
|
||||
export default function Traces() {
|
||||
const { addToast } = useOutletContext()
|
||||
const [activeTab, setActiveTab] = useState('api')
|
||||
const [searchParams] = useSearchParams()
|
||||
const [activeTab, setActiveTab] = useState(() => searchParams.get('tab') === 'backend' ? 'backend' : 'api')
|
||||
const [traces, setTraces] = useState([])
|
||||
const [apiCount, setApiCount] = useState(0)
|
||||
const [backendCount, setBackendCount] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedRow, setExpandedRow] = useState(null)
|
||||
const [tracingEnabled, setTracingEnabled] = useState(null)
|
||||
const [backendLoggingEnabled, setBackendLoggingEnabled] = useState(null)
|
||||
const [settings, setSettings] = useState(null)
|
||||
const [settingsExpanded, setSettingsExpanded] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -299,6 +301,7 @@ export default function Traces() {
|
||||
settingsApi.get()
|
||||
.then(data => {
|
||||
setTracingEnabled(!!data.enable_tracing)
|
||||
setBackendLoggingEnabled(!!data.enable_backend_logging)
|
||||
setSettings(data)
|
||||
if (!data.enable_tracing) setSettingsExpanded(true)
|
||||
})
|
||||
@@ -310,6 +313,7 @@ export default function Traces() {
|
||||
try {
|
||||
await settingsApi.save(settings)
|
||||
setTracingEnabled(!!settings.enable_tracing)
|
||||
setBackendLoggingEnabled(!!settings.enable_backend_logging)
|
||||
addToast('Tracing settings saved', 'success')
|
||||
if (settings.enable_tracing) setSettingsExpanded(false)
|
||||
} catch (err) {
|
||||
@@ -397,9 +401,11 @@ export default function Traces() {
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleExport} disabled={traces.length === 0}><i className="fas fa-download" /> Export</button>
|
||||
</div>
|
||||
|
||||
{settings && (
|
||||
{settings && (() => {
|
||||
const allEnabled = tracingEnabled && backendLoggingEnabled
|
||||
return (
|
||||
<div style={{
|
||||
border: `1px solid ${tracingEnabled ? 'var(--color-success-border)' : 'var(--color-warning-border)'}`,
|
||||
border: `1px solid ${allEnabled ? 'var(--color-success-border)' : 'var(--color-warning-border)'}`,
|
||||
borderRadius: 'var(--radius-md)',
|
||||
marginBottom: 'var(--spacing-md)',
|
||||
overflow: 'hidden',
|
||||
@@ -409,16 +415,17 @@ export default function Traces() {
|
||||
style={{
|
||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
padding: 'var(--spacing-sm) var(--spacing-md)',
|
||||
background: tracingEnabled ? 'var(--color-success-light)' : 'var(--color-warning-light)',
|
||||
background: allEnabled ? 'var(--color-success-light)' : 'var(--color-warning-light)',
|
||||
border: 'none', cursor: 'pointer',
|
||||
color: 'var(--color-text-primary)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)' }}>
|
||||
<i className={`fas ${tracingEnabled ? 'fa-circle-check' : 'fa-exclamation-triangle'}`}
|
||||
style={{ color: tracingEnabled ? 'var(--color-success)' : 'var(--color-warning)', flexShrink: 0 }} />
|
||||
<i className={`fas ${allEnabled ? 'fa-circle-check' : 'fa-exclamation-triangle'}`}
|
||||
style={{ color: allEnabled ? 'var(--color-success)' : 'var(--color-warning)', flexShrink: 0 }} />
|
||||
<span style={{ fontSize: '0.8125rem', textAlign: 'left' }}>
|
||||
Tracing is <strong>{tracingEnabled ? 'enabled' : 'disabled'}</strong>
|
||||
{' · Backend logging is '}<strong>{backendLoggingEnabled ? 'enabled' : 'disabled'}</strong>
|
||||
{!tracingEnabled && ' — new requests will not be recorded'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -444,6 +451,12 @@ export default function Traces() {
|
||||
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) => setSettings(prev => ({ ...prev, enable_backend_logging: v }))}
|
||||
/>
|
||||
</SettingRow>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 'var(--spacing-sm)' }}>
|
||||
<button className="btn btn-primary btn-sm" onClick={handleSaveSettings} disabled={saving}>
|
||||
{saving ? <><LoadingSpinner size="sm" /> Saving...</> : <><i className="fas fa-save" /> Save</>}
|
||||
@@ -452,7 +465,8 @@ export default function Traces() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
)
|
||||
})()}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { useParams, useOutletContext } from 'react-router-dom'
|
||||
import ModelSelector from '../components/ModelSelector'
|
||||
import LoadingSpinner from '../components/LoadingSpinner'
|
||||
import ErrorWithTraceLink from '../components/ErrorWithTraceLink'
|
||||
import { videoApi, fileToBase64 } from '../utils/api'
|
||||
|
||||
const SIZES = ['256x256', '512x512', '768x768', '1024x1024']
|
||||
@@ -20,6 +21,7 @@ export default function VideoGen() {
|
||||
const [seed, setSeed] = useState('')
|
||||
const [cfgScale, setCfgScale] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [videos, setVideos] = useState([])
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [showImageInputs, setShowImageInputs] = useState(false)
|
||||
@@ -33,6 +35,7 @@ export default function VideoGen() {
|
||||
|
||||
setLoading(true)
|
||||
setVideos([])
|
||||
setError(null)
|
||||
|
||||
const [w, h] = size.split('x').map(Number)
|
||||
const body = { model, prompt: prompt.trim(), width: w, height: h, fps: parseInt(fps) || 16 }
|
||||
@@ -50,7 +53,7 @@ export default function VideoGen() {
|
||||
setVideos(data?.data || [])
|
||||
if (!data?.data?.length) addToast('No videos generated', 'warning')
|
||||
} catch (err) {
|
||||
addToast(`Error: ${err.message}`, 'error')
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -129,6 +132,8 @@ export default function VideoGen() {
|
||||
<div className="media-result">
|
||||
{loading ? (
|
||||
<LoadingSpinner size="lg" />
|
||||
) : error ? (
|
||||
<ErrorWithTraceLink message={error} />
|
||||
) : videos.length > 0 ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-md)', width: '100%' }}>
|
||||
{videos.map((v, i) => (
|
||||
|
||||
@@ -52,6 +52,9 @@ func (m *MockBackend) LoadModel(ctx context.Context, in *pb.ModelOptions) (*pb.R
|
||||
|
||||
func (m *MockBackend) Predict(ctx context.Context, in *pb.PredictOptions) (*pb.Reply, error) {
|
||||
xlog.Debug("Predict called", "prompt", in.Prompt)
|
||||
if strings.Contains(in.Prompt, "MOCK_ERROR") {
|
||||
return nil, fmt.Errorf("mock backend predict error: simulated failure")
|
||||
}
|
||||
var response string
|
||||
toolName := mockToolNameFromRequest(in)
|
||||
if toolName != "" && !promptHasToolResults(in.Prompt) {
|
||||
@@ -74,6 +77,17 @@ func (m *MockBackend) Predict(ctx context.Context, in *pb.PredictOptions) (*pb.R
|
||||
|
||||
func (m *MockBackend) PredictStream(in *pb.PredictOptions, stream pb.Backend_PredictStreamServer) error {
|
||||
xlog.Debug("PredictStream called", "prompt", in.Prompt)
|
||||
if strings.Contains(in.Prompt, "MOCK_ERROR_IMMEDIATE") {
|
||||
return fmt.Errorf("mock backend stream error: simulated failure")
|
||||
}
|
||||
if strings.Contains(in.Prompt, "MOCK_ERROR_MIDSTREAM") {
|
||||
for _, r := range "Partial resp" {
|
||||
if err := stream.Send(&pb.Reply{Message: []byte(string(r))}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("mock backend stream error: simulated mid-stream failure")
|
||||
}
|
||||
var toStream string
|
||||
toolName := mockToolNameFromRequest(in)
|
||||
if toolName != "" && !promptHasToolResults(in.Prompt) {
|
||||
|
||||
@@ -55,6 +55,65 @@ var _ = Describe("Mock Backend E2E Tests", Label("MockBackend"), func() {
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Error Handling", func() {
|
||||
Context("Non-streaming errors", func() {
|
||||
It("should return error for request with error trigger", func() {
|
||||
_, err := client.Chat.Completions.New(
|
||||
context.TODO(),
|
||||
openai.ChatCompletionNewParams{
|
||||
Model: "mock-model",
|
||||
Messages: []openai.ChatCompletionMessageParamUnion{
|
||||
openai.UserMessage("MOCK_ERROR"),
|
||||
},
|
||||
},
|
||||
)
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("simulated failure"))
|
||||
})
|
||||
})
|
||||
|
||||
Context("Streaming errors", func() {
|
||||
It("should return error for streaming request with immediate error trigger", func() {
|
||||
stream := client.Chat.Completions.NewStreaming(
|
||||
context.TODO(),
|
||||
openai.ChatCompletionNewParams{
|
||||
Model: "mock-model",
|
||||
Messages: []openai.ChatCompletionMessageParamUnion{
|
||||
openai.UserMessage("MOCK_ERROR_IMMEDIATE"),
|
||||
},
|
||||
},
|
||||
)
|
||||
for stream.Next() {
|
||||
// drain
|
||||
}
|
||||
Expect(stream.Err()).To(HaveOccurred())
|
||||
})
|
||||
|
||||
It("should return structured error for mid-stream failure", func() {
|
||||
body := `{"model":"mock-model","messages":[{"role":"user","content":"MOCK_ERROR_MIDSTREAM"}],"stream":true}`
|
||||
req, err := http.NewRequest("POST", apiURL+"/chat/completions", strings.NewReader(body))
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
defer resp.Body.Close()
|
||||
Expect(resp.StatusCode).To(Equal(200))
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
bodyStr := string(data)
|
||||
|
||||
// Should contain a structured error event
|
||||
Expect(bodyStr).To(ContainSubstring(`"error"`))
|
||||
Expect(bodyStr).To(ContainSubstring(`"message"`))
|
||||
Expect(bodyStr).To(ContainSubstring("simulated mid-stream failure"))
|
||||
// Should also contain [DONE]
|
||||
Expect(bodyStr).To(ContainSubstring("[DONE]"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Describe("Embeddings API", func() {
|
||||
It("should return mocked embeddings", func() {
|
||||
resp, err := client.Embeddings.New(
|
||||
|
||||
Reference in New Issue
Block a user