diff --git a/core/http/react-ui/src/contexts/OperationsContext.jsx b/core/http/react-ui/src/contexts/OperationsContext.jsx new file mode 100644 index 000000000..716fd8ada --- /dev/null +++ b/core/http/react-ui/src/contexts/OperationsContext.jsx @@ -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 {children} +} + +export function useOperations() { + const ctx = useContext(OperationsContext) + if (!ctx) throw new Error('useOperations must be used within OperationsProvider') + return ctx +} diff --git a/core/http/react-ui/src/hooks/useOperations.js b/core/http/react-ui/src/hooks/useOperations.js index 56c2ad846..a4d65f4ad 100644 --- a/core/http/react-ui/src/hooks/useOperations.js +++ b/core/http/react-ui/src/hooks/useOperations.js @@ -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' diff --git a/core/http/react-ui/src/main.jsx b/core/http/react-ui/src/main.jsx index ec62ed766..2cf7acef6 100644 --- a/core/http/react-ui/src/main.jsx +++ b/core/http/react-ui/src/main.jsx @@ -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( - + + +