fix(react-ui): share single /api/operations poller across consumers (#10029)

useOperations() spun up its own setInterval per hook instance, so on
pages like /app/models the OperationsBar in App.jsx plus the page's
own useOperations() call each polled /api/operations at 1 Hz - 2 RPS
sustained for the whole session, repeated on Backends and Chat.

Lift the poller into an OperationsProvider mounted under AuthProvider
so all consumers (OperationsBar, Models, Backends, Chat) share one
timer. The hook file re-exports from the context to keep call sites
unchanged.


Assisted-by: Claude:claude-opus-4-7 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
LocalAI [bot]
2026-05-27 16:39:09 +02:00
committed by GitHub
parent c9a1a7e6a0
commit 893e69cbf8
3 changed files with 108 additions and 99 deletions

View File

@@ -0,0 +1,100 @@
import { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
import { operationsApi } from '../utils/api'
import { useAuth } from '../context/AuthContext'
// Serialize ops into a stable comparison key. Each op is a flat map of
// primitives, so JSON.stringify is good enough and stable as long as the
// server emits keys in the same order (Go's map iteration into JSON happens
// to be stable here because we build an explicit map[string]any).
function serializeOps(ops) {
return JSON.stringify(ops)
}
const OperationsContext = createContext(null)
// Single shared poller for /api/operations. Before this provider existed,
// each useOperations() call ran its own setInterval; with OperationsBar
// always mounted plus the per-page consumers (Models, Backends, Chat), the
// browser was firing 2-3 polls per second against the API for the lifetime
// of the session.
export function OperationsProvider({ children, pollInterval = 1000 }) {
const [operations, setOperations] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const { isAdmin } = useAuth()
const intervalRef = useRef(null)
const lastSerializedRef = useRef('[]')
const fetchOperations = useCallback(async () => {
if (!isAdmin) {
setLoading((prev) => (prev ? false : prev))
return
}
try {
const data = await operationsApi.list()
const ops = data?.operations || (Array.isArray(data) ? data : [])
const serialized = serializeOps(ops)
if (serialized !== lastSerializedRef.current) {
lastSerializedRef.current = serialized
setOperations(ops)
}
setError((prev) => (prev === null ? prev : null))
} catch (err) {
setError((prev) => (prev === err.message ? prev : err.message))
} finally {
setLoading((prev) => (prev ? false : prev))
}
}, [isAdmin])
useEffect(() => {
if (!isAdmin) return
fetchOperations()
intervalRef.current = setInterval(fetchOperations, pollInterval)
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current)
intervalRef.current = null
}
}
}, [fetchOperations, pollInterval, isAdmin])
const cancelOperation = useCallback(async (jobID) => {
try {
await operationsApi.cancel(jobID)
await fetchOperations()
} catch (err) {
setError(err.message)
}
}, [fetchOperations])
const dismissFailedOp = useCallback(async (opId) => {
try {
const op = operations.find((o) => o.id === opId)
if (op?.jobID) {
await operationsApi.dismiss(op.jobID)
await fetchOperations()
}
} catch {
// Ignore dismiss errors
}
}, [operations, fetchOperations])
const value = {
operations,
loading,
error,
cancelOperation,
dismissFailedOp,
refetch: fetchOperations,
}
return <OperationsContext.Provider value={value}>{children}</OperationsContext.Provider>
}
export function useOperations() {
const ctx = useContext(OperationsContext)
if (!ctx) throw new Error('useOperations must be used within OperationsProvider')
return ctx
}

View File

@@ -1,98 +1,4 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { operationsApi } from '../utils/api'
import { useAuth } from '../context/AuthContext'
// Serialize ops into a stable comparison key. Each op is a flat map of
// primitives, so JSON.stringify is good enough and stable as long as the
// server emits keys in the same order (Go's map iteration into JSON happens
// to be stable here because we build an explicit map[string]any).
function serializeOps(ops) {
return JSON.stringify(ops)
}
export function useOperations(pollInterval = 1000) {
const [operations, setOperations] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const intervalRef = useRef(null)
const { isAdmin } = useAuth()
const previousCountRef = useRef(0)
const onAllCompleteRef = useRef(null)
// Track the last payload we wrote into state. Each poll otherwise produces
// a fresh array reference even when nothing changed, and that re-render
// ripples into the Chat page — wiping the user's text selection mid-read
// (#9904).
const lastSerializedRef = useRef('[]')
const fetchOperations = useCallback(async () => {
if (!isAdmin) {
setLoading((prev) => (prev ? false : prev))
return
}
try {
const data = await operationsApi.list()
const ops = data?.operations || (Array.isArray(data) ? data : [])
const serialized = serializeOps(ops)
if (serialized !== lastSerializedRef.current) {
lastSerializedRef.current = serialized
setOperations(ops)
}
// Separate active (non-failed) operations from failed ones
const activeOps = ops.filter(op => !op.error)
const failedOps = ops.filter(op => op.error)
// Notify when all operations complete (no active or failed remaining)
if (previousCountRef.current > 0 && activeOps.length === 0 && failedOps.length === 0) {
onAllCompleteRef.current?.()
}
previousCountRef.current = activeOps.length
setError((prev) => (prev === null ? prev : null))
} catch (err) {
setError((prev) => (prev === err.message ? prev : err.message))
} finally {
setLoading((prev) => (prev ? false : prev))
}
}, [isAdmin])
const cancelOperation = useCallback(async (jobID) => {
try {
await operationsApi.cancel(jobID)
await fetchOperations()
} catch (err) {
setError(err.message)
}
}, [fetchOperations])
// Dismiss a failed operation (acknowledge the error and remove it)
const dismissFailedOp = useCallback(async (opId) => {
try {
const op = operations.find(o => o.id === opId)
if (op?.jobID) {
await operationsApi.dismiss(op.jobID)
await fetchOperations()
}
} catch {
// Ignore dismiss errors
}
}, [operations, fetchOperations])
useEffect(() => {
if (!isAdmin) return
fetchOperations()
intervalRef.current = setInterval(fetchOperations, pollInterval)
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [fetchOperations, pollInterval, isAdmin])
// Allow callers to register a callback for when all operations finish
const onAllComplete = useCallback((cb) => {
onAllCompleteRef.current = cb
}, [])
return { operations, loading, error, cancelOperation, dismissFailedOp, refetch: fetchOperations, onAllComplete }
}
// useOperations now lives in OperationsContext so all consumers
// (OperationsBar, Models, Backends, Chat) share a single poller instead
// of each spinning up its own setInterval against /api/operations.
export { useOperations } from '../contexts/OperationsContext'

View File

@@ -4,6 +4,7 @@ import { RouterProvider } from 'react-router-dom'
import { ThemeProvider } from './contexts/ThemeContext'
import { BrandingProvider } from './contexts/BrandingContext'
import { AuthProvider } from './context/AuthContext'
import { OperationsProvider } from './contexts/OperationsContext'
import { router } from './router'
import './i18n'
import '@fortawesome/fontawesome-free/css/all.min.css'
@@ -30,7 +31,9 @@ createRoot(document.getElementById('root')).render(
<ThemeProvider>
<BrandingProvider>
<AuthProvider>
<RouterProvider router={router} />
<OperationsProvider>
<RouterProvider router={router} />
</OperationsProvider>
</AuthProvider>
</BrandingProvider>
</ThemeProvider>