From 547a0ac2d42de0b5ee3997eaf5eae0d9cd419fe0 Mon Sep 17 00:00:00 2001 From: Thomas Tupper Date: Sat, 14 Mar 2026 03:40:29 -0500 Subject: [PATCH] feat: add multi-agent support to dashboard frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - uiSlice: adds selectedAgentId to persisted Redux state - types.ts: adds TenantRecord and AgentRecord interfaces - dashboardApi.ts: parameterizes all foxmemory RTK Query endpoints with optional agentId; adds getTenants and getAgents endpoints; scopes cache tags by agentId - AgentSelector: new component — dropdown that fetches tenants/agents and lets users switch between agents (persisted via redux-persist) - App.tsx: renders AgentSelector in sidebar, passes selectedAgentId to FoxMemorySection and ModelConfigSection - FoxMemorySection, FoxMemoryAgentsView, FoxMemoryGraphView, ModelConfigSection: accept agentId prop, pass to RTK Query hooks and mutation calls - Sidebar: accepts children prop for rendering AgentSelector When no agent is selected, all queries fall back to global (legacy) behavior. Co-Authored-By: Claude Opus 4.6 --- web/src/App.tsx | 14 ++- .../AgentSelector/AgentSelector.tsx | 72 ++++++++++++ .../AgentSelector/AgentSelector.types.ts | 4 + .../FoxMemoryAgentsView.types.ts | 1 + .../FoxMemoryGraphView/FoxMemoryGraphView.tsx | 15 +-- .../FoxMemoryGraphView.types.ts | 1 + .../FoxMemorySection/FoxMemorySection.tsx | 14 ++- .../FoxMemorySection.types.ts | 1 + .../ModelConfigSection/ModelConfigSection.tsx | 18 +-- .../ModelConfigSection.types.ts | 4 +- web/src/components/Sidebar/Sidebar.tsx | 21 +++- web/src/components/Sidebar/Sidebar.types.ts | 1 + web/src/services/dashboardApi.ts | 109 ++++++++++-------- web/src/types.ts | 18 +++ web/src/uiSlice.ts | 7 +- 15 files changed, 223 insertions(+), 77 deletions(-) create mode 100644 web/src/components/AgentSelector/AgentSelector.tsx create mode 100644 web/src/components/AgentSelector/AgentSelector.types.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index faecf5c..3df1f69 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Alert, Box, Container, CssBaseline } from '@mui/material'; import { createTheme, styled, ThemeProvider } from '@mui/material/styles'; import { useDispatch, useSelector } from 'react-redux'; -import { setSection, setChartRange, toggleMode } from './uiSlice'; +import { setSection, setChartRange, toggleMode, setSelectedAgentId } from './uiSlice'; import { useGetSessionsQuery, useGetFoxmemoryOverviewQuery, @@ -20,6 +20,7 @@ import FoxMemorySection from './components/FoxMemorySection/FoxMemorySection'; import ModelConfigSection from './components/ModelConfigSection/ModelConfigSection'; import LoginView from './components/LoginView/LoginView'; import MfaView from './components/MfaView/MfaView'; +import AgentSelector from './components/AgentSelector/AgentSelector'; import SeatedFoxIcon from './components/shared/SeatedFoxIcon'; const AppShell = styled(Box, { shouldForwardProp: (p) => p !== 'mode' })<{ mode: 'light' | 'dark' }>(({ mode }) => ({ @@ -63,7 +64,7 @@ const isActive = (s: OpenclawSession) => (s.ageMs ?? Infinity) < MS_12H; const App = () => { const dispatch = useDispatch(); - const { mode, section, chartRange } = useSelector((s: RootState) => s.ui); + const { mode, section, chartRange, selectedAgentId } = useSelector((s: RootState) => s.ui); const [notice, setNotice] = useState(null); const [authState, setAuthState] = useState('loading'); const [authUser, setAuthUser] = useState(null); @@ -104,7 +105,7 @@ const App = () => { error: foxError, refetch: refetchFox, fulfilledTimeStamp: foxFulfilledAt, - } = useGetFoxmemoryOverviewQuery(undefined, { skip: section !== 'foxmemory', pollingInterval: 15000, skipPollingIfUnfocused: true, refetchOnFocus: true, refetchOnReconnect: true }); + } = useGetFoxmemoryOverviewQuery(selectedAgentId ?? undefined, { skip: section !== 'foxmemory', pollingInterval: 15000, skipPollingIfUnfocused: true, refetchOnFocus: true, refetchOnReconnect: true }); const { data: cronsData, @@ -250,7 +251,9 @@ const App = () => { onLogout={() => { window.location.href = '/auth/logout'; }} /> - dispatch(setSection(s))} health={healthMap} /> + dispatch(setSection(s))} health={healthMap}> + dispatch(setSelectedAgentId(id))} /> + {notice && setNotice(null)}>{notice.text}} @@ -267,13 +270,14 @@ const App = () => { onDelete={onDelete} /> ) : section === 'config' ? ( - + ) : ( dispatch(setChartRange(r))} tabHealth={{ performance: foxIsError ? 'error' : foxmemory ? 'ok' : undefined }} + agentId={selectedAgentId} /> )} diff --git a/web/src/components/AgentSelector/AgentSelector.tsx b/web/src/components/AgentSelector/AgentSelector.tsx new file mode 100644 index 0000000..ac3ab89 --- /dev/null +++ b/web/src/components/AgentSelector/AgentSelector.tsx @@ -0,0 +1,72 @@ +import { CircularProgress, MenuItem, Typography } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import Select from '@mui/material/Select'; +import { useGetTenantsQuery, useGetAgentsQuery } from '../../services/dashboardApi'; +import type { AgentSelectorProps } from './AgentSelector.types'; + +const CompactSelect = styled(Select)(({ theme }) => ({ + fontSize: 12, + fontWeight: 600, + borderRadius: 8, + color: theme.palette.mode === 'dark' ? '#67748e' : 'rgba(255,255,255,0.85)', + '& .MuiOutlinedInput-notchedOutline': { + borderColor: theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.12)' : 'rgba(255,255,255,0.18)', + }, + '&:hover .MuiOutlinedInput-notchedOutline': { + borderColor: theme.palette.mode === 'dark' ? 'rgba(0,0,0,0.24)' : 'rgba(255,255,255,0.35)', + }, + '& .MuiSvgIcon-root': { + color: theme.palette.mode === 'dark' ? '#67748e' : 'rgba(255,255,255,0.6)', + fontSize: 18, + }, + '& .MuiSelect-select': { + py: '6px', + px: '12px', + }, +})); + +const NONE_VALUE = '__none__'; + +const AgentSelector = ({ selectedAgentId, onAgentChange }: AgentSelectorProps) => { + const { data: tenantsData, isLoading: tenantsLoading } = useGetTenantsQuery(); + const firstTenantId = tenantsData?.data?.[0]?.id; + const { data: agentsData, isLoading: agentsLoading } = useGetAgentsQuery(firstTenantId!, { + skip: !firstTenantId, + }); + + const loading = tenantsLoading || agentsLoading; + const agents = agentsData?.data ?? []; + + if (loading) { + return ; + } + + if (agents.length === 0) { + return ( + + Default agent + + ); + } + + return ( + { + const val = e.target.value as string; + onAgentChange(val === NONE_VALUE ? null : val); + }} + > + Default (all) + {agents.map((agent) => ( + + {agent.name} + + ))} + + ); +}; + +export default AgentSelector; diff --git a/web/src/components/AgentSelector/AgentSelector.types.ts b/web/src/components/AgentSelector/AgentSelector.types.ts new file mode 100644 index 0000000..2958995 --- /dev/null +++ b/web/src/components/AgentSelector/AgentSelector.types.ts @@ -0,0 +1,4 @@ +export interface AgentSelectorProps { + selectedAgentId: string | null; + onAgentChange: (agentId: string | null) => void; +} diff --git a/web/src/components/FoxMemoryAgentsView/FoxMemoryAgentsView.types.ts b/web/src/components/FoxMemoryAgentsView/FoxMemoryAgentsView.types.ts index 2dc2640..ffa6d79 100644 --- a/web/src/components/FoxMemoryAgentsView/FoxMemoryAgentsView.types.ts +++ b/web/src/components/FoxMemoryAgentsView/FoxMemoryAgentsView.types.ts @@ -7,4 +7,5 @@ export interface FoxMemoryAgentsViewProps { onSaveExtractionPrompt: (prompt: string | null) => Promise; onSaveUpdatePrompt: (prompt: string | null) => Promise; onSaveGraphPrompt: (prompt: string | null) => Promise; + agentId?: string | null; } diff --git a/web/src/components/FoxMemoryGraphView/FoxMemoryGraphView.tsx b/web/src/components/FoxMemoryGraphView/FoxMemoryGraphView.tsx index fdcebfa..67a6a73 100644 --- a/web/src/components/FoxMemoryGraphView/FoxMemoryGraphView.tsx +++ b/web/src/components/FoxMemoryGraphView/FoxMemoryGraphView.tsx @@ -93,8 +93,9 @@ const MIN_DEGREE_OPTIONS = [ type PanelTab = 'connections' | 'details'; -const GraphExplorer = ({ minDegree }: { minDegree: number }) => { - const { data, isLoading } = useGetFoxmemoryGraphDataQuery(); +const GraphExplorer = ({ minDegree, agentId }: { minDegree: number; agentId?: string | null }) => { + const effectiveAgentId = agentId ?? undefined; + const { data, isLoading } = useGetFoxmemoryGraphDataQuery(effectiveAgentId); const [searchGraph, { data: richData, isLoading: richLoading, reset: resetRich }] = useSearchFoxmemoryGraphMutation(); const [searchMemories, { data: memoriesData, isLoading: memoriesLoading, reset: resetMemories }] = useSearchFoxmemoryMemoriesMutation(); const [selectedNode, setSelectedNode] = useState(null); @@ -161,12 +162,12 @@ const GraphExplorer = ({ minDegree }: { minDegree: number }) => { const n = node as RawNode; setSelectedNode((prev) => { if (prev?.id === n.id) { resetRich(); resetMemories(); return null; } - searchGraph({ query: n.name }); - searchMemories({ query: n.name, top_k: 8 }); + searchGraph({ query: n.name, agentId: effectiveAgentId }); + searchMemories({ query: n.name, top_k: 8, agentId: effectiveAgentId }); setPanelTab('connections'); return { id: n.id, name: n.name, degree: n.degree }; }); - }, [searchGraph, resetRich, searchMemories, resetMemories]); + }, [searchGraph, resetRich, searchMemories, resetMemories, effectiveAgentId]); const nodeColor = useCallback((node: object) => { const n = node as RawNode; @@ -373,7 +374,7 @@ const GraphExplorer = ({ minDegree }: { minDegree: number }) => { // ── Main component ──────────────────────────────────────────────────────────── -const FoxMemoryGraphView = ({ stats, diagnostics, loading }: FoxMemoryGraphViewProps) => { +const FoxMemoryGraphView = ({ stats, diagnostics, loading, agentId }: FoxMemoryGraphViewProps) => { const [subView, setSubView] = useState('performance'); const [minDegree, setMinDegree] = useState(1); @@ -461,7 +462,7 @@ const FoxMemoryGraphView = ({ stats, diagnostics, loading }: FoxMemoryGraphViewP {subView === 'explorer' ? ( - + ) : ( <> diff --git a/web/src/components/FoxMemoryGraphView/FoxMemoryGraphView.types.ts b/web/src/components/FoxMemoryGraphView/FoxMemoryGraphView.types.ts index 4716eaa..ecc41cd 100644 --- a/web/src/components/FoxMemoryGraphView/FoxMemoryGraphView.types.ts +++ b/web/src/components/FoxMemoryGraphView/FoxMemoryGraphView.types.ts @@ -4,4 +4,5 @@ export interface FoxMemoryGraphViewProps { stats: FoxmemoryGraphStats | undefined; diagnostics: FoxmemoryDiagnostics | null | undefined; loading: boolean; + agentId?: string | null; } diff --git a/web/src/components/FoxMemorySection/FoxMemorySection.tsx b/web/src/components/FoxMemorySection/FoxMemorySection.tsx index feb37cb..f7cd188 100644 --- a/web/src/components/FoxMemorySection/FoxMemorySection.tsx +++ b/web/src/components/FoxMemorySection/FoxMemorySection.tsx @@ -53,7 +53,7 @@ const TabLabel = ({ label, health }: { label: string; health?: import('../../typ ); -const FoxMemorySection = ({ foxmemory, chartRange, onChartRangeChange, tabHealth }: FoxMemorySectionProps) => { +const FoxMemorySection = ({ foxmemory, chartRange, onChartRangeChange, tabHealth, agentId }: FoxMemorySectionProps) => { const [subView, setSubView] = useState('performance'); const [expandedRows, setExpandedRows] = useState>(new Set()); const toggleRow = (i: number) => setExpandedRows((prev) => { @@ -62,10 +62,11 @@ const FoxMemorySection = ({ foxmemory, chartRange, onChartRangeChange, tabHealth return next; }); - const { data: prompts, isFetching: promptsLoading } = useGetFoxmemoryPromptsQuery(undefined, { + const effectiveAgentId = agentId ?? undefined; + const { data: prompts, isFetching: promptsLoading } = useGetFoxmemoryPromptsQuery(effectiveAgentId, { skip: subView !== 'agents', }); - const { data: graphStatsData, isFetching: graphStatsLoading } = useGetFoxmemoryGraphStatsQuery(undefined, { + const { data: graphStatsData, isFetching: graphStatsLoading } = useGetFoxmemoryGraphStatsQuery(effectiveAgentId, { skip: subView !== 'graph', pollingInterval: 15000, skipPollingIfUnfocused: true, @@ -74,9 +75,9 @@ const FoxMemorySection = ({ foxmemory, chartRange, onChartRangeChange, tabHealth const [saveUpdatePrompt] = useSetFoxmemoryUpdatePromptMutation(); const [saveGraphPrompt] = useSetFoxmemoryGraphPromptMutation(); - const onSaveExtractionPrompt = async (prompt: string | null) => { await saveExtractionPrompt({ prompt }).unwrap(); }; - const onSaveUpdatePrompt = async (prompt: string | null) => { await saveUpdatePrompt({ prompt }).unwrap(); }; - const onSaveGraphPrompt = async (prompt: string | null) => { await saveGraphPrompt({ prompt }).unwrap(); }; + const onSaveExtractionPrompt = async (prompt: string | null) => { await saveExtractionPrompt({ prompt, agentId: effectiveAgentId }).unwrap(); }; + const onSaveUpdatePrompt = async (prompt: string | null) => { await saveUpdatePrompt({ prompt, agentId: effectiveAgentId }).unwrap(); }; + const onSaveGraphPrompt = async (prompt: string | null) => { await saveGraphPrompt({ prompt, agentId: effectiveAgentId }).unwrap(); }; const chartData = useMemo(() => { if (!foxmemory) return []; @@ -111,6 +112,7 @@ const FoxMemorySection = ({ foxmemory, chartRange, onChartRangeChange, tabHealth stats={graphStatsData?.data} diagnostics={foxmemory?.diagnostics} loading={graphStatsLoading} + agentId={agentId} /> ) : subView === 'agents' ? ( void; tabHealth?: { performance?: SectionHealth; agents?: SectionHealth; graph?: SectionHealth }; + agentId?: string | null; } diff --git a/web/src/components/ModelConfigSection/ModelConfigSection.tsx b/web/src/components/ModelConfigSection/ModelConfigSection.tsx index c53e9a4..403fc76 100644 --- a/web/src/components/ModelConfigSection/ModelConfigSection.tsx +++ b/web/src/components/ModelConfigSection/ModelConfigSection.tsx @@ -8,6 +8,7 @@ import DeleteOutlineRoundedIcon from '@mui/icons-material/DeleteOutlineRounded'; import EditRoundedIcon from '@mui/icons-material/EditRounded'; import { useGetFoxmemoryModelsQuery, useGetFoxmemoryCatalogQuery, useSetFoxmemoryModelMutation, useRevertFoxmemoryModelMutation, useAddCatalogModelMutation, useUpdateCatalogModelMutation, useDeleteCatalogModelMutation } from '../../services/dashboardApi'; import type { CatalogModel, ModelRoleKey } from '../../types'; +import type { ModelConfigSectionProps } from './ModelConfigSection.types'; const fmtCost = (v?: number | null) => v == null ? '—' : `$${v.toFixed(3)}`; @@ -253,9 +254,10 @@ const CatalogForm = ({ initial, isNew, saving, onSave, onCancel }: CatalogFormPr ); }; -const ModelConfigSection = () => { - const { data: modelsData, isLoading: modelsLoading } = useGetFoxmemoryModelsQuery(); - const { data: catalogData, isLoading: catalogLoading } = useGetFoxmemoryCatalogQuery(); +const ModelConfigSection = ({ agentId }: ModelConfigSectionProps) => { + const effectiveAgentId = agentId ?? undefined; + const { data: modelsData, isLoading: modelsLoading } = useGetFoxmemoryModelsQuery(effectiveAgentId); + const { data: catalogData, isLoading: catalogLoading } = useGetFoxmemoryCatalogQuery(effectiveAgentId); const [setModel] = useSetFoxmemoryModelMutation(); const [revertModel] = useRevertFoxmemoryModelMutation(); const [addCatalog] = useAddCatalogModelMutation(); @@ -270,7 +272,7 @@ const ModelConfigSection = () => { const handleApply = async (key: ModelRoleKey, value: string) => { setSavingKey(key); try { - await setModel({ key, value }).unwrap(); + await setModel({ key, value, agentId: effectiveAgentId }).unwrap(); } finally { setSavingKey(null); } @@ -279,7 +281,7 @@ const ModelConfigSection = () => { const handleRevert = async (key: ModelRoleKey) => { setSavingKey(key); try { - await revertModel(key).unwrap(); + await revertModel({ key, agentId: effectiveAgentId }).unwrap(); } finally { setSavingKey(null); } @@ -297,19 +299,19 @@ const ModelConfigSection = () => { const handleAdd = async (form: CatalogFormData) => { setCatalogSaving(true); - try { await addCatalog(formToCatalog(form) as CatalogModel).unwrap(); setAddingNew(false); } + try { await addCatalog({ ...formToCatalog(form), agentId: effectiveAgentId } as CatalogModel & { agentId?: string }).unwrap(); setAddingNew(false); } finally { setCatalogSaving(false); } }; const handleUpdate = async (form: CatalogFormData) => { setCatalogSaving(true); - try { await updateCatalog(formToCatalog(form) as CatalogModel).unwrap(); setEditingId(null); } + try { await updateCatalog({ ...formToCatalog(form), agentId: effectiveAgentId } as CatalogModel & { agentId?: string }).unwrap(); setEditingId(null); } finally { setCatalogSaving(false); } }; const handleDelete = async (id: string) => { setCatalogSaving(true); - try { await deleteCatalog(id).unwrap(); setDeletingId(null); } + try { await deleteCatalog({ id, agentId: effectiveAgentId }).unwrap(); setDeletingId(null); } finally { setCatalogSaving(false); } }; diff --git a/web/src/components/ModelConfigSection/ModelConfigSection.types.ts b/web/src/components/ModelConfigSection/ModelConfigSection.types.ts index 57a35c2..e850e07 100644 --- a/web/src/components/ModelConfigSection/ModelConfigSection.types.ts +++ b/web/src/components/ModelConfigSection/ModelConfigSection.types.ts @@ -1 +1,3 @@ -export interface ModelConfigSectionProps {} +export interface ModelConfigSectionProps { + agentId?: string | null; +} diff --git a/web/src/components/Sidebar/Sidebar.tsx b/web/src/components/Sidebar/Sidebar.tsx index 048ed03..21c359e 100644 --- a/web/src/components/Sidebar/Sidebar.tsx +++ b/web/src/components/Sidebar/Sidebar.tsx @@ -103,7 +103,7 @@ const NAV_ITEMS = [ { section: 'config' as const, label: 'Model Config', icon: }, ]; -const Sidebar = ({ section, onSectionChange, health }: SidebarProps) => ( +const Sidebar = ({ section, onSectionChange, health, children }: SidebarProps) => ( @@ -115,6 +115,25 @@ const Sidebar = ({ section, onSectionChange, health }: SidebarProps) => ( ))} + {children && ( + + theme.palette.mode === 'dark' ? '#adb5bd' : 'rgba(255,255,255,0.5)', + fontSize: 10, + fontWeight: 700, + textTransform: 'uppercase', + letterSpacing: 0.5, + display: 'block', + mb: 0.75, + }} + > + Agent + + {children} + + )} void; health?: HealthMap; + children?: React.ReactNode; } diff --git a/web/src/services/dashboardApi.ts b/web/src/services/dashboardApi.ts index c77dae7..c110efe 100644 --- a/web/src/services/dashboardApi.ts +++ b/web/src/services/dashboardApi.ts @@ -1,76 +1,87 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; -import type { SessionsResponse, FoxmemoryResponse, FoxmemoryPromptsResponse, FoxmemoryGraphStats, FoxmemoryGraphData, FoxmemoryGraphSearchResult, FoxmemoryMemorySearchResult, KillArgs, KillResponse, DeleteSessionArgs, DeleteSessionResponse, CronJobsResponse, CronRunsResponse, FoxmemoryModelsResponse, FoxmemoryCatalogResponse, ModelRoleKey, CatalogModel } from '../types'; +import type { SessionsResponse, FoxmemoryResponse, FoxmemoryPromptsResponse, FoxmemoryGraphStats, FoxmemoryGraphData, FoxmemoryGraphSearchResult, FoxmemoryMemorySearchResult, KillArgs, KillResponse, DeleteSessionArgs, DeleteSessionResponse, CronJobsResponse, CronRunsResponse, FoxmemoryModelsResponse, FoxmemoryCatalogResponse, ModelRoleKey, CatalogModel, TenantRecord, AgentRecord } from '../types'; + +const withAgent = (path: string, agentId?: string) => + agentId ? `${path}${path.includes('?') ? '&' : '?'}agentId=${agentId}` : path; export const dashboardApi = createApi({ reducerPath: 'dashboardApi', baseQuery: fetchBaseQuery({ baseUrl: '/api' }), - tagTypes: ['Sessions', 'Foxmemory', 'Crons', 'FoxmemoryPrompts', 'FoxmemoryGraph', 'FoxmemoryModels'], + tagTypes: ['Sessions', 'Foxmemory', 'Crons', 'FoxmemoryPrompts', 'FoxmemoryGraph', 'FoxmemoryModels', 'Tenants', 'Agents'], endpoints: (builder) => ({ getSessions: builder.query({ query: () => '/sessions', providesTags: ['Sessions'], }), - getFoxmemoryOverview: builder.query({ - query: () => '/foxmemory/overview', - providesTags: ['Foxmemory'], + getTenants: builder.query<{ ok: boolean; data: TenantRecord[] }, void>({ + query: () => '/tenants', + providesTags: ['Tenants'], + }), + getAgents: builder.query<{ ok: boolean; data: AgentRecord[] }, string>({ + query: (tenantId) => `/tenants/${tenantId}/agents`, + providesTags: (_, __, tenantId) => [{ type: 'Agents' as const, id: tenantId }], + }), + getFoxmemoryOverview: builder.query({ + query: (agentId) => withAgent('/foxmemory/overview', agentId), + providesTags: (_, __, agentId) => [{ type: 'Foxmemory' as const, id: agentId ?? 'GLOBAL' }], }), - getFoxmemoryPrompts: builder.query({ - query: () => '/foxmemory/prompts', - providesTags: ['FoxmemoryPrompts'], + getFoxmemoryPrompts: builder.query({ + query: (agentId) => withAgent('/foxmemory/prompts', agentId), + providesTags: (_, __, agentId) => [{ type: 'FoxmemoryPrompts' as const, id: agentId ?? 'GLOBAL' }], }), - getFoxmemoryGraphStats: builder.query<{ ok: boolean; data: FoxmemoryGraphStats }, void>({ - query: () => '/foxmemory/graph-stats', - providesTags: ['FoxmemoryGraph'], + getFoxmemoryGraphStats: builder.query<{ ok: boolean; data: FoxmemoryGraphStats }, string | undefined>({ + query: (agentId) => withAgent('/foxmemory/graph-stats', agentId), + providesTags: (_, __, agentId) => [{ type: 'FoxmemoryGraph' as const, id: agentId ?? 'GLOBAL' }], }), - getFoxmemoryGraphData: builder.query<{ ok: boolean; data: FoxmemoryGraphData }, void>({ - query: () => '/foxmemory/graph-data', - providesTags: ['FoxmemoryGraph'], + getFoxmemoryGraphData: builder.query<{ ok: boolean; data: FoxmemoryGraphData }, string | undefined>({ + query: (agentId) => withAgent('/foxmemory/graph-data', agentId), + providesTags: (_, __, agentId) => [{ type: 'FoxmemoryGraph' as const, id: agentId ?? 'GLOBAL' }], }), - getFoxmemoryModels: builder.query({ - query: () => '/foxmemory/config/models', - providesTags: ['FoxmemoryModels'], + getFoxmemoryModels: builder.query({ + query: (agentId) => withAgent('/foxmemory/config/models', agentId), + providesTags: (_, __, agentId) => [{ type: 'FoxmemoryModels' as const, id: agentId ?? 'GLOBAL' }], }), - getFoxmemoryCatalog: builder.query({ - query: () => '/foxmemory/config/models/catalog', - providesTags: ['FoxmemoryModels'], + getFoxmemoryCatalog: builder.query({ + query: (agentId) => withAgent('/foxmemory/config/models/catalog', agentId), + providesTags: (_, __, agentId) => [{ type: 'FoxmemoryModels' as const, id: agentId ?? 'GLOBAL' }], }), - setFoxmemoryModel: builder.mutation<{ ok: boolean; data: { key: string; value: string; reloaded: boolean } }, { key: ModelRoleKey; value: string }>({ - query: (body) => ({ url: '/foxmemory/config/model', method: 'PUT', body }), - invalidatesTags: ['FoxmemoryModels'], + setFoxmemoryModel: builder.mutation<{ ok: boolean; data: { key: string; value: string; reloaded: boolean } }, { key: ModelRoleKey; value: string; agentId?: string }>({ + query: ({ key, value, agentId }) => ({ url: withAgent('/foxmemory/config/model', agentId), method: 'PUT', body: { key, value } }), + invalidatesTags: (_, __, { agentId }) => [{ type: 'FoxmemoryModels' as const, id: agentId ?? 'GLOBAL' }], }), - revertFoxmemoryModel: builder.mutation<{ ok: boolean }, ModelRoleKey>({ - query: (key) => ({ url: `/foxmemory/config/model/${key}`, method: 'DELETE' }), - invalidatesTags: ['FoxmemoryModels'], + revertFoxmemoryModel: builder.mutation<{ ok: boolean }, { key: ModelRoleKey; agentId?: string }>({ + query: ({ key, agentId }) => ({ url: withAgent(`/foxmemory/config/model/${key}`, agentId), method: 'DELETE' }), + invalidatesTags: (_, __, { agentId }) => [{ type: 'FoxmemoryModels' as const, id: agentId ?? 'GLOBAL' }], }), - addCatalogModel: builder.mutation<{ ok: boolean; data: { model: CatalogModel } }, Omit>({ - query: (body) => ({ url: '/foxmemory/config/models/catalog', method: 'POST', body }), - invalidatesTags: ['FoxmemoryModels'], + addCatalogModel: builder.mutation<{ ok: boolean; data: { model: CatalogModel } }, Omit & { agentId?: string }>({ + query: ({ agentId, ...body }) => ({ url: withAgent('/foxmemory/config/models/catalog', agentId), method: 'POST', body }), + invalidatesTags: (_, __, { agentId }) => [{ type: 'FoxmemoryModels' as const, id: agentId ?? 'GLOBAL' }], }), - updateCatalogModel: builder.mutation<{ ok: boolean; data: { model: CatalogModel } }, Omit>({ - query: ({ id, ...rest }) => ({ url: `/foxmemory/config/models/catalog/${encodeURIComponent(id)}`, method: 'PUT', body: { id, ...rest } }), - invalidatesTags: ['FoxmemoryModels'], + updateCatalogModel: builder.mutation<{ ok: boolean; data: { model: CatalogModel } }, Omit & { agentId?: string }>({ + query: ({ id, agentId, ...rest }) => ({ url: withAgent(`/foxmemory/config/models/catalog/${encodeURIComponent(id)}`, agentId), method: 'PUT', body: { id, ...rest } }), + invalidatesTags: (_, __, { agentId }) => [{ type: 'FoxmemoryModels' as const, id: agentId ?? 'GLOBAL' }], }), - deleteCatalogModel: builder.mutation<{ ok: boolean; data: { deleted: string } }, string>({ - query: (id) => ({ url: `/foxmemory/config/models/catalog/${encodeURIComponent(id)}`, method: 'DELETE' }), - invalidatesTags: ['FoxmemoryModels'], + deleteCatalogModel: builder.mutation<{ ok: boolean; data: { deleted: string } }, { id: string; agentId?: string }>({ + query: ({ id, agentId }) => ({ url: withAgent(`/foxmemory/config/models/catalog/${encodeURIComponent(id)}`, agentId), method: 'DELETE' }), + invalidatesTags: (_, __, { agentId }) => [{ type: 'FoxmemoryModels' as const, id: agentId ?? 'GLOBAL' }], }), - setFoxmemoryExtractionPrompt: builder.mutation<{ ok: boolean }, { prompt: string | null }>({ - query: (body) => ({ url: '/foxmemory/config/prompt', method: 'PUT', body }), - invalidatesTags: ['FoxmemoryPrompts'], + setFoxmemoryExtractionPrompt: builder.mutation<{ ok: boolean }, { prompt: string | null; agentId?: string }>({ + query: ({ prompt, agentId }) => ({ url: withAgent('/foxmemory/config/prompt', agentId), method: 'PUT', body: { prompt } }), + invalidatesTags: (_, __, { agentId }) => [{ type: 'FoxmemoryPrompts' as const, id: agentId ?? 'GLOBAL' }], }), - setFoxmemoryUpdatePrompt: builder.mutation<{ ok: boolean }, { prompt: string | null }>({ - query: (body) => ({ url: '/foxmemory/config/update-prompt', method: 'PUT', body }), - invalidatesTags: ['FoxmemoryPrompts'], + setFoxmemoryUpdatePrompt: builder.mutation<{ ok: boolean }, { prompt: string | null; agentId?: string }>({ + query: ({ prompt, agentId }) => ({ url: withAgent('/foxmemory/config/update-prompt', agentId), method: 'PUT', body: { prompt } }), + invalidatesTags: (_, __, { agentId }) => [{ type: 'FoxmemoryPrompts' as const, id: agentId ?? 'GLOBAL' }], }), - setFoxmemoryGraphPrompt: builder.mutation<{ ok: boolean }, { prompt: string | null }>({ - query: (body) => ({ url: '/foxmemory/config/graph-prompt', method: 'PUT', body }), - invalidatesTags: ['FoxmemoryPrompts'], + setFoxmemoryGraphPrompt: builder.mutation<{ ok: boolean }, { prompt: string | null; agentId?: string }>({ + query: ({ prompt, agentId }) => ({ url: withAgent('/foxmemory/config/graph-prompt', agentId), method: 'PUT', body: { prompt } }), + invalidatesTags: (_, __, { agentId }) => [{ type: 'FoxmemoryPrompts' as const, id: agentId ?? 'GLOBAL' }], }), - searchFoxmemoryGraph: builder.mutation<{ ok: boolean; data: FoxmemoryGraphSearchResult }, { query: string }>({ - query: (body) => ({ url: '/foxmemory/graph/search', method: 'POST', body }), + searchFoxmemoryGraph: builder.mutation<{ ok: boolean; data: FoxmemoryGraphSearchResult }, { query: string; agentId?: string }>({ + query: ({ query, agentId }) => ({ url: withAgent('/foxmemory/graph/search', agentId), method: 'POST', body: { query } }), }), - searchFoxmemoryMemories: builder.mutation<{ ok: boolean; data: FoxmemoryMemorySearchResult }, { query: string; top_k?: number }>({ - query: (body) => ({ url: '/foxmemory/memories/search', method: 'POST', body }), + searchFoxmemoryMemories: builder.mutation<{ ok: boolean; data: FoxmemoryMemorySearchResult }, { query: string; top_k?: number; agentId?: string }>({ + query: ({ query, top_k, agentId }) => ({ url: withAgent('/foxmemory/memories/search', agentId), method: 'POST', body: { query, top_k } }), }), getCrons: builder.query({ query: () => '/crons', @@ -104,6 +115,8 @@ export const dashboardApi = createApi({ export const { useGetSessionsQuery, + useGetTenantsQuery, + useGetAgentsQuery, useGetFoxmemoryOverviewQuery, useGetFoxmemoryPromptsQuery, useGetFoxmemoryGraphStatsQuery, diff --git a/web/src/types.ts b/web/src/types.ts index 32a9656..bca9be1 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -328,3 +328,21 @@ export interface DeleteSessionResponse { export type SectionHealth = 'ok' | 'error'; export type HealthMap = Partial>; + +export interface TenantRecord { + id: string; + slug: string; + name: string; + created_at: string; +} + +export interface AgentRecord { + id: string; + tenant_id: string; + slug: string; + name: string; + qdrant_collection: string; + neo4j_database: string; + status: 'provisioning' | 'active' | 'deprovisioning' | 'archived'; + created_at: string; +} diff --git a/web/src/uiSlice.ts b/web/src/uiSlice.ts index 546eb81..0adb9f5 100644 --- a/web/src/uiSlice.ts +++ b/web/src/uiSlice.ts @@ -5,6 +5,7 @@ export interface UiState { mode: ThemeMode; section: Section; chartRange: ChartRange; + selectedAgentId: string | null; } const initialState: UiState = { @@ -14,6 +15,7 @@ const initialState: UiState = { : 'light', section: 'foxmemory', chartRange: '7d', + selectedAgentId: null, }; const uiSlice = createSlice({ @@ -32,8 +34,11 @@ const uiSlice = createSlice({ setChartRange(state, action: PayloadAction) { state.chartRange = action.payload; }, + setSelectedAgentId(state, action: PayloadAction) { + state.selectedAgentId = action.payload; + }, }, }); -export const { setMode, toggleMode, setSection, setChartRange } = uiSlice.actions; +export const { setMode, toggleMode, setSection, setChartRange, setSelectedAgentId } = uiSlice.actions; export default uiSlice.reducer;